dsh-lite-memory 0.18.26 → 0.18.28

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.
@@ -83,9 +83,10 @@
83
83
  **范围路由**:模型给的 `scope` 是提案,不是命令。
84
84
 
85
85
  - 显式 `scope="global"` 表示「用户在这一次对话里要求存全局」,只有用户确实这么说才传;模型自己觉得通用不是理由。显式全局不再因为缺少 `evidenceRef` 被降级。
86
- - 仍然会拦住的两类:设备事实没有条件(`machine` / `targetOs` / `runtime` / `shell` 至少一个),以及正文或依据里出现本机路径、回环地址、端口的「通用流程」——后者换台机器/换个项目就不成立,留在项目层。
86
+ - 仍然会拦住的两类:设备事实没有条件(`machine` / `targetOs` / `runtime` / `shell` 至少一个),以及正文或依据里写死本机路径、回环地址、端口却没声明适用环境的「通用流程」——后者换台机器/换个项目就不成立,留在项目层。
87
+ - 声明了上述环境条件的本机流程(例如本机特有的命令包装)可以进全局层:条件不成立的机器上不会拿出来用;命令名/工具名本身不算本机痕迹。
87
88
  - `memoryClass="project_fact"` 不因写了 `global` 就变全局:项目事实换个项目本来就不成立。
88
- - 按类别推导(没有显式 `scope`)保持更严:`portable_procedure` 没有 `evidenceRef` 就落项目层。
89
+ - 按类别推导(没有显式 `scope`)保持更严:`portable_procedure` 没有 `evidenceRef`、也没有环境条件时落项目层。
89
90
  - 每次降级都要能解释:只在降级时返回 `scopeNote` 一行,说明为什么没进全局、怎么改才进得去;正常落全局或落项目不带这行。
90
91
 
91
92
  **限额与腾位**:每层活跃条目软上限 200、硬上限 1000;单条正文 4096 字节;单层总字节 1 MiB。到硬上限时把最久没被读的活跃条目移进归档,并在结果里写明「已归档「标题」腾位」,不拒绝新记忆;单条正文超限只报错、不截断。
@@ -0,0 +1,5 @@
1
+ # 0.18.27
2
+
3
+ - 可信上下文补上宿主真实平台(`darwin → macos`,`win32 → windows`):条件写 `targetOs` 的规则从「永远待确认」变成按平台裁决,macOS 上写的本机流程在 macOS 上直接生效。
4
+ - 此前全局层只能收「声明了 machine/runtime 条件」的流程,但条件无从比对;这次把能自动核验的那一维接上,条件不再只是摆设。
5
+ - 召回夹具改用与平台无关的条件,避免 CI 与开发机行为不一致。
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,
@@ -919,7 +920,39 @@ const FORGET_PARAMS = {
919
920
  additionalProperties: false,
920
921
  };
921
922
 
922
- /** 对外只认标题:id 留作内部地址,模型和界面都不用碰。 */
923
+ /**
924
+ * 按标题解析内部身份。对外仍只显示标题,但解析必须能说明「唯一 / 同层歧义 / 不存在」:
925
+ * 同层同名是正常情况(不同前置条件各一条),静默取第一条会读错、写错。
926
+ */
927
+ function titleHitsIn(target, title, { includeArchived = false } = {}) {
928
+ let index;
929
+ try { index = loadIndex(target); } catch { return []; }
930
+ const rows = includeArchived ? (index.archived || []) : (index.entries || []);
931
+ return rows.filter((entry) => entry && (includeArchived || entry.status !== 'archived')
932
+ && String(entry.title || '').trim() === title);
933
+ }
934
+
935
+ function candidateNote(entry) {
936
+ const conditions = Array.isArray(entry.conditions) ? entry.conditions.filter((row) => row && row.key) : [];
937
+ const parts = [];
938
+ if (entry.when) parts.push(String(entry.when));
939
+ if (conditions.length) parts.push(conditions.map((row) => `${row.key}=${row.value}`).join('·'));
940
+ return parts.join(',');
941
+ }
942
+
943
+ /** 同层同名时把候选条件一起报出来,模型/用户才能决定改哪条或先归档哪条。 */
944
+ function ambiguousTitleError(title, hits, label) {
945
+ const detail = hits.slice(0, 4).map((entry) => {
946
+ const note = candidateNote(entry);
947
+ return note ? `「${title}」(${note})` : `「${title}」`;
948
+ }).join('、');
949
+ return `${label}「${title}」有 ${hits.length} 条同名,无法确定是哪一条:${detail}。先归档其中一条,或换一个能区分的标题再试`;
950
+ }
951
+
952
+ /**
953
+ * 返回 { id } 或抛出:显式层只查那一层;默认先项目后全局(别的项目不参与解析)。
954
+ * 同一层出现多条同名一律报歧义,绝不取第一条。
955
+ */
923
956
  function refIdOf(scope, args, label = '记忆') {
924
957
  const id = typeof args.id === 'string' ? args.id.trim() : '';
925
958
  if (id) return id;
@@ -928,34 +961,30 @@ function refIdOf(scope, args, label = '记忆') {
928
961
  /** 显式指定层时只解析那一层:同名条目不会被顺手改掉,项目层也不会命中全局。 */
929
962
  const explicit = args && args.scope === 'global' ? globalScope() : args && args.scope === 'project' ? scope : null;
930
963
  const sources = explicit ? [explicit] : runtimeLayers(scope);
931
- /** 运行时只解析当前项目与全局:别的项目正文不进本会话。 */
964
+ let crossLayerHit = null;
932
965
  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;
966
+ const hits = titleHitsIn(target, title);
967
+ if (hits.length > 1) throw new Error(ambiguousTitleError(title, hits, label));
968
+ if (hits.length === 1 && !crossLayerHit) crossLayerHit = hits[0];
938
969
  }
970
+ if (crossLayerHit) return crossLayerHit.id;
939
971
  /** 忘掉已归档的条目也要能按标题解析到;归档不会因此重新参与召回。 */
940
972
  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;
973
+ const hits = titleHitsIn(target, title, { includeArchived: true });
974
+ if (hits.length > 1) throw new Error(ambiguousTitleError(title, hits, label));
975
+ if (hits.length === 1) return hits[0].id;
946
976
  }
947
- throw new Error(`没有「${title}」这条记忆;先用 memory_search 对一下标题`);
977
+ throw new Error(`没有「${title}」这条${label};先用 memory_search 对一下标题`);
948
978
  }
949
979
 
950
980
  /** 同名条目可能分布在项目层和全局层:更新前先报候选,不能先匹配。 */
951
981
  function titleCandidates(scope, title) {
952
982
  const out = [];
953
983
  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' });
984
+ const hits = titleHitsIn(target, title);
985
+ if (!hits.length) continue;
986
+ if (hits.length > 1) out.push({ ambiguous: true, title, hits, scope: target.key === 'global' ? 'global' : 'project' });
987
+ else out.push({ id: hits[0].id, scope: target.key === 'global' ? 'global' : 'project', note: candidateNote(hits[0]) });
959
988
  }
960
989
  return out;
961
990
  }
@@ -1073,6 +1102,8 @@ export function apply(ctx, config) {
1073
1102
  };
1074
1103
  /** 已准备好的快照只挂在 Agent 上,弱引用随手回收,不跨会话串。 */
1075
1104
  const prepared = new WeakMap();
1105
+ /** 最近一次请求的任务身份:本轮没注入任何内容时 prepared 会清掉,补查仍需要知道上一轮任务。 */
1106
+ const lastTask = new Map();
1076
1107
  /** turn-stopping 只回 signal,不带头 agent;这里记 signal → agent 供收尾清理。 */
1077
1108
  const signalAgents = new Map();
1078
1109
  const generations = new WeakMap();
@@ -1273,7 +1304,7 @@ export function apply(ctx, config) {
1273
1304
  win.recallHeader = true;
1274
1305
  }
1275
1306
  lines.push(line);
1276
- cards.push({ revision, scope: card.scope, title: card.title });
1307
+ cards.push({ id: card.id, revision, scope: card.scope, title: card.title });
1277
1308
  /** 卡片行与到期时间一起留到最后输出:中间隔了系统提示组装,不能拿投影时的判断当现在。 */
1278
1309
  linesMeta.push({ line, validUntil: row.record.validUntil || '' });
1279
1310
  }
@@ -1318,8 +1349,9 @@ export function apply(ctx, config) {
1318
1349
  : waiting ? 'needs_context'
1319
1350
  : (gateRuntime && gateRuntime.enabled ? 'no_overlap' : 'needs_context');
1320
1351
  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 };
1352
+ const ids = cards.map((card) => card.id).filter(Boolean);
1353
+ if (!cards.length) return { text: '', cards: [], lines: [], ids, bytes: 0, query, reason, matched };
1354
+ return { text: finalText, cards, lines: linesMeta, ids, bytes: Buffer.byteLength(finalText), query, reason, matched };
1323
1355
  }
1324
1356
 
1325
1357
  /* ---------------- 项目自进化:任务锚点、读取决策、上下文解析 ---------------- */
@@ -1491,21 +1523,31 @@ export function apply(ctx, config) {
1491
1523
  const unknown = [];
1492
1524
  const byTopic = new Map();
1493
1525
  let conflict = false;
1526
+ const now = Date.now();
1527
+ const query = held && held.query ? String(held.query) : '';
1528
+ const runtime = { projectId: scope.key, experimental: true, context: context || {}, task: (held && held.task) || {} };
1494
1529
  for (const record of index.entries) {
1495
1530
  if (record.origin !== 'project_evolution') continue;
1496
1531
  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;
1532
+ const score = query ? view.matchScore(record, query).score : 0;
1533
+ /** 生命周期、归属、学习状态、适用条件统一走 eligibility:被取代、过期、暂停都不该从实验通道溜进来。 */
1534
+ const usable = view.eligibility(record, runtime, 'auto', now);
1535
+ if (!usable.ok) {
1536
+ if (usable.status === 'needs_context' && usable.reason !== 'condition_mismatch'
1537
+ && score >= view.MATCH_FLOOR) unknown.push(record);
1538
+ continue;
1539
+ }
1540
+ /**
1541
+ * 实验规则的任务条件本身就是相关度:栏目/阶段都对上,说明这条规则就属于当前这一集。
1542
+ * 没有声明条件的规则仍要过词面门,避免一条宽泛规则在所有请求里都出现。
1543
+ */
1544
+ const conditions = Array.isArray(record.conditions) ? record.conditions.filter((row) => row && row.key) : [];
1545
+ const bound = conditions.length > 0;
1546
+ if (!bound && score < view.MATCH_FLOOR) continue;
1505
1547
  const topic = record.topicKey || record.id;
1506
1548
  if (byTopic.has(topic) && byTopic.get(topic).body !== record.body) conflict = true;
1507
1549
  byTopic.set(topic, record);
1508
- matched.push(record);
1550
+ matched.push({ record, score: bound ? Math.max(score, view.MATCH_FLOOR) : score });
1509
1551
  }
1510
1552
  if (conflict) {
1511
1553
  return chargeExperiment(win, '这些实验规则互相冲突,先按用户这次说的做,别叠加对立的规则。');
@@ -1520,15 +1562,10 @@ export function apply(ctx, config) {
1520
1562
  ? Math.max(0, view.CARD.bytes - Buffer.byteLength(auto.normalText || '') - 2)
1521
1563
  : EXPERIMENT_BYTES;
1522
1564
  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;
1565
+ const relevant = matched
1566
+ .slice()
1567
+ .sort((a, b) => b.score - a.score)
1568
+ .map((row) => row.record);
1532
1569
  for (const record of relevant.slice(0, cardCap)) {
1533
1570
  const body = String(record.body || '');
1534
1571
  const short = body && body.length <= EXPERIMENT_SHORT_CHARS && body.split('\n').length <= 4;
@@ -1649,6 +1686,7 @@ export function apply(ctx, config) {
1649
1686
  projectKey: scope.key, sessionId, batchKey, messageIds, query,
1650
1687
  context, task, anchor: routed.anchor, ranges, decision, epoch,
1651
1688
  experimental: enabled,
1689
+ episodeId: (episode && episode.episodeId) || (routed.anchor && routed.anchor.episodeId) || '',
1652
1690
  episodeUpdatedAt: episode ? episode.updatedAt : '', injectedIds: [],
1653
1691
  at: Date.now(),
1654
1692
  };
@@ -1889,6 +1927,7 @@ export function apply(ctx, config) {
1889
1927
  archived: { type: 'string' },
1890
1928
  pendingConflicts: { type: 'integer' },
1891
1929
  error: { type: 'string' },
1930
+ offCopy: { type: 'string' },
1892
1931
  },
1893
1932
  required: ['title', 'summary', 'when', 'revision', 'merged', 'changed', 'scope', 'needsResolution', 'archived'],
1894
1933
  additionalProperties: false,
@@ -1899,7 +1938,7 @@ export function apply(ctx, config) {
1899
1938
  ? `memory · ${value.error}`
1900
1939
  : value.needsScope
1901
1940
  ? value.reason === 'memory_disabled'
1902
- ? `memory · ${memoryOffCopy(projectScope)}`
1941
+ ? `memory · ${value.offCopy || '本项目记忆已关闭,先在面板打开'}`
1903
1942
  : value.reason === 'unconfirmed_project'
1904
1943
  ? `memory · 当前目录还不能确认属于哪个项目:「${value.title}」\n先确认项目根目录,或显式 scope="global"`
1905
1944
  : `memory · 归属还没定:「${value.title}」\n补 memoryClass(personal_preference / project_fact / environment_fact / portable_procedure)或显式 scope;设备事实还要给设备条件`
@@ -1919,7 +1958,7 @@ export function apply(ctx, config) {
1919
1958
  return {
1920
1959
  title: String(args.title || ''), summary: '', when: '', revision: 0,
1921
1960
  merged: false, changed: false, scope: '', needsScope: true, reason: 'memory_disabled',
1922
- needsResolution: false, similarTitles: '', similar: '', archived: '',
1961
+ needsResolution: false, similarTitles: '', similar: '', archived: '', offCopy: memoryOffCopy(projectScope),
1923
1962
  };
1924
1963
  }
1925
1964
  const supersedes = String(args.supersedes || '').trim();
@@ -1932,13 +1971,17 @@ export function apply(ctx, config) {
1932
1971
  /** 更新句柄也用标题:id 只是底层实现,模型抄标题就行。 */
1933
1972
  const updateTitle = String(args.updateTitle || '').trim();
1934
1973
  if (updateTitle) {
1935
- const candidates = args.scope ? [] : titleCandidates(projectScope, updateTitle);
1936
- if (candidates.length > 1) {
1974
+ const candidates = titleCandidates(projectScope, updateTitle);
1975
+ const ambiguous = candidates.find((row) => row.ambiguous);
1976
+ /** 同层同名是允许的(不同前置条件各一条),但不能替用户挑第一条改。 */
1977
+ if (ambiguous) return saveFailure(ambiguousTitleError(updateTitle, ambiguous.hits, '记忆'), args);
1978
+ if (!args.scope && candidates.length > 1) {
1937
1979
  return saveFailure(`「${updateTitle}」在项目层和全局层各有一条:改全局加 scope="global",改项目加 scope="project"`, args);
1938
1980
  }
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;
1981
+ const layer = args.scope === 'global' ? 'global' : args.scope === 'project' ? 'project' : '';
1982
+ const picked = layer ? candidates.find((row) => row.scope === layer) : candidates[0];
1983
+ if (!picked) return saveFailure(`没有「${updateTitle}」这条记忆;要新增就不用 updateTitle`, args);
1984
+ draft.id = picked.id;
1942
1985
  }
1943
1986
  /** 关联目标同样按标题解析成内部 id 再落盘。 */
1944
1987
  if (Array.isArray(args.links) && args.links.length) {
@@ -1946,8 +1989,10 @@ export function apply(ctx, config) {
1946
1989
  for (const link of args.links) {
1947
1990
  const title = typeof link?.title === 'string' ? link.title.trim() : '';
1948
1991
  if (!title) return saveFailure('关联要写成 {type, title}:给被关联那条记忆的标题', args);
1949
- const targetId = (() => { try { return refIdOf(projectScope, { title }, '记忆'); } catch { return ''; } })();
1950
- if (!targetId) return saveFailure(`没有「${title}」这条记忆,关联没写成`, args);
1992
+ let targetId = '';
1993
+ try { targetId = refIdOf(projectScope, { title }, '记忆'); } catch (error) {
1994
+ return saveFailure(String(error && error.message ? error.message : `没有「${title}」这条记忆`), args);
1995
+ }
1951
1996
  resolved.push({ type: link.type || 'related', id: targetId });
1952
1997
  }
1953
1998
  draft.links = resolved;
@@ -2162,6 +2207,8 @@ export function apply(ctx, config) {
2162
2207
  context,
2163
2208
  history: args.history === true,
2164
2209
  allowExperimental: resolved.experimental === true,
2210
+ /** 「用过」要等真正给了模型看再记:检索到的前 50 条不是交付。 */
2211
+ markUsed: false,
2165
2212
  }).filter((item) => !item.ownerProjectId || item.ownerProjectId === scope.key)
2166
2213
  .filter((item) => item.archived || view.eligibility(item, gateRuntime, 'search').ok);
2167
2214
  const hits = rawHits.filter((item) => item.archived || item.status === 'ok').slice(0, limit);
@@ -2219,10 +2266,12 @@ export function apply(ctx, config) {
2219
2266
  win.toolBytes += jsonBytes(value);
2220
2267
  win.reads += 1;
2221
2268
  recordCitations(scope, sessionIdOf(execution), hits.slice(0, kept));
2269
+ markUsed(scope, hits.slice(0, kept).map((item) => item.id));
2222
2270
  return value;
2223
2271
  }
2224
2272
  const value = valueOf(shown.length);
2225
2273
  recordCitations(scope, sessionIdOf(execution), hits);
2274
+ markUsed(scope, hits.map((item) => item.id));
2226
2275
  return value;
2227
2276
  },
2228
2277
  }), 'lite-memory: memory_search');
@@ -2554,7 +2603,7 @@ export function apply(ctx, config) {
2554
2603
  .filter((message) => message?.source?.kind === 'user');
2555
2604
  const fresh = freshHumans(sessionId, humans);
2556
2605
  const previous = prepared.get(agent);
2557
- const sameSession = previous && previous.sessionId === sessionId;
2606
+ const sameSession = Boolean(previous && previous.sessionId === sessionId);
2558
2607
 
2559
2608
  if (fresh.length === 0) {
2560
2609
  /** 工具续步:内容冻结,只把快照绑到本步的 signal;盘上版本变了才重投影。 */
@@ -2651,7 +2700,6 @@ export function apply(ctx, config) {
2651
2700
  /** 任务上下文独立于自进化开关:普通记忆按当前任务判适用,实验规则再叠一层可用性。 */
2652
2701
  const auto = emptyAutoBudget();
2653
2702
  if (win) win.autoBudget = auto;
2654
- const experiment = runtime ? experimentFor(scope, runtime, win) : null;
2655
2703
  const recalledContext = runtime ? runtime.held.context : context;
2656
2704
  const remembered = runtime && runtime.enabled
2657
2705
  ? rememberFeedback(scope, sessionId, fresh, runtime.held, turn)
@@ -2661,7 +2709,16 @@ export function apply(ctx, config) {
2661
2709
  * 预防型记忆约束的是「下一步动作」,词面常写在上一轮助手的话和工具调用里,用户这句话反而没提。
2662
2710
  * 第一次检索一张卡都没有时,用最近的实词/工具名补一次有界查询,预算仍是同一份。
2663
2711
  */
2664
- if (!snapshot.cards.length && !carried) {
2712
+ const currentTask = {
2713
+ kind: (runtime && runtime.held && runtime.held.task && runtime.held.task.kind) || '',
2714
+ episodeId: (runtime && runtime.held && runtime.held.episodeId) || '',
2715
+ };
2716
+ const rememberedTask = lastTask.get(sessionId) || null;
2717
+ const previousTask = previous && previous.task ? previous.task : rememberedTask;
2718
+ const sameTask = Boolean(sameSession || rememberedTask) && view.sameRecallTask(previousTask, currentTask);
2719
+ lastTask.set(sessionId, currentTask);
2720
+ while (lastTask.size > 64) lastTask.delete(lastTask.keys().next().value);
2721
+ if (!snapshot.cards.length && !carried && sameTask) {
2665
2722
  const humanTail = (() => {
2666
2723
  const messages = Array.isArray(decision.messages) ? decision.messages : [];
2667
2724
  for (let i = messages.length - 1; i >= 0; i -= 1) {
@@ -2680,6 +2737,8 @@ export function apply(ctx, config) {
2680
2737
  }
2681
2738
  }
2682
2739
  }
2740
+ /** 实验规则最后才取预算:普通卡片先占,弱相关实验不能把强相关项目规则挤出去。 */
2741
+ const experiment = runtime ? experimentFor(scope, runtime, win) : null;
2683
2742
  noteRecall(scope, sessionId, {
2684
2743
  reason: snapshot.reason, matched: snapshot.matched, cards: snapshot.cards.length, chars: snapshot.bytes,
2685
2744
  });
@@ -2729,6 +2788,7 @@ export function apply(ctx, config) {
2729
2788
  cards: snapshot ? snapshot.cards : [],
2730
2789
  lines: snapshot ? snapshot.lines : [],
2731
2790
  matched: snapshot ? snapshot.matched : 0,
2791
+ task: currentTask,
2732
2792
  scope,
2733
2793
  query,
2734
2794
  sessionId,
@@ -2740,6 +2800,7 @@ export function apply(ctx, config) {
2740
2800
  at: Date.now(),
2741
2801
  };
2742
2802
  prepared.set(agent, state);
2803
+ if (snapshot && snapshot.ids && snapshot.ids.length) markUsed(scope, snapshot.ids);
2743
2804
  payloadBytes.set(sessionId, win.bytes);
2744
2805
  while (payloadBytes.size > 64) payloadBytes.delete(payloadBytes.keys().next().value);
2745
2806
  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.26",
3
+ "version": "0.18.28",
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) {
@@ -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);
@@ -460,6 +512,8 @@ const pass = (text, id) => async () => ({
460
512
  messages: [{ role: 'user', id, source: { kind: 'user' }, content: [{ type: 'text', text }] }],
461
513
  });
462
514
  const recall = promptContexts.find((entry) => entry.name === 'lite-memory:recall');
515
+ /* CI 冷启动偶尔超过召回耗时上限,逻辑断言不该被「这轮先不注入」判失败。 */
516
+ globalThis.__liteMemoryRecall?.setDeadlineMs?.(5000);
463
517
  assert.ok(recall, 'recall context registered');
464
518
  assert.equal(recall.order, 60);
465
519
  assert.equal(recall.interpolate, false);
@@ -545,6 +599,8 @@ const evoEnter = async () => ({
545
599
  });
546
600
  await preStep({ agent: evoAgent, signal: evoSig, turn: '21' }, evoEnter);
547
601
  const evoRecall = promptContexts.find((entry) => entry.name === 'lite-memory:recall');
602
+ /* CI 冷启动偶尔超过召回耗时上限,逻辑断言不该被「这轮先不注入」判失败。 */
603
+ globalThis.__liteMemoryRecall?.setDeadlineMs?.(5000);
548
604
  assert.match(evoRecall.text({ agent: evoAgent, signal: evoSig }), /开场三句内给结论/);
549
605
  assert.equal(evoRecall.text({ agent: evoAgent, signal: evoSig }).includes('README'), false);
550
606
 
@@ -668,6 +724,8 @@ assert.equal(closedTool.changed, false);
668
724
  assert.equal(closedTool.reason, 'memory_disabled');
669
725
  assertSchema('memory_save', closedTool);
670
726
  assert.equal(closedTool.id, undefined);
727
+ /** 关闭态的保存回执也要能渲染:不能等用户真去写一条才报 ReferenceError。 */
728
+ assert.match(render('memory_save', {}, closedTool), /^memory · 本项目记忆已关闭/);
671
729
  /** 关闭状态下的读取也只报状态,不返回正文。 */
672
730
  const closedRead = await defs.get('memory_read').execute({ title: closedSeed.title }, { agent: closedAgent });
673
731
  assert.equal(closedRead.status, 'memory_disabled');
@@ -33,6 +33,8 @@ function boot() {
33
33
  };
34
34
  apply(ctx, { mode: 'adaptive' });
35
35
  const provider = promptContexts.find((entry) => entry.name === 'lite-memory:recall');
36
+ /* CI 冷启动偶尔超过召回耗时上限,逻辑断言不该被「这轮先不注入」判失败。 */
37
+ globalThis.__liteMemoryRecall?.setDeadlineMs?.(5000);
36
38
  const exec = (name, args, agent) => defs.get(name).execute(args, { agent });
37
39
  const preStep = (payload, decision) => handlers.get('agent/pre-step').at(-1)(payload, async () => decision);
38
40
  return { exec, preStep, text: (agent, signal) => provider.text({ agent, signal }), debug: globalThis.__liteMemoryRecall };
@@ -286,8 +288,11 @@ const toggle = (scope, enabled) => store.setMemoryEnabled(scope, enabled, { oper
286
288
  assert.equal(store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a1' }).ok, true);
287
289
  assert.equal(store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a2' }).ok, true);
288
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, '先有滚动额度记录');
289
293
  toggle(scope, false);
290
294
  toggle(scope, true);
295
+ assert.ok(store.loadIndex(scope).__evolution.learning.recent.length >= ledgerBefore, '关开不重置滚动额度账本');
291
296
  assert.deepEqual(store.taskState(scope).defaults, { ...defaults, confirmedBy: 'user', confirmedAt: '' });
292
297
  assert.equal(store.setEvolution(scope, true, { operationId: 'evo-quota-again' }).ok, true);
293
298
  const limited = store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a4' });
@@ -59,6 +59,28 @@ await test('S03 项目事实留在项目层', () => {
59
59
  assert.equal(idsOf(scope).length, 1);
60
60
  });
61
61
 
62
+ await test('S03 模型给的类别标签不能把「本项目」抬进全局层', () => {
63
+ const scope = fresh('route-project-bound');
64
+ const pref = saveEntry(scope, {
65
+ title: '本项目回复用中文',
66
+ summary: '这个项目里回复用中文',
67
+ body: '本项目统一中文、简短',
68
+ memoryClass: 'personal_preference',
69
+ });
70
+ assert.equal(pref.chosenScope, 'project');
71
+ assert.equal(pref.reasonCode, 'project_bound_preference');
72
+ assert.equal(stateOf(globalScope()).entries.filter((row) => row.current.title === '本项目回复用中文').length, 0);
73
+ const proc = saveEntry(scope, {
74
+ title: '本项目构建',
75
+ summary: '这个仓库构建用 pnpm',
76
+ body: '本仓库统一 pnpm build',
77
+ memoryClass: 'portable_procedure',
78
+ evidenceRef: 'README',
79
+ });
80
+ assert.equal(proc.chosenScope, 'project');
81
+ assert.equal(proc.reasonCode, 'project_bound_procedure');
82
+ });
83
+
62
84
  await test('S03 设备事实没给设备条件就返回 needsScope,不落盘', () => {
63
85
  const scope = fresh('route-env');
64
86
  const saved = saveEntry(scope, { title: '本机 Node', summary: '本机 Node 版本', body: 'v24', memoryClass: 'environment_fact' });
@@ -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
+ }
@@ -39,6 +39,8 @@ function boot(mode = 'adaptive') {
39
39
  apply(ctx, { mode });
40
40
  const debug = globalThis.__liteMemoryRecall;
41
41
  const provider = promptContexts.find((entry) => entry.name === 'lite-memory:recall');
42
+ /* CI 冷启动偶尔超过召回耗时上限,逻辑断言不该被「这轮先不注入」判失败。 */
43
+ globalThis.__liteMemoryRecall?.setDeadlineMs?.(5000);
42
44
  assert.ok(provider, 'recall provider registered');
43
45
  assert.equal(provider.order, 60);
44
46
  assert.equal(provider.interpolate, false);
@@ -111,8 +113,8 @@ const linuxOnly = save(scopeA, {
111
113
  tags: ['插件', '构建'],
112
114
  when: '',
113
115
  applicability: 'conditional',
114
- conditions: [{ key: 'targetOs', value: 'linux' }],
115
- summary: '只在 Linux CI 上跑构建检查',
116
+ conditions: [{ key: 'machine', value: '别的机器' }],
117
+ summary: '只在另一台机器上跑构建检查',
116
118
  });
117
119
  const unresolved = save(scopeA, { title: '发布前要检查什么', tags: ['发布', '检查'], when: '发布前', applicability: 'unresolved' });
118
120
  const globalHot = save(global, { title: '插件改动后先热刷新', tags: ['插件', '热刷新'], summary: '全局规矩:先热刷新再谈重启' });
@@ -266,10 +268,10 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
266
268
  const cwdC = mkdir('c');
267
269
  const scopeC = store.scopeOf(cwdC);
268
270
  save(scopeC, {
269
- title: '只在 Linux 上跑构建检查',
271
+ title: '只在另一台机器上跑构建检查',
270
272
  tags: ['构建'],
271
273
  applicability: 'conditional',
272
- conditions: [{ key: 'targetOs', value: 'linux' }],
274
+ conditions: [{ key: 'machine', value: '别的机器' }],
273
275
  });
274
276
  save(scopeC, { title: '发布前检查清单', tags: ['发布'], when: '发布前', applicability: 'unresolved' });
275
277
  const agent = makeAgent('agent-cond', cwdC);
@@ -585,3 +587,29 @@ console.log('recall.test.mjs: ok');
585
587
  await runtime.preStep({ agent, signal: second, turn: 't2' }, enter(assistantMessage, human('这个怎么弄')));
586
588
  assert.match(runtime.provider.text({ agent, signal: second }), /改完 client\.js 先重载宿主/, '动作侧线索要能召回预防型记忆');
587
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
+ }
@@ -33,6 +33,8 @@ function boot(mode = 'adaptive') {
33
33
  };
34
34
  apply(ctx, { mode });
35
35
  const provider = promptContexts.find((entry) => entry.name === 'lite-memory:recall');
36
+ /* CI 冷启动偶尔超过召回耗时上限,逻辑断言不该被「这轮先不注入」判失败。 */
37
+ globalThis.__liteMemoryRecall?.setDeadlineMs?.(5000);
36
38
  assert.ok(provider, 'recall provider registered');
37
39
  const exec = (name, args, agent) => defs.get(name).execute(args, { agent });
38
40
  const preStep = (payload, decision) => handlers.get('agent/pre-step').at(-1)(payload, async () => decision);
@@ -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 });
@@ -70,7 +70,11 @@ test('条件裁决:不把不知道当不存在,也不把未解决当普遍
70
70
  const record = { id: 'mem_000000000001', applicability: 'conditional', conditions: [{ key: 'targetOs', value: 'macOS' }], when: '' };
71
71
  assert.equal(view.evaluate(record, ctxOf(null, { targetOs: 'macos' })).state, 'match');
72
72
  assert.equal(view.evaluate(record, ctxOf(null, { targetOs: 'linux' })).state, 'mismatch');
73
- assert.equal(view.evaluate(record, ctxOf(null, {})).state, 'unknown');
73
+ assert.equal(ctxOf(null, {}).targetOs, view.hostTargetOs());
74
+ const hostRecord = { ...record, conditions: [{ key: 'targetOs', value: view.hostTargetOs() || 'macos' }] };
75
+ assert.equal(view.evaluate(hostRecord, ctxOf(null, {})).state, 'match');
76
+ const machineOnly = { ...record, conditions: [{ key: 'machine', value: '这台机器' }] };
77
+ assert.equal(view.evaluate(machineOnly, ctxOf(null, {})).state, 'unknown');
74
78
  const unresolved = { ...record, applicability: 'unresolved', conditions: [] };
75
79
  assert.equal(view.statusOf(unresolved, ctxOf(null, {})), 'needs_context');
76
80
  assert.equal(view.canAutoRecall(unresolved, ctxOf(null, {})), false);
package/view.js CHANGED
@@ -152,7 +152,8 @@ export function validateApplicability(applicability, conditions, selection, when
152
152
  export function trustedContext(scope, options = {}) {
153
153
  const cwd = typeof options.cwd === 'string' && options.cwd ? options.cwd : (scope && scope.cwd) || '';
154
154
  return {
155
- targetOs: options.targetOs,
155
+ /** 宿主的真实平台是可信上下文:条件里写 targetOs 的规则本职就是按平台生效。 */
156
+ targetOs: options.targetOs === undefined ? hostTargetOs() : options.targetOs,
156
157
  shell: options.shell,
157
158
  runtime: options.runtime,
158
159
  machine: options.machine,
@@ -166,6 +167,15 @@ export function trustedContext(scope, options = {}) {
166
167
  };
167
168
  }
168
169
 
170
+ /** 只报归一化后的平台名;未知平台不硬猜,留给条件判 unknown。 */
171
+ export function hostTargetOs() {
172
+ const platform = typeof process !== 'undefined' && process ? process.platform : '';
173
+ if (platform === 'darwin') return 'macos';
174
+ if (platform === 'win32') return 'windows';
175
+ if (platform === 'linux') return 'linux';
176
+ return '';
177
+ }
178
+
169
179
  function sameText(left, right) {
170
180
  return plain(left, PATH_CHARS).toLowerCase() === plain(right, PATH_CHARS).toLowerCase();
171
181
  }
@@ -700,12 +710,37 @@ export function isContinuationQuery(text) {
700
710
  return /^(继续|接着|然后|然后呢|还有|还有呢|往下|说下去|goon|continue|next|more)$/.test(raw);
701
711
  }
702
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
+
703
731
  /**
704
732
  * 动作侧检索词:用户这句话没提,但「刚才在动什么」往往就写在上一轮助手的话和工具名里。
705
733
  * 预防型记忆(先重载再重启、改插件后别用 Cmd+R)约束的是下一步动作,
706
734
  * 只拿用户原话查,词面永远在错的一侧。这里只取少量最近的实词和工具名,避免长文稀释命中。
707
735
  */
708
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
+ }
709
744
  export function actionTerms(messages, options = {}) {
710
745
  if (!Array.isArray(messages)) return '';
711
746
  const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 16;
@@ -724,6 +759,7 @@ export function actionTerms(messages, options = {}) {
724
759
  for (const part of content) {
725
760
  if (!part || typeof part !== 'object') continue;
726
761
  if (part.type === 'tool-call' || part.type === 'tool_call') {
762
+ if (isMemoryNoise(part.name, '')) continue;
727
763
  if (typeof part.name === 'string') push(part.name);
728
764
  const input = part.input || part.arguments || part.args;
729
765
  if (input && typeof input === 'object') {
@@ -735,7 +771,7 @@ export function actionTerms(messages, options = {}) {
735
771
  }
736
772
  continue;
737
773
  }
738
- 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`;
739
775
  }
740
776
  return text;
741
777
  };