dsh-lite-memory 0.18.32 → 0.18.34

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.
@@ -0,0 +1,7 @@
1
+ # 0.18.33:作品规则不串到别的任务
2
+
3
+ - 作品里学到的规范自动带上当时的任务范围:模型写成「全项目」也收窄成任务条件,不再一条代码习惯跑进数据库任务。
4
+ - 收集时固定的任务范围只补不丢:整理器漏写时补回,写成别的条件时自动合并,不整条作废。
5
+ - 生命周期先于适用范围:归档、被取代、过期的条目读取时如实报状态,不再显示成现行规则,也不回写 `lastUsed`。
6
+ - 空正文读取只回标题与状态,不占正文额度、不计引用、不算「用过」。
7
+ - 没有可用模型时不先扣自进化滚动额度,避免扣了却发不出调用。
@@ -0,0 +1,11 @@
1
+ # 0.18.34:学到的东西能落下来,续句和审批算同一件事
2
+
3
+ - 首次在空项目里学到的规则不再因为“任务范围不在本项目”整条作废:收集时的任务范围被信任并原样保留,模型自己编的范围仍要校验。
4
+ - 条件按 key 唯一合并:同一个条件键两个值明确判冲突,不再先写成 active、再发现不适用;收集时的阶段只作上下文,不混进适用范围。
5
+ - 待裁决另存不再丢 `selection`、`triggers`、`links`:草稿审阅看到的就是最终会写下的内容。
6
+ - 任务身份独立于召回快照:正常轮次结束后的“继续”仍接上一次任务;上一轮零召回时,“继续”也不会把更早的记忆带回来。
7
+ - 历史线索以任务起始消息为边界:只补本任务内发生的动作,不把上一个任务的工具调用当线索。
8
+ - 工具动作后最多补查一次,且和首次召回共用一个请求额度。
9
+ - 真正交付给模型的卡片才更新 `lastUsed`:准备阶段被丢弃、超出预算、已过期的卡片不再算“用过”。
10
+ - 生命周期先于主题裁决:过期/被取代的条目不再参与同主题分组,不能把现行条目标成冲突。
11
+ - 审批原子化:待裁决的版本检查、另存、出队在同一临界区;旧面板不能批准新草稿,也不能删掉刚更新的待裁决。
package/index.js CHANGED
@@ -1157,6 +1157,8 @@ export function apply(ctx, config) {
1157
1157
  const prepared = new WeakMap();
1158
1158
  /** 最近一次请求的任务身份:本轮没注入任何内容时 prepared 会清掉,补查仍需要知道上一轮任务。 */
1159
1159
  const lastTask = new Map();
1160
+ /** 任务本身也要独立于 prepared:轮次收尾只清投影,不把「刚才在做什么」一起清掉。 */
1161
+ const recallTasks = new Map();
1160
1162
  /** turn-stopping 只回 signal,不带头 agent;这里记 signal → agent 供收尾清理。 */
1161
1163
  const signalAgents = new Map();
1162
1164
  const generations = new WeakMap();
@@ -1299,6 +1301,8 @@ export function apply(ctx, config) {
1299
1301
  try { index = loadIndex(source); } catch { continue; }
1300
1302
  for (const item of index.entries) {
1301
1303
  if (!item || !item.title || item.status === 'archived') continue;
1304
+ /** 生命周期先于主题裁决:过期/被取代的条目不再参与分组,不能把现行条目标成冲突。 */
1305
+ if (view.lifecycleStatusOf(item)) continue;
1302
1306
  /** 实验来源只走任务绑定后的专用读取,不混进普通自动召回。 */
1303
1307
  if (item.origin === 'project_evolution') continue;
1304
1308
  if (seen.has(item.id)) continue;
@@ -1359,7 +1363,7 @@ export function apply(ctx, config) {
1359
1363
  lines.push(line);
1360
1364
  cards.push({ id: card.id, revision, scope: card.scope, title: card.title });
1361
1365
  /** 卡片行与到期时间一起留到最后输出:中间隔了系统提示组装,不能拿投影时的判断当现在。 */
1362
- linesMeta.push({ line, validUntil: row.record.validUntil || '' });
1366
+ linesMeta.push({ id: card.id, line, validUntil: row.record.validUntil || '' });
1363
1367
  }
1364
1368
  const finalText = `${RECALL_HEADER}\n${lines.join('\n')}`;
1365
1369
  if (auto) {
@@ -1916,6 +1920,8 @@ export function apply(ctx, config) {
1916
1920
  const now = Date.now();
1917
1921
  const live = state.lines.filter((row) => !row.validUntil || now <= Date.parse(String(row.validUntil)));
1918
1922
  normalText = live.length ? `${RECALL_HEADER}\n${live.map((row) => row.line).join('\n')}` : '';
1923
+ /** 真正交付的卡片才记使用:到期被撤下的、准备后被丢弃的都不算。 */
1924
+ if (live.length && state.scope) markUsed(state.scope, live.map((row) => row.id).filter(Boolean));
1919
1925
  /** 投影时还在、发出时已过期:回执跟着真实输出改,别在面板上说「提醒了 2 条」而正文是空的。 */
1920
1926
  if (state.scope && live.length !== state.cards.length) {
1921
1927
  noteRecall(state.scope, state.sessionId, {
@@ -2449,6 +2455,8 @@ export function apply(ctx, config) {
2449
2455
  }
2450
2456
  const revision = record.revision || 1;
2451
2457
  const body = String(record.body ?? '');
2458
+ /** 空正文只回标题与状态:没有正文可交付,就不占正文额度、不算引用。 */
2459
+ const hasBody = body.trim() !== '';
2452
2460
  const value = {
2453
2461
  title: record.title,
2454
2462
  when: record.when || '',
@@ -2474,14 +2482,14 @@ export function apply(ctx, config) {
2474
2482
  return receipt;
2475
2483
  }
2476
2484
  const bytes = jsonBytes(value);
2477
- if (!affordableTool(win, bytes, 1)) return { scope: '', status: 'budget_exhausted' };
2485
+ if (!affordableTool(win, bytes, hasBody ? 1 : 0)) return { scope: '', status: 'budget_exhausted' };
2478
2486
  win.bytes += bytes;
2479
2487
  win.toolBytes += bytes;
2480
- win.bodies += 1;
2488
+ if (hasBody) win.bodies += 1;
2481
2489
  win.reads += 1;
2482
- win.readBodies.set(record.id, revision);
2490
+ if (hasBody) win.readBodies.set(record.id, revision);
2483
2491
  }
2484
- recordCitations(scope, sessionIdOf(execution), [record]);
2492
+ if (hasBody) recordCitations(scope, sessionIdOf(execution), [record]);
2485
2493
  return value;
2486
2494
  },
2487
2495
  }), 'lite-memory: memory_read');
@@ -2657,6 +2665,7 @@ export function apply(ctx, config) {
2657
2665
  const fresh = freshHumans(sessionId, humans);
2658
2666
  const previous = prepared.get(agent);
2659
2667
  const sameSession = Boolean(previous && previous.sessionId === sessionId);
2668
+ const sessionTask = recallTasks.get(sessionId) || null;
2660
2669
 
2661
2670
  if (fresh.length === 0) {
2662
2671
  /** 工具续步:内容冻结,只把快照绑到本步的 signal;盘上版本变了才重投影。 */
@@ -2699,6 +2708,32 @@ export function apply(ctx, config) {
2699
2708
  }
2700
2709
  }
2701
2710
  }
2711
+ /**
2712
+ * 工具续步里没有新的真人原话,但刚执行的动作本身就是线索。
2713
+ * 只补一次、共用一个请求额度,补到的卡片追加到已发布快照,超出仍由预算挡。
2714
+ */
2715
+ const stepMessages = Array.isArray(decision.messages) ? decision.messages : [];
2716
+ const seenAt = Number.isInteger(previous.seenMessages) ? previous.seenMessages : 0;
2717
+ const stepActions = seenAt < stepMessages.length ? stepMessages.slice(seenAt) : [];
2718
+ previous.seenMessages = stepMessages.length;
2719
+ const actionQuery = previous.recalledContext && !previous.actionRetried
2720
+ ? view.actionTerms(stepActions, { limit: 10 }).slice(0, 240) : '';
2721
+ if (actionQuery) {
2722
+ previous.actionRetried = true;
2723
+ const win = windowOf(agent);
2724
+ if (win && generation === generations.get(agent) && !signal?.aborted) {
2725
+ const retry = projectRecall(scope, `${actionQuery} ${previous.query}`.trim().slice(0, RECALL_QUERY_CHARS), previous.recalledContext, win, previous.runtimeHeld || null);
2726
+ const known = new Set((previous.cards || []).map((card) => card.id));
2727
+ const added = (retry.cards || []).filter((card) => !known.has(card.id));
2728
+ if (added.length) {
2729
+ const addedIds = new Set(added.map((card) => card.id));
2730
+ const extraLines = (retry.lines || []).filter((row, index) => addedIds.has(retry.cards[index] && retry.cards[index].id));
2731
+ previous.cards = [...(previous.cards || []), ...added];
2732
+ previous.lines = [...(previous.lines || []), ...extraLines];
2733
+ previous.text = `${RECALL_HEADER}\n${previous.lines.map((row) => row.line).join('\n')}`;
2734
+ }
2735
+ }
2736
+ }
2702
2737
  return decision;
2703
2738
  }
2704
2739
 
@@ -2714,9 +2749,8 @@ export function apply(ctx, config) {
2714
2749
  /** 工具模式也要记下本轮真人原话:只用于核对绑定,不自动注入。 */
2715
2750
  const said = view.newestUserQuery(fresh).slice(0, RECALL_QUERY_CHARS);
2716
2751
  /** 「继续」这类续说没有检索词:带上同会话上一轮原话,否则这一轮必然召回为空。 */
2717
- const carried = sameSession && previous?.query
2718
- && (view.isContinuationQuery(said) || view.isEllipticalContinuation(said))
2719
- ? previous.query : '';
2752
+ const carried = (view.isContinuationQuery(said) || view.isEllipticalContinuation(said))
2753
+ ? (previous?.query || sessionTask?.query || '') : '';
2720
2754
  let query = (carried ? `${said} ${carried}` : said).trim().slice(0, RECALL_QUERY_CHARS);
2721
2755
  if (win && query) win.query = query;
2722
2756
 
@@ -2764,12 +2798,22 @@ export function apply(ctx, config) {
2764
2798
  episodeId: (runtime && runtime.held && runtime.held.episodeId) || '',
2765
2799
  };
2766
2800
  const rememberedTask = lastTask.get(sessionId) || null;
2767
- const previousTask = previous && previous.task ? previous.task : rememberedTask;
2768
- const sameTask = Boolean(sameSession || rememberedTask)
2769
- && !view.isExplicitSwitch(said, previous && previous.query ? previous.query : '')
2801
+ const previousTask = (previous && previous.task) || (sessionTask && sessionTask.task) || rememberedTask;
2802
+ const previousText = (previous && previous.query) || (sessionTask && sessionTask.query) || '';
2803
+ const sameTask = Boolean(sameSession || sessionTask || rememberedTask)
2804
+ && !view.isExplicitSwitch(said, previousText)
2770
2805
  && view.sameRecallTask(previousTask, currentTask);
2806
+ const taskStartMessageId = sameTask && sessionTask && sessionTask.startMessageId
2807
+ ? sessionTask.startMessageId : ids[0];
2771
2808
  lastTask.set(sessionId, currentTask);
2809
+ recallTasks.set(sessionId, {
2810
+ query,
2811
+ task: currentTask,
2812
+ startMessageId: taskStartMessageId,
2813
+ updatedAt: Date.now(),
2814
+ });
2772
2815
  while (lastTask.size > 64) lastTask.delete(lastTask.keys().next().value);
2816
+ while (recallTasks.size > 64) recallTasks.delete(recallTasks.keys().next().value);
2773
2817
  /**
2774
2818
  * 预防型记忆约束的是「下一步动作」,词面常写在上一轮助手的话和工具调用里,用户这句话反而没提。
2775
2819
  * 续句或没有具体动作的短问句,直接把最近的动作线索并进同一份查询:只查一次,不额外占预算。
@@ -2782,7 +2826,11 @@ export function apply(ctx, config) {
2782
2826
  }
2783
2827
  return messages;
2784
2828
  })();
2785
- const trail = sameTask ? view.actionTerms(humanTail, { limit: 12 }).slice(0, 240) : '';
2829
+ /** 只拿当前任务开始之后的消息,旧任务的工具痕迹不能混进新任务。 */
2830
+ const taskStart = taskStartMessageId
2831
+ ? decision.messages.findIndex((message) => String(message?.id ?? '') === taskStartMessageId) : -1;
2832
+ const tailMessages = sameTask && taskStart >= 0 ? decision.messages.slice(taskStart + 1) : humanTail;
2833
+ const trail = sameTask ? view.actionTerms(tailMessages, { limit: 12 }).slice(0, 240) : '';
2786
2834
  const trailInQuery = Boolean(trail) && (Boolean(carried) || !view.hasConcreteAction(said));
2787
2835
  if (trailInQuery) {
2788
2836
  query = `${query} ${trail}`.trim().slice(0, RECALL_QUERY_CHARS);
@@ -2858,10 +2906,14 @@ export function apply(ctx, config) {
2858
2906
  fingerprint,
2859
2907
  controlEpoch,
2860
2908
  evolutionEpoch,
2909
+ recalledContext,
2910
+ runtimeHeld: runtime && runtime.held ? runtime.held : null,
2911
+ seenMessages: Array.isArray(decision.messages) ? decision.messages.length : 0,
2912
+ actionRetried: false,
2861
2913
  at: Date.now(),
2862
2914
  };
2863
2915
  prepared.set(agent, state);
2864
- if (snapshot && snapshot.ids && snapshot.ids.length) markUsed(scope, snapshot.ids);
2916
+ /** 真正输出给模型时才算使用;准备阶段被丢弃/中止的快照不能改 lastUsed。 */
2865
2917
  payloadBytes.set(sessionId, win.bytes);
2866
2918
  while (payloadBytes.size > 64) payloadBytes.delete(payloadBytes.keys().next().value);
2867
2919
  if (signal) signalAgents.set(signal, agent);
@@ -2959,6 +3011,9 @@ export function apply(ctx, config) {
2959
3011
  const operationId = `learn_${crypto.createHash('sha256')
2960
3012
  .update(`${scope.key}|${candidate.episodeId}|${candidate.messageIds.join(',')}`)
2961
3013
  .digest('hex').slice(0, 16)}`;
3014
+ /** 没有可用模型就别先扣滚动额度:扣了却发不出调用,额度会白烧一整窗口。 */
3015
+ const llm = getService('llm');
3016
+ if (!llm || typeof llm.prepareCall !== 'function') return;
2962
3017
  const begin = beginLearningAttempt(scope, { episodeId: candidate.episodeId, operationId: `${operationId}_start`, now: Date.now() });
2963
3018
  if (!begin || begin.ok !== true || begin.replayed) return;
2964
3019
  const ranges = rangesOf(scope);
@@ -2977,11 +3032,6 @@ export function apply(ctx, config) {
2977
3032
  revision: record.revision || 1,
2978
3033
  specDigest: learning.specDigestOf(record),
2979
3034
  }));
2980
- const llm = getService('llm');
2981
- if (!llm || typeof llm.prepareCall !== 'function') {
2982
- recordLearningFailure(scope, { operationId: `${operationId}_fail`, episodeId: candidate.episodeId, reason: '宿主没有可用模型', epoch: begin.epoch });
2983
- return;
2984
- }
2985
3035
  const controller = new AbortController();
2986
3036
  learningAborts.set(scope.key, controller);
2987
3037
  const deadline = setTimeout(() => { try { controller.abort(); } catch { /* already aborted */ } }, learning.LIMITS.timeoutMs);
@@ -3125,7 +3175,7 @@ export function apply(ctx, config) {
3125
3175
  clearPrepared(agent);
3126
3176
  windows.delete(agentKey(agent));
3127
3177
  const sessionId = String(agent?.session?.id ?? '');
3128
- if (sessionId) seenHumans.delete(sessionId);
3178
+ if (sessionId) { seenHumans.delete(sessionId); recallTasks.delete(sessionId); }
3129
3179
  });
3130
3180
 
3131
3181
  /**
package/learning.js CHANGED
@@ -370,9 +370,11 @@ export function pendingChangeFrom(feedback = {}) {
370
370
  */
371
371
  const expected = feedback.expectedTarget && typeof feedback.expectedTarget === 'object' ? feedback.expectedTarget : null;
372
372
  const taskKind = String(feedback.cue?.taskKind || expected?.taskKind || '').trim();
373
- const expectedConditions = ['taskKind', 'seriesId', 'phase']
373
+ /** 采集阶段是「在哪儿学到」的证据,不是长期适用范围:只把任务身份与系列带进条件。 */
374
+ const collectedConditions = ['taskKind', 'seriesId']
374
375
  .filter((key) => String(expected?.[key] || '').trim())
375
376
  .map((key) => ({ key, value: String(expected[key]).trim() }));
377
+ const expectedConditions = collectedConditions;
376
378
  /**
377
379
  * 兜底不重新推导作用域:候选收集时已经固定 expectedTarget 就照抄(只能沿用或收窄),
378
380
  * 没有 expectedTarget 的旧调用才退回原句判断。
@@ -623,9 +625,32 @@ export function validateChange(change, { refs, rules, ranges, expectedTarget } =
623
625
  }
624
626
  /** 收集时固定的作用域只能沿用或收窄:整理器不能把待确认候选抬成全项目规则。 */
625
627
  const expected = expectedTarget && typeof expectedTarget === 'object' ? expectedTarget : null;
626
- const expectedConditions = ['taskKind', 'seriesId', 'phase']
628
+ /** 采集阶段是「在哪儿学到」的证据,不是长期适用范围:只把任务身份与系列带进条件。 */
629
+ const collectedConditions = ['taskKind', 'seriesId']
627
630
  .filter((key) => String(expected?.[key] || '').trim())
628
631
  .map((key) => ({ key, value: String(expected[key]).trim() }));
632
+ const expectedConditions = collectedConditions;
633
+ /** 作品里学到的规范必须带上当时的任务范围:模型想写成全项目时收窄成条件规则,而不是丢掉这条反馈。 */
634
+ const narrowToEpisode = target === 'new' && expected?.scope === 'episode' && expectedConditions.length > 0;
635
+ if (narrowToEpisode) {
636
+ if (applicability === undefined || applicability === 'all_in_scope') applicability = 'conditional';
637
+ const merged = conditions !== undefined ? [...conditions] : [];
638
+ for (const row of expectedConditions) {
639
+ const sameKey = merged.find((item) => item.key === row.key);
640
+ if (sameKey) {
641
+ if (sameKey.value !== row.value) return { ok: false, reason: '条件与收集时的任务范围冲突' };
642
+ continue;
643
+ }
644
+ /** 采集条件来自已绑定的真实任务,不要求它先出现在历史规则里;模型新提的条件仍走范围校验。 */
645
+ merged.push(row);
646
+ }
647
+ const keys = new Set();
648
+ for (const row of merged) {
649
+ if (keys.has(row.key)) return { ok: false, reason: '同一条件键给了两个值' };
650
+ keys.add(row.key);
651
+ }
652
+ conditions = merged;
653
+ }
629
654
  const expectedApplicability = expected
630
655
  ? (expected.applicability === 'all_in_scope'
631
656
  ? 'all_in_scope'
@@ -638,6 +663,12 @@ export function validateChange(change, { refs, rules, ranges, expectedTarget } =
638
663
  return { ok: false, reason: '作品规则不能抬成全项目' };
639
664
  }
640
665
  const effectiveConditions = conditions !== undefined ? conditions : normalizeConditions(existing?.conditions);
666
+ /** 条件键必须唯一:phase=script 与 phase=brief 同时存在,规则会显示生效却永远不适用。 */
667
+ const conditionKeys = new Set();
668
+ for (const row of effectiveConditions) {
669
+ if (conditionKeys.has(row.key)) return { ok: false, reason: '同一条件键给了两个值' };
670
+ conditionKeys.add(row.key);
671
+ }
641
672
  if (applicability === 'all_in_scope' && effectiveConditions.length) {
642
673
  return { ok: false, reason: '全项目规则不能带条件' };
643
674
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lite-memory",
3
- "version": "0.18.32",
3
+ "version": "0.18.34",
4
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",
package/store.js CHANGED
@@ -1653,23 +1653,27 @@ export function readEntry(scope, id, options = {}) {
1653
1653
  if (!record && !archived) continue;
1654
1654
  if (!readableRecord(record || archived, options, index)) continue;
1655
1655
  const body = readBody(source, id);
1656
+ /** 生命周期先判定:过期/被取代的条目不能再报成现行规则。 */
1657
+ const lifecycle = view.lifecycleStatusOf(record || archived, options.now || Date.now());
1656
1658
  return {
1657
1659
  source,
1658
1660
  archived: Boolean(archived),
1661
+ lifecycle,
1659
1662
  record: {
1660
1663
  ...(record || archived),
1661
1664
  scope: source.key === 'global' ? 'global' : 'project',
1662
1665
  body,
1663
- status: archived ? 'archived' : view.statusOf(record, options.context || {}),
1666
+ status: lifecycle || view.statusOf(record, options.context || {}),
1664
1667
  },
1665
1668
  };
1666
1669
  }
1667
1670
  return null;
1668
1671
  });
1669
1672
  if (!found) return null;
1670
- /** 读过才算用过;归档、被取代、过期或条件不匹配的读取都不算「在用」。 */
1673
+ /** 读过才算用过;归档、被取代、过期、空正文或条件不匹配的读取都不算「在用」。 */
1671
1674
  const countsAsUsage = typeof options.usage !== 'function' || options.usage(found.record) !== false;
1672
- if (!found.archived && countsAsUsage) touchUsed(found.source, [id]);
1675
+ const hasBody = typeof found.record.body === 'string' && found.record.body.trim() !== '';
1676
+ if (!found.archived && !found.lifecycle && hasBody && countsAsUsage) touchUsed(found.source, [id]);
1673
1677
  return found.record;
1674
1678
  }
1675
1679
 
@@ -2734,7 +2738,7 @@ function bindSameTopic(projectScope, ref) {
2734
2738
  const CONFLICT_LIMIT = 20;
2735
2739
 
2736
2740
  /** 待办里留一份可重放的草稿:面板能直接另存,不用用户回忆刚才想记什么。 */
2737
- const CONFLICT_DRAFT_KEYS = ['title', 'summary', 'body', 'when', 'kind', 'tags', 'conditions', 'applicability', 'topicKey', 'source', 'origin'];
2741
+ const CONFLICT_DRAFT_KEYS = ['title', 'summary', 'body', 'when', 'kind', 'tags', 'conditions', 'applicability', 'selection', 'triggers', 'links', 'topicKey', 'source', 'origin'];
2738
2742
  function conflictDraftOf(draft) {
2739
2743
  const out = {};
2740
2744
  if (!draft || typeof draft !== 'object') return out;
@@ -2750,6 +2754,10 @@ function conflictDraftOf(draft) {
2750
2754
  else if (key === 'conditions') out.conditions = Array.isArray(value) ? value.slice(0, 8) : undefined;
2751
2755
  else if (key === 'applicability') out.applicability = value;
2752
2756
  else if (key === 'topicKey') out.topicKey = normalizeText(value, 80);
2757
+ /** 适用范围、召回触发词与关联整份重放:经过待裁决不能改变语义。 */
2758
+ else if (key === 'selection') out.selection = value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : null;
2759
+ else if (key === 'triggers') out.triggers = Array.isArray(value) ? value.slice(0, 8).map((item) => normalizeText(item, 40)).filter(Boolean) : [];
2760
+ else if (key === 'links') out.links = Array.isArray(value) ? value.slice(0, 3).map((item) => (item && typeof item === 'object' ? { ...item } : null)).filter(Boolean) : [];
2753
2761
  else if (key === 'source') out.source = value;
2754
2762
  else if (key === 'origin') out.origin = value;
2755
2763
  }
@@ -2817,30 +2825,35 @@ function staleDraft(row, ref) {
2817
2825
 
2818
2826
  /** 确认另存:用队列里的草稿按 distinct 重放一次,成功后按 id 出队。 */
2819
2827
  export function resolveConflict(scope, ref, options = {}) {
2820
- const row = conflictRowLocked(scope, ref);
2821
- if (!row) return { ok: false, reason: '这条待裁决已经不在了,刷新面板看看' };
2822
- if (staleDraft(row, ref)) return { ok: false, reasonCode: 'stale_draft', reason: '这条待裁决又更新了,刷新后再处理' };
2823
- const draft = { ...(row.draft || {}), resolution: 'distinct', writeMode: options.writeMode === 'append' ? 'append' : 'patch' };
2824
- if (scope.key === 'global') draft.scope = 'global';
2825
- else draft.scope = 'project';
2826
- if (!draft.title) draft.title = row.title;
2827
- const result = saveEntry(scope, draft);
2828
- if (result && result.needsResolution) return { ok: false, reason: '还是相近:换一个标题或等确认', result };
2829
- if (result && result.needsScope) {
2830
- const reason = result.reasonCode === 'memory_disabled' ? '这层记忆已关闭' : '这条记忆的适用范围还没定,先说明当前环境';
2831
- return { ok: false, reason, result };
2832
- }
2833
- clearConflictLocked(scope, { id: row.id });
2834
- return { ok: true, result, record: result && result.record };
2828
+ /** 版本检查、另存和出队必须在同一临界区:中间被新反馈改写时,旧面板不能批准新草稿。 */
2829
+ return withLocks([scope, globalScope()], () => {
2830
+ const row = conflictRowLocked(scope, ref);
2831
+ if (!row) return { ok: false, reason: '这条待裁决已经不在了,刷新面板看看' };
2832
+ if (staleDraft(row, ref)) return { ok: false, reasonCode: 'stale_draft', reason: '这条待裁决又更新了,刷新后再处理' };
2833
+ const draft = { ...(row.draft || {}), resolution: 'distinct', writeMode: options.writeMode === 'append' ? 'append' : 'patch' };
2834
+ if (scope.key === 'global') draft.scope = 'global';
2835
+ else draft.scope = 'project';
2836
+ if (!draft.title) draft.title = row.title;
2837
+ const result = saveEntry(scope, draft);
2838
+ if (result && result.needsResolution) return { ok: false, reason: '还是相近:换一个标题或等确认', result };
2839
+ if (result && result.needsScope) {
2840
+ const reason = result.reasonCode === 'memory_disabled' ? '这层记忆已关闭' : '这条记忆的适用范围还没定,先说明当前环境';
2841
+ return { ok: false, reason, result };
2842
+ }
2843
+ clearConflictLocked(scope, { id: row.id });
2844
+ return { ok: true, result, record: result && result.record };
2845
+ });
2835
2846
  }
2836
2847
 
2837
2848
  /** 删掉待裁决:用户决定不再另存。 */
2838
2849
  export function dropConflict(scope, ref) {
2839
- const row = conflictRowLocked(scope, ref);
2840
- if (!row) return { ok: false, reason: '这条待裁决已经不在了,刷新面板看看' };
2841
- if (staleDraft(row, ref)) return { ok: false, reasonCode: 'stale_draft', reason: '这条待裁决又更新了,刷新后再处理' };
2842
- clearConflictLocked(scope, { id: row.id });
2843
- return { ok: true };
2850
+ return withLocks([scope, globalScope()], () => {
2851
+ const row = conflictRowLocked(scope, ref);
2852
+ if (!row) return { ok: false, reason: '这条待裁决已经不在了,刷新面板看看' };
2853
+ if (staleDraft(row, ref)) return { ok: false, reasonCode: 'stale_draft', reason: '这条待裁决又更新了,刷新后再处理' };
2854
+ clearConflictLocked(scope, { id: row.id });
2855
+ return { ok: true };
2856
+ });
2844
2857
  }
2845
2858
 
2846
2859
  /** 当前层还有几条没处理的冲突。 */
@@ -434,7 +434,32 @@ await test('S12 待裁决留可重放草稿,面板确认另存后才落库', (
434
434
  assert.equal(pendingConflictsOf(scope), 0);
435
435
  assert.equal(loadIndex(scope).entries.length, 1);
436
436
  assert.equal(loadIndex(scope).entries[0].body, '项目补充');
437
+ assert.equal(loadIndex(scope).entries[0].when, '本仓库', 'when 经过待裁决不能丢');
437
438
  assert.equal(dropConflict(scope, '不存在').ok, false);
438
439
  });
439
440
 
441
+ await test('S12 经过待裁决另存不改变适用范围、触发词和关联', () => {
442
+ const scope = fresh('conflict-semantics');
443
+ const globalOne = saveEntry(globalScope(), { title: '发票抬头规则', summary: '开票信息固定', body: '全局版本', resolution: 'distinct' });
444
+ const linked = saveEntry(globalScope(), { title: '生效核验顺序', summary: '先验规则再验页面', body: '先验规则', resolution: 'distinct' });
445
+ const near = saveEntry(scope, {
446
+ title: '发票抬头规则', summary: '本项目发票只认这套抬头', body: '项目版本',
447
+ selection: { mode: 'complete', artifactKey: 'artifact-invoice-v1' },
448
+ triggers: ['artifact-hookword'],
449
+ links: [{ type: 'related', id: linked.record.id }],
450
+ topicKey: 'invoice',
451
+ });
452
+ assert.equal(near.needsResolution, true);
453
+ const [row] = conflictsOf(scope);
454
+ assert.deepEqual(row.draft.selection, { mode: 'complete', artifactKey: 'artifact-invoice-v1', items: [] });
455
+ assert.deepEqual(row.draft.triggers, ['artifact-hookword']);
456
+ assert.deepEqual(row.draft.links, [{ type: 'related', id: linked.record.id }]);
457
+ const kept = resolveConflict(scope, { id: row.id, expectedRevision: row.revision });
458
+ assert.equal(kept.ok, true, JSON.stringify(kept));
459
+ const [record] = loadIndex(scope).entries;
460
+ assert.deepEqual(record.selection, { mode: 'complete', artifactKey: 'artifact-invoice-v1', items: [] });
461
+ assert.deepEqual(record.triggers, ['artifact-hookword']);
462
+ assert.deepEqual(record.links, [{ type: 'related', id: linked.record.id }]);
463
+ });
464
+
440
465
  console.log(`crossing.test.mjs: ${passed} passed`);
@@ -8,6 +8,7 @@ const store = await import('../store.js');
8
8
  const learning = await import('../learning.js');
9
9
  const anchor = await import('../anchor.js');
10
10
  const state = await import('../state.js');
11
+ const view = await import('../view.js');
11
12
 
12
13
  let passed = 0;
13
14
  const test = (name, fn) => {
@@ -996,18 +997,93 @@ test('L03d 作用域在收集时固定,整理只能沿用或收窄', () => {
996
997
  assert.equal(learning.validateChange({ ...base, applicability: 'all_in_scope' }, { refs, rules: [], ranges: RANGES, expectedTarget: wide.expectedTarget }).ok, true);
997
998
 
998
999
  const video = { scope: 'episode', episodeId: 'ep_x', taskKind: 'video' };
1000
+ /** 作品里学到的规范:想说成全项目时收窄成任务条件,而不是整条作废。 */
999
1001
  const episodeWide = learning.validateChange({ ...base, applicability: 'all_in_scope' }, { refs, rules: [], ranges: RANGES, expectedTarget: video });
1000
- assert.equal(episodeWide.ok, false, '作品规则不能抬成全项目');
1002
+ assert.equal(episodeWide.ok, true, episodeWide.reason);
1003
+ assert.equal(episodeWide.change.applicability, 'conditional');
1004
+ assert.deepEqual(episodeWide.change.conditions, [{ key: 'taskKind', value: 'video' }]);
1001
1005
  const droppedKind = learning.validateChange({ ...base, applicability: 'conditional', conditions: [{ key: 'phase', value: 'script' }] }, { refs, rules: [], ranges: RANGES, expectedTarget: video });
1002
- assert.equal(droppedKind.ok, false, '不能删掉收集时的任务范围');
1006
+ assert.equal(droppedKind.ok, true, droppedKind.reason);
1007
+ assert.deepEqual(droppedKind.change.conditions, [{ key: 'phase', value: 'script' }, { key: 'taskKind', value: 'video' }], '缺的任务范围自动补回,不能丢');
1003
1008
  const keptKind = learning.validateChange({ ...base, applicability: 'conditional', conditions: [{ key: 'taskKind', value: 'video' }, { key: 'phase', value: 'script' }] }, { refs, rules: [], ranges: RANGES, expectedTarget: video });
1004
1009
  assert.equal(keptKind.ok, true, keptKind.reason);
1010
+ assert.deepEqual(keptKind.change.conditions, [{ key: 'taskKind', value: 'video' }, { key: 'phase', value: 'script' }]);
1005
1011
 
1006
1012
  const fallback = learning.pendingChangeFrom({ text: '以后所有开头都先给结论。', cue: { explicitLongTerm: true }, expectedTarget: narrow.expectedTarget });
1007
1013
  assert.equal(fallback.applicability, 'unresolved', '兜底不重新推导作用域');
1008
1014
  assert.equal(learning.pendingChangeFrom({ text: '以后开头都先给结论。', cue: { explicitLongTerm: true }, expectedTarget: wide.expectedTarget }).applicability, 'all_in_scope');
1009
1015
  });
1010
1016
 
1017
+ test('L03d2 代码任务学到的规则不会在数据库任务里生效', () => {
1018
+ const refs = { feedback: { u0: 'm1' }, rules: {} };
1019
+ const parsed = learning.parseOutput(JSON.stringify({
1020
+ changes: [{
1021
+ target: 'new', feedbackType: 'spec_change', topicKey: '迁移',
1022
+ currentText: '先跑迁移再改代码', applicability: 'all_in_scope', evidence: ['u0'],
1023
+ }],
1024
+ }), {
1025
+ finish: { kind: 'stop' }, refs, rules: [], ranges: { ...RANGES, taskKinds: ['code', 'database'] },
1026
+ expectedTarget: { scope: 'episode', projectId: 'p', episodeId: 'ep_code', taskKind: 'code' },
1027
+ });
1028
+ assert.equal(parsed.ok, true, parsed.reason);
1029
+ const change = parsed.changes[0];
1030
+ assert.equal(change.applicability, 'conditional');
1031
+ assert.deepEqual(change.conditions, [{ key: 'taskKind', value: 'code' }]);
1032
+ const code = view.eligibility({ id: 'mem_1', title: '迁移', status: 'active', origin: 'project_evolution', conditions: change.conditions, learning: { state: 'active' } }, { task: { kind: 'code' } }, 'auto');
1033
+ const db = view.eligibility({ id: 'mem_1', title: '迁移', status: 'active', origin: 'project_evolution', conditions: change.conditions, learning: { state: 'active' } }, { task: { kind: 'database' } }, 'auto');
1034
+ assert.equal(code.ok, true);
1035
+ assert.equal(db.ok, false);
1036
+ assert.equal(db.reason, 'condition_mismatch');
1037
+ });
1038
+
1039
+ test('L03d3 空项目的第一条反馈能带上真实任务范围保存', () => {
1040
+ const parsed = learning.parseOutput(JSON.stringify({
1041
+ changes: [{
1042
+ target: 'new', feedbackType: 'spec_change', topicKey: '前端边框',
1043
+ currentText: '前端页面默认采用细边框', applicability: 'all_in_scope', evidence: ['u0'],
1044
+ }],
1045
+ }), {
1046
+ finish: { kind: 'stop' }, refs: { feedback: { u0: 'm1' }, rules: {} }, rules: [],
1047
+ ranges: { taskKinds: [], series: [], projects: ['p'] },
1048
+ expectedTarget: { scope: 'episode', projectId: 'p', episodeId: 'ep1', taskKind: 'code', phase: 'brief' },
1049
+ });
1050
+ assert.equal(parsed.ok, true, parsed.reason);
1051
+ assert.equal(parsed.changes[0].applicability, 'conditional');
1052
+ assert.deepEqual(parsed.changes[0].conditions, [{ key: 'taskKind', value: 'code' }], '采集阶段不该锁死长期适用范围');
1053
+ });
1054
+
1055
+ test('L03d4 模型条件与采集任务范围冲突时拒绝,不生成永不生效的规则', () => {
1056
+ const parsed = learning.parseOutput(JSON.stringify({
1057
+ changes: [{
1058
+ target: 'new', feedbackType: 'spec_change', topicKey: '迁移',
1059
+ currentText: '先跑迁移再改代码', applicability: 'conditional',
1060
+ conditions: [{ key: 'taskKind', value: 'database' }], evidence: ['u0'],
1061
+ }],
1062
+ }), {
1063
+ finish: { kind: 'stop' }, refs: { feedback: { u0: 'm1' }, rules: {} }, rules: [],
1064
+ ranges: { ...RANGES, taskKinds: ['code', 'database'] },
1065
+ expectedTarget: { scope: 'episode', projectId: 'p', episodeId: 'ep1', taskKind: 'code' },
1066
+ });
1067
+ assert.equal(parsed.ok, false);
1068
+ assert.match(parsed.reason, /冲突/);
1069
+ });
1070
+
1071
+ test('L03d5 同一条件键的两个值不能都留下', () => {
1072
+ const parsed = learning.parseOutput(JSON.stringify({
1073
+ changes: [{
1074
+ target: 'new', feedbackType: 'spec_change', topicKey: '阶段冲突',
1075
+ currentText: '只在脚本阶段生效', applicability: 'conditional',
1076
+ conditions: [{ key: 'phase', value: 'script' }, { key: 'phase', value: 'brief' }], evidence: ['u0'],
1077
+ }],
1078
+ }), {
1079
+ finish: { kind: 'stop' }, refs: { feedback: { u0: 'm1' }, rules: {} }, rules: [],
1080
+ ranges: { ...RANGES },
1081
+ expectedTarget: { scope: 'episode', projectId: 'p', episodeId: 'ep1', taskKind: 'code', phase: 'brief' },
1082
+ });
1083
+ assert.equal(parsed.ok, false);
1084
+ assert.match(parsed.reason, /条件/);
1085
+ });
1086
+
1011
1087
  test('L03e 先按相关性排序再截断,输入顺序不挤掉真正相关的旧规则', () => {
1012
1088
  const rules = [];
1013
1089
  for (let index = 0; index < 32; index += 1) {
@@ -94,7 +94,10 @@ const patchState = (scope, id, patch) => {
94
94
  const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
95
95
  const entry = raw.payload.entries.find((row) => row.id === id);
96
96
  assert.ok(entry, `state 里没有 ${id}`);
97
- Object.assign(entry, patch);
97
+ for (const [key, value] of Object.entries(patch)) {
98
+ entry[key] = value && typeof value === 'object' && !Array.isArray(value)
99
+ ? { ...(entry[key] || {}), ...value } : value;
100
+ }
98
101
  raw.checksum = `sha256:${crypto.createHash('sha256').update(JSON.stringify(raw.payload)).digest('hex')}`;
99
102
  fs.writeFileSync(file, `${JSON.stringify(raw, null, 2)}\n`);
100
103
  };
@@ -337,6 +340,25 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
337
340
  assert.equal(bootA.debug.windowOf(agentReads).reads, 8);
338
341
  }
339
342
 
343
+ // A11b:准备阶段不算使用;只有真正交付给模型的卡片才更新 lastUsed。
344
+ {
345
+ const cwdUse = mkdir('delivered');
346
+ const scopeUse = store.scopeOf(cwdUse);
347
+ const only = save(scopeUse, { title: '交付才算用过的记忆', tags: ['交付测试'], summary: '只看不交付不该改变使用时间' });
348
+ const agent = makeAgent('agent-delivered', cwdUse);
349
+ const runtime = boot();
350
+ const sig = signal();
351
+ patchState(scopeUse, only.record.id, { maintenance: { lastUsed: '2020-01-01T00:00:00.000Z' } });
352
+ const saved = store.readEntry(scopeUse, only.record.id, { usage: () => false });
353
+ assert.equal(saved.lastUsed, '2020-01-01T00:00:00.000Z', 'patch 生效');
354
+ await runtime.preStep({ agent, signal: sig, turn: 'u1' }, enter(human('交付测试怎么做')));
355
+ const preparedOnly = store.readEntry(scopeUse, only.record.id, { usage: () => false });
356
+ assert.equal(preparedOnly.lastUsed, saved.lastUsed, '准备但没输出时不能改 lastUsed');
357
+ assert.match(runtime.provider.text({ agent, signal: sig }), /交付才算用过的记忆/);
358
+ const delivered = store.readEntry(scopeUse, only.record.id, { usage: () => false });
359
+ assert.notEqual(delivered.lastUsed, saved.lastUsed, '真正交付后才记使用');
360
+ }
361
+
340
362
  // A12:额度状态与冻结快照分开;清掉快照后重准备不重新分配。
341
363
  {
342
364
  const agent = makeAgent('agent-preserve', cwdA);
@@ -353,6 +375,58 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
353
375
  assert.equal(win.bytes, used, 'same window does not re-allocate for already provided cards');
354
376
  }
355
377
 
378
+ // A13b:轮次收尾清的是投影,不是任务;下一句「继续」仍要接上上一轮的原话。
379
+ {
380
+ const agent = makeAgent('agent-after-turn', cwdA);
381
+ const first = signal();
382
+ await bootA.preStep({ agent, signal: first, turn: 'n1' }, enter(human('插件改完怎么让它生效?')));
383
+ assert.ok(bootA.provider.text({ agent, signal: first }));
384
+ bootA.handlers.get('agent/turn-stopping').at(-1)({ turn: 'n1', signal: first });
385
+ assert.equal(bootA.provider.text({ agent, signal: first }), '');
386
+ const second = signal();
387
+ await bootA.preStep({ agent, signal: second, turn: 'n2' }, enter(human('继续')));
388
+ assert.match(bootA.provider.text({ agent, signal: second }), /插件改完先试热刷新/, '收尾后「继续」仍属原任务');
389
+ }
390
+
391
+ // A13c:新任务零召回也照样记下新查询;再说「继续」不能把旧任务的记忆带回来。
392
+ {
393
+ const agent = makeAgent('agent-zero-task', cwdA);
394
+ const first = signal();
395
+ await bootA.preStep({ agent, signal: first, turn: 'z1' }, enter(human('插件改完怎么让它生效?')));
396
+ bootA.handlers.get('agent/turn-stopping').at(-1)({ turn: 'z1', signal: first });
397
+ const second = signal();
398
+ await bootA.preStep({ agent, signal: second, turn: 'z2' }, enter(human('帮我算一下 17 乘以 23 等于多少')));
399
+ bootA.handlers.get('agent/turn-stopping').at(-1)({ turn: 'z2', signal: second });
400
+ const third = signal();
401
+ await bootA.preStep({ agent, signal: third, turn: 'z3' }, enter(human('继续')));
402
+ assert.doesNotMatch(bootA.provider.text({ agent, signal: third }), /插件改完先试热刷新/, '零召回的新任务不能被旧任务续上');
403
+ }
404
+
405
+ // A13d:工具续步没有新的真人原话,刚执行的动作可以换来一次有界补查。
406
+ {
407
+ const cwdStep = mkdir('tool-step');
408
+ const scopeStep = store.scopeOf(cwdStep);
409
+ save(scopeStep, { title: '记忆插件先看状态再改', tags: ['记忆插件', '排查'], summary: '先看状态文件再动代码' });
410
+ save(scopeStep, { title: '改完 client.js 先重载宿主', tags: ['client.js', '重载'], summary: '普通刷新覆盖不到 client.js' });
411
+ const agent = makeAgent('agent-tool-step', cwdStep);
412
+ const runtime = boot();
413
+ const said = human('记忆插件怎么排查');
414
+ const first = signal();
415
+ await runtime.preStep({ agent, signal: first, turn: 's1' }, enter(said));
416
+ assert.match(runtime.provider.text({ agent, signal: first }), /记忆插件先看状态再改/);
417
+ const toolStep = {
418
+ role: 'assistant',
419
+ id: 'asst-tool-step',
420
+ content: [
421
+ { type: 'text', text: '我把改动写进文件了。' },
422
+ { type: 'tool-call', name: 'write', input: { file_path: '/tmp/step/client.js' } },
423
+ ],
424
+ };
425
+ const second = signal();
426
+ await runtime.preStep({ agent, signal: second, turn: 's1' }, enter(said, toolStep));
427
+ assert.match(runtime.provider.text({ agent, signal: second }), /改完 client\.js 先重载宿主/, '工具动作后补查一次');
428
+ }
429
+
356
430
  // A14:上一轮注入过的文字不会被当成这一轮的来源;只有合成消息的新 turn 不继承。
357
431
  {
358
432
  const agent = makeAgent('agent-stale-section', cwdA);
@@ -162,6 +162,28 @@ test('expired or superseded records do not block a new same-title save', () => {
162
162
  assert.notEqual(replacement.record.id, second.record.id);
163
163
  });
164
164
 
165
+ test('expired reads report their lifecycle and empty reads do not count as usage', () => {
166
+ const scope = fresh('lifecycle-read');
167
+ const first = upsert(scope, { title: '过期条目', summary: '已经过了有效期', body: '旧正文' });
168
+ const index = loadIndex(scope);
169
+ index.entries.find((row) => row.id === first.record.id).validUntil = new Date(Date.now() - 1000).toISOString();
170
+ index.entries.find((row) => row.id === first.record.id).lastUsed = '2026-01-01T00:00:00.000Z';
171
+ saveIndex(scope, index);
172
+ const expired = readEntry(scope, first.record.id);
173
+ assert.equal(expired.status, 'expired');
174
+ assert.equal(expired.body, '旧正文');
175
+ assert.equal(loadIndex(scope).entries.find((row) => row.id === first.record.id).lastUsed, '2026-01-01T00:00:00.000Z');
176
+
177
+ const empty = upsert(scope, { title: '空正文条目', summary: '只有摘要', body: '' }).record;
178
+ loadIndex(scope).entries.find((row) => row.id === empty.id).lastUsed = '2026-01-01T00:00:00.000Z';
179
+ const saved = loadIndex(scope);
180
+ saved.entries.find((row) => row.id === empty.id).lastUsed = '2026-01-01T00:00:00.000Z';
181
+ saveIndex(scope, saved);
182
+ const record = readEntry(scope, empty.id);
183
+ assert.equal(record.body, '');
184
+ assert.equal(loadIndex(scope).entries.find((row) => row.id === empty.id).lastUsed, '2026-01-01T00:00:00.000Z', '空读不算用过');
185
+ });
186
+
165
187
  test('oversized body is rejected, old data untouched', () => {
166
188
  const scope = fresh('oversize');
167
189
  const big = 'x'.repeat(LIMITS.entryBytes + 1);
package/view.js CHANGED
@@ -241,13 +241,19 @@ export function contextWithTask(context = {}, task = {}) {
241
241
  return out;
242
242
  }
243
243
 
244
- export function eligibility(record, runtime = {}, purpose = 'read', now = Date.now()) {
245
- if (!record) return { ok: false, status: 'gone', reason: 'gone' };
246
- if (record.status === 'archived') return { ok: false, status: 'gone', reason: 'archived' };
247
- /** 生命周期与手动检索共用一条:被取代或过期的条目不再像现行规则一样进入召回和正文读取。 */
248
- if (record.supersededBy) return { ok: false, status: 'gone', reason: 'superseded' };
244
+ /** 生命周期先于适用范围:归档、被取代、过期这三类都不该再算现行规则。 */
245
+ export function lifecycleStatusOf(record, now = Date.now()) {
246
+ if (!record) return 'gone';
247
+ if (record.status === 'archived') return 'archived';
248
+ if (record.supersededBy) return 'superseded';
249
249
  const validUntil = Date.parse(record.validUntil || '');
250
- if (Number.isFinite(validUntil) && validUntil <= now) return { ok: false, status: 'gone', reason: 'expired' };
250
+ if (Number.isFinite(validUntil) && validUntil <= now) return 'expired';
251
+ return '';
252
+ }
253
+
254
+ export function eligibility(record, runtime = {}, purpose = 'read', now = Date.now()) {
255
+ const lifecycle = lifecycleStatusOf(record, now);
256
+ if (lifecycle) return { ok: false, status: 'gone', reason: lifecycle };
251
257
  const pid = runtime && runtime.projectId;
252
258
  if (pid && record.ownerProjectId && record.ownerProjectId !== pid) {
253
259
  return { ok: false, status: 'not_applicable', reason: 'owner_mismatch' };
@@ -735,7 +741,7 @@ export function hasConcreteAction(text) {
735
741
  * 明确换任务:句子里有换做/改用/先别管这类词,或点了一个上一句没有的英文对象。
736
742
  * 认不出任务类型时,上一轮的动作线索只能靠这条规则隔开,不能因为类型为空就默认同任务。
737
743
  */
738
- const SWITCH_MARK = /(换成|换个|换一个|改用|改做|改为|改到|转到|先不管|别管|先别|不弄这个|不看这个|另一个|新任务|去做|开始做)/u;
744
+ const SWITCH_MARK = /(换成|换个|换一个|改用|改做|改为|改到|转到|先不管|别管|先别|不弄这个|不看这个|另一个|新任务|换任务|先结束|结束刚才|结束上一个|去做|开始做)/u;
739
745
  export function isExplicitSwitch(text, previousText = '') {
740
746
  const current = plain(text, 400);
741
747
  if (!current) return false;