deskpaw-memory/src/store.js

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);
}
}