@modusensus/dsh-mneme 0.10.1 → 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 (97) hide show
  1. package/README.md +17 -6
  2. package/bin/dsh-mneme-choro.mjs +254 -0
  3. package/docs/CONFIGURATION.md +10 -1
  4. package/lib/api-standalone.js +17 -21
  5. package/lib/api.js +414 -12
  6. package/lib/bootstrap-http.js +105 -0
  7. package/lib/bootstrap.js +5 -1
  8. package/lib/choro-command.js +104 -0
  9. package/lib/client.js +1637 -110
  10. package/lib/config-keys.js +144 -0
  11. package/lib/config.js +60 -1
  12. package/lib/document.js +9 -3
  13. package/lib/dream/clustering.js +6 -1
  14. package/lib/index.js +87 -5
  15. package/lib/inject.js +18 -4
  16. package/lib/lang.js +82 -0
  17. package/lib/profile-facts.js +356 -0
  18. package/lib/quality-filter.js +48 -12
  19. package/lib/service.js +112 -4
  20. package/lib/settings.js +13 -1
  21. package/lib/store.js +978 -27
  22. package/lib/tools.js +244 -1
  23. package/lib/user-settings.js +63 -7
  24. package/lib/wiki/inject.js +164 -0
  25. package/lib/wiki/partition.js +200 -0
  26. package/lib/wiki/plan.js +214 -0
  27. package/lib/wiki/queries.js +360 -0
  28. package/lib/wiki/render.js +187 -0
  29. package/lib/wiki/scan.js +371 -0
  30. package/lib/wiki/t0.js +463 -0
  31. package/lib/wiki/t1.js +496 -0
  32. package/lib/write-admission.js +5 -3
  33. package/package.json +4 -2
  34. package/scripts/sync-test-badge.mjs +1 -1
  35. package/scripts/test-count-sync.mjs +9 -5
  36. package/src/api-standalone.js +17 -21
  37. package/src/api.js +414 -12
  38. package/src/bootstrap-http.js +105 -0
  39. package/src/bootstrap.js +5 -1
  40. package/src/choro-command.js +104 -0
  41. package/src/config-keys.js +144 -0
  42. package/src/config.js +60 -1
  43. package/src/document.js +9 -3
  44. package/src/dream/clustering.js +6 -1
  45. package/src/index.js +87 -5
  46. package/src/inject.js +18 -4
  47. package/src/lang.js +82 -0
  48. package/src/profile-facts.js +356 -0
  49. package/src/quality-filter.js +48 -12
  50. package/src/service.js +112 -4
  51. package/src/settings.js +13 -1
  52. package/src/store.js +978 -27
  53. package/src/tools.js +244 -1
  54. package/src/user-settings.js +63 -7
  55. package/src/wiki/inject.js +164 -0
  56. package/src/wiki/partition.js +200 -0
  57. package/src/wiki/plan.js +214 -0
  58. package/src/wiki/queries.js +360 -0
  59. package/src/wiki/render.js +187 -0
  60. package/src/wiki/scan.js +371 -0
  61. package/src/wiki/t0.js +463 -0
  62. package/src/wiki/t1.js +496 -0
  63. package/src/write-admission.js +5 -3
  64. package/test/api-routes-gaps.test.js +131 -1
  65. package/test/api.test.js +9 -1
  66. package/test/bootstrap.test.js +8 -0
  67. package/test/choro-command.test.js +218 -0
  68. package/test/client.test.js +802 -21
  69. package/test/config-keys-routes.test.js +233 -0
  70. package/test/config.test.js +79 -0
  71. package/test/contrast.test.js +14 -7
  72. package/test/document.test.js +32 -0
  73. package/test/embedding-cache-f32.test.js +276 -0
  74. package/test/inject-pin-explicit.test.js +300 -0
  75. package/test/inject-pin-vector-crowd.test.js +225 -0
  76. package/test/inject.test.js +51 -0
  77. package/test/llm-audit.test.js +29 -1
  78. package/test/persona-settings.test.js +31 -0
  79. package/test/profile-facts.test.js +492 -0
  80. package/test/quality-backfill.test.js +290 -0
  81. package/test/quality-filter.test.js +113 -4
  82. package/test/service.test.js +1 -1
  83. package/test/session-gate.test.js +1 -1
  84. package/test/test-count-sync.test.js +14 -3
  85. package/test/tool-state-ignore.test.js +65 -0
  86. package/test/tools-dto-schema-matrix.test.js +23 -2
  87. package/test/tools.test.js +144 -7
  88. package/test/wiki-api.test.js +273 -0
  89. package/test/wiki-inject.test.js +265 -0
  90. package/test/wiki-partition.test.js +112 -0
  91. package/test/wiki-plan.test.js +80 -0
  92. package/test/wiki-queries.test.js +270 -0
  93. package/test/wiki-scan.test.js +208 -0
  94. package/test/wiki-store.test.js +190 -0
  95. package/test/wiki-t0.test.js +380 -0
  96. package/test/wiki-t1.test.js +235 -0
  97. package/test/wiki-tools.test.js +176 -0
package/src/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.
@@ -76,7 +78,10 @@ const MEMORY_PROVENANCE = (m) => {
76
78
  return parts.length ? ` | ${parts.join(" | ")}` : "";
77
79
  };
78
80
 
79
- export function createTools(ctx, service, config, embedder, sessionGate = null) {
81
+ // choro(M1):chorography 只读查询对象(createChoroQueries 产物)。缺省 null =
82
+ // 装配层没接(headless/测试),三个 choro_* 工具走 hiddenTools 闸根本不注册。
83
+ // 它与 service 刻意分开传——chorography 的所有权模型与记忆域相反(spec §3)。
84
+ export function createTools(ctx, service, config, embedder, sessionGate = null, choro = null) {
80
85
  const toolsRegistry = ctx.tools;
81
86
  let registration = REGISTERED_TOOLS.get(toolsRegistry);
82
87
  if (!registration) {
@@ -89,6 +94,19 @@ export function createTools(ctx, service, config, embedder, sessionGate = null)
89
94
  // flag 关闭时恒返回 null——写入不标注,检索不加权,行为与 A1 前完全一致。
90
95
  // logger 透传:registry 反查失败时 warnOnce 才有出口(否则静默降级无观测)。
91
96
  const resolveSessionScope = createScopeResolver({ ctx, config, logger: ctx.logger });
97
+ // 宿主在 render 前按成功 schema 校验返回值,仓库解析失败要走工具错误通道。
98
+ const resolveChoroRepo = (ref) => {
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
+ }
108
+ return row;
109
+ };
92
110
  // #249 能力说明(injectGuidanceEnabled):开启时给「何时用」最有歧义的两个
93
111
  // 工具补一句判断指引。工具描述常驻、不进每轮上下文,这个位子零注入成本;
94
112
  // 关闭时描述逐字节不变,也不动其他工具的文案。
@@ -466,6 +484,110 @@ export function createTools(ctx, service, config, embedder, sessionGate = null)
466
484
  }
467
485
  }),
468
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
+
469
591
  defineTool({
470
592
  name: "memory_archive",
471
593
  description:
@@ -1032,6 +1154,119 @@ export function createTools(ctx, service, config, embedder, sessionGate = null)
1032
1154
  ...(report.reason ? { reason: report.reason } : {})
1033
1155
  };
1034
1156
  }
1157
+ }),
1158
+
1159
+ // --- chorography(仓库地志)只读工具(M1,spec §7)------------------
1160
+ // 三个全只读(红线 3:写操作一个不注册——生成只走 dsh-mneme-choro CLI,
1161
+ // 那是人按的按钮,不是模型的权利)。默认不在场:chorographyEnabled 关着时
1162
+ // 走下面的 hiddenTools 闸,模型连名字都看不到(与 memory_organize 同款反向
1163
+ // 语义)。执行器包 sessionGate 由注册循环统一做——仓库地志是文档不是会话
1164
+ // 派生内容,无痕会话里被一并关掉是保守取向,可接受。
1165
+ defineTool({
1166
+ name: "choro_module_map",
1167
+ description: "Show the chorography module map of a registered repository: module list (slug, title, file count, status) and cross-module dependency edges. Read-only. Use it to orient yourself in a codebase before reading pages; requires the user to have enabled chorography and generated the wiki (T0 skeleton).",
1168
+ parameters: {
1169
+ repo: { type: "string", description: "Repository id or display name; omit when exactly one repository is registered" }
1170
+ },
1171
+ output: {
1172
+ schema: {
1173
+ type: "object",
1174
+ additionalProperties: false,
1175
+ properties: {
1176
+ repo: { type: "string", required: true },
1177
+ modules: { type: "array", required: true, items: { type: "object", additionalProperties: true } },
1178
+ edges: { type: "array", required: true, items: { type: "object", additionalProperties: true } }
1179
+ }
1180
+ },
1181
+ render: (_args, value) => {
1182
+ const mods = value.modules ?? [];
1183
+ const edges = value.edges ?? [];
1184
+ const lines = mods.map((m) =>
1185
+ `- ${m.slug} — ${m.title}(${m.file_count} 文件${m.page_status ? `,页 ${m.page_status}` : ",无页"})`
1186
+ );
1187
+ for (const e of edges) lines.push(` ↳ ${e.from_slug} → ${e.to_slug} ×${e.weight}`);
1188
+ return TEXT_OUTPUT(`${mods.length} modules, ${edges.length} edges:\n${lines.join("\n")}`);
1189
+ }
1190
+ },
1191
+ async execute(args) {
1192
+ const repo = resolveChoroRepo(args?.repo);
1193
+ const { modules, edges } = choro.moduleMap(repo.id);
1194
+ return { repo: repo.name, modules, edges };
1195
+ }
1196
+ }),
1197
+
1198
+ defineTool({
1199
+ name: "choro_read_page",
1200
+ description: "Read one chorography wiki page (module skeleton: file list, entries, internal/external dependencies) of a registered repository by slug. Use choro_module_map first to learn the slugs. Read-only.",
1201
+ parameters: {
1202
+ slug: { type: "string", description: "Page slug as listed by choro_module_map" },
1203
+ repo: { type: "string", description: "Repository id or display name; omit when exactly one repository is registered" }
1204
+ },
1205
+ output: {
1206
+ schema: {
1207
+ type: "object",
1208
+ additionalProperties: false,
1209
+ properties: {
1210
+ slug: { type: "string", required: true },
1211
+ content: { type: "string", required: true }
1212
+ }
1213
+ },
1214
+ render: (_args, value) => TEXT_OUTPUT(value.content)
1215
+ },
1216
+ async execute(args) {
1217
+ const repo = resolveChoroRepo(args?.repo);
1218
+ const page = choro.readPage(repo.id, args?.slug);
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
+ }
1228
+ return { slug: page.slug, content: page.content };
1229
+ }
1230
+ }),
1231
+
1232
+ defineTool({
1233
+ name: "choro_search",
1234
+ description: "Keyword search over a repository's chorography wiki pages. Returns matching sections with page slug, section title, line number and excerpt (case-insensitive substring; matches frontmatter too — sections let you tell metadata hits from body hits). Read-only.",
1235
+ parameters: {
1236
+ query: { type: "string", description: "Keyword substring to search for" },
1237
+ repo: { type: "string", description: "Repository id or display name; omit when exactly one repository is registered" },
1238
+ module: { type: "string", description: "Restrict to one module slug" },
1239
+ limit: { type: "integer", description: "Max hits (default 20, cap 100)" }
1240
+ },
1241
+ output: {
1242
+ schema: {
1243
+ type: "object",
1244
+ additionalProperties: false,
1245
+ properties: {
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" }
1248
+ }
1249
+ },
1250
+ render: (_args, value) => {
1251
+ const { hits, unreadablePages } = value;
1252
+ if (hits.length === 0) return TEXT_OUTPUT("0 hits.");
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"));
1256
+ }
1257
+ },
1258
+ async execute(args) {
1259
+ const repo = resolveChoroRepo(args?.repo);
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 };
1269
+ }
1035
1270
  })
1036
1271
  ];
1037
1272
 
@@ -1046,6 +1281,14 @@ export function createTools(ctx, service, config, embedder, sessionGate = null)
1046
1281
  // 注册。仍走同一个闸门而不是在数组里条件展开——模型看不到就不会调,这比在描述里
1047
1282
  // 劝它别用可靠(上面那段 v0.8.5 的定性对两个方向都成立)。
1048
1283
  if (config?.organizeEnabled !== true) hiddenTools.add("memory_organize");
1284
+ // chorography 三个只读工具(M1):默认不在场,chorographyEnabled 打开才注册。
1285
+ // 与 organizeEnabled 同款反向语义;queries 未装配(null)时同样藏——「开了开关
1286
+ // 但装配缺件」与「没开」对模型都是不可见,可区分性由面板路由的 409/503 负责。
1287
+ if (config?.chorographyEnabled !== true || !choro) {
1288
+ hiddenTools.add("choro_module_map");
1289
+ hiddenTools.add("choro_read_page");
1290
+ hiddenTools.add("choro_search");
1291
+ }
1049
1292
 
1050
1293
  for (const tool of tools) {
1051
1294
  if (hiddenTools.has(tool.name)) {
@@ -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
  }
@@ -0,0 +1,164 @@
1
+ // dsh-mneme/src/wiki/inject.js
2
+ // chorography 的注入面(M3):会话开局往 systemPrompt 里塞一小块「仓库地志提纲」。
3
+ //
4
+ // 三条边界(spec §7 / P24–P33):
5
+ // 1. **只注标题级**——模块名 + 文件数 + 页状态 + 页相对路径,不读页正文。正文要
6
+ // 模型自己用 choro_read_page 取,那不是「开局就该付的成本」。
7
+ // 2. **不上热路径的硬活**——不 fork git、不 stat 磁盘、不解析 markdown。这里只吃
8
+ // DB 里已有的行;过期判定留给 /choro(人主动触发,付得起一次 git rev-parse)。
9
+ // 3. **绝不抛**——本块折在既有的 memory context 里,一个附属信息块把整轮
10
+ // systemPrompt 搞崩是不成比例的失败。任何异常都退化成空串(= 本帧不注入,
11
+ // 宿主契约里空文本会被过滤,不占位)。
12
+ import { escapePromptVars } from "../user-settings.js";
13
+ import { STR } from "../lang.js";
14
+
15
+ /** 档位枚举(与 config.js 的 chorographyInject 同一份取值,配置闸门在 settings.js)。 */
16
+ export const INJECT_TIERS = Object.freeze(["off", "light", "full"]);
17
+
18
+ /**
19
+ * light 档最多列几个模块。
20
+ *
21
+ * 定 6 是照着 spec §7 的「≤8 行」倒推的:块头 1 行 + 模块行 6 行 + 「还有 N 个」
22
+ * 1 行 = 8 行。多给两行要挤掉「还有 N 个」那句,读者就不知道清单被截断了——
23
+ * 静默截断比明说「还有 30 个」更坏。
24
+ */
25
+ export const LIGHT_MODULE_LIMIT = 6;
26
+
27
+ /** 各档字符硬上限。宿主侧没有任何体积预算(V3 实测),所以上限只能自己兜。 */
28
+ export const MAX_BLOCK_CHARS = Object.freeze({ light: 900, full: 6000 });
29
+
30
+ /**
31
+ * 配置 → 档位。纯函数,无副作用,供注入侧与 /choro 共用同一份判据。
32
+ *
33
+ * 轻量档(lightMode)在这里被压成 off 而不是进 LIGHT_MODE_OFF:那张表对平铺键
34
+ * 一律赋 false,枚举键塞进去会得到与 schema 不符的 false,而 false !== "off"
35
+ * 时注入照样渲染——比不关更糟(P26)。
36
+ */
37
+ export function resolveInjectTier(config) {
38
+ if (!config || config.chorographyEnabled !== true) return "off";
39
+ if (config.lightMode === true) return "off";
40
+ const raw = config.chorographyInject;
41
+ return INJECT_TIERS.includes(raw) ? raw : "light";
42
+ }
43
+
44
+ /** 页状态 → 标签。skeleton 不出标签(那是默认态,全标一遍等于没标)。 */
45
+ function statusLabel(status, language) {
46
+ if (status === "enriched") return STR.choroStatusEnriched[language];
47
+ if (status === "failed") return STR.choroStatusFailed[language];
48
+ if (status === "stale") return STR.choroStatusStale[language];
49
+ return "";
50
+ }
51
+
52
+ /**
53
+ * light 档取哪些模块。
54
+ *
55
+ * 按文件数降序、同数按 slug 升序——**不是**取字母序前 6 个:字母序会永远只显示
56
+ * 同一批(通常还是 src 这种大而空的目录页),而大模块是「被问到的概率」最高的
57
+ * 那批。排序里带 slug 是为了同一份库每次渲染字节一致(缓存与测试都靠它)。
58
+ */
59
+ function pickModules(modules, tier) {
60
+ // 两档用同一份排序:light 与 full 若各按各的排,同一份地志在两个档位下会给出
61
+ // 不同次序,读起来像「刚才那份被重扫过了」。大模块在前对两档都成立——
62
+ // 被问到的概率最高的那批,本来就该排前面。
63
+ const ordered = [...modules].sort((a, b) => {
64
+ const d = (b.fileCount ?? 0) - (a.fileCount ?? 0);
65
+ if (d !== 0) return d;
66
+ return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0;
67
+ });
68
+ if (tier === "full") return { shown: ordered, rest: 0 };
69
+ return { shown: ordered.slice(0, LIGHT_MODULE_LIMIT), rest: Math.max(0, ordered.length - LIGHT_MODULE_LIMIT) };
70
+ }
71
+
72
+ /** 生成时间只留日期部分:注入块里精确到毫秒没有信息量,只挤体积。 */
73
+ function dateOnly(iso) {
74
+ const match = /^(\d{4}-\d{2}-\d{2})/.exec(String(iso ?? ""));
75
+ return match ? match[1] : String(iso ?? "");
76
+ }
77
+
78
+ /**
79
+ * 渲染地志提纲块。纯函数(入参进、字符串出),注入侧与 /choro 的「看提纲」
80
+ * 共用这一份——两张皮在这里只剩档位与语言两个参数之差。
81
+ *
82
+ * @param {object|null} outline createChoroQueries().outline() 的产物
83
+ * @param {"light"|"full"} tier 已解析的档位(off 不该调到这里)
84
+ * @param {"zh"|"en"} language
85
+ * @returns {string} 空串 = 不注入
86
+ */
87
+ export function renderChoroBlock(outline, tier, language = "zh") {
88
+ if (!outline || !Array.isArray(outline.modules) || outline.modules.length === 0) return "";
89
+ const lang = language === "en" ? "en" : "zh";
90
+ const cap = MAX_BLOCK_CHARS[tier] ?? MAX_BLOCK_CHARS.light;
91
+ const picked = pickModules(outline.modules, tier);
92
+
93
+ const lines = [
94
+ STR.choroBlockHeader[lang](outline.repo.name, outline.modules.length, dateOnly(outline.generatedAt))
95
+ ];
96
+ for (const mod of picked.shown) {
97
+ lines.push(STR.choroBlockLine[lang](mod.title, mod.fileCount ?? 0, statusLabel(mod.status, lang), mod.relPath ?? ""));
98
+ }
99
+ if (picked.rest > 0) lines.push(STR.choroBlockMore[lang](picked.rest));
100
+
101
+ // 逐行累加而不是拼完再切:切在行中间会让最后一条模块行变成半句话,
102
+ // 模型读到的「路径」就可能是断的——宁可少一行,不可半行。
103
+ const kept = [];
104
+ let used = 0;
105
+ for (const line of lines) {
106
+ if (used + line.length + 1 > cap) break;
107
+ kept.push(line);
108
+ used += line.length + 1;
109
+ }
110
+ if (kept.length <= 1) return "";
111
+ return kept.join("\n");
112
+ }
113
+
114
+ /**
115
+ * 注入器(给 src/inject.js 用)。
116
+ *
117
+ * 缓存键用 **outline.generatedAt**(页级生成时间的最大值),不是 choro_repos.
118
+ * updated_at:T1 富化只推 choro_pages.generated_at,不动仓库行——若按仓库行做键,
119
+ * 用户刚在终端跑完 `--t1 --apply`,注入块还写着「骨架」,而 /choro(无缓存)已经
120
+ * 报「已富化」。两个面说同一件事却给出不同答案,比慢一点糟得多。
121
+ * 每轮仍查一次库取提纲(一次索引查询,几十微秒,不 fork git、不 stat 磁盘),
122
+ * 只是**渲染**走缓存——省的是拼字符串,不是正确性。
123
+ *
124
+ * @param {object} opts.choro createChoroQueries() 的产物(注入侧只读它)
125
+ * @param {object} opts.config 合并后的配置
126
+ * @param {object} [opts.logger]
127
+ * @param {() => string} [opts.language] 语言取值器(跟随配置,不在创建时定死)
128
+ * @returns {{ text: (cwd: string|null|undefined) => string, tier: string, dispose: () => void }}
129
+ */
130
+ export function createChoroInject({ choro, config, logger = null, language = null } = {}) {
131
+ const langOf = typeof language === "function" ? language : () => "zh";
132
+ const tier = resolveInjectTier(config);
133
+ let cache = null; // { key, text }
134
+
135
+ function text(cwd) {
136
+ if (tier === "off" || !choro) return "";
137
+ try {
138
+ const repo = typeof choro.repoByCwd === "function" ? choro.repoByCwd(cwd) : null;
139
+ if (!repo) return "";
140
+ const lang = langOf() === "en" ? "en" : "zh";
141
+ const outline = typeof choro.outline === "function" ? choro.outline(repo.id) : null;
142
+ // 键取页级生成时间:T0 重算与 T1 富化都会推进它,两种「地志变了」都命中。
143
+ const key = tier + "|" + lang + "|" + repo.id + "|" + (outline?.generatedAt ?? repo.updated_at ?? "");
144
+ if (cache && cache.key === key) return cache.text;
145
+ // 转义只在这一层做:/choro 的 UI 回执也调 renderChoroBlock,但它不进宿主模板,
146
+ // 转义了反而会显示成一串反斜杠。这里的 config 与 inject.js 同一份开关。
147
+ const rendered = escapePromptVars(config, renderChoroBlock(outline, tier, lang));
148
+ cache = { key, text: rendered };
149
+ return rendered;
150
+ } catch (error) {
151
+ // P31:注入永远不抛。日志留一次线索,但本帧就当没有地志。
152
+ logger?.warn?.("[dsh-mneme] chorography 注入块渲染失败,本帧跳过:" + String(error?.message ?? error));
153
+ return "";
154
+ }
155
+ }
156
+
157
+ return {
158
+ text,
159
+ tier,
160
+ dispose() {
161
+ cache = null;
162
+ }
163
+ };
164
+ }