dsh-lite-memory 0.18.43 → 0.18.45

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.
@@ -1,1039 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import fs from 'node:fs';
3
- import crypto from 'node:crypto';
4
- import os from 'node:os';
5
- import path from 'node:path';
6
-
7
- process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-recall-'));
8
- const { apply } = await import('../index.js');
9
- const store = await import('../store.js');
10
-
11
- const HEADER = '已有记忆可能相关';
12
- const CARD_LINE = /^- \[[^\]]+\] 「.+?」(?:|.*)?$/;
13
- const CARD_LINE_M = new RegExp(CARD_LINE.source, 'm');
14
- const INTERNAL_ID = /mem_[a-z0-9]{6,}/;
15
-
16
- const effect = (fn) => {
17
- const dispose = fn();
18
- return typeof dispose === 'function' ? dispose : () => {};
19
- };
20
-
21
- function boot(mode = 'adaptive') {
22
- const defs = new Map();
23
- const handlers = new Map();
24
- const promptContexts = [];
25
- const ctx = {
26
- tools: { register: (def) => { defs.set(def.name, def); return () => {}; } },
27
- effect,
28
- inject: () => () => {},
29
- get: (name) => (name === 'systemPrompt'
30
- ? { context: (entry) => { promptContexts.push(entry); return () => {}; } }
31
- : name === 'agents' ? { currentInitiator: () => null } : undefined),
32
- on: (event, handler) => {
33
- const list = handlers.get(event) || [];
34
- list.push(handler);
35
- handlers.set(event, list);
36
- return () => {};
37
- },
38
- };
39
- apply(ctx, { mode });
40
- const debug = globalThis.__liteMemoryRecall;
41
- const provider = promptContexts.find((entry) => entry.name === 'lite-memory:recall');
42
- /* CI 冷启动偶尔超过召回耗时上限,逻辑断言不该被「这轮先不注入」判失败。 */
43
- globalThis.__liteMemoryRecall?.setDeadlineMs?.(5000);
44
- assert.ok(provider, 'recall provider registered');
45
- assert.equal(provider.order, 60);
46
- assert.equal(provider.interpolate, false);
47
- assert.equal(provider.required, false);
48
- const exec = (name, args, agent) => defs.get(name).execute(args, { agent });
49
- const render = (name, args, value) => defs.get(name).output.render(args, value)[0].text;
50
- const preStep = (payload, decision) => {
51
- const handler = handlers.get('agent/pre-step').at(-1);
52
- return handler(payload, async () => decision);
53
- };
54
- return { defs, handlers, provider, exec, render, preStep, debug };
55
- }
56
-
57
- const signal = () => new AbortController().signal;
58
-
59
- let messageSeq = 0;
60
- function human(body, options = {}) {
61
- messageSeq += 1;
62
- return {
63
- role: 'user',
64
- id: options.id ?? `msg-${messageSeq}`,
65
- source: { kind: 'user' },
66
- content: [{ type: 'text', text: body }],
67
- };
68
- }
69
- const synthetic = (kind, body) => ({
70
- role: 'user', id: `synth-${(messageSeq += 1)}`, source: { kind }, content: [{ type: 'text', text: body }],
71
- });
72
- const enter = (...messages) => ({ kind: 'enter', messages });
73
- const prepare = async (runtime, agent, query, turn = 't1') => {
74
- const sig = signal();
75
- await runtime.preStep({ agent, signal: sig, turn }, enter(human(query)));
76
- return sig;
77
- };
78
-
79
- const mkdir = (label) => fs.mkdtempSync(path.join(os.tmpdir(), `lite-memory-recall-${label}-`));
80
- const makeAgent = (id, dir) => ({ id, cwd: dir, session: { id: `session-${id}`, header: { cwd: dir } } });
81
- const save = (scope, fields) => {
82
- const result = store.upsert(scope, {
83
- applicability: 'all_in_scope',
84
- body: `正文:${fields.title}`,
85
- summary: fields.summary ?? `${fields.title}的摘要`,
86
- ...fields,
87
- });
88
- return { ...result, id: result.record?.id ?? result.id };
89
- };
90
-
91
- /** 有效期这类时间不能靠等待制造:直接改热状态,并同步重算校验和。 */
92
- const patchState = (scope, id, patch) => {
93
- const file = path.join(scope.dir, 'state.json');
94
- const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
95
- const entry = raw.payload.entries.find((row) => row.id === id);
96
- assert.ok(entry, `state 里没有 ${id}`);
97
- for (const [key, value] of Object.entries(patch)) {
98
- entry[key] = value && typeof value === 'object' && !Array.isArray(value)
99
- ? { ...(entry[key] || {}), ...value } : value;
100
- }
101
- raw.checksum = `sha256:${crypto.createHash('sha256').update(JSON.stringify(raw.payload)).digest('hex')}`;
102
- fs.writeFileSync(file, `${JSON.stringify(raw, null, 2)}\n`);
103
- };
104
-
105
- const cwdA = mkdir('a');
106
- const scopeA = store.scopeOf(cwdA);
107
- const global = store.globalScope();
108
-
109
- const hotReload = save(scopeA, { title: '插件改完先试热刷新', tags: ['插件', '热刷新'], summary: '改完先热刷新,覆盖不到再选生效方式' });
110
- const installPath = save(scopeA, { title: '插件安装位置', tags: ['插件', '安装'], summary: '装上后文件落在用户目录的插件包里' });
111
- const crash = save(scopeA, { title: '插件刷新崩溃排查', tags: ['插件', '崩溃'], summary: '先看日志末尾有没有 reload 行' });
112
- const publish = save(scopeA, { title: '发布前跑文案检查', tags: ['发布'], summary: '交付前把界面文案过一遍 lint' });
113
- const unrelated = save(scopeA, { title: '本地视频侧边栏播放', tags: ['视频'], summary: '本地文件也要能在侧边栏播' });
114
- const linuxOnly = save(scopeA, {
115
- title: '插件在 Linux CI 里构建',
116
- tags: ['插件', '构建'],
117
- when: '',
118
- applicability: 'conditional',
119
- conditions: [{ key: 'machine', value: '别的机器' }],
120
- summary: '只在另一台机器上跑构建检查',
121
- });
122
- const unresolved = save(scopeA, { title: '发布前要检查什么', tags: ['发布', '检查'], when: '发布前', applicability: 'unresolved' });
123
- const globalHot = save(global, { title: '插件改动后先热刷新', tags: ['插件', '热刷新'], summary: '全局规矩:先热刷新再谈重启' });
124
-
125
- /** 预算用:6 条都命中「发布」,单卡够大,只能装下少数几张。 */
126
- const longEntries = [];
127
- const longParts = ['文案', '图标', '包体', '权限', '链接', '版本'];
128
- for (let i = 0; i < 6; i += 1) {
129
- longEntries.push(save(scopeA, {
130
- title: `发布流程${longParts[i]}检查${'长'.repeat(35)}`,
131
- summary: `这一步的说明比较长,${'细'.repeat(50)}`,
132
- tags: ['发布', longParts[i]],
133
- resolution: 'distinct',
134
- }));
135
- }
136
- const bigBodies = [];
137
- for (let i = 0; i < 9; i += 1) {
138
- bigBodies.push(save(scopeA, {
139
- title: `大正文样本${i + 1}`,
140
- summary: '读取预算测试',
141
- tags: ['大正文'],
142
- resolution: 'distinct',
143
- body: `样本${i + 1}:${'正文内容'.repeat(80)}`,
144
- }));
145
- }
146
-
147
- const bootA = boot('adaptive');
148
-
149
- // A01/A02:真人的第一句请求先准备,准备好之前 provider 是空的。
150
- const agentA = makeAgent('agent-a', cwdA);
151
- const signalA = signal();
152
- assert.equal(bootA.provider.text({ agent: agentA, signal: signalA }), '');
153
- const decisionA = enter(human('插件改完怎么让它生效?'));
154
- await bootA.preStep({ agent: agentA, signal: signalA, turn: 't1' }, decisionA);
155
- const preparedText = bootA.provider.text({ agent: agentA, signal: signalA });
156
- assert.ok(preparedText.startsWith(HEADER), 'recall text starts with header');
157
- assert.match(preparedText, /插件改完先试热刷新/);
158
- assert.match(preparedText, CARD_LINE_M);
159
- assert.ok(Buffer.byteLength(preparedText) <= 1200, 'snapshot stays inside card budget');
160
- const cards = bootA.provider.text({ agent: agentA, signal: signalA }).split('\n').slice(1);
161
- assert.ok(cards.length <= 3);
162
- for (const line of cards) assert.match(line, CARD_LINE);
163
- assert.doesNotMatch(preparedText, /第 .* 版/);
164
- assert.equal(bootA.provider.text({ agent: agentA, signal: signalA }), preparedText, 'frozen between calls');
165
- /** 到期在输出前复核:投影之后过期的那条不再发给模型。 */
166
- const expiring = bootA.debug.preparedOf(agentA);
167
- assert.ok(expiring.lines.length >= 1, 'prepared cards carry their own deadline');
168
- const firstLine = expiring.lines[0].line;
169
- expiring.lines[0].validUntil = new Date(Date.now() - 1000).toISOString();
170
- const afterExpiry = bootA.provider.text({ agent: agentA, signal: signalA });
171
- assert.ok(!afterExpiry.includes(firstLine), 'expired card withheld at emit');
172
- assert.match(afterExpiry, /^已有记忆可能相关/);
173
- expiring.lines[0].validUntil = '';
174
- assert.equal(bootA.provider.text({ agent: agentA, signal: signalA }), preparedText, 'unexpired card still delivered');
175
-
176
- // A02b:同一会话里只说「继续」时沿用上一句的查询,短追问也能接上刚才那条记忆。
177
- const contAgent = makeAgent('agent-cont', cwdA);
178
- const contSignal = signal();
179
- await bootA.preStep({ agent: contAgent, signal: contSignal, turn: 'c1' }, enter(human('发布前文案检查要怎么过?')));
180
- const contFirst = bootA.debug.preparedOf(contAgent);
181
- assert.ok(contFirst && contFirst.cards.length, 'first turn recalls the publish rule');
182
- assert.match(contFirst.query, /发布前文案检查/);
183
- const contSignal2 = signal();
184
- await bootA.preStep({ agent: contAgent, signal: contSignal2, turn: 'c2' }, enter(human('继续')));
185
- const contNext = bootA.debug.preparedOf(contAgent);
186
- assert.ok(contNext, 'short continuation still prepares a snapshot');
187
- assert.match(contNext.query, /^继续 /);
188
- assert.match(contNext.query, /发布前文案检查/, 'continuation carries the previous query');
189
- assert.ok(contNext.cards.length, 'continuation still resolves cards');
190
- assert.ok(contNext.cards.some((card) => /文案检查/.test(card.title || card.line || '')), 'same topic comes back');
191
-
192
- // A03:provider 纯内存,不写盘、不动 hits。
193
- const statePath = path.join(scopeA.dir, 'state.json');
194
- const before = fs.readFileSync(statePath);
195
- for (let i = 0; i < 20; i += 1) bootA.provider.text({ agent: agentA, signal: signalA });
196
- assert.deepEqual(fs.readFileSync(statePath), before);
197
- const storedHits = store.loadIndex(scopeA).entries.reduce((sum, item) => sum + (item.hits || 0), 0);
198
- assert.equal(storedHits, 0);
199
-
200
- // A04:不同 Agent/会话各自准备,不串卡片。
201
- const cwdB = mkdir('b');
202
- const scopeB = store.scopeOf(cwdB);
203
- const video = save(scopeB, { title: '本地视频在侧边栏播放', tags: ['视频', '播放'], summary: '不受会话目录限制' });
204
- const agentB = makeAgent('agent-b', cwdB);
205
- const signalB = signal();
206
- await bootA.preStep({ agent: agentB, signal: signalB, turn: 't1' }, enter(human('本地视频在侧边栏怎么播放?')));
207
- const textB = bootA.provider.text({ agent: agentB, signal: signalB });
208
- assert.match(textB, new RegExp(video.title));
209
- assert.doesNotMatch(textB, /插件改完先试热刷新/);
210
- assert.equal(bootA.provider.text({ agent: agentA, signal: signalA }), preparedText, 'agent A untouched');
211
- assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), preparedText);
212
-
213
- // A05:取消/超时/迟到结果都不能覆盖新请求。
214
- {
215
- const agent = makeAgent('agent-abort', cwdA);
216
- const ctrl = new AbortController();
217
- await bootA.preStep({ agent, signal: ctrl.signal, turn: 't1' }, enter(human('插件改完怎么让它生效?')));
218
- assert.ok(bootA.provider.text({ agent, signal: ctrl.signal }));
219
- ctrl.abort();
220
- assert.equal(bootA.provider.text({ agent, signal: ctrl.signal }), '');
221
-
222
- const staleAgent = makeAgent('agent-stale', cwdA);
223
- const slower = new Promise((resolve) => setTimeout(() => resolve(enter(human('插件改完怎么让它生效?'))), 30));
224
- const stale = bootA.preStep({ agent: staleAgent, signal: signal(), turn: 't1' }, await slower);
225
- const newerSignal = signal();
226
- await bootA.preStep({ agent: staleAgent, signal: newerSignal, turn: 't2' }, enter(human('本地视频侧边栏播放')));
227
- await stale;
228
- const state = bootA.debug.preparedOf(staleAgent);
229
- assert.ok(state, 'newer request stays prepared');
230
- assert.match(state.query, /本地视频/);
231
- }
232
-
233
- // A06:模式策略;关掉时不准备、也不返回内容。
234
- {
235
- const toolOnly = boot('tool_only');
236
- const agent = makeAgent('agent-tool', cwdA);
237
- const toolSig = await prepare(toolOnly, agent, '插件改完怎么让它生效?');
238
- assert.equal(toolOnly.provider.text({ agent, signal: toolSig }), '');
239
- const off = boot('off');
240
- const agentOff = makeAgent('agent-off', cwdA);
241
- const offSig = await prepare(off, agentOff, '插件改完怎么让它生效?');
242
- assert.equal(off.provider.text({ agent: agentOff, signal: offSig }), '');
243
- const disabled = await off.exec('memory_search', { query: '插件' }, agentOff);
244
- assert.equal(disabled.status, 'memory_disabled');
245
- assert.equal(off.render('memory_search', {}, disabled), 'memory · 记忆检索已关闭');
246
- const readDisabled = await off.exec('memory_read', { id: hotReload.id }, agentOff);
247
- assert.equal(readDisabled.status, 'memory_disabled');
248
- }
249
-
250
- // A07:合成消息不算真人请求,不准备也不开窗。
251
- {
252
- const agent = makeAgent('agent-synth', cwdA);
253
- const webSig = signal();
254
- await bootA.preStep({ agent, signal: webSig, turn: 't1' }, enter(synthetic('web', '插件改完怎么让它生效?做一个网页搜索')));
255
- assert.equal(bootA.provider.text({ agent, signal: webSig }), '');
256
- const goalSig = signal();
257
- await bootA.preStep({ agent, signal: goalSig, turn: 't2' }, enter(synthetic('goal', '插件改完怎么让它生效')));
258
- assert.equal(bootA.provider.text({ agent, signal: goalSig }), '');
259
- assert.equal(bootA.debug.windowOf(agent), null);
260
- }
261
-
262
- // A08:真的无关时一个字都不注入。
263
- {
264
- const agent = makeAgent('agent-unrelated', cwdA);
265
- const sig = await prepare(bootA, agent, '周末去山里走走');
266
- assert.equal(bootA.provider.text({ agent, signal: sig }), '');
267
- }
268
-
269
- // A09:条件不匹配、适用范围没解决的条目绝不自动召回。
270
- {
271
- const cwdC = mkdir('c');
272
- const scopeC = store.scopeOf(cwdC);
273
- save(scopeC, {
274
- title: '只在另一台机器上跑构建检查',
275
- tags: ['构建'],
276
- applicability: 'conditional',
277
- conditions: [{ key: 'machine', value: '别的机器' }],
278
- });
279
- save(scopeC, { title: '发布前检查清单', tags: ['发布'], when: '发布前', applicability: 'unresolved' });
280
- const agent = makeAgent('agent-cond', cwdC);
281
- const sig = await prepare(bootA, agent, '发布前检查清单和 Linux 构建怎么做');
282
- assert.equal(bootA.provider.text({ agent, signal: sig }), '');
283
- assert.notEqual(linuxOnly.id, unresolved.id);
284
- }
285
-
286
- // A10:整卡装预算;装不下就整卡省略,不截断半个标题。
287
- {
288
- const agent = makeAgent('agent-budget', cwdA);
289
- const sig = await prepare(bootA, agent, '发布流程每一步要检查什么?');
290
- const snapshot = bootA.provider.text({ agent, signal: sig });
291
- assert.ok(snapshot.length > 0);
292
- assert.ok(Buffer.byteLength(snapshot) <= 1200);
293
- const lines = snapshot.split('\n').slice(1);
294
- assert.ok(lines.length >= 1 && lines.length <= 3);
295
- for (const line of lines) {
296
- assert.match(line, CARD_LINE);
297
- assert.ok(line.trimEnd().endsWith('版') || line.includes('|'), 'card is not cut mid-title');
298
- const title = line.match(/「(.+?)」/)?.[1] ?? '';
299
- assert.ok(title.length > 0);
300
- if (!longEntries.some((item) => item.record?.title === title) && !/插件|发布前跑/.test(title)) {
301
- throw new Error(`unexpected card: ${line}`);
302
- }
303
- }
304
- }
305
-
306
- // A11/A12/A13:读正文共用 8192 B / 3 条正文 / 8 次处理,重复读只回短句柄。
307
- {
308
- const agent = makeAgent('agent-ledger', cwdA);
309
- await bootA.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('大正文样本怎么用')));
310
- const first = await bootA.exec('memory_read', { id: bigBodies[0].id }, agent);
311
- assert.equal(first.status, 'ok');
312
- assert.ok(Buffer.byteLength(first.body) > 900);
313
- const repeat = await bootA.exec('memory_read', { id: bigBodies[0].id }, agent);
314
- assert.equal(repeat.repeat, true);
315
- assert.equal(repeat.body, '');
316
- assert.match(bootA.render('memory_read', {}, repeat), /本请求已读过/);
317
- const second = await bootA.exec('memory_read', { id: bigBodies[1].id }, agent);
318
- assert.equal(second.status, 'ok');
319
- const third = await bootA.exec('memory_read', { id: bigBodies[2].id }, agent);
320
- assert.equal(third.status, 'ok');
321
- const fourth = await bootA.exec('memory_read', { id: bigBodies[3].id }, agent);
322
- assert.equal(fourth.status, 'budget_exhausted');
323
- assert.equal(fourth.body || '', '');
324
- assert.equal(bootA.render('memory_read', {}, fourth), 'memory · 这条消息已停止读记忆,下一条消息再读');
325
- const win = bootA.debug.windowOf(agent);
326
- assert.ok(win.bytes <= 8192);
327
- assert.equal(win.bodies, 3);
328
-
329
- const agentReads = makeAgent('agent-reads', cwdA);
330
- await bootA.preStep({ agent: agentReads, signal: signal(), turn: 't1' }, enter(human('大正文样本怎么用')));
331
- const firstRead = await bootA.exec('memory_read', { id: bigBodies[3].id }, agentReads);
332
- assert.equal(firstRead.status, 'ok');
333
- for (let i = 0; i < 7; i += 1) {
334
- const row = await bootA.exec('memory_read', { id: bigBodies[3].id }, agentReads);
335
- assert.equal(row.repeat, true, `repeat ${i + 1}`);
336
- }
337
- const capped = await bootA.exec('memory_read', { id: bigBodies[3].id }, agentReads);
338
- assert.equal(capped.status, 'budget_exhausted');
339
- assert.equal(capped.title, undefined);
340
- assert.equal(bootA.debug.windowOf(agentReads).reads, 8);
341
- }
342
-
343
- // A11b:准备阶段不算使用;只有真正交付给模型的卡片才更新 lastUsed。
344
- {
345
- const cwdUse = mkdir('delivered');
346
- const scopeUse = store.scopeOf(cwdUse);
347
- const only = save(scopeUse, { title: '交付才算用过的记忆', tags: ['交付测试'], summary: '只看不交付不该改变使用时间' });
348
- const agent = makeAgent('agent-delivered', cwdUse);
349
- const runtime = boot();
350
- const sig = signal();
351
- patchState(scopeUse, only.record.id, { maintenance: { lastUsed: '2020-01-01T00:00:00.000Z' } });
352
- const saved = store.readEntry(scopeUse, only.record.id, { usage: () => false });
353
- assert.equal(saved.lastUsed, '2020-01-01T00:00:00.000Z', 'patch 生效');
354
- await runtime.preStep({ agent, signal: sig, turn: 'u1' }, enter(human('交付测试怎么做')));
355
- const preparedOnly = store.readEntry(scopeUse, only.record.id, { usage: () => false });
356
- assert.equal(preparedOnly.lastUsed, saved.lastUsed, '准备但没输出时不能改 lastUsed');
357
- assert.match(runtime.provider.text({ agent, signal: sig }), /交付才算用过的记忆/);
358
- const afterProvider = store.readEntry(scopeUse, only.record.id, { usage: () => false });
359
- assert.equal(afterProvider.lastUsed, saved.lastUsed, 'provider 输出路径不写使用时间');
360
- runtime.handlers.get('agent/turn-stopping').at(-1)({ turn: 'u1', signal: sig });
361
- const delivered = store.readEntry(scopeUse, only.record.id, { usage: () => false });
362
- assert.notEqual(delivered.lastUsed, saved.lastUsed, '回合收尾只给真正交付的卡片记使用');
363
- }
364
-
365
- // A12:额度状态与冻结快照分开;清掉快照后重准备不重新分配。
366
- {
367
- const agent = makeAgent('agent-preserve', cwdA);
368
- const sig = signal();
369
- await bootA.preStep({ agent, signal: sig, turn: 't1' }, enter(human('插件改完怎么让它生效?')));
370
- const win = bootA.debug.windowOf(agent);
371
- const used = win.bytes;
372
- assert.ok(used > 0);
373
- const turnEnd = bootA.handlers.get('agent/turn-stopping').at(-1);
374
- turnEnd({ turn: 't1', signal: sig });
375
- assert.equal(bootA.provider.text({ agent, signal: sig }), '');
376
- await bootA.preStep({ agent, signal: sig, turn: 't2' }, enter(human('插件改完怎么让它生效?再确认一下')));
377
- assert.ok(bootA.provider.text({ agent, signal: sig }).startsWith(HEADER));
378
- assert.equal(win.bytes, used, 'same window does not re-allocate for already provided cards');
379
- }
380
-
381
- // A13b:轮次收尾清的是投影,不是任务;下一句「继续」仍要接上上一轮的原话。
382
- {
383
- const agent = makeAgent('agent-after-turn', cwdA);
384
- const first = signal();
385
- await bootA.preStep({ agent, signal: first, turn: 'n1' }, enter(human('插件改完怎么让它生效?')));
386
- assert.ok(bootA.provider.text({ agent, signal: first }));
387
- bootA.handlers.get('agent/turn-stopping').at(-1)({ turn: 'n1', signal: first });
388
- assert.equal(bootA.provider.text({ agent, signal: first }), '');
389
- const second = signal();
390
- await bootA.preStep({ agent, signal: second, turn: 'n2' }, enter(human('继续')));
391
- assert.match(bootA.provider.text({ agent, signal: second }), /插件改完先试热刷新/, '收尾后「继续」仍属原任务');
392
- }
393
-
394
- // A13c:新任务零召回也照样记下新查询;再说「继续」不能把旧任务的记忆带回来。
395
- {
396
- const agent = makeAgent('agent-zero-task', cwdA);
397
- const first = signal();
398
- await bootA.preStep({ agent, signal: first, turn: 'z1' }, enter(human('插件改完怎么让它生效?')));
399
- bootA.handlers.get('agent/turn-stopping').at(-1)({ turn: 'z1', signal: first });
400
- const second = signal();
401
- await bootA.preStep({ agent, signal: second, turn: 'z2' }, enter(human('帮我算一下 17 乘以 23 等于多少')));
402
- bootA.handlers.get('agent/turn-stopping').at(-1)({ turn: 'z2', signal: second });
403
- const third = signal();
404
- await bootA.preStep({ agent, signal: third, turn: 'z3' }, enter(human('继续')));
405
- assert.doesNotMatch(bootA.provider.text({ agent, signal: third }), /插件改完先试热刷新/, '零召回的新任务不能被旧任务续上');
406
- }
407
-
408
- // A13d:工具续步没有新的真人原话,刚执行的动作可以换来一次有界补查。
409
- {
410
- const cwdStep = mkdir('tool-step');
411
- const scopeStep = store.scopeOf(cwdStep);
412
- save(scopeStep, { title: '记忆插件先看状态再改', tags: ['记忆插件', '排查'], summary: '先看状态文件再动代码' });
413
- save(scopeStep, { title: '改完 client.js 先重载宿主', tags: ['client.js', '重载'], summary: '普通刷新覆盖不到 client.js' });
414
- const agent = makeAgent('agent-tool-step', cwdStep);
415
- const runtime = boot();
416
- const said = human('记忆插件怎么排查');
417
- const first = signal();
418
- await runtime.preStep({ agent, signal: first, turn: 's1' }, enter(said));
419
- assert.match(runtime.provider.text({ agent, signal: first }), /记忆插件先看状态再改/);
420
- const toolStep = {
421
- role: 'assistant',
422
- id: 'asst-tool-step',
423
- content: [
424
- { type: 'text', text: '我把改动写进文件了。' },
425
- { type: 'tool-call', name: 'write', input: { file_path: '/tmp/step/client.js' } },
426
- ],
427
- };
428
- const second = signal();
429
- await runtime.preStep({ agent, signal: second, turn: 's1' }, enter(said, toolStep));
430
- assert.match(runtime.provider.text({ agent, signal: second }), /改完 client\.js 先重载宿主/, '工具动作后补查一次');
431
- }
432
-
433
- // A14:上一轮注入过的文字不会被当成这一轮的来源;只有合成消息的新 turn 不继承。
434
- {
435
- const agent = makeAgent('agent-stale-section', cwdA);
436
- const sig1 = signal();
437
- await bootA.preStep({ agent, signal: sig1, turn: 't1' }, enter(human('插件改完怎么让它生效?')));
438
- assert.ok(bootA.provider.text({ agent, signal: sig1 }));
439
- const sig2 = signal();
440
- await bootA.preStep({ agent, signal: sig2, turn: 't2' }, enter(synthetic('tool', '命令输出:done')));
441
- assert.equal(bootA.provider.text({ agent, signal: sig2 }), '');
442
- }
443
-
444
- // A15:忘掉之后旧快照不复活;下一次请求不再带它。
445
- {
446
- const cwdD = mkdir('d');
447
- const scopeD = store.scopeOf(cwdD);
448
- const doomed = save(scopeD, { title: '插件改完要重启应用', tags: ['插件', '重启'], summary: '旧结论:必须重启' });
449
- const agent = makeAgent('agent-purge', cwdD);
450
- const sig = signal();
451
- await bootA.preStep({ agent, signal: sig, turn: 't1' }, enter(human('插件改完怎么让它生效?')));
452
- assert.match(bootA.provider.text({ agent, signal: sig }), /插件改完要重启应用/);
453
- const purged = await bootA.exec('memory_forget', { title: '插件改完要重启应用', mode: 'purge' }, agent);
454
- assert.equal(purged.mode, 'purged');
455
- assert.match(bootA.provider.text({ agent, signal: sig }), /插件改完要重启应用/, '已提交的旧快照不回头改写');
456
- assert.doesNotMatch(bootA.provider.text({ agent, signal: sig }), /mem_[a-z0-9]{6,}/, '召回卡从不带内部编号');
457
- const sig2 = signal();
458
- await bootA.preStep({ agent, signal: sig2, turn: 't2' }, enter(human('插件改完怎么让它生效?')));
459
- const after = bootA.provider.text({ agent, signal: sig2 });
460
- assert.doesNotMatch(after, new RegExp(doomed.id));
461
- assert.doesNotMatch(after, /插件改完要重启应用/);
462
- }
463
-
464
- // 退出清理:disposed 之后 provider 一律空。
465
- {
466
- const agent = makeAgent('agent-dispose', cwdA);
467
- const sig = signal();
468
- await bootA.preStep({ agent, signal: sig, turn: 't1' }, enter(human('插件改完怎么让它生效?')));
469
- const disposed = bootA.handlers.get('agent/disposed').at(-1);
470
- disposed({ agent });
471
- assert.equal(bootA.provider.text({ agent, signal: sig }), '');
472
- }
473
-
474
- // A16:全局层被彻底删除后,工具续步立刻重投影;冻结的旧卡片不复活,新真人任务重算。
475
- {
476
- const globalNote = save(global, { title: '全局发布前文案核对', tags: ['发布', '核对'], summary: '全局规则:发布前先核对文案' });
477
- const agent = makeAgent('agent-global-purge', cwdA);
478
- const sig = signal();
479
- await bootA.preStep({ agent, signal: sig, turn: 't1' }, enter(human('发布前跑文案检查')));
480
- assert.match(bootA.provider.text({ agent, signal: sig }), /全局发布前文案核对/);
481
- const purged = store.forget(global, globalNote.id, { mode: 'purge' });
482
- assert.equal(purged.mode, 'purged');
483
- /** 同一 turn 的续步没有新真人消息:不重新问人,但盘上变了必须换掉快照。 */
484
- await bootA.preStep({ agent, signal: sig, turn: 't1' }, enter());
485
- const after = bootA.provider.text({ agent, signal: sig });
486
- assert.doesNotMatch(after, /全局发布前文案核对/);
487
- assert.match(after, /发布前跑文案检查/, '项目层卡片仍在');
488
- const sigNext = signal();
489
- await bootA.preStep({ agent, signal: sigNext, turn: 't2' }, enter(human('大正文样本怎么用')));
490
- assert.match(bootA.debug.preparedOf(agent).query, /大正文样本/);
491
- assert.doesNotMatch(bootA.provider.text({ agent, signal: sigNext }), /全局发布前文案核对/);
492
- }
493
-
494
- // A17:工具模式不自动注入,但工具调用同样共享每请求额度。
495
- {
496
- const toolOnly = boot('tool_only');
497
- const agent = makeAgent('agent-tool-budget', cwdA);
498
- await prepare(toolOnly, agent, '大正文样本怎么用');
499
- assert.equal(toolOnly.debug.preparedOf(agent), null);
500
- const win = toolOnly.debug.windowOf(agent);
501
- assert.ok(win, '工具模式也开额度窗口');
502
- let exhausted = null;
503
- for (let i = 0; i < 9 && !exhausted; i += 1) {
504
- const row = await toolOnly.exec('memory_read', { id: bigBodies[i].id }, agent);
505
- if (row.status === 'budget_exhausted') exhausted = row;
506
- }
507
- assert.ok(exhausted, '超过额度后拒绝读正文');
508
- assert.equal(exhausted.id, undefined);
509
- assert.ok(win.bytes <= 8192);
510
- assert.ok(win.bodies <= 3);
511
- assert.ok(win.reads <= 8);
512
- }
513
-
514
- // A21:自动注入把这一轮用得差不多,模型按标题来读正文不该被连带挡掉。
515
- {
516
- const agent = makeAgent('agent-read-after-auto', cwdA);
517
- await prepare(bootA, agent, '大正文样本怎么用');
518
- const win = bootA.debug.windowOf(agent);
519
- assert.ok(win, '自动召回开了额度窗口');
520
- /** 模拟自动注入已经把这轮的 8192 B 用满。 */
521
- win.bytes = 8192;
522
- const row = await bootA.exec('memory_read', { title: '大正文样本1' }, agent);
523
- assert.equal(row.status, 'ok', '自动注入用满之后仍能读正文');
524
- assert.ok(Buffer.byteLength(row.body) > 900);
525
- }
526
-
527
- // A18:归档/旧正文不进正常检索;只有明确查历史才列归档。
528
- {
529
- const cwdE = mkdir('e');
530
- const scopeE = store.scopeOf(cwdE);
531
- const archivedEntry = save(scopeE, { title: '旧端口修正', tags: ['端口'], summary: '端口改到 49661', body: '旧正文:端口是 49661' });
532
- const agent = makeAgent('agent-history', cwdE);
533
- const fresh = await bootA.exec('memory_search', { query: '旧端口修正' }, agent);
534
- assert.equal(fresh.count, 1);
535
- await bootA.exec('memory_forget', { id: archivedEntry.id, mode: 'archive' }, agent);
536
- const hidden = await bootA.exec('memory_search', { query: '旧端口修正' }, agent);
537
- assert.equal(hidden.count, 0, '归档后默认检索不到');
538
- const history = await bootA.exec('memory_search', { query: '旧端口修正', history: true }, agent);
539
- assert.equal(history.count, 1);
540
- assert.equal(history.refs[0].status, 'archived');
541
- const oldBody = save(scopeE, { title: '口端口令', tags: ['端口'], summary: '端口以最新为准', body: '第一版:端口是 5000' });
542
- store.upsert(scopeE, { id: oldBody.id, title: '口端口令', summary: '端口以最新为准', body: '第二版:端口是 49661' });
543
- const current = await bootA.exec('memory_search', { query: '口端口令', history: true }, agent);
544
- assert.ok(current.refs.some((ref) => ref.title === '口端口令' && ref.revision === 2), '检索只给当前版本');
545
- const latest = await bootA.exec('memory_read', { id: oldBody.id }, agent);
546
- assert.match(latest.body, /第二版/);
547
- assert.doesNotMatch(latest.body, /第一版/);
548
- }
549
-
550
- // A19:预算按规范化 JSON 字节算,中文、换行、引号、反斜杠都算进去。
551
- {
552
- const cwdF = mkdir('f');
553
- const scopeF = store.scopeOf(cwdF);
554
- const tricky = store.upsert(scopeF, {
555
- title: '转义预算样本',
556
- summary: '换行与引号',
557
- applicability: 'all_in_scope',
558
- body: '第一行\n第二行 "双引号" \\反斜杠\t制表',
559
- });
560
- const agent = makeAgent('agent-json-budget', cwdF);
561
- await bootA.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('转义预算样本怎么读')));
562
- const win = bootA.debug.windowOf(agent);
563
- const before = win.bytes;
564
- const value = await bootA.exec('memory_read', { id: tricky.record.id }, agent);
565
- assert.equal(value.status, 'ok');
566
- assert.equal(win.bytes - before, Buffer.byteLength(JSON.stringify(value)));
567
- assert.ok(win.bytes <= 8192);
568
- }
569
-
570
- // 生命周期退出默认召回:被取代、过期的条目不再自动注入,也不再进手动检索。
571
- {
572
- const runtime = boot();
573
- const cwdLife = mkdir('lifecycle');
574
- const scopeLife = store.scopeOf(cwdLife);
575
- const oldDeploy = save(scopeLife, {
576
- title: '旧部署目标', summary: '部署到 staging,旧流程。', body: '旧流程只发 staging。', resolution: 'distinct',
577
- });
578
- const newDeploy = save(scopeLife, {
579
- title: '新部署目标', summary: '部署到 production,先跑测试。', body: '新流程先跑测试再发 production。', resolution: 'distinct',
580
- supersedes: oldDeploy.id,
581
- });
582
- const stalePort = save(scopeLife, {
583
- title: '临时调试端口', summary: '本地服务跑在 18080。', body: '旧端口已经不用了。', resolution: 'distinct',
584
- });
585
- patchState(scopeLife, stalePort.id, {
586
- validFrom: new Date(Date.now() - 7200_000).toISOString(),
587
- validUntil: new Date(Date.now() - 3600_000).toISOString(),
588
- });
589
- const agentLife = makeAgent('agent-lifecycle', cwdLife);
590
- const lifeSignal = await prepare(runtime, agentLife, '部署目标怎么走,临时调试端口是多少');
591
- const text = runtime.provider.text({ agent: agentLife, signal: lifeSignal });
592
- assert.match(text, /新部署目标/);
593
- assert.doesNotMatch(text, /旧部署目标|旧流程只发 staging/, '被取代的条目退出自动召回');
594
- assert.doesNotMatch(text, /临时调试端口|旧端口已经不用了/, '过期的条目退出自动召回');
595
- const found = await runtime.exec('memory_search', { query: '部署目标' }, agentLife);
596
- assert.equal(found.refs.some((ref) => ref.title === '旧部署目标'), false);
597
- assert.equal(found.refs.some((ref) => ref.title === '新部署目标'), true);
598
- const port = await runtime.exec('memory_search', { query: '临时调试端口' }, agentLife);
599
- assert.equal(port.refs.some((ref) => ref.title === '临时调试端口'), false, '过期条目也不进默认检索');
600
- }
601
-
602
- // A20:没有命中是正常空结果,不是额度用尽。
603
- {
604
- const agent = makeAgent('agent-empty-search', cwdA);
605
- await bootA.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('完全无关的一串词')));
606
- const value = await bootA.exec('memory_search', { query: '完全不存在的主题 zzzqqq' }, agent);
607
- assert.equal(value.status, 'ok');
608
- assert.equal(value.count, 0);
609
- assert.deepEqual(value.refs, []);
610
- }
611
-
612
- console.log('recall.test.mjs: ok');
613
-
614
- // A20:条件未确认的条目,model 侧读正文只拿状态,不拿标题;面板仍可查。
615
- {
616
- const agent = makeAgent('agent-withheld-read', cwdA);
617
- await prepare(bootA, agent, '发布前要检查什么');
618
- const row = await bootA.exec('memory_read', { id: unresolved.id }, agent);
619
- assert.equal(row.body, '');
620
- assert.equal(row.status, 'needs_context');
621
- assert.doesNotMatch(JSON.stringify(row), /发布前要检查什么/);
622
- assert.doesNotMatch(bootA.render('memory_read', { id: unresolved.id }, row), /发布前要检查什么/);
623
- const links = await bootA.exec('memory_read', { id: unresolved.id, view: 'links' }, agent);
624
- assert.equal(links.status, 'needs_context');
625
- assert.doesNotMatch(JSON.stringify(links), /发布前要检查什么/);
626
- }
627
-
628
- // A24:纯英文查询按整词判断,自动注入和手动搜索必须给同一个结论。
629
- {
630
- const cwdEn = mkdir('en');
631
- const scopeEn = store.scopeOf(cwdEn);
632
- save(scopeEn, { title: 'Digital asset catalog', summary: '图形素材目录' });
633
- const agent = makeAgent('agent-en', cwdEn);
634
- const runtime = boot();
635
- const sig = signal();
636
- await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('git')));
637
- assert.doesNotMatch(runtime.provider.text({ agent, signal: sig }), /Digital asset catalog/);
638
- assert.equal(store.search(scopeEn, 'git', 5).length, 0, '手动搜索也不给这条');
639
- }
640
-
641
- // A25:用户这句话没提动作时,用上一轮助手的话和工具名补一次查询;补不到不盖掉原判断。
642
- {
643
- const cwdAct = mkdir('action');
644
- const scopeAct = store.scopeOf(cwdAct);
645
- save(scopeAct, {
646
- title: '改完 client.js 先重载宿主',
647
- tags: ['client.js', '重载'],
648
- summary: 'client.js 一改就要重载宿主,普通刷新覆盖不到',
649
- });
650
- const agent = makeAgent('agent-action', cwdAct);
651
- const runtime = boot();
652
- const first = signal();
653
- await runtime.preStep({ agent, signal: first, turn: 't1' }, enter(human('这个怎么弄')));
654
- assert.equal(runtime.provider.text({ agent, signal: first }), '', '只说「这个怎么弄」时不该凭空命中');
655
- const assistantMessage = {
656
- role: 'assistant',
657
- id: 'asst-action-1',
658
- content: [
659
- { type: 'text', text: '我把改动写进文件了。' },
660
- { type: 'tool-call', name: 'write', input: { file_path: '/tmp/act/client.js' } },
661
- ],
662
- };
663
- const second = signal();
664
- await runtime.preStep({ agent, signal: second, turn: 't2' }, enter(assistantMessage, human('这个怎么弄')));
665
- const actionText = runtime.provider.text({ agent, signal: second });
666
- assert.match(actionText, /改完 client\.js 先重载宿主/, '动作侧线索要能召回预防型记忆');
667
- const actionWin = runtime.debug.windowOf(agent);
668
- assert.equal(actionWin.autoBudget.totalBytes, Buffer.byteLength(actionText), '最终交付字节与预算账本一致');
669
- assert.equal(actionWin.autoBudget.cards, runtime.debug.preparedOf(agent).cards.length, '最终卡片数与预算账本一致');
670
- }
671
-
672
- // A25b:短续句「继续优化收敛」没有自己的检索词,要沿用上一句,不能断在上一轮。
673
- {
674
- const cwdEllipse = mkdir('ellipse');
675
- const scopeEllipse = store.scopeOf(cwdEllipse);
676
- save(scopeEllipse, {
677
- title: '轻量化记忆与前端展示逻辑先定召回顺序',
678
- tags: ['轻量化记忆', '前端展示逻辑'],
679
- summary: '先定召回顺序,再谈展示;触发机制跟着任务走',
680
- });
681
- const agent = makeAgent('agent-ellipse', cwdEllipse);
682
- const runtime = boot();
683
- const first = signal();
684
- await runtime.preStep({ agent, signal: first, turn: 'e1' }, enter(human('继续优化轻量化记忆与前端展示逻辑 还有触发机制等')));
685
- const second = signal();
686
- await runtime.preStep({ agent, signal: second, turn: 'e2' }, enter(human('继续优化收敛')));
687
- const prepared = runtime.debug.preparedOf(agent);
688
- assert.ok(prepared, '短续句要准备召回');
689
- assert.match(prepared.query, /轻量化记忆与前端展示逻辑/, '短续句沿用上一句原话');
690
- assert.ok(prepared.cards.some((card) => /轻量化记忆/.test(card.title || card.line || '')), '同一话题的记忆要回来');
691
- }
692
-
693
- // A25c:上一轮有动作、这句话只有「这个怎么弄」时,通用卡片不能挡住动作线索的补查。
694
- {
695
- const cwdGeneric = mkdir('generic');
696
- const scopeGeneric = store.scopeOf(cwdGeneric);
697
- save(scopeGeneric, {
698
- title: '这个怎么弄时先读现有文档',
699
- tags: ['怎么弄', '文档'],
700
- summary: '没头绪时先读现有文档再动手',
701
- });
702
- save(scopeGeneric, {
703
- title: '改完 client.js 先重载宿主',
704
- tags: ['client.js', '重载'],
705
- summary: 'client.js 一改就要重载宿主,普通刷新覆盖不到',
706
- });
707
- const agent = makeAgent('agent-generic', cwdGeneric);
708
- const runtime = boot();
709
- const first = signal();
710
- await runtime.preStep({ agent, signal: first, turn: 'g1' }, enter(human('这个怎么弄')));
711
- assert.match(runtime.provider.text({ agent, signal: first }), /先读现有文档/, '第一轮命中通用卡片');
712
- const assistantMessage = {
713
- role: 'assistant',
714
- id: 'asst-generic-1',
715
- content: [
716
- { type: 'text', text: '我把改动写进文件了。' },
717
- { type: 'tool-call', name: 'write', input: { file_path: '/tmp/generic/client.js' } },
718
- ],
719
- };
720
- const second = signal();
721
- await runtime.preStep({ agent, signal: second, turn: 'g2' }, enter(assistantMessage, human('这个怎么弄')));
722
- assert.match(runtime.provider.text({ agent, signal: second }), /改完 client\.js 先重载宿主/, '通用卡片不能挡住动作线索');
723
- }
724
-
725
- // A25d:换到数据库任务后再说「继续」,只承接数据库这一句,不能把前端任务的记忆带回来。
726
- {
727
- const cwdSwitch = mkdir('switch-cont');
728
- const scopeSwitch = store.scopeOf(cwdSwitch);
729
- save(scopeSwitch, {
730
- title: '前端按钮颜色先对齐设计稿 token',
731
- tags: ['前端按钮', '颜色'],
732
- summary: '按钮颜色只取设计稿 token,不要另写十六进制',
733
- });
734
- save(scopeSwitch, {
735
- title: 'PostgreSQL 连接池先查 max_connections',
736
- tags: ['PostgreSQL', '连接池'],
737
- summary: '连接池调大前先看 max_connections 和实际占用',
738
- });
739
- const agent = makeAgent('agent-switch-cont', cwdSwitch);
740
- const runtime = boot();
741
- const first = signal();
742
- await runtime.preStep({ agent, signal: first, turn: 'sc1' }, enter(human('前端按钮颜色怎么改')));
743
- assert.match(runtime.provider.text({ agent, signal: first }), /前端按钮颜色/, '第一轮命中前端任务');
744
- const second = signal();
745
- await runtime.preStep({ agent, signal: second, turn: 'sc2' }, enter(human('改用 PostgreSQL,先看连接池')));
746
- assert.match(runtime.provider.text({ agent, signal: second }), /PostgreSQL 连接池/, '第二轮命中数据库任务');
747
- const third = signal();
748
- await runtime.preStep({ agent, signal: third, turn: 'sc3' }, enter(human('继续')));
749
- const prepared = runtime.debug.preparedOf(agent);
750
- assert.match(prepared.query, /PostgreSQL/, '续句承接的是数据库那一句');
751
- assert.ok(prepared.cards.some((card) => /PostgreSQL/.test(card.title || card.line || '')), '续句仍命中数据库记忆');
752
- assert.ok(!prepared.cards.some((card) => /前端按钮/.test(card.title || card.line || '')), '不把前端任务记忆带回来');
753
- }
754
-
755
- // A25e:认不出任务类型的明确换任务,也不能把上一轮的动作线索带过来。
756
- {
757
- const cwdUnknown = mkdir('unknown-switch');
758
- const scopeUnknown = store.scopeOf(cwdUnknown);
759
- save(scopeUnknown, {
760
- title: '改完 client.js 先重载宿主',
761
- tags: ['client.js', '重载'],
762
- summary: 'client.js 改动后必须重载宿主,普通刷新覆盖不到',
763
- });
764
- save(scopeUnknown, {
765
- title: 'nginx 502 先看 upstream 日志',
766
- tags: ['nginx', '502', 'upstream'],
767
- summary: '502 先查 upstream 是否存活,再看超时配置',
768
- });
769
- const agent = makeAgent('agent-unknown', cwdUnknown);
770
- const runtime = boot();
771
- const first = signal();
772
- await runtime.preStep({ agent, signal: first, turn: 'u1' }, enter(human('把前端按钮颜色调一下')));
773
- const assistantMessage = {
774
- role: 'assistant',
775
- id: 'asst-unknown-1',
776
- content: [
777
- { type: 'text', text: '我把 client.js 的样式改好了。' },
778
- { type: 'tool-call', name: 'write', input: { file_path: '/tmp/unknown/client.js' } },
779
- ],
780
- };
781
- const second = signal();
782
- await runtime.preStep({ agent, signal: second, turn: 'u2' }, enter(assistantMessage, human('先别管这个了,帮我看看 nginx 502')));
783
- const switchedText = runtime.provider.text({ agent, signal: second });
784
- assert.doesNotMatch(switchedText, /改完 client\.js 先重载宿主/, '明确换任务后不带旧动作线索');
785
- assert.match(switchedText, /nginx 502/, '新任务本身能命中');
786
- const third = signal();
787
- await runtime.preStep({ agent, signal: third, turn: 'u3' }, enter(human('继续')));
788
- const continuedText = runtime.provider.text({ agent, signal: third });
789
- assert.match(continuedText, /nginx 502/, '续句承接新任务');
790
- assert.doesNotMatch(continuedText, /改完 client\.js 先重载宿主/, '续句不回旧任务');
791
- }
792
-
793
- // A26:明确换任务后,不能拿上一轮助手的话去补查、把旧任务记忆塞进新任务。
794
- {
795
- const cwdSwitch = mkdir('switch');
796
- const scopeSwitch = store.scopeOf(cwdSwitch);
797
- save(scopeSwitch, {
798
- title: '口播开场先给结论',
799
- tags: ['口播', '开场'],
800
- summary: '口播开场三句内先给结论,别先铺垫',
801
- });
802
- const agent = makeAgent('agent-switch', cwdSwitch);
803
- const runtime = boot();
804
- const first = signal();
805
- await runtime.preStep({ agent, signal: first, turn: 't1' }, enter(human('这期口播开场怎么改')));
806
- const assistantMessage = {
807
- role: 'assistant',
808
- id: 'asst-switch-1',
809
- content: [
810
- { type: 'text', text: '口播开场先给结论,栏目风格保持短句。' },
811
- { type: 'tool-call', name: 'write', input: { file_path: '/tmp/switch/talk.md' } },
812
- ],
813
- };
814
- const second = signal();
815
- await runtime.preStep({ agent, signal: second, turn: 't2' }, enter(assistantMessage, human('开始写 PostgreSQL 迁移脚本,先建表')));
816
- assert.equal(runtime.provider.text({ agent, signal: second }), '', '换任务后不补查旧任务的动作线索');
817
- }
818
-
819
- // A27:npm 原生命令在动作发生前拦一次,把凭据用法直接交给模型;同一轮不重复。
820
- {
821
- const cwdNpm = mkdir('npm-guard');
822
- const scopeNpm = store.scopeOf(cwdNpm);
823
- store.setRecallMode(scopeNpm, 'adaptive', { operationId: 'npm-guard-mode' });
824
- assert.ok(store.upsert(global, {
825
- title: 'npm 发布一律走 dsh-npm,ENEEDAUTH 不是缺凭据',
826
- summary: '发布走 dsh-npm,不要用 npm whoami 判断凭据',
827
- body: '本机 npm 凭据在 Keychain,npm whoami 常报 ENEEDAUTH;发布一律用 dsh-npm publish,不要重新登录。',
828
- tags: ['npm', 'dsh-npm', '凭据'],
829
- applicability: 'all_in_scope',
830
- }).record);
831
- const guards = [];
832
- const agent = makeAgent('agent-npm-guard', cwdNpm);
833
- agent.ctx = {
834
- tools: { guard: (fn) => { guards.push(fn); return () => {}; } },
835
- effect: (fn) => fn(),
836
- };
837
- const runtime = boot();
838
- runtime.handlers.get('agent/created').at(-1)({ agent });
839
- assert.equal(guards.length, 1, '每个 agent 装一个执行前守卫');
840
- const sig = signal();
841
- await runtime.preStep({ agent, signal: sig, turn: 'npm-1' }, enter(human('帮我发布这个包')));
842
- const blocked = guards[0]({ name: 'bash', callId: 'call-npm-1', arguments: { command: 'npm whoami' } });
843
- assert.match(String(blocked), /npm 发布一律走 dsh-npm/, '动作发生前给已有记忆正文');
844
- assert.match(String(blocked), /Keychain/, '给正文而不是只给标题');
845
- assert.equal(guards[0]({ name: 'bash', callId: 'call-npm-2', arguments: { command: 'npm publish' } }), undefined, '同一轮只提示一次');
846
- assert.ok(scopeNpm.key, '项目层不受影响');
847
- }
848
-
849
- // A27b:npm 提醒先看当前项目层,再看全局层;本机流程留在项目层也能在动作前拦人。
850
- {
851
- const cwdNpmProject = mkdir('npm-guard-project');
852
- const scopeNpmProject = store.scopeOf(cwdNpmProject);
853
- store.setRecallMode(scopeNpmProject, 'adaptive', { operationId: 'npm-guard-project-mode' });
854
- assert.ok(store.upsert(global, {
855
- title: 'npm 全局登录提醒',
856
- summary: '全局层的 npm 登录说明',
857
- body: '全局层:npm login 会误导,改用 dsh-npm。',
858
- tags: ['npm'],
859
- applicability: 'all_in_scope',
860
- }).record);
861
- assert.ok(store.upsert(scopeNpmProject, {
862
- title: 'npm 项目发布前先看凭据',
863
- summary: '这个项目的 npm 发布步骤',
864
- body: '项目层:先读 Keychain 位置,再用 dsh-npm publish,不要 npm whoami。',
865
- tags: ['npm', '发布'],
866
- applicability: 'all_in_scope',
867
- }).record);
868
- const guards = [];
869
- const agent = makeAgent('agent-npm-project', cwdNpmProject);
870
- agent.ctx = {
871
- tools: { guard: (fn) => { guards.push(fn); return () => {}; } },
872
- effect: (fn) => fn(),
873
- };
874
- const runtime = boot();
875
- runtime.handlers.get('agent/created').at(-1)({ agent });
876
- const sig = signal();
877
- await runtime.preStep({ agent, signal: sig, turn: 'npm-project-1' }, enter(human('先看下环境状态')));
878
- const blocked = guards[0]({ name: 'bash', callId: 'call-npm-project', arguments: { command: 'npm publish' } });
879
- assert.match(String(blocked), /项目发布前先看凭据/, '项目层规则优先于全局层');
880
- assert.match(String(blocked), /Keychain/, '给项目层正文');
881
- }
882
-
883
- // A28:零命中的同一轮里,关键动作发生后补查一次;补到后不再重复注入。
884
- {
885
- const cwdActionKey = mkdir('action-key');
886
- const scopeActionKey = store.scopeOf(cwdActionKey);
887
- store.setRecallMode(scopeActionKey, 'adaptive', { operationId: 'action-key-mode' });
888
- const agent = makeAgent('agent-action-key', cwdActionKey);
889
- const runtime = boot();
890
- const first = signal();
891
- await runtime.preStep({ agent, signal: first, turn: 'ak1' }, enter(human('这个怎么弄')));
892
- assert.equal(runtime.provider.text({ agent, signal: first }), '', '第一步没有线索时不凭空命中');
893
- const assistantMessage = {
894
- role: 'assistant',
895
- id: 'asst-action-key-1',
896
- content: [
897
- { type: 'text', text: '我先看一下。' },
898
- { type: 'tool-call', name: 'bash', input: { command: 'npm whoami' } },
899
- ],
900
- };
901
- const toolResult = { role: 'user', id: 'tool-action-key-1', source: { kind: 'tool' }, content: [{ type: 'text', text: '已完成' }] };
902
- const second = signal();
903
- await runtime.preStep({ agent, signal: second, turn: 'ak1' }, enter(assistantMessage, toolResult));
904
- const text = runtime.provider.text({ agent, signal: second });
905
- assert.match(text, /npm 发布一律走 dsh-npm/, '关键动作让凭据记忆在动作后补进来');
906
- assert.equal(runtime.debug.preparedOf(agent).actionRetried, true, '一次请求只补查一次');
907
- const actionWin = runtime.debug.windowOf(agent);
908
- assert.equal(actionWin.autoBudget.totalBytes, Buffer.byteLength(text), '补查后的最终字节与账本一致');
909
- assert.equal(actionWin.autoBudget.cards, runtime.debug.preparedOf(agent).cards.length, '补查后的卡片数与账本一致');
910
- const third = signal();
911
- await runtime.preStep({ agent, signal: third, turn: 'ak1' }, enter({
912
- role: 'assistant',
913
- id: 'asst-action-key-2',
914
- content: [{ type: 'tool-call', name: 'bash', input: { command: 'npm publish' } }],
915
- }, { role: 'user', id: 'tool-action-key-2', source: { kind: 'tool' }, content: [{ type: 'text', text: 'ERR' }] }));
916
- assert.equal(runtime.provider.text({ agent, signal: third }), text, '同一请求不重复补查,也不花第二份额度');
917
- }
918
-
919
- // A29:没有「换任务」这类明说,但新话题已经可判定为另一件事时,旧动作线索不能跟过来。
920
- {
921
- const cwdFree = mkdir('free-switch');
922
- const scopeFree = store.scopeOf(cwdFree);
923
- save(scopeFree, {
924
- title: '改完 client.js 先重载宿主',
925
- tags: ['client.js', '重载'],
926
- summary: 'client.js 改动后必须重载宿主,普通刷新覆盖不到',
927
- });
928
- save(scopeFree, {
929
- title: 'PostgreSQL 连接池先查 max_connections',
930
- tags: ['PostgreSQL', '连接池'],
931
- summary: '连接池调大前先看 max_connections 和实际占用',
932
- });
933
- const agent = makeAgent('agent-free-switch', cwdFree);
934
- const runtime = boot();
935
- const first = signal();
936
- await runtime.preStep({ agent, signal: first, turn: 'f1' }, enter(human('前端按钮颜色怎么改')));
937
- const assistantMessage = {
938
- role: 'assistant',
939
- id: 'asst-free-1',
940
- content: [
941
- { type: 'text', text: '我把 client.js 的样式改好了。' },
942
- { type: 'tool-call', name: 'write', input: { file_path: '/tmp/free/client.js' } },
943
- ],
944
- };
945
- const second = signal();
946
- await runtime.preStep({ agent, signal: second, turn: 'f2' }, enter(assistantMessage, human('PostgreSQL 连接池满了怎么排查')));
947
- const text = runtime.provider.text({ agent, signal: second });
948
- assert.match(text, /PostgreSQL 连接池/, '新话题命中自己的记忆');
949
- assert.doesNotMatch(text, /改完 client\.js 先重载宿主/, '旧任务线索不能进新任务');
950
- }
951
-
952
- // A30:只读文件、memory 工具输出不算「关键动作」,不能提前花掉一次补查机会。
953
- {
954
- const cwdRead = mkdir('read-action');
955
- const scopeRead = store.scopeOf(cwdRead);
956
- store.setRecallMode(scopeRead, 'adaptive', { operationId: 'read-action-mode' });
957
- save(scopeRead, {
958
- title: 'npm 发布一律走 dsh-npm,ENEEDAUTH 不是缺凭据',
959
- tags: ['npm', 'dsh-npm', '凭据'],
960
- summary: '发布走 dsh-npm,不要用 npm whoami 判断凭据',
961
- body: '本机 npm 凭据在 Keychain,npm whoami 常报 ENEEDAUTH;发布一律用 dsh-npm publish,不要重新登录。',
962
- });
963
- const agent = makeAgent('agent-read-action', cwdRead);
964
- const runtime = boot();
965
- const first = signal();
966
- await runtime.preStep({ agent, signal: first, turn: 'ra1' }, enter(human('这个怎么弄')));
967
- assert.equal(runtime.provider.text({ agent, signal: first }), '', '第一步没有线索时不凭空命中');
968
- const passiveStep = {
969
- role: 'assistant',
970
- id: 'asst-read-action-1',
971
- content: [
972
- { type: 'text', text: '先看文档和已有记录。' },
973
- { type: 'tool-call', name: 'read', input: { file_path: '/tmp/lite-memory/README.md' } },
974
- { type: 'tool-call', name: 'memory_search', input: { query: 'npm 凭据怎么用' } },
975
- ],
976
- };
977
- const readResult = { role: 'user', id: 'tool-read-action-1', source: { kind: 'tool' }, content: [{ type: 'text', text: '# README 一段普通说明' }] };
978
- const second = signal();
979
- await runtime.preStep({ agent, signal: second, turn: 'ra1' }, enter(passiveStep, readResult));
980
- const heldAfterRead = runtime.debug.preparedOf(agent);
981
- assert.equal(heldAfterRead?.actionRetried, false, '只读操作不触发补查');
982
- assert.equal(runtime.provider.text({ agent, signal: second }), '', '只读操作后仍保持空快照');
983
- const activeStep = {
984
- role: 'assistant',
985
- id: 'asst-read-action-2',
986
- content: [{ type: 'tool-call', name: 'bash', input: { command: 'npm whoami' } }],
987
- };
988
- const activeResult = { role: 'user', id: 'tool-read-action-2', source: { kind: 'tool' }, content: [{ type: 'text', text: '已完成' }] };
989
- const third = signal();
990
- await runtime.preStep({ agent, signal: third, turn: 'ra1' }, enter(activeStep, activeResult));
991
- const text = runtime.provider.text({ agent, signal: third });
992
- assert.match(text, /npm 发布一律走 dsh-npm/, '真正关键动作仍能补查一次');
993
- assert.equal(runtime.debug.preparedOf(agent).actionRetried, true);
994
- const fourth = signal();
995
- await runtime.preStep({ agent, signal: fourth, turn: 'ra1' }, enter({
996
- role: 'assistant', id: 'asst-read-action-3', content: [{ type: 'tool-call', name: 'bash', input: { command: 'npm publish' } }],
997
- }, { role: 'user', id: 'tool-read-action-3', source: { kind: 'tool' }, content: [{ type: 'text', text: 'ERR' }] }));
998
- assert.equal(runtime.provider.text({ agent, signal: fourth }), text, '同一请求不重复补查');
999
- }
1000
-
1001
- // A31:只有真人明确说「存到全局/所有项目」才算跨项目授权,普通「全局变量」不能放行。
1002
- {
1003
- const runtime = boot();
1004
- const asked = runtime.debug.humanAskedGlobal;
1005
- for (const text of ['以后这条存全局', '记到全局记忆里', '所有项目都按这个来', '以后所有项目都这样做']) {
1006
- assert.equal(asked(text), true, text);
1007
- }
1008
- for (const text of ['把全局变量保存一下', '全局搜索这个函数', '这个项目保存一下', '都改完了保存']) {
1009
- assert.equal(asked(text), false, text);
1010
- }
1011
- }
1012
-
1013
- // A32:只有真递到模型眼里的条目才记使用时间;空召回/未命中的回合不能顺手记账。
1014
- {
1015
- const cwd = mkdir('usage');
1016
- const scope = store.scopeOf(cwd);
1017
- const probe = save(scope, { title: '量子纠缠咖啡机', tags: ['记账'], summary: '只有送达才记 lastUsed' });
1018
- const lastUsedOf = () => store.locateByTitle(scope, '量子纠缠咖啡机').record.lastUsed;
1019
- const usedOf = () => store.locateByTitle(scope, '量子纠缠咖啡机').record.used;
1020
- const hitsOf = () => store.locateByTitle(scope, '量子纠缠咖啡机').record.hits;
1021
- assert.equal(usedOf(), false, '新建但没被用过的条目不能算读过');
1022
- const baseline = lastUsedOf();
1023
- const runtime = boot();
1024
- const agent = makeAgent('agent-usage', cwd);
1025
- const missSignal = await prepare(runtime, agent, '完全无关的一串词');
1026
- assert.doesNotMatch(runtime.provider.text({ agent, signal: missSignal }), /量子纠缠咖啡机/);
1027
- runtime.handlers.get('agent/turn-stopping').at(-1)({ signal: missSignal });
1028
- assert.equal(lastUsedOf(), baseline, '没送达的条目不能被记账');
1029
- const hitSignal = await prepare(runtime, agent, '量子纠缠咖啡机', 't2');
1030
- assert.match(runtime.provider.text({ agent, signal: hitSignal }), /量子纠缠咖啡机/);
1031
- assert.equal(lastUsedOf(), baseline, '送达也不在 provider 路径上写盘');
1032
- runtime.handlers.get('agent/turn-stopping').at(-1)({ signal: hitSignal });
1033
- assert.equal(usedOf(), true, '送达并记账后「读过」标记跟着成立');
1034
- assert.equal(hitsOf(), 1, '送达一次记一次使用次数');
1035
- const id = store.locateByTitle(scope, '量子纠缠咖啡机').record.id;
1036
- store.markUsed(scope, [id]);
1037
- assert.equal(hitsOf(), 2, '再次记账继续累加,不受 lastUsed 防抖影响');
1038
- assert.equal(store.locateByTitle(scope, '量子纠缠咖啡机').record.id, probe.id);
1039
- }