@modusensus/dsh-mneme 0.12.0 → 0.14.0-beta.1

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 (182) hide show
  1. package/README.md +51 -20
  2. package/bin/dsh-mneme-choro.mjs +9 -1
  3. package/docs/CONFIGURATION.md +47 -6
  4. package/docs/DIRECTORIES.md +114 -0
  5. package/docs/DISTILL.md +64 -0
  6. package/docs/DSH-GO.md +97 -0
  7. package/docs/ENTITIES.md +5 -3
  8. package/docs/FORGET.md +77 -0
  9. package/docs/KB.md +110 -0
  10. package/docs/SLEEP.md +28 -3
  11. package/docs/STORAGE.md +23 -0
  12. package/lib/api-standalone.js +4 -1
  13. package/lib/api.js +788 -30
  14. package/lib/attachment-text.js +122 -0
  15. package/lib/choro-command.js +9 -2
  16. package/lib/client.js +4627 -1409
  17. package/lib/config.js +166 -1
  18. package/lib/continuity.js +7 -6
  19. package/lib/diary/auto.js +190 -0
  20. package/lib/diary/compose.js +440 -0
  21. package/lib/diary/dir.js +59 -0
  22. package/lib/diary/export.js +97 -0
  23. package/lib/diary/index.js +26 -0
  24. package/lib/diary/library.js +189 -0
  25. package/lib/diary/qq-inbox.js +142 -0
  26. package/lib/diary/rollover.js +61 -0
  27. package/lib/diary/session-provider.js +122 -0
  28. package/lib/dream/decisions.js +95 -6
  29. package/lib/dream/sleep.js +760 -19
  30. package/lib/dream/triage.js +139 -0
  31. package/lib/dream.js +168 -21
  32. package/lib/dsh-go/host.js +162 -0
  33. package/lib/dsh-go/panel.js +2091 -0
  34. package/lib/dsh-go/text.js +342 -0
  35. package/lib/dsh-go/views/common.js +68 -0
  36. package/lib/dsh-go/views/entity.js +48 -0
  37. package/lib/dsh-go/views/memory.js +198 -0
  38. package/lib/dsh-go/views/root.js +30 -0
  39. package/lib/dsh-go/views/settings.js +398 -0
  40. package/lib/dsh-go/views/status.js +51 -0
  41. package/lib/embedding.js +42 -8
  42. package/lib/entities/extractor.js +36 -7
  43. package/lib/hot-memory.js +22 -11
  44. package/lib/index.js +123 -3
  45. package/lib/inject.js +203 -20
  46. package/lib/kb/index.js +309 -0
  47. package/lib/kb/routes.js +220 -0
  48. package/lib/kb/tools.js +148 -0
  49. package/lib/lang.js +98 -4
  50. package/lib/peak-hours.js +57 -2
  51. package/lib/semantic.js +347 -60
  52. package/lib/service.js +268 -69
  53. package/lib/settings.js +128 -15
  54. package/lib/store.js +585 -26
  55. package/lib/summarize.js +38 -11
  56. package/lib/tools.js +35 -4
  57. package/lib/user-settings.js +128 -32
  58. package/lib/vector-index.js +76 -6
  59. package/lib/wiki/claims.js +267 -0
  60. package/lib/wiki/cli-hint.js +90 -0
  61. package/lib/wiki/queries.js +282 -2
  62. package/lib/wiki/render.js +74 -0
  63. package/lib/wiki/t0.js +43 -6
  64. package/lib/wiki/t1-jobs.js +232 -0
  65. package/lib/wiki/t1.js +145 -32
  66. package/package.json +39 -1
  67. package/scripts/release-docs.mjs +7 -1
  68. package/scripts/release-notes.mjs +4 -1
  69. package/src/api-standalone.js +4 -1
  70. package/src/api.js +788 -30
  71. package/src/attachment-text.js +122 -0
  72. package/src/choro-command.js +9 -2
  73. package/src/config.js +166 -1
  74. package/src/continuity.js +7 -6
  75. package/src/diary/auto.js +190 -0
  76. package/src/diary/compose.js +440 -0
  77. package/src/diary/dir.js +59 -0
  78. package/src/diary/export.js +97 -0
  79. package/src/diary/index.js +26 -0
  80. package/src/diary/library.js +189 -0
  81. package/src/diary/qq-inbox.js +142 -0
  82. package/src/diary/rollover.js +61 -0
  83. package/src/diary/session-provider.js +122 -0
  84. package/src/dream/decisions.js +95 -6
  85. package/src/dream/sleep.js +760 -19
  86. package/src/dream/triage.js +139 -0
  87. package/src/dream.js +168 -21
  88. package/src/dsh-go/host.js +162 -0
  89. package/src/dsh-go/panel.js +2091 -0
  90. package/src/dsh-go/text.js +342 -0
  91. package/src/dsh-go/views/common.js +68 -0
  92. package/src/dsh-go/views/entity.js +48 -0
  93. package/src/dsh-go/views/memory.js +198 -0
  94. package/src/dsh-go/views/root.js +30 -0
  95. package/src/dsh-go/views/settings.js +398 -0
  96. package/src/dsh-go/views/status.js +51 -0
  97. package/src/embedding.js +42 -8
  98. package/src/entities/extractor.js +36 -7
  99. package/src/hot-memory.js +22 -11
  100. package/src/index.js +123 -3
  101. package/src/inject.js +203 -20
  102. package/src/kb/index.js +309 -0
  103. package/src/kb/routes.js +220 -0
  104. package/src/kb/tools.js +148 -0
  105. package/src/lang.js +98 -4
  106. package/src/peak-hours.js +57 -2
  107. package/src/semantic.js +347 -60
  108. package/src/service.js +268 -69
  109. package/src/settings.js +128 -15
  110. package/src/store.js +585 -26
  111. package/src/summarize.js +38 -11
  112. package/src/tools.js +35 -4
  113. package/src/user-settings.js +128 -32
  114. package/src/vector-index.js +76 -6
  115. package/src/wiki/claims.js +267 -0
  116. package/src/wiki/cli-hint.js +90 -0
  117. package/src/wiki/queries.js +282 -2
  118. package/src/wiki/render.js +74 -0
  119. package/src/wiki/t0.js +43 -6
  120. package/src/wiki/t1-jobs.js +232 -0
  121. package/src/wiki/t1.js +145 -32
  122. package/test/api-routes-gaps.test.js +63 -0
  123. package/test/api.test.js +385 -4
  124. package/test/attachment-text.test.js +140 -0
  125. package/test/bm25-corpus-cache.test.js +145 -0
  126. package/test/capacity-warning.test.js +262 -0
  127. package/test/client.test.js +1051 -185
  128. package/test/conflict-freeze.test.js +45 -1
  129. package/test/conflict-ledger-enforce.test.js +452 -0
  130. package/test/conflict-ledger.test.js +242 -0
  131. package/test/continuity.test.js +8 -1
  132. package/test/contrast.test.js +124 -12
  133. package/test/diary-library.test.js +187 -0
  134. package/test/diary.test.js +576 -0
  135. package/test/dream-baseline-clamp.test.js +174 -0
  136. package/test/dream-maxtokens-fallback.test.js +3 -3
  137. package/test/dream.test.js +62 -1
  138. package/test/dsh-go-host.test.js +134 -0
  139. package/test/dsh-go-panel-actions.test.js +854 -0
  140. package/test/dsh-go-panel.test.js +251 -0
  141. package/test/embedding-ready.test.js +33 -3
  142. package/test/entities.test.js +73 -0
  143. package/test/forget-corrections.test.js +70 -0
  144. package/test/graph-api.test.js +124 -0
  145. package/test/hot-memory.test.js +48 -0
  146. package/test/image-memory.test.js +171 -0
  147. package/test/inject-parent-gate.test.js +6 -3
  148. package/test/inject.test.js +59 -0
  149. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  150. package/test/kb-api-wiring.test.js +128 -0
  151. package/test/kb-collections.test.js +272 -0
  152. package/test/kb-routes.test.js +379 -0
  153. package/test/mint-source.test.js +26 -0
  154. package/test/panel-chart-scale.test.js +90 -0
  155. package/test/panel-tokens.test.js +245 -0
  156. package/test/peak-hours-observability.test.js +70 -0
  157. package/test/persona-api.test.js +3 -3
  158. package/test/persona-by-session.test.js +568 -0
  159. package/test/persona-settings.test.js +7 -7
  160. package/test/reflection-update-gate.test.js +129 -0
  161. package/test/reindex-backfill.test.js +334 -0
  162. package/test/reindex-scheduler.test.js +369 -0
  163. package/test/release-docs.test.js +20 -0
  164. package/test/release-notes.test.js +25 -0
  165. package/test/scope-candidates.test.js +249 -0
  166. package/test/security-doc.test.js +10 -0
  167. package/test/sleep-action-set.test.js +14 -0
  168. package/test/sleep-heat.test.js +49 -0
  169. package/test/sleep.test.js +635 -0
  170. package/test/standalone-api.test.js +24 -0
  171. package/test/store.test.js +20 -0
  172. package/test/summarize.test.js +26 -0
  173. package/test/user-settings-segments.test.js +528 -0
  174. package/test/vector-index.test.js +120 -6
  175. package/test/wiki-api.test.js +463 -1
  176. package/test/wiki-claims.test.js +194 -0
  177. package/test/wiki-cli-hint.test.js +118 -0
  178. package/test/wiki-provenance.test.js +202 -0
  179. package/test/wiki-queries.test.js +128 -0
  180. package/test/wiki-t0.test.js +77 -1
  181. package/test/wiki-t1-jobs.test.js +125 -0
  182. package/test/wiki-t1.test.js +226 -1
package/lib/summarize.js CHANGED
@@ -9,6 +9,8 @@ import { createScopeResolver } from "./scope.js";
9
9
  // #493 补充 §③:断链与巩固侧同一份实现(src/fence.js)。转录里出现闭合标记时
10
10
  // 原先能提前闭合围栏——那是把用户消息升格成指令最短的一条路。
11
11
  import { breakFenceMarkers } from "./fence.js";
12
+ // Issue #611:图片块的文本口径(占位,不是空串)。三个 textOf 副本共用这一份。
13
+ import { contentToText, countImages } from "./attachment-text.js";
12
14
  // #512:每轮蒸馏一个 run id(归因主体)。store 侧也能铸造,但这里必须知道它——
13
15
  // 写记忆时要把它一路带到 saveMemoryProvenance / 落 run 行,拿回来再写就是两次往返。
14
16
  import { randomUUID } from "node:crypto";
@@ -292,13 +294,13 @@ const SUBAGENT_MESSAGE_KINDS = new Set(["agent-message", "subagent-settled"]);
292
294
 
293
295
  // 兼容严格形状 [{type:"text",text}] 与宽松形状 ["字符串", ...](lib-smoke 用例
294
296
  // 直接传字符串数组)。只取公开文本块;reasoning 私有推理块不进蒸馏上下文。
297
+ //
298
+ // Issue #611:图片块曾经在这里一律变空串,于是图-only 消息整条被丢、图+文消息只剩
299
+ // 文字——静默,没有任何计数。现在改由 attachment-text 统一产占位,
300
+ // `[图: 屏幕截图 … 1543x983]`。抽取口径只有一份,三个调用方(本文件 /
301
+ // continuity / hot-memory)共用,避免同一件事写成三份会漂移的实现。
295
302
  function textOf(content) {
296
- if (typeof content === "string") return content;
297
- if (!Array.isArray(content)) return "";
298
- return content
299
- .map((block) => (typeof block === "string" ? block : (block && block.type === "text" && typeof block.text === "string" ? block.text : "")))
300
- .filter((s) => s)
301
- .join("\n");
303
+ return contentToText(content);
302
304
  }
303
305
 
304
306
  /**
@@ -333,6 +335,10 @@ function publicTranscriptParts(event) {
333
335
  parts.push({
334
336
  role: "agent",
335
337
  text,
338
+ // Issue #611:把产文本的那份 content 一起带出去。判定与文本同源,计量才能
339
+ // 跟着同一份判定走(见 collectMessages 里的 imageCount)——另写一套「哪些块
340
+ // 算公开」给计量用,就是第二份实现,迟早与这份漂移。
341
+ blocks: Array.isArray(message?.content) ? message.content : [],
336
342
  // 同一条子会话消息可能同时出现在 user/message 与 agent/inbox/spliced
337
343
  // 里(投递路径重叠),按 id(无 id 时按内容)去重,否则转录会重复。
338
344
  key: typeof message?.id === "string" && message.id
@@ -346,7 +352,7 @@ function publicTranscriptParts(event) {
346
352
  const text = textOf(data?.content);
347
353
  if (!text.trim()) break;
348
354
  if (SUBAGENT_MESSAGE_KINDS.has(kind)) pushAgentMessage(data);
349
- else parts.push({ role: "user", text });
355
+ else parts.push({ role: "user", text, blocks: Array.isArray(data?.content) ? data.content : [] });
350
356
  break;
351
357
  }
352
358
  case "agent/inbox/spliced": {
@@ -358,7 +364,7 @@ function publicTranscriptParts(event) {
358
364
  const blocks = Array.isArray(data?.message?.content) ? data.message.content : [];
359
365
  const text = textOf(blocks);
360
366
  // 私有推理块(reasoning)刻意不采集:蒸馏记忆不得沉淀模型私有思考链。
361
- if (text.trim()) parts.push({ role: "assistant", text });
367
+ if (text.trim()) parts.push({ role: "assistant", text, blocks });
362
368
  break;
363
369
  }
364
370
  default:
@@ -395,6 +401,10 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
395
401
  const events = snapshotSessionEvents(session, afterSeq, throughSeq);
396
402
  const lines = [];
397
403
  const seenSubagentMessages = new Set();
404
+ // Issue #611:这一窗里被折成占位的图片张数。占位本身不丢信息,但「折了几张」
405
+ // 必须是个能读到的数——否则 off 档和旧版静默丢弃在观测上无法区分,下次再有人
406
+ // 问「图到底进来没有」还是得翻源码。计量只数窗口内事件,不受截断影响。
407
+ let imageCount = 0;
398
408
  const trim = (s, n) => (typeof s === "string" && s.length > n ? `${s.slice(0, n)}…` : s);
399
409
  const trimTranscript = (s, n) => {
400
410
  if (typeof s !== "string" || s.length <= n) return s;
@@ -421,9 +431,20 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
421
431
  if (part.role === "agent") {
422
432
  if (part.key && seenSubagentMessages.has(part.key)) continue;
423
433
  if (part.key) seenSubagentMessages.add(part.key);
424
- lines.push(STR.transcriptAgent[language](part.text));
425
- } else if (part.role === "user") {
434
+ }
435
+ // Issue #611 计量:数「真正进了转录的图块」,所以按 part 数、并且在去重之后
436
+ // ——同一条子会话消息会同时出现在 user/message 与 agent/inbox/spliced 里
437
+ // (投递路径重叠),按事件数会把它算两遍。
438
+ //
439
+ // 不按事件数还有个更要紧的理由:被 publicTranscriptParts 挡掉的注入消息
440
+ // (工具结果、插件注入、陌生 source.kind)里的图根本进不了转录,按事件累加
441
+ // 会报「这一窗有 2 张图」而转录里一张都没有。part 是渲染与落盘共用的同一份
442
+ // 判定,计量跟着它走,两处不会再各有一套口径。
443
+ imageCount += countImages(part.blocks);
444
+ if (part.role === "user") {
426
445
  lines.push(STR.transcriptUser[language](part.text));
446
+ } else if (part.role === "agent") {
447
+ lines.push(STR.transcriptAgent[language](part.text));
427
448
  } else {
428
449
  lines.push(STR.transcriptAssistant[language](part.text));
429
450
  }
@@ -485,6 +506,9 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
485
506
  : [],
486
507
  hasEvents: events.length > 0,
487
508
  lastSeq,
509
+ // Issue #611:这一窗折成占位的图片张数。调用方把它写进审计 metadata,
510
+ // 让「图进来了几张」成为可对账的事实而不是推断。
511
+ imageCount,
488
512
  // Issue #493:只数**转录正文**的字符数。minWindowChars 闸门与审计里的
489
513
  // distillChars 量的都是「这一窗有多少可提炼内容」,不是「我们额外包了多少
490
514
  // 提示词」——若把围栏与角色句算进去,一条「短」也会凭空越过任何阈值,
@@ -850,6 +874,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
850
874
  0
851
875
  );
852
876
  const minWindowChars = config.summarizeMinWindowChars ?? 0;
877
+ const imageCount = collected.imageCount ?? 0;
853
878
  if (minWindowChars > 0 && distillChars < minWindowChars) {
854
879
  commitCursor(session.id, nextSeq);
855
880
  writeAudit({
@@ -857,7 +882,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
857
882
  model_id: route ? `${route.provider}:${route.model}` : "unknown",
858
883
  status: "skipped",
859
884
  error_message: "window-too-small",
860
- metadata: { distillChars, minWindowChars }
885
+ metadata: { distillChars, minWindowChars, ...(imageCount > 0 ? { imageCount } : {}) }
861
886
  });
862
887
  return;
863
888
  }
@@ -1064,6 +1089,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
1064
1089
  audit.errorMessage = failureAudit.errorMessage;
1065
1090
  audit.metadata = {
1066
1091
  distillChars,
1092
+ ...(imageCount > 0 ? { imageCount } : {}),
1067
1093
  ...failureAudit.metadata,
1068
1094
  streak,
1069
1095
  ...(maxParseRetries > 0 ? { maxRetries: maxParseRetries } : {}),
@@ -1203,6 +1229,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
1203
1229
  if (audit) {
1204
1230
  audit.metadata = {
1205
1231
  distillChars,
1232
+ ...(imageCount > 0 ? { imageCount } : {}),
1206
1233
  parsed: parsed.length,
1207
1234
  ...(parsedResult.salvaged ? { json_salvaged: true } : {}),
1208
1235
  ...(capped > 0 ? { capped } : {}),
package/lib/tools.js CHANGED
@@ -9,8 +9,16 @@ import { TOOL_GUIDE, CONTINUITY_TOOL_RULE } from "./guide.js";
9
9
  import { injectChildEnabled } from "./config.js";
10
10
  import { normalizeScopeStateDimension, normalizeScopeStateValue, normalizeScopeStateState } from "./scope-state.js";
11
11
  import { lastUserQuery } from "./inject.js";
12
+ // issue #607:工具报错里的 CLI 命令同样要给绝对路径入口——profile 装的插件没有
13
+ // 全局 shim,光写 `dsh-mneme-choro` 用户照抄只会 command not found。
14
+ import { choroCliCommandFrom } from "./wiki/cli-hint.js";
15
+ // 知识库三工具(契约 §3.4):定义住在 kb/tools.js,本文件只决定注册不注册。
16
+ import { kbToolDefinitions } from "./kb/tools.js";
12
17
 
13
18
  const TEXT_OUTPUT = (text) => [{ type: "text", text }];
19
+ // 工具报错里的命令串:本文件在 src/ 与 lib/ 同一层(bin/ 的父级),模块加载时推导一次。
20
+ // 推导不出(源码树被裁剪)就返回 null,文案退回通用说法。
21
+ const CHORO_CLI = choroCliCommandFrom(import.meta.url, "<repo>");
14
22
  const incompleteChoroSearch = (pages) =>
15
23
  `Search was incomplete because these pages could not be read (${pages.join(", ")}). Check the local checkout and page permissions.`;
16
24
  // Per-registry tool-name registry: guards against duplicate registration on
@@ -81,7 +89,8 @@ const MEMORY_PROVENANCE = (m) => {
81
89
  // choro(M1):chorography 只读查询对象(createChoroQueries 产物)。缺省 null =
82
90
  // 装配层没接(headless/测试),三个 choro_* 工具走 hiddenTools 闸根本不注册。
83
91
  // 它与 service 刻意分开传——chorography 的所有权模型与记忆域相反(spec §3)。
84
- export function createTools(ctx, service, config, embedder, sessionGate = null, choro = null) {
92
+ // kb:知识库装配对象(createKb 产物);缺省 null = 宿主没装它,三个 kb 工具不注册。
93
+ export function createTools(ctx, service, config, embedder, sessionGate = null, choro = null, kb = null) {
85
94
  const toolsRegistry = ctx.tools;
86
95
  let registration = REGISTERED_TOOLS.get(toolsRegistry);
87
96
  if (!registration) {
@@ -99,7 +108,7 @@ export function createTools(ctx, service, config, embedder, sessionGate = null,
99
108
  const row = choro.resolveRepo(ref);
100
109
  if (!row) {
101
110
  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.`);
111
+ throw new Error(`${reason} ${CHORO_CLI ? `Run \`${CHORO_CLI}\`` : "Run `dsh-mneme-choro <repo> --apply`"} to register a repository.`);
103
112
  }
104
113
  if (row.ambiguous) {
105
114
  const choices = row.ambiguous.map(({ id, name }) => `${id} (${name})`).join(", ");
@@ -453,7 +462,11 @@ export function createTools(ctx, service, config, embedder, sessionGate = null,
453
462
  name: "memory_forget",
454
463
  description:
455
464
  "Stop a memory from being auto-injected and from appearing in searches and lists without deleting it. " +
456
- "The entry stays in storage; pass forgotten: false to restore it.",
465
+ "The entry stays in storage; pass forgotten: false to restore it. " +
466
+ "#532: suppression is per ROW - facts fused into the same entry leave visibility together. " +
467
+ "When the request targets one fact among several held by a row, inspect the row first (memory_get), " +
468
+ "split it with memory_update if needed, or say in your reply what else the suppression took down " +
469
+ "(docs/FORGET.md records the supersession gap behind this).",
457
470
  parameters: {
458
471
  id: { type: "string", required: true },
459
472
  forgotten: { type: "boolean", description: "Suppress (true, default) or restore (false) the entry's visibility" }
@@ -1220,7 +1233,7 @@ export function createTools(ctx, service, config, embedder, sessionGate = null,
1220
1233
  throw new Error(`Page ${JSON.stringify(args?.slug ?? "")} was not found. Use choro_module_map to list page slugs.`);
1221
1234
  }
1222
1235
  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.`);
1236
+ throw new Error(`Page ${JSON.stringify(page.slug)} is missing from disk (${page.relPath}). Check the local checkout or run ${CHORO_CLI ? `\`${CHORO_CLI}\`` : "`dsh-mneme-choro <repo> --apply`"} to rebuild it.`);
1224
1237
  }
1225
1238
  if (page.error) {
1226
1239
  throw new Error(`Page ${JSON.stringify(page.slug)} could not be read (${page.relPath}). Check the local file and its permissions.`);
@@ -1289,6 +1302,24 @@ export function createTools(ctx, service, config, embedder, sessionGate = null,
1289
1302
  hiddenTools.add("choro_read_page");
1290
1303
  hiddenTools.add("choro_search");
1291
1304
  }
1305
+ // 知识库三工具(契约 §3.4):与 choro 同款反向语义。装配缺件(kb 为 null)与没开
1306
+ // 对模型同样不可见——可区分性归路由层(503 / 409),模型侧不需要看见半个知识库。
1307
+ if (config?.kbEnabled !== true || !kb) {
1308
+ hiddenTools.add("kb_list_collections");
1309
+ hiddenTools.add("kb_search");
1310
+ hiddenTools.add("kb_read_doc");
1311
+ }
1312
+
1313
+ // 知识库工具定义在 kb/tools.js。**必须过 defineTool**:那里返回的是「describe 形状」
1314
+ // (属性上的 required:true、output.schema 用 spec 写法),直接 ctx.tools.register
1315
+ // 会被宿主的 JSON Schema 校验拒("required is not supported on type string")——
1316
+ // 这是接线时踩到的真故障,不是可选加工。判定按 parameters.type==="object" 分:
1317
+ // 已经是 JSON Schema 的说明调用方自己包过,再包一次会把 schema 转坏。
1318
+ if (typeof kbToolDefinitions === "function") {
1319
+ for (const def of kbToolDefinitions({ kb, cfg: config })) {
1320
+ tools.push(def?.parameters?.type === "object" ? def : defineTool(def));
1321
+ }
1322
+ }
1292
1323
 
1293
1324
  for (const tool of tools) {
1294
1325
  if (hiddenTools.has(tool.name)) {
@@ -36,6 +36,14 @@ export function escapePromptVars(config, text) {
36
36
  * 按会话身份取人格卡。返回 `{ card, source }`,`source` 是给面板解释「用的哪张卡」的
37
37
  * 读数,不参与判定。
38
38
  *
39
+ * 寻址维度(#606 起两维,子 agent 继承再加一维共三维):`sessionId`(绑会话卡,
40
+ * 最具体)优先于 `parentSessionId`(子 agent 继承父会话卡),最后是 `agentId`(按预设建卡)。
41
+ * 理由是具体性——会话比预设更具体,而 DS Bot 这类宿主里 `agentPreset` 恒为
42
+ * `"standard"`(`ds-bot/src/host/roster.js:72-80` 强制写入,实测 50 个会话里 43 个是
43
+ * 它),若让 byAgent 优先,则谁给 `standard` 建一张卡就会盖掉所有按会话建的卡。
44
+ * 父会话卡排在 byAgent 之前:子 agent 自己没卡时,它属于哪段对话比它属于哪个预设
45
+ * 更能说明「该用谁的人格」。
46
+ *
39
47
  * `strict`(config.personaStrictAddressing,默认关)决定「有身份、没这张身份的卡」
40
48
  * 与「这张卡被显式关掉」这两种情况:
41
49
  * - 关(现状):落 default 卡。default 于是变成「所有没建卡的 agent 都能拿到它」,
@@ -44,20 +52,45 @@ export function escapePromptVars(config, text) {
44
52
  * 身份」的会话(无名会话本来就是 default 卡的受众)。
45
53
  * 默认关是有意的:这是一次口径变更,不能让老用户的注入文本在升级后静默变化。
46
54
  *
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
- * 于是必然落进后一种处境:开了严格寻址就把唯一那张卡静默关死。
55
+ * 严格模式有一格例外(#587):两维**都没建过卡**时仍回落 default。两种处境必须
56
+ * 分开——「建了卡、只是没这一张的卡」是真该按 none 处理(否则关掉一张卡等于没关),
57
+ * 而「一张卡都没建」时 default 是用户手里唯一的一张卡,按 none 处理等于把它永久
58
+ * 废掉,且面板不报错。判据看两维合起来的键数(#606 起):只建了 bySession 卡时,
59
+ * 一个按 byAgent 寻址的会话同样不该被 default 兜底——用户已经做过分区了。
53
60
  */
54
- export function resolvePersonaCard(settings, agentId, { strict = false } = {}) {
61
+ export function resolvePersonaCard(
62
+ settings,
63
+ agentId,
64
+ { strict = false, sessionId = null, parentSessionId = null } = {}
65
+ ) {
55
66
  const store = settings.getPersona();
56
67
  const id = typeof agentId === "string" && agentId.trim() ? agentId.trim() : "";
68
+ const sid = typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : "";
69
+ const pid =
70
+ typeof parentSessionId === "string" && parentSessionId.trim() ? parentSessionId.trim() : "";
71
+ // 第一维:绑会话卡(最具体)
72
+ const sessionCard = sid ? store.bySession?.[sid] : null;
73
+ if (sessionCard && sessionCard.enabled === true) return { card: sessionCard, source: "bySession" };
74
+ // 第二维(子 agent 继承):自己没卡就取父会话的卡。
75
+ // 为什么必须有这一维:子 agent 是新会话,用户不可能给每个派生会话都建一张卡;而
76
+ // strict 一旦生效,「有身份、没这张身份的卡」= 不注入人格,子 agent 会从「自称父
77
+ // 会话的人格」变成「不知道自己是谁」——比串味更糟。继承父会话卡才是用户能预期的
78
+ // 语义:同一个 agent 派生出去的手脚,仍是同一个 agent。
79
+ // 注意与 `src/session-gate.js` 的口径区别:那里**刻意不**用 parentSession 判「是不是
80
+ // 子代理」(宿主 fork 出来的主会话同样带 parentSession,用它判会把主会话误关)。
81
+ // 这里要问的不是「你是不是子代理」而是「你自己有没有卡」,所以 fork 出的分支继承
82
+ // 父卡也是正确的(它就是同一段对话的副本)。父 id 与自身 id 相同(脏数据)时跳过,
83
+ // 免得一次寻址查两遍同一个键。
84
+ const parentCard = pid && pid !== sid ? store.bySession?.[pid] : null;
85
+ if (parentCard && parentCard.enabled === true) return { card: parentCard, source: "parentSession" };
86
+ // 第三维:按预设/身份建的卡
57
87
  const card = id ? store.byAgent[id] : null;
58
88
  if (card && card.enabled === true) return { card, source: "byAgent" };
59
- // 严格模式只在「确实建过身份卡」时才拒绝兜底:一张都没建时兜底是唯一的出路。
60
- if (id && strict && Object.keys(store.byAgent).length > 0) return { card: {}, source: "none" };
89
+ // 严格模式只在「确实建过卡」时才拒绝兜底:两维都空时兜底是唯一的出路。
90
+ const anyCard = Object.keys(store.byAgent ?? {}).length > 0 || Object.keys(store.bySession ?? {}).length > 0;
91
+ // 身份判定要把父会话 id 算进来:子 agent 只有父会话 id、没有自己的卡时,也是
92
+ // 「确实有身份」,不许被 default 兜底。
93
+ if ((id || sid || pid) && strict && anyCard) return { card: {}, source: "none" };
61
94
  return { card: store.default, source: "default" };
62
95
  }
63
96
 
@@ -78,57 +111,120 @@ export function resolveFactsCard(factsStore, agentId, { strict = false } = {}) {
78
111
  }
79
112
 
80
113
  /**
81
- * 渲染整块「用户设置」。`agentId` 是会话身份(解析不到传 null → 走 default 兜底)。
114
+ * 渲染整块「用户设置」。`agentId` 是会话身份(解析不到传 null → 走 default 兜底),
115
+ * `sessionId` 是绑会话卡的键(#606;解析不到传 null)。
82
116
  * `factsProvider` 是 profile-facts 域的读口(返回 `{ default: [...], byAgent: {...} }`),
83
117
  * 可选;**开关与 provider 双闸**——profileFactsEnabled 关着时连 provider 都不调,
84
- * 「关」必须是零读数而不是「读了不渲染」。返回 `{ text, source, agent, configGate }`:
118
+ * 「关」必须是零读数而不是「读了不渲染」。返回 `{ text, source, agent, session, configGate }`:
85
119
  * `text` 为最终注入文本(全空时 ""),`configGate` 是 personaEnabled 的现值——面板
86
120
  * 要靠它解释「卡开着为什么没生效」。
87
121
  */
88
- export function renderUserSettingsBlock({ settings, config, agentId = null, factsProvider = null }) {
122
+ export function renderUserSettingsBlock({
123
+ settings,
124
+ config,
125
+ agentId = null,
126
+ sessionId = null,
127
+ parentSessionId = null,
128
+ factsProvider = null
129
+ }) {
89
130
  const language = langOf(config);
90
131
  const profile = settings.getProfile().trim();
91
132
  const rules = settings.getRules();
92
133
  const enabled = config?.personaEnabled === true;
134
+ const strict = config?.personaStrictAddressing === true;
93
135
  const resolved = enabled
94
- ? resolvePersonaCard(settings, agentId, { strict: config?.personaStrictAddressing === true })
136
+ ? resolvePersonaCard(settings, agentId, { strict, sessionId, parentSessionId })
95
137
  : { card: {}, source: "default" };
96
138
  const persona = resolved.card;
97
- const personaLines = persona?.enabled === true
98
- ? [
99
- ...(typeof persona.name === "string" && persona.name.trim() ? [STR.personaNameLine[language](persona.name.trim())] : []),
100
- ...(typeof persona.personality === "string" && persona.personality.trim() ? [STR.personaPersonalityLine[language](persona.personality.trim())] : []),
101
- ...(typeof persona.replyStyle === "string" && persona.replyStyle.trim() ? [STR.personaReplyStyleLine[language](persona.replyStyle.trim())] : [])
102
- ]
103
- : [];
139
+ // 段(#600):每一行都带上它的 kind 与写回所需的定位信息,供面板按段读、按段改。
140
+ // 段文本是**未转义**的原样行;整串 `text` 仍走原来的「先 join 再统一转义」,
141
+ // 所以注入字节不变(转义只动连续花括号,换行不参与,逐段转义与整串转义等价)。
142
+ //
143
+ // `text` 是**带模板前缀的整行**(如 `- 用户画像:xxx`),不能拿去当新值回写,
144
+ // 否则每写一次叠一层前缀。可回写的段另带 `value`=原始值(无前缀、未转义);
145
+ // 没有原始值的段(表头、事实表头、截断尾注)不带 `value` 键。
146
+ const personaLines = [];
147
+ if (persona?.enabled === true) {
148
+ if (typeof persona.name === "string" && persona.name.trim()) {
149
+ const value = persona.name.trim();
150
+ personaLines.push({ kind: "personaName", field: "name", value, text: STR.personaNameLine[language](value) });
151
+ }
152
+ if (typeof persona.personality === "string" && persona.personality.trim()) {
153
+ const value = persona.personality.trim();
154
+ personaLines.push({ kind: "personaPersonality", field: "personality", value, text: STR.personaPersonalityLine[language](value) });
155
+ }
156
+ if (typeof persona.replyStyle === "string" && persona.replyStyle.trim()) {
157
+ const value = persona.replyStyle.trim();
158
+ personaLines.push({ kind: "personaReplyStyle", field: "replyStyle", value, text: STR.personaReplyStyleLine[language](value) });
159
+ }
160
+ // scenario / mesExample(#606):只有设置了才输出,未设置时本块逐字节不变。
161
+ if (typeof persona.scenario === "string" && persona.scenario.trim()) {
162
+ const value = persona.scenario.trim();
163
+ personaLines.push({ kind: "personaScenario", field: "scenario", value, text: STR.personaScenarioLine[language](value) });
164
+ }
165
+ if (typeof persona.mesExample === "string" && persona.mesExample.trim()) {
166
+ const value = persona.mesExample.trim();
167
+ personaLines.push({ kind: "personaMesExample", field: "mesExample", value, text: STR.personaMesExampleLine[language](value) });
168
+ }
169
+ }
104
170
  // 事实段:开关关 / 无 provider / 该卡没事实三种情况都不渲染,注入文本与
105
171
  // 未启用事实时逐字节一致(老用户零变化)。
106
172
  const factsResolved = config?.profileFactsEnabled === true && typeof factsProvider === "function"
107
- ? resolveFactsCard(factsProvider(), agentId, { strict: config?.personaStrictAddressing === true })
173
+ ? resolveFactsCard(factsProvider(), agentId, { strict })
108
174
  : { facts: [], source: "none" };
109
175
  const factLines = [];
110
176
  if (factsResolved.facts.length) {
111
177
  let chars = 0;
112
178
  let shown = 0;
113
179
  for (const fact of factsResolved.facts) {
114
- const line = STR.profileFactLine[language](String(fact));
180
+ const value = String(fact);
181
+ const line = STR.profileFactLine[language](value);
115
182
  if (shown >= PROFILE_FACTS_MAX_LINES || chars + line.length > PROFILE_FACTS_MAX_CHARS) break;
116
- factLines.push(line);
183
+ factLines.push({ kind: "fact", index: shown, value, text: line });
117
184
  chars += line.length;
118
185
  shown += 1;
119
186
  }
120
187
  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));
188
+ if (remaining > 0) factLines.push({ kind: "factsTruncated", remaining, text: STR.profileFactsTruncated[language](remaining) });
189
+ if (factLines.length) {
190
+ // 表头的 n 是**总条数**(含没注入的),与「另有 N 条未注入」互补,别改成 shown。
191
+ factLines.unshift({
192
+ kind: "factsHeader",
193
+ shown,
194
+ total: factsResolved.facts.length,
195
+ text: STR.profileFactsHeader[language](factsResolved.facts.length)
196
+ });
197
+ }
123
198
  }
124
199
  const id = typeof agentId === "string" && agentId.trim() ? agentId.trim() : null;
200
+ const sid = typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : null;
201
+ const pid =
202
+ typeof parentSessionId === "string" && parentSessionId.trim() ? parentSessionId.trim() : null;
125
203
  if (!profile && !rules.length && !personaLines.length && !factLines.length) {
126
- return { text: "", source: resolved.source, agent: id, configGate: enabled };
204
+ // `parent` 是读数(面板要能说清「这张卡是从父会话继承来的」),不参与判定。
205
+ return {
206
+ text: "",
207
+ segments: [],
208
+ source: resolved.source,
209
+ agent: id,
210
+ session: sid,
211
+ parent: pid,
212
+ configGate: enabled
213
+ };
127
214
  }
128
- const lines = [STR.userSettingsHeader[language]];
129
- lines.push(...personaLines);
130
- if (profile) lines.push(STR.profileLine[language](profile));
131
- lines.push(...factLines);
132
- for (const rule of rules) lines.push(STR.ruleLine[language](rule));
133
- return { text: escapePromptVars(config, lines.join("\n")), source: resolved.source, agent: id, configGate: enabled };
215
+ const segments = [{ kind: "header", text: STR.userSettingsHeader[language] }];
216
+ segments.push(...personaLines);
217
+ if (profile) segments.push({ kind: "profile", value: profile, text: STR.profileLine[language](profile) });
218
+ segments.push(...factLines);
219
+ for (const rule of rules) segments.push({ kind: "rule", value: rule, text: STR.ruleLine[language](rule) });
220
+ const lines = segments.map((s) => s.text);
221
+ return {
222
+ text: escapePromptVars(config, lines.join("\n")),
223
+ segments,
224
+ source: resolved.source,
225
+ agent: id,
226
+ session: sid,
227
+ parent: pid,
228
+ configGate: enabled
229
+ };
134
230
  }
@@ -65,35 +65,105 @@ export function createVectorIndex({ store, logger }) {
65
65
  return store.searchVector(queryVector, { limit, threshold });
66
66
  },
67
67
 
68
- /** Re-embed every row missing an embedding. Returns indexed count. */
68
+ /**
69
+ * Re-embed every row whose vector is missing OR came from another model.
70
+ *
71
+ * Returns { indexed, failed, skipped, remaining, error? } — three distinct
72
+ * outcomes instead of one `skipped` bucket that meant "failed", "threw" and
73
+ * "did not fit in the limit" all at once. #604.
74
+ */
69
75
  async rebuildIndex(embedder, { limit = 1000 } = {}) {
70
76
  // The loop needs a single-text embedder. Native embedSingle is preferred;
71
77
  // embed-only OpenAI-compatible clients (issue #10) are accepted too via
72
78
  // their `embed` single-text interface, so /vector-reindex no longer
73
79
  // silently returns 0 for them. An embedder exposing neither is ignored.
80
+ // #604: prefer the detailed entry point when the embedder has one. The
81
+ // legacy OpenAI-compatible client drops the failure reason on its normal
82
+ // path (embedText returns only the vector), so a provider outage and an
83
+ // unembeddable row both surfaced here as a bare null. The reason is what
84
+ // tells them apart, and the operator needs it to know whether pressing
85
+ // the button again is worth anything.
74
86
  let embedOne = null;
87
+ let embedOneDetailed = null;
88
+ if (embedder && typeof embedder.embedSingleDetailed === "function") {
89
+ embedOneDetailed = (text) => embedder.embedSingleDetailed(text);
90
+ }
75
91
  if (embedder && typeof embedder.embedSingle === "function") {
76
92
  embedOne = (text) => embedder.embedSingle(text);
77
93
  } else if (embedder && typeof embedder.embed === "function") {
78
94
  embedOne = (text) => Promise.resolve(embedder.embed(text));
79
95
  }
80
- if (!embedOne) return { indexed: 0, skipped: 0 };
81
- const rows = store.needsEmbedding(limit);
96
+ if (!embedOne && !embedOneDetailed) {
97
+ return { indexed: 0, failed: 0, skipped: 0, remaining: 0, error: "no-embedder" };
98
+ }
99
+ const model = embedder?.modelHash ?? null;
100
+ // #604: this used to call needsEmbedding(), which only matches empty
101
+ // vectors. After a model switch every row already has a vector — from the
102
+ // previous model — so "reindex" selected nothing, embedded nothing, and
103
+ // reported {indexed: 0, skipped: 0} as if the index were already current.
104
+ // The boot sweep has always used the model-aware selector; using the same
105
+ // one here is what makes the button mean what it says.
106
+ const rows = model && typeof store.needsEmbeddingFor === "function"
107
+ ? store.needsEmbeddingFor(model, limit)
108
+ : store.needsEmbedding(limit);
82
109
  let indexed = 0;
110
+ let failed = 0;
111
+ const failureReasons = {};
112
+ const noteFailure = (reason) => {
113
+ failureReasons[reason] = (failureReasons[reason] ?? 0) + 1;
114
+ };
83
115
  for (const row of rows) {
84
116
  try {
85
117
  const text = [row.title, row.content].filter(Boolean).join("\n");
86
- const vector = await embedOne(text);
118
+ let vector;
119
+ if (embedOneDetailed) {
120
+ const detailed = await embedOneDetailed(text);
121
+ vector = detailed ? detailed.vector : null;
122
+ if (!vector && detailed?.reason) noteFailure(`error:${detailed.reason}`);
123
+ } else {
124
+ vector = await embedOne(text);
125
+ }
87
126
  if (vector && vector.length) {
88
- store.setEmbedding(row.id, vector);
127
+ // #604: the model fingerprint was omitted here, so every rebuilt
128
+ // row kept embedding_model NULL. needsEmbeddingFor() then treated
129
+ // it as stale forever: the boot sweep re-embedded it on each start,
130
+ // the panel counted it as unsearchable, and pressing reindex again
131
+ // found nothing (the vector was no longer empty). Passing the
132
+ // fingerprint closes the loop between the write and the selector.
133
+ store.setEmbedding(row.id, vector, model ?? undefined);
89
134
  indexed++;
135
+ } else {
136
+ failed++;
137
+ // A detailed adapter already recorded why; the plain path has no
138
+ // reason to record, so name the only thing it can mean.
139
+ if (!embedOneDetailed) noteFailure("empty-vector");
140
+ logger?.warn?.(`[dsh-mneme] rebuild produced no vector for ${row.id}`);
90
141
  }
91
142
  } catch (error) {
143
+ failed++;
144
+ noteFailure(`error:${String(error)}`);
92
145
  logger?.warn?.(`[dsh-mneme] rebuild embed failed for ${row.id}: ${String(error)}`);
93
146
  }
94
147
  }
95
148
  if (indexed > 0 && embedder.modelHash) this.markModel(embedder.modelHash, embedder.dimension);
96
- return { indexed, skipped: rows.length - indexed };
149
+ // remaining = still selected by the same predicate after this run. Rows
150
+ // that failed stay in it (they were never written), which is exactly the
151
+ // number the caller needs to decide whether to run again.
152
+ const remaining = typeof store.countNeedsEmbeddingFor === "function"
153
+ ? store.countNeedsEmbeddingFor(model)
154
+ : null;
155
+ // `skipped` is 0 by definition here, not as a placeholder: every row the
156
+ // selector returned is attempted in the loop above, so nothing is left
157
+ // untouched. Rows that produced no vector are `failed`. The remaining
158
+ // bucket is the selector's own count, which is why a row can be both
159
+ // failed and remaining — it was tried and it is still stale.
160
+ //
161
+ // `failureReasons` mirrors the boot sweep's bucket names so the two paths
162
+ // read the same: `empty-vector` means this row will never embed, while an
163
+ // `error:` bucket means the provider or the store failed and the same
164
+ // press may well succeed next time. A run that only ever reports
165
+ // empty-vector is not a retry candidate.
166
+ return { indexed, failed, skipped: 0, remaining, failureReasons };
97
167
  },
98
168
 
99
169
  /** Index stats for the Web panel / debugging. */