dsh-lite-memory 0.16.4 → 0.18.1

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',
@@ -91,6 +103,10 @@ function toFlat(entry) {
91
103
  reviewState: maintenance.reviewState || 'active',
92
104
  created: entry.createdAt || '',
93
105
  updated: entry.updatedAt || '',
106
+ validFrom: typeof entry.validFrom === 'string' && entry.validFrom ? entry.validFrom : (entry.createdAt || ''),
107
+ validUntil: typeof entry.validUntil === 'string' ? entry.validUntil : '',
108
+ supersededBy: typeof entry.supersededBy === 'string' ? entry.supersededBy : '',
109
+ supersedeReason: typeof entry.supersedeReason === 'string' ? entry.supersedeReason : '',
94
110
  lastUsed: maintenance.lastUsed || entry.updatedAt || entry.createdAt || '',
95
111
  revision: Number(entry.revision) || 1,
96
112
  status: entry.status === 'archived' ? 'archived' : 'active',
@@ -110,7 +126,7 @@ function toFlat(entry) {
110
126
 
111
127
  function fromFlat(record, status) {
112
128
  const at = record.updated || record.created || new Date().toISOString();
113
- return {
129
+ const entry = {
114
130
  id: record.id,
115
131
  revision: Number(record.revision) || 1,
116
132
  status: status || (record.status === 'archived' ? 'archived' : 'active'),
@@ -118,6 +134,10 @@ function fromFlat(record, status) {
118
134
  ownerProjectId: record.ownerProjectId || '',
119
135
  createdAt: record.created || at,
120
136
  updatedAt: at,
137
+ validFrom: typeof record.validFrom === 'string' && record.validFrom ? record.validFrom : (record.created || at),
138
+ validUntil: typeof record.validUntil === 'string' ? record.validUntil : '',
139
+ supersededBy: typeof record.supersededBy === 'string' ? record.supersededBy : '',
140
+ supersedeReason: typeof record.supersedeReason === 'string' ? record.supersedeReason : '',
121
141
  current: {
122
142
  title: record.title || '',
123
143
  summary: record.summary || '',
@@ -148,6 +168,13 @@ function fromFlat(record, status) {
148
168
  lastUsed: record.lastUsed || at,
149
169
  },
150
170
  };
171
+ /** 完整摘要是维护区字段:显式给了就覆盖,给了空串就清掉,不给则沿用旧值。 */
172
+ if (Object.prototype.hasOwnProperty.call(record, 'summaryFull')) {
173
+ const full = typeof record.summaryFull === 'string' ? record.summaryFull : '';
174
+ if (full) entry.maintenance.summaryFull = full;
175
+ else delete entry.maintenance.summaryFull;
176
+ }
177
+ return entry;
151
178
  }
152
179
 
153
180
  /**
@@ -161,7 +188,16 @@ export function loadIndex(scope, options = {}) {
161
188
  }
162
189
  const loaded = state.load(scope, options);
163
190
  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);
191
+ /**
192
+ * 归档条目只加载元数据,正文留在冷存里按需读:列表、图谱、宫殿、snapshot 都
193
+ * 不该为了显示标题去读归档正文,更不能因为某个旧归档缺冷存就把整层读失败。
194
+ */
195
+ const archived = loaded.state.entries.filter((entry) => entry.status === 'archived').map((entry) => {
196
+ const record = toFlat(entry);
197
+ record.cold = Boolean(entry.maintenance && entry.maintenance[COLD_MARK]);
198
+ record.coldBody = '';
199
+ return record;
200
+ });
165
201
  cacheBodies(scope, loaded.state.entries);
166
202
  const index = {
167
203
  version: 2,
@@ -175,10 +211,12 @@ export function loadIndex(scope, options = {}) {
175
211
  receipts: JSON.parse(JSON.stringify(loaded.state.receipts || [])),
176
212
  lastSuggestedAt: loaded.state.lastSuggestedAt || '',
177
213
  suppressions: JSON.parse(JSON.stringify(loaded.state.suppressions || [])),
214
+ conflicts: JSON.parse(JSON.stringify(loaded.state.conflicts || [])),
178
215
  __evolution: state.evolutionOf(loaded.state),
179
216
  __tasks: anchor.normalizeTasks(state.evolutionOf(loaded.state).tasks),
180
217
  __control: state.controlOf(loaded.state),
181
218
  __exists: loaded.exists === true,
219
+ __recovery: loaded.recovery || null,
182
220
  };
183
221
  index.__meta = {
184
222
  exists: loaded.exists,
@@ -190,14 +228,16 @@ export function loadIndex(scope, options = {}) {
190
228
  }
191
229
 
192
230
  /** 扁平索引 → v2 状态:整层重写,条目、候选、回执一次换完。 */
193
- function applyFlat(draft, index) {
231
+ function applyFlat(scope, draft, index) {
194
232
  const next = [];
195
233
  for (const record of index.entries) next.push(fromFlat(record));
196
- for (const record of index.archived || []) next.push(fromFlat(record, 'archived'));
234
+ /** 归档正文不写回热状态:先落冷存再摘空,冷存写失败就整次保存失败,不丢正文。 */
235
+ for (const record of index.archived || []) next.push(fromFlat(coldStored(scope, record), 'archived'));
197
236
  draft.entries = next;
198
237
  draft.candidates = Array.isArray(index.suggestions) ? JSON.parse(JSON.stringify(index.suggestions)) : [];
199
238
  draft.receipts = Array.isArray(index.receipts) ? JSON.parse(JSON.stringify(index.receipts)) : [];
200
239
  draft.suppressions = Array.isArray(index.suppressions) ? JSON.parse(JSON.stringify(index.suppressions)) : [];
240
+ draft.conflicts = Array.isArray(index.conflicts) ? JSON.parse(JSON.stringify(index.conflicts)) : [];
201
241
  draft.lastSuggestedAt = typeof index.lastSuggestedAt === 'string' ? index.lastSuggestedAt : draft.lastSuggestedAt || '';
202
242
  draft.scopeKey = draft.scopeKey || index.scope;
203
243
  }
@@ -210,9 +250,10 @@ export function saveIndex(scope, index) {
210
250
  const meta = index.__meta || { exists: false };
211
251
  const expected = meta.exists ? { generation: meta.generation, epoch: meta.epoch } : null;
212
252
  const result = state.transact(scope, { expected, expectCreate: !meta.exists }, (draft) => {
213
- const before = JSON.stringify([draft.entries, draft.candidates, draft.receipts, draft.suppressions, draft.lastSuggestedAt]);
214
- applyFlat(draft, index);
215
- const after = JSON.stringify([draft.entries, draft.candidates, draft.receipts, draft.suppressions, draft.lastSuggestedAt]);
253
+ const stamp = () => JSON.stringify([draft.entries, draft.candidates, draft.receipts, draft.suppressions, draft.conflicts, draft.lastSuggestedAt]);
254
+ const before = stamp();
255
+ applyFlat(scope, draft, index);
256
+ const after = stamp();
216
257
  return { changed: before !== after };
217
258
  });
218
259
  const published = result.state;
@@ -291,6 +332,7 @@ export function normalizeDraft(source, options = {}) {
291
332
  const has = (key) => Object.prototype.hasOwnProperty.call(draft, key) && draft[key] !== undefined;
292
333
  const id = typeof draft.id === 'string' && /^mem_[a-z0-9]+$/.test(draft.id) ? draft.id : '';
293
334
  if (has('id') && !id) throw new Error('id 格式不对');
335
+ if (has('supersedes') && !/^mem_[a-z0-9]+$/.test(String(draft.supersedes ?? ''))) throw new Error('supersedes 要给被取代的 id');
294
336
  const when = has('when') ? textField(draft.when, LIMITS.whenChars, '前置条件') : '';
295
337
  const title = textField(cleanTitle(draft.title), LIMITS.titleChars, '标题');
296
338
  /** 带 id 是更新:省略的字段保持原值,标题也一样。 */
@@ -311,17 +353,18 @@ export function normalizeDraft(source, options = {}) {
311
353
  const mayOmitSummary = options.requireSummary === false || Boolean(id);
312
354
  if (!summary && !mayOmitSummary) throw new Error('摘要为空');
313
355
  /**
314
- * 正文是否由调用方显式给出,和「摘要太长所以自动补一份」必须分开。
315
- * 否则同名更新会被自动补出的正文当成显式覆盖,把原正文冲掉。
356
+ * 正文只属于调用方显式给出的内容:摘要超长时不再往正文里塞托管尾巴,
357
+ * 完整原句单独进维护区(maintenance.summaryFull),正文保持原样。
316
358
  */
317
359
  const bodyExplicit = has('body');
318
360
  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} 字节;先拆成两条写,别丢后半段`
361
+ const summaryFull = summaryShortened ? rawSummary : '';
362
+ const bodyBytes = Buffer.byteLength(body);
363
+ /** 预算按真正会存进去的正文 + 完整摘要算:超限就报错,不截断后谎报成功。 */
364
+ const storedBytes = bodyBytes + Buffer.byteLength(summaryFull);
365
+ if (storedBytes > LIMITS.entryBytes) {
366
+ throw new Error(summaryFull
367
+ ? `正文 ${bodyBytes} 字节加完整摘要 ${Buffer.byteLength(summaryFull)} 字节,超过单条 ${LIMITS.entryBytes} 字节;先拆成两条写,别丢后半段`
325
368
  : `正文 ${bodyBytes} 字节,超过单条 ${LIMITS.entryBytes} 字节;只留原则、判断规则和触发条件`);
326
369
  }
327
370
  const kind = has('kind') ? textField(draft.kind, 20, '类型') : 'note';
@@ -337,8 +380,9 @@ export function normalizeDraft(source, options = {}) {
337
380
  const links = view.normalizeLinks(draft.links, id);
338
381
  const applicability = view.normalizeApplicability(draft.applicability);
339
382
  const topicKey = has('topicKey') ? textField(draft.topicKey, LIMITS.tagChars, '主题') : undefined;
340
- const secret = detectSecret([title, summary, when, body, tags.join(' '), topicKey || ''].join('\n'));
341
- if (secret) throw new Error(`疑似凭据(${secret}),只记位置,不记值`);
383
+ /** 凭据扫描必须覆盖完整原文:截短后的摘要看不出凭据,原文里有也不行。 */
384
+ const secret = detectSecret([title, summary, rawSummary, when, body, tags.join(' '), topicKey || ''].join('\n'));
385
+ if (secret) throw new state.StateError('unsafe', `疑似凭据(${secret}),只记位置,不记值:正文里写「去哪取、怎么取」,不抄值`);
342
386
  return {
343
387
  id,
344
388
  title,
@@ -347,7 +391,10 @@ export function normalizeDraft(source, options = {}) {
347
391
  body,
348
392
  summaryShortened,
349
393
  summaryRaw: rawSummary,
350
- autoBody,
394
+ summaryFull,
395
+ /** 回执字段:完整原句已另存维护区(不是塞进正文)。 */
396
+ fullTextSaved: Boolean(summaryFull),
397
+ /** 兼容旧回执:正文里从来没有托管尾巴,这个字段恒为 false。 */
351
398
  fullTextInBody: false,
352
399
  kind: kind || 'note',
353
400
  tags,
@@ -357,6 +404,8 @@ export function normalizeDraft(source, options = {}) {
357
404
  links,
358
405
  applicability,
359
406
  topicKey,
407
+ /** 显式取代:新记录写好后再给旧记录打 supersededBy/validUntil。 */
408
+ supersedes: has('supersedes') ? textField(draft.supersedes, 60, '取代目标') : '',
360
409
  has: {
361
410
  title: has('title') && Boolean(title),
362
411
  summary: has('summary'),
@@ -370,6 +419,7 @@ export function normalizeDraft(source, options = {}) {
370
419
  links: links !== undefined,
371
420
  applicability: applicability !== undefined,
372
421
  topicKey: topicKey !== undefined,
422
+ supersedes: has('supersedes'),
373
423
  },
374
424
  };
375
425
  }
@@ -381,9 +431,27 @@ function newId() {
381
431
  /** 正文就在状态里;缓存只是省一次读盘,得手时一律回状态读。 */
382
432
  function readBody(scope, id) {
383
433
  const cached = cachedBody(scope, id);
384
- if (typeof cached === 'string') return cached;
434
+ if (typeof cached === 'string' && cached !== '') return cached;
385
435
  const found = state.readEntryBody(scope, id);
386
436
  if (!found) throw new Error(`记忆正文缺失:${id} 在索引里,但正文文件不在了`);
437
+ /**
438
+ * 归档正文不在热状态里:只有真要读这一条正文时才回冷存取,
439
+ * 缺文件/坏文件都报明确错误,不拿空正文冒充「这条没有正文」。
440
+ */
441
+ if (!found.body && found.entry && found.entry.status === 'archived') {
442
+ let payload = null;
443
+ try {
444
+ payload = readCold(scope, id, { required: true });
445
+ } catch (error) {
446
+ /** 旧版归档在正文为空时可能没写过冷存:这类老记录按空正文读回,不改写也不再报错。 */
447
+ const legacy = !(found.entry.maintenance && found.entry.maintenance.coldSaved === true);
448
+ if (!(legacy && error && error.code === 'cold_missing')) throw error;
449
+ }
450
+ if (payload && typeof payload.body === 'string' && payload.body) {
451
+ writeBody(scope, id, payload.body);
452
+ return payload.body;
453
+ }
454
+ }
387
455
  return found.body;
388
456
  }
389
457
 
@@ -398,6 +466,192 @@ function removeBody(scope, id) {
398
466
  if (map) map.delete(id);
399
467
  }
400
468
 
469
+ /**
470
+ * 归档正文冷存:热状态只留元数据,正文和旧版本正文放 scope.dir/cold/<id>.json。
471
+ * 五百条归档 ≈ 2 MiB 时,只有真看过的那几条会回到内存,保存不再被整层预算卡死。
472
+ */
473
+ const COLD_DIR = 'cold';
474
+ const COLD_MARK = 'coldBody';
475
+
476
+ function coldDirOf(scope) {
477
+ return path.join(scope.dir, COLD_DIR);
478
+ }
479
+
480
+ function coldFileOf(scope, id) {
481
+ return path.join(coldDirOf(scope), `${id}.json`);
482
+ }
483
+
484
+ function coldPayload(scope, record) {
485
+ return {
486
+ /** 冷存文件自带身份:读回来先核对是不是这一条、这一层,不靠文件名猜。 */
487
+ id: record.id,
488
+ scope: scope.key,
489
+ revision: Number(record.revision) || 1,
490
+ at: new Date().toISOString(),
491
+ body: typeof record.body === 'string' ? record.body : '',
492
+ versions: (Array.isArray(record.versions) ? record.versions : [])
493
+ .map((version) => ({ at: String((version && version.at) || ''), body: typeof (version && version.body) === 'string' ? version.body : '' }))
494
+ .filter((version) => version.body),
495
+ };
496
+ }
497
+
498
+ /**
499
+ * 冷存写失败就报错:宁可整次保存失败,也不能把正文悄悄丢掉。
500
+ * 落盘序列与 state.json 一致(fsync 文件 → rename → fsync 目录):归档正文只在这里,
501
+ * 少一步 fsync,崩溃后就是「热状态说有归档、冷存文件是空的」。
502
+ */
503
+ function writeCold(scope, record) {
504
+ const file = coldFileOf(scope, record.id);
505
+ try {
506
+ state.writeDurable(file, `${JSON.stringify(coldPayload(scope, record))}\n`);
507
+ } catch (error) {
508
+ throw new state.StateError('unavailable', `归档正文没写进冷存(${path.basename(file)}):${(error && error.message) || 'IO 错误'}`);
509
+ }
510
+ }
511
+
512
+ /**
513
+ * 读冷存:文件缺失时按 required 决定返回 null 还是报错;JSON 损坏、身份不符
514
+ * 一律抛明确错误,绝不静默返回空正文冒充「这条没有正文」。
515
+ */
516
+ function readCold(scope, id, options = {}) {
517
+ const required = options.required === true;
518
+ const file = coldFileOf(scope, id);
519
+ let raw;
520
+ try {
521
+ raw = fs.readFileSync(file, 'utf8');
522
+ } catch (error) {
523
+ if (error && error.code === 'ENOENT') {
524
+ if (!required) return null;
525
+ throw new state.StateError('cold_missing', `冷存里没有这条的正文备份(${file});先修复冷存再读,不拿空正文顶上`);
526
+ }
527
+ throw error;
528
+ }
529
+ let payload;
530
+ try {
531
+ payload = JSON.parse(raw);
532
+ } catch {
533
+ throw new state.StateError('cold_corrupt', `冷存文件不是合法 JSON(${file});先修复再读,不拿空正文顶上`);
534
+ }
535
+ if (!payload || typeof payload !== 'object' || payload.id !== id || payload.scope !== scope.key || typeof payload.body !== 'string') {
536
+ throw new state.StateError('cold_corrupt', `冷存文件记录的条目不匹配这一条或这一层(${file});先修复再读,不拿空正文顶上`);
537
+ }
538
+ return payload;
539
+ }
540
+
541
+ /** 写回状态前把归档正文摘出去:只有正文/旧版本真变了才重写冷存文件。 */
542
+ function coldStored(scope, record) {
543
+ if (record.status !== 'archived') return record;
544
+ const versions = Array.isArray(record.versions) ? record.versions : [];
545
+ const hasVersionBodies = versions.some((version) => typeof (version && version.body) === 'string' && version.body);
546
+ /** 内存记录的正文可能已经是空串(占位/摘过):先按 coldBody、冷存文件、热状态顺序找回。 */
547
+ let body = typeof record.body === 'string' ? record.body : '';
548
+ const known = typeof record.coldBody === 'string' ? record.coldBody : null;
549
+ if (body || known !== null) {
550
+ body = body || known;
551
+ } else {
552
+ /** 冷存是唯一权威副本;它坏了先退回热状态里的正文(保存时会重写成好文件),两处都没有才报错。 */
553
+ let coldError = null;
554
+ try {
555
+ const payload = readCold(scope, record.id);
556
+ if (payload && typeof payload.body === 'string' && payload.body) body = payload.body;
557
+ } catch (error) {
558
+ coldError = error;
559
+ }
560
+ if (!body) {
561
+ const found = state.readEntryBody(scope, record.id);
562
+ if (found && typeof found.body === 'string') body = found.body;
563
+ }
564
+ if (coldError && !body) throw coldError;
565
+ }
566
+ /** 正文和冷存里的一致就不重写:五百条归档每次保存只动真变过的那几条。 */
567
+ const cached = record.cold === true && known !== null && known === body;
568
+ /** 空正文也要落一份冷存:只要标了 coldBody,就必须有自证身份的文件可读回。 */
569
+ if (!cached) writeCold(scope, { ...record, body });
570
+ return {
571
+ ...record,
572
+ body: '',
573
+ cold: true,
574
+ coldBody: body,
575
+ versions: versions.map((version) => ({ ...(version || {}), body: '' })),
576
+ maintenance: { ...(record.maintenance || {}), [COLD_MARK]: true, coldSaved: true },
577
+ };
578
+ }
579
+
580
+ /**
581
+ * 彻底删除是一个事务:先把要删的冷存文件按 id 精确移到层外的隔离区
582
+ * (state.purge 只扫 scope.dir,所以隔离区必须在层外),state.purge 成功后才丢弃隔离副本;
583
+ * 失败按错误类型恢复或留待重放收尾。绝不按标题/正文片段扫描别人的冷存文件。
584
+ */
585
+ const PURGE_TRASH_DIR = '.purge-trash';
586
+
587
+ function trashDirOf(scope) {
588
+ const safe = String(scope.key || '').replace(/[^A-Za-z0-9._-]/g, '_') || '_';
589
+ return path.join(memoryRoot(), PURGE_TRASH_DIR, safe);
590
+ }
591
+
592
+ /** 只搬走 ids 对应的 <id>.json;别的条目一个字节都不动。 */
593
+ function isolateCold(scope, ids, operationId) {
594
+ const token = String(operationId || '').replace(/[^A-Za-z0-9._-]/g, '_') || crypto.randomBytes(8).toString('hex');
595
+ const dir = path.join(trashDirOf(scope), token);
596
+ const moved = [];
597
+ for (const id of ids || []) {
598
+ const from = coldFileOf(scope, id);
599
+ if (!fs.existsSync(from)) continue;
600
+ fs.mkdirSync(dir, { recursive: true });
601
+ const to = path.join(dir, `${id}.json`);
602
+ fs.renameSync(from, to);
603
+ moved.push({ from, to });
604
+ }
605
+ return { dir, moved };
606
+ }
607
+
608
+ function restoreCold(isolated) {
609
+ let restored = true;
610
+ for (const row of isolated.moved) {
611
+ try {
612
+ fs.renameSync(row.to, row.from);
613
+ } catch {
614
+ /** 恢复失败就保留整个隔离区:副本还在,重放/人工都能取回,不能顺手删掉。 */
615
+ restored = false;
616
+ }
617
+ }
618
+ if (restored) dropColdQuarantine(isolated);
619
+ }
620
+
621
+ function dropColdQuarantine(isolated) {
622
+ try {
623
+ fs.rmSync(isolated.dir, { recursive: true, force: true });
624
+ } catch {
625
+ /** 删不掉隔离目录不影响删除结果,下次收尾再清。 */
626
+ }
627
+ }
628
+
629
+ /** 删除确认后的收尾:清掉这一层隔离区里残留的副本(上一次做到一半留下的)。 */
630
+ function cleanColdQuarantine(scope) {
631
+ try {
632
+ fs.rmSync(trashDirOf(scope), { recursive: true, force: true });
633
+ } catch {
634
+ /** 收尾清不掉不影响数据正确性。 */
635
+ }
636
+ }
637
+
638
+ /**
639
+ * 删除与冷存的一致顺序:隔离 → 发布删除 → 丢弃隔离副本。
640
+ * 发布失败(例如 purge_pending)时保留隔离副本,留给重放路径收尾。
641
+ */
642
+ function purgeWithCold(scope, ids, run) {
643
+ const isolated = isolateCold(scope, ids);
644
+ try {
645
+ const result = run();
646
+ dropColdQuarantine(isolated);
647
+ return result;
648
+ } catch (error) {
649
+ if (error && error.code === 'purge_pending') throw error;
650
+ restoreCold(isolated);
651
+ throw error;
652
+ }
653
+ }
654
+
401
655
  function entrySize(record, body) {
402
656
  const meta = [record.title || '', record.summary || '', record.when || '', (record.tags || []).join(',')].join('\n');
403
657
  return Buffer.byteLength(meta) + Buffer.byteLength(body || '') + Buffer.byteLength(state.fieldsBytesOf(record));
@@ -456,9 +710,9 @@ const STOP_GRAMS = new Set([
456
710
  '的话', '这里', '那里', '一个', '一些', '这样', '那样', '看看', '帮我看', '继续',
457
711
  ]);
458
712
 
459
- const asciiWordsOf = (text) => normalizeText(text, 400).toLowerCase().match(/[a-z][a-z0-9_-]{1,}/g) || [];
460
-
461
- const wordIn = (pool, word) => new RegExp(`(^|[^a-z0-9])${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^a-z0-9])`).test(pool);
713
+ /** 英文整词规则只有一份,放在 view:自动召回和手动搜索必须同一个判断。 */
714
+ const asciiWordsOf = view.asciiWordsOf;
715
+ const wordIn = view.wordIn;
462
716
 
463
717
  /** 查询按空格/标点切成段:多段按关键词处理,整句仍按相关度处理。 */
464
718
  function queryRuns(query) {
@@ -573,7 +827,16 @@ export function similarEntries(entries, title, summary, limit = 2) {
573
827
  const titleScore = Math.max(0, ...names.map((name) => similarity(name, title)));
574
828
  const summaryScore = similarity(item.summary, summary);
575
829
  const score = Math.max(titleScore, summaryScore);
576
- if (score >= MERGE.near) rows.push({ id: item.id, title: item.title, summary: item.summary, when: item.when || '', score });
830
+ if (score >= MERGE.near) rows.push({
831
+ id: item.id,
832
+ title: item.title,
833
+ summary: item.summary,
834
+ when: item.when || '',
835
+ /** 候选要能自己解释「为什么像」:标题相近,还是同一主题槽位。 */
836
+ updated: item.updated || item.created || '',
837
+ reason: titleScore >= MERGE.near ? 'similar_title' : 'similar_summary',
838
+ score,
839
+ });
577
840
  }
578
841
  rows.sort((a, b) => b.score - a.score);
579
842
  return rows.slice(0, limit);
@@ -607,6 +870,7 @@ function snapshot(record, body) {
607
870
  at: record.updated || record.created || new Date().toISOString(),
608
871
  title: record.title,
609
872
  summary: record.summary,
873
+ summaryFull: typeof record.summaryFull === 'string' ? record.summaryFull : '',
610
874
  when: record.when || '',
611
875
  kind: record.kind || 'note',
612
876
  tags: [...(record.tags || [])],
@@ -651,12 +915,12 @@ function rulesOf(draft, body, index, foreign) {
651
915
  return rules;
652
916
  }
653
917
 
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 };
918
+ /**
919
+ * 旧正文里可能留着「摘要原文:」尾巴:只识别、做标记,绝不改写。
920
+ * 猜不出来哪一段是托管内容,删错就是把用户原话删了。
921
+ */
922
+ function hasLegacySummaryTail(text) {
923
+ return typeof text === 'string' && text.includes(LEGACY_SUMMARY_TAIL);
660
924
  }
661
925
 
662
926
  function mergedFields(draft, record, oldBody, index, writeMode = 'patch', foreign) {
@@ -672,9 +936,8 @@ function mergedFields(draft, record, oldBody, index, writeMode = 'patch', foreig
672
936
  selection: draft.has.selection ? draft.selection : null,
673
937
  links: draft.has.links ? draft.links : [],
674
938
  topicKey: draft.has.topicKey ? draft.topicKey : '',
675
- body: bodyWithSummaryTail(draft.body, draft).body,
939
+ body: String(draft.body || ''),
676
940
  };
677
- if (next.body !== draft.body) draft.fullTextInBody = true;
678
941
  next.applicability = view.validateApplicability(draft.has.applicability ? draft.applicability : undefined,
679
942
  next.conditions, next.selection, next.when);
680
943
  assertLinksVisible(next.links, index, foreign);
@@ -707,36 +970,74 @@ function mergedFields(draft, record, oldBody, index, writeMode = 'patch', foreig
707
970
  return next;
708
971
  }
709
972
 
973
+ /** 已被取代或过了有效期:默认检索看不见,明确要 history 时仍可查到并读正文。 */
974
+ function staleRecord(record, now = Date.now()) {
975
+ if (record.supersededBy) return true;
976
+ const until = Date.parse(record.validUntil || '');
977
+ return Number.isFinite(until) && until <= now;
978
+ }
979
+
980
+ /** 给旧记录打上取代链:正文快照交回调用方,放进新记录的 versions 链头。 */
981
+ function stampSupersede(scope, target, nextId, now) {
982
+ if (target.supersededBy) {
983
+ throw new state.StateError('conflict', `这条记忆已经被 ${target.supersededBy} 取代,不能再取代一次`);
984
+ }
985
+ if (target.id === nextId) throw new Error('不能取代自己');
986
+ const versions = snapshot(target, readBody(scope, target.id));
987
+ target.validUntil = now;
988
+ target.supersededBy = nextId;
989
+ target.supersedeReason = 'user_replaced';
990
+ return versions;
991
+ }
992
+
710
993
  /**
711
994
  * 一次写入的纯业务部分:校验、身份、字节预算、版本。不改盘(正文写入由调用方在同一把锁里做)。
712
995
  */
713
996
  function applyDraft(scope, index, source, now, options = {}) {
714
997
  const draft = normalizeDraft(source);
998
+ scanUnsafe(draft);
715
999
  const foreign = options.foreignEntries || [];
716
1000
  const distinct = source && source.resolution === 'distinct';
1001
+ /** 取代/过期记录不参与合并判断:新内容不该再合进一条已经不是现行的记录。 */
1002
+ const live = index.entries.filter((item) => !staleRecord(item));
1003
+ const supersedeTarget = (selfId) => {
1004
+ if (!draft.supersedes) return null;
1005
+ const target = index.entries.find((item) => item.id === draft.supersedes);
1006
+ if (!target) throw new Error(`要取代的记忆不在这一层:${draft.supersedes}`);
1007
+ if (target.id === selfId) throw new Error('不能取代自己');
1008
+ return target;
1009
+ };
717
1010
  let record = draft.id ? index.entries.find((item) => item.id === draft.id) : null;
718
1011
  if (draft.id && !record) throw new Error('没有这条记忆,改用新增');
719
1012
  let mergedFrom = null;
720
1013
  let similarList = [];
721
1014
  if (!record) {
722
1015
  /** distinct 是用户明确说的「这是另一条」:同名也另起一条,不被规范化后的精确同名吞掉。 */
723
- const hit = distinct ? null : resolveTarget(index.entries, draft);
1016
+ const hit = distinct ? null : resolveTarget(live, draft);
724
1017
  if (hit) {
725
1018
  record = hit.target;
726
1019
  mergedFrom = hit.target.title === draft.title ? null : hit.target.title;
727
1020
  } else if (draft.has.topicKey && draft.topicKey && !distinct) {
728
1021
  /** 同一槽位只留一条现行规范:同层同 topicKey + 同条件必须确认是更新还是另一条。 */
729
- const slot = index.entries.find((item) => (item.topicKey || '') === draft.topicKey
1022
+ const slot = live.find((item) => (item.topicKey || '') === draft.topicKey
730
1023
  && view.conditionsKey(item.conditions) === view.conditionsKey(draft.conditions));
731
1024
  if (slot) {
732
- const row = { id: slot.id, title: slot.title, summary: slot.summary || '', revision: slot.revision || 1 };
1025
+ const row = {
1026
+ id: slot.id,
1027
+ title: slot.title,
1028
+ summary: slot.summary || '',
1029
+ when: slot.when || '',
1030
+ updated: slot.updated || slot.created || '',
1031
+ reason: 'same_topic',
1032
+ revision: slot.revision || 1,
1033
+ };
733
1034
  return { needsResolution: true, sameTopic: true, similarList: [row], similar: row, draft, record: null, merged: false, previousBody: null, archived: [] };
734
1035
  }
735
- similarList = similarEntries(index.entries, draft.title, draft.summary, 2);
1036
+ similarList = similarEntries(live, draft.title, draft.summary, 2);
736
1037
  } else {
737
- similarList = similarEntries(index.entries, draft.title, draft.summary, 2);
1038
+ similarList = similarEntries(live, draft.title, draft.summary, 2);
738
1039
  /** 同名不同前置条件是用户明确表达的另一条,不走确认;只有换个说法的相近标题才拦。 */
739
- const sameName = index.entries.some((item) => [item.title, ...(item.aliases || [])]
1040
+ const sameName = live.some((item) => [item.title, ...(item.aliases || [])]
740
1041
  .some((name) => String(name || '').toLowerCase() === draft.title.toLowerCase()));
741
1042
  if (similarList.length > 0 && !distinct && !sameName) {
742
1043
  return { needsResolution: true, similarList, similar: similarList[0], draft, record: null, merged: false, previousBody: null, archived: [] };
@@ -750,11 +1051,16 @@ function applyDraft(scope, index, source, now, options = {}) {
750
1051
  }
751
1052
  }
752
1053
  if (record) {
1054
+ const target = supersedeTarget(record.id);
753
1055
  const oldBody = readBody(scope, record.id);
754
1056
  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;
1057
+ /** 正文原样保存:摘要再长也不往正文里补尾巴;完整原句进维护区。 */
1058
+ draft.fullTextInBody = false;
1059
+ /** 摘要这次被改过就按本次结果落 summaryFull:改短即清空,没改则保持原值。 */
1060
+ const summaryFull = draft.has.summary
1061
+ ? (draft.summaryShortened ? String(draft.summaryRaw || '') : '')
1062
+ : (typeof record.summaryFull === 'string' ? record.summaryFull : '');
1063
+ draft.fullTextSaved = Boolean(summaryFull);
758
1064
  const noop = next.title === record.title && next.summary === record.summary
759
1065
  && next.when === (record.when || '') && next.kind === (record.kind || 'note')
760
1066
  && JSON.stringify(next.tags) === JSON.stringify(record.tags || [])
@@ -764,8 +1070,9 @@ function applyDraft(scope, index, source, now, options = {}) {
764
1070
  && JSON.stringify(next.links) === JSON.stringify(record.links || [])
765
1071
  && (next.topicKey || '') === (record.topicKey || '')
766
1072
  && next.applicability === (record.applicability || 'all_in_scope')
767
- && next.body === oldBody;
768
- if (noop) {
1073
+ && next.body === oldBody
1074
+ && summaryFull === (typeof record.summaryFull === 'string' ? record.summaryFull : '');
1075
+ if (noop && !target) {
769
1076
  return { record, merged: true, mergedFrom: null, similar: null, similarList, draft, noop: true, previousBody: oldBody, archived: [] };
770
1077
  }
771
1078
  const versions = snapshot(record, oldBody);
@@ -773,6 +1080,7 @@ function applyDraft(scope, index, source, now, options = {}) {
773
1080
  const nextRecord = {
774
1081
  ...record,
775
1082
  ...next,
1083
+ summaryFull,
776
1084
  aliases: titles,
777
1085
  versions,
778
1086
  revision: (record.revision || 1) + 1,
@@ -782,22 +1090,36 @@ function applyDraft(scope, index, source, now, options = {}) {
782
1090
  status: 'active',
783
1091
  source: record.source || (source && source.source) || 'user_explicit',
784
1092
  };
1093
+ nextRecord.maintenance = { ...(record.maintenance || {}) };
1094
+ if (summaryFull) nextRecord.maintenance.summaryFull = summaryFull;
1095
+ else delete nextRecord.maintenance.summaryFull;
1096
+ /** 旧正文里的托管尾巴只标记待整理,绝不在这里改写用户正文。 */
1097
+ if (hasLegacySummaryTail(next.body)) nextRecord.maintenance.legacySummaryTail = true;
785
1098
  nextRecord.bytes = entrySize(nextRecord, next.body);
786
1099
  const archived = assertFits(scope, index, nextRecord);
787
1100
  writeBody(scope, record.id, next.body);
788
1101
  Object.assign(record, nextRecord);
789
- return { record, merged: true, mergedFrom, similar: null, similarList, draft, noop: false, previousBody: oldBody, archived };
1102
+ /** 更新让这条重新成为现行版本:旧的取代链标记要摘掉。 */
1103
+ delete record.supersededBy;
1104
+ record.validUntil = '';
1105
+ if (target) {
1106
+ const chain = stampSupersede(scope, target, record.id, now);
1107
+ record.versions = [...chain, ...(record.versions || [])].slice(0, 2);
1108
+ }
1109
+ return { record, merged: true, mergedFrom, similar: null, similarList, draft, noop: Boolean(noop), previousBody: oldBody, archived };
790
1110
  }
791
1111
  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;
1112
+ /** 新建也只用调用方给的正文:超长摘要的完整原句进维护区,不混进正文。 */
1113
+ const target = supersedeTarget('');
1114
+ const createdBody = draft.body;
1115
+ draft.fullTextInBody = false;
1116
+ draft.fullTextSaved = Boolean(draft.summaryFull);
796
1117
  const rules = rulesOf(draft, createdBody, index, foreign);
797
1118
  const created = {
798
1119
  id: newId(),
799
1120
  title: draft.title,
800
1121
  summary: draft.summary,
1122
+ summaryFull: draft.summaryFull,
801
1123
  when: draft.when,
802
1124
  kind: draft.kind,
803
1125
  tags: draft.tags,
@@ -811,6 +1133,10 @@ function applyDraft(scope, index, source, now, options = {}) {
811
1133
  aliases: [],
812
1134
  created: now,
813
1135
  updated: now,
1136
+ validFrom: now,
1137
+ validUntil: '',
1138
+ supersededBy: '',
1139
+ supersedeReason: '',
814
1140
  lastUsed: now,
815
1141
  revision: 1,
816
1142
  status: 'active',
@@ -818,15 +1144,52 @@ function applyDraft(scope, index, source, now, options = {}) {
818
1144
  hits: 0,
819
1145
  merges: 0,
820
1146
  versions: [],
1147
+ maintenance: {},
821
1148
  bytes: 0,
822
1149
  };
1150
+ if (draft.summaryFull) created.maintenance.summaryFull = draft.summaryFull;
1151
+ /** 旧正文里的托管尾巴只标记待整理,绝不改写用户正文。 */
1152
+ if (hasLegacySummaryTail(createdBody)) created.maintenance.legacySummaryTail = true;
823
1153
  created.bytes = entrySize(created, created.body);
824
1154
  const archived = assertFits(scope, index, created);
825
1155
  writeBody(scope, created.id, created.body);
826
1156
  index.entries.push(created);
1157
+ if (target) created.versions = stampSupersede(scope, target, created.id, now);
827
1158
  return { record: created, merged: false, mergedFrom: null, similar: similarList[0] || null, similarList, draft, noop: false, previousBody: null, archived };
828
1159
  }
829
1160
 
1161
+ /**
1162
+ * 写入前兜底扫描:记忆会被 memory_read 原样喂回模型,所以只拦形状明确的三类——
1163
+ * 提示注入指令、凭据原文、不可见 Unicode;提到「凭据 / 指令」的正文不算。
1164
+ */
1165
+ const UNSAFE_TEXT = [
1166
+ { code: 'injection', why: '像提示注入的指令', fix: '把「指令」改成对规则的描述', test: /ignore\s+(?:all\s+)?(?:previous|prior|above)\s+(?:instructions?|rules?|prompts?)/i },
1167
+ { code: 'injection', why: '像提示注入的指令', fix: '把「指令」改成对规则的描述', test: /忽略(?:上面|以上|此前|之前)?(?:的)?(?:所有)?(?:指令|指示|要求|规则)/ },
1168
+ { code: 'injection', why: '像提示注入的指令', fix: '把「指令」改成对规则的描述', test: /<\/?(?:system|instructions?|prompt|tool_call)[^>]*>/i },
1169
+ { code: 'injection', why: '像提示注入的指令', fix: '把「指令」改成对规则的描述', test: /(?:^|\n)\s*(?:system|assistant)\s*[::]/i },
1170
+ ];
1171
+ const INVISIBLE = /[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]|[\u{E0000}-\u{E007F}]/u;
1172
+ const SCAN_FIELD_NAMES = { title: '标题', summary: '摘要', when: '适用场景', body: '正文', tags: '标签', triggers: '触发词', conditions: '条件', selection: '适用范围' };
1173
+
1174
+ function scanUnsafe(draft) {
1175
+ const fields = [
1176
+ ['title', draft.title], ['summary', draft.summary], ['when', draft.when], ['body', draft.body],
1177
+ ['tags', (draft.tags || []).join('\n')], ['triggers', (draft.triggers || []).join('\n')],
1178
+ ['conditions', JSON.stringify(draft.conditions || [])], ['selection', JSON.stringify(draft.selection || null)],
1179
+ ];
1180
+ for (const [field, raw] of fields) {
1181
+ const text = typeof raw === 'string' ? raw : '';
1182
+ if (!text) continue;
1183
+ const name = SCAN_FIELD_NAMES[field] || field;
1184
+ if (INVISIBLE.test(text)) {
1185
+ throw new state.StateError('unsafe', `${name}里有看不见的字符(零宽或方向控制);它们会跟着记忆原样回到模型,删掉再存`);
1186
+ }
1187
+ for (const rule of UNSAFE_TEXT) {
1188
+ if (rule.test.test(text)) throw new state.StateError('unsafe', `${name}${rule.why};${rule.fix}`);
1189
+ }
1190
+ }
1191
+ }
1192
+
830
1193
  /**
831
1194
  * 单条与记录序列化两档预算:新增与更新走同一套。
832
1195
  * 整层条数/正文预算由 state.transact 在发布前裁决(腾最久没读的那条,不拒绝新记忆),
@@ -1055,8 +1418,41 @@ export function setTaskDefault(scope, defaults, options = {}) {
1055
1418
  * 返回的记录带着「本次检索之前」的 lastUsed,给 renderIndex 判断陈旧用;
1056
1419
  * 命中计数在返回前写入,所以同一次调用既改盘又不污染展示。
1057
1420
  */
1421
+ /** 「用过」的判定门槛:lastUsed 超过这么久才回写,检索不至于每次都抢锁落盘。 */
1422
+ const USED_TOUCH_MS = 10 * 60 * 1000;
1423
+
1424
+ function usedStale(record, now) {
1425
+ const at = Date.parse(String((record && (record.lastUsed || record.updated)) || ''));
1426
+ return !Number.isFinite(at) || now - at >= USED_TOUCH_MS;
1427
+ }
1428
+
1429
+ /**
1430
+ * 真正被搜到/读过才算用过:只回写 lastUsed,不动 hits,也不改正文。
1431
+ * 抢不到锁或并发冲突都吞掉——这个是提示字段,不该让检索/阅读失败。
1432
+ */
1433
+ function touchUsed(scope, ids) {
1434
+ const wanted = new Set((ids || []).filter((id) => typeof id === 'string' && id));
1435
+ if (wanted.size === 0) return;
1436
+ try {
1437
+ withLock(scope, () => {
1438
+ const index = loadIndex(scope);
1439
+ const now = Date.now();
1440
+ const stamp = new Date(now).toISOString();
1441
+ let changed = false;
1442
+ for (const record of index.entries) {
1443
+ if (!wanted.has(record.id) || !usedStale(record, now)) continue;
1444
+ record.lastUsed = stamp;
1445
+ changed = true;
1446
+ }
1447
+ if (changed) saveIndex(scope, index);
1448
+ });
1449
+ } catch {
1450
+ /* lastUsed 只是提示:失败不影响这次检索/阅读的结果。 */
1451
+ }
1452
+ }
1453
+
1058
1454
  export function search(scope, query, limit = 4, options = {}) {
1059
- return withLocks([scope, globalScope()], () => {
1455
+ const found = withLocks([scope, globalScope()], () => {
1060
1456
  const grams = bigrams(query);
1061
1457
  if (grams.size === 0) return [];
1062
1458
  const runs = queryRuns(query);
@@ -1071,6 +1467,8 @@ export function search(scope, query, limit = 4, options = {}) {
1071
1467
  const collect = (pool, archived) => {
1072
1468
  for (const item of pool) {
1073
1469
  if (!readableRecord(item, options, index)) continue;
1470
+ /** 被取代/过期待记录默认不出现;history:true 才列出,供模型看演化。 */
1471
+ if (options.history !== true && staleRecord(item, options.now || Date.now())) continue;
1074
1472
  /** 已暂停、已过期或待确认的学习规则不进入检索结果,除非明确要病史。 */
1075
1473
  if (options.allowExperimental === true && options.history !== true
1076
1474
  && isExperimentalRecord(item) && !view.experimentalUsable(item, options.now)) continue;
@@ -1098,14 +1496,23 @@ export function search(scope, query, limit = 4, options = {}) {
1098
1496
  || Number(a.archived) - Number(b.archived)
1099
1497
  || String(b.item.lastUsed).localeCompare(String(a.item.lastUsed)));
1100
1498
  const chosen = visible.slice(0, limit);
1101
- /** 检索不改盘:hits/lastUsed 只在用户真的处理过结果时才算数。 */
1499
+ /** hits 不在检索里记,lastUsed 也只在锁外补写;这里先把层信息和结果一起带出去。 */
1102
1500
  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 || {}),
1501
+ record: {
1502
+ ...row.item,
1503
+ scope: row.source.key === 'global' ? 'global' : 'project',
1504
+ status: row.archived ? 'archived' : view.statusOf(row.item, options.context || {}),
1505
+ archived: Boolean(row.archived),
1506
+ },
1507
+ source: row.source,
1106
1508
  archived: Boolean(row.archived),
1107
1509
  }));
1108
1510
  });
1511
+ /** 锁外回写:在层锁里再抢同一层的锁会死锁。 */
1512
+ for (const row of found) {
1513
+ if (!row.archived) touchUsed(row.source, [row.record.id]);
1514
+ }
1515
+ return found.map((row) => row.record);
1109
1516
  }
1110
1517
 
1111
1518
  /**
@@ -1146,7 +1553,7 @@ export function peek(scope, query, limit = 3, options = {}) {
1146
1553
  }
1147
1554
 
1148
1555
  export function readEntry(scope, id, options = {}) {
1149
- return withLocks([scope, globalScope()], () => {
1556
+ const found = withLocks([scope, globalScope()], () => {
1150
1557
  for (const source of [scope, globalScope()]) {
1151
1558
  const index = loadIndex(source);
1152
1559
  const record = index.entries.find((item) => item.id === id);
@@ -1155,14 +1562,22 @@ export function readEntry(scope, id, options = {}) {
1155
1562
  if (!readableRecord(record || archived, options, index)) continue;
1156
1563
  const body = readBody(source, id);
1157
1564
  return {
1158
- ...(record || archived),
1159
- scope: source.key === 'global' ? 'global' : 'project',
1160
- body,
1161
- status: archived ? 'archived' : view.statusOf(record, options.context || {}),
1565
+ source,
1566
+ archived: Boolean(archived),
1567
+ record: {
1568
+ ...(record || archived),
1569
+ scope: source.key === 'global' ? 'global' : 'project',
1570
+ body,
1571
+ status: archived ? 'archived' : view.statusOf(record, options.context || {}),
1572
+ },
1162
1573
  };
1163
1574
  }
1164
1575
  return null;
1165
1576
  });
1577
+ if (!found) return null;
1578
+ /** 读过才算用过;归档的不回写(看病史不等于「在用」)。 */
1579
+ if (!found.archived) touchUsed(found.source, [id]);
1580
+ return found.record;
1166
1581
  }
1167
1582
 
1168
1583
  /**
@@ -1179,7 +1594,9 @@ export function forget(scope, id, options = {}) {
1179
1594
  for (const source of [scope, globalScope()]) {
1180
1595
  const pending = state.purgeIntent(source);
1181
1596
  if (!pending) continue;
1182
- const result = state.purge(source, { resume: true });
1597
+ /** 冷存里有待删条目的正文副本:先隔离,state.purge 的残留扫描才收得干净。 */
1598
+ const result = purgeWithCold(source, pending.ids, () => state.purge(source, { resume: true }));
1599
+ cleanColdQuarantine(source);
1183
1600
  removeBody(source, id);
1184
1601
  if (pending.ids.includes(id)) {
1185
1602
  replayed = {
@@ -1217,7 +1634,8 @@ export function forget(scope, id, options = {}) {
1217
1634
  }
1218
1635
  if (archived) {
1219
1636
  /** 归档条目也能彻底删除:state.purge 本来就按 id 清整份状态,只是以前没走到这里。 */
1220
- const result = state.purge(source, { ids: [id], operationId: options.operationId });
1637
+ const result = purgeWithCold(source, [id], () => state.purge(source, { ids: [id], operationId: options.operationId }));
1638
+ cleanColdQuarantine(source);
1221
1639
  removeBody(source, id);
1222
1640
  return {
1223
1641
  id,
@@ -1228,7 +1646,8 @@ export function forget(scope, id, options = {}) {
1228
1646
  scopeKey: source.key,
1229
1647
  };
1230
1648
  }
1231
- const result = state.purge(source, { ids: [id], operationId: options.operationId });
1649
+ const result = purgeWithCold(source, [id], () => state.purge(source, { ids: [id], operationId: options.operationId }));
1650
+ cleanColdQuarantine(source);
1232
1651
  removeBody(source, id);
1233
1652
  return {
1234
1653
  id,
@@ -1275,8 +1694,9 @@ function findCurrent(id, scope, options = {}) {
1275
1694
  const record = index.entries.find((item) => item.id === id && item.status !== 'archived');
1276
1695
  if (!record) continue;
1277
1696
  if (!readableRecord(record, options, index)) continue;
1278
- /** 关联目标也按本轮上下文判适用;条件未知、过期或暂停的目标不给标题。 */
1279
- if (view.statusOf(record, options.context || {}) !== 'ok') continue;
1697
+ /** 关联目标与来源走同一资格门槛:项目归属、学习状态、条件适用缺一不可。 */
1698
+ const gate = view.eligibility(record, options.runtime || {}, 'read', options.now || Date.now());
1699
+ if (!gate.ok || gate.status !== 'ok') continue;
1280
1700
  if (isExperimentalRecord(record) && !view.experimentalUsable(record, options.now)) continue;
1281
1701
  return {
1282
1702
  id: record.id,
@@ -1284,6 +1704,8 @@ function findCurrent(id, scope, options = {}) {
1284
1704
  summary: record.summary || '',
1285
1705
  scope: source.key === 'global' ? 'global' : 'project',
1286
1706
  revision: record.revision || 1,
1707
+ validUntil: record.validUntil || '',
1708
+ supersededBy: record.supersededBy || '',
1287
1709
  status: 'ok',
1288
1710
  };
1289
1711
  }
@@ -1314,6 +1736,7 @@ export function undoChange(scope, id, options = {}) {
1314
1736
  ...record,
1315
1737
  title: last.title || record.title,
1316
1738
  summary: last.summary || record.summary,
1739
+ summaryFull: typeof last.summaryFull === 'string' ? last.summaryFull : '',
1317
1740
  when: last.when || '',
1318
1741
  kind: last.kind || record.kind,
1319
1742
  tags: Array.isArray(last.tags) ? last.tags : record.tags,
@@ -1330,6 +1753,9 @@ export function undoChange(scope, id, options = {}) {
1330
1753
  };
1331
1754
  writeBody(source, record.id, last.body);
1332
1755
  Object.assign(record, restored);
1756
+ record.maintenance = { ...(record.maintenance || {}) };
1757
+ if (record.summaryFull) record.maintenance.summaryFull = record.summaryFull;
1758
+ else delete record.maintenance.summaryFull;
1333
1759
  record.bytes = entrySize(record, last.body);
1334
1760
  try {
1335
1761
  saveIndex(source, index);
@@ -1893,6 +2319,27 @@ const ENVIRONMENT_KEYS = ['machine', 'targetOs', 'runtime', 'shell'];
1893
2319
  * 范围路由:用户明确说的范围最高;没有就按记忆类别提案,设备事实必须带设备条件。
1894
2320
  * 判不出来就返回 needsScope,让调用方问一句,而不是默默写进某一层。
1895
2321
  */
2322
+ /** 本机痕迹:路径、回环地址、端口。带这些字样的「通用」规则换个项目就用不了。 */
2323
+ const MACHINE_BOUND_PATTERNS = [
2324
+ /(?:^|[\s(('"`])(?:\/tmp|\/Users|\/home|\/opt|\/private|\/var|\/etc)(?:[\/]|$)/m,
2325
+ /(?:^|[\s(('"`])~\//m,
2326
+ /(?:^|[^A-Za-z0-9])[A-Za-z]:\\/,
2327
+ /(?:^|[^0-9])127\.0\.0\.1(?::\d+)?/,
2328
+ /(?:^|[^A-Za-z0-9.])localhost(?::\d+)?/,
2329
+ /\b\d{1,3}(?:\.\d{1,3}){3}:\d{2,5}\b/,
2330
+ ];
2331
+
2332
+ function machineBoundText(source) {
2333
+ const text = [source?.title, source?.summary, source?.when, source?.body, source?.topicKey]
2334
+ .filter((value) => typeof value === 'string' && value)
2335
+ .join('\n');
2336
+ return MACHINE_BOUND_PATTERNS.some((re) => re.test(text));
2337
+ }
2338
+
2339
+ /**
2340
+ * 适用范围判定:内容/依据的普适性优先,模型声明的 scope 只是提案。
2341
+ * 模型说 global、但正文写死了本机路径或依据是本机文件时,先留在项目层(或问一次条件)。
2342
+ */
1896
2343
  export function routeScope(draft, options = {}) {
1897
2344
  const source = draft || {};
1898
2345
  const explicit = source.scope === 'global' || source.scope === 'project' ? source.scope : '';
@@ -1900,9 +2347,30 @@ export function routeScope(draft, options = {}) {
1900
2347
  const projectReady = Boolean(options.project) && options.project.confirmed !== false;
1901
2348
  const cls = typeof source.memoryClass === 'string' ? source.memoryClass : '';
1902
2349
  const durability = typeof source.durability === 'string' ? source.durability : 'durable';
2350
+ const evidence = typeof source.evidenceRef === 'string' ? source.evidenceRef.trim() : '';
2351
+ /** 依据本身是本机文件/路径的,不能算通用流程;正文里带本机痕迹同理。 */
2352
+ const machineBound = machineBoundText(source) || (evidence ? machineBoundText({ title: evidence }) : false);
1903
2353
  const base = { chosenScope: '', reasonCode: '', requiredConditions: [], needsScope: false, explicit };
2354
+ /** 通用层拿不出普适证据时:有项目就留在项目层,没有就问一次运行环境。 */
2355
+ const fallbackToProject = (reasonCode) => {
2356
+ if (projectReady) return { ...base, chosenScope: 'project', reasonCode };
2357
+ return {
2358
+ ...base,
2359
+ needsScope: true,
2360
+ reasonCode: 'needs_environment',
2361
+ requiredConditions: [{ key: 'machine', value: '' }, { key: 'runtime', value: '' }],
2362
+ };
2363
+ };
2364
+ /** 人明确点过「就存全局」才算数;模型自己提的 global 不抵消本机痕迹。 */
2365
+ const humanConfirmed = options.humanScope === true || source.scopeConfirmed === true;
1904
2366
  if (durability === 'task') return { ...base, needsScope: true, reasonCode: 'task_state' };
1905
2367
  if (explicit) {
2368
+ /** 模型自己填的 global 只是提案:本机痕迹、项目事实、无依据的通用流程都按提案降级。 */
2369
+ if (explicit === 'global' && !humanConfirmed) {
2370
+ if (machineBound) return fallbackToProject('machine_bound_scope');
2371
+ if (cls === 'project_fact') return fallbackToProject('model_global_project_fact');
2372
+ if (cls === 'portable_procedure' && !evidence) return fallbackToProject('unbound_evidence');
2373
+ }
1906
2374
  if (explicit === 'project' && !projectReady) {
1907
2375
  const reasonCode = options.project && options.project.confirmed === false ? 'unconfirmed_project' : 'no_project';
1908
2376
  return { ...base, needsScope: true, reasonCode, requiredConditions: [{ key: 'projectRoot', value: '' }] };
@@ -1923,8 +2391,17 @@ export function routeScope(draft, options = {}) {
1923
2391
  }
1924
2392
  return { ...base, chosenScope: 'project', reasonCode: 'class_project_fact' };
1925
2393
  }
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' };
2394
+ if (cls === 'personal_preference') {
2395
+ /** 偏好也分「我的习惯」和「这台机器上的习惯」:后者绑机器,不进通用层。 */
2396
+ if (machineBound) return fallbackToProject('machine_bound_preference');
2397
+ return { ...base, chosenScope: 'global', reasonCode: 'class_personal_preference' };
2398
+ }
2399
+ if (cls === 'portable_procedure') {
2400
+ if (machineBound) return fallbackToProject('machine_bound_procedure');
2401
+ /** 说能搬去别的项目,就要有能搬的依据;没有就先留在本项目。 */
2402
+ if (!evidence) return fallbackToProject('unbound_evidence');
2403
+ return { ...base, chosenScope: 'global', reasonCode: 'class_portable_procedure' };
2404
+ }
1928
2405
  const conditions = view.normalizeConditions(source.conditions) || [];
1929
2406
  const scoped = conditions.some((item) => ENVIRONMENT_KEYS.includes(item.key));
1930
2407
  if (!scoped) {
@@ -1988,6 +2465,9 @@ function crossLayerSimilar(projectScope, targetScope, draft) {
1988
2465
  id: item.id,
1989
2466
  title: item.title,
1990
2467
  summary: item.summary || '',
2468
+ when: item.when || '',
2469
+ updated: item.updated || '',
2470
+ reason: item.reason || 'similar_title',
1991
2471
  revision: item.revision || 1,
1992
2472
  scope: other.key === 'global' ? 'global' : 'project',
1993
2473
  }));
@@ -2004,6 +2484,49 @@ function bindSameTopic(projectScope, ref) {
2004
2484
  return { topicKey: view.topicSlot(record) };
2005
2485
  }
2006
2486
 
2487
+
2488
+ /** 冲突队列:只记待办,不预写记忆;最多 20 条。 */
2489
+ const CONFLICT_LIMIT = 20;
2490
+
2491
+ function queueConflictLocked(scope, draft, candidates) {
2492
+ const index = loadIndex(scope);
2493
+ const title = normalizeText(draft && draft.title, 120);
2494
+ if (!title) return Array.isArray(index.conflicts) ? index.conflicts.length : 0;
2495
+ const scopeKey = scope.key === 'global' ? 'global' : 'project';
2496
+ const now = new Date().toISOString();
2497
+ const rows = Array.isArray(index.conflicts) ? index.conflicts : [];
2498
+ const view = candidates.slice(0, 2).map((item) => ({
2499
+ id: item.id, title: item.title || '', when: item.when || '', reason: item.reason || 'similar_title',
2500
+ }));
2501
+ const hit = rows.find((row) => row.title === title && row.scope === scopeKey);
2502
+ if (hit) {
2503
+ hit.candidates = view;
2504
+ hit.updatedAt = now;
2505
+ } else {
2506
+ rows.push({ id: `cf_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`, title, scope: scopeKey, candidates: view, createdAt: now, updatedAt: now });
2507
+ }
2508
+ index.conflicts = rows.slice(-CONFLICT_LIMIT);
2509
+ saveIndex(scope, index);
2510
+ return index.conflicts.length;
2511
+ }
2512
+
2513
+ function clearConflictLocked(scope, title) {
2514
+ const index = loadIndex(scope);
2515
+ const rows = Array.isArray(index.conflicts) ? index.conflicts : [];
2516
+ const norm = normalizeText(title, 120);
2517
+ const next = rows.filter((row) => !(row.title === norm && row.scope === (scope.key === 'global' ? 'global' : 'project')));
2518
+ if (next.length === rows.length) return rows.length;
2519
+ index.conflicts = next;
2520
+ saveIndex(scope, index);
2521
+ return next.length;
2522
+ }
2523
+
2524
+ /** 当前层还有几条没处理的冲突。 */
2525
+ export function pendingConflictsOf(scope) {
2526
+ const rows = loadIndex(scope).conflicts;
2527
+ return Array.isArray(rows) ? rows.length : 0;
2528
+ }
2529
+
2007
2530
  /**
2008
2531
  * 保存的唯一入口:先路由,再在两层里查重(完全相同 → existing_ref;只是相近 → 只给候选),
2009
2532
  * 最后才写。写之前判不出来的范围返回 needsScope,不替用户选。
@@ -2047,6 +2570,7 @@ export function saveEntry(projectScope, source) {
2047
2570
  }
2048
2571
  const near = crossLayerSimilar(projectScope, targetScope, draft);
2049
2572
  if (near.length) {
2573
+ const pendingConflicts = queueConflictLocked(targetScope, draft, near);
2050
2574
  return {
2051
2575
  needsResolution: true,
2052
2576
  crossLayer: true,
@@ -2054,12 +2578,19 @@ export function saveEntry(projectScope, source) {
2054
2578
  similar: near[0],
2055
2579
  draft,
2056
2580
  needsScope: false,
2581
+ pendingConflicts,
2057
2582
  chosenScope: targetScope.key === 'global' ? 'global' : 'project',
2058
2583
  };
2059
2584
  }
2060
2585
  }
2061
2586
  const foreign = targetScope.key === 'global' ? [] : loadIndex(globalScope()).entries;
2062
2587
  const result = upsertLocked(targetScope, next, { foreignEntries: foreign });
2588
+ if (!result.needsResolution) clearConflictLocked(targetScope, draft ? draft.title : next.title);
2589
+ if (result.needsResolution) {
2590
+ const rows = Array.isArray(result.similarList) ? result.similarList : [];
2591
+ const pendingConflicts = queueConflictLocked(targetScope, result.draft || draft, rows);
2592
+ return { ...result, chosenScope: targetScope.key === 'global' ? 'global' : 'project', needsScope: false, reasonCode: routed.reasonCode, pendingConflicts };
2593
+ }
2063
2594
  return { ...result, chosenScope: targetScope.key === 'global' ? 'global' : 'project', needsScope: false, reasonCode: routed.reasonCode };
2064
2595
  });
2065
2596
  }
@@ -2456,9 +2987,15 @@ function economyOf(ledger) {
2456
2987
  }
2457
2988
 
2458
2989
  /** 给整理器的候选规则:只读、有限,不跨项目。 */
2990
+ /** 整理器看到的规则顺序与扣尝试时一致:按状态/最近使用排序,不靠插入顺序。 */
2991
+ function orderedLearningRules(index) {
2992
+ const visible = learningRulesOf(index).filter(visibleLearningRule);
2993
+ return learning.orderOrganizerRules(visible, { limit: learning.LIMITS.organizerRules });
2994
+ }
2995
+
2459
2996
  export function learningRulesForOrganizer(scope) {
2460
2997
  const index = loadIndex(scope);
2461
- return learningRulesOf(index).filter(visibleLearningRule).slice(0, 8).map(learningRuleView);
2998
+ return orderedLearningRules(index).map(learningRuleView);
2462
2999
  }
2463
3000
 
2464
3001
  /**
@@ -2467,16 +3004,17 @@ export function learningRulesForOrganizer(scope) {
2467
3004
  */
2468
3005
  export function beginLearningAttempt(scope, options = {}) {
2469
3006
  const episodeId = String(options.episodeId || '');
2470
- if (!episodeId) return { ok: false, reason: '缺少作品标识' };
3007
+ /** 项目层候选没有作品绑定:按项目身份记一次尝试,限额照样生效,不编造作品 id。 */
3008
+ const attemptKey = episodeId || `project:${scope.key}`;
2471
3009
  const now = options.now || Date.now();
2472
3010
  return withLock(scope, () => {
2473
3011
  const index = loadIndex(scope);
2474
3012
  const evolution = index.__evolution;
2475
3013
  if (!evolution.enabled || evolution.compatible === false) return { ok: false, reason: '实验没有开启' };
2476
3014
  if (evolution.policyVersion !== learning.POLICY_VERSION) return { ok: false, reason: '实验策略版本不认' };
2477
- const check = learning.quota(evolution.learning, { episodeId, now });
3015
+ const check = learning.quota(evolution.learning, { episodeId: attemptKey, now });
2478
3016
  if (!check.ok) return { ...check, learning: evolution.learning };
2479
- const next = learning.recordAttempt(evolution.learning, { episodeId, now });
3017
+ const next = learning.recordAttempt(evolution.learning, { episodeId: attemptKey, now });
2480
3018
  try {
2481
3019
  const result = state.transact(scope, {
2482
3020
  operationId: options.operationId || '',
@@ -2493,7 +3031,7 @@ export function beginLearningAttempt(scope, options = {}) {
2493
3031
  generation: index.generation,
2494
3032
  episodeId,
2495
3033
  learning: next,
2496
- rules: learningRulesOf(index).filter(visibleLearningRule).slice(0, 8).map(learningRuleView),
3034
+ rules: orderedLearningRules(index).map(learningRuleView),
2497
3035
  replayed: result.replayed === true,
2498
3036
  };
2499
3037
  } catch (error) {
@@ -2527,7 +3065,11 @@ export function commitLearning(scope, batch = {}) {
2527
3065
  const operationId = String(batch.operationId || '');
2528
3066
  const now = batch.now || Date.now();
2529
3067
  const at = new Date(now).toISOString();
2530
- if (!episodeId) return { ok: false, reason: '缺少作品标识' };
3068
+ /**
3069
+ * 没绑作品的候选(learning.candidateFrom 在项目层给的空 episodeId)按项目身份提交:
3070
+ * 不编造作品 id,但必须由本项目发起;规则版本、epoch 与目标版本照旧要对得上。
3071
+ */
3072
+ if (!episodeId && String(batch.projectId || '') !== scope.key) return { ok: false, reason: '缺少作品标识' };
2531
3073
  if (!operationId) return { ok: false, reason: '缺少提交标识' };
2532
3074
  if (changes.length > learning.LIMITS.maxChanges) return { ok: false, reason: '一次改动太多' };
2533
3075
  return withLock(scope, () => {
@@ -2542,13 +3084,13 @@ export function commitLearning(scope, batch = {}) {
2542
3084
  if (!evolution || evolution.enabled !== true) throw new Error('实验没有开启,这次整理作废');
2543
3085
  if (evolution.policyVersion !== learning.POLICY_VERSION) throw new Error('实验策略版本不认');
2544
3086
  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) {
3087
+ const episode = episodeId ? episodeOfEvolution(evolution, episodeId) : null;
3088
+ if (episodeId && !episode) throw new Error('作品已经失效,这次整理作废');
3089
+ if (episodeId && batch.sessionId) {
2548
3090
  const focus = evolution.tasks?.sessions?.[batch.sessionId];
2549
3091
  if (!focus || focus.episodeId !== episodeId) throw new Error('这期已经换到别的作品,这轮结果没采用');
2550
3092
  }
2551
- if (batch.episodeUpdatedAt && episode.updatedAt !== batch.episodeUpdatedAt) throw new Error('用户又改过这期,这次整理作废');
3093
+ if (episode && batch.episodeUpdatedAt && episode.updatedAt !== batch.episodeUpdatedAt) throw new Error('用户又改过这期,这次整理作废');
2552
3094
  const prepared = new Map((Array.isArray(batch.preparedTargets) ? batch.preparedTargets : []).map((row) => [row.id, row]));
2553
3095
  let touched = 0;
2554
3096
  const currentRules = draft.entries.filter((entry) => entry.status === 'active' && isExperimentalRecord(toFlat(entry)));
@@ -2591,6 +3133,11 @@ export function commitLearning(scope, batch = {}) {
2591
3133
  messageIds: (batch.messageIds || []).slice(-3),
2592
3134
  at,
2593
3135
  };
3136
+ /** 用户原句优先取本次改动带的摘录,其次取整批的:四处回执都能看到原话。 */
3137
+ const excerpt = typeof change.sourceExcerpt === 'string'
3138
+ ? change.sourceExcerpt
3139
+ : (typeof batch.sourceExcerpt === 'string' ? batch.sourceExcerpt : '');
3140
+ if (excerpt) evidence.sourceExcerpt = excerpt;
2594
3141
  merged.learning = {
2595
3142
  ...learning.nextLearningState({
2596
3143
  existing: flat,
@@ -2624,6 +3171,11 @@ export function commitLearning(scope, batch = {}) {
2624
3171
  messageIds: (batch.messageIds || []).slice(-3),
2625
3172
  at,
2626
3173
  };
3174
+ /** 用户原句优先取本次改动带的摘录,其次取整批的:四处回执都能看到原话。 */
3175
+ const excerpt = typeof change.sourceExcerpt === 'string'
3176
+ ? change.sourceExcerpt
3177
+ : (typeof batch.sourceExcerpt === 'string' ? batch.sourceExcerpt : '');
3178
+ if (excerpt) evidence.sourceExcerpt = excerpt;
2627
3179
  const merged = learning.mergeChange(null, change, { now });
2628
3180
  /** 同主题、同条件的新规则并进已有条目:整理器换个措辞不该长出第二条。 */
2629
3181
  const signature = (list) => (Array.isArray(list) ? list : [])