mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-06-02 06:19:36 +02:00
fix(memory): isolate test workspace cleanup
This commit is contained in:
+2
-1
@@ -52,5 +52,6 @@ pnpm-lock.yaml
|
||||
# Superpowers local planning artifacts
|
||||
docs/superpowers/plans/
|
||||
|
||||
# Local migration dry-run roots
|
||||
# Local dev/admin script inputs
|
||||
scripts/dev/run-migration-roots.local.txt
|
||||
scripts/dev/dry-run-roots.local.txt
|
||||
|
||||
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- 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
|
||||
|
||||
@@ -21,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- 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
|
||||
|
||||
|
||||
@@ -176,6 +176,15 @@ 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.
|
||||
@@ -212,6 +221,7 @@ cd opencode-working-memory
|
||||
npm install
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run cleanup:workspaces -- --dry-run
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -16,6 +16,8 @@ The quality gate is now shared across compaction extraction and migration checks
|
||||
- **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
|
||||
|
||||
@@ -63,6 +65,22 @@ Explicit and manual memories are also protected.
|
||||
|
||||
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.
|
||||
|
||||
+2
-1
@@ -16,7 +16,8 @@
|
||||
"scripts": {
|
||||
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test --experimental-strip-types tests/*.test.ts",
|
||||
"test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts",
|
||||
"cleanup:workspaces": "node --experimental-strip-types scripts/dev/cleanup-workspaces.ts",
|
||||
"check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Safely inspect or quarantine stale test/temp workspace memory stores.
|
||||
*
|
||||
* Default mode is dry-run. Quarantine moves only definite temp/test residues.
|
||||
* Unknown missing roots are reported but skipped unless --include-orphans is set.
|
||||
*/
|
||||
|
||||
import { cleanupWorkspaceResidues } from "../../src/workspace-cleanup.ts";
|
||||
|
||||
type CliOptions = {
|
||||
mode: "dry-run" | "quarantine";
|
||||
dataHome?: string;
|
||||
olderThanDays?: number;
|
||||
includeOrphans: boolean;
|
||||
};
|
||||
|
||||
function usage(): string {
|
||||
return `Usage:
|
||||
npm run cleanup:workspaces -- --dry-run
|
||||
npm run cleanup:workspaces -- --quarantine
|
||||
npm run cleanup:workspaces -- --quarantine --older-than-days 1
|
||||
|
||||
Options:
|
||||
--dry-run List candidates without moving anything (default)
|
||||
--quarantine Move definite temp/test residues to quarantine
|
||||
--data-home <path> Override XDG data home for testing/admin work
|
||||
--older-than-days <n> Only consider workspace dirs older than n days
|
||||
--include-orphans Also quarantine missing non-temp roots (off by default)
|
||||
--help Show this help
|
||||
`;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
const options: CliOptions = { mode: "dry-run", includeOrphans: false };
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
switch (arg) {
|
||||
case "--dry-run":
|
||||
options.mode = "dry-run";
|
||||
break;
|
||||
case "--quarantine":
|
||||
options.mode = "quarantine";
|
||||
break;
|
||||
case "--data-home":
|
||||
options.dataHome = argv[++i];
|
||||
if (!options.dataHome) throw new Error("--data-home requires a path");
|
||||
break;
|
||||
case "--older-than-days": {
|
||||
const value = Number(argv[++i]);
|
||||
if (!Number.isFinite(value) || value < 0) throw new Error("--older-than-days requires a non-negative number");
|
||||
options.olderThanDays = value;
|
||||
break;
|
||||
}
|
||||
case "--include-orphans":
|
||||
options.includeOrphans = true;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
default:
|
||||
throw new Error(`Unknown option: ${arg}\n${usage()}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const result = await cleanupWorkspaceResidues({
|
||||
dataHome: options.dataHome,
|
||||
mode: options.mode,
|
||||
includeOrphans: options.includeOrphans,
|
||||
minAgeMs: options.olderThanDays === undefined ? undefined : options.olderThanDays * 24 * 60 * 60 * 1_000,
|
||||
});
|
||||
|
||||
console.log(`Mode: ${result.mode}`);
|
||||
console.log(`Scanned: ${result.results.length}`);
|
||||
console.log(`Candidates: ${result.candidates.length}`);
|
||||
|
||||
if (result.candidates.length > 0) {
|
||||
console.log("\nCandidates:");
|
||||
for (const candidate of result.candidates) {
|
||||
console.log(`- ${candidate.workspaceKey} ${candidate.classification} root=${candidate.root ?? "<missing>"}`);
|
||||
console.log(` reasons=${candidate.reasons.join(",")}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.quarantined.length > 0) {
|
||||
console.log(`\nQuarantined: ${result.quarantined.length}`);
|
||||
console.log(`Quarantine dir: ${result.quarantineDir}`);
|
||||
}
|
||||
|
||||
const unknownOrphans = result.results.filter(item => item.classification === "orphan_unknown");
|
||||
if (unknownOrphans.length > 0 && !options.includeOrphans) {
|
||||
console.log(`\nUnknown missing-root workspaces skipped: ${unknownOrphans.length}`);
|
||||
console.log("Use --include-orphans only after manually confirming they are safe to quarantine.");
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
* Local helper to trigger migration on workspace roots.
|
||||
*
|
||||
* Usage:
|
||||
* MIGRATION_DRY_RUN_ROOTS=/path/a:/path/b bun run scripts/dev/dry-run-migration.ts
|
||||
* 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/dry-run-roots.local.txt
|
||||
* echo "/path/to/workspace2" >> scripts/dev/dry-run-roots.local.txt
|
||||
* bun run scripts/dev/dry-run-migration.ts
|
||||
* 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";
|
||||
@@ -17,13 +17,13 @@ import { loadWorkspaceMemory } from "../../src/workspace-memory.ts";
|
||||
|
||||
async function getRoots(): Promise<string[]> {
|
||||
// Priority 1: environment variable
|
||||
const envRoots = process.env.MIGRATION_DRY_RUN_ROOTS;
|
||||
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, "dry-run-roots.local.txt");
|
||||
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);
|
||||
@@ -31,7 +31,7 @@ async function getRoots(): Promise<string[]> {
|
||||
|
||||
// No roots configured
|
||||
console.log("No workspace roots configured.");
|
||||
console.log("Set MIGRATION_DRY_RUN_ROOTS=/path/a:/path/b or create dry-run-roots.local.txt");
|
||||
console.log("Set MIGRATION_RUN_ROOTS=/path/a:/path/b or create run-migration-roots.local.txt");
|
||||
return [];
|
||||
}
|
||||
|
||||
+2
-10
@@ -5,6 +5,7 @@ import type { ActiveFile, LongTermMemoryEntry, LongTermType, OpenError } from ".
|
||||
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)}`;
|
||||
@@ -248,15 +249,6 @@ async function logExtractionRejection(entry: ExtractionRejectionLogEntry): Promi
|
||||
}
|
||||
}
|
||||
|
||||
function redactSensitiveText(text: string): string {
|
||||
return text
|
||||
.replace(/bearer\s+[a-zA-Z0-9._-]+/gi, "bearer [REDACTED]")
|
||||
.replace(/token[=:]\s*[a-zA-Z0-9._-]+/gi, "token=[REDACTED]")
|
||||
.replace(/password[=:]\s*[a-zA-Z0-9._-]+/gi, "password=[REDACTED]")
|
||||
.replace(/secret[=:]\s*[a-zA-Z0-9._-]+/gi, "secret=[REDACTED]")
|
||||
.replace(/api[-_]?key[=:]\s*[a-zA-Z0-9._-]+/gi, "api_key=[REDACTED]");
|
||||
}
|
||||
|
||||
function shouldAcceptWorkspaceMemoryCandidate(
|
||||
entry: {
|
||||
type: LongTermType;
|
||||
@@ -287,7 +279,7 @@ function shouldAcceptWorkspaceMemoryCandidate(
|
||||
void logExtractionRejection({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: entry.type,
|
||||
text: redactSensitiveText(text),
|
||||
text: redactCredentials(text),
|
||||
reasons: quality.reasons,
|
||||
source: "compaction",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Shared redaction utilities for sensitive credential patterns.
|
||||
* Used by both workspace memory normalization and extraction rejection logging.
|
||||
*/
|
||||
|
||||
// Password labels in multiple languages
|
||||
const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i;
|
||||
|
||||
// Username labels in multiple languages
|
||||
const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i;
|
||||
|
||||
// Sensitive key labels
|
||||
const SENSITIVE_LABELS = /api[_-]?key|token|bearer|secret|credential|auth|auth[_-]?key|private[_-]?key/i;
|
||||
|
||||
// Secret value pattern (excludes common delimiters and brackets)
|
||||
const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s\[]+`;
|
||||
|
||||
// Prefix patterns for different credential types
|
||||
const PIN_PREFIX = String.raw`(\bPIN\b(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||
const PASSWORD_PREFIX = String.raw`((?:${PASSWORD_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||
const USERNAME_PREFIX = String.raw`((?:${USERNAME_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||
const SENSITIVE_PREFIX = String.raw`((?:${SENSITIVE_LABELS.source})(?:\s*(?:推|是|=|:|:)\s*|[::]\s*))`;
|
||||
const BEARER_PREFIX = String.raw`(Bearer\s+)`;
|
||||
|
||||
/**
|
||||
* Redacts sensitive credentials from text.
|
||||
* Handles:
|
||||
* - PINs in multiple formats
|
||||
* - Username/password pairs
|
||||
* - Standalone passwords
|
||||
* - Bearer tokens
|
||||
* - API keys, secrets, credentials, auth tokens, private keys
|
||||
*
|
||||
* Supports multiple languages and delimiters (ASCII and CJK).
|
||||
*/
|
||||
export function redactCredentials(text: string): string {
|
||||
let result = text;
|
||||
|
||||
// 1. PIN
|
||||
result = result.replace(
|
||||
new RegExp(String.raw`${PIN_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||
"$1[REDACTED]",
|
||||
);
|
||||
|
||||
// 2. Username+password pair
|
||||
result = result.replace(
|
||||
new RegExp(
|
||||
String.raw`${USERNAME_PREFIX}[\`'"]?(${SECRET_VALUE})((?:,|,)\s*)${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`,
|
||||
"gi",
|
||||
),
|
||||
"$1[REDACTED]$3$4[REDACTED]",
|
||||
);
|
||||
|
||||
// 3. Standalone password
|
||||
result = result.replace(
|
||||
new RegExp(String.raw`${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||
"$1[REDACTED]",
|
||||
);
|
||||
|
||||
// 4. Bearer tokens (but not "bearer token:" labels)
|
||||
result = result.replace(
|
||||
new RegExp(String.raw`${BEARER_PREFIX}(?!token\s*[:=:])[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||
"$1[REDACTED]",
|
||||
);
|
||||
|
||||
// 5. Sensitive keys/tokens
|
||||
result = result.replace(
|
||||
new RegExp(String.raw`${SENSITIVE_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||
"$1[REDACTED]",
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { appendFile, mkdir, readFile, readdir, rename, stat } from "node:fs/promises";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dataHome as defaultDataHome } from "./paths.ts";
|
||||
|
||||
export type WorkspaceCleanupClassification =
|
||||
| "test_temp_definite"
|
||||
| "orphan_unknown"
|
||||
| "live_or_existing"
|
||||
| "invalid_or_unreadable";
|
||||
|
||||
export type WorkspaceCleanupResult = {
|
||||
workspaceKey: string;
|
||||
workspaceDir: string;
|
||||
root?: string;
|
||||
rootExists: boolean;
|
||||
classification: WorkspaceCleanupClassification;
|
||||
reasons: string[];
|
||||
entryCount?: number;
|
||||
migrations?: string[];
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type WorkspaceCleanupScanOptions = {
|
||||
dataHome?: string;
|
||||
nowMs?: number;
|
||||
minAgeMs?: number;
|
||||
includeOrphans?: boolean;
|
||||
};
|
||||
|
||||
export type WorkspaceCleanupScan = {
|
||||
results: WorkspaceCleanupResult[];
|
||||
candidates: WorkspaceCleanupResult[];
|
||||
};
|
||||
|
||||
export type WorkspaceCleanupMode = "dry-run" | "quarantine";
|
||||
|
||||
export type WorkspaceCleanupOptions = WorkspaceCleanupScanOptions & {
|
||||
mode?: WorkspaceCleanupMode;
|
||||
now?: Date;
|
||||
};
|
||||
|
||||
export type WorkspaceCleanupQuarantineEvent = WorkspaceCleanupResult & {
|
||||
from: string;
|
||||
to: string;
|
||||
quarantinedAt: string;
|
||||
};
|
||||
|
||||
export type WorkspaceCleanupRunResult = WorkspaceCleanupScan & {
|
||||
mode: WorkspaceCleanupMode;
|
||||
quarantined: WorkspaceCleanupQuarantineEvent[];
|
||||
quarantineDir?: string;
|
||||
};
|
||||
|
||||
type WorkspaceMemoryShape = {
|
||||
workspace?: {
|
||||
root?: unknown;
|
||||
key?: unknown;
|
||||
};
|
||||
entries?: unknown[];
|
||||
migrations?: unknown[];
|
||||
updatedAt?: unknown;
|
||||
};
|
||||
|
||||
const DEFAULT_MIN_AGE_MS = 10 * 60 * 1_000;
|
||||
|
||||
const KNOWN_TEST_ROOT_PREFIXES = [
|
||||
"memory-plugin-test-",
|
||||
"memory-plugin-prompt-",
|
||||
"wm-",
|
||||
"wm-quality-",
|
||||
"wm-accounting-",
|
||||
"wm-redact-",
|
||||
"wm-normalized-",
|
||||
"wm-ordering-",
|
||||
"wm-extraction-",
|
||||
];
|
||||
|
||||
function normalizePathForComparison(path: string): string {
|
||||
return resolve(path).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function isInsidePath(path: string, parent: string): boolean {
|
||||
const normalizedPath = normalizePathForComparison(path);
|
||||
const normalizedParent = normalizePathForComparison(parent);
|
||||
return normalizedPath === normalizedParent || normalizedPath.startsWith(`${normalizedParent}/`);
|
||||
}
|
||||
|
||||
export function isTempRoot(root: string, osTmpdir = tmpdir()): boolean {
|
||||
const normalized = normalizePathForComparison(root);
|
||||
const normalizedTmp = normalizePathForComparison(osTmpdir);
|
||||
|
||||
if (isInsidePath(normalized, normalizedTmp)) return true;
|
||||
if (isInsidePath(normalized, "/tmp")) return true;
|
||||
if (isInsidePath(normalized, "/private/tmp")) return true;
|
||||
|
||||
return /^\/(?:private\/)?var\/folders\/[^/]+\/[^/]+\/T(?:\/|$)/.test(normalized);
|
||||
}
|
||||
|
||||
export function isKnownTestWorkspaceRoot(root: string): boolean {
|
||||
const name = basename(root);
|
||||
return KNOWN_TEST_ROOT_PREFIXES.some(prefix => name.startsWith(prefix));
|
||||
}
|
||||
|
||||
function classifyCandidate(result: WorkspaceCleanupResult, includeOrphans: boolean): boolean {
|
||||
if (result.reasons.includes("recent_workspace_dir")) return false;
|
||||
if (result.reasons.includes("lock_present")) return false;
|
||||
if (result.classification === "test_temp_definite") return true;
|
||||
return includeOrphans && result.classification === "orphan_unknown";
|
||||
}
|
||||
|
||||
export async function classifyWorkspaceDir(
|
||||
workspaceDir: string,
|
||||
options: { nowMs?: number; minAgeMs?: number } = {},
|
||||
): Promise<WorkspaceCleanupResult> {
|
||||
const workspaceKey = basename(workspaceDir);
|
||||
const reasons: string[] = [];
|
||||
const memoryPath = join(workspaceDir, "workspace-memory.json");
|
||||
|
||||
if (existsSync(`${memoryPath}.lock`)) {
|
||||
reasons.push("lock_present");
|
||||
}
|
||||
|
||||
let stats;
|
||||
try {
|
||||
stats = await stat(workspaceDir);
|
||||
} catch {
|
||||
return {
|
||||
workspaceKey,
|
||||
workspaceDir,
|
||||
rootExists: false,
|
||||
classification: "invalid_or_unreadable",
|
||||
reasons: ["workspace_dir_unreadable"],
|
||||
};
|
||||
}
|
||||
|
||||
const minAgeMs = options.minAgeMs ?? DEFAULT_MIN_AGE_MS;
|
||||
const nowMs = options.nowMs ?? Date.now();
|
||||
if (minAgeMs > 0 && nowMs - stats.mtimeMs < minAgeMs) {
|
||||
reasons.push("recent_workspace_dir");
|
||||
}
|
||||
|
||||
let store: WorkspaceMemoryShape;
|
||||
try {
|
||||
store = JSON.parse(await readFile(memoryPath, "utf8")) as WorkspaceMemoryShape;
|
||||
} catch {
|
||||
return {
|
||||
workspaceKey,
|
||||
workspaceDir,
|
||||
rootExists: false,
|
||||
classification: "invalid_or_unreadable",
|
||||
reasons: [...reasons, "invalid_json"],
|
||||
};
|
||||
}
|
||||
|
||||
const root = typeof store.workspace?.root === "string" ? store.workspace.root : undefined;
|
||||
const key = typeof store.workspace?.key === "string" ? store.workspace.key : workspaceKey;
|
||||
const entryCount = Array.isArray(store.entries) ? store.entries.length : undefined;
|
||||
const migrations = Array.isArray(store.migrations) ? store.migrations.filter((item): item is string => typeof item === "string") : undefined;
|
||||
const updatedAt = typeof store.updatedAt === "string" ? store.updatedAt : undefined;
|
||||
|
||||
if (!root) {
|
||||
return {
|
||||
workspaceKey: key,
|
||||
workspaceDir,
|
||||
rootExists: false,
|
||||
classification: "invalid_or_unreadable",
|
||||
reasons: [...reasons, "missing_workspace_root"],
|
||||
entryCount,
|
||||
migrations,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const rootExists = existsSync(root);
|
||||
if (rootExists) {
|
||||
return {
|
||||
workspaceKey: key,
|
||||
workspaceDir,
|
||||
root,
|
||||
rootExists,
|
||||
classification: "live_or_existing",
|
||||
reasons: [...reasons, "root_exists"],
|
||||
entryCount,
|
||||
migrations,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
reasons.push("root_missing");
|
||||
const tempRoot = isTempRoot(root);
|
||||
const testRoot = isKnownTestWorkspaceRoot(root);
|
||||
if (tempRoot) reasons.push("root_under_temp");
|
||||
if (testRoot) reasons.push(`test_prefix_${KNOWN_TEST_ROOT_PREFIXES.find(prefix => basename(root).startsWith(prefix))?.replace(/-$/, "") ?? basename(root)}`);
|
||||
|
||||
return {
|
||||
workspaceKey: key,
|
||||
workspaceDir,
|
||||
root,
|
||||
rootExists,
|
||||
classification: tempRoot || testRoot ? "test_temp_definite" : "orphan_unknown",
|
||||
reasons,
|
||||
entryCount,
|
||||
migrations,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function workspacesDir(dataHome: string): string {
|
||||
return join(dataHome, "opencode-working-memory", "workspaces");
|
||||
}
|
||||
|
||||
export async function scanWorkspaceResidues(options: WorkspaceCleanupScanOptions = {}): Promise<WorkspaceCleanupScan> {
|
||||
const root = workspacesDir(options.dataHome ?? defaultDataHome());
|
||||
const results: WorkspaceCleanupResult[] = [];
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(root);
|
||||
} catch {
|
||||
return { results, candidates: [] };
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const workspaceDir = join(root, entry);
|
||||
const stats = await stat(workspaceDir).catch(() => undefined);
|
||||
if (!stats?.isDirectory()) continue;
|
||||
|
||||
results.push(await classifyWorkspaceDir(workspaceDir, {
|
||||
nowMs: options.nowMs,
|
||||
minAgeMs: options.minAgeMs,
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
results,
|
||||
candidates: results.filter(result => classifyCandidate(result, options.includeOrphans ?? false)),
|
||||
};
|
||||
}
|
||||
|
||||
function quarantineName(now: Date): string {
|
||||
return `workspace-cleanup-${now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}`;
|
||||
}
|
||||
|
||||
export async function cleanupWorkspaceResidues(options: WorkspaceCleanupOptions = {}): Promise<WorkspaceCleanupRunResult> {
|
||||
const mode = options.mode ?? "dry-run";
|
||||
const now = options.now ?? new Date();
|
||||
const scan = await scanWorkspaceResidues({
|
||||
dataHome: options.dataHome,
|
||||
nowMs: options.nowMs,
|
||||
minAgeMs: options.minAgeMs,
|
||||
includeOrphans: options.includeOrphans,
|
||||
});
|
||||
|
||||
if (mode === "dry-run" || scan.candidates.length === 0) {
|
||||
return { ...scan, mode, quarantined: [] };
|
||||
}
|
||||
|
||||
const dataHome = options.dataHome ?? defaultDataHome();
|
||||
const quarantineDir = join(dataHome, "opencode-working-memory", "quarantine", quarantineName(now));
|
||||
const quarantined: WorkspaceCleanupQuarantineEvent[] = [];
|
||||
|
||||
for (const candidate of scan.candidates) {
|
||||
const destination = join(quarantineDir, "workspaces", candidate.workspaceKey);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await rename(candidate.workspaceDir, destination);
|
||||
|
||||
const event: WorkspaceCleanupQuarantineEvent = {
|
||||
...candidate,
|
||||
from: candidate.workspaceDir,
|
||||
to: destination,
|
||||
quarantinedAt: now.toISOString(),
|
||||
};
|
||||
quarantined.push(event);
|
||||
|
||||
await mkdir(quarantineDir, { recursive: true });
|
||||
await appendFile(join(quarantineDir, "manifest.jsonl"), JSON.stringify(event) + "\n", "utf8");
|
||||
}
|
||||
|
||||
return { ...scan, mode, quarantined, quarantineDir };
|
||||
}
|
||||
+1
-50
@@ -5,24 +5,13 @@ import { LONG_TERM_LIMITS } from "./types.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 QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup";
|
||||
|
||||
const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s\[]+`;
|
||||
|
||||
const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i;
|
||||
const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i;
|
||||
const SENSITIVE_LABELS = /api[_-]?key|token|bearer|secret|credential|auth|auth[_-]?key|private[_-]?key/i;
|
||||
|
||||
const PIN_PREFIX = String.raw`(\bPIN\b(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||
const PASSWORD_PREFIX = String.raw`((?:${PASSWORD_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||
const USERNAME_PREFIX = String.raw`((?:${USERNAME_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||
const SENSITIVE_PREFIX = String.raw`((?:${SENSITIVE_LABELS.source})(?:\s*(?:推|是|=|:|:)\s*|[::]\s*))`;
|
||||
const BEARER_PREFIX = String.raw`(Bearer\s+)`;
|
||||
|
||||
export type MemoryConsolidationReason =
|
||||
| "promoted"
|
||||
| "absorbed_exact"
|
||||
@@ -250,44 +239,6 @@ export async function normalizeWorkspaceMemoryWithAccounting(
|
||||
};
|
||||
}
|
||||
|
||||
export function redactCredentials(text: string): string {
|
||||
let result = text;
|
||||
|
||||
// 1. PIN
|
||||
result = result.replace(
|
||||
new RegExp(String.raw`${PIN_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||
"$1[REDACTED]",
|
||||
);
|
||||
|
||||
// 2. Username+password pair
|
||||
result = result.replace(
|
||||
new RegExp(
|
||||
String.raw`${USERNAME_PREFIX}[\`'"]?(${SECRET_VALUE})((?:,|,)\s*)${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`,
|
||||
"gi",
|
||||
),
|
||||
"$1[REDACTED]$3$4[REDACTED]",
|
||||
);
|
||||
|
||||
// 3. Standalone password
|
||||
result = result.replace(
|
||||
new RegExp(String.raw`${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||
"$1[REDACTED]",
|
||||
);
|
||||
|
||||
// 4. Standalone sensitive keys/tokens
|
||||
result = result.replace(
|
||||
new RegExp(String.raw`${BEARER_PREFIX}(?!token\s*[:=:])[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||
"$1[REDACTED]",
|
||||
);
|
||||
|
||||
result = result.replace(
|
||||
new RegExp(String.raw`${SENSITIVE_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||
"$1[REDACTED]",
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function runMigrationP0Cleanup(
|
||||
store: WorkspaceMemoryStore,
|
||||
nowIso: string,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { after } from "node:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
||||
const previousTestFlag = process.env.OPENCODE_WORKING_MEMORY_TEST;
|
||||
const testDataHome = await mkdtemp(join(tmpdir(), "opencode-working-memory-test-xdg-"));
|
||||
|
||||
process.env.XDG_DATA_HOME = testDataHome;
|
||||
process.env.OPENCODE_WORKING_MEMORY_TEST = "1";
|
||||
|
||||
after(async () => {
|
||||
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
||||
else process.env.XDG_DATA_HOME = previousXdgDataHome;
|
||||
|
||||
if (previousTestFlag === undefined) delete process.env.OPENCODE_WORKING_MEMORY_TEST;
|
||||
else process.env.OPENCODE_WORKING_MEMORY_TEST = previousTestFlag;
|
||||
|
||||
await rm(testDataHome, { recursive: true, force: true });
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
classifyWorkspaceDir,
|
||||
cleanupWorkspaceResidues,
|
||||
scanWorkspaceResidues,
|
||||
} from "../src/workspace-cleanup.ts";
|
||||
|
||||
async function writeWorkspaceStore(dataHome: string, key: string, root: string): Promise<string> {
|
||||
const workspaceDir = join(dataHome, "opencode-working-memory", "workspaces", key);
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(workspaceDir, "workspace-memory.json"),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
workspace: { root, key },
|
||||
limits: { maxRenderedChars: 5200, maxEntries: 28 },
|
||||
entries: [],
|
||||
updatedAt: "2026-04-28T00:00:00.000Z",
|
||||
}, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
return workspaceDir;
|
||||
}
|
||||
|
||||
test("workspace cleanup classifies missing temp test roots as definite residue", async () => {
|
||||
const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-"));
|
||||
try {
|
||||
const missingTempRoot = join(tmpdir(), "memory-plugin-test-missing-root");
|
||||
await rm(missingTempRoot, { recursive: true, force: true });
|
||||
const workspaceDir = await writeWorkspaceStore(dataHome, "definite", missingTempRoot);
|
||||
|
||||
const result = await classifyWorkspaceDir(workspaceDir);
|
||||
|
||||
assert.equal(result.classification, "test_temp_definite");
|
||||
assert.equal(result.rootExists, false);
|
||||
assert.ok(result.reasons.includes("root_missing"));
|
||||
assert.ok(result.reasons.some(reason => reason.startsWith("root_under_temp")));
|
||||
assert.ok(result.reasons.includes("test_prefix_memory-plugin-test"));
|
||||
} finally {
|
||||
await rm(dataHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace cleanup keeps existing temp roots live", async () => {
|
||||
const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-"));
|
||||
const liveRoot = await mkdtemp(join(tmpdir(), "wm-quality-live-root-"));
|
||||
try {
|
||||
const workspaceDir = await writeWorkspaceStore(dataHome, "live", liveRoot);
|
||||
|
||||
const result = await classifyWorkspaceDir(workspaceDir);
|
||||
|
||||
assert.equal(result.classification, "live_or_existing");
|
||||
assert.equal(result.rootExists, true);
|
||||
} finally {
|
||||
await rm(dataHome, { recursive: true, force: true });
|
||||
await rm(liveRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace cleanup reports missing non-temp roots as unknown orphans", async () => {
|
||||
const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-"));
|
||||
try {
|
||||
const missingNonTempRoot = `/definitely-not-temp-opencode-working-memory-test-${Date.now()}`;
|
||||
const workspaceDir = await writeWorkspaceStore(dataHome, "orphan", missingNonTempRoot);
|
||||
|
||||
const result = await classifyWorkspaceDir(workspaceDir);
|
||||
|
||||
assert.equal(result.classification, "orphan_unknown");
|
||||
assert.equal(result.rootExists, false);
|
||||
assert.ok(result.reasons.includes("root_missing"));
|
||||
} finally {
|
||||
await rm(dataHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace cleanup reports invalid stores without moving them", async () => {
|
||||
const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-"));
|
||||
try {
|
||||
const workspaceDir = join(dataHome, "opencode-working-memory", "workspaces", "invalid");
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
await writeFile(join(workspaceDir, "workspace-memory.json"), "{ invalid", "utf8");
|
||||
|
||||
const result = await classifyWorkspaceDir(workspaceDir);
|
||||
|
||||
assert.equal(result.classification, "invalid_or_unreadable");
|
||||
assert.ok(result.reasons.includes("invalid_json"));
|
||||
|
||||
const cleanup = await cleanupWorkspaceResidues({ dataHome, mode: "quarantine" });
|
||||
assert.equal(cleanup.quarantined.length, 0);
|
||||
assert.equal(existsSync(workspaceDir), true);
|
||||
} finally {
|
||||
await rm(dataHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace cleanup dry-run scans definite residue without moving it", async () => {
|
||||
const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-"));
|
||||
try {
|
||||
const missingTempRoot = join(tmpdir(), "wm-accounting-missing-root");
|
||||
await rm(missingTempRoot, { recursive: true, force: true });
|
||||
const workspaceDir = await writeWorkspaceStore(dataHome, "dryrun", missingTempRoot);
|
||||
|
||||
const result = await cleanupWorkspaceResidues({ dataHome, minAgeMs: 0 });
|
||||
|
||||
assert.equal(result.mode, "dry-run");
|
||||
assert.equal(result.candidates.length, 1);
|
||||
assert.equal(result.quarantined.length, 0);
|
||||
assert.equal(existsSync(workspaceDir), true);
|
||||
} finally {
|
||||
await rm(dataHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace cleanup quarantine moves definite residue and writes manifest", async () => {
|
||||
const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-"));
|
||||
try {
|
||||
const missingTempRoot = join(tmpdir(), "wm-redact-missing-root");
|
||||
await rm(missingTempRoot, { recursive: true, force: true });
|
||||
const definiteDir = await writeWorkspaceStore(dataHome, "definite", missingTempRoot);
|
||||
const orphanDir = await writeWorkspaceStore(dataHome, "orphan", `/definitely-not-temp-opencode-working-memory-test-${Date.now()}`);
|
||||
|
||||
const result = await cleanupWorkspaceResidues({
|
||||
dataHome,
|
||||
mode: "quarantine",
|
||||
minAgeMs: 0,
|
||||
now: new Date("2026-04-28T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
assert.equal(result.quarantined.length, 1);
|
||||
assert.equal(result.quarantined[0]?.workspaceKey, "definite");
|
||||
assert.equal(existsSync(definiteDir), false);
|
||||
assert.equal(existsSync(orphanDir), true);
|
||||
assert.ok(result.quarantineDir);
|
||||
assert.equal(existsSync(join(result.quarantineDir!, "workspaces", "definite", "workspace-memory.json")), true);
|
||||
|
||||
const manifest = await readFile(join(result.quarantineDir!, "manifest.jsonl"), "utf8");
|
||||
const event = JSON.parse(manifest.trim());
|
||||
assert.equal(event.workspaceKey, "definite");
|
||||
assert.equal(event.classification, "test_temp_definite");
|
||||
assert.equal(event.root, missingTempRoot);
|
||||
} finally {
|
||||
await rm(dataHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace cleanup skips recently updated definite residue", async () => {
|
||||
const dataHome = await mkdtemp(join(tmpdir(), "wm-cleanup-data-"));
|
||||
try {
|
||||
const missingTempRoot = join(tmpdir(), "wm-extraction-missing-root");
|
||||
await rm(missingTempRoot, { recursive: true, force: true });
|
||||
const workspaceDir = await writeWorkspaceStore(dataHome, "recent", missingTempRoot);
|
||||
|
||||
const stats = await stat(workspaceDir);
|
||||
const result = await scanWorkspaceResidues({
|
||||
dataHome,
|
||||
nowMs: stats.mtimeMs + 1_000,
|
||||
minAgeMs: 10 * 60 * 1_000,
|
||||
});
|
||||
|
||||
assert.equal(result.candidates.length, 0);
|
||||
assert.equal(result.results.find(item => item.workspaceKey === "recent")?.classification, "test_temp_definite");
|
||||
assert.ok(result.results.find(item => item.workspaceKey === "recent")?.reasons.includes("recent_workspace_dir"));
|
||||
} finally {
|
||||
await rm(dataHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -14,13 +14,13 @@ import {
|
||||
normalizeWorkspaceMemoryWithAccounting,
|
||||
workspaceMemoryExactKey,
|
||||
workspaceMemoryIdentityKey,
|
||||
redactCredentials,
|
||||
runMigrationP0Cleanup,
|
||||
runMigrationQualityCleanup,
|
||||
loadWorkspaceMemory,
|
||||
saveWorkspaceMemory,
|
||||
updateWorkspaceMemoryWithAccounting,
|
||||
} from "../src/workspace-memory.ts";
|
||||
import { redactCredentials } from "../src/redaction.ts";
|
||||
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "../src/memory-quality.ts";
|
||||
import { reviewerCurrent28Fixture } from "./fixtures/memory-quality-current-28.ts";
|
||||
import { REAL_WORKSPACE_FIXTURES } from "./fixtures/real-workspaces-snapshot.ts";
|
||||
|
||||
Reference in New Issue
Block a user