@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/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 = config.dreamReasoningEffort && config.dreamReasoningEffort !== "none" ? config.dreamReasoningEffort : null;
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.25",
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),
@@ -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 = config.sleepReasoningEffort && config.sleepReasoningEffort !== "none" ? config.sleepReasoningEffort : null;
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 = config.sleepReasoningEffort && config.sleepReasoningEffort !== "none" ? config.sleepReasoningEffort : null;
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 = config.dreamReasoningEffort && config.dreamReasoningEffort !== "none" ? config.dreamReasoningEffort : null;
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 覆盖全部 35 个白名单键(含 v0.7.20 heatEnabled、Issue #89 新增
531
- // dreamSkipInvalid/allowCrossTypeMerge/dreamMinIntervalMinutes 与本轮补入面板
532
- // dreamMaxTokens),未覆盖时取 bundle 配置的解析默认值;
533
- // dreamProvider/dreamModel 无 schema 默认值(Config({}) 解析为 undefined),
534
- // 不编造给前端35 - 2 = 33
535
- assert.equal(Object.keys(data.effective).length, 33);
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
+ });
@@ -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`'.replace("${nativeCls} mneme-topentry-native", "${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
+ });