@modusensus/dsh-mneme 0.6.7 → 0.6.9

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.
Files changed (139) hide show
  1. package/.github/workflows/test.yml +32 -0
  2. package/.release-notes-v0.6.9.md +13 -0
  3. package/CHANGELOG.md +89 -0
  4. package/LICENSE +21 -21
  5. package/README.md +219 -463
  6. package/SECURITY.md +544 -0
  7. package/docs/devlog/2026-08-14-dsh-mneme-dev-log.md +247 -0
  8. package/docs/devlog/2026-08-15-dsh-mneme-audit-stress-dev-log.md +145 -0
  9. package/docs/devlog/2026-08-15-dsh-mneme-pipeline-dev-log.md +56 -0
  10. package/docs/devlog/2026-08-15-dsh-mneme-reflection-dev-log.md +77 -0
  11. package/docs/devlog/2026-08-15-dsh-mneme-review-fixes-dev-log.md +64 -0
  12. package/docs/devlog/2026-08-15-dsh-mneme-semantic-dev-log.md +90 -0
  13. package/dsh-mneme/CHANGELOG.md +248 -0
  14. package/dsh-mneme/LICENSE +21 -0
  15. package/dsh-mneme/README.md +465 -0
  16. package/{cordis.patch.yml → dsh-mneme/cordis.patch.yml} +15 -15
  17. package/dsh-mneme/docs/AGENT_MEMORY_RESEARCH.md +183 -0
  18. package/dsh-mneme/docs/ENTITIES.md +245 -0
  19. package/dsh-mneme/docs/LOCAL_MODEL.md +141 -0
  20. package/dsh-mneme/docs/MIGRATION.md +127 -0
  21. package/dsh-mneme/docs/SEMANTIC.md +256 -0
  22. package/dsh-mneme/docs/SLEEP.md +163 -0
  23. package/{lib → dsh-mneme/lib}/api.js +783 -783
  24. package/{lib → dsh-mneme/lib}/client.js +1757 -1757
  25. package/{src → dsh-mneme/lib}/commands.js +64 -64
  26. package/{lib → dsh-mneme/lib}/config.js +298 -288
  27. package/{lib → dsh-mneme/lib}/dream/clustering.js +118 -118
  28. package/{src → dsh-mneme/lib}/dream/decisions.js +488 -439
  29. package/{lib → dsh-mneme/lib}/dream/sleep.js +561 -554
  30. package/{src → dsh-mneme/lib}/dream/tag-extractor.js +156 -156
  31. package/{lib → dsh-mneme/lib}/dream.js +958 -929
  32. package/{src → dsh-mneme/lib}/embedding.js +154 -154
  33. package/{src → dsh-mneme/lib}/entities/extractor.js +242 -242
  34. package/{src → dsh-mneme/lib}/hot-memory.js +53 -53
  35. package/{lib → dsh-mneme/lib}/index.js +361 -361
  36. package/{src → dsh-mneme/lib}/inject.js +208 -208
  37. package/{lib → dsh-mneme/lib}/local-embedder.js +282 -282
  38. package/{lib → dsh-mneme/lib}/mirror.js +170 -170
  39. package/{lib → dsh-mneme/lib}/parser/tag.js +59 -59
  40. package/{lib → dsh-mneme/lib}/parser/wiki-link.js +38 -38
  41. package/{src → dsh-mneme/lib}/quality-filter.js +123 -123
  42. package/{lib → dsh-mneme/lib}/reranker.js +218 -218
  43. package/{lib → dsh-mneme/lib}/search/adaptive.js +22 -22
  44. package/{lib → dsh-mneme/lib}/search/bm25.js +96 -96
  45. package/{src → dsh-mneme/lib}/search/tag-boost.js +61 -61
  46. package/{src → dsh-mneme/lib}/service.js +1726 -1726
  47. package/{lib → dsh-mneme/lib}/settings.js +172 -172
  48. package/{lib → dsh-mneme/lib}/store.js +2238 -2238
  49. package/{lib → dsh-mneme/lib}/summarize.js +236 -236
  50. package/{lib → dsh-mneme/lib}/tools.js +290 -290
  51. package/{lib → dsh-mneme/lib}/vector-index.js +116 -116
  52. package/dsh-mneme/package-lock.json +1936 -0
  53. package/dsh-mneme/package.json +80 -0
  54. package/{scripts → dsh-mneme/scripts}/benchmark-embed.js +201 -201
  55. package/{scripts → dsh-mneme/scripts}/benchmark-recall.js +133 -133
  56. package/{scripts → dsh-mneme/scripts}/benchmark-rerank.js +166 -166
  57. package/{scripts → dsh-mneme/scripts}/e2e-dsh.js +218 -218
  58. package/{scripts → dsh-mneme/scripts}/stress-dsh.js +255 -255
  59. package/{scripts → dsh-mneme/scripts}/sync-lib.js +52 -52
  60. package/{src → dsh-mneme/src}/api.js +783 -783
  61. package/{lib → dsh-mneme/src}/commands.js +64 -64
  62. package/{src → dsh-mneme/src}/config.js +298 -288
  63. package/{src → dsh-mneme/src}/dream/clustering.js +118 -118
  64. package/{lib → dsh-mneme/src}/dream/decisions.js +488 -439
  65. package/{src → dsh-mneme/src}/dream/sleep.js +561 -554
  66. package/{lib → dsh-mneme/src}/dream/tag-extractor.js +156 -156
  67. package/{src → dsh-mneme/src}/dream.js +958 -929
  68. package/{lib → dsh-mneme/src}/embedding.js +154 -154
  69. package/{lib → dsh-mneme/src}/entities/extractor.js +242 -242
  70. package/{lib → dsh-mneme/src}/hot-memory.js +53 -53
  71. package/{src → dsh-mneme/src}/index.js +361 -361
  72. package/{lib → dsh-mneme/src}/inject.js +208 -208
  73. package/{src → dsh-mneme/src}/local-embedder.js +282 -282
  74. package/{src → dsh-mneme/src}/mirror.js +170 -170
  75. package/{src → dsh-mneme/src}/parser/tag.js +59 -59
  76. package/{src → dsh-mneme/src}/parser/wiki-link.js +38 -38
  77. package/{lib → dsh-mneme/src}/quality-filter.js +123 -123
  78. package/{src → dsh-mneme/src}/reranker.js +218 -218
  79. package/{src → dsh-mneme/src}/search/adaptive.js +22 -22
  80. package/{src → dsh-mneme/src}/search/bm25.js +96 -96
  81. package/{lib → dsh-mneme/src}/search/tag-boost.js +61 -61
  82. package/{lib → dsh-mneme/src}/service.js +1726 -1726
  83. package/{src → dsh-mneme/src}/settings.js +172 -172
  84. package/{src → dsh-mneme/src}/store.js +2238 -2238
  85. package/{src → dsh-mneme/src}/summarize.js +236 -236
  86. package/{src → dsh-mneme/src}/tools.js +290 -290
  87. package/{src → dsh-mneme/src}/vector-index.js +116 -116
  88. package/{test → dsh-mneme/test}/api.test.js +594 -594
  89. package/{test → dsh-mneme/test}/audit.test.js +448 -448
  90. package/{test → dsh-mneme/test}/benchmark.test.js +35 -35
  91. package/{test → dsh-mneme/test}/boundary-v0625.test.js +82 -82
  92. package/{test → dsh-mneme/test}/client.test.js +368 -368
  93. package/{test → dsh-mneme/test}/clustering.test.js +100 -100
  94. package/{test → dsh-mneme/test}/commands.test.js +69 -69
  95. package/{test → dsh-mneme/test}/config.test.js +50 -50
  96. package/{test → dsh-mneme/test}/conflict-freeze.test.js +290 -290
  97. package/{test → dsh-mneme/test}/directory.test.js +134 -134
  98. package/{test → dsh-mneme/test}/dream.test.js +1060 -901
  99. package/{test → dsh-mneme/test}/entities.test.js +522 -522
  100. package/{test → dsh-mneme/test}/epistemic.test.js +298 -298
  101. package/{test → dsh-mneme/test}/fnew-0112.test.js +311 -311
  102. package/{test → dsh-mneme/test}/fnew-03.test.js +422 -422
  103. package/{test → dsh-mneme/test}/graph-api.test.js +175 -175
  104. package/{test → dsh-mneme/test}/helpers/dream-mock.js +82 -82
  105. package/{test → dsh-mneme/test}/hot-memory.test.js +174 -174
  106. package/{test → dsh-mneme/test}/inject.test.js +103 -103
  107. package/{test → dsh-mneme/test}/llm-audit.test.js +279 -279
  108. package/{test → dsh-mneme/test}/local-embedder.test.js +227 -227
  109. package/{test → dsh-mneme/test}/mirror-dirty.test.js +424 -424
  110. package/{test → dsh-mneme/test}/mirror-edit-digest.test.js +187 -187
  111. package/{test → dsh-mneme/test}/mirror-generation.test.js +499 -499
  112. package/{test → dsh-mneme/test}/mirror.test.js +249 -249
  113. package/{test → dsh-mneme/test}/normalize-decisions.test.js +120 -120
  114. package/{test → dsh-mneme/test}/peer-blockers.test.js +190 -190
  115. package/{test → dsh-mneme/test}/policy-epoch.test.js +259 -259
  116. package/{test → dsh-mneme/test}/provenance.test.js +103 -103
  117. package/{test → dsh-mneme/test}/quality-filter.test.js +118 -118
  118. package/{test → dsh-mneme/test}/reasoning-effort.test.js +199 -199
  119. package/{test → dsh-mneme/test}/recall-evals.test.js +235 -235
  120. package/{test → dsh-mneme/test}/recall-layer.test.js +315 -315
  121. package/{test → dsh-mneme/test}/receipt-chain.test.js +451 -451
  122. package/{test → dsh-mneme/test}/reflection.test.js +226 -226
  123. package/{test → dsh-mneme/test}/reranker.test.js +240 -240
  124. package/{test → dsh-mneme/test}/search-fusion.test.js +90 -90
  125. package/{test → dsh-mneme/test}/semantic.test.js +124 -124
  126. package/{test → dsh-mneme/test}/service-search.test.js +199 -199
  127. package/{test → dsh-mneme/test}/service.test.js +435 -435
  128. package/{test → dsh-mneme/test}/settings.test.js +118 -118
  129. package/{test → dsh-mneme/test}/sleep.test.js +365 -365
  130. package/{test → dsh-mneme/test}/store.test.js +436 -436
  131. package/{test → dsh-mneme/test}/stress.test.js +209 -209
  132. package/{test → dsh-mneme/test}/summarize.test.js +191 -191
  133. package/{test → dsh-mneme/test}/tag-boost.test.js +125 -125
  134. package/{test → dsh-mneme/test}/tag.test.js +312 -312
  135. package/{test → dsh-mneme/test}/tools.test.js +285 -285
  136. package/{test → dsh-mneme/test}/vector-index.test.js +221 -221
  137. package/{test → dsh-mneme/test}/wiki-link.test.js +332 -332
  138. package/package.json +18 -40
  139. package//346/250/252/345/271/205.png +0 -0
@@ -1,236 +1,236 @@
1
- import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
2
-
3
- const SUMMARY_PROMPT = `你是记忆库提炼助手。根据下面的会话内容,提炼 2-3 条值得跨会话记住的记忆。
4
- 只输出 JSON 数组,每项形如 {"type":"preference|project|decision|history","title":"简短标题","content":"一句话内容","importance":1-5}。
5
- 不要输出任何其他文字。`;
6
-
7
- /** Extract a JSON array from LLM output that may contain prose around it. */
8
- export function parseSummaryJson(raw) {
9
- const text = String(raw ?? "");
10
- const start = text.indexOf("[");
11
- const end = text.lastIndexOf("]");
12
- if (start === -1 || end === -1 || end <= start) return [];
13
- let arr;
14
- try {
15
- arr = JSON.parse(text.slice(start, end + 1));
16
- } catch {
17
- return [];
18
- }
19
- if (!Array.isArray(arr)) return [];
20
- const VALID = new Set(["preference", "project", "decision", "history"]);
21
- return arr.filter(
22
- (item) =>
23
- item &&
24
- typeof item === "object" &&
25
- VALID.has(item.type) &&
26
- typeof item.title === "string" &&
27
- item.title.trim() &&
28
- typeof item.content === "string" &&
29
- item.content.trim()
30
- ).map((item) => ({
31
- type: item.type,
32
- title: item.title.trim(),
33
- content: item.content.trim(),
34
- importance: Number.isInteger(item.importance) ? Math.min(5, Math.max(1, item.importance)) : 3
35
- }));
36
- }
37
-
38
- // The dsh-llm StreamChunk protocol BlockAssembler.push() consumes:
39
- // block-start {index, blockType}, text-delta {index, text},
40
- // block-end {index, block}, finish {reason}. Some consumers observe a
41
- // looser shape ({block} / {delta} / {kind}); normalize before pushing so
42
- // both real adapter streams and shape-tolerant test doubles assemble.
43
- const STREAM_CHUNK_TYPES = new Set([
44
- "block-start",
45
- "text-delta",
46
- "reasoning-delta",
47
- "tool-call-delta",
48
- "block-end",
49
- "usage",
50
- "finish"
51
- ]);
52
-
53
- function toProtocolChunk(chunk) {
54
- switch (chunk.type) {
55
- case "block-start":
56
- return { type: "block-start", index: chunk.index ?? 0, blockType: chunk.blockType ?? chunk.block?.type ?? "text" };
57
- case "text-delta":
58
- return { type: "text-delta", index: chunk.index ?? 0, text: chunk.text ?? chunk.delta ?? "" };
59
- case "reasoning-delta":
60
- return { type: "reasoning-delta", index: chunk.index ?? 0, text: chunk.text ?? chunk.delta ?? "" };
61
- case "block-end":
62
- return { type: "block-end", index: chunk.index ?? 0, block: chunk.block ?? { type: "text" } };
63
- case "finish":
64
- return {
65
- type: "finish",
66
- reason: chunk.reason ?? { kind: chunk.kind === "error" ? "error" : "stop" },
67
- replayState: chunk.replayState
68
- };
69
- default:
70
- return chunk;
71
- }
72
- }
73
-
74
- // Only direct human prompts are summarized: plugin-injected context
75
- // (AGENTS.md, skill bodies, file-change notices) and other machine-originated
76
- // events must not leak into the memory store. Events without a data payload
77
- // (minimal test doubles) pass the kind check and are handled by the content
78
- // check below.
79
- function collectMessages(session) {
80
- const messages = [];
81
- for (const event of session.events ?? []) {
82
- const kind = event.data?.source?.kind;
83
- if (event.type !== "user/message") continue;
84
- if (kind !== undefined && kind !== "user") continue;
85
- if (!event.data?.content?.length) continue; // nothing to summarize
86
- messages.push(createUserMessage({ content: event.data.content }));
87
- }
88
- return messages.slice(-20);
89
- }
90
-
91
- export function createSummarizer(ctx, service, config) {
92
- if (!config.autoSummarize) return { dispose: () => {} };
93
-
94
- const inFlight = new Map();
95
- let disposed = false;
96
-
97
- async function summarize(session) {
98
- if (disposed || inFlight.has(session.id)) return;
99
- const controller = new AbortController();
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;
107
- try {
108
- const header = session.requestHeader?.()?.config;
109
- // Config override takes priority, then session header, then nothing.
110
- const route = (config.summarizeProvider && config.summarizeModel)
111
- ? { provider: config.summarizeProvider, model: config.summarizeModel }
112
- : (header?.provider && header?.model)
113
- ? { provider: header.provider, model: header.model }
114
- : undefined;
115
- if (!route) return;
116
- const messages = collectMessages(session);
117
- if (!messages.length) return;
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
-
131
- const assembler = new BlockAssembler();
132
- let text = "";
133
- const options = {
134
- provider: route.provider,
135
- model: route.model,
136
- purpose: "summarization",
137
- messages: [
138
- { role: "system", content: [{ type: "text", text: SUMMARY_PROMPT }] },
139
- ...messages
140
- ],
141
- signal: controller.signal
142
- };
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
- }
165
- }
166
- } catch (error) {
167
- if (audit) {
168
- audit.status = "error";
169
- audit.errorMessage = String(error?.message ?? error);
170
- }
171
- throw error; // caller's catch handles the failure; audit already staged
172
- }
173
- // Direct delta accumulation is the primary extraction path (it works
174
- // for real protocol chunks {index,text} and looser {delta} shapes
175
- // alike); the assembler blocks are a fallback for streams that only
176
- // deliver text inside block-end. This dsh-llm exposes no public
177
- // no-arg assemble() — blocks() is the message-level API.
178
- const blocks = assembler.blocks();
179
- const assembledText = blocks
180
- .filter((b) => b.type === "text")
181
- .map((b) => b.text ?? "")
182
- .join("");
183
- const entries = parseSummaryJson(text || assembledText);
184
- for (const entry of entries) {
185
- // Provenance: the summarizer runs on a real session (turn/end hook), so
186
- // session.id is always available here — it rides both the human-readable
187
- // source label and the structured session_id column (v0.5.x memory
188
- // provenance, the raw material for v0.6.0 reasoning-path / drift analysis).
189
- service.saveWithDedupe({ ...entry, source: `session:${session.id}`, session_id: session.id });
190
- }
191
- } finally {
192
- if (audit) {
193
- try {
194
- service.saveLlmAudit({
195
- timestamp: audit.timestamp,
196
- trigger_source: "autoSummarize",
197
- operation_type: "summarize_compress",
198
- model_id: `${audit.route.provider}:${audit.route.model}`,
199
- input_tokens: audit.inputTokens,
200
- output_tokens: audit.outputTokens,
201
- total_tokens: audit.inputTokens + audit.outputTokens,
202
- cost_usd: 0,
203
- duration_ms: Date.now() - audit.startedAt,
204
- status: audit.status,
205
- error_message: audit.errorMessage,
206
- related_memory_ids: []
207
- });
208
- } catch (auditError) {
209
- ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
210
- }
211
- }
212
- inFlight.delete(session.id);
213
- }
214
- }
215
-
216
- const unsubscribe = ctx.on("session/event", (session, event) => {
217
- if (disposed || event.type !== "turn/end") return;
218
- // Return the summarization promise so awaiters observe the writes; the
219
- // catch keeps listener dispatch from rejecting. Dispose-initiated aborts
220
- // and external AbortErrors are silent.
221
- return summarize(session).catch((error) => {
222
- if (disposed || error?.name === "AbortError") return;
223
- ctx.logger?.warn?.(`dsh-mneme: summarization failed: ${String(error)}`);
224
- });
225
- });
226
-
227
- return {
228
- dispose() {
229
- if (disposed) return;
230
- disposed = true;
231
- unsubscribe?.();
232
- for (const controller of inFlight.values()) controller.abort();
233
- inFlight.clear();
234
- }
235
- };
236
- }
1
+ import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
2
+
3
+ const SUMMARY_PROMPT = `你是记忆库提炼助手。根据下面的会话内容,提炼 2-3 条值得跨会话记住的记忆。
4
+ 只输出 JSON 数组,每项形如 {"type":"preference|project|decision|history","title":"简短标题","content":"一句话内容","importance":1-5}。
5
+ 不要输出任何其他文字。`;
6
+
7
+ /** Extract a JSON array from LLM output that may contain prose around it. */
8
+ export function parseSummaryJson(raw) {
9
+ const text = String(raw ?? "");
10
+ const start = text.indexOf("[");
11
+ const end = text.lastIndexOf("]");
12
+ if (start === -1 || end === -1 || end <= start) return [];
13
+ let arr;
14
+ try {
15
+ arr = JSON.parse(text.slice(start, end + 1));
16
+ } catch {
17
+ return [];
18
+ }
19
+ if (!Array.isArray(arr)) return [];
20
+ const VALID = new Set(["preference", "project", "decision", "history"]);
21
+ return arr.filter(
22
+ (item) =>
23
+ item &&
24
+ typeof item === "object" &&
25
+ VALID.has(item.type) &&
26
+ typeof item.title === "string" &&
27
+ item.title.trim() &&
28
+ typeof item.content === "string" &&
29
+ item.content.trim()
30
+ ).map((item) => ({
31
+ type: item.type,
32
+ title: item.title.trim(),
33
+ content: item.content.trim(),
34
+ importance: Number.isInteger(item.importance) ? Math.min(5, Math.max(1, item.importance)) : 3
35
+ }));
36
+ }
37
+
38
+ // The dsh-llm StreamChunk protocol BlockAssembler.push() consumes:
39
+ // block-start {index, blockType}, text-delta {index, text},
40
+ // block-end {index, block}, finish {reason}. Some consumers observe a
41
+ // looser shape ({block} / {delta} / {kind}); normalize before pushing so
42
+ // both real adapter streams and shape-tolerant test doubles assemble.
43
+ const STREAM_CHUNK_TYPES = new Set([
44
+ "block-start",
45
+ "text-delta",
46
+ "reasoning-delta",
47
+ "tool-call-delta",
48
+ "block-end",
49
+ "usage",
50
+ "finish"
51
+ ]);
52
+
53
+ function toProtocolChunk(chunk) {
54
+ switch (chunk.type) {
55
+ case "block-start":
56
+ return { type: "block-start", index: chunk.index ?? 0, blockType: chunk.blockType ?? chunk.block?.type ?? "text" };
57
+ case "text-delta":
58
+ return { type: "text-delta", index: chunk.index ?? 0, text: chunk.text ?? chunk.delta ?? "" };
59
+ case "reasoning-delta":
60
+ return { type: "reasoning-delta", index: chunk.index ?? 0, text: chunk.text ?? chunk.delta ?? "" };
61
+ case "block-end":
62
+ return { type: "block-end", index: chunk.index ?? 0, block: chunk.block ?? { type: "text" } };
63
+ case "finish":
64
+ return {
65
+ type: "finish",
66
+ reason: chunk.reason ?? { kind: chunk.kind === "error" ? "error" : "stop" },
67
+ replayState: chunk.replayState
68
+ };
69
+ default:
70
+ return chunk;
71
+ }
72
+ }
73
+
74
+ // Only direct human prompts are summarized: plugin-injected context
75
+ // (AGENTS.md, skill bodies, file-change notices) and other machine-originated
76
+ // events must not leak into the memory store. Events without a data payload
77
+ // (minimal test doubles) pass the kind check and are handled by the content
78
+ // check below.
79
+ function collectMessages(session) {
80
+ const messages = [];
81
+ for (const event of session.events ?? []) {
82
+ const kind = event.data?.source?.kind;
83
+ if (event.type !== "user/message") continue;
84
+ if (kind !== undefined && kind !== "user") continue;
85
+ if (!event.data?.content?.length) continue; // nothing to summarize
86
+ messages.push(createUserMessage({ content: event.data.content }));
87
+ }
88
+ return messages.slice(-20);
89
+ }
90
+
91
+ export function createSummarizer(ctx, service, config) {
92
+ if (!config.autoSummarize) return { dispose: () => {} };
93
+
94
+ const inFlight = new Map();
95
+ let disposed = false;
96
+
97
+ async function summarize(session) {
98
+ if (disposed || inFlight.has(session.id)) return;
99
+ const controller = new AbortController();
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;
107
+ try {
108
+ const header = session.requestHeader?.()?.config;
109
+ // Config override takes priority, then session header, then nothing.
110
+ const route = (config.summarizeProvider && config.summarizeModel)
111
+ ? { provider: config.summarizeProvider, model: config.summarizeModel }
112
+ : (header?.provider && header?.model)
113
+ ? { provider: header.provider, model: header.model }
114
+ : undefined;
115
+ if (!route) return;
116
+ const messages = collectMessages(session);
117
+ if (!messages.length) return;
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
+
131
+ const assembler = new BlockAssembler();
132
+ let text = "";
133
+ const options = {
134
+ provider: route.provider,
135
+ model: route.model,
136
+ purpose: "summarization",
137
+ messages: [
138
+ { role: "system", content: [{ type: "text", text: SUMMARY_PROMPT }] },
139
+ ...messages
140
+ ],
141
+ signal: controller.signal
142
+ };
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
+ }
165
+ }
166
+ } catch (error) {
167
+ if (audit) {
168
+ audit.status = "error";
169
+ audit.errorMessage = String(error?.message ?? error);
170
+ }
171
+ throw error; // caller's catch handles the failure; audit already staged
172
+ }
173
+ // Direct delta accumulation is the primary extraction path (it works
174
+ // for real protocol chunks {index,text} and looser {delta} shapes
175
+ // alike); the assembler blocks are a fallback for streams that only
176
+ // deliver text inside block-end. This dsh-llm exposes no public
177
+ // no-arg assemble() — blocks() is the message-level API.
178
+ const blocks = assembler.blocks();
179
+ const assembledText = blocks
180
+ .filter((b) => b.type === "text")
181
+ .map((b) => b.text ?? "")
182
+ .join("");
183
+ const entries = parseSummaryJson(text || assembledText);
184
+ for (const entry of entries) {
185
+ // Provenance: the summarizer runs on a real session (turn/end hook), so
186
+ // session.id is always available here — it rides both the human-readable
187
+ // source label and the structured session_id column (v0.5.x memory
188
+ // provenance, the raw material for v0.6.0 reasoning-path / drift analysis).
189
+ service.saveWithDedupe({ ...entry, source: `session:${session.id}`, session_id: session.id });
190
+ }
191
+ } finally {
192
+ if (audit) {
193
+ try {
194
+ service.saveLlmAudit({
195
+ timestamp: audit.timestamp,
196
+ trigger_source: "autoSummarize",
197
+ operation_type: "summarize_compress",
198
+ model_id: `${audit.route.provider}:${audit.route.model}`,
199
+ input_tokens: audit.inputTokens,
200
+ output_tokens: audit.outputTokens,
201
+ total_tokens: audit.inputTokens + audit.outputTokens,
202
+ cost_usd: 0,
203
+ duration_ms: Date.now() - audit.startedAt,
204
+ status: audit.status,
205
+ error_message: audit.errorMessage,
206
+ related_memory_ids: []
207
+ });
208
+ } catch (auditError) {
209
+ ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
210
+ }
211
+ }
212
+ inFlight.delete(session.id);
213
+ }
214
+ }
215
+
216
+ const unsubscribe = ctx.on("session/event", (session, event) => {
217
+ if (disposed || event.type !== "turn/end") return;
218
+ // Return the summarization promise so awaiters observe the writes; the
219
+ // catch keeps listener dispatch from rejecting. Dispose-initiated aborts
220
+ // and external AbortErrors are silent.
221
+ return summarize(session).catch((error) => {
222
+ if (disposed || error?.name === "AbortError") return;
223
+ ctx.logger?.warn?.(`dsh-mneme: summarization failed: ${String(error)}`);
224
+ });
225
+ });
226
+
227
+ return {
228
+ dispose() {
229
+ if (disposed) return;
230
+ disposed = true;
231
+ unsubscribe?.();
232
+ for (const controller of inFlight.values()) controller.abort();
233
+ inFlight.clear();
234
+ }
235
+ };
236
+ }