skimpyclaw 0.1.6 → 0.1.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.
- package/README.md +17 -0
- package/dist/__tests__/cli.test.js +56 -1
- package/dist/__tests__/heartbeat.test.d.ts +1 -0
- package/dist/__tests__/heartbeat.test.js +44 -0
- package/dist/__tests__/voice.test.js +29 -1
- package/dist/cli.js +38 -1
- package/dist/heartbeat.js +17 -1
- package/dist/providers/openai.js +12 -5
- package/dist/setup.js +1 -1
- package/dist/tools.js +42 -0
- package/dist/voice.js +21 -5
- package/package.json +1 -1
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,44 @@
|
|
|
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
|
+
});
|
|
@@ -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,31 @@ 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
|
+
});
|
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
|
@@ -30,6 +30,22 @@ 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 legacy/wrong heartbeat locations to the agent template path.
|
|
40
|
+
const normalized = basePrompt.replace(/(~\/(?:\.skimpyclaw\/)?HEARTBEAT\.md|\/Users\/[^/\s]+\/(?:\.skimpyclaw\/)?HEARTBEAT\.md|\/HEARTBEAT\.md)/g, heartbeatPath);
|
|
41
|
+
if (normalized.includes('HEARTBEAT.md')) {
|
|
42
|
+
return normalized;
|
|
43
|
+
}
|
|
44
|
+
if (normalized.trim().length === 0) {
|
|
45
|
+
return `Read ${heartbeatPath}. Follow it strictly. If nothing needs attention, reply HEARTBEAT_OK.`;
|
|
46
|
+
}
|
|
47
|
+
return `Read ${heartbeatPath}. Follow it strictly.\n\n${normalized}`;
|
|
48
|
+
}
|
|
33
49
|
export function initHeartbeat(config) {
|
|
34
50
|
const { heartbeat } = config;
|
|
35
51
|
if (!heartbeat?.prompt || !heartbeat?.intervalMs) {
|
|
@@ -68,7 +84,7 @@ export async function runHeartbeatCheck(config) {
|
|
|
68
84
|
running = true;
|
|
69
85
|
try {
|
|
70
86
|
console.log('[heartbeat] Running check...');
|
|
71
|
-
const response = await runAgentTurn(config.agents.default, config
|
|
87
|
+
const response = await runAgentTurn(config.agents.default, getHeartbeatPrompt(config), config, config.heartbeat.model, getHeartbeatTools(config), undefined, {
|
|
72
88
|
channel: 'heartbeat',
|
|
73
89
|
sessionId: undefined,
|
|
74
90
|
});
|
package/dist/providers/openai.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
@@ -398,7 +398,7 @@ export function buildSetupConfig(input) {
|
|
|
398
398
|
},
|
|
399
399
|
heartbeat: {
|
|
400
400
|
intervalMs: 1800000,
|
|
401
|
-
prompt: 'Read HEARTBEAT.md. Follow it strictly. If nothing needs attention, reply HEARTBEAT_OK.',
|
|
401
|
+
prompt: 'Read ~/.skimpyclaw/agents/main/HEARTBEAT.md. Follow it strictly. If nothing needs attention, reply HEARTBEAT_OK.',
|
|
402
402
|
tools: {
|
|
403
403
|
enabled: true,
|
|
404
404
|
allowedPaths: [
|
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,11 +187,27 @@ function getSTTProvider(config) {
|
|
|
187
187
|
if (!providers || Object.keys(providers).length === 0) {
|
|
188
188
|
return null;
|
|
189
189
|
}
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
+
if (name === 'macos')
|
|
195
|
+
return false;
|
|
196
|
+
return Boolean(provider.stt || provider.apiKey);
|
|
197
|
+
};
|
|
198
|
+
if (config.defaultProvider) {
|
|
199
|
+
const preferredName = config.defaultProvider;
|
|
200
|
+
const preferred = providers[preferredName];
|
|
201
|
+
if (isApiBackedSttProvider(preferredName, preferred)) {
|
|
202
|
+
return { name: config.defaultProvider, provider: preferred };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
for (const [name, provider] of Object.entries(providers)) {
|
|
206
|
+
if (isApiBackedSttProvider(name, provider)) {
|
|
207
|
+
return { name, provider };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
195
211
|
}
|
|
196
212
|
/**
|
|
197
213
|
* Resolve the API key, supporting ${ENV_VAR} syntax.
|