Compare commits

..

19 Commits

Author SHA1 Message Date
Ralph Chang 034dfe8d91 fix: run compatibility CI on Node 24 2026-04-27 22:13:23 +08:00
Ralph Chang e3ebdbfe9f fix: support CI installs without lockfile 2026-04-27 22:04:11 +08:00
Ralph Chang fdebd304f6 chore: prepare v1.3.1 release 2026-04-27 22:00:04 +08:00
Ralph Chang 77d60abf5f refactor: make memory dedupe repo-agnostic 2026-04-27 21:19:42 +08:00
Ralph Chang 560f63f96b docs: note PR 3 security hardening 2026-04-27 20:22:26 +08:00
Ralph Chang 11361abc91 test: cover security hardening edge cases 2026-04-27 20:22:09 +08:00
Ralph Chang e071095422 merge: integrate PR #3 security hardening 2026-04-27 20:14:08 +08:00
Ralph Chang 909d6c7767 docs: document concise compatibility limitations 2026-04-27 19:57:21 +08:00
Ralph Chang c697f63c67 fix: cap and prune pending memory journal 2026-04-27 18:54:44 +08:00
Ralph Chang 25b673fbb7 test: add opencode plugin compatibility checks 2026-04-27 18:54:14 +08:00
Steven Choo acaa829df4 feat: implement indirect prompt injection protection and expanded secret redaction 2026-04-27 12:42:20 +02:00
Ralph Chang fe6ce36e09 docs: prepare v1.3.0 release notes 2026-04-27 17:06:43 +08:00
Ralph Chang 3cc6dff7ae feat: add consolidation accounting for workspace memory promotion
P0 implementation with four waves:

Wave 1: Dedup with accounting
- Add dedupeLongTermEntriesWithAccounting()
- Classify exact duplicate, identity duplicate, topic duplicate

Wave 2: Normalization with accounting
- Add normalizeWorkspaceMemoryWithAccounting()
- Chain redaction → migration → enforceLongTermLimitsWithAccounting

Wave 3: Promotion accounting integration
- Update accountPendingPromotions() to use new accounting API
- Add supersededKeys to classification
- Distinguish promoted / absorbed / superseded / rejected

Wave 4: Integration tests
- End-to-end tests covering full pipeline

Bug fixes:
- Fix active vs superseded boundary (superseded entries no longer block promotion)
- Remove unused rejected_duplicate_lower_quality type
- Defer pending journal safety cap (TODO added)

Tests: 135 passing (up from 115)
2026-04-27 16:45:55 +08:00
Ralph Chang 1c748f3ee2 chore: ignore superpowers plans and update architecture doc
- Add docs/superpowers/plans/ to .gitignore
- Remove tracked plan files from git
- Update docs/architecture.md:
  - Change primary extraction format from XML to 'Memory candidates:'
  - Mark XML format as legacy/deprecated
  - Fix hot session state injection example
2026-04-27 14:53:07 +08:00
Ralph Chang ca68b7f55c feat: sharpen compaction memory extraction prompt
Wave 3 of memory quality optimization plan.

- Add good memory examples in buildCompactionPrompt()
- Add bad memory examples to skip (test counts, commit hashes, etc.)
- Add prompt assertions in tests to prevent regression
- Emphasize 'useful if a new agent opens this workspace next week'
2026-04-27 14:40:32 +08:00
Ralph Chang 023589a905 test: add memory quality eval fixtures
Wave 2 of memory quality optimization plan.

- 5 accepted cases: durable facts that should be kept
- 7 rejected cases: noise that should be filtered
- Parser-level regression guard (zero API call)
- All cases pass against current extractors.ts
2026-04-27 14:34:53 +08:00
Ralph Chang 24f807fed0 fix: account for absorbed pending memories
- Add workspaceMemoryIdentityKey() to unify dedup/supersession identity semantics
- Add accountPendingPromotions() to distinguish promoted/absorbed/rejected
- Wire promotion accounting into promotePendingMemories()
- Add clearableKeys.size > 0 guard to prevent journal wipe
- Add regression tests for absorbed duplicate, cap-rejected, all-rejected edge cases

Wave 1 of memory quality optimization plan.
2026-04-27 14:27:43 +08:00
Ralph Chang 097235e43b docs: add memory quality optimization implementation plan
P0 implementation plan with 3 waves:
- Wave 1: Promotion accounting (fix absorbed duplicate data loss)
- Wave 2: Memory quality eval (fixture-based regression guard)
- Wave 3: Compaction prompt negative examples

Addresses architecture review feedback:
- Distinguish promoted/absorbed/rejected pending memories
- Add clearableKeys.size > 0 guard to prevent journal wipe
- Add regression tests for all edge cases
2026-04-27 14:16:46 +08:00
sdwolf4103 14bbb76cf1 Fix formatting in README.md 2026-04-27 13:00:26 +08:00
29 changed files with 280 additions and 3280 deletions
-4
View File
@@ -51,7 +51,3 @@ 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
-33
View File
@@ -5,39 +5,6 @@ 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
- Added atomic cross-process storage writes with stale-lock recovery and heartbeat refresh to prevent concurrent memory-file corruption.
- Scoped pending-memory promotion by owner/session so global unowned cleanup no longer removes active owned entries.
- Retained source-aware pending memories until they are actually promoted, absorbed, superseded, or rejected.
- Persisted load-time security redaction and expanded Bearer-token redaction to reduce secret retention risk.
- Hardened workspace normalization, cache bounds, rejected-entry retention, and session cleanup behavior.
## [1.3.2] - 2026-04-27
### Fixed
-12
View File
@@ -174,17 +174,6 @@ 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.
@@ -221,7 +210,6 @@ cd opencode-working-memory
npm install
npm test
npm run typecheck
npm run cleanup:workspaces -- --dry-run
```
## Requirements
-100
View File
@@ -1,105 +1,5 @@
# 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
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "opencode-working-memory",
"version": "1.4.0",
"version": "1.3.2",
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
"type": "module",
"main": "index.ts",
@@ -16,8 +16,7 @@
"scripts": {
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
"typecheck": "tsc --noEmit",
"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",
"test": "node --test --experimental-strip-types tests/*.test.ts",
"check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test"
},
"keywords": [
-100
View File
@@ -1,100 +0,0 @@
#!/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.");
}
-50
View File
@@ -1,50 +0,0 @@
/**
* 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(",")}`);
}
+50 -37
View File
@@ -1,11 +1,6 @@
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)}`;
@@ -56,7 +51,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
@@ -204,7 +199,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,
];
@@ -228,27 +223,9 @@ function extractFirstPath(text: string): string | undefined {
}
/**
* Acceptance gate for workspace memory candidates.
* Keeps extraction-specific checks local and delegates memory quality rules to memory-quality.ts.
* Quality gate for workspace memory candidates.
* Rejects low-quality entries like git hashes, error messages, etc.
*/
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;
@@ -268,27 +245,63 @@ 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;
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;
// 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;
}
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.
-124
View File
@@ -1,124 +0,0 @@
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;
}
-8
View File
@@ -28,11 +28,3 @@ 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 -83
View File
@@ -1,5 +1,4 @@
import type { LongTermMemoryEntry, PendingMemoryJournalStore } from "./types.ts";
import { PROMOTION_RETRY_LIMITS } from "./types.ts";
import { workspaceKey, workspacePendingJournalPath } from "./paths.ts";
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
@@ -43,7 +42,7 @@ function dedupeByText(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
const result: LongTermMemoryEntry[] = [];
for (const entry of entries) {
const key = `${memoryKey(entry)}\u0000${entry.pendingOwnerSessionID ?? ""}`;
const key = memoryKey(entry);
if (seen.has(key)) continue;
seen.add(key);
result.push(entry);
@@ -68,15 +67,14 @@ function entryTime(entry: LongTermMemoryEntry): number {
function isStaleEntry(entry: LongTermMemoryEntry, maxAgeDays: number): boolean {
const time = entryTime(entry);
// Invalid timestamps are corruption safety and apply to every source.
// If timestamp is 0 (both invalid), treat as stale
if (time === 0) return true;
// TTL policy applies only to compaction candidates. Explicit/manual entries
// represent user intent and should survive age while under the hard cap.
if (entry.source !== "compaction") return false;
return Date.now() - time > maxAgeDays * 86_400_000;
const ageMs = Date.now() - time;
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
return ageMs > maxAgeMs;
}
function applyRetention(
@@ -84,18 +82,23 @@ function applyRetention(
maxEntries: number,
maxAgeDays: number,
): LongTermMemoryEntry[] {
// 1. Dedupe first
const deduped = dedupeByText(entries);
// 2. Remove stale entries
const freshEntries = deduped.filter(entry => !isStaleEntry(entry, maxAgeDays));
// 3. Sort by entryTime descending (newest first) for cap, using updatedAt then createdAt
const sorted = [...freshEntries].sort((a, b) => {
const timeDiff = entryTime(b) - entryTime(a);
if (timeDiff !== 0) return timeDiff;
return a.id.localeCompare(b.id);
return entryTime(b) - entryTime(a);
});
// 4. Keep maxEntries newest
const capped = sorted.slice(0, maxEntries);
// 5. Restore stable order (oldest-to-newest) for consistency with existing code
return capped.sort((a, b) => {
const timeDiff = entryTime(a) - entryTime(b);
if (timeDiff !== 0) return timeDiff;
return a.id.localeCompare(b.id);
return entryTime(a) - entryTime(b);
});
}
@@ -156,78 +159,13 @@ export async function hasPendingJournalEntries(root: string): Promise<boolean> {
return journal.entries.length > 0;
}
export async function clearPendingMemories(
root: string,
keys?: Set<string>,
options: { ownerSessionID?: string; clearUnowned?: boolean } = {},
): Promise<void> {
export async function clearPendingMemories(root: string, keys?: Set<string>): Promise<void> {
await updatePendingJournal(root, store => {
if (!keys || keys.size === 0) {
store.entries = [];
return store;
}
store.entries = store.entries.filter(entry => {
if (!keys.has(memoryKey(entry))) return true;
if (options.ownerSessionID) {
if (entry.pendingOwnerSessionID === options.ownerSessionID) return false;
if (options.clearUnowned && !entry.pendingOwnerSessionID) return false;
return true;
}
if (options.clearUnowned) {
return Boolean(entry.pendingOwnerSessionID);
}
return false;
});
store.entries = store.entries.filter(entry => !keys.has(memoryKey(entry)));
return store;
});
}
export async function recordPromotionRejections(
root: string,
keys: Set<string>,
reason: string,
options: { ownerSessionID?: string; includeUnownedOnly?: boolean } = {},
): Promise<Set<string>> {
const exhausted = new Set<string>();
if (keys.size === 0) return exhausted;
await updatePendingJournal(root, store => {
const nowIso = new Date().toISOString();
const exhaustedEntries = new Set<string>();
store.entries = store.entries.map(entry => {
const key = memoryKey(entry);
if (!keys.has(key)) return entry;
if (options.ownerSessionID && entry.pendingOwnerSessionID !== options.ownerSessionID) return entry;
if (!options.ownerSessionID && options.includeUnownedOnly && entry.pendingOwnerSessionID) return entry;
const promotionAttempts = (entry.promotionAttempts ?? 0) + 1;
const max = entry.source === "manual"
? PROMOTION_RETRY_LIMITS.maxManualAttempts
: PROMOTION_RETRY_LIMITS.maxExplicitAttempts;
if (promotionAttempts >= max) {
exhausted.add(key);
exhaustedEntries.add(`${key}\u0000${entry.pendingOwnerSessionID ?? ""}`);
}
return {
...entry,
promotionAttempts,
lastPromotionAttemptAt: nowIso,
lastPromotionFailureReason: reason,
};
});
store.entries = store.entries.filter(entry => (
!exhaustedEntries.has(`${memoryKey(entry)}\u0000${entry.pendingOwnerSessionID ?? ""}`)
));
return store;
});
return exhausted;
}
+49 -143
View File
@@ -43,7 +43,6 @@ import {
hasPendingJournalEntries,
loadPendingJournal,
memoryKey,
recordPromotionRejections,
} from "./pending-journal.ts";
import {
loadSessionState,
@@ -62,7 +61,6 @@ import {
pendingTodos,
} from "./opencode.ts";
import { accountPendingPromotions } from "./promotion-accounting.ts";
import { WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts";
/**
* Build the complete compaction prompt.
@@ -108,37 +106,45 @@ function buildCompactionPrompt(privateContext: string): string {
"",
"## Relevant Files",
"",
"At the end of the summary, include a Memory candidates section only if there are durable facts that will change future behavior.",
"At the end of the summary, extract durable memory entries for future sessions.",
"",
"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.",
"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.",
"",
"Good memory examples:",
"- [feedback] User prefers architecture reviews in Traditional Chinese.",
"- [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.",
"- [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.",
"",
"Bad memory examples to skip:",
"- 42 tests passed.",
"- Wave 2 completed successfully.",
"- 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.",
"- Modified 5 files.",
"- commit 4309cb8 contains the latest fix.",
"- TypeError: Cannot read properties of undefined.",
"- Currently running npm test.",
"",
"Format when there ARE durable memories:",
"Memory candidates:",
"- [feedback|decision|project|reference] future-facing durable fact",
"A memory should still be useful if a new agent opens this workspace next week.",
"",
"Only extract facts that are likely to stay true across sessions.",
"Do not extract session-specific progress like exact test counts, file counts, or phase numbers.",
"For progress, extract the stable goal or durable milestone, not the current number.",
"For references, extract configuration values that do not usually change between sessions.",
"For feedback, extract unresolved issues or user preferences that future sessions need to know.",
"Use exactly this candidate format, including square brackets around the type:",
"",
"Format when there are NO durable memories:",
"Memory candidates:",
"(none)",
"- [feedback] content",
"- [project] content",
"- [decision] content",
"- [reference] content",
"",
"Do not write '- project content'; write '- [project] content'.",
"",
"Background context, use this to inform the summary above.",
"Do not output this context verbatim:",
@@ -197,67 +203,13 @@ export const MemoryV2Plugin: Plugin = async (input) => {
// Cache for processed user message IDs (to avoid duplicate processing)
const processedUserMessages = new Map<string, Set<string>>();
function pruneFrozenWorkspaceMemoryCache(now = Date.now()): void {
for (const [sessionID, cached] of frozenWorkspaceMemoryCache) {
if (now - cached.loadedAt > WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs) {
frozenWorkspaceMemoryCache.delete(sessionID);
}
}
while (frozenWorkspaceMemoryCache.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxFrozenSessions) {
const oldest = [...frozenWorkspaceMemoryCache.entries()]
.sort((a, b) => a[1].loadedAt - b[1].loadedAt)[0]?.[0];
if (!oldest) break;
frozenWorkspaceMemoryCache.delete(oldest);
}
}
function pruneProcessedUserMessagesCache(): void {
for (const [sessionID, messages] of processedUserMessages) {
while (messages.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedMessagesPerSession) {
const oldest = messages.values().next().value as string | undefined;
if (!oldest) break;
messages.delete(oldest);
}
if (messages.size === 0) {
processedUserMessages.delete(sessionID);
}
}
while (processedUserMessages.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedSessionIDs) {
const oldestSessionID = processedUserMessages.keys().next().value as string | undefined;
if (!oldestSessionID) break;
processedUserMessages.delete(oldestSessionID);
}
}
function rememberProcessedUserMessage(sessionID: string, messageID: string, processedForSession: Set<string>): void {
processedForSession.add(messageID);
while (processedForSession.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedMessagesPerSession) {
const oldest = processedForSession.values().next().value as string | undefined;
if (!oldest) break;
processedForSession.delete(oldest);
}
if (processedUserMessages.has(sessionID)) {
processedUserMessages.delete(sessionID);
}
processedUserMessages.set(sessionID, processedForSession);
pruneProcessedUserMessagesCache();
}
async function processLatestUserMessage(sessionID: string): Promise<void> {
const processedForSession = processedUserMessages.get(sessionID) ?? new Set<string>();
const latestMessage = await latestUserText(client, sessionID);
if (!latestMessage?.id || processedForSession.has(latestMessage.id)) return;
const memories = extractExplicitMemories(latestMessage.text).map(memory => ({
...memory,
pendingOwnerSessionID: sessionID,
pendingMessageID: latestMessage.id,
}));
const memories = extractExplicitMemories(latestMessage.text);
const decisions = memories.filter(memory => memory.type === "decision");
if (memories.length > 0) {
@@ -281,29 +233,19 @@ export const MemoryV2Plugin: Plugin = async (input) => {
});
}
rememberProcessedUserMessage(sessionID, latestMessage.id, processedForSession);
processedForSession.add(latestMessage.id);
processedUserMessages.set(sessionID, processedForSession);
}
async function promotePendingMemories(
sessionID?: string,
options: { includeUnownedJournal?: boolean; includeOwnedJournal?: boolean } = {},
): Promise<void> {
const includeUnownedJournal = options.includeUnownedJournal ?? !sessionID;
const includeOwnedJournal = options.includeOwnedJournal ?? Boolean(sessionID);
async function promotePendingMemories(sessionID?: string): Promise<void> {
const [journal, sessionState] = await Promise.all([
loadPendingJournal(directory),
sessionID ? loadSessionState(directory, sessionID) : Promise.resolve(undefined),
]);
const journalPending = journal.entries.filter(memory => {
if (sessionID && includeOwnedJournal && memory.pendingOwnerSessionID === sessionID) return true;
if (includeUnownedJournal && !memory.pendingOwnerSessionID) return true;
return false;
});
const pending = [
...(sessionState?.pendingMemories ?? []),
...journalPending,
...journal.entries,
];
if (pending.length === 0) return;
@@ -335,42 +277,16 @@ export const MemoryV2Plugin: Plugin = async (input) => {
events: updateResult.events,
});
const exhaustedRejectedKeys = await recordPromotionRejections(
directory,
accounting.retryableRejectedKeys,
"rejected_capacity",
{
ownerSessionID: sessionID,
includeUnownedOnly: !sessionID,
},
);
const sessionRemovalKeys = new Set([
...accounting.clearableKeys,
...exhaustedRejectedKeys,
]);
if (sessionID) {
await updateSessionState(directory, sessionID, state => {
state.pendingMemories = state.pendingMemories.filter(memory => {
const key = memoryKey(memory);
if (!sessionRemovalKeys.has(key)) return true;
if (accounting.clearableKeys.has(key)) return false;
if (exhaustedRejectedKeys.has(key)) return false;
return true;
});
state.pendingMemories = state.pendingMemories.filter(memory => !accounting.clearableKeys.has(memoryKey(memory)));
return state;
});
clearFrozenWorkspaceMemoryCache(sessionID);
}
if (accounting.clearableKeys.size > 0) {
await clearPendingMemories(directory, accounting.clearableKeys, {
ownerSessionID: sessionID,
clearUnowned: !sessionID || includeUnownedJournal === true,
});
await clearPendingMemories(directory, accounting.clearableKeys);
}
}
@@ -408,7 +324,6 @@ export const MemoryV2Plugin: Plugin = async (input) => {
renderedPrompt: string;
}> {
const now = Date.now();
pruneFrozenWorkspaceMemoryCache(now);
const cached = frozenWorkspaceMemoryCache.get(sessionID);
// Cache is valid for the current session cache epoch.
@@ -421,7 +336,6 @@ export const MemoryV2Plugin: Plugin = async (input) => {
const store = await loadWorkspaceMemory(root);
const renderedPrompt = renderWorkspaceMemory(store);
frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: now });
pruneFrozenWorkspaceMemoryCache(now);
return { store, renderedPrompt };
}
@@ -443,23 +357,19 @@ export const MemoryV2Plugin: Plugin = async (input) => {
const { sessionID } = hookInput;
if (!sessionID) return;
pruneFrozenWorkspaceMemoryCache();
pruneProcessedUserMessagesCache();
// Sub-agents are short-lived - skip memory system
if (await isSubAgent(sessionID)) return;
// Process explicit user memory even on no-tool turns. Keep this after the
// sub-agent guard so child sessions never append to the parent journal.
await processLatestUserMessage(sessionID);
// Before first snapshot in this session, promote durable unowned backlog from
// prior sessions. Current-turn owned explicit memory remains pending and only
// appears in hot state for this transform.
// Before first snapshot in this session, promote durable pending memories from
// prior sessions. Keep this before processing latest user text so current-turn
// explicit memory remains pending (not immediately frozen into system[1]).
if (!frozenWorkspaceMemoryCache.has(sessionID) && await hasPendingJournalEntries(directory)) {
await promotePendingMemories(undefined, { includeUnownedJournal: true, includeOwnedJournal: false });
await promotePendingMemories();
}
// Process explicit user memory even on no-tool turns.
await processLatestUserMessage(sessionID);
// Get frozen workspace memory snapshot (loaded and rendered once per session)
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
@@ -611,7 +521,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
}
try {
await promotePendingMemories(sessionID, { includeUnownedJournal: true });
await promotePendingMemories(sessionID);
} catch {
// Keep pending memories in session/journal for retry on next event/session.
}
@@ -622,20 +532,16 @@ export const MemoryV2Plugin: Plugin = async (input) => {
if (sessionID) {
// Promote pending memories before deleting per-session state.
// If promotion fails, leave session state and journal intact.
let promoted = false;
try {
await promotePendingMemories(sessionID, { includeOwnedJournal: true, includeUnownedJournal: false });
promoted = true;
await promotePendingMemories(sessionID);
} catch {
return;
} finally {
if (promoted) {
frozenWorkspaceMemoryCache.delete(sessionID);
processedUserMessages.delete(sessionID);
sessionParentCache.delete(sessionID);
}
}
// Clean up caches
frozenWorkspaceMemoryCache.delete(sessionID);
processedUserMessages.delete(sessionID);
sessionParentCache.delete(sessionID);
await rm(await sessionStatePath(directory, sessionID), { force: true });
}
}
+14 -29
View File
@@ -8,7 +8,6 @@ export type PendingPromotionAccounting = {
absorbedKeys: Set<string>;
supersededKeys: Set<string>;
rejectedKeys: Set<string>;
retryableRejectedKeys: Set<string>;
clearableKeys: Set<string>;
};
@@ -73,38 +72,24 @@ export function accountPendingPromotions(input: {
rejectedKeys.add(key);
}
const clearableKeys = new Set([
...promotedKeys,
...absorbedKeys,
...supersededKeys,
...input.pending
.filter(memory => {
const terminal = terminalEventByKey.get(memoryKey(memory));
return memory.source === "compaction" && (
terminal?.reason === "rejected_capacity" ||
terminal?.reason === "rejected_stale"
);
})
.map(memory => memoryKey(memory)),
]);
const retryableRejectedKeys = new Set(
input.pending
.filter(memory => {
const key = memoryKey(memory);
return rejectedKeys.has(key) &&
!clearableKeys.has(key) &&
(memory.source === "explicit" || memory.source === "manual");
})
.map(memory => memoryKey(memory)),
);
return {
promotedKeys,
absorbedKeys,
supersededKeys,
rejectedKeys,
retryableRejectedKeys,
clearableKeys,
clearableKeys: new Set([
...promotedKeys,
...absorbedKeys,
...supersededKeys,
...input.pending
.filter(memory => {
const terminal = terminalEventByKey.get(memoryKey(memory));
return memory.source === "compaction" && (
terminal?.reason === "rejected_capacity" ||
terminal?.reason === "rejected_stale"
);
})
.map(memory => memoryKey(memory)),
]),
};
}
-73
View File
@@ -1,73 +0,0 @@
/**
* 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;
}
+5 -87
View File
@@ -1,13 +1,9 @@
import { existsSync } from "fs";
import { randomUUID } from "crypto";
import { mkdir, open, readFile, rename, rm, stat, writeFile } from "fs/promises";
import type { FileHandle } from "fs/promises";
import { mkdir, readFile, rename, writeFile } from "fs/promises";
import { dirname } from "path";
const fileLocks = new Map<string, Promise<unknown>>();
const LOCK_WAIT_TIMEOUT_MS = 5000;
const LOCK_STALE_MS = 30_000;
const LOCK_HEARTBEAT_MS = 1_000;
export async function readJSON<T>(path: string, fallback: () => T): Promise<T> {
if (!existsSync(path)) return fallback();
@@ -18,82 +14,6 @@ export async function readJSON<T>(path: string, fallback: () => T): Promise<T> {
}
}
async function readJSONStrict<T>(path: string, fallback: () => T): Promise<T> {
if (!existsSync(path)) return fallback();
try {
return JSON.parse(await readFile(path, "utf8")) as T;
} catch (error) {
throw new Error(`Invalid JSON in ${path}: ${(error as Error).message}`);
}
}
async function isLockStale(lockPath: string, now = Date.now()): Promise<boolean> {
try {
const stats = await stat(lockPath);
if (now - stats.mtimeMs > LOCK_STALE_MS) return true;
const content = await readFile(lockPath, "utf8");
const [, createdText] = content.split("\n");
const createdAt = Number(createdText);
return Number.isFinite(createdAt) && now - createdAt > LOCK_STALE_MS;
} catch (error) {
return (error as NodeJS.ErrnoException).code !== "ENOENT";
}
}
async function writeLockInfo(handle: FileHandle): Promise<void> {
const content = `${process.pid}\n${Date.now()}\n`;
await handle.truncate(0);
await handle.write(content, 0, "utf8");
}
async function withFileLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
const lockPath = `${path}.lock`;
await mkdir(dirname(path), { recursive: true });
const started = Date.now();
while (true) {
try {
const handle = await open(lockPath, "wx", 0o600);
let heartbeat: NodeJS.Timeout | undefined;
let heartbeatWrite: Promise<void> = Promise.resolve();
const queueHeartbeat = (): void => {
heartbeatWrite = heartbeatWrite
.catch(() => undefined)
.then(() => writeLockInfo(handle))
.catch(() => undefined);
};
try {
await writeLockInfo(handle);
heartbeat = setInterval(queueHeartbeat, LOCK_HEARTBEAT_MS);
return await fn();
} finally {
if (heartbeat) clearInterval(heartbeat);
await heartbeatWrite.catch(() => undefined);
await handle.close();
await rm(lockPath, { force: true });
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "EEXIST") throw error;
if (await isLockStale(lockPath)) {
await rm(lockPath, { force: true });
continue;
}
if (Date.now() - started > LOCK_WAIT_TIMEOUT_MS) {
throw new Error(`Timed out waiting for lock ${lockPath}`);
}
await new Promise(resolve => setTimeout(resolve, 25));
}
}
}
export async function atomicWriteJSON(path: string, data: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const tmp = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
@@ -116,12 +36,10 @@ export async function updateJSON<T>(
try {
await previous.catch(() => undefined);
return await withFileLock(path, async () => {
const current = await readJSONStrict(path, fallback);
const updated = await updater(current);
await atomicWriteJSON(path, updated);
return updated;
});
const current = await readJSON(path, fallback);
const updated = await updater(current);
await atomicWriteJSON(path, updated);
return updated;
} finally {
release();
if (fileLocks.get(path) === queued) {
-17
View File
@@ -15,11 +15,6 @@ export type LongTermMemoryEntry = {
staleAfterDays?: number;
supersedes?: string[];
tags?: string[];
pendingOwnerSessionID?: string;
pendingMessageID?: string;
promotionAttempts?: number;
lastPromotionAttemptAt?: string;
lastPromotionFailureReason?: string;
};
export type WorkspaceMemoryStore = {
@@ -105,15 +100,3 @@ export const HOT_STATE_LIMITS = {
maxPendingMemoriesStored: 12,
maxPendingMemoriesRendered: 6,
} as const;
export const PROMOTION_RETRY_LIMITS = {
maxExplicitAttempts: 3,
maxManualAttempts: 3,
} as const;
export const WORKSPACE_MEMORY_CACHE_LIMITS = {
maxFrozenSessions: 50,
maxProcessedSessionIDs: 200,
maxProcessedMessagesPerSession: 50,
frozenTtlMs: 60 * 60 * 1000,
} as const;
-282
View File
@@ -1,282 +0,0 @@
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 };
}
+94 -134
View File
@@ -1,16 +1,22 @@
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 { migrationLogPath, workspaceKey, workspaceMemoryPath } from "./paths.ts";
import { 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 QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup";
const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s\[]+`;
const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i;
const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i;
const SENSITIVE_LABELS = /api[_-]?key|token|bearer|secret|credential|auth|auth[_-]?key|private[_-]?key/i;
const PIN_PREFIX = String.raw`(\bPIN\b(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
const PASSWORD_PREFIX = String.raw`((?:${PASSWORD_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
const USERNAME_PREFIX = String.raw`((?:${USERNAME_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
const SENSITIVE_PREFIX = String.raw`((?:${SENSITIVE_LABELS.source})(?:\s*(?:推|是|=|:|:)\s*|[::]\s*))`;
export type MemoryConsolidationReason =
| "promoted"
@@ -41,21 +47,6 @@ 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,
@@ -88,33 +79,30 @@ export async function loadWorkspaceMemory(root: string): Promise<WorkspaceMemory
};
// Always normalize on load so redaction/migrations are always-on.
const normalized = await normalizeWorkspaceMemoryWithAccounting(root, store);
const normalized = await normalizeWorkspaceMemory(root, store);
// Persist security/correctness mutations, but avoid read-time maintenance
// writes for ordering/capacity/timestamp-only normalization.
if (hasSecurityOrMigrationChange(store, normalized.store)) {
await atomicWriteJSON(path, normalized.store);
// Persist only when meaningful content changed (ignore timestamps).
if (didStoreMeaningfullyChange(store, normalized)) {
await atomicWriteJSON(path, normalized);
}
return normalized.store;
return normalized;
}
function hasSecurityOrMigrationChange(
function didStoreMeaningfullyChange(
before: WorkspaceMemoryStore,
after: WorkspaceMemoryStore,
): boolean {
const beforeById = new Map((before.entries ?? []).map(entry => [entry.id, entry]));
for (const afterEntry of after.entries ?? []) {
const beforeEntry = beforeById.get(afterEntry.id);
if (!beforeEntry) continue;
if (beforeEntry.text !== afterEntry.text) return true;
if ((beforeEntry.rationale ?? "") !== (afterEntry.rationale ?? "")) return true;
if (beforeEntry.status !== afterEntry.status) return true;
}
const sanitize = (store: WorkspaceMemoryStore) => ({
...store,
updatedAt: "",
entries: store.entries.map(entry => ({
...entry,
updatedAt: "",
})),
});
const beforeMigrations = JSON.stringify(before.migrations ?? []);
const afterMigrations = JSON.stringify(after.migrations ?? []);
return beforeMigrations !== afterMigrations;
return JSON.stringify(sanitize(before)) !== JSON.stringify(sanitize(after));
}
export async function saveWorkspaceMemory(root: string, store: WorkspaceMemoryStore): Promise<void> {
@@ -194,26 +182,8 @@ export async function normalizeWorkspaceMemoryWithAccounting(
};
});
// 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);
}
// One-time migration for legacy snapshot violations
result = runMigrationP0Cleanup(result, nowIso);
// P0 accounting only considers active entries. Entries that were already
// superseded before this normalization are preserved in storage; entries that
@@ -239,6 +209,61 @@ 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`${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,
@@ -248,10 +273,10 @@ export function runMigrationP0Cleanup(
}
const entries = store.entries.map(entry => {
if (entry.source !== "compaction") return entry;
if (entry.source === "explicit") return entry;
if (entry.type !== "project") return entry;
if (isProgressSnapshotViolation(entry.text)) {
if (isProjectSnapshotViolation(entry.text)) {
return {
...entry,
status: "superseded" as const,
@@ -270,74 +295,6 @@ 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;
@@ -599,14 +556,12 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
}
function compareLongTermMemoryForRetention(a: LongTermMemoryEntry, b: LongTermMemoryEntry): number {
const pA = priority(a);
const pB = priority(b);
const pA = priorityWithFreshness(a);
const pB = priorityWithFreshness(b);
if (pB !== pA) return pB - pA;
const sourceDiff = sourcePriority(b.source) - sourcePriority(a.source);
if (sourceDiff !== 0) return sourceDiff;
const createdDiff = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
if (createdDiff !== 0) return createdDiff;
return a.id.localeCompare(b.id);
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
}
function priority(entry: LongTermMemoryEntry): number {
@@ -621,6 +576,11 @@ function priority(entry: LongTermMemoryEntry): number {
return sourceWeight + typeWeight + entry.confidence * 10;
}
/** Extended priority including freshness for tie-breaking */
function priorityWithFreshness(entry: LongTermMemoryEntry): number {
return priority(entry);
}
function wouldFit(
lines: string[],
nextLine: string,
+6 -78
View File
@@ -1,22 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
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;
}
import { extractErrorsFromBash, extractExplicitMemories } from "../src/extractors.ts";
// ============================================
// Task 1: extractErrorsFromBash tests
@@ -145,6 +129,8 @@ 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
@@ -237,7 +223,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 User prefers Traditional Chinese memory summaries
- feedback 端口 9473 可能被舊進程佔用,需殺掉後重啟
- decision Use output.prompt to replace the default compaction template
`;
@@ -295,64 +281,6 @@ 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:
@@ -523,14 +451,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] Use a frozen workspace snapshot plus hot session state for prompt context.
- [decision] Prompt context uses a frozen workspace snapshot plus hot session state.
`;
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, "Use a frozen workspace snapshot plus hot session state for prompt context.");
assert.equal(items[2].text, "Prompt context uses a frozen workspace snapshot plus hot session state.");
});
test("parseWorkspaceMemoryCandidates rejects direct system prompt override attempts", () => {
-74
View File
@@ -1,74 +0,0 @@
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
View File
@@ -1,67 +0,0 @@
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 -83
View File
@@ -1,8 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
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";
import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
const acceptedCases = [
{
@@ -66,18 +64,6 @@ 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) {
@@ -105,71 +91,3 @@ ${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);
});
+14 -262
View File
@@ -6,20 +6,16 @@
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert";
import { mkdir, mkdtemp as fsMkdtemp, rm } from "fs/promises";
import { mkdir, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import {
loadPendingJournal,
savePendingJournal,
appendPendingMemories,
clearPendingMemories,
recordPromotionRejections,
memoryKey,
PENDING_JOURNAL_LIMITS,
} from "../src/pending-journal.ts";
import type { LongTermMemoryEntry } from "../src/types.ts";
import { PROMOTION_RETRY_LIMITS } from "../src/types.ts";
describe("pending journal retention", () => {
let testDir: string;
@@ -197,38 +193,22 @@ describe("pending journal retention", () => {
);
});
it("retains old explicit and manual pending entries while under cap", async () => {
it("savePendingJournal prunes stale entries regardless of source", async () => {
const now = new Date();
const staleDate = new Date(now.getTime() - 35 * 24 * 60 * 60 * 1000);
const entries: LongTermMemoryEntry[] = [
{
id: "explicit_old",
type: "feedback",
text: "Old explicit preference",
type: "decision",
text: "Stale explicit entry",
source: "explicit",
confidence: 1,
status: "active",
createdAt: staleDate.toISOString(),
updatedAt: staleDate.toISOString(),
},
{
id: "manual_old",
type: "reference",
text: "Old manual reference",
source: "manual",
confidence: 1,
status: "active",
createdAt: staleDate.toISOString(),
updatedAt: staleDate.toISOString(),
},
{
id: "compaction_old",
type: "reference",
text: "Old compaction reference",
type: "decision",
text: "Stale compaction entry",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: staleDate.toISOString(),
updatedAt: staleDate.toISOString(),
},
@@ -243,241 +223,13 @@ describe("pending journal retention", () => {
const loaded = await loadPendingJournal(testDir);
assert.deepEqual(loaded.entries.map(entry => entry.id), ["explicit_old", "manual_old"]);
});
it("clears only entries matching both key and owner when owner is supplied", async () => {
const now = new Date().toISOString();
await appendPendingMemories(testDir, [
{
id: "a",
type: "feedback",
text: "Session A preference",
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
pendingOwnerSessionID: "session-a",
},
{
id: "b",
type: "feedback",
text: "Session B preference",
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
pendingOwnerSessionID: "session-b",
},
]);
await clearPendingMemories(
testDir,
new Set(["feedback:session a preference", "feedback:session b preference"]),
{ ownerSessionID: "session-a" },
// Both explicit and compaction entries past maxAgeDays are pruned
// Retention does not differentiate by source
assert.strictEqual(
loaded.entries.length,
0,
"Stale entries should be pruned regardless of source"
);
const loaded = await loadPendingJournal(testDir);
assert.deepEqual(loaded.entries.map(entry => entry.pendingOwnerSessionID), ["session-b"]);
});
it("global unowned clear keeps owned entries with the same key", async () => {
const now = new Date().toISOString();
const unowned: LongTermMemoryEntry = {
id: "clear-unowned",
type: "feedback",
text: "Prefer scoped cleanup.",
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
};
const owned: LongTermMemoryEntry = {
...unowned,
id: "clear-owned",
pendingOwnerSessionID: "session-owned",
};
await appendPendingMemories(testDir, [unowned, owned]);
await clearPendingMemories(testDir, new Set([memoryKey(unowned)]), {
clearUnowned: true,
});
const loaded = await loadPendingJournal(testDir);
assert.deepEqual(loaded.entries.map(entry => entry.id), ["clear-owned"]);
assert.equal(loaded.entries[0].pendingOwnerSessionID, "session-owned");
});
it("retains same-key pending entries owned by different sessions", async () => {
const now = new Date().toISOString();
await appendPendingMemories(testDir, [
{
id: "same-a",
type: "feedback",
text: "Prefer owner-scoped promotion.",
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
pendingOwnerSessionID: "session-a",
},
{
id: "same-b",
type: "feedback",
text: "Prefer owner-scoped promotion.",
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
pendingOwnerSessionID: "session-b",
},
]);
const loaded = await loadPendingJournal(testDir);
assert.deepEqual(
loaded.entries.map(entry => entry.pendingOwnerSessionID).sort(),
["session-a", "session-b"],
"same memory key must remain separately retryable/clearable per owner",
);
});
it("records bounded promotion rejection attempts and exhausts only matching owner", async () => {
const now = new Date().toISOString();
const sessionA: LongTermMemoryEntry = {
id: "reject-a",
type: "reference",
text: "Capacity rejected explicit reference.",
source: "explicit",
confidence: 0.1,
status: "active",
createdAt: now,
updatedAt: now,
pendingOwnerSessionID: "session-a",
};
const sessionB: LongTermMemoryEntry = {
...sessionA,
id: "reject-b",
pendingOwnerSessionID: "session-b",
};
await appendPendingMemories(testDir, [sessionA, sessionB]);
for (let attempt = 1; attempt < PROMOTION_RETRY_LIMITS.maxExplicitAttempts; attempt += 1) {
const exhausted = await recordPromotionRejections(
testDir,
new Set([memoryKey(sessionA)]),
"rejected_capacity",
{ ownerSessionID: "session-a" },
);
assert.equal(exhausted.size, 0, "entry should not exhaust before the max attempt");
const loaded = await loadPendingJournal(testDir);
const ownedA = loaded.entries.find(entry => entry.pendingOwnerSessionID === "session-a");
const ownedB = loaded.entries.find(entry => entry.pendingOwnerSessionID === "session-b");
assert.equal(ownedA?.promotionAttempts, attempt);
assert.equal(ownedA?.lastPromotionFailureReason, "rejected_capacity");
assert.equal(ownedB?.promotionAttempts, undefined,
"same-key entry for another owner must not be mutated");
}
const exhausted = await recordPromotionRejections(
testDir,
new Set([memoryKey(sessionA)]),
"rejected_capacity",
{ ownerSessionID: "session-a" },
);
assert.deepEqual([...exhausted], [memoryKey(sessionA)]);
const loaded = await loadPendingJournal(testDir);
assert.deepEqual(loaded.entries.map(entry => entry.pendingOwnerSessionID), ["session-b"]);
});
it("global unowned rejection exhausts only unowned entries with the same key", async () => {
const now = new Date().toISOString();
const unowned: LongTermMemoryEntry = {
id: "reject-unowned",
type: "reference",
text: "Capacity rejected unowned reference.",
source: "explicit",
confidence: 0.1,
status: "active",
createdAt: now,
updatedAt: now,
promotionAttempts: PROMOTION_RETRY_LIMITS.maxExplicitAttempts - 1,
};
const owned: LongTermMemoryEntry = {
...unowned,
id: "reject-owned",
pendingOwnerSessionID: "session-owned",
promotionAttempts: undefined,
};
await appendPendingMemories(testDir, [unowned, owned]);
const exhausted = await recordPromotionRejections(
testDir,
new Set([memoryKey(unowned)]),
"rejected_capacity",
{ includeUnownedOnly: true },
);
assert.deepEqual([...exhausted], [memoryKey(unowned)]);
const loaded = await loadPendingJournal(testDir);
assert.deepEqual(loaded.entries.map(entry => entry.id), ["reject-owned"]);
assert.equal(
loaded.entries[0].promotionAttempts,
undefined,
"owned same-key entry must not be mutated by global unowned rejection",
);
assert.equal(loaded.entries[0].lastPromotionFailureReason, undefined);
});
it("drops invalid timestamp entries for every source as corruption safety", async () => {
await savePendingJournal(testDir, {
version: 1,
workspace: { root: testDir, key: "test" },
updatedAt: new Date().toISOString(),
entries: [
{
id: "bad_explicit",
type: "feedback",
text: "Bad explicit timestamp",
source: "explicit",
confidence: 1,
status: "active",
createdAt: "not-a-date",
updatedAt: "also-bad",
},
{
id: "bad_manual",
type: "reference",
text: "Bad manual timestamp",
source: "manual",
confidence: 1,
status: "active",
createdAt: "",
updatedAt: "",
},
{
id: "bad_compaction",
type: "reference",
text: "Bad compaction timestamp",
source: "compaction",
confidence: 0.75,
status: "active",
createdAt: "bad",
updatedAt: "bad",
},
],
});
const loaded = await loadPendingJournal(testDir);
assert.equal(loaded.entries.length, 0);
});
it("savePendingJournal uses updatedAt when createdAt is missing", async () => {
@@ -523,5 +275,5 @@ describe("pending journal retention", () => {
async function mkdtemp(): Promise<string> {
const base = join(tmpdir(), "pending-journal-test");
await mkdir(base, { recursive: true });
return fsMkdtemp(join(base, "case-"));
}
return base;
}
+14 -506
View File
@@ -7,9 +7,8 @@ import { MemoryV2Plugin } from "../src/plugin.ts";
import { loadSessionState, saveSessionState } from "../src/session-state.ts";
import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
import type { OpenError } from "../src/types.ts";
import { PROMOTION_RETRY_LIMITS, WORKSPACE_MEMORY_CACHE_LIMITS } from "../src/types.ts";
import { workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
import { loadPendingJournal, savePendingJournal, memoryKey } from "../src/pending-journal.ts";
import { loadPendingJournal, savePendingJournal } from "../src/pending-journal.ts";
import { loadWorkspaceMemory, updateWorkspaceMemory } from "../src/workspace-memory.ts";
// Mock client for root session (not a sub-agent)
@@ -297,9 +296,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("180 tests passed"), true,
assert.equal(prompt!.includes("42 tests passed"), true,
"Prompt should explicitly reject test-count snapshots");
assert.equal(prompt!.includes("Commit a762e86"), true,
assert.equal(prompt!.includes("commit 4309cb8"), true,
"Prompt should explicitly reject commit-hash snapshots");
// Should contain our context data (hot session state)
@@ -317,27 +316,6 @@ 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-"));
@@ -450,7 +428,7 @@ test("chat system transform reuses frozen rendered workspace snapshot", async ()
}
});
test("no compaction: owned explicit memory is not promoted by unrelated next session start", async () => {
test("no compaction: explicit memory is promoted on next session start from durable journal", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
@@ -471,113 +449,7 @@ test("no compaction: owned explicit memory is not promoted by unrelated next ses
);
const workspacePrompt = output.system.find((part: string) => part.startsWith("Workspace memory"));
assert.doesNotMatch(workspacePrompt ?? "", /Prefer boring cache boundaries/);
const pending = await loadPendingJournal(tmpDir);
assert.equal(pending.entries.length, 1);
assert.equal(pending.entries[0].pendingOwnerSessionID, "session-without-compaction");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("explicit memory appended from user message is owned by session and not promoted before current snapshot", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const plugin = await MemoryV2Plugin({
directory: tmpDir,
client: mockClientWithLatestUser("remember this: Prefer Traditional Chinese.", "msg-a"),
});
const output = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "session-a", model: {} },
output,
);
const pending = await loadPendingJournal(tmpDir);
assert.equal(pending.entries.length, 1);
assert.equal(pending.entries[0].pendingOwnerSessionID, "session-a");
assert.equal(pending.entries[0].pendingMessageID, "msg-a");
const workspace = await loadWorkspaceMemory(tmpDir);
assert.equal(
workspace.entries.some(entry => /Prefer Traditional Chinese/.test(entry.text)),
false,
"current-turn explicit memory should remain pending until compaction/promotion",
);
assert.match(output.system.join("\n"), /Prefer Traditional Chinese/,
"current-turn explicit memory should still appear in hot session state");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("session promotion does not clear another session's same-key pending journal entry", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const now = new Date().toISOString();
const pendingA = {
id: "mem_same_key_a",
type: "feedback" as const,
text: "Prefer owner-scoped pending cleanup.",
source: "explicit" as const,
confidence: 1,
status: "active" as const,
createdAt: now,
updatedAt: now,
pendingOwnerSessionID: "session-a",
pendingMessageID: "msg-a",
};
const pendingB = {
...pendingA,
id: "mem_same_key_b",
pendingOwnerSessionID: "session-b",
pendingMessageID: "msg-b",
};
await saveSessionState(tmpDir, {
version: 1,
sessionID: "session-a",
turn: 0,
updatedAt: now,
activeFiles: [],
openErrors: [],
recentDecisions: [],
pendingMemories: [pendingA],
});
await saveSessionState(tmpDir, {
version: 1,
sessionID: "session-b",
turn: 0,
updatedAt: now,
activeFiles: [],
openErrors: [],
recentDecisions: [],
pendingMemories: [pendingB],
});
await savePendingJournal(tmpDir, {
version: 1,
workspace: { root: tmpDir, key: "test" },
updatedAt: now,
entries: [pendingA, pendingB],
});
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "session-a" } },
});
const journal = await loadPendingJournal(tmpDir);
assert.equal(journal.entries.length, 1);
assert.equal(journal.entries[0].pendingOwnerSessionID, "session-b",
"session-a promotion must not clear session-b's same-key journal entry");
const stateB = await loadSessionState(tmpDir, "session-b");
assert.equal(stateB.pendingMemories.length, 1);
assert.equal(memoryKey(stateB.pendingMemories[0]), memoryKey(pendingB));
assert.match(workspacePrompt ?? "", /Prefer boring cache boundaries/);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
@@ -616,76 +488,6 @@ test("session.deleted promotes pending memories before deleting session state",
}
});
test("session.deleted clears caches even when session state file is already gone", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const now = new Date().toISOString();
await updateWorkspaceMemory(tmpDir, store => {
store.entries.push({
id: "mem_before_delete_cache",
type: "project",
text: "Workspace memory before delete cleanup.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: now,
updatedAt: now,
});
return store;
});
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
const beforeOutput = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "deleted-missing-state-session", model: {} },
beforeOutput,
);
assert.match(beforeOutput.system.join("\n"), /Workspace memory before delete cleanup/);
const ownedPending = {
id: "mem_delete_owned_journal",
type: "decision" as const,
text: "Owned journal memory promotes during delete cleanup.",
source: "explicit" as const,
confidence: 1,
status: "active" as const,
createdAt: now,
updatedAt: now,
pendingOwnerSessionID: "deleted-missing-state-session",
pendingMessageID: "msg-delete-owned",
};
await savePendingJournal(tmpDir, {
version: 1,
workspace: { root: tmpDir, key: "test" },
updatedAt: now,
entries: [ownedPending],
});
await (plugin as Record<string, Function>)["event"]({
event: {
type: "session.deleted",
properties: { sessionID: "deleted-missing-state-session" },
},
});
const pendingAfter = await loadPendingJournal(tmpDir);
assert.equal(pendingAfter.entries.length, 0,
"clearable owned journal entry should be removed even when session state file is absent");
const afterOutput = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "deleted-missing-state-session", model: {} },
afterOutput,
);
const workspacePrompt = afterOutput.system.find((part: string) => part.startsWith("Workspace memory"));
assert.match(workspacePrompt ?? "", /Owned journal memory promotes during delete cleanup/,
"session.deleted should clear frozen cache after successful promotion");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("duplicate explicit memories dedupe by normalized type and text, not generated id", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
@@ -848,26 +650,18 @@ Continue durable memory work.
}
});
test("integration: next session promotes prior unowned journal and leaves journal clean", async () => {
test("integration: next session promotes prior explicit journal and leaves journal clean", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const now = new Date().toISOString();
await savePendingJournal(tmpDir, {
version: 1,
workspace: { root: tmpDir, key: "test" },
updatedAt: now,
entries: [{
id: "mem_unowned_cross_session",
type: "feedback",
text: "Cross-session unowned promotion keeps the journal clean.",
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
}],
const firstPlugin = await MemoryV2Plugin({
directory: tmpDir,
client: mockClientWithLatestUser("remember this: Cross-session promotion keeps the journal clean.", "msg-cross-session"),
});
await (firstPlugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "first-cross-session", model: {} },
{ system: ["base header"] },
);
assert.equal((await loadPendingJournal(tmpDir)).entries.length, 1);
@@ -879,7 +673,7 @@ test("integration: next session promotes prior unowned journal and leaves journa
);
assert.equal((await loadPendingJournal(tmpDir)).entries.length, 0);
assert.match(output.system.join("\n"), /Cross-session unowned promotion keeps the journal clean/);
assert.match(output.system.join("\n"), /Cross-session promotion keeps the journal clean/);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
@@ -957,209 +751,6 @@ test("same-session explicit memory does not mutate frozen system[1]", async () =
}
});
test("chat system transform reloads frozen workspace snapshot after cache TTL expires", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
const originalNow = Date.now;
let now = originalNow();
Date.now = () => now;
try {
const timestamp = new Date().toISOString();
await updateWorkspaceMemory(tmpDir, store => {
store.entries.push({
id: "mem_cache_ttl_old",
type: "project",
text: "Workspace memory before TTL expiry.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: timestamp,
updatedAt: timestamp,
});
return store;
});
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
const output1 = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "ttl-session", model: {} },
output1,
);
assert.match(output1.system.join("\n"), /Workspace memory before TTL expiry/);
await updateWorkspaceMemory(tmpDir, store => {
store.entries.push({
id: "mem_cache_ttl_new",
type: "project",
text: "Workspace memory after TTL expiry.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: timestamp,
updatedAt: timestamp,
});
return store;
});
now += WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs + 1;
const output2 = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "ttl-session", model: {} },
output2,
);
assert.match(output2.system.join("\n"), /Workspace memory after TTL expiry/);
} finally {
Date.now = originalNow;
await rm(tmpDir, { recursive: true, force: true });
}
});
test("chat system transform evicts oldest frozen snapshots when cache exceeds session limit", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const timestamp = new Date().toISOString();
await updateWorkspaceMemory(tmpDir, store => {
store.entries.push({
id: "mem_cache_size_old",
type: "project",
text: "Workspace memory before cache pressure.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: timestamp,
updatedAt: timestamp,
});
return store;
});
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
for (let i = 0; i <= WORKSPACE_MEMORY_CACHE_LIMITS.maxFrozenSessions; i += 1) {
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: `cache-size-session-${i}`, model: {} },
{ system: ["base header"] },
);
}
await updateWorkspaceMemory(tmpDir, store => {
store.entries.push({
id: "mem_cache_size_new",
type: "project",
text: "Workspace memory after cache pressure.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: timestamp,
updatedAt: timestamp,
});
return store;
});
const output = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "cache-size-session-0", model: {} },
output,
);
assert.match(output.system.join("\n"), /Workspace memory after cache pressure/);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("processed user message cache keeps only the latest message IDs per session", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
let latestMessages: Array<Record<string, unknown>> = [];
const client = {
session: {
get: async () => ({ data: { parentID: null } }),
messages: async () => ({ data: latestMessages }),
todo: async () => ({ data: [] }),
},
};
const plugin = await MemoryV2Plugin({ directory: tmpDir, client });
for (let i = 0; i <= WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedMessagesPerSession; i += 1) {
latestMessages = [{
info: { role: "user", id: `msg-${i}` },
parts: [{ type: "text", text: `remember this: Processed cache filler memory ${i}.` }],
}];
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "processed-cache-session", model: {} },
{ system: ["base header"] },
);
}
latestMessages = [{
info: { role: "user", id: "msg-0" },
parts: [{ type: "text", text: "remember this: Evicted processed message id can be reused." }],
}];
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "processed-cache-session", model: {} },
{ system: ["base header"] },
);
const state = await loadSessionState(tmpDir, "processed-cache-session");
assert.ok(
state.pendingMemories.some(memory => /Evicted processed message id can be reused/.test(memory.text)),
"oldest processed message id should be evicted and accepted again",
);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("processed user message cache evicts oldest session ID sets", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const latestBySession = new Map<string, Array<Record<string, unknown>>>();
const client = {
session: {
get: async () => ({ data: { parentID: null } }),
messages: async ({ path }: { path?: { id?: string } } = {}) => ({
data: latestBySession.get(path?.id ?? "") ?? [],
}),
todo: async () => ({ data: [] }),
},
};
const plugin = await MemoryV2Plugin({ directory: tmpDir, client });
for (let i = 0; i <= WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedSessionIDs; i += 1) {
const sessionID = `processed-session-${i}`;
latestBySession.set(sessionID, [{
info: { role: "user", id: `msg-${i}` },
parts: [{ type: "text", text: `remember this: Session cache filler memory ${i}.` }],
}]);
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID, model: {} },
{ system: ["base header"] },
);
}
latestBySession.set("processed-session-0", [{
info: { role: "user", id: "msg-0" },
parts: [{ type: "text", text: "remember this: Evicted processed session set can process again." }],
}]);
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "processed-session-0", model: {} },
{ system: ["base header"] },
);
const state = await loadSessionState(tmpDir, "processed-session-0");
assert.ok(
state.pendingMemories.some(memory => /Evicted processed session set can process again/.test(memory.text)),
"oldest processed session set should be evicted and accept the same message id again",
);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("compaction intentionally refreshes frozen system[1] with promoted memories", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
@@ -1525,89 +1116,6 @@ test("session.compacted keeps explicit pending memory rejected by workspace entr
}
});
test("explicit capacity rejection records bounded retry metadata", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const now = new Date().toISOString();
await updateWorkspaceMemory(tmpDir, store => {
for (let i = 0; i < 28; i += 1) {
store.entries.push({
id: `mem_high_bounded_reject_${i}`,
type: "feedback",
text: `Pinned high priority feedback for bounded rejection ${i}.`,
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
});
}
return store;
});
const rejectedMemory = {
id: "mem_explicit_bounded_reject",
type: "reference" as const,
text: "Explicit reference should retry only a bounded number of times.",
source: "explicit" as const,
confidence: 0.1,
status: "active" as const,
createdAt: now,
updatedAt: now,
pendingOwnerSessionID: "bounded-reject-session",
pendingMessageID: "msg-bounded-reject",
};
await saveSessionState(tmpDir, {
version: 1,
sessionID: "bounded-reject-session",
turn: 0,
updatedAt: now,
activeFiles: [],
openErrors: [],
recentDecisions: [],
pendingMemories: [rejectedMemory],
});
await savePendingJournal(tmpDir, {
version: 1,
workspace: { root: tmpDir, key: "test" },
updatedAt: now,
entries: [rejectedMemory],
});
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
for (let attempt = 1; attempt < PROMOTION_RETRY_LIMITS.maxExplicitAttempts; attempt += 1) {
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "bounded-reject-session" } },
});
const journal = await loadPendingJournal(tmpDir);
assert.equal(journal.entries.length, 1,
"explicit rejection should not silently clear before retry exhaustion");
assert.equal(journal.entries[0].promotionAttempts, attempt);
assert.equal(journal.entries[0].lastPromotionFailureReason, "rejected_capacity");
const state = await loadSessionState(tmpDir, "bounded-reject-session");
assert.equal(state.pendingMemories.length, 1,
"hot session state should keep retryable explicit memory visible before exhaustion");
}
await (plugin as Record<string, Function>)["event"]({
event: { type: "session.compacted", properties: { sessionID: "bounded-reject-session" } },
});
const journal = await loadPendingJournal(tmpDir);
assert.equal(journal.entries.length, 0,
"explicit pending journal entry should clear after max retry attempts");
const state = await loadSessionState(tmpDir, "bounded-reject-session");
assert.equal(state.pendingMemories.length, 0,
"explicit hot session state should clear after retry exhaustion");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("session.compacted clears compaction pending memories when all rejected by workspace cap", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
-19
View File
@@ -209,25 +209,6 @@ test("accountPendingPromotions keeps explicit capacity rejection pending", () =>
assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]);
assert.equal(result.clearableKeys.size, 0);
assert.deepEqual([...result.retryableRejectedKeys], [memoryKey(pending[0])]);
});
test("accountPendingPromotions marks manual capacity rejection as retryable", () => {
const pending = [mem("pending_manual_capacity", "Manual reference should retry if capacity rejected.", {
type: "reference",
source: "manual",
})];
const result = accountPendingPromotions({
pending,
before: [],
after: [],
events: [event(pending[0], "rejected_capacity")],
});
assert.deepEqual([...result.rejectedKeys], [memoryKey(pending[0])]);
assert.equal(result.clearableKeys.size, 0);
assert.deepEqual([...result.retryableRejectedKeys], [memoryKey(pending[0])]);
});
test("accountPendingPromotions clears compaction stale rejection from accounting", () => {
-21
View File
@@ -1,21 +0,0 @@
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 });
});
-83
View File
@@ -1,83 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { spawn } from "node:child_process";
import { updateJSON } from "../src/storage.ts";
test("updateJSON serializes concurrent increments", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-storage-"));
try {
const path = join(root, "counter.json");
await Promise.all(Array.from({ length: 25 }, () =>
updateJSON(path, () => ({ count: 0 }), current => ({ count: current.count + 1 })),
));
const final = await updateJSON(path, () => ({ count: 0 }), current => current);
assert.equal(final.count, 25);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("updateJSON does not replace corrupt JSON with fallback", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-storage-corrupt-"));
try {
const path = join(root, "bad.json");
await writeFile(path, "{not json", "utf8");
await assert.rejects(
updateJSON(path, () => ({ ok: true }), current => current),
/Invalid JSON/,
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("updateJSON recovers stale lock files left by crashed process", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-storage-stale-lock-"));
try {
const path = join(root, "locked.json");
const lockPath = `${path}.lock`;
await writeFile(lockPath, `999999\n0\n`, "utf8");
const updated = await updateJSON(path, () => ({ count: 0 }), current => ({ count: current.count + 1 }));
assert.equal(updated.count, 1);
assert.equal(existsSync(lockPath), false, "stale lock file should be removed after update");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("updateJSON serializes writes across separate node processes", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-storage-xproc-"));
try {
const path = join(root, "counter.json");
const worker = `
import { updateJSON } from ${JSON.stringify(new URL("../src/storage.ts", import.meta.url).href)};
const path = process.argv[1];
await Promise.all(Array.from({ length: 20 }, () => updateJSON(path, () => ({ count: 0 }), async current => {
await new Promise(resolve => setTimeout(resolve, Math.floor(Math.random() * 5)));
return { count: current.count + 1 };
})));
`;
await Promise.all(Array.from({ length: 5 }, () => new Promise<void>((resolve, reject) => {
const child = spawn(
process.execPath,
["--experimental-strip-types", "--input-type=module", "-e", worker, path],
{ stdio: "inherit" },
);
child.on("exit", code => code === 0 ? resolve() : reject(new Error(`child exited ${code}`)));
child.on("error", reject);
})));
const final = await updateJSON(path, () => ({ count: 0 }), current => current);
assert.equal(final.count, 100);
} finally {
await rm(root, { recursive: true, force: true });
}
});
-171
View File
@@ -1,171 +0,0 @@
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 });
}
});
+10 -597
View File
@@ -1,11 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { mkdtemp, rm } from "node:fs/promises";
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 { workspaceKey, workspaceMemoryPath } from "../src/paths.ts";
import { workspaceMemoryPath } from "../src/paths.ts";
import {
renderWorkspaceMemory,
enforceLongTermLimits,
@@ -14,16 +14,13 @@ 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();
@@ -39,10 +36,6 @@ function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "de
};
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/** Create an entry with a createdAt offset from now (negative = in the past) */
function agedEntry(
id: string,
@@ -256,26 +249,6 @@ test("enforceLongTermLimits respects maxEntries limit", () => {
assert.ok(kept.length <= 28, `Should respect maxEntries. Got: ${kept.length}`);
});
test("normalization ordering is deterministic for retention ties", () => {
const createdAt = "2026-04-28T00:00:00.000Z";
const a = {
...entry("a", "Durable unique memory A"),
createdAt,
updatedAt: createdAt,
};
const b = {
...entry("b", "Durable unique memory B"),
createdAt,
updatedAt: createdAt,
};
const first = enforceLongTermLimits([b, a]).map(memory => memory.id);
const second = enforceLongTermLimits([a, b]).map(memory => memory.id);
assert.deepEqual(first, ["a", "b"]);
assert.deepEqual(second, ["a", "b"]);
});
test("dedupeLongTermEntriesWithAccounting reports exact duplicates as absorbed", () => {
const now = new Date().toISOString();
const lower: LongTermMemoryEntry = {
@@ -823,21 +796,6 @@ test("redactCredentials handles generic API keys and tokens", () => {
assert.equal(redactCredentials("auth: abc123def"), "auth: [REDACTED]");
});
test("redactCredentials redacts bearer tokens", () => {
assert.equal(
redactCredentials("Bearer sk-test-123"),
"Bearer [REDACTED]",
);
assert.equal(
redactCredentials("Authorization: Bearer sk-test-123"),
"Authorization: Bearer [REDACTED]",
);
assert.equal(
redactCredentials("curl -H 'Authorization: Bearer ghp_secret123'"),
"curl -H 'Authorization: Bearer [REDACTED]'",
);
});
test("redactCredentials does not redact benign security-related wording", () => {
assert.equal(redactCredentials("token budget is 5200 characters"), "token budget is 5200 characters");
assert.equal(redactCredentials("auth config uses OAuth"), "auth config uses OAuth");
@@ -892,13 +850,13 @@ test("redactCredentials is idempotent and also redacts rationale text", () => {
assert.equal(migrated.entries[0].rationale, "password: [REDACTED]");
});
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);
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);
assert.equal(isProgressSnapshotViolation("Upload limit is 10 files"), false);
assert.equal(isProgressSnapshotViolation("Project supports 5 test suites"), false);
assert.equal(isProjectSnapshotViolation("Upload limit is 10 files"), false);
assert.equal(isProjectSnapshotViolation("Project supports 5 test suites"), false);
});
test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs once", () => {
@@ -956,379 +914,6 @@ 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 = {
@@ -1366,178 +951,6 @@ test("renderWorkspaceMemory excludes superseded entries", () => {
assert.doesNotMatch(rendered, /Waves 1-5 已完成/);
});
test("loadWorkspaceMemory does not rewrite an already normalized store", async () => {
const sandbox = await mkdtemp(join(tmpdir(), "wm-normalized-"));
const dataHome = join(sandbox, "xdg-data-home");
const root = join(sandbox, "workspace");
const previousXdgDataHome = process.env.XDG_DATA_HOME;
process.env.XDG_DATA_HOME = dataHome;
try {
await mkdir(root, { recursive: true });
const now = "2026-04-28T00:00:00.000Z";
await saveWorkspaceMemory(root, {
version: 1,
workspace: { root, key: "test" },
limits: {
maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars,
maxEntries: LONG_TERM_LIMITS.maxEntries,
},
entries: [
{
...entry("normalized-feedback", "Normalized feedback memory", "feedback"),
source: "explicit",
confidence: 1,
createdAt: now,
updatedAt: now,
},
],
migrations: [],
updatedAt: now,
});
const storePath = await workspaceMemoryPath(root);
const before = (await stat(storePath)).mtimeMs;
await sleep(20);
await loadWorkspaceMemory(root);
await loadWorkspaceMemory(root);
const after = (await stat(storePath)).mtimeMs;
assert.equal(after, before, "normalized loads should not touch the store file");
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = previousXdgDataHome;
}
await rm(sandbox, { recursive: true, force: true });
}
});
test("loadWorkspaceMemory does not persist pure ordering normalization", async () => {
const sandbox = await mkdtemp(join(tmpdir(), "wm-ordering-"));
const dataHome = join(sandbox, "xdg-data-home");
const root = join(sandbox, "workspace");
const previousXdgDataHome = process.env.XDG_DATA_HOME;
process.env.XDG_DATA_HOME = dataHome;
try {
await mkdir(root, { recursive: true });
const now = "2026-04-28T00:00:00.000Z";
await saveWorkspaceMemory(root, {
version: 1,
workspace: { root, key: "test" },
limits: {
maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars,
maxEntries: LONG_TERM_LIMITS.maxEntries,
},
entries: [
{
...entry("feedback-first", "High priority feedback memory", "feedback"),
source: "explicit",
confidence: 1,
createdAt: now,
updatedAt: now,
},
{
...entry("reference-second", "Lower priority reference memory", "reference"),
source: "compaction",
confidence: 0.75,
createdAt: now,
updatedAt: now,
},
],
migrations: [],
updatedAt: now,
});
const storePath = await workspaceMemoryPath(root);
const canonical = JSON.parse(await readFile(storePath, "utf-8")) as WorkspaceMemoryStore;
await writeFile(
storePath,
JSON.stringify({ ...canonical, entries: [...canonical.entries].reverse() }, null, 2),
"utf-8",
);
const before = (await stat(storePath)).mtimeMs;
await sleep(20);
const loaded = await loadWorkspaceMemory(root);
const after = (await stat(storePath)).mtimeMs;
assert.deepEqual(loaded.entries.map(memory => memory.id), ["feedback-first", "reference-second"]);
assert.equal(after, before, "order-only normalization should not write during load");
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = previousXdgDataHome;
}
await rm(sandbox, { recursive: true, force: true });
}
});
test("loadWorkspaceMemory persists redaction changes and is stable afterward", async () => {
const sandbox = await mkdtemp(join(tmpdir(), "wm-redact-stable-"));
const dataHome = join(sandbox, "xdg-data-home");
const root = join(sandbox, "workspace");
const previousXdgDataHome = process.env.XDG_DATA_HOME;
process.env.XDG_DATA_HOME = dataHome;
try {
await mkdir(root, { recursive: true });
const now = "2026-04-28T00:00:00.000Z";
const unredactedStore: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "test" },
limits: {
maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars,
maxEntries: LONG_TERM_LIMITS.maxEntries,
},
entries: [
{
id: "bearer-secret",
text: "Authorization: Bearer sk-test-123",
rationale: "password: sushi",
type: "reference",
source: "manual",
confidence: 0.9,
status: "active",
createdAt: now,
updatedAt: now,
},
],
migrations: ["2026-04-26-p0-cleanup"],
updatedAt: now,
};
const storePath = await workspaceMemoryPath(root);
await mkdir(dirname(storePath), { recursive: true });
await writeFile(storePath, JSON.stringify(unredactedStore, null, 2), "utf-8");
const loaded = await loadWorkspaceMemory(root);
assert.equal(loaded.entries[0].text, "Authorization: Bearer [REDACTED]");
assert.equal(loaded.entries[0].rationale, "password: [REDACTED]");
const persistedAfterFirstLoad = await readFile(storePath, "utf-8");
assert.equal(persistedAfterFirstLoad.includes("sk-test-123"), false);
assert.equal(persistedAfterFirstLoad.includes("sushi"), false);
const beforeSecondLoad = (await stat(storePath)).mtimeMs;
await sleep(20);
await loadWorkspaceMemory(root);
const afterSecondLoad = (await stat(storePath)).mtimeMs;
assert.equal(afterSecondLoad, beforeSecondLoad, "second load should not rewrite redacted content");
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = previousXdgDataHome;
}
await rm(sandbox, { recursive: true, force: true });
}
});
test("loadWorkspaceMemory normalizes and persists credentials from legacy unredacted store", async () => {
const sandbox = await mkdtemp(join(tmpdir(), "wm-redact-"));
const dataHome = join(sandbox, "xdg-data-home");