@modusensus/dsh-mneme 0.9.0 → 0.9.2

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 (106) hide show
  1. package/README.md +82 -19
  2. package/bin/cli.mjs +69 -9
  3. package/docs/CONFIGURATION.md +48 -0
  4. package/docs/SEMANTIC.md +3 -3
  5. package/lib/api-standalone.js +78 -12
  6. package/lib/api.js +339 -59
  7. package/lib/candidates.js +454 -454
  8. package/lib/client.js +669 -256
  9. package/lib/config.js +50 -2
  10. package/lib/dream/sleep.js +35 -12
  11. package/lib/dream.js +86 -14
  12. package/lib/embedding.js +153 -22
  13. package/lib/index.js +32 -0
  14. package/lib/inject.js +34 -3
  15. package/lib/lang.js +124 -0
  16. package/lib/pipeline-activities.js +178 -0
  17. package/lib/pipeline-attribution.js +165 -0
  18. package/lib/pipeline-command.js +279 -0
  19. package/lib/pipeline-confirm.js +142 -0
  20. package/lib/pipeline-exec.js +174 -0
  21. package/lib/pipeline-nudge.js +1281 -0
  22. package/lib/pipeline-observations.js +181 -0
  23. package/lib/pipeline-observe.js +133 -0
  24. package/lib/pipeline-runner.js +346 -0
  25. package/lib/pipeline-runs.js +442 -0
  26. package/lib/pipeline.js +128 -7
  27. package/lib/scope-state.js +277 -0
  28. package/lib/semantic.js +66 -10
  29. package/lib/service.js +80 -3
  30. package/lib/settings.js +139 -1
  31. package/lib/store.js +216 -3
  32. package/lib/summarize.js +2 -3
  33. package/lib/tools.js +71 -0
  34. package/package.json +3 -3
  35. package/scripts/changelog-prep.mjs +62 -7
  36. package/scripts/check-sync.js +8 -6
  37. package/scripts/release-docs.mjs +204 -0
  38. package/scripts/release-notes.mjs +162 -0
  39. package/src/api-standalone.js +78 -12
  40. package/src/api.js +339 -59
  41. package/src/candidates.js +454 -454
  42. package/src/config.js +50 -2
  43. package/src/dream/sleep.js +35 -12
  44. package/src/dream.js +86 -14
  45. package/src/embedding.js +153 -22
  46. package/src/index.js +32 -0
  47. package/src/inject.js +34 -3
  48. package/src/lang.js +124 -0
  49. package/src/pipeline-activities.js +178 -0
  50. package/src/pipeline-attribution.js +165 -0
  51. package/src/pipeline-command.js +279 -0
  52. package/src/pipeline-confirm.js +142 -0
  53. package/src/pipeline-exec.js +174 -0
  54. package/src/pipeline-nudge.js +1281 -0
  55. package/src/pipeline-observations.js +181 -0
  56. package/src/pipeline-observe.js +133 -0
  57. package/src/pipeline-runner.js +346 -0
  58. package/src/pipeline-runs.js +442 -0
  59. package/src/pipeline.js +128 -7
  60. package/src/scope-state.js +277 -0
  61. package/src/semantic.js +66 -10
  62. package/src/service.js +80 -3
  63. package/src/settings.js +139 -1
  64. package/src/store.js +216 -3
  65. package/src/summarize.js +2 -3
  66. package/src/tools.js +71 -0
  67. package/test/api-hardening.test.js +173 -0
  68. package/test/api.test.js +210 -1
  69. package/test/changelog-prep.test.js +74 -8
  70. package/test/cli-enums.test.js +37 -0
  71. package/test/cli-flag-value.test.js +86 -0
  72. package/test/client.test.js +216 -6
  73. package/test/distill-tx-embed-gap.test.js +110 -0
  74. package/test/docs-config-keys.test.js +65 -0
  75. package/test/dream-maxtokens-fallback.test.js +132 -0
  76. package/test/embed-text-reasons.test.js +112 -0
  77. package/test/helpers/write-admission-samples.js +6 -2
  78. package/test/lib-smoke.test.js +20 -0
  79. package/test/persona-api.test.js +172 -0
  80. package/test/persona-flag.test.js +36 -0
  81. package/test/persona-inject.test.js +145 -0
  82. package/test/persona-settings.test.js +145 -0
  83. package/test/pipeline-activities.test.js +155 -0
  84. package/test/pipeline-attribution.test.js +175 -0
  85. package/test/pipeline-command.test.js +243 -0
  86. package/test/pipeline-confirm.test.js +148 -0
  87. package/test/pipeline-exec.test.js +172 -0
  88. package/test/pipeline-nudge.test.js +1036 -0
  89. package/test/pipeline-observations.test.js +157 -0
  90. package/test/pipeline-observe.test.js +127 -0
  91. package/test/pipeline-r1r2.test.js +149 -0
  92. package/test/pipeline-runner.test.js +442 -0
  93. package/test/pipeline-runs.test.js +767 -0
  94. package/test/pipeline.test.js +137 -6
  95. package/test/readme-claims.test.js +68 -0
  96. package/test/reindex-backfill.test.js +83 -4
  97. package/test/release-docs.test.js +130 -0
  98. package/test/release-notes.test.js +240 -0
  99. package/test/release-workflow.test.js +16 -0
  100. package/test/route-policy.test.js +113 -0
  101. package/test/scope-state.test.js +481 -0
  102. package/test/security-doc.test.js +139 -0
  103. package/test/sensitive-scan.test.js +44 -0
  104. package/test/tools-dto-schema-matrix.test.js +12 -3
  105. package/test/tools.test.js +6 -6
  106. package/test/workflow-guards.test.js +156 -0
package/src/settings.js CHANGED
@@ -31,6 +31,67 @@ function parseList(raw) {
31
31
  }
32
32
  }
33
33
 
34
+ // persona 对象的字段白名单:写入时只挑这些键,未知字段丢弃(设计稿①:字段从简,
35
+ // beta 收到真实反馈再加;provenance 是 v1.5 协作式人格的预留位,v1 不写管道)。
36
+ // 读侧损坏即回空对象——同一容错口径照 parseList(坏值不抛错、面板显示为空可重写)。
37
+ const PERSONA_FIELDS = ["name", "personality", "replyStyle", "enabled", "provenance"];
38
+
39
+ function parsePersona(raw) {
40
+ try {
41
+ const value = JSON.parse(raw);
42
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
43
+ const out = {};
44
+ for (const key of PERSONA_FIELDS) {
45
+ if (value[key] !== undefined) out[key] = value[key];
46
+ }
47
+ return out;
48
+ } catch {
49
+ return {};
50
+ }
51
+ }
52
+
53
+ // 多卡模型(v1.2):存储形为 { default: <卡>, byAgent: { <agentId>: <卡>, … } }。
54
+ // 兼容层:v1 存的是单对象(有 PERSONA_FIELDS 键、无 default/byAgent 键),读侧
55
+ // 判形状——单对象原样落 default 位,旧数据零迁移。byAgent 的键是 agent 身份
56
+ // id(注入时按会话身份寻址,取不到落 default)。default 卡不可删(兜底位)。
57
+ function parsePersonaStore(raw) {
58
+ try {
59
+ const value = JSON.parse(raw);
60
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
61
+ return { default: {}, byAgent: {} };
62
+ }
63
+ // 新形:含 default 或 byAgent 键 → 按多卡解析
64
+ if ("default" in value || "byAgent" in value) {
65
+ const byAgent = {};
66
+ if (value.byAgent && typeof value.byAgent === "object" && !Array.isArray(value.byAgent)) {
67
+ for (const [agentId, card] of Object.entries(value.byAgent)) {
68
+ const parsed = parsePersona(JSON.stringify(card ?? {}));
69
+ if (Object.keys(parsed).length) byAgent[String(agentId)] = parsed;
70
+ }
71
+ }
72
+ return { default: parsePersona(JSON.stringify(value.default ?? {})), byAgent };
73
+ }
74
+ // 旧形:v1 单对象 → 整体作 default(字段白名单在 parsePersona 内裁)
75
+ return { default: parsePersona(raw), byAgent: {} };
76
+ } catch {
77
+ return { default: {}, byAgent: {} };
78
+ }
79
+ }
80
+
81
+ function serializePersonaStore(store) {
82
+ const out = {
83
+ default: parsePersona(JSON.stringify(store?.default ?? {})),
84
+ byAgent: {}
85
+ };
86
+ if (store?.byAgent && typeof store.byAgent === "object") {
87
+ for (const [agentId, card] of Object.entries(store.byAgent)) {
88
+ const parsed = parsePersona(JSON.stringify(card ?? {}));
89
+ if (Object.keys(parsed).length) out.byAgent[String(agentId)] = parsed;
90
+ }
91
+ }
92
+ return JSON.stringify(out);
93
+ }
94
+
34
95
  // --- feature flags(功能开关)白名单 -----------------------------------------
35
96
  // 面板可逐项开关的后端能力。设计约束:
36
97
  // 1. 键名与类型必须和 config.js schema 同名同型,这份白名单是唯一校验源
@@ -113,6 +174,13 @@ const FEATURE_FLAG_BOOLEANS = [
113
174
  // Issue #17(v0.8.0 A3):strictScope 硬过滤——他 scope 完全不可见(关闭时
114
175
  // 为 A2 软隔离:降权保留可见)。
115
176
  "strictScope",
177
+ // Issue #471:政策轴——作用域级可逆闸门(scopeState)。与上面两个键是两条不同的
178
+ // 轴:那两个管「这条记忆属于谁」(事实轴),这两个管「这个作用域现在通不通」
179
+ // (政策轴,按作用域整体按下、跨会话存续、松开即恢复)。两级语义同
180
+ // writeAdmission/preInjectGate——enabled 跑判定(观察档,留审计行但照常注入)、
181
+ // enforce 真拦(不注入);都默认关。点号键平铺存、合并时展开回 scopeState 对象。
182
+ "scopeState.enabled",
183
+ "scopeState.enforce",
116
184
  // 工具暴露开关(v0.8.5):记忆已每轮自动注入,memory_search/memory_archive
117
185
  // 在慢/轻量模型上是多余往返,面板可关(默认关=行为不变)。
118
186
  "disableMemorySearch",
@@ -146,7 +214,18 @@ const FEATURE_FLAG_BOOLEANS = [
146
214
  // 决定是否注册座位(会话头图标 / 浮层 / 右栏 / 对话行卡片),服务端只负责把
147
215
  // 这个键稳定暴露出来——键若不在 effective 里,客户端静默少键、一个座位都不注册。
148
216
  // 默认关(beta、opt-in);顶层扁平键,因此不需要 NESTED_FLAG_PATHS 条目。
149
- "actionPipelineEnabled"
217
+ "actionPipelineEnabled",
218
+ // v0.9.0 第二批:M.O. 执行记账(`/mo-run` 开窗 → 窗口内命令被记成一条 run)。
219
+ // 与上面那个键分开:那个管「看图的座位」,这个管「往账本里写」。默认关(beta、
220
+ // opt-in),顶层扁平键,不需要 NESTED_FLAG_PATHS 条目。
221
+ "actionRunEnabled",
222
+ // 人格定义页(beta):注入器渲染人格段的顶层总闸(双闸门的 config 半边;对象内
223
+ // 的 persona.enabled 是 per-agent 半边,存 user_settings 的 "persona" 键,见
224
+ // getPersona/setPersona)。默认关(beta、opt-in,老用户行为逐字节不变);不进
225
+ // LIGHT_MODE_OFF——人格段是用户自写的一小段轻量常驻文本,不引入任何重型后台
226
+ // 路径,轻量档用户显式勾选仍然赢(同 actionPipelineEnabled 的定性)。顶层扁平
227
+ // 键,不需要 NESTED_FLAG_PATHS 条目。
228
+ "personaEnabled"
150
229
  ];
151
230
  // 整数开关的闭区间,与 config.js 里 z.natural().min().max() 对齐。
152
231
  const FEATURE_FLAG_INT_RANGES = {
@@ -403,6 +482,65 @@ export function createSettings(db) {
403
482
  setSetting("rules", JSON.stringify(list));
404
483
  },
405
484
 
485
+ /**
486
+ * Agent persona (name / personality / replyStyle + enabled gate), stored as
487
+ * one JSON key. v1.2 多卡:存储形 { default, byAgent },旧 v1 单对象读作
488
+ * default 兼容。getPersona 返回全量多卡形(面板用);getPersonaFor 按 agent
489
+ * 身份取卡(注入用),身份缺失/无卡/卡 enabled=false 均落 default。
490
+ */
491
+ getPersona() {
492
+ return parsePersonaStore(getSetting("persona") ?? "{}");
493
+ },
494
+ setPersona(input) {
495
+ // 双形兼容:单对象形 = 只更新 default、byAgent 保留(旧面板/旧调用方合并语义);
496
+ // 多卡形 = 全量替换。坏体(非对象)在此不落库不抛错,保值由调用方守卫。
497
+ if (typeof input !== "object" || input === null || Array.isArray(input)) return;
498
+ if ("default" in input || "byAgent" in input) {
499
+ setSetting("persona", serializePersonaStore(input));
500
+ return;
501
+ }
502
+ const current = parsePersonaStore(getSetting("persona") ?? "{}");
503
+ current.default = parsePersona(JSON.stringify(input));
504
+ setSetting("persona", serializePersonaStore(current));
505
+ },
506
+ /** Per-agent lookup for the injector: id card → default fallback. */
507
+ getPersonaFor(agentId) {
508
+ const store = parsePersonaStore(getSetting("persona") ?? "{}");
509
+ const id = typeof agentId === "string" && agentId.trim() ? agentId.trim() : "";
510
+ if (id) {
511
+ const card = store.byAgent[id];
512
+ if (card && card.enabled === true) return card;
513
+ }
514
+ return store.default;
515
+ },
516
+ /** Replace one byAgent card wholesale (panel save); default 不可经此改。 */
517
+ setPersonaFor(agentId, card) {
518
+ const id = typeof agentId === "string" && agentId.trim() ? agentId.trim() : "";
519
+ if (!id || id === "default") return false;
520
+ const store = parsePersonaStore(getSetting("persona") ?? "{}");
521
+ const clean = parsePersona(JSON.stringify(
522
+ typeof card === "object" && card !== null && !Array.isArray(card) ? card : {}
523
+ ));
524
+ if (Object.keys(clean).length) store.byAgent[id] = clean;
525
+ else delete store.byAgent[id];
526
+ setSetting("persona", serializePersonaStore(store));
527
+ return true;
528
+ },
529
+ /** byAgent 键列表(面板卡列表用,default 不在其中)。 */
530
+ listPersonas() {
531
+ return Object.keys(parsePersonaStore(getSetting("persona") ?? "{}").byAgent);
532
+ },
533
+ /** 删一张 byAgent 卡;default 不可删(返回 false)。 */
534
+ removePersona(agentId) {
535
+ const id = typeof agentId === "string" && agentId.trim() ? agentId.trim() : "";
536
+ if (!id || id === "default") return false;
537
+ const store = parsePersonaStore(getSetting("persona") ?? "{}");
538
+ if (!(id in store.byAgent)) return false;
539
+ delete store.byAgent[id];
540
+ setSetting("persona", serializePersonaStore(store));
541
+ return true;
542
+ },
543
+
406
544
  /** All custom commands, sorted by name. */
407
545
  listCommands() {
408
546
  const rows = db.prepare("SELECT * FROM custom_commands ORDER BY name ASC").all();
package/src/store.js CHANGED
@@ -2,6 +2,15 @@ import { DatabaseSync } from "node:sqlite";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { statSync } from "node:fs";
4
4
  import { contentHashOf } from "./content-hash.js";
5
+ // 政策轴(issue #471)的归一化函数。刻意只取归一化,不取判定:判定(buildPausedIndex /
6
+ // matchPausedScopes / createScopeStateGate)留在 scope-state.js,由 service 侧消费,
7
+ // 存储层只管「一格状态怎么落」。依赖方向 store → scope-state → scope 是单向的
8
+ // (scope.js 不 import 任何本包模块),所以不构成环。
9
+ import {
10
+ normalizeScopeStateDimension,
11
+ normalizeScopeStateState,
12
+ normalizeScopeStateValue
13
+ } from "./scope-state.js";
5
14
 
6
15
  const SCHEMA = `
7
16
  CREATE TABLE IF NOT EXISTS memories (
@@ -185,6 +194,25 @@ CREATE TABLE IF NOT EXISTS scope_changes (
185
194
  );
186
195
  CREATE INDEX IF NOT EXISTS idx_scope_changes_memory ON scope_changes(memory_id);
187
196
 
197
+ -- scope_state: v0.9.1 政策轴(issue #471)。与 scope_changes 那条事实轴相对:
198
+ -- scope_changes 记「某条记忆的归属被人工改过」(跟着记忆走),本表记「某个作用域
199
+ -- 现在通不通」(跟着作用域走)。按下/松开都是单方面动作,不触碰任何记忆行,
200
+ -- 松开即恢复——所以它是可逆的,而逐条 forget/archive 不是。
201
+ -- dimension 只认 'agent'|'workspace'(scopeKeyOf 那两个维度);scope_value='*'
202
+ -- 表示该维度全局。一行一格,重复按下同一状态不刷新 decided_at(见 setScopeState)。
203
+ -- Bookkeeping like the other audit tables: it never triggers write hooks.
204
+ CREATE TABLE IF NOT EXISTS scope_state (
205
+ id TEXT PRIMARY KEY,
206
+ dimension TEXT NOT NULL,
207
+ scope_value TEXT NOT NULL,
208
+ state TEXT NOT NULL, -- active | paused
209
+ note TEXT,
210
+ decided_at TEXT NOT NULL
211
+ );
212
+ -- 唯一性靠独立索引(本仓惯例:UNIQUE 不进 CREATE TABLE,见 idx_memory_provenance_link)。
213
+ -- 它同时是 setScopeState 那条 UPSERT 的冲突目标,所以不能省。
214
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_scope_state_key ON scope_state(dimension, scope_value);
215
+
188
216
  -- llm_audit_logs: every background LLM call (autoDream consolidation + summary,
189
217
  -- autoSummarize compression) is recorded here — tokens in/out, duration, status
190
218
  -- and the trigger that caused it (Bug8). Failures are captured as status='error'
@@ -604,6 +632,20 @@ function toScopeChange(row) {
604
632
  };
605
633
  }
606
634
 
635
+ // scope_state 行 → 读侧对象(issue #471)。NULL note 落 undefined,与
636
+ // toScopeChange 同口径。
637
+ function toScopeState(row) {
638
+ if (!row) return undefined;
639
+ return {
640
+ id: row.id,
641
+ dimension: row.dimension,
642
+ scope_value: row.scope_value,
643
+ state: row.state,
644
+ note: row.note ?? undefined,
645
+ decided_at: row.decided_at
646
+ };
647
+ }
648
+
607
649
  function toRecallRun(row) {
608
650
  if (!row) return undefined;
609
651
  return {
@@ -835,6 +877,15 @@ export function createStore(path) {
835
877
  // 老库打开时 SCHEMA 的 CREATE TABLE 对既有表不生效,列还不存在,把索引写进 SCHEMA
836
878
  // 会直接报 no such column(与下方 llm_audit_logs.session_key 同理)。
837
879
  addColumn("memories", "content_hash", "ALTER TABLE memories ADD COLUMN content_hash TEXT");
880
+ // 每条向量的出身(行级 model hash,如 `qwen3.7-text-embedding-flash#17a6a0b1`)。
881
+ // 为什么必须行级:vector_meta 只有一个 model_hash,它记的是「最后一次成功写入
882
+ // 的模型」,换模型时被整表覆盖——于是上一代模型产出的向量在元数据上看起来完全
883
+ // 合规,实际与新模型的查询向量不可比。没有这一列,「这条向量是不是当前模型产
884
+ // 的」就无从判断(content_hash 是内容归一化哈希,与向量模型无关;dimension 在
885
+ // 同为 1024 维的模型间也区分不出来)。
886
+ // 存量行的值是 NULL,且**不推断**:按 updated_at 猜出的模型名会变成日后审计信
887
+ // 以为真的证据。NULL 一律读作「出身未知」,等价的补法是重算,不是猜。
888
+ addColumn("memories", "embedding_model", "ALTER TABLE memories ADD COLUMN embedding_model TEXT");
838
889
  // issue #24 块2(权重演化):entity_relations 加 weight + source。存量关系
839
890
  // 行缺列 → 默认 manual 1.0(最保守:既有语义不因迁移而变弱)。
840
891
  addColumn("entity_relations", "weight", "ALTER TABLE entity_relations ADD COLUMN weight REAL NOT NULL DEFAULT 1.0");
@@ -1606,10 +1657,24 @@ export function createStore(path) {
1606
1657
  return rows.map((row) => row.content ?? "");
1607
1658
  }
1608
1659
 
1609
- /** Set (or clear with null) the embedding vector of a memory. */
1610
- function setEmbedding(id, vector) {
1660
+ /** Set (or clear with null) the embedding vector of a memory.
1661
+ * `modelHash` records which model produced THIS vector (see the
1662
+ * embedding_model migration note). Clearing the vector clears the
1663
+ * provenance with it, so a NULL vector is never paired with a stale model
1664
+ * name; omitting modelHash leaves the column untouched (callers that
1665
+ * predate the column still work, their rows simply read as unknown). */
1666
+ function setEmbedding(id, vector, modelHash) {
1611
1667
  const json = Array.isArray(vector) && vector.length ? JSON.stringify(vector) : null;
1612
- db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run(json, id);
1668
+ if (json) {
1669
+ if (modelHash !== undefined) {
1670
+ db.prepare("UPDATE memories SET embedding = ?, embedding_model = ? WHERE id = ?")
1671
+ .run(json, modelHash === null ? null : String(modelHash), id);
1672
+ } else {
1673
+ db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run(json, id);
1674
+ }
1675
+ } else {
1676
+ db.prepare("UPDATE memories SET embedding = NULL, embedding_model = NULL WHERE id = ?").run(id);
1677
+ }
1613
1678
  // Issue #202:单一写入口即单一失效点——重写缓存条目(向量在手上不必重解析),
1614
1679
  // 清空则移除,删除过的 id 不会被下一次 searchVector 的 id 集合选中。
1615
1680
  if (json) {
@@ -1662,6 +1727,80 @@ export function createStore(path) {
1662
1727
  ).all(limit);
1663
1728
  }
1664
1729
 
1730
+ /**
1731
+ * Rows whose vector is missing OR was produced by a different model than
1732
+ * `modelHash` — the selector the backfill should use whenever the caller
1733
+ * knows which model the vectors are supposed to come from.
1734
+ *
1735
+ * Why `needsEmbedding` alone is not enough: it only matches empty vectors,
1736
+ * so after a model switch every vector written by the previous model stays
1737
+ * eligible for "has a vector" forever, while its similarity to the current
1738
+ * query embedding is meaningless. Those rows keep inflating the status
1739
+ * card's coverage while semantic recall silently degrades.
1740
+ *
1741
+ * `embedding_model IS NULL` counts as stale on purpose: NULL means "written
1742
+ * before per-row provenance existed, producing model unknown", and an
1743
+ * unknown origin cannot be proven to match. Those rows get re-embedded
1744
+ * rather than assumed correct.
1745
+ *
1746
+ * When `modelHash` is falsy (embedder unconfigured / no fingerprint yet)
1747
+ * this degrades to the plain missing-only selector instead of re-embedding
1748
+ * the whole table against nothing.
1749
+ */
1750
+ function needsEmbeddingFor(modelHash, limit = 50) {
1751
+ if (!modelHash) return needsEmbedding(limit);
1752
+ return db.prepare(
1753
+ `SELECT id, title, content, embedding_model FROM memories
1754
+ WHERE ((embedding IS NULL OR embedding = '')
1755
+ OR embedding_model IS NULL
1756
+ OR embedding_model != ?)
1757
+ AND forgotten = 0 AND archived = 0
1758
+ ORDER BY updated_at DESC LIMIT ?`
1759
+ ).all(String(modelHash), limit);
1760
+ }
1761
+
1762
+ /** True when any active row would be picked by needsEmbeddingFor(). */
1763
+ function hasStaleEmbedding(modelHash) {
1764
+ return needsEmbeddingFor(modelHash, 1).length > 0;
1765
+ }
1766
+
1767
+ /** Per-model provenance counts over active rows ('' = unknown origin). */
1768
+ function embeddingModelBreakdown() {
1769
+ return db.prepare(
1770
+ `SELECT COALESCE(embedding_model, '') AS model, count(*) AS c
1771
+ FROM memories
1772
+ WHERE embedding IS NOT NULL AND embedding != ''
1773
+ AND forgotten = 0 AND archived = 0
1774
+ GROUP BY COALESCE(embedding_model, '')`
1775
+ ).all();
1776
+ }
1777
+
1778
+ /**
1779
+ * Per-dimension counts over active embedded rows ('' = vector that failed to
1780
+ * parse as an array — legacy garbage rows, not a real dimension).
1781
+ *
1782
+ * Why per-dimension: embeddedCount() alone says "has a vector", but vectors
1783
+ * written by a different model/dimension are not comparable with the current
1784
+ * query embedding — searchVector skips them, so they are "present" without
1785
+ * being "retrievable". The status card needs this split to stop reporting a
1786
+ * coverage number that silently counts unsearchable rows.
1787
+ */
1788
+ function embeddingDimensionBreakdown() {
1789
+ return db.prepare(
1790
+ `SELECT id, embedding FROM memories
1791
+ WHERE embedding IS NOT NULL AND embedding != ''
1792
+ AND forgotten = 0 AND archived = 0`
1793
+ ).all().reduce((acc, row) => {
1794
+ let dim = "";
1795
+ try {
1796
+ const parsed = JSON.parse(row.embedding);
1797
+ dim = Array.isArray(parsed) ? String(parsed.length) : "";
1798
+ } catch { /* unparseable vector counts under "" */ }
1799
+ acc.set(dim, (acc.get(dim) ?? 0) + 1);
1800
+ return acc;
1801
+ }, new Map());
1802
+ }
1803
+
1665
1804
  function search(query, { limit = 20, includeArchived = false } = {}) {
1666
1805
  const q = String(query).trim();
1667
1806
  if (!q) return [];
@@ -1718,6 +1857,11 @@ export function createStore(path) {
1718
1857
  for (const row of rows) {
1719
1858
  const v = getParsedEmbedding(row.id);
1720
1859
  if (!v) continue;
1860
+ // distill-tx-embed-gap 顺带修:维度不匹配的行按维度分流,直接跳过。旧路径
1861
+ // 喂进 cosine 不等长就 return 0,换了模型/维度的旧向量以 0 分混进结果尾部
1862
+ // (threshold 默认 0 时照样入选),检索方拿到的分数是跨向量空间的假值。
1863
+ // 不等长的向量对本次查询没有可比分数,跳过是唯一诚实的处置。
1864
+ if (v.length !== vector.length) continue;
1721
1865
  const score = cosine(vector, v);
1722
1866
  if (score >= threshold) scored.push({ row: { id: row.id }, score });
1723
1867
  }
@@ -2538,6 +2682,66 @@ export function createStore(path) {
2538
2682
  return rows.map(toScopeChange);
2539
2683
  }
2540
2684
 
2685
+ /**
2686
+ * 政策轴(issue #471):按下或松开一个作用域。一行一格,靠
2687
+ * idx_scope_state_key(dimension, scope_value) 做 UPSERT——重复按下同一状态
2688
+ * 不刷新 decided_at(CASE WHEN),因为「第一次按下的时刻」才是回执该报的东西;
2689
+ * 状态翻转则刷新,那是一次真的决策。
2690
+ *
2691
+ * note 的取舍与 decided_at 相反:非空的新 note 总是覆盖旧的,空的则原样留着。
2692
+ * 理由是这条记录里「当前通不通」由 state 那一列承担,note 是**理由**——理由被
2693
+ * 一次不带理由的重按抹掉是净损失(用户回看时只会看到「按下过」却不知道为什么),
2694
+ * 而「清空理由」不是一个真实需求(要改就再按一次带上新理由)。这与本仓防御段
2695
+ * 「宁可留着也不要静默丢」的取向一致。
2696
+ *
2697
+ * 归一化失败(维度/值/状态任一非法)返回 undefined,由调用方拒绝——本表只存
2698
+ * 可解释的三种枚举,与 memories 的 scope 列不同:那里脏输入落 NULL 是正当的
2699
+ * (「未标注」本身有语义),这里落 NULL 没有任何意思,会静默变成一行永远不
2700
+ * 生效的状态。
2701
+ */
2702
+ function setScopeState({ dimension, scope_value, state, note, decided_at }) {
2703
+ const dim = normalizeScopeStateDimension(dimension);
2704
+ const val = normalizeScopeStateValue(scope_value);
2705
+ const st = normalizeScopeStateState(state);
2706
+ if (!dim || !val || !st) return undefined;
2707
+ const at = normalizeOccurredAt(decided_at) ?? nowIso();
2708
+ const noteText = normalizeScopeText(note);
2709
+ db.prepare(
2710
+ `INSERT INTO scope_state (id, dimension, scope_value, state, note, decided_at)
2711
+ VALUES (?, ?, ?, ?, ?, ?)
2712
+ ON CONFLICT(dimension, scope_value) DO UPDATE SET
2713
+ state = excluded.state,
2714
+ note = COALESCE(excluded.note, scope_state.note),
2715
+ decided_at = CASE WHEN scope_state.state = excluded.state THEN scope_state.decided_at ELSE excluded.decided_at END`
2716
+ ).run(randomUUID(), dim, val, st, noteText, at);
2717
+ return toScopeState(
2718
+ db.prepare("SELECT * FROM scope_state WHERE dimension = ? AND scope_value = ?").get(dim, val)
2719
+ );
2720
+ }
2721
+
2722
+ /** 政策轴状态行(单格)。 */
2723
+ function getScopeState(dimension, scope_value) {
2724
+ const dim = normalizeScopeStateDimension(dimension);
2725
+ const val = normalizeScopeStateValue(scope_value);
2726
+ if (!dim || !val) return undefined;
2727
+ return toScopeState(
2728
+ db.prepare("SELECT * FROM scope_state WHERE dimension = ? AND scope_value = ?").get(dim, val)
2729
+ );
2730
+ }
2731
+
2732
+ /**
2733
+ * 政策轴状态行(全量)。不提供按维度/值过滤的参数——闸门判定要的就是全量
2734
+ * (一次现读一次索引),过滤留到 scope-state.js 的 buildPausedIndex 里做。
2735
+ * 排 decided_at DESC + rowid DESC 同 listScopeChanges(毫秒精度同毫秒写入
2736
+ * 时 UUID 排序随机,回执要报「最近一次决策」就得有确定的次键)。
2737
+ */
2738
+ function listScopeStates({ state } = {}) {
2739
+ const rows = state
2740
+ ? db.prepare("SELECT * FROM scope_state WHERE state = ? ORDER BY decided_at DESC, rowid DESC").all(state)
2741
+ : db.prepare("SELECT * FROM scope_state ORDER BY decided_at DESC, rowid DESC").all();
2742
+ return rows.map(toScopeState);
2743
+ }
2744
+
2541
2745
  function getFailureStats({ since } = {}) {
2542
2746
  const clause = since ? "WHERE created_at >= ?" : "";
2543
2747
  const params = since ? [since] : [];
@@ -3159,6 +3363,10 @@ export function createStore(path) {
3159
3363
  getParsedEmbedding,
3160
3364
  embeddedCount,
3161
3365
  needsEmbedding,
3366
+ needsEmbeddingFor,
3367
+ hasStaleEmbedding,
3368
+ embeddingModelBreakdown,
3369
+ embeddingDimensionBreakdown,
3162
3370
  searchVector,
3163
3371
  saveDreamRun,
3164
3372
  lastDreamRunAt,
@@ -3207,6 +3415,11 @@ export function createStore(path) {
3207
3415
  countConflictPending,
3208
3416
  saveScopeChange,
3209
3417
  listScopeChanges,
3418
+ // 政策轴(issue #471):按下/松开一个作用域。纯 bookkeeping,不调
3419
+ // incrementGeneration()——它不动任何记忆行,动它只会让所有缓存无谓失效。
3420
+ setScopeState,
3421
+ getScopeState,
3422
+ listScopeStates,
3210
3423
  createEntity,
3211
3424
  findEntityByName,
3212
3425
  findEntityById,
package/src/summarize.js CHANGED
@@ -514,8 +514,7 @@ export function createSummarizer(ctx, service, config, deps = {}) {
514
514
  const clearTimer = typeof deps.clearTimeoutFn === "function" ? deps.clearTimeoutFn : clearTimeout;
515
515
 
516
516
  // v0.9.0(M.O. 项目记忆):蒸馏写入也要盖作用域章。此前只有 memory_save 那条工具
517
- // 路径盖章(tools.js 的 memory_save 里 resolveSessionScope 到 workspaceLabel 那一段,
518
- // 2026-10-13 在 :173-193),而机器提炼是写入量的绝大多数——于是「项目记忆」
517
+ // 路径盖章(tools.js 的 resolveSessionScope 盖章块,2026-10-14 在 :174-181),而机器提炼是写入量的绝大多数——于是「项目记忆」
519
518
  // 在库里根本不存在(全库带 scope 的行不足 3%,且带 scope 的全部来自工具路径)。
520
519
  // 这里不另造口径:同一把解析器(scope.js 的 createScopeResolver,2026-10-13 在 :24)、同一把闸门
521
520
  // (config.scopeEnabled === true)、同一对列(*_scope + *_scope_source='auto'),
@@ -888,7 +887,7 @@ export function createSummarizer(ctx, service, config, deps = {}) {
888
887
  // v0.9.0(M.O. 项目记忆):一次窗口解析一次——同一蒸馏窗口里所有条目同属一个
889
888
  // 工作区、一个 agent 身份,逐条解析是白花钱。解析不到就落 NULL(NULL = 未标注 =
890
889
  // 全局可见),且解析器绝不抛错(scope.js 的硬约束:记忆写入不该被作用域拖挂)。
891
- // 闸门与 tools.js 的 autoStamping(2026-10-13 在 :180)同一把:关着就一个字段都不加,老行为不变。
890
+ // 闸门与 tools.js 的 autoStamping(2026-10-14 在 :175)同一把:关着就一个字段都不加,老行为不变。
892
891
  const writeScope = config?.scopeEnabled === true
893
892
  ? resolveSessionScope({ agent: { session } })
894
893
  : null;
package/src/tools.js CHANGED
@@ -7,6 +7,7 @@ import { matchesPlatform } from "./runtime/closure.js";
7
7
  import { verifyPayload } from "./runtime/verify.js";
8
8
  import { TOOL_GUIDE, CONTINUITY_TOOL_RULE } from "./guide.js";
9
9
  import { injectChildEnabled } from "./config.js";
10
+ import { normalizeScopeStateDimension, normalizeScopeStateValue, normalizeScopeStateState } from "./scope-state.js";
10
11
  import { lastUserQuery } from "./inject.js";
11
12
 
12
13
  const TEXT_OUTPUT = (text) => [{ type: "text", text }];
@@ -500,6 +501,76 @@ export function createTools(ctx, service, config, embedder) {
500
501
  }
501
502
  }),
502
503
 
504
+ // #471:政策轴——作用域级可逆闸门。与 memory_forget/memory_archive 的分工:
505
+ // 那两个是事实轴的单条操作(改的是「这条记忆属于谁 / 还算不算数」,一条条来);
506
+ // 本工具是政策轴的按作用域操作(改的是「这一路现在通不通」,跨会话存续、
507
+ // 松开即原样回来,一条记忆行都不动)。判断指引:整条工作线要临时静音用本工具;
508
+ // 单条记忆不该再被想起才用 memory_forget/memory_archive。
509
+ defineTool({
510
+ name: "set_scope_state",
511
+ description:
512
+ "Press or release the reversible scope gate for one scope (issue #471): while paused, that scope's memories stay stored and unchanged but are held back from injection and dream consolidation. " +
513
+ "This is not deletion and not forgetting — release with state=active and everything comes back exactly as it was, which is why it is the right tool for temporarily silencing a whole work stream rather than editing memories one by one. " +
514
+ "dimension is agent or workspace; scope_value is the label itself, or * for that dimension's global gate. " +
515
+ "Both switches (scopeState.enabled/enforce) are off by default, so setting a state has no effect on injection until the user turns them on in the panel.",
516
+ parameters: {
517
+ dimension: { type: "string", required: true, description: "Scope dimension to press: \"agent\" or \"workspace\"" },
518
+ scope_value: { type: "string", required: true, description: "The scope label, or \"*\" for that dimension's global gate" },
519
+ state: { type: "string", required: true, description: "\"paused\" presses the gate, \"active\" releases it" },
520
+ note: { type: "string", description: "Optional short reason, stored with the decision so the next reader knows why it was pressed" }
521
+ },
522
+ output: {
523
+ schema: {
524
+ type: "object",
525
+ additionalProperties: false,
526
+ properties: {
527
+ scope: {
528
+ type: "object",
529
+ additionalProperties: false,
530
+ properties: {
531
+ dimension: { type: "string", required: true },
532
+ scope_value: { type: "string", required: true },
533
+ state: { type: "string", required: true },
534
+ // 理由要能读回来:不带它,下次读的人只知道「按下过」却不知道为什么。
535
+ // 这与库层 setScopeState 的 note 取舍是同一条理由——不带理由的重按
536
+ // 不抹掉旧理由,正是为了让这里读得回来。可选:从没写过就没有。
537
+ note: { type: "string" },
538
+ decided_at: { type: "string", required: true }
539
+ }
540
+ }
541
+ }
542
+ },
543
+ // 回执如实回显当前值(不是回显入参):同一个状态重复按下时 decided_at 保持
544
+ // 首次值,回显它才看得出「这次没有真的改变什么」。
545
+ render: (_args, value) =>
546
+ TEXT_OUTPUT(
547
+ `Scope ${value.scope.dimension}:${value.scope.scope_value} is now ${value.scope.state} (decided at ${value.scope.decided_at}).`
548
+ )
549
+ },
550
+ async execute(args) {
551
+ const dimension = normalizeScopeStateDimension(args.dimension);
552
+ const scopeValue = normalizeScopeStateValue(args.scope_value);
553
+ const state = normalizeScopeStateState(args.state);
554
+ if (dimension === null) throw new Error("dimension must be \"agent\" or \"workspace\"");
555
+ if (scopeValue === null) throw new Error("scope_value must be a non-empty label or \"*\"");
556
+ if (state === null) throw new Error("state must be \"active\" or \"paused\"");
557
+ const row = service.setScopeState({ dimension, scope_value: scopeValue, state, note: args.note });
558
+ if (row === undefined) throw new Error("scope state could not be recorded");
559
+ return {
560
+ scope: {
561
+ dimension: row.dimension,
562
+ scope_value: row.scope_value,
563
+ state: row.state,
564
+ // 没写过理由时键整个不出现(而不是 note: undefined):output schema 是
565
+ // 闭合的,DTO 矩阵会拿真实返回值过 validateJsonSchemaValue,留一个
566
+ // 值为 undefined 的键等于留个空壳。
567
+ ...(row.note === undefined ? {} : { note: row.note }),
568
+ decided_at: row.decided_at
569
+ }
570
+ };
571
+ }
572
+ }),
573
+
503
574
  // #230(#164 设计稿评审线):document 型记忆注册——agent 产长文档的指针
504
575
  // 行铸造口。写入权分离:全文归 agent(管线对文件零读零写零改),库里只
505
576
  // 存摘要 + doc_path + evidence 三样;C2 比对去重、supersede 记账在