@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.
- package/README.md +54 -21
- package/docs/CONFIGURATION.md +45 -4
- package/docs/DIRECTORIES.md +114 -0
- package/docs/DISTILL.md +64 -0
- package/docs/DSH-GO.md +97 -0
- package/docs/ENTITIES.md +5 -3
- package/docs/FORGET.md +77 -0
- package/docs/KB.md +110 -0
- package/docs/SLEEP.md +28 -3
- package/docs/STORAGE.md +23 -0
- package/lib/api-standalone.js +4 -1
- package/lib/api.js +744 -26
- package/lib/attachment-text.js +122 -0
- package/lib/choro-command.js +9 -2
- package/lib/client.js +3752 -819
- package/lib/config.js +171 -2
- package/lib/continuity.js +7 -6
- package/lib/diary/auto.js +190 -0
- package/lib/diary/compose.js +440 -0
- package/lib/diary/dir.js +59 -0
- package/lib/diary/export.js +97 -0
- package/lib/diary/index.js +26 -0
- package/lib/diary/library.js +189 -0
- package/lib/diary/qq-inbox.js +142 -0
- package/lib/diary/rollover.js +61 -0
- package/lib/diary/session-provider.js +122 -0
- package/lib/document.js +9 -3
- package/lib/dream/clustering.js +6 -1
- package/lib/dream/decisions.js +56 -5
- package/lib/dream/sleep.js +616 -15
- package/lib/dream.js +45 -13
- package/lib/dsh-go/host.js +162 -0
- package/lib/dsh-go/panel.js +2091 -0
- package/lib/dsh-go/text.js +342 -0
- package/lib/dsh-go/views/common.js +68 -0
- package/lib/dsh-go/views/entity.js +48 -0
- package/lib/dsh-go/views/memory.js +198 -0
- package/lib/dsh-go/views/root.js +30 -0
- package/lib/dsh-go/views/settings.js +398 -0
- package/lib/dsh-go/views/status.js +51 -0
- package/lib/embedding.js +42 -8
- package/lib/entities/extractor.js +36 -7
- package/lib/hot-memory.js +8 -9
- package/lib/index.js +131 -5
- package/lib/inject.js +192 -19
- package/lib/kb/index.js +309 -0
- package/lib/kb/routes.js +220 -0
- package/lib/kb/tools.js +148 -0
- package/lib/lang.js +115 -4
- package/lib/peak-hours.js +57 -2
- package/lib/profile-facts.js +356 -0
- package/lib/semantic.js +347 -60
- package/lib/service.js +302 -70
- package/lib/settings.js +117 -14
- package/lib/store.js +830 -55
- package/lib/summarize.js +38 -11
- package/lib/tools.js +173 -25
- package/lib/user-settings.js +175 -23
- package/lib/vector-index.js +76 -6
- package/lib/wiki/cli-hint.js +90 -0
- package/lib/wiki/queries.js +264 -10
- package/lib/wiki/t1-jobs.js +232 -0
- package/lib/wiki/t1.js +7 -1
- package/package.json +25 -1
- package/scripts/sync-test-badge.mjs +1 -1
- package/scripts/test-count-sync.mjs +9 -5
- package/src/api-standalone.js +4 -1
- package/src/api.js +744 -26
- package/src/attachment-text.js +122 -0
- package/src/choro-command.js +9 -2
- package/src/config.js +171 -2
- package/src/continuity.js +7 -6
- package/src/diary/auto.js +190 -0
- package/src/diary/compose.js +440 -0
- package/src/diary/dir.js +59 -0
- package/src/diary/export.js +97 -0
- package/src/diary/index.js +26 -0
- package/src/diary/library.js +189 -0
- package/src/diary/qq-inbox.js +142 -0
- package/src/diary/rollover.js +61 -0
- package/src/diary/session-provider.js +122 -0
- package/src/document.js +9 -3
- package/src/dream/clustering.js +6 -1
- package/src/dream/decisions.js +56 -5
- package/src/dream/sleep.js +616 -15
- package/src/dream.js +45 -13
- package/src/dsh-go/host.js +162 -0
- package/src/dsh-go/panel.js +2091 -0
- package/src/dsh-go/text.js +342 -0
- package/src/dsh-go/views/common.js +68 -0
- package/src/dsh-go/views/entity.js +48 -0
- package/src/dsh-go/views/memory.js +198 -0
- package/src/dsh-go/views/root.js +30 -0
- package/src/dsh-go/views/settings.js +398 -0
- package/src/dsh-go/views/status.js +51 -0
- package/src/embedding.js +42 -8
- package/src/entities/extractor.js +36 -7
- package/src/hot-memory.js +8 -9
- package/src/index.js +131 -5
- package/src/inject.js +192 -19
- package/src/kb/index.js +309 -0
- package/src/kb/routes.js +220 -0
- package/src/kb/tools.js +148 -0
- package/src/lang.js +115 -4
- package/src/peak-hours.js +57 -2
- package/src/profile-facts.js +356 -0
- package/src/semantic.js +347 -60
- package/src/service.js +302 -70
- package/src/settings.js +117 -14
- package/src/store.js +830 -55
- package/src/summarize.js +38 -11
- package/src/tools.js +173 -25
- package/src/user-settings.js +175 -23
- package/src/vector-index.js +76 -6
- package/src/wiki/cli-hint.js +90 -0
- package/src/wiki/queries.js +264 -10
- package/src/wiki/t1-jobs.js +232 -0
- package/src/wiki/t1.js +7 -1
- package/test/api-routes-gaps.test.js +63 -0
- package/test/api.test.js +377 -4
- package/test/attachment-text.test.js +140 -0
- package/test/bm25-corpus-cache.test.js +145 -0
- package/test/capacity-warning.test.js +262 -0
- package/test/client.test.js +908 -138
- package/test/config.test.js +79 -0
- package/test/conflict-freeze.test.js +45 -1
- package/test/continuity.test.js +8 -1
- package/test/diary-library.test.js +187 -0
- package/test/diary.test.js +576 -0
- package/test/document.test.js +32 -0
- package/test/dream-maxtokens-fallback.test.js +3 -3
- package/test/dream.test.js +62 -1
- package/test/dsh-go-host.test.js +134 -0
- package/test/dsh-go-panel-actions.test.js +854 -0
- package/test/dsh-go-panel.test.js +251 -0
- package/test/embedding-cache-f32.test.js +276 -0
- package/test/embedding-ready.test.js +33 -3
- package/test/entities.test.js +73 -0
- package/test/forget-corrections.test.js +70 -0
- package/test/image-memory.test.js +171 -0
- package/test/inject-pin-explicit.test.js +300 -0
- package/test/inject-pin-vector-crowd.test.js +225 -0
- package/test/issue-617-step1-query-and-fusion.test.js +361 -0
- package/test/kb-api-wiring.test.js +128 -0
- package/test/kb-collections.test.js +272 -0
- package/test/kb-routes.test.js +379 -0
- package/test/mint-source.test.js +26 -0
- package/test/peak-hours-observability.test.js +70 -0
- package/test/persona-api.test.js +3 -3
- package/test/persona-by-session.test.js +568 -0
- package/test/persona-settings.test.js +38 -7
- package/test/profile-facts.test.js +492 -0
- package/test/reflection-update-gate.test.js +129 -0
- package/test/reindex-backfill.test.js +334 -0
- package/test/reindex-scheduler.test.js +369 -0
- package/test/scope-candidates.test.js +249 -0
- package/test/session-gate.test.js +1 -1
- package/test/sleep-action-set.test.js +14 -0
- package/test/sleep-heat.test.js +49 -0
- package/test/sleep.test.js +598 -0
- package/test/standalone-api.test.js +24 -0
- package/test/store.test.js +20 -0
- package/test/summarize.test.js +26 -0
- package/test/test-count-sync.test.js +14 -3
- package/test/tool-state-ignore.test.js +65 -0
- package/test/tools-dto-schema-matrix.test.js +23 -2
- package/test/tools.test.js +144 -7
- package/test/user-settings-segments.test.js +528 -0
- package/test/vector-index.test.js +120 -6
- package/test/wiki-api.test.js +476 -1
- package/test/wiki-cli-hint.test.js +118 -0
- package/test/wiki-queries.test.js +91 -1
- package/test/wiki-t1-jobs.test.js +125 -0
- 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
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|