@kira905/dsh-memory-toolkit 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/LICENSE +661 -0
- package/README.en.md +110 -0
- package/README.md +102 -2
- package/dsh-memory-local/FORK-BASE.json +24 -0
- package/dsh-memory-local/README.md +113 -0
- package/dsh-memory-local/build.mjs +101 -0
- package/dsh-memory-local/cordis.patch.yml +20 -0
- package/dsh-memory-local/deploy.mjs +294 -0
- package/dsh-memory-local/entity-status.mjs +75 -0
- package/dsh-memory-local/lib/bridge.mjs +108 -0
- package/dsh-memory-local/lib/host-adapter.js +1171 -0
- package/dsh-memory-local/lib/index.d.ts +128 -0
- package/dsh-memory-local/lib/index.js +944 -0
- package/dsh-memory-local/lib/injector.mjs +400 -0
- package/dsh-memory-local/lib/memory-read.mjs +116 -0
- package/dsh-memory-local/lib/module-gate.mjs +68 -0
- package/dsh-memory-local/lib/pointer.mjs +152 -0
- package/dsh-memory-local/lib/prod-gate.mjs +121 -0
- package/dsh-memory-local/lib/recall-log.mjs +118 -0
- package/dsh-memory-local/lib/recall.mjs +192 -0
- package/dsh-memory-local/lib/schema-migrate.mjs +365 -0
- package/dsh-memory-local/lib/user-message.mjs +32 -0
- package/dsh-memory-local/lib/vectors-read.mjs +110 -0
- package/dsh-memory-local/package.json +51 -0
- package/dsh-memory-local/restore.mjs +259 -0
- package/dsh-memory-local/rollback-injector-fix.mjs +166 -0
- package/dsh-memory-local/src/bridge.mjs +108 -0
- package/dsh-memory-local/src/host-adapter.js +1171 -0
- package/dsh-memory-local/src/index.d.ts +128 -0
- package/dsh-memory-local/src/index.js +944 -0
- package/dsh-memory-local/src/injector.mjs +400 -0
- package/dsh-memory-local/src/memory-read.mjs +116 -0
- package/dsh-memory-local/src/module-gate.mjs +68 -0
- package/dsh-memory-local/src/pointer.mjs +152 -0
- package/dsh-memory-local/src/recall-log.mjs +118 -0
- package/dsh-memory-local/src/recall.mjs +192 -0
- package/dsh-memory-local/src/schema-migrate.mjs +365 -0
- package/dsh-memory-local/src/user-message.mjs +32 -0
- package/dsh-memory-local/src/vectors-read.mjs +110 -0
- package/dsh-memory-local/test/fixtures-s2b.mjs +216 -0
- package/dsh-memory-local/test/probe-entity.mjs +170 -0
- package/dsh-memory-local/test/regression.mjs +270 -0
- package/dsh-memory-local/test/s2b.test.mjs +568 -0
- package/dsh-memory-local/test/schema-migrate.test.mjs +334 -0
- package/dsh-memory-local/test/test-adapter-converge.mjs +127 -0
- package/dsh-memory-local/test/visible-injections.test.mjs +239 -0
- package/dsh-memory-local/verify-after-restart.mjs +181 -0
- package/lib/deploy-guard.mjs +391 -0
- package/lib/guard-http.mjs +93 -0
- package/lib/pitfall-form-map.mjs +130 -0
- package/lib/pitfall-score.mjs +185 -0
- package/lib/session-file-name.mjs +160 -0
- package/lib/zstd-frames.mjs +78 -0
- package/memory-db-tools/README.md +68 -0
- package/memory-db-tools/_test/test-memory-scope.mjs +302 -0
- package/memory-db-tools/audit-analyze.mjs +352 -0
- package/memory-db-tools/audit-dump-ids.mjs +245 -0
- package/memory-db-tools/audit-export.mjs +270 -0
- package/memory-db-tools/audit-hit-probe.mjs +227 -0
- package/memory-db-tools/audit-ledger-probe.mjs +236 -0
- package/memory-db-tools/audit-persona-overlap.mjs +277 -0
- package/memory-db-tools/audit-struct-probe.mjs +213 -0
- package/memory-db-tools/audit-verify-unchanged.mjs +276 -0
- package/memory-db-tools/check-memory-db.mjs +8 -0
- package/memory-db-tools/dump-memory-db.mjs +12 -0
- package/memory-db-tools/lib/memory-scope.mjs +446 -0
- package/memory-db-tools/list-memory-summary.mjs +477 -0
- package/memory-db-tools/memory-identity-check.mjs +101 -0
- package/memory-db-tools/memory-schema-migrate.mjs +290 -0
- package/memory-db-tools/memory-scope-fixture.mjs +197 -0
- package/memory-db-tools/read-memories.mjs +26 -0
- package/memory-db-tools/read-memory-db.mjs +20 -0
- package/memory-db-tools/refine-memories.mjs +247 -0
- package/memory-db-tools/show-mem-times.mjs +22 -0
- package/memory-engine/README.md +70 -0
- package/memory-engine/bench/bench.mjs +221 -0
- package/memory-engine/cli/distill-rollback.mjs +86 -0
- package/memory-engine/cli/distill.mjs +156 -0
- package/memory-engine/cli/doc-index-rebuild.mjs +155 -0
- package/memory-engine/cli/doc-index.mjs +48 -0
- package/memory-engine/cli/domain-tags.mjs +251 -0
- package/memory-engine/cli/pitfall-hook.mjs +91 -0
- package/memory-engine/cli/recall.mjs +132 -0
- package/memory-engine/cli/selfcheck.mjs +57 -0
- package/memory-engine/cli/serve-start.mjs +247 -0
- package/memory-engine/cli/sweep.mjs +58 -0
- package/memory-engine/cli/sync.mjs +69 -0
- package/memory-engine/lib/cli-util.mjs +197 -0
- package/memory-engine/lib/config.mjs +135 -0
- package/memory-engine/lib/dirty-ledger.mjs +174 -0
- package/memory-engine/lib/distill-rollback.mjs +178 -0
- package/memory-engine/lib/distill.mjs +898 -0
- package/memory-engine/lib/doc-index-store.mjs +325 -0
- package/memory-engine/lib/doc-index.mjs +529 -0
- package/memory-engine/lib/doc-scan.mjs +271 -0
- package/memory-engine/lib/doc-summarize.mjs +305 -0
- package/memory-engine/lib/domain-tags.mjs +429 -0
- package/memory-engine/lib/embedder.mjs +242 -0
- package/memory-engine/lib/hashes.mjs +12 -0
- package/memory-engine/lib/memory-source.mjs +127 -0
- package/memory-engine/lib/pitfall-source.mjs +170 -0
- package/memory-engine/lib/policy-source.mjs +371 -0
- package/memory-engine/lib/recall.mjs +331 -0
- package/memory-engine/lib/selfcheck.mjs +131 -0
- package/memory-engine/lib/sweep.mjs +159 -0
- package/memory-engine/lib/sync.mjs +180 -0
- package/memory-engine/lib/tick-hook.mjs +218 -0
- package/memory-engine/lib/vectors-store.mjs +292 -0
- package/memory-engine/package-lock.json +1086 -0
- package/memory-engine/package.json +22 -0
- package/memory-engine/serve.mjs +171 -0
- package/memory-engine/test/01-store.test.mjs +127 -0
- package/memory-engine/test/02-sync.test.mjs +196 -0
- package/memory-engine/test/03-sweep.test.mjs +145 -0
- package/memory-engine/test/04-recall.test.mjs +214 -0
- package/memory-engine/test/05-selfcheck.test.mjs +175 -0
- package/memory-engine/test/06-doc-index.test.mjs +928 -0
- package/memory-engine/test/07-domain-tags.test.mjs +298 -0
- package/memory-engine/test/08-distill.test.mjs +686 -0
- package/memory-engine/test/09-pitfall-source.test.mjs +365 -0
- package/memory-engine/test/10-tick-hook.test.mjs +252 -0
- package/memory-engine/test/11-policy-source.test.mjs +224 -0
- package/memory-engine/test/_tickhook-worker.mjs +56 -0
- package/memory-engine/test/fixtures.mjs +205 -0
- package/memory-engine/test/harness.mjs +73 -0
- package/memory-engine/test/run.mjs +35 -0
- package/memory-engine/tools/bridge-smoke.mjs +80 -0
- package/memory-engine/tools/fetch-model.mjs +128 -0
- package/memory-engine/tools/smoke-recall.mjs +76 -0
- package/package.json +53 -4
- package/persona-memory-audit.mjs +217 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// 记忆引擎 v2 · S3b —— 域标签补齐单测(零依赖;夹具在系统临时区,不碰任何真实库)
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
6
|
+
import { test, section, assert, assertEq } from './harness.mjs';
|
|
7
|
+
import * as D from '../lib/domain-tags.mjs';
|
|
8
|
+
|
|
9
|
+
export const __section = 'S3b · 域标签补齐(幂等 / 判重 / 成本闸 / 续跑 / 不碰 updated_at)';
|
|
10
|
+
|
|
11
|
+
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 's3b-domaintags-'));
|
|
12
|
+
|
|
13
|
+
/** 造一个 8 列的临时库(含 owner_user/visibility),rows = [{id,text,tags,pinned,owner,visibility}] */
|
|
14
|
+
function makeDb(name, rows, { withOwner = true } = {}) {
|
|
15
|
+
const dir = path.join(TMP, name);
|
|
16
|
+
fs.mkdirSync(path.join(dir, 'memory'), { recursive: true });
|
|
17
|
+
const p = path.join(dir, 'memory', 'memory.db');
|
|
18
|
+
const db = new DatabaseSync(p);
|
|
19
|
+
db.exec(`CREATE TABLE memories (
|
|
20
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, tags TEXT NOT NULL DEFAULT '',
|
|
21
|
+
pinned INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
22
|
+
${withOwner ? ", owner_user TEXT NOT NULL DEFAULT 'LiJianXiao', visibility TEXT NOT NULL DEFAULT 'private_owner'" : ''}
|
|
23
|
+
)`);
|
|
24
|
+
const sql = withOwner
|
|
25
|
+
? 'INSERT INTO memories (id, text, tags, pinned, created_at, updated_at, owner_user, visibility) VALUES (?,?,?,?,?,?,?,?)'
|
|
26
|
+
: 'INSERT INTO memories (id, text, tags, pinned, created_at, updated_at) VALUES (?,?,?,?,?,?)';
|
|
27
|
+
const ins = db.prepare(sql);
|
|
28
|
+
for (const r of rows) {
|
|
29
|
+
const base = [r.id, r.text, r.tags ?? '', r.pinned ?? 0, r.created_at ?? 1700000000000, r.updated_at ?? 1700000000000];
|
|
30
|
+
if (withOwner) ins.run(...base, r.owner ?? 'LiJianXiao', r.visibility ?? 'private_owner');
|
|
31
|
+
else ins.run(...base);
|
|
32
|
+
}
|
|
33
|
+
db.close();
|
|
34
|
+
return { dir, dbPath: p, home: dir };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── 01 追加标签:幂等 + 判重 ────────────────────────────────────────────────
|
|
38
|
+
section('01 追加域标签(幂等 + token 级判重)');
|
|
39
|
+
test('appendDomainTags:追加不覆盖原标签', () => {
|
|
40
|
+
const r = D.appendDomainTags('project dsh tools', ['技术', 'DSH生态']);
|
|
41
|
+
assertEq(r.tags, 'project dsh tools 技术 dsh生态', '追加到尾部且保持原 token 顺序');
|
|
42
|
+
assertEq(r.added, ['技术', 'dsh生态'], 'added 记录实际新增');
|
|
43
|
+
});
|
|
44
|
+
test('appendDomainTags:同输入跑两次 ⇒ 第二次零新增(写前逐标签判重)', () => {
|
|
45
|
+
const once = D.appendDomainTags('项目 工作rog', ['工作']);
|
|
46
|
+
const twice = D.appendDomainTags(once.tags, ['工作']);
|
|
47
|
+
assertEq(once.tags, '项目 工作rog 工作', '第一次追加');
|
|
48
|
+
assertEq(twice.added, [], '第二次零新增');
|
|
49
|
+
assertEq(twice.tags, once.tags, '第二次不改内容');
|
|
50
|
+
const tokens = D.tokensOf(twice.tags);
|
|
51
|
+
assertEq(tokens.length, new Set(tokens).size, '无重复 token');
|
|
52
|
+
});
|
|
53
|
+
test('appendDomainTags:**token 级**判重(`工作rog` ≠ `工作`)', () => {
|
|
54
|
+
const r = D.appendDomainTags('工作rog', ['工作']);
|
|
55
|
+
assertEq(r.added, ['工作'], '`工作rog` 不算已含 `工作`');
|
|
56
|
+
assert(r.tags.split(/\s+/).includes('工作'), '新 token 独立存在');
|
|
57
|
+
});
|
|
58
|
+
test('appendDomainTags:集合外的标签被丢弃(模型自创词不落库)', () => {
|
|
59
|
+
const r = D.appendDomainTags('', ['技术', '编程', '学习']);
|
|
60
|
+
assertEq(r.wanted, ['技术'], '只认固定集合');
|
|
61
|
+
assertEq(r.tags, '技术', '自创词不写库');
|
|
62
|
+
});
|
|
63
|
+
test('appendDomainTags:标签集为空 ⇒ 一个字都不动', () => {
|
|
64
|
+
const r = D.appendDomainTags('a b', []);
|
|
65
|
+
assertEq(r.tags, 'a b', 'tags 原样');
|
|
66
|
+
assertEq(r.added, [], '零新增');
|
|
67
|
+
});
|
|
68
|
+
test('domainTokensIn:只认域标签 token', () => {
|
|
69
|
+
assertEq(D.domainTokensIn('技术 dsh生态 工作rog'), ['技术', 'dsh生态'], 'token 级识别');
|
|
70
|
+
assertEq(D.domainTokensIn(''), [], '空 tags');
|
|
71
|
+
});
|
|
72
|
+
test('tagSetHash:与顺序无关、与重复无关、不同集不同哈希', () => {
|
|
73
|
+
assertEq(D.tagSetHash(['工作', '技术']), D.tagSetHash(['技术', '工作', '技术']), '顺序/重复不敏感');
|
|
74
|
+
assert(D.tagSetHash(['工作']) !== D.tagSetHash(['技术']), '不同集合不同哈希');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ── 02 库层:写回 + 幂等 + 不动 updated_at ──────────────────────────────────
|
|
78
|
+
section('02 库层写回(幂等 / 事务 / 不动 updated_at)');
|
|
79
|
+
test('applyResults:写回并追加(事务内)', async () => {
|
|
80
|
+
const { dbPath } = makeDb('apply1', [{ id: 1, text: 'a', tags: 'x' }, { id: 2, text: 'b', tags: '' }]);
|
|
81
|
+
const db = D.openDb(dbPath, { write: true });
|
|
82
|
+
const r = await D.applyResults(db, [{ id: 1, domains: ['技术'] }, { id: 2, domains: ['家庭', '工作'] }]);
|
|
83
|
+
const rows = db.prepare('SELECT id, tags FROM memories ORDER BY id').all();
|
|
84
|
+
db.close();
|
|
85
|
+
assert(r.ok, '写回成功');
|
|
86
|
+
assertEq(r.written, 2, '写 2 条');
|
|
87
|
+
assertEq(rows[0].tags, 'x 技术', '第 1 条追加');
|
|
88
|
+
assertEq(rows[1].tags, '家庭 工作', '第 2 条写入');
|
|
89
|
+
});
|
|
90
|
+
test('applyResults:同一批跑两次 ⇒ 第二次零写入、无重复 token(§3.6 验收)', async () => {
|
|
91
|
+
const { dbPath } = makeDb('apply2', [{ id: 1, text: 'a', tags: 'project' }]);
|
|
92
|
+
const results = [{ id: 1, domains: ['技术', 'DSH生态'] }];
|
|
93
|
+
const db = D.openDb(dbPath, { write: true });
|
|
94
|
+
const first = await D.applyResults(db, results);
|
|
95
|
+
const before = db.prepare('SELECT tags FROM memories WHERE id=1').get().tags;
|
|
96
|
+
const second = await D.applyResults(db, results);
|
|
97
|
+
const after = db.prepare('SELECT tags FROM memories WHERE id=1').get().tags;
|
|
98
|
+
db.close();
|
|
99
|
+
assertEq(first.written, 1, '第一次写 1 条');
|
|
100
|
+
assertEq(second.written, 0, '第二次零写入');
|
|
101
|
+
assertEq(second.unchanged, 1, '第二次判为 already');
|
|
102
|
+
assertEq(after, before, 'tags 逐字不变');
|
|
103
|
+
const tokens = D.tokensOf(after);
|
|
104
|
+
assertEq(tokens.length, new Set(tokens).size, '没有重复 token');
|
|
105
|
+
});
|
|
106
|
+
test('applyResults:**不动 updated_at**(防顶进 recall 段 / 误入提炼窗口)', async () => {
|
|
107
|
+
const { dbPath } = makeDb('apply3', [{ id: 1, text: 'a', tags: '', updated_at: 1234567 }]);
|
|
108
|
+
const db = D.openDb(dbPath, { write: true });
|
|
109
|
+
await D.applyResults(db, [{ id: 1, domains: ['技术'] }]);
|
|
110
|
+
const row = db.prepare('SELECT tags, updated_at, created_at FROM memories WHERE id=1').get();
|
|
111
|
+
db.close();
|
|
112
|
+
assertEq(row.updated_at, 1234567, 'updated_at 未被改动');
|
|
113
|
+
assertEq(row.created_at, 1700000000000, 'created_at 未被改动');
|
|
114
|
+
assertEq(row.tags, '技术', '只改了 tags');
|
|
115
|
+
});
|
|
116
|
+
test('applyResults:模型给不存在的 id ⇒ 记为 missing,不误写', async () => {
|
|
117
|
+
const { dbPath } = makeDb('apply4', [{ id: 1, text: 'a', tags: '' }]);
|
|
118
|
+
const db = D.openDb(dbPath, { write: true });
|
|
119
|
+
const r = await D.applyResults(db, [{ id: 999, domains: ['技术'] }]);
|
|
120
|
+
db.close();
|
|
121
|
+
assertEq(r.written, 0, '零写入');
|
|
122
|
+
assertEq(r.details[0].action, 'missing', '如实记 missing');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ── 03 取样与 owner 过滤 ────────────────────────────────────────────────────
|
|
126
|
+
section('03 取样(owner 列有无都支持)');
|
|
127
|
+
test('readCandidates:8 列库带上 owner;6 列库标记 ownerColumn=false', () => {
|
|
128
|
+
const a = makeDb('cand-a', [{ id: 1, text: 'x'.repeat(400), tags: '技术', owner: 'Wife_1' }]);
|
|
129
|
+
const ra = D.readCandidates(a.dbPath, { maxCharsPerItem: 100 });
|
|
130
|
+
assertEq(ra.ownerColumn, true, '检测到 owner_user 列');
|
|
131
|
+
assertEq(ra.items[0].ownerUser, 'Wife_1', '读出 owner');
|
|
132
|
+
assertEq(ra.items[0].textForModel.length, 100, '正文按 maxCharsPerItem 截断');
|
|
133
|
+
assertEq(ra.items[0].textTruncated, true, '标记被截断');
|
|
134
|
+
assertEq(ra.items[0].existingDomains, ['技术'], '识别已有域标签');
|
|
135
|
+
const b = makeDb('cand-b', [{ id: 1, text: 'y', tags: '' }], { withOwner: false });
|
|
136
|
+
const rb = D.readCandidates(b.dbPath);
|
|
137
|
+
assertEq(rb.ownerColumn, false, '6 列库如实标记');
|
|
138
|
+
assertEq(rb.items[0].ownerUser, null, '无 owner 列 ⇒ null(由调用方按本机 owner 处理)');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ── 04 成本闸 ───────────────────────────────────────────────────────────────
|
|
142
|
+
section('04 成本闸(≤20 次/日、≤150K token/日,超限即停)');
|
|
143
|
+
test('costGate:次数用尽 ⇒ 拒绝', () => {
|
|
144
|
+
const st = D.defaultState('x', '2026-09-21T00:00:00Z');
|
|
145
|
+
st.cost.calls = 20;
|
|
146
|
+
const g = D.costGate(st, 1000);
|
|
147
|
+
assertEq(g.ok, false, '拒绝');
|
|
148
|
+
assert(/上限 20 次/.test(g.reason), '原因点名次数上限');
|
|
149
|
+
});
|
|
150
|
+
test('costGate:token 用尽 ⇒ 拒绝', () => {
|
|
151
|
+
const st = D.defaultState('x', '2026-09-21T00:00:00Z');
|
|
152
|
+
st.cost.promptTokens = 140000;
|
|
153
|
+
st.cost.completionTokens = 9000;
|
|
154
|
+
const g = D.costGate(st, 2000);
|
|
155
|
+
assertEq(g.ok, false, '拒绝');
|
|
156
|
+
assert(/上限|超过/.test(g.reason), `原因值得:${g.reason}`);
|
|
157
|
+
});
|
|
158
|
+
test('costGate:余量够 ⇒ 放行', () => {
|
|
159
|
+
const st = D.defaultState('x', '2026-09-21T00:00:00Z');
|
|
160
|
+
st.cost.calls = 19;
|
|
161
|
+
st.cost.promptTokens = 1000;
|
|
162
|
+
assertEq(D.costGate(st, 5000).ok, true, '放行');
|
|
163
|
+
});
|
|
164
|
+
test('readState:跨日 ⇒ 当日成本归零(每日口径)', () => {
|
|
165
|
+
const f = path.join(TMP, 'state-crossday.json');
|
|
166
|
+
fs.writeFileSync(f, JSON.stringify({ ...D.defaultState('x', '2026-09-20T00:00:00Z'), cost: { date: '2026-09-20', calls: 20, promptTokens: 99999, completionTokens: 1 } }), 'utf8');
|
|
167
|
+
const st = D.readState(f, 'x', '2026-09-21T10:00:00.000Z');
|
|
168
|
+
assertEq(st.cost.date, '2026-09-21', '日期切到当天');
|
|
169
|
+
assertEq(st.cost.calls, 0, '调用数归零');
|
|
170
|
+
assertEq(st.cost.promptTokens, 0, 'prompt token 归零');
|
|
171
|
+
});
|
|
172
|
+
test('readState/writeState:落盘可读、坏文件回落默认', () => {
|
|
173
|
+
const f = path.join(TMP, 'state-rw.json');
|
|
174
|
+
const st = D.defaultState('x', '2026-09-21T00:00:00Z');
|
|
175
|
+
st.processed['7'] = D.tagSetHash(['技术']);
|
|
176
|
+
D.writeState(f, st);
|
|
177
|
+
const back = D.readState(f, 'x', '2026-09-21T01:00:00Z');
|
|
178
|
+
assertEq(back.processed['7'], D.tagSetHash(['技术']), '台账往返一致');
|
|
179
|
+
fs.writeFileSync(f, '{ 坏 json', 'utf8');
|
|
180
|
+
assertEq(D.readState(f, 'x', '2026-09-21T01:00:00Z').processed, {}, '坏文件回落默认(不抛)');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ── 05 并发保护 ─────────────────────────────────────────────────────────────
|
|
184
|
+
section('05 并发保护(busy_timeout + 重试 3 次 / 退避上限 60s)');
|
|
185
|
+
test('withBusyRetry:busy 两次后成功 ⇒ attempts=3', async () => {
|
|
186
|
+
let n = 0;
|
|
187
|
+
const db = { exec: (sql) => { if (sql === 'BEGIN IMMEDIATE') { n += 1; if (n < 3) { const e = new Error('database is locked'); throw e; } } } };
|
|
188
|
+
const r = await D.withBusyRetry(db, () => 'done', { sleep: async () => {} });
|
|
189
|
+
assert(r.ok, '最终成功');
|
|
190
|
+
assertEq(r.attempts, 3, '重试到第 3 次');
|
|
191
|
+
assertEq(r.value, 'done', '返回值透传');
|
|
192
|
+
});
|
|
193
|
+
test('withBusyRetry:持续 busy ⇒ 停止重试并给原因(不无限等)', async () => {
|
|
194
|
+
const db = { exec: () => { throw new Error('database is locked'); } };
|
|
195
|
+
const r = await D.withBusyRetry(db, () => 'x', { sleep: async () => {} });
|
|
196
|
+
assertEq(r.ok, false, '失败');
|
|
197
|
+
assertEq(r.attempts, 3, '最多 3 次');
|
|
198
|
+
assert(/locked/.test(r.error), '错误里带原始信息');
|
|
199
|
+
});
|
|
200
|
+
test('withBusyRetry:非 busy 错误 ⇒ 立即抛(不重试)', async () => {
|
|
201
|
+
let calls = 0;
|
|
202
|
+
const db = { exec: (sql) => { if (sql === 'BEGIN IMMEDIATE') { calls += 1; throw new Error('no such table: memories'); } } };
|
|
203
|
+
const r = await D.withBusyRetry(db, () => 'x', { sleep: async () => {} });
|
|
204
|
+
assertEq(r.ok, false, '失败');
|
|
205
|
+
assertEq(calls, 1, '只试了 1 次');
|
|
206
|
+
});
|
|
207
|
+
test('withBusyRetry:退避将超 60s 上限 ⇒ 不等待,直接停', async () => {
|
|
208
|
+
const slept = [];
|
|
209
|
+
const db = { exec: (sql) => { if (sql === 'BEGIN IMMEDIATE') throw new Error('database is locked'); } };
|
|
210
|
+
const r = await D.withBusyRetry(db, () => 'x', { sleep: async (ms) => { slept.push(ms); } });
|
|
211
|
+
assertEq(r.ok, false, '失败');
|
|
212
|
+
assert(slept.length <= 3, `退避次数受控(实测 ${slept.length})`);
|
|
213
|
+
assert(slept.reduce((a, b) => a + b, 0) <= D.RETRY.totalCapMs, '累计退避不超上限');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// ── 06 模型输出解析 ─────────────────────────────────────────────────────────
|
|
217
|
+
section('06 模型输出解析(容忍围栏/废话/坏 JSON)');
|
|
218
|
+
test('extractJson:裸 JSON / 代码围栏 / 前后废话都能抠出来', () => {
|
|
219
|
+
const want = { items: [{ id: 1, domains: ['技术'] }] };
|
|
220
|
+
assertEq(D.extractJson(JSON.stringify(want)), want, '裸 JSON');
|
|
221
|
+
assertEq(D.extractJson(`\`\`\`json\n${JSON.stringify(want)}\n\`\`\``), want, '代码围栏');
|
|
222
|
+
assertEq(D.extractJson(`好的,结果如下:\n${JSON.stringify(want)}\n以上。`), want, '前后废话');
|
|
223
|
+
assertEq(D.extractJson('不是 JSON'), null, '无 JSON ⇒ null');
|
|
224
|
+
assertEq(D.extractJson('{ 坏'), null, '坏 JSON ⇒ null');
|
|
225
|
+
});
|
|
226
|
+
test('callClassifier:注入 fetch ⇒ 只从固定集合认标签、模型漏答不误写', async () => {
|
|
227
|
+
const fetchImpl = async () => ({
|
|
228
|
+
ok: true,
|
|
229
|
+
text: async () => JSON.stringify({
|
|
230
|
+
choices: [{ message: { content: JSON.stringify({ items: [{ id: 1, domains: ['技术', '自创词'] }, { id: 999, domains: ['工作'] }] }) } }],
|
|
231
|
+
usage: { prompt_tokens: 100, completion_tokens: 20 },
|
|
232
|
+
}),
|
|
233
|
+
});
|
|
234
|
+
const items = [{ id: 1, textForModel: 'a' }, { id: 2, textForModel: 'b' }];
|
|
235
|
+
const r = await D.callClassifier({ items, apiKey: 'k', fetchImpl });
|
|
236
|
+
assert(r.ok, '调用成功');
|
|
237
|
+
assertEq(r.items.length, 2, '返回 2 条(含越界 id)');
|
|
238
|
+
assertEq(r.usage.prompt_tokens, 100, 'usage 透传');
|
|
239
|
+
});
|
|
240
|
+
test('呼叫体:thinking disabled + 无 developer role(DeepSeek 口径)', async () => {
|
|
241
|
+
let body = null;
|
|
242
|
+
const fetchImpl = async (_url, init) => {
|
|
243
|
+
body = JSON.parse(init.body);
|
|
244
|
+
return { ok: true, text: async () => JSON.stringify({ choices: [{ message: { content: '{"items":[]}' } }], usage: {} }) };
|
|
245
|
+
};
|
|
246
|
+
await D.callClassifier({ items: [{ id: 1, textForModel: 'a' }], apiKey: 'k', fetchImpl });
|
|
247
|
+
assertEq(body.thinking, { type: 'disabled' }, 'thinking disabled');
|
|
248
|
+
assertEq(body.messages.map((m) => m.role), ['system', 'user'], '只用 system/user(不用 developer)');
|
|
249
|
+
assertEq(body.stream, false, '非流式');
|
|
250
|
+
assertEq(body.model, D.DEFAULT_LLM.model, '模型默认档');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ── 07 快照 ─────────────────────────────────────────────────────────────────
|
|
254
|
+
section('07 快照(VACUUM INTO)');
|
|
255
|
+
test('vacuumInto:快照可读且条数一致', () => {
|
|
256
|
+
const { dbPath } = makeDb('snap', [{ id: 1, text: 'a' }, { id: 2, text: 'b' }]);
|
|
257
|
+
const dest = path.join(TMP, 'snap', 'memory.db');
|
|
258
|
+
const file = D.vacuumInto(dbPath, dest);
|
|
259
|
+
const db = new DatabaseSync(file, { readOnly: true });
|
|
260
|
+
const n = db.prepare('SELECT COUNT(*) n FROM memories').get().n;
|
|
261
|
+
const cols = db.prepare('PRAGMA table_info(memories)').all().map((r) => r.cols ?? r.name);
|
|
262
|
+
db.close();
|
|
263
|
+
assertEq(n, 2, '条数一致');
|
|
264
|
+
assertEq(cols.length, 8, '列数一致(含两列)');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ── 08 日报行 ───────────────────────────────────────────────────────────────
|
|
268
|
+
section('08 日报一行(幂等段替换)');
|
|
269
|
+
test('updateDailySection:首次追加段、再次替换同一段(不重复堆叠)', () => {
|
|
270
|
+
const f = path.join(TMP, 'daily.md');
|
|
271
|
+
fs.writeFileSync(f, '# 日报\n\n## 别的段\n> 内容\n', 'utf8');
|
|
272
|
+
D.updateDailySection({ file: f, line: 'A 行' });
|
|
273
|
+
const t1 = fs.readFileSync(f, 'utf8');
|
|
274
|
+
assert(t1.includes(D.DAILY_SECTION_TITLE) && t1.includes('A 行'), '段已追加');
|
|
275
|
+
D.updateDailySection({ file: f, line: 'B 行' });
|
|
276
|
+
const t2 = fs.readFileSync(f, 'utf8');
|
|
277
|
+
assertEq(t2.split(D.DAILY_SECTION_TITLE).length - 1, 1, '段只有一个(替换而非堆叠)');
|
|
278
|
+
assert(t2.includes('B 行') && !t2.includes('A 行'), '内容被替换');
|
|
279
|
+
assert(t2.includes('## 别的段'), '别的段未被破坏');
|
|
280
|
+
});
|
|
281
|
+
test('updateDailySection:日报不存在 ⇒ 不新建(同 doc-index 口径)', () => {
|
|
282
|
+
const f = path.join(TMP, 'no-such-daily.md');
|
|
283
|
+
const r = D.updateDailySection({ file: f, line: 'x' });
|
|
284
|
+
assertEq(r.ok, false, '如实报不 ok');
|
|
285
|
+
assertEq(fs.existsSync(f), false, '没有新建文件');
|
|
286
|
+
});
|
|
287
|
+
test('renderDailyLine:含成本与停机原因', () => {
|
|
288
|
+
const line = D.renderDailyLine({
|
|
289
|
+
mode: 'apply',
|
|
290
|
+
stats: { batches: 3, tagged: 60, noTag: 5, skippedOwner: 2 },
|
|
291
|
+
cost: { calls: 3, promptTokens: 1000, completionTokens: 200 },
|
|
292
|
+
state: { processedCount: 67 },
|
|
293
|
+
stoppedReason: '当日调用数已达上限 20 次',
|
|
294
|
+
});
|
|
295
|
+
for (const kw of ['批次 3', '新增标签条数 60', '当日调用 3/20', '当日 token 1200/150000', '停机原因']) {
|
|
296
|
+
assert(line.includes(kw), `日报行含「${kw}」:${line}`);
|
|
297
|
+
}
|
|
298
|
+
});
|