dsh-lite-memory 0.11.1 → 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.
@@ -0,0 +1,278 @@
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-control-'));
7
+ const { apply } = await import('../index.js');
8
+ const store = await import('../store.js');
9
+ const state = await import('../state.js');
10
+
11
+ const effect = (fn) => {
12
+ const dispose = fn();
13
+ return typeof dispose === 'function' ? dispose : () => {};
14
+ };
15
+
16
+ function boot() {
17
+ const defs = new Map();
18
+ const handlers = new Map();
19
+ const promptContexts = [];
20
+ const ctx = {
21
+ tools: { register: (def) => { defs.set(def.name, def); return () => {}; } },
22
+ effect,
23
+ inject: () => () => {},
24
+ get: (name) => (name === 'systemPrompt'
25
+ ? { context: (entry) => { promptContexts.push(entry); return () => {}; } }
26
+ : name === 'agents' ? { currentInitiator: () => null } : undefined),
27
+ on: (event, handler) => {
28
+ const list = handlers.get(event) || [];
29
+ list.push(handler);
30
+ handlers.set(event, list);
31
+ return () => {};
32
+ },
33
+ };
34
+ apply(ctx, { mode: 'adaptive' });
35
+ const provider = promptContexts.find((entry) => entry.name === 'lite-memory:recall');
36
+ const exec = (name, args, agent) => defs.get(name).execute(args, { agent });
37
+ const preStep = (payload, decision) => handlers.get('agent/pre-step').at(-1)(payload, async () => decision);
38
+ return { exec, preStep, text: (agent, signal) => provider.text({ agent, signal }), debug: globalThis.__liteMemoryRecall };
39
+ }
40
+
41
+ let seq = 0;
42
+ const human = (body) => { seq += 1; return { role: 'user', id: `msg-${seq}`, source: { kind: 'user' }, content: [{ type: 'text', text: body }] }; };
43
+ const enter = (...messages) => ({ kind: 'enter', messages });
44
+ const mkdir = (label) => fs.mkdtempSync(path.join(os.tmpdir(), `lite-memory-control-${label}-`));
45
+ const makeAgent = (id, dir) => ({ id, cwd: dir, session: { id: `session-${id}`, header: { cwd: dir } } });
46
+ const save = (scope, fields) => store.upsert(scope, {
47
+ applicability: 'all_in_scope',
48
+ body: `正文:${fields.title}`,
49
+ resolution: 'distinct',
50
+ ...fields,
51
+ summary: fields.summary ?? `${fields.title}的摘要`,
52
+ });
53
+ const seedRule = (scope, title) => {
54
+ const saved = save(scope, { title, tags: ['口播'] });
55
+ const id = saved.record?.id ?? saved.id;
56
+ state.transact(scope, {}, (draft) => {
57
+ const row = draft.entries.find((entry) => entry.id === id);
58
+ row.origin = 'project_evolution';
59
+ row.current.conditions = [{ key: 'seriesId', value: '口播' }];
60
+ row.maintenance.learning = { state: 'active' };
61
+ return { changed: true };
62
+ });
63
+ return id;
64
+ };
65
+ const scopeOf = (dir) => store.scopeOf(dir);
66
+ const toggle = (scope, enabled) => store.setMemoryEnabled(scope, enabled, { operationId: `op-${Math.random().toString(16).slice(2)}` });
67
+
68
+ /* C01:总开关关闭后,自动注入、全局召回、检索、读取、写入、学习全部停。 */
69
+ {
70
+ const dir = mkdir('off');
71
+ const scope = scopeOf(dir);
72
+ const globalSaved = save(store.globalScope(), { title: '全局写作偏好', tags: ['写作'] });
73
+ const globalId = globalSaved.record?.id ?? globalSaved.id;
74
+ save(scope, { title: '项目构建约定', tags: ['构建'] });
75
+ seedRule(scope, '口播开头');
76
+ assert.equal(store.setEvolution(scope, true, { operationId: 'evo-on' }).ok, true);
77
+ assert.equal(toggle(scope, false).ok, true);
78
+ assert.equal(store.controlStatus(scope).enabled, false);
79
+
80
+ const runtime = boot();
81
+ const agent = makeAgent('a-off', dir);
82
+ const sig = new AbortController().signal;
83
+ await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('按全局写作偏好来修构建脚本')));
84
+ assert.equal(runtime.text(agent, sig), '');
85
+ assert.equal(runtime.debug.preparedOf(agent), null);
86
+
87
+ const found = await runtime.exec('memory_search', { query: '写作 构建 口播开头' }, agent);
88
+ assert.equal(found.status, 'memory_disabled');
89
+ assert.equal(found.count, 0);
90
+ assert.ok(globalId);
91
+ assert.equal((await runtime.exec('memory_read', { id: globalId }, agent)).status, 'memory_disabled');
92
+ assert.equal((await runtime.exec('memory_forget', { id: globalId, mode: 'archive' }, agent)).mode, 'memory_disabled');
93
+ const suggested = await runtime.exec('memory_suggest', { title: '新偏好', summary: '一条偏好' }, agent);
94
+ assert.equal(suggested.asked, false);
95
+ const saved = await runtime.exec('memory_save', { title: '关闭后写入', summary: '应当拒绝', body: '不该落盘' }, agent);
96
+ assert.equal(saved.changed, false);
97
+ assert.equal(saved.reason, 'memory_disabled');
98
+ assert.equal(saved.id, '');
99
+ }
100
+
101
+ /* C02:关闭期间所有写入入口都拒绝,自进化不能绕过总开关打开。 */
102
+ {
103
+ const dir = mkdir('write');
104
+ const scope = scopeOf(dir);
105
+ assert.equal(toggle(scope, false).ok, true);
106
+ assert.equal(store.setEvolution(scope, true, { operationId: 'evo-blocked' }).ok, false);
107
+ const saved = store.saveEntry(scope, { title: '一条项目规范', summary: '普通摘要', body: '正文', kind: 'convention', scope: 'project', durability: 'durable' });
108
+ assert.equal(saved.reasonCode, 'memory_disabled');
109
+ const task = store.saveTaskState(scope, { version: 1, sessions: {}, episodes: {}, defaults: null, revision: 1 }, { operationId: 'task-blocked' });
110
+ assert.equal(task.ok, false);
111
+ const committed = store.commitLearning(scope, { title: '学习规则', summary: '摘要', body: '正文' }, {}, { operationId: 'learn-blocked' });
112
+ assert.equal(committed.ok, false);
113
+ const run = store.setEvolution(scope, false, { operationId: 'evo-off' });
114
+ assert.equal(run.ok, false);
115
+ assert.equal(run.code, 'memory_disabled');
116
+ assert.equal(store.controlStatus(scope).enabled, false);
117
+ }
118
+
119
+ /* C03:关一个项目不影响另一个;重新打开记忆后自进化仍保持关闭。 */
120
+ {
121
+ const dirA = mkdir('iso-a');
122
+ const dirB = mkdir('iso-b');
123
+ const scopeA = scopeOf(dirA);
124
+ const scopeB = scopeOf(dirB);
125
+ save(scopeB, { title: 'B 项目构建约定', tags: ['构建', '脚本'] });
126
+ seedRule(scopeB, '口播开头');
127
+ assert.equal(store.setEvolution(scopeB, true, { operationId: 'evo-b' }).ok, true);
128
+ assert.equal(toggle(scopeA, false).ok, true);
129
+ assert.equal(store.evolutionStatus(scopeB).enabled, true);
130
+ assert.equal(store.controlStatus(scopeB).enabled, true);
131
+
132
+ const runtime = boot();
133
+ const agentB = makeAgent('b-live', dirB);
134
+ const sigB = new AbortController().signal;
135
+ await runtime.preStep({ agent: agentB, signal: sigB, turn: 't1' }, enter(human('按项目构建约定改脚本')));
136
+ assert.match(runtime.text(agentB, sigB), /B 项目构建约定/);
137
+
138
+ toggle(scopeB, false);
139
+ assert.equal(store.evolutionStatus(scopeB).enabled, false);
140
+ assert.equal(store.evolutionStatus(scopeB).configuredEnabled, false);
141
+ toggle(scopeB, true);
142
+ assert.equal(store.evolutionStatus(scopeB).enabled, false);
143
+ assert.equal(store.setEvolution(scopeB, true, { operationId: 'evo-b-again' }).ok, true);
144
+ assert.equal(store.evolutionStatus(scopeB).enabled, true);
145
+ }
146
+
147
+ /* C04:切换总开关让旧任务上下文立即失效,不拿关闭前的绑定继续注入。 */
148
+ {
149
+ const dir = mkdir('stale');
150
+ const scope = scopeOf(dir);
151
+ seedRule(scope, '口播开头');
152
+ assert.equal(store.setEvolution(scope, true, { operationId: 'evo-stale' }).ok, true);
153
+ const runtime = boot();
154
+ const agent = makeAgent('a-stale', dir);
155
+ const sig = new AbortController().signal;
156
+ await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期口播开头怎么改')));
157
+ const held = runtime.debug.windowOf(agent).experiment;
158
+ assert.ok(held && held.contextId);
159
+ toggle(scope, false);
160
+ toggle(scope, true);
161
+ const stale = await runtime.exec('memory_search', { query: '口播开头', contextId: held.contextId }, agent);
162
+ assert.equal(stale.status !== 'ok', true);
163
+ assert.equal((await runtime.exec('memory_search', { query: '口播开头' }, agent)).status, 'ok');
164
+ }
165
+
166
+ /* C05:关闭保留候选、试用、结果与整理回执;重开记忆后自进化仍是关闭。 */
167
+ {
168
+ const dir = mkdir('preserve');
169
+ const scope = scopeOf(dir);
170
+ const id = seedRule(scope, '口播开头');
171
+ assert.equal(store.setEvolution(scope, true, { operationId: 'evo-preserve' }).ok, true);
172
+ const before = store.learningSnapshot(scope);
173
+ assert.equal(before.counts.active, 1);
174
+ assert.equal(toggle(scope, false).ok, true);
175
+ const closed = store.learningSnapshot(scope);
176
+ assert.equal(closed.enabled, false);
177
+ assert.equal(closed.rules.length, before.rules.length);
178
+ assert.equal(closed.counts.active, 1);
179
+ assert.equal(toggle(scope, true).ok, true);
180
+ const reopened = store.learningSnapshot(scope);
181
+ assert.equal(reopened.enabled, false);
182
+ assert.equal(reopened.counts.active, 1);
183
+ assert.equal(store.evolutionStatus(scope).configuredEnabled, false);
184
+ assert.equal(store.setEvolution(scope, true, { operationId: 'evo-resume' }).ok, true);
185
+ assert.equal(store.learningSnapshot(scope).counts.active, 1);
186
+ assert.ok(id);
187
+ }
188
+
189
+ /* C06:关闭期间管理面写操作一律拒绝,只读快照不写盘。 */
190
+ {
191
+ const dir = mkdir('managed-off');
192
+ const scope = scopeOf(dir);
193
+ const id = seedRule(scope, '口播开头');
194
+ assert.equal(store.setEvolution(scope, true, { operationId: 'evo-managed' }).ok, true);
195
+ toggle(scope, false);
196
+ const file = state.stateFiles(scope).current;
197
+ const before = fs.readFileSync(file);
198
+ const snapshot = store.learningSnapshot(scope);
199
+ const rule = snapshot.rules[0];
200
+ assert.equal(rule.id, id);
201
+ assert.equal(Buffer.compare(before, fs.readFileSync(file)), 0);
202
+ for (const action of ['pin', 'pause', 'resume', 'delete', 'undo', 'adopt', 'edit']) {
203
+ let result;
204
+ if (action === 'pin') result = store.pinLearningRule(scope, id, { pinned: true, expectedRevision: rule.revision });
205
+ if (action === 'pause') result = store.pauseLearningRules(scope, [id], { operationId: 'pause-off' });
206
+ if (action === 'resume') result = store.resumeLearningRule(scope, id);
207
+ if (action === 'delete') result = store.removeLearningRule(scope, id, { expectedRevision: rule.revision });
208
+ if (action === 'undo') result = store.undoLearningChange(scope, id, { expectedRevision: rule.revision });
209
+ if (action === 'adopt') result = store.adoptLearningRule(scope, id, { expectedRevision: rule.revision });
210
+ if (action === 'edit') result = store.editLearningRule(scope, id, { expectedRevision: rule.revision, body: '改过的正文' });
211
+ assert.equal(result.ok, false, action);
212
+ assert.equal(result.code, 'memory_disabled', action);
213
+ }
214
+ assert.equal(Buffer.compare(before, fs.readFileSync(file)), 0);
215
+ }
216
+
217
+ /* C07:采纳把候选转为生效;编辑只保存并留上一版,不改状态以外的东西。 */
218
+ {
219
+ const dir = mkdir('adopt');
220
+ const scope = scopeOf(dir);
221
+ const id = seedRule(scope, '口播开头');
222
+ const first = store.learningSnapshot(scope).rules[0];
223
+ const edited = store.editLearningRule(scope, id, { expectedRevision: first.revision, body: '开头两句内给结论。', title: '口播结论前置' });
224
+ assert.equal(edited.ok, true);
225
+ const afterEdit = store.learningSnapshot(scope).rules[0];
226
+ assert.equal(afterEdit.state, 'trial');
227
+ assert.equal(afterEdit.body, '开头两句内给结论。');
228
+ assert.equal(afterEdit.title, '口播结论前置');
229
+ assert.equal(afterEdit.learning.versions.length, 1);
230
+ const adopted = store.adoptLearningRule(scope, id, { expectedRevision: afterEdit.revision });
231
+ assert.equal(adopted.ok, true);
232
+ const afterAdopt = store.learningSnapshot(scope).rules[0];
233
+ assert.equal(afterAdopt.state, 'active');
234
+ assert.equal(afterAdopt.body, '开头两句内给结论。');
235
+ assert.equal(afterAdopt.title, '口播结论前置');
236
+ }
237
+
238
+ /* C08:关闭保留已消耗额度与默认绑定,重开后仍按原额度限额。 */
239
+ {
240
+ const dir = mkdir('preserve-quota');
241
+ const scope = scopeOf(dir);
242
+ assert.equal(store.setEvolution(scope, true, { operationId: 'evo-quota' }).ok, true);
243
+ const defaults = { taskKind: 'video', seriesId: '口播' };
244
+ assert.equal(store.saveTaskState(scope, { version: 1, sessions: {}, episodes: {}, defaults, revision: 1 }, { operationId: 'defaults' }).ok, true);
245
+ assert.equal(store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a1' }).ok, true);
246
+ assert.equal(store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a2' }).ok, true);
247
+ assert.equal(store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a3' }).ok, false);
248
+ toggle(scope, false);
249
+ toggle(scope, true);
250
+ assert.deepEqual(store.taskState(scope).defaults, { ...defaults, confirmedBy: 'user', confirmedAt: '' });
251
+ assert.equal(store.setEvolution(scope, true, { operationId: 'evo-quota-again' }).ok, true);
252
+ const limited = store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a4' });
253
+ assert.equal(limited.ok, false);
254
+ assert.equal(limited.reason, 'episode_limit');
255
+ }
256
+
257
+ /* C09:同一项目两个会话都已准备内容,关闭后两边都立即失效。 */
258
+ {
259
+ const dir = mkdir('two-sessions');
260
+ const scope = scopeOf(dir);
261
+ seedRule(scope, '口播开头');
262
+ assert.equal(store.setEvolution(scope, true, { operationId: 'evo-two' }).ok, true);
263
+ const runtime = boot();
264
+ const a1 = makeAgent('two-1', dir);
265
+ const a2 = makeAgent('two-2', dir);
266
+ const sig = new AbortController().signal;
267
+ await runtime.preStep({ agent: a1, signal: sig, turn: 't1' }, enter(human('这期口播开头怎么改')));
268
+ await runtime.preStep({ agent: a2, signal: sig, turn: 't1' }, enter(human('这期口播开头怎么改')));
269
+ assert.match(runtime.text(a1, sig), /口播开头/);
270
+ assert.match(runtime.text(a2, sig), /口播开头/);
271
+ toggle(scope, false);
272
+ assert.equal(runtime.text(a1, sig), '');
273
+ assert.equal(runtime.text(a2, sig), '');
274
+ assert.equal(runtime.debug.preparedOf(a1).text, '');
275
+ assert.equal(runtime.debug.preparedOf(a2).text, '');
276
+ }
277
+
278
+ console.log('control.test.mjs: all passed');
@@ -102,7 +102,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
102
102
  assert.equal(runtime.debug.windowOf(agent).experiment, null);
103
103
  }
104
104
 
105
- /* L02:开关打开但没有任何栏目,先问类型,不猜。 */
105
+ /* L02:开关打开但没有任何栏目,不注入任何领域问题,也不给空上下文。 */
106
106
  {
107
107
  const dir = mkdir('clarify');
108
108
  const scope = scopeOf(dir);
@@ -112,30 +112,50 @@ const scopeOf = (dir) => store.scopeOf(dir);
112
112
  const sig = signal();
113
113
  await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期怎么开头')));
114
114
  const text = runtime.text(agent, sig);
115
- assert.match(text, /先问用户这期做什么栏目/);
116
- assert.match(text, /contextId=ctx_/);
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);
117
119
  }
118
120
 
119
121
  /* L03:未绑定任务只给栏目索引,不超过三条;短规则不进正文。 */
120
122
  {
121
123
  const dir = mkdir('index');
122
124
  const scope = scopeOf(dir);
123
- seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
124
- seedRule(scope, { title: '口播收尾', body: '结尾只留一个行动。', conditions: [{ key: 'seriesId', value: '口播' }] });
125
+ seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播视频' }] });
126
+ seedRule(scope, { title: '口播收尾', body: '结尾只留一个行动。', conditions: [{ key: 'seriesId', value: '口播视频' }] });
125
127
  seedRule(scope, { title: 'Vlog 节奏', body: '每段不超过二十秒。', conditions: [{ key: 'seriesId', value: 'Vlog' }] });
126
128
  enable(scope);
127
129
  const runtime = boot();
128
130
  const agent = makeAgent('a-index', dir);
129
131
  const sig = signal();
130
- await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期怎么开头')));
132
+ await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('视频开头怎么改')));
131
133
  const text = runtime.text(agent, sig);
132
- assert.match(text, /项目里有这些栏目/);
134
+ assert.match(text, /这些记忆范围可能适用/);
135
+ assert.doesNotMatch(text, /Vlog/);
133
136
  assert.doesNotMatch(text, /开场三句内给结论/);
134
137
  const listed = text.split('\n').filter((line) => line.startsWith('- '));
135
138
  assert.equal(listed.length <= 3, true);
136
139
  assert.equal(Buffer.byteLength(text) <= 600 + 160, true, 'index stays small');
137
140
  }
138
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
+
139
159
  /* L04:项目默认绑定后给规则正文,带 id 与版本。 */
140
160
  {
141
161
  const dir = mkdir('default');
@@ -150,7 +170,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
150
170
  assert.match(text, /项目实验规则/);
151
171
  assert.match(text, /先给结论再解释/);
152
172
  assert.ok(text.includes(id));
153
- assert.match(text, /第 1 版/);
173
+ assert.doesNotMatch(text, /第 .* 版/);
154
174
  }
155
175
 
156
176
  /* L05:长规则只给用途与 memory_read 指针,不把正文塞进上下文。 */
@@ -247,6 +267,22 @@ const scopeOf = (dir) => store.scopeOf(dir);
247
267
  assert.ok(current, 'switch leaves an unbound episode');
248
268
  }
249
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
+
250
286
  /* L10:没有本请求上下文时,普通检索看不到实验来源。 */
251
287
  {
252
288
  const dir = mkdir('nocx');
@@ -390,4 +426,23 @@ const scopeOf = (dir) => store.scopeOf(dir);
390
426
  assert.equal(text.includes('实验规则:发版前先备份'), false);
391
427
  }
392
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
+
393
448
  console.log('evolution-read.test.mjs: all passed');
@@ -180,7 +180,9 @@ test('B01 调用输入不超过 8 KiB 且只有本地引用', () => {
180
180
  ranges: { taskKinds: ['video'], series: ['口播'] },
181
181
  });
182
182
  assert.ok(input.bytes <= 8192, `bytes=${input.bytes}`);
183
- assert.equal(input.rules.length, 6);
183
+ assert.equal(input.rules.length >= 1, true);
184
+ assert.equal(input.rules.length <= 6, true);
185
+ assert.equal(input.prompt.includes(long), false);
184
186
  assert.equal(input.refs.feedback.u0, 'm1');
185
187
  assert.equal(input.refs.rules.r0, 'mem_0');
186
188
  assert.equal(input.messages.length, 1);
@@ -470,7 +472,7 @@ test('P02 两个独立作品后升为已生效,单次只到试用', () => {
470
472
  const version = ruleOf(scope, id).revision;
471
473
  const rules = [{ ref: 'r0', id, revision: version, conditions: [{ key: 'seriesId', value: '口播' }] }];
472
474
  const refs = { feedback: { u0: 'b1' }, rules: { r0: id } };
473
- const check = parsed({ target: 'r0', feedbackType: 'spec_change', currentText: '开头三句内先给结论。', evidence: ['u0'] }, { rules, refs });
475
+ const check = parsed({ target: 'r0', feedbackType: 'spec_change', currentText: NEW_CHANGE.currentText, evidence: ['u0'] }, { rules, refs });
474
476
  const second = store.commitLearning(scope, {
475
477
  operationId: 'learn_promote',
476
478
  epoch: begin(scope, two).epoch,
@@ -0,0 +1,33 @@
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-lexical-'));
7
+ const store = await import('../store.js');
8
+
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-lexical-scope-'));
10
+ const scope = store.scopeOf(dir);
11
+ const add = (title, extra = {}) => store.upsert(scope, {
12
+ applicability: 'all_in_scope',
13
+ body: `正文:${title}`,
14
+ summary: extra.summary || `${title}的摘要`,
15
+ tags: extra.tags || [],
16
+ resolution: 'distinct',
17
+ title,
18
+ });
19
+ add('digital signal 调试', { tags: ['音频'] });
20
+ add('怎么做发布检查', { tags: ['发布'] });
21
+ add('git 提交前跑检查', { tags: ['git'] });
22
+
23
+ /* 英文按整词:git 不能因为共享 gi/it 命中 digital。 */
24
+ assert.equal(store.search(scope, 'git', 5).some((row) => row.title.includes('digital')), false);
25
+ assert.equal(store.search(scope, 'digital', 5).some((row) => row.title.includes('digital')), true);
26
+
27
+ /* 只重合「怎么」这类高频词不算命中。 */
28
+ assert.equal(store.search(scope, '这个怎么弄', 5).length, 0);
29
+
30
+ /* 实词仍然命中。 */
31
+ assert.equal(store.search(scope, '发布检查', 5).some((row) => row.title.includes('怎么做发布检查')), true);
32
+
33
+ console.log('lexical.test.mjs: all passed');
@@ -8,7 +8,7 @@ const { apply } = await import('../index.js');
8
8
  const store = await import('../store.js');
9
9
 
10
10
  const HEADER = '已有记忆可能相关';
11
- const CARD_LINE = /^- 「.+?」.* · mem_[a-z0-9]+ · 第 \d+ 版/;
11
+ const CARD_LINE = /^- \[[^\]]+\] 「.+?」.* · mem_[a-z0-9]+(?:|.*)?$/;
12
12
  const CARD_LINE_M = new RegExp(CARD_LINE.source, 'm');
13
13
 
14
14
  const effect = (fn) => {
@@ -122,7 +122,7 @@ for (let i = 0; i < 9; i += 1) {
122
122
  summary: '读取预算测试',
123
123
  tags: ['大正文'],
124
124
  resolution: 'distinct',
125
- body: `样本${i + 1}:${'正文内容'.repeat(230)}`,
125
+ body: `样本${i + 1}:${'正文内容'.repeat(80)}`,
126
126
  }));
127
127
  }
128
128
 
@@ -142,7 +142,7 @@ assert.ok(Buffer.byteLength(preparedText) <= 1200, 'snapshot stays inside card b
142
142
  const cards = bootA.provider.text({ agent: agentA, signal: signalA }).split('\n').slice(1);
143
143
  assert.ok(cards.length <= 3);
144
144
  for (const line of cards) assert.match(line, CARD_LINE);
145
- assert.match(preparedText, /第 1 版/);
145
+ assert.doesNotMatch(preparedText, /第 .* 版/);
146
146
  assert.equal(bootA.provider.text({ agent: agentA, signal: signalA }), preparedText, 'frozen between calls');
147
147
 
148
148
  // A03:provider 纯内存,不写盘、不动 hits。
@@ -265,7 +265,7 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
265
265
  await bootA.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('大正文样本怎么用')));
266
266
  const first = await bootA.exec('memory_read', { id: bigBodies[0].id }, agent);
267
267
  assert.equal(first.status, 'ok');
268
- assert.ok(first.body.length > 900);
268
+ assert.ok(Buffer.byteLength(first.body) > 900);
269
269
  const repeat = await bootA.exec('memory_read', { id: bigBodies[0].id }, agent);
270
270
  assert.equal(repeat.repeat, true);
271
271
  assert.equal(repeat.body, '');
@@ -273,13 +273,15 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
273
273
  const second = await bootA.exec('memory_read', { id: bigBodies[1].id }, agent);
274
274
  assert.equal(second.status, 'ok');
275
275
  const third = await bootA.exec('memory_read', { id: bigBodies[2].id }, agent);
276
- assert.equal(third.status, 'budget_exhausted');
277
- assert.equal(third.body || '', '');
278
- assert.equal(third.id, '');
279
- assert.equal(bootA.render('memory_read', {}, third), 'memory · 本请求的记忆额度已用完');
276
+ assert.equal(third.status, 'ok');
277
+ const fourth = await bootA.exec('memory_read', { id: bigBodies[3].id }, agent);
278
+ assert.equal(fourth.status, 'budget_exhausted');
279
+ assert.equal(fourth.body || '', '');
280
+ assert.equal(fourth.id, '');
281
+ assert.equal(bootA.render('memory_read', {}, fourth), 'memory · 本请求的记忆额度已用完');
280
282
  const win = bootA.debug.windowOf(agent);
281
283
  assert.ok(win.bytes <= 8192);
282
- assert.equal(win.bodies, 2);
284
+ assert.equal(win.bodies, 3);
283
285
 
284
286
  const agentReads = makeAgent('agent-reads', cwdA);
285
287
  await bootA.preStep({ agent: agentReads, signal: signal(), turn: 't1' }, enter(human('大正文样本怎么用')));
@@ -210,6 +210,27 @@ test('S02 符号链接、非 Git 目录、嵌套仓库各归各的', () => {
210
210
  assert.notEqual(scopeOf(nested).key, scopeOf(repo).key);
211
211
  });
212
212
 
213
+ test('S03 配置的项目根只覆盖自己的子树,不吞别的目录', () => {
214
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-config-'));
215
+ dirs.push(root);
216
+ const sub = path.join(root, 'packages', 'app');
217
+ fs.mkdirSync(sub, { recursive: true });
218
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-outside-'));
219
+ dirs.push(outside);
220
+ const config = path.join(memoryRoot(), 'config.json');
221
+ fs.mkdirSync(path.dirname(config), { recursive: true });
222
+ fs.writeFileSync(config, JSON.stringify({ projectRoot: root }));
223
+ try {
224
+ assert.equal(resolveRoot(sub).root, fs.realpathSync(root));
225
+ assert.equal(resolveRoot(sub).source, 'configured');
226
+ assert.equal(scopeOf(sub).key, scopeOf(root).key);
227
+ assert.notEqual(resolveRoot(outside).source, 'configured');
228
+ assert.notEqual(scopeOf(outside).key, scopeOf(root).key);
229
+ } finally {
230
+ fs.rmSync(config, { force: true });
231
+ }
232
+ });
233
+
213
234
  test('P04 Git 查询失败不冒充已确认工程,不用 cwd 兜底学习', () => {
214
235
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-gitfail-'));
215
236
  dirs.push(dir);
@@ -40,6 +40,26 @@ const rawState = (scope) => fs.readFileSync(path.join(scope.dir, 'state.json'),
40
40
  const countOf = (scope) => loadIndex(scope).entries.length;
41
41
  const suggestionsOf = (scope) => loadIndex(scope).suggestions;
42
42
 
43
+ test('over-specific body is rejected instead of becoming a constraint', () => {
44
+ const scope = fresh('specific');
45
+ assert.throws(
46
+ () => upsert(scope, {
47
+ title: '自绘菜单',
48
+ summary: '菜单用应用自绘,不用系统原生',
49
+ body: '菜单 z-index: 1100;宽度 min(264px, 100vw - 24px);字号 12px;内边距 10px;圆角 8px。',
50
+ }),
51
+ /正文太具体/,
52
+ );
53
+ });
54
+
55
+ test('body over the distilled budget is rejected', () => {
56
+ const scope = fresh('toolong');
57
+ assert.throws(
58
+ () => upsert(scope, { title: '长正文', summary: '太长就不存', body: '记'.repeat(500) }),
59
+ /超过单条 1200 字节/,
60
+ );
61
+ });
62
+
43
63
  test('create then same title merges with full old body kept', () => {
44
64
  const scope = fresh('create');
45
65
  const first = upsert(scope, { title: '发布顺序', summary: '发版前要按顺序做的事', body: '第一步\n- 跑测试' });
@@ -185,7 +205,8 @@ test('search finds Chinese queries, when clauses and reports counts', () => {
185
205
  assert.ok(when.some((item) => item.title === '文案检查'));
186
206
  const rendered = renderIndex(hits);
187
207
  assert.match(rendered.text, /^- mem_[a-z0-9]+ 发布顺序/);
188
- assert.match(rendered.text, /第 1 版/);
208
+ assert.match(rendered.text, /mem_[a-z0-9]+ 发布顺序/);
209
+ assert.doesNotMatch(rendered.text, /第 .* 版/);
189
210
  assert.ok(rendered.chars <= 400);
190
211
  assert.deepEqual(search(scope, '完全无关的主题词'), []);
191
212
  });
@@ -448,7 +469,7 @@ test('projects and the global layer stay separate', () => {
448
469
  assert.equal(global.length, 1);
449
470
  assert.equal(global[0].scope, 'global');
450
471
  const rendered = renderIndex(global);
451
- assert.match(rendered.text, /^- \[全局\] mem_/);
472
+ assert.match(rendered.text, /^- \[global\] mem_/);
452
473
  const removed = forget(b, 'mem_000000000000', { mode: 'archive' });
453
474
  assert.equal(removed, null);
454
475
  });
@@ -731,7 +752,8 @@ test('实验来源条目默认不进入检索、正文与关联,只有管理
731
752
  });
732
753
  assert.equal(search(scope, '实验记录', 4).some((row) => row.id === target.id), false);
733
754
  assert.equal(readEntry(scope, target.id), null);
734
- assert.equal(search(scope, '实验记录', 4, { allowExperimental: true }).some((row) => row.id === target.id), true);
755
+ assert.equal(search(scope, '实验记录', 4, { allowExperimental: true }).some((row) => row.id === target.id), false);
756
+ assert.equal(search(scope, '实验记录', 4, { allowExperimental: true, history: true }).some((row) => row.id === target.id), true);
735
757
  assert.equal(readEntry(scope, target.id, { allowExperimental: true }).body, '实验正文');
736
758
  assert.ok(readEntry(scope, normal.record.id));
737
759
  });
package/view.js CHANGED
@@ -222,6 +222,21 @@ export function canAutoRecall(record, context) {
222
222
  return statusOf(record, context) === 'ok';
223
223
  }
224
224
 
225
+ /**
226
+ * 学习规则是否还在有效期内:paused/pending 和被时间淘汰的 trial 都不能当依据。
227
+ * 只对实验来源生效;普通记忆不看这一层。
228
+ */
229
+ export function experimentalUsable(record, now = Date.now()) {
230
+ if (!record) return false;
231
+ const learning = record.learning && typeof record.learning === 'object' ? record.learning : null;
232
+ if (!learning && record.origin !== 'project_evolution') return true;
233
+ if (!learning) return false;
234
+ if (learning.state === 'active') return true;
235
+ if (learning.state !== 'trial') return false;
236
+ const until = Date.parse(String(learning.trialUntil || ''));
237
+ return !Number.isFinite(until) || until > Number(now);
238
+ }
239
+
225
240
  function gramsOf(text) {
226
241
  const plainText = plain(text, 400).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
227
242
  const set = new Set();
@@ -511,7 +526,7 @@ export function newestUserQuery(messages) {
511
526
  export function projectionText(view) {
512
527
  if (!view) return '';
513
528
  const rows = [];
514
- rows.push(`${view.title || view.id}${view.when ? `(${view.when})` : ''} · ${view.id} · 第 ${view.revision || 1} 版`);
529
+ rows.push(`${view.title || view.id}${view.when ? `(${view.when})` : ''} · ${view.id}`);
515
530
  if (view.conditions && view.conditions.length) rows.push(`条件:${view.conditions.map((item) => `${item.key}=${item.value}`).join(',')}`);
516
531
  if (view.selection) rows.push(`仅对 ${view.selection} 生效`);
517
532
  if (view.state === 'needs_context') rows.push('适用范围待确认');