@modusensus/dsh-mneme 0.4.1 → 0.4.3-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/summarize.js CHANGED
@@ -100,9 +100,12 @@ export function createSummarizer(ctx, service, config) {
100
100
  inFlight.set(session.id, controller);
101
101
  try {
102
102
  const header = session.requestHeader?.()?.config;
103
- const route = header?.provider && header?.model
104
- ? { provider: header.provider, model: header.model }
105
- : undefined;
103
+ // Config override takes priority, then session header, then nothing.
104
+ const route = (config.summarizeProvider && config.summarizeModel)
105
+ ? { provider: config.summarizeProvider, model: config.summarizeModel }
106
+ : (header?.provider && header?.model)
107
+ ? { provider: header.provider, model: header.model }
108
+ : undefined;
106
109
  if (!route) return;
107
110
  const messages = collectMessages(session);
108
111
  if (!messages.length) return;
@@ -1,6 +1,6 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { mkdtempSync, rmSync } from "node:fs";
3
+ import { mkdtempSync, rmSync, existsSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { EventEmitter } from "node:events";
@@ -464,3 +464,36 @@ test("V0.3.6-F1: 旧库(v0.3.5 5 列)打开自动 ALTER 加 3 列,不丢
464
464
  rmSync(dir, { recursive: true, force: true });
465
465
  }
466
466
  });
467
+
468
+ test("v0.3.9-D: mirror 逐 type 物理终态——兄弟 type 失败不误标已提交 type", () => {
469
+ const { dir, store, mirror, service } = setup();
470
+ try {
471
+ // 注入逐 type 故障:project 写成功、decision 抛错(模拟 EISDIR),其余 type 正常。
472
+ // 对应审计 D:project 文件已物理提交、decision 失败,状态必须逐 type 记录——
473
+ // 不能像旧逻辑那样整体批量标 failed。
474
+ const original = mirror.sync.bind(mirror);
475
+ mirror.sync = (memories) => {
476
+ const results = original(memories);
477
+ results.decision = { ok: false, error: "EISDIR: decision.md is a directory" };
478
+ return results;
479
+ };
480
+ // project 与 decision 都有真实记忆 → 触发逐 type 渲染
481
+ store.save({ id: "mem-project", type: "project", title: "已提交", content: "物理写入", importance: 3, tags: [] });
482
+ store.save({ id: "mem-decision", type: "decision", title: "写失败", content: "此 type 失败", importance: 3, tags: [] });
483
+ // 直接调 service 内部 syncMirror(通过一次写触发)
484
+ service.saveWithDedupe({ type: "project", title: "触发", content: "sync", importance: 3 });
485
+
486
+ const ts = store.getTypeStatus();
487
+ // project 物理提交 → 必须 committed,不能被 decision 失败拖成 failed
488
+ assert.equal(ts.project.status, "committed", "物理已提交的 type 必须标记 committed");
489
+ assert.equal(ts.decision.status, "failed", "失败的 type 必须标记 failed");
490
+ // 部分失败 = 未完全收敛 → dirty 必须持久
491
+ assert.equal(store.getMirrorState().dirty, true, "部分 type 失败必须持久 dirty");
492
+ // 镜像里 project 文件真实存在(物理终态已落地,不是整体失败)
493
+ const projectFile = mirror.filePath("project");
494
+ assert.ok(existsSync(projectFile), "project 镜像文件必须已物理写入");
495
+ } finally {
496
+ store.close();
497
+ rmSync(dir, { recursive: true, force: true });
498
+ }
499
+ });
@@ -146,3 +146,45 @@ test("peer-D: generation 上界与负数拒绝", () => {
146
146
  rmSync(dir, { recursive: true, force: true });
147
147
  }
148
148
  });
149
+
150
+ test("v0.3.9-A: compareAndUpdate 的 CAS UPDATE 与 generation 同事务——miss 不得递增", () => {
151
+ const { dir, store } = setup();
152
+ try {
153
+ const saved = store.save({ type: "project", title: "CAS 原子", content: "v0" });
154
+ const before = store.getById(saved.id);
155
+ const genBefore = store.getMirrorState().generation;
156
+
157
+ // 成功 CAS:业务写入 + generation 递增必须一次提交(同事务)
158
+ const updated = store.compareAndUpdate(saved.id, before.updated_at, { content: "v1" });
159
+ assert.ok(updated, "当前版本 CAS 必须成功");
160
+ const genAfterOk = store.getMirrorState().generation;
161
+ assert.equal(genAfterOk, genBefore + 1, "成功 CAS 必须恰好递增一次 generation");
162
+
163
+ // miss CAS:不写任何东西,generation 也不得递增
164
+ const stale = store.getById(saved.id).updated_at; // v1 的 token
165
+ store.compareAndUpdate(saved.id, before.updated_at, { content: "v2" }); // 用旧 token → miss
166
+ assert.equal(store.getById(saved.id).content, "v1", "miss 不得改数据");
167
+ assert.equal(store.getMirrorState().generation, genAfterOk,
168
+ "CAS miss 不得递增 generation(UPDATE 与 increment 必须同事务)");
169
+ } finally {
170
+ store.close();
171
+ rmSync(dir, { recursive: true, force: true });
172
+ }
173
+ });
174
+
175
+ test("v0.3.9-F: generation 非整数必须拒绝(不得截断)", () => {
176
+ const { dir, store } = setup();
177
+ try {
178
+ // 审计 peer F:1.5 这类小数此前被 Math.trunc 截断 + SQLite CHECK 接受 → 静默脏值。
179
+ // fail-closed:JS 与 SQL 统一只接受整数。
180
+ assert.throws(() => store.setMirrorState({ generation: 1.5 }), RangeError, "小数 generation 必须拒绝");
181
+ assert.throws(() => store.setMirrorState({ applied_generation: -1.5 }), RangeError, "负数小数必须拒绝");
182
+ assert.throws(() => store.setMirrorState({ generation: Number.MAX_SAFE_INTEGER + 0.5 }), RangeError, "超界小数必须拒绝");
183
+ // 整数仍正常
184
+ const s = store.setMirrorState({ generation: 7 });
185
+ assert.equal(s.generation, 7, "整数 generation 正常");
186
+ } finally {
187
+ store.close();
188
+ rmSync(dir, { recursive: true, force: true });
189
+ }
190
+ });
@@ -0,0 +1,172 @@
1
+ // Regression for issue #9:
2
+ // - B: dreamMaxTokens cap widened (min 256, max 131072) so large memory
3
+ // libraries no longer starve the consolidation output.
4
+ // - A: dreamReasoningEffort / sleepReasoningEffort pass-through. Default
5
+ // 'none' must OMIT the reasoningEffort field entirely (the provider's own
6
+ // default applies); low/medium/high are forwarded verbatim on every dream /
7
+ // sleep LLM call. Asserted by capturing the options each llm.stream() sees.
8
+ import test from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { Config } from "../src/config.js";
11
+ import { createDreamScheduler } from "../src/dream.js";
12
+ import { runSleep } from "../src/dream/sleep.js";
13
+ import { createStore } from "../src/store.js";
14
+ import { createService } from "../src/service.js";
15
+ import { createVectorIndex } from "../src/vector-index.js";
16
+
17
+ const embedder = {
18
+ embedSingle: async () => [1, 0, 0],
19
+ embed: async () => [1, 0, 0],
20
+ schedule: () => {},
21
+ modelHash: "mock#1",
22
+ dimension: 3
23
+ };
24
+
25
+ // ---------------------------------------------------------------- config schema
26
+
27
+ test("issue#9: dreamMaxTokens accepts the widened 131072 cap and defaults to 4096", () => {
28
+ assert.equal(Config({}).dreamMaxTokens, 4096, "default unchanged");
29
+ assert.equal(Config({ dreamMaxTokens: 131072 }).dreamMaxTokens, 131072, "new upper bound accepted");
30
+ assert.equal(Config({ dreamMaxTokens: 65536 }).dreamMaxTokens, 65536, "intermediate value accepted");
31
+ });
32
+
33
+ test("issue#9: reasoningEffort config defaults to none and rejects unknown values", () => {
34
+ const cfg = Config({});
35
+ assert.equal(cfg.dreamReasoningEffort, "none");
36
+ assert.equal(cfg.sleepReasoningEffort, "none");
37
+ assert.equal(Config({ dreamReasoningEffort: "high" }).dreamReasoningEffort, "high");
38
+ assert.equal(Config({ sleepReasoningEffort: "medium" }).sleepReasoningEffort, "medium");
39
+ assert.throws(() => Config({ dreamReasoningEffort: "bogus" }), "invalid effort rejected");
40
+ assert.throws(() => Config({ sleepReasoningEffort: "ultra" }), "invalid effort rejected");
41
+ });
42
+
43
+ // ---------------------------------------------------------------- dream passthrough
44
+
45
+ /** dream ctx that records every llm.stream() call's options for inspection. */
46
+ function dreamCtx({ onConsolidation, summaryText = "记忆库总览:用户偏好中文。", captured = [] } = {}) {
47
+ return {
48
+ logger: { warn: () => {} },
49
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
50
+ llm: {
51
+ async *stream(options) {
52
+ captured.push(options);
53
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
54
+ if (userText.startsWith("id=")) {
55
+ yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText) : "[]" };
56
+ } else {
57
+ yield { type: "text-delta", index: 0, text: summaryText };
58
+ }
59
+ yield { type: "finish", reason: { kind: "stop" } };
60
+ }
61
+ }
62
+ };
63
+ }
64
+
65
+ test("issue#9: dream omits reasoningEffort under default 'none' and still consolidates (applied>0)", async () => {
66
+ const store = createStore(":memory:");
67
+ const service = createService({ store, mirror: null, config: {} });
68
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
69
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
70
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
71
+ const captured = [];
72
+ const ctx = dreamCtx({
73
+ captured,
74
+ onConsolidation: () => JSON.stringify([
75
+ { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "插件总览", content: "合并内容", importance: 4 }
76
+ ])
77
+ });
78
+ const result = await dream.runDream(ctx, service, {});
79
+ assert.equal(result.ok, true);
80
+ assert.ok(result.applied > 0, "end-to-end dream run still lands changes");
81
+ assert.equal(captured.length, 2, "consolidation + summary both hit the LLM");
82
+ for (const options of captured) {
83
+ assert.equal("reasoningEffort" in options, false, `default 'none' must not forward reasoningEffort (${options.purpose})`);
84
+ }
85
+ store.close();
86
+ });
87
+
88
+ test("issue#9: dream forwards dreamReasoningEffort on both LLM calls", async () => {
89
+ const store = createStore(":memory:");
90
+ const service = createService({ store, mirror: null, config: {} });
91
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
92
+ const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
93
+ const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
94
+ const captured = [];
95
+ const ctx = dreamCtx({
96
+ captured,
97
+ onConsolidation: () => JSON.stringify([
98
+ { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "插件总览", content: "合并内容", importance: 4 }
99
+ ])
100
+ });
101
+ const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "high" });
102
+ assert.equal(result.ok, true);
103
+ assert.equal(captured.length, 2);
104
+ for (const options of captured) {
105
+ assert.equal(options.reasoningEffort, "high", `reasoningEffort forwarded on ${options.purpose}`);
106
+ }
107
+ store.close();
108
+ });
109
+
110
+ // ---------------------------------------------------------------- sleep passthrough
111
+
112
+ function sleepSetup() {
113
+ const store = createStore(":memory:");
114
+ const service = createService({ store, mirror: null, config: {} });
115
+ const vectorIndex = createVectorIndex({ store });
116
+ service.setEmbedder(embedder);
117
+ service.setVectorIndex(vectorIndex);
118
+ return { store, service, vectorIndex };
119
+ }
120
+
121
+ function baseConfig(overrides = {}) {
122
+ return {
123
+ sleepModeEnabled: true,
124
+ sleepIdleMinutes: 5,
125
+ sleepMinIntervalHours: 8,
126
+ sleepConflictStrictness: "normal",
127
+ sleepArchiveDays: 30,
128
+ sleepCompressDays: 90,
129
+ sleepPatternMinMemories: 10,
130
+ sleepMaxPatternPerRun: 3,
131
+ ...overrides
132
+ };
133
+ }
134
+
135
+ /** sleep ctx that records every llm.stream() call's options. */
136
+ function sleepCtx(onConsolidation, selection = { provider: "mock", model: "sleep-model" }, captured = []) {
137
+ return {
138
+ logger: { warn: () => {}, info: () => {} },
139
+ agentDefaultModel: { currentSelection: () => selection },
140
+ llm: {
141
+ async *stream(options) {
142
+ captured.push(options);
143
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
144
+ yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText) : "[]" };
145
+ yield { type: "finish", reason: { kind: "stop" } };
146
+ }
147
+ }
148
+ };
149
+ }
150
+
151
+ test("issue#9: sleep forwards sleepReasoningEffort on its LLM passes", async () => {
152
+ const { store, service, vectorIndex } = sleepSetup();
153
+ const a = service.saveWithDedupe({ type: "project", title: "主题X", content: "内容A 关于主题X", importance: 3 }).memory;
154
+ const b = service.saveWithDedupe({ type: "project", title: "主题X副本", content: "内容B 关于主题X", importance: 3 }).memory;
155
+ vectorIndex.saveEmbedding(a.id, [1, 0, 0]);
156
+ vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
157
+ const captured = [];
158
+ const ctx = sleepCtx(
159
+ (userText) => userText.startsWith("候选冲突")
160
+ ? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
161
+ : "[]",
162
+ { provider: "mock", model: "sleep-model" },
163
+ captured
164
+ );
165
+ const result = await runSleep(ctx, service, baseConfig({ sleepReasoningEffort: "medium" }), ctx.logger, { embedder, vectorIndex }, null);
166
+ assert.equal(result.status, "ok");
167
+ assert.ok(captured.length >= 2, "conflict + pattern passes both hit the LLM");
168
+ for (const options of captured) {
169
+ assert.equal(options.reasoningEffort, "medium", `reasoningEffort forwarded on ${options.purpose}`);
170
+ }
171
+ store.close();
172
+ });