dsh-lite-memory 0.18.1 → 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.
package/learning.js CHANGED
@@ -40,6 +40,11 @@ const NEGATIVE = /不要|不用|别用|别再|停用|取消这条|这条不要|
40
40
  const CORRECTION = /改成|改为|换成|重做|重来|再来|下次|不对|错了|不是这样|又没|怎么又|还是没|没按|不好|不行|不满意|不要|别/;
41
41
  const EXECUTION_MISS = /之前说过|已经说过|不是说好|怎么又|又没|还是没|没按|又忘|重复/;
42
42
  const UNCLEAR = /不太对|再想想|感觉不对|说不上|有点怪|不对劲/;
43
+ /**
44
+ * 点名项目范围才算全项目授权;「以后都」不等于「所有项目」。
45
+ * 「所有口播」「一律」这种覆盖全部对象的说法也算,但只限当前项目层。
46
+ */
47
+ const PROJECT_WIDE = /(这个项目|本项目|所有项目|全项目|整个项目|全库|每个项目|所有(?!项目)|一律)/;
43
48
 
44
49
  const has = (pattern, text) => pattern.test(text);
45
50
 
@@ -182,6 +187,8 @@ export function candidateFrom({ messages, anchor, sessionId, projectId, now } =
182
187
  if (!context.projectId) return null;
183
188
  /** 没有作品绑定时,规范纠正同样按项目候选收集;单次局部改动交给整理器判空,不再用电平词一刀切。 */
184
189
  if (cue.type !== 'spec_change') return null;
190
+ /** 作用域在收集时就固定:只有原句点名项目范围才给全项目,否则先待确认。 */
191
+ const applicability = PROJECT_WIDE.test(text) ? 'all_in_scope' : 'unresolved';
185
192
  return {
186
193
  projectId: context.projectId,
187
194
  sessionId: context.sessionId,
@@ -195,7 +202,7 @@ export function candidateFrom({ messages, anchor, sessionId, projectId, now } =
195
202
  text,
196
203
  sourceExcerpt,
197
204
  cue,
198
- expectedTarget: { scope: 'project', projectId, applicability: 'unresolved' },
205
+ expectedTarget: { scope: 'project', projectId, applicability },
199
206
  episodeUpdatedAt: '',
200
207
  at,
201
208
  };
@@ -356,13 +363,36 @@ export function pendingChangeFrom(feedback = {}) {
356
363
  if (!currentText) return null;
357
364
  const first = (lines[0] || currentText).split(/[,。!?;]/)[0].trim();
358
365
  const topicKey = String(feedback.cue?.dimension || first || currentText).slice(0, 60);
366
+ /**
367
+ * 兜底不是授权:只在文本自己说到全项目时才按全项目收,否则保留它原本的适用范围
368
+ * (有任务种类就挂条件,没有就待确认),不能让一句「以后开头先给结论」变成全库规则。
369
+ */
370
+ const expected = feedback.expectedTarget && typeof feedback.expectedTarget === 'object' ? feedback.expectedTarget : null;
371
+ const taskKind = String(feedback.cue?.taskKind || expected?.taskKind || '').trim();
372
+ const expectedConditions = ['taskKind', 'seriesId', 'phase']
373
+ .filter((key) => String(expected?.[key] || '').trim())
374
+ .map((key) => ({ key, value: String(expected[key]).trim() }));
375
+ /**
376
+ * 兜底不重新推导作用域:候选收集时已经固定 expectedTarget 就照抄(只能沿用或收窄),
377
+ * 没有 expectedTarget 的旧调用才退回原句判断。
378
+ */
379
+ const applicability = expected
380
+ ? (expected.applicability === 'all_in_scope'
381
+ ? 'all_in_scope'
382
+ : expectedConditions.length ? 'conditional' : 'unresolved')
383
+ : /(所有|全都|一律|任何|默认|整个项目|这个项目|本项目)/.test(currentText)
384
+ ? 'all_in_scope'
385
+ : taskKind ? 'conditional' : 'unresolved';
386
+ const conditions = expected
387
+ ? (applicability === 'conditional' ? expectedConditions : [])
388
+ : applicability === 'conditional' ? [{ key: 'taskKind', value: taskKind }] : [];
359
389
  return {
360
390
  target: 'new',
361
391
  feedbackType: 'spec_change',
362
392
  topicKey,
363
393
  currentText,
364
- applicability: 'all_in_scope',
365
- conditions: [],
394
+ applicability,
395
+ conditions,
366
396
  evidence: ['u0'],
367
397
  persistence: 'project_candidate',
368
398
  /** 摘录从候选一路带到提交路径,模型返回空时也不丢来源。 */
@@ -504,7 +534,7 @@ export function buildCallInput({ projectId, episodeId, taskKind, seriesId, phase
504
534
  return { system, prompt, messages: [{ role: 'user', content: prompt }], refs, rules: inputRules, bytes: Buffer.byteLength(system) + Buffer.byteLength(prompt), sourceExcerpt, messageIds };
505
535
  }
506
536
  /** 严格解析:截断、工具调用、额外字段、超限都整批拒绝,不做第二次修复调用。 */
507
- export function parseOutput(text, { finish, refs, rules, ranges } = {}) {
537
+ export function parseOutput(text, { finish, refs, rules, ranges, expectedTarget } = {}) {
508
538
  if (finish && finish.kind !== 'stop' && finish.kind !== 'completed') return { ok: false, reason: '整理没有正常结束' };
509
539
  const raw = String(text || '').trim();
510
540
  if (!raw) return { ok: false, reason: '整理没有返回内容' };
@@ -522,14 +552,14 @@ export function parseOutput(text, { finish, refs, rules, ranges } = {}) {
522
552
  if (data.changes.length > LIMITS.maxChanges) return { ok: false, reason: '一次改动太多' };
523
553
  const changes = [];
524
554
  for (const change of data.changes) {
525
- const checked = validateChange(change, { refs, rules, ranges });
555
+ const checked = validateChange(change, { refs, rules, ranges, expectedTarget });
526
556
  if (!checked.ok) return checked;
527
557
  changes.push(checked.change);
528
558
  }
529
559
  return { ok: true, changes };
530
560
  }
531
561
 
532
- export function validateChange(change, { refs, rules, ranges } = {}) {
562
+ export function validateChange(change, { refs, rules, ranges, expectedTarget } = {}) {
533
563
  if (!change || typeof change !== 'object' || Array.isArray(change)) return { ok: false, reason: '变化不是对象' };
534
564
  for (const key of Object.keys(change)) if (!ALLOWED_KEYS.has(key)) return { ok: false, reason: `变化里有未知字段:${key}` };
535
565
  const byRef = new Map((Array.isArray(rules) ? rules : []).map((row) => [row.ref, row]));
@@ -590,6 +620,35 @@ export function validateChange(change, { refs, rules, ranges } = {}) {
590
620
  if (!allowed) return { ok: false, reason: '条件值不在本项目的栏目或阶段里' };
591
621
  }
592
622
  }
623
+ /** 收集时固定的作用域只能沿用或收窄:整理器不能把待确认候选抬成全项目规则。 */
624
+ const expected = expectedTarget && typeof expectedTarget === 'object' ? expectedTarget : null;
625
+ const expectedConditions = ['taskKind', 'seriesId', 'phase']
626
+ .filter((key) => String(expected?.[key] || '').trim())
627
+ .map((key) => ({ key, value: String(expected[key]).trim() }));
628
+ const expectedApplicability = expected
629
+ ? (expected.applicability === 'all_in_scope'
630
+ ? 'all_in_scope'
631
+ : expectedConditions.length ? 'conditional' : 'unresolved')
632
+ : '';
633
+ if (expectedApplicability && applicability === 'all_in_scope' && expectedApplicability !== 'all_in_scope') {
634
+ return { ok: false, reason: '适用范围不能从待确认抬成全项目' };
635
+ }
636
+ if (expected && expected.scope === 'episode' && applicability === 'all_in_scope') {
637
+ return { ok: false, reason: '作品规则不能抬成全项目' };
638
+ }
639
+ const effectiveConditions = conditions !== undefined ? conditions : normalizeConditions(existing?.conditions);
640
+ if (applicability === 'all_in_scope' && effectiveConditions.length) {
641
+ return { ok: false, reason: '全项目规则不能带条件' };
642
+ }
643
+ if (applicability === 'conditional' && !conditions && !existing && !sampleOnly) {
644
+ return { ok: false, reason: '条件适用必须给出条件' };
645
+ }
646
+ if (conditions && expectedConditions.length) {
647
+ const has = new Set(conditions.map((row) => `${row.key}=${row.value}`));
648
+ if (expectedConditions.some((row) => !has.has(`${row.key}=${row.value}`))) {
649
+ return { ok: false, reason: '条件不能删掉收集时的任务范围' };
650
+ }
651
+ }
593
652
  const evidence = Array.isArray(change.evidence) ? change.evidence.map((value) => String(value)) : [];
594
653
  if (!evidence.length || evidence.length > LIMITS.evidence) return { ok: false, reason: '证据数量不对' };
595
654
  for (const ref of evidence) if (!refs?.feedback?.[ref]) return { ok: false, reason: '证据不在本次反馈里' };
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "dsh-lite-memory",
3
- "version": "0.18.1",
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.",
3
+ "version": "0.18.19",
4
+ "description": "On-demand memory for the DeepSeek Harness: nothing resident, at most three small cards on a real hit, bodies read on demand.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "engines": {
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 && node test/safety.test.mjs && node test/ha.test.mjs && node test/record.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/layer-read.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 && node test/eval-seed.test.mjs",
12
12
  "bench": "node bench/bench.mjs",
13
13
  "bench:evolution": "node bench/evolution.mjs"
14
14
  },
package/state.js CHANGED
@@ -106,12 +106,13 @@ export function evolutionOf(state) {
106
106
  export function controlOf(state) {
107
107
  const raw = state && state.control;
108
108
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
109
- return { enabled: true, epoch: '', changedAt: '', compatible: true };
109
+ return { enabled: true, epoch: '', changedAt: '', recoveryPending: false, compatible: true };
110
110
  }
111
111
  return {
112
- enabled: raw.enabled !== false,
112
+ enabled: raw.enabled !== false && raw.recoveryPending !== true,
113
113
  epoch: typeof raw.epoch === 'string' ? raw.epoch : '',
114
114
  changedAt: typeof raw.changedAt === 'string' ? raw.changedAt : '',
115
+ recoveryPending: raw.recoveryPending === true,
115
116
  compatible: true,
116
117
  };
117
118
  }
@@ -143,13 +144,39 @@ export function applyControl(draft, enabled) {
143
144
  return { changed: true, enabled: next, epoch: config.epoch };
144
145
  }
145
146
 
147
+ /**
148
+ * 旧策略生成的实验规则没记下作用域指纹,无法证明它该在哪些任务里出现。
149
+ * 只把它们转成待复核:正文、来源、版本原样保留,不删除也不改写内容。
150
+ */
151
+ function quarantineLegacyRules(draft, now) {
152
+ let touched = 0;
153
+ for (const entry of Array.isArray(draft.entries) ? draft.entries : []) {
154
+ if (!entry || entry.origin !== 'project_evolution') continue;
155
+ const maintenance = entry.maintenance || (entry.maintenance = {});
156
+ if (maintenance.reviewState === 'legacy_unreviewed') continue;
157
+ if (maintenance.learning && maintenance.learning.specDigest) continue;
158
+ maintenance.reviewState = 'legacy_unreviewed';
159
+ maintenance.legacyReview = { reason: 'scope_unverified', at: now };
160
+ const learning = maintenance.learning;
161
+ if (learning && (learning.state === 'trial' || learning.state === 'active')) {
162
+ learning.state = 'paused';
163
+ learning.blockedReason = 'needs_scope_review';
164
+ learning.pausedAt = now;
165
+ learning.pausedBy = 'legacy_scope';
166
+ }
167
+ touched += 1;
168
+ }
169
+ return touched;
170
+ }
171
+
146
172
  /** 自进化开关只停运行层:规则、候选、试用、证据和额度都保留,恢复时接着用。 */
147
173
  export function applyEvolution(draft, enabled) {
148
174
  const now = new Date().toISOString();
149
175
  const current = evolutionOf(draft);
150
176
  if (!current.compatible) throw new StateError('invalid', '实验配置版本不认,先不要改动开关');
151
177
  const next = Boolean(enabled);
152
- if (current.enabled === next) return { changed: false, enabled: next };
178
+ const quarantined = next ? quarantineLegacyRules(draft, now) : 0;
179
+ if (current.enabled === next) return { changed: quarantined > 0, enabled: next, quarantined };
153
180
  const config = {
154
181
  enabled: next,
155
182
  epoch: crypto.randomUUID(),
@@ -174,7 +201,7 @@ export function applyEvolution(draft, enabled) {
174
201
  }
175
202
  }
176
203
  }
177
- return { changed: true, enabled: next, epoch: config.epoch };
204
+ return { changed: true, enabled: next, epoch: config.epoch, quarantined };
178
205
  }
179
206
 
180
207
  /** 容量与预算都归这里管:写入路径只报「要写什么」,能不能落地由 state 决定。 */
@@ -1142,6 +1169,20 @@ export function migrateLegacy(scope, options = {}) {
1142
1169
  });
1143
1170
  }
1144
1171
 
1172
+ /** 恢复出来的状态把总开关留在关闭态并记下原因,避免用一份旧快照静默恢复授权。 */
1173
+ function withRecoveryMarker(payload) {
1174
+ const raw = payload && payload.control && typeof payload.control === 'object' && !Array.isArray(payload.control) ? payload.control : {};
1175
+ return {
1176
+ ...payload,
1177
+ control: {
1178
+ enabled: false,
1179
+ epoch: typeof raw.epoch === 'string' ? raw.epoch : '',
1180
+ changedAt: new Date().toISOString(),
1181
+ recoveryPending: true,
1182
+ },
1183
+ };
1184
+ }
1185
+
1145
1186
  /**
1146
1187
  * 读当前状态:只有「完全没有」才算新库;坏文件、未知版本、迁移痕迹都报错,
1147
1188
  * 绝不用空库覆盖。
@@ -1173,7 +1214,7 @@ export function load(scope, options = {}) {
1173
1214
  }
1174
1215
  if (recovered) {
1175
1216
  return {
1176
- state: recovered,
1217
+ state: withRecoveryMarker(recovered),
1177
1218
  exists: true,
1178
1219
  recovered: true,
1179
1220
  recovery: {
@@ -1204,7 +1245,7 @@ export function load(scope, options = {}) {
1204
1245
  const prev = readEnvelope(prevFile(scope));
1205
1246
  if (prev) {
1206
1247
  // current 丢了但 prev 完好:恢复 prev,并标记需要重写 current。
1207
- return { state: prev, exists: false, recovered: true };
1248
+ return { state: withRecoveryMarker(prev), exists: false, recovered: true };
1208
1249
  }
1209
1250
  return { state: emptyState(scope), exists: false, recovered: false };
1210
1251
  }
package/store.js CHANGED
@@ -1011,6 +1011,24 @@ function applyDraft(scope, index, source, now, options = {}) {
1011
1011
  if (draft.id && !record) throw new Error('没有这条记忆,改用新增');
1012
1012
  let mergedFrom = null;
1013
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
+ }
1014
1032
  if (!record) {
1015
1033
  /** distinct 是用户明确说的「这是另一条」:同名也另起一条,不被规范化后的精确同名吞掉。 */
1016
1034
  const hit = distinct ? null : resolveTarget(live, draft);
@@ -1045,6 +1063,13 @@ function applyDraft(scope, index, source, now, options = {}) {
1045
1063
  }
1046
1064
  }
1047
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
+ }
1048
1073
  if (record && writeMode === 'replace_current') {
1049
1074
  for (const key of ['title', 'summary', 'when', 'body', 'kind', 'tags']) {
1050
1075
  if (!draft.has[key]) throw new Error(`整份替换要给出 ${key},空值也要显式写`);
@@ -1553,8 +1578,10 @@ export function peek(scope, query, limit = 3, options = {}) {
1553
1578
  }
1554
1579
 
1555
1580
  export function readEntry(scope, id, options = {}) {
1556
- const found = withLocks([scope, globalScope()], () => {
1557
- 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) {
1558
1585
  const index = loadIndex(source);
1559
1586
  const record = index.entries.find((item) => item.id === id);
1560
1587
  const archived = record ? null : index.archived.find((item) => item.id === id);
@@ -1575,8 +1602,9 @@ export function readEntry(scope, id, options = {}) {
1575
1602
  return null;
1576
1603
  });
1577
1604
  if (!found) return null;
1578
- /** 读过才算用过;归档的不回写(看病史不等于「在用」)。 */
1579
- 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]);
1580
1608
  return found.record;
1581
1609
  }
1582
1610
 
@@ -1587,11 +1615,14 @@ export function readEntry(scope, id, options = {}) {
1587
1615
  export function forget(scope, id, options = {}) {
1588
1616
  const mode = options.mode;
1589
1617
  if (mode !== 'archive' && mode !== 'purge') return { id, mode: 'needs_resolution' };
1590
- 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, () => {
1591
1622
  /** 屏障优先:上次彻底删除没做完时,先按原层 + 原 operationId 收尾,再决定这条在哪。 */
1592
1623
  let replayed = null;
1593
1624
  if (mode === 'purge') {
1594
- for (const source of [scope, globalScope()]) {
1625
+ for (const source of scopeList) {
1595
1626
  const pending = state.purgeIntent(source);
1596
1627
  if (!pending) continue;
1597
1628
  /** 冷存里有待删条目的正文副本:先隔离,state.purge 的残留扫描才收得干净。 */
@@ -1612,7 +1643,7 @@ export function forget(scope, id, options = {}) {
1612
1643
  }
1613
1644
  if (replayed) return replayed;
1614
1645
  }
1615
- for (const source of [scope, globalScope()]) {
1646
+ for (const source of scopeList) {
1616
1647
  const index = loadIndex(source);
1617
1648
  const active = index.entries.find((item) => item.id === id);
1618
1649
  const archived = active ? null : index.archived.find((item) => item.id === id);
@@ -1664,8 +1695,8 @@ export function forget(scope, id, options = {}) {
1664
1695
 
1665
1696
  /** 直接读邻居:只给当前投影,不缓存目标正文;目标不在了就标 invalid,不拿旧版本顶上。 */
1666
1697
  export function readLinks(scope, id, options = {}) {
1667
- return withLocks([scope, globalScope()], () => {
1668
- for (const source of [scope, globalScope()]) {
1698
+ return withLocks(runtimeLayers(scope), () => {
1699
+ for (const source of runtimeLayers(scope)) {
1669
1700
  const index = loadIndex(source);
1670
1701
  const record = index.entries.find((item) => item.id === id);
1671
1702
  if (!record) continue;
@@ -1684,7 +1715,7 @@ export function readLinks(scope, id, options = {}) {
1684
1715
 
1685
1716
  /** 关联目标按当前投影现场解引用:先项目层再全局层,归档和删除都算不存在。 */
1686
1717
  function findCurrent(id, scope, options = {}) {
1687
- for (const source of [scope, globalScope()]) {
1718
+ for (const source of runtimeLayers(scope)) {
1688
1719
  let index;
1689
1720
  try {
1690
1721
  index = loadIndex(source);
@@ -1823,9 +1854,51 @@ export function findAnywhere(id) {
1823
1854
  return null;
1824
1855
  }
1825
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
+
1826
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
+
1827
1892
  export function locate(scope, id, options = {}) {
1828
- 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) {
1829
1902
  const index = loadIndex(source, options);
1830
1903
  const active = index.entries.find((item) => item.id === id);
1831
1904
  if (active) return { scope: source, record: active, archived: false };
@@ -1835,6 +1908,35 @@ export function locate(scope, id, options = {}) {
1835
1908
  return null;
1836
1909
  }
1837
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
+
1838
1940
  function forgottenFingerprints(index) {
1839
1941
  const out = new Set();
1840
1942
  for (const row of index.suppressions || []) {
@@ -2114,7 +2216,7 @@ export function renderIndex(entries, budget = 400) {
2114
2216
  const stateTag = item.state === 'archived' ? ' · 已归档' : item.state === 'needs_context' ? ' · 适用范围待确认' : item.state === 'not_applicable' ? ' · 当前条件不适用' : '';
2115
2217
  const originTag = item.origin === 'project_evolution' ? ' · 实验' : '';
2116
2218
  const tag = item.scope === 'global' ? '[global]' : item.scopeName ? `[${item.scopeName}]` : '';
2117
- 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}`;
2118
2220
  const line = stale ? `- ${head}` : `- ${head}|${item.summary}`;
2119
2221
  if (used + line.length > budget) break;
2120
2222
  lines.push(line);
@@ -2288,6 +2390,30 @@ export function readCitationBody(sessionId, id) {
2288
2390
  const data = readSessionFile(sessionId);
2289
2391
  const row = ((data && data.items) || []).find((item) => item && item.id === id);
2290
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;
2291
2417
  const found = resolveCitation(row);
2292
2418
  if (!found) return null;
2293
2419
  let body;
@@ -2300,6 +2426,7 @@ export function readCitationBody(sessionId, id) {
2300
2426
  }
2301
2427
  return {
2302
2428
  ...found.row,
2429
+ status: found.entry.status || 'active',
2303
2430
  scopeLabel: found.scope.key === 'global' ? '全局' : path.basename(found.scope.cwd || '') || found.scope.key,
2304
2431
  body: body.slice(0, LIMITS.entryBytes),
2305
2432
  };
@@ -2474,12 +2601,15 @@ function crossLayerSimilar(projectScope, targetScope, draft) {
2474
2601
  }
2475
2602
 
2476
2603
  function bindSameTopic(projectScope, ref) {
2477
- 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}' };
2478
2605
  const source = ref.scope === 'global' ? globalScope() : projectScope;
2479
- const record = loadIndex(source).entries.find((item) => item.id === ref.id);
2480
- if (!record) return { error: `sameTopicAs 指向的记忆不存在:${ref.id}` };
2481
- if (Number(ref.revision) !== Number(record.revision || 1)) {
2482
- 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}」` };
2483
2613
  }
2484
2614
  return { topicKey: view.topicSlot(record) };
2485
2615
  }
@@ -2531,8 +2661,21 @@ export function pendingConflictsOf(scope) {
2531
2661
  * 保存的唯一入口:先路由,再在两层里查重(完全相同 → existing_ref;只是相近 → 只给候选),
2532
2662
  * 最后才写。写之前判不出来的范围返回 needsScope,不替用户选。
2533
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
+
2534
2676
  export function saveEntry(projectScope, source) {
2535
- return withLocks([projectScope, globalScope()], () => {
2677
+ /** 只锁真正要写的那一层:别的项目写它自己的条目时,不会因为共享全局锁互相等待。 */
2678
+ return withLocks([saveTargetOf(projectScope, source)], () => {
2536
2679
  const control = loadIndex(projectScope).__control || state.controlOf(null);
2537
2680
  if (!control.enabled) {
2538
2681
  return { needsScope: true, chosenScope: '', reasonCode: 'memory_disabled', requiredConditions: [], needsResolution: false, similarList: [], draft: null };
@@ -2554,7 +2697,23 @@ export function saveEntry(projectScope, source) {
2554
2697
  if (next.id) {
2555
2698
  const found = locateInLayers(projectScope, next.id);
2556
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
+ }
2557
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
+ }
2558
2717
  }
2559
2718
  /** 更新已有项目条目也必须落在确认过的根目录里。 */
2560
2719
  if (targetScope.key !== 'global' && targetScope.confirmed === false) {
@@ -2574,6 +2733,8 @@ export function saveEntry(projectScope, source) {
2574
2733
  return {
2575
2734
  needsResolution: true,
2576
2735
  crossLayer: true,
2736
+ /** 相近那条在哪一层:跨层「用同一个标题再存一次」得落在它那层,否则会另建一条。 */
2737
+ crossScope: near[0] && near[0].scope === 'global' ? 'global' : 'project',
2577
2738
  similarList: near,
2578
2739
  similar: near[0],
2579
2740
  draft,
@@ -2926,6 +3087,7 @@ function learningRuleView(record) {
2926
3087
  conditions: Array.isArray(record.conditions) ? record.conditions : [],
2927
3088
  topicKey: record.topicKey || '',
2928
3089
  origin: record.origin,
3090
+ reviewState: record.reviewState === 'legacy_unreviewed' ? 'legacy_unreviewed' : 'active',
2929
3091
  state: record.learning?.state || 'pending',
2930
3092
  samples: (Array.isArray(record.learning?.samples) ? record.learning.samples : []).slice(-4).map((row) => ({
2931
3093
  dimension: row.dimension || '作品',