@modusensus/dsh-mneme 0.8.15 → 0.9.0

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 (100) hide show
  1. package/README.md +21 -3
  2. package/assets/icon.svg +2 -2
  3. package/bin/cli.mjs +4 -2
  4. package/docs/CONFIGURATION.md +16 -3
  5. package/docs/CONTINUITY.md +18 -3
  6. package/docs/DISTILL.md +162 -0
  7. package/docs/STORAGE.md +11 -2
  8. package/docs/WORKFLOWS.md +299 -0
  9. package/lib/api-standalone.js +22 -39
  10. package/lib/api.js +294 -10
  11. package/lib/candidates.js +454 -0
  12. package/lib/capabilities.js +819 -0
  13. package/lib/client.js +2767 -337
  14. package/lib/config.js +99 -6
  15. package/lib/continuity.js +35 -6
  16. package/lib/dream/decisions.js +37 -1
  17. package/lib/dream/sleep.js +10 -2
  18. package/lib/dream.js +7 -1
  19. package/lib/entities/extractor.js +4 -0
  20. package/lib/hot-memory.js +62 -0
  21. package/lib/index.js +72 -18
  22. package/lib/inject.js +33 -64
  23. package/lib/maintenance.js +25 -5
  24. package/lib/pipeline.js +534 -0
  25. package/lib/recall-stats.js +22 -7
  26. package/lib/reclaim-http.js +150 -0
  27. package/lib/safe-slice.js +18 -0
  28. package/lib/scope.js +16 -4
  29. package/lib/serve.js +1 -1
  30. package/lib/service.js +83 -17
  31. package/lib/session-usage.js +350 -0
  32. package/lib/settings.js +19 -2
  33. package/lib/store.js +251 -10
  34. package/lib/summarize.js +239 -60
  35. package/lib/tools.js +11 -2
  36. package/package.json +1 -1
  37. package/scripts/publish-gate.mjs +197 -0
  38. package/src/api-standalone.js +22 -39
  39. package/src/api.js +294 -10
  40. package/src/candidates.js +454 -0
  41. package/src/capabilities.js +819 -0
  42. package/src/config.js +99 -6
  43. package/src/continuity.js +35 -6
  44. package/src/dream/decisions.js +37 -1
  45. package/src/dream/sleep.js +10 -2
  46. package/src/dream.js +7 -1
  47. package/src/entities/extractor.js +4 -0
  48. package/src/hot-memory.js +62 -0
  49. package/src/index.js +72 -18
  50. package/src/inject.js +33 -64
  51. package/src/maintenance.js +25 -5
  52. package/src/pipeline.js +534 -0
  53. package/src/recall-stats.js +22 -7
  54. package/src/reclaim-http.js +150 -0
  55. package/src/safe-slice.js +18 -0
  56. package/src/scope.js +16 -4
  57. package/src/serve.js +1 -1
  58. package/src/service.js +83 -17
  59. package/src/session-usage.js +350 -0
  60. package/src/settings.js +19 -2
  61. package/src/store.js +251 -10
  62. package/src/summarize.js +239 -60
  63. package/src/tools.js +11 -2
  64. package/test/api-routes-gaps.test.js +681 -0
  65. package/test/api.test.js +171 -8
  66. package/test/candidates.test.js +345 -0
  67. package/test/capabilities.test.js +577 -0
  68. package/test/client.test.js +715 -4
  69. package/test/continuity.test.js +84 -0
  70. package/test/contrast.test.js +314 -0
  71. package/test/dream-merge-guardrail.test.js +219 -0
  72. package/test/embed-ready-poll.test.js +94 -0
  73. package/test/entities.test.js +13 -2
  74. package/test/failure-query.test.js +147 -0
  75. package/test/failure-retention.test.js +105 -0
  76. package/test/graph-api.test.js +36 -2
  77. package/test/hot-memory.test.js +67 -1
  78. package/test/inject-recall.test.js +68 -1
  79. package/test/inject.test.js +23 -0
  80. package/test/llm-audit.test.js +190 -1
  81. package/test/maintenance.test.js +112 -2
  82. package/test/mint-source.test.js +202 -0
  83. package/test/pipeline.test.js +505 -0
  84. package/test/placement.test.js +1 -1
  85. package/test/pre-inject-gate.test.js +10 -0
  86. package/test/publish-gate.test.js +272 -0
  87. package/test/recall-retention.test.js +128 -0
  88. package/test/recall-stats.test.js +89 -4
  89. package/test/reclaim-http.test.js +209 -0
  90. package/test/release-workflow.test.js +77 -0
  91. package/test/scope-distill-stamp.test.js +135 -0
  92. package/test/serve.test.js +1 -1
  93. package/test/service.test.js +7 -2
  94. package/test/session-usage.test.js +308 -0
  95. package/test/settings.test.js +8 -0
  96. package/test/store-write-path-columns.test.js +191 -0
  97. package/test/store.test.js +108 -0
  98. package/test/summarize.test.js +350 -0
  99. package/test/supersede-state-tracking.test.js +4 -4
  100. package/test/tools.test.js +16 -1
@@ -3,6 +3,9 @@ import assert from "node:assert/strict";
3
3
  import { mkdtempSync, rmSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
+ import { DatabaseSync } from "node:sqlite";
7
+ import { Context } from "@deepseek-ai/cordis";
8
+ import * as mneme from "../src/index.js";
6
9
  import { createStore } from "../src/store.js";
7
10
  import { createService } from "../src/service.js";
8
11
  import { createSummarizer, parseSummaryJson, parsePeakSpec, isInPeakWindow, nextOffPeakAt } from "../src/summarize.js";
@@ -274,6 +277,43 @@ test("#239 窗口过短:零 LLM 预判拦下调用,留 skip 审计并消费
274
277
  assert.equal(service.listLlmAudits().length, 1, "游标已消费:同一窗口不重复评估、不重复审计");
275
278
  });
276
279
 
280
+ // #239 定标埋点:窗口字符数必须落进审计,否则下一级预判规则的阈值无从选起。
281
+ // 第二条才是本改动的目的——预判开关关着(默认档位)时行为逐字节不变,但从这一刻
282
+ // 起成功行开始留数,验收 4「默认配置下调用量可量化下降」才有账可对。
283
+ test("#239 计量:window-too-small 的 skip 行带 distillChars 与阈值", async () => {
284
+ const { events, service } = setup({ summarizeMinWindowChars: 1000 });
285
+ const handler = events.find((e) => e.name === "session/event").fn;
286
+ const session = {
287
+ id: "s239-metric-skip",
288
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
289
+ events: [userMessage("好", 1), { seq: 2, type: "turn/end" }]
290
+ };
291
+ await handler(session, { seq: 2, type: "turn/end" });
292
+ const row = service.listLlmAudits()[0];
293
+ assert.equal(row.error_message, "window-too-small");
294
+ // 计的是**真正发给模型的转录文本**长度(含角色前缀:一条 1 字的「好」实测记 4),
295
+ // 与闸门比较的口径同一个数,定标才对得上。这里不锁字面量,锁语义:短于阈值才该 skip。
296
+ assert.ok(Number.isInteger(row.metadata?.distillChars) && row.metadata.distillChars > 0,
297
+ "skip 行必须带窗口字符数");
298
+ assert.equal(row.metadata.minWindowChars, 1000, "阈值随行落档,事后能复核是哪条闸门拦的");
299
+ assert.ok(row.metadata.distillChars < row.metadata.minWindowChars, "skip 的前提:窗口短于阈值");
300
+ });
301
+
302
+ test("#239 计量:预判开关关闭时成功行仍记录 distillChars", async () => {
303
+ const { events, service } = setup();
304
+ const handler = events.find((e) => e.name === "session/event").fn;
305
+ const session = {
306
+ id: "s239-metric-success",
307
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
308
+ events: [userMessage("今天决定把窗口字符数记进审计", 1), { seq: 2, type: "turn/end" }]
309
+ };
310
+ await handler(session, { seq: 2, type: "turn/end" });
311
+ const row = service.listLlmAudits()[0];
312
+ assert.equal(row.status, "success");
313
+ assert.ok(Number.isInteger(row.metadata?.distillChars) && row.metadata.distillChars > 0,
314
+ "默认档位下也要留数,否则验收 4 无从对账");
315
+ });
316
+
277
317
  test("#239 窗口达到阈值时预判放行(不误杀)", async () => {
278
318
  const { events, calls } = setup({ summarizeMinWindowChars: 5 });
279
319
  const handler = events.find((e) => e.name === "session/event").fn;
@@ -1463,3 +1503,313 @@ test("parseSummaryJson salvage does not turn string-embedded objects into memori
1463
1503
  assert.equal(parsed[0].title, "good");
1464
1504
  assert.match(parsed[0].content, /fake/);
1465
1505
  });
1506
+
1507
+ // ── Issue #399:蒸馏窗口原始记录 + 记忆级溯源 ─────────────────────────────
1508
+ //
1509
+ // 这五条锁的是「把蒸馏从替换降级为叠加索引」的四个承重点:重跑幂等、隐私谓词
1510
+ // 在落盘路径同样成立、留存不受喂给模型的裁剪约束、裁剪不毁资产、默认关零写入。
1511
+ // 锁形状而非锁实现:断言直接查表,不去读 summarize.js 的中间变量。
1512
+
1513
+ /** Issue #399 的开启档 + 关掉真实节流(否则同会话第二轮会被间隔门挡下)。 */
1514
+ const RECORDS_ON = { distillRecords: { enabled: true }, distillRateLimitIntervalMs: 0 };
1515
+
1516
+ function countRows(store, sql) {
1517
+ return store.db.prepare(sql).get().c;
1518
+ }
1519
+
1520
+ function recordsSession(id, events) {
1521
+ return {
1522
+ id,
1523
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1524
+ events
1525
+ };
1526
+ }
1527
+
1528
+ function eventHandler(events) {
1529
+ return events.find((e) => e.name === "session/event").fn;
1530
+ }
1531
+
1532
+ test("issue#399: re-distilling the same window never duplicates records", async () => {
1533
+ const first = setup(RECORDS_ON);
1534
+ const session = recordsSession("s399-idem", [userMessage("同一窗口的提问", 1), { seq: 2, type: "turn/end" }]);
1535
+ await eventHandler(first.events)(session, { seq: 2, type: "turn/end" });
1536
+ assert.equal(countRows(first.store, "SELECT count(*) AS c FROM distill_records"), 1);
1537
+
1538
+ // 模拟宿主重启后重跑同一 seq 段(代码注释里点名的路径:max-runs 耗尽额度、游标
1539
+ // 写入随事务回滚后丢失):删掉持久化游标 + 换一个全新的 summarizer 实例,于是
1540
+ // lastDistilledSeq 这份内存游标也一并归零,同一窗口会被完整重蒸一遍。
1541
+ first.store.db.prepare("DELETE FROM distill_cursors").run();
1542
+ const second = setup(RECORDS_ON, { store: first.store });
1543
+ await eventHandler(second.events)(session, { seq: 2, type: "turn/end" });
1544
+
1545
+ assert.equal(
1546
+ countRows(first.store, "SELECT count(*) AS c FROM distill_records"),
1547
+ 1,
1548
+ "同一 (session_id, seq) 只留一行:重跑必须幂等,保留首次落盘时间才诚实"
1549
+ );
1550
+ assert.equal(
1551
+ countRows(
1552
+ first.store,
1553
+ `SELECT count(*) AS c FROM (
1554
+ SELECT memory_id, session_id, seq_start, seq_end FROM memory_provenance
1555
+ GROUP BY memory_id, session_id, seq_start, seq_end HAVING count(*) > 1)`
1556
+ ),
1557
+ 0,
1558
+ "同一窗口重跑不重复挂链接(唯一索引 + ON CONFLICT DO NOTHING)"
1559
+ );
1560
+ });
1561
+
1562
+ test("issue#399: records keep only public transcript; reasoning and machine-injected content never land on disk", async () => {
1563
+ const { events, store, calls } = setup(RECORDS_ON);
1564
+ const session = recordsSession("s399-priv", [
1565
+ userMessage("真实提问:这个报错怎么修", 1),
1566
+ // 机器注入的三类:AGENTS.md / skill 正文 / 文件变更通知。source.kind 不是
1567
+ // user / agent-message / subagent-settled,必须整个事件被挡在门外。
1568
+ { seq: 2, type: "user/message", data: { source: { kind: "plugin" }, content: [{ type: "text", text: "AGENTS.md 注入正文不该落盘" }] } },
1569
+ { seq: 3, type: "user/message", data: { source: { kind: "skill" }, content: [{ type: "text", text: "SKILL_BODY 不该落盘" }] } },
1570
+ { seq: 4, type: "user/message", data: { source: { kind: "file-change" }, content: [{ type: "text", text: "FILE_CHANGE 通知不该落盘" }] } },
1571
+ {
1572
+ seq: 5,
1573
+ type: "assistant/message",
1574
+ data: { message: { content: [
1575
+ { type: "reasoning", text: "PRIVATE_REASONING 不该落盘" },
1576
+ { type: "text", text: "公开回答:检查第 3 行。" }
1577
+ ] } }
1578
+ },
1579
+ toolResultEvent("TOOL_OUTPUT 只进渲染不落盘", false, 6),
1580
+ { seq: 7, type: "turn/end" }
1581
+ ]);
1582
+ await eventHandler(events)(session, { seq: 7, type: "turn/end" });
1583
+
1584
+ const texts = store.db.prepare("SELECT text FROM distill_records ORDER BY seq").all().map((r) => r.text).join("\n");
1585
+ assert.ok(texts.includes("真实提问"), "用户提示落盘");
1586
+ assert.ok(texts.includes("公开回答"), "助手公开正文落盘");
1587
+ assert.ok(!texts.includes("AGENTS.md"), "插件注入的 AGENTS.md 绝不落盘");
1588
+ assert.ok(!texts.includes("SKILL_BODY"), "skill 正文绝不落盘");
1589
+ assert.ok(!texts.includes("FILE_CHANGE"), "文件变更通知绝不落盘");
1590
+ assert.ok(!texts.includes("PRIVATE_REASONING"), "私有推理链绝不落盘");
1591
+ assert.ok(!texts.includes("TOOL_OUTPUT"), "工具结果不落盘(#399 已知边界 3)");
1592
+ // 渲染路径照旧带工具输出(codingRetrospect 靠它沉淀踩坑根因)——两条路径在
1593
+ // 「工具输出」上的差异是设计的一部分,不是漂移:漂移的方向必须是「落盘的更少」。
1594
+ assert.ok(JSON.stringify(calls[0].messages).includes("TOOL_OUTPUT"), "工具输出仍进 LLM 转录");
1595
+ });
1596
+
1597
+ test("issue#399: records keep the untruncated prompt while the LLM transcript stays capped", async () => {
1598
+ // 头部标记 + 超长正文:trimTranscript 保尾部,所以「渲染里找不到头部标记」正是
1599
+ // 被 distillMaxChars 截断的证据;记录里必须连头部标记都在。
1600
+ const long = `HEAD_MARKER ${"x".repeat(1200)} TAIL_MARKER`;
1601
+ const { events, store, calls } = setup({ ...RECORDS_ON, distillMaxChars: 200 });
1602
+ const session = recordsSession("s399-long", [userMessage(long, 1), { seq: 2, type: "turn/end" }]);
1603
+ await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1604
+
1605
+ const rows = store.db.prepare("SELECT text FROM distill_records ORDER BY seq").all();
1606
+ assert.equal(rows.length, 1);
1607
+ assert.equal(rows[0].text, long, "留存的是整段原文,不截断、不加省略号");
1608
+ assert.ok(!JSON.stringify(calls[0].messages).includes("HEAD_MARKER"), "喂 LLM 的转录仍受 distillMaxChars 约束");
1609
+ });
1610
+
1611
+ test("issue#399: expiring records keeps the memories and leaves provenance dangling-but-present", async () => {
1612
+ const { events, store } = setup(RECORDS_ON);
1613
+ const session = recordsSession("s399-expire", [userMessage("会被裁剪的原文", 1), { seq: 2, type: "turn/end" }]);
1614
+ await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1615
+ assert.equal(store.count(), 2);
1616
+ assert.equal(countRows(store, "SELECT count(*) AS c FROM memory_provenance"), 2);
1617
+
1618
+ // 到期裁剪:把 cutoff 推到未来,等价于「这条记录已经过期」。
1619
+ assert.equal(store.clearDistillRecords(new Date(Date.now() + 86400000).toISOString()), 1);
1620
+ assert.equal(countRows(store, "SELECT count(*) AS c FROM distill_records"), 0);
1621
+ assert.equal(store.count(), 2, "裁剪原文绝不级联删记忆");
1622
+ assert.equal(countRows(store, "SELECT count(*) AS c FROM memory_provenance"), 2, "链接保留,从此指向空区间");
1623
+ const memoryId = store.all()[0].id;
1624
+ assert.ok(store.provenanceFor(memoryId).length >= 1, "面板/CLI 仍能回答「这条记忆出自哪个窗口」,只是来源已过期");
1625
+ });
1626
+
1627
+ test("issue#399: enabled=false (the default) writes nothing at all", async () => {
1628
+ const { events, store, service } = setup({ distillRateLimitIntervalMs: 0 });
1629
+ let saverCalls = 0;
1630
+ const original = service.saveDistillRecords;
1631
+ service.saveDistillRecords = (...args) => {
1632
+ saverCalls++;
1633
+ return original(...args);
1634
+ };
1635
+ const session = recordsSession("s399-off", [userMessage("默认关不该留原文", 1), { seq: 2, type: "turn/end" }]);
1636
+ await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1637
+
1638
+ assert.equal(saverCalls, 0, "默认关:连收集都不做,一次写入调用都不发");
1639
+ assert.equal(countRows(store, "SELECT count(*) AS c FROM distill_records"), 0);
1640
+ assert.equal(countRows(store, "SELECT count(*) AS c FROM memory_provenance"), 0);
1641
+ assert.equal(store.count(), 2, "记忆写入照旧");
1642
+ });
1643
+ // ── 蒸馏留存:开关从「设置面」一路走到「盘上有行」 ──────────────────────────────
1644
+ //
1645
+ // 上面 #399 那五条锁的是 summarize.js 内部的行为,它们把 config 直接递给
1646
+ // createSummarizer。真实链路上,config 不来自测试也不来自 bundle:它由 index.js
1647
+ // 在**启动时**把 kv(feature_flags,面板 PUT /api/dsh-mneme/features 写的就是它)
1648
+ // 合并进 cfg 得到,再交给 createSummarizer。中间两环——设置面写入与启动期
1649
+ // 合并——一条测试都没覆盖过,而它们正是「distill_records 行数开始增长」这条
1650
+ // 验收判据的唯一通路:开关没写进 kv,或者写进了但装配时没被读出来,盘上就永远
1651
+ // 是 0 行,而 summarize.js 内部的五条锁照样全绿。
1652
+ //
1653
+ // 所以这两条不在 createSummarizer 层造 config,而是起一个最小宿主(真实 cordis
1654
+ // Context + 六个服务位),让 ctx.plugin(mneme, …) 走完 index.js 的装配:
1655
+ // ① kv 写开 → 装配 → 派发 turn/end → 原文与溯源链接都落盘;
1656
+ // ② 同一个装配但 kv 一个键都不写(默认态)→ 两表 0 行,记忆写入照旧。
1657
+ // 第二条守的是反面:装配默认不许把 opt-in 的开关私自打开。
1658
+
1659
+ /** 最小宿主:六个服务位按 webServer-optional.test.js 的形状提供。 */
1660
+ function minimalHost() {
1661
+ const ctx = new Context();
1662
+ ctx.provide("tools", { register() { return () => {}; } });
1663
+ ctx.provide("commands", { register() { return () => {}; } });
1664
+ ctx.provide("systemPrompt", { context() { return () => {}; } });
1665
+ ctx.provide("agentDefaultModel", { currentSelection() { return { provider: "mock", model: "mock" }; } });
1666
+ ctx.provide("llm", {
1667
+ async *stream() {
1668
+ yield { type: "block-start", block: { type: "text" } };
1669
+ yield {
1670
+ type: "text-delta",
1671
+ delta: JSON.stringify([
1672
+ { type: "decision", title: "选型", content: "确定用 node:sqlite", importance: 4 },
1673
+ { type: "preference", title: "语言", content: "用户喜欢中文交流", importance: 5 }
1674
+ ])
1675
+ };
1676
+ yield { type: "block-end", block: { type: "text" } };
1677
+ yield { type: "finish", kind: "ok" };
1678
+ }
1679
+ });
1680
+ ctx.provide("webServer", { register() { return () => {}; } });
1681
+ return ctx;
1682
+ }
1683
+
1684
+ /** 盘上的三张表:装配前后各查一次,才知道行是谁写的。 */
1685
+ function readTables(dbPath) {
1686
+ const db = new DatabaseSync(dbPath);
1687
+ const count = (table) => db.prepare(`SELECT count(*) AS c FROM ${table}`).get().c;
1688
+ const out = {
1689
+ records: count("distill_records"),
1690
+ provenance: count("memory_provenance"),
1691
+ memories: count("memories")
1692
+ };
1693
+ out.rows = db.prepare("SELECT session_id, seq, role, text FROM distill_records ORDER BY seq").all();
1694
+ db.close();
1695
+ return out;
1696
+ }
1697
+
1698
+ /** 装配 + 派发 turn/end + 拆掉装配(不给宿主留开着的手柄)。 */
1699
+ async function bootAndDistill(config, session) {
1700
+ const ctx = minimalHost();
1701
+ const fiber = ctx.plugin(mneme, config);
1702
+ await fiber;
1703
+ // ctx.parallel 会等所有监听器跑完;summarize.js 的监听器 return 了这次写入的
1704
+ // promise(summarize.js 的 session/event 订阅处明写「Return the summarization
1705
+ // promise so awaiters observe the writes」),所以并行派发完就是写完了。
1706
+ await ctx.parallel("session/event", session, { seq: 2, type: "turn/end" });
1707
+ await fiber.dispose();
1708
+ }
1709
+
1710
+ test("distill records grow from the settings face all the way to disk", async () => {
1711
+ const dir = mkdtempSync(join(tmpdir(), "mneme-distill-on-"));
1712
+ const dbPath = join(dir, "memory.db");
1713
+ try {
1714
+ // 第一环:用产品自己的设置面写 kv,等价于面板 PUT /api/dsh-mneme/features。
1715
+ const seed = createStore(dbPath);
1716
+ const merged = createSettings(seed.db).setFeatureFlags({ "distillRecords.enabled": true });
1717
+ assert.equal(merged["distillRecords.enabled"], true, "开关必须先真的落进 kv");
1718
+ assert.equal(readTables(dbPath).records, 0, "开之前盘上一行都没有");
1719
+ seed.close();
1720
+
1721
+ // 第二环 + 第三环:装配时 bundle 说关、kv 说开,合并取 kv;然后真跑一次蒸馏。
1722
+ await bootAndDistill(
1723
+ { memoryDir: dir, distillRecords: { enabled: false }, distillRateLimitIntervalMs: 0 },
1724
+ recordsSession("s-distill-on", [userMessage("这句话该落进 distill_records", 1), { seq: 2, type: "turn/end" }])
1725
+ );
1726
+
1727
+ const after = readTables(dbPath);
1728
+ assert.equal(after.records, 1, "装配读过 kv 之后,原文必须真的落盘");
1729
+ assert.equal(after.rows[0].session_id, "s-distill-on");
1730
+ assert.equal(after.rows[0].seq, 1);
1731
+ assert.equal(after.rows[0].role, "user");
1732
+ assert.match(after.rows[0].text, /该落进 distill_records/, "落盘的是公开正文原文");
1733
+ assert.equal(after.memories, 2, "记忆照常写入,留存是叠加不是替换");
1734
+ assert.equal(after.provenance, 2, "每条新记忆各落一条回指链接");
1735
+ } finally {
1736
+ rmSync(dir, { recursive: true, force: true });
1737
+ }
1738
+ });
1739
+
1740
+ test("the same assembly writes nothing when the flag was never set", async () => {
1741
+ const dir = mkdtempSync(join(tmpdir(), "mneme-distill-off-"));
1742
+ const dbPath = join(dir, "memory.db");
1743
+ try {
1744
+ // 连 createStore 都不预先起:一个全新的库就是「从没碰过设置面」的真实形态。
1745
+ await bootAndDistill(
1746
+ { memoryDir: dir, distillRateLimitIntervalMs: 0 },
1747
+ recordsSession("s-distill-off", [userMessage("默认态:不该有原文落盘", 1), { seq: 2, type: "turn/end" }])
1748
+ );
1749
+
1750
+ const after = readTables(dbPath);
1751
+ assert.equal(after.records, 0, "opt-in 的开关不许被装配私自打开");
1752
+ assert.equal(after.provenance, 0);
1753
+ assert.equal(after.memories, 2, "关的只是留存,蒸馏本身照常");
1754
+ } finally {
1755
+ rmSync(dir, { recursive: true, force: true });
1756
+ }
1757
+ });
1758
+
1759
+
1760
+ // v0.9.0(M.O. 项目记忆):机器提炼是写入量的绝大多数,而它此前一个作用域字段都不带
1761
+ // ——全库带 scope 的行不足 3%,且全部来自 memory_save 那条工具路径。这组用例钉住
1762
+ // 「蒸馏写入也盖章」这件事:值取 session.header(agentPreset + cwd),闸门与工具路径
1763
+ // 同一把(scopeEnabled),来源一律 'auto'(auto 不构成硬墙,只吃软加权,见
1764
+ // test/scope-strict.test.js 的 "auto and legacy rows are NOT hard walls" 用例,
1765
+ // 2026-10-13 在 :41-54)。
1766
+ function scopedSession(id, header) {
1767
+ const session = {
1768
+ id,
1769
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
1770
+ events: [userMessage("继续"), { seq: 2, type: "turn/end" }]
1771
+ };
1772
+ if (header !== undefined) session.header = header;
1773
+ return session;
1774
+ }
1775
+
1776
+ test("v0.9.0: scopeEnabled 开时蒸馏写入盖 auto 章(agent=agentPreset、workspace=header.cwd)", async () => {
1777
+ const { events, store } = setup({ scopeEnabled: true });
1778
+ const session = scopedSession("mo-scope", { agentPreset: "amarre", cwd: "C:\\work\\dsh-mneme" });
1779
+ await eventHandler(events)(session, { seq: 2, type: "turn/end" });
1780
+
1781
+ const rows = store.list({ limit: null });
1782
+ assert.equal(rows.length, 2, "两个条目都落库");
1783
+ for (const row of rows) {
1784
+ assert.equal(row.agent_scope, "amarre", "agent 维取自 session.header.agentPreset");
1785
+ assert.equal(row.agent_scope_source, "auto", "来源必须标 auto:显式声明才会构成硬过滤");
1786
+ assert.equal(row.workspace_scope, "C:\\work\\dsh-mneme", "workspace 维取自 session.header.cwd");
1787
+ assert.equal(row.workspace_scope_source, "auto");
1788
+ }
1789
+ });
1790
+
1791
+ test("v0.9.0: 解析不出身份时照样标 auto、值落 NULL(与「从未标注」可区分)", async () => {
1792
+ const { events, store } = setup({ scopeEnabled: true });
1793
+ // 无 header、无 workspaceRegistry(mock ctx 只有 on/llm):正是老会话/测试环境的形状。
1794
+ await eventHandler(events)(scopedSession("mo-scope-anon"), { seq: 2, type: "turn/end" });
1795
+
1796
+ for (const row of store.list({ limit: null })) {
1797
+ assert.equal(row.agent_scope, undefined);
1798
+ assert.equal(row.workspace_scope, undefined);
1799
+ // 关键区别:*_source='auto' 说明「自动标注跑过但没解析出值」,NULL 值才等于全局可见。
1800
+ assert.equal(row.agent_scope_source, "auto");
1801
+ assert.equal(row.workspace_scope_source, "auto");
1802
+ }
1803
+ });
1804
+
1805
+ test("v0.9.0: scopeEnabled 关时蒸馏写入一个 scope 字段都不加(老行为不变)", async () => {
1806
+ const { events, store } = setup({});
1807
+ await eventHandler(events)(scopedSession("mo-scope-off", { agentPreset: "amarre", cwd: "C:\\work\\dsh-mneme" }), { seq: 2, type: "turn/end" });
1808
+
1809
+ for (const row of store.list({ limit: null })) {
1810
+ assert.equal(row.agent_scope, undefined);
1811
+ assert.equal(row.agent_scope_source, undefined);
1812
+ assert.equal(row.workspace_scope, undefined);
1813
+ assert.equal(row.workspace_scope_source, undefined);
1814
+ }
1815
+ });
@@ -337,10 +337,10 @@ test("supersede 后注入语义候选池(向量命中 / 语义缓存)不得
337
337
  const { store, service, legacy, current } = await supersededFixture({ embedder: semanticEmbedder() });
338
338
  service.setVectorIndex(leakyVectorIndex(store));
339
339
 
340
- // 先填一次语义缓存:新查询的首帧 render 没有 queryVector(src/inject.js:327
341
- // 的预取是异步 .then),只能吃 lastSemanticRecall。真实检索不会吐归档行,
342
- // 是上面的假索引在源侧失守。填缓存的这一刻已经是 supersede 之后,所以缓存
343
- // 条目里 loser 的 archived 是 true。
340
+ // 先填一次语义缓存:新查询的首帧 render 没有 queryVector(src/inject.js 的
341
+ // prefetchQueryVector 预取是异步 .then,2026-10-14 在 :296),只能吃
342
+ // lastSemanticRecall。真实检索不会吐归档行,是上面的假索引在源侧失守。填缓存
343
+ // 的这一刻已经是 supersede 之后,所以缓存条目里 loser 的 archived 是 true。
344
344
  const cached = await service.searchMemories(QUERY, { mode: "vector", useRerank: false, recordRecall: false });
345
345
  const cachedOld = cached.find((r) => r.id === legacy.id);
346
346
  // 夹具自检:确认缓存里确实躺着带 archived 标记的旧值,否则 (e) 是空压。
@@ -8,6 +8,7 @@ import { tmpdir } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import { createTools } from "../src/tools.js";
10
10
  import { TRANSFORMERS_ENTRY, defaultModelCacheDir, payloadDir, payloadId } from "../src/runtime/layout.js";
11
+ import { hostModulesDir } from "../src/runtime/provision.js";
11
12
 
12
13
  function setup(embedder, config = {}) {
13
14
  const store = createStore(":memory:");
@@ -370,7 +371,21 @@ test("memory_runtime:status 只读且带代价说明;两条来源都不可
370
371
  const provision = await tool.execute({ action: "provision" });
371
372
  assert.deepEqual(validateJsonSchemaValue(tool.output.schema, provision), []);
372
373
  assert.equal(provision.status, "failed");
373
- assert.match(provision.reason, /收编失败|下载失败/);
374
+ // #323:车道(junction node_modules)里这条用例报过一次红,红的是下面那句「半份 payload」,
375
+ // 但变的是**环境**不是产品。所以把这条用例赖以成立的前提显式钉在这里:
376
+ // hostModulesDir 是 import.meta.url 的 4 级上溯,只有装入布局
377
+ // (<profile>/node_modules/@modusensus/dsh-mneme/src/tools.js → <profile>/node_modules)
378
+ // 才指向真的 node_modules;仓库根与车道布局下它落到仓库父目录 / <repo>\.worktrees,
379
+ // 于是收编在**计划阶段**就因「入口包不存在」零写入地失败——根本没走到物化分支,残留无从产生。
380
+ // (「物化到一半要清干净」由 runtime-adopt.test.js / runtime-download.test.js 的注入式用例负责,不靠这里。)
381
+ // 若有人改了目录层级、让源目录变成真的 node_modules,这一条会先红并把推导出的路径打出来,
382
+ // 而不是让「半份 payload」背一个看不懂的锅。
383
+ const source = hostModulesDir(new URL("../src/tools.js", import.meta.url).href);
384
+ assert.match(
385
+ provision.reason,
386
+ /收编失败:入口包不存在/,
387
+ `本用例的前提是「${source} 里没有可收编的 payload」;源目录换了形状,收编就会真的走物化分支(issue #323)`
388
+ );
374
389
  assert.equal(existsSync(runtimeDir) ? readdirSync(runtimeDir).length : 0, 0, "失败后不该留下半份 payload");
375
390
  });
376
391