@modusensus/dsh-mneme 0.12.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 (154) hide show
  1. package/README.md +48 -19
  2. package/docs/CONFIGURATION.md +43 -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 +581 -29
  13. package/lib/attachment-text.js +122 -0
  14. package/lib/choro-command.js +9 -2
  15. package/lib/client.js +3537 -1205
  16. package/lib/config.js +139 -1
  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/dream/decisions.js +56 -5
  28. package/lib/dream/sleep.js +616 -15
  29. package/lib/dream.js +45 -13
  30. package/lib/dsh-go/host.js +162 -0
  31. package/lib/dsh-go/panel.js +2091 -0
  32. package/lib/dsh-go/text.js +342 -0
  33. package/lib/dsh-go/views/common.js +68 -0
  34. package/lib/dsh-go/views/entity.js +48 -0
  35. package/lib/dsh-go/views/memory.js +198 -0
  36. package/lib/dsh-go/views/root.js +30 -0
  37. package/lib/dsh-go/views/settings.js +398 -0
  38. package/lib/dsh-go/views/status.js +51 -0
  39. package/lib/embedding.js +42 -8
  40. package/lib/entities/extractor.js +36 -7
  41. package/lib/hot-memory.js +8 -9
  42. package/lib/index.js +92 -2
  43. package/lib/inject.js +189 -18
  44. package/lib/kb/index.js +309 -0
  45. package/lib/kb/routes.js +220 -0
  46. package/lib/kb/tools.js +148 -0
  47. package/lib/lang.js +87 -4
  48. package/lib/peak-hours.js +57 -2
  49. package/lib/semantic.js +347 -60
  50. package/lib/service.js +193 -66
  51. package/lib/settings.js +112 -14
  52. package/lib/store.js +347 -21
  53. package/lib/summarize.js +38 -11
  54. package/lib/tools.js +35 -4
  55. package/lib/user-settings.js +128 -32
  56. package/lib/vector-index.js +76 -6
  57. package/lib/wiki/cli-hint.js +90 -0
  58. package/lib/wiki/queries.js +116 -2
  59. package/lib/wiki/t1-jobs.js +232 -0
  60. package/lib/wiki/t1.js +7 -1
  61. package/package.json +25 -1
  62. package/src/api-standalone.js +4 -1
  63. package/src/api.js +581 -29
  64. package/src/attachment-text.js +122 -0
  65. package/src/choro-command.js +9 -2
  66. package/src/config.js +139 -1
  67. package/src/continuity.js +7 -6
  68. package/src/diary/auto.js +190 -0
  69. package/src/diary/compose.js +440 -0
  70. package/src/diary/dir.js +59 -0
  71. package/src/diary/export.js +97 -0
  72. package/src/diary/index.js +26 -0
  73. package/src/diary/library.js +189 -0
  74. package/src/diary/qq-inbox.js +142 -0
  75. package/src/diary/rollover.js +61 -0
  76. package/src/diary/session-provider.js +122 -0
  77. package/src/dream/decisions.js +56 -5
  78. package/src/dream/sleep.js +616 -15
  79. package/src/dream.js +45 -13
  80. package/src/dsh-go/host.js +162 -0
  81. package/src/dsh-go/panel.js +2091 -0
  82. package/src/dsh-go/text.js +342 -0
  83. package/src/dsh-go/views/common.js +68 -0
  84. package/src/dsh-go/views/entity.js +48 -0
  85. package/src/dsh-go/views/memory.js +198 -0
  86. package/src/dsh-go/views/root.js +30 -0
  87. package/src/dsh-go/views/settings.js +398 -0
  88. package/src/dsh-go/views/status.js +51 -0
  89. package/src/embedding.js +42 -8
  90. package/src/entities/extractor.js +36 -7
  91. package/src/hot-memory.js +8 -9
  92. package/src/index.js +92 -2
  93. package/src/inject.js +189 -18
  94. package/src/kb/index.js +309 -0
  95. package/src/kb/routes.js +220 -0
  96. package/src/kb/tools.js +148 -0
  97. package/src/lang.js +87 -4
  98. package/src/peak-hours.js +57 -2
  99. package/src/semantic.js +347 -60
  100. package/src/service.js +193 -66
  101. package/src/settings.js +112 -14
  102. package/src/store.js +347 -21
  103. package/src/summarize.js +38 -11
  104. package/src/tools.js +35 -4
  105. package/src/user-settings.js +128 -32
  106. package/src/vector-index.js +76 -6
  107. package/src/wiki/cli-hint.js +90 -0
  108. package/src/wiki/queries.js +116 -2
  109. package/src/wiki/t1-jobs.js +232 -0
  110. package/src/wiki/t1.js +7 -1
  111. package/test/api-routes-gaps.test.js +63 -0
  112. package/test/api.test.js +375 -4
  113. package/test/attachment-text.test.js +140 -0
  114. package/test/bm25-corpus-cache.test.js +145 -0
  115. package/test/capacity-warning.test.js +262 -0
  116. package/test/client.test.js +779 -154
  117. package/test/conflict-freeze.test.js +45 -1
  118. package/test/continuity.test.js +8 -1
  119. package/test/diary-library.test.js +187 -0
  120. package/test/diary.test.js +576 -0
  121. package/test/dream-maxtokens-fallback.test.js +3 -3
  122. package/test/dream.test.js +62 -1
  123. package/test/dsh-go-host.test.js +134 -0
  124. package/test/dsh-go-panel-actions.test.js +854 -0
  125. package/test/dsh-go-panel.test.js +251 -0
  126. package/test/embedding-ready.test.js +33 -3
  127. package/test/entities.test.js +73 -0
  128. package/test/forget-corrections.test.js +70 -0
  129. package/test/image-memory.test.js +171 -0
  130. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  131. package/test/kb-api-wiring.test.js +128 -0
  132. package/test/kb-collections.test.js +272 -0
  133. package/test/kb-routes.test.js +379 -0
  134. package/test/mint-source.test.js +26 -0
  135. package/test/peak-hours-observability.test.js +70 -0
  136. package/test/persona-api.test.js +3 -3
  137. package/test/persona-by-session.test.js +568 -0
  138. package/test/persona-settings.test.js +7 -7
  139. package/test/reflection-update-gate.test.js +129 -0
  140. package/test/reindex-backfill.test.js +334 -0
  141. package/test/reindex-scheduler.test.js +369 -0
  142. package/test/scope-candidates.test.js +249 -0
  143. package/test/sleep-action-set.test.js +14 -0
  144. package/test/sleep-heat.test.js +49 -0
  145. package/test/sleep.test.js +598 -0
  146. package/test/standalone-api.test.js +24 -0
  147. package/test/store.test.js +20 -0
  148. package/test/summarize.test.js +26 -0
  149. package/test/user-settings-segments.test.js +528 -0
  150. package/test/vector-index.test.js +120 -6
  151. package/test/wiki-api.test.js +355 -0
  152. package/test/wiki-cli-hint.test.js +118 -0
  153. package/test/wiki-queries.test.js +7 -0
  154. package/test/wiki-t1-jobs.test.js +125 -0
@@ -0,0 +1,232 @@
1
+ // dsh-mneme/src/wiki/t1-jobs.js
2
+ // T1 面板后台任务的进程账与状态机(契约 §3.2)。
3
+ //
4
+ // 为什么住在这里而不是 api.js:T1 要 spawn 子进程,而 api.js 刻意只依赖少数 node
5
+ // 内置(不 import child_process)——HTTP 面只做参数解析与闸门分叉,进程生命周期与
6
+ // 状态账归 wiki 层。副作用是单测可以在 API 侧注入假 job 对象,永不真跑一次 T1
7
+ // (真跑要花模型额度,测试里花钱是不可接受的)。
8
+ //
9
+ // 为什么 spawn CLI 而不是宿主进程内直接 createT1():单模块 30–240s,挂在宿主事件
10
+ // 循环上会拖住整个插件;而 bin/dsh-mneme-choro.mjs --t1 已经是那条唯一收费入口,
11
+ // 面板再走一遍编排逻辑等于造第二条路径,两边迟早不一致(收据形状、降级语义都会漂)。
12
+ //
13
+ // stdout 契约(防一类回归):CLI 的 --json 用 JSON.stringify(receipt, null, 2) 输出
14
+ // ——**多行 JSON,不是 NDJSON**。所以 ring buffer 按行收(面板要的是人能看的日志尾巴),
15
+ // 回执解析另留一份可截断的全文,别指望从 200 行里拼出完整 JSON。
16
+ import { spawn as nodeSpawn } from "node:child_process";
17
+ import { randomUUID } from "node:crypto";
18
+ import { dirname, join } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ // lib/wiki/t1-jobs.js 与 src/wiki/t1-jobs.js 到 bin/ 都是上两级:sync 只换目录名,
22
+ // 深度不变,所以这里可以硬编码相对位置。
23
+ const DEFAULT_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "bin", "dsh-mneme-choro.mjs");
24
+
25
+ // 日志尾巴的上限取 200 行:面板是个窄弹层,再多也没人往下翻;而上限存在的真理由
26
+ // 是长任务会往 stdout 泼几万行,不封顶就是内存泄漏。
27
+ const DEFAULT_RING = 200;
28
+ // 已完成任务保留条数:status 要能被反复轮询(面板 2s 一次),但也不能无限留。
29
+ const DEFAULT_FINISHED = 20;
30
+
31
+ /** 逐行切分:chunk 边界可能落在一行中间,所以每路各留一个未完成余量。 */
32
+ function makeLineSplitter(onLine) {
33
+ let rest = "";
34
+ return {
35
+ push(chunk) {
36
+ rest += chunk;
37
+ const parts = rest.split(/\r?\n/);
38
+ rest = parts.pop() ?? "";
39
+ for (const line of parts) onLine(line);
40
+ },
41
+ flush() {
42
+ if (rest !== "") { onLine(rest); rest = ""; }
43
+ }
44
+ };
45
+ }
46
+
47
+ /**
48
+ * @param {object} opts
49
+ * @param {object} opts.store createStore 产物(只需要它来定位 bin 之外的路径时用;目前不读库)
50
+ * @param {Function} [opts.spawn] 注入的 spawn(测试注假实现,绝不真起进程)
51
+ * @param {string} [opts.nodePath] 跑脚本的解释器,缺省 process.execPath
52
+ * @param {string} [opts.scriptPath] CLI 路径,缺省按 lib/src 同深度推导
53
+ * @param {string|null} [opts.dbPath] 透传 --db;不传则 CLI 用自己的缺省记忆库
54
+ * @param {() => string} [opts.now]
55
+ * @param {number} [opts.ringLimit]
56
+ * @param {number} [opts.maxFinished]
57
+ */
58
+ export function createT1Jobs({
59
+ store = null,
60
+ spawn: spawnFn = nodeSpawn,
61
+ nodePath = process.execPath,
62
+ scriptPath = DEFAULT_SCRIPT,
63
+ dbPath = null,
64
+ now = () => new Date().toISOString(),
65
+ ringLimit = DEFAULT_RING,
66
+ maxFinished = DEFAULT_FINISHED,
67
+ env = process.env
68
+ } = {}) {
69
+ const jobs = new Map();
70
+ // 同一个仓库同时只许一个 T1:两个进程同时往同一批页面写,claims 与 status 会
71
+ // 交错成没人能解释的中间态(T1 的写路径没有跨进程锁,这是硬事实不是保守)。
72
+ const runningByRepo = new Map();
73
+
74
+ function pushLine(job, line) {
75
+ job.logTail.push(line);
76
+ if (job.logTail.length > ringLimit) job.logTail.splice(0, job.logTail.length - ringLimit);
77
+ }
78
+
79
+ /** 回执解析:只认 JSON.parsable 的完整 stdout;解析不出来就保持 0/0/0 不瞎猜。 */
80
+ function readCounts(job) {
81
+ const text = job.stdoutAll.trim();
82
+ if (text === "") return null;
83
+ let receipt;
84
+ try { receipt = JSON.parse(text); } catch { return null; }
85
+ if (!receipt || typeof receipt !== "object") return null;
86
+ if (Array.isArray(receipt.enriched) || Array.isArray(receipt.failed)) {
87
+ const done = Array.isArray(receipt.enriched) ? receipt.enriched.length : 0;
88
+ const failed = Array.isArray(receipt.failed) ? receipt.failed.length : 0;
89
+ return { total: done + failed, done, failed };
90
+ }
91
+ if (Array.isArray(receipt.candidates)) {
92
+ return { total: receipt.candidates.length, done: 0, failed: 0 };
93
+ }
94
+ return null;
95
+ }
96
+
97
+ function trimFinished() {
98
+ const finished = [...jobs.values()].filter((j) => j.state !== "running");
99
+ if (finished.length <= maxFinished) return;
100
+ finished.sort((a, b) => String(a.finishedAt).localeCompare(String(b.finishedAt)));
101
+ for (const job of finished.slice(0, finished.length - maxFinished)) jobs.delete(job.jobId);
102
+ }
103
+
104
+ function finish(job, state, error) {
105
+ if (job.state !== "running") return; // close 与 error 可能都到,只认第一次结算
106
+ job.state = state;
107
+ job.finishedAt = now();
108
+ if (error) job.error = error;
109
+ const counts = readCounts(job);
110
+ if (counts) job.modules = counts;
111
+ const key = String(job.repoKey);
112
+ if (runningByRepo.get(key) === job.jobId) runningByRepo.delete(key);
113
+ trimFinished();
114
+ }
115
+
116
+ /**
117
+ * 起一个后台 T1。返回 {started:true, jobId} 或 {started:false, code, jobId?}——
118
+ * 路由据此分叉 200 / 409,本层不碰 HTTP。
119
+ */
120
+ function start({ repo = null, module = null, retryFailed = false } = {}) {
121
+ const rootPath = typeof repo?.root_path === "string" ? repo.root_path : "";
122
+ if (rootPath === "") return { started: false, code: "repo-invalid" };
123
+ const repoKey = String(repo.id ?? rootPath);
124
+ const existing = runningByRepo.get(repoKey);
125
+ if (existing) {
126
+ const job = jobs.get(existing);
127
+ // runningByRepo 里还留着但 job 已经结算(理论不可达)时不该把它当占用,
128
+ // 否则这个仓库会被一条幽灵记录永久锁死。
129
+ if (job && job.state === "running") return { started: false, code: "job-running", jobId: existing };
130
+ runningByRepo.delete(repoKey);
131
+ }
132
+
133
+ const jobId = randomUUID();
134
+ const job = {
135
+ jobId,
136
+ repoKey,
137
+ repo: { id: repo.id ?? null, name: repo.name ?? null, rootPath },
138
+ state: "running",
139
+ startedAt: now(),
140
+ finishedAt: null,
141
+ modules: { total: 0, done: 0, failed: 0 },
142
+ logTail: [],
143
+ error: null,
144
+ stdoutAll: "",
145
+ stderrTail: []
146
+ };
147
+ jobs.set(jobId, job);
148
+ runningByRepo.set(repoKey, jobId);
149
+
150
+ const args = [scriptPath, rootPath, "--t1", "--apply", "--json"];
151
+ if (module) args.push("--module", String(module));
152
+ if (retryFailed === true) args.push("--retry-failed");
153
+ if (dbPath) args.push("--db", String(dbPath));
154
+
155
+ let child;
156
+ try {
157
+ child = spawnFn(nodePath, args, {
158
+ // Electron 宿主里 process.execPath 是 electron 本体;不设这个变量它会去开
159
+ // 窗口而不是跑 Node 脚本(t1.js 的真实 runner 用的是同一招)。
160
+ env: { ...env, ELECTRON_RUN_AS_NODE: "1" },
161
+ stdio: ["ignore", "pipe", "pipe"],
162
+ windowsHide: true
163
+ });
164
+ } catch (err) {
165
+ finish(job, "failed", String((err && err.message) || err));
166
+ return { started: true, jobId };
167
+ }
168
+ job.child = child;
169
+
170
+ const outLines = makeLineSplitter((line) => pushLine(job, line));
171
+ const errLines = makeLineSplitter((line) => {
172
+ job.stderrTail.push(line);
173
+ if (job.stderrTail.length > 20) job.stderrTail.shift();
174
+ });
175
+ child.stdout?.setEncoding?.("utf8");
176
+ child.stderr?.setEncoding?.("utf8");
177
+ child.stdout?.on?.("data", (chunk) => {
178
+ // 回执解析要完整 JSON,而 ring buffer 只留尾巴——所以另攒一份,封顶 256 KiB
179
+ // 防止一个跑飞的任务把宿主内存吃光。
180
+ if (job.stdoutAll.length < 256 * 1024) job.stdoutAll += chunk;
181
+ outLines.push(chunk);
182
+ });
183
+ child.stderr?.on?.("data", (chunk) => errLines.push(chunk));
184
+ child.on?.("error", (err) => {
185
+ outLines.flush();
186
+ errLines.flush();
187
+ finish(job, "failed", String((err && err.message) || err));
188
+ });
189
+ child.on?.("close", (code) => {
190
+ outLines.flush();
191
+ errLines.flush();
192
+ if (code === 0) {
193
+ finish(job, "done", null);
194
+ } else {
195
+ // CLI 的拒绝与异常都走 stderr + exit 1(bin:161-171):只报 exit code 等于
196
+ // 让面板显示「失败了,不知道为啥」,所以把 stderr 尾巴当 error 带出去。
197
+ const tail = job.stderrTail.length > 0 ? job.stderrTail[job.stderrTail.length - 1] : "";
198
+ finish(job, "failed", tail || `exit code ${code}`);
199
+ }
200
+ });
201
+ return { started: true, jobId };
202
+ }
203
+
204
+ /** 状态快照(不含 child 等内部件)。未知 jobId → null。 */
205
+ function status(jobId) {
206
+ const job = jobs.get(String(jobId ?? ""));
207
+ if (!job) return null;
208
+ const view = {
209
+ jobId: job.jobId,
210
+ repo: job.repo,
211
+ state: job.state,
212
+ startedAt: job.startedAt,
213
+ modules: { ...job.modules },
214
+ logTail: job.logTail.slice()
215
+ };
216
+ if (job.finishedAt) view.finishedAt = job.finishedAt;
217
+ if (job.error) view.error = job.error;
218
+ return view;
219
+ }
220
+
221
+ function dispose() {
222
+ for (const job of jobs.values()) {
223
+ if (job.state === "running") {
224
+ try { job.child?.kill?.(); } catch { /* 进程可能已退出:dispose 不该抛 */ }
225
+ }
226
+ }
227
+ jobs.clear();
228
+ runningByRepo.clear();
229
+ }
230
+
231
+ return { start, status, dispose, scriptPath };
232
+ }
package/src/wiki/t1.js CHANGED
@@ -24,6 +24,9 @@ import { join, dirname, sep, resolve } from "node:path";
24
24
  import { tmpdir } from "node:os";
25
25
  import { realpathSync } from "node:fs";
26
26
  import { extractHumanBlocks, injectHumanBlocks, T1_SECTION_START, T1_SECTION_END } from "./render.js";
27
+ // issue #607:拒绝文案里的 CLI 命令要给绝对路径入口(profile 装的插件没有全局
28
+ // shim,光写 `dsh-mneme-choro` 用户照抄只会 command not found)。
29
+ import { choroCliCommandFrom } from "./cli-hint.js";
27
30
 
28
31
  // 标记的单一来源在 render.js(页面格式的家);这里转出去,调用方与测试继续从
29
32
  // t1.js 取,不必知道它住在哪。
@@ -31,6 +34,9 @@ export { T1_SECTION_START, T1_SECTION_END };
31
34
  export const T1_FAIL_THRESHOLD = 2;
32
35
  export const DEFAULT_T1_MODEL = "xdpool/deepseek-v4.1-flash";
33
36
 
37
+ // #607:拒绝文案里的命令串推导一次(本文件在 src/wiki/ 与 lib/wiki/ 同一层)。
38
+ const T1_CLI = choroCliCommandFrom(import.meta.url, "<repo>");
39
+
34
40
  // 三重上限缺省(实现计划 P15):单模块预算——timeout 墙钟、工具调用次数、
35
41
  // 累计输出字节。真实成本实测见 V1(10s/次任务),这里给的是上限不是预期。
36
42
  export const DEFAULT_LIMITS = Object.freeze({
@@ -239,7 +245,7 @@ export function createT1({ store, runSession = null, limits = DEFAULT_LIMITS, no
239
245
  const rootPath = realpathSync(resolve(repoRoot));
240
246
  const repo = store.db.prepare("SELECT * FROM choro_repos WHERE root_path = ?").get(rootPath);
241
247
  if (!repo) {
242
- throw new T1Refusal("not-registered", `${rootPath} 还没有登记:先跑一次 T0(dsh-mneme-choro <repo> --apply)`);
248
+ throw new T1Refusal("not-registered", `${rootPath} 还没有登记:先跑一次 T0(${T1_CLI ?? "dsh-mneme-choro <repo> --apply"})`);
243
249
  }
244
250
 
245
251
  // 选模块:skeleton 必跑;failed 只在 retryFailed 时;enriched 跳过(重富化留待显式 --module)。
@@ -271,6 +271,52 @@ test("external-api PUT applies valid config and echoes it", async () => {
271
271
  assert.ok(typeof cfg.token === "string");
272
272
  });
273
273
 
274
+ // 名字过滤必须下推到 store:面板的「搜索实体」此前是自己取回 limit 条再本地
275
+ // 过滤,排在 limit 之外的实体永远搜不到。q 走 SQL LIKE,% 与 _ 要转义,否则
276
+ // 用户输入一个 "%" 就把整库匹配出来。
277
+ test("entities q filters by name in the store, not after the limit", async () => {
278
+ const { routes, service } = setup();
279
+ service.createEntity({ name: "SQLite", type: "technology" });
280
+ service.createEntity({ name: "记忆", type: "concept" });
281
+ service.createEntity({ name: "postgres", type: "technology" });
282
+ const route = handler(routes, "/api/dsh-mneme/entities");
283
+
284
+ let res = new FakeRes();
285
+ await route.handler(req("/api/dsh-mneme/entities?q=lite"), res);
286
+ let data = JSON.parse(res.body);
287
+ assert.deepEqual(data.entities.map((e) => e.name), ["SQLite"], "substring match");
288
+
289
+ // q 命中排在 limit 之外的那一条:这正是本地过滤拿不到的情形
290
+ res = new FakeRes();
291
+ await route.handler(req("/api/dsh-mneme/entities?limit=1&q=postgres"), res);
292
+ data = JSON.parse(res.body);
293
+ assert.deepEqual(data.entities.map((e) => e.name), ["postgres"]);
294
+
295
+ // 通配符必须被转义,不能变成「匹配一切」
296
+ res = new FakeRes();
297
+ await route.handler(req("/api/dsh-mneme/entities?q=%25"), res);
298
+ assert.equal(JSON.parse(res.body).entities.length, 0, "% must not match everything");
299
+ });
300
+
301
+ // 手机面板只需要 enabled/port/host 来渲染表单,但默认的 GET 会顺手 materialize
302
+ // 一个 Bearer 写进 user_settings —— 纯读变成写路径。?token=omit 让它只读。
303
+ test("external-api GET with token=omit neither materializes nor returns a token", async () => {
304
+ const { routes, settings } = setup();
305
+ const route = handler(routes, "/api/dsh-mneme/external-api");
306
+
307
+ const res = new FakeRes();
308
+ await route.handler(req("/api/dsh-mneme/external-api?token=omit"), res);
309
+ assert.equal(res.statusCode, 200);
310
+ const cfg = JSON.parse(res.body).config;
311
+ assert.equal(cfg.token, "", "no plaintext token on a read-only call");
312
+ assert.equal(settings.getExternalApi()?.token ?? "", "", "nothing persisted either");
313
+
314
+ // 不带参数的老客户端行为不变:仍然发 token 并落库
315
+ const res2 = new FakeRes();
316
+ await route.handler(req("/api/dsh-mneme/external-api"), res2);
317
+ assert.ok(JSON.parse(res2.body).config.token.length >= 16);
318
+ });
319
+
274
320
  // ---------------------------------------------------------------------------
275
321
  // /api/dsh-mneme/create(#386 第 5 条:面板手动新增记忆)
276
322
  // ---------------------------------------------------------------------------
@@ -944,6 +990,23 @@ test("wiring: maintenance is built before the panel createApi call and passed in
944
990
  assert.match(callText(src, standaloneAt), /maintenance/);
945
991
  });
946
992
 
993
+ test("wiring: the panel createApi receives createSemantic's surface, not a bare literal", () => {
994
+ // 接线锁。#604 的诊断面(backfill / receipts)挂在 createSemantic() 的返回值上,
995
+ // 而面板这一处曾传字面量 { vectorIndex, reranker },embeddingDiagnostics 挂不上去,
996
+ // api.js 的 typeof 守卫每次都落空 —— 真机上 data.embeddings 恒为 null,
997
+ // 而且没有任何用例断言过它,所以测试全绿。这里锁住传参形状。
998
+ const src = readFileSync(join(root, "src", "index.js"), "utf8");
999
+ const callAt = src.indexOf("createApi(ctx, service, settings");
1000
+ assert.ok(callAt >= 0, "src/index.js 里找不到面板 createApi 调用");
1001
+ const call = callText(src, callAt);
1002
+ assert.match(
1003
+ call, /\.\.\.semantic/,
1004
+ "面板 createApi 必须展开 createSemantic() 的返回值,否则 embeddingDiagnostics 永远挂不上"
1005
+ );
1006
+ assert.match(call, /vectorIndex/, "createSemantic() 不带 vectorIndex,必须显式补上");
1007
+ assert.match(call, /reranker/, "reranker 仍要传,/semantic 的 reranker 状态靠它");
1008
+ });
1009
+
947
1010
 
948
1011
  // ---------------------------------------------------------------------------
949
1012
  // /api/dsh-mneme/semantic/consolidation-stats(8 周沉淀图的数据源)