@dotsetlabs/dotclaw 1.1.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 (170) hide show
  1. package/.env.example +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +111 -0
  4. package/config-examples/groups/global/CLAUDE.md +21 -0
  5. package/config-examples/groups/main/CLAUDE.md +47 -0
  6. package/config-examples/mount-allowlist.json +25 -0
  7. package/config-examples/plugin-http.json +18 -0
  8. package/config-examples/runtime.json +30 -0
  9. package/config-examples/tool-budgets.json +24 -0
  10. package/config-examples/tool-policy.json +51 -0
  11. package/container/.dockerignore +6 -0
  12. package/container/Dockerfile +74 -0
  13. package/container/agent-runner/package-lock.json +92 -0
  14. package/container/agent-runner/package.json +20 -0
  15. package/container/agent-runner/src/agent-config.ts +295 -0
  16. package/container/agent-runner/src/container-protocol.ts +73 -0
  17. package/container/agent-runner/src/daemon.ts +91 -0
  18. package/container/agent-runner/src/index.ts +1428 -0
  19. package/container/agent-runner/src/ipc.ts +321 -0
  20. package/container/agent-runner/src/memory.ts +336 -0
  21. package/container/agent-runner/src/prompt-packs.ts +341 -0
  22. package/container/agent-runner/src/tools.ts +1720 -0
  23. package/container/agent-runner/tsconfig.json +19 -0
  24. package/container/build.sh +23 -0
  25. package/container/skills/agent-browser.md +159 -0
  26. package/dist/admin-commands.d.ts +7 -0
  27. package/dist/admin-commands.d.ts.map +1 -0
  28. package/dist/admin-commands.js +87 -0
  29. package/dist/admin-commands.js.map +1 -0
  30. package/dist/agent-context.d.ts +42 -0
  31. package/dist/agent-context.d.ts.map +1 -0
  32. package/dist/agent-context.js +92 -0
  33. package/dist/agent-context.js.map +1 -0
  34. package/dist/agent-execution.d.ts +68 -0
  35. package/dist/agent-execution.d.ts.map +1 -0
  36. package/dist/agent-execution.js +169 -0
  37. package/dist/agent-execution.js.map +1 -0
  38. package/dist/agent-semaphore.d.ts +2 -0
  39. package/dist/agent-semaphore.d.ts.map +1 -0
  40. package/dist/agent-semaphore.js +52 -0
  41. package/dist/agent-semaphore.js.map +1 -0
  42. package/dist/behavior-config.d.ts +14 -0
  43. package/dist/behavior-config.d.ts.map +1 -0
  44. package/dist/behavior-config.js +52 -0
  45. package/dist/behavior-config.js.map +1 -0
  46. package/dist/cli.d.ts +3 -0
  47. package/dist/cli.d.ts.map +1 -0
  48. package/dist/cli.js +626 -0
  49. package/dist/cli.js.map +1 -0
  50. package/dist/config.d.ts +31 -0
  51. package/dist/config.d.ts.map +1 -0
  52. package/dist/config.js +38 -0
  53. package/dist/config.js.map +1 -0
  54. package/dist/container-protocol.d.ts +72 -0
  55. package/dist/container-protocol.d.ts.map +1 -0
  56. package/dist/container-protocol.js +3 -0
  57. package/dist/container-protocol.js.map +1 -0
  58. package/dist/container-runner.d.ts +59 -0
  59. package/dist/container-runner.d.ts.map +1 -0
  60. package/dist/container-runner.js +813 -0
  61. package/dist/container-runner.js.map +1 -0
  62. package/dist/cost.d.ts +9 -0
  63. package/dist/cost.d.ts.map +1 -0
  64. package/dist/cost.js +11 -0
  65. package/dist/cost.js.map +1 -0
  66. package/dist/dashboard.d.ts +58 -0
  67. package/dist/dashboard.d.ts.map +1 -0
  68. package/dist/dashboard.js +471 -0
  69. package/dist/dashboard.js.map +1 -0
  70. package/dist/db.d.ts +99 -0
  71. package/dist/db.d.ts.map +1 -0
  72. package/dist/db.js +423 -0
  73. package/dist/db.js.map +1 -0
  74. package/dist/error-messages.d.ts +17 -0
  75. package/dist/error-messages.d.ts.map +1 -0
  76. package/dist/error-messages.js +109 -0
  77. package/dist/error-messages.js.map +1 -0
  78. package/dist/index.d.ts +2 -0
  79. package/dist/index.d.ts.map +1 -0
  80. package/dist/index.js +2072 -0
  81. package/dist/index.js.map +1 -0
  82. package/dist/locks.d.ts +2 -0
  83. package/dist/locks.d.ts.map +1 -0
  84. package/dist/locks.js +26 -0
  85. package/dist/locks.js.map +1 -0
  86. package/dist/logger.d.ts +4 -0
  87. package/dist/logger.d.ts.map +1 -0
  88. package/dist/logger.js +15 -0
  89. package/dist/logger.js.map +1 -0
  90. package/dist/maintenance.d.ts +13 -0
  91. package/dist/maintenance.d.ts.map +1 -0
  92. package/dist/maintenance.js +151 -0
  93. package/dist/maintenance.js.map +1 -0
  94. package/dist/memory-embeddings.d.ts +13 -0
  95. package/dist/memory-embeddings.d.ts.map +1 -0
  96. package/dist/memory-embeddings.js +126 -0
  97. package/dist/memory-embeddings.js.map +1 -0
  98. package/dist/memory-recall.d.ts +8 -0
  99. package/dist/memory-recall.d.ts.map +1 -0
  100. package/dist/memory-recall.js +127 -0
  101. package/dist/memory-recall.js.map +1 -0
  102. package/dist/memory-store.d.ts +149 -0
  103. package/dist/memory-store.d.ts.map +1 -0
  104. package/dist/memory-store.js +787 -0
  105. package/dist/memory-store.js.map +1 -0
  106. package/dist/metrics.d.ts +12 -0
  107. package/dist/metrics.d.ts.map +1 -0
  108. package/dist/metrics.js +134 -0
  109. package/dist/metrics.js.map +1 -0
  110. package/dist/model-registry.d.ts +67 -0
  111. package/dist/model-registry.d.ts.map +1 -0
  112. package/dist/model-registry.js +230 -0
  113. package/dist/model-registry.js.map +1 -0
  114. package/dist/mount-security.d.ts +37 -0
  115. package/dist/mount-security.d.ts.map +1 -0
  116. package/dist/mount-security.js +284 -0
  117. package/dist/mount-security.js.map +1 -0
  118. package/dist/paths.d.ts +80 -0
  119. package/dist/paths.d.ts.map +1 -0
  120. package/dist/paths.js +149 -0
  121. package/dist/paths.js.map +1 -0
  122. package/dist/personalization.d.ts +6 -0
  123. package/dist/personalization.d.ts.map +1 -0
  124. package/dist/personalization.js +180 -0
  125. package/dist/personalization.js.map +1 -0
  126. package/dist/progress.d.ts +15 -0
  127. package/dist/progress.d.ts.map +1 -0
  128. package/dist/progress.js +92 -0
  129. package/dist/progress.js.map +1 -0
  130. package/dist/runtime-config.d.ts +227 -0
  131. package/dist/runtime-config.d.ts.map +1 -0
  132. package/dist/runtime-config.js +297 -0
  133. package/dist/runtime-config.js.map +1 -0
  134. package/dist/task-scheduler.d.ts +9 -0
  135. package/dist/task-scheduler.d.ts.map +1 -0
  136. package/dist/task-scheduler.js +195 -0
  137. package/dist/task-scheduler.js.map +1 -0
  138. package/dist/telegram-format.d.ts +3 -0
  139. package/dist/telegram-format.d.ts.map +1 -0
  140. package/dist/telegram-format.js +200 -0
  141. package/dist/telegram-format.js.map +1 -0
  142. package/dist/tool-budgets.d.ts +16 -0
  143. package/dist/tool-budgets.d.ts.map +1 -0
  144. package/dist/tool-budgets.js +83 -0
  145. package/dist/tool-budgets.js.map +1 -0
  146. package/dist/tool-policy.d.ts +18 -0
  147. package/dist/tool-policy.d.ts.map +1 -0
  148. package/dist/tool-policy.js +84 -0
  149. package/dist/tool-policy.js.map +1 -0
  150. package/dist/trace-writer.d.ts +39 -0
  151. package/dist/trace-writer.d.ts.map +1 -0
  152. package/dist/trace-writer.js +27 -0
  153. package/dist/trace-writer.js.map +1 -0
  154. package/dist/types.d.ts +81 -0
  155. package/dist/types.d.ts.map +1 -0
  156. package/dist/types.js +2 -0
  157. package/dist/types.js.map +1 -0
  158. package/dist/utils.d.ts +4 -0
  159. package/dist/utils.d.ts.map +1 -0
  160. package/dist/utils.js +30 -0
  161. package/dist/utils.js.map +1 -0
  162. package/launchd/com.dotclaw.plist +32 -0
  163. package/package.json +89 -0
  164. package/scripts/autotune.js +53 -0
  165. package/scripts/bootstrap.js +348 -0
  166. package/scripts/configure.js +200 -0
  167. package/scripts/doctor.js +164 -0
  168. package/scripts/init.js +209 -0
  169. package/scripts/install.sh +219 -0
  170. package/systemd/dotclaw.service +22 -0
@@ -0,0 +1,1428 @@
1
+ /**
2
+ * DotClaw Agent Runner (OpenRouter)
3
+ * Runs inside a container, receives config via stdin, outputs result to stdout
4
+ */
5
+
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import { fileURLToPath } from 'url';
9
+ import { OpenRouter, stepCountIs } from '@openrouter/sdk';
10
+ import { createTools, ToolCallRecord } from './tools.js';
11
+ import { createIpcHandlers } from './ipc.js';
12
+ import { loadAgentConfig } from './agent-config.js';
13
+ import { OUTPUT_START_MARKER, OUTPUT_END_MARKER, type ContainerInput, type ContainerOutput } from './container-protocol.js';
14
+ import {
15
+ createSessionContext,
16
+ appendHistory,
17
+ loadHistory,
18
+ splitRecentHistory,
19
+ shouldCompact,
20
+ archiveConversation,
21
+ buildSummaryPrompt,
22
+ parseSummaryResponse,
23
+ retrieveRelevantMemories,
24
+ saveMemoryState,
25
+ writeHistory,
26
+ MemoryConfig,
27
+ Message
28
+ } from './memory.js';
29
+ import { loadPromptPackWithCanary, formatTaskExtractionPack, formatResponseQualityPack, formatToolCallingPack, formatToolOutcomePack, formatMemoryPolicyPack, formatMemoryRecallPack, PromptPack } from './prompt-packs.js';
30
+
31
+
32
+ const SESSION_ROOT = '/workspace/session';
33
+ const GROUP_DIR = '/workspace/group';
34
+ const IPC_DIR = '/workspace/ipc';
35
+ const GLOBAL_DIR = '/workspace/global';
36
+ const PROMPTS_DIR = '/workspace/prompts';
37
+ const AVAILABLE_GROUPS_PATH = '/workspace/ipc/available_groups.json';
38
+ const GROUP_CLAUDE_PATH = path.join(GROUP_DIR, 'CLAUDE.md');
39
+ const GLOBAL_CLAUDE_PATH = path.join(GLOBAL_DIR, 'CLAUDE.md');
40
+ const CLAUDE_NOTES_MAX_CHARS = 4000;
41
+
42
+ const agentConfig = loadAgentConfig();
43
+ const agent = agentConfig.agent;
44
+
45
+ const PROMPT_PACKS_ENABLED = agent.promptPacks.enabled;
46
+ const PROMPT_PACKS_MAX_CHARS = agent.promptPacks.maxChars;
47
+ const PROMPT_PACKS_MAX_DEMOS = agent.promptPacks.maxDemos;
48
+ const PROMPT_PACKS_CANARY_RATE = agent.promptPacks.canaryRate;
49
+
50
+ let cachedOpenRouter: OpenRouter | null = null;
51
+ let cachedOpenRouterKey = '';
52
+ let cachedOpenRouterOptions = '';
53
+
54
+ function getCachedOpenRouter(apiKey: string, options: ReturnType<typeof getOpenRouterOptions>): OpenRouter {
55
+ const optionsKey = JSON.stringify(options);
56
+ if (cachedOpenRouter && cachedOpenRouterKey === apiKey && cachedOpenRouterOptions === optionsKey) {
57
+ return cachedOpenRouter;
58
+ }
59
+ cachedOpenRouter = new OpenRouter({
60
+ apiKey,
61
+ ...options
62
+ });
63
+ cachedOpenRouterKey = apiKey;
64
+ cachedOpenRouterOptions = optionsKey;
65
+ return cachedOpenRouter;
66
+ }
67
+
68
+ function log(message: string): void {
69
+ console.error(`[agent-runner] ${message}`);
70
+ }
71
+
72
+ function writeOutput(output: ContainerOutput): void {
73
+ console.log(OUTPUT_START_MARKER);
74
+ console.log(JSON.stringify(output));
75
+ console.log(OUTPUT_END_MARKER);
76
+ }
77
+
78
+ async function runSelfCheck(params: {
79
+ model: string;
80
+ }) {
81
+ const details: string[] = [];
82
+
83
+ if (!process.env.OPENROUTER_API_KEY) {
84
+ throw new Error('OPENROUTER_API_KEY is not set');
85
+ }
86
+
87
+ fs.mkdirSync(GROUP_DIR, { recursive: true });
88
+ fs.mkdirSync(SESSION_ROOT, { recursive: true });
89
+ fs.mkdirSync(IPC_DIR, { recursive: true });
90
+ fs.mkdirSync(path.join(IPC_DIR, 'messages'), { recursive: true });
91
+ fs.mkdirSync(path.join(IPC_DIR, 'tasks'), { recursive: true });
92
+
93
+ const filePath = path.join(GROUP_DIR, '.dotclaw-selfcheck');
94
+ fs.writeFileSync(filePath, `self-check-${Date.now()}`);
95
+ const readBack = fs.readFileSync(filePath, 'utf-8');
96
+ if (!readBack.startsWith('self-check-')) {
97
+ throw new Error('Failed to read back self-check file');
98
+ }
99
+ fs.unlinkSync(filePath);
100
+ details.push('group directory writable');
101
+
102
+ const sessionPath = path.join(SESSION_ROOT, 'self-check');
103
+ fs.mkdirSync(sessionPath, { recursive: true });
104
+ const sessionFile = path.join(sessionPath, 'probe.txt');
105
+ fs.writeFileSync(sessionFile, 'ok');
106
+ fs.readFileSync(sessionFile, 'utf-8');
107
+ fs.unlinkSync(sessionFile);
108
+ details.push('session directory writable');
109
+
110
+ const ipcFile = path.join(IPC_DIR, 'messages', `self-check-${Date.now()}.json`);
111
+ fs.writeFileSync(ipcFile, JSON.stringify({ ok: true }, null, 2));
112
+ fs.unlinkSync(ipcFile);
113
+ details.push('ipc directory writable');
114
+
115
+ const headers: Record<string, string> = {
116
+ 'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
117
+ 'Content-Type': 'application/json'
118
+ };
119
+ if (agent.openrouter.siteUrl) {
120
+ headers['HTTP-Referer'] = agent.openrouter.siteUrl;
121
+ }
122
+ if (agent.openrouter.siteName) {
123
+ headers['X-Title'] = agent.openrouter.siteName;
124
+ }
125
+
126
+ const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
127
+ method: 'POST',
128
+ headers,
129
+ body: JSON.stringify({
130
+ model: params.model,
131
+ messages: [{ role: 'user', content: 'Return exactly the string "OK".' }],
132
+ max_tokens: 8,
133
+ temperature: 0
134
+ })
135
+ });
136
+
137
+ const bodyText = await response.text();
138
+ if (!response.ok) {
139
+ throw new Error(`OpenRouter HTTP ${response.status}: ${bodyText.slice(0, 500)}`);
140
+ }
141
+
142
+ try {
143
+ const data = JSON.parse(bodyText);
144
+ const content = data?.choices?.[0]?.message?.content || data?.choices?.[0]?.text;
145
+ if (!content || !String(content).trim()) {
146
+ throw new Error('OpenRouter call returned empty response');
147
+ }
148
+ } catch (err) {
149
+ throw new Error(`OpenRouter response parse failed: ${err instanceof Error ? err.message : String(err)}`);
150
+ }
151
+
152
+ details.push('openrouter call ok');
153
+
154
+ return details;
155
+ }
156
+
157
+ async function readStdin(): Promise<string> {
158
+ return new Promise((resolve, reject) => {
159
+ let data = '';
160
+ process.stdin.setEncoding('utf8');
161
+ process.stdin.on('data', chunk => { data += chunk; });
162
+ process.stdin.on('end', () => resolve(data));
163
+ process.stdin.on('error', reject);
164
+ });
165
+ }
166
+
167
+ function getConfig(config: ReturnType<typeof loadAgentConfig>): MemoryConfig & {
168
+ maxOutputTokens: number;
169
+ summaryMaxOutputTokens: number;
170
+ temperature: number;
171
+ } {
172
+ return {
173
+ maxContextTokens: config.agent.context.maxContextTokens,
174
+ compactionTriggerTokens: config.agent.context.compactionTriggerTokens,
175
+ recentContextTokens: config.agent.context.recentContextTokens,
176
+ summaryUpdateEveryMessages: config.agent.context.summaryUpdateEveryMessages,
177
+ memoryMaxResults: config.agent.memory.maxResults,
178
+ memoryMaxTokens: config.agent.memory.maxTokens,
179
+ maxOutputTokens: config.agent.context.maxOutputTokens,
180
+ summaryMaxOutputTokens: config.agent.context.summaryMaxOutputTokens,
181
+ temperature: config.agent.context.temperature
182
+ };
183
+ }
184
+
185
+ function buildPlannerPrompt(messages: Message[]): { instructions: string; input: string } {
186
+ const transcript = messages.map(msg => `${msg.role.toUpperCase()}: ${msg.content}`).join('\n\n');
187
+ const instructions = [
188
+ 'You are a planning module for a personal assistant.',
189
+ 'Given the conversation, produce a concise plan in JSON.',
190
+ 'Return JSON only with keys:',
191
+ '- steps: array of short action steps',
192
+ '- tools: array of tool names you expect to use (if any)',
193
+ '- risks: array of potential pitfalls or missing info',
194
+ '- questions: array of clarifying questions (if any)',
195
+ 'Keep each array short. Use empty arrays if not needed.'
196
+ ].join('\n');
197
+ const input = `Conversation:\n${transcript}`;
198
+ return { instructions, input };
199
+ }
200
+
201
+ function parsePlannerResponse(text: string): { steps: string[]; tools: string[]; risks: string[]; questions: string[] } | null {
202
+ const trimmed = text.trim();
203
+ let jsonText = trimmed;
204
+ const fenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
205
+ if (fenceMatch) {
206
+ jsonText = fenceMatch[1].trim();
207
+ }
208
+ try {
209
+ const parsed = JSON.parse(jsonText) as Record<string, unknown>;
210
+ const steps = Array.isArray(parsed.steps) ? parsed.steps.filter(item => typeof item === 'string') : [];
211
+ const tools = Array.isArray(parsed.tools) ? parsed.tools.filter(item => typeof item === 'string') : [];
212
+ const risks = Array.isArray(parsed.risks) ? parsed.risks.filter(item => typeof item === 'string') : [];
213
+ const questions = Array.isArray(parsed.questions) ? parsed.questions.filter(item => typeof item === 'string') : [];
214
+ return { steps, tools, risks, questions };
215
+ } catch {
216
+ return null;
217
+ }
218
+ }
219
+
220
+ function formatPlanBlock(plan: { steps: string[]; tools: string[]; risks: string[]; questions: string[] }): string {
221
+ const lines: string[] = ['Planned approach (planner):'];
222
+ if (plan.steps.length > 0) {
223
+ lines.push('Steps:');
224
+ for (const step of plan.steps) lines.push(`- ${step}`);
225
+ }
226
+ if (plan.tools.length > 0) {
227
+ lines.push('Tools:');
228
+ for (const tool of plan.tools) lines.push(`- ${tool}`);
229
+ }
230
+ if (plan.risks.length > 0) {
231
+ lines.push('Risks:');
232
+ for (const risk of plan.risks) lines.push(`- ${risk}`);
233
+ }
234
+ if (plan.questions.length > 0) {
235
+ lines.push('Questions:');
236
+ for (const question of plan.questions) lines.push(`- ${question}`);
237
+ }
238
+ return lines.join('\n');
239
+ }
240
+
241
+ function getOpenRouterOptions(config: ReturnType<typeof loadAgentConfig>) {
242
+ const timeoutMs = config.agent.openrouter.timeoutMs;
243
+ const retryEnabled = config.agent.openrouter.retry;
244
+ const retryConfig = retryEnabled
245
+ ? {
246
+ strategy: 'backoff' as const,
247
+ backoff: {
248
+ initialInterval: 500,
249
+ maxInterval: 5000,
250
+ exponent: 2,
251
+ maxElapsedTime: 20_000
252
+ },
253
+ retryConnectionErrors: true
254
+ }
255
+ : { strategy: 'none' as const };
256
+
257
+ return {
258
+ timeoutMs,
259
+ retryConfig,
260
+ httpReferer: config.agent.openrouter.siteUrl || undefined,
261
+ xTitle: config.agent.openrouter.siteName || undefined
262
+ };
263
+ }
264
+
265
+ function resolveTokenEstimate(
266
+ input: ContainerInput,
267
+ config: ReturnType<typeof loadAgentConfig>
268
+ ): { tokensPerChar: number; tokensPerMessage: number; tokensPerRequest: number } {
269
+ const fallbackChar = config.agent.tokenEstimate.tokensPerChar;
270
+ const fallbackMessage = config.agent.tokenEstimate.tokensPerMessage;
271
+ const fallbackRequest = config.agent.tokenEstimate.tokensPerRequest;
272
+ const tokensPerChar = Number.isFinite(input.tokenEstimate?.tokens_per_char)
273
+ ? Number(input.tokenEstimate?.tokens_per_char)
274
+ : fallbackChar;
275
+ const tokensPerMessage = Number.isFinite(input.tokenEstimate?.tokens_per_message)
276
+ ? Number(input.tokenEstimate?.tokens_per_message)
277
+ : fallbackMessage;
278
+ const tokensPerRequest = Number.isFinite(input.tokenEstimate?.tokens_per_request)
279
+ ? Number(input.tokenEstimate?.tokens_per_request)
280
+ : fallbackRequest;
281
+ return {
282
+ tokensPerChar: Math.max(0, tokensPerChar),
283
+ tokensPerMessage: Math.max(0, tokensPerMessage),
284
+ tokensPerRequest: Math.max(0, tokensPerRequest)
285
+ };
286
+ }
287
+
288
+ function estimateTokensForModel(text: string, tokensPerChar: number): number {
289
+ if (!text) return 0;
290
+ const bytes = Buffer.byteLength(text, 'utf-8');
291
+ return Math.ceil(bytes * tokensPerChar);
292
+ }
293
+
294
+ function estimateMessagesTokens(messages: Message[], tokensPerChar: number, tokensPerMessage: number): number {
295
+ let total = 0;
296
+ for (const message of messages) {
297
+ total += estimateTokensForModel(message.content, tokensPerChar);
298
+ total += tokensPerMessage;
299
+ }
300
+ return total;
301
+ }
302
+
303
+ function buildSystemInstructions(params: {
304
+ assistantName: string;
305
+ groupNotes?: string | null;
306
+ globalNotes?: string | null;
307
+ memorySummary: string;
308
+ memoryFacts: string[];
309
+ sessionRecall: string[];
310
+ longTermRecall: string[];
311
+ userProfile?: string | null;
312
+ memoryStats?: { total: number; user: number; group: number; global: number };
313
+ availableGroups?: Array<{ jid: string; name: string; lastActivity: string; isRegistered: boolean }>;
314
+ toolReliability?: Array<{ name: string; success_rate: number; count: number; avg_duration_ms: number | null }>;
315
+ behaviorConfig?: Record<string, unknown>;
316
+ isScheduledTask: boolean;
317
+ isBackgroundTask: boolean;
318
+ taskId?: string;
319
+ planBlock?: string;
320
+ taskExtractionPack?: PromptPack | null;
321
+ responseQualityPack?: PromptPack | null;
322
+ toolCallingPack?: PromptPack | null;
323
+ toolOutcomePack?: PromptPack | null;
324
+ memoryPolicyPack?: PromptPack | null;
325
+ memoryRecallPack?: PromptPack | null;
326
+ }): string {
327
+ const toolsDoc = [
328
+ 'Tools available (use with care):',
329
+ '- `Bash`: run shell commands in `/workspace/group`.',
330
+ '- `Read`, `Write`, `Edit`, `Glob`, `Grep`: filesystem operations within mounted paths.',
331
+ '- `WebSearch`: Brave Search API (requires `BRAVE_SEARCH_API_KEY`).',
332
+ '- `WebFetch`: fetch URLs (limit payload sizes).',
333
+ '- `GitClone`: clone git repositories into the workspace.',
334
+ '- `NpmInstall`: install npm dependencies in the workspace.',
335
+ '- `mcp__dotclaw__send_message`: send Telegram messages.',
336
+ '- `mcp__dotclaw__schedule_task`: schedule tasks.',
337
+ '- `mcp__dotclaw__list_tasks`, `mcp__dotclaw__pause_task`, `mcp__dotclaw__resume_task`, `mcp__dotclaw__cancel_task`.',
338
+ '- `mcp__dotclaw__update_task`: update a task (state, prompt, schedule, status).',
339
+ '- `mcp__dotclaw__register_group`: main group only.',
340
+ '- `mcp__dotclaw__remove_group`, `mcp__dotclaw__list_groups`: main group only.',
341
+ '- `mcp__dotclaw__set_model`: main group only.',
342
+ '- `mcp__dotclaw__memory_upsert`: store durable memories.',
343
+ '- `mcp__dotclaw__memory_search`, `mcp__dotclaw__memory_list`, `mcp__dotclaw__memory_forget`, `mcp__dotclaw__memory_stats`.',
344
+ '- `plugin__*`: dynamically loaded plugin tools (if present and allowed by policy).'
345
+ ].join('\n');
346
+ const browserAutomation = [
347
+ 'Browser automation (via Bash):',
348
+ '- Use `agent-browser open <url>` then `agent-browser snapshot -i`.',
349
+ '- Interact with refs using `agent-browser click @e1`, `fill @e2 "text"`.',
350
+ '- Capture evidence with `agent-browser screenshot`.'
351
+ ].join('\n');
352
+
353
+ const memorySummary = params.memorySummary ? params.memorySummary : 'None yet.';
354
+ const memoryFacts = params.memoryFacts.length > 0
355
+ ? params.memoryFacts.map(fact => `- ${fact}`).join('\n')
356
+ : 'None yet.';
357
+ const sessionRecall = params.sessionRecall.length > 0
358
+ ? params.sessionRecall.map(item => `- ${item}`).join('\n')
359
+ : 'None.';
360
+
361
+ const longTermRecall = params.longTermRecall.length > 0
362
+ ? params.longTermRecall.map(item => `- ${item}`).join('\n')
363
+ : 'None.';
364
+
365
+ const userProfile = params.userProfile
366
+ ? params.userProfile
367
+ : 'None.';
368
+
369
+ const memoryStats = params.memoryStats
370
+ ? `Total: ${params.memoryStats.total}, User: ${params.memoryStats.user}, Group: ${params.memoryStats.group}, Global: ${params.memoryStats.global}`
371
+ : 'Unknown.';
372
+
373
+ const availableGroups = params.availableGroups && params.availableGroups.length > 0
374
+ ? params.availableGroups
375
+ .map(group => `- ${group.name} (chat ${group.jid}, last: ${group.lastActivity})`)
376
+ .join('\n')
377
+ : 'None.';
378
+
379
+ const groupNotes = params.groupNotes ? `Group notes:\n${params.groupNotes}` : '';
380
+ const globalNotes = params.globalNotes ? `Global notes:\n${params.globalNotes}` : '';
381
+
382
+ const toolReliability = params.toolReliability && params.toolReliability.length > 0
383
+ ? params.toolReliability
384
+ .sort((a, b) => b.success_rate - a.success_rate)
385
+ .map(tool => {
386
+ const pct = `${Math.round(tool.success_rate * 100)}%`;
387
+ const avg = Number.isFinite(tool.avg_duration_ms) ? `${Math.round(tool.avg_duration_ms!)}ms` : 'n/a';
388
+ return `- ${tool.name}: success ${pct} over ${tool.count} calls (avg ${avg})`;
389
+ })
390
+ .join('\n')
391
+ : 'No recent tool reliability data.';
392
+
393
+ const behaviorNotes: string[] = [];
394
+ const responseStyle = typeof params.behaviorConfig?.response_style === 'string'
395
+ ? String(params.behaviorConfig.response_style)
396
+ : '';
397
+ if (responseStyle === 'concise') {
398
+ behaviorNotes.push('Response style: concise and action-oriented.');
399
+ } else if (responseStyle === 'detailed') {
400
+ behaviorNotes.push('Response style: detailed and step-by-step where helpful.');
401
+ }
402
+ const toolBias = typeof params.behaviorConfig?.tool_calling_bias === 'number'
403
+ ? Number(params.behaviorConfig.tool_calling_bias)
404
+ : null;
405
+ if (toolBias !== null && toolBias < 0.4) {
406
+ behaviorNotes.push('Tool usage: be conservative, ask clarifying questions before calling tools.');
407
+ } else if (toolBias !== null && toolBias > 0.6) {
408
+ behaviorNotes.push('Tool usage: be proactive when tools add accuracy or save time.');
409
+ }
410
+ const cautionBias = typeof params.behaviorConfig?.caution_bias === 'number'
411
+ ? Number(params.behaviorConfig.caution_bias)
412
+ : null;
413
+ if (cautionBias !== null && cautionBias > 0.6) {
414
+ behaviorNotes.push('Caution: verify uncertain facts and flag limitations.');
415
+ }
416
+
417
+ const behaviorConfig = params.behaviorConfig
418
+ ? `Behavior overrides:\n${JSON.stringify(params.behaviorConfig, null, 2)}`
419
+ : '';
420
+
421
+ const scheduledNote = params.isScheduledTask
422
+ ? `You are running as a scheduled task${params.taskId ? ` (task id: ${params.taskId})` : ''}. If you need to communicate, use \`mcp__dotclaw__send_message\`.`
423
+ : '';
424
+ const backgroundNote = params.isBackgroundTask
425
+ ? 'You are running in the background for a user request. Focus on completing the task and return a complete response without asking follow-up questions unless strictly necessary.'
426
+ : '';
427
+
428
+ const taskExtractionBlock = params.taskExtractionPack
429
+ ? formatTaskExtractionPack({
430
+ pack: params.taskExtractionPack,
431
+ maxDemos: PROMPT_PACKS_MAX_DEMOS,
432
+ maxChars: PROMPT_PACKS_MAX_CHARS
433
+ })
434
+ : '';
435
+
436
+ const responseQualityBlock = params.responseQualityPack
437
+ ? formatResponseQualityPack({
438
+ pack: params.responseQualityPack,
439
+ maxDemos: PROMPT_PACKS_MAX_DEMOS,
440
+ maxChars: PROMPT_PACKS_MAX_CHARS
441
+ })
442
+ : '';
443
+
444
+ const toolCallingBlock = params.toolCallingPack
445
+ ? formatToolCallingPack({
446
+ pack: params.toolCallingPack,
447
+ maxDemos: PROMPT_PACKS_MAX_DEMOS,
448
+ maxChars: PROMPT_PACKS_MAX_CHARS
449
+ })
450
+ : '';
451
+
452
+ const toolOutcomeBlock = params.toolOutcomePack
453
+ ? formatToolOutcomePack({
454
+ pack: params.toolOutcomePack,
455
+ maxDemos: PROMPT_PACKS_MAX_DEMOS,
456
+ maxChars: PROMPT_PACKS_MAX_CHARS
457
+ })
458
+ : '';
459
+
460
+ const memoryPolicyBlock = params.memoryPolicyPack
461
+ ? formatMemoryPolicyPack({
462
+ pack: params.memoryPolicyPack,
463
+ maxDemos: PROMPT_PACKS_MAX_DEMOS,
464
+ maxChars: PROMPT_PACKS_MAX_CHARS
465
+ })
466
+ : '';
467
+
468
+ const memoryRecallBlock = params.memoryRecallPack
469
+ ? formatMemoryRecallPack({
470
+ pack: params.memoryRecallPack,
471
+ maxDemos: PROMPT_PACKS_MAX_DEMOS,
472
+ maxChars: PROMPT_PACKS_MAX_CHARS
473
+ })
474
+ : '';
475
+
476
+ return [
477
+ `You are ${params.assistantName}, a personal assistant running inside DotClaw.`,
478
+ scheduledNote,
479
+ backgroundNote,
480
+ toolsDoc,
481
+ browserAutomation,
482
+ groupNotes,
483
+ globalNotes,
484
+ params.planBlock || '',
485
+ toolCallingBlock,
486
+ toolOutcomeBlock,
487
+ taskExtractionBlock,
488
+ responseQualityBlock,
489
+ memoryPolicyBlock,
490
+ memoryRecallBlock,
491
+ 'Long-term memory summary:',
492
+ memorySummary,
493
+ 'Long-term facts:',
494
+ memoryFacts,
495
+ 'User profile (if available):',
496
+ userProfile,
497
+ 'Long-term memory recall (durable facts/preferences):',
498
+ longTermRecall,
499
+ 'Session recall (recent/older conversation snippets):',
500
+ sessionRecall,
501
+ 'Memory stats:',
502
+ memoryStats,
503
+ 'Available groups (main group only):',
504
+ availableGroups,
505
+ 'Tool reliability (recent):',
506
+ toolReliability,
507
+ behaviorNotes.length > 0 ? `Behavior notes:\n${behaviorNotes.join('\n')}` : '',
508
+ behaviorConfig,
509
+ 'Respond succinctly and helpfully. If you perform tool actions, summarize the results.'
510
+ ].filter(Boolean).join('\n\n');
511
+ }
512
+
513
+ function loadAvailableGroups(): Array<{ jid: string; name: string; lastActivity: string; isRegistered: boolean }> {
514
+ try {
515
+ if (!fs.existsSync(AVAILABLE_GROUPS_PATH)) return [];
516
+ const raw = JSON.parse(fs.readFileSync(AVAILABLE_GROUPS_PATH, 'utf-8')) as {
517
+ groups?: Array<{ jid: string; name: string; lastActivity: string; isRegistered: boolean }>;
518
+ };
519
+ return Array.isArray(raw.groups) ? raw.groups.filter(group => group && typeof group.jid === 'string') : [];
520
+ } catch {
521
+ return [];
522
+ }
523
+ }
524
+
525
+ function readTextFileLimited(filePath: string, maxChars: number): string | null {
526
+ try {
527
+ if (!fs.existsSync(filePath)) return null;
528
+ const content = fs.readFileSync(filePath, 'utf-8').trim();
529
+ if (!content) return null;
530
+ if (content.length <= maxChars) return content;
531
+ return `${content.slice(0, maxChars)}\n\n[Truncated for length]`;
532
+ } catch {
533
+ return null;
534
+ }
535
+ }
536
+
537
+ function loadClaudeNotes(): { group: string | null; global: string | null } {
538
+ return {
539
+ group: readTextFileLimited(GROUP_CLAUDE_PATH, CLAUDE_NOTES_MAX_CHARS),
540
+ global: readTextFileLimited(GLOBAL_CLAUDE_PATH, CLAUDE_NOTES_MAX_CHARS)
541
+ };
542
+ }
543
+
544
+ function extractQueryFromPrompt(prompt: string): string {
545
+ if (!prompt) return '';
546
+ const messageMatches = [...prompt.matchAll(/<message[^>]*>([\s\S]*?)<\/message>/g)];
547
+ if (messageMatches.length > 0) {
548
+ const last = messageMatches[messageMatches.length - 1][1];
549
+ return decodeXml(last).trim();
550
+ }
551
+ return prompt.trim();
552
+ }
553
+
554
+ function decodeXml(value: string): string {
555
+ return value
556
+ .replace(/&lt;/g, '<')
557
+ .replace(/&gt;/g, '>')
558
+ .replace(/&quot;/g, '"')
559
+ .replace(/&amp;/g, '&');
560
+ }
561
+
562
+ function messagesToOpenRouter(messages: Message[]) {
563
+ return messages.map(message => ({
564
+ role: message.role,
565
+ content: message.content
566
+ }));
567
+ }
568
+
569
+ function clampContextMessages(messages: Message[], tokensPerChar: number, maxTokens: number): Message[] {
570
+ if (!Number.isFinite(maxTokens) || maxTokens <= 0) return messages;
571
+ const tpc = tokensPerChar > 0 ? tokensPerChar : 0.25;
572
+ const maxBytes = Math.max(200, Math.floor(maxTokens / tpc));
573
+ const suffix = '\n\n[Context truncated for length]';
574
+ const suffixBytes = Buffer.byteLength(suffix, 'utf-8');
575
+ return messages.map(message => {
576
+ const contentBytes = Buffer.byteLength(message.content, 'utf-8');
577
+ if (contentBytes <= maxBytes) return message;
578
+ const budget = Math.max(0, maxBytes - suffixBytes);
579
+ const truncated = Buffer.from(message.content, 'utf-8')
580
+ .subarray(0, budget)
581
+ .toString('utf-8');
582
+ return { ...message, content: `${truncated}${suffix}` };
583
+ });
584
+ }
585
+
586
+ async function updateMemorySummary(params: {
587
+ openrouter: OpenRouter;
588
+ model: string;
589
+ existingSummary: string;
590
+ existingFacts: string[];
591
+ newMessages: Message[];
592
+ maxOutputTokens: number;
593
+ }): Promise<{ summary: string; facts: string[] } | null> {
594
+ if (params.newMessages.length === 0) return null;
595
+ const prompt = buildSummaryPrompt(params.existingSummary, params.existingFacts, params.newMessages);
596
+ const result = await params.openrouter.callModel({
597
+ model: params.model,
598
+ instructions: prompt.instructions,
599
+ input: prompt.input,
600
+ maxOutputTokens: params.maxOutputTokens,
601
+ temperature: 0.1
602
+ });
603
+ const text = await result.getText();
604
+ return parseSummaryResponse(text);
605
+ }
606
+
607
+ function buildMemoryExtractionPrompt(params: {
608
+ assistantName: string;
609
+ userId?: string;
610
+ userName?: string;
611
+ messages: Message[];
612
+ memoryPolicyPack?: PromptPack | null;
613
+ }): { instructions: string; input: string } {
614
+ const policyBlock = params.memoryPolicyPack
615
+ ? formatMemoryPolicyPack({
616
+ pack: params.memoryPolicyPack,
617
+ maxDemos: PROMPT_PACKS_MAX_DEMOS,
618
+ maxChars: PROMPT_PACKS_MAX_CHARS
619
+ })
620
+ : '';
621
+
622
+ const instructions = [
623
+ `You are ${params.assistantName}'s long-term memory extractor.`,
624
+ 'Extract durable, user-approved memories only.',
625
+ 'Prefer stable facts, preferences, identity details, projects, and long-running tasks.',
626
+ 'Avoid transient details, ephemeral scheduling, or speculative statements.',
627
+ 'If the user explicitly asked to remember something, include it.',
628
+ 'Return JSON only with key "items": array of memory objects.',
629
+ 'Each item fields:',
630
+ '- scope: "user" | "group" | "global"',
631
+ '- subject_id: user id for user scope (optional for group/global)',
632
+ '- type: "identity" | "preference" | "fact" | "relationship" | "project" | "task" | "note"',
633
+ '- kind: optional "semantic" | "episodic" | "procedural" | "preference"',
634
+ '- conflict_key: optional string to replace older memories with same key (e.g., "favorite_color")',
635
+ '- content: the memory string',
636
+ '- importance: 0-1 (higher = more important)',
637
+ '- confidence: 0-1',
638
+ '- tags: array of short tags',
639
+ '- ttl_days: optional number (omit for permanent memories).',
640
+ '- For preferences about response style, tool usage, caution, or memory strictness, use conflict_key:',
641
+ ' response_style, tool_calling_bias, caution_bias, memory_importance_threshold.',
642
+ ' Include metadata fields for these preferences where possible, e.g.',
643
+ ' { "metadata": { "response_style": "concise" } } or { "metadata": { "bias": 0.7 } }.',
644
+ policyBlock
645
+ ].filter(Boolean).join('\n');
646
+
647
+ const transcript = params.messages
648
+ .map(msg => `${msg.role.toUpperCase()}: ${msg.content}`)
649
+ .join('\n\n');
650
+
651
+ const input = [
652
+ `User: ${params.userName || 'Unknown'} (${params.userId || 'unknown'})`,
653
+ 'Transcript:',
654
+ transcript
655
+ ].join('\n\n');
656
+
657
+ return { instructions, input };
658
+ }
659
+
660
+ function parseMemoryExtraction(text: string): Array<Record<string, unknown>> {
661
+ const trimmed = text.trim();
662
+ let jsonText = trimmed;
663
+ const fenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
664
+ if (fenceMatch) {
665
+ jsonText = fenceMatch[1].trim();
666
+ }
667
+ try {
668
+ const parsed = JSON.parse(jsonText);
669
+ const items = Array.isArray(parsed?.items) ? parsed.items : [];
670
+ return items.filter((item: unknown) => !!item && typeof item === 'object');
671
+ } catch {
672
+ return [];
673
+ }
674
+ }
675
+
676
+ type ResponseValidation = {
677
+ verdict: 'pass' | 'fail';
678
+ issues: string[];
679
+ missing: string[];
680
+ };
681
+
682
+ function buildResponseValidationPrompt(params: { userPrompt: string; response: string }): { instructions: string; input: string } {
683
+ const instructions = [
684
+ 'You are a strict response quality checker.',
685
+ 'Given a user request and an assistant response, decide if the response fully addresses the request.',
686
+ 'Fail if the response is empty, generic, deflects, promises work without results, or ignores any explicit questions.',
687
+ 'Pass only if the response directly answers all parts with concrete, relevant content.',
688
+ 'Return JSON only with keys: verdict ("pass"|"fail"), issues (array of strings), missing (array of strings).'
689
+ ].join('\n');
690
+
691
+ const input = [
692
+ 'User request:',
693
+ params.userPrompt,
694
+ '',
695
+ 'Assistant response:',
696
+ params.response
697
+ ].join('\n');
698
+
699
+ return { instructions, input };
700
+ }
701
+
702
+ function parseResponseValidation(text: string): ResponseValidation | null {
703
+ const trimmed = text.trim();
704
+ let jsonText = trimmed;
705
+ const fenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
706
+ if (fenceMatch) {
707
+ jsonText = fenceMatch[1].trim();
708
+ }
709
+ try {
710
+ const parsed = JSON.parse(jsonText);
711
+ const verdict = parsed?.verdict;
712
+ if (verdict !== 'pass' && verdict !== 'fail') return null;
713
+ const issues = Array.isArray(parsed?.issues)
714
+ ? parsed.issues.filter((issue: unknown) => typeof issue === 'string')
715
+ : [];
716
+ const missing = Array.isArray(parsed?.missing)
717
+ ? parsed.missing.filter((item: unknown) => typeof item === 'string')
718
+ : [];
719
+ return { verdict, issues, missing };
720
+ } catch {
721
+ return null;
722
+ }
723
+ }
724
+
725
+ async function validateResponseQuality(params: {
726
+ openrouter: OpenRouter;
727
+ model: string;
728
+ userPrompt: string;
729
+ response: string;
730
+ maxOutputTokens: number;
731
+ temperature: number;
732
+ }): Promise<ResponseValidation | null> {
733
+ const prompt = buildResponseValidationPrompt({
734
+ userPrompt: params.userPrompt,
735
+ response: params.response
736
+ });
737
+ const result = await params.openrouter.callModel({
738
+ model: params.model,
739
+ instructions: prompt.instructions,
740
+ input: prompt.input,
741
+ maxOutputTokens: params.maxOutputTokens,
742
+ temperature: params.temperature
743
+ });
744
+ const text = await result.getText();
745
+ return parseResponseValidation(text);
746
+ }
747
+
748
+ function buildRetryGuidance(validation: ResponseValidation | null): string {
749
+ const issues = validation?.issues || [];
750
+ const missing = validation?.missing || [];
751
+ const points = [...issues, ...missing].filter(Boolean).slice(0, 8);
752
+ const details = points.length > 0
753
+ ? points.map(item => `- ${item}`).join('\n')
754
+ : '- The previous response did not fully address the request.';
755
+ return [
756
+ 'IMPORTANT: Your previous response did not fully answer the user request.',
757
+ 'Provide a direct, complete answer now. Do not mention this retry.',
758
+ 'Issues to fix:',
759
+ details
760
+ ].join('\n');
761
+ }
762
+
763
+ function buildPlannerTrigger(pattern: string | undefined): RegExp | null {
764
+ if (!pattern) return null;
765
+ try {
766
+ return new RegExp(pattern, 'i');
767
+ } catch {
768
+ return null;
769
+ }
770
+ }
771
+
772
+ function shouldRunPlanner(params: {
773
+ enabled: boolean;
774
+ mode: string;
775
+ prompt: string;
776
+ tokensPerChar: number;
777
+ minTokens: number;
778
+ trigger: RegExp | null;
779
+ }): boolean {
780
+ if (!params.enabled) return false;
781
+ const mode = params.mode.toLowerCase();
782
+ if (mode === 'always') return true;
783
+ if (mode === 'off') return false;
784
+
785
+ const estimatedTokens = estimateTokensForModel(params.prompt, params.tokensPerChar);
786
+ if (params.minTokens > 0 && estimatedTokens >= params.minTokens) return true;
787
+ if (params.trigger && params.trigger.test(params.prompt)) return true;
788
+ return false;
789
+ }
790
+
791
+ export async function runAgentOnce(input: ContainerInput): Promise<ContainerOutput> {
792
+ log(`Received input for group: ${input.groupFolder}`);
793
+
794
+ const apiKey = process.env.OPENROUTER_API_KEY;
795
+ if (!apiKey) {
796
+ return {
797
+ status: 'error',
798
+ result: null,
799
+ error: 'OPENROUTER_API_KEY is not set'
800
+ };
801
+ }
802
+
803
+ const model = input.modelOverride || agentConfig.defaultModel;
804
+ const summaryModel = agent.models.summary;
805
+ const memoryModel = agent.models.memory;
806
+ const assistantName = agent.assistantName;
807
+ const config = getConfig(agentConfig);
808
+ if (input.modelContextTokens && Number.isFinite(input.modelContextTokens)) {
809
+ config.maxContextTokens = Math.min(config.maxContextTokens, input.modelContextTokens);
810
+ const compactionTarget = input.modelContextTokens - config.maxOutputTokens;
811
+ config.compactionTriggerTokens = Math.max(1000, Math.min(config.compactionTriggerTokens, compactionTarget));
812
+ }
813
+ if (input.modelMaxOutputTokens && Number.isFinite(input.modelMaxOutputTokens)) {
814
+ config.maxOutputTokens = Math.min(config.maxOutputTokens, input.modelMaxOutputTokens);
815
+ }
816
+ if (input.modelTemperature && Number.isFinite(input.modelTemperature)) {
817
+ config.temperature = input.modelTemperature;
818
+ }
819
+ const openrouterOptions = getOpenRouterOptions(agentConfig);
820
+ const maxToolSteps = agent.tools.maxToolSteps;
821
+ const memoryExtractionEnabled = agent.memory.extraction.enabled;
822
+ const isDaemon = process.env.DOTCLAW_DAEMON === '1';
823
+ const memoryExtractionAsync = agent.memory.extraction.async;
824
+ const memoryExtractionMaxMessages = agent.memory.extraction.maxMessages;
825
+ const memoryExtractionMaxOutputTokens = agent.memory.extraction.maxOutputTokens;
826
+ const memoryExtractScheduled = agent.memory.extractScheduled;
827
+ const memoryArchiveSync = agent.memory.archiveSync;
828
+ const plannerEnabled = agent.planner.enabled;
829
+ const plannerMode = String(agent.planner.mode || 'auto').toLowerCase();
830
+ const plannerMinTokens = agent.planner.minTokens;
831
+ const plannerTrigger = buildPlannerTrigger(agent.planner.triggerRegex);
832
+ const plannerModel = agent.models.planner;
833
+ const plannerMaxOutputTokens = agent.planner.maxOutputTokens;
834
+ const plannerTemperature = agent.planner.temperature;
835
+ const responseValidateEnabled = agent.responseValidation.enabled;
836
+ const responseValidateModel = agent.models.responseValidation;
837
+ const responseValidateMaxOutputTokens = agent.responseValidation.maxOutputTokens;
838
+ const responseValidateTemperature = agent.responseValidation.temperature;
839
+ const responseValidateMaxRetries = agent.responseValidation.maxRetries;
840
+ const responseValidateAllowToolCalls = agent.responseValidation.allowToolCalls;
841
+ const maxContextMessageTokens = agent.context.maxContextMessageTokens;
842
+ const streamingEnabled = Boolean(input.streaming?.enabled && typeof input.streaming?.draftId === 'number');
843
+ const streamingDraftId = streamingEnabled ? input.streaming?.draftId : undefined;
844
+ const streamingMinIntervalMs = Math.max(
845
+ 0,
846
+ Math.floor(
847
+ typeof input.streaming?.minIntervalMs === 'number'
848
+ ? input.streaming.minIntervalMs
849
+ : agent.streaming.minIntervalMs
850
+ )
851
+ );
852
+ const streamingMinChars = Math.max(
853
+ 1,
854
+ Math.floor(
855
+ typeof input.streaming?.minChars === 'number'
856
+ ? input.streaming.minChars
857
+ : agent.streaming.minChars
858
+ )
859
+ );
860
+
861
+ const openrouter = getCachedOpenRouter(apiKey, openrouterOptions);
862
+ const tokenEstimate = resolveTokenEstimate(input, agentConfig);
863
+ const availableGroups = loadAvailableGroups();
864
+ const claudeNotes = loadClaudeNotes();
865
+
866
+ const { ctx: sessionCtx, isNew } = createSessionContext(SESSION_ROOT, input.sessionId);
867
+ const toolCalls: ToolCallRecord[] = [];
868
+ let memoryItemsUpserted = 0;
869
+ let memoryItemsExtracted = 0;
870
+ const ipc = createIpcHandlers({
871
+ chatJid: input.chatJid,
872
+ groupFolder: input.groupFolder,
873
+ isMain: input.isMain
874
+ }, agent.ipc);
875
+ const tools = createTools({
876
+ chatJid: input.chatJid,
877
+ groupFolder: input.groupFolder,
878
+ isMain: input.isMain
879
+ }, agent, {
880
+ onToolCall: (call) => {
881
+ toolCalls.push(call);
882
+ },
883
+ policy: input.toolPolicy
884
+ });
885
+
886
+ let streamLastSentAt = 0;
887
+ let streamLastSentLength = 0;
888
+ const sendStreamUpdate = (text: string, force = false) => {
889
+ if (!streamingEnabled || !streamingDraftId) return;
890
+ if (!text || !text.trim()) return;
891
+ const now = Date.now();
892
+ if (!force) {
893
+ if (now - streamLastSentAt < streamingMinIntervalMs) return;
894
+ if (text.length - streamLastSentLength < streamingMinChars) return;
895
+ }
896
+ streamLastSentAt = now;
897
+ streamLastSentLength = text.length;
898
+ void ipc.sendDraft(text, streamingDraftId).catch(() => undefined);
899
+ };
900
+
901
+ if (process.env.DOTCLAW_SELF_CHECK === '1') {
902
+ try {
903
+ const details = await runSelfCheck({ model });
904
+ return {
905
+ status: 'success',
906
+ result: `Self-check passed: ${details.join(', ')}`,
907
+ newSessionId: isNew ? sessionCtx.sessionId : undefined
908
+ };
909
+ } catch (err) {
910
+ const errorMessage = err instanceof Error ? err.message : String(err);
911
+ log(`Self-check failed: ${errorMessage}`);
912
+ return {
913
+ status: 'error',
914
+ result: null,
915
+ newSessionId: isNew ? sessionCtx.sessionId : undefined,
916
+ error: errorMessage
917
+ };
918
+ }
919
+ }
920
+
921
+ let prompt = input.prompt;
922
+ if (input.isScheduledTask) {
923
+ 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}`;
924
+ }
925
+
926
+ appendHistory(sessionCtx, 'user', prompt);
927
+ let history = loadHistory(sessionCtx);
928
+
929
+ const tokenRatio = tokenEstimate.tokensPerChar > 0 ? (0.25 / tokenEstimate.tokensPerChar) : 1;
930
+ const adjustedRecentTokens = Math.max(1000, Math.floor(config.recentContextTokens * tokenRatio));
931
+
932
+ const totalTokens = history.reduce(
933
+ (sum, message) => sum + estimateTokensForModel(message.content, tokenEstimate.tokensPerChar) + tokenEstimate.tokensPerMessage,
934
+ 0
935
+ );
936
+ let { recentMessages, olderMessages } = splitRecentHistory(history, adjustedRecentTokens);
937
+
938
+ if (shouldCompact(totalTokens, config)) {
939
+ log(`Compacting history: ${totalTokens} tokens`);
940
+ archiveConversation(history, sessionCtx.state.summary || null, GROUP_DIR);
941
+
942
+ const summaryUpdate = await updateMemorySummary({
943
+ openrouter,
944
+ model: summaryModel,
945
+ existingSummary: sessionCtx.state.summary,
946
+ existingFacts: sessionCtx.state.facts,
947
+ newMessages: olderMessages,
948
+ maxOutputTokens: config.summaryMaxOutputTokens
949
+ });
950
+
951
+ if (summaryUpdate) {
952
+ sessionCtx.state.summary = summaryUpdate.summary;
953
+ sessionCtx.state.facts = summaryUpdate.facts;
954
+ sessionCtx.state.lastSummarySeq = olderMessages.length > 0
955
+ ? olderMessages[olderMessages.length - 1].seq
956
+ : sessionCtx.state.lastSummarySeq;
957
+ saveMemoryState(sessionCtx);
958
+
959
+ if (memoryArchiveSync) {
960
+ try {
961
+ const archiveItems: Array<Record<string, unknown>> = [];
962
+ if (summaryUpdate.summary) {
963
+ archiveItems.push({
964
+ scope: 'group',
965
+ type: 'archive',
966
+ content: `Conversation summary: ${summaryUpdate.summary}`,
967
+ importance: 0.6,
968
+ confidence: 0.7,
969
+ tags: ['summary', 'archive']
970
+ });
971
+ }
972
+ for (const fact of summaryUpdate.facts || []) {
973
+ if (!fact || typeof fact !== 'string') continue;
974
+ archiveItems.push({
975
+ scope: 'group',
976
+ type: 'fact',
977
+ content: fact,
978
+ importance: 0.7,
979
+ confidence: 0.7,
980
+ tags: ['fact', 'archive']
981
+ });
982
+ }
983
+ if (archiveItems.length > 0) {
984
+ await ipc.memoryUpsert({ items: archiveItems, source: 'compaction' });
985
+ memoryItemsUpserted += archiveItems.length;
986
+ }
987
+ } catch (err) {
988
+ log(`Memory archive sync failed: ${err instanceof Error ? err.message : String(err)}`);
989
+ }
990
+ }
991
+ }
992
+
993
+ writeHistory(sessionCtx, recentMessages);
994
+ history = recentMessages;
995
+ }
996
+
997
+ // Recompute split after possible compaction
998
+ ({ recentMessages, olderMessages } = splitRecentHistory(history, adjustedRecentTokens));
999
+
1000
+ const query = extractQueryFromPrompt(prompt);
1001
+ const sessionRecall = retrieveRelevantMemories({
1002
+ query,
1003
+ summary: sessionCtx.state.summary,
1004
+ facts: sessionCtx.state.facts,
1005
+ olderMessages,
1006
+ config
1007
+ });
1008
+ const sessionRecallCount = sessionRecall.length;
1009
+ const memoryRecallCount = input.memoryRecall ? input.memoryRecall.length : 0;
1010
+
1011
+ const sharedPromptDir = fs.existsSync(PROMPTS_DIR) ? PROMPTS_DIR : undefined;
1012
+ const taskPackResult = PROMPT_PACKS_ENABLED
1013
+ ? loadPromptPackWithCanary({ behavior: 'task-extraction', groupDir: GROUP_DIR, globalDir: GLOBAL_DIR, sharedDir: sharedPromptDir, canaryRate: PROMPT_PACKS_CANARY_RATE })
1014
+ : null;
1015
+ const responseQualityResult = PROMPT_PACKS_ENABLED
1016
+ ? loadPromptPackWithCanary({ behavior: 'response-quality', groupDir: GROUP_DIR, globalDir: GLOBAL_DIR, sharedDir: sharedPromptDir, canaryRate: PROMPT_PACKS_CANARY_RATE })
1017
+ : null;
1018
+ const toolCallingResult = PROMPT_PACKS_ENABLED
1019
+ ? loadPromptPackWithCanary({ behavior: 'tool-calling', groupDir: GROUP_DIR, globalDir: GLOBAL_DIR, sharedDir: sharedPromptDir, canaryRate: PROMPT_PACKS_CANARY_RATE })
1020
+ : null;
1021
+ const toolOutcomeResult = PROMPT_PACKS_ENABLED
1022
+ ? loadPromptPackWithCanary({ behavior: 'tool-outcome', groupDir: GROUP_DIR, globalDir: GLOBAL_DIR, sharedDir: sharedPromptDir, canaryRate: PROMPT_PACKS_CANARY_RATE })
1023
+ : null;
1024
+ const memoryPolicyResult = PROMPT_PACKS_ENABLED
1025
+ ? loadPromptPackWithCanary({ behavior: 'memory-policy', groupDir: GROUP_DIR, globalDir: GLOBAL_DIR, sharedDir: sharedPromptDir, canaryRate: PROMPT_PACKS_CANARY_RATE })
1026
+ : null;
1027
+ const memoryRecallResult = PROMPT_PACKS_ENABLED
1028
+ ? loadPromptPackWithCanary({ behavior: 'memory-recall', groupDir: GROUP_DIR, globalDir: GLOBAL_DIR, sharedDir: sharedPromptDir, canaryRate: PROMPT_PACKS_CANARY_RATE })
1029
+ : null;
1030
+
1031
+ const logPack = (label: string, result: { pack: PromptPack; source: string; isCanary?: boolean } | null) => {
1032
+ if (!result) return;
1033
+ const canaryNote = result.isCanary ? ' (canary)' : '';
1034
+ log(`Loaded prompt pack (${label}${canaryNote}): ${result.pack.name}@${result.pack.version}`);
1035
+ };
1036
+ logPack(taskPackResult?.source || 'unknown', taskPackResult);
1037
+ logPack(responseQualityResult?.source || 'unknown', responseQualityResult);
1038
+ logPack(toolCallingResult?.source || 'unknown', toolCallingResult);
1039
+ logPack(toolOutcomeResult?.source || 'unknown', toolOutcomeResult);
1040
+ logPack(memoryPolicyResult?.source || 'unknown', memoryPolicyResult);
1041
+ logPack(memoryRecallResult?.source || 'unknown', memoryRecallResult);
1042
+
1043
+ const promptPackVersions: Record<string, string> = {};
1044
+ if (taskPackResult) promptPackVersions['task-extraction'] = taskPackResult.pack.version;
1045
+ if (responseQualityResult) promptPackVersions['response-quality'] = responseQualityResult.pack.version;
1046
+ if (toolCallingResult) promptPackVersions['tool-calling'] = toolCallingResult.pack.version;
1047
+ if (toolOutcomeResult) promptPackVersions['tool-outcome'] = toolOutcomeResult.pack.version;
1048
+ if (memoryPolicyResult) promptPackVersions['memory-policy'] = memoryPolicyResult.pack.version;
1049
+ if (memoryRecallResult) promptPackVersions['memory-recall'] = memoryRecallResult.pack.version;
1050
+
1051
+ const buildInstructions = (planBlockValue: string) => buildSystemInstructions({
1052
+ assistantName,
1053
+ groupNotes: claudeNotes.group,
1054
+ globalNotes: claudeNotes.global,
1055
+ memorySummary: sessionCtx.state.summary,
1056
+ memoryFacts: sessionCtx.state.facts,
1057
+ sessionRecall,
1058
+ longTermRecall: input.memoryRecall || [],
1059
+ userProfile: input.userProfile ?? null,
1060
+ memoryStats: input.memoryStats,
1061
+ availableGroups,
1062
+ toolReliability: input.toolReliability,
1063
+ behaviorConfig: input.behaviorConfig,
1064
+ isScheduledTask: !!input.isScheduledTask,
1065
+ isBackgroundTask: !!input.isBackgroundTask,
1066
+ taskId: input.taskId,
1067
+ planBlock: planBlockValue,
1068
+ taskExtractionPack: taskPackResult?.pack || null,
1069
+ responseQualityPack: responseQualityResult?.pack || null,
1070
+ toolCallingPack: toolCallingResult?.pack || null,
1071
+ toolOutcomePack: toolOutcomeResult?.pack || null,
1072
+ memoryPolicyPack: memoryPolicyResult?.pack || null,
1073
+ memoryRecallPack: memoryRecallResult?.pack || null
1074
+ });
1075
+
1076
+ let planBlock = '';
1077
+ let instructions = buildInstructions(planBlock);
1078
+ let instructionsTokens = estimateTokensForModel(instructions, tokenEstimate.tokensPerChar);
1079
+ let maxContextTokens = Math.max(config.maxContextTokens - config.maxOutputTokens - instructionsTokens, 2000);
1080
+ let adjustedContextTokens = Math.max(1000, Math.floor(maxContextTokens * tokenRatio));
1081
+ let { recentMessages: plannerContextMessages } = splitRecentHistory(recentMessages, adjustedContextTokens, 6);
1082
+ plannerContextMessages = clampContextMessages(plannerContextMessages, tokenEstimate.tokensPerChar, maxContextMessageTokens);
1083
+
1084
+ if (shouldRunPlanner({
1085
+ enabled: plannerEnabled,
1086
+ mode: plannerMode,
1087
+ prompt,
1088
+ tokensPerChar: tokenEstimate.tokensPerChar,
1089
+ minTokens: plannerMinTokens,
1090
+ trigger: plannerTrigger
1091
+ })) {
1092
+ try {
1093
+ const plannerPrompt = buildPlannerPrompt(plannerContextMessages);
1094
+ const plannerResult = await openrouter.callModel({
1095
+ model: plannerModel,
1096
+ instructions: plannerPrompt.instructions,
1097
+ input: plannerPrompt.input,
1098
+ maxOutputTokens: plannerMaxOutputTokens,
1099
+ temperature: plannerTemperature
1100
+ });
1101
+ const plannerText = await plannerResult.getText();
1102
+ const plan = parsePlannerResponse(plannerText);
1103
+ if (plan) {
1104
+ planBlock = formatPlanBlock(plan);
1105
+ }
1106
+ } catch (err) {
1107
+ log(`Planner failed: ${err instanceof Error ? err.message : String(err)}`);
1108
+ }
1109
+ }
1110
+
1111
+ if (planBlock) {
1112
+ instructions = buildInstructions(planBlock);
1113
+ instructionsTokens = estimateTokensForModel(instructions, tokenEstimate.tokensPerChar);
1114
+ maxContextTokens = Math.max(config.maxContextTokens - config.maxOutputTokens - instructionsTokens, 2000);
1115
+ adjustedContextTokens = Math.max(1000, Math.floor(maxContextTokens * tokenRatio));
1116
+ ({ recentMessages: plannerContextMessages } = splitRecentHistory(recentMessages, adjustedContextTokens, 6));
1117
+ plannerContextMessages = clampContextMessages(plannerContextMessages, tokenEstimate.tokensPerChar, maxContextMessageTokens);
1118
+ }
1119
+
1120
+ const buildContext = (extraInstruction?: string) => {
1121
+ let resolvedInstructions = buildInstructions(planBlock);
1122
+ if (extraInstruction) {
1123
+ resolvedInstructions = `${resolvedInstructions}\n\n${extraInstruction}`;
1124
+ }
1125
+ const resolvedInstructionTokens = estimateTokensForModel(resolvedInstructions, tokenEstimate.tokensPerChar);
1126
+ const resolvedMaxContext = Math.max(config.maxContextTokens - config.maxOutputTokens - resolvedInstructionTokens, 2000);
1127
+ const resolvedAdjusted = Math.max(1000, Math.floor(resolvedMaxContext * tokenRatio));
1128
+ let { recentMessages: contextMessages } = splitRecentHistory(recentMessages, resolvedAdjusted, 6);
1129
+ contextMessages = clampContextMessages(contextMessages, tokenEstimate.tokensPerChar, maxContextMessageTokens);
1130
+ return {
1131
+ instructions: resolvedInstructions,
1132
+ instructionsTokens: resolvedInstructionTokens,
1133
+ contextMessages
1134
+ };
1135
+ };
1136
+
1137
+ let responseText = '';
1138
+ let completionTokens = 0;
1139
+ let promptTokens = 0;
1140
+ let modelToolCalls: Array<{ name: string }> = [];
1141
+
1142
+ let latencyMs: number | undefined;
1143
+ const runCompletion = async (extraInstruction?: string): Promise<{
1144
+ responseText: string;
1145
+ completionTokens: number;
1146
+ promptTokens: number;
1147
+ latencyMs?: number;
1148
+ modelToolCalls: Array<{ name: string }>;
1149
+ }> => {
1150
+ const { instructions: resolvedInstructions, instructionsTokens: resolvedInstructionTokens, contextMessages } = buildContext(extraInstruction);
1151
+ const resolvedPromptTokens = resolvedInstructionTokens
1152
+ + estimateMessagesTokens(contextMessages, tokenEstimate.tokensPerChar, tokenEstimate.tokensPerMessage)
1153
+ + tokenEstimate.tokensPerRequest;
1154
+
1155
+ log('Starting OpenRouter call...');
1156
+ const startedAt = Date.now();
1157
+ const callParams = {
1158
+ model,
1159
+ instructions: resolvedInstructions,
1160
+ input: messagesToOpenRouter(contextMessages),
1161
+ tools,
1162
+ stopWhen: stepCountIs(maxToolSteps),
1163
+ maxOutputTokens: config.maxOutputTokens,
1164
+ temperature: config.temperature,
1165
+ stream: streamingEnabled
1166
+ };
1167
+ const result = await openrouter.callModel(callParams as Parameters<typeof openrouter.callModel>[0]);
1168
+ const localLatencyMs = Date.now() - startedAt;
1169
+ const toolCallsFromModel = await result.getToolCalls();
1170
+ if (toolCallsFromModel.length > 0) {
1171
+ log(`Model made ${toolCallsFromModel.length} tool call(s): ${toolCallsFromModel.map(t => t.name).join(', ')}`);
1172
+ }
1173
+
1174
+ let localResponseText = '';
1175
+ let streamed = false;
1176
+ if (streamingEnabled && typeof (result as { getTextStream?: () => AsyncIterable<unknown> }).getTextStream === 'function') {
1177
+ try {
1178
+ const stream = (result as { getTextStream: () => AsyncIterable<unknown> }).getTextStream();
1179
+ for await (const chunk of stream) {
1180
+ const delta = typeof chunk === 'string'
1181
+ ? chunk
1182
+ : (typeof (chunk as { text?: unknown })?.text === 'string' ? (chunk as { text?: string }).text || '' : '');
1183
+ if (!delta) continue;
1184
+ localResponseText += delta;
1185
+ sendStreamUpdate(localResponseText);
1186
+ }
1187
+ if (localResponseText) {
1188
+ sendStreamUpdate(localResponseText, true);
1189
+ }
1190
+ streamed = true;
1191
+ } catch (err) {
1192
+ log(`Streaming failed, falling back to full response: ${err instanceof Error ? err.message : String(err)}`);
1193
+ }
1194
+ }
1195
+ if (!streamed || !localResponseText || !localResponseText.trim()) {
1196
+ localResponseText = await result.getText();
1197
+ if (localResponseText && localResponseText.trim()) {
1198
+ sendStreamUpdate(localResponseText, true);
1199
+ }
1200
+ }
1201
+ if (!localResponseText || !localResponseText.trim()) {
1202
+ if (toolCallsFromModel.length > 0) {
1203
+ 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".';
1204
+ }
1205
+ log(`Warning: Model returned empty/whitespace response. Raw length: ${localResponseText?.length ?? 0}, tool calls: ${toolCallsFromModel.length}`);
1206
+ } else {
1207
+ log(`Model returned text response (${localResponseText.length} chars)`);
1208
+ }
1209
+
1210
+ const localCompletionTokens = estimateTokensForModel(localResponseText || '', tokenEstimate.tokensPerChar);
1211
+ return {
1212
+ responseText: localResponseText,
1213
+ completionTokens: localCompletionTokens,
1214
+ promptTokens: resolvedPromptTokens,
1215
+ latencyMs: localLatencyMs,
1216
+ modelToolCalls: toolCallsFromModel
1217
+ };
1218
+ };
1219
+
1220
+ try {
1221
+ const firstAttempt = await runCompletion();
1222
+ responseText = firstAttempt.responseText;
1223
+ completionTokens = firstAttempt.completionTokens;
1224
+ promptTokens = firstAttempt.promptTokens;
1225
+ latencyMs = firstAttempt.latencyMs;
1226
+ modelToolCalls = firstAttempt.modelToolCalls;
1227
+
1228
+ const shouldValidate = responseValidateEnabled
1229
+ && (responseValidateAllowToolCalls || modelToolCalls.length === 0);
1230
+ if (shouldValidate) {
1231
+ let retriesLeft = responseValidateMaxRetries;
1232
+ while (true) {
1233
+ if (!responseValidateAllowToolCalls && modelToolCalls.length > 0) {
1234
+ break;
1235
+ }
1236
+ let validationResult: ResponseValidation | null = null;
1237
+ if (!responseText || !responseText.trim()) {
1238
+ validationResult = { verdict: 'fail', issues: ['Response was empty.'], missing: [] };
1239
+ } else {
1240
+ try {
1241
+ validationResult = await validateResponseQuality({
1242
+ openrouter,
1243
+ model: responseValidateModel,
1244
+ userPrompt: query,
1245
+ response: responseText,
1246
+ maxOutputTokens: responseValidateMaxOutputTokens,
1247
+ temperature: responseValidateTemperature
1248
+ });
1249
+ } catch (err) {
1250
+ log(`Response validation failed: ${err instanceof Error ? err.message : String(err)}`);
1251
+ }
1252
+ }
1253
+ if (!validationResult || validationResult.verdict === 'pass') {
1254
+ break;
1255
+ }
1256
+ if (retriesLeft <= 0) {
1257
+ break;
1258
+ }
1259
+ retriesLeft -= 1;
1260
+ log(`Response validation failed; retrying (${retriesLeft} retries left)`);
1261
+ streamLastSentAt = 0;
1262
+ streamLastSentLength = 0;
1263
+ const retryGuidance = buildRetryGuidance(validationResult);
1264
+ const retryAttempt = await runCompletion(retryGuidance);
1265
+ responseText = retryAttempt.responseText;
1266
+ completionTokens = retryAttempt.completionTokens;
1267
+ promptTokens = retryAttempt.promptTokens;
1268
+ latencyMs = retryAttempt.latencyMs;
1269
+ modelToolCalls = retryAttempt.modelToolCalls;
1270
+ }
1271
+ }
1272
+ } catch (err) {
1273
+ const errorMessage = err instanceof Error ? err.message : String(err);
1274
+ log(`Agent error: ${errorMessage}`);
1275
+ return {
1276
+ status: 'error',
1277
+ result: null,
1278
+ newSessionId: isNew ? sessionCtx.sessionId : undefined,
1279
+ error: errorMessage,
1280
+ model,
1281
+ prompt_pack_versions: Object.keys(promptPackVersions).length > 0 ? promptPackVersions : undefined,
1282
+ memory_summary: sessionCtx.state.summary,
1283
+ memory_facts: sessionCtx.state.facts,
1284
+ tokens_prompt: promptTokens,
1285
+ tokens_completion: completionTokens,
1286
+ memory_recall_count: memoryRecallCount,
1287
+ session_recall_count: sessionRecallCount,
1288
+ memory_items_upserted: memoryItemsUpserted,
1289
+ memory_items_extracted: memoryItemsExtracted,
1290
+ tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
1291
+ latency_ms: latencyMs
1292
+ };
1293
+ }
1294
+
1295
+ appendHistory(sessionCtx, 'assistant', responseText || '');
1296
+
1297
+ history = loadHistory(sessionCtx);
1298
+ const newMessages = history.filter(m => m.seq > sessionCtx.state.lastSummarySeq);
1299
+ if (newMessages.length >= config.summaryUpdateEveryMessages) {
1300
+ const summaryUpdate = await updateMemorySummary({
1301
+ openrouter,
1302
+ model: summaryModel,
1303
+ existingSummary: sessionCtx.state.summary,
1304
+ existingFacts: sessionCtx.state.facts,
1305
+ newMessages,
1306
+ maxOutputTokens: config.summaryMaxOutputTokens
1307
+ });
1308
+ if (summaryUpdate) {
1309
+ sessionCtx.state.summary = summaryUpdate.summary;
1310
+ sessionCtx.state.facts = summaryUpdate.facts;
1311
+ sessionCtx.state.lastSummarySeq = newMessages[newMessages.length - 1].seq;
1312
+ saveMemoryState(sessionCtx);
1313
+ }
1314
+ }
1315
+
1316
+ const runMemoryExtraction = async () => {
1317
+ const extractionMessages = history.slice(-memoryExtractionMaxMessages);
1318
+ if (extractionMessages.length === 0) return;
1319
+ const extractionPrompt = buildMemoryExtractionPrompt({
1320
+ assistantName,
1321
+ userId: input.userId,
1322
+ userName: input.userName,
1323
+ messages: extractionMessages,
1324
+ memoryPolicyPack: memoryPolicyResult?.pack || null
1325
+ });
1326
+ const extractionResult = await openrouter.callModel({
1327
+ model: memoryModel,
1328
+ instructions: extractionPrompt.instructions,
1329
+ input: extractionPrompt.input,
1330
+ maxOutputTokens: memoryExtractionMaxOutputTokens,
1331
+ temperature: 0.1
1332
+ });
1333
+ const extractionText = await extractionResult.getText();
1334
+ const extractedItems = parseMemoryExtraction(extractionText);
1335
+ if (extractedItems.length === 0) return;
1336
+
1337
+ const behaviorThreshold = typeof input.behaviorConfig?.memory_importance_threshold === 'number'
1338
+ ? Number(input.behaviorConfig?.memory_importance_threshold)
1339
+ : null;
1340
+ const normalizedItems = extractedItems
1341
+ .filter((item) => {
1342
+ if (behaviorThreshold === null) return true;
1343
+ const importance = typeof item.importance === 'number' ? item.importance : null;
1344
+ if (importance === null) return true;
1345
+ return importance >= behaviorThreshold;
1346
+ })
1347
+ .map((item) => {
1348
+ const scope = typeof item.scope === 'string' ? item.scope : '';
1349
+ const subject = item.subject_id;
1350
+ if (scope === 'user' && !subject && input.userId) {
1351
+ return { ...item, subject_id: input.userId };
1352
+ }
1353
+ return item;
1354
+ });
1355
+
1356
+ if (normalizedItems.length > 0) {
1357
+ await ipc.memoryUpsert({
1358
+ items: normalizedItems as unknown[],
1359
+ source: 'agent-extraction'
1360
+ });
1361
+ memoryItemsExtracted += normalizedItems.length;
1362
+ memoryItemsUpserted += normalizedItems.length;
1363
+ }
1364
+ };
1365
+
1366
+ if (memoryExtractionEnabled && (!input.isScheduledTask || memoryExtractScheduled)) {
1367
+ if (memoryExtractionAsync && isDaemon) {
1368
+ void runMemoryExtraction().catch(err => {
1369
+ log(`Memory extraction failed: ${err instanceof Error ? err.message : String(err)}`);
1370
+ });
1371
+ } else {
1372
+ try {
1373
+ await runMemoryExtraction();
1374
+ } catch (err) {
1375
+ log(`Memory extraction failed: ${err instanceof Error ? err.message : String(err)}`);
1376
+ }
1377
+ }
1378
+ }
1379
+
1380
+ // Normalize empty/whitespace-only responses to null
1381
+ const finalResult = responseText && responseText.trim() ? responseText : null;
1382
+
1383
+ return {
1384
+ status: 'success',
1385
+ result: finalResult,
1386
+ newSessionId: isNew ? sessionCtx.sessionId : undefined,
1387
+ model,
1388
+ prompt_pack_versions: Object.keys(promptPackVersions).length > 0 ? promptPackVersions : undefined,
1389
+ memory_summary: sessionCtx.state.summary,
1390
+ memory_facts: sessionCtx.state.facts,
1391
+ tokens_prompt: promptTokens,
1392
+ tokens_completion: completionTokens,
1393
+ memory_recall_count: memoryRecallCount,
1394
+ session_recall_count: sessionRecallCount,
1395
+ memory_items_upserted: memoryItemsUpserted,
1396
+ memory_items_extracted: memoryItemsExtracted,
1397
+ tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
1398
+ latency_ms: latencyMs
1399
+ };
1400
+ }
1401
+
1402
+ async function main(): Promise<void> {
1403
+ try {
1404
+ const stdinData = await readStdin();
1405
+ const input = JSON.parse(stdinData) as ContainerInput;
1406
+ const output = await runAgentOnce(input);
1407
+ writeOutput(output);
1408
+ } catch (err) {
1409
+ writeOutput({
1410
+ status: 'error',
1411
+ result: null,
1412
+ error: err instanceof Error ? err.message : String(err)
1413
+ });
1414
+ }
1415
+ }
1416
+
1417
+ const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
1418
+ if (isMain) {
1419
+ main().catch(err => {
1420
+ log(`Fatal error: ${err instanceof Error ? err.message : String(err)}`);
1421
+ writeOutput({
1422
+ status: 'error',
1423
+ result: null,
1424
+ error: err instanceof Error ? err.message : String(err)
1425
+ });
1426
+ process.exit(1);
1427
+ });
1428
+ }