skimpyclaw 0.1.7 → 0.1.9

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.
package/README.md CHANGED
@@ -95,6 +95,23 @@ Onboarding validates your Telegram token, provider auth, and creates:
95
95
 
96
96
  ```bash
97
97
  skimpyclaw start
98
+ # or run as launchd daemon on macOS:
99
+ skimpyclaw start --daemon
100
+ ```
101
+
102
+ **Stop/Restart daemon (macOS):**
103
+
104
+ ```bash
105
+ skimpyclaw stop
106
+ skimpyclaw restart
107
+ ```
108
+
109
+ **Uninstall helper:**
110
+
111
+ ```bash
112
+ skimpyclaw uninstall # removes launch agent, keeps ~/.skimpyclaw
113
+ skimpyclaw uninstall --purge # removes launch agent and ~/.skimpyclaw
114
+ pnpm remove -g skimpyclaw # removes global package
98
115
  ```
99
116
 
100
117
  **Verify:**
@@ -1,11 +1,18 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  const CONFIG_PATH = '/tmp/skimpyclaw-test-config.json';
3
- const { mockLoadConfig, mockLoadRawConfig, mockSaveConfig, mockRunSetup, mockRunDoctor, } = vi.hoisted(() => ({
3
+ const { mockLoadConfig, mockLoadRawConfig, mockSaveConfig, mockRunSetup, mockRunDoctor, mockExistsSync, mockMkdirSync, mockReadFileSync, mockWriteFileSync, mockRmSync, mockSpawn, mockSpawnSync, } = vi.hoisted(() => ({
4
4
  mockLoadConfig: vi.fn(),
5
5
  mockLoadRawConfig: vi.fn(),
6
6
  mockSaveConfig: vi.fn(),
7
7
  mockRunSetup: vi.fn(),
8
8
  mockRunDoctor: vi.fn(),
9
+ mockExistsSync: vi.fn(),
10
+ mockMkdirSync: vi.fn(),
11
+ mockReadFileSync: vi.fn(),
12
+ mockWriteFileSync: vi.fn(),
13
+ mockRmSync: vi.fn(),
14
+ mockSpawn: vi.fn(),
15
+ mockSpawnSync: vi.fn(),
9
16
  }));
10
17
  vi.mock('../config.js', () => ({
11
18
  loadConfig: mockLoadConfig,
@@ -22,6 +29,20 @@ vi.mock('../service.js', () => ({
22
29
  vi.mock('../doctor/index.js', () => ({
23
30
  runDoctor: mockRunDoctor,
24
31
  }));
32
+ vi.mock('os', () => ({
33
+ homedir: () => '/tmp/skimpyclaw-test-home',
34
+ }));
35
+ vi.mock('fs', () => ({
36
+ existsSync: mockExistsSync,
37
+ mkdirSync: mockMkdirSync,
38
+ readFileSync: mockReadFileSync,
39
+ writeFileSync: mockWriteFileSync,
40
+ rmSync: mockRmSync,
41
+ }));
42
+ vi.mock('child_process', () => ({
43
+ spawn: mockSpawn,
44
+ spawnSync: mockSpawnSync,
45
+ }));
25
46
  import { parseConfigValue, setDeepValue, getDeepValue, runCli } from '../cli.js';
26
47
  function mockJsonResponse(body, ok = true) {
27
48
  return {
@@ -69,6 +90,13 @@ describe('runCli', () => {
69
90
  mockSaveConfig.mockReset();
70
91
  mockRunSetup.mockReset();
71
92
  mockRunDoctor.mockReset();
93
+ mockExistsSync.mockReset();
94
+ mockMkdirSync.mockReset();
95
+ mockReadFileSync.mockReset();
96
+ mockWriteFileSync.mockReset();
97
+ mockRmSync.mockReset();
98
+ mockSpawn.mockReset();
99
+ mockSpawnSync.mockReset();
72
100
  mockLoadConfig.mockReturnValue({
73
101
  gateway: { port: 18790 },
74
102
  models: { aliases: { fast: 'anthropic/claude-3-5-haiku-20241022' } },
@@ -77,6 +105,9 @@ describe('runCli', () => {
77
105
  gateway: { port: 18790 },
78
106
  channels: { telegram: { enabled: false } },
79
107
  });
108
+ mockExistsSync.mockReturnValue(false);
109
+ mockSpawnSync.mockReturnValue({ status: 1, stdout: '', stderr: '' });
110
+ mockReadFileSync.mockReturnValue('{}');
80
111
  vi.spyOn(console, 'log').mockImplementation(() => { });
81
112
  vi.spyOn(console, 'error').mockImplementation(() => { });
82
113
  vi.stubGlobal('fetch', vi.fn());
@@ -98,6 +129,30 @@ describe('runCli', () => {
98
129
  expect(mockRunSetup).toHaveBeenCalledTimes(1);
99
130
  expect(mockRunSetup).toHaveBeenCalledWith({ dryRun: true });
100
131
  });
132
+ it('uninstall removes launch agent and keeps data by default', async () => {
133
+ mockExistsSync.mockImplementation((path) => typeof path === 'string'
134
+ && path.includes('/Library/LaunchAgents/com.skimpyclaw.gateway.plist'));
135
+ const code = await runCli(['uninstall']);
136
+ expect(code).toBe(0);
137
+ expect(mockRmSync).toHaveBeenCalledWith('/tmp/skimpyclaw-test-home/Library/LaunchAgents/com.skimpyclaw.gateway.plist', { force: true });
138
+ expect(mockRmSync).not.toHaveBeenCalledWith('/tmp/skimpyclaw-test-home/.skimpyclaw', expect.anything());
139
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Kept data directory'));
140
+ });
141
+ it('uninstall purges data when --purge is provided', async () => {
142
+ mockExistsSync.mockReturnValue(true);
143
+ const code = await runCli(['uninstall', '--purge']);
144
+ expect(code).toBe(0);
145
+ expect(mockRmSync).toHaveBeenCalledWith('/tmp/skimpyclaw-test-home/.skimpyclaw', {
146
+ recursive: true,
147
+ force: true,
148
+ });
149
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Purged data directory'));
150
+ });
151
+ it('uninstall rejects conflicting flags', async () => {
152
+ const code = await runCli(['uninstall', '--purge', '--keep-data']);
153
+ expect(code).toBe(1);
154
+ expect(console.error).toHaveBeenCalledWith('Usage: skimpyclaw uninstall [--keep-data|--purge]');
155
+ });
101
156
  it('supports config path/get/set operations', async () => {
102
157
  const pathCode = await runCli(['config', 'path']);
103
158
  expect(pathCode).toBe(0);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ const { mockRunAgentTurn } = vi.hoisted(() => ({
3
+ mockRunAgentTurn: vi.fn(),
4
+ }));
5
+ vi.mock('../agent.js', () => ({
6
+ runAgentTurn: mockRunAgentTurn,
7
+ }));
8
+ vi.mock('../channels.js', () => ({
9
+ getActiveChannelId: () => 'telegram',
10
+ isActiveChannelSilenced: () => false,
11
+ sendActiveChannelProactiveMessage: vi.fn(async () => true),
12
+ }));
13
+ import { runHeartbeatCheck } from '../heartbeat.js';
14
+ describe('heartbeat prompt path normalization', () => {
15
+ beforeEach(() => {
16
+ vi.clearAllMocks();
17
+ mockRunAgentTurn.mockResolvedValue('HEARTBEAT_OK');
18
+ });
19
+ it('normalizes legacy heartbeat file path to agents/main/HEARTBEAT.md', async () => {
20
+ const config = {
21
+ agents: { default: 'main' },
22
+ heartbeat: {
23
+ intervalMs: 60000,
24
+ prompt: 'Read /Users/katre/HEARTBEAT.md only. Reply HEARTBEAT_OK.',
25
+ model: 'claude-fast',
26
+ tools: {
27
+ enabled: true,
28
+ allowedPaths: ['/Users/katre/.skimpyclaw'],
29
+ maxIterations: 10,
30
+ bashTimeout: 15000,
31
+ },
32
+ },
33
+ channels: {
34
+ active: 'telegram',
35
+ telegram: {
36
+ defaultAllowedPaths: ['/Users/katre/.skimpyclaw'],
37
+ },
38
+ },
39
+ };
40
+ await runHeartbeatCheck(config);
41
+ expect(mockRunAgentTurn).toHaveBeenCalledTimes(1);
42
+ expect(mockRunAgentTurn).toHaveBeenCalledWith('main', expect.stringContaining('/.skimpyclaw/agents/main/HEARTBEAT.md'), config, 'claude-fast', expect.any(Object), undefined, expect.any(Object));
43
+ });
44
+ it('normalizes /workspace heartbeat path to agents/main/HEARTBEAT.md', async () => {
45
+ const config = {
46
+ agents: { default: 'main' },
47
+ heartbeat: {
48
+ intervalMs: 60000,
49
+ prompt: 'Read /workspace/HEARTBEAT.md only. Reply HEARTBEAT_OK.',
50
+ model: 'claude-fast',
51
+ tools: {
52
+ enabled: true,
53
+ allowedPaths: ['/Users/katre/.skimpyclaw'],
54
+ maxIterations: 10,
55
+ bashTimeout: 15000,
56
+ },
57
+ },
58
+ channels: {
59
+ active: 'telegram',
60
+ telegram: {
61
+ defaultAllowedPaths: ['/Users/katre/.skimpyclaw'],
62
+ },
63
+ },
64
+ };
65
+ await runHeartbeatCheck(config);
66
+ expect(mockRunAgentTurn).toHaveBeenCalledTimes(1);
67
+ const promptArg = mockRunAgentTurn.mock.calls[0][1];
68
+ expect(promptArg).toContain('/.skimpyclaw/agents/main/HEARTBEAT.md');
69
+ expect(promptArg).not.toContain('/workspace/HEARTBEAT.md');
70
+ });
71
+ });
@@ -15,7 +15,10 @@ describe('setup config generation', () => {
15
15
  expect(config.models.providers.anthropic.apiKey).toBe('${ANTHROPIC_API_KEY}');
16
16
  expect(config.models.providers.codex.authPath).toBe('${HOME}/.codex/auth.json');
17
17
  expect(config.channels.telegram.allowFrom).toEqual([12345]);
18
- expect(config.channels.telegram.defaultAllowedPaths).toContain('/tmp/workspace');
18
+ expect(config.channels.telegram.dailyNotesDir).toBe('${HOME}/.skimpyclaw/Daily Notes');
19
+ expect(config.channels.telegram.defaultAllowedPaths).toEqual(['${HOME}/.skimpyclaw']);
20
+ expect(config.channels.discord.defaultAllowedPaths).toEqual(['${HOME}/.skimpyclaw']);
21
+ expect(config.heartbeat.tools.allowedPaths).toEqual(['${HOME}/.skimpyclaw']);
19
22
  expect(config.models.aliases['claude-think']).toBe('anthropic/claude-sonnet-4-6');
20
23
  expect(config.models.aliases['claude-opus']).toBe('anthropic/claude-opus-4-6');
21
24
  expect(config.models.aliases.codex).toBe('codex/gpt-5.3-codex');
@@ -34,7 +34,7 @@ vi.mock('fs', async (importOriginal) => {
34
34
  const mockFetch = vi.fn();
35
35
  global.fetch = mockFetch;
36
36
  // Import after mocks are set up
37
- const { synthesizeSpeech, checkTTSDependencies } = await import('../voice.js');
37
+ const { synthesizeSpeech, checkTTSDependencies, checkVoiceDependencies } = await import('../voice.js');
38
38
  // --- Config helpers ---
39
39
  const baseVoiceConfig = {
40
40
  enabled: true,
@@ -212,3 +212,43 @@ describe('checkTTSDependencies', () => {
212
212
  }
213
213
  });
214
214
  });
215
+ describe('checkVoiceDependencies', () => {
216
+ it('ignores macos provider for STT and uses API-backed provider', () => {
217
+ const config = {
218
+ ...baseVoiceConfig,
219
+ defaultProvider: 'macos',
220
+ providers: {
221
+ macos: { tts: { voice: 'Samantha' } },
222
+ openai: { apiKey: 'openai-key', stt: { model: 'whisper-1' } },
223
+ },
224
+ };
225
+ const result = checkVoiceDependencies(config);
226
+ expect(result.ok).toBe(true);
227
+ expect(result.localWhisper).toBe(false);
228
+ expect(result.missing).toHaveLength(0);
229
+ });
230
+ it('reports missing STT provider when only macos provider is configured', () => {
231
+ const config = {
232
+ ...baseVoiceConfig,
233
+ defaultProvider: 'macos',
234
+ providers: {
235
+ macos: { tts: { voice: 'Samantha' } },
236
+ },
237
+ };
238
+ const result = checkVoiceDependencies(config);
239
+ expect(result.ok).toBe(false);
240
+ expect(result.missing[0]).toContain('No local whisper CLI and no API providers configured');
241
+ });
242
+ });
243
+ describe('transcription provider messaging', () => {
244
+ it('explains that macos is TTS-only when no STT provider exists', async () => {
245
+ const { transcribeAudio } = await import('../voice.js');
246
+ const config = {
247
+ ...baseVoiceConfig,
248
+ providers: {
249
+ macos: { tts: { voice: 'Samantha' } },
250
+ },
251
+ };
252
+ await expect(transcribeAudio('/tmp/fake-audio.ogg', config)).rejects.toThrow('macos" is TTS-only');
253
+ });
254
+ });
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { homedir } from 'os';
5
5
  import { spawn, spawnSync } from 'child_process';
@@ -21,6 +21,8 @@ Commands:
21
21
  start [--daemon] Start service (foreground by default)
22
22
  stop Stop launchd daemon (macOS)
23
23
  restart Restart launchd daemon (macOS)
24
+ uninstall [--keep-data|--purge]
25
+ Remove launch agent and optionally purge ~/.skimpyclaw
24
26
  status Show daemon and gateway status
25
27
  logs [--file name] Show logs (stdout|stderr|app), default stdout
26
28
  [--lines N]
@@ -153,6 +155,38 @@ function stopDaemon() {
153
155
  console.log(`Daemon stopped: ${LAUNCHD_LABEL}`);
154
156
  return 0;
155
157
  }
158
+ function commandUninstall(args) {
159
+ const hasPurge = args.includes('--purge');
160
+ const hasKeepData = args.includes('--keep-data');
161
+ const unknownFlags = args.filter((arg) => arg.startsWith('--') && arg !== '--purge' && arg !== '--keep-data');
162
+ if (unknownFlags.length > 0 || (hasPurge && hasKeepData)) {
163
+ console.error('Usage: skimpyclaw uninstall [--keep-data|--purge]');
164
+ return 1;
165
+ }
166
+ if (existsSync(LAUNCHD_PLIST) && launchctlAvailable()) {
167
+ const stopResult = runLaunchctl(['unload', LAUNCHD_PLIST]);
168
+ if (!stopResult.ok && !stopResult.output.includes('Could not find specified service')) {
169
+ console.error(`Warning: failed to unload daemon: ${stopResult.output || 'unknown error'}`);
170
+ }
171
+ }
172
+ if (existsSync(LAUNCHD_PLIST)) {
173
+ rmSync(LAUNCHD_PLIST, { force: true });
174
+ console.log(`Removed launch agent: ${LAUNCHD_PLIST}`);
175
+ }
176
+ else {
177
+ console.log('No launch agent found.');
178
+ }
179
+ const dataDir = join(homedir(), '.skimpyclaw');
180
+ if (hasPurge) {
181
+ rmSync(dataDir, { recursive: true, force: true });
182
+ console.log(`Purged data directory: ${dataDir}`);
183
+ }
184
+ else {
185
+ console.log(`Kept data directory: ${dataDir}`);
186
+ }
187
+ console.log('To remove the global package, run: pnpm remove -g skimpyclaw');
188
+ return 0;
189
+ }
156
190
  function daemonStatus() {
157
191
  if (!launchctlAvailable()) {
158
192
  return 'unsupported';
@@ -713,6 +747,9 @@ export async function runCli(argv = process.argv.slice(2)) {
713
747
  }
714
748
  return startDaemon();
715
749
  }
750
+ if (command === 'uninstall') {
751
+ return commandUninstall(args);
752
+ }
716
753
  if (command === 'status') {
717
754
  return await commandStatus();
718
755
  }
package/dist/heartbeat.js CHANGED
@@ -11,7 +11,7 @@ let heartbeatTimer = null;
11
11
  let running = false;
12
12
  const DEFAULT_HEARTBEAT_TOOLS = {
13
13
  enabled: true,
14
- allowedPaths: [join(homedir(), '.skimpyclaw'), process.cwd()],
14
+ allowedPaths: [join(homedir(), '.skimpyclaw')],
15
15
  maxIterations: 100,
16
16
  bashTimeout: 15000,
17
17
  };
@@ -30,6 +30,23 @@ function getHeartbeatTools(config) {
30
30
  }
31
31
  return DEFAULT_HEARTBEAT_TOOLS;
32
32
  }
33
+ function getHeartbeatFilePath(config) {
34
+ return join(homedir(), '.skimpyclaw', 'agents', config.agents.default, 'HEARTBEAT.md');
35
+ }
36
+ function getHeartbeatPrompt(config) {
37
+ const heartbeatPath = getHeartbeatFilePath(config);
38
+ const basePrompt = config.heartbeat.prompt || '';
39
+ // Normalize any explicit HEARTBEAT.md path token (legacy /workspace, /Users/*, ~/...)
40
+ // to the active agent heartbeat template path.
41
+ const normalized = basePrompt.replace(/(?:~|\/)\S*HEARTBEAT\.md/g, heartbeatPath);
42
+ if (normalized.includes('HEARTBEAT.md')) {
43
+ return normalized;
44
+ }
45
+ if (normalized.trim().length === 0) {
46
+ return `Read ${heartbeatPath}. Follow it strictly. If nothing needs attention, reply HEARTBEAT_OK.`;
47
+ }
48
+ return `Read ${heartbeatPath}. Follow it strictly.\n\n${normalized}`;
49
+ }
33
50
  export function initHeartbeat(config) {
34
51
  const { heartbeat } = config;
35
52
  if (!heartbeat?.prompt || !heartbeat?.intervalMs) {
@@ -68,7 +85,7 @@ export async function runHeartbeatCheck(config) {
68
85
  running = true;
69
86
  try {
70
87
  console.log('[heartbeat] Running check...');
71
- const response = await runAgentTurn(config.agents.default, config.heartbeat.prompt, config, config.heartbeat.model, getHeartbeatTools(config), undefined, {
88
+ const response = await runAgentTurn(config.agents.default, getHeartbeatPrompt(config), config, config.heartbeat.model, getHeartbeatTools(config), undefined, {
72
89
  channel: 'heartbeat',
73
90
  sessionId: undefined,
74
91
  });
@@ -128,16 +128,12 @@ export async function chatWithToolsOpenAI(params, provider) {
128
128
  projects: toolContext?.fullConfig?.projects
129
129
  });
130
130
  const openaiTools = toOpenAITools(toolDefs);
131
- // Inject Kimi $web_search builtin tool when using Moonshot/Kimi provider
131
+ // Kimi requires interleaved reasoning content when replaying tool calls.
132
132
  const providerBaseURL = config.models.providers[provider]?.baseURL || '';
133
133
  const requiresReasoningContent = providerBaseURL.includes('kimi.com') || providerBaseURL.includes('moonshot.ai');
134
134
  const kimiRequestExtras = requiresReasoningContent
135
135
  ? { extra_body: { interleaved: { field: 'reasoning_content' } } }
136
136
  : {};
137
- if (providerBaseURL.includes('kimi.com') || providerBaseURL.includes('moonshot.ai')) {
138
- openaiTools.push({ type: 'builtin_function', function: { name: '$web_search' } });
139
- console.log('[agent:openai-tools] Injected Kimi $web_search builtin tool');
140
- }
141
137
  // Build messages for OpenAI format — preserve images for vision models
142
138
  const apiMessages = messages.map(m => ({
143
139
  role: m.role,
@@ -252,6 +248,17 @@ export async function chatWithToolsOpenAI(params, provider) {
252
248
  // Execute each tool call
253
249
  for (const toolCall of message.tool_calls) {
254
250
  const fnName = toolCall.function.name;
251
+ if (fnName.startsWith('$') && fnName !== '$web_search') {
252
+ const unsupported = `Provider-native tool "${fnName}" is not supported in this runtime.`;
253
+ console.warn(`[agent:openai-tools] ${unsupported}`);
254
+ apiMessages.push({
255
+ role: 'tool',
256
+ tool_call_id: toolCall.id,
257
+ content: unsupported,
258
+ });
259
+ toolLog.push(`${fnName} [SKIPPED: provider-native tool unsupported]`);
260
+ continue;
261
+ }
255
262
  let args;
256
263
  try {
257
264
  args = JSON.parse(toolCall.function.arguments || '{}');
package/dist/setup.js CHANGED
@@ -376,20 +376,14 @@ export function buildSetupConfig(input) {
376
376
  enabled: true,
377
377
  token: '${TELEGRAM_BOT_TOKEN}',
378
378
  allowFrom: [parseInt(input.telegramId, 10) || input.telegramId],
379
- dailyNotesDir: '${HOME}/Daily Notes',
380
- defaultAllowedPaths: [
381
- '${HOME}/.skimpyclaw',
382
- input.workspaceDir,
383
- ],
379
+ dailyNotesDir: '${HOME}/.skimpyclaw/Daily Notes',
380
+ defaultAllowedPaths: ['${HOME}/.skimpyclaw'],
384
381
  },
385
382
  discord: {
386
383
  enabled: useDiscord,
387
384
  token: useDiscord ? '${DISCORD_BOT_TOKEN}' : '',
388
385
  allowFrom: useDiscord ? [input.discordUserId || ''] : [],
389
- defaultAllowedPaths: [
390
- '${HOME}/.skimpyclaw',
391
- input.workspaceDir,
392
- ],
386
+ defaultAllowedPaths: ['${HOME}/.skimpyclaw'],
393
387
  ...(input.discordDefaultChannelId ? { defaultChannelId: input.discordDefaultChannelId } : {}),
394
388
  },
395
389
  },
@@ -401,10 +395,7 @@ export function buildSetupConfig(input) {
401
395
  prompt: 'Read ~/.skimpyclaw/agents/main/HEARTBEAT.md. Follow it strictly. If nothing needs attention, reply HEARTBEAT_OK.',
402
396
  tools: {
403
397
  enabled: true,
404
- allowedPaths: [
405
- '${HOME}/.skimpyclaw',
406
- input.workspaceDir,
407
- ],
398
+ allowedPaths: ['${HOME}/.skimpyclaw'],
408
399
  maxIterations: 10,
409
400
  bashTimeout: 15000,
410
401
  ...(features.browser ? { browser: { enabled: true } } : { browser: { enabled: false } }),
package/dist/tools.js CHANGED
@@ -198,6 +198,44 @@ export async function cleanupMcp() {
198
198
  mcpRuntime = null;
199
199
  }
200
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})`;
209
+ }
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
+ }
217
+ }
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
+ }
226
+ }
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}`);
232
+ }
233
+ }
234
+ if (lines.length === 0) {
235
+ return `No web results found for: ${q}`;
236
+ }
237
+ return lines.join('\n');
238
+ }
201
239
  // --- Tool Executor ---
202
240
  export async function executeTool(name, input, config, context) {
203
241
  try {
@@ -227,6 +265,10 @@ export async function executeTool(name, input, config, context) {
227
265
  // Map Claude Code names to internal names for built-in tools
228
266
  const normalized = fromClaudeCodeName(name).toLowerCase().replace(/-/g, '_');
229
267
  switch (normalized) {
268
+ case '$web_search':
269
+ case 'web_search':
270
+ case 'websearch':
271
+ return await executeWebSearch(input.query || input.q || input.text || '');
230
272
  case 'read_file':
231
273
  return executeReadFile(input.file_path || input.path, config);
232
274
  case 'write_file':
package/dist/voice.js CHANGED
@@ -187,15 +187,24 @@ function getSTTProvider(config) {
187
187
  if (!providers || Object.keys(providers).length === 0) {
188
188
  return null;
189
189
  }
190
- const isApiBackedSttProvider = (provider) => Boolean(provider && (provider.stt || provider.apiKey));
190
+ const isApiBackedSttProvider = (name, provider) => {
191
+ if (!provider)
192
+ return false;
193
+ // macOS voice provider is TTS-only and must never be used for transcription.
194
+ const normalizedName = name.trim().toLowerCase();
195
+ if (normalizedName === 'macos')
196
+ return false;
197
+ return Boolean(provider.stt || provider.apiKey);
198
+ };
191
199
  if (config.defaultProvider) {
192
- const preferred = providers[config.defaultProvider];
193
- if (isApiBackedSttProvider(preferred)) {
200
+ const preferredName = config.defaultProvider;
201
+ const preferred = providers[preferredName];
202
+ if (isApiBackedSttProvider(preferredName, preferred)) {
194
203
  return { name: config.defaultProvider, provider: preferred };
195
204
  }
196
205
  }
197
206
  for (const [name, provider] of Object.entries(providers)) {
198
- if (isApiBackedSttProvider(provider)) {
207
+ if (isApiBackedSttProvider(name, provider)) {
199
208
  return { name, provider };
200
209
  }
201
210
  }
@@ -267,6 +276,9 @@ export async function transcribeAudio(audioPath, config) {
267
276
  // No local whisper — try API provider directly
268
277
  const sttProvider = getSTTProvider(config);
269
278
  if (!sttProvider) {
279
+ if (config.providers?.macos) {
280
+ throw new Error('No voice transcription provider configured. "macos" is TTS-only. Install local whisper or configure an API STT provider (e.g. openai.stt).');
281
+ }
270
282
  throw new Error('No voice transcription available. Install whisper (pip install openai-whisper) or configure an API provider.');
271
283
  }
272
284
  return transcribeWithAPI(audioPath, sttProvider.name, sttProvider.provider);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skimpyclaw",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Lightweight personal AI assistant with Telegram and Discord integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",