mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-02 06:19:36 +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
|
||||
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/),
|
||||
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
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -174,6 +174,17 @@ It includes guards for:
|
||||
|
||||
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
|
||||
|
||||
OpenCode Working Memory works out of the box.
|
||||
@@ -210,6 +221,7 @@ cd opencode-working-memory
|
||||
npm install
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run cleanup:workspaces -- --dry-run
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -1,5 +1,105 @@
|
||||
# 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)
|
||||
|
||||
### CI Compatibility Patch
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
@@ -16,7 +16,8 @@
|
||||
"scripts": {
|
||||
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
|
||||
"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"
|
||||
},
|
||||
"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 { appendFile, mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import type { ActiveFile, LongTermMemoryEntry, LongTermType, OpenError } 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 {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -51,7 +56,7 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
|
||||
// 韓文(長詞優先):기억해줘/메모해줘 must come before 기억해/메모해
|
||||
/(?:^|\n)\s*(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[::,,]?\s*(.+)$/gim,
|
||||
// 英文: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
|
||||
/(?:^|\n)\s*(?:please\s+)?(?:save|add)\s+(?:this|that)?\s*(?:to|in)\s+memory[::,,]?\s*(.+)$/gim,
|
||||
// commit to memory
|
||||
@@ -199,7 +204,7 @@ function normalizeCandidateBody(body: string): { text: string; hadTrigger: boole
|
||||
/(?:请|請)?(?:帮我|幫我)?(?:记住|記住|记得|記得|记下来|記下來)(?:这一点|這一點|这点|這點|这个|這個)?[::,,]?\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+)?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.
|
||||
* Rejects low-quality entries like git hashes, error messages, etc.
|
||||
* Acceptance gate for workspace memory candidates.
|
||||
* 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(
|
||||
entry: {
|
||||
type: LongTermType;
|
||||
@@ -245,63 +268,27 @@ function shouldAcceptWorkspaceMemoryCandidate(
|
||||
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
|
||||
// Rejects attempts to overwrite system behavior or "ignore" rules.
|
||||
// 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|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 pathCount = (text.match(/\/[\w.-]+(\/[\w.-]+)+/g) || []).length;
|
||||
if (pathCount > 2) return false;
|
||||
|
||||
// Session-specific progress snapshots for project type
|
||||
if (entry.type === "project") {
|
||||
if (isProjectSnapshotViolation(text)) return false;
|
||||
const quality = assessMemoryQuality({ type: entry.type, text, source: "compaction" });
|
||||
if (!quality.accepted) {
|
||||
void logExtractionRejection({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: entry.type,
|
||||
text: redactCredentials(text),
|
||||
reasons: quality.reasons,
|
||||
source: "compaction",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
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.
|
||||
* 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);
|
||||
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",
|
||||
"",
|
||||
"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:",
|
||||
"Extract only durable facts that will change future behavior: user preferences, decisions with rationale, stable constraints, or hard-to-rediscover references.",
|
||||
"",
|
||||
"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.",
|
||||
"",
|
||||
"When unsure, skip it. Fewer high-signal memories are better than many low-value ones.",
|
||||
"CRITICAL MEMORY RULES:",
|
||||
"- 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.",
|
||||
"- 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.",
|
||||
"- 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:",
|
||||
"- [feedback] User prefers architecture reviews in Traditional Chinese.",
|
||||
"- [decision] Use frozen workspace memory snapshots plus ephemeral hot state for cache stability.",
|
||||
"- [project] The plugin should piggyback memory extraction on OpenCode compaction and avoid extra LLM calls.",
|
||||
"- [reference] Workspace memory appears in frozen system[1]; pending memories appear in hot session state until compaction.",
|
||||
"- [decision] Do not add semantic merge to memory dedupe.",
|
||||
"- [project] This repository is an OpenCode plugin using local JSON stores.",
|
||||
"- [reference] Workspace memory is rendered as frozen system[1]; pending memories remain in hot state until compaction.",
|
||||
"",
|
||||
"Bad memory examples to skip:",
|
||||
"- 42 tests passed.",
|
||||
"- Wave 2 completed successfully.",
|
||||
"- Modified 5 files.",
|
||||
"- commit 4309cb8 contains the latest fix.",
|
||||
"- TypeError: Cannot read properties of undefined.",
|
||||
"- Currently running npm test.",
|
||||
"",
|
||||
"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:",
|
||||
"- 180 tests passed and CI is green.",
|
||||
"- Implemented owner-aware cleanup in plugin.ts.",
|
||||
"- The assistant reviewed code reviewer feedback and updated the plan.",
|
||||
"- Commit a762e86 contains the owner scope fix.",
|
||||
"",
|
||||
"Format when there ARE durable memories:",
|
||||
"Memory candidates:",
|
||||
"- [feedback] content",
|
||||
"- [project] content",
|
||||
"- [decision] content",
|
||||
"- [reference] content",
|
||||
"- [feedback|decision|project|reference] future-facing durable fact",
|
||||
"",
|
||||
"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.",
|
||||
"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 { 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 { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts";
|
||||
import { redactCredentials } from "./redaction.ts";
|
||||
|
||||
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
|
||||
const MIN_ENVELOPE_LENGTH = 80;
|
||||
const MIGRATION_ID = "2026-04-26-p0-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+)`;
|
||||
const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup";
|
||||
|
||||
export type MemoryConsolidationReason =
|
||||
| "promoted"
|
||||
@@ -48,6 +41,21 @@ export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & {
|
||||
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> {
|
||||
return {
|
||||
version: 1,
|
||||
@@ -186,8 +194,26 @@ export async function normalizeWorkspaceMemoryWithAccounting(
|
||||
};
|
||||
});
|
||||
|
||||
// One-time migration for legacy snapshot violations
|
||||
result = runMigrationP0Cleanup(result, nowIso);
|
||||
// One-time migrations for legacy/low-quality snapshot violations.
|
||||
// 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
|
||||
// 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(
|
||||
store: WorkspaceMemoryStore,
|
||||
nowIso: string,
|
||||
@@ -282,10 +248,10 @@ export function runMigrationP0Cleanup(
|
||||
}
|
||||
|
||||
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 (isProjectSnapshotViolation(entry.text)) {
|
||||
if (isProgressSnapshotViolation(entry.text)) {
|
||||
return {
|
||||
...entry,
|
||||
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 {
|
||||
if (source === "explicit") return 3;
|
||||
if (source === "manual") return 2;
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import test from "node:test";
|
||||
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
|
||||
@@ -129,8 +145,6 @@ test("extractExplicitMemories captures multiple memories in same message", () =>
|
||||
// Task 7: Compaction quality gate tests
|
||||
// ============================================
|
||||
|
||||
import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
|
||||
|
||||
test("parseWorkspaceMemoryCandidates rejects short text", () => {
|
||||
const summary = `
|
||||
## Memory Candidates
|
||||
@@ -223,7 +237,7 @@ test("parseWorkspaceMemoryCandidates accepts bracketless candidate format", () =
|
||||
Memory candidates:
|
||||
- project Backend health improvements organized into phased milestones
|
||||
- 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
|
||||
`;
|
||||
|
||||
@@ -281,6 +295,64 @@ Memory candidates:
|
||||
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", () => {
|
||||
const summary = `
|
||||
Memory candidates:
|
||||
@@ -451,14 +523,14 @@ test("parseWorkspaceMemoryCandidates allows benign ignore/instruction wording",
|
||||
Memory candidates:
|
||||
- [project] Use .gitignore to ignore generated files.
|
||||
- [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);
|
||||
|
||||
assert.equal(items.length, 3);
|
||||
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[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", () => {
|
||||
|
||||
+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 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 = [
|
||||
{
|
||||
@@ -64,6 +66,18 @@ const rejectedCases = [
|
||||
name: "temporary pending task",
|
||||
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;
|
||||
|
||||
for (const item of acceptedCases) {
|
||||
@@ -91,3 +105,71 @@ ${item.line}
|
||||
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");
|
||||
assert.equal(prompt!.includes("Bad memory examples to skip:"), true,
|
||||
"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");
|
||||
assert.equal(prompt!.includes("commit 4309cb8"), true,
|
||||
assert.equal(prompt!.includes("Commit a762e86"), true,
|
||||
"Prompt should explicitly reject commit-hash snapshots");
|
||||
|
||||
// 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 () => {
|
||||
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 type { LongTermMemoryEntry, WorkspaceMemoryStore } 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 {
|
||||
renderWorkspaceMemory,
|
||||
enforceLongTermLimits,
|
||||
@@ -14,13 +14,16 @@ import {
|
||||
normalizeWorkspaceMemoryWithAccounting,
|
||||
workspaceMemoryExactKey,
|
||||
workspaceMemoryIdentityKey,
|
||||
redactCredentials,
|
||||
isProjectSnapshotViolation,
|
||||
runMigrationP0Cleanup,
|
||||
runMigrationQualityCleanup,
|
||||
loadWorkspaceMemory,
|
||||
saveWorkspaceMemory,
|
||||
updateWorkspaceMemoryWithAccounting,
|
||||
} 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 {
|
||||
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]");
|
||||
});
|
||||
|
||||
test("isProjectSnapshotViolation detects wave progress and avoids limit context false positives", () => {
|
||||
assert.equal(isProjectSnapshotViolation("1237 tests pass, 226 suites"), true);
|
||||
assert.equal(isProjectSnapshotViolation("USB 同步:37 個文件"), true);
|
||||
assert.equal(isProjectSnapshotViolation("Waves 1-5 已完成,Wave 6 deferred"), true);
|
||||
test("shared progress snapshot rule detects wave progress and avoids limit context false positives", () => {
|
||||
assert.equal(isProgressSnapshotViolation("1237 tests pass, 226 suites"), true);
|
||||
assert.equal(isProgressSnapshotViolation("USB 同步:37 個文件"), true);
|
||||
assert.equal(isProgressSnapshotViolation("Waves 1-5 已完成,Wave 6 deferred"), true);
|
||||
|
||||
assert.equal(isProjectSnapshotViolation("Upload limit is 10 files"), false);
|
||||
assert.equal(isProjectSnapshotViolation("Project supports 5 test suites"), false);
|
||||
assert.equal(isProgressSnapshotViolation("Upload limit is 10 files"), false);
|
||||
assert.equal(isProgressSnapshotViolation("Project supports 5 test suites"), false);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const now = new Date().toISOString();
|
||||
const store: WorkspaceMemoryStore = {
|
||||
|
||||
Reference in New Issue
Block a user