mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
ffb612226c
Wave 2c - Dormant workspace tracking: - Add lastActivityAt to WorkspaceMemoryStore - Implement calculateDormantDays with 14-day grace period - Wire dormant days into retention-strength calculation Wave 3 - Reinforcement: - Add lastReinforcedSessionID to LongTermMemoryEntry - Implement reinforceMemory with guards (same-session, 1hr interval, max 6) - Set retentionClock on memory creation in extractors.ts and plugin.ts Tests: 219 → 222, all pass
819 lines
27 KiB
TypeScript
819 lines
27 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";
|
|
|
|
// 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";
|
|
|
|
// Retention decay model constants (v1.5)
|
|
const BASE_HALF_LIFE_DAYS = 45;
|
|
const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
|
|
const REINFORCEMENT_MAX_COUNT = 6;
|
|
const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
|
const WORKSPACE_DORMANT_AFTER_DAYS = 14;
|
|
const DORMANT_DECAY_MULTIPLIER = 0.25;
|
|
|
|
const TYPE_FACTOR = {
|
|
reference: 1.0,
|
|
project: 1.25,
|
|
feedback: 2.25,
|
|
decision: 2.5,
|
|
} as const;
|
|
|
|
const SOURCE_FACTOR = {
|
|
compaction: 1.0,
|
|
manual: 1.4,
|
|
explicit: 2.0,
|
|
} as const;
|
|
|
|
const USER_IMPORTANCE_FACTOR = {
|
|
low: 0.7,
|
|
normal: 1.0,
|
|
high: 1.5,
|
|
} as const;
|
|
|
|
const SAFETY_CRITICAL_FACTOR = 6.0;
|
|
|
|
const TYPE_MAX = {
|
|
feedback: 10,
|
|
decision: 10,
|
|
project: 8,
|
|
reference: 6,
|
|
} as const;
|
|
|
|
export function calculateInitialStrength(memory: LongTermMemoryEntry): number {
|
|
const typeFactor = TYPE_FACTOR[memory.type] ?? 1.0;
|
|
const sourceFactor = SOURCE_FACTOR[memory.source] ?? 1.0;
|
|
const importanceFactor = USER_IMPORTANCE_FACTOR[memory.userImportance ?? "normal"] ?? 1.0;
|
|
const safetyFactor = memory.safetyCritical ? SAFETY_CRITICAL_FACTOR : 1.0;
|
|
|
|
return typeFactor * sourceFactor * importanceFactor * safetyFactor;
|
|
}
|
|
|
|
export function calculateEffectiveHalfLife(memory: LongTermMemoryEntry): number {
|
|
const reinforcementCount = Math.min(
|
|
memory.reinforcementCount ?? 0,
|
|
REINFORCEMENT_MAX_COUNT,
|
|
);
|
|
const factor = Math.pow(REINFORCEMENT_HALFLIFE_FACTOR, reinforcementCount);
|
|
return BASE_HALF_LIFE_DAYS / factor;
|
|
}
|
|
|
|
export function calculateRetentionStrength(
|
|
memory: LongTermMemoryEntry,
|
|
now: number,
|
|
dormantDays: number,
|
|
): number {
|
|
const initialStrength = calculateInitialStrength(memory);
|
|
const effectiveHalfLife = calculateEffectiveHalfLife(memory);
|
|
|
|
// Use retentionClock if available, fallback to updatedAt.
|
|
const retentionStart = memory.retentionClock ?? memory.updatedAt;
|
|
const createdAtMs = new Date(retentionStart).getTime();
|
|
const ageMs = now - createdAtMs;
|
|
const ageDays = ageMs / (24 * 60 * 60 * 1000);
|
|
|
|
// Apply dormant boost to effective age.
|
|
let effectiveAgeDays = ageDays;
|
|
if (dormantDays > 0) {
|
|
// Dormant workspace ages faster.
|
|
effectiveAgeDays += dormantDays * DORMANT_DECAY_MULTIPLIER;
|
|
}
|
|
|
|
// Calculate strength using exponential decay.
|
|
const strength = initialStrength * Math.pow(2, -effectiveAgeDays / effectiveHalfLife);
|
|
|
|
return Math.max(0, strength);
|
|
}
|
|
|
|
export function calculateDormantDays(store: WorkspaceMemoryStore, now: number): number {
|
|
const lastActivity = store.lastActivityAt
|
|
? new Date(store.lastActivityAt).getTime()
|
|
: now;
|
|
if (!Number.isFinite(lastActivity)) return 0;
|
|
|
|
const daysSinceActivity = (now - lastActivity) / (24 * 60 * 60 * 1000);
|
|
return Math.max(0, daysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS);
|
|
}
|
|
|
|
export function reinforceMemory(
|
|
memory: LongTermMemoryEntry,
|
|
sessionId: string,
|
|
now: number,
|
|
): LongTermMemoryEntry {
|
|
if (memory.lastReinforcedSessionID === sessionId) {
|
|
return memory;
|
|
}
|
|
|
|
if (memory.lastReinforcedAt && now - memory.lastReinforcedAt < REINFORCEMENT_MIN_INTERVAL_MS) {
|
|
return memory;
|
|
}
|
|
|
|
if ((memory.reinforcementCount ?? 0) >= REINFORCEMENT_MAX_COUNT) {
|
|
return memory;
|
|
}
|
|
|
|
return {
|
|
...memory,
|
|
reinforcementCount: (memory.reinforcementCount ?? 0) + 1,
|
|
lastReinforcedAt: now,
|
|
lastReinforcedSessionID: sessionId,
|
|
};
|
|
}
|
|
|
|
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 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(/(?:\/[^ |