@modusensus/dsh-mneme 0.8.2 → 0.8.4
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/LICENSE +21 -21
- package/README.md +718 -633
- package/bin/cli.mjs +0 -0
- package/bin/dsh-mneme-mcp.mjs +502 -0
- package/cordis.patch.yml +21 -21
- package/lib/api-standalone.js +495 -316
- package/lib/api.js +1382 -1316
- package/lib/bootstrap.js +211 -0
- package/lib/client.js +4475 -4362
- package/lib/commands.js +95 -95
- package/lib/config.js +533 -500
- package/lib/dream/clustering.js +118 -118
- package/lib/dream/decisions.js +658 -658
- package/lib/dream/narratives.js +44 -0
- package/lib/dream/sleep.js +700 -700
- package/lib/dream.js +1319 -1216
- package/lib/embedding.js +177 -177
- package/lib/entities/extractor.js +249 -249
- package/lib/heat.js +142 -136
- package/lib/hot-memory.js +46 -46
- package/lib/index.js +515 -513
- package/lib/inject.js +299 -253
- package/lib/lang.js +202 -179
- package/lib/local-embedder.js +320 -308
- package/lib/mirror.js +198 -198
- package/lib/quality-filter.js +126 -126
- package/lib/recall-stats.js +103 -0
- package/lib/reranker.js +269 -260
- package/lib/runtime/adopt.js +176 -176
- package/lib/runtime/closure.js +172 -172
- package/lib/runtime/download.js +247 -247
- package/lib/runtime/layout.js +235 -235
- package/lib/runtime/loader.js +249 -249
- package/lib/runtime/model-fetch.js +361 -0
- package/lib/runtime/provision.js +237 -237
- package/lib/runtime/tarball.js +130 -130
- package/lib/runtime/verify.js +191 -191
- package/lib/scope.js +118 -118
- package/lib/search/adaptive.js +22 -22
- package/lib/search/bm25.js +96 -96
- package/lib/service.js +2171 -2071
- package/lib/settings.js +430 -419
- package/lib/store.js +2448 -2367
- package/lib/summarize.js +575 -449
- package/lib/tools.js +574 -574
- package/lib/vector-index.js +110 -110
- package/lib/version-check.js +122 -122
- package/package.json +109 -108
- package/runtime-manifest.json +596 -596
- package/scripts/benchmark-embed.js +201 -201
- package/scripts/benchmark-recall.js +199 -199
- package/scripts/benchmark-rerank.js +166 -166
- package/scripts/build-runtime-manifest.mjs +217 -217
- package/scripts/check-sync.js +42 -42
- package/scripts/e2e-dsh.js +220 -220
- package/scripts/mneme-runtime.mjs +277 -277
- package/scripts/stress-dsh.js +255 -255
- package/scripts/sync-lib.js +47 -47
- package/scripts/sync-test-badge.mjs +61 -61
- package/src/api-standalone.js +495 -316
- package/src/api.js +1382 -1316
- package/src/bootstrap.js +211 -0
- package/src/commands.js +95 -95
- package/src/config.js +533 -500
- package/src/dream/clustering.js +118 -118
- package/src/dream/decisions.js +658 -658
- package/src/dream/narratives.js +44 -0
- package/src/dream/sleep.js +700 -700
- package/src/dream.js +1319 -1216
- package/src/embedding.js +177 -177
- package/src/entities/extractor.js +249 -249
- package/src/heat.js +142 -136
- package/src/hot-memory.js +46 -46
- package/src/index.js +515 -513
- package/src/inject.js +299 -253
- package/src/lang.js +202 -179
- package/src/local-embedder.js +320 -308
- package/src/mirror.js +198 -198
- package/src/quality-filter.js +126 -126
- package/src/recall-stats.js +103 -0
- package/src/reranker.js +269 -260
- package/src/runtime/adopt.js +176 -176
- package/src/runtime/closure.js +172 -172
- package/src/runtime/download.js +247 -247
- package/src/runtime/layout.js +235 -235
- package/src/runtime/loader.js +249 -249
- package/src/runtime/model-fetch.js +361 -0
- package/src/runtime/provision.js +237 -237
- package/src/runtime/tarball.js +130 -130
- package/src/runtime/verify.js +191 -191
- package/src/scope.js +118 -118
- package/src/search/adaptive.js +22 -22
- package/src/search/bm25.js +96 -96
- package/src/service.js +2171 -2071
- package/src/settings.js +430 -419
- package/src/store.js +2448 -2367
- package/src/summarize.js +575 -449
- package/src/tools.js +574 -574
- package/src/vector-index.js +110 -110
- package/src/version-check.js +122 -122
- package/test/api-routes-gaps.test.js +218 -218
- package/test/api.test.js +1578 -1481
- package/test/audit.test.js +448 -448
- package/test/benchmark.test.js +94 -94
- package/test/bootstrap.test.js +220 -0
- package/test/client.test.js +799 -736
- package/test/clustering.test.js +100 -100
- package/test/commands.test.js +106 -106
- package/test/config.test.js +66 -66
- package/test/conflict-freeze.test.js +300 -300
- package/test/dream-archive-guardrail.test.js +116 -116
- package/test/dream-candidate-hybrid.test.js +281 -281
- package/test/dream-coverage-degrade.test.js +86 -86
- package/test/dream.test.js +1257 -1254
- package/test/embedding-ready.test.js +196 -196
- package/test/entities.test.js +547 -547
- package/test/entity-recall.test.js +179 -0
- package/test/epistemic.test.js +298 -298
- package/test/fnew-0112.test.js +311 -311
- package/test/fnew-03.test.js +422 -422
- package/test/graph-api.test.js +175 -175
- package/test/heat.test.js +175 -148
- package/test/helpers/dream-mock.js +82 -82
- package/test/helpers/peer-worker.mjs +25 -25
- package/test/helpers/runtime-source.js +60 -60
- package/test/helpers/tar-builder.js +80 -80
- package/test/hot-memory.test.js +145 -145
- package/test/inject-content-cap.test.js +89 -0
- package/test/inject-first-round-bm25.test.js +89 -89
- package/test/inject-heat.test.js +61 -0
- package/test/inject-rotation.test.js +161 -0
- package/test/inject.test.js +249 -249
- package/test/lang.test.js +87 -87
- package/test/lib-smoke.test.js +96 -96
- package/test/llm-audit.test.js +309 -309
- package/test/llm-message-source.test.js +177 -177
- package/test/local-embedder.test.js +249 -229
- package/test/mcp-stdio.test.js +385 -0
- package/test/mirror-dirty.test.js +424 -424
- package/test/mirror-edit-digest.test.js +187 -187
- package/test/mirror-generation.test.js +499 -499
- package/test/mirror.test.js +249 -249
- package/test/narrative-bars.test.js +162 -0
- package/test/peer-blockers.test.js +193 -193
- package/test/policy-epoch.test.js +259 -259
- package/test/quality-filter.test.js +186 -186
- package/test/reasoning-effort.test.js +746 -746
- package/test/recall-evals.test.js +235 -235
- package/test/recall-layer.test.js +326 -326
- package/test/recall-stats.test.js +117 -0
- package/test/receipt-chain.test.js +451 -451
- package/test/reflection.test.js +226 -226
- package/test/reindex-backfill.test.js +62 -62
- package/test/reranker-single-logit.test.js +73 -73
- package/test/reranker.test.js +197 -197
- package/test/resident-status.test.js +83 -0
- package/test/runtime-adopt.test.js +206 -206
- package/test/runtime-cli.test.js +89 -89
- package/test/runtime-closure.test.js +193 -193
- package/test/runtime-download.test.js +302 -302
- package/test/runtime-layout.test.js +163 -163
- package/test/runtime-loader.test.js +237 -237
- package/test/runtime-manifest.test.js +158 -158
- package/test/runtime-model-fetch.test.js +348 -0
- package/test/runtime-provision.test.js +101 -101
- package/test/runtime-tarball.test.js +81 -81
- package/test/runtime-verify.test.js +214 -214
- package/test/scope-candidates.test.js +239 -239
- package/test/scope-explicit.test.js +452 -452
- package/test/scope-retrieval.test.js +197 -197
- package/test/scope-storage.test.js +255 -255
- package/test/scope-strict.test.js +231 -231
- package/test/search-fusion.test.js +90 -90
- package/test/semantic.test.js +124 -124
- package/test/service-search.test.js +174 -174
- package/test/service.test.js +405 -405
- package/test/settings.test.js +261 -261
- package/test/skip-invalid-restore.test.js +262 -262
- package/test/sleep-action-set.test.js +422 -422
- package/test/sleep-heat.test.js +126 -125
- package/test/sleep-scheduler-rearm.test.js +89 -89
- package/test/sleep.test.js +373 -373
- package/test/standalone-api.test.js +544 -359
- package/test/store-vector-cache.test.js +98 -98
- package/test/store.test.js +383 -383
- package/test/stress.test.js +209 -209
- package/test/summarize.test.js +884 -575
- package/test/tools-memory-get-schema.test.js +65 -65
- package/test/tools.test.js +341 -341
- package/test/updated-at-semantics.test.js +113 -113
- package/test/vector-index.test.js +221 -221
- package/test/version-check.test.js +152 -152
- package/test/webServer-optional.test.js +82 -82
package/src/api.js
CHANGED
|
@@ -1,1316 +1,1382 @@
|
|
|
1
|
-
import { URL } from "node:url";
|
|
2
|
-
import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
3
|
-
import { FEATURE_FLAG_SPEC } from "./settings.js";
|
|
4
|
-
import { TYPE_FILE, renderMirrorText, parseHumanEdits } from "./mirror.js";
|
|
5
|
-
import { langOf } from "./lang.js";
|
|
6
|
-
import { computeHeat } from "./heat.js";
|
|
7
|
-
import { describeStreamFailure, resolveRoute } from "./dream.js";
|
|
8
|
-
import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
|
|
9
|
-
import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
|
|
10
|
-
import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
|
|
11
|
-
|
|
12
|
-
// headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
|
|
13
|
-
function sendJson(res, status, payload, headers = {}) {
|
|
14
|
-
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", ...headers });
|
|
15
|
-
res.end(JSON.stringify(payload));
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// 附件下载响应(导出端点):Content-Type 与文件名(含日期后缀)由调用方给定。
|
|
19
|
-
function sendAttachment(res, status, contentType, filename, body) {
|
|
20
|
-
res.writeHead(status, {
|
|
21
|
-
"Content-Type": contentType,
|
|
22
|
-
"Content-Disposition": `attachment; filename="${filename}"`
|
|
23
|
-
});
|
|
24
|
-
res.end(body);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// 版本号统一从 version-check.js 取(同一份读 package.json 逻辑,读取失败
|
|
28
|
-
// 同样降级 "unknown")——/info 与 /version-check 两个路由共用,不再各写一份。
|
|
29
|
-
|
|
30
|
-
// Defaults for the standalone external API — keep in step with the schema
|
|
31
|
-
// defaults in config.js (externalApiPort / externalApiHost).
|
|
32
|
-
const EXTERNAL_API_DEFAULTS = { enabled: false, port: 8790, host: "127.0.0.1" };
|
|
33
|
-
|
|
34
|
-
/** Merge persisted external-api kv over the defaults for client display. */
|
|
35
|
-
function fullExternalConfig(kv = {}) {
|
|
36
|
-
return {
|
|
37
|
-
enabled: kv.enabled === true,
|
|
38
|
-
port: Number.isInteger(kv.port) && kv.port > 0 ? kv.port : EXTERNAL_API_DEFAULTS.port,
|
|
39
|
-
host: kv.host || EXTERNAL_API_DEFAULTS.host,
|
|
40
|
-
token: kv.token || ""
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Mask an API key for client display: keep a recognizable prefix and suffix,
|
|
46
|
-
* hide the middle. Empty keys stay empty; short keys are fully hidden.
|
|
47
|
-
* The mask only exists in the API layer — storage keeps the real key.
|
|
48
|
-
*/
|
|
49
|
-
function maskApiKey(key) {
|
|
50
|
-
if (!key) return "";
|
|
51
|
-
if (key.length <= 8) return "***";
|
|
52
|
-
return `${key.slice(0, 3)}***${key.slice(-4)}`;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** True when the request carries the configured apiToken (or no token is set). */
|
|
56
|
-
function isAuthorized(req, apiToken) {
|
|
57
|
-
if (!apiToken) return true;
|
|
58
|
-
const raw = req.headers?.authorization ?? req.headers?.["x-dsh-mneme-token"] ?? "";
|
|
59
|
-
const token = raw.startsWith("Bearer ") ? raw.slice(7).trim() : raw.trim();
|
|
60
|
-
if (token === "" || token.length !== apiToken.length) return false;
|
|
61
|
-
// Constant-time comparison: avoid leaking the token via timing when the API
|
|
62
|
-
// is exposed beyond loopback.
|
|
63
|
-
return timingSafeEqual(Buffer.from(token), Buffer.from(apiToken));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Reject a request with 401 when auth is enabled and the token is missing or
|
|
68
|
-
* wrong. Returns true when the request may proceed.
|
|
69
|
-
*/
|
|
70
|
-
function requireAuth(req, res, apiToken) {
|
|
71
|
-
if (isAuthorized(req, apiToken)) return true;
|
|
72
|
-
sendJson(res, 401, { error: "unauthorized" });
|
|
73
|
-
return false;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Collect the request body as text (tolerant of empty/invalid bodies). */
|
|
77
|
-
function readBody(req) {
|
|
78
|
-
return new Promise((resolve) => {
|
|
79
|
-
let body = "";
|
|
80
|
-
req.on("data", (chunk) => { body += chunk; });
|
|
81
|
-
req.on("end", () => resolve(body));
|
|
82
|
-
req.on("error", () => resolve(""));
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function parseBody(text) {
|
|
87
|
-
try {
|
|
88
|
-
return JSON.parse(text || "{}");
|
|
89
|
-
} catch {
|
|
90
|
-
return {};
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null) {
|
|
95
|
-
const disposers = [];
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
function
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
register({
|
|
194
|
-
kind: "exact",
|
|
195
|
-
path: "/api/dsh-mneme/
|
|
196
|
-
handler(req, res) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
{
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
if (!
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
if (
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
if (body.
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
if (
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
}
|
|
542
|
-
if (
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
sendJson(res,
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
//
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
//
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
});
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
//
|
|
779
|
-
// only
|
|
780
|
-
//
|
|
781
|
-
register({
|
|
782
|
-
kind: "exact",
|
|
783
|
-
path: "/api/dsh-mneme/semantic/
|
|
784
|
-
handler(req, res) {
|
|
785
|
-
try {
|
|
786
|
-
const url = new URL(req.url, "http://localhost");
|
|
787
|
-
const
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
:
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
} catch {
|
|
928
|
-
sendJson(res, 500, { error: "internal" });
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
});
|
|
932
|
-
|
|
933
|
-
// ---
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
//
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
const
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
const
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
//
|
|
977
|
-
//
|
|
978
|
-
//
|
|
979
|
-
//
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
if (
|
|
988
|
-
sendJson(res,
|
|
989
|
-
return;
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
if (
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
});
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
//
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
if (
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1
|
+
import { URL } from "node:url";
|
|
2
|
+
import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
3
|
+
import { FEATURE_FLAG_SPEC } from "./settings.js";
|
|
4
|
+
import { TYPE_FILE, renderMirrorText, parseHumanEdits } from "./mirror.js";
|
|
5
|
+
import { langOf } from "./lang.js";
|
|
6
|
+
import { computeHeat } from "./heat.js";
|
|
7
|
+
import { describeStreamFailure, resolveRoute } from "./dream.js";
|
|
8
|
+
import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
|
|
9
|
+
import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
|
|
10
|
+
import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
|
|
11
|
+
|
|
12
|
+
// headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
|
|
13
|
+
function sendJson(res, status, payload, headers = {}) {
|
|
14
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", ...headers });
|
|
15
|
+
res.end(JSON.stringify(payload));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 附件下载响应(导出端点):Content-Type 与文件名(含日期后缀)由调用方给定。
|
|
19
|
+
function sendAttachment(res, status, contentType, filename, body) {
|
|
20
|
+
res.writeHead(status, {
|
|
21
|
+
"Content-Type": contentType,
|
|
22
|
+
"Content-Disposition": `attachment; filename="${filename}"`
|
|
23
|
+
});
|
|
24
|
+
res.end(body);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 版本号统一从 version-check.js 取(同一份读 package.json 逻辑,读取失败
|
|
28
|
+
// 同样降级 "unknown")——/info 与 /version-check 两个路由共用,不再各写一份。
|
|
29
|
+
|
|
30
|
+
// Defaults for the standalone external API — keep in step with the schema
|
|
31
|
+
// defaults in config.js (externalApiPort / externalApiHost).
|
|
32
|
+
const EXTERNAL_API_DEFAULTS = { enabled: false, port: 8790, host: "127.0.0.1" };
|
|
33
|
+
|
|
34
|
+
/** Merge persisted external-api kv over the defaults for client display. */
|
|
35
|
+
function fullExternalConfig(kv = {}) {
|
|
36
|
+
return {
|
|
37
|
+
enabled: kv.enabled === true,
|
|
38
|
+
port: Number.isInteger(kv.port) && kv.port > 0 ? kv.port : EXTERNAL_API_DEFAULTS.port,
|
|
39
|
+
host: kv.host || EXTERNAL_API_DEFAULTS.host,
|
|
40
|
+
token: kv.token || ""
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Mask an API key for client display: keep a recognizable prefix and suffix,
|
|
46
|
+
* hide the middle. Empty keys stay empty; short keys are fully hidden.
|
|
47
|
+
* The mask only exists in the API layer — storage keeps the real key.
|
|
48
|
+
*/
|
|
49
|
+
function maskApiKey(key) {
|
|
50
|
+
if (!key) return "";
|
|
51
|
+
if (key.length <= 8) return "***";
|
|
52
|
+
return `${key.slice(0, 3)}***${key.slice(-4)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** True when the request carries the configured apiToken (or no token is set). */
|
|
56
|
+
function isAuthorized(req, apiToken) {
|
|
57
|
+
if (!apiToken) return true;
|
|
58
|
+
const raw = req.headers?.authorization ?? req.headers?.["x-dsh-mneme-token"] ?? "";
|
|
59
|
+
const token = raw.startsWith("Bearer ") ? raw.slice(7).trim() : raw.trim();
|
|
60
|
+
if (token === "" || token.length !== apiToken.length) return false;
|
|
61
|
+
// Constant-time comparison: avoid leaking the token via timing when the API
|
|
62
|
+
// is exposed beyond loopback.
|
|
63
|
+
return timingSafeEqual(Buffer.from(token), Buffer.from(apiToken));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Reject a request with 401 when auth is enabled and the token is missing or
|
|
68
|
+
* wrong. Returns true when the request may proceed.
|
|
69
|
+
*/
|
|
70
|
+
function requireAuth(req, res, apiToken) {
|
|
71
|
+
if (isAuthorized(req, apiToken)) return true;
|
|
72
|
+
sendJson(res, 401, { error: "unauthorized" });
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Collect the request body as text (tolerant of empty/invalid bodies). */
|
|
77
|
+
function readBody(req) {
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
let body = "";
|
|
80
|
+
req.on("data", (chunk) => { body += chunk; });
|
|
81
|
+
req.on("end", () => resolve(body));
|
|
82
|
+
req.on("error", () => resolve(""));
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseBody(text) {
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(text || "{}");
|
|
89
|
+
} catch {
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createApi(ctx, service, settings, commands, embedder, semantic = null, apiToken = "", config = null) {
|
|
95
|
+
const disposers = [];
|
|
96
|
+
|
|
97
|
+
// #182 注入状态探测:面板状态页的「极简模式下注入按宿主设计关闭」提示需要
|
|
98
|
+
// 知道当前会话的 agentPreset。注入回调在 minimal 下被宿主整体压制(#175 的
|
|
99
|
+
// 定论),不能作为检测源;session/event 是 preset 无关钩子(蒸馏同款),任
|
|
100
|
+
// 何事件都带完整 session 对象。只记最近一次会话头——这是面板展示用的「当前
|
|
101
|
+
// 会话」最佳近似,不是逐会话账本。读法与 scope.js 同源(session.header 冻结
|
|
102
|
+
// 的 SessionHeader,降级 requestHeader().config;agentPreset 取不到留 null,
|
|
103
|
+
// 面板对 null 静默)。任何失败静默:纯可观测性,不新增故障面。
|
|
104
|
+
let lastAgentPreset = null;
|
|
105
|
+
try {
|
|
106
|
+
const unsubscribe = ctx.on?.("session/event", (session) => {
|
|
107
|
+
try {
|
|
108
|
+
const header = session?.header ?? session?.requestHeader?.()?.config ?? null;
|
|
109
|
+
const preset = header?.agentPreset;
|
|
110
|
+
lastAgentPreset = typeof preset === "string" && preset.trim() ? preset.trim() : null;
|
|
111
|
+
} catch { /* 观测失败不影响会话流 */ }
|
|
112
|
+
});
|
|
113
|
+
if (typeof unsubscribe === "function") disposers.push(unsubscribe);
|
|
114
|
+
} catch { /* 宿主无 session/event 时面板提示退化为「未知」,不报错 */ }
|
|
115
|
+
|
|
116
|
+
// webServer 在 index.js 的 inject 声明中(cordis 等宿主服务就绪后 apply),
|
|
117
|
+
// 这里做防御性读取:cordis ctx 的 Proxy 不允许直接访问未 inject 的属性
|
|
118
|
+
// (会抛 "cannot get property without inject"),用 ctx.reflect.get 免
|
|
119
|
+
// inject 读取(未提供返回 undefined);对象字面量 mock ctx(测试)没有
|
|
120
|
+
// reflect,退回直接属性访问。createApi 只在有 webServer 时被调用(index.js
|
|
121
|
+
// 守卫 + 测试 mock),故下方 register 用之非空。
|
|
122
|
+
const webServer = typeof ctx.reflect?.get === "function" ? ctx.reflect.get("webServer") : ctx.webServer;
|
|
123
|
+
|
|
124
|
+
// feature flags 快照(GET/PUT 共用):overrides 是持久化的用户显式覆盖;
|
|
125
|
+
// effective 是启动配置在白名单键上被 overrides 覆盖后的最终值。config 缺席
|
|
126
|
+
// (旧调用方直连、未传 cfg)时只报被覆盖的键,不把不存在的默认值编造给前端。
|
|
127
|
+
const flagKeys = [
|
|
128
|
+
...FEATURE_FLAG_SPEC.booleans,
|
|
129
|
+
...Object.keys(FEATURE_FLAG_SPEC.ints),
|
|
130
|
+
...Object.keys(FEATURE_FLAG_SPEC.numbers),
|
|
131
|
+
...FEATURE_FLAG_SPEC.strings,
|
|
132
|
+
...FEATURE_FLAG_SPEC.urls,
|
|
133
|
+
...Object.keys(FEATURE_FLAG_SPEC.enums)
|
|
134
|
+
];
|
|
135
|
+
// 嵌套键在 kv 里按点号平铺("memoryQualityFilter.enabled"),运行时 cfg 里
|
|
136
|
+
// 是嵌套对象,effective 从对象子字段取值;其余键照旧从 cfg 顶层取。
|
|
137
|
+
const NESTED_FLAG_PATHS = {
|
|
138
|
+
"memoryQualityFilter.enabled": ["memoryQualityFilter", "enabled"],
|
|
139
|
+
"llmAudit.enabled": ["llmAudit", "enabled"]
|
|
140
|
+
};
|
|
141
|
+
function configFlagValue(key) {
|
|
142
|
+
const path = NESTED_FLAG_PATHS[key];
|
|
143
|
+
if (path) return config?.[path[0]]?.[path[1]];
|
|
144
|
+
return config?.[key];
|
|
145
|
+
}
|
|
146
|
+
function featureSnapshot() {
|
|
147
|
+
const overrides = settings.getFeatureFlags();
|
|
148
|
+
const effective = {};
|
|
149
|
+
for (const key of flagKeys) {
|
|
150
|
+
if (overrides[key] !== undefined) effective[key] = overrides[key];
|
|
151
|
+
else {
|
|
152
|
+
const value = configFlagValue(key);
|
|
153
|
+
if (value !== undefined) effective[key] = value;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { overrides, effective };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Ensure the service has an embedder when the API layer was handed one
|
|
160
|
+
// (tests wire the embedder through the API instead of index.js). Without
|
|
161
|
+
// this, /api/dsh-mneme/search would silently degrade to keyword-only.
|
|
162
|
+
if (embedder && typeof service.setEmbedder === "function") {
|
|
163
|
+
service.setEmbedder(embedder);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const register = (route) => {
|
|
167
|
+
disposers.push(webServer.register(route));
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// /api/dsh-mneme prefix fallback → 404 JSON for unknown sub-paths
|
|
171
|
+
register({
|
|
172
|
+
kind: "prefix",
|
|
173
|
+
path: "/api/dsh-mneme",
|
|
174
|
+
handler(req, res) {
|
|
175
|
+
sendJson(res, 404, { error: "not-found" });
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// 反馈入口预填用的插件版本(面板「帮助与反馈」卡片拉取)。读取失败(打包
|
|
180
|
+
// 环境)返回 "unknown",链接照常可用,纯展示信息不拦截。
|
|
181
|
+
register({
|
|
182
|
+
kind: "exact",
|
|
183
|
+
path: "/api/dsh-mneme/info",
|
|
184
|
+
handler(req, res) {
|
|
185
|
+
sendJson(res, 200, { version: PACKAGE_VERSION });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// 版本自检(#174 后续):运行版本 vs npm registry latest。只读,宿主围栏
|
|
190
|
+
// 统一鉴权(与 dream-status 同款,面板 apiFetch 直接可用)。registry 查询
|
|
191
|
+
// 全失败时 latest=null、status=unknown——前端对 unknown 完全静默,版本提示
|
|
192
|
+
// 是锦上添花,绝不因它新增故障面。
|
|
193
|
+
register({
|
|
194
|
+
kind: "exact",
|
|
195
|
+
path: "/api/dsh-mneme/version-check",
|
|
196
|
+
handler(req, res) {
|
|
197
|
+
if (req.method !== "GET") {
|
|
198
|
+
sendJson(res, 404, { error: "not-found" });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
fetchLatestVersion()
|
|
202
|
+
.then((latest) => sendJson(res, 200, {
|
|
203
|
+
version: PACKAGE_VERSION,
|
|
204
|
+
latest,
|
|
205
|
+
status: classify(PACKAGE_VERSION, latest),
|
|
206
|
+
checkedAt: new Date().toISOString()
|
|
207
|
+
}))
|
|
208
|
+
.catch(() => sendJson(res, 200, { version: PACKAGE_VERSION, latest: null, status: "unknown" }));
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
register({
|
|
213
|
+
kind: "exact",
|
|
214
|
+
path: "/api/dsh-mneme/list",
|
|
215
|
+
handler(req, res) {
|
|
216
|
+
try {
|
|
217
|
+
const url = new URL(req.url, "http://localhost");
|
|
218
|
+
const type = url.searchParams.get("type") ?? undefined;
|
|
219
|
+
const limit = Number(url.searchParams.get("limit") ?? 50);
|
|
220
|
+
const offset = Number(url.searchParams.get("offset") ?? 0);
|
|
221
|
+
// order=chrono: pure newest-first for paged browsing; default keeps
|
|
222
|
+
// the importance-ranked order other callers rely on.
|
|
223
|
+
const order = url.searchParams.get("order") ?? undefined;
|
|
224
|
+
// minImportance: numeric lower bound on importance (absent/NaN → no
|
|
225
|
+
// floor); source: exact match on the source column (empty → no filter).
|
|
226
|
+
const minRaw = url.searchParams.get("minImportance");
|
|
227
|
+
const minImportance = minRaw !== null && minRaw !== "" && !Number.isNaN(Number(minRaw))
|
|
228
|
+
? Number(minRaw)
|
|
229
|
+
: undefined;
|
|
230
|
+
const source = url.searchParams.get("source") || undefined;
|
|
231
|
+
// updatedFrom/updatedTo:updated_at 闭区间过滤(ISO 日期或完整时间戳)。
|
|
232
|
+
// 边界归一化在 store 的 updatedAtBounds(list/count 共用同一纯函数)
|
|
233
|
+
// 完成,非法值在那里被忽略——这里原样透传即可。
|
|
234
|
+
const updatedFrom = url.searchParams.get("updatedFrom") ?? undefined;
|
|
235
|
+
const updatedTo = url.searchParams.get("updatedTo") ?? undefined;
|
|
236
|
+
// archived=only:只看归档(状态页的归档列表用)。归档行不进默认列表,
|
|
237
|
+
// 所以这是独立的视图开关,而不是 includeArchived 的混看模式。
|
|
238
|
+
const onlyArchived = url.searchParams.get("archived") === "only";
|
|
239
|
+
// deposited=only:只看 autoDream 巩固过的记忆(receipt_chain 的
|
|
240
|
+
// merge/update verdict ∪ source=dream 直写)——状态页「查看全部」
|
|
241
|
+
// 与记忆库的「沉淀」筛选 chip 共用这个视图。
|
|
242
|
+
const depositedOnly = url.searchParams.get("deposited") === "only";
|
|
243
|
+
const rows = service.list({ type, limit, offset, order, minImportance, source, updatedFrom, updatedTo, onlyArchived, depositedOnly });
|
|
244
|
+
// 面板行在 wire DTO 之上补 archived/quality_score——模型工具的输出
|
|
245
|
+
// schema 严格复用 toApiList,扩展只发生在 HTTP 层。
|
|
246
|
+
// heat 投影(阶段二前端数据源):仅 heatEnabled=true 时下发逐条热度
|
|
247
|
+
// (heat.js 纯函数,λ=0 免疫类型恒 1.0);字段缺省时前端徽章自动隐藏。
|
|
248
|
+
const heatOn = config?.heatEnabled === true;
|
|
249
|
+
const items = service.toApiList(rows).map((m, i) => ({
|
|
250
|
+
...m,
|
|
251
|
+
archived: rows[i].archived === true || rows[i].archived === 1,
|
|
252
|
+
quality_score: rows[i].quality_score ?? null,
|
|
253
|
+
...(heatOn ? { heat: computeHeat(rows[i], Date.now(), config ?? {}) } : {})
|
|
254
|
+
}));
|
|
255
|
+
// Total honors the same filters as the rows, or the pager's
|
|
256
|
+
// has-more math breaks whenever minImportance/source/updated-at
|
|
257
|
+
// bounds are active.
|
|
258
|
+
sendJson(res, 200, { items, total: service.count(type, { minImportance, source, updatedFrom, updatedTo, onlyArchived, depositedOnly }) });
|
|
259
|
+
} catch {
|
|
260
|
+
sendJson(res, 500, { error: "internal" });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// 记忆复用统计(#217,只读聚合):Top-N 召回 + 僵尸率 + 覆盖度标注。窗口
|
|
266
|
+
// 参数钳制 1-365 天(缺省 30),聚合口径见 service.recallStats;注入命中
|
|
267
|
+
// 率待注入留痕口径拍板后接入。失败走既有 500 JSON 惯例,前端整卡不渲染。
|
|
268
|
+
register({
|
|
269
|
+
kind: "exact",
|
|
270
|
+
path: "/api/dsh-mneme/recall-stats",
|
|
271
|
+
handler(req, res) {
|
|
272
|
+
if (req.method !== "GET") {
|
|
273
|
+
sendJson(res, 404, { error: "not-found" });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
const url = new URL(req.url, "http://localhost");
|
|
278
|
+
const raw = Number(url.searchParams.get("window"));
|
|
279
|
+
const windowDays = Number.isInteger(raw) && raw >= 1 && raw <= 365 ? raw : 30;
|
|
280
|
+
sendJson(res, 200, service.recallStats({ windowDays }));
|
|
281
|
+
} catch {
|
|
282
|
+
sendJson(res, 500, { error: "internal" });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
register({
|
|
288
|
+
kind: "exact",
|
|
289
|
+
path: "/api/dsh-mneme/search",
|
|
290
|
+
handler(req, res) {
|
|
291
|
+
try {
|
|
292
|
+
const url = new URL(req.url, "http://localhost");
|
|
293
|
+
const q = url.searchParams.get("q") ?? "";
|
|
294
|
+
const limit = Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 20);
|
|
295
|
+
// mode selects the recall strategy (defaults to auto):
|
|
296
|
+
// auto (default) keyword first, vector fills remaining slots
|
|
297
|
+
// hybrid vector first, keyword fills remaining slots; scores of
|
|
298
|
+
// memories hit by both sides are weight-blended
|
|
299
|
+
// vector vector only, falls back to keyword when the vector path
|
|
300
|
+
// is unavailable (no embedder or a throwing one)
|
|
301
|
+
// keyword literal text only; never queries the embedder
|
|
302
|
+
// rerank=false disables the cross-encoder reorder for this request;
|
|
303
|
+
// the response `mode` field reports which path actually produced rows.
|
|
304
|
+
const mode = url.searchParams.get("mode") ?? "auto";
|
|
305
|
+
const rerank = url.searchParams.get("rerank") !== "false";
|
|
306
|
+
const query = q.trim();
|
|
307
|
+
if (!query) {
|
|
308
|
+
sendJson(res, 200, { items: [], mode: "keyword" });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
// Route through the unified semantic pipeline; any vector/rerank
|
|
312
|
+
// failure degrades to keyword results inside searchMemories. The
|
|
313
|
+
// returned promise lets the test double await the async search.
|
|
314
|
+
return Promise.resolve(
|
|
315
|
+
service.searchMemories(query, { mode, topK: limit, useRerank: rerank })
|
|
316
|
+
).then((rows) => {
|
|
317
|
+
// mode reflects what actually happened: rows marked `vector` came
|
|
318
|
+
// through the semantic path, everything else is keyword fallback.
|
|
319
|
+
const used = rows.some((m) => m.vector === true) ? "vector" : "keyword";
|
|
320
|
+
sendJson(res, 200, { items: service.toApiList(rows), mode: used });
|
|
321
|
+
}).catch(() => {
|
|
322
|
+
sendJson(res, 200, { items: service.toApiList(service.search(query, { limit })), mode: "keyword" });
|
|
323
|
+
});
|
|
324
|
+
} catch {
|
|
325
|
+
sendJson(res, 500, { error: "internal" });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// --- user profile ---
|
|
331
|
+
register({
|
|
332
|
+
kind: "exact",
|
|
333
|
+
path: "/api/dsh-mneme/profile",
|
|
334
|
+
handler(req, res) {
|
|
335
|
+
try {
|
|
336
|
+
if (req.method === "PUT" || req.method === "POST") {
|
|
337
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
338
|
+
return readBody(req).then((text) => {
|
|
339
|
+
const body = parseBody(text);
|
|
340
|
+
settings.setProfile(typeof body.profile === "string" ? body.profile : "");
|
|
341
|
+
sendJson(res, 200, { profile: settings.getProfile() });
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
sendJson(res, 200, { profile: settings.getProfile() });
|
|
345
|
+
} catch {
|
|
346
|
+
sendJson(res, 500, { error: "internal" });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// --- dream/sleep 模型连通性 + 模型发现(settings 面板)----------------------
|
|
352
|
+
// Connectivity probe for a dream/sleep model route: runs one minimal LLM call
|
|
353
|
+
// through the same harness path the consolidation uses and reports success /
|
|
354
|
+
// failure + latency + error. Reuses describeStreamFailure so the panel shows
|
|
355
|
+
// the same error wording the audit rows carry. Optional reasoningEffort
|
|
356
|
+
// mirrors what the consolidation would send — the panel can verify a model
|
|
357
|
+
// actually accepts the configured effort without tripping a full run.
|
|
358
|
+
async function testLlmConnectivity(llm, provider, model, reasoningEffort) {
|
|
359
|
+
const startedAt = Date.now();
|
|
360
|
+
const elapsed = () => Date.now() - startedAt;
|
|
361
|
+
const modelId = `${provider}:${model}`;
|
|
362
|
+
try {
|
|
363
|
+
let reply = "";
|
|
364
|
+
let error = "";
|
|
365
|
+
for await (const chunk of llm.stream({
|
|
366
|
+
provider,
|
|
367
|
+
model,
|
|
368
|
+
purpose: "dsh-mneme-connectivity-test",
|
|
369
|
+
// 给思考模型留出「思考完还有正文」的空间:16 会被 reasoning 全部吃掉,
|
|
370
|
+
// reply 恒为空,「真的答了 ok」对思考模型不成立。按实际用量计费,手动
|
|
371
|
+
// 按钮无放大风险。
|
|
372
|
+
maxTokens: 1024,
|
|
373
|
+
...(reasoningEffort && reasoningEffort !== "none" ? { reasoningEffort } : {}),
|
|
374
|
+
// role 用 user 而非 system(#189 后续):0.1.6-alpha.1 起适配器把 system
|
|
375
|
+
// 抽成顶层参数,system-only 对话会被官方 API 以
|
|
376
|
+
// 「messages: at least one message is required」整单拒绝——探测按钮 502。
|
|
377
|
+
// user 消息在任何适配器下都是一条合法对话,探测从此不怕 host 演进。
|
|
378
|
+
messages: [
|
|
379
|
+
{
|
|
380
|
+
role: "user",
|
|
381
|
+
content: [{ type: "text", text: "Reply with exactly one word: ok" }],
|
|
382
|
+
source: { kind: "plugin", plugin: "dsh-mneme" }
|
|
383
|
+
}
|
|
384
|
+
]
|
|
385
|
+
})) {
|
|
386
|
+
if (chunk.type === "text-delta" && typeof chunk.text === "string") reply += chunk.text;
|
|
387
|
+
if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
|
|
388
|
+
error = describeStreamFailure(chunk.reason);
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (error) return { ok: false, durationMs: elapsed(), error, modelId };
|
|
393
|
+
return { ok: true, durationMs: elapsed(), modelId, reply: reply.trim().slice(0, 100) };
|
|
394
|
+
} catch (error) {
|
|
395
|
+
return { ok: false, durationMs: elapsed(), error: String(error?.message ?? error), modelId };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// List every provider + model the host has registered, so the panel can offer
|
|
400
|
+
// a dropdown over REAL models instead of free-text guesses. Read-only, no auth
|
|
401
|
+
// (same as list/search). Best-effort per provider: one whose model list can't
|
|
402
|
+
// be resolved yields an empty models array instead of failing the whole call.
|
|
403
|
+
register({
|
|
404
|
+
kind: "exact",
|
|
405
|
+
path: "/api/dsh-mneme/llm-providers",
|
|
406
|
+
handler(req, res) {
|
|
407
|
+
try {
|
|
408
|
+
const llm = ctx.llm;
|
|
409
|
+
if (typeof llm?.listProviders !== "function" || typeof llm?.listModels !== "function") {
|
|
410
|
+
return sendJson(res, 501, { error: "llm-unavailable" });
|
|
411
|
+
}
|
|
412
|
+
let providers;
|
|
413
|
+
try { providers = llm.listProviders() ?? []; } catch { providers = []; }
|
|
414
|
+
return Promise.all(providers.map(async (p) => {
|
|
415
|
+
let models = [];
|
|
416
|
+
try { models = (await llm.listModels(p.id)) ?? []; } catch { models = []; }
|
|
417
|
+
return {
|
|
418
|
+
provider: p.id,
|
|
419
|
+
models: models.map((m) => ({ id: m.id, name: m.name ?? m.id }))
|
|
420
|
+
};
|
|
421
|
+
})).then((rows) => sendJson(res, 200, { providers: rows }));
|
|
422
|
+
} catch {
|
|
423
|
+
return sendJson(res, 500, { error: "internal" });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// Connectivity test for a dream/sleep model route. Runs one tiny LLM call and
|
|
429
|
+
// reports ok / failure + latency + error. Auth-gated: it spends the user's
|
|
430
|
+
// API quota, so a stray local page must not be able to drain it via loopback.
|
|
431
|
+
register({
|
|
432
|
+
kind: "exact",
|
|
433
|
+
path: "/api/dsh-mneme/test-model",
|
|
434
|
+
handler(req, res) {
|
|
435
|
+
try {
|
|
436
|
+
if (req.method !== "POST") return sendJson(res, 404, { error: "not-found" });
|
|
437
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
438
|
+
return readBody(req).then(async (text) => {
|
|
439
|
+
const body = parseBody(text);
|
|
440
|
+
let provider = typeof body.provider === "string" ? body.provider.trim() : "";
|
|
441
|
+
let model = typeof body.model === "string" ? body.model.trim() : "";
|
|
442
|
+
if ((!provider && model) || (provider && !model)) return sendJson(res, 400, { error: "missing-provider-or-model" });
|
|
443
|
+
if (!provider && !model) {
|
|
444
|
+
// 空 = 按巩固路由解析(dreamProvider/dreamModel > agent 默认模型),
|
|
445
|
+
// 让面板一键测"当前巩固模型"而无需手填。
|
|
446
|
+
const route = resolveRoute(ctx, config ?? {}, ctx.logger);
|
|
447
|
+
if (!route) return sendJson(res, 400, { error: "no-route" });
|
|
448
|
+
provider = route.provider;
|
|
449
|
+
model = route.model;
|
|
450
|
+
}
|
|
451
|
+
const llm = ctx.llm;
|
|
452
|
+
if (typeof llm?.stream !== "function") return sendJson(res, 501, { error: "llm-unavailable" });
|
|
453
|
+
const result = await testLlmConnectivity(llm, provider, model, body.reasoningEffort);
|
|
454
|
+
return sendJson(res, result.ok ? 200 : 502, result);
|
|
455
|
+
});
|
|
456
|
+
} catch {
|
|
457
|
+
return sendJson(res, 500, { error: "internal" });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// --- delete one memory by id ---
|
|
463
|
+
// Mutation route: apiToken-gated like the profile/rules writes above. POST
|
|
464
|
+
// body is JSON { id }. store.remove deletes silently, so existence is checked
|
|
465
|
+
// up front to give clients a distinguishable 404 instead of a fake success.
|
|
466
|
+
register({
|
|
467
|
+
kind: "exact",
|
|
468
|
+
path: "/api/dsh-mneme/delete",
|
|
469
|
+
handler(req, res) {
|
|
470
|
+
try {
|
|
471
|
+
if (req.method !== "POST") {
|
|
472
|
+
sendJson(res, 404, { error: "not-found" });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
476
|
+
return readBody(req).then((text) => {
|
|
477
|
+
const body = parseBody(text);
|
|
478
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
479
|
+
if (!id) {
|
|
480
|
+
sendJson(res, 400, { error: "missing-id" });
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (!service.getById(id)) {
|
|
484
|
+
sendJson(res, 404, { error: "not-found" });
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
service.remove(id);
|
|
488
|
+
sendJson(res, 200, { ok: true });
|
|
489
|
+
});
|
|
490
|
+
} catch {
|
|
491
|
+
sendJson(res, 500, { error: "internal" });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// --- 编辑/归档一条记忆(面板写路径,与 /delete 对称命名)-------------------
|
|
497
|
+
// POST body {id, title?, content?, importance?, tags?, archived?}。字段校验
|
|
498
|
+
// 只做类型/范围检查:字段出现(!== undefined)就必须合法,宁 400 不静默纠正
|
|
499
|
+
// ——静默丢字段会让面板误以为保存成功。写入走 service 的正规更新路径:
|
|
500
|
+
// updated_at 由 store 的单调时钟推进;content 被改写时旧版本按 human_override
|
|
501
|
+
// 入档(与镜像人工编辑回灌 mergeHumanEdits 的语义对齐,FIFO 上限 20);
|
|
502
|
+
// archived 走 setArchived。两条路径都会触发镜像重渲染(afterSync)。
|
|
503
|
+
register({
|
|
504
|
+
kind: "exact",
|
|
505
|
+
path: "/api/dsh-mneme/update",
|
|
506
|
+
handler(req, res) {
|
|
507
|
+
try {
|
|
508
|
+
if (req.method !== "POST") {
|
|
509
|
+
sendJson(res, 404, { error: "not-found" });
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
513
|
+
return readBody(req).then((text) => {
|
|
514
|
+
const body = parseBody(text);
|
|
515
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
516
|
+
if (!id) {
|
|
517
|
+
sendJson(res, 400, { error: "missing-id" });
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
const patch = {};
|
|
521
|
+
if (body.title !== undefined) {
|
|
522
|
+
if (typeof body.title !== "string" || !body.title.trim()) {
|
|
523
|
+
sendJson(res, 400, { error: "invalid-title" });
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
patch.title = body.title.trim();
|
|
527
|
+
}
|
|
528
|
+
if (body.content !== undefined) {
|
|
529
|
+
if (typeof body.content !== "string" || !body.content.trim()) {
|
|
530
|
+
sendJson(res, 400, { error: "invalid-content" });
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
patch.content = body.content.trim();
|
|
534
|
+
}
|
|
535
|
+
if (body.importance !== undefined) {
|
|
536
|
+
if (!Number.isInteger(body.importance) || body.importance < 1 || body.importance > 5) {
|
|
537
|
+
sendJson(res, 400, { error: "invalid-importance" });
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
patch.importance = body.importance;
|
|
541
|
+
}
|
|
542
|
+
if (body.tags !== undefined) {
|
|
543
|
+
if (!Array.isArray(body.tags) || !body.tags.every((t) => typeof t === "string")) {
|
|
544
|
+
sendJson(res, 400, { error: "invalid-tags" });
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
patch.tags = body.tags;
|
|
548
|
+
}
|
|
549
|
+
// v0.8.1 底座(issue #170):显式 scope 修正。null=放宽到全局,
|
|
550
|
+
// 字符串=收窄/改标;归一化("global"/"*"/空白→NULL + explicit 章 +
|
|
551
|
+
// 审计行)在 service.updateMemory 内做,路由只做形状校验。
|
|
552
|
+
if (body.agent_scope !== undefined && body.agent_scope !== null && typeof body.agent_scope !== "string") {
|
|
553
|
+
sendJson(res, 400, { error: "invalid-agent-scope" });
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (body.workspace_scope !== undefined && body.workspace_scope !== null && typeof body.workspace_scope !== "string") {
|
|
557
|
+
sendJson(res, 400, { error: "invalid-workspace-scope" });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (body.agent_scope !== undefined) patch.agent_scope = body.agent_scope;
|
|
561
|
+
if (body.workspace_scope !== undefined) patch.workspace_scope = body.workspace_scope;
|
|
562
|
+
if (body.archived !== undefined && typeof body.archived !== "boolean") {
|
|
563
|
+
sendJson(res, 400, { error: "invalid-archived" });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (Object.keys(patch).length === 0 && body.archived === undefined) {
|
|
567
|
+
sendJson(res, 400, { error: "no-fields" });
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const existing = service.getById(id);
|
|
571
|
+
if (!existing) {
|
|
572
|
+
sendJson(res, 404, { error: "not-found" });
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
// 内容被改写时旧版本先入档(human_override),人工修正不静默销毁旧值。
|
|
576
|
+
if (patch.content !== undefined && patch.content !== existing.content) {
|
|
577
|
+
const history = Array.isArray(existing.content_history) ? existing.content_history : [];
|
|
578
|
+
patch.content_history = [
|
|
579
|
+
{ content: existing.content ?? "", source: "human_override", updated_at: new Date().toISOString() },
|
|
580
|
+
...history
|
|
581
|
+
].slice(0, 20);
|
|
582
|
+
}
|
|
583
|
+
if (Object.keys(patch).length) service.update(id, patch, { actor: "panel" });
|
|
584
|
+
if (body.archived !== undefined) service.setArchived(id, body.archived);
|
|
585
|
+
sendJson(res, 200, { memory: service.getById(id) });
|
|
586
|
+
});
|
|
587
|
+
} catch {
|
|
588
|
+
sendJson(res, 500, { error: "internal" });
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// --- rules ---
|
|
594
|
+
register({
|
|
595
|
+
kind: "exact",
|
|
596
|
+
path: "/api/dsh-mneme/rules",
|
|
597
|
+
handler(req, res) {
|
|
598
|
+
try {
|
|
599
|
+
if (req.method === "PUT" || req.method === "POST") {
|
|
600
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
601
|
+
return readBody(req).then((text) => {
|
|
602
|
+
const body = parseBody(text);
|
|
603
|
+
settings.setRules(Array.isArray(body.rules) ? body.rules : []);
|
|
604
|
+
sendJson(res, 200, { rules: settings.getRules() });
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
sendJson(res, 200, { rules: settings.getRules() });
|
|
608
|
+
} catch {
|
|
609
|
+
sendJson(res, 500, { error: "internal" });
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
// --- vector search config ---
|
|
615
|
+
register({
|
|
616
|
+
kind: "exact",
|
|
617
|
+
path: "/api/dsh-mneme/vector-config",
|
|
618
|
+
handler(req, res) {
|
|
619
|
+
try {
|
|
620
|
+
// Secret-bearing endpoint: fully protected when apiToken is set.
|
|
621
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
622
|
+
if (req.method === "PUT" || req.method === "POST") {
|
|
623
|
+
return readBody(req).then((text) => {
|
|
624
|
+
const body = parseBody(text);
|
|
625
|
+
// An empty apiKey, or one that already looks masked (round-trips
|
|
626
|
+
// through maskApiKey unchanged), means "keep the existing key".
|
|
627
|
+
// Only a fresh, unmasked key is treated as a real replacement.
|
|
628
|
+
const prev = settings.getVectorConfig();
|
|
629
|
+
const incoming = typeof body.apiKey === "string" ? body.apiKey.trim() : "";
|
|
630
|
+
const isMaskedOrEmpty = incoming === "" || maskApiKey(incoming) === incoming;
|
|
631
|
+
const key = isMaskedOrEmpty
|
|
632
|
+
? (prev?.apiKey ?? "")
|
|
633
|
+
: incoming;
|
|
634
|
+
const cfg = settings.setVectorConfig({
|
|
635
|
+
enabled: body.enabled,
|
|
636
|
+
baseUrl: body.baseUrl,
|
|
637
|
+
apiKey: key,
|
|
638
|
+
model: body.model
|
|
639
|
+
});
|
|
640
|
+
sendJson(res, 200, { config: { ...cfg, apiKey: maskApiKey(cfg.apiKey) } });
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
const cfg = settings.getVectorConfig() ?? { enabled: false, baseUrl: "", apiKey: "", model: "" };
|
|
644
|
+
sendJson(res, 200, { config: { ...cfg, apiKey: maskApiKey(cfg.apiKey) } });
|
|
645
|
+
} catch {
|
|
646
|
+
sendJson(res, 500, { error: "internal" });
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
// --- vector re-index (backfill embeddings for rows missing them) ---
|
|
652
|
+
register({
|
|
653
|
+
kind: "exact",
|
|
654
|
+
path: "/api/dsh-mneme/vector-reindex",
|
|
655
|
+
handler(req, res) {
|
|
656
|
+
try {
|
|
657
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
658
|
+
if (!embedder) {
|
|
659
|
+
sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-unavailable" });
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const url = new URL(req.url, "http://localhost");
|
|
663
|
+
const limit = Number(url.searchParams.get("limit") ?? 100);
|
|
664
|
+
// Unified re-index entry: works for both the legacy OpenAI embedder and
|
|
665
|
+
// the new local/ollama backends (which have no reindexMissing method).
|
|
666
|
+
const viaIndex = semantic?.vectorIndex && semantic?.vectorIndex.rebuildIndex;
|
|
667
|
+
const task = viaIndex
|
|
668
|
+
? semantic.vectorIndex.rebuildIndex(embedder, { limit })
|
|
669
|
+
: embedder.reindexMissing ? embedder.reindexMissing(limit) : Promise.resolve({ indexed: 0, skipped: 0, error: "vector-unavailable" });
|
|
670
|
+
// Return the chain so awaiting callers (tests/health checks) observe the
|
|
671
|
+
// finished response rather than racing the async backfill.
|
|
672
|
+
return task.then((result) => {
|
|
673
|
+
sendJson(res, 200, result);
|
|
674
|
+
}).catch(() => {
|
|
675
|
+
sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-failed" });
|
|
676
|
+
});
|
|
677
|
+
} catch {
|
|
678
|
+
sendJson(res, 500, { error: "internal" });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// --- semantic pipeline status (model, index, reranker) ---
|
|
684
|
+
register({
|
|
685
|
+
kind: "exact",
|
|
686
|
+
path: "/api/dsh-mneme/semantic",
|
|
687
|
+
handler(req, res) {
|
|
688
|
+
try {
|
|
689
|
+
const stats = semantic?.vectorIndex?.getStats?.() ?? null;
|
|
690
|
+
// #118: expose readiness so the status card can tell "initializing /
|
|
691
|
+
// unreachable" from "disabled". An embedder carrying no `ready` prop at
|
|
692
|
+
// all (test doubles, third-party adapters) is assumed usable.
|
|
693
|
+
// issue #135: 但「可用」和「配了」是两件事 —— legacy OpenAI embedder
|
|
694
|
+
// 此前恒报 ready,于是 baseUrl/apiKey/model 全空时状态卡依然是绿的
|
|
695
|
+
// ("绿色假阳性")。下面把两者分开,并附上索引覆盖度。
|
|
696
|
+
const ready = embedder ? ("ready" in embedder ? embedder.ready === true : true) : null;
|
|
697
|
+
// local/ollama 的配置来自 settings 兜底,没有「未配置」态:它们的
|
|
698
|
+
// ready=false 表示「还在初始化」,不是「没配」。
|
|
699
|
+
const configured = embedder ? (typeof embedder.configured === "boolean" ? embedder.configured : true) : null;
|
|
700
|
+
const embedded = Number(stats?.embeddedCount ?? 0);
|
|
701
|
+
const total = Number(stats?.totalCount ?? 0);
|
|
702
|
+
sendJson(res, 200, {
|
|
703
|
+
ready,
|
|
704
|
+
configured,
|
|
705
|
+
// 机器可读的原因;null = 一切正常。面板按它选文案,不用猜布尔组合。
|
|
706
|
+
reason: !embedder ? "no-embedder"
|
|
707
|
+
: configured === false ? "not-configured"
|
|
708
|
+
: ready !== true ? "initializing"
|
|
709
|
+
: null,
|
|
710
|
+
// 空库时 ratio 必须是 null 而非 0 —— 0 会被读成「一条都没嵌上」。
|
|
711
|
+
coverage: { embedded, total, ratio: total > 0 ? embedded / total : null },
|
|
712
|
+
// 配了、库里有东西可嵌、却一条都没嵌上 = 真的降级(本机此前长期
|
|
713
|
+
// 处于 ready:true + embeddedCount:0,面板却显示正常)。
|
|
714
|
+
degraded: configured === true && total > 0 && embedded === 0,
|
|
715
|
+
// Explicit display name first: the legacy OpenAI-compatible embedder
|
|
716
|
+
// is an object literal, so constructor.name would be "Object".
|
|
717
|
+
embedProvider: embedder ? (embedder.name ?? embedder.constructor?.name ?? "unknown") : null,
|
|
718
|
+
modelHash: embedder?.modelHash ?? null,
|
|
719
|
+
dimension: embedder?.dimension ?? null,
|
|
720
|
+
reranker: semantic?.reranker ? "ready" : null,
|
|
721
|
+
index: stats,
|
|
722
|
+
// issue #131 / PR-A:自管运行时的状态。无论当前 provider 是不是 local
|
|
723
|
+
// 都报 —— 面板需要区分「已收编但没启用」和「要用本地却没收编」。
|
|
724
|
+
// 这里只做结构检查(不跑推理),失败降级成状态对象,不会让本端点 500。
|
|
725
|
+
// 必须经 publicRuntimeStatus 投影:本端点是免鉴权的,绝对路径与原始错误
|
|
726
|
+
// 文本不能出去(CWE-200)。完整诊断走本机 CLI 的 status。
|
|
727
|
+
localRuntime: publicRuntimeStatus(describeLocalRuntime({ runtimeDir: config?.runtimeDir ?? "" }))
|
|
728
|
+
});
|
|
729
|
+
} catch {
|
|
730
|
+
sendJson(res, 500, { error: "internal" });
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
// --- 一键取回自管运行时(issue #131 / PR-C:三档来源)-------------------------
|
|
736
|
+
// 写路径(requireAuth):它会往 ~/.dsh/mneme/runtime/ 落一份依赖闭包。
|
|
737
|
+
//
|
|
738
|
+
// 第一档源固定取本 profile 的 node_modules(由插件自身路径推出),不接受客户端传源目录 ——
|
|
739
|
+
// 「从任意目录往运行时目录里搬东西」没有必要成为对外能力。
|
|
740
|
+
//
|
|
741
|
+
// 一律回 200:请求本身合法,成不成看 body 里的 ok/status/reason。让 HTTP 码只表达
|
|
742
|
+
// 「有没有鉴权/路径对不对」,结论交给 ok —— 面板要能显示「收编了但闭包缺件」这种
|
|
743
|
+
// 中间状态,用一个 4xx 概括会把信息压掉。
|
|
744
|
+
register({
|
|
745
|
+
kind: "exact",
|
|
746
|
+
path: "/api/dsh-mneme/runtime/provision",
|
|
747
|
+
handler(req, res) {
|
|
748
|
+
try {
|
|
749
|
+
if (req.method !== "POST") {
|
|
750
|
+
sendJson(res, 405, { error: "method-not-allowed" });
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
754
|
+
return readBody(req).then(async (text) => {
|
|
755
|
+
const body = parseBody(text);
|
|
756
|
+
const runtimeDir = config?.runtimeDir ?? "";
|
|
757
|
+
// 三档来源依次尝试(收编本机 node_modules → 本地 .tgz 目录 → registry)。
|
|
758
|
+
// 所以这个端点会**联网**:它可能拉一份几十到上百 MB 的闭包。
|
|
759
|
+
const result = await provisionRuntime({
|
|
760
|
+
hostModulesDir: hostModulesDir(import.meta.url),
|
|
761
|
+
runtimeDir,
|
|
762
|
+
localTarballDir: config?.runtimeTarballDir ?? "",
|
|
763
|
+
mirror: config?.runtimeMirror ?? "",
|
|
764
|
+
overwrite: body?.overwrite === true
|
|
765
|
+
});
|
|
766
|
+
// 顺手带回最新状态,面板不必再打一次 /semantic(且它走的是免鉴权投影)。
|
|
767
|
+
sendJson(res, 200, {
|
|
768
|
+
...result,
|
|
769
|
+
localRuntime: publicRuntimeStatus(describeLocalRuntime({ runtimeDir }))
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
} catch {
|
|
773
|
+
sendJson(res, 500, { error: "internal" });
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
// --- LLM audit trail (Bug8): paginated read + aggregate stats ---
|
|
779
|
+
// Read-only endpoints, so like list/search/semantic they stay open even when
|
|
780
|
+
// apiToken is set. The stats aggregate budget by source over the last N days.
|
|
781
|
+
register({
|
|
782
|
+
kind: "exact",
|
|
783
|
+
path: "/api/dsh-mneme/semantic/llm-audit",
|
|
784
|
+
handler(req, res) {
|
|
785
|
+
try {
|
|
786
|
+
const url = new URL(req.url, "http://localhost");
|
|
787
|
+
const page = Math.max(1, Number(url.searchParams.get("page") ?? 1) || 1);
|
|
788
|
+
const pageSize = Math.min(200, Math.max(1, Number(url.searchParams.get("pageSize") ?? 50) || 50));
|
|
789
|
+
const source = url.searchParams.get("source") ?? undefined;
|
|
790
|
+
const items = service.listLlmAudits?.({ limit: pageSize, offset: (page - 1) * pageSize, source }) ?? [];
|
|
791
|
+
const total = service.countLlmAudits?.({ source }) ?? items.length;
|
|
792
|
+
sendJson(res, 200, { items, total, page, pageSize });
|
|
793
|
+
} catch {
|
|
794
|
+
sendJson(res, 500, { error: "internal" });
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
register({
|
|
800
|
+
kind: "exact",
|
|
801
|
+
path: "/api/dsh-mneme/semantic/llm-audit/stats",
|
|
802
|
+
handler(req, res) {
|
|
803
|
+
try {
|
|
804
|
+
const url = new URL(req.url, "http://localhost");
|
|
805
|
+
const days = Math.max(1, Math.min(365, Number(url.searchParams.get("days") ?? 7) || 7));
|
|
806
|
+
const stats = service.getLlmAuditStats?.({ days }) ?? null;
|
|
807
|
+
sendJson(res, 200, stats ?? { error: "unavailable" });
|
|
808
|
+
} catch {
|
|
809
|
+
sendJson(res, 500, { error: "internal" });
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
// --- ego graph: 1-2 hop neighborhood of one entity (graph panel P1) ---
|
|
815
|
+
// Read-only like list/search/semantic, so it stays open when apiToken is set.
|
|
816
|
+
// BFS from the root entity over entity_relations (both directions; the
|
|
817
|
+
// idx_relations_from/to indexes keep a 2-hop walk in the tens of ms even
|
|
818
|
+
// for a few thousand nodes). `distance` on each node is the hop count from
|
|
819
|
+
// the root so the UI can shade the frontier. The API is graph-traversal
|
|
820
|
+
// only — nodes carry no attr payload; hover summaries come from
|
|
821
|
+
// /semantic/graph/entity-attrs.
|
|
822
|
+
register({
|
|
823
|
+
kind: "exact",
|
|
824
|
+
path: "/api/dsh-mneme/semantic/graph/ego",
|
|
825
|
+
handler(req, res) {
|
|
826
|
+
try {
|
|
827
|
+
const url = new URL(req.url, "http://localhost");
|
|
828
|
+
const name = (url.searchParams.get("entity") ?? "").trim();
|
|
829
|
+
if (!name) {
|
|
830
|
+
sendJson(res, 400, { error: "missing-entity" });
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
const root = service.findEntityByName?.(name);
|
|
834
|
+
if (!root) {
|
|
835
|
+
sendJson(res, 404, { error: "entity-not-found" });
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
const depth = Math.max(1, Math.min(2, Number(url.searchParams.get("depth") ?? 1) || 1));
|
|
839
|
+
const limit = Math.max(1, Math.min(100, Number(url.searchParams.get("limit") ?? 40) || 40));
|
|
840
|
+
|
|
841
|
+
const nodes = new Map([[root.id, { ...root, distance: 0 }]]);
|
|
842
|
+
let frontier = [root.id];
|
|
843
|
+
for (let d = 1; d <= depth && nodes.size < limit; d++) {
|
|
844
|
+
const next = [];
|
|
845
|
+
for (const id of frontier) {
|
|
846
|
+
for (const rel of service.getRelations?.(id) ?? []) {
|
|
847
|
+
const other = rel.from_entity === id ? rel.to_entity : rel.from_entity;
|
|
848
|
+
if (nodes.has(other) || nodes.size >= limit) continue;
|
|
849
|
+
const entity = service.findEntityById?.(other);
|
|
850
|
+
if (!entity) continue;
|
|
851
|
+
nodes.set(other, { ...entity, distance: d });
|
|
852
|
+
next.push(other);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
frontier = next;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Collect every relation whose endpoints both survived the limit cut;
|
|
859
|
+
// each edge is visited twice (once per endpoint) so dedupe by id.
|
|
860
|
+
const edgeMap = new Map();
|
|
861
|
+
for (const id of nodes.keys()) {
|
|
862
|
+
for (const rel of service.getRelations?.(id) ?? []) {
|
|
863
|
+
if (nodes.has(rel.from_entity) && nodes.has(rel.to_entity)) {
|
|
864
|
+
edgeMap.set(rel.id, rel);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
sendJson(res, 200, {
|
|
870
|
+
root: { id: root.id, name: root.name, type: root.type ?? null, mention_count: root.mention_count ?? 1 },
|
|
871
|
+
nodes: [...nodes.values()].map((n) => ({
|
|
872
|
+
id: n.id,
|
|
873
|
+
name: n.name,
|
|
874
|
+
type: n.type ?? null,
|
|
875
|
+
mention_count: n.mention_count ?? 1,
|
|
876
|
+
distance: n.distance,
|
|
877
|
+
// v0.7.0 实体热投影:实体热 = 关联记忆 heat 聚合(max),前端据此
|
|
878
|
+
// 缩放节点大小/明暗。heatEnabled=false 时 entityHeat 返回 null。
|
|
879
|
+
heat: service.entityHeat?.(n.id) ?? null
|
|
880
|
+
})),
|
|
881
|
+
edges: [...edgeMap.values()].map((e) => ({
|
|
882
|
+
id: e.id,
|
|
883
|
+
from: e.from_entity,
|
|
884
|
+
to: e.to_entity,
|
|
885
|
+
relation_type: e.relation_type,
|
|
886
|
+
memory_id: e.memory_id ?? null,
|
|
887
|
+
created_at: e.created_at
|
|
888
|
+
}))
|
|
889
|
+
});
|
|
890
|
+
} catch {
|
|
891
|
+
sendJson(res, 500, { error: "internal" });
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
// --- entity attrs: current valid attrs for one entity (graph hover panel) ---
|
|
897
|
+
// Read-only; mirrors getCurrentAttrs (valid_until IS NULL). Also used as the
|
|
898
|
+
// graph panel's fallback list when the ego graph is too sparse to draw.
|
|
899
|
+
register({
|
|
900
|
+
kind: "exact",
|
|
901
|
+
path: "/api/dsh-mneme/semantic/graph/entity-attrs",
|
|
902
|
+
handler(req, res) {
|
|
903
|
+
try {
|
|
904
|
+
const url = new URL(req.url, "http://localhost");
|
|
905
|
+
const name = (url.searchParams.get("entity") ?? "").trim();
|
|
906
|
+
if (!name) {
|
|
907
|
+
sendJson(res, 400, { error: "missing-entity" });
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const entity = service.findEntityByName?.(name);
|
|
911
|
+
if (!entity) {
|
|
912
|
+
sendJson(res, 404, { error: "entity-not-found" });
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
const attrs = service.getCurrentAttrs?.(entity.id) ?? [];
|
|
916
|
+
sendJson(res, 200, {
|
|
917
|
+
entity: { id: entity.id, name: entity.name, type: entity.type ?? null, mention_count: entity.mention_count ?? 1 },
|
|
918
|
+
attrs: Array.isArray(attrs)
|
|
919
|
+
? attrs.map((a) => ({
|
|
920
|
+
key: a.attr_key,
|
|
921
|
+
value: a.attr_value,
|
|
922
|
+
confidence: a.confidence ?? null,
|
|
923
|
+
valid_from: a.valid_from ?? null
|
|
924
|
+
}))
|
|
925
|
+
: []
|
|
926
|
+
});
|
|
927
|
+
} catch {
|
|
928
|
+
sendJson(res, 500, { error: "internal" });
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
// --- entity directory (entity gene v0.3.0): read-only list for the UI ---
|
|
934
|
+
// Flat entity list (newest-seen first). Attributes and relations stay on
|
|
935
|
+
// their own per-entity endpoints, the directory only carries the columns
|
|
936
|
+
// the rail needs: name, type, mention_count, first/last seen.
|
|
937
|
+
register({
|
|
938
|
+
kind: "exact",
|
|
939
|
+
path: "/api/dsh-mneme/entities",
|
|
940
|
+
handler(req, res) {
|
|
941
|
+
try {
|
|
942
|
+
const url = new URL(req.url, "http://localhost");
|
|
943
|
+
const limit = Math.min(1000, Math.max(1, Number(url.searchParams.get("limit") ?? 500) || 500));
|
|
944
|
+
const entities = service.listEntities?.({ limit }) ?? [];
|
|
945
|
+
sendJson(res, 200, { entities, total: entities.length });
|
|
946
|
+
} catch {
|
|
947
|
+
sendJson(res, 500, { error: "internal" });
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
// --- 一条记忆的关联实体(记忆详情侧栏)-------------------------------------
|
|
953
|
+
// 只读:entity_attrs.memory_id 反查实体(store 一条 JOIN 完成,按提及去重)。
|
|
954
|
+
// 与目录 /entities 不同,这里以记忆为锚点,回答"这条记忆提到了谁"。记忆不
|
|
955
|
+
// 存在或无关联一律返回空数组——详情侧栏不需要区分这两种情况。
|
|
956
|
+
register({
|
|
957
|
+
kind: "exact",
|
|
958
|
+
path: "/api/dsh-mneme/memories/entities",
|
|
959
|
+
handler(req, res) {
|
|
960
|
+
try {
|
|
961
|
+
const url = new URL(req.url, "http://localhost");
|
|
962
|
+
const memoryId = (url.searchParams.get("memoryId") ?? "").trim();
|
|
963
|
+
if (!memoryId) {
|
|
964
|
+
sendJson(res, 400, { error: "missing-memory-id" });
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
sendJson(res, 200, { entities: service.entitiesForMemory?.(memoryId) ?? [] });
|
|
968
|
+
} catch {
|
|
969
|
+
sendJson(res, 500, { error: "internal" });
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
// --- 导出(面板备份/迁移)---------------------------------------------------
|
|
975
|
+
// 只读。json:全字段行(含 archived/forgotten,布尔化),updated_at DESC;
|
|
976
|
+
// markdown:按类型分节,块格式与磁盘镜像完全同构(renderMirrorText 与
|
|
977
|
+
// mirror.sync 共用同一条渲染路径),因此导出文本可以被 /import 原样吃回。
|
|
978
|
+
// 全量一次性取回(store.all 按 updated_at DESC)——导出是一次性备份动作,
|
|
979
|
+
// 不需要流式。
|
|
980
|
+
register({
|
|
981
|
+
kind: "exact",
|
|
982
|
+
path: "/api/dsh-mneme/export",
|
|
983
|
+
handler(req, res) {
|
|
984
|
+
try {
|
|
985
|
+
const url = new URL(req.url, "http://localhost");
|
|
986
|
+
const format = url.searchParams.get("format") ?? "json";
|
|
987
|
+
if (format !== "json" && format !== "markdown") {
|
|
988
|
+
sendJson(res, 400, { error: "invalid-format" });
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
const rows = service.all?.() ?? [];
|
|
992
|
+
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
|
993
|
+
if (format === "json") {
|
|
994
|
+
sendJson(res, 200, {
|
|
995
|
+
exported_at: new Date().toISOString(),
|
|
996
|
+
version: PACKAGE_VERSION,
|
|
997
|
+
count: rows.length,
|
|
998
|
+
memories: rows
|
|
999
|
+
}, { "Content-Disposition": `attachment; filename="dsh-mneme-export-${stamp}.json"` });
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
const byType = {};
|
|
1003
|
+
for (const row of rows) {
|
|
1004
|
+
if (TYPE_FILE[row.type]) (byType[row.type] ??= []).push(row);
|
|
1005
|
+
}
|
|
1006
|
+
const sections = [];
|
|
1007
|
+
for (const type of Object.keys(TYPE_FILE)) {
|
|
1008
|
+
if (byType[type]?.length) sections.push(renderMirrorText(type, byType[type], langOf(config)));
|
|
1009
|
+
}
|
|
1010
|
+
sendAttachment(res, 200, "text/markdown; charset=utf-8", `dsh-mneme-export-${stamp}.md`, sections.join("\n"));
|
|
1011
|
+
} catch {
|
|
1012
|
+
sendJson(res, 500, { error: "internal" });
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
// --- 导入(Markdown 镜像回填)----------------------------------------------
|
|
1018
|
+
// 写路径(requireAuth)。body {type, markdown}:type 是镜像 TYPE_FILE 键
|
|
1019
|
+
// (preference/project/decision/history/summary),markdown 是与镜像文件同构
|
|
1020
|
+
// 的文本。解析复用 readHumanEdits 的纯函数核心 parseHumanEdits(同一实现,
|
|
1021
|
+
// 行为一致是硬约束),合并走 mergeHumanEdits(只吃 title/content;digest 命
|
|
1022
|
+
// 中或无差异的条目在 service 侧自动跳过)。解析出 0 条不算错误。
|
|
1023
|
+
register({
|
|
1024
|
+
kind: "exact",
|
|
1025
|
+
path: "/api/dsh-mneme/import",
|
|
1026
|
+
handler(req, res) {
|
|
1027
|
+
try {
|
|
1028
|
+
if (req.method !== "POST") {
|
|
1029
|
+
sendJson(res, 404, { error: "not-found" });
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
1033
|
+
return readBody(req).then((text) => {
|
|
1034
|
+
const body = parseBody(text);
|
|
1035
|
+
if (typeof body.type !== "string" || !Object.hasOwn(TYPE_FILE, body.type)) {
|
|
1036
|
+
sendJson(res, 400, { error: "invalid-type" });
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
if (typeof body.markdown !== "string" || !body.markdown.trim()) {
|
|
1040
|
+
sendJson(res, 400, { error: "invalid-markdown" });
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
const edits = parseHumanEdits(body.markdown);
|
|
1044
|
+
service.mergeHumanEdits(body.type, edits);
|
|
1045
|
+
sendJson(res, 200, { merged: edits.length, type: body.type });
|
|
1046
|
+
});
|
|
1047
|
+
} catch {
|
|
1048
|
+
sendJson(res, 500, { error: "internal" });
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
// --- 巩固状态(dream 面板)--------------------------------------------------
|
|
1054
|
+
// 只读:最近巩固运行(最多 5 条,created_at DESC)+ 未解决冲突队列。
|
|
1055
|
+
// pendingMemoryIds 从未解决冲突行提取涉及的记忆 id,去重后上限 200,避免积
|
|
1056
|
+
// 压很大时响应失控。listDreamRuns/listConflictPending 走 service 现成的审计
|
|
1057
|
+
// 只读通道(bookkeeping 语义,不触发写钩子)。
|
|
1058
|
+
register({
|
|
1059
|
+
kind: "exact",
|
|
1060
|
+
path: "/api/dsh-mneme/dream-status",
|
|
1061
|
+
handler(req, res) {
|
|
1062
|
+
try {
|
|
1063
|
+
const runs = (service.listDreamRuns?.({ limit: 5 }) ?? []).map((r) => ({
|
|
1064
|
+
created_at: r.created_at,
|
|
1065
|
+
status: r.status,
|
|
1066
|
+
provider: r.provider ?? null,
|
|
1067
|
+
model: r.model ?? null,
|
|
1068
|
+
error: r.error ?? null,
|
|
1069
|
+
run_type: r.run_type ?? "auto",
|
|
1070
|
+
// sleep 审计:heat/时间分层降级决策计数(工作动态可展示"降级 N 条")。
|
|
1071
|
+
// 数据来自 runSleep 写入的 decisions.demotion({demoted,archived} 数组)。
|
|
1072
|
+
demotion: r.decisions?.demotion
|
|
1073
|
+
? { demoted: (r.decisions.demotion.demoted ?? []).length, archived: (r.decisions.demotion.archived ?? []).length }
|
|
1074
|
+
: null
|
|
1075
|
+
}));
|
|
1076
|
+
const pendingConflicts = service.countConflictPending?.() ?? 0;
|
|
1077
|
+
const ids = new Set();
|
|
1078
|
+
for (const row of service.listConflictPending?.({ limit: 200, includeResolved: false }) ?? []) {
|
|
1079
|
+
if (ids.size >= 200) break;
|
|
1080
|
+
if (row.memory_a) ids.add(row.memory_a);
|
|
1081
|
+
if (ids.size >= 200) break;
|
|
1082
|
+
if (row.memory_b) ids.add(row.memory_b);
|
|
1083
|
+
}
|
|
1084
|
+
sendJson(res, 200, {
|
|
1085
|
+
lastRun: runs[0] ?? null,
|
|
1086
|
+
runs,
|
|
1087
|
+
pendingConflicts,
|
|
1088
|
+
pendingMemoryIds: [...ids].slice(0, 200)
|
|
1089
|
+
});
|
|
1090
|
+
} catch {
|
|
1091
|
+
sendJson(res, 500, { error: "internal" });
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
// --- 冲突集中处理(v0.8.0):队列 + 人工确认 --------------------------------
|
|
1097
|
+
// GET /conflicts:未解决冲突 + 双方当前内容(此前面板只有计数与徽章,
|
|
1098
|
+
// resolveConflictPending 在存储层躺着没有任何调用方)。POST /conflicts/resolve:
|
|
1099
|
+
// {id, winner: "a"|"b"|null, apply}——盖章审计,apply 时按 dream 非冻结
|
|
1100
|
+
// conflict 路径同款处置(service.resolveConflictPending 内部复用
|
|
1101
|
+
// applyDecisions:CAS + 事务 + 注记 + 幂等)。认证与其他写路由一致。
|
|
1102
|
+
register({
|
|
1103
|
+
kind: "exact",
|
|
1104
|
+
path: "/api/dsh-mneme/conflicts",
|
|
1105
|
+
handler(req, res) {
|
|
1106
|
+
try {
|
|
1107
|
+
if (req.method !== "GET") {
|
|
1108
|
+
sendJson(res, 404, { error: "not-found" });
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
1112
|
+
const url = new URL(req.url, "http://localhost");
|
|
1113
|
+
const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit") ?? 50) || 50));
|
|
1114
|
+
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0) || 0);
|
|
1115
|
+
const items = service.listConflictQueue({ limit, offset });
|
|
1116
|
+
sendJson(res, 200, { items, total: service.countConflictPending?.() ?? items.length });
|
|
1117
|
+
} catch {
|
|
1118
|
+
sendJson(res, 500, { error: "internal" });
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
register({
|
|
1124
|
+
kind: "exact",
|
|
1125
|
+
path: "/api/dsh-mneme/conflicts/resolve",
|
|
1126
|
+
handler(req, res) {
|
|
1127
|
+
try {
|
|
1128
|
+
if (req.method !== "POST") {
|
|
1129
|
+
sendJson(res, 404, { error: "not-found" });
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
1133
|
+
return readBody(req).then((text) => {
|
|
1134
|
+
let body = {};
|
|
1135
|
+
try { body = JSON.parse(text || "{}"); } catch { /* 坏 JSON → 走缺参分支 */ }
|
|
1136
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
1137
|
+
if (!id) {
|
|
1138
|
+
sendJson(res, 400, { error: "missing-id" });
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
const winner = body.winner === "a" || body.winner === "b" ? body.winner : null;
|
|
1142
|
+
const apply = body.apply !== false;
|
|
1143
|
+
const result = service.resolveConflictPending(id, { winner, apply });
|
|
1144
|
+
if (!result) {
|
|
1145
|
+
sendJson(res, 404, { error: "not-found" });
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
sendJson(res, 200, { ok: true, conflict: result });
|
|
1149
|
+
}).catch(() => sendJson(res, 500, { error: "internal" }));
|
|
1150
|
+
} catch {
|
|
1151
|
+
sendJson(res, 500, { error: "internal" });
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
// --- health: mirror sync state (F-NEW-03 / v0.3.6) ---
|
|
1157
|
+
// Auth-gated; only returns a sanitized error code (never raw last_error which
|
|
1158
|
+
// may leak paths/token-like strings/internal hosts). On state read failure it
|
|
1159
|
+
// reports unknown/degraded (fail-closed) instead of a false dirty=false.
|
|
1160
|
+
register({
|
|
1161
|
+
kind: "exact",
|
|
1162
|
+
path: "/api/dsh-mneme/health",
|
|
1163
|
+
handler(req, res) {
|
|
1164
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
1165
|
+
let state = null;
|
|
1166
|
+
try {
|
|
1167
|
+
state = service.getMirrorHealth?.() ?? null;
|
|
1168
|
+
} catch {
|
|
1169
|
+
// read failure is itself a health signal: do not report a false clean
|
|
1170
|
+
sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
if (!state) {
|
|
1174
|
+
sendJson(res, 200, { mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null } });
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
// Real read failure surfaces as dirty === null (peer blocker 5): report
|
|
1178
|
+
// unknown explicitly instead of collapsing into a false "ok"/"degraded".
|
|
1179
|
+
if (state.dirty === null) {
|
|
1180
|
+
sendJson(res, 200, {
|
|
1181
|
+
mirror: { dirty: null, status: "unknown", last_error: null, last_attempt: null, success_at: null }
|
|
1182
|
+
});
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
// Sanitized: boolean dirty + coarse status only; error string is mapped to
|
|
1186
|
+
// a bounded code, never echoed verbatim.
|
|
1187
|
+
let code = null;
|
|
1188
|
+
if (state.last_error) {
|
|
1189
|
+
const e = String(state.last_error);
|
|
1190
|
+
code = /enospc|no space/i.test(e) ? "no-space" : /permission|eacces/i.test(e) ? "permission" : "sync-failed";
|
|
1191
|
+
}
|
|
1192
|
+
sendJson(res, 200, {
|
|
1193
|
+
mirror: {
|
|
1194
|
+
dirty: state.dirty === true,
|
|
1195
|
+
status: state.dirty === true ? "degraded" : (code ? "degraded" : "ok"),
|
|
1196
|
+
last_error: code,
|
|
1197
|
+
last_attempt: state.last_attempt ?? null,
|
|
1198
|
+
success_at: state.success_at ?? null
|
|
1199
|
+
}
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
// --- panel mode (v0.7.12): light / standard -------------------------------
|
|
1205
|
+
// Persists the Web panel's feature preset into the settings kv
|
|
1206
|
+
// ("panel_mode"). PUT requires auth like every other settings write; the
|
|
1207
|
+
// value is validated against the enum. index.js applies the light preset on
|
|
1208
|
+
// the next boot (persisted mode wins over the bundle config).
|
|
1209
|
+
register({
|
|
1210
|
+
kind: "exact",
|
|
1211
|
+
path: "/api/dsh-mneme/mode",
|
|
1212
|
+
handler(req, res) {
|
|
1213
|
+
try {
|
|
1214
|
+
if (req.method === "PUT" || req.method === "POST") {
|
|
1215
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
1216
|
+
return readBody(req).then((text) => {
|
|
1217
|
+
const body = parseBody(text);
|
|
1218
|
+
if (body.mode !== "light" && body.mode !== "standard") {
|
|
1219
|
+
sendJson(res, 400, { error: "invalid-mode" });
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
settings.setPanelMode(body.mode);
|
|
1223
|
+
sendJson(res, 200, { mode: settings.getPanelMode() });
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
sendJson(res, 200, { mode: settings.getPanelMode() });
|
|
1227
|
+
} catch {
|
|
1228
|
+
sendJson(res, 500, { error: "internal" });
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
// --- external API settings (the standalone server's panel-facing config) ---
|
|
1234
|
+
register({
|
|
1235
|
+
kind: "exact",
|
|
1236
|
+
path: "/api/dsh-mneme/external-api",
|
|
1237
|
+
handler(req, res) {
|
|
1238
|
+
try {
|
|
1239
|
+
if (req.method === "PUT" || req.method === "POST") {
|
|
1240
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
1241
|
+
return readBody(req).then((text) => {
|
|
1242
|
+
const body = parseBody(text);
|
|
1243
|
+
const patch = {};
|
|
1244
|
+
if (body.enabled !== undefined) patch.enabled = body.enabled === true;
|
|
1245
|
+
if (body.port !== undefined) {
|
|
1246
|
+
const port = Number(body.port);
|
|
1247
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
1248
|
+
sendJson(res, 400, { error: "invalid-port" });
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
patch.port = port;
|
|
1252
|
+
}
|
|
1253
|
+
if (body.host !== undefined) {
|
|
1254
|
+
const host = String(body.host).trim();
|
|
1255
|
+
if (!host || host.includes("://")) {
|
|
1256
|
+
sendJson(res, 400, { error: "invalid-host" });
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
patch.host = host;
|
|
1260
|
+
}
|
|
1261
|
+
sendJson(res, 200, { config: fullExternalConfig(settings.setExternalApi(patch)) });
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
// GET: always hand back a token — the standalone server generates its
|
|
1265
|
+
// own on first enabled boot, but the panel displays it before that,
|
|
1266
|
+
// so materialize and persist one here.
|
|
1267
|
+
const kv = settings.getExternalApi() ?? {};
|
|
1268
|
+
if (!kv.token) {
|
|
1269
|
+
kv.token = randomBytes(24).toString("base64url");
|
|
1270
|
+
settings.setExternalApi({ token: kv.token });
|
|
1271
|
+
}
|
|
1272
|
+
sendJson(res, 200, { config: fullExternalConfig(kv) });
|
|
1273
|
+
} catch {
|
|
1274
|
+
sendJson(res, 500, { error: "internal" });
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
// --- feature flags(功能开关:面板逐项开关后端能力)-------------------------
|
|
1280
|
+
// 读路由保持开放(与 mode/list 一致),前端无需 token 即可渲染开关状态;PUT
|
|
1281
|
+
// 与其他设置写一致走 requireAuth。空/非对象 patch 直接 400:它不携带任何
|
|
1282
|
+
// 意图,静默成功只会掩盖前端 bug。校验失败(未知键/类型/越界)的 400 把键
|
|
1283
|
+
// 名放进 error,前端能直接定位写坏的开关。生效节奏与 panel_mode 相同:
|
|
1284
|
+
// 持久化后下次启动合并进 cfg,本次启动的运行时行为不变。
|
|
1285
|
+
register({
|
|
1286
|
+
kind: "exact",
|
|
1287
|
+
path: "/api/dsh-mneme/features",
|
|
1288
|
+
handler(req, res) {
|
|
1289
|
+
try {
|
|
1290
|
+
if (req.method === "PUT" || req.method === "POST") {
|
|
1291
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
1292
|
+
return readBody(req).then((text) => {
|
|
1293
|
+
const body = parseBody(text);
|
|
1294
|
+
if (typeof body !== "object" || body === null || Array.isArray(body) || Object.keys(body).length === 0) {
|
|
1295
|
+
sendJson(res, 400, { error: "invalid-patch" });
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
try {
|
|
1299
|
+
settings.setFeatureFlags(body);
|
|
1300
|
+
} catch (error) {
|
|
1301
|
+
sendJson(res, 400, { error: error.message });
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
sendJson(res, 200, featureSnapshot());
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
sendJson(res, 200, featureSnapshot());
|
|
1308
|
+
} catch {
|
|
1309
|
+
sendJson(res, 500, { error: "internal" });
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
// 注入状态(#182,只读):面板状态页据此决定是否渲染「极简模式下注入按宿主
|
|
1315
|
+
// 设计关闭」提示卡。suppressed=true 需同时满足:autoInject 生效值开着(否则
|
|
1316
|
+
// 关注入是用户自己的选择,不是黑盒)、会话头 preset 为 minimal、且 preset 确
|
|
1317
|
+
// 实观测到了(null=未知会话/宿主不支持,面板静默不猜)。preset 值按字符串
|
|
1318
|
+
// 精确匹配 "minimal"——host bundle 的 preset id(#175 逐字证据),不做模糊。
|
|
1319
|
+
register({
|
|
1320
|
+
kind: "exact",
|
|
1321
|
+
path: "/api/dsh-mneme/inject-status",
|
|
1322
|
+
handler(req, res) {
|
|
1323
|
+
try {
|
|
1324
|
+
if (req.method !== "GET") {
|
|
1325
|
+
sendJson(res, 404, { error: "not-found" });
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
const effective = featureSnapshot().effective;
|
|
1329
|
+
const autoInject = effective.autoInject === true;
|
|
1330
|
+
const agentPreset = lastAgentPreset;
|
|
1331
|
+
const suppressed = autoInject && agentPreset === "minimal";
|
|
1332
|
+
sendJson(res, 200, { autoInject, agentPreset, suppressed });
|
|
1333
|
+
} catch {
|
|
1334
|
+
sendJson(res, 500, { error: "internal" });
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
// --- custom commands ---
|
|
1340
|
+
register({
|
|
1341
|
+
kind: "exact",
|
|
1342
|
+
path: "/api/dsh-mneme/commands",
|
|
1343
|
+
handler(req, res) {
|
|
1344
|
+
try {
|
|
1345
|
+
if (req.method === "POST") {
|
|
1346
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
1347
|
+
return readBody(req).then((text) => {
|
|
1348
|
+
const body = parseBody(text);
|
|
1349
|
+
try {
|
|
1350
|
+
const command = commands.add({
|
|
1351
|
+
name: body.name,
|
|
1352
|
+
description: body.description,
|
|
1353
|
+
instruction: body.instruction
|
|
1354
|
+
});
|
|
1355
|
+
sendJson(res, 200, { command });
|
|
1356
|
+
} catch (error) {
|
|
1357
|
+
sendJson(res, 400, { error: error.message });
|
|
1358
|
+
}
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
if (req.method === "DELETE") {
|
|
1362
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
1363
|
+
const url = new URL(req.url, "http://localhost");
|
|
1364
|
+
const id = url.searchParams.get("id");
|
|
1365
|
+
const removed = id ? commands.remove(id) : false;
|
|
1366
|
+
sendJson(res, 200, { removed });
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
sendJson(res, 200, { commands: commands.list() });
|
|
1370
|
+
} catch {
|
|
1371
|
+
sendJson(res, 500, { error: "internal" });
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
return {
|
|
1377
|
+
routes: 32,
|
|
1378
|
+
dispose: () => {
|
|
1379
|
+
for (const dispose of disposers) dispose();
|
|
1380
|
+
}
|
|
1381
|
+
};
|
|
1382
|
+
}
|