dsh-lite-memory 0.18.33 → 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,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, {
@@ -2659,6 +2665,7 @@ export function apply(ctx, config) {
2659
2665
  const fresh = freshHumans(sessionId, humans);
2660
2666
  const previous = prepared.get(agent);
2661
2667
  const sameSession = Boolean(previous && previous.sessionId === sessionId);
2668
+ const sessionTask = recallTasks.get(sessionId) || null;
2662
2669
 
2663
2670
  if (fresh.length === 0) {
2664
2671
  /** 工具续步:内容冻结,只把快照绑到本步的 signal;盘上版本变了才重投影。 */
@@ -2701,6 +2708,32 @@ export function apply(ctx, config) {
2701
2708
  }
2702
2709
  }
2703
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
+ }
2704
2737
  return decision;
2705
2738
  }
2706
2739
 
@@ -2716,9 +2749,8 @@ export function apply(ctx, config) {
2716
2749
  /** 工具模式也要记下本轮真人原话:只用于核对绑定,不自动注入。 */
2717
2750
  const said = view.newestUserQuery(fresh).slice(0, RECALL_QUERY_CHARS);
2718
2751
  /** 「继续」这类续说没有检索词:带上同会话上一轮原话,否则这一轮必然召回为空。 */
2719
- const carried = sameSession && previous?.query
2720
- && (view.isContinuationQuery(said) || view.isEllipticalContinuation(said))
2721
- ? previous.query : '';
2752
+ const carried = (view.isContinuationQuery(said) || view.isEllipticalContinuation(said))
2753
+ ? (previous?.query || sessionTask?.query || '') : '';
2722
2754
  let query = (carried ? `${said} ${carried}` : said).trim().slice(0, RECALL_QUERY_CHARS);
2723
2755
  if (win && query) win.query = query;
2724
2756
 
@@ -2766,12 +2798,22 @@ export function apply(ctx, config) {
2766
2798
  episodeId: (runtime && runtime.held && runtime.held.episodeId) || '',
2767
2799
  };
2768
2800
  const rememberedTask = lastTask.get(sessionId) || null;
2769
- const previousTask = previous && previous.task ? previous.task : rememberedTask;
2770
- const sameTask = Boolean(sameSession || rememberedTask)
2771
- && !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)
2772
2805
  && view.sameRecallTask(previousTask, currentTask);
2806
+ const taskStartMessageId = sameTask && sessionTask && sessionTask.startMessageId
2807
+ ? sessionTask.startMessageId : ids[0];
2773
2808
  lastTask.set(sessionId, currentTask);
2809
+ recallTasks.set(sessionId, {
2810
+ query,
2811
+ task: currentTask,
2812
+ startMessageId: taskStartMessageId,
2813
+ updatedAt: Date.now(),
2814
+ });
2774
2815
  while (lastTask.size > 64) lastTask.delete(lastTask.keys().next().value);
2816
+ while (recallTasks.size > 64) recallTasks.delete(recallTasks.keys().next().value);
2775
2817
  /**
2776
2818
  * 预防型记忆约束的是「下一步动作」,词面常写在上一轮助手的话和工具调用里,用户这句话反而没提。
2777
2819
  * 续句或没有具体动作的短问句,直接把最近的动作线索并进同一份查询:只查一次,不额外占预算。
@@ -2784,7 +2826,11 @@ export function apply(ctx, config) {
2784
2826
  }
2785
2827
  return messages;
2786
2828
  })();
2787
- 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) : '';
2788
2834
  const trailInQuery = Boolean(trail) && (Boolean(carried) || !view.hasConcreteAction(said));
2789
2835
  if (trailInQuery) {
2790
2836
  query = `${query} ${trail}`.trim().slice(0, RECALL_QUERY_CHARS);
@@ -2860,10 +2906,14 @@ export function apply(ctx, config) {
2860
2906
  fingerprint,
2861
2907
  controlEpoch,
2862
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,
2863
2913
  at: Date.now(),
2864
2914
  };
2865
2915
  prepared.set(agent, state);
2866
- if (snapshot && snapshot.ids && snapshot.ids.length) markUsed(scope, snapshot.ids);
2916
+ /** 真正输出给模型时才算使用;准备阶段被丢弃/中止的快照不能改 lastUsed。 */
2867
2917
  payloadBytes.set(sessionId, win.bytes);
2868
2918
  while (payloadBytes.size > 64) payloadBytes.delete(payloadBytes.keys().next().value);
2869
2919
  if (signal) signalAgents.set(signal, agent);
@@ -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,19 +625,30 @@ 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;
629
633
  /** 作品里学到的规范必须带上当时的任务范围:模型想写成全项目时收窄成条件规则,而不是丢掉这条反馈。 */
630
634
  const narrowToEpisode = target === 'new' && expected?.scope === 'episode' && expectedConditions.length > 0;
631
635
  if (narrowToEpisode) {
632
636
  if (applicability === undefined || applicability === 'all_in_scope') applicability = 'conditional';
633
637
  const merged = conditions !== undefined ? [...conditions] : [];
634
638
  for (const row of expectedConditions) {
635
- if (merged.some((item) => item.key === row.key && item.value === row.value)) continue;
636
- if (!conditionValueAllowed(row, { ranges, existing })) return { ok: false, reason: '收集时的任务范围不在本项目里' };
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
+ /** 采集条件来自已绑定的真实任务,不要求它先出现在历史规则里;模型新提的条件仍走范围校验。 */
637
645
  merged.push(row);
638
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
+ }
639
652
  conditions = merged;
640
653
  }
641
654
  const expectedApplicability = expected
@@ -650,6 +663,12 @@ export function validateChange(change, { refs, rules, ranges, expectedTarget } =
650
663
  return { ok: false, reason: '作品规则不能抬成全项目' };
651
664
  }
652
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
+ }
653
672
  if (applicability === 'all_in_scope' && effectiveConditions.length) {
654
673
  return { ok: false, reason: '全项目规则不能带条件' };
655
674
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lite-memory",
3
- "version": "0.18.33",
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
@@ -2738,7 +2738,7 @@ function bindSameTopic(projectScope, ref) {
2738
2738
  const CONFLICT_LIMIT = 20;
2739
2739
 
2740
2740
  /** 待办里留一份可重放的草稿:面板能直接另存,不用用户回忆刚才想记什么。 */
2741
- 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'];
2742
2742
  function conflictDraftOf(draft) {
2743
2743
  const out = {};
2744
2744
  if (!draft || typeof draft !== 'object') return out;
@@ -2754,6 +2754,10 @@ function conflictDraftOf(draft) {
2754
2754
  else if (key === 'conditions') out.conditions = Array.isArray(value) ? value.slice(0, 8) : undefined;
2755
2755
  else if (key === 'applicability') out.applicability = value;
2756
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) : [];
2757
2761
  else if (key === 'source') out.source = value;
2758
2762
  else if (key === 'origin') out.origin = value;
2759
2763
  }
@@ -2821,30 +2825,35 @@ function staleDraft(row, ref) {
2821
2825
 
2822
2826
  /** 确认另存:用队列里的草稿按 distinct 重放一次,成功后按 id 出队。 */
2823
2827
  export function resolveConflict(scope, ref, options = {}) {
2824
- const row = conflictRowLocked(scope, ref);
2825
- if (!row) return { ok: false, reason: '这条待裁决已经不在了,刷新面板看看' };
2826
- if (staleDraft(row, ref)) return { ok: false, reasonCode: 'stale_draft', reason: '这条待裁决又更新了,刷新后再处理' };
2827
- const draft = { ...(row.draft || {}), resolution: 'distinct', writeMode: options.writeMode === 'append' ? 'append' : 'patch' };
2828
- if (scope.key === 'global') draft.scope = 'global';
2829
- else draft.scope = 'project';
2830
- if (!draft.title) draft.title = row.title;
2831
- const result = saveEntry(scope, draft);
2832
- if (result && result.needsResolution) return { ok: false, reason: '还是相近:换一个标题或等确认', result };
2833
- if (result && result.needsScope) {
2834
- const reason = result.reasonCode === 'memory_disabled' ? '这层记忆已关闭' : '这条记忆的适用范围还没定,先说明当前环境';
2835
- return { ok: false, reason, result };
2836
- }
2837
- clearConflictLocked(scope, { id: row.id });
2838
- 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
+ });
2839
2846
  }
2840
2847
 
2841
2848
  /** 删掉待裁决:用户决定不再另存。 */
2842
2849
  export function dropConflict(scope, ref) {
2843
- const row = conflictRowLocked(scope, ref);
2844
- if (!row) return { ok: false, reason: '这条待裁决已经不在了,刷新面板看看' };
2845
- if (staleDraft(row, ref)) return { ok: false, reasonCode: 'stale_draft', reason: '这条待裁决又更新了,刷新后再处理' };
2846
- clearConflictLocked(scope, { id: row.id });
2847
- 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
+ });
2848
2857
  }
2849
2858
 
2850
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`);
@@ -1036,6 +1036,54 @@ test('L03d2 代码任务学到的规则不会在数据库任务里生效', () =>
1036
1036
  assert.equal(db.reason, 'condition_mismatch');
1037
1037
  });
1038
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
+
1039
1087
  test('L03e 先按相关性排序再截断,输入顺序不挤掉真正相关的旧规则', () => {
1040
1088
  const rules = [];
1041
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);
package/view.js CHANGED
@@ -741,7 +741,7 @@ export function hasConcreteAction(text) {
741
741
  * 明确换任务:句子里有换做/改用/先别管这类词,或点了一个上一句没有的英文对象。
742
742
  * 认不出任务类型时,上一轮的动作线索只能靠这条规则隔开,不能因为类型为空就默认同任务。
743
743
  */
744
- const SWITCH_MARK = /(换成|换个|换一个|改用|改做|改为|改到|转到|先不管|别管|先别|不弄这个|不看这个|另一个|新任务|去做|开始做)/u;
744
+ const SWITCH_MARK = /(换成|换个|换一个|改用|改做|改为|改到|转到|先不管|别管|先别|不弄这个|不看这个|另一个|新任务|换任务|先结束|结束刚才|结束上一个|去做|开始做)/u;
745
745
  export function isExplicitSwitch(text, previousText = '') {
746
746
  const current = plain(text, 400);
747
747
  if (!current) return false;