dsh-lite-memory 0.17.0 → 0.18.19

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.
Files changed (42) hide show
  1. package/README.md +15 -15
  2. package/TRIGGERS.md +1 -1
  3. package/client.js +343 -44
  4. package/docs/BENCH.md +10 -8
  5. package/docs/RELEASE-0.18.0.md +35 -0
  6. package/docs/RELEASE-0.18.1.md +18 -0
  7. package/docs/RELEASE-0.18.10.md +25 -0
  8. package/docs/RELEASE-0.18.11.md +48 -0
  9. package/docs/RELEASE-0.18.12.md +27 -0
  10. package/docs/RELEASE-0.18.13.md +13 -0
  11. package/docs/RELEASE-0.18.14.md +13 -0
  12. package/docs/RELEASE-0.18.15.md +5 -0
  13. package/docs/RELEASE-0.18.2.md +21 -0
  14. package/docs/RELEASE-0.18.3.md +16 -0
  15. package/docs/RELEASE-0.18.4.md +15 -0
  16. package/docs/RELEASE-0.18.5.md +21 -0
  17. package/docs/RELEASE-0.18.6.md +18 -0
  18. package/docs/RELEASE-0.18.7.md +23 -0
  19. package/docs/RELEASE-0.18.8.md +18 -0
  20. package/docs/RELEASE-0.18.9.md +24 -0
  21. package/index.js +403 -131
  22. package/learning.js +65 -6
  23. package/package.json +7 -4
  24. package/state.js +56 -8
  25. package/store.js +361 -33
  26. package/test/client.test.mjs +79 -17
  27. package/test/contract.test.mjs +231 -93
  28. package/test/control.test.mjs +2 -2
  29. package/test/crossing.test.mjs +22 -1
  30. package/test/eval-seed.test.mjs +113 -0
  31. package/test/evolution-read.test.mjs +33 -10
  32. package/test/ha.test.mjs +186 -0
  33. package/test/layer-read.test.mjs +293 -0
  34. package/test/learning.test.mjs +80 -0
  35. package/test/palace.test.mjs +2 -0
  36. package/test/recall.test.mjs +80 -9
  37. package/test/record.test.mjs +130 -0
  38. package/test/safety.test.mjs +119 -0
  39. package/test/state.test.mjs +57 -1
  40. package/test/store.test.mjs +49 -5
  41. package/test/view.test.mjs +3 -0
  42. package/view.js +97 -13
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,92 @@ 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 = [];
1014
+ /**
1015
+ * 同名就是同一条:标题是唯一句柄,重复存同一个标题必须是更新,不能悄悄另建一条
1016
+ * (否则按标题读、按标题忘都会撞上两条)。前置条件明确不同的同名才允许是另一条。
1017
+ */
1018
+ if (!record && !distinct) {
1019
+ const wantedConditions = view.conditionsKey(draft.conditions || []);
1020
+ /** 只改摘要/正文时没重写适用条件,别因此把有条件的那条挤成另一条。 */
1021
+ const statedConditions = Boolean(draft.has.when || draft.has.conditions || draft.has.applicability);
1022
+ const named = live.find((item) => [item.title, ...(item.aliases || [])]
1023
+ .some((name) => String(name || '').trim().toLowerCase() === String(draft.title || '').trim().toLowerCase())
1024
+ && (!statedConditions || view.conditionsKey(item.conditions || []) === wantedConditions)
1025
+ /** 同名但两边都明确写了不同的 when,是用户表达的另一种情况,算另一条。 */
1026
+ && !(draft.has.when && String(item.when || '') && String(item.when || '') !== String(draft.when || '')));
1027
+ if (named) {
1028
+ record = named;
1029
+ mergedFrom = named.title === draft.title ? null : named.title;
1030
+ }
1031
+ }
960
1032
  if (!record) {
961
1033
  /** distinct 是用户明确说的「这是另一条」:同名也另起一条,不被规范化后的精确同名吞掉。 */
962
- const hit = distinct ? null : resolveTarget(index.entries, draft);
1034
+ const hit = distinct ? null : resolveTarget(live, draft);
963
1035
  if (hit) {
964
1036
  record = hit.target;
965
1037
  mergedFrom = hit.target.title === draft.title ? null : hit.target.title;
966
1038
  } else if (draft.has.topicKey && draft.topicKey && !distinct) {
967
1039
  /** 同一槽位只留一条现行规范:同层同 topicKey + 同条件必须确认是更新还是另一条。 */
968
- const slot = index.entries.find((item) => (item.topicKey || '') === draft.topicKey
1040
+ const slot = live.find((item) => (item.topicKey || '') === draft.topicKey
969
1041
  && view.conditionsKey(item.conditions) === view.conditionsKey(draft.conditions));
970
1042
  if (slot) {
971
- const row = { id: slot.id, title: slot.title, summary: slot.summary || '', revision: slot.revision || 1 };
1043
+ const row = {
1044
+ id: slot.id,
1045
+ title: slot.title,
1046
+ summary: slot.summary || '',
1047
+ when: slot.when || '',
1048
+ updated: slot.updated || slot.created || '',
1049
+ reason: 'same_topic',
1050
+ revision: slot.revision || 1,
1051
+ };
972
1052
  return { needsResolution: true, sameTopic: true, similarList: [row], similar: row, draft, record: null, merged: false, previousBody: null, archived: [] };
973
1053
  }
974
- similarList = similarEntries(index.entries, draft.title, draft.summary, 2);
1054
+ similarList = similarEntries(live, draft.title, draft.summary, 2);
975
1055
  } else {
976
- similarList = similarEntries(index.entries, draft.title, draft.summary, 2);
1056
+ similarList = similarEntries(live, draft.title, draft.summary, 2);
977
1057
  /** 同名不同前置条件是用户明确表达的另一条,不走确认;只有换个说法的相近标题才拦。 */
978
- const sameName = index.entries.some((item) => [item.title, ...(item.aliases || [])]
1058
+ const sameName = live.some((item) => [item.title, ...(item.aliases || [])]
979
1059
  .some((name) => String(name || '').toLowerCase() === draft.title.toLowerCase()));
980
1060
  if (similarList.length > 0 && !distinct && !sameName) {
981
1061
  return { needsResolution: true, similarList, similar: similarList[0], draft, record: null, merged: false, previousBody: null, archived: [] };
@@ -983,12 +1063,20 @@ function applyDraft(scope, index, source, now, options = {}) {
983
1063
  }
984
1064
  }
985
1065
  const writeMode = source && source.writeMode === 'replace_current' ? 'replace_current' : 'patch';
1066
+ /** 改名不能撞上另一条现行记忆:标题是唯一句柄,撞了以后按标题读会分不清。 */
1067
+ if (record && draft.has.title && cleanTitle(draft.title) !== record.title) {
1068
+ const wanted = cleanTitle(draft.title).toLowerCase();
1069
+ const clash = live.find((item) => item.id !== record.id
1070
+ && [item.title, ...(item.aliases || [])].some((name) => String(name || '').trim().toLowerCase() === wanted));
1071
+ if (clash) throw new Error(`已经有同名记忆「${clash.title}」,先改那一条的标题`);
1072
+ }
986
1073
  if (record && writeMode === 'replace_current') {
987
1074
  for (const key of ['title', 'summary', 'when', 'body', 'kind', 'tags']) {
988
1075
  if (!draft.has[key]) throw new Error(`整份替换要给出 ${key},空值也要显式写`);
989
1076
  }
990
1077
  }
991
1078
  if (record) {
1079
+ const target = supersedeTarget(record.id);
992
1080
  const oldBody = readBody(scope, record.id);
993
1081
  const next = mergedFields(draft, record, oldBody, index, writeMode, foreign);
994
1082
  /** 正文原样保存:摘要再长也不往正文里补尾巴;完整原句进维护区。 */
@@ -1009,7 +1097,7 @@ function applyDraft(scope, index, source, now, options = {}) {
1009
1097
  && next.applicability === (record.applicability || 'all_in_scope')
1010
1098
  && next.body === oldBody
1011
1099
  && summaryFull === (typeof record.summaryFull === 'string' ? record.summaryFull : '');
1012
- if (noop) {
1100
+ if (noop && !target) {
1013
1101
  return { record, merged: true, mergedFrom: null, similar: null, similarList, draft, noop: true, previousBody: oldBody, archived: [] };
1014
1102
  }
1015
1103
  const versions = snapshot(record, oldBody);
@@ -1036,10 +1124,18 @@ function applyDraft(scope, index, source, now, options = {}) {
1036
1124
  const archived = assertFits(scope, index, nextRecord);
1037
1125
  writeBody(scope, record.id, next.body);
1038
1126
  Object.assign(record, nextRecord);
1039
- return { record, merged: true, mergedFrom, similar: null, similarList, draft, noop: false, previousBody: oldBody, archived };
1127
+ /** 更新让这条重新成为现行版本:旧的取代链标记要摘掉。 */
1128
+ delete record.supersededBy;
1129
+ record.validUntil = '';
1130
+ if (target) {
1131
+ const chain = stampSupersede(scope, target, record.id, now);
1132
+ record.versions = [...chain, ...(record.versions || [])].slice(0, 2);
1133
+ }
1134
+ return { record, merged: true, mergedFrom, similar: null, similarList, draft, noop: Boolean(noop), previousBody: oldBody, archived };
1040
1135
  }
1041
1136
  if (writeMode === 'replace_current') throw new Error('整份替换要先指明是哪条,不能用来新增');
1042
1137
  /** 新建也只用调用方给的正文:超长摘要的完整原句进维护区,不混进正文。 */
1138
+ const target = supersedeTarget('');
1043
1139
  const createdBody = draft.body;
1044
1140
  draft.fullTextInBody = false;
1045
1141
  draft.fullTextSaved = Boolean(draft.summaryFull);
@@ -1062,6 +1158,10 @@ function applyDraft(scope, index, source, now, options = {}) {
1062
1158
  aliases: [],
1063
1159
  created: now,
1064
1160
  updated: now,
1161
+ validFrom: now,
1162
+ validUntil: '',
1163
+ supersededBy: '',
1164
+ supersedeReason: '',
1065
1165
  lastUsed: now,
1066
1166
  revision: 1,
1067
1167
  status: 'active',
@@ -1079,9 +1179,42 @@ function applyDraft(scope, index, source, now, options = {}) {
1079
1179
  const archived = assertFits(scope, index, created);
1080
1180
  writeBody(scope, created.id, created.body);
1081
1181
  index.entries.push(created);
1182
+ if (target) created.versions = stampSupersede(scope, target, created.id, now);
1082
1183
  return { record: created, merged: false, mergedFrom: null, similar: similarList[0] || null, similarList, draft, noop: false, previousBody: null, archived };
1083
1184
  }
1084
1185
 
1186
+ /**
1187
+ * 写入前兜底扫描:记忆会被 memory_read 原样喂回模型,所以只拦形状明确的三类——
1188
+ * 提示注入指令、凭据原文、不可见 Unicode;提到「凭据 / 指令」的正文不算。
1189
+ */
1190
+ const UNSAFE_TEXT = [
1191
+ { code: 'injection', why: '像提示注入的指令', fix: '把「指令」改成对规则的描述', test: /ignore\s+(?:all\s+)?(?:previous|prior|above)\s+(?:instructions?|rules?|prompts?)/i },
1192
+ { code: 'injection', why: '像提示注入的指令', fix: '把「指令」改成对规则的描述', test: /忽略(?:上面|以上|此前|之前)?(?:的)?(?:所有)?(?:指令|指示|要求|规则)/ },
1193
+ { code: 'injection', why: '像提示注入的指令', fix: '把「指令」改成对规则的描述', test: /<\/?(?:system|instructions?|prompt|tool_call)[^>]*>/i },
1194
+ { code: 'injection', why: '像提示注入的指令', fix: '把「指令」改成对规则的描述', test: /(?:^|\n)\s*(?:system|assistant)\s*[::]/i },
1195
+ ];
1196
+ const INVISIBLE = /[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]|[\u{E0000}-\u{E007F}]/u;
1197
+ const SCAN_FIELD_NAMES = { title: '标题', summary: '摘要', when: '适用场景', body: '正文', tags: '标签', triggers: '触发词', conditions: '条件', selection: '适用范围' };
1198
+
1199
+ function scanUnsafe(draft) {
1200
+ const fields = [
1201
+ ['title', draft.title], ['summary', draft.summary], ['when', draft.when], ['body', draft.body],
1202
+ ['tags', (draft.tags || []).join('\n')], ['triggers', (draft.triggers || []).join('\n')],
1203
+ ['conditions', JSON.stringify(draft.conditions || [])], ['selection', JSON.stringify(draft.selection || null)],
1204
+ ];
1205
+ for (const [field, raw] of fields) {
1206
+ const text = typeof raw === 'string' ? raw : '';
1207
+ if (!text) continue;
1208
+ const name = SCAN_FIELD_NAMES[field] || field;
1209
+ if (INVISIBLE.test(text)) {
1210
+ throw new state.StateError('unsafe', `${name}里有看不见的字符(零宽或方向控制);它们会跟着记忆原样回到模型,删掉再存`);
1211
+ }
1212
+ for (const rule of UNSAFE_TEXT) {
1213
+ if (rule.test.test(text)) throw new state.StateError('unsafe', `${name}${rule.why};${rule.fix}`);
1214
+ }
1215
+ }
1216
+ }
1217
+
1085
1218
  /**
1086
1219
  * 单条与记录序列化两档预算:新增与更新走同一套。
1087
1220
  * 整层条数/正文预算由 state.transact 在发布前裁决(腾最久没读的那条,不拒绝新记忆),
@@ -1359,6 +1492,8 @@ export function search(scope, query, limit = 4, options = {}) {
1359
1492
  const collect = (pool, archived) => {
1360
1493
  for (const item of pool) {
1361
1494
  if (!readableRecord(item, options, index)) continue;
1495
+ /** 被取代/过期待记录默认不出现;history:true 才列出,供模型看演化。 */
1496
+ if (options.history !== true && staleRecord(item, options.now || Date.now())) continue;
1362
1497
  /** 已暂停、已过期或待确认的学习规则不进入检索结果,除非明确要病史。 */
1363
1498
  if (options.allowExperimental === true && options.history !== true
1364
1499
  && isExperimentalRecord(item) && !view.experimentalUsable(item, options.now)) continue;
@@ -1443,8 +1578,10 @@ export function peek(scope, query, limit = 3, options = {}) {
1443
1578
  }
1444
1579
 
1445
1580
  export function readEntry(scope, id, options = {}) {
1446
- const found = withLocks([scope, globalScope()], () => {
1447
- for (const source of [scope, globalScope()]) {
1581
+ /** 显式指定层时只读那一层;默认仍只读当前项目和全局。 */
1582
+ const sources = Array.isArray(options.layers) && options.layers.length ? options.layers : runtimeLayers(scope);
1583
+ const found = withLocks(sources, () => {
1584
+ for (const source of sources) {
1448
1585
  const index = loadIndex(source);
1449
1586
  const record = index.entries.find((item) => item.id === id);
1450
1587
  const archived = record ? null : index.archived.find((item) => item.id === id);
@@ -1465,8 +1602,9 @@ export function readEntry(scope, id, options = {}) {
1465
1602
  return null;
1466
1603
  });
1467
1604
  if (!found) return null;
1468
- /** 读过才算用过;归档的不回写(看病史不等于「在用」)。 */
1469
- if (!found.archived) touchUsed(found.source, [id]);
1605
+ /** 读过才算用过;归档、被取代、过期或条件不匹配的读取都不算「在用」。 */
1606
+ const countsAsUsage = typeof options.usage !== 'function' || options.usage(found.record) !== false;
1607
+ if (!found.archived && countsAsUsage) touchUsed(found.source, [id]);
1470
1608
  return found.record;
1471
1609
  }
1472
1610
 
@@ -1477,11 +1615,14 @@ export function readEntry(scope, id, options = {}) {
1477
1615
  export function forget(scope, id, options = {}) {
1478
1616
  const mode = options.mode;
1479
1617
  if (mode !== 'archive' && mode !== 'purge') return { id, mode: 'needs_resolution' };
1480
- return withLocks([scope, globalScope()], () => {
1618
+ /** 显式指定的层就是唯一范围;不然才按运行时「当前项目 + 全局」找。 */
1619
+ const sources = Array.isArray(options.layers) && options.layers.length ? options.layers : null;
1620
+ const scopeList = sources || [scope, globalScope()];
1621
+ return withLocks(scopeList, () => {
1481
1622
  /** 屏障优先:上次彻底删除没做完时,先按原层 + 原 operationId 收尾,再决定这条在哪。 */
1482
1623
  let replayed = null;
1483
1624
  if (mode === 'purge') {
1484
- for (const source of [scope, globalScope()]) {
1625
+ for (const source of scopeList) {
1485
1626
  const pending = state.purgeIntent(source);
1486
1627
  if (!pending) continue;
1487
1628
  /** 冷存里有待删条目的正文副本:先隔离,state.purge 的残留扫描才收得干净。 */
@@ -1502,7 +1643,7 @@ export function forget(scope, id, options = {}) {
1502
1643
  }
1503
1644
  if (replayed) return replayed;
1504
1645
  }
1505
- for (const source of [scope, globalScope()]) {
1646
+ for (const source of scopeList) {
1506
1647
  const index = loadIndex(source);
1507
1648
  const active = index.entries.find((item) => item.id === id);
1508
1649
  const archived = active ? null : index.archived.find((item) => item.id === id);
@@ -1554,8 +1695,8 @@ export function forget(scope, id, options = {}) {
1554
1695
 
1555
1696
  /** 直接读邻居:只给当前投影,不缓存目标正文;目标不在了就标 invalid,不拿旧版本顶上。 */
1556
1697
  export function readLinks(scope, id, options = {}) {
1557
- return withLocks([scope, globalScope()], () => {
1558
- for (const source of [scope, globalScope()]) {
1698
+ return withLocks(runtimeLayers(scope), () => {
1699
+ for (const source of runtimeLayers(scope)) {
1559
1700
  const index = loadIndex(source);
1560
1701
  const record = index.entries.find((item) => item.id === id);
1561
1702
  if (!record) continue;
@@ -1574,7 +1715,7 @@ export function readLinks(scope, id, options = {}) {
1574
1715
 
1575
1716
  /** 关联目标按当前投影现场解引用:先项目层再全局层,归档和删除都算不存在。 */
1576
1717
  function findCurrent(id, scope, options = {}) {
1577
- for (const source of [scope, globalScope()]) {
1718
+ for (const source of runtimeLayers(scope)) {
1578
1719
  let index;
1579
1720
  try {
1580
1721
  index = loadIndex(source);
@@ -1594,6 +1735,8 @@ function findCurrent(id, scope, options = {}) {
1594
1735
  summary: record.summary || '',
1595
1736
  scope: source.key === 'global' ? 'global' : 'project',
1596
1737
  revision: record.revision || 1,
1738
+ validUntil: record.validUntil || '',
1739
+ supersededBy: record.supersededBy || '',
1597
1740
  status: 'ok',
1598
1741
  };
1599
1742
  }
@@ -1711,9 +1854,51 @@ export function findAnywhere(id) {
1711
1854
  return null;
1712
1855
  }
1713
1856
 
1857
+ /** 运行时只允许当前项目和全局层:别的项目仅供人类管理面只读枚举。 */
1858
+ export function runtimeLayers(scope) {
1859
+ const out = [];
1860
+ const seen = new Set();
1861
+ const push = (item) => {
1862
+ if (!item || !item.dir || seen.has(item.dir)) return;
1863
+ seen.add(item.dir);
1864
+ out.push(item);
1865
+ };
1866
+ push(scope);
1867
+ push(globalScope());
1868
+ return out;
1869
+ }
1870
+
1714
1871
  /** 按 id 在项目层与全局层里找位置,不靠文案猜作用域;管理动作也要看得见已归档的条目。 */
1872
+ /**
1873
+ * 标题是唯一对外的句柄:当前项目找不到,就去全局和别的项目层找。
1874
+ * 只用于读路径(读正文、查关联、解析标题);写入仍然只动自己那层。
1875
+ */
1876
+ export function layersFor(scope) {
1877
+ const out = [];
1878
+ const seen = new Set();
1879
+ const push = (item) => {
1880
+ if (!item || !item.dir || seen.has(item.dir)) return;
1881
+ seen.add(item.dir);
1882
+ out.push(item);
1883
+ };
1884
+ push(scope);
1885
+ push(globalScope());
1886
+ try {
1887
+ for (const row of state.knownProjectScopes()) push({ key: row.key, dir: row.dir, cwd: row.cwd });
1888
+ } catch { /* 层列表读不出来就只查当前层和全局,不影响读取 */ }
1889
+ return out;
1890
+ }
1891
+
1715
1892
  export function locate(scope, id, options = {}) {
1716
- for (const source of [scope, globalScope()]) {
1893
+ /**
1894
+ * 默认只看当前层和全局:面板/HTTP 用 id 探正文时跨层就是越权。
1895
+ * 工具侧按标题读,明确传 crossLayer 才跨层(标题是用户给的句柄);
1896
+ * 显式指定某一层时只给那一层,绝不拿项目层去命中全局条目。
1897
+ */
1898
+ const sources = Array.isArray(options.layers) && options.layers.length
1899
+ ? options.layers
1900
+ : options.crossLayer ? layersFor(scope) : runtimeLayers(scope);
1901
+ for (const source of sources) {
1717
1902
  const index = loadIndex(source, options);
1718
1903
  const active = index.entries.find((item) => item.id === id);
1719
1904
  if (active) return { scope: source, record: active, archived: false };
@@ -1723,6 +1908,35 @@ export function locate(scope, id, options = {}) {
1723
1908
  return null;
1724
1909
  }
1725
1910
 
1911
+ /**
1912
+ * 标题是界面和模型唯一的句柄:在给定范围里按标题(含别名)找那一条。
1913
+ * 同层可能同名但适用条件不同,所以带了版次就先按版次收敛,再退回第一条。
1914
+ */
1915
+ export function locateByTitle(scope, title, options = {}) {
1916
+ const wanted = normalizeText(title, 120).toLowerCase();
1917
+ if (!wanted) return null;
1918
+ const sources = options.crossLayer ? layersFor(scope) : runtimeLayers(scope);
1919
+ const want = Number.isInteger(options.revision) ? options.revision : null;
1920
+ for (const source of sources) {
1921
+ const index = loadIndex(source, options);
1922
+ const pick = (list) => {
1923
+ const rows = list.filter((item) => [item.title, ...(Array.isArray(item.aliases) ? item.aliases : [])]
1924
+ .some((name) => normalizeText(name, 120).toLowerCase() === wanted));
1925
+ if (rows.length === 0) return null;
1926
+ if (want !== null) {
1927
+ const exact = rows.find((item) => (Number(item.revision) || 1) === want);
1928
+ if (exact) return exact;
1929
+ }
1930
+ return rows[0];
1931
+ };
1932
+ const active = pick(index.entries);
1933
+ if (active) return { scope: source, record: active, archived: false };
1934
+ const archived = pick(index.archived);
1935
+ if (archived) return { scope: source, record: archived, archived: true };
1936
+ }
1937
+ return null;
1938
+ }
1939
+
1726
1940
  function forgottenFingerprints(index) {
1727
1941
  const out = new Set();
1728
1942
  for (const row of index.suppressions || []) {
@@ -2002,7 +2216,7 @@ export function renderIndex(entries, budget = 400) {
2002
2216
  const stateTag = item.state === 'archived' ? ' · 已归档' : item.state === 'needs_context' ? ' · 适用范围待确认' : item.state === 'not_applicable' ? ' · 当前条件不适用' : '';
2003
2217
  const originTag = item.origin === 'project_evolution' ? ' · 实验' : '';
2004
2218
  const tag = item.scope === 'global' ? '[global]' : item.scopeName ? `[${item.scopeName}]` : '';
2005
- const head = `${tag ? `${tag} ` : ''}${item.id} ${item.title}${item.when ? `(${item.when})` : ''}${originTag}${stateTag}`;
2219
+ const head = `${tag ? `${tag} ` : ''}「${item.title}」${item.when ? `(${item.when})` : ''}${originTag}${stateTag}`;
2006
2220
  const line = stale ? `- ${head}` : `- ${head}|${item.summary}`;
2007
2221
  if (used + line.length > budget) break;
2008
2222
  lines.push(line);
@@ -2176,6 +2390,30 @@ export function readCitationBody(sessionId, id) {
2176
2390
  const data = readSessionFile(sessionId);
2177
2391
  const row = ((data && data.items) || []).find((item) => item && item.id === id);
2178
2392
  if (!row) return null;
2393
+ return readCitationRow(sessionId, row);
2394
+ }
2395
+
2396
+ /** 按标题读引用正文:界面手里只有标题,id 留在引用文件里。 */
2397
+ export function readCitationBodyByTitle(sessionId, title, options = {}) {
2398
+ const wanted = normalizeText(title, 120).toLowerCase();
2399
+ if (!wanted) return null;
2400
+ const data = readSessionFile(sessionId);
2401
+ const want = Number.isInteger(options.revision) ? options.revision : null;
2402
+ const scopeWanted = options.scope === 'global' || options.scope === 'project' ? options.scope : '';
2403
+ /** 引用行只存身份,标题要从当前索引解析出来再比;行上没有 title 字段。 */
2404
+ const matches = ((data && data.items) || [])
2405
+ .map((row) => ({ row, found: resolveCitation(row) }))
2406
+ .filter((item) => item.found
2407
+ && (!scopeWanted || (item.found.scope.key === 'global' ? 'global' : 'project') === scopeWanted)
2408
+ && normalizeText(item.found.row.title || '', 120).toLowerCase() === wanted);
2409
+ if (matches.length === 0) return null;
2410
+ const chosen = (want !== null
2411
+ && matches.find((item) => (Number(item.found.entry.revision) || 1) === want)) || matches[0];
2412
+ return readCitationRow(sessionId, chosen.row);
2413
+ }
2414
+
2415
+ function readCitationRow(sessionId, row) {
2416
+ const id = row.id;
2179
2417
  const found = resolveCitation(row);
2180
2418
  if (!found) return null;
2181
2419
  let body;
@@ -2188,6 +2426,7 @@ export function readCitationBody(sessionId, id) {
2188
2426
  }
2189
2427
  return {
2190
2428
  ...found.row,
2429
+ status: found.entry.status || 'active',
2191
2430
  scopeLabel: found.scope.key === 'global' ? '全局' : path.basename(found.scope.cwd || '') || found.scope.key,
2192
2431
  body: body.slice(0, LIMITS.entryBytes),
2193
2432
  };
@@ -2353,28 +2592,90 @@ function crossLayerSimilar(projectScope, targetScope, draft) {
2353
2592
  id: item.id,
2354
2593
  title: item.title,
2355
2594
  summary: item.summary || '',
2595
+ when: item.when || '',
2596
+ updated: item.updated || '',
2597
+ reason: item.reason || 'similar_title',
2356
2598
  revision: item.revision || 1,
2357
2599
  scope: other.key === 'global' ? 'global' : 'project',
2358
2600
  }));
2359
2601
  }
2360
2602
 
2361
2603
  function bindSameTopic(projectScope, ref) {
2362
- if (!ref || typeof ref !== 'object' || typeof ref.id !== 'string') return { error: 'sameTopicAs 要写成 {scope, id, revision}' };
2604
+ if (!ref || typeof ref !== 'object') return { error: 'sameTopicAs 要写成 {scope, title}' };
2363
2605
  const source = ref.scope === 'global' ? globalScope() : projectScope;
2364
- const record = loadIndex(source).entries.find((item) => item.id === ref.id);
2365
- if (!record) return { error: `sameTopicAs 指向的记忆不存在:${ref.id}` };
2366
- if (Number(ref.revision) !== Number(record.revision || 1)) {
2367
- return { error: `sameTopicAs 的目标已经改过:${ref.id}` };
2606
+ /** 句柄统一用标题:id 是底层实现,模型手里不该出现;老调用传 id 仍认。 */
2607
+ const byTitle = typeof ref.title === 'string' ? normalizeText(ref.title, 120).toLowerCase() : '';
2608
+ const record = loadIndex(source).entries.find((item) => (typeof ref.id === 'string' && item.id === ref.id)
2609
+ || (byTitle && [item.title, ...(item.aliases || [])].some((name) => normalizeText(name, 120).toLowerCase() === byTitle)));
2610
+ if (!record) return { error: `sameTopicAs 指向的记忆不存在:${ref.title || ref.id || ''}` };
2611
+ if (ref.revision !== undefined && Number(ref.revision) !== Number(record.revision || 1)) {
2612
+ return { error: `sameTopicAs 的目标已经改过:「${record.title}」` };
2368
2613
  }
2369
2614
  return { topicKey: view.topicSlot(record) };
2370
2615
  }
2371
2616
 
2617
+
2618
+ /** 冲突队列:只记待办,不预写记忆;最多 20 条。 */
2619
+ const CONFLICT_LIMIT = 20;
2620
+
2621
+ function queueConflictLocked(scope, draft, candidates) {
2622
+ const index = loadIndex(scope);
2623
+ const title = normalizeText(draft && draft.title, 120);
2624
+ if (!title) return Array.isArray(index.conflicts) ? index.conflicts.length : 0;
2625
+ const scopeKey = scope.key === 'global' ? 'global' : 'project';
2626
+ const now = new Date().toISOString();
2627
+ const rows = Array.isArray(index.conflicts) ? index.conflicts : [];
2628
+ const view = candidates.slice(0, 2).map((item) => ({
2629
+ id: item.id, title: item.title || '', when: item.when || '', reason: item.reason || 'similar_title',
2630
+ }));
2631
+ const hit = rows.find((row) => row.title === title && row.scope === scopeKey);
2632
+ if (hit) {
2633
+ hit.candidates = view;
2634
+ hit.updatedAt = now;
2635
+ } else {
2636
+ rows.push({ id: `cf_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`, title, scope: scopeKey, candidates: view, createdAt: now, updatedAt: now });
2637
+ }
2638
+ index.conflicts = rows.slice(-CONFLICT_LIMIT);
2639
+ saveIndex(scope, index);
2640
+ return index.conflicts.length;
2641
+ }
2642
+
2643
+ function clearConflictLocked(scope, title) {
2644
+ const index = loadIndex(scope);
2645
+ const rows = Array.isArray(index.conflicts) ? index.conflicts : [];
2646
+ const norm = normalizeText(title, 120);
2647
+ const next = rows.filter((row) => !(row.title === norm && row.scope === (scope.key === 'global' ? 'global' : 'project')));
2648
+ if (next.length === rows.length) return rows.length;
2649
+ index.conflicts = next;
2650
+ saveIndex(scope, index);
2651
+ return next.length;
2652
+ }
2653
+
2654
+ /** 当前层还有几条没处理的冲突。 */
2655
+ export function pendingConflictsOf(scope) {
2656
+ const rows = loadIndex(scope).conflicts;
2657
+ return Array.isArray(rows) ? rows.length : 0;
2658
+ }
2659
+
2372
2660
  /**
2373
2661
  * 保存的唯一入口:先路由,再在两层里查重(完全相同 → existing_ref;只是相近 → 只给候选),
2374
2662
  * 最后才写。写之前判不出来的范围返回 needsScope,不替用户选。
2375
2663
  */
2664
+ function saveTargetOf(projectScope, source) {
2665
+ /** 更新已有条目:先按 id 认出它到底在哪一层,只锁那一层。 */
2666
+ const id = source && typeof source.id === 'string' ? source.id.trim() : '';
2667
+ if (id) {
2668
+ const found = locateInLayers(projectScope, id);
2669
+ if (found) return found.scope;
2670
+ }
2671
+ /** 新增条目:归属由路由定,写全局就不该占住项目锁,反之亦然。 */
2672
+ const routed = routeScope(source, { project: projectScope });
2673
+ return routed.chosenScope === 'global' ? globalScope() : projectScope;
2674
+ }
2675
+
2376
2676
  export function saveEntry(projectScope, source) {
2377
- return withLocks([projectScope, globalScope()], () => {
2677
+ /** 只锁真正要写的那一层:别的项目写它自己的条目时,不会因为共享全局锁互相等待。 */
2678
+ return withLocks([saveTargetOf(projectScope, source)], () => {
2378
2679
  const control = loadIndex(projectScope).__control || state.controlOf(null);
2379
2680
  if (!control.enabled) {
2380
2681
  return { needsScope: true, chosenScope: '', reasonCode: 'memory_disabled', requiredConditions: [], needsResolution: false, similarList: [], draft: null };
@@ -2396,7 +2697,23 @@ export function saveEntry(projectScope, source) {
2396
2697
  if (next.id) {
2397
2698
  const found = locateInLayers(projectScope, next.id);
2398
2699
  if (!found) throw new Error('没有这条记忆,改用新增');
2700
+ /** 写了 scope 就只改那一层:同名条目在别层时不能悄悄改到别处。 */
2701
+ const explicitScope = String(source.scope || '').trim();
2702
+ if (explicitScope === 'global' && found.scope.key !== 'global') {
2703
+ throw new Error('这条记忆在项目层;scope="global" 只改全局同名条目');
2704
+ }
2705
+ if (explicitScope === 'project' && found.scope.key === 'global') {
2706
+ throw new Error('这条记忆在全局层;只有明确说「所有项目都这样」才改它');
2707
+ }
2399
2708
  targetScope = found.scope;
2709
+ /** 面板拿旧版本改正文:中间被别处改过就拒绝,不覆盖别人的修改。 */
2710
+ if (source.expectedRevision !== undefined) {
2711
+ const current = loadIndex(targetScope).entries.find((item) => item.id === next.id && item.status !== 'archived');
2712
+ if (!current) throw new Error('没有这条记忆,改用新增');
2713
+ if (!Number.isInteger(Number(source.expectedRevision)) || Number(current.revision) !== Number(source.expectedRevision)) {
2714
+ throw new state.StateError('conflict', '这条记忆已经被更新过,刷新后再改');
2715
+ }
2716
+ }
2400
2717
  }
2401
2718
  /** 更新已有项目条目也必须落在确认过的根目录里。 */
2402
2719
  if (targetScope.key !== 'global' && targetScope.confirmed === false) {
@@ -2412,19 +2729,29 @@ export function saveEntry(projectScope, source) {
2412
2729
  }
2413
2730
  const near = crossLayerSimilar(projectScope, targetScope, draft);
2414
2731
  if (near.length) {
2732
+ const pendingConflicts = queueConflictLocked(targetScope, draft, near);
2415
2733
  return {
2416
2734
  needsResolution: true,
2417
2735
  crossLayer: true,
2736
+ /** 相近那条在哪一层:跨层「用同一个标题再存一次」得落在它那层,否则会另建一条。 */
2737
+ crossScope: near[0] && near[0].scope === 'global' ? 'global' : 'project',
2418
2738
  similarList: near,
2419
2739
  similar: near[0],
2420
2740
  draft,
2421
2741
  needsScope: false,
2742
+ pendingConflicts,
2422
2743
  chosenScope: targetScope.key === 'global' ? 'global' : 'project',
2423
2744
  };
2424
2745
  }
2425
2746
  }
2426
2747
  const foreign = targetScope.key === 'global' ? [] : loadIndex(globalScope()).entries;
2427
2748
  const result = upsertLocked(targetScope, next, { foreignEntries: foreign });
2749
+ if (!result.needsResolution) clearConflictLocked(targetScope, draft ? draft.title : next.title);
2750
+ if (result.needsResolution) {
2751
+ const rows = Array.isArray(result.similarList) ? result.similarList : [];
2752
+ const pendingConflicts = queueConflictLocked(targetScope, result.draft || draft, rows);
2753
+ return { ...result, chosenScope: targetScope.key === 'global' ? 'global' : 'project', needsScope: false, reasonCode: routed.reasonCode, pendingConflicts };
2754
+ }
2428
2755
  return { ...result, chosenScope: targetScope.key === 'global' ? 'global' : 'project', needsScope: false, reasonCode: routed.reasonCode };
2429
2756
  });
2430
2757
  }
@@ -2760,6 +3087,7 @@ function learningRuleView(record) {
2760
3087
  conditions: Array.isArray(record.conditions) ? record.conditions : [],
2761
3088
  topicKey: record.topicKey || '',
2762
3089
  origin: record.origin,
3090
+ reviewState: record.reviewState === 'legacy_unreviewed' ? 'legacy_unreviewed' : 'active',
2763
3091
  state: record.learning?.state || 'pending',
2764
3092
  samples: (Array.isArray(record.learning?.samples) ? record.learning.samples : []).slice(-4).map((row) => ({
2765
3093
  dimension: row.dimension || '作品',