feat: add shared-session supervisor memory recall

This commit is contained in:
18962558917 2026-09-23 03:32:56 +08:00
parent 792c30ce68
commit 95b77187de
4 changed files with 178 additions and 1 deletions

View File

@ -35,7 +35,29 @@ DESKPAW_MEMORY_BUNDLES=/path/to/bundles
协议为单请求连接的 JSON Lines。请求包含 `id、method、params`,响应包含 `id、ok、result/error`。 协议为单请求连接的 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` 只判断任务切换及上下文整理,
不替代记忆召回。
服务故障不会暂停执行队列。顶层会显示实际的记忆请求错误;恢复服务后,
后续交互会继续同步最近的已完成问答。
## 资料夹 ## 资料夹

View File

@ -18,6 +18,8 @@ const store = new MemoryStore(databasePath, bundlesDir);
function call(method, params) { function call(method, params) {
if (method === "health") return { status: "ok", version: "0.1.0", ...store.status() }; 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 === "transition.assess") return store.assessTransition(params);
if (method === "context.prepare") return store.prepareContext(params); if (method === "context.prepare") return store.prepareContext(params);
if (method === "task.complete") return store.completeTask(params); if (method === "task.complete") return store.completeTask(params);

View File

@ -41,6 +41,11 @@ export class MemoryStore {
result TEXT NOT NULL, schedule_id TEXT, context_epoch 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 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 ( CREATE TABLE IF NOT EXISTS bundles (
bundle_id TEXT PRIMARY KEY, scope TEXT NOT NULL, object_id TEXT NOT NULL, 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, 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 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 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);`); 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 = "") { ensureBundle(scope, objectId, title, initialRequirements = "") {
scope = identifier(scope, "bundle scope"); scope = identifier(scope, "bundle scope");
objectId = identifier(objectId, "bundle object id"); objectId = identifier(objectId, "bundle object id");

View File

@ -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", () => { test("schedule context creates and projects a stable bundle", () => {
const directory = mkdtempSync(join(tmpdir(), "deskpaw-memory-")); const directory = mkdtempSync(join(tmpdir(), "deskpaw-memory-"));
const store = new MemoryStore(join(directory, "memory.sqlite"), join(directory, "bundles")); const store = new MemoryStore(join(directory, "memory.sqlite"), join(directory, "bundles"));