@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/dream.js CHANGED
@@ -164,6 +164,17 @@ export function buildOutcome(decisions) {
164
164
  byId[d.loser] = "superseded-archived";
165
165
  } else if (d.action === "differentiate") {
166
166
  for (const id of d.ids ?? []) byId[id] = "differentiated";
167
+ } else if (d.action === "create") {
168
+ // #619:create 铸出新行——记下「这一条是这一轮新生的」。它此前特意不派
169
+ // disposition,好让 receipt_chain 不为它产行(新行的改动无处可记,只有
170
+ // memory_mints 记出生)。这里补上的是审计面能读懂的那句话,不改 receipt_chain。
171
+ if (d.id) byId[d.id] = "created";
172
+ } else if (d.action === "create_merged") {
173
+ // #619 / Ephor 第七轮 ②:create 撞上同题活跃行时走原地刷新,没有新条目。
174
+ // 这条动作原本在 if 链里缺席,`outcome.byId` 就是空对象——一个只刷新不铸造
175
+ // 的轮,审计行看不出任何目标被处置过(#126 review 在 supersede 上踩过同一个
176
+ // 坑)。刷新有自己的名字,不冒充 created。
177
+ if (d.id) byId[d.id] = "created-refreshed";
167
178
  }
168
179
  }
169
180
  return { byId };
@@ -1172,6 +1183,9 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1172
1183
  // manual review instead of auto-adjudicated. Read once up front so the
1173
1184
  // prompt hint and the apply-split agree on the same gate.
1174
1185
  const freezeEnabled = config.conflictFreezeEnabled === true;
1186
+ // reflectionUpdateEnabled(默认 true,「关」= 旧记忆永不被自动改写)。同样先读
1187
+ // 一次,prompt 后缀与校验器选项用同一个值,避免两处各读一次 cfg 后彼此不一致。
1188
+ const updateEnabled = config.reflectionUpdateEnabled !== false;
1175
1189
  // Every exit (success or failure) funnels through `finish`, which writes
1176
1190
  // the audit row + receipt. A record failure is logged, never thrown —
1177
1191
  // auditing must not break the consolidation path. Failed runs still
@@ -1222,14 +1236,16 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1222
1236
  // input can be rebuilt offline from the audit row alone — the
1223
1237
  // digest + decisions + outcome triple makes silent errors locatable
1224
1238
  // even after the store has moved on.
1225
- input: [...snapshot.values()].map((m) => ({
1226
- id: m.id,
1227
- type: m.type,
1228
- title: m.title,
1229
- content: m.content,
1230
- importance: m.importance,
1231
- updated_at: m.updated_at
1232
- })),
1239
+ // #557(dreamInputSummaryEnabled,opt-in 默认关):瘦身只动这里的落库
1240
+ // 副本——LLM 输入在下方由内存快照现算、snapshot_hash 在内存里对含
1241
+ // content 的快照计算,两者都不经过这行,prompt 与 receipt 完整性不受
1242
+ // 影响。organize 轮的重放载荷走 organize.js 自己的 saveDreamRun,
1243
+ // 不经过这里,与 reclaim 的 run_type != 'organize' 同一条边界。
1244
+ input: [...snapshot.values()].map((m) => (
1245
+ config.dreamInputSummaryEnabled === true
1246
+ ? { id: m.id, type: m.type, title: m.title, importance: m.importance, updated_at: m.updated_at }
1247
+ : { id: m.id, type: m.type, title: m.title, content: m.content, importance: m.importance, updated_at: m.updated_at }
1248
+ )),
1233
1249
  decisions: result.decisions,
1234
1250
  outcome: result.outcome,
1235
1251
  applied,
@@ -1264,6 +1280,12 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1264
1280
  }
1265
1281
 
1266
1282
  let listText;
1283
+ // Issue #603 第 2 层:把预分组阶段算出的向量相似度留下来,供停车入队时落列。
1284
+ // 这是**本进程内唯一**真实的相似度来源——LLM 裁决给出的只有理由文本,从文本
1285
+ // 里回收数字是猜测。键与 conflict_pending 的对序归一化一致(sort 后的 id 对)。
1286
+ // 拿不到(无向量、跨进程重放、模型自由裁决)就写 NULL,排序按「未知」处理。
1287
+ const pairSimilarity = new Map();
1288
+ const similarityKey = (x, y) => (x < y ? `${x}\u0000${y}` : `${y}\u0000${x}`);
1267
1289
  if (semantic?.embedder && semantic?.vectorIndex) {
1268
1290
  try {
1269
1291
  const vectors = await collectVectors(memories, semantic);
@@ -1272,6 +1294,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1272
1294
  const clusters = clusterMemories(memories, vectors, k);
1273
1295
  const conflicts = findPotentialConflicts(memories, vectors, 0.85);
1274
1296
  const conflictIds = new Set(conflicts.flatMap((c) => [c.a.id, c.b.id]));
1297
+ for (const c of conflicts) pairSimilarity.set(similarityKey(c.a.id, c.b.id), c.similarity);
1275
1298
  const parts = [];
1276
1299
  clusters.forEach((cluster, ci) => {
1277
1300
  parts.push(STR.clusterHeader[language](ci + 1));
@@ -1301,6 +1324,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1301
1324
  const consolidationPrompt = freezeEnabled
1302
1325
  ? STR.prompts.consolidation[language] + STR.prompts.freezeSuffix[language]
1303
1326
  : STR.prompts.consolidation[language];
1327
+ // 两个后缀可以叠加:冻结管冲突怎么处理,关闭改写管 update 能不能用,互不相干。
1328
+ const promptWithUpdateGate = updateEnabled
1329
+ ? consolidationPrompt
1330
+ : consolidationPrompt + STR.prompts.noUpdateSuffix[language];
1304
1331
  let decisionText;
1305
1332
  // 加固(v0.8.1):配置的 reasoningEffort 被 provider 拒收时回退重试一次
1306
1333
  // (不带该字段),避免 thinking 模型配置 low/medium 直接整单失败。解析放
@@ -1335,7 +1362,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1335
1362
  maxTokens: tokens,
1336
1363
  ...(withEffort && effort ? { reasoningEffort: effort } : {}),
1337
1364
  messages: [
1338
- { role: "system", content: [{ type: "text", text: consolidationPrompt }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
1365
+ { role: "system", content: [{ type: "text", text: promptWithUpdateGate }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
1339
1366
  { role: "user", content: [{ type: "text", text: fenceListBody(language, listText) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
1340
1367
  ]
1341
1368
  }, reportUsage, (reason) => { streamFailure = describeStreamFailure(reason); }));
@@ -1371,6 +1398,8 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1371
1398
  }
1372
1399
  const { ok, errors, skipped, resolvedShortIds, coverageShortfall } = validateDecisions(decisions, snapshot, {
1373
1400
  maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
1401
+ // reflectionUpdateEnabled=false 时校验器单条跳过所有 update(不是整单拒绝)。
1402
+ allowUpdate: updateEnabled,
1374
1403
  // Issue #104 方向 2:archive 批量上限(全局闸门,超限整单拒绝)。
1375
1404
  maxArchivePerRun: config.dreamMaxArchivePerRun,
1376
1405
  // #135 成本审计派生:merge 批量上限(merge 是唯一回吐正文的动作,同款全局闸门)。
@@ -1488,8 +1517,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1488
1517
  try {
1489
1518
  // 返回 undefined = 该对已被人工裁决且两侧内容未变(issue #170 复核
1490
1519
  // 项 4:内容变了才重新入队)——不算停车、不进 frozenIds。
1491
- const parked = service.saveConflictPending({ run_id: runId, memory_a: d.winner, memory_b: d.loser, reason: d.reason });
1492
- if (parked == null) continue;
1520
+ const parked = service.saveConflictPending({ run_id: runId, memory_a: d.winner, memory_b: d.loser, reason: d.reason, similarity: pairSimilarity.get(similarityKey(d.winner, d.loser)) });
1521
+ // 只有真的新插了一行才算停车:这一对已在队列里时返回的是旧行
1522
+ // (fresh=false),旧代码把它也算进 frozen,虚报由此而来(#603)。
1523
+ if (parked?.fresh !== true) continue;
1493
1524
  frozenCount++;
1494
1525
  frozenIds.push(d.winner, d.loser);
1495
1526
  } catch (error) {
@@ -1510,9 +1541,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1510
1541
  run_id: runId,
1511
1542
  memory_a: d.winner,
1512
1543
  memory_b: d.loser,
1513
- reason: STR.scopeConflictParkReason[language](d.reason)
1544
+ reason: STR.scopeConflictParkReason[language](d.reason),
1545
+ similarity: pairSimilarity.get(similarityKey(d.winner, d.loser))
1514
1546
  });
1515
- if (parked != null) {
1547
+ if (parked?.fresh === true) {
1516
1548
  frozenCount++;
1517
1549
  frozenIds.push(d.winner, d.loser);
1518
1550
  }
@@ -0,0 +1,162 @@
1
+ // In-process call into the mneme HTTP API.
2
+ //
3
+ // The panel runs inside the engine process, exactly where mneme registered its
4
+ // routes. Going through real HTTP would hit the dsh-go bridge guard (401 unless
5
+ // the request carries the process launch token, which is never written to disk),
6
+ // so we dispatch the registered route handler directly and never leave the
7
+ // process. Everything stays on the local web server table: no sockets.
8
+ //
9
+ // Auth is not skipped: mneme's route registrar wraps every route with a policy
10
+ // check, so a write route answers 401 unless the request carries the configured
11
+ // apiToken. We forward it as the x-dsh-mneme-token header (one of the two the
12
+ // registrar accepts) instead of weakening the policy for in-process callers.
13
+ import { EventEmitter } from "node:events";
14
+
15
+ const PREFIX = "/api/dsh-mneme";
16
+ const DEFAULT_TIMEOUT = 5000;
17
+
18
+ function makeRequest(url, method, body, apiToken) {
19
+ const req = new EventEmitter();
20
+ req.url = url;
21
+ req.method = method;
22
+ req.headers = {
23
+ host: "127.0.0.1",
24
+ "content-type": "application/json",
25
+ "content-length": String(body === undefined ? 0 : Buffer.byteLength(body)),
26
+ };
27
+ if (typeof apiToken === "string" && apiToken.length > 0) {
28
+ req.headers["x-dsh-mneme-token"] = apiToken;
29
+ }
30
+ req.socket = { remoteAddress: "127.0.0.1", encrypted: false };
31
+ req.resume = () => {};
32
+ req.pause = () => {};
33
+ req.setEncoding = () => {};
34
+ // The handler attaches its body listeners synchronously when invoked, so the
35
+ // body is only released afterwards by flushRequest(); emitting during
36
+ // construction would fire 'data'/'end' with no listener attached and the
37
+ // handler's readBody() would wait forever.
38
+ req.flush = () => {
39
+ queueMicrotask(() => {
40
+ if (body !== undefined) req.emit("data", Buffer.from(body));
41
+ req.emit("end");
42
+ });
43
+ };
44
+ return req;
45
+ }
46
+
47
+ function makeResponse() {
48
+ const chunks = [];
49
+ let settled;
50
+ const done = new Promise((resolve) => {
51
+ settled = resolve;
52
+ });
53
+ const headers = {};
54
+ const res = {
55
+ statusCode: 200,
56
+ headersSent: false,
57
+ done,
58
+ writeHead(status, extra) {
59
+ res.statusCode = status;
60
+ res.headersSent = true;
61
+ if (extra !== undefined && extra !== null) {
62
+ for (const key of Object.keys(extra)) headers[key.toLowerCase()] = extra[key];
63
+ }
64
+ return res;
65
+ },
66
+ setHeader(key, value) {
67
+ headers[key.toLowerCase()] = value;
68
+ return res;
69
+ },
70
+ getHeader(key) {
71
+ return headers[key.toLowerCase()];
72
+ },
73
+ removeHeader(key) {
74
+ delete headers[key.toLowerCase()];
75
+ },
76
+ write(chunk) {
77
+ if (chunk !== undefined && chunk !== null && typeof chunk !== "function") {
78
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
79
+ }
80
+ return true;
81
+ },
82
+ end(chunk) {
83
+ if (chunk !== undefined && chunk !== null && typeof chunk !== "function") {
84
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
85
+ }
86
+ settled();
87
+ return res;
88
+ },
89
+ flushHeaders() {},
90
+ };
91
+ res.text = () => Buffer.concat(chunks).toString("utf8");
92
+ res.bytes = () => Buffer.concat(chunks);
93
+ return res;
94
+ }
95
+
96
+ // 超时定时器不能被 unref():handler 永不响应时,它是唯一还挂在事件循环上的
97
+ // 东西;unref 之后 node 22 的 test runner 会判定循环已空,把用例记成 cancelled
98
+ // (CI 的 node22 两腿就是这么红的)。改成显式 cancel —— 成功路径也不白挂 5 秒。
99
+ function timeout(ms) {
100
+ let timer;
101
+ const promise = new Promise((_, reject) => {
102
+ timer = setTimeout(() => reject(new Error("mneme 接口超时")), ms);
103
+ });
104
+ return { promise, cancel: () => clearTimeout(timer) };
105
+ }
106
+
107
+ /**
108
+ * Resolve the mneme route handler and run it in-process.
109
+ * @param {object} ctx - the plugin context (must expose get()).
110
+ * @param {{apiToken?: string}} [options] - the host's configured API token; the
111
+ * route registrar requires it for every token / token-write route.
112
+ */
113
+ export function createHostCall(ctx, options = {}) {
114
+ const apiToken = options.apiToken;
115
+ return async function callMneme(path, options = {}) {
116
+ const method = (options.method ?? "GET").toUpperCase();
117
+ const body = options.body === undefined ? undefined : JSON.stringify(options.body);
118
+ const url = new URL(PREFIX + path, "http://127.0.0.1");
119
+ const webServer = ctx.get?.("webServer") ?? ctx.reflect?.get?.("webServer");
120
+ if (webServer === undefined || typeof webServer.match !== "function") {
121
+ throw new Error("宿主 webServer 不可用");
122
+ }
123
+ const route = webServer.match(url.pathname);
124
+ if (route === undefined || route === null) throw new Error(`mneme 未注册 ${url.pathname}`);
125
+ const req = makeRequest(url.pathname + url.search, method, body, apiToken);
126
+ const res = makeResponse();
127
+ let run;
128
+ try {
129
+ run = Promise.resolve(route.handler(req, res));
130
+ } finally {
131
+ req.flush();
132
+ }
133
+ // Wait for both: most routes return a promise and end the response inside it,
134
+ // but a few (version-check) return undefined and end later from a callback.
135
+ // Racing the three would return the moment the synchronous call returns and
136
+ // hand an empty body to the caller, which then looks like a failed fetch.
137
+ const guard = timeout(options.timeoutMs ?? DEFAULT_TIMEOUT);
138
+ try {
139
+ await Promise.race([
140
+ Promise.all([run, res.done]).then(() => undefined),
141
+ guard.promise,
142
+ ]);
143
+ } finally {
144
+ guard.cancel();
145
+ }
146
+ const text = res.text();
147
+ if (res.statusCode >= 400) {
148
+ const detail = text.length > 200 ? `${text.slice(0, 200)}…` : text;
149
+ const error = new Error(`mneme ${method} ${path} → ${res.statusCode} ${detail}`);
150
+ error.status = res.statusCode;
151
+ error.body = text;
152
+ throw error;
153
+ }
154
+ if (options.raw === true) return text;
155
+ if (text === "") return {};
156
+ try {
157
+ return JSON.parse(text);
158
+ } catch {
159
+ return { text };
160
+ }
161
+ };
162
+ }