mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-02 06:19:36 +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 |
@@ -0,0 +1,32 @@
|
||||
name: compatibility
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "0 9 * * 1"
|
||||
|
||||
jobs:
|
||||
locked:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
- run: npm install
|
||||
- run: npm run typecheck
|
||||
- run: npm test
|
||||
|
||||
opencode-latest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
- run: npm install
|
||||
- run: npm install --no-save @opencode-ai/plugin@latest
|
||||
- run: npm run typecheck
|
||||
- run: npm test
|
||||
@@ -51,3 +51,7 @@ pnpm-lock.yaml
|
||||
|
||||
# Superpowers local planning artifacts
|
||||
docs/superpowers/plans/
|
||||
|
||||
# Local dev/admin script inputs
|
||||
scripts/dev/run-migration-roots.local.txt
|
||||
scripts/dev/dry-run-roots.local.txt
|
||||
|
||||
@@ -5,6 +5,76 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.4.0] - 2026-04-28
|
||||
|
||||
### Added
|
||||
|
||||
- Local migration audit log for the `2026-04-28-quality-cleanup` migration:
|
||||
`~/.local/share/opencode-working-memory/migration-logs/2026-04-28-quality-cleanup.jsonl`.
|
||||
- Local extraction rejection log for rejected compaction memory candidates:
|
||||
`~/.local/share/opencode-working-memory/extraction-rejections.jsonl`.
|
||||
- Sanitized real-workspace regression fixtures for memory cleanup migration behavior.
|
||||
- Safe workspace residue cleanup tooling that dry-runs by default and quarantines definite temp/test workspace stores instead of deleting them.
|
||||
|
||||
### Changed
|
||||
|
||||
- Unified memory quality rules in a shared quality gate for compaction memory candidates and cleanup checks.
|
||||
- Rewritten compaction memory prompt to reduce over-production of low-quality memories.
|
||||
- Changed quality cleanup migration to be conservative: it supersedes only high-confidence garbage patterns, including progress snapshots, raw errors, commit/CI snapshots, temporary status notes, active file snapshots, code/API signatures, path-heavy entries, and empty entries.
|
||||
- Soft heuristic failures (`bad_feedback`, `bad_decision`) are intentionally excluded from automatic migration cleanup to protect durable declarative memories such as branding rules, API facts, release rules, user workflow preferences, and architecture decisions.
|
||||
- Isolated test runs under a temporary `XDG_DATA_HOME` so test workspaces no longer pollute real local workspace memory data.
|
||||
|
||||
### Recovery note
|
||||
|
||||
The cleanup migration changes matching entries to `status: "superseded"`; it does not delete the entry. If a useful memory is superseded, inspect the migration audit log and restore by changing that entry back to `status: "active"` in the workspace's `workspace-memory.json`. The migration runs once per workspace.
|
||||
|
||||
## [1.3.3] - 2026-04-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Added atomic cross-process storage writes with stale-lock recovery and heartbeat refresh to prevent concurrent memory-file corruption.
|
||||
- Scoped pending-memory promotion by owner/session so global unowned cleanup no longer removes active owned entries.
|
||||
- Retained source-aware pending memories until they are actually promoted, absorbed, superseded, or rejected.
|
||||
- Persisted load-time security redaction and expanded Bearer-token redaction to reduce secret retention risk.
|
||||
- Hardened workspace normalization, cache bounds, rejected-entry retention, and session cleanup behavior.
|
||||
|
||||
## [1.3.2] - 2026-04-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- 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
|
||||
|
||||
### Added
|
||||
|
||||
- Pending journal retention: max 50 entries, 30-day TTL, automatic pruning on save.
|
||||
- Plugin capability test to catch missing OpenCode hooks before release.
|
||||
- CI workflow for weekly OpenCode plugin API compatibility testing.
|
||||
- Indirect prompt-injection filtering for workspace memory candidates.
|
||||
- Expanded credential redaction for common API key, token, secret, credential, auth, and private-key labels.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pending memory journal entries are now bounded and pruned instead of growing indefinitely.
|
||||
- Adversarial memory candidates that try to override system instructions are rejected before storage.
|
||||
- Broader credential-like labels are redacted from workspace memory text.
|
||||
|
||||
### Changed
|
||||
|
||||
- Memory dedupe is now repo-agnostic: project/reference entries use exact canonical text plus generic URL/path identity, while decision/feedback entries no longer use repository-specific topic heuristics.
|
||||
- OpenCode plugin compatibility is documented and declared as `>=1.2.0 <2.0.0`.
|
||||
- README limitations now concisely document compatibility, secret handling, semantic-memory scope, plugin ordering, and multi-process write boundaries.
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- Compatibility is tested against OpenCode plugin API `>=1.2.0 <2.0.0`.
|
||||
- Credential redaction is best-effort; do not store secrets.
|
||||
- This is working memory, not semantic search.
|
||||
- Other prompt or compaction plugins may conflict depending on plugin order.
|
||||
- Multi-process writes to the same workspace are not fully serialized.
|
||||
|
||||
## [1.3.0] - 2026-04-27
|
||||
|
||||
### Added
|
||||
|
||||
@@ -174,6 +174,17 @@ It includes guards for:
|
||||
|
||||
The goal is to remember durable facts, not every detail.
|
||||
|
||||
Historical cleanup is intentionally conservative: extraction-time filtering may reject more aggressively, but one-time migration cleanup only supersedes high-confidence garbage patterns. This protects existing durable memories written in declarative style, such as "API endpoint is X" or "Product branding is Y".
|
||||
|
||||
For local development cleanup, use:
|
||||
|
||||
```bash
|
||||
npm run cleanup:workspaces -- --dry-run
|
||||
npm run cleanup:workspaces -- --quarantine
|
||||
```
|
||||
|
||||
The cleanup command only quarantines definite temp/test workspace residues by default. It does not delete unknown missing-root workspaces.
|
||||
|
||||
## Configuration
|
||||
|
||||
OpenCode Working Memory works out of the box.
|
||||
@@ -210,13 +221,22 @@ cd opencode-working-memory
|
||||
npm install
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run cleanup:workspaces -- --dry-run
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- OpenCode >= 1.0.0
|
||||
- OpenCode plugin API `>=1.2.0 <2.0.0`
|
||||
- Node.js >= 18.0.0
|
||||
|
||||
## Limitations
|
||||
|
||||
- Requires OpenCode plugin API `>=1.2.0 <2.0.0`; OpenCode hook changes may break compatibility.
|
||||
- Not a secret manager. Credential redaction is best-effort. Do not store secrets.
|
||||
- Working memory only. No semantic search, embeddings, or vector knowledge base.
|
||||
- Other prompt or compaction plugins may conflict depending on plugin order.
|
||||
- Multiple OpenCode processes on the same workspace may race on local files.
|
||||
|
||||
## License
|
||||
|
||||
MIT License. See [LICENSE](LICENSE) for details.
|
||||
|
||||
@@ -1,5 +1,152 @@
|
||||
# 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)
|
||||
|
||||
### Security and Reliability Patch
|
||||
|
||||
This patch release keeps the v1.3 memory-consolidation model intact while tightening storage safety, compatibility checks, and repository-agnostic dedupe behavior.
|
||||
|
||||
### What Changed
|
||||
|
||||
- **Bounded pending journal**: pending memories are capped at 50 entries and pruned after 30 days.
|
||||
- **Security hardening**: workspace memory candidates now reject indirect prompt-injection attempts, and redaction covers broader token, secret, credential, auth, and private-key labels.
|
||||
- **Compatibility coverage**: plugin capability tests and weekly OpenCode plugin API compatibility CI help catch hook drift before release.
|
||||
- **Repo-agnostic dedupe**: long-term memory dedupe no longer depends on hardcoded project-specific topic rules; project/reference memories use generic URL/path identity plus exact canonical matching.
|
||||
- **Clearer limitations**: README and changelog now document compatibility, best-effort secret redaction, working-memory scope, plugin ordering, and multi-process write boundaries.
|
||||
|
||||
### Thanks
|
||||
|
||||
- Thanks @StevenChoo for the security hardening contribution in #3.
|
||||
|
||||
### Upgrade Notes
|
||||
|
||||
- No user migration is required.
|
||||
- Existing workspace memory and pending journal files remain compatible.
|
||||
- The OpenCode config entry stays the same:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": ["opencode-working-memory"]
|
||||
}
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
- `npm test`
|
||||
- `npm run typecheck`
|
||||
|
||||
---
|
||||
|
||||
## 1.3.0 (2026-04-27)
|
||||
|
||||
### Better Memory Consolidation
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencode-working-memory",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
@@ -16,7 +16,9 @@
|
||||
"scripts": {
|
||||
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test --experimental-strip-types tests/*.test.ts"
|
||||
"test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts",
|
||||
"cleanup:workspaces": "node --experimental-strip-types scripts/dev/cleanup-workspaces.ts",
|
||||
"check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test"
|
||||
},
|
||||
"keywords": [
|
||||
"opencode",
|
||||
@@ -37,7 +39,7 @@
|
||||
},
|
||||
"homepage": "https://github.com/sdwolf4103/opencode-working-memory#readme",
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/plugin": "^1.2.0"
|
||||
"@opencode-ai/plugin": ">=1.2.0 <2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
|
||||
@@ -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(",")}`);
|
||||
}
|
||||
+42
-49
@@ -1,6 +1,11 @@
|
||||
import { createHash } from "crypto";
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import type { ActiveFile, LongTermMemoryEntry, LongTermType, OpenError } from "./types.ts";
|
||||
import { LONG_TERM_LIMITS } from "./types.ts";
|
||||
import { assessMemoryQuality } from "./memory-quality.ts";
|
||||
import { extractionRejectionLogPath } from "./paths.ts";
|
||||
import { redactCredentials } from "./redaction.ts";
|
||||
|
||||
function id(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -51,7 +56,7 @@ export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
|
||||
// 韓文(長詞優先):기억해줘/메모해줘 must come before 기억해/메모해
|
||||
/(?:^|\n)\s*(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[::,,]?\s*(.+)$/gim,
|
||||
// 英文:remember this/that - 必須在行首,避免 "to remember" 非指令匹配
|
||||
/(?:^|\n)\s*(?:please\s+)?remember\s+(?:this|that)?[::,,]?\s*(.+)$/gim,
|
||||
/(?:^|\n)\s*(?:please\s+)?remember(?:\s+(?:this|that))?[::,,]?\s*(.+)$/gim,
|
||||
// save/add to memory
|
||||
/(?:^|\n)\s*(?:please\s+)?(?:save|add)\s+(?:this|that)?\s*(?:to|in)\s+memory[::,,]?\s*(.+)$/gim,
|
||||
// commit to memory
|
||||
@@ -199,7 +204,7 @@ function normalizeCandidateBody(body: string): { text: string; hadTrigger: boole
|
||||
/(?:请|請)?(?:帮我|幫我)?(?:记住|記住|记得|記得|记下来|記下來)(?:这一点|這一點|这点|這點|这个|這個)?[::,,]?\s*(.+)$/im,
|
||||
/(?:覚えておいて|覚えて|忘れないで|メモして)[::,,]?\s*(.+)$/im,
|
||||
/(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[::,,]?\s*(.+)$/im,
|
||||
/(?:please\s+)?remember\s+(?:this|that)?[::,,]?\s*(.+)$/im,
|
||||
/(?:please\s+)?remember(?:\s+(?:this|that))?[::,,]?\s*(.+)$/im,
|
||||
/(?:please\s+)?(?:save|add)\s+(?:this|that)?\s*(?:to|in)\s+memory[::,,]?\s*(.+)$/im,
|
||||
/(?:please\s+)?commit\s+(?:this|that)?\s*to memory[::,,]?\s*(.+)$/im,
|
||||
];
|
||||
@@ -223,9 +228,27 @@ function extractFirstPath(text: string): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Quality gate for workspace memory candidates.
|
||||
* Rejects low-quality entries like git hashes, error messages, etc.
|
||||
* Acceptance gate for workspace memory candidates.
|
||||
* Keeps extraction-specific checks local and delegates memory quality rules to memory-quality.ts.
|
||||
*/
|
||||
type ExtractionRejectionLogEntry = {
|
||||
timestamp: string;
|
||||
type: LongTermType;
|
||||
text: string;
|
||||
reasons: string[];
|
||||
source: "compaction";
|
||||
};
|
||||
|
||||
async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promise<void> {
|
||||
try {
|
||||
const path = extractionRejectionLogPath();
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await appendFile(path, JSON.stringify(entry) + "\n", "utf8");
|
||||
} catch (error) {
|
||||
console.error("[memory] failed to write extraction rejection log:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function shouldAcceptWorkspaceMemoryCandidate(
|
||||
entry: {
|
||||
type: LongTermType;
|
||||
@@ -245,57 +268,27 @@ function shouldAcceptWorkspaceMemoryCandidate(
|
||||
return false;
|
||||
}
|
||||
|
||||
// Git history / commit hash
|
||||
if (/\b[0-9a-f]{7,40}\b/.test(text)) return false;
|
||||
if (/^(fix|feat|chore|docs|refactor|test):/i.test(text)) return false;
|
||||
// Indirect Prompt Injection / Adversarial Instructions
|
||||
// Rejects attempts to overwrite system behavior or "ignore" rules.
|
||||
// comparative "instead of" is allowed.
|
||||
if (/\b(ignore\s+all|ignore\s+previous|ignore\s+instruction|overwrite\s+system|overwrite\s+rules|forget\s+all|delete\s+root)\b/i.test(text)) return false;
|
||||
if (/\b(ignore|instruction|overwrite)\b/i.test(text) && /\b(previous|all|rules|behavior|prompt|system)\b/i.test(text)) return false;
|
||||
|
||||
// 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;
|
||||
|
||||
// Path-heavy facts (rediscoverable from repo)
|
||||
const pathCount = (text.match(/\/[\w.-]+(\/[\w.-]+)+/g) || []).length;
|
||||
if (pathCount > 2) return false;
|
||||
|
||||
// Session-specific progress snapshots for project type
|
||||
if (entry.type === "project") {
|
||||
if (isProjectSnapshotViolation(text)) return false;
|
||||
const quality = assessMemoryQuality({ type: entry.type, text, source: "compaction" });
|
||||
if (!quality.accepted) {
|
||||
void logExtractionRejection({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: entry.type,
|
||||
text: redactCredentials(text),
|
||||
reasons: quality.reasons,
|
||||
source: "compaction",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isProjectSnapshotViolation(text: string): boolean {
|
||||
// Test/suite counts
|
||||
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
|
||||
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
|
||||
|
||||
// File counts with snapshot/process context only, not static limits
|
||||
if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) {
|
||||
const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text);
|
||||
const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text);
|
||||
if (hasSnapshotContext && !hasLimitContext) return true;
|
||||
}
|
||||
|
||||
// Phase/Wave/Sprint/Milestone/Task progress
|
||||
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) {
|
||||
if (/completed|done|finished|完成/i.test(text)) return true;
|
||||
}
|
||||
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract candidate block from summary using multiple formats.
|
||||
* Supports: Plain text label, Markdown section, legacy XML.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { LongTermMemoryEntry, LongTermSource } from "./types.ts";
|
||||
|
||||
export type MemoryQualityInput = Pick<LongTermMemoryEntry, "type" | "text"> & {
|
||||
source?: LongTermSource;
|
||||
};
|
||||
|
||||
export type MemoryQualityResult = {
|
||||
accepted: boolean;
|
||||
reasons: string[];
|
||||
};
|
||||
|
||||
export const HARD_QUALITY_REASONS: ReadonlySet<string> = new Set([
|
||||
"empty",
|
||||
"progress_snapshot",
|
||||
"raw_error",
|
||||
"commit_or_ci_snapshot",
|
||||
"temporary_status",
|
||||
"active_file_snapshot",
|
||||
"code_or_api_signature",
|
||||
"path_heavy",
|
||||
]);
|
||||
|
||||
export function isHardQualityReason(reason: string): boolean {
|
||||
return HARD_QUALITY_REASONS.has(reason);
|
||||
}
|
||||
|
||||
export function assessMemoryQuality(entry: MemoryQualityInput): MemoryQualityResult {
|
||||
const reasons: string[] = [];
|
||||
const text = entry.text.trim();
|
||||
|
||||
if (text.length === 0) reasons.push("empty");
|
||||
if (isProgressSnapshotViolation(text)) reasons.push("progress_snapshot");
|
||||
if (isRawErrorViolation(text)) reasons.push("raw_error");
|
||||
if (isCommitOrCiViolation(text)) reasons.push("commit_or_ci_snapshot");
|
||||
if (isPathHeavyViolation(text)) reasons.push("path_heavy");
|
||||
if (isTemporaryStatusViolation(text)) reasons.push("temporary_status");
|
||||
if (isActiveFileSnapshotViolation(text)) reasons.push("active_file_snapshot");
|
||||
if (isCodeOrApiSignatureViolation(text)) reasons.push("code_or_api_signature");
|
||||
if (entry.type === "feedback" && isFeedbackQualityViolation(text)) reasons.push("bad_feedback");
|
||||
if (entry.type === "decision" && isDecisionQualityViolation(text)) reasons.push("bad_decision");
|
||||
|
||||
return { accepted: reasons.length === 0, reasons };
|
||||
}
|
||||
|
||||
export function isProgressSnapshotViolation(text: string): boolean {
|
||||
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
|
||||
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
|
||||
|
||||
if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) {
|
||||
const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text);
|
||||
const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text);
|
||||
if (hasSnapshotContext && !hasLimitContext) return true;
|
||||
}
|
||||
|
||||
if (/\b(?:completed|done|finished|implemented|added|updated|fixed|reviewed|passed|modified)\b/i.test(text)) {
|
||||
if (/\b(?:wave|phase|task|plan|pr|commit|ci|test|suite|implementation|session|change|fix|review|file)\b/i.test(text)) return true;
|
||||
}
|
||||
if (/(?:已完成|完成|修復|实现|實作).{0,40}(?:wave|phase|task|plan|PR|測試|测试|實作|实现|修復)/iu.test(text)) return true;
|
||||
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) {
|
||||
if (/completed|done|finished|完成|已完成/i.test(text)) return true;
|
||||
}
|
||||
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
|
||||
if (/\b(?:currently|right now|latest change|previous session|last wave|next step)\b/i.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isFeedbackQualityViolation(text: string): boolean {
|
||||
const stablePreference = /\b(?:user|the user)\s+(?:prefers|wants|asked|expects|requires|likes|dislikes)\b/i.test(text)
|
||||
|| /\b(?:prefer|preference|going forward|from now on|always|never)\b/i.test(text)
|
||||
|| /(?:使用者|用戶|用户).{0,12}(?:偏好|希望|要求|想要)/u.test(text)
|
||||
|| /(?:以後|以后|請|请).{0,20}(?:使用|回答|保持|避免)/u.test(text);
|
||||
|
||||
if (stablePreference) return false;
|
||||
|
||||
const internalNote = /\b(?:implemented|updated|fixed|reviewed|added|changed|modified|created|writes|wrote)\b/i.test(text);
|
||||
if (internalNote) return true;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isDecisionQualityViolation(text: string): boolean {
|
||||
const futureRule = /\b(?:use|keep|prefer|avoid|do not|don't|must|should|never|always|require|choose|reject)\b/i.test(text)
|
||||
|| /(?:使用|保持|避免|不要|必須|必须|應該|应该|選擇|选择)/u.test(text);
|
||||
if (!futureRule) return true;
|
||||
if (/\b(?:implemented|added|updated|fixed|completed|reviewed)\b/i.test(text)) return true;
|
||||
if (/\b(?:was|were|has been|had been)\b/i.test(text) && /\b(?:previous|last|latest|this session|this wave|already)\b/i.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isRawErrorViolation(text: string): boolean {
|
||||
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError|Exception):/i.test(text)) return true;
|
||||
if (/at \S+ \([^)]+:\d+:\d+\)/.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isCommitOrCiViolation(text: string): boolean {
|
||||
if (/^(fix|feat|chore|docs|refactor|test):/i.test(text)) return true;
|
||||
if (/\b[0-9a-f]{7,40}\b/.test(text)) return true;
|
||||
if (/\bCI\b.*\b(?:passed|failed|run|compatibility|flaky)\b/i.test(text)) return true;
|
||||
if (/\b(?:passed|failed|run|compatibility|flaky)\b.*\bCI\b/i.test(text)) return true;
|
||||
if (/\bcompatibility\s+run\s+\d+/i.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPathHeavyViolation(text: string): boolean {
|
||||
const pathCount = (text.match(/\/[\w.-]+(?:\/[\w.-]+)+/g) || []).length;
|
||||
return pathCount > 2;
|
||||
}
|
||||
|
||||
function isTemporaryStatusViolation(text: string): boolean {
|
||||
if (/^(currently|now|pending|in progress|todo|wip)\b/i.test(text)) return true;
|
||||
if (/\b(?:run npm test|tests? are running|next reply|before continuing)\b/i.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isActiveFileSnapshotViolation(text: string): boolean {
|
||||
return /^(modified|created|deleted|renamed)\s+\S+\.\S+$/i.test(text);
|
||||
}
|
||||
|
||||
function isCodeOrApiSignatureViolation(text: string): boolean {
|
||||
if (/^(function|class|interface|type|const|let|var)\s+\w+/.test(text)) return true;
|
||||
if (/^(GET|POST|PUT|DELETE|PATCH)\s+\//.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -28,3 +28,11 @@ export async function sessionStatePath(root: string, sessionID: string): Promise
|
||||
const safeSessionID = createHash("sha256").update(sessionID).digest("hex").slice(0, 32);
|
||||
return join(await memoryRoot(root), "sessions", `${safeSessionID}.json`);
|
||||
}
|
||||
|
||||
export function migrationLogPath(migrationId: string): string {
|
||||
return join(dataHome(), "opencode-working-memory", "migration-logs", `${migrationId}.jsonl`);
|
||||
}
|
||||
|
||||
export function extractionRejectionLogPath(): string {
|
||||
return join(dataHome(), "opencode-working-memory", "extraction-rejections.jsonl");
|
||||
}
|
||||
|
||||
+135
-8
@@ -1,7 +1,22 @@
|
||||
import type { LongTermMemoryEntry, PendingMemoryJournalStore } from "./types.ts";
|
||||
import { PROMOTION_RETRY_LIMITS } from "./types.ts";
|
||||
import { workspaceKey, workspacePendingJournalPath } from "./paths.ts";
|
||||
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||
|
||||
/**
|
||||
* Retention limits for the pending memory journal.
|
||||
*
|
||||
* The journal is a scratchpad for memories that haven't been promoted to
|
||||
* workspace memory yet. It should not grow unboundedly:
|
||||
* - maxEntries: Hard cap on number of pending entries
|
||||
* - maxAgeDays: Prune entries older than this (compaction candidates that
|
||||
* were never promoted)
|
||||
*/
|
||||
export const PENDING_JOURNAL_LIMITS = {
|
||||
maxEntries: 50,
|
||||
maxAgeDays: 30,
|
||||
} as const;
|
||||
|
||||
function normalizeMemoryText(text: string): string {
|
||||
return text
|
||||
.normalize("NFKC")
|
||||
@@ -28,7 +43,7 @@ function dedupeByText(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
|
||||
const result: LongTermMemoryEntry[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const key = memoryKey(entry);
|
||||
const key = `${memoryKey(entry)}\u0000${entry.pendingOwnerSessionID ?? ""}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(entry);
|
||||
@@ -37,6 +52,53 @@ function dedupeByText(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective timestamp for an entry, preferring updatedAt over createdAt.
|
||||
* Returns 0 if both are invalid/missing.
|
||||
*/
|
||||
function entryTime(entry: LongTermMemoryEntry): number {
|
||||
const updatedAt = entry.updatedAt ? new Date(entry.updatedAt).getTime() : NaN;
|
||||
if (!Number.isNaN(updatedAt)) return updatedAt;
|
||||
|
||||
const createdAt = entry.createdAt ? new Date(entry.createdAt).getTime() : NaN;
|
||||
if (!Number.isNaN(createdAt)) return createdAt;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isStaleEntry(entry: LongTermMemoryEntry, maxAgeDays: number): boolean {
|
||||
const time = entryTime(entry);
|
||||
|
||||
// Invalid timestamps are corruption safety and apply to every source.
|
||||
if (time === 0) return true;
|
||||
|
||||
// TTL policy applies only to compaction candidates. Explicit/manual entries
|
||||
// represent user intent and should survive age while under the hard cap.
|
||||
if (entry.source !== "compaction") return false;
|
||||
|
||||
return Date.now() - time > maxAgeDays * 86_400_000;
|
||||
}
|
||||
|
||||
function applyRetention(
|
||||
entries: LongTermMemoryEntry[],
|
||||
maxEntries: number,
|
||||
maxAgeDays: number,
|
||||
): LongTermMemoryEntry[] {
|
||||
const deduped = dedupeByText(entries);
|
||||
const freshEntries = deduped.filter(entry => !isStaleEntry(entry, maxAgeDays));
|
||||
const sorted = [...freshEntries].sort((a, b) => {
|
||||
const timeDiff = entryTime(b) - entryTime(a);
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
const capped = sorted.slice(0, maxEntries);
|
||||
return capped.sort((a, b) => {
|
||||
const timeDiff = entryTime(a) - entryTime(b);
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeJournal(
|
||||
root: string,
|
||||
store: PendingMemoryJournalStore,
|
||||
@@ -44,11 +106,11 @@ function normalizeJournal(
|
||||
return workspaceKey(root).then(key => ({
|
||||
version: 1,
|
||||
workspace: { root, key },
|
||||
// TODO(memory-consolidation follow-up): add the deferred pending journal
|
||||
// safety cap (max entries and old compaction pruning). P0 currently relies
|
||||
// on promotion accounting to clear terminal compaction candidates without
|
||||
// changing journal capacity behavior.
|
||||
entries: dedupeByText(Array.isArray(store.entries) ? store.entries : []),
|
||||
entries: applyRetention(
|
||||
Array.isArray(store.entries) ? store.entries : [],
|
||||
PENDING_JOURNAL_LIMITS.maxEntries,
|
||||
PENDING_JOURNAL_LIMITS.maxAgeDays,
|
||||
),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
@@ -94,13 +156,78 @@ export async function hasPendingJournalEntries(root: string): Promise<boolean> {
|
||||
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 => {
|
||||
if (!keys || keys.size === 0) {
|
||||
store.entries = [];
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
loadPendingJournal,
|
||||
memoryKey,
|
||||
recordPromotionRejections,
|
||||
} from "./pending-journal.ts";
|
||||
import {
|
||||
loadSessionState,
|
||||
@@ -61,6 +62,7 @@ import {
|
||||
pendingTodos,
|
||||
} from "./opencode.ts";
|
||||
import { accountPendingPromotions } from "./promotion-accounting.ts";
|
||||
import { WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Build the complete compaction prompt.
|
||||
@@ -106,45 +108,37 @@ function buildCompactionPrompt(privateContext: string): string {
|
||||
"",
|
||||
"## Relevant Files",
|
||||
"",
|
||||
"At the end of the summary, extract durable memory entries for future sessions.",
|
||||
"At the end of the summary, include a Memory candidates section only if there are durable facts that will change future behavior.",
|
||||
"",
|
||||
"Memory quality bar:",
|
||||
"Extract only durable facts that will change future behavior: user preferences, decisions with rationale, stable constraints, or hard-to-rediscover references.",
|
||||
"",
|
||||
"Do not extract trivia: transient IDs/revisions, task progress, test/file counts, bare status updates, local UI details, or facts easily rediscovered from the repo.",
|
||||
"",
|
||||
"When unsure, skip it. Fewer high-signal memories are better than many low-value ones.",
|
||||
"CRITICAL MEMORY RULES:",
|
||||
"- Most compactions should produce ZERO memories. Empty is correct when nothing durable changed.",
|
||||
"- NO completion or progress statements: do not extract completed work, passing tests, commits, PR status, wave/task/phase completion, or current state.",
|
||||
"- NO session-internal implementation notes: do not extract what files were edited, what bug was just fixed, what command just ran, or what the assistant reviewed.",
|
||||
"- feedback ONLY means stable user preferences or user instructions, written in imperative/future-facing form.",
|
||||
"- decision ONLY means rules that apply to FUTURE work, not decisions already implemented in this session.",
|
||||
"- project/reference ONLY when the fact is stable across sessions and hard to rediscover from the repository.",
|
||||
"- If unsure, skip it.",
|
||||
"",
|
||||
"Good memory examples:",
|
||||
"- [feedback] User prefers architecture reviews in Traditional Chinese.",
|
||||
"- [decision] Use frozen workspace memory snapshots plus ephemeral hot state for cache stability.",
|
||||
"- [project] The plugin should piggyback memory extraction on OpenCode compaction and avoid extra LLM calls.",
|
||||
"- [reference] Workspace memory appears in frozen system[1]; pending memories appear in hot session state until compaction.",
|
||||
"- [decision] Do not add semantic merge to memory dedupe.",
|
||||
"- [project] This repository is an OpenCode plugin using local JSON stores.",
|
||||
"- [reference] Workspace memory is rendered as frozen system[1]; pending memories remain in hot state until compaction.",
|
||||
"",
|
||||
"Bad memory examples to skip:",
|
||||
"- 42 tests passed.",
|
||||
"- Wave 2 completed successfully.",
|
||||
"- Modified 5 files.",
|
||||
"- commit 4309cb8 contains the latest fix.",
|
||||
"- TypeError: Cannot read properties of undefined.",
|
||||
"- Currently running npm test.",
|
||||
"",
|
||||
"A memory should still be useful if a new agent opens this workspace next week.",
|
||||
"",
|
||||
"Only extract facts that are likely to stay true across sessions.",
|
||||
"Do not extract session-specific progress like exact test counts, file counts, or phase numbers.",
|
||||
"For progress, extract the stable goal or durable milestone, not the current number.",
|
||||
"For references, extract configuration values that do not usually change between sessions.",
|
||||
"For feedback, extract unresolved issues or user preferences that future sessions need to know.",
|
||||
"Use exactly this candidate format, including square brackets around the type:",
|
||||
"- 180 tests passed and CI is green.",
|
||||
"- Implemented owner-aware cleanup in plugin.ts.",
|
||||
"- The assistant reviewed code reviewer feedback and updated the plan.",
|
||||
"- Commit a762e86 contains the owner scope fix.",
|
||||
"",
|
||||
"Format when there ARE durable memories:",
|
||||
"Memory candidates:",
|
||||
"- [feedback] content",
|
||||
"- [project] content",
|
||||
"- [decision] content",
|
||||
"- [reference] content",
|
||||
"- [feedback|decision|project|reference] future-facing durable fact",
|
||||
"",
|
||||
"Do not write '- project content'; write '- [project] content'.",
|
||||
"Format when there are NO durable memories:",
|
||||
"Memory candidates:",
|
||||
"(none)",
|
||||
"",
|
||||
"Background context, use this to inform the summary above.",
|
||||
"Do not output this context verbatim:",
|
||||
@@ -203,13 +197,67 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
// Cache for processed user message IDs (to avoid duplicate processing)
|
||||
const processedUserMessages = new Map<string, Set<string>>();
|
||||
|
||||
function pruneFrozenWorkspaceMemoryCache(now = Date.now()): void {
|
||||
for (const [sessionID, cached] of frozenWorkspaceMemoryCache) {
|
||||
if (now - cached.loadedAt > WORKSPACE_MEMORY_CACHE_LIMITS.frozenTtlMs) {
|
||||
frozenWorkspaceMemoryCache.delete(sessionID);
|
||||
}
|
||||
}
|
||||
|
||||
while (frozenWorkspaceMemoryCache.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxFrozenSessions) {
|
||||
const oldest = [...frozenWorkspaceMemoryCache.entries()]
|
||||
.sort((a, b) => a[1].loadedAt - b[1].loadedAt)[0]?.[0];
|
||||
if (!oldest) break;
|
||||
frozenWorkspaceMemoryCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function pruneProcessedUserMessagesCache(): void {
|
||||
for (const [sessionID, messages] of processedUserMessages) {
|
||||
while (messages.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedMessagesPerSession) {
|
||||
const oldest = messages.values().next().value as string | undefined;
|
||||
if (!oldest) break;
|
||||
messages.delete(oldest);
|
||||
}
|
||||
|
||||
if (messages.size === 0) {
|
||||
processedUserMessages.delete(sessionID);
|
||||
}
|
||||
}
|
||||
|
||||
while (processedUserMessages.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedSessionIDs) {
|
||||
const oldestSessionID = processedUserMessages.keys().next().value as string | undefined;
|
||||
if (!oldestSessionID) break;
|
||||
processedUserMessages.delete(oldestSessionID);
|
||||
}
|
||||
}
|
||||
|
||||
function rememberProcessedUserMessage(sessionID: string, messageID: string, processedForSession: Set<string>): void {
|
||||
processedForSession.add(messageID);
|
||||
while (processedForSession.size > WORKSPACE_MEMORY_CACHE_LIMITS.maxProcessedMessagesPerSession) {
|
||||
const oldest = processedForSession.values().next().value as string | undefined;
|
||||
if (!oldest) break;
|
||||
processedForSession.delete(oldest);
|
||||
}
|
||||
|
||||
if (processedUserMessages.has(sessionID)) {
|
||||
processedUserMessages.delete(sessionID);
|
||||
}
|
||||
processedUserMessages.set(sessionID, processedForSession);
|
||||
pruneProcessedUserMessagesCache();
|
||||
}
|
||||
|
||||
async function processLatestUserMessage(sessionID: string): Promise<void> {
|
||||
const processedForSession = processedUserMessages.get(sessionID) ?? new Set<string>();
|
||||
const latestMessage = await latestUserText(client, sessionID);
|
||||
|
||||
if (!latestMessage?.id || processedForSession.has(latestMessage.id)) return;
|
||||
|
||||
const memories = extractExplicitMemories(latestMessage.text);
|
||||
const memories = extractExplicitMemories(latestMessage.text).map(memory => ({
|
||||
...memory,
|
||||
pendingOwnerSessionID: sessionID,
|
||||
pendingMessageID: latestMessage.id,
|
||||
}));
|
||||
const decisions = memories.filter(memory => memory.type === "decision");
|
||||
|
||||
if (memories.length > 0) {
|
||||
@@ -233,19 +281,29 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
});
|
||||
}
|
||||
|
||||
processedForSession.add(latestMessage.id);
|
||||
processedUserMessages.set(sessionID, processedForSession);
|
||||
rememberProcessedUserMessage(sessionID, latestMessage.id, 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([
|
||||
loadPendingJournal(directory),
|
||||
sessionID ? loadSessionState(directory, sessionID) : Promise.resolve(undefined),
|
||||
]);
|
||||
|
||||
const journalPending = journal.entries.filter(memory => {
|
||||
if (sessionID && includeOwnedJournal && memory.pendingOwnerSessionID === sessionID) return true;
|
||||
if (includeUnownedJournal && !memory.pendingOwnerSessionID) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const pending = [
|
||||
...(sessionState?.pendingMemories ?? []),
|
||||
...journal.entries,
|
||||
...journalPending,
|
||||
];
|
||||
if (pending.length === 0) return;
|
||||
|
||||
@@ -277,16 +335,42 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
events: updateResult.events,
|
||||
});
|
||||
|
||||
const exhaustedRejectedKeys = await recordPromotionRejections(
|
||||
directory,
|
||||
accounting.retryableRejectedKeys,
|
||||
"rejected_capacity",
|
||||
{
|
||||
ownerSessionID: sessionID,
|
||||
includeUnownedOnly: !sessionID,
|
||||
},
|
||||
);
|
||||
|
||||
const sessionRemovalKeys = new Set([
|
||||
...accounting.clearableKeys,
|
||||
...exhaustedRejectedKeys,
|
||||
]);
|
||||
|
||||
if (sessionID) {
|
||||
await updateSessionState(directory, sessionID, state => {
|
||||
state.pendingMemories = state.pendingMemories.filter(memory => !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;
|
||||
});
|
||||
clearFrozenWorkspaceMemoryCache(sessionID);
|
||||
}
|
||||
|
||||
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;
|
||||
}> {
|
||||
const now = Date.now();
|
||||
pruneFrozenWorkspaceMemoryCache(now);
|
||||
const cached = frozenWorkspaceMemoryCache.get(sessionID);
|
||||
|
||||
// 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 renderedPrompt = renderWorkspaceMemory(store);
|
||||
frozenWorkspaceMemoryCache.set(sessionID, { store, renderedPrompt, loadedAt: now });
|
||||
pruneFrozenWorkspaceMemoryCache(now);
|
||||
return { store, renderedPrompt };
|
||||
}
|
||||
|
||||
@@ -357,19 +443,23 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
const { sessionID } = hookInput;
|
||||
if (!sessionID) return;
|
||||
|
||||
pruneFrozenWorkspaceMemoryCache();
|
||||
pruneProcessedUserMessagesCache();
|
||||
|
||||
// Sub-agents are short-lived - skip memory system
|
||||
if (await isSubAgent(sessionID)) return;
|
||||
|
||||
// Before first snapshot in this session, promote durable pending memories from
|
||||
// prior sessions. Keep this before processing latest user text so current-turn
|
||||
// explicit memory remains pending (not immediately frozen into system[1]).
|
||||
if (!frozenWorkspaceMemoryCache.has(sessionID) && await hasPendingJournalEntries(directory)) {
|
||||
await promotePendingMemories();
|
||||
}
|
||||
|
||||
// Process explicit user memory even on no-tool turns.
|
||||
// Process explicit user memory even on no-tool turns. Keep this after the
|
||||
// sub-agent guard so child sessions never append to the parent journal.
|
||||
await processLatestUserMessage(sessionID);
|
||||
|
||||
// Before first snapshot in this session, promote durable unowned backlog from
|
||||
// prior sessions. Current-turn owned explicit memory remains pending and only
|
||||
// appears in hot state for this transform.
|
||||
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)
|
||||
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
|
||||
|
||||
@@ -521,7 +611,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
}
|
||||
|
||||
try {
|
||||
await promotePendingMemories(sessionID);
|
||||
await promotePendingMemories(sessionID, { includeUnownedJournal: true });
|
||||
} catch {
|
||||
// Keep pending memories in session/journal for retry on next event/session.
|
||||
}
|
||||
@@ -532,16 +622,20 @@ export const MemoryV2Plugin: Plugin = async (input) => {
|
||||
if (sessionID) {
|
||||
// Promote pending memories before deleting per-session state.
|
||||
// If promotion fails, leave session state and journal intact.
|
||||
let promoted = false;
|
||||
try {
|
||||
await promotePendingMemories(sessionID);
|
||||
await promotePendingMemories(sessionID, { includeOwnedJournal: true, includeUnownedJournal: false });
|
||||
promoted = true;
|
||||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
if (promoted) {
|
||||
frozenWorkspaceMemoryCache.delete(sessionID);
|
||||
processedUserMessages.delete(sessionID);
|
||||
sessionParentCache.delete(sessionID);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up caches
|
||||
frozenWorkspaceMemoryCache.delete(sessionID);
|
||||
processedUserMessages.delete(sessionID);
|
||||
sessionParentCache.delete(sessionID);
|
||||
await rm(await sessionStatePath(directory, sessionID), { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
+29
-14
@@ -8,6 +8,7 @@ export type PendingPromotionAccounting = {
|
||||
absorbedKeys: Set<string>;
|
||||
supersededKeys: Set<string>;
|
||||
rejectedKeys: Set<string>;
|
||||
retryableRejectedKeys: Set<string>;
|
||||
clearableKeys: Set<string>;
|
||||
};
|
||||
|
||||
@@ -72,24 +73,38 @@ export function accountPendingPromotions(input: {
|
||||
rejectedKeys.add(key);
|
||||
}
|
||||
|
||||
const clearableKeys = new Set([
|
||||
...promotedKeys,
|
||||
...absorbedKeys,
|
||||
...supersededKeys,
|
||||
...input.pending
|
||||
.filter(memory => {
|
||||
const terminal = terminalEventByKey.get(memoryKey(memory));
|
||||
return memory.source === "compaction" && (
|
||||
terminal?.reason === "rejected_capacity" ||
|
||||
terminal?.reason === "rejected_stale"
|
||||
);
|
||||
})
|
||||
.map(memory => memoryKey(memory)),
|
||||
]);
|
||||
|
||||
const retryableRejectedKeys = new Set(
|
||||
input.pending
|
||||
.filter(memory => {
|
||||
const key = memoryKey(memory);
|
||||
return rejectedKeys.has(key) &&
|
||||
!clearableKeys.has(key) &&
|
||||
(memory.source === "explicit" || memory.source === "manual");
|
||||
})
|
||||
.map(memory => memoryKey(memory)),
|
||||
);
|
||||
|
||||
return {
|
||||
promotedKeys,
|
||||
absorbedKeys,
|
||||
supersededKeys,
|
||||
rejectedKeys,
|
||||
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)),
|
||||
]),
|
||||
retryableRejectedKeys,
|
||||
clearableKeys,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 { 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";
|
||||
|
||||
const fileLocks = new Map<string, Promise<unknown>>();
|
||||
const LOCK_WAIT_TIMEOUT_MS = 5000;
|
||||
const LOCK_STALE_MS = 30_000;
|
||||
const LOCK_HEARTBEAT_MS = 1_000;
|
||||
|
||||
export async function readJSON<T>(path: string, fallback: () => T): Promise<T> {
|
||||
if (!existsSync(path)) return fallback();
|
||||
@@ -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> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
||||
@@ -36,10 +116,12 @@ export async function updateJSON<T>(
|
||||
|
||||
try {
|
||||
await previous.catch(() => undefined);
|
||||
const current = await readJSON(path, fallback);
|
||||
const updated = await updater(current);
|
||||
await atomicWriteJSON(path, updated);
|
||||
return updated;
|
||||
return await withFileLock(path, async () => {
|
||||
const current = await readJSONStrict(path, fallback);
|
||||
const updated = await updater(current);
|
||||
await atomicWriteJSON(path, updated);
|
||||
return updated;
|
||||
});
|
||||
} finally {
|
||||
release();
|
||||
if (fileLocks.get(path) === queued) {
|
||||
|
||||
@@ -15,6 +15,11 @@ export type LongTermMemoryEntry = {
|
||||
staleAfterDays?: number;
|
||||
supersedes?: string[];
|
||||
tags?: string[];
|
||||
pendingOwnerSessionID?: string;
|
||||
pendingMessageID?: string;
|
||||
promotionAttempts?: number;
|
||||
lastPromotionAttemptAt?: string;
|
||||
lastPromotionFailureReason?: string;
|
||||
};
|
||||
|
||||
export type WorkspaceMemoryStore = {
|
||||
@@ -100,3 +105,15 @@ export const HOT_STATE_LIMITS = {
|
||||
maxPendingMemoriesStored: 12,
|
||||
maxPendingMemoriesRendered: 6,
|
||||
} as const;
|
||||
|
||||
export const PROMOTION_RETRY_LIMITS = {
|
||||
maxExplicitAttempts: 3,
|
||||
maxManualAttempts: 3,
|
||||
} as const;
|
||||
|
||||
export const WORKSPACE_MEMORY_CACHE_LIMITS = {
|
||||
maxFrozenSessions: 50,
|
||||
maxProcessedSessionIDs: 200,
|
||||
maxProcessedMessagesPerSession: 50,
|
||||
frozenTtlMs: 60 * 60 * 1000,
|
||||
} as const;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
+201
-154
@@ -1,20 +1,16 @@
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
|
||||
import { LONG_TERM_LIMITS } from "./types.ts";
|
||||
import { workspaceKey, workspaceMemoryPath } from "./paths.ts";
|
||||
import { migrationLogPath, workspaceKey, workspaceMemoryPath } from "./paths.ts";
|
||||
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts";
|
||||
import { redactCredentials } from "./redaction.ts";
|
||||
|
||||
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
|
||||
const MIN_ENVELOPE_LENGTH = 80;
|
||||
const MIGRATION_ID = "2026-04-26-p0-cleanup";
|
||||
|
||||
const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s\[]+`;
|
||||
|
||||
const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i;
|
||||
const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i;
|
||||
|
||||
const 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 QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup";
|
||||
|
||||
export type MemoryConsolidationReason =
|
||||
| "promoted"
|
||||
@@ -45,6 +41,21 @@ export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & {
|
||||
events: MemoryConsolidationEvent[];
|
||||
};
|
||||
|
||||
export type QualityCleanupMigrationLogEntry = {
|
||||
migrationId: string;
|
||||
timestamp: string;
|
||||
workspaceKey: string;
|
||||
workspaceRoot: string;
|
||||
entryId: string;
|
||||
type: LongTermMemoryEntry["type"];
|
||||
source: LongTermMemoryEntry["source"];
|
||||
text: string;
|
||||
reasons: string[];
|
||||
hardReasons: string[];
|
||||
beforeStatus: "active";
|
||||
afterStatus: "superseded";
|
||||
};
|
||||
|
||||
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
|
||||
return {
|
||||
version: 1,
|
||||
@@ -77,30 +88,33 @@ export async function loadWorkspaceMemory(root: string): Promise<WorkspaceMemory
|
||||
};
|
||||
|
||||
// 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).
|
||||
if (didStoreMeaningfullyChange(store, normalized)) {
|
||||
await atomicWriteJSON(path, normalized);
|
||||
// Persist security/correctness mutations, but avoid read-time maintenance
|
||||
// writes for ordering/capacity/timestamp-only normalization.
|
||||
if (hasSecurityOrMigrationChange(store, normalized.store)) {
|
||||
await atomicWriteJSON(path, normalized.store);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
return normalized.store;
|
||||
}
|
||||
|
||||
function didStoreMeaningfullyChange(
|
||||
function hasSecurityOrMigrationChange(
|
||||
before: WorkspaceMemoryStore,
|
||||
after: WorkspaceMemoryStore,
|
||||
): boolean {
|
||||
const sanitize = (store: WorkspaceMemoryStore) => ({
|
||||
...store,
|
||||
updatedAt: "",
|
||||
entries: store.entries.map(entry => ({
|
||||
...entry,
|
||||
updatedAt: "",
|
||||
})),
|
||||
});
|
||||
const beforeById = new Map((before.entries ?? []).map(entry => [entry.id, entry]));
|
||||
for (const afterEntry of after.entries ?? []) {
|
||||
const beforeEntry = beforeById.get(afterEntry.id);
|
||||
if (!beforeEntry) continue;
|
||||
if (beforeEntry.text !== afterEntry.text) return true;
|
||||
if ((beforeEntry.rationale ?? "") !== (afterEntry.rationale ?? "")) return true;
|
||||
if (beforeEntry.status !== afterEntry.status) return true;
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -180,8 +194,26 @@ export async function normalizeWorkspaceMemoryWithAccounting(
|
||||
};
|
||||
});
|
||||
|
||||
// One-time migration for legacy snapshot violations
|
||||
result = runMigrationP0Cleanup(result, nowIso);
|
||||
// One-time migrations for legacy/low-quality snapshot violations.
|
||||
// Run quality cleanup first so hard violations receive quality audit tags
|
||||
// before the older P0 project-only cleanup marks progress snapshots.
|
||||
const beforeQualityCleanup = result;
|
||||
const qualityCleanup = runMigrationQualityCleanup(result, nowIso);
|
||||
result = qualityCleanup.store;
|
||||
let skipRemainingMigrations = false;
|
||||
if (qualityCleanup.events.length > 0) {
|
||||
try {
|
||||
await appendQualityCleanupMigrationLog(qualityCleanup.events);
|
||||
} catch (error) {
|
||||
console.error("[memory] failed to write quality cleanup migration log:", error);
|
||||
console.error("[memory] aborting migration to maintain audit trail integrity");
|
||||
result = beforeQualityCleanup;
|
||||
skipRemainingMigrations = true;
|
||||
}
|
||||
}
|
||||
if (!skipRemainingMigrations) {
|
||||
result = runMigrationP0Cleanup(result, nowIso);
|
||||
}
|
||||
|
||||
// P0 accounting only considers active entries. Entries that were already
|
||||
// superseded before this normalization are preserved in storage; entries that
|
||||
@@ -207,55 +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]",
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isProjectSnapshotViolation(text: string): boolean {
|
||||
// Test/suite counts
|
||||
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
|
||||
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
|
||||
|
||||
// File counts with snapshot context, excluding limit statements
|
||||
if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) {
|
||||
const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text);
|
||||
const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text);
|
||||
if (hasSnapshotContext && !hasLimitContext) return true;
|
||||
}
|
||||
|
||||
// Phase/Wave/Sprint/Milestone/Task progress
|
||||
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) {
|
||||
if (/completed|done|finished|完成/i.test(text)) return true;
|
||||
}
|
||||
|
||||
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function runMigrationP0Cleanup(
|
||||
store: WorkspaceMemoryStore,
|
||||
nowIso: string,
|
||||
@@ -265,10 +248,10 @@ export function runMigrationP0Cleanup(
|
||||
}
|
||||
|
||||
const entries = store.entries.map(entry => {
|
||||
if (entry.source === "explicit") return entry;
|
||||
if (entry.source !== "compaction") return entry;
|
||||
if (entry.type !== "project") return entry;
|
||||
|
||||
if (isProjectSnapshotViolation(entry.text)) {
|
||||
if (isProgressSnapshotViolation(entry.text)) {
|
||||
return {
|
||||
...entry,
|
||||
status: "superseded" as const,
|
||||
@@ -287,6 +270,74 @@ export function runMigrationP0Cleanup(
|
||||
};
|
||||
}
|
||||
|
||||
async function appendQualityCleanupMigrationLog(events: QualityCleanupMigrationLogEntry[]): Promise<void> {
|
||||
if (events.length === 0) return;
|
||||
const path = migrationLogPath(QUALITY_CLEANUP_MIGRATION_ID);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await appendFile(path, events.map(event => JSON.stringify(event)).join("\n") + "\n", "utf8");
|
||||
}
|
||||
|
||||
export function runMigrationQualityCleanup(
|
||||
store: WorkspaceMemoryStore,
|
||||
nowIso: string,
|
||||
): { store: WorkspaceMemoryStore; events: QualityCleanupMigrationLogEntry[] } {
|
||||
if (store.migrations?.includes(QUALITY_CLEANUP_MIGRATION_ID)) {
|
||||
return { store, events: [] };
|
||||
}
|
||||
|
||||
const events: QualityCleanupMigrationLogEntry[] = [];
|
||||
let changed = false;
|
||||
const entries = store.entries.map(entry => {
|
||||
if (entry.source !== "compaction") return entry;
|
||||
if (entry.status === "superseded") return entry;
|
||||
|
||||
const quality = assessMemoryQuality(entry);
|
||||
if (quality.accepted) return entry;
|
||||
|
||||
const hardReasons = quality.reasons.filter(isHardQualityReason);
|
||||
if (hardReasons.length === 0) return entry;
|
||||
|
||||
changed = true;
|
||||
events.push({
|
||||
migrationId: QUALITY_CLEANUP_MIGRATION_ID,
|
||||
timestamp: nowIso,
|
||||
workspaceKey: store.workspace.key,
|
||||
workspaceRoot: store.workspace.root,
|
||||
entryId: entry.id,
|
||||
type: entry.type,
|
||||
source: entry.source,
|
||||
text: entry.text,
|
||||
reasons: quality.reasons,
|
||||
hardReasons,
|
||||
beforeStatus: "active",
|
||||
afterStatus: "superseded",
|
||||
});
|
||||
|
||||
const tags = new Set([
|
||||
...(entry.tags ?? []),
|
||||
"quality_cleanup",
|
||||
...hardReasons.map(reason => `quality:${reason}`),
|
||||
]);
|
||||
|
||||
return {
|
||||
...entry,
|
||||
status: "superseded" as const,
|
||||
updatedAt: nowIso,
|
||||
tags: [...tags],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
store: {
|
||||
...store,
|
||||
entries,
|
||||
migrations: [...(store.migrations ?? []), QUALITY_CLEANUP_MIGRATION_ID],
|
||||
updatedAt: changed ? nowIso : store.updatedAt,
|
||||
},
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
function sourcePriority(source: LongTermMemoryEntry["source"]): number {
|
||||
if (source === "explicit") return 3;
|
||||
if (source === "manual") return 2;
|
||||
@@ -305,72 +356,80 @@ export function workspaceMemoryExactKey(entry: Pick<LongTermMemoryEntry, "type"
|
||||
return `${entry.type}:${canonicalMemoryText(entry.text)}`;
|
||||
}
|
||||
|
||||
/** Extract entity/destination keys for project and reference dedup */
|
||||
function extractEntityKey(text: string): string | null {
|
||||
const normalized = canonicalMemoryText(text);
|
||||
// Check known key phrases (bilingual-friendly)
|
||||
// opencode + agenthub plugin system
|
||||
if (/opencode.*agenthub/i.test(normalized)) {
|
||||
return "opencode-agenthub plugin system";
|
||||
function normalizeUrlIdentity(raw: string): string | null {
|
||||
const cleaned = raw.replace(/[),.;:!?]+$/g, "");
|
||||
try {
|
||||
const url = new URL(cleaned);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
url.protocol = url.protocol.toLowerCase();
|
||||
url.hostname = url.hostname.toLowerCase();
|
||||
url.hash = "";
|
||||
if (url.pathname.length > 1) {
|
||||
url.pathname = url.pathname.replace(/\/+$/g, "");
|
||||
}
|
||||
return `url:${url.toString()}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// For generic config references, fall back to canonical text dedup — no entity key
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract decision topic key for supersession detection */
|
||||
function decisionTopicKey(text: string): string | null {
|
||||
const normalized = text.toLowerCase();
|
||||
// Parser format versions
|
||||
if (/parser.*formats?|supports?\s*\d+\s*format/i.test(normalized)) {
|
||||
return "parser-supported-formats";
|
||||
}
|
||||
// Compaction template replacement
|
||||
if (/compaction.*template|output\.prompt|template.*replace/i.test(normalized)) {
|
||||
return "compaction-template-replacement";
|
||||
}
|
||||
// Plugin loading
|
||||
if (/plugin.*load|npm.*cache|plugin.*config/i.test(normalized)) {
|
||||
return "plugin-loading-config";
|
||||
}
|
||||
// Output format changes (purple/italic, YAML frontmatter, etc)
|
||||
if (/purple.*italic|markup|markdown.*render|frontmatter/i.test(normalized)) {
|
||||
return "output-format-rendering";
|
||||
}
|
||||
return null;
|
||||
function normalizePathIdentity(raw: string): string | null {
|
||||
const unwrapped = raw
|
||||
.trim()
|
||||
.replace(/^[`"']+|[`"']+$/g, "")
|
||||
.replace(/[),.;:!?]+$/g, "")
|
||||
.replace(/\\+/g, "/");
|
||||
|
||||
if (!unwrapped) return null;
|
||||
const collapsed = unwrapped.startsWith("/")
|
||||
? `/${unwrapped.slice(1).replace(/\/+$/g, "/").replace(/\/+/g, "/")}`
|
||||
: unwrapped.replace(/\/+/g, "/");
|
||||
const withoutTrailingSlash = collapsed.length > 1 ? collapsed.replace(/\/+$/g, "") : collapsed;
|
||||
return `path:${withoutTrailingSlash}`;
|
||||
}
|
||||
|
||||
/** Extract feedback topic key for supersession detection */
|
||||
function feedbackTopicKey(text: string): string | null {
|
||||
const normalized = text.toLowerCase();
|
||||
// Purple/italic rendering issue
|
||||
if (/purple.*italic/i.test(normalized)) {
|
||||
return "purple-italic-rendering";
|
||||
function isConcretePathIdentity(pathIdentity: string): boolean {
|
||||
const path = pathIdentity.slice("path:".length);
|
||||
if (!path || path === "." || path === "..") return false;
|
||||
|
||||
if (path.startsWith("/")) return true;
|
||||
if (/^\.\.?\//.test(path)) return true;
|
||||
if (/^\.[A-Za-z0-9_.-]+\//.test(path)) return true;
|
||||
if (/^[A-Za-z0-9_.-]+\//.test(path)) return true;
|
||||
return /\.(?:json|jsonc|ts|tsx|js|jsx|mjs|cjs|md|yaml|yml|toml|lock|config)$/i.test(path);
|
||||
}
|
||||
|
||||
function normalizeConcretePathIdentity(raw: string): string | null {
|
||||
const pathIdentity = normalizePathIdentity(raw);
|
||||
if (!pathIdentity) return null;
|
||||
return isConcretePathIdentity(pathIdentity) ? pathIdentity : null;
|
||||
}
|
||||
|
||||
function extractConcreteIdentityKey(text: string): string | null {
|
||||
const urlMatch = text.match(/https?:\/\/[^\s`"'<>]+/i);
|
||||
if (urlMatch) {
|
||||
const urlIdentity = normalizeUrlIdentity(urlMatch[0]);
|
||||
if (urlIdentity) return urlIdentity;
|
||||
}
|
||||
// Browser login/server errors (500 internal_error)
|
||||
if (/login.*500|500.*internal|internal_error|server.*error/i.test(normalized)) {
|
||||
return "server-error";
|
||||
|
||||
const wrappedPathPattern = /[`"']([^`"']+)[`"']/g;
|
||||
for (const match of text.matchAll(wrappedPathPattern)) {
|
||||
const pathIdentity = normalizeConcretePathIdentity(match[1]);
|
||||
if (pathIdentity) return pathIdentity;
|
||||
}
|
||||
// Port occupied / environment issues
|
||||
if (/port.*occup|9473|端口|舊進程|旧进程/i.test(normalized)) {
|
||||
return "port-occupied-environment";
|
||||
}
|
||||
// Theme preferences
|
||||
if (/theme|dark.*light|prefer.*theme/i.test(normalized)) {
|
||||
return "theme-preference";
|
||||
}
|
||||
return null;
|
||||
|
||||
const pathMatch = text.match(/(?:\/[^ | ||||