pi-codebuddy-sdk 0.1.13 → 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.13",
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,143 +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
- };
76
- }
77
- return null;
78
- }
55
+ // ── extract user message ──
79
56
 
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
- let raw = extractRawContent((messages[i] as any).content);
87
- if (!raw) continue;
88
- // Strip context-mode session state injection prepended to user messages.
89
- // Pi's context-mode adds its hierarchy/state before the actual user input.
90
- raw = stripContextModeInjection(raw);
91
- if (raw) return raw;
92
- }
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);
93
77
  }
94
78
  return "Continue.";
95
79
  }
96
80
 
97
- function extractRawContent(content: unknown): string {
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 {
98
91
  if (typeof content === "string") return content;
99
92
  if (!Array.isArray(content)) return "";
100
93
  return content
101
94
  .filter((c: any) => c?.type === "text")
102
- .map((c: any) => c.text)
95
+ .map((c: any) => c.text as string)
103
96
  .join("\n");
104
97
  }
105
98
 
106
99
  /**
107
- * Pi context-mode prepends its session state to user messages.
108
- * Strip it to keep CodeBuddy from treating it as conversation content.
100
+ * Strip context-mode session state that Pi prepends to user messages.
109
101
  *
110
- * Injected format:
102
+ * Pi injects several formats:
111
103
  * context-mode active. Hierarchy: ...
112
- * <session_state source="compaction">
113
- * <session_mode>investigate</session_mode>
114
- * </session_state>
115
- * [actual user message]
104
+ * <session_state source="compaction"> ... </session_state>
105
+ * <summary>Compacted: ...</summary>
106
+ *
107
+ * Strip all of them, keep only the actual user message.
116
108
  */
117
- function stripContextModeInjection(text: string): string {
118
- // Find the end of context-mode injection
119
- // Pattern: </session_state> followed by newlines then actual message
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>
120
119
  const sessionEnd = text.lastIndexOf("</session_state>");
121
120
  if (sessionEnd >= 0) {
122
121
  const after = text.slice(sessionEnd + "</session_state>".length).trimStart();
123
- if (after) return after;
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;
128
+ }
124
129
  }
125
130
 
126
- // Fallback: look for "context-mode active" header and take everything after it + session_state
127
- const cmStart = text.indexOf("context-mode active.");
128
- if (cmStart >= 0) {
129
- // Find first blank line after </session_state> or end of XML
130
- const afterSession = afterSessionState(text);
131
- if (afterSession) return afterSession;
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();
132
137
  }
133
138
 
134
- return text;
135
- }
136
-
137
- function afterSessionState(text: string): string | null {
138
- // Try to find the actual user message after context-mode XML injection
139
- const patterns = [
140
- /context-mode active\.[\s\S]*?<\/session_state>\s*\n+([\s\S]+)$/,
141
- /<session_state[\s\S]*?<\/session_state>\s*\n+([\s\S]+)$/,
142
- ];
143
- for (const re of patterns) {
144
- const m = text.match(re);
145
- if (m?.[1]?.trim()) return m[1].trim();
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;
146
143
  }
147
- return null;
144
+
145
+ return text.trim() || text;
148
146
  }
149
147
 
150
- // ── thinking config mapping ──
148
+ // ── thinking config ──
151
149
 
152
- const DEFAULT_THINKING_BUDGETS: Record<string, number> = {
153
- minimal: 1600,
154
- low: 4000,
155
- medium: 0,
156
- high: 0,
157
- xhigh: 32000,
150
+ const THINKING_BUDGETS: Record<string, number> = {
151
+ minimal: 1600, low: 4000, medium: 0, high: 0, xhigh: 32000,
158
152
  };
159
-
160
- const DEFAULT_EFFORT: Record<string, Effort | undefined> = {
161
- minimal: 'low',
162
- low: 'low',
163
- medium: 'medium',
164
- high: 'high',
165
- xhigh: 'xhigh',
153
+ const EFFORT_MAP: Record<string, Effort | undefined> = {
154
+ minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "xhigh",
166
155
  };
167
156
 
168
- function buildThinkingConfig(
169
- reasoning: string | undefined,
170
- thinkingBudgets?: SimpleStreamOptions["thinkingBudgets"],
171
- ): { thinking?: ThinkingConfig; effort?: Effort } {
157
+ function buildThinking(reasoning?: SimpleStreamOptions["reasoning"], budgets?: SimpleStreamOptions["thinkingBudgets"]): { thinking?: ThinkingConfig; effort?: Effort } {
172
158
  if (!reasoning) return {};
173
159
  if (reasoning === "off") return { thinking: { type: "disabled" } };
174
-
175
- const budget = thinkingBudgets?.[reasoning as keyof typeof thinkingBudgets]
176
- ?? DEFAULT_THINKING_BUDGETS[reasoning] ?? 0;
177
- const effort = DEFAULT_EFFORT[reasoning];
178
-
160
+ const budget = budgets?.[reasoning as keyof typeof budgets] ?? THINKING_BUDGETS[reasoning] ?? 0;
161
+ const effort = EFFORT_MAP[reasoning];
179
162
  if (budget > 0) return { thinking: { type: "enabled", budgetTokens: budget }, effort };
180
163
  return { thinking: { type: "adaptive" }, effort };
181
164
  }
182
165
 
183
- // ── 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 ──
184
187
 
185
188
  export function streamCodebuddy(
186
- model: Model<Api>,
189
+ model: Model<any>,
187
190
  context: Context,
188
191
  options?: SimpleStreamOptions,
189
192
  ): AssistantMessageEventStream {
@@ -194,13 +197,10 @@ export function streamCodebuddy(
194
197
  try {
195
198
  stream.push({ type: "start", partial });
196
199
 
197
- const sdk = await loadCodebuddySdk();
198
- const userText = extractLatestUserText(context);
200
+ const sdk = await loadSdk();
201
+ const userText = extractUserMessage(context);
199
202
 
200
- const { thinking, effort } = buildThinkingConfig(
201
- options?.reasoning,
202
- options?.thinkingBudgets,
203
- );
203
+ const { thinking, effort } = buildThinking(options?.reasoning, options?.thinkingBudgets);
204
204
 
205
205
  const q = sdk.query({
206
206
  prompt: userText,
@@ -214,7 +214,7 @@ export function streamCodebuddy(
214
214
  },
215
215
  });
216
216
 
217
- // ── abort handling ──
217
+ // ── abort ──
218
218
  let aborted = false;
219
219
  const onAbort = async () => {
220
220
  if (aborted) return;
@@ -225,86 +225,74 @@ export function streamCodebuddy(
225
225
  };
226
226
  if (options?.signal) {
227
227
  options.signal.addEventListener("abort", onAbort, { once: true });
228
- if (options.signal.aborted) {
229
- await onAbort();
230
- return;
231
- }
228
+ if (options.signal.aborted) { await onAbort(); return; }
232
229
  }
233
230
 
234
- // ── per-index block tracking ──
235
- 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";
236
233
  const blockKind = new Map<number, BlockKind>();
237
234
  const blockAccum = new Map<number, string>();
238
235
  const blockMeta = new Map<number, { id: string; name: string; signature?: string }>();
239
236
 
240
237
  let hasStreamEvents = false;
241
238
 
242
- 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 }) {
243
240
  blockKind.set(idx, kind);
244
- blockAccum.set(idx, initText);
241
+ blockAccum.set(idx, init);
245
242
  if (meta) blockMeta.set(idx, meta);
246
243
 
247
- if (kind === 'redacted_thinking') {
248
- 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 };
249
246
  partial.content = [...partial.content, tc];
250
- stream.push({ type: 'thinking_start', contentIndex: idx, partial });
251
- stream.push({ type: 'thinking_end', contentIndex: idx, content: '[redacted]', partial });
252
- } else if (kind === 'thinking') {
253
- 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 };
254
251
  partial.content = [...partial.content, tc];
255
- stream.push({ type: 'thinking_start', contentIndex: idx, partial });
256
- } else if (kind === 'text') {
257
- const tb: TextContent = { type: 'text', text: initText };
258
- partial.content = [...partial.content, tb];
259
- stream.push({ type: 'text_start', contentIndex: idx, partial });
260
- } else if (kind === 'tool_use' && meta) {
261
- const toolCall: ToolCall = { type: 'toolCall', id: meta.id, name: meta.name, arguments: {} };
262
- partial.content = [...partial.content, toolCall];
263
- 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 });
264
259
  }
265
260
  }
266
261
 
267
- function onBlockDelta(idx: number, deltaType: string, deltaValue: string) {
262
+ function onDelta(idx: number, deltaType: string, value: string) {
268
263
  const kind = blockKind.get(idx);
269
264
  if (!kind) return;
270
265
 
271
- if (deltaType === 'text_delta' && kind === 'text') {
272
- const newText = (blockAccum.get(idx) || '') + deltaValue;
273
- blockAccum.set(idx, newText);
266
+ if (deltaType === "text_delta" && kind === "text") {
267
+ const next = (blockAccum.get(idx) || "") + value;
268
+ blockAccum.set(idx, next);
274
269
  const blocks = [...partial.content];
275
- blocks[idx] = { type: 'text', text: newText };
270
+ blocks[idx] = { type: "text", text: next };
276
271
  partial.content = blocks;
277
- stream.push({ type: 'text_delta', contentIndex: idx, delta: deltaValue, partial });
278
- } else if (deltaType === 'thinking_delta' && kind === 'thinking') {
279
- const newThinking = (blockAccum.get(idx) || '') + deltaValue;
280
- 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);
281
276
  const blocks = [...partial.content];
282
- blocks[idx] = { type: 'thinking', thinking: newThinking, thinkingSignature: blockMeta.get(idx)?.signature };
277
+ blocks[idx] = { type: "thinking", thinking: next, thinkingSignature: blockMeta.get(idx)?.signature };
283
278
  partial.content = blocks;
284
- stream.push({ type: 'thinking_delta', contentIndex: idx, delta: deltaValue, partial });
285
- } else if (deltaType === 'input_json_delta' && kind === 'tool_use') {
286
- const newJson = (blockAccum.get(idx) || '') + deltaValue;
287
- 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);
288
283
  const blocks = [...partial.content];
289
- try {
290
- blocks[idx] = { ...blocks[idx], arguments: JSON.parse(newJson) };
291
- } catch { /* partial JSON */ }
284
+ try { blocks[idx] = { ...blocks[idx], arguments: JSON.parse(json) }; } catch { /* partial */ }
292
285
  partial.content = blocks;
293
- 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);
294
287
  }
295
288
  }
296
289
 
297
- function onBlockStop(idx: number) {
290
+ function onStop(idx: number) {
298
291
  const kind = blockKind.get(idx);
299
- const accum = blockAccum.get(idx) || '';
300
- if (kind === 'text') {
301
- stream.push({ type: 'text_end', contentIndex: idx, content: accum, partial });
302
- } else if (kind === 'thinking') {
303
- stream.push({ type: 'thinking_end', contentIndex: idx, content: accum, partial });
304
- } else if (kind === 'tool_use') {
305
- const toolCall = partial.content[idx] as ToolCall;
306
- stream.push({ type: 'toolcall_end', contentIndex: idx, toolCall, partial });
307
- }
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 });
308
296
  }
309
297
 
310
298
  // ── iterate ──
@@ -316,29 +304,24 @@ export function streamCodebuddy(
316
304
  continue;
317
305
  }
318
306
 
307
+ // Token-level streaming via stream_event (preferred)
319
308
  if (msg.type === "stream_event") {
320
309
  hasStreamEvents = true;
321
310
  const evt = msg.event;
322
311
 
323
312
  if (evt.type === "content_block_start") {
324
- const block = evt.content_block;
325
- if (block.type === "text") {
326
- onBlockStart(evt.index, 'text', block.text || '');
327
- } else if (block.type === "thinking") {
328
- onBlockStart(evt.index, 'thinking', block.thinking || '', { id: '', name: '', signature: block.signature });
329
- } else if (block.type === "redacted_thinking") {
330
- onBlockStart(evt.index, 'redacted_thinking', '[redacted]', { id: '', name: '', signature: block.data });
331
- } else if (block.type === "tool_use") {
332
- const initJson = JSON.stringify(block.input || {});
333
- onBlockStart(evt.index, 'tool_use', initJson, { id: block.id, name: block.name });
334
- }
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 });
335
318
  } else if (evt.type === "content_block_delta") {
336
319
  const d = evt.delta;
337
- if (d.type === 'text_delta') onBlockDelta(evt.index, 'text_delta', d.text);
338
- else if (d.type === 'thinking_delta') onBlockDelta(evt.index, 'thinking_delta', d.thinking);
339
- 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);
340
323
  } else if (evt.type === "content_block_stop") {
341
- onBlockStop(evt.index);
324
+ onStop(evt.index);
342
325
  }
343
326
  continue;
344
327
  }
@@ -348,25 +331,16 @@ export function streamCodebuddy(
348
331
  let ci = 0;
349
332
  for (const block of msg.message.content) {
350
333
  const idx = ci++;
351
- if (block.type === "thinking") {
352
- onBlockStart(idx, 'thinking', block.thinking);
353
- onBlockStop(idx);
354
- } else if (block.type === "redacted_thinking") {
355
- onBlockStart(idx, 'redacted_thinking', '[redacted]', { id: '', name: '', signature: block.data });
356
- } else if (block.type === "text") {
357
- onBlockStart(idx, 'text', block.text);
358
- onBlockStop(idx);
359
- } else if (block.type === "tool_use") {
360
- const initJson = JSON.stringify(block.input ?? {});
361
- onBlockStart(idx, 'tool_use', initJson, { id: block.id, name: block.name });
362
- onBlockStop(idx);
363
- }
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); }
364
338
  }
365
339
  }
366
340
 
367
341
  if (msg.type === "result") {
368
- const usageUpdate = extractUsageUpdate(msg);
369
- if (usageUpdate) Object.assign(partial, usageUpdate);
342
+ const u = extractUsage(msg);
343
+ if (u) Object.assign(partial, u);
370
344
 
371
345
  if (msg.subtype === "success") {
372
346
  partial.stopReason = "stop";
@@ -380,8 +354,7 @@ export function streamCodebuddy(
380
354
  }
381
355
  }
382
356
  } catch (error) {
383
- const errPartial = makeErrorPartial(model, error);
384
- stream.push({ type: "error", reason: "error", error: errPartial });
357
+ stream.push({ type: "error", reason: "error", error: makeErrorPartial(model, error) });
385
358
  }
386
359
  });
387
360