dsh-lite-memory 0.15.0 → 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.
package/store.js CHANGED
@@ -27,7 +27,6 @@ const SECRET_PATTERNS = [
27
27
  ];
28
28
 
29
29
  /** A summary is a noun phrase. An imperative turns a note into an instruction. */
30
- const IMPERATIVE = /^(?:你要|你得|你必须|请|务必|必须|记得|记住|别忘了|do not|don't|please|you (?:must|should|need))/i;
31
30
 
32
31
  const SUGGESTION_ID = /^sug_[a-z0-9]{6,}$/;
33
32
 
@@ -276,19 +275,9 @@ function cleanTitle(value) {
276
275
  return String(value ?? '').replace(/\s+/g, ' ').trim();
277
276
  }
278
277
 
279
- /** 正文里的实现参数(尺寸/层级/字号)不算记忆:数到三次就要求重写成原则。 */
280
- const UI_SPEC_PATTERNS = [
281
- /\bz-index\s*:\s*-?\d+/gi,
282
- /\b(?:padding|margin|gap|font-size|line-height|border-radius|width|height|min-width|max-width|min-height|max-height|box-shadow)\s*:[^;\n]{0,32}\d/gi,
283
- /\b\d+(?:\.\d+)?(?:px|rem|em)\b/gi,
284
- /\bmin\(\s*\d/gi,
285
- ];
286
-
287
278
  export function validSummary(summary) {
288
- const text = String(summary ?? '').replace(/\s+/g, ' ').trim();
279
+ const text = String(summary ?? '').replace(/\s+/g, ' ').trim().slice(0, LIMITS.summaryChars);
289
280
  if (!text) return { ok: false, reason: '摘要为空' };
290
- if (text.length > LIMITS.summaryChars) return { ok: false, reason: `摘要超过 ${LIMITS.summaryChars} 字,先写短些` };
291
- if (IMPERATIVE.test(text)) return { ok: false, reason: '摘要必须写成名词短语,不能写成指令' };
292
281
  return { ok: true, text };
293
282
  }
294
283
 
@@ -308,25 +297,32 @@ export function normalizeDraft(source, options = {}) {
308
297
  const mayOmitTitle = Boolean(id);
309
298
  if (!title && !mayOmitTitle) throw new Error('标题为空');
310
299
  let summary = '';
300
+ let summaryShortened = false;
311
301
  const rawSummary = String(draft.summary ?? '').replace(/\s+/g, ' ').trim();
312
302
  if (rawSummary) {
313
303
  const check = validSummary(rawSummary);
314
304
  if (!check.ok) throw new Error(check.reason);
315
305
  summary = check.text;
306
+ summaryShortened = summary.length < rawSummary.length;
316
307
  } else if (has('summary')) {
317
308
  throw new Error('摘要为空');
318
309
  }
319
310
  /** 更新(带 id)与候选草稿允许省略摘要;省略表示保持原值,不拿标题补。 */
320
311
  const mayOmitSummary = options.requireSummary === false || Boolean(id);
321
312
  if (!summary && !mayOmitSummary) throw new Error('摘要为空');
322
- const body = String(draft.body ?? '').trim();
323
- const bodyBytes = Buffer.byteLength(body);
313
+ /**
314
+ * 正文是否由调用方显式给出,和「摘要太长所以自动补一份」必须分开。
315
+ * 否则同名更新会被自动补出的正文当成显式覆盖,把原正文冲掉。
316
+ */
317
+ const bodyExplicit = has('body');
318
+ const body = bodyExplicit ? String(draft.body ?? '').trim() : '';
319
+ const autoBody = !bodyExplicit && summaryShortened ? rawSummary : '';
320
+ const checked = bodyExplicit ? body : autoBody;
321
+ const bodyBytes = Buffer.byteLength(checked);
324
322
  if (bodyBytes > LIMITS.entryBytes) {
325
- throw new Error(`正文 ${bodyBytes} 字节,超过单条 ${LIMITS.entryBytes} 字节;只留原则、判断规则和触发条件`);
326
- }
327
- if (has('body')) {
328
- const concrete = UI_SPEC_PATTERNS.reduce((count, pattern) => count + (body.match(pattern) || []).length, 0);
329
- if (concrete >= 3) throw new Error('正文太具体:记忆只留原则和触发条件,像素、字号、层级这类实现参数留在代码或文档里');
323
+ throw new Error(autoBody
324
+ ? `摘要 ${rawSummary.length} 字,超过单条 ${LIMITS.entryBytes} 字节;先拆成两条写,别丢后半段`
325
+ : `正文 ${bodyBytes} 字节,超过单条 ${LIMITS.entryBytes} 字节;只留原则、判断规则和触发条件`);
330
326
  }
331
327
  const kind = has('kind') ? textField(draft.kind, 20, '类型') : 'note';
332
328
  if (kind && !KINDS.includes(kind)) throw new Error(`类型只能是 ${KINDS.join(' / ')}`);
@@ -349,6 +345,10 @@ export function normalizeDraft(source, options = {}) {
349
345
  summary,
350
346
  when,
351
347
  body,
348
+ summaryShortened,
349
+ summaryRaw: rawSummary,
350
+ autoBody,
351
+ fullTextInBody: false,
352
352
  kind: kind || 'note',
353
353
  tags,
354
354
  triggers,
@@ -361,7 +361,7 @@ export function normalizeDraft(source, options = {}) {
361
361
  title: has('title') && Boolean(title),
362
362
  summary: has('summary'),
363
363
  when: has('when'),
364
- body: has('body'),
364
+ body: bodyExplicit,
365
365
  kind: has('kind'),
366
366
  tags: has('tags'),
367
367
  triggers: triggers !== undefined,
@@ -651,6 +651,14 @@ function rulesOf(draft, body, index, foreign) {
651
651
  return rules;
652
652
  }
653
653
 
654
+ /** 长摘要的尾部不能丢:正文里没有这段时补在末尾,已有则不动。 */
655
+ function bodyWithSummaryTail(body, draft) {
656
+ const raw = String(draft.summaryRaw || '');
657
+ const current = String(body || '');
658
+ if (!raw || !draft.summaryShortened || current.includes(raw)) return { body: current, added: false };
659
+ return { body: current ? `${current}\n\n摘要原文:${raw}` : raw, added: true };
660
+ }
661
+
654
662
  function mergedFields(draft, record, oldBody, index, writeMode = 'patch', foreign) {
655
663
  if (writeMode === 'replace_current') {
656
664
  const next = {
@@ -664,8 +672,9 @@ function mergedFields(draft, record, oldBody, index, writeMode = 'patch', foreig
664
672
  selection: draft.has.selection ? draft.selection : null,
665
673
  links: draft.has.links ? draft.links : [],
666
674
  topicKey: draft.has.topicKey ? draft.topicKey : '',
667
- body: draft.body,
675
+ body: bodyWithSummaryTail(draft.body, draft).body,
668
676
  };
677
+ if (next.body !== draft.body) draft.fullTextInBody = true;
669
678
  next.applicability = view.validateApplicability(draft.has.applicability ? draft.applicability : undefined,
670
679
  next.conditions, next.selection, next.when);
671
680
  assertLinksVisible(next.links, index, foreign);
@@ -743,6 +752,9 @@ function applyDraft(scope, index, source, now, options = {}) {
743
752
  if (record) {
744
753
  const oldBody = readBody(scope, record.id);
745
754
  const next = mergedFields(draft, record, oldBody, index, writeMode, foreign);
755
+ const tail = bodyWithSummaryTail(next.body, draft);
756
+ next.body = tail.body;
757
+ if (tail.added) draft.fullTextInBody = true;
746
758
  const noop = next.title === record.title && next.summary === record.summary
747
759
  && next.when === (record.when || '') && next.kind === (record.kind || 'note')
748
760
  && JSON.stringify(next.tags) === JSON.stringify(record.tags || [])
@@ -777,7 +789,11 @@ function applyDraft(scope, index, source, now, options = {}) {
777
789
  return { record, merged: true, mergedFrom, similar: null, similarList, draft, noop: false, previousBody: oldBody, archived };
778
790
  }
779
791
  if (writeMode === 'replace_current') throw new Error('整份替换要先指明是哪条,不能用来新增');
780
- const rules = rulesOf(draft, draft.body, index, foreign);
792
+ const seed = draft.autoBody || draft.body;
793
+ const tail = bodyWithSummaryTail(seed, draft);
794
+ const createdBody = tail.body;
795
+ if (tail.added || draft.autoBody) draft.fullTextInBody = true;
796
+ const rules = rulesOf(draft, createdBody, index, foreign);
781
797
  const created = {
782
798
  id: newId(),
783
799
  title: draft.title,
@@ -791,7 +807,7 @@ function applyDraft(scope, index, source, now, options = {}) {
791
807
  selection: rules.selection,
792
808
  links: rules.links,
793
809
  topicKey: rules.topicKey,
794
- body: draft.body,
810
+ body: createdBody,
795
811
  aliases: [],
796
812
  created: now,
797
813
  updated: now,
@@ -817,8 +833,9 @@ function applyDraft(scope, index, source, now, options = {}) {
817
833
  * 结果经 index.__archived 回填,调用方在工具结果里点名。
818
834
  */
819
835
  function assertFits(scope, index, record) {
820
- if (record.bytes > LIMITS.entryBytes) {
821
- throw new Error(`这一条 ${record.bytes} 字节,超过单条 ${LIMITS.entryBytes} 字节;先精简正文`);
836
+ const bodyBytes = Buffer.byteLength(record.body || '');
837
+ if (bodyBytes > LIMITS.entryBytes) {
838
+ throw new Error(`正文 ${bodyBytes} 字节,超过单条 ${LIMITS.entryBytes} 字节;先精简正文`);
822
839
  }
823
840
  const size = recordSize(record);
824
841
  if (size > LIMITS.recordBytes) {
@@ -1073,8 +1090,11 @@ export function search(scope, query, limit = 4, options = {}) {
1073
1090
  const group = resolved.get(view.topicSlot(item));
1074
1091
  return Boolean(group && group.status === 'ok' && group.record && group.record.id !== item.id);
1075
1092
  };
1076
- const visible = rows.filter((row) => row.archived || !shadowed(row.item));
1077
- visible.sort((a, b) => b.score - a.score
1093
+ const visible = rows.filter((row) => row.archived || !shadowed(row.item))
1094
+ .map((row) => ({ ...row, usable: row.archived || view.statusOf(row.item, options.context || {}) === 'ok' }));
1095
+ /** 先按「本轮能不能用」分组再按相关度:高相关但当前不适用的条目不能挤掉后面真正能用的。 */
1096
+ visible.sort((a, b) => Number(b.usable) - Number(a.usable)
1097
+ || b.score - a.score
1078
1098
  || Number(a.archived) - Number(b.archived)
1079
1099
  || String(b.item.lastUsed).localeCompare(String(a.item.lastUsed)));
1080
1100
  const chosen = visible.slice(0, limit);
@@ -1255,13 +1275,16 @@ function findCurrent(id, scope, options = {}) {
1255
1275
  const record = index.entries.find((item) => item.id === id && item.status !== 'archived');
1256
1276
  if (!record) continue;
1257
1277
  if (!readableRecord(record, options, index)) continue;
1278
+ /** 关联目标也按本轮上下文判适用;条件未知、过期或暂停的目标不给标题。 */
1279
+ if (view.statusOf(record, options.context || {}) !== 'ok') continue;
1280
+ if (isExperimentalRecord(record) && !view.experimentalUsable(record, options.now)) continue;
1258
1281
  return {
1259
1282
  id: record.id,
1260
1283
  title: record.title,
1261
1284
  summary: record.summary || '',
1262
1285
  scope: source.key === 'global' ? 'global' : 'project',
1263
1286
  revision: record.revision || 1,
1264
- status: view.statusOf(record, {}),
1287
+ status: 'ok',
1265
1288
  };
1266
1289
  }
1267
1290
  return null;
@@ -2643,7 +2666,7 @@ export function commitLearning(scope, batch = {}) {
2643
2666
  touched += 1;
2644
2667
  continue;
2645
2668
  }
2646
- if (currentRules.length + (touched + 1) > learning.LIMITS.rules) throw new Error('项目实验规则已经到上限');
2669
+ if (currentRules.length + 1 > learning.LIMITS.rules) throw new Error('项目实验规则已经到上限');
2647
2670
  const pending = currentRules.filter((entry) => toFlat(entry).learning?.state === 'pending').length;
2648
2671
  if (pending >= learning.LIMITS.pending) throw new Error('待确认候选已经到上限');
2649
2672
  merged.learning = {
@@ -2906,15 +2929,18 @@ export function editLearningRule(scope, id, options = {}) {
2906
2929
  const nextBody = body || flat.body;
2907
2930
  if (nextTitle === flat.title && nextBody === flat.body) return { changed: false };
2908
2931
  const nextCurrent = { ...entry.current, title: nextTitle, body: nextBody };
2932
+ const bodyChanged = nextBody !== flat.body;
2909
2933
  const learningState = { ...(flat.learning || {}) };
2910
2934
  if (!learningState.specDigest) learningState.specDigest = learning.specDigestOf(flat);
2911
- learningState.versions = [...(Array.isArray(learningState.versions) ? learningState.versions : []),
2912
- { digest: learningState.specDigest, body: flat.body, at }].slice(-learning.LIMITS.versions);
2935
+ if (bodyChanged) {
2936
+ learningState.versions = [...(Array.isArray(learningState.versions) ? learningState.versions : []),
2937
+ { digest: learningState.specDigest, body: flat.body, at }].slice(-learning.LIMITS.versions);
2938
+ learningState.supportedEpisodeIds = [];
2939
+ learningState.evidence = [];
2940
+ }
2913
2941
  learningState.specDigest = learning.specDigestOf(nextCurrent);
2914
- learningState.supportedEpisodeIds = [];
2915
- learningState.evidence = [];
2916
2942
  learningState.updatedAt = at;
2917
- if (learningState.state === 'active') {
2943
+ if (bodyChanged && learningState.state === 'active') {
2918
2944
  learningState.state = 'trial';
2919
2945
  learningState.trialUntil = new Date(now + learning.LIMITS.trialMs).toISOString();
2920
2946
  }
@@ -434,3 +434,93 @@ test('R28 指代加动作的续接仍保留旧任务范围', () => {
434
434
  assert.equal(next.anchor.episodeId, previous.episodeId);
435
435
  assert.equal(next.anchor.seriesId, '口播');
436
436
  });
437
+
438
+ test('R29 带「继续改」但对象换成新的英文主题时撤销旧范围', () => {
439
+ const tasks = activeVideoSession();
440
+ const next = anchor.routeTask(tasks, {
441
+ sessionId: 'sess-switch',
442
+ batchKey: 'rb_switch_pg_continue',
443
+ messageIds: ['m2'],
444
+ query: '继续改 PostgreSQL 连接池配置',
445
+ registeredSeries: ['口播'],
446
+ now: 2000,
447
+ });
448
+ assert.equal(next.decision.kind, 'unbound');
449
+ assert.equal(next.decision.reason, 'new_task');
450
+ assert.equal(next.anchor.seriesId, undefined);
451
+ });
452
+
453
+ test('R30 带「调整一下」但对象换了时撤销旧范围', () => {
454
+ const tasks = activeVideoSession();
455
+ const next = anchor.routeTask(tasks, {
456
+ sessionId: 'sess-switch',
457
+ batchKey: 'rb_switch_pg_adjust',
458
+ messageIds: ['m2'],
459
+ query: '调整一下 PostgreSQL 连接池配置',
460
+ registeredSeries: ['口播'],
461
+ now: 2000,
462
+ });
463
+ assert.equal(next.decision.kind, 'unbound');
464
+ assert.equal(next.decision.reason, 'new_task');
465
+ });
466
+
467
+ test('R31 带「继续改」但明确指向新对象动作时撤销旧范围', () => {
468
+ const tasks = activeVideoSession();
469
+ const next = anchor.routeTask(tasks, {
470
+ sessionId: 'sess-switch',
471
+ batchKey: 'rb_switch_db_code',
472
+ messageIds: ['m2'],
473
+ query: '继续改数据库连接超时的处理代码',
474
+ registeredSeries: ['口播'],
475
+ now: 2000,
476
+ });
477
+ assert.equal(next.decision.kind, 'unbound');
478
+ assert.equal(next.decision.reason, 'new_task');
479
+ });
480
+
481
+ /** 复核复现:句首带「继续」不能成为沿用旧正文的通行证。 */
482
+ for (const [label, query] of [
483
+ ['R32 继续改数据库连接池配置', '继续改数据库连接池配置'],
484
+ ['R33 继续优化数据库连接池', '继续优化数据库连接池'],
485
+ ['R34 继续写数据库故障报告', '继续写数据库故障报告'],
486
+ ['R35 这个数据库连接池配置怎么改', '这个数据库连接池配置怎么改'],
487
+ ]) {
488
+ test(`${label}:与旧任务没有对象交集时不继承旧范围`, () => {
489
+ const tasks = activeVideoSession();
490
+ const next = anchor.routeTask(tasks, {
491
+ sessionId: 'sess-switch',
492
+ batchKey: `rb_${label.slice(0, 3)}_db`,
493
+ messageIds: ['m2'],
494
+ query,
495
+ registeredSeries: ['口播'],
496
+ now: 2000,
497
+ });
498
+ assert.equal(next.decision.kind, 'unbound');
499
+ assert.equal(next.decision.reason, 'new_task');
500
+ assert.equal(next.anchor.seriesId, undefined);
501
+ });
502
+ }
503
+
504
+ test('R41 旧稿提到但明确换到排查任务时不再绑定旧栏目', () => {
505
+ const tasks = activeVideoSession();
506
+ for (const query of ['排查 PostgreSQL 连接池故障,旧稿先放着', '这版数据库连接池配置怎么改']) {
507
+ const next = anchor.routeTask(tasks, {
508
+ sessionId: 'sess-switch',
509
+ batchKey: `rb_${query.length}`,
510
+ messageIds: ['m2'],
511
+ query,
512
+ registeredSeries: ['口播'],
513
+ now: 2000,
514
+ });
515
+ assert.equal(next.decision.kind, 'unbound', query);
516
+ assert.equal(next.anchor.seriesId, undefined, query);
517
+ }
518
+ });
519
+
520
+ test('R42 栏目只被提到、否定或搁置时都不算用户选中', () => {
521
+ assert.equal(anchor.seriesChosenBy('把口播开头改一下', '口播'), true);
522
+ assert.equal(anchor.seriesChosenBy('先不做口播了', '口播'), false);
523
+ assert.equal(anchor.seriesChosenBy('不要按口播的规矩来', '口播'), false);
524
+ assert.equal(anchor.seriesChosenBy('日志里提到口播吗', '口播'), false);
525
+ assert.equal(anchor.seriesChosenBy('先放着口播,改数据库', '口播'), false);
526
+ });
@@ -235,6 +235,25 @@ const toggle = (scope, enabled) => store.setMemoryEnabled(scope, enabled, { oper
235
235
  assert.equal(afterAdopt.title, '口播结论前置');
236
236
  }
237
237
 
238
+ /* C07b:只改标题不动正文时保留生效状态与学习依据。 */
239
+ {
240
+ const dir = mkdir('title-only');
241
+ const scope = scopeOf(dir);
242
+ const id = seedRule(scope, '口播开头');
243
+ state.transact(scope, {}, (draft) => {
244
+ const row = draft.entries.find((entry) => entry.id === id);
245
+ row.maintenance.learning = { state: 'active', evidence: [{ text: '三次口播都先改开头' }] };
246
+ return { changed: true };
247
+ });
248
+ const first = store.learningSnapshot(scope).rules[0];
249
+ const edited = store.editLearningRule(scope, id, { expectedRevision: first.revision, title: '口播开头重命名' });
250
+ assert.equal(edited.ok, true);
251
+ const after = store.learningSnapshot(scope).rules[0];
252
+ assert.equal(after.state, 'active');
253
+ assert.equal(after.learning.evidence.length, 1);
254
+ assert.equal(after.title, '口播开头重命名');
255
+ }
256
+
238
257
  /* C08:关闭保留已消耗额度与默认绑定,重开后仍按原额度限额。 */
239
258
  {
240
259
  const dir = mkdir('preserve-quota');
@@ -296,7 +296,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
296
296
  assert.equal((value.refs || []).some((ref) => ref.id === id), false);
297
297
  }
298
298
 
299
- /* L11:带本请求 contextId 才能绑定;绑定后只升到 proposed。 */
299
+ /* L11:真人原话点名栏目就直接生效;工具绑定仍只认这句原话。 */
300
300
  {
301
301
  const dir = mkdir('bind');
302
302
  const scope = scopeOf(dir);
@@ -305,7 +305,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
305
305
  const runtime = boot();
306
306
  const agent = makeAgent('a-bind', dir);
307
307
  const sig = signal();
308
- await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期怎么开头')));
308
+ await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期口播怎么开头')));
309
309
  const held = runtime.debug.windowOf(agent).experiment;
310
310
  assert.ok(held && held.contextId, 'context leaked');
311
311
  const value = await runtime.exec('memory_search', { query: '口播', contextId: held.contextId, bindForRequest: true }, agent);
@@ -313,10 +313,30 @@ const scopeOf = (dir) => store.scopeOf(dir);
313
313
  assert.ok((value.text || '').includes('口播'));
314
314
  const episodes = Object.values(store.taskState(scope).episodes);
315
315
  assert.equal(episodes.length, 1);
316
- assert.equal(episodes[0].status, 'proposed');
316
+ assert.equal(episodes[0].status, 'active');
317
317
  assert.match(runtime.text(agent, sig), /开场三句内给结论/);
318
318
  }
319
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
+
320
340
  /* L12:跨项目上下文直接拒绝。 */
321
341
  {
322
342
  const dirA = mkdir('cross-a');
@@ -445,4 +465,114 @@ const scopeOf = (dir) => store.scopeOf(dir);
445
465
  assert.doesNotMatch(many.text, /点名|先问|栏目/);
446
466
  }
447
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
+
448
578
  console.log('evolution-read.test.mjs: all passed');
@@ -447,3 +447,17 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
447
447
  }
448
448
 
449
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
+ }
@@ -40,24 +40,31 @@ 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', () => {
43
+ test('over-specific body is stored, not rejected for naming concrete values', () => {
44
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
- );
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'));
53
52
  });
54
53
 
55
- test('body over the distilled budget is rejected', () => {
54
+ test('a 500-character body is accepted', () => {
56
55
  const scope = fresh('toolong');
57
- assert.throws(
58
- () => upsert(scope, { title: '长正文', summary: '太长就不存', body: '记'.repeat(500) }),
59
- /超过单条 1200 字节/,
60
- );
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 字节/);
61
68
  });
62
69
 
63
70
  test('create then same title merges with full old body kept', () => {
@@ -146,16 +153,20 @@ test('oversized body is rejected, old data untouched', () => {
146
153
  assert.equal(loadIndex(scope).entries[0].revision, 1);
147
154
  });
148
155
 
149
- 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', () => {
150
157
  const scope = fresh('length');
151
158
  assert.throws(() => upsert(scope, { title: 't'.repeat(LIMITS.titleChars + 1), summary: '写得短一点' }), /标题超过/);
152
- assert.throws(() => upsert(scope, { title: '长摘要', summary: 's'.repeat(LIMITS.summaryChars + 1) }), /摘要超过/);
153
- 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);
154
165
  });
155
166
 
156
- test('imperative summary and credentials are rejected', () => {
167
+ test('imperative summary is kept; credentials are still rejected', () => {
157
168
  const scope = fresh('validate');
158
- assert.throws(() => upsert(scope, { title: '回复语言', summary: '请用中文回复' }), /名词短语/);
169
+ assert.equal(upsert(scope, { title: '回复语言', summary: '请用中文回复' }).record.summary, '请用中文回复');
159
170
  assert.throws(() => upsert(scope, { title: '凭据', summary: '一个 token 的值', body: 'token = sk-abcdefghijklmnopqrst' }), /凭据/);
160
171
  assert.equal(normalizeDraft({ title: 'a', summary: '名词短语' }).kind, 'note');
161
172
  assert.throws(() => normalizeDraft({ title: 'a', summary: '名词短语', kind: 'guess' }), /类型只能是/);
@@ -816,5 +827,62 @@ test('实验策略版本不认时保持关闭,开关拒绝改动且盘上不
816
827
  assert.equal(fs.readFileSync(path.join(scope.dir, 'state.json'), 'utf8'), before);
817
828
  });
818
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
+
819
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
+
820
888
  console.log(`store.test.mjs: ${passed} passed`);