@kira905/dsh-memory-toolkit 0.0.0-stage → 0.1.1
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 +42 -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 +306 -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,568 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FORK-EDIT(2026-09-21): 记忆引擎 v2 · S2b 单测 —— 四类各正例 + 反例
|
|
3
|
+
* ① 注入(渲染/预算/编排) ② 召回(触发/融合/排除 pinned)
|
|
4
|
+
* ③ 去重(surface 可见性 / 回合一次性) ④ 降级(fail-open / 模块门 / 超时)
|
|
5
|
+
*
|
|
6
|
+
* 家规:**测试一律可注入路径参数、默认落临时目录,不得写生产路径**(见 fixtures-s2b.mjs)。
|
|
7
|
+
* 跑法:node --test test/s2b.test.mjs
|
|
8
|
+
*/
|
|
9
|
+
import test from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { renderPointer, estimateTokens, summarize, pointerLine, POINTER_HEADER_TEXT, compactTags, sliceSafe } from '../src/pointer.mjs';
|
|
16
|
+
import { createRecallInjector, lastUserText, visibleInjections, withTimeout, SOURCE_KIND } from '../src/injector.mjs';
|
|
17
|
+
import { shouldTrigger, extractFtsTokens, buildFtsMatch, recall } from '../src/recall.mjs';
|
|
18
|
+
import { createModuleGate } from '../src/module-gate.mjs';
|
|
19
|
+
import { createRecallLogger } from '../src/recall-log.mjs';
|
|
20
|
+
import { makeTempEnv, memoryLogger, stubBridge, fakeAgent, injectionEvent } from './fixtures-s2b.mjs';
|
|
21
|
+
|
|
22
|
+
function recallCfg(env, over = {}) {
|
|
23
|
+
return {
|
|
24
|
+
enabled: true,
|
|
25
|
+
home: env.home,
|
|
26
|
+
memoryDb: env.memoryDb,
|
|
27
|
+
vectorsDb: env.vectorsDb,
|
|
28
|
+
logsDir: env.logsDir,
|
|
29
|
+
bridgeHost: '127.0.0.1',
|
|
30
|
+
bridgePort: 8201,
|
|
31
|
+
bridgePortScanMax: 10,
|
|
32
|
+
timeoutMs: 300,
|
|
33
|
+
encodeTimeoutMs: 250,
|
|
34
|
+
maxItems: 8,
|
|
35
|
+
maxChars: 800,
|
|
36
|
+
maxTokens: 600,
|
|
37
|
+
maxPerSession: 3,
|
|
38
|
+
summaryChars: 80,
|
|
39
|
+
queryMaxChars: 512,
|
|
40
|
+
vectorTopK: 20,
|
|
41
|
+
ftsTopK: 20,
|
|
42
|
+
rrfK: 60,
|
|
43
|
+
resultTopK: 8,
|
|
44
|
+
indexTtlMs: 60000,
|
|
45
|
+
...over,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const SAMPLE = [
|
|
50
|
+
{ text: '三仓同步的实测结论:仓1 dsh-core 用 -Sync 后 -Push,跨机交接走 Gitee 私有三仓', tags: '跨机同步 gitee', updated_at: 1757462400000 },
|
|
51
|
+
{ text: 'task-board 看板卡的 run 前必须绑定 butler-bind-card,否则验收无从判断', tags: 'task-board 看板', updated_at: 1757548800000 },
|
|
52
|
+
{ text: '编码桥只绑 127.0.0.1,端口 8201 被占则顺延,绝不给它改绑非回环', tags: 'memory 桥', updated_at: 1757635200000 },
|
|
53
|
+
{ text: '守护进程日志在临时目录下,另有旧版日志文件(排障时以新版为准)', tags: '守护鱼 排障', updated_at: 1757721600000 },
|
|
54
|
+
{ text: 'pinned 沙盒铁律:不许把 node.exe 或工作区根加进安全软件信任区', tags: '安全 360', pinned: true, updated_at: 1757808000000 },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
// ───────────────────────── ① 注入 ─────────────────────────
|
|
58
|
+
|
|
59
|
+
test('注入/正例:指针渲染满足 条数≤8 · ≤800字 · ≤600token · 每条摘要≤80字', () => {
|
|
60
|
+
const rows = Array.from({ length: 12 }, (_, i) => ({
|
|
61
|
+
id: 100 + i,
|
|
62
|
+
text: `第 ${i} 条记忆:${'内容'.repeat(60)}`,
|
|
63
|
+
tags: 'a b c',
|
|
64
|
+
updated_at: 1757462400000 + i * 1000,
|
|
65
|
+
}));
|
|
66
|
+
const r = renderPointer(rows);
|
|
67
|
+
assert.ok(r.items.length <= 8, `items=${r.items.length}`);
|
|
68
|
+
assert.ok(r.chars <= 800, `chars=${r.chars}`);
|
|
69
|
+
assert.ok(r.estTokens <= 600, `tokens=${r.estTokens}`);
|
|
70
|
+
assert.ok(r.dropped >= 4, `dropped=${r.dropped}`);
|
|
71
|
+
for (const line of r.lines) {
|
|
72
|
+
const summary = summarize(line.slice(line.indexOf(') ') + 2));
|
|
73
|
+
assert.ok(summary.length <= 80, `summary=${summary.length}`);
|
|
74
|
+
}
|
|
75
|
+
assert.equal(r.text.split('\n')[0], POINTER_HEADER_TEXT);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('注入/反例:超长正文与标签被摘要截断,仍不越预算', () => {
|
|
79
|
+
const rows = [
|
|
80
|
+
{ id: 1, text: `超长正文${'字'.repeat(3000)}`, tags: 'x'.repeat(500), updated_at: 1757462400000 },
|
|
81
|
+
{ id: 2, text: '短记忆', tags: 'ok', updated_at: 1757462400001 },
|
|
82
|
+
];
|
|
83
|
+
const r = renderPointer(rows);
|
|
84
|
+
assert.ok(r.chars <= 800, `chars=${r.chars}`);
|
|
85
|
+
assert.ok(r.estTokens <= 600, `tokens=${r.estTokens}`);
|
|
86
|
+
assert.equal(r.items.length, 2);
|
|
87
|
+
for (const line of r.lines) {
|
|
88
|
+
assert.ok(!line.includes('字'.repeat(120)), '正文不得整段进指针行(摘要 ≤80 字)');
|
|
89
|
+
const summary = line.slice(line.indexOf(') ') + 2);
|
|
90
|
+
assert.ok(summary.length <= 80, `summary=${summary.length}`);
|
|
91
|
+
}
|
|
92
|
+
// 条数触顶时仍然只出 8 条
|
|
93
|
+
const many = Array.from({ length: 20 }, (_, i) => ({ id: i + 1, text: '短句', tags: '', updated_at: 1757462400000 }));
|
|
94
|
+
assert.equal(renderPointer(many).items.length, 8);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('注入/回归 2026-10-12:emoji 边界截断不得留孤立代理项(lone surrogate ⇒ 上游 HTTP 400)', () => {
|
|
98
|
+
const LONE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
99
|
+
// 事故复现形态:第 79 个码元正好是 emoji 的高代理项(原实现 slice(0,79) 会切在它后面)
|
|
100
|
+
const text = `${'A'.repeat(78)}🐋尾巴还有很长很长的一段正文`;
|
|
101
|
+
const s = summarize(text, 80);
|
|
102
|
+
assert.ok(!LONE.test(s), `summarize 留了孤立代理项:${JSON.stringify(s.slice(-8))}`);
|
|
103
|
+
const tags = `${'x'.repeat(38)}📦tail 还有很长的标签串`;
|
|
104
|
+
const t = compactTags(tags, 40);
|
|
105
|
+
assert.ok(!LONE.test(t), `compactTags 留了孤立代理项:${JSON.stringify(t.slice(-8))}`);
|
|
106
|
+
// sliceSafe 逐长度穷举:任何截断长度都不得留孤立代理项
|
|
107
|
+
const mixed = 'a🐋b📦c🐜d';
|
|
108
|
+
for (let n = 0; n <= mixed.length + 1; n++) {
|
|
109
|
+
assert.ok(!LONE.test(sliceSafe(mixed, n)), `sliceSafe(${n}) 留了孤立代理项`);
|
|
110
|
+
}
|
|
111
|
+
// 输入本身就带孤儿代理时也要剥掉
|
|
112
|
+
assert.ok(!LONE.test(summarize('坏\ud83d字符', 80)));
|
|
113
|
+
assert.ok(!LONE.test(compactTags('坏\udc0b标签')));
|
|
114
|
+
// 端到端:整行指针文本必须干净(这条坏文本一旦进会话历史就永久 400)
|
|
115
|
+
const r = renderPointer([{ id: 9, text, tags, updated_at: 1757462400000 }]);
|
|
116
|
+
assert.ok(!LONE.test(r.text), '指针文本整体含孤立代理项');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('注入/正例:编排端到端(stub 桥 + 临时库)产出可注入指针', async () => {
|
|
120
|
+
const env = makeTempEnv({ memories: SAMPLE });
|
|
121
|
+
const logger = memoryLogger();
|
|
122
|
+
const inj = createRecallInjector({
|
|
123
|
+
config: recallCfg(env),
|
|
124
|
+
logger,
|
|
125
|
+
moduleGate: createModuleGate({ home: env.home }),
|
|
126
|
+
bridge: stubBridge(),
|
|
127
|
+
deps: { recall: await import('../src/recall.mjs') },
|
|
128
|
+
});
|
|
129
|
+
const out = await inj.decideWithTimeout({
|
|
130
|
+
query: 'task-board 看板卡怎么验收',
|
|
131
|
+
agent: fakeAgent([]),
|
|
132
|
+
turn: 1,
|
|
133
|
+
step: 1,
|
|
134
|
+
sessionId: 's-test',
|
|
135
|
+
});
|
|
136
|
+
assert.equal(out.action, 'inject', JSON.stringify(out.meta));
|
|
137
|
+
assert.ok(out.text.startsWith(POINTER_HEADER_TEXT));
|
|
138
|
+
assert.ok(out.meta.chars <= 800);
|
|
139
|
+
assert.ok(out.meta.estTokens <= 600);
|
|
140
|
+
assert.equal(logger.find('inject').length, 1);
|
|
141
|
+
inj.close();
|
|
142
|
+
env.cleanup();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('注入/反例:recallEnabled=false ⇒ 不注入(plugin-disabled)', async () => {
|
|
146
|
+
const env = makeTempEnv({ memories: SAMPLE });
|
|
147
|
+
const logger = memoryLogger();
|
|
148
|
+
const inj = createRecallInjector({
|
|
149
|
+
config: recallCfg(env, { enabled: false }),
|
|
150
|
+
logger,
|
|
151
|
+
moduleGate: createModuleGate({ home: env.home }),
|
|
152
|
+
bridge: stubBridge(),
|
|
153
|
+
});
|
|
154
|
+
const out = await inj.decideWithTimeout({ query: '随便问点啥', agent: fakeAgent([]), turn: 1, step: 1 });
|
|
155
|
+
assert.equal(out.action, 'skip');
|
|
156
|
+
assert.equal(out.reason, 'plugin-disabled');
|
|
157
|
+
inj.close();
|
|
158
|
+
env.cleanup();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('注入/反例:step≠1 ⇒ 不注入(not-step-1)', async () => {
|
|
162
|
+
const env = makeTempEnv({ memories: SAMPLE });
|
|
163
|
+
const inj = createRecallInjector({
|
|
164
|
+
config: recallCfg(env),
|
|
165
|
+
logger: memoryLogger(),
|
|
166
|
+
bridge: stubBridge(),
|
|
167
|
+
});
|
|
168
|
+
const out = await inj.decide({ query: '第几个 step 了', agent: fakeAgent([]), turn: 1, step: 2 });
|
|
169
|
+
assert.equal(out.reason, 'not-step-1');
|
|
170
|
+
inj.close();
|
|
171
|
+
env.cleanup();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('注入/正例:lastUserText 取本轮最后一条 user 文本(含多段 content)', () => {
|
|
175
|
+
const messages = [
|
|
176
|
+
{ role: 'assistant', content: [{ type: 'text', text: '在的' }] },
|
|
177
|
+
{ role: 'user', content: [{ type: 'text', text: '第一句' }, { type: 'text', text: '重点在这句' }] },
|
|
178
|
+
];
|
|
179
|
+
assert.equal(lastUserText(messages), '第一句\n重点在这句');
|
|
180
|
+
assert.equal(lastUserText([]), '');
|
|
181
|
+
assert.equal(lastUserText([{ role: 'assistant', content: [{ type: 'text', text: 'x' }] }]), '');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ───────────────────────── ② 召回 ─────────────────────────
|
|
185
|
+
|
|
186
|
+
test('召回/正例:短暗号非空即触发(「退下吧」必须触发)', () => {
|
|
187
|
+
assert.deepEqual(shouldTrigger('退下吧'), { trigger: true, reason: 'non-empty' });
|
|
188
|
+
assert.equal(shouldTrigger('整夜干活').trigger, true);
|
|
189
|
+
assert.equal(shouldTrigger('a1').trigger, true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('召回/反例:空 / 纯符号 / 单字不触发', () => {
|
|
193
|
+
assert.deepEqual(shouldTrigger(' '), { trigger: false, reason: 'empty' });
|
|
194
|
+
assert.deepEqual(shouldTrigger('!!!'), { trigger: false, reason: 'symbols-only' });
|
|
195
|
+
assert.deepEqual(shouldTrigger('鱼'), { trigger: false, reason: 'single-char' });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('召回/正例:FTS 补位只取英文/数字 token,中文连续串不参与', () => {
|
|
199
|
+
assert.deepEqual(extractFtsTokens('task-board 3080 怎么看'), ['task-board', '3080']);
|
|
200
|
+
assert.equal(buildFtsMatch(['task-board']), '"task-board"');
|
|
201
|
+
assert.equal(extractFtsTokens('中文连续串没有关键词').length, 0);
|
|
202
|
+
assert.equal(buildFtsMatch([]), null);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('召回/正例:向量路命中 + pinned 被排除(方案 §3.3)', async () => {
|
|
206
|
+
const env = makeTempEnv({ memories: SAMPLE });
|
|
207
|
+
const { openVectorsReader } = await import('../src/vectors-read.mjs');
|
|
208
|
+
const { openMemoryReader } = await import('../src/memory-read.mjs');
|
|
209
|
+
const { buildVectorIndex } = await import('../src/recall.mjs');
|
|
210
|
+
const store = openVectorsReader(env.vectorsDb);
|
|
211
|
+
const source = openMemoryReader(env.memoryDb);
|
|
212
|
+
const index = buildVectorIndex(store.db, { source: 'memory' });
|
|
213
|
+
const embedder = stubBridge();
|
|
214
|
+
const hit = await recall({
|
|
215
|
+
query: '三仓同步的实测结论',
|
|
216
|
+
store: { db: store.db },
|
|
217
|
+
source,
|
|
218
|
+
embedder,
|
|
219
|
+
index,
|
|
220
|
+
resultTopK: 8,
|
|
221
|
+
});
|
|
222
|
+
assert.equal(hit.triggered, true);
|
|
223
|
+
assert.ok(hit.hits.length > 0, 'stub 向量应至少命中自身');
|
|
224
|
+
assert.equal(hit.hits[0].id, 1, `top1=${JSON.stringify(hit.hits[0])}`);
|
|
225
|
+
const pinnedId = SAMPLE.findIndex((m) => m.pinned) + 1;
|
|
226
|
+
assert.ok(!hit.hits.some((h) => h.id === pinnedId), 'pinned 条不得出现在召回结果里');
|
|
227
|
+
assert.equal(hit.stats.pinnedExcluded, 1);
|
|
228
|
+
store.close();
|
|
229
|
+
source.close();
|
|
230
|
+
env.cleanup();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('召回/反例:空查询不触发(不编码、不检索)', async () => {
|
|
234
|
+
const env = makeTempEnv({ memories: SAMPLE });
|
|
235
|
+
const { openVectorsReader } = await import('../src/vectors-read.mjs');
|
|
236
|
+
const { openMemoryReader } = await import('../src/memory-read.mjs');
|
|
237
|
+
const store = openVectorsReader(env.vectorsDb);
|
|
238
|
+
const source = openMemoryReader(env.memoryDb);
|
|
239
|
+
const bridge = stubBridge();
|
|
240
|
+
const r = await recall({ query: ' ', store: { db: store.db }, source, embedder: bridge });
|
|
241
|
+
assert.equal(r.triggered, false);
|
|
242
|
+
assert.equal(bridge.calls.encode, 0, '不触发时不得调用编码桥');
|
|
243
|
+
store.close();
|
|
244
|
+
source.close();
|
|
245
|
+
env.cleanup();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ───────────────────────── ③ 去重 ─────────────────────────
|
|
249
|
+
|
|
250
|
+
test('去重/正例:surface 可见注入数达上限 ⇒ 不再注入', async () => {
|
|
251
|
+
const env = makeTempEnv({ memories: SAMPLE });
|
|
252
|
+
const events = [
|
|
253
|
+
injectionEvent('旧指针 A', [1], 0),
|
|
254
|
+
injectionEvent('旧指针 B', [2], 1),
|
|
255
|
+
injectionEvent('旧指针 C', [3], 2),
|
|
256
|
+
];
|
|
257
|
+
const inj = createRecallInjector({
|
|
258
|
+
config: recallCfg(env, { maxPerSession: 3 }),
|
|
259
|
+
logger: memoryLogger(),
|
|
260
|
+
bridge: stubBridge(),
|
|
261
|
+
});
|
|
262
|
+
const out = await inj.decide({ query: 'task-board 看板', agent: fakeAgent(events), turn: 9, step: 1 });
|
|
263
|
+
assert.equal(out.reason, 'surface-visible-limit');
|
|
264
|
+
assert.equal(out.meta.visible, 3);
|
|
265
|
+
inj.close();
|
|
266
|
+
env.cleanup();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('去重/正例:同一回合第二次调用 ⇒ turn-already-injected(V4 口径)', async () => {
|
|
270
|
+
const env = makeTempEnv({ memories: SAMPLE });
|
|
271
|
+
const inj = createRecallInjector({ config: recallCfg(env), logger: memoryLogger(), bridge: stubBridge() });
|
|
272
|
+
const agent = fakeAgent([]);
|
|
273
|
+
const first = await inj.decide({ query: 'task-board 看板卡怎么验收', agent, turn: 7, step: 1 });
|
|
274
|
+
assert.equal(first.action, 'inject');
|
|
275
|
+
const second = await inj.decide({ query: 'task-board 看板卡怎么验收', agent, turn: 7, step: 1 });
|
|
276
|
+
assert.equal(second.reason, 'turn-already-injected');
|
|
277
|
+
inj.close();
|
|
278
|
+
env.cleanup();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('去重/反例:被压缩遮蔽的注入不计入可见数(可见性口径而非累计次数)', async () => {
|
|
282
|
+
const env = makeTempEnv({ memories: SAMPLE });
|
|
283
|
+
const three = [
|
|
284
|
+
injectionEvent('旧指针 A', [1], 0),
|
|
285
|
+
injectionEvent('旧指针 B', [2], 1),
|
|
286
|
+
injectionEvent('旧指针 C', [3], 2),
|
|
287
|
+
];
|
|
288
|
+
// 对照 1:三条全部可见 ⇒ 触顶不注入
|
|
289
|
+
const visibleAll = fakeAgent(three.map((e) => ({ ...e })));
|
|
290
|
+
assert.equal(visibleInjections(visibleAll).count, 3);
|
|
291
|
+
const inj = createRecallInjector({
|
|
292
|
+
config: recallCfg(env, { maxPerSession: 3 }),
|
|
293
|
+
logger: memoryLogger(),
|
|
294
|
+
bridge: stubBridge(),
|
|
295
|
+
});
|
|
296
|
+
const blocked = await inj.decide({ query: 'task-board 看板卡怎么验收', agent: visibleAll, turn: 3, step: 1 });
|
|
297
|
+
assert.equal(blocked.reason, 'surface-visible-limit');
|
|
298
|
+
|
|
299
|
+
// 对照 2:同样三条事件,但两条已被压缩遮蔽(不在 surface.nodes)⇒ 可见数 1 < 3 ⇒ 允许注入
|
|
300
|
+
const shadowed = fakeAgent(three.map((e) => ({ ...e })));
|
|
301
|
+
shadowed.session.surface.nodes = [2]; // 只有 seq=2 那条还在可见 surface 内
|
|
302
|
+
assert.equal(visibleInjections(shadowed).count, 1, '遮蔽的注入不计入可见数');
|
|
303
|
+
const out = await inj.decide({ query: 'task-board 看板卡怎么验收', agent: shadowed, turn: 4, step: 1 });
|
|
304
|
+
assert.equal(out.action, 'inject', JSON.stringify(out.meta));
|
|
305
|
+
assert.equal(out.meta.visibleBefore, 1);
|
|
306
|
+
inj.close();
|
|
307
|
+
env.cleanup();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('去重/反例:完全相同的指针 payload 仍可见 ⇒ skip(surface-same-payload)', async () => {
|
|
311
|
+
const env = makeTempEnv({ memories: SAMPLE });
|
|
312
|
+
const inj = createRecallInjector({ config: recallCfg(env), logger: memoryLogger(), bridge: stubBridge() });
|
|
313
|
+
const first = await inj.decide({ query: 'task-board 看板卡怎么验收', agent: fakeAgent([]), turn: 1, step: 1 });
|
|
314
|
+
assert.equal(first.action, 'inject');
|
|
315
|
+
// 把这次注入原样放进可见 surface,再问同样的问题(新回合)
|
|
316
|
+
const agent = fakeAgent([injectionEvent(first.text, first.meta.ids, 0)]);
|
|
317
|
+
const second = await inj.decide({ query: 'task-board 看板卡怎么验收', agent, turn: 2, step: 1 });
|
|
318
|
+
assert.equal(second.reason, 'surface-same-payload');
|
|
319
|
+
inj.close();
|
|
320
|
+
env.cleanup();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// ───────────────────────── ④ 降级(fail-open) ─────────────────────────
|
|
324
|
+
|
|
325
|
+
test('降级/正例:桥不在 ⇒ skip(bridge-unavailable),不抛错', async () => {
|
|
326
|
+
const env = makeTempEnv({ memories: SAMPLE });
|
|
327
|
+
const logger = memoryLogger();
|
|
328
|
+
const inj = createRecallInjector({
|
|
329
|
+
config: recallCfg(env),
|
|
330
|
+
logger,
|
|
331
|
+
moduleGate: createModuleGate({ home: env.home }),
|
|
332
|
+
bridge: stubBridge({ ready: false }),
|
|
333
|
+
});
|
|
334
|
+
const out = await inj.decideWithTimeout({ query: 'task-board 看板', agent: fakeAgent([]), turn: 1, step: 1 });
|
|
335
|
+
assert.equal(out.action, 'skip');
|
|
336
|
+
assert.equal(out.reason, 'bridge-unavailable');
|
|
337
|
+
assert.equal(logger.find('skip').length, 1);
|
|
338
|
+
inj.close();
|
|
339
|
+
env.cleanup();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('降级/正例:桥慢于硬预算 ⇒ skip(timeout) 且在预算附近返回(不排队)', async () => {
|
|
343
|
+
const env = makeTempEnv({ memories: SAMPLE });
|
|
344
|
+
const inj = createRecallInjector({
|
|
345
|
+
config: recallCfg(env, { timeoutMs: 60, encodeTimeoutMs: 50 }),
|
|
346
|
+
logger: memoryLogger(),
|
|
347
|
+
bridge: stubBridge({ delayMs: 400 }),
|
|
348
|
+
});
|
|
349
|
+
const t0 = Date.now();
|
|
350
|
+
const out = await inj.decideWithTimeout({ query: 'task-board 看板', agent: fakeAgent([]), turn: 1, step: 1 });
|
|
351
|
+
const elapsed = Date.now() - t0;
|
|
352
|
+
assert.equal(out.reason, 'timeout');
|
|
353
|
+
assert.ok(elapsed < 350, `elapsed=${elapsed}ms 应接近预算而非等桥返回`);
|
|
354
|
+
inj.close();
|
|
355
|
+
env.cleanup();
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test('降级/反例:召回内部抛异常 ⇒ skip(exception) 且不冒泡', async () => {
|
|
359
|
+
const env = makeTempEnv({ memories: SAMPLE });
|
|
360
|
+
const logger = memoryLogger();
|
|
361
|
+
const inj = createRecallInjector({
|
|
362
|
+
config: recallCfg(env),
|
|
363
|
+
logger,
|
|
364
|
+
bridge: stubBridge(),
|
|
365
|
+
deps: {
|
|
366
|
+
recall: {
|
|
367
|
+
shouldTrigger: () => ({ trigger: true, reason: 'non-empty' }),
|
|
368
|
+
buildVectorIndex: () => ({}),
|
|
369
|
+
recall: async () => {
|
|
370
|
+
throw new Error('boom-from-recall');
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
const out = await inj.decideWithTimeout({ query: '会炸的查询', agent: fakeAgent([]), turn: 1, step: 1 });
|
|
376
|
+
assert.equal(out.action, 'skip');
|
|
377
|
+
assert.equal(out.reason, 'exception');
|
|
378
|
+
assert.ok(String(out.meta.error).includes('boom-from-recall'));
|
|
379
|
+
assert.ok(logger.find('skip').some((l) => String(l.error).includes('boom')));
|
|
380
|
+
inj.close();
|
|
381
|
+
env.cleanup();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('降级/正例:L2 模块位关闭 ⇒ 自我 drain;文件缺失 ⇒ fail-safe 判关', async () => {
|
|
385
|
+
const env = makeTempEnv({ memories: SAMPLE, moduleEnabled: false });
|
|
386
|
+
const inj = createRecallInjector({
|
|
387
|
+
config: recallCfg(env),
|
|
388
|
+
logger: memoryLogger(),
|
|
389
|
+
moduleGate: createModuleGate({ home: env.home }),
|
|
390
|
+
bridge: stubBridge(),
|
|
391
|
+
});
|
|
392
|
+
const out = await inj.decide({ query: 'task-board 看板', agent: fakeAgent([]), turn: 1, step: 1 });
|
|
393
|
+
assert.equal(out.reason, 'module-drain:module-disabled');
|
|
394
|
+
|
|
395
|
+
const env2 = makeTempEnv({ memories: SAMPLE, writeAutonomy: false });
|
|
396
|
+
const inj2 = createRecallInjector({
|
|
397
|
+
config: recallCfg(env2),
|
|
398
|
+
logger: memoryLogger(),
|
|
399
|
+
moduleGate: createModuleGate({ home: env2.home }),
|
|
400
|
+
bridge: stubBridge(),
|
|
401
|
+
});
|
|
402
|
+
const out2 = await inj2.decide({ query: 'task-board 看板', agent: fakeAgent([]), turn: 1, step: 1 });
|
|
403
|
+
assert.equal(out2.reason, 'module-drain:config-missing');
|
|
404
|
+
inj.close();
|
|
405
|
+
inj2.close();
|
|
406
|
+
env.cleanup();
|
|
407
|
+
env2.cleanup();
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test('降级/正例:向量库不存在 ⇒ skip(datastore-unavailable)', async () => {
|
|
411
|
+
const env = makeTempEnv({ memories: SAMPLE, makeVectors: false });
|
|
412
|
+
const inj = createRecallInjector({
|
|
413
|
+
config: recallCfg(env),
|
|
414
|
+
logger: memoryLogger(),
|
|
415
|
+
bridge: stubBridge(),
|
|
416
|
+
});
|
|
417
|
+
const out = await inj.decide({ query: 'task-board 看板', agent: fakeAgent([]), turn: 1, step: 1 });
|
|
418
|
+
assert.equal(out.reason, 'datastore-unavailable');
|
|
419
|
+
assert.ok(String(out.meta.error).includes('向量库不存在'));
|
|
420
|
+
inj.close();
|
|
421
|
+
env.cleanup();
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test('降级/正例:日志写失败不得影响注入(fail-open 铁律)', async () => {
|
|
425
|
+
const env = makeTempEnv({ memories: SAMPLE });
|
|
426
|
+
// 把 logsDir 指到一个「文件」路径下 ⇒ mkdir/append 必失败
|
|
427
|
+
const blocker = path.join(env.root, 'not-a-dir');
|
|
428
|
+
fs.writeFileSync(blocker, 'x', 'utf8');
|
|
429
|
+
const logger = createRecallLogger({ dir: path.join(blocker, 'logs') });
|
|
430
|
+
const inj = createRecallInjector({
|
|
431
|
+
config: recallCfg(env, { logsDir: path.join(blocker, 'logs') }),
|
|
432
|
+
logger,
|
|
433
|
+
bridge: stubBridge(),
|
|
434
|
+
});
|
|
435
|
+
const out = await inj.decideWithTimeout({ query: 'task-board 看板卡怎么验收', agent: fakeAgent([]), turn: 1, step: 1 });
|
|
436
|
+
assert.equal(out.action, 'inject', '日志写不了也必须完成注入');
|
|
437
|
+
assert.ok(logger.stats().errors > 0, '日志应记录了写失败');
|
|
438
|
+
inj.close();
|
|
439
|
+
env.cleanup();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test('降级/正例:withTimeout 把未决 promise 转成 timedOut(不改原 promise 语义)', async () => {
|
|
443
|
+
const r = await withTimeout(new Promise((res) => setTimeout(() => res('late'), 200)), 30);
|
|
444
|
+
assert.equal(r.timedOut, true);
|
|
445
|
+
const ok = await withTimeout(Promise.resolve('now'), 200);
|
|
446
|
+
assert.equal(ok, 'now');
|
|
447
|
+
const err = await withTimeout(Promise.reject(new Error('nope')), 200);
|
|
448
|
+
assert.ok(String(err.error).includes('nope'));
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('降级/正例:展开留痕可统计(memory_get 的 expand 行)', async () => {
|
|
452
|
+
const env = makeTempEnv({ memories: SAMPLE });
|
|
453
|
+
const logger = memoryLogger();
|
|
454
|
+
const inj = createRecallInjector({ config: recallCfg(env), logger, bridge: stubBridge() });
|
|
455
|
+
inj.logExpand({ id: 1, chars: 120, truncated: false, sessionId: 's1' });
|
|
456
|
+
inj.logExpand({ id: 2, chars: 2400, truncated: true, sessionId: 's1' });
|
|
457
|
+
const expands = logger.find('expand');
|
|
458
|
+
assert.equal(expands.length, 2);
|
|
459
|
+
assert.equal(expands[1].truncated, true);
|
|
460
|
+
const record = inj.getById(1);
|
|
461
|
+
assert.equal(record.id, 1);
|
|
462
|
+
assert.ok(record.text.includes('三仓同步'));
|
|
463
|
+
inj.close();
|
|
464
|
+
env.cleanup();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test('降级/正例:estimateTokens 口径(CJK 1.5 字/token,非 CJK 3.6 字/token)', () => {
|
|
468
|
+
assert.equal(estimateTokens(''), 0);
|
|
469
|
+
assert.equal(estimateTokens('中文九个字啊啊啊'), Math.ceil(9 / 1.5)); // 全 CJK
|
|
470
|
+
assert.equal(estimateTokens('abcdefgh'), Math.ceil(8 / 3.6));
|
|
471
|
+
// 800 字中文 ≤ 600 token(判据:本包裁剪同时约束两者)
|
|
472
|
+
assert.ok(estimateTokens('字'.repeat(800)) <= 600);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test('降级/正例:硬超时后迟到完成的内层链不补写日志(一次超时 = 一行)', async () => {
|
|
476
|
+
const env = makeTempEnv({ memories: SAMPLE });
|
|
477
|
+
const logger = memoryLogger();
|
|
478
|
+
const inj = createRecallInjector({
|
|
479
|
+
config: recallCfg(env, { timeoutMs: 60, encodeTimeoutMs: 50 }),
|
|
480
|
+
logger,
|
|
481
|
+
bridge: stubBridge({ delayMs: 300 }),
|
|
482
|
+
});
|
|
483
|
+
const out = await inj.decideWithTimeout({ query: 'task-board 看板卡怎么验收', agent: fakeAgent([]), turn: 1, step: 1 });
|
|
484
|
+
assert.equal(out.reason, 'timeout');
|
|
485
|
+
await new Promise((r) => setTimeout(r, 900)); // 等被放弃的内层链跑到终点
|
|
486
|
+
const skips = logger.lines.filter((l) => l.event === 'skip');
|
|
487
|
+
assert.equal(skips.filter((l) => l.reason === 'timeout').length, 1, JSON.stringify(skips.map((s) => s.reason)));
|
|
488
|
+
assert.equal(skips.filter((l) => l.reason === 'exception').length, 0, '迟到链不得再写 exception 行');
|
|
489
|
+
assert.equal(logger.lines.filter((l) => l.event === 'inject').length, 0, '迟到链不得写 inject 行');
|
|
490
|
+
inj.close();
|
|
491
|
+
env.cleanup();
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test('降级/反例:事件循环被长同步段阻塞致定时器迟到 ⇒ 仍判超时、不注入', async () => {
|
|
495
|
+
// 复现 2026-09-21 真机踩到的那次 524ms:setTimeout 回调被同步段推迟,
|
|
496
|
+
// 若只看 timedOut 标志会把超预算的决策当成功 ⇒ 必须有「返回时 elapsed 复核」。
|
|
497
|
+
const env = makeTempEnv({ memories: SAMPLE });
|
|
498
|
+
const logger = memoryLogger();
|
|
499
|
+
const busy = (ms) => { const end = Date.now() + ms; while (Date.now() < end) { /* spin */ } };
|
|
500
|
+
const inj = createRecallInjector({
|
|
501
|
+
config: recallCfg(env, { timeoutMs: 50 }),
|
|
502
|
+
logger,
|
|
503
|
+
bridge: stubBridge(),
|
|
504
|
+
deps: {
|
|
505
|
+
recall: {
|
|
506
|
+
shouldTrigger: () => ({ trigger: true, reason: 'non-empty' }),
|
|
507
|
+
buildVectorIndex: () => ({ count: 1 }),
|
|
508
|
+
recall: async () => {
|
|
509
|
+
busy(150); // 同步阻塞 > 预算
|
|
510
|
+
return { triggered: true, reason: 'non-empty', hits: [{ id: 1, sources: ['vector'] }], timings: {}, stats: { indexSize: 1 } };
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
const out = await inj.decideWithTimeout({ query: '会阻塞的查询', agent: fakeAgent([]), turn: 1, step: 1 });
|
|
516
|
+
assert.equal(out.reason, 'timeout');
|
|
517
|
+
assert.equal(out.meta.lateTimer, true, '应标出「定时器迟到」而不是静默成功');
|
|
518
|
+
assert.equal(logger.lines.filter((l) => l.event === 'inject').length, 0, '超预算不得注入');
|
|
519
|
+
inj.close();
|
|
520
|
+
env.cleanup();
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test('降级/正例:warmup 预热向量矩阵与桥(禁用时不动任何资源)', async () => {
|
|
524
|
+
const env = makeTempEnv({ memories: SAMPLE });
|
|
525
|
+
const inj = createRecallInjector({ config: recallCfg(env), logger: memoryLogger(), bridge: stubBridge() });
|
|
526
|
+
const w = await inj.warmup();
|
|
527
|
+
assert.equal(w.ok, true, JSON.stringify(w));
|
|
528
|
+
assert.ok(w.count > 0, `count=${w.count}`);
|
|
529
|
+
assert.equal(inj.status().cache.index, SAMPLE.length);
|
|
530
|
+
|
|
531
|
+
const off = createRecallInjector({ config: recallCfg(env, { enabled: false }), logger: memoryLogger() });
|
|
532
|
+
const w2 = await off.warmup();
|
|
533
|
+
assert.equal(w2.ok, false);
|
|
534
|
+
assert.equal(w2.reason, 'disabled');
|
|
535
|
+
inj.close();
|
|
536
|
+
off.close();
|
|
537
|
+
env.cleanup();
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test('注入/正例:日志轮转(3 份 × 保留 30 天)与写失败容错', () => {
|
|
541
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'g24-s2b-log-'));
|
|
542
|
+
const logger = createRecallLogger({ dir, maxBytes: 200, keepFiles: 3, keepDays: 30 });
|
|
543
|
+
for (let i = 0; i < 40; i += 1) logger.log({ event: 'x', i, pad: 'p'.repeat(60) });
|
|
544
|
+
logger.sweep();
|
|
545
|
+
const files = fs.readdirSync(dir);
|
|
546
|
+
const rotated = files.filter((f) => /^memory-recall\.\d{8}-\d{6}\.log$/.test(f));
|
|
547
|
+
assert.ok(logger.stats().rotations >= 1, `rotations=${logger.stats().rotations}`);
|
|
548
|
+
assert.ok(rotated.length >= 1 && rotated.length <= 3, `轮转文件数 ${rotated.length} 应在 1..keepFiles(3)`);
|
|
549
|
+
assert.ok(files.includes('memory-recall.log'), '主文件应重建');
|
|
550
|
+
// 30 天前的轮转文件被清掉
|
|
551
|
+
const old = path.join(dir, 'memory-recall.20200101-000000.log');
|
|
552
|
+
fs.writeFileSync(old, 'x', 'utf8');
|
|
553
|
+
const past = (Date.now() - 40 * 86400000) / 1000;
|
|
554
|
+
fs.utimesSync(old, past, past);
|
|
555
|
+
logger.sweep();
|
|
556
|
+
assert.equal(fs.existsSync(old), false, '超过 30 天的轮转文件应被清理');
|
|
557
|
+
// 写失败(目录不可写)不影响调用方
|
|
558
|
+
const blocked = createRecallLogger({ dir: path.join(dir, 'memory-recall.log', 'nope') });
|
|
559
|
+
assert.equal(blocked.log({ event: 'y' }), false);
|
|
560
|
+
assert.ok(blocked.stats().errors >= 1);
|
|
561
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test('注入/正例:SOURCE_KIND 形态固定为 memory-recall(不复用 form)', () => {
|
|
565
|
+
assert.equal(SOURCE_KIND, 'memory-recall');
|
|
566
|
+
const line = pointerLine({ id: 452, text: '三仓同步的实测结论与修复', tags: '跨机同步 gitee', updated_at: 1757462400000 });
|
|
567
|
+
assert.match(line, /^- #452 \(\d{4}-\d{2}-\d{2} · tags: .+\) /);
|
|
568
|
+
});
|