flashlite 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,341 @@
1
+ // Anthropic Messages wire <-> normalized types (pinned 2023-06-01, like the Swift app).
2
+ // Tool results are batched into a single user turn (the flush pattern); signed thinking
3
+ // is retained for continuation but never displayed; refusal text is surfaced as content.
4
+ import { RouterError } from "../client.js";
5
+ import { parseToolArgs } from "./openai.js";
6
+ import { anthropicThinkingBudget, effectiveOutputTokens } from "../effort.js";
7
+ export const ANTHROPIC_DEFAULT_MAX_TOKENS = 8192;
8
+ function thinkingBlock(block) {
9
+ if (block.type === 'thinking')
10
+ return {
11
+ type: 'thinking',
12
+ thinking: typeof block.thinking === 'string' ? block.thinking : '',
13
+ signature: typeof block.signature === 'string' ? block.signature : '',
14
+ };
15
+ if (block.type === 'redacted_thinking' && typeof block.data === 'string')
16
+ return { type: 'redacted_thinking', data: block.data };
17
+ return undefined;
18
+ }
19
+ /** Normalized request -> Messages body. max_tokens is required on this dialect. */
20
+ export function toAnthropicBody(req, options = {}) {
21
+ const messages = [];
22
+ let system = req.system;
23
+ let pendingToolResults = [];
24
+ const flushToolResults = () => {
25
+ if (pendingToolResults.length === 0)
26
+ return;
27
+ messages.push({
28
+ role: 'user',
29
+ content: pendingToolResults.map((r) => ({
30
+ type: 'tool_result',
31
+ tool_use_id: r.id,
32
+ content: r.content,
33
+ is_error: r.isError,
34
+ })),
35
+ });
36
+ pendingToolResults = [];
37
+ };
38
+ for (const m of req.messages) {
39
+ if (m.role === 'system') {
40
+ system = system ? `${system}\n${m.content}` : m.content; // stray system messages fold into the top-level prompt
41
+ continue;
42
+ }
43
+ if (m.role === 'assistant') {
44
+ flushToolResults();
45
+ const blocks = (m.thinkingBlocks ?? []).map((block) => ({ ...block }));
46
+ if (m.content)
47
+ blocks.push({ type: 'text', text: m.content });
48
+ for (const c of m.toolCalls ?? []) {
49
+ blocks.push({ type: 'tool_use', id: c.id, name: c.name, input: c.arguments });
50
+ }
51
+ if (blocks.length === 0)
52
+ continue; // content-less turns are dropped (API rejects them)
53
+ messages.push({ role: 'assistant', content: blocks });
54
+ continue;
55
+ }
56
+ if (m.role === 'tool') {
57
+ // Batch consecutive tool results into one user turn.
58
+ pendingToolResults.push({ id: m.toolCallId ?? '', content: m.content, isError: m.isError ?? false });
59
+ continue;
60
+ }
61
+ flushToolResults(); // any non-tool message ends the batch (ordering matters)
62
+ if (m.images?.length) {
63
+ const content = m.content ? [{ type: 'text', text: m.content }] : [];
64
+ content.push(...m.images.map((image) => ({
65
+ type: 'image', source: { type: 'base64', media_type: image.mediaType, data: image.data },
66
+ })));
67
+ messages.push({ role: 'user', content });
68
+ }
69
+ else
70
+ messages.push({ role: 'user', content: m.content });
71
+ }
72
+ flushToolResults();
73
+ const budget = anthropicThinkingBudget(req.effort);
74
+ const maxTokens = effectiveOutputTokens('anthropic', req.maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, req.effort);
75
+ const body = { model: req.model, max_tokens: maxTokens, messages, stream: req.stream };
76
+ if (budget)
77
+ body.thinking = { type: 'enabled', budget_tokens: budget };
78
+ if (system)
79
+ body.system = system;
80
+ if (req.tools && req.tools.length > 0) {
81
+ body.tools = req.tools.map((t) => ({
82
+ name: t.name,
83
+ description: t.description,
84
+ input_schema: t.parameters,
85
+ }));
86
+ }
87
+ if (options.cache)
88
+ applyPromptCaching(body, messages);
89
+ return body;
90
+ }
91
+ /**
92
+ * Prompt caching: tools + system form one stable prefix, and a second breakpoint on the
93
+ * newest message lets every tool round reuse everything sent the round before. Cached
94
+ * input is billed at a fraction of the normal rate, which is the big saving for agent loops.
95
+ */
96
+ function applyPromptCaching(body, messages) {
97
+ const marker = { type: 'ephemeral' };
98
+ if (typeof body.system === 'string' && body.system) {
99
+ body.system = [{ type: 'text', text: body.system, cache_control: marker }];
100
+ }
101
+ const last = messages[messages.length - 1];
102
+ if (!last)
103
+ return;
104
+ if (typeof last.content === 'string') {
105
+ if (last.content)
106
+ last.content = [{ type: 'text', text: last.content, cache_control: marker }];
107
+ }
108
+ else if (Array.isArray(last.content) && last.content.length) {
109
+ const blocks = last.content;
110
+ blocks[blocks.length - 1] = { ...blocks[blocks.length - 1], cache_control: marker };
111
+ }
112
+ }
113
+ /** stop_reason mapping for the Messages API (Swift parity). */
114
+ export function mapAnthropicStopReason(reason) {
115
+ switch (reason) {
116
+ case 'end_turn':
117
+ case 'stop_sequence':
118
+ return 'stop';
119
+ case 'tool_use':
120
+ return 'tool_calls';
121
+ case 'max_tokens':
122
+ return 'length';
123
+ case 'refusal':
124
+ return 'refusal';
125
+ default:
126
+ return 'unknown';
127
+ }
128
+ }
129
+ /**
130
+ * Streaming assembly for the Messages API: message_start → input tokens;
131
+ * content_block_start(tool_use) → tool_call_start; text_delta → text;
132
+ * input_json_delta → tool_call_args (accumulated, parsed only at the end);
133
+ * thinking/signature deltas retained silently; message_delta → stop_reason/output tokens;
134
+ * message_stop → single stop event. Refusals surface as text.
135
+ */
136
+ export class AnthropicStreamParser {
137
+ textParts = [];
138
+ blocks = new Map();
139
+ toolSeq = 0; // tool_use blocks get sequential indexes, gaps or not
140
+ inputTokens;
141
+ outputTokens;
142
+ cachedInputTokens;
143
+ stopReason;
144
+ done = false;
145
+ push(payload) {
146
+ if (this.done)
147
+ return []; // message_stop is final — later frames are ignored
148
+ const data = JSON.parse(payload);
149
+ const type = data.type;
150
+ const events = [];
151
+ if (type === 'error') {
152
+ const err = data.error;
153
+ const errObj = typeof err === 'object' && err !== null ? err : undefined;
154
+ throw new RouterError(typeof errObj?.message === 'string' ? errObj.message : 'The router returned an error event.');
155
+ }
156
+ if (type === 'ping')
157
+ return events;
158
+ if (type === 'message_start') {
159
+ const msg = data.message;
160
+ if (typeof msg === 'object' && msg !== null) {
161
+ const usage = msg.usage;
162
+ if (typeof usage === 'object' && usage !== null) {
163
+ const u = usage;
164
+ this.inputTokens = typeof u.input_tokens === 'number' ? u.input_tokens : this.inputTokens;
165
+ this.cachedInputTokens = typeof u.cache_read_input_tokens === 'number' ? u.cache_read_input_tokens : this.cachedInputTokens;
166
+ }
167
+ }
168
+ return events;
169
+ }
170
+ if (type === 'content_block_start') {
171
+ const block = data.content_block;
172
+ const index = typeof data.index === 'number' ? data.index : this.blocks.size;
173
+ if (typeof block === 'object' && block !== null && block.type === 'tool_use') {
174
+ const b = block;
175
+ const seq = this.toolSeq++;
176
+ this.blocks.set(index, {
177
+ text: '',
178
+ tool: { id: typeof b.id === 'string' ? b.id : '', name: typeof b.name === 'string' ? b.name : '', partialJson: '' },
179
+ });
180
+ events.push({ kind: 'tool_call_start', index: seq, id: this.blocks.get(index).tool.id, name: this.blocks.get(index).tool.name });
181
+ }
182
+ else {
183
+ this.blocks.set(index, { text: '', thinking: typeof block === 'object' && block !== null ? thinkingBlock(block) : undefined });
184
+ }
185
+ return events;
186
+ }
187
+ if (type === 'content_block_delta') {
188
+ const index = typeof data.index === 'number' ? data.index : 0;
189
+ const delta = data.delta;
190
+ if (typeof delta !== 'object' || delta === null)
191
+ return events;
192
+ const d = delta;
193
+ const state = this.blocks.get(index) ?? { text: '' };
194
+ this.blocks.set(index, state);
195
+ if (d.type === 'text_delta' && typeof d.text === 'string') {
196
+ this.textParts.push(d.text);
197
+ events.push({ kind: 'text', text: d.text });
198
+ }
199
+ else if (d.type === 'refusal_delta' && typeof d.refusal === 'string') {
200
+ this.textParts.push(d.refusal); // surfaced as content, not as a tool result
201
+ events.push({ kind: 'text', text: d.refusal });
202
+ }
203
+ else if (d.type === 'input_json_delta' && typeof d.partial_json === 'string') {
204
+ if (!state.tool)
205
+ state.tool = { id: '', name: '', partialJson: '' };
206
+ state.tool.partialJson += d.partial_json;
207
+ events.push({ kind: 'tool_call_args', index: this.seqFor(index), delta: d.partial_json });
208
+ }
209
+ else if (state.thinking?.type === 'thinking') {
210
+ if (d.type === 'thinking_delta' && typeof d.thinking === 'string')
211
+ state.thinking.thinking += d.thinking;
212
+ else if (d.type === 'signature_delta' && typeof d.signature === 'string')
213
+ state.thinking.signature += d.signature;
214
+ }
215
+ // Signed thinking is retained for tool continuations, never emitted to the UI.
216
+ return events;
217
+ }
218
+ if (type === 'content_block_stop') {
219
+ return events;
220
+ }
221
+ if (type === 'message_delta') {
222
+ const delta = data.delta;
223
+ if (typeof delta === 'object' && delta !== null && typeof delta.stop_reason === 'string') {
224
+ this.stopReason = mapAnthropicStopReason(delta.stop_reason);
225
+ }
226
+ const usage = data.usage;
227
+ if (typeof usage === 'object' && usage !== null && typeof usage.output_tokens === 'number') {
228
+ this.outputTokens = usage.output_tokens;
229
+ }
230
+ return events;
231
+ }
232
+ if (type === 'message_stop') {
233
+ this.done = true;
234
+ const usage = this.inputTokens !== undefined && this.outputTokens !== undefined
235
+ ? {
236
+ inputTokens: this.inputTokens,
237
+ outputTokens: this.outputTokens,
238
+ ...(this.cachedInputTokens !== undefined ? { cachedInputTokens: this.cachedInputTokens } : {}),
239
+ }
240
+ : undefined;
241
+ events.push({ kind: 'stop', stopReason: this.stopReason ?? 'unknown', usage });
242
+ return events;
243
+ }
244
+ return events; // unknown event types are ignored, never fatal
245
+ }
246
+ seqFor(index) {
247
+ // Map a content-block index back to the sequential tool index it was assigned.
248
+ const ordered = [...this.blocks.entries()];
249
+ let seq = 0;
250
+ for (const [i, s] of ordered) {
251
+ if (i === index)
252
+ return seq;
253
+ if (s.tool)
254
+ seq++;
255
+ }
256
+ return 0;
257
+ }
258
+ assistantMessage() {
259
+ const toolCalls = [];
260
+ for (const s of this.blocks.values()) {
261
+ if (!s.tool)
262
+ continue;
263
+ const { arguments: parsed, invalid } = parseToolArgs(s.tool.partialJson);
264
+ toolCalls.push({
265
+ id: s.tool.id,
266
+ name: s.tool.name,
267
+ arguments: parsed,
268
+ argumentsJson: s.tool.partialJson,
269
+ argumentsInvalid: invalid,
270
+ });
271
+ }
272
+ const message = { role: 'assistant', content: this.textParts.join('') };
273
+ if (toolCalls.length > 0)
274
+ message.toolCalls = toolCalls;
275
+ const thinkingBlocks = [...this.blocks.values()].flatMap((state) => state.thinking ? [{ ...state.thinking }] : []);
276
+ if (thinkingBlocks.length)
277
+ message.thinkingBlocks = thinkingBlocks;
278
+ return message;
279
+ }
280
+ }
281
+ /** Non-streaming Messages response -> normalized turn. */
282
+ export function parseAnthropicCompleteBody(json) {
283
+ if (typeof json !== 'object' || json === null) {
284
+ throw new RouterError('Unexpected response from the router (not an object).');
285
+ }
286
+ const body = json;
287
+ if (body.type === 'error') {
288
+ const err = body.error;
289
+ const errObj = typeof err === 'object' && err !== null ? err : undefined;
290
+ throw new RouterError(typeof errObj?.message === 'string' ? errObj.message : 'The router returned an error.');
291
+ }
292
+ if (!Array.isArray(body.content)) {
293
+ throw new RouterError('Unexpected response from the router (no content array).');
294
+ }
295
+ const textParts = [];
296
+ const toolCalls = [];
297
+ const thinkingBlocks = [];
298
+ for (const block of body.content) {
299
+ if (typeof block !== 'object' || block === null)
300
+ continue;
301
+ const thinking = thinkingBlock(block);
302
+ if (thinking)
303
+ thinkingBlocks.push(thinking);
304
+ if (block.type === 'text' && typeof block.text === 'string')
305
+ textParts.push(block.text);
306
+ if (block.type === 'refusal' && typeof block.refusal === 'string')
307
+ textParts.push(block.refusal);
308
+ if (block.type === 'tool_use') {
309
+ const input = block.input;
310
+ const rawArgs = typeof input === 'object' && input !== null ? JSON.stringify(input) : '';
311
+ const { arguments: parsed, invalid } = parseToolArgs(rawArgs);
312
+ toolCalls.push({
313
+ id: typeof block.id === 'string' ? block.id : '',
314
+ name: typeof block.name === 'string' ? block.name : '',
315
+ arguments: parsed,
316
+ argumentsJson: rawArgs,
317
+ argumentsInvalid: invalid,
318
+ });
319
+ }
320
+ }
321
+ const message = { role: 'assistant', content: textParts.join('') };
322
+ if (toolCalls.length > 0)
323
+ message.toolCalls = toolCalls;
324
+ if (thinkingBlocks.length)
325
+ message.thinkingBlocks = thinkingBlocks;
326
+ const stopReason = typeof body.stop_reason === 'string' ? mapAnthropicStopReason(body.stop_reason) : 'unknown';
327
+ const usageWire = body.usage;
328
+ let usage;
329
+ if (typeof usageWire === 'object' && usageWire !== null) {
330
+ const u = usageWire;
331
+ if (typeof u.input_tokens === 'number' && typeof u.output_tokens === 'number') {
332
+ const cached = typeof u.cache_read_input_tokens === 'number' ? u.cache_read_input_tokens : undefined;
333
+ usage = {
334
+ inputTokens: u.input_tokens,
335
+ outputTokens: u.output_tokens,
336
+ ...(cached !== undefined ? { cachedInputTokens: cached } : {}),
337
+ };
338
+ }
339
+ }
340
+ return { message, stopReason, usage };
341
+ }
@@ -0,0 +1,241 @@
1
+ // OpenAI Chat Completions wire <-> normalized types.
2
+ // Arguments travel as JSON strings; tool-call deltas arrive chunked by index and are
3
+ // merged here, never parsed mid-stream.
4
+ import { RouterError } from "../client.js";
5
+ import { deepSeekWireEffort, isDeepSeek } from "../effort.js";
6
+ /** Normalized request -> Chat Completions body. */
7
+ export function toWireBody(req) {
8
+ const messages = [];
9
+ if (req.system)
10
+ messages.push({ role: 'system', content: req.system });
11
+ for (const m of req.messages) {
12
+ if (m.role === 'assistant') {
13
+ const wire = { role: 'assistant', content: m.content };
14
+ if (m.toolCalls && m.toolCalls.length > 0) {
15
+ wire.tool_calls = m.toolCalls.map((c) => ({
16
+ id: c.id,
17
+ type: 'function',
18
+ function: { name: c.name, arguments: c.argumentsJson },
19
+ }));
20
+ }
21
+ // A tool_calls-only turn must not carry an empty content string.
22
+ if (!m.content && wire.tool_calls)
23
+ delete wire.content;
24
+ // Multi-turn contract (router docs): prior-turn CoT is concatenated only while the
25
+ // request carries tools; a tool-less request must omit it.
26
+ if (m.reasoningContent && req.tools && req.tools.length > 0)
27
+ wire.reasoning_content = m.reasoningContent;
28
+ messages.push(wire);
29
+ }
30
+ else if (m.role === 'tool') {
31
+ messages.push({ role: 'tool', tool_call_id: m.toolCallId ?? '', content: m.content });
32
+ }
33
+ else if (m.role === 'user' && m.images?.length) {
34
+ const content = m.content ? [{ type: 'text', text: m.content }] : [];
35
+ content.push(...m.images.map((image) => ({
36
+ type: 'image_url', image_url: { url: `data:${image.mediaType};base64,${image.data}` },
37
+ })));
38
+ messages.push({ role: 'user', content });
39
+ }
40
+ else {
41
+ messages.push({ role: m.role, content: m.content });
42
+ }
43
+ }
44
+ const body = { model: req.model, messages, stream: req.stream };
45
+ // Ask OpenAI-compatible providers to emit token usage (including cache hits) on the
46
+ // final stream chunk; without this the client has no usage to report at all.
47
+ if (req.stream)
48
+ body.stream_options = { include_usage: true };
49
+ if (req.tools && req.tools.length > 0) {
50
+ body.tools = req.tools.map((t) => ({
51
+ type: 'function',
52
+ function: { name: t.name, description: t.description, parameters: t.parameters },
53
+ }));
54
+ }
55
+ if (req.maxTokens)
56
+ body.max_tokens = req.maxTokens;
57
+ if (req.effort && req.effort !== 'auto') {
58
+ // DeepSeek only accepts a subset of reasoning levels; coerce CLI levels onto the
59
+ // provider's real ones. Other models keep sending the CLI level verbatim.
60
+ body.reasoning_effort = isDeepSeek(req.model) ? deepSeekWireEffort(req.model, req.effort) : req.effort;
61
+ }
62
+ return body;
63
+ }
64
+ /** Lenient tool-arguments parse: {} plus a flag on malformed JSON, never a throw. */
65
+ export function parseToolArgs(raw) {
66
+ try {
67
+ const value = JSON.parse(raw || '{}');
68
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
69
+ return { arguments: value, invalid: false };
70
+ }
71
+ return { arguments: {}, invalid: true };
72
+ }
73
+ catch {
74
+ return { arguments: {}, invalid: true };
75
+ }
76
+ }
77
+ function parseWireToolCalls(wire) {
78
+ if (!Array.isArray(wire))
79
+ return [];
80
+ const result = [];
81
+ for (const entry of wire) {
82
+ if (typeof entry !== 'object' || entry === null)
83
+ continue;
84
+ const c = entry;
85
+ const fn = c.function;
86
+ const name = typeof fn === 'object' && fn !== null ? fn.name : undefined;
87
+ if (typeof c.id !== 'string' || typeof name !== 'string')
88
+ continue;
89
+ const rawArgs = typeof fn === 'object' && fn !== null ? fn.arguments ?? '' : '';
90
+ const { arguments: parsed, invalid } = parseToolArgs(rawArgs);
91
+ result.push({ id: c.id, name, arguments: parsed, argumentsJson: rawArgs, argumentsInvalid: invalid });
92
+ }
93
+ return result;
94
+ }
95
+ export function mapFinishReason(finishReason) {
96
+ switch (finishReason) {
97
+ case 'stop':
98
+ return 'stop';
99
+ case 'tool_calls':
100
+ return 'tool_calls';
101
+ case 'length':
102
+ return 'length';
103
+ case 'content_filter':
104
+ return 'refusal';
105
+ default:
106
+ return 'unknown';
107
+ }
108
+ }
109
+ function parseUsage(wire) {
110
+ if (typeof wire !== 'object' || wire === null)
111
+ return undefined;
112
+ const u = wire;
113
+ if (typeof u.prompt_tokens !== 'number' || typeof u.completion_tokens !== 'number')
114
+ return undefined;
115
+ const details = (u.prompt_tokens_details ?? {});
116
+ // DeepSeek reports prompt_cache_hit_tokens; OpenAI-compatible APIs report
117
+ // prompt_tokens_details.cached_tokens. Either way it is a subset of prompt_tokens.
118
+ const cached = typeof details.cached_tokens === 'number'
119
+ ? details.cached_tokens
120
+ : typeof u.prompt_cache_hit_tokens === 'number'
121
+ ? u.prompt_cache_hit_tokens
122
+ : undefined;
123
+ return {
124
+ inputTokens: u.prompt_tokens,
125
+ outputTokens: u.completion_tokens,
126
+ ...(cached !== undefined ? { cachedInputTokens: cached } : {}),
127
+ };
128
+ }
129
+ /** Non-streaming Chat Completions response -> AssistantTurn. */
130
+ export function parseCompleteBody(json) {
131
+ const root = json;
132
+ const choice = root.choices?.[0];
133
+ if (!choice || typeof choice !== 'object') {
134
+ throw new RouterError('The router returned an invalid completion response.');
135
+ }
136
+ const msg = (choice.message ?? {});
137
+ const content = typeof msg.content === 'string' ? msg.content : '';
138
+ const toolCalls = parseWireToolCalls(msg.tool_calls);
139
+ const message = { role: 'assistant', content };
140
+ if (toolCalls.length > 0)
141
+ message.toolCalls = toolCalls;
142
+ if (typeof msg.reasoning_content === 'string' && msg.reasoning_content)
143
+ message.reasoningContent = msg.reasoning_content;
144
+ return {
145
+ message,
146
+ stopReason: mapFinishReason(choice.finish_reason),
147
+ usage: parseUsage(root.usage),
148
+ };
149
+ }
150
+ /**
151
+ * Streaming state machine. Feed one SSE `data:` payload per push(); get StreamEvents.
152
+ * Emits a single 'stop' event once finish_reason appears (verified live: the final
153
+ * chunk carries finish_reason + usage).
154
+ */
155
+ export class OpenAiStreamParser {
156
+ text = '';
157
+ reasoning = '';
158
+ toolCalls = [];
159
+ stopped = false;
160
+ push(payload) {
161
+ if (this.stopped)
162
+ return [];
163
+ let json;
164
+ try {
165
+ json = JSON.parse(payload);
166
+ }
167
+ catch {
168
+ throw new RouterError('The router returned an invalid streaming response.');
169
+ }
170
+ const root = json;
171
+ if (root.error) {
172
+ const err = root.error;
173
+ const message = typeof err.message === 'string' ? err.message : 'The router could not complete the stream.';
174
+ throw new RouterError(message);
175
+ }
176
+ const events = [];
177
+ const choice = root.choices?.[0];
178
+ const delta = (choice?.delta ?? {});
179
+ if (typeof delta.content === 'string' && delta.content) {
180
+ this.text += delta.content;
181
+ events.push({ kind: 'text', text: delta.content });
182
+ }
183
+ if (typeof delta.reasoning_content === 'string' && delta.reasoning_content) {
184
+ this.reasoning += delta.reasoning_content;
185
+ events.push({ kind: 'reasoning', text: delta.reasoning_content });
186
+ }
187
+ if (Array.isArray(delta.tool_calls)) {
188
+ for (const call of delta.tool_calls) {
189
+ if (typeof call !== 'object' || call === null)
190
+ continue;
191
+ const index = typeof call.index === 'number' ? call.index : 0;
192
+ let entry = this.toolCalls.find((t) => t.index === index);
193
+ if (!entry) {
194
+ entry = { index, id: '', name: '', args: '', started: false };
195
+ this.toolCalls.push(entry);
196
+ }
197
+ if (typeof call.id === 'string' && call.id)
198
+ entry.id = call.id;
199
+ const fn = call.function;
200
+ let argDelta = '';
201
+ if (typeof fn === 'object' && fn !== null) {
202
+ if (typeof fn.name === 'string' && fn.name)
203
+ entry.name = fn.name;
204
+ if (typeof fn.arguments === 'string') {
205
+ argDelta = fn.arguments;
206
+ entry.args += argDelta;
207
+ }
208
+ }
209
+ if (!entry.started) {
210
+ entry.started = true;
211
+ events.push({ kind: 'tool_call_start', index, id: entry.id, name: entry.name });
212
+ }
213
+ if (argDelta) {
214
+ events.push({ kind: 'tool_call_args', index, delta: argDelta });
215
+ }
216
+ }
217
+ }
218
+ if (typeof choice?.finish_reason === 'string' && choice.finish_reason) {
219
+ const usage = parseUsage(root.usage);
220
+ this.stopped = true;
221
+ events.push({ kind: 'stop', stopReason: mapFinishReason(choice.finish_reason), usage });
222
+ }
223
+ return events;
224
+ }
225
+ /** The assembled assistant message for this turn. */
226
+ assistantMessage() {
227
+ const message = { role: 'assistant', content: this.text };
228
+ if (this.toolCalls.length > 0) {
229
+ message.toolCalls = this.toolCalls.map((t) => {
230
+ const { arguments: parsed, invalid } = parseToolArgs(t.args);
231
+ return { id: t.id, name: t.name, arguments: parsed, argumentsJson: t.args, argumentsInvalid: invalid };
232
+ });
233
+ }
234
+ if (this.reasoning)
235
+ message.reasoningContent = this.reasoning;
236
+ return message;
237
+ }
238
+ reasoningSoFar() {
239
+ return this.reasoning;
240
+ }
241
+ }
@@ -0,0 +1,28 @@
1
+ // Permission modes are session-local; the legacy auto_approve setting is file-only.
2
+ export const APPROVAL_MODES = ['ask', 'files', 'auto', 'absolute'];
3
+ export const APPROVAL_DESCRIPTIONS = {
4
+ ask: 'Ask before file changes and terminal commands',
5
+ files: 'Allow project file changes; ask before terminal commands',
6
+ auto: 'Allow files and commands; ask before network, installs, sudo, outside paths and destructive operations',
7
+ absolute: 'Whole drive and every command run without prompts; only recognizable catastrophic deletion or disk commands ask',
8
+ };
9
+ export const APPROVAL_LABELS = {
10
+ ask: 'ask before writes',
11
+ files: 'files allowed · commands ask',
12
+ auto: 'guarded auto',
13
+ absolute: 'unguarded auto',
14
+ };
15
+ /** Short names for the mode picker; the internal id `absolute` is never shown to the user. */
16
+ export const APPROVAL_NAMES = {
17
+ ask: 'ask',
18
+ files: 'files',
19
+ auto: 'auto',
20
+ absolute: 'unguarded',
21
+ };
22
+ /** The internal id stays `absolute`; users type `unguarded`/`unguarded auto` or the legacy `absolute`. */
23
+ export function parseApprovalMode(value) {
24
+ const key = value.trim().toLowerCase();
25
+ if (key === 'unguarded' || key === 'unguarded auto')
26
+ return 'absolute';
27
+ return APPROVAL_MODES.find((mode) => mode === key);
28
+ }
@@ -0,0 +1,15 @@
1
+ /** Conservative gate: Boost never turns casual chat or a tiny edit into extra model calls. */
2
+ export function boostRoles(task) {
3
+ const text = task.trim();
4
+ if (text.length < 60 || /^(?:hi|hello|hey|thanks|what is|explain|summarize)\b/i.test(text))
5
+ return [];
6
+ const substantial = /\b(?:implement|build|refactor|redesign|investigate|debug|fix|review|improve|migrate|create|audit|overhaul)\b/i.test(text);
7
+ if (!substantial)
8
+ return [];
9
+ const ui = /\b(?:ui|ux|interface|layout|design|visual|accessibility|theme|tui)\b/i.test(text);
10
+ const research = /\b(?:research|documentation|docs|latest|web|online|compare providers)\b/i.test(text);
11
+ if (research)
12
+ return ['researcher', 'code'];
13
+ return ui ? ['ui-ux', 'code'] : ['code', 'reviewer'];
14
+ }
15
+ export const BOOST_WORKFLOW_GUIDANCE = `Boost workflow (host-selected for a substantial task): independent read-only helpers are inspecting relevant files, locating code and researching prerequisites while you work. Their completed findings arrive at safe model-round boundaries. Continue implementation now; use useful findings to avoid duplicate investigation, but verify before relying on them. You own edits, commands, approvals and focused validation. Keep user-selected model and effort, report what passed or remains unverified, and do not spawn redundant helpers.`;