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 +4 -2
- package/docs/BENCH.md +7 -7
- package/docs/RELEASE-0.18.40.md +13 -0
- package/docs/RELEASE-0.18.41.md +26 -0
- package/index.js +8 -1
- package/package.json +1 -1
- package/store.js +11 -1
- package/test/client.test.mjs +2 -0
- package/test/contract.test.mjs +5 -0
- package/test/recall.test.mjs +23 -0
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
|
|
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
|
|
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.
|
|
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 |
|
|
35
|
-
| 准备时延 P95 |
|
|
36
|
-
| 冷启动首次准备 |
|
|
37
|
-
| 最慢一次 |
|
|
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.
|
|
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 |
|
|
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)
|
|
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
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)
|
|
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
|
}
|
package/test/client.test.mjs
CHANGED
|
@@ -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);
|
package/test/contract.test.mjs
CHANGED
|
@@ -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' });
|
package/test/recall.test.mjs
CHANGED
|
@@ -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
|
+
}
|