@modusensus/dsh-mneme 0.9.0 → 0.9.2
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 +82 -19
- package/bin/cli.mjs +69 -9
- package/docs/CONFIGURATION.md +48 -0
- package/docs/SEMANTIC.md +3 -3
- package/lib/api-standalone.js +78 -12
- package/lib/api.js +339 -59
- package/lib/candidates.js +454 -454
- package/lib/client.js +669 -256
- package/lib/config.js +50 -2
- package/lib/dream/sleep.js +35 -12
- package/lib/dream.js +86 -14
- package/lib/embedding.js +153 -22
- package/lib/index.js +32 -0
- package/lib/inject.js +34 -3
- package/lib/lang.js +124 -0
- package/lib/pipeline-activities.js +178 -0
- package/lib/pipeline-attribution.js +165 -0
- package/lib/pipeline-command.js +279 -0
- package/lib/pipeline-confirm.js +142 -0
- package/lib/pipeline-exec.js +174 -0
- package/lib/pipeline-nudge.js +1281 -0
- package/lib/pipeline-observations.js +181 -0
- package/lib/pipeline-observe.js +133 -0
- package/lib/pipeline-runner.js +346 -0
- package/lib/pipeline-runs.js +442 -0
- package/lib/pipeline.js +128 -7
- package/lib/scope-state.js +277 -0
- package/lib/semantic.js +66 -10
- package/lib/service.js +80 -3
- package/lib/settings.js +139 -1
- package/lib/store.js +216 -3
- package/lib/summarize.js +2 -3
- package/lib/tools.js +71 -0
- package/package.json +3 -3
- package/scripts/changelog-prep.mjs +62 -7
- package/scripts/check-sync.js +8 -6
- package/scripts/release-docs.mjs +204 -0
- package/scripts/release-notes.mjs +162 -0
- package/src/api-standalone.js +78 -12
- package/src/api.js +339 -59
- package/src/candidates.js +454 -454
- package/src/config.js +50 -2
- package/src/dream/sleep.js +35 -12
- package/src/dream.js +86 -14
- package/src/embedding.js +153 -22
- package/src/index.js +32 -0
- package/src/inject.js +34 -3
- package/src/lang.js +124 -0
- package/src/pipeline-activities.js +178 -0
- package/src/pipeline-attribution.js +165 -0
- package/src/pipeline-command.js +279 -0
- package/src/pipeline-confirm.js +142 -0
- package/src/pipeline-exec.js +174 -0
- package/src/pipeline-nudge.js +1281 -0
- package/src/pipeline-observations.js +181 -0
- package/src/pipeline-observe.js +133 -0
- package/src/pipeline-runner.js +346 -0
- package/src/pipeline-runs.js +442 -0
- package/src/pipeline.js +128 -7
- package/src/scope-state.js +277 -0
- package/src/semantic.js +66 -10
- package/src/service.js +80 -3
- package/src/settings.js +139 -1
- package/src/store.js +216 -3
- package/src/summarize.js +2 -3
- package/src/tools.js +71 -0
- package/test/api-hardening.test.js +173 -0
- package/test/api.test.js +210 -1
- package/test/changelog-prep.test.js +74 -8
- package/test/cli-enums.test.js +37 -0
- package/test/cli-flag-value.test.js +86 -0
- package/test/client.test.js +216 -6
- package/test/distill-tx-embed-gap.test.js +110 -0
- package/test/docs-config-keys.test.js +65 -0
- package/test/dream-maxtokens-fallback.test.js +132 -0
- package/test/embed-text-reasons.test.js +112 -0
- package/test/helpers/write-admission-samples.js +6 -2
- package/test/lib-smoke.test.js +20 -0
- package/test/persona-api.test.js +172 -0
- package/test/persona-flag.test.js +36 -0
- package/test/persona-inject.test.js +145 -0
- package/test/persona-settings.test.js +145 -0
- package/test/pipeline-activities.test.js +155 -0
- package/test/pipeline-attribution.test.js +175 -0
- package/test/pipeline-command.test.js +243 -0
- package/test/pipeline-confirm.test.js +148 -0
- package/test/pipeline-exec.test.js +172 -0
- package/test/pipeline-nudge.test.js +1036 -0
- package/test/pipeline-observations.test.js +157 -0
- package/test/pipeline-observe.test.js +127 -0
- package/test/pipeline-r1r2.test.js +149 -0
- package/test/pipeline-runner.test.js +442 -0
- package/test/pipeline-runs.test.js +767 -0
- package/test/pipeline.test.js +137 -6
- package/test/readme-claims.test.js +68 -0
- package/test/reindex-backfill.test.js +83 -4
- package/test/release-docs.test.js +130 -0
- package/test/release-notes.test.js +240 -0
- package/test/release-workflow.test.js +16 -0
- package/test/route-policy.test.js +113 -0
- package/test/scope-state.test.js +481 -0
- package/test/security-doc.test.js +139 -0
- package/test/sensitive-scan.test.js +44 -0
- package/test/tools-dto-schema-matrix.test.js +12 -3
- package/test/tools.test.js +6 -6
- package/test/workflow-guards.test.js +156 -0
package/src/api.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { URL } from "node:url";
|
|
2
2
|
import { isAbsolute } from "node:path";
|
|
3
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
4
|
import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
4
5
|
import { FEATURE_FLAG_SPEC } from "./settings.js";
|
|
5
6
|
import { TYPES } from "./store.js";
|
|
@@ -12,7 +13,10 @@ import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
|
|
|
12
13
|
import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
|
|
13
14
|
import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
|
|
14
15
|
import { parseReclaimQuery, parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE } from "./reclaim-http.js";
|
|
15
|
-
import { readPipelineDoc } from "./pipeline.js";
|
|
16
|
+
import { readPipelineDoc, loadPipelines, projectPipelineDir } from "./pipeline.js";
|
|
17
|
+
import { loadRunsById, runsDir, RUNS_SUFFIX } from "./pipeline-runs.js";
|
|
18
|
+
import { observationDir, loadObservationsById } from "./pipeline-observations.js";
|
|
19
|
+
import { projectKey } from "./candidates.js";
|
|
16
20
|
import { readCandidateDoc } from "./candidates.js";
|
|
17
21
|
|
|
18
22
|
// headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
|
|
@@ -37,6 +41,16 @@ function sendAttachment(res, status, contentType, filename, body) {
|
|
|
37
41
|
// defaults in config.js (externalApiPort / externalApiHost).
|
|
38
42
|
const EXTERNAL_API_DEFAULTS = { enabled: false, port: 8790, host: "127.0.0.1" };
|
|
39
43
|
|
|
44
|
+
// 请求体上限(防御,不是功能):readBody 只累加字符串,不设上限时一个超大 body
|
|
45
|
+
// 就能把进程内存拉到 Content-Length 那个量级。4 MiB 远高于任何真实写入(记忆
|
|
46
|
+
// 正文、导入的 markdown、面板 patch 都远小于它)。
|
|
47
|
+
const MAX_REQUEST_BODY_BYTES = 4 * 1024 * 1024;
|
|
48
|
+
// 分页上界(防御):list/search 的参数直接进 SQL LIMIT,无上界时
|
|
49
|
+
// ?limit=100000 会让一次免鉴权 GET 把全库序列化出来(2026-10 审计:免鉴权读面
|
|
50
|
+
// + 浏览器简单请求 = 廉价 DoS)。1000/200 远高于面板与 CLI 的任何真实用法。
|
|
51
|
+
const MAX_PAGE_LIMIT = 1000;
|
|
52
|
+
const MAX_SEARCH_TOP_K = 200;
|
|
53
|
+
|
|
40
54
|
/** Merge persisted external-api kv over the defaults for client display. */
|
|
41
55
|
function fullExternalConfig(kv = {}) {
|
|
42
56
|
return {
|
|
@@ -70,22 +84,82 @@ function isAuthorized(req, apiToken) {
|
|
|
70
84
|
}
|
|
71
85
|
|
|
72
86
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
87
|
+
* 路由鉴权策略(2026-10 审计的结构性修法)。三条:
|
|
88
|
+
* open 永不鉴权——「公开读面」,必须写进 SECURITY.md 的安全模型表
|
|
89
|
+
* token 配置了 apiToken 就整条要带(写路由 + 敏感读)
|
|
90
|
+
* token-write 只有非 GET/HEAD 才要 token(面板要读、只有写才有副作用)
|
|
91
|
+
*
|
|
92
|
+
* 少声明不是「默认开放」:register 直接抛错,插件起不来。这是本方案的要点——
|
|
93
|
+
* 「新增路由忘了加鉴权」从运行期静默漏洞变成装配期硬失败,不依赖谁记得写、也
|
|
94
|
+
* 不依赖测试有没有覆盖到(/export 就是这么漏的,见 2026-10 审计 P0)。
|
|
75
95
|
*/
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
96
|
+
export const ROUTE_POLICIES = Object.freeze(["open", "token", "token-write"]);
|
|
97
|
+
|
|
98
|
+
/** True when this request must present the apiToken under the route's policy. */
|
|
99
|
+
function needsToken(req, policy, apiToken) {
|
|
100
|
+
if (policy === "open") return false;
|
|
101
|
+
const method = String(req.method || "GET").toUpperCase();
|
|
102
|
+
if (policy === "token-write" && (method === "GET" || method === "HEAD")) return false;
|
|
103
|
+
return !isAuthorized(req, apiToken);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 唯一的登记入口:校验 policy、拒绝重复路径、把 handler 包上鉴权。
|
|
108
|
+
* 返回的 count() 供 createApi 汇报真实路由数(此前的字面量 36 已经和现实脱节)。
|
|
109
|
+
*/
|
|
110
|
+
export function createRouteRegistrar(webServer, apiToken, disposers) {
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
const register = (route) => {
|
|
113
|
+
if (!ROUTE_POLICIES.includes(route.policy)) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`[dsh-mneme] 路由 ${route.path} 必须声明 policy(${ROUTE_POLICIES.join(" / ")}),` +
|
|
116
|
+
`实际是 ${JSON.stringify(route.policy)}。先想清楚它属于哪一类,再同步 SECURITY.md 的安全模型表。`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const key = `${route.kind}:${route.path}`;
|
|
120
|
+
if (seen.has(key)) throw new Error(`[dsh-mneme] 路由 ${key} 被重复登记`);
|
|
121
|
+
seen.add(key);
|
|
122
|
+
const inner = route.handler;
|
|
123
|
+
const handler = (req, res) => {
|
|
124
|
+
if (!needsToken(req, route.policy, apiToken)) return inner(req, res);
|
|
125
|
+
sendJson(res, 401, { error: "unauthorized" });
|
|
126
|
+
return undefined;
|
|
127
|
+
};
|
|
128
|
+
disposers.push(webServer.register({ ...route, handler }));
|
|
129
|
+
};
|
|
130
|
+
return { register, count: () => seen.size };
|
|
80
131
|
}
|
|
81
132
|
|
|
82
|
-
|
|
83
|
-
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Collect the request body as text, or null when it exceeds the cap.
|
|
136
|
+
*
|
|
137
|
+
* 超限时**必须回 null 而不是空串**:空串经 parseBody 变成 {},而 /profile、
|
|
138
|
+
* /rules、/persona 的 PUT 会把「字段缺失」当成「用户要清空」——一个 5 MiB 的
|
|
139
|
+
* 请求就能把画像/规则/人格静默清掉。返回 null 让调用点在碰任何 store 之前
|
|
140
|
+
* 直接 413(2026-10 评审指出的数据损坏路径)。
|
|
141
|
+
* 连接不 destroy:调用方要能收到明确的 413,而不是被掐断。
|
|
142
|
+
*/
|
|
143
|
+
function readBody(req, res) {
|
|
84
144
|
return new Promise((resolve) => {
|
|
85
145
|
let body = "";
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
req.on("
|
|
146
|
+
let bytes = 0;
|
|
147
|
+
let overflow = false;
|
|
148
|
+
req.on("data", (chunk) => {
|
|
149
|
+
if (overflow) return;
|
|
150
|
+
bytes += chunk.length;
|
|
151
|
+
if (bytes > MAX_REQUEST_BODY_BYTES) {
|
|
152
|
+
overflow = true;
|
|
153
|
+
body = "";
|
|
154
|
+
sendJson(res, 413, { error: "payload-too-large", limit: MAX_REQUEST_BODY_BYTES });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
body += chunk;
|
|
158
|
+
});
|
|
159
|
+
req.on("end", () => resolve(overflow ? null : body));
|
|
160
|
+
// 超限后再来 transport error 也必须保留超限状态:否则调用点会拿着空串
|
|
161
|
+
// 继续往 store 写(PUT /persona 把 "" 解析成 {},覆盖既有设置)。
|
|
162
|
+
req.on("error", () => resolve(overflow ? null : ""));
|
|
89
163
|
});
|
|
90
164
|
}
|
|
91
165
|
|
|
@@ -133,6 +207,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
133
207
|
// feature flags 快照(GET/PUT 共用):overrides 是持久化的用户显式覆盖;
|
|
134
208
|
// effective 是启动配置在白名单键上被 overrides 覆盖后的最终值。config 缺席
|
|
135
209
|
// (旧调用方直连、未传 cfg)时只报被覆盖的键,不把不存在的默认值编造给前端。
|
|
210
|
+
//
|
|
211
|
+
// pendingRestart 回答的是第三个问题:「我改的开关,现在真的生效了吗?」
|
|
212
|
+
// 功能开关的生效节奏与 panel_mode 相同——启动时由 index.js 合并进 cfg,运行中
|
|
213
|
+
// 那份 cfg 是冻结的(见 index.js 装配处的注释)。于是面板显示「已改」不等于
|
|
214
|
+
// 运行时已改:#481 实测人格总闸正是这样,面板开着、人格段却不注入,用户只能
|
|
215
|
+
// 靠反复试错发现。effective 表达的是「运行时取什么值」(overrides 优先),
|
|
216
|
+
// **反推不出待重启**,必须另比一次冻结值——这是本字段存在的唯一理由。
|
|
217
|
+
// 只报「显式覆盖 ≠ 冻结值」的键:未被覆盖的键运行时值就是 cfg 值,不存在落差;
|
|
218
|
+
// config 缺席(旧调用方直连、未传 cfg)时无从比较,返回空数组而不编造。
|
|
136
219
|
const flagKeys = [
|
|
137
220
|
...FEATURE_FLAG_SPEC.booleans,
|
|
138
221
|
...Object.keys(FEATURE_FLAG_SPEC.ints),
|
|
@@ -158,7 +241,11 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
158
241
|
// Issue #399:蒸馏原文留存 + 溯源。两个键都是嵌套子字段(enabled 开关 +
|
|
159
242
|
// retentionDays 整数),漏登记的后果同上——effective 静默缺键。
|
|
160
243
|
"distillRecords.enabled": ["distillRecords", "enabled"],
|
|
161
|
-
"distillRecords.retentionDays": ["distillRecords", "retentionDays"]
|
|
244
|
+
"distillRecords.retentionDays": ["distillRecords", "retentionDays"],
|
|
245
|
+
// Issue #471:政策轴——作用域级可逆闸门。两个键都是嵌套子字段,漏登记的后果
|
|
246
|
+
// 同上(effective 静默缺键、面板读不到也 PUT 不进)。
|
|
247
|
+
"scopeState.enabled": ["scopeState", "enabled"],
|
|
248
|
+
"scopeState.enforce": ["scopeState", "enforce"]
|
|
162
249
|
// sensitiveScanEnabled(#164 A2)是顶层扁平键,不需要进这张表——走 configFlagValue
|
|
163
250
|
// 的默认分支即可。
|
|
164
251
|
};
|
|
@@ -167,6 +254,19 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
167
254
|
if (path) return config?.[path[0]]?.[path[1]];
|
|
168
255
|
return config?.[key];
|
|
169
256
|
}
|
|
257
|
+
// 「显式覆盖 ≠ 冻结值」的键。用 Object.is 比而不是 !==:NaN 与 ±0 的边角上
|
|
258
|
+
// 两者会给出相反的结论(Object.is(NaN, NaN) 为真、Object.is(0, -0) 为假),
|
|
259
|
+
// 开关值里虽然不该出现它们,但比较语义与「值是否相同」对齐更省心。
|
|
260
|
+
// 未被覆盖的键不看:它的运行时值本来就取自 cfg,不存在落差。
|
|
261
|
+
function pendingRestartKeys(overrides) {
|
|
262
|
+
if (!config) return [];
|
|
263
|
+
const pending = [];
|
|
264
|
+
for (const key of flagKeys) {
|
|
265
|
+
if (overrides[key] === undefined) continue;
|
|
266
|
+
if (!Object.is(overrides[key], configFlagValue(key))) pending.push(key);
|
|
267
|
+
}
|
|
268
|
+
return pending;
|
|
269
|
+
}
|
|
170
270
|
function featureSnapshot() {
|
|
171
271
|
const overrides = settings.getFeatureFlags();
|
|
172
272
|
const effective = {};
|
|
@@ -177,7 +277,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
177
277
|
if (value !== undefined) effective[key] = value;
|
|
178
278
|
}
|
|
179
279
|
}
|
|
180
|
-
return { overrides, effective };
|
|
280
|
+
return { overrides, effective, pendingRestart: pendingRestartKeys(overrides) };
|
|
181
281
|
}
|
|
182
282
|
|
|
183
283
|
// Ensure the service has an embedder when the API layer was handed one
|
|
@@ -187,14 +287,14 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
187
287
|
service.setEmbedder(embedder);
|
|
188
288
|
}
|
|
189
289
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
};
|
|
290
|
+
// 所有路由都经这一处登记:policy 必填、鉴权在这里统一判定(见 createRouteRegistrar)。
|
|
291
|
+
const { register, count: countRoutes } = createRouteRegistrar(webServer, apiToken, disposers);
|
|
193
292
|
|
|
194
293
|
// /api/dsh-mneme prefix fallback → 404 JSON for unknown sub-paths
|
|
195
294
|
register({
|
|
196
295
|
kind: "prefix",
|
|
197
296
|
path: "/api/dsh-mneme",
|
|
297
|
+
policy: "open",
|
|
198
298
|
handler(req, res) {
|
|
199
299
|
sendJson(res, 404, { error: "not-found" });
|
|
200
300
|
}
|
|
@@ -205,6 +305,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
205
305
|
register({
|
|
206
306
|
kind: "exact",
|
|
207
307
|
path: "/api/dsh-mneme/info",
|
|
308
|
+
policy: "open",
|
|
208
309
|
handler(req, res) {
|
|
209
310
|
sendJson(res, 200, { version: PACKAGE_VERSION });
|
|
210
311
|
}
|
|
@@ -217,6 +318,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
217
318
|
register({
|
|
218
319
|
kind: "exact",
|
|
219
320
|
path: "/api/dsh-mneme/version-check",
|
|
321
|
+
policy: "open",
|
|
220
322
|
handler(req, res) {
|
|
221
323
|
if (req.method !== "GET") {
|
|
222
324
|
sendJson(res, 404, { error: "not-found" });
|
|
@@ -236,11 +338,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
236
338
|
register({
|
|
237
339
|
kind: "exact",
|
|
238
340
|
path: "/api/dsh-mneme/list",
|
|
341
|
+
policy: "open",
|
|
239
342
|
handler(req, res) {
|
|
240
343
|
try {
|
|
241
344
|
const url = new URL(req.url, "http://localhost");
|
|
242
345
|
const type = url.searchParams.get("type") ?? undefined;
|
|
243
|
-
|
|
346
|
+
// 上界是防御:参数直接进 SQL LIMIT(见 MAX_PAGE_LIMIT 注释)。
|
|
347
|
+
const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), MAX_PAGE_LIMIT);
|
|
244
348
|
const offset = Number(url.searchParams.get("offset") ?? 0);
|
|
245
349
|
// order=chrono: pure newest-first for paged browsing; default keeps
|
|
246
350
|
// the importance-ranked order other callers rely on.
|
|
@@ -292,6 +396,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
292
396
|
register({
|
|
293
397
|
kind: "exact",
|
|
294
398
|
path: "/api/dsh-mneme/recall-stats",
|
|
399
|
+
policy: "open",
|
|
295
400
|
handler(req, res) {
|
|
296
401
|
if (req.method !== "GET") {
|
|
297
402
|
sendJson(res, 404, { error: "not-found" });
|
|
@@ -311,11 +416,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
311
416
|
register({
|
|
312
417
|
kind: "exact",
|
|
313
418
|
path: "/api/dsh-mneme/search",
|
|
419
|
+
policy: "open",
|
|
314
420
|
handler(req, res) {
|
|
315
421
|
try {
|
|
316
422
|
const url = new URL(req.url, "http://localhost");
|
|
317
423
|
const q = url.searchParams.get("q") ?? "";
|
|
318
|
-
|
|
424
|
+
// 上界是防御:topK 直接进召回(见 MAX_SEARCH_TOP_K 注释)。
|
|
425
|
+
const limit = Math.min(Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 20), MAX_SEARCH_TOP_K);
|
|
319
426
|
// mode selects the recall strategy (defaults to auto):
|
|
320
427
|
// auto (default) keyword first, vector fills remaining slots
|
|
321
428
|
// hybrid vector first, keyword fills remaining slots; scores of
|
|
@@ -355,11 +462,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
355
462
|
register({
|
|
356
463
|
kind: "exact",
|
|
357
464
|
path: "/api/dsh-mneme/profile",
|
|
465
|
+
policy: "token-write",
|
|
358
466
|
handler(req, res) {
|
|
359
467
|
try {
|
|
360
468
|
if (req.method === "PUT" || req.method === "POST") {
|
|
361
|
-
|
|
362
|
-
|
|
469
|
+
return readBody(req, res).then((text) => {
|
|
470
|
+
if (text === null) return;
|
|
363
471
|
const body = parseBody(text);
|
|
364
472
|
settings.setProfile(typeof body.profile === "string" ? body.profile : "");
|
|
365
473
|
sendJson(res, 200, { profile: settings.getProfile() });
|
|
@@ -372,6 +480,44 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
372
480
|
}
|
|
373
481
|
});
|
|
374
482
|
|
|
483
|
+
// --- agent persona(人格定义页,beta) -------------------------------------
|
|
484
|
+
// 形状照抄 /profile 路由(设计稿②):写方法走 requireAuth + readBody + parseBody。
|
|
485
|
+
// v1.2 多卡:GET 回全量 { default, byAgent };PUT 双形——单对象形(旧面板)只更新
|
|
486
|
+
// default 且 byAgent 保留(合并语义,settings.setPersona 内判形状),多卡形全量
|
|
487
|
+
// 替换。坏体守卫:jsonOk=false 时保值回显,不清空库内现值。
|
|
488
|
+
register({
|
|
489
|
+
kind: "exact",
|
|
490
|
+
path: "/api/dsh-mneme/persona",
|
|
491
|
+
policy: "token-write",
|
|
492
|
+
handler(req, res) {
|
|
493
|
+
try {
|
|
494
|
+
if (req.method === "PUT" || req.method === "POST") {
|
|
495
|
+
return readBody(req, res).then((text) => {
|
|
496
|
+
if (text === null) return;
|
|
497
|
+
// parseBody 对坏 JSON 回 {}(容错不抛),所以这里的守卫必须把
|
|
498
|
+
// 「坏体解析成的 {}」与「真的传了 {persona:{}}」区分开:文本解析
|
|
499
|
+
// 失败的体不该把人格清空。jsonOk=false 时保持库内现值原样回显。
|
|
500
|
+
let body;
|
|
501
|
+
let jsonOk = true;
|
|
502
|
+
try {
|
|
503
|
+
body = JSON.parse(text || "{}");
|
|
504
|
+
} catch {
|
|
505
|
+
body = {};
|
|
506
|
+
jsonOk = false;
|
|
507
|
+
}
|
|
508
|
+
if (jsonOk && body && typeof body === "object") {
|
|
509
|
+
settings.setPersona(body.persona && typeof body.persona === "object" ? body.persona : body);
|
|
510
|
+
}
|
|
511
|
+
sendJson(res, 200, { persona: settings.getPersona() });
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
sendJson(res, 200, { persona: settings.getPersona() });
|
|
515
|
+
} catch {
|
|
516
|
+
sendJson(res, 500, { error: "internal" });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
375
521
|
// --- dream/sleep 模型连通性 + 模型发现(settings 面板)----------------------
|
|
376
522
|
// Connectivity probe for a dream/sleep model route: runs one minimal LLM call
|
|
377
523
|
// through the same harness path the consolidation uses and reports success /
|
|
@@ -471,6 +617,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
471
617
|
register({
|
|
472
618
|
kind: "exact",
|
|
473
619
|
path: "/api/dsh-mneme/llm-providers",
|
|
620
|
+
policy: "open",
|
|
474
621
|
handler(req, res) {
|
|
475
622
|
try {
|
|
476
623
|
const llm = ctx.llm;
|
|
@@ -499,11 +646,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
499
646
|
register({
|
|
500
647
|
kind: "exact",
|
|
501
648
|
path: "/api/dsh-mneme/test-model",
|
|
649
|
+
policy: "token",
|
|
502
650
|
handler(req, res) {
|
|
503
651
|
try {
|
|
504
652
|
if (req.method !== "POST") return sendJson(res, 404, { error: "not-found" });
|
|
505
|
-
|
|
506
|
-
|
|
653
|
+
return readBody(req, res).then(async (text) => {
|
|
654
|
+
if (text === null) return;
|
|
507
655
|
const body = parseBody(text);
|
|
508
656
|
let provider = typeof body.provider === "string" ? body.provider.trim() : "";
|
|
509
657
|
let model = typeof body.model === "string" ? body.model.trim() : "";
|
|
@@ -534,14 +682,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
534
682
|
register({
|
|
535
683
|
kind: "exact",
|
|
536
684
|
path: "/api/dsh-mneme/delete",
|
|
685
|
+
policy: "token",
|
|
537
686
|
handler(req, res) {
|
|
538
687
|
try {
|
|
539
688
|
if (req.method !== "POST") {
|
|
540
689
|
sendJson(res, 404, { error: "not-found" });
|
|
541
690
|
return;
|
|
542
691
|
}
|
|
543
|
-
|
|
544
|
-
|
|
692
|
+
return readBody(req, res).then((text) => {
|
|
693
|
+
if (text === null) return;
|
|
545
694
|
const body = parseBody(text);
|
|
546
695
|
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
547
696
|
if (!id) {
|
|
@@ -571,14 +720,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
571
720
|
register({
|
|
572
721
|
kind: "exact",
|
|
573
722
|
path: "/api/dsh-mneme/update",
|
|
723
|
+
policy: "token",
|
|
574
724
|
handler(req, res) {
|
|
575
725
|
try {
|
|
576
726
|
if (req.method !== "POST") {
|
|
577
727
|
sendJson(res, 404, { error: "not-found" });
|
|
578
728
|
return;
|
|
579
729
|
}
|
|
580
|
-
|
|
581
|
-
|
|
730
|
+
return readBody(req, res).then((text) => {
|
|
731
|
+
if (text === null) return;
|
|
582
732
|
const body = parseBody(text);
|
|
583
733
|
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
584
734
|
if (!id) {
|
|
@@ -677,14 +827,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
677
827
|
register({
|
|
678
828
|
kind: "exact",
|
|
679
829
|
path: "/api/dsh-mneme/create",
|
|
830
|
+
policy: "token",
|
|
680
831
|
handler(req, res) {
|
|
681
832
|
try {
|
|
682
833
|
if (req.method !== "POST") {
|
|
683
834
|
sendJson(res, 404, { error: "not-found" });
|
|
684
835
|
return;
|
|
685
836
|
}
|
|
686
|
-
|
|
687
|
-
|
|
837
|
+
return readBody(req, res).then((text) => {
|
|
838
|
+
if (text === null) return;
|
|
688
839
|
const body = parseBody(text);
|
|
689
840
|
if (!TYPES.has(body.type)) {
|
|
690
841
|
sendJson(res, 400, { error: "invalid-type" });
|
|
@@ -759,10 +910,10 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
759
910
|
register({
|
|
760
911
|
kind: "exact",
|
|
761
912
|
path: "/api/dsh-mneme/maintenance/reclaim",
|
|
913
|
+
policy: "token",
|
|
762
914
|
handler(req, res) {
|
|
763
915
|
try {
|
|
764
916
|
if (req.method === "GET") {
|
|
765
|
-
if (!requireAuth(req, res, apiToken)) return;
|
|
766
917
|
if (!maintenance) {
|
|
767
918
|
sendJson(res, 503, { error: RECLAIM_UNAVAILABLE });
|
|
768
919
|
return;
|
|
@@ -785,8 +936,8 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
785
936
|
sendJson(res, 404, { error: "not-found" });
|
|
786
937
|
return;
|
|
787
938
|
}
|
|
788
|
-
|
|
789
|
-
|
|
939
|
+
return readBody(req, res).then((text) => {
|
|
940
|
+
if (text === null) return;
|
|
790
941
|
const parsed = parseReclaimJson(text);
|
|
791
942
|
if (!parsed.ok) {
|
|
792
943
|
sendJson(res, 400, { error: parsed.error });
|
|
@@ -821,11 +972,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
821
972
|
register({
|
|
822
973
|
kind: "exact",
|
|
823
974
|
path: "/api/dsh-mneme/rules",
|
|
975
|
+
policy: "token-write",
|
|
824
976
|
handler(req, res) {
|
|
825
977
|
try {
|
|
826
978
|
if (req.method === "PUT" || req.method === "POST") {
|
|
827
|
-
|
|
828
|
-
|
|
979
|
+
return readBody(req, res).then((text) => {
|
|
980
|
+
if (text === null) return;
|
|
829
981
|
const body = parseBody(text);
|
|
830
982
|
settings.setRules(Array.isArray(body.rules) ? body.rules : []);
|
|
831
983
|
sendJson(res, 200, { rules: settings.getRules() });
|
|
@@ -842,12 +994,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
842
994
|
register({
|
|
843
995
|
kind: "exact",
|
|
844
996
|
path: "/api/dsh-mneme/vector-config",
|
|
997
|
+
policy: "token",
|
|
845
998
|
handler(req, res) {
|
|
846
999
|
try {
|
|
847
1000
|
// Secret-bearing endpoint: fully protected when apiToken is set.
|
|
848
|
-
if (!requireAuth(req, res, apiToken)) return;
|
|
849
1001
|
if (req.method === "PUT" || req.method === "POST") {
|
|
850
|
-
return readBody(req).then((text) => {
|
|
1002
|
+
return readBody(req, res).then((text) => {
|
|
1003
|
+
if (text === null) return;
|
|
851
1004
|
const body = parseBody(text);
|
|
852
1005
|
// An empty apiKey, or one that already looks masked (round-trips
|
|
853
1006
|
// through maskApiKey unchanged), means "keep the existing key".
|
|
@@ -879,9 +1032,9 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
879
1032
|
register({
|
|
880
1033
|
kind: "exact",
|
|
881
1034
|
path: "/api/dsh-mneme/vector-reindex",
|
|
1035
|
+
policy: "token",
|
|
882
1036
|
handler(req, res) {
|
|
883
1037
|
try {
|
|
884
|
-
if (!requireAuth(req, res, apiToken)) return;
|
|
885
1038
|
if (!embedder) {
|
|
886
1039
|
sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-unavailable" });
|
|
887
1040
|
return;
|
|
@@ -911,6 +1064,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
911
1064
|
register({
|
|
912
1065
|
kind: "exact",
|
|
913
1066
|
path: "/api/dsh-mneme/semantic",
|
|
1067
|
+
policy: "open",
|
|
914
1068
|
handler(req, res) {
|
|
915
1069
|
try {
|
|
916
1070
|
const stats = semantic?.vectorIndex?.getStats?.() ?? null;
|
|
@@ -926,6 +1080,25 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
926
1080
|
const configured = embedder ? (typeof embedder.configured === "boolean" ? embedder.configured : true) : null;
|
|
927
1081
|
const embedded = Number(stats?.embeddedCount ?? 0);
|
|
928
1082
|
const total = Number(stats?.totalCount ?? 0);
|
|
1083
|
+
// Per-model / per-dimension provenance of stored vectors. "embedded"
|
|
1084
|
+
// counts rows with any vector; rows whose dimension differs from the
|
|
1085
|
+
// current embedder output are skipped by searchVector, so the honest
|
|
1086
|
+
// retrievable number is the current-dimension slice. modelBreakdown
|
|
1087
|
+
// groups by row-level embedding_model ('' = unknown origin).
|
|
1088
|
+
const dimensionBreakdown = typeof service.embeddingDimensionBreakdown === "function"
|
|
1089
|
+
? service.embeddingDimensionBreakdown()
|
|
1090
|
+
: null;
|
|
1091
|
+
const currentDim = embedder?.dimension != null ? String(embedder.dimension) : null;
|
|
1092
|
+
const byDimension = dimensionBreakdown
|
|
1093
|
+
? [...dimensionBreakdown.entries()].map(([dim, c]) => ({ dimension: dim === "" ? null : Number(dim), count: c }))
|
|
1094
|
+
: [];
|
|
1095
|
+
const unsearchable = (currentDim && dimensionBreakdown)
|
|
1096
|
+
? [...dimensionBreakdown.entries()]
|
|
1097
|
+
.filter(([dim]) => dim !== currentDim)
|
|
1098
|
+
.reduce((s, [, c]) => s + c, 0)
|
|
1099
|
+
: 0; const modelBreakdown = typeof service.embeddingModelBreakdown === "function"
|
|
1100
|
+
? service.embeddingModelBreakdown().map((r) => ({ model: r.model === "" ? null : r.model, count: r.c }))
|
|
1101
|
+
: [];
|
|
929
1102
|
sendJson(res, 200, {
|
|
930
1103
|
ready,
|
|
931
1104
|
configured,
|
|
@@ -936,6 +1109,16 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
936
1109
|
: null,
|
|
937
1110
|
// 空库时 ratio 必须是 null 而非 0 —— 0 会被读成「一条都没嵌上」。
|
|
938
1111
|
coverage: { embedded, total, ratio: total > 0 ? embedded / total : null },
|
|
1112
|
+
// distill-tx-embed-gap 后续:向量「存在」≠「可检索」。unsearchable =
|
|
1113
|
+
// 维度与当前 embedder 输出不同的行数(searchVector 会跳过它们),
|
|
1114
|
+
// retrievable = 与当前维度同维、真正参与召回的行数。两者差值非零时
|
|
1115
|
+
// 面板必须能说出「有 N 条向量不可检索」,而不是只给一个虚高的 coverage。
|
|
1116
|
+
vectors: {
|
|
1117
|
+
byDimension,
|
|
1118
|
+
byModel: modelBreakdown,
|
|
1119
|
+
unsearchable,
|
|
1120
|
+
retrievable: currentDim ? (dimensionBreakdown.get(currentDim) ?? 0) : null,
|
|
1121
|
+
},
|
|
939
1122
|
// 配了、库里有东西可嵌、却一条都没嵌上 = 真的降级(本机此前长期
|
|
940
1123
|
// 处于 ready:true + embeddedCount:0,面板却显示正常)。
|
|
941
1124
|
degraded: configured === true && total > 0 && embedded === 0,
|
|
@@ -971,14 +1154,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
971
1154
|
register({
|
|
972
1155
|
kind: "exact",
|
|
973
1156
|
path: "/api/dsh-mneme/runtime/provision",
|
|
1157
|
+
policy: "token",
|
|
974
1158
|
handler(req, res) {
|
|
975
1159
|
try {
|
|
976
1160
|
if (req.method !== "POST") {
|
|
977
1161
|
sendJson(res, 405, { error: "method-not-allowed" });
|
|
978
1162
|
return;
|
|
979
1163
|
}
|
|
980
|
-
|
|
981
|
-
|
|
1164
|
+
return readBody(req, res).then(async (text) => {
|
|
1165
|
+
if (text === null) return;
|
|
982
1166
|
const body = parseBody(text);
|
|
983
1167
|
const runtimeDir = config?.runtimeDir ?? "";
|
|
984
1168
|
// 三档来源依次尝试(收编本机 node_modules → 本地 .tgz 目录 → registry)。
|
|
@@ -1008,6 +1192,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1008
1192
|
register({
|
|
1009
1193
|
kind: "exact",
|
|
1010
1194
|
path: "/api/dsh-mneme/semantic/llm-audit",
|
|
1195
|
+
policy: "open",
|
|
1011
1196
|
handler(req, res) {
|
|
1012
1197
|
try {
|
|
1013
1198
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -1030,6 +1215,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1030
1215
|
register({
|
|
1031
1216
|
kind: "exact",
|
|
1032
1217
|
path: "/api/dsh-mneme/semantic/llm-audit/stats",
|
|
1218
|
+
policy: "open",
|
|
1033
1219
|
handler(req, res) {
|
|
1034
1220
|
try {
|
|
1035
1221
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -1053,6 +1239,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1053
1239
|
register({
|
|
1054
1240
|
kind: "exact",
|
|
1055
1241
|
path: "/api/dsh-mneme/semantic/graph/ego",
|
|
1242
|
+
policy: "open",
|
|
1056
1243
|
handler(req, res) {
|
|
1057
1244
|
try {
|
|
1058
1245
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -1130,6 +1317,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1130
1317
|
register({
|
|
1131
1318
|
kind: "exact",
|
|
1132
1319
|
path: "/api/dsh-mneme/semantic/graph/entity-attrs",
|
|
1320
|
+
policy: "open",
|
|
1133
1321
|
handler(req, res) {
|
|
1134
1322
|
try {
|
|
1135
1323
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -1168,6 +1356,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1168
1356
|
register({
|
|
1169
1357
|
kind: "exact",
|
|
1170
1358
|
path: "/api/dsh-mneme/entities",
|
|
1359
|
+
policy: "open",
|
|
1171
1360
|
handler(req, res) {
|
|
1172
1361
|
try {
|
|
1173
1362
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -1187,6 +1376,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1187
1376
|
register({
|
|
1188
1377
|
kind: "exact",
|
|
1189
1378
|
path: "/api/dsh-mneme/memories/entities",
|
|
1379
|
+
policy: "open",
|
|
1190
1380
|
handler(req, res) {
|
|
1191
1381
|
try {
|
|
1192
1382
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -1217,8 +1407,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1217
1407
|
register({
|
|
1218
1408
|
kind: "exact",
|
|
1219
1409
|
path: "/api/dsh-mneme/export",
|
|
1410
|
+
policy: "token",
|
|
1220
1411
|
handler(req, res) {
|
|
1221
1412
|
try {
|
|
1413
|
+
// 导出是「全库 + 含 archived/forgotten 的全字段」的一次读,敏感度不低于
|
|
1414
|
+
// 写面:它必须与写路由同门。历史漏项——这条路由长期没有鉴权分支,配了
|
|
1415
|
+
// apiToken 也照旧对任何调用方开放(2026-10 审计 P0)。
|
|
1222
1416
|
const url = new URL(req.url, "http://localhost");
|
|
1223
1417
|
const format = url.searchParams.get("format") ?? "json";
|
|
1224
1418
|
if (format !== "json" && format !== "markdown") {
|
|
@@ -1267,14 +1461,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1267
1461
|
register({
|
|
1268
1462
|
kind: "exact",
|
|
1269
1463
|
path: "/api/dsh-mneme/import",
|
|
1464
|
+
policy: "token",
|
|
1270
1465
|
handler(req, res) {
|
|
1271
1466
|
try {
|
|
1272
1467
|
if (req.method !== "POST") {
|
|
1273
1468
|
sendJson(res, 404, { error: "not-found" });
|
|
1274
1469
|
return;
|
|
1275
1470
|
}
|
|
1276
|
-
|
|
1277
|
-
|
|
1471
|
+
return readBody(req, res).then((text) => {
|
|
1472
|
+
if (text === null) return;
|
|
1278
1473
|
const body = parseBody(text);
|
|
1279
1474
|
if (typeof body.type !== "string" || !Object.hasOwn(TYPE_FILE, body.type)) {
|
|
1280
1475
|
sendJson(res, 400, { error: "invalid-type" });
|
|
@@ -1308,6 +1503,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1308
1503
|
register({
|
|
1309
1504
|
kind: "exact",
|
|
1310
1505
|
path: "/api/dsh-mneme/dream-status",
|
|
1506
|
+
policy: "open",
|
|
1311
1507
|
handler(req, res) {
|
|
1312
1508
|
try {
|
|
1313
1509
|
const runs = (service.listDreamRuns?.({ limit: 5 }) ?? []).map((r) => ({
|
|
@@ -1352,13 +1548,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1352
1548
|
register({
|
|
1353
1549
|
kind: "exact",
|
|
1354
1550
|
path: "/api/dsh-mneme/conflicts",
|
|
1551
|
+
policy: "token",
|
|
1355
1552
|
handler(req, res) {
|
|
1356
1553
|
try {
|
|
1357
1554
|
if (req.method !== "GET") {
|
|
1358
1555
|
sendJson(res, 404, { error: "not-found" });
|
|
1359
1556
|
return;
|
|
1360
1557
|
}
|
|
1361
|
-
if (!requireAuth(req, res, apiToken)) return;
|
|
1362
1558
|
const url = new URL(req.url, "http://localhost");
|
|
1363
1559
|
const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit") ?? 50) || 50));
|
|
1364
1560
|
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0) || 0);
|
|
@@ -1373,14 +1569,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1373
1569
|
register({
|
|
1374
1570
|
kind: "exact",
|
|
1375
1571
|
path: "/api/dsh-mneme/conflicts/resolve",
|
|
1572
|
+
policy: "token",
|
|
1376
1573
|
handler(req, res) {
|
|
1377
1574
|
try {
|
|
1378
1575
|
if (req.method !== "POST") {
|
|
1379
1576
|
sendJson(res, 404, { error: "not-found" });
|
|
1380
1577
|
return;
|
|
1381
1578
|
}
|
|
1382
|
-
|
|
1383
|
-
|
|
1579
|
+
return readBody(req, res).then((text) => {
|
|
1580
|
+
if (text === null) return;
|
|
1384
1581
|
let body = {};
|
|
1385
1582
|
try { body = JSON.parse(text || "{}"); } catch { /* 坏 JSON → 走缺参分支 */ }
|
|
1386
1583
|
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
@@ -1410,8 +1607,8 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1410
1607
|
register({
|
|
1411
1608
|
kind: "exact",
|
|
1412
1609
|
path: "/api/dsh-mneme/health",
|
|
1610
|
+
policy: "token",
|
|
1413
1611
|
handler(req, res) {
|
|
1414
|
-
if (!requireAuth(req, res, apiToken)) return;
|
|
1415
1612
|
let state = null;
|
|
1416
1613
|
try {
|
|
1417
1614
|
state = service.getMirrorHealth?.() ?? null;
|
|
@@ -1459,11 +1656,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1459
1656
|
register({
|
|
1460
1657
|
kind: "exact",
|
|
1461
1658
|
path: "/api/dsh-mneme/mode",
|
|
1659
|
+
policy: "token-write",
|
|
1462
1660
|
handler(req, res) {
|
|
1463
1661
|
try {
|
|
1464
1662
|
if (req.method === "PUT" || req.method === "POST") {
|
|
1465
|
-
|
|
1466
|
-
|
|
1663
|
+
return readBody(req, res).then((text) => {
|
|
1664
|
+
if (text === null) return;
|
|
1467
1665
|
const body = parseBody(text);
|
|
1468
1666
|
if (body.mode !== "light" && body.mode !== "standard") {
|
|
1469
1667
|
sendJson(res, 400, { error: "invalid-mode" });
|
|
@@ -1484,11 +1682,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1484
1682
|
register({
|
|
1485
1683
|
kind: "exact",
|
|
1486
1684
|
path: "/api/dsh-mneme/external-api",
|
|
1685
|
+
policy: "token",
|
|
1487
1686
|
handler(req, res) {
|
|
1488
1687
|
try {
|
|
1489
1688
|
if (req.method === "PUT" || req.method === "POST") {
|
|
1490
|
-
|
|
1491
|
-
|
|
1689
|
+
return readBody(req, res).then((text) => {
|
|
1690
|
+
if (text === null) return;
|
|
1492
1691
|
const body = parseBody(text);
|
|
1493
1692
|
const patch = {};
|
|
1494
1693
|
if (body.enabled !== undefined) patch.enabled = body.enabled === true;
|
|
@@ -1511,9 +1710,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1511
1710
|
sendJson(res, 200, { config: fullExternalConfig(settings.setExternalApi(patch)) });
|
|
1512
1711
|
});
|
|
1513
1712
|
}
|
|
1514
|
-
// GET
|
|
1515
|
-
//
|
|
1516
|
-
//
|
|
1713
|
+
// GET 回的是能解锁整个 8790 数据面的 Bearer,敏感度等同写面:必须与
|
|
1714
|
+
// PUT 同门。未配置 apiToken 时 isAuthorized 恒真(既有的 fail-open 模型,
|
|
1715
|
+
// 见 SECURITY.md「安全模型」一节),所以默认配置下面板仍旧即装即用。
|
|
1716
|
+
// GET: hand back a token — the standalone server generates its own on
|
|
1717
|
+
// first enabled boot, but the panel displays it before that, so
|
|
1718
|
+
// materialize and persist one here. 只有通过上面那道门才会走到这里,
|
|
1719
|
+
// 否则「未鉴权的 GET 顺手写库」就是一条不需要凭证的写路径。
|
|
1517
1720
|
const kv = settings.getExternalApi() ?? {};
|
|
1518
1721
|
if (!kv.token) {
|
|
1519
1722
|
kv.token = randomBytes(24).toString("base64url");
|
|
@@ -1535,11 +1738,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1535
1738
|
register({
|
|
1536
1739
|
kind: "exact",
|
|
1537
1740
|
path: "/api/dsh-mneme/features",
|
|
1741
|
+
policy: "token-write",
|
|
1538
1742
|
handler(req, res) {
|
|
1539
1743
|
try {
|
|
1540
1744
|
if (req.method === "PUT" || req.method === "POST") {
|
|
1541
|
-
|
|
1542
|
-
|
|
1745
|
+
return readBody(req, res).then((text) => {
|
|
1746
|
+
if (text === null) return;
|
|
1543
1747
|
const body = parseBody(text);
|
|
1544
1748
|
if (typeof body !== "object" || body === null || Array.isArray(body) || Object.keys(body).length === 0) {
|
|
1545
1749
|
sendJson(res, 400, { error: "invalid-patch" });
|
|
@@ -1569,6 +1773,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1569
1773
|
register({
|
|
1570
1774
|
kind: "exact",
|
|
1571
1775
|
path: "/api/dsh-mneme/inject-status",
|
|
1776
|
+
policy: "open",
|
|
1572
1777
|
handler(req, res) {
|
|
1573
1778
|
try {
|
|
1574
1779
|
if (req.method !== "GET") {
|
|
@@ -1593,6 +1798,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1593
1798
|
register({
|
|
1594
1799
|
kind: "exact",
|
|
1595
1800
|
path: "/api/dsh-mneme/inject-preview",
|
|
1801
|
+
policy: "open",
|
|
1596
1802
|
handler(req, res) {
|
|
1597
1803
|
try {
|
|
1598
1804
|
if (req.method !== "GET") {
|
|
@@ -1610,11 +1816,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1610
1816
|
register({
|
|
1611
1817
|
kind: "exact",
|
|
1612
1818
|
path: "/api/dsh-mneme/commands",
|
|
1819
|
+
policy: "token-write",
|
|
1613
1820
|
handler(req, res) {
|
|
1614
1821
|
try {
|
|
1615
1822
|
if (req.method === "POST") {
|
|
1616
|
-
|
|
1617
|
-
|
|
1823
|
+
return readBody(req, res).then((text) => {
|
|
1824
|
+
if (text === null) return;
|
|
1618
1825
|
const body = parseBody(text);
|
|
1619
1826
|
try {
|
|
1620
1827
|
const command = commands.add({
|
|
@@ -1629,7 +1836,6 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1629
1836
|
});
|
|
1630
1837
|
}
|
|
1631
1838
|
if (req.method === "DELETE") {
|
|
1632
|
-
if (!requireAuth(req, res, apiToken)) return;
|
|
1633
1839
|
const url = new URL(req.url, "http://localhost");
|
|
1634
1840
|
const id = url.searchParams.get("id");
|
|
1635
1841
|
const removed = id ? commands.remove(id) : false;
|
|
@@ -1647,11 +1853,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1647
1853
|
// 只读端点,读的是**项目里**的 `<项目>/.mneme/pipelines/*.json`(文件头在
|
|
1648
1854
|
// src/pipeline.js),项目层优先、插件包里那份降级成全局层;投影成
|
|
1649
1855
|
// mneme.pipeline/v1 交给面板与 agent——两边读同一份,画面只是它的投影。
|
|
1856
|
+
// 运行账(`<id>.runs.jsonl`,文件头在 src/pipeline-runs.js)也在这里读:**没有账,
|
|
1857
|
+
// 读侧算不出任何状态**(`tallyRuns` 拿空数组时全体节点恒为 candidate),所以喂不喂
|
|
1858
|
+
// runs 决定的是「面板上有没有一句实话」,不是多一个可选装饰。
|
|
1650
1859
|
// 刻意不做旗标闸门:旗标管的是**客户端座位**(关=一个座位都不注册),不是数据面;
|
|
1651
1860
|
// 多一个只读端点不会让任何人看见它,而给它加闸门只会让「为什么面板打不开」多一种成因。
|
|
1652
1861
|
register({
|
|
1653
1862
|
kind: "exact",
|
|
1654
1863
|
path: "/api/dsh-mneme/pipeline",
|
|
1864
|
+
policy: "open",
|
|
1655
1865
|
handler(req, res) {
|
|
1656
1866
|
try {
|
|
1657
1867
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -1665,7 +1875,76 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1665
1875
|
sendJson(res, 400, { error: "project-not-absolute", detail: project });
|
|
1666
1876
|
return;
|
|
1667
1877
|
}
|
|
1668
|
-
|
|
1878
|
+
|
|
1879
|
+
// 只读**账本目录**来定「哪些 id 有账」:最新一条 run 可能属于一条刚被删掉或
|
|
1880
|
+
// 还没写完的 .json,这时工作流目录里问不到它的 id,而账本还在盘上。
|
|
1881
|
+
// runIds 必须在 handler 级声明:下面观察旁挂块(同函数的另一个 if 分支)也按
|
|
1882
|
+
// 这份 id 列表取观察——之前声明在账本分支的块级作用域里,旁挂块一引用就
|
|
1883
|
+
// ReferenceError,被 catch 静默吞成「没有观察」(CodeRabbit #458 F1,
|
|
1884
|
+
// 复盘注释见下方旁挂块)。
|
|
1885
|
+
let runIds = [];
|
|
1886
|
+
let runsResult = { runsById: {}, errors: [], statesById: {} };
|
|
1887
|
+
if (project !== null) {
|
|
1888
|
+
const where = runsDir(project);
|
|
1889
|
+
runIds = existsSync(where)
|
|
1890
|
+
? readdirSync(where)
|
|
1891
|
+
.filter((f) => f.endsWith(RUNS_SUFFIX))
|
|
1892
|
+
.sort()
|
|
1893
|
+
.map((f) => f.slice(0, -RUNS_SUFFIX.length))
|
|
1894
|
+
: [];
|
|
1895
|
+
if (runIds.length) {
|
|
1896
|
+
// step id 闸门:账本里出现「图上没有的 step id」时,credit 会落在一个没有节点的
|
|
1897
|
+
// 地方——**记了、数得出来、面板上永远看不见**,症状与「从没跑过」同形。
|
|
1898
|
+
// 这里把每条工作流的步骤表读出来当白名单,让那种行变成看得见的噪音。
|
|
1899
|
+
let stepIdsById = null;
|
|
1900
|
+
try {
|
|
1901
|
+
stepIdsById = Object.create(null);
|
|
1902
|
+
for (const p of loadPipelines(projectPipelineDir(project), { scope: "project" })) {
|
|
1903
|
+
stepIdsById[p.id] = p.steps.map((s) => s.id);
|
|
1904
|
+
}
|
|
1905
|
+
} catch {
|
|
1906
|
+
// 项目层有坏 .json 时 readPipelineDoc 会把它记进 `unreadable`;这里不重复报,
|
|
1907
|
+
// 也绝不因此让整个面板打不开——闸门退化成「没有白名单」,账本照读。
|
|
1908
|
+
stepIdsById = null;
|
|
1909
|
+
}
|
|
1910
|
+
runsResult = loadRunsById(project, runIds, { expectedStepIdsById: stepIdsById });
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
const { runsById, errors: runsErrors, statesById } = runsResult;
|
|
1914
|
+
|
|
1915
|
+
// P3 投影的燃料:观察旁挂(与 /candidates 的 memoryDir 同源同默认)。
|
|
1916
|
+
// 失败不连累图——旁挂是可再生的辅助数据,读不到就当没有,提议少一类而已。
|
|
1917
|
+
let observationsById = {};
|
|
1918
|
+
if (project !== null) {
|
|
1919
|
+
try {
|
|
1920
|
+
const memoryDir = (config && typeof config.memoryDir === "string" && config.memoryDir.trim() !== "")
|
|
1921
|
+
? config.memoryDir
|
|
1922
|
+
: "~/.dsh/memory";
|
|
1923
|
+
const odir = observationDir({ memoryDir, projectKey: projectKey(project) });
|
|
1924
|
+
if (odir) {
|
|
1925
|
+
observationsById = loadObservationsById(odir, runIds.filter(Boolean));
|
|
1926
|
+
}
|
|
1927
|
+
} catch {
|
|
1928
|
+
// 读旁挂失败不算账本问题:如实降级成「没有观察」,fix-step-tool 少发而已
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
const doc = readPipelineDoc({ projectRoot: project, id, runsById, observationsById });
|
|
1933
|
+
|
|
1934
|
+
// 「读不出来」与「还没跑过」在节点状态上完全同形(都是全体 candidate),而且
|
|
1935
|
+
// **不带 id 的默认视图没有兜底**——那正是用户打开时看到的那一屏。所以状态按
|
|
1936
|
+
// pipeline 逐条显式带出去(清单行的 `runsState` 与选中那条的顶层 `runsState`),
|
|
1937
|
+
// 客户端不必靠顶层噪音猜。也刻意**不再**对坏账本回 500:那会让客户端把它当成
|
|
1938
|
+
// 「离线」,还顺手吞掉本来读得出来的那张图(账本坏了,工作流本身没坏)。
|
|
1939
|
+
// 没有项目根时账本层无从读起,如实给 null,而不是谎称「没有账本文件」。
|
|
1940
|
+
const chosenId = doc.pipeline ? doc.pipeline.id : null;
|
|
1941
|
+
const runsState = project !== null && chosenId !== null ? statesById[chosenId] || "absent" : null;
|
|
1942
|
+
const available =
|
|
1943
|
+
project === null
|
|
1944
|
+
? doc.available
|
|
1945
|
+
: doc.available.map((row) => ({ ...row, runsState: statesById[row.id] || "absent" }));
|
|
1946
|
+
|
|
1947
|
+
sendJson(res, 200, { ...doc, available, runsState, runsErrors });
|
|
1669
1948
|
} catch (error) {
|
|
1670
1949
|
// 工作流是人手写的 JSON,报错本身就是给人看的:说清哪一条、坏在哪,
|
|
1671
1950
|
// 而不是回一句 internal 让人去翻日志。
|
|
@@ -1690,6 +1969,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1690
1969
|
register({
|
|
1691
1970
|
kind: "exact",
|
|
1692
1971
|
path: "/api/dsh-mneme/candidates",
|
|
1972
|
+
policy: "open",
|
|
1693
1973
|
handler(req, res) {
|
|
1694
1974
|
try {
|
|
1695
1975
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -1713,7 +1993,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1713
1993
|
});
|
|
1714
1994
|
|
|
1715
1995
|
return {
|
|
1716
|
-
routes:
|
|
1996
|
+
routes: countRoutes(),
|
|
1717
1997
|
dispose: () => {
|
|
1718
1998
|
for (const dispose of disposers) dispose();
|
|
1719
1999
|
}
|