skimpyclaw 0.2.0 → 0.3.0

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.
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { parseDualOutput } from '../cron.js';
2
+ import { parseDualOutput, validatePrReviewOutput } from '../cron.js';
3
3
  describe('parseDualOutput', () => {
4
4
  it('returns full response as text when no delimiters present', () => {
5
5
  const response = 'Hello, this is a regular response with no delimiters.';
@@ -64,3 +64,53 @@ Voice content here
64
64
  expect(result.text).toBe(fullResponse);
65
65
  });
66
66
  });
67
+ describe('validatePrReviewOutput', () => {
68
+ it('returns null for NO_CANDIDATES result', () => {
69
+ const output = 'No PRs found.\n[PR_REVIEW_RESULT: NO_CANDIDATES]';
70
+ expect(validatePrReviewOutput(output)).toBeNull();
71
+ });
72
+ it('returns null when candidates were reviewed with code_with_agent', () => {
73
+ const output = 'Reviewed 3 PRs.\n[PR_REVIEW_RESULT: CANDIDATES=3 CODE_AGENT_CALLS=3 BLOCKED=0]';
74
+ expect(validatePrReviewOutput(output)).toBeNull();
75
+ });
76
+ it('returns null when all candidates are blocked', () => {
77
+ const output = 'All blocked.\n[PR_REVIEW_RESULT: CANDIDATES=2 CODE_AGENT_CALLS=0 BLOCKED=2]';
78
+ expect(validatePrReviewOutput(output)).toBeNull();
79
+ });
80
+ it('returns alert when candidates exist but no code_with_agent calls', () => {
81
+ const output = 'Inline review.\n[PR_REVIEW_RESULT: CANDIDATES=3 CODE_AGENT_CALLS=0 BLOCKED=0]';
82
+ const result = validatePrReviewOutput(output);
83
+ expect(result).not.toBeNull();
84
+ expect(result).toContain('code_with_agent was never called');
85
+ expect(result).toContain('3 PR candidate');
86
+ });
87
+ it('returns alert when result line is missing entirely', () => {
88
+ const output = 'The agent just rambled about PRs without following the prompt.';
89
+ const result = validatePrReviewOutput(output);
90
+ expect(result).not.toBeNull();
91
+ expect(result).toContain('Missing [PR_REVIEW_RESULT]');
92
+ });
93
+ it('returns null when some candidates reviewed and some blocked', () => {
94
+ const output = '[PR_REVIEW_RESULT: CANDIDATES=4 CODE_AGENT_CALLS=2 BLOCKED=2]';
95
+ expect(validatePrReviewOutput(output)).toBeNull();
96
+ });
97
+ it('returns alert when partially blocked but zero calls', () => {
98
+ const output = '[PR_REVIEW_RESULT: CANDIDATES=3 CODE_AGENT_CALLS=0 BLOCKED=1]';
99
+ const result = validatePrReviewOutput(output);
100
+ expect(result).not.toBeNull();
101
+ expect(result).toContain('code_with_agent was never called');
102
+ });
103
+ });
104
+ describe('cron job tool injection', () => {
105
+ it('isCronJob field exists on ExecuteToolContext', () => {
106
+ // Verify the field is part of the type (compile-time check via assignment)
107
+ const ctx = {
108
+ isCronJob: true,
109
+ };
110
+ expect(ctx.isCronJob).toBe(true);
111
+ });
112
+ it('isCronJob defaults to undefined when not set', () => {
113
+ const ctx = {};
114
+ expect(ctx.isCronJob).toBeUndefined();
115
+ });
116
+ });
@@ -4,7 +4,7 @@ const { mockSpawn, mockSpawnSync } = vi.hoisted(() => ({
4
4
  mockSpawnSync: vi.fn().mockReturnValue({ status: 0, stdout: '', stderr: '' }),
5
5
  }));
6
6
  vi.mock('child_process', () => ({ spawn: mockSpawn, spawnSync: mockSpawnSync }));
7
- import { createContainer, execInContainer, removeContainer, isContainerRunning, cleanupOrphans, setRuntime, resetRuntime, } from '../sandbox/runtime.js';
7
+ import { createContainer, execInContainer, removeContainer, isContainerRunning, cleanupOrphans, setRuntime, resetRuntime, probeRuntime, } from '../sandbox/runtime.js';
8
8
  function fakeChild(exitCode, stdout = '', stderr = '', opts) {
9
9
  const stdoutCallbacks = [];
10
10
  const stderrCallbacks = [];
@@ -117,6 +117,42 @@ describe('sandbox/runtime', () => {
117
117
  expect(await isContainerRunning('ctr')).toBe(false);
118
118
  });
119
119
  });
120
+ describe('probeRuntime', () => {
121
+ it('returns preferred runtime when available', () => {
122
+ mockSpawnSync.mockReturnValue({ status: 0 });
123
+ expect(probeRuntime('docker')).toBe('docker');
124
+ expect(mockSpawnSync).toHaveBeenCalledWith('docker', ['--version'], { stdio: 'ignore' });
125
+ });
126
+ it('falls back to auto-detect when preferred is unavailable', () => {
127
+ mockSpawnSync.mockImplementation((cmd) => {
128
+ // preferred 'docker' fails, but 'container' succeeds
129
+ if (cmd === 'docker')
130
+ return { status: 1 };
131
+ if (cmd === 'container')
132
+ return { status: 0 };
133
+ return { status: 1 };
134
+ });
135
+ expect(probeRuntime('docker')).toBe('container');
136
+ });
137
+ it('returns null when no runtime is available', () => {
138
+ mockSpawnSync.mockReturnValue({ status: 1 });
139
+ expect(probeRuntime('docker')).toBeNull();
140
+ });
141
+ it('auto-detects without preferred runtime', () => {
142
+ mockSpawnSync.mockImplementation((cmd) => {
143
+ if (cmd === 'container')
144
+ return { status: 0 };
145
+ return { status: 1 };
146
+ });
147
+ expect(probeRuntime()).toBe('container');
148
+ });
149
+ it('prefers container over docker in auto-detect', () => {
150
+ mockSpawnSync.mockReturnValue({ status: 0 });
151
+ expect(probeRuntime()).toBe('container');
152
+ // First call should be to 'container'
153
+ expect(mockSpawnSync.mock.calls[0][0]).toBe('container');
154
+ });
155
+ });
120
156
  describe('cleanupOrphans', () => {
121
157
  it('lists containers, filters by prefix, removes matches', async () => {
122
158
  let callCount = 0;
@@ -133,8 +133,8 @@ describe('setup config generation', () => {
133
133
  },
134
134
  });
135
135
  expect(config.cron.jobs).toHaveLength(2);
136
- expect(config.cron.jobs[0].id).toBe('starter-tech-news-hn');
137
- expect(config.cron.jobs[1].id).toBe('starter-weather-7am');
136
+ expect(config.cron.jobs[0].id).toBe('tech-digest');
137
+ expect(config.cron.jobs[1].id).toBe('weather');
138
138
  expect(config.cron.jobs[1].schedule.tz).toBe('America/New_York');
139
139
  expect(config.cron.jobs[1].payload.message).toContain('Austin, TX');
140
140
  expect(config.skills.enabled).toBe(true);
package/dist/agent.js CHANGED
@@ -155,6 +155,7 @@ export async function runAgentTurn(agentId, userMessage, config, modelOverride,
155
155
  approverUsername: context?.metadata?.username,
156
156
  sandboxConfig: config.sandbox,
157
157
  sessionId: context?.sessionId || String(chatIdNum ?? 'default'),
158
+ isCronJob: context?.metadata?.isCronJob === true,
158
159
  };
159
160
  const runTurn = async () => {
160
161
  if (toolConfig?.enabled) {
package/dist/cron.d.ts CHANGED
@@ -23,6 +23,12 @@ export declare function parseDualOutput(response: string): {
23
23
  voice: string | null;
24
24
  text: string;
25
25
  };
26
+ /**
27
+ * Post-run guard for the pr-review cron job.
28
+ * Validates that the agent actually used code_with_agent when PRs were found.
29
+ * Non-throwing — logs a warning and returns an alert message (or null if OK).
30
+ */
31
+ export declare function validatePrReviewOutput(output: string): string | null;
26
32
  export declare function getCronJobs(): {
27
33
  id: string;
28
34
  name: string;
package/dist/cron.js CHANGED
@@ -139,7 +139,7 @@ async function executeJobPayload(jobDef, config) {
139
139
  channel: getActiveChannelId() || 'telegram',
140
140
  trigger: 'cron',
141
141
  sessionId: jobDef.id,
142
- metadata: { jobName: jobDef.name },
142
+ metadata: { jobName: jobDef.name, isCronJob: true },
143
143
  });
144
144
  appendCronLogLine(jobDef.id, `Agent turn completed (${response.length} chars)`);
145
145
  // Parse dual output (voice + text) if delimiters present
@@ -149,6 +149,19 @@ async function executeJobPayload(jobDef, config) {
149
149
  }
150
150
  // Use text portion for log output and notifications
151
151
  logEntry.output = textPortion.slice(0, 5000);
152
+ // Post-run guard for pr-review job
153
+ if (jobDef.id === 'pr-review') {
154
+ const guardAlert = validatePrReviewOutput(textPortion);
155
+ if (guardAlert) {
156
+ appendCronLogLine(jobDef.id, guardAlert);
157
+ try {
158
+ await sendActiveChannelProactiveMessage(config, guardAlert);
159
+ }
160
+ catch {
161
+ // Non-critical
162
+ }
163
+ }
164
+ }
152
165
  // Parse and save digest from the text portion
153
166
  try {
154
167
  parseAndSaveDigest(jobDef.id, jobDef.name, textPortion);
@@ -356,6 +369,35 @@ export function parseDualOutput(response) {
356
369
  text: text || response,
357
370
  };
358
371
  }
372
+ /**
373
+ * Post-run guard for the pr-review cron job.
374
+ * Validates that the agent actually used code_with_agent when PRs were found.
375
+ * Non-throwing — logs a warning and returns an alert message (or null if OK).
376
+ */
377
+ export function validatePrReviewOutput(output) {
378
+ // Check for the machine-readable result line
379
+ const resultMatch = output.match(/\[PR_REVIEW_RESULT:\s*(.+?)\]/);
380
+ if (!resultMatch) {
381
+ return '⚠️ PR Pre-Review: Missing [PR_REVIEW_RESULT] line in output. The agent may not have followed the prompt correctly.';
382
+ }
383
+ const resultLine = resultMatch[1].trim();
384
+ // NO_CANDIDATES is fine — nothing to review
385
+ if (resultLine === 'NO_CANDIDATES') {
386
+ return null;
387
+ }
388
+ // Parse CANDIDATES=N CODE_AGENT_CALLS=M BLOCKED=B
389
+ const candidatesMatch = resultLine.match(/CANDIDATES=(\d+)/);
390
+ const callsMatch = resultLine.match(/CODE_AGENT_CALLS=(\d+)/);
391
+ const blockedMatch = resultLine.match(/BLOCKED=(\d+)/);
392
+ const candidates = candidatesMatch ? parseInt(candidatesMatch[1], 10) : 0;
393
+ const calls = callsMatch ? parseInt(callsMatch[1], 10) : 0;
394
+ const blocked = blockedMatch ? parseInt(blockedMatch[1], 10) : 0;
395
+ // If there were candidates but zero code_with_agent calls (and not all blocked), alert
396
+ if (candidates > 0 && calls === 0 && blocked < candidates) {
397
+ return `⚠️ PR Pre-Review: Found ${candidates} PR candidate(s) but code_with_agent was never called (blocked: ${blocked}). The agent likely wrote inline commentary instead of delegating.`;
398
+ }
399
+ return null;
400
+ }
359
401
  function expandVariables(message) {
360
402
  const now = new Date();
361
403
  const date = now.toLocaleDateString('en-US', {
@@ -122,7 +122,7 @@ export async function chatWithToolsAnthropic(params) {
122
122
  const modelId = stripProvider(options.model);
123
123
  const maxIterations = toolConfig.maxIterations || 20;
124
124
  // Resolve tools once at start of agent loop
125
- const includeSpawn = !!(toolContext?.chatId && toolContext?.fullConfig);
125
+ const includeSpawn = !!(toolContext?.fullConfig && (toolContext?.chatId || toolContext?.isCronJob));
126
126
  const toolDefs = await getToolDefinitions(toolConfig, { includeSpawnSubagent: includeSpawn, projects: toolContext?.fullConfig?.projects });
127
127
  // Enable prompt caching for system + tools
128
128
  const cacheEnabled = config.models?.promptCaching !== false;
@@ -286,7 +286,7 @@ export async function chatWithToolsCodex(params) {
286
286
  }
287
287
  // Get tool definitions
288
288
  const { getToolDefinitions } = await import('../tools.js');
289
- const includeSpawn = !!(toolContext?.chatId && toolContext?.fullConfig);
289
+ const includeSpawn = !!(toolContext?.fullConfig && (toolContext?.chatId || toolContext?.isCronJob));
290
290
  const toolDefs = await getToolDefinitions(toolConfig, {
291
291
  includeSpawnSubagent: includeSpawn,
292
292
  includeMcp: false,
@@ -121,7 +121,7 @@ export async function chatWithToolsOpenAI(params, provider) {
121
121
  const modelId = stripProvider(options.model, openaiClients);
122
122
  const maxIterations = toolConfig.maxIterations || 20;
123
123
  // Resolve tools once at start
124
- const includeSpawn = !!(toolContext?.chatId && toolContext?.fullConfig);
124
+ const includeSpawn = !!(toolContext?.fullConfig && (toolContext?.chatId || toolContext?.isCronJob));
125
125
  const toolDefs = await getToolDefinitions(toolConfig, {
126
126
  includeSpawnSubagent: includeSpawn,
127
127
  includeMcp: false,
@@ -1,4 +1,4 @@
1
- export { createContainer, execInContainer, removeContainer, isContainerRunning, cleanupOrphans, setRuntime, getRuntime, resetRuntime } from './runtime.js';
1
+ export { createContainer, execInContainer, removeContainer, isContainerRunning, cleanupOrphans, setRuntime, getRuntime, resetRuntime, probeRuntime } from './runtime.js';
2
2
  export type { ContainerOpts, ExecOpts, ExecResult } from './runtime.js';
3
3
  export { ensureContainer, releaseContainer, pruneIdle, releaseAll, SANDBOX_DEFAULTS } from './manager.js';
4
4
  export { sandboxBash, sandboxReadFile, sandboxWriteFile, sandboxListDir, sandboxGlob } from './bridge.js';
@@ -1,4 +1,4 @@
1
- export { createContainer, execInContainer, removeContainer, isContainerRunning, cleanupOrphans, setRuntime, getRuntime, resetRuntime } from './runtime.js';
1
+ export { createContainer, execInContainer, removeContainer, isContainerRunning, cleanupOrphans, setRuntime, getRuntime, resetRuntime, probeRuntime } from './runtime.js';
2
2
  export { ensureContainer, releaseContainer, pruneIdle, releaseAll, SANDBOX_DEFAULTS } from './manager.js';
3
3
  export { sandboxBash, sandboxReadFile, sandboxWriteFile, sandboxListDir, sandboxGlob } from './bridge.js';
4
4
  export { validateMountPaths, isBlockedPath, translatePath } from './mount-security.js';
@@ -24,6 +24,11 @@ export interface ExecResult {
24
24
  export declare function setRuntime(runtime: 'container' | 'docker'): void;
25
25
  /** Get the container runtime binary, auto-detecting if not explicitly set. */
26
26
  export declare function getRuntime(): string;
27
+ /**
28
+ * Check if a usable container runtime is available.
29
+ * Returns the runtime name if found, null otherwise. Never throws.
30
+ */
31
+ export declare function probeRuntime(preferred?: string): string | null;
27
32
  /** Reset runtime detection (for testing). */
28
33
  export declare function resetRuntime(): void;
29
34
  export declare function createContainer(name: string, opts: ContainerOpts): Promise<void>;
@@ -23,6 +23,26 @@ export function getRuntime() {
23
23
  }
24
24
  return runtimeBinary;
25
25
  }
26
+ /**
27
+ * Check if a usable container runtime is available.
28
+ * Returns the runtime name if found, null otherwise. Never throws.
29
+ */
30
+ export function probeRuntime(preferred) {
31
+ // If a preferred runtime is specified, check that one first
32
+ if (preferred) {
33
+ const result = spawnSync(preferred, ['--version'], { stdio: 'ignore' });
34
+ if (result.status === 0)
35
+ return preferred;
36
+ }
37
+ // Auto-detect: prefer Apple Containers, fall back to Docker
38
+ if (spawnSync('container', ['--version'], { stdio: 'ignore' }).status === 0) {
39
+ return 'container';
40
+ }
41
+ if (spawnSync('docker', ['--version'], { stdio: 'ignore' }).status === 0) {
42
+ return 'docker';
43
+ }
44
+ return null;
45
+ }
26
46
  /** Reset runtime detection (for testing). */
27
47
  export function resetRuntime() {
28
48
  runtimeBinary = null;
package/dist/service.js CHANGED
@@ -5,26 +5,34 @@ import { initActiveChannel, startActiveChannel, stopActiveChannel } from './chan
5
5
  import { initProviders } from './agent.js';
6
6
  import { initLangfuse, shutdownLangfuse } from './langfuse.js';
7
7
  import { restoreCodeAgentTasks, setCodeAgentConfig } from './tools.js';
8
- import { releaseAll, cleanupOrphans, setRuntime } from './sandbox/index.js';
8
+ import { releaseAll, cleanupOrphans, setRuntime, probeRuntime } from './sandbox/index.js';
9
9
  export async function startRuntime(config) {
10
10
  const smokeTest = process.env.SKIMPYCLAW_SMOKE_TEST === '1';
11
11
  initLangfuse(config);
12
12
  initProviders(config);
13
13
  restoreCodeAgentTasks();
14
14
  setCodeAgentConfig(config);
15
- // Initialize sandbox runtime if configured
16
- if (config.sandbox?.runtime) {
17
- setRuntime(config.sandbox.runtime);
18
- }
19
- // Clean up orphaned sandbox containers from previous runs
15
+ // Initialize sandbox runtime if configured — auto-disable if no runtime available
20
16
  if (config.sandbox?.enabled) {
21
- try {
22
- const count = await cleanupOrphans();
23
- if (count > 0)
24
- console.log(`[sandbox] Cleaned up ${count} orphaned container(s)`);
17
+ const detected = probeRuntime(config.sandbox.runtime);
18
+ if (detected) {
19
+ setRuntime(detected);
20
+ if (detected !== config.sandbox.runtime) {
21
+ console.log(`[sandbox] Configured runtime "${config.sandbox.runtime}" not found, using "${detected}" instead`);
22
+ }
23
+ // Clean up orphaned sandbox containers from previous runs
24
+ try {
25
+ const count = await cleanupOrphans();
26
+ if (count > 0)
27
+ console.log(`[sandbox] Cleaned up ${count} orphaned container(s)`);
28
+ }
29
+ catch (err) {
30
+ console.warn('[sandbox] Failed to clean up orphaned containers:', err instanceof Error ? err.message : err);
31
+ }
25
32
  }
26
- catch (err) {
27
- console.warn('[sandbox] Failed to clean up orphaned containers:', err instanceof Error ? err.message : err);
33
+ else {
34
+ console.warn('[sandbox] No container runtime found (docker/container not installed). Sandbox disabled.');
35
+ config.sandbox.enabled = false;
28
36
  }
29
37
  }
30
38
  const port = smokeTest ? (parseInt(process.env.SKIMPYCLAW_SMOKE_PORT || '19999', 10)) : config.gateway.port;
package/dist/setup.js CHANGED
@@ -226,8 +226,8 @@ function buildStarterCronJobs(starters) {
226
226
  const jobs = [];
227
227
  if (starters.cronTechNews) {
228
228
  jobs.push({
229
- id: 'starter-tech-news-hn',
230
- name: 'Tech News — Top 10 HN',
229
+ id: 'tech-digest',
230
+ name: 'Tech News',
231
231
  schedule: {
232
232
  kind: 'cron',
233
233
  expr: '0 8 * * *',
@@ -241,8 +241,8 @@ function buildStarterCronJobs(starters) {
241
241
  }
242
242
  if (starters.cronWeather) {
243
243
  jobs.push({
244
- id: 'starter-weather-7am',
245
- name: 'Weather Check — 7:00 AM',
244
+ id: 'weather',
245
+ name: 'Weather',
246
246
  schedule: {
247
247
  kind: 'cron',
248
248
  expr: '0 7 * * *',
@@ -23,6 +23,8 @@ export interface ExecuteToolContext {
23
23
  trigger?: string;
24
24
  /** Agent ID for usage tracking */
25
25
  agentId?: string;
26
+ /** True when this context is from a cron job — enables spawn tools even without a chatId */
27
+ isCronJob?: boolean;
26
28
  /** Sandbox configuration for containerized tool execution */
27
29
  sandboxConfig?: import('../types.js').SandboxConfig;
28
30
  /** Session ID for sandbox container mapping */
package/dist/voice.js CHANGED
@@ -190,11 +190,18 @@ function getSTTProvider(config) {
190
190
  const isApiBackedSttProvider = (name, provider) => {
191
191
  if (!provider)
192
192
  return false;
193
- // macOS voice provider is TTS-only and must never be used for transcription.
194
193
  const normalizedName = name.trim().toLowerCase();
194
+ // macOS is TTS-only
195
195
  if (normalizedName === 'macos')
196
196
  return false;
197
- return Boolean(provider.stt || provider.apiKey);
197
+ // Explicit stt config — always good
198
+ if (provider.stt)
199
+ return true;
200
+ // Only OpenAI has a Whisper-compatible transcription endpoint by default.
201
+ // Other providers (elevenlabs, etc.) need explicit stt config to be used for STT.
202
+ if (normalizedName === 'openai' && provider.apiKey)
203
+ return true;
204
+ return false;
198
205
  };
199
206
  if (config.defaultProvider) {
200
207
  const preferredName = config.defaultProvider;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skimpyclaw",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Lightweight personal AI assistant with Telegram and Discord integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",