357 lines
18 KiB
JavaScript
357 lines
18 KiB
JavaScript
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);
|
|
}
|
|
}
|