deskpaw-memory/test/chain.test.js

300 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { DatabaseSync } from "node:sqlite";
import assert from "node:assert/strict";
import { test } from "node:test";
import { ChainStore } from "../src/chain-store.js";
import { Timeline } from "../src/timeline.js";
import { CoreProjection } from "../src/core-projection.js";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { estimateTokens } from "../src/token-budget.js";
function fixture() {
const db = new DatabaseSync(":memory:"), timeline = new Timeline(db), chain = new ChainStore(db, timeline);
const append = (id, text = "用户讨论报销工作与发票整理。".repeat(100), kind = "user", at = 1) => timeline.append({ id, text, kind, at, source: { channel: "test", sender: "owner" } });
const claim = (mode = "compact") => {
chain.request({ request_id: `test:${Math.random()}`, mode }, 10);
return chain.claim({}, 20).job;
};
const proposal = (job, extra = {}) => ({ job_id: job.id, token: job.token, covered_node_ids: job.nodes.map(n => n.id),
summary: "讨论报销,尚未提交。详细依据可以展开原始事件。", objects: [], edges: [], core: [], catalog: [], ...extra });
return { db, timeline, chain, append, claim, proposal };
}
const person = (id = "person:qhy", refs = ["meeting"]) => ({ id, type: "person", name: "钱灏宇", summary: "用户的同事,与报销工作有关。",
body: "已确认是同事。具体职责尚未确认。", aliases: ["灏宇"], cues: ["发票整理找谁沟通", "同事交接报销材料"], source_refs: refs, status: "active" });
test("atomic compaction preserves raw evidence and concurrent suffix; receipt retries are idempotent", () => {
const f = fixture();
try {
f.append("meeting"); const job = f.claim();
f.append("new", "正在整理时用户的新消息", "user", 30); f.chain.sync();
const receipt = f.chain.commit(f.proposal(job, { objects: [person()], catalog: [{ type: "person", summary: "有同事和报销协作人的相关记忆" }] }), 40);
assert.equal(f.chain.commit(f.proposal(job), 50).revision, receipt.revision);
const context = f.chain.context(); assert.equal(context.ready, true);
assert.ok(context.nodes.some(n => n.text.includes("新消息")));
assert.ok(context.nodes.some(n => n.id === receipt.node_id));
assert.ok(f.timeline.read({ id: "meeting" }).text.length > 1000);
assert.ok(f.chain.node({ id: receipt.node_id }).children.length);
assert.equal(f.chain.graph.find({ query: "钱灏宇" }).hits[0].id, "person:qhy");
assert.equal(f.chain.graph.find({ query: "灏宇" }).hits[0].id, "person:qhy");
assert.equal(f.chain.graph.find({ query: "发票整理找谁沟通" }).hits[0].id, "person:qhy");
} finally { f.db.close(); }
});
test("invalid evidence rolls back graph, timeline and revision together", () => {
const f = fixture();
try {
f.append("meeting"); const job = f.claim(), before = f.chain.context();
assert.throws(() => f.chain.commit(f.proposal(job, { objects: [person(), person("person:bad", ["not-in-batch"])] }), 40), /Evidence/);
assert.equal(f.chain.graph.object("person:qhy"), null);
assert.deepEqual(f.chain.context().nodes, before.nodes);
assert.equal(f.chain.revision(), 0);
assert.throws(() => f.chain.commit({ ...f.proposal(job), covered_node_ids: [] }, 40), /every claimed node/);
} finally { f.db.close(); }
});
test("core changes invalidate stale leases and expired leases can be reclaimed", () => {
const f = fixture();
try {
f.append("meeting"); const job = f.claim();
f.chain.bootstrapCore({ "SOUL.md": "做可靠的工作同事。" });
assert.throws(() => f.chain.commit(f.proposal(job), 40), /Memory changed/);
assert.equal(f.chain.fail({ job_id: job.id, token: job.token, error: "revision conflict" }, 50).released, true);
const next = f.chain.claim({}, 30000).job; assert.equal(next.id, job.id); assert.notEqual(next.token, job.token);
assert.throws(() => f.chain.commit(f.proposal(next), 300001), /Expired/);
assert.equal(f.chain.claim({}, 300002).job.id, job.id);
} finally { f.db.close(); }
});
test("02:50 local nightly job waits five quiet minutes, is unique, and yields between batches", () => {
const f = fixture();
try {
f.append("meeting");
const now = Date.parse("2026-10-11T18:50:00Z");
f.chain.tick({ last_dialogue_at: now - 240000, busy: false }, now);
assert.equal(f.chain.claim({}, now).job, null);
f.chain.tick({ busy: true }, now + 120000);
assert.equal(f.chain.claim({}, now + 120000).job, null);
f.chain.tick({ busy: false }, now + 120001);
const job = f.chain.claim({}, now + 120001).job;
assert.equal(job.trigger, "nightly"); assert.equal(job.mode, "deep");
f.chain.commit(f.proposal(job), now + 120010);
f.chain.tick({ busy: false }, now + 240000);
assert.equal(f.chain.status().jobs.filter(j => j.trigger === "nightly").length, 1);
assert.equal(f.chain.claim({}, now + 240000).job, null);
} finally { f.db.close(); }
});
test("threshold is not an every-message model trigger; explicit request is an event", () => {
const f = fixture();
try {
f.append("meeting", "普通聊天");
const now = Date.parse("2026-10-11T17:00:00Z");
f.chain.tick({ threshold_tokens: 8000 }, now);
assert.equal(f.chain.status().jobs.length, 0);
f.append("big", "财务材料".repeat(2000));
f.chain.tick({ threshold_tokens: 8000 }, now + 1);
assert.equal(f.chain.status().jobs[0].trigger, "threshold");
f.chain.request({ request_id: "explicit:one", scope: "整理钱灏宇有关的记忆", mode: "deep" }, now + 2);
f.chain.request({ request_id: "explicit:one", scope: "整理钱灏宇有关的记忆", mode: "deep" }, now + 3);
assert.equal(f.db.prepare("SELECT count(*) AS n FROM timeline WHERE kind='organize_request'").get().n, 1);
} finally { f.db.close(); }
});
test("projects require user confirmation, graph links are cross-project and paginated", () => {
const f = fixture();
try {
f.append("meeting"); const job = f.claim();
const project = { ...person("project:expense"), type: "project", name: "报销", summary: "每月报销与日常发票收集" };
f.chain.commit(f.proposal(job, { objects: [person(), project], edges: [{ from_id: "person:qhy", relation: "参与", to_id: project.id, source_refs: ["meeting"] }] }), 40);
assert.equal(f.chain.graph.object(project.id).status, "candidate");
assert.equal(f.chain.graph.find({ project_id: project.id, type: "person" }).hits[0].id, "person:qhy");
assert.equal(f.chain.graph.read({ id: "person:qhy" }).edges.length, 1);
f.append("yes", "同意创建报销项目", "user", 60);
assert.throws(() => f.chain.projectDecision({ id: project.id, request_id: "decision", status: "active", source_id: "yes", user_quote: "不是用户原话" }), /exact user evidence/);
f.chain.projectDecision({ id: project.id, request_id: "decision", status: "active", source_id: "yes", user_quote: "同意创建报销项目" });
assert.equal(f.chain.graph.object(project.id).status, "active");
} finally { f.db.close(); }
});
test("same name does not merge identities; read/cues never become new factual evidence", () => {
const f = fixture();
try {
f.append("meeting"); f.append("read", "钱灏宇的历史记忆", "memory_recall"); const job = f.claim();
assert.throws(() => f.chain.commit(f.proposal(job, { objects: [person("person:false", ["read"])] }), 40), /independent evidence/);
f.chain.commit(f.proposal(job, { objects: [person(), person("person:another")] }), 40);
assert.equal(f.chain.graph.find({ query: "钱灏宇" }).hits.length, 2);
} finally { f.db.close(); }
});
test("catalog remains bounded, types can be fully paginated, no implicit graph fan-out", () => {
const f = fixture();
try {
for (let i = 0; i < 300; i++) f.chain.graph.apply({ objects: [person(`person:${i}`)] }, i + 1, new Map([["meeting", { kind: "user", text: "" }]]), i);
assert.ok(JSON.stringify(f.chain.graph.catalog()).length < 3000);
assert.equal(f.chain.graph.catalog().entries[0].count, 300);
assert.equal(f.chain.graph.find({ type: "person", offset: 290, limit: 20 }).hits.length, 10);
assert.throws(() => f.chain.graph.find({ limit: 300 }));
} finally { f.db.close(); }
});
test("credential metadata permits vault refs but rejects values; core identity needs user evidence", () => {
const f = fixture();
try {
f.append("meeting"); const job = f.claim();
assert.throws(() => f.chain.commit(f.proposal(job, { core: [{ name: "USER.md", body: "用户喜欢自动付款", source_refs: ["meeting"] }] }), 40), /exact user evidence/);
assert.throws(() => f.chain.commit(f.proposal(job, { objects: [{ ...person(), body: "sk-example-secret-01234567890" }] }), 40), /Credential/);
assert.throws(() => f.chain.commit(f.proposal(job, { objects: [{ ...person("credential:bad"), type: "credential", secret_ref: "sk-example-secret-01234567890" }] }), 40), /Credential/);
f.chain.commit(f.proposal(job, { objects: [{ ...person("credential:model"), type: "credential", name: "模型服务", body: "用于模型调用;真实值仅存密钥库", secret_ref: "provider/example/api_key" }] }), 40);
assert.equal(f.chain.graph.object("credential:model").secret_ref, "provider/example/api_key");
} finally { f.db.close(); }
});
test("large events span bounded batches without skipping sibling chunks", () => {
const f = fixture();
try {
f.append("big", "原始会议内容".repeat(5000));
const request = f.chain.request({ request_id: "large", mode: "deep" }, 10);
let batches = 0;
for (let now = 20; now < 100; now++) {
const job = f.chain.claim({ max_bytes: 24000 }, now).job;
if (!job) break;
assert.ok(Buffer.byteLength(JSON.stringify(job)) <= 24000);
f.chain.commit(f.proposal(job), now); batches++;
}
assert.ok(batches > 1);
assert.equal(f.chain.status({ id: request.id }).jobs[0].status, "done");
assert.equal(f.db.prepare("SELECT count(*) AS n FROM memory_nodes WHERE active=1 AND event_id='big'").get().n, 0);
assert.equal(f.timeline.read({ id: "big", limit: 32000 }).text.length, 30000);
} finally { f.db.close(); }
});
test("core files import human edits, project committed versions, and reject oversized/secret edits without data loss", () => {
const f = fixture(), dir = mkdtempSync(join(tmpdir(), "memory-core-"));
try {
writeFileSync(join(dir, "USER.md"), "用户喜欢简洁沟通。");
const projection = new CoreProjection(f.chain, dir); projection.refresh();
assert.equal(f.chain.graph.core()[0].body, "用户喜欢简洁沟通。");
const revision = f.chain.revision();
f.chain.editCore({ name: "USER.md", body: "用户喜欢简洁且有依据的回复。", expected_revision: revision });
projection.refresh();
assert.equal(readFileSync(join(dir, "USER.md"), "utf8"), "用户喜欢简洁且有依据的回复。");
writeFileSync(join(dir, "USER.md"), "用户自己修改的新偏好。"); projection.refresh();
assert.equal(f.chain.graph.core()[0].body, "用户自己修改的新偏好。");
writeFileSync(join(dir, "USER.md"), "password=never-store-this-value"); projection.refresh();
assert.equal(f.chain.graph.core()[0].body, "用户自己修改的新偏好。");
assert.ok(f.chain.meta("core_projection_error"));
assert.equal(readFileSync(join(dir, "USER.md"), "utf8"), "password=never-store-this-value");
assert.throws(() => f.chain.editCore({ name: "USER.md", body: "stale", expected_revision: revision }), /revision conflict/);
} finally { f.db.close(); rmSync(dir, { recursive: true, force: true }); }
});
test("organization survives SQLite reopen and a stale worker cannot overwrite a reclaimed batch", () => {
const dir = mkdtempSync(join(tmpdir(), "memory-restart-")), path = join(dir, "memory.sqlite");
let db = new DatabaseSync(path);
try {
let timeline = new Timeline(db), chain = new ChainStore(db, timeline);
timeline.append({ id: "event", kind: "user", at: 1, text: "旧事件需要保留".repeat(100), source: {} });
chain.request({ request_id: "restart" }, 10); const first = chain.claim({}, 20).job;
db.close(); db = new DatabaseSync(path); timeline = new Timeline(db); chain = new ChainStore(db, timeline);
assert.equal(chain.claim({}, 30).job, null);
const next = chain.claim({}, 180021).job;
assert.equal(next.id, first.id); assert.notEqual(next.token, first.token);
const proposal = { job_id: first.id, token: first.token, summary: "保留经历", covered_node_ids: first.nodes.map(n => n.id) };
assert.throws(() => chain.commit(proposal, 180022), /stale/);
chain.commit({ ...proposal, token: next.token, covered_node_ids: next.nodes.map(n => n.id) }, 180022);
assert.equal(db.prepare("PRAGMA integrity_check").get().integrity_check, "ok");
assert.equal(timeline.read({ id: "event" }).text, "旧事件需要保留".repeat(100));
} finally { db.close(); rmSync(dir, { recursive: true, force: true }); }
});
test("deep inspection grants evidence without letting uninspected objects be replaced", () => {
const f = fixture();
try {
f.append("meeting"); let job = f.claim();
f.chain.commit(f.proposal(job, { objects: [person()] }), 40);
f.append("new", "用户进一步说明了同事职责".repeat(100));
f.chain.request({ request_id: "inspect", mode: "compact", scope: "完全不相关词 abcxyz" }, 50);
job = f.chain.claim({}, 60).job;
const change = { ...person(), body: "更正后的同事信息", source_refs: ["new"] };
assert.throws(() => f.chain.commit(f.proposal(job, { objects: [change] }), 70), /Inspect existing/);
const inspected = f.chain.inspect({ job_id: job.id, token: job.token, kind: "object", id: "person:qhy" }, 70);
assert.equal(inspected.name, "钱灏宇");
f.chain.commit(f.proposal(job, { objects: [change] }), 80);
assert.equal(f.chain.graph.object("person:qhy").body, "更正后的同事信息");
assert.equal(f.chain.graph.read({ id: "person:qhy" }).total_sources, 2);
} finally { f.db.close(); }
});
test("timeline clipping returns newest nodes, explicit omission and separate byte/token accounting without deleting evidence", () => {
const f = fixture();
try {
for (let i=0;i<10;i++) f.append(`raw:${i}`, `事件${i}:${"中文正文".repeat(200)}`, "user", i);
const full=f.chain.context({max_tokens:100000,max_bytes:350000});
const view=f.chain.context({max_tokens:4000,max_bytes:350000});
assert.equal(view.ready,true); assert.equal(view.truncated,true);
assert.equal(view.nodes.at(-1).event_id,"raw:9");
assert.ok(view.view_estimated_tokens<=4000);
assert.equal(view.view_estimated_tokens,2+view.nodes.reduce((n,node)=>n+estimateTokens(node)+1,0));
assert.equal(view.omitted_nodes,full.nodes.length-view.nodes.length);
assert.deepEqual(f.chain.context({max_tokens:100000,max_bytes:350000}).nodes,full.nodes);
assert.ok(f.timeline.read({id:"raw:0"}).text.includes("中文正文"));
} finally {f.db.close();}
});
test("deep review covers dormant and archived objects across durable batches and never invents timeline events", () => {
const f=fixture();
try {
f.append("meeting");
for(let i=0;i<13;i++) f.chain.graph.apply({objects:[{...person(`person:old-${i}`),status:i===0?"archived":"active"}]},i+1,new Map([["meeting",{kind:"user",text:"证据"}]]),i);
let after, ids=[];
do { const page=f.chain.graph.browse({after,limit:3}); ids.push(...page.entries.map(x=>x.id)); after=page.next_after; } while(after);
assert.equal(ids.length,14); assert.ok(ids.includes("person:old-0"));
const req=f.chain.request({request_id:"global-review",mode:"deep",scope:"与旧人物无关的新工作"},10);
const reviewed=new Set(); let emptyBatches=0;
for(let now=20;now<40;now++) {
const job=f.chain.claim({},now).job; if(!job)break;
for(const obj of job.review_objects) reviewed.add(obj.id);
if(!job.nodes.length)emptyBatches++;
const before=f.chain.context().nodes;
f.chain.commit(f.proposal(job),now);
if(!job.nodes.length)assert.deepEqual(f.chain.context().nodes,before);
}
assert.equal(reviewed.size,14); assert.ok(emptyBatches>0);
const status=f.chain.status({id:req.id}).jobs[0];
assert.equal(status.status,"done"); assert.deepEqual({...status.object_review},{total:14,reviewed:14});
assert.equal(f.chain.graph.object("person:old-0").status,"archived");
}finally{f.db.close();}
});
test("failed deep batch does not advance review coverage",()=>{
const f=fixture();
try {
f.append("meeting"); const first=f.claim("deep");
f.chain.fail({job_id:first.id,token:first.token,error:"test failure"},30);
assert.equal(f.chain.status({id:first.id}).jobs[0].object_review.reviewed,0);
const retry=f.chain.claim({},30000).job;
assert.deepEqual(retry.review_objects.map(x=>x.id),first.review_objects.map(x=>x.id));
}finally{f.db.close();}
});
test("opaque Chinese evidence IDs remain expandable and credential metadata is not a secret", () => {
const f = fixture();
try {
f.append("会议记录/十月十二日", "讨论密钥管理:用途是模型调用,引用 provider/example/api_key,未提供真实值。".repeat(10));
const job = f.claim();
assert.ok(f.chain.node({ id: "会议记录/十月十二日" }).text.includes("provider/example/api_key"));
f.chain.commit(f.proposal(job, { objects: [person("person:qhy", ["会议记录/十月十二日"])] }), 40);
assert.deepEqual(f.chain.graph.read({ id: "person:qhy" }).source_refs, ["会议记录/十月十二日"]);
f.append("quoted-secret", '{"api_key":"sk-example-secret-01234567890"}');
assert.equal(f.chain.node({ id: "quoted-secret" }).text, "[敏感内容未写入记忆]");
} finally { f.db.close(); }
});
test("failed model batches shrink on retry without consuming or dropping their original nodes", () => {
const f = fixture();
try {
for(let i=0;i<12;i++) f.append(`event:${i}`, "有效工作事实".repeat(20));
const first=f.claim(), before=f.chain.context().nodes;
assert.equal(first.nodes.length,8);
f.chain.fail({job_id:first.id,token:first.token,error:"Memory output truncated"},30);
const next=f.chain.claim({},30000).job;
assert.equal(next.id,first.id); assert.equal(next.nodes.length,4);
assert.deepEqual(f.chain.context().nodes,before);
f.chain.commit(f.proposal(next),30001);
assert.ok(f.chain.context().nodes.some(node=>node.event_id==="event:11"));
} finally { f.db.close(); }
});