mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
aa7cc6c60e
Move retention constants and math to a focused src/retention.ts module: - All half-life, reinforcement, dormancy constants - TYPE_FACTOR, SOURCE_FACTOR, USER_IMPORTANCE_FACTOR - RETENTION_TYPE_MAX (renamed from TYPE_MAX) - calculateInitialStrength, calculateEffectiveHalfLife, calculateRetentionStrength, calculateDormantDays, calculateEffectiveAgeDays, reinforceMemory No behavior changes. retention.ts imports only types from types.ts. Workspace-memory.ts still owns storage, consolidation, and rendering.
695 lines
23 KiB
TypeScript
695 lines
23 KiB
TypeScript
import { appendFile, mkdir } from "node:fs/promises";
|
|
import { dirname } from "node:path";
|
|
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts";
|
|
import { LONG_TERM_LIMITS } from "./types.ts";
|
|
import { migrationLogPath, workspaceKey, workspaceMemoryPath } from "./paths.ts";
|
|
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
|
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts";
|
|
import { redactCredentials } from "./redaction.ts";
|
|
import {
|
|
RETENTION_TYPE_MAX,
|
|
calculateRetentionStrength,
|
|
reinforceMemory,
|
|
} from "./retention.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";
|
|
|
|
export type MemoryConsolidationReason =
|
|
| "promoted"
|
|
| "absorbed_exact"
|
|
| "absorbed_identity"
|
|
| "superseded_existing"
|
|
| "rejected_capacity";
|
|
|
|
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 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> {
|
|
const nowIso = new Date().toISOString();
|
|
return {
|
|
version: 1,
|
|
workspace: { root, key: await workspaceKey(root) },
|
|
limits: {
|
|
maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars,
|
|
maxEntries: LONG_TERM_LIMITS.maxEntries,
|
|
},
|
|
entries: [],
|
|
migrations: [],
|
|
updatedAt: nowIso,
|
|
lastActivityAt: nowIso,
|
|
};
|
|
}
|
|
|
|
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,
|
|
lastActivityAt: loaded.lastActivityAt ?? loaded.updatedAt ?? fallback.lastActivityAt,
|
|
};
|
|
|
|
// 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 ?? []);
|
|
if ((before.lastActivityAt ?? "") !== (after.lastActivityAt ?? "")) return true;
|
|
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 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
|
|
// 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, result);
|
|
|
|
const normalizedStore = {
|
|
...result,
|
|
entries: [...accounting.kept, ...supersededEntries],
|
|
updatedAt: nowIso,
|
|
lastActivityAt: 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 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 (isProgressSnapshotViolation(entry.text)) {
|
|
return {
|
|
...entry,
|
|
status: "superseded" as const,
|
|
updatedAt: nowIso,
|
|
};
|
|
}
|
|
|
|
return entry;
|
|
});
|
|
|
|
return {
|
|
...store,
|
|
entries,
|
|
migrations: [...(store.migrations || []), MIGRATION_ID],
|
|
updatedAt: nowIso,
|
|
};
|
|
}
|
|
|
|
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;
|
|
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(/(?:\/[^ |