diff --git a/README.md b/README.md index 4b745ee..6a37868 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,29 @@ DESKPAW_MEMORY_BUNDLES=/path/to/bundles 协议为单请求连接的 JSON Lines。请求包含 `id、method、params`,响应包含 `id、ok、result/error`。 -首版接口:`health`、`transition.assess`、`context.prepare`、`task.complete`、`task.list`、`consolidation.record`、`bundle.get`、`bundle.list`、`bundle.document.put`。修改资料使用 `expected_revision`,防止猫窝和桌喵同时编辑时旧内容覆盖新内容。 +接口:`health`、`memory.recall`、`interaction.remember`、`transition.assess`、`context.prepare`、`task.complete`、`task.list`、`consolidation.record`、`bundle.get`、`bundle.list`、`bundle.document.put`。修改资料使用 `expected_revision`,防止猫窝和桌喵同时编辑时旧内容覆盖新内容。 + +## 顶层记忆 + +DeskPaw 使用一个 Session。顶层 Agent 每轮先同步已完成的问答,再调用 `memory.recall`; +Memory 返回相关与最近的问答、任务卡以及资料包,供顶层优先回答“之前的任务”“刚才怎么样”等问题。 +来源仅作为 `source` 元数据保存,不作为检索过滤条件。同步按 `interaction_id` 幂等写入, +并经 `safeMemoryText` 过滤敏感文本;不要在记忆资料中放入凭据或隐藏推理。 + +`memory.recall` 请求示例: + +```json +{"id":"request-1","method":"memory.recall","params":{"query":"之前 UFO 检查怎么样了","limit":8}} +``` + +结果包含 `related_task_cards`、`recent_task_cards`、`related_interactions`、 +`recent_interactions` 和 `bundles`。每类结果有界:最多返回 `limit` 条任务或问答、 +最多 3 个资料包。当前相关性计算扫描最近 100 张任务卡和 200 条顶层问答; +更早内容仍保存在 SQLite,但不会进入这一版召回结果。`transition.assess` 只判断任务切换及上下文整理, +不替代记忆召回。 + +服务故障不会暂停执行队列。顶层会显示实际的记忆请求错误;恢复服务后, +后续交互会继续同步最近的已完成问答。 ## 资料夹 diff --git a/src/server.js b/src/server.js index 92dac00..0be1924 100644 --- a/src/server.js +++ b/src/server.js @@ -18,6 +18,8 @@ const store = new MemoryStore(databasePath, bundlesDir); function call(method, params) { if (method === "health") return { status: "ok", version: "0.1.0", ...store.status() }; + if (method === "memory.recall") return store.recall(params); + if (method === "interaction.remember") return store.rememberInteractions(params); if (method === "transition.assess") return store.assessTransition(params); if (method === "context.prepare") return store.prepareContext(params); if (method === "task.complete") return store.completeTask(params); diff --git a/src/store.js b/src/store.js index af149ab..b782387 100644 --- a/src/store.js +++ b/src/store.js @@ -41,6 +41,11 @@ export class MemoryStore { 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, @@ -57,6 +62,7 @@ export class MemoryStore { 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);`); } @@ -97,6 +103,117 @@ export class MemoryStore { }; } + 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"); diff --git a/test/store.test.js b/test/store.test.js index 4860e79..ba36e06 100644 --- a/test/store.test.js +++ b/test/store.test.js @@ -21,6 +21,42 @@ test("task transition recommends consolidation for an unrelated completed task", } }); +test("recall returns related and recent task memories for the shared session", () => { + const directory = mkdtempSync(join(tmpdir(), "deskpaw-memory-")); + const store = new MemoryStore(join(directory, "memory.sqlite"), join(directory, "bundles")); + try { + store.completeTask({ task_id: "screen", objective: "通过 HDMI 截取当前屏幕", status: "completed", result: "HDMI 抓帧成功", context_epoch: "e1" }); + store.completeTask({ task_id: "ufo", objective: "检查 UFO RPC 是否恢复", status: "completed", result: "UFO 仍然超时", context_epoch: "e2" }); + const recalled = store.recall({ query: "之前 UFO 检查怎么样了", limit: 2 }); + assert.equal(recalled.related_task_cards[0].task_id, "ufo"); + assert.deepEqual(recalled.recent_task_cards.map((task) => task.task_id), ["ufo", "screen"]); + assert.equal(recalled.recent_task_cards[0].result, "UFO 仍然超时"); + } finally { + store.close(); + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("recall includes durable top-level conversations without gateway isolation", () => { + const directory = mkdtempSync(join(tmpdir(), "deskpaw-memory-")); + const store = new MemoryStore(join(directory, "memory.sqlite"), join(directory, "bundles")); + try { + assert.deepEqual(store.rememberInteractions({ interactions: [ + { interaction_id: "one", source: "terminal", user: "我喜欢简短回复", manager: "记住了,会保持简短。", created_at: 1, updated_at: 1 }, + { interaction_id: "two", source: "feishu", user: "UFO 检查结果呢", manager: "UFO 仍然超时。", created_at: 2, updated_at: 2 }, + ] }), { stored: 2 }); + assert.deepEqual(store.rememberInteractions({ interactions: [ + { interaction_id: "one", source: "terminal", user: "我喜欢简短回复", manager: "记住了,会保持简短。", created_at: 1, updated_at: 1 }, + ] }), { stored: 0 }); + const recalled = store.recall({ query: "之前 UFO 怎么样", limit: 2 }); + assert.equal(recalled.related_interactions[0].interaction_id, "two"); + assert.deepEqual(recalled.recent_interactions.map((interaction) => interaction.source), ["feishu", "terminal"]); + } finally { + store.close(); + rmSync(directory, { recursive: true, force: true }); + } +}); + test("schedule context creates and projects a stable bundle", () => { const directory = mkdtempSync(join(tmpdir(), "deskpaw-memory-")); const store = new MemoryStore(join(directory, "memory.sqlite"), join(directory, "bundles"));