dsh-lite-memory 0.18.40 → 0.18.42
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/RELEASE-0.18.41.md +26 -0
- package/docs/RELEASE-0.18.42.md +26 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/store.js +27 -8
- package/test/client.test.mjs +2 -0
- package/test/contract.test.mjs +2 -1
- package/test/recall.test.mjs +8 -1
- package/test/store.test.mjs +15 -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
|
};
|
|
@@ -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。
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# 0.18.42:回执会自己过期,使用次数说真话
|
|
2
|
+
|
|
3
|
+
## 问题
|
|
4
|
+
|
|
5
|
+
- 回执(最近操作记录)只在「接受候选」时清过期项。不产候选的层会一直留着过期回执,
|
|
6
|
+
状态文件只增不减;`receipts` 上限 256 条,过期数据还可能把新回执挤掉。
|
|
7
|
+
- 记录里的 `hits` 字段从来没人加:写的时候是 0,之后一直是 0,既占状态字节又不反映
|
|
8
|
+
真实使用,面板也没法解释「这条被用过几次」。
|
|
9
|
+
|
|
10
|
+
## 改法
|
|
11
|
+
|
|
12
|
+
- 抽出 `pruneReceiptList(rows, now)`:按 7 天 TTL 过滤并截到上限。每次状态写入
|
|
13
|
+
(`applyFlat`)顺手调用,不再依赖候选审批;`pruneReceipts` 改为复用同一函数。
|
|
14
|
+
- `hits` 改为「真实交付并记账的回合数」:只有 `markUsed` / 回合收尾记账才加一;
|
|
15
|
+
检索、阅读、注入正文仍是纯读,不碰 `hits`,只在需要时补 `lastUsed`。
|
|
16
|
+
- `everUsed` 与 `lastUsed` 的写入合并进同一次保存判断:第一次被用到时标记一定落盘,
|
|
17
|
+
十分钟防抖只约束 `lastUsed` 的重复回写。
|
|
18
|
+
- 没有新增常驻提示、没有改召回顺序与注入字节,状态只减不增。
|
|
19
|
+
|
|
20
|
+
## 验证
|
|
21
|
+
|
|
22
|
+
- `test/store.test.mjs`:预置一条过期、一条新鲜回执,任意一次普通写入后过期项被清掉。
|
|
23
|
+
- `test/store.test.mjs`:检索/阅读后 `hits` 不变、`lastUsed` 不变。
|
|
24
|
+
- `test/recall.test.mjs`:送达记账后 `hits === 1`,再次记账累加到 2,不受防抖影响。
|
|
25
|
+
- `test/contract.test.mjs`:注入正文本身不写 `hits`;交付过的卡片在下一轮开头记一次。
|
|
26
|
+
- 全量 `npm test` 通过。
|
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 ? {
|
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
|
};
|
|
@@ -242,7 +246,8 @@ function applyFlat(scope, draft, index) {
|
|
|
242
246
|
for (const record of index.archived || []) next.push(fromFlat(coldStored(scope, record), 'archived'));
|
|
243
247
|
draft.entries = next;
|
|
244
248
|
draft.candidates = Array.isArray(index.suggestions) ? JSON.parse(JSON.stringify(index.suggestions)) : [];
|
|
245
|
-
|
|
249
|
+
/** 每次写入顺手清过期回执:只有接受候选才清的话,不产候选的层会一直留着过期的。 */
|
|
250
|
+
draft.receipts = pruneReceiptList(Array.isArray(index.receipts) ? JSON.parse(JSON.stringify(index.receipts)) : []);
|
|
246
251
|
draft.suppressions = Array.isArray(index.suppressions) ? JSON.parse(JSON.stringify(index.suppressions)) : [];
|
|
247
252
|
draft.conflicts = Array.isArray(index.conflicts) ? JSON.parse(JSON.stringify(index.conflicts)) : [];
|
|
248
253
|
draft.lastSuggestedAt = typeof index.lastSuggestedAt === 'string' ? index.lastSuggestedAt : draft.lastSuggestedAt || '';
|
|
@@ -1509,7 +1514,7 @@ function usedStale(record, now) {
|
|
|
1509
1514
|
* 真正被搜到/读过才算用过:只回写 lastUsed,不动 hits,也不改正文。
|
|
1510
1515
|
* 抢不到锁或并发冲突都吞掉——这个是提示字段,不该让检索/阅读失败。
|
|
1511
1516
|
*/
|
|
1512
|
-
function touchUsed(scope, ids) {
|
|
1517
|
+
function touchUsed(scope, ids, countHit = false) {
|
|
1513
1518
|
const wanted = new Set((ids || []).filter((id) => typeof id === 'string' && id));
|
|
1514
1519
|
if (wanted.size === 0) return;
|
|
1515
1520
|
try {
|
|
@@ -1519,7 +1524,18 @@ function touchUsed(scope, ids) {
|
|
|
1519
1524
|
const stamp = new Date(now).toISOString();
|
|
1520
1525
|
let changed = false;
|
|
1521
1526
|
for (const record of index.entries) {
|
|
1522
|
-
if (!wanted.has(record.id)
|
|
1527
|
+
if (!wanted.has(record.id)) continue;
|
|
1528
|
+
/** 检索/阅读只补 lastUsed,不记 hits:只有真正交付给模型的记账才算一次使用。 */
|
|
1529
|
+
if (countHit) {
|
|
1530
|
+
record.hits = (Number(record.hits) || 0) + 1;
|
|
1531
|
+
changed = true;
|
|
1532
|
+
}
|
|
1533
|
+
/** 第一次被真用到必须记下来,哪怕刚创建、还没过 lastUsed 的防抖门槛。 */
|
|
1534
|
+
if (record.everUsed !== true) {
|
|
1535
|
+
record.everUsed = true;
|
|
1536
|
+
changed = true;
|
|
1537
|
+
}
|
|
1538
|
+
if (!usedStale(record, now)) continue;
|
|
1523
1539
|
record.lastUsed = stamp;
|
|
1524
1540
|
changed = true;
|
|
1525
1541
|
}
|
|
@@ -1601,7 +1617,7 @@ export function markUsed(scope, ids) {
|
|
|
1601
1617
|
const wanted = (Array.isArray(ids) ? ids : []).filter((id) => typeof id === 'string' && id);
|
|
1602
1618
|
if (!wanted.length) return;
|
|
1603
1619
|
for (const source of [scope, globalScope()]) {
|
|
1604
|
-
try { touchUsed(source, wanted); } catch { /* lastUsed 只是提示 */ }
|
|
1620
|
+
try { touchUsed(source, wanted, true); } catch { /* lastUsed 只是提示 */ }
|
|
1605
1621
|
}
|
|
1606
1622
|
}
|
|
1607
1623
|
|
|
@@ -2052,12 +2068,15 @@ function suppressionOf(index, fingerprint) {
|
|
|
2052
2068
|
return '';
|
|
2053
2069
|
}
|
|
2054
2070
|
|
|
2055
|
-
function
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
const at = Date.parse(item.at || '');
|
|
2071
|
+
function pruneReceiptList(rows, now = Date.now()) {
|
|
2072
|
+
return (Array.isArray(rows) ? rows : []).filter((item) => {
|
|
2073
|
+
const at = Date.parse(item && item.at ? item.at : '');
|
|
2059
2074
|
return Number.isFinite(at) && now - at < LIMITS.receiptTtlMs;
|
|
2060
2075
|
}).slice(-LIMITS.receipts);
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
function pruneReceipts(index) {
|
|
2079
|
+
index.receipts = pruneReceiptList(index.receipts);
|
|
2061
2080
|
return index.receipts;
|
|
2062
2081
|
}
|
|
2063
2082
|
|
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
|
@@ -542,6 +542,7 @@ const sig1 = new AbortController().signal;
|
|
|
542
542
|
const hitsBefore = store.loadIndex(store.scopeOf(cwd)).entries.find((item) => item.id === hintedRow.id).hits;
|
|
543
543
|
await preStep({ agent: recallAgent, signal: sig1, turn: '2' }, pass('又弹钥匙串窗口了,怎么弄', 'h1'));
|
|
544
544
|
const hintText = recall.text({ agent: recallAgent, signal: sig1 });
|
|
545
|
+
assert.equal(store.loadIndex(store.scopeOf(cwd)).entries.find((item) => item.id === hintedRow.id).hits, hitsBefore, '注入正文本身是纯读,不写 hits');
|
|
545
546
|
assert.doesNotMatch(hintText, /mem_[a-z0-9]{6,}/, '召回卡只给标题,不给内部编号');
|
|
546
547
|
assert.match(hintText, /需要细节用 memory_read/);
|
|
547
548
|
sessionCwds.set('session-recall', cwd);
|
|
@@ -559,7 +560,7 @@ const missReceipt = await call('/lite-memory/overview', { sessionId: 'session-re
|
|
|
559
560
|
assert.ok(['no_overlap', 'needs_context'].includes(missReceipt.body.current.recall.reason), '没提醒时也说明依据');
|
|
560
561
|
assert.equal(missReceipt.body.current.recall.cards, 0);
|
|
561
562
|
const hitsAfter = store.loadIndex(store.scopeOf(cwd)).entries.find((item) => item.id === hintedRow.id).hits;
|
|
562
|
-
assert.equal(hitsAfter, hitsBefore);
|
|
563
|
+
assert.equal(hitsAfter, hitsBefore + 1, '交付过的卡片在下一轮开头记一次使用');
|
|
563
564
|
|
|
564
565
|
const sig2 = new AbortController().signal;
|
|
565
566
|
await preStep({ agent: recallAgent, signal: sig2, turn: '3' }, pass('周四晚上发版要注意什么', 'h2'));
|
package/test/recall.test.mjs
CHANGED
|
@@ -1016,6 +1016,9 @@ console.log('recall.test.mjs: ok');
|
|
|
1016
1016
|
const scope = store.scopeOf(cwd);
|
|
1017
1017
|
const probe = save(scope, { title: '量子纠缠咖啡机', tags: ['记账'], summary: '只有送达才记 lastUsed' });
|
|
1018
1018
|
const lastUsedOf = () => store.locateByTitle(scope, '量子纠缠咖啡机').record.lastUsed;
|
|
1019
|
+
const usedOf = () => store.locateByTitle(scope, '量子纠缠咖啡机').record.used;
|
|
1020
|
+
const hitsOf = () => store.locateByTitle(scope, '量子纠缠咖啡机').record.hits;
|
|
1021
|
+
assert.equal(usedOf(), false, '新建但没被用过的条目不能算读过');
|
|
1019
1022
|
const baseline = lastUsedOf();
|
|
1020
1023
|
const runtime = boot();
|
|
1021
1024
|
const agent = makeAgent('agent-usage', cwd);
|
|
@@ -1027,6 +1030,10 @@ console.log('recall.test.mjs: ok');
|
|
|
1027
1030
|
assert.match(runtime.provider.text({ agent, signal: hitSignal }), /量子纠缠咖啡机/);
|
|
1028
1031
|
assert.equal(lastUsedOf(), baseline, '送达也不在 provider 路径上写盘');
|
|
1029
1032
|
runtime.handlers.get('agent/turn-stopping').at(-1)({ signal: hitSignal });
|
|
1030
|
-
assert.
|
|
1033
|
+
assert.equal(usedOf(), true, '送达并记账后「读过」标记跟着成立');
|
|
1034
|
+
assert.equal(hitsOf(), 1, '送达一次记一次使用次数');
|
|
1035
|
+
const id = store.locateByTitle(scope, '量子纠缠咖啡机').record.id;
|
|
1036
|
+
store.markUsed(scope, [id]);
|
|
1037
|
+
assert.equal(hitsOf(), 2, '再次记账继续累加,不受 lastUsed 防抖影响');
|
|
1031
1038
|
assert.equal(store.locateByTitle(scope, '量子纠缠咖啡机').record.id, probe.id);
|
|
1032
1039
|
}
|
package/test/store.test.mjs
CHANGED
|
@@ -506,6 +506,21 @@ test('到硬上限时归档最久没读的一条,不拒绝新记忆', () => {
|
|
|
506
506
|
assert.equal(archived.current.body.length, chunk);
|
|
507
507
|
});
|
|
508
508
|
|
|
509
|
+
test('过期回执在任意写入时清掉,不靠接受候选', () => {
|
|
510
|
+
const scope = fresh('receipt-ttl');
|
|
511
|
+
const now = Date.now();
|
|
512
|
+
const stale = new Date(now - LIMITS.receiptTtlMs - 60 * 1000).toISOString();
|
|
513
|
+
const freshAt = new Date(now - 60 * 1000).toISOString();
|
|
514
|
+
state.transact(scope, { expectCreate: true }, (draft) => {
|
|
515
|
+
draft.receipts = [{ operationId: 'op_old', at: stale, changed: 1 }, { operationId: 'op_new', at: freshAt, changed: 2 }];
|
|
516
|
+
return { changed: true };
|
|
517
|
+
});
|
|
518
|
+
const saved = upsert(scope, { title: '顺手写一条', summary: '触发一次事务', body: '正文' });
|
|
519
|
+
assert.ok(saved.record.id);
|
|
520
|
+
const kept = stateOf(scope).receipts.map((row) => row.operationId);
|
|
521
|
+
assert.deepEqual(kept, ['op_new']);
|
|
522
|
+
});
|
|
523
|
+
|
|
509
524
|
test('the global layer reports both lines', () => {
|
|
510
525
|
const global = globalScope();
|
|
511
526
|
assert.equal(LIMITS.globalEntries, 200);
|