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