@modusensus/dsh-mneme 0.8.4 → 0.8.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 (69) hide show
  1. package/README.md +16 -7
  2. package/bin/dsh-mneme-mcp.mjs +11 -3
  3. package/lib/api-standalone.js +11 -0
  4. package/lib/api.js +21 -0
  5. package/lib/client.js +213 -31
  6. package/lib/config.js +82 -0
  7. package/lib/document.js +221 -0
  8. package/lib/dream/sleep.js +6 -4
  9. package/lib/dream.js +1347 -1319
  10. package/lib/guide.js +44 -0
  11. package/lib/heat.js +2 -0
  12. package/lib/inject.js +91 -15
  13. package/lib/lang.js +6 -0
  14. package/lib/organize.js +450 -0
  15. package/lib/recall-stats.js +23 -1
  16. package/lib/runtime/layout.js +30 -0
  17. package/lib/runtime/loader.js +4 -1
  18. package/lib/runtime/verify.js +19 -6
  19. package/lib/search/adaptive.js +38 -0
  20. package/lib/service.js +165 -12
  21. package/lib/settings.js +31 -2
  22. package/lib/store.js +109 -8
  23. package/lib/summarize.js +253 -8
  24. package/lib/tools.js +136 -8
  25. package/package.json +6 -6
  26. package/runtime-manifest.json +104 -174
  27. package/scripts/build-runtime-manifest.mjs +8 -1
  28. package/scripts/sync-test-badge.mjs +18 -12
  29. package/src/api-standalone.js +11 -0
  30. package/src/api.js +21 -0
  31. package/src/config.js +82 -0
  32. package/src/document.js +221 -0
  33. package/src/dream/sleep.js +6 -4
  34. package/src/dream.js +1347 -1319
  35. package/src/guide.js +44 -0
  36. package/src/heat.js +2 -0
  37. package/src/inject.js +91 -15
  38. package/src/lang.js +6 -0
  39. package/src/organize.js +450 -0
  40. package/src/recall-stats.js +23 -1
  41. package/src/runtime/layout.js +30 -0
  42. package/src/runtime/loader.js +4 -1
  43. package/src/runtime/verify.js +19 -6
  44. package/src/search/adaptive.js +38 -0
  45. package/src/service.js +165 -12
  46. package/src/settings.js +31 -2
  47. package/src/store.js +109 -8
  48. package/src/summarize.js +253 -8
  49. package/src/tools.js +136 -8
  50. package/test/adaptive-inject-budget.test.js +85 -0
  51. package/test/api.test.js +65 -3
  52. package/test/client.test.js +63 -2
  53. package/test/document.test.js +523 -0
  54. package/test/dream.test.js +51 -0
  55. package/test/inject-content-cap.test.js +5 -3
  56. package/test/inject-pools.test.js +170 -0
  57. package/test/inject-recall.test.js +105 -0
  58. package/test/inject.test.js +35 -1
  59. package/test/llm-audit.test.js +153 -0
  60. package/test/organize.test.js +418 -0
  61. package/test/recall-stats.test.js +7 -1
  62. package/test/runtime-loader.test.js +18 -0
  63. package/test/runtime-manifest.test.js +2 -1
  64. package/test/runtime-verify.test.js +65 -0
  65. package/test/sleep.test.js +37 -0
  66. package/test/store.test.js +13 -0
  67. package/test/summarize.test.js +407 -3
  68. package/test/tools-dto-schema-matrix.test.js +325 -0
  69. package/test/tools.test.js +36 -3
package/lib/service.js CHANGED
@@ -7,16 +7,30 @@ import { computeHeat } from "./heat.js";
7
7
  import { recallStats } from "./recall-stats.js";
8
8
  import { evaluateMemoryQuality } from "./quality-filter.js";
9
9
  import { applyDecisions } from "./dream/decisions.js";
10
+ import { createDocumentRegistrar } from "./document.js";
11
+ import { createOrganizer } from "./organize.js";
10
12
  import { createBM25Index } from "./search/bm25.js";
11
13
  import { adaptiveThreshold } from "./search/adaptive.js";
12
14
 
13
15
  const INJECT_TYPES = new Set(["preference", "project", "decision", "summary", "rejected_solution", "pitfall", "constraint"]);
14
16
 
17
+ // document 摘要行(#230):仅 documentMemoryEnabled 开启时进注入候选。独立
18
+ // 集合而非常改共享 Set——INJECT_TYPES 本身是「类型可注入」的静态事实,flag
19
+ // 是运行时状态,两者不能搅在一起。
20
+ const INJECT_TYPES_WITH_DOCUMENT = new Set([...INJECT_TYPES, "document"]);
21
+
15
22
  // 编码记忆类型(codingRetrospect):rejected_solution / pitfall / constraint
16
23
  // 只在编码任务时注入(防噪声污染其他业务),且编码场景下按 codingBoostFactor
17
24
  // 加权排序提前。
18
25
  const CODING_MEMORY_TYPES = new Set(["rejected_solution", "pitfall", "constraint"]);
19
26
 
27
+ // pin 池类型(#249 第一批):约束与偏好是「对谁都成立的边界」,需要逐字保真
28
+ // 而不是相关性排序——它们与情景日志同池同速率摘要会被静默降级(立项依据)。
29
+ // 注意 constraint 同时属于 CODING_MEMORY_TYPES,非编码任务里已被 codingGate
30
+ // 滤掉,pin 池同样拿不到它;是否豁免该门控是待维护者拍板的口径问题,本批次
31
+ // 不动既有门控(不放大行为面)。
32
+ const PINNED_MEMORY_TYPES = new Set(["constraint", "preference"]);
33
+
20
34
  /**
21
35
  * 判断一段文本是否编码类任务(关键词匹配,codingRetrospect 读取侧门控)。
22
36
  * 纯函数,无副作用,便于单测。
@@ -39,8 +53,10 @@ const CONTENT_HISTORY_MAX = 20;
39
53
 
40
54
  // Issue #135 附属发现 2:质量过滤器写下的系统信号标签(「为什么被降权/归档」的
41
55
  // 唯一审计线索)。更新路径整组替换 tags 会把它们抹掉——更新时按此清单并集保留。
42
- // 清单必须与 quality-filter.js 的写入端对齐(6 个,含 type 自指的 self_referential)。
43
- const SIGNAL_TAGS = ["low_quality", "duplicate", "meta", "repetitive", "short_content", "self_referential"];
56
+ // 清单必须与 quality-filter.js 的写入端对齐(6 个,含 type 自指的 self_referential);
57
+ // evidence_degraded 是 #230 注册端的求交标记(quality-filter 不写它,但同样
58
+ // 不许被更新抹掉)。
59
+ const SIGNAL_TAGS = ["low_quality", "duplicate", "meta", "repetitive", "short_content", "self_referential", "evidence_degraded"];
44
60
 
45
61
  // scopeKeyOf 已上提到 ./scope.js(v0.8.1,issue #170 第 2 步):与 sleep 的跨
46
62
  // scope 配对共用同一把比较钥匙,避免两处定义漂移。
@@ -1110,6 +1126,13 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1110
1126
  * @returns {{action: "created"|"merged", memory: object}}
1111
1127
  */
1112
1128
  function saveWithDedupe(memory) {
1129
+ // #230 写入权分离:document 行只能经 registerDocument 铸造(注册校验 +
1130
+ // doc_path + evidence 三样俱全)。通用保存路径(memory_save 工具 / MCP /
1131
+ // standalone API / bootstrap)一律拒绝——防止绕过注册校验造出无指针无
1132
+ // 回链的伪 document 行。与 updateMemory 的同款守卫构成双保险。
1133
+ if (memory?.type === "document") {
1134
+ throw new Error("type 'document' is minted only via registerDocument (summary + doc_path + evidence)");
1135
+ }
1113
1136
  // Bug7: score quality once (after dedupe lookup, before write). Failures
1114
1137
  // inside the evaluator are impossible (pure function), but the write that
1115
1138
  // records the score must never fail the save — wrap defensively.
@@ -1301,8 +1324,13 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1301
1324
  * lead the selection (up to maxItems*2 candidates) and the rule-based pick
1302
1325
  * fills + dedupes the remaining slots. Empty query / no cached recall /
1303
1326
  * hybridInject off → pure legacy rule-based selection.
1327
+ *
1328
+ * #249 第一批(B1 pin 池):`pinnedInjectBudget` > 0 时,约束/偏好类先按相关性
1329
+ * 取满独立预算、再从候选里摘除(于是轮换重排碰不到它们),由调用方前置到块
1330
+ * 头。`pinnedStats` 是可选出参:回报实际 pin 条数与超预算未展示条数,不改变
1331
+ * 本函数「返回数组」的既有契约。预算为 0 时整段不执行,行为逐字节不变。
1304
1332
  */
1305
- function injectCandidates({ query = "", maxItems = 5, threshold = 3, queryVector, scope = null, rotate = null, rotateWindow = 0 } = {}) {
1333
+ function injectCandidates({ query = "", maxItems = 5, threshold = 3, queryVector, scope = null, rotate = null, rotateWindow = 0, pinnedStats = null } = {}) {
1306
1334
  const q = String(query ?? "").trim();
1307
1335
  // codingRetrospect 读取侧门控:编码记忆(rejected_solution / pitfall /
1308
1336
  // constraint)只在编码任务时注入,防噪声污染其他业务;编码任务时按
@@ -1321,11 +1349,19 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1321
1349
  const poolSize = rotateWindowN > 0
1322
1350
  ? Math.max(maxItems * 2, maxItems * (rotateWindowN + 1))
1323
1351
  : maxItems * 2;
1352
+ // #230 拍板:注入档位合并设计(叙述条次优先档 + document 摘要行预算)。
1353
+ // ①叙述条(source=narrative)从纯按需解禁进注入候选,落次优先档——但受
1354
+ // 生成总闸 dreamNarrativeEnabled 约束(flag 关 = 该类行不再注入,存量行
1355
+ // 仍可检索);②document 摘要行同落次优先档,另有独立预算(见下方选取)。
1356
+ const injectTypes = config?.documentMemoryEnabled === true ? INJECT_TYPES_WITH_DOCUMENT : INJECT_TYPES;
1357
+ // #230 拍板:叙述条解禁进注入受生成总闸约束——门必须对全部候选路径一致
1358
+ // (规则/向量/缓存/BM25),只锁规则路的话语义路仍会漏进 narrative 行。
1359
+ const allowNarrative = config?.dreamNarrativeEnabled === true;
1324
1360
  const filtered = store.list({ limit: Math.max(200, poolSize), includeForgotten: false })
1325
- .filter((m) => !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
1326
- // 叙述条(#164 对齐)按需检索:source=narrative 的 per-topic 叙述不进
1327
- // 注入候选——常驻位只留给 dream 总览(source=dream)。
1328
- m.source !== "narrative" &&
1361
+ .filter((m) => !m.archived && injectTypes.has(m.type) && !m.forgotten &&
1362
+ // 叙述条:#228 落地为纯按需检索;#230 合并拍板解禁为次优先档注入
1363
+ // (per-topic 叙述常驻位仍只留给 dream 总览 source=dream)。
1364
+ (m.source !== "narrative" || allowNarrative) &&
1329
1365
  codingGate(m) &&
1330
1366
  (m.type === "summary" || m.type === "preference" || m.importance >= threshold));
1331
1367
  // #218 v1: heat 乘数——heatEnabled 时在优先级层内给 importance×quality 乘
@@ -1341,8 +1377,13 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1341
1377
  // 编码记忆在编码任务时优先于普通 decision(与 preference 同级),
1342
1378
  // importance 乘 codingBoostFactor 加权(封顶 5,保持 importance 语义)。
1343
1379
  const priority = (m) => {
1380
+ // #230:叙述条与 document 摘要行同为次优先档——蒸馏摘要(0)仍最
1381
+ // 先,指针型聚合产物(1)先于普通 project/decision(2)。叙述条是
1382
+ // type=summary,判 source 必须在判 type 之前。
1383
+ if (m.source === "narrative") return 1;
1344
1384
  if (m.type === "summary") return 0;
1345
1385
  if (m.type === "preference") return 1;
1386
+ if (m.type === "document") return 1;
1346
1387
  if (isCoding && CODING_MEMORY_TYPES.has(m.type)) return 1;
1347
1388
  return 2;
1348
1389
  };
@@ -1367,7 +1408,8 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1367
1408
  try {
1368
1409
  const hits = vectorIndex.search(queryVector, { limit: poolSize, threshold: 0 });
1369
1410
  for (const m of hits) {
1370
- if (m && !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
1411
+ if (m && !m.archived && injectTypes.has(m.type) && !m.forgotten &&
1412
+ (m.source !== "narrative" || allowNarrative) &&
1371
1413
  codingGate(m) &&
1372
1414
  (m.type === "summary" || m.type === "preference" || m.importance >= threshold)) {
1373
1415
  semanticItems.push(m);
@@ -1377,7 +1419,8 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1377
1419
  }
1378
1420
  if (!semanticItems.length && lastSemanticRecall?.query === q && lastSemanticRecall.items?.length) {
1379
1421
  for (const m of lastSemanticRecall.items) {
1380
- if (m && !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten && codingGate(m)) semanticItems.push(m);
1422
+ if (m && !m.archived && injectTypes.has(m.type) && !m.forgotten &&
1423
+ (m.source !== "narrative" || allowNarrative) && codingGate(m)) semanticItems.push(m);
1381
1424
  }
1382
1425
  }
1383
1426
  // Issue #198:首轮(无向量、无缓存召回)的同步兜底——BM25 词法召回领位。
@@ -1389,7 +1432,8 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1389
1432
  // 语义向量命中时本分支不参与,行为不变。
1390
1433
  if (!semanticItems.length) {
1391
1434
  for (const hit of bm25Recall(q, poolSize)) {
1392
- if (hit && !hit.archived && INJECT_TYPES.has(hit.type) && !hit.forgotten &&
1435
+ if (hit && !hit.archived && injectTypes.has(hit.type) && !hit.forgotten &&
1436
+ (hit.source !== "narrative" || allowNarrative) &&
1393
1437
  codingGate(hit) &&
1394
1438
  (hit.type === "summary" || hit.type === "preference" || hit.importance >= threshold)) {
1395
1439
  semanticItems.push(hit);
@@ -1434,6 +1478,20 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1434
1478
  if (config?.strictScope === true && scope) {
1435
1479
  candidates = candidates.filter((m) => isVisibleInScope(m, scope));
1436
1480
  }
1481
+ // #249 第一批:B1 pin 池。取在相关性排序之后、轮换之前——取谁按此刻的候选
1482
+ // 次序(即相关性次序),取到后从候选中摘除,于是下面的轮换重排碰不到它们
1483
+ // (验收:pin 不参与跨轮轮换)。独立预算的两层意义:pin 既不占 maxItems
1484
+ // 名额、也不被 document 预算截断,因此不会把当前任务需要的情景候选挤出去;
1485
+ // 超预算的条数回报给调用方,在块内如实标注(绝不静默省略)。
1486
+ const pinnedBudget = Math.max(0, Math.min(5, Math.floor(config?.pinnedInjectBudget ?? 0)));
1487
+ // eligible 留到块外:未展示条数要等 general 槽选完才算得准(见 selected 之后)。
1488
+ const eligible = pinnedBudget > 0 ? candidates.filter((m) => PINNED_MEMORY_TYPES.has(m.type)) : [];
1489
+ let pinned = [];
1490
+ if (pinnedBudget > 0 && eligible.length > 0) {
1491
+ pinned = eligible.slice(0, pinnedBudget);
1492
+ const pinnedIds = new Set(pinned.map((m) => m.id));
1493
+ candidates = candidates.filter((m) => !pinnedIds.has(m.id));
1494
+ }
1437
1495
  // Issue #205:注入位跨轮轮换。rotate = 最近 N 轮注入过的 id 集合(由注入层
1438
1496
  // 按会话维护并传入):这些条目本轮不再优先——新鲜者前置(各自内部相对次序
1439
1497
  // 保持),不足时按原序回填,槽位数与 touchRecall 语义均不变。rotate 为空时
@@ -1442,8 +1500,58 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1442
1500
  const fresh = candidates.filter((m) => !rotate.has(m.id));
1443
1501
  if (fresh.length > 0) candidates = [...fresh, ...candidates.filter((m) => rotate.has(m.id))];
1444
1502
  }
1445
- const selected = candidates.slice(0, maxItems);
1503
+ // #230 拍板:document 摘要行预算——次优先档内最多 documentInjectBudget
1504
+ // 条,超预算的 document 行跳过、由后续候选补位(不占槽)。预算只约束
1505
+ // 注入不约束检索:指针行价值在「按需读全文」,常驻注入若不设界就会把
1506
+ // 注入块变成文档目录(#164 失败判据:批量把历史塞进上下文)。
1507
+ const documentBudget = config?.documentInjectBudget ?? 2;
1508
+ let documentSeen = 0;
1509
+ const general = [];
1510
+ for (const m of candidates) {
1511
+ if (general.length >= maxItems) break;
1512
+ if (m.type === "document") {
1513
+ if (documentSeen >= documentBudget) continue;
1514
+ documentSeen++;
1515
+ }
1516
+ general.push(m);
1517
+ }
1518
+ // #249 第一批:pin 前置到块内相关性排序之前(验收项),且不占 maxItems 名额。
1519
+ // pinned 为空时 selected 就是 general 本身——关闭态与改动前逐字节一致。
1520
+ const selected = pinned.length > 0 ? [...pinned, ...general] : general;
1521
+ if (pinnedStats) {
1522
+ // 「未展示」只数**真的没进块**的 pin 类条目:被 pin 预算挤下来的条目会回到
1523
+ // 候选池,仍可能被 general 槽选中——那就是展示了。按 eligible - pinned 直接
1524
+ // 相减会把它们也算成未展示,块头那一行于是虚报(评审实测:pref#2 已在块内,
1525
+ // 仍报「另有 2 条未展示」)。所以统一按「有没有进 selected」判。
1526
+ const shownIds = new Set(selected.map((m) => m.id));
1527
+ pinnedStats.shown = pinned.length;
1528
+ pinnedStats.suppressed = eligible.filter((m) => !shownIds.has(m.id)).length;
1529
+ }
1446
1530
  touchRecalled(selected);
1531
+ // #217 口径(2026-09-19 拍板):注入是曝光型访问事件,与检索命中同表分账
1532
+ // (mode='inject',candidates 存实际注入集)。跟随 recallRecordDefault——
1533
+ // 与检索侧同门,不设新配置键;heat 关闭时照写,留痕与消费解耦(两 issue
1534
+ // 独立验收)。空选不记(没有访问发生);记账失败不影响注入本身。
1535
+ if ((config?.recallRecordDefault ?? true) && recallRecorder && selected.length > 0) {
1536
+ try {
1537
+ recallRecorder({
1538
+ // recall_runs.query 是 NOT NULL(store.js:61):注入是主动曝光、
1539
+ // 没有查询词,用空串而非 null——SQLite 改列约束需重建表,不值得。
1540
+ query: "",
1541
+ mode: "inject",
1542
+ topK: maxItems,
1543
+ threshold: null,
1544
+ candidates: selected.map((m) => ({
1545
+ id: m.id,
1546
+ title: m.title,
1547
+ content: m.content,
1548
+ score: m.score ?? null,
1549
+ source: m.source ?? "keyword"
1550
+ })),
1551
+ createdAt: new Date().toISOString()
1552
+ });
1553
+ } catch { /* recall receipt is best effort */ }
1554
+ }
1447
1555
  return selected;
1448
1556
  }
1449
1557
 
@@ -1604,7 +1712,10 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1604
1712
  ...(m.workspace_scope_source !== undefined ? { workspace_scope_source: m.workspace_scope_source } : {}),
1605
1713
  ...(m.scope_decided_at !== undefined ? { scope_decided_at: m.scope_decided_at } : {}),
1606
1714
  ...(m.sensitivity !== undefined ? { sensitivity: m.sensitivity } : {}),
1607
- ...(m.occurred_at !== undefined ? { occurred_at: m.occurred_at } : {})
1715
+ ...(m.occurred_at !== undefined ? { occurred_at: m.occurred_at } : {}),
1716
+ // #230:document 指针行的文件定位随行透出——「全文按需读」的入口就是
1717
+ // 这个路径;普通行恒不带键,DTO 与 #230 前逐字节同形。
1718
+ ...(m.doc_path !== undefined && m.doc_path !== null ? { doc_path: m.doc_path } : {})
1608
1719
  }));
1609
1720
  }
1610
1721
 
@@ -1889,6 +2000,12 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1889
2000
  // 处置)共用的写路径:mirror 同步、写通知、嵌入调度全部同源,不能各写一份。
1890
2001
  function updateMemory(id, p, ctx = {}) {
1891
2002
  const old = store.getById(id);
2003
+ // #230 写入权分离(与 saveWithDedupe 同款守卫):document 行只能经
2004
+ // registerDocument 铸造。既有 document 行的摘要修复(memory_update 改
2005
+ // content/title——设计定案的更新通道)照常放行,type 不许改入 document。
2006
+ if (p?.type === "document" && old?.type !== "document") {
2007
+ throw new Error("type 'document' is minted only via registerDocument (summary + doc_path + evidence)");
2008
+ }
1892
2009
  // Issue #135 附属发现 2:质量过滤器把「为什么被降权/归档」写在系统信号标签
1893
2010
  // 上(SIGNAL_TAGS,applyQualityDisposition 以并集写入),而这里整组替换
1894
2011
  // tags——任何带 tags 的更新都会抹掉这条唯一的审计线索(报告实测 150 条低分
@@ -1978,8 +2095,42 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1978
2095
  return updated;
1979
2096
  }
1980
2097
 
2098
+ // document 型记忆注册(#230):内聚块在 src/document.js(AGENTS.md 尺寸
2099
+ // 约定,同 recallStats 先例),这里只做依赖注入 + barrel 出口,调用方零改动。
2100
+ // 写后语只做重嵌入:镜像同步与写通知由 transaction 的 commit 路径统一执行
2101
+ // (notifyWrite 在 txDepth>0 时 deferred 到 finally),这里再调就是双份。
2102
+ const registerDocument = createDocumentRegistrar({
2103
+ store,
2104
+ config,
2105
+ embedQuery,
2106
+ pushContentHistory,
2107
+ transaction,
2108
+ finalize: (rows) => {
2109
+ for (const row of rows) scheduleEmbed(row);
2110
+ }
2111
+ });
2112
+
2113
+ // agent 主动整理接口(#231):dryRun 比对报告 → agent 判断 → apply 落库,筛除项
2114
+ // 进归档不删,全程复用 dream_runs 的 receipt 语义(不新建审计面)。走
2115
+ // saveWithDedupe 落库 = 复用常规写路径的镜像/通知语;重嵌入由 finalize 在事务
2116
+ // 提交后补(事务里的 scheduleEmbed 被 txDepth 挡掉)——document 同款先例。
2117
+ const { organize } = createOrganizer({
2118
+ store,
2119
+ embedQuery,
2120
+ saveWithDedupe,
2121
+ transaction,
2122
+ finalize: (rows) => {
2123
+ for (const row of rows) scheduleEmbed(row);
2124
+ }
2125
+ });
2126
+
1981
2127
  return {
1982
2128
  saveWithDedupe,
2129
+ registerDocument,
2130
+ // agent 主动整理接口(#231):内聚块在 src/organize.js,这里只做依赖注入 +
2131
+ // barrel 出口。刻意不加 opt-in 开关(维护者口径:功能本体不做开关,与 #249
2132
+ // 同批暴露时再定配置面),也不进工具列表——工具注册在 #249 那批。
2133
+ organize,
1983
2134
  recoverMirror,
1984
2135
  getMirrorHealth,
1985
2136
  getMirrorState: () => store.getMirrorState(),
@@ -1988,6 +2139,8 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1988
2139
  toApiList,
1989
2140
  isVisibleInScope,
1990
2141
  transaction,
2142
+ getDistillCursor: (sessionId) => store.getDistillCursor(sessionId),
2143
+ setDistillCursor: (sessionId, lastSeq) => store.setDistillCursor(sessionId, lastSeq),
1991
2144
  enqueue,
1992
2145
  setDreamHook(fn) { dreamHook = fn; },
1993
2146
  setSleepHook(fn) { sleepHook = fn; },
package/lib/settings.js CHANGED
@@ -43,6 +43,10 @@ const FEATURE_FLAG_BOOLEANS = [
43
43
  "autoInject",
44
44
  "autoSummarize",
45
45
  "hotMemoryEnabled",
46
+ // Issue #239 第 5 项:注入条数的查询自适应(确定性强则收缩注入条数,默认关)。
47
+ "injectUncertaintyAdaptive",
48
+ // Issue #249 第一批:能力说明(工具描述判断指引 + order 150 总则段,默认关)。
49
+ "injectGuidanceEnabled",
46
50
  "entityExtractionEnabled",
47
51
  // Issue #219:图召回轴——查询命中实体名时把挂联记忆并入检索融合池
48
52
  // (默认关;依赖实体抽取产出,lightMode 强制关闭)。
@@ -50,6 +54,9 @@ const FEATURE_FLAG_BOOLEANS = [
50
54
  // Issue #164:叙述条——dream 期间按 tag 主题簇合成叙述落库(source=
51
55
  // narrative,evidence 回链簇内记忆;按需检索不常驻注入;默认关)。
52
56
  "dreamNarrativeEnabled",
57
+ // Issue #230:document 型记忆——agent 产长文档的指针行(注册校验/摘要+
58
+ // doc_path 落库/C2 去重/supersede 记账;全文归 agent;默认关,lightMode 强制关)。
59
+ "documentMemoryEnabled",
53
60
  "codingRetrospect",
54
61
  "autoDream",
55
62
  "sleepModeEnabled",
@@ -78,6 +85,10 @@ const FEATURE_FLAG_BOOLEANS = [
78
85
  // Issue #17(v0.8.0 A3):strictScope 硬过滤——他 scope 完全不可见(关闭时
79
86
  // 为 A2 软隔离:降权保留可见)。
80
87
  "strictScope",
88
+ // 工具暴露开关(v0.8.5):记忆已每轮自动注入,memory_search/memory_archive
89
+ // 在慢/轻量模型上是多余往返,面板可关(默认关=行为不变)。
90
+ "disableMemorySearch",
91
+ "disableMemoryArchive",
81
92
  // 嵌套对象开关:config.js 里是 memoryQualityFilter / llmAudit 对象的 enabled
82
93
  // 子字段。kv 按点号键平铺存("memoryQualityFilter.enabled": false),index.js
83
94
  // 合并时展开回嵌套对象,api.js 的 effective 从对象子字段取值。
@@ -97,12 +108,25 @@ const FEATURE_FLAG_INT_RANGES = {
97
108
  summarizeMinIntervalMinutes: [0, 10080],
98
109
  summarizeMaxEntriesPerRun: [0, 50],
99
110
  summarizeDedupeWindowHours: [0, 168],
111
+ // Issue #239:蒸馏零 LLM 预判(窗口最小字符数)与每会话 run 预算(0 = 零行为变化)。
112
+ summarizeMinWindowChars: [0, 100000],
113
+ summarizeMaxRunsPerSession: [0, 1000],
114
+ // Issue #239 第 4 项:高峰顺延上限(分钟,0 = 不设上限)。
115
+ summarizePeakMaxDeferMinutes: [0, 1440],
100
116
  // Issue #125:hybrid 候选量上限(0 = 复用 dreamMaxSnapshotSize)。
101
117
  dreamCandidateMax: [0, 5000],
102
118
  // Issue #164①:注入单条正文截断上限(默认 300 = 既有行为)。
103
119
  injectContentMaxChars: [60, 4000],
104
120
  // Issue #164:叙述条成簇门槛(共享同一 tag 的记忆数下限)。
105
- dreamNarrativeMinCluster: [2, 20]
121
+ dreamNarrativeMinCluster: [2, 20],
122
+ // Issue #230:document 摘要行的注入预算(次优先档内最多几条指针行)。
123
+ documentInjectBudget: [1, 5],
124
+ // Issue #249 第一批:B1 pin 池(约束/偏好)的独立条数预算(0 = 关闭/现状)。
125
+ pinnedInjectBudget: [0, 5],
126
+ // Issue #257:sleep 冲突/模式阶段的 LLM 输出预算(原硬编码 2048,实测不足)。
127
+ sleepMaxTokens: [256, 131072],
128
+ // Issue #258:总览(dream_summarize)输入条数硬上限(0 = 不设上限)。
129
+ dreamSummaryMaxInputs: [0, 100000]
106
130
  };
107
131
  // 浮点开关的闭区间(与 config.js 的 z.number().min().max() 对齐)。与整数开关
108
132
  // 分开:面板的整数控件要求 Number.isInteger,而余弦相似度阈值必须允许小数。
@@ -125,8 +149,13 @@ const FEATURE_FLAG_STRINGS = [
125
149
  // 留空 = 用当前默认模型。
126
150
  "entityExtractionProvider",
127
151
  "entityExtractionModel",
152
+ // Issue #258:总览(dream_summarize)专用路由,留空 = 用巩固模型。
153
+ "dreamSummaryProvider",
154
+ "dreamSummaryModel",
128
155
  "localEmbedModel",
129
- "ollamaModel"
156
+ "ollamaModel",
157
+ // Issue #239 第 4 项:高峰时段串("09:00-18:00",空串 = 关闭)。
158
+ "summarizePeakHours"
130
159
  ];
131
160
  // URL 字符串开关:trim 后必须为空或合法 http/https URL(new URL() 校验协议,
132
161
  // 拒绝其余协议——这是 SSRF 防线的一部分)。
package/lib/store.js CHANGED
@@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS memories (
18
18
  last_accessed_at TEXT,
19
19
  _full_content TEXT,
20
20
  evidence TEXT,
21
+ doc_path TEXT,
21
22
  created_at TEXT NOT NULL,
22
23
  updated_at TEXT NOT NULL
23
24
  );
@@ -47,7 +48,7 @@ CREATE TABLE IF NOT EXISTS dream_runs (
47
48
  summary_stored INTEGER NOT NULL DEFAULT 0,
48
49
  receipt TEXT NOT NULL,
49
50
  policy_epoch INTEGER NOT NULL DEFAULT 0, -- 裁决规则版本:规则升级后旧裁决降级为历史证据
50
- run_type TEXT NOT NULL DEFAULT 'auto', -- auto | sleep:睡眠周期的审计区分
51
+ run_type TEXT NOT NULL DEFAULT 'auto', -- auto | sleep | organize:周期审计的类别区分
51
52
  skipped TEXT -- JSON: degraded 轮被跳过的逐条明细(index/action/ids/error)
52
53
  );
53
54
  CREATE INDEX IF NOT EXISTS idx_dream_runs_created ON dream_runs(created_at);
@@ -196,6 +197,14 @@ CREATE TABLE IF NOT EXISTS llm_audit_logs (
196
197
  CREATE INDEX IF NOT EXISTS idx_llm_audit_timestamp ON llm_audit_logs(timestamp);
197
198
  CREATE INDEX IF NOT EXISTS idx_llm_audit_source ON llm_audit_logs(trigger_source);
198
199
 
200
+ -- autoSummarize 的增量蒸馏游标:按 session.id 持久化最近一次成功消费的事件序。
201
+ -- 游标是蒸馏窗口的恢复事实,不与 user_settings 或记忆内容混用。
202
+ CREATE TABLE IF NOT EXISTS distill_cursors (
203
+ session_id TEXT PRIMARY KEY,
204
+ last_seq INTEGER NOT NULL,
205
+ updated_at TEXT NOT NULL
206
+ );
207
+
199
208
  -- entity gene (v0.3.0): named entities mentioned across memories, with
200
209
  -- time-boxed attributes (valid_from → valid_until) and typed relations.
201
210
  -- Attributes follow the snapshot style: saveAttr invalidates the previous
@@ -268,7 +277,10 @@ CREATE TABLE IF NOT EXISTS mirror_state (
268
277
  // Exported for API-layer type validation (standalone API POST /memories and
269
278
  // the /status byType breakdown); the set itself stays the single source of
270
279
  // truth for what store.save accepts.
271
- export const TYPES = new Set(["preference", "project", "decision", "history", "summary", "pattern", "rejected_solution", "pitfall", "constraint"]);
280
+ export const TYPES = new Set(["preference", "project", "decision", "history", "summary", "pattern", "rejected_solution", "pitfall", "constraint",
281
+ // #230:agent 产长文档的指针行(摘要 + doc_path + evidence)。铸造口唯一
282
+ // (registerDocument)——saveWithDedupe/updateMemory 另有守卫拒绝旁路铸造。
283
+ "document"]);
272
284
 
273
285
  // Epistemic status: what kind of evidence a memory rests on. Defaults to
274
286
  // 'subjective' so legacy rows (and rows without any signal) stay compatible.
@@ -400,6 +412,7 @@ function toRow(row) {
400
412
  source: row.source ?? undefined,
401
413
  content_history: parseJsonArray(row.content_history),
402
414
  evidence: parseJsonArray(row.evidence),
415
+ doc_path: row.doc_path ?? undefined,
403
416
  quality_score: row.quality_score !== null && row.quality_score !== undefined ? Number(row.quality_score) : undefined,
404
417
  epistemic_status: row.epistemic_status ?? "subjective",
405
418
  agent_scope: row.agent_scope ?? undefined,
@@ -674,6 +687,8 @@ export function createStore(path) {
674
687
  // 叙述条证据链(#164 对齐):[{memory_id, op, at}] JSON 数组——叙述/模式类
675
688
  // 记忆回链其支撑原子记忆,写入前与候选集求交防模型捏造。
676
689
  addColumn("memories", "evidence", "ALTER TABLE memories ADD COLUMN evidence TEXT");
690
+ // #230:document 指针行的文件定位。只有 registerDocument 写它,普通行恒 NULL。
691
+ addColumn("memories", "doc_path", "ALTER TABLE memories ADD COLUMN doc_path TEXT");
677
692
  addColumn("memories", "epistemic_status", "ALTER TABLE memories ADD COLUMN epistemic_status TEXT NOT NULL DEFAULT 'subjective'");
678
693
  addColumn("memories", "content_history", "ALTER TABLE memories ADD COLUMN content_history TEXT");
679
694
  addColumn("memories", "quality_score", "ALTER TABLE memories ADD COLUMN quality_score REAL");
@@ -782,6 +797,37 @@ export function createStore(path) {
782
797
  return ts;
783
798
  }
784
799
 
800
+ /** 返回指定会话持久化的 autoSummarize 游标;不存在时返回 undefined。 */
801
+ function getDistillCursor(sessionId) {
802
+ if (typeof sessionId !== "string" || sessionId.length === 0) return undefined;
803
+ const row = db.prepare(
804
+ "SELECT session_id, last_seq, updated_at FROM distill_cursors WHERE session_id = ?"
805
+ ).get(sessionId);
806
+ return row
807
+ ? { session_id: row.session_id, last_seq: Number(row.last_seq), updated_at: row.updated_at }
808
+ : undefined;
809
+ }
810
+
811
+ /**
812
+ * 单调持久化会话游标。调用方若同时写入记忆,必须放在外层 SQLite 事务内。
813
+ */
814
+ function setDistillCursor(sessionId, lastSeq) {
815
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
816
+ throw new TypeError("setDistillCursor: sessionId must be a non-empty string");
817
+ }
818
+ if (!Number.isSafeInteger(lastSeq)) {
819
+ throw new TypeError("setDistillCursor: lastSeq must be a safe integer");
820
+ }
821
+ db.prepare(`
822
+ INSERT INTO distill_cursors (session_id, last_seq, updated_at)
823
+ VALUES (?, ?, ?)
824
+ ON CONFLICT(session_id) DO UPDATE SET
825
+ last_seq = MAX(distill_cursors.last_seq, excluded.last_seq),
826
+ updated_at = excluded.updated_at
827
+ `).run(sessionId, lastSeq, nowIso());
828
+ return getDistillCursor(sessionId);
829
+ }
830
+
785
831
  function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, updatedFrom = null, updatedTo = null, occurredFrom = null, occurredTo = null, visibility = null } = {}) {
786
832
  const clauses = [];
787
833
  const params = [];
@@ -863,7 +909,28 @@ export function createStore(path) {
863
909
  return toRow(row);
864
910
  }
865
911
 
912
+ // #230 写入权分离(存储层唯一铸造口,CodeRabbit 复核最终形态):document
913
+ // 行只能经 saveDocument 铸造,通用 save 整类拒绝。doc_path 是任意调用方
914
+ // 都能捏造的字符串——「必带 doc_path」挡不住绕过注册校验(flag 闸 / 文件
915
+ // 存在 / 路径归一化 / evidence 可见性 / scope 匹配 / supersede 探测)的直
916
+ // 铸,必须整类拒绝;受控通道是独立方法而不是隐藏旗标(旗标可被载荷携带,
917
+ // 方法名在 DI 合同里可审计)。
866
918
  function save(memory) {
919
+ if (memory?.type === "document") {
920
+ throw new Error("document rows are minted only via store.saveDocument (registerDocument)");
921
+ }
922
+ return insertMemoryRow(memory);
923
+ }
924
+
925
+ function saveDocument(memory) {
926
+ // 指针行结构不变量:doc_path 是 document 的存在依据(无指针 = 死行)。
927
+ if (!(typeof memory?.doc_path === "string" && memory.doc_path.trim())) {
928
+ throw new Error("document rows are pointer rows: doc_path is required");
929
+ }
930
+ return insertMemoryRow(memory);
931
+ }
932
+
933
+ function insertMemoryRow(memory) {
867
934
  const id = memory.id ?? randomUUID();
868
935
  const type = memory.type;
869
936
  if (!TYPES.has(type)) throw new Error(`invalid memory type: ${type}`);
@@ -874,6 +941,7 @@ export function createStore(path) {
874
941
  const tags = JSON.stringify(memory.tags ?? []);
875
942
  const importance = Number.isInteger(memory.importance) ? memory.importance : 3;
876
943
  const evidence = Array.isArray(memory.evidence) ? JSON.stringify(memory.evidence) : null;
944
+ const docPath = typeof memory.doc_path === "string" && memory.doc_path.trim() ? memory.doc_path : null;
877
945
  const embedding = Array.isArray(memory.embedding) && memory.embedding.length
878
946
  ? JSON.stringify(memory.embedding)
879
947
  : null;
@@ -884,8 +952,8 @@ export function createStore(path) {
884
952
  : inferEpistemicStatus(memory);
885
953
  runAtomically(() => {
886
954
  db.prepare(
887
- `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, 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, created_at, updated_at)
888
- VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
955
+ `INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, 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, created_at, updated_at)
956
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
889
957
  ).run(
890
958
  id,
891
959
  type,
@@ -907,6 +975,7 @@ export function createStore(path) {
907
975
  normalizeScopeText(memory.sensitivity),
908
976
  normalizeOccurredAt(memory.occurred_at),
909
977
  evidence,
978
+ docPath,
910
979
  now,
911
980
  now
912
981
  );
@@ -924,6 +993,12 @@ export function createStore(path) {
924
993
  if (!existing) throw new Error(`memory not found: ${id}`);
925
994
  const type = patch.type ?? existing.type;
926
995
  if (!TYPES.has(type)) throw new Error(`invalid memory type: ${type}`);
996
+ // #230:type 不许改入/改出 document——铸造与退役都只经 registerDocument
997
+ // (摘要修复走同 type 的 content/title 更新,doc_path 不在本 UPDATE 的
998
+ // SET 里,天然保持不变)。
999
+ if ((existing.type === "document") !== (type === "document")) {
1000
+ throw new Error("memory type cannot be changed to or from 'document' (registerDocument is the only mint path)");
1001
+ }
927
1002
  if (patch.tags !== undefined && !Array.isArray(patch.tags)) {
928
1003
  throw new Error("tags must be an array");
929
1004
  }
@@ -1015,6 +1090,11 @@ export function createStore(path) {
1015
1090
  if (!existing) throw new Error(`memory not found: ${id}`);
1016
1091
  const type = patch.type ?? existing.type;
1017
1092
  if (!TYPES.has(type)) throw new Error(`invalid memory type: ${type}`);
1093
+ // 同 update:document 类型转换在这里同样封死(CAS 路径绕过 updateMemory
1094
+ // 的 service 守卫,必须在存储层兜住)。
1095
+ if ((existing.type === "document") !== (type === "document")) {
1096
+ throw new Error("memory type cannot be changed to or from 'document' (registerDocument is the only mint path)");
1097
+ }
1018
1098
  if (patch.tags !== undefined && !Array.isArray(patch.tags)) {
1019
1099
  throw new Error("tags must be an array");
1020
1100
  }
@@ -1135,11 +1215,15 @@ export function createStore(path) {
1135
1215
  // Live memories that have not been touched since `cutMs` (never-touched ones
1136
1216
  // fall back to created_at). Ordered by last access ascending — the coldest
1137
1217
  // first. Used by sleep phase 2 to pick archival-demotion candidates.
1218
+ // #230: document pointer rows are excluded — they are heat-immune by design
1219
+ // (λ=0) and their "unread" state is normal (full text lives outside the DB),
1220
+ // so the cold scan must never demote/archive them for lacking access.
1138
1221
  function getUnrecalledSince(cutMs, { limit = 500 } = {}) {
1139
1222
  const cutIso = new Date(cutMs).toISOString();
1140
1223
  const rows = db.prepare(
1141
1224
  `SELECT * FROM memories
1142
1225
  WHERE forgotten = 0 AND archived = 0
1226
+ AND type <> 'document'
1143
1227
  AND (last_accessed_at IS NULL OR last_accessed_at < ?)
1144
1228
  ORDER BY COALESCE(last_accessed_at, created_at) ASC, id
1145
1229
  LIMIT ?`
@@ -1147,13 +1231,20 @@ export function createStore(path) {
1147
1231
  return rows.map(toRow);
1148
1232
  }
1149
1233
 
1150
- function list({ type, 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 } = {}) {
1234
+ 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 } = {}) {
1151
1235
  const clauses = [];
1152
1236
  const params = [];
1153
1237
  if (type) {
1154
1238
  clauses.push("type = ?");
1155
1239
  params.push(type);
1156
1240
  }
1241
+ // #230:整类排除必须在 LIMIT 之前做——sleep 的模式扫描池(limit 200 +
1242
+ // sleepPatternMinMemories 门槛)若先截断后过滤,document 行一多就会把
1243
+ // 普通记忆挤出窗口,池子被饿空。
1244
+ if (Array.isArray(excludeTypes) && excludeTypes.length) {
1245
+ clauses.push(`type NOT IN (${excludeTypes.map(() => "?").join(", ")})`);
1246
+ params.push(...excludeTypes);
1247
+ }
1157
1248
  // Optional server-side filters: importance floor and exact source match.
1158
1249
  // Both stay out of the query when unset so existing callers are unaffected.
1159
1250
  if (minImportance != null) {
@@ -1227,15 +1318,20 @@ export function createStore(path) {
1227
1318
  "(id IN (SELECT record_id FROM receipt_chain WHERE kind IN ('merge', 'update') AND verdict = 'live') OR source = 'dream')"
1228
1319
  );
1229
1320
  }
1321
+ // limit == null = 无界(#230 注册器的精确 supersede 全量扫描用——同路径/
1322
+ // 同标题判定不允许窗口截断)。其余调用传数字,走 sanitizePage 默认档。
1323
+ const unbounded = limit == null;
1230
1324
  const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
1231
1325
  const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1232
1326
  // "chrono" is pure newest-first — the stable order paged browsing (month
1233
1327
  // tree, infinite scroll) needs; importance ordering would interleave
1234
1328
  // months across pages.
1235
1329
  const orderBy = order === "chrono" ? "updated_at DESC, id DESC" : "importance DESC, updated_at DESC, id";
1236
- const rows = db.prepare(
1237
- `SELECT * FROM memories ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`
1238
- ).all(...params, lim, off);
1330
+ const rows = unbounded
1331
+ ? db.prepare(`SELECT * FROM memories ${where} ORDER BY ${orderBy}`).all(...params)
1332
+ : db.prepare(
1333
+ `SELECT * FROM memories ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`
1334
+ ).all(...params, lim, off);
1239
1335
  return rows.map(toRow);
1240
1336
  }
1241
1337
 
@@ -2368,6 +2464,9 @@ export function createStore(path) {
2368
2464
  count,
2369
2465
  getById,
2370
2466
  save,
2467
+ // document 唯一铸造口(#230 写入权分离):registerDocument 专用,通用
2468
+ // save/update/CAS 均拒绝 document 创建或类型转换。
2469
+ saveDocument,
2371
2470
  update,
2372
2471
  compareAndUpdate,
2373
2472
  remove,
@@ -2401,6 +2500,8 @@ export function createStore(path) {
2401
2500
  getRecallEval,
2402
2501
  listRecallEvals,
2403
2502
  saveLlmAudit,
2503
+ getDistillCursor,
2504
+ setDistillCursor,
2404
2505
  listLlmAudits,
2405
2506
  countLlmAudits,
2406
2507
  getLlmAuditStats,