skimpyclaw 0.3.9 → 0.3.14

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.
Files changed (59) hide show
  1. package/dist/__tests__/channels.test.js +1 -1
  2. package/dist/__tests__/context-manager.test.js +219 -76
  3. package/dist/__tests__/providers-utils.test.js +2 -0
  4. package/dist/__tests__/sandbox-manager.test.js +25 -0
  5. package/dist/__tests__/sandbox-mount-security.test.js +8 -0
  6. package/dist/__tests__/setup.test.js +1 -1
  7. package/dist/__tests__/skills.test.js +53 -26
  8. package/dist/__tests__/token-efficiency.test.js +37 -15
  9. package/dist/__tests__/tools.test.js +11 -9
  10. package/dist/agent.js +2 -2
  11. package/dist/api.js +5 -0
  12. package/dist/channels/discord/handlers.d.ts +7 -0
  13. package/dist/channels/discord/handlers.js +479 -0
  14. package/dist/channels/discord/index.d.ts +8 -0
  15. package/dist/channels/discord/index.js +149 -0
  16. package/dist/channels/discord/types.d.ts +6 -0
  17. package/dist/channels/discord/types.js +17 -0
  18. package/dist/channels/discord/utils.d.ts +14 -0
  19. package/dist/channels/discord/utils.js +161 -0
  20. package/dist/channels/telegram/utils.d.ts +1 -1
  21. package/dist/channels/telegram/utils.js +7 -9
  22. package/dist/channels.js +1 -1
  23. package/dist/cli.js +8 -43
  24. package/dist/code-agents/parser.js +5 -0
  25. package/dist/config.d.ts +7 -0
  26. package/dist/config.js +13 -0
  27. package/dist/cron.js +6 -3
  28. package/dist/heartbeat.js +11 -15
  29. package/dist/providers/anthropic.js +7 -1
  30. package/dist/providers/codex.js +8 -2
  31. package/dist/providers/context-manager.d.ts +37 -6
  32. package/dist/providers/context-manager.js +303 -47
  33. package/dist/providers/openai.js +8 -2
  34. package/dist/providers/utils.d.ts +6 -2
  35. package/dist/providers/utils.js +36 -4
  36. package/dist/sandbox/manager.js +11 -0
  37. package/dist/sandbox/mount-security.js +5 -1
  38. package/dist/sandbox/runtime.d.ts +1 -0
  39. package/dist/sandbox/runtime.js +5 -0
  40. package/dist/sandbox-utils.d.ts +6 -0
  41. package/dist/sandbox-utils.js +36 -0
  42. package/dist/security.js +4 -3
  43. package/dist/service.js +25 -0
  44. package/dist/setup-templates.d.ts +14 -0
  45. package/dist/setup-templates.js +214 -0
  46. package/dist/setup.d.ts +1 -9
  47. package/dist/setup.js +3 -244
  48. package/dist/skills-types.d.ts +6 -0
  49. package/dist/skills.d.ts +5 -1
  50. package/dist/skills.js +25 -2
  51. package/dist/tools/bash-tool.js +11 -1
  52. package/dist/tools/definitions.d.ts +57 -0
  53. package/dist/tools/definitions.js +19 -1
  54. package/dist/tools/fetch-tool.d.ts +8 -0
  55. package/dist/tools/fetch-tool.js +80 -0
  56. package/dist/tools.d.ts +4 -2
  57. package/dist/tools.js +110 -62
  58. package/dist/types.d.ts +5 -0
  59. package/package.json +23 -29
@@ -0,0 +1,149 @@
1
+ import { Client, GatewayIntentBits, Partials, AttachmentBuilder, } from 'discord.js';
2
+ import { onApprovalEvent } from '../../exec-approval.js';
3
+ import { KNOWN_COMMANDS } from './types.js';
4
+ import { handleCommand, handleIncomingMessage, handleInteraction, sendApprovalCard } from './handlers.js';
5
+ import { splitToChunks } from './utils.js';
6
+ let client = null;
7
+ let config;
8
+ let silenceUntil = null;
9
+ export async function initDiscord(cfg) {
10
+ const discord = cfg.channels.discord;
11
+ if (!discord?.enabled || !discord.token) {
12
+ console.log('[discord] Disabled or no token configured');
13
+ return false;
14
+ }
15
+ config = cfg;
16
+ client = new Client({
17
+ intents: [
18
+ GatewayIntentBits.Guilds,
19
+ GatewayIntentBits.GuildMessages,
20
+ GatewayIntentBits.DirectMessages,
21
+ GatewayIntentBits.MessageContent,
22
+ ],
23
+ partials: [Partials.Channel],
24
+ });
25
+ client.on('messageCreate', (message) => {
26
+ if (message.author.bot)
27
+ return;
28
+ void (async () => {
29
+ const text = message.content.trim();
30
+ const isPrefixedCommand = text.startsWith('/') || text.startsWith('!');
31
+ const isDm = message.channel.isDMBased();
32
+ // Route commands through handleCommand with silenceUntil access
33
+ if (isPrefixedCommand || isDm) {
34
+ const commandText = isPrefixedCommand ? text.slice(1).trim() : text;
35
+ const [commandPart, ...args] = commandText.split(/\s+/);
36
+ const command = (commandPart || '').toLowerCase();
37
+ if (KNOWN_COMMANDS.has(command)) {
38
+ await handleCommand(message, command, args, config, silenceUntil, (d) => { silenceUntil = d; });
39
+ return;
40
+ }
41
+ if (isPrefixedCommand) {
42
+ await message.reply(`Unknown command: /${command}\n\nType /help to see available commands.`);
43
+ return;
44
+ }
45
+ }
46
+ // Non-command messages
47
+ await handleIncomingMessage(message, config);
48
+ })();
49
+ });
50
+ client.on('interactionCreate', (interaction) => {
51
+ void handleInteraction(interaction);
52
+ });
53
+ client.once('clientReady', () => {
54
+ console.log(`[discord] Bot started as ${client?.user?.tag ?? 'unknown'}`);
55
+ });
56
+ client.on('error', (error) => {
57
+ console.error('[discord] Client error:', error);
58
+ });
59
+ // Subscribe to approval-created events
60
+ onApprovalEvent('created', (event) => {
61
+ if (!client)
62
+ return;
63
+ const { approval } = event;
64
+ const meta = approval.channelMeta;
65
+ if (meta?.channel && meta.channel !== 'discord')
66
+ return;
67
+ let targetChannelId;
68
+ if (meta?.chatId) {
69
+ targetChannelId = String(meta.chatId);
70
+ }
71
+ if (!targetChannelId) {
72
+ targetChannelId = getDiscordDefaultTarget(cfg) ?? undefined;
73
+ }
74
+ if (!targetChannelId)
75
+ return;
76
+ void sendApprovalCard(client, targetChannelId, approval).catch((err) => {
77
+ console.error('[discord] Failed to send approval notification:', err);
78
+ });
79
+ });
80
+ return true;
81
+ }
82
+ export async function startDiscord() {
83
+ if (!client || !config.channels.discord?.token)
84
+ return;
85
+ console.log('[discord] Starting bot...');
86
+ await client.login(config.channels.discord.token);
87
+ }
88
+ export async function stopDiscord() {
89
+ if (!client)
90
+ return;
91
+ client.destroy();
92
+ console.log('[discord] Bot stopped');
93
+ }
94
+ export function isDiscordSilenced() {
95
+ if (!silenceUntil)
96
+ return false;
97
+ return new Date() < silenceUntil;
98
+ }
99
+ export function getDiscordDefaultTarget(cfg) {
100
+ const discord = cfg.channels.discord;
101
+ if (!discord)
102
+ return null;
103
+ if (discord.defaultChannelId?.trim())
104
+ return discord.defaultChannelId.trim();
105
+ for (const entry of discord.allowFrom) {
106
+ const value = String(entry).trim();
107
+ if (value)
108
+ return value;
109
+ }
110
+ return null;
111
+ }
112
+ async function sendChunked(target, text) {
113
+ const chunks = splitToChunks(text, 1900);
114
+ for (const chunk of chunks) {
115
+ await target.send(chunk);
116
+ }
117
+ }
118
+ export async function sendDiscordProactiveMessage(target, message) {
119
+ if (!client || isDiscordSilenced())
120
+ return;
121
+ const targetId = String(target);
122
+ const channel = await client.channels.fetch(targetId).catch(() => null);
123
+ if (channel && 'send' in channel && typeof channel.send === 'function') {
124
+ await sendChunked(channel, message);
125
+ return;
126
+ }
127
+ const user = await client.users.fetch(targetId).catch(() => null);
128
+ if (user) {
129
+ await sendChunked(user, message);
130
+ }
131
+ }
132
+ export async function sendDiscordProactiveVoice(target, buffer, format) {
133
+ if (!client || isDiscordSilenced())
134
+ return;
135
+ const targetId = String(target);
136
+ const attachment = new AttachmentBuilder(buffer, {
137
+ name: `voice.${format}`,
138
+ description: 'Voice message',
139
+ });
140
+ const channel = await client.channels.fetch(targetId).catch(() => null);
141
+ if (channel && 'send' in channel && typeof channel.send === 'function') {
142
+ await channel.send({ files: [attachment] });
143
+ return;
144
+ }
145
+ const user = await client.users.fetch(targetId).catch(() => null);
146
+ if (user) {
147
+ await user.send({ files: [attachment] });
148
+ }
149
+ }
@@ -0,0 +1,6 @@
1
+ export declare const BOT_COMMANDS: {
2
+ command: string;
3
+ description: string;
4
+ }[];
5
+ export declare const KNOWN_COMMANDS: Set<string>;
6
+ export declare const MAX_HISTORY_PAIRS = 5;
@@ -0,0 +1,17 @@
1
+ export const BOT_COMMANDS = [
2
+ { command: 'help', description: 'Show available commands' },
3
+ { command: 'model', description: 'Switch model (fast/smart/opus)' },
4
+ { command: 'status', description: 'Show bot status' },
5
+ { command: 'clear', description: 'Clear conversation history' },
6
+ { command: 'compact', description: 'Compress conversation history' },
7
+ { command: 'silence', description: 'Pause proactive messages' },
8
+ { command: 'cron', description: 'List or run scheduled jobs' },
9
+ { command: 'tasks', description: 'List active coding agents and cron jobs' },
10
+ { command: 'cancel', description: 'Cancel a coding agent (use dashboard) or cron job' },
11
+ { command: 'approvals', description: 'List pending exec approvals' },
12
+ { command: 'approve', description: 'Approve an exec request by ID' },
13
+ { command: 'deny', description: 'Deny an exec request by ID' },
14
+ { command: 'heartbeat', description: 'Trigger heartbeat check' },
15
+ ];
16
+ export const KNOWN_COMMANDS = new Set(BOT_COMMANDS.map(c => c.command));
17
+ export const MAX_HISTORY_PAIRS = 5;
@@ -0,0 +1,14 @@
1
+ import type { Message } from 'discord.js';
2
+ import type { AgentRunContext, ChatMessage, Config, ToolConfig } from '../../types.js';
3
+ export declare function getHistory(key: string): Promise<ChatMessage[]>;
4
+ export declare function addToHistory(key: string, userMsg: string, assistantMsg: string): Promise<void>;
5
+ export declare function clearHistory(key: string): Promise<void>;
6
+ /** Replace history with a compact summary (used by /compact). */
7
+ export declare function replaceHistory(key: string, summary: string): void;
8
+ export declare function getDiscordToolConfig(cfg: Config): ToolConfig;
9
+ export declare function conversationKey(message: Message): string;
10
+ export declare function getDiscordRunContext(message: Message): AgentRunContext;
11
+ export declare function buildHelpText(cfg: Config): string;
12
+ export declare function splitToChunks(text: string, maxLength: number): string[];
13
+ export declare function sendLongText(message: Message, text: string): Promise<void>;
14
+ export declare function startTypingIndicator(message: Message): () => void;
@@ -0,0 +1,161 @@
1
+ import { resolveAllowedPaths } from '../../config.js';
2
+ import * as sessions from '../../sessions.js';
3
+ import { BOT_COMMANDS, MAX_HISTORY_PAIRS } from './types.js';
4
+ // ── State ───────────────────────────────────────────────────────────
5
+ const chatHistory = new Map();
6
+ const loadedFromDisk = new Set();
7
+ // ── History ─────────────────────────────────────────────────────────
8
+ export async function getHistory(key) {
9
+ if (!loadedFromDisk.has(key)) {
10
+ loadedFromDisk.add(key);
11
+ const diskHistory = await sessions.loadHistory('discord', key).catch(() => []);
12
+ if (diskHistory.length > 0 && !chatHistory.has(key)) {
13
+ chatHistory.set(key, diskHistory);
14
+ }
15
+ }
16
+ return chatHistory.get(key) || [];
17
+ }
18
+ export async function addToHistory(key, userMsg, assistantMsg) {
19
+ const history = await getHistory(key);
20
+ history.push({ role: 'user', content: userMsg });
21
+ history.push({ role: 'assistant', content: assistantMsg });
22
+ while (history.length > MAX_HISTORY_PAIRS * 2) {
23
+ history.shift();
24
+ history.shift();
25
+ }
26
+ chatHistory.set(key, history);
27
+ sessions.saveExchange('discord', key, userMsg, assistantMsg).catch(() => { });
28
+ }
29
+ export async function clearHistory(key) {
30
+ chatHistory.delete(key);
31
+ loadedFromDisk.delete(key);
32
+ await sessions.clearHistory('discord', key).catch(() => { });
33
+ }
34
+ /** Replace history with a compact summary (used by /compact). */
35
+ export function replaceHistory(key, summary) {
36
+ chatHistory.set(key, [
37
+ { role: 'user', content: 'Summary of our previous conversation:' },
38
+ { role: 'assistant', content: summary },
39
+ ]);
40
+ loadedFromDisk.add(key);
41
+ }
42
+ // ── Tool config ─────────────────────────────────────────────────────
43
+ export function getDiscordToolConfig(cfg) {
44
+ const discord = cfg.channels.discord;
45
+ if (discord?.tools) {
46
+ return {
47
+ ...discord.tools,
48
+ allowedPaths: discord.tools.allowedPaths?.length
49
+ ? discord.tools.allowedPaths
50
+ : resolveAllowedPaths(cfg),
51
+ };
52
+ }
53
+ return {
54
+ enabled: true,
55
+ allowedPaths: resolveAllowedPaths(cfg),
56
+ maxIterations: 100,
57
+ bashTimeout: 15000,
58
+ };
59
+ }
60
+ // ── Helpers ─────────────────────────────────────────────────────────
61
+ export function conversationKey(message) {
62
+ if (message.channel.isDMBased()) {
63
+ return `dm:${message.author.id}`;
64
+ }
65
+ return `channel:${message.channelId}`;
66
+ }
67
+ export function getDiscordRunContext(message) {
68
+ return {
69
+ userId: message.author.id,
70
+ sessionId: message.channel.id,
71
+ channel: 'discord',
72
+ trigger: 'discord',
73
+ metadata: {
74
+ username: message.author.username,
75
+ },
76
+ };
77
+ }
78
+ export function buildHelpText(cfg) {
79
+ const agentConfig = cfg.agents.list[cfg.agents.default];
80
+ const emoji = agentConfig?.identity?.emoji || '🦞';
81
+ const name = agentConfig?.identity?.name || 'SkimpyClaw';
82
+ const commandList = BOT_COMMANDS.map(c => `/${c.command} - ${c.description}`).join('\n');
83
+ return `${emoji} ${name} online.\n\nSend a message to chat, or use a command:\n\n${commandList}`;
84
+ }
85
+ export function splitToChunks(text, maxLength) {
86
+ if (text.length <= maxLength)
87
+ return [text];
88
+ const chunks = [];
89
+ let current = '';
90
+ for (const paragraph of text.split('\n\n')) {
91
+ if (current.length + paragraph.length + 2 > maxLength) {
92
+ if (current)
93
+ chunks.push(current.trim());
94
+ if (paragraph.length > maxLength) {
95
+ const lines = paragraph.split('\n');
96
+ let lineBuf = '';
97
+ for (const line of lines) {
98
+ if (lineBuf.length + line.length + 1 > maxLength) {
99
+ if (lineBuf)
100
+ chunks.push(lineBuf.trim());
101
+ if (line.length > maxLength) {
102
+ for (let i = 0; i < line.length; i += maxLength) {
103
+ chunks.push(line.slice(i, i + maxLength));
104
+ }
105
+ lineBuf = '';
106
+ }
107
+ else {
108
+ lineBuf = line;
109
+ }
110
+ }
111
+ else {
112
+ lineBuf += (lineBuf ? '\n' : '') + line;
113
+ }
114
+ }
115
+ current = lineBuf;
116
+ }
117
+ else {
118
+ current = paragraph;
119
+ }
120
+ }
121
+ else {
122
+ current += (current ? '\n\n' : '') + paragraph;
123
+ }
124
+ }
125
+ if (current)
126
+ chunks.push(current.trim());
127
+ return chunks.filter(c => c.length > 0);
128
+ }
129
+ export async function sendLongText(message, text) {
130
+ const chunks = splitToChunks(text, 1900);
131
+ for (const chunk of chunks) {
132
+ await message.reply(chunk);
133
+ }
134
+ }
135
+ export function startTypingIndicator(message) {
136
+ const maxDurationMs = 90_000;
137
+ let stopped = false;
138
+ const stop = () => {
139
+ if (stopped)
140
+ return;
141
+ stopped = true;
142
+ clearInterval(interval);
143
+ clearTimeout(watchdog);
144
+ };
145
+ const channel = message.channel;
146
+ if (typeof channel.sendTyping === 'function') {
147
+ void channel.sendTyping().catch(() => { });
148
+ }
149
+ const interval = setInterval(() => {
150
+ if (stopped)
151
+ return;
152
+ if (typeof channel.sendTyping === 'function') {
153
+ void channel.sendTyping().catch(() => { });
154
+ }
155
+ }, 4000);
156
+ const watchdog = setTimeout(() => {
157
+ console.warn('[discord] Typing indicator watchdog reached; auto-stopping.');
158
+ stop();
159
+ }, maxDurationMs);
160
+ return stop;
161
+ }
@@ -12,7 +12,7 @@ export declare function getHistory(chatId: number): Promise<ChatMessage[]>;
12
12
  export declare function addToHistory(chatId: number, userMsg: string, assistantMsg: string): Promise<void>;
13
13
  export declare function clearHistory(chatId: number): Promise<void>;
14
14
  export declare function getRunContext(ctx: Context): AgentRunContext;
15
- export declare function getDefaultTelegramToolConfig(cfg: Config): ToolConfig | undefined;
15
+ export declare function getDefaultTelegramToolConfig(cfg: Config): ToolConfig;
16
16
  /** Get Telegram default chat ID from config */
17
17
  export declare function getTelegramDefaultChatId(cfg: Config): number | null;
18
18
  /** Send a long message by splitting it into chunks */
@@ -2,6 +2,7 @@
2
2
  import { existsSync, readdirSync, statSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { homedir } from 'os';
5
+ import { resolveAllowedPaths } from '../../config.js';
5
6
  import { state, MAX_HISTORY_PAIRS, BOT_COMMANDS } from './types.js';
6
7
  import * as sessions from '../../sessions.js';
7
8
  /** Keep sending "typing..." every 4s until the returned stop function is called. */
@@ -102,20 +103,17 @@ export function getRunContext(ctx) {
102
103
  // Default tool config for Telegram — gives the agent file/bash access
103
104
  export function getDefaultTelegramToolConfig(cfg) {
104
105
  if (cfg.channels.telegram.tools) {
105
- return cfg.channels.telegram.tools;
106
- }
107
- if (cfg.channels.telegram.defaultAllowedPaths?.length) {
108
106
  return {
109
- enabled: true,
110
- allowedPaths: cfg.channels.telegram.defaultAllowedPaths,
111
- maxIterations: 100,
112
- bashTimeout: 15000,
107
+ ...cfg.channels.telegram.tools,
108
+ allowedPaths: cfg.channels.telegram.tools.allowedPaths?.length
109
+ ? cfg.channels.telegram.tools.allowedPaths
110
+ : resolveAllowedPaths(cfg),
113
111
  };
114
112
  }
115
113
  return {
116
114
  enabled: true,
117
- allowedPaths: [join(homedir(), '.skimpyclaw')],
118
- maxIterations: 30,
115
+ allowedPaths: resolveAllowedPaths(cfg),
116
+ maxIterations: 100,
119
117
  bashTimeout: 15000,
120
118
  };
121
119
  }
package/dist/channels.js CHANGED
@@ -38,7 +38,7 @@ async function loadAdapter(channel) {
38
38
  resolveDefaultTarget: telegram.getTelegramDefaultChatId,
39
39
  };
40
40
  }
41
- const discord = await import('./discord.js');
41
+ const discord = await import('./channels/discord/index.js');
42
42
  return {
43
43
  init: discord.initDiscord,
44
44
  start: discord.startDiscord,
package/dist/cli.js CHANGED
@@ -4,12 +4,13 @@ import { join } from 'path';
4
4
  import { homedir } from 'os';
5
5
  import { spawn, spawnSync } from 'child_process';
6
6
  import { fileURLToPath } from 'url';
7
- import { loadConfig, loadRawConfig, getConfigPath, saveConfig } from './config.js';
7
+ import { loadConfig, loadRawConfig, getConfigPath, saveConfig, resolveAllowedPaths } from './config.js';
8
8
  import { startRuntime } from './service.js';
9
9
  import { runSetup, renderGatewayPlist } from './setup.js';
10
10
  import { runDoctor as runDoctorCommand } from './doctor/index.js';
11
11
  import { executeTool, getToolDefinitions, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION } from './tools.js';
12
12
  import { formatModelSelectionError, getModelSelectionUsage, resolveModelSelection } from './model-selection.js';
13
+ import { detectSandboxRuntime, isSandboxRuntimeRunning, sandboxNetworkExists, defaultSandboxNetwork, sandboxImageExists, } from './sandbox-utils.js';
13
14
  const APP_NAME = 'skimpyclaw';
14
15
  const DEFAULT_PORT = 18790;
15
16
  const LAUNCHD_LABEL = 'com.skimpyclaw.gateway';
@@ -102,19 +103,17 @@ function hasFlag(args, flag) {
102
103
  return args.includes(flag);
103
104
  }
104
105
  function getCliToolConfig(config) {
105
- if (config.channels.telegram.tools)
106
- return config.channels.telegram.tools;
107
- if (config.channels.telegram.defaultAllowedPaths?.length) {
106
+ if (config.channels.telegram.tools) {
108
107
  return {
109
- enabled: true,
110
- allowedPaths: config.channels.telegram.defaultAllowedPaths,
111
- maxIterations: 100,
112
- bashTimeout: 15000,
108
+ ...config.channels.telegram.tools,
109
+ allowedPaths: config.channels.telegram.tools.allowedPaths?.length
110
+ ? config.channels.telegram.tools.allowedPaths
111
+ : resolveAllowedPaths(config),
113
112
  };
114
113
  }
115
114
  return {
116
115
  enabled: true,
117
- allowedPaths: [join(homedir(), '.skimpyclaw')],
116
+ allowedPaths: resolveAllowedPaths(config),
118
117
  maxIterations: 100,
119
118
  bashTimeout: 15000,
120
119
  };
@@ -771,40 +770,6 @@ const SANDBOX_CLI_BY_PROFILE = {
771
770
  dev: ['bash', 'curl', 'git', 'gh', 'jq', 'python3', 'rg', 'pnpm', 'gcc', 'g++', 'make'],
772
771
  full: ['bash', 'curl', 'git', 'gh', 'jq', 'python3', 'rg', 'pnpm', 'gcc', 'g++', 'make', 'pip3', 'sqlite3'],
773
772
  };
774
- function defaultSandboxNetwork(runtime) {
775
- return runtime === 'container' ? 'default' : 'bridge';
776
- }
777
- function detectSandboxRuntime(preferred) {
778
- if (preferred === 'container' || preferred === 'docker') {
779
- return spawnSync(preferred, ['--version'], { encoding: 'utf-8' }).status === 0 ? preferred : null;
780
- }
781
- if (spawnSync('container', ['--version'], { encoding: 'utf-8' }).status === 0) {
782
- return 'container';
783
- }
784
- if (spawnSync('docker', ['--version'], { encoding: 'utf-8' }).status === 0) {
785
- return 'docker';
786
- }
787
- return null;
788
- }
789
- function isSandboxRuntimeRunning(runtime) {
790
- if (runtime === 'container') {
791
- return spawnSync('container', ['system', 'status'], { encoding: 'utf-8' }).status === 0;
792
- }
793
- return spawnSync('docker', ['info'], { encoding: 'utf-8' }).status === 0;
794
- }
795
- function sandboxNetworkExists(runtime, network) {
796
- if (runtime === 'container') {
797
- const result = spawnSync('container', ['network', 'ls'], { encoding: 'utf-8' });
798
- if (result.status !== 0)
799
- return false;
800
- return result.stdout.split('\n').some((line) => line.trim().split(/\s+/)[0] === network);
801
- }
802
- const result = spawnSync('docker', ['network', 'inspect', network], { encoding: 'utf-8' });
803
- return result.status === 0;
804
- }
805
- function sandboxImageExists(runtime, image) {
806
- return spawnSync(runtime, ['image', 'inspect', image], { encoding: 'utf-8' }).status === 0;
807
- }
808
773
  function resolveSandboxDir() {
809
774
  // 1. Check CWD (user is in repo root)
810
775
  const cwdSandbox = join(process.cwd(), 'sandbox');
@@ -184,9 +184,14 @@ export function parseCodexOutput(stdout) {
184
184
  for (const line of lines) {
185
185
  try {
186
186
  const obj = JSON.parse(line);
187
+ // Standard output_text events
187
188
  if (obj.type === 'output_text' || obj.output_text) {
188
189
  outputs.push(obj.output_text || obj.text || '');
189
190
  }
191
+ // Codex stream-json: item.completed with agent_message
192
+ else if (obj.type === 'item.completed' && obj.item?.type === 'agent_message' && obj.item?.text) {
193
+ outputs.push(obj.item.text);
194
+ }
190
195
  }
191
196
  catch {
192
197
  if (line.trim())
package/dist/config.d.ts CHANGED
@@ -17,4 +17,11 @@ export declare function listMemoryFiles(agentId: string): {
17
17
  date: string;
18
18
  size: number;
19
19
  }[];
20
+ /**
21
+ * Resolve allowed paths for a given context. Priority:
22
+ * 1. Explicit toolConfig.allowedPaths (if provided)
23
+ * 2. Config top-level allowedPaths
24
+ * 3. Fallback: ~/.skimpyclaw only
25
+ */
26
+ export declare function resolveAllowedPaths(config: Config, overridePaths?: string[]): string[];
20
27
  export declare function readMemoryFile(agentId: string, filename: string): string;
package/dist/config.js CHANGED
@@ -109,6 +109,19 @@ export function listMemoryFiles(agentId) {
109
109
  };
110
110
  }).sort((a, b) => b.date.localeCompare(a.date));
111
111
  }
112
+ /**
113
+ * Resolve allowed paths for a given context. Priority:
114
+ * 1. Explicit toolConfig.allowedPaths (if provided)
115
+ * 2. Config top-level allowedPaths
116
+ * 3. Fallback: ~/.skimpyclaw only
117
+ */
118
+ export function resolveAllowedPaths(config, overridePaths) {
119
+ if (overridePaths?.length)
120
+ return overridePaths;
121
+ if (config.allowedPaths?.length)
122
+ return config.allowedPaths;
123
+ return [join(homedir(), '.skimpyclaw')];
124
+ }
112
125
  export function readMemoryFile(agentId, filename) {
113
126
  if (!isValidAgentId(agentId)) {
114
127
  throw new Error('Invalid agent ID');
package/dist/cron.js CHANGED
@@ -3,7 +3,7 @@ import { Cron } from 'croner';
3
3
  import { exec } from 'child_process';
4
4
  import { existsSync, mkdirSync, appendFileSync, readFileSync, watch } from 'fs';
5
5
  import { join } from 'path';
6
- import { getLogsDir, getConfigPath, loadConfig } from './config.js';
6
+ import { getLogsDir, getConfigPath, loadConfig, resolveAllowedPaths } from './config.js';
7
7
  import { homedir } from 'node:os';
8
8
  import { runAgentTurn } from './agent.js';
9
9
  import { startTrace, addEvent, endTrace } from './audit.js';
@@ -152,11 +152,14 @@ async function executeJobPayload(jobDef, config) {
152
152
  appendCronLogLine(jobDef.id, `Agent turn started (prompt: ${message.slice(0, 100)}...)`);
153
153
  const defaultTools = {
154
154
  enabled: true,
155
- allowedPaths: [`${homedir()}/.skimpyclaw`],
155
+ allowedPaths: resolveAllowedPaths(config),
156
156
  maxIterations: 30,
157
157
  bashTimeout: 15000,
158
158
  };
159
- const response = await runAgentTurn(config.agents.default, message, config, jobDef.model, jobDef.payload.tools || defaultTools, undefined, {
159
+ const tools = jobDef.payload.tools
160
+ ? { ...jobDef.payload.tools, allowedPaths: jobDef.payload.tools.allowedPaths?.length ? jobDef.payload.tools.allowedPaths : resolveAllowedPaths(config) }
161
+ : defaultTools;
162
+ const response = await runAgentTurn(config.agents.default, message, config, jobDef.model, tools, undefined, {
160
163
  channel: getActiveChannelId() || 'telegram',
161
164
  trigger: 'cron',
162
165
  sessionId: jobDef.id,
package/dist/heartbeat.js CHANGED
@@ -6,30 +6,26 @@
6
6
  import { join } from 'path';
7
7
  import { homedir } from 'os';
8
8
  import { runAgentTurn } from './agent.js';
9
+ import { resolveAllowedPaths } from './config.js';
9
10
  import { pruneIdle, SANDBOX_DEFAULTS } from './sandbox/index.js';
10
11
  import { getActiveChannelId, isActiveChannelSilenced, sendActiveChannelProactiveMessage, } from './channels.js';
11
12
  let heartbeatTimer = null;
12
13
  let running = false;
13
- const DEFAULT_HEARTBEAT_TOOLS = {
14
- enabled: true,
15
- allowedPaths: [join(homedir(), '.skimpyclaw')],
16
- maxIterations: 100,
17
- bashTimeout: 15000,
18
- };
19
14
  function getHeartbeatTools(config) {
20
15
  if (config.heartbeat.tools) {
21
- return config.heartbeat.tools;
22
- }
23
- const defaultAllowedPaths = config.channels.active === 'discord'
24
- ? config.channels.discord?.defaultAllowedPaths
25
- : config.channels.telegram.defaultAllowedPaths || config.channels.discord?.defaultAllowedPaths;
26
- if (defaultAllowedPaths?.length) {
27
16
  return {
28
- ...DEFAULT_HEARTBEAT_TOOLS,
29
- allowedPaths: defaultAllowedPaths,
17
+ ...config.heartbeat.tools,
18
+ allowedPaths: config.heartbeat.tools.allowedPaths?.length
19
+ ? config.heartbeat.tools.allowedPaths
20
+ : resolveAllowedPaths(config),
30
21
  };
31
22
  }
32
- return DEFAULT_HEARTBEAT_TOOLS;
23
+ return {
24
+ enabled: true,
25
+ allowedPaths: resolveAllowedPaths(config),
26
+ maxIterations: 100,
27
+ bashTimeout: 15000,
28
+ };
33
29
  }
34
30
  function getHeartbeatFilePath(config) {
35
31
  return join(homedir(), '.skimpyclaw', 'agents', config.agents.default, 'HEARTBEAT.md');
@@ -152,7 +152,13 @@ export async function chatWithToolsAnthropic(params) {
152
152
  };
153
153
  }
154
154
  // Compact old tool results if context is growing large
155
- const messagesForApi = compactAnthropicMessages(apiMessages, toolConfig.contextManagement, i + 1);
155
+ const compactionResult = await compactAnthropicMessages(apiMessages, toolConfig.contextManagement, i + 1, config);
156
+ const messagesForApi = compactionResult.messages;
157
+ if (compactionResult.compacted) {
158
+ const method = compactionResult.method === 'llm' ? 'LLM summary' : 'truncation';
159
+ const detail = `~${Math.round((compactionResult.tokensBefore || 0) / 1000)}k → ~${Math.round((compactionResult.tokensAfter || 0) / 1000)}k tokens`;
160
+ toolLog.push(`[context compacted via ${method}: ${detail}]`);
161
+ }
156
162
  const anthropicParams = {
157
163
  model: modelId,
158
164
  max_tokens: options.maxTokens || 16384,
@@ -265,7 +265,7 @@ export async function chatCodex(params) {
265
265
  }
266
266
  }
267
267
  export async function chatWithToolsCodex(params) {
268
- const { messages, options, toolConfig, toolContext } = params;
268
+ const { messages, options, config, toolConfig, toolContext } = params;
269
269
  const modelId = stripProvider(options.model);
270
270
  const maxIterations = toolConfig.maxIterations || 100;
271
271
  // Build input — system messages go to `instructions`, rest to `input`
@@ -308,7 +308,13 @@ export async function chatWithToolsCodex(params) {
308
308
  };
309
309
  }
310
310
  // Compact old tool results if context is growing large
311
- const inputForApi = compactCodexMessages(input, toolConfig.contextManagement, i + 1);
311
+ const compactionResult = await compactCodexMessages(input, toolConfig.contextManagement, i + 1, config);
312
+ const inputForApi = compactionResult.messages;
313
+ if (compactionResult.compacted) {
314
+ const method = compactionResult.method === 'llm' ? 'LLM summary' : 'truncation';
315
+ const detail = `~${Math.round((compactionResult.tokensBefore || 0) / 1000)}k → ~${Math.round((compactionResult.tokensAfter || 0) / 1000)}k tokens`;
316
+ toolLog.push(`[context compacted via ${method}: ${detail}]`);
317
+ }
312
318
  const body = {
313
319
  model: modelId,
314
320
  instructions,