@modusensus/dsh-mneme 0.8.16 → 0.9.1
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 +91 -16
- package/assets/icon.svg +2 -2
- package/bin/cli.mjs +8 -2
- package/docs/CONFIGURATION.md +27 -1
- package/docs/CONTINUITY.md +18 -3
- package/docs/SEMANTIC.md +3 -3
- package/docs/WORKFLOWS.md +299 -0
- package/lib/api-standalone.js +78 -12
- package/lib/api.js +403 -62
- package/lib/candidates.js +454 -0
- package/lib/capabilities.js +819 -0
- package/lib/client.js +2294 -423
- package/lib/config.js +63 -6
- package/lib/continuity.js +35 -6
- package/lib/dream/decisions.js +17 -1
- package/lib/dream/sleep.js +10 -2
- package/lib/dream.js +5 -1
- package/lib/embedding.js +116 -15
- package/lib/entities/extractor.js +4 -0
- package/lib/index.js +81 -12
- package/lib/inject.js +40 -4
- 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 +655 -0
- package/lib/scope.js +16 -4
- package/lib/semantic.js +66 -10
- package/lib/serve.js +1 -1
- package/lib/service.js +59 -18
- package/lib/session-usage.js +350 -0
- package/lib/settings.js +138 -1
- package/lib/store.js +132 -6
- package/lib/summarize.js +30 -0
- package/lib/tools.js +11 -2
- package/package.json +3 -3
- package/scripts/changelog-prep.mjs +62 -7
- package/scripts/check-sync.js +8 -6
- package/scripts/publish-gate.mjs +197 -0
- 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 +403 -62
- package/src/candidates.js +454 -0
- package/src/capabilities.js +819 -0
- package/src/config.js +63 -6
- package/src/continuity.js +35 -6
- package/src/dream/decisions.js +17 -1
- package/src/dream/sleep.js +10 -2
- package/src/dream.js +5 -1
- package/src/embedding.js +116 -15
- package/src/entities/extractor.js +4 -0
- package/src/index.js +81 -12
- package/src/inject.js +40 -4
- 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 +655 -0
- package/src/scope.js +16 -4
- package/src/semantic.js +66 -10
- package/src/serve.js +1 -1
- package/src/service.js +59 -18
- package/src/session-usage.js +350 -0
- package/src/settings.js +138 -1
- package/src/store.js +132 -6
- package/src/summarize.js +30 -0
- package/src/tools.js +11 -2
- package/test/api-hardening.test.js +173 -0
- package/test/api.test.js +244 -7
- package/test/candidates.test.js +345 -0
- package/test/capabilities.test.js +577 -0
- package/test/changelog-prep.test.js +74 -8
- package/test/cli-enums.test.js +37 -0
- package/test/client.test.js +510 -2
- package/test/continuity.test.js +84 -0
- package/test/docs-config-keys.test.js +65 -0
- package/test/embed-ready-poll.test.js +94 -0
- package/test/entities.test.js +13 -2
- package/test/failure-query.test.js +147 -0
- package/test/failure-retention.test.js +105 -0
- package/test/graph-api.test.js +36 -2
- package/test/helpers/write-admission-samples.js +6 -2
- package/test/inject-recall.test.js +32 -4
- package/test/lib-smoke.test.js +20 -0
- package/test/llm-audit.test.js +92 -1
- package/test/mint-source.test.js +202 -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 +636 -0
- package/test/placement.test.js +1 -1
- package/test/publish-gate.test.js +272 -0
- package/test/recall-retention.test.js +128 -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 +93 -0
- package/test/route-policy.test.js +113 -0
- package/test/scope-distill-stamp.test.js +135 -0
- package/test/security-doc.test.js +139 -0
- package/test/sensitive-scan.test.js +44 -0
- package/test/serve.test.js +1 -1
- package/test/session-usage.test.js +308 -0
- package/test/store-write-path-columns.test.js +191 -0
- package/test/summarize.test.js +176 -0
- package/test/supersede-state-tracking.test.js +4 -4
- package/test/tools.test.js +16 -1
- package/test/workflow-guards.test.js +156 -0
package/lib/api.js
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import { URL } from "node:url";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
3
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
4
|
import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
3
5
|
import { FEATURE_FLAG_SPEC } from "./settings.js";
|
|
4
6
|
import { TYPES } from "./store.js";
|
|
5
7
|
import { TYPE_FILE, renderMirrorText, renderFileHeader, parseHumanEdits, MIRROR_READONLY_TYPES } from "./mirror.js";
|
|
6
8
|
import { langOf } from "./lang.js";
|
|
7
9
|
import { computeHeat } from "./heat.js";
|
|
8
|
-
import { describeStreamFailure, resolveRoute } from "./dream.js";
|
|
10
|
+
import { EFFORT_REJECT_RE, describeStreamFailure, resolveRoute, withEffortFallback } from "./dream.js";
|
|
9
11
|
import { getInjectionSnapshot } from "./inject.js";
|
|
10
12
|
import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
|
|
11
13
|
import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
|
|
12
14
|
import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
|
|
13
15
|
import { parseReclaimQuery, parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE } from "./reclaim-http.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";
|
|
20
|
+
import { readCandidateDoc } from "./candidates.js";
|
|
14
21
|
|
|
15
22
|
// headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
|
|
16
23
|
function sendJson(res, status, payload, headers = {}) {
|
|
@@ -34,6 +41,16 @@ function sendAttachment(res, status, contentType, filename, body) {
|
|
|
34
41
|
// defaults in config.js (externalApiPort / externalApiHost).
|
|
35
42
|
const EXTERNAL_API_DEFAULTS = { enabled: false, port: 8790, host: "127.0.0.1" };
|
|
36
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
|
+
|
|
37
54
|
/** Merge persisted external-api kv over the defaults for client display. */
|
|
38
55
|
function fullExternalConfig(kv = {}) {
|
|
39
56
|
return {
|
|
@@ -67,22 +84,82 @@ function isAuthorized(req, apiToken) {
|
|
|
67
84
|
}
|
|
68
85
|
|
|
69
86
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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)。
|
|
95
|
+
*/
|
|
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 已经和现实脱节)。
|
|
72
109
|
*/
|
|
73
|
-
function
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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 };
|
|
77
131
|
}
|
|
78
132
|
|
|
79
|
-
|
|
80
|
-
|
|
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) {
|
|
81
144
|
return new Promise((resolve) => {
|
|
82
145
|
let body = "";
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
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 : ""));
|
|
86
163
|
});
|
|
87
164
|
}
|
|
88
165
|
|
|
@@ -184,14 +261,14 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
184
261
|
service.setEmbedder(embedder);
|
|
185
262
|
}
|
|
186
263
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
};
|
|
264
|
+
// 所有路由都经这一处登记:policy 必填、鉴权在这里统一判定(见 createRouteRegistrar)。
|
|
265
|
+
const { register, count: countRoutes } = createRouteRegistrar(webServer, apiToken, disposers);
|
|
190
266
|
|
|
191
267
|
// /api/dsh-mneme prefix fallback → 404 JSON for unknown sub-paths
|
|
192
268
|
register({
|
|
193
269
|
kind: "prefix",
|
|
194
270
|
path: "/api/dsh-mneme",
|
|
271
|
+
policy: "open",
|
|
195
272
|
handler(req, res) {
|
|
196
273
|
sendJson(res, 404, { error: "not-found" });
|
|
197
274
|
}
|
|
@@ -202,6 +279,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
202
279
|
register({
|
|
203
280
|
kind: "exact",
|
|
204
281
|
path: "/api/dsh-mneme/info",
|
|
282
|
+
policy: "open",
|
|
205
283
|
handler(req, res) {
|
|
206
284
|
sendJson(res, 200, { version: PACKAGE_VERSION });
|
|
207
285
|
}
|
|
@@ -214,6 +292,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
214
292
|
register({
|
|
215
293
|
kind: "exact",
|
|
216
294
|
path: "/api/dsh-mneme/version-check",
|
|
295
|
+
policy: "open",
|
|
217
296
|
handler(req, res) {
|
|
218
297
|
if (req.method !== "GET") {
|
|
219
298
|
sendJson(res, 404, { error: "not-found" });
|
|
@@ -233,11 +312,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
233
312
|
register({
|
|
234
313
|
kind: "exact",
|
|
235
314
|
path: "/api/dsh-mneme/list",
|
|
315
|
+
policy: "open",
|
|
236
316
|
handler(req, res) {
|
|
237
317
|
try {
|
|
238
318
|
const url = new URL(req.url, "http://localhost");
|
|
239
319
|
const type = url.searchParams.get("type") ?? undefined;
|
|
240
|
-
|
|
320
|
+
// 上界是防御:参数直接进 SQL LIMIT(见 MAX_PAGE_LIMIT 注释)。
|
|
321
|
+
const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), MAX_PAGE_LIMIT);
|
|
241
322
|
const offset = Number(url.searchParams.get("offset") ?? 0);
|
|
242
323
|
// order=chrono: pure newest-first for paged browsing; default keeps
|
|
243
324
|
// the importance-ranked order other callers rely on.
|
|
@@ -289,6 +370,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
289
370
|
register({
|
|
290
371
|
kind: "exact",
|
|
291
372
|
path: "/api/dsh-mneme/recall-stats",
|
|
373
|
+
policy: "open",
|
|
292
374
|
handler(req, res) {
|
|
293
375
|
if (req.method !== "GET") {
|
|
294
376
|
sendJson(res, 404, { error: "not-found" });
|
|
@@ -308,11 +390,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
308
390
|
register({
|
|
309
391
|
kind: "exact",
|
|
310
392
|
path: "/api/dsh-mneme/search",
|
|
393
|
+
policy: "open",
|
|
311
394
|
handler(req, res) {
|
|
312
395
|
try {
|
|
313
396
|
const url = new URL(req.url, "http://localhost");
|
|
314
397
|
const q = url.searchParams.get("q") ?? "";
|
|
315
|
-
|
|
398
|
+
// 上界是防御:topK 直接进召回(见 MAX_SEARCH_TOP_K 注释)。
|
|
399
|
+
const limit = Math.min(Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 20), MAX_SEARCH_TOP_K);
|
|
316
400
|
// mode selects the recall strategy (defaults to auto):
|
|
317
401
|
// auto (default) keyword first, vector fills remaining slots
|
|
318
402
|
// hybrid vector first, keyword fills remaining slots; scores of
|
|
@@ -352,11 +436,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
352
436
|
register({
|
|
353
437
|
kind: "exact",
|
|
354
438
|
path: "/api/dsh-mneme/profile",
|
|
439
|
+
policy: "token-write",
|
|
355
440
|
handler(req, res) {
|
|
356
441
|
try {
|
|
357
442
|
if (req.method === "PUT" || req.method === "POST") {
|
|
358
|
-
|
|
359
|
-
|
|
443
|
+
return readBody(req, res).then((text) => {
|
|
444
|
+
if (text === null) return;
|
|
360
445
|
const body = parseBody(text);
|
|
361
446
|
settings.setProfile(typeof body.profile === "string" ? body.profile : "");
|
|
362
447
|
sendJson(res, 200, { profile: settings.getProfile() });
|
|
@@ -369,6 +454,44 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
369
454
|
}
|
|
370
455
|
});
|
|
371
456
|
|
|
457
|
+
// --- agent persona(人格定义页,beta) -------------------------------------
|
|
458
|
+
// 形状照抄 /profile 路由(设计稿②):写方法走 requireAuth + readBody + parseBody。
|
|
459
|
+
// v1.2 多卡:GET 回全量 { default, byAgent };PUT 双形——单对象形(旧面板)只更新
|
|
460
|
+
// default 且 byAgent 保留(合并语义,settings.setPersona 内判形状),多卡形全量
|
|
461
|
+
// 替换。坏体守卫:jsonOk=false 时保值回显,不清空库内现值。
|
|
462
|
+
register({
|
|
463
|
+
kind: "exact",
|
|
464
|
+
path: "/api/dsh-mneme/persona",
|
|
465
|
+
policy: "token-write",
|
|
466
|
+
handler(req, res) {
|
|
467
|
+
try {
|
|
468
|
+
if (req.method === "PUT" || req.method === "POST") {
|
|
469
|
+
return readBody(req, res).then((text) => {
|
|
470
|
+
if (text === null) return;
|
|
471
|
+
// parseBody 对坏 JSON 回 {}(容错不抛),所以这里的守卫必须把
|
|
472
|
+
// 「坏体解析成的 {}」与「真的传了 {persona:{}}」区分开:文本解析
|
|
473
|
+
// 失败的体不该把人格清空。jsonOk=false 时保持库内现值原样回显。
|
|
474
|
+
let body;
|
|
475
|
+
let jsonOk = true;
|
|
476
|
+
try {
|
|
477
|
+
body = JSON.parse(text || "{}");
|
|
478
|
+
} catch {
|
|
479
|
+
body = {};
|
|
480
|
+
jsonOk = false;
|
|
481
|
+
}
|
|
482
|
+
if (jsonOk && body && typeof body === "object") {
|
|
483
|
+
settings.setPersona(body.persona && typeof body.persona === "object" ? body.persona : body);
|
|
484
|
+
}
|
|
485
|
+
sendJson(res, 200, { persona: settings.getPersona() });
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
sendJson(res, 200, { persona: settings.getPersona() });
|
|
489
|
+
} catch {
|
|
490
|
+
sendJson(res, 500, { error: "internal" });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
|
|
372
495
|
// --- dream/sleep 模型连通性 + 模型发现(settings 面板)----------------------
|
|
373
496
|
// Connectivity probe for a dream/sleep model route: runs one minimal LLM call
|
|
374
497
|
// through the same harness path the consolidation uses and reports success /
|
|
@@ -376,13 +499,30 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
376
499
|
// the same error wording the audit rows carry. Optional reasoningEffort
|
|
377
500
|
// mirrors what the consolidation would send — the panel can verify a model
|
|
378
501
|
// actually accepts the configured effort without tripping a full run.
|
|
502
|
+
//
|
|
503
|
+
// 档位被拒时探针必须和真实 run 一样降级:这里是全仓唯一「接受并转发调用方档位、
|
|
504
|
+
// 却此前没有兜底」的调用面——巩固/睡眠/蒸馏/总览/实体抽取都包在 withEffortFallback
|
|
505
|
+
// 里,档位被拒就丢掉字段重试一次(issue #9/#109/#135)。直调 llm.stream 的其余各面
|
|
506
|
+
// 要么本来就在兜底之内(dream.js/summarize.js/sleep.js 的共用辅助),要么是
|
|
507
|
+
// src/index.js 的手写版兜底,要么像 pre-inject-gate 的判定池那样根本不转发档位。
|
|
508
|
+
// 探针原先没有这层,于是同一份配置「后台真的跑得通、点一下测试就报红」,把用户
|
|
509
|
+
// 引向「换哪家 Provider 都失败」的错误诊断(issue #215 的后续)。降级跑通后响应带
|
|
510
|
+
// effortDropped:true,面板据此说明「档位不被接受、已按无档位连通」,而不是与「真的
|
|
511
|
+
// 连不上」同色。
|
|
379
512
|
async function testLlmConnectivity(llm, provider, model, reasoningEffort) {
|
|
380
513
|
const startedAt = Date.now();
|
|
381
514
|
const elapsed = () => Date.now() - startedAt;
|
|
382
515
|
const modelId = `${provider}:${model}`;
|
|
383
|
-
|
|
516
|
+
// 流级失败原因:withEffortFallback 只用它做拒收甄别,回给面板的仍是同一条
|
|
517
|
+
// describeStreamFailure 文案(与审计行同口径,不制造第二套措辞)。
|
|
518
|
+
let streamError = "";
|
|
519
|
+
// 降级标记刻意放在 attempt 之外:fallback 里置位、attempt 里读出,结果对象
|
|
520
|
+
// 据此带上 effortDropped——降级是「换个姿势跑通了」,不是失败。
|
|
521
|
+
let droppedEffort = false;
|
|
522
|
+
|
|
523
|
+
const attempt = async (effort) => {
|
|
524
|
+
streamError = "";
|
|
384
525
|
let reply = "";
|
|
385
|
-
let error = "";
|
|
386
526
|
for await (const chunk of llm.stream({
|
|
387
527
|
provider,
|
|
388
528
|
model,
|
|
@@ -391,7 +531,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
391
531
|
// reply 恒为空,「真的答了 ok」对思考模型不成立。按实际用量计费,手动
|
|
392
532
|
// 按钮无放大风险。
|
|
393
533
|
maxTokens: 1024,
|
|
394
|
-
...(
|
|
534
|
+
...(effort && effort !== "none" ? { reasoningEffort: effort } : {}),
|
|
395
535
|
// role 用 user 而非 system(#189 后续):0.1.6-alpha.1 起适配器把 system
|
|
396
536
|
// 抽成顶层参数,system-only 对话会被官方 API 以
|
|
397
537
|
// 「messages: at least one message is required」整单拒绝——探测按钮 502。
|
|
@@ -406,12 +546,39 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
406
546
|
})) {
|
|
407
547
|
if (chunk.type === "text-delta" && typeof chunk.text === "string") reply += chunk.text;
|
|
408
548
|
if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
|
|
409
|
-
|
|
549
|
+
streamError = describeStreamFailure(chunk.reason);
|
|
410
550
|
break;
|
|
411
551
|
}
|
|
412
552
|
}
|
|
413
|
-
|
|
414
|
-
|
|
553
|
+
const dropped = droppedEffort ? { effortDropped: true } : {};
|
|
554
|
+
if (streamError) {
|
|
555
|
+
// 只在「确实带了档位」时把流级拒收交回给 withEffortFallback 的 undefined
|
|
556
|
+
// 分支——rc.1 的适配器不抛错,把 provider 的拒收折叠成终止 finish chunk,
|
|
557
|
+
// 不看这里那次重试就是死代码。不带档位时原样上报:探针不该因为一句文案
|
|
558
|
+
// 没匹配上正则就退化成 500。
|
|
559
|
+
if (effort && effort !== "none" && EFFORT_REJECT_RE.test(streamError)) return undefined;
|
|
560
|
+
// 失败行不带 effortDropped:该字段只有一个含义——「连通了,但不是用你配的
|
|
561
|
+
// 档位连通的」(见下方成功分支的注释),失败时它只是噪音。
|
|
562
|
+
return { ok: false, durationMs: elapsed(), error: streamError, modelId };
|
|
563
|
+
}
|
|
564
|
+
return { ok: true, durationMs: elapsed(), modelId, reply: reply.trim().slice(0, 100), ...dropped };
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
try {
|
|
568
|
+
const result = await withEffortFallback(
|
|
569
|
+
ctx,
|
|
570
|
+
reasoningEffort,
|
|
571
|
+
() => attempt(reasoningEffort),
|
|
572
|
+
() => { droppedEffort = true; return attempt(""); },
|
|
573
|
+
() => streamError
|
|
574
|
+
);
|
|
575
|
+
// result === undefined 只可能来自上面那次「判据一致」的主动交还(两边用同
|
|
576
|
+
// 一份 EFFORT_REJECT_RE,走到这里等于假设被推翻):退回已捕获的流失败原
|
|
577
|
+
// 因,绝不让探针 500。
|
|
578
|
+
if (result === undefined) {
|
|
579
|
+
return { ok: false, durationMs: elapsed(), error: streamError || "no stream result", modelId };
|
|
580
|
+
}
|
|
581
|
+
return result;
|
|
415
582
|
} catch (error) {
|
|
416
583
|
return { ok: false, durationMs: elapsed(), error: String(error?.message ?? error), modelId };
|
|
417
584
|
}
|
|
@@ -424,6 +591,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
424
591
|
register({
|
|
425
592
|
kind: "exact",
|
|
426
593
|
path: "/api/dsh-mneme/llm-providers",
|
|
594
|
+
policy: "open",
|
|
427
595
|
handler(req, res) {
|
|
428
596
|
try {
|
|
429
597
|
const llm = ctx.llm;
|
|
@@ -452,11 +620,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
452
620
|
register({
|
|
453
621
|
kind: "exact",
|
|
454
622
|
path: "/api/dsh-mneme/test-model",
|
|
623
|
+
policy: "token",
|
|
455
624
|
handler(req, res) {
|
|
456
625
|
try {
|
|
457
626
|
if (req.method !== "POST") return sendJson(res, 404, { error: "not-found" });
|
|
458
|
-
|
|
459
|
-
|
|
627
|
+
return readBody(req, res).then(async (text) => {
|
|
628
|
+
if (text === null) return;
|
|
460
629
|
const body = parseBody(text);
|
|
461
630
|
let provider = typeof body.provider === "string" ? body.provider.trim() : "";
|
|
462
631
|
let model = typeof body.model === "string" ? body.model.trim() : "";
|
|
@@ -487,14 +656,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
487
656
|
register({
|
|
488
657
|
kind: "exact",
|
|
489
658
|
path: "/api/dsh-mneme/delete",
|
|
659
|
+
policy: "token",
|
|
490
660
|
handler(req, res) {
|
|
491
661
|
try {
|
|
492
662
|
if (req.method !== "POST") {
|
|
493
663
|
sendJson(res, 404, { error: "not-found" });
|
|
494
664
|
return;
|
|
495
665
|
}
|
|
496
|
-
|
|
497
|
-
|
|
666
|
+
return readBody(req, res).then((text) => {
|
|
667
|
+
if (text === null) return;
|
|
498
668
|
const body = parseBody(text);
|
|
499
669
|
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
500
670
|
if (!id) {
|
|
@@ -524,14 +694,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
524
694
|
register({
|
|
525
695
|
kind: "exact",
|
|
526
696
|
path: "/api/dsh-mneme/update",
|
|
697
|
+
policy: "token",
|
|
527
698
|
handler(req, res) {
|
|
528
699
|
try {
|
|
529
700
|
if (req.method !== "POST") {
|
|
530
701
|
sendJson(res, 404, { error: "not-found" });
|
|
531
702
|
return;
|
|
532
703
|
}
|
|
533
|
-
|
|
534
|
-
|
|
704
|
+
return readBody(req, res).then((text) => {
|
|
705
|
+
if (text === null) return;
|
|
535
706
|
const body = parseBody(text);
|
|
536
707
|
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
537
708
|
if (!id) {
|
|
@@ -630,14 +801,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
630
801
|
register({
|
|
631
802
|
kind: "exact",
|
|
632
803
|
path: "/api/dsh-mneme/create",
|
|
804
|
+
policy: "token",
|
|
633
805
|
handler(req, res) {
|
|
634
806
|
try {
|
|
635
807
|
if (req.method !== "POST") {
|
|
636
808
|
sendJson(res, 404, { error: "not-found" });
|
|
637
809
|
return;
|
|
638
810
|
}
|
|
639
|
-
|
|
640
|
-
|
|
811
|
+
return readBody(req, res).then((text) => {
|
|
812
|
+
if (text === null) return;
|
|
641
813
|
const body = parseBody(text);
|
|
642
814
|
if (!TYPES.has(body.type)) {
|
|
643
815
|
sendJson(res, 400, { error: "invalid-type" });
|
|
@@ -712,10 +884,10 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
712
884
|
register({
|
|
713
885
|
kind: "exact",
|
|
714
886
|
path: "/api/dsh-mneme/maintenance/reclaim",
|
|
887
|
+
policy: "token",
|
|
715
888
|
handler(req, res) {
|
|
716
889
|
try {
|
|
717
890
|
if (req.method === "GET") {
|
|
718
|
-
if (!requireAuth(req, res, apiToken)) return;
|
|
719
891
|
if (!maintenance) {
|
|
720
892
|
sendJson(res, 503, { error: RECLAIM_UNAVAILABLE });
|
|
721
893
|
return;
|
|
@@ -738,8 +910,8 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
738
910
|
sendJson(res, 404, { error: "not-found" });
|
|
739
911
|
return;
|
|
740
912
|
}
|
|
741
|
-
|
|
742
|
-
|
|
913
|
+
return readBody(req, res).then((text) => {
|
|
914
|
+
if (text === null) return;
|
|
743
915
|
const parsed = parseReclaimJson(text);
|
|
744
916
|
if (!parsed.ok) {
|
|
745
917
|
sendJson(res, 400, { error: parsed.error });
|
|
@@ -774,11 +946,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
774
946
|
register({
|
|
775
947
|
kind: "exact",
|
|
776
948
|
path: "/api/dsh-mneme/rules",
|
|
949
|
+
policy: "token-write",
|
|
777
950
|
handler(req, res) {
|
|
778
951
|
try {
|
|
779
952
|
if (req.method === "PUT" || req.method === "POST") {
|
|
780
|
-
|
|
781
|
-
|
|
953
|
+
return readBody(req, res).then((text) => {
|
|
954
|
+
if (text === null) return;
|
|
782
955
|
const body = parseBody(text);
|
|
783
956
|
settings.setRules(Array.isArray(body.rules) ? body.rules : []);
|
|
784
957
|
sendJson(res, 200, { rules: settings.getRules() });
|
|
@@ -795,12 +968,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
795
968
|
register({
|
|
796
969
|
kind: "exact",
|
|
797
970
|
path: "/api/dsh-mneme/vector-config",
|
|
971
|
+
policy: "token",
|
|
798
972
|
handler(req, res) {
|
|
799
973
|
try {
|
|
800
974
|
// Secret-bearing endpoint: fully protected when apiToken is set.
|
|
801
|
-
if (!requireAuth(req, res, apiToken)) return;
|
|
802
975
|
if (req.method === "PUT" || req.method === "POST") {
|
|
803
|
-
return readBody(req).then((text) => {
|
|
976
|
+
return readBody(req, res).then((text) => {
|
|
977
|
+
if (text === null) return;
|
|
804
978
|
const body = parseBody(text);
|
|
805
979
|
// An empty apiKey, or one that already looks masked (round-trips
|
|
806
980
|
// through maskApiKey unchanged), means "keep the existing key".
|
|
@@ -832,9 +1006,9 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
832
1006
|
register({
|
|
833
1007
|
kind: "exact",
|
|
834
1008
|
path: "/api/dsh-mneme/vector-reindex",
|
|
1009
|
+
policy: "token",
|
|
835
1010
|
handler(req, res) {
|
|
836
1011
|
try {
|
|
837
|
-
if (!requireAuth(req, res, apiToken)) return;
|
|
838
1012
|
if (!embedder) {
|
|
839
1013
|
sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-unavailable" });
|
|
840
1014
|
return;
|
|
@@ -864,6 +1038,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
864
1038
|
register({
|
|
865
1039
|
kind: "exact",
|
|
866
1040
|
path: "/api/dsh-mneme/semantic",
|
|
1041
|
+
policy: "open",
|
|
867
1042
|
handler(req, res) {
|
|
868
1043
|
try {
|
|
869
1044
|
const stats = semantic?.vectorIndex?.getStats?.() ?? null;
|
|
@@ -924,14 +1099,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
924
1099
|
register({
|
|
925
1100
|
kind: "exact",
|
|
926
1101
|
path: "/api/dsh-mneme/runtime/provision",
|
|
1102
|
+
policy: "token",
|
|
927
1103
|
handler(req, res) {
|
|
928
1104
|
try {
|
|
929
1105
|
if (req.method !== "POST") {
|
|
930
1106
|
sendJson(res, 405, { error: "method-not-allowed" });
|
|
931
1107
|
return;
|
|
932
1108
|
}
|
|
933
|
-
|
|
934
|
-
|
|
1109
|
+
return readBody(req, res).then(async (text) => {
|
|
1110
|
+
if (text === null) return;
|
|
935
1111
|
const body = parseBody(text);
|
|
936
1112
|
const runtimeDir = config?.runtimeDir ?? "";
|
|
937
1113
|
// 三档来源依次尝试(收编本机 node_modules → 本地 .tgz 目录 → registry)。
|
|
@@ -961,6 +1137,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
961
1137
|
register({
|
|
962
1138
|
kind: "exact",
|
|
963
1139
|
path: "/api/dsh-mneme/semantic/llm-audit",
|
|
1140
|
+
policy: "open",
|
|
964
1141
|
handler(req, res) {
|
|
965
1142
|
try {
|
|
966
1143
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -983,6 +1160,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
983
1160
|
register({
|
|
984
1161
|
kind: "exact",
|
|
985
1162
|
path: "/api/dsh-mneme/semantic/llm-audit/stats",
|
|
1163
|
+
policy: "open",
|
|
986
1164
|
handler(req, res) {
|
|
987
1165
|
try {
|
|
988
1166
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -1006,6 +1184,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1006
1184
|
register({
|
|
1007
1185
|
kind: "exact",
|
|
1008
1186
|
path: "/api/dsh-mneme/semantic/graph/ego",
|
|
1187
|
+
policy: "open",
|
|
1009
1188
|
handler(req, res) {
|
|
1010
1189
|
try {
|
|
1011
1190
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -1083,6 +1262,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1083
1262
|
register({
|
|
1084
1263
|
kind: "exact",
|
|
1085
1264
|
path: "/api/dsh-mneme/semantic/graph/entity-attrs",
|
|
1265
|
+
policy: "open",
|
|
1086
1266
|
handler(req, res) {
|
|
1087
1267
|
try {
|
|
1088
1268
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -1121,6 +1301,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1121
1301
|
register({
|
|
1122
1302
|
kind: "exact",
|
|
1123
1303
|
path: "/api/dsh-mneme/entities",
|
|
1304
|
+
policy: "open",
|
|
1124
1305
|
handler(req, res) {
|
|
1125
1306
|
try {
|
|
1126
1307
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -1140,6 +1321,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1140
1321
|
register({
|
|
1141
1322
|
kind: "exact",
|
|
1142
1323
|
path: "/api/dsh-mneme/memories/entities",
|
|
1324
|
+
policy: "open",
|
|
1143
1325
|
handler(req, res) {
|
|
1144
1326
|
try {
|
|
1145
1327
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -1170,8 +1352,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1170
1352
|
register({
|
|
1171
1353
|
kind: "exact",
|
|
1172
1354
|
path: "/api/dsh-mneme/export",
|
|
1355
|
+
policy: "token",
|
|
1173
1356
|
handler(req, res) {
|
|
1174
1357
|
try {
|
|
1358
|
+
// 导出是「全库 + 含 archived/forgotten 的全字段」的一次读,敏感度不低于
|
|
1359
|
+
// 写面:它必须与写路由同门。历史漏项——这条路由长期没有鉴权分支,配了
|
|
1360
|
+
// apiToken 也照旧对任何调用方开放(2026-10 审计 P0)。
|
|
1175
1361
|
const url = new URL(req.url, "http://localhost");
|
|
1176
1362
|
const format = url.searchParams.get("format") ?? "json";
|
|
1177
1363
|
if (format !== "json" && format !== "markdown") {
|
|
@@ -1220,14 +1406,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1220
1406
|
register({
|
|
1221
1407
|
kind: "exact",
|
|
1222
1408
|
path: "/api/dsh-mneme/import",
|
|
1409
|
+
policy: "token",
|
|
1223
1410
|
handler(req, res) {
|
|
1224
1411
|
try {
|
|
1225
1412
|
if (req.method !== "POST") {
|
|
1226
1413
|
sendJson(res, 404, { error: "not-found" });
|
|
1227
1414
|
return;
|
|
1228
1415
|
}
|
|
1229
|
-
|
|
1230
|
-
|
|
1416
|
+
return readBody(req, res).then((text) => {
|
|
1417
|
+
if (text === null) return;
|
|
1231
1418
|
const body = parseBody(text);
|
|
1232
1419
|
if (typeof body.type !== "string" || !Object.hasOwn(TYPE_FILE, body.type)) {
|
|
1233
1420
|
sendJson(res, 400, { error: "invalid-type" });
|
|
@@ -1261,6 +1448,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1261
1448
|
register({
|
|
1262
1449
|
kind: "exact",
|
|
1263
1450
|
path: "/api/dsh-mneme/dream-status",
|
|
1451
|
+
policy: "open",
|
|
1264
1452
|
handler(req, res) {
|
|
1265
1453
|
try {
|
|
1266
1454
|
const runs = (service.listDreamRuns?.({ limit: 5 }) ?? []).map((r) => ({
|
|
@@ -1305,13 +1493,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1305
1493
|
register({
|
|
1306
1494
|
kind: "exact",
|
|
1307
1495
|
path: "/api/dsh-mneme/conflicts",
|
|
1496
|
+
policy: "token",
|
|
1308
1497
|
handler(req, res) {
|
|
1309
1498
|
try {
|
|
1310
1499
|
if (req.method !== "GET") {
|
|
1311
1500
|
sendJson(res, 404, { error: "not-found" });
|
|
1312
1501
|
return;
|
|
1313
1502
|
}
|
|
1314
|
-
if (!requireAuth(req, res, apiToken)) return;
|
|
1315
1503
|
const url = new URL(req.url, "http://localhost");
|
|
1316
1504
|
const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit") ?? 50) || 50));
|
|
1317
1505
|
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0) || 0);
|
|
@@ -1326,14 +1514,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1326
1514
|
register({
|
|
1327
1515
|
kind: "exact",
|
|
1328
1516
|
path: "/api/dsh-mneme/conflicts/resolve",
|
|
1517
|
+
policy: "token",
|
|
1329
1518
|
handler(req, res) {
|
|
1330
1519
|
try {
|
|
1331
1520
|
if (req.method !== "POST") {
|
|
1332
1521
|
sendJson(res, 404, { error: "not-found" });
|
|
1333
1522
|
return;
|
|
1334
1523
|
}
|
|
1335
|
-
|
|
1336
|
-
|
|
1524
|
+
return readBody(req, res).then((text) => {
|
|
1525
|
+
if (text === null) return;
|
|
1337
1526
|
let body = {};
|
|
1338
1527
|
try { body = JSON.parse(text || "{}"); } catch { /* 坏 JSON → 走缺参分支 */ }
|
|
1339
1528
|
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
@@ -1363,8 +1552,8 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1363
1552
|
register({
|
|
1364
1553
|
kind: "exact",
|
|
1365
1554
|
path: "/api/dsh-mneme/health",
|
|
1555
|
+
policy: "token",
|
|
1366
1556
|
handler(req, res) {
|
|
1367
|
-
if (!requireAuth(req, res, apiToken)) return;
|
|
1368
1557
|
let state = null;
|
|
1369
1558
|
try {
|
|
1370
1559
|
state = service.getMirrorHealth?.() ?? null;
|
|
@@ -1412,11 +1601,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1412
1601
|
register({
|
|
1413
1602
|
kind: "exact",
|
|
1414
1603
|
path: "/api/dsh-mneme/mode",
|
|
1604
|
+
policy: "token-write",
|
|
1415
1605
|
handler(req, res) {
|
|
1416
1606
|
try {
|
|
1417
1607
|
if (req.method === "PUT" || req.method === "POST") {
|
|
1418
|
-
|
|
1419
|
-
|
|
1608
|
+
return readBody(req, res).then((text) => {
|
|
1609
|
+
if (text === null) return;
|
|
1420
1610
|
const body = parseBody(text);
|
|
1421
1611
|
if (body.mode !== "light" && body.mode !== "standard") {
|
|
1422
1612
|
sendJson(res, 400, { error: "invalid-mode" });
|
|
@@ -1437,11 +1627,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1437
1627
|
register({
|
|
1438
1628
|
kind: "exact",
|
|
1439
1629
|
path: "/api/dsh-mneme/external-api",
|
|
1630
|
+
policy: "token",
|
|
1440
1631
|
handler(req, res) {
|
|
1441
1632
|
try {
|
|
1442
1633
|
if (req.method === "PUT" || req.method === "POST") {
|
|
1443
|
-
|
|
1444
|
-
|
|
1634
|
+
return readBody(req, res).then((text) => {
|
|
1635
|
+
if (text === null) return;
|
|
1445
1636
|
const body = parseBody(text);
|
|
1446
1637
|
const patch = {};
|
|
1447
1638
|
if (body.enabled !== undefined) patch.enabled = body.enabled === true;
|
|
@@ -1464,9 +1655,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1464
1655
|
sendJson(res, 200, { config: fullExternalConfig(settings.setExternalApi(patch)) });
|
|
1465
1656
|
});
|
|
1466
1657
|
}
|
|
1467
|
-
// GET
|
|
1468
|
-
//
|
|
1469
|
-
//
|
|
1658
|
+
// GET 回的是能解锁整个 8790 数据面的 Bearer,敏感度等同写面:必须与
|
|
1659
|
+
// PUT 同门。未配置 apiToken 时 isAuthorized 恒真(既有的 fail-open 模型,
|
|
1660
|
+
// 见 SECURITY.md「安全模型」一节),所以默认配置下面板仍旧即装即用。
|
|
1661
|
+
// GET: hand back a token — the standalone server generates its own on
|
|
1662
|
+
// first enabled boot, but the panel displays it before that, so
|
|
1663
|
+
// materialize and persist one here. 只有通过上面那道门才会走到这里,
|
|
1664
|
+
// 否则「未鉴权的 GET 顺手写库」就是一条不需要凭证的写路径。
|
|
1470
1665
|
const kv = settings.getExternalApi() ?? {};
|
|
1471
1666
|
if (!kv.token) {
|
|
1472
1667
|
kv.token = randomBytes(24).toString("base64url");
|
|
@@ -1488,11 +1683,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1488
1683
|
register({
|
|
1489
1684
|
kind: "exact",
|
|
1490
1685
|
path: "/api/dsh-mneme/features",
|
|
1686
|
+
policy: "token-write",
|
|
1491
1687
|
handler(req, res) {
|
|
1492
1688
|
try {
|
|
1493
1689
|
if (req.method === "PUT" || req.method === "POST") {
|
|
1494
|
-
|
|
1495
|
-
|
|
1690
|
+
return readBody(req, res).then((text) => {
|
|
1691
|
+
if (text === null) return;
|
|
1496
1692
|
const body = parseBody(text);
|
|
1497
1693
|
if (typeof body !== "object" || body === null || Array.isArray(body) || Object.keys(body).length === 0) {
|
|
1498
1694
|
sendJson(res, 400, { error: "invalid-patch" });
|
|
@@ -1522,6 +1718,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1522
1718
|
register({
|
|
1523
1719
|
kind: "exact",
|
|
1524
1720
|
path: "/api/dsh-mneme/inject-status",
|
|
1721
|
+
policy: "open",
|
|
1525
1722
|
handler(req, res) {
|
|
1526
1723
|
try {
|
|
1527
1724
|
if (req.method !== "GET") {
|
|
@@ -1546,6 +1743,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1546
1743
|
register({
|
|
1547
1744
|
kind: "exact",
|
|
1548
1745
|
path: "/api/dsh-mneme/inject-preview",
|
|
1746
|
+
policy: "open",
|
|
1549
1747
|
handler(req, res) {
|
|
1550
1748
|
try {
|
|
1551
1749
|
if (req.method !== "GET") {
|
|
@@ -1563,11 +1761,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1563
1761
|
register({
|
|
1564
1762
|
kind: "exact",
|
|
1565
1763
|
path: "/api/dsh-mneme/commands",
|
|
1764
|
+
policy: "token-write",
|
|
1566
1765
|
handler(req, res) {
|
|
1567
1766
|
try {
|
|
1568
1767
|
if (req.method === "POST") {
|
|
1569
|
-
|
|
1570
|
-
|
|
1768
|
+
return readBody(req, res).then((text) => {
|
|
1769
|
+
if (text === null) return;
|
|
1571
1770
|
const body = parseBody(text);
|
|
1572
1771
|
try {
|
|
1573
1772
|
const command = commands.add({
|
|
@@ -1582,7 +1781,6 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1582
1781
|
});
|
|
1583
1782
|
}
|
|
1584
1783
|
if (req.method === "DELETE") {
|
|
1585
|
-
if (!requireAuth(req, res, apiToken)) return;
|
|
1586
1784
|
const url = new URL(req.url, "http://localhost");
|
|
1587
1785
|
const id = url.searchParams.get("id");
|
|
1588
1786
|
const removed = id ? commands.remove(id) : false;
|
|
@@ -1596,8 +1794,151 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1596
1794
|
}
|
|
1597
1795
|
});
|
|
1598
1796
|
|
|
1797
|
+
// --- M.O.(Modus Operandi · 手法)工作流 ---
|
|
1798
|
+
// 只读端点,读的是**项目里**的 `<项目>/.mneme/pipelines/*.json`(文件头在
|
|
1799
|
+
// src/pipeline.js),项目层优先、插件包里那份降级成全局层;投影成
|
|
1800
|
+
// mneme.pipeline/v1 交给面板与 agent——两边读同一份,画面只是它的投影。
|
|
1801
|
+
// 运行账(`<id>.runs.jsonl`,文件头在 src/pipeline-runs.js)也在这里读:**没有账,
|
|
1802
|
+
// 读侧算不出任何状态**(`tallyRuns` 拿空数组时全体节点恒为 candidate),所以喂不喂
|
|
1803
|
+
// runs 决定的是「面板上有没有一句实话」,不是多一个可选装饰。
|
|
1804
|
+
// 刻意不做旗标闸门:旗标管的是**客户端座位**(关=一个座位都不注册),不是数据面;
|
|
1805
|
+
// 多一个只读端点不会让任何人看见它,而给它加闸门只会让「为什么面板打不开」多一种成因。
|
|
1806
|
+
register({
|
|
1807
|
+
kind: "exact",
|
|
1808
|
+
path: "/api/dsh-mneme/pipeline",
|
|
1809
|
+
policy: "open",
|
|
1810
|
+
handler(req, res) {
|
|
1811
|
+
try {
|
|
1812
|
+
const url = new URL(req.url, "http://localhost");
|
|
1813
|
+
const id = url.searchParams.get("id") || null;
|
|
1814
|
+
const project = url.searchParams.get("project");
|
|
1815
|
+
// project 是**会话工作目录**,由面板侧解析后带过来——工作目录只有客户端拿得到
|
|
1816
|
+
// (宿主会话头里的 header.cwd),服务端没有「当前项目」这个概念。
|
|
1817
|
+
// 不是绝对路径就**拒掉**,不能忽略:忽略了会静默退成「全局」,而全局看起来
|
|
1818
|
+
// 一切正常——那正是这版要修掉的那类错。
|
|
1819
|
+
if (project !== null && !isAbsolute(project)) {
|
|
1820
|
+
sendJson(res, 400, { error: "project-not-absolute", detail: project });
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
// 只读**账本目录**来定「哪些 id 有账」:最新一条 run 可能属于一条刚被删掉或
|
|
1825
|
+
// 还没写完的 .json,这时工作流目录里问不到它的 id,而账本还在盘上。
|
|
1826
|
+
// runIds 必须在 handler 级声明:下面观察旁挂块(同函数的另一个 if 分支)也按
|
|
1827
|
+
// 这份 id 列表取观察——之前声明在账本分支的块级作用域里,旁挂块一引用就
|
|
1828
|
+
// ReferenceError,被 catch 静默吞成「没有观察」(CodeRabbit #458 F1,
|
|
1829
|
+
// 复盘注释见下方旁挂块)。
|
|
1830
|
+
let runIds = [];
|
|
1831
|
+
let runsResult = { runsById: {}, errors: [], statesById: {} };
|
|
1832
|
+
if (project !== null) {
|
|
1833
|
+
const where = runsDir(project);
|
|
1834
|
+
runIds = existsSync(where)
|
|
1835
|
+
? readdirSync(where)
|
|
1836
|
+
.filter((f) => f.endsWith(RUNS_SUFFIX))
|
|
1837
|
+
.sort()
|
|
1838
|
+
.map((f) => f.slice(0, -RUNS_SUFFIX.length))
|
|
1839
|
+
: [];
|
|
1840
|
+
if (runIds.length) {
|
|
1841
|
+
// step id 闸门:账本里出现「图上没有的 step id」时,credit 会落在一个没有节点的
|
|
1842
|
+
// 地方——**记了、数得出来、面板上永远看不见**,症状与「从没跑过」同形。
|
|
1843
|
+
// 这里把每条工作流的步骤表读出来当白名单,让那种行变成看得见的噪音。
|
|
1844
|
+
let stepIdsById = null;
|
|
1845
|
+
try {
|
|
1846
|
+
stepIdsById = Object.create(null);
|
|
1847
|
+
for (const p of loadPipelines(projectPipelineDir(project), { scope: "project" })) {
|
|
1848
|
+
stepIdsById[p.id] = p.steps.map((s) => s.id);
|
|
1849
|
+
}
|
|
1850
|
+
} catch {
|
|
1851
|
+
// 项目层有坏 .json 时 readPipelineDoc 会把它记进 `unreadable`;这里不重复报,
|
|
1852
|
+
// 也绝不因此让整个面板打不开——闸门退化成「没有白名单」,账本照读。
|
|
1853
|
+
stepIdsById = null;
|
|
1854
|
+
}
|
|
1855
|
+
runsResult = loadRunsById(project, runIds, { expectedStepIdsById: stepIdsById });
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
const { runsById, errors: runsErrors, statesById } = runsResult;
|
|
1859
|
+
|
|
1860
|
+
// P3 投影的燃料:观察旁挂(与 /candidates 的 memoryDir 同源同默认)。
|
|
1861
|
+
// 失败不连累图——旁挂是可再生的辅助数据,读不到就当没有,提议少一类而已。
|
|
1862
|
+
let observationsById = {};
|
|
1863
|
+
if (project !== null) {
|
|
1864
|
+
try {
|
|
1865
|
+
const memoryDir = (config && typeof config.memoryDir === "string" && config.memoryDir.trim() !== "")
|
|
1866
|
+
? config.memoryDir
|
|
1867
|
+
: "~/.dsh/memory";
|
|
1868
|
+
const odir = observationDir({ memoryDir, projectKey: projectKey(project) });
|
|
1869
|
+
if (odir) {
|
|
1870
|
+
observationsById = loadObservationsById(odir, runIds.filter(Boolean));
|
|
1871
|
+
}
|
|
1872
|
+
} catch {
|
|
1873
|
+
// 读旁挂失败不算账本问题:如实降级成「没有观察」,fix-step-tool 少发而已
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
const doc = readPipelineDoc({ projectRoot: project, id, runsById, observationsById });
|
|
1878
|
+
|
|
1879
|
+
// 「读不出来」与「还没跑过」在节点状态上完全同形(都是全体 candidate),而且
|
|
1880
|
+
// **不带 id 的默认视图没有兜底**——那正是用户打开时看到的那一屏。所以状态按
|
|
1881
|
+
// pipeline 逐条显式带出去(清单行的 `runsState` 与选中那条的顶层 `runsState`),
|
|
1882
|
+
// 客户端不必靠顶层噪音猜。也刻意**不再**对坏账本回 500:那会让客户端把它当成
|
|
1883
|
+
// 「离线」,还顺手吞掉本来读得出来的那张图(账本坏了,工作流本身没坏)。
|
|
1884
|
+
// 没有项目根时账本层无从读起,如实给 null,而不是谎称「没有账本文件」。
|
|
1885
|
+
const chosenId = doc.pipeline ? doc.pipeline.id : null;
|
|
1886
|
+
const runsState = project !== null && chosenId !== null ? statesById[chosenId] || "absent" : null;
|
|
1887
|
+
const available =
|
|
1888
|
+
project === null
|
|
1889
|
+
? doc.available
|
|
1890
|
+
: doc.available.map((row) => ({ ...row, runsState: statesById[row.id] || "absent" }));
|
|
1891
|
+
|
|
1892
|
+
sendJson(res, 200, { ...doc, available, runsState, runsErrors });
|
|
1893
|
+
} catch (error) {
|
|
1894
|
+
// 工作流是人手写的 JSON,报错本身就是给人看的:说清哪一条、坏在哪,
|
|
1895
|
+
// 而不是回一句 internal 让人去翻日志。
|
|
1896
|
+
sendJson(res, 500, {
|
|
1897
|
+
error: "pipeline-unreadable",
|
|
1898
|
+
detail: String((error && error.message) || error)
|
|
1899
|
+
});
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
});
|
|
1903
|
+
|
|
1904
|
+
// --- M.O. 候选层(绕行走出来、还没进仓库的那些路) ---
|
|
1905
|
+
// 和 /pipeline 读的是两处不同的东西,别混:
|
|
1906
|
+
// /pipeline 定稿,住**仓库**(<项目>/.mneme/pipelines/),可提交、可 review
|
|
1907
|
+
// /candidates 候选,住**本地**(<memoryDir>/candidates/<项目键>/),随时长、随时扔
|
|
1908
|
+
// 分开是刻意的:候选是草稿,一天可能长十条死九条,进仓库就是垃圾;
|
|
1909
|
+
// 而定稿只放人亲手批过的东西——候选是定稿的唯一入口。
|
|
1910
|
+
//
|
|
1911
|
+
// 这一刀只给**读**:提议与批准的动作还没有落点(那要等执行侧)。
|
|
1912
|
+
// 契约里显式写着 boundaries.autoPromote/autoApprove/injectIntoMemory 三个 false,
|
|
1913
|
+
// 免得以后有人以为忘了做。
|
|
1914
|
+
register({
|
|
1915
|
+
kind: "exact",
|
|
1916
|
+
path: "/api/dsh-mneme/candidates",
|
|
1917
|
+
policy: "open",
|
|
1918
|
+
handler(req, res) {
|
|
1919
|
+
try {
|
|
1920
|
+
const url = new URL(req.url, "http://localhost");
|
|
1921
|
+
const project = url.searchParams.get("project");
|
|
1922
|
+
if (project !== null && !isAbsolute(project)) {
|
|
1923
|
+
sendJson(res, 400, { error: "project-not-absolute", detail: project });
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
// memoryDir 从 config 拿(与 store 同源);取不到就退回默认,与 config.js 的默认值一致。
|
|
1927
|
+
const memoryDir = (config && typeof config.memoryDir === "string" && config.memoryDir.trim())
|
|
1928
|
+
? config.memoryDir
|
|
1929
|
+
: "~/.dsh/memory";
|
|
1930
|
+
sendJson(res, 200, readCandidateDoc({ memoryDir, projectRoot: project }));
|
|
1931
|
+
} catch (error) {
|
|
1932
|
+
sendJson(res, 500, {
|
|
1933
|
+
error: "candidates-unreadable",
|
|
1934
|
+
detail: String((error && error.message) || error)
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
});
|
|
1939
|
+
|
|
1599
1940
|
return {
|
|
1600
|
-
routes:
|
|
1941
|
+
routes: countRoutes(),
|
|
1601
1942
|
dispose: () => {
|
|
1602
1943
|
for (const dispose of disposers) dispose();
|
|
1603
1944
|
}
|