@dotsetlabs/dotclaw 1.5.2 → 1.7.0

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.
Files changed (111) hide show
  1. package/README.md +8 -3
  2. package/config-examples/runtime.json +10 -8
  3. package/config-examples/tool-policy.json +17 -0
  4. package/container/Dockerfile +4 -0
  5. package/container/agent-runner/package-lock.json +2 -2
  6. package/container/agent-runner/package.json +1 -1
  7. package/container/agent-runner/src/agent-config.ts +17 -15
  8. package/container/agent-runner/src/container-protocol.ts +10 -6
  9. package/container/agent-runner/src/daemon.ts +237 -32
  10. package/container/agent-runner/src/heartbeat-worker.ts +94 -0
  11. package/container/agent-runner/src/id.ts +4 -0
  12. package/container/agent-runner/src/index.ts +356 -188
  13. package/container/agent-runner/src/ipc.ts +161 -12
  14. package/container/agent-runner/src/memory.ts +8 -2
  15. package/container/agent-runner/src/prompt-packs.ts +5 -209
  16. package/container/agent-runner/src/request-worker.ts +25 -0
  17. package/container/agent-runner/src/tools.ts +423 -32
  18. package/dist/agent-context.d.ts +1 -0
  19. package/dist/agent-context.d.ts.map +1 -1
  20. package/dist/agent-context.js +15 -7
  21. package/dist/agent-context.js.map +1 -1
  22. package/dist/agent-execution.d.ts +11 -6
  23. package/dist/agent-execution.d.ts.map +1 -1
  24. package/dist/agent-execution.js +6 -4
  25. package/dist/agent-execution.js.map +1 -1
  26. package/dist/background-jobs.d.ts +9 -0
  27. package/dist/background-jobs.d.ts.map +1 -1
  28. package/dist/background-jobs.js +70 -15
  29. package/dist/background-jobs.js.map +1 -1
  30. package/dist/behavior-config.d.ts +0 -1
  31. package/dist/behavior-config.d.ts.map +1 -1
  32. package/dist/behavior-config.js +0 -3
  33. package/dist/behavior-config.js.map +1 -1
  34. package/dist/cli.js +313 -45
  35. package/dist/cli.js.map +1 -1
  36. package/dist/config.d.ts +6 -0
  37. package/dist/config.d.ts.map +1 -1
  38. package/dist/config.js +6 -0
  39. package/dist/config.js.map +1 -1
  40. package/dist/container-protocol.d.ts +10 -6
  41. package/dist/container-protocol.d.ts.map +1 -1
  42. package/dist/container-runner.d.ts +28 -8
  43. package/dist/container-runner.d.ts.map +1 -1
  44. package/dist/container-runner.js +254 -54
  45. package/dist/container-runner.js.map +1 -1
  46. package/dist/dashboard.js +1 -1
  47. package/dist/dashboard.js.map +1 -1
  48. package/dist/db.d.ts +31 -3
  49. package/dist/db.d.ts.map +1 -1
  50. package/dist/db.js +277 -39
  51. package/dist/db.js.map +1 -1
  52. package/dist/id.d.ts +2 -0
  53. package/dist/id.d.ts.map +1 -0
  54. package/dist/id.js +4 -0
  55. package/dist/id.js.map +1 -0
  56. package/dist/index.js +1194 -381
  57. package/dist/index.js.map +1 -1
  58. package/dist/json-helpers.d.ts +1 -0
  59. package/dist/json-helpers.d.ts.map +1 -1
  60. package/dist/json-helpers.js +33 -1
  61. package/dist/json-helpers.js.map +1 -1
  62. package/dist/maintenance.d.ts +2 -0
  63. package/dist/maintenance.d.ts.map +1 -1
  64. package/dist/maintenance.js +196 -17
  65. package/dist/maintenance.js.map +1 -1
  66. package/dist/memory-embeddings.d.ts +1 -0
  67. package/dist/memory-embeddings.d.ts.map +1 -1
  68. package/dist/memory-embeddings.js +38 -4
  69. package/dist/memory-embeddings.js.map +1 -1
  70. package/dist/memory-recall.d.ts.map +1 -1
  71. package/dist/memory-recall.js +6 -1
  72. package/dist/memory-recall.js.map +1 -1
  73. package/dist/memory-store.d.ts +1 -0
  74. package/dist/memory-store.d.ts.map +1 -1
  75. package/dist/memory-store.js +72 -10
  76. package/dist/memory-store.js.map +1 -1
  77. package/dist/metrics.d.ts +1 -0
  78. package/dist/metrics.d.ts.map +1 -1
  79. package/dist/metrics.js +16 -2
  80. package/dist/metrics.js.map +1 -1
  81. package/dist/path-mapping.d.ts +4 -0
  82. package/dist/path-mapping.d.ts.map +1 -0
  83. package/dist/path-mapping.js +50 -0
  84. package/dist/path-mapping.js.map +1 -0
  85. package/dist/paths.d.ts +4 -2
  86. package/dist/paths.d.ts.map +1 -1
  87. package/dist/paths.js +4 -2
  88. package/dist/paths.js.map +1 -1
  89. package/dist/personalization.d.ts +1 -0
  90. package/dist/personalization.d.ts.map +1 -1
  91. package/dist/personalization.js +14 -0
  92. package/dist/personalization.js.map +1 -1
  93. package/dist/progress.d.ts +2 -2
  94. package/dist/progress.d.ts.map +1 -1
  95. package/dist/progress.js +18 -14
  96. package/dist/progress.js.map +1 -1
  97. package/dist/runtime-config.d.ts +18 -7
  98. package/dist/runtime-config.d.ts.map +1 -1
  99. package/dist/runtime-config.js +29 -18
  100. package/dist/runtime-config.js.map +1 -1
  101. package/dist/task-scheduler.d.ts +7 -1
  102. package/dist/task-scheduler.d.ts.map +1 -1
  103. package/dist/task-scheduler.js +182 -48
  104. package/dist/task-scheduler.js.map +1 -1
  105. package/dist/timezone.d.ts +4 -0
  106. package/dist/timezone.d.ts.map +1 -0
  107. package/dist/timezone.js +111 -0
  108. package/dist/timezone.js.map +1 -0
  109. package/dist/types.d.ts +31 -0
  110. package/dist/types.d.ts.map +1 -1
  111. package/package.json +6 -1
@@ -26,7 +26,7 @@ import {
26
26
  MemoryConfig,
27
27
  Message
28
28
  } from './memory.js';
29
- import { loadPromptPackWithCanary, formatTaskExtractionPack, formatResponseQualityPack, formatToolCallingPack, formatToolOutcomePack, formatMemoryPolicyPack, formatMemoryRecallPack, PromptPack } from './prompt-packs.js';
29
+ import { loadPromptPackWithCanary, formatPromptPack, PromptPack } from './prompt-packs.js';
30
30
 
31
31
  type OpenRouterResult = ReturnType<OpenRouter['callModel']>;
32
32
 
@@ -40,6 +40,9 @@ const AVAILABLE_GROUPS_PATH = '/workspace/ipc/available_groups.json';
40
40
  const GROUP_CLAUDE_PATH = path.join(GROUP_DIR, 'CLAUDE.md');
41
41
  const GLOBAL_CLAUDE_PATH = path.join(GLOBAL_DIR, 'CLAUDE.md');
42
42
  const CLAUDE_NOTES_MAX_CHARS = 4000;
43
+ const SKILL_NOTES_MAX_FILES = 16;
44
+ const SKILL_NOTES_MAX_CHARS = 3000;
45
+ const SKILL_NOTES_TOTAL_MAX_CHARS = 18_000;
43
46
 
44
47
  const agentConfig = loadAgentConfig();
45
48
  const agent = agentConfig.agent;
@@ -71,72 +74,64 @@ function log(message: string): void {
71
74
  console.error(`[agent-runner] ${message}`);
72
75
  }
73
76
 
74
- function coerceTextFromContent(content: unknown): string {
75
- if (!content) return '';
76
- if (typeof content === 'string') return content;
77
- if (Array.isArray(content)) {
78
- return content.map(part => {
79
- if (!part) return '';
80
- if (typeof part === 'string') return part;
81
- if (typeof part === 'object') {
82
- const record = part as { text?: unknown; content?: unknown; value?: unknown };
83
- if (typeof record.text === 'string') return record.text;
84
- if (typeof record.content === 'string') return record.content;
85
- if (typeof record.value === 'string') return record.value;
86
- }
87
- return '';
88
- }).join('');
89
- }
90
- if (typeof content === 'object') {
91
- const record = content as { text?: unknown; content?: unknown; value?: unknown };
92
- if (typeof record.text === 'string') return record.text;
93
- if (typeof record.content === 'string') return record.content;
94
- if (typeof record.value === 'string') return record.value;
95
- }
96
- return '';
77
+ // ── Response extraction pipeline ─────────────────────────────────────
78
+ // OpenRouter SDK v0.3.x returns raw response IDs (gen-*, resp-*, etc.) instead
79
+ // of text for fast reasoning models (GPT-5-mini/nano). Reasoning tokens consume
80
+ // the output budget, leaving nothing for actual text. This multi-layer pipeline
81
+ // works around that:
82
+ // 1. isLikelyResponseId detect leaked IDs so we never surface them
83
+ // 2. extractTextFromRawResponse walk raw response fields ourselves
84
+ // 3. getTextWithFallback try SDK getText(), fall back to raw extraction
85
+ // 4. chatCompletionsFallback retry via /chat/completions when all else fails
86
+ // Remove this pipeline once the SDK reliably returns text for reasoning models.
87
+
88
+ const RESPONSE_ID_PREFIXES = ['gen-', 'resp-', 'resp_', 'chatcmpl-', 'msg_'];
89
+
90
+ function isLikelyResponseId(value: string): boolean {
91
+ const trimmed = value.trim();
92
+ if (!trimmed || trimmed.includes(' ') || trimmed.includes('\n')) return false;
93
+ return RESPONSE_ID_PREFIXES.some(prefix => trimmed.startsWith(prefix));
94
+ }
95
+
96
+ function isValidText(value: unknown): value is string {
97
+ return typeof value === 'string' && value.trim().length > 0 && !isLikelyResponseId(value);
97
98
  }
98
99
 
99
- function extractTextFallbackFromResponse(response: unknown): string {
100
+ function extractTextFromRawResponse(response: unknown): string {
100
101
  if (!response || typeof response !== 'object') return '';
101
- const record = response as {
102
- outputText?: unknown;
103
- output_text?: unknown;
104
- output?: unknown;
105
- choices?: unknown;
106
- };
102
+ const record = response as Record<string, unknown>;
107
103
 
108
- if (typeof record.outputText === 'string' && record.outputText.trim()) {
109
- return record.outputText;
110
- }
111
- if (typeof record.output_text === 'string' && record.output_text.trim()) {
112
- return record.output_text;
113
- }
104
+ // 1. SDK-parsed camelCase field
105
+ if (isValidText(record.outputText)) return record.outputText;
106
+
107
+ // 2. Raw API snake_case field
108
+ if (isValidText(record.output_text)) return record.output_text;
114
109
 
110
+ // 3. Walk response.output[] for message/output_text items
115
111
  if (Array.isArray(record.output)) {
116
- const outputTexts: string[] = [];
112
+ const parts: string[] = [];
117
113
  for (const item of record.output) {
118
114
  if (!item || typeof item !== 'object') continue;
119
- const typed = item as { type?: unknown; content?: unknown; text?: unknown };
120
- const type = typeof typed.type === 'string' ? typed.type : '';
121
- if (type === 'message') {
122
- const text = coerceTextFromContent(typed.content);
123
- if (text.trim()) outputTexts.push(text);
124
- } else if (type === 'output_text' && typeof typed.text === 'string' && typed.text.trim()) {
125
- outputTexts.push(typed.text);
115
+ const typed = item as { type?: string; content?: unknown; text?: string };
116
+ if (typed.type === 'message' && Array.isArray(typed.content)) {
117
+ for (const part of typed.content as Array<{ type?: string; text?: string }>) {
118
+ if (part?.type === 'output_text' && isValidText(part.text)) {
119
+ parts.push(part.text);
120
+ }
121
+ }
122
+ } else if (typed.type === 'output_text' && isValidText(typed.text)) {
123
+ parts.push(typed.text);
126
124
  }
127
125
  }
128
- const joined = outputTexts.join('');
126
+ const joined = parts.join('');
129
127
  if (joined.trim()) return joined;
130
128
  }
131
129
 
130
+ // 4. OpenAI chat completions compat
132
131
  if (Array.isArray(record.choices) && record.choices.length > 0) {
133
- const choice = record.choices[0] as { message?: { content?: unknown }; text?: unknown } | null | undefined;
134
- if (choice?.message) {
135
- const text = coerceTextFromContent(choice.message.content);
136
- if (text.trim()) return text;
137
- }
138
- if (typeof choice?.text === 'string' && choice.text.trim()) {
139
- return choice.text;
132
+ const choice = record.choices[0] as { message?: { content?: unknown } } | null | undefined;
133
+ if (choice?.message && isValidText(choice.message.content)) {
134
+ return choice.message.content;
140
135
  }
141
136
  }
142
137
 
@@ -144,22 +139,103 @@ function extractTextFallbackFromResponse(response: unknown): string {
144
139
  }
145
140
 
146
141
  async function getTextWithFallback(result: OpenRouterResult, context: string): Promise<string> {
147
- const text = await result.getText();
148
- if (text && text.trim()) {
149
- return text;
142
+ // 1. Try the SDK's proper getText() first — this handles tool execution and
143
+ // extracts text from the final response via the SDK's own logic.
144
+ try {
145
+ const text = await result.getText();
146
+ if (isValidText(text)) {
147
+ return text;
148
+ }
149
+ if (text && isLikelyResponseId(text)) {
150
+ log(`Ignored response id from getText (${context}): ${String(text).slice(0, 60)}`);
151
+ }
152
+ } catch (err) {
153
+ log(`getText failed (${context}): ${err instanceof Error ? err.message : String(err)}`);
150
154
  }
155
+
156
+ // 2. Fall back to raw response extraction — walk known fields ourselves
151
157
  try {
152
158
  const response = await result.getResponse();
153
- const fallbackText = extractTextFallbackFromResponse(response);
154
- if (fallbackText && fallbackText.trim()) {
155
- log(`Recovered empty response text from payload (${context})`);
159
+ const fallbackText = extractTextFromRawResponse(response);
160
+ if (fallbackText) {
161
+ log(`Recovered text from raw response (${context})`);
156
162
  return fallbackText;
157
163
  }
158
- log(`Model returned empty response and fallback extraction failed (${context})`);
164
+ const r = response as Record<string, unknown>;
165
+ const outputLen = Array.isArray(r.output) ? (r.output as unknown[]).length : 0;
166
+ log(`No text in raw response (${context}): id=${String(r.id ?? 'none').slice(0, 40)} status=${String(r.status ?? '?')} outputs=${outputLen}`);
159
167
  } catch (err) {
160
- log(`Failed to recover empty response text (${context}): ${err instanceof Error ? err.message : String(err)}`);
168
+ log(`Raw response extraction failed (${context}): ${err instanceof Error ? err.message : String(err)}`);
161
169
  }
162
- return text;
170
+
171
+ // 3. Never return a response ID
172
+ return '';
173
+ }
174
+
175
+ /**
176
+ * Direct Chat Completions API fallback.
177
+ * When the Responses API returns a gen-ID instead of text (common with fast
178
+ * models like gpt-5-nano/mini via OpenRouter), retry using the standard
179
+ * /chat/completions endpoint which reliably returns text content.
180
+ */
181
+ async function chatCompletionsFallback(params: {
182
+ model: string;
183
+ instructions: string;
184
+ messages: Array<{ role: string; content: string }>;
185
+ maxOutputTokens: number;
186
+ temperature: number;
187
+ }): Promise<string> {
188
+ const apiKey = process.env.OPENROUTER_API_KEY;
189
+ if (!apiKey) return '';
190
+
191
+ const headers: Record<string, string> = {
192
+ 'Authorization': `Bearer ${apiKey}`,
193
+ 'Content-Type': 'application/json'
194
+ };
195
+ if (agent.openrouter.siteUrl) {
196
+ headers['HTTP-Referer'] = agent.openrouter.siteUrl;
197
+ }
198
+ if (agent.openrouter.siteName) {
199
+ headers['X-Title'] = agent.openrouter.siteName;
200
+ }
201
+
202
+ const chatMessages = [
203
+ { role: 'system', content: params.instructions },
204
+ ...params.messages
205
+ ];
206
+
207
+ log(`Chat Completions fallback: model=${params.model}, messages=${chatMessages.length}`);
208
+ const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
209
+ method: 'POST',
210
+ headers,
211
+ body: JSON.stringify({
212
+ model: params.model,
213
+ messages: chatMessages,
214
+ max_completion_tokens: params.maxOutputTokens,
215
+ temperature: params.temperature,
216
+ reasoning_effort: 'low'
217
+ }),
218
+ signal: AbortSignal.timeout(agent.openrouter.timeoutMs)
219
+ });
220
+
221
+ const bodyText = await response.text();
222
+ if (!response.ok) {
223
+ log(`Chat Completions fallback HTTP ${response.status}: ${bodyText.slice(0, 300)}`);
224
+ return '';
225
+ }
226
+
227
+ try {
228
+ const data = JSON.parse(bodyText);
229
+ const content = data?.choices?.[0]?.message?.content;
230
+ if (isValidText(content)) {
231
+ log(`Chat Completions fallback recovered text (${String(content).length} chars)`);
232
+ return content;
233
+ }
234
+ log(`Chat Completions fallback returned no text: ${JSON.stringify(data).slice(0, 300)}`);
235
+ } catch (err) {
236
+ log(`Chat Completions fallback parse error: ${err instanceof Error ? err.message : String(err)}`);
237
+ }
238
+ return '';
163
239
  }
164
240
 
165
241
  function writeOutput(output: ContainerOutput): void {
@@ -397,6 +473,7 @@ function buildSystemInstructions(params: {
397
473
  assistantName: string;
398
474
  groupNotes?: string | null;
399
475
  globalNotes?: string | null;
476
+ skillNotes?: SkillNote[];
400
477
  memorySummary: string;
401
478
  memoryFacts: string[];
402
479
  sessionRecall: string[];
@@ -429,7 +506,19 @@ function buildSystemInstructions(params: {
429
506
  '- `GitClone`: clone git repositories into the workspace.',
430
507
  '- `NpmInstall`: install npm dependencies in the workspace.',
431
508
  '- `mcp__dotclaw__send_message`: send Telegram messages.',
432
- '- `mcp__dotclaw__schedule_task`: schedule tasks.',
509
+ '- `mcp__dotclaw__send_file`: send a file/document.',
510
+ '- `mcp__dotclaw__send_photo`: send a photo with compression.',
511
+ '- `mcp__dotclaw__send_voice`: send a voice message (.ogg format).',
512
+ '- `mcp__dotclaw__send_audio`: send an audio file (mp3, m4a, etc.).',
513
+ '- `mcp__dotclaw__send_location`: send a map pin (latitude/longitude).',
514
+ '- `mcp__dotclaw__send_contact`: send a contact card (phone + name).',
515
+ '- `mcp__dotclaw__send_poll`: create a Telegram poll.',
516
+ '- `mcp__dotclaw__send_buttons`: send a message with inline keyboard buttons.',
517
+ '- `mcp__dotclaw__edit_message`: edit a previously sent message.',
518
+ '- `mcp__dotclaw__delete_message`: delete a message.',
519
+ '- `mcp__dotclaw__download_url`: download a URL to the workspace as a file.',
520
+ '- Users may send photos, documents, voice messages, and videos. These are downloaded to `/workspace/group/inbox/` and referenced as `<attachment>` tags in messages. Process them with Read/Bash/Python tools.',
521
+ '- `mcp__dotclaw__schedule_task`: schedule tasks (set `timezone` for locale-specific schedules).',
433
522
  '- `mcp__dotclaw__run_task`: run a scheduled task immediately.',
434
523
  '- `mcp__dotclaw__list_tasks`, `mcp__dotclaw__pause_task`, `mcp__dotclaw__resume_task`, `mcp__dotclaw__cancel_task`.',
435
524
  '- `mcp__dotclaw__update_task`: update a task (state, prompt, schedule, status).',
@@ -479,6 +568,7 @@ function buildSystemInstructions(params: {
479
568
 
480
569
  const groupNotes = params.groupNotes ? `Group notes:\n${params.groupNotes}` : '';
481
570
  const globalNotes = params.globalNotes ? `Global notes:\n${params.globalNotes}` : '';
571
+ const skillNotes = formatSkillNotes(params.skillNotes || []);
482
572
 
483
573
  const toolReliability = params.toolReliability && params.toolReliability.length > 0
484
574
  ? params.toolReliability
@@ -536,53 +626,15 @@ function buildSystemInstructions(params: {
536
626
  ? `Job artifacts directory: /workspace/group/jobs/${params.jobId}`
537
627
  : '';
538
628
 
539
- const taskExtractionBlock = params.taskExtractionPack
540
- ? formatTaskExtractionPack({
541
- pack: params.taskExtractionPack,
542
- maxDemos: PROMPT_PACKS_MAX_DEMOS,
543
- maxChars: PROMPT_PACKS_MAX_CHARS
544
- })
545
- : '';
546
-
547
- const responseQualityBlock = params.responseQualityPack
548
- ? formatResponseQualityPack({
549
- pack: params.responseQualityPack,
550
- maxDemos: PROMPT_PACKS_MAX_DEMOS,
551
- maxChars: PROMPT_PACKS_MAX_CHARS
552
- })
553
- : '';
629
+ const fmtPack = (label: string, pack: PromptPack | null | undefined) =>
630
+ pack ? formatPromptPack({ label, pack, maxDemos: PROMPT_PACKS_MAX_DEMOS, maxChars: PROMPT_PACKS_MAX_CHARS }) : '';
554
631
 
555
- const toolCallingBlock = params.toolCallingPack
556
- ? formatToolCallingPack({
557
- pack: params.toolCallingPack,
558
- maxDemos: PROMPT_PACKS_MAX_DEMOS,
559
- maxChars: PROMPT_PACKS_MAX_CHARS
560
- })
561
- : '';
562
-
563
- const toolOutcomeBlock = params.toolOutcomePack
564
- ? formatToolOutcomePack({
565
- pack: params.toolOutcomePack,
566
- maxDemos: PROMPT_PACKS_MAX_DEMOS,
567
- maxChars: PROMPT_PACKS_MAX_CHARS
568
- })
569
- : '';
570
-
571
- const memoryPolicyBlock = params.memoryPolicyPack
572
- ? formatMemoryPolicyPack({
573
- pack: params.memoryPolicyPack,
574
- maxDemos: PROMPT_PACKS_MAX_DEMOS,
575
- maxChars: PROMPT_PACKS_MAX_CHARS
576
- })
577
- : '';
578
-
579
- const memoryRecallBlock = params.memoryRecallPack
580
- ? formatMemoryRecallPack({
581
- pack: params.memoryRecallPack,
582
- maxDemos: PROMPT_PACKS_MAX_DEMOS,
583
- maxChars: PROMPT_PACKS_MAX_CHARS
584
- })
585
- : '';
632
+ const taskExtractionBlock = fmtPack('Task Extraction Guidelines', params.taskExtractionPack);
633
+ const responseQualityBlock = fmtPack('Response Quality Guidelines', params.responseQualityPack);
634
+ const toolCallingBlock = fmtPack('Tool Calling Guidelines', params.toolCallingPack);
635
+ const toolOutcomeBlock = fmtPack('Tool Outcome Guidelines', params.toolOutcomePack);
636
+ const memoryPolicyBlock = fmtPack('Memory Policy Guidelines', params.memoryPolicyPack);
637
+ const memoryRecallBlock = fmtPack('Memory Recall Guidelines', params.memoryRecallPack);
586
638
 
587
639
  return [
588
640
  `You are ${params.assistantName}, a personal assistant running inside DotClaw.`,
@@ -594,6 +646,7 @@ function buildSystemInstructions(params: {
594
646
  browserAutomation,
595
647
  groupNotes,
596
648
  globalNotes,
649
+ skillNotes,
597
650
  timezoneNote,
598
651
  params.planBlock || '',
599
652
  toolCallingBlock,
@@ -655,6 +708,123 @@ function loadClaudeNotes(): { group: string | null; global: string | null } {
655
708
  };
656
709
  }
657
710
 
711
+ export type SkillNote = {
712
+ scope: 'group' | 'global';
713
+ path: string;
714
+ content: string;
715
+ };
716
+
717
+ function collectSkillFiles(rootDir: string, maxFiles: number): string[] {
718
+ const files: string[] = [];
719
+ const seen = new Set<string>();
720
+ const addFile = (filePath: string) => {
721
+ const normalized = path.resolve(filePath);
722
+ if (seen.has(normalized)) return;
723
+ if (!fs.existsSync(normalized)) return;
724
+ let stat: fs.Stats;
725
+ try {
726
+ stat = fs.statSync(normalized);
727
+ } catch {
728
+ return;
729
+ }
730
+ if (!stat.isFile()) return;
731
+ if (!normalized.toLowerCase().endsWith('.md')) return;
732
+ seen.add(normalized);
733
+ files.push(normalized);
734
+ };
735
+
736
+ addFile(path.join(rootDir, 'SKILL.md'));
737
+
738
+ const skillsDir = path.join(rootDir, 'skills');
739
+ if (fs.existsSync(skillsDir)) {
740
+ const stack = [skillsDir];
741
+ while (stack.length > 0 && files.length < maxFiles) {
742
+ const current = stack.pop();
743
+ if (!current) continue;
744
+ let entries: fs.Dirent[];
745
+ try {
746
+ entries = fs.readdirSync(current, { withFileTypes: true });
747
+ } catch {
748
+ continue;
749
+ }
750
+ entries.sort((a, b) => a.name.localeCompare(b.name));
751
+ for (const entry of entries) {
752
+ const nextPath = path.join(current, entry.name);
753
+ if (entry.isSymbolicLink()) continue;
754
+ if (entry.isDirectory()) {
755
+ stack.push(nextPath);
756
+ continue;
757
+ }
758
+ if (entry.isFile()) {
759
+ addFile(nextPath);
760
+ }
761
+ if (files.length >= maxFiles) break;
762
+ }
763
+ }
764
+ }
765
+
766
+ files.sort((a, b) => a.localeCompare(b));
767
+ return files.slice(0, maxFiles);
768
+ }
769
+
770
+ export function loadSkillNotesFromRoots(params: {
771
+ groupDir: string;
772
+ globalDir: string;
773
+ maxFiles?: number;
774
+ maxCharsPerFile?: number;
775
+ maxTotalChars?: number;
776
+ }): SkillNote[] {
777
+ const maxFiles = Number.isFinite(params.maxFiles) ? Math.max(1, Math.floor(params.maxFiles!)) : SKILL_NOTES_MAX_FILES;
778
+ const maxCharsPerFile = Number.isFinite(params.maxCharsPerFile)
779
+ ? Math.max(200, Math.floor(params.maxCharsPerFile!))
780
+ : SKILL_NOTES_MAX_CHARS;
781
+ const maxTotalChars = Number.isFinite(params.maxTotalChars)
782
+ ? Math.max(maxCharsPerFile, Math.floor(params.maxTotalChars!))
783
+ : SKILL_NOTES_TOTAL_MAX_CHARS;
784
+
785
+ const notes: SkillNote[] = [];
786
+ let consumedChars = 0;
787
+
788
+ const appendScopeNotes = (scope: 'group' | 'global', rootDir: string) => {
789
+ const skillFiles = collectSkillFiles(rootDir, maxFiles);
790
+ for (const filePath of skillFiles) {
791
+ if (notes.length >= maxFiles) break;
792
+ if (consumedChars >= maxTotalChars) break;
793
+ const content = readTextFileLimited(filePath, maxCharsPerFile);
794
+ if (!content) continue;
795
+ const remaining = maxTotalChars - consumedChars;
796
+ const truncated = content.length > remaining
797
+ ? `${content.slice(0, remaining)}\n\n[Truncated for total skill budget]`
798
+ : content;
799
+ const relativePath = path.relative(rootDir, filePath).split(path.sep).join('/');
800
+ notes.push({
801
+ scope,
802
+ path: relativePath || path.basename(filePath),
803
+ content: truncated
804
+ });
805
+ consumedChars += truncated.length;
806
+ if (consumedChars >= maxTotalChars) break;
807
+ }
808
+ };
809
+
810
+ appendScopeNotes('group', params.groupDir);
811
+ appendScopeNotes('global', params.globalDir);
812
+ return notes;
813
+ }
814
+
815
+ function formatSkillNotes(notes: SkillNote[]): string {
816
+ if (!notes || notes.length === 0) return '';
817
+ const lines: string[] = [
818
+ 'Skill instructions (loaded from SKILL.md / skills/*.md):',
819
+ 'When a task matches a skill, follow that skill workflow first and keep output concise.'
820
+ ];
821
+ for (const note of notes) {
822
+ lines.push(`[${note.scope}] ${note.path}`);
823
+ lines.push(note.content);
824
+ }
825
+ return lines.join('\n\n');
826
+ }
827
+
658
828
  function extractQueryFromPrompt(prompt: string): string {
659
829
  if (!prompt) return '';
660
830
  const messageMatches = [...prompt.matchAll(/<message[^>]*>([\s\S]*?)<\/message>/g)];
@@ -670,6 +840,7 @@ function decodeXml(value: string): string {
670
840
  .replace(/&lt;/g, '<')
671
841
  .replace(/&gt;/g, '>')
672
842
  .replace(/&quot;/g, '"')
843
+ .replace(/&apos;/g, "'")
673
844
  .replace(/&amp;/g, '&');
674
845
  }
675
846
 
@@ -712,7 +883,8 @@ async function updateMemorySummary(params: {
712
883
  instructions: prompt.instructions,
713
884
  input: prompt.input,
714
885
  maxOutputTokens: params.maxOutputTokens,
715
- temperature: 0.1
886
+ temperature: 0.1,
887
+ reasoning: { effort: 'low' as const }
716
888
  });
717
889
  const text = await getTextWithFallback(result, 'summary');
718
890
  return parseSummaryResponse(text);
@@ -726,7 +898,8 @@ function buildMemoryExtractionPrompt(params: {
726
898
  memoryPolicyPack?: PromptPack | null;
727
899
  }): { instructions: string; input: string } {
728
900
  const policyBlock = params.memoryPolicyPack
729
- ? formatMemoryPolicyPack({
901
+ ? formatPromptPack({
902
+ label: 'Memory Policy Guidelines',
730
903
  pack: params.memoryPolicyPack,
731
904
  maxDemos: PROMPT_PACKS_MAX_DEMOS,
732
905
  maxChars: PROMPT_PACKS_MAX_CHARS
@@ -853,7 +1026,8 @@ async function validateResponseQuality(params: {
853
1026
  instructions: prompt.instructions,
854
1027
  input: prompt.input,
855
1028
  maxOutputTokens: params.maxOutputTokens,
856
- temperature: params.temperature
1029
+ temperature: params.temperature,
1030
+ reasoning: { effort: 'low' as const }
857
1031
  });
858
1032
  const text = await getTextWithFallback(result, 'response_validation');
859
1033
  return parseResponseValidation(text);
@@ -959,29 +1133,15 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
959
1133
  const responseValidateMinPromptTokens = agent.responseValidation.minPromptTokens || 0;
960
1134
  const responseValidateMinResponseTokens = agent.responseValidation.minResponseTokens || 0;
961
1135
  const maxContextMessageTokens = agent.context.maxContextMessageTokens;
962
- const streamingEnabled = Boolean(input.streaming?.enabled && typeof input.streaming?.draftId === 'number');
963
- const streamingDraftId = streamingEnabled ? input.streaming?.draftId : undefined;
964
- const streamingMinIntervalMs = Math.max(
965
- 0,
966
- Math.floor(
967
- typeof input.streaming?.minIntervalMs === 'number'
968
- ? input.streaming.minIntervalMs
969
- : agent.streaming.minIntervalMs
970
- )
971
- );
972
- const streamingMinChars = Math.max(
973
- 1,
974
- Math.floor(
975
- typeof input.streaming?.minChars === 'number'
976
- ? input.streaming.minChars
977
- : agent.streaming.minChars
978
- )
979
- );
980
1136
 
981
1137
  const openrouter = getCachedOpenRouter(apiKey, openrouterOptions);
982
1138
  const tokenEstimate = resolveTokenEstimate(input, agentConfig);
983
1139
  const availableGroups = loadAvailableGroups();
984
1140
  const claudeNotes = loadClaudeNotes();
1141
+ const skillNotes = loadSkillNotesFromRoots({
1142
+ groupDir: GROUP_DIR,
1143
+ globalDir: GLOBAL_DIR
1144
+ });
985
1145
 
986
1146
  const { ctx: sessionCtx, isNew } = createSessionContext(SESSION_ROOT, input.sessionId);
987
1147
  const toolCalls: ToolCallRecord[] = [];
@@ -1008,21 +1168,6 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1008
1168
  }
1009
1169
  });
1010
1170
 
1011
- let streamLastSentAt = 0;
1012
- let streamLastSentLength = 0;
1013
- const sendStreamUpdate = (text: string, force = false) => {
1014
- if (!streamingEnabled || !streamingDraftId) return;
1015
- if (!text || !text.trim()) return;
1016
- const now = Date.now();
1017
- if (!force) {
1018
- if (now - streamLastSentAt < streamingMinIntervalMs) return;
1019
- if (text.length - streamLastSentLength < streamingMinChars) return;
1020
- }
1021
- streamLastSentAt = now;
1022
- streamLastSentLength = text.length;
1023
- void ipc.sendDraft(text, streamingDraftId).catch(() => undefined);
1024
- };
1025
-
1026
1171
  if (process.env.DOTCLAW_SELF_CHECK === '1') {
1027
1172
  try {
1028
1173
  const details = await runSelfCheck({ model });
@@ -1047,6 +1192,16 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1047
1192
  if (input.isScheduledTask) {
1048
1193
  prompt = `[SCHEDULED TASK - You are running automatically, not in response to a user message. Use mcp__dotclaw__send_message if needed to communicate with the user.]\n\n${input.prompt}`;
1049
1194
  }
1195
+ if (Array.isArray(input.attachments) && input.attachments.length > 0) {
1196
+ const attachmentSummary = input.attachments.map(attachment => {
1197
+ const parts = [`type=${attachment.type}`, `path=${attachment.path}`];
1198
+ if (attachment.file_name) parts.push(`filename=${attachment.file_name}`);
1199
+ if (attachment.mime_type) parts.push(`mime=${attachment.mime_type}`);
1200
+ if (Number.isFinite(attachment.file_size)) parts.push(`size=${attachment.file_size}`);
1201
+ return `- ${parts.join(' ')}`;
1202
+ }).join('\n');
1203
+ prompt = `${prompt}\n\n<latest_attachments>\n${attachmentSummary}\n</latest_attachments>`;
1204
+ }
1050
1205
 
1051
1206
  appendHistory(sessionCtx, 'user', prompt);
1052
1207
  let history = loadHistory(sessionCtx);
@@ -1177,6 +1332,7 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1177
1332
  assistantName,
1178
1333
  groupNotes: claudeNotes.group,
1179
1334
  globalNotes: claudeNotes.global,
1335
+ skillNotes,
1180
1336
  memorySummary: sessionCtx.state.summary,
1181
1337
  memoryFacts: sessionCtx.state.facts,
1182
1338
  sessionRecall,
@@ -1225,7 +1381,8 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1225
1381
  instructions: plannerPrompt.instructions,
1226
1382
  input: plannerPrompt.input,
1227
1383
  maxOutputTokens: plannerMaxOutputTokens,
1228
- temperature: plannerTemperature
1384
+ temperature: plannerTemperature,
1385
+ reasoning: { effort: 'low' as const }
1229
1386
  });
1230
1387
  const plannerText = await getTextWithFallback(plannerResult, 'planner');
1231
1388
  const plan = parsePlannerResponse(plannerText);
@@ -1282,6 +1439,16 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1282
1439
  + estimateMessagesTokens(contextMessages, tokenEstimate.tokensPerChar, tokenEstimate.tokensPerMessage)
1283
1440
  + tokenEstimate.tokensPerRequest;
1284
1441
 
1442
+ const safeLimit = Math.floor(config.maxContextTokens * 0.9);
1443
+ if (resolvedPromptTokens > safeLimit && contextMessages.length > 2) {
1444
+ log(`Estimated ${resolvedPromptTokens} tokens exceeds safe limit ${safeLimit}, truncating`);
1445
+ while (contextMessages.length > 2) {
1446
+ const currentTokens = resolvedInstructionTokens + estimateMessagesTokens(contextMessages, tokenEstimate.tokensPerChar, tokenEstimate.tokensPerMessage) + tokenEstimate.tokensPerRequest;
1447
+ if (currentTokens <= safeLimit) break;
1448
+ contextMessages.splice(0, 1);
1449
+ }
1450
+ }
1451
+
1285
1452
  log('Starting OpenRouter call...');
1286
1453
  const startedAt = Date.now();
1287
1454
  const callParams = {
@@ -1292,47 +1459,39 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1292
1459
  stopWhen: stepCountIs(maxToolSteps),
1293
1460
  maxOutputTokens: config.maxOutputTokens,
1294
1461
  temperature: config.temperature,
1295
- stream: streamingEnabled
1462
+ reasoning: { effort: 'low' as const }
1296
1463
  };
1297
- const result = await openrouter.callModel(callParams as Parameters<typeof openrouter.callModel>[0]);
1464
+ const result = await openrouter.callModel(callParams);
1298
1465
  const localLatencyMs = Date.now() - startedAt;
1466
+
1467
+ // Get the complete response text via the SDK's proper getText() path
1468
+ let localResponseText = await getTextWithFallback(result, 'completion');
1469
+
1299
1470
  const toolCallsFromModel = await result.getToolCalls();
1300
1471
  if (toolCallsFromModel.length > 0) {
1301
1472
  log(`Model made ${toolCallsFromModel.length} tool call(s): ${toolCallsFromModel.map(t => t.name).join(', ')}`);
1302
1473
  }
1303
1474
 
1304
- let localResponseText = '';
1305
- let streamed = false;
1306
- if (streamingEnabled && typeof (result as { getTextStream?: () => AsyncIterable<unknown> }).getTextStream === 'function') {
1307
- try {
1308
- const stream = (result as { getTextStream: () => AsyncIterable<unknown> }).getTextStream();
1309
- for await (const chunk of stream) {
1310
- const delta = typeof chunk === 'string'
1311
- ? chunk
1312
- : (typeof (chunk as { text?: unknown })?.text === 'string' ? (chunk as { text?: string }).text || '' : '');
1313
- if (!delta) continue;
1314
- localResponseText += delta;
1315
- sendStreamUpdate(localResponseText);
1316
- }
1317
- if (localResponseText) {
1318
- sendStreamUpdate(localResponseText, true);
1319
- }
1320
- streamed = true;
1321
- } catch (err) {
1322
- log(`Streaming failed, falling back to full response: ${err instanceof Error ? err.message : String(err)}`);
1323
- }
1324
- }
1325
- if (!streamed || !localResponseText || !localResponseText.trim()) {
1326
- localResponseText = await getTextWithFallback(result, 'completion');
1327
- if (localResponseText && localResponseText.trim()) {
1328
- sendStreamUpdate(localResponseText, true);
1329
- }
1330
- }
1331
1475
  if (!localResponseText || !localResponseText.trim()) {
1332
1476
  if (toolCallsFromModel.length > 0) {
1333
1477
  localResponseText = 'I started running tool calls but did not get a final response. If you want me to continue, please ask a narrower subtask or say "continue".';
1478
+ } else {
1479
+ // Responses API likely returned a gen-ID; retry with Chat Completions API
1480
+ try {
1481
+ localResponseText = await chatCompletionsFallback({
1482
+ model,
1483
+ instructions: resolvedInstructions,
1484
+ messages: messagesToOpenRouter(contextMessages),
1485
+ maxOutputTokens: config.maxOutputTokens,
1486
+ temperature: config.temperature
1487
+ });
1488
+ } catch (err) {
1489
+ log(`Chat Completions fallback error: ${err instanceof Error ? err.message : String(err)}`);
1490
+ }
1491
+ }
1492
+ if (!localResponseText || !localResponseText.trim()) {
1493
+ log(`Warning: Model returned empty/whitespace response after all fallbacks. tool calls: ${toolCallsFromModel.length}`);
1334
1494
  }
1335
- log(`Warning: Model returned empty/whitespace response. Raw length: ${localResponseText?.length ?? 0}, tool calls: ${toolCallsFromModel.length}`);
1336
1495
  } else {
1337
1496
  log(`Model returned text response (${localResponseText.length} chars)`);
1338
1497
  }
@@ -1360,8 +1519,9 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1360
1519
  && completionTokens >= responseValidateMinResponseTokens
1361
1520
  && (responseValidateAllowToolCalls || modelToolCalls.length === 0);
1362
1521
  if (shouldValidate) {
1522
+ const MAX_VALIDATION_ITERATIONS = 5;
1363
1523
  let retriesLeft = responseValidateMaxRetries;
1364
- while (true) {
1524
+ for (let _validationIter = 0; _validationIter < MAX_VALIDATION_ITERATIONS; _validationIter++) {
1365
1525
  if (!responseValidateAllowToolCalls && modelToolCalls.length > 0) {
1366
1526
  break;
1367
1527
  }
@@ -1392,8 +1552,6 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1392
1552
  }
1393
1553
  retriesLeft -= 1;
1394
1554
  log(`Response validation failed; retrying (${retriesLeft} retries left)`);
1395
- streamLastSentAt = 0;
1396
- streamLastSentLength = 0;
1397
1555
  const retryGuidance = buildRetryGuidance(validationResult);
1398
1556
  const retryAttempt = await runCompletion(retryGuidance);
1399
1557
  responseText = retryAttempt.responseText;
@@ -1464,7 +1622,8 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1464
1622
  instructions: extractionPrompt.instructions,
1465
1623
  input: extractionPrompt.input,
1466
1624
  maxOutputTokens: memoryExtractionMaxOutputTokens,
1467
- temperature: 0.1
1625
+ temperature: 0.1,
1626
+ reasoning: { effort: 'low' as const }
1468
1627
  });
1469
1628
  const extractionText = await getTextWithFallback(extractionResult, 'memory_extraction');
1470
1629
  const extractedItems = parseMemoryExtraction(extractionText);
@@ -1501,16 +1660,25 @@ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutp
1501
1660
  };
1502
1661
 
1503
1662
  if (memoryExtractionEnabled && (!input.isScheduledTask || memoryExtractScheduled)) {
1663
+ const runMemoryExtractionWithRetry = async (maxRetries = 2): Promise<void> => {
1664
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1665
+ try {
1666
+ await runMemoryExtraction();
1667
+ return;
1668
+ } catch (err) {
1669
+ log(`Memory extraction attempt ${attempt + 1} failed: ${err instanceof Error ? err.message : String(err)}`);
1670
+ if (attempt < maxRetries) {
1671
+ await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
1672
+ }
1673
+ }
1674
+ }
1675
+ log('Memory extraction failed after all retries');
1676
+ };
1677
+
1504
1678
  if (memoryExtractionAsync && isDaemon) {
1505
- void runMemoryExtraction().catch(err => {
1506
- log(`Memory extraction failed: ${err instanceof Error ? err.message : String(err)}`);
1507
- });
1679
+ void runMemoryExtractionWithRetry().catch(() => {});
1508
1680
  } else {
1509
- try {
1510
- await runMemoryExtraction();
1511
- } catch (err) {
1512
- log(`Memory extraction failed: ${err instanceof Error ? err.message : String(err)}`);
1513
- }
1681
+ await runMemoryExtractionWithRetry();
1514
1682
  }
1515
1683
  }
1516
1684