@dotsetlabs/dotclaw 2.5.0 → 2.6.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.
package/.env.example CHANGED
@@ -8,16 +8,18 @@
8
8
  # (they are NOT read from .env unless the script explicitly loads dotenv).
9
9
 
10
10
  # --- Required (app runtime) ---
11
+ # Set at least one provider token (Telegram or Discord).
12
+
11
13
  # Telegram bot token from @BotFather
12
- TELEGRAM_BOT_TOKEN=123456789:replace-with-real-token
14
+ # TELEGRAM_BOT_TOKEN=123456789:replace-with-real-token
15
+
16
+ # Discord bot token (from Discord Developer Portal)
17
+ # DISCORD_BOT_TOKEN=replace-with-discord-token
13
18
 
14
19
  # OpenRouter API key
15
20
  OPENROUTER_API_KEY=sk-or-replace-with-real-key
16
21
 
17
22
  # --- Optional (app runtime) ---
18
- # Discord bot token (enables Discord provider)
19
- # DISCORD_BOT_TOKEN=replace-with-discord-token
20
-
21
23
  # Brave Search API key (enables WebSearch tool in the agent)
22
24
  BRAVE_SEARCH_API_KEY=replace-with-brave-key
23
25
 
@@ -27,11 +29,8 @@ TZ=America/New_York
27
29
  # GitHub Personal Access Token (enables gh CLI in containers)
28
30
  # GH_TOKEN=ghp_your_token_here
29
31
 
30
- # OpenAI API key (enables OpenAI TTS provider; falls back to OPENROUTER_API_KEY)
31
- # OPENAI_API_KEY=sk-replace-with-openai-key
32
-
33
- # Override vision model for the AnalyzeImage tool (defaults to openai/gpt-4o)
34
- # DOTCLAW_VISION_MODEL=openai/gpt-4o
32
+ # Container-only overrides (for example OPENAI_API_KEY or DOTCLAW_VISION_MODEL)
33
+ # should be set per group in ~/.dotclaw/data/registered_groups.json under containerConfig.env.
35
34
 
36
35
  # --- Optional: System (set in shell before starting) ---
37
36
  # Override DotClaw home directory (defaults to ~/.dotclaw)
@@ -63,7 +62,7 @@ TZ=America/New_York
63
62
  # AUTOTUNE_OUTPUT_DIR=~/.dotclaw/prompts
64
63
  # AUTOTUNE_CANARY_FRACTION=0.1
65
64
  # AUTOTUNE_BEHAVIOR_CONFIG_PATH=~/.dotclaw/config/behavior.json
66
- # AUTOTUNE_BEHAVIOR_REPORT_DIR=~/.dotclaw/config
65
+ # AUTOTUNE_BEHAVIOR_REPORT_DIR=~/.dotclaw/data
67
66
  # AUTOTUNE_BEHAVIOR_ENABLED=1
68
67
 
69
68
  # --- Internal (do not set) ---
package/README.md CHANGED
@@ -23,9 +23,10 @@ Personal OpenRouter-based assistant for Telegram and Discord. Each request runs
23
23
 
24
24
  - Node.js 20+
25
25
  - Docker (running)
26
- - Telegram bot token (from @BotFather)
26
+ - At least one provider token:
27
+ - Telegram bot token (from @BotFather), or
28
+ - Discord bot token
27
29
  - OpenRouter API key
28
- - Discord bot token (optional — for Discord provider)
29
30
 
30
31
  ## Quick Start
31
32
 
@@ -64,7 +65,10 @@ dotclaw groups # List registered chats
64
65
  dotclaw build # Build the Docker container image
65
66
  dotclaw add-instance # Create and start an isolated instance
66
67
  dotclaw instances # List discovered instances
68
+ dotclaw install-service # Install launchd/systemd service
69
+ dotclaw uninstall-service # Remove launchd/systemd service
67
70
  dotclaw version # Show installed version
71
+ dotclaw help # Show help
68
72
  ```
69
73
 
70
74
  Instance flags:
@@ -80,7 +84,7 @@ All configuration and data is stored in `~/.dotclaw/`:
80
84
 
81
85
  ```
82
86
  ~/.dotclaw/
83
- .env # Secrets (Telegram, OpenRouter keys)
87
+ .env # Secrets (provider tokens, OpenRouter key, optional Brave/GH keys)
84
88
  config/
85
89
  runtime.json # Runtime overrides
86
90
  model.json # Model selection
@@ -119,7 +123,7 @@ Or see:
119
123
  ## Development
120
124
 
121
125
  ```bash
122
- npm run dev # Run with hot reload
126
+ npm run dev # Run from source (tsx)
123
127
  npm run dev:up # Full dev cycle: rebuild container + kill stale daemons + start dev
124
128
  npm run dev:down # Remove all running dotclaw agent containers
125
129
  npm run build # Compile TypeScript (host)
@@ -100,13 +100,13 @@
100
100
  },
101
101
  "bash": {
102
102
  "timeoutMs": 600000
103
- },
104
- "process": {
105
- "maxSessions": 16,
106
- "maxOutputBytes": 1048576,
107
- "defaultTimeoutMs": 1800000
108
103
  }
109
104
  },
105
+ "process": {
106
+ "maxSessions": 16,
107
+ "maxOutputBytes": 1048576,
108
+ "defaultTimeoutMs": 1800000
109
+ },
110
110
  "tts": {
111
111
  "provider": "edge-tts",
112
112
  "openaiModel": "tts-1",
@@ -13,6 +13,8 @@
13
13
  "Bash",
14
14
  "Python",
15
15
  "Browser",
16
+ "Process",
17
+ "AnalyzeImage",
16
18
  "mcp__dotclaw__send_message",
17
19
  "mcp__dotclaw__send_file",
18
20
  "mcp__dotclaw__send_photo",
@@ -41,14 +43,22 @@
41
43
  "mcp__dotclaw__memory_forget",
42
44
  "mcp__dotclaw__memory_list",
43
45
  "mcp__dotclaw__memory_search",
44
- "mcp__dotclaw__memory_stats"
46
+ "mcp__dotclaw__memory_stats",
47
+ "mcp__dotclaw__get_config",
48
+ "mcp__dotclaw__set_tool_policy",
49
+ "mcp__dotclaw__set_behavior",
50
+ "mcp__dotclaw__set_mcp_config",
51
+ "mcp__dotclaw__subagent"
45
52
  ],
46
53
  "deny": [],
47
54
  "max_per_run": {
48
55
  "Bash": 128,
49
56
  "Python": 64,
50
57
  "WebSearch": 40,
51
- "WebFetch": 60
58
+ "WebFetch": 60,
59
+ "Process": 128,
60
+ "AnalyzeImage": 16,
61
+ "mcp__dotclaw__subagent": 8
52
62
  },
53
63
  "default_max_per_run": 256
54
64
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "dotclaw-agent-runner",
3
- "version": "1.9.0",
3
+ "version": "2.3.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "dotclaw-agent-runner",
9
- "version": "1.9.0",
9
+ "version": "2.3.1",
10
10
  "dependencies": {
11
11
  "@openrouter/sdk": "^0.3.0",
12
12
  "cron-parser": "^5.0.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotclaw-agent-runner",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
4
4
  "type": "module",
5
5
  "description": "Container-side agent runner for DotClaw",
6
6
  "main": "dist/index.js",
@@ -40,6 +40,7 @@ import {
40
40
  buildToolOutcomeFallback,
41
41
  compactToolConversationItems,
42
42
  detectToolExecutionRequirement,
43
+ buildMalformedArgumentsRecoveryHint,
43
44
  isNonRetryableToolError,
44
45
  normalizeToolCallArguments,
45
46
  normalizeToolCallSignature,
@@ -48,6 +49,15 @@ import {
48
49
  parseListReadNewestInstruction,
49
50
  shouldRetryIdempotentToolCall,
50
51
  } from './tool-loop-policy.js';
52
+ import {
53
+ injectImagesIntoContextInput,
54
+ loadImageAttachmentsForInput,
55
+ messagesToOpenRouterInput,
56
+ } from './openrouter-input.js';
57
+ import {
58
+ extractFunctionCallsForReplay,
59
+ toReplayFunctionCallItems,
60
+ } from './openrouter-followup.js';
51
61
 
52
62
  type OpenRouterResult = ReturnType<OpenRouter['callModel']>;
53
63
 
@@ -202,21 +212,6 @@ function extractTextFromApiResponse(response: any): string {
202
212
  return '';
203
213
  }
204
214
 
205
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
206
- function extractFunctionCalls(response: any): Array<{ id: string; name: string; arguments: any }> {
207
- const calls: Array<{ id: string; name: string; arguments: unknown }> = [];
208
- for (const item of response?.output || []) {
209
- if (item?.type === 'function_call') {
210
- let args = item.arguments;
211
- if (typeof args === 'string') {
212
- try { args = JSON.parse(args); } catch { /* keep as string */ }
213
- }
214
- calls.push({ id: item.callId, name: item.name, arguments: args });
215
- }
216
- }
217
- return calls;
218
- }
219
-
220
215
  function writeOutput(output: ContainerOutput): void {
221
216
  console.log(OUTPUT_START_MARKER);
222
217
  console.log(JSON.stringify(output));
@@ -513,55 +508,6 @@ function loadClaudeNotes(): { group: string | null; global: string | null } {
513
508
  };
514
509
  }
515
510
 
516
-
517
- // ── Image/Vision support ──────────────────────────────────────────────
518
-
519
- const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5MB per image
520
- const MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024; // 20MB total across all images
521
- const IMAGE_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
522
-
523
- function loadImageAttachments(attachments?: ContainerInput['attachments']): Array<{
524
- type: 'image_url';
525
- image_url: { url: string };
526
- }> {
527
- if (!attachments) return [];
528
- const images: Array<{ type: 'image_url'; image_url: { url: string } }> = [];
529
- let totalBytes = 0;
530
- for (const att of attachments) {
531
- if (att.type !== 'photo') continue;
532
- const mime = att.mime_type || 'image/jpeg';
533
- if (!IMAGE_MIME_TYPES.has(mime)) continue;
534
- try {
535
- const stat = fs.statSync(att.path);
536
- if (stat.size > MAX_IMAGE_BYTES) {
537
- log(`Skipping image ${att.path}: ${stat.size} bytes exceeds ${MAX_IMAGE_BYTES}`);
538
- continue;
539
- }
540
- if (totalBytes + stat.size > MAX_TOTAL_IMAGE_BYTES) {
541
- log(`Skipping image ${att.path}: cumulative size would exceed ${MAX_TOTAL_IMAGE_BYTES}`);
542
- break;
543
- }
544
- const data = fs.readFileSync(att.path);
545
- totalBytes += data.length;
546
- const b64 = data.toString('base64');
547
- images.push({
548
- type: 'image_url',
549
- image_url: { url: `data:${mime};base64,${b64}` }
550
- });
551
- } catch (err) {
552
- log(`Failed to load image ${att.path}: ${err instanceof Error ? err.message : err}`);
553
- }
554
- }
555
- return images;
556
- }
557
-
558
- function messagesToOpenRouter(messages: Message[]) {
559
- return messages.map(message => ({
560
- role: message.role,
561
- content: message.content
562
- }));
563
- }
564
-
565
511
  function clampContextMessages(messages: Message[], tokensPerChar: number, maxTokens: number): Message[] {
566
512
  if (!Number.isFinite(maxTokens) || maxTokens <= 0) return messages;
567
513
  const tpc = tokensPerChar > 0 ? tokensPerChar : 0.25;
@@ -1287,21 +1233,12 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1287
1233
  }
1288
1234
  }
1289
1235
 
1290
- const contextInput = messagesToOpenRouter(contextMessages);
1236
+ const contextInput = messagesToOpenRouterInput(contextMessages);
1291
1237
 
1292
- // Inject vision content into the last user message if images are present
1293
- const imageContent = loadImageAttachments(input.attachments);
1294
- if (imageContent.length > 0 && contextInput.length > 0) {
1295
- const lastMsg = contextInput[contextInput.length - 1];
1296
- if (lastMsg.role === 'user') {
1297
- // Convert string content to multi-modal content array
1298
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1299
- (lastMsg as any).content = [
1300
- { type: 'text', text: typeof lastMsg.content === 'string' ? lastMsg.content : '' },
1301
- ...imageContent
1302
- ];
1303
- }
1304
- }
1238
+ // Inject vision content into the last user message if images are present.
1239
+ // Uses OpenRouter Responses API content part types (input_text/input_image).
1240
+ const imageContent = loadImageAttachmentsForInput(input.attachments, { log });
1241
+ injectImagesIntoContextInput(contextInput, imageContent);
1305
1242
 
1306
1243
  let lastError: unknown = null;
1307
1244
  for (let attempt = 0; attempt < modelChain.length; attempt++) {
@@ -1363,7 +1300,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1363
1300
  }
1364
1301
 
1365
1302
  responseText = extractTextFromApiResponse(lastResponse);
1366
- let pendingCalls = extractFunctionCalls(lastResponse);
1303
+ let pendingCalls = extractFunctionCallsForReplay(lastResponse);
1367
1304
  const callSignatureCounts = new Map<string, number>();
1368
1305
  let previousRoundSignature = '';
1369
1306
  let repeatedRoundCount = 0;
@@ -1482,8 +1419,10 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1482
1419
  reason: nudgeReason,
1483
1420
  attempt: toolRequirementNudgeAttempt
1484
1421
  });
1485
- const responseItems = Array.isArray(lastResponse?.output) ? lastResponse.output : [];
1486
- conversationInput = [...conversationInput, ...responseItems, { role: 'user', content: nudgePrompt }];
1422
+ if (responseText) {
1423
+ conversationInput = [...conversationInput, { role: 'assistant', content: responseText }];
1424
+ }
1425
+ conversationInput = [...conversationInput, { role: 'user', content: nudgePrompt }];
1487
1426
  try {
1488
1427
  const nudgeResult = openrouter.callModel({
1489
1428
  model: currentModel,
@@ -1500,7 +1439,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1500
1439
  responseText = nudgeText;
1501
1440
  writeStreamChunk(nudgeText);
1502
1441
  }
1503
- pendingCalls = extractFunctionCalls(lastResponse);
1442
+ pendingCalls = extractFunctionCallsForReplay(lastResponse);
1504
1443
  } catch (nudgeErr) {
1505
1444
  log(`Tool requirement nudge failed: ${nudgeErr instanceof Error ? nudgeErr.message : String(nudgeErr)}`);
1506
1445
  break;
@@ -1563,7 +1502,13 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1563
1502
  rawArguments: fc.arguments
1564
1503
  });
1565
1504
  if (normalizedArgs.malformedReason) {
1566
- const error = `Malformed arguments for ${fc.name}: ${normalizedArgs.malformedReason}`;
1505
+ const recoveryHint = buildMalformedArgumentsRecoveryHint({
1506
+ toolName: fc.name,
1507
+ malformedReason: normalizedArgs.malformedReason
1508
+ });
1509
+ const error = recoveryHint
1510
+ ? `Malformed arguments for ${fc.name}: ${normalizedArgs.malformedReason}. ${recoveryHint}`
1511
+ : `Malformed arguments for ${fc.name}: ${normalizedArgs.malformedReason}`;
1567
1512
  toolResults.push({
1568
1513
  type: 'function_call_output',
1569
1514
  callId: fc.id,
@@ -1634,7 +1579,8 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1634
1579
 
1635
1580
  // Build follow-up input with FULL conversation context:
1636
1581
  // original messages + model output + tool results (accumulated each round)
1637
- conversationInput = [...conversationInput, ...lastResponse.output, ...toolResults];
1582
+ const replayFunctionCalls = toReplayFunctionCallItems(pendingCalls);
1583
+ conversationInput = [...conversationInput, ...replayFunctionCalls, ...toolResults];
1638
1584
 
1639
1585
  // Compact oversized tool payloads before follow-up calls to reduce context bloat.
1640
1586
  const compactedConversation = compactToolConversationItems(conversationInput, {
@@ -1741,7 +1687,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1741
1687
  responseText = retryText;
1742
1688
  writeStreamChunk(retryText);
1743
1689
  }
1744
- pendingCalls = extractFunctionCalls(lastResponse);
1690
+ pendingCalls = extractFunctionCallsForReplay(lastResponse);
1745
1691
  continue;
1746
1692
  } catch (retryErr) {
1747
1693
  log(`Context overflow retry failed: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
@@ -1761,7 +1707,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1761
1707
  writeStreamChunk(followupText);
1762
1708
  }
1763
1709
 
1764
- pendingCalls = extractFunctionCalls(lastResponse);
1710
+ pendingCalls = extractFunctionCallsForReplay(lastResponse);
1765
1711
  }
1766
1712
 
1767
1713
  if (toolExecutionRequirement.required && toolCalls.length === 0) {
@@ -158,21 +158,121 @@ export function appendHistory(ctx: SessionContext, role: 'user' | 'assistant', c
158
158
  return message;
159
159
  }
160
160
 
161
+ function safeStringifyHistoryValue(value: unknown): string {
162
+ try {
163
+ const serialized = JSON.stringify(value);
164
+ return typeof serialized === 'string' ? serialized : String(value);
165
+ } catch {
166
+ return String(value);
167
+ }
168
+ }
169
+
170
+ function coerceHistoryContentToString(value: unknown): string {
171
+ if (typeof value === 'string') return value;
172
+ if (value == null) return '';
173
+
174
+ if (Array.isArray(value)) {
175
+ const parts = value
176
+ .map((part) => {
177
+ if (!part || typeof part !== 'object') return null;
178
+ const record = part as Record<string, unknown>;
179
+ if (typeof record.text === 'string' && record.text.trim()) return record.text;
180
+ if (typeof record.content === 'string' && record.content.trim()) return record.content;
181
+ if (typeof record.output === 'string' && record.output.trim()) return record.output;
182
+ if (typeof record.refusal === 'string' && record.refusal.trim()) return record.refusal;
183
+ return null;
184
+ })
185
+ .filter((part): part is string => typeof part === 'string');
186
+ if (parts.length > 0) return parts.join('\n');
187
+ return safeStringifyHistoryValue(value);
188
+ }
189
+
190
+ if (typeof value === 'object') {
191
+ const record = value as Record<string, unknown>;
192
+ if (typeof record.text === 'string' && record.text.trim()) return record.text;
193
+ if (typeof record.content === 'string' && record.content.trim()) return record.content;
194
+ if (typeof record.output === 'string' && record.output.trim()) return record.output;
195
+ if (typeof record.refusal === 'string' && record.refusal.trim()) return record.refusal;
196
+ return safeStringifyHistoryValue(value);
197
+ }
198
+
199
+ return String(value);
200
+ }
201
+
161
202
  export function loadHistory(ctx: SessionContext): Message[] {
162
203
  if (!fs.existsSync(ctx.historyPath)) return [];
163
- const lines = fs.readFileSync(ctx.historyPath, 'utf-8').trim().split('\n');
204
+ const raw = fs.readFileSync(ctx.historyPath, 'utf-8');
205
+ if (!raw.trim()) return [];
206
+ const lines = raw.trim().split('\n');
164
207
  const messages: Message[] = [];
208
+ let needsRewrite = false;
209
+ let highestSeq = 0;
165
210
  for (const line of lines) {
166
- if (!line.trim()) continue;
211
+ if (!line.trim()) {
212
+ needsRewrite = true;
213
+ continue;
214
+ }
167
215
  try {
168
- const parsed = JSON.parse(line);
169
- if (parsed?.role && parsed?.content) {
170
- messages.push(parsed);
216
+ const parsed = JSON.parse(line) as Record<string, unknown>;
217
+ const role = parsed?.role === 'user' || parsed?.role === 'assistant'
218
+ ? parsed.role
219
+ : null;
220
+ if (!role) {
221
+ needsRewrite = true;
222
+ continue;
223
+ }
224
+
225
+ const content = coerceHistoryContentToString(parsed.content);
226
+ if (typeof parsed.content !== 'string') {
227
+ needsRewrite = true;
228
+ }
229
+
230
+ const timestamp = typeof parsed.timestamp === 'string' && parsed.timestamp
231
+ ? parsed.timestamp
232
+ : new Date().toISOString();
233
+ if (timestamp !== parsed.timestamp) {
234
+ needsRewrite = true;
171
235
  }
236
+
237
+ const parsedSeq = Number(parsed.seq);
238
+ const seq = Number.isFinite(parsedSeq) && parsedSeq > 0
239
+ ? Math.floor(parsedSeq)
240
+ : (highestSeq + 1);
241
+ if (seq !== parsedSeq) {
242
+ needsRewrite = true;
243
+ }
244
+ highestSeq = Math.max(highestSeq, seq);
245
+
246
+ messages.push({
247
+ role,
248
+ content,
249
+ timestamp,
250
+ seq
251
+ });
172
252
  } catch {
173
- // ignore malformed lines
253
+ needsRewrite = true;
174
254
  }
175
255
  }
256
+
257
+ messages.sort((a, b) => a.seq - b.seq);
258
+ let nextSeq = 1;
259
+ for (const message of messages) {
260
+ if (message.seq < nextSeq) {
261
+ message.seq = nextSeq;
262
+ needsRewrite = true;
263
+ }
264
+ nextSeq = message.seq + 1;
265
+ }
266
+
267
+ if (ctx.meta.nextSeq < nextSeq) {
268
+ ctx.meta.nextSeq = nextSeq;
269
+ saveSessionMeta(ctx);
270
+ }
271
+
272
+ if (needsRewrite) {
273
+ writeHistory(ctx, messages);
274
+ }
275
+
176
276
  return messages;
177
277
  }
178
278
 
@@ -0,0 +1,87 @@
1
+ type RawRecord = Record<string, unknown>;
2
+
3
+ export type ExtractedFunctionCall = {
4
+ id: string;
5
+ itemId?: string;
6
+ name: string;
7
+ arguments: unknown;
8
+ argumentsText: string;
9
+ };
10
+
11
+ export type ReplayFunctionCallItem = {
12
+ type: 'function_call';
13
+ id: string;
14
+ callId: string;
15
+ name: string;
16
+ arguments: string;
17
+ };
18
+
19
+ function normalizeCallId(record: RawRecord): { callId?: string; itemId?: string } {
20
+ const callId = typeof record.callId === 'string' && record.callId.trim()
21
+ ? record.callId.trim()
22
+ : (typeof record.id === 'string' && record.id.trim() ? record.id.trim() : undefined);
23
+ const itemId = typeof record.id === 'string' && record.id.trim()
24
+ ? record.id.trim()
25
+ : undefined;
26
+ return { callId, itemId };
27
+ }
28
+
29
+ function normalizeArguments(raw: unknown): { arguments: unknown; argumentsText: string } {
30
+ if (typeof raw === 'string') {
31
+ const trimmed = raw.trim();
32
+ if (!trimmed) return { arguments: {}, argumentsText: '{}' };
33
+ try {
34
+ return { arguments: JSON.parse(trimmed), argumentsText: trimmed };
35
+ } catch {
36
+ return { arguments: raw, argumentsText: raw };
37
+ }
38
+ }
39
+ if (raw == null) return { arguments: {}, argumentsText: '{}' };
40
+ try {
41
+ const serialized = JSON.stringify(raw);
42
+ if (typeof serialized === 'string') {
43
+ return { arguments: raw, argumentsText: serialized };
44
+ }
45
+ } catch {
46
+ // fall through
47
+ }
48
+ return { arguments: raw, argumentsText: String(raw) };
49
+ }
50
+
51
+ function extractFromOutput(output: unknown[]): ExtractedFunctionCall[] {
52
+ const calls: ExtractedFunctionCall[] = [];
53
+ for (const item of output) {
54
+ if (!item || typeof item !== 'object') continue;
55
+ const record = item as RawRecord;
56
+ if (record.type !== 'function_call') continue;
57
+ const { callId, itemId } = normalizeCallId(record);
58
+ const name = typeof record.name === 'string' ? record.name.trim() : '';
59
+ if (!callId || !name) continue;
60
+ const normalizedArguments = normalizeArguments(record.arguments);
61
+ calls.push({
62
+ id: callId,
63
+ itemId,
64
+ name,
65
+ arguments: normalizedArguments.arguments,
66
+ argumentsText: normalizedArguments.argumentsText
67
+ });
68
+ }
69
+ return calls;
70
+ }
71
+
72
+ export function extractFunctionCallsForReplay(response: unknown): ExtractedFunctionCall[] {
73
+ if (!response || typeof response !== 'object') return [];
74
+ const output = (response as { output?: unknown }).output;
75
+ if (!Array.isArray(output)) return [];
76
+ return extractFromOutput(output);
77
+ }
78
+
79
+ export function toReplayFunctionCallItems(calls: ExtractedFunctionCall[]): ReplayFunctionCallItem[] {
80
+ return calls.map((call) => ({
81
+ type: 'function_call',
82
+ id: call.itemId || call.id,
83
+ callId: call.id,
84
+ name: call.name,
85
+ arguments: call.argumentsText || '{}'
86
+ }));
87
+ }