@modusensus/dsh-mneme 0.7.24 → 0.7.26

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/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/lib/tools.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import { defineTool } from "@deepseek-ai/dsh-tools";
2
2
 
3
3
  const TEXT_OUTPUT = (text) => [{ type: "text", text }];
4
+ // Per-registry tool-name registry: guards against duplicate registration on
5
+ // live patch reload (DSH Desktop `patchReload: "live"`) where a plugin may be
6
+ // re-applied on the same tools registry without an intervening unregister.
7
+ const REGISTERED_TOOLS = new WeakMap();
4
8
 
5
9
  // Wire shape emitted by service.toApiList: shared by memory_search and
6
10
  // memory_list so their output schemas always declare every key the runtime
@@ -22,6 +26,12 @@ const MEMORY_ITEM_SCHEMA = {
22
26
  };
23
27
 
24
28
  export function createTools(ctx, service, config, embedder) {
29
+ const toolsRegistry = ctx.tools;
30
+ let registeredTools = REGISTERED_TOOLS.get(toolsRegistry);
31
+ if (!registeredTools) {
32
+ registeredTools = new Set();
33
+ REGISTERED_TOOLS.set(toolsRegistry, registeredTools);
34
+ }
25
35
  const tools = [
26
36
  defineTool({
27
37
  name: "memory_save",
@@ -82,7 +92,18 @@ export function createTools(ctx, service, config, embedder) {
82
92
  }
83
93
  }
84
94
  },
85
- render: (_args, value) => TEXT_OUTPUT(`Found ${value.items.length} memory entr${value.items.length === 1 ? "y" : "ies"}.`)
95
+ render: (_args, value) => {
96
+ const items = value.items ?? [];
97
+ if (items.length === 0) return TEXT_OUTPUT("No memory entries found.");
98
+ const body = items
99
+ .map((m, i) => {
100
+ const preview = (m.content ?? "").replace(/\s+/g, " ").trim();
101
+ const cut = preview.length > 200 ? `${preview.slice(0, 200)}…` : preview;
102
+ return `[${i + 1}] ${m.title}\n ID: ${m.id} | type: ${m.type} | importance: ${m.importance} | updated: ${m.updated_at}\n ${cut}`;
103
+ })
104
+ .join("\n\n");
105
+ return TEXT_OUTPUT(`Found ${items.length} memory entr${items.length === 1 ? "y" : "ies"}:\n\n${body}`);
106
+ }
86
107
  },
87
108
  async execute(args) {
88
109
  const limit = args.limit ?? 20;
@@ -117,7 +138,14 @@ export function createTools(ctx, service, config, embedder) {
117
138
  total: { type: "integer", required: true }
118
139
  }
119
140
  },
120
- render: (_args, value) => TEXT_OUTPUT(`${value.items.length} memory entries (of ${value.total}).`)
141
+ render: (_args, value) => {
142
+ const items = value.items ?? [];
143
+ if (items.length === 0) return TEXT_OUTPUT(`0 memory entries (of ${value.total}).`);
144
+ const body = items
145
+ .map((m, i) => `[${i + 1}] ${m.title} (type=${m.type}, importance=${m.importance})\n ID: ${m.id} | updated: ${m.updated_at}`)
146
+ .join("\n\n");
147
+ return TEXT_OUTPUT(`${items.length} memory entries (of ${value.total}):\n\n${body}`);
148
+ }
121
149
  },
122
150
  async execute(args) {
123
151
  const includeArchived = args.include_archived === true;
@@ -131,6 +159,46 @@ export function createTools(ctx, service, config, embedder) {
131
159
  }
132
160
  }),
133
161
 
162
+ defineTool({
163
+ name: "memory_get",
164
+ description: "Fetch one memory entry by ID and return its full content as text. Use after memory_list to read a specific entry.",
165
+ parameters: {
166
+ id: { type: "string", required: true, description: "Memory id" }
167
+ },
168
+ output: {
169
+ schema: {
170
+ type: "object",
171
+ additionalProperties: false,
172
+ properties: {
173
+ memory: {
174
+ type: "object",
175
+ additionalProperties: false,
176
+ properties: {
177
+ id: { type: "string", required: true },
178
+ title: { type: "string", required: true },
179
+ type: { type: "string", required: true },
180
+ importance: { type: "integer", required: true },
181
+ tags: { type: "array", items: { type: "string" } },
182
+ content: { type: "string", required: true },
183
+ source: { type: "string" },
184
+ created_at: { type: "string" },
185
+ updated_at: { type: "string" }
186
+ }
187
+ }
188
+ }
189
+ },
190
+ render: (_args, value) => {
191
+ const m = value.memory;
192
+ return TEXT_OUTPUT(`${m.title}\nID: ${m.id} | type: ${m.type} | importance: ${m.importance}\n\n${m.content}`);
193
+ }
194
+ },
195
+ async execute(args) {
196
+ const memory = service.getById(args.id);
197
+ if (memory === undefined) throw new Error("memory not found");
198
+ return { memory: service.toApiList([memory])[0] };
199
+ }
200
+ }),
201
+
134
202
  defineTool({
135
203
  name: "memory_update",
136
204
  description: "Modify an existing memory entry (title, content, type, tags, importance).",
@@ -267,7 +335,12 @@ export function createTools(ctx, service, config, embedder) {
267
335
  ];
268
336
 
269
337
  for (const tool of tools) {
270
- ctx.tools.register(tool);
338
+ if (registeredTools.has(tool.name)) {
339
+ ctx.logger?.warn?.(`[dsh-mneme] tool "${tool.name}" already registered, skipping duplicate`);
340
+ continue;
341
+ }
342
+ registeredTools.add(tool.name);
343
+ ctx.tools.register(tool);
271
344
  }
272
345
 
273
346
  return tools;
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.24",
4
+ "version": "0.7.26",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -37,9 +37,7 @@
37
37
  "client": {
38
38
  "inject": [
39
39
  "slots",
40
- "locale",
41
- "layout",
42
- "connection"
40
+ "locale"
43
41
  ],
44
42
  "platform": "web"
45
43
  },
@@ -54,7 +52,7 @@
54
52
  "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
55
53
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
56
54
  "@deepseek-ai/schemastery": "^3.18.1",
57
- "dsh-better-sidebar": "*"
55
+ "dsh-better-sidebar": "^0.18.0"
58
56
  },
59
57
  "peerDependenciesMeta": {
60
58
  "dsh-better-sidebar": {
@@ -16,6 +16,46 @@ const DEFAULT_HOST = "127.0.0.1";
16
16
  // Hardcoded release version (package.json is bumped at publish time and may
17
17
  // lag the code that ships in between).
18
18
  const VERSION = "0.7.12";
19
+ const MAX_PORT_ATTEMPTS = 20;
20
+
21
+ /**
22
+ * Listen with automatic EADDRINUSE recovery. Tries the configured port, then
23
+ * the next MAX_PORT_ATTEMPTS-1 ports, and finally falls back to port 0 so the
24
+ * OS assigns a free port. Multiple DSH profiles/instances sharing the default
25
+ * port no longer leave the standalone API permanently unavailable.
26
+ */
27
+ function listenWithRetry(server, startPort, host, logger) {
28
+ return new Promise((resolve, reject) => {
29
+ let attempt = 0;
30
+ const tryListen = (port) => {
31
+ const onListening = () => {
32
+ server.off("error", onError);
33
+ const address = server.address();
34
+ resolve(address && typeof address === "object" ? address.port : port);
35
+ };
36
+ const onError = (error) => {
37
+ server.off("listening", onListening);
38
+ if (error?.code === "EADDRINUSE" && attempt < MAX_PORT_ATTEMPTS - 1) {
39
+ attempt++;
40
+ const next = startPort + attempt;
41
+ logger?.warn?.(`[dsh-mneme] standalone API port ${port} in use, retrying ${next}`);
42
+ tryListen(next);
43
+ return;
44
+ }
45
+ if (error?.code === "EADDRINUSE") {
46
+ logger?.warn?.(`[dsh-mneme] standalone API port ${port} still in use, falling back to OS-assigned port`);
47
+ tryListen(0);
48
+ return;
49
+ }
50
+ reject(error);
51
+ };
52
+ server.once("listening", onListening);
53
+ server.once("error", onError);
54
+ server.listen(port, host);
55
+ };
56
+ tryListen(startPort);
57
+ });
58
+ }
19
59
 
20
60
  function sendJson(res, status, payload) {
21
61
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
@@ -243,10 +283,10 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
243
283
  logger?.warn?.(`[dsh-mneme] standalone API error: ${String(error)}`);
244
284
  });
245
285
  const ready = new Promise((resolve, reject) => {
246
- server.once("listening", resolve);
247
- server.once("error", reject);
286
+ // listening handled by listenWithRetry
287
+ listenWithRetry(server, boundPort, boundHost, logger).then(resolve, reject);
248
288
  });
249
- server.listen(boundPort, boundHost);
289
+ // server.listen is called inside listenWithRetry
250
290
  ready.then(() => {
251
291
  const address = server.address();
252
292
  if (address && typeof address === "object") {
package/src/config.js CHANGED
@@ -52,20 +52,33 @@ export const Config = z.object({
52
52
  // 起算,失败/degraded 的 run 也占用间隔;间隔内的触发请求静默跳过,下一次
53
53
  // 写入事件会重新评估。
54
54
  dreamMinIntervalMinutes: z.natural().min(0).max(10080).default(0),
55
- dreamProvider: z.string(),
56
- dreamModel: z.string(),
55
+ // 巩固模型路由(settings panel「巩固模型」/ dreamProvider+dreamModel):
56
+ // dream 的记忆沉淀专用 LLM 路由,显式配置优先于 agent 默认模型(config-first,
57
+ // Issue #25)。模型分类声明:
58
+ // - 非思考模型(推荐,如 glm-5-2 类):无 reasoning 声明,effort 请求被 harness
59
+ // 拒绝后 withEffortFallback 去掉字段重试即成功;空体/no json array 风险最低。
60
+ // - 思考模型(如 deepseek-v4-flash-ga 等 v4-flash-ga 系):默认开推理,可能烧光
61
+ // token 预算返回空体;且部分(如 v4-flash-ga)在 harness 侧被声明为不接受任何
62
+ // reasoning effort —— 即使去掉 effort 重试,harness 的 defaultEffort 也会顶上来
63
+ // 再次拒绝(UNSUPPORTED_REASONING_EFFORT),插件 fallback 无法绕开。
64
+ // 选用时建议配 dreamReasoningEffort 并实测;不行就换非思考模型。
65
+ dreamProvider: z.string().description("记忆巩固专用模型的服务商(settings「巩固模型」)。巩固反复失败时,优先改用官方非思考模型的服务商(如 deepseek / glm)。"),
66
+ dreamModel: z.string().description("记忆巩固专用模型。建议选非思考模型(如 deepseek-chat、glm-5-2 类):思考模型可能烧光 token 预算返回空体,导致巩固失败(UNSUPPORTED_REASONING_EFFORT)。"),
57
67
  dreamMaxTokens: z.natural().min(256).max(131072).default(32768),
58
68
  // Pass-through reasoning effort for dream's LLM calls. 'none' (default)
59
69
  // omits the field so the provider's own default applies; low/medium/high
60
70
  // are forwarded verbatim. Useful to cap reasoning spend on thinking-type
61
71
  // models that would otherwise drain the whole token budget and return an
62
72
  // empty body ("no json array in llm output").
73
+ // Caveat: on some thinking models (e.g. v4-flash-ga) the harness declares NO
74
+ // supported effort, so even the fallback retry (field stripped) is rejected
75
+ // again via its defaultEffort — prefer a non-reasoning dreamProvider/dreamModel.
63
76
  dreamReasoningEffort: z.union([
64
77
  z.const("low"),
65
78
  z.const("medium"),
66
79
  z.const("high"),
67
80
  z.const("none")
68
- ]).default("none"),
81
+ ]).default("none").description("巩固模型的推理档位:'none'(默认)用服务商自带默认;low/medium/high 原样传递。模型不支持的值会自动换用其支持的档位(v0.7.26+)。"),
69
82
  // 滑动窗口上限(v0.4.4):autoDream 每次只对最近 dreamMaxSnapshotSize 条
70
83
  // 记忆做 consolidation。大记忆量下全量快照会把 LLM 输入撑爆(636 记忆 →
71
84
  // 677 "missing" errors、applied=0),窗口外的旧记忆不进 snapshot。
@@ -238,8 +251,8 @@ export const Config = z.object({
238
251
  sleepMaxPatternPerRun: z.natural().min(0).max(10).default(3),
239
252
  // Optional LLM route override for sleep's bulk passes (empty = use dream
240
253
  // route / agent default model).
241
- sleepProvider: z.string().default(""),
242
- sleepModel: z.string().default(""),
254
+ sleepProvider: z.string().default("").description("sleep 深维护专用模型服务商,留空用巩固模型或当前模型。"),
255
+ sleepModel: z.string().default("").description("sleep 深维护专用模型,留空用巩固模型或当前模型;建议同巩固模型选非思考模型。"),
243
256
  // Pass-through reasoning effort for sleep's LLM passes, same semantics as
244
257
  // dreamReasoningEffort: 'none' (default) omits the field; low/medium/high
245
258
  // are forwarded verbatim.
@@ -248,7 +261,7 @@ export const Config = z.object({
248
261
  z.const("medium"),
249
262
  z.const("high"),
250
263
  z.const("none")
251
- ]).default("none"),
264
+ ]).default("none").description("同 dreamReasoningEffort:sleep 各阶段 LLM 的推理档位,'none' 用服务商默认。"),
252
265
 
253
266
  // --- epistemic trust: memory source credibility (v0.4.5) -----------------
254
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 };
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/src/tools.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import { defineTool } from "@deepseek-ai/dsh-tools";
2
2
 
3
3
  const TEXT_OUTPUT = (text) => [{ type: "text", text }];
4
+ // Per-registry tool-name registry: guards against duplicate registration on
5
+ // live patch reload (DSH Desktop `patchReload: "live"`) where a plugin may be
6
+ // re-applied on the same tools registry without an intervening unregister.
7
+ const REGISTERED_TOOLS = new WeakMap();
4
8
 
5
9
  // Wire shape emitted by service.toApiList: shared by memory_search and
6
10
  // memory_list so their output schemas always declare every key the runtime
@@ -22,6 +26,12 @@ const MEMORY_ITEM_SCHEMA = {
22
26
  };
23
27
 
24
28
  export function createTools(ctx, service, config, embedder) {
29
+ const toolsRegistry = ctx.tools;
30
+ let registeredTools = REGISTERED_TOOLS.get(toolsRegistry);
31
+ if (!registeredTools) {
32
+ registeredTools = new Set();
33
+ REGISTERED_TOOLS.set(toolsRegistry, registeredTools);
34
+ }
25
35
  const tools = [
26
36
  defineTool({
27
37
  name: "memory_save",
@@ -82,7 +92,18 @@ export function createTools(ctx, service, config, embedder) {
82
92
  }
83
93
  }
84
94
  },
85
- render: (_args, value) => TEXT_OUTPUT(`Found ${value.items.length} memory entr${value.items.length === 1 ? "y" : "ies"}.`)
95
+ render: (_args, value) => {
96
+ const items = value.items ?? [];
97
+ if (items.length === 0) return TEXT_OUTPUT("No memory entries found.");
98
+ const body = items
99
+ .map((m, i) => {
100
+ const preview = (m.content ?? "").replace(/\s+/g, " ").trim();
101
+ const cut = preview.length > 200 ? `${preview.slice(0, 200)}…` : preview;
102
+ return `[${i + 1}] ${m.title}\n ID: ${m.id} | type: ${m.type} | importance: ${m.importance} | updated: ${m.updated_at}\n ${cut}`;
103
+ })
104
+ .join("\n\n");
105
+ return TEXT_OUTPUT(`Found ${items.length} memory entr${items.length === 1 ? "y" : "ies"}:\n\n${body}`);
106
+ }
86
107
  },
87
108
  async execute(args) {
88
109
  const limit = args.limit ?? 20;
@@ -117,7 +138,14 @@ export function createTools(ctx, service, config, embedder) {
117
138
  total: { type: "integer", required: true }
118
139
  }
119
140
  },
120
- render: (_args, value) => TEXT_OUTPUT(`${value.items.length} memory entries (of ${value.total}).`)
141
+ render: (_args, value) => {
142
+ const items = value.items ?? [];
143
+ if (items.length === 0) return TEXT_OUTPUT(`0 memory entries (of ${value.total}).`);
144
+ const body = items
145
+ .map((m, i) => `[${i + 1}] ${m.title} (type=${m.type}, importance=${m.importance})\n ID: ${m.id} | updated: ${m.updated_at}`)
146
+ .join("\n\n");
147
+ return TEXT_OUTPUT(`${items.length} memory entries (of ${value.total}):\n\n${body}`);
148
+ }
121
149
  },
122
150
  async execute(args) {
123
151
  const includeArchived = args.include_archived === true;
@@ -131,6 +159,46 @@ export function createTools(ctx, service, config, embedder) {
131
159
  }
132
160
  }),
133
161
 
162
+ defineTool({
163
+ name: "memory_get",
164
+ description: "Fetch one memory entry by ID and return its full content as text. Use after memory_list to read a specific entry.",
165
+ parameters: {
166
+ id: { type: "string", required: true, description: "Memory id" }
167
+ },
168
+ output: {
169
+ schema: {
170
+ type: "object",
171
+ additionalProperties: false,
172
+ properties: {
173
+ memory: {
174
+ type: "object",
175
+ additionalProperties: false,
176
+ properties: {
177
+ id: { type: "string", required: true },
178
+ title: { type: "string", required: true },
179
+ type: { type: "string", required: true },
180
+ importance: { type: "integer", required: true },
181
+ tags: { type: "array", items: { type: "string" } },
182
+ content: { type: "string", required: true },
183
+ source: { type: "string" },
184
+ created_at: { type: "string" },
185
+ updated_at: { type: "string" }
186
+ }
187
+ }
188
+ }
189
+ },
190
+ render: (_args, value) => {
191
+ const m = value.memory;
192
+ return TEXT_OUTPUT(`${m.title}\nID: ${m.id} | type: ${m.type} | importance: ${m.importance}\n\n${m.content}`);
193
+ }
194
+ },
195
+ async execute(args) {
196
+ const memory = service.getById(args.id);
197
+ if (memory === undefined) throw new Error("memory not found");
198
+ return { memory: service.toApiList([memory])[0] };
199
+ }
200
+ }),
201
+
134
202
  defineTool({
135
203
  name: "memory_update",
136
204
  description: "Modify an existing memory entry (title, content, type, tags, importance).",
@@ -267,7 +335,12 @@ export function createTools(ctx, service, config, embedder) {
267
335
  ];
268
336
 
269
337
  for (const tool of tools) {
270
- ctx.tools.register(tool);
338
+ if (registeredTools.has(tool.name)) {
339
+ ctx.logger?.warn?.(`[dsh-mneme] tool "${tool.name}" already registered, skipping duplicate`);
340
+ continue;
341
+ }
342
+ registeredTools.add(tool.name);
343
+ ctx.tools.register(tool);
271
344
  }
272
345
 
273
346
  return tools;
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);
@@ -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(
@@ -235,7 +235,7 @@ test("better-sidebar tab mounts via an inner sub-plugin, standalone mode intact"
235
235
  "the inner apply must still guard the service shape before registering"
236
236
  );
237
237
  assert.ok(
238
- /id: "dsh-mneme:memory"/.test(clientSource),
238
+ /(?:const TAB_ID = |id: )"dsh-mneme:memory"/.test(clientSource),
239
239
  "the registered tab id must be package-prefixed"
240
240
  );
241
241
  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
+ });