dsh-lite-memory 0.3.0 → 0.15.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.
Files changed (39) hide show
  1. package/README.md +73 -17
  2. package/TRIGGERS.md +18 -14
  3. package/anchor.js +518 -0
  4. package/bench/bench.mjs +145 -0
  5. package/bench/evolution.mjs +123 -0
  6. package/client.js +1519 -33
  7. package/docs/BENCH.md +74 -0
  8. package/docs/RELEASE-0.11.0.md +27 -0
  9. package/docs/RELEASE-0.11.1.md +11 -0
  10. package/docs/RELEASE-0.12.0.md +15 -0
  11. package/docs/RELEASE-0.12.1.md +17 -0
  12. package/docs/RELEASE-0.12.2.md +17 -0
  13. package/docs/RELEASE-0.13.0.md +7 -0
  14. package/docs/RELEASE-0.14.0.md +7 -0
  15. package/docs/RELEASE-0.15.0.md +8 -0
  16. package/index.js +2080 -76
  17. package/learning.js +585 -0
  18. package/package.json +10 -2
  19. package/state.js +1476 -0
  20. package/store.js +2967 -244
  21. package/test/anchor.test.mjs +436 -0
  22. package/test/client.test.mjs +114 -0
  23. package/test/contract.test.mjs +880 -0
  24. package/test/control.test.mjs +278 -0
  25. package/test/crossing.test.mjs +372 -0
  26. package/test/evolution-read.test.mjs +448 -0
  27. package/test/fixtures/legacy-v1/archive/mem_444444444444.json +18 -0
  28. package/test/fixtures/legacy-v1/entries/mem_111111111111.md +2 -0
  29. package/test/fixtures/legacy-v1/entries/mem_222222222222.md +1 -0
  30. package/test/fixtures/legacy-v1/entries/mem_333333333333.md +1 -0
  31. package/test/fixtures/legacy-v1/index.json +63 -0
  32. package/test/learning.test.mjs +772 -0
  33. package/test/lexical.test.mjs +33 -0
  34. package/test/recall.test.mjs +449 -0
  35. package/test/scenario.test.mjs +184 -0
  36. package/test/state.test.mjs +485 -0
  37. package/test/store.test.mjs +816 -115
  38. package/test/view.test.mjs +257 -0
  39. package/view.js +616 -0
@@ -0,0 +1,448 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-evolution-read-'));
7
+ const { apply } = await import('../index.js');
8
+ const store = await import('../store.js');
9
+ const state = await import('../state.js');
10
+ const anchor = await import('../anchor.js');
11
+
12
+ const effect = (fn) => {
13
+ const dispose = fn();
14
+ return typeof dispose === 'function' ? dispose : () => {};
15
+ };
16
+
17
+ function boot(mode = 'adaptive') {
18
+ const defs = new Map();
19
+ const handlers = new Map();
20
+ const promptContexts = [];
21
+ const ctx = {
22
+ tools: { register: (def) => { defs.set(def.name, def); return () => {}; } },
23
+ effect,
24
+ inject: () => () => {},
25
+ get: (name) => (name === 'systemPrompt'
26
+ ? { context: (entry) => { promptContexts.push(entry); return () => {}; } }
27
+ : name === 'agents' ? { currentInitiator: () => null } : undefined),
28
+ on: (event, handler) => {
29
+ const list = handlers.get(event) || [];
30
+ list.push(handler);
31
+ handlers.set(event, list);
32
+ return () => {};
33
+ },
34
+ };
35
+ apply(ctx, { mode });
36
+ const provider = promptContexts.find((entry) => entry.name === 'lite-memory:recall');
37
+ assert.ok(provider, 'recall provider registered');
38
+ const exec = (name, args, agent) => defs.get(name).execute(args, { agent });
39
+ const preStep = (payload, decision) => handlers.get('agent/pre-step').at(-1)(payload, async () => decision);
40
+ const text = (agent, signal) => provider.text({ agent, signal });
41
+ return { defs, handlers, provider, exec, preStep, text, debug: globalThis.__liteMemoryRecall };
42
+ }
43
+
44
+ const signal = () => new AbortController().signal;
45
+ let seq = 0;
46
+ const human = (body) => {
47
+ seq += 1;
48
+ return { role: 'user', id: `msg-${seq}`, source: { kind: 'user' }, content: [{ type: 'text', text: body }] };
49
+ };
50
+ const enter = (...messages) => ({ kind: 'enter', messages });
51
+ const mkdir = (label) => fs.mkdtempSync(path.join(os.tmpdir(), `lite-memory-evolution-${label}-`));
52
+ const makeAgent = (id, dir) => ({ id, cwd: dir, session: { id: `session-${id}`, header: { cwd: dir } } });
53
+
54
+ const save = (scope, fields) => {
55
+ const result = store.upsert(scope, {
56
+ applicability: 'all_in_scope',
57
+ body: `正文:${fields.title}`,
58
+ resolution: 'distinct',
59
+ ...fields,
60
+ summary: fields.summary ?? `${fields.title}的摘要`,
61
+ });
62
+ return { ...result, id: result.record?.id ?? result.id };
63
+ };
64
+
65
+ /** 直接种一条实验来源条目,绕开 PR4 的整理入口。 */
66
+ function seedRule(scope, { title, body, summary, conditions = [], learning = { state: 'active' }, topicKey, tags = [] }) {
67
+ const saved = save(scope, { title, body, summary, tags });
68
+ state.transact(scope, {}, (draft) => {
69
+ const row = draft.entries.find((entry) => entry.id === saved.id);
70
+ row.origin = 'project_evolution';
71
+ row.current.conditions = conditions;
72
+ row.maintenance.learning = { ...learning };
73
+ if (topicKey) row.topicKey = topicKey;
74
+ return { changed: true };
75
+ });
76
+ return saved.id;
77
+ }
78
+
79
+ const enable = (scope, defaults) => {
80
+ const turned = store.setEvolution(scope, true, { operationId: `op-on-${Math.random().toString(16).slice(2)}` });
81
+ assert.equal(turned.ok, true, turned.reason);
82
+ assert.equal(turned.enabled, true);
83
+ if (defaults) {
84
+ const applied = store.setTaskDefault(scope, defaults);
85
+ assert.equal(applied.ok, true, applied.reason);
86
+ }
87
+ return turned;
88
+ };
89
+
90
+ const scopeOf = (dir) => store.scopeOf(dir);
91
+
92
+ /* L01:默认关闭时,自动读取与普通召回完全不动。 */
93
+ {
94
+ const dir = mkdir('off');
95
+ const scope = scopeOf(dir);
96
+ seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
97
+ const runtime = boot();
98
+ const agent = makeAgent('a-off', dir);
99
+ const sig = signal();
100
+ await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期口播开头怎么改')));
101
+ assert.equal(runtime.text(agent, sig), '');
102
+ assert.equal(runtime.debug.windowOf(agent).experiment, null);
103
+ }
104
+
105
+ /* L02:开关打开但没有任何栏目,不注入任何领域问题,也不给空上下文。 */
106
+ {
107
+ const dir = mkdir('clarify');
108
+ const scope = scopeOf(dir);
109
+ enable(scope);
110
+ const runtime = boot();
111
+ const agent = makeAgent('a-clarify', dir);
112
+ const sig = signal();
113
+ await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期怎么开头')));
114
+ const text = runtime.text(agent, sig);
115
+ assert.equal(text, '');
116
+ assert.doesNotMatch(text, /栏目|Vlog|口播|视频/);
117
+ const experiment = runtime.debug.windowOf(agent).experiment;
118
+ assert.equal(experiment ? experiment.injectedIds.length : 0, 0);
119
+ }
120
+
121
+ /* L03:未绑定任务只给栏目索引,不超过三条;短规则不进正文。 */
122
+ {
123
+ const dir = mkdir('index');
124
+ const scope = scopeOf(dir);
125
+ seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播视频' }] });
126
+ seedRule(scope, { title: '口播收尾', body: '结尾只留一个行动。', conditions: [{ key: 'seriesId', value: '口播视频' }] });
127
+ seedRule(scope, { title: 'Vlog 节奏', body: '每段不超过二十秒。', conditions: [{ key: 'seriesId', value: 'Vlog' }] });
128
+ enable(scope);
129
+ const runtime = boot();
130
+ const agent = makeAgent('a-index', dir);
131
+ const sig = signal();
132
+ await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('视频开头怎么改')));
133
+ const text = runtime.text(agent, sig);
134
+ assert.match(text, /这些记忆范围可能适用/);
135
+ assert.doesNotMatch(text, /Vlog/);
136
+ assert.doesNotMatch(text, /开场三句内给结论/);
137
+ const listed = text.split('\n').filter((line) => line.startsWith('- '));
138
+ assert.equal(listed.length <= 3, true);
139
+ assert.equal(Buffer.byteLength(text) <= 600 + 160, true, 'index stays small');
140
+ }
141
+
142
+ /* L03b:项目有视频栏目,本轮是修代码:无关栏目与选择提示都不进上下文。 */
143
+ {
144
+ const dir = mkdir('index-unrelated');
145
+ const scope = scopeOf(dir);
146
+ seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
147
+ seedRule(scope, { title: 'Vlog 节奏', body: '每段不超过二十秒。', conditions: [{ key: 'seriesId', value: 'Vlog' }] });
148
+ enable(scope);
149
+ const runtime = boot();
150
+ const agent = makeAgent('a-index-unrelated', dir);
151
+ const sig = signal();
152
+ await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('修一下 dsh-unlag 的重新载入问题')));
153
+ const injected = runtime.text(agent, sig);
154
+ assert.equal(injected, '');
155
+ const experiment = runtime.debug.windowOf(agent).experiment;
156
+ assert.equal(experiment ? experiment.injectedIds.length : 0, 0);
157
+ }
158
+
159
+ /* L04:项目默认绑定后给规则正文,带 id 与版本。 */
160
+ {
161
+ const dir = mkdir('default');
162
+ const scope = scopeOf(dir);
163
+ const id = seedRule(scope, { title: '默认口播开头', body: '先给结论再解释。', conditions: [{ key: 'seriesId', value: '口播' }, { key: 'taskKind', value: 'video' }] });
164
+ enable(scope, { taskKind: 'video', seriesId: '口播' });
165
+ const runtime = boot();
166
+ const agent = makeAgent('a-default', dir);
167
+ const sig = signal();
168
+ await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期开头怎么改')));
169
+ const text = runtime.text(agent, sig);
170
+ assert.match(text, /项目实验规则/);
171
+ assert.match(text, /先给结论再解释/);
172
+ assert.ok(text.includes(id));
173
+ assert.doesNotMatch(text, /第 .* 版/);
174
+ }
175
+
176
+ /* L05:长规则只给用途与 memory_read 指针,不把正文塞进上下文。 */
177
+ {
178
+ const dir = mkdir('long');
179
+ const scope = scopeOf(dir);
180
+ const longBody = `完整流程:${'先做这一步再检查,'.repeat(30)}`;
181
+ const id = seedRule(scope, { title: '长流程口播', body: longBody, summary: '完整流程按顺序做', conditions: [{ key: 'seriesId', value: '口播' }] });
182
+ enable(scope, { seriesId: '口播' });
183
+ const runtime = boot();
184
+ const agent = makeAgent('a-long', dir);
185
+ const sig = signal();
186
+ await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期口播怎么做')));
187
+ const text = runtime.text(agent, sig);
188
+ assert.ok(text.includes(id));
189
+ assert.match(text, /memory_read/);
190
+ assert.equal(text.includes('先做这一步再检查'), false);
191
+ assert.equal(Buffer.byteLength(text) <= 1200 + 200, true);
192
+ }
193
+
194
+ /* L06:道谢不注入规则,只留一个可续的指针。 */
195
+ {
196
+ const dir = mkdir('thanks');
197
+ const scope = scopeOf(dir);
198
+ seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
199
+ enable(scope, { seriesId: '口播' });
200
+ const runtime = boot();
201
+ const agent = makeAgent('a-thanks', dir);
202
+ const sig = signal();
203
+ await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('谢谢!')));
204
+ const text = runtime.text(agent, sig);
205
+ assert.equal(text.includes('开场三句内给结论'), false);
206
+ }
207
+
208
+ /* L07:再做一期是新的一集,继承栏目与风格,不继承旧主题。 */
209
+ {
210
+ const dir = mkdir('continue');
211
+ const scope = scopeOf(dir);
212
+ seedRule(scope, { title: '风格规则', body: '口播全程用短句。', conditions: [{ key: 'seriesId', value: '口播' }, { key: 'phase', value: 'brief' }] });
213
+ seedRule(scope, { title: '本期主题', body: '这一期讲春季收纳。', conditions: [{ key: 'seriesId', value: '口播' }, { key: 'phase', value: 'theme' }] });
214
+ enable(scope, { seriesId: '口播' });
215
+ const selected = anchor.applySelection(store.taskState(scope), {
216
+ sessionId: 'session-a-continue',
217
+ selection: { seriesId: '口播', phase: 'theme' },
218
+ batchKey: anchor.requestBatchKey(['seed-theme']),
219
+ messageIds: ['seed-theme'],
220
+ });
221
+ const seededEpisode = selected.tasks.episodes[selected.episode.episodeId];
222
+ seededEpisode.status = 'active';
223
+ selected.tasks.sessions['session-a-continue'].status = 'active';
224
+ assert.equal(store.saveTaskState(scope, selected.tasks, { operationId: 'op-seed-theme' }).ok, true);
225
+ const runtime = boot();
226
+ const agent = makeAgent('a-continue', dir);
227
+ const first = signal();
228
+ await runtime.preStep({ agent, signal: first, turn: 't1' }, enter(human('这期讲春季收纳')));
229
+ assert.match(runtime.text(agent, first), /这一期讲春季收纳/);
230
+ const second = signal();
231
+ await runtime.preStep({ agent, signal: second, turn: 't2' }, enter(human('再做一期')));
232
+ const text = runtime.text(agent, second);
233
+ assert.match(text, /口播全程用短句/);
234
+ assert.equal(text.includes('这一期讲春季收纳'), false);
235
+ }
236
+
237
+ /* L08:改一版沿用同一集,不新建集。 */
238
+ {
239
+ const dir = mkdir('revision');
240
+ const scope = scopeOf(dir);
241
+ enable(scope, { seriesId: '口播' });
242
+ const runtime = boot();
243
+ const agent = makeAgent('a-revision', dir);
244
+ await runtime.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('这期口播开头快点')));
245
+ const before = store.taskState(scope).episodes;
246
+ const beforeIds = Object.keys(before);
247
+ await runtime.preStep({ agent, signal: signal(), turn: 't2' }, enter(human('再改一下开头')));
248
+ const after = store.taskState(scope).episodes;
249
+ assert.deepEqual(Object.keys(after).sort(), beforeIds.sort());
250
+ assert.equal(Object.values(after)[0].status, 'continuable');
251
+ }
252
+
253
+ /* L09:改做别的类型不把视频规则带过去。 */
254
+ {
255
+ const dir = mkdir('switch');
256
+ const scope = scopeOf(dir);
257
+ seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
258
+ enable(scope, { seriesId: '口播' });
259
+ const runtime = boot();
260
+ const agent = makeAgent('a-switch', dir);
261
+ await runtime.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('这期口播开头')));
262
+ const sig = signal();
263
+ await runtime.preStep({ agent, signal: sig, turn: 't2' }, enter(human('改做 README 文案')));
264
+ const text = runtime.text(agent, sig);
265
+ assert.equal(text.includes('开场三句内给结论'), false);
266
+ const current = Object.values(store.taskState(scope).episodes).find((episode) => episode.status === 'unbound');
267
+ assert.ok(current, 'switch leaves an unbound episode');
268
+ }
269
+
270
+ /* L09b:明确改为排查数据库后,旧视频规则和栏目提示都不再注入。 */
271
+ {
272
+ const dir = mkdir('switch-db');
273
+ const scope = scopeOf(dir);
274
+ seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
275
+ enable(scope, { seriesId: '口播' });
276
+ const runtime = boot();
277
+ const agent = makeAgent('a-switch-db', dir);
278
+ await runtime.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('这期口播开头')));
279
+ const sig = signal();
280
+ await runtime.preStep({ agent, signal: sig, turn: 't2' }, enter(human('这期不做了,改为排查 PostgreSQL 慢查询')));
281
+ const text = runtime.text(agent, sig);
282
+ assert.equal(text.includes('开场三句内给结论'), false);
283
+ assert.equal(/栏目|点名/.test(text), false);
284
+ }
285
+
286
+ /* L10:没有本请求上下文时,普通检索看不到实验来源。 */
287
+ {
288
+ const dir = mkdir('nocx');
289
+ const scope = scopeOf(dir);
290
+ const id = seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
291
+ enable(scope, { seriesId: '口播' });
292
+ const runtime = boot();
293
+ const agent = makeAgent('a-nocx', dir);
294
+ await runtime.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('这期口播开头')));
295
+ const value = await runtime.exec('memory_search', { query: '口播开头' }, agent);
296
+ assert.equal((value.refs || []).some((ref) => ref.id === id), false);
297
+ }
298
+
299
+ /* L11:带本请求 contextId 才能绑定;绑定后只升到 proposed。 */
300
+ {
301
+ const dir = mkdir('bind');
302
+ const scope = scopeOf(dir);
303
+ seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
304
+ enable(scope);
305
+ const runtime = boot();
306
+ const agent = makeAgent('a-bind', dir);
307
+ const sig = signal();
308
+ await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期怎么开头')));
309
+ const held = runtime.debug.windowOf(agent).experiment;
310
+ assert.ok(held && held.contextId, 'context leaked');
311
+ const value = await runtime.exec('memory_search', { query: '口播', contextId: held.contextId, bindForRequest: true }, agent);
312
+ assert.equal(value.status, 'ok');
313
+ assert.ok((value.text || '').includes('口播'));
314
+ const episodes = Object.values(store.taskState(scope).episodes);
315
+ assert.equal(episodes.length, 1);
316
+ assert.equal(episodes[0].status, 'proposed');
317
+ assert.match(runtime.text(agent, sig), /开场三句内给结论/);
318
+ }
319
+
320
+ /* L12:跨项目上下文直接拒绝。 */
321
+ {
322
+ const dirA = mkdir('cross-a');
323
+ const dirB = mkdir('cross-b');
324
+ const scopeA = scopeOf(dirA);
325
+ seedRule(scopeA, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
326
+ enable(scopeA);
327
+ const runtime = boot();
328
+ const agentA = makeAgent('a-cross-a', dirA);
329
+ await runtime.preStep({ agent: agentA, signal: signal(), turn: 't1' }, enter(human('这期怎么开头')));
330
+ const held = runtime.debug.windowOf(agentA).experiment;
331
+ const agentB = makeAgent('a-cross-b', dirB);
332
+ const value = await runtime.exec('memory_search', { query: '口播', contextId: held.contextId }, agentB);
333
+ assert.equal(value.status, 'needs_context');
334
+ assert.match(value.text, /另一个项目/);
335
+ }
336
+
337
+ /* L13:跨请求复用旧上下文算过期。 */
338
+ {
339
+ const dir = mkdir('stale');
340
+ const scope = scopeOf(dir);
341
+ seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
342
+ enable(scope);
343
+ const runtime = boot();
344
+ const agent = makeAgent('a-stale', dir);
345
+ await runtime.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('这期怎么开头')));
346
+ const old = runtime.debug.windowOf(agent).experiment.contextId;
347
+ await runtime.preStep({ agent, signal: signal(), turn: 't2' }, enter(human('再问一次')));
348
+ const value = await runtime.exec('memory_search', { query: '口播', contextId: old }, agent);
349
+ assert.equal(value.status, 'needs_context');
350
+ }
351
+
352
+ /* L14:绑定必须带 contextId,不能凭空绑。 */
353
+ {
354
+ const dir = mkdir('bind-missing');
355
+ const scope = scopeOf(dir);
356
+ enable(scope);
357
+ const runtime = boot();
358
+ const agent = makeAgent('a-bind-missing', dir);
359
+ const value = await runtime.exec('memory_search', { query: '口播', bindForRequest: true }, agent);
360
+ assert.equal(value.status, 'needs_context');
361
+ }
362
+
363
+ /* L15:暂停的实验条目即使有上下文也不给正文。 */
364
+ {
365
+ const dir = mkdir('paused');
366
+ const scope = scopeOf(dir);
367
+ const id = seedRule(scope, {
368
+ title: '暂停口播规则',
369
+ body: '这条已经暂停,不该给正文。',
370
+ conditions: [{ key: 'seriesId', value: '口播' }],
371
+ learning: { state: 'paused', blockedReason: 'needs_revalidation' },
372
+ });
373
+ enable(scope, { seriesId: '口播' });
374
+ const runtime = boot();
375
+ const agent = makeAgent('a-paused', dir);
376
+ await runtime.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('这期口播怎么做')));
377
+ const held = runtime.debug.windowOf(agent).experiment;
378
+ assert.ok(held);
379
+ const value = await runtime.exec('memory_read', { id, contextId: held.contextId }, agent);
380
+ assert.equal(value.status, 'needs_context');
381
+ assert.equal(value.body, '');
382
+ }
383
+
384
+ /* L16:工具模式不自动注入,窗口仍然开。 */
385
+ {
386
+ const dir = mkdir('tool-only');
387
+ const scope = scopeOf(dir);
388
+ seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
389
+ enable(scope, { seriesId: '口播' });
390
+ const runtime = boot('tool_only');
391
+ const agent = makeAgent('a-tool-only', dir);
392
+ await runtime.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('这期口播开头')));
393
+ assert.equal(runtime.text(agent, signal()), '');
394
+ }
395
+
396
+ /* L16b:工具模式下前台点名栏目可按需绑定,仍然不自动注入。 */
397
+ {
398
+ const dir = mkdir('tool-only-bind');
399
+ const scope = scopeOf(dir);
400
+ const id = seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
401
+ enable(scope, { seriesId: '口播' });
402
+ const runtime = boot('tool_only');
403
+ const agent = makeAgent('a-tool-only-bind', dir);
404
+ await runtime.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('这期口播开头')));
405
+ const value = await runtime.exec('memory_search', { query: '口播开头', bindForRequest: true }, agent);
406
+ assert.equal(value.status, 'ok');
407
+ assert.equal((value.refs || []).some((ref) => ref.id === id), true);
408
+ const episodes = Object.values(store.taskState(scope).episodes);
409
+ assert.equal(episodes.length, 1);
410
+ assert.equal(episodes[0].status, 'proposed');
411
+ }
412
+
413
+ /* L17:固定规则照常召回,实验规则不混进普通自动召回。 */
414
+ {
415
+ const dir = mkdir('fixed');
416
+ const scope = scopeOf(dir);
417
+ save(scope, { title: '固定发布检查', body: '发版前跑一遍检查。', tags: ['发布'] });
418
+ seedRule(scope, { title: '实验发布检查', body: '实验规则:发版前先备份。', tags: ['发布'], learning: { state: 'active' } });
419
+ enable(scope);
420
+ const runtime = boot();
421
+ const agent = makeAgent('a-fixed', dir);
422
+ const sig = signal();
423
+ await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('发版前检查什么')));
424
+ const text = runtime.text(agent, sig);
425
+ assert.match(text, /固定发布检查/);
426
+ assert.equal(text.includes('实验规则:发版前先备份'), false);
427
+ }
428
+
429
+ /* L18:工具绑定零命中/多命中时只回一句读取状态,不要求用户点名栏目。 */
430
+ {
431
+ const dir = mkdir('bind-none');
432
+ const scope = scopeOf(dir);
433
+ seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
434
+ seedRule(scope, { title: 'Vlog 节奏', body: '每段不超过二十秒。', conditions: [{ key: 'seriesId', value: 'Vlog' }] });
435
+ enable(scope);
436
+ const runtime = boot();
437
+ const agent = makeAgent('a-bind-none', dir);
438
+ await runtime.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('这期怎么开头')));
439
+ const held = runtime.debug.windowOf(agent).experiment;
440
+ const none = await runtime.exec('memory_search', { query: '修一下构建脚本', contextId: held.contextId, bindForRequest: true }, agent);
441
+ assert.equal(none.status, 'needs_context');
442
+ assert.doesNotMatch(none.text, /点名|先问|栏目/);
443
+ const many = await runtime.exec('memory_search', { query: '口播和 Vlog 都要', contextId: held.contextId, bindForRequest: true }, agent);
444
+ assert.equal(many.status, 'needs_context');
445
+ assert.doesNotMatch(many.text, /点名|先问|栏目/);
446
+ }
447
+
448
+ console.log('evolution-read.test.mjs: all passed');
@@ -0,0 +1,18 @@
1
+ {
2
+ "id": "mem_444444444444",
3
+ "title": "旧归档条目",
4
+ "summary": "已经归档的一条",
5
+ "when": "",
6
+ "kind": "note",
7
+ "tags": [],
8
+ "created": "2026-01-04T00:00:00.000Z",
9
+ "updated": "2026-01-04T00:00:00.000Z",
10
+ "lastUsed": "2026-01-04T00:00:00.000Z",
11
+ "revision": 2,
12
+ "status": "archived",
13
+ "source": "user_explicit",
14
+ "hits": 3,
15
+ "merges": 1,
16
+ "versions": [],
17
+ "body": "归档正文"
18
+ }
@@ -0,0 +1,2 @@
1
+ 第一步跑测试
2
+ 第二步改版本号
@@ -0,0 +1 @@
1
+ 用中文回复。
@@ -0,0 +1 @@
1
+ node test/store.test.mjs
@@ -0,0 +1,63 @@
1
+ {
2
+ "version": 1,
3
+ "scope": "fixture",
4
+ "cwd": "CWD_PLACEHOLDER",
5
+ "entries": [
6
+ {
7
+ "id": "mem_111111111111",
8
+ "title": "发布顺序",
9
+ "summary": "发版前要按顺序做的事",
10
+ "when": "所有发布",
11
+ "kind": "note",
12
+ "tags": [],
13
+ "created": "2026-01-01T00:00:00.000Z",
14
+ "updated": "2026-01-01T00:00:00.000Z",
15
+ "lastUsed": "2026-01-01T00:00:00.000Z",
16
+ "revision": 1,
17
+ "status": "active",
18
+ "source": "user_explicit",
19
+ "hits": 0,
20
+ "merges": 0,
21
+ "versions": [],
22
+ "bytes": 100
23
+ },
24
+ {
25
+ "id": "mem_222222222222",
26
+ "title": "回复用中文",
27
+ "summary": "用户要求回复用中文",
28
+ "when": "所有对话",
29
+ "kind": "preference",
30
+ "tags": [],
31
+ "created": "2026-01-02T00:00:00.000Z",
32
+ "updated": "2026-01-02T00:00:00.000Z",
33
+ "lastUsed": "2026-01-02T00:00:00.000Z",
34
+ "revision": 1,
35
+ "status": "active",
36
+ "source": "user_explicit",
37
+ "hits": 0,
38
+ "merges": 0,
39
+ "versions": [],
40
+ "bytes": 100
41
+ },
42
+ {
43
+ "id": "mem_333333333333",
44
+ "title": "跑测试",
45
+ "summary": "提交前跑哪条命令",
46
+ "when": "本地",
47
+ "kind": "note",
48
+ "tags": [],
49
+ "created": "2026-01-03T00:00:00.000Z",
50
+ "updated": "2026-01-03T00:00:00.000Z",
51
+ "lastUsed": "2026-01-03T00:00:00.000Z",
52
+ "revision": 1,
53
+ "status": "active",
54
+ "source": "user_explicit",
55
+ "hits": 0,
56
+ "merges": 0,
57
+ "versions": [],
58
+ "bytes": 100
59
+ }
60
+ ],
61
+ "suggestions": [],
62
+ "receipts": []
63
+ }