dsh-lite-memory 0.18.41 → 0.18.43

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.
@@ -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` 通过。
@@ -0,0 +1,18 @@
1
+ # 0.18.43:回执上限从 256 降到 64
2
+
3
+ ## 问题
4
+
5
+ 回执只用于同一 `operationId` 的重放保护,重试都发生在当下;按 7 天 TTL 叠加 256 条
6
+ 上限,热点层实测单层回执 35 KB。面板并不读这份列表,`state.receipts` 纯粹是防重
7
+ 数据,留 256 条等于白占状态体积。
8
+
9
+ ## 改法
10
+
11
+ - `LIMITS.receipts` 256 → 64。仍按 7 天 TTL 过期,仍在每次状态写入时清理。
12
+ - 单个热点层实测回执体积从 35.7 KB 降到约 9 KB;重放窗口覆盖当下重试与并发收尾,
13
+ 不涉及历史查询。
14
+
15
+ ## 验证
16
+
17
+ - 全量 `npm test` 通过(含回执过期清理、按 `operationId` 重放两条回归)。
18
+ - 线上层 `DSH-0ea3b310` 下一次写入即截到 64 条。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lite-memory",
3
- "version": "0.18.41",
3
+ "version": "0.18.43",
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/state.js CHANGED
@@ -232,7 +232,8 @@ export const LIMITS = {
232
232
  suggestionTtlMs: 48 * 60 * 60 * 1000,
233
233
  pending: 20,
234
234
  pendingBytes: 64 * 1024,
235
- receipts: 256,
235
+ /** 回执只用于同一操作的重放保护,重试都发生在当下;256 条会白占几十 KB。 */
236
+ receipts: 64,
236
237
  receiptTtlMs: 7 * 24 * 60 * 60 * 1000,
237
238
  suppressions: 64,
238
239
  suppressionTtlMs: 7 * 24 * 60 * 60 * 1000,
package/store.js CHANGED
@@ -246,7 +246,8 @@ function applyFlat(scope, draft, index) {
246
246
  for (const record of index.archived || []) next.push(fromFlat(coldStored(scope, record), 'archived'));
247
247
  draft.entries = next;
248
248
  draft.candidates = Array.isArray(index.suggestions) ? JSON.parse(JSON.stringify(index.suggestions)) : [];
249
- draft.receipts = Array.isArray(index.receipts) ? JSON.parse(JSON.stringify(index.receipts)) : [];
249
+ /** 每次写入顺手清过期回执:只有接受候选才清的话,不产候选的层会一直留着过期的。 */
250
+ draft.receipts = pruneReceiptList(Array.isArray(index.receipts) ? JSON.parse(JSON.stringify(index.receipts)) : []);
250
251
  draft.suppressions = Array.isArray(index.suppressions) ? JSON.parse(JSON.stringify(index.suppressions)) : [];
251
252
  draft.conflicts = Array.isArray(index.conflicts) ? JSON.parse(JSON.stringify(index.conflicts)) : [];
252
253
  draft.lastSuggestedAt = typeof index.lastSuggestedAt === 'string' ? index.lastSuggestedAt : draft.lastSuggestedAt || '';
@@ -1513,7 +1514,7 @@ function usedStale(record, now) {
1513
1514
  * 真正被搜到/读过才算用过:只回写 lastUsed,不动 hits,也不改正文。
1514
1515
  * 抢不到锁或并发冲突都吞掉——这个是提示字段,不该让检索/阅读失败。
1515
1516
  */
1516
- function touchUsed(scope, ids) {
1517
+ function touchUsed(scope, ids, countHit = false) {
1517
1518
  const wanted = new Set((ids || []).filter((id) => typeof id === 'string' && id));
1518
1519
  if (wanted.size === 0) return;
1519
1520
  try {
@@ -1524,8 +1525,13 @@ function touchUsed(scope, ids) {
1524
1525
  let changed = false;
1525
1526
  for (const record of index.entries) {
1526
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 的防抖门槛。 */
1527
1534
  if (record.everUsed !== true) {
1528
- /** 第一次被真用到必须记下来,哪怕刚创建、还没过 lastUsed 的防抖门槛。 */
1529
1535
  record.everUsed = true;
1530
1536
  changed = true;
1531
1537
  }
@@ -1611,7 +1617,7 @@ export function markUsed(scope, ids) {
1611
1617
  const wanted = (Array.isArray(ids) ? ids : []).filter((id) => typeof id === 'string' && id);
1612
1618
  if (!wanted.length) return;
1613
1619
  for (const source of [scope, globalScope()]) {
1614
- try { touchUsed(source, wanted); } catch { /* lastUsed 只是提示 */ }
1620
+ try { touchUsed(source, wanted, true); } catch { /* lastUsed 只是提示 */ }
1615
1621
  }
1616
1622
  }
1617
1623
 
@@ -2062,12 +2068,15 @@ function suppressionOf(index, fingerprint) {
2062
2068
  return '';
2063
2069
  }
2064
2070
 
2065
- function pruneReceipts(index) {
2066
- const now = Date.now();
2067
- index.receipts = (index.receipts || []).filter((item) => {
2068
- 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 : '');
2069
2074
  return Number.isFinite(at) && now - at < LIMITS.receiptTtlMs;
2070
2075
  }).slice(-LIMITS.receipts);
2076
+ }
2077
+
2078
+ function pruneReceipts(index) {
2079
+ index.receipts = pruneReceiptList(index.receipts);
2071
2080
  return index.receipts;
2072
2081
  }
2073
2082
 
@@ -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'));
@@ -1017,6 +1017,7 @@ console.log('recall.test.mjs: ok');
1017
1017
  const probe = save(scope, { title: '量子纠缠咖啡机', tags: ['记账'], summary: '只有送达才记 lastUsed' });
1018
1018
  const lastUsedOf = () => store.locateByTitle(scope, '量子纠缠咖啡机').record.lastUsed;
1019
1019
  const usedOf = () => store.locateByTitle(scope, '量子纠缠咖啡机').record.used;
1020
+ const hitsOf = () => store.locateByTitle(scope, '量子纠缠咖啡机').record.hits;
1020
1021
  assert.equal(usedOf(), false, '新建但没被用过的条目不能算读过');
1021
1022
  const baseline = lastUsedOf();
1022
1023
  const runtime = boot();
@@ -1030,5 +1031,9 @@ console.log('recall.test.mjs: ok');
1030
1031
  assert.equal(lastUsedOf(), baseline, '送达也不在 provider 路径上写盘');
1031
1032
  runtime.handlers.get('agent/turn-stopping').at(-1)({ signal: hitSignal });
1032
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 防抖影响');
1033
1038
  assert.equal(store.locateByTitle(scope, '量子纠缠咖啡机').record.id, probe.id);
1034
1039
  }
@@ -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);