dsh-lite-memory 0.18.39 → 0.18.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -1006,6 +1006,8 @@ window.__ModuleLoader__.load({
1006
1006
  });
1007
1007
  return counts;
1008
1008
  };
1009
+ /** 真被用过才算读过:`lastUsed` 有创建时间回退值。 */
1010
+ const usedOf = (record) => (record && record.used !== undefined ? record.used === true : Boolean(record && record.lastUsed));
1009
1011
  /** 标签/话题按精确值筛选;只有自由搜索才用包含匹配。 */
1010
1012
  const filterRecords = (records, query, hub) => {
1011
1013
  const rows = Array.isArray(records) ? records : EMPTY;
@@ -1020,7 +1022,7 @@ window.__ModuleLoader__.load({
1020
1022
  if (kind === 'duplicate' || kind === 'unread' || kind === 'noWhen') {
1021
1023
  const counts = titleCountsOf(rows);
1022
1024
  return rows.filter((record) => {
1023
- if (kind === 'unread') return !record.lastUsed;
1025
+ if (kind === 'unread') return !usedOf(record);
1024
1026
  if (kind === 'noWhen') return !record.when;
1025
1027
  const title = typeof record.title === 'string' ? record.title.trim() : '';
1026
1028
  return Boolean(title) && counts.get(title) > 1;
@@ -1043,7 +1045,7 @@ window.__ModuleLoader__.load({
1043
1045
  const globalRow = (Array.isArray(scopes) ? scopes : EMPTY).find((scope) => scope && scope.kind === 'global');
1044
1046
  return {
1045
1047
  duplicate,
1046
- unread: rows.filter((record) => !record.lastUsed).length,
1048
+ unread: rows.filter((record) => !usedOf(record)).length,
1047
1049
  globalEntries: globalRow && Number.isInteger(globalRow.entries) ? globalRow.entries : null,
1048
1050
  globalMissingWhen: globalSelected ? rows.filter((record) => !record.when).length : null,
1049
1051
  };
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,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` 不变;真正送达到卡片的回合收尾时才回写。防止以后改动把「检索过」误记为「用过」。
@@ -0,0 +1,26 @@
1
+ # 0.18.41:把「从未读过」变成真话
2
+
3
+ ## 问题
4
+
5
+ 面板「结构体检」里的「从未读过」和筛选器按 `lastUsed` 是否为空判断。但 `lastUsed`
6
+ 在新建、编辑时都会写入回退值,于是这个数字恒为 0,筛选也永远筛不出东西——一条从没被
7
+ 召回过的记忆看起来像读过。
8
+
9
+ ## 改法
10
+
11
+ - 记录新增 `everUsed`:只有真正送达模型并完成使用记账(`touchUsed`)才置真;
12
+ 第一次被用到时即使还在 `lastUsed` 的十分钟防抖窗口内,也只补标记、不额外落盘。
13
+ - 旧数据没有这个标记,用时间戳差异回填:`lastUsed` 与创建/更新时间都不同才算用过,
14
+ 因此升级后「从未读过」直接可信,不需要迁移脚本。
15
+ - 面板记录多带一个 `used` 布尔值;筛选项与体检计数都改看它,`lastUsed` 只继续用于
16
+ 陈旧度排序。界面文案不变。
17
+ - 没有新增常驻提示、没有改召回顺序、没有增加单次注入字节。
18
+
19
+ ## 验证
20
+
21
+ - `test/recall.test.mjs`:新建条目 `used === false`;同一回合送达后变 `true`,
22
+ 且未送达的回合不变。
23
+ - `test/client.test.mjs`:带 `lastUsed` 回退值但 `used === false` 的条目仍计入
24
+ 「从未读过」,`used === true` 的排除。
25
+ - 全量 `npm test` 通过;三次基准 P50 67–69 ms,发生在系统负载 8.6 的机器上,
26
+ 与 0.18.40 的 48.6 ms 差异来自负载,上下文字节仍为 368 B。
package/index.js CHANGED
@@ -476,6 +476,7 @@ function keepHandler(webServer, cwdForSession) {
476
476
  origin: originOut(record.origin),
477
477
  updated: record.updated || '',
478
478
  lastUsed: record.lastUsed || '',
479
+ used: record.used === true,
479
480
  bytes: Number(record.bytes) || 0,
480
481
  experimental: isExperimentalRecord(record),
481
482
  learning: record.learning ? {
@@ -2094,7 +2095,13 @@ export function apply(ctx, config) {
2094
2095
  }
2095
2096
  const layer = args.scope === 'global' ? 'global' : args.scope === 'project' ? 'project' : '';
2096
2097
  const picked = layer ? candidates.find((row) => row.scope === layer) : candidates[0];
2097
- if (!picked) return saveFailure(`没有「${updateTitle}」这条记忆;要新增就不用 updateTitle`, args);
2098
+ if (!picked) {
2099
+ const near = nearTitleHints(runtimeLayers(projectScope), updateTitle);
2100
+ if (near.length) {
2101
+ return saveFailure(`没有「${updateTitle}」这条记忆。相近的里有:${near.map((item) => `「${item}」`).join('、')};要新增就不用 updateTitle`, args);
2102
+ }
2103
+ return saveFailure(`没有「${updateTitle}」这条记忆;要新增就不用 updateTitle`, args);
2104
+ }
2098
2105
  draft.id = picked.id;
2099
2106
  }
2100
2107
  /** 关联目标同样按标题解析成内部 id 再落盘。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lite-memory",
3
- "version": "0.18.39",
3
+ "version": "0.18.41",
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
@@ -115,6 +115,9 @@ function toFlat(entry) {
115
115
  supersededBy: typeof entry.supersededBy === 'string' ? entry.supersededBy : '',
116
116
  supersedeReason: typeof entry.supersedeReason === 'string' ? entry.supersedeReason : '',
117
117
  lastUsed: maintenance.lastUsed || entry.updatedAt || entry.createdAt || '',
118
+ /** 是否真被用过(送达模型并记账过);`lastUsed` 有创建/更新时间回退值,不能拿它判断「从未读过」。 */
119
+ used: maintenance.everUsed === true
120
+ || Boolean(maintenance.lastUsed && maintenance.lastUsed !== entry.createdAt && maintenance.lastUsed !== entry.updatedAt),
118
121
  revision: Number(entry.revision) || 1,
119
122
  status: entry.status === 'archived' ? 'archived' : 'active',
120
123
  origin: ORIGINS.includes(entry.origin) ? entry.origin : 'user_explicit',
@@ -172,6 +175,7 @@ function fromFlat(record, status) {
172
175
  learning: cloneJson(record.learning) || cloneJson(record.maintenance?.learning) || undefined,
173
176
  hits: Number(record.hits) || 0,
174
177
  merges: Number(record.merges) || 0,
178
+ everUsed: record.everUsed === true || (record.maintenance && record.maintenance.everUsed === true),
175
179
  lastUsed: record.lastUsed || at,
176
180
  },
177
181
  };
@@ -1519,7 +1523,13 @@ function touchUsed(scope, ids) {
1519
1523
  const stamp = new Date(now).toISOString();
1520
1524
  let changed = false;
1521
1525
  for (const record of index.entries) {
1522
- if (!wanted.has(record.id) || !usedStale(record, now)) continue;
1526
+ if (!wanted.has(record.id)) continue;
1527
+ if (record.everUsed !== true) {
1528
+ /** 第一次被真用到必须记下来,哪怕刚创建、还没过 lastUsed 的防抖门槛。 */
1529
+ record.everUsed = true;
1530
+ changed = true;
1531
+ }
1532
+ if (!usedStale(record, now)) continue;
1523
1533
  record.lastUsed = stamp;
1524
1534
  changed = true;
1525
1535
  }
@@ -339,6 +339,8 @@ assert.deepEqual(checkupOf(checkupRows, checkupScopes, false), { duplicate: 1, u
339
339
  assert.deepEqual(checkupOf(checkupRows, checkupScopes, true), { duplicate: 1, unread: 1, globalEntries: 213, globalMissingWhen: 1 });
340
340
  assert.deepEqual(filterRecords(checkupRows, '', { kind: 'duplicate' }).map((row) => row.id), ['mem_c0000001', 'mem_c0000002']);
341
341
  assert.deepEqual(filterRecords(checkupRows, '', { kind: 'unread' }).map((row) => row.id), ['mem_c0000001']);
342
+ assert.deepEqual(filterRecords([{ id: 'mem_u1', title: '新写的', lastUsed: '2026-01-01T00:00:00.000Z', used: false }], '', { kind: 'unread' }).map((row) => row.id), ['mem_u1'], '有创建时间回退值但没真用过,仍算从未读过');
343
+ assert.equal(checkupOf([{ id: 'mem_u2', title: '用过的', lastUsed: '2026-01-01T00:00:00.000Z', used: true }], [], false).unread, 0);
342
344
  assert.deepEqual(filterRecords(checkupRows, '', { kind: 'noWhen' }).map((row) => row.id), ['mem_c0000001']);
343
345
  assert.deepEqual([...titleCountsOf(checkupRows).values()], [2, 1]);
344
346
  assert.equal(checkupOf([], [], false).globalEntries, null);
@@ -295,6 +295,11 @@ await assert.rejects(
295
295
  );
296
296
  const hintedRead = await exec('memory_read', { title: '生效验证顺序(含:桌面端没有 Cmd+R)' });
297
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
+
298
303
 
299
304
  /** 重命名后仍按原 id 定位到同一条;归档后重建同名条目也不能冒领旧 id。 */
300
305
  const renameSaved = store.saveEntry(twinScope, { title: '改名前的探针', summary: '旧身份', body: '旧正文', writeMode: 'patch' });
@@ -1009,3 +1009,26 @@ 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 usedOf = () => store.locateByTitle(scope, '量子纠缠咖啡机').record.used;
1020
+ assert.equal(usedOf(), false, '新建但没被用过的条目不能算读过');
1021
+ const baseline = lastUsedOf();
1022
+ const runtime = boot();
1023
+ const agent = makeAgent('agent-usage', cwd);
1024
+ const missSignal = await prepare(runtime, agent, '完全无关的一串词');
1025
+ assert.doesNotMatch(runtime.provider.text({ agent, signal: missSignal }), /量子纠缠咖啡机/);
1026
+ runtime.handlers.get('agent/turn-stopping').at(-1)({ signal: missSignal });
1027
+ assert.equal(lastUsedOf(), baseline, '没送达的条目不能被记账');
1028
+ const hitSignal = await prepare(runtime, agent, '量子纠缠咖啡机', 't2');
1029
+ assert.match(runtime.provider.text({ agent, signal: hitSignal }), /量子纠缠咖啡机/);
1030
+ assert.equal(lastUsedOf(), baseline, '送达也不在 provider 路径上写盘');
1031
+ runtime.handlers.get('agent/turn-stopping').at(-1)({ signal: hitSignal });
1032
+ assert.equal(usedOf(), true, '送达并记账后「读过」标记跟着成立');
1033
+ assert.equal(store.locateByTitle(scope, '量子纠缠咖啡机').record.id, probe.id);
1034
+ }