dsh-lite-memory 0.11.1 → 0.16.2

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.
@@ -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');
@@ -260,7 +296,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
260
296
  assert.equal((value.refs || []).some((ref) => ref.id === id), false);
261
297
  }
262
298
 
263
- /* L11:带本请求 contextId 才能绑定;绑定后只升到 proposed。 */
299
+ /* L11:真人原话点名栏目就直接生效;工具绑定仍只认这句原话。 */
264
300
  {
265
301
  const dir = mkdir('bind');
266
302
  const scope = scopeOf(dir);
@@ -269,7 +305,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
269
305
  const runtime = boot();
270
306
  const agent = makeAgent('a-bind', dir);
271
307
  const sig = signal();
272
- await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期怎么开头')));
308
+ await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期口播怎么开头')));
273
309
  const held = runtime.debug.windowOf(agent).experiment;
274
310
  assert.ok(held && held.contextId, 'context leaked');
275
311
  const value = await runtime.exec('memory_search', { query: '口播', contextId: held.contextId, bindForRequest: true }, agent);
@@ -277,10 +313,30 @@ const scopeOf = (dir) => store.scopeOf(dir);
277
313
  assert.ok((value.text || '').includes('口播'));
278
314
  const episodes = Object.values(store.taskState(scope).episodes);
279
315
  assert.equal(episodes.length, 1);
280
- assert.equal(episodes[0].status, 'proposed');
316
+ assert.equal(episodes[0].status, 'active');
281
317
  assert.match(runtime.text(agent, sig), /开场三句内给结论/);
282
318
  }
283
319
 
320
+ /* L11b:模型自己写 query 不能建绑定;只认本轮真人原话。 */
321
+ {
322
+ const dir = mkdir('bind-model');
323
+ const scope = scopeOf(dir);
324
+ seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
325
+ enable(scope);
326
+ const runtime = boot();
327
+ const agent = makeAgent('a-bind-model', dir);
328
+ const sig = signal();
329
+ await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期怎么开头')));
330
+ const held = runtime.debug.windowOf(agent).experiment;
331
+ const value = await runtime.exec('memory_search', { query: '口播', contextId: held.contextId, bindForRequest: true }, agent);
332
+ assert.equal(value.status, 'needs_context');
333
+ assert.equal((value.text || '').includes('口播'), false);
334
+ assert.equal((value.text || '').includes('开场三句内给结论'), false);
335
+ const episodes = Object.values(store.taskState(scope).episodes);
336
+ assert.equal(episodes.some((episode) => episode.status === 'proposed'), false);
337
+ assert.equal(runtime.text(agent, sig).includes('开场三句内给结论'), false);
338
+ }
339
+
284
340
  /* L12:跨项目上下文直接拒绝。 */
285
341
  {
286
342
  const dirA = mkdir('cross-a');
@@ -390,4 +446,133 @@ const scopeOf = (dir) => store.scopeOf(dir);
390
446
  assert.equal(text.includes('实验规则:发版前先备份'), false);
391
447
  }
392
448
 
449
+ /* L18:工具绑定零命中/多命中时只回一句读取状态,不要求用户点名栏目。 */
450
+ {
451
+ const dir = mkdir('bind-none');
452
+ const scope = scopeOf(dir);
453
+ seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
454
+ seedRule(scope, { title: 'Vlog 节奏', body: '每段不超过二十秒。', conditions: [{ key: 'seriesId', value: 'Vlog' }] });
455
+ enable(scope);
456
+ const runtime = boot();
457
+ const agent = makeAgent('a-bind-none', dir);
458
+ await runtime.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('这期怎么开头')));
459
+ const held = runtime.debug.windowOf(agent).experiment;
460
+ const none = await runtime.exec('memory_search', { query: '修一下构建脚本', contextId: held.contextId, bindForRequest: true }, agent);
461
+ assert.equal(none.status, 'needs_context');
462
+ assert.doesNotMatch(none.text, /点名|先问|栏目/);
463
+ const many = await runtime.exec('memory_search', { query: '口播和 Vlog 都要', contextId: held.contextId, bindForRequest: true }, agent);
464
+ assert.equal(many.status, 'needs_context');
465
+ assert.doesNotMatch(many.text, /点名|先问|栏目/);
466
+ }
467
+
468
+ /* L19:试用到期后不再自动注入,和检索、读取用同一档可用性。 */
469
+ {
470
+ const dir = mkdir('trial-expired');
471
+ const scope = scopeOf(dir);
472
+ const id = seedRule(scope, {
473
+ title: '过期口播规则',
474
+ body: '这条试用已经到期,不该自动出现。',
475
+ conditions: [{ key: 'seriesId', value: '口播' }],
476
+ learning: { state: 'trial', trialUntil: new Date(Date.now() - 60_000).toISOString() },
477
+ });
478
+ enable(scope, { seriesId: '口播' });
479
+ const runtime = boot();
480
+ const agent = makeAgent('a-trial-expired', dir);
481
+ const sig = signal();
482
+ await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期口播怎么做')));
483
+ const text = runtime.text(agent, sig);
484
+ assert.equal(text.includes('这条试用已经到期,不该自动出现。'), false);
485
+ const held = runtime.debug.windowOf(agent).experiment;
486
+ assert.deepEqual(held ? held.injectedIds : [], []);
487
+ }
488
+
489
+ /* L19b:准备时还在试用期、请求中途到期,同一请求也不能再输出正文。 */
490
+ {
491
+ const dir = mkdir('trial-expiring');
492
+ const scope = scopeOf(dir);
493
+ seedRule(scope, {
494
+ title: '快到期口播规则',
495
+ body: '这条试用马上到期,过期后同一请求也不能再出现。',
496
+ conditions: [{ key: 'seriesId', value: '口播' }],
497
+ learning: { state: 'trial', trialUntil: new Date(Date.now() + 700).toISOString() },
498
+ });
499
+ enable(scope, { seriesId: '口播' });
500
+ const runtime = boot();
501
+ const agent = makeAgent('a-trial-expiring', dir);
502
+ const sig = signal();
503
+ await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期口播怎么做')));
504
+ assert.match(runtime.text(agent, sig), /马上到期/);
505
+ await new Promise((resolve) => setTimeout(resolve, 900));
506
+ assert.equal(runtime.text(agent, sig), '');
507
+ }
508
+
509
+ /* L19c:一条试用到期只撤这一条,不把同请求里其他可用规则一起清掉。 */
510
+ {
511
+ const dir = mkdir('trial-parts');
512
+ const scope = scopeOf(dir);
513
+ seedRule(scope, {
514
+ title: '快到期的主题规则',
515
+ body: '这条试用马上到期。',
516
+ conditions: [{ key: 'seriesId', value: '口播' }],
517
+ learning: { state: 'trial', trialUntil: new Date(Date.now() + 700).toISOString() },
518
+ topicKey: 'theme',
519
+ });
520
+ seedRule(scope, {
521
+ title: '长期口播开头',
522
+ body: '开场三句内给结论。',
523
+ conditions: [{ key: 'seriesId', value: '口播' }],
524
+ learning: { state: 'active' },
525
+ topicKey: 'opening',
526
+ });
527
+ enable(scope, { seriesId: '口播' });
528
+ const runtime = boot();
529
+ const agent = makeAgent('a-trial-parts', dir);
530
+ const sig = signal();
531
+ await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期口播怎么做')));
532
+ const first = runtime.text(agent, sig);
533
+ assert.match(first, /快到期的主题规则/);
534
+ assert.match(first, /长期口播开头/);
535
+ await new Promise((resolve) => setTimeout(resolve, 900));
536
+ const later = runtime.text(agent, sig);
537
+ assert.doesNotMatch(later, /快到期的主题规则/);
538
+ assert.match(later, /长期口播开头/, '无关的试用到期不该清掉别的规则');
539
+ }
540
+
541
+ /* L20:自进化关掉后,已经准备好的实验正文立刻不再输出;重开也不复活。 */
542
+ {
543
+ const dir = mkdir('off-after-prepare');
544
+ const scope = scopeOf(dir);
545
+ seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
546
+ enable(scope, { seriesId: '口播' });
547
+ const runtime = boot();
548
+ const agent = makeAgent('a-off-after-prepare', dir);
549
+ const sig = signal();
550
+ await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期口播怎么做')));
551
+ assert.match(runtime.text(agent, sig), /开场三句内给结论/);
552
+ assert.equal(store.setEvolution(scope, false, { operationId: 'evo-off-after-prepare' }).ok, true);
553
+ assert.equal(runtime.text(agent, sig), '');
554
+ assert.equal(store.setEvolution(scope, true, { operationId: 'evo-on-again' }).ok, true);
555
+ assert.equal(runtime.text(agent, sig), '');
556
+ }
557
+
558
+ /* L21:检索没有可用命中、但有条件未确认的候选时,只说状态,不把标题递给模型。 */
559
+ {
560
+ const dir = mkdir('search-withheld');
561
+ const scope = scopeOf(dir);
562
+ save(scope, {
563
+ title: '发布前检查清单',
564
+ body: '按清单逐项确认。',
565
+ summary: '发布前逐项确认',
566
+ applicability: 'unresolved',
567
+ });
568
+ const runtime = boot();
569
+ const agent = makeAgent('a-search-withheld', dir);
570
+ const value = await runtime.exec('memory_search', { query: '发布前检查' }, agent);
571
+ assert.equal(value.count, 0);
572
+ assert.equal(value.status, 'needs_context');
573
+ assert.equal(value.reasonCode, 'applicability_unresolved');
574
+ assert.equal(value.text.includes('发布前检查清单'), false);
575
+ assert.match(value.text, /适用条件还没确认/);
576
+ }
577
+
393
578
  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('大正文样本怎么用')));
@@ -445,3 +447,17 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
445
447
  }
446
448
 
447
449
  console.log('recall.test.mjs: ok');
450
+
451
+ // A20:条件未确认的条目,model 侧读正文只拿状态,不拿标题;面板仍可查。
452
+ {
453
+ const agent = makeAgent('agent-withheld-read', cwdA);
454
+ await prepare(bootA, agent, '发布前要检查什么');
455
+ const row = await bootA.exec('memory_read', { id: unresolved.id }, agent);
456
+ assert.equal(row.body, '');
457
+ assert.equal(row.status, 'needs_context');
458
+ assert.doesNotMatch(JSON.stringify(row), /发布前要检查什么/);
459
+ assert.doesNotMatch(bootA.render('memory_read', { id: unresolved.id }, row), /发布前要检查什么/);
460
+ const links = await bootA.exec('memory_read', { id: unresolved.id, view: 'links' }, agent);
461
+ assert.equal(links.status, 'needs_context');
462
+ assert.doesNotMatch(JSON.stringify(links), /发布前要检查什么/);
463
+ }
@@ -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,33 @@ 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 stored, not rejected for naming concrete values', () => {
44
+ const scope = fresh('specific');
45
+ const saved = upsert(scope, {
46
+ title: '自绘菜单',
47
+ summary: '菜单用应用自绘,不用系统原生',
48
+ body: '菜单 z-index: 1100;宽度 min(264px, 100vw - 24px);字号 12px;内边距 10px;圆角 8px。',
49
+ });
50
+ assert.equal(saved.record.title, '自绘菜单');
51
+ assert.ok(saved.record.body.includes('z-index'));
52
+ });
53
+
54
+ test('a 500-character body is accepted', () => {
55
+ const scope = fresh('toolong');
56
+ const saved = upsert(scope, { title: '长正文', summary: '长一点也要存下来', body: '记'.repeat(500) });
57
+ assert.equal(saved.record.title, '长正文');
58
+ assert.equal(saved.record.body.length, 500);
59
+ });
60
+
61
+ test('正文正好到 4096 字节可以存,元数据不算进正文上限', () => {
62
+ const scope = fresh('body-cap');
63
+ const body = `${'测'.repeat(1365)}a`;
64
+ const saved = upsert(scope, { title: '正文到顶', summary: '正文刚好到上限', body });
65
+ assert.equal(saved.record.body, body);
66
+ assert.ok(saved.record.bytes > LIMITS.entryBytes);
67
+ assert.throws(() => upsert(scope, { title: '超一点', summary: '正文超一点', body: `${body}a` }), /正文 4097 字节/);
68
+ });
69
+
43
70
  test('create then same title merges with full old body kept', () => {
44
71
  const scope = fresh('create');
45
72
  const first = upsert(scope, { title: '发布顺序', summary: '发版前要按顺序做的事', body: '第一步\n- 跑测试' });
@@ -126,16 +153,20 @@ test('oversized body is rejected, old data untouched', () => {
126
153
  assert.equal(loadIndex(scope).entries[0].revision, 1);
127
154
  });
128
155
 
129
- test('over-long title and summary are errors, not silent slices', () => {
156
+ test('over-long title is an error; over-long summary is truncated but the full text is saved', () => {
130
157
  const scope = fresh('length');
131
158
  assert.throws(() => upsert(scope, { title: 't'.repeat(LIMITS.titleChars + 1), summary: '写得短一点' }), /标题超过/);
132
- assert.throws(() => upsert(scope, { title: '长摘要', summary: 's'.repeat(LIMITS.summaryChars + 1) }), /摘要超过/);
133
- assert.equal(fs.existsSync(path.join(scope.dir, 'state.json')), false);
159
+ const long = '长'.repeat(LIMITS.summaryChars + 1);
160
+ const saved = upsert(scope, { title: '长摘要', summary: long });
161
+ assert.equal(saved.record.summary.length, LIMITS.summaryChars);
162
+ assert.equal(readEntry(scope, saved.record.id).body, long);
163
+ assert.equal(saved.draft.fullTextInBody, true);
164
+ assert.equal(saved.draft.summaryShortened, true);
134
165
  });
135
166
 
136
- test('imperative summary and credentials are rejected', () => {
167
+ test('imperative summary is kept; credentials are still rejected', () => {
137
168
  const scope = fresh('validate');
138
- assert.throws(() => upsert(scope, { title: '回复语言', summary: '请用中文回复' }), /名词短语/);
169
+ assert.equal(upsert(scope, { title: '回复语言', summary: '请用中文回复' }).record.summary, '请用中文回复');
139
170
  assert.throws(() => upsert(scope, { title: '凭据', summary: '一个 token 的值', body: 'token = sk-abcdefghijklmnopqrst' }), /凭据/);
140
171
  assert.equal(normalizeDraft({ title: 'a', summary: '名词短语' }).kind, 'note');
141
172
  assert.throws(() => normalizeDraft({ title: 'a', summary: '名词短语', kind: 'guess' }), /类型只能是/);
@@ -185,7 +216,8 @@ test('search finds Chinese queries, when clauses and reports counts', () => {
185
216
  assert.ok(when.some((item) => item.title === '文案检查'));
186
217
  const rendered = renderIndex(hits);
187
218
  assert.match(rendered.text, /^- mem_[a-z0-9]+ 发布顺序/);
188
- assert.match(rendered.text, /第 1 版/);
219
+ assert.match(rendered.text, /mem_[a-z0-9]+ 发布顺序/);
220
+ assert.doesNotMatch(rendered.text, /第 .* 版/);
189
221
  assert.ok(rendered.chars <= 400);
190
222
  assert.deepEqual(search(scope, '完全无关的主题词'), []);
191
223
  });
@@ -448,7 +480,7 @@ test('projects and the global layer stay separate', () => {
448
480
  assert.equal(global.length, 1);
449
481
  assert.equal(global[0].scope, 'global');
450
482
  const rendered = renderIndex(global);
451
- assert.match(rendered.text, /^- \[全局\] mem_/);
483
+ assert.match(rendered.text, /^- \[global\] mem_/);
452
484
  const removed = forget(b, 'mem_000000000000', { mode: 'archive' });
453
485
  assert.equal(removed, null);
454
486
  });
@@ -731,7 +763,8 @@ test('实验来源条目默认不进入检索、正文与关联,只有管理
731
763
  });
732
764
  assert.equal(search(scope, '实验记录', 4).some((row) => row.id === target.id), false);
733
765
  assert.equal(readEntry(scope, target.id), null);
734
- assert.equal(search(scope, '实验记录', 4, { allowExperimental: true }).some((row) => row.id === target.id), true);
766
+ assert.equal(search(scope, '实验记录', 4, { allowExperimental: true }).some((row) => row.id === target.id), false);
767
+ assert.equal(search(scope, '实验记录', 4, { allowExperimental: true, history: true }).some((row) => row.id === target.id), true);
735
768
  assert.equal(readEntry(scope, target.id, { allowExperimental: true }).body, '实验正文');
736
769
  assert.ok(readEntry(scope, normal.record.id));
737
770
  });
@@ -794,5 +827,62 @@ test('实验策略版本不认时保持关闭,开关拒绝改动且盘上不
794
827
  assert.equal(fs.readFileSync(path.join(scope.dir, 'state.json'), 'utf8'), before);
795
828
  });
796
829
 
830
+ test('当前不适用的高相关条目不挤掉后面真能用的条目', () => {
831
+ const scope = fresh('usable-rank');
832
+ for (let index = 0; index < 40; index += 1) {
833
+ upsert(scope, {
834
+ title: `旧规则${index}`,
835
+ summary: `旧规则${index}`,
836
+ body: `口播检查项${index}的正文`,
837
+ applicability: 'unresolved',
838
+ });
839
+ }
840
+ const usable = upsert(scope, {
841
+ title: '口播检查项可用',
842
+ summary: '口播检查项可用',
843
+ body: '口播检查项的可用正文',
844
+ applicability: 'all_in_scope',
845
+ });
846
+ const usableId = usable.record?.id ?? usable.id;
847
+ const hits = search(scope, '口播检查项', 3);
848
+ assert.equal(hits.some((row) => row.id === usableId), true);
849
+ });
850
+
797
851
  for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
852
+
853
+ test('R24 省略正文的更新不覆盖已有正文(按 id 与同名都成立)', () => {
854
+ const scope = fresh('omit-body');
855
+ const first = upsert(scope, { title: '连接池改法', summary: '旧摘要', body: '旧正文:先看连接数。' });
856
+ const id = first.record.id;
857
+ upsert(scope, { id, title: '连接池改法', summary: '新摘要' });
858
+ assert.equal(bodyOf(scope, id), '旧正文:先看连接数。', 'id 更新省略正文时保留原正文');
859
+ upsert(scope, { title: '连接池改法', summary: '再新一点' });
860
+ assert.equal(bodyOf(scope, id), '旧正文:先看连接数。', '同名更新省略正文时保留原正文');
861
+ });
862
+
863
+ test('R25 长摘要改动即使只差第 61 个字符也算改过,全文仍保留', () => {
864
+ const scope = fresh('long-summary-change');
865
+ const head = '摘要主体'.repeat(15);
866
+ const first = upsert(scope, { title: '长摘要规则', summary: `${head}甲` });
867
+ const id = first.record.id;
868
+ assert.equal(first.record.summary.length, LIMITS.summaryChars);
869
+ assert.match(bodyOf(scope, id), /甲$/);
870
+ const again = upsert(scope, { id, title: '长摘要规则', summary: `${head}乙` });
871
+ assert.equal(again.noop, false, '第 61 个字符变化不能当作没改');
872
+ const body = bodyOf(scope, id);
873
+ assert.match(body, /甲/);
874
+ assert.match(body, /乙/, '新的完整摘要也落盘');
875
+ assert.equal(again.record.summary.length, LIMITS.summaryChars);
876
+ });
877
+
878
+ test('R26 显式长正文加短摘要时补上摘要原文,不把两者混成一条', () => {
879
+ const scope = fresh('body-plus-summary');
880
+ const long = '现场记录'.repeat(20);
881
+ const saved = upsert(scope, { title: '带正文的长摘要', summary: long, body: '显式正文:先复现再改。' });
882
+ const body = bodyOf(scope, saved.record.id);
883
+ assert.match(body, /^显式正文:先复现再改。/);
884
+ assert.match(body, /摘要原文:/);
885
+ assert.match(body, /现场记录/);
886
+ });
887
+
798
888
  console.log(`store.test.mjs: ${passed} passed`);
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('适用范围待确认');