mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
686 lines
23 KiB
TypeScript
686 lines
23 KiB
TypeScript
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
|
||
import { LONG_TERM_LIMITS } from "./types.ts";
|
||
import { workspaceKey, workspaceMemoryPath } from "./paths.ts";
|
||
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||
import { assessMemoryQuality } from "./memory-quality.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"
|
||
| "absorbed_identity"
|
||
| "superseded_existing"
|
||
| "rejected_capacity"
|
||
| "rejected_stale";
|
||
|
||
export type MemoryConsolidationEvent = {
|
||
memoryKey: string;
|
||
identityKey: string;
|
||
memory: LongTermMemoryEntry;
|
||
reason: MemoryConsolidationReason;
|
||
retainedId?: string;
|
||
supersededId?: string;
|
||
};
|
||
|
||
export type LongTermLimitResult = {
|
||
kept: LongTermMemoryEntry[];
|
||
dropped: MemoryConsolidationEvent[];
|
||
absorbed: MemoryConsolidationEvent[];
|
||
superseded: MemoryConsolidationEvent[];
|
||
};
|
||
|
||
export type WorkspaceMemoryNormalizationResult = LongTermLimitResult & {
|
||
store: WorkspaceMemoryStore;
|
||
events: MemoryConsolidationEvent[];
|
||
};
|
||
|
||
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
|
||
return {
|
||
version: 1,
|
||
workspace: { root, key: await workspaceKey(root) },
|
||
limits: {
|
||
maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars,
|
||
maxEntries: LONG_TERM_LIMITS.maxEntries,
|
||
},
|
||
entries: [],
|
||
migrations: [],
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
export async function loadWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
|
||
const path = await workspaceMemoryPath(root);
|
||
const fallback = await emptyWorkspaceMemory(root);
|
||
const loaded = await readJSON(path, () => fallback) as Partial<WorkspaceMemoryStore>;
|
||
|
||
const store: WorkspaceMemoryStore = {
|
||
version: loaded.version ?? 1,
|
||
workspace: loaded.workspace ?? { root, key: await workspaceKey(root) },
|
||
limits: {
|
||
maxRenderedChars: loaded.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars,
|
||
maxEntries: loaded.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries,
|
||
},
|
||
entries: Array.isArray(loaded.entries) ? loaded.entries : [],
|
||
migrations: Array.isArray(loaded.migrations) ? loaded.migrations : [],
|
||
updatedAt: loaded.updatedAt ?? fallback.updatedAt,
|
||
};
|
||
|
||
// Always normalize on load so redaction/migrations are always-on.
|
||
const normalized = await normalizeWorkspaceMemoryWithAccounting(root, store);
|
||
|
||
// Persist security/correctness mutations, but avoid read-time maintenance
|
||
// writes for ordering/capacity/timestamp-only normalization.
|
||
if (hasSecurityOrMigrationChange(store, normalized.store)) {
|
||
await atomicWriteJSON(path, normalized.store);
|
||
}
|
||
|
||
return normalized.store;
|
||
}
|
||
|
||
function hasSecurityOrMigrationChange(
|
||
before: WorkspaceMemoryStore,
|
||
after: WorkspaceMemoryStore,
|
||
): boolean {
|
||
const beforeById = new Map((before.entries ?? []).map(entry => [entry.id, entry]));
|
||
for (const afterEntry of after.entries ?? []) {
|
||
const beforeEntry = beforeById.get(afterEntry.id);
|
||
if (!beforeEntry) continue;
|
||
if (beforeEntry.text !== afterEntry.text) return true;
|
||
if ((beforeEntry.rationale ?? "") !== (afterEntry.rationale ?? "")) return true;
|
||
if (beforeEntry.status !== afterEntry.status) return true;
|
||
}
|
||
|
||
const beforeMigrations = JSON.stringify(before.migrations ?? []);
|
||
const afterMigrations = JSON.stringify(after.migrations ?? []);
|
||
return beforeMigrations !== afterMigrations;
|
||
}
|
||
|
||
export async function saveWorkspaceMemory(root: string, store: WorkspaceMemoryStore): Promise<void> {
|
||
const normalized = await normalizeWorkspaceMemory(root, store);
|
||
await atomicWriteJSON(await workspaceMemoryPath(root), normalized);
|
||
}
|
||
|
||
export async function updateWorkspaceMemory(
|
||
root: string,
|
||
updater: (store: WorkspaceMemoryStore) => WorkspaceMemoryStore | Promise<WorkspaceMemoryStore>,
|
||
): Promise<WorkspaceMemoryStore> {
|
||
return (await updateWorkspaceMemoryWithAccounting(root, updater)).store;
|
||
}
|
||
|
||
export async function updateWorkspaceMemoryWithAccounting(
|
||
root: string,
|
||
updater: (store: WorkspaceMemoryStore) => WorkspaceMemoryStore | Promise<WorkspaceMemoryStore>,
|
||
): Promise<WorkspaceMemoryNormalizationResult> {
|
||
const path = await workspaceMemoryPath(root);
|
||
const fallback = await emptyWorkspaceMemory(root);
|
||
let finalResult: WorkspaceMemoryNormalizationResult | undefined;
|
||
const store = await updateJSON(path, () => fallback, async current => {
|
||
const normalized = await normalizeWorkspaceMemory(root, current);
|
||
finalResult = await normalizeWorkspaceMemoryWithAccounting(root, await updater(normalized));
|
||
return finalResult.store;
|
||
});
|
||
|
||
return finalResult ?? {
|
||
store,
|
||
kept: store.entries.filter(entry => entry.status !== "superseded"),
|
||
dropped: [],
|
||
absorbed: [],
|
||
superseded: [],
|
||
events: [],
|
||
};
|
||
}
|
||
|
||
export async function normalizeWorkspaceMemory(
|
||
root: string,
|
||
store: WorkspaceMemoryStore,
|
||
): Promise<WorkspaceMemoryStore> {
|
||
return (await normalizeWorkspaceMemoryWithAccounting(root, store)).store;
|
||
}
|
||
|
||
export async function normalizeWorkspaceMemoryWithAccounting(
|
||
root: string,
|
||
store: WorkspaceMemoryStore,
|
||
): Promise<WorkspaceMemoryNormalizationResult> {
|
||
const nowIso = new Date().toISOString();
|
||
|
||
let result: WorkspaceMemoryStore = {
|
||
...store,
|
||
workspace: { root, key: await workspaceKey(root) },
|
||
limits: {
|
||
maxRenderedChars: store.limits?.maxRenderedChars ?? LONG_TERM_LIMITS.maxRenderedChars,
|
||
maxEntries: store.limits?.maxEntries ?? LONG_TERM_LIMITS.maxEntries,
|
||
},
|
||
entries: Array.isArray(store.entries) ? store.entries : [],
|
||
migrations: Array.isArray(store.migrations) ? store.migrations : [],
|
||
updatedAt: nowIso,
|
||
};
|
||
|
||
// Always-on credential redaction
|
||
result.entries = result.entries.map(entry => {
|
||
const text = redactCredentials(entry.text);
|
||
const rationale = entry.rationale ? redactCredentials(entry.rationale) : undefined;
|
||
|
||
if (text === entry.text && rationale === entry.rationale) {
|
||
return entry;
|
||
}
|
||
|
||
return {
|
||
...entry,
|
||
text,
|
||
rationale,
|
||
updatedAt: nowIso,
|
||
};
|
||
});
|
||
|
||
// One-time migration for legacy snapshot violations
|
||
result = runMigrationP0Cleanup(result, nowIso);
|
||
result = runMigrationQualityCleanup(result, nowIso);
|
||
|
||
// P0 accounting only considers active entries. Entries that were already
|
||
// superseded before this normalization are preserved in storage; entries that
|
||
// lose during this enforcement are reported via accounting events but are not
|
||
// archived as superseded records in this wave.
|
||
const activeEntries = result.entries.filter(entry => entry.status !== "superseded");
|
||
const supersededEntries = result.entries.filter(entry => entry.status === "superseded");
|
||
const accounting = enforceLongTermLimitsWithAccounting(activeEntries);
|
||
|
||
const normalizedStore = {
|
||
...result,
|
||
entries: [...accounting.kept, ...supersededEntries],
|
||
updatedAt: nowIso,
|
||
};
|
||
|
||
return {
|
||
store: normalizedStore,
|
||
kept: accounting.kept,
|
||
dropped: accounting.dropped,
|
||
absorbed: accounting.absorbed,
|
||
superseded: accounting.superseded,
|
||
events: [...accounting.dropped, ...accounting.absorbed, ...accounting.superseded],
|
||
};
|
||
}
|
||
|
||
export function redactCredentials(text: string): string {
|
||
let result = text;
|
||
|
||
// 1. PIN
|
||
result = result.replace(
|
||
new RegExp(String.raw`${PIN_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||
"$1[REDACTED]",
|
||
);
|
||
|
||
// 2. Username+password pair
|
||
result = result.replace(
|
||
new RegExp(
|
||
String.raw`${USERNAME_PREFIX}[\`'"]?(${SECRET_VALUE})((?:,|,)\s*)${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`,
|
||
"gi",
|
||
),
|
||
"$1[REDACTED]$3$4[REDACTED]",
|
||
);
|
||
|
||
// 3. Standalone password
|
||
result = result.replace(
|
||
new RegExp(String.raw`${PASSWORD_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||
"$1[REDACTED]",
|
||
);
|
||
|
||
// 4. Standalone sensitive keys/tokens
|
||
result = result.replace(
|
||
new RegExp(String.raw`${BEARER_PREFIX}(?!token\s*[:=:])[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||
"$1[REDACTED]",
|
||
);
|
||
|
||
result = result.replace(
|
||
new RegExp(String.raw`${SENSITIVE_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||
"$1[REDACTED]",
|
||
);
|
||
|
||
return result;
|
||
}
|
||
|
||
export function isProjectSnapshotViolation(text: string): boolean {
|
||
// Test/suite counts
|
||
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
|
||
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
|
||
|
||
// File counts with snapshot context, excluding limit statements
|
||
if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) {
|
||
const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text);
|
||
const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text);
|
||
if (hasSnapshotContext && !hasLimitContext) return true;
|
||
}
|
||
|
||
// Phase/Wave/Sprint/Milestone/Task progress
|
||
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) {
|
||
if (/completed|done|finished|完成/i.test(text)) return true;
|
||
}
|
||
|
||
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
|
||
|
||
return false;
|
||
}
|
||
|
||
export function runMigrationP0Cleanup(
|
||
store: WorkspaceMemoryStore,
|
||
nowIso: string,
|
||
): WorkspaceMemoryStore {
|
||
if (store.migrations?.includes(MIGRATION_ID)) {
|
||
return store;
|
||
}
|
||
|
||
const entries = store.entries.map(entry => {
|
||
if (entry.source !== "compaction") return entry;
|
||
if (entry.type !== "project") return entry;
|
||
|
||
if (isProjectSnapshotViolation(entry.text)) {
|
||
return {
|
||
...entry,
|
||
status: "superseded" as const,
|
||
updatedAt: nowIso,
|
||
};
|
||
}
|
||
|
||
return entry;
|
||
});
|
||
|
||
return {
|
||
...store,
|
||
entries,
|
||
migrations: [...(store.migrations || []), MIGRATION_ID],
|
||
updatedAt: nowIso,
|
||
};
|
||
}
|
||
|
||
function runMigrationQualityCleanup(
|
||
store: WorkspaceMemoryStore,
|
||
nowIso: string,
|
||
): WorkspaceMemoryStore {
|
||
if (store.migrations?.includes(QUALITY_CLEANUP_MIGRATION_ID)) {
|
||
return store;
|
||
}
|
||
|
||
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;
|
||
|
||
changed = true;
|
||
const tags = new Set([
|
||
...(entry.tags ?? []),
|
||
"quality_cleanup",
|
||
...quality.reasons.map(reason => `quality:${reason}`),
|
||
]);
|
||
|
||
return {
|
||
...entry,
|
||
status: "superseded" as const,
|
||
updatedAt: nowIso,
|
||
tags: [...tags],
|
||
};
|
||
});
|
||
|
||
return {
|
||
...store,
|
||
entries,
|
||
migrations: [...(store.migrations ?? []), QUALITY_CLEANUP_MIGRATION_ID],
|
||
updatedAt: changed ? nowIso : store.updatedAt,
|
||
};
|
||
}
|
||
|
||
function sourcePriority(source: LongTermMemoryEntry["source"]): number {
|
||
if (source === "explicit") return 3;
|
||
if (source === "manual") return 2;
|
||
return 1;
|
||
}
|
||
|
||
function canonicalMemoryText(text: string): string {
|
||
return text
|
||
.normalize("NFKC")
|
||
.toLowerCase()
|
||
.replace(/[\s\p{P}]+/gu, " ")
|
||
.trim();
|
||
}
|
||
|
||
export function workspaceMemoryExactKey(entry: Pick<LongTermMemoryEntry, "type" | "text">): string {
|
||
return `${entry.type}:${canonicalMemoryText(entry.text)}`;
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
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}`;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
const wrappedPathPattern = /[`"']([^`"']+)[`"']/g;
|
||
for (const match of text.matchAll(wrappedPathPattern)) {
|
||
const pathIdentity = normalizeConcretePathIdentity(match[1]);
|
||
if (pathIdentity) return pathIdentity;
|
||
}
|
||
|
||
const pathMatch = text.match(/(?:\/[^ |