@modusensus/dsh-mneme 0.7.25 → 0.7.27
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.en.md +12 -6
- package/README.md +12 -6
- package/lib/api.js +99 -0
- package/lib/client.js +132 -4
- package/lib/config.js +6 -6
- package/lib/dream/sleep.js +3 -3
- package/lib/dream.js +56 -2
- package/lib/settings.js +4 -0
- package/package.json +1 -1
- package/src/api.js +99 -0
- package/src/config.js +6 -6
- package/src/dream/sleep.js +3 -3
- package/src/dream.js +56 -2
- package/src/settings.js +4 -0
- package/test/api.test.js +171 -6
- package/test/client.test.js +72 -1
- package/test/reasoning-effort.test.js +163 -0
package/lib/dream.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { validateDecisions, applyDecisions } from "./dream/decisions.js";
|
|
2
2
|
import { clusterMemories, findPotentialConflicts } from "./dream/clustering.js";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
-
export { validateDecisions, applyDecisions, withEffortFallback, describeStreamFailure };
|
|
4
|
+
export { validateDecisions, applyDecisions, withEffortFallback, describeStreamFailure, resolveDreamEffort, resolveRoute };
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
// Extract the first JSON array from LLM output, tolerating markdown fences,
|
|
@@ -373,6 +373,60 @@ async function withEffortFallback(ctx, effort, attempt, fallback, getStreamError
|
|
|
373
373
|
}
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Resolve the reasoning effort to actually send for a dream/sleep route.
|
|
378
|
+
*
|
|
379
|
+
* Reasoning-effort config that the provider does not accept trips the harness's
|
|
380
|
+
* UNSUPPORTED_REASONING_EFFORT, and the defaultEffort trap makes retrying
|
|
381
|
+
* "without the field" useless: the harness substitutes `reasoning.defaultEffort`,
|
|
382
|
+
* which may itself be unsupported (DSH Desktop volcano-engine adapter declares
|
|
383
|
+
* defaultEffort=low that its model rejects). So instead of blind retries, ask
|
|
384
|
+
* the harness for the model's declared capability and pick a value that is
|
|
385
|
+
* actually accepted — or omit the field entirely when the model declares no
|
|
386
|
+
* reasoning capability at all.
|
|
387
|
+
*
|
|
388
|
+
* @returns a supported effort id, or null when no effort should be sent, or the
|
|
389
|
+
* configured value unchanged when the capability query is unavailable.
|
|
390
|
+
*/
|
|
391
|
+
async function resolveDreamEffort(ctx, route, configuredEffort, logger) {
|
|
392
|
+
if (!configuredEffort || configuredEffort === "none") return null;
|
|
393
|
+
// Capability query unavailable (older harness / minimal mocks): forward the
|
|
394
|
+
// configured value as before — absence of the API proves nothing about the
|
|
395
|
+
// model, and withEffortFallback still guards against rejection.
|
|
396
|
+
if (typeof ctx?.llm?.resolveModelInfo !== "function") return configuredEffort;
|
|
397
|
+
try {
|
|
398
|
+
const info = await ctx.llm.resolveModelInfo(route.provider, route.model);
|
|
399
|
+
const reasoning = info?.reasoning;
|
|
400
|
+
if (!reasoning) {
|
|
401
|
+
// Model declares no reasoning capability: the harness rejects ANY
|
|
402
|
+
// explicit effort for such a model, and omitting the field is safe
|
|
403
|
+
// (no reasoning capability → no defaultEffort substitution).
|
|
404
|
+
logger?.warn?.(`dsh-mneme dream: model ${route.provider}:${route.model} declares no reasoning capability; ignoring configured effort "${configuredEffort}"`);
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
const supported = reasoning.efforts?.map((effort) => effort.id) ?? [];
|
|
408
|
+
if (supported.includes(configuredEffort)) return configuredEffort;
|
|
409
|
+
// Configured effort unsupported → pick defaultEffort if it is supported,
|
|
410
|
+
// else the first declared effort, so the run never trips
|
|
411
|
+
// UNSUPPORTED_REASONING_EFFORT (nor the defaultEffort trap: we always
|
|
412
|
+
// pass an explicit value, so the harness never falls back to a poison
|
|
413
|
+
// default).
|
|
414
|
+
const picked = reasoning.defaultEffort && supported.includes(reasoning.defaultEffort)
|
|
415
|
+
? reasoning.defaultEffort
|
|
416
|
+
: supported[0];
|
|
417
|
+
if (picked) {
|
|
418
|
+
logger?.warn?.(`dsh-mneme dream: model ${route.provider}:${route.model} does not support effort "${configuredEffort}" (supported: ${supported.join(", ")}); using "${picked}"`);
|
|
419
|
+
return picked;
|
|
420
|
+
}
|
|
421
|
+
return null;
|
|
422
|
+
} catch (error) {
|
|
423
|
+
// Capability query failed — forward the configured value; withEffortFallback
|
|
424
|
+
// still retries on rejection as before.
|
|
425
|
+
logger?.warn?.(`dsh-mneme dream: resolveModelInfo failed (${String(error?.message ?? error)}); forwarding effort as configured`);
|
|
426
|
+
return configuredEffort;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
376
430
|
/**
|
|
377
431
|
* Resolve the LLM route (Issue #25): an explicit plugin config
|
|
378
432
|
* (dreamProvider/dreamModel) is the user's declared override and wins; the
|
|
@@ -665,7 +719,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
665
719
|
// (不带该字段),避免 thinking 模型配置 low/medium 直接整单失败。解析放
|
|
666
720
|
// 在 auditError 检查器里、闭包交回主流程,避免二次解析;解析失败同时如实
|
|
667
721
|
// 记 audit error 并在日志带原始输出前 300 字节,便于定位"推理吞预算返回空体"。
|
|
668
|
-
const effort =
|
|
722
|
+
const effort = await resolveDreamEffort(ctx, route, config.dreamReasoningEffort, logger);
|
|
669
723
|
let decisions = null;
|
|
670
724
|
let streamFailure = "";
|
|
671
725
|
const runConsolidation = (withEffort) => {
|
package/lib/settings.js
CHANGED
|
@@ -82,6 +82,10 @@ const FEATURE_FLAG_INT_RANGES = {
|
|
|
82
82
|
const FEATURE_FLAG_STRINGS = [
|
|
83
83
|
"dreamProvider",
|
|
84
84
|
"dreamModel",
|
|
85
|
+
// 睡眠侧专用路由(sleep.js 的 config-first 第三层):面板下拉随
|
|
86
|
+
// /llm-providers 端点一起提供,留空 = 用巩固模型或当前模型。
|
|
87
|
+
"sleepProvider",
|
|
88
|
+
"sleepModel",
|
|
85
89
|
"localEmbedModel",
|
|
86
90
|
"ollamaModel"
|
|
87
91
|
];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modusensus/dsh-mneme",
|
|
3
3
|
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.27",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
package/src/api.js
CHANGED
|
@@ -4,6 +4,7 @@ import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
|
4
4
|
import { FEATURE_FLAG_SPEC } from "./settings.js";
|
|
5
5
|
import { TYPE_FILE, renderMirrorText, parseHumanEdits } from "./mirror.js";
|
|
6
6
|
import { computeHeat } from "./heat.js";
|
|
7
|
+
import { describeStreamFailure, resolveRoute } from "./dream.js";
|
|
7
8
|
|
|
8
9
|
// headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
|
|
9
10
|
function sendJson(res, status, payload, headers = {}) {
|
|
@@ -277,6 +278,104 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
277
278
|
}
|
|
278
279
|
});
|
|
279
280
|
|
|
281
|
+
// --- dream/sleep 模型连通性 + 模型发现(settings 面板)----------------------
|
|
282
|
+
// Connectivity probe for a dream/sleep model route: runs one minimal LLM call
|
|
283
|
+
// through the same harness path the consolidation uses and reports success /
|
|
284
|
+
// failure + latency + error. Reuses describeStreamFailure so the panel shows
|
|
285
|
+
// the same error wording the audit rows carry. Optional reasoningEffort
|
|
286
|
+
// mirrors what the consolidation would send — the panel can verify a model
|
|
287
|
+
// actually accepts the configured effort without tripping a full run.
|
|
288
|
+
async function testLlmConnectivity(llm, provider, model, reasoningEffort) {
|
|
289
|
+
const startedAt = Date.now();
|
|
290
|
+
const elapsed = () => Date.now() - startedAt;
|
|
291
|
+
const modelId = `${provider}:${model}`;
|
|
292
|
+
try {
|
|
293
|
+
let reply = "";
|
|
294
|
+
let error = "";
|
|
295
|
+
for await (const chunk of llm.stream({
|
|
296
|
+
provider,
|
|
297
|
+
model,
|
|
298
|
+
purpose: "dsh-mneme-connectivity-test",
|
|
299
|
+
maxTokens: 16,
|
|
300
|
+
...(reasoningEffort && reasoningEffort !== "none" ? { reasoningEffort } : {}),
|
|
301
|
+
messages: [{ role: "system", content: [{ type: "text", text: "Reply with exactly one word: ok" }] }]
|
|
302
|
+
})) {
|
|
303
|
+
if (chunk.type === "text-delta" && typeof chunk.text === "string") reply += chunk.text;
|
|
304
|
+
if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
|
|
305
|
+
error = describeStreamFailure(chunk.reason);
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (error) return { ok: false, durationMs: elapsed(), error, modelId };
|
|
310
|
+
return { ok: true, durationMs: elapsed(), modelId, reply: reply.trim().slice(0, 100) };
|
|
311
|
+
} catch (error) {
|
|
312
|
+
return { ok: false, durationMs: elapsed(), error: String(error?.message ?? error), modelId };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// List every provider + model the host has registered, so the panel can offer
|
|
317
|
+
// a dropdown over REAL models instead of free-text guesses. Read-only, no auth
|
|
318
|
+
// (same as list/search). Best-effort per provider: one whose model list can't
|
|
319
|
+
// be resolved yields an empty models array instead of failing the whole call.
|
|
320
|
+
register({
|
|
321
|
+
kind: "exact",
|
|
322
|
+
path: "/api/dsh-mneme/llm-providers",
|
|
323
|
+
handler(req, res) {
|
|
324
|
+
try {
|
|
325
|
+
const llm = ctx.llm;
|
|
326
|
+
if (typeof llm?.listProviders !== "function" || typeof llm?.listModels !== "function") {
|
|
327
|
+
return sendJson(res, 501, { error: "llm-unavailable" });
|
|
328
|
+
}
|
|
329
|
+
let providers;
|
|
330
|
+
try { providers = llm.listProviders() ?? []; } catch { providers = []; }
|
|
331
|
+
return Promise.all(providers.map(async (p) => {
|
|
332
|
+
let models = [];
|
|
333
|
+
try { models = (await llm.listModels(p.id)) ?? []; } catch { models = []; }
|
|
334
|
+
return {
|
|
335
|
+
provider: p.id,
|
|
336
|
+
models: models.map((m) => ({ id: m.id, name: m.name ?? m.id }))
|
|
337
|
+
};
|
|
338
|
+
})).then((rows) => sendJson(res, 200, { providers: rows }));
|
|
339
|
+
} catch {
|
|
340
|
+
return sendJson(res, 500, { error: "internal" });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Connectivity test for a dream/sleep model route. Runs one tiny LLM call and
|
|
346
|
+
// reports ok / failure + latency + error. Auth-gated: it spends the user's
|
|
347
|
+
// API quota, so a stray local page must not be able to drain it via loopback.
|
|
348
|
+
register({
|
|
349
|
+
kind: "exact",
|
|
350
|
+
path: "/api/dsh-mneme/test-model",
|
|
351
|
+
handler(req, res) {
|
|
352
|
+
try {
|
|
353
|
+
if (req.method !== "POST") return sendJson(res, 404, { error: "not-found" });
|
|
354
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
355
|
+
return readBody(req).then(async (text) => {
|
|
356
|
+
const body = parseBody(text);
|
|
357
|
+
let provider = typeof body.provider === "string" ? body.provider.trim() : "";
|
|
358
|
+
let model = typeof body.model === "string" ? body.model.trim() : "";
|
|
359
|
+
if ((!provider && model) || (provider && !model)) return sendJson(res, 400, { error: "missing-provider-or-model" });
|
|
360
|
+
if (!provider && !model) {
|
|
361
|
+
// 空 = 按巩固路由解析(dreamProvider/dreamModel > agent 默认模型),
|
|
362
|
+
// 让面板一键测"当前巩固模型"而无需手填。
|
|
363
|
+
const route = resolveRoute(ctx, config ?? {}, ctx.logger);
|
|
364
|
+
if (!route) return sendJson(res, 400, { error: "no-route" });
|
|
365
|
+
provider = route.provider;
|
|
366
|
+
model = route.model;
|
|
367
|
+
}
|
|
368
|
+
const llm = ctx.llm;
|
|
369
|
+
if (typeof llm?.stream !== "function") return sendJson(res, 501, { error: "llm-unavailable" });
|
|
370
|
+
const result = await testLlmConnectivity(llm, provider, model, body.reasoningEffort);
|
|
371
|
+
return sendJson(res, result.ok ? 200 : 502, result);
|
|
372
|
+
});
|
|
373
|
+
} catch {
|
|
374
|
+
return sendJson(res, 500, { error: "internal" });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
280
379
|
// --- delete one memory by id ---
|
|
281
380
|
// Mutation route: apiToken-gated like the profile/rules writes above. POST
|
|
282
381
|
// body is JSON { id }. store.remove deletes silently, so existence is checked
|
package/src/config.js
CHANGED
|
@@ -62,8 +62,8 @@ export const Config = z.object({
|
|
|
62
62
|
// reasoning effort —— 即使去掉 effort 重试,harness 的 defaultEffort 也会顶上来
|
|
63
63
|
// 再次拒绝(UNSUPPORTED_REASONING_EFFORT),插件 fallback 无法绕开。
|
|
64
64
|
// 选用时建议配 dreamReasoningEffort 并实测;不行就换非思考模型。
|
|
65
|
-
dreamProvider: z.string(),
|
|
66
|
-
dreamModel: z.string(),
|
|
65
|
+
dreamProvider: z.string().description("记忆巩固专用模型的服务商(settings「巩固模型」)。巩固反复失败时,优先改用官方非思考模型的服务商(如 deepseek / glm)。"),
|
|
66
|
+
dreamModel: z.string().description("记忆巩固专用模型。建议选非思考模型(如 deepseek-chat、glm-5-2 类):思考模型可能烧光 token 预算返回空体,导致巩固失败(UNSUPPORTED_REASONING_EFFORT)。"),
|
|
67
67
|
dreamMaxTokens: z.natural().min(256).max(131072).default(32768),
|
|
68
68
|
// Pass-through reasoning effort for dream's LLM calls. 'none' (default)
|
|
69
69
|
// omits the field so the provider's own default applies; low/medium/high
|
|
@@ -78,7 +78,7 @@ export const Config = z.object({
|
|
|
78
78
|
z.const("medium"),
|
|
79
79
|
z.const("high"),
|
|
80
80
|
z.const("none")
|
|
81
|
-
]).default("none"),
|
|
81
|
+
]).default("none").description("巩固模型的推理档位:'none'(默认)用服务商自带默认;low/medium/high 原样传递。模型不支持的值会自动换用其支持的档位(v0.7.26+)。"),
|
|
82
82
|
// 滑动窗口上限(v0.4.4):autoDream 每次只对最近 dreamMaxSnapshotSize 条
|
|
83
83
|
// 记忆做 consolidation。大记忆量下全量快照会把 LLM 输入撑爆(636 记忆 →
|
|
84
84
|
// 677 "missing" errors、applied=0),窗口外的旧记忆不进 snapshot。
|
|
@@ -251,8 +251,8 @@ export const Config = z.object({
|
|
|
251
251
|
sleepMaxPatternPerRun: z.natural().min(0).max(10).default(3),
|
|
252
252
|
// Optional LLM route override for sleep's bulk passes (empty = use dream
|
|
253
253
|
// route / agent default model).
|
|
254
|
-
sleepProvider: z.string().default(""),
|
|
255
|
-
sleepModel: z.string().default(""),
|
|
254
|
+
sleepProvider: z.string().default("").description("sleep 深维护专用模型服务商,留空用巩固模型或当前模型。"),
|
|
255
|
+
sleepModel: z.string().default("").description("sleep 深维护专用模型,留空用巩固模型或当前模型;建议同巩固模型选非思考模型。"),
|
|
256
256
|
// Pass-through reasoning effort for sleep's LLM passes, same semantics as
|
|
257
257
|
// dreamReasoningEffort: 'none' (default) omits the field; low/medium/high
|
|
258
258
|
// are forwarded verbatim.
|
|
@@ -261,7 +261,7 @@ export const Config = z.object({
|
|
|
261
261
|
z.const("medium"),
|
|
262
262
|
z.const("high"),
|
|
263
263
|
z.const("none")
|
|
264
|
-
]).default("none"),
|
|
264
|
+
]).default("none").description("同 dreamReasoningEffort:sleep 各阶段 LLM 的推理档位,'none' 用服务商默认。"),
|
|
265
265
|
|
|
266
266
|
// --- epistemic trust: memory source credibility (v0.4.5) -----------------
|
|
267
267
|
// Distinguish memories by source: observation (measured / witnessed),
|
package/src/dream/sleep.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { randomUUID, createHash } from "node:crypto";
|
|
18
18
|
import { validateDecisions, applyDecisions } from "./decisions.js";
|
|
19
19
|
import { findPotentialConflicts } from "./clustering.js";
|
|
20
|
-
import { buildReceipt, describeStreamFailure, withEffortFallback } from "../dream.js";
|
|
20
|
+
import { buildReceipt, describeStreamFailure, resolveDreamEffort, withEffortFallback } from "../dream.js";
|
|
21
21
|
import { computeHeat } from "../heat.js";
|
|
22
22
|
|
|
23
23
|
const SUMMARY_MAX = 120;
|
|
@@ -190,7 +190,7 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
|
|
|
190
190
|
const listText = selected.map((p) =>
|
|
191
191
|
`候选冲突:\nid=${p.a.id} | type=${p.a.type} | title=${p.a.title}\n${p.a.content}\n---\nid=${p.b.id} | type=${p.b.type} | title=${p.b.title}\n${p.b.content}\n(相似度 ${p.similarity.toFixed(2)})`
|
|
192
192
|
).join("\n\n");
|
|
193
|
-
const sleepEffort =
|
|
193
|
+
const sleepEffort = await resolveDreamEffort(ctx, route, config.sleepReasoningEffort, logger);
|
|
194
194
|
let conflictStreamFailure = "";
|
|
195
195
|
const runConflict = (withEffort) => {
|
|
196
196
|
conflictStreamFailure = "";
|
|
@@ -309,7 +309,7 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
|
|
|
309
309
|
.map((m) => `id=${m.id} | type=${m.type} | importance=${m.importance} | updated=${m.updated_at} | title=${m.title} | content=${m.content}`)
|
|
310
310
|
.join("\n");
|
|
311
311
|
const maxPatterns = config.sleepMaxPatternPerRun ?? 3;
|
|
312
|
-
const sleepEffort =
|
|
312
|
+
const sleepEffort = await resolveDreamEffort(ctx, route, config.sleepReasoningEffort, logger);
|
|
313
313
|
let patternStreamFailure = "";
|
|
314
314
|
const runPattern = (withEffort) => {
|
|
315
315
|
patternStreamFailure = "";
|
package/src/dream.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { validateDecisions, applyDecisions } from "./dream/decisions.js";
|
|
2
2
|
import { clusterMemories, findPotentialConflicts } from "./dream/clustering.js";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
-
export { validateDecisions, applyDecisions, withEffortFallback, describeStreamFailure };
|
|
4
|
+
export { validateDecisions, applyDecisions, withEffortFallback, describeStreamFailure, resolveDreamEffort, resolveRoute };
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
// Extract the first JSON array from LLM output, tolerating markdown fences,
|
|
@@ -373,6 +373,60 @@ async function withEffortFallback(ctx, effort, attempt, fallback, getStreamError
|
|
|
373
373
|
}
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Resolve the reasoning effort to actually send for a dream/sleep route.
|
|
378
|
+
*
|
|
379
|
+
* Reasoning-effort config that the provider does not accept trips the harness's
|
|
380
|
+
* UNSUPPORTED_REASONING_EFFORT, and the defaultEffort trap makes retrying
|
|
381
|
+
* "without the field" useless: the harness substitutes `reasoning.defaultEffort`,
|
|
382
|
+
* which may itself be unsupported (DSH Desktop volcano-engine adapter declares
|
|
383
|
+
* defaultEffort=low that its model rejects). So instead of blind retries, ask
|
|
384
|
+
* the harness for the model's declared capability and pick a value that is
|
|
385
|
+
* actually accepted — or omit the field entirely when the model declares no
|
|
386
|
+
* reasoning capability at all.
|
|
387
|
+
*
|
|
388
|
+
* @returns a supported effort id, or null when no effort should be sent, or the
|
|
389
|
+
* configured value unchanged when the capability query is unavailable.
|
|
390
|
+
*/
|
|
391
|
+
async function resolveDreamEffort(ctx, route, configuredEffort, logger) {
|
|
392
|
+
if (!configuredEffort || configuredEffort === "none") return null;
|
|
393
|
+
// Capability query unavailable (older harness / minimal mocks): forward the
|
|
394
|
+
// configured value as before — absence of the API proves nothing about the
|
|
395
|
+
// model, and withEffortFallback still guards against rejection.
|
|
396
|
+
if (typeof ctx?.llm?.resolveModelInfo !== "function") return configuredEffort;
|
|
397
|
+
try {
|
|
398
|
+
const info = await ctx.llm.resolveModelInfo(route.provider, route.model);
|
|
399
|
+
const reasoning = info?.reasoning;
|
|
400
|
+
if (!reasoning) {
|
|
401
|
+
// Model declares no reasoning capability: the harness rejects ANY
|
|
402
|
+
// explicit effort for such a model, and omitting the field is safe
|
|
403
|
+
// (no reasoning capability → no defaultEffort substitution).
|
|
404
|
+
logger?.warn?.(`dsh-mneme dream: model ${route.provider}:${route.model} declares no reasoning capability; ignoring configured effort "${configuredEffort}"`);
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
const supported = reasoning.efforts?.map((effort) => effort.id) ?? [];
|
|
408
|
+
if (supported.includes(configuredEffort)) return configuredEffort;
|
|
409
|
+
// Configured effort unsupported → pick defaultEffort if it is supported,
|
|
410
|
+
// else the first declared effort, so the run never trips
|
|
411
|
+
// UNSUPPORTED_REASONING_EFFORT (nor the defaultEffort trap: we always
|
|
412
|
+
// pass an explicit value, so the harness never falls back to a poison
|
|
413
|
+
// default).
|
|
414
|
+
const picked = reasoning.defaultEffort && supported.includes(reasoning.defaultEffort)
|
|
415
|
+
? reasoning.defaultEffort
|
|
416
|
+
: supported[0];
|
|
417
|
+
if (picked) {
|
|
418
|
+
logger?.warn?.(`dsh-mneme dream: model ${route.provider}:${route.model} does not support effort "${configuredEffort}" (supported: ${supported.join(", ")}); using "${picked}"`);
|
|
419
|
+
return picked;
|
|
420
|
+
}
|
|
421
|
+
return null;
|
|
422
|
+
} catch (error) {
|
|
423
|
+
// Capability query failed — forward the configured value; withEffortFallback
|
|
424
|
+
// still retries on rejection as before.
|
|
425
|
+
logger?.warn?.(`dsh-mneme dream: resolveModelInfo failed (${String(error?.message ?? error)}); forwarding effort as configured`);
|
|
426
|
+
return configuredEffort;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
376
430
|
/**
|
|
377
431
|
* Resolve the LLM route (Issue #25): an explicit plugin config
|
|
378
432
|
* (dreamProvider/dreamModel) is the user's declared override and wins; the
|
|
@@ -665,7 +719,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
665
719
|
// (不带该字段),避免 thinking 模型配置 low/medium 直接整单失败。解析放
|
|
666
720
|
// 在 auditError 检查器里、闭包交回主流程,避免二次解析;解析失败同时如实
|
|
667
721
|
// 记 audit error 并在日志带原始输出前 300 字节,便于定位"推理吞预算返回空体"。
|
|
668
|
-
const effort =
|
|
722
|
+
const effort = await resolveDreamEffort(ctx, route, config.dreamReasoningEffort, logger);
|
|
669
723
|
let decisions = null;
|
|
670
724
|
let streamFailure = "";
|
|
671
725
|
const runConsolidation = (withEffort) => {
|
package/src/settings.js
CHANGED
|
@@ -82,6 +82,10 @@ const FEATURE_FLAG_INT_RANGES = {
|
|
|
82
82
|
const FEATURE_FLAG_STRINGS = [
|
|
83
83
|
"dreamProvider",
|
|
84
84
|
"dreamModel",
|
|
85
|
+
// 睡眠侧专用路由(sleep.js 的 config-first 第三层):面板下拉随
|
|
86
|
+
// /llm-providers 端点一起提供,留空 = 用巩固模型或当前模型。
|
|
87
|
+
"sleepProvider",
|
|
88
|
+
"sleepModel",
|
|
85
89
|
"localEmbedModel",
|
|
86
90
|
"ollamaModel"
|
|
87
91
|
];
|
package/test/api.test.js
CHANGED
|
@@ -527,16 +527,18 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
527
527
|
assert.equal(res.statusCode, 200);
|
|
528
528
|
const data = JSON.parse(res.body);
|
|
529
529
|
assert.deepEqual(data.overrides, {});
|
|
530
|
-
// effective 覆盖全部
|
|
531
|
-
// dreamSkipInvalid/allowCrossTypeMerge/dreamMinIntervalMinutes
|
|
532
|
-
//
|
|
533
|
-
// dreamProvider/dreamModel 无 schema
|
|
534
|
-
//
|
|
535
|
-
assert.equal(Object.keys(data.effective).length,
|
|
530
|
+
// effective 覆盖全部 37 个白名单键(含 v0.7.20 heatEnabled、Issue #89 新增
|
|
531
|
+
// dreamSkipInvalid/allowCrossTypeMerge/dreamMinIntervalMinutes、面板可调的
|
|
532
|
+
// dreamMaxTokens 与本轮睡眠路由 sleepProvider/sleepModel),未覆盖时取
|
|
533
|
+
// bundle 配置的解析默认值;dreamProvider/dreamModel 无 schema 默认值
|
|
534
|
+
// (Config({}) 解析为 undefined),不编造给前端 → 37 - 2 = 35
|
|
535
|
+
assert.equal(Object.keys(data.effective).length, 35);
|
|
536
536
|
assert.equal(data.effective.dreamSkipInvalid, true);
|
|
537
537
|
assert.equal(data.effective.allowCrossTypeMerge, false);
|
|
538
538
|
assert.equal(data.effective.dreamMinIntervalMinutes, 0);
|
|
539
539
|
assert.equal(data.effective.dreamMaxTokens, 32768);
|
|
540
|
+
assert.equal(data.effective.sleepProvider, "");
|
|
541
|
+
assert.equal(data.effective.sleepModel, "");
|
|
540
542
|
assert.equal(data.effective.autoInject, true);
|
|
541
543
|
assert.equal(data.effective.codingRetrospect, false);
|
|
542
544
|
assert.equal(data.effective.distillMaxChars, 24000);
|
|
@@ -1083,3 +1085,166 @@ test("GET /api/dsh-mneme/list projects per-memory heat only when heatEnabled=tru
|
|
|
1083
1085
|
assert.ok(typeof v === "number" && v >= 0 && v <= 1, "heat values stay within [0,1]");
|
|
1084
1086
|
}
|
|
1085
1087
|
});
|
|
1088
|
+
|
|
1089
|
+
// ---------------------------------------------------------------- llm-providers / test-model
|
|
1090
|
+
// Settings-panel support: enumerate host-registered providers/models so the
|
|
1091
|
+
// panel can offer a dropdown over real models, and probe connectivity with a
|
|
1092
|
+
// minimal LLM call (same harness path the consolidation uses).
|
|
1093
|
+
|
|
1094
|
+
function setupLlm(llm, apiToken = "", extraCtx = {}, config = null) {
|
|
1095
|
+
const store = createStore(":memory:");
|
|
1096
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
1097
|
+
const settings = createSettings(store.db);
|
|
1098
|
+
const commands = { add: () => {}, remove: () => {}, list: () => [] };
|
|
1099
|
+
const routes = [];
|
|
1100
|
+
const ctx = {
|
|
1101
|
+
webServer: { register(route) { routes.push(route); return () => {}; } },
|
|
1102
|
+
llm,
|
|
1103
|
+
...extraCtx
|
|
1104
|
+
};
|
|
1105
|
+
const api = createApi(ctx, service, settings, commands, undefined, undefined, apiToken, config);
|
|
1106
|
+
return { routes };
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
const MOCK_LLM = {
|
|
1110
|
+
listProviders: () => [
|
|
1111
|
+
{ id: "deepseek", name: "DeepSeek" },
|
|
1112
|
+
{ id: "broken", name: "Broken Adapter" }
|
|
1113
|
+
],
|
|
1114
|
+
listModels: async (provider) => {
|
|
1115
|
+
if (provider === "broken") throw new Error("provider not reachable");
|
|
1116
|
+
return [{ id: "deepseek-chat", name: "DeepSeek Chat" }, { id: "deepseek-reasoner", name: "DeepSeek Reasoner" }];
|
|
1117
|
+
},
|
|
1118
|
+
async *stream(options) {
|
|
1119
|
+
MOCK_LLM.lastOptions = options;
|
|
1120
|
+
if (options.model === "bad-model") {
|
|
1121
|
+
yield {
|
|
1122
|
+
type: "finish",
|
|
1123
|
+
reason: { kind: "error", failure: { code: "AUTH_FAILED", message: "invalid api key" } }
|
|
1124
|
+
};
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
if (options.reasoningEffort === "reject") {
|
|
1128
|
+
throw new Error('UNSUPPORTED_REASONING_EFFORT: provider "mock" model "mock-model" does not support reasoning effort "reject"');
|
|
1129
|
+
}
|
|
1130
|
+
yield { type: "text-delta", index: 0, text: " ok " };
|
|
1131
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
|
|
1135
|
+
test("GET /api/dsh-mneme/llm-providers lists host providers with models, per-provider best effort", async () => {
|
|
1136
|
+
const { routes } = setupLlm(MOCK_LLM);
|
|
1137
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/llm-providers");
|
|
1138
|
+
const res = new FakeRes();
|
|
1139
|
+
await route.handler(req("/api/dsh-mneme/llm-providers"), res);
|
|
1140
|
+
assert.equal(res.statusCode, 200);
|
|
1141
|
+
const data = JSON.parse(res.body);
|
|
1142
|
+
assert.equal(data.providers.length, 2);
|
|
1143
|
+
const deepseek = data.providers.find((p) => p.provider === "deepseek");
|
|
1144
|
+
assert.deepEqual(deepseek.models.map((m) => m.id), ["deepseek-chat", "deepseek-reasoner"]);
|
|
1145
|
+
const broken = data.providers.find((p) => p.provider === "broken");
|
|
1146
|
+
assert.deepEqual(broken.models, [], "a provider whose model list throws degrades to empty, not a hard fail");
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
test("GET /api/dsh-mneme/llm-providers without ctx.llm returns 501", async () => {
|
|
1150
|
+
const { routes } = setup();
|
|
1151
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/llm-providers");
|
|
1152
|
+
const res = new FakeRes();
|
|
1153
|
+
await route.handler(req("/api/dsh-mneme/llm-providers"), res);
|
|
1154
|
+
assert.equal(res.statusCode, 501);
|
|
1155
|
+
assert.equal(JSON.parse(res.body).error, "llm-unavailable");
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
test("POST /api/dsh-mneme/test-model succeeds and reports reply + latency", async () => {
|
|
1159
|
+
const { routes } = setupLlm(MOCK_LLM);
|
|
1160
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1161
|
+
const res = new FakeRes();
|
|
1162
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat" }), res);
|
|
1163
|
+
assert.equal(res.statusCode, 200);
|
|
1164
|
+
const data = JSON.parse(res.body);
|
|
1165
|
+
assert.equal(data.ok, true);
|
|
1166
|
+
assert.equal(data.reply, "ok", "reply trimmed");
|
|
1167
|
+
assert.ok(data.durationMs >= 0);
|
|
1168
|
+
assert.equal(data.modelId, "deepseek:deepseek-chat", "modelId reports the probed route");
|
|
1169
|
+
assert.equal(MOCK_LLM.lastOptions.provider, "deepseek");
|
|
1170
|
+
assert.equal(MOCK_LLM.lastOptions.purpose, "dsh-mneme-connectivity-test");
|
|
1171
|
+
assert.equal("reasoningEffort" in MOCK_LLM.lastOptions, false, "no effort configured -> field omitted");
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
test("POST /api/dsh-mneme/test-model forwards reasoningEffort verbatim", async () => {
|
|
1175
|
+
const { routes } = setupLlm(MOCK_LLM);
|
|
1176
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1177
|
+
const res = new FakeRes();
|
|
1178
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat", reasoningEffort: "low" }), res);
|
|
1179
|
+
assert.equal(res.statusCode, 200);
|
|
1180
|
+
assert.equal(MOCK_LLM.lastOptions.reasoningEffort, "low", "effort forwarded to the probe call");
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
test("POST /api/dsh-mneme/test-model reports stream-level failure with 502", async () => {
|
|
1184
|
+
const { routes } = setupLlm(MOCK_LLM);
|
|
1185
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1186
|
+
const res = new FakeRes();
|
|
1187
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "bad-model" }), res);
|
|
1188
|
+
assert.equal(res.statusCode, 502);
|
|
1189
|
+
const data = JSON.parse(res.body);
|
|
1190
|
+
assert.equal(data.ok, false);
|
|
1191
|
+
assert.match(data.error, /AUTH_FAILED/, "stream failure reason surfaced like audit rows");
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
test("POST /api/dsh-mneme/test-model reports thrown failure with 502", async () => {
|
|
1195
|
+
const { routes } = setupLlm(MOCK_LLM);
|
|
1196
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1197
|
+
const res = new FakeRes();
|
|
1198
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "mock", model: "mock-model", reasoningEffort: "reject" }), res);
|
|
1199
|
+
assert.equal(res.statusCode, 502);
|
|
1200
|
+
const data = JSON.parse(res.body);
|
|
1201
|
+
assert.equal(data.ok, false);
|
|
1202
|
+
assert.match(data.error, /UNSUPPORTED_REASONING_EFFORT/, "throw path surfaces the harness rejection");
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
test("POST /api/dsh-mneme/test-model resolves empty body against the consolidation route", async () => {
|
|
1206
|
+
// Empty provider/model = "test whatever consolidation uses now": the route
|
|
1207
|
+
// falls back to agentDefaultModel (config dreamProvider/dreamModel absent),
|
|
1208
|
+
// the probe runs against that model, and modelId reports what was tested.
|
|
1209
|
+
const { routes } = setupLlm(MOCK_LLM, "", {
|
|
1210
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "sel-provider", model: "sel-model" }) }
|
|
1211
|
+
});
|
|
1212
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1213
|
+
const res = new FakeRes();
|
|
1214
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", {}), res);
|
|
1215
|
+
assert.equal(res.statusCode, 200);
|
|
1216
|
+
const data = JSON.parse(res.body);
|
|
1217
|
+
assert.equal(data.ok, true);
|
|
1218
|
+
assert.equal(data.modelId, "sel-provider:sel-model", "probe ran against the resolved consolidation route");
|
|
1219
|
+
assert.equal(MOCK_LLM.lastOptions.provider, "sel-provider");
|
|
1220
|
+
assert.equal(MOCK_LLM.lastOptions.model, "sel-model");
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
test("POST /api/dsh-mneme/test-model validates input and llm availability", async () => {
|
|
1224
|
+
const { routes } = setupLlm(MOCK_LLM);
|
|
1225
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1226
|
+
let res = new FakeRes();
|
|
1227
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek" }), res);
|
|
1228
|
+
assert.equal(res.statusCode, 400, "partial input (provider without model) rejected");
|
|
1229
|
+
assert.equal(JSON.parse(res.body).error, "missing-provider-or-model");
|
|
1230
|
+
|
|
1231
|
+
// empty body with no route (no agentDefaultModel, no config route) -> no-route
|
|
1232
|
+
res = new FakeRes();
|
|
1233
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", {}), res);
|
|
1234
|
+
assert.equal(res.statusCode, 400, "empty body with no resolvable route rejected");
|
|
1235
|
+
assert.equal(JSON.parse(res.body).error, "no-route");
|
|
1236
|
+
|
|
1237
|
+
const { routes: routesNoLlm } = setup();
|
|
1238
|
+
const routeNoLlm = routesNoLlm.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1239
|
+
res = new FakeRes();
|
|
1240
|
+
await routeNoLlm.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat" }), res);
|
|
1241
|
+
assert.equal(res.statusCode, 501, "no ctx.llm -> llm-unavailable");
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
test("POST /api/dsh-mneme/test-model is auth-gated like other expensive endpoints", async () => {
|
|
1245
|
+
const { routes } = setupLlm(MOCK_LLM, "secret-token");
|
|
1246
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1247
|
+
const res = new FakeRes();
|
|
1248
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat" }), res);
|
|
1249
|
+
assert.equal(res.statusCode, 401, "probe spends the user's API quota, so it must require auth");
|
|
1250
|
+
});
|
package/test/client.test.js
CHANGED
|
@@ -147,7 +147,7 @@ test("sidebar entry portals above the workspaces region with footer fallback", (
|
|
|
147
147
|
"the portal entry persists across collapse (no footer jump); footer fallback only covers portal failure"
|
|
148
148
|
);
|
|
149
149
|
assert.ok(
|
|
150
|
-
clientSource.includes('className: `${nativeCls} mneme-topentry-native`'
|
|
150
|
+
clientSource.includes('className: `${nativeCls} mneme-topentry-native`'),
|
|
151
151
|
"the entry must reuse the host New-Session button class for native geometry alignment"
|
|
152
152
|
);
|
|
153
153
|
assert.ok(
|
|
@@ -466,3 +466,74 @@ test("heat badges render from the /list projection and self-hide when off", () =
|
|
|
466
466
|
"toggling heat sort must land on the cards view (sort does not apply to the month tree)"
|
|
467
467
|
);
|
|
468
468
|
});
|
|
469
|
+
|
|
470
|
+
// 巩固/睡眠模型路由 UI:下拉数据来自宿主侧已注册适配器(/llm-providers,
|
|
471
|
+
// 云端插件侧端点),「测试连通性」走 POST /test-model 真实最小调用。旧后端
|
|
472
|
+
// 端点 404 时必须回退纯文本输入——前端自门控,不挡旧版本。
|
|
473
|
+
test("consolidation/sleep model routing: provider dropdowns from /llm-providers, connectivity test via /test-model, graceful fallback", () => {
|
|
474
|
+
// 1. 端点探测与降级
|
|
475
|
+
assert.ok(
|
|
476
|
+
clientSource.includes('apiFetch("/api/dsh-mneme/llm-providers")'),
|
|
477
|
+
"the features card must probe GET /llm-providers for the route dropdowns"
|
|
478
|
+
);
|
|
479
|
+
assert.ok(
|
|
480
|
+
/setRoutes\(Array\.isArray\(j && j\.providers\) \? j\.providers : \[\]\)/.test(clientSource),
|
|
481
|
+
"the probe must only accept a {providers: []} shape"
|
|
482
|
+
);
|
|
483
|
+
assert.ok(
|
|
484
|
+
/Array\.isArray\(routes\)\s*\?\s*routeSelects\("dreamProvider", "dreamModel"\)/.test(clientSource),
|
|
485
|
+
"dream routing must upgrade to dropdowns only when the probe succeeded"
|
|
486
|
+
);
|
|
487
|
+
assert.ok(
|
|
488
|
+
/:\s*h\(react\.Fragment, null, strRow\("dreamProvider"\), strRow\("dreamModel"\)\)/.test(clientSource),
|
|
489
|
+
"when /llm-providers is unavailable the plain text inputs must remain (old-backend fallback)"
|
|
490
|
+
);
|
|
491
|
+
// 2. 睡眠侧行:跟随 sleepModeEnabled 门控,与巩固共用数据源
|
|
492
|
+
assert.ok(
|
|
493
|
+
/const sleepSub = eff\.sleepModeEnabled && Array\.isArray\(routes\) && h\("div", \{ className: "mneme-featsub" \},\s*\n\s*routeSelects\("sleepProvider", "sleepModel"\)/.test(clientSource),
|
|
494
|
+
"the sleep route row must gate on sleepModeEnabled and share the providers source"
|
|
495
|
+
);
|
|
496
|
+
// 3. 连通性测试:真实最小调用 + 结果展示(成功/失败 + 耗时 + 报错原因)
|
|
497
|
+
assert.ok(
|
|
498
|
+
clientSource.includes('apiFetch("/api/dsh-mneme/test-model", {'),
|
|
499
|
+
"the connectivity test must POST /test-model"
|
|
500
|
+
);
|
|
501
|
+
assert.ok(
|
|
502
|
+
/body: JSON\.stringify\(\{ provider, model \}\)/.test(clientSource),
|
|
503
|
+
"the test payload must carry the selected provider/model (empty = follow default route)"
|
|
504
|
+
);
|
|
505
|
+
assert.ok(
|
|
506
|
+
/typeof j\.durationMs === "number" \? j\.durationMs : Date\.now\(\) - started/.test(clientSource),
|
|
507
|
+
"the result duration must prefer the backend's durationMs and fall back to client timing"
|
|
508
|
+
);
|
|
509
|
+
assert.ok(
|
|
510
|
+
/detail: \[j\.modelId, j\.reply \? "「" \+ j\.reply \+ "」" : ""\]\.filter\(Boolean\)\.join\(" · "\)/.test(clientSource),
|
|
511
|
+
"the success line must surface the model's actual reply (backend caps it at 100 chars)"
|
|
512
|
+
);
|
|
513
|
+
assert.ok(
|
|
514
|
+
/detail: \[j\.modelId, j\.error \|\| "HTTP " \+ res\.status\]\.filter\(Boolean\)\.join\(" · "\)/.test(clientSource),
|
|
515
|
+
"the failure line must carry the tested modelId plus the backend error (502/400 bodies)"
|
|
516
|
+
);
|
|
517
|
+
assert.ok(
|
|
518
|
+
clientSource.includes("memory.features.modelTestOk") && clientSource.includes("memory.features.modelTestFail"),
|
|
519
|
+
"the result line must render success/failure with the localized labels"
|
|
520
|
+
);
|
|
521
|
+
// 4. 下拉改动即提交(与 embedProvider 一致),当前值不在枚举时保留为额外选项
|
|
522
|
+
assert.ok(
|
|
523
|
+
/setStrs\(\(c\) => \(\{ \.\.\.c, \[key\]: v \}\)\);\s*\n\s*put\(\{ \[key\]: v \}\)/.test(clientSource),
|
|
524
|
+
"select changes must commit through the features PUT like the embed provider select"
|
|
525
|
+
);
|
|
526
|
+
assert.ok(
|
|
527
|
+
/curM && !mVals\.includes\(curM\) \? h\("option", \{ key: "current", value: curM \}, curM\) : null/.test(clientSource),
|
|
528
|
+
"a configured value missing from the provider's model list must survive as an extra option"
|
|
529
|
+
);
|
|
530
|
+
// 5. 双语 i18n 与样式
|
|
531
|
+
for (const key of ["routeFollowDefault", "modelTest", "modelTesting", "modelTestOk", "modelTestFail", "sleepModelHint"]) {
|
|
532
|
+
const occurrences = clientSource.split(`"memory.features.${key}"`).length - 1;
|
|
533
|
+
assert.ok(occurrences >= 2, `i18n key memory.features.${key} must exist in both zh and en (got ${occurrences})`);
|
|
534
|
+
}
|
|
535
|
+
assert.ok(
|
|
536
|
+
clientSource.includes(".mneme-routeselect{width:240px;max-width:60%}"),
|
|
537
|
+
"the route selects must share the string-input width budget"
|
|
538
|
+
);
|
|
539
|
+
});
|