dsh-lite-memory 0.11.1 → 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/store.js CHANGED
@@ -27,7 +27,6 @@ const SECRET_PATTERNS = [
27
27
  ];
28
28
 
29
29
  /** A summary is a noun phrase. An imperative turns a note into an instruction. */
30
- const IMPERATIVE = /^(?:你要|你得|你必须|请|务必|必须|记得|记住|别忘了|do not|don't|please|you (?:must|should|need))/i;
31
30
 
32
31
  const SUGGESTION_ID = /^sug_[a-z0-9]{6,}$/;
33
32
 
@@ -96,6 +95,7 @@ function toFlat(entry) {
96
95
  revision: Number(entry.revision) || 1,
97
96
  status: entry.status === 'archived' ? 'archived' : 'active',
98
97
  origin: entry.origin || 'user_explicit',
98
+ ownerProjectId: typeof entry.ownerProjectId === 'string' ? entry.ownerProjectId : '',
99
99
  hits: Number(maintenance.hits) || 0,
100
100
  merges: Number(maintenance.merges) || 0,
101
101
  versions: Array.isArray(maintenance.versions) ? cloneJson(maintenance.versions) : [],
@@ -115,6 +115,7 @@ function fromFlat(record, status) {
115
115
  revision: Number(record.revision) || 1,
116
116
  status: status || (record.status === 'archived' ? 'archived' : 'active'),
117
117
  origin: typeof record.origin === 'string' && record.origin ? record.origin : 'user_explicit',
118
+ ownerProjectId: record.ownerProjectId || '',
118
119
  createdAt: record.created || at,
119
120
  updatedAt: at,
120
121
  current: {
@@ -154,7 +155,7 @@ function fromFlat(record, status) {
154
155
  * 由 state.js 拦成报告,这里不猜、不补空正文。
155
156
  */
156
157
  export function loadIndex(scope, options = {}) {
157
- if (!fs.existsSync(path.join(scope.dir, 'state.json'))) {
158
+ if (!options.readOnly && !fs.existsSync(path.join(scope.dir, 'state.json'))) {
158
159
  const report = state.migrationReport(scope);
159
160
  if (report.required || report.adoptable.length) state.migrateLegacy(scope);
160
161
  }
@@ -176,6 +177,7 @@ export function loadIndex(scope, options = {}) {
176
177
  suppressions: JSON.parse(JSON.stringify(loaded.state.suppressions || [])),
177
178
  __evolution: state.evolutionOf(loaded.state),
178
179
  __tasks: anchor.normalizeTasks(state.evolutionOf(loaded.state).tasks),
180
+ __control: state.controlOf(loaded.state),
179
181
  __exists: loaded.exists === true,
180
182
  };
181
183
  index.__meta = {
@@ -274,10 +276,8 @@ function cleanTitle(value) {
274
276
  }
275
277
 
276
278
  export function validSummary(summary) {
277
- const text = String(summary ?? '').replace(/\s+/g, ' ').trim();
279
+ const text = String(summary ?? '').replace(/\s+/g, ' ').trim().slice(0, LIMITS.summaryChars);
278
280
  if (!text) return { ok: false, reason: '摘要为空' };
279
- if (text.length > LIMITS.summaryChars) return { ok: false, reason: `摘要超过 ${LIMITS.summaryChars} 字,先写短些` };
280
- if (IMPERATIVE.test(text)) return { ok: false, reason: '摘要必须写成名词短语,不能写成指令' };
281
281
  return { ok: true, text };
282
282
  }
283
283
 
@@ -297,21 +297,32 @@ export function normalizeDraft(source, options = {}) {
297
297
  const mayOmitTitle = Boolean(id);
298
298
  if (!title && !mayOmitTitle) throw new Error('标题为空');
299
299
  let summary = '';
300
+ let summaryShortened = false;
300
301
  const rawSummary = String(draft.summary ?? '').replace(/\s+/g, ' ').trim();
301
302
  if (rawSummary) {
302
303
  const check = validSummary(rawSummary);
303
304
  if (!check.ok) throw new Error(check.reason);
304
305
  summary = check.text;
306
+ summaryShortened = summary.length < rawSummary.length;
305
307
  } else if (has('summary')) {
306
308
  throw new Error('摘要为空');
307
309
  }
308
310
  /** 更新(带 id)与候选草稿允许省略摘要;省略表示保持原值,不拿标题补。 */
309
311
  const mayOmitSummary = options.requireSummary === false || Boolean(id);
310
312
  if (!summary && !mayOmitSummary) throw new Error('摘要为空');
311
- const body = String(draft.body ?? '').trim();
312
- const bodyBytes = Buffer.byteLength(body);
313
+ /**
314
+ * 正文是否由调用方显式给出,和「摘要太长所以自动补一份」必须分开。
315
+ * 否则同名更新会被自动补出的正文当成显式覆盖,把原正文冲掉。
316
+ */
317
+ const bodyExplicit = has('body');
318
+ const body = bodyExplicit ? String(draft.body ?? '').trim() : '';
319
+ const autoBody = !bodyExplicit && summaryShortened ? rawSummary : '';
320
+ const checked = bodyExplicit ? body : autoBody;
321
+ const bodyBytes = Buffer.byteLength(checked);
313
322
  if (bodyBytes > LIMITS.entryBytes) {
314
- throw new Error(`正文 ${bodyBytes} 字节,超过单条 ${LIMITS.entryBytes} 字节;先精简,别让尾部条件被截掉`);
323
+ throw new Error(autoBody
324
+ ? `摘要 ${rawSummary.length} 字,超过单条 ${LIMITS.entryBytes} 字节;先拆成两条写,别丢后半段`
325
+ : `正文 ${bodyBytes} 字节,超过单条 ${LIMITS.entryBytes} 字节;只留原则、判断规则和触发条件`);
315
326
  }
316
327
  const kind = has('kind') ? textField(draft.kind, 20, '类型') : 'note';
317
328
  if (kind && !KINDS.includes(kind)) throw new Error(`类型只能是 ${KINDS.join(' / ')}`);
@@ -334,6 +345,10 @@ export function normalizeDraft(source, options = {}) {
334
345
  summary,
335
346
  when,
336
347
  body,
348
+ summaryShortened,
349
+ summaryRaw: rawSummary,
350
+ autoBody,
351
+ fullTextInBody: false,
337
352
  kind: kind || 'note',
338
353
  tags,
339
354
  triggers,
@@ -346,7 +361,7 @@ export function normalizeDraft(source, options = {}) {
346
361
  title: has('title') && Boolean(title),
347
362
  summary: has('summary'),
348
363
  when: has('when'),
349
- body: has('body'),
364
+ body: bodyExplicit,
350
365
  kind: has('kind'),
351
366
  tags: has('tags'),
352
367
  triggers: triggers !== undefined,
@@ -434,6 +449,17 @@ function bigrams(text) {
434
449
  return out;
435
450
  }
436
451
 
452
+ /** 只靠这类高频两字词重合不算相关:查询里的实词必须自己命中。 */
453
+ const STOP_GRAMS = new Set([
454
+ '这个', '那个', '怎么', '什么', '可以', '需要', '我们', '你们', '一下', '现在',
455
+ '然后', '还是', '就是', '已经', '没有', '不是', '要是', '如果', '因为', '所以',
456
+ '的话', '这里', '那里', '一个', '一些', '这样', '那样', '看看', '帮我看', '继续',
457
+ ]);
458
+
459
+ const asciiWordsOf = (text) => normalizeText(text, 400).toLowerCase().match(/[a-z][a-z0-9_-]{1,}/g) || [];
460
+
461
+ const wordIn = (pool, word) => new RegExp(`(^|[^a-z0-9])${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^a-z0-9])`).test(pool);
462
+
437
463
  /** 查询按空格/标点切成段:多段按关键词处理,整句仍按相关度处理。 */
438
464
  function queryRuns(query) {
439
465
  return normalizeText(query, 400)
@@ -463,6 +489,13 @@ function matchesRun(item, runs) {
463
489
  let qualified = 0;
464
490
  let substantial = 0;
465
491
  for (const run of runs) {
492
+ /** 英文按整词匹配:git 不能因为共享 gi/it 就命中 digital。 */
493
+ if (/^[a-z][a-z0-9_-]+$/.test(run)) {
494
+ if (!wordIn(pool, run)) continue;
495
+ qualified += 1;
496
+ substantial += 1;
497
+ continue;
498
+ }
466
499
  const grams = bigrams(run);
467
500
  if (grams.size === 0) continue;
468
501
  let hit = 0;
@@ -618,6 +651,14 @@ function rulesOf(draft, body, index, foreign) {
618
651
  return rules;
619
652
  }
620
653
 
654
+ /** 长摘要的尾部不能丢:正文里没有这段时补在末尾,已有则不动。 */
655
+ function bodyWithSummaryTail(body, draft) {
656
+ const raw = String(draft.summaryRaw || '');
657
+ const current = String(body || '');
658
+ if (!raw || !draft.summaryShortened || current.includes(raw)) return { body: current, added: false };
659
+ return { body: current ? `${current}\n\n摘要原文:${raw}` : raw, added: true };
660
+ }
661
+
621
662
  function mergedFields(draft, record, oldBody, index, writeMode = 'patch', foreign) {
622
663
  if (writeMode === 'replace_current') {
623
664
  const next = {
@@ -631,8 +672,9 @@ function mergedFields(draft, record, oldBody, index, writeMode = 'patch', foreig
631
672
  selection: draft.has.selection ? draft.selection : null,
632
673
  links: draft.has.links ? draft.links : [],
633
674
  topicKey: draft.has.topicKey ? draft.topicKey : '',
634
- body: draft.body,
675
+ body: bodyWithSummaryTail(draft.body, draft).body,
635
676
  };
677
+ if (next.body !== draft.body) draft.fullTextInBody = true;
636
678
  next.applicability = view.validateApplicability(draft.has.applicability ? draft.applicability : undefined,
637
679
  next.conditions, next.selection, next.when);
638
680
  assertLinksVisible(next.links, index, foreign);
@@ -710,6 +752,9 @@ function applyDraft(scope, index, source, now, options = {}) {
710
752
  if (record) {
711
753
  const oldBody = readBody(scope, record.id);
712
754
  const next = mergedFields(draft, record, oldBody, index, writeMode, foreign);
755
+ const tail = bodyWithSummaryTail(next.body, draft);
756
+ next.body = tail.body;
757
+ if (tail.added) draft.fullTextInBody = true;
713
758
  const noop = next.title === record.title && next.summary === record.summary
714
759
  && next.when === (record.when || '') && next.kind === (record.kind || 'note')
715
760
  && JSON.stringify(next.tags) === JSON.stringify(record.tags || [])
@@ -744,7 +789,11 @@ function applyDraft(scope, index, source, now, options = {}) {
744
789
  return { record, merged: true, mergedFrom, similar: null, similarList, draft, noop: false, previousBody: oldBody, archived };
745
790
  }
746
791
  if (writeMode === 'replace_current') throw new Error('整份替换要先指明是哪条,不能用来新增');
747
- const rules = rulesOf(draft, draft.body, index, foreign);
792
+ const seed = draft.autoBody || draft.body;
793
+ const tail = bodyWithSummaryTail(seed, draft);
794
+ const createdBody = tail.body;
795
+ if (tail.added || draft.autoBody) draft.fullTextInBody = true;
796
+ const rules = rulesOf(draft, createdBody, index, foreign);
748
797
  const created = {
749
798
  id: newId(),
750
799
  title: draft.title,
@@ -758,7 +807,7 @@ function applyDraft(scope, index, source, now, options = {}) {
758
807
  selection: rules.selection,
759
808
  links: rules.links,
760
809
  topicKey: rules.topicKey,
761
- body: draft.body,
810
+ body: createdBody,
762
811
  aliases: [],
763
812
  created: now,
764
813
  updated: now,
@@ -784,8 +833,9 @@ function applyDraft(scope, index, source, now, options = {}) {
784
833
  * 结果经 index.__archived 回填,调用方在工具结果里点名。
785
834
  */
786
835
  function assertFits(scope, index, record) {
787
- if (record.bytes > LIMITS.entryBytes) {
788
- throw new Error(`这一条 ${record.bytes} 字节,超过单条 ${LIMITS.entryBytes} 字节;先精简正文`);
836
+ const bodyBytes = Buffer.byteLength(record.body || '');
837
+ if (bodyBytes > LIMITS.entryBytes) {
838
+ throw new Error(`正文 ${bodyBytes} 字节,超过单条 ${LIMITS.entryBytes} 字节;先精简正文`);
789
839
  }
790
840
  const size = recordSize(record);
791
841
  if (size > LIMITS.recordBytes) {
@@ -842,23 +892,66 @@ function scoreQuery(item, grams, raw) {
842
892
  [(item.triggers || []).join(' '), MATCH_WEIGHT.tags],
843
893
  ];
844
894
  let score = 0;
895
+ let substantive = 0;
845
896
  for (const [text, weight] of fields) {
846
897
  if (!text) continue;
847
898
  const pool = bigrams(text);
848
899
  let shared = 0;
849
- for (const gram of grams) if (pool.has(gram)) shared += 1;
900
+ for (const gram of grams) {
901
+ if (!pool.has(gram)) continue;
902
+ shared += 1;
903
+ if (!STOP_GRAMS.has(gram)) substantive += 1;
904
+ }
850
905
  score += shared * weight;
851
906
  }
852
907
  const plain = normalizeText(raw, 200).toLowerCase().replace(/\s+/g, '');
853
908
  if (plain && normalizeText(item.title, 200).toLowerCase().replace(/\s+/g, '').includes(plain)) score += 5;
854
909
  /** 触发词命中按整词算:用户说的那句话里带着它,就该搜得到。 */
855
- if (plain && (item.triggers || []).some((trigger) => {
910
+ const triggerHit = Boolean(plain) && (item.triggers || []).some((trigger) => {
856
911
  const text = normalizeText(trigger, 200).toLowerCase().replace(/\s+/g, '');
857
912
  return Boolean(text) && plain.includes(text);
858
- })) score += 4;
913
+ });
914
+ if (triggerHit) score += 4;
915
+ /** 纯英文查询必须按整词命中,避免 git 命中 digital 这种字面巧合;混合查询仍按中文实词判断。 */
916
+ const words = asciiWordsOf(raw);
917
+ const hasCjk = /[\u4e00-\u9fff]/.test(String(raw || ''));
918
+ if (words.length && !hasCjk) {
919
+ const pool = fields.map(([text]) => fieldText(text)).join('\n');
920
+ if (!words.some((word) => wordIn(pool, word))) return 0;
921
+ }
922
+ /** 只重合了「这个」「怎么」这类词,不算命中;触发词命中仍算。 */
923
+ if (substantive === 0 && !triggerHit && words.length === 0) return 0;
859
924
  return score;
860
925
  }
861
926
 
927
+ /** 项目记忆总开关:读当前层,缺字段按开启。 */
928
+ export function controlStatus(scope) {
929
+ const index = loadIndex(scope);
930
+ return { ...(index.__control || state.controlOf(null)) };
931
+ }
932
+
933
+ export function setMemoryEnabled(scope, enabled, options = {}) {
934
+ return withLock(scope, () => {
935
+ const before = loadIndex(scope);
936
+ const operationId = typeof options.operationId === 'string' ? options.operationId : '';
937
+ let applied = null;
938
+ try {
939
+ state.transact(scope, {
940
+ operationId,
941
+ expectCreate: !before.__exists,
942
+ expected: before.__exists ? { generation: before.generation, epoch: before.epoch } : null,
943
+ }, (draft) => {
944
+ applied = state.applyControl(draft, enabled);
945
+ return applied.changed ? { changed: true } : { changed: false };
946
+ });
947
+ } catch (error) {
948
+ if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
949
+ return { ok: false, reason: error instanceof Error ? error.message : '记忆开关没有改动' };
950
+ }
951
+ return { ok: true, changed: Boolean(applied && applied.changed), ...controlStatus(scope) };
952
+ });
953
+ }
954
+
862
955
  /**
863
956
  * 项目实验开关:权威状态里缺字段即关闭。状态只回可核验事实,不编学习分数。
864
957
  * 关闭时保留已完成成果;试用条目暂停,等新的有效反馈或明确管理动作再复核。
@@ -872,13 +965,26 @@ export function evolutionStatus(scope) {
872
965
  const phase = record.learning && typeof record.learning === 'object' ? record.learning.state : '';
873
966
  if (phase === 'active' || phase === 'trial' || phase === 'paused') counts[phase] += 1;
874
967
  }
875
- return { ...config, counts };
968
+ const control = index.__control || state.controlOf(null);
969
+ return {
970
+ ...config,
971
+ /** 模型可见的 enabled 是有效值:总开关关掉时,自进化一律不运行。 */
972
+ enabled: config.enabled === true && control.enabled === true,
973
+ configuredEnabled: config.enabled === true,
974
+ memoryEnabled: control.enabled === true,
975
+ ownerProject: config.ownerProject || '',
976
+ counts,
977
+ };
876
978
  }
877
979
 
878
980
  export function setEvolution(scope, enabled, options = {}) {
879
981
  return withLock(scope, () => {
880
982
  const before = loadIndex(scope);
881
983
  const config = before.__evolution || {};
984
+ /** 总开关关掉后,自进化开关也不能改:先恢复本项目记忆。 */
985
+ if (!(before.__control || state.controlOf(null)).enabled) {
986
+ return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
987
+ }
882
988
  if (config.compatible === false) {
883
989
  return { ok: false, reason: '实验配置版本不认,先不要改动开关', compatible: false };
884
990
  }
@@ -913,6 +1019,7 @@ export function taskState(scope) {
913
1019
  export function saveTaskState(scope, tasks, options = {}) {
914
1020
  return withLock(scope, () => {
915
1021
  const before = loadIndex(scope);
1022
+ if (!(before.__control || state.controlOf(null)).enabled) return { ok: false, reason: '本项目记忆已关闭' };
916
1023
  if (!before.__evolution.enabled) return { ok: false, reason: '实验还没开启' };
917
1024
  const next = anchor.normalizeTasks(tasks);
918
1025
  const operationId = typeof options.operationId === 'string' ? options.operationId : '';
@@ -964,6 +1071,9 @@ export function search(scope, query, limit = 4, options = {}) {
964
1071
  const collect = (pool, archived) => {
965
1072
  for (const item of pool) {
966
1073
  if (!readableRecord(item, options, index)) continue;
1074
+ /** 已暂停、已过期或待确认的学习规则不进入检索结果,除非明确要病史。 */
1075
+ if (options.allowExperimental === true && options.history !== true
1076
+ && isExperimentalRecord(item) && !view.experimentalUsable(item, options.now)) continue;
967
1077
  const score = scoreQuery(item, grams, query);
968
1078
  if (score <= 0) continue;
969
1079
  if (keywordMode && !matchesRun(item, runs)) continue;
@@ -980,8 +1090,11 @@ export function search(scope, query, limit = 4, options = {}) {
980
1090
  const group = resolved.get(view.topicSlot(item));
981
1091
  return Boolean(group && group.status === 'ok' && group.record && group.record.id !== item.id);
982
1092
  };
983
- const visible = rows.filter((row) => row.archived || !shadowed(row.item));
984
- visible.sort((a, b) => b.score - a.score
1093
+ const visible = rows.filter((row) => row.archived || !shadowed(row.item))
1094
+ .map((row) => ({ ...row, usable: row.archived || view.statusOf(row.item, options.context || {}) === 'ok' }));
1095
+ /** 先按「本轮能不能用」分组再按相关度:高相关但当前不适用的条目不能挤掉后面真正能用的。 */
1096
+ visible.sort((a, b) => Number(b.usable) - Number(a.usable)
1097
+ || b.score - a.score
985
1098
  || Number(a.archived) - Number(b.archived)
986
1099
  || String(b.item.lastUsed).localeCompare(String(a.item.lastUsed)));
987
1100
  const chosen = visible.slice(0, limit);
@@ -1162,13 +1275,16 @@ function findCurrent(id, scope, options = {}) {
1162
1275
  const record = index.entries.find((item) => item.id === id && item.status !== 'archived');
1163
1276
  if (!record) continue;
1164
1277
  if (!readableRecord(record, options, index)) continue;
1278
+ /** 关联目标也按本轮上下文判适用;条件未知、过期或暂停的目标不给标题。 */
1279
+ if (view.statusOf(record, options.context || {}) !== 'ok') continue;
1280
+ if (isExperimentalRecord(record) && !view.experimentalUsable(record, options.now)) continue;
1165
1281
  return {
1166
1282
  id: record.id,
1167
1283
  title: record.title,
1168
1284
  summary: record.summary || '',
1169
1285
  scope: source.key === 'global' ? 'global' : 'project',
1170
1286
  revision: record.revision || 1,
1171
- status: view.statusOf(record, {}),
1287
+ status: 'ok',
1172
1288
  };
1173
1289
  }
1174
1290
  return null;
@@ -1184,7 +1300,7 @@ export function undoChange(scope, id, options = {}) {
1184
1300
  if (!record) continue;
1185
1301
  /** 旧回执不能撤掉别人后来的修改:版本对不上就明确拒绝。 */
1186
1302
  if (options.expectedRevision !== undefined && Number(record.revision) !== Number(options.expectedRevision)) {
1187
- throw new state.StateError('conflict', `这条记忆已经更新到第 ${record.revision} 版`);
1303
+ throw new state.StateError('conflict', '这条记忆已经被更新过,刷新后再改');
1188
1304
  }
1189
1305
  const versions = record.versions || [];
1190
1306
  const last = versions[versions.length - 1];
@@ -1282,9 +1398,9 @@ export function findAnywhere(id) {
1282
1398
  }
1283
1399
 
1284
1400
  /** 按 id 在项目层与全局层里找位置,不靠文案猜作用域;管理动作也要看得见已归档的条目。 */
1285
- export function locate(scope, id) {
1401
+ export function locate(scope, id, options = {}) {
1286
1402
  for (const source of [scope, globalScope()]) {
1287
- const index = loadIndex(source);
1403
+ const index = loadIndex(source, options);
1288
1404
  const active = index.entries.find((item) => item.id === id);
1289
1405
  if (active) return { scope: source, record: active, archived: false };
1290
1406
  const archived = index.archived.find((item) => item.id === id);
@@ -1571,7 +1687,8 @@ export function renderIndex(entries, budget = 400) {
1571
1687
  const stale = now - last > staleMs;
1572
1688
  const stateTag = item.state === 'archived' ? ' · 已归档' : item.state === 'needs_context' ? ' · 适用范围待确认' : item.state === 'not_applicable' ? ' · 当前条件不适用' : '';
1573
1689
  const originTag = item.origin === 'project_evolution' ? ' · 实验' : '';
1574
- const head = `${item.scope === 'global' ? '[全局] ' : ''}${item.id} ${item.title}${item.when ? `(${item.when})` : ''} · 第 ${item.revision || 1} 版${originTag}${stateTag}`;
1690
+ const tag = item.scope === 'global' ? '[global]' : item.scopeName ? `[${item.scopeName}]` : '';
1691
+ const head = `${tag ? `${tag} ` : ''}${item.id} ${item.title}${item.when ? `(${item.when})` : ''}${originTag}${stateTag}`;
1575
1692
  const line = stale ? `- ${head}` : `- ${head}|${item.summary}`;
1576
1693
  if (used + line.length > budget) break;
1577
1694
  lines.push(line);
@@ -1882,7 +1999,7 @@ function bindSameTopic(projectScope, ref) {
1882
1999
  const record = loadIndex(source).entries.find((item) => item.id === ref.id);
1883
2000
  if (!record) return { error: `sameTopicAs 指向的记忆不存在:${ref.id}` };
1884
2001
  if (Number(ref.revision) !== Number(record.revision || 1)) {
1885
- return { error: `sameTopicAs 的版本不对:${ref.id} 当前是第 ${record.revision || 1} 版` };
2002
+ return { error: `sameTopicAs 的目标已经改过:${ref.id}` };
1886
2003
  }
1887
2004
  return { topicKey: view.topicSlot(record) };
1888
2005
  }
@@ -1893,6 +2010,10 @@ function bindSameTopic(projectScope, ref) {
1893
2010
  */
1894
2011
  export function saveEntry(projectScope, source) {
1895
2012
  return withLocks([projectScope, globalScope()], () => {
2013
+ const control = loadIndex(projectScope).__control || state.controlOf(null);
2014
+ if (!control.enabled) {
2015
+ return { needsScope: true, chosenScope: '', reasonCode: 'memory_disabled', requiredConditions: [], needsResolution: false, similarList: [], draft: null };
2016
+ }
1896
2017
  const routed = routeScope(source, { project: projectScope });
1897
2018
  const draft = (() => {
1898
2019
  try { return normalizeDraft({ ...source, title: source && source.title }); } catch { return null; }
@@ -2166,6 +2287,9 @@ function driveMove(projectScope, intent, options = {}) {
2166
2287
  * 协调文件里带项目身份与两侧 epoch:换项目续跑会被拒绝,续跑也不再靠猜。
2167
2288
  */
2168
2289
  export function move(projectScope, id, options = {}) {
2290
+ if (!(loadIndex(projectScope).__control || state.controlOf(null)).enabled) {
2291
+ throw new state.StateError('conflict', '本项目记忆已关闭,先开启再整理');
2292
+ }
2169
2293
  return state.withRootLock(() => {
2170
2294
  const existing = state.readMoveIntent();
2171
2295
  if (existing) {
@@ -2183,6 +2307,10 @@ export function move(projectScope, id, options = {}) {
2183
2307
  const sourceScope = projectRecord ? projectScope : globalScope();
2184
2308
  const sourceIndex = projectRecord ? projectIndex : globalIndex;
2185
2309
  const record = projectRecord || globalRecord;
2310
+ /** 实验记录只能在原项目内用:普通移动不能把它带进全局层。 */
2311
+ if (record.origin === EXPERIMENTAL_ORIGIN && options.target === 'global') {
2312
+ throw new state.StateError('conflict', '实验规则不能直接搬到全局,先采纳为普通项目规范');
2313
+ }
2186
2314
  const targetScope = options.target === 'global' ? globalScope() : projectScope;
2187
2315
  if (targetScope.dir === sourceScope.dir) throw new state.StateError('conflict', '源和目标在同一层');
2188
2316
  const targetIndex = loadIndex(targetScope);
@@ -2289,7 +2417,7 @@ function learningRuleView(record) {
2289
2417
  * 普通记忆读取面不会调用这个入口。
2290
2418
  */
2291
2419
  export function learningSnapshot(scope, options = {}) {
2292
- const index = loadIndex(scope);
2420
+ const index = loadIndex(scope, options);
2293
2421
  const evolution = index.__evolution;
2294
2422
  const rules = learningRulesOf(index).map(learningRuleView);
2295
2423
  return {
@@ -2403,6 +2531,7 @@ export function commitLearning(scope, batch = {}) {
2403
2531
  if (!operationId) return { ok: false, reason: '缺少提交标识' };
2404
2532
  if (changes.length > learning.LIMITS.maxChanges) return { ok: false, reason: '一次改动太多' };
2405
2533
  return withLock(scope, () => {
2534
+ if (!(loadIndex(scope).__control || state.controlOf(null)).enabled) return { ok: false, reason: '本项目记忆已关闭' };
2406
2535
  let receipt = null;
2407
2536
  try {
2408
2537
  const result = state.transact(scope, {
@@ -2467,6 +2596,7 @@ export function commitLearning(scope, batch = {}) {
2467
2596
  existing: flat,
2468
2597
  change,
2469
2598
  evidence: { ...evidence, explicitLongTerm: Boolean(batch.explicitLongTerm) },
2599
+ specChanged: merged.specChanged === true,
2470
2600
  now,
2471
2601
  }),
2472
2602
  specDigest: merged.digest,
@@ -2486,10 +2616,6 @@ export function commitLearning(scope, batch = {}) {
2486
2616
  entry.updatedAt = at;
2487
2617
  entry.maintenance = { ...(entry.maintenance || {}), learning: merged.learning };
2488
2618
  } else {
2489
- if (currentRules.length + (touched + 1) > learning.LIMITS.rules) throw new Error('项目实验规则已经到上限');
2490
- const pending = currentRules.filter((entry) => toFlat(entry).learning?.state === 'pending').length;
2491
- if (pending >= learning.LIMITS.pending) throw new Error('待确认候选已经到上限');
2492
- const merged = learning.mergeChange(null, change, { now });
2493
2619
  const evidence = {
2494
2620
  sourceType: 'human_feedback',
2495
2621
  feedbackType: change.feedbackType,
@@ -2498,11 +2624,57 @@ export function commitLearning(scope, batch = {}) {
2498
2624
  messageIds: (batch.messageIds || []).slice(-3),
2499
2625
  at,
2500
2626
  };
2627
+ const merged = learning.mergeChange(null, change, { now });
2628
+ /** 同主题、同条件的新规则并进已有条目:整理器换个措辞不该长出第二条。 */
2629
+ const signature = (list) => (Array.isArray(list) ? list : [])
2630
+ .map((row) => `${row.key}=${row.value}`).sort().join('\u0000');
2631
+ const duplicate = merged.current.topicKey
2632
+ ? currentRules.find((row) => {
2633
+ const flat = toFlat(row);
2634
+ return flat.topicKey === merged.current.topicKey
2635
+ && signature(flat.conditions) === signature(merged.current.conditions)
2636
+ && flat.applicability === merged.current.applicability;
2637
+ })
2638
+ : null;
2639
+ if (duplicate) {
2640
+ const flat = toFlat(duplicate);
2641
+ const rem = learning.mergeChange(flat, change, { now });
2642
+ rem.learning = {
2643
+ ...learning.nextLearningState({
2644
+ existing: flat,
2645
+ change,
2646
+ evidence: { ...evidence, explicitLongTerm: Boolean(batch.explicitLongTerm) },
2647
+ specChanged: rem.specChanged === true,
2648
+ now,
2649
+ }),
2650
+ specDigest: rem.digest,
2651
+ versions: rem.learning.versions,
2652
+ };
2653
+ duplicate.current = {
2654
+ ...duplicate.current,
2655
+ title: rem.current.title,
2656
+ summary: rem.current.summary,
2657
+ body: rem.current.body,
2658
+ when: rem.current.when,
2659
+ applicability: rem.current.applicability,
2660
+ conditions: rem.current.conditions,
2661
+ topicKey: rem.current.topicKey,
2662
+ };
2663
+ duplicate.revision = Number(duplicate.revision) + 1;
2664
+ duplicate.updatedAt = at;
2665
+ duplicate.maintenance = { ...(duplicate.maintenance || {}), learning: rem.learning };
2666
+ touched += 1;
2667
+ continue;
2668
+ }
2669
+ if (currentRules.length + 1 > learning.LIMITS.rules) throw new Error('项目实验规则已经到上限');
2670
+ const pending = currentRules.filter((entry) => toFlat(entry).learning?.state === 'pending').length;
2671
+ if (pending >= learning.LIMITS.pending) throw new Error('待确认候选已经到上限');
2501
2672
  merged.learning = {
2502
2673
  ...learning.nextLearningState({
2503
2674
  existing: { learning: merged.learning },
2504
2675
  change,
2505
2676
  evidence: { ...evidence, explicitLongTerm: Boolean(batch.explicitLongTerm) },
2677
+ specChanged: merged.specChanged === true,
2506
2678
  now,
2507
2679
  }),
2508
2680
  specDigest: merged.digest,
@@ -2523,6 +2695,7 @@ export function commitLearning(scope, batch = {}) {
2523
2695
  selection: null,
2524
2696
  links: [],
2525
2697
  origin: EXPERIMENTAL_ORIGIN,
2698
+ ownerProjectId: scope.key,
2526
2699
  reviewState: 'active',
2527
2700
  created: at,
2528
2701
  updated: at,
@@ -2536,8 +2709,9 @@ export function commitLearning(scope, batch = {}) {
2536
2709
  checks: [],
2537
2710
  learning: merged.learning,
2538
2711
  };
2539
- draft.entries.push(fromFlat(flat));
2540
- currentRules.push(flat);
2712
+ const created = fromFlat(flat);
2713
+ draft.entries.push(created);
2714
+ currentRules.push(created);
2541
2715
  touched += 1;
2542
2716
  continue;
2543
2717
  }
@@ -2603,12 +2777,19 @@ export function recordLearningFailure(scope, options = {}) {
2603
2777
  });
2604
2778
  }
2605
2779
 
2780
+ /** 管理面写操作的统一闸门:总开关关掉时服务端直接拒绝,不写盘也不返回旧正文。 */
2781
+ function managementClosed(scope) {
2782
+ const index = loadIndex(scope);
2783
+ return !(index.__control || state.controlOf(null)).enabled;
2784
+ }
2785
+
2606
2786
  /** 删除:仍按管理面看到的版本比对,删掉正文与旧版本,只留审计回执。 */
2607
2787
  export function removeLearningRule(scope, id, options = {}) {
2608
2788
  const expectedRevision = Number(options.expectedRevision);
2609
2789
  const operationId = String(options.operationId || '');
2610
2790
  const at = new Date(options.now || Date.now()).toISOString();
2611
2791
  return withLock(scope, () => {
2792
+ if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
2612
2793
  try {
2613
2794
  const result = state.transact(scope, { operationId }, (draft) => {
2614
2795
  const evolution = draft.experimental && draft.experimental.projectEvolution;
@@ -2621,18 +2802,19 @@ export function removeLearningRule(scope, id, options = {}) {
2621
2802
  entry.status = 'archived';
2622
2803
  entry.updatedAt = at;
2623
2804
  entry.current = { ...entry.current, body: '', summary: '' };
2624
- entry.maintenance = {
2625
- ...(entry.maintenance || {}),
2626
- learning: {
2627
- ...(flat.learning || {}),
2628
- state: 'paused',
2629
- blockedReason: 'deleted',
2630
- pausedBy: 'management',
2631
- pausedAt: at,
2632
- versions: [],
2633
- specDigest: '',
2634
- },
2805
+ const deletedLearning = {
2806
+ ...(flat.learning || {}),
2807
+ state: 'paused',
2808
+ blockedReason: 'deleted',
2809
+ pausedBy: 'management',
2810
+ pausedAt: at,
2811
+ versions: [],
2812
+ specDigest: '',
2635
2813
  };
2814
+ /** 删掉的规范正文不能再从 lastText 里漏出来。 */
2815
+ delete deletedLearning.lastText;
2816
+ delete deletedLearning.trialUntil;
2817
+ entry.maintenance = { ...(entry.maintenance || {}), learning: deletedLearning };
2636
2818
  addLearningReceipt(evolution, learning.receiptOf({ operationId, episodeId: flat.learning?.evidence?.[0]?.episodeId || '', status: 'deleted', changes: 1, at }));
2637
2819
  return { changed: true, kind: 'learning_delete' };
2638
2820
  });
@@ -2651,6 +2833,7 @@ export function undoLearningChange(scope, id, options = {}) {
2651
2833
  const now = options.now || Date.now();
2652
2834
  const at = new Date(now).toISOString();
2653
2835
  return withLock(scope, () => {
2836
+ if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
2654
2837
  try {
2655
2838
  const result = state.transact(scope, { operationId }, (draft) => {
2656
2839
  const evolution = draft.experimental && draft.experimental.projectEvolution;
@@ -2696,6 +2879,7 @@ export function pinLearningRule(scope, id, options = {}) {
2696
2879
  const operationId = String(options.operationId || '');
2697
2880
  const at = new Date(options.now || Date.now()).toISOString();
2698
2881
  return withLock(scope, () => {
2882
+ if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
2699
2883
  try {
2700
2884
  const result = state.transact(scope, { operationId }, (draft) => {
2701
2885
  const evolution = draft.experimental && draft.experimental.projectEvolution;
@@ -2719,12 +2903,125 @@ export function pinLearningRule(scope, id, options = {}) {
2719
2903
  });
2720
2904
  }
2721
2905
 
2906
+ /**
2907
+ * 管理面编辑:只保存内容,不改变学习状态;正文变了就清掉旧证据并留上一版。
2908
+ * 已生效规则改写后降回试用,避免拿新正文沿用旧证据直接当默认。
2909
+ */
2910
+ export function editLearningRule(scope, id, options = {}) {
2911
+ const expectedRevision = Number(options.expectedRevision);
2912
+ const operationId = String(options.operationId || '');
2913
+ const now = options.now || Date.now();
2914
+ const at = new Date(now).toISOString();
2915
+ const title = typeof options.title === 'string' ? options.title.trim() : '';
2916
+ const body = typeof options.body === 'string' ? options.body.trim() : '';
2917
+ if (!title && !body) throw new Error('没有要保存的内容');
2918
+ if (title && title.length > LIMITS.titleChars) throw new Error(`标题超过 ${LIMITS.titleChars} 字`);
2919
+ if (body && Buffer.byteLength(body) > LIMITS.entryBytes) throw new Error(`正文超过单条 ${LIMITS.entryBytes} 字节`);
2920
+ return withLock(scope, () => {
2921
+ if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
2922
+ try {
2923
+ const result = state.transact(scope, { operationId }, (draft) => {
2924
+ const entry = draft.entries.find((row) => row.id === id && row.status === 'active');
2925
+ if (!entry || !isExperimentalRecord(toFlat(entry))) throw new Error('没有这条实验规则');
2926
+ if (!Number.isInteger(expectedRevision) || Number(entry.revision) !== expectedRevision) throw new Error('这条规则刚被改过,先刷新再保存');
2927
+ const flat = toFlat(entry);
2928
+ const nextTitle = title || flat.title;
2929
+ const nextBody = body || flat.body;
2930
+ if (nextTitle === flat.title && nextBody === flat.body) return { changed: false };
2931
+ const nextCurrent = { ...entry.current, title: nextTitle, body: nextBody };
2932
+ const bodyChanged = nextBody !== flat.body;
2933
+ const learningState = { ...(flat.learning || {}) };
2934
+ if (!learningState.specDigest) learningState.specDigest = learning.specDigestOf(flat);
2935
+ if (bodyChanged) {
2936
+ learningState.versions = [...(Array.isArray(learningState.versions) ? learningState.versions : []),
2937
+ { digest: learningState.specDigest, body: flat.body, at }].slice(-learning.LIMITS.versions);
2938
+ learningState.supportedEpisodeIds = [];
2939
+ learningState.evidence = [];
2940
+ }
2941
+ learningState.specDigest = learning.specDigestOf(nextCurrent);
2942
+ learningState.updatedAt = at;
2943
+ if (bodyChanged && learningState.state === 'active') {
2944
+ learningState.state = 'trial';
2945
+ learningState.trialUntil = new Date(now + learning.LIMITS.trialMs).toISOString();
2946
+ }
2947
+ if (learningState.state === 'paused') {
2948
+ learningState.blockedReason = 'edited';
2949
+ learningState.pausedAt = at;
2950
+ learningState.pausedBy = 'management';
2951
+ }
2952
+ entry.current = nextCurrent;
2953
+ entry.maintenance = { ...(entry.maintenance || {}), learning: learningState };
2954
+ entry.revision = Number(entry.revision) + 1;
2955
+ entry.updatedAt = at;
2956
+ return { changed: true, kind: 'learning_edit' };
2957
+ });
2958
+ return { ok: true, changed: result.changed === true, replayed: result.replayed === true };
2959
+ } catch (error) {
2960
+ if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
2961
+ return { ok: false, reason: error instanceof Error ? error.message : '没有保存' };
2962
+ }
2963
+ });
2964
+ }
2965
+
2966
+ /** 采纳:候选/试用转为生效,默认在适用任务中参与召回;可选同时保存改过的标题或正文。 */
2967
+ export function adoptLearningRule(scope, id, options = {}) {
2968
+ const expectedRevision = Number(options.expectedRevision);
2969
+ const operationId = String(options.operationId || '');
2970
+ const now = options.now || Date.now();
2971
+ const at = new Date(now).toISOString();
2972
+ const title = typeof options.title === 'string' ? options.title.trim() : '';
2973
+ const body = typeof options.body === 'string' ? options.body.trim() : '';
2974
+ if (!Number.isInteger(expectedRevision) || expectedRevision < 1) throw new Error('缺少版本,先刷新');
2975
+ if (title && title.length > LIMITS.titleChars) throw new Error(`标题超过 ${LIMITS.titleChars} 字`);
2976
+ if (body && Buffer.byteLength(body) > LIMITS.entryBytes) throw new Error(`正文超过单条 ${LIMITS.entryBytes} 字节`);
2977
+ return withLock(scope, () => {
2978
+ if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
2979
+ try {
2980
+ const result = state.transact(scope, { operationId }, (draft) => {
2981
+ const entry = draft.entries.find((row) => row.id === id && row.status === 'active');
2982
+ if (!entry || !isExperimentalRecord(toFlat(entry))) throw new Error('没有这条实验规则');
2983
+ if (Number(entry.revision) !== expectedRevision) throw new Error('这条规则刚被改过,先刷新再采纳');
2984
+ const flat = toFlat(entry);
2985
+ const learningState = { ...(flat.learning || {}) };
2986
+ if (learningState.state === 'active' && !title && !body) return { changed: false };
2987
+ const nextTitle = title || flat.title;
2988
+ const nextBody = body || flat.body;
2989
+ const nextCurrent = { ...entry.current, title: nextTitle, body: nextBody };
2990
+ if (!learningState.specDigest) learningState.specDigest = learning.specDigestOf(flat);
2991
+ if (nextBody !== flat.body || nextTitle !== flat.title) {
2992
+ learningState.versions = [...(Array.isArray(learningState.versions) ? learningState.versions : []),
2993
+ { digest: learningState.specDigest, body: flat.body, at }].slice(-learning.LIMITS.versions);
2994
+ learningState.specDigest = learning.specDigestOf(nextCurrent);
2995
+ learningState.supportedEpisodeIds = [];
2996
+ learningState.evidence = [];
2997
+ }
2998
+ learningState.state = 'active';
2999
+ delete learningState.trialUntil;
3000
+ delete learningState.blockedReason;
3001
+ delete learningState.pausedAt;
3002
+ delete learningState.pausedBy;
3003
+ learningState.updatedAt = at;
3004
+ entry.current = nextCurrent;
3005
+ entry.maintenance = { ...(entry.maintenance || {}), learning: learningState };
3006
+ entry.revision = Number(entry.revision) + 1;
3007
+ entry.updatedAt = at;
3008
+ return { changed: true, kind: 'learning_adopt' };
3009
+ });
3010
+ return { ok: true, changed: result.changed === true, replayed: result.replayed === true };
3011
+ } catch (error) {
3012
+ if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
3013
+ return { ok: false, reason: error instanceof Error ? error.message : '没有采纳' };
3014
+ }
3015
+ });
3016
+ }
3017
+
2722
3018
  /** 结构化负反馈先在本地暂停命中的试用规则,再决定是否调用整理器。 */
2723
3019
  export function pauseLearningRules(scope, ids, options = {}) {
2724
3020
  const targets = Array.isArray(ids) ? ids.filter(Boolean).slice(0, 12) : [];
2725
3021
  if (!targets.length) return { ok: true, changed: 0 };
2726
3022
  const at = new Date(options.now || Date.now()).toISOString();
2727
3023
  return withLock(scope, () => {
3024
+ if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
2728
3025
  try {
2729
3026
  const result = state.transact(scope, { operationId: options.operationId || '' }, (draft) => {
2730
3027
  let changed = 0;
@@ -2754,6 +3051,7 @@ export function resumeLearningRule(scope, id, options = {}) {
2754
3051
  const operationId = String(options.operationId || '');
2755
3052
  const at = new Date(options.now || Date.now()).toISOString();
2756
3053
  return withLock(scope, () => {
3054
+ if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
2757
3055
  try {
2758
3056
  const result = state.transact(scope, { operationId }, (draft) => {
2759
3057
  const entry = draft.entries.find((row) => row.id === id && row.status === 'active');
@@ -2780,6 +3078,7 @@ export function expireLearning(scope, options = {}) {
2780
3078
  const now = options.now || Date.now();
2781
3079
  const at = new Date(now).toISOString();
2782
3080
  return withLock(scope, () => {
3081
+ if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
2783
3082
  try {
2784
3083
  const result = state.transact(scope, { operationId: options.operationId || '' }, (draft) => {
2785
3084
  const evolution = draft.experimental && draft.experimental.projectEvolution;