mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d700f4877f | |||
| c0a083ddaf | |||
| 8e07bfe3c1 | |||
| c7088a8a6e | |||
| efed9e5585 | |||
| 7de10c5808 | |||
| 12eddc2f8c | |||
| 5e85d098d8 | |||
| 99c6b97c96 | |||
| 83dcfb479c | |||
| ed6005f6cf | |||
| 069ec8ecbb |
@@ -51,3 +51,7 @@ pnpm-lock.yaml
|
|||||||
|
|
||||||
# Superpowers local planning artifacts
|
# Superpowers local planning artifacts
|
||||||
docs/superpowers/plans/
|
docs/superpowers/plans/
|
||||||
|
|
||||||
|
# Local dev/admin script inputs
|
||||||
|
scripts/dev/run-migration-roots.local.txt
|
||||||
|
scripts/dev/dry-run-roots.local.txt
|
||||||
|
|||||||
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-04-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Local migration audit log for the `2026-04-28-quality-cleanup` migration:
|
||||||
|
`~/.local/share/opencode-working-memory/migration-logs/2026-04-28-quality-cleanup.jsonl`.
|
||||||
|
- Local extraction rejection log for rejected compaction memory candidates:
|
||||||
|
`~/.local/share/opencode-working-memory/extraction-rejections.jsonl`.
|
||||||
|
- Sanitized real-workspace regression fixtures for memory cleanup migration behavior.
|
||||||
|
- Safe workspace residue cleanup tooling that dry-runs by default and quarantines definite temp/test workspace stores instead of deleting them.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Unified memory quality rules in a shared quality gate for compaction memory candidates and cleanup checks.
|
||||||
|
- Rewritten compaction memory prompt to reduce over-production of low-quality memories.
|
||||||
|
- Changed quality cleanup migration to be conservative: it supersedes only high-confidence garbage patterns, including progress snapshots, raw errors, commit/CI snapshots, temporary status notes, active file snapshots, code/API signatures, path-heavy entries, and empty entries.
|
||||||
|
- Soft heuristic failures (`bad_feedback`, `bad_decision`) are intentionally excluded from automatic migration cleanup to protect durable declarative memories such as branding rules, API facts, release rules, user workflow preferences, and architecture decisions.
|
||||||
|
- Isolated test runs under a temporary `XDG_DATA_HOME` so test workspaces no longer pollute real local workspace memory data.
|
||||||
|
|
||||||
|
### Recovery note
|
||||||
|
|
||||||
|
The cleanup migration changes matching entries to `status: "superseded"`; it does not delete the entry. If a useful memory is superseded, inspect the migration audit log and restore by changing that entry back to `status: "active"` in the workspace's `workspace-memory.json`. The migration runs once per workspace.
|
||||||
|
|
||||||
## [1.3.3] - 2026-04-28
|
## [1.3.3] - 2026-04-28
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -174,6 +174,17 @@ It includes guards for:
|
|||||||
|
|
||||||
The goal is to remember durable facts, not every detail.
|
The goal is to remember durable facts, not every detail.
|
||||||
|
|
||||||
|
Historical cleanup is intentionally conservative: extraction-time filtering may reject more aggressively, but one-time migration cleanup only supersedes high-confidence garbage patterns. This protects existing durable memories written in declarative style, such as "API endpoint is X" or "Product branding is Y".
|
||||||
|
|
||||||
|
For local development cleanup, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run cleanup:workspaces -- --dry-run
|
||||||
|
npm run cleanup:workspaces -- --quarantine
|
||||||
|
```
|
||||||
|
|
||||||
|
The cleanup command only quarantines definite temp/test workspace residues by default. It does not delete unknown missing-root workspaces.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
OpenCode Working Memory works out of the box.
|
OpenCode Working Memory works out of the box.
|
||||||
@@ -210,6 +221,7 @@ cd opencode-working-memory
|
|||||||
npm install
|
npm install
|
||||||
npm test
|
npm test
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
|
npm run cleanup:workspaces -- --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|||||||
@@ -1,5 +1,105 @@
|
|||||||
# Release Notes
|
# Release Notes
|
||||||
|
|
||||||
|
## 1.4.0 (2026-04-28)
|
||||||
|
|
||||||
|
### Memory Quality Cleanup
|
||||||
|
|
||||||
|
This release improves automatic workspace memory quality without risking broad cleanup of useful existing memories.
|
||||||
|
|
||||||
|
The quality gate is now shared across compaction extraction and migration checks, the compaction prompt is stricter about what should become durable memory, and the one-time migration is intentionally conservative.
|
||||||
|
|
||||||
|
### What Changed
|
||||||
|
|
||||||
|
- **Unified quality rules**: memory quality checks now live in one shared module and apply consistently across feedback, decisions, project facts, and references.
|
||||||
|
- **Stricter compaction output**: the compaction prompt now tells the model to save fewer memories and prefer durable facts, user preferences, architecture decisions, and hard-to-rediscover references.
|
||||||
|
- **Conservative migration cleanup**: the `2026-04-28-quality-cleanup` migration only supersedes high-confidence garbage patterns, not every rejected memory.
|
||||||
|
- **Audit logs**: automatic migration cleanup writes local JSONL audit records so superseded entries can be inspected and restored.
|
||||||
|
- **Extraction rejection logs**: newly rejected compaction candidates are logged locally to help calibrate future quality rules.
|
||||||
|
- **Regression coverage**: migration behavior is tested against sanitized real-workspace patterns to prevent mass false positives from coming back.
|
||||||
|
- **Workspace cleanup tooling**: a dev/admin cleanup command can dry-run or quarantine definite temp/test workspace residues without deleting unknown missing-root workspaces.
|
||||||
|
- **Test storage isolation**: test runs now use a temporary `XDG_DATA_HOME`, preventing fixture workspaces from polluting real local memory data.
|
||||||
|
|
||||||
|
### What Gets Cleaned Up
|
||||||
|
|
||||||
|
The migration may supersede existing `source: "compaction"` memories only when they match hard garbage patterns:
|
||||||
|
|
||||||
|
- Empty entries
|
||||||
|
- Progress snapshots, such as "Wave 1 completed successfully"
|
||||||
|
- Test or suite count snapshots, such as "180 tests passed"
|
||||||
|
- Raw errors and stack traces
|
||||||
|
- Commit or CI snapshots
|
||||||
|
- Temporary status notes, such as "Currently running npm test"
|
||||||
|
- Active file snapshots
|
||||||
|
- Code or API signatures
|
||||||
|
- Path-heavy entries that are just rediscoverable file lists
|
||||||
|
|
||||||
|
### What Is Protected
|
||||||
|
|
||||||
|
The migration does not supersede entries whose only issue is a soft heuristic failure, such as:
|
||||||
|
|
||||||
|
- `bad_feedback`
|
||||||
|
- `bad_decision`
|
||||||
|
|
||||||
|
This protects useful declarative memories like:
|
||||||
|
|
||||||
|
- Product branding rules
|
||||||
|
- API facts
|
||||||
|
- Release rules
|
||||||
|
- Architecture decisions
|
||||||
|
- User workflow preferences
|
||||||
|
|
||||||
|
Explicit and manual memories are also protected.
|
||||||
|
|
||||||
|
### Migration Behavior
|
||||||
|
|
||||||
|
- Runs once per workspace.
|
||||||
|
- Only affects active `source: "compaction"` entries.
|
||||||
|
- Marks matching entries as `status: "superseded"` instead of deleting them.
|
||||||
|
- Adds `quality_cleanup` and `quality:<reason>` tags to superseded entries.
|
||||||
|
- Writes audit logs to:
|
||||||
|
`~/.local/share/opencode-working-memory/migration-logs/2026-04-28-quality-cleanup.jsonl`
|
||||||
|
- Writes extraction rejection logs to:
|
||||||
|
`~/.local/share/opencode-working-memory/extraction-rejections.jsonl`
|
||||||
|
|
||||||
|
### Recovery
|
||||||
|
|
||||||
|
If a useful memory is superseded, inspect the migration audit log and restore the entry by changing its status back to `"active"` in the workspace's `workspace-memory.json`.
|
||||||
|
|
||||||
|
### Workspace Residue Cleanup
|
||||||
|
|
||||||
|
If old test/temp workspace stores already exist locally, inspect them first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run cleanup:workspaces -- --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
To move definite temp/test residues into a local quarantine folder instead of deleting them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run cleanup:workspaces -- --quarantine
|
||||||
|
```
|
||||||
|
|
||||||
|
The cleanup command skips existing workspace roots and unknown missing-root workspaces by default.
|
||||||
|
|
||||||
|
### Upgrade Notes
|
||||||
|
|
||||||
|
- No configuration changes required.
|
||||||
|
- Existing workspace memory files remain compatible.
|
||||||
|
- The OpenCode config entry stays the same:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plugin": ["opencode-working-memory"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- `npm test`
|
||||||
|
- `npm run typecheck`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1.3.2 (2026-04-27)
|
## 1.3.2 (2026-04-27)
|
||||||
|
|
||||||
### CI Compatibility Patch
|
### CI Compatibility Patch
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "opencode-working-memory",
|
"name": "opencode-working-memory",
|
||||||
"version": "1.3.3",
|
"version": "1.4.0",
|
||||||
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
|
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
|
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "node --test --experimental-strip-types tests/*.test.ts",
|
"test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts",
|
||||||
|
"cleanup:workspaces": "node --experimental-strip-types scripts/dev/cleanup-workspaces.ts",
|
||||||
"check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test"
|
"check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Safely inspect or quarantine stale test/temp workspace memory stores.
|
||||||
|
*
|
||||||
|
* Default mode is dry-run. Quarantine moves only definite temp/test residues.
|
||||||
|
* Unknown missing roots are reported but skipped unless --include-orphans is set.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { cleanupWorkspaceResidues } from "../../src/workspace-cleanup.ts";
|
||||||
|
|
||||||
|
type CliOptions = {
|
||||||
|
mode: "dry-run" | "quarantine";
|
||||||
|
dataHome?: string;
|
||||||
|
olderThanDays?: number;
|
||||||
|
includeOrphans: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function usage(): string {
|
||||||
|
return `Usage:
|
||||||
|
npm run cleanup:workspaces -- --dry-run
|
||||||
|
npm run cleanup:workspaces -- --quarantine
|
||||||
|
npm run cleanup:workspaces -- --quarantine --older-than-days 1
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--dry-run List candidates without moving anything (default)
|
||||||
|
--quarantine Move definite temp/test residues to quarantine
|
||||||
|
--data-home <path> Override XDG data home for testing/admin work
|
||||||
|
--older-than-days <n> Only consider workspace dirs older than n days
|
||||||
|
--include-orphans Also quarantine missing non-temp roots (off by default)
|
||||||
|
--help Show this help
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): CliOptions {
|
||||||
|
const options: CliOptions = { mode: "dry-run", includeOrphans: false };
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const arg = argv[i];
|
||||||
|
switch (arg) {
|
||||||
|
case "--dry-run":
|
||||||
|
options.mode = "dry-run";
|
||||||
|
break;
|
||||||
|
case "--quarantine":
|
||||||
|
options.mode = "quarantine";
|
||||||
|
break;
|
||||||
|
case "--data-home":
|
||||||
|
options.dataHome = argv[++i];
|
||||||
|
if (!options.dataHome) throw new Error("--data-home requires a path");
|
||||||
|
break;
|
||||||
|
case "--older-than-days": {
|
||||||
|
const value = Number(argv[++i]);
|
||||||
|
if (!Number.isFinite(value) || value < 0) throw new Error("--older-than-days requires a non-negative number");
|
||||||
|
options.olderThanDays = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "--include-orphans":
|
||||||
|
options.includeOrphans = true;
|
||||||
|
break;
|
||||||
|
case "--help":
|
||||||
|
case "-h":
|
||||||
|
console.log(usage());
|
||||||
|
process.exit(0);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown option: ${arg}\n${usage()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
const result = await cleanupWorkspaceResidues({
|
||||||
|
dataHome: options.dataHome,
|
||||||
|
mode: options.mode,
|
||||||
|
includeOrphans: options.includeOrphans,
|
||||||
|
minAgeMs: options.olderThanDays === undefined ? undefined : options.olderThanDays * 24 * 60 * 60 * 1_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Mode: ${result.mode}`);
|
||||||
|
console.log(`Scanned: ${result.results.length}`);
|
||||||
|
console.log(`Candidates: ${result.candidates.length}`);
|
||||||
|
|
||||||
|
if (result.candidates.length > 0) {
|
||||||
|
console.log("\nCandidates:");
|
||||||
|
for (const candidate of result.candidates) {
|
||||||
|
console.log(`- ${candidate.workspaceKey} ${candidate.classification} root=${candidate.root ?? "<missing>"}`);
|
||||||
|
console.log(` reasons=${candidate.reasons.join(",")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.quarantined.length > 0) {
|
||||||
|
console.log(`\nQuarantined: ${result.quarantined.length}`);
|
||||||
|
console.log(`Quarantine dir: ${result.quarantineDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unknownOrphans = result.results.filter(item => item.classification === "orphan_unknown");
|
||||||
|
if (unknownOrphans.length > 0 && !options.includeOrphans) {
|
||||||
|
console.log(`\nUnknown missing-root workspaces skipped: ${unknownOrphans.length}`);
|
||||||
|
console.log("Use --include-orphans only after manually confirming they are safe to quarantine.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Local helper to trigger migration on workspace roots.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* MIGRATION_RUN_ROOTS=/path/a:/path/b bun run scripts/dev/run-migration.ts
|
||||||
|
*
|
||||||
|
* Or create a local file (gitignored):
|
||||||
|
* echo "/path/to/workspace1" > scripts/dev/run-migration-roots.local.txt
|
||||||
|
* echo "/path/to/workspace2" >> scripts/dev/run-migration-roots.local.txt
|
||||||
|
* bun run scripts/dev/run-migration.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { loadWorkspaceMemory } from "../../src/workspace-memory.ts";
|
||||||
|
|
||||||
|
async function getRoots(): Promise<string[]> {
|
||||||
|
// Priority 1: environment variable
|
||||||
|
const envRoots = process.env.MIGRATION_RUN_ROOTS;
|
||||||
|
if (envRoots) {
|
||||||
|
return envRoots.split(":").filter(root => root.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: local file
|
||||||
|
const localFile = join(import.meta.dirname, "run-migration-roots.local.txt");
|
||||||
|
if (existsSync(localFile)) {
|
||||||
|
const content = await readFile(localFile, "utf8");
|
||||||
|
return content.trim().split("\n").filter(root => root.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No roots configured
|
||||||
|
console.log("No workspace roots configured.");
|
||||||
|
console.log("Set MIGRATION_RUN_ROOTS=/path/a:/path/b or create run-migration-roots.local.txt");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots = await getRoots();
|
||||||
|
|
||||||
|
if (roots.length === 0) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const root of roots) {
|
||||||
|
console.log(`Loading workspace memory: ${root}`);
|
||||||
|
const store = await loadWorkspaceMemory(root);
|
||||||
|
const active = store.entries.filter(entry => entry.status !== "superseded").length;
|
||||||
|
const superseded = store.entries.filter(entry => entry.status === "superseded").length;
|
||||||
|
console.log(` active=${active} superseded=${superseded} migrations=${(store.migrations ?? []).join(",")}`);
|
||||||
|
}
|
||||||
+37
-50
@@ -1,6 +1,11 @@
|
|||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
|
import { appendFile, mkdir } from "node:fs/promises";
|
||||||
|
import { dirname } from "node:path";
|
||||||
import type { ActiveFile, LongTermMemoryEntry, LongTermType, OpenError } from "./types.ts";
|
import type { ActiveFile, LongTermMemoryEntry, LongTermType, OpenError } from "./types.ts";
|
||||||
import { LONG_TERM_LIMITS } from "./types.ts";
|
import { LONG_TERM_LIMITS } from "./types.ts";
|
||||||
|
import { assessMemoryQuality } from "./memory-quality.ts";
|
||||||
|
import { extractionRejectionLogPath } from "./paths.ts";
|
||||||
|
import { redactCredentials } from "./redaction.ts";
|
||||||
|
|
||||||
function id(prefix: string): string {
|
function id(prefix: string): string {
|
||||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
@@ -51,7 +56,7 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
|
|||||||
// 韓文(長詞優先):기억해줘/메모해줘 must come before 기억해/메모해
|
// 韓文(長詞優先):기억해줘/메모해줘 must come before 기억해/메모해
|
||||||
/(?:^|\n)\s*(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[::,,]?\s*(.+)$/gim,
|
/(?:^|\n)\s*(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[::,,]?\s*(.+)$/gim,
|
||||||
// 英文:remember this/that - 必須在行首,避免 "to remember" 非指令匹配
|
// 英文:remember this/that - 必須在行首,避免 "to remember" 非指令匹配
|
||||||
/(?:^|\n)\s*(?:please\s+)?remember\s+(?:this|that)?[::,,]?\s*(.+)$/gim,
|
/(?:^|\n)\s*(?:please\s+)?remember(?:\s+(?:this|that))?[::,,]?\s*(.+)$/gim,
|
||||||
// save/add to memory
|
// save/add to memory
|
||||||
/(?:^|\n)\s*(?:please\s+)?(?:save|add)\s+(?:this|that)?\s*(?:to|in)\s+memory[::,,]?\s*(.+)$/gim,
|
/(?:^|\n)\s*(?:please\s+)?(?:save|add)\s+(?:this|that)?\s*(?:to|in)\s+memory[::,,]?\s*(.+)$/gim,
|
||||||
// commit to memory
|
// commit to memory
|
||||||
@@ -199,7 +204,7 @@ function normalizeCandidateBody(body: string): { text: string; hadTrigger: boole
|
|||||||
/(?:请|請)?(?:帮我|幫我)?(?:记住|記住|记得|記得|记下来|記下來)(?:这一点|這一點|这点|這點|这个|這個)?[::,,]?\s*(.+)$/im,
|
/(?:请|請)?(?:帮我|幫我)?(?:记住|記住|记得|記得|记下来|記下來)(?:这一点|這一點|这点|這點|这个|這個)?[::,,]?\s*(.+)$/im,
|
||||||
/(?:覚えておいて|覚えて|忘れないで|メモして)[::,,]?\s*(.+)$/im,
|
/(?:覚えておいて|覚えて|忘れないで|メモして)[::,,]?\s*(.+)$/im,
|
||||||
/(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[::,,]?\s*(.+)$/im,
|
/(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[::,,]?\s*(.+)$/im,
|
||||||
/(?:please\s+)?remember\s+(?:this|that)?[::,,]?\s*(.+)$/im,
|
/(?:please\s+)?remember(?:\s+(?:this|that))?[::,,]?\s*(.+)$/im,
|
||||||
/(?:please\s+)?(?:save|add)\s+(?:this|that)?\s*(?:to|in)\s+memory[::,,]?\s*(.+)$/im,
|
/(?:please\s+)?(?:save|add)\s+(?:this|that)?\s*(?:to|in)\s+memory[::,,]?\s*(.+)$/im,
|
||||||
/(?:please\s+)?commit\s+(?:this|that)?\s*to memory[::,,]?\s*(.+)$/im,
|
/(?:please\s+)?commit\s+(?:this|that)?\s*to memory[::,,]?\s*(.+)$/im,
|
||||||
];
|
];
|
||||||
@@ -223,9 +228,27 @@ function extractFirstPath(text: string): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Quality gate for workspace memory candidates.
|
* Acceptance gate for workspace memory candidates.
|
||||||
* Rejects low-quality entries like git hashes, error messages, etc.
|
* Keeps extraction-specific checks local and delegates memory quality rules to memory-quality.ts.
|
||||||
*/
|
*/
|
||||||
|
type ExtractionRejectionLogEntry = {
|
||||||
|
timestamp: string;
|
||||||
|
type: LongTermType;
|
||||||
|
text: string;
|
||||||
|
reasons: string[];
|
||||||
|
source: "compaction";
|
||||||
|
};
|
||||||
|
|
||||||
|
async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promise<void> {
|
||||||
|
try {
|
||||||
|
const path = extractionRejectionLogPath();
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await appendFile(path, JSON.stringify(entry) + "\n", "utf8");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[memory] failed to write extraction rejection log:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function shouldAcceptWorkspaceMemoryCandidate(
|
function shouldAcceptWorkspaceMemoryCandidate(
|
||||||
entry: {
|
entry: {
|
||||||
type: LongTermType;
|
type: LongTermType;
|
||||||
@@ -245,63 +268,27 @@ function shouldAcceptWorkspaceMemoryCandidate(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Git history / commit hash
|
|
||||||
if (/\b[0-9a-f]{7,40}\b/.test(text)) return false;
|
|
||||||
if (/^(fix|feat|chore|docs|refactor|test):/i.test(text)) return false;
|
|
||||||
|
|
||||||
// Raw error / stack trace
|
|
||||||
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError):/i.test(text)) return false;
|
|
||||||
if (/at \S+ \([^)]+:\d+:\d+\)/.test(text)) return false;
|
|
||||||
|
|
||||||
// Active file list
|
|
||||||
if (/^(modified|created|deleted|renamed)\s+\S+\.\S+$/i.test(text)) return false;
|
|
||||||
|
|
||||||
// Temporary progress
|
|
||||||
if (/^(currently|now|pending|in progress|todo|wip):/i.test(text)) return false;
|
|
||||||
|
|
||||||
// Code signature / API doc
|
|
||||||
if (/^(function|class|interface|type|const|let|var)\s+\w+/.test(text)) return false;
|
|
||||||
if (/^(GET|POST|PUT|DELETE|PATCH)\s+\//.test(text)) return false;
|
|
||||||
|
|
||||||
// Indirect Prompt Injection / Adversarial Instructions
|
// Indirect Prompt Injection / Adversarial Instructions
|
||||||
// Rejects attempts to overwrite system behavior or "ignore" rules.
|
// Rejects attempts to overwrite system behavior or "ignore" rules.
|
||||||
// comparative "instead of" is allowed.
|
// comparative "instead of" is allowed.
|
||||||
if (/\b(ignore\s+all|ignore\s+previous|ignore\s+instruction|overwrite\s+system|overwrite\s+rules|forget\s+all|delete\s+root)\b/i.test(text)) return false;
|
if (/\b(ignore\s+all|ignore\s+previous|ignore\s+instruction|overwrite\s+system|overwrite\s+rules|forget\s+all|delete\s+root)\b/i.test(text)) return false;
|
||||||
if (/\b(ignore|instruction|overwrite)\b/i.test(text) && /\b(previous|all|rules|behavior|prompt|system)\b/i.test(text)) return false;
|
if (/\b(ignore|instruction|overwrite)\b/i.test(text) && /\b(previous|all|rules|behavior|prompt|system)\b/i.test(text)) return false;
|
||||||
|
|
||||||
// Path-heavy facts (rediscoverable from repo)
|
const quality = assessMemoryQuality({ type: entry.type, text, source: "compaction" });
|
||||||
const pathCount = (text.match(/\/[\w.-]+(\/[\w.-]+)+/g) || []).length;
|
if (!quality.accepted) {
|
||||||
if (pathCount > 2) return false;
|
void logExtractionRejection({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
// Session-specific progress snapshots for project type
|
type: entry.type,
|
||||||
if (entry.type === "project") {
|
text: redactCredentials(text),
|
||||||
if (isProjectSnapshotViolation(text)) return false;
|
reasons: quality.reasons,
|
||||||
|
source: "compaction",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isProjectSnapshotViolation(text: string): boolean {
|
|
||||||
// Test/suite counts
|
|
||||||
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
|
|
||||||
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
|
|
||||||
|
|
||||||
// File counts with snapshot/process context only, not static limits
|
|
||||||
if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) {
|
|
||||||
const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text);
|
|
||||||
const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text);
|
|
||||||
if (hasSnapshotContext && !hasLimitContext) return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase/Wave/Sprint/Milestone/Task progress
|
|
||||||
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) {
|
|
||||||
if (/completed|done|finished|完成/i.test(text)) return true;
|
|
||||||
}
|
|
||||||
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract candidate block from summary using multiple formats.
|
* Extract candidate block from summary using multiple formats.
|
||||||
* Supports: Plain text label, Markdown section, legacy XML.
|
* Supports: Plain text label, Markdown section, legacy XML.
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { LongTermMemoryEntry, LongTermSource } from "./types.ts";
|
||||||
|
|
||||||
|
export type MemoryQualityInput = Pick<LongTermMemoryEntry, "type" | "text"> & {
|
||||||
|
source?: LongTermSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MemoryQualityResult = {
|
||||||
|
accepted: boolean;
|
||||||
|
reasons: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HARD_QUALITY_REASONS: ReadonlySet<string> = new Set([
|
||||||
|
"empty",
|
||||||
|
"progress_snapshot",
|
||||||
|
"raw_error",
|
||||||
|
"commit_or_ci_snapshot",
|
||||||
|
"temporary_status",
|
||||||
|
"active_file_snapshot",
|
||||||
|
"code_or_api_signature",
|
||||||
|
"path_heavy",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isHardQualityReason(reason: string): boolean {
|
||||||
|
return HARD_QUALITY_REASONS.has(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assessMemoryQuality(entry: MemoryQualityInput): MemoryQualityResult {
|
||||||
|
const reasons: string[] = [];
|
||||||
|
const text = entry.text.trim();
|
||||||
|
|
||||||
|
if (text.length === 0) reasons.push("empty");
|
||||||
|
if (isProgressSnapshotViolation(text)) reasons.push("progress_snapshot");
|
||||||
|
if (isRawErrorViolation(text)) reasons.push("raw_error");
|
||||||
|
if (isCommitOrCiViolation(text)) reasons.push("commit_or_ci_snapshot");
|
||||||
|
if (isPathHeavyViolation(text)) reasons.push("path_heavy");
|
||||||
|
if (isTemporaryStatusViolation(text)) reasons.push("temporary_status");
|
||||||
|
if (isActiveFileSnapshotViolation(text)) reasons.push("active_file_snapshot");
|
||||||
|
if (isCodeOrApiSignatureViolation(text)) reasons.push("code_or_api_signature");
|
||||||
|
if (entry.type === "feedback" && isFeedbackQualityViolation(text)) reasons.push("bad_feedback");
|
||||||
|
if (entry.type === "decision" && isDecisionQualityViolation(text)) reasons.push("bad_decision");
|
||||||
|
|
||||||
|
return { accepted: reasons.length === 0, reasons };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isProgressSnapshotViolation(text: string): boolean {
|
||||||
|
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
|
||||||
|
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
|
||||||
|
|
||||||
|
if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) {
|
||||||
|
const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text);
|
||||||
|
const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text);
|
||||||
|
if (hasSnapshotContext && !hasLimitContext) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\b(?:completed|done|finished|implemented|added|updated|fixed|reviewed|passed|modified)\b/i.test(text)) {
|
||||||
|
if (/\b(?:wave|phase|task|plan|pr|commit|ci|test|suite|implementation|session|change|fix|review|file)\b/i.test(text)) return true;
|
||||||
|
}
|
||||||
|
if (/(?:已完成|完成|修復|实现|實作).{0,40}(?:wave|phase|task|plan|PR|測試|测试|實作|实现|修復)/iu.test(text)) return true;
|
||||||
|
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) {
|
||||||
|
if (/completed|done|finished|完成|已完成/i.test(text)) return true;
|
||||||
|
}
|
||||||
|
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
|
||||||
|
if (/\b(?:currently|right now|latest change|previous session|last wave|next step)\b/i.test(text)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFeedbackQualityViolation(text: string): boolean {
|
||||||
|
const stablePreference = /\b(?:user|the user)\s+(?:prefers|wants|asked|expects|requires|likes|dislikes)\b/i.test(text)
|
||||||
|
|| /\b(?:prefer|preference|going forward|from now on|always|never)\b/i.test(text)
|
||||||
|
|| /(?:使用者|用戶|用户).{0,12}(?:偏好|希望|要求|想要)/u.test(text)
|
||||||
|
|| /(?:以後|以后|請|请).{0,20}(?:使用|回答|保持|避免)/u.test(text);
|
||||||
|
|
||||||
|
if (stablePreference) return false;
|
||||||
|
|
||||||
|
const internalNote = /\b(?:implemented|updated|fixed|reviewed|added|changed|modified|created|writes|wrote)\b/i.test(text);
|
||||||
|
if (internalNote) return true;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDecisionQualityViolation(text: string): boolean {
|
||||||
|
const futureRule = /\b(?:use|keep|prefer|avoid|do not|don't|must|should|never|always|require|choose|reject)\b/i.test(text)
|
||||||
|
|| /(?:使用|保持|避免|不要|必須|必须|應該|应该|選擇|选择)/u.test(text);
|
||||||
|
if (!futureRule) return true;
|
||||||
|
if (/\b(?:implemented|added|updated|fixed|completed|reviewed)\b/i.test(text)) return true;
|
||||||
|
if (/\b(?:was|were|has been|had been)\b/i.test(text) && /\b(?:previous|last|latest|this session|this wave|already)\b/i.test(text)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRawErrorViolation(text: string): boolean {
|
||||||
|
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError|Exception):/i.test(text)) return true;
|
||||||
|
if (/at \S+ \([^)]+:\d+:\d+\)/.test(text)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCommitOrCiViolation(text: string): boolean {
|
||||||
|
if (/^(fix|feat|chore|docs|refactor|test):/i.test(text)) return true;
|
||||||
|
if (/\b[0-9a-f]{7,40}\b/.test(text)) return true;
|
||||||
|
if (/\bCI\b.*\b(?:passed|failed|run|compatibility|flaky)\b/i.test(text)) return true;
|
||||||
|
if (/\b(?:passed|failed|run|compatibility|flaky)\b.*\bCI\b/i.test(text)) return true;
|
||||||
|
if (/\bcompatibility\s+run\s+\d+/i.test(text)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathHeavyViolation(text: string): boolean {
|
||||||
|
const pathCount = (text.match(/\/[\w.-]+(?:\/[\w.-]+)+/g) || []).length;
|
||||||
|
return pathCount > 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTemporaryStatusViolation(text: string): boolean {
|
||||||
|
if (/^(currently|now|pending|in progress|todo|wip)\b/i.test(text)) return true;
|
||||||
|
if (/\b(?:run npm test|tests? are running|next reply|before continuing)\b/i.test(text)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActiveFileSnapshotViolation(text: string): boolean {
|
||||||
|
return /^(modified|created|deleted|renamed)\s+\S+\.\S+$/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCodeOrApiSignatureViolation(text: string): boolean {
|
||||||
|
if (/^(function|class|interface|type|const|let|var)\s+\w+/.test(text)) return true;
|
||||||
|
if (/^(GET|POST|PUT|DELETE|PATCH)\s+\//.test(text)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -28,3 +28,11 @@ export async function sessionStatePath(root: string, sessionID: string): Promise
|
|||||||
const safeSessionID = createHash("sha256").update(sessionID).digest("hex").slice(0, 32);
|
const safeSessionID = createHash("sha256").update(sessionID).digest("hex").slice(0, 32);
|
||||||
return join(await memoryRoot(root), "sessions", `${safeSessionID}.json`);
|
return join(await memoryRoot(root), "sessions", `${safeSessionID}.json`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function migrationLogPath(migrationId: string): string {
|
||||||
|
return join(dataHome(), "opencode-working-memory", "migration-logs", `${migrationId}.jsonl`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractionRejectionLogPath(): string {
|
||||||
|
return join(dataHome(), "opencode-working-memory", "extraction-rejections.jsonl");
|
||||||
|
}
|
||||||
|
|||||||
+21
-29
@@ -108,45 +108,37 @@ function buildCompactionPrompt(privateContext: string): string {
|
|||||||
"",
|
"",
|
||||||
"## Relevant Files",
|
"## Relevant Files",
|
||||||
"",
|
"",
|
||||||
"At the end of the summary, extract durable memory entries for future sessions.",
|
"At the end of the summary, include a Memory candidates section only if there are durable facts that will change future behavior.",
|
||||||
"",
|
"",
|
||||||
"Memory quality bar:",
|
"CRITICAL MEMORY RULES:",
|
||||||
"Extract only durable facts that will change future behavior: user preferences, decisions with rationale, stable constraints, or hard-to-rediscover references.",
|
"- Most compactions should produce ZERO memories. Empty is correct when nothing durable changed.",
|
||||||
"",
|
"- NO completion or progress statements: do not extract completed work, passing tests, commits, PR status, wave/task/phase completion, or current state.",
|
||||||
"Do not extract trivia: transient IDs/revisions, task progress, test/file counts, bare status updates, local UI details, or facts easily rediscovered from the repo.",
|
"- NO session-internal implementation notes: do not extract what files were edited, what bug was just fixed, what command just ran, or what the assistant reviewed.",
|
||||||
"",
|
"- feedback ONLY means stable user preferences or user instructions, written in imperative/future-facing form.",
|
||||||
"When unsure, skip it. Fewer high-signal memories are better than many low-value ones.",
|
"- decision ONLY means rules that apply to FUTURE work, not decisions already implemented in this session.",
|
||||||
|
"- project/reference ONLY when the fact is stable across sessions and hard to rediscover from the repository.",
|
||||||
|
"- If unsure, skip it.",
|
||||||
"",
|
"",
|
||||||
"Good memory examples:",
|
"Good memory examples:",
|
||||||
"- [feedback] User prefers architecture reviews in Traditional Chinese.",
|
"- [feedback] User prefers architecture reviews in Traditional Chinese.",
|
||||||
"- [decision] Use frozen workspace memory snapshots plus ephemeral hot state for cache stability.",
|
"- [decision] Do not add semantic merge to memory dedupe.",
|
||||||
"- [project] The plugin should piggyback memory extraction on OpenCode compaction and avoid extra LLM calls.",
|
"- [project] This repository is an OpenCode plugin using local JSON stores.",
|
||||||
"- [reference] Workspace memory appears in frozen system[1]; pending memories appear in hot session state until compaction.",
|
"- [reference] Workspace memory is rendered as frozen system[1]; pending memories remain in hot state until compaction.",
|
||||||
"",
|
"",
|
||||||
"Bad memory examples to skip:",
|
"Bad memory examples to skip:",
|
||||||
"- 42 tests passed.",
|
|
||||||
"- Wave 2 completed successfully.",
|
"- Wave 2 completed successfully.",
|
||||||
"- Modified 5 files.",
|
"- 180 tests passed and CI is green.",
|
||||||
"- commit 4309cb8 contains the latest fix.",
|
"- Implemented owner-aware cleanup in plugin.ts.",
|
||||||
"- TypeError: Cannot read properties of undefined.",
|
"- The assistant reviewed code reviewer feedback and updated the plan.",
|
||||||
"- Currently running npm test.",
|
"- Commit a762e86 contains the owner scope fix.",
|
||||||
"",
|
|
||||||
"A memory should still be useful if a new agent opens this workspace next week.",
|
|
||||||
"",
|
|
||||||
"Only extract facts that are likely to stay true across sessions.",
|
|
||||||
"Do not extract session-specific progress like exact test counts, file counts, or phase numbers.",
|
|
||||||
"For progress, extract the stable goal or durable milestone, not the current number.",
|
|
||||||
"For references, extract configuration values that do not usually change between sessions.",
|
|
||||||
"For feedback, extract unresolved issues or user preferences that future sessions need to know.",
|
|
||||||
"Use exactly this candidate format, including square brackets around the type:",
|
|
||||||
"",
|
"",
|
||||||
|
"Format when there ARE durable memories:",
|
||||||
"Memory candidates:",
|
"Memory candidates:",
|
||||||
"- [feedback] content",
|
"- [feedback|decision|project|reference] future-facing durable fact",
|
||||||
"- [project] content",
|
|
||||||
"- [decision] content",
|
|
||||||
"- [reference] content",
|
|
||||||
"",
|
"",
|
||||||
"Do not write '- project content'; write '- [project] content'.",
|
"Format when there are NO durable memories:",
|
||||||
|
"Memory candidates:",
|
||||||
|
"(none)",
|
||||||
"",
|
"",
|
||||||
"Background context, use this to inform the summary above.",
|
"Background context, use this to inform the summary above.",
|
||||||
"Do not output this context verbatim:",
|
"Do not output this context verbatim:",
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Shared redaction utilities for sensitive credential patterns.
|
||||||
|
* Used by both workspace memory normalization and extraction rejection logging.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Password labels in multiple languages
|
||||||
|
const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i;
|
||||||
|
|
||||||
|
// Username labels in multiple languages
|
||||||
|
const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i;
|
||||||
|
|
||||||
|
// Sensitive key labels
|
||||||
|
const SENSITIVE_LABELS = /api[_-]?key|token|bearer|secret|credential|auth|auth[_-]?key|private[_-]?key/i;
|
||||||
|
|
||||||
|
// Secret value pattern (excludes common delimiters and brackets)
|
||||||
|
const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s\[]+`;
|
||||||
|
|
||||||
|
// Prefix patterns for different credential types
|
||||||
|
const PIN_PREFIX = String.raw`(\bPIN\b(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||||
|
const PASSWORD_PREFIX = String.raw`((?:${PASSWORD_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||||
|
const USERNAME_PREFIX = String.raw`((?:${USERNAME_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||||
|
const SENSITIVE_PREFIX = String.raw`((?:${SENSITIVE_LABELS.source})(?:\s*(?:推|是|=|:|:)\s*|[::]\s*))`;
|
||||||
|
const BEARER_PREFIX = String.raw`(Bearer\s+)`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redacts sensitive credentials from text.
|
||||||
|
* Handles:
|
||||||
|
* - PINs in multiple formats
|
||||||
|
* - Username/password pairs
|
||||||
|
* - Standalone passwords
|
||||||
|
* - Bearer tokens
|
||||||
|
* - API keys, secrets, credentials, auth tokens, private keys
|
||||||
|
*
|
||||||
|
* Supports multiple languages and delimiters (ASCII and CJK).
|
||||||
|
*/
|
||||||
|
export function redactCredentials(text: string): string {
|
||||||
|
let result = text;
|
||||||
|
|
||||||
|
// 1. PIN
|
||||||
|
result = result.replace(
|
||||||
|
new RegExp(String.raw`${PIN_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||||
|
"$1[REDACTED]",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Username+password pair
|
||||||
|
result = result.replace(
|
||||||
|
new RegExp(
|
||||||
|
String.raw`${USERNAME_PREFIX}[\`'"]?(${SECRET_VALUE})((?:,|,)\s*)${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`,
|
||||||
|
"gi",
|
||||||
|
),
|
||||||
|
"$1[REDACTED]$3$4[REDACTED]",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Standalone password
|
||||||
|
result = result.replace(
|
||||||
|
new RegExp(String.raw`${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||||
|
"$1[REDACTED]",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Bearer tokens (but not "bearer token:" labels)
|
||||||
|
result = result.replace(
|
||||||
|
new RegExp(String.raw`${BEARER_PREFIX}(?!token\s*[:=:])[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||||
|
"$1[REDACTED]",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Sensitive keys/tokens
|
||||||
|
result = result.replace(
|
||||||
|
new RegExp(String.raw`${SENSITIVE_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||||
|
"$1[REDACTED]",
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { appendFile, mkdir, readFile, readdir, rename, stat } from "node:fs/promises";
|
||||||
|
import { basename, dirname, join, resolve } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dataHome as defaultDataHome } from "./paths.ts";
|
||||||
|
|
||||||
|
export type WorkspaceCleanupClassification =
|
||||||
|
| "test_temp_definite"
|
||||||
|
| "orphan_unknown"
|
||||||
|
| "live_or_existing"
|
||||||
|
| "invalid_or_unreadable";
|
||||||
|
|
||||||
|
export type WorkspaceCleanupResult = {
|
||||||
|
workspaceKey: string;
|
||||||
|
workspaceDir: string;
|
||||||
|
root?: string;
|
||||||
|
rootExists: boolean;
|
||||||
|
classification: WorkspaceCleanupClassification;
|
||||||
|
reasons: string[];
|
||||||
|
entryCount?: number;
|
||||||
|
migrations?: string[];
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceCleanupScanOptions = {
|
||||||
|
dataHome?: string;
|
||||||
|
nowMs?: number;
|
||||||
|
minAgeMs?: number;
|
||||||
|
includeOrphans?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceCleanupScan = {
|
||||||
|
results: WorkspaceCleanupResult[];
|
||||||
|
candidates: WorkspaceCleanupResult[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceCleanupMode = "dry-run" | "quarantine";
|
||||||
|
|
||||||
|
export type WorkspaceCleanupOptions = WorkspaceCleanupScanOptions & {
|
||||||
|
mode?: WorkspaceCleanupMode;
|
||||||
|
now?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceCleanupQuarantineEvent = WorkspaceCleanupResult & {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
quarantinedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceCleanupRunResult = WorkspaceCleanupScan & {
|
||||||
|
mode: WorkspaceCleanupMode;
|
||||||
|
quarantined: WorkspaceCleanupQuarantineEvent[];
|
||||||
|
quarantineDir?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkspaceMemoryShape = {
|
||||||
|
workspace?: {
|
||||||
|
root?: unknown;
|
||||||
|
key?: unknown;
|
||||||
|
};
|
||||||
|
entries?: unknown[];
|
||||||
|
migrations?: unknown[];
|
||||||
|
updatedAt?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_MIN_AGE_MS = 10 * 60 * 1_000;
|
||||||
|
|
||||||
|
const KNOWN_TEST_ROOT_PREFIXES = [
|
||||||
|
"memory-plugin-test-",
|
||||||
|
"memory-plugin-prompt-",
|
||||||
|
"wm-",
|
||||||
|
"wm-quality-",
|
||||||
|
"wm-accounting-",
|
||||||
|
"wm-redact-",
|
||||||
|
"wm-normalized-",
|
||||||
|
"wm-ordering-",
|
||||||
|
"wm-extraction-",
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizePathForComparison(path: string): string {
|
||||||
|
return resolve(path).replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsidePath(path: string, parent: string): boolean {
|
||||||
|
const normalizedPath = normalizePathForComparison(path);
|
||||||
|
const normalizedParent = normalizePathForComparison(parent);
|
||||||
|
return normalizedPath === normalizedParent || normalizedPath.startsWith(`${normalizedParent}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTempRoot(root: string, osTmpdir = tmpdir()): boolean {
|
||||||
|
const normalized = normalizePathForComparison(root);
|
||||||
|
const normalizedTmp = normalizePathForComparison(osTmpdir);
|
||||||
|
|
||||||
|
if (isInsidePath(normalized, normalizedTmp)) return true;
|
||||||
|
if (isInsidePath(normalized, "/tmp")) return true;
|
||||||
|
if (isInsidePath(normalized, "/private/tmp")) return true;
|
||||||
|
|
||||||
|
return /^\/(?:private\/)?var\/folders\/[^/]+\/[^/]+\/T(?:\/|$)/.test(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKnownTestWorkspaceRoot(root: string): boolean {
|
||||||
|
const name = basename(root);
|
||||||
|
return KNOWN_TEST_ROOT_PREFIXES.some(prefix => name.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyCandidate(result: WorkspaceCleanupResult, includeOrphans: boolean): boolean {
|
||||||
|
if (result.reasons.includes("recent_workspace_dir")) return false;
|
||||||
|
if (result.reasons.includes("lock_present")) return false;
|
||||||
|
if (result.classification === "test_temp_definite") return true;
|
||||||
|
return includeOrphans && result.classification === "orphan_unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function classifyWorkspaceDir(
|
||||||
|
workspaceDir: string,
|
||||||
|
options: { nowMs?: number; minAgeMs?: number } = {},
|
||||||
|
): Promise<WorkspaceCleanupResult> {
|
||||||
|
const workspaceKey = basename(workspaceDir);
|
||||||
|
const reasons: string[] = [];
|
||||||
|
const memoryPath = join(workspaceDir, "workspace-memory.json");
|
||||||
|
|
||||||
|
if (existsSync(`${memoryPath}.lock`)) {
|
||||||
|
reasons.push("lock_present");
|
||||||
|
}
|
||||||
|
|
||||||
|
let stats;
|
||||||
|
try {
|
||||||
|
stats = await stat(workspaceDir);
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
workspaceKey,
|
||||||
|
workspaceDir,
|
||||||
|
rootExists: false,
|
||||||
|
classification: "invalid_or_unreadable",
|
||||||
|
reasons: ["workspace_dir_unreadable"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const minAgeMs = options.minAgeMs ?? DEFAULT_MIN_AGE_MS;
|
||||||
|
const nowMs = options.nowMs ?? Date.now();
|
||||||
|
if (minAgeMs > 0 && nowMs - stats.mtimeMs < minAgeMs) {
|
||||||
|
reasons.push("recent_workspace_dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
let store: WorkspaceMemoryShape;
|
||||||
|
try {
|
||||||
|
store = JSON.parse(await readFile(memoryPath, "utf8")) as WorkspaceMemoryShape;
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
workspaceKey,
|
||||||
|
workspaceDir,
|
||||||
|
rootExists: false,
|
||||||
|
classification: "invalid_or_unreadable",
|
||||||
|
reasons: [...reasons, "invalid_json"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = typeof store.workspace?.root === "string" ? store.workspace.root : undefined;
|
||||||
|
const key = typeof store.workspace?.key === "string" ? store.workspace.key : workspaceKey;
|
||||||
|
const entryCount = Array.isArray(store.entries) ? store.entries.length : undefined;
|
||||||
|
const migrations = Array.isArray(store.migrations) ? store.migrations.filter((item): item is string => typeof item === "string") : undefined;
|
||||||
|
const updatedAt = typeof store.updatedAt === "string" ? store.updatedAt : undefined;
|
||||||
|
|
||||||
|
if (!root) {
|
||||||
|
return {
|
||||||
|
workspaceKey: key,
|
||||||
|
workspaceDir,
|
||||||
|
rootExists: false,
|
||||||
|
classification: "invalid_or_unreadable",
|
||||||
|
reasons: [...reasons, "missing_workspace_root"],
|
||||||
|
entryCount,
|
||||||
|
migrations,
|
||||||
|
updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootExists = existsSync(root);
|
||||||
|
if (rootExists) {
|
||||||
|
return {
|
||||||
|
workspaceKey: key,
|
||||||
|
workspaceDir,
|
||||||
|
root,
|
||||||
|
rootExists,
|
||||||
|
classification: "live_or_existing",
|
||||||
|
reasons: [...reasons, "root_exists"],
|
||||||
|
entryCount,
|
||||||
|
migrations,
|
||||||
|
updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
reasons.push("root_missing");
|
||||||
|
const tempRoot = isTempRoot(root);
|
||||||
|
const testRoot = isKnownTestWorkspaceRoot(root);
|
||||||
|
if (tempRoot) reasons.push("root_under_temp");
|
||||||
|
if (testRoot) reasons.push(`test_prefix_${KNOWN_TEST_ROOT_PREFIXES.find(prefix => basename(root).startsWith(prefix))?.replace(/-$/, "") ?? basename(root)}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspaceKey: key,
|
||||||
|
workspaceDir,
|
||||||
|
root,
|
||||||
|
rootExists,
|
||||||
|
classification: tempRoot || testRoot ? "test_temp_definite" : "orphan_unknown",
|
||||||
|
reasons,
|
||||||
|
entryCount,
|
||||||
|
migrations,
|
||||||
|
updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspacesDir(dataHome: string): string {
|
||||||
|
return join(dataHome, "opencode-working-memory", "workspaces");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanWorkspaceResidues(options: WorkspaceCleanupScanOptions = {}): Promise<WorkspaceCleanupScan> {
|
||||||
|
const root = workspacesDir(options.dataHome ?? defaultDataHome());
|
||||||
|
const results: WorkspaceCleanupResult[] = [];
|
||||||
|
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = await readdir(root);
|
||||||
|
} catch {
|
||||||
|
return { results, candidates: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const workspaceDir = join(root, entry);
|
||||||
|
const stats = await stat(workspaceDir).catch(() => undefined);
|
||||||
|
if (!stats?.isDirectory()) continue;
|
||||||
|
|
||||||
|
results.push(await classifyWorkspaceDir(workspaceDir, {
|
||||||
|
nowMs: options.nowMs,
|
||||||
|
minAgeMs: options.minAgeMs,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
results,
|
||||||
|
candidates: results.filter(result => classifyCandidate(result, options.includeOrphans ?? false)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function quarantineName(now: Date): string {
|
||||||
|
return `workspace-cleanup-${now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupWorkspaceResidues(options: WorkspaceCleanupOptions = {}): Promise<WorkspaceCleanupRunResult> {
|
||||||
|
const mode = options.mode ?? "dry-run";
|
||||||
|
const now = options.now ?? new Date();
|
||||||
|
const scan = await scanWorkspaceResidues({
|
||||||
|
dataHome: options.dataHome,
|
||||||
|
nowMs: options.nowMs,
|
||||||
|
minAgeMs: options.minAgeMs,
|
||||||
|
includeOrphans: options.includeOrphans,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mode === "dry-run" || scan.candidates.length === 0) {
|
||||||
|
return { ...scan, mode, quarantined: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataHome = options.dataHome ?? defaultDataHome();
|
||||||
|
const quarantineDir = join(dataHome, "opencode-working-memory", "quarantine", quarantineName(now));
|
||||||
|
const quarantined: WorkspaceCleanupQuarantineEvent[] = [];
|
||||||
|
|
||||||
|
for (const candidate of scan.candidates) {
|
||||||
|
const destination = join(quarantineDir, "workspaces", candidate.workspaceKey);
|
||||||
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
|
await rename(candidate.workspaceDir, destination);
|
||||||
|
|
||||||
|
const event: WorkspaceCleanupQuarantineEvent = {
|
||||||
|
...candidate,
|
||||||
|
from: candidate.workspaceDir,
|
||||||
|
to: destination,
|
||||||
|
quarantinedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
quarantined.push(event);
|
||||||
|
|
||||||
|
await mkdir(quarantineDir, { recursive: true });
|
||||||
|
await appendFile(join(quarantineDir, "manifest.jsonl"), JSON.stringify(event) + "\n", "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...scan, mode, quarantined, quarantineDir };
|
||||||
|
}
|
||||||
+111
-77
@@ -1,23 +1,16 @@
|
|||||||
|
import { appendFile, mkdir } from "node:fs/promises";
|
||||||
|
import { dirname } from "node:path";
|
||||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
|
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
|
||||||
import { LONG_TERM_LIMITS } from "./types.ts";
|
import { LONG_TERM_LIMITS } from "./types.ts";
|
||||||
import { workspaceKey, workspaceMemoryPath } from "./paths.ts";
|
import { migrationLogPath, workspaceKey, workspaceMemoryPath } from "./paths.ts";
|
||||||
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||||
|
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts";
|
||||||
|
import { redactCredentials } from "./redaction.ts";
|
||||||
|
|
||||||
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
|
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
|
||||||
const MIN_ENVELOPE_LENGTH = 80;
|
const MIN_ENVELOPE_LENGTH = 80;
|
||||||
const MIGRATION_ID = "2026-04-26-p0-cleanup";
|
const MIGRATION_ID = "2026-04-26-p0-cleanup";
|
||||||
|
const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup";
|
||||||
const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s\[]+`;
|
|
||||||
|
|
||||||
const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i;
|
|
||||||
const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i;
|
|
||||||
const SENSITIVE_LABELS = /api[_-]?key|token|bearer|secret|credential|auth|auth[_-]?key|private[_-]?key/i;
|
|
||||||
|
|
||||||
const PIN_PREFIX = String.raw`(\bPIN\b(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
|
||||||
const PASSWORD_PREFIX = String.raw`((?:${PASSWORD_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
|
||||||
const USERNAME_PREFIX = String.raw`((?:${USERNAME_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
|
||||||
const SENSITIVE_PREFIX = String.raw`((?:${SENSITIVE_LABELS.source})(?:\s*(?:推|是|=|:|:)\s*|[::]\s*))`;
|
|
||||||
const BEARER_PREFIX = String.raw`(Bearer\s+)`;
|
|
||||||
|
|
||||||
export type MemoryConsolidationReason =
|
export type MemoryConsolidationReason =
|
||||||
| "promoted"
|
| "promoted"
|
||||||
@@ -48,6 +41,21 @@ export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & {
|
|||||||
events: MemoryConsolidationEvent[];
|
events: MemoryConsolidationEvent[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QualityCleanupMigrationLogEntry = {
|
||||||
|
migrationId: string;
|
||||||
|
timestamp: string;
|
||||||
|
workspaceKey: string;
|
||||||
|
workspaceRoot: string;
|
||||||
|
entryId: string;
|
||||||
|
type: LongTermMemoryEntry["type"];
|
||||||
|
source: LongTermMemoryEntry["source"];
|
||||||
|
text: string;
|
||||||
|
reasons: string[];
|
||||||
|
hardReasons: string[];
|
||||||
|
beforeStatus: "active";
|
||||||
|
afterStatus: "superseded";
|
||||||
|
};
|
||||||
|
|
||||||
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
|
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -186,8 +194,26 @@ export async function normalizeWorkspaceMemoryWithAccounting(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// One-time migration for legacy snapshot violations
|
// One-time migrations for legacy/low-quality snapshot violations.
|
||||||
result = runMigrationP0Cleanup(result, nowIso);
|
// Run quality cleanup first so hard violations receive quality audit tags
|
||||||
|
// before the older P0 project-only cleanup marks progress snapshots.
|
||||||
|
const beforeQualityCleanup = result;
|
||||||
|
const qualityCleanup = runMigrationQualityCleanup(result, nowIso);
|
||||||
|
result = qualityCleanup.store;
|
||||||
|
let skipRemainingMigrations = false;
|
||||||
|
if (qualityCleanup.events.length > 0) {
|
||||||
|
try {
|
||||||
|
await appendQualityCleanupMigrationLog(qualityCleanup.events);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[memory] failed to write quality cleanup migration log:", error);
|
||||||
|
console.error("[memory] aborting migration to maintain audit trail integrity");
|
||||||
|
result = beforeQualityCleanup;
|
||||||
|
skipRemainingMigrations = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!skipRemainingMigrations) {
|
||||||
|
result = runMigrationP0Cleanup(result, nowIso);
|
||||||
|
}
|
||||||
|
|
||||||
// P0 accounting only considers active entries. Entries that were already
|
// P0 accounting only considers active entries. Entries that were already
|
||||||
// superseded before this normalization are preserved in storage; entries that
|
// superseded before this normalization are preserved in storage; entries that
|
||||||
@@ -213,66 +239,6 @@ export async function normalizeWorkspaceMemoryWithAccounting(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function redactCredentials(text: string): string {
|
|
||||||
let result = text;
|
|
||||||
|
|
||||||
// 1. PIN
|
|
||||||
result = result.replace(
|
|
||||||
new RegExp(String.raw`${PIN_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
|
||||||
"$1[REDACTED]",
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. Username+password pair
|
|
||||||
result = result.replace(
|
|
||||||
new RegExp(
|
|
||||||
String.raw`${USERNAME_PREFIX}[\`'"]?(${SECRET_VALUE})((?:,|,)\s*)${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`,
|
|
||||||
"gi",
|
|
||||||
),
|
|
||||||
"$1[REDACTED]$3$4[REDACTED]",
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. Standalone password
|
|
||||||
result = result.replace(
|
|
||||||
new RegExp(String.raw`${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
|
||||||
"$1[REDACTED]",
|
|
||||||
);
|
|
||||||
|
|
||||||
// 4. Standalone sensitive keys/tokens
|
|
||||||
result = result.replace(
|
|
||||||
new RegExp(String.raw`${BEARER_PREFIX}(?!token\s*[:=:])[\`'"]?(${SECRET_VALUE})`, "gi"),
|
|
||||||
"$1[REDACTED]",
|
|
||||||
);
|
|
||||||
|
|
||||||
result = result.replace(
|
|
||||||
new RegExp(String.raw`${SENSITIVE_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
|
||||||
"$1[REDACTED]",
|
|
||||||
);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isProjectSnapshotViolation(text: string): boolean {
|
|
||||||
// Test/suite counts
|
|
||||||
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
|
|
||||||
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
|
|
||||||
|
|
||||||
// File counts with snapshot context, excluding limit statements
|
|
||||||
if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) {
|
|
||||||
const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text);
|
|
||||||
const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text);
|
|
||||||
if (hasSnapshotContext && !hasLimitContext) return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase/Wave/Sprint/Milestone/Task progress
|
|
||||||
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) {
|
|
||||||
if (/completed|done|finished|完成/i.test(text)) return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runMigrationP0Cleanup(
|
export function runMigrationP0Cleanup(
|
||||||
store: WorkspaceMemoryStore,
|
store: WorkspaceMemoryStore,
|
||||||
nowIso: string,
|
nowIso: string,
|
||||||
@@ -282,10 +248,10 @@ export function runMigrationP0Cleanup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const entries = store.entries.map(entry => {
|
const entries = store.entries.map(entry => {
|
||||||
if (entry.source === "explicit") return entry;
|
if (entry.source !== "compaction") return entry;
|
||||||
if (entry.type !== "project") return entry;
|
if (entry.type !== "project") return entry;
|
||||||
|
|
||||||
if (isProjectSnapshotViolation(entry.text)) {
|
if (isProgressSnapshotViolation(entry.text)) {
|
||||||
return {
|
return {
|
||||||
...entry,
|
...entry,
|
||||||
status: "superseded" as const,
|
status: "superseded" as const,
|
||||||
@@ -304,6 +270,74 @@ export function runMigrationP0Cleanup(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function appendQualityCleanupMigrationLog(events: QualityCleanupMigrationLogEntry[]): Promise<void> {
|
||||||
|
if (events.length === 0) return;
|
||||||
|
const path = migrationLogPath(QUALITY_CLEANUP_MIGRATION_ID);
|
||||||
|
await mkdir(dirname(path), { recursive: true });
|
||||||
|
await appendFile(path, events.map(event => JSON.stringify(event)).join("\n") + "\n", "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runMigrationQualityCleanup(
|
||||||
|
store: WorkspaceMemoryStore,
|
||||||
|
nowIso: string,
|
||||||
|
): { store: WorkspaceMemoryStore; events: QualityCleanupMigrationLogEntry[] } {
|
||||||
|
if (store.migrations?.includes(QUALITY_CLEANUP_MIGRATION_ID)) {
|
||||||
|
return { store, events: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const events: QualityCleanupMigrationLogEntry[] = [];
|
||||||
|
let changed = false;
|
||||||
|
const entries = store.entries.map(entry => {
|
||||||
|
if (entry.source !== "compaction") return entry;
|
||||||
|
if (entry.status === "superseded") return entry;
|
||||||
|
|
||||||
|
const quality = assessMemoryQuality(entry);
|
||||||
|
if (quality.accepted) return entry;
|
||||||
|
|
||||||
|
const hardReasons = quality.reasons.filter(isHardQualityReason);
|
||||||
|
if (hardReasons.length === 0) return entry;
|
||||||
|
|
||||||
|
changed = true;
|
||||||
|
events.push({
|
||||||
|
migrationId: QUALITY_CLEANUP_MIGRATION_ID,
|
||||||
|
timestamp: nowIso,
|
||||||
|
workspaceKey: store.workspace.key,
|
||||||
|
workspaceRoot: store.workspace.root,
|
||||||
|
entryId: entry.id,
|
||||||
|
type: entry.type,
|
||||||
|
source: entry.source,
|
||||||
|
text: entry.text,
|
||||||
|
reasons: quality.reasons,
|
||||||
|
hardReasons,
|
||||||
|
beforeStatus: "active",
|
||||||
|
afterStatus: "superseded",
|
||||||
|
});
|
||||||
|
|
||||||
|
const tags = new Set([
|
||||||
|
...(entry.tags ?? []),
|
||||||
|
"quality_cleanup",
|
||||||
|
...hardReasons.map(reason => `quality:${reason}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
status: "superseded" as const,
|
||||||
|
updatedAt: nowIso,
|
||||||
|
tags: [...tags],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
store: {
|
||||||
|
...store,
|
||||||
|
entries,
|
||||||
|
migrations: [...(store.migrations ?? []), QUALITY_CLEANUP_MIGRATION_ID],
|
||||||
|
updatedAt: changed ? nowIso : store.updatedAt,
|
||||||
|
},
|
||||||
|
events,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function sourcePriority(source: LongTermMemoryEntry["source"]): number {
|
function sourcePriority(source: LongTermMemoryEntry["source"]): number {
|
||||||
if (source === "explicit") return 3;
|
if (source === "explicit") return 3;
|
||||||
if (source === "manual") return 2;
|
if (source === "manual") return 2;
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { extractErrorsFromBash, extractExplicitMemories } from "../src/extractors.ts";
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { extractErrorsFromBash, extractExplicitMemories, parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
|
||||||
|
|
||||||
|
async function waitForFile(path: string, attempts = 20): Promise<string> {
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let i = 0; i < attempts; i += 1) {
|
||||||
|
try {
|
||||||
|
return await readFile(path, "utf8");
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Task 1: extractErrorsFromBash tests
|
// Task 1: extractErrorsFromBash tests
|
||||||
@@ -129,8 +145,6 @@ test("extractExplicitMemories captures multiple memories in same message", () =>
|
|||||||
// Task 7: Compaction quality gate tests
|
// Task 7: Compaction quality gate tests
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
|
|
||||||
|
|
||||||
test("parseWorkspaceMemoryCandidates rejects short text", () => {
|
test("parseWorkspaceMemoryCandidates rejects short text", () => {
|
||||||
const summary = `
|
const summary = `
|
||||||
## Memory Candidates
|
## Memory Candidates
|
||||||
@@ -223,7 +237,7 @@ test("parseWorkspaceMemoryCandidates accepts bracketless candidate format", () =
|
|||||||
Memory candidates:
|
Memory candidates:
|
||||||
- project Backend health improvements organized into phased milestones
|
- project Backend health improvements organized into phased milestones
|
||||||
- reference Scrypt 參數必須是 N=16384, r=8, p=1
|
- reference Scrypt 參數必須是 N=16384, r=8, p=1
|
||||||
- feedback 端口 9473 可能被舊進程佔用,需殺掉後重啟
|
- feedback User prefers Traditional Chinese memory summaries
|
||||||
- decision Use output.prompt to replace the default compaction template
|
- decision Use output.prompt to replace the default compaction template
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -281,6 +295,64 @@ Memory candidates:
|
|||||||
assert.equal(items.length, 0, "Exact test counts are session snapshots, not durable memory");
|
assert.equal(items.length, 0, "Exact test counts are session snapshots, not durable memory");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("parseWorkspaceMemoryCandidates logs quality gate rejections locally", async () => {
|
||||||
|
const dataHome = await mkdtemp(join(tmpdir(), "wm-extraction-reject-data-"));
|
||||||
|
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
||||||
|
process.env.XDG_DATA_HOME = dataHome;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summary = `
|
||||||
|
Memory candidates:
|
||||||
|
- feedback Wave 1 completed successfully and all tests passed
|
||||||
|
`;
|
||||||
|
|
||||||
|
const items = parseWorkspaceMemoryCandidates(summary);
|
||||||
|
|
||||||
|
assert.equal(items.length, 0);
|
||||||
|
const logPath = join(dataHome, "opencode-working-memory", "extraction-rejections.jsonl");
|
||||||
|
const lines = (await waitForFile(logPath)).trim().split("\n");
|
||||||
|
assert.equal(lines.length, 1);
|
||||||
|
const event = JSON.parse(lines[0]);
|
||||||
|
assert.equal(event.type, "feedback");
|
||||||
|
assert.equal(event.text, "Wave 1 completed successfully and all tests passed");
|
||||||
|
assert.deepEqual(event.reasons, ["progress_snapshot", "bad_feedback"]);
|
||||||
|
assert.equal(event.source, "compaction");
|
||||||
|
} finally {
|
||||||
|
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
||||||
|
else process.env.XDG_DATA_HOME = previousXdgDataHome;
|
||||||
|
await rm(dataHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseWorkspaceMemoryCandidates redacts secrets in extraction rejection log", async () => {
|
||||||
|
const dataHome = await mkdtemp(join(tmpdir(), "wm-extraction-redact-data-"));
|
||||||
|
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
||||||
|
process.env.XDG_DATA_HOME = dataHome;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summary = `
|
||||||
|
Memory candidates:
|
||||||
|
- reference TypeError: bearer sk_test token=tok123 password=pass123 secret=sec123 api_key=key123
|
||||||
|
`;
|
||||||
|
|
||||||
|
const items = parseWorkspaceMemoryCandidates(summary);
|
||||||
|
|
||||||
|
assert.equal(items.length, 0);
|
||||||
|
const logPath = join(dataHome, "opencode-working-memory", "extraction-rejections.jsonl");
|
||||||
|
const lines = (await waitForFile(logPath)).trim().split("\n");
|
||||||
|
assert.equal(lines.length, 1);
|
||||||
|
const event = JSON.parse(lines[0]);
|
||||||
|
assert.equal(
|
||||||
|
event.text,
|
||||||
|
"TypeError: bearer [REDACTED] token=[REDACTED] password=[REDACTED] secret=[REDACTED] api_key=[REDACTED]",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
||||||
|
else process.env.XDG_DATA_HOME = previousXdgDataHome;
|
||||||
|
await rm(dataHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("parseWorkspaceMemoryCandidates rejects exact file count snapshots", () => {
|
test("parseWorkspaceMemoryCandidates rejects exact file count snapshots", () => {
|
||||||
const summary = `
|
const summary = `
|
||||||
Memory candidates:
|
Memory candidates:
|
||||||
@@ -451,14 +523,14 @@ test("parseWorkspaceMemoryCandidates allows benign ignore/instruction wording",
|
|||||||
Memory candidates:
|
Memory candidates:
|
||||||
- [project] Use .gitignore to ignore generated files.
|
- [project] Use .gitignore to ignore generated files.
|
||||||
- [reference] Instruction parser supports Markdown sections and bracketed memory types.
|
- [reference] Instruction parser supports Markdown sections and bracketed memory types.
|
||||||
- [decision] Prompt context uses a frozen workspace snapshot plus hot session state.
|
- [decision] Use a frozen workspace snapshot plus hot session state for prompt context.
|
||||||
`;
|
`;
|
||||||
const items = parseWorkspaceMemoryCandidates(summary);
|
const items = parseWorkspaceMemoryCandidates(summary);
|
||||||
|
|
||||||
assert.equal(items.length, 3);
|
assert.equal(items.length, 3);
|
||||||
assert.equal(items[0].text, "Use .gitignore to ignore generated files.");
|
assert.equal(items[0].text, "Use .gitignore to ignore generated files.");
|
||||||
assert.equal(items[1].text, "Instruction parser supports Markdown sections and bracketed memory types.");
|
assert.equal(items[1].text, "Instruction parser supports Markdown sections and bracketed memory types.");
|
||||||
assert.equal(items[2].text, "Prompt context uses a frozen workspace snapshot plus hot session state.");
|
assert.equal(items[2].text, "Use a frozen workspace snapshot plus hot session state for prompt context.");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseWorkspaceMemoryCandidates rejects direct system prompt override attempts", () => {
|
test("parseWorkspaceMemoryCandidates rejects direct system prompt override attempts", () => {
|
||||||
|
|||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
import type { LongTermMemoryEntry } from "../../src/types.ts";
|
||||||
|
|
||||||
|
const now = "2026-04-28T00:00:00.000Z";
|
||||||
|
|
||||||
|
function mem(
|
||||||
|
id: string,
|
||||||
|
type: LongTermMemoryEntry["type"],
|
||||||
|
text: string,
|
||||||
|
source: LongTermMemoryEntry["source"] = "compaction",
|
||||||
|
): LongTermMemoryEntry {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
text,
|
||||||
|
source,
|
||||||
|
confidence: source === "explicit" ? 1 : 0.75,
|
||||||
|
status: "active",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reviewerCurrent28Fixture: LongTermMemoryEntry[] = [
|
||||||
|
// High-value durable entries. These should survive.
|
||||||
|
mem("good_feedback_language", "feedback", "User prefers architecture reviews in Traditional Chinese", "explicit"),
|
||||||
|
mem("good_feedback_direct", "feedback", "User wants direct architecture feedback with concrete file paths", "explicit"),
|
||||||
|
mem("good_feedback_no_manual_cleanup", "feedback", "User prefers automatic memory cleanup over manual cleanup instructions", "explicit"),
|
||||||
|
mem("good_decision_no_extra_api", "decision", "Do not add extra LLM API calls for memory consolidation"),
|
||||||
|
mem("good_decision_no_semantic_merge", "decision", "Memory dedupe must use exact canonical keys and generic URL/path identity only"),
|
||||||
|
mem("good_decision_no_render_tracking", "decision", "Do not use rendered-memory access tracking as evidence"),
|
||||||
|
mem("good_reference_frozen", "reference", "Workspace memory is rendered as a frozen system[1] snapshot; pending memories remain in hot session state until compaction"),
|
||||||
|
mem("good_project_plugin", "project", "The project is an OpenCode plugin using TypeScript and local JSON stores"),
|
||||||
|
mem("good_reference_accounting", "reference", "Promotion accounting reports promoted, absorbed, superseded, and rejected outcomes"),
|
||||||
|
|
||||||
|
// Pseudo feedback/decision/progress snapshots. These should be superseded/rejected.
|
||||||
|
mem("bad_feedback_wave_done", "feedback", "Wave 1 completed successfully and all tests passed"),
|
||||||
|
mem("bad_feedback_plan_done", "feedback", "Plan 1 critical stability fixes were implemented"),
|
||||||
|
mem("bad_feedback_session_note", "feedback", "The assistant reviewed the code reviewer feedback and updated the plan"),
|
||||||
|
mem("bad_feedback_impl_note", "feedback", "Implemented owner-aware pending journal cleanup in plugin.ts"),
|
||||||
|
mem("bad_decision_commit", "decision", "Commit 53aa6d3 completed consolidation accounting"),
|
||||||
|
mem("bad_decision_tests", "decision", "180 tests pass and 0 tests fail after the latest change"),
|
||||||
|
mem("bad_decision_pr_status", "decision", "PR1 is done and PR2 is ready to start"),
|
||||||
|
mem("bad_project_files", "project", "Modified src/plugin.ts src/workspace-memory.ts src/pending-journal.ts during the last wave"),
|
||||||
|
mem("bad_project_wave", "project", "Wave 3 finished after cache bounds and Bearer redaction were added"),
|
||||||
|
mem("bad_reference_commit", "reference", "Commit a762e86 contains the owner scope fix"),
|
||||||
|
mem("bad_reference_ci", "reference", "CI compatibility run 25033906652 passed"),
|
||||||
|
mem("bad_reference_error", "reference", "TypeError: Cannot read properties of undefined"),
|
||||||
|
mem("bad_project_current", "project", "Currently running npm test before continuing"),
|
||||||
|
|
||||||
|
// Borderline implementation facts. Reject unless they are written as future rules.
|
||||||
|
mem("bad_decision_impl_detail", "decision", "dedupeLongTermEntriesWithAccounting was updated in the previous session"),
|
||||||
|
mem("bad_feedback_internal", "feedback", "The migration writes to disk when redaction changes content"),
|
||||||
|
mem("bad_reference_tmp", "reference", "storage.test.ts had a flaky cross-process test in CI"),
|
||||||
|
|
||||||
|
// Durable future-facing rules. These should survive.
|
||||||
|
mem("good_decision_quality", "decision", "Reject completion and progress statements before storing compaction memory candidates"),
|
||||||
|
mem("good_decision_quality_shared", "decision", "Use one shared memory quality gate for extraction and migration"),
|
||||||
|
mem("good_reference_quality_migration", "reference", "Quality cleanup migration supersedes low-quality compaction memories and does not touch explicit memories"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const expectedAcceptedFixtureIds = new Set([
|
||||||
|
"good_feedback_language",
|
||||||
|
"good_feedback_direct",
|
||||||
|
"good_feedback_no_manual_cleanup",
|
||||||
|
"good_decision_no_extra_api",
|
||||||
|
"good_decision_no_semantic_merge",
|
||||||
|
"good_decision_no_render_tracking",
|
||||||
|
"good_reference_frozen",
|
||||||
|
"good_project_plugin",
|
||||||
|
"good_reference_accounting",
|
||||||
|
"good_decision_quality",
|
||||||
|
"good_decision_quality_shared",
|
||||||
|
"good_reference_quality_migration",
|
||||||
|
]);
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
import type { LongTermMemoryEntry } from "../../src/types.ts";
|
||||||
|
|
||||||
|
export type RealWorkspaceFixtureEntry = LongTermMemoryEntry & {
|
||||||
|
expectedAfterMigration: "active" | "superseded";
|
||||||
|
expectation: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseTimestamp = "2026-04-28T00:00:00.000Z";
|
||||||
|
|
||||||
|
function mem(
|
||||||
|
id: string,
|
||||||
|
type: LongTermMemoryEntry["type"],
|
||||||
|
text: string,
|
||||||
|
expectedAfterMigration: "active" | "superseded",
|
||||||
|
expectation: string,
|
||||||
|
): RealWorkspaceFixtureEntry {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
text,
|
||||||
|
source: "compaction",
|
||||||
|
confidence: 0.75,
|
||||||
|
status: "active",
|
||||||
|
createdAt: baseTimestamp,
|
||||||
|
updatedAt: baseTimestamp,
|
||||||
|
staleAfterDays: type === "feedback" ? undefined : 45,
|
||||||
|
expectedAfterMigration,
|
||||||
|
expectation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REAL_WORKSPACE_FIXTURES: Record<string, RealWorkspaceFixtureEntry[]> = {
|
||||||
|
"workspace-alpha": [
|
||||||
|
mem("alpha_ui_rule", "feedback", "UI should have consistent style: both tables scrollable, about 20 rows", "active", "durable UI rule without user preference keyword"),
|
||||||
|
mem("alpha_csp_rule", "feedback", "Architecture recommendation: migrate the content security policy to nonce or hash rules rather than unsafe inline scripts", "active", "durable architecture recommendation"),
|
||||||
|
mem("alpha_form_rule", "decision", "Form uses defensive action and method attributes so the fallback does not navigate to the home page when scripts fail", "active", "declarative design rule"),
|
||||||
|
mem("alpha_logging_rule", "decision", "Cloud logging filter supports multiple log formats: structured event type, structured message, and text payload", "active", "durable declarative logging spec"),
|
||||||
|
],
|
||||||
|
"workspace-beta": [
|
||||||
|
mem("beta_phase_snapshot", "project", "Backend health improvement plan completed Phase 1-4", "superseded", "progress snapshot"),
|
||||||
|
mem("beta_test_snapshot", "project", "Test suite: 1237 tests pass, 226 suites", "superseded", "test count snapshot"),
|
||||||
|
mem("beta_sync_snapshot", "project", "External drive synced 37 files including bundles, service, frontend, tests, and docs", "superseded", "file sync snapshot"),
|
||||||
|
],
|
||||||
|
"workspace-gamma": [
|
||||||
|
mem("gamma_need_check", "feedback", "Architecture recommendation: confirm actual demand before executing the later priority phase", "active", "durable plan decision"),
|
||||||
|
mem("gamma_review_fallback", "feedback", "Primary review automation can be unreliable; use phase verification as the fallback", "active", "durable workaround rule"),
|
||||||
|
mem("gamma_wave_rule", "feedback", "Each wave should end with verifier confirmation, and the full implementation should end with code review", "active", "durable workflow rule"),
|
||||||
|
mem("gamma_remote_headers", "decision", "Remote headers are passed through the HTTP transport request initialization headers option", "active", "declarative API rule"),
|
||||||
|
mem("gamma_signal_order", "decision", "Graceful process cleanup signal order: interrupt for 300ms, terminate for 700ms, then kill", "active", "durable process cleanup spec"),
|
||||||
|
mem("gamma_ownership", "decision", "Runtime state ownership model: the command-line entrypoint owns both runtime objects, and disposal order is primary runtime first", "active", "durable ownership model"),
|
||||||
|
mem("gamma_retry_policy", "decision", "Recovery retry policy: only once per tool call, only for transport or session failures", "active", "durable retry policy"),
|
||||||
|
],
|
||||||
|
"workspace-delta": [
|
||||||
|
mem("delta_user_cycle", "feedback", "User requires a complete plan, review, feedback, modify, and verify loop rather than direct execution", "active", "user workflow preference"),
|
||||||
|
mem("delta_batching", "feedback", "Large-batch embedding requires controlled batch size around 20 to 50 items and a delay between requests", "active", "durable operational knowledge"),
|
||||||
|
mem("delta_option_b", "decision", "Phase 2 fix adopted Option B: grouped search across multiple profiles", "active", "design decision using adopted"),
|
||||||
|
mem("delta_single_source", "decision", "MCP source keeps a single generic source type, with item identity encoded in the source ID", "active", "design constraint using keeps"),
|
||||||
|
mem("delta_endpoint", "decision", "Embedding service endpoint is `/api/embed` rather than `/api/embeddings`, with the input field in the request body", "active", "declarative API fact"),
|
||||||
|
mem("delta_filter_pipeline", "decision", "Filter pipeline uses pre-chunk filtering rather than post-chunk filtering to prevent embedding contamination", "active", "durable architecture rule"),
|
||||||
|
mem("delta_do_not_delete", "decision", "Do not delete isolated reference-like lines because citation fragments in body text can be valid references", "active", "do-not rule"),
|
||||||
|
],
|
||||||
|
"workspace-epsilon": [
|
||||||
|
mem("epsilon_author_credit", "feedback", "User insists on preserving external contributor author credit and uses merge workflow", "active", "durable preference using insists"),
|
||||||
|
mem("epsilon_branding", "decision", "Product branding is \"Generic Working Memory\" without \"Plugin\" in the name", "active", "durable branding rule"),
|
||||||
|
mem("epsilon_changelog", "decision", "Changelog version scope follows release tags: changes from the previous version tag through the current branch belong to the next version", "active", "durable release rule"),
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
|
import { extractExplicitMemories, parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
|
||||||
|
import { assessMemoryQuality, isHardQualityReason } from "../src/memory-quality.ts";
|
||||||
|
import { expectedAcceptedFixtureIds, reviewerCurrent28Fixture } from "./fixtures/memory-quality-current-28.ts";
|
||||||
|
|
||||||
const acceptedCases = [
|
const acceptedCases = [
|
||||||
{
|
{
|
||||||
@@ -64,6 +66,18 @@ const rejectedCases = [
|
|||||||
name: "temporary pending task",
|
name: "temporary pending task",
|
||||||
line: "- [decision] currently: run npm test before the next reply",
|
line: "- [decision] currently: run npm test before the next reply",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "misclassified feedback completion snapshot",
|
||||||
|
line: "- [feedback] Wave 1 completed successfully and all tests passed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "misclassified decision implementation note",
|
||||||
|
line: "- [decision] Implemented owner-aware cleanup in plugin.ts",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "session internal review note",
|
||||||
|
line: "- [feedback] The assistant reviewed the code reviewer feedback and updated the plan",
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
for (const item of acceptedCases) {
|
for (const item of acceptedCases) {
|
||||||
@@ -91,3 +105,71 @@ ${item.line}
|
|||||||
assert.equal(entries.length, 0);
|
assert.equal(entries.length, 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("reviewer current-28 fixture keeps durable memories and rejects pseudo memories", () => {
|
||||||
|
for (const entry of reviewerCurrent28Fixture) {
|
||||||
|
const result = assessMemoryQuality(entry);
|
||||||
|
assert.equal(
|
||||||
|
result.accepted,
|
||||||
|
expectedAcceptedFixtureIds.has(entry.id),
|
||||||
|
`${entry.id}: ${entry.text} -> ${result.reasons.join(",")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("progress snapshot rejection is type independent", () => {
|
||||||
|
for (const type of ["feedback", "project", "decision", "reference"] as const) {
|
||||||
|
const result = assessMemoryQuality({ type, text: "Wave 2 completed successfully", source: "compaction" });
|
||||||
|
assert.equal(result.accepted, false, `${type} progress snapshots must reject`);
|
||||||
|
assert.ok(result.reasons.includes("progress_snapshot"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("feedback must be stable user preference or instruction", () => {
|
||||||
|
assert.equal(assessMemoryQuality({ type: "feedback", text: "User prefers concise architecture reviews", source: "compaction" }).accepted, true);
|
||||||
|
assert.equal(assessMemoryQuality({ type: "feedback", text: "Implemented owner-aware cleanup in plugin.ts", source: "compaction" }).accepted, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decision must be future-facing rule, not completed implementation note", () => {
|
||||||
|
assert.equal(assessMemoryQuality({ type: "decision", text: "Do not add semantic merge to memory dedupe", source: "compaction" }).accepted, true);
|
||||||
|
assert.equal(assessMemoryQuality({ type: "decision", text: "Use the cache boundary that was chosen in ADR-2 for future memory rendering", source: "compaction" }).accepted, true);
|
||||||
|
assert.equal(assessMemoryQuality({ type: "decision", text: "Added semantic merge tests in the previous wave", source: "compaction" }).accepted, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shared quality gate owns extractor low-quality syntax rejections", () => {
|
||||||
|
const rejected = [
|
||||||
|
{ type: "project" as const, text: "fix: add new feature" },
|
||||||
|
{ type: "reference" as const, text: "modified src/plugin.ts" },
|
||||||
|
{ type: "reference" as const, text: "function buildCompactionPrompt(privateContext: string): string" },
|
||||||
|
{ type: "reference" as const, text: "GET /api/sessions" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const entry of rejected) {
|
||||||
|
assert.equal(
|
||||||
|
assessMemoryQuality({ ...entry, source: "compaction" }).accepted,
|
||||||
|
false,
|
||||||
|
`${entry.type}: ${entry.text}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("explicit memories bypass extraction quality gate", () => {
|
||||||
|
const entries = extractExplicitMemories("remember: Wave 1 completed successfully and all tests passed");
|
||||||
|
assert.equal(entries.length, 1);
|
||||||
|
assert.equal(entries[0].source, "explicit");
|
||||||
|
assert.match(entries[0].text, /Wave 1 completed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hard quality reasons exclude soft whitelist failures", () => {
|
||||||
|
assert.equal(isHardQualityReason("progress_snapshot"), true);
|
||||||
|
assert.equal(isHardQualityReason("raw_error"), true);
|
||||||
|
assert.equal(isHardQualityReason("commit_or_ci_snapshot"), true);
|
||||||
|
assert.equal(isHardQualityReason("temporary_status"), true);
|
||||||
|
assert.equal(isHardQualityReason("active_file_snapshot"), true);
|
||||||
|
assert.equal(isHardQualityReason("code_or_api_signature"), true);
|
||||||
|
assert.equal(isHardQualityReason("path_heavy"), true);
|
||||||
|
assert.equal(isHardQualityReason("empty"), true);
|
||||||
|
|
||||||
|
assert.equal(isHardQualityReason("bad_feedback"), false);
|
||||||
|
assert.equal(isHardQualityReason("bad_decision"), false);
|
||||||
|
});
|
||||||
|
|||||||
+23
-2
@@ -297,9 +297,9 @@ test("compaction hook sets output.prompt with ---free template", async () => {
|
|||||||
"Prompt should include concrete positive memory examples");
|
"Prompt should include concrete positive memory examples");
|
||||||
assert.equal(prompt!.includes("Bad memory examples to skip:"), true,
|
assert.equal(prompt!.includes("Bad memory examples to skip:"), true,
|
||||||
"Prompt should include concrete negative memory examples");
|
"Prompt should include concrete negative memory examples");
|
||||||
assert.equal(prompt!.includes("42 tests passed"), true,
|
assert.equal(prompt!.includes("180 tests passed"), true,
|
||||||
"Prompt should explicitly reject test-count snapshots");
|
"Prompt should explicitly reject test-count snapshots");
|
||||||
assert.equal(prompt!.includes("commit 4309cb8"), true,
|
assert.equal(prompt!.includes("Commit a762e86"), true,
|
||||||
"Prompt should explicitly reject commit-hash snapshots");
|
"Prompt should explicitly reject commit-hash snapshots");
|
||||||
|
|
||||||
// Should contain our context data (hot session state)
|
// Should contain our context data (hot session state)
|
||||||
@@ -317,6 +317,27 @@ test("compaction hook sets output.prompt with ---free template", async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("compaction prompt forbids progress and session-internal memory candidates", async () => {
|
||||||
|
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-prompt-"));
|
||||||
|
try {
|
||||||
|
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
|
||||||
|
const output = { prompt: "", context: [] as string[] };
|
||||||
|
|
||||||
|
await (plugin as Record<string, Function>)["experimental.session.compacting"](
|
||||||
|
{ sessionID: "prompt-session", model: {} },
|
||||||
|
output,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(output.prompt, /CRITICAL MEMORY RULES/);
|
||||||
|
assert.match(output.prompt, /NO completion or progress statements/i);
|
||||||
|
assert.match(output.prompt, /NO session-internal implementation notes/i);
|
||||||
|
assert.match(output.prompt, /feedback ONLY/i);
|
||||||
|
assert.match(output.prompt, /Most compactions should produce ZERO memories/i);
|
||||||
|
} finally {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("compaction hook merges existing output.context from other plugins", async () => {
|
test("compaction hook merges existing output.context from other plugins", async () => {
|
||||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { after } from "node:test";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
|
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
||||||
|
const previousTestFlag = process.env.OPENCODE_WORKING_MEMORY_TEST;
|
||||||
|
const testDataHome = await mkdtemp(join(tmpdir(), "opencode-working-memory-test-xdg-"));
|
||||||
|
|
||||||
|
process.env.XDG_DATA_HOME = testDataHome;
|
||||||
|
process.env.OPENCODE_WORKING_MEMORY_TEST = "1";
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
||||||
|
else process.env.XDG_DATA_HOME = previousXdgDataHome;
|
||||||
|
|
||||||
|
if (previousTestFlag === undefined) delete process.env.OPENCODE_WORKING_MEMORY_TEST;
|
||||||
|
else process.env.OPENCODE_WORKING_MEMORY_TEST = previousTestFlag;
|
||||||
|
|
||||||
|
await rm(testDataHome, { recursive: true, force: true });
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import {
|
||||||
|
classifyWorkspaceDir,
|
||||||
|
cleanupWorkspaceResidues,
|
||||||
|
scanWorkspaceResidues,
|
||||||
|
} from "../src/workspace-cleanup.ts";
|
||||||
|
|
||||||
|
async function writeWorkspaceStore(dataHome: string, key: string, root: string): Promise<string> {
|
||||||
|
const workspaceDir = join(dataHome, "opencode-working-memory", "workspaces", key);
|
||||||
|
await mkdir(workspaceDir, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(workspaceDir, "workspace-memory.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
workspace: { root, key },
|
||||||
|
limits: { maxRenderedChars: 5200, maxEntries: 28 },
|
||||||
|
entries: [],
|
||||||
|
updatedAt: "2026-04-28T00:00:00.000Z",
|
||||||
|
}, null, 2),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
return workspaceDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("workspace cleanup classifies missing temp test roots as definite residue", async () => {
|
||||||
|
const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-"));
|
||||||
|
try {
|
||||||
|
const missingTempRoot = join(tmpdir(), "memory-plugin-test-missing-root");
|
||||||
|
await rm(missingTempRoot, { recursive: true, force: true });
|
||||||
|
const workspaceDir = await writeWorkspaceStore(dataHome, "definite", missingTempRoot);
|
||||||
|
|
||||||
|
const result = await classifyWorkspaceDir(workspaceDir);
|
||||||
|
|
||||||
|
assert.equal(result.classification, "test_temp_definite");
|
||||||
|
assert.equal(result.rootExists, false);
|
||||||
|
assert.ok(result.reasons.includes("root_missing"));
|
||||||
|
assert.ok(result.reasons.some(reason => reason.startsWith("root_under_temp")));
|
||||||
|
assert.ok(result.reasons.includes("test_prefix_memory-plugin-test"));
|
||||||
|
} finally {
|
||||||
|
await rm(dataHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workspace cleanup keeps existing temp roots live", async () => {
|
||||||
|
const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-"));
|
||||||
|
const liveRoot = await mkdtemp(join(tmpdir(), "wm-quality-live-root-"));
|
||||||
|
try {
|
||||||
|
const workspaceDir = await writeWorkspaceStore(dataHome, "live", liveRoot);
|
||||||
|
|
||||||
|
const result = await classifyWorkspaceDir(workspaceDir);
|
||||||
|
|
||||||
|
assert.equal(result.classification, "live_or_existing");
|
||||||
|
assert.equal(result.rootExists, true);
|
||||||
|
} finally {
|
||||||
|
await rm(dataHome, { recursive: true, force: true });
|
||||||
|
await rm(liveRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workspace cleanup reports missing non-temp roots as unknown orphans", async () => {
|
||||||
|
const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-"));
|
||||||
|
try {
|
||||||
|
const missingNonTempRoot = `/definitely-not-temp-opencode-working-memory-test-${Date.now()}`;
|
||||||
|
const workspaceDir = await writeWorkspaceStore(dataHome, "orphan", missingNonTempRoot);
|
||||||
|
|
||||||
|
const result = await classifyWorkspaceDir(workspaceDir);
|
||||||
|
|
||||||
|
assert.equal(result.classification, "orphan_unknown");
|
||||||
|
assert.equal(result.rootExists, false);
|
||||||
|
assert.ok(result.reasons.includes("root_missing"));
|
||||||
|
} finally {
|
||||||
|
await rm(dataHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workspace cleanup reports invalid stores without moving them", async () => {
|
||||||
|
const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-"));
|
||||||
|
try {
|
||||||
|
const workspaceDir = join(dataHome, "opencode-working-memory", "workspaces", "invalid");
|
||||||
|
await mkdir(workspaceDir, { recursive: true });
|
||||||
|
await writeFile(join(workspaceDir, "workspace-memory.json"), "{ invalid", "utf8");
|
||||||
|
|
||||||
|
const result = await classifyWorkspaceDir(workspaceDir);
|
||||||
|
|
||||||
|
assert.equal(result.classification, "invalid_or_unreadable");
|
||||||
|
assert.ok(result.reasons.includes("invalid_json"));
|
||||||
|
|
||||||
|
const cleanup = await cleanupWorkspaceResidues({ dataHome, mode: "quarantine" });
|
||||||
|
assert.equal(cleanup.quarantined.length, 0);
|
||||||
|
assert.equal(existsSync(workspaceDir), true);
|
||||||
|
} finally {
|
||||||
|
await rm(dataHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workspace cleanup dry-run scans definite residue without moving it", async () => {
|
||||||
|
const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-"));
|
||||||
|
try {
|
||||||
|
const missingTempRoot = join(tmpdir(), "wm-accounting-missing-root");
|
||||||
|
await rm(missingTempRoot, { recursive: true, force: true });
|
||||||
|
const workspaceDir = await writeWorkspaceStore(dataHome, "dryrun", missingTempRoot);
|
||||||
|
|
||||||
|
const result = await cleanupWorkspaceResidues({ dataHome, minAgeMs: 0 });
|
||||||
|
|
||||||
|
assert.equal(result.mode, "dry-run");
|
||||||
|
assert.equal(result.candidates.length, 1);
|
||||||
|
assert.equal(result.quarantined.length, 0);
|
||||||
|
assert.equal(existsSync(workspaceDir), true);
|
||||||
|
} finally {
|
||||||
|
await rm(dataHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workspace cleanup quarantine moves definite residue and writes manifest", async () => {
|
||||||
|
const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-"));
|
||||||
|
try {
|
||||||
|
const missingTempRoot = join(tmpdir(), "wm-redact-missing-root");
|
||||||
|
await rm(missingTempRoot, { recursive: true, force: true });
|
||||||
|
const definiteDir = await writeWorkspaceStore(dataHome, "definite", missingTempRoot);
|
||||||
|
const orphanDir = await writeWorkspaceStore(dataHome, "orphan", `/definitely-not-temp-opencode-working-memory-test-${Date.now()}`);
|
||||||
|
|
||||||
|
const result = await cleanupWorkspaceResidues({
|
||||||
|
dataHome,
|
||||||
|
mode: "quarantine",
|
||||||
|
minAgeMs: 0,
|
||||||
|
now: new Date("2026-04-28T12:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.quarantined.length, 1);
|
||||||
|
assert.equal(result.quarantined[0]?.workspaceKey, "definite");
|
||||||
|
assert.equal(existsSync(definiteDir), false);
|
||||||
|
assert.equal(existsSync(orphanDir), true);
|
||||||
|
assert.ok(result.quarantineDir);
|
||||||
|
assert.equal(existsSync(join(result.quarantineDir!, "workspaces", "definite", "workspace-memory.json")), true);
|
||||||
|
|
||||||
|
const manifest = await readFile(join(result.quarantineDir!, "manifest.jsonl"), "utf8");
|
||||||
|
const event = JSON.parse(manifest.trim());
|
||||||
|
assert.equal(event.workspaceKey, "definite");
|
||||||
|
assert.equal(event.classification, "test_temp_definite");
|
||||||
|
assert.equal(event.root, missingTempRoot);
|
||||||
|
} finally {
|
||||||
|
await rm(dataHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workspace cleanup skips recently updated definite residue", async () => {
|
||||||
|
const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-"));
|
||||||
|
try {
|
||||||
|
const missingTempRoot = join(tmpdir(), "wm-extraction-missing-root");
|
||||||
|
await rm(missingTempRoot, { recursive: true, force: true });
|
||||||
|
const workspaceDir = await writeWorkspaceStore(dataHome, "recent", missingTempRoot);
|
||||||
|
|
||||||
|
const stats = await stat(workspaceDir);
|
||||||
|
const result = await scanWorkspaceResidues({
|
||||||
|
dataHome,
|
||||||
|
nowMs: stats.mtimeMs + 1_000,
|
||||||
|
minAgeMs: 10 * 60 * 1_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.candidates.length, 0);
|
||||||
|
assert.equal(result.results.find(item => item.workspaceKey === "recent")?.classification, "test_temp_definite");
|
||||||
|
assert.ok(result.results.find(item => item.workspaceKey === "recent")?.reasons.includes("recent_workspace_dir"));
|
||||||
|
} finally {
|
||||||
|
await rm(dataHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ import { join, dirname } from "node:path";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
|
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
|
||||||
import { LONG_TERM_LIMITS } from "../src/types.ts";
|
import { LONG_TERM_LIMITS } from "../src/types.ts";
|
||||||
import { workspaceMemoryPath } from "../src/paths.ts";
|
import { workspaceKey, workspaceMemoryPath } from "../src/paths.ts";
|
||||||
import {
|
import {
|
||||||
renderWorkspaceMemory,
|
renderWorkspaceMemory,
|
||||||
enforceLongTermLimits,
|
enforceLongTermLimits,
|
||||||
@@ -14,13 +14,16 @@ import {
|
|||||||
normalizeWorkspaceMemoryWithAccounting,
|
normalizeWorkspaceMemoryWithAccounting,
|
||||||
workspaceMemoryExactKey,
|
workspaceMemoryExactKey,
|
||||||
workspaceMemoryIdentityKey,
|
workspaceMemoryIdentityKey,
|
||||||
redactCredentials,
|
|
||||||
isProjectSnapshotViolation,
|
|
||||||
runMigrationP0Cleanup,
|
runMigrationP0Cleanup,
|
||||||
|
runMigrationQualityCleanup,
|
||||||
loadWorkspaceMemory,
|
loadWorkspaceMemory,
|
||||||
saveWorkspaceMemory,
|
saveWorkspaceMemory,
|
||||||
updateWorkspaceMemoryWithAccounting,
|
updateWorkspaceMemoryWithAccounting,
|
||||||
} from "../src/workspace-memory.ts";
|
} from "../src/workspace-memory.ts";
|
||||||
|
import { redactCredentials } from "../src/redaction.ts";
|
||||||
|
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "../src/memory-quality.ts";
|
||||||
|
import { reviewerCurrent28Fixture } from "./fixtures/memory-quality-current-28.ts";
|
||||||
|
import { REAL_WORKSPACE_FIXTURES } from "./fixtures/real-workspaces-snapshot.ts";
|
||||||
|
|
||||||
function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry {
|
function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -889,13 +892,13 @@ test("redactCredentials is idempotent and also redacts rationale text", () => {
|
|||||||
assert.equal(migrated.entries[0].rationale, "password: [REDACTED]");
|
assert.equal(migrated.entries[0].rationale, "password: [REDACTED]");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("isProjectSnapshotViolation detects wave progress and avoids limit context false positives", () => {
|
test("shared progress snapshot rule detects wave progress and avoids limit context false positives", () => {
|
||||||
assert.equal(isProjectSnapshotViolation("1237 tests pass, 226 suites"), true);
|
assert.equal(isProgressSnapshotViolation("1237 tests pass, 226 suites"), true);
|
||||||
assert.equal(isProjectSnapshotViolation("USB 同步:37 個文件"), true);
|
assert.equal(isProgressSnapshotViolation("USB 同步:37 個文件"), true);
|
||||||
assert.equal(isProjectSnapshotViolation("Waves 1-5 已完成,Wave 6 deferred"), true);
|
assert.equal(isProgressSnapshotViolation("Waves 1-5 已完成,Wave 6 deferred"), true);
|
||||||
|
|
||||||
assert.equal(isProjectSnapshotViolation("Upload limit is 10 files"), false);
|
assert.equal(isProgressSnapshotViolation("Upload limit is 10 files"), false);
|
||||||
assert.equal(isProjectSnapshotViolation("Project supports 5 test suites"), false);
|
assert.equal(isProgressSnapshotViolation("Project supports 5 test suites"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs once", () => {
|
test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs once", () => {
|
||||||
@@ -953,6 +956,379 @@ test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs o
|
|||||||
assert.equal(twice.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.entries.find(e => e.id === "project-snapshot")?.updatedAt);
|
assert.equal(twice.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.entries.find(e => e.id === "project-snapshot")?.updatedAt);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("quality cleanup migration preserves soft-only feedback and decision violations", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "wm-quality-soft-preserve-"));
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await saveWorkspaceMemory(root, {
|
||||||
|
version: 1,
|
||||||
|
workspace: { root, key: await workspaceKey(root) },
|
||||||
|
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
id: "soft_feedback",
|
||||||
|
type: "feedback",
|
||||||
|
text: "UI 要統一風格:兩個表格都要 scrollable,約 20 rows",
|
||||||
|
source: "compaction",
|
||||||
|
confidence: 0.75,
|
||||||
|
status: "active",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "soft_decision",
|
||||||
|
type: "decision",
|
||||||
|
text: "Product branding is \"OpenCode Working Memory\" without \"Plugin\" in the name",
|
||||||
|
source: "compaction",
|
||||||
|
confidence: 0.75,
|
||||||
|
status: "active",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
staleAfterDays: 45,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
migrations: [],
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaded = await loadWorkspaceMemory(root);
|
||||||
|
assert.equal(loaded.entries.find(e => e.id === "soft_feedback")?.status, "active");
|
||||||
|
assert.equal(loaded.entries.find(e => e.id === "soft_decision")?.status, "active");
|
||||||
|
assert.ok(loaded.migrations?.includes("2026-04-28-quality-cleanup"));
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quality cleanup migration supersedes hard quality violations", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "wm-quality-hard-supersede-"));
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await saveWorkspaceMemory(root, {
|
||||||
|
version: 1,
|
||||||
|
workspace: { root, key: await workspaceKey(root) },
|
||||||
|
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||||
|
entries: [{
|
||||||
|
id: "hard_progress",
|
||||||
|
type: "project",
|
||||||
|
text: "測試套件:1237 tests pass, 226 suites",
|
||||||
|
source: "compaction",
|
||||||
|
confidence: 0.75,
|
||||||
|
status: "active",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
staleAfterDays: 60,
|
||||||
|
}],
|
||||||
|
migrations: [],
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaded = await loadWorkspaceMemory(root);
|
||||||
|
const entry = loaded.entries.find(e => e.id === "hard_progress");
|
||||||
|
assert.equal(entry?.status, "superseded");
|
||||||
|
assert.ok(entry?.tags?.includes("quality_cleanup"));
|
||||||
|
assert.ok(entry?.tags?.includes("quality:progress_snapshot"));
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quality cleanup migration writes audit log for hard supersedes", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "wm-quality-audit-root-"));
|
||||||
|
const dataHome = await mkdtemp(join(tmpdir(), "wm-quality-audit-data-"));
|
||||||
|
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
||||||
|
process.env.XDG_DATA_HOME = dataHome;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await saveWorkspaceMemory(root, {
|
||||||
|
version: 1,
|
||||||
|
workspace: { root, key: await workspaceKey(root) },
|
||||||
|
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||||
|
entries: [{
|
||||||
|
id: "hard_progress",
|
||||||
|
type: "project",
|
||||||
|
text: "測試套件:1237 tests pass, 226 suites",
|
||||||
|
source: "compaction",
|
||||||
|
confidence: 0.75,
|
||||||
|
status: "active",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
staleAfterDays: 60,
|
||||||
|
}],
|
||||||
|
migrations: [],
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadWorkspaceMemory(root);
|
||||||
|
|
||||||
|
const logPath = join(dataHome, "opencode-working-memory", "migration-logs", "2026-04-28-quality-cleanup.jsonl");
|
||||||
|
const lines = (await readFile(logPath, "utf8")).trim().split("\n");
|
||||||
|
assert.equal(lines.length, 1);
|
||||||
|
const event = JSON.parse(lines[0]);
|
||||||
|
assert.equal(event.migrationId, "2026-04-28-quality-cleanup");
|
||||||
|
assert.equal(event.entryId, "hard_progress");
|
||||||
|
assert.deepEqual(event.hardReasons, ["progress_snapshot"]);
|
||||||
|
assert.equal(event.beforeStatus, "active");
|
||||||
|
assert.equal(event.afterStatus, "superseded");
|
||||||
|
assert.equal(event.text, "測試套件:1237 tests pass, 226 suites");
|
||||||
|
} finally {
|
||||||
|
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
||||||
|
else process.env.XDG_DATA_HOME = previousXdgDataHome;
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
await rm(dataHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quality cleanup migration aborts supersede when audit log cannot be written", async () => {
|
||||||
|
const sandbox = await mkdtemp(join(tmpdir(), "wm-quality-audit-fail-"));
|
||||||
|
const dataHome = join(sandbox, "xdg-data-home");
|
||||||
|
const root = join(sandbox, "workspace");
|
||||||
|
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
||||||
|
const previousConsoleError = console.error;
|
||||||
|
process.env.XDG_DATA_HOME = dataHome;
|
||||||
|
console.error = () => {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(root, { recursive: true });
|
||||||
|
const now = "2026-04-28T00:00:00.000Z";
|
||||||
|
const storePath = await workspaceMemoryPath(root);
|
||||||
|
await mkdir(dirname(storePath), { recursive: true });
|
||||||
|
await writeFile(storePath, JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
workspace: { root, key: await workspaceKey(root) },
|
||||||
|
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||||
|
entries: [{
|
||||||
|
id: "hard_progress",
|
||||||
|
type: "project",
|
||||||
|
text: "Test suite: 1237 tests pass, 226 suites",
|
||||||
|
source: "compaction",
|
||||||
|
confidence: 0.75,
|
||||||
|
status: "active",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
staleAfterDays: 60,
|
||||||
|
}],
|
||||||
|
migrations: [],
|
||||||
|
updatedAt: now,
|
||||||
|
}, null, 2), "utf8");
|
||||||
|
|
||||||
|
const blockedLogDir = join(dataHome, "opencode-working-memory", "migration-logs");
|
||||||
|
await writeFile(blockedLogDir, "not a directory", "utf8");
|
||||||
|
|
||||||
|
const loaded = await loadWorkspaceMemory(root);
|
||||||
|
const persisted = JSON.parse(await readFile(storePath, "utf8")) as WorkspaceMemoryStore;
|
||||||
|
|
||||||
|
assert.equal(loaded.entries.find(entry => entry.id === "hard_progress")?.status, "active");
|
||||||
|
assert.equal(persisted.entries.find(entry => entry.id === "hard_progress")?.status, "active");
|
||||||
|
assert.equal(loaded.migrations?.includes("2026-04-28-quality-cleanup"), false);
|
||||||
|
assert.equal(persisted.migrations?.includes("2026-04-28-quality-cleanup"), false);
|
||||||
|
} finally {
|
||||||
|
console.error = previousConsoleError;
|
||||||
|
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
||||||
|
else process.env.XDG_DATA_HOME = previousXdgDataHome;
|
||||||
|
await rm(sandbox, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("real workspace regression fixture is de-identified and English-only", () => {
|
||||||
|
const cjkText = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
||||||
|
const identifyingTerms = [
|
||||||
|
"medical-atlas",
|
||||||
|
"opencode-record",
|
||||||
|
"agent-reports",
|
||||||
|
"pdf-extraction",
|
||||||
|
"self-repo",
|
||||||
|
"OpenCode Working Memory",
|
||||||
|
];
|
||||||
|
const failures: string[] = [];
|
||||||
|
|
||||||
|
for (const [workspaceName, fixtureEntries] of Object.entries(REAL_WORKSPACE_FIXTURES)) {
|
||||||
|
if (identifyingTerms.some(term => workspaceName.includes(term))) {
|
||||||
|
failures.push(`${workspaceName}: workspace key should be generalized`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of fixtureEntries) {
|
||||||
|
if (cjkText.test(entry.text)) {
|
||||||
|
failures.push(`${workspaceName}/${entry.id}: text must be English-only`);
|
||||||
|
}
|
||||||
|
for (const term of identifyingTerms) {
|
||||||
|
if (entry.text.includes(term)) {
|
||||||
|
failures.push(`${workspaceName}/${entry.id}: text contains identifying term ${term}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(failures.length, 0, `Fixture privacy failures:\n${failures.join("\n")}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quality cleanup migration regression against real workspace samples", async () => {
|
||||||
|
const failures: string[] = [];
|
||||||
|
const now = "2026-04-28T00:00:00.000Z";
|
||||||
|
|
||||||
|
for (const [workspaceName, fixtureEntries] of Object.entries(REAL_WORKSPACE_FIXTURES)) {
|
||||||
|
const root = `/fixture/${workspaceName}`;
|
||||||
|
const store = {
|
||||||
|
version: 1,
|
||||||
|
workspace: { root, key: workspaceName.padEnd(16, "0").slice(0, 16) },
|
||||||
|
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||||
|
entries: fixtureEntries.map(({ expectedAfterMigration, expectation, ...entry }) => entry),
|
||||||
|
migrations: [],
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = runMigrationQualityCleanup(store, now).store;
|
||||||
|
const byId = new Map(result.entries.map(entry => [entry.id, entry]));
|
||||||
|
|
||||||
|
for (const original of fixtureEntries) {
|
||||||
|
const after = byId.get(original.id);
|
||||||
|
if (!after) {
|
||||||
|
failures.push(`${workspaceName}/${original.id}: missing after migration`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (after.status !== original.expectedAfterMigration) {
|
||||||
|
failures.push(
|
||||||
|
`${workspaceName}/${original.id}: expected ${original.expectedAfterMigration}, got ${after.status}\n` +
|
||||||
|
` text: ${original.text.slice(0, 120)}\n` +
|
||||||
|
` why: ${original.expectation}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(failures.length, 0, `Regression failures:\n${failures.join("\n")}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quality cleanup migration supersedes only hard violations from current fixture", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "wm-quality-cleanup-"));
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await saveWorkspaceMemory(root, {
|
||||||
|
version: 1,
|
||||||
|
workspace: { root, key: await workspaceKey(root) },
|
||||||
|
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||||
|
entries: reviewerCurrent28Fixture,
|
||||||
|
migrations: [],
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaded = await loadWorkspaceMemory(root);
|
||||||
|
const activeIds = new Set(loaded.entries.filter(entry => entry.status === "active").map(entry => entry.id));
|
||||||
|
const supersededIds = new Set(loaded.entries.filter(entry => entry.status === "superseded").map(entry => entry.id));
|
||||||
|
|
||||||
|
for (const entry of reviewerCurrent28Fixture) {
|
||||||
|
const quality = assessMemoryQuality(entry);
|
||||||
|
const hasHardReason = quality.reasons.some(isHardQualityReason);
|
||||||
|
if (entry.source === "compaction" && !quality.accepted && hasHardReason) {
|
||||||
|
assert.equal(supersededIds.has(entry.id), true, `${entry.id} should be superseded`);
|
||||||
|
} else {
|
||||||
|
assert.equal(activeIds.has(entry.id), true, `${entry.id} should remain active`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(loaded.migrations?.includes("2026-04-28-quality-cleanup"));
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quality cleanup migration dedupes tags", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "wm-quality-tags-"));
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await saveWorkspaceMemory(root, {
|
||||||
|
version: 1,
|
||||||
|
workspace: { root, key: await workspaceKey(root) },
|
||||||
|
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||||
|
entries: [{
|
||||||
|
id: "bad_with_tags",
|
||||||
|
type: "feedback",
|
||||||
|
text: "Wave 1 completed successfully and all tests passed",
|
||||||
|
source: "compaction",
|
||||||
|
confidence: 0.75,
|
||||||
|
status: "active",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
tags: ["quality_cleanup", "quality:progress_snapshot"],
|
||||||
|
}],
|
||||||
|
migrations: [],
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaded = await loadWorkspaceMemory(root);
|
||||||
|
const tags = loaded.entries[0].tags ?? [];
|
||||||
|
assert.equal(tags.filter(tag => tag === "quality_cleanup").length, 1);
|
||||||
|
assert.equal(tags.filter(tag => tag === "quality:progress_snapshot").length, 1);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quality cleanup migration does not supersede explicit memories", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "wm-quality-explicit-"));
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const explicitBadShape = {
|
||||||
|
id: "explicit_progress_like",
|
||||||
|
type: "feedback" as const,
|
||||||
|
text: "Wave 1 completed successfully and all tests passed",
|
||||||
|
source: "explicit" as const,
|
||||||
|
confidence: 1,
|
||||||
|
status: "active" as const,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveWorkspaceMemory(root, {
|
||||||
|
version: 1,
|
||||||
|
workspace: { root, key: await workspaceKey(root) },
|
||||||
|
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||||
|
entries: [explicitBadShape],
|
||||||
|
migrations: [],
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaded = await loadWorkspaceMemory(root);
|
||||||
|
assert.equal(loaded.entries[0].status, "active");
|
||||||
|
assert.equal(loaded.entries[0].source, "explicit");
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quality cleanup migration does not supersede manual memories", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "wm-quality-manual-"));
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const manualBadShape = {
|
||||||
|
id: "manual_progress_like",
|
||||||
|
type: "feedback" as const,
|
||||||
|
text: "Wave 1 completed successfully and all tests passed",
|
||||||
|
source: "manual" as const,
|
||||||
|
confidence: 0.9,
|
||||||
|
status: "active" as const,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveWorkspaceMemory(root, {
|
||||||
|
version: 1,
|
||||||
|
workspace: { root, key: await workspaceKey(root) },
|
||||||
|
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||||
|
entries: [manualBadShape],
|
||||||
|
migrations: [],
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaded = await loadWorkspaceMemory(root);
|
||||||
|
assert.equal(loaded.entries[0].status, "active");
|
||||||
|
assert.equal(loaded.entries[0].source, "manual");
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("renderWorkspaceMemory excludes superseded entries", () => {
|
test("renderWorkspaceMemory excludes superseded entries", () => {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const store: WorkspaceMemoryStore = {
|
const store: WorkspaceMemoryStore = {
|
||||||
|
|||||||
Reference in New Issue
Block a user