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 +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 +71 -0
- package/dist/__tests__/setup.test.js +4 -1
- package/dist/__tests__/voice.test.js +41 -1
- package/dist/cli.js +38 -1
- package/dist/heartbeat.js +19 -2
- package/dist/providers/openai.js +12 -5
- package/dist/setup.js +4 -13
- package/dist/tools.js +42 -0
- package/dist/voice.js +16 -4
- 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,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.
|
|
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')
|
|
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
|
|
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
|
});
|
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
|
@@ -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) =>
|
|
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
|
|
193
|
-
|
|
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);
|