skimpyclaw 0.1.7 → 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 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.heartbeat.prompt, config, config.heartbeat.model, getHeartbeatTools(config), undefined, {
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
  });
@@ -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/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,23 @@ 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
+ if (name === 'macos')
195
+ return false;
196
+ return Boolean(provider.stt || provider.apiKey);
197
+ };
191
198
  if (config.defaultProvider) {
192
- const preferred = providers[config.defaultProvider];
193
- if (isApiBackedSttProvider(preferred)) {
199
+ const preferredName = config.defaultProvider;
200
+ const preferred = providers[preferredName];
201
+ if (isApiBackedSttProvider(preferredName, preferred)) {
194
202
  return { name: config.defaultProvider, provider: preferred };
195
203
  }
196
204
  }
197
205
  for (const [name, provider] of Object.entries(providers)) {
198
- if (isApiBackedSttProvider(provider)) {
206
+ if (isApiBackedSttProvider(name, provider)) {
199
207
  return { name, provider };
200
208
  }
201
209
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skimpyclaw",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Lightweight personal AI assistant with Telegram and Discord integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",