import { randomUUID } from "node:crypto"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { boundedText, safeMemoryText, similarity } from "./policy.js"; function json(value) { return JSON.stringify(value); } function parse(value, fallback) { try { return JSON.parse(String(value)); } catch { return fallback; } } function markdown(value) { return String(value).replaceAll("\r", "").trim() + "\n"; } function identifier(value, name) { const text = boundedText(value, 200); if (!/^[a-z0-9_.-]+$/i.test(text)) throw new Error(`Invalid ${name}`); return text; } export class MemoryStore { constructor(path, bundlesDir) { this.bundlesDir = bundlesDir; mkdirSync(bundlesDir, { recursive: true, mode: 0o700 }); this.db = new DatabaseSync(path); this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS events ( seq INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, at INTEGER NOT NULL, task_id TEXT, scope TEXT, object_id TEXT, data TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS task_cards ( task_id TEXT PRIMARY KEY, objective TEXT NOT NULL, status TEXT NOT NULL, result TEXT NOT NULL, schedule_id TEXT, context_epoch TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS interaction_memories ( interaction_id TEXT PRIMARY KEY, source TEXT NOT NULL, user_text TEXT NOT NULL, manager_text TEXT NOT NULL, task_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS bundles ( bundle_id TEXT PRIMARY KEY, scope TEXT NOT NULL, object_id TEXT NOT NULL, title TEXT NOT NULL, revision INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE(scope, object_id) ); CREATE TABLE IF NOT EXISTS bundle_documents ( bundle_id TEXT NOT NULL, name TEXT NOT NULL, body TEXT NOT NULL, revision INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY(bundle_id, name) ); CREATE TABLE IF NOT EXISTS consolidations ( id TEXT PRIMARY KEY, before_task_id TEXT, after_task_id TEXT, reason TEXT NOT NULL, status TEXT NOT NULL, details TEXT NOT NULL, created_at INTEGER NOT NULL, completed_at INTEGER ); CREATE INDEX IF NOT EXISTS events_task ON events(task_id, seq); CREATE INDEX IF NOT EXISTS interaction_memories_updated ON interaction_memories(updated_at DESC); CREATE INDEX IF NOT EXISTS task_cards_updated ON task_cards(updated_at DESC);`); } close() { this.db.close(); } event(type, data, taskId = null, scope = null, objectId = null) { this.db.prepare("INSERT INTO events(type,at,task_id,scope,object_id,data) VALUES (?,?,?,?,?,?)") .run(type, Date.now(), taskId, scope, objectId, json(data)); } latestTask() { return this.db.prepare("SELECT * FROM task_cards ORDER BY updated_at DESC LIMIT 1").get(); } assessTransition(params) { const objective = boundedText(params.objective, 16_000); const scheduleId = boundedText(params.schedule_id, 200) || null; const previous = this.latestTask(); if (!previous) return { relationship: "new", score: 0, previous_task: null, recommended_action: "keep" }; const score = similarity(previous.objective, objective); const sameSchedule = Boolean(scheduleId && previous.schedule_id === scheduleId); const ageMs = Math.max(0, Date.now() - Number(previous.updated_at)); const relationship = sameSchedule || score >= 0.42 ? "same" : score >= 0.18 ? "related" : "new"; const recommendedAction = relationship === "new" && previous.status !== "running" ? "consolidate" : "keep"; return { relationship, score: Number(score.toFixed(3)), previous_task: { task_id: previous.task_id, objective: previous.objective.slice(0, 500), status: previous.status, result: previous.result.slice(0, 800), age_ms: ageMs, }, recommended_action: recommendedAction, }; } recall(params) { const query = boundedText(params.query, 16_000); const requestedLimit = Number(params.limit ?? 8); const limit = Number.isSafeInteger(requestedLimit) ? Math.max(1, Math.min(20, requestedLimit)) : 8; const rows = this.db .prepare("SELECT task_id,objective,status,result,schedule_id,created_at,updated_at FROM task_cards ORDER BY updated_at DESC, rowid DESC LIMIT 100") .all(); const taskCard = (row, score) => ({ task_id: row.task_id, objective: row.objective.slice(0, 800), status: row.status, result: row.result.slice(0, 1_500), schedule_id: row.schedule_id, created_at: row.created_at, updated_at: row.updated_at, score: Number(score.toFixed(3)), }); const relatedTaskCards = rows .map((row) => ({ row, score: similarity(`${row.objective}\n${row.result}`, query) })) .filter((candidate) => candidate.score > 0) .sort((left, right) => right.score - left.score || Number(right.row.updated_at) - Number(left.row.updated_at)) .slice(0, limit) .map((candidate) => taskCard(candidate.row, candidate.score)); const recentTaskCards = rows.slice(0, limit).map((row) => taskCard(row, similarity(`${row.objective}\n${row.result}`, query))); const interactionRows = this.db .prepare("SELECT * FROM interaction_memories ORDER BY updated_at DESC, rowid DESC LIMIT 200") .all(); const interactionMemory = (row, score) => ({ interaction_id: row.interaction_id, source: row.source, user: row.user_text.slice(0, 1_000), manager: row.manager_text.slice(0, 1_500), task_id: row.task_id, created_at: row.created_at, updated_at: row.updated_at, score: Number(score.toFixed(3)), }); const relatedInteractions = interactionRows .map((row) => ({ row, score: similarity(`${row.user_text}\n${row.manager_text}`, query) })) .filter((candidate) => candidate.score > 0) .sort((left, right) => right.score - left.score || Number(right.row.updated_at) - Number(left.row.updated_at)) .slice(0, limit) .map((candidate) => interactionMemory(candidate.row, candidate.score)); const recentInteractions = interactionRows .slice(0, limit) .map((row) => interactionMemory(row, similarity(`${row.user_text}\n${row.manager_text}`, query))); const bundles = this.db .prepare("SELECT * FROM bundles ORDER BY updated_at DESC LIMIT 20") .all() .map((bundle) => { const documents = this.db .prepare("SELECT name,body,revision,updated_at FROM bundle_documents WHERE bundle_id=? ORDER BY name") .all(bundle.bundle_id) .filter((document) => ["INDEX.md", "requirements.md", "preferences.md", "lessons.md"].includes(document.name)); const score = similarity(`${bundle.title}\n${documents.map((document) => document.body).join("\n")}`, query); return { scope: bundle.scope, object_id: bundle.object_id, title: bundle.title, revision: bundle.revision, updated_at: bundle.updated_at, score: Number(score.toFixed(3)), documents: documents.map((document) => ({ name: document.name, body: document.body.slice(0, 2_000), revision: document.revision, updated_at: document.updated_at, })), }; }) .sort((left, right) => right.score - left.score || Number(right.updated_at) - Number(left.updated_at)) .slice(0, 3); this.event("memory.recalled", { query_chars: query.length, related_tasks: relatedTaskCards.length, recent_tasks: recentTaskCards.length, related_interactions: relatedInteractions.length, recent_interactions: recentInteractions.length, bundles: bundles.length, }); return { query, related_task_cards: relatedTaskCards, recent_task_cards: recentTaskCards, related_interactions: relatedInteractions, recent_interactions: recentInteractions, bundles, }; } rememberInteractions(params) { const interactions = Array.isArray(params.interactions) ? params.interactions.slice(0, 100) : []; const statement = this.db.prepare(`INSERT OR IGNORE INTO interaction_memories (interaction_id,source,user_text,manager_text,task_id,created_at,updated_at) VALUES (?,?,?,?,?,?,?)`); let stored = 0; for (const interaction of interactions) { const interactionId = boundedText(interaction?.interaction_id, 200); const userText = safeMemoryText(interaction?.user, 4_000); const managerText = safeMemoryText(interaction?.manager, 4_000); if (!interactionId || !userText || !managerText) continue; const source = boundedText(interaction?.source, 100) || "unknown"; const taskId = boundedText(interaction?.task_id, 200) || null; const createdAt = Number.isSafeInteger(interaction?.created_at) ? interaction.created_at : Date.now(); const updatedAt = Number.isSafeInteger(interaction?.updated_at) ? interaction.updated_at : createdAt; stored += Number(statement.run(interactionId, source, userText, managerText, taskId, createdAt, updatedAt).changes); } this.event("interactions.remembered", { stored }); return { stored }; } ensureBundle(scope, objectId, title, initialRequirements = "") { scope = identifier(scope, "bundle scope"); objectId = identifier(objectId, "bundle object id"); const existing = this.db.prepare("SELECT * FROM bundles WHERE scope=? AND object_id=?").get(scope, objectId); if (existing) return existing; const now = Date.now(); const bundle = { bundle_id: randomUUID(), scope, object_id: objectId, title, revision: 1, created_at: now, updated_at: now }; this.db.prepare("INSERT INTO bundles VALUES (?,?,?,?,?,?,?)") .run(bundle.bundle_id, scope, objectId, title, 1, now, now); this.putDocument(bundle.bundle_id, "INDEX.md", `# ${title}\n\n此资料夹属于 ${scope}:${objectId}。\n`, false); this.putDocument(bundle.bundle_id, "requirements.md", `# 执行要求\n\n${safeMemoryText(initialRequirements) || "尚未设置。"}\n`, false); this.putDocument(bundle.bundle_id, "preferences.md", "# 偏好\n\n尚无。\n", false); this.putDocument(bundle.bundle_id, "lessons.md", "# 已验证经验\n\n尚无。\n", false); this.projectBundle(bundle.bundle_id); this.event("bundle.created", { title }, null, scope, objectId); return this.db.prepare("SELECT * FROM bundles WHERE bundle_id=?").get(bundle.bundle_id); } putDocument(bundleId, name, body, bumpBundle = true) { if (!/^[a-z0-9_.-]+$/i.test(name)) throw new Error("Invalid document name"); const current = this.db.prepare("SELECT revision FROM bundle_documents WHERE bundle_id=? AND name=?").get(bundleId, name); const revision = Number(current?.revision ?? 0) + 1; this.db.prepare("INSERT OR REPLACE INTO bundle_documents VALUES (?,?,?,?,?)") .run(bundleId, name, safeMemoryText(body, 32_000), revision, Date.now()); if (bumpBundle) this.db.prepare("UPDATE bundles SET revision=revision+1,updated_at=? WHERE bundle_id=?").run(Date.now(), bundleId); } bundle(scope, objectId) { scope = identifier(scope, "bundle scope"); objectId = identifier(objectId, "bundle object id"); const bundle = this.db.prepare("SELECT * FROM bundles WHERE scope=? AND object_id=?").get(scope, objectId); if (!bundle) return null; const documents = this.db.prepare("SELECT name,body,revision,updated_at FROM bundle_documents WHERE bundle_id=? ORDER BY name").all(bundle.bundle_id); return { ...bundle, documents }; } listBundles(scope) { const rows = scope ? this.db.prepare("SELECT * FROM bundles WHERE scope=? ORDER BY updated_at DESC").all(identifier(scope, "bundle scope")) : this.db.prepare("SELECT * FROM bundles ORDER BY updated_at DESC").all(); return rows.map((row) => ({ ...row, documents: this.db.prepare("SELECT name,revision,updated_at FROM bundle_documents WHERE bundle_id=? ORDER BY name").all(row.bundle_id) })); } updateBundleDocument(params) { const scope = identifier(params.scope, "bundle scope"); const objectId = identifier(params.object_id, "bundle object id"); const name = identifier(params.name, "document name"); const bundle = this.db.prepare("SELECT * FROM bundles WHERE scope=? AND object_id=?").get(scope, objectId); if (!bundle) throw new Error("Bundle not found"); if (params.expected_revision !== undefined && Number(params.expected_revision) !== Number(bundle.revision)) throw new Error(`Bundle revision conflict: expected ${String(params.expected_revision)}, current ${String(bundle.revision)}`); this.putDocument(bundle.bundle_id, name, params.body, true); this.projectBundle(bundle.bundle_id); const updated = this.bundle(scope, objectId); this.event("bundle.document_updated", { name, revision: updated.revision }, null, scope, objectId); return updated; } projectBundle(bundleId) { const bundle = this.db.prepare("SELECT * FROM bundles WHERE bundle_id=?").get(bundleId); if (!bundle) return; const directory = join(this.bundlesDir, bundle.scope, bundle.object_id); mkdirSync(join(directory, "runs"), { recursive: true, mode: 0o700 }); const documents = this.db.prepare("SELECT name,body FROM bundle_documents WHERE bundle_id=?").all(bundleId); for (const document of documents) writeFileSync(join(directory, document.name), markdown(document.body), { mode: 0o600 }); writeFileSync(join(directory, "state.json"), json({ scope: bundle.scope, object_id: bundle.object_id, revision: bundle.revision, updated_at: bundle.updated_at }) + "\n", { mode: 0o600 }); } prepareContext(params) { const taskId = boundedText(params.task_id, 200); const objective = boundedText(params.objective, 16_000); const scheduleId = boundedText(params.schedule_id, 200) || null; const contextEpoch = randomUUID(); let bundle = null; if (scheduleId) { this.ensureBundle("schedule", scheduleId, boundedText(params.schedule_name, 200) || "日程资料", objective); bundle = this.bundle("schedule", scheduleId); } const recent = this.db .prepare("SELECT task_id,objective,status,result,schedule_id,updated_at FROM task_cards ORDER BY updated_at DESC LIMIT 20") .all() .filter((row) => (scheduleId ? row.schedule_id === scheduleId : similarity(row.objective, objective) >= 0.18)) .slice(0, 3); const selectedDocs = bundle?.documents.filter((document) => ["INDEX.md", "requirements.md", "preferences.md", "lessons.md"].includes(document.name)) ?? []; const pack = { context_epoch: contextEpoch, task: { task_id: taskId, objective }, schedule_bundle: bundle ? { bundle_id: bundle.bundle_id, revision: bundle.revision, documents: selectedDocs.map((document) => ({ name: document.name, body: document.body.slice(0, 2500), revision: document.revision })) } : null, recent_task_cards: recent.map((row) => ({ task_id: row.task_id, objective: row.objective.slice(0, 500), status: row.status, result: row.result.slice(0, 800), updated_at: row.updated_at })), }; this.event("context.prepared", { context_epoch: contextEpoch, schedule_id: scheduleId }, taskId, scheduleId ? "schedule" : "task", scheduleId ?? taskId); return pack; } completeTask(params) { const taskId = boundedText(params.task_id, 200); const objective = boundedText(params.objective, 16_000); const status = boundedText(params.status, 40) || "completed"; const result = safeMemoryText(params.result, 4_000); const scheduleId = boundedText(params.schedule_id, 200) || null; const contextEpoch = boundedText(params.context_epoch, 200) || randomUUID(); const now = Date.now(); const existing = this.db.prepare("SELECT created_at FROM task_cards WHERE task_id=?").get(taskId); this.db.prepare("INSERT OR REPLACE INTO task_cards VALUES (?,?,?,?,?,?,?,?)") .run(taskId, objective, status, result, scheduleId, contextEpoch, Number(existing?.created_at ?? now), now); if (scheduleId) { const bundle = this.ensureBundle("schedule", scheduleId, boundedText(params.schedule_name, 200) || "日程资料", objective); const directory = join(this.bundlesDir, "schedule", scheduleId, "runs", taskId); mkdirSync(directory, { recursive: true, mode: 0o700 }); writeFileSync(join(directory, "summary.md"), markdown(`# ${status}\n\n${result}`), { mode: 0o600 }); this.putDocument(bundle.bundle_id, "INDEX.md", `# ${bundle.title}\n\n最近一次运行:${new Date(now).toISOString()}\n\n状态:${status}\n`, true); this.projectBundle(bundle.bundle_id); } this.event("task.completed", { status, context_epoch: contextEpoch }, taskId, scheduleId ? "schedule" : "task", scheduleId ?? taskId); return { task_id: taskId, stored: true }; } recordConsolidation(params) { const id = randomUUID(); const status = boundedText(params.status, 40) || "completed"; this.db.prepare("INSERT INTO consolidations VALUES (?,?,?,?,?,?,?,?)") .run(id, boundedText(params.before_task_id, 200) || null, boundedText(params.after_task_id, 200) || null, boundedText(params.reason, 500), status, json(params.details ?? {}), Date.now(), status === "started" ? null : Date.now()); this.event("context.consolidated", { id, status, reason: boundedText(params.reason, 500) }, boundedText(params.after_task_id, 200) || null); return { consolidation_id: id, status }; } status() { return { tasks: Number(this.db.prepare("SELECT count(*) AS count FROM task_cards").get().count), bundles: Number(this.db.prepare("SELECT count(*) AS count FROM bundles").get().count), consolidations: Number(this.db.prepare("SELECT count(*) AS count FROM consolidations").get().count), last_event_seq: Number(this.db.prepare("SELECT max(seq) AS seq FROM events").get().seq ?? 0), }; } listTasks(limit = 20) { const safeLimit = Number.isSafeInteger(limit) ? Math.max(1, Math.min(100, limit)) : 20; return this.db.prepare("SELECT * FROM task_cards ORDER BY updated_at DESC LIMIT ?").all(safeLimit); } }