@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/README.md +24 -0
- package/lib/api.js +40 -2
- package/lib/config.js +35 -0
- package/lib/dream.js +86 -6
- package/lib/embedding.js +59 -2
- package/lib/index.js +68 -1
- package/lib/inject.js +79 -4
- package/lib/quality-filter.js +123 -0
- package/lib/service.js +214 -13
- package/lib/store.js +212 -5
- package/lib/summarize.js +65 -7
- package/lib/vector-index.js +12 -2
- package/package.json +1 -1
- package/src/api.js +40 -2
- package/src/config.js +35 -0
- package/src/dream.js +86 -6
- package/src/embedding.js +59 -2
- package/src/index.js +68 -1
- package/src/inject.js +79 -4
- package/src/quality-filter.js +123 -0
- package/src/service.js +214 -13
- package/src/store.js +212 -5
- package/src/summarize.js +65 -7
- package/src/vector-index.js +12 -2
- package/test/api.test.js +84 -0
- package/test/dream.test.js +52 -0
- package/test/inject.test.js +21 -0
- package/test/llm-audit.test.js +279 -0
- package/test/mirror-edit-digest.test.js +3 -1
- package/test/quality-filter.test.js +118 -0
- package/test/service.test.js +133 -2
- package/test/vector-index.test.js +22 -6
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
}
|
package/src/vector-index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
});
|
package/test/dream.test.js
CHANGED
|
@@ -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
|
+
});
|
package/test/inject.test.js
CHANGED
|
@@ -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
|
-
|
|
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, /新内容/, "镜像已重渲染为新值");
|