@dotsetlabs/dotclaw 2.6.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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "dotclaw-agent-runner",
3
- "version": "2.3.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": "2.3.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.3.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",
@@ -54,6 +54,10 @@ import {
54
54
  loadImageAttachmentsForInput,
55
55
  messagesToOpenRouterInput,
56
56
  } from './openrouter-input.js';
57
+ import {
58
+ extractFunctionCallsForReplay,
59
+ toReplayFunctionCallItems,
60
+ } from './openrouter-followup.js';
57
61
 
58
62
  type OpenRouterResult = ReturnType<OpenRouter['callModel']>;
59
63
 
@@ -208,21 +212,6 @@ function extractTextFromApiResponse(response: any): string {
208
212
  return '';
209
213
  }
210
214
 
211
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
212
- function extractFunctionCalls(response: any): Array<{ id: string; name: string; arguments: any }> {
213
- const calls: Array<{ id: string; name: string; arguments: unknown }> = [];
214
- for (const item of response?.output || []) {
215
- if (item?.type === 'function_call') {
216
- let args = item.arguments;
217
- if (typeof args === 'string') {
218
- try { args = JSON.parse(args); } catch { /* keep as string */ }
219
- }
220
- calls.push({ id: item.callId, name: item.name, arguments: args });
221
- }
222
- }
223
- return calls;
224
- }
225
-
226
215
  function writeOutput(output: ContainerOutput): void {
227
216
  console.log(OUTPUT_START_MARKER);
228
217
  console.log(JSON.stringify(output));
@@ -1311,7 +1300,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1311
1300
  }
1312
1301
 
1313
1302
  responseText = extractTextFromApiResponse(lastResponse);
1314
- let pendingCalls = extractFunctionCalls(lastResponse);
1303
+ let pendingCalls = extractFunctionCallsForReplay(lastResponse);
1315
1304
  const callSignatureCounts = new Map<string, number>();
1316
1305
  let previousRoundSignature = '';
1317
1306
  let repeatedRoundCount = 0;
@@ -1430,8 +1419,10 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1430
1419
  reason: nudgeReason,
1431
1420
  attempt: toolRequirementNudgeAttempt
1432
1421
  });
1433
- const responseItems = Array.isArray(lastResponse?.output) ? lastResponse.output : [];
1434
- 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 }];
1435
1426
  try {
1436
1427
  const nudgeResult = openrouter.callModel({
1437
1428
  model: currentModel,
@@ -1448,7 +1439,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1448
1439
  responseText = nudgeText;
1449
1440
  writeStreamChunk(nudgeText);
1450
1441
  }
1451
- pendingCalls = extractFunctionCalls(lastResponse);
1442
+ pendingCalls = extractFunctionCallsForReplay(lastResponse);
1452
1443
  } catch (nudgeErr) {
1453
1444
  log(`Tool requirement nudge failed: ${nudgeErr instanceof Error ? nudgeErr.message : String(nudgeErr)}`);
1454
1445
  break;
@@ -1588,7 +1579,8 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1588
1579
 
1589
1580
  // Build follow-up input with FULL conversation context:
1590
1581
  // original messages + model output + tool results (accumulated each round)
1591
- conversationInput = [...conversationInput, ...lastResponse.output, ...toolResults];
1582
+ const replayFunctionCalls = toReplayFunctionCallItems(pendingCalls);
1583
+ conversationInput = [...conversationInput, ...replayFunctionCalls, ...toolResults];
1592
1584
 
1593
1585
  // Compact oversized tool payloads before follow-up calls to reduce context bloat.
1594
1586
  const compactedConversation = compactToolConversationItems(conversationInput, {
@@ -1695,7 +1687,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1695
1687
  responseText = retryText;
1696
1688
  writeStreamChunk(retryText);
1697
1689
  }
1698
- pendingCalls = extractFunctionCalls(lastResponse);
1690
+ pendingCalls = extractFunctionCallsForReplay(lastResponse);
1699
1691
  continue;
1700
1692
  } catch (retryErr) {
1701
1693
  log(`Context overflow retry failed: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
@@ -1715,7 +1707,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1715
1707
  writeStreamChunk(followupText);
1716
1708
  }
1717
1709
 
1718
- pendingCalls = extractFunctionCalls(lastResponse);
1710
+ pendingCalls = extractFunctionCallsForReplay(lastResponse);
1719
1711
  }
1720
1712
 
1721
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotsetlabs/dotclaw",
3
- "version": "2.6.0",
3
+ "version": "2.6.1",
4
4
  "description": "Personal OpenRouter-based assistant. Lightweight, secure, customizable.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",