@modusensus/dsh-mneme 0.4.5 → 0.4.6

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
@@ -98,6 +98,12 @@ export function createSummarizer(ctx, service, config) {
98
98
  if (disposed || inFlight.has(session.id)) return;
99
99
  const controller = new AbortController();
100
100
  inFlight.set(session.id, controller);
101
+ // Bug8: audit state for the compression call. null = no audit for this run
102
+ // (disabled, or no LLM call was actually made). The audit row is written in
103
+ // the finally below — once, regardless of which exit path the call took —
104
+ // so a failed/aborted stream still leaves a status='error' trail without
105
+ // ever blocking the summarization itself.
106
+ let audit = null;
101
107
  try {
102
108
  const header = session.requestHeader?.()?.config;
103
109
  // Config override takes priority, then session header, then nothing.
@@ -110,6 +116,18 @@ export function createSummarizer(ctx, service, config) {
110
116
  const messages = collectMessages(session);
111
117
  if (!messages.length) return;
112
118
 
119
+ if (config?.llmAudit?.enabled !== false && typeof service.saveLlmAudit === "function") {
120
+ audit = {
121
+ route,
122
+ timestamp: new Date().toISOString(),
123
+ startedAt: Date.now(),
124
+ inputTokens: 0,
125
+ outputTokens: 0,
126
+ status: "success",
127
+ errorMessage: null
128
+ };
129
+ }
130
+
113
131
  const assembler = new BlockAssembler();
114
132
  let text = "";
115
133
  const options = {
@@ -122,15 +140,35 @@ export function createSummarizer(ctx, service, config) {
122
140
  ],
123
141
  signal: controller.signal
124
142
  };
125
- for await (const chunk of ctx.llm.stream(options)) {
126
- if (STREAM_CHUNK_TYPES.has(chunk.type)) assembler.push(toProtocolChunk(chunk));
127
- if (chunk.type === "text-delta") {
128
- text += chunk.text ?? chunk.delta ?? "";
143
+ try {
144
+ for await (const chunk of ctx.llm.stream(options)) {
145
+ if (STREAM_CHUNK_TYPES.has(chunk.type)) assembler.push(toProtocolChunk(chunk));
146
+ if (chunk.type === "text-delta") {
147
+ text += chunk.text ?? chunk.delta ?? "";
148
+ }
149
+ if (chunk.type === "usage" && audit) {
150
+ const i = chunk.input_tokens ?? chunk.inputTokens ?? chunk.prompt_tokens ?? chunk.promptTokens;
151
+ const o = chunk.output_tokens ?? chunk.outputTokens ?? chunk.completion_tokens ?? chunk.completionTokens;
152
+ if (Number.isFinite(i)) audit.inputTokens = i;
153
+ if (Number.isFinite(o)) audit.outputTokens = o;
154
+ }
155
+ if (chunk.type === "finish") {
156
+ const reasonKind = chunk.reason?.kind ?? chunk.kind;
157
+ if (reasonKind === "error" || reasonKind === "aborted") {
158
+ if (audit) {
159
+ audit.status = "error";
160
+ audit.errorMessage = `llm stream ${reasonKind}`;
161
+ }
162
+ return;
163
+ }
164
+ }
129
165
  }
130
- if (chunk.type === "finish") {
131
- const reasonKind = chunk.reason?.kind ?? chunk.kind;
132
- if (reasonKind === "error" || reasonKind === "aborted") return;
166
+ } catch (error) {
167
+ if (audit) {
168
+ audit.status = "error";
169
+ audit.errorMessage = String(error?.message ?? error);
133
170
  }
171
+ throw error; // caller's catch handles the failure; audit already staged
134
172
  }
135
173
  // Direct delta accumulation is the primary extraction path (it works
136
174
  // for real protocol chunks {index,text} and looser {delta} shapes
@@ -147,6 +185,26 @@ export function createSummarizer(ctx, service, config) {
147
185
  service.saveWithDedupe({ ...entry, source: `session:${session.id}` });
148
186
  }
149
187
  } finally {
188
+ if (audit) {
189
+ try {
190
+ service.saveLlmAudit({
191
+ timestamp: audit.timestamp,
192
+ trigger_source: "autoSummarize",
193
+ operation_type: "summarize_compress",
194
+ model_id: `${audit.route.provider}:${audit.route.model}`,
195
+ input_tokens: audit.inputTokens,
196
+ output_tokens: audit.outputTokens,
197
+ total_tokens: audit.inputTokens + audit.outputTokens,
198
+ cost_usd: 0,
199
+ duration_ms: Date.now() - audit.startedAt,
200
+ status: audit.status,
201
+ error_message: audit.errorMessage,
202
+ related_memory_ids: []
203
+ });
204
+ } catch (auditError) {
205
+ ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
206
+ }
207
+ }
150
208
  inFlight.delete(session.id);
151
209
  }
152
210
  }
@@ -73,13 +73,23 @@ export function createVectorIndex({ store, logger }) {
73
73
 
74
74
  /** Re-embed every row missing an embedding. Returns indexed count. */
75
75
  async rebuildIndex(embedder, { limit = 1000 } = {}) {
76
- if (!embedder || typeof embedder.embedSingle !== "function") return { indexed: 0, skipped: 0 };
76
+ // The loop needs a single-text embedder. Native embedSingle is preferred;
77
+ // embed-only OpenAI-compatible clients (issue #10) are accepted too via
78
+ // their `embed` single-text interface, so /vector-reindex no longer
79
+ // silently returns 0 for them. An embedder exposing neither is ignored.
80
+ let embedOne = null;
81
+ if (embedder && typeof embedder.embedSingle === "function") {
82
+ embedOne = (text) => embedder.embedSingle(text);
83
+ } else if (embedder && typeof embedder.embed === "function") {
84
+ embedOne = (text) => Promise.resolve(embedder.embed(text));
85
+ }
86
+ if (!embedOne) return { indexed: 0, skipped: 0 };
77
87
  const rows = store.needsEmbedding(limit);
78
88
  let indexed = 0;
79
89
  for (const row of rows) {
80
90
  try {
81
91
  const text = [row.title, row.content].filter(Boolean).join("\n");
82
- const vector = await embedder.embedSingle(text);
92
+ const vector = await embedOne(text);
83
93
  if (vector && vector.length) {
84
94
  store.setEmbedding(row.id, vector);
85
95
  indexed++;
package/test/api.test.js CHANGED
@@ -5,6 +5,7 @@ import { createStore } from "../src/store.js";
5
5
  import { createService } from "../src/service.js";
6
6
  import { createApi } from "../src/api.js";
7
7
  import { createSettings } from "../src/settings.js";
8
+ import { createVectorIndex } from "../src/vector-index.js";
8
9
 
9
10
  class FakeRes extends EventEmitter {
10
11
  constructor() { super(); this.statusCode = 200; this.body = ""; }
@@ -383,3 +384,86 @@ test("no apiToken configured keeps all endpoints open", async () => {
383
384
  await vec.handler(req("/api/dsh-mneme/vector-config"), res);
384
385
  assert.equal(res.statusCode, 200, "open when apiToken is unset");
385
386
  });
387
+
388
+ // --- Bug8: llm-audit API (pagination + stats) --------------------------------
389
+
390
+ test("GET /api/dsh-mneme/semantic/llm-audit returns paginated rows", async () => {
391
+ const { routes, service } = setup();
392
+ for (let i = 0; i < 5; i++) {
393
+ service.saveLlmAudit({ trigger_source: "autoDream", operation_type: "dream_consolidate", model_id: "m1", input_tokens: 10, output_tokens: 5, status: "success", related_memory_ids: [] });
394
+ }
395
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/llm-audit");
396
+ const res = new FakeRes();
397
+ await route.handler(req("/api/dsh-mneme/semantic/llm-audit?page=2&pageSize=2"), res);
398
+ assert.equal(res.statusCode, 200);
399
+ const data = JSON.parse(res.body);
400
+ assert.equal(data.total, 5);
401
+ assert.equal(data.page, 2);
402
+ assert.equal(data.pageSize, 2);
403
+ assert.equal(data.items.length, 2, "second page of 2");
404
+ });
405
+
406
+ test("GET /api/dsh-mneme/semantic/llm-audit filters by source", async () => {
407
+ const { routes, service } = setup();
408
+ service.saveLlmAudit({ trigger_source: "autoDream", operation_type: "dream_consolidate", model_id: "m1", status: "success" });
409
+ service.saveLlmAudit({ trigger_source: "autoSummarize", operation_type: "summarize_compress", model_id: "m2", status: "success" });
410
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/llm-audit");
411
+ const res = new FakeRes();
412
+ await route.handler(req("/api/dsh-mneme/semantic/llm-audit?source=autoSummarize"), res);
413
+ const data = JSON.parse(res.body);
414
+ assert.equal(data.total, 1);
415
+ assert.equal(data.items[0].operation_type, "summarize_compress");
416
+ });
417
+
418
+ test("GET /api/dsh-mneme/semantic/llm-audit/stats aggregates tokens by source and status", async () => {
419
+ const { routes, service } = setup();
420
+ service.saveLlmAudit({
421
+ trigger_source: "autoDream", operation_type: "dream_consolidate", model_id: "m1",
422
+ input_tokens: 100, output_tokens: 50, total_tokens: 150, duration_ms: 12, status: "success", related_memory_ids: []
423
+ });
424
+ service.saveLlmAudit({
425
+ trigger_source: "autoSummarize", operation_type: "summarize_compress", model_id: "m2",
426
+ input_tokens: 20, output_tokens: 10, total_tokens: 30, duration_ms: 5, status: "error", error_message: "boom", related_memory_ids: []
427
+ });
428
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/llm-audit/stats");
429
+ const res = new FakeRes();
430
+ await route.handler(req("/api/dsh-mneme/semantic/llm-audit/stats?days=7"), res);
431
+ assert.equal(res.statusCode, 200);
432
+ const data = JSON.parse(res.body);
433
+ assert.equal(data.total_calls, 2);
434
+ assert.equal(data.input_tokens, 120);
435
+ assert.equal(data.output_tokens, 60);
436
+ assert.equal(data.total_tokens, 180);
437
+ assert.equal(data.total_duration_ms, 17);
438
+ assert.ok(data.by_source.some((s) => s.source === "autoDream" && s.total_tokens === 150), "autoDream aggregate present");
439
+ assert.ok(data.by_status.some((s) => s.status === "error" && s.c === 1), "error status counted");
440
+ });
441
+
442
+ // --- issue #10: vector-reindex with an embed-only OpenAI-compatible embedder --
443
+
444
+ test("Bug10: vector-reindex with an embed-only OpenAI-compatible embedder returns the real count and records the model fingerprint", async () => {
445
+ const store = createStore(":memory:");
446
+ const service = createService({ store, mirror: null, config: {} });
447
+ const settings = createSettings(store.db);
448
+ const vectorIndex = createVectorIndex({ store });
449
+ const embedder = {
450
+ embed: async (text) => [0.1, 0.2, 0.3], // OpenAI-compatible single-text embed
451
+ modelHash: "text-embedding-3#abc",
452
+ dimension: 3
453
+ };
454
+ // A pre-index row written before the embedder is attached (so it still has no vector).
455
+ service.saveWithDedupe({ type: "project", title: "待回填", content: "缺少向量的存量记忆" });
456
+ const routes = [];
457
+ const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
458
+ createApi(ctx, service, settings, { add() {}, remove() {}, list() { return []; } }, embedder, { vectorIndex }, "");
459
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/vector-reindex");
460
+ const res = new FakeRes();
461
+ await route.handler(req("/api/dsh-mneme/vector-reindex"), res);
462
+ assert.equal(res.statusCode, 200);
463
+ const data = JSON.parse(res.body);
464
+ assert.equal(data.indexed, 1, "actual indexed count, not 0");
465
+ assert.equal(data.skipped, 0);
466
+ assert.equal(vectorIndex.modelHash(), "text-embedding-3#abc", "model_hash written to vector_meta");
467
+ assert.equal(vectorIndex.dimension(), 3, "dimension written to vector_meta");
468
+ assert.equal(vectorIndex.getEmbedding(service.all()[0].id).length, 3, "embedding persisted");
469
+ });
@@ -847,3 +847,55 @@ test("consolidation prompt pins the decision schema (action field, single-string
847
847
  assert.match(systemText, /决策 JSON 示例/, "prompt includes a canonical example block");
848
848
  store.close();
849
849
  });
850
+
851
+ // --- Bug8: llm_audit_logs trail ----------------------------------------------
852
+
853
+ test("Bug8: runDream records llm_audit_logs rows for consolidation and summary", async () => {
854
+ const { store, service } = dreamSetup();
855
+ service.saveWithDedupe({ type: "project", title: "旧1", content: "第一段内容" });
856
+ service.saveWithDedupe({ type: "project", title: "旧2", content: "第二段内容" });
857
+ const ctx = mockCtx({
858
+ onConsolidation: (listText) => JSON.stringify([{ action: "keep", ids: [listText.match(/id=([^\s|]+)/)[1]] }])
859
+ });
860
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
861
+ const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
862
+ assert.equal(result.ok, true, "run succeeds");
863
+ const rows = store.listLlmAudits();
864
+ assert.equal(rows.length, 2, "consolidation + summary both audited");
865
+ assert.deepEqual(rows.map((r) => r.trigger_source), ["autoDream", "autoDream"]);
866
+ assert.deepEqual(rows.map((r) => r.operation_type).sort(), ["dream_consolidate", "dream_summarize"]);
867
+ const consolidate = rows.find((r) => r.operation_type === "dream_consolidate");
868
+ assert.equal(consolidate.related_memory_ids.length, 2, "consolidation audit links the snapshot ids");
869
+ const summarize = rows.find((r) => r.operation_type === "dream_summarize");
870
+ assert.deepEqual(summarize.related_memory_ids, [], "summary audit has no related ids");
871
+ for (const row of rows) {
872
+ assert.equal(row.status, "success");
873
+ assert.equal(row.model_id, "mock:stress-model");
874
+ assert.ok(Number.isInteger(row.duration_ms) && row.duration_ms >= 0, "duration recorded");
875
+ assert.equal(row.input_tokens, 0);
876
+ assert.equal(row.output_tokens, 0);
877
+ }
878
+ store.close();
879
+ });
880
+
881
+ test("Bug8: a failed LLM call is recorded with status=error and does not block the run", async () => {
882
+ const { store, service } = dreamSetup();
883
+ service.saveWithDedupe({ type: "project", title: "主题", content: "内容" });
884
+ const ctx = {
885
+ logger: { warn: () => {} },
886
+ llm: {
887
+ stream: async function* () {
888
+ yield { type: "finish", reason: { kind: "error" } };
889
+ }
890
+ }
891
+ };
892
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
893
+ const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
894
+ assert.equal(result.ok, false, "failed run reported");
895
+ const rows = store.listLlmAudits();
896
+ assert.equal(rows.length, 1, "one audit row for the failed consolidation call");
897
+ assert.equal(rows[0].operation_type, "dream_consolidate");
898
+ assert.equal(rows[0].status, "error", "LLM failure status=error");
899
+ assert.ok(rows[0].error_message, "error message recorded");
900
+ store.close();
901
+ });
@@ -80,3 +80,24 @@ test("user-settings context precedes memory block (order 85 < 90)", () => {
80
80
  const settingsCtx = contexts.find((c) => c.name === "user-settings");
81
81
  assert.ok(settingsCtx.order < contexts.find((c) => c.name === "memory").order);
82
82
  });
83
+
84
+ test("Bug6: long content is truncated to ~300 chars with an ellipsis", () => {
85
+ const { contexts, service } = setup();
86
+ const longContent = "这是一段非常长的记忆正文".repeat(200); // ~2600 chars
87
+ service.saveWithDedupe({ type: "preference", title: "长记忆", content: longContent, importance: 5 });
88
+ const text = contexts[0].text({});
89
+ assert.ok(text.includes("长记忆"), "memory still rendered");
90
+ assert.ok(text.includes("…"), "ellipsis marks the truncation");
91
+ assert.ok(!text.includes(longContent.slice(300)), "full body not injected verbatim");
92
+ });
93
+
94
+ test("Bug6: injected block stays within the ~1500 char budget, later entries collapse", () => {
95
+ const { contexts, service } = setup({ maxInjectedItems: 8 });
96
+ for (let i = 0; i < 8; i++) {
97
+ service.saveWithDedupe({ type: "preference", title: `长标题记忆${i}`, content: "这是一段".repeat(100), importance: 5 });
98
+ }
99
+ const text = contexts[0].text({});
100
+ assert.ok(text.length <= 1600, `memory block bounded near budget, got ${text.length} chars`);
101
+ // The first entries render full bodies; every entry is present by title.
102
+ for (let i = 0; i < 8; i++) assert.ok(text.includes(`长标题记忆${i}`), `entry ${i} present`);
103
+ });
@@ -0,0 +1,279 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { EventEmitter } from "node:events";
4
+ import { createStore } from "../src/store.js";
5
+ import { createService } from "../src/service.js";
6
+ import { createDreamScheduler } from "../src/dream.js";
7
+ import { createSummarizer } from "../src/summarize.js";
8
+ import { createApi } from "../src/api.js";
9
+ import { createSettings } from "../src/settings.js";
10
+ import { mockCtx } from "./helpers/dream-mock.js";
11
+
12
+ // Bug8: LLM audit trail. Every background LLM call (autoDream consolidation +
13
+ // summary, autoSummarize compression) records tokens/time/status into
14
+ // llm_audit_logs. Failures are captured as status='error' and never block the
15
+ // calling feature. Read back through the API (/llm-audit + /llm-audit/stats).
16
+ // Gating: runDream treats llmAudit.enabled === false as off; summarize treats
17
+ // llmAudit.enabled !== false as on.
18
+
19
+ function dreamSetup(over = {}) {
20
+ const store = createStore(":memory:");
21
+ const service = createService({ store, mirror: null, config: {} });
22
+ return {
23
+ store,
24
+ service,
25
+ config: { dreamProvider: "deepseek", dreamModel: "deepseek-chat", llmAudit: { enabled: true }, ...over }
26
+ };
27
+ }
28
+
29
+ test("autoDream writes llm_audit_logs rows for consolidation and summary", async () => {
30
+ const { store, service, config } = dreamSetup();
31
+ service.saveWithDedupe({ type: "project", title: "旧1", content: "第一段内容" });
32
+ service.saveWithDedupe({ type: "project", title: "旧2", content: "第二段内容" });
33
+ const ctx = mockCtx({
34
+ onConsolidation: (listText) => {
35
+ const ids = [...listText.matchAll(/id=([^\s|]+)\s*\|\s*type=\w+\s*\|\s*importance=\d+/g)].map((m) => m[1]);
36
+ return JSON.stringify(ids.map((id) => ({ action: "keep", ids: [id] })));
37
+ }
38
+ });
39
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
40
+ const result = await dream.runDream(ctx, service, config);
41
+ assert.equal(result.ok, true);
42
+
43
+ const rows = service.listLlmAudits();
44
+ assert.equal(rows.length, 2, "one consolidation + one summary audit row");
45
+ const consolidate = rows.find((r) => r.operation_type === "dream_consolidate");
46
+ const summarize = rows.find((r) => r.operation_type === "dream_summarize");
47
+ assert.ok(consolidate, "dream_consolidate row present");
48
+ assert.ok(summarize, "dream_summarize row present");
49
+ assert.equal(consolidate.trigger_source, "autoDream");
50
+ assert.equal(summarize.trigger_source, "autoDream");
51
+ assert.equal(consolidate.status, "success");
52
+ assert.equal(summarize.status, "success");
53
+ // mockCtx resolves the route from agentDefaultModel (mock:stress-model), not
54
+ // the config fallback — assert the actually-used route.
55
+ assert.equal(consolidate.model_id, "mock:stress-model");
56
+ assert.equal(summarize.model_id, "mock:stress-model");
57
+ assert.ok(Array.isArray(consolidate.related_memory_ids) && consolidate.related_memory_ids.length === 2,
58
+ "consolidation audit records the snapshot ids");
59
+ assert.ok(consolidate.total_tokens >= 0 && summarize.total_tokens >= 0);
60
+ assert.ok(typeof consolidate.duration_ms === "number" && consolidate.duration_ms >= 0);
61
+ store.close();
62
+ });
63
+
64
+ test("autoDream LLM failure is audited as status=error and never blocks the run", async () => {
65
+ const { store, service, config } = dreamSetup();
66
+ service.saveWithDedupe({ type: "project", title: "主题", content: "内容" });
67
+ // Consolidation stream finishes with kind "error" → streamText returns
68
+ // undefined → the run fails safe; runAuditedLlm must still leave an
69
+ // error audit row before the failure propagates.
70
+ const ctx = {
71
+ logger: { warn: () => {} },
72
+ llm: {
73
+ async *stream() {
74
+ yield { type: "text-delta", index: 0, text: "" };
75
+ yield { type: "finish", reason: { kind: "error" } };
76
+ }
77
+ }
78
+ };
79
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
80
+ const result = await dream.runDream(ctx, service, config);
81
+ assert.equal(result.ok, false, "aborted/errored stream fails the run");
82
+ const rows = service.listLlmAudits();
83
+ assert.equal(rows.length, 1, "failed consolidation still audited");
84
+ assert.equal(rows[0].operation_type, "dream_consolidate");
85
+ assert.equal(rows[0].status, "error");
86
+ assert.ok(rows[0].error_message, "error message recorded");
87
+ // the audit write did not block the failure path
88
+ assert.equal(result.error, "llm failed");
89
+ store.close();
90
+ });
91
+
92
+ test("autoDream throwing LLM is audited as status=error", async () => {
93
+ const { store, service, config } = dreamSetup();
94
+ service.saveWithDedupe({ type: "project", title: "主题", content: "内容" });
95
+ const ctx = {
96
+ logger: { warn: () => {} },
97
+ llm: {
98
+ async *stream() {
99
+ throw new Error("network down");
100
+ }
101
+ }
102
+ };
103
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
104
+ const result = await dream.runDream(ctx, service, config);
105
+ assert.equal(result.ok, false);
106
+ const rows = service.listLlmAudits();
107
+ assert.equal(rows.length, 1, "throwing call still audited");
108
+ assert.equal(rows[0].status, "error");
109
+ assert.match(rows[0].error_message, /network down/);
110
+ store.close();
111
+ });
112
+
113
+ test("autoDream audit is skipped when llmAudit.enabled === false", async () => {
114
+ const { store, service, config } = dreamSetup({ llmAudit: { enabled: false } });
115
+ service.saveWithDedupe({ type: "project", title: "主题", content: "内容" });
116
+ const ctx = mockCtx();
117
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
118
+ await dream.runDream(ctx, service, config);
119
+ assert.equal(service.listLlmAudits().length, 0, "no audit rows when disabled");
120
+ store.close();
121
+ });
122
+
123
+ test("autoSummarize writes an llm_audit_logs row (trigger_source autoSummarize)", async () => {
124
+ const store = createStore(":memory:");
125
+ const service = createService({ store, mirror: null, config: {} });
126
+ const events = [];
127
+ const ctx = {
128
+ on(name, fn) {
129
+ events.push({ name, fn });
130
+ return () => {};
131
+ },
132
+ logger: { warn: () => {} },
133
+ llm: {
134
+ async *stream() {
135
+ yield { type: "text-delta", index: 0, text: JSON.stringify([
136
+ { type: "decision", title: "选型", content: "确定用 node:sqlite", importance: 4 }
137
+ ]) };
138
+ yield { type: "finish", reason: { kind: "stop" } };
139
+ }
140
+ }
141
+ };
142
+ const config = {
143
+ autoSummarize: true,
144
+ summarizeProvider: "deepseek",
145
+ summarizeModel: "deepseek-chat",
146
+ llmAudit: { enabled: true }
147
+ };
148
+ createSummarizer(ctx, service, config);
149
+ const handler = events.find((e) => e.name === "session/event").fn;
150
+ const session = {
151
+ id: "s1",
152
+ requestHeader: () => ({ config: {} }),
153
+ events: [
154
+ { type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "帮我选型" }] } },
155
+ { seq: 2, type: "turn/end" }
156
+ ]
157
+ };
158
+ await handler(session, { seq: 2, type: "turn/end" });
159
+ const rows = service.listLlmAudits();
160
+ assert.equal(rows.length, 1, "one autoSummarize audit row");
161
+ assert.equal(rows[0].trigger_source, "autoSummarize");
162
+ assert.equal(rows[0].operation_type, "summarize_compress");
163
+ assert.equal(rows[0].model_id, "deepseek:deepseek-chat");
164
+ assert.equal(rows[0].status, "success");
165
+ store.close();
166
+ });
167
+
168
+ // --- API surface ------------------------------------------------------------
169
+
170
+ class FakeRes extends EventEmitter {
171
+ constructor() { super(); this.statusCode = 200; this.body = ""; }
172
+ writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
173
+ end(text) { this.body = text ?? ""; this.emit("end"); return this; }
174
+ }
175
+
176
+ function req(path) {
177
+ const r = new EventEmitter();
178
+ r.url = path;
179
+ r.method = "GET";
180
+ r.headers = {};
181
+ return r;
182
+ }
183
+
184
+ function apiSetup() {
185
+ const store = createStore(":memory:");
186
+ const service = createService({ store, mirror: null, config: {} });
187
+ const settings = createSettings(store.db);
188
+ const routes = [];
189
+ const ctx = {
190
+ webServer: {
191
+ register(route) {
192
+ routes.push(route);
193
+ return () => {};
194
+ }
195
+ }
196
+ };
197
+ createApi(ctx, service, settings, { add: () => {}, remove: () => false, list: () => [] });
198
+ return { store, service, routes };
199
+ }
200
+
201
+ test("GET /api/dsh-mneme/semantic/llm-audit paginates and filters by source", async () => {
202
+ const { store, service, routes } = apiSetup();
203
+ for (let i = 0; i < 3; i++) {
204
+ service.saveLlmAudit({
205
+ trigger_source: "autoDream",
206
+ operation_type: "dream_consolidate",
207
+ model_id: "deepseek:deepseek-chat",
208
+ input_tokens: 10, output_tokens: 5, status: "success"
209
+ });
210
+ }
211
+ service.saveLlmAudit({
212
+ trigger_source: "autoSummarize",
213
+ operation_type: "summarize_compress",
214
+ model_id: "deepseek:deepseek-chat",
215
+ input_tokens: 3, output_tokens: 1, status: "success"
216
+ });
217
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/llm-audit");
218
+
219
+ // full page
220
+ let res = new FakeRes();
221
+ await route.handler(req("/api/dsh-mneme/semantic/llm-audit?page=1&pageSize=2"), res);
222
+ let data = JSON.parse(res.body);
223
+ assert.equal(res.statusCode, 200);
224
+ assert.equal(data.total, 4);
225
+ assert.equal(data.page, 1);
226
+ assert.equal(data.pageSize, 2);
227
+ assert.equal(data.items.length, 2);
228
+
229
+ // second page
230
+ res = new FakeRes();
231
+ await route.handler(req("/api/dsh-mneme/semantic/llm-audit?page=2&pageSize=2"), res);
232
+ data = JSON.parse(res.body);
233
+ assert.equal(data.items.length, 2, "page 2 has the remaining rows");
234
+ assert.equal(data.total, 4);
235
+
236
+ // source filter
237
+ res = new FakeRes();
238
+ await route.handler(req("/api/dsh-mneme/semantic/llm-audit?source=autoSummarize"), res);
239
+ data = JSON.parse(res.body);
240
+ assert.equal(data.total, 1);
241
+ assert.equal(data.items[0].trigger_source, "autoSummarize");
242
+ store.close();
243
+ });
244
+
245
+ test("GET /api/dsh-mneme/semantic/llm-audit/stats aggregates by source and status", async () => {
246
+ const { store, service, routes } = apiSetup();
247
+ service.saveLlmAudit({
248
+ timestamp: new Date().toISOString(),
249
+ trigger_source: "autoDream", operation_type: "dream_consolidate",
250
+ model_id: "m", input_tokens: 100, output_tokens: 40, duration_ms: 25, status: "success"
251
+ });
252
+ service.saveLlmAudit({
253
+ timestamp: new Date().toISOString(),
254
+ trigger_source: "autoDream", operation_type: "dream_summarize",
255
+ model_id: "m", input_tokens: 30, output_tokens: 10, duration_ms: 8, status: "success"
256
+ });
257
+ service.saveLlmAudit({
258
+ timestamp: new Date().toISOString(),
259
+ trigger_source: "autoSummarize", operation_type: "summarize_compress",
260
+ model_id: "m", input_tokens: 5, output_tokens: 2, duration_ms: 4, status: "error"
261
+ });
262
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/llm-audit/stats");
263
+ const res = new FakeRes();
264
+ await route.handler(req("/api/dsh-mneme/semantic/llm-audit/stats?days=7"), res);
265
+ const stats = JSON.parse(res.body);
266
+ assert.equal(res.statusCode, 200);
267
+ assert.equal(stats.days, 7);
268
+ assert.equal(stats.total_calls, 3);
269
+ assert.equal(stats.input_tokens, 135);
270
+ assert.equal(stats.output_tokens, 52);
271
+ assert.equal(stats.total_tokens, 187);
272
+ assert.equal(stats.total_duration_ms, 37);
273
+ const bySource = stats.by_source.find((s) => s.source === "autoDream");
274
+ assert.equal(bySource.c, 2);
275
+ assert.equal(bySource.total_tokens, 180);
276
+ const errStatus = stats.by_status.find((s) => s.status === "error");
277
+ assert.equal(errStatus.c, 1);
278
+ store.close();
279
+ });
@@ -75,7 +75,9 @@ test("digest 匹配:saveWithDedupe 同标题 merge 后新值落地、无伪冲
75
75
  assert.equal(result.action, "merged");
76
76
  assert.equal(store.count(), 1, "同标题合并不新增条目");
77
77
  const m = service.getById(result.memory.id);
78
- assert.equal(m.content, "新内容", "合并后新值必须落地");
78
+ // Bug5: 同标题合并不是覆盖,而是追加(旧内容 + --- 分隔 + 新内容)
79
+ assert.ok(m.content.includes("旧内容"), "合并后旧内容保留在追加正文");
80
+ assert.ok(m.content.includes("新内容"), "合并后新值必须落地");
79
81
  assert.ok(!m.content.includes(CONFLICT_MARKER), "不得出现伪冲突 marker");
80
82
  const file = readFileSync(mirrorFile(dir, "preference"), "utf8");
81
83
  assert.match(file, /新内容/, "镜像已重渲染为新值");