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.
- package/dist/__tests__/channels.test.js +1 -1
- package/dist/__tests__/context-manager.test.js +219 -76
- package/dist/__tests__/providers-utils.test.js +2 -0
- package/dist/__tests__/sandbox-manager.test.js +25 -0
- package/dist/__tests__/sandbox-mount-security.test.js +8 -0
- package/dist/__tests__/setup.test.js +1 -1
- package/dist/__tests__/skills.test.js +53 -26
- package/dist/__tests__/token-efficiency.test.js +37 -15
- package/dist/__tests__/tools.test.js +11 -9
- package/dist/agent.js +2 -2
- package/dist/api.js +5 -0
- package/dist/channels/discord/handlers.d.ts +7 -0
- package/dist/channels/discord/handlers.js +479 -0
- package/dist/channels/discord/index.d.ts +8 -0
- package/dist/channels/discord/index.js +149 -0
- package/dist/channels/discord/types.d.ts +6 -0
- package/dist/channels/discord/types.js +17 -0
- package/dist/channels/discord/utils.d.ts +14 -0
- package/dist/channels/discord/utils.js +161 -0
- package/dist/channels/telegram/utils.d.ts +1 -1
- package/dist/channels/telegram/utils.js +7 -9
- package/dist/channels.js +1 -1
- package/dist/cli.js +8 -43
- package/dist/code-agents/parser.js +5 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +13 -0
- package/dist/cron.js +6 -3
- package/dist/heartbeat.js +11 -15
- package/dist/providers/anthropic.js +7 -1
- package/dist/providers/codex.js +8 -2
- package/dist/providers/context-manager.d.ts +37 -6
- package/dist/providers/context-manager.js +303 -47
- package/dist/providers/openai.js +8 -2
- package/dist/providers/utils.d.ts +6 -2
- package/dist/providers/utils.js +36 -4
- package/dist/sandbox/manager.js +11 -0
- package/dist/sandbox/mount-security.js +5 -1
- package/dist/sandbox/runtime.d.ts +1 -0
- package/dist/sandbox/runtime.js +5 -0
- package/dist/sandbox-utils.d.ts +6 -0
- package/dist/sandbox-utils.js +36 -0
- package/dist/security.js +4 -3
- package/dist/service.js +25 -0
- package/dist/setup-templates.d.ts +14 -0
- package/dist/setup-templates.js +214 -0
- package/dist/setup.d.ts +1 -9
- package/dist/setup.js +3 -244
- package/dist/skills-types.d.ts +6 -0
- package/dist/skills.d.ts +5 -1
- package/dist/skills.js +25 -2
- package/dist/tools/bash-tool.js +11 -1
- package/dist/tools/definitions.d.ts +57 -0
- package/dist/tools/definitions.js +19 -1
- package/dist/tools/fetch-tool.d.ts +8 -0
- package/dist/tools/fetch-tool.js +80 -0
- package/dist/tools.d.ts +4 -2
- package/dist/tools.js +110 -62
- package/dist/types.d.ts +5 -0
- 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,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
|
|
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
|
-
|
|
110
|
-
allowedPaths: cfg.channels.telegram.
|
|
111
|
-
|
|
112
|
-
|
|
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:
|
|
118
|
-
maxIterations:
|
|
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
|
-
|
|
110
|
-
allowedPaths: config.channels.telegram.
|
|
111
|
-
|
|
112
|
-
|
|
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:
|
|
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:
|
|
155
|
+
allowedPaths: resolveAllowedPaths(config),
|
|
156
156
|
maxIterations: 30,
|
|
157
157
|
bashTimeout: 15000,
|
|
158
158
|
};
|
|
159
|
-
const
|
|
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
|
-
...
|
|
29
|
-
allowedPaths:
|
|
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
|
|
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
|
|
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,
|
package/dist/providers/codex.js
CHANGED
|
@@ -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
|
|
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,
|