skimpyclaw 0.3.6 → 0.3.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 +14 -6
- package/dist/__tests__/api.test.js +1 -0
- package/dist/__tests__/channels.test.js +1 -1
- package/dist/__tests__/code-agents-orchestrator.test.js +74 -7
- package/dist/__tests__/code-agents-sandbox.test.d.ts +1 -0
- package/dist/__tests__/code-agents-sandbox.test.js +163 -0
- package/dist/__tests__/context-manager.test.d.ts +1 -0
- package/dist/__tests__/context-manager.test.js +236 -0
- package/dist/__tests__/package-manager-detection.test.js +5 -5
- package/dist/__tests__/setup.test.js +7 -5
- package/dist/__tests__/skills.test.js +2 -2
- package/dist/__tests__/structured-context.test.d.ts +1 -0
- package/dist/__tests__/structured-context.test.js +100 -0
- package/dist/__tests__/tools.test.js +65 -3
- package/dist/agent.js +4 -5
- package/dist/api.js +10 -58
- package/dist/audit.js +5 -51
- package/dist/channels/telegram/handlers.js +2 -60
- package/dist/channels/telegram/index.js +0 -7
- package/dist/channels.js +1 -1
- package/dist/cli.js +151 -16
- package/dist/code-agents/executor.d.ts +9 -4
- package/dist/code-agents/executor.js +187 -13
- package/dist/code-agents/index.d.ts +1 -1
- package/dist/code-agents/index.js +23 -21
- package/dist/code-agents/orchestrator.d.ts +8 -2
- package/dist/code-agents/orchestrator.js +297 -27
- package/dist/code-agents/structured-context.d.ts +7 -0
- package/dist/code-agents/structured-context.js +54 -0
- package/dist/code-agents/types.d.ts +2 -0
- package/dist/code-agents/utils.js +12 -2
- package/dist/code-agents/worktree.d.ts +40 -0
- package/dist/code-agents/worktree.js +215 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.js +5 -3
- package/dist/cron.js +18 -4
- package/dist/dashboard/assets/{index-CkonC7Cd.js → index-BoTHPby4.js} +20 -20
- package/dist/dashboard/assets/{index-EAg6lqF5.css → index-D4mufvBg.css} +1 -1
- package/dist/dashboard/index.html +2 -2
- package/dist/discord.js +4 -40
- package/dist/exec-approval.js +1 -1
- package/dist/file-lock.js +1 -1
- package/dist/gateway.js +3 -10
- package/dist/providers/anthropic.js +9 -5
- package/dist/providers/codex.js +10 -6
- package/dist/providers/context-manager.d.ts +22 -0
- package/dist/providers/context-manager.js +100 -0
- package/dist/providers/openai.js +9 -5
- package/dist/providers/types.d.ts +1 -0
- package/dist/security.js +9 -0
- package/dist/setup.js +112 -14
- package/dist/skills.js +9 -2
- package/dist/subagent.js +33 -2
- package/dist/tools/bash-tool.js +8 -0
- package/dist/tools/browser-tool.js +2 -1
- package/dist/tools/definitions.d.ts +0 -27
- package/dist/tools/definitions.js +0 -18
- package/dist/tools/execute-context.d.ts +4 -4
- package/dist/tools/file-tools.d.ts +1 -1
- package/dist/tools/file-tools.js +1 -1
- package/dist/tools.d.ts +5 -5
- package/dist/tools.js +87 -98
- package/dist/types.d.ts +14 -22
- package/dist/usage.d.ts +1 -0
- package/dist/usage.js +30 -46
- package/dist/utils.d.ts +18 -0
- package/dist/utils.js +71 -0
- package/dist/voice.js +9 -7
- package/package.json +1 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseAgentOutput, formatStructuredContext } from '../code-agents/structured-context.js';
|
|
3
|
+
describe('parseAgentOutput', () => {
|
|
4
|
+
it('returns empty files and errors for plain text', () => {
|
|
5
|
+
const result = parseAgentOutput('Everything looks good.');
|
|
6
|
+
expect(result.summary).toBe('Everything looks good.');
|
|
7
|
+
expect(result.files).toEqual([]);
|
|
8
|
+
expect(result.errors).toEqual([]);
|
|
9
|
+
});
|
|
10
|
+
it('extracts backtick-quoted file paths', () => {
|
|
11
|
+
const raw = 'Updated `src/agent.ts` and `src/types.ts` to add the new field.';
|
|
12
|
+
const result = parseAgentOutput(raw);
|
|
13
|
+
expect(result.files).toContain('src/agent.ts');
|
|
14
|
+
expect(result.files).toContain('src/types.ts');
|
|
15
|
+
});
|
|
16
|
+
it('extracts absolute file paths', () => {
|
|
17
|
+
const raw = 'Wrote changes to /Users/katre/Sites/skimpyclaw/src/agent.ts successfully.';
|
|
18
|
+
const result = parseAgentOutput(raw);
|
|
19
|
+
expect(result.files).toContain('/Users/katre/Sites/skimpyclaw/src/agent.ts');
|
|
20
|
+
});
|
|
21
|
+
it('ignores backtick tokens without dots (not file paths)', () => {
|
|
22
|
+
const raw = 'Call `myFunction` and `anotherMethod` to do the work.';
|
|
23
|
+
const result = parseAgentOutput(raw);
|
|
24
|
+
expect(result.files).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
it('ignores backtick tokens with spaces (commands, not paths)', () => {
|
|
27
|
+
const raw = 'Run `pnpm build && pnpm test` to verify.';
|
|
28
|
+
const result = parseAgentOutput(raw);
|
|
29
|
+
expect(result.files).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
it('extracts error lines', () => {
|
|
32
|
+
const raw = 'Build completed.\nError: Cannot find module src/missing.js\nAll tests passed.';
|
|
33
|
+
const result = parseAgentOutput(raw);
|
|
34
|
+
expect(result.errors).toHaveLength(1);
|
|
35
|
+
expect(result.errors[0]).toContain('Cannot find module');
|
|
36
|
+
});
|
|
37
|
+
it('caps errors at 5', () => {
|
|
38
|
+
const lines = Array.from({ length: 10 }, (_, i) => `Error: problem ${i}`).join('\n');
|
|
39
|
+
const result = parseAgentOutput(lines);
|
|
40
|
+
expect(result.errors.length).toBeLessThanOrEqual(5);
|
|
41
|
+
});
|
|
42
|
+
it('caps files at 15', () => {
|
|
43
|
+
const lines = Array.from({ length: 20 }, (_, i) => `Updated \`src/file${i}.ts\`.`).join('\n');
|
|
44
|
+
const result = parseAgentOutput(lines);
|
|
45
|
+
expect(result.files.length).toBeLessThanOrEqual(15);
|
|
46
|
+
});
|
|
47
|
+
it('deduplicates file paths', () => {
|
|
48
|
+
const raw = 'Updated `src/agent.ts`. Also modified `src/agent.ts` again.';
|
|
49
|
+
const result = parseAgentOutput(raw);
|
|
50
|
+
const count = result.files.filter(f => f === 'src/agent.ts').length;
|
|
51
|
+
expect(count).toBe(1);
|
|
52
|
+
});
|
|
53
|
+
it('truncates summary to 500 chars', () => {
|
|
54
|
+
const raw = 'x'.repeat(1000);
|
|
55
|
+
const result = parseAgentOutput(raw);
|
|
56
|
+
expect(result.summary).toHaveLength(500);
|
|
57
|
+
});
|
|
58
|
+
it('strips trailing punctuation from absolute paths', () => {
|
|
59
|
+
const raw = 'Modified /src/foo.ts, and /src/bar.ts.';
|
|
60
|
+
const result = parseAgentOutput(raw);
|
|
61
|
+
expect(result.files).not.toContain('/src/foo.ts,');
|
|
62
|
+
expect(result.files).not.toContain('/src/bar.ts.');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe('formatStructuredContext', () => {
|
|
66
|
+
it('formats summary only when no files or errors', () => {
|
|
67
|
+
const result = formatStructuredContext({ summary: 'Done.', files: [], errors: [] });
|
|
68
|
+
expect(result).toBe('Summary: Done.');
|
|
69
|
+
});
|
|
70
|
+
it('includes files line when files present', () => {
|
|
71
|
+
const result = formatStructuredContext({
|
|
72
|
+
summary: 'Updated auth.',
|
|
73
|
+
files: ['src/auth.ts', 'src/types.ts'],
|
|
74
|
+
errors: [],
|
|
75
|
+
});
|
|
76
|
+
expect(result).toContain('Files: src/auth.ts, src/types.ts');
|
|
77
|
+
});
|
|
78
|
+
it('includes errors line when errors present', () => {
|
|
79
|
+
const result = formatStructuredContext({
|
|
80
|
+
summary: 'Build failed.',
|
|
81
|
+
files: [],
|
|
82
|
+
errors: ['Error: missing export'],
|
|
83
|
+
});
|
|
84
|
+
expect(result).toContain('Errors: Error: missing export');
|
|
85
|
+
});
|
|
86
|
+
it('separates multiple errors with pipe', () => {
|
|
87
|
+
const result = formatStructuredContext({
|
|
88
|
+
summary: 'Done.',
|
|
89
|
+
files: [],
|
|
90
|
+
errors: ['Error: foo', 'Error: bar'],
|
|
91
|
+
});
|
|
92
|
+
expect(result).toContain('Error: foo | Error: bar');
|
|
93
|
+
});
|
|
94
|
+
it('produces a shorter output than raw 1000-char input', () => {
|
|
95
|
+
const raw = 'x'.repeat(1000);
|
|
96
|
+
const parsed = parseAgentOutput(raw);
|
|
97
|
+
const formatted = formatStructuredContext(parsed);
|
|
98
|
+
expect(formatted.length).toBeLessThan(raw.length);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -59,6 +59,42 @@ describe('getToolDefinitions', () => {
|
|
|
59
59
|
const tools = await getToolDefinitions();
|
|
60
60
|
expect(tools.map(t => t.name)).not.toContain('Browser');
|
|
61
61
|
});
|
|
62
|
+
describe('tool profiles', () => {
|
|
63
|
+
it('minimal returns exactly 4 built-in tools', async () => {
|
|
64
|
+
const config = { ...toolConfig, toolProfile: 'minimal' };
|
|
65
|
+
const tools = await getToolDefinitions(config, { includeAgentTools: true, includeMcp: true });
|
|
66
|
+
expect(tools).toHaveLength(4);
|
|
67
|
+
expect(tools.map(t => t.name)).toEqual(['Read', 'Write', 'Glob', 'Bash']);
|
|
68
|
+
});
|
|
69
|
+
it('minimal excludes Browser even when browser.enabled is true', async () => {
|
|
70
|
+
const config = { ...toolConfig, toolProfile: 'minimal', browser: { enabled: true } };
|
|
71
|
+
const tools = await getToolDefinitions(config);
|
|
72
|
+
expect(tools.map(t => t.name)).not.toContain('Browser');
|
|
73
|
+
});
|
|
74
|
+
it('minimal excludes MCP tools', async () => {
|
|
75
|
+
const config = { ...toolConfig, toolProfile: 'minimal' };
|
|
76
|
+
const tools = await getToolDefinitions(config, { includeMcp: true });
|
|
77
|
+
expect(tools.every(t => !t.name.startsWith('mcp__'))).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
it('coding includes code_with_agent and check_code_agent but not code_with_team', async () => {
|
|
80
|
+
const config = { ...toolConfig, toolProfile: 'coding' };
|
|
81
|
+
const tools = await getToolDefinitions(config, { includeAgentTools: true });
|
|
82
|
+
const names = tools.map(t => t.name);
|
|
83
|
+
expect(names).toContain('code_with_agent');
|
|
84
|
+
expect(names).toContain('check_code_agent');
|
|
85
|
+
expect(names).not.toContain('code_with_team');
|
|
86
|
+
});
|
|
87
|
+
it('coding excludes MCP tools', async () => {
|
|
88
|
+
const config = { ...toolConfig, toolProfile: 'coding' };
|
|
89
|
+
const tools = await getToolDefinitions(config, { includeMcp: true });
|
|
90
|
+
expect(tools.every(t => !t.name.startsWith('mcp__'))).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
it('full profile behaves like default (no profile set)', async () => {
|
|
93
|
+
const defaultTools = await getToolDefinitions(toolConfig);
|
|
94
|
+
const fullTools = await getToolDefinitions({ ...toolConfig, toolProfile: 'full' });
|
|
95
|
+
expect(fullTools.map(t => t.name)).toEqual(defaultTools.map(t => t.name));
|
|
96
|
+
}, 15000);
|
|
97
|
+
});
|
|
62
98
|
});
|
|
63
99
|
describe('tool name mapping', () => {
|
|
64
100
|
it('maps Claude Code names to internal names', () => {
|
|
@@ -192,6 +228,32 @@ describe('bash', () => {
|
|
|
192
228
|
const result = await executeTool('delete_everything', {}, toolConfig);
|
|
193
229
|
expect(result).toContain('Error: Unknown tool');
|
|
194
230
|
});
|
|
231
|
+
describe('exec approval in unattended contexts', () => {
|
|
232
|
+
it('fast-denies tier 3 inline interpreter scripts in subagent context', async () => {
|
|
233
|
+
const result = await executeTool('Bash', { command: 'node -e "console.log(1)"' }, toolConfig, { channel: 'subagent' });
|
|
234
|
+
expect(result).toContain('⛔');
|
|
235
|
+
expect(result).toContain('tier 3');
|
|
236
|
+
expect(result).not.toContain('approved');
|
|
237
|
+
});
|
|
238
|
+
it('fast-denies tier 2 commands in subagent context', async () => {
|
|
239
|
+
const result = await executeTool('Bash', { command: 'gh pr review --approve' }, toolConfig, { channel: 'subagent' });
|
|
240
|
+
expect(result).toContain('⛔');
|
|
241
|
+
expect(result).toContain('tier 2');
|
|
242
|
+
});
|
|
243
|
+
it('fast-denies tier 3 commands in cron context', async () => {
|
|
244
|
+
const result = await executeTool('Bash', { command: 'node -e "console.log(1)"' }, toolConfig, { isCronJob: true });
|
|
245
|
+
expect(result).toContain('⛔');
|
|
246
|
+
expect(result).toContain('tier 3');
|
|
247
|
+
});
|
|
248
|
+
it('fast-denies when no approver and no chatId', async () => {
|
|
249
|
+
const result = await executeTool('Bash', { command: 'kubectl delete pods --all' }, toolConfig, {});
|
|
250
|
+
expect(result).toContain('⛔');
|
|
251
|
+
});
|
|
252
|
+
it('allows safe tier 0 commands in subagent context', async () => {
|
|
253
|
+
const result = await executeTool('Bash', { command: 'echo hello' }, toolConfig, { channel: 'subagent' });
|
|
254
|
+
expect(result.trim()).toBe('hello');
|
|
255
|
+
});
|
|
256
|
+
});
|
|
195
257
|
});
|
|
196
258
|
describe('browser', () => {
|
|
197
259
|
const browserDisabledConfig = {
|
|
@@ -286,7 +348,7 @@ describe('code_with_agent', () => {
|
|
|
286
348
|
expect(props).toContain('validate');
|
|
287
349
|
});
|
|
288
350
|
it('is included in getToolDefinitions when includeSpawnSubagent is true', async () => {
|
|
289
|
-
const tools = await getToolDefinitions(toolConfig, {
|
|
351
|
+
const tools = await getToolDefinitions(toolConfig, { includeAgentTools: true });
|
|
290
352
|
expect(tools.map(t => t.name)).toContain('code_with_agent');
|
|
291
353
|
});
|
|
292
354
|
it('is excluded from getToolDefinitions when includeSpawnSubagent is false', async () => {
|
|
@@ -401,7 +463,7 @@ describe('code_with_agent', () => {
|
|
|
401
463
|
expect(CHECK_CODE_AGENT_TOOL.input_schema.properties).toHaveProperty('id');
|
|
402
464
|
});
|
|
403
465
|
it('is included in getToolDefinitions when includeSpawnSubagent is true', async () => {
|
|
404
|
-
const tools = await getToolDefinitions(toolConfig, {
|
|
466
|
+
const tools = await getToolDefinitions(toolConfig, { includeAgentTools: true });
|
|
405
467
|
expect(tools.map(t => t.name)).toContain('check_code_agent');
|
|
406
468
|
});
|
|
407
469
|
it('is excluded from getToolDefinitions when includeSpawnSubagent is false', async () => {
|
|
@@ -453,7 +515,7 @@ describe('code_with_team', () => {
|
|
|
453
515
|
expect(props).not.toContain('max_turns');
|
|
454
516
|
});
|
|
455
517
|
it('is included in getToolDefinitions when includeSpawnSubagent is true', async () => {
|
|
456
|
-
const tools = await getToolDefinitions(toolConfig, {
|
|
518
|
+
const tools = await getToolDefinitions(toolConfig, { includeAgentTools: true });
|
|
457
519
|
expect(tools.map(t => t.name)).toContain('code_with_team');
|
|
458
520
|
});
|
|
459
521
|
it('is excluded from getToolDefinitions when includeSpawnSubagent is false', async () => {
|
package/dist/agent.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Agent runner: loads templates, calls models, manages memory
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'fs';
|
|
2
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, unlinkSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { getAgentDir } from './config.js';
|
|
5
5
|
import { buildSafeSystemPrompt, sanitizeUserInput } from './security.js';
|
|
6
|
+
import { toErrorMessage } from './utils.js';
|
|
6
7
|
import { startTrace, endTrace } from './audit.js';
|
|
7
8
|
import { loadSkills, getSkillsForContext, formatSkillsPrompt } from './skills.js';
|
|
8
9
|
import { getLangfuseConfig, isLangfuseEnabled } from './langfuse.js';
|
|
@@ -73,9 +74,7 @@ export function appendToMemory(agentId, entry) {
|
|
|
73
74
|
}
|
|
74
75
|
const path = getTodayMemoryPath(agentId);
|
|
75
76
|
const timestamp = new Date().toISOString();
|
|
76
|
-
|
|
77
|
-
const newContent = content + `\n## ${timestamp}\n\n${entry}\n`;
|
|
78
|
-
writeFileSync(path, newContent.trim() + '\n');
|
|
77
|
+
appendFileSync(path, `\n## ${timestamp}\n\n${entry}\n`, 'utf-8');
|
|
79
78
|
}
|
|
80
79
|
// --- Agent Turn ---
|
|
81
80
|
// Langfuse app tagging
|
|
@@ -230,7 +229,7 @@ export async function runAgentTurn(agentId, userMessage, config, modelOverride,
|
|
|
230
229
|
return result;
|
|
231
230
|
}
|
|
232
231
|
catch (err) {
|
|
233
|
-
const errorMessage =
|
|
232
|
+
const errorMessage = toErrorMessage(err);
|
|
234
233
|
agentObs.update({
|
|
235
234
|
level: 'ERROR',
|
|
236
235
|
statusMessage: errorMessage,
|
package/dist/api.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
// Dashboard API endpoints
|
|
2
2
|
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync, rmSync } from 'fs';
|
|
3
|
-
import {
|
|
3
|
+
import { validateBearerToken } from './utils.js';
|
|
4
4
|
import { join, basename, resolve } from 'path';
|
|
5
5
|
import { homedir } from 'os';
|
|
6
|
-
import { loadConfig, loadRawConfig, saveConfig, getSessionsDir, getLogsDir, getAgentDir, listMemoryFiles, readMemoryFile, } from './config.js';
|
|
6
|
+
import { loadConfig, loadRawConfig, saveConfig, getSessionsDir, getLogsDir, getAgentDir, isValidAgentId, listMemoryFiles, readMemoryFile, } from './config.js';
|
|
7
7
|
import { TEMPLATE_FILES, getAgentTemplateContent, saveAgentTemplate, } from './agent.js';
|
|
8
8
|
import { getCronJobs, getCronJobDetails, runCronJob } from './cron.js';
|
|
9
9
|
import { getCurrentModel, setCurrentModel, getLastMessage, setGatewayConfig } from './gateway.js';
|
|
10
10
|
import { redactSecrets } from './security.js';
|
|
11
|
-
import { getActiveTasks, getRecentTasks } from './subagent.js';
|
|
12
11
|
import { readAuditTraces } from './audit.js';
|
|
13
12
|
import { getUsageSummary, readUsageRecords } from './usage.js';
|
|
14
13
|
import { getAllCodeAgents, getCodeAgent, cancelCodeAgent } from './tools.js';
|
|
@@ -36,10 +35,7 @@ const DEFAULT_MODEL_ALIASES = {
|
|
|
36
35
|
function validateFilename(filename) {
|
|
37
36
|
return !filename.includes('..') && filename === basename(filename);
|
|
38
37
|
}
|
|
39
|
-
|
|
40
|
-
// Agent IDs should be simple identifiers: alphanumeric, hyphens, underscores
|
|
41
|
-
return /^[a-zA-Z0-9_-]+$/.test(agentId);
|
|
42
|
-
}
|
|
38
|
+
// Use isValidAgentId from config.ts
|
|
43
39
|
function validateSkillName(name) {
|
|
44
40
|
return /^[a-zA-Z0-9-]+$/.test(name) && name.length <= 100;
|
|
45
41
|
}
|
|
@@ -98,30 +94,14 @@ export function registerDashboardAPI(fastify, config) {
|
|
|
98
94
|
if (!token) {
|
|
99
95
|
return; // No token configured, allow access
|
|
100
96
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return reply.code(401).send({ error: 'Unauthorized: Bearer token required' });
|
|
104
|
-
}
|
|
105
|
-
const providedToken = authHeader.slice(7);
|
|
106
|
-
// Timing-safe comparison to prevent token extraction via timing attacks
|
|
107
|
-
const tokenBuf = Buffer.from(token, 'utf8');
|
|
108
|
-
const providedBuf = Buffer.from(providedToken, 'utf8');
|
|
109
|
-
if (tokenBuf.length !== providedBuf.length || !timingSafeEqual(tokenBuf, providedBuf)) {
|
|
110
|
-
return reply.code(401).send({ error: 'Unauthorized: Invalid token' });
|
|
97
|
+
if (!validateBearerToken(token, request.headers.authorization)) {
|
|
98
|
+
return reply.code(401).send({ error: 'Unauthorized: Invalid or missing token' });
|
|
111
99
|
}
|
|
112
100
|
});
|
|
113
101
|
// --- Status ---
|
|
114
102
|
fastify.get('/api/dashboard/status', async () => {
|
|
115
103
|
const jobs = getCronJobs();
|
|
116
104
|
const uptime = process.uptime();
|
|
117
|
-
const activeTasks = getActiveTasks();
|
|
118
|
-
const recentTasks = getRecentTasks(20);
|
|
119
|
-
const pendingCount = activeTasks.filter(t => t.status === 'pending').length;
|
|
120
|
-
const runningCount = activeTasks.filter(t => t.status === 'running').length;
|
|
121
|
-
const recentCompleted = recentTasks.filter(t => t.status === 'completed').length;
|
|
122
|
-
const recentFailed = recentTasks.filter(t => t.status === 'failed').length;
|
|
123
|
-
const recentCancelled = recentTasks.filter(t => t.status === 'cancelled').length;
|
|
124
|
-
const maxConcurrent = runtimeConfig.subagents?.maxConcurrent ?? 5;
|
|
125
105
|
return {
|
|
126
106
|
uptime,
|
|
127
107
|
model: getCurrentModel(),
|
|
@@ -129,34 +109,6 @@ export function registerDashboardAPI(fastify, config) {
|
|
|
129
109
|
lastMessage: getLastMessage(),
|
|
130
110
|
activeChannel: getActiveChannelId() ?? runtimeConfig.channels.active ?? null,
|
|
131
111
|
cronJobs: jobs,
|
|
132
|
-
subagents: {
|
|
133
|
-
maxConcurrent,
|
|
134
|
-
active: activeTasks.length,
|
|
135
|
-
running: runningCount,
|
|
136
|
-
pending: pendingCount,
|
|
137
|
-
recentTotal: recentTasks.length,
|
|
138
|
-
recentCompleted,
|
|
139
|
-
recentFailed,
|
|
140
|
-
recentCancelled,
|
|
141
|
-
},
|
|
142
|
-
activeSubagents: activeTasks
|
|
143
|
-
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
|
144
|
-
.map(task => {
|
|
145
|
-
const started = task.startedAt || task.createdAt;
|
|
146
|
-
return {
|
|
147
|
-
id: task.id,
|
|
148
|
-
type: task.type,
|
|
149
|
-
status: task.status,
|
|
150
|
-
model: task.model,
|
|
151
|
-
label: task.label,
|
|
152
|
-
promptPreview: task.prompt.slice(0, 120),
|
|
153
|
-
retryCount: task.retryCount ?? 0,
|
|
154
|
-
maxRetries: task.maxRetries ?? 2,
|
|
155
|
-
createdAt: task.createdAt,
|
|
156
|
-
startedAt: task.startedAt,
|
|
157
|
-
elapsedSeconds: Math.max(0, Math.round((Date.now() - started.getTime()) / 1000)),
|
|
158
|
-
};
|
|
159
|
-
}),
|
|
160
112
|
};
|
|
161
113
|
});
|
|
162
114
|
// --- Sessions ---
|
|
@@ -306,7 +258,7 @@ export function registerDashboardAPI(fastify, config) {
|
|
|
306
258
|
// --- Memory ---
|
|
307
259
|
fastify.get('/api/dashboard/memory/:agentId', async (request, reply) => {
|
|
308
260
|
const { agentId } = request.params;
|
|
309
|
-
if (!
|
|
261
|
+
if (!isValidAgentId(agentId)) {
|
|
310
262
|
return reply.code(400).send({ error: 'Invalid agent ID' });
|
|
311
263
|
}
|
|
312
264
|
const files = listMemoryFiles(agentId);
|
|
@@ -314,7 +266,7 @@ export function registerDashboardAPI(fastify, config) {
|
|
|
314
266
|
});
|
|
315
267
|
fastify.get('/api/dashboard/memory/:agentId/:filename', async (request, reply) => {
|
|
316
268
|
const { agentId, filename } = request.params;
|
|
317
|
-
if (!
|
|
269
|
+
if (!isValidAgentId(agentId)) {
|
|
318
270
|
return reply.code(400).send({ error: 'Invalid agent ID' });
|
|
319
271
|
}
|
|
320
272
|
// Special case: "curated" reads MEMORY.md from the agent root
|
|
@@ -455,7 +407,7 @@ export function registerDashboardAPI(fastify, config) {
|
|
|
455
407
|
// --- Templates ---
|
|
456
408
|
fastify.get('/api/dashboard/templates/:agentId', async (request, reply) => {
|
|
457
409
|
const { agentId } = request.params;
|
|
458
|
-
if (!
|
|
410
|
+
if (!isValidAgentId(agentId)) {
|
|
459
411
|
return reply.code(400).send({ error: 'Invalid agent ID' });
|
|
460
412
|
}
|
|
461
413
|
const agentDir = getAgentDir(agentId);
|
|
@@ -472,7 +424,7 @@ export function registerDashboardAPI(fastify, config) {
|
|
|
472
424
|
});
|
|
473
425
|
fastify.get('/api/dashboard/templates/:agentId/:name', async (request, reply) => {
|
|
474
426
|
const { agentId, name } = request.params;
|
|
475
|
-
if (!
|
|
427
|
+
if (!isValidAgentId(agentId)) {
|
|
476
428
|
return reply.code(400).send({ error: 'Invalid agent ID' });
|
|
477
429
|
}
|
|
478
430
|
const content = getAgentTemplateContent(agentId, name);
|
|
@@ -483,7 +435,7 @@ export function registerDashboardAPI(fastify, config) {
|
|
|
483
435
|
});
|
|
484
436
|
fastify.put('/api/dashboard/templates/:agentId/:name', async (request, reply) => {
|
|
485
437
|
const { agentId, name } = request.params;
|
|
486
|
-
if (!
|
|
438
|
+
if (!isValidAgentId(agentId)) {
|
|
487
439
|
return reply.code(400).send({ error: 'Invalid agent ID' });
|
|
488
440
|
}
|
|
489
441
|
const { content } = request.body;
|
package/dist/audit.js
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
// Audit log reader/writer for ~/.skimpyclaw/logs/audit/YYYY-MM-DD.jsonl
|
|
2
2
|
import { randomUUID } from 'crypto';
|
|
3
|
-
import {
|
|
3
|
+
import { appendFileSync, existsSync, mkdirSync } from 'fs';
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
import { homedir } from 'os';
|
|
6
|
+
import { formatDate, readJsonlDir } from './utils.js';
|
|
6
7
|
const AUDIT_DIR = join(homedir(), '.skimpyclaw', 'logs', 'audit');
|
|
7
|
-
function formatDate(date) {
|
|
8
|
-
const y = date.getFullYear();
|
|
9
|
-
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
10
|
-
const d = String(date.getDate()).padStart(2, '0');
|
|
11
|
-
return `${y}-${m}-${d}`;
|
|
12
|
-
}
|
|
13
8
|
// --- In-memory trace lifecycle ---
|
|
14
9
|
/** Active traces keyed by traceId */
|
|
15
10
|
const activeTraces = new Map();
|
|
@@ -60,52 +55,11 @@ export async function readAuditTraces(options = {}) {
|
|
|
60
55
|
const limit = options.limit ?? 50;
|
|
61
56
|
const offset = options.offset ?? 0;
|
|
62
57
|
const triggerFilter = options.trigger;
|
|
63
|
-
// Determine date range
|
|
64
58
|
const endDate = options.endDate ?? new Date();
|
|
65
|
-
const startDate = options.startDate ?? new Date(endDate.getTime() - 90 * 24 * 60 * 60 * 1000);
|
|
66
|
-
|
|
67
|
-
if (!existsSync(AUDIT_DIR)) {
|
|
68
|
-
return { traces: [], total: 0 };
|
|
69
|
-
}
|
|
70
|
-
const files = readdirSync(AUDIT_DIR)
|
|
71
|
-
.filter(f => f.endsWith('.jsonl'))
|
|
72
|
-
.sort()
|
|
73
|
-
.reverse(); // Newest first
|
|
74
|
-
const startStr = formatDate(startDate);
|
|
75
|
-
const endStr = formatDate(endDate);
|
|
76
|
-
// Collect all matching traces
|
|
77
|
-
const allTraces = [];
|
|
78
|
-
for (const file of files) {
|
|
79
|
-
const dateStr = file.replace('.jsonl', '');
|
|
80
|
-
if (dateStr < startStr || dateStr > endStr)
|
|
81
|
-
continue;
|
|
82
|
-
const filePath = join(AUDIT_DIR, file);
|
|
83
|
-
try {
|
|
84
|
-
const content = readFileSync(filePath, 'utf-8');
|
|
85
|
-
const lines = content.trim().split('\n').filter(Boolean);
|
|
86
|
-
// Parse lines in reverse (newest first within file)
|
|
87
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
88
|
-
try {
|
|
89
|
-
const trace = JSON.parse(lines[i]);
|
|
90
|
-
if (triggerFilter && trace.trigger !== triggerFilter)
|
|
91
|
-
continue;
|
|
92
|
-
allTraces.push(trace);
|
|
93
|
-
}
|
|
94
|
-
catch {
|
|
95
|
-
// Skip malformed lines
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
100
|
-
// Skip unreadable files
|
|
101
|
-
}
|
|
102
|
-
}
|
|
59
|
+
const startDate = options.startDate ?? new Date(endDate.getTime() - 90 * 24 * 60 * 60 * 1000);
|
|
60
|
+
const allTraces = readJsonlDir(AUDIT_DIR, formatDate(startDate), formatDate(endDate), triggerFilter ? (t) => t.trigger === triggerFilter : undefined);
|
|
103
61
|
// Sort newest first by startedAt
|
|
104
|
-
allTraces.sort((a, b) =>
|
|
105
|
-
const dateA = new Date(b.startedAt || b.endedAt).getTime();
|
|
106
|
-
const dateB = new Date(a.startedAt || a.endedAt).getTime();
|
|
107
|
-
return dateA - dateB;
|
|
108
|
-
});
|
|
62
|
+
allTraces.sort((a, b) => Date.parse(b.startedAt || b.endedAt) - Date.parse(a.startedAt || a.endedAt));
|
|
109
63
|
const total = allTraces.length;
|
|
110
64
|
const paged = allTraces.slice(offset, offset + limit);
|
|
111
65
|
return { traces: paged, total };
|
|
@@ -4,7 +4,6 @@ import { spawnSync } from 'child_process';
|
|
|
4
4
|
import { getCurrentModel, setCurrentModel, getLastMessage } from '../../gateway.js';
|
|
5
5
|
import { getCronJobs, runCronJob } from '../../cron.js';
|
|
6
6
|
import { runHeartbeatCheck } from '../../heartbeat.js';
|
|
7
|
-
import { cancelTask, getActiveTasks, getRecentTasks } from '../../subagent.js';
|
|
8
7
|
import { getActiveCodeAgents, getRecentCodeAgents } from '../../code-agents/index.js';
|
|
9
8
|
import { listApprovals, approveRequest, denyRequest, getApproval, onApprovalEvent } from '../../exec-approval.js';
|
|
10
9
|
import { loadSkills } from '../../skills.js';
|
|
@@ -47,28 +46,9 @@ export async function handleStatus(ctx, cfg) {
|
|
|
47
46
|
const model = getCurrentModel();
|
|
48
47
|
const last = getLastMessage();
|
|
49
48
|
const jobs = getCronJobs();
|
|
50
|
-
const activeTasks = getActiveTasks();
|
|
51
|
-
const recentTasks = getRecentTasks(20);
|
|
52
49
|
const jobList = jobs
|
|
53
50
|
.map((j) => ` - ${j.name}: ${j.nextRun?.toLocaleString() || 'unknown'}`)
|
|
54
51
|
.join('\n');
|
|
55
|
-
const pendingCount = activeTasks.filter((t) => t.status === 'pending').length;
|
|
56
|
-
const runningCount = activeTasks.filter((t) => t.status === 'running').length;
|
|
57
|
-
const maxConcurrent = cfg.subagents?.maxConcurrent ?? 5;
|
|
58
|
-
const recentCompleted = recentTasks.filter((t) => t.status === 'completed').length;
|
|
59
|
-
const recentFailed = recentTasks.filter((t) => t.status === 'failed').length;
|
|
60
|
-
const recentCancelled = recentTasks.filter((t) => t.status === 'cancelled').length;
|
|
61
|
-
const activePreview = activeTasks
|
|
62
|
-
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
|
63
|
-
.slice(0, 3)
|
|
64
|
-
.map((task) => {
|
|
65
|
-
const started = task.startedAt || task.createdAt;
|
|
66
|
-
const elapsedSeconds = Math.max(0, Math.round((Date.now() - started.getTime()) / 1000));
|
|
67
|
-
const elapsed = elapsedSeconds < 60 ? `${elapsedSeconds}s` : `${Math.round(elapsedSeconds / 60)}m`;
|
|
68
|
-
const label = task.label ? ` (${task.label})` : '';
|
|
69
|
-
return ` - ${task.id} [${task.type}] ${task.status}${label} • ${elapsed}`;
|
|
70
|
-
})
|
|
71
|
-
.join('\n');
|
|
72
52
|
// Coding agents status (multi-agent)
|
|
73
53
|
const caActive = getActiveCodeAgents();
|
|
74
54
|
const caRecent = getRecentCodeAgents(20);
|
|
@@ -109,9 +89,6 @@ export async function handleStatus(ctx, cfg) {
|
|
|
109
89
|
`Last message: ${last?.toLocaleString() || 'never'}\n` +
|
|
110
90
|
`Silence until: ${state.silenceUntil?.toLocaleTimeString() || 'not silenced'}\n\n` +
|
|
111
91
|
`${caLine}\n\n` +
|
|
112
|
-
`Subagents: ${activeTasks.length}/${maxConcurrent} active (running: ${runningCount}, pending: ${pendingCount})\n` +
|
|
113
|
-
`Recent (last ${recentTasks.length}): ✅ ${recentCompleted} • ❌ ${recentFailed} • 🚫 ${recentCancelled}\n` +
|
|
114
|
-
`${activePreview ? `Active now:\n${activePreview}\n\n` : '\n'}` +
|
|
115
92
|
`Scheduled jobs:\n${jobList || ' (none)'}`);
|
|
116
93
|
}
|
|
117
94
|
export async function handleCron(ctx, cfg) {
|
|
@@ -182,45 +159,10 @@ export async function handleRestart(ctx, cfg) {
|
|
|
182
159
|
}
|
|
183
160
|
}
|
|
184
161
|
export async function handleTasks(ctx, cfg) {
|
|
185
|
-
|
|
186
|
-
const recent = getRecentTasks(5);
|
|
187
|
-
if (recent.length === 0) {
|
|
188
|
-
await ctx.reply('No agent tasks yet. Subagents spawn automatically for complex requests.');
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
const formatTask = (t) => {
|
|
192
|
-
const elapsed = ((t.completedAt || new Date()).getTime() - t.createdAt.getTime()) / 1000;
|
|
193
|
-
const elapsedStr = elapsed < 60 ? `${Math.round(elapsed)}s` : `${Math.round(elapsed / 60)}m`;
|
|
194
|
-
const status = {
|
|
195
|
-
pending: '⏳ Pending',
|
|
196
|
-
running: `🔄 Running (${elapsedStr})`,
|
|
197
|
-
completed: `✅ Done (${elapsedStr})`,
|
|
198
|
-
failed: `❌ Failed (${elapsedStr})`,
|
|
199
|
-
cancelled: '🚫 Cancelled'
|
|
200
|
-
};
|
|
201
|
-
const promptPreview = t.prompt.slice(0, 60) + (t.prompt.length > 60 ? '...' : '');
|
|
202
|
-
return `${t.id}: ${status[t.status] || t.status} [${t.type}] ${promptPreview}`;
|
|
203
|
-
};
|
|
204
|
-
const lines = [...active, ...recent].slice(0, 10).map(formatTask).join('\n');
|
|
205
|
-
await ctx.reply(`Agent tasks:\n\n${lines}`);
|
|
162
|
+
await ctx.reply('Use /agents to list active coding agents, or /cron to manage scheduled tasks.');
|
|
206
163
|
}
|
|
207
164
|
export async function handleCancel(ctx, cfg) {
|
|
208
|
-
|
|
209
|
-
if (!id) {
|
|
210
|
-
await ctx.reply('Usage: /cancel <task-id>\nExample: /cancel t1');
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
const task = cancelTask(id);
|
|
214
|
-
if (!task) {
|
|
215
|
-
await ctx.reply(`No task found: ${id}`);
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
if (task.status === 'cancelled') {
|
|
219
|
-
await ctx.reply(`Cancelled ${id}.`);
|
|
220
|
-
}
|
|
221
|
-
else {
|
|
222
|
-
await ctx.reply(`Task ${id} is already ${task.status}.`);
|
|
223
|
-
}
|
|
165
|
+
await ctx.reply('Use the dashboard to cancel coding agents, or /cron to manage scheduled tasks.');
|
|
224
166
|
}
|
|
225
167
|
export async function handleSkills(ctx, cfg) {
|
|
226
168
|
const skillConfig = cfg.skills;
|
|
@@ -3,7 +3,6 @@ import { Bot, GrammyError, HttpError, InputFile } from 'grammy';
|
|
|
3
3
|
import { run } from '@grammyjs/runner';
|
|
4
4
|
import { isAllowed, isRateLimited } from '../../security.js';
|
|
5
5
|
import { runAgentTurn } from '../../agent.js';
|
|
6
|
-
import { initSubagentSystem } from '../../subagent.js';
|
|
7
6
|
import { getCurrentModel } from '../../gateway.js';
|
|
8
7
|
import { getApproval, approveRequest, denyRequest } from '../../exec-approval.js';
|
|
9
8
|
import { transcribeAudio, synthesizeSpeech } from '../../voice.js';
|
|
@@ -93,12 +92,6 @@ export async function initTelegram(cfg) {
|
|
|
93
92
|
}
|
|
94
93
|
const bot = new Bot(cfg.channels.telegram.token);
|
|
95
94
|
activeBot = bot;
|
|
96
|
-
// Initialize subagent system with message delivery callback
|
|
97
|
-
initSubagentSystem(async (chatId, message) => {
|
|
98
|
-
if (!activeBot)
|
|
99
|
-
return;
|
|
100
|
-
await sendLongMessage({ reply: (text) => activeBot.api.sendMessage(chatId, text) }, message);
|
|
101
|
-
});
|
|
102
95
|
// Register commands with Telegram for the / menu
|
|
103
96
|
bot.api.setMyCommands(BOT_COMMANDS).catch((err) => {
|
|
104
97
|
console.error('[telegram] Failed to set bot commands:', err);
|
package/dist/channels.js
CHANGED
|
@@ -27,7 +27,7 @@ function resolveChannelPreference(config) {
|
|
|
27
27
|
}
|
|
28
28
|
async function loadAdapter(channel) {
|
|
29
29
|
if (channel === 'telegram') {
|
|
30
|
-
const telegram = await import('./telegram.js');
|
|
30
|
+
const telegram = await import('./channels/telegram/index.js');
|
|
31
31
|
return {
|
|
32
32
|
init: async (config) => (await telegram.initTelegram(config)) !== null,
|
|
33
33
|
start: telegram.startTelegram,
|