@modusensus/dsh-mneme 0.11.0 → 0.13.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 (174) hide show
  1. package/README.md +54 -21
  2. package/docs/CONFIGURATION.md +45 -4
  3. package/docs/DIRECTORIES.md +114 -0
  4. package/docs/DISTILL.md +64 -0
  5. package/docs/DSH-GO.md +97 -0
  6. package/docs/ENTITIES.md +5 -3
  7. package/docs/FORGET.md +77 -0
  8. package/docs/KB.md +110 -0
  9. package/docs/SLEEP.md +28 -3
  10. package/docs/STORAGE.md +23 -0
  11. package/lib/api-standalone.js +4 -1
  12. package/lib/api.js +744 -26
  13. package/lib/attachment-text.js +122 -0
  14. package/lib/choro-command.js +9 -2
  15. package/lib/client.js +3752 -819
  16. package/lib/config.js +171 -2
  17. package/lib/continuity.js +7 -6
  18. package/lib/diary/auto.js +190 -0
  19. package/lib/diary/compose.js +440 -0
  20. package/lib/diary/dir.js +59 -0
  21. package/lib/diary/export.js +97 -0
  22. package/lib/diary/index.js +26 -0
  23. package/lib/diary/library.js +189 -0
  24. package/lib/diary/qq-inbox.js +142 -0
  25. package/lib/diary/rollover.js +61 -0
  26. package/lib/diary/session-provider.js +122 -0
  27. package/lib/document.js +9 -3
  28. package/lib/dream/clustering.js +6 -1
  29. package/lib/dream/decisions.js +56 -5
  30. package/lib/dream/sleep.js +616 -15
  31. package/lib/dream.js +45 -13
  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 +8 -9
  44. package/lib/index.js +131 -5
  45. package/lib/inject.js +192 -19
  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 +115 -4
  50. package/lib/peak-hours.js +57 -2
  51. package/lib/profile-facts.js +356 -0
  52. package/lib/semantic.js +347 -60
  53. package/lib/service.js +302 -70
  54. package/lib/settings.js +117 -14
  55. package/lib/store.js +830 -55
  56. package/lib/summarize.js +38 -11
  57. package/lib/tools.js +173 -25
  58. package/lib/user-settings.js +175 -23
  59. package/lib/vector-index.js +76 -6
  60. package/lib/wiki/cli-hint.js +90 -0
  61. package/lib/wiki/queries.js +264 -10
  62. package/lib/wiki/t1-jobs.js +232 -0
  63. package/lib/wiki/t1.js +7 -1
  64. package/package.json +25 -1
  65. package/scripts/sync-test-badge.mjs +1 -1
  66. package/scripts/test-count-sync.mjs +9 -5
  67. package/src/api-standalone.js +4 -1
  68. package/src/api.js +744 -26
  69. package/src/attachment-text.js +122 -0
  70. package/src/choro-command.js +9 -2
  71. package/src/config.js +171 -2
  72. package/src/continuity.js +7 -6
  73. package/src/diary/auto.js +190 -0
  74. package/src/diary/compose.js +440 -0
  75. package/src/diary/dir.js +59 -0
  76. package/src/diary/export.js +97 -0
  77. package/src/diary/index.js +26 -0
  78. package/src/diary/library.js +189 -0
  79. package/src/diary/qq-inbox.js +142 -0
  80. package/src/diary/rollover.js +61 -0
  81. package/src/diary/session-provider.js +122 -0
  82. package/src/document.js +9 -3
  83. package/src/dream/clustering.js +6 -1
  84. package/src/dream/decisions.js +56 -5
  85. package/src/dream/sleep.js +616 -15
  86. package/src/dream.js +45 -13
  87. package/src/dsh-go/host.js +162 -0
  88. package/src/dsh-go/panel.js +2091 -0
  89. package/src/dsh-go/text.js +342 -0
  90. package/src/dsh-go/views/common.js +68 -0
  91. package/src/dsh-go/views/entity.js +48 -0
  92. package/src/dsh-go/views/memory.js +198 -0
  93. package/src/dsh-go/views/root.js +30 -0
  94. package/src/dsh-go/views/settings.js +398 -0
  95. package/src/dsh-go/views/status.js +51 -0
  96. package/src/embedding.js +42 -8
  97. package/src/entities/extractor.js +36 -7
  98. package/src/hot-memory.js +8 -9
  99. package/src/index.js +131 -5
  100. package/src/inject.js +192 -19
  101. package/src/kb/index.js +309 -0
  102. package/src/kb/routes.js +220 -0
  103. package/src/kb/tools.js +148 -0
  104. package/src/lang.js +115 -4
  105. package/src/peak-hours.js +57 -2
  106. package/src/profile-facts.js +356 -0
  107. package/src/semantic.js +347 -60
  108. package/src/service.js +302 -70
  109. package/src/settings.js +117 -14
  110. package/src/store.js +830 -55
  111. package/src/summarize.js +38 -11
  112. package/src/tools.js +173 -25
  113. package/src/user-settings.js +175 -23
  114. package/src/vector-index.js +76 -6
  115. package/src/wiki/cli-hint.js +90 -0
  116. package/src/wiki/queries.js +264 -10
  117. package/src/wiki/t1-jobs.js +232 -0
  118. package/src/wiki/t1.js +7 -1
  119. package/test/api-routes-gaps.test.js +63 -0
  120. package/test/api.test.js +377 -4
  121. package/test/attachment-text.test.js +140 -0
  122. package/test/bm25-corpus-cache.test.js +145 -0
  123. package/test/capacity-warning.test.js +262 -0
  124. package/test/client.test.js +908 -138
  125. package/test/config.test.js +79 -0
  126. package/test/conflict-freeze.test.js +45 -1
  127. package/test/continuity.test.js +8 -1
  128. package/test/diary-library.test.js +187 -0
  129. package/test/diary.test.js +576 -0
  130. package/test/document.test.js +32 -0
  131. package/test/dream-maxtokens-fallback.test.js +3 -3
  132. package/test/dream.test.js +62 -1
  133. package/test/dsh-go-host.test.js +134 -0
  134. package/test/dsh-go-panel-actions.test.js +854 -0
  135. package/test/dsh-go-panel.test.js +251 -0
  136. package/test/embedding-cache-f32.test.js +276 -0
  137. package/test/embedding-ready.test.js +33 -3
  138. package/test/entities.test.js +73 -0
  139. package/test/forget-corrections.test.js +70 -0
  140. package/test/image-memory.test.js +171 -0
  141. package/test/inject-pin-explicit.test.js +300 -0
  142. package/test/inject-pin-vector-crowd.test.js +225 -0
  143. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  144. package/test/kb-api-wiring.test.js +128 -0
  145. package/test/kb-collections.test.js +272 -0
  146. package/test/kb-routes.test.js +379 -0
  147. package/test/mint-source.test.js +26 -0
  148. package/test/peak-hours-observability.test.js +70 -0
  149. package/test/persona-api.test.js +3 -3
  150. package/test/persona-by-session.test.js +568 -0
  151. package/test/persona-settings.test.js +38 -7
  152. package/test/profile-facts.test.js +492 -0
  153. package/test/reflection-update-gate.test.js +129 -0
  154. package/test/reindex-backfill.test.js +334 -0
  155. package/test/reindex-scheduler.test.js +369 -0
  156. package/test/scope-candidates.test.js +249 -0
  157. package/test/session-gate.test.js +1 -1
  158. package/test/sleep-action-set.test.js +14 -0
  159. package/test/sleep-heat.test.js +49 -0
  160. package/test/sleep.test.js +598 -0
  161. package/test/standalone-api.test.js +24 -0
  162. package/test/store.test.js +20 -0
  163. package/test/summarize.test.js +26 -0
  164. package/test/test-count-sync.test.js +14 -3
  165. package/test/tool-state-ignore.test.js +65 -0
  166. package/test/tools-dto-schema-matrix.test.js +23 -2
  167. package/test/tools.test.js +144 -7
  168. package/test/user-settings-segments.test.js +528 -0
  169. package/test/vector-index.test.js +120 -6
  170. package/test/wiki-api.test.js +476 -1
  171. package/test/wiki-cli-hint.test.js +118 -0
  172. package/test/wiki-queries.test.js +91 -1
  173. package/test/wiki-t1-jobs.test.js +125 -0
  174. package/test/wiki-tools.test.js +131 -76
package/src/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/src/tools.js CHANGED
@@ -9,8 +9,18 @@ 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>");
22
+ const incompleteChoroSearch = (pages) =>
23
+ `Search was incomplete because these pages could not be read (${pages.join(", ")}). Check the local checkout and page permissions.`;
14
24
  // Per-registry tool-name registry: guards against duplicate registration on
15
25
  // live patch reload (DSH Desktop `patchReload: "live"`) where a plugin may be
16
26
  // re-applied on the same tools registry without an intervening unregister.
@@ -79,7 +89,8 @@ const MEMORY_PROVENANCE = (m) => {
79
89
  // choro(M1):chorography 只读查询对象(createChoroQueries 产物)。缺省 null =
80
90
  // 装配层没接(headless/测试),三个 choro_* 工具走 hiddenTools 闸根本不注册。
81
91
  // 它与 service 刻意分开传——chorography 的所有权模型与记忆域相反(spec §3)。
82
- 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) {
83
94
  const toolsRegistry = ctx.tools;
84
95
  let registration = REGISTERED_TOOLS.get(toolsRegistry);
85
96
  if (!registration) {
@@ -92,14 +103,17 @@ export function createTools(ctx, service, config, embedder, sessionGate = null,
92
103
  // flag 关闭时恒返回 null——写入不标注,检索不加权,行为与 A1 前完全一致。
93
104
  // logger 透传:registry 反查失败时 warnOnce 才有出口(否则静默降级无观测)。
94
105
  const resolveSessionScope = createScopeResolver({ ctx, config, logger: ctx.logger });
95
- // choro 工具共用的 repo 解析(M1):不带 repo 且只有一个登记仓时自动收敛;
96
- // 多仓歧义时把可选清单带回给模型(render 层会把它转成人话)。三个 execute 里
97
- // 都先走这里,错误形状统一 { error, ... },output.schema 容忍多余键由
98
- // additionalProperties:false + render 兜底——错误分支直接透传给渲染层判。
106
+ // 宿主在 render 前按成功 schema 校验返回值,仓库解析失败要走工具错误通道。
99
107
  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" };
108
+ const row = choro.resolveRepo(ref);
109
+ if (!row) {
110
+ const reason = ref ? `Repository ${JSON.stringify(ref)} is not registered.` : "No repository is registered for chorography.";
111
+ throw new Error(`${reason} ${CHORO_CLI ? `Run \`${CHORO_CLI}\`` : "Run `dsh-mneme-choro <repo> --apply`"} to register a repository.`);
112
+ }
113
+ if (row.ambiguous) {
114
+ const choices = row.ambiguous.map(({ id, name }) => `${id} (${name})`).join(", ");
115
+ throw new Error(`More than one repository is registered. Pass one of these repository IDs (${choices}).`);
116
+ }
103
117
  return row;
104
118
  };
105
119
  // #249 能力说明(injectGuidanceEnabled):开启时给「何时用」最有歧义的两个
@@ -448,7 +462,11 @@ export function createTools(ctx, service, config, embedder, sessionGate = null,
448
462
  name: "memory_forget",
449
463
  description:
450
464
  "Stop a memory from being auto-injected and from appearing in searches and lists without deleting it. " +
451
- "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).",
452
470
  parameters: {
453
471
  id: { type: "string", required: true },
454
472
  forgotten: { type: "boolean", description: "Suppress (true, default) or restore (false) the entry's visibility" }
@@ -479,6 +497,110 @@ export function createTools(ctx, service, config, embedder, sessionGate = null,
479
497
  }
480
498
  }),
481
499
 
500
+ // #249 第二批:显式 pin。注入侧原来靠「type 是 constraint/preference 就算
501
+ // 重要」推断谁该每轮置顶,那是隐含假设——库里 1100+ 条自动成了「每轮必进」
502
+ // 的候选而预算只有 5,真正该进去的反而排到二十名外。这个工具是那条推断的
503
+ // 唯一人工替代口:钉住 = 每轮注入块最前面那几条(受 pinnedInjectBudget 约束),
504
+ // 与类型无关(写得最全的那版常常是 pattern)。
505
+ //
506
+ // 三个边界写在这里:
507
+ // - 它不改记忆内容,也不改任何「记忆行」字段,只动 pinned_memories 这张关系
508
+ // 表,所以与 memory_forget / memory_archive / set_scope_state 都是**不同的轴**:
509
+ // 遗忘/归档/暂停作用域仍然优先——被它们处理掉的行就算钉着也不会注入,
510
+ // 回执里的 injectable 就是如实告诉我们这件事。
511
+ // - 存在性校验走 visibleForTool:strictScope 下 he scope 的行按「不存在」处理,
512
+ // 与 memory_delete 同款无存在性泄漏。
513
+ // - unpin 幂等:本来没钉也返回 pinned:false,不报错。
514
+ defineTool({
515
+ name: "memory_pin",
516
+ description:
517
+ "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. " +
518
+ "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. " +
519
+ "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. " +
520
+ "Pass unpin: true to release it. Pin order is first-pinned-first, so the oldest pin holds its slot when the budget is tight.",
521
+ parameters: {
522
+ id: { type: "string", required: true, description: "Memory id to pin (or unpin with unpin: true)" },
523
+ note: { type: "string", description: "Optional short reason to keep with the pin, so a later reader knows why it is pinned" },
524
+ unpin: { type: "boolean", description: "true releases the pin instead of setting it" }
525
+ },
526
+ output: {
527
+ schema: {
528
+ type: "object",
529
+ additionalProperties: false,
530
+ properties: {
531
+ memory_id: { type: "string", required: true },
532
+ pinned: { type: "boolean", required: true },
533
+ // 如实回显库现值,不是回显入参:重钉一条已有的 pin 不刷新 created_at,
534
+ // 回显它才看得出「这次其实没改变顺序」。
535
+ created_at: { type: "string" },
536
+ note: { type: "string" },
537
+ // 钉着 ≠ 会注入。遗忘/归档/暂停作用域/autoInject 关/pinnedInjectBudget=0
538
+ // 都优先于 pin,把这件事放进回执,而不是让用户以为钉完就一定出现在上下文里。
539
+ // 回执回答的是「pin 这条通道此刻开不开」:报 false 不等于这条记忆一定不在
540
+ // 上下文里——它仍可能凭自身重要性走常规候选路进块。
541
+ // autoInject 是这条通道的总闸:关着时宿主根本不注册注入器
542
+ // (src/index.js:617 的 if (cfg.autoInject)),pin 段自然也不执行。
543
+ injectable: { type: "boolean", required: true }
544
+ }
545
+ },
546
+ render: (_args, value) => {
547
+ if (!value.pinned) return TEXT_OUTPUT(`Memory ${value.memory_id} is no longer pinned.`);
548
+ const when = value.created_at ? ` (pinned at ${value.created_at})` : "";
549
+ const blocked = value.injectable
550
+ ? ""
551
+ : " 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.";
552
+ return TEXT_OUTPUT(`Memory ${value.memory_id} is pinned${when}.${blocked}`);
553
+ }
554
+ },
555
+ async execute(args) {
556
+ // 不可见(explicit 他 scope)= 视作不存在,无存在性泄漏。
557
+ const target = visibleForTool(args.id, arguments[1]);
558
+ if (target === undefined) throw new Error("memory not found");
559
+ // 钉着 ≠ 会注入。这里必须跟注入侧用同一个出口判定,否则回执照样会说谎:
560
+ // 暂停作用域的行在 enforce 档会被 scopeStateGate 剔掉,但 forgotten/archived
561
+ // 两个标志位对此一无所知——只算这两项,一条被暂停作用域挡住的行就会报
562
+ // injectable: true 而实际一条都没注入。filterPaused 正是那个出口(开关关、
563
+ // 或只开观察档时它原样返回,于是此时报 true 是对的:那种状态下本来就不拦)。
564
+ // #593 遗留缺口:pin 池整体由 pinnedInjectBudget 开关(src/service.js:1885 的
565
+ // pinnedBudget > 0 才读表选路),而它默认 0 = 关闭。只看遗忘/归档/暂停作用域时,
566
+ // 预算为 0 的一条回执照样报 injectable: true——钉住的动作确实成功了,但它此刻
567
+ // 一条都不会被注入,这正是「返回成功不等于生效」。预算 0 必须报 false。
568
+ //
569
+ // 同一类谎话的第二处(CodeRabbit 在 #596 指出):autoInject 才是 pin 通道的总闸
570
+ // ——关着时 src/index.js:617 根本不注册注入器,pin 段无从执行,而回执仍会报
571
+ // true。config schema 里它的默认值是 true(src/config.js:10),所以按「只有显式
572
+ // false 才算关」判:拿不到已填 defaults 的 config(测试直连 createTools)时不会
573
+ // 被误报成 false。
574
+ //
575
+ // 仍然没建模的:候选池侧的 type / importance / codingGate 闸(一条被钉的
576
+ // document 行同样进不了注入块)。那几道闸随候选池变化,回执不做猜测;
577
+ // 这里只回答「pin 这条通道此刻开不开」——报 false 也不等于这条记忆一定不在
578
+ // 上下文里,它仍可能凭自身重要性走常规候选路进块。
579
+ const pinnedBudget = Math.max(0, Math.min(5, Math.floor(config?.pinnedInjectBudget ?? 0)));
580
+ const autoInjectOn = config?.autoInject !== false;
581
+ const injectable =
582
+ autoInjectOn &&
583
+ pinnedBudget > 0 &&
584
+ !target.forgotten &&
585
+ !target.archived &&
586
+ service.filterPaused([target]).length > 0;
587
+ if (args.unpin === true) {
588
+ service.unpinMemory(target.id);
589
+ return { memory_id: target.id, pinned: false, injectable };
590
+ }
591
+ const row = service.pinMemory(target.id, { note: args.note });
592
+ if (row === undefined) throw new Error("pin could not be recorded");
593
+ return {
594
+ memory_id: row.memory_id,
595
+ pinned: true,
596
+ created_at: row.created_at,
597
+ // 没写过理由时键整个不出现(output schema 是闭合的,留 undefined 等于空壳)。
598
+ ...(row.note === undefined ? {} : { note: row.note }),
599
+ injectable
600
+ };
601
+ }
602
+ }),
603
+
482
604
  defineTool({
483
605
  name: "memory_archive",
484
606
  description:
@@ -1081,7 +1203,6 @@ export function createTools(ctx, service, config, embedder, sessionGate = null,
1081
1203
  },
1082
1204
  async execute(args) {
1083
1205
  const repo = resolveChoroRepo(args?.repo);
1084
- if (repo.error) return repo;
1085
1206
  const { modules, edges } = choro.moduleMap(repo.id);
1086
1207
  return { repo: repo.name, modules, edges };
1087
1208
  }
@@ -1107,10 +1228,16 @@ export function createTools(ctx, service, config, embedder, sessionGate = null,
1107
1228
  },
1108
1229
  async execute(args) {
1109
1230
  const repo = resolveChoroRepo(args?.repo);
1110
- if (repo.error) return repo;
1111
1231
  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;
1232
+ if (!page) {
1233
+ throw new Error(`Page ${JSON.stringify(args?.slug ?? "")} was not found. Use choro_module_map to list page slugs.`);
1234
+ }
1235
+ if (page.error === "page-missing") {
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.`);
1237
+ }
1238
+ if (page.error) {
1239
+ throw new Error(`Page ${JSON.stringify(page.slug)} could not be read (${page.relPath}). Check the local file and its permissions.`);
1240
+ }
1114
1241
  return { slug: page.slug, content: page.content };
1115
1242
  }
1116
1243
  }),
@@ -1129,26 +1256,29 @@ export function createTools(ctx, service, config, embedder, sessionGate = null,
1129
1256
  type: "object",
1130
1257
  additionalProperties: false,
1131
1258
  properties: {
1132
- hits: { type: "array", required: true, items: { type: "object", additionalProperties: true } }
1259
+ hits: { type: "array", required: true, items: { type: "object", additionalProperties: true } },
1260
+ unreadablePages: { type: "array", items: { type: "string" }, description: "Page slugs that could not be read during the search" }
1133
1261
  }
1134
1262
  },
1135
1263
  render: (_args, value) => {
1136
- const hits = value.hits ?? [];
1264
+ const { hits, unreadablePages } = value;
1137
1265
  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"));
1266
+ const lines = hits.map((hit) => `${hit.slug} · ${hit.section} · L${hit.line}: ${hit.excerpt}`);
1267
+ if (unreadablePages?.length) lines.push("", incompleteChoroSearch(unreadablePages));
1268
+ return TEXT_OUTPUT(lines.join("\n"));
1141
1269
  }
1142
1270
  },
1143
1271
  async execute(args) {
1144
1272
  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
- };
1273
+ const unreadablePages = [];
1274
+ const hits = choro.search(repo.id, args?.query, {
1275
+ moduleSlug: args?.module ?? null,
1276
+ limit: args?.limit,
1277
+ onUnreadablePage: (slug) => unreadablePages.push(slug)
1278
+ });
1279
+ if (unreadablePages.length === 0) return { hits };
1280
+ if (hits.length === 0) throw new Error(incompleteChoroSearch(unreadablePages));
1281
+ return { hits, unreadablePages };
1152
1282
  }
1153
1283
  })
1154
1284
  ];
@@ -1172,6 +1302,24 @@ export function createTools(ctx, service, config, embedder, sessionGate = null,
1172
1302
  hiddenTools.add("choro_read_page");
1173
1303
  hiddenTools.add("choro_search");
1174
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
+ }
1175
1323
 
1176
1324
  for (const tool of tools) {
1177
1325
  if (hiddenTools.has(tool.name)) {