diff --git a/README.md b/README.md index 6a37868..6748dc8 100644 --- a/README.md +++ b/README.md @@ -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`;执行层可以读取草稿用于审阅,但只有已启用技能可作为执行参考。 +不要把凭据、隐藏思考或无界工具输出写入记忆。 服务故障不会暂停执行队列。顶层会显示实际的记忆请求错误;恢复服务后, 后续交互会继续同步最近的已完成问答。 diff --git a/src/server.js b/src/server.js index 0be1924..f1890e3 100644 --- a/src/server.js +++ b/src/server.js @@ -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); diff --git a/src/store.js b/src/store.js index b782387..7cc2144 100644 --- a/src/store.js +++ b/src/store.js @@ -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), diff --git a/src/workday.js b/src/workday.js new file mode 100644 index 0000000..2e3b660 --- /dev/null +++ b/src/workday.js @@ -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); +} diff --git a/test/topics.test.js b/test/topics.test.js new file mode 100644 index 0000000..fcefcc1 --- /dev/null +++ b/test/topics.test.js @@ -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"]); +}));