@modusensus/dsh-mneme 0.1.6 → 0.2.0

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/lib/summarize.js CHANGED
@@ -1,171 +1,171 @@
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
- try {
102
- const header = session.requestHeader?.()?.config;
103
- const route = header?.provider && header?.model
104
- ? { provider: header.provider, model: header.model }
105
- : undefined;
106
- if (!route) return;
107
- const messages = collectMessages(session);
108
- if (!messages.length) return;
109
-
110
- const assembler = new BlockAssembler();
111
- let text = "";
112
- const options = {
113
- provider: route.provider,
114
- model: route.model,
115
- purpose: "summarization",
116
- messages: [
117
- { role: "system", content: [{ type: "text", text: SUMMARY_PROMPT }] },
118
- ...messages
119
- ],
120
- signal: controller.signal
121
- };
122
- for await (const chunk of ctx.llm.stream(options)) {
123
- if (STREAM_CHUNK_TYPES.has(chunk.type)) assembler.push(toProtocolChunk(chunk));
124
- if (chunk.type === "text-delta") {
125
- text += chunk.text ?? chunk.delta ?? "";
126
- }
127
- if (chunk.type === "finish") {
128
- const reasonKind = chunk.reason?.kind ?? chunk.kind;
129
- if (reasonKind === "error" || reasonKind === "aborted") return;
130
- }
131
- }
132
- // Direct delta accumulation is the primary extraction path (it works
133
- // for real protocol chunks {index,text} and looser {delta} shapes
134
- // alike); the assembler blocks are a fallback for streams that only
135
- // deliver text inside block-end. This dsh-llm exposes no public
136
- // no-arg assemble() — blocks() is the message-level API.
137
- const blocks = assembler.blocks();
138
- const assembledText = blocks
139
- .filter((b) => b.type === "text")
140
- .map((b) => b.text ?? "")
141
- .join("");
142
- const entries = parseSummaryJson(text || assembledText);
143
- for (const entry of entries) {
144
- service.saveWithDedupe({ ...entry, source: `session:${session.id}` });
145
- }
146
- } finally {
147
- inFlight.delete(session.id);
148
- }
149
- }
150
-
151
- const unsubscribe = ctx.on("session/event", (session, event) => {
152
- if (disposed || event.type !== "turn/end") return;
153
- // Return the summarization promise so awaiters observe the writes; the
154
- // catch keeps listener dispatch from rejecting. Dispose-initiated aborts
155
- // and external AbortErrors are silent.
156
- return summarize(session).catch((error) => {
157
- if (disposed || error?.name === "AbortError") return;
158
- ctx.logger?.warn?.(`dsh-mneme: summarization failed: ${String(error)}`);
159
- });
160
- });
161
-
162
- return {
163
- dispose() {
164
- if (disposed) return;
165
- disposed = true;
166
- unsubscribe?.();
167
- for (const controller of inFlight.values()) controller.abort();
168
- inFlight.clear();
169
- }
170
- };
171
- }
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
+ try {
102
+ const header = session.requestHeader?.()?.config;
103
+ const route = header?.provider && header?.model
104
+ ? { provider: header.provider, model: header.model }
105
+ : undefined;
106
+ if (!route) return;
107
+ const messages = collectMessages(session);
108
+ if (!messages.length) return;
109
+
110
+ const assembler = new BlockAssembler();
111
+ let text = "";
112
+ const options = {
113
+ provider: route.provider,
114
+ model: route.model,
115
+ purpose: "summarization",
116
+ messages: [
117
+ { role: "system", content: [{ type: "text", text: SUMMARY_PROMPT }] },
118
+ ...messages
119
+ ],
120
+ signal: controller.signal
121
+ };
122
+ for await (const chunk of ctx.llm.stream(options)) {
123
+ if (STREAM_CHUNK_TYPES.has(chunk.type)) assembler.push(toProtocolChunk(chunk));
124
+ if (chunk.type === "text-delta") {
125
+ text += chunk.text ?? chunk.delta ?? "";
126
+ }
127
+ if (chunk.type === "finish") {
128
+ const reasonKind = chunk.reason?.kind ?? chunk.kind;
129
+ if (reasonKind === "error" || reasonKind === "aborted") return;
130
+ }
131
+ }
132
+ // Direct delta accumulation is the primary extraction path (it works
133
+ // for real protocol chunks {index,text} and looser {delta} shapes
134
+ // alike); the assembler blocks are a fallback for streams that only
135
+ // deliver text inside block-end. This dsh-llm exposes no public
136
+ // no-arg assemble() — blocks() is the message-level API.
137
+ const blocks = assembler.blocks();
138
+ const assembledText = blocks
139
+ .filter((b) => b.type === "text")
140
+ .map((b) => b.text ?? "")
141
+ .join("");
142
+ const entries = parseSummaryJson(text || assembledText);
143
+ for (const entry of entries) {
144
+ service.saveWithDedupe({ ...entry, source: `session:${session.id}` });
145
+ }
146
+ } finally {
147
+ inFlight.delete(session.id);
148
+ }
149
+ }
150
+
151
+ const unsubscribe = ctx.on("session/event", (session, event) => {
152
+ if (disposed || event.type !== "turn/end") return;
153
+ // Return the summarization promise so awaiters observe the writes; the
154
+ // catch keeps listener dispatch from rejecting. Dispose-initiated aborts
155
+ // and external AbortErrors are silent.
156
+ return summarize(session).catch((error) => {
157
+ if (disposed || error?.name === "AbortError") return;
158
+ ctx.logger?.warn?.(`dsh-mneme: summarization failed: ${String(error)}`);
159
+ });
160
+ });
161
+
162
+ return {
163
+ dispose() {
164
+ if (disposed) return;
165
+ disposed = true;
166
+ unsubscribe?.();
167
+ for (const controller of inFlight.values()) controller.abort();
168
+ inFlight.clear();
169
+ }
170
+ };
171
+ }