@geminilight/mindos 0.5.11 → 0.5.13

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 (42) hide show
  1. package/README.md +9 -9
  2. package/README_zh.md +9 -9
  3. package/app/README.md +2 -2
  4. package/app/app/api/ask/route.ts +191 -19
  5. package/app/app/api/mcp/install/route.ts +1 -1
  6. package/app/app/api/mcp/status/route.ts +11 -16
  7. package/app/app/api/settings/route.ts +3 -1
  8. package/app/app/api/setup/route.ts +7 -7
  9. package/app/app/api/sync/route.ts +18 -15
  10. package/app/components/AskModal.tsx +28 -32
  11. package/app/components/SettingsModal.tsx +7 -3
  12. package/app/components/ask/MessageList.tsx +65 -3
  13. package/app/components/ask/ThinkingBlock.tsx +55 -0
  14. package/app/components/ask/ToolCallBlock.tsx +97 -0
  15. package/app/components/settings/AiTab.tsx +76 -2
  16. package/app/components/settings/types.ts +8 -0
  17. package/app/components/setup/StepReview.tsx +31 -25
  18. package/app/components/setup/index.tsx +6 -3
  19. package/app/lib/agent/context.ts +317 -0
  20. package/app/lib/agent/index.ts +4 -0
  21. package/app/lib/agent/prompt.ts +46 -31
  22. package/app/lib/agent/stream-consumer.ts +212 -0
  23. package/app/lib/agent/tools.ts +159 -4
  24. package/app/lib/i18n.ts +28 -0
  25. package/app/lib/settings.ts +22 -0
  26. package/app/lib/types.ts +23 -0
  27. package/app/package.json +2 -3
  28. package/bin/cli.js +41 -21
  29. package/bin/lib/build.js +6 -2
  30. package/bin/lib/gateway.js +24 -3
  31. package/bin/lib/mcp-install.js +2 -2
  32. package/bin/lib/mcp-spawn.js +3 -3
  33. package/bin/lib/stop.js +1 -1
  34. package/bin/lib/sync.js +81 -40
  35. package/mcp/README.md +5 -5
  36. package/mcp/src/index.ts +2 -2
  37. package/package.json +3 -2
  38. package/scripts/setup.js +17 -12
  39. package/scripts/upgrade-prompt.md +6 -6
  40. package/skills/mindos/SKILL.md +47 -183
  41. package/skills/mindos-zh/SKILL.md +47 -183
  42. package/app/package-lock.json +0 -15615
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Phase 3: Context management — token estimation, compaction, tool output truncation.
3
+ *
4
+ * All operations are request-scoped (no persistence to frontend session).
5
+ */
6
+ import { generateText, type ModelMessage, type ToolResultPart, type ToolModelMessage } from 'ai';
7
+ import type { LanguageModel } from 'ai';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Token estimation (1 token ≈ 4 chars)
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** Rough token count for a single ModelMessage */
14
+ function messageTokens(msg: ModelMessage): number {
15
+ if (typeof msg.content === 'string') return Math.ceil(msg.content.length / 4);
16
+ if (Array.isArray(msg.content)) {
17
+ let chars = 0;
18
+ for (const part of msg.content) {
19
+ if ('text' in part && typeof part.text === 'string') chars += part.text.length;
20
+ if ('value' in part && typeof part.value === 'string') chars += part.value.length;
21
+ if ('input' in part) chars += JSON.stringify(part.input).length;
22
+ }
23
+ return Math.ceil(chars / 4);
24
+ }
25
+ return 0;
26
+ }
27
+
28
+ /** Estimate total tokens for a message array */
29
+ export function estimateTokens(messages: ModelMessage[]): number {
30
+ let total = 0;
31
+ for (const m of messages) total += messageTokens(m);
32
+ return total;
33
+ }
34
+
35
+ /** Estimate tokens for a plain string (e.g. system prompt) */
36
+ export function estimateStringTokens(text: string): number {
37
+ return Math.ceil(text.length / 4);
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Context limits by model family
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const MODEL_LIMITS: Record<string, number> = {
45
+ 'claude': 200_000,
46
+ 'gpt-4o': 128_000,
47
+ 'gpt-4': 128_000,
48
+ 'gpt-3.5': 16_000,
49
+ 'gpt-5': 200_000,
50
+ };
51
+
52
+ // Sort by prefix length descending so "gpt-4o" matches before "gpt-4"
53
+ const MODEL_LIMIT_ENTRIES = Object.entries(MODEL_LIMITS)
54
+ .sort((a, b) => b[0].length - a[0].length);
55
+
56
+ /** Get context token limit for a model string */
57
+ export function getContextLimit(model: string): number {
58
+ const lower = model.toLowerCase();
59
+ for (const [prefix, limit] of MODEL_LIMIT_ENTRIES) {
60
+ if (lower.includes(prefix)) return limit;
61
+ }
62
+ return 100_000; // conservative default
63
+ }
64
+
65
+ /** Check if messages + system prompt exceed threshold of context limit */
66
+ export function needsCompact(
67
+ messages: ModelMessage[],
68
+ systemPrompt: string,
69
+ model: string,
70
+ threshold = 0.7,
71
+ ): boolean {
72
+ const total = estimateTokens(messages) + estimateStringTokens(systemPrompt);
73
+ const limit = getContextLimit(model);
74
+ return total > limit * threshold;
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Tool output truncation (per-tool-type thresholds)
79
+ // ---------------------------------------------------------------------------
80
+
81
+ const TOOL_OUTPUT_LIMITS: Record<string, number> = {
82
+ // List/search tools — only need to know "what was found"
83
+ search: 500,
84
+ list_files: 500,
85
+ get_recent: 500,
86
+ get_backlinks: 500,
87
+ get_history: 500,
88
+ // Read tools — some context value, but not full file
89
+ read_file: 2000,
90
+ get_file_at_version: 2000,
91
+ // Write tools — only need success/failure
92
+ write_file: 200,
93
+ create_file: 200,
94
+ delete_file: 200,
95
+ rename_file: 200,
96
+ move_file: 200,
97
+ append_to_file: 200,
98
+ insert_after_heading: 200,
99
+ update_section: 200,
100
+ append_csv: 200,
101
+ };
102
+
103
+ /**
104
+ * Truncate tool outputs in historical messages to save tokens.
105
+ * Only truncates non-last tool messages (the last tool message is kept intact
106
+ * because the model may need its full output for the current step).
107
+ */
108
+ export function truncateToolOutputs(messages: ModelMessage[]): ModelMessage[] {
109
+ // Find the index of the last 'tool' role message
110
+ let lastToolIdx = -1;
111
+ for (let i = messages.length - 1; i >= 0; i--) {
112
+ if (messages[i].role === 'tool') { lastToolIdx = i; break; }
113
+ }
114
+
115
+ return messages.map((msg, idx) => {
116
+ if (msg.role !== 'tool' || idx === lastToolIdx) return msg;
117
+
118
+ const toolMsg = msg as ToolModelMessage;
119
+ const truncatedContent = toolMsg.content.map(part => {
120
+ if (part.type !== 'tool-result') return part;
121
+ const trp = part as ToolResultPart;
122
+ const toolName = trp.toolName ?? '';
123
+ const limit = TOOL_OUTPUT_LIMITS[toolName] ?? 500;
124
+ if (!trp.output || typeof trp.output !== 'object' || trp.output.type !== 'text') return part;
125
+ if (trp.output.value.length <= limit) return part;
126
+
127
+ return {
128
+ ...trp,
129
+ output: {
130
+ ...trp.output,
131
+ value: trp.output.value.slice(0, limit) + `\n[...truncated from ${trp.output.value.length} chars]`,
132
+ },
133
+ } satisfies ToolResultPart;
134
+ });
135
+
136
+ return { ...toolMsg, content: truncatedContent } satisfies ToolModelMessage;
137
+ });
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Compact: summarize early messages via LLM
142
+ // ---------------------------------------------------------------------------
143
+
144
+ const COMPACT_PROMPT = `Summarize the key points, decisions, and file operations from this conversation in under 500 words. Focus on:
145
+ - What the user asked for
146
+ - What files were read, created, or modified
147
+ - Key decisions and outcomes
148
+ - Any unresolved issues
149
+
150
+ Be concise and factual. Output only the summary, no preamble.`;
151
+
152
+ /** Extract a short text representation from a ModelMessage for summarization */
153
+ function messageToText(m: ModelMessage): string {
154
+ const role = m.role;
155
+ let content = '';
156
+ if (typeof m.content === 'string') {
157
+ content = m.content;
158
+ } else if (Array.isArray(m.content)) {
159
+ const pieces: string[] = [];
160
+ for (const part of m.content) {
161
+ if ('text' in part && typeof (part as { text?: string }).text === 'string') {
162
+ pieces.push((part as { text: string }).text);
163
+ } else if (part.type === 'tool-call' && 'toolName' in part) {
164
+ pieces.push(`[Tool: ${(part as { toolName: string }).toolName}]`);
165
+ } else if (part.type === 'tool-result' && 'output' in part) {
166
+ const trp = part as ToolResultPart;
167
+ const val = trp.output && typeof trp.output === 'object' && trp.output.type === 'text' ? trp.output.value : '';
168
+ pieces.push(`[Result: ${val.slice(0, 200)}]`);
169
+ }
170
+ }
171
+ content = pieces.filter(Boolean).join(' ');
172
+ }
173
+ return `${role}: ${content}`;
174
+ }
175
+
176
+ /**
177
+ * Compact messages by summarizing early ones with LLM.
178
+ * Returns a new message array with early messages replaced by a summary.
179
+ * Only called when needsCompact() returns true.
180
+ *
181
+ * NOTE: Currently uses the same model as the main generation. A cheaper model
182
+ * (e.g. haiku) would suffice for summarization and avoid competing for rate
183
+ * limits. Deferred until users report rate-limit issues — compact triggers
184
+ * infrequently (>70% context fill).
185
+ */
186
+ export async function compactMessages(
187
+ messages: ModelMessage[],
188
+ model: LanguageModel,
189
+ ): Promise<{ messages: ModelMessage[]; compacted: boolean }> {
190
+ if (messages.length < 6) {
191
+ return { messages, compacted: false };
192
+ }
193
+
194
+ // Keep the last 6 messages intact, summarize the rest.
195
+ // Adjust split point to avoid cutting between an assistant (with tool calls)
196
+ // and its tool result. Only need to check for orphaned 'tool' messages —
197
+ // an assistant at the split point is safe because its tool results follow it.
198
+ // (Orphaned assistants without results can't exist in history: only completed
199
+ // tool calls are persisted by the frontend.)
200
+ let splitIdx = messages.length - 6;
201
+ while (splitIdx > 0 && messages[splitIdx]?.role === 'tool') {
202
+ splitIdx--;
203
+ }
204
+ if (splitIdx < 2) {
205
+ return { messages, compacted: false };
206
+ }
207
+ const earlyMessages = messages.slice(0, splitIdx);
208
+ const recentMessages = messages.slice(splitIdx);
209
+
210
+ // Build a text representation of early messages for summarization
211
+ let earlyText = earlyMessages.map(messageToText).join('\n\n');
212
+
213
+ // Truncate if enormous (avoid sending too much to summarizer)
214
+ if (earlyText.length > 30_000) {
215
+ earlyText = earlyText.slice(0, 30_000) + '\n[...truncated]';
216
+ }
217
+
218
+ try {
219
+ const { text: summary } = await generateText({
220
+ model,
221
+ prompt: `${COMPACT_PROMPT}\n\n---\n\nConversation to summarize:\n\n${earlyText}`,
222
+ });
223
+
224
+ console.log(`[ask] Compacted ${earlyMessages.length} early messages into summary (${summary.length} chars)`);
225
+
226
+ const summaryText = `[Summary of earlier conversation]\n\n${summary}`;
227
+
228
+ // If first recent message is also 'user', merge summary into it to avoid
229
+ // consecutive user messages (Anthropic rejects user→user sequences).
230
+ if (recentMessages[0]?.role === 'user') {
231
+ const merged = { ...recentMessages[0] };
232
+ if (typeof merged.content === 'string') {
233
+ merged.content = `${summaryText}\n\n---\n\n${merged.content}`;
234
+ } else if (Array.isArray(merged.content)) {
235
+ // Multimodal content (e.g. images) — prepend summary as text part
236
+ merged.content = [{ type: 'text' as const, text: `${summaryText}\n\n---\n\n` }, ...merged.content];
237
+ } else {
238
+ merged.content = summaryText;
239
+ }
240
+ return {
241
+ messages: [merged, ...recentMessages.slice(1)],
242
+ compacted: true,
243
+ };
244
+ }
245
+
246
+ // Otherwise prepend as separate user message
247
+ const summaryMessage: ModelMessage = {
248
+ role: 'user',
249
+ content: summaryText,
250
+ };
251
+
252
+ return {
253
+ messages: [summaryMessage, ...recentMessages],
254
+ compacted: true,
255
+ };
256
+ } catch (err) {
257
+ console.error('[ask] Compact failed, using uncompacted messages:', err);
258
+ return { messages, compacted: false };
259
+ }
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Hard prune: drop earliest messages as last resort (>90% context)
264
+ // ---------------------------------------------------------------------------
265
+
266
+ /**
267
+ * Hard prune: if still over 90% context after compact, drop earliest messages.
268
+ * Respects assistant-tool pairs: never cuts between an assistant message
269
+ * (containing tool calls) and its following tool result message.
270
+ */
271
+ export function hardPrune(
272
+ messages: ModelMessage[],
273
+ systemPrompt: string,
274
+ model: string,
275
+ ): ModelMessage[] {
276
+ const limit = getContextLimit(model);
277
+ const threshold = limit * 0.9;
278
+ const systemTokens = estimateStringTokens(systemPrompt);
279
+
280
+ let total = systemTokens + estimateTokens(messages);
281
+ if (total <= threshold) return messages;
282
+
283
+ // Find the cut index: keep messages from cutIdx onward
284
+ let cutIdx = 0;
285
+ while (cutIdx < messages.length - 2 && total > threshold) {
286
+ total -= messageTokens(messages[cutIdx]);
287
+ cutIdx++;
288
+ }
289
+
290
+ // Ensure we don't cut between an assistant (with tool calls) and its tool result.
291
+ // If cutIdx lands on a 'tool' message, advance past it so the pair stays together
292
+ // or is fully removed.
293
+ while (cutIdx < messages.length - 1 && messages[cutIdx].role === 'tool') {
294
+ total -= messageTokens(messages[cutIdx]);
295
+ cutIdx++;
296
+ }
297
+
298
+ // Ensure first message is 'user' (Anthropic requirement)
299
+ while (cutIdx < messages.length - 1 && messages[cutIdx].role !== 'user') {
300
+ total -= messageTokens(messages[cutIdx]);
301
+ cutIdx++;
302
+ }
303
+
304
+ // Fallback: if no user message found in remaining messages, inject a synthetic one
305
+ const pruned = cutIdx > 0 ? messages.slice(cutIdx) : messages;
306
+ if (pruned.length > 0 && pruned[0].role !== 'user') {
307
+ console.log(`[ask] Hard pruned ${cutIdx} messages, injecting synthetic user message (${messages.length} → ${pruned.length + 1})`);
308
+ return [{ role: 'user', content: '[Conversation context was pruned due to length. Continuing from here.]' } as ModelMessage, ...pruned];
309
+ }
310
+
311
+ if (cutIdx > 0) {
312
+ console.log(`[ask] Hard pruned ${cutIdx} messages (${messages.length} → ${messages.length - cutIdx})`);
313
+ return pruned;
314
+ }
315
+
316
+ return messages;
317
+ }
@@ -1,3 +1,7 @@
1
1
  export { getModel } from './model';
2
2
  export { knowledgeBaseTools, truncate, assertWritable } from './tools';
3
3
  export { AGENT_SYSTEM_PROMPT } from './prompt';
4
+ export {
5
+ estimateTokens, estimateStringTokens, getContextLimit, needsCompact,
6
+ truncateToolOutputs, compactMessages, hardPrune,
7
+ } from './context';
@@ -1,32 +1,47 @@
1
- // Agent system prompt — v2: uploaded-file awareness + pdfjs extraction fix
2
- export const AGENT_SYSTEM_PROMPT = `You are MindOS Agent an execution-oriented AI assistant for a personal knowledge base.
3
-
4
- Runtime capabilities already available in this request:
5
- - bootstrap context (MindOS startup files) is auto-loaded by the server
6
- - mindos skill guidance is auto-loaded by the server
7
- - knowledge-base tools are available for file operations
8
-
9
- How to operate:
10
- 1. Treat the auto-loaded bootstrap + skill context as your initialization baseline.
11
- 2. If the task needs fresher or broader evidence, call tools proactively (list/search/read) before concluding.
12
- 3. Execute edits safely and minimally, then verify outcomes.
13
-
14
- Tool policy:
15
- - Always read a file before modifying it.
16
- - Use search/list tools first when file location is unclear.
17
- - Prefer targeted edits (update_section / insert_after_heading / append_to_file) over full overwrite.
18
- - Use write_file only when replacing the whole file is required.
19
- - INSTRUCTION.md is read-only and must not be modified.
20
-
21
- Uploaded files:
22
- - Users may upload local files (PDF, txt, csv, etc.) via the chat interface.
23
- - The content of uploaded files is ALREADY INCLUDED in this system prompt in a dedicated "⚠️ USER-UPLOADED FILES" section near the end.
24
- - IMPORTANT: When the user references an uploaded file (e.g. a resume/CV, a report, a document), you MUST use the content from that section directly. Extract specific details, quote relevant passages, and demonstrate that you have read the file thoroughly.
25
- - Do NOT attempt to use read_file or search tools to find uploaded files — they do not exist in the knowledge base. They are ONLY available in the uploaded files section of this prompt.
26
- - If the uploaded files section is empty or missing, tell the user the upload may have failed and ask them to re-upload.
27
-
28
- Response policy:
1
+ /**
2
+ * Agent system promptv3: de-duplicated, persona-driven, with missing instructions added.
3
+ *
4
+ * Design principles:
5
+ * - prompt.ts owns: identity, persona, global behavioral constraints, output format
6
+ * - SKILL.md owns: knowledge-base-specific execution patterns, tool selection, safety rules
7
+ * - Tool descriptions own: per-tool usage instructions (no duplication here)
8
+ *
9
+ * Token budget: ~600 tokens (down from ~900 in v2). Freed space = more room for
10
+ * SKILL.md + bootstrap context within the same context window.
11
+ */
12
+ export const AGENT_SYSTEM_PROMPT = `You are MindOS Agent — a personal knowledge-base operator that reads, writes, and organizes a user's second brain.
13
+
14
+ Persona: methodical, concise, execution-oriented. You surface what you found (or didn't find) and act on it — no filler, no caveats that add no information.
15
+
16
+ ## What is already loaded
17
+
18
+ The server auto-loads before each request:
19
+ - Bootstrap context: INSTRUCTION.md, README.md, CONFIG files, and directory-local guidance.
20
+ - Skill guidance (SKILL.md): detailed knowledge-base rules, tool selection, execution patterns.
21
+ - Tool definitions with per-tool usage instructions.
22
+
23
+ Treat these as your initialization baseline. If the task needs fresher or broader evidence, call tools proactively before concluding.
24
+
25
+ ## Behavioral rules
26
+
27
+ 1. **Read before write.** Never modify a file you haven't read in this request.
28
+ 2. **Minimal edits.** Prefer section/heading/line-level tools over full file overwrites.
29
+ 3. **Verify after edit.** Re-read the changed file to confirm correctness.
30
+ 4. **Cite sources.** When answering from stored knowledge, state the file path so the user can verify.
31
+ 5. **Fail fast.** If a tool call returns an error or unexpected result, try a different approach or ask the user — do not retry identical arguments.
32
+ 6. **Be token-aware.** You have a limited step budget (typically 10-30). Batch parallel reads/searches when possible. Do not waste steps on redundant tool calls.
33
+ 7. **Multilingual content, user-language replies.** Write file content in whatever language the file already uses. Reply to the user in the language they used.
34
+
35
+ ## Uploaded files
36
+
37
+ Users may upload local files (PDF, txt, csv, etc.) via the chat interface.
38
+ - Their content appears in a "⚠️ USER-UPLOADED FILES" section near the end of this prompt.
39
+ - Use that content directly — do NOT call read_file or search tools for uploaded files; they are not in the knowledge base.
40
+ - If the section is empty or missing, tell the user the upload may have failed.
41
+
42
+ ## Output format
43
+
29
44
  - Answer in the user's language.
30
- - Be concise, concrete, and action-oriented.
31
- - Use Markdown for structure when it improves clarity.
32
- - When relevant, explicitly state whether initialization context appears sufficient or if additional tool reads were needed.`;
45
+ - Use Markdown when it improves clarity (headings, lists, tables, code blocks).
46
+ - For multi-step tasks: output a brief numbered plan, execute, then summarize outcomes.
47
+ - End with concrete next actions when applicable.`;
@@ -0,0 +1,212 @@
1
+ import type { Message, MessagePart, ToolCallPart, TextPart, ReasoningPart } from '@/lib/types';
2
+
3
+ /**
4
+ * Parse a UIMessageStream SSE response into structured Message parts.
5
+ * The stream format is Server-Sent Events where each data line is a JSON-encoded UIMessageChunk.
6
+ */
7
+ export async function consumeUIMessageStream(
8
+ body: ReadableStream<Uint8Array>,
9
+ onUpdate: (message: Message) => void,
10
+ signal?: AbortSignal,
11
+ ): Promise<Message> {
12
+ const reader = body.getReader();
13
+ const decoder = new TextDecoder();
14
+ let buffer = '';
15
+
16
+ // Mutable working copies — we deep-clone when emitting to React
17
+ const parts: MessagePart[] = [];
18
+ const toolCalls = new Map<string, ToolCallPart>();
19
+ let currentTextId: string | null = null;
20
+ let currentReasoningPart: ReasoningPart | null = null;
21
+
22
+ /** Deep-clone parts into an immutable Message snapshot for React state */
23
+ function buildMessage(): Message {
24
+ const clonedParts: MessagePart[] = parts.map(p => {
25
+ if (p.type === 'text') return { type: 'text' as const, text: p.text };
26
+ if (p.type === 'reasoning') return { type: 'reasoning' as const, text: p.text };
27
+ return { ...p }; // ToolCallPart — shallow copy is safe (all primitive fields + `input` is replaced, not mutated)
28
+ });
29
+ const textContent = clonedParts
30
+ .filter((p): p is TextPart => p.type === 'text')
31
+ .map(p => p.text)
32
+ .join('');
33
+ return {
34
+ role: 'assistant',
35
+ content: textContent,
36
+ parts: clonedParts,
37
+ };
38
+ }
39
+
40
+ function findOrCreateTextPart(id: string): TextPart {
41
+ if (currentTextId === id) {
42
+ const last = parts[parts.length - 1];
43
+ if (last && last.type === 'text') return last;
44
+ }
45
+ const part: TextPart = { type: 'text', text: '' };
46
+ parts.push(part);
47
+ currentTextId = id;
48
+ return part;
49
+ }
50
+
51
+ function findOrCreateToolCall(toolCallId: string, toolName?: string): ToolCallPart {
52
+ let tc = toolCalls.get(toolCallId);
53
+ if (!tc) {
54
+ tc = {
55
+ type: 'tool-call',
56
+ toolCallId,
57
+ toolName: toolName ?? 'unknown',
58
+ input: undefined,
59
+ state: 'pending',
60
+ };
61
+ toolCalls.set(toolCallId, tc);
62
+ parts.push(tc);
63
+ currentTextId = null; // break text continuity
64
+ }
65
+ return tc;
66
+ }
67
+
68
+ try {
69
+ while (true) {
70
+ if (signal?.aborted) break;
71
+ const { done, value } = await reader.read();
72
+ if (done) break;
73
+
74
+ buffer += decoder.decode(value, { stream: true });
75
+
76
+ // Process complete SSE lines
77
+ const lines = buffer.split('\n');
78
+ buffer = lines.pop() ?? ''; // keep incomplete last line
79
+
80
+ let changed = false;
81
+
82
+ for (const line of lines) {
83
+ const trimmed = line.trim();
84
+
85
+ // SSE format: the ai SDK v6 UIMessageStream uses "d:{json}\n"
86
+ // Also handle standard "data:{json}" for robustness
87
+ let jsonStr: string | null = null;
88
+ if (trimmed.startsWith('d:')) {
89
+ jsonStr = trimmed.slice(2);
90
+ } else if (trimmed.startsWith('data:')) {
91
+ jsonStr = trimmed.slice(5).trim();
92
+ }
93
+
94
+ if (!jsonStr) continue;
95
+
96
+ let chunk: Record<string, unknown>;
97
+ try {
98
+ chunk = JSON.parse(jsonStr);
99
+ } catch {
100
+ continue; // skip malformed lines
101
+ }
102
+
103
+ const type = chunk.type as string;
104
+
105
+ switch (type) {
106
+ case 'text-start': {
107
+ findOrCreateTextPart(chunk.id as string);
108
+ changed = true;
109
+ break;
110
+ }
111
+ case 'text-delta': {
112
+ const part = findOrCreateTextPart(chunk.id as string);
113
+ part.text += chunk.delta as string;
114
+ changed = true;
115
+ break;
116
+ }
117
+ case 'text-end': {
118
+ // Text part is complete — no state change needed
119
+ break;
120
+ }
121
+ case 'tool-input-start': {
122
+ const tc = findOrCreateToolCall(chunk.toolCallId as string, chunk.toolName as string);
123
+ tc.state = 'running';
124
+ changed = true;
125
+ break;
126
+ }
127
+ case 'tool-input-delta': {
128
+ // Streaming input — we wait for input-available for the complete input
129
+ break;
130
+ }
131
+ case 'tool-input-available': {
132
+ const tc = findOrCreateToolCall(chunk.toolCallId as string, chunk.toolName as string);
133
+ tc.input = chunk.input;
134
+ tc.state = 'running';
135
+ changed = true;
136
+ break;
137
+ }
138
+ case 'tool-output-available': {
139
+ const tc = toolCalls.get(chunk.toolCallId as string);
140
+ if (tc) {
141
+ tc.output = typeof chunk.output === 'string' ? chunk.output : JSON.stringify(chunk.output);
142
+ tc.state = 'done';
143
+ changed = true;
144
+ }
145
+ break;
146
+ }
147
+ case 'tool-output-error':
148
+ case 'tool-input-error': {
149
+ const tc = toolCalls.get(chunk.toolCallId as string);
150
+ if (tc) {
151
+ tc.output = (chunk.errorText as string) ?? 'Error';
152
+ tc.state = 'error';
153
+ changed = true;
154
+ }
155
+ break;
156
+ }
157
+ case 'error': {
158
+ const errorText = (chunk.errorText as string) ?? 'Unknown error';
159
+ parts.push({ type: 'text', text: `\n\n**Error:** ${errorText}` });
160
+ currentTextId = null;
161
+ changed = true;
162
+ break;
163
+ }
164
+ // step-start, metadata, finish — ignored for now
165
+ case 'reasoning-start': {
166
+ currentReasoningPart = { type: 'reasoning', text: '' };
167
+ parts.push(currentReasoningPart);
168
+ currentTextId = null;
169
+ changed = true;
170
+ break;
171
+ }
172
+ case 'reasoning-delta': {
173
+ if (currentReasoningPart) {
174
+ currentReasoningPart.text += chunk.delta as string;
175
+ changed = true;
176
+ }
177
+ break;
178
+ }
179
+ case 'reasoning-end': {
180
+ currentReasoningPart = null;
181
+ break;
182
+ }
183
+ default:
184
+ break;
185
+ }
186
+ }
187
+
188
+ // Emit once per reader.read() batch, not per SSE line
189
+ if (changed) {
190
+ onUpdate(buildMessage());
191
+ }
192
+ }
193
+ } finally {
194
+ reader.releaseLock();
195
+ }
196
+
197
+ // Finalize any tool calls still stuck in running/pending state
198
+ // (stream ended before their output arrived — e.g. abort, network error, step limit)
199
+ let finalized = false;
200
+ for (const tc of toolCalls.values()) {
201
+ if (tc.state === 'running' || tc.state === 'pending') {
202
+ tc.state = 'error';
203
+ tc.output = tc.output ?? 'Stream ended before tool completed';
204
+ finalized = true;
205
+ }
206
+ }
207
+ if (finalized) {
208
+ onUpdate(buildMessage());
209
+ }
210
+
211
+ return buildMessage();
212
+ }