dsh-lite-memory 0.18.34 → 0.18.35

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/README.md CHANGED
@@ -29,12 +29,16 @@ Lightweight memory for the DeepSeek Harness. Nothing resident: no standing promp
29
29
 
30
30
  真命中看这句话和记忆的标题(含旧标题)、标签、触发词、摘要有没有对得上的词:有就多一行「已有记忆可能相关」,最多三条标题,每条前面标归属(`[global]` 或项目目录名如 `[DSH]`);没对上就不出现,不改命中计数,正文要 `memory_read` 才读。一条记忆写了触发词,用户说到那个词也算命中。
31
31
 
32
+ 普通记忆卡片和实验规则共用一份额度:一次最多 3 条、1200 字节,按整条准入,先到先占;自动提示只带标题和摘要,不带正文。实验短规则(正文不超过 240 字且不超过 4 行)直接带正文,更长的只给标题和用途,正文仍要 `memory_read`。
33
+
32
34
  | Case | Behaviour |
33
35
  | --- | --- |
34
36
  | **L0** you ask it to remember | written immediately, one confirmation line |
35
37
  | **L1** it spots something worth keeping | one muted line `memory · suggested "X" · sug_x` with a single 「保留」 button — readable before you click, nothing written unless you click, at most once per two days |
36
38
  | **L2** common sense, one-off parameters, credential values | never written |
37
39
 
40
+ The automatic hint carries titles and summaries, never bodies. Normal cards and experimental rules share one budget: at most 3 cards / 1200 bytes per request, admitted as whole cards, first come first served. An experimental rule with a body of at most 240 characters and 4 lines is inlined; a longer one gives only its title and purpose, and the body still requires `memory_read`.
41
+
38
42
  Those lines belong to the turn that made them: each turn is rebuilt from its own tool results, so reopening the session shows them again and no other turn leaks in. Saving again updates an entry only when the title (or one of its earlier titles) matches exactly under the same precondition. A merely similar title writes nothing first: the result names the closest entry, so the caller either re-saves with that title or adds `resolution="distinct"`. An update keeps every field it was not given and replaces the body wholesale; the previous body is kept only for internal undo and never shown or searched. The same subject under different preconditions stays two entries.
39
43
 
40
44
  Writes land in one of two layers: the current project by default, or the global layer (warns past 200, hard ceiling 1000) when the user says the rule holds for every project. Both layers are searched together, and a `when` field separates the same subject under different preconditions.
package/bench/bench.mjs CHANGED
@@ -38,7 +38,7 @@ apply(ctx);
38
38
 
39
39
  const cwd = path.join(os.tmpdir(), 'lite-memory-bench-project');
40
40
  fs.mkdirSync(cwd, { recursive: true });
41
- const agent = { cwd, session: { id: 'bench-session', header: { cwd } } };
41
+ const agent = { id: 'bench-agent', cwd, session: { id: 'bench-session', header: { cwd } } };
42
42
  const exec = (name, args) => defs.get(name).execute(args, { agent });
43
43
  const save = (args) => exec('memory_save', args);
44
44
 
@@ -63,16 +63,22 @@ for (let i = 0; i < 200; i += 1) {
63
63
  resolution: 'distinct',
64
64
  });
65
65
  }
66
+ /**
67
+ * 全局层用受信任写入直接种:经 memory_save 的模型提案会被降到项目层,
68
+ * 这里要测的是两层都在时的召回路径,不是降级路径。
69
+ */
70
+ const globalScope = store.globalScope();
66
71
  for (let i = 0; i < 200; i += 1) {
67
- save({
72
+ const saved = store.saveEntry(globalScope, {
68
73
  title: `全局条目 ${i} 交付规范`,
69
74
  summary: `全局条目 ${i} 的通用说明`,
70
75
  when: `全局场景 ${i}`,
71
76
  body: `全局正文 ${i}:交付前清零告警。`,
72
- scope: 'global',
73
77
  applicability: 'all_in_scope',
78
+ scopeConfirmed: true,
74
79
  resolution: 'distinct',
75
80
  });
81
+ if (!saved || !saved.record) throw new Error(`global seed ${i} failed: ${JSON.stringify(saved).slice(0, 500)}`);
76
82
  }
77
83
  report.seedMs = Date.now() - started;
78
84
  report.entries = {
@@ -108,6 +114,19 @@ report.prepare = {
108
114
  avgContextBytes: Math.round(bytes / timings.length),
109
115
  };
110
116
 
117
+ // Positive scenario: a normal question must actually receive budgeted memory text.
118
+ const positiveSignal = new AbortController().signal;
119
+ await preStep({ agent, signal: positiveSignal, turn: '9000' }, async () => ({
120
+ kind: 'enter',
121
+ messages: [{ role: 'user', id: 'bench-positive', source: { kind: 'user' }, content: [{ type: 'text', text: '全局条目 7 的交付规范是什么' }] }],
122
+ }));
123
+ const positiveText = recall.text({ agent, signal: positiveSignal });
124
+ report.positive = {
125
+ bytes: Buffer.byteLength(positiveText, 'utf8'),
126
+ hasGlobalCard: /全局条目 7 交付规范/.test(positiveText),
127
+ underCap: Buffer.byteLength(positiveText, 'utf8') <= 1300,
128
+ };
129
+
111
130
  // File reads per prepare, measured on the same path.
112
131
  let reads = 0;
113
132
  const original = fs.readFileSync;
@@ -140,6 +159,9 @@ report.afterSequence = {
140
159
  files: fs.readdirSync(store.scopeOf(cwd).dir).sort(),
141
160
  };
142
161
  report.rssEndMB = rss();
143
- report.ok = project.entries.length <= store.LIMITS.entries + store.LIMITS.hardEntries;
162
+ report.ok = project.entries.length <= store.LIMITS.entries + store.LIMITS.hardEntries
163
+ && report.entries.project >= 200 && report.entries.global >= 200
164
+ && report.prepare.avgContextBytes > 0
165
+ && report.positive.bytes > 0 && report.positive.hasGlobalCard && report.positive.underCap;
144
166
 
145
167
  console.log(JSON.stringify(report, null, 2));
package/docs/BENCH.md CHANGED
@@ -25,21 +25,22 @@ npm run bench:evolution # 实验规则/样例/检查点/回执的存储与投
25
25
 
26
26
  ## 固定数据集性能
27
27
 
28
- 2026-10-27,macOS arm64,Node v24.16.0,本地 SSD。数据:项目 200 条 + 全局 200 条,全部带标题、摘要、`when`、正文。
28
+ 2026-10-29,macOS arm64,Node v24.16.0,本地 SSD。数据:项目 200 条 + 全局 200 条,全部带标题、摘要、`when`、正文;另加一条正面场景,确认全局卡片真的进得来且不超预算。
29
29
 
30
30
  0.18.0 起每次保存都走「写临时文件 → fsync → rename → fsync 目录」并保留上一版,代价是单次保存多两次落盘;下面这组数是带这套持久化保证后测的。
31
31
 
32
32
  | 指标 | 数值 |
33
33
  | --- | --- |
34
- | 准备时延 P50 | 27.9 ms |
35
- | 准备时延 P95 | 31.8 ms |
36
- | 冷启动首次准备 | 37 ms |
37
- | 最慢一次 | 37 ms |
38
- | 每次准备上下文 | 402 B |
39
- | 每次准备文件读取 | 0 |
40
- | 120 次运行总字节 | 48 KB |
34
+ | 准备时延 P50 | 62.3 ms |
35
+ | 准备时延 P95 | 112.4 ms |
36
+ | 冷启动首次准备 | 83.9 ms |
37
+ | 最慢一次 | 136.1 ms |
38
+ | 每次准备上下文 | 368 B |
39
+ | 正面场景上下文 | 335 B,含 1 张全局卡片,未超 3 条 / 1200 字节 |
40
+ | 每次准备文件读取 | 0.2 |
41
+ | 本次数据规模 | 项目 200 条 + 全局 200 条 |
41
42
 
42
- 本地目标改为热准备 P95 ≤ 40 ms(deadline 150 ms 不变):每次保存都要落盘两遍才敢说没丢,30 ms 的旧目标只适用于不落盘的版本。当前 P95 31.8 ms 达标。模型 provider 在准备阶段无 I/O、无网络。
43
+ 数据翻倍到两层各 200 条后,准备时延比 0.18.0 的 200 条单层基线高;deadline 150 ms 不变,当前 P95 112.4 ms 仍在限内。模型 provider 在准备阶段无 I/O、无网络。
43
44
 
44
45
  ## 长期序列
45
46
 
@@ -49,10 +50,10 @@ npm run bench:evolution # 实验规则/样例/检查点/回执的存储与投
49
50
  | --- | --- |
50
51
  | 项目活跃条目 | 350 |
51
52
  | 全局活跃条目 | 200 |
52
- | `state.json` | 338 KB(上限 2 MiB) |
53
+ | `state.json` | 396 KB(上限 2 MiB) |
53
54
  | 层内文件 | `state.json`、`state.prev.json` 两个,不随轮数增长 |
54
- | RSS | 165 MB 量级(含进程内缓存与测试数据) |
55
- | 序列耗时 | 18.4 s(150 轮全落盘) |
55
+ | RSS | 190 MB 量级(含进程内缓存与测试数据) |
56
+ | 序列耗时 | 7.3 s(150 轮全落盘) |
56
57
 
57
58
  ## 实验整理成本(0.11.0)
58
59
 
@@ -0,0 +1,11 @@
1
+ # 0.18.35:召回账本能对上,跨项目写入要人点头
2
+
3
+ - npm 原生凭据命令在执行前拦一次:`npm whoami` / `publish` / `login` / `token` / `config get` 会直接收到已有记忆里那条「发布一律走 dsh-npm」的正文,同一请求只提示一次,`dsh-npm` 自己的命令不受影响。
4
+ - 自动注入只有一份预算:普通卡片与实验规则合计最多 3 条、1200 字节,按整条准入;最终交付文本的字节数和卡片数与账本一致,准备阶段被丢弃、到期撤下的条目不再记使用。
5
+ - 任务状态和展示快照分开:第一步零命中不再丢掉请求身份;之后只有真正的执行动作(写文件、跑命令等)触发一次补查,读文件、`memory_search`/`memory_read` 的输出不花这次机会;换任务后旧动作线索不跟过来。
6
+ - 使用记账推迟到回合收尾批量写,provider 输出路径不碰写锁;回合结束、请求替换和 agent 销毁都不会漏掉最后一轮的使用记录。
7
+ - 跨项目写入只认真人原话:模型写进 `memory_save` 参数里的 `scope` 只是提案,自动整理产物一律留在项目层;只有本轮明确说「存到全局/所有项目/跨项目」才允许落全局,普通说「全局变量」不会误判成授权。
8
+ - 学习条件统一校验:收集阶段带进来的 `taskKind` / `seriesId` 是可信范围,模型新提的条件仍要落在本项目已有栏目或阶段里;同一主题连续纠正收敛到一条现行版,换栏目不适用,关掉再开还是同一现行版。
9
+ - 基准改成真实的跨层召回:200 条项目记忆 + 200 条可信全局记忆,加一条正面场景,确认全局卡片确实进得来且不超预算。
10
+
11
+ 验证:`npm test` 全绿;`npm run bench` 在本机固定数据集上通过。
package/index.js CHANGED
@@ -1176,10 +1176,20 @@ export function apply(ctx, config) {
1176
1176
 
1177
1177
  const agentKey = (agent) => String(agent?.id ?? '');
1178
1178
 
1179
+ /** 回合收尾/换请求时批量记账:一次锁,不在 provider 输出路径上。 */
1180
+ const flushUsage = (state) => {
1181
+ if (!state || !state.scope || !(state.deliveredIds instanceof Set) || state.deliveredIds.size === 0) return;
1182
+ const ids = [...state.deliveredIds].filter(Boolean);
1183
+ state.deliveredIds = new Set();
1184
+ if (!ids.length) return;
1185
+ try { markUsed(state.scope, ids); } catch { /* usage is best-effort */ }
1186
+ };
1187
+
1179
1188
  const clearPrepared = (agent) => {
1180
1189
  if (!agent) return;
1181
1190
  const state = prepared.get(agent);
1182
1191
  if (state && state.signal) signalAgents.delete(state.signal);
1192
+ flushUsage(state);
1183
1193
  prepared.delete(agent);
1184
1194
  const key = agentKey(agent);
1185
1195
  const cleanup = key ? abortCleanups.get(key) : null;
@@ -1920,8 +1930,11 @@ export function apply(ctx, config) {
1920
1930
  const now = Date.now();
1921
1931
  const live = state.lines.filter((row) => !row.validUntil || now <= Date.parse(String(row.validUntil)));
1922
1932
  normalText = live.length ? `${RECALL_HEADER}\n${live.map((row) => row.line).join('\n')}` : '';
1923
- /** 真正交付的卡片才记使用:到期被撤下的、准备后被丢弃的都不算。 */
1924
- if (live.length && state.scope) markUsed(state.scope, live.map((row) => row.id).filter(Boolean));
1933
+ /** 真正交付的卡片才记使用;记账推迟到回合收尾,provider 不碰写锁。 */
1934
+ if (live.length && state.scope) {
1935
+ if (!(state.deliveredIds instanceof Set)) state.deliveredIds = new Set();
1936
+ for (const row of live) if (row.id) state.deliveredIds.add(row.id);
1937
+ }
1925
1938
  /** 投影时还在、发出时已过期:回执跟着真实输出改,别在面板上说「提醒了 2 条」而正文是空的。 */
1926
1939
  if (state.scope && live.length !== state.cards.length) {
1927
1940
  noteRecall(state.scope, state.sessionId, {
@@ -2024,8 +2037,21 @@ export function apply(ctx, config) {
2024
2037
  const supersedesId = supersedes && !/^mem_[a-z0-9]+$/.test(supersedes)
2025
2038
  ? (() => { try { return refIdOf(projectScope, { title: supersedes }, '记忆'); } catch { return ''; } })()
2026
2039
  : supersedes;
2027
- /** 工具写入的正文由模型写,来源标成模型整理,不能冒充用户原话。 */
2028
- const draft = { ...args, writeMode: args.mode || 'patch', origin: 'model_drafted' };
2040
+ /**
2041
+ * 模型写进参数里的 scope 只是提案:只有本轮真人原话明确说「所有项目/全局」,
2042
+ * 或面板直接确认过,才允许落到全局层。工具写入的正文由模型写,不能冒充用户原话。
2043
+ */
2044
+ const preparedState = prepared.get(execution && execution.agent);
2045
+ const humanSaid = String(preparedState && preparedState.saidOnly || '');
2046
+ const humanGlobal = humanAskedGlobal(humanSaid);
2047
+ const draft = {
2048
+ ...args,
2049
+ writeMode: args.mode || 'patch',
2050
+ origin: 'model_drafted',
2051
+ scopeConfirmed: humanGlobal,
2052
+ requestedScope: args.scope,
2053
+ };
2054
+ if (humanGlobal) draft.scope = 'global'; else delete draft.scope;
2029
2055
  if (supersedes) { if (supersedesId) draft.supersedes = supersedesId; else delete draft.supersedes; }
2030
2056
  /** 更新句柄也用标题:id 只是底层实现,模型抄标题就行。 */
2031
2057
  const updateTitle = String(args.updateTitle || '').trim();
@@ -2097,8 +2123,9 @@ export function apply(ctx, config) {
2097
2123
  /** 显式要了全局却被硬冲突挡回项目层时说清原因:否则模型只能看到 scope=project,无从纠。 */
2098
2124
  const scopeNote = (() => {
2099
2125
  if (args.scope !== 'global' || scope.key === 'global') return '';
2100
- if (saved.reasonCode === 'machine_bound_scope') return '按本机痕迹留在项目层:正文或依据里写了本机路径/端口,改成别的项目仍成立的通用规则再存全局;只在本机适用的流程可补 machine 条件后存全局';
2101
- if (saved.reasonCode === 'model_global_project_fact') return '按项目事实留在项目层:换成别的项目就不成立;确实是通用流程就改 memoryClass';
2126
+ if (saved.reasonCode === 'machine_bound_scope' || saved.reasonCode === 'machine_bound_procedure') return '按本机痕迹留在项目层:正文或依据里写了本机路径/端口,改成别的项目仍成立的通用规则再存全局;只在本机适用的流程可补 machine 条件后存全局';
2127
+ if (saved.reasonCode === 'model_global_project_fact' || saved.reasonCode === 'class_project_fact') return '按项目事实留在项目层:换成别的项目就不成立;确实是通用流程就改 memoryClass';
2128
+ if (saved.reasonCode === 'model_class_project' || saved.reasonCode === 'unclassified_project') return '按当前项目保存:模型归类只是提案,要存全局得由用户明确说「所有项目都这样」';
2102
2129
  return '';
2103
2130
  })();
2104
2131
  if (saved.needsResolution) {
@@ -2617,14 +2644,66 @@ export function apply(ctx, config) {
2617
2644
  }), 'lite-memory: recall context');
2618
2645
  }
2619
2646
 
2647
+ /**
2648
+ * 只有人在这句话里明确要求「存到全局/所有项目」才算跨项目授权。
2649
+ * 不能因为句子里同时出现「全局」和「保存」就放行:那可能只是在说全局变量。
2650
+ */
2651
+ const humanAskedGlobal = (said) => {
2652
+ const text = String(said || '');
2653
+ return /(?:存|记|写|放|加|保存|放到|记到|写进).{0,10}(?:全局|所有项目|跨项目)/u.test(text)
2654
+ || /(?:全局|所有项目|跨项目).{0,10}(?:都|一律|通用|适用|记住)/u.test(text)
2655
+ || /(?:以后|今后).{0,16}(?:所有项目|每个项目|全局|跨项目)/u.test(text);
2656
+ };
2657
+
2658
+ /**
2659
+ * npm 原生命令最容易把「没登录」误判成「没凭据」:whoami 报 ENEEDAUTH 之后,
2660
+ * 会话常会反复试 login/token,真正该走的 dsh-npm 反而没人提。
2661
+ * 这里只在动作发生前拦一次,把已有记忆正文直接交给模型;同一轮不重复,不产生循环。
2662
+ */
2663
+ const npmProbeHint = (agent, execution) => {
2664
+ try {
2665
+ const name = String(execution?.name || '');
2666
+ if (!/^(bash|run_command|shell|terminal|exec)$/.test(name)) return undefined;
2667
+ const command = String(execution?.arguments?.command || execution?.arguments?.cmd || execution?.arguments?.input || '');
2668
+ if (!command || command.length > 4000 || !/\bnpm\b/.test(command)) return undefined;
2669
+ if (/dsh-npm/.test(command)) return undefined;
2670
+ if (!/(^|[;&|]\s*)(?:sudo\s+)?npm\s+(?:whoami|publish|login|adduser|add-user|token|config\s+get)\b/.test(command)) return undefined;
2671
+ const held = prepared.get(agent);
2672
+ if (!held || held.npmHinted) return undefined;
2673
+ if (!memoryOnForAgent(agent)) return undefined;
2674
+ const index = loadIndex(globalScope());
2675
+ const row = (index.entries || []).find((entry) => {
2676
+ if (!entry || entry.archived || entry.origin === 'project_evolution') return false;
2677
+ /** 过期/被取代的规则不能再在动作前拦人。 */
2678
+ if (view.lifecycleStatusOf(entry)) return false;
2679
+ if (held.deliveredIds instanceof Set && held.deliveredIds.has(entry.id)) return false;
2680
+ const head = `${entry.title || ''} ${entry.summary || ''} ${(entry.tags || []).join(' ')}`;
2681
+ const body = String(entry.body || '').slice(0, 800);
2682
+ return /npm|dsh-npm/i.test(head)
2683
+ && /(凭据|登录|token|whoami|发布|publish|ENEEDAUTH|Keychain)/i.test(`${head} ${body}`);
2684
+ });
2685
+ if (!row) return undefined;
2686
+ held.npmHinted = true;
2687
+ /** 这条正文真的交给模型了,按交付记使用。 */
2688
+ if (!(held.deliveredIds instanceof Set)) held.deliveredIds = new Set();
2689
+ if (row.id) held.deliveredIds.add(row.id);
2690
+ const body = String(row.body || row.summary || '').trim().slice(0, 600);
2691
+ return `[lite-memory] 这条 npm 命令先别执行:它容易被登录态误导。已有记忆「${row.title}」:${body}\n按记忆改用 dsh-npm;这条提示同一轮只出现一次,直接继续,不要重复探测凭据。`;
2692
+ } catch {
2693
+ return undefined;
2694
+ }
2695
+ };
2696
+
2620
2697
  /** 执行入口再拦一次:工具若已出现在旧快照里,调用时也不放行。 */
2621
2698
  if (typeof ctx.on === 'function') ctx.on('agent/created', (payload) => {
2622
2699
  const agent = payload?.agent;
2623
2700
  if (!agent?.ctx?.tools) return;
2624
2701
  try {
2625
2702
  agent.ctx.effect(() => agent.ctx.tools.guard((execution) => {
2626
- if (!MEMORY_TOOLS.includes(execution?.name)) return undefined;
2627
- return memoryOnForAgent(agent) ? undefined : '本项目记忆已关闭,先在面板打开';
2703
+ if (MEMORY_TOOLS.includes(execution?.name)) {
2704
+ return memoryOnForAgent(agent) ? undefined : '本项目记忆已关闭,先在面板打开';
2705
+ }
2706
+ return npmProbeHint(agent, execution);
2628
2707
  }), 'lite-memory: tool guard');
2629
2708
  } catch { /* older host without guards */ }
2630
2709
  syncMemoryTools(agent);
@@ -2664,6 +2743,7 @@ export function apply(ctx, config) {
2664
2743
  .filter((message) => message?.source?.kind === 'user');
2665
2744
  const fresh = freshHumans(sessionId, humans);
2666
2745
  const previous = prepared.get(agent);
2746
+ if (fresh.length > 0 && previous) flushUsage(previous);
2667
2747
  const sameSession = Boolean(previous && previous.sessionId === sessionId);
2668
2748
  const sessionTask = recallTasks.get(sessionId) || null;
2669
2749
 
@@ -2713,24 +2793,31 @@ export function apply(ctx, config) {
2713
2793
  * 只补一次、共用一个请求额度,补到的卡片追加到已发布快照,超出仍由预算挡。
2714
2794
  */
2715
2795
  const stepMessages = Array.isArray(decision.messages) ? decision.messages : [];
2716
- const seenAt = Number.isInteger(previous.seenMessages) ? previous.seenMessages : 0;
2717
- const stepActions = seenAt < stepMessages.length ? stepMessages.slice(seenAt) : [];
2796
+ const seenIds = previous.seenIds instanceof Set ? previous.seenIds : new Set(previous.seenIds || []);
2797
+ const stepActions = stepMessages.filter((message) => {
2798
+ const id = String(message?.id ?? '');
2799
+ return id ? !seenIds.has(id) : false;
2800
+ });
2801
+ previous.seenIds = new Set([...seenIds, ...stepMessages.map((message) => String(message?.id ?? '')).filter(Boolean)].slice(-64));
2718
2802
  previous.seenMessages = stepMessages.length;
2803
+ /** 只读文件、搜索、记忆工具都不算动作;一次请求最多补查一次。 */
2719
2804
  const actionQuery = previous.recalledContext && !previous.actionRetried
2805
+ && view.hasEffectiveAction(stepActions)
2720
2806
  ? view.actionTerms(stepActions, { limit: 10 }).slice(0, 240) : '';
2721
2807
  if (actionQuery) {
2722
2808
  previous.actionRetried = true;
2723
2809
  const win = windowOf(agent);
2724
2810
  if (win && generation === generations.get(agent) && !signal?.aborted) {
2725
- const retry = projectRecall(scope, `${actionQuery} ${previous.query}`.trim().slice(0, RECALL_QUERY_CHARS), previous.recalledContext, win, previous.runtimeHeld || null);
2726
- const known = new Set((previous.cards || []).map((card) => card.id));
2727
- const added = (retry.cards || []).filter((card) => !known.has(card.id));
2728
- if (added.length) {
2729
- const addedIds = new Set(added.map((card) => card.id));
2730
- const extraLines = (retry.lines || []).filter((row, index) => addedIds.has(retry.cards[index] && retry.cards[index].id));
2731
- previous.cards = [...(previous.cards || []), ...added];
2732
- previous.lines = [...(previous.lines || []), ...extraLines];
2733
- previous.text = `${RECALL_HEADER}\n${previous.lines.map((row) => row.line).join('\n')}`;
2811
+ const nextQuery = `${actionQuery} ${previous.query}`.trim().slice(0, RECALL_QUERY_CHARS);
2812
+ const retry = projectRecall(scope, nextQuery, previous.recalledContext, win, previous.runtimeHeld || null);
2813
+ if (retry.cards.length) {
2814
+ /** 补查结果整体替换展示快照;账本与正文来自同一次准入。 */
2815
+ previous.text = retry.text;
2816
+ previous.cards = retry.cards;
2817
+ previous.lines = retry.lines;
2818
+ previous.query = nextQuery;
2819
+ previous.matched = retry.matched || retry.cards.length;
2820
+ if (win) win.query = nextQuery;
2734
2821
  }
2735
2822
  }
2736
2823
  }
@@ -2748,9 +2835,10 @@ export function apply(ctx, config) {
2748
2835
  } : null;
2749
2836
  /** 工具模式也要记下本轮真人原话:只用于核对绑定,不自动注入。 */
2750
2837
  const said = view.newestUserQuery(fresh).slice(0, RECALL_QUERY_CHARS);
2838
+ const continuationHint = view.isContinuationQuery(said) || view.isEllipticalContinuation(said);
2751
2839
  /** 「继续」这类续说没有检索词:带上同会话上一轮原话,否则这一轮必然召回为空。 */
2752
- const carried = (view.isContinuationQuery(said) || view.isEllipticalContinuation(said))
2753
- ? (previous?.query || sessionTask?.query || '') : '';
2840
+ const carried = continuationHint
2841
+ ? (sessionTask?.taskQuery || previous?.query || sessionTask?.query || '') : '';
2754
2842
  let query = (carried ? `${said} ${carried}` : said).trim().slice(0, RECALL_QUERY_CHARS);
2755
2843
  if (win && query) win.query = query;
2756
2844
 
@@ -2800,14 +2888,19 @@ export function apply(ctx, config) {
2800
2888
  const rememberedTask = lastTask.get(sessionId) || null;
2801
2889
  const previousTask = (previous && previous.task) || (sessionTask && sessionTask.task) || rememberedTask;
2802
2890
  const previousText = (previous && previous.query) || (sessionTask && sessionTask.query) || '';
2891
+ const previousSaid = (previous && previous.saidOnly) || (sessionTask && sessionTask.saidOnly) || '';
2892
+ const sameQuery = Boolean(previousSaid && said && previousSaid === said);
2803
2893
  const sameTask = Boolean(sameSession || sessionTask || rememberedTask)
2804
2894
  && !view.isExplicitSwitch(said, previousText)
2805
- && view.sameRecallTask(previousTask, currentTask);
2895
+ && view.sameRecallTask(previousTask, currentTask, { continuation: continuationHint, sameQuery });
2806
2896
  const taskStartMessageId = sameTask && sessionTask && sessionTask.startMessageId
2807
2897
  ? sessionTask.startMessageId : ids[0];
2808
2898
  lastTask.set(sessionId, currentTask);
2809
2899
  recallTasks.set(sessionId, {
2810
2900
  query,
2901
+ saidOnly: said,
2902
+ /** 当前任务的稳定原话:续说只拼这一份,不把上一轮的动作线索越滚越长。 */
2903
+ taskQuery: sameTask ? (sessionTask?.taskQuery || carried || said) : said,
2811
2904
  task: currentTask,
2812
2905
  startMessageId: taskStartMessageId,
2813
2906
  updatedAt: Date.now(),
@@ -2837,12 +2930,16 @@ export function apply(ctx, config) {
2837
2930
  if (win) win.query = query;
2838
2931
  }
2839
2932
  let snapshot = projectRecall(scope, query, recalledContext, win, runtime ? runtime.held : null);
2840
- /** 并了动作线索仍一张卡都没有,才补一次有界查询;补到了才替换,不拿空结果盖掉原判断。 */
2933
+ /**
2934
+ * 并了动作线索仍一张卡都没有,才补一次有界查询。
2935
+ * 补到就用补查结果整体替换:两批卡片各量一次会绕过 3 条 / 1200 字节的共用额度。
2936
+ */
2841
2937
  if (!snapshot.cards.length && !carried && sameTask && trail && !trailInQuery) {
2842
2938
  const actionQuery = `${trail} ${query}`.trim().slice(0, RECALL_QUERY_CHARS);
2843
2939
  const retry = projectRecall(scope, actionQuery, recalledContext, win, runtime ? runtime.held : null);
2844
2940
  if (retry.cards.length) {
2845
2941
  snapshot = retry;
2942
+ query = actionQuery;
2846
2943
  if (win) win.query = actionQuery;
2847
2944
  }
2848
2945
  }
@@ -2881,12 +2978,10 @@ export function apply(ctx, config) {
2881
2978
  clearPrepared(agent);
2882
2979
  return decision;
2883
2980
  }
2884
- /** 实验上下文即使本轮零注入也要留着:工具绑定后同一请求还能补上规则。 */
2885
- if ((!snapshot || !snapshot.cards.length) && !experiment) {
2886
- rollbackBudget();
2887
- clearPrepared(agent);
2888
- return decision;
2889
- }
2981
+ /**
2982
+ * 零命中也要保留请求状态:这一轮第一步没命中,下一步的动作仍可能触发一次补查。
2983
+ * 展示快照为空即可,不把任务身份和额度窗口一起丢掉。
2984
+ */
2890
2985
  let evolutionEpoch = '';
2891
2986
  try { evolutionEpoch = String(evolutionStatus(scope).epoch || ''); } catch { evolutionEpoch = ''; }
2892
2987
  const state = {
@@ -2900,6 +2995,7 @@ export function apply(ctx, config) {
2900
2995
  task: currentTask,
2901
2996
  scope,
2902
2997
  query,
2998
+ saidOnly: said,
2903
2999
  sessionId,
2904
3000
  turn,
2905
3001
  signal,
@@ -2909,7 +3005,9 @@ export function apply(ctx, config) {
2909
3005
  recalledContext,
2910
3006
  runtimeHeld: runtime && runtime.held ? runtime.held : null,
2911
3007
  seenMessages: Array.isArray(decision.messages) ? decision.messages.length : 0,
3008
+ seenIds: Array.isArray(decision.messages) ? decision.messages.map((message) => String(message?.id ?? '')).filter(Boolean).slice(-64) : [],
2912
3009
  actionRetried: false,
3010
+ deliveredIds: new Set(),
2913
3011
  at: Date.now(),
2914
3012
  };
2915
3013
  prepared.set(agent, state);
@@ -3219,6 +3317,7 @@ export function apply(ctx, config) {
3219
3317
  windowOf: (agent) => windowOf(agent),
3220
3318
  /** 测试专用:CI 冷启动偶尔超过 150ms,逻辑测试不该被耗时上限判失败。 */
3221
3319
  setDeadlineMs: (ms) => { if (Number.isFinite(ms) && ms > 0) recallDeadlineMs = ms; },
3320
+ humanAskedGlobal,
3222
3321
  };
3223
3322
  return () => {
3224
3323
  delete globalThis.__liteMemoryRecall;
package/learning.js CHANGED
@@ -618,10 +618,6 @@ export function validateChange(change, { refs, rules, ranges, expectedTarget } =
618
618
  if (!Array.isArray(change.conditions)) return { ok: false, reason: '条件不是列表' };
619
619
  conditions = normalizeConditions(change.conditions);
620
620
  if (conditions.length !== change.conditions.length) return { ok: false, reason: '条件里有不认的键或空值' };
621
- for (const condition of conditions) {
622
- const allowed = conditionValueAllowed(condition, { ranges, existing });
623
- if (!allowed) return { ok: false, reason: '条件值不在本项目的栏目或阶段里' };
624
- }
625
621
  }
626
622
  /** 收集时固定的作用域只能沿用或收窄:整理器不能把待确认候选抬成全项目规则。 */
627
623
  const expected = expectedTarget && typeof expectedTarget === 'object' ? expectedTarget : null;
@@ -669,6 +665,17 @@ export function validateChange(change, { refs, rules, ranges, expectedTarget } =
669
665
  if (conditionKeys.has(row.key)) return { ok: false, reason: '同一条件键给了两个值' };
670
666
  conditionKeys.add(row.key);
671
667
  }
668
+ /**
669
+ * 统一校验最终条件:收集阶段带进来的 taskKind/seriesId 是可信范围,历史范围里没有也允许;
670
+ * 模型新提的条件仍必须落在本项目已有栏目或阶段里。
671
+ */
672
+ for (const condition of effectiveConditions) {
673
+ const fromCollected = expectedConditions.some((row) => row.key === condition.key && row.value === condition.value);
674
+ if (fromCollected) continue;
675
+ if (!conditionValueAllowed(condition, { ranges, existing })) {
676
+ return { ok: false, reason: '条件值不在本项目的栏目或阶段里' };
677
+ }
678
+ }
672
679
  if (applicability === 'all_in_scope' && effectiveConditions.length) {
673
680
  return { ok: false, reason: '全项目规则不能带条件' };
674
681
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lite-memory",
3
- "version": "0.18.34",
3
+ "version": "0.18.35",
4
4
  "description": "On-demand memory for the DeepSeek Harness: nothing resident, at most three small cards on a real hit, bodies read on demand.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/store.js CHANGED
@@ -2595,16 +2595,16 @@ export function routeScope(draft, options = {}) {
2595
2595
  requiredConditions: [{ key: 'machine', value: '' }, { key: 'runtime', value: '' }],
2596
2596
  };
2597
2597
  };
2598
- /** 人明确点过「就存全局」才算数;模型自己提的 global 不抵消本机痕迹。 */
2598
+ /** 人明确点过「就存全局」才算数;模型自己提的 scope 只是提案。 */
2599
2599
  const humanConfirmed = options.humanScope === true || source.scopeConfirmed === true;
2600
+ /** 实验/自动整理的产物永远留在项目层:它们没有「用户要求跨项目」这层依据。 */
2601
+ const autoOrigin = source.origin === 'project_evolution' || source.origin === 'model_drafted';
2600
2602
  /** 「本项目里…」是项目事实的措辞,类别写成偏好/流程也不能这样进通用层。 */
2601
2603
  const projectBound = projectBoundText(source);
2602
2604
  if (durability === 'task') return { ...base, needsScope: true, reasonCode: 'task_state' };
2603
- if (explicit) {
2605
+ if (explicit && (humanConfirmed || !autoOrigin)) {
2604
2606
  /**
2605
- * 显式 scope 代表「用户这个消息里要的那一层」:这是唯一能让全局层落地的输入,
2606
- * 所以不能再按模型自荐降级,只有本机痕迹和项目事实两种硬冲突能改落点。
2607
- * 没显式 scope 时才用 memoryClass 提案,缺可搬依据的流程留在项目层。
2607
+ * 只有人确认过的 scope 才代表「用户这个消息里要的那一层」;模型写进参数的 scope 只是提案。
2608
2608
  */
2609
2609
  if (explicit === 'global' && !humanConfirmed) {
2610
2610
  if (machineConflict) return fallbackToProject('machine_bound_scope');
@@ -2631,21 +2631,28 @@ export function routeScope(draft, options = {}) {
2631
2631
  }
2632
2632
  return { ...base, chosenScope: 'project', reasonCode: 'class_project_fact' };
2633
2633
  }
2634
+ /**
2635
+ * 自动归类只能决定「它像哪类」,不能决定跨项目;默认落点一律是当前项目。
2636
+ * 具体冲突(本机痕迹、「本项目」措辞)仍按原原因报告,其余全局结论统一降级。
2637
+ */
2638
+ const globalOutcome = (reasonCode) => (humanConfirmed
2639
+ ? { ...base, chosenScope: 'global', reasonCode }
2640
+ : fallbackToProject('model_class_project'));
2634
2641
  if (cls === 'personal_preference') {
2635
2642
  /** 偏好也分「我的习惯」和「这台机器上的习惯」:后者绑机器,不进通用层。 */
2636
2643
  if (machineConflict) return fallbackToProject('machine_bound_preference');
2637
2644
  /** 说了「本项目」的偏好其实是项目约定,类别写错也不能跨项目。 */
2638
2645
  if (projectBound) return fallbackToProject('project_bound_preference');
2639
- return { ...base, chosenScope: 'global', reasonCode: 'class_personal_preference' };
2646
+ return globalOutcome('class_personal_preference');
2640
2647
  }
2641
2648
  if (cls === 'portable_procedure') {
2642
2649
  if (machineConflict) return fallbackToProject('machine_bound_procedure');
2643
2650
  if (projectBound) return fallbackToProject('project_bound_procedure');
2644
2651
  /** 带适用环境条件的本机流程:条件成立时才用,换台机器自然不生效。 */
2645
- if (envScoped) return { ...base, chosenScope: 'global', reasonCode: 'machine_scoped_procedure' };
2652
+ if (envScoped) return globalOutcome('machine_scoped_procedure');
2646
2653
  /** 说能搬去别的项目,就要有能搬的依据;没有就先留在本项目。 */
2647
2654
  if (!evidence) return fallbackToProject('unbound_evidence');
2648
- return { ...base, chosenScope: 'global', reasonCode: 'class_portable_procedure' };
2655
+ return globalOutcome('class_portable_procedure');
2649
2656
  }
2650
2657
  if (projectBound) return fallbackToProject('project_bound_environment');
2651
2658
  const conditions = view.normalizeConditions(source.conditions) || [];
@@ -2658,7 +2665,7 @@ export function routeScope(draft, options = {}) {
2658
2665
  requiredConditions: [{ key: 'machine', value: '' }, { key: 'runtime', value: '' }],
2659
2666
  };
2660
2667
  }
2661
- return { ...base, chosenScope: 'global', reasonCode: 'class_environment_fact' };
2668
+ return globalOutcome('class_environment_fact');
2662
2669
  }
2663
2670
 
2664
2671
  function locateInLayers(projectScope, id) {
@@ -377,10 +377,15 @@ assert.equal((await call('/lite-memory/unknown', { cwd })).status, 404);
377
377
  assert.equal((await call('/lite-memory/keep', {}, { method: 'GET' })).status, 405);
378
378
 
379
379
  // ── global layer ─────────────────────────────────────────────────────────────
380
- const global = await exec('memory_save', { title: '回复用中文', summary: '用户要求回复用中文', scope: 'global' });
381
- assert.equal(global.scope, 'global');
382
- assert.equal(global.scopeNote, undefined, '正常落全局不该带降级说明');
383
- assert.match(render('memory_save', {}, global), /^memory · saved \[global\] "回复用中文"$/);
380
+ const proposedGlobal = await exec('memory_save', { title: '回复语言模型提案', summary: '模型建议回复语言用中文', scope: 'global' });
381
+ assert.equal(proposedGlobal.scope, 'project', '模型自填 scope 只是提案,默认落项目层');
382
+ assert.match(proposedGlobal.scopeNote, /模型归类只是提案/, '降级要能解释');
383
+ const global = store.saveEntry(store.globalScope(), {
384
+ title: '回复用中文', summary: '用户要求回复用中文', body: '回复默认用中文', scopeConfirmed: true,
385
+ });
386
+ assert.ok(global.record, '人明确要全局时才落全局层');
387
+ assert.equal(global.chosenScope, 'global');
388
+ assert.equal(exec === null, false);
384
389
 
385
390
  // 显式写 global 也不再被悄无声息地改层:本机痕迹和项目事实各有一条能解释的降级。
386
391
  const machineGlobal = await exec('memory_save', {
@@ -402,7 +407,7 @@ const factGlobal = await exec('memory_save', {
402
407
  assert.equal(factGlobal.scope, 'project');
403
408
  assert.match(factGlobal.scopeNote, /按项目事实留在项目层/);
404
409
  const globalSearch = await exec('memory_search', { query: '回复用中文' });
405
- assert.equal(globalSearch.count, 1);
410
+ assert.ok(globalSearch.count >= 1, '全局层条目能被搜到');
406
411
  assert.match(render('memory_search', {}, globalSearch), /\[global\] 「/);
407
412
  assert.equal(typeof globalThis.__liteMemoryStats, 'function');
408
413
  const stats = globalThis.__liteMemoryStats(cwd);
@@ -1050,10 +1055,9 @@ await defs.get('memory_save').execute(
1050
1055
  { title: '发布窗口', summary: '只在周四晚上发版', body: '周四晚上再发版。', when: '周四晚上' },
1051
1056
  { agent: ovAgent },
1052
1057
  );
1053
- await defs.get('memory_save').execute(
1054
- { title: '观察全局', summary: '跨项目通用说明', body: '全局正文。', scope: 'global' },
1055
- { agent: ovAgent },
1056
- );
1058
+ store.saveEntry(store.globalScope(), {
1059
+ title: '观察全局', summary: '跨项目通用说明', body: '全局正文。', scopeConfirmed: true,
1060
+ });
1057
1061
  const ovOther = await defs.get('memory_save').execute(
1058
1062
  { title: '别的项目', summary: '只在别的项目里', body: '另一层的正文。', tags: ['别的'] },
1059
1063
  { agent: { id: 'session-overview-other', cwd: ovOtherCwd, session: { id: 'session-overview-other', header: { cwd: ovOtherCwd } } } },
@@ -1215,7 +1219,7 @@ const cfCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-confli
1215
1219
  sessionCwds.set('session-conflict-panel', cfCwd);
1216
1220
  sessionCwds.set('session-conflict-other', fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-conflict-other-')));
1217
1221
  const cfAgent = { id: 'session-conflict-panel', cwd: cfCwd, session: { id: 'session-conflict-panel', header: { cwd: cfCwd } } };
1218
- await defs.get('memory_save').execute({ title: '发布窗口', summary: '只在周四晚上发版', body: '周四晚上再发版。', scope: 'global' }, { agent: cfAgent });
1222
+ store.saveEntry(store.globalScope(), { title: '发布窗口', summary: '只在周四晚上发版', body: '周四晚上再发版。', scopeConfirmed: true });
1219
1223
  const cfSave = await defs.get('memory_save').execute({ title: '发布窗口', summary: '本项目周五发版', body: '周五发版。' }, { agent: cfAgent });
1220
1224
  assert.equal(cfSave.needsResolution, true);
1221
1225
  assert.equal(store.loadIndex(store.scopeOf(cfCwd)).entries.length, 0, '没确认前不落库');
@@ -1276,7 +1280,7 @@ const cfgCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-confl
1276
1280
  sessionCwds.set('session-conflict-global', cfgCwd);
1277
1281
  const cfgAgent = { id: 'session-conflict-global', cwd: cfgCwd, session: { id: 'session-conflict-global', header: { cwd: cfgCwd } } };
1278
1282
  await defs.get('memory_save').execute({ title: '命名规则', summary: '项目里用短横线', body: '短横线。' }, { agent: cfgAgent });
1279
- const cfgSave = await defs.get('memory_save').execute({ title: '命名规则', summary: '所有项目用下划线', body: '下划线。', scope: 'global' }, { agent: cfgAgent });
1283
+ const cfgSave = store.saveEntry(store.scopeOf(cfgCwd), { title: '命名规则', summary: '所有项目用下划线', body: '下划线。', scope: 'global', scopeConfirmed: true });
1280
1284
  assert.equal(cfgSave.needsResolution, true);
1281
1285
  const cfgView = await call('/lite-memory/overview', { sessionId: 'session-conflict-global', scopeKey: 'global' });
1282
1286
  assert.equal(cfgView.body.selected.conflicts.length, 1, '全局层也要有这条待裁决');
@@ -44,12 +44,18 @@ const idsOfPrev = (scope) => {
44
44
  };
45
45
 
46
46
  // ── S03 范围路由 ────────────────────────────────────────────────────────────
47
- await test('S03 个人偏好没有显式范围就进全局层,项目层不落副本', () => {
47
+ await test('S03 个人偏好:模型自动归类留项目层,人明确要全局才落全局', () => {
48
48
  const scope = fresh('route-pref');
49
- const saved = saveEntry(scope, { title: '回答用中文', summary: '回复语言偏好', body: '中文、简短', memoryClass: 'personal_preference' });
49
+ const automatic = saveEntry(scope, { title: '回答用中文(自动)', summary: '回复语言偏好', body: '中文、简短(自动)', memoryClass: 'personal_preference' });
50
+ assert.equal(automatic.chosenScope, 'project', '模型归类只是提案,不能自己跨项目');
51
+ assert.equal(automatic.reasonCode, 'model_class_project');
52
+ assert.equal(idsOf(scope).length, 1);
53
+ const saved = saveEntry(scope, {
54
+ title: '回答用中文', summary: '回复语言偏好', body: '中文、简短',
55
+ memoryClass: 'personal_preference', scopeConfirmed: true, resolution: 'distinct',
56
+ });
50
57
  assert.equal(saved.chosenScope, 'global');
51
58
  assert.equal(saved.reasonCode, 'class_personal_preference');
52
- assert.equal(idsOf(scope).length, 0);
53
59
  assert.equal(stateOf(globalScope()).entries.filter((row) => row.current.title === '回答用中文').length, 1);
54
60
  });
55
61
 
@@ -100,7 +106,7 @@ await test('S03 设备事实没给设备条件就返回 needsScope,不落盘',
100
106
  await test('S03 设备事实带条件进全局层且是条件适用', () => {
101
107
  const scope = fresh('route-env-ok');
102
108
  const saved = saveEntry(scope, {
103
- title: '本机 Node', summary: '本机 Node 版本', body: 'v24', memoryClass: 'environment_fact',
109
+ title: '本机 Node', summary: '本机 Node 版本', body: 'v24', memoryClass: 'environment_fact', scopeConfirmed: true,
104
110
  conditions: [{ key: 'machine', value: 'mac-mini' }],
105
111
  });
106
112
  assert.equal(saved.chosenScope, 'global');
@@ -1,4 +1,4 @@
1
- import { fileURLToPath } from 'node:url'
1
+ import { fileURLToPath } from 'node:url'
2
2
  import assert from 'node:assert/strict';
3
3
  import fs from 'node:fs';
4
4
  import { spawn } from 'node:child_process';
@@ -172,7 +172,7 @@ const globalDraft = store.saveEntry(store.scopeOf(projectB), {
172
172
  title: 'npm 发布凭据取用与发布后验版',
173
173
  summary: 'npm 发布用 account regrevia,发布后轮询 dist-tags 验版',
174
174
  body: '发布用 regrevia 身份;发完轮询 dist-tags.latest 判上线。',
175
- memoryClass: 'personal_preference',
175
+ memoryClass: 'personal_preference', scope: 'global', scopeConfirmed: true,
176
176
  });
177
177
  assert.equal(globalDraft.chosenScope, 'global');
178
178
  const near = await exec('memory_save', {
@@ -1408,4 +1408,43 @@ test('L10 整理器规则先稳定排序再取 8', () => {
1408
1408
  assert.equal(learning.orderOrganizerRules([], { limit: 3 }).length, 0);
1409
1409
  });
1410
1410
 
1411
+ /* 收敛:连续纠正同一主题只留一条现行规则;换栏目不适用;关掉再开还是同一现行版。 */
1412
+ test('B30 连续纠正同一规则收敛到一条现行版', () => {
1413
+ const scope = fresh('converge');
1414
+ enable(scope);
1415
+ const firstEpisode = seedEpisode(scope, { sessionId: 'conv1', messageIds: ['cm1'] });
1416
+ const firstStart = begin(scope, firstEpisode);
1417
+ assert.equal(commitNew(scope, firstEpisode, firstStart, NEW_CHANGE).ok, true);
1418
+ const id = ruleRows(scope)[0].id;
1419
+ const bodies = ['开头两句内给结论。', '开头一句内给结论。'];
1420
+ bodies.forEach((body, index) => {
1421
+ const episode = seedEpisode(scope, { sessionId: `conv${index + 2}`, messageIds: [`cm${index + 2}`] });
1422
+ const started = begin(scope, episode);
1423
+ assert.equal(started.ok, true, started.reason);
1424
+ const result = commitExisting(scope, episode, started, id, {
1425
+ target: 'r0', feedbackType: 'spec_change', currentText: body, evidence: ['u0'],
1426
+ });
1427
+ assert.equal(result.ok, true, result.reason);
1428
+ });
1429
+ const rows = ruleRows(scope);
1430
+ assert.equal(rows.length, 1, '同一主题不堆多份现行规则');
1431
+ const current = rows[0];
1432
+ assert.equal(current.id, id);
1433
+ assert.match(current.body, /一句内给结论/);
1434
+ assert.ok((current.versions || []).length <= 2, '旧版有上限');
1435
+ const wrongColumn = view.eligibility(current, {
1436
+ projectId: scope.key,
1437
+ context: { seriesId: '字幕' },
1438
+ task: { taskKind: 'video', seriesId: '字幕' },
1439
+ }, 'auto');
1440
+ assert.equal(wrongColumn.ok, false, '换栏目不适用');
1441
+ store.setMemoryEnabled(scope, false, { operationId: 'conv-close' });
1442
+ assert.equal(ruleRows(scope).length, 1, '关闭不清成果');
1443
+ store.setMemoryEnabled(scope, true, { operationId: 'conv-open' });
1444
+ const reopened = ruleOf(scope, id);
1445
+ assert.equal(reopened.body, current.body);
1446
+ assert.equal(reopened.revision, current.revision);
1447
+ assert.equal(reopened.learning.state, current.learning.state);
1448
+ });
1449
+
1411
1450
  console.log(`learning.test.mjs: ${passed} passed`);
@@ -355,8 +355,11 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
355
355
  const preparedOnly = store.readEntry(scopeUse, only.record.id, { usage: () => false });
356
356
  assert.equal(preparedOnly.lastUsed, saved.lastUsed, '准备但没输出时不能改 lastUsed');
357
357
  assert.match(runtime.provider.text({ agent, signal: sig }), /交付才算用过的记忆/);
358
+ const afterProvider = store.readEntry(scopeUse, only.record.id, { usage: () => false });
359
+ assert.equal(afterProvider.lastUsed, saved.lastUsed, 'provider 输出路径不写使用时间');
360
+ runtime.handlers.get('agent/turn-stopping').at(-1)({ turn: 'u1', signal: sig });
358
361
  const delivered = store.readEntry(scopeUse, only.record.id, { usage: () => false });
359
- assert.notEqual(delivered.lastUsed, saved.lastUsed, '真正交付后才记使用');
362
+ assert.notEqual(delivered.lastUsed, saved.lastUsed, '回合收尾只给真正交付的卡片记使用');
360
363
  }
361
364
 
362
365
  // A12:额度状态与冻结快照分开;清掉快照后重准备不重新分配。
@@ -659,7 +662,11 @@ console.log('recall.test.mjs: ok');
659
662
  };
660
663
  const second = signal();
661
664
  await runtime.preStep({ agent, signal: second, turn: 't2' }, enter(assistantMessage, human('这个怎么弄')));
662
- assert.match(runtime.provider.text({ agent, signal: second }), /改完 client\.js 先重载宿主/, '动作侧线索要能召回预防型记忆');
665
+ const actionText = runtime.provider.text({ agent, signal: second });
666
+ assert.match(actionText, /改完 client\.js 先重载宿主/, '动作侧线索要能召回预防型记忆');
667
+ const actionWin = runtime.debug.windowOf(agent);
668
+ assert.equal(actionWin.autoBudget.totalBytes, Buffer.byteLength(actionText), '最终交付字节与预算账本一致');
669
+ assert.equal(actionWin.autoBudget.cards, runtime.debug.preparedOf(agent).cards.length, '最终卡片数与预算账本一致');
663
670
  }
664
671
 
665
672
  // A25b:短续句「继续优化收敛」没有自己的检索词,要沿用上一句,不能断在上一轮。
@@ -808,3 +815,163 @@ console.log('recall.test.mjs: ok');
808
815
  await runtime.preStep({ agent, signal: second, turn: 't2' }, enter(assistantMessage, human('开始写 PostgreSQL 迁移脚本,先建表')));
809
816
  assert.equal(runtime.provider.text({ agent, signal: second }), '', '换任务后不补查旧任务的动作线索');
810
817
  }
818
+
819
+ // A27:npm 原生命令在动作发生前拦一次,把凭据用法直接交给模型;同一轮不重复。
820
+ {
821
+ const cwdNpm = mkdir('npm-guard');
822
+ const scopeNpm = store.scopeOf(cwdNpm);
823
+ store.setRecallMode(scopeNpm, 'adaptive', { operationId: 'npm-guard-mode' });
824
+ assert.ok(store.upsert(global, {
825
+ title: 'npm 发布一律走 dsh-npm,ENEEDAUTH 不是缺凭据',
826
+ summary: '发布走 dsh-npm,不要用 npm whoami 判断凭据',
827
+ body: '本机 npm 凭据在 Keychain,npm whoami 常报 ENEEDAUTH;发布一律用 dsh-npm publish,不要重新登录。',
828
+ tags: ['npm', 'dsh-npm', '凭据'],
829
+ applicability: 'all_in_scope',
830
+ }).record);
831
+ const guards = [];
832
+ const agent = makeAgent('agent-npm-guard', cwdNpm);
833
+ agent.ctx = {
834
+ tools: { guard: (fn) => { guards.push(fn); return () => {}; } },
835
+ effect: (fn) => fn(),
836
+ };
837
+ const runtime = boot();
838
+ runtime.handlers.get('agent/created').at(-1)({ agent });
839
+ assert.equal(guards.length, 1, '每个 agent 装一个执行前守卫');
840
+ const sig = signal();
841
+ await runtime.preStep({ agent, signal: sig, turn: 'npm-1' }, enter(human('帮我发布这个包')));
842
+ const blocked = guards[0]({ name: 'bash', callId: 'call-npm-1', arguments: { command: 'npm whoami' } });
843
+ assert.match(String(blocked), /npm 发布一律走 dsh-npm/, '动作发生前给已有记忆正文');
844
+ assert.match(String(blocked), /Keychain/, '给正文而不是只给标题');
845
+ assert.equal(guards[0]({ name: 'bash', callId: 'call-npm-2', arguments: { command: 'npm publish' } }), undefined, '同一轮只提示一次');
846
+ assert.ok(scopeNpm.key, '项目层不受影响');
847
+ }
848
+
849
+ // A28:零命中的同一轮里,关键动作发生后补查一次;补到后不再重复注入。
850
+ {
851
+ const cwdActionKey = mkdir('action-key');
852
+ const scopeActionKey = store.scopeOf(cwdActionKey);
853
+ store.setRecallMode(scopeActionKey, 'adaptive', { operationId: 'action-key-mode' });
854
+ const agent = makeAgent('agent-action-key', cwdActionKey);
855
+ const runtime = boot();
856
+ const first = signal();
857
+ await runtime.preStep({ agent, signal: first, turn: 'ak1' }, enter(human('这个怎么弄')));
858
+ assert.equal(runtime.provider.text({ agent, signal: first }), '', '第一步没有线索时不凭空命中');
859
+ const assistantMessage = {
860
+ role: 'assistant',
861
+ id: 'asst-action-key-1',
862
+ content: [
863
+ { type: 'text', text: '我先看一下。' },
864
+ { type: 'tool-call', name: 'bash', input: { command: 'npm whoami' } },
865
+ ],
866
+ };
867
+ const toolResult = { role: 'user', id: 'tool-action-key-1', source: { kind: 'tool' }, content: [{ type: 'text', text: '已完成' }] };
868
+ const second = signal();
869
+ await runtime.preStep({ agent, signal: second, turn: 'ak1' }, enter(assistantMessage, toolResult));
870
+ const text = runtime.provider.text({ agent, signal: second });
871
+ assert.match(text, /npm 发布一律走 dsh-npm/, '关键动作让凭据记忆在动作后补进来');
872
+ assert.equal(runtime.debug.preparedOf(agent).actionRetried, true, '一次请求只补查一次');
873
+ const actionWin = runtime.debug.windowOf(agent);
874
+ assert.equal(actionWin.autoBudget.totalBytes, Buffer.byteLength(text), '补查后的最终字节与账本一致');
875
+ assert.equal(actionWin.autoBudget.cards, runtime.debug.preparedOf(agent).cards.length, '补查后的卡片数与账本一致');
876
+ const third = signal();
877
+ await runtime.preStep({ agent, signal: third, turn: 'ak1' }, enter({
878
+ role: 'assistant',
879
+ id: 'asst-action-key-2',
880
+ content: [{ type: 'tool-call', name: 'bash', input: { command: 'npm publish' } }],
881
+ }, { role: 'user', id: 'tool-action-key-2', source: { kind: 'tool' }, content: [{ type: 'text', text: 'ERR' }] }));
882
+ assert.equal(runtime.provider.text({ agent, signal: third }), text, '同一请求不重复补查,也不花第二份额度');
883
+ }
884
+
885
+ // A29:没有「换任务」这类明说,但新话题已经可判定为另一件事时,旧动作线索不能跟过来。
886
+ {
887
+ const cwdFree = mkdir('free-switch');
888
+ const scopeFree = store.scopeOf(cwdFree);
889
+ save(scopeFree, {
890
+ title: '改完 client.js 先重载宿主',
891
+ tags: ['client.js', '重载'],
892
+ summary: 'client.js 改动后必须重载宿主,普通刷新覆盖不到',
893
+ });
894
+ save(scopeFree, {
895
+ title: 'PostgreSQL 连接池先查 max_connections',
896
+ tags: ['PostgreSQL', '连接池'],
897
+ summary: '连接池调大前先看 max_connections 和实际占用',
898
+ });
899
+ const agent = makeAgent('agent-free-switch', cwdFree);
900
+ const runtime = boot();
901
+ const first = signal();
902
+ await runtime.preStep({ agent, signal: first, turn: 'f1' }, enter(human('前端按钮颜色怎么改')));
903
+ const assistantMessage = {
904
+ role: 'assistant',
905
+ id: 'asst-free-1',
906
+ content: [
907
+ { type: 'text', text: '我把 client.js 的样式改好了。' },
908
+ { type: 'tool-call', name: 'write', input: { file_path: '/tmp/free/client.js' } },
909
+ ],
910
+ };
911
+ const second = signal();
912
+ await runtime.preStep({ agent, signal: second, turn: 'f2' }, enter(assistantMessage, human('PostgreSQL 连接池满了怎么排查')));
913
+ const text = runtime.provider.text({ agent, signal: second });
914
+ assert.match(text, /PostgreSQL 连接池/, '新话题命中自己的记忆');
915
+ assert.doesNotMatch(text, /改完 client\.js 先重载宿主/, '旧任务线索不能进新任务');
916
+ }
917
+
918
+ // A30:只读文件、memory 工具输出不算「关键动作」,不能提前花掉一次补查机会。
919
+ {
920
+ const cwdRead = mkdir('read-action');
921
+ const scopeRead = store.scopeOf(cwdRead);
922
+ store.setRecallMode(scopeRead, 'adaptive', { operationId: 'read-action-mode' });
923
+ save(scopeRead, {
924
+ title: 'npm 发布一律走 dsh-npm,ENEEDAUTH 不是缺凭据',
925
+ tags: ['npm', 'dsh-npm', '凭据'],
926
+ summary: '发布走 dsh-npm,不要用 npm whoami 判断凭据',
927
+ body: '本机 npm 凭据在 Keychain,npm whoami 常报 ENEEDAUTH;发布一律用 dsh-npm publish,不要重新登录。',
928
+ });
929
+ const agent = makeAgent('agent-read-action', cwdRead);
930
+ const runtime = boot();
931
+ const first = signal();
932
+ await runtime.preStep({ agent, signal: first, turn: 'ra1' }, enter(human('这个怎么弄')));
933
+ assert.equal(runtime.provider.text({ agent, signal: first }), '', '第一步没有线索时不凭空命中');
934
+ const passiveStep = {
935
+ role: 'assistant',
936
+ id: 'asst-read-action-1',
937
+ content: [
938
+ { type: 'text', text: '先看文档和已有记录。' },
939
+ { type: 'tool-call', name: 'read', input: { file_path: '/tmp/lite-memory/README.md' } },
940
+ { type: 'tool-call', name: 'memory_search', input: { query: 'npm 凭据怎么用' } },
941
+ ],
942
+ };
943
+ const readResult = { role: 'user', id: 'tool-read-action-1', source: { kind: 'tool' }, content: [{ type: 'text', text: '# README 一段普通说明' }] };
944
+ const second = signal();
945
+ await runtime.preStep({ agent, signal: second, turn: 'ra1' }, enter(passiveStep, readResult));
946
+ const heldAfterRead = runtime.debug.preparedOf(agent);
947
+ assert.equal(heldAfterRead?.actionRetried, false, '只读操作不触发补查');
948
+ assert.equal(runtime.provider.text({ agent, signal: second }), '', '只读操作后仍保持空快照');
949
+ const activeStep = {
950
+ role: 'assistant',
951
+ id: 'asst-read-action-2',
952
+ content: [{ type: 'tool-call', name: 'bash', input: { command: 'npm whoami' } }],
953
+ };
954
+ const activeResult = { role: 'user', id: 'tool-read-action-2', source: { kind: 'tool' }, content: [{ type: 'text', text: '已完成' }] };
955
+ const third = signal();
956
+ await runtime.preStep({ agent, signal: third, turn: 'ra1' }, enter(activeStep, activeResult));
957
+ const text = runtime.provider.text({ agent, signal: third });
958
+ assert.match(text, /npm 发布一律走 dsh-npm/, '真正关键动作仍能补查一次');
959
+ assert.equal(runtime.debug.preparedOf(agent).actionRetried, true);
960
+ const fourth = signal();
961
+ await runtime.preStep({ agent, signal: fourth, turn: 'ra1' }, enter({
962
+ role: 'assistant', id: 'asst-read-action-3', content: [{ type: 'tool-call', name: 'bash', input: { command: 'npm publish' } }],
963
+ }, { role: 'user', id: 'tool-read-action-3', source: { kind: 'tool' }, content: [{ type: 'text', text: 'ERR' }] }));
964
+ assert.equal(runtime.provider.text({ agent, signal: fourth }), text, '同一请求不重复补查');
965
+ }
966
+
967
+ // A31:只有真人明确说「存到全局/所有项目」才算跨项目授权,普通「全局变量」不能放行。
968
+ {
969
+ const runtime = boot();
970
+ const asked = runtime.debug.humanAskedGlobal;
971
+ for (const text of ['以后这条存全局', '记到全局记忆里', '所有项目都按这个来', '以后所有项目都这样做']) {
972
+ assert.equal(asked(text), true, text);
973
+ }
974
+ for (const text of ['把全局变量保存一下', '全局搜索这个函数', '这个项目保存一下', '都改完了保存']) {
975
+ assert.equal(asked(text), false, text);
976
+ }
977
+ }
@@ -166,6 +166,10 @@ const seedRule = (scope, { title, body, summary, conditions = [], learning = { s
166
166
  assert.ok(auto.experimentBytes <= 1200, 'experiment bytes over cap');
167
167
  assert.ok(auto.cardBytes <= 1200);
168
168
  assert.ok(Buffer.byteLength(text) <= 1300, 'rendered text exceeds budget');
169
+ assert.equal(auto.totalBytes, Buffer.byteLength(text), '最终交付字节与预算账本一致');
170
+ const held = runtime.debug.preparedOf(agent);
171
+ assert.equal(auto.cards, (auto.normalCards || 0) + (auto.experimentCards || 0), '卡片账本内部一致');
172
+ assert.equal(auto.normalCards || 0, (held?.cards || []).length, '普通卡片数与预算账本一致');
169
173
  const heads = new Set(text.split('\n').filter((line) => /口播(节奏规范|实验规则)/.test(line)).map((line) => line.trim()));
170
174
  assert.ok(heads.size <= 3, `too many cards: ${heads.size}`);
171
175
  }
@@ -1101,8 +1101,15 @@ test('归属路由:本机痕迹的「通用流程」留在项目层,干净
1101
1101
  title: '提交前跑测试', summary: '提交前跑测试', body: 'pnpm test',
1102
1102
  memoryClass: 'portable_procedure', evidenceRef: '团队 wiki 的提交流程页',
1103
1103
  }, { project });
1104
- assert.equal(withEvidence.chosenScope, 'global');
1105
- assert.equal(withEvidence.reasonCode, 'class_portable_procedure');
1104
+ assert.equal(withEvidence.chosenScope, 'project', '模型自动归类即便有依据也要先留在项目层');
1105
+ assert.equal(withEvidence.reasonCode, 'model_class_project');
1106
+
1107
+ const withEvidenceConfirmed = store.routeScope({
1108
+ title: '提交前跑测试', summary: '提交前跑测试', body: 'pnpm test',
1109
+ memoryClass: 'portable_procedure', evidenceRef: '团队 wiki 的提交流程页',
1110
+ }, { project, humanScope: true });
1111
+ assert.equal(withEvidenceConfirmed.chosenScope, 'global', '人确认后才按归类落全局');
1112
+ assert.equal(withEvidenceConfirmed.reasonCode, 'class_portable_procedure');
1106
1113
 
1107
1114
  const evidencePath = store.routeScope({
1108
1115
  title: '提交前跑测试', summary: '提交前跑测试', body: 'pnpm test',
@@ -1133,8 +1140,17 @@ test('归属路由:本机痕迹的「通用流程」留在项目层,干净
1133
1140
  memoryClass: 'portable_procedure',
1134
1141
  conditions: [{ key: 'machine', value: 'macOS 桌面机' }],
1135
1142
  }, { project });
1136
- assert.equal(scopedProcedure.chosenScope, 'global', '声明了机器条件的本机流程可进全局层');
1137
- assert.equal(scopedProcedure.reasonCode, 'machine_scoped_procedure');
1143
+ assert.equal(scopedProcedure.chosenScope, 'project', '模型自动归类不能自己搬去全局层');
1144
+ assert.equal(scopedProcedure.reasonCode, 'model_class_project');
1145
+
1146
+ const scopedProcedureConfirmed = store.routeScope({
1147
+ title: 'npm 操作走 dsh-npm', summary: '本机 npm 命令用包装脚本',
1148
+ body: '本机 npm 一律用 dsh-npm;裸 npm 报 ENEEDAUTH 不是缺凭据',
1149
+ memoryClass: 'portable_procedure',
1150
+ conditions: [{ key: 'machine', value: 'macOS 桌面机' }],
1151
+ }, { project, humanScope: true });
1152
+ assert.equal(scopedProcedureConfirmed.chosenScope, 'global', '人确认后带机器条件的流程可进全局层');
1153
+ assert.equal(scopedProcedureConfirmed.reasonCode, 'machine_scoped_procedure');
1138
1154
 
1139
1155
  const scopedExplicit = store.routeScope({
1140
1156
  title: 'npm 操作走 dsh-npm', summary: '本机 npm 命令用包装脚本',
package/view.js CHANGED
@@ -755,18 +755,30 @@ export function isExplicitSwitch(text, previousText = '') {
755
755
  * 补查只有在「还是同一个任务」时才成立:任务类型或 episode 变了,上一轮的动作历史属于旧任务,
756
756
  * 不能拿它给新任务找记忆。类型缺失时保留补查,由调用方的明确换任务判断兜底。
757
757
  */
758
- export function sameRecallTask(previous, current) {
759
- if (!previous || !current) return true;
758
+ export function sameRecallTask(previous, current, options = {}) {
759
+ if (!previous || !current) return false;
760
+ const continuation = options.continuation === true;
760
761
  const kind = (value) => plain(value && value.kind, 40).trim().toLowerCase();
761
762
  const episode = (value) => plain(value && value.episodeId, 120).trim();
763
+ const previousKind = kind(previous);
764
+ const currentKind = kind(current);
762
765
  /** 两侧都认出了任务类型且不同:明确换任务,上一轮的动作历史不再属于当前请求。 */
763
- if (kind(previous) && kind(current) && kind(previous) !== kind(current)) return false;
764
- /** 同类型但是另一个 episode:同类任务的新一轮,也不沿用旧任务的动作线索。 */
765
- if (kind(previous) && kind(current) && episode(previous) && episode(current)) {
766
- return episode(previous) === episode(current);
766
+ if (previousKind && currentKind && previousKind !== currentKind) return false;
767
+ const previousEpisode = episode(previous);
768
+ const currentEpisode = episode(current);
769
+ if (previousEpisode && currentEpisode) {
770
+ if (previousEpisode === currentEpisode) return true;
771
+ /** 同一句追问又出现在下一轮:episode 只是平台切的新轮次,任务没有变。 */
772
+ if (options.sameQuery === true && !previousKind && !currentKind) return true;
773
+ /** 真正的续说可能新开 episode:只有明确续说才允许跨 episode 沿用上一任务。 */
774
+ return continuation && (!previousKind || !currentKind || previousKind === currentKind);
767
775
  }
768
- /** 认不出类型(或只有一侧有)时保留补查:这正是该机制要救的场景。 */
769
- return true;
776
+ /**
777
+ * 认不出新任务类型时不能默认沿用旧任务:上一轮的动作线索只属于旧任务。
778
+ * 只有明确续说、或真人原话与上一轮完全相同(同一条追问的下一步)才接着用。
779
+ */
780
+ if (!previousKind && !currentKind && options.sameQuery === true) return true;
781
+ return continuation;
770
782
  }
771
783
 
772
784
  /**
@@ -782,6 +794,31 @@ function isMemoryNoise(name, text) {
782
794
  if (name && MEMORY_TOOL.test(String(name))) return true;
783
795
  return Boolean(text && MEMORY_TEXT.test(String(text).trimStart()));
784
796
  }
797
+ /** 纯读取不会改变下一步动作:读 README / 搜索 / 记忆工具都不消耗唯一补查。 */
798
+ const PASSIVE_TOOL = /^(?:read|glob|grep|list|view|fetch|web_search|web_fetch|job_output|job_list|todo_write|question|ask|memory(?:[_-].*)?)$/i;
799
+ const EFFECTIVE_COMMAND = /(?:^|[\s;&|])(?:npm|npx|pnpm|yarn|git|gh|docker|kubectl|helm|systemctl|launchctl|make|cargo|go|python|node|curl|ssh|rsync|deploy|publish|install|test|build|migrate|reload)(?:[\s;&|]|$)/i;
800
+ /** 动作侧是否真的发生了一个会改变状态的动作;只有这种动作才允许补查一次。 */
801
+ export function hasEffectiveAction(messages) {
802
+ if (!Array.isArray(messages)) return false;
803
+ for (const message of messages) {
804
+ const content = message?.content;
805
+ if (!Array.isArray(content)) continue;
806
+ for (const part of content) {
807
+ if (!part || typeof part !== 'object') continue;
808
+ if (part.type !== 'tool-call' && part.type !== 'tool_call') continue;
809
+ const name = String(part.name || '');
810
+ if (isMemoryNoise(name, '')) continue;
811
+ if (!PASSIVE_TOOL.test(name)) return true;
812
+ const input = part.input || part.arguments || part.args;
813
+ if (!input || typeof input !== 'object') continue;
814
+ for (const value of Object.values(input)) {
815
+ if (typeof value === 'string' && EFFECTIVE_COMMAND.test(value)) return true;
816
+ }
817
+ }
818
+ }
819
+ return false;
820
+ }
821
+
785
822
  export function actionTerms(messages, options = {}) {
786
823
  if (!Array.isArray(messages)) return '';
787
824
  const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 16;
@@ -804,8 +841,12 @@ export function actionTerms(messages, options = {}) {
804
841
  if (typeof part.name === 'string') push(part.name);
805
842
  const input = part.input || part.arguments || part.args;
806
843
  if (input && typeof input === 'object') {
807
- for (const value of Object.values(input)) {
808
- if (typeof value === 'string' && /[./_-]/.test(value)) {
844
+ for (const [key, value] of Object.entries(input)) {
845
+ if (typeof value !== 'string' || value.length > 300) continue;
846
+ /** 命令参数要按空格切词,否则 `npm whoami` 只剩工具名 bash,动作线索就丢了。 */
847
+ const commandish = /^(?:command|cmd|script|args?|input|shell)$/i.test(key)
848
+ || EFFECTIVE_COMMAND.test(value);
849
+ if (commandish || /[./_-]/.test(value)) {
809
850
  for (const piece of value.split(/[^\p{L}\p{N}_.-]+/u)) push(piece);
810
851
  }
811
852
  }