pi-codebuddy-sdk 0.1.12 → 0.1.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codebuddy-sdk",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "CodeBuddy Agent SDK provider for Pi — native tool execution, 29+ models, thinking controls, session pool",
5
5
  "author": "slahser",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -1,97 +1,101 @@
1
- import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
1
+ /**
2
+ * pi-codebuddy-sdk — CodeBuddy Agent SDK as a Pi provider.
3
+ *
4
+ * Architecture follows pi-claude-bridge:
5
+ * - Stateless: one sdk.query() per Pi turn
6
+ * - No Pi systemPrompt forwarding — CodeBuddy uses its own default
7
+ * - No MCP tool bridge — CodeBuddy SDK tools are used natively
8
+ * - Only the latest user message is forwarded (Pi manages conversation)
9
+ *
10
+ * Two files: index.ts (registration + model discovery), provider.ts (streaming).
11
+ */
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import type { ProviderApi, SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
14
+ import { streamCodebuddy } from "./provider.js";
2
15
 
3
- // ponytail: hardcoded patterns for capability detection since supportedModels() returns
4
- // only {id, name} without capability metadata.
5
- const THINKING_PATTERNS = [/claude/i, /gemini/i, /gpt-5/i];
6
- const IMAGE_PATTERNS = [/claude/i, /gemini/i, /gpt/i];
7
- // ponytail: rough context window estimates by model family
8
- const CONTEXT_ESTIMATES: Record<string, [number, number]> = {
9
- // [contextWindow, maxTokens]
10
- claude: [200000, 8192],
11
- gemini: [1048576, 8192],
12
- gpt: [200000, 16384],
13
- deepseek: [131072, 8192],
14
- glm: [131072, 8192],
15
- minimax: [131072, 8192],
16
- kimi: [131072, 8192],
17
- hy: [131072, 8192], // Hunyuan
18
- };
16
+ // ── provider registration ──
19
17
 
20
- function detectCapabilities(modelId: string) {
21
- const reasoning = THINKING_PATTERNS.some((p) => p.test(modelId));
22
- const hasImage = IMAGE_PATTERNS.some((p) => p.test(modelId));
23
-
24
- // Match family for context window estimate
25
- const family = Object.keys(CONTEXT_ESTIMATES).find((k) =>
26
- modelId.toLowerCase().startsWith(k),
27
- );
28
- const [contextWindow, maxTokens] = family
29
- ? CONTEXT_ESTIMATES[family]
30
- : [128000, 8192];
31
-
32
- return {
33
- reasoning,
34
- input: (hasImage ? ["text", "image"] : ["text"]) as ("text" | "image")[],
35
- contextWindow,
36
- maxTokens,
37
- };
38
- }
18
+ export default function activate(api: ExtensionAPI) {
19
+ api.registerProvider("codebuddy", {
20
+ name: "CodeBuddy",
21
+ baseUrl: "codebuddy-local",
22
+ apiKey: "codebuddy-local-auth",
23
+ api: "codebuddy-sdk",
24
+ models: [],
25
+ streamSimple: streamCodebuddy,
26
+ });
39
27
 
40
- async function discoverModels(): Promise<ProviderModelConfig[]> {
41
- try {
42
- const sdk = await import("@tencent-ai/agent-sdk");
43
- const q = sdk.query({
44
- prompt: "",
45
- options: {
46
- permissionMode: "bypassPermissions",
47
- maxTurns: 0,
48
- },
49
- });
50
- const models = await Promise.race([
51
- q.supportedModels(),
52
- new Promise<never>((_, reject) =>
53
- setTimeout(() => reject(new Error("CodeBuddy model discovery timed out after 15s")), 15000),
54
- ),
55
- ]);
56
- if (models && models.length > 0) {
57
- return models.map((m) => {
58
- const caps = detectCapabilities(m.id);
59
- return {
60
- id: m.id,
61
- name: m.name || m.id,
62
- reasoning: caps.reasoning,
63
- input: caps.input,
64
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
65
- contextWindow: caps.contextWindow,
66
- maxTokens: caps.maxTokens,
67
- };
28
+ // Lazy model discovery at startup
29
+ queueMicrotask(async () => {
30
+ try {
31
+ const models = await discoverModels();
32
+ api.registerProvider("codebuddy", {
33
+ name: "CodeBuddy",
34
+ baseUrl: "codebuddy-local",
35
+ apiKey: "codebuddy-local-auth",
36
+ api: "codebuddy-sdk",
37
+ models,
38
+ streamSimple: streamCodebuddy,
68
39
  });
40
+ } catch {
41
+ // Model discovery failed — provider registered with empty models,
42
+ // user can still try manually.
69
43
  }
70
- } catch (e) {
71
- // Discovery failed — SDK not installed, CLI missing, or network issue.
72
- // Fall back to hardcoded minimal list so Pi can still show a provider.
73
- console.warn(
74
- `[pi-codebuddy-sdk] Model discovery failed: ${e instanceof Error ? e.message : String(e)}. Using ${3} fallback models.`,
75
- );
76
- }
44
+ });
45
+ }
77
46
 
78
- // ponytail: fallback model list when discovery fails
79
- return [
80
- { id: "hy3-preview-agent-ioa", name: "Hunyuan 3 Preview", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 8192 },
81
- { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", reasoning: true, input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 8192 },
82
- { id: "deepseek-v4-pro-ioa", name: "DeepSeek V4 Pro", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 8192 },
83
- ];
47
+ // ── model discovery ──
48
+
49
+ let _sdk: typeof import("@tencent-ai/agent-sdk") | undefined;
50
+ async function getSdk() {
51
+ if (!_sdk) _sdk = await import("@tencent-ai/agent-sdk");
52
+ return _sdk;
84
53
  }
85
54
 
86
- export default async function (pi: ExtensionAPI) {
87
- const models = await discoverModels();
55
+ export async function discoverModels(): Promise<ProviderApi["models"]> {
56
+ const sdk = await getSdk();
57
+ const q = sdk.query({ prompt: " ", options: { maxTurns: 0 } });
58
+ const supported = await q.supportedModels();
59
+ await q.interrupt();
88
60
 
89
- pi.registerProvider("codebuddy", {
90
- name: "CodeBuddy",
91
- baseUrl: "https://codebuddy.ai",
92
- apiKey: "codebuddy-local",
93
- api: "codebuddy-sdk",
94
- models,
95
- streamSimple: (await import("./provider.js")).streamCodebuddy,
61
+ const models = supported.map((m) => {
62
+ const caps = detectCapabilities(m.id);
63
+ return {
64
+ id: m.id,
65
+ name: m.name || m.id,
66
+ api: "codebuddy-sdk" as const,
67
+ provider: "codebuddy" as const,
68
+ reasoning: caps.reasoning,
69
+ input: caps.input,
70
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
71
+ contextWindow: caps.contextWindow,
72
+ maxTokens: caps.maxTokens,
73
+ };
96
74
  });
75
+
76
+ return models;
77
+ }
78
+
79
+ // ── capability detection (pattern-based, like pi-claude-bridge) ──
80
+
81
+ const THINKING_PATTERNS = [/claude/i, /gemini/i, /gpt-5/i];
82
+ const IMAGE_PATTERNS = [/claude/i, /gemini/i, /gpt/i];
83
+ const CONTEXT_ESTIMATES: Record<string, { window: number; maxTokens: number }> = {
84
+ claude: { window: 200000, maxTokens: 8192 },
85
+ gemini: { window: 1048576, maxTokens: 8192 },
86
+ gpt: { window: 200000, maxTokens: 16384 },
87
+ deepseek: { window: 131072, maxTokens: 8192 },
88
+ glm: { window: 131072, maxTokens: 8192 },
89
+ minimax: { window: 131072, maxTokens: 8192 },
90
+ kimi: { window: 131072, maxTokens: 8192 },
91
+ hy: { window: 131072, maxTokens: 8192 },
92
+ };
93
+
94
+ function detectCapabilities(id: string) {
95
+ const reasoning = THINKING_PATTERNS.some((p) => p.test(id));
96
+ const supportsImages = IMAGE_PATTERNS.some((p) => p.test(id));
97
+ const input = supportsImages ? (["text", "image"] as const) : (["text"] as const);
98
+ const family = Object.keys(CONTEXT_ESTIMATES).find((k) => id.toLowerCase().includes(k));
99
+ const est = family ? CONTEXT_ESTIMATES[family] : { window: 131072, maxTokens: 8192 };
100
+ return { reasoning, input, contextWindow: est.window, maxTokens: est.maxTokens };
97
101
  }
@@ -0,0 +1 @@
1
+ # Temporary: add debug logging to extractUserMessage
package/src/provider.ts CHANGED
@@ -1,15 +1,13 @@
1
1
  /**
2
- * CodeBuddy Provider — stateless, lightweight.
2
+ * Provider entry point — pi-claude-bridge pattern.
3
3
  *
4
- * Uses sdk.query() (one-shot) — no context building, no session pool,
5
- * no tool boundary. Pi manages conversation, CodeBuddy is just a model backend.
6
- *
7
- * Architecture follows pi-openai-compatible / pi-anthropic pattern:
8
- * extract latest user message → send to SDK → stream events back to Pi.
4
+ * For each Pi turn:
5
+ * 1. Extract the latest user message (strip context-mode injection)
6
+ * 2. Call sdk.query() — stateless, CodeBuddy uses its own system prompt
7
+ * 3. Stream events back to Pi (text, thinking, toolcalls)
9
8
  */
10
9
  import {
11
10
  createAssistantMessageEventStream,
12
- type Api,
13
11
  type AssistantMessage,
14
12
  type AssistantMessageEventStream,
15
13
  type Context,
@@ -20,21 +18,20 @@ import {
20
18
  type ToolCall,
21
19
  } from "@earendil-works/pi-ai/compat";
22
20
  import type {
21
+ Effort,
23
22
  Message as CbMessage,
24
23
  ThinkingConfig,
25
- Effort,
26
- RawMessageStreamEvent,
27
24
  } from "@tencent-ai/agent-sdk";
28
25
 
29
26
  type CodebuddySdk = typeof import("@tencent-ai/agent-sdk");
30
27
 
31
- async function loadCodebuddySdk(): Promise<CodebuddySdk> {
28
+ async function loadSdk(): Promise<CodebuddySdk> {
32
29
  return import("@tencent-ai/agent-sdk");
33
30
  }
34
31
 
35
32
  // ── helpers ──
36
33
 
37
- function makePartial(model: Model<Api>): AssistantMessage {
34
+ function makePartial(model: Model<any>): AssistantMessage {
38
35
  return {
39
36
  role: "assistant",
40
37
  content: [],
@@ -47,93 +44,149 @@ function makePartial(model: Model<Api>): AssistantMessage {
47
44
  };
48
45
  }
49
46
 
50
- function makeErrorPartial(model: Model<Api>, error: unknown): AssistantMessage {
47
+ function makeErrorPartial(model: Model<any>, error: unknown): AssistantMessage {
51
48
  return {
52
49
  ...makePartial(model),
53
50
  stopReason: "error",
54
- errorMessage: `CodeBuddy SDK error: ${error instanceof Error ? error.message : String(error)}`,
51
+ errorMessage: error instanceof Error ? error.message : String(error),
55
52
  };
56
53
  }
57
54
 
58
- function extractUsageUpdate(msg: CbMessage): Partial<AssistantMessage> | null {
59
- if (msg.type === "result" && msg.usage) {
60
- return {
61
- usage: {
62
- input: msg.usage.input_tokens ?? 0,
63
- output: msg.usage.output_tokens ?? 0,
64
- cacheRead: msg.usage.cache_read_input_tokens ?? 0,
65
- cacheWrite: msg.usage.cache_creation_input_tokens ?? 0,
66
- totalTokens: (msg.usage.input_tokens ?? 0) + (msg.usage.output_tokens ?? 0) + (msg.usage.cache_read_input_tokens ?? 0) + (msg.usage.cache_creation_input_tokens ?? 0),
67
- cost: {
68
- input: 0,
69
- output: 0,
70
- cacheRead: 0,
71
- cacheWrite: 0,
72
- total: msg.total_cost_usd ?? 0,
73
- },
74
- },
75
- };
55
+ // ── extract user message ──
56
+
57
+ /**
58
+ * Extract the latest user message from Pi Context.
59
+ * Strips context-mode session state injection.
60
+ *
61
+ * Like pi-claude-bridge's extractUserPrompt() but with context-mode defense.
62
+ */
63
+ function extractUserMessage(context: Context): string {
64
+ const msgs = context.messages;
65
+
66
+ // Pi injects context-mode state as the LAST user message (after the real one).
67
+ // Search from the end, skip obvious context-mode injections.
68
+ for (let i = msgs.length - 1; i >= 0; i--) {
69
+ if (msgs[i].role !== "user") continue;
70
+ const raw = contentToText((msgs[i] as any).content);
71
+ if (!raw) continue;
72
+
73
+ // Skip messages that are entirely context-mode injection
74
+ if (isContextModeInjection(raw)) continue;
75
+
76
+ return stripPiInjection(raw);
76
77
  }
77
- return null;
78
+ return "Continue.";
78
79
  }
79
80
 
80
- // ── extract latest user message ──
81
-
82
- function extractLatestUserText(context: Context): string {
83
- const messages = context.messages;
84
- for (let i = messages.length - 1; i >= 0; i--) {
85
- if (messages[i].role === "user") {
86
- const content = (messages[i] as any).content;
87
- if (typeof content === "string") return content;
88
- if (Array.isArray(content)) {
89
- const texts = content
90
- .filter((c: any) => c?.type === "text")
91
- .map((c: any) => c.text)
92
- .join("\n");
93
- if (texts) return texts;
94
- }
81
+ /** Detect if a user message is a Pi context-mode state injection (not a real message). */
82
+ function isContextModeInjection(text: string): boolean {
83
+ const t = text.trim();
84
+ if (t.startsWith("context-mode active.")) return true;
85
+ if (t.includes("<session_state") || t.includes("<session_mode")) return true;
86
+ if (t.startsWith("<summary>") && t.includes("</summary>")) return true;
87
+ return false;
88
+ }
89
+
90
+ function contentToText(content: unknown): string {
91
+ if (typeof content === "string") return content;
92
+ if (!Array.isArray(content)) return "";
93
+ return content
94
+ .filter((c: any) => c?.type === "text")
95
+ .map((c: any) => c.text as string)
96
+ .join("\n");
97
+ }
98
+
99
+ /**
100
+ * Strip context-mode session state that Pi prepends to user messages.
101
+ *
102
+ * Pi injects several formats:
103
+ * context-mode active. Hierarchy: ...
104
+ * <session_state source="compaction"> ... </session_state>
105
+ * <summary>Compacted: ...</summary>
106
+ *
107
+ * Strip all of them, keep only the actual user message.
108
+ */
109
+ function stripPiInjection(text: string): string {
110
+ // Fast path
111
+ if (!text.includes("context-mode") && !text.includes("<session_state") && !text.includes("<summary>")) {
112
+ return text;
113
+ }
114
+
115
+ // 1. Remove <summary> blocks (compaction summaries contain context-mode state)
116
+ text = text.replace(/<summary>[\s\S]*?<\/summary>\s*/g, "");
117
+
118
+ // 2. Remove <session_state> block and everything between "context-mode" and </session_state>
119
+ const sessionEnd = text.lastIndexOf("</session_state>");
120
+ if (sessionEnd >= 0) {
121
+ const after = text.slice(sessionEnd + "</session_state>".length).trimStart();
122
+ if (after) {
123
+ text = after;
124
+ } else {
125
+ // All content was in the session_state block
126
+ const before = text.slice(0, text.indexOf("<session_state")).trim();
127
+ if (before) text = before;
95
128
  }
96
129
  }
97
- return "Continue.";
130
+
131
+ // 3. Remove "context-mode active." line and everything after until a blank line
132
+ // Pattern: "context-mode active. ... Hierarchy: ..." followed by blank line, then real message
133
+ const cmRe = /context-mode active\.[\s\S]*?\n\s*\n([\s\S]*)$/;
134
+ const cmMatch = text.match(cmRe);
135
+ if (cmMatch?.[1]?.trim()) {
136
+ text = cmMatch[1].trim();
137
+ }
138
+
139
+ // 4. If "context-mode" is still the only content, return empty
140
+ if (text.trim() === "context-mode active." || text.trim().startsWith("context-mode active.")) {
141
+ const afterCm = text.replace(/context-mode active\.[\s\S]*/, "").trim();
142
+ if (afterCm) text = afterCm;
143
+ }
144
+
145
+ return text.trim() || text;
98
146
  }
99
147
 
100
- // ── thinking config mapping ──
148
+ // ── thinking config ──
101
149
 
102
- const DEFAULT_THINKING_BUDGETS: Record<string, number> = {
103
- minimal: 1600,
104
- low: 4000,
105
- medium: 0,
106
- high: 0,
107
- xhigh: 32000,
150
+ const THINKING_BUDGETS: Record<string, number> = {
151
+ minimal: 1600, low: 4000, medium: 0, high: 0, xhigh: 32000,
108
152
  };
109
-
110
- const DEFAULT_EFFORT: Record<string, Effort | undefined> = {
111
- minimal: 'low',
112
- low: 'low',
113
- medium: 'medium',
114
- high: 'high',
115
- xhigh: 'xhigh',
153
+ const EFFORT_MAP: Record<string, Effort | undefined> = {
154
+ minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "xhigh",
116
155
  };
117
156
 
118
- function buildThinkingConfig(
119
- reasoning: string | undefined,
120
- thinkingBudgets?: SimpleStreamOptions["thinkingBudgets"],
121
- ): { thinking?: ThinkingConfig; effort?: Effort } {
157
+ function buildThinking(reasoning?: SimpleStreamOptions["reasoning"], budgets?: SimpleStreamOptions["thinkingBudgets"]): { thinking?: ThinkingConfig; effort?: Effort } {
122
158
  if (!reasoning) return {};
123
159
  if (reasoning === "off") return { thinking: { type: "disabled" } };
124
-
125
- const budget = thinkingBudgets?.[reasoning as keyof typeof thinkingBudgets]
126
- ?? DEFAULT_THINKING_BUDGETS[reasoning] ?? 0;
127
- const effort = DEFAULT_EFFORT[reasoning];
128
-
160
+ const budget = budgets?.[reasoning as keyof typeof budgets] ?? THINKING_BUDGETS[reasoning] ?? 0;
161
+ const effort = EFFORT_MAP[reasoning];
129
162
  if (budget > 0) return { thinking: { type: "enabled", budgetTokens: budget }, effort };
130
163
  return { thinking: { type: "adaptive" }, effort };
131
164
  }
132
165
 
133
- // ── main provider ──
166
+ // ── usage extraction ──
167
+
168
+ function extractUsage(msg: CbMessage): Partial<AssistantMessage> | null {
169
+ if (msg.type !== "result" || !msg.usage) return null;
170
+ return {
171
+ usage: {
172
+ input: msg.usage.input_tokens ?? 0,
173
+ output: msg.usage.output_tokens ?? 0,
174
+ cacheRead: msg.usage.cache_read_input_tokens ?? 0,
175
+ cacheWrite: msg.usage.cache_creation_input_tokens ?? 0,
176
+ totalTokens:
177
+ (msg.usage.input_tokens ?? 0) +
178
+ (msg.usage.output_tokens ?? 0) +
179
+ (msg.usage.cache_read_input_tokens ?? 0) +
180
+ (msg.usage.cache_creation_input_tokens ?? 0),
181
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: msg.total_cost_usd ?? 0 },
182
+ },
183
+ };
184
+ }
185
+
186
+ // ── main ──
134
187
 
135
188
  export function streamCodebuddy(
136
- model: Model<Api>,
189
+ model: Model<any>,
137
190
  context: Context,
138
191
  options?: SimpleStreamOptions,
139
192
  ): AssistantMessageEventStream {
@@ -144,13 +197,10 @@ export function streamCodebuddy(
144
197
  try {
145
198
  stream.push({ type: "start", partial });
146
199
 
147
- const sdk = await loadCodebuddySdk();
148
- const userText = extractLatestUserText(context);
200
+ const sdk = await loadSdk();
201
+ const userText = extractUserMessage(context);
149
202
 
150
- const { thinking, effort } = buildThinkingConfig(
151
- options?.reasoning,
152
- options?.thinkingBudgets,
153
- );
203
+ const { thinking, effort } = buildThinking(options?.reasoning, options?.thinkingBudgets);
154
204
 
155
205
  const q = sdk.query({
156
206
  prompt: userText,
@@ -164,7 +214,7 @@ export function streamCodebuddy(
164
214
  },
165
215
  });
166
216
 
167
- // ── abort handling ──
217
+ // ── abort ──
168
218
  let aborted = false;
169
219
  const onAbort = async () => {
170
220
  if (aborted) return;
@@ -175,86 +225,74 @@ export function streamCodebuddy(
175
225
  };
176
226
  if (options?.signal) {
177
227
  options.signal.addEventListener("abort", onAbort, { once: true });
178
- if (options.signal.aborted) {
179
- await onAbort();
180
- return;
181
- }
228
+ if (options.signal.aborted) { await onAbort(); return; }
182
229
  }
183
230
 
184
- // ── per-index block tracking ──
185
- type BlockKind = 'text' | 'thinking' | 'tool_use' | 'redacted_thinking';
231
+ // ── per-block tracking (for stream_event delta accumulation) ──
232
+ type BlockKind = "text" | "thinking" | "tool_use" | "redacted_thinking";
186
233
  const blockKind = new Map<number, BlockKind>();
187
234
  const blockAccum = new Map<number, string>();
188
235
  const blockMeta = new Map<number, { id: string; name: string; signature?: string }>();
189
236
 
190
237
  let hasStreamEvents = false;
191
238
 
192
- function onBlockStart(idx: number, kind: BlockKind, initText: string, meta?: { id: string; name: string; signature?: string }) {
239
+ function onStart(idx: number, kind: BlockKind, init: string, meta?: { id: string; name: string; signature?: string }) {
193
240
  blockKind.set(idx, kind);
194
- blockAccum.set(idx, initText);
241
+ blockAccum.set(idx, init);
195
242
  if (meta) blockMeta.set(idx, meta);
196
243
 
197
- if (kind === 'redacted_thinking') {
198
- const tc: ThinkingContent = { type: 'thinking', thinking: '[redacted]', redacted: true, thinkingSignature: meta?.signature };
244
+ if (kind === "redacted_thinking") {
245
+ const tc: ThinkingContent = { type: "thinking", thinking: "[redacted]", redacted: true, thinkingSignature: meta?.signature };
199
246
  partial.content = [...partial.content, tc];
200
- stream.push({ type: 'thinking_start', contentIndex: idx, partial });
201
- stream.push({ type: 'thinking_end', contentIndex: idx, content: '[redacted]', partial });
202
- } else if (kind === 'thinking') {
203
- const tc: ThinkingContent = { type: 'thinking', thinking: initText, thinkingSignature: meta?.signature };
247
+ stream.push({ type: "thinking_start", contentIndex: idx, partial });
248
+ stream.push({ type: "thinking_end", contentIndex: idx, content: "[redacted]", partial });
249
+ } else if (kind === "thinking") {
250
+ const tc: ThinkingContent = { type: "thinking", thinking: init, thinkingSignature: meta?.signature };
204
251
  partial.content = [...partial.content, tc];
205
- stream.push({ type: 'thinking_start', contentIndex: idx, partial });
206
- } else if (kind === 'text') {
207
- const tb: TextContent = { type: 'text', text: initText };
208
- partial.content = [...partial.content, tb];
209
- stream.push({ type: 'text_start', contentIndex: idx, partial });
210
- } else if (kind === 'tool_use' && meta) {
211
- const toolCall: ToolCall = { type: 'toolCall', id: meta.id, name: meta.name, arguments: {} };
212
- partial.content = [...partial.content, toolCall];
213
- stream.push({ type: 'toolcall_start', contentIndex: idx, partial });
252
+ stream.push({ type: "thinking_start", contentIndex: idx, partial });
253
+ } else if (kind === "text") {
254
+ partial.content = [...partial.content, { type: "text", text: init }];
255
+ stream.push({ type: "text_start", contentIndex: idx, partial });
256
+ } else if (kind === "tool_use" && meta) {
257
+ partial.content = [...partial.content, { type: "toolCall", id: meta.id, name: meta.name, arguments: {} }];
258
+ stream.push({ type: "toolcall_start", contentIndex: idx, partial });
214
259
  }
215
260
  }
216
261
 
217
- function onBlockDelta(idx: number, deltaType: string, deltaValue: string) {
262
+ function onDelta(idx: number, deltaType: string, value: string) {
218
263
  const kind = blockKind.get(idx);
219
264
  if (!kind) return;
220
265
 
221
- if (deltaType === 'text_delta' && kind === 'text') {
222
- const newText = (blockAccum.get(idx) || '') + deltaValue;
223
- blockAccum.set(idx, newText);
266
+ if (deltaType === "text_delta" && kind === "text") {
267
+ const next = (blockAccum.get(idx) || "") + value;
268
+ blockAccum.set(idx, next);
224
269
  const blocks = [...partial.content];
225
- blocks[idx] = { type: 'text', text: newText };
270
+ blocks[idx] = { type: "text", text: next };
226
271
  partial.content = blocks;
227
- stream.push({ type: 'text_delta', contentIndex: idx, delta: deltaValue, partial });
228
- } else if (deltaType === 'thinking_delta' && kind === 'thinking') {
229
- const newThinking = (blockAccum.get(idx) || '') + deltaValue;
230
- blockAccum.set(idx, newThinking);
272
+ stream.push({ type: "text_delta", contentIndex: idx, delta: value, partial });
273
+ } else if (deltaType === "thinking_delta" && kind === "thinking") {
274
+ const next = (blockAccum.get(idx) || "") + value;
275
+ blockAccum.set(idx, next);
231
276
  const blocks = [...partial.content];
232
- blocks[idx] = { type: 'thinking', thinking: newThinking, thinkingSignature: blockMeta.get(idx)?.signature };
277
+ blocks[idx] = { type: "thinking", thinking: next, thinkingSignature: blockMeta.get(idx)?.signature };
233
278
  partial.content = blocks;
234
- stream.push({ type: 'thinking_delta', contentIndex: idx, delta: deltaValue, partial });
235
- } else if (deltaType === 'input_json_delta' && kind === 'tool_use') {
236
- const newJson = (blockAccum.get(idx) || '') + deltaValue;
237
- blockAccum.set(idx, newJson);
279
+ stream.push({ type: "thinking_delta", contentIndex: idx, delta: value, partial });
280
+ } else if (deltaType === "input_json_delta" && kind === "tool_use") {
281
+ const json = (blockAccum.get(idx) || "") + value;
282
+ blockAccum.set(idx, json);
238
283
  const blocks = [...partial.content];
239
- try {
240
- blocks[idx] = { ...blocks[idx], arguments: JSON.parse(newJson) };
241
- } catch { /* partial JSON */ }
284
+ try { blocks[idx] = { ...blocks[idx], arguments: JSON.parse(json) }; } catch { /* partial */ }
242
285
  partial.content = blocks;
243
- stream.push({ type: 'toolcall_delta', contentIndex: idx, delta: deltaValue, partial } as any);
286
+ stream.push({ type: "toolcall_delta", contentIndex: idx, delta: value, partial } as any);
244
287
  }
245
288
  }
246
289
 
247
- function onBlockStop(idx: number) {
290
+ function onStop(idx: number) {
248
291
  const kind = blockKind.get(idx);
249
- const accum = blockAccum.get(idx) || '';
250
- if (kind === 'text') {
251
- stream.push({ type: 'text_end', contentIndex: idx, content: accum, partial });
252
- } else if (kind === 'thinking') {
253
- stream.push({ type: 'thinking_end', contentIndex: idx, content: accum, partial });
254
- } else if (kind === 'tool_use') {
255
- const toolCall = partial.content[idx] as ToolCall;
256
- stream.push({ type: 'toolcall_end', contentIndex: idx, toolCall, partial });
257
- }
292
+ const accum = blockAccum.get(idx) || "";
293
+ if (kind === "text") stream.push({ type: "text_end", contentIndex: idx, content: accum, partial });
294
+ else if (kind === "thinking") stream.push({ type: "thinking_end", contentIndex: idx, content: accum, partial });
295
+ else if (kind === "tool_use") stream.push({ type: "toolcall_end", contentIndex: idx, toolCall: partial.content[idx] as ToolCall, partial });
258
296
  }
259
297
 
260
298
  // ── iterate ──
@@ -266,29 +304,24 @@ export function streamCodebuddy(
266
304
  continue;
267
305
  }
268
306
 
307
+ // Token-level streaming via stream_event (preferred)
269
308
  if (msg.type === "stream_event") {
270
309
  hasStreamEvents = true;
271
310
  const evt = msg.event;
272
311
 
273
312
  if (evt.type === "content_block_start") {
274
- const block = evt.content_block;
275
- if (block.type === "text") {
276
- onBlockStart(evt.index, 'text', block.text || '');
277
- } else if (block.type === "thinking") {
278
- onBlockStart(evt.index, 'thinking', block.thinking || '', { id: '', name: '', signature: block.signature });
279
- } else if (block.type === "redacted_thinking") {
280
- onBlockStart(evt.index, 'redacted_thinking', '[redacted]', { id: '', name: '', signature: block.data });
281
- } else if (block.type === "tool_use") {
282
- const initJson = JSON.stringify(block.input || {});
283
- onBlockStart(evt.index, 'tool_use', initJson, { id: block.id, name: block.name });
284
- }
313
+ const b = evt.content_block;
314
+ if (b.type === "text") onStart(evt.index, "text", b.text || "");
315
+ else if (b.type === "thinking") onStart(evt.index, "thinking", b.thinking || "", { id: "", name: "", signature: b.signature });
316
+ else if (b.type === "redacted_thinking") onStart(evt.index, "redacted_thinking", "[redacted]", { id: "", name: "", signature: b.data });
317
+ else if (b.type === "tool_use") onStart(evt.index, "tool_use", JSON.stringify(b.input || {}), { id: b.id, name: b.name });
285
318
  } else if (evt.type === "content_block_delta") {
286
319
  const d = evt.delta;
287
- if (d.type === 'text_delta') onBlockDelta(evt.index, 'text_delta', d.text);
288
- else if (d.type === 'thinking_delta') onBlockDelta(evt.index, 'thinking_delta', d.thinking);
289
- else if (d.type === 'input_json_delta') onBlockDelta(evt.index, 'input_json_delta', d.partial_json);
320
+ if (d.type === "text_delta") onDelta(evt.index, "text_delta", d.text);
321
+ else if (d.type === "thinking_delta") onDelta(evt.index, "thinking_delta", d.thinking);
322
+ else if (d.type === "input_json_delta") onDelta(evt.index, "input_json_delta", d.partial_json);
290
323
  } else if (evt.type === "content_block_stop") {
291
- onBlockStop(evt.index);
324
+ onStop(evt.index);
292
325
  }
293
326
  continue;
294
327
  }
@@ -298,25 +331,16 @@ export function streamCodebuddy(
298
331
  let ci = 0;
299
332
  for (const block of msg.message.content) {
300
333
  const idx = ci++;
301
- if (block.type === "thinking") {
302
- onBlockStart(idx, 'thinking', block.thinking);
303
- onBlockStop(idx);
304
- } else if (block.type === "redacted_thinking") {
305
- onBlockStart(idx, 'redacted_thinking', '[redacted]', { id: '', name: '', signature: block.data });
306
- } else if (block.type === "text") {
307
- onBlockStart(idx, 'text', block.text);
308
- onBlockStop(idx);
309
- } else if (block.type === "tool_use") {
310
- const initJson = JSON.stringify(block.input ?? {});
311
- onBlockStart(idx, 'tool_use', initJson, { id: block.id, name: block.name });
312
- onBlockStop(idx);
313
- }
334
+ if (block.type === "thinking") { onStart(idx, "thinking", block.thinking); onStop(idx); }
335
+ else if (block.type === "redacted_thinking") onStart(idx, "redacted_thinking", "[redacted]", { id: "", name: "", signature: block.data });
336
+ else if (block.type === "text") { onStart(idx, "text", block.text); onStop(idx); }
337
+ else if (block.type === "tool_use") { onStart(idx, "tool_use", JSON.stringify(block.input ?? {}), { id: block.id, name: block.name }); onStop(idx); }
314
338
  }
315
339
  }
316
340
 
317
341
  if (msg.type === "result") {
318
- const usageUpdate = extractUsageUpdate(msg);
319
- if (usageUpdate) Object.assign(partial, usageUpdate);
342
+ const u = extractUsage(msg);
343
+ if (u) Object.assign(partial, u);
320
344
 
321
345
  if (msg.subtype === "success") {
322
346
  partial.stopReason = "stop";
@@ -330,8 +354,7 @@ export function streamCodebuddy(
330
354
  }
331
355
  }
332
356
  } catch (error) {
333
- const errPartial = makeErrorPartial(model, error);
334
- stream.push({ type: "error", reason: "error", error: errPartial });
357
+ stream.push({ type: "error", reason: "error", error: makeErrorPartial(model, error) });
335
358
  }
336
359
  });
337
360