skimpyclaw 0.3.6 → 0.3.8

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 (69) hide show
  1. package/README.md +14 -6
  2. package/dist/__tests__/api.test.js +1 -0
  3. package/dist/__tests__/channels.test.js +1 -1
  4. package/dist/__tests__/code-agents-orchestrator.test.js +74 -7
  5. package/dist/__tests__/code-agents-sandbox.test.d.ts +1 -0
  6. package/dist/__tests__/code-agents-sandbox.test.js +163 -0
  7. package/dist/__tests__/context-manager.test.d.ts +1 -0
  8. package/dist/__tests__/context-manager.test.js +236 -0
  9. package/dist/__tests__/package-manager-detection.test.js +5 -5
  10. package/dist/__tests__/setup.test.js +7 -5
  11. package/dist/__tests__/skills.test.js +2 -2
  12. package/dist/__tests__/structured-context.test.d.ts +1 -0
  13. package/dist/__tests__/structured-context.test.js +100 -0
  14. package/dist/__tests__/tools.test.js +65 -3
  15. package/dist/agent.js +4 -5
  16. package/dist/api.js +10 -58
  17. package/dist/audit.js +5 -51
  18. package/dist/channels/telegram/handlers.js +2 -60
  19. package/dist/channels/telegram/index.js +0 -7
  20. package/dist/channels.js +1 -1
  21. package/dist/cli.js +151 -16
  22. package/dist/code-agents/executor.d.ts +9 -4
  23. package/dist/code-agents/executor.js +187 -13
  24. package/dist/code-agents/index.d.ts +1 -1
  25. package/dist/code-agents/index.js +23 -21
  26. package/dist/code-agents/orchestrator.d.ts +8 -2
  27. package/dist/code-agents/orchestrator.js +297 -27
  28. package/dist/code-agents/structured-context.d.ts +7 -0
  29. package/dist/code-agents/structured-context.js +54 -0
  30. package/dist/code-agents/types.d.ts +2 -0
  31. package/dist/code-agents/utils.js +12 -2
  32. package/dist/code-agents/worktree.d.ts +40 -0
  33. package/dist/code-agents/worktree.js +215 -0
  34. package/dist/config.d.ts +1 -0
  35. package/dist/config.js +5 -3
  36. package/dist/cron.js +18 -4
  37. package/dist/dashboard/assets/{index-CkonC7Cd.js → index-BoTHPby4.js} +20 -20
  38. package/dist/dashboard/assets/{index-EAg6lqF5.css → index-D4mufvBg.css} +1 -1
  39. package/dist/dashboard/index.html +2 -2
  40. package/dist/discord.js +4 -40
  41. package/dist/exec-approval.js +1 -1
  42. package/dist/file-lock.js +1 -1
  43. package/dist/gateway.js +3 -10
  44. package/dist/providers/anthropic.js +9 -5
  45. package/dist/providers/codex.js +10 -6
  46. package/dist/providers/context-manager.d.ts +22 -0
  47. package/dist/providers/context-manager.js +100 -0
  48. package/dist/providers/openai.js +9 -5
  49. package/dist/providers/types.d.ts +1 -0
  50. package/dist/security.js +9 -0
  51. package/dist/setup.js +112 -14
  52. package/dist/skills.js +9 -2
  53. package/dist/subagent.js +33 -2
  54. package/dist/tools/bash-tool.js +8 -0
  55. package/dist/tools/browser-tool.js +2 -1
  56. package/dist/tools/definitions.d.ts +0 -27
  57. package/dist/tools/definitions.js +0 -18
  58. package/dist/tools/execute-context.d.ts +4 -4
  59. package/dist/tools/file-tools.d.ts +1 -1
  60. package/dist/tools/file-tools.js +1 -1
  61. package/dist/tools.d.ts +5 -5
  62. package/dist/tools.js +87 -98
  63. package/dist/types.d.ts +14 -22
  64. package/dist/usage.d.ts +1 -0
  65. package/dist/usage.js +30 -46
  66. package/dist/utils.d.ts +18 -0
  67. package/dist/utils.js +71 -0
  68. package/dist/voice.js +9 -7
  69. package/package.json +1 -1
@@ -0,0 +1,100 @@
1
+ // Context manager for agentic tool loops.
2
+ // When accumulated messages exceed the token threshold, compacts old tool results
3
+ // to keep context size bounded without breaking message structure.
4
+ //
5
+ // Key constraint: tool_use/tool_result pairs (Anthropic) and
6
+ // function_call/function_call_output pairs (Codex) must stay structurally intact.
7
+ // We truncate the CONTENT of old results — never remove blocks entirely.
8
+ const DEFAULT_MAX_CONTEXT_TOKENS = 200_000;
9
+ const KEEP_TAIL = 8; // always keep last N messages/items untouched
10
+ const RESULT_MAX_CHARS = 500; // compact old results to this length
11
+ /** Rough token estimate: 1 token ≈ 4 chars of JSON. */
12
+ export function estimateTokens(data) {
13
+ return Math.ceil(JSON.stringify(data).length / 4);
14
+ }
15
+ /**
16
+ * Compact Anthropic-format apiMessages when over threshold.
17
+ * Truncates content of old tool_result blocks; leaves last KEEP_TAIL messages intact.
18
+ * Does NOT mutate the input array — returns a new array.
19
+ */
20
+ export function compactAnthropicMessages(messages, config, iteration = 0) {
21
+ if (config?.enabled === false)
22
+ return messages;
23
+ const maxTokens = config?.maxContextTokens ?? DEFAULT_MAX_CONTEXT_TOKENS;
24
+ const estimated = estimateTokens(messages);
25
+ if (estimated <= maxTokens)
26
+ return messages;
27
+ console.log(`[context-manager] Compacting at iteration ${iteration} (~${Math.round(estimated / 1000)}k tokens > ${Math.round(maxTokens / 1000)}k threshold)`);
28
+ const tail = messages.slice(-KEEP_TAIL);
29
+ const head = messages.slice(0, -KEEP_TAIL);
30
+ const compacted = head.map(msg => {
31
+ if (!Array.isArray(msg.content))
32
+ return msg;
33
+ let changed = false;
34
+ const newContent = msg.content.map((block) => {
35
+ if (block.type !== 'tool_result')
36
+ return block;
37
+ const raw = typeof block.content === 'string'
38
+ ? block.content
39
+ : JSON.stringify(block.content);
40
+ if (raw.length <= RESULT_MAX_CHARS)
41
+ return block;
42
+ changed = true;
43
+ return { ...block, content: raw.slice(0, RESULT_MAX_CHARS) + ' [truncated]' };
44
+ });
45
+ return changed ? { ...msg, content: newContent } : msg;
46
+ });
47
+ return [...compacted, ...tail];
48
+ }
49
+ /**
50
+ * Compact OpenAI-format apiMessages when over threshold.
51
+ * Truncates content of old `role: 'tool'` messages; leaves last KEEP_TAIL messages intact.
52
+ * Does NOT mutate the input array — returns a new array.
53
+ */
54
+ export function compactOpenAIMessages(messages, config, iteration = 0) {
55
+ if (config?.enabled === false)
56
+ return messages;
57
+ const maxTokens = config?.maxContextTokens ?? DEFAULT_MAX_CONTEXT_TOKENS;
58
+ const estimated = estimateTokens(messages);
59
+ if (estimated <= maxTokens)
60
+ return messages;
61
+ console.log(`[context-manager] Compacting OpenAI messages at iteration ${iteration} (~${Math.round(estimated / 1000)}k tokens > ${Math.round(maxTokens / 1000)}k threshold)`);
62
+ const tail = messages.slice(-KEEP_TAIL);
63
+ const head = messages.slice(0, -KEEP_TAIL);
64
+ const compacted = head.map(msg => {
65
+ if (msg.role !== 'tool')
66
+ return msg;
67
+ if (typeof msg.content !== 'string')
68
+ return msg;
69
+ if (msg.content.length <= RESULT_MAX_CHARS)
70
+ return msg;
71
+ return { ...msg, content: msg.content.slice(0, RESULT_MAX_CHARS) + ' [truncated]' };
72
+ });
73
+ return [...compacted, ...tail];
74
+ }
75
+ /**
76
+ * Compact Codex-format input items when over threshold.
77
+ * Truncates output of old function_call_output items; leaves last KEEP_TAIL items intact.
78
+ * Does NOT mutate the input array — returns a new array.
79
+ */
80
+ export function compactCodexMessages(input, config, iteration = 0) {
81
+ if (config?.enabled === false)
82
+ return input;
83
+ const maxTokens = config?.maxContextTokens ?? DEFAULT_MAX_CONTEXT_TOKENS;
84
+ const estimated = estimateTokens(input);
85
+ if (estimated <= maxTokens)
86
+ return input;
87
+ console.log(`[context-manager] Compacting Codex input at iteration ${iteration} (~${Math.round(estimated / 1000)}k tokens > ${Math.round(maxTokens / 1000)}k threshold)`);
88
+ const tail = input.slice(-KEEP_TAIL);
89
+ const head = input.slice(0, -KEEP_TAIL);
90
+ const compacted = head.map(item => {
91
+ if (item.type !== 'function_call_output')
92
+ return item;
93
+ if (typeof item.output !== 'string')
94
+ return item;
95
+ if (item.output.length <= RESULT_MAX_CHARS)
96
+ return item;
97
+ return { ...item, output: item.output.slice(0, RESULT_MAX_CHARS) + ' [truncated]' };
98
+ });
99
+ return [...compacted, ...tail];
100
+ }
@@ -1,9 +1,11 @@
1
1
  // OpenAI-Compatible Provider (OpenAI, OpenRouter, Groq, etc.)
2
2
  import { startObservation } from '@langfuse/tracing';
3
3
  import { stripProvider, toOpenAITools, truncateToolResult } from './utils.js';
4
+ import { compactOpenAIMessages } from './context-manager.js';
4
5
  import { toOpenAIContent } from './content.js';
5
6
  import { toUsageDetails, toCostDetails } from './observability.js';
6
7
  import { getToolDefinitions, executeTool } from '../tools.js';
8
+ import { toErrorMessage } from '../utils.js';
7
9
  import { ToolCallGuard } from './tool-guard.js';
8
10
  import { addEvent } from '../audit.js';
9
11
  import { buildUsageRecord, recordUsage } from '../usage.js';
@@ -106,7 +108,7 @@ export async function chatOpenAI(params, provider) {
106
108
  return content;
107
109
  }
108
110
  catch (err) {
109
- const errorMessage = err instanceof Error ? err.message : String(err);
111
+ const errorMessage = toErrorMessage(err);
110
112
  genObs?.update({ level: 'ERROR', statusMessage: errorMessage, output: { error: errorMessage } });
111
113
  genObs?.end();
112
114
  throw err;
@@ -123,7 +125,7 @@ export async function chatWithToolsOpenAI(params, provider) {
123
125
  // Resolve tools once at start
124
126
  const includeSpawn = !!(toolContext?.fullConfig && (toolContext?.chatId || toolContext?.isCronJob));
125
127
  const toolDefs = await getToolDefinitions(toolConfig, {
126
- includeSpawnSubagent: includeSpawn,
128
+ includeAgentTools: includeSpawn,
127
129
  includeMcp: false,
128
130
  projects: toolContext?.fullConfig?.projects
129
131
  });
@@ -150,6 +152,8 @@ export async function chatWithToolsOpenAI(params, provider) {
150
152
  toolCalls: toolLog,
151
153
  };
152
154
  }
155
+ // Compact old tool results if context is growing large
156
+ const messagesForApi = compactOpenAIMessages(apiMessages, toolConfig.contextManagement, i + 1);
153
157
  console.log(`[agent:openai-tools] Iteration ${i + 1}/${maxIterations} (provider: ${provider}, model: ${modelId})`);
154
158
  const genObs = await startGenerationObservation(`${provider}:${modelId}`, {
155
159
  input: { messages: apiMessages },
@@ -164,7 +168,7 @@ export async function chatWithToolsOpenAI(params, provider) {
164
168
  try {
165
169
  completion = await client.chat.completions.create({
166
170
  model: modelId,
167
- messages: apiMessages,
171
+ messages: messagesForApi,
168
172
  tools: openaiTools,
169
173
  max_tokens: options.maxTokens || 4096,
170
174
  temperature: options.temperature,
@@ -187,7 +191,7 @@ export async function chatWithToolsOpenAI(params, provider) {
187
191
  guard.recordTokens(completion.usage?.prompt_tokens ?? 0, completion.usage?.completion_tokens ?? 0);
188
192
  }
189
193
  catch (err) {
190
- const errorMessage = err instanceof Error ? err.message : String(err);
194
+ const errorMessage = toErrorMessage(err);
191
195
  genObs?.update({ level: 'ERROR', statusMessage: errorMessage, output: { error: errorMessage } });
192
196
  genObs?.end();
193
197
  throw err;
@@ -315,7 +319,7 @@ export async function chatWithToolsOpenAI(params, provider) {
315
319
  }
316
320
  }
317
321
  catch (err) {
318
- const errorMessage = err instanceof Error ? err.message : String(err);
322
+ const errorMessage = toErrorMessage(err);
319
323
  toolObs?.update({ level: 'ERROR', statusMessage: errorMessage, output: { error: errorMessage } });
320
324
  toolObs?.end();
321
325
  if (toolContext?.auditTraceId) {
@@ -1,5 +1,6 @@
1
1
  import type { Config, ChatMessage, ChatOptions, ToolConfig } from '../types.js';
2
2
  import type { ExecuteToolContext } from '../tools/execute-context.js';
3
+ export type ContextManagementConfig = NonNullable<ToolConfig['contextManagement']>;
3
4
  export interface ToolChatResult {
4
5
  response: string;
5
6
  toolCalls: string[];
package/dist/security.js CHANGED
@@ -77,10 +77,19 @@ export function isRateLimited(userId) {
77
77
  const timestamps = rateLimiter.get(key) || [];
78
78
  const recent = timestamps.filter(t => now - t < WINDOW_MS);
79
79
  if (recent.length >= RATE_LIMIT) {
80
+ rateLimiter.set(key, recent);
80
81
  return true;
81
82
  }
82
83
  recent.push(now);
83
84
  rateLimiter.set(key, recent);
85
+ // Prune stale entries periodically (every 100th call)
86
+ if (rateLimiter.size > 50) {
87
+ for (const [k, ts] of rateLimiter) {
88
+ if (ts.every(t => now - t >= WINDOW_MS)) {
89
+ rateLimiter.delete(k);
90
+ }
91
+ }
92
+ }
84
93
  return false;
85
94
  }
86
95
  export function clearRateLimiter() {
package/dist/setup.js CHANGED
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'url';
7
7
  import { spawnSync } from 'child_process';
8
8
  import { randomUUID } from 'crypto';
9
9
  import { runDoctor as runDoctorChecks } from './doctor/runner.js';
10
+ import { toErrorMessage } from './utils.js';
10
11
  const __filename = fileURLToPath(import.meta.url);
11
12
  const __dirname = dirname(__filename);
12
13
  // ANSI color helpers (no chalk dependency)
@@ -224,6 +225,28 @@ async function askProviders(rl, existingProviders) {
224
225
  }
225
226
  function buildStarterCronJobs(starters) {
226
227
  const jobs = [];
228
+ // Memory trim is always included — runs 2x/day on a cheap model
229
+ jobs.push({
230
+ id: 'memory-trim',
231
+ name: 'Memory Trim',
232
+ model: 'claude-haiku',
233
+ schedule: {
234
+ kind: 'cron',
235
+ expr: '0 0,12 * * *',
236
+ tz: starters.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
237
+ },
238
+ payload: {
239
+ kind: 'agentTurn',
240
+ message: '~/.skimpyclaw/prompts/memory-trim.md',
241
+ tools: {
242
+ enabled: true,
243
+ allowedPaths: [`${homedir()}/.skimpyclaw`],
244
+ maxIterations: 30,
245
+ bashTimeout: 10000,
246
+ toolProfile: 'minimal',
247
+ },
248
+ },
249
+ });
227
250
  if (starters.cronTechNews) {
228
251
  jobs.push({
229
252
  id: 'tech-digest',
@@ -532,7 +555,7 @@ export function buildSetupConfig(input) {
532
555
  jobs: starterCronJobs,
533
556
  },
534
557
  heartbeat: {
535
- intervalMs: 1800000,
558
+ intervalMs: 3600000,
536
559
  prompt: 'Read ~/.skimpyclaw/agents/main/HEARTBEAT.md. Follow it strictly. If nothing needs attention, reply HEARTBEAT_OK.',
537
560
  tools: {
538
561
  enabled: true,
@@ -640,6 +663,62 @@ When asked to search the web:
640
663
  5. Close the browser when done.
641
664
 
642
665
  Do NOT fabricate results. If the search returns nothing useful, say so.
666
+ `,
667
+ 'duckduckgo-html-search': `---
668
+ name: duckduckgo-html-search
669
+ description: Search the web via DuckDuckGo HTML results using the Browser tool
670
+ emoji: 🦆
671
+ tags: [search, web, browser]
672
+ priority: 45
673
+ enabled: true
674
+ ---
675
+
676
+ # DuckDuckGo HTML Search Skill
677
+
678
+ Use this skill when the user asks for web search, source gathering, or lightweight browsing.
679
+
680
+ ## Priority rule
681
+ DuckDuckGo HTML via Browser is the default search path.
682
+ - Prefer DuckDuckGo first, even if \\\`$web_search\\\` is available.
683
+ - Use \\\`$web_search\\\` only when the user explicitly asks for it, DuckDuckGo is blocked, or Browser is unavailable.
684
+
685
+ ## Default workflow
686
+ 1. Build query URL: \\\`https://duckduckgo.com/html/?q=<urlencoded query>\\\`
687
+ 2. Open the URL with Browser.
688
+ 3. Wait for result anchors (\\\`a.result__a\\\`) or fallback body text.
689
+ 4. Extract results using one Browser \\\`evaluate\\\` call when possible.
690
+ 5. Return only actually extracted items (never pad count).
691
+
692
+ ## Extraction requirements
693
+ For each result, capture when available:
694
+ - title
695
+ - url
696
+ - snippet
697
+
698
+ If a field is missing, set it to \\\`UNAVAILABLE\\\`.
699
+
700
+ ## Integrity rules
701
+ - Never fabricate results.
702
+ - If the page blocks, fails, or no results render, return \\\`UNAVAILABLE\\\` and state why.
703
+ - Never mix real and invented entries.
704
+ - Include source URLs in output.
705
+
706
+ ## Browser strategy
707
+ - Prefer one-page extraction via \\\`evaluate\\\`:
708
+ - Collect \\\`a.result__a\\\` for title + href
709
+ - Collect nearby snippet nodes (\\\`.result__snippet\\\`) when present
710
+ - Use minimal actions: open → waitFor → evaluate → optional screenshot.
711
+ - If selectors change, fallback to visible text extraction and clearly mark reduced confidence.
712
+
713
+ ## Output format (concise)
714
+ - Query used
715
+ - Result count actually extracted
716
+ - Bulleted results with title + URL + snippet
717
+ - Notes section for failures/limits
718
+
719
+ ## Safe defaults
720
+ - Default top results target: 5 (or user-specified)
721
+ - If user asks for deep research, gather multiple queries but keep each query's extraction explicit and separated.
643
722
  `,
644
723
  };
645
724
  function ensureCoreTemplates(agentDir) {
@@ -657,7 +736,7 @@ function ensureStarterSkills(starters) {
657
736
  const created = [];
658
737
  const skillsDir = join(CONFIG_DIR, 'skills');
659
738
  mkdirSync(skillsDir, { recursive: true });
660
- const requested = [];
739
+ const requested = ['duckduckgo-html-search']; // always installed
661
740
  if (starters.skillDailyNotes)
662
741
  requested.push('daily-notes');
663
742
  if (starters.skillWeather)
@@ -690,7 +769,7 @@ async function validateTelegramToken(token) {
690
769
  return { ok: true, detail: body.result?.username ? `@${body.result.username}` : 'valid token' };
691
770
  }
692
771
  catch (err) {
693
- return { ok: false, detail: err instanceof Error ? err.message : String(err) };
772
+ return { ok: false, detail: toErrorMessage(err) };
694
773
  }
695
774
  }
696
775
  async function validateProviderAuth(providers, secrets) {
@@ -709,7 +788,7 @@ async function validateProviderAuth(providers, secrets) {
709
788
  checks.push({ name: 'Anthropic API', ok: res.ok, detail: res.ok ? 'auth ok' : `HTTP ${res.status}` });
710
789
  }
711
790
  catch (err) {
712
- checks.push({ name: 'Anthropic API', ok: false, detail: err instanceof Error ? err.message : String(err) });
791
+ checks.push({ name: 'Anthropic API', ok: false, detail: toErrorMessage(err) });
713
792
  }
714
793
  }
715
794
  if (providers.has('openai-api') && secrets.openaiKey) {
@@ -720,7 +799,7 @@ async function validateProviderAuth(providers, secrets) {
720
799
  checks.push({ name: 'OpenAI API', ok: res.ok, detail: res.ok ? 'auth ok' : `HTTP ${res.status}` });
721
800
  }
722
801
  catch (err) {
723
- checks.push({ name: 'OpenAI API', ok: false, detail: err instanceof Error ? err.message : String(err) });
802
+ checks.push({ name: 'OpenAI API', ok: false, detail: toErrorMessage(err) });
724
803
  }
725
804
  }
726
805
  if (providers.has('minimax-api') && secrets.minimaxKey) {
@@ -737,7 +816,7 @@ async function validateProviderAuth(providers, secrets) {
737
816
  checks.push({ name: 'MiniMax API', ok: res.ok, detail: res.ok ? 'auth ok' : `HTTP ${res.status}` });
738
817
  }
739
818
  catch (err) {
740
- checks.push({ name: 'MiniMax API', ok: false, detail: err instanceof Error ? err.message : String(err) });
819
+ checks.push({ name: 'MiniMax API', ok: false, detail: toErrorMessage(err) });
741
820
  }
742
821
  }
743
822
  if (providers.has('codex-oauth')) {
@@ -1012,6 +1091,13 @@ export async function runSetup(options = {}) {
1012
1091
  if (addTechNewsCron || addWeatherCron) {
1013
1092
  const tzInput = await ask(rl, ` Timezone for starter cron jobs [${localTz}]: `);
1014
1093
  cronTimezone = tzInput || localTz;
1094
+ try {
1095
+ Intl.DateTimeFormat(undefined, { timeZone: cronTimezone });
1096
+ }
1097
+ catch {
1098
+ console.log(` ⚠ Invalid timezone "${cronTimezone}", using ${localTz}`);
1099
+ cronTimezone = localTz;
1100
+ }
1015
1101
  }
1016
1102
  if (addWeatherCron) {
1017
1103
  const locationInput = await ask(rl, ' Weather location (city, state/country) [New York, NY]: ');
@@ -1043,7 +1129,7 @@ export async function runSetup(options = {}) {
1043
1129
  features,
1044
1130
  starters,
1045
1131
  });
1046
- // On reconfigure, preserve dashboard token, cron jobs, subagents, security, langfuse
1132
+ // On reconfigure, preserve dashboard token, cron jobs, codeAgents config, security, langfuse
1047
1133
  if (isReconfigure && existing.config) {
1048
1134
  if (existing.config.dashboard?.token) {
1049
1135
  generatedConfig.dashboard = existing.config.dashboard;
@@ -1062,8 +1148,8 @@ export async function runSetup(options = {}) {
1062
1148
  }
1063
1149
  generatedConfig.cron = { ...(existing.config.cron || {}), jobs: mergedCronJobs };
1064
1150
  }
1065
- if (existing.config.subagents) {
1066
- generatedConfig.subagents = existing.config.subagents;
1151
+ if (existing.config.codeAgents) {
1152
+ generatedConfig.codeAgents = existing.config.codeAgents;
1067
1153
  }
1068
1154
  if (existing.config.security) {
1069
1155
  generatedConfig.security = existing.config.security;
@@ -1215,13 +1301,25 @@ export async function runSetup(options = {}) {
1215
1301
  console.log(` Token: ${c.cyan(dashboardToken)}`);
1216
1302
  console.log(` ${c.dim('(also available via: skimpyclaw status)')}`);
1217
1303
  console.log('\nNext steps:');
1218
- console.log('1. Review templates in ~/.skimpyclaw/agents/main/');
1219
- console.log('2. Start the daemon:');
1304
+ let step = 1;
1305
+ console.log(`${step++}. Review templates in ~/.skimpyclaw/agents/main/`);
1306
+ if (enableSandbox) {
1307
+ const runtimeHint = detectedSandboxRuntime === 'docker'
1308
+ ? 'open -a Docker # or start Docker Desktop'
1309
+ : 'container system start';
1310
+ console.log(`${step++}. Start the container runtime (if not already running):`);
1311
+ console.log(` ${runtimeHint}`);
1312
+ console.log(`${step++}. Initialize the sandbox:`);
1313
+ console.log(' skimpyclaw sandbox init');
1314
+ console.log(`${step++}. Verify sandbox is working:`);
1315
+ console.log(' skimpyclaw sandbox doctor');
1316
+ }
1317
+ console.log(`${step++}. Start the daemon:`);
1220
1318
  console.log(' skimpyclaw start --daemon');
1221
- console.log('3. Check health:');
1319
+ console.log(`${step++}. Check health:`);
1222
1320
  console.log(' skimpyclaw status');
1223
- console.log(`4. Optional daemon controls: skimpyclaw stop | skimpyclaw restart`);
1224
- console.log(`5. Send /help in your ${useDiscord ? 'Discord bot DM/server' : 'Telegram bot'}`);
1321
+ console.log(`${step++}. Optional daemon controls: skimpyclaw stop | skimpyclaw restart`);
1322
+ console.log(`${step++}. Send /help in your ${useDiscord ? 'Discord bot DM/server' : 'Telegram bot'}`);
1225
1323
  console.log('\n👙🦞 Enjoy!');
1226
1324
  }
1227
1325
  finally {
package/dist/skills.js CHANGED
@@ -5,18 +5,25 @@ import { homedir } from 'os';
5
5
  import { execSync } from 'child_process';
6
6
  import matter from 'gray-matter';
7
7
  import { TTLCache } from './cache.js';
8
+ import { toErrorMessage } from './utils.js';
8
9
  const DEFAULT_SKILLS_DIR = join(homedir(), '.skimpyclaw', 'skills');
9
10
  const DEFAULT_PRIORITY = 100;
10
11
  /**
11
12
  * Check if a binary exists on PATH.
12
13
  * Returns true if found, false otherwise.
13
14
  */
15
+ const binExistsCache = new Map();
14
16
  function binExists(name) {
17
+ const cached = binExistsCache.get(name);
18
+ if (cached !== undefined)
19
+ return cached;
15
20
  try {
16
21
  execSync(`which ${name}`, { stdio: 'ignore' });
22
+ binExistsCache.set(name, true);
17
23
  return true;
18
24
  }
19
25
  catch {
26
+ binExistsCache.set(name, false);
20
27
  return false;
21
28
  }
22
29
  }
@@ -64,7 +71,7 @@ export function checkEligibility(skill, toolConfig) {
64
71
  if (!toolConfig.browser?.enabled)
65
72
  missing.push(tool);
66
73
  }
67
- // spawn_subagent and other built-ins are available whenever tools.enabled is true
74
+ // built-in tools are available whenever tools.enabled is true
68
75
  }
69
76
  if (missing.length > 0) {
70
77
  return { eligible: false, reason: `Tools not enabled (needs: ${missing.join(', ')})` };
@@ -128,7 +135,7 @@ function parseSkillFile(dirPath, dirName, skillConfig, toolConfig) {
128
135
  };
129
136
  }
130
137
  catch (err) {
131
- const msg = err instanceof Error ? err.message : String(err);
138
+ const msg = toErrorMessage(err);
132
139
  console.warn(`[skills] Failed to parse ${skillPath}: ${msg}`);
133
140
  return null;
134
141
  }
package/dist/subagent.js CHANGED
@@ -18,7 +18,8 @@ const PRESETS = {
18
18
  enabled: true,
19
19
  allowedPaths: [join(homedir(), '.skimpyclaw')],
20
20
  maxIterations: 50,
21
- bashTimeout: 30000
21
+ bashTimeout: 30000,
22
+ toolProfile: 'minimal',
22
23
  },
23
24
  description: 'Code tasks with broad file + bash access'
24
25
  },
@@ -29,7 +30,8 @@ const PRESETS = {
29
30
  enabled: true,
30
31
  allowedPaths: [join(homedir(), '.skimpyclaw')],
31
32
  maxIterations: 30,
32
- bashTimeout: 15000
33
+ bashTimeout: 15000,
34
+ toolProfile: 'minimal',
33
35
  },
34
36
  description: 'Research tasks with configurable file access'
35
37
  },
@@ -63,6 +65,21 @@ within your allowed paths (provided at runtime).
63
65
  You have a LIMITED number of tool iterations. Every message you spend talking about what
64
66
  you're going to do is one less chance to actually do it.
65
67
 
68
+ ## Tool Priority — Use Native Tools First
69
+
70
+ **For file operations, always prefer native tools over bash scripts. They are faster, safer, and use fewer iterations.**
71
+
72
+ | Task | Use this | NOT this |
73
+ |------|----------|----------|
74
+ | List files/dirs | \`Glob\` | \`ls\`, \`find\`, \`python3\` scripts |
75
+ | Read a file | \`Read\` | \`cat\`, \`python3\` scripts |
76
+ | Write a file | \`Write\` | \`echo >\`, \`tee\`, \`python3\` scripts |
77
+ | Run builds/tests | \`Bash\` | — |
78
+ | Git operations | \`Bash\` | — |
79
+ | Install packages | \`Bash\` | — |
80
+
81
+ **NEVER use \`python3 -\`, \`perl -e\`, \`ruby -e\`, or other inline interpreter scripts to explore the filesystem. Use Glob and Read instead.**
82
+
66
83
  ## Your 4 Tools
67
84
 
68
85
  ### Read
@@ -76,6 +93,7 @@ List files and directories at a path. Parameter: \`path\` (string, required)
76
93
 
77
94
  ### Bash
78
95
  Execute a shell command. Parameters: \`command\` (string, required), \`cwd\` (string, optional)
96
+ Reserved for: builds, tests, git, package managers, and commands that have no native tool equivalent.
79
97
 
80
98
  ## Key Paths
81
99
  - Config: ~/.skimpyclaw/config.json
@@ -106,6 +124,18 @@ You are a research subagent dispatched for a specific task.
106
124
 
107
125
  **NEVER say "let me check" or "I'll look into that" — just call the tool.**
108
126
 
127
+ ## Tool Priority — Use Native Tools First
128
+
129
+ **For file operations, always prefer native tools over bash scripts.**
130
+
131
+ | Task | Use this | NOT this |
132
+ |------|----------|----------|
133
+ | List files/dirs | \`Glob\` | \`ls\`, \`find\`, \`python3\` scripts |
134
+ | Read a file | \`Read\` | \`cat\`, \`python3\` scripts |
135
+ | Write a file | \`Write\` | \`echo >\`, \`tee\` |
136
+
137
+ **NEVER use \`python3 -\`, \`perl -e\`, or other inline interpreter scripts to explore the filesystem.**
138
+
109
139
  ## Your 4 Tools
110
140
 
111
141
  ### Read
@@ -119,6 +149,7 @@ List files and directories at a path. Parameter: \`path\` (string, required)
119
149
 
120
150
  ### Bash
121
151
  Execute a shell command. Parameters: \`command\` (string, required), \`cwd\` (string, optional)
152
+ Reserved for: git, package managers, and commands with no native tool equivalent.
122
153
 
123
154
  ## Key Paths
124
155
  - Config: ~/.skimpyclaw/config.json
@@ -37,6 +37,14 @@ export async function executeBash(command, cwd, config, context) {
37
37
  if (approvalConfig?.enabled !== false) {
38
38
  const classification = classifyCommandRisk(command);
39
39
  if (requiresApproval(classification, approvalConfig)) {
40
+ // Unattended contexts (cron, no approver) have no human available to approve.
41
+ // Fast-deny instead of blocking for the full TTL.
42
+ const isUnattended = context?.channel === 'subagent' ||
43
+ context?.isCronJob === true ||
44
+ (!context?.approverUserId && !context?.channelTargetId && !context?.chatId);
45
+ if (isUnattended) {
46
+ return `⛔ Command blocked — tier ${classification.tier} commands require approval but no approver is available in this context (${classification.reason}). Use safer alternatives or request approval via an interactive channel.`;
47
+ }
40
48
  // Build channel metadata from context for notification routing
41
49
  const channelMeta = context?.channel
42
50
  ? {
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import { isPathAllowed } from './path-utils.js';
5
+ import { toErrorMessage } from '../utils.js';
5
6
  let playwrightModule = null;
6
7
  let browserContext = null;
7
8
  let browserPage = null;
@@ -101,7 +102,7 @@ async function ensureBrowser(config, overrides) {
101
102
  });
102
103
  }
103
104
  catch (err) {
104
- const msg = err instanceof Error ? err.message : String(err);
105
+ const msg = toErrorMessage(err);
105
106
  throw new Error(`Failed to launch browser (${options.type}): ${msg}. Ensure the browser is installed: npx playwright install ${options.type}`);
106
107
  }
107
108
  const pages = browserContext.pages();
@@ -305,33 +305,6 @@ export declare const TOOL_DEFINITIONS: ({
305
305
  required: string[];
306
306
  };
307
307
  })[];
308
- export declare const SPAWN_SUBAGENT_TOOL: {
309
- name: string;
310
- description: string;
311
- input_schema: {
312
- type: "object";
313
- properties: {
314
- task: {
315
- type: string;
316
- description: string;
317
- };
318
- type: {
319
- type: string;
320
- enum: string[];
321
- description: string;
322
- };
323
- model: {
324
- type: string;
325
- description: string;
326
- };
327
- label: {
328
- type: string;
329
- description: string;
330
- };
331
- };
332
- required: string[];
333
- };
334
- };
335
308
  export declare const CODE_WITH_AGENT_TOOL: {
336
309
  name: string;
337
310
  description: string;
@@ -112,24 +112,6 @@ export const BROWSER_TOOL_DEFINITION = {
112
112
  };
113
113
  // Legacy export for backward compat — static list (built-ins + browser + no MCP)
114
114
  export const TOOL_DEFINITIONS = [...BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION];
115
- export const SPAWN_SUBAGENT_TOOL = {
116
- name: 'spawn_subagent',
117
- description: 'Spawn a background subagent to handle a task independently. Returns immediately with a run ID. Results are announced back to this chat when done. Use for tasks that benefit from parallel work or long-running operations.',
118
- input_schema: {
119
- type: 'object',
120
- properties: {
121
- task: { type: 'string', description: 'What the subagent should do — be specific and self-contained' },
122
- type: {
123
- type: 'string',
124
- enum: ['coding', 'research'],
125
- description: 'Agent type: coding (code/files/bash), research (investigation/reading)',
126
- },
127
- model: { type: 'string', description: 'Optional model override (e.g. claude-opus, claude-think)' },
128
- label: { type: 'string', description: 'Short label for status display (e.g. "write tests", "check logs")' },
129
- },
130
- required: ['task', 'type'],
131
- },
132
- };
133
115
  export const CODE_WITH_AGENT_TOOL = {
134
116
  name: 'code_with_agent',
135
117
  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.',
@@ -1,13 +1,13 @@
1
1
  export interface ExecuteToolContext {
2
- /** Task ID for file lock acquisition (subagent writes) */
2
+ /** Task ID for file lock acquisition (concurrent writes) */
3
3
  lockTaskId?: string;
4
4
  /** Abort signal for cancelling long-running tool loops */
5
5
  abortSignal?: AbortSignal;
6
- /** Chat ID for spawn_subagent dispatch */
6
+ /** Chat ID for channel routing */
7
7
  chatId?: number;
8
- /** Full config for spawn_subagent */
8
+ /** Full config for agent tools */
9
9
  fullConfig?: import('../types.js').Config;
10
- /** Conversation history for spawn_subagent */
10
+ /** Conversation history */
11
11
  history?: import('../types.js').ChatMessage[];
12
12
  /** Audit trace ID for recording tool events */
13
13
  auditTraceId?: string;
@@ -1,7 +1,7 @@
1
1
  import type { ToolConfig } from '../types.js';
2
2
  export declare function executeReadFile(path: string, config: ToolConfig): string;
3
3
  /**
4
- * Write with file locking when a lockTaskId is provided (subagent context).
4
+ * Write with file locking when a lockTaskId is provided (concurrent context).
5
5
  * Falls back to unlocked write when no lockTaskId.
6
6
  */
7
7
  export declare function executeWriteFileLocked(path: string, content: string, config: ToolConfig, lockTaskId?: string): Promise<string>;
@@ -26,7 +26,7 @@ function executeWriteFile(path, content, config) {
26
26
  return `Written: ${path} (${content.length} bytes)`;
27
27
  }
28
28
  /**
29
- * Write with file locking when a lockTaskId is provided (subagent context).
29
+ * Write with file locking when a lockTaskId is provided (concurrent context).
30
30
  * Falls back to unlocked write when no lockTaskId.
31
31
  */
32
32
  export async function executeWriteFileLocked(path, content, config, lockTaskId) {