dsh-lite-memory 0.18.38 → 0.18.40

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/docs/BENCH.md CHANGED
@@ -25,22 +25,22 @@ npm run bench:evolution # 实验规则/样例/检查点/回执的存储与投
25
25
 
26
26
  ## 固定数据集性能
27
27
 
28
- 2026-10-29,macOS arm64,Node v24.16.0,本地 SSD。数据:项目 200 条 + 全局 200 条,全部带标题、摘要、`when`、正文;另加一条正面场景,确认全局卡片真的进得来且不超预算。0.18.38 版含跨层搬移后的复测。
28
+ 2026-10-29,macOS arm64,Node v24.16.0,本地 SSD。数据:项目 200 条 + 全局 200 条,全部带标题、摘要、`when`、正文;另加一条正面场景,确认全局卡片真的进得来且不超预算。0.18.40 版复测(两次取稳定的一次)。
29
29
 
30
30
  0.18.0 起每次保存都走「写临时文件 → fsync → rename → fsync 目录」并保留上一版,代价是单次保存多两次落盘;下面这组数是带这套持久化保证后测的。
31
31
 
32
32
  | 指标 | 数值 |
33
33
  | --- | --- |
34
- | 准备时延 P50 | 51.4 ms |
35
- | 准备时延 P95 | 59.4 ms |
36
- | 冷启动首次准备 | 76.5 ms |
37
- | 最慢一次 | 76.5 ms |
34
+ | 准备时延 P50 | 48.6 ms |
35
+ | 准备时延 P95 | 51.4 ms |
36
+ | 冷启动首次准备 | 69.6 ms |
37
+ | 最慢一次 | 69.6 ms |
38
38
  | 每次准备上下文 | 368 B |
39
39
  | 正面场景上下文 | 335 B,含 1 张全局卡片,未超 3 条 / 1200 字节 |
40
40
  | 每次准备文件读取 | 0.2 |
41
41
  | 本次数据规模 | 项目 200 条 + 全局 200 条 |
42
42
 
43
- 数据翻倍到两层各 200 条后仍在 deadline 150 ms 内:0.18.38 复测 P95 59.4 ms。模型 provider 在准备阶段无 I/O、无网络。
43
+ 数据翻倍到两层各 200 条后仍在 deadline 150 ms 内:0.18.40 复测 P95 51.4 ms。模型 provider 在准备阶段无 I/O、无网络。
44
44
 
45
45
  ## 长期序列
46
46
 
@@ -52,7 +52,7 @@ npm run bench:evolution # 实验规则/样例/检查点/回执的存储与投
52
52
  | 全局活跃条目 | 200 |
53
53
  | `state.json` | 396 KB(上限 2 MiB) |
54
54
  | 层内文件 | `state.json`、`state.prev.json` 两个,不随轮数增长 |
55
- | RSS | 219 MB(含进程内缓存与测试数据) |
55
+ | RSS | 315 MB(含进程内缓存与测试数据;两次 301/315 MB) |
56
56
  | 序列耗时 | 5.8 s(150 轮全落盘) |
57
57
 
58
58
  ## 实验整理成本(0.11.0)
@@ -0,0 +1,8 @@
1
+ # 0.18.39:读记忆找不到时直接给完整标题
2
+
3
+ - 上下文被压缩后常只剩缩短的标题;按短标题读会精确匹配失败,以前只报「没有这条」,模型只能再猜一轮。
4
+ - 现在失败时就地列出最多 3 条相近的完整标题,模型一次就能换对;成功路径与常驻提示都不变,多出的字只出现在失败回执里。
5
+ - 候选只从当前会话能看到的项目层与全局层取,不跨项目;仍然不按标题模糊读正文,避免读错条目。
6
+ - 补回归:存一条带括号后缀的标题,用前缀读要报出完整标题,再用完整标题读到正文。
7
+
8
+ 验证:`npm test` 全绿;常驻工具文本仍是 13375 B。
@@ -0,0 +1,13 @@
1
+ # 0.18.40
2
+
3
+ ## 更新句柄也能拿到相近完整标题
4
+
5
+ `memory_save` 用 `updateTitle` 改一条记忆时,如果传的是缩短过的标题,以前只回「没有这条记忆」;现在和 `memory_read` / `memory_search` 一致,在报错里列出最多 3 条相近的完整标题,模型可以一次改对。
6
+
7
+ - 只影响失败路径:成功写入的流程、工具描述和常驻字节不变(13375 字节)。
8
+ - 不会模糊读取或模糊改写:仍要求完整标题或 id。
9
+ - 回归:`test/contract.test.mjs` 断言缩短标题的更新请求报出完整标题,且不出现 `undefined`。
10
+
11
+ ## 使用记账回归
12
+
13
+ `test/recall.test.mjs` 新增用例:没有送达模型的回合结束后,条目的 `lastUsed` 不变;真正送达到卡片的回合收尾时才回写。防止以后改动把「检索过」误记为「用过」。
package/index.js CHANGED
@@ -1005,6 +1005,28 @@ function ambiguousTitleError(title, hits, label) {
1005
1005
  return `${label}「${title}」有 ${hits.length} 条同名,无法确定是哪一条:${detail}。先归档其中一条,或换一个能区分的标题再试`;
1006
1006
  }
1007
1007
 
1008
+ /**
1009
+ * 标题解析失败时给几条相近的完整标题:上下文里常只剩缩短的标题,
1010
+ * 精确匹配失败就地列出候选,比让模型再猜一次省一轮。
1011
+ */
1012
+ function nearTitleHints(targets, title) {
1013
+ const out = [];
1014
+ const seen = new Set();
1015
+ for (const target of targets) {
1016
+ let index;
1017
+ try { index = loadIndex(target); } catch { continue; }
1018
+ for (const entry of [...(index.entries || []), ...(index.archived || [])]) {
1019
+ const current = String(entry && entry.title || '').trim();
1020
+ if (!current || current === title || seen.has(current)) continue;
1021
+ if (!current.includes(title) && !title.includes(current)) continue;
1022
+ seen.add(current);
1023
+ out.push(current);
1024
+ if (out.length >= 3) return out;
1025
+ }
1026
+ }
1027
+ return out;
1028
+ }
1029
+
1008
1030
  /**
1009
1031
  * 返回 { id } 或抛出:显式层只查那一层;默认先项目后全局(别的项目不参与解析)。
1010
1032
  * 同一层出现多条同名一律报歧义,绝不取第一条。
@@ -1030,6 +1052,10 @@ function refIdOf(scope, args, label = '记忆') {
1030
1052
  if (hits.length > 1) throw new Error(ambiguousTitleError(title, hits, label));
1031
1053
  if (hits.length === 1) return hits[0].id;
1032
1054
  }
1055
+ const near = nearTitleHints(sources, title);
1056
+ if (near.length) {
1057
+ throw new Error(`没有「${title}」这条${label}。相近的里有:${near.map((item) => `「${item}」`).join('、')};用完整标题或 id 再试`);
1058
+ }
1033
1059
  throw new Error(`没有「${title}」这条${label};先用 memory_search 对一下标题`);
1034
1060
  }
1035
1061
 
@@ -2068,7 +2094,13 @@ export function apply(ctx, config) {
2068
2094
  }
2069
2095
  const layer = args.scope === 'global' ? 'global' : args.scope === 'project' ? 'project' : '';
2070
2096
  const picked = layer ? candidates.find((row) => row.scope === layer) : candidates[0];
2071
- if (!picked) return saveFailure(`没有「${updateTitle}」这条记忆;要新增就不用 updateTitle`, args);
2097
+ if (!picked) {
2098
+ const near = nearTitleHints(runtimeLayers(projectScope), updateTitle);
2099
+ if (near.length) {
2100
+ return saveFailure(`没有「${updateTitle}」这条记忆。相近的里有:${near.map((item) => `「${item}」`).join('、')};要新增就不用 updateTitle`, args);
2101
+ }
2102
+ return saveFailure(`没有「${updateTitle}」这条记忆;要新增就不用 updateTitle`, args);
2103
+ }
2072
2104
  draft.id = picked.id;
2073
2105
  }
2074
2106
  /** 关联目标同样按标题解析成内部 id 再落盘。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lite-memory",
3
- "version": "0.18.38",
3
+ "version": "0.18.40",
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",
@@ -287,6 +287,20 @@ assertSchema('memory_save', twinUpdate);
287
287
  assert.equal(store.readEntry(twinScope, twinAId).body, '正文甲', '按 id 读不能被同名条目串走');
288
288
  assert.equal(store.readEntry(twinScope, twinBId).body, '正文乙');
289
289
 
290
+ /** 上下文里常只剩缩短的标题:精确匹配失败时要把完整标题就地报出来,不让模型再猜一轮。 */
291
+ store.saveEntry(twinScope, { title: '生效验证顺序(含:桌面端没有 Cmd+R)', summary: '缩短标题的探针', body: '正文', writeMode: 'patch' });
292
+ await assert.rejects(
293
+ () => exec('memory_read', { title: '生效验证顺序' }),
294
+ /相近的里有:「生效验证顺序(含:桌面端没有 Cmd\+R)」/,
295
+ );
296
+ const hintedRead = await exec('memory_read', { title: '生效验证顺序(含:桌面端没有 Cmd+R)' });
297
+ assert.equal(hintedRead.body, '正文');
298
+ const hintedUpdate = await exec('memory_save', { title: '缩短标题的探针', summary: '改正文', body: '新正文', updateTitle: '生效验证顺序' });
299
+ assert.ok(hintedUpdate.error);
300
+ assert.match(hintedUpdate.error, /相近的里有:「生效验证顺序(含:桌面端没有 Cmd\+R)」/);
301
+ assert.doesNotMatch(hintedUpdate.error, /undefined/);
302
+
303
+
290
304
  /** 重命名后仍按原 id 定位到同一条;归档后重建同名条目也不能冒领旧 id。 */
291
305
  const renameSaved = store.saveEntry(twinScope, { title: '改名前的探针', summary: '旧身份', body: '旧正文', writeMode: 'patch' });
292
306
  const renameSeedId = renameSaved.record?.id ?? renameSaved.id;
@@ -1009,3 +1009,24 @@ console.log('recall.test.mjs: ok');
1009
1009
  assert.equal(asked(text), false, text);
1010
1010
  }
1011
1011
  }
1012
+
1013
+ // A32:只有真递到模型眼里的条目才记使用时间;空召回/未命中的回合不能顺手记账。
1014
+ {
1015
+ const cwd = mkdir('usage');
1016
+ const scope = store.scopeOf(cwd);
1017
+ const probe = save(scope, { title: '量子纠缠咖啡机', tags: ['记账'], summary: '只有送达才记 lastUsed' });
1018
+ const lastUsedOf = () => store.locateByTitle(scope, '量子纠缠咖啡机').record.lastUsed;
1019
+ const baseline = lastUsedOf();
1020
+ const runtime = boot();
1021
+ const agent = makeAgent('agent-usage', cwd);
1022
+ const missSignal = await prepare(runtime, agent, '完全无关的一串词');
1023
+ assert.doesNotMatch(runtime.provider.text({ agent, signal: missSignal }), /量子纠缠咖啡机/);
1024
+ runtime.handlers.get('agent/turn-stopping').at(-1)({ signal: missSignal });
1025
+ assert.equal(lastUsedOf(), baseline, '没送达的条目不能被记账');
1026
+ const hitSignal = await prepare(runtime, agent, '量子纠缠咖啡机', 't2');
1027
+ assert.match(runtime.provider.text({ agent, signal: hitSignal }), /量子纠缠咖啡机/);
1028
+ assert.equal(lastUsedOf(), baseline, '送达也不在 provider 路径上写盘');
1029
+ runtime.handlers.get('agent/turn-stopping').at(-1)({ signal: hitSignal });
1030
+ assert.ok(lastUsedOf(), '回合收尾时把真正送达的条目记账');
1031
+ assert.equal(store.locateByTitle(scope, '量子纠缠咖啡机').record.id, probe.id);
1032
+ }