skimpyclaw 0.3.9 → 0.3.14

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 (59) hide show
  1. package/dist/__tests__/channels.test.js +1 -1
  2. package/dist/__tests__/context-manager.test.js +219 -76
  3. package/dist/__tests__/providers-utils.test.js +2 -0
  4. package/dist/__tests__/sandbox-manager.test.js +25 -0
  5. package/dist/__tests__/sandbox-mount-security.test.js +8 -0
  6. package/dist/__tests__/setup.test.js +1 -1
  7. package/dist/__tests__/skills.test.js +53 -26
  8. package/dist/__tests__/token-efficiency.test.js +37 -15
  9. package/dist/__tests__/tools.test.js +11 -9
  10. package/dist/agent.js +2 -2
  11. package/dist/api.js +5 -0
  12. package/dist/channels/discord/handlers.d.ts +7 -0
  13. package/dist/channels/discord/handlers.js +479 -0
  14. package/dist/channels/discord/index.d.ts +8 -0
  15. package/dist/channels/discord/index.js +149 -0
  16. package/dist/channels/discord/types.d.ts +6 -0
  17. package/dist/channels/discord/types.js +17 -0
  18. package/dist/channels/discord/utils.d.ts +14 -0
  19. package/dist/channels/discord/utils.js +161 -0
  20. package/dist/channels/telegram/utils.d.ts +1 -1
  21. package/dist/channels/telegram/utils.js +7 -9
  22. package/dist/channels.js +1 -1
  23. package/dist/cli.js +8 -43
  24. package/dist/code-agents/parser.js +5 -0
  25. package/dist/config.d.ts +7 -0
  26. package/dist/config.js +13 -0
  27. package/dist/cron.js +6 -3
  28. package/dist/heartbeat.js +11 -15
  29. package/dist/providers/anthropic.js +7 -1
  30. package/dist/providers/codex.js +8 -2
  31. package/dist/providers/context-manager.d.ts +37 -6
  32. package/dist/providers/context-manager.js +303 -47
  33. package/dist/providers/openai.js +8 -2
  34. package/dist/providers/utils.d.ts +6 -2
  35. package/dist/providers/utils.js +36 -4
  36. package/dist/sandbox/manager.js +11 -0
  37. package/dist/sandbox/mount-security.js +5 -1
  38. package/dist/sandbox/runtime.d.ts +1 -0
  39. package/dist/sandbox/runtime.js +5 -0
  40. package/dist/sandbox-utils.d.ts +6 -0
  41. package/dist/sandbox-utils.js +36 -0
  42. package/dist/security.js +4 -3
  43. package/dist/service.js +25 -0
  44. package/dist/setup-templates.d.ts +14 -0
  45. package/dist/setup-templates.js +214 -0
  46. package/dist/setup.d.ts +1 -9
  47. package/dist/setup.js +3 -244
  48. package/dist/skills-types.d.ts +6 -0
  49. package/dist/skills.d.ts +5 -1
  50. package/dist/skills.js +25 -2
  51. package/dist/tools/bash-tool.js +11 -1
  52. package/dist/tools/definitions.d.ts +57 -0
  53. package/dist/tools/definitions.js +19 -1
  54. package/dist/tools/fetch-tool.d.ts +8 -0
  55. package/dist/tools/fetch-tool.js +80 -0
  56. package/dist/tools.d.ts +4 -2
  57. package/dist/tools.js +110 -62
  58. package/dist/types.d.ts +5 -0
  59. package/package.json +23 -29
@@ -9,14 +9,24 @@ const SENSITIVE_ENV_PATTERNS = [
9
9
  /^ANTHROPIC_/i, /^OPENAI_/i, /^CLAUDE/i, /^CODEX_/i, /^MINIMAX_/i,
10
10
  /^KIMI_/i, /^TOGETHER_/i, /^GROQ_/i, /^OPENROUTER_/i,
11
11
  ];
12
+ /** Env vars that match SENSITIVE_ENV_PATTERNS but should be kept (e.g. tool auth). */
13
+ const SENSITIVE_ENV_ALLOWLIST = new Set(['GH_TOKEN']);
14
+ /** Common tool paths that may be missing when launched as a service/daemon. */
15
+ const EXTRA_PATH_DIRS = ['/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin'];
12
16
  /** Create a sanitized copy of process.env with secrets stripped. */
13
17
  function sanitizeEnv() {
14
18
  const env = { ...process.env };
15
19
  for (const key of Object.keys(env)) {
16
- if (SENSITIVE_ENV_PATTERNS.some(p => p.test(key))) {
20
+ if (!SENSITIVE_ENV_ALLOWLIST.has(key) && SENSITIVE_ENV_PATTERNS.some(p => p.test(key))) {
17
21
  delete env[key];
18
22
  }
19
23
  }
24
+ // Ensure common tool directories are in PATH (daemon/service launches often have a minimal PATH)
25
+ const currentPath = env.PATH || '';
26
+ const missing = EXTRA_PATH_DIRS.filter(d => !currentPath.includes(d));
27
+ if (missing.length > 0) {
28
+ env.PATH = `${currentPath}:${missing.join(':')}`;
29
+ }
20
30
  return env;
21
31
  }
22
32
  export async function executeBash(command, cwd, config, context) {
@@ -154,6 +154,35 @@ export declare const BROWSER_TOOL_DEFINITION: {
154
154
  required: string[];
155
155
  };
156
156
  };
157
+ export declare const FETCH_TOOL_DEFINITION: {
158
+ name: string;
159
+ description: string;
160
+ input_schema: {
161
+ type: "object";
162
+ properties: {
163
+ url: {
164
+ type: string;
165
+ description: string;
166
+ };
167
+ method: {
168
+ type: string;
169
+ description: string;
170
+ };
171
+ headers: {
172
+ type: "object";
173
+ description: string;
174
+ additionalProperties: {
175
+ type: string;
176
+ };
177
+ };
178
+ body: {
179
+ type: string;
180
+ description: string;
181
+ };
182
+ };
183
+ required: string[];
184
+ };
185
+ };
157
186
  export declare const TOOL_DEFINITIONS: ({
158
187
  name: string;
159
188
  description: string;
@@ -304,6 +333,34 @@ export declare const TOOL_DEFINITIONS: ({
304
333
  };
305
334
  required: string[];
306
335
  };
336
+ } | {
337
+ name: string;
338
+ description: string;
339
+ input_schema: {
340
+ type: "object";
341
+ properties: {
342
+ url: {
343
+ type: string;
344
+ description: string;
345
+ };
346
+ method: {
347
+ type: string;
348
+ description: string;
349
+ };
350
+ headers: {
351
+ type: "object";
352
+ description: string;
353
+ additionalProperties: {
354
+ type: string;
355
+ };
356
+ };
357
+ body: {
358
+ type: string;
359
+ description: string;
360
+ };
361
+ };
362
+ required: string[];
363
+ };
307
364
  })[];
308
365
  export declare const CODE_WITH_AGENT_TOOL: {
309
366
  name: string;
@@ -110,8 +110,26 @@ export const BROWSER_TOOL_DEFINITION = {
110
110
  required: ['action'],
111
111
  },
112
112
  };
113
+ export const FETCH_TOOL_DEFINITION = {
114
+ name: 'Fetch',
115
+ description: 'Make an HTTP request and return the response. HTML is auto-converted to plain text. Use for APIs, web search (e.g. https://duckduckgo.com/html/?q=your+query), or fetching page content. Prefer over Browser for simple requests.',
116
+ input_schema: {
117
+ type: 'object',
118
+ properties: {
119
+ url: { type: 'string', description: 'URL to fetch' },
120
+ method: { type: 'string', description: 'HTTP method (GET, POST, PUT, DELETE, PATCH). Default: GET' },
121
+ headers: {
122
+ type: 'object',
123
+ description: 'Request headers (e.g. {"Authorization": "Bearer ..."})',
124
+ additionalProperties: { type: 'string' },
125
+ },
126
+ body: { type: 'string', description: 'Request body (for POST/PUT/PATCH)' },
127
+ },
128
+ required: ['url'],
129
+ },
130
+ };
113
131
  // Legacy export for backward compat — static list (built-ins + browser + no MCP)
114
- export const TOOL_DEFINITIONS = [...BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION];
132
+ export const TOOL_DEFINITIONS = [...BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, FETCH_TOOL_DEFINITION];
115
133
  export const CODE_WITH_AGENT_TOOL = {
116
134
  name: 'code_with_agent',
117
135
  description: 'Delegate a coding task to a coding agent CLI (Claude Code or Codex). The agent will edit files, run commands, and return results. Always use this for code changes instead of writing code directly.',
@@ -0,0 +1,8 @@
1
+ import type { ToolConfig } from '../types.js';
2
+ export interface FetchInput {
3
+ url: string;
4
+ method?: string;
5
+ headers?: Record<string, string>;
6
+ body?: string;
7
+ }
8
+ export declare function executeFetch(input: FetchInput, _config: ToolConfig): Promise<string>;
@@ -0,0 +1,80 @@
1
+ // Fetch tool — lightweight HTTP requests and web search without browser overhead
2
+ const MAX_RESPONSE_CHARS = 50_000;
3
+ const TIMEOUT_MS = 30_000;
4
+ /**
5
+ * Strip HTML tags and decode common entities. Returns plain text.
6
+ */
7
+ function htmlToText(html) {
8
+ return html
9
+ // Remove script/style blocks
10
+ .replace(/<script[\s\S]*?<\/script>/gi, '')
11
+ .replace(/<style[\s\S]*?<\/style>/gi, '')
12
+ // Convert block elements to newlines
13
+ .replace(/<\/(p|div|h[1-6]|li|tr|br\s*\/?)>/gi, '\n')
14
+ .replace(/<br\s*\/?>/gi, '\n')
15
+ // Remove remaining tags
16
+ .replace(/<[^>]+>/g, '')
17
+ // Decode common entities
18
+ .replace(/&amp;/g, '&')
19
+ .replace(/&lt;/g, '<')
20
+ .replace(/&gt;/g, '>')
21
+ .replace(/&quot;/g, '"')
22
+ .replace(/&#39;/g, "'")
23
+ .replace(/&nbsp;/g, ' ')
24
+ // Collapse whitespace
25
+ .replace(/[ \t]+/g, ' ')
26
+ .replace(/\n{3,}/g, '\n\n')
27
+ .trim();
28
+ }
29
+ export async function executeFetch(input, _config) {
30
+ const { url, method = 'GET', headers = {}, body } = input;
31
+ if (!url)
32
+ return 'Error: url is required';
33
+ try {
34
+ new URL(url);
35
+ }
36
+ catch {
37
+ return `Error: Invalid URL: ${url}`;
38
+ }
39
+ try {
40
+ const defaultHeaders = {
41
+ 'User-Agent': 'SkimpyClaw/1.0',
42
+ };
43
+ const response = await fetch(url, {
44
+ method: method.toUpperCase(),
45
+ headers: { ...defaultHeaders, ...headers },
46
+ body: body || undefined,
47
+ signal: AbortSignal.timeout(TIMEOUT_MS),
48
+ redirect: 'follow',
49
+ });
50
+ const status = `${response.status} ${response.statusText}`;
51
+ const contentType = response.headers.get('content-type') || '';
52
+ let responseBody;
53
+ if (contentType.includes('json')) {
54
+ const text = await response.text();
55
+ try {
56
+ responseBody = JSON.stringify(JSON.parse(text), null, 2);
57
+ }
58
+ catch {
59
+ responseBody = text;
60
+ }
61
+ }
62
+ else if (contentType.includes('html')) {
63
+ const html = await response.text();
64
+ responseBody = htmlToText(html);
65
+ }
66
+ else {
67
+ responseBody = await response.text();
68
+ }
69
+ if (responseBody.length > MAX_RESPONSE_CHARS) {
70
+ responseBody = responseBody.slice(0, MAX_RESPONSE_CHARS) + `\n\n[Truncated: ${responseBody.length} chars total]`;
71
+ }
72
+ return `HTTP ${status}\n\n${responseBody}`;
73
+ }
74
+ catch (err) {
75
+ if (err instanceof Error && err.name === 'TimeoutError') {
76
+ return `Error: Request timed out after ${TIMEOUT_MS / 1000}s`;
77
+ }
78
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
79
+ }
80
+ }
package/dist/tools.d.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  import type { ToolConfig } from './types.js';
2
- import { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, TOOL_DEFINITIONS, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL } from './tools/definitions.js';
2
+ import { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, FETCH_TOOL_DEFINITION, TOOL_DEFINITIONS, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL } from './tools/definitions.js';
3
3
  import type { ExecuteToolContext } from './tools/execute-context.js';
4
4
  import { cleanupBrowser } from './tools/browser-tool.js';
5
5
  export { getActiveCodeAgents, getRecentCodeAgents, getAllCodeAgents, getCodeAgent, cancelCodeAgent, restoreCodeAgentTasks, setCodeAgentConfig, computeWaves, decomposeTask, synthesizeResults, runValidation, runCodeAgentBackground, buildCodeAgentArgs, readTeamState, parseStreamJsonForLive, } from './code-agents/index.js';
6
- export { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, TOOL_DEFINITIONS, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL, cleanupBrowser, };
6
+ export { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, FETCH_TOOL_DEFINITION, TOOL_DEFINITIONS, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL, cleanupBrowser, };
7
7
  export type { ExecuteToolContext };
8
8
  export type { CodeAgentTask, DecomposedSubtask } from './code-agents/index.js';
9
9
  export declare function discoverMcpTools(): Promise<any[]>;
10
10
  export declare function clearMcpToolCache(): void;
11
+ /** Force-reconnect the MCP runtime (clears cached runtime and tool discovery). */
12
+ export declare function reconnectMcp(): Promise<void>;
11
13
  /**
12
14
  * Get all available tool definitions: built-ins + browser (if enabled) + MCP (auto-discovered) + agent tools.
13
15
  * This is the primary way to get tools — replaces the static TOOL_DEFINITIONS export.
package/dist/tools.js CHANGED
@@ -1,12 +1,14 @@
1
1
  // Tool definitions and executors for Anthropic API tool_use
2
+ import { readFileSync, existsSync } from 'fs';
2
3
  import { join, resolve } from 'path';
3
4
  import { homedir } from 'os';
4
5
  import { TTLCache } from './cache.js';
5
6
  import { toErrorMessage } from './utils.js';
6
- import { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, TOOL_DEFINITIONS, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL, } from './tools/definitions.js';
7
+ import { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, FETCH_TOOL_DEFINITION, TOOL_DEFINITIONS, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL, } from './tools/definitions.js';
7
8
  import { executeReadFile, executeWriteFileLocked, executeListDirectory } from './tools/file-tools.js';
8
9
  import { executeBash } from './tools/bash-tool.js';
9
10
  import { executeBrowser, cleanupBrowser } from './tools/browser-tool.js';
11
+ import { executeFetch } from './tools/fetch-tool.js';
10
12
  import { ensureContainer, SANDBOX_DEFAULTS, translatePath, validateMountPaths } from './sandbox/index.js';
11
13
  import { sandboxBash, sandboxReadFile, sandboxWriteFile, sandboxListDir, sandboxGlob } from './sandbox/index.js';
12
14
  import { isBashCommandSafe } from './security.js';
@@ -26,7 +28,7 @@ buildCodeAgentArgs, readTeamState,
26
28
  // Parsers
27
29
  parseStreamJsonForLive, } from './code-agents/index.js';
28
30
  // Re-export from definitions
29
- export { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, TOOL_DEFINITIONS, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL, cleanupBrowser, };
31
+ export { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, FETCH_TOOL_DEFINITION, TOOL_DEFINITIONS, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL, cleanupBrowser, };
30
32
  // --- MCP (mcporter) ---
31
33
  let mcpRuntime = null;
32
34
  async function getMcpRuntime() {
@@ -46,6 +48,32 @@ function sanitizeToolName(name) {
46
48
  }
47
49
  // Maps sanitized tool name → { server (original), tool (original) } for routing
48
50
  const mcpToolNameMap = new Map();
51
+ /**
52
+ * Fallback tool definitions for MCP servers that don't support tools/list.
53
+ * Loaded from ~/.skimpyclaw/mcp-fallbacks.json if it exists.
54
+ *
55
+ * Format: { "<server-name>": [ { name, description, input_schema } ] }
56
+ */
57
+ let mcpFallbackCache = null;
58
+ function getMcpFallbackTools(server) {
59
+ if (!mcpFallbackCache) {
60
+ const fallbackPath = join(homedir(), '.skimpyclaw', 'mcp-fallbacks.json');
61
+ try {
62
+ if (existsSync(fallbackPath)) {
63
+ mcpFallbackCache = JSON.parse(readFileSync(fallbackPath, 'utf-8'));
64
+ console.log(`[mcp] Loaded fallback tools from ${fallbackPath}`);
65
+ }
66
+ else {
67
+ mcpFallbackCache = {};
68
+ }
69
+ }
70
+ catch (err) {
71
+ console.warn('[mcp] Failed to load mcp-fallbacks.json:', err instanceof Error ? err.message : err);
72
+ mcpFallbackCache = {};
73
+ }
74
+ }
75
+ return mcpFallbackCache[server] || [];
76
+ }
49
77
  export async function discoverMcpTools() {
50
78
  if (discoveredMcpTools !== null)
51
79
  return discoveredMcpTools;
@@ -72,6 +100,18 @@ export async function discoverMcpTools() {
72
100
  }
73
101
  catch (err) {
74
102
  console.warn(`[mcp] Failed to list tools for server "${server}":`, err instanceof Error ? err.message : err);
103
+ // Fallback: register well-known tools for servers that don't support tools/list
104
+ const fallbackTools = getMcpFallbackTools(server);
105
+ if (fallbackTools.length > 0) {
106
+ const sanitizedServer = sanitizeToolName(server);
107
+ for (const ft of fallbackTools) {
108
+ const sanitizedTool = sanitizeToolName(ft.name);
109
+ const name = `mcp__${sanitizedServer}__${sanitizedTool}`;
110
+ mcpToolNameMap.set(name, { server, tool: ft.name });
111
+ tools.push({ name, description: ft.description, input_schema: ft.input_schema });
112
+ }
113
+ console.log(`[mcp] Registered ${fallbackTools.length} fallback tools for "${server}"`);
114
+ }
75
115
  }
76
116
  }
77
117
  }
@@ -85,6 +125,26 @@ export function clearMcpToolCache() {
85
125
  discoveredMcpTools = null;
86
126
  }
87
127
  const toolDefsCache = new TTLCache(60_000);
128
+ /** Force-reconnect the MCP runtime (clears cached runtime and tool discovery). */
129
+ export async function reconnectMcp() {
130
+ console.log('[mcp] Reconnecting...');
131
+ if (mcpRuntime) {
132
+ await mcpRuntime.close().catch(() => { });
133
+ mcpRuntime = null;
134
+ }
135
+ discoveredMcpTools = null;
136
+ mcpToolNameMap.clear();
137
+ mcpFallbackCache = null;
138
+ toolDefsCache.clear();
139
+ try {
140
+ await getMcpRuntime();
141
+ const tools = await discoverMcpTools();
142
+ console.log(`[mcp] Reconnected — ${tools.length} tools discovered`);
143
+ }
144
+ catch (err) {
145
+ console.error('[mcp] Reconnect failed:', err instanceof Error ? err.message : err);
146
+ }
147
+ }
88
148
  /** Inject project names into a tool's workdir description, or return the tool unchanged. */
89
149
  function injectProjects(tool, projects) {
90
150
  if (!projects || Object.keys(projects).length === 0)
@@ -126,7 +186,9 @@ export async function getToolDefinitions(config, options) {
126
186
  if (cached)
127
187
  return cached;
128
188
  const tools = [...BUILTIN_TOOL_DEFINITIONS];
129
- // Minimal profile: only the 4 built-in tools (Read, Write, Glob, Bash).
189
+ // Fetch is always available (lightweight HTTP, no dependencies)
190
+ tools.push(FETCH_TOOL_DEFINITION);
191
+ // Minimal profile: built-in tools + fetch.
130
192
  // Used by orchestrator decompose/synthesize calls.
131
193
  if (profile === 'minimal') {
132
194
  toolDefsCache.set(cacheKey, tools);
@@ -166,75 +228,49 @@ export function clearToolDefsCache() {
166
228
  toolDefsCache.clear();
167
229
  }
168
230
  // --- MCP Tool Execution (generic) ---
169
- async function executeMcpToolGeneric(fullName, args) {
170
- // Look up original server/tool names from the sanitized name map
171
- const mapping = mcpToolNameMap.get(fullName);
172
- if (!mapping) {
173
- // Fallback: parse from the name directly (works when names don't need sanitizing)
174
- const parts = fullName.split('__');
175
- if (parts.length < 3)
176
- return `Error: Invalid MCP tool name "${fullName}"`;
177
- const server = parts[1];
178
- const toolName = parts.slice(2).join('__');
179
- const runtime = await getMcpRuntime();
180
- const result = await runtime.callTool(server, toolName, { args });
181
- const content = result?.content;
182
- if (Array.isArray(content)) {
183
- return content.map((c) => c.text || JSON.stringify(c)).join('\n');
184
- }
185
- return JSON.stringify(result);
186
- }
231
+ async function callMcpTool(server, tool, args) {
187
232
  const runtime = await getMcpRuntime();
188
- const result = await runtime.callTool(mapping.server, mapping.tool, { args });
233
+ const result = await runtime.callTool(server, tool, { args });
189
234
  const content = result?.content;
190
235
  if (Array.isArray(content)) {
191
236
  return content.map((c) => c.text || JSON.stringify(c)).join('\n');
192
237
  }
193
238
  return JSON.stringify(result);
194
239
  }
195
- export async function cleanupMcp() {
196
- if (mcpRuntime) {
197
- await mcpRuntime.close().catch(() => { });
198
- mcpRuntime = null;
199
- }
200
- }
201
- async function executeWebSearch(query) {
202
- const q = query.trim();
203
- if (!q)
204
- return 'Error: $web_search requires a non-empty query';
205
- const url = `https://api.duckduckgo.com/?q=${encodeURIComponent(q)}&format=json&no_redirect=1&no_html=1&skip_disambig=1`;
206
- const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
207
- if (!res.ok) {
208
- return `Error: web search failed (${res.status} ${res.statusText})`;
240
+ async function executeMcpToolGeneric(fullName, args) {
241
+ const mapping = mcpToolNameMap.get(fullName);
242
+ let server;
243
+ let toolName;
244
+ if (mapping) {
245
+ server = mapping.server;
246
+ toolName = mapping.tool;
209
247
  }
210
- const data = await res.json();
211
- const lines = [];
212
- if (data.AbstractText) {
213
- lines.push(`Summary: ${data.AbstractText}`);
214
- if (data.AbstractURL) {
215
- lines.push(`Source: ${data.AbstractURL}`);
216
- }
248
+ else {
249
+ const parts = fullName.split('__');
250
+ if (parts.length < 3)
251
+ return `Error: Invalid MCP tool name "${fullName}"`;
252
+ server = parts[1];
253
+ toolName = parts.slice(2).join('__');
217
254
  }
218
- const related = [];
219
- for (const item of data.RelatedTopics || []) {
220
- if ('Topics' in item && Array.isArray(item.Topics)) {
221
- related.push(...item.Topics);
222
- }
223
- else {
224
- related.push(item);
225
- }
255
+ try {
256
+ return await callMcpTool(server, toolName, args);
226
257
  }
227
- const top = related.filter((item) => item.Text && item.FirstURL).slice(0, 5);
228
- if (top.length > 0) {
229
- lines.push('Top results:');
230
- for (const item of top) {
231
- lines.push(`- ${item.Text}\n ${item.FirstURL}`);
258
+ catch (err) {
259
+ const msg = err instanceof Error ? err.message : String(err);
260
+ // Retry once on connection/session errors
261
+ if (msg.includes('session') || msg.includes('Session') || msg.includes('ECONNR') || msg.includes('EPIPE') || msg.includes('closed') || msg.includes('disconnected')) {
262
+ console.warn(`[mcp] Tool call failed (${msg}), reconnecting and retrying...`);
263
+ await reconnectMcp();
264
+ return await callMcpTool(server, toolName, args);
232
265
  }
266
+ throw err;
233
267
  }
234
- if (lines.length === 0) {
235
- return `No web results found for: ${q}`;
268
+ }
269
+ export async function cleanupMcp() {
270
+ if (mcpRuntime) {
271
+ await mcpRuntime.close().catch(() => { });
272
+ mcpRuntime = null;
236
273
  }
237
- return lines.join('\n');
238
274
  }
239
275
  // --- Tool Executor ---
240
276
  export async function executeTool(name, input, config, context) {
@@ -324,12 +360,21 @@ export async function executeTool(name, input, config, context) {
324
360
  switch (normalized) {
325
361
  case 'bash': {
326
362
  const translatedCmd = translateBashPaths(input.command);
327
- // Apply hard safety blocks and exec-approval inside sandbox.
328
- // The sandbox provides filesystem isolation but not command-level policy.
363
+ // Apply hard safety blocks inside sandbox.
364
+ // Sandbox provides filesystem isolation, so opaque script execution
365
+ // (heredocs, -c, -e) is safe — only require approval for truly
366
+ // destructive patterns (rm -rf, dd, mkfs, etc.).
329
367
  if (!isBashCommandSafe(translatedCmd)) {
330
368
  return 'Error: Command blocked by safety filter.';
331
369
  }
332
370
  const classification = classifyCommandRisk(translatedCmd);
371
+ // Downgrade opaque-script classifications in sandbox — the isolation
372
+ // already handles the risk that inline code poses on the host.
373
+ if (classification.tier >= 2 && (classification.reason === 'Inline heredoc script execution' ||
374
+ classification.reason === 'Inline interpreter code execution')) {
375
+ classification.tier = 0;
376
+ classification.reason = `${classification.reason} (sandboxed — auto-approved)`;
377
+ }
333
378
  const approvalConfig = config.execApproval;
334
379
  if (requiresApproval(classification, approvalConfig)) {
335
380
  const isUnattended = context?.channel === 'subagent' ||
@@ -372,7 +417,8 @@ export async function executeTool(name, input, config, context) {
372
417
  case '$web_search':
373
418
  case 'web_search':
374
419
  case 'websearch':
375
- return await executeWebSearch(input.query || input.q || input.text || '');
420
+ // Legacy: redirect to Fetch with DuckDuckGo HTML search
421
+ return await executeFetch({ url: `https://duckduckgo.com/html/?q=${encodeURIComponent(input.query || input.q || input.text || '')}` }, config);
376
422
  case 'read_file':
377
423
  return executeReadFile(input.file_path || input.path, config);
378
424
  case 'write_file':
@@ -383,6 +429,8 @@ export async function executeTool(name, input, config, context) {
383
429
  return await executeBash(input.command, input.cwd, config, context);
384
430
  case 'browser':
385
431
  return await executeBrowser(input, config);
432
+ case 'fetch':
433
+ return await executeFetch(input, config);
386
434
  default:
387
435
  return `Error: Unknown tool "${name}"`;
388
436
  }
package/dist/types.d.ts CHANGED
@@ -25,6 +25,9 @@ export interface VoiceConfig {
25
25
  channels?: Record<string, VoiceChannelConfig>;
26
26
  }
27
27
  export interface Config {
28
+ /** Global allowed paths. Channels, cron, and heartbeat inherit these
29
+ * unless they specify their own tools.allowedPaths override. */
30
+ allowedPaths?: string[];
28
31
  gateway: {
29
32
  port: number;
30
33
  host?: string;
@@ -149,6 +152,7 @@ export interface SandboxConfig {
149
152
  memory?: string;
150
153
  network?: string;
151
154
  idleTimeoutMs?: number;
155
+ env?: Record<string, string>;
152
156
  }
153
157
  export interface ToolConfig {
154
158
  enabled: boolean;
@@ -160,6 +164,7 @@ export interface ToolConfig {
160
164
  contextManagement?: {
161
165
  enabled?: boolean;
162
166
  maxContextTokens?: number;
167
+ compactionModel?: string;
163
168
  };
164
169
  browser?: {
165
170
  enabled?: boolean;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "skimpyclaw",
3
- "version": "0.3.9",
4
- "description": "Lightweight personal AI assistant with Telegram and Discord integration",
3
+ "version": "0.3.14",
4
+ "description": "A lobster in a bikini",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -16,31 +16,11 @@
16
16
  "README.md",
17
17
  "LICENSE"
18
18
  ],
19
- "scripts": {
20
- "cli": "tsx src/cli.ts",
21
- "start": "tsx src/index.ts",
22
- "dev": "tsx watch src/index.ts",
23
- "dashboard:dev": "pnpm --dir web/dashboard dev",
24
- "dashboard:build": "pnpm --dir web/dashboard install --frozen-lockfile && pnpm --dir web/dashboard build",
25
- "docs:dev": "pnpm --dir docs dev",
26
- "docs:build": "pnpm --dir docs install --frozen-lockfile && pnpm --dir docs build",
27
- "docs:preview": "pnpm --dir docs preview",
28
- "setup": "tsx src/setup.ts",
29
- "onboard": "tsx src/cli.ts onboard",
30
- "build": "tsc && pnpm dashboard:build",
31
- "release:check": "pnpm build && pnpm test",
32
- "release:local": "bash ./scripts/release.sh",
33
- "lint": "eslint \"src/**/*.ts\"",
34
- "typecheck": "tsc --noEmit",
35
- "test": "vitest run",
36
- "ci": "pnpm run lint && pnpm run typecheck && pnpm run test"
37
- },
38
19
  "dependencies": {
39
20
  "@anthropic-ai/sdk": "^0.52.0",
40
21
  "@grammyjs/runner": "^2.0.3",
41
22
  "@langfuse/otel": "^4.5.1",
42
23
  "@langfuse/tracing": "^4.5.1",
43
- "@mariozechner/pi-ai": "^0.51.6",
44
24
  "@opentelemetry/api": "^1.9.0",
45
25
  "@opentelemetry/core": "^2.0.1",
46
26
  "@opentelemetry/exporter-trace-otlp-http": "^0.202.0",
@@ -49,18 +29,13 @@
49
29
  "croner": "^8.0.0",
50
30
  "discord.js": "^14.25.1",
51
31
  "dotenv": "^16.4.0",
52
- "fastify": "^4.26.0",
32
+ "fastify": "^5.7.2",
53
33
  "grammy": "^1.21.1",
54
34
  "gray-matter": "^4.0.3",
55
35
  "mcporter": "^0.7.3",
56
36
  "openai": "^4.47.0",
57
37
  "playwright": "^1.49.0"
58
38
  },
59
- "pnpm": {
60
- "onlyBuiltDependencies": [
61
- "esbuild"
62
- ]
63
- },
64
39
  "devDependencies": {
65
40
  "@eslint/js": "^9.39.2",
66
41
  "@types/node": "^20.11.0",
@@ -70,5 +45,24 @@
70
45
  "typescript": "^5.4.0",
71
46
  "typescript-eslint": "^8.54.0",
72
47
  "vitest": "^4.0.18"
48
+ },
49
+ "scripts": {
50
+ "cli": "tsx src/cli.ts",
51
+ "start": "tsx src/index.ts",
52
+ "dev": "tsx watch src/index.ts",
53
+ "dashboard:dev": "pnpm --dir web/dashboard dev",
54
+ "dashboard:build": "pnpm --dir web/dashboard install --frozen-lockfile && pnpm --dir web/dashboard build",
55
+ "docs:dev": "pnpm --dir docs dev",
56
+ "docs:build": "pnpm --dir docs install --frozen-lockfile && pnpm --dir docs build",
57
+ "docs:preview": "pnpm --dir docs preview",
58
+ "setup": "tsx src/setup.ts",
59
+ "onboard": "tsx src/cli.ts onboard",
60
+ "build": "tsc && pnpm dashboard:build",
61
+ "release:check": "pnpm build && pnpm test",
62
+ "release:local": "bash ./scripts/release.sh",
63
+ "lint": "eslint \"src/**/*.ts\"",
64
+ "typecheck": "tsc --noEmit",
65
+ "test": "vitest run",
66
+ "ci": "pnpm run lint && pnpm run typecheck && pnpm run test"
73
67
  }
74
- }
68
+ }