@modusensus/dsh-mneme 0.9.1 → 0.9.3

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 (55) hide show
  1. package/README.md +12 -10
  2. package/bin/cli.mjs +61 -7
  3. package/docs/CONFIGURATION.md +26 -2
  4. package/docs/WORKFLOWS.md +70 -3
  5. package/lib/api.js +176 -3
  6. package/lib/capabilities.js +1 -1
  7. package/lib/client.js +170 -16
  8. package/lib/config.js +37 -2
  9. package/lib/dream/sleep.js +35 -12
  10. package/lib/dream.js +86 -14
  11. package/lib/embedding.js +52 -22
  12. package/lib/index.js +15 -0
  13. package/lib/pipeline-nudge-auto.js +126 -0
  14. package/lib/pipeline-nudge.js +337 -28
  15. package/lib/pipeline-runs.js +1 -1
  16. package/lib/pipeline.js +4 -4
  17. package/lib/scope-state.js +277 -0
  18. package/lib/scope.js +30 -1
  19. package/lib/service.js +80 -3
  20. package/lib/settings.js +12 -1
  21. package/lib/store.js +139 -0
  22. package/lib/tools.js +71 -0
  23. package/package.json +1 -1
  24. package/src/api.js +176 -3
  25. package/src/capabilities.js +1 -1
  26. package/src/config.js +37 -2
  27. package/src/dream/sleep.js +35 -12
  28. package/src/dream.js +86 -14
  29. package/src/embedding.js +52 -22
  30. package/src/index.js +15 -0
  31. package/src/pipeline-nudge-auto.js +126 -0
  32. package/src/pipeline-nudge.js +337 -28
  33. package/src/pipeline-runs.js +1 -1
  34. package/src/pipeline.js +4 -4
  35. package/src/scope-state.js +277 -0
  36. package/src/scope.js +30 -1
  37. package/src/service.js +80 -3
  38. package/src/settings.js +12 -1
  39. package/src/store.js +139 -0
  40. package/src/tools.js +71 -0
  41. package/test/api-nudge-route.test.js +259 -0
  42. package/test/api.test.js +118 -1
  43. package/test/cli-flag-value.test.js +86 -0
  44. package/test/client.test.js +103 -4
  45. package/test/distill-tx-embed-gap.test.js +110 -0
  46. package/test/dream-maxtokens-fallback.test.js +132 -0
  47. package/test/embed-text-reasons.test.js +112 -0
  48. package/test/pipeline-nudge-auto.test.js +224 -0
  49. package/test/pipeline-nudge.test.js +161 -6
  50. package/test/readme-claims.test.js +68 -0
  51. package/test/scope-optional-service.test.js +110 -0
  52. package/test/scope-state.test.js +481 -0
  53. package/test/session-usage.test.js +11 -2
  54. package/test/tools-dto-schema-matrix.test.js +12 -3
  55. package/test/tools.test.js +6 -6
package/lib/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 {
@@ -1733,6 +1775,32 @@ export function createStore(path) {
1733
1775
  ).all();
1734
1776
  }
1735
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
+
1736
1804
  function search(query, { limit = 20, includeArchived = false } = {}) {
1737
1805
  const q = String(query).trim();
1738
1806
  if (!q) return [];
@@ -1789,6 +1857,11 @@ export function createStore(path) {
1789
1857
  for (const row of rows) {
1790
1858
  const v = getParsedEmbedding(row.id);
1791
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;
1792
1865
  const score = cosine(vector, v);
1793
1866
  if (score >= threshold) scored.push({ row: { id: row.id }, score });
1794
1867
  }
@@ -2609,6 +2682,66 @@ export function createStore(path) {
2609
2682
  return rows.map(toScopeChange);
2610
2683
  }
2611
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
+
2612
2745
  function getFailureStats({ since } = {}) {
2613
2746
  const clause = since ? "WHERE created_at >= ?" : "";
2614
2747
  const params = since ? [since] : [];
@@ -3233,6 +3366,7 @@ export function createStore(path) {
3233
3366
  needsEmbeddingFor,
3234
3367
  hasStaleEmbedding,
3235
3368
  embeddingModelBreakdown,
3369
+ embeddingDimensionBreakdown,
3236
3370
  searchVector,
3237
3371
  saveDreamRun,
3238
3372
  lastDreamRunAt,
@@ -3281,6 +3415,11 @@ export function createStore(path) {
3281
3415
  countConflictPending,
3282
3416
  saveScopeChange,
3283
3417
  listScopeChanges,
3418
+ // 政策轴(issue #471):按下/松开一个作用域。纯 bookkeeping,不调
3419
+ // incrementGeneration()——它不动任何记忆行,动它只会让所有缓存无谓失效。
3420
+ setScopeState,
3421
+ getScopeState,
3422
+ listScopeStates,
3284
3423
  createEntity,
3285
3424
  findEntityByName,
3286
3425
  findEntityById,
package/lib/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 记账在
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@modusensus/dsh-mneme",
3
3
  "description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 10 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
4
- "version": "0.9.1",
4
+ "version": "0.9.3",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
package/src/api.js CHANGED
@@ -18,6 +18,14 @@ import { loadRunsById, runsDir, RUNS_SUFFIX } from "./pipeline-runs.js";
18
18
  import { observationDir, loadObservationsById } from "./pipeline-observations.js";
19
19
  import { projectKey } from "./candidates.js";
20
20
  import { readCandidateDoc } from "./candidates.js";
21
+ // M.O. 自动沉淀:/nudge 是 pipeline-nudge.js 的第一个运行时消费者(此前它只有测试在用,
22
+ // 是个孤儿模块——「不能自动沉淀工作流」的根因)。
23
+ import {
24
+ resolveProjectSessionDir,
25
+ nudgeForProject,
26
+ NUDGE_BOUNDARIES,
27
+ NUDGE_AUTO_BUDGET_MS
28
+ } from "./pipeline-nudge.js";
21
29
 
22
30
  // headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
23
31
  function sendJson(res, status, payload, headers = {}) {
@@ -207,6 +215,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
207
215
  // feature flags 快照(GET/PUT 共用):overrides 是持久化的用户显式覆盖;
208
216
  // effective 是启动配置在白名单键上被 overrides 覆盖后的最终值。config 缺席
209
217
  // (旧调用方直连、未传 cfg)时只报被覆盖的键,不把不存在的默认值编造给前端。
218
+ //
219
+ // pendingRestart 回答的是第三个问题:「我改的开关,现在真的生效了吗?」
220
+ // 功能开关的生效节奏与 panel_mode 相同——启动时由 index.js 合并进 cfg,运行中
221
+ // 那份 cfg 是冻结的(见 index.js 装配处的注释)。于是面板显示「已改」不等于
222
+ // 运行时已改:#481 实测人格总闸正是这样,面板开着、人格段却不注入,用户只能
223
+ // 靠反复试错发现。effective 表达的是「运行时取什么值」(overrides 优先),
224
+ // **反推不出待重启**,必须另比一次冻结值——这是本字段存在的唯一理由。
225
+ // 只报「显式覆盖 ≠ 冻结值」的键:未被覆盖的键运行时值就是 cfg 值,不存在落差;
226
+ // config 缺席(旧调用方直连、未传 cfg)时无从比较,返回空数组而不编造。
210
227
  const flagKeys = [
211
228
  ...FEATURE_FLAG_SPEC.booleans,
212
229
  ...Object.keys(FEATURE_FLAG_SPEC.ints),
@@ -232,7 +249,11 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
232
249
  // Issue #399:蒸馏原文留存 + 溯源。两个键都是嵌套子字段(enabled 开关 +
233
250
  // retentionDays 整数),漏登记的后果同上——effective 静默缺键。
234
251
  "distillRecords.enabled": ["distillRecords", "enabled"],
235
- "distillRecords.retentionDays": ["distillRecords", "retentionDays"]
252
+ "distillRecords.retentionDays": ["distillRecords", "retentionDays"],
253
+ // Issue #471:政策轴——作用域级可逆闸门。两个键都是嵌套子字段,漏登记的后果
254
+ // 同上(effective 静默缺键、面板读不到也 PUT 不进)。
255
+ "scopeState.enabled": ["scopeState", "enabled"],
256
+ "scopeState.enforce": ["scopeState", "enforce"]
236
257
  // sensitiveScanEnabled(#164 A2)是顶层扁平键,不需要进这张表——走 configFlagValue
237
258
  // 的默认分支即可。
238
259
  };
@@ -241,6 +262,19 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
241
262
  if (path) return config?.[path[0]]?.[path[1]];
242
263
  return config?.[key];
243
264
  }
265
+ // 「显式覆盖 ≠ 冻结值」的键。用 Object.is 比而不是 !==:NaN 与 ±0 的边角上
266
+ // 两者会给出相反的结论(Object.is(NaN, NaN) 为真、Object.is(0, -0) 为假),
267
+ // 开关值里虽然不该出现它们,但比较语义与「值是否相同」对齐更省心。
268
+ // 未被覆盖的键不看:它的运行时值本来就取自 cfg,不存在落差。
269
+ function pendingRestartKeys(overrides) {
270
+ if (!config) return [];
271
+ const pending = [];
272
+ for (const key of flagKeys) {
273
+ if (overrides[key] === undefined) continue;
274
+ if (!Object.is(overrides[key], configFlagValue(key))) pending.push(key);
275
+ }
276
+ return pending;
277
+ }
244
278
  function featureSnapshot() {
245
279
  const overrides = settings.getFeatureFlags();
246
280
  const effective = {};
@@ -251,7 +285,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
251
285
  if (value !== undefined) effective[key] = value;
252
286
  }
253
287
  }
254
- return { overrides, effective };
288
+ return { overrides, effective, pendingRestart: pendingRestartKeys(overrides) };
255
289
  }
256
290
 
257
291
  // Ensure the service has an embedder when the API layer was handed one
@@ -1054,6 +1088,25 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1054
1088
  const configured = embedder ? (typeof embedder.configured === "boolean" ? embedder.configured : true) : null;
1055
1089
  const embedded = Number(stats?.embeddedCount ?? 0);
1056
1090
  const total = Number(stats?.totalCount ?? 0);
1091
+ // Per-model / per-dimension provenance of stored vectors. "embedded"
1092
+ // counts rows with any vector; rows whose dimension differs from the
1093
+ // current embedder output are skipped by searchVector, so the honest
1094
+ // retrievable number is the current-dimension slice. modelBreakdown
1095
+ // groups by row-level embedding_model ('' = unknown origin).
1096
+ const dimensionBreakdown = typeof service.embeddingDimensionBreakdown === "function"
1097
+ ? service.embeddingDimensionBreakdown()
1098
+ : null;
1099
+ const currentDim = embedder?.dimension != null ? String(embedder.dimension) : null;
1100
+ const byDimension = dimensionBreakdown
1101
+ ? [...dimensionBreakdown.entries()].map(([dim, c]) => ({ dimension: dim === "" ? null : Number(dim), count: c }))
1102
+ : [];
1103
+ const unsearchable = (currentDim && dimensionBreakdown)
1104
+ ? [...dimensionBreakdown.entries()]
1105
+ .filter(([dim]) => dim !== currentDim)
1106
+ .reduce((s, [, c]) => s + c, 0)
1107
+ : 0; const modelBreakdown = typeof service.embeddingModelBreakdown === "function"
1108
+ ? service.embeddingModelBreakdown().map((r) => ({ model: r.model === "" ? null : r.model, count: r.c }))
1109
+ : [];
1057
1110
  sendJson(res, 200, {
1058
1111
  ready,
1059
1112
  configured,
@@ -1064,6 +1117,16 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1064
1117
  : null,
1065
1118
  // 空库时 ratio 必须是 null 而非 0 —— 0 会被读成「一条都没嵌上」。
1066
1119
  coverage: { embedded, total, ratio: total > 0 ? embedded / total : null },
1120
+ // distill-tx-embed-gap 后续:向量「存在」≠「可检索」。unsearchable =
1121
+ // 维度与当前 embedder 输出不同的行数(searchVector 会跳过它们),
1122
+ // retrievable = 与当前维度同维、真正参与召回的行数。两者差值非零时
1123
+ // 面板必须能说出「有 N 条向量不可检索」,而不是只给一个虚高的 coverage。
1124
+ vectors: {
1125
+ byDimension,
1126
+ byModel: modelBreakdown,
1127
+ unsearchable,
1128
+ retrievable: currentDim ? (dimensionBreakdown.get(currentDim) ?? 0) : null,
1129
+ },
1067
1130
  // 配了、库里有东西可嵌、却一条都没嵌上 = 真的降级(本机此前长期
1068
1131
  // 处于 ready:true + embeddedCount:0,面板却显示正常)。
1069
1132
  degraded: configured === true && total > 0 && embedded === 0,
@@ -1794,7 +1857,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1794
1857
  }
1795
1858
  });
1796
1859
 
1797
- // --- M.O.(Modus Operandi · 手法)工作流 ---
1860
+ // --- M.O.(Modus Operandi)工作流 ---
1798
1861
  // 只读端点,读的是**项目里**的 `<项目>/.mneme/pipelines/*.json`(文件头在
1799
1862
  // src/pipeline.js),项目层优先、插件包里那份降级成全局层;投影成
1800
1863
  // mneme.pipeline/v1 交给面板与 agent——两边读同一份,画面只是它的投影。
@@ -1937,6 +2000,116 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1937
2000
  }
1938
2001
  });
1939
2002
 
2003
+ // --- M.O. 自动沉淀(nudge)---
2004
+ // 读+写两种方法同路:GET 是**预览**(不落盘),POST 是**真跑**(落提议、可落草稿)。
2005
+ // 为什么不全放 GET:GET 会被浏览器预取、被面板刷新反复调用,而这一跑要解压几十份
2006
+ // zstd 会话日志(同步 I/O,本机实测 16.2s)。预览走 GET 时才关掉提议落盘
2007
+ // (persistProposals:false)——否则每刷一次面板就往 memoryDir 里塞一批文件。
2008
+ //
2009
+ // policy 是 token-write 而不是 open:GET 免 token(与 /pipeline、/candidates 同级的
2010
+ // 只读预览),POST 要 token——它会写盘,写盘面一律受门控。
2011
+ register({
2012
+ kind: "exact",
2013
+ path: "/api/dsh-mneme/nudge",
2014
+ policy: "token-write",
2015
+ handler(req, res) {
2016
+ const method = String(req.method || "GET").toUpperCase();
2017
+ // 只认这四种。为什么必须在**读 body 之前**拦:下面 persistProposals 是按
2018
+ // 「不是 GET/HEAD」算的,于是 PATCH/DELETE 会被当成写动作,走进 run() 落提议;
2019
+ // 而它又不像 PUT/POST 那样读 body,等于一个不需要 body 的静默写盘口。
2020
+ // 这条 PR 自己的卖点是「GET 只预览、写动作一律要 POST」,那就得把口子堵死
2021
+ // (v0.9.3 评审抓出:带 token 的 PATCH/DELETE 会往 memoryDir 里写提议)。
2022
+ const allowed = method === "GET" || method === "HEAD" || method === "POST" || method === "PUT";
2023
+ if (!allowed) {
2024
+ sendJson(res, 405, { error: "method-not-allowed", allow: "GET, HEAD, POST, PUT" }, { Allow: "GET, HEAD, POST, PUT" });
2025
+ return;
2026
+ }
2027
+ const run = (body) => {
2028
+ try {
2029
+ const url = new URL(req.url, "http://localhost");
2030
+ const project = url.searchParams.get("project");
2031
+ // 与 /pipeline、/candidates 同一条规矩:不是绝对路径就拒掉,不能静默退成全局。
2032
+ if (project !== null && !isAbsolute(project)) {
2033
+ sendJson(res, 400, { error: "project-not-absolute", detail: project });
2034
+ return;
2035
+ }
2036
+ // 预算从**这里**起算,不是一个端点两套预算:归属解析(同步扫桶,最多 500 个)
2037
+ // 与后面的挖掘必须共用同一条截止线,否则「真卡就调 budgetMs」对前一段是空话。
2038
+ // POST(显式真跑)给三倍预算,面板预览给短预算。
2039
+ const previewMethod = method === "GET" || method === "HEAD";
2040
+ const startedAt = Date.now();
2041
+ const budgetMs = previewMethod ? NUDGE_AUTO_BUDGET_MS : NUDGE_AUTO_BUDGET_MS * 3;
2042
+ const deadline = startedAt + budgetMs;
2043
+ // 归属解析先做、单独回执:解析不到桶时要能说清是「这个项目还没跑过会话」
2044
+ // 还是「会话目录读不到」——两者在候选列表上都表现为空。
2045
+ const where = resolveProjectSessionDir({ projectRoot: project, deadline });
2046
+ if (!where.sessionDir) {
2047
+ // no-sessions-dir / unreadable / budget-exceeded / bucket-limit 都是**环境问题**
2048
+ // (读不到、没扫完、没扫全),回 503 让调用方知道这不是答案;
2049
+ // no-bucket / bad-project 是**关于这个项目的答案**,回 200。
2050
+ const envProblem = where.reason === "no-sessions-dir"
2051
+ || where.reason === "unreadable"
2052
+ || where.reason === "budget-exceeded"
2053
+ || where.reason === "bucket-limit";
2054
+ sendJson(res, envProblem ? 503 : 200, {
2055
+ error: envProblem
2056
+ ? (where.reason === "budget-exceeded"
2057
+ ? "nudge-attribution-budget-exceeded"
2058
+ : where.reason === "bucket-limit"
2059
+ ? "nudge-attribution-bucket-limit"
2060
+ : "nudge-sessions-unreadable")
2061
+ : undefined,
2062
+ contract: "mneme.nudge/v1",
2063
+ attribution: where,
2064
+ skipped: true,
2065
+ skippedReason: "no-session-attribution",
2066
+ counts: { candidates: 0, buckets: 0, sessionsWithCandidates: 0, savedProposals: 0, draftsWritten: 0 },
2067
+ available: [],
2068
+ drafts: [],
2069
+ boundaries: NUDGE_BOUNDARIES
2070
+ });
2071
+ return;
2072
+ }
2073
+ const memoryDir = (config && typeof config.memoryDir === "string" && config.memoryDir.trim())
2074
+ ? config.memoryDir
2075
+ : "~/.dsh/memory";
2076
+ const doc = nudgeForProject({
2077
+ projectRoot: project,
2078
+ // 上面已经解析过并判过 400/503,这里传进来,别再扫一遍桶。
2079
+ attribution: where,
2080
+ // now 传起算时刻:nudgeForProject 内部按 now+budgetMs 重新算截止点,这样
2081
+ // 两处算出来是同一条线(传 Date.now() 会让挖掘白拿一段已经花掉的预算)。
2082
+ now: startedAt,
2083
+ memoryDir,
2084
+ // GET 只预览:不落提议、不落草稿。写动作只认 POST/PUT——PATCH/DELETE 已在
2085
+ // 入口回 405,否则它们会落进这个「不是 GET/HEAD 就算写」的分支。
2086
+ persistProposals: method !== "GET" && method !== "HEAD",
2087
+ // 落草稿是「往仓库里塞文件」,必须显式要(body.materialize),默认不落。
2088
+ materialize: body && body.materialize === true,
2089
+ // 与归属解析共用同一条预算线:宁可少看几份并如实标 budgetExceeded,
2090
+ // 也不能让一次面板刷新把宿主主线程按住十几秒。
2091
+ budgetMs
2092
+ });
2093
+ sendJson(res, 200, doc);
2094
+ } catch (error) {
2095
+ sendJson(res, 500, {
2096
+ error: "nudge-failed",
2097
+ detail: String((error && error.message) || error)
2098
+ });
2099
+ }
2100
+ };
2101
+ if (method === "PUT" || method === "POST") {
2102
+ readBody(req, res).then((text) => {
2103
+ // readBody 已经回过 413:null 就是「别再往下走」。
2104
+ if (text === null) return;
2105
+ run(parseBody(text));
2106
+ });
2107
+ return;
2108
+ }
2109
+ run(null);
2110
+ }
2111
+ });
2112
+
1940
2113
  return {
1941
2114
  routes: countRoutes(),
1942
2115
  dispose: () => {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * M.O.(Modus Operandi · 手法)能力清单的**读侧**。
2
+ * M.O.(Modus Operandi)能力清单的**读侧**。
3
3
  *
4
4
  * 能力清单 = 「这台机器上、这个项目里,agent 能拿到哪些外部增强」的一份可复核投影。
5
5
  * 三类:技能(注入文本指令)/MCP(注册可执行工具)/插件(加载单元)。三者在 DSH 里
package/src/config.js CHANGED
@@ -863,7 +863,24 @@ export const Config = z.object({
863
863
  // 数据、观察 A2 加权质量后再切硬过滤。
864
864
  strictScope: z.boolean().default(false),
865
865
 
866
- // --- v0.9.0 M.O.(Modus Operandi · 手法,beta)--------------------------
866
+ // scopeState(issue #471):政策轴——作用域级可逆闸门。与上面两个键是**两条不同
867
+ // 的轴**:scopeEnabled/strictScope 管「这条记忆属于谁」(事实轴:持久、跟着记忆
868
+ // 行走、改的是归属数据),本键管「这个作用域现在通不通」(政策轴:暂时、由人单
869
+ // 方面按下、松开即恢复、不改任何记忆行)。
870
+ // 两级语义同 writeAdmission/preInjectGate——enabled 打开时被按下的作用域其候选
871
+ // 照常注入,但留一条审计行说清「本应被拦下几条」(观察档,先在真实流量看会少掉
872
+ // 多少再决定真按);enforce 打开才真的不注入(拦截档)。两个都默认关,打开前
873
+ // 行为与今天逐字段一致。
874
+ // 不进 LIGHT_MODE_OFF:它只是查一张小表的判定,不引入重型后台路径、不加常驻
875
+ // 文本(同 actionPipelineEnabled 的定性)——轻量档预设该关的是重型能力,不该替
876
+ // 用户关掉一个纯判定入口,用户显式勾选仍然赢。
877
+ // 也走 feature_flags(FEATURE_FLAG_BOOLEANS 白名单),面板可启停=线上回滚开关。
878
+ scopeState: z.object({
879
+ enabled: z.boolean().default(false),
880
+ enforce: z.boolean().default(false)
881
+ }).default({}),
882
+
883
+ // --- v0.9.0 M.O.(Modus Operandi,beta)--------------------------
867
884
  // 命名:M.O. = Modus Operandi,拉丁语「一个人一贯的做事方式」,缩写 M.O.;
868
885
  // 也正是 org 词根 modus(modusensus)。界面主显示 M.O.,设置项 hint 第一句
869
886
  // 给全名——缩写不靠注释解释,靠用户第一眼能看见全名。
@@ -885,6 +902,20 @@ export const Config = z.object({
885
902
  // 所以轻量档要关掉:`LIGHT_MODE_OFF` 里有它。
886
903
  actionRunEnabled: z.boolean().default(false),
887
904
 
905
+ // M.O. 自动沉淀(v0.9.0 第三批):会话跑完一轮后,把这个项目自己的会话日志挖一遍,
906
+ // 把「反复出现的同一段能力序列」落成提议(memoryDir 下),可选再落一份 draft 草稿。
907
+ //
908
+ // 与上面两个键三件事分开:actionPipelineEnabled 管「看图的座位」,actionRunEnabled
909
+ // 管「往账本里写」,这个管「从日志里长出候选」。三个都是独立能力,混成一个键会让
910
+ // 「只想看图」的人顺手获得写盘能力。
911
+ //
912
+ // 默认关(beta 必须 opt-in),并且进 LIGHT_MODE_OFF:它挂在 session/event 热路径上,
913
+ // 且解压会话日志是**同步**的(本机 118 份实测 16.2s),轻量档最不该背这条路径。
914
+ // 触发侧另有 10 分钟/项目的冷却与 budgetMs 上限(见 src/pipeline-nudge.js 的
915
+ // NUDGE_AUTO_MIN_INTERVAL_MS 与 src/index.js 的装配注释)——旗标只是总闸。
916
+ // (冷却值以常量为准:早前这里写「30 分钟」,与实际 10 分钟不符。)
917
+ actionNudgeEnabled: z.boolean().default(false),
918
+
888
919
  // --- 人格定义页(beta)---------------------------------------------------
889
920
  // 注入器渲染人格段的顶层总闸(双闸门的 config 半边;user_settings "persona"
890
921
  // 键内的 persona.enabled 是 per-agent 半边,两闸同真才渲染)。API 的 effective
@@ -945,7 +976,11 @@ const LIGHT_MODE_OFF = [
945
976
  // M.O. 执行记账:它挂在**热路径**上(每个会话事件都过一次监听)。轻量档的取向
946
977
  // 是「小模型 / 小上下文 / 少常驻路径」,这与一个每事件都过一遍的监听器相冲突。
947
978
  // 默认关,这里只是轻量预设再确认一次;用户显式勾选仍然赢(合并顺序同上面两条)。
948
- "actionRunEnabled"
979
+ "actionRunEnabled",
980
+ // M.O. 自动沉淀:同样挂在 session/event 热路径上,而且解压会话日志是同步 I/O。
981
+ // 轻量档的取向(小模型 / 小上下文 / 少常驻路径)与它直接冲突,预设里关掉;
982
+ // 用户显式勾选仍然赢(合并顺序同上面几条)。
983
+ "actionNudgeEnabled"
949
984
  ];
950
985
 
951
986
  /**