@modusensus/dsh-mneme 0.11.0 → 0.12.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 (51) hide show
  1. package/README.md +10 -6
  2. package/docs/CONFIGURATION.md +2 -0
  3. package/lib/api.js +172 -6
  4. package/lib/client.js +619 -18
  5. package/lib/config.js +32 -1
  6. package/lib/document.js +9 -3
  7. package/lib/dream/clustering.js +6 -1
  8. package/lib/index.js +40 -4
  9. package/lib/inject.js +4 -2
  10. package/lib/lang.js +28 -0
  11. package/lib/profile-facts.js +356 -0
  12. package/lib/service.js +109 -4
  13. package/lib/settings.js +5 -0
  14. package/lib/store.js +483 -34
  15. package/lib/tools.js +140 -23
  16. package/lib/user-settings.js +63 -7
  17. package/lib/wiki/queries.js +149 -9
  18. package/package.json +1 -1
  19. package/scripts/sync-test-badge.mjs +1 -1
  20. package/scripts/test-count-sync.mjs +9 -5
  21. package/src/api.js +172 -6
  22. package/src/config.js +32 -1
  23. package/src/document.js +9 -3
  24. package/src/dream/clustering.js +6 -1
  25. package/src/index.js +40 -4
  26. package/src/inject.js +4 -2
  27. package/src/lang.js +28 -0
  28. package/src/profile-facts.js +356 -0
  29. package/src/service.js +109 -4
  30. package/src/settings.js +5 -0
  31. package/src/store.js +483 -34
  32. package/src/tools.js +140 -23
  33. package/src/user-settings.js +63 -7
  34. package/src/wiki/queries.js +149 -9
  35. package/test/api.test.js +3 -1
  36. package/test/client.test.js +147 -2
  37. package/test/config.test.js +79 -0
  38. package/test/document.test.js +32 -0
  39. package/test/embedding-cache-f32.test.js +276 -0
  40. package/test/inject-pin-explicit.test.js +300 -0
  41. package/test/inject-pin-vector-crowd.test.js +225 -0
  42. package/test/persona-settings.test.js +31 -0
  43. package/test/profile-facts.test.js +492 -0
  44. package/test/session-gate.test.js +1 -1
  45. package/test/test-count-sync.test.js +14 -3
  46. package/test/tool-state-ignore.test.js +65 -0
  47. package/test/tools-dto-schema-matrix.test.js +23 -2
  48. package/test/tools.test.js +144 -7
  49. package/test/wiki-api.test.js +121 -1
  50. package/test/wiki-queries.test.js +84 -1
  51. package/test/wiki-tools.test.js +131 -76
package/lib/tools.js CHANGED
@@ -11,6 +11,8 @@ import { normalizeScopeStateDimension, normalizeScopeStateValue, normalizeScopeS
11
11
  import { lastUserQuery } from "./inject.js";
12
12
 
13
13
  const TEXT_OUTPUT = (text) => [{ type: "text", text }];
14
+ const incompleteChoroSearch = (pages) =>
15
+ `Search was incomplete because these pages could not be read (${pages.join(", ")}). Check the local checkout and page permissions.`;
14
16
  // Per-registry tool-name registry: guards against duplicate registration on
15
17
  // live patch reload (DSH Desktop `patchReload: "live"`) where a plugin may be
16
18
  // re-applied on the same tools registry without an intervening unregister.
@@ -92,14 +94,17 @@ export function createTools(ctx, service, config, embedder, sessionGate = null,
92
94
  // flag 关闭时恒返回 null——写入不标注,检索不加权,行为与 A1 前完全一致。
93
95
  // logger 透传:registry 反查失败时 warnOnce 才有出口(否则静默降级无观测)。
94
96
  const resolveSessionScope = createScopeResolver({ ctx, config, logger: ctx.logger });
95
- // choro 工具共用的 repo 解析(M1):不带 repo 且只有一个登记仓时自动收敛;
96
- // 多仓歧义时把可选清单带回给模型(render 层会把它转成人话)。三个 execute 里
97
- // 都先走这里,错误形状统一 { error, ... },output.schema 容忍多余键由
98
- // additionalProperties:false + render 兜底——错误分支直接透传给渲染层判。
97
+ // 宿主在 render 前按成功 schema 校验返回值,仓库解析失败要走工具错误通道。
99
98
  const resolveChoroRepo = (ref) => {
100
- const row = choro ? choro.resolveRepo(ref) : null;
101
- if (!row) return { error: "no-repos", hint: "run `dsh-mneme-choro <repo> --apply` first (registers the repository)" };
102
- if (row.ambiguous) return { error: "repo-ambiguous", repos: row.ambiguous, hint: "pass repo id or name" };
99
+ const row = choro.resolveRepo(ref);
100
+ if (!row) {
101
+ const reason = ref ? `Repository ${JSON.stringify(ref)} is not registered.` : "No repository is registered for chorography.";
102
+ throw new Error(`${reason} Run \`dsh-mneme-choro <repo> --apply\` to register a repository.`);
103
+ }
104
+ if (row.ambiguous) {
105
+ const choices = row.ambiguous.map(({ id, name }) => `${id} (${name})`).join(", ");
106
+ throw new Error(`More than one repository is registered. Pass one of these repository IDs (${choices}).`);
107
+ }
103
108
  return row;
104
109
  };
105
110
  // #249 能力说明(injectGuidanceEnabled):开启时给「何时用」最有歧义的两个
@@ -479,6 +484,110 @@ export function createTools(ctx, service, config, embedder, sessionGate = null,
479
484
  }
480
485
  }),
481
486
 
487
+ // #249 第二批:显式 pin。注入侧原来靠「type 是 constraint/preference 就算
488
+ // 重要」推断谁该每轮置顶,那是隐含假设——库里 1100+ 条自动成了「每轮必进」
489
+ // 的候选而预算只有 5,真正该进去的反而排到二十名外。这个工具是那条推断的
490
+ // 唯一人工替代口:钉住 = 每轮注入块最前面那几条(受 pinnedInjectBudget 约束),
491
+ // 与类型无关(写得最全的那版常常是 pattern)。
492
+ //
493
+ // 三个边界写在这里:
494
+ // - 它不改记忆内容,也不改任何「记忆行」字段,只动 pinned_memories 这张关系
495
+ // 表,所以与 memory_forget / memory_archive / set_scope_state 都是**不同的轴**:
496
+ // 遗忘/归档/暂停作用域仍然优先——被它们处理掉的行就算钉着也不会注入,
497
+ // 回执里的 injectable 就是如实告诉我们这件事。
498
+ // - 存在性校验走 visibleForTool:strictScope 下 he scope 的行按「不存在」处理,
499
+ // 与 memory_delete 同款无存在性泄漏。
500
+ // - unpin 幂等:本来没钉也返回 pinned:false,不报错。
501
+ defineTool({
502
+ name: "memory_pin",
503
+ description:
504
+ "Pin a memory so it is injected at the top of the memory block every turn, regardless of its type or how it ranks against other memories. " +
505
+ "Use this only when the user explicitly says an entry must always be present (a hard rule, a standing preference); it is not a way to mark something as important — importance already affects ranking. " +
506
+ "Pinned entries still obey forgetting, archiving, the scope gate and the two injection switches: when autoInject is off the host registers no injector at all, and when pinnedInjectBudget is 0 the pin pool is off (its default). In either case a pinned entry is not injected through the pin channel, and the receipt reports injectable: false. The receipt answers \"is the pin channel open right now\", not \"is this memory kept out of every block\": a row reported false can still be picked up by the ordinary candidate path on its own merits. " +
507
+ "Pass unpin: true to release it. Pin order is first-pinned-first, so the oldest pin holds its slot when the budget is tight.",
508
+ parameters: {
509
+ id: { type: "string", required: true, description: "Memory id to pin (or unpin with unpin: true)" },
510
+ note: { type: "string", description: "Optional short reason to keep with the pin, so a later reader knows why it is pinned" },
511
+ unpin: { type: "boolean", description: "true releases the pin instead of setting it" }
512
+ },
513
+ output: {
514
+ schema: {
515
+ type: "object",
516
+ additionalProperties: false,
517
+ properties: {
518
+ memory_id: { type: "string", required: true },
519
+ pinned: { type: "boolean", required: true },
520
+ // 如实回显库现值,不是回显入参:重钉一条已有的 pin 不刷新 created_at,
521
+ // 回显它才看得出「这次其实没改变顺序」。
522
+ created_at: { type: "string" },
523
+ note: { type: "string" },
524
+ // 钉着 ≠ 会注入。遗忘/归档/暂停作用域/autoInject 关/pinnedInjectBudget=0
525
+ // 都优先于 pin,把这件事放进回执,而不是让用户以为钉完就一定出现在上下文里。
526
+ // 回执回答的是「pin 这条通道此刻开不开」:报 false 不等于这条记忆一定不在
527
+ // 上下文里——它仍可能凭自身重要性走常规候选路进块。
528
+ // autoInject 是这条通道的总闸:关着时宿主根本不注册注入器
529
+ // (src/index.js:617 的 if (cfg.autoInject)),pin 段自然也不执行。
530
+ injectable: { type: "boolean", required: true }
531
+ }
532
+ },
533
+ render: (_args, value) => {
534
+ if (!value.pinned) return TEXT_OUTPUT(`Memory ${value.memory_id} is no longer pinned.`);
535
+ const when = value.created_at ? ` (pinned at ${value.created_at})` : "";
536
+ const blocked = value.injectable
537
+ ? ""
538
+ : " The pin channel is closed right now (forgotten, archived, inside a paused scope, autoInject off, or pinnedInjectBudget is 0 — the pin pool is off by default), so it is not placed at the top of the block; the ordinary candidate path can still pick it up on its own merits.";
539
+ return TEXT_OUTPUT(`Memory ${value.memory_id} is pinned${when}.${blocked}`);
540
+ }
541
+ },
542
+ async execute(args) {
543
+ // 不可见(explicit 他 scope)= 视作不存在,无存在性泄漏。
544
+ const target = visibleForTool(args.id, arguments[1]);
545
+ if (target === undefined) throw new Error("memory not found");
546
+ // 钉着 ≠ 会注入。这里必须跟注入侧用同一个出口判定,否则回执照样会说谎:
547
+ // 暂停作用域的行在 enforce 档会被 scopeStateGate 剔掉,但 forgotten/archived
548
+ // 两个标志位对此一无所知——只算这两项,一条被暂停作用域挡住的行就会报
549
+ // injectable: true 而实际一条都没注入。filterPaused 正是那个出口(开关关、
550
+ // 或只开观察档时它原样返回,于是此时报 true 是对的:那种状态下本来就不拦)。
551
+ // #593 遗留缺口:pin 池整体由 pinnedInjectBudget 开关(src/service.js:1885 的
552
+ // pinnedBudget > 0 才读表选路),而它默认 0 = 关闭。只看遗忘/归档/暂停作用域时,
553
+ // 预算为 0 的一条回执照样报 injectable: true——钉住的动作确实成功了,但它此刻
554
+ // 一条都不会被注入,这正是「返回成功不等于生效」。预算 0 必须报 false。
555
+ //
556
+ // 同一类谎话的第二处(CodeRabbit 在 #596 指出):autoInject 才是 pin 通道的总闸
557
+ // ——关着时 src/index.js:617 根本不注册注入器,pin 段无从执行,而回执仍会报
558
+ // true。config schema 里它的默认值是 true(src/config.js:10),所以按「只有显式
559
+ // false 才算关」判:拿不到已填 defaults 的 config(测试直连 createTools)时不会
560
+ // 被误报成 false。
561
+ //
562
+ // 仍然没建模的:候选池侧的 type / importance / codingGate 闸(一条被钉的
563
+ // document 行同样进不了注入块)。那几道闸随候选池变化,回执不做猜测;
564
+ // 这里只回答「pin 这条通道此刻开不开」——报 false 也不等于这条记忆一定不在
565
+ // 上下文里,它仍可能凭自身重要性走常规候选路进块。
566
+ const pinnedBudget = Math.max(0, Math.min(5, Math.floor(config?.pinnedInjectBudget ?? 0)));
567
+ const autoInjectOn = config?.autoInject !== false;
568
+ const injectable =
569
+ autoInjectOn &&
570
+ pinnedBudget > 0 &&
571
+ !target.forgotten &&
572
+ !target.archived &&
573
+ service.filterPaused([target]).length > 0;
574
+ if (args.unpin === true) {
575
+ service.unpinMemory(target.id);
576
+ return { memory_id: target.id, pinned: false, injectable };
577
+ }
578
+ const row = service.pinMemory(target.id, { note: args.note });
579
+ if (row === undefined) throw new Error("pin could not be recorded");
580
+ return {
581
+ memory_id: row.memory_id,
582
+ pinned: true,
583
+ created_at: row.created_at,
584
+ // 没写过理由时键整个不出现(output schema 是闭合的,留 undefined 等于空壳)。
585
+ ...(row.note === undefined ? {} : { note: row.note }),
586
+ injectable
587
+ };
588
+ }
589
+ }),
590
+
482
591
  defineTool({
483
592
  name: "memory_archive",
484
593
  description:
@@ -1081,7 +1190,6 @@ export function createTools(ctx, service, config, embedder, sessionGate = null,
1081
1190
  },
1082
1191
  async execute(args) {
1083
1192
  const repo = resolveChoroRepo(args?.repo);
1084
- if (repo.error) return repo;
1085
1193
  const { modules, edges } = choro.moduleMap(repo.id);
1086
1194
  return { repo: repo.name, modules, edges };
1087
1195
  }
@@ -1107,10 +1215,16 @@ export function createTools(ctx, service, config, embedder, sessionGate = null,
1107
1215
  },
1108
1216
  async execute(args) {
1109
1217
  const repo = resolveChoroRepo(args?.repo);
1110
- if (repo.error) return repo;
1111
1218
  const page = choro.readPage(repo.id, args?.slug);
1112
- if (!page) return { error: "page-not-found", slug: String(args?.slug ?? ""), hint: "use choro_module_map to list slugs" };
1113
- if (page.error) return page;
1219
+ if (!page) {
1220
+ throw new Error(`Page ${JSON.stringify(args?.slug ?? "")} was not found. Use choro_module_map to list page slugs.`);
1221
+ }
1222
+ if (page.error === "page-missing") {
1223
+ throw new Error(`Page ${JSON.stringify(page.slug)} is missing from disk (${page.relPath}). Check the local checkout or run \`dsh-mneme-choro <repo> --apply\` to rebuild it.`);
1224
+ }
1225
+ if (page.error) {
1226
+ throw new Error(`Page ${JSON.stringify(page.slug)} could not be read (${page.relPath}). Check the local file and its permissions.`);
1227
+ }
1114
1228
  return { slug: page.slug, content: page.content };
1115
1229
  }
1116
1230
  }),
@@ -1129,26 +1243,29 @@ export function createTools(ctx, service, config, embedder, sessionGate = null,
1129
1243
  type: "object",
1130
1244
  additionalProperties: false,
1131
1245
  properties: {
1132
- hits: { type: "array", required: true, items: { type: "object", additionalProperties: true } }
1246
+ hits: { type: "array", required: true, items: { type: "object", additionalProperties: true } },
1247
+ unreadablePages: { type: "array", items: { type: "string" }, description: "Page slugs that could not be read during the search" }
1133
1248
  }
1134
1249
  },
1135
1250
  render: (_args, value) => {
1136
- const hits = value.hits ?? [];
1251
+ const { hits, unreadablePages } = value;
1137
1252
  if (hits.length === 0) return TEXT_OUTPUT("0 hits.");
1138
- return TEXT_OUTPUT(hits
1139
- .map((hit) => `${hit.slug} · ${hit.section} · L${hit.line}: ${hit.excerpt}`)
1140
- .join("\n"));
1253
+ const lines = hits.map((hit) => `${hit.slug} · ${hit.section} · L${hit.line}: ${hit.excerpt}`);
1254
+ if (unreadablePages?.length) lines.push("", incompleteChoroSearch(unreadablePages));
1255
+ return TEXT_OUTPUT(lines.join("\n"));
1141
1256
  }
1142
1257
  },
1143
1258
  async execute(args) {
1144
1259
  const repo = resolveChoroRepo(args?.repo);
1145
- if (repo.error) return repo;
1146
- return {
1147
- hits: choro.search(repo.id, args?.query, {
1148
- moduleSlug: args?.module ?? null,
1149
- limit: args?.limit
1150
- })
1151
- };
1260
+ const unreadablePages = [];
1261
+ const hits = choro.search(repo.id, args?.query, {
1262
+ moduleSlug: args?.module ?? null,
1263
+ limit: args?.limit,
1264
+ onUnreadablePage: (slug) => unreadablePages.push(slug)
1265
+ });
1266
+ if (unreadablePages.length === 0) return { hits };
1267
+ if (hits.length === 0) throw new Error(incompleteChoroSearch(unreadablePages));
1268
+ return { hits, unreadablePages };
1152
1269
  }
1153
1270
  })
1154
1271
  ];
@@ -1,5 +1,5 @@
1
1
  // dsh-mneme/src/user-settings.js
2
- // 「用户设置」注入块(人格 / 画像 / 规则)的唯一渲染处。
2
+ // 「用户设置」注入块(人格 / 画像 / 自动画像事实 / 规则)的唯一渲染处。
3
3
  //
4
4
  // 为什么要单独成模块:面板「Agent 眼中的画像」那张卡原本自己在前端拼了一份 profile +
5
5
  // rules 的列表,注释写着「注入生效文本」——但它没有表头、没有「- 用户画像:」前缀,
@@ -7,10 +7,18 @@
7
7
  // 「看着像」。现在注入器与预览路由调同一个函数,前端只显示后端给的文本。
8
8
  //
9
9
  // 顺序是设计口径(人格设计稿⑥):特化指令(人格)先出,画像与规则后出、后写覆盖占
10
- // 上风,rules 恒最硬。改这个顺序等于改提示词权重,别顺手调。
10
+ // 上风,rules 恒最硬。改这个顺序等于改提示词权重,别顺手调。自动画像事实(2026-11-06,
11
+ // src/profile-facts.js)插在画像之后、规则之前——它是「画像的 agent 侧补充」,与
12
+ // 手填画像物理分离(模型对 user_settings.profile 永远只有读权)。
11
13
 
12
14
  import { STR, langOf } from "./lang.js";
13
15
 
16
+ // 事实段的注入预算(本块此前无任何截断机制——它是「身份文本」,从不轮换)。
17
+ // 不设上限的话,一次失控的维护轮能把每帧 systemPrompt 撑爆;行数与总字符双帽,
18
+ // 截断不静默(尾注如实说还剩几条,与 #164① 同一纪律)。
19
+ const PROFILE_FACTS_MAX_LINES = 40;
20
+ const PROFILE_FACTS_MAX_CHARS = 1200;
21
+
14
22
  /**
15
23
  * 提示词变量转义:宿主模板插值对 `{{`/`}}` 会按变量名解析并在缺失时抛错,整轮崩掉。
16
24
  * 所以把 2 个以上连续花括号逐字符插 `\`,让任何 `{{`/`}}` 子串都不残留;转换幂等,
@@ -35,22 +43,49 @@ export function escapePromptVars(config, text) {
35
43
  * - 开:不注入人格(`source: "none"`)。default 退回它的本意:只兜「这一帧解析不到
36
44
  * 身份」的会话(无名会话本来就是 default 卡的受众)。
37
45
  * 默认关是有意的:这是一次口径变更,不能让老用户的注入文本在升级后静默变化。
46
+ *
47
+ * 严格模式有一格例外(#587):`byAgent` **整体为空**时仍回落 default。两种处境必须
48
+ * 分开——「建了卡、只是没这张身份的卡」是真该按 none 处理(否则关掉一张卡等于没关),
49
+ * 而「一张身份卡都没建」时 default 是用户手里唯一的一张卡,按 none 处理等于把它永久
50
+ * 废掉,且面板不报错。DS Bot 这类宿主里所有 bot 会话共用 `agentPreset: "standard"`
51
+ * (`ds-bot/src/host/roster.js:72-80` 强制写入),用户按 bot 名字建卡永远不会命中,
52
+ * 于是必然落进后一种处境:开了严格寻址就把唯一那张卡静默关死。
38
53
  */
39
54
  export function resolvePersonaCard(settings, agentId, { strict = false } = {}) {
40
55
  const store = settings.getPersona();
41
56
  const id = typeof agentId === "string" && agentId.trim() ? agentId.trim() : "";
42
57
  const card = id ? store.byAgent[id] : null;
43
58
  if (card && card.enabled === true) return { card, source: "byAgent" };
44
- if (id && strict) return { card: {}, source: "none" };
59
+ // 严格模式只在「确实建过身份卡」时才拒绝兜底:一张都没建时兜底是唯一的出路。
60
+ if (id && strict && Object.keys(store.byAgent).length > 0) return { card: {}, source: "none" };
45
61
  return { card: store.default, source: "default" };
46
62
  }
47
63
 
64
+ /**
65
+ * 按会话身份取自动事实卡(resolvePersonaCard 的镜像,形状不同:facts 没有
66
+ * enabled 位,「卡存在」就是非空数组)。strict 语义与人格一致:有身份但没这张
67
+ * 身份的卡时不落 default——两个 bot 对用户的认知不互串是 store 层分区(输入行)
68
+ * 与本层寻址(输出注入)共同守住的边界。与人格同一条例外(#587):`byAgent` 整体
69
+ * 为空时仍落 default,「一张都没建」不等于「这一张没建」。
70
+ */
71
+ export function resolveFactsCard(factsStore, agentId, { strict = false } = {}) {
72
+ const id = typeof agentId === "string" && agentId.trim() ? agentId.trim() : "";
73
+ const byAgent = factsStore?.byAgent ?? {};
74
+ const card = id ? (byAgent[id] ?? null) : null;
75
+ if (card && card.length) return { facts: card, source: "byAgent" };
76
+ if (id && strict && Object.keys(byAgent).length > 0) return { facts: [], source: "none" };
77
+ return { facts: factsStore?.default ?? [], source: "default" };
78
+ }
79
+
48
80
  /**
49
81
  * 渲染整块「用户设置」。`agentId` 是会话身份(解析不到传 null → 走 default 兜底)。
50
- * 返回 `{ text, source, agent, configGate }`:`text` 为最终注入文本(全空时 ""),
51
- * `configGate` 是 personaEnabled 的现值——面板要靠它解释「卡开着为什么没生效」。
82
+ * `factsProvider` 是 profile-facts 域的读口(返回 `{ default: [...], byAgent: {...} }`),
83
+ * 可选;**开关与 provider 双闸**——profileFactsEnabled 关着时连 provider 都不调,
84
+ * 「关」必须是零读数而不是「读了不渲染」。返回 `{ text, source, agent, configGate }`:
85
+ * `text` 为最终注入文本(全空时 ""),`configGate` 是 personaEnabled 的现值——面板
86
+ * 要靠它解释「卡开着为什么没生效」。
52
87
  */
53
- export function renderUserSettingsBlock({ settings, config, agentId = null }) {
88
+ export function renderUserSettingsBlock({ settings, config, agentId = null, factsProvider = null }) {
54
89
  const language = langOf(config);
55
90
  const profile = settings.getProfile().trim();
56
91
  const rules = settings.getRules();
@@ -66,13 +101,34 @@ export function renderUserSettingsBlock({ settings, config, agentId = null }) {
66
101
  ...(typeof persona.replyStyle === "string" && persona.replyStyle.trim() ? [STR.personaReplyStyleLine[language](persona.replyStyle.trim())] : [])
67
102
  ]
68
103
  : [];
104
+ // 事实段:开关关 / 无 provider / 该卡没事实三种情况都不渲染,注入文本与
105
+ // 未启用事实时逐字节一致(老用户零变化)。
106
+ const factsResolved = config?.profileFactsEnabled === true && typeof factsProvider === "function"
107
+ ? resolveFactsCard(factsProvider(), agentId, { strict: config?.personaStrictAddressing === true })
108
+ : { facts: [], source: "none" };
109
+ const factLines = [];
110
+ if (factsResolved.facts.length) {
111
+ let chars = 0;
112
+ let shown = 0;
113
+ for (const fact of factsResolved.facts) {
114
+ const line = STR.profileFactLine[language](String(fact));
115
+ if (shown >= PROFILE_FACTS_MAX_LINES || chars + line.length > PROFILE_FACTS_MAX_CHARS) break;
116
+ factLines.push(line);
117
+ chars += line.length;
118
+ shown += 1;
119
+ }
120
+ const remaining = factsResolved.facts.length - shown;
121
+ if (remaining > 0) factLines.push(STR.profileFactsTruncated[language](remaining));
122
+ if (factLines.length) factLines.unshift(STR.profileFactsHeader[language](factsResolved.facts.length));
123
+ }
69
124
  const id = typeof agentId === "string" && agentId.trim() ? agentId.trim() : null;
70
- if (!profile && !rules.length && !personaLines.length) {
125
+ if (!profile && !rules.length && !personaLines.length && !factLines.length) {
71
126
  return { text: "", source: resolved.source, agent: id, configGate: enabled };
72
127
  }
73
128
  const lines = [STR.userSettingsHeader[language]];
74
129
  lines.push(...personaLines);
75
130
  if (profile) lines.push(STR.profileLine[language](profile));
131
+ lines.push(...factLines);
76
132
  for (const rule of rules) lines.push(STR.ruleLine[language](rule));
77
133
  return { text: escapePromptVars(config, lines.join("\n")), source: resolved.source, agent: id, configGate: enabled };
78
134
  }
@@ -1,6 +1,8 @@
1
1
  // dsh-mneme/src/wiki/queries.js
2
2
  // chorography 的读侧(M1):仓库列表 / 模块图 / 页面正文 / 关键词检索。
3
- // 只读,零写路径——写入口只有 M0 的 t0.js(红线 3:模型侧一个写工具都不注册)。
3
+ // 收尾档追加:向量检索(P37–P39,惰性物化 + cosine)。
4
+ // 检索读侧零写路径的例外只有一处——向量物化写 choro_vectors(P37:检索时惰性
5
+ // 补嵌,host 侧才有 embedder;那是向量账本,不是页面真源)。
4
6
  //
5
7
  // 真源纪律(spec §4 / P12):页面正文永远读仓库磁盘的 MD(root_path + DB 里的
6
8
  // rel_path),库里的表只是索引与状态账。slug 只从 DB 白名单解析,路径拼装零用户
@@ -8,6 +10,36 @@
8
10
  import { readFileSync, existsSync, realpathSync } from "node:fs";
9
11
  import { join, resolve, sep } from "node:path";
10
12
 
13
+ // 页正文去 frontmatter / CHORO 标记行后作为嵌入文本(P40:嵌页级全文)。
14
+ function pageEmbedText(content) {
15
+ const lines = String(content ?? "").split("\n");
16
+ let start = 0;
17
+ if (lines[0]?.trim() === "---") {
18
+ start = 1;
19
+ while (start < lines.length && lines[start].trim() !== "---") start++;
20
+ start++;
21
+ }
22
+ return lines
23
+ .slice(start)
24
+ .filter((l) => !l.startsWith("<!-- CHORO:T1:") && !l.startsWith("<!-- CHORO:HUMAN:"))
25
+ .join("\n")
26
+ .trim();
27
+ }
28
+
29
+ function cosine(a, b) {
30
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || a.length === 0) return 0;
31
+ let dot = 0;
32
+ let na = 0;
33
+ let nb = 0;
34
+ for (let i = 0; i < a.length; i++) {
35
+ dot += a[i] * b[i];
36
+ na += a[i] * a[i];
37
+ nb += b[i] * b[i];
38
+ }
39
+ const denom = Math.sqrt(na) * Math.sqrt(nb);
40
+ return denom === 0 ? 0 : dot / denom;
41
+ }
42
+
11
43
  export function createChoroQueries({ store }) {
12
44
  const db = store.db;
13
45
 
@@ -54,8 +86,8 @@ export function createChoroQueries({ store }) {
54
86
  }
55
87
 
56
88
  /**
57
- * 读一页正文。slug 必须命中 DB 的模块行(P12:路径零用户输入);磁盘文件被手删
58
- * 时返回 { error: "page-missing" }(索引还没重算,读侧如实报缺,不伪造内容)。
89
+ * 读一页正文。slug 必须命中 DB 的模块行(P12:路径零用户输入);索引仍在但
90
+ * 文件缺失或不可读时带回相对路径,让调用方给出对应的恢复提示。
59
91
  */
60
92
  function readPage(repoId, slug) {
61
93
  const row = db.prepare(
@@ -67,7 +99,13 @@ export function createChoroQueries({ store }) {
67
99
  if (!row) return null;
68
100
  const repo = db.prepare("SELECT root_path, name FROM choro_repos WHERE id = ?").get(repoId);
69
101
  const abs = join(repo.root_path, row.rel_path);
70
- if (!existsSync(abs)) return { error: "page-missing", slug: row.slug, relPath: row.rel_path };
102
+ let content;
103
+ try {
104
+ content = readFileSync(abs, "utf8");
105
+ } catch (cause) {
106
+ const error = cause.code === "ENOENT" || cause.code === "ENOTDIR" ? "page-missing" : "page-unreadable";
107
+ return { error, slug: row.slug, relPath: row.rel_path };
108
+ }
71
109
  return {
72
110
  slug: row.slug,
73
111
  title: row.title,
@@ -79,16 +117,17 @@ export function createChoroQueries({ store }) {
79
117
  humanBlockCount: row.human_block_count,
80
118
  repoName: repo.name,
81
119
  relPath: row.rel_path,
82
- content: readFileSync(abs, "utf8"),
120
+ content,
83
121
  };
84
122
  }
85
123
 
86
124
  /**
87
125
  * 关键词检索(P10):按 `## ` 分节做大小写不敏感子串匹配,命中返回页 slug、
88
126
  * 节标题、起始行号与摘录。只扫 DB 里登记过的页面文件(不是全目录 glob——
89
- * 划分变更后未重算的孤儿文件不该被搜出来)。q 为空返回空数组不报错。
127
+ * 划分变更后未重算的孤儿文件不该被搜出来)。需要提示检索不完整的调用方可接收
128
+ * 不可读的页 slug。q 为空返回空数组不报错。
90
129
  */
91
- function search(repoId, q, { moduleSlug = null, limit = 20 } = {}) {
130
+ function search(repoId, q, { moduleSlug = null, limit = 20, onUnreadablePage } = {}) {
92
131
  const needle = String(q ?? "").trim().toLowerCase();
93
132
  if (needle === "") return [];
94
133
  const cap = Math.max(1, Math.min(100, Number(limit) || 20));
@@ -102,11 +141,11 @@ export function createChoroQueries({ store }) {
102
141
  const hits = [];
103
142
  for (const page of pages) {
104
143
  const abs = join(repo.root_path, page.rel_path);
105
- if (!existsSync(abs)) continue;
106
144
  let text;
107
145
  try {
108
146
  text = readFileSync(abs, "utf8");
109
147
  } catch {
148
+ onUnreadablePage?.(page.slug);
110
149
  continue;
111
150
  }
112
151
  const lines = text.split("\n");
@@ -216,5 +255,106 @@ export function createChoroQueries({ store }) {
216
255
  };
217
256
  }
218
257
 
219
- return { listRepos, resolveRepo, moduleMap, readPage, search, repoByCwd, outline };
258
+ // --- chorography 向量(收尾档 P37–P39)---------------------------------
259
+ // 惰性物化(P37):只在 host 侧检索路径调用(embedder 由 index.js 注入);
260
+ // CLI 环境没有 embedder → 向量永不物化,检索退关键词。staleness 两维:
261
+ // 页 generated_at 变了(内容变了)、行 dim ≠ 本次查询向量 dim(换 embedder
262
+ // 模型了,P38)→ 重嵌。dim 失配是全表性的,遇到一次就把整仓重嵌(几十页)。
263
+ async function embedPageVectors(repoId, embedder, { limit = 200, expectDim = null } = {}) {
264
+ const pages = db.prepare(
265
+ `SELECT p.id, p.generated_at, m.slug, m.title, p.rel_path
266
+ FROM choro_pages p JOIN choro_modules m ON p.module_id = m.id
267
+ WHERE m.repo_id = ? ORDER BY m.slug`
268
+ ).all(repoId);
269
+ const repo = db.prepare("SELECT root_path FROM choro_repos WHERE id = ?").get(repoId);
270
+ const embedOne = embedder.embedSingle ? (t) => embedder.embedSingle(t) : (t) => embedder.embed(t);
271
+ const rows = store.getChoroPageVectors(repoId);
272
+ const bySlug = new Map(rows.map((r) => [r.slug, r]));
273
+ const result = { embedded: 0, skipped: 0, stale: 0, failed: [], dimMismatch: false };
274
+ // dim 失配的全量重嵌(P38):expectDim 来自查询向量(调用方先嵌查询再物化,
275
+ // 维度免费拿到)。expectDim 与任何现存行失配 → 本轮 fresh 检查失效,全量重嵌;
276
+ // 没给 expectDim(无查询场景)退化为只按 generated_at 判 staleness。
277
+ const dimDrift = expectDim !== null && rows.some((r) => r.dim !== expectDim);
278
+ if (dimDrift) result.dimMismatch = true;
279
+ for (const page of pages) {
280
+ if (result.embedded >= limit) break;
281
+ const row = bySlug.get(page.slug);
282
+ const fresh = !dimDrift && row && row.source_generated_at === page.generated_at && row.vector;
283
+ if (fresh) { result.skipped++; continue; }
284
+ if (row && row.vector) result.stale++;
285
+ const abs = join(repo.root_path, page.rel_path);
286
+ if (!existsSync(abs)) continue;
287
+ let text;
288
+ try {
289
+ text = pageEmbedText(readFileSync(abs, "utf8"));
290
+ } catch {
291
+ result.failed.push({ slug: page.slug, reason: "page-unreadable" });
292
+ continue;
293
+ }
294
+ if (text.trim() === "") { result.skipped++; continue; }
295
+ let vector;
296
+ try {
297
+ vector = await embedOne(text);
298
+ } catch (err) {
299
+ result.failed.push({ slug: page.slug, reason: String(err?.message ?? err).slice(0, 120) });
300
+ continue;
301
+ }
302
+ if (!Array.isArray(vector) || vector.length === 0) {
303
+ result.failed.push({ slug: page.slug, reason: "embedder-returned-no-vector" });
304
+ continue;
305
+ }
306
+ if (row && row.dim !== vector.length) result.dimMismatch = true;
307
+ store.saveChoroPageVector(page.id, {
308
+ vector,
309
+ dim: vector.length,
310
+ model: String(embedder.model ?? "choro"),
311
+ sourceGeneratedAt: page.generated_at,
312
+ createdAt: new Date().toISOString(),
313
+ });
314
+ result.embedded++;
315
+ }
316
+ return result;
317
+ }
318
+
319
+ /** 嵌查询文本(宿主侧)。embedder 缺席 / 不可用 → null。 */
320
+ async function embedQueryText(embedder, q) {
321
+ if (!embedder) return null;
322
+ const embedOne = embedder.embedSingle ? (t) => embedder.embedSingle(t) : (t) => embedder.embed(t);
323
+ try {
324
+ const v = await embedOne(String(q ?? ""));
325
+ return Array.isArray(v) && v.length > 0 ? v : null;
326
+ } catch {
327
+ return null;
328
+ }
329
+ }
330
+
331
+ /** 纯余弦 Top-N:向量已给定时零嵌入。行 dim 与查询失配的行过滤掉(P38)。 */
332
+ function semanticTopN(repoId, queryVector, { limit = 10 } = {}) {
333
+ if (!Array.isArray(queryVector) || queryVector.length === 0) return [];
334
+ const rows = store.getChoroPageVectors(repoId).filter((r) => r.vector && r.dim === queryVector.length);
335
+ return rows
336
+ .map((r) => ({
337
+ slug: r.slug,
338
+ title: r.title,
339
+ score: Number(cosine(queryVector, JSON.parse(r.vector)).toFixed(4)),
340
+ }))
341
+ .sort((a, b) => b.score - a.score)
342
+ .slice(0, Math.max(1, Math.min(50, Number(limit) || 10)));
343
+ }
344
+
345
+ /** 便捷组合:嵌查询 + Top-N(P39:独立数组,不与 keyword hits 混排序)。
346
+ * 空查询 → [](空白查询的「语义」是噪声,面板逐键触发时尤其如此);
347
+ * embedder 缺席 → null(调用方退纯关键词)。 */
348
+ async function searchSemantic(repoId, embedder, q, { limit = 10 } = {}) {
349
+ if (!embedder) return null;
350
+ if (String(q ?? "").trim() === "") return [];
351
+ const queryVector = await embedQueryText(embedder, q);
352
+ if (!queryVector) return [];
353
+ return semanticTopN(repoId, queryVector, { limit });
354
+ }
355
+
356
+ return {
357
+ listRepos, resolveRepo, moduleMap, readPage, search, repoByCwd, outline,
358
+ embedPageVectors, searchSemantic, embedQueryText, semanticTopN,
359
+ };
220
360
  }
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.11.0",
4
+ "version": "0.12.0",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // 做三件事:
6
6
  // 1. 跑一遍全量测试(npm test),取套件总数(`ℹ tests N` 汇总行);
7
- // 2. 把双 README 的 tests 徽章(img.shields.io/badge/tests-N%20passed)与
7
+ // 2. 把双 README 的 tests 徽章(img.shields.io/badge/tests-N%20total)与
8
8
  // 开发命令注释(# N 个测试 / # 运行 N 个测试 / # N 个 node:test 测试 /
9
9
  // # N tests)刷成该数字——形状集合收在 scripts/test-count-sync.mjs,与发版
10
10
  // 自动路径 scripts/release-prep.mjs 共用(两条路径各存一份曾导致 v0.8.11
@@ -9,17 +9,21 @@
9
9
  // 四条注释留在 1431 —— 自动路径反而成了漂移的来源。规则收在这里共用,
10
10
  // 由 test/test-count-sync.test.js 锁住形状集合与「双 README 同数」这条不变量。
11
11
  //
12
- // 徽章正则刻意不带尾随 `-`:`tests-N%20passed` 既能命中 `…passed-3E63DD`(root)
13
- // 也能命中 `…passed-success`(包 README),将来出现无后缀写法也不会静默漏改。
12
+ // 徽章正则刻意不带尾随 `-`:`tests-N%20total` 既能命中 `…total-3E63DD`(root)
13
+ // 也能命中 `…total-success`(包 README),将来出现无后缀写法也不会静默漏改。
14
+ //
15
+ // 徽章报的是**套件总数**不是通过数,所以字面量写 `total` 而不是 `passed`(#599 评审指出):
16
+ // 本地 Windows 未开开发者模式时有一条符号链接用例被条件跳过,pass 数会比总数少 1,
17
+ // 而徽章口径是 CI(ubuntu)的套件总数——写 `passed` 就在非 CI 平台上多报了一个通过。
14
18
 
15
- const BADGE = /tests-\d+%20passed/g;
19
+ const BADGE = /tests-\d+%20total/g;
16
20
  const COMMENT_CJK = /(#\s*(?:运行\s*)?)\d+( 个(?:\s*node:test)?\s*测试)/g;
17
21
  const COMMENT_EN = /(#\s*)\d+( tests)/g;
18
22
 
19
23
  /** 把一段文本里所有测试数出现处刷成 count(含徽章与四种注释形状)。 */
20
24
  export function applyTestCount(text, count) {
21
25
  return text
22
- .replace(BADGE, `tests-${count}%20passed`)
26
+ .replace(BADGE, `tests-${count}%20total`)
23
27
  .replace(COMMENT_CJK, `$1${count}$2`)
24
28
  .replace(COMMENT_EN, `$1${count}$2`);
25
29
  }
@@ -31,7 +35,7 @@ export function applyTestCount(text, count) {
31
35
  */
32
36
  export function readTestCounts(text) {
33
37
  const found = [];
34
- for (const m of text.matchAll(/tests-(\d+)%20passed/g)) {
38
+ for (const m of text.matchAll(/tests-(\d+)%20total/g)) {
35
39
  found.push({ shape: "badge", value: Number(m[1]) });
36
40
  }
37
41
  for (const m of text.matchAll(/#\s*(?:运行\s*)?(\d+)\s*个(?:\s*node:test)?\s*测试/g)) {