feat: organize topic memory and filter durable facts

This commit is contained in:
18962558917 2026-09-24 00:44:11 +08:00
parent 95b77187de
commit 97e88c0a24
5 changed files with 582 additions and 7 deletions

View File

@ -6,6 +6,7 @@ DeskPaw 的本地记忆与上下文整理模块。它作为 `deskpaw-agent` 的
- 新旧任务关联度提示;
- 日程附属资料夹;
- 每轮执行前的有限上下文包;
- 按 03:30 工作日组织的话题经历、长期事实和技能草稿;
- 主动整理记录与可观测事件。
模块不保存隐藏思考,不接管日程执行,不直接操作电脑或发送消息。Agent 不依赖本服务才能运行;服务离线时自动降级。
@ -35,7 +36,7 @@ DESKPAW_MEMORY_BUNDLES=/path/to/bundles
协议为单请求连接的 JSON Lines。请求包含 `id、method、params`,响应包含 `id、ok、result/error`。
接口:`health`、`memory.recall`、`interaction.remember`、`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`、`memory.maintenance.status/plan/commit`、`memory.topic.get`、`memory.skill.list/get/set_status`、`bundle.get/list/document.put`。修改资料使用 `expected_revision`,防止猫窝和桌喵同时编辑时旧内容覆盖新内容。
## 顶层记忆
@ -50,11 +51,38 @@ Memory 返回相关与最近的问答、任务卡以及资料包,供顶层优
{"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` 只判断任务切换及上下文整理,
不替代记忆召回。
结果保留旧的任务卡、问答和资料包字段,并新增 `recent_workdays`、`related_topics`、
`core_facts`、`related_facts` 和 `relevant_skills`。话题按标题和概括召回,长期事实按范围和内容匹配;
上层会裁剪候选数量,详细经历通过 `memory.topic.get` 按话题 ID 分页读取。
旧任务卡检索仍只扫描最近 100 张、逐条问答检索仍只扫描最近 200 条;
话题检索目前扫描全部话题并排序,尚未建立全文索引;数据量显著增长后应换成索引召回。
`transition.assess` 只提示任务切换,
不决定经历是否合并。
## 话题与整理
记忆中的 `topic_id` 是话题单元,既可表示执行任务,也可表示闲聊和讨论;它与 Agent 执行队列的
`task_id` 不同。一个话题可跨工作日继续。工作日固定为北京时间 03:30 至次日 03:29:59,
与实际压缩作业是否延期无关。SQLite 中的 `topic_interactions` 只建立关联,不改写原始交互;
`topic_segments` 保存每日极简概括。最近三个有话题片段的工作日进入顶层概览,旧话题仍可检索。
原始执行日志仍由 Agent 运行时 SQLite 保存;Agent 按话题读取详情时,利用关联的执行任务 ID
补充有限的执行日志,不在 Memory 中复制无界工具输出。
下层 Agent 在无执行任务时分批调用 `memory.maintenance.plan`,使用执行模型归类话题、概括结果与问题,
然后用 `memory.maintenance.commit` 原子写入。每条交互必须恰好分配一次;重启中断不会删除原始记录。
一小时无用户或执行活动后会整理未归档交互;跨过 03:30 时,在下层空闲后复核前一工作日。
整理期间上层 Supervisor 仍可交流,新执行任务排队等待。Memory 服务本身不调用模型或 Executor。
长期事实必须附可逐字核对的证据,但不要求每批对话都提炼事实。`profile` 只收来自用户原话的
稳定身份、交流偏好和职业工作方式,并须标明 `profile_kind`;当前推进的工作用 `project`,
只按相关性提供,不常驻顶层画像。具体日程、临时位置、输入法状态、软件是否已打开和单次测试结果
留在话题经历,不提炼为稳定画像。`environment` 仅限受控设备范围;无论观察来自用户原话还是
任务结果,单个工作日都只能是 `candidate`,跨两个有效工作日有独立证据才会变成 `active`。
仅有上层回复复述不算验证;三天未获佐证的候选在日审中停用。已停用的事实不会被整理作业自动重启。
少量全局 `profile` 常驻上层,`project` 和已确认的设备事实按问题相关性挑选。
用户指导产生的技能只进入 `draft`,须用户本轮明确要求启用后才能
`set_status` 为 `active`;执行层可以读取草稿用于审阅,但只有已启用技能可作为执行参考。
不要把凭据、隐藏思考或无界工具输出写入记忆。
服务故障不会暂停执行队列。顶层会显示实际的记忆请求错误;恢复服务后,
后续交互会继续同步最近的已完成问答。

View File

@ -19,6 +19,13 @@ 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 === "memory.maintenance.status") return store.maintenanceStatus(Number(params.now ?? Date.now()));
if (method === "memory.maintenance.plan") return store.maintenancePlan(params);
if (method === "memory.maintenance.commit") return store.commitMaintenance(params);
if (method === "memory.topic.get") return store.topicDetail(params);
if (method === "memory.skill.list") return store.listSkills(params);
if (method === "memory.skill.get") return store.skillDetail(params);
if (method === "memory.skill.set_status") return store.setSkillStatus(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);

View File

@ -3,6 +3,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { boundedText, safeMemoryText, similarity } from "./policy.js";
import { workdayAt } from "./workday.js";
function json(value) {
return JSON.stringify(value);
@ -61,9 +62,41 @@ export class MemoryStore {
reason TEXT NOT NULL, status TEXT NOT NULL, details TEXT NOT NULL,
created_at INTEGER NOT NULL, completed_at INTEGER
);
CREATE TABLE IF NOT EXISTS topics (
topic_id TEXT PRIMARY KEY, title TEXT NOT NULL, category TEXT NOT NULL,
summary TEXT NOT NULL, result TEXT NOT NULL, issues TEXT NOT NULL,
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS topic_segments (
topic_id TEXT NOT NULL, workday TEXT NOT NULL, summary TEXT NOT NULL,
result TEXT NOT NULL, issues TEXT NOT NULL, updated_at INTEGER NOT NULL,
PRIMARY KEY(topic_id, workday)
);
CREATE TABLE IF NOT EXISTS topic_interactions (
interaction_id TEXT PRIMARY KEY, topic_id TEXT NOT NULL, workday TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS memory_facts (
fact_id TEXT PRIMARY KEY, category TEXT NOT NULL, statement TEXT NOT NULL,
scope TEXT NOT NULL, status TEXT NOT NULL, source_quote TEXT NOT NULL,
evidence_interaction_id TEXT NOT NULL, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL, UNIQUE(category, statement, scope)
);
CREATE TABLE IF NOT EXISTS memory_fact_evidence (
fact_id TEXT NOT NULL, interaction_id TEXT NOT NULL, source_type TEXT NOT NULL,
source_quote TEXT NOT NULL, workday TEXT NOT NULL,
PRIMARY KEY(fact_id, interaction_id, source_type)
);
CREATE TABLE IF NOT EXISTS memory_skills (
skill_id TEXT PRIMARY KEY, title TEXT NOT NULL, body TEXT NOT NULL,
status TEXT NOT NULL, evidence_interaction_id TEXT NOT NULL,
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS memory_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
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);
CREATE INDEX IF NOT EXISTS topic_segments_workday ON topic_segments(workday DESC);
CREATE INDEX IF NOT EXISTS topic_interactions_topic ON topic_interactions(topic_id, workday);`);
}
close() {
@ -190,9 +223,53 @@ export class MemoryStore {
related_interactions: relatedInteractions,
recent_interactions: recentInteractions,
bundles,
...this.relevantMemory(query, limit, params),
};
}
relevantMemory(query, limit = 8, context = {}) {
const safeLimit = Math.max(1, Math.min(10, limit));
const continuation = /继续|刚才|上次|之前|那个|这件事|接着|then|previous/i.test(query);
const previousInteractionId = boundedText(context.previous_interaction_id, 200);
const taskId = boundedText(context.task_id, 200);
const previousTopicId = previousInteractionId
? this.db.prepare("SELECT topic_id FROM topic_interactions WHERE interaction_id=?").get(previousInteractionId)?.topic_id
: null;
const taskTopics = new Set(taskId ? this.db.prepare(`SELECT DISTINCT ti.topic_id FROM topic_interactions ti
JOIN interaction_memories i ON i.interaction_id=ti.interaction_id WHERE i.task_id=?`).all(taskId)
.map((row) => row.topic_id) : []);
const allTopics = this.db.prepare("SELECT * FROM topics").all();
const relatedTopics = allTopics
.map((row) => ({ row, score: similarity(`${row.title}\n${row.summary}\n${row.result}\n${row.issues}`, query)
+ (continuation && row.topic_id === previousTopicId ? 0.6 : 0) + (taskTopics.has(row.topic_id) ? 1 : 0) }))
.filter((candidate) => candidate.score > 0)
.sort((a, b) => b.score - a.score || Number(b.row.updated_at) - Number(a.row.updated_at))
.slice(0, safeLimit)
.map(({ row, score }) => ({ ...row, score: Number(score.toFixed(3)) }));
const workdays = this.db.prepare("SELECT DISTINCT workday FROM topic_segments ORDER BY workday DESC LIMIT 3").all()
.map((item) => ({ workday: item.workday, topics: this.db.prepare(`SELECT t.topic_id,t.title,t.category,s.summary,s.result,s.issues
FROM topic_segments s JOIN topics t ON t.topic_id=s.topic_id
WHERE s.workday=? ORDER BY s.updated_at DESC LIMIT 8`).all(item.workday) }));
const coreFacts = this.db.prepare(`SELECT fact_id,category,statement,scope,updated_at FROM memory_facts
WHERE status='active' AND category='profile' AND scope='global' ORDER BY updated_at DESC LIMIT 8`).all();
const relatedFacts = this.db.prepare(`SELECT fact_id,category,statement,scope,updated_at FROM memory_facts
WHERE status='active' ORDER BY updated_at DESC`).all()
.map((row) => ({ ...row, score: similarity(`${row.scope}\n${row.statement}`, query) }))
.filter((row) => row.score > 0 && (row.scope === "global" ||
boundedText(context.scope, 100).toLowerCase() === String(row.scope).toLowerCase()))
.sort((a, b) => b.score - a.score || Number(b.updated_at) - Number(a.updated_at))
.slice(0, safeLimit)
.map((row) => ({ ...row, score: Number(row.score.toFixed(3)) }));
const skills = this.db.prepare("SELECT skill_id,title,body,updated_at FROM memory_skills WHERE status='active' ORDER BY updated_at DESC")
.all().map((row) => ({ row, score: similarity(`${row.title}\n${row.body}`, query) }))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score || Number(b.row.updated_at) - Number(a.row.updated_at))
.slice(0, safeLimit)
.map((item) => ({ skill_id: item.row.skill_id, title: item.row.title, score: Number(item.score.toFixed(3)) }));
return { recent_workdays: workdays, related_topics: relatedTopics, core_facts: coreFacts,
related_facts: relatedFacts, relevant_skills: skills };
}
rememberInteractions(params) {
const interactions = Array.isArray(params.interactions) ? params.interactions.slice(0, 100) : [];
const statement = this.db.prepare(`INSERT OR IGNORE INTO interaction_memories
@ -303,6 +380,11 @@ export class MemoryStore {
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 })),
relevant_memory: (() => {
const selected = this.relevantMemory(objective, 3, { task_id: params.related_task_id, scope: params.scope });
return { related_topics: selected.related_topics, related_facts: selected.related_facts,
core_facts: selected.core_facts, relevant_skills: selected.relevant_skills };
})(),
};
this.event("context.prepared", { context_epoch: contextEpoch, schedule_id: scheduleId }, taskId, scheduleId ? "schedule" : "task", scheduleId ?? taskId);
return pack;
@ -340,9 +422,240 @@ export class MemoryStore {
return { consolidation_id: id, status };
}
maintenanceStatus(now = Date.now()) {
const workday = workdayAt(now);
const pending = Number(this.db.prepare(`SELECT count(*) AS count FROM interaction_memories i
LEFT JOIN topic_interactions ti ON ti.interaction_id=i.interaction_id
WHERE ti.interaction_id IS NULL`).get().count);
const reviewed = this.db.prepare("SELECT value FROM memory_meta WHERE key='last_reviewed_workday'").get()?.value ?? null;
return { workday, pending, nightly_due: reviewed !== workday, last_reviewed_workday: reviewed };
}
maintenancePlan(params = {}) {
const now = Number.isSafeInteger(params.now) ? params.now : Date.now();
const limit = Number.isSafeInteger(params.limit) ? Math.max(1, Math.min(40, params.limit)) : 24;
const pending = this.db.prepare(`SELECT i.* FROM interaction_memories i
LEFT JOIN topic_interactions ti ON ti.interaction_id=i.interaction_id
WHERE ti.interaction_id IS NULL ORDER BY i.created_at,i.rowid LIMIT ?`).all(limit);
const workday = pending.length ? workdayAt(Number(pending[0].created_at)) : workdayAt(now);
const interactions = pending.filter((row) => workdayAt(Number(row.created_at)) === workday).map((row) => ({
interaction_id: row.interaction_id,
source: row.source,
user: row.user_text.slice(0, 2_000),
manager: row.manager_text.slice(0, 2_000),
task_id: row.task_id,
created_at: row.created_at,
}));
const query = interactions.map((item) => item.user).join("\n");
const topics = this.db.prepare(`SELECT t.*,s.summary AS workday_summary,
s.result AS workday_result,s.issues AS workday_issues FROM topics t
LEFT JOIN topic_segments s ON s.topic_id=t.topic_id AND s.workday=?`).all(workday)
.map((row) => ({ ...row, score: similarity(`${row.title}\n${row.summary}\n${row.workday_summary ?? ""}`, query) }))
.sort((a, b) => b.score - a.score || Number(b.updated_at) - Number(a.updated_at))
.slice(0, 12)
.map((row) => ({ topic_id: row.topic_id, title: row.title, category: row.category,
summary: row.summary, result: row.result, issues: row.issues, updated_at: row.updated_at,
workday_summary: row.workday_summary, workday_result: row.workday_result,
workday_issues: row.workday_issues }));
const taskIds = [...new Set(interactions.map((item) => item.task_id).filter(Boolean))];
const tasks = taskIds.map((id) => this.db.prepare("SELECT task_id,objective,status,result FROM task_cards WHERE task_id=?").get(id))
.filter(Boolean).map((row) => ({ ...row, objective: row.objective.slice(0, 500), result: row.result.slice(0, 1_000) }));
const facts = this.db.prepare(`SELECT fact_id,category,statement,scope,status,source_quote
FROM memory_facts WHERE status IN ('active','candidate') ORDER BY updated_at DESC LIMIT 30`).all();
const currentWorkday = workdayAt(now);
const reviewWorkday = new Date(Date.parse(`${currentWorkday}T00:00:00Z`) - 86_400_000).toISOString().slice(0, 10);
const reviewTopics = this.db.prepare(`SELECT t.topic_id,t.title,t.category,s.summary,s.result,s.issues,s.workday
FROM topic_segments s JOIN topics t ON t.topic_id=s.topic_id
WHERE s.workday=? ORDER BY s.updated_at DESC LIMIT 30`).all(reviewWorkday);
return { workday, review_workday: reviewWorkday, interactions, topics, tasks, facts,
review_topics: reviewTopics, status: this.maintenanceStatus(now) };
}
commitMaintenance(params) {
const workday = boundedText(params.workday, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(workday)) throw new Error("Invalid workday");
const groups = Array.isArray(params.topics) ? params.topics.slice(0, 40) : [];
const facts = Array.isArray(params.facts) ? params.facts.slice(0, 30) : [];
const skills = Array.isArray(params.skills) ? params.skills.slice(0, 10) : [];
const reviews = Array.isArray(params.fact_reviews) ? params.fact_reviews.slice(0, 30) : [];
const topicReviews = Array.isArray(params.topic_reviews) ? params.topic_reviews.slice(0, 30) : [];
const seen = new Set();
const assignments = [];
for (const group of groups) {
const ids = Array.isArray(group.interaction_ids) ? group.interaction_ids.slice(0, 40) : [];
for (const id of ids) {
const interactionId = identifier(id, "interaction id");
if (seen.has(interactionId)) throw new Error(`Duplicate interaction: ${interactionId}`);
const row = this.db.prepare("SELECT created_at FROM interaction_memories WHERE interaction_id=?").get(interactionId);
if (!row || workdayAt(Number(row.created_at)) !== workday) throw new Error(`Interaction outside workday: ${interactionId}`);
const existing = this.db.prepare("SELECT topic_id FROM topic_interactions WHERE interaction_id=?").get(interactionId);
if (existing) throw new Error(`Interaction already assigned: ${interactionId}`);
seen.add(interactionId);
}
if (ids.length) assignments.push({ group, ids });
}
const now = Date.now();
this.db.exec("BEGIN IMMEDIATE");
try {
const assigned = [];
for (const { group, ids } of assignments) {
const requestedId = boundedText(group.topic_id, 200);
const topicId = requestedId || randomUUID();
const existing = requestedId ? this.db.prepare("SELECT created_at FROM topics WHERE topic_id=?").get(topicId) : null;
if (requestedId && !existing) throw new Error(`Unknown topic: ${topicId}`);
const title = safeMemoryText(group.title, 120);
const category = boundedText(group.category, 40) || "chat";
const summary = safeMemoryText(group.summary, 800);
const segmentSummary = safeMemoryText(group.segment_summary, 800) || summary;
const result = safeMemoryText(group.result, 400);
const issues = safeMemoryText(group.issues, 400);
if (!title || !summary) throw new Error("Topic title and summary are required");
this.db.prepare("INSERT OR REPLACE INTO topics VALUES (?,?,?,?,?,?,?,?)")
.run(topicId, title, category, summary, result, issues, Number(existing?.created_at ?? now), now);
this.db.prepare("INSERT OR REPLACE INTO topic_segments VALUES (?,?,?,?,?,?)")
.run(topicId, workday, segmentSummary, result, issues, now);
for (const id of ids) {
this.db.prepare("INSERT INTO topic_interactions VALUES (?,?,?)").run(id, topicId, workday);
assigned.push(id);
}
}
let storedFacts = 0;
for (const fact of facts) {
const interactionId = boundedText(fact.evidence_interaction_id, 200);
const evidence = this.db.prepare("SELECT user_text,manager_text,task_id,created_at FROM interaction_memories WHERE interaction_id=?").get(interactionId);
const quote = safeMemoryText(fact.evidence_quote ?? fact.user_quote, 200);
if (!evidence || !quote || quote === "[敏感内容未写入记忆]") continue;
const taskResult = evidence.task_id
? this.db.prepare("SELECT result FROM task_cards WHERE task_id=?").get(evidence.task_id)?.result ?? ""
: "";
const sourceType = String(evidence.user_text).includes(quote) ? "user"
: String(taskResult).includes(quote) ? "task"
: String(evidence.manager_text).includes(quote) ? "manager" : null;
if (!sourceType) continue;
const category = ["profile", "project", "environment"].includes(fact.category) ? fact.category : null;
const statement = safeMemoryText(fact.statement, category === "environment" ? 300 : 180);
const scope = boundedText(fact.scope, 100) || (category === "environment" ? boundedText(params.default_scope, 100) : "global");
const profileKind = boundedText(fact.profile_kind, 40);
const stableProfile = category !== "profile" ||
(["identity", "preference", "work_style"].includes(profileKind) &&
!/(曾经|当时|这次|本次|今天|刚才|目前|当前|暂时|现在|正在|\d{1,2}[::]\d{2})/.test(statement));
if (!category || !scope || !statement || statement === "[敏感内容未写入记忆]" ||
!stableProfile || (category !== "environment" && (scope !== "global" || sourceType !== "user")) ||
(category === "environment" && scope === "global")) continue;
const current = this.db.prepare("SELECT fact_id,created_at,status FROM memory_facts WHERE category=? AND statement=? AND scope=?")
.get(category, statement, scope);
const factId = current?.fact_id ?? randomUUID();
this.db.prepare("INSERT OR IGNORE INTO memory_fact_evidence VALUES (?,?,?,?,?)")
.run(factId, interactionId, sourceType, quote, workdayAt(Number(evidence.created_at)));
const independentDays = Number(this.db.prepare(`SELECT count(DISTINCT workday) AS count FROM memory_fact_evidence
WHERE fact_id=? AND source_type IN ('user','task')`).get(factId).count);
const status = current?.status === "inactive" ? "inactive"
: category !== "environment" || current?.status === "active" || independentDays >= 2
? "active" : "candidate";
this.db.prepare("INSERT OR REPLACE INTO memory_facts VALUES (?,?,?,?,?,?,?,?,?)")
.run(factId, category, statement, scope, status, quote,
interactionId, Number(current?.created_at ?? now), now);
storedFacts++;
}
let storedSkills = 0;
for (const skill of skills) {
const interactionId = boundedText(skill.evidence_interaction_id, 200);
const evidence = this.db.prepare("SELECT user_text FROM interaction_memories WHERE interaction_id=?").get(interactionId);
const quote = safeMemoryText(skill.user_quote, 200);
if (!evidence || !quote || !String(evidence.user_text).includes(quote)) continue;
const title = safeMemoryText(skill.title, 120);
const body = safeMemoryText(skill.body, 4_000);
if (!title || !body || body === "[敏感内容未写入记忆]") continue;
const existingSkill = this.db.prepare("SELECT skill_id FROM memory_skills WHERE title=? AND evidence_interaction_id=?")
.get(title, interactionId);
if (existingSkill) continue;
this.db.prepare("INSERT INTO memory_skills VALUES (?,?,?,?,?,?,?)")
.run(randomUUID(), title, body, "draft", interactionId, now, now);
storedSkills++;
}
if (params.nightly === true) for (const review of topicReviews) {
const topicId = boundedText(review.topic_id, 200);
const reviewWorkday = boundedText(params.review_workday, 10);
const existing = this.db.prepare("SELECT topic_id FROM topic_segments WHERE topic_id=? AND workday=?")
.get(topicId, reviewWorkday);
if (!existing) continue;
const summary = safeMemoryText(review.summary, 800);
if (!summary || summary === "[敏感内容未写入记忆]") continue;
const result = safeMemoryText(review.result, 400);
const issues = safeMemoryText(review.issues, 400);
this.db.prepare("UPDATE topic_segments SET summary=?,result=?,issues=?,updated_at=? WHERE topic_id=? AND workday=?")
.run(summary, result, issues, now, topicId, reviewWorkday);
this.db.prepare(`UPDATE topics SET summary=?,result=?,issues=?,updated_at=? WHERE topic_id=?
AND NOT EXISTS (SELECT 1 FROM topic_segments WHERE topic_id=? AND workday>?)`)
.run(summary, result, issues, now, topicId, topicId, reviewWorkday);
}
for (const review of params.nightly === true ? reviews : []) {
if (review.status !== "inactive") continue;
this.db.prepare("UPDATE memory_facts SET status='inactive',updated_at=? WHERE fact_id=?")
.run(now, boundedText(review.fact_id, 200));
}
if (params.nightly === true)
this.db.prepare(`UPDATE memory_facts SET status='inactive',updated_at=?
WHERE status='candidate' AND
(SELECT max(workday) FROM memory_fact_evidence WHERE fact_id=memory_facts.fact_id) <= date(?, '-3 day')`)
.run(now, workdayAt(now));
if (params.nightly === true)
this.db.prepare("INSERT OR REPLACE INTO memory_meta VALUES ('last_reviewed_workday',?)")
.run(workdayAt(now));
this.event("maintenance.committed", { workday, assigned: assigned.length, facts: storedFacts, skills: storedSkills });
this.db.exec("COMMIT");
return { assigned: assigned.length, facts: storedFacts, skills: storedSkills };
} catch (error) {
this.db.exec("ROLLBACK");
throw error;
}
}
topicDetail(params) {
const topicId = identifier(params.topic_id, "topic id");
const topic = this.db.prepare("SELECT * FROM topics WHERE topic_id=?").get(topicId);
if (!topic) return null;
const limit = Number.isSafeInteger(params.limit) ? Math.max(1, Math.min(100, params.limit)) : 30;
const after = Number.isSafeInteger(params.after) ? Math.max(0, params.after) : 0;
const interactions = this.db.prepare(`SELECT i.*,i.rowid AS cursor_seq,ti.workday FROM topic_interactions ti
JOIN interaction_memories i ON i.interaction_id=ti.interaction_id
WHERE ti.topic_id=? AND i.rowid>? ORDER BY i.rowid LIMIT ?`).all(topicId, after, limit)
.map((row) => ({ ...row, user_text: row.user_text.slice(0, 1_000), manager_text: row.manager_text.slice(0, 1_000) }));
return { ...topic, segments: this.db.prepare("SELECT * FROM topic_segments WHERE topic_id=? ORDER BY workday DESC LIMIT 30").all(topicId),
interactions, next_after: interactions.at(-1)?.cursor_seq ?? null };
}
listSkills(params = {}) {
const status = params.status === "draft" ? "draft" : "active";
const limit = Number.isSafeInteger(params.limit) ? Math.max(1, Math.min(50, params.limit)) : 20;
return this.db.prepare("SELECT skill_id,title,status,updated_at FROM memory_skills WHERE status=? ORDER BY updated_at DESC LIMIT ?")
.all(status, limit);
}
skillDetail(params) {
return this.db.prepare("SELECT * FROM memory_skills WHERE skill_id=?").get(identifier(params.skill_id, "skill id")) ?? null;
}
setSkillStatus(params) {
if (params.status !== "active" && params.status !== "inactive") throw new Error("Invalid skill status");
if (params.confirmed_by_user !== true) throw new Error("Skill activation requires explicit user confirmation");
const skillId = identifier(params.skill_id, "skill id");
const existing = this.skillDetail({ skill_id: skillId });
if (!existing) throw new Error("Skill not found");
this.db.prepare("UPDATE memory_skills SET status=?,updated_at=? WHERE skill_id=?")
.run(params.status, Date.now(), skillId);
this.event("skill.status_changed", { skill_id: skillId, status: params.status });
return this.skillDetail({ skill_id: skillId });
}
status() {
return {
tasks: Number(this.db.prepare("SELECT count(*) AS count FROM task_cards").get().count),
topics: Number(this.db.prepare("SELECT count(*) AS count FROM topics").get().count),
facts: Number(this.db.prepare("SELECT count(*) AS count FROM memory_facts WHERE status='active'").get().count),
candidate_facts: Number(this.db.prepare("SELECT count(*) AS count FROM memory_facts WHERE status='candidate'").get().count),
skills: Number(this.db.prepare("SELECT count(*) AS count FROM memory_skills WHERE status='active'").get().count),
draft_skills: Number(this.db.prepare("SELECT count(*) AS count FROM memory_skills WHERE status='draft'").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),

23
src/workday.js Normal file
View File

@ -0,0 +1,23 @@
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
});
/** A DeskPaw workday starts at 03:30 China time, irrespective of job timing. */
export function workdayAt(timestamp) {
if (!Number.isFinite(timestamp)) throw new Error("Invalid workday timestamp");
const values = Object.fromEntries(formatter.formatToParts(new Date(timestamp)).map((part) => [part.type, part.value]));
const year = Number(values.year);
const month = Number(values.month);
const day = Number(values.day);
const hour = Number(values.hour);
const minute = Number(values.minute);
const date = Date.UTC(year, month - 1, day);
return new Date(date - (hour < 3 || (hour === 3 && minute < 30) ? 86_400_000 : 0))
.toISOString().slice(0, 10);
}

204
test/topics.test.js Normal file
View File

@ -0,0 +1,204 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { MemoryStore } from "../src/store.js";
import { workdayAt } from "../src/workday.js";
function withStore(run) {
const directory = mkdtempSync(join(tmpdir(), "deskpaw-topics-"));
const store = new MemoryStore(join(directory, "memory.sqlite"), join(directory, "bundles"));
try { run(store); } finally {
store.close();
rmSync(directory, { recursive: true, force: true });
}
}
test("workday has a fixed 03:30 Asia/Shanghai boundary", () => {
assert.equal(workdayAt(Date.parse("2026-09-22T19:29:59Z")), "2026-09-22");
assert.equal(workdayAt(Date.parse("2026-09-22T19:30:00Z")), "2026-09-23");
});
test("lower maintenance groups chat into a stable cross-day topic", () => withStore((store) => {
const first = Date.parse("2026-09-22T19:29:00Z");
const second = Date.parse("2026-09-22T19:31:00Z");
store.rememberInteractions({ interactions: [
{ interaction_id: "one", source: "feishu", user: "我们聊聊记忆架构", manager: "可以讨论短期记忆。", created_at: first },
{ interaction_id: "two", source: "terminal", user: "继续聊记忆架构", manager: "继续讨论话题单元。", created_at: second },
] });
const firstPlan = store.maintenancePlan({ now: second });
assert.equal(firstPlan.workday, "2026-09-22");
assert.deepEqual(firstPlan.interactions.map((item) => item.interaction_id), ["one"]);
assert.deepEqual(store.commitMaintenance({ workday: firstPlan.workday, topics: [
{ title: "记忆架构", category: "chat", summary: "讨论短期记忆。", result: "待续", issues: "话题边界", interaction_ids: ["one"] },
] }), { assigned: 1, facts: 0, skills: 0 });
const firstTopic = store.recall({ query: "记忆架构" }).related_topics[0];
const secondPlan = store.maintenancePlan({ now: second });
assert.equal(secondPlan.workday, "2026-09-23");
assert.deepEqual(secondPlan.interactions.map((item) => item.interaction_id), ["two"]);
store.commitMaintenance({ workday: secondPlan.workday, topics: [
{ topic_id: firstTopic.topic_id, title: "记忆架构", category: "chat", summary: "讨论话题单元。", result: "确定 03:30 边界", issues: "待实施", interaction_ids: ["two"] },
] });
const detail = store.topicDetail({ topic_id: firstTopic.topic_id });
assert.equal(detail.segments.length, 2);
assert.deepEqual(detail.interactions.map((item) => item.interaction_id), ["one", "two"]);
assert.equal(store.recall({ query: "记忆架构" }).recent_workdays.length, 2);
}));
test("later batches see the previous segment for the same workday", () => withStore((store) => {
const at = Date.parse("2026-09-23T04:00:00Z");
store.rememberInteractions({ interactions: [
{ interaction_id: "first", source: "terminal", user: "讨论记忆压缩", manager: "记录短期", created_at: at },
{ interaction_id: "later", source: "terminal", user: "继续讨论记忆压缩", manager: "讨论长期", created_at: at + 60_000 },
] });
const workday = workdayAt(at);
store.commitMaintenance({ workday, topics: [
{ title: "记忆压缩", category: "chat", summary: "短期设计", segment_summary: "确定短期记忆的设计", result: "已定义短期", issues: "长期待议", interaction_ids: ["first"] },
] });
const plan = store.maintenancePlan({ now: at + 60_000 });
assert.deepEqual(plan.interactions.map((row) => row.interaction_id), ["later"]);
const topic = plan.topics.find((row) => row.title === "记忆压缩");
assert.equal(topic.workday_summary, "确定短期记忆的设计");
assert.equal(topic.workday_result, "已定义短期");
assert.equal(topic.workday_issues, "长期待议");
}));
test("facts require verbatim user evidence and skills remain drafts", () => withStore((store) => {
store.rememberInteractions({ interactions: [
{ interaction_id: "pref", source: "feishu", user: "我喜欢简洁的回复", manager: "知道了", created_at: Date.now() },
] });
const workday = store.maintenancePlan().workday;
const result = store.commitMaintenance({ workday, topics: [], facts: [
{ category: "profile", profile_kind: "preference", statement: "用户偏好简洁回复", scope: "global", evidence_interaction_id: "pref", user_quote: "我喜欢简洁的回复" },
{ category: "environment", statement: "用户电脑永久需要 VPN", scope: "global", evidence_interaction_id: "pref", user_quote: "用户电脑永久需要 VPN" },
], skills: [
{ title: "回复", body: "简短回答", evidence_interaction_id: "pref", user_quote: "我喜欢简洁的回复" },
] });
assert.deepEqual(result, { assigned: 0, facts: 1, skills: 1 });
const recalled = store.recall({ query: "回复偏好" });
assert.equal(recalled.core_facts[0].statement, "用户偏好简洁回复");
const skill = store.db.prepare("SELECT skill_id,status FROM memory_skills").get();
assert.equal(skill.status, "draft");
assert.throws(() => store.setSkillStatus({ skill_id: skill.skill_id, status: "active" }), /confirmation/);
assert.equal(store.setSkillStatus({ skill_id: skill.skill_id, status: "active", confirmed_by_user: true }).status, "active");
assert.equal(store.recall({ query: "简短回答" }).relevant_skills[0].skill_id, skill.skill_id);
}));
test("nightly review has a fixed prior workday and does not rewrite raw interactions", () => withStore((store) => {
const eventAt = Date.parse("2026-09-22T12:00:00Z");
const reviewAt = Date.parse("2026-09-22T19:31:00Z");
store.rememberInteractions({ interactions: [
{ interaction_id: "night", source: "terminal", user: "讨论一下话题", manager: "先记录", created_at: eventAt },
] });
store.commitMaintenance({ workday: "2026-09-22", topics: [
{ title: "话题讨论", category: "chat", summary: "初稿", result: "未完成", issues: "待续", interaction_ids: ["night"] },
] });
const plan = store.maintenancePlan({ now: reviewAt });
assert.equal(plan.review_workday, "2026-09-22");
assert.equal(plan.review_topics.length, 1);
const topicId = plan.review_topics[0].topic_id;
store.commitMaintenance({ workday: plan.workday, review_workday: plan.review_workday, nightly: true,
topic_reviews: [{ topic_id: topicId, summary: "讨论话题边界", result: "形成初步定义", issues: "待验证" }] });
assert.equal(store.maintenanceStatus(reviewAt).nightly_due, false);
assert.equal(store.topicDetail({ topic_id: topicId }).segments[0].summary, "讨论话题边界");
assert.equal(store.topicDetail({ topic_id: topicId }).interactions[0].user_text, "讨论一下话题");
}));
test("relevance prioritizes linked topics and requires two workdays for device facts", () => withStore((store) => {
const first = Date.parse("2026-09-21T04:00:00Z");
const second = Date.parse("2026-09-22T04:00:00Z");
store.rememberInteractions({ interactions: [
{ interaction_id: "linked", source: "feishu", user: "讨论键鼠", manager: "记录了", task_id: "task-1", created_at: first },
{ interaction_id: "scope", source: "terminal", user: "这台 Windows 电脑运行 Codex 要开 VPN", manager: "收到", created_at: first },
{ interaction_id: "scope-again", source: "terminal", user: "这台 Windows 电脑运行 Codex 要开 VPN", manager: "已复核", created_at: second },
] });
store.commitMaintenance({ workday: workdayAt(first), topics: [
{ title: "键鼠", category: "execution", summary: "讨论纯 HID 控制", interaction_ids: ["linked"] },
{ title: "网络", category: "chat", summary: "讨论 Codex 网络", interaction_ids: ["scope"] },
], facts: [
{ category: "environment", statement: "运行 Codex 要开 VPN", scope: "windows-pc", evidence_interaction_id: "scope", user_quote: "这台 Windows 电脑运行 Codex 要开 VPN" },
] });
const linked = store.recall({ query: "刚才说的事", previous_interaction_id: "linked", task_id: "task-1" });
assert.equal(linked.related_topics[0].title, "键鼠");
assert.equal(store.recall({ query: "Codex", scope: "windows-pc" }).related_facts.length, 0);
store.commitMaintenance({ workday: workdayAt(second), facts: [
{ category: "environment", statement: "运行 Codex 要开 VPN", scope: "windows-pc", evidence_interaction_id: "scope-again", user_quote: "这台 Windows 电脑运行 Codex 要开 VPN" },
] });
assert.equal(store.recall({ query: "Codex" }).related_facts.length, 0);
assert.equal(store.recall({ query: "Codex", scope: "windows-pc" }).related_facts.length, 1);
}));
test("episodic profile claims stay out of core while a current project is related-only", () => withStore((store) => {
store.rememberInteractions({ interactions: [
{ interaction_id: "desk", source: "feishu", user: "我今天就在电脑前,可以自己核对效果", manager: "收到", created_at: Date.now() },
{ interaction_id: "project", source: "feishu", user: "我在推进纯 HDMI 加 HID 控制电脑的优化", manager: "了解", created_at: Date.now() },
{ interaction_id: "schedule", source: "feishu", user: "每天 8:30 分享推文到三个群", manager: "收到", created_at: Date.now() },
] });
store.commitMaintenance({ workday: store.maintenancePlan().workday, facts: [
{ category: "profile", profile_kind: "identity", statement: "用户当时在电脑前可自行核对", scope: "global", evidence_interaction_id: "desk", user_quote: "我今天就在电脑前" },
{ category: "profile", statement: "用户在推进 HDMI HID 优化", scope: "global", evidence_interaction_id: "project", user_quote: "我在推进纯 HDMI 加 HID" },
{ category: "profile", profile_kind: "work_style", statement: "用户每天 8:30 分享推文到三个群", scope: "global", evidence_interaction_id: "schedule", user_quote: "每天 8:30 分享推文到三个群" },
{ category: "project", statement: "用户在推进纯 HDMI 与 HID 控制电脑项目", scope: "global", evidence_interaction_id: "project", user_quote: "我在推进纯 HDMI 加 HID" },
] });
const recall = store.recall({ query: "HDMI HID 项目" });
assert.equal(recall.core_facts.length, 0);
assert.equal(recall.related_facts.length, 1);
assert.equal(recall.related_facts[0].category, "project");
}));
test("nightly review expires stale candidates and inactive facts cannot be revived by maintenance", () => withStore((store) => {
const old = Date.now() - 5 * 86_400_000;
store.rememberInteractions({ interactions: [
{ interaction_id: "old-device", source: "feishu", user: "鼠标临时没有反应", manager: "已记录", created_at: old },
{ interaction_id: "pref", source: "feishu", user: "我喜欢简洁回答", manager: "收到", created_at: old },
] });
const day = workdayAt(old);
store.commitMaintenance({ workday: day, facts: [
{ category: "environment", statement: "鼠标没有反应", scope: "device-a", evidence_interaction_id: "old-device", user_quote: "鼠标临时没有反应" },
{ category: "profile", profile_kind: "preference", statement: "用户喜欢简洁回答", scope: "global", evidence_interaction_id: "pref", user_quote: "我喜欢简洁回答" },
] });
const profileId = store.db.prepare("SELECT fact_id FROM memory_facts WHERE category='profile'").get().fact_id;
store.commitMaintenance({ workday: workdayAt(Date.now()), nightly: true,
fact_reviews: [{ fact_id: profileId, status: "inactive" }] });
assert.deepEqual(store.db.prepare("SELECT status FROM memory_facts ORDER BY category").all().map((row) => row.status), ["inactive", "inactive"]);
store.commitMaintenance({ workday: day, facts: [
{ category: "profile", profile_kind: "preference", statement: "用户喜欢简洁回答", scope: "global", evidence_interaction_id: "pref", user_quote: "我喜欢简洁回答" },
] });
assert.equal(store.db.prepare("SELECT status FROM memory_facts WHERE fact_id=?").get(profileId).status, "inactive");
assert.equal(store.maintenancePlan().facts.length, 0);
}));
test("one observed environment failure stays a candidate until a second workday corroborates it", () => withStore((store) => {
const first = Date.parse("2026-09-21T04:00:00Z");
const second = Date.parse("2026-09-22T04:00:00Z");
store.completeTask({ task_id: "check-1", objective: "检查网络", status: "completed", result: "Codex 直连超时" });
store.completeTask({ task_id: "check-2", objective: "再次检查网络", status: "completed", result: "Codex 直连超时" });
store.rememberInteractions({ interactions: [
{ interaction_id: "obs-1", source: "terminal", user: "检查网络", manager: "任务完成", task_id: "check-1", created_at: first },
{ interaction_id: "obs-2", source: "terminal", user: "再次检查网络", manager: "任务完成", task_id: "check-2", created_at: second },
] });
const fact = (id) => ({ category: "environment", statement: "该设备 Codex 直连可能超时", scope: "pc-a",
evidence_interaction_id: id, evidence_quote: "Codex 直连超时" });
store.commitMaintenance({ workday: workdayAt(first), facts: [fact("obs-1")] });
assert.equal(store.db.prepare("SELECT status FROM memory_facts").get().status, "candidate");
assert.equal(store.recall({ query: "Codex", scope: "pc-a" }).related_facts.length, 0);
store.commitMaintenance({ workday: workdayAt(second), facts: [fact("obs-2")] });
assert.equal(store.db.prepare("SELECT status FROM memory_facts").get().status, "active");
assert.equal(store.recall({ query: "Codex", scope: "pc-a" }).related_facts.length, 1);
}));
test("topic detail pagination does not skip interactions with identical timestamps", () => withStore((store) => {
const at = Date.parse("2026-09-23T04:00:00Z");
store.rememberInteractions({ interactions: [
{ interaction_id: "a", source: "terminal", user: "第一条", manager: "收到", created_at: at },
{ interaction_id: "b", source: "terminal", user: "第二条", manager: "收到", created_at: at },
] });
store.commitMaintenance({ workday: workdayAt(at), topics: [
{ title: "连续对话", summary: "两条对话", interaction_ids: ["a", "b"] },
] });
const id = store.recall({ query: "连续对话" }).related_topics[0].topic_id;
const first = store.topicDetail({ topic_id: id, limit: 1 });
const second = store.topicDetail({ topic_id: id, limit: 1, after: first.next_after });
assert.deepEqual([first.interactions[0].interaction_id, second.interactions[0].interaction_id], ["a", "b"]);
}));