@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 +9 -10
- package/README.md +8 -4
- package/config-examples/runtime.json +5 -5
- package/config-examples/tool-policy.json +12 -2
- package/container/agent-runner/package-lock.json +2 -2
- package/container/agent-runner/package.json +1 -1
- package/container/agent-runner/src/index.ts +32 -86
- package/container/agent-runner/src/memory.ts +106 -6
- package/container/agent-runner/src/openrouter-followup.ts +87 -0
- package/container/agent-runner/src/openrouter-input.ts +159 -0
- package/container/agent-runner/src/system-prompt.ts +1 -0
- package/container/agent-runner/src/tool-loop-policy.ts +17 -0
- package/dist/error-messages.d.ts.map +1 -1
- package/dist/error-messages.js +18 -4
- package/dist/error-messages.js.map +1 -1
- package/dist/providers/discord/discord-provider.d.ts.map +1 -1
- package/dist/providers/discord/discord-provider.js +72 -4
- package/dist/providers/discord/discord-provider.js.map +1 -1
- package/dist/providers/telegram/telegram-provider.d.ts.map +1 -1
- package/dist/providers/telegram/telegram-provider.js +65 -3
- package/dist/providers/telegram/telegram-provider.js.map +1 -1
- package/package.json +1 -1
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
|
-
#
|
|
31
|
-
#
|
|
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/
|
|
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
|
-
-
|
|
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 (
|
|
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
|
|
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": "
|
|
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": "
|
|
9
|
+
"version": "2.3.1",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@openrouter/sdk": "^0.3.0",
|
|
12
12
|
"cron-parser": "^5.0.0",
|
|
@@ -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 =
|
|
1236
|
+
const contextInput = messagesToOpenRouterInput(contextMessages);
|
|
1291
1237
|
|
|
1292
|
-
// Inject vision content into the last user message if images are present
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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 =
|
|
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
|
-
|
|
1486
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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())
|
|
211
|
+
if (!line.trim()) {
|
|
212
|
+
needsRewrite = true;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
167
215
|
try {
|
|
168
|
-
const parsed = JSON.parse(line)
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
+
}
|