dsh-lite-memory 0.17.0 → 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/index.js CHANGED
@@ -644,6 +644,8 @@ function refsOf(hits) {
644
644
  /** 界面要按类型分开展示(偏好/约定/流程/资料),维护字段不进正文。 */
645
645
  kind: typeof item.kind === 'string' && item.kind ? item.kind : 'note',
646
646
  when: typeof item.when === 'string' ? item.when : '',
647
+ /** 界面要能说「N 天前更新」,只给时间不给正文。 */
648
+ updated: typeof item.updated === 'string' ? item.updated : '',
647
649
  }));
648
650
  }
649
651
 
@@ -657,6 +659,10 @@ const SAVE_PARAMS = {
657
659
  when: { type: 'string', description: `前置条件或适用场景(「在哪台机器 / 哪个环境 / 什么情况下」),不超过 ${LIMITS.whenChars} 字。同一标题但前置条件不同,会存成两条,不合并;更新时不给就保留原值` },
658
660
  kind: { type: 'string', enum: ['preference', 'convention', 'procedure', 'pointer', 'pitfall', 'credential_hint', 'note'], description: '条目类型;更新时不给就保留原值' },
659
661
  tags: { type: 'array', items: { type: 'string' }, description: '最多 5 个标签;更新时不给就保留原值' },
662
+ supersedes: {
663
+ type: 'string',
664
+ description: '要取代的记忆 id:新内容写成新条目,旧条目标为已被取代并保留在历史里;同层才有效,已被取代过的不能再取代',
665
+ },
660
666
  resolution: {
661
667
  type: 'string',
662
668
  enum: ['distinct'],
@@ -795,6 +801,29 @@ function withTag(value, text) {
795
801
  return tag ? `${tag} ${text}` : text;
796
802
  }
797
803
 
804
+ /** 候选要能自己解释「什么时候的、为什么像」,模型才好判断是更新还是另一条。 */
805
+ function humanAge(iso) {
806
+ const at = Date.parse(String(iso || ''));
807
+ if (!Number.isFinite(at)) return '';
808
+ const days = Math.floor((Date.now() - at) / 86400000);
809
+ if (days <= 0) return '今天';
810
+ if (days === 1) return '昨天';
811
+ if (days < 30) return `${days} 天前`;
812
+ if (days < 365) return `${Math.floor(days / 30)} 个月前`;
813
+ return `${Math.floor(days / 365)} 年前`;
814
+ }
815
+
816
+ function describeCandidate(item, options = {}) {
817
+ const age = humanAge(item.updated);
818
+ const why = options.sameTopic ? '' : item.reason === 'similar_summary' ? '摘要相近' : '标题相近';
819
+ const parts = [`${item.id}「${item.title}」`];
820
+ if (age) parts.push(`${age}更新`);
821
+ if (item.when) parts.push(item.when);
822
+ if (why) parts.push(why);
823
+ if (item.summary) parts.push(item.summary);
824
+ return parts.join(' · ');
825
+ }
826
+
798
827
  function statusLabel(status) {
799
828
  if (status === 'archived') return ' · 已归档';
800
829
  if (status === 'needs_context') return ' · 适用范围待确认';
@@ -907,6 +936,16 @@ export function apply(ctx, config) {
907
936
  return win.bytes + bytes <= view.REQUEST_BYTES;
908
937
  }
909
938
 
939
+ /** 续写轮没有新消息:沿用上一次能核对的请求上下文。 */
940
+ const lastRuntime = new Map();
941
+ const rememberRuntime = (scope, sessionId, held) => {
942
+ if (!held || !sessionId) return;
943
+ const key = `${scope.key}\u0000${sessionId}`;
944
+ lastRuntime.delete(key);
945
+ lastRuntime.set(key, held);
946
+ if (lastRuntime.size > 20) lastRuntime.delete(lastRuntime.keys().next().value);
947
+ };
948
+
910
949
  function openWindow(agent, { sessionId, turn, messageIds }) {
911
950
  const key = agentKey(agent);
912
951
  if (!key) return null;
@@ -1394,11 +1433,13 @@ export function apply(ctx, config) {
1394
1433
  const contextId = typeof optionalContextId === 'string' ? optionalContextId.trim() : '';
1395
1434
  if (!contextId) {
1396
1435
  /** 没有显式上下文时,用本请求已经路由出来的任务;这层不依赖自进化开关。 */
1397
- const runtime = win && win.runtime;
1436
+ const live = win && win.runtime ? win.runtime : null;
1437
+ const runtime = live
1438
+ || (win && win.sessionId ? lastRuntime.get(`${scope.key}\u0000${win.sessionId}`) : null);
1398
1439
  const sameRequest = runtime
1399
1440
  && runtime.projectKey === scope.key
1400
1441
  && (!runtime.sessionId || runtime.sessionId === String(win.sessionId || ''))
1401
- && runtime.batchKey === anchor.requestBatchKey(win.messageIds || []);
1442
+ && (runtime === live ? runtime.batchKey === anchor.requestBatchKey(win.messageIds || []) : true);
1402
1443
  if (sameRequest) {
1403
1444
  /** 普通记忆可以直接用本请求上下文;实验正文仍要求显式 contextId,不能顺手放行。 */
1404
1445
  return { ok: true, scope, win, context: runtime.context, held: runtime, experimental: false };
@@ -1585,7 +1626,9 @@ export function apply(ctx, config) {
1585
1626
  sameTopic: { type: 'boolean' },
1586
1627
  similarIds: { type: 'string' },
1587
1628
  similar: { type: 'string' },
1629
+ supersededId: { type: 'string' },
1588
1630
  archived: { type: 'string' },
1631
+ pendingConflicts: { type: 'integer' },
1589
1632
  },
1590
1633
  required: ['id', 'title', 'summary', 'when', 'revision', 'merged', 'changed', 'scope', 'needsResolution', 'archived'],
1591
1634
  additionalProperties: false,
@@ -1599,12 +1642,12 @@ export function apply(ctx, config) {
1599
1642
  ? `memory · 当前目录还不能确认属于哪个项目:「${value.title}」\n先确认项目根目录,或显式 scope="global"`
1600
1643
  : `memory · 归属还没定:「${value.title}」\n补 memoryClass(personal_preference / project_fact / environment_fact / portable_procedure)或显式 scope;设备事实还要给设备条件`
1601
1644
  : value.existingId
1602
- ? `memory · 已有相同记忆:「${value.title}」 · ${value.existingId}(${value.existingScope})\n要改成另一条,加 resolution="distinct"`
1645
+ ? `memory · 已有相同记忆:「${value.title}」(${value.existingScope})\n要改成另一条,加 resolution="distinct"`
1603
1646
  : value.needsResolution
1604
1647
  ? `memory · 需要确认:「${value.title}」与已有条目相近${value.crossLayer ? '(在另一层)' : ''}${value.sameTopic ? '(同一主题槽位)' : ''}\n${value.similar}\n要更新它,用 id 重存;确实是另一条,加 resolution="distinct"`
1605
1648
  : value.changed === false
1606
- ? `memory · unchanged ${withTag(value, `"${value.title}"`)} · ${value.id}`
1607
- : `memory · ${value.merged ? 'merged' : 'saved'} ${withTag(value, `"${value.title}"`)}${value.when ? `(${value.when})` : ''} · ${value.id}${value.similar ? ` · 相近:${value.similar}` : ''}${value.archived ? ` · 已归档「${value.archived}」腾位` : ''}${value.fullTextSaved ? ' · 完整原句已另存' : ''}`,
1649
+ ? `memory · unchanged ${withTag(value, `"${value.title}"`)}`
1650
+ : `memory · ${value.merged ? 'merged' : 'saved'} ${withTag(value, `"${value.title}"`)}${value.when ? `(${value.when})` : ''}${value.similar ? ` · 相近:${value.similar}` : ''}${value.archived ? ` · 已归档「${value.archived}」腾位` : ''}${value.fullTextSaved ? ' · 完整原句已另存' : ''}${value.supersededId ? ' · 已取代旧说法' : ''}`,
1608
1651
  }],
1609
1652
  },
1610
1653
  execute: async (args, execution) => {
@@ -1672,8 +1715,9 @@ export function apply(ctx, config) {
1672
1715
  crossLayer: Boolean(saved.crossLayer),
1673
1716
  sameTopic: Boolean(saved.sameTopic),
1674
1717
  similarIds: saved.similarList.map((item) => item.id).join('、'),
1675
- similar: saved.similarList.map((item) => `${item.id}「${item.title}」${item.summary ? `|${item.summary}` : ''}`).join(';'),
1718
+ similar: saved.similarList.map((item) => describeCandidate(item, { sameTopic: saved.sameTopic })).join(';'),
1676
1719
  archived: '',
1720
+ pendingConflicts: Number(saved.pendingConflicts) || 0,
1677
1721
  };
1678
1722
  }
1679
1723
  recordCitations(scope, sessionIdOf(execution), [saved.record]);
@@ -1693,8 +1737,10 @@ export function apply(ctx, config) {
1693
1737
  needsScope: false,
1694
1738
  needsResolution: false,
1695
1739
  similarIds: saved.similarList.map((item) => item.id).join('、'),
1740
+ supersededId: String(args.supersedes || ''),
1696
1741
  archived: (saved.archived || []).map((item) => item.title).join('、'),
1697
- ...(saved.similar ? { similar: `${saved.similar.id}「${saved.similar.title}」` } : {}),
1742
+ pendingConflicts: Number(saved.pendingConflicts) || 0,
1743
+ ...(saved.similar ? { similar: `「${saved.similar.title}」` } : {}),
1698
1744
  };
1699
1745
  },
1700
1746
  }), 'lite-memory: memory_save');
@@ -1721,7 +1767,7 @@ export function apply(ctx, config) {
1721
1767
  render: (_args, value) => [{
1722
1768
  type: 'text',
1723
1769
  text: value.asked
1724
- ? `memory · suggested "${value.title}" · ${value.id}${value.targetId ? ` · 确认后更新 ${value.targetId}「${value.targetTitle}」` : ''}`
1770
+ ? `memory · suggested "${value.title}"${value.targetId ? ` · 确认后更新「${value.targetTitle}」` : ''}`
1725
1771
  : `memory · skipped${value.reason ? `\n${value.reason}` : ''}`,
1726
1772
  }],
1727
1773
  },
@@ -1771,6 +1817,7 @@ export function apply(ctx, config) {
1771
1817
  origin: { type: 'string' },
1772
1818
  kind: { type: 'string' },
1773
1819
  when: { type: 'string' },
1820
+ updated: { type: 'string' }, // ui-copy-ignore
1774
1821
  },
1775
1822
  required: ['id', 'title', 'scope', 'revision', 'status', 'origin', 'kind', 'when'],
1776
1823
  additionalProperties: false,
@@ -1914,11 +1961,11 @@ export function apply(ctx, config) {
1914
1961
  : value.repeat
1915
1962
  ? 'memory · 本请求已读过这条记忆'
1916
1963
  : value.body
1917
- ? `memory · ${withTag(value, `「${value.title}」`)}${value.when ? `(${value.when})` : ''} · ${value.id}${statusLabel(value.status)}\n${value.body}`
1964
+ ? `memory · ${withTag(value, `「${value.title}」`)}${value.when ? `(${value.when})` : ''}${statusLabel(value.status)}\n${value.body}`
1918
1965
  : value.links !== undefined && value.status === 'ok'
1919
1966
  ? `memory · 「${value.title}」的相关记忆\n${value.links || '(无)'}`
1920
1967
  : value.status === 'needs_context'
1921
- ? 'memory · 这条记忆的适用条件还没确认'
1968
+ ? 'memory · 这条记忆的适用条件还没确认,先说明当前任务再读'
1922
1969
  : value.status === 'not_applicable'
1923
1970
  ? 'memory · 这条记忆不适用于当前任务'
1924
1971
  : value.status === 'archived'
@@ -2047,12 +2094,12 @@ export function apply(ctx, config) {
2047
2094
  text: value.mode === 'memory_disabled'
2048
2095
  ? 'memory · 本项目记忆已关闭,先在面板打开'
2049
2096
  : value.mode === 'purged'
2050
- ? `memory · forgotten "${value.title}" · ${value.id}`
2097
+ ? `memory · forgotten "${value.title}"`
2051
2098
  : value.mode === 'purge_pending'
2052
- ? `memory · 清除未完成 · ${value.id},重启后会继续`
2099
+ ? `memory · 清除未完成 · 「${value.title || '这条记忆'}」,重启后会继续`
2053
2100
  : value.mode === 'needs_resolution'
2054
- ? `memory · 先确认是归档还是彻底忘掉 · ${value.id}`
2055
- : `memory · archived ${withTag(value, `"${value.title}"`)} · ${value.id},可找回`,
2101
+ ? `memory · 先确认是归档还是彻底忘掉 · 「${value.title || '这条记忆'}」`
2102
+ : `memory · archived ${withTag(value, `"${value.title}"`)},可找回`,
2056
2103
  }],
2057
2104
  presentationMeta: (_args, value) => ({
2058
2105
  kind: 'memory-forget',
@@ -2069,7 +2116,7 @@ export function apply(ctx, config) {
2069
2116
  const located = locate(scope, args.id);
2070
2117
  if (!located && !args.mode) throw new Error(`没有 ${args.id} 这条记忆`);
2071
2118
  if (!args.mode) {
2072
- return { id: args.id, mode: 'needs_resolution', scope: located.scope.key === 'global' ? 'global' : 'project' };
2119
+ return { id: args.id, title: located.record.title || '', mode: 'needs_resolution', scope: located.scope.key === 'global' ? 'global' : 'project' };
2073
2120
  }
2074
2121
  let dropped;
2075
2122
  try {
@@ -2269,6 +2316,7 @@ export function apply(ctx, config) {
2269
2316
  */
2270
2317
  const runtime = runtimeFor(scope, { sessionId, query, batchKey, messageIds: ids, autoRoute: recallMode() === 'adaptive' });
2271
2318
  if (win && runtime) win.runtime = runtime.held;
2319
+ rememberRuntime(scope, sessionId, runtime && runtime.held);
2272
2320
  if (recallMode() !== 'adaptive') {
2273
2321
  clearPrepared(agent);
2274
2322
  return decision;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lite-memory",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
4
4
  "description": "On-demand memory for the DeepSeek Harness: nothing in the system prompt, one muted line only when something is saved, hit or suggested.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -8,7 +8,7 @@
8
8
  "dsh": ">=0.1.5"
9
9
  },
10
10
  "scripts": {
11
- "test": "node test/store.test.mjs && node test/state.test.mjs && node test/view.test.mjs && node test/anchor.test.mjs && node test/recall.test.mjs && node test/evolution-read.test.mjs && node test/lexical.test.mjs && node test/control.test.mjs && node test/learning.test.mjs && node test/scenario.test.mjs && node test/crossing.test.mjs && node test/runtime-context.test.mjs && node test/client.test.mjs && node test/palace.test.mjs && node test/contract.test.mjs",
11
+ "test": "node test/store.test.mjs && node test/state.test.mjs && node test/view.test.mjs && node test/anchor.test.mjs && node test/recall.test.mjs && node test/evolution-read.test.mjs && node test/lexical.test.mjs && node test/control.test.mjs && node test/learning.test.mjs && node test/scenario.test.mjs && node test/crossing.test.mjs && node test/runtime-context.test.mjs && node test/client.test.mjs && node test/palace.test.mjs && node test/contract.test.mjs && node test/safety.test.mjs && node test/ha.test.mjs && node test/record.test.mjs",
12
12
  "bench": "node bench/bench.mjs",
13
13
  "bench:evolution": "node bench/evolution.mjs"
14
14
  },
@@ -66,6 +66,9 @@
66
66
  "bugs": {
67
67
  "url": "https://github.com/regrevia/dsh-lite-memory/issues"
68
68
  },
69
- "author": "regrevia",
69
+ "author": {
70
+ "name": "regrevia",
71
+ "url": "https://github.com/regrevia"
72
+ },
70
73
  "license": "MIT"
71
74
  }
package/state.js CHANGED
@@ -535,13 +535,17 @@ export function emptyState(scope) {
535
535
  candidates: [],
536
536
  receipts: [],
537
537
  suppressions: [],
538
+ conflicts: [],
538
539
  control: { enabled: true, epoch: crypto.randomUUID(), changedAt: now },
539
540
  experimental: { projectEvolution: newEvolution() },
540
541
  };
541
542
  }
542
543
 
544
+ /** 坏文件不自动覆盖(可能还能人工找回),但错误里必须给出下一步,不能只说「坏了」。 */
543
545
  function fail(file, why, code = 'corrupt') {
544
- return new StateError(code, `记忆状态${why},已停止读写:${file}`);
546
+ const name = path.basename(file);
547
+ const prev = name === 'state.json' ? 'state.prev.json' : `${name}.prev`;
548
+ return new StateError(code, `记忆状态${why},已停止读写:${file}。恢复:把 ${name} 改名成 ${name}.bad 后重试,会自动从上一份完好的 ${prev} 恢复;${prev} 也不在就删掉整个目录重建(旧文件可人工找回)`);
545
549
  }
546
550
 
547
551
  function validEntry(entry) {
@@ -566,7 +570,7 @@ export function validateState(data) {
566
570
  if (typeof data[key] !== 'string' || !data[key]) throw new StateError('invalid', `记忆状态缺少 ${key}`);
567
571
  }
568
572
  if (!Number.isInteger(data.generation) || data.generation < 0) throw new StateError('invalid', '记忆状态 generation 不对');
569
- for (const key of ['entries', 'candidates', 'receipts', 'suppressions']) {
573
+ for (const key of ['entries', 'candidates', 'receipts', 'suppressions', 'conflicts']) {
570
574
  if (!Array.isArray(data[key])) throw new StateError('invalid', `记忆状态 ${key} 不是数组`);
571
575
  }
572
576
  const ids = new Set();
@@ -662,9 +666,12 @@ function readEnvelope(file) {
662
666
  if (upgraded.experimental === undefined) {
663
667
  upgraded.experimental = { projectEvolution: { enabled: false, epoch: '', policyVersion: EVOLUTION_POLICY_VERSION, feedbackGeneration: 0, learning: emptyLearning() } };
664
668
  }
669
+ if (!Array.isArray(upgraded.conflicts)) upgraded.conflicts = [];
665
670
  validateState(upgraded);
666
671
  return upgraded;
667
672
  }
673
+ /** 旧库没有冲突队列:读时补空数组。 */
674
+ if (!Array.isArray(data.payload.conflicts)) data.payload.conflicts = [];
668
675
  validateState(data.payload);
669
676
  return data.payload;
670
677
  }
package/store.js CHANGED
@@ -103,6 +103,10 @@ function toFlat(entry) {
103
103
  reviewState: maintenance.reviewState || 'active',
104
104
  created: entry.createdAt || '',
105
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 : '',
106
110
  lastUsed: maintenance.lastUsed || entry.updatedAt || entry.createdAt || '',
107
111
  revision: Number(entry.revision) || 1,
108
112
  status: entry.status === 'archived' ? 'archived' : 'active',
@@ -130,6 +134,10 @@ function fromFlat(record, status) {
130
134
  ownerProjectId: record.ownerProjectId || '',
131
135
  createdAt: record.created || at,
132
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 : '',
133
141
  current: {
134
142
  title: record.title || '',
135
143
  summary: record.summary || '',
@@ -203,6 +211,7 @@ export function loadIndex(scope, options = {}) {
203
211
  receipts: JSON.parse(JSON.stringify(loaded.state.receipts || [])),
204
212
  lastSuggestedAt: loaded.state.lastSuggestedAt || '',
205
213
  suppressions: JSON.parse(JSON.stringify(loaded.state.suppressions || [])),
214
+ conflicts: JSON.parse(JSON.stringify(loaded.state.conflicts || [])),
206
215
  __evolution: state.evolutionOf(loaded.state),
207
216
  __tasks: anchor.normalizeTasks(state.evolutionOf(loaded.state).tasks),
208
217
  __control: state.controlOf(loaded.state),
@@ -228,6 +237,7 @@ function applyFlat(scope, draft, index) {
228
237
  draft.candidates = Array.isArray(index.suggestions) ? JSON.parse(JSON.stringify(index.suggestions)) : [];
229
238
  draft.receipts = Array.isArray(index.receipts) ? JSON.parse(JSON.stringify(index.receipts)) : [];
230
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)) : [];
231
241
  draft.lastSuggestedAt = typeof index.lastSuggestedAt === 'string' ? index.lastSuggestedAt : draft.lastSuggestedAt || '';
232
242
  draft.scopeKey = draft.scopeKey || index.scope;
233
243
  }
@@ -240,9 +250,10 @@ export function saveIndex(scope, index) {
240
250
  const meta = index.__meta || { exists: false };
241
251
  const expected = meta.exists ? { generation: meta.generation, epoch: meta.epoch } : null;
242
252
  const result = state.transact(scope, { expected, expectCreate: !meta.exists }, (draft) => {
243
- const before = 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();
244
255
  applyFlat(scope, draft, index);
245
- const after = JSON.stringify([draft.entries, draft.candidates, draft.receipts, draft.suppressions, draft.lastSuggestedAt]);
256
+ const after = stamp();
246
257
  return { changed: before !== after };
247
258
  });
248
259
  const published = result.state;
@@ -321,6 +332,7 @@ export function normalizeDraft(source, options = {}) {
321
332
  const has = (key) => Object.prototype.hasOwnProperty.call(draft, key) && draft[key] !== undefined;
322
333
  const id = typeof draft.id === 'string' && /^mem_[a-z0-9]+$/.test(draft.id) ? draft.id : '';
323
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');
324
336
  const when = has('when') ? textField(draft.when, LIMITS.whenChars, '前置条件') : '';
325
337
  const title = textField(cleanTitle(draft.title), LIMITS.titleChars, '标题');
326
338
  /** 带 id 是更新:省略的字段保持原值,标题也一样。 */
@@ -370,7 +382,7 @@ export function normalizeDraft(source, options = {}) {
370
382
  const topicKey = has('topicKey') ? textField(draft.topicKey, LIMITS.tagChars, '主题') : undefined;
371
383
  /** 凭据扫描必须覆盖完整原文:截短后的摘要看不出凭据,原文里有也不行。 */
372
384
  const secret = detectSecret([title, summary, rawSummary, when, body, tags.join(' '), topicKey || ''].join('\n'));
373
- if (secret) throw new Error(`疑似凭据(${secret}),只记位置,不记值`);
385
+ if (secret) throw new state.StateError('unsafe', `疑似凭据(${secret}),只记位置,不记值:正文里写「去哪取、怎么取」,不抄值`);
374
386
  return {
375
387
  id,
376
388
  title,
@@ -392,6 +404,8 @@ export function normalizeDraft(source, options = {}) {
392
404
  links,
393
405
  applicability,
394
406
  topicKey,
407
+ /** 显式取代:新记录写好后再给旧记录打 supersededBy/validUntil。 */
408
+ supersedes: has('supersedes') ? textField(draft.supersedes, 60, '取代目标') : '',
395
409
  has: {
396
410
  title: has('title') && Boolean(title),
397
411
  summary: has('summary'),
@@ -405,6 +419,7 @@ export function normalizeDraft(source, options = {}) {
405
419
  links: links !== undefined,
406
420
  applicability: applicability !== undefined,
407
421
  topicKey: topicKey !== undefined,
422
+ supersedes: has('supersedes'),
408
423
  },
409
424
  };
410
425
  }
@@ -695,9 +710,9 @@ const STOP_GRAMS = new Set([
695
710
  '的话', '这里', '那里', '一个', '一些', '这样', '那样', '看看', '帮我看', '继续',
696
711
  ]);
697
712
 
698
- const asciiWordsOf = (text) => normalizeText(text, 400).toLowerCase().match(/[a-z][a-z0-9_-]{1,}/g) || [];
699
-
700
- 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;
701
716
 
702
717
  /** 查询按空格/标点切成段:多段按关键词处理,整句仍按相关度处理。 */
703
718
  function queryRuns(query) {
@@ -812,7 +827,16 @@ export function similarEntries(entries, title, summary, limit = 2) {
812
827
  const titleScore = Math.max(0, ...names.map((name) => similarity(name, title)));
813
828
  const summaryScore = similarity(item.summary, summary);
814
829
  const score = Math.max(titleScore, summaryScore);
815
- 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
+ });
816
840
  }
817
841
  rows.sort((a, b) => b.score - a.score);
818
842
  return rows.slice(0, limit);
@@ -946,36 +970,74 @@ function mergedFields(draft, record, oldBody, index, writeMode = 'patch', foreig
946
970
  return next;
947
971
  }
948
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
+
949
993
  /**
950
994
  * 一次写入的纯业务部分:校验、身份、字节预算、版本。不改盘(正文写入由调用方在同一把锁里做)。
951
995
  */
952
996
  function applyDraft(scope, index, source, now, options = {}) {
953
997
  const draft = normalizeDraft(source);
998
+ scanUnsafe(draft);
954
999
  const foreign = options.foreignEntries || [];
955
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
+ };
956
1010
  let record = draft.id ? index.entries.find((item) => item.id === draft.id) : null;
957
1011
  if (draft.id && !record) throw new Error('没有这条记忆,改用新增');
958
1012
  let mergedFrom = null;
959
1013
  let similarList = [];
960
1014
  if (!record) {
961
1015
  /** distinct 是用户明确说的「这是另一条」:同名也另起一条,不被规范化后的精确同名吞掉。 */
962
- const hit = distinct ? null : resolveTarget(index.entries, draft);
1016
+ const hit = distinct ? null : resolveTarget(live, draft);
963
1017
  if (hit) {
964
1018
  record = hit.target;
965
1019
  mergedFrom = hit.target.title === draft.title ? null : hit.target.title;
966
1020
  } else if (draft.has.topicKey && draft.topicKey && !distinct) {
967
1021
  /** 同一槽位只留一条现行规范:同层同 topicKey + 同条件必须确认是更新还是另一条。 */
968
- const slot = index.entries.find((item) => (item.topicKey || '') === draft.topicKey
1022
+ const slot = live.find((item) => (item.topicKey || '') === draft.topicKey
969
1023
  && view.conditionsKey(item.conditions) === view.conditionsKey(draft.conditions));
970
1024
  if (slot) {
971
- 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
+ };
972
1034
  return { needsResolution: true, sameTopic: true, similarList: [row], similar: row, draft, record: null, merged: false, previousBody: null, archived: [] };
973
1035
  }
974
- similarList = similarEntries(index.entries, draft.title, draft.summary, 2);
1036
+ similarList = similarEntries(live, draft.title, draft.summary, 2);
975
1037
  } else {
976
- similarList = similarEntries(index.entries, draft.title, draft.summary, 2);
1038
+ similarList = similarEntries(live, draft.title, draft.summary, 2);
977
1039
  /** 同名不同前置条件是用户明确表达的另一条,不走确认;只有换个说法的相近标题才拦。 */
978
- const sameName = index.entries.some((item) => [item.title, ...(item.aliases || [])]
1040
+ const sameName = live.some((item) => [item.title, ...(item.aliases || [])]
979
1041
  .some((name) => String(name || '').toLowerCase() === draft.title.toLowerCase()));
980
1042
  if (similarList.length > 0 && !distinct && !sameName) {
981
1043
  return { needsResolution: true, similarList, similar: similarList[0], draft, record: null, merged: false, previousBody: null, archived: [] };
@@ -989,6 +1051,7 @@ function applyDraft(scope, index, source, now, options = {}) {
989
1051
  }
990
1052
  }
991
1053
  if (record) {
1054
+ const target = supersedeTarget(record.id);
992
1055
  const oldBody = readBody(scope, record.id);
993
1056
  const next = mergedFields(draft, record, oldBody, index, writeMode, foreign);
994
1057
  /** 正文原样保存:摘要再长也不往正文里补尾巴;完整原句进维护区。 */
@@ -1009,7 +1072,7 @@ function applyDraft(scope, index, source, now, options = {}) {
1009
1072
  && next.applicability === (record.applicability || 'all_in_scope')
1010
1073
  && next.body === oldBody
1011
1074
  && summaryFull === (typeof record.summaryFull === 'string' ? record.summaryFull : '');
1012
- if (noop) {
1075
+ if (noop && !target) {
1013
1076
  return { record, merged: true, mergedFrom: null, similar: null, similarList, draft, noop: true, previousBody: oldBody, archived: [] };
1014
1077
  }
1015
1078
  const versions = snapshot(record, oldBody);
@@ -1036,10 +1099,18 @@ function applyDraft(scope, index, source, now, options = {}) {
1036
1099
  const archived = assertFits(scope, index, nextRecord);
1037
1100
  writeBody(scope, record.id, next.body);
1038
1101
  Object.assign(record, nextRecord);
1039
- 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 };
1040
1110
  }
1041
1111
  if (writeMode === 'replace_current') throw new Error('整份替换要先指明是哪条,不能用来新增');
1042
1112
  /** 新建也只用调用方给的正文:超长摘要的完整原句进维护区,不混进正文。 */
1113
+ const target = supersedeTarget('');
1043
1114
  const createdBody = draft.body;
1044
1115
  draft.fullTextInBody = false;
1045
1116
  draft.fullTextSaved = Boolean(draft.summaryFull);
@@ -1062,6 +1133,10 @@ function applyDraft(scope, index, source, now, options = {}) {
1062
1133
  aliases: [],
1063
1134
  created: now,
1064
1135
  updated: now,
1136
+ validFrom: now,
1137
+ validUntil: '',
1138
+ supersededBy: '',
1139
+ supersedeReason: '',
1065
1140
  lastUsed: now,
1066
1141
  revision: 1,
1067
1142
  status: 'active',
@@ -1079,9 +1154,42 @@ function applyDraft(scope, index, source, now, options = {}) {
1079
1154
  const archived = assertFits(scope, index, created);
1080
1155
  writeBody(scope, created.id, created.body);
1081
1156
  index.entries.push(created);
1157
+ if (target) created.versions = stampSupersede(scope, target, created.id, now);
1082
1158
  return { record: created, merged: false, mergedFrom: null, similar: similarList[0] || null, similarList, draft, noop: false, previousBody: null, archived };
1083
1159
  }
1084
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
+
1085
1193
  /**
1086
1194
  * 单条与记录序列化两档预算:新增与更新走同一套。
1087
1195
  * 整层条数/正文预算由 state.transact 在发布前裁决(腾最久没读的那条,不拒绝新记忆),
@@ -1359,6 +1467,8 @@ export function search(scope, query, limit = 4, options = {}) {
1359
1467
  const collect = (pool, archived) => {
1360
1468
  for (const item of pool) {
1361
1469
  if (!readableRecord(item, options, index)) continue;
1470
+ /** 被取代/过期待记录默认不出现;history:true 才列出,供模型看演化。 */
1471
+ if (options.history !== true && staleRecord(item, options.now || Date.now())) continue;
1362
1472
  /** 已暂停、已过期或待确认的学习规则不进入检索结果,除非明确要病史。 */
1363
1473
  if (options.allowExperimental === true && options.history !== true
1364
1474
  && isExperimentalRecord(item) && !view.experimentalUsable(item, options.now)) continue;
@@ -1594,6 +1704,8 @@ function findCurrent(id, scope, options = {}) {
1594
1704
  summary: record.summary || '',
1595
1705
  scope: source.key === 'global' ? 'global' : 'project',
1596
1706
  revision: record.revision || 1,
1707
+ validUntil: record.validUntil || '',
1708
+ supersededBy: record.supersededBy || '',
1597
1709
  status: 'ok',
1598
1710
  };
1599
1711
  }
@@ -2353,6 +2465,9 @@ function crossLayerSimilar(projectScope, targetScope, draft) {
2353
2465
  id: item.id,
2354
2466
  title: item.title,
2355
2467
  summary: item.summary || '',
2468
+ when: item.when || '',
2469
+ updated: item.updated || '',
2470
+ reason: item.reason || 'similar_title',
2356
2471
  revision: item.revision || 1,
2357
2472
  scope: other.key === 'global' ? 'global' : 'project',
2358
2473
  }));
@@ -2369,6 +2484,49 @@ function bindSameTopic(projectScope, ref) {
2369
2484
  return { topicKey: view.topicSlot(record) };
2370
2485
  }
2371
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
+
2372
2530
  /**
2373
2531
  * 保存的唯一入口:先路由,再在两层里查重(完全相同 → existing_ref;只是相近 → 只给候选),
2374
2532
  * 最后才写。写之前判不出来的范围返回 needsScope,不替用户选。
@@ -2412,6 +2570,7 @@ export function saveEntry(projectScope, source) {
2412
2570
  }
2413
2571
  const near = crossLayerSimilar(projectScope, targetScope, draft);
2414
2572
  if (near.length) {
2573
+ const pendingConflicts = queueConflictLocked(targetScope, draft, near);
2415
2574
  return {
2416
2575
  needsResolution: true,
2417
2576
  crossLayer: true,
@@ -2419,12 +2578,19 @@ export function saveEntry(projectScope, source) {
2419
2578
  similar: near[0],
2420
2579
  draft,
2421
2580
  needsScope: false,
2581
+ pendingConflicts,
2422
2582
  chosenScope: targetScope.key === 'global' ? 'global' : 'project',
2423
2583
  };
2424
2584
  }
2425
2585
  }
2426
2586
  const foreign = targetScope.key === 'global' ? [] : loadIndex(globalScope()).entries;
2427
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
+ }
2428
2594
  return { ...result, chosenScope: targetScope.key === 'global' ? 'global' : 'project', needsScope: false, reasonCode: routed.reasonCode };
2429
2595
  });
2430
2596
  }