dsh-lite-memory 0.18.27 → 0.18.29

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/client.js CHANGED
@@ -2200,6 +2200,21 @@ window.__ModuleLoader__.load({
2200
2200
 
2201
2201
  React.useEffect(() => { load(); }, [sessionId]);
2202
2202
 
2203
+ /** 待裁决只做两件事:把草稿另存成一条,或删掉这条待办;不在面板里悄悄合并。 */
2204
+ const resolveQueued = (row, op) => {
2205
+ if (!row || busy) return;
2206
+ setBusy(true);
2207
+ setNote('');
2208
+ post('/lite-memory/conflict', { sessionId, scopeKey: selectedKey, title: row.title, op }).then((result) => {
2209
+ if (!alive.current) return;
2210
+ setBusy(false);
2211
+ if (result.ok && result.body && result.body.ok) {
2212
+ if (op === 'drop') setNote('已忽略这条待裁决');
2213
+ load(selectedKey);
2214
+ } else setNote(failure(result, '待裁决没处理成'));
2215
+ });
2216
+ };
2217
+
2203
2218
  /** 显示设置跟着当前项目走;换项目或换范围时重新读。 */
2204
2219
  React.useEffect(() => {
2205
2220
  setDisplay(readDisplaySettings(selectedKey));
@@ -2249,6 +2264,7 @@ window.__ModuleLoader__.load({
2249
2264
  const selected = data ? data.selected : null;
2250
2265
  const records = selected && Array.isArray(selected.records) ? selected.records : EMPTY;
2251
2266
  const total = selected && selected.stats && Number.isInteger(selected.stats.total) ? selected.stats.total : records.length;
2267
+ const queued = selected && Array.isArray(selected.conflicts) ? selected.conflicts : EMPTY;
2252
2268
  const currentKey = current ? current.key : '';
2253
2269
  const control = selected && selected.control
2254
2270
  ? selected.control
@@ -2710,12 +2726,24 @@ window.__ModuleLoader__.load({
2710
2726
  const memoryViewActive = viewModeOf(display, memoryView);
2711
2727
  const evolutionViewActive = graphViewOf(display.graph, evolutionView);
2712
2728
 
2729
+ const conflictBar = queued.length === 0 ? null : h('div', {
2730
+ style: { display: 'flex', flexDirection: 'column', gap: '3px', padding: '7px 9px 8px', borderBottom: '1px solid var(--dsw-alias-border-l1)', flex: '0 0 auto' },
2731
+ },
2732
+ h('div', { style: { ...PANEL_MUTED, lineHeight: '18px' } }, `${queued.length} 条保存没确认`),
2733
+ queued.slice(0, 2).map((row) => h('div', {
2734
+ key: row.id || row.title,
2735
+ style: { display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 },
2736
+ },
2737
+ h('span', { style: { flex: 1, minWidth: 0, fontSize: '12px', lineHeight: '20px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, row.title),
2738
+ h(Action, { disabled: busy, onClick: () => resolveQueued(row, 'keep') }, '另存'),
2739
+ h(Action, { disabled: busy, onClick: () => resolveQueued(row, 'drop') }, '忽略'),
2740
+ )));
2713
2741
  const memoryListPane = h('div', {
2714
2742
  style: {
2715
2743
  ...PANEL_CARD, padding: '2px 4px', minHeight: 0, overflowY: 'auto',
2716
2744
  display: 'flex', flexDirection: 'column', gap: 0,
2717
2745
  },
2718
- }, memoryList);
2746
+ }, conflictBar, memoryList);
2719
2747
  const memoryGraphPane = h('div', {
2720
2748
  style: { ...PANEL_CARD, padding: 0, minHeight: 0, overflow: 'hidden', display: 'flex' },
2721
2749
  },
package/index.js CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  globalScope,
4
4
  upsert,
5
5
  search,
6
+ markUsed,
6
7
  readEntry,
7
8
  readLinks,
8
9
  forget,
@@ -12,6 +13,9 @@ import {
12
13
  runtimeLayers,
13
14
  findAnywhere,
14
15
  renderIndex,
16
+ conflictsOf,
17
+ resolveConflict,
18
+ dropConflict,
15
19
  loadIndex,
16
20
  saveEntry,
17
21
  move,
@@ -213,6 +217,7 @@ function keepHandler(webServer, cwdForSession) {
213
217
  : pathname === REMOTE_PATH + '/entry' ? 'entry'
214
218
  : pathname === REMOTE_PATH + '/move' ? 'move'
215
219
  : pathname === REMOTE_PATH + '/overview' ? 'overview'
220
+ : pathname === REMOTE_PATH + '/conflict' ? 'conflict'
216
221
  : pathname === REMOTE_PATH + '/control' ? 'control'
217
222
  : pathname === REMOTE_PATH + '/evolution' ? 'evolution'
218
223
  : pathname === REMOTE_PATH + '/resume-move' ? 'resume-move' : '';
@@ -529,6 +534,7 @@ function keepHandler(webServer, cwdForSession) {
529
534
  evolution: selectedEvolution,
530
535
  records,
531
536
  archived,
537
+ conflicts: conflictsOf(selectedScope),
532
538
  stats: {
533
539
  total: records.length,
534
540
  experimental: records.filter((record) => record.experimental).length,
@@ -540,6 +546,26 @@ function keepHandler(webServer, cwdForSession) {
540
546
  });
541
547
  }
542
548
 
549
+ /** 待裁决:用队列里留下的草稿另存,或删掉这条待办;不预写、不替用户合并。 */
550
+ if (action === 'conflict') {
551
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
552
+ if (!sessionId) return sendJson(res, 400, { ok: false, reason: '缺少 sessionId' });
553
+ const cwd = typeof cwdForSession === 'function' ? cwdForSession(sessionId) : '';
554
+ if (!cwd) return sendJson(res, 409, { ok: false, reason: '这个会话还没有项目目录' });
555
+ const projectScope = scopeOf(cwd);
556
+ const requested = typeof body.scopeKey === 'string' ? body.scopeKey : '';
557
+ const target = requested === 'global' ? globalScope() : projectScope;
558
+ const title = typeof body.title === 'string' ? body.title.trim() : '';
559
+ if (!title) return sendJson(res, 400, { ok: false, reason: '缺少标题' });
560
+ try {
561
+ const outcome = body.op === 'drop' ? { ok: dropConflict(target, title) } : resolveConflict(target, title);
562
+ if (!outcome.ok) return sendJson(res, 409, { ok: false, reason: outcome.reason || '这条待裁决还没处理成' });
563
+ return sendJson(res, 200, { ok: true, remaining: conflictsOf(target).length });
564
+ } catch (error) {
565
+ return sendJson(res, 409, { ok: false, reason: error instanceof Error ? error.message : '待裁决处理失败' });
566
+ }
567
+ }
568
+
543
569
  /**
544
570
  * 本项目记忆总开关:关闭后这层不再召回、检索、读写、学习,也不继承全局记忆。
545
571
  * 自进化设置原样保留,但有效状态随总开关暂停。
@@ -919,7 +945,39 @@ const FORGET_PARAMS = {
919
945
  additionalProperties: false,
920
946
  };
921
947
 
922
- /** 对外只认标题:id 留作内部地址,模型和界面都不用碰。 */
948
+ /**
949
+ * 按标题解析内部身份。对外仍只显示标题,但解析必须能说明「唯一 / 同层歧义 / 不存在」:
950
+ * 同层同名是正常情况(不同前置条件各一条),静默取第一条会读错、写错。
951
+ */
952
+ function titleHitsIn(target, title, { includeArchived = false } = {}) {
953
+ let index;
954
+ try { index = loadIndex(target); } catch { return []; }
955
+ const rows = includeArchived ? (index.archived || []) : (index.entries || []);
956
+ return rows.filter((entry) => entry && (includeArchived || entry.status !== 'archived')
957
+ && String(entry.title || '').trim() === title);
958
+ }
959
+
960
+ function candidateNote(entry) {
961
+ const conditions = Array.isArray(entry.conditions) ? entry.conditions.filter((row) => row && row.key) : [];
962
+ const parts = [];
963
+ if (entry.when) parts.push(String(entry.when));
964
+ if (conditions.length) parts.push(conditions.map((row) => `${row.key}=${row.value}`).join('·'));
965
+ return parts.join(',');
966
+ }
967
+
968
+ /** 同层同名时把候选条件一起报出来,模型/用户才能决定改哪条或先归档哪条。 */
969
+ function ambiguousTitleError(title, hits, label) {
970
+ const detail = hits.slice(0, 4).map((entry) => {
971
+ const note = candidateNote(entry);
972
+ return note ? `「${title}」(${note})` : `「${title}」`;
973
+ }).join('、');
974
+ return `${label}「${title}」有 ${hits.length} 条同名,无法确定是哪一条:${detail}。先归档其中一条,或换一个能区分的标题再试`;
975
+ }
976
+
977
+ /**
978
+ * 返回 { id } 或抛出:显式层只查那一层;默认先项目后全局(别的项目不参与解析)。
979
+ * 同一层出现多条同名一律报歧义,绝不取第一条。
980
+ */
923
981
  function refIdOf(scope, args, label = '记忆') {
924
982
  const id = typeof args.id === 'string' ? args.id.trim() : '';
925
983
  if (id) return id;
@@ -928,34 +986,30 @@ function refIdOf(scope, args, label = '记忆') {
928
986
  /** 显式指定层时只解析那一层:同名条目不会被顺手改掉,项目层也不会命中全局。 */
929
987
  const explicit = args && args.scope === 'global' ? globalScope() : args && args.scope === 'project' ? scope : null;
930
988
  const sources = explicit ? [explicit] : runtimeLayers(scope);
931
- /** 运行时只解析当前项目与全局:别的项目正文不进本会话。 */
989
+ let crossLayerHit = null;
932
990
  for (const target of sources) {
933
- let index;
934
- try { index = loadIndex(target); } catch { continue; }
935
- const hit = (index.entries || []).find((entry) => entry && entry.status !== 'archived'
936
- && String(entry.title || '').trim() === title);
937
- if (hit) return hit.id;
991
+ const hits = titleHitsIn(target, title);
992
+ if (hits.length > 1) throw new Error(ambiguousTitleError(title, hits, label));
993
+ if (hits.length === 1 && !crossLayerHit) crossLayerHit = hits[0];
938
994
  }
995
+ if (crossLayerHit) return crossLayerHit.id;
939
996
  /** 忘掉已归档的条目也要能按标题解析到;归档不会因此重新参与召回。 */
940
997
  for (const target of sources) {
941
- let index;
942
- try { index = loadIndex(target); } catch { continue; }
943
- const hit = (index.archived || []).find((entry) => entry
944
- && String(entry.title || '').trim() === title);
945
- if (hit) return hit.id;
998
+ const hits = titleHitsIn(target, title, { includeArchived: true });
999
+ if (hits.length > 1) throw new Error(ambiguousTitleError(title, hits, label));
1000
+ if (hits.length === 1) return hits[0].id;
946
1001
  }
947
- throw new Error(`没有「${title}」这条记忆;先用 memory_search 对一下标题`);
1002
+ throw new Error(`没有「${title}」这条${label};先用 memory_search 对一下标题`);
948
1003
  }
949
1004
 
950
1005
  /** 同名条目可能分布在项目层和全局层:更新前先报候选,不能先匹配。 */
951
1006
  function titleCandidates(scope, title) {
952
1007
  const out = [];
953
1008
  for (const target of runtimeLayers(scope)) {
954
- let index;
955
- try { index = loadIndex(target); } catch { continue; }
956
- const hit = (index.entries || []).find((entry) => entry && entry.status !== 'archived'
957
- && String(entry.title || '').trim() === title);
958
- if (hit) out.push({ id: hit.id, scope: target.key === 'global' ? 'global' : 'project' });
1009
+ const hits = titleHitsIn(target, title);
1010
+ if (!hits.length) continue;
1011
+ if (hits.length > 1) out.push({ ambiguous: true, title, hits, scope: target.key === 'global' ? 'global' : 'project' });
1012
+ else out.push({ id: hits[0].id, scope: target.key === 'global' ? 'global' : 'project', note: candidateNote(hits[0]) });
959
1013
  }
960
1014
  return out;
961
1015
  }
@@ -1073,6 +1127,8 @@ export function apply(ctx, config) {
1073
1127
  };
1074
1128
  /** 已准备好的快照只挂在 Agent 上,弱引用随手回收,不跨会话串。 */
1075
1129
  const prepared = new WeakMap();
1130
+ /** 最近一次请求的任务身份:本轮没注入任何内容时 prepared 会清掉,补查仍需要知道上一轮任务。 */
1131
+ const lastTask = new Map();
1076
1132
  /** turn-stopping 只回 signal,不带头 agent;这里记 signal → agent 供收尾清理。 */
1077
1133
  const signalAgents = new Map();
1078
1134
  const generations = new WeakMap();
@@ -1273,7 +1329,7 @@ export function apply(ctx, config) {
1273
1329
  win.recallHeader = true;
1274
1330
  }
1275
1331
  lines.push(line);
1276
- cards.push({ revision, scope: card.scope, title: card.title });
1332
+ cards.push({ id: card.id, revision, scope: card.scope, title: card.title });
1277
1333
  /** 卡片行与到期时间一起留到最后输出:中间隔了系统提示组装,不能拿投影时的判断当现在。 */
1278
1334
  linesMeta.push({ line, validUntil: row.record.validUntil || '' });
1279
1335
  }
@@ -1318,8 +1374,9 @@ export function apply(ctx, config) {
1318
1374
  : waiting ? 'needs_context'
1319
1375
  : (gateRuntime && gateRuntime.enabled ? 'no_overlap' : 'needs_context');
1320
1376
  const matched = ranked.length || nearMatched;
1321
- if (!cards.length) return { text: '', cards: [], lines: [], bytes: 0, query, reason, matched };
1322
- return { text: finalText, cards, lines: linesMeta, bytes: Buffer.byteLength(finalText), query, reason, matched };
1377
+ const ids = cards.map((card) => card.id).filter(Boolean);
1378
+ if (!cards.length) return { text: '', cards: [], lines: [], ids, bytes: 0, query, reason, matched };
1379
+ return { text: finalText, cards, lines: linesMeta, ids, bytes: Buffer.byteLength(finalText), query, reason, matched };
1323
1380
  }
1324
1381
 
1325
1382
  /* ---------------- 项目自进化:任务锚点、读取决策、上下文解析 ---------------- */
@@ -1491,21 +1548,31 @@ export function apply(ctx, config) {
1491
1548
  const unknown = [];
1492
1549
  const byTopic = new Map();
1493
1550
  let conflict = false;
1551
+ const now = Date.now();
1552
+ const query = held && held.query ? String(held.query) : '';
1553
+ const runtime = { projectId: scope.key, experimental: true, context: context || {}, task: (held && held.task) || {} };
1494
1554
  for (const record of index.entries) {
1495
1555
  if (record.origin !== 'project_evolution') continue;
1496
1556
  if (record.ownerProjectId && record.ownerProjectId !== scope.key) continue;
1497
- if (record.reviewState === 'legacy_unreviewed') continue;
1498
- const learning = record.learning && typeof record.learning === 'object' ? record.learning : {};
1499
- if (learning.state !== 'active' && learning.state !== 'trial') continue;
1500
- /** 试用到期、暂停或身份失效:自动注入与检索、读取用同一档可用性,不能各判各的。 */
1501
- if (!view.experimentalUsable(record, Date.now())) continue;
1502
- const status = view.statusOf(record, context);
1503
- if (status === 'needs_context') { unknown.push(record); continue; }
1504
- if (status !== 'ok') continue;
1557
+ const score = query ? view.matchScore(record, query).score : 0;
1558
+ /** 生命周期、归属、学习状态、适用条件统一走 eligibility:被取代、过期、暂停都不该从实验通道溜进来。 */
1559
+ const usable = view.eligibility(record, runtime, 'auto', now);
1560
+ if (!usable.ok) {
1561
+ if (usable.status === 'needs_context' && usable.reason !== 'condition_mismatch'
1562
+ && score >= view.MATCH_FLOOR) unknown.push(record);
1563
+ continue;
1564
+ }
1565
+ /**
1566
+ * 实验规则的任务条件本身就是相关度:栏目/阶段都对上,说明这条规则就属于当前这一集。
1567
+ * 没有声明条件的规则仍要过词面门,避免一条宽泛规则在所有请求里都出现。
1568
+ */
1569
+ const conditions = Array.isArray(record.conditions) ? record.conditions.filter((row) => row && row.key) : [];
1570
+ const bound = conditions.length > 0;
1571
+ if (!bound && score < view.MATCH_FLOOR) continue;
1505
1572
  const topic = record.topicKey || record.id;
1506
1573
  if (byTopic.has(topic) && byTopic.get(topic).body !== record.body) conflict = true;
1507
1574
  byTopic.set(topic, record);
1508
- matched.push(record);
1575
+ matched.push({ record, score: bound ? Math.max(score, view.MATCH_FLOOR) : score });
1509
1576
  }
1510
1577
  if (conflict) {
1511
1578
  return chargeExperiment(win, '这些实验规则互相冲突,先按用户这次说的做,别叠加对立的规则。');
@@ -1520,15 +1587,10 @@ export function apply(ctx, config) {
1520
1587
  ? Math.max(0, view.CARD.bytes - Buffer.byteLength(auto.normalText || '') - 2)
1521
1588
  : EXPERIMENT_BYTES;
1522
1589
  let used = Buffer.byteLength(EXPERIMENT_RULES_HEADER) + 1;
1523
- /** 实验规则也要过本轮相关度:和当前问题没有交集就不占卡片,不能“来自自进化”就天然优先。 */
1524
- const query = held && held.query ? String(held.query) : '';
1525
- const relevant = query
1526
- ? matched.map((record) => ({ record, score: view.matchScore(record, query).score }))
1527
- /** 条件明确且已满足的规则按绑定注入;无条件的全项目规则必须和本轮问题有交集。 */
1528
- .filter((row) => row.score > 0 || (Array.isArray(row.record.conditions) && row.record.conditions.length > 0))
1529
- .sort((a, b) => b.score - a.score)
1530
- .map((row) => row.record)
1531
- : matched;
1590
+ const relevant = matched
1591
+ .slice()
1592
+ .sort((a, b) => b.score - a.score)
1593
+ .map((row) => row.record);
1532
1594
  for (const record of relevant.slice(0, cardCap)) {
1533
1595
  const body = String(record.body || '');
1534
1596
  const short = body && body.length <= EXPERIMENT_SHORT_CHARS && body.split('\n').length <= 4;
@@ -1649,6 +1711,7 @@ export function apply(ctx, config) {
1649
1711
  projectKey: scope.key, sessionId, batchKey, messageIds, query,
1650
1712
  context, task, anchor: routed.anchor, ranges, decision, epoch,
1651
1713
  experimental: enabled,
1714
+ episodeId: (episode && episode.episodeId) || (routed.anchor && routed.anchor.episodeId) || '',
1652
1715
  episodeUpdatedAt: episode ? episode.updatedAt : '', injectedIds: [],
1653
1716
  at: Date.now(),
1654
1717
  };
@@ -1889,6 +1952,7 @@ export function apply(ctx, config) {
1889
1952
  archived: { type: 'string' },
1890
1953
  pendingConflicts: { type: 'integer' },
1891
1954
  error: { type: 'string' },
1955
+ offCopy: { type: 'string' },
1892
1956
  },
1893
1957
  required: ['title', 'summary', 'when', 'revision', 'merged', 'changed', 'scope', 'needsResolution', 'archived'],
1894
1958
  additionalProperties: false,
@@ -1899,7 +1963,7 @@ export function apply(ctx, config) {
1899
1963
  ? `memory · ${value.error}`
1900
1964
  : value.needsScope
1901
1965
  ? value.reason === 'memory_disabled'
1902
- ? `memory · ${memoryOffCopy(projectScope)}`
1966
+ ? `memory · ${value.offCopy || '本项目记忆已关闭,先在面板打开'}`
1903
1967
  : value.reason === 'unconfirmed_project'
1904
1968
  ? `memory · 当前目录还不能确认属于哪个项目:「${value.title}」\n先确认项目根目录,或显式 scope="global"`
1905
1969
  : `memory · 归属还没定:「${value.title}」\n补 memoryClass(personal_preference / project_fact / environment_fact / portable_procedure)或显式 scope;设备事实还要给设备条件`
@@ -1919,7 +1983,7 @@ export function apply(ctx, config) {
1919
1983
  return {
1920
1984
  title: String(args.title || ''), summary: '', when: '', revision: 0,
1921
1985
  merged: false, changed: false, scope: '', needsScope: true, reason: 'memory_disabled',
1922
- needsResolution: false, similarTitles: '', similar: '', archived: '',
1986
+ needsResolution: false, similarTitles: '', similar: '', archived: '', offCopy: memoryOffCopy(projectScope),
1923
1987
  };
1924
1988
  }
1925
1989
  const supersedes = String(args.supersedes || '').trim();
@@ -1932,13 +1996,17 @@ export function apply(ctx, config) {
1932
1996
  /** 更新句柄也用标题:id 只是底层实现,模型抄标题就行。 */
1933
1997
  const updateTitle = String(args.updateTitle || '').trim();
1934
1998
  if (updateTitle) {
1935
- const candidates = args.scope ? [] : titleCandidates(projectScope, updateTitle);
1936
- if (candidates.length > 1) {
1999
+ const candidates = titleCandidates(projectScope, updateTitle);
2000
+ const ambiguous = candidates.find((row) => row.ambiguous);
2001
+ /** 同层同名是允许的(不同前置条件各一条),但不能替用户挑第一条改。 */
2002
+ if (ambiguous) return saveFailure(ambiguousTitleError(updateTitle, ambiguous.hits, '记忆'), args);
2003
+ if (!args.scope && candidates.length > 1) {
1937
2004
  return saveFailure(`「${updateTitle}」在项目层和全局层各有一条:改全局加 scope="global",改项目加 scope="project"`, args);
1938
2005
  }
1939
- const updateId = (() => { try { return refIdOf(projectScope, { title: updateTitle, scope: args.scope }, '记忆'); } catch { return ''; } })();
1940
- if (!updateId) return saveFailure(`没有「${updateTitle}」这条记忆;要新增就不用 updateTitle`, args);
1941
- draft.id = updateId;
2006
+ const layer = args.scope === 'global' ? 'global' : args.scope === 'project' ? 'project' : '';
2007
+ const picked = layer ? candidates.find((row) => row.scope === layer) : candidates[0];
2008
+ if (!picked) return saveFailure(`没有「${updateTitle}」这条记忆;要新增就不用 updateTitle`, args);
2009
+ draft.id = picked.id;
1942
2010
  }
1943
2011
  /** 关联目标同样按标题解析成内部 id 再落盘。 */
1944
2012
  if (Array.isArray(args.links) && args.links.length) {
@@ -1946,8 +2014,10 @@ export function apply(ctx, config) {
1946
2014
  for (const link of args.links) {
1947
2015
  const title = typeof link?.title === 'string' ? link.title.trim() : '';
1948
2016
  if (!title) return saveFailure('关联要写成 {type, title}:给被关联那条记忆的标题', args);
1949
- const targetId = (() => { try { return refIdOf(projectScope, { title }, '记忆'); } catch { return ''; } })();
1950
- if (!targetId) return saveFailure(`没有「${title}」这条记忆,关联没写成`, args);
2017
+ let targetId = '';
2018
+ try { targetId = refIdOf(projectScope, { title }, '记忆'); } catch (error) {
2019
+ return saveFailure(String(error && error.message ? error.message : `没有「${title}」这条记忆`), args);
2020
+ }
1951
2021
  resolved.push({ type: link.type || 'related', id: targetId });
1952
2022
  }
1953
2023
  draft.links = resolved;
@@ -2162,6 +2232,8 @@ export function apply(ctx, config) {
2162
2232
  context,
2163
2233
  history: args.history === true,
2164
2234
  allowExperimental: resolved.experimental === true,
2235
+ /** 「用过」要等真正给了模型看再记:检索到的前 50 条不是交付。 */
2236
+ markUsed: false,
2165
2237
  }).filter((item) => !item.ownerProjectId || item.ownerProjectId === scope.key)
2166
2238
  .filter((item) => item.archived || view.eligibility(item, gateRuntime, 'search').ok);
2167
2239
  const hits = rawHits.filter((item) => item.archived || item.status === 'ok').slice(0, limit);
@@ -2219,10 +2291,12 @@ export function apply(ctx, config) {
2219
2291
  win.toolBytes += jsonBytes(value);
2220
2292
  win.reads += 1;
2221
2293
  recordCitations(scope, sessionIdOf(execution), hits.slice(0, kept));
2294
+ markUsed(scope, hits.slice(0, kept).map((item) => item.id));
2222
2295
  return value;
2223
2296
  }
2224
2297
  const value = valueOf(shown.length);
2225
2298
  recordCitations(scope, sessionIdOf(execution), hits);
2299
+ markUsed(scope, hits.map((item) => item.id));
2226
2300
  return value;
2227
2301
  },
2228
2302
  }), 'lite-memory: memory_search');
@@ -2554,7 +2628,7 @@ export function apply(ctx, config) {
2554
2628
  .filter((message) => message?.source?.kind === 'user');
2555
2629
  const fresh = freshHumans(sessionId, humans);
2556
2630
  const previous = prepared.get(agent);
2557
- const sameSession = previous && previous.sessionId === sessionId;
2631
+ const sameSession = Boolean(previous && previous.sessionId === sessionId);
2558
2632
 
2559
2633
  if (fresh.length === 0) {
2560
2634
  /** 工具续步:内容冻结,只把快照绑到本步的 signal;盘上版本变了才重投影。 */
@@ -2651,7 +2725,6 @@ export function apply(ctx, config) {
2651
2725
  /** 任务上下文独立于自进化开关:普通记忆按当前任务判适用,实验规则再叠一层可用性。 */
2652
2726
  const auto = emptyAutoBudget();
2653
2727
  if (win) win.autoBudget = auto;
2654
- const experiment = runtime ? experimentFor(scope, runtime, win) : null;
2655
2728
  const recalledContext = runtime ? runtime.held.context : context;
2656
2729
  const remembered = runtime && runtime.enabled
2657
2730
  ? rememberFeedback(scope, sessionId, fresh, runtime.held, turn)
@@ -2661,7 +2734,16 @@ export function apply(ctx, config) {
2661
2734
  * 预防型记忆约束的是「下一步动作」,词面常写在上一轮助手的话和工具调用里,用户这句话反而没提。
2662
2735
  * 第一次检索一张卡都没有时,用最近的实词/工具名补一次有界查询,预算仍是同一份。
2663
2736
  */
2664
- if (!snapshot.cards.length && !carried) {
2737
+ const currentTask = {
2738
+ kind: (runtime && runtime.held && runtime.held.task && runtime.held.task.kind) || '',
2739
+ episodeId: (runtime && runtime.held && runtime.held.episodeId) || '',
2740
+ };
2741
+ const rememberedTask = lastTask.get(sessionId) || null;
2742
+ const previousTask = previous && previous.task ? previous.task : rememberedTask;
2743
+ const sameTask = Boolean(sameSession || rememberedTask) && view.sameRecallTask(previousTask, currentTask);
2744
+ lastTask.set(sessionId, currentTask);
2745
+ while (lastTask.size > 64) lastTask.delete(lastTask.keys().next().value);
2746
+ if (!snapshot.cards.length && !carried && sameTask) {
2665
2747
  const humanTail = (() => {
2666
2748
  const messages = Array.isArray(decision.messages) ? decision.messages : [];
2667
2749
  for (let i = messages.length - 1; i >= 0; i -= 1) {
@@ -2680,6 +2762,8 @@ export function apply(ctx, config) {
2680
2762
  }
2681
2763
  }
2682
2764
  }
2765
+ /** 实验规则最后才取预算:普通卡片先占,弱相关实验不能把强相关项目规则挤出去。 */
2766
+ const experiment = runtime ? experimentFor(scope, runtime, win) : null;
2683
2767
  noteRecall(scope, sessionId, {
2684
2768
  reason: snapshot.reason, matched: snapshot.matched, cards: snapshot.cards.length, chars: snapshot.bytes,
2685
2769
  });
@@ -2729,6 +2813,7 @@ export function apply(ctx, config) {
2729
2813
  cards: snapshot ? snapshot.cards : [],
2730
2814
  lines: snapshot ? snapshot.lines : [],
2731
2815
  matched: snapshot ? snapshot.matched : 0,
2816
+ task: currentTask,
2732
2817
  scope,
2733
2818
  query,
2734
2819
  sessionId,
@@ -2740,6 +2825,7 @@ export function apply(ctx, config) {
2740
2825
  at: Date.now(),
2741
2826
  };
2742
2827
  prepared.set(agent, state);
2828
+ if (snapshot && snapshot.ids && snapshot.ids.length) markUsed(scope, snapshot.ids);
2743
2829
  payloadBytes.set(sessionId, win.bytes);
2744
2830
  while (payloadBytes.size > 64) payloadBytes.delete(payloadBytes.keys().next().value);
2745
2831
  if (signal) signalAgents.set(signal, agent);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lite-memory",
3
- "version": "0.18.27",
3
+ "version": "0.18.29",
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/state.js CHANGED
@@ -143,8 +143,8 @@ export function applyControl(draft, enabled, mode) {
143
143
  evolution.changedAt = now;
144
144
  /** 默认绑定属于已确认结果;临时锚点和在途观察不属于。 */
145
145
  evolution.tasks = { ...emptyTasks(), ...(evolution.tasks && evolution.tasks.defaults ? { defaults: evolution.tasks.defaults } : {}) };
146
- /** 额度只增不减:次数与回执留着,最近观察清掉。 */
147
- evolution.learning = { ...ledger, recent: [] };
146
+ /** 额度只增不减:次数、回执和滚动时间戳都留着,关开不能把项目额度洗掉。 */
147
+ evolution.learning = ledger;
148
148
  }
149
149
  return { changed: true, enabled: next, mode: nextMode, epoch: config.epoch };
150
150
  }
@@ -190,8 +190,11 @@ export function applyEvolution(draft, enabled) {
190
190
  changedAt: now,
191
191
  /** 开与关都让上一轮锚点失效;已经确认的项目默认保留,它不属于临时作品状态。 */
192
192
  tasks: { ...emptyTasks(), ...(current.tasks && current.tasks.defaults ? { defaults: current.tasks.defaults } : {}) },
193
- /** 只清在途观察;已学规则的来源、版本和候选都在条目里,关开不动它们。 */
194
- learning: { ...normalizeLearning(current.learning), recent: [] },
193
+ /** 只清在途候选与回执;额度账本 recent 是滚动时间戳,开关/重启都得留着。 */
194
+ learning: {
195
+ ...normalizeLearning(current.learning),
196
+ recent: (Array.isArray(current.learning && current.learning.recent) ? current.learning.recent : []).slice(-32),
197
+ },
195
198
  };
196
199
  draft.experimental = { ...(draft.experimental || {}), projectEvolution: config };
197
200
  /** 显式关闭自进化:试用转暂停待复核,不删除候选与证据;重新打开后可逐条恢复。 */
package/store.js CHANGED
@@ -1587,13 +1587,24 @@ export function search(scope, query, limit = 4, options = {}) {
1587
1587
  archived: Boolean(row.archived),
1588
1588
  }));
1589
1589
  });
1590
- /** 锁外回写:在层锁里再抢同一层的锁会死锁。 */
1591
- for (const row of found) {
1592
- if (!row.archived) touchUsed(row.source, [row.record.id]);
1590
+ /** 锁外回写:在层锁里再抢同一层的锁会死锁;只给真正会交付给调用方的检索默认记账。 */
1591
+ if (options.markUsed !== false) {
1592
+ for (const row of found) {
1593
+ if (!row.archived) touchUsed(row.source, [row.record.id]);
1594
+ }
1593
1595
  }
1594
1596
  return found.map((row) => row.record);
1595
1597
  }
1596
1598
 
1599
+ /** 交付后再记账:调用方只把最终真给模型看过的条目交回来。 */
1600
+ export function markUsed(scope, ids) {
1601
+ const wanted = (Array.isArray(ids) ? ids : []).filter((id) => typeof id === 'string' && id);
1602
+ if (!wanted.length) return;
1603
+ for (const source of [scope, globalScope()]) {
1604
+ try { touchUsed(source, wanted); } catch { /* lastUsed 只是提示 */ }
1605
+ }
1606
+ }
1607
+
1597
1608
  /**
1598
1609
  * 只读命中:给自动提示用。不改 hits / lastUsed,不落盘。
1599
1610
  * 标题(含旧标题)必须真跟查询有交集,否则长句里零星重合也会冒出提示。
@@ -2511,11 +2522,27 @@ const MACHINE_BOUND_PATTERNS = [
2511
2522
  /\b\d{1,3}(?:\.\d{1,3}){3}:\d{2,5}\b/,
2512
2523
  ];
2513
2524
 
2514
- function machineBoundText(source) {
2515
- const text = [source?.title, source?.summary, source?.when, source?.body, source?.topicKey]
2525
+ function contentText(source) {
2526
+ return [source?.title, source?.summary, source?.when, source?.body, source?.topicKey]
2516
2527
  .filter((value) => typeof value === 'string' && value)
2517
2528
  .join('\n');
2518
- return MACHINE_BOUND_PATTERNS.some((re) => re.test(text));
2529
+ }
2530
+
2531
+ function machineBoundText(source) {
2532
+ return MACHINE_BOUND_PATTERNS.some((re) => re.test(contentText(source)));
2533
+ }
2534
+
2535
+ /** 内容自己说了「本项目 / 这个仓库」:模型给的类别标签不能把它抬到全局层。 */
2536
+ const PROJECT_BOUND_PATTERNS = [
2537
+ /本项目|这个项目|当前项目|该项目/,
2538
+ /本仓库|这个仓库|当前仓库/,
2539
+ /本工程|这个工程|当前工程/,
2540
+ /本目录|当前目录/,
2541
+ /本项目里|我们项目/,
2542
+ ];
2543
+
2544
+ function projectBoundText(source) {
2545
+ return PROJECT_BOUND_PATTERNS.some((re) => re.test(contentText(source)));
2519
2546
  }
2520
2547
 
2521
2548
  /**
@@ -2556,6 +2583,8 @@ export function routeScope(draft, options = {}) {
2556
2583
  };
2557
2584
  /** 人明确点过「就存全局」才算数;模型自己提的 global 不抵消本机痕迹。 */
2558
2585
  const humanConfirmed = options.humanScope === true || source.scopeConfirmed === true;
2586
+ /** 「本项目里…」是项目事实的措辞,类别写成偏好/流程也不能这样进通用层。 */
2587
+ const projectBound = projectBoundText(source);
2559
2588
  if (durability === 'task') return { ...base, needsScope: true, reasonCode: 'task_state' };
2560
2589
  if (explicit) {
2561
2590
  /**
@@ -2566,6 +2595,7 @@ export function routeScope(draft, options = {}) {
2566
2595
  if (explicit === 'global' && !humanConfirmed) {
2567
2596
  if (machineConflict) return fallbackToProject('machine_bound_scope');
2568
2597
  if (cls === 'project_fact') return fallbackToProject('model_global_project_fact');
2598
+ if (projectBound) return fallbackToProject('project_bound_scope');
2569
2599
  }
2570
2600
  if (explicit === 'project' && !projectReady) {
2571
2601
  const reasonCode = options.project && options.project.confirmed === false ? 'unconfirmed_project' : 'no_project';
@@ -2590,16 +2620,20 @@ export function routeScope(draft, options = {}) {
2590
2620
  if (cls === 'personal_preference') {
2591
2621
  /** 偏好也分「我的习惯」和「这台机器上的习惯」:后者绑机器,不进通用层。 */
2592
2622
  if (machineConflict) return fallbackToProject('machine_bound_preference');
2623
+ /** 说了「本项目」的偏好其实是项目约定,类别写错也不能跨项目。 */
2624
+ if (projectBound) return fallbackToProject('project_bound_preference');
2593
2625
  return { ...base, chosenScope: 'global', reasonCode: 'class_personal_preference' };
2594
2626
  }
2595
2627
  if (cls === 'portable_procedure') {
2596
2628
  if (machineConflict) return fallbackToProject('machine_bound_procedure');
2629
+ if (projectBound) return fallbackToProject('project_bound_procedure');
2597
2630
  /** 带适用环境条件的本机流程:条件成立时才用,换台机器自然不生效。 */
2598
2631
  if (envScoped) return { ...base, chosenScope: 'global', reasonCode: 'machine_scoped_procedure' };
2599
2632
  /** 说能搬去别的项目,就要有能搬的依据;没有就先留在本项目。 */
2600
2633
  if (!evidence) return fallbackToProject('unbound_evidence');
2601
2634
  return { ...base, chosenScope: 'global', reasonCode: 'class_portable_procedure' };
2602
2635
  }
2636
+ if (projectBound) return fallbackToProject('project_bound_environment');
2603
2637
  const conditions = view.normalizeConditions(source.conditions) || [];
2604
2638
  const scoped = conditions.some((item) => ENVIRONMENT_KEYS.includes(item.key));
2605
2639
  if (!scoped) {
@@ -2689,6 +2723,29 @@ function bindSameTopic(projectScope, ref) {
2689
2723
  /** 冲突队列:只记待办,不预写记忆;最多 20 条。 */
2690
2724
  const CONFLICT_LIMIT = 20;
2691
2725
 
2726
+ /** 待办里留一份可重放的草稿:面板能直接另存,不用用户回忆刚才想记什么。 */
2727
+ const CONFLICT_DRAFT_KEYS = ['title', 'summary', 'body', 'when', 'kind', 'tags', 'conditions', 'applicability', 'topicKey', 'source', 'origin'];
2728
+ function conflictDraftOf(draft) {
2729
+ const out = {};
2730
+ if (!draft || typeof draft !== 'object') return out;
2731
+ for (const key of CONFLICT_DRAFT_KEYS) {
2732
+ const value = draft[key];
2733
+ if (value === undefined || value === null) continue;
2734
+ if (key === 'title') out.title = normalizeText(value, 120);
2735
+ else if (key === 'summary') out.summary = normalizeText(value, 400);
2736
+ else if (key === 'body') out.body = typeof value === 'string' ? value.slice(0, 8000) : '';
2737
+ else if (key === 'when') out.when = normalizeText(value, 160);
2738
+ else if (key === 'kind') { if (KINDS.includes(value)) out.kind = value; }
2739
+ else if (key === 'tags') out.tags = Array.isArray(value) ? value.slice(0, 5) : undefined;
2740
+ else if (key === 'conditions') out.conditions = Array.isArray(value) ? value.slice(0, 8) : undefined;
2741
+ else if (key === 'applicability') out.applicability = value;
2742
+ else if (key === 'topicKey') out.topicKey = normalizeText(value, 80);
2743
+ else if (key === 'source') out.source = value;
2744
+ else if (key === 'origin') out.origin = value;
2745
+ }
2746
+ return out;
2747
+ }
2748
+
2692
2749
  function queueConflictLocked(scope, draft, candidates) {
2693
2750
  const index = loadIndex(scope);
2694
2751
  const title = normalizeText(draft && draft.title, 120);
@@ -2700,11 +2757,13 @@ function queueConflictLocked(scope, draft, candidates) {
2700
2757
  id: item.id, title: item.title || '', when: item.when || '', reason: item.reason || 'similar_title',
2701
2758
  }));
2702
2759
  const hit = rows.find((row) => row.title === title && row.scope === scopeKey);
2760
+ const replay = conflictDraftOf(draft);
2703
2761
  if (hit) {
2704
2762
  hit.candidates = view;
2763
+ hit.draft = replay;
2705
2764
  hit.updatedAt = now;
2706
2765
  } else {
2707
- rows.push({ id: `cf_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`, title, scope: scopeKey, candidates: view, createdAt: now, updatedAt: now });
2766
+ rows.push({ id: `cf_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`, title, scope: scopeKey, candidates: view, draft: replay, createdAt: now, updatedAt: now });
2708
2767
  }
2709
2768
  index.conflicts = rows.slice(-CONFLICT_LIMIT);
2710
2769
  saveIndex(scope, index);
@@ -2722,6 +2781,36 @@ function clearConflictLocked(scope, title) {
2722
2781
  return next.length;
2723
2782
  }
2724
2783
 
2784
+ /** 面板待裁决列表:只给可重放草稿与候选,不预写记忆。 */
2785
+ export function conflictsOf(scope) {
2786
+ const rows = loadIndex(scope).conflicts;
2787
+ return Array.isArray(rows) ? JSON.parse(JSON.stringify(rows)) : [];
2788
+ }
2789
+
2790
+ /** 确认另存:用队列里的草稿按 distinct 重放一次,成功后出队。 */
2791
+ export function resolveConflict(scope, title, options = {}) {
2792
+ const rows = Array.isArray(loadIndex(scope).conflicts) ? loadIndex(scope).conflicts : [];
2793
+ const norm = normalizeText(title, 120);
2794
+ const row = rows.find((item) => item.title === norm && item.scope === (scope.key === 'global' ? 'global' : 'project'));
2795
+ if (!row) return { ok: false, reason: '这条待裁决已经不在了,刷新面板看看' };
2796
+ const draft = { ...(row.draft || {}), resolution: 'distinct', writeMode: options.writeMode === 'append' ? 'append' : 'patch' };
2797
+ if (scope.key === 'global') draft.scope = 'global';
2798
+ else draft.scope = 'project';
2799
+ if (!draft.title) draft.title = row.title;
2800
+ const result = saveEntry(scope, draft);
2801
+ if (result.needsResolution) return { ok: false, reason: '还是相近:换一个标题或等确认', result };
2802
+ if (result.needsScope) return { ok: false, reason: '这条记忆的适用范围还没定,先说明当前环境', result };
2803
+ clearConflictLocked(scope, row.title);
2804
+ return { ok: true, result };
2805
+ }
2806
+
2807
+ /** 删掉待裁决:用户决定不再另存。 */
2808
+ export function dropConflict(scope, title) {
2809
+ const before = pendingConflictsOf(scope);
2810
+ clearConflictLocked(scope, title);
2811
+ return pendingConflictsOf(scope) < before;
2812
+ }
2813
+
2725
2814
  /** 当前层还有几条没处理的冲突。 */
2726
2815
  export function pendingConflictsOf(scope) {
2727
2816
  const rows = loadIndex(scope).conflicts;
@@ -190,6 +190,23 @@ const hitText = render('memory_search', {}, hits);
190
190
  assert.match(hitText, new RegExp(`^memory · 命中 ${hits.count} 条 · 引用 ${hits.chars} 字\\n- \\[[^\\]]+\\] 「`));
191
191
  assert.doesNotMatch(hitText, /mem_[a-z0-9]{6,}/, '检索结果只给标题与适用范围');
192
192
  assert.doesNotMatch(hitText, /第 .* 版/);
193
+ /** 用量只在真交付后才记:检索候选(尤其被预算/上限丢掉的那些)不算用过。 */
194
+ const contractScope = store.scopeOf(cwd);
195
+ await exec('memory_save', { title: '用量记账探针', summary: '只在交付后记 lastUsed', kind: 'note' });
196
+ const usedRow = () => store.loadIndex(contractScope).entries.find((item) => item.title === '用量记账探针');
197
+ const oldUse = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
198
+ const probeId = usedRow().id;
199
+ state.transact(contractScope, {}, (draft) => {
200
+ const row = draft.entries.find((item) => item.id === probeId);
201
+ row.maintenance.lastUsed = oldUse;
202
+ return { changed: true };
203
+ });
204
+ assert.equal(usedRow().lastUsed, oldUse);
205
+ const silent = store.search(contractScope, '用量记账探针', 50, { markUsed: false });
206
+ assert.ok(silent.length >= 1);
207
+ assert.equal(usedRow().lastUsed, oldUse, '候选检索不记 usage');
208
+ await exec('memory_search', { query: '用量记账探针' });
209
+ assert.notEqual(usedRow().lastUsed, oldUse, '真交付给模型后才记 usage');
193
210
  /** 「发版当天」这类自由文本条件没有可信上下文,不能当成适用条目塞给模型。 */
194
211
  const miss = await exec('memory_search', { query: '完全没有出现过的主题' });
195
212
  assert.equal(miss.count, 0);
@@ -254,6 +271,41 @@ assert.ok(ambiguousSave.error, '跨层同名要报改哪一层');
254
271
  assertSchema('memory_save', ambiguousSave);
255
272
  assert.match(render('memory_save', {}, ambiguousSave), /^memory · /);
256
273
  assert.doesNotMatch(render('memory_save', {}, ambiguousSave), /undefined|saved|merged/);
274
+ /** 同层同名不是「取第一条」:读、忘、更新都必须报歧义,按 id 仍能精确到条。 */
275
+ const twinScope = store.scopeOf(cwd);
276
+ const twinA = store.saveEntry(twinScope, { title: '同层重名探针', summary: '第一条', body: '正文甲', when: '机器甲', writeMode: 'patch' });
277
+ const twinB = store.saveEntry(twinScope, { title: '同层重名探针', summary: '第二条', body: '正文乙', when: '机器乙', writeMode: 'patch' });
278
+ const twinAId = twinA.record?.id ?? twinA.id;
279
+ const twinBId = twinB.record?.id ?? twinB.id;
280
+ assert.notEqual(twinAId, twinBId);
281
+ await assert.rejects(() => exec('memory_read', { title: '同层重名探针' }), /有 2 条同名/);
282
+ await assert.rejects(() => exec('memory_forget', { title: '同层重名探针', mode: 'archive' }), /有 2 条同名/);
283
+ const twinUpdate = await exec('memory_save', { title: '换个标题', summary: '尝试更新同名条目', updateTitle: '同层重名探针' });
284
+ assert.ok(twinUpdate.error, '同层同名更新要报歧义');
285
+ assert.match(twinUpdate.error, /有 2 条同名/);
286
+ assertSchema('memory_save', twinUpdate);
287
+ assert.equal(store.readEntry(twinScope, twinAId).body, '正文甲', '按 id 读不能被同名条目串走');
288
+ assert.equal(store.readEntry(twinScope, twinBId).body, '正文乙');
289
+
290
+ /** 重命名后仍按原 id 定位到同一条;归档后重建同名条目也不能冒领旧 id。 */
291
+ const renameSaved = store.saveEntry(twinScope, { title: '改名前的探针', summary: '旧身份', body: '旧正文', writeMode: 'patch' });
292
+ const renameSeedId = renameSaved.record?.id ?? renameSaved.id;
293
+ const renamed = await exec('memory_save', { title: '改名后的探针', summary: '旧身份', updateTitle: '改名前的探针' });
294
+ assert.equal(renamed.changed, true);
295
+ assert.equal(store.locateByTitle(twinScope, '改名后的探针').record.id, renameSeedId, '改名必须落在同一条记录上');
296
+ const oldIdRead = store.readEntry(twinScope, renameSeedId);
297
+ assert.equal(oldIdRead.title, '改名后的探针');
298
+ assert.equal(oldIdRead.body, '旧正文');
299
+ await exec('memory_forget', { id: renameSeedId, mode: 'archive' });
300
+ const recreated = await exec('memory_save', { title: '改名后的探针', summary: '新身份', body: '新正文' });
301
+ const recreatedId = store.locateByTitle(twinScope, '改名后的探针').record.id;
302
+ assert.notEqual(recreatedId, renameSeedId, '删掉再建是新的身份');
303
+ const staleRead = await exec('memory_read', { id: renameSeedId });
304
+ assert.equal(staleRead.status, 'archived', '旧 id 只能读到旧条目的归档状态');
305
+ assert.equal(staleRead.body, '');
306
+ const freshRead = await exec('memory_read', { id: recreatedId });
307
+ assert.equal(freshRead.body, '新正文');
308
+
257
309
  const missingSave = await exec('memory_save', { title: '别的标题', summary: '没有这条', updateTitle: '根本不存在的条目' });
258
310
  assert.ok(missingSave.error);
259
311
  assertSchema('memory_save', missingSave);
@@ -672,6 +724,8 @@ assert.equal(closedTool.changed, false);
672
724
  assert.equal(closedTool.reason, 'memory_disabled');
673
725
  assertSchema('memory_save', closedTool);
674
726
  assert.equal(closedTool.id, undefined);
727
+ /** 关闭态的保存回执也要能渲染:不能等用户真去写一条才报 ReferenceError。 */
728
+ assert.match(render('memory_save', {}, closedTool), /^memory · 本项目记忆已关闭/);
675
729
  /** 关闭状态下的读取也只报状态,不返回正文。 */
676
730
  const closedRead = await defs.get('memory_read').execute({ title: closedSeed.title }, { agent: closedAgent });
677
731
  assert.equal(closedRead.status, 'memory_disabled');
@@ -1156,4 +1210,26 @@ assert.equal(ovArchivedBody.status, 200);
1156
1210
  assert.equal(ovArchivedBody.body.status, 'archived');
1157
1211
  assert.equal(ovArchivedBody.body.body, '右侧留白。');
1158
1212
 
1213
+ // 待裁决:面板看得到队列,确认后按草稿另存,不预写正文
1214
+ const cfCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-conflict-'));
1215
+ sessionCwds.set('session-conflict-panel', cfCwd);
1216
+ const cfAgent = { id: 'session-conflict-panel', cwd: cfCwd, session: { id: 'session-conflict-panel', header: { cwd: cfCwd } } };
1217
+ await defs.get('memory_save').execute({ title: '发布窗口', summary: '只在周四晚上发版', body: '周四晚上再发版。', scope: 'global' }, { agent: cfAgent });
1218
+ const cfSave = await defs.get('memory_save').execute({ title: '发布窗口', summary: '本项目周五发版', body: '周五发版。' }, { agent: cfAgent });
1219
+ assert.equal(cfSave.needsResolution, true);
1220
+ assert.equal(store.loadIndex(store.scopeOf(cfCwd)).entries.length, 0, '没确认前不落库');
1221
+ const cfBefore = await call('/lite-memory/overview', { sessionId: 'session-conflict-panel' });
1222
+ assert.equal(cfBefore.status, 200);
1223
+ assert.equal(cfBefore.body.selected.conflicts.length, 1);
1224
+ assert.equal(cfBefore.body.selected.conflicts[0].title, '发布窗口');
1225
+ assert.equal(cfBefore.body.selected.conflicts[0].draft.body, '周五发版。');
1226
+ const cfKeep = await call('/lite-memory/conflict', { sessionId: 'session-conflict-panel', scopeKey: cfBefore.body.selected.key, title: '发布窗口', op: 'keep' });
1227
+ assert.equal(cfKeep.status, 200);
1228
+ assert.equal(cfKeep.body.ok, true);
1229
+ const cfAfter = await call('/lite-memory/overview', { sessionId: 'session-conflict-panel' });
1230
+ assert.equal(cfAfter.body.selected.conflicts.length, 0);
1231
+ assert.ok(cfAfter.body.selected.records.some((row) => row.title === '发布窗口' && row.summary === '本项目周五发版'));
1232
+ const cfAgain = await call('/lite-memory/conflict', { sessionId: 'session-conflict-panel', scopeKey: cfBefore.body.selected.key, title: '发布窗口', op: 'keep' });
1233
+ assert.equal(cfAgain.status, 409, '队列没了就明确说,不重复落库');
1234
+
1159
1235
  console.log('contract.test.mjs: all passed');
@@ -288,8 +288,11 @@ const toggle = (scope, enabled) => store.setMemoryEnabled(scope, enabled, { oper
288
288
  assert.equal(store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a1' }).ok, true);
289
289
  assert.equal(store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a2' }).ok, true);
290
290
  assert.equal(store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a3' }).ok, false);
291
+ const ledgerBefore = store.loadIndex(scope).__evolution.learning.recent.length;
292
+ assert.ok(ledgerBefore >= 2, '先有滚动额度记录');
291
293
  toggle(scope, false);
292
294
  toggle(scope, true);
295
+ assert.ok(store.loadIndex(scope).__evolution.learning.recent.length >= ledgerBefore, '关开不重置滚动额度账本');
293
296
  assert.deepEqual(store.taskState(scope).defaults, { ...defaults, confirmedBy: 'user', confirmedAt: '' });
294
297
  assert.equal(store.setEvolution(scope, true, { operationId: 'evo-quota-again' }).ok, true);
295
298
  const limited = store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a4' });
@@ -11,6 +11,7 @@ const view = await import('../view.js');
11
11
  const {
12
12
  saveEntry, routeScope, visibleRules, move, resumeMove, movePending,
13
13
  upsert, forget, readLinks, loadIndex, scopeOf, globalScope, readEntry, pendingConflictsOf,
14
+ conflictsOf, resolveConflict, dropConflict,
14
15
  } = store;
15
16
 
16
17
  let passed = 0;
@@ -59,6 +60,34 @@ await test('S03 项目事实留在项目层', () => {
59
60
  assert.equal(idsOf(scope).length, 1);
60
61
  });
61
62
 
63
+ await test('S03 模型给的类别标签不能把「本项目」抬进全局层', () => {
64
+ const scope = fresh('route-project-bound');
65
+ const pref = saveEntry(scope, {
66
+ title: '本项目回复用中文',
67
+ summary: '这个项目里回复用中文',
68
+ body: '本项目统一中文、简短',
69
+ memoryClass: 'personal_preference',
70
+ });
71
+ assert.equal(pref.chosenScope, 'project');
72
+ assert.equal(pref.reasonCode, 'project_bound_preference');
73
+ assert.equal(stateOf(globalScope()).entries.filter((row) => row.current.title === '本项目回复用中文').length, 0);
74
+ const proc = saveEntry(scope, {
75
+ title: '本项目构建',
76
+ summary: '这个仓库构建用 pnpm',
77
+ body: '本仓库统一 pnpm build',
78
+ memoryClass: 'portable_procedure',
79
+ evidenceRef: 'README',
80
+ });
81
+ assert.equal(proc.chosenScope, 'project');
82
+ assert.equal(proc.reasonCode, 'project_bound_procedure');
83
+ /** 人自己确认「就存全局」时,措辞推断不能压过明确指令。 */
84
+ const human = routeScope({
85
+ title: '本项目回复用中文', body: '本项目统一中文、简短', memoryClass: 'personal_preference', scope: 'global',
86
+ }, { humanScope: true, project: { confirmed: true } });
87
+ assert.equal(human.chosenScope, 'global');
88
+ assert.equal(human.reasonCode, 'explicit');
89
+ });
90
+
62
91
  await test('S03 设备事实没给设备条件就返回 needsScope,不落盘', () => {
63
92
  const scope = fresh('route-env');
64
93
  const saved = saveEntry(scope, { title: '本机 Node', summary: '本机 Node 版本', body: 'v24', memoryClass: 'environment_fact' });
@@ -390,4 +419,22 @@ await test('S12 相近保存进待办队列,确认 distinct 后出队', () =>
390
419
  assert.equal(pendingConflictsOf(scope), 0);
391
420
  });
392
421
 
422
+ await test('S12 待裁决留可重放草稿,面板确认另存后才落库', () => {
423
+ const scope = fresh('conflict-replay');
424
+ saveEntry(globalScope(), { title: '提交信息格式', summary: '用一句话说清做了什么', body: '全局约定', resolution: 'distinct' });
425
+ const near = saveEntry(scope, { title: '提交信息格式', summary: '本项目用中文写提交信息', body: '项目补充', when: '本仓库' });
426
+ assert.equal(near.needsResolution, true);
427
+ assert.equal(loadIndex(scope).entries.length, 0, '没确认前不能预写正文');
428
+ const [row] = conflictsOf(scope);
429
+ assert.equal(row.title, '提交信息格式');
430
+ assert.equal(row.draft.body, '项目补充');
431
+ assert.equal(row.draft.when, '本仓库');
432
+ const kept = resolveConflict(scope, '提交信息格式');
433
+ assert.equal(kept.ok, true);
434
+ assert.equal(pendingConflictsOf(scope), 0);
435
+ assert.equal(loadIndex(scope).entries.length, 1);
436
+ assert.equal(loadIndex(scope).entries[0].body, '项目补充');
437
+ assert.equal(dropConflict(scope, '不存在'), false);
438
+ });
439
+
393
440
  console.log(`crossing.test.mjs: ${passed} passed`);
@@ -601,3 +601,39 @@ const scopeOf = (dir) => store.scopeOf(dir);
601
601
  }
602
602
 
603
603
  console.log('evolution-read.test.mjs: all passed');
604
+
605
+ /* L23:被取代 / 已过期的实验规则不再从实验通道注入正文。 */
606
+ {
607
+ const dir = mkdir('lifecycle');
608
+ const scope = scopeOf(dir);
609
+ const superseded = seedRule(scope, { title: '旧开场规则', body: '旧规则正文:开场先自我介绍。', conditions: [{ key: 'seriesId', value: '口播' }] });
610
+ const expired = seedRule(scope, { title: '过期规则', body: '过期规则正文:开场念固定slogan。', conditions: [{ key: 'seriesId', value: '口播' }] });
611
+ enable(scope, { seriesId: '口播' });
612
+ state.transact(scope, {}, (draft) => {
613
+ const first = draft.entries.find((entry) => entry.id === superseded);
614
+ first.supersededBy = 'mem_deadbeef';
615
+ const second = draft.entries.find((entry) => entry.id === expired);
616
+ second.validUntil = new Date(Date.now() - 60_000).toISOString();
617
+ return { changed: true };
618
+ });
619
+ const runtime = boot();
620
+ const agent = makeAgent('a-lifecycle', dir);
621
+ const sig = signal();
622
+ await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期口播开场怎么改')));
623
+ const text = runtime.text(agent, sig);
624
+ assert.equal(text.includes('旧规则正文'), false, '被取代的规则不能注入');
625
+ assert.equal(text.includes('过期规则正文'), false, '过期规则不能注入');
626
+ }
627
+
628
+ /* L24:没有声明条件的实验规则,问题不相关时也不靠「有规则」硬注入。 */
629
+ {
630
+ const dir = mkdir('unbound');
631
+ const scope = scopeOf(dir);
632
+ seedRule(scope, { title: '导出参数', body: '导出统一用 H.264。', conditions: [] });
633
+ enable(scope, { seriesId: '口播' });
634
+ const runtime = boot();
635
+ const agent = makeAgent('a-unbound', dir);
636
+ const sig = signal();
637
+ await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期封面标题怎么写')));
638
+ assert.equal(runtime.text(agent, sig).includes('导出统一用 H.264'), false, '无关问题不注入宽泛规则');
639
+ }
@@ -587,3 +587,29 @@ console.log('recall.test.mjs: ok');
587
587
  await runtime.preStep({ agent, signal: second, turn: 't2' }, enter(assistantMessage, human('这个怎么弄')));
588
588
  assert.match(runtime.provider.text({ agent, signal: second }), /改完 client\.js 先重载宿主/, '动作侧线索要能召回预防型记忆');
589
589
  }
590
+
591
+ // A26:明确换任务后,不能拿上一轮助手的话去补查、把旧任务记忆塞进新任务。
592
+ {
593
+ const cwdSwitch = mkdir('switch');
594
+ const scopeSwitch = store.scopeOf(cwdSwitch);
595
+ save(scopeSwitch, {
596
+ title: '口播开场先给结论',
597
+ tags: ['口播', '开场'],
598
+ summary: '口播开场三句内先给结论,别先铺垫',
599
+ });
600
+ const agent = makeAgent('agent-switch', cwdSwitch);
601
+ const runtime = boot();
602
+ const first = signal();
603
+ await runtime.preStep({ agent, signal: first, turn: 't1' }, enter(human('这期口播开场怎么改')));
604
+ const assistantMessage = {
605
+ role: 'assistant',
606
+ id: 'asst-switch-1',
607
+ content: [
608
+ { type: 'text', text: '口播开场先给结论,栏目风格保持短句。' },
609
+ { type: 'tool-call', name: 'write', input: { file_path: '/tmp/switch/talk.md' } },
610
+ ],
611
+ };
612
+ const second = signal();
613
+ await runtime.preStep({ agent, signal: second, turn: 't2' }, enter(assistantMessage, human('开始写 PostgreSQL 迁移脚本,先建表')));
614
+ assert.equal(runtime.provider.text({ agent, signal: second }), '', '换任务后不补查旧任务的动作线索');
615
+ }
@@ -633,7 +633,7 @@ test('P0-B 坏 state 且没有 prev:报错给出损坏副本与可执行的恢
633
633
  assert.equal(fs.readFileSync(stateFile(scope), 'utf8'), broken);
634
634
  });
635
635
 
636
- test('D05 学习开关关掉再开,不重置已用次数和整理记录', () => {
636
+ test('D05 学习开关关掉再开,不重置已用次数、滚动额度与整理记录', () => {
637
637
  const scope = fresh('d05-learning-budget');
638
638
  const at = new Date().toISOString();
639
639
  transact(scope, {}, (draft) => {
@@ -647,7 +647,7 @@ test('D05 学习开关关掉再开,不重置已用次数和整理记录', () =
647
647
  });
648
648
  const opened = load(scope).state.experimental.projectEvolution;
649
649
  assert.equal(opened.enabled, true);
650
- assert.deepEqual(opened.learning.recent, [], '打开时清掉在途观察');
650
+ assert.deepEqual(opened.learning.recent, [at], '滚动额度时间戳要留着,关开不洗额度');
651
651
  assert.equal(opened.learning.receipts.length, 1, '打开不能清整理回执');
652
652
  assert.equal(opened.learning.attempts.ep_seed.count, 2, '打开不能清已用次数');
653
653
  transact(scope, {}, (draft) => state.applyEvolution(draft, false));
@@ -656,7 +656,7 @@ test('D05 学习开关关掉再开,不重置已用次数和整理记录', () =
656
656
  assert.equal(again.enabled, true);
657
657
  assert.equal(again.learning.receipts.length, 1, '关掉再开不重置整理回执');
658
658
  assert.equal(again.learning.attempts.ep_seed.count, 2, '关掉再开不重置已用次数');
659
- assert.deepEqual(again.learning.recent, []);
659
+ assert.deepEqual(again.learning.recent, [at], '关掉再开也不能洗掉滚动额度');
660
660
  });
661
661
 
662
662
  for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
package/view.js CHANGED
@@ -710,12 +710,37 @@ export function isContinuationQuery(text) {
710
710
  return /^(继续|接着|然后|然后呢|还有|还有呢|往下|说下去|goon|continue|next|more)$/.test(raw);
711
711
  }
712
712
 
713
+ /**
714
+ * 补查只有在「还是同一个任务」时才成立:任务类型或 episode 变了,上一轮的动作历史属于旧任务,
715
+ * 不能拿它给新任务找记忆。缺任一侧身份都按不同任务处理,宁可少补一次也不串旧话题。
716
+ */
717
+ export function sameRecallTask(previous, current) {
718
+ if (!previous || !current) return true;
719
+ const kind = (value) => plain(value && value.kind, 40).trim().toLowerCase();
720
+ const episode = (value) => plain(value && value.episodeId, 120).trim();
721
+ /** 两侧都认出了任务类型且不同:明确换任务,上一轮的动作历史不再属于当前请求。 */
722
+ if (kind(previous) && kind(current) && kind(previous) !== kind(current)) return false;
723
+ /** 同类型但是另一个 episode:同类任务的新一轮,也不沿用旧任务的动作线索。 */
724
+ if (kind(previous) && kind(current) && episode(previous) && episode(current)) {
725
+ return episode(previous) === episode(current);
726
+ }
727
+ /** 认不出类型(或只有一侧有)时保留补查:这正是该机制要救的场景。 */
728
+ return true;
729
+ }
730
+
713
731
  /**
714
732
  * 动作侧检索词:用户这句话没提,但「刚才在动什么」往往就写在上一轮助手的话和工具名里。
715
733
  * 预防型记忆(先重载再重启、改插件后别用 Cmd+R)约束的是下一步动作,
716
734
  * 只拿用户原话查,词面永远在错的一侧。这里只取少量最近的实词和工具名,避免长文稀释命中。
717
735
  */
718
736
  const ACTION_NOISE = new Set(['const', 'function', 'return', 'import', 'export', 'from', 'async', 'await', 'true', 'false', 'null', 'undefined', 'string', 'number', 'object']);
737
+ const MEMORY_TOOL = /^memory(?:[_-]|$)/i;
738
+ const MEMORY_TEXT = /^(?:memory ·|已有记忆可能相关|这条记忆|有些实验规则)/u;
739
+ /** 记忆工具的调用与记忆自身的呈现文本不能充当新召回线索:否则记忆会自己触发自己。 */
740
+ function isMemoryNoise(name, text) {
741
+ if (name && MEMORY_TOOL.test(String(name))) return true;
742
+ return Boolean(text && MEMORY_TEXT.test(String(text).trimStart()));
743
+ }
719
744
  export function actionTerms(messages, options = {}) {
720
745
  if (!Array.isArray(messages)) return '';
721
746
  const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 16;
@@ -734,6 +759,7 @@ export function actionTerms(messages, options = {}) {
734
759
  for (const part of content) {
735
760
  if (!part || typeof part !== 'object') continue;
736
761
  if (part.type === 'tool-call' || part.type === 'tool_call') {
762
+ if (isMemoryNoise(part.name, '')) continue;
737
763
  if (typeof part.name === 'string') push(part.name);
738
764
  const input = part.input || part.arguments || part.args;
739
765
  if (input && typeof input === 'object') {
@@ -745,7 +771,7 @@ export function actionTerms(messages, options = {}) {
745
771
  }
746
772
  continue;
747
773
  }
748
- if (part.type === 'text' && typeof part.text === 'string') text += `${part.text}\n`;
774
+ if (part.type === 'text' && typeof part.text === 'string' && !isMemoryNoise('', part.text)) text += `${part.text}\n`;
749
775
  }
750
776
  return text;
751
777
  };