@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.
@@ -413,3 +413,166 @@ test("sleep passes the stream failure accessor so a stream-level effort rejectio
413
413
  assert.equal("reasoningEffort" in captured[1], false, "conflict retry omits the rejected effort field");
414
414
  store.close();
415
415
  });
416
+
417
+ // ------------------------------------------------------------------ defaultEffort trap
418
+ // DSH Desktop's volcano-engine adapter declares reasoning.defaultEffort="low"
419
+ // for a model that rejects "low", so omitting the field is NOT a safe retry —
420
+ // the harness substitutes the poison default and fails again. resolveDreamEffort
421
+ // queries resolveModelInfo up front and forwards a value that is actually in
422
+ // the model's declared efforts, so the first attempt already carries a
423
+ // supported effort and never trips UNSUPPORTED_REASONING_EFFORT.
424
+
425
+ test("defaultEffort trap: configured 'low' remapped to the first supported effort when default is poison", async () => {
426
+ const store = createStore(":memory:");
427
+ const service = createService({ store, mirror: null, config: {} });
428
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
429
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
430
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
431
+ const captured = [];
432
+ const infoCalls = [];
433
+ const ctx = dreamCtx({
434
+ captured,
435
+ onConsolidation: () => JSON.stringify([
436
+ { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
437
+ ])
438
+ });
439
+ // The adapter's capability report: defaultEffort "low" is NOT in efforts
440
+ // (the model rejects it) — exactly the volcano-engine/deepseek-v4-flash trap.
441
+ ctx.llm.resolveModelInfo = async (provider, model) => {
442
+ infoCalls.push([provider, model]);
443
+ return {
444
+ provider,
445
+ model,
446
+ reasoning: {
447
+ efforts: [{ id: "medium" }, { id: "high" }],
448
+ defaultEffort: "low"
449
+ }
450
+ };
451
+ };
452
+ const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "low" });
453
+ assert.equal(result.ok, true, "run succeeds without ever tripping the poison default");
454
+ assert.ok(result.applied > 0, "consolidation lands changes");
455
+ assert.deepEqual(infoCalls[0], ["mock", "mock-model"], "capability queried for the exact dream route");
456
+ assert.equal(captured[0].reasoningEffort, "medium", "poison 'low' remapped to the first supported effort");
457
+ assert.equal(captured[1].reasoningEffort, "medium", "summary pass uses the same resolved effort");
458
+ store.close();
459
+ });
460
+
461
+ test("defaultEffort trap: model with no reasoning capability omits the field entirely", async () => {
462
+ const store = createStore(":memory:");
463
+ const service = createService({ store, mirror: null, config: {} });
464
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
465
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
466
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
467
+ const captured = [];
468
+ const ctx = dreamCtx({
469
+ captured,
470
+ onConsolidation: () => JSON.stringify([
471
+ { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
472
+ ])
473
+ });
474
+ // Non-thinking model (e.g. deepseek-v4-flash): adapter reports no reasoning
475
+ // capability, so ANY explicit effort would be rejected — the helper must
476
+ // drop it, which is the harness's safe "no reasoning" path.
477
+ ctx.llm.resolveModelInfo = async () => ({ provider: "mock", model: "mock-model", reasoning: undefined });
478
+ const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "high" });
479
+ assert.equal(result.ok, true);
480
+ for (const options of captured) {
481
+ assert.equal("reasoningEffort" in options, false, "no reasoning capability -> effort omitted, never rejected");
482
+ }
483
+ store.close();
484
+ });
485
+
486
+ test("defaultEffort trap: configured effort supported is forwarded verbatim", async () => {
487
+ const store = createStore(":memory:");
488
+ const service = createService({ store, mirror: null, config: {} });
489
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
490
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
491
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
492
+ const captured = [];
493
+ const ctx = dreamCtx({
494
+ captured,
495
+ onConsolidation: () => JSON.stringify([
496
+ { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
497
+ ])
498
+ });
499
+ ctx.llm.resolveModelInfo = async () => ({
500
+ provider: "mock",
501
+ model: "mock-model",
502
+ reasoning: { efforts: [{ id: "high" }, { id: "low" }], defaultEffort: "low" }
503
+ });
504
+ const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "high" });
505
+ assert.equal(result.ok, true);
506
+ for (const options of captured) {
507
+ assert.equal(options.reasoningEffort, "high", "supported configured value untouched");
508
+ }
509
+ store.close();
510
+ });
511
+
512
+ test("defaultEffort trap: capability query failure falls back to configured effort (retry still guards)", async () => {
513
+ const store = createStore(":memory:");
514
+ const service = createService({ store, mirror: null, config: {} });
515
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
516
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
517
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
518
+ const calls = [];
519
+ const warnings = [];
520
+ const ctx = {
521
+ logger: { warn: (m) => warnings.push(String(m)) },
522
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
523
+ llm: {
524
+ async *stream(options) {
525
+ calls.push(options);
526
+ if (options.reasoningEffort) {
527
+ throw new Error("UNSUPPORTED_REASONING_EFFORT: mock does not support reasoning effort \"high\"");
528
+ }
529
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
530
+ if (userText.startsWith("id=")) {
531
+ yield { type: "text-delta", index: 0, text: JSON.stringify([
532
+ { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
533
+ ]) };
534
+ } else {
535
+ yield { type: "text-delta", index: 0, text: "记忆库总览:用户偏好中文。" };
536
+ }
537
+ yield { type: "finish", reason: { kind: "stop" } };
538
+ },
539
+ // Adapter knows nothing about the model — helper must not crash, and the
540
+ // configured effort flows through so withEffortFallback still retries.
541
+ resolveModelInfo: async () => { throw new Error("adapter not reachable"); }
542
+ }
543
+ };
544
+ const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "high" });
545
+ assert.equal(result.ok, true, "run succeeds via the no-effort retry");
546
+ assert.equal(calls[0].reasoningEffort, "high", "configured effort forwarded when capability query fails");
547
+ assert.equal("reasoningEffort" in calls[1], false, "rejected effort retried without the field");
548
+ assert.ok(warnings.some((w) => w.includes("resolveModelInfo failed")), "capability-query failure is logged");
549
+ store.close();
550
+ });
551
+
552
+ test("defaultEffort trap: sleep conflict pass remaps a poison effort too", async () => {
553
+ const { store, service, vectorIndex } = sleepSetup();
554
+ const a = service.saveWithDedupe({ type: "project", title: "主题X", content: "内容A 关于主题X", importance: 3 }).memory;
555
+ const b = service.saveWithDedupe({ type: "project", title: "主题X副本", content: "内容B 关于主题X", importance: 3 }).memory;
556
+ vectorIndex.saveEmbedding(a.id, [1, 0, 0]);
557
+ vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
558
+ const captured = [];
559
+ const ctx = sleepCtx(
560
+ (userText) => userText.startsWith("候选冲突")
561
+ ? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
562
+ : "[]",
563
+ { provider: "mock", model: "sleep-model" },
564
+ captured
565
+ );
566
+ ctx.llm.resolveModelInfo = async (provider, model) => ({
567
+ provider,
568
+ model,
569
+ reasoning: { efforts: [{ id: "medium" }, { id: "high" }], defaultEffort: "low" }
570
+ });
571
+ const result = await runSleep(ctx, service, baseConfig({ sleepReasoningEffort: "low" }), ctx.logger, { embedder, vectorIndex }, null);
572
+ assert.equal(result.status, "ok");
573
+ assert.ok(captured.length >= 2, "conflict + pattern passes both hit the LLM");
574
+ for (const options of captured) {
575
+ assert.equal(options.reasoningEffort, "medium", "poison 'low' remapped on sleep passes too");
576
+ }
577
+ store.close();
578
+ });
@@ -44,15 +44,15 @@ function walkSchema(node, path, problems) {
44
44
  }
45
45
  }
46
46
 
47
- test("registers seven tools with correct names", () => {
47
+ test("registers eight tools with correct names", () => {
48
48
  const { registered } = setup();
49
49
  const names = registered.map((t) => t.name).sort();
50
- assert.deepEqual(names, ["memory_archive", "memory_delete", "memory_forget", "memory_list", "memory_save", "memory_search", "memory_update"]);
50
+ assert.deepEqual(names, ["memory_archive", "memory_delete", "memory_forget", "memory_get", "memory_list", "memory_save", "memory_search", "memory_update"]);
51
51
  });
52
52
 
53
53
  test("compiled schemas pass the enforced DSH subset (defineTool projection)", () => {
54
54
  const { registered } = setup();
55
- assert.equal(registered.length, 7);
55
+ assert.equal(registered.length, 8);
56
56
  for (const tool of registered) {
57
57
  assertSupportedJsonSchema(tool.parameters);
58
58
  assertSupportedJsonSchema(tool.output.schema);
@@ -91,6 +91,57 @@ test("memory_search finds by CJK substring", async () => {
91
91
  assert.equal(result.items[0].title, "记忆插件");
92
92
  });
93
93
 
94
+ // Regression: memory_get.execute must live on the defineTool options (top
95
+ // level), NOT nested inside output — a misplaced execute silently becomes
96
+ // options.execute === undefined and every call throws "userExecute is not a
97
+ // function" while tests that only count tool names still pass.
98
+ test("memory_get returns the full body via execute and render", async () => {
99
+ const { registered, service } = setup();
100
+ const { memory } = service.saveWithDedupe({ type: "decision", title: "t", content: "完整正文内容" });
101
+ const get = registered.find((t) => t.name === "memory_get");
102
+ const res = await get.execute({ id: memory.id });
103
+ assert.equal(res.memory.id, memory.id);
104
+ assert.equal(res.memory.content, "完整正文内容");
105
+ assert.deepEqual(validateJsonSchemaValue(get.output.schema, res), []);
106
+ const text = get.output.render({}, res)[0].text;
107
+ assert.ok(text.includes("完整正文内容"), "full body in render");
108
+ assert.ok(text.includes(memory.id) && text.includes("t"), "id + title in render");
109
+ });
110
+
111
+ test("memory_get on missing id rejects", async () => {
112
+ const { registered } = setup();
113
+ const get = registered.find((t) => t.name === "memory_get");
114
+ await assert.rejects(() => get.execute({ id: "missing" }), /memory not found/);
115
+ });
116
+
117
+ // Render output is what hosts surface to the model (not the structured JSON),
118
+ // so it must embed titles + body previews, not just a hit count.
119
+ test("memory_search render embeds titles and body previews, not just a count", async () => {
120
+ const { registered, service } = setup();
121
+ service.saveWithDedupe({ type: "history", title: "旅行计划", content: "用户当前最苦恼时间安排与伦敦行程" });
122
+ service.saveWithDedupe({ type: "project", title: "插件定位", content: "dsh-mneme 插件做记忆沉淀" });
123
+ const search = registered.find((t) => t.name === "memory_search");
124
+ const res = await search.execute({ query: "时间" });
125
+ assert.ok(res.items.length >= 1, "search hit exists");
126
+ const text = search.output.render({}, res)[0].text;
127
+ assert.match(text, /Found \d+ memory entr/);
128
+ assert.ok(text.includes("旅行计划"), "title embedded in render");
129
+ assert.ok(text.includes("伦敦行程"), "body preview embedded in render");
130
+ assert.ok(text.includes("ID: "), "id embedded");
131
+ });
132
+
133
+ test("memory_list render embeds titles and ids, not just counts", async () => {
134
+ const { registered, service } = setup();
135
+ service.saveWithDedupe({ type: "preference", title: "昵称", content: "桉桉" });
136
+ service.saveWithDedupe({ type: "project", title: "博客", content: "modusensus" });
137
+ const list = registered.find((t) => t.name === "memory_list");
138
+ const res = await list.execute({});
139
+ const text = list.output.render({}, res)[0].text;
140
+ assert.match(text, /\d+ memory entries \(of \d+\):/);
141
+ assert.ok(text.includes("昵称") && text.includes("博客"), "titles embedded");
142
+ assert.ok(text.includes("ID: "), "ids embedded");
143
+ });
144
+
94
145
  test("memory_list filters by type", async () => {
95
146
  const { registered, service } = setup();
96
147
  service.saveWithDedupe({ type: "preference", title: "a", content: "x" });