dsh-lite-memory 0.11.1 → 0.15.0

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
@@ -96,6 +96,7 @@ function toFlat(entry) {
96
96
  revision: Number(entry.revision) || 1,
97
97
  status: entry.status === 'archived' ? 'archived' : 'active',
98
98
  origin: entry.origin || 'user_explicit',
99
+ ownerProjectId: typeof entry.ownerProjectId === 'string' ? entry.ownerProjectId : '',
99
100
  hits: Number(maintenance.hits) || 0,
100
101
  merges: Number(maintenance.merges) || 0,
101
102
  versions: Array.isArray(maintenance.versions) ? cloneJson(maintenance.versions) : [],
@@ -115,6 +116,7 @@ function fromFlat(record, status) {
115
116
  revision: Number(record.revision) || 1,
116
117
  status: status || (record.status === 'archived' ? 'archived' : 'active'),
117
118
  origin: typeof record.origin === 'string' && record.origin ? record.origin : 'user_explicit',
119
+ ownerProjectId: record.ownerProjectId || '',
118
120
  createdAt: record.created || at,
119
121
  updatedAt: at,
120
122
  current: {
@@ -154,7 +156,7 @@ function fromFlat(record, status) {
154
156
  * 由 state.js 拦成报告,这里不猜、不补空正文。
155
157
  */
156
158
  export function loadIndex(scope, options = {}) {
157
- if (!fs.existsSync(path.join(scope.dir, 'state.json'))) {
159
+ if (!options.readOnly && !fs.existsSync(path.join(scope.dir, 'state.json'))) {
158
160
  const report = state.migrationReport(scope);
159
161
  if (report.required || report.adoptable.length) state.migrateLegacy(scope);
160
162
  }
@@ -176,6 +178,7 @@ export function loadIndex(scope, options = {}) {
176
178
  suppressions: JSON.parse(JSON.stringify(loaded.state.suppressions || [])),
177
179
  __evolution: state.evolutionOf(loaded.state),
178
180
  __tasks: anchor.normalizeTasks(state.evolutionOf(loaded.state).tasks),
181
+ __control: state.controlOf(loaded.state),
179
182
  __exists: loaded.exists === true,
180
183
  };
181
184
  index.__meta = {
@@ -273,6 +276,14 @@ function cleanTitle(value) {
273
276
  return String(value ?? '').replace(/\s+/g, ' ').trim();
274
277
  }
275
278
 
279
+ /** 正文里的实现参数(尺寸/层级/字号)不算记忆:数到三次就要求重写成原则。 */
280
+ const UI_SPEC_PATTERNS = [
281
+ /\bz-index\s*:\s*-?\d+/gi,
282
+ /\b(?:padding|margin|gap|font-size|line-height|border-radius|width|height|min-width|max-width|min-height|max-height|box-shadow)\s*:[^;\n]{0,32}\d/gi,
283
+ /\b\d+(?:\.\d+)?(?:px|rem|em)\b/gi,
284
+ /\bmin\(\s*\d/gi,
285
+ ];
286
+
276
287
  export function validSummary(summary) {
277
288
  const text = String(summary ?? '').replace(/\s+/g, ' ').trim();
278
289
  if (!text) return { ok: false, reason: '摘要为空' };
@@ -311,7 +322,11 @@ export function normalizeDraft(source, options = {}) {
311
322
  const body = String(draft.body ?? '').trim();
312
323
  const bodyBytes = Buffer.byteLength(body);
313
324
  if (bodyBytes > LIMITS.entryBytes) {
314
- throw new Error(`正文 ${bodyBytes} 字节,超过单条 ${LIMITS.entryBytes} 字节;先精简,别让尾部条件被截掉`);
325
+ throw new Error(`正文 ${bodyBytes} 字节,超过单条 ${LIMITS.entryBytes} 字节;只留原则、判断规则和触发条件`);
326
+ }
327
+ if (has('body')) {
328
+ const concrete = UI_SPEC_PATTERNS.reduce((count, pattern) => count + (body.match(pattern) || []).length, 0);
329
+ if (concrete >= 3) throw new Error('正文太具体:记忆只留原则和触发条件,像素、字号、层级这类实现参数留在代码或文档里');
315
330
  }
316
331
  const kind = has('kind') ? textField(draft.kind, 20, '类型') : 'note';
317
332
  if (kind && !KINDS.includes(kind)) throw new Error(`类型只能是 ${KINDS.join(' / ')}`);
@@ -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;
@@ -842,23 +875,66 @@ function scoreQuery(item, grams, raw) {
842
875
  [(item.triggers || []).join(' '), MATCH_WEIGHT.tags],
843
876
  ];
844
877
  let score = 0;
878
+ let substantive = 0;
845
879
  for (const [text, weight] of fields) {
846
880
  if (!text) continue;
847
881
  const pool = bigrams(text);
848
882
  let shared = 0;
849
- for (const gram of grams) if (pool.has(gram)) shared += 1;
883
+ for (const gram of grams) {
884
+ if (!pool.has(gram)) continue;
885
+ shared += 1;
886
+ if (!STOP_GRAMS.has(gram)) substantive += 1;
887
+ }
850
888
  score += shared * weight;
851
889
  }
852
890
  const plain = normalizeText(raw, 200).toLowerCase().replace(/\s+/g, '');
853
891
  if (plain && normalizeText(item.title, 200).toLowerCase().replace(/\s+/g, '').includes(plain)) score += 5;
854
892
  /** 触发词命中按整词算:用户说的那句话里带着它,就该搜得到。 */
855
- if (plain && (item.triggers || []).some((trigger) => {
893
+ const triggerHit = Boolean(plain) && (item.triggers || []).some((trigger) => {
856
894
  const text = normalizeText(trigger, 200).toLowerCase().replace(/\s+/g, '');
857
895
  return Boolean(text) && plain.includes(text);
858
- })) score += 4;
896
+ });
897
+ if (triggerHit) score += 4;
898
+ /** 纯英文查询必须按整词命中,避免 git 命中 digital 这种字面巧合;混合查询仍按中文实词判断。 */
899
+ const words = asciiWordsOf(raw);
900
+ const hasCjk = /[\u4e00-\u9fff]/.test(String(raw || ''));
901
+ if (words.length && !hasCjk) {
902
+ const pool = fields.map(([text]) => fieldText(text)).join('\n');
903
+ if (!words.some((word) => wordIn(pool, word))) return 0;
904
+ }
905
+ /** 只重合了「这个」「怎么」这类词,不算命中;触发词命中仍算。 */
906
+ if (substantive === 0 && !triggerHit && words.length === 0) return 0;
859
907
  return score;
860
908
  }
861
909
 
910
+ /** 项目记忆总开关:读当前层,缺字段按开启。 */
911
+ export function controlStatus(scope) {
912
+ const index = loadIndex(scope);
913
+ return { ...(index.__control || state.controlOf(null)) };
914
+ }
915
+
916
+ export function setMemoryEnabled(scope, enabled, options = {}) {
917
+ return withLock(scope, () => {
918
+ const before = loadIndex(scope);
919
+ const operationId = typeof options.operationId === 'string' ? options.operationId : '';
920
+ let applied = null;
921
+ try {
922
+ state.transact(scope, {
923
+ operationId,
924
+ expectCreate: !before.__exists,
925
+ expected: before.__exists ? { generation: before.generation, epoch: before.epoch } : null,
926
+ }, (draft) => {
927
+ applied = state.applyControl(draft, enabled);
928
+ return applied.changed ? { changed: true } : { changed: false };
929
+ });
930
+ } catch (error) {
931
+ if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
932
+ return { ok: false, reason: error instanceof Error ? error.message : '记忆开关没有改动' };
933
+ }
934
+ return { ok: true, changed: Boolean(applied && applied.changed), ...controlStatus(scope) };
935
+ });
936
+ }
937
+
862
938
  /**
863
939
  * 项目实验开关:权威状态里缺字段即关闭。状态只回可核验事实,不编学习分数。
864
940
  * 关闭时保留已完成成果;试用条目暂停,等新的有效反馈或明确管理动作再复核。
@@ -872,13 +948,26 @@ export function evolutionStatus(scope) {
872
948
  const phase = record.learning && typeof record.learning === 'object' ? record.learning.state : '';
873
949
  if (phase === 'active' || phase === 'trial' || phase === 'paused') counts[phase] += 1;
874
950
  }
875
- return { ...config, counts };
951
+ const control = index.__control || state.controlOf(null);
952
+ return {
953
+ ...config,
954
+ /** 模型可见的 enabled 是有效值:总开关关掉时,自进化一律不运行。 */
955
+ enabled: config.enabled === true && control.enabled === true,
956
+ configuredEnabled: config.enabled === true,
957
+ memoryEnabled: control.enabled === true,
958
+ ownerProject: config.ownerProject || '',
959
+ counts,
960
+ };
876
961
  }
877
962
 
878
963
  export function setEvolution(scope, enabled, options = {}) {
879
964
  return withLock(scope, () => {
880
965
  const before = loadIndex(scope);
881
966
  const config = before.__evolution || {};
967
+ /** 总开关关掉后,自进化开关也不能改:先恢复本项目记忆。 */
968
+ if (!(before.__control || state.controlOf(null)).enabled) {
969
+ return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
970
+ }
882
971
  if (config.compatible === false) {
883
972
  return { ok: false, reason: '实验配置版本不认,先不要改动开关', compatible: false };
884
973
  }
@@ -913,6 +1002,7 @@ export function taskState(scope) {
913
1002
  export function saveTaskState(scope, tasks, options = {}) {
914
1003
  return withLock(scope, () => {
915
1004
  const before = loadIndex(scope);
1005
+ if (!(before.__control || state.controlOf(null)).enabled) return { ok: false, reason: '本项目记忆已关闭' };
916
1006
  if (!before.__evolution.enabled) return { ok: false, reason: '实验还没开启' };
917
1007
  const next = anchor.normalizeTasks(tasks);
918
1008
  const operationId = typeof options.operationId === 'string' ? options.operationId : '';
@@ -964,6 +1054,9 @@ export function search(scope, query, limit = 4, options = {}) {
964
1054
  const collect = (pool, archived) => {
965
1055
  for (const item of pool) {
966
1056
  if (!readableRecord(item, options, index)) continue;
1057
+ /** 已暂停、已过期或待确认的学习规则不进入检索结果,除非明确要病史。 */
1058
+ if (options.allowExperimental === true && options.history !== true
1059
+ && isExperimentalRecord(item) && !view.experimentalUsable(item, options.now)) continue;
967
1060
  const score = scoreQuery(item, grams, query);
968
1061
  if (score <= 0) continue;
969
1062
  if (keywordMode && !matchesRun(item, runs)) continue;
@@ -1184,7 +1277,7 @@ export function undoChange(scope, id, options = {}) {
1184
1277
  if (!record) continue;
1185
1278
  /** 旧回执不能撤掉别人后来的修改:版本对不上就明确拒绝。 */
1186
1279
  if (options.expectedRevision !== undefined && Number(record.revision) !== Number(options.expectedRevision)) {
1187
- throw new state.StateError('conflict', `这条记忆已经更新到第 ${record.revision} 版`);
1280
+ throw new state.StateError('conflict', '这条记忆已经被更新过,刷新后再改');
1188
1281
  }
1189
1282
  const versions = record.versions || [];
1190
1283
  const last = versions[versions.length - 1];
@@ -1282,9 +1375,9 @@ export function findAnywhere(id) {
1282
1375
  }
1283
1376
 
1284
1377
  /** 按 id 在项目层与全局层里找位置,不靠文案猜作用域;管理动作也要看得见已归档的条目。 */
1285
- export function locate(scope, id) {
1378
+ export function locate(scope, id, options = {}) {
1286
1379
  for (const source of [scope, globalScope()]) {
1287
- const index = loadIndex(source);
1380
+ const index = loadIndex(source, options);
1288
1381
  const active = index.entries.find((item) => item.id === id);
1289
1382
  if (active) return { scope: source, record: active, archived: false };
1290
1383
  const archived = index.archived.find((item) => item.id === id);
@@ -1571,7 +1664,8 @@ export function renderIndex(entries, budget = 400) {
1571
1664
  const stale = now - last > staleMs;
1572
1665
  const stateTag = item.state === 'archived' ? ' · 已归档' : item.state === 'needs_context' ? ' · 适用范围待确认' : item.state === 'not_applicable' ? ' · 当前条件不适用' : '';
1573
1666
  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}`;
1667
+ const tag = item.scope === 'global' ? '[global]' : item.scopeName ? `[${item.scopeName}]` : '';
1668
+ const head = `${tag ? `${tag} ` : ''}${item.id} ${item.title}${item.when ? `(${item.when})` : ''}${originTag}${stateTag}`;
1575
1669
  const line = stale ? `- ${head}` : `- ${head}|${item.summary}`;
1576
1670
  if (used + line.length > budget) break;
1577
1671
  lines.push(line);
@@ -1882,7 +1976,7 @@ function bindSameTopic(projectScope, ref) {
1882
1976
  const record = loadIndex(source).entries.find((item) => item.id === ref.id);
1883
1977
  if (!record) return { error: `sameTopicAs 指向的记忆不存在:${ref.id}` };
1884
1978
  if (Number(ref.revision) !== Number(record.revision || 1)) {
1885
- return { error: `sameTopicAs 的版本不对:${ref.id} 当前是第 ${record.revision || 1} 版` };
1979
+ return { error: `sameTopicAs 的目标已经改过:${ref.id}` };
1886
1980
  }
1887
1981
  return { topicKey: view.topicSlot(record) };
1888
1982
  }
@@ -1893,6 +1987,10 @@ function bindSameTopic(projectScope, ref) {
1893
1987
  */
1894
1988
  export function saveEntry(projectScope, source) {
1895
1989
  return withLocks([projectScope, globalScope()], () => {
1990
+ const control = loadIndex(projectScope).__control || state.controlOf(null);
1991
+ if (!control.enabled) {
1992
+ return { needsScope: true, chosenScope: '', reasonCode: 'memory_disabled', requiredConditions: [], needsResolution: false, similarList: [], draft: null };
1993
+ }
1896
1994
  const routed = routeScope(source, { project: projectScope });
1897
1995
  const draft = (() => {
1898
1996
  try { return normalizeDraft({ ...source, title: source && source.title }); } catch { return null; }
@@ -2166,6 +2264,9 @@ function driveMove(projectScope, intent, options = {}) {
2166
2264
  * 协调文件里带项目身份与两侧 epoch:换项目续跑会被拒绝,续跑也不再靠猜。
2167
2265
  */
2168
2266
  export function move(projectScope, id, options = {}) {
2267
+ if (!(loadIndex(projectScope).__control || state.controlOf(null)).enabled) {
2268
+ throw new state.StateError('conflict', '本项目记忆已关闭,先开启再整理');
2269
+ }
2169
2270
  return state.withRootLock(() => {
2170
2271
  const existing = state.readMoveIntent();
2171
2272
  if (existing) {
@@ -2183,6 +2284,10 @@ export function move(projectScope, id, options = {}) {
2183
2284
  const sourceScope = projectRecord ? projectScope : globalScope();
2184
2285
  const sourceIndex = projectRecord ? projectIndex : globalIndex;
2185
2286
  const record = projectRecord || globalRecord;
2287
+ /** 实验记录只能在原项目内用:普通移动不能把它带进全局层。 */
2288
+ if (record.origin === EXPERIMENTAL_ORIGIN && options.target === 'global') {
2289
+ throw new state.StateError('conflict', '实验规则不能直接搬到全局,先采纳为普通项目规范');
2290
+ }
2186
2291
  const targetScope = options.target === 'global' ? globalScope() : projectScope;
2187
2292
  if (targetScope.dir === sourceScope.dir) throw new state.StateError('conflict', '源和目标在同一层');
2188
2293
  const targetIndex = loadIndex(targetScope);
@@ -2289,7 +2394,7 @@ function learningRuleView(record) {
2289
2394
  * 普通记忆读取面不会调用这个入口。
2290
2395
  */
2291
2396
  export function learningSnapshot(scope, options = {}) {
2292
- const index = loadIndex(scope);
2397
+ const index = loadIndex(scope, options);
2293
2398
  const evolution = index.__evolution;
2294
2399
  const rules = learningRulesOf(index).map(learningRuleView);
2295
2400
  return {
@@ -2403,6 +2508,7 @@ export function commitLearning(scope, batch = {}) {
2403
2508
  if (!operationId) return { ok: false, reason: '缺少提交标识' };
2404
2509
  if (changes.length > learning.LIMITS.maxChanges) return { ok: false, reason: '一次改动太多' };
2405
2510
  return withLock(scope, () => {
2511
+ if (!(loadIndex(scope).__control || state.controlOf(null)).enabled) return { ok: false, reason: '本项目记忆已关闭' };
2406
2512
  let receipt = null;
2407
2513
  try {
2408
2514
  const result = state.transact(scope, {
@@ -2467,6 +2573,7 @@ export function commitLearning(scope, batch = {}) {
2467
2573
  existing: flat,
2468
2574
  change,
2469
2575
  evidence: { ...evidence, explicitLongTerm: Boolean(batch.explicitLongTerm) },
2576
+ specChanged: merged.specChanged === true,
2470
2577
  now,
2471
2578
  }),
2472
2579
  specDigest: merged.digest,
@@ -2486,10 +2593,6 @@ export function commitLearning(scope, batch = {}) {
2486
2593
  entry.updatedAt = at;
2487
2594
  entry.maintenance = { ...(entry.maintenance || {}), learning: merged.learning };
2488
2595
  } 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
2596
  const evidence = {
2494
2597
  sourceType: 'human_feedback',
2495
2598
  feedbackType: change.feedbackType,
@@ -2498,11 +2601,57 @@ export function commitLearning(scope, batch = {}) {
2498
2601
  messageIds: (batch.messageIds || []).slice(-3),
2499
2602
  at,
2500
2603
  };
2604
+ const merged = learning.mergeChange(null, change, { now });
2605
+ /** 同主题、同条件的新规则并进已有条目:整理器换个措辞不该长出第二条。 */
2606
+ const signature = (list) => (Array.isArray(list) ? list : [])
2607
+ .map((row) => `${row.key}=${row.value}`).sort().join('\u0000');
2608
+ const duplicate = merged.current.topicKey
2609
+ ? currentRules.find((row) => {
2610
+ const flat = toFlat(row);
2611
+ return flat.topicKey === merged.current.topicKey
2612
+ && signature(flat.conditions) === signature(merged.current.conditions)
2613
+ && flat.applicability === merged.current.applicability;
2614
+ })
2615
+ : null;
2616
+ if (duplicate) {
2617
+ const flat = toFlat(duplicate);
2618
+ const rem = learning.mergeChange(flat, change, { now });
2619
+ rem.learning = {
2620
+ ...learning.nextLearningState({
2621
+ existing: flat,
2622
+ change,
2623
+ evidence: { ...evidence, explicitLongTerm: Boolean(batch.explicitLongTerm) },
2624
+ specChanged: rem.specChanged === true,
2625
+ now,
2626
+ }),
2627
+ specDigest: rem.digest,
2628
+ versions: rem.learning.versions,
2629
+ };
2630
+ duplicate.current = {
2631
+ ...duplicate.current,
2632
+ title: rem.current.title,
2633
+ summary: rem.current.summary,
2634
+ body: rem.current.body,
2635
+ when: rem.current.when,
2636
+ applicability: rem.current.applicability,
2637
+ conditions: rem.current.conditions,
2638
+ topicKey: rem.current.topicKey,
2639
+ };
2640
+ duplicate.revision = Number(duplicate.revision) + 1;
2641
+ duplicate.updatedAt = at;
2642
+ duplicate.maintenance = { ...(duplicate.maintenance || {}), learning: rem.learning };
2643
+ touched += 1;
2644
+ continue;
2645
+ }
2646
+ if (currentRules.length + (touched + 1) > learning.LIMITS.rules) throw new Error('项目实验规则已经到上限');
2647
+ const pending = currentRules.filter((entry) => toFlat(entry).learning?.state === 'pending').length;
2648
+ if (pending >= learning.LIMITS.pending) throw new Error('待确认候选已经到上限');
2501
2649
  merged.learning = {
2502
2650
  ...learning.nextLearningState({
2503
2651
  existing: { learning: merged.learning },
2504
2652
  change,
2505
2653
  evidence: { ...evidence, explicitLongTerm: Boolean(batch.explicitLongTerm) },
2654
+ specChanged: merged.specChanged === true,
2506
2655
  now,
2507
2656
  }),
2508
2657
  specDigest: merged.digest,
@@ -2523,6 +2672,7 @@ export function commitLearning(scope, batch = {}) {
2523
2672
  selection: null,
2524
2673
  links: [],
2525
2674
  origin: EXPERIMENTAL_ORIGIN,
2675
+ ownerProjectId: scope.key,
2526
2676
  reviewState: 'active',
2527
2677
  created: at,
2528
2678
  updated: at,
@@ -2536,8 +2686,9 @@ export function commitLearning(scope, batch = {}) {
2536
2686
  checks: [],
2537
2687
  learning: merged.learning,
2538
2688
  };
2539
- draft.entries.push(fromFlat(flat));
2540
- currentRules.push(flat);
2689
+ const created = fromFlat(flat);
2690
+ draft.entries.push(created);
2691
+ currentRules.push(created);
2541
2692
  touched += 1;
2542
2693
  continue;
2543
2694
  }
@@ -2603,12 +2754,19 @@ export function recordLearningFailure(scope, options = {}) {
2603
2754
  });
2604
2755
  }
2605
2756
 
2757
+ /** 管理面写操作的统一闸门:总开关关掉时服务端直接拒绝,不写盘也不返回旧正文。 */
2758
+ function managementClosed(scope) {
2759
+ const index = loadIndex(scope);
2760
+ return !(index.__control || state.controlOf(null)).enabled;
2761
+ }
2762
+
2606
2763
  /** 删除:仍按管理面看到的版本比对,删掉正文与旧版本,只留审计回执。 */
2607
2764
  export function removeLearningRule(scope, id, options = {}) {
2608
2765
  const expectedRevision = Number(options.expectedRevision);
2609
2766
  const operationId = String(options.operationId || '');
2610
2767
  const at = new Date(options.now || Date.now()).toISOString();
2611
2768
  return withLock(scope, () => {
2769
+ if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
2612
2770
  try {
2613
2771
  const result = state.transact(scope, { operationId }, (draft) => {
2614
2772
  const evolution = draft.experimental && draft.experimental.projectEvolution;
@@ -2621,18 +2779,19 @@ export function removeLearningRule(scope, id, options = {}) {
2621
2779
  entry.status = 'archived';
2622
2780
  entry.updatedAt = at;
2623
2781
  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
- },
2782
+ const deletedLearning = {
2783
+ ...(flat.learning || {}),
2784
+ state: 'paused',
2785
+ blockedReason: 'deleted',
2786
+ pausedBy: 'management',
2787
+ pausedAt: at,
2788
+ versions: [],
2789
+ specDigest: '',
2635
2790
  };
2791
+ /** 删掉的规范正文不能再从 lastText 里漏出来。 */
2792
+ delete deletedLearning.lastText;
2793
+ delete deletedLearning.trialUntil;
2794
+ entry.maintenance = { ...(entry.maintenance || {}), learning: deletedLearning };
2636
2795
  addLearningReceipt(evolution, learning.receiptOf({ operationId, episodeId: flat.learning?.evidence?.[0]?.episodeId || '', status: 'deleted', changes: 1, at }));
2637
2796
  return { changed: true, kind: 'learning_delete' };
2638
2797
  });
@@ -2651,6 +2810,7 @@ export function undoLearningChange(scope, id, options = {}) {
2651
2810
  const now = options.now || Date.now();
2652
2811
  const at = new Date(now).toISOString();
2653
2812
  return withLock(scope, () => {
2813
+ if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
2654
2814
  try {
2655
2815
  const result = state.transact(scope, { operationId }, (draft) => {
2656
2816
  const evolution = draft.experimental && draft.experimental.projectEvolution;
@@ -2696,6 +2856,7 @@ export function pinLearningRule(scope, id, options = {}) {
2696
2856
  const operationId = String(options.operationId || '');
2697
2857
  const at = new Date(options.now || Date.now()).toISOString();
2698
2858
  return withLock(scope, () => {
2859
+ if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
2699
2860
  try {
2700
2861
  const result = state.transact(scope, { operationId }, (draft) => {
2701
2862
  const evolution = draft.experimental && draft.experimental.projectEvolution;
@@ -2719,12 +2880,122 @@ export function pinLearningRule(scope, id, options = {}) {
2719
2880
  });
2720
2881
  }
2721
2882
 
2883
+ /**
2884
+ * 管理面编辑:只保存内容,不改变学习状态;正文变了就清掉旧证据并留上一版。
2885
+ * 已生效规则改写后降回试用,避免拿新正文沿用旧证据直接当默认。
2886
+ */
2887
+ export function editLearningRule(scope, id, options = {}) {
2888
+ const expectedRevision = Number(options.expectedRevision);
2889
+ const operationId = String(options.operationId || '');
2890
+ const now = options.now || Date.now();
2891
+ const at = new Date(now).toISOString();
2892
+ const title = typeof options.title === 'string' ? options.title.trim() : '';
2893
+ const body = typeof options.body === 'string' ? options.body.trim() : '';
2894
+ if (!title && !body) throw new Error('没有要保存的内容');
2895
+ if (title && title.length > LIMITS.titleChars) throw new Error(`标题超过 ${LIMITS.titleChars} 字`);
2896
+ if (body && Buffer.byteLength(body) > LIMITS.entryBytes) throw new Error(`正文超过单条 ${LIMITS.entryBytes} 字节`);
2897
+ return withLock(scope, () => {
2898
+ if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
2899
+ try {
2900
+ const result = state.transact(scope, { operationId }, (draft) => {
2901
+ const entry = draft.entries.find((row) => row.id === id && row.status === 'active');
2902
+ if (!entry || !isExperimentalRecord(toFlat(entry))) throw new Error('没有这条实验规则');
2903
+ if (!Number.isInteger(expectedRevision) || Number(entry.revision) !== expectedRevision) throw new Error('这条规则刚被改过,先刷新再保存');
2904
+ const flat = toFlat(entry);
2905
+ const nextTitle = title || flat.title;
2906
+ const nextBody = body || flat.body;
2907
+ if (nextTitle === flat.title && nextBody === flat.body) return { changed: false };
2908
+ const nextCurrent = { ...entry.current, title: nextTitle, body: nextBody };
2909
+ const learningState = { ...(flat.learning || {}) };
2910
+ if (!learningState.specDigest) learningState.specDigest = learning.specDigestOf(flat);
2911
+ learningState.versions = [...(Array.isArray(learningState.versions) ? learningState.versions : []),
2912
+ { digest: learningState.specDigest, body: flat.body, at }].slice(-learning.LIMITS.versions);
2913
+ learningState.specDigest = learning.specDigestOf(nextCurrent);
2914
+ learningState.supportedEpisodeIds = [];
2915
+ learningState.evidence = [];
2916
+ learningState.updatedAt = at;
2917
+ if (learningState.state === 'active') {
2918
+ learningState.state = 'trial';
2919
+ learningState.trialUntil = new Date(now + learning.LIMITS.trialMs).toISOString();
2920
+ }
2921
+ if (learningState.state === 'paused') {
2922
+ learningState.blockedReason = 'edited';
2923
+ learningState.pausedAt = at;
2924
+ learningState.pausedBy = 'management';
2925
+ }
2926
+ entry.current = nextCurrent;
2927
+ entry.maintenance = { ...(entry.maintenance || {}), learning: learningState };
2928
+ entry.revision = Number(entry.revision) + 1;
2929
+ entry.updatedAt = at;
2930
+ return { changed: true, kind: 'learning_edit' };
2931
+ });
2932
+ return { ok: true, changed: result.changed === true, replayed: result.replayed === true };
2933
+ } catch (error) {
2934
+ if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
2935
+ return { ok: false, reason: error instanceof Error ? error.message : '没有保存' };
2936
+ }
2937
+ });
2938
+ }
2939
+
2940
+ /** 采纳:候选/试用转为生效,默认在适用任务中参与召回;可选同时保存改过的标题或正文。 */
2941
+ export function adoptLearningRule(scope, id, options = {}) {
2942
+ const expectedRevision = Number(options.expectedRevision);
2943
+ const operationId = String(options.operationId || '');
2944
+ const now = options.now || Date.now();
2945
+ const at = new Date(now).toISOString();
2946
+ const title = typeof options.title === 'string' ? options.title.trim() : '';
2947
+ const body = typeof options.body === 'string' ? options.body.trim() : '';
2948
+ if (!Number.isInteger(expectedRevision) || expectedRevision < 1) throw new Error('缺少版本,先刷新');
2949
+ if (title && title.length > LIMITS.titleChars) throw new Error(`标题超过 ${LIMITS.titleChars} 字`);
2950
+ if (body && Buffer.byteLength(body) > LIMITS.entryBytes) throw new Error(`正文超过单条 ${LIMITS.entryBytes} 字节`);
2951
+ return withLock(scope, () => {
2952
+ if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
2953
+ try {
2954
+ const result = state.transact(scope, { operationId }, (draft) => {
2955
+ const entry = draft.entries.find((row) => row.id === id && row.status === 'active');
2956
+ if (!entry || !isExperimentalRecord(toFlat(entry))) throw new Error('没有这条实验规则');
2957
+ if (Number(entry.revision) !== expectedRevision) throw new Error('这条规则刚被改过,先刷新再采纳');
2958
+ const flat = toFlat(entry);
2959
+ const learningState = { ...(flat.learning || {}) };
2960
+ if (learningState.state === 'active' && !title && !body) return { changed: false };
2961
+ const nextTitle = title || flat.title;
2962
+ const nextBody = body || flat.body;
2963
+ const nextCurrent = { ...entry.current, title: nextTitle, body: nextBody };
2964
+ if (!learningState.specDigest) learningState.specDigest = learning.specDigestOf(flat);
2965
+ if (nextBody !== flat.body || nextTitle !== flat.title) {
2966
+ learningState.versions = [...(Array.isArray(learningState.versions) ? learningState.versions : []),
2967
+ { digest: learningState.specDigest, body: flat.body, at }].slice(-learning.LIMITS.versions);
2968
+ learningState.specDigest = learning.specDigestOf(nextCurrent);
2969
+ learningState.supportedEpisodeIds = [];
2970
+ learningState.evidence = [];
2971
+ }
2972
+ learningState.state = 'active';
2973
+ delete learningState.trialUntil;
2974
+ delete learningState.blockedReason;
2975
+ delete learningState.pausedAt;
2976
+ delete learningState.pausedBy;
2977
+ learningState.updatedAt = at;
2978
+ entry.current = nextCurrent;
2979
+ entry.maintenance = { ...(entry.maintenance || {}), learning: learningState };
2980
+ entry.revision = Number(entry.revision) + 1;
2981
+ entry.updatedAt = at;
2982
+ return { changed: true, kind: 'learning_adopt' };
2983
+ });
2984
+ return { ok: true, changed: result.changed === true, replayed: result.replayed === true };
2985
+ } catch (error) {
2986
+ if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
2987
+ return { ok: false, reason: error instanceof Error ? error.message : '没有采纳' };
2988
+ }
2989
+ });
2990
+ }
2991
+
2722
2992
  /** 结构化负反馈先在本地暂停命中的试用规则,再决定是否调用整理器。 */
2723
2993
  export function pauseLearningRules(scope, ids, options = {}) {
2724
2994
  const targets = Array.isArray(ids) ? ids.filter(Boolean).slice(0, 12) : [];
2725
2995
  if (!targets.length) return { ok: true, changed: 0 };
2726
2996
  const at = new Date(options.now || Date.now()).toISOString();
2727
2997
  return withLock(scope, () => {
2998
+ if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
2728
2999
  try {
2729
3000
  const result = state.transact(scope, { operationId: options.operationId || '' }, (draft) => {
2730
3001
  let changed = 0;
@@ -2754,6 +3025,7 @@ export function resumeLearningRule(scope, id, options = {}) {
2754
3025
  const operationId = String(options.operationId || '');
2755
3026
  const at = new Date(options.now || Date.now()).toISOString();
2756
3027
  return withLock(scope, () => {
3028
+ if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
2757
3029
  try {
2758
3030
  const result = state.transact(scope, { operationId }, (draft) => {
2759
3031
  const entry = draft.entries.find((row) => row.id === id && row.status === 'active');
@@ -2780,6 +3052,7 @@ export function expireLearning(scope, options = {}) {
2780
3052
  const now = options.now || Date.now();
2781
3053
  const at = new Date(now).toISOString();
2782
3054
  return withLock(scope, () => {
3055
+ if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
2783
3056
  try {
2784
3057
  const result = state.transact(scope, { operationId: options.operationId || '' }, (draft) => {
2785
3058
  const evolution = draft.experimental && draft.experimental.projectEvolution;