skimpyclaw 0.3.14 → 0.4.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 (222) hide show
  1. package/README.md +47 -37
  2. package/dist/__tests__/adapter-types.test.d.ts +4 -0
  3. package/dist/__tests__/adapter-types.test.js +63 -0
  4. package/dist/__tests__/anthropic-adapter.test.d.ts +4 -0
  5. package/dist/__tests__/anthropic-adapter.test.js +264 -0
  6. package/dist/__tests__/api.test.js +0 -1
  7. package/dist/__tests__/cli.integration.test.js +2 -4
  8. package/dist/__tests__/cli.test.js +0 -1
  9. package/dist/__tests__/code-agents-notifications.test.js +137 -0
  10. package/dist/__tests__/code-agents-parser.test.js +19 -1
  11. package/dist/__tests__/code-agents-preflight.test.js +3 -28
  12. package/dist/__tests__/code-agents-utils.test.js +34 -9
  13. package/dist/__tests__/code-agents-worktrees.test.js +116 -0
  14. package/dist/__tests__/codex-adapter.test.js +184 -0
  15. package/dist/__tests__/codex-auth.test.js +66 -0
  16. package/dist/__tests__/codex-provider-gating.test.js +35 -0
  17. package/dist/__tests__/codex-unified-loop.test.js +111 -0
  18. package/dist/__tests__/config-security.test.js +127 -0
  19. package/dist/__tests__/config.test.js +23 -0
  20. package/dist/__tests__/context-manager.test.js +243 -164
  21. package/dist/__tests__/cron-run.test.js +250 -0
  22. package/dist/__tests__/cron.test.js +12 -38
  23. package/dist/__tests__/digests.test.js +67 -0
  24. package/dist/__tests__/discord-attachments.test.js +211 -0
  25. package/dist/__tests__/discord-docs.test.d.ts +1 -0
  26. package/dist/__tests__/discord-docs.test.js +27 -0
  27. package/dist/__tests__/discord-thread-agents.test.d.ts +1 -0
  28. package/dist/__tests__/discord-thread-agents.test.js +115 -0
  29. package/dist/__tests__/discord-thread-context.test.d.ts +1 -0
  30. package/dist/__tests__/discord-thread-context.test.js +42 -0
  31. package/dist/__tests__/doctor.formatters.test.js +4 -4
  32. package/dist/__tests__/doctor.index.test.js +1 -1
  33. package/dist/__tests__/doctor.runner.test.js +3 -15
  34. package/dist/__tests__/env-sanitizer.test.d.ts +1 -0
  35. package/dist/__tests__/env-sanitizer.test.js +45 -0
  36. package/dist/__tests__/exec-approval.test.js +61 -0
  37. package/dist/__tests__/fetch-tool.test.d.ts +1 -0
  38. package/dist/__tests__/fetch-tool.test.js +85 -0
  39. package/dist/__tests__/gateway-status-auth.test.d.ts +1 -0
  40. package/dist/__tests__/gateway-status-auth.test.js +72 -0
  41. package/dist/__tests__/heartbeat.test.js +3 -3
  42. package/dist/__tests__/interactive-sessions.test.d.ts +1 -0
  43. package/dist/__tests__/interactive-sessions.test.js +96 -0
  44. package/dist/__tests__/langfuse.test.js +6 -18
  45. package/dist/__tests__/model-selection.test.js +3 -4
  46. package/dist/__tests__/providers-init.test.js +2 -8
  47. package/dist/__tests__/providers-routing.test.js +1 -1
  48. package/dist/__tests__/providers-utils.test.js +13 -3
  49. package/dist/__tests__/sessions.test.js +14 -10
  50. package/dist/__tests__/setup.test.js +12 -29
  51. package/dist/__tests__/skills.test.js +10 -7
  52. package/dist/__tests__/stream-formatter.test.d.ts +1 -0
  53. package/dist/__tests__/stream-formatter.test.js +114 -0
  54. package/dist/__tests__/token-efficiency.test.js +131 -15
  55. package/dist/__tests__/tool-loop.test.d.ts +4 -0
  56. package/dist/__tests__/tool-loop.test.js +505 -0
  57. package/dist/__tests__/tools.test.js +101 -276
  58. package/dist/__tests__/utils.test.d.ts +1 -0
  59. package/dist/__tests__/utils.test.js +14 -0
  60. package/dist/__tests__/voice.test.js +21 -0
  61. package/dist/agent.js +35 -4
  62. package/dist/api.js +113 -37
  63. package/dist/channels/discord/attachments.d.ts +50 -0
  64. package/dist/channels/discord/attachments.js +137 -0
  65. package/dist/channels/discord/delegation.d.ts +5 -0
  66. package/dist/channels/discord/delegation.js +136 -0
  67. package/dist/channels/discord/handlers.js +694 -7
  68. package/dist/channels/discord/index.d.ts +16 -1
  69. package/dist/channels/discord/index.js +64 -1
  70. package/dist/channels/discord/thread-agents.d.ts +54 -0
  71. package/dist/channels/discord/thread-agents.js +323 -0
  72. package/dist/channels/discord/threads.d.ts +58 -0
  73. package/dist/channels/discord/threads.js +192 -0
  74. package/dist/channels/discord/types.js +4 -2
  75. package/dist/channels/discord/utils.d.ts +16 -0
  76. package/dist/channels/discord/utils.js +86 -6
  77. package/dist/channels/telegram/index.d.ts +1 -1
  78. package/dist/channels/telegram/types.js +1 -1
  79. package/dist/channels/telegram/utils.js +9 -3
  80. package/dist/channels.d.ts +1 -1
  81. package/dist/cli.js +20 -400
  82. package/dist/code-agents/executor.d.ts +1 -1
  83. package/dist/code-agents/executor.js +101 -45
  84. package/dist/code-agents/index.d.ts +2 -7
  85. package/dist/code-agents/index.js +111 -80
  86. package/dist/code-agents/interactive-resume.d.ts +6 -0
  87. package/dist/code-agents/interactive-resume.js +98 -0
  88. package/dist/code-agents/interactive-sessions.d.ts +20 -0
  89. package/dist/code-agents/interactive-sessions.js +132 -0
  90. package/dist/code-agents/parser.js +5 -1
  91. package/dist/code-agents/registry.d.ts +7 -1
  92. package/dist/code-agents/registry.js +11 -23
  93. package/dist/code-agents/stream-formatter.d.ts +8 -0
  94. package/dist/code-agents/stream-formatter.js +92 -0
  95. package/dist/code-agents/types.d.ts +16 -24
  96. package/dist/code-agents/utils.d.ts +35 -11
  97. package/dist/code-agents/utils.js +349 -95
  98. package/dist/code-agents/worktrees.d.ts +37 -0
  99. package/dist/code-agents/worktrees.js +116 -0
  100. package/dist/config.d.ts +2 -4
  101. package/dist/config.js +123 -23
  102. package/dist/cron.d.ts +1 -6
  103. package/dist/cron.js +175 -82
  104. package/dist/dashboard/assets/index-B345aOO-.js +65 -0
  105. package/dist/dashboard/assets/index-ZWK4dalJ.css +1 -0
  106. package/dist/dashboard/index.html +2 -2
  107. package/dist/digests.d.ts +1 -0
  108. package/dist/digests.js +132 -42
  109. package/dist/doctor/checks.d.ts +0 -3
  110. package/dist/doctor/checks.js +1 -108
  111. package/dist/doctor/runner.js +1 -4
  112. package/dist/env-sanitizer.d.ts +2 -0
  113. package/dist/env-sanitizer.js +61 -0
  114. package/dist/exec-approval.d.ts +11 -1
  115. package/dist/exec-approval.js +17 -4
  116. package/dist/gateway.d.ts +3 -1
  117. package/dist/gateway.js +17 -7
  118. package/dist/heartbeat.js +1 -6
  119. package/dist/langfuse.js +3 -29
  120. package/dist/model-selection.js +3 -1
  121. package/dist/providers/adapter.d.ts +118 -0
  122. package/dist/providers/adapter.js +6 -0
  123. package/dist/providers/adapters/anthropic-adapter.d.ts +22 -0
  124. package/dist/providers/adapters/anthropic-adapter.js +204 -0
  125. package/dist/providers/adapters/codex-adapter.d.ts +26 -0
  126. package/dist/providers/adapters/codex-adapter.js +203 -0
  127. package/dist/providers/anthropic.d.ts +1 -0
  128. package/dist/providers/anthropic.js +10 -272
  129. package/dist/providers/codex.d.ts +21 -0
  130. package/dist/providers/codex.js +149 -330
  131. package/dist/providers/content.d.ts +1 -1
  132. package/dist/providers/content.js +2 -2
  133. package/dist/providers/context-manager.d.ts +18 -6
  134. package/dist/providers/context-manager.js +199 -223
  135. package/dist/providers/index.d.ts +9 -1
  136. package/dist/providers/index.js +73 -64
  137. package/dist/providers/loop-utils.d.ts +20 -0
  138. package/dist/providers/loop-utils.js +30 -0
  139. package/dist/providers/tool-loop.d.ts +12 -0
  140. package/dist/providers/tool-loop.js +251 -0
  141. package/dist/providers/utils.d.ts +19 -3
  142. package/dist/providers/utils.js +100 -29
  143. package/dist/secure-store.d.ts +8 -0
  144. package/dist/secure-store.js +80 -0
  145. package/dist/service.js +3 -28
  146. package/dist/sessions.d.ts +3 -0
  147. package/dist/sessions.js +147 -18
  148. package/dist/setup-templates.js +13 -25
  149. package/dist/setup.d.ts +10 -6
  150. package/dist/setup.js +84 -292
  151. package/dist/skills.js +3 -11
  152. package/dist/tools/agent-delegation.d.ts +19 -0
  153. package/dist/tools/agent-delegation.js +49 -0
  154. package/dist/tools/bash-tool.js +89 -34
  155. package/dist/tools/definitions.d.ts +199 -302
  156. package/dist/tools/definitions.js +70 -123
  157. package/dist/tools/execute-context.d.ts +13 -4
  158. package/dist/tools/fetch-tool.js +109 -13
  159. package/dist/tools/file-tools.js +7 -1
  160. package/dist/tools.d.ts +7 -7
  161. package/dist/tools.js +133 -151
  162. package/dist/types.d.ts +37 -30
  163. package/dist/utils.js +4 -6
  164. package/dist/voice.d.ts +1 -1
  165. package/dist/voice.js +17 -4
  166. package/package.json +33 -23
  167. package/templates/TOOLS.md +0 -27
  168. package/dist/__tests__/audit.test.js +0 -122
  169. package/dist/__tests__/code-agents-orchestrator.test.js +0 -216
  170. package/dist/__tests__/code-agents-sandbox.test.js +0 -163
  171. package/dist/__tests__/orchestrator.test.js +0 -425
  172. package/dist/__tests__/sandbox-bridge.test.js +0 -116
  173. package/dist/__tests__/sandbox-manager.test.js +0 -144
  174. package/dist/__tests__/sandbox-mount-security.test.js +0 -139
  175. package/dist/__tests__/sandbox-runtime.test.js +0 -176
  176. package/dist/__tests__/subagent.test.js +0 -240
  177. package/dist/__tests__/telegram.test.js +0 -42
  178. package/dist/code-agents/orchestrator.d.ts +0 -29
  179. package/dist/code-agents/orchestrator.js +0 -694
  180. package/dist/code-agents/worktree.d.ts +0 -40
  181. package/dist/code-agents/worktree.js +0 -215
  182. package/dist/dashboard/assets/index-BoTHPby4.js +0 -65
  183. package/dist/dashboard/assets/index-D4mufvBg.css +0 -1
  184. package/dist/dashboard.d.ts +0 -8
  185. package/dist/dashboard.js +0 -4071
  186. package/dist/discord.d.ts +0 -8
  187. package/dist/discord.js +0 -792
  188. package/dist/mcp-context-a8c.d.ts +0 -13
  189. package/dist/mcp-context-a8c.js +0 -34
  190. package/dist/orchestrator.d.ts +0 -15
  191. package/dist/orchestrator.js +0 -676
  192. package/dist/providers/openai.d.ts +0 -10
  193. package/dist/providers/openai.js +0 -355
  194. package/dist/sandbox/bridge.d.ts +0 -5
  195. package/dist/sandbox/bridge.js +0 -63
  196. package/dist/sandbox/index.d.ts +0 -5
  197. package/dist/sandbox/index.js +0 -4
  198. package/dist/sandbox/manager.d.ts +0 -7
  199. package/dist/sandbox/manager.js +0 -100
  200. package/dist/sandbox/mount-security.d.ts +0 -12
  201. package/dist/sandbox/mount-security.js +0 -122
  202. package/dist/sandbox/runtime.d.ts +0 -39
  203. package/dist/sandbox/runtime.js +0 -192
  204. package/dist/sandbox-utils.d.ts +0 -6
  205. package/dist/sandbox-utils.js +0 -36
  206. package/dist/subagent.d.ts +0 -19
  207. package/dist/subagent.js +0 -407
  208. package/dist/telegram.d.ts +0 -2
  209. package/dist/telegram.js +0 -11
  210. package/dist/tools/browser-tool.d.ts +0 -3
  211. package/dist/tools/browser-tool.js +0 -266
  212. package/sandbox/Dockerfile +0 -40
  213. /package/dist/__tests__/{audit.test.d.ts → code-agents-notifications.test.d.ts} +0 -0
  214. /package/dist/__tests__/{code-agents-orchestrator.test.d.ts → code-agents-worktrees.test.d.ts} +0 -0
  215. /package/dist/__tests__/{code-agents-sandbox.test.d.ts → codex-adapter.test.d.ts} +0 -0
  216. /package/dist/__tests__/{orchestrator.test.d.ts → codex-auth.test.d.ts} +0 -0
  217. /package/dist/__tests__/{sandbox-bridge.test.d.ts → codex-provider-gating.test.d.ts} +0 -0
  218. /package/dist/__tests__/{sandbox-manager.test.d.ts → codex-unified-loop.test.d.ts} +0 -0
  219. /package/dist/__tests__/{sandbox-mount-security.test.d.ts → config-security.test.d.ts} +0 -0
  220. /package/dist/__tests__/{sandbox-runtime.test.d.ts → cron-run.test.d.ts} +0 -0
  221. /package/dist/__tests__/{subagent.test.d.ts → digests.test.d.ts} +0 -0
  222. /package/dist/__tests__/{telegram.test.d.ts → discord-attachments.test.d.ts} +0 -0
@@ -1,355 +0,0 @@
1
- // OpenAI-Compatible Provider (OpenAI, OpenRouter, Groq, etc.)
2
- import { startObservation } from '@langfuse/tracing';
3
- import { stripProvider, toOpenAITools, truncateToolResult } from './utils.js';
4
- import { compactOpenAIMessages } from './context-manager.js';
5
- import { toOpenAIContent } from './content.js';
6
- import { toUsageDetails, toCostDetails } from './observability.js';
7
- import { getToolDefinitions, executeTool } from '../tools.js';
8
- import { toErrorMessage } from '../utils.js';
9
- import { ToolCallGuard } from './tool-guard.js';
10
- import { addEvent } from '../audit.js';
11
- import { buildUsageRecord, recordUsage } from '../usage.js';
12
- // Map of provider name → OpenAI client
13
- const openaiClients = new Map();
14
- export function addOpenAIClient(name, client) {
15
- openaiClients.set(name, client);
16
- }
17
- export function getOpenAIClient(name) {
18
- return openaiClients.get(name);
19
- }
20
- export function hasOpenAIClient(name) {
21
- return openaiClients.has(name);
22
- }
23
- export function clearOpenAIClients() {
24
- openaiClients.clear();
25
- }
26
- export function resetOpenAIProviderState() {
27
- openaiClients.clear();
28
- }
29
- export function isOpenAIAvailable(provider) {
30
- return openaiClients.has(provider);
31
- }
32
- const LANGFUSE_APP_NAME = 'skimpyclaw';
33
- function recordOpenAIUsage(params) {
34
- const usage = params.usage;
35
- let inputTokens = typeof usage?.prompt_tokens === 'number'
36
- ? usage.prompt_tokens
37
- : (typeof usage?.input_tokens === 'number' ? usage.input_tokens : 0);
38
- const outputTokens = typeof usage?.completion_tokens === 'number'
39
- ? usage.completion_tokens
40
- : (typeof usage?.output_tokens === 'number' ? usage.output_tokens : 0);
41
- // Some OpenAI-compatible providers only return total_tokens.
42
- if (inputTokens === 0 && outputTokens === 0 && typeof usage?.total_tokens === 'number') {
43
- inputTokens = usage.total_tokens;
44
- }
45
- const cost = toCostDetails(params.model, usage);
46
- recordUsage(buildUsageRecord({
47
- model: params.model,
48
- provider: params.provider,
49
- inputTokens,
50
- outputTokens,
51
- inputCost: cost?.input ?? 0,
52
- outputCost: cost?.output ?? 0,
53
- totalCost: cost?.total ?? 0,
54
- trigger: params.trigger || 'api',
55
- agentId: params.agentId,
56
- }));
57
- }
58
- async function startGenerationObservation(name, attributes) {
59
- const { isLangfuseEnabled } = await import('../langfuse.js');
60
- if (!isLangfuseEnabled())
61
- return null;
62
- attributes.metadata = { app: LANGFUSE_APP_NAME, ...attributes.metadata };
63
- return startObservation(name, attributes, { asType: 'generation' });
64
- }
65
- export async function chatOpenAI(params, provider) {
66
- const client = openaiClients.get(provider);
67
- if (!client) {
68
- throw new Error(`OpenAI client not initialized for provider: ${provider}`);
69
- }
70
- const { messages, options, config } = params;
71
- const modelId = stripProvider(options.model, openaiClients);
72
- const providerBaseURL = config.models.providers[provider]?.baseURL || '';
73
- const isKimiLike = providerBaseURL.includes('kimi.com') || providerBaseURL.includes('moonshot.ai');
74
- const kimiRequestExtras = isKimiLike
75
- ? { extra_body: { interleaved: { field: 'reasoning_content' } } }
76
- : {};
77
- const openaiMessages = messages.map(m => ({
78
- role: m.role,
79
- content: toOpenAIContent(m.content),
80
- }));
81
- const genObs = await startGenerationObservation(`${provider}:${modelId}`, {
82
- input: { messages: openaiMessages },
83
- model: modelId,
84
- modelParameters: {
85
- max_tokens: options.maxTokens || 4096,
86
- temperature: options.temperature,
87
- },
88
- metadata: { provider },
89
- });
90
- try {
91
- const response = await client.chat.completions.create({
92
- model: modelId,
93
- messages: openaiMessages,
94
- max_tokens: options.maxTokens || 4096,
95
- temperature: options.temperature,
96
- ...kimiRequestExtras,
97
- });
98
- let content = response.choices[0]?.message?.content || '';
99
- // Strip <think>...</think> reasoning blocks (e.g. MiniMax M2.x)
100
- content = content.replace(/<think>[\s\S]*?<\/think>\s*/g, '').trim();
101
- recordOpenAIUsage({ model: modelId, provider, usage: response.usage, trigger: 'api' });
102
- genObs?.update({
103
- output: response.choices[0]?.message,
104
- usageDetails: toUsageDetails(response.usage),
105
- costDetails: toCostDetails(modelId, response.usage),
106
- });
107
- genObs?.end();
108
- return content;
109
- }
110
- catch (err) {
111
- const errorMessage = toErrorMessage(err);
112
- genObs?.update({ level: 'ERROR', statusMessage: errorMessage, output: { error: errorMessage } });
113
- genObs?.end();
114
- throw err;
115
- }
116
- }
117
- export async function chatWithToolsOpenAI(params, provider) {
118
- const client = openaiClients.get(provider);
119
- if (!client) {
120
- throw new Error(`OpenAI client not initialized for provider: ${provider}`);
121
- }
122
- const { messages, options, config, toolConfig, toolContext } = params;
123
- const modelId = stripProvider(options.model, openaiClients);
124
- const maxIterations = toolConfig.maxIterations || 20;
125
- // Resolve tools once at start
126
- const includeSpawn = !!(toolContext?.fullConfig && (toolContext?.chatId || toolContext?.isCronJob));
127
- const toolDefs = await getToolDefinitions(toolConfig, {
128
- includeAgentTools: includeSpawn,
129
- includeMcp: false,
130
- projects: toolContext?.fullConfig?.projects
131
- });
132
- const openaiTools = toOpenAITools(toolDefs);
133
- // Kimi requires interleaved reasoning content when replaying tool calls.
134
- const providerBaseURL = config.models.providers[provider]?.baseURL || '';
135
- const requiresReasoningContent = providerBaseURL.includes('kimi.com') || providerBaseURL.includes('moonshot.ai');
136
- const kimiRequestExtras = requiresReasoningContent
137
- ? { extra_body: { interleaved: { field: 'reasoning_content' } } }
138
- : {};
139
- // Build messages for OpenAI format — preserve images for vision models
140
- const apiMessages = messages.map(m => ({
141
- role: m.role,
142
- content: toOpenAIContent(m.content),
143
- }));
144
- const toolLog = [];
145
- // Guard: spin detection, no-progress detection, token budget
146
- const guard = new ToolCallGuard(toolConfig.maxTurnTokens);
147
- for (let i = 0; i < maxIterations; i++) {
148
- // Check abort signal
149
- if (toolContext?.abortSignal?.aborted) {
150
- return {
151
- response: `[Cancelled after ${toolLog.length} tool calls]`,
152
- toolCalls: toolLog,
153
- };
154
- }
155
- // Compact old tool results if context is growing large
156
- const compactionResult = await compactOpenAIMessages(apiMessages, toolConfig.contextManagement, i + 1, config);
157
- const messagesForApi = compactionResult.messages;
158
- if (compactionResult.compacted) {
159
- const method = compactionResult.method === 'llm' ? 'LLM summary' : 'truncation';
160
- const detail = `~${Math.round((compactionResult.tokensBefore || 0) / 1000)}k → ~${Math.round((compactionResult.tokensAfter || 0) / 1000)}k tokens`;
161
- toolLog.push(`[context compacted via ${method}: ${detail}]`);
162
- }
163
- console.log(`[agent:openai-tools] Iteration ${i + 1}/${maxIterations} (provider: ${provider}, model: ${modelId})`);
164
- const genObs = await startGenerationObservation(`${provider}:${modelId}`, {
165
- input: { messages: apiMessages },
166
- model: modelId,
167
- modelParameters: {
168
- max_tokens: options.maxTokens || 4096,
169
- temperature: options.temperature,
170
- },
171
- metadata: { provider, iteration: i + 1 },
172
- });
173
- let completion;
174
- try {
175
- completion = await client.chat.completions.create({
176
- model: modelId,
177
- messages: messagesForApi,
178
- tools: openaiTools,
179
- max_tokens: options.maxTokens || 4096,
180
- temperature: options.temperature,
181
- ...kimiRequestExtras,
182
- });
183
- recordOpenAIUsage({
184
- model: modelId,
185
- provider,
186
- usage: completion.usage,
187
- trigger: toolContext?.trigger || 'api',
188
- agentId: toolContext?.agentId,
189
- });
190
- genObs?.update({
191
- output: completion.choices[0]?.message,
192
- usageDetails: toUsageDetails(completion.usage),
193
- costDetails: toCostDetails(modelId, completion.usage),
194
- });
195
- genObs?.end();
196
- // Guard: track token usage (stats only, no enforcement)
197
- guard.recordTokens(completion.usage?.prompt_tokens ?? 0, completion.usage?.completion_tokens ?? 0);
198
- }
199
- catch (err) {
200
- const errorMessage = toErrorMessage(err);
201
- genObs?.update({ level: 'ERROR', statusMessage: errorMessage, output: { error: errorMessage } });
202
- genObs?.end();
203
- throw err;
204
- }
205
- const message = completion.choices[0]?.message;
206
- if (!message) {
207
- return {
208
- response: '[No response from model]',
209
- toolCalls: toolLog,
210
- usage: {
211
- prompt_tokens: completion.usage?.prompt_tokens ?? 0,
212
- completion_tokens: completion.usage?.completion_tokens ?? 0,
213
- total_tokens: completion.usage?.total_tokens ?? 0,
214
- },
215
- cost: toCostDetails(modelId, completion.usage),
216
- };
217
- }
218
- // No tool calls — return the text response
219
- if (completion.choices[0]?.finish_reason !== 'tool_calls' || !message.tool_calls?.length) {
220
- let content = message.content || '';
221
- // Strip <think>...</think> reasoning blocks (e.g. MiniMax M2.x)
222
- content = content.replace(/<think>[\s\S]*?<\/think>\s*/g, '').trim();
223
- if (!content && toolLog.length > 0) {
224
- content = `[Completed with ${toolLog.length} tool calls, no text response]`;
225
- }
226
- return {
227
- response: content,
228
- toolCalls: toolLog,
229
- usage: {
230
- prompt_tokens: completion.usage?.prompt_tokens ?? 0,
231
- completion_tokens: completion.usage?.completion_tokens ?? 0,
232
- total_tokens: completion.usage?.total_tokens ?? 0,
233
- },
234
- cost: toCostDetails(modelId, completion.usage),
235
- };
236
- }
237
- // Append assistant message with tool_calls to conversation.
238
- // Kimi requires reasoning_content when thinking mode is enabled.
239
- const assistantToolCallMessage = {
240
- role: 'assistant',
241
- content: message.content ?? null,
242
- tool_calls: message.tool_calls,
243
- };
244
- const rawReasoning = message.reasoning_content
245
- ?? message.additional_kwargs?.reasoning_content
246
- ?? message.reasoning?.content;
247
- if (rawReasoning !== undefined && rawReasoning !== null) {
248
- assistantToolCallMessage.reasoning_content = Array.isArray(rawReasoning)
249
- ? rawReasoning.join('\n')
250
- : String(rawReasoning);
251
- }
252
- else if (requiresReasoningContent) {
253
- // Some Kimi responses omit reasoning_content despite thinking mode.
254
- // Send a placeholder to satisfy strict tool-call replay validation.
255
- assistantToolCallMessage.reasoning_content = ' ';
256
- }
257
- apiMessages.push(assistantToolCallMessage);
258
- // Execute each tool call
259
- for (const toolCall of message.tool_calls) {
260
- const fnName = toolCall.function.name;
261
- if (fnName.startsWith('$')) {
262
- const unsupported = `Provider-native tool "${fnName}" is not supported in this runtime.`;
263
- console.warn(`[agent:openai-tools] ${unsupported}`);
264
- apiMessages.push({
265
- role: 'tool',
266
- tool_call_id: toolCall.id,
267
- content: unsupported,
268
- });
269
- toolLog.push(`${fnName} [SKIPPED: provider-native tool unsupported]`);
270
- continue;
271
- }
272
- let args;
273
- try {
274
- args = JSON.parse(toolCall.function.arguments || '{}');
275
- }
276
- catch {
277
- args = {};
278
- }
279
- const inputStr = JSON.stringify(args).slice(0, 200);
280
- console.log(`[agent:openai-tools] -> ${fnName}(${inputStr})`);
281
- // Guard: spin detection
282
- const guardResult = guard.recordCall(fnName, args);
283
- if (guardResult.warning)
284
- console.warn(`[agent:openai-tools:guard] ${guardResult.warning}`);
285
- if (guardResult.blocked) {
286
- apiMessages.push({
287
- role: 'tool',
288
- tool_call_id: toolCall.id,
289
- content: guardResult.warning || 'Blocked: repeated identical call',
290
- });
291
- toolLog.push(`${fnName} [BLOCKED: spin detected]`);
292
- continue;
293
- }
294
- const { isLangfuseEnabled } = await import('../langfuse.js');
295
- const toolObs = isLangfuseEnabled()
296
- ? startObservation(`tool:${fnName}`, { input: args, metadata: { app: LANGFUSE_APP_NAME, tool: fnName } }, { asType: 'tool' })
297
- : null;
298
- const toolStart = Date.now();
299
- try {
300
- const result = await executeTool(fnName, args, toolConfig, toolContext) || '';
301
- const truncatedResult = truncateToolResult(result);
302
- const resultPreview = result.slice(0, 200) + (result.length > 200 ? '...' : '');
303
- console.log(`[agent:openai-tools] <- ${resultPreview}`);
304
- toolLog.push(`${fnName}(${inputStr}) → ${resultPreview}`);
305
- toolObs?.update({ output: result });
306
- toolObs?.end();
307
- if (toolContext?.auditTraceId) {
308
- addEvent(toolContext.auditTraceId, {
309
- type: 'tool_use',
310
- summary: `${fnName}(${inputStr})`,
311
- durationMs: Date.now() - toolStart,
312
- });
313
- }
314
- // Add tool result in OpenAI format
315
- apiMessages.push({
316
- role: 'tool',
317
- tool_call_id: toolCall.id,
318
- content: truncatedResult,
319
- });
320
- // Guard: no-progress detection
321
- const progressResult = guard.recordResult(result);
322
- if (progressResult.nudge) {
323
- console.warn(`[agent:openai-tools:guard] ${progressResult.nudge}`);
324
- apiMessages[apiMessages.length - 1].content += `\n\n[System: ${progressResult.nudge}]`;
325
- }
326
- }
327
- catch (err) {
328
- const errorMessage = toErrorMessage(err);
329
- toolObs?.update({ level: 'ERROR', statusMessage: errorMessage, output: { error: errorMessage } });
330
- toolObs?.end();
331
- if (toolContext?.auditTraceId) {
332
- addEvent(toolContext.auditTraceId, {
333
- type: 'tool_error',
334
- summary: `${fnName} error: ${errorMessage.slice(0, 150)}`,
335
- durationMs: Date.now() - toolStart,
336
- });
337
- }
338
- throw err;
339
- }
340
- }
341
- }
342
- console.warn(`[agent:openai-tools] Max iterations (${maxIterations}) reached`);
343
- return {
344
- response: '[Tool use loop reached maximum iterations]',
345
- toolCalls: toolLog,
346
- };
347
- }
348
- /** Build UsageDetails from OpenAI usage response */
349
- function buildOpenAIUsageDetails(usage) {
350
- return {
351
- prompt_tokens: usage?.prompt_tokens ?? 0,
352
- completion_tokens: usage?.completion_tokens ?? 0,
353
- total_tokens: usage?.total_tokens ?? 0,
354
- };
355
- }
@@ -1,5 +0,0 @@
1
- export declare function sandboxBash(containerName: string, command: string, cwd?: string, timeout?: number): Promise<string>;
2
- export declare function sandboxReadFile(containerName: string, path: string): Promise<string>;
3
- export declare function sandboxWriteFile(containerName: string, path: string, content: string): Promise<string>;
4
- export declare function sandboxListDir(containerName: string, path: string): Promise<string>;
5
- export declare function sandboxGlob(containerName: string, base: string, pattern: string): Promise<string>;
@@ -1,63 +0,0 @@
1
- import { execInContainer } from './runtime.js';
2
- const MAX_BASH_OUTPUT = 50 * 1024; // 50KB
3
- const MAX_READ_OUTPUT = 100 * 1024; // 100KB
4
- function truncate(s, maxBytes) {
5
- if (Buffer.byteLength(s) <= maxBytes)
6
- return s;
7
- const truncated = Buffer.from(s).subarray(0, maxBytes).toString('utf-8');
8
- return truncated + '\n... (output truncated)';
9
- }
10
- /**
11
- * Shell-escape a string for use inside sh -c '...'
12
- * NOTE: execInContainer already wraps in sh -c, so callers pass
13
- * args as individual tokens. The runtime joins them with spaces
14
- * and does basic single-quote escaping. For bash commands we pass
15
- * the whole command as a single arg string.
16
- */
17
- function shellEscape(s) {
18
- return "'" + s.replace(/'/g, "'\\''") + "'";
19
- }
20
- export async function sandboxBash(containerName, command, cwd, timeout) {
21
- // Pass the full command as a single string so sh -c runs it verbatim
22
- const shellCmd = cwd
23
- ? `cd ${shellEscape(cwd)} && ${command}`
24
- : command;
25
- const result = await execInContainer(containerName, [shellCmd], { timeout: timeout ?? 30_000 });
26
- let output = [result.stdout, result.stderr].filter(Boolean).join('\n');
27
- output = truncate(output, MAX_BASH_OUTPUT);
28
- if (result.exitCode !== 0) {
29
- output += `\n[exit code: ${result.exitCode}]`;
30
- }
31
- return output || '(no output)';
32
- }
33
- export async function sandboxReadFile(containerName, path) {
34
- // Pass as a single command string to avoid double-escaping
35
- const result = await execInContainer(containerName, [`cat -- ${shellEscape(path)}`]);
36
- if (result.exitCode !== 0) {
37
- throw new Error(`Failed to read ${path}: ${result.stderr}`);
38
- }
39
- return truncate(result.stdout, MAX_READ_OUTPUT);
40
- }
41
- export async function sandboxWriteFile(containerName, path, content) {
42
- // mkdir -p for parent dir, then write via stdin
43
- const result = await execInContainer(containerName, [`mkdir -p $(dirname ${shellEscape(path)}) && cat > ${shellEscape(path)}`], { stdin: content });
44
- if (result.exitCode !== 0) {
45
- throw new Error(`Failed to write ${path}: ${result.stderr}`);
46
- }
47
- const bytes = Buffer.byteLength(content);
48
- return `Written: ${path} (${bytes} bytes)`;
49
- }
50
- export async function sandboxListDir(containerName, path) {
51
- const result = await execInContainer(containerName, [`ls -la ${shellEscape(path)}`]);
52
- if (result.exitCode !== 0) {
53
- throw new Error(`Failed to list ${path}: ${result.stderr}`);
54
- }
55
- return result.stdout;
56
- }
57
- export async function sandboxGlob(containerName, base, pattern) {
58
- const result = await execInContainer(containerName, [`find ${shellEscape(base)} -name ${shellEscape(pattern)} -maxdepth 10 -type f`]);
59
- if (result.exitCode !== 0) {
60
- throw new Error(`Failed to glob ${base}/${pattern}: ${result.stderr}`);
61
- }
62
- return result.stdout;
63
- }
@@ -1,5 +0,0 @@
1
- export { createContainer, execInContainer, removeContainer, isContainerRunning, cleanupOrphans, setRuntime, getRuntime, resetRuntime, probeRuntime } from './runtime.js';
2
- export type { ContainerOpts, ExecOpts, ExecResult } from './runtime.js';
3
- export { ensureContainer, releaseContainer, pruneIdle, releaseAll, SANDBOX_DEFAULTS } from './manager.js';
4
- export { sandboxBash, sandboxReadFile, sandboxWriteFile, sandboxListDir, sandboxGlob } from './bridge.js';
5
- export { validateMountPaths, isBlockedPath, translatePath } from './mount-security.js';
@@ -1,4 +0,0 @@
1
- export { createContainer, execInContainer, removeContainer, isContainerRunning, cleanupOrphans, setRuntime, getRuntime, resetRuntime, probeRuntime } from './runtime.js';
2
- export { ensureContainer, releaseContainer, pruneIdle, releaseAll, SANDBOX_DEFAULTS } from './manager.js';
3
- export { sandboxBash, sandboxReadFile, sandboxWriteFile, sandboxListDir, sandboxGlob } from './bridge.js';
4
- export { validateMountPaths, isBlockedPath, translatePath } from './mount-security.js';
@@ -1,7 +0,0 @@
1
- import type { SandboxConfig } from '../types.js';
2
- export declare const SANDBOX_DEFAULTS: SandboxConfig;
3
- export declare function ensureContainer(sessionId: string, config: SandboxConfig, allowedPaths: string[]): Promise<string>;
4
- export declare function releaseContainer(sessionId: string): Promise<void>;
5
- export declare function pruneIdle(maxIdleMs: number): Promise<number>;
6
- export declare function releaseAll(): Promise<void>;
7
- export declare function resetForTesting(): void;
@@ -1,100 +0,0 @@
1
- import { createContainer, removeContainer, isContainerRunning } from './runtime.js';
2
- import { validateMountPaths } from './mount-security.js';
3
- export const SANDBOX_DEFAULTS = {
4
- enabled: false,
5
- image: 'skimpyclaw-sandbox',
6
- cpus: 2,
7
- memory: '2G',
8
- network: 'bridge',
9
- idleTimeoutMs: 3_600_000,
10
- };
11
- const activeContainers = new Map();
12
- export async function ensureContainer(sessionId, config, allowedPaths) {
13
- const name = `skimpyclaw-sbx-${sessionId}`;
14
- const existing = activeContainers.get(sessionId);
15
- if (existing) {
16
- const running = await isContainerRunning(existing.name);
17
- if (running) {
18
- existing.lastUsed = Date.now();
19
- return existing.name;
20
- }
21
- // Container died — remove from map, recreate
22
- activeContainers.delete(sessionId);
23
- }
24
- // Process restarts clear in-memory state; adopt or clean an existing named
25
- // container so a duplicate-name create does not fail.
26
- const alreadyRunning = await isContainerRunning(name);
27
- if (alreadyRunning) {
28
- activeContainers.set(sessionId, {
29
- name,
30
- sessionId,
31
- lastUsed: Date.now(),
32
- });
33
- return name;
34
- }
35
- // Best effort cleanup for stopped or half-created containers with same name.
36
- await removeContainer(name);
37
- const mounts = validateMountPaths(allowedPaths);
38
- const uid = process.getuid?.() ?? 501;
39
- const gid = process.getgid?.() ?? 20;
40
- const merged = { ...SANDBOX_DEFAULTS, ...config };
41
- // Expand ${VAR} references in env values from process.env
42
- let resolvedEnv;
43
- if (config.env) {
44
- resolvedEnv = {};
45
- for (const [key, val] of Object.entries(config.env)) {
46
- resolvedEnv[key] = val.replace(/\$\{(\w+)\}/g, (_match, name) => {
47
- return process.env[name] ?? '';
48
- });
49
- }
50
- }
51
- const opts = {
52
- image: merged.image,
53
- cpus: merged.cpus,
54
- memory: merged.memory,
55
- network: merged.network,
56
- mounts: mounts.map((m) => ({
57
- host: m.host,
58
- container: m.container,
59
- readOnly: m.readOnly,
60
- })),
61
- env: resolvedEnv,
62
- user: `${uid}:${gid}`,
63
- };
64
- await createContainer(name, opts);
65
- activeContainers.set(sessionId, {
66
- name,
67
- sessionId,
68
- lastUsed: Date.now(),
69
- });
70
- return name;
71
- }
72
- export async function releaseContainer(sessionId) {
73
- const entry = activeContainers.get(sessionId);
74
- if (!entry)
75
- return;
76
- activeContainers.delete(sessionId);
77
- await removeContainer(entry.name);
78
- }
79
- export async function pruneIdle(maxIdleMs) {
80
- const now = Date.now();
81
- let pruned = 0;
82
- for (const [sessionId, entry] of activeContainers) {
83
- if (now - entry.lastUsed > maxIdleMs) {
84
- activeContainers.delete(sessionId);
85
- await removeContainer(entry.name);
86
- pruned++;
87
- }
88
- }
89
- return pruned;
90
- }
91
- export async function releaseAll() {
92
- const entries = Array.from(activeContainers.values());
93
- activeContainers.clear();
94
- for (const entry of entries) {
95
- await removeContainer(entry.name);
96
- }
97
- }
98
- export function resetForTesting() {
99
- activeContainers.clear();
100
- }
@@ -1,12 +0,0 @@
1
- export interface MountSpec {
2
- host: string;
3
- container: string;
4
- readOnly: boolean;
5
- }
6
- export declare function isBlockedPath(resolvedPath: string): boolean;
7
- export declare function validateMountPaths(allowedPaths: string[]): MountSpec[];
8
- /**
9
- * Translate a host path to its container equivalent using mount specs.
10
- * Returns the original path if no mount matches (will likely fail inside container).
11
- */
12
- export declare function translatePath(hostPath: string, mounts: MountSpec[]): string;
@@ -1,122 +0,0 @@
1
- import { realpathSync, existsSync } from 'fs';
2
- import { resolve, basename } from 'path';
3
- import { homedir } from 'os';
4
- const BLOCKED_PATTERNS = [
5
- '.ssh',
6
- '.gnupg',
7
- '.gpg',
8
- '.aws',
9
- '.azure',
10
- '.gcloud',
11
- 'credentials',
12
- '.env',
13
- '.npmrc',
14
- '.pypirc',
15
- '.docker/config.json',
16
- '.kube',
17
- ];
18
- export function isBlockedPath(resolvedPath) {
19
- const segments = resolvedPath.split('/');
20
- for (const segment of segments) {
21
- if (BLOCKED_PATTERNS.includes(segment)) {
22
- return true;
23
- }
24
- }
25
- // Also check for compound patterns like .docker/config.json
26
- for (const pattern of BLOCKED_PATTERNS) {
27
- if (pattern.includes('/') && resolvedPath.includes(pattern)) {
28
- return true;
29
- }
30
- }
31
- return false;
32
- }
33
- export function validateMountPaths(allowedPaths) {
34
- const home = homedir();
35
- const mounts = [];
36
- const usedBaseNames = new Map();
37
- for (const rawPath of allowedPaths) {
38
- const expanded = rawPath.startsWith('~')
39
- ? resolve(home, rawPath.slice(2))
40
- : resolve(rawPath);
41
- let resolved;
42
- try {
43
- if (!existsSync(expanded)) {
44
- continue; // Skip missing paths
45
- }
46
- resolved = realpathSync(expanded);
47
- }
48
- catch {
49
- continue; // Skip paths that can't be resolved
50
- }
51
- if (isBlockedPath(resolved)) {
52
- throw new Error(`Blocked path: ${resolved} matches security exclusion pattern`);
53
- }
54
- let base = basename(resolved);
55
- const count = usedBaseNames.get(base) ?? 0;
56
- usedBaseNames.set(base, count + 1);
57
- if (count > 0) {
58
- base = `${base}_${count}`;
59
- }
60
- mounts.push({
61
- host: resolved,
62
- container: `/workspace/${base}`,
63
- readOnly: false,
64
- });
65
- }
66
- // Always add ~/.skimpyclaw at /workspace/config
67
- const skimpyclawDir = resolve(home, '.skimpyclaw');
68
- if (existsSync(skimpyclawDir)) {
69
- const resolved = realpathSync(skimpyclawDir);
70
- // Only add if not already included
71
- if (!mounts.some((m) => m.host === resolved)) {
72
- mounts.push({
73
- host: resolved,
74
- container: '/workspace/config',
75
- readOnly: false,
76
- });
77
- }
78
- }
79
- return mounts;
80
- }
81
- /**
82
- * Translate a host path to its container equivalent using mount specs.
83
- * Returns the original path if no mount matches (will likely fail inside container).
84
- */
85
- export function translatePath(hostPath, mounts) {
86
- // Expand ~ to home directory (~ is a shell feature, not handled by resolve())
87
- const expanded = hostPath.startsWith('~/')
88
- ? homedir() + hostPath.slice(1)
89
- : hostPath;
90
- const candidates = getPathCandidates(expanded);
91
- // Sort by host path length descending so we match the most specific mount first
92
- const sorted = [...mounts].sort((a, b) => b.host.length - a.host.length);
93
- for (const candidate of candidates) {
94
- for (const mount of sorted) {
95
- if (candidate === mount.host || candidate.startsWith(mount.host + '/')) {
96
- const relative = candidate.slice(mount.host.length);
97
- return mount.container + relative;
98
- }
99
- }
100
- }
101
- return hostPath;
102
- }
103
- function getPathCandidates(hostPath) {
104
- const set = new Set();
105
- const resolved = resolve(hostPath);
106
- set.add(resolved);
107
- try {
108
- set.add(realpathSync(resolved));
109
- }
110
- catch {
111
- // Path may not exist yet (e.g. write targets); keep resolved form.
112
- }
113
- for (const p of Array.from(set)) {
114
- if (p.startsWith('/Users/')) {
115
- set.add(`/System/Volumes/Data${p}`);
116
- }
117
- else if (p.startsWith('/System/Volumes/Data/Users/')) {
118
- set.add(p.replace('/System/Volumes/Data', ''));
119
- }
120
- }
121
- return Array.from(set);
122
- }