feat: add shared-session supervisor memory recall
This commit is contained in:
parent
792c30ce68
commit
95b77187de
24
README.md
24
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` 只判断任务切换及上下文整理,
|
||||
不替代记忆召回。
|
||||
|
||||
服务故障不会暂停执行队列。顶层会显示实际的记忆请求错误;恢复服务后,
|
||||
后续交互会继续同步最近的已完成问答。
|
||||
|
||||
## 资料夹
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
117
src/store.js
117
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");
|
||||
|
||||
@ -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"));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user