feat: organize topic memory and filter durable facts
This commit is contained in:
parent
95b77187de
commit
97e88c0a24
40
README.md
40
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`;执行层可以读取草稿用于审阅,但只有已启用技能可作为执行参考。
|
||||
不要把凭据、隐藏思考或无界工具输出写入记忆。
|
||||
|
||||
服务故障不会暂停执行队列。顶层会显示实际的记忆请求错误;恢复服务后,
|
||||
后续交互会继续同步最近的已完成问答。
|
||||
|
||||
@ -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);
|
||||
|
||||
315
src/store.js
315
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),
|
||||
|
||||
23
src/workday.js
Normal file
23
src/workday.js
Normal 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
204
test/topics.test.js
Normal 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"]);
|
||||
}));
|
||||
Loading…
Reference in New Issue
Block a user