dsh-lite-memory 0.16.2 → 0.17.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.
package/store.js CHANGED
@@ -30,6 +30,12 @@ const SECRET_PATTERNS = [
30
30
 
31
31
  const SUGGESTION_ID = /^sug_[a-z0-9]{6,}$/;
32
32
 
33
+ /**
34
+ * 旧版把超长摘要的原文托管在正文末尾,用这个标记分隔。
35
+ * 现在只拿它做保守识别:旧正文可以标记待整理,但绝不改写;新写入也不再产生尾巴。
36
+ */
37
+ const LEGACY_SUMMARY_TAIL = '摘要原文:';
38
+
33
39
  /**
34
40
  * 实验来源的条目默认不属于普通记忆读取面:开关关闭、暂停或身份未知时,
35
41
  * 老 ID、病史查询和关联跳转都不能把正文带出来。管理面显式传 allowExperimental。
@@ -54,7 +60,11 @@ const bodyCache = new Map();
54
60
 
55
61
  function cacheBodies(scope, entries) {
56
62
  const map = new Map();
57
- for (const entry of entries) map.set(entry.id, entry.current && entry.current.body);
63
+ for (const entry of entries) {
64
+ const body = entry.current && entry.current.body;
65
+ /** 空正文不进缓存:归档条目的正文可能冷存,读取时要能回冷存兜底。 */
66
+ if (typeof body === 'string' && body !== '') map.set(entry.id, body);
67
+ }
58
68
  bodyCache.set(scope.dir, map);
59
69
  }
60
70
 
@@ -77,6 +87,8 @@ function toFlat(entry) {
77
87
  id: entry.id,
78
88
  title: current.title || '',
79
89
  summary: current.summary || '',
90
+ /** 超长摘要的完整原句单独托管,不进正文;平铺出来供保存路径与回执核对。 */
91
+ summaryFull: typeof maintenance.summaryFull === 'string' ? maintenance.summaryFull : '',
80
92
  when: current.when || '',
81
93
  body: typeof current.body === 'string' ? current.body : '',
82
94
  kind: current.kind || 'note',
@@ -110,7 +122,7 @@ function toFlat(entry) {
110
122
 
111
123
  function fromFlat(record, status) {
112
124
  const at = record.updated || record.created || new Date().toISOString();
113
- return {
125
+ const entry = {
114
126
  id: record.id,
115
127
  revision: Number(record.revision) || 1,
116
128
  status: status || (record.status === 'archived' ? 'archived' : 'active'),
@@ -148,6 +160,13 @@ function fromFlat(record, status) {
148
160
  lastUsed: record.lastUsed || at,
149
161
  },
150
162
  };
163
+ /** 完整摘要是维护区字段:显式给了就覆盖,给了空串就清掉,不给则沿用旧值。 */
164
+ if (Object.prototype.hasOwnProperty.call(record, 'summaryFull')) {
165
+ const full = typeof record.summaryFull === 'string' ? record.summaryFull : '';
166
+ if (full) entry.maintenance.summaryFull = full;
167
+ else delete entry.maintenance.summaryFull;
168
+ }
169
+ return entry;
151
170
  }
152
171
 
153
172
  /**
@@ -161,7 +180,16 @@ export function loadIndex(scope, options = {}) {
161
180
  }
162
181
  const loaded = state.load(scope, options);
163
182
  const entries = loaded.state.entries.filter((entry) => entry.status === 'active').map(toFlat);
164
- const archived = loaded.state.entries.filter((entry) => entry.status === 'archived').map(toFlat);
183
+ /**
184
+ * 归档条目只加载元数据,正文留在冷存里按需读:列表、图谱、宫殿、snapshot 都
185
+ * 不该为了显示标题去读归档正文,更不能因为某个旧归档缺冷存就把整层读失败。
186
+ */
187
+ const archived = loaded.state.entries.filter((entry) => entry.status === 'archived').map((entry) => {
188
+ const record = toFlat(entry);
189
+ record.cold = Boolean(entry.maintenance && entry.maintenance[COLD_MARK]);
190
+ record.coldBody = '';
191
+ return record;
192
+ });
165
193
  cacheBodies(scope, loaded.state.entries);
166
194
  const index = {
167
195
  version: 2,
@@ -179,6 +207,7 @@ export function loadIndex(scope, options = {}) {
179
207
  __tasks: anchor.normalizeTasks(state.evolutionOf(loaded.state).tasks),
180
208
  __control: state.controlOf(loaded.state),
181
209
  __exists: loaded.exists === true,
210
+ __recovery: loaded.recovery || null,
182
211
  };
183
212
  index.__meta = {
184
213
  exists: loaded.exists,
@@ -190,10 +219,11 @@ export function loadIndex(scope, options = {}) {
190
219
  }
191
220
 
192
221
  /** 扁平索引 → v2 状态:整层重写,条目、候选、回执一次换完。 */
193
- function applyFlat(draft, index) {
222
+ function applyFlat(scope, draft, index) {
194
223
  const next = [];
195
224
  for (const record of index.entries) next.push(fromFlat(record));
196
- for (const record of index.archived || []) next.push(fromFlat(record, 'archived'));
225
+ /** 归档正文不写回热状态:先落冷存再摘空,冷存写失败就整次保存失败,不丢正文。 */
226
+ for (const record of index.archived || []) next.push(fromFlat(coldStored(scope, record), 'archived'));
197
227
  draft.entries = next;
198
228
  draft.candidates = Array.isArray(index.suggestions) ? JSON.parse(JSON.stringify(index.suggestions)) : [];
199
229
  draft.receipts = Array.isArray(index.receipts) ? JSON.parse(JSON.stringify(index.receipts)) : [];
@@ -211,7 +241,7 @@ export function saveIndex(scope, index) {
211
241
  const expected = meta.exists ? { generation: meta.generation, epoch: meta.epoch } : null;
212
242
  const result = state.transact(scope, { expected, expectCreate: !meta.exists }, (draft) => {
213
243
  const before = JSON.stringify([draft.entries, draft.candidates, draft.receipts, draft.suppressions, draft.lastSuggestedAt]);
214
- applyFlat(draft, index);
244
+ applyFlat(scope, draft, index);
215
245
  const after = JSON.stringify([draft.entries, draft.candidates, draft.receipts, draft.suppressions, draft.lastSuggestedAt]);
216
246
  return { changed: before !== after };
217
247
  });
@@ -311,17 +341,18 @@ export function normalizeDraft(source, options = {}) {
311
341
  const mayOmitSummary = options.requireSummary === false || Boolean(id);
312
342
  if (!summary && !mayOmitSummary) throw new Error('摘要为空');
313
343
  /**
314
- * 正文是否由调用方显式给出,和「摘要太长所以自动补一份」必须分开。
315
- * 否则同名更新会被自动补出的正文当成显式覆盖,把原正文冲掉。
344
+ * 正文只属于调用方显式给出的内容:摘要超长时不再往正文里塞托管尾巴,
345
+ * 完整原句单独进维护区(maintenance.summaryFull),正文保持原样。
316
346
  */
317
347
  const bodyExplicit = has('body');
318
348
  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);
322
- if (bodyBytes > LIMITS.entryBytes) {
323
- throw new Error(autoBody
324
- ? `摘要 ${rawSummary.length} 字,超过单条 ${LIMITS.entryBytes} 字节;先拆成两条写,别丢后半段`
349
+ const summaryFull = summaryShortened ? rawSummary : '';
350
+ const bodyBytes = Buffer.byteLength(body);
351
+ /** 预算按真正会存进去的正文 + 完整摘要算:超限就报错,不截断后谎报成功。 */
352
+ const storedBytes = bodyBytes + Buffer.byteLength(summaryFull);
353
+ if (storedBytes > LIMITS.entryBytes) {
354
+ throw new Error(summaryFull
355
+ ? `正文 ${bodyBytes} 字节加完整摘要 ${Buffer.byteLength(summaryFull)} 字节,超过单条 ${LIMITS.entryBytes} 字节;先拆成两条写,别丢后半段`
325
356
  : `正文 ${bodyBytes} 字节,超过单条 ${LIMITS.entryBytes} 字节;只留原则、判断规则和触发条件`);
326
357
  }
327
358
  const kind = has('kind') ? textField(draft.kind, 20, '类型') : 'note';
@@ -337,7 +368,8 @@ export function normalizeDraft(source, options = {}) {
337
368
  const links = view.normalizeLinks(draft.links, id);
338
369
  const applicability = view.normalizeApplicability(draft.applicability);
339
370
  const topicKey = has('topicKey') ? textField(draft.topicKey, LIMITS.tagChars, '主题') : undefined;
340
- const secret = detectSecret([title, summary, when, body, tags.join(' '), topicKey || ''].join('\n'));
371
+ /** 凭据扫描必须覆盖完整原文:截短后的摘要看不出凭据,原文里有也不行。 */
372
+ const secret = detectSecret([title, summary, rawSummary, when, body, tags.join(' '), topicKey || ''].join('\n'));
341
373
  if (secret) throw new Error(`疑似凭据(${secret}),只记位置,不记值`);
342
374
  return {
343
375
  id,
@@ -347,7 +379,10 @@ export function normalizeDraft(source, options = {}) {
347
379
  body,
348
380
  summaryShortened,
349
381
  summaryRaw: rawSummary,
350
- autoBody,
382
+ summaryFull,
383
+ /** 回执字段:完整原句已另存维护区(不是塞进正文)。 */
384
+ fullTextSaved: Boolean(summaryFull),
385
+ /** 兼容旧回执:正文里从来没有托管尾巴,这个字段恒为 false。 */
351
386
  fullTextInBody: false,
352
387
  kind: kind || 'note',
353
388
  tags,
@@ -381,9 +416,27 @@ function newId() {
381
416
  /** 正文就在状态里;缓存只是省一次读盘,得手时一律回状态读。 */
382
417
  function readBody(scope, id) {
383
418
  const cached = cachedBody(scope, id);
384
- if (typeof cached === 'string') return cached;
419
+ if (typeof cached === 'string' && cached !== '') return cached;
385
420
  const found = state.readEntryBody(scope, id);
386
421
  if (!found) throw new Error(`记忆正文缺失:${id} 在索引里,但正文文件不在了`);
422
+ /**
423
+ * 归档正文不在热状态里:只有真要读这一条正文时才回冷存取,
424
+ * 缺文件/坏文件都报明确错误,不拿空正文冒充「这条没有正文」。
425
+ */
426
+ if (!found.body && found.entry && found.entry.status === 'archived') {
427
+ let payload = null;
428
+ try {
429
+ payload = readCold(scope, id, { required: true });
430
+ } catch (error) {
431
+ /** 旧版归档在正文为空时可能没写过冷存:这类老记录按空正文读回,不改写也不再报错。 */
432
+ const legacy = !(found.entry.maintenance && found.entry.maintenance.coldSaved === true);
433
+ if (!(legacy && error && error.code === 'cold_missing')) throw error;
434
+ }
435
+ if (payload && typeof payload.body === 'string' && payload.body) {
436
+ writeBody(scope, id, payload.body);
437
+ return payload.body;
438
+ }
439
+ }
387
440
  return found.body;
388
441
  }
389
442
 
@@ -398,6 +451,192 @@ function removeBody(scope, id) {
398
451
  if (map) map.delete(id);
399
452
  }
400
453
 
454
+ /**
455
+ * 归档正文冷存:热状态只留元数据,正文和旧版本正文放 scope.dir/cold/<id>.json。
456
+ * 五百条归档 ≈ 2 MiB 时,只有真看过的那几条会回到内存,保存不再被整层预算卡死。
457
+ */
458
+ const COLD_DIR = 'cold';
459
+ const COLD_MARK = 'coldBody';
460
+
461
+ function coldDirOf(scope) {
462
+ return path.join(scope.dir, COLD_DIR);
463
+ }
464
+
465
+ function coldFileOf(scope, id) {
466
+ return path.join(coldDirOf(scope), `${id}.json`);
467
+ }
468
+
469
+ function coldPayload(scope, record) {
470
+ return {
471
+ /** 冷存文件自带身份:读回来先核对是不是这一条、这一层,不靠文件名猜。 */
472
+ id: record.id,
473
+ scope: scope.key,
474
+ revision: Number(record.revision) || 1,
475
+ at: new Date().toISOString(),
476
+ body: typeof record.body === 'string' ? record.body : '',
477
+ versions: (Array.isArray(record.versions) ? record.versions : [])
478
+ .map((version) => ({ at: String((version && version.at) || ''), body: typeof (version && version.body) === 'string' ? version.body : '' }))
479
+ .filter((version) => version.body),
480
+ };
481
+ }
482
+
483
+ /**
484
+ * 冷存写失败就报错:宁可整次保存失败,也不能把正文悄悄丢掉。
485
+ * 落盘序列与 state.json 一致(fsync 文件 → rename → fsync 目录):归档正文只在这里,
486
+ * 少一步 fsync,崩溃后就是「热状态说有归档、冷存文件是空的」。
487
+ */
488
+ function writeCold(scope, record) {
489
+ const file = coldFileOf(scope, record.id);
490
+ try {
491
+ state.writeDurable(file, `${JSON.stringify(coldPayload(scope, record))}\n`);
492
+ } catch (error) {
493
+ throw new state.StateError('unavailable', `归档正文没写进冷存(${path.basename(file)}):${(error && error.message) || 'IO 错误'}`);
494
+ }
495
+ }
496
+
497
+ /**
498
+ * 读冷存:文件缺失时按 required 决定返回 null 还是报错;JSON 损坏、身份不符
499
+ * 一律抛明确错误,绝不静默返回空正文冒充「这条没有正文」。
500
+ */
501
+ function readCold(scope, id, options = {}) {
502
+ const required = options.required === true;
503
+ const file = coldFileOf(scope, id);
504
+ let raw;
505
+ try {
506
+ raw = fs.readFileSync(file, 'utf8');
507
+ } catch (error) {
508
+ if (error && error.code === 'ENOENT') {
509
+ if (!required) return null;
510
+ throw new state.StateError('cold_missing', `冷存里没有这条的正文备份(${file});先修复冷存再读,不拿空正文顶上`);
511
+ }
512
+ throw error;
513
+ }
514
+ let payload;
515
+ try {
516
+ payload = JSON.parse(raw);
517
+ } catch {
518
+ throw new state.StateError('cold_corrupt', `冷存文件不是合法 JSON(${file});先修复再读,不拿空正文顶上`);
519
+ }
520
+ if (!payload || typeof payload !== 'object' || payload.id !== id || payload.scope !== scope.key || typeof payload.body !== 'string') {
521
+ throw new state.StateError('cold_corrupt', `冷存文件记录的条目不匹配这一条或这一层(${file});先修复再读,不拿空正文顶上`);
522
+ }
523
+ return payload;
524
+ }
525
+
526
+ /** 写回状态前把归档正文摘出去:只有正文/旧版本真变了才重写冷存文件。 */
527
+ function coldStored(scope, record) {
528
+ if (record.status !== 'archived') return record;
529
+ const versions = Array.isArray(record.versions) ? record.versions : [];
530
+ const hasVersionBodies = versions.some((version) => typeof (version && version.body) === 'string' && version.body);
531
+ /** 内存记录的正文可能已经是空串(占位/摘过):先按 coldBody、冷存文件、热状态顺序找回。 */
532
+ let body = typeof record.body === 'string' ? record.body : '';
533
+ const known = typeof record.coldBody === 'string' ? record.coldBody : null;
534
+ if (body || known !== null) {
535
+ body = body || known;
536
+ } else {
537
+ /** 冷存是唯一权威副本;它坏了先退回热状态里的正文(保存时会重写成好文件),两处都没有才报错。 */
538
+ let coldError = null;
539
+ try {
540
+ const payload = readCold(scope, record.id);
541
+ if (payload && typeof payload.body === 'string' && payload.body) body = payload.body;
542
+ } catch (error) {
543
+ coldError = error;
544
+ }
545
+ if (!body) {
546
+ const found = state.readEntryBody(scope, record.id);
547
+ if (found && typeof found.body === 'string') body = found.body;
548
+ }
549
+ if (coldError && !body) throw coldError;
550
+ }
551
+ /** 正文和冷存里的一致就不重写:五百条归档每次保存只动真变过的那几条。 */
552
+ const cached = record.cold === true && known !== null && known === body;
553
+ /** 空正文也要落一份冷存:只要标了 coldBody,就必须有自证身份的文件可读回。 */
554
+ if (!cached) writeCold(scope, { ...record, body });
555
+ return {
556
+ ...record,
557
+ body: '',
558
+ cold: true,
559
+ coldBody: body,
560
+ versions: versions.map((version) => ({ ...(version || {}), body: '' })),
561
+ maintenance: { ...(record.maintenance || {}), [COLD_MARK]: true, coldSaved: true },
562
+ };
563
+ }
564
+
565
+ /**
566
+ * 彻底删除是一个事务:先把要删的冷存文件按 id 精确移到层外的隔离区
567
+ * (state.purge 只扫 scope.dir,所以隔离区必须在层外),state.purge 成功后才丢弃隔离副本;
568
+ * 失败按错误类型恢复或留待重放收尾。绝不按标题/正文片段扫描别人的冷存文件。
569
+ */
570
+ const PURGE_TRASH_DIR = '.purge-trash';
571
+
572
+ function trashDirOf(scope) {
573
+ const safe = String(scope.key || '').replace(/[^A-Za-z0-9._-]/g, '_') || '_';
574
+ return path.join(memoryRoot(), PURGE_TRASH_DIR, safe);
575
+ }
576
+
577
+ /** 只搬走 ids 对应的 <id>.json;别的条目一个字节都不动。 */
578
+ function isolateCold(scope, ids, operationId) {
579
+ const token = String(operationId || '').replace(/[^A-Za-z0-9._-]/g, '_') || crypto.randomBytes(8).toString('hex');
580
+ const dir = path.join(trashDirOf(scope), token);
581
+ const moved = [];
582
+ for (const id of ids || []) {
583
+ const from = coldFileOf(scope, id);
584
+ if (!fs.existsSync(from)) continue;
585
+ fs.mkdirSync(dir, { recursive: true });
586
+ const to = path.join(dir, `${id}.json`);
587
+ fs.renameSync(from, to);
588
+ moved.push({ from, to });
589
+ }
590
+ return { dir, moved };
591
+ }
592
+
593
+ function restoreCold(isolated) {
594
+ let restored = true;
595
+ for (const row of isolated.moved) {
596
+ try {
597
+ fs.renameSync(row.to, row.from);
598
+ } catch {
599
+ /** 恢复失败就保留整个隔离区:副本还在,重放/人工都能取回,不能顺手删掉。 */
600
+ restored = false;
601
+ }
602
+ }
603
+ if (restored) dropColdQuarantine(isolated);
604
+ }
605
+
606
+ function dropColdQuarantine(isolated) {
607
+ try {
608
+ fs.rmSync(isolated.dir, { recursive: true, force: true });
609
+ } catch {
610
+ /** 删不掉隔离目录不影响删除结果,下次收尾再清。 */
611
+ }
612
+ }
613
+
614
+ /** 删除确认后的收尾:清掉这一层隔离区里残留的副本(上一次做到一半留下的)。 */
615
+ function cleanColdQuarantine(scope) {
616
+ try {
617
+ fs.rmSync(trashDirOf(scope), { recursive: true, force: true });
618
+ } catch {
619
+ /** 收尾清不掉不影响数据正确性。 */
620
+ }
621
+ }
622
+
623
+ /**
624
+ * 删除与冷存的一致顺序:隔离 → 发布删除 → 丢弃隔离副本。
625
+ * 发布失败(例如 purge_pending)时保留隔离副本,留给重放路径收尾。
626
+ */
627
+ function purgeWithCold(scope, ids, run) {
628
+ const isolated = isolateCold(scope, ids);
629
+ try {
630
+ const result = run();
631
+ dropColdQuarantine(isolated);
632
+ return result;
633
+ } catch (error) {
634
+ if (error && error.code === 'purge_pending') throw error;
635
+ restoreCold(isolated);
636
+ throw error;
637
+ }
638
+ }
639
+
401
640
  function entrySize(record, body) {
402
641
  const meta = [record.title || '', record.summary || '', record.when || '', (record.tags || []).join(',')].join('\n');
403
642
  return Buffer.byteLength(meta) + Buffer.byteLength(body || '') + Buffer.byteLength(state.fieldsBytesOf(record));
@@ -607,6 +846,7 @@ function snapshot(record, body) {
607
846
  at: record.updated || record.created || new Date().toISOString(),
608
847
  title: record.title,
609
848
  summary: record.summary,
849
+ summaryFull: typeof record.summaryFull === 'string' ? record.summaryFull : '',
610
850
  when: record.when || '',
611
851
  kind: record.kind || 'note',
612
852
  tags: [...(record.tags || [])],
@@ -651,12 +891,12 @@ function rulesOf(draft, body, index, foreign) {
651
891
  return rules;
652
892
  }
653
893
 
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 };
894
+ /**
895
+ * 旧正文里可能留着「摘要原文:」尾巴:只识别、做标记,绝不改写。
896
+ * 猜不出来哪一段是托管内容,删错就是把用户原话删了。
897
+ */
898
+ function hasLegacySummaryTail(text) {
899
+ return typeof text === 'string' && text.includes(LEGACY_SUMMARY_TAIL);
660
900
  }
661
901
 
662
902
  function mergedFields(draft, record, oldBody, index, writeMode = 'patch', foreign) {
@@ -672,9 +912,8 @@ function mergedFields(draft, record, oldBody, index, writeMode = 'patch', foreig
672
912
  selection: draft.has.selection ? draft.selection : null,
673
913
  links: draft.has.links ? draft.links : [],
674
914
  topicKey: draft.has.topicKey ? draft.topicKey : '',
675
- body: bodyWithSummaryTail(draft.body, draft).body,
915
+ body: String(draft.body || ''),
676
916
  };
677
- if (next.body !== draft.body) draft.fullTextInBody = true;
678
917
  next.applicability = view.validateApplicability(draft.has.applicability ? draft.applicability : undefined,
679
918
  next.conditions, next.selection, next.when);
680
919
  assertLinksVisible(next.links, index, foreign);
@@ -752,9 +991,13 @@ function applyDraft(scope, index, source, now, options = {}) {
752
991
  if (record) {
753
992
  const oldBody = readBody(scope, record.id);
754
993
  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;
994
+ /** 正文原样保存:摘要再长也不往正文里补尾巴;完整原句进维护区。 */
995
+ draft.fullTextInBody = false;
996
+ /** 摘要这次被改过就按本次结果落 summaryFull:改短即清空,没改则保持原值。 */
997
+ const summaryFull = draft.has.summary
998
+ ? (draft.summaryShortened ? String(draft.summaryRaw || '') : '')
999
+ : (typeof record.summaryFull === 'string' ? record.summaryFull : '');
1000
+ draft.fullTextSaved = Boolean(summaryFull);
758
1001
  const noop = next.title === record.title && next.summary === record.summary
759
1002
  && next.when === (record.when || '') && next.kind === (record.kind || 'note')
760
1003
  && JSON.stringify(next.tags) === JSON.stringify(record.tags || [])
@@ -764,7 +1007,8 @@ function applyDraft(scope, index, source, now, options = {}) {
764
1007
  && JSON.stringify(next.links) === JSON.stringify(record.links || [])
765
1008
  && (next.topicKey || '') === (record.topicKey || '')
766
1009
  && next.applicability === (record.applicability || 'all_in_scope')
767
- && next.body === oldBody;
1010
+ && next.body === oldBody
1011
+ && summaryFull === (typeof record.summaryFull === 'string' ? record.summaryFull : '');
768
1012
  if (noop) {
769
1013
  return { record, merged: true, mergedFrom: null, similar: null, similarList, draft, noop: true, previousBody: oldBody, archived: [] };
770
1014
  }
@@ -773,6 +1017,7 @@ function applyDraft(scope, index, source, now, options = {}) {
773
1017
  const nextRecord = {
774
1018
  ...record,
775
1019
  ...next,
1020
+ summaryFull,
776
1021
  aliases: titles,
777
1022
  versions,
778
1023
  revision: (record.revision || 1) + 1,
@@ -782,6 +1027,11 @@ function applyDraft(scope, index, source, now, options = {}) {
782
1027
  status: 'active',
783
1028
  source: record.source || (source && source.source) || 'user_explicit',
784
1029
  };
1030
+ nextRecord.maintenance = { ...(record.maintenance || {}) };
1031
+ if (summaryFull) nextRecord.maintenance.summaryFull = summaryFull;
1032
+ else delete nextRecord.maintenance.summaryFull;
1033
+ /** 旧正文里的托管尾巴只标记待整理,绝不在这里改写用户正文。 */
1034
+ if (hasLegacySummaryTail(next.body)) nextRecord.maintenance.legacySummaryTail = true;
785
1035
  nextRecord.bytes = entrySize(nextRecord, next.body);
786
1036
  const archived = assertFits(scope, index, nextRecord);
787
1037
  writeBody(scope, record.id, next.body);
@@ -789,15 +1039,16 @@ function applyDraft(scope, index, source, now, options = {}) {
789
1039
  return { record, merged: true, mergedFrom, similar: null, similarList, draft, noop: false, previousBody: oldBody, archived };
790
1040
  }
791
1041
  if (writeMode === 'replace_current') throw new Error('整份替换要先指明是哪条,不能用来新增');
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;
1042
+ /** 新建也只用调用方给的正文:超长摘要的完整原句进维护区,不混进正文。 */
1043
+ const createdBody = draft.body;
1044
+ draft.fullTextInBody = false;
1045
+ draft.fullTextSaved = Boolean(draft.summaryFull);
796
1046
  const rules = rulesOf(draft, createdBody, index, foreign);
797
1047
  const created = {
798
1048
  id: newId(),
799
1049
  title: draft.title,
800
1050
  summary: draft.summary,
1051
+ summaryFull: draft.summaryFull,
801
1052
  when: draft.when,
802
1053
  kind: draft.kind,
803
1054
  tags: draft.tags,
@@ -818,8 +1069,12 @@ function applyDraft(scope, index, source, now, options = {}) {
818
1069
  hits: 0,
819
1070
  merges: 0,
820
1071
  versions: [],
1072
+ maintenance: {},
821
1073
  bytes: 0,
822
1074
  };
1075
+ if (draft.summaryFull) created.maintenance.summaryFull = draft.summaryFull;
1076
+ /** 旧正文里的托管尾巴只标记待整理,绝不改写用户正文。 */
1077
+ if (hasLegacySummaryTail(createdBody)) created.maintenance.legacySummaryTail = true;
823
1078
  created.bytes = entrySize(created, created.body);
824
1079
  const archived = assertFits(scope, index, created);
825
1080
  writeBody(scope, created.id, created.body);
@@ -1055,8 +1310,41 @@ export function setTaskDefault(scope, defaults, options = {}) {
1055
1310
  * 返回的记录带着「本次检索之前」的 lastUsed,给 renderIndex 判断陈旧用;
1056
1311
  * 命中计数在返回前写入,所以同一次调用既改盘又不污染展示。
1057
1312
  */
1313
+ /** 「用过」的判定门槛:lastUsed 超过这么久才回写,检索不至于每次都抢锁落盘。 */
1314
+ const USED_TOUCH_MS = 10 * 60 * 1000;
1315
+
1316
+ function usedStale(record, now) {
1317
+ const at = Date.parse(String((record && (record.lastUsed || record.updated)) || ''));
1318
+ return !Number.isFinite(at) || now - at >= USED_TOUCH_MS;
1319
+ }
1320
+
1321
+ /**
1322
+ * 真正被搜到/读过才算用过:只回写 lastUsed,不动 hits,也不改正文。
1323
+ * 抢不到锁或并发冲突都吞掉——这个是提示字段,不该让检索/阅读失败。
1324
+ */
1325
+ function touchUsed(scope, ids) {
1326
+ const wanted = new Set((ids || []).filter((id) => typeof id === 'string' && id));
1327
+ if (wanted.size === 0) return;
1328
+ try {
1329
+ withLock(scope, () => {
1330
+ const index = loadIndex(scope);
1331
+ const now = Date.now();
1332
+ const stamp = new Date(now).toISOString();
1333
+ let changed = false;
1334
+ for (const record of index.entries) {
1335
+ if (!wanted.has(record.id) || !usedStale(record, now)) continue;
1336
+ record.lastUsed = stamp;
1337
+ changed = true;
1338
+ }
1339
+ if (changed) saveIndex(scope, index);
1340
+ });
1341
+ } catch {
1342
+ /* lastUsed 只是提示:失败不影响这次检索/阅读的结果。 */
1343
+ }
1344
+ }
1345
+
1058
1346
  export function search(scope, query, limit = 4, options = {}) {
1059
- return withLocks([scope, globalScope()], () => {
1347
+ const found = withLocks([scope, globalScope()], () => {
1060
1348
  const grams = bigrams(query);
1061
1349
  if (grams.size === 0) return [];
1062
1350
  const runs = queryRuns(query);
@@ -1098,14 +1386,23 @@ export function search(scope, query, limit = 4, options = {}) {
1098
1386
  || Number(a.archived) - Number(b.archived)
1099
1387
  || String(b.item.lastUsed).localeCompare(String(a.item.lastUsed)));
1100
1388
  const chosen = visible.slice(0, limit);
1101
- /** 检索不改盘:hits/lastUsed 只在用户真的处理过结果时才算数。 */
1389
+ /** hits 不在检索里记,lastUsed 也只在锁外补写;这里先把层信息和结果一起带出去。 */
1102
1390
  return chosen.map((row) => ({
1103
- ...row.item,
1104
- scope: row.source.key === 'global' ? 'global' : 'project',
1105
- status: row.archived ? 'archived' : view.statusOf(row.item, options.context || {}),
1391
+ record: {
1392
+ ...row.item,
1393
+ scope: row.source.key === 'global' ? 'global' : 'project',
1394
+ status: row.archived ? 'archived' : view.statusOf(row.item, options.context || {}),
1395
+ archived: Boolean(row.archived),
1396
+ },
1397
+ source: row.source,
1106
1398
  archived: Boolean(row.archived),
1107
1399
  }));
1108
1400
  });
1401
+ /** 锁外回写:在层锁里再抢同一层的锁会死锁。 */
1402
+ for (const row of found) {
1403
+ if (!row.archived) touchUsed(row.source, [row.record.id]);
1404
+ }
1405
+ return found.map((row) => row.record);
1109
1406
  }
1110
1407
 
1111
1408
  /**
@@ -1146,7 +1443,7 @@ export function peek(scope, query, limit = 3, options = {}) {
1146
1443
  }
1147
1444
 
1148
1445
  export function readEntry(scope, id, options = {}) {
1149
- return withLocks([scope, globalScope()], () => {
1446
+ const found = withLocks([scope, globalScope()], () => {
1150
1447
  for (const source of [scope, globalScope()]) {
1151
1448
  const index = loadIndex(source);
1152
1449
  const record = index.entries.find((item) => item.id === id);
@@ -1155,14 +1452,22 @@ export function readEntry(scope, id, options = {}) {
1155
1452
  if (!readableRecord(record || archived, options, index)) continue;
1156
1453
  const body = readBody(source, id);
1157
1454
  return {
1158
- ...(record || archived),
1159
- scope: source.key === 'global' ? 'global' : 'project',
1160
- body,
1161
- status: archived ? 'archived' : view.statusOf(record, options.context || {}),
1455
+ source,
1456
+ archived: Boolean(archived),
1457
+ record: {
1458
+ ...(record || archived),
1459
+ scope: source.key === 'global' ? 'global' : 'project',
1460
+ body,
1461
+ status: archived ? 'archived' : view.statusOf(record, options.context || {}),
1462
+ },
1162
1463
  };
1163
1464
  }
1164
1465
  return null;
1165
1466
  });
1467
+ if (!found) return null;
1468
+ /** 读过才算用过;归档的不回写(看病史不等于「在用」)。 */
1469
+ if (!found.archived) touchUsed(found.source, [id]);
1470
+ return found.record;
1166
1471
  }
1167
1472
 
1168
1473
  /**
@@ -1179,7 +1484,9 @@ export function forget(scope, id, options = {}) {
1179
1484
  for (const source of [scope, globalScope()]) {
1180
1485
  const pending = state.purgeIntent(source);
1181
1486
  if (!pending) continue;
1182
- const result = state.purge(source, { resume: true });
1487
+ /** 冷存里有待删条目的正文副本:先隔离,state.purge 的残留扫描才收得干净。 */
1488
+ const result = purgeWithCold(source, pending.ids, () => state.purge(source, { resume: true }));
1489
+ cleanColdQuarantine(source);
1183
1490
  removeBody(source, id);
1184
1491
  if (pending.ids.includes(id)) {
1185
1492
  replayed = {
@@ -1217,7 +1524,8 @@ export function forget(scope, id, options = {}) {
1217
1524
  }
1218
1525
  if (archived) {
1219
1526
  /** 归档条目也能彻底删除:state.purge 本来就按 id 清整份状态,只是以前没走到这里。 */
1220
- const result = state.purge(source, { ids: [id], operationId: options.operationId });
1527
+ const result = purgeWithCold(source, [id], () => state.purge(source, { ids: [id], operationId: options.operationId }));
1528
+ cleanColdQuarantine(source);
1221
1529
  removeBody(source, id);
1222
1530
  return {
1223
1531
  id,
@@ -1228,7 +1536,8 @@ export function forget(scope, id, options = {}) {
1228
1536
  scopeKey: source.key,
1229
1537
  };
1230
1538
  }
1231
- const result = state.purge(source, { ids: [id], operationId: options.operationId });
1539
+ const result = purgeWithCold(source, [id], () => state.purge(source, { ids: [id], operationId: options.operationId }));
1540
+ cleanColdQuarantine(source);
1232
1541
  removeBody(source, id);
1233
1542
  return {
1234
1543
  id,
@@ -1275,8 +1584,9 @@ function findCurrent(id, scope, options = {}) {
1275
1584
  const record = index.entries.find((item) => item.id === id && item.status !== 'archived');
1276
1585
  if (!record) continue;
1277
1586
  if (!readableRecord(record, options, index)) continue;
1278
- /** 关联目标也按本轮上下文判适用;条件未知、过期或暂停的目标不给标题。 */
1279
- if (view.statusOf(record, options.context || {}) !== 'ok') continue;
1587
+ /** 关联目标与来源走同一资格门槛:项目归属、学习状态、条件适用缺一不可。 */
1588
+ const gate = view.eligibility(record, options.runtime || {}, 'read', options.now || Date.now());
1589
+ if (!gate.ok || gate.status !== 'ok') continue;
1280
1590
  if (isExperimentalRecord(record) && !view.experimentalUsable(record, options.now)) continue;
1281
1591
  return {
1282
1592
  id: record.id,
@@ -1314,6 +1624,7 @@ export function undoChange(scope, id, options = {}) {
1314
1624
  ...record,
1315
1625
  title: last.title || record.title,
1316
1626
  summary: last.summary || record.summary,
1627
+ summaryFull: typeof last.summaryFull === 'string' ? last.summaryFull : '',
1317
1628
  when: last.when || '',
1318
1629
  kind: last.kind || record.kind,
1319
1630
  tags: Array.isArray(last.tags) ? last.tags : record.tags,
@@ -1330,6 +1641,9 @@ export function undoChange(scope, id, options = {}) {
1330
1641
  };
1331
1642
  writeBody(source, record.id, last.body);
1332
1643
  Object.assign(record, restored);
1644
+ record.maintenance = { ...(record.maintenance || {}) };
1645
+ if (record.summaryFull) record.maintenance.summaryFull = record.summaryFull;
1646
+ else delete record.maintenance.summaryFull;
1333
1647
  record.bytes = entrySize(record, last.body);
1334
1648
  try {
1335
1649
  saveIndex(source, index);
@@ -1893,6 +2207,27 @@ const ENVIRONMENT_KEYS = ['machine', 'targetOs', 'runtime', 'shell'];
1893
2207
  * 范围路由:用户明确说的范围最高;没有就按记忆类别提案,设备事实必须带设备条件。
1894
2208
  * 判不出来就返回 needsScope,让调用方问一句,而不是默默写进某一层。
1895
2209
  */
2210
+ /** 本机痕迹:路径、回环地址、端口。带这些字样的「通用」规则换个项目就用不了。 */
2211
+ const MACHINE_BOUND_PATTERNS = [
2212
+ /(?:^|[\s(('"`])(?:\/tmp|\/Users|\/home|\/opt|\/private|\/var|\/etc)(?:[\/]|$)/m,
2213
+ /(?:^|[\s(('"`])~\//m,
2214
+ /(?:^|[^A-Za-z0-9])[A-Za-z]:\\/,
2215
+ /(?:^|[^0-9])127\.0\.0\.1(?::\d+)?/,
2216
+ /(?:^|[^A-Za-z0-9.])localhost(?::\d+)?/,
2217
+ /\b\d{1,3}(?:\.\d{1,3}){3}:\d{2,5}\b/,
2218
+ ];
2219
+
2220
+ function machineBoundText(source) {
2221
+ const text = [source?.title, source?.summary, source?.when, source?.body, source?.topicKey]
2222
+ .filter((value) => typeof value === 'string' && value)
2223
+ .join('\n');
2224
+ return MACHINE_BOUND_PATTERNS.some((re) => re.test(text));
2225
+ }
2226
+
2227
+ /**
2228
+ * 适用范围判定:内容/依据的普适性优先,模型声明的 scope 只是提案。
2229
+ * 模型说 global、但正文写死了本机路径或依据是本机文件时,先留在项目层(或问一次条件)。
2230
+ */
1896
2231
  export function routeScope(draft, options = {}) {
1897
2232
  const source = draft || {};
1898
2233
  const explicit = source.scope === 'global' || source.scope === 'project' ? source.scope : '';
@@ -1900,9 +2235,30 @@ export function routeScope(draft, options = {}) {
1900
2235
  const projectReady = Boolean(options.project) && options.project.confirmed !== false;
1901
2236
  const cls = typeof source.memoryClass === 'string' ? source.memoryClass : '';
1902
2237
  const durability = typeof source.durability === 'string' ? source.durability : 'durable';
2238
+ const evidence = typeof source.evidenceRef === 'string' ? source.evidenceRef.trim() : '';
2239
+ /** 依据本身是本机文件/路径的,不能算通用流程;正文里带本机痕迹同理。 */
2240
+ const machineBound = machineBoundText(source) || (evidence ? machineBoundText({ title: evidence }) : false);
1903
2241
  const base = { chosenScope: '', reasonCode: '', requiredConditions: [], needsScope: false, explicit };
2242
+ /** 通用层拿不出普适证据时:有项目就留在项目层,没有就问一次运行环境。 */
2243
+ const fallbackToProject = (reasonCode) => {
2244
+ if (projectReady) return { ...base, chosenScope: 'project', reasonCode };
2245
+ return {
2246
+ ...base,
2247
+ needsScope: true,
2248
+ reasonCode: 'needs_environment',
2249
+ requiredConditions: [{ key: 'machine', value: '' }, { key: 'runtime', value: '' }],
2250
+ };
2251
+ };
2252
+ /** 人明确点过「就存全局」才算数;模型自己提的 global 不抵消本机痕迹。 */
2253
+ const humanConfirmed = options.humanScope === true || source.scopeConfirmed === true;
1904
2254
  if (durability === 'task') return { ...base, needsScope: true, reasonCode: 'task_state' };
1905
2255
  if (explicit) {
2256
+ /** 模型自己填的 global 只是提案:本机痕迹、项目事实、无依据的通用流程都按提案降级。 */
2257
+ if (explicit === 'global' && !humanConfirmed) {
2258
+ if (machineBound) return fallbackToProject('machine_bound_scope');
2259
+ if (cls === 'project_fact') return fallbackToProject('model_global_project_fact');
2260
+ if (cls === 'portable_procedure' && !evidence) return fallbackToProject('unbound_evidence');
2261
+ }
1906
2262
  if (explicit === 'project' && !projectReady) {
1907
2263
  const reasonCode = options.project && options.project.confirmed === false ? 'unconfirmed_project' : 'no_project';
1908
2264
  return { ...base, needsScope: true, reasonCode, requiredConditions: [{ key: 'projectRoot', value: '' }] };
@@ -1923,8 +2279,17 @@ export function routeScope(draft, options = {}) {
1923
2279
  }
1924
2280
  return { ...base, chosenScope: 'project', reasonCode: 'class_project_fact' };
1925
2281
  }
1926
- if (cls === 'personal_preference') return { ...base, chosenScope: 'global', reasonCode: 'class_personal_preference' };
1927
- if (cls === 'portable_procedure') return { ...base, chosenScope: 'global', reasonCode: 'class_portable_procedure' };
2282
+ if (cls === 'personal_preference') {
2283
+ /** 偏好也分「我的习惯」和「这台机器上的习惯」:后者绑机器,不进通用层。 */
2284
+ if (machineBound) return fallbackToProject('machine_bound_preference');
2285
+ return { ...base, chosenScope: 'global', reasonCode: 'class_personal_preference' };
2286
+ }
2287
+ if (cls === 'portable_procedure') {
2288
+ if (machineBound) return fallbackToProject('machine_bound_procedure');
2289
+ /** 说能搬去别的项目,就要有能搬的依据;没有就先留在本项目。 */
2290
+ if (!evidence) return fallbackToProject('unbound_evidence');
2291
+ return { ...base, chosenScope: 'global', reasonCode: 'class_portable_procedure' };
2292
+ }
1928
2293
  const conditions = view.normalizeConditions(source.conditions) || [];
1929
2294
  const scoped = conditions.some((item) => ENVIRONMENT_KEYS.includes(item.key));
1930
2295
  if (!scoped) {
@@ -2456,9 +2821,15 @@ function economyOf(ledger) {
2456
2821
  }
2457
2822
 
2458
2823
  /** 给整理器的候选规则:只读、有限,不跨项目。 */
2824
+ /** 整理器看到的规则顺序与扣尝试时一致:按状态/最近使用排序,不靠插入顺序。 */
2825
+ function orderedLearningRules(index) {
2826
+ const visible = learningRulesOf(index).filter(visibleLearningRule);
2827
+ return learning.orderOrganizerRules(visible, { limit: learning.LIMITS.organizerRules });
2828
+ }
2829
+
2459
2830
  export function learningRulesForOrganizer(scope) {
2460
2831
  const index = loadIndex(scope);
2461
- return learningRulesOf(index).filter(visibleLearningRule).slice(0, 8).map(learningRuleView);
2832
+ return orderedLearningRules(index).map(learningRuleView);
2462
2833
  }
2463
2834
 
2464
2835
  /**
@@ -2467,16 +2838,17 @@ export function learningRulesForOrganizer(scope) {
2467
2838
  */
2468
2839
  export function beginLearningAttempt(scope, options = {}) {
2469
2840
  const episodeId = String(options.episodeId || '');
2470
- if (!episodeId) return { ok: false, reason: '缺少作品标识' };
2841
+ /** 项目层候选没有作品绑定:按项目身份记一次尝试,限额照样生效,不编造作品 id。 */
2842
+ const attemptKey = episodeId || `project:${scope.key}`;
2471
2843
  const now = options.now || Date.now();
2472
2844
  return withLock(scope, () => {
2473
2845
  const index = loadIndex(scope);
2474
2846
  const evolution = index.__evolution;
2475
2847
  if (!evolution.enabled || evolution.compatible === false) return { ok: false, reason: '实验没有开启' };
2476
2848
  if (evolution.policyVersion !== learning.POLICY_VERSION) return { ok: false, reason: '实验策略版本不认' };
2477
- const check = learning.quota(evolution.learning, { episodeId, now });
2849
+ const check = learning.quota(evolution.learning, { episodeId: attemptKey, now });
2478
2850
  if (!check.ok) return { ...check, learning: evolution.learning };
2479
- const next = learning.recordAttempt(evolution.learning, { episodeId, now });
2851
+ const next = learning.recordAttempt(evolution.learning, { episodeId: attemptKey, now });
2480
2852
  try {
2481
2853
  const result = state.transact(scope, {
2482
2854
  operationId: options.operationId || '',
@@ -2493,7 +2865,7 @@ export function beginLearningAttempt(scope, options = {}) {
2493
2865
  generation: index.generation,
2494
2866
  episodeId,
2495
2867
  learning: next,
2496
- rules: learningRulesOf(index).filter(visibleLearningRule).slice(0, 8).map(learningRuleView),
2868
+ rules: orderedLearningRules(index).map(learningRuleView),
2497
2869
  replayed: result.replayed === true,
2498
2870
  };
2499
2871
  } catch (error) {
@@ -2527,7 +2899,11 @@ export function commitLearning(scope, batch = {}) {
2527
2899
  const operationId = String(batch.operationId || '');
2528
2900
  const now = batch.now || Date.now();
2529
2901
  const at = new Date(now).toISOString();
2530
- if (!episodeId) return { ok: false, reason: '缺少作品标识' };
2902
+ /**
2903
+ * 没绑作品的候选(learning.candidateFrom 在项目层给的空 episodeId)按项目身份提交:
2904
+ * 不编造作品 id,但必须由本项目发起;规则版本、epoch 与目标版本照旧要对得上。
2905
+ */
2906
+ if (!episodeId && String(batch.projectId || '') !== scope.key) return { ok: false, reason: '缺少作品标识' };
2531
2907
  if (!operationId) return { ok: false, reason: '缺少提交标识' };
2532
2908
  if (changes.length > learning.LIMITS.maxChanges) return { ok: false, reason: '一次改动太多' };
2533
2909
  return withLock(scope, () => {
@@ -2542,13 +2918,13 @@ export function commitLearning(scope, batch = {}) {
2542
2918
  if (!evolution || evolution.enabled !== true) throw new Error('实验没有开启,这次整理作废');
2543
2919
  if (evolution.policyVersion !== learning.POLICY_VERSION) throw new Error('实验策略版本不认');
2544
2920
  if (batch.epoch && evolution.epoch !== batch.epoch) throw new Error('实验已经重新开启过,这次整理作废');
2545
- const episode = episodeOfEvolution(evolution, episodeId);
2546
- if (!episode) throw new Error('作品已经失效,这次整理作废');
2547
- if (batch.sessionId) {
2921
+ const episode = episodeId ? episodeOfEvolution(evolution, episodeId) : null;
2922
+ if (episodeId && !episode) throw new Error('作品已经失效,这次整理作废');
2923
+ if (episodeId && batch.sessionId) {
2548
2924
  const focus = evolution.tasks?.sessions?.[batch.sessionId];
2549
- if (!focus || focus.episodeId !== episodeId) throw new Error('这期已经换到别的作品,这次整理作废');
2925
+ if (!focus || focus.episodeId !== episodeId) throw new Error('这期已经换到别的作品,这轮结果没采用');
2550
2926
  }
2551
- if (batch.episodeUpdatedAt && episode.updatedAt !== batch.episodeUpdatedAt) throw new Error('用户又改过这期,这次整理作废');
2927
+ if (episode && batch.episodeUpdatedAt && episode.updatedAt !== batch.episodeUpdatedAt) throw new Error('用户又改过这期,这次整理作废');
2552
2928
  const prepared = new Map((Array.isArray(batch.preparedTargets) ? batch.preparedTargets : []).map((row) => [row.id, row]));
2553
2929
  let touched = 0;
2554
2930
  const currentRules = draft.entries.filter((entry) => entry.status === 'active' && isExperimentalRecord(toFlat(entry)));
@@ -2591,6 +2967,11 @@ export function commitLearning(scope, batch = {}) {
2591
2967
  messageIds: (batch.messageIds || []).slice(-3),
2592
2968
  at,
2593
2969
  };
2970
+ /** 用户原句优先取本次改动带的摘录,其次取整批的:四处回执都能看到原话。 */
2971
+ const excerpt = typeof change.sourceExcerpt === 'string'
2972
+ ? change.sourceExcerpt
2973
+ : (typeof batch.sourceExcerpt === 'string' ? batch.sourceExcerpt : '');
2974
+ if (excerpt) evidence.sourceExcerpt = excerpt;
2594
2975
  merged.learning = {
2595
2976
  ...learning.nextLearningState({
2596
2977
  existing: flat,
@@ -2624,6 +3005,11 @@ export function commitLearning(scope, batch = {}) {
2624
3005
  messageIds: (batch.messageIds || []).slice(-3),
2625
3006
  at,
2626
3007
  };
3008
+ /** 用户原句优先取本次改动带的摘录,其次取整批的:四处回执都能看到原话。 */
3009
+ const excerpt = typeof change.sourceExcerpt === 'string'
3010
+ ? change.sourceExcerpt
3011
+ : (typeof batch.sourceExcerpt === 'string' ? batch.sourceExcerpt : '');
3012
+ if (excerpt) evidence.sourceExcerpt = excerpt;
2627
3013
  const merged = learning.mergeChange(null, change, { now });
2628
3014
  /** 同主题、同条件的新规则并进已有条目:整理器换个措辞不该长出第二条。 */
2629
3015
  const signature = (list) => (Array.isArray(list) ? list : [])