@modusensus/dsh-mneme 0.9.3 → 0.9.5

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.
Files changed (102) hide show
  1. package/README.md +56 -10
  2. package/bin/cli.mjs +215 -2
  3. package/docs/CONFIGURATION.md +10 -2
  4. package/docs/DAEMON.md +9 -2
  5. package/docs/DISTILL.md +66 -4
  6. package/docs/SHARE.md +552 -0
  7. package/docs/SLEEP.md +1 -1
  8. package/docs/STORAGE.md +1 -1
  9. package/lib/api-standalone.js +286 -8
  10. package/lib/api.js +254 -3
  11. package/lib/client.js +1000 -50
  12. package/lib/config.js +50 -1
  13. package/lib/document.js +15 -2
  14. package/lib/dream/decisions.js +88 -3
  15. package/lib/dream/sleep.js +63 -13
  16. package/lib/dream.js +66 -10
  17. package/lib/index.js +6 -1
  18. package/lib/inject.js +10 -25
  19. package/lib/lang.js +48 -6
  20. package/lib/memory-source.js +9 -0
  21. package/lib/organize.js +9 -3
  22. package/lib/pipeline.js +62 -1
  23. package/lib/profile-extract.js +161 -0
  24. package/lib/provenance-http.js +126 -0
  25. package/lib/serve.js +13 -0
  26. package/lib/service.js +70 -6
  27. package/lib/settings.js +17 -13
  28. package/lib/share-http.js +281 -0
  29. package/lib/share-import.js +234 -0
  30. package/lib/share.js +335 -0
  31. package/lib/store.js +272 -11
  32. package/lib/summarize.js +118 -8
  33. package/lib/tools.js +240 -1
  34. package/lib/user-settings.js +78 -0
  35. package/package.json +1 -1
  36. package/src/api-standalone.js +286 -8
  37. package/src/api.js +254 -3
  38. package/src/config.js +50 -1
  39. package/src/document.js +15 -2
  40. package/src/dream/decisions.js +88 -3
  41. package/src/dream/sleep.js +63 -13
  42. package/src/dream.js +66 -10
  43. package/src/index.js +6 -1
  44. package/src/inject.js +10 -25
  45. package/src/lang.js +48 -6
  46. package/src/memory-source.js +9 -0
  47. package/src/organize.js +9 -3
  48. package/src/pipeline.js +62 -1
  49. package/src/profile-extract.js +161 -0
  50. package/src/provenance-http.js +126 -0
  51. package/src/serve.js +13 -0
  52. package/src/service.js +70 -6
  53. package/src/settings.js +17 -13
  54. package/src/share-http.js +281 -0
  55. package/src/share-import.js +234 -0
  56. package/src/share.js +335 -0
  57. package/src/store.js +272 -11
  58. package/src/summarize.js +118 -8
  59. package/src/tools.js +240 -1
  60. package/src/user-settings.js +78 -0
  61. package/test/api.test.js +384 -3
  62. package/test/audit.test.js +2 -2
  63. package/test/cli-receive.test.js +390 -0
  64. package/test/cli-share.test.js +437 -0
  65. package/test/client-share.test.js +225 -0
  66. package/test/client.test.js +215 -6
  67. package/test/dedupe-candidate-window.test.js +180 -0
  68. package/test/dream-candidate-hybrid.test.js +2 -2
  69. package/test/dream-fence.test.js +164 -0
  70. package/test/dream.test.js +87 -5
  71. package/test/helpers/dream-mock.js +6 -2
  72. package/test/lang.test.js +11 -0
  73. package/test/llm-message-source.test.js +1 -1
  74. package/test/mint-source.test.js +1 -1
  75. package/test/organize-tool.test.js +232 -0
  76. package/test/persona-addressing.test.js +83 -0
  77. package/test/persona-settings.test.js +30 -8
  78. package/test/pipeline-r1r2.test.js +129 -0
  79. package/test/policy-epoch.test.js +2 -2
  80. package/test/profile-extract.test.js +269 -0
  81. package/test/provenance-http.test.js +184 -0
  82. package/test/readme-claims.test.js +45 -0
  83. package/test/reasoning-effort.test.js +10 -10
  84. package/test/scope-candidates.test.js +7 -4
  85. package/test/scope-state.test.js +130 -1
  86. package/test/serve.test.js +62 -0
  87. package/test/share-foreign-rows.test.js +144 -0
  88. package/test/share-http.test.js +144 -0
  89. package/test/share-import.test.js +577 -0
  90. package/test/share-imported-guard.test.js +311 -0
  91. package/test/share-imported-identity.test.js +111 -0
  92. package/test/share-pack.test.js +511 -0
  93. package/test/share-receive-http.test.js +756 -0
  94. package/test/share-routes.test.js +337 -0
  95. package/test/share-store.test.js +133 -0
  96. package/test/share-validate.test.js +101 -0
  97. package/test/standalone-api.test.js +524 -5
  98. package/test/summarize-parse-diagnostics.test.js +75 -0
  99. package/test/summarize.test.js +240 -2
  100. package/test/tools-dto-schema-matrix.test.js +48 -5
  101. package/test/tools.test.js +6 -1
  102. package/test/user-settings-preview.test.js +127 -0
package/lib/store.js CHANGED
@@ -11,6 +11,11 @@ import {
11
11
  normalizeScopeStateState,
12
12
  normalizeScopeStateValue
13
13
  } from "./scope-state.js";
14
+ // 外来行的**唯一**定义住在 memory-source.js,这里只取那个常量:store 的查询口径与
15
+ // service/sleep 的 isImportedRow 必须是同一个字符串,自己再拼一份字面量就是第二个口径,
16
+ // 而「本库不巩固别人导入的原文」这条契约正靠它只有一个人说得出口。
17
+ // 依赖方向 store → memory-source 单向(该模块不 import 任何东西),不构成环。
18
+ import { IMPORTED_SOURCE } from "./memory-source.js";
14
19
 
15
20
  const SCHEMA = `
16
21
  CREATE TABLE IF NOT EXISTS memories (
@@ -275,10 +280,12 @@ CREATE INDEX IF NOT EXISTS idx_distill_records_distilled_at ON distill_records(d
275
280
  -- 不该因删掉其中一条派生记忆而消失。
276
281
  -- memory_id 是 TEXT 而非 issue 草稿里的 INTEGER:memories.id 是 TEXT 主键,
277
282
  -- 类型必须与父表一致(否则 join 恒空)。
278
- -- ON DELETE CASCADE 在本库**不生效**——store.js 从不打开 PRAGMA foreign_keys,
279
- -- 全库唯一那条 REFERENCES 也一直是惰性的。与其为一个新表翻转全局 pragma
280
- -- (会同时改变既有 recall_events 的写入行为),不如在 remove() 里显式清链接:
281
- -- 语义相同、影响面只在本表。见 remove() 的注释。
283
+ -- ON DELETE CASCADE 在本库是**生效的**:createStore 走 new DatabaseSync(path) 不带选项,
284
+ -- 而 node:sqlite 的 enableForeignKeyConstraints 默认为真(实测:新库上 PRAGMA foreign_keys
285
+ -- 返回 1,删父行确实级联清掉本表)。本库两条 FK 都因此一直是活的——这一条,以及
286
+ -- recall_evals(recall_run_id) → recall_runs(id)。级联在兜底,remove() 仍然显式清链接,
287
+ -- 理由不是「FK 没开」,而是这份清理不该挂在一条没人钉过的驱动默认上:连接哪天被以
288
+ -- enableForeignKeyConstraints:false 打开,悬空链接只有那条 DELETE 挡得住。见 remove() 的注释。
282
289
  CREATE TABLE IF NOT EXISTS memory_provenance (
283
290
  memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
284
291
  session_id TEXT NOT NULL,
@@ -536,6 +543,11 @@ function toRow(row) {
536
543
  // 顺手 `?? updated_at` 把代理口径捡回来。
537
544
  archived_at: row.archived_at ?? undefined,
538
545
  source: row.source ?? undefined,
546
+ // 车道 C:外来行身份两格。NULL 读作 undefined(与 archived_at 同款「不可知就不编
547
+ // 值」)。注意「这行是不是外来行」的判据仍是 source(memory-source.js 的
548
+ // isImportedRow)——这两格只回答「来自哪个包 / 幂等键是什么」,不回答身份。
549
+ share_pack_id: row.share_pack_id ?? undefined,
550
+ share_external_id: row.share_external_id ?? undefined,
539
551
  content_history: parseJsonArray(row.content_history),
540
552
  evidence: parseJsonArray(row.evidence),
541
553
  doc_path: row.doc_path ?? undefined,
@@ -886,6 +898,32 @@ export function createStore(path) {
886
898
  // 存量行的值是 NULL,且**不推断**:按 updated_at 猜出的模型名会变成日后审计信
887
899
  // 以为真的证据。NULL 一律读作「出身未知」,等价的补法是重算,不是猜。
888
900
  addColumn("memories", "embedding_model", "ALTER TABLE memories ADD COLUMN embedding_model TEXT");
901
+ // 车道 C:外来行的身份两件套。share_pack_id 是「整包退出」的寻址键,share_external_id
902
+ // 是导入的幂等键。它们只对自己铸造的行有意义,所以缺省是 NULL 而不是 '':空串会参与
903
+ // 唯一性(第二条本地行就撞响),而存量行零重写、读侧 undefined = 「不是外来行」。
904
+ addColumn("memories", "share_pack_id", "ALTER TABLE memories ADD COLUMN share_pack_id TEXT");
905
+ addColumn("memories", "share_external_id", "ALTER TABLE memories ADD COLUMN share_external_id TEXT");
906
+ // 索引建在加列之后而非写进 SCHEMA(本文件 content_hash 那段:存量库打开时 CREATE TABLE
907
+ // 是 no-op,列还不存在就建索引会直接 no such column)。
908
+ // 部分索引(WHERE ... IS NOT NULL)是刻意的,但它承重的地方要说准:SQLite 的 UNIQUE
909
+ // 本就把 NULL 视为互不相等,本地行(这一格恒 NULL)挂在普通 UNIQUE 上也不撞——变异检验
910
+ // 证实摘掉 WHERE 五个用例照绿。它承的是另外两件事:①约束的适用范围写进索引本身,
911
+ // 「只有导入路径铸造的 id 参与唯一性」这条语义没有别的表达渠道,读代码的人不必再去猜
912
+ // NULL 在这里算什么;②本地行不进这棵 b 树——普通 UNIQUE 要给每一条本地记忆插一个永不
913
+ // 生效的空键,而库里绝大多数是本地行,每次写都白付一次索引维护。
914
+ // 「NULL 必须自由共享」那条用例锁的是行为而非实现:它防的是将来有人把空串当缺省值写
915
+ // 进来('' 参与唯一性,第二条 '' 直接撞响),不是防 WHERE 子句消失。
916
+ db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_share_external ON memories(share_external_id) WHERE share_external_id IS NOT NULL");
917
+ db.exec("CREATE INDEX IF NOT EXISTS idx_memories_share_pack ON memories(share_pack_id)");
918
+ // Issue #505:saveWithDedupe 的定向候选是 `type = ? AND TRIM(title) = ?`。只有
919
+ // idx_memories_type 时这条语句等于「把该类型的行全读一遍、逐行 TRIM」,代价随最大
920
+ // 类型的行数线性长(本机 node:sqlite 实测同类型 1000 / 5000 / 20000 行 = 0.13 /
921
+ // 0.68 / 4.63 ms,而它是每存一条都要付一次的写路径)。TRIM 本身不是瓶颈——同句去掉
922
+ // TRIM 只差 0.07 ms,缺的是能把整个条件当 seek 用的索引;建好之后同一查询在同一批
923
+ // 数据上 ~0.00 ms。索引表达式必须与 SQL 里的写法逐字相同(TRIM(title),不是 title),
924
+ // 差一点优化器就静默不认,而所有行为用例照样全绿——所以 dedupe-candidate-window
925
+ // 那条用例用 EXPLAIN QUERY PLAN 钉住它真的被选中。改 service 的 WHERE 必须同时改这里。
926
+ db.exec("CREATE INDEX IF NOT EXISTS idx_memories_type_trimmed_title ON memories(type, TRIM(title))");
889
927
  // issue #24 块2(权重演化):entity_relations 加 weight + source。存量关系
890
928
  // 行缺列 → 默认 manual 1.0(最保守:既有语义不因迁移而变弱)。
891
929
  addColumn("entity_relations", "weight", "ALTER TABLE entity_relations ADD COLUMN weight REAL NOT NULL DEFAULT 1.0");
@@ -1123,6 +1161,30 @@ export function createStore(path) {
1123
1161
  return toRow(row);
1124
1162
  }
1125
1163
 
1164
+ /**
1165
+ * 车道 C 导入的幂等读:按包内那条记忆的铸造 id 找本库的行。
1166
+ * 走 idx_memories_share_external(部分索引,非 NULL 那一侧),等值 seek。
1167
+ * 本地行这一格恒 NULL,因此本函数永远不会误命中本地记忆。
1168
+ */
1169
+ function getByShareExternalId(externalId) {
1170
+ if (typeof externalId !== "string" || !externalId) return undefined;
1171
+ return toRow(db.prepare("SELECT * FROM memories WHERE share_external_id = ?").get(externalId));
1172
+ }
1173
+
1174
+ /**
1175
+ * 一个包在本库落下的全部行(撤回前的清点 / 面板「外来区」列表)。
1176
+ *
1177
+ * 不过滤 archived / forgotten:整包退出要连归档面与遗忘面一起收走,否则撤回后库里
1178
+ * 还剩几条「来自那个已经收起的包」的行,身份与包对不上。
1179
+ * 读显式列清单而非 SELECT *(#430 的口径):一个包可以有几百行,每行 9.4 MB 级别
1180
+ * 的 embedding 拖进来没有任何消费方会读。
1181
+ */
1182
+ function listBySharePackId(packId) {
1183
+ if (typeof packId !== "string" || !packId) return [];
1184
+ return db.prepare(`SELECT ${memoryColumnList} FROM memories WHERE share_pack_id = ? ORDER BY updated_at DESC, id DESC`)
1185
+ .all(packId).map(toRow);
1186
+ }
1187
+
1126
1188
  // #230 写入权分离(存储层唯一铸造口,CodeRabbit 复核最终形态):document
1127
1189
  // 行只能经 saveDocument 铸造,通用 save 整类拒绝。doc_path 是任意调用方
1128
1190
  // 都能捏造的字符串——「必带 doc_path」挡不住绕过注册校验(flag 闸 / 文件
@@ -1168,8 +1230,8 @@ export function createStore(path) {
1168
1230
  : inferEpistemicStatus(memory);
1169
1231
  runAtomically(() => {
1170
1232
  db.prepare(
1171
- `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, archived_at, source, content_history, quality_score, embedding, epistemic_status, agent_scope, workspace_scope, agent_scope_source, workspace_scope_source, scope_decided_at, sensitivity, occurred_at, evidence, doc_path, content_hash, created_at, updated_at)
1172
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1233
+ `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, archived_at, source, content_history, quality_score, embedding, epistemic_status, agent_scope, workspace_scope, agent_scope_source, workspace_scope_source, scope_decided_at, sensitivity, occurred_at, evidence, doc_path, content_hash, share_pack_id, share_external_id, created_at, updated_at)
1234
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1173
1235
  ).run(
1174
1236
  id,
1175
1237
  type,
@@ -1197,6 +1259,11 @@ export function createStore(path) {
1197
1259
  evidence,
1198
1260
  docPath,
1199
1261
  contentHash,
1262
+ // 车道 C:外来行身份。空串一律落 NULL——空串会占住 UNIQUE 索引的一格,第二条
1263
+ // 空串外来行就撞响;这两格只由导入路径铸造,其余写入恒 NULL。
1264
+ // created_at / updated_at / content_hash 仍由本函数铸造,不接受调用方传值。
1265
+ typeof memory.share_pack_id === "string" && memory.share_pack_id.trim() ? memory.share_pack_id.trim() : null,
1266
+ typeof memory.share_external_id === "string" && memory.share_external_id.trim() ? memory.share_external_id.trim() : null,
1200
1267
  now,
1201
1268
  now
1202
1269
  );
@@ -1332,10 +1399,11 @@ export function createStore(path) {
1332
1399
 
1333
1400
  function remove(id) {
1334
1401
  runAtomically(() => {
1335
- // #399:memory_provenance 的 ON DELETE CASCADE 在本库是惰性的(createStore
1336
- // 从不打开 PRAGMA foreign_keys),所以链接在这里显式清。不用翻转全局 pragma
1337
- // 换这一条级联:那会同时激活 recall_events 那条既有的 REFERENCES,改变一个
1338
- // 与 #399 无关的写入路径。记录本身(distill_records)不删——删掉一条派生记忆
1402
+ // #399:memory_provenance 挂着 ON DELETE CASCADE,而 node:sqlite 默认就把
1403
+ // foreign_keys 开成 1(实测 PRAGMA foreign_keys = 1),所以级联今天确实在兜底。
1404
+ // 这条显式 DELETE 不是冗余、也不是「FK 没开才需要」:它是不把清理挂在一份没人
1405
+ // 钉过的驱动默认上——连接哪天以 enableForeignKeyConstraints:false 打开,悬空链接
1406
+ // 只有这一条挡得住。记录本身(distill_records)不删——删掉一条派生记忆
1339
1407
  // 不该让整个窗口的原文凭证消失。
1340
1408
  db.prepare("DELETE FROM memory_provenance WHERE memory_id = ?").run(id);
1341
1409
  db.prepare("DELETE FROM memories WHERE id = ?").run(id);
@@ -1345,6 +1413,66 @@ export function createStore(path) {
1345
1413
  });
1346
1414
  }
1347
1415
 
1416
+ // 一条 DELETE ... WHERE id IN (...) 最多绑多少个 `?`。SQLite 的上限是 32766
1417
+ // (SQLITE_MAX_VARIABLE_NUMBER),取 500 后离上限还有六十多倍余量:这条语句一行一个参数,
1418
+ // 而包的大小按字节不按条数封顶(见 deleteBySharePackId 的分块理由),500 让单条语句的
1419
+ // 解析成本可以忽略、又永远够不到那格上限,块数只随包条数线性增长。
1420
+ const DELETE_ID_CHUNK = 500;
1421
+
1422
+ /**
1423
+ * 整包退出(车道 C)。删空一个不存在的包是幂等的 0,不是错误——对方点第二次「收起
1424
+ * 这个包」与「收起一个从来没导入过的包」都该是这个答案;早退也不 bump generation:
1425
+ * 什么都没删就别记一笔镜像债(remove() 每次必 bump,那是它的既有行为,不在这里扩大)。
1426
+ *
1427
+ * side table 清理口径 = remove() 的口径(#399):显式同事务清 memory_provenance。
1428
+ * 这条是双保险,不是「FK 没开所以必须手写」:memory_provenance 挂着 ON DELETE CASCADE,
1429
+ * 而 node:sqlite 默认就把 foreign_keys 开成 1(实测 PRAGMA foreign_keys = 1),今天删行
1430
+ * 确实另有级联兜底;显式清理的存在是为了不依赖这份没人钉过的驱动默认,同时跟 remove()
1431
+ * 保持一致的口径(连接哪天以 enableForeignKeyConstraints:false 打开,悬空链接只有它挡得住)。
1432
+ *
1433
+ * remove() 不碰的(entity_attrs / entity_relations 的 memory_id 反查、conflict_pending、
1434
+ * scope_changes、embedding 解析缓存)这里也不碰——批删不是新的清理语义。
1435
+ *
1436
+ * 向量索引这一格 C4 实测过,把 C1 留下的那句「由 C4 在 service 侧处理」改对:service
1437
+ * 侧没有活儿要接。`vector-index.js` 是 memories.embedding 那一列的薄层,余弦检索
1438
+ * (`searchVector`) 每次调用都从 SQL 现取「带向量的行」的 id 集合,行被物理删掉即从集合
1439
+ * 里消失——向量跟着行一起走,一列残值都留不下;事后落到的 embed 也只是
1440
+ * `UPDATE ... WHERE id = ?` 命中 0 行,不会把僵尸行写回来。唯一的残留是 #202 那个
1441
+ * 进程内的解析缓存(id → 已解析向量),它与 remove() 同处置且读不出僵尸:每个调用方
1442
+ * (searchVector / getEmbeddings / document.js / organize.js)的 id 集合都来自活行的
1443
+ * SQL 读,未命中缓存时按 id 点查库,行没了就返回 undefined——最坏是一条等 FIFO 驱逐的
1444
+ * 内存,不是一条搜得到的鬼行。要在这里清它等于给批删加一份 remove() 没有的新语义,
1445
+ * 与本方法的口径声明相反,所以不动(C4 的报告记了这一格,remove() 是否要一起补是
1446
+ * 单独一刀的事)。
1447
+ *
1448
+ * IN 列表分块(chunk = DELETE_ID_CHUNK):SQLite 一条语句最多绑 32766 个参数
1449
+ * (SQLITE_MAX_VARIABLE_NUMBER,实测第 32767 个报 "too many SQL variables"),而这条
1450
+ * 语句原本一行一个 `?`。车道 A 的包水位按字节不按条数(MAX_PACK_BYTES = 3 MiB),所以
1451
+ * 「正文很短、条数很多」的包理论上能逼近那一格——一条语句撞上限是整笔回滚,效果等于
1452
+ * 「这个包永远撤不掉」,而那正是退出这条路径最不该坏的方式。
1453
+ * 分块不是语义变化,四点逐条对齐分块前的形状:ids 是本事务开头一次读到的固定快照
1454
+ * (slice 出来的连续段,块之间互不相交、并起来就是全集);按主键删本就幂等;整包裹在同
1455
+ * 一个 runAtomically 里,原子性与快照语义一字未动;generation 只在最后加一次,所以
1456
+ * 「删了行却没记镜像债」这个 crash 窗口和分块前同样不存在。changes 逐块求和——那是
1457
+ * 调用方答复里报出去的数,少加一块就少报一块。
1458
+ */
1459
+ function deleteBySharePackId(packId) {
1460
+ if (typeof packId !== "string" || !packId) return 0;
1461
+ return runAtomically(() => {
1462
+ const ids = db.prepare("SELECT id FROM memories WHERE share_pack_id = ?").all(packId).map((r) => r.id);
1463
+ if (!ids.length) return 0;
1464
+ let deleted = 0;
1465
+ for (let start = 0; start < ids.length; start += DELETE_ID_CHUNK) {
1466
+ const chunk = ids.slice(start, start + DELETE_ID_CHUNK);
1467
+ const marks = chunk.map(() => "?").join(", ");
1468
+ db.prepare(`DELETE FROM memory_provenance WHERE memory_id IN (${marks})`).run(...chunk);
1469
+ deleted += db.prepare(`DELETE FROM memories WHERE id IN (${marks})`).run(...chunk).changes;
1470
+ }
1471
+ incrementGeneration();
1472
+ return deleted;
1473
+ });
1474
+ }
1475
+
1348
1476
  /**
1349
1477
  * Atomic compare-and-set update: applies `patch` only when the row still
1350
1478
  * carries `expectedUpdatedAt` (the version token read by the caller). Returns
@@ -1521,7 +1649,7 @@ export function createStore(path) {
1521
1649
  return rows.map(toRow);
1522
1650
  }
1523
1651
 
1524
- function list({ type, excludeTypes = null, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, minImportance = null, source = null, updatedFrom = null, updatedTo = null, occurredFrom = null, occurredTo = null, visibility = null } = {}) {
1652
+ function list({ type, excludeTypes = null, excludeImported = false, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, minImportance = null, source = null, updatedFrom = null, updatedTo = null, occurredFrom = null, occurredTo = null, visibility = null } = {}) {
1525
1653
  const clauses = [];
1526
1654
  const params = [];
1527
1655
  if (type) {
@@ -1545,6 +1673,17 @@ export function createStore(path) {
1545
1673
  clauses.push("source = ?");
1546
1674
  params.push(source);
1547
1675
  }
1676
+ // excludeImported:「本库不巩固别人导入的原文」这条政策轴的查询层口径。
1677
+ // 它必须站在 LIMIT 之前而不是跟着 .filter 走在后面——与上面 excludeTypes 那条同理由
1678
+ // (#230):先截断后丢弃的话,被丢掉的外来行照样吃掉窗口名额,导入过包的库里
1679
+ // 模式扫描池会因此饿到 sleepPatternMinMemories 以下而静默跳过。
1680
+ // `source IS NULL OR` 那一支不是防御性冗余,是必填:本方行的 source 恒为 NULL,
1681
+ // 而 SQL 里三值逻辑让 `NULL <> IMPORTED_SOURCE` 求值为 NULL(不是 true),只写后者的
1682
+ // 话本方行整批消失。opt-in 默认关,其余调用方的读数逐字节不变。
1683
+ if (excludeImported) {
1684
+ clauses.push("(source IS NULL OR source <> ?)");
1685
+ params.push(IMPORTED_SOURCE);
1686
+ }
1548
1687
  // Optional updated_at closed range (date-only "to" is normalized to the
1549
1688
  // end of that day). Same helper as count() so total matches the rows.
1550
1689
  const bounds = updatedAtBounds(updatedFrom, updatedTo);
@@ -1645,6 +1784,30 @@ export function createStore(path) {
1645
1784
  return rows.map(toRow);
1646
1785
  }
1647
1786
 
1787
+ // Issue #505:写路径的去重候选必须「按同标题定向取」,不能「按 importance 取
1788
+ // 前 N 条」。后者是「该类型最重要的 100 行」,与「找出同标题的那一行」无关:
1789
+ // 某类型里 importance 高于待写行的行数一旦超过窗口,同标题行与待写行双双落在
1790
+ // 窗口外,saveWithDedupe 的 find 恒不命中,于是静默走创建分支——不报错、不告
1791
+ // 警,合并语义随库增长而失效(报告实测 1047 行类型、importance=5 有 180 行时
1792
+ // 稳定复现)。
1793
+ //
1794
+ // 口径刻意与 saveWithDedupe 的比较式对齐:那边用 m.title.trim() 比
1795
+ // String(memory.title).trim(),而 title 入库时并不 trim(见 save() 的
1796
+ // `memory.title` 直写)⇒ 这里必须 TRIM(title),否则存量里带首尾空白的标题
1797
+ // 会被精确匹配漏掉,退回同一种静默降级。
1798
+ //
1799
+ // archived/forgotten 的过滤与 list() 默认口径一致(两者都为 0):合并目标必须
1800
+ // 是活跃行,否则会把内容并进一条已经归档/遗忘的记忆里。
1801
+ // 无 LIMIT:同 (type, title) 的活跃行本就极少,加窗口等于把 #505 重新引进来。
1802
+ function activeByTypeAndTitle(type, title) {
1803
+ const rows = db.prepare(
1804
+ `SELECT ${memoryColumnList} FROM memories
1805
+ WHERE type = ? AND TRIM(title) = ?
1806
+ AND forgotten = 0 AND archived = 0`
1807
+ ).all(type, String(title ?? "").trim());
1808
+ return rows.map(toRow);
1809
+ }
1810
+
1648
1811
  // Issue #430:写路径要的「最近若干条正文」。质量过滤的近重复判定只读 content
1649
1812
  // 这一列 + 前 20 行,此前写成 all().slice(0, 20) —— 全表逐行 toRow(27 字段、
1650
1813
  // 三次 JSON 解析)之后只截前 20 条正文;5k 行实测 100ms 级 → 0.06ms 级。
@@ -1801,6 +1964,71 @@ export function createStore(path) {
1801
1964
  }, new Map());
1802
1965
  }
1803
1966
 
1967
+ /**
1968
+ * 维度探针(#383):**存在性判据**——库里有没有一条活跃向量的维度等于查询维度。
1969
+ *
1970
+ * #508 评审把判据从「读一行的维度」换成了存在性,因为 LIMIT 1 拿任意一条代表全库,
1971
+ * 在混合维度库里两个方向都错:抽到与查询不符的那条 → 明明有可比的 3 维向量却报
1972
+ * degraded;抽到的那条恰好解析失败 → 其余有效向量都不匹配时又漏报成 full。一行
1973
+ * 读数回答不了「这个维度在这库里能不能比」这个问题。
1974
+ *
1975
+ * 行集与 searchVector **必须同源**:下面这段 WHERE 的谓词与 searchVector(本文件
1976
+ * vector search 段)逐条相同(只是书写顺序不同,语义等价),改一处必须同步改另一处
1977
+ * ——否则 tier 的核心断言「searchVector 会不会找到可比向量」会悄悄失真。活跃 = 未
1978
+ * 遗忘未归档、embedding 非空;/context 只经 searchVector 触达检索,从不传
1979
+ * includeArchived。
1980
+ *
1981
+ * 状态语义(后两个是 fail-closed 兜底态,不是正常读数):
1982
+ * match 有维度相符的活跃向量 → 语义路通。注意「通」≠「有命中」:type /
1983
+ * importance / codingGate / scope 四道门(service.js 的 inject 段)
1984
+ * 仍可能让语义档贡献为 0,这是旧探针就有的边界。
1985
+ * mismatch 有向量,但没有一条维度相符 → 语义召回实际不可用 → degraded
1986
+ * none 行集本身为空(空库 / 从未建索引)→ 没有东西可比,**不是**维度
1987
+ * 不符 → full
1988
+ * unreadable 有行,但一条都读不出来(embedding 列损坏)→ 与 none 是**两种空**:
1989
+ * 前者是还没有内容,后者是内容在那儿却读不出来。判不出来 → unknown,
1990
+ * 绝不因为「读不到」就宣称 full。
1991
+ * invalid 查询维度本身不是正整数 → 同样判不出来 → unknown。当前 /context
1992
+ * 不可达(embedQueryVector 保证返回非空数组),防御性留着:别让
1993
+ * 「判不了」落到 full 上。
1994
+ *
1995
+ * 成本模型(取代原来「只读一行,O(1)」的说法——那句话对现在的实现是假的):
1996
+ * · SQL 是一次**无 ORDER BY 无 LIMIT 的全 id 扫描**,每次调用都有,不免费
1997
+ * (只拖 id 列,约 40 KB/千行,比 SELECT * 的全表 TEXT 便宜得多)。
1998
+ * · 按匹配短路:全库同维度(常态)时 JS 循环第一行就 return,只解析 1 个向量。
1999
+ * · 一条都不匹配时才走完整个循环,而那正是要报 degraded 的情形,值得。此时每个
2000
+ * 缓存未命中的行都要点查一次 embedding TEXT(冷缓存下 = N+1 条语句);
2001
+ * getParsedEmbedding 不缓存否定结果,所以读不出来的行每次请求都会重解析一次。
2002
+ * · 缓存热不热是有条件的:/context 里让 embeddingCache 变热的只有 injectCandidates
2003
+ * 内部两个 vectorIndex 分支(service.js:1696 / :1768),需要 hybridInject 与
2004
+ * selectiveInject 都没被关掉、行数在 4000 条 FIFO 上限内;standalone 装配不设
2005
+ * vectorIndex 时缓存是冷的。判档跑在 injectCandidates 之后,装配齐时缓存已热。
2006
+ *
2007
+ * @param {number} dimension 查询向量维度
2008
+ * @returns {{state: "match"|"mismatch"|"none"|"unreadable"|"invalid"}}
2009
+ */
2010
+ function vectorDimensionState(dimension) {
2011
+ if (!Number.isInteger(dimension) || dimension <= 0) return { state: "invalid" };
2012
+ // 与 searchVector 的 WHERE 谓词逐条相同(顺序不同、语义等价)——同源行集,同步改。
2013
+ const rows = db.prepare(
2014
+ `SELECT id FROM memories
2015
+ WHERE embedding IS NOT NULL AND embedding != ''
2016
+ AND forgotten = 0 AND archived = 0`
2017
+ ).all();
2018
+ if (!rows.length) return { state: "none" };
2019
+ let sawVector = false;
2020
+ for (const row of rows) {
2021
+ // 走 getParsedEmbedding 的解析缓存,不自己 JSON.parse——与 searchVector /
2022
+ // getEmbedding(s) 同一条读路径,缓存热时零解析。
2023
+ const v = getParsedEmbedding(row.id);
2024
+ if (!v || !v.length) continue;
2025
+ sawVector = true;
2026
+ if (v.length === dimension) return { state: "match" };
2027
+ }
2028
+ // 有行却零条可解析:不是「没有向量」,是「有向量读不出来」。
2029
+ return { state: sawVector ? "mismatch" : "unreadable" };
2030
+ }
2031
+
1804
2032
  function search(query, { limit = 20, includeArchived = false } = {}) {
1805
2033
  const q = String(query).trim();
1806
2034
  if (!q) return [];
@@ -2429,6 +2657,22 @@ export function createStore(path) {
2429
2657
  ).all(memoryId);
2430
2658
  }
2431
2659
 
2660
+ /**
2661
+ * 一个 (session, seq 区间) 的蒸馏原文行(#399 写入、#421 读出口)。
2662
+ *
2663
+ * 按 seq 升序——原文的先后顺序就是区间顺序,乱序会让「这句从哪来」读不出来。
2664
+ * 到期裁剪后区间可能变空,那时返回 []:链接刻意保留并指向空区间
2665
+ * (见 clearDistillRecords 注释),面板据此显示「原文已过期」而非「没有来源」。
2666
+ */
2667
+ function distillRecordsFor(sessionId, seqStart, seqEnd) {
2668
+ return db.prepare(
2669
+ `SELECT seq, role, text, window_id, distilled_at
2670
+ FROM distill_records
2671
+ WHERE session_id = ? AND seq >= ? AND seq <= ?
2672
+ ORDER BY seq`
2673
+ ).all(sessionId, seqStart, seqEnd);
2674
+ }
2675
+
2432
2676
  // --- 存储生命周期:无损回收(#275 第一批)--------------------------------
2433
2677
  //
2434
2678
  // 两项都零价值判断、零条数变化:行数不变,只把不可重建/不可达的内容丢掉。
@@ -3338,6 +3582,9 @@ export function createStore(path) {
3338
3582
  db,
3339
3583
  count,
3340
3584
  getById,
3585
+ // 车道 C:外来行身份的两个读口(幂等键 / 整包清点)。
3586
+ getByShareExternalId,
3587
+ listBySharePackId,
3341
3588
  save,
3342
3589
  // document 唯一铸造口(#230 写入权分离):registerDocument 专用,通用
3343
3590
  // save/update/CAS 均拒绝 document 创建或类型转换。
@@ -3347,6 +3594,15 @@ export function createStore(path) {
3347
3594
  // #254:写入准入的内容哈希候选集(只读)。
3348
3595
  findContentHashMatches,
3349
3596
  remove,
3597
+ // 车道 C:整包退出(幂等批删,side table 口径同 remove())。
3598
+ deleteBySharePackId,
3599
+ // 只管 SQLite 回滚的存储层原语(本文件每个写方法都在用它,这里只是对外露一层:
3600
+ // 外层已开着事务时它直接执行回调,db.isTransaction 早退,嵌套不会二次 BEGIN)。
3601
+ // **它不是导入口**:车道 C 的导入走 service.transaction()——那层除回滚外还会随
3602
+ // ROLLBACK 截断 pendingEmbed / pendingEntity,用这个裸原语导入会让队列和库分叉。
3603
+ // 露出的用途是存储级批写与用例:C1 的 UNIQUE 撞库用例(test/share-store.test.js)
3604
+ // 就靠它把「撞一条、整批不落」这条 ROLLBACK 路径直接命中。
3605
+ runAtomically,
3350
3606
  setForget,
3351
3607
  setArchived,
3352
3608
  touchLastAccess,
@@ -3355,6 +3611,9 @@ export function createStore(path) {
3355
3611
  getUnrecalledSince,
3356
3612
  list,
3357
3613
  all,
3614
+ // Issue #505:写路径去重的定向候选(按 type + TRIM(title) 精确取活跃行),
3615
+ // 替代「importance 前 N 条」那个会随库增长失效的窗口。
3616
+ activeByTypeAndTitle,
3358
3617
  // #430:有界单列读,写路径(质量过滤)用。
3359
3618
  recentContents,
3360
3619
  search,
@@ -3367,6 +3626,7 @@ export function createStore(path) {
3367
3626
  hasStaleEmbedding,
3368
3627
  embeddingModelBreakdown,
3369
3628
  embeddingDimensionBreakdown,
3629
+ vectorDimensionState,
3370
3630
  searchVector,
3371
3631
  saveDreamRun,
3372
3632
  lastDreamRunAt,
@@ -3398,6 +3658,7 @@ export function createStore(path) {
3398
3658
  clearDistillRecords,
3399
3659
  saveMemoryProvenance,
3400
3660
  provenanceFor,
3661
+ distillRecordsFor,
3401
3662
  // 存储生命周期(#275 第一批):无损回收的统计、清理与体积口径。
3402
3663
  dreamRunInputStats,
3403
3664
  clearDreamRunInputs,
package/lib/summarize.js CHANGED
@@ -33,11 +33,56 @@ function normalizeDistilledOccurredAt(raw, nowMs = Date.now()) {
33
33
  }
34
34
  }
35
35
 
36
- function parseSummaryJsonResult(raw) {
36
+ /**
37
+ * 失败形态分类(#502),只在结果不 ok 时产出:
38
+ * - no-array:连 `[` 都没有——模型在说话,不是没按格式;
39
+ * - syntax:有方括号、整体 parse 失败且括号扫描也没救回条目(#339 那一族);
40
+ * - filtered-empty:JSON 合法、`arr` 非空,条目却被 VALID 全数滤掉。**这一种最贵**:
41
+ * 读起来像「没按格式写」,实际是分类词不在枚举内,而游标不推进会让整窗反复重放。
42
+ */
43
+ function describeParseFailure(arr, entries, salvaged, hasBracket) {
44
+ if (!Array.isArray(arr) || salvaged) return { kind: hasBracket ? "syntax" : "no-array", arrCount: 0, sampleTypes: [] };
45
+ if (arr.length === 0) return { kind: "no-array", arrCount: 0, sampleTypes: [] };
46
+ if (entries.length > 0) return null;
47
+ const sampleTypes = [...new Set(arr.map((item) => (typeof item?.type === "string" ? item.type.trim() : "")).filter(Boolean))].slice(0, 5);
48
+ return { kind: "filtered-empty", arrCount: arr.length, sampleTypes };
49
+ }
50
+
51
+ /**
52
+ * 失败行的审计读数(纯函数:不驱动整条蒸馏管线也能测)。filtered-empty 的
53
+ * errorMessage 直接写出 arr/valid 两个数,一眼可辨;metadata 补 failure_kind /
54
+ * arr_count / invalid_entries / sample_types / raw_tail——尾巴是给「先写几条合法
55
+ * 条目再跑偏」那种输出准备的,前 300 字看不出来。
56
+ */
57
+ export function summarizeParseFailureAudit(raw, failure) {
58
+ const text = String(raw ?? "");
59
+ const kind = failure?.kind ?? (text.includes("[") ? "syntax" : "no-array");
60
+ const tail = text.length > 500 ? text.slice(-200) : "";
61
+ const filtered = kind === "filtered-empty" ? failure : null;
62
+ return {
63
+ errorMessage: filtered ? `no valid summary entries (arr=${filtered.arrCount}, valid=0)` : "invalid summary JSON",
64
+ metadata: {
65
+ parse_failure: true,
66
+ failure_kind: kind,
67
+ raw_len: text.length,
68
+ raw_head: text.slice(0, 300),
69
+ ...(tail ? { raw_tail: tail } : {}),
70
+ raw_has_bracket: text.includes("["),
71
+ ...(filtered ? { arr_count: filtered.arrCount, invalid_entries: filtered.arrCount, sample_types: filtered.sampleTypes } : {})
72
+ }
73
+ };
74
+ }
75
+
76
+ /** 导出的原因:#502 要能锁住「哪一种失败」这条判据本身,而不是只锁它的下游读数。 */
77
+ export function parseSummaryJsonResult(raw) {
37
78
  const text = String(raw ?? "");
38
79
  const start = text.indexOf("[");
39
80
  const end = text.lastIndexOf("]");
40
- if (start === -1 || end === -1 || end <= start) return { ok: false, entries: [] };
81
+ // 连方括号都没有 = 模型在说话(#493 的 B 臂正是这一型)。早退也要带上下:否则下游
82
+ // 只能靠「输出里有没有 [」再猜一次,而猜出来的东西不该写进错误消息。
83
+ if (start === -1 || end === -1 || end <= start) {
84
+ return { ok: false, entries: [], failure: describeParseFailure(null, [], false, text.includes("[")) };
85
+ }
41
86
  let arr;
42
87
  let salvaged = false;
43
88
  try {
@@ -77,10 +122,16 @@ function parseSummaryJsonResult(raw) {
77
122
  // 空数组表示模型明确判断本轮没有可沉淀内容;非空数组若全部无效,
78
123
  // 则不能消费窗口,否则无效输出会永久推进 seq 游标。salvage 路径
79
124
  // 没有「显式空数组」可言——ok 只看是否救回了条目。
125
+ const ok = salvaged ? entries.length > 0 : (arr.length === 0 || entries.length > 0);
80
126
  return {
81
- ok: salvaged ? entries.length > 0 : (arr.length === 0 || entries.length > 0),
127
+ ok,
82
128
  entries,
83
- salvaged
129
+ salvaged,
130
+ // #502:失败时要能说出**是哪一种**失败。「压根没写数组」与「写了合法数组、但条目
131
+ // type 全不在枚举里」过去共用一句 invalid summary JSON,而两者要修的地方完全不同
132
+ // ——后者是菜单与闸门不一致(summary prompt 只列 4 个类型、VALID 吃 7 个),且失败
133
+ // 路径游标不推进,同一窗口会被一遍遍重放(社区报告 #493 追加的实测形态)。
134
+ ...(!ok ? { failure: describeParseFailure(arr, entries, salvaged, text.includes("[")) } : {})
84
135
  };
85
136
  }
86
137
 
@@ -404,12 +455,29 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
404
455
  if (seq === undefined) return max;
405
456
  return max === undefined ? seq : Math.max(max, seq);
406
457
  }, undefined);
458
+ const transcriptBody = trimTranscript(lines.join("\n"), maxChars);
407
459
  return {
408
460
  messages: lines.length
409
- ? [createUserMessage({ content: [{ type: "text", text: trimTranscript(lines.join("\n"), maxChars) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } })]
461
+ ? [createUserMessage({
462
+ content: [{
463
+ type: "text",
464
+ // Issue #493:转录裸放 user 位时,模型把它当成「轮到我说了」接着对话
465
+ // 往下写,输出里连 `[` 都没有(解析器在括号早退处直接判失败)。围栏 +
466
+ // 角色重申把「这段文本是被提炼的素材」写进**它所在的那条消息**,而不是
467
+ // 只写在 system 里——实测同段转录只加 system 约束仍 0/2 出 JSON。
468
+ // 围栏是确定性的:重试收到的转录与首次逐字节相同(既有回归用例依赖)。
469
+ text: `${STR.distillRoleReminder[language]}\n\n${STR.transcriptFenceOpen[language]}\n${transcriptBody}\n${STR.transcriptFenceClose[language]}`
470
+ }],
471
+ source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" }
472
+ })]
410
473
  : [],
411
474
  hasEvents: events.length > 0,
412
- lastSeq
475
+ lastSeq,
476
+ // Issue #493:只数**转录正文**的字符数。minWindowChars 闸门与审计里的
477
+ // distillChars 量的都是「这一窗有多少可提炼内容」,不是「我们额外包了多少
478
+ // 提示词」——若把围栏与角色句算进去,一条「短」也会凭空越过任何阈值,
479
+ // 闸门随之失效(实测 summarizeMinWindowChars=10 会被 4 字正文 + 包裹放行)。
480
+ transcriptChars: lines.length ? transcriptBody.length : 0
413
481
  };
414
482
  }
415
483
 
@@ -534,6 +602,10 @@ export function createSummarizer(ctx, service, config, deps = {}) {
534
602
  const deferredRuns = new Map();
535
603
  // Issue #210:只在一次蒸馏完整成功后记录已消费的最后事件 seq。
536
604
  const lastDistilledSeq = new Map();
605
+ // Issue #493:per-session 连续解析失败计数(失败护栏用)。同一窗口在确定性
606
+ // 失败下会被每个 turn/end 重试一次、且窗口只会越攒越大——没有出口。成功即
607
+ // 清零;dispose 一并清理(与 runsUsed / lastRunAt 同生命周期)。
608
+ const parseFailureStreak = new Map();
537
609
  let disposed = false;
538
610
 
539
611
  /** 写一条 autoSummarize 的 LLM 审计行(审计失败绝不阻塞蒸馏本身)。 */
@@ -701,7 +773,12 @@ export function createSummarizer(ctx, service, config, deps = {}) {
701
773
  // summarize_compress 成功行的 metadata 里窗口字符数为 0 条),验收 4「默认配置
702
774
  // 下调用量可量化下降」因此无法对账。本改动只加计量,不动任何跳过决策——
703
775
  // summarizeMinWindowChars 仍为 0 时行为逐字节不变。
704
- const distillChars = messages.reduce(
776
+ //
777
+ // Issue #493:口径改为只数转录正文(collected.transcriptChars),不再把
778
+ // user 消息整段长度当窗口大小——#493 给转录加了围栏与角色句,若继续按
779
+ // 「消息总字符数」算,一条 4 字的「短」也会被包裹撑过任何阈值,闸门等于
780
+ // 被自己的提示词旁路掉。
781
+ const distillChars = collected.transcriptChars ?? messages.reduce(
705
782
  (total, message) => total + (Array.isArray(message.content) ? message.content : [])
706
783
  .reduce((n, part) => n + (typeof part?.text === "string" ? part.text.length : 0), 0),
707
784
  0
@@ -869,12 +946,44 @@ export function createSummarizer(ctx, service, config, deps = {}) {
869
946
  const parsedResult = parseSummaryJsonResult(text || assembledText);
870
947
  // 解析失败不推进 seq 游标,下一次 turn/end 仍会重试同一窗口。
871
948
  if (!parsedResult.ok) {
949
+ // Issue #493:失败也要留证据。原先 metadata 只在成功路径赋值,失败行恒为
950
+ // null —— 事后无法回答「模型到底吐了什么形状」。这里只加计量、不做任何
951
+ // 跳过决策(raw_head 截断到 300 字符,避免把审计表撑大)。
952
+ const raw = text || assembledText || "";
953
+ const streak = (parseFailureStreak.get(session.id) ?? 0) + 1;
954
+ parseFailureStreak.set(session.id, streak);
955
+ // Issue #493 失败护栏:同一窗口连续失败到阈值后强制消费游标跳过它。
956
+ // 确定性失败(finish_reason=stop、同文同温)重试收益为 0,而窗口只会随
957
+ // 新事件继续变大——没有出口时它会一直失败下去。默认 0 = 关闭,行为与
958
+ // 历史版本逐字节一致(本仓新行为一律 opt-in,见 AGENTS.md)。
959
+ const maxParseRetries = config.summarizeParseFailureMaxRetries ?? 0;
960
+ // nextSeq 必须是有限数才算真的「跳过了窗口」:没有可验证的 seq 时
961
+ // commitCursor 是空操作(persistCursor 早退、内存游标也不写),此时若
962
+ // 仍按跳过记账并清零 streak,审计会宣称「已放弃」而窗口下一轮照旧重试
963
+ // —— 那是最坏的一种假象:用户以为止血了,实际还在烧。
964
+ const skipWindow = maxParseRetries > 0 && streak >= maxParseRetries && Number.isFinite(nextSeq);
965
+ if (skipWindow) {
966
+ // 先提交游标再复位计数:窗口已放弃,下一窗从全新状态起算。
967
+ commitCursor(session.id, nextSeq);
968
+ parseFailureStreak.delete(session.id);
969
+ }
872
970
  if (audit) {
873
971
  audit.status = "error";
874
- audit.errorMessage = "invalid summary JSON";
972
+ // 读数与错误文案由同一个纯函数产出(#502):三种失败形态各有各的说法,
973
+ // 「模型在说话」与「分类词不在枚举内」不能都写成 invalid summary JSON。
974
+ const failureAudit = summarizeParseFailureAudit(raw, parsedResult?.failure);
975
+ audit.errorMessage = failureAudit.errorMessage;
976
+ audit.metadata = {
977
+ distillChars,
978
+ ...failureAudit.metadata,
979
+ streak,
980
+ ...(maxParseRetries > 0 ? { maxRetries: maxParseRetries } : {}),
981
+ ...(skipWindow ? { skipped: "parse-failure-streak" } : {})
982
+ };
875
983
  }
876
984
  return;
877
985
  }
986
+ parseFailureStreak.delete(session.id);
878
987
  const parsed = parsedResult.entries;
879
988
  // Issue #127 条数上限:0 = 不限(现状)。被截断的条数与去重命中数一并进
880
989
  // 审计 metadata——此前产出条数完全由模型输出决定,用户无从自查。
@@ -1064,6 +1173,7 @@ export function createSummarizer(ctx, service, config, deps = {}) {
1064
1173
  inFlight.clear();
1065
1174
  lastRunAt.clear();
1066
1175
  lastDistilledSeq.clear();
1176
+ parseFailureStreak.clear();
1067
1177
  }
1068
1178
  };
1069
1179
  }