@kira905/dsh-memory-toolkit 0.1.2 → 0.2.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 +130 -1
- package/README.en.md +225 -2
- package/README.md +208 -2
- package/cordis.patch.yml +32 -0
- package/lib/deploy-guard.mjs +463 -275
- package/lib/guard-http.mjs +59 -62
- package/lib/pitfall-form-map.mjs +94 -84
- package/lib/pitfall-score.mjs +262 -185
- package/lib/session-file-name.mjs +116 -100
- package/lib/zstd-frames.mjs +88 -67
- package/memory-db-tools/README.md +29 -1
- package/memory-db-tools/audit-analyze.mjs +1 -1
- package/memory-db-tools/audit-export.mjs +60 -16
- package/memory-db-tools/audit-struct-probe.mjs +29 -8
- package/memory-db-tools/lib/known-devices.mjs +137 -0
- package/memory-engine/README.md +58 -4
- package/memory-engine/bench/bench.mjs +1 -1
- package/memory-engine/cli/band-sentinel.mjs +164 -0
- package/memory-engine/cli/distill-rollback.mjs +112 -44
- package/memory-engine/cli/distill-sessions.mjs +237 -0
- package/memory-engine/cli/distill.mjs +196 -95
- package/memory-engine/cli/doc-index-rebuild.mjs +142 -108
- package/memory-engine/cli/doc-index.mjs +47 -38
- package/memory-engine/cli/domain-tags.mjs +246 -162
- package/memory-engine/cli/layer-migrate.mjs +176 -0
- package/memory-engine/cli/layer-move.mjs +189 -0
- package/memory-engine/cli/pitfall-hook.mjs +136 -70
- package/memory-engine/cli/recall.mjs +243 -110
- package/memory-engine/cli/selfcheck.mjs +93 -44
- package/memory-engine/cli/serve-start.mjs +221 -140
- package/memory-engine/cli/sweep.mjs +83 -44
- package/memory-engine/cli/sync.mjs +93 -57
- package/memory-engine/config/audit-devices.json +6 -0
- package/memory-engine/config/bands.json +7 -0
- package/memory-engine/config/layers.json +7 -0
- package/memory-engine/dict/LICENSE-/350/257/215/345/205/270.md +53 -0
- package/memory-engine/dict/lexicon-config.json +11 -0
- package/memory-engine/dict//350/257/215/345/205/270.txt +109750 -0
- package/memory-engine/dict//350/257/215/351/242/221.txt +20000 -0
- package/memory-engine/lib/band-sentinel.mjs +417 -0
- package/memory-engine/lib/cli-strict.mjs +128 -0
- package/memory-engine/lib/cli-util.mjs +147 -125
- package/memory-engine/lib/config.mjs +83 -96
- package/memory-engine/lib/dirty-ledger.mjs +127 -94
- package/memory-engine/lib/distill-rollback.mjs +173 -133
- package/memory-engine/lib/distill.mjs +878 -585
- package/memory-engine/lib/doc-index-store.mjs +208 -235
- package/memory-engine/lib/doc-index.mjs +351 -350
- package/memory-engine/lib/doc-scan.mjs +178 -156
- package/memory-engine/lib/doc-summarize.mjs +220 -205
- package/memory-engine/lib/domain-tags.mjs +358 -277
- package/memory-engine/lib/embedder.mjs +175 -194
- package/memory-engine/lib/hashes.mjs +22 -7
- package/memory-engine/lib/layer-migrate.mjs +330 -0
- package/memory-engine/lib/layer-move.mjs +307 -0
- package/memory-engine/lib/layers.mjs +191 -0
- package/memory-engine/lib/lexicon-recall.mjs +473 -0
- package/memory-engine/lib/memory-source.mjs +78 -80
- package/memory-engine/lib/pitfall-source.mjs +115 -114
- package/memory-engine/lib/policy-source.mjs +279 -261
- package/memory-engine/lib/recall-log.mjs +285 -0
- package/memory-engine/lib/recall.mjs +565 -240
- package/memory-engine/lib/selfcheck.mjs +142 -106
- package/memory-engine/lib/session-distill.mjs +603 -0
- package/memory-engine/lib/session-source.mjs +421 -0
- package/memory-engine/lib/sweep.mjs +107 -130
- package/memory-engine/lib/sync.mjs +110 -140
- package/memory-engine/lib/tick-hook.mjs +123 -148
- package/memory-engine/lib/vectors-store.mjs +213 -188
- package/memory-engine/test/12-layering.test.mjs +539 -0
- package/memory-engine/test/13-session-distill.test.mjs +616 -0
- package/memory-engine/tools/bridge-smoke.mjs +94 -55
- package/memory-engine/tools/fetch-model.mjs +167 -87
- package/memory-engine/tools/smoke-recall.mjs +94 -38
- package/memory-engine/tools/verify-recall-chain.mjs +413 -0
- package/package.json +11 -1
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
// 会话日志 → 记忆(K6)单测 —— 零依赖、不联网、夹具一律落系统临时区。
|
|
2
|
+
//
|
|
3
|
+
// 覆盖卡面要的几条:没配预算 ⇒ 零调用零写盘(阴性)· 给预算 ⇒ ≤5 条且带来源锚(阳性)·
|
|
4
|
+
// 超额预算 ⇒ 不跑 · `.zstd` 输入给明确提示 · `--apply` 前有快照 · 撤回整批走既有回滚器。
|
|
5
|
+
// 既有 12 个测试文件一行未改;本文件是新增的第 13 个。
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import zlib from 'node:zlib';
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
12
|
+
import { test, section, assert, assertEq } from './harness.mjs';
|
|
13
|
+
import * as S from '../lib/session-source.mjs';
|
|
14
|
+
import * as D from '../lib/session-distill.mjs';
|
|
15
|
+
import * as R from '../lib/distill-rollback.mjs';
|
|
16
|
+
|
|
17
|
+
export const __section = 'K6 · 会话日志 → 记忆(抽句回指 / 预算闸 / 快照 / 整批撤回)';
|
|
18
|
+
|
|
19
|
+
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'k6-session-distill-'));
|
|
20
|
+
const CLI = path.join(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')), '..', 'cli', 'distill-sessions.mjs');
|
|
21
|
+
const NOW = Date.parse('2026-10-30T23:30:00+08:00');
|
|
22
|
+
let seq = 0;
|
|
23
|
+
const nextDir = (label) => {
|
|
24
|
+
seq += 1;
|
|
25
|
+
const dir = path.join(TMP, `${String(seq).padStart(2, '0')}-${label}`);
|
|
26
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
27
|
+
return dir;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const ev = (o) => JSON.stringify(o);
|
|
31
|
+
|
|
32
|
+
/** 造一份"3 类结论 + 一堆噪音"的日志(每用例独立文件,避免互相污染)。 */
|
|
33
|
+
function writeSession(dir, lines) {
|
|
34
|
+
const file = path.join(dir, 'session.jsonl');
|
|
35
|
+
fs.writeFileSync(file, `${lines.join('\n')}\n`, 'utf8');
|
|
36
|
+
return file;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const baseEvents = (sessionId) => [
|
|
40
|
+
ev({ type: 'session', version: 4, id: sessionId, createdAt: NOW - 60000 }),
|
|
41
|
+
ev({ type: 'message', role: 'user', content: '你好呀,今天在吗?' }),
|
|
42
|
+
ev({ type: 'message', role: 'user', content: '这条口径要不要再确认一下,是不是得先跟主人对一遍?' }),
|
|
43
|
+
ev({ type: 'message', role: 'user', content: '以后提炼产物一律带 distilled 前缀标签,不带前缀的一律不算。' }),
|
|
44
|
+
ev({ type: 'message', role: 'assistant', content: '好的。' }),
|
|
45
|
+
ev({ type: 'message', role: 'user', content: '撤回就按整批走:一次运行一个来源键,撤回器整组删掉。' }),
|
|
46
|
+
ev({ type: 'message', role: 'user', content: '我觉得大概是那样,可能没差。' }),
|
|
47
|
+
ev({ type: 'message', role: 'user', content: '写库前必须先拍快照,没有快照就不许写。' }),
|
|
48
|
+
ev({ type: 'message', role: 'tool', content: 'exit code 0' }),
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** 造夹具库(与生产同构 + owner 两列 + FTS 触发器)。 */
|
|
52
|
+
function makeDb(dir, { rows = [], withOwner = true } = {}) {
|
|
53
|
+
const dbPath = path.join(dir, 'memory', 'memory.db');
|
|
54
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
55
|
+
const db = new DatabaseSync(dbPath);
|
|
56
|
+
db.exec(`
|
|
57
|
+
CREATE TABLE memories (
|
|
58
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, tags TEXT NOT NULL DEFAULT '',
|
|
59
|
+
pinned INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
60
|
+
${withOwner ? ", owner_user TEXT NOT NULL DEFAULT 'local', visibility TEXT NOT NULL DEFAULT 'private_owner'" : ''}
|
|
61
|
+
);
|
|
62
|
+
CREATE VIRTUAL TABLE memories_fts USING fts5(text, tags, content='memories', content_rowid='id');
|
|
63
|
+
CREATE TRIGGER memories_ai AFTER INSERT ON memories BEGIN
|
|
64
|
+
INSERT INTO memories_fts(rowid, text, tags) VALUES (new.id, new.text, new.tags);
|
|
65
|
+
END;
|
|
66
|
+
CREATE TRIGGER memories_ad AFTER DELETE ON memories BEGIN
|
|
67
|
+
INSERT INTO memories_fts(memories_fts, rowid, text, tags) VALUES ('delete', old.id, old.text, old.tags);
|
|
68
|
+
END;
|
|
69
|
+
`);
|
|
70
|
+
const ins = withOwner
|
|
71
|
+
? db.prepare('INSERT INTO memories (id, text, tags, pinned, created_at, updated_at, owner_user, visibility) VALUES (?,?,?,0,?,?,?,?)')
|
|
72
|
+
: db.prepare('INSERT INTO memories (id, text, tags, pinned, created_at, updated_at) VALUES (?,?,?,0,?,?)');
|
|
73
|
+
for (const row of rows) {
|
|
74
|
+
if (withOwner) ins.run(row.id ?? null, row.text, row.tags ?? '', NOW, NOW, 'local', 'private_owner');
|
|
75
|
+
else ins.run(row.id ?? null, row.text, row.tags ?? '', NOW, NOW);
|
|
76
|
+
}
|
|
77
|
+
db.close();
|
|
78
|
+
return dbPath;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const allRows = (dbPath) => {
|
|
82
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
83
|
+
try { return db.prepare('SELECT id, text, tags, owner_user FROM memories ORDER BY id').all(); } finally { db.close(); }
|
|
84
|
+
};
|
|
85
|
+
const distilledRows = (dbPath) => allRows(dbPath).filter((row) => String(row.tags).startsWith('distilled'));
|
|
86
|
+
const countRows = (dbPath) => allRows(dbPath).length;
|
|
87
|
+
const sha = (file) => (fs.existsSync(file) ? (() => {
|
|
88
|
+
const buf = fs.readFileSync(file);
|
|
89
|
+
return `${buf.length}:${buf.subarray(0, 16).toString('hex')}`;
|
|
90
|
+
})() : null);
|
|
91
|
+
|
|
92
|
+
/** 假模型:按候选编号合成 ≤limit 条结论;`calls` 记账,验证"零调用"就靠它。 */
|
|
93
|
+
function makeFakeFetch({ failOnce = false, empty = false, badRefs = false, usage = { prompt_tokens: 300, completion_tokens: 90 } } = {}) {
|
|
94
|
+
let calls = 0;
|
|
95
|
+
const seen = [];
|
|
96
|
+
const fn = async (_url, init) => {
|
|
97
|
+
calls += 1;
|
|
98
|
+
const body = JSON.parse(init.body);
|
|
99
|
+
const user = body.messages.find((m) => m.role === 'user').content;
|
|
100
|
+
seen.push({ model: body.model, user, thinking: body.thinking, maxTokens: body.max_tokens });
|
|
101
|
+
if (failOnce) return { ok: false, status: 500, text: async () => 'boom' };
|
|
102
|
+
const refIds = [...user.matchAll(/^\[(\d+)\]/gm)].map((m) => Number(m[1]));
|
|
103
|
+
const items = empty ? [] : refIds.slice(0, 5).map((id, index) => ({
|
|
104
|
+
refs: [badRefs ? 999 : id],
|
|
105
|
+
conclusion: `合成结论 ${index + 1}:这条由候选 ${id} 支撑,用于单测断言,不超过一百二十字。`,
|
|
106
|
+
}));
|
|
107
|
+
return {
|
|
108
|
+
ok: true, status: 200,
|
|
109
|
+
text: async () => JSON.stringify({ choices: [{ message: { content: JSON.stringify({ items }) } }], usage }),
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
fn.calls = () => calls;
|
|
113
|
+
fn.seen = seen;
|
|
114
|
+
return fn;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const commonRun = (dir, overrides = {}) => {
|
|
118
|
+
const file = writeSession(dir, baseEvents(`session-k6-${seq}`));
|
|
119
|
+
const dbPath = makeDb(path.join(dir, 'dbdir'), { rows: [{ id: 1, text: '真实记忆:重启 DSH 走 dsh-restart.ps1。', tags: 'dsh' }] });
|
|
120
|
+
return {
|
|
121
|
+
source: file, dbPath,
|
|
122
|
+
stateFile: path.join(dir, 'state.json'),
|
|
123
|
+
snapshotDir: path.join(dir, 'snapshots'),
|
|
124
|
+
now: NOW, ...overrides,
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// ── 抽句:只抽能回指的结论条 ────────────────────────────────────────────────
|
|
129
|
+
section('抽句:抽什么 / 不抽什么(全部确定性规则)');
|
|
130
|
+
test('抽到的是决定/口径/修复句,寒暄与疑问被挡在门外', () => {
|
|
131
|
+
const dir = nextDir('extract');
|
|
132
|
+
const file = writeSession(dir, baseEvents('session-k6-x1'));
|
|
133
|
+
const out = S.extractCandidates({ file });
|
|
134
|
+
assertEq(out.sessionId, 'session-k6-x1', '来源 id 从 type=session 事件里取');
|
|
135
|
+
assertEq(out.candidates.length, 3, '三条结论句');
|
|
136
|
+
const kinds = out.candidates.map((c) => c.kind);
|
|
137
|
+
assert(kinds.includes('rule') || kinds.includes('decision'), '至少命中规则/决定类');
|
|
138
|
+
assert(out.skipped.question >= 1, '疑问句要计数挡下');
|
|
139
|
+
assert(out.skipped['tool-event'] >= 1, '工具事件要计数挡下');
|
|
140
|
+
assert(out.candidates.every((c) => !/^(你好|好的)/.test(c.text)), '寒暄不进候选');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('每条候选都能逐字回指:行号对得上,anchor 是原文前缀', () => {
|
|
144
|
+
const dir = nextDir('anchor');
|
|
145
|
+
const file = writeSession(dir, baseEvents('session-k6-x2'));
|
|
146
|
+
const out = S.extractCandidates({ file });
|
|
147
|
+
const rawLines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
|
|
148
|
+
for (const candidate of out.candidates) {
|
|
149
|
+
const ref = candidate.sourceRef;
|
|
150
|
+
assert(ref.sessionId === 'session-k6-x2', '带场次 id');
|
|
151
|
+
assert(Number.isInteger(ref.line) && ref.line >= 1, '带行号');
|
|
152
|
+
assert(Number.isInteger(ref.offset) && ref.offset >= 0, '带字节偏移');
|
|
153
|
+
assert(ref.anchor.length > 0 && candidate.text.startsWith(ref.anchor), 'anchor 是原句前缀');
|
|
154
|
+
assert(rawLines[ref.line - 1].includes(ref.anchor), `第 ${ref.line} 行里能逐字找到 anchor`);
|
|
155
|
+
assert(rawLines[ref.line - 1].includes(candidate.text), '整句也在那一行里(不是拼出来的)');
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('超过长度上限的整段转述被跳过并计数(不硬塞长文)', () => {
|
|
160
|
+
const dir = nextDir('toolong');
|
|
161
|
+
// 一句到底(只用顿号/逗号连),这样不会被句末标点切短 —— 才是真正的"整段转述"
|
|
162
|
+
const long = `这条口径很长:${'一段用逗号连起来的长句,'.repeat(24)}就这么长。`;
|
|
163
|
+
assert(long.length > 240, `夹具得真超长(${long.length} 字)`);
|
|
164
|
+
const file = writeSession(dir, [ev({ type: 'message', role: 'user', content: long })]);
|
|
165
|
+
const out = S.extractCandidates({ file, defaults: { maxChars: 240 } });
|
|
166
|
+
assertEq(out.candidates.length, 0, '长句不抽');
|
|
167
|
+
assert(out.skipped['too-long'] >= 1, '长句要计数');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('推测句不抽("我觉得/可能" 没有规则或决定词撑着)', () => {
|
|
171
|
+
const verdict = S.judgeSentence('我觉得这个方案可能更好一点,大概吧。');
|
|
172
|
+
assertEq(verdict.ok, false, '推测不抽');
|
|
173
|
+
assertEq(verdict.reason, 'speculation');
|
|
174
|
+
assertEq(S.judgeSentence('这个方案可能不划算,所以一律先做本地估算再调模型。').ok, true, '带"一律"的推测句仍按规则抽');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('流水账不抽(先…然后…接着…最后),哪怕句尾撞上"决定"', () => {
|
|
178
|
+
const verdict = S.judgeSentence('我先打开文件,然后逐行看,接着把每段读了一遍,最后才决定不下结论。');
|
|
179
|
+
assertEq(verdict.ok, false, '过程叙述不抽');
|
|
180
|
+
assertEq(verdict.reason, 'process');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('同一句重复出现只算一条(duplicate 计数)', () => {
|
|
184
|
+
const dir = nextDir('dup');
|
|
185
|
+
const line = ev({ type: 'message', role: 'user', content: '默认一律走本地模型,不走云端。' });
|
|
186
|
+
const file = writeSession(dir, [line, line, line]);
|
|
187
|
+
const out = S.extractCandidates({ file });
|
|
188
|
+
assertEq(out.candidates.length, 1, '只留一条');
|
|
189
|
+
assertEq(out.skipped.duplicate, 2, '另外两条按重复计');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('坏行整行跳过并报出来(不静默当正文)', () => {
|
|
193
|
+
const dir = nextDir('badjson');
|
|
194
|
+
const file = writeSession(dir, ['{ 这不是 JSON', ev({ type: 'message', role: 'user', content: '约定:夹具一律落系统临时区。' })]);
|
|
195
|
+
const out = S.extractCandidates({ file });
|
|
196
|
+
assertEq(out.skipped['bad-json'], 1);
|
|
197
|
+
assertEq(out.candidates.length, 1);
|
|
198
|
+
assert(out.warnings.some((w) => w.includes('不是合法 JSON')), '要有警告');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ── zstd:能零依赖解就解,解不开给人话 ──────────────────────────────────────
|
|
202
|
+
section('.zstd 输入:多帧逐帧解 / 坏了给明确提示');
|
|
203
|
+
test('单帧与多帧 zstd 都解出与原文逐字节一致的文本', () => {
|
|
204
|
+
const dir = nextDir('zstd');
|
|
205
|
+
const file = writeSession(dir, baseEvents('session-k6-z'));
|
|
206
|
+
const plain = fs.readFileSync(file);
|
|
207
|
+
const single = path.join(dir, 'one.jsonl.zstd');
|
|
208
|
+
fs.writeFileSync(single, zlib.zstdCompressSync(plain));
|
|
209
|
+
const multi = path.join(dir, 'multi.jsonl.zstd');
|
|
210
|
+
fs.writeFileSync(multi, Buffer.concat([zlib.zstdCompressSync(plain.subarray(0, 120)), zlib.zstdCompressSync(plain.subarray(120))]));
|
|
211
|
+
|
|
212
|
+
const one = S.readSourceText(single);
|
|
213
|
+
assertEq(one.compressed, true);
|
|
214
|
+
assertEq(one.frames, 1);
|
|
215
|
+
assertEq(one.text, plain.toString('utf8'), '单帧解压与原文一致(不多不少)');
|
|
216
|
+
|
|
217
|
+
const many = S.readSourceText(multi);
|
|
218
|
+
assertEq(many.frames, 2, '两帧都解了(不是只解第一帧)');
|
|
219
|
+
assertEq(many.text, plain.toString('utf8'), '多帧拼起来与原文逐字节一致');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('坏 zstd 抛带人话的错误(提示先解压),不静默返回半截数据', () => {
|
|
223
|
+
const dir = nextDir('zstd-bad');
|
|
224
|
+
const bad = path.join(dir, 'broken.jsonl.zstd');
|
|
225
|
+
fs.writeFileSync(bad, Buffer.from([0x28, 0xb5, 0x2f, 0xfd, 0x00, 0x00, 0x00, 0x00]));
|
|
226
|
+
let caught = null;
|
|
227
|
+
try { S.readSourceText(bad); } catch (failure) { caught = failure; }
|
|
228
|
+
assert(caught, '必须抛错');
|
|
229
|
+
assert(/zstd/.test(caught.message), '错误里点名 zstd');
|
|
230
|
+
assert(caught.message.includes('先解压'), '错误里给"先解压"的人话提示');
|
|
231
|
+
assertEq(caught.code, 'ZSTD_CORRUPT');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('extractCandidates 认压缩输入并在 note 里说清做了什么', () => {
|
|
235
|
+
const dir = nextDir('zstd-note');
|
|
236
|
+
const file = writeSession(dir, baseEvents('session-k6-n'));
|
|
237
|
+
const packed = path.join(dir, 'p.jsonl.zstd');
|
|
238
|
+
fs.writeFileSync(packed, zlib.zstdCompressSync(fs.readFileSync(file)));
|
|
239
|
+
const out = S.extractCandidates({ file: packed });
|
|
240
|
+
assertEq(out.compressed, true);
|
|
241
|
+
assert(out.note && out.note.includes('zstd'), 'note 讲清"这是 zstd 已解开"');
|
|
242
|
+
assertEq(out.candidates.length, 3, '解压后照样抽得出结论句');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ── 预算闸 ──────────────────────────────────────────────────────────────────
|
|
246
|
+
section('预算闸:没配就是没配(绝不回落到任何缺省额度)');
|
|
247
|
+
test('resolveBudget:CLI 优先 → 台账兜底 → 都没有就是 null', () => {
|
|
248
|
+
const state = { cost: { date: '2026-10-30', calls: 0, promptTokens: 0, completionTokens: 0 }, budget: { tokensPerDay: null } };
|
|
249
|
+
assertEq(D.resolveBudget({ flagTokens: null, state, now: NOW }).tokens, null, '两边都没有 ⇒ null');
|
|
250
|
+
assertEq(D.resolveBudget({ flagTokens: 30000, state, now: NOW }).tokens, 30000, 'CLI 给了就用它');
|
|
251
|
+
assertEq(D.resolveBudget({ flagTokens: null, state: { ...state, budget: { tokensPerDay: 12000 } }, now: NOW }).tokens, 12000, '台账兜底');
|
|
252
|
+
assertEq(D.resolveBudget({ flagTokens: 5000, state: { ...state, budget: { tokensPerDay: 12000 } }, now: NOW }).tokens, 5000, 'CLI 覆盖台账');
|
|
253
|
+
assertEq(D.resolveBudget({ flagTokens: 0, state, now: NOW }).tokens, null, '给 0 等于没给(不是"无限")');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('resolveBudget:当日已花 + 跨天清零 + exhausted', () => {
|
|
257
|
+
const spent = { cost: { date: '2026-10-30', calls: 2, promptTokens: 9000, completionTokens: 1000 }, budget: { tokensPerDay: 10000 } };
|
|
258
|
+
const hit = D.resolveBudget({ state: spent, now: NOW });
|
|
259
|
+
assertEq(hit.spent, 10000, '同一天累计');
|
|
260
|
+
assertEq(hit.exhausted, true, '花满即超');
|
|
261
|
+
assertEq(hit.remaining, 0);
|
|
262
|
+
const nextDay = D.resolveBudget({ state: spent, now: NOW + 86400000 });
|
|
263
|
+
assertEq(nextDay.spent, 0, '换天清零(额度按本地日算)');
|
|
264
|
+
assertEq(nextDay.exhausted, false);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('阴性 · 没配预算 ⇒ 一次模型都不调、库与台账都不动', async () => {
|
|
268
|
+
const dir = nextDir('nobudget');
|
|
269
|
+
const opts = commonRun(dir, { apply: true, fetchImpl: makeFakeFetch(), apiKey: 'stub-key' });
|
|
270
|
+
const before = sha(opts.dbPath);
|
|
271
|
+
const result = await D.runSessionDistill(opts);
|
|
272
|
+
assertEq(result.stopping, 'NO_BUDGET');
|
|
273
|
+
assertEq(result.message, D.NO_BUDGET_MESSAGE, '打印的就是那句"这是故意的"');
|
|
274
|
+
assertEq(opts.fetchImpl.calls(), 0, '零调用');
|
|
275
|
+
assertEq(result.written, 0, '零写入');
|
|
276
|
+
assertEq(sha(opts.dbPath), before, '库文件一个字节都没动');
|
|
277
|
+
assertEq(fs.existsSync(opts.stateFile), false, '台账都没建');
|
|
278
|
+
assertEq(result.exitCode, 0, 'rc=0(没配预算不是错误)');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('阴性 · 台账里配了预算也算"配了"(不带 --max-tokens 也照跑)', async () => {
|
|
282
|
+
const dir = nextDir('statebudget');
|
|
283
|
+
const opts = commonRun(dir, { apply: true, fetchImpl: makeFakeFetch(), apiKey: 'stub-key' });
|
|
284
|
+
fs.writeFileSync(opts.stateFile, `${JSON.stringify({ budget: { tokensPerDay: 20000 }, cost: { date: '2026-10-30', calls: 0, promptTokens: 0, completionTokens: 0 } }, null, 2)}\n`, 'utf8');
|
|
285
|
+
const result = await D.runSessionDistill(opts);
|
|
286
|
+
assertEq(result.budget.source, 'state', '预算来自台账');
|
|
287
|
+
assertEq(result.written >= 1, true, '照跑并写回');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('超预算 ⇒ 不跑(打印用量,零调用)', async () => {
|
|
291
|
+
const dir = nextDir('overspent');
|
|
292
|
+
const opts = commonRun(dir, { apply: true, fetchImpl: makeFakeFetch(), apiKey: 'stub-key', maxTokens: 5000 });
|
|
293
|
+
fs.writeFileSync(opts.stateFile, `${JSON.stringify({ budget: { tokensPerDay: 5000 }, cost: { date: '2026-10-30', calls: 3, promptTokens: 4900, completionTokens: 200 } }, null, 2)}\n`, 'utf8');
|
|
294
|
+
const result = await D.runSessionDistill(opts);
|
|
295
|
+
assertEq(result.stopping, 'BUDGET_SPENT');
|
|
296
|
+
assertEq(opts.fetchImpl.calls(), 0, '零调用');
|
|
297
|
+
assert(result.message.includes('5100') && result.message.includes('5000'), '把已用/预算写出来');
|
|
298
|
+
assertEq(result.written, 0);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('估算就会超 ⇒ 也不跑(ESTIMATE_OVER,先算再花)', async () => {
|
|
302
|
+
const dir = nextDir('estimate');
|
|
303
|
+
const opts = commonRun(dir, { apply: true, fetchImpl: makeFakeFetch(), apiKey: 'stub-key', maxTokens: 400 });
|
|
304
|
+
const result = await D.runSessionDistill(opts);
|
|
305
|
+
assertEq(result.stopping, 'ESTIMATE_OVER');
|
|
306
|
+
assertEq(opts.fetchImpl.calls(), 0, '估算超了就不调模型');
|
|
307
|
+
assert(result.tokens.estimated > 400, '估算值照实报出来');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ── 阳性:写回 + 回指 + 快照 ────────────────────────────────────────────────
|
|
311
|
+
section('阳性:给预算 ⇒ ≤5 条 · 带来源锚 · 写前有快照');
|
|
312
|
+
test('给预算跑一轮:≤5 条、每条带 distilled/run=/sess= 标签与合法回指', async () => {
|
|
313
|
+
const dir = nextDir('happy');
|
|
314
|
+
const opts = commonRun(dir, { apply: true, maxTokens: 30000, fetchImpl: makeFakeFetch(), apiKey: 'stub-key' });
|
|
315
|
+
const result = await D.runSessionDistill(opts);
|
|
316
|
+
assertEq(result.stopping, null, '正常跑完(没有 stopping)');
|
|
317
|
+
assertEq(result.calls, 1, '只调一次模型');
|
|
318
|
+
assert(result.written >= 1 && result.written <= 5, `写入条数在 1..5(实际 ${result.written})`);
|
|
319
|
+
|
|
320
|
+
const rows = distilledRows(opts.dbPath);
|
|
321
|
+
assertEq(rows.length, result.written, '库里提炼条数 = 报告的写入数');
|
|
322
|
+
for (const row of rows) {
|
|
323
|
+
assert(row.tags.startsWith('distilled '), '标签以 distilled 开头(撤回器认这个前缀)');
|
|
324
|
+
assert(row.tags.includes(`run=session:${result.runId}`), '带批次标识');
|
|
325
|
+
assert(/src=[0-9a-f]+/.test(row.tags), '带来源键(撤回按它整批走)');
|
|
326
|
+
assert(/sess=session-k6-/.test(row.tags), '带场次');
|
|
327
|
+
assert(row.text.length <= 120, '结论正文 ≤120 字');
|
|
328
|
+
assertEq(row.owner_user, 'local', '有 owner 列时按 local 落库(没给 --owner-user)');
|
|
329
|
+
}
|
|
330
|
+
// 每条计划项都能回指到原文的某一行
|
|
331
|
+
const rawLines = fs.readFileSync(opts.source, 'utf8').split(/\r?\n/);
|
|
332
|
+
for (const item of result.planned) {
|
|
333
|
+
assert(item.anchors.length >= 1, '每条结论至少一个锚');
|
|
334
|
+
for (const anchor of item.anchors) {
|
|
335
|
+
assert(rawLines[anchor.line - 1].includes(anchor.anchor), `L${anchor.line} 能逐字找到锚`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('--limit 2 ⇒ 最多写 2 条(模型给多了也不超)', async () => {
|
|
341
|
+
const dir = nextDir('limit');
|
|
342
|
+
const opts = commonRun(dir, { apply: true, maxTokens: 30000, limit: 2, fetchImpl: makeFakeFetch(), apiKey: 'stub-key' });
|
|
343
|
+
const result = await D.runSessionDistill(opts);
|
|
344
|
+
assert(result.written <= 2, `写入 ${result.written} 条,不超过 limit`);
|
|
345
|
+
assert(result.rejected?.some((entry) => entry.why === 'over-limit'), '多出来的要按 over-limit 剔掉');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('写库前必拍快照,且快照是能打开的完整库(行数 = 写前)', async () => {
|
|
349
|
+
const dir = nextDir('snapshot');
|
|
350
|
+
const opts = commonRun(dir, { apply: true, maxTokens: 30000, fetchImpl: makeFakeFetch(), apiKey: 'stub-key' });
|
|
351
|
+
const before = countRows(opts.dbPath);
|
|
352
|
+
const result = await D.runSessionDistill(opts);
|
|
353
|
+
assert(result.snapshot, '报告里要有快照路径');
|
|
354
|
+
assertEq(fs.existsSync(result.snapshot), true, '快照文件存在');
|
|
355
|
+
assertEq(countRows(result.snapshot), before, '快照是写前的库(行数对得上)');
|
|
356
|
+
assert(result.snapshot.includes(result.runId), '快照文件名带批次(能对上是哪一轮)');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('快照拍不成 ⇒ 按规矩不写库(fail-closed)', async () => {
|
|
360
|
+
const dir = nextDir('snapshot-fail');
|
|
361
|
+
const opts = commonRun(dir, { apply: true, maxTokens: 30000, fetchImpl: makeFakeFetch(), apiKey: 'stub-key' });
|
|
362
|
+
// 让 snapshotDir 指向一个目录不可建的位置(用文件占位)
|
|
363
|
+
const blocked = path.join(dir, 'blocked');
|
|
364
|
+
fs.writeFileSync(blocked, 'not a dir');
|
|
365
|
+
opts.snapshotDir = path.join(blocked, 'snapshots');
|
|
366
|
+
const before = countRows(opts.dbPath);
|
|
367
|
+
const result = await D.runSessionDistill(opts);
|
|
368
|
+
assertEq(result.stopping, 'SNAPSHOT_FAILED');
|
|
369
|
+
assertEq(result.written, 0, '没快照就不写');
|
|
370
|
+
assertEq(countRows(opts.dbPath), before, '库没变');
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test('模型失败:不写库,但把花掉的 token 记进台账(不许装没发生)', async () => {
|
|
374
|
+
const dir = nextDir('modelfail');
|
|
375
|
+
const opts = commonRun(dir, { apply: true, maxTokens: 30000, fetchImpl: makeFakeFetch({ failOnce: true }), apiKey: 'stub-key' });
|
|
376
|
+
const result = await D.runSessionDistill(opts);
|
|
377
|
+
assertEq(result.stopping, 'MODEL_FAILED');
|
|
378
|
+
assertEq(result.written, 0);
|
|
379
|
+
assertEq(distilledRows(opts.dbPath).length, 0, '库里没有提炼条');
|
|
380
|
+
const state = JSON.parse(fs.readFileSync(opts.stateFile, 'utf8'));
|
|
381
|
+
assert(state.cost.promptTokens > 0 || state.cost.completionTokens > 0, '失败也记账');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('模型给不出可用条目(空 items)⇒ 不写库', async () => {
|
|
385
|
+
const dir = nextDir('empty');
|
|
386
|
+
const opts = commonRun(dir, { apply: true, maxTokens: 30000, fetchImpl: makeFakeFetch({ empty: true }), apiKey: 'stub-key' });
|
|
387
|
+
const result = await D.runSessionDistill(opts);
|
|
388
|
+
assertEq(result.stopping, 'NOTHING_KEPT');
|
|
389
|
+
assertEq(result.written, 0);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test('模型编出越界 refs ⇒ 一条都不写(回指不成立就不留)', async () => {
|
|
393
|
+
const dir = nextDir('badrefs');
|
|
394
|
+
const opts = commonRun(dir, { apply: true, maxTokens: 30000, fetchImpl: makeFakeFetch({ badRefs: true }), apiKey: 'stub-key' });
|
|
395
|
+
const result = await D.runSessionDistill(opts);
|
|
396
|
+
assertEq(result.stopping, 'NOTHING_KEPT');
|
|
397
|
+
assert(result.rejected?.every((entry) => entry.why === 'no-valid-ref'), '理由要写清是 refs 不成立');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// ── 幂等与台账 ──────────────────────────────────────────────────────────────
|
|
401
|
+
section('幂等:同一批不重复写 / 同一句不重复花 token');
|
|
402
|
+
test('同 runId 再 apply ⇒ 0 写(按正文去重跳过)', async () => {
|
|
403
|
+
const dir = nextDir('idempotent');
|
|
404
|
+
const opts = commonRun(dir, { apply: true, maxTokens: 30000, fetchImpl: makeFakeFetch(), apiKey: 'stub-key', runId: 'fixed-run' });
|
|
405
|
+
const first = await D.runSessionDistill(opts);
|
|
406
|
+
const rowsAfterFirst = countRows(opts.dbPath);
|
|
407
|
+
// 清掉台账(模拟"台账丢了"),让第二次真的走到模型,靠库里的正文去重兜住
|
|
408
|
+
fs.rmSync(opts.stateFile, { force: true });
|
|
409
|
+
const second = await D.runSessionDistill({ ...opts, fetchImpl: makeFakeFetch(), apiKey: 'stub-key' });
|
|
410
|
+
assertEq(second.written, 0, '第二次一条都不写');
|
|
411
|
+
assert(second.skippedExisting >= 1, '重复正文按"已存在"跳过');
|
|
412
|
+
assertEq(countRows(opts.dbPath), rowsAfterFirst, '库行数没变');
|
|
413
|
+
assertEq(first.written >= 1, true);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test('台账记过来源句 ⇒ 换个 runId 也不重复调模型(ALL_PROCESSED)', async () => {
|
|
417
|
+
const dir = nextDir('processed');
|
|
418
|
+
const opts = commonRun(dir, { apply: true, maxTokens: 30000, fetchImpl: makeFakeFetch(), apiKey: 'stub-key' });
|
|
419
|
+
await D.runSessionDistill(opts);
|
|
420
|
+
const secondFetch = makeFakeFetch();
|
|
421
|
+
const second = await D.runSessionDistill({ ...opts, runId: 'another-run', fetchImpl: secondFetch, apiKey: 'stub-key' });
|
|
422
|
+
assertEq(second.stopping, 'ALL_PROCESSED');
|
|
423
|
+
assertEq(secondFetch.calls(), 0, '一句都不再送模型');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test('没抽到候选 ⇒ 不调模型(NO_CANDIDATES)', async () => {
|
|
427
|
+
const dir = nextDir('nocandidates');
|
|
428
|
+
const file = writeSession(dir, [ev({ type: 'message', role: 'user', content: '你好。' }), ev({ type: 'message', role: 'user', content: '好的。' })]);
|
|
429
|
+
const dbPath = makeDb(path.join(dir, 'dbdir'));
|
|
430
|
+
const fetchImpl = makeFakeFetch();
|
|
431
|
+
const result = await D.runSessionDistill({ source: file, dbPath, stateFile: path.join(dir, 's.json'), apply: true, maxTokens: 30000, fetchImpl, apiKey: 'k', now: NOW });
|
|
432
|
+
assertEq(result.stopping, 'NO_CANDIDATES');
|
|
433
|
+
assertEq(fetchImpl.calls(), 0);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// ── 撤回:整批走既有回滚器 ──────────────────────────────────────────────────
|
|
437
|
+
section('撤回:一批一个键 ⇒ 既有回滚器整组删,真实记忆不误伤');
|
|
438
|
+
test('按批次键撤回:提炼条清零、真实行不动、内容摘要回到原状', async () => {
|
|
439
|
+
const dir = nextDir('rollback');
|
|
440
|
+
const opts = commonRun(dir, { apply: true, maxTokens: 30000, fetchImpl: makeFakeFetch(), apiKey: 'stub-key' });
|
|
441
|
+
const beforeDigest = R.contentDigest(opts.dbPath).digest;
|
|
442
|
+
const result = await D.runSessionDistill(opts);
|
|
443
|
+
assert(result.written >= 1);
|
|
444
|
+
assert(distilledRows(opts.dbPath).length >= 1, '先确认写进去了');
|
|
445
|
+
|
|
446
|
+
const dry = R.rollbackDistilled({ dbPath: opts.dbPath, keys: [result.srcKey], apply: false });
|
|
447
|
+
assertEq(dry.matched, result.written, 'dry-run 按键命中整批');
|
|
448
|
+
assertEq(dry.deleted, 0, 'dry-run 不删');
|
|
449
|
+
|
|
450
|
+
const wiped = R.rollbackDistilled({ dbPath: opts.dbPath, keys: [result.srcKey], apply: true, snapshotDir: path.join(dir, 'snapshots-rollback') });
|
|
451
|
+
assertEq(wiped.deleted, result.written, '整批删掉');
|
|
452
|
+
assertEq(distilledRows(opts.dbPath).length, 0, '库里没有提炼条了');
|
|
453
|
+
assertEq(countRows(opts.dbPath), 1, '只剩那条真实记忆');
|
|
454
|
+
assertEq(R.contentDigest(opts.dbPath).digest, beforeDigest, '内容摘要回到写之前的原状');
|
|
455
|
+
assert(wiped.snapshot && fs.existsSync(wiped.snapshot), '撤回前也拍了快照');
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test('批次键能反查出批次(run= 标签可解析)', async () => {
|
|
459
|
+
const dir = nextDir('runs');
|
|
460
|
+
const opts = commonRun(dir, { apply: true, maxTokens: 30000, fetchImpl: makeFakeFetch(), apiKey: 'stub-key', runId: 'run-readable' });
|
|
461
|
+
const applied = await D.runSessionDistill(opts);
|
|
462
|
+
const runs = D.listSessionRuns(opts.dbPath);
|
|
463
|
+
assertEq(runs.length, 1, '只有一个批次');
|
|
464
|
+
assertEq(runs[0].runId, 'run-readable');
|
|
465
|
+
assertEq(runs[0].rows, applied.written, '行数对得上这一批写了几条');
|
|
466
|
+
assertEq(distilledRows(opts.dbPath).length, applied.written, '库里这条批次的条数一致');
|
|
467
|
+
const meta = D.tagsOfRun(`distilled src=abc srcids=1,2 date=20261030 sess=s1 run=session:run-readable`);
|
|
468
|
+
assertEq(meta.runId, 'run-readable');
|
|
469
|
+
assertEq(meta.sessionId, 's1');
|
|
470
|
+
assertEq(D.tagsOfRun('task-board, 验收'), null, '不是提炼条就返回 null');
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// ── 收口逻辑 ────────────────────────────────────────────────────────────────
|
|
474
|
+
section('收口:越界 refs / 空结论 / 太长 / 重复 / 超 limit 一律剔');
|
|
475
|
+
test('attachSourceRefs 逐条给出剔除理由', () => {
|
|
476
|
+
const candidates = [
|
|
477
|
+
{ kind: 'rule', text: '甲', sourceRef: { sessionId: 's', line: 3, anchor: '甲' } },
|
|
478
|
+
{ kind: 'rule', text: '乙', sourceRef: { sessionId: 's', line: 4, anchor: '乙' } },
|
|
479
|
+
];
|
|
480
|
+
const out = D.attachSourceRefs([
|
|
481
|
+
{ refs: [1], conclusion: '合法结论一条,长度够用。' },
|
|
482
|
+
{ refs: [999], conclusion: '引用越界的结论,长度也够。' },
|
|
483
|
+
{ refs: [1], conclusion: '' },
|
|
484
|
+
{ refs: [2], conclusion: '这条结论长得离谱,'.repeat(20) },
|
|
485
|
+
{ refs: [1], conclusion: '合法结论一条,长度够用。' },
|
|
486
|
+
], candidates, { limit: 5 });
|
|
487
|
+
assertEq(out.items.length, 1, '只留下合法那条');
|
|
488
|
+
const whys = out.rejected.map((entry) => entry.why).sort();
|
|
489
|
+
assertEq(whys, ['duplicate', 'no-valid-ref', 'over-120-chars', 'too-short-or-empty']);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test('estimateSessionTokens 与实测同量级(保守但不出格)', () => {
|
|
493
|
+
const chars = D.buildSessionSystemPrompt().length + D.buildSessionUserPrompt([{ kind: 'rule', text: '一条候选句,长度适中。' }], 5).length;
|
|
494
|
+
const estimated = D.estimateSessionTokens(chars, 5);
|
|
495
|
+
// 实测(deepseek-flash 三轮)prompt ≈ 350 token,估算按 1.4 字/token 再留输出余量 ⇒ 落在 0.5x~4x 字符数之间
|
|
496
|
+
assert(estimated > chars / 2 && estimated < chars * 4, `估算 ${estimated} 与字符数 ${chars} 同量级`);
|
|
497
|
+
assert(D.estimateSessionTokens(chars, 5) > D.estimateSessionTokens(chars, 1), 'limit 越大留的余量越大');
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test('提示词里明确要求"只许用候选、必须报 refs、不要相对时间"', () => {
|
|
501
|
+
const prompt = D.buildSessionSystemPrompt();
|
|
502
|
+
assert(prompt.includes('refs'), '要求报 refs');
|
|
503
|
+
assert(prompt.includes('不许补常识'), '禁止补常识');
|
|
504
|
+
assert(prompt.includes('相对时间'), '禁止相对时间');
|
|
505
|
+
const user = D.buildSessionUserPrompt([{ kind: 'rule', text: '甲句' }, { kind: 'fix', text: '乙句' }], 5);
|
|
506
|
+
assert(user.includes('[1] (rule) 甲句') && user.includes('[2] (fix) 乙句'), '候选带编号与类别');
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// ── CLI 层 ──────────────────────────────────────────────────────────────────
|
|
510
|
+
section('CLI:--help / 未知参数 fail-closed / --json 只落文件 / 无预算 rc=0');
|
|
511
|
+
const runCli = (args, extraEnv = {}) => {
|
|
512
|
+
const home = nextDir('home');
|
|
513
|
+
return spawnSync(process.execPath, [CLI, ...args], {
|
|
514
|
+
cwd: TMP,
|
|
515
|
+
encoding: 'utf8',
|
|
516
|
+
env: { ...process.env, USERPROFILE: home, HOME: home, DSH_HOME: home, ...extraEnv },
|
|
517
|
+
timeout: 60000,
|
|
518
|
+
});
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
test('--help:退出码 0,正文里写清预算规则与撤回入口', () => {
|
|
522
|
+
const out = runCli(['--help']);
|
|
523
|
+
assertEq(out.status, 0, `rc=${out.status} :: ${out.stderr}`);
|
|
524
|
+
assert(out.stdout.includes('没配') && out.stdout.includes('不跑'), 'help 里点明预算规则');
|
|
525
|
+
assert(out.stdout.includes('distill-rollback.mjs'), 'help 里给撤回入口');
|
|
526
|
+
assert(out.stdout.includes('不做摘要式写入'), 'help 里点明不做摘要');
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test('未知参数 / 数值不合法 ⇒ rc=2 且点名是哪几个', () => {
|
|
530
|
+
const unknown = runCli(['--source', 'x.jsonl', '--frobnicate', '1']);
|
|
531
|
+
assertEq(unknown.status, 2);
|
|
532
|
+
assert(unknown.stderr.includes('--frobnicate'), '点出未知开关');
|
|
533
|
+
const bad = runCli(['--source', 'x.jsonl', '--limit', 'abc']);
|
|
534
|
+
assertEq(bad.status, 2);
|
|
535
|
+
assert(bad.stderr.includes('--limit'), '点出数值不合法');
|
|
536
|
+
const rest = runCli(['--source', 'x.jsonl', 'oops']);
|
|
537
|
+
assertEq(rest.status, 2, '多余位置参数也算用法错');
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test('--apply 缺 --db / --json 缺 --out / 两个模式同时给 ⇒ rc=2', () => {
|
|
541
|
+
const noDb = runCli(['--source', 'x.jsonl', '--apply']);
|
|
542
|
+
assertEq(noDb.status, 2);
|
|
543
|
+
assert(noDb.stderr.includes('--db'), '要求 --db');
|
|
544
|
+
const noOut = runCli(['--source', 'x.jsonl', '--json']);
|
|
545
|
+
assertEq(noOut.status, 2);
|
|
546
|
+
assert(noOut.stderr.includes('--out'), '--json 必须配 --out');
|
|
547
|
+
const both = runCli(['--source', 'x.jsonl', '--apply', '--dry-run']);
|
|
548
|
+
assertEq(both.status, 2, '两种模式互斥');
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test('CLI 无预算:rc=0、打印那句"这是故意的"、库与台账都没动', () => {
|
|
552
|
+
const dir = nextDir('cli-nobudget');
|
|
553
|
+
const file = writeSession(dir, baseEvents('session-k6-cli'));
|
|
554
|
+
const dbPath = makeDb(path.join(dir, 'dbdir'), { rows: [{ id: 1, text: '真实记忆一条。', tags: 'dsh' }] });
|
|
555
|
+
const stateFile = path.join(dir, 'state.json');
|
|
556
|
+
const before = sha(dbPath);
|
|
557
|
+
const out = runCli(['--source', file, '--db', dbPath, '--state', stateFile, '--apply', '--quiet']);
|
|
558
|
+
assertEq(out.status, 0, `rc=${out.status} :: ${out.stderr}`);
|
|
559
|
+
assert(out.stdout.includes(D.NO_BUDGET_MESSAGE), '打印的就是那句话');
|
|
560
|
+
assertEq(sha(dbPath), before, '库一个字节没动');
|
|
561
|
+
assertEq(fs.existsSync(stateFile), false, '台账没建');
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test('CLI 有预算 dry-run:列出将写什么,但不写库、不建台账', () => {
|
|
565
|
+
const dir = nextDir('cli-dryrun');
|
|
566
|
+
const file = writeSession(dir, baseEvents('session-k6-cli2'));
|
|
567
|
+
const dbPath = makeDb(path.join(dir, 'dbdir'));
|
|
568
|
+
const stateFile = path.join(dir, 'state.json');
|
|
569
|
+
const before = sha(dbPath);
|
|
570
|
+
const out = runCli(['--source', file, '--db', dbPath, '--state', stateFile, '--max-tokens', '30000']);
|
|
571
|
+
assertEq(out.status, 0, `rc=${out.status} :: ${out.stderr}`);
|
|
572
|
+
assert(out.stdout.includes('dry-run'), '说明自己是干跑');
|
|
573
|
+
assert(out.stdout.includes('将写'), '列出将写什么');
|
|
574
|
+
assert(/L\d+/.test(out.stdout), '带行号(能看出从哪来)');
|
|
575
|
+
assertEq(sha(dbPath), before, '库没动');
|
|
576
|
+
assertEq(fs.existsSync(stateFile), false, 'dry-run 不写台账');
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test('CLI --json:机器可读内容只落文件,不上 stdout', () => {
|
|
580
|
+
const dir = nextDir('cli-json');
|
|
581
|
+
const file = writeSession(dir, baseEvents('session-k6-cli3'));
|
|
582
|
+
const dbPath = makeDb(path.join(dir, 'dbdir'));
|
|
583
|
+
const outFile = path.join(dir, 'report.json');
|
|
584
|
+
const out = runCli(['--source', file, '--db', dbPath, '--max-tokens', '30000', '--json', '--out', outFile]);
|
|
585
|
+
assertEq(out.status, 0, `rc=${out.status} :: ${out.stderr}`);
|
|
586
|
+
assert(fs.existsSync(outFile), '报告落盘了');
|
|
587
|
+
const payload = JSON.parse(fs.readFileSync(outFile, 'utf8'));
|
|
588
|
+
assertEq(payload.stopping, null);
|
|
589
|
+
assert(Array.isArray(payload.candidates) && payload.candidates.length >= 1, '报告里有候选');
|
|
590
|
+
assertEq(out.stdout.includes('"stopping"'), false, 'stdout 上没有 JSON');
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test('CLI 源文件不存在 ⇒ rc=3(环境不可用,不是用法错)', () => {
|
|
594
|
+
const out = runCli(['--source', path.join(TMP, 'nonexistent.jsonl')]);
|
|
595
|
+
assertEq(out.status, 3, `rc=${out.status}`);
|
|
596
|
+
assert(out.stderr.includes('输入文件不存在') || out.stdout.includes('输入文件不存在'), '说清是文件不在');
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test('CLI --run 指定批次 ⇒ 批次 id 可指定、批次键由它唯一决定(撤回要用它)', () => {
|
|
600
|
+
const dir = nextDir('cli-run');
|
|
601
|
+
const file = writeSession(dir, baseEvents('session-k6-cli4'));
|
|
602
|
+
const dbPath = makeDb(path.join(dir, 'dbdir'));
|
|
603
|
+
const out = runCli(['--source', file, '--db', dbPath, '--max-tokens', '30000', '--run', 'cli-run-1']);
|
|
604
|
+
assertEq(out.status, 0, `rc=${out.status} :: ${out.stderr}`);
|
|
605
|
+
assert(out.stdout.includes('cli-run-1'), '打出批次 id');
|
|
606
|
+
// 真跑时打出来的键就是这个函数算出来的(同一个 runId 永远同一个键 ⇒ 撤回对得上)
|
|
607
|
+
const key = D.runKeyOf('cli-run-1');
|
|
608
|
+
assert(/^d[0-9a-f]{16}$/.test(key), `批次键形态正确:${key}`);
|
|
609
|
+
assertEq(D.runKeyOf('cli-run-1'), key, '同一个批次 id 算出来的键稳定');
|
|
610
|
+
assert(D.runKeyOf('cli-run-2') !== key, '不同批次不同键');
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test('夹具收尾:临时根仍在系统临时区(没有写到任何真实路径)', () => {
|
|
614
|
+
assert(TMP.includes(os.tmpdir()), '夹具根在系统临时区');
|
|
615
|
+
assert(!TMP.toLowerCase().includes('dsh-oss'), '没落在仓里');
|
|
616
|
+
});
|