skimpyclaw 0.3.5 → 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 (71) hide show
  1. package/README.md +14 -6
  2. package/dist/__tests__/api.test.js +1 -19
  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 +10 -7
  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 -85
  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 +186 -17
  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-BoTHPby4.js +65 -0
  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.d.ts +2 -1
  52. package/dist/setup.js +156 -34
  53. package/dist/skills.js +9 -2
  54. package/dist/subagent.js +33 -2
  55. package/dist/tools/bash-tool.js +8 -0
  56. package/dist/tools/browser-tool.js +3 -2
  57. package/dist/tools/definitions.d.ts +0 -27
  58. package/dist/tools/definitions.js +0 -18
  59. package/dist/tools/execute-context.d.ts +4 -4
  60. package/dist/tools/file-tools.d.ts +1 -1
  61. package/dist/tools/file-tools.js +1 -1
  62. package/dist/tools.d.ts +5 -5
  63. package/dist/tools.js +87 -98
  64. package/dist/types.d.ts +14 -22
  65. package/dist/usage.d.ts +1 -0
  66. package/dist/usage.js +30 -46
  67. package/dist/utils.d.ts +18 -0
  68. package/dist/utils.js +71 -0
  69. package/dist/voice.js +9 -7
  70. package/package.json +1 -1
  71. package/dist/dashboard/assets/index-UVAjSXCG.js +0 -107
@@ -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.d.ts CHANGED
@@ -21,8 +21,9 @@ interface SetupStarters {
21
21
  cronWeather: boolean;
22
22
  timezone: string;
23
23
  weatherLocation: string;
24
- skillCodeReview: boolean;
25
24
  skillDailyNotes: boolean;
25
+ skillWeather: boolean;
26
+ skillWebSearch: boolean;
26
27
  }
27
28
  interface SetupBuildInput {
28
29
  workspaceDir: string;
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',
@@ -460,17 +483,20 @@ export function buildSetupConfig(input) {
460
483
  cronWeather: false,
461
484
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
462
485
  weatherLocation: 'New York, NY',
463
- skillCodeReview: false,
464
486
  skillDailyNotes: false,
487
+ skillWeather: false,
488
+ skillWebSearch: false,
465
489
  };
466
490
  const basePaths = ['${HOME}/.skimpyclaw'];
467
491
  const allPaths = [...basePaths, ...(input.extraAllowedPaths || [])];
468
492
  const starterCronJobs = buildStarterCronJobs(starters);
469
493
  const starterSkillEntries = {};
470
- if (starters.skillCodeReview)
471
- starterSkillEntries['code-review'] = true;
472
494
  if (starters.skillDailyNotes)
473
495
  starterSkillEntries['daily-notes'] = true;
496
+ if (starters.skillWeather)
497
+ starterSkillEntries['weather'] = true;
498
+ if (starters.skillWebSearch)
499
+ starterSkillEntries['web-search'] = true;
474
500
  return {
475
501
  gateway: {
476
502
  port: 18790,
@@ -529,7 +555,7 @@ export function buildSetupConfig(input) {
529
555
  jobs: starterCronJobs,
530
556
  },
531
557
  heartbeat: {
532
- intervalMs: 1800000,
558
+ intervalMs: 3600000,
533
559
  prompt: 'Read ~/.skimpyclaw/agents/main/HEARTBEAT.md. Follow it strictly. If nothing needs attention, reply HEARTBEAT_OK.',
534
560
  tools: {
535
561
  enabled: true,
@@ -595,19 +621,6 @@ const REQUIRED_TEMPLATE_DEFAULTS = {
595
621
  'HEARTBEAT.md': '# HEARTBEAT\n\nIf nothing needs attention, reply HEARTBEAT_OK.\n',
596
622
  };
597
623
  const STARTER_SKILL_TEMPLATES = {
598
- 'code-review': `---
599
- name: code-review
600
- description: Structured code review checklist for bugs, regressions, and missing tests.
601
- triggers: ["review", "pr", "regression", "tests"]
602
- priority: 80
603
- ---
604
-
605
- When asked to review code:
606
- 1. Focus on correctness and regressions first.
607
- 2. Call out missing or weak test coverage.
608
- 3. Prefer concrete file-level findings.
609
- 4. End with risk summary and recommended fixes.
610
- `,
611
624
  'daily-notes': `---
612
625
  name: daily-notes
613
626
  description: Keep daily notes organized under the configured daily notes directory.
@@ -620,6 +633,92 @@ When writing daily notes:
620
633
  2. Include sections: Priorities, Schedule, Notes, Follow-ups.
621
634
  3. Keep entries concise and actionable.
622
635
  4. Avoid creating files outside the configured daily notes directory.
636
+ `,
637
+ 'weather': `---
638
+ name: weather
639
+ description: Fetch and format weather data for daily briefings and quick checks.
640
+ triggers: ["weather", "forecast", "temperature", "rain"]
641
+ priority: 45
642
+ ---
643
+
644
+ When asked about weather or generating a daily briefing:
645
+ 1. Use web search to find current weather for the user's location.
646
+ 2. Format as: conditions, high/low temps, precipitation chance.
647
+ 3. Keep it to 2-3 sentences max.
648
+ 4. Include any weather alerts if present.
649
+ 5. For daily briefings: mention if rain is expected (affects outdoor plans).
650
+ `,
651
+ 'web-search': `---
652
+ name: web-search
653
+ description: Search the web using the Browser tool. Opens DuckDuckGo, reads results, and returns findings.
654
+ triggers: ["search", "look up", "google", "find online", "web search"]
655
+ priority: 50
656
+ ---
657
+
658
+ When asked to search the web:
659
+ 1. Use the Browser tool to open https://html.duckduckgo.com/html/?q=<URL-encoded query>
660
+ 2. Use getText to read the search results page.
661
+ 3. If a specific result looks promising, open that URL and extract the relevant content.
662
+ 4. Summarize findings concisely — include source URLs.
663
+ 5. Close the browser when done.
664
+
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.
623
722
  `,
624
723
  };
625
724
  function ensureCoreTemplates(agentDir) {
@@ -637,11 +736,13 @@ function ensureStarterSkills(starters) {
637
736
  const created = [];
638
737
  const skillsDir = join(CONFIG_DIR, 'skills');
639
738
  mkdirSync(skillsDir, { recursive: true });
640
- const requested = [];
641
- if (starters.skillCodeReview)
642
- requested.push('code-review');
739
+ const requested = ['duckduckgo-html-search']; // always installed
643
740
  if (starters.skillDailyNotes)
644
741
  requested.push('daily-notes');
742
+ if (starters.skillWeather)
743
+ requested.push('weather');
744
+ if (starters.skillWebSearch)
745
+ requested.push('web-search');
645
746
  for (const skillName of requested) {
646
747
  const dir = join(skillsDir, skillName);
647
748
  const skillPath = join(dir, 'SKILL.md');
@@ -668,7 +769,7 @@ async function validateTelegramToken(token) {
668
769
  return { ok: true, detail: body.result?.username ? `@${body.result.username}` : 'valid token' };
669
770
  }
670
771
  catch (err) {
671
- return { ok: false, detail: err instanceof Error ? err.message : String(err) };
772
+ return { ok: false, detail: toErrorMessage(err) };
672
773
  }
673
774
  }
674
775
  async function validateProviderAuth(providers, secrets) {
@@ -687,7 +788,7 @@ async function validateProviderAuth(providers, secrets) {
687
788
  checks.push({ name: 'Anthropic API', ok: res.ok, detail: res.ok ? 'auth ok' : `HTTP ${res.status}` });
688
789
  }
689
790
  catch (err) {
690
- 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) });
691
792
  }
692
793
  }
693
794
  if (providers.has('openai-api') && secrets.openaiKey) {
@@ -698,7 +799,7 @@ async function validateProviderAuth(providers, secrets) {
698
799
  checks.push({ name: 'OpenAI API', ok: res.ok, detail: res.ok ? 'auth ok' : `HTTP ${res.status}` });
699
800
  }
700
801
  catch (err) {
701
- 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) });
702
803
  }
703
804
  }
704
805
  if (providers.has('minimax-api') && secrets.minimaxKey) {
@@ -715,7 +816,7 @@ async function validateProviderAuth(providers, secrets) {
715
816
  checks.push({ name: 'MiniMax API', ok: res.ok, detail: res.ok ? 'auth ok' : `HTTP ${res.status}` });
716
817
  }
717
818
  catch (err) {
718
- 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) });
719
820
  }
720
821
  }
721
822
  if (providers.has('codex-oauth')) {
@@ -990,20 +1091,29 @@ export async function runSetup(options = {}) {
990
1091
  if (addTechNewsCron || addWeatherCron) {
991
1092
  const tzInput = await ask(rl, ` Timezone for starter cron jobs [${localTz}]: `);
992
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
+ }
993
1101
  }
994
1102
  if (addWeatherCron) {
995
1103
  const locationInput = await ask(rl, ' Weather location (city, state/country) [New York, NY]: ');
996
1104
  weatherLocation = locationInput || 'New York, NY';
997
1105
  }
998
- const addCodeReviewSkill = /^y(es)?$/i.test(await ask(rl, ' Add starter skill: code-review? [y/N]: '));
999
1106
  const addDailyNotesSkill = /^y(es)?$/i.test(await ask(rl, ' Add starter skill: daily-notes? [y/N]: '));
1107
+ const addWeatherSkill = /^y(es)?$/i.test(await ask(rl, ' Add starter skill: weather? [y/N]: '));
1108
+ const addWebSearchSkill = /^y(es)?$/i.test(await ask(rl, ' Add starter skill: web-search (uses Browser tool)? [y/N]: '));
1000
1109
  const starters = {
1001
1110
  cronTechNews: addTechNewsCron,
1002
1111
  cronWeather: addWeatherCron,
1003
1112
  timezone: cronTimezone,
1004
1113
  weatherLocation,
1005
- skillCodeReview: addCodeReviewSkill,
1006
1114
  skillDailyNotes: addDailyNotesSkill,
1115
+ skillWeather: addWeatherSkill,
1116
+ skillWebSearch: addWebSearchSkill,
1007
1117
  };
1008
1118
  const { envContent, config: generatedConfig } = buildSetupArtifacts({
1009
1119
  workspaceDir: extraAllowedPaths[0] || join(homedir(), '.skimpyclaw'),
@@ -1019,7 +1129,7 @@ export async function runSetup(options = {}) {
1019
1129
  features,
1020
1130
  starters,
1021
1131
  });
1022
- // On reconfigure, preserve dashboard token, cron jobs, subagents, security, langfuse
1132
+ // On reconfigure, preserve dashboard token, cron jobs, codeAgents config, security, langfuse
1023
1133
  if (isReconfigure && existing.config) {
1024
1134
  if (existing.config.dashboard?.token) {
1025
1135
  generatedConfig.dashboard = existing.config.dashboard;
@@ -1038,8 +1148,8 @@ export async function runSetup(options = {}) {
1038
1148
  }
1039
1149
  generatedConfig.cron = { ...(existing.config.cron || {}), jobs: mergedCronJobs };
1040
1150
  }
1041
- if (existing.config.subagents) {
1042
- generatedConfig.subagents = existing.config.subagents;
1151
+ if (existing.config.codeAgents) {
1152
+ generatedConfig.codeAgents = existing.config.codeAgents;
1043
1153
  }
1044
1154
  if (existing.config.security) {
1045
1155
  generatedConfig.security = existing.config.security;
@@ -1191,13 +1301,25 @@ export async function runSetup(options = {}) {
1191
1301
  console.log(` Token: ${c.cyan(dashboardToken)}`);
1192
1302
  console.log(` ${c.dim('(also available via: skimpyclaw status)')}`);
1193
1303
  console.log('\nNext steps:');
1194
- console.log('1. Review templates in ~/.skimpyclaw/agents/main/');
1195
- 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:`);
1196
1318
  console.log(' skimpyclaw start --daemon');
1197
- console.log('3. Check health:');
1319
+ console.log(`${step++}. Check health:`);
1198
1320
  console.log(' skimpyclaw status');
1199
- console.log(`4. Optional daemon controls: skimpyclaw stop | skimpyclaw restart`);
1200
- 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'}`);
1201
1323
  console.log('\n👙🦞 Enjoy!');
1202
1324
  }
1203
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
  ? {