skimpyclaw 0.3.10 → 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__/tools.test.js +11 -9
- package/dist/agent.js +1 -1
- 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.js +1 -1
- 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/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/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 +3 -4
|
@@ -18,7 +18,7 @@ const discordMock = vi.hoisted(() => ({
|
|
|
18
18
|
getDiscordDefaultTarget: vi.fn(() => '999'),
|
|
19
19
|
}));
|
|
20
20
|
vi.mock('../channels/telegram/index.js', () => telegramMock);
|
|
21
|
-
vi.mock('../discord.js', () => discordMock);
|
|
21
|
+
vi.mock('../channels/discord/index.js', () => discordMock);
|
|
22
22
|
import { getActiveChannelId, initActiveChannel, sendActiveChannelProactiveMessage, } from '../channels.js';
|
|
23
23
|
function makeConfig(overrides = {}) {
|
|
24
24
|
return {
|
|
@@ -1,5 +1,22 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { estimateTokens, compactAnthropicMessages, compactOpenAIMessages, compactCodexMessages, } from '../providers/context-manager.js';
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { estimateTokens, compactAnthropicMessages, compactOpenAIMessages, compactCodexMessages, serializeAnthropicMessages, serializeOpenAIMessages, serializeCodexMessages, } from '../providers/context-manager.js';
|
|
3
|
+
// Mock the chat function used for LLM summarization
|
|
4
|
+
vi.mock('../providers/index.js', () => ({
|
|
5
|
+
chat: vi.fn().mockResolvedValue('Summary of the conversation: the user asked to list files and the assistant ran ls.'),
|
|
6
|
+
}));
|
|
7
|
+
import { chat } from '../providers/index.js';
|
|
8
|
+
const mockChat = vi.mocked(chat);
|
|
9
|
+
// Minimal config for LLM compaction
|
|
10
|
+
const fullConfig = {
|
|
11
|
+
models: {
|
|
12
|
+
providers: { anthropic: { apiKey: 'test' } },
|
|
13
|
+
aliases: {},
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
mockChat.mockClear();
|
|
18
|
+
mockChat.mockResolvedValue('Summary of the conversation: the user asked to list files and the assistant ran ls.');
|
|
19
|
+
});
|
|
3
20
|
// Helper: build an Anthropic-style tool exchange (assistant + user pair)
|
|
4
21
|
function anthropicExchange(toolResult) {
|
|
5
22
|
return [
|
|
@@ -23,6 +40,17 @@ function codexExchange(output) {
|
|
|
23
40
|
{ type: 'function_call_output', call_id: 'fc_1', output },
|
|
24
41
|
];
|
|
25
42
|
}
|
|
43
|
+
// Helper: build an OpenAI-style tool exchange (assistant + tool result)
|
|
44
|
+
function openaiExchange(toolResult) {
|
|
45
|
+
return [
|
|
46
|
+
{
|
|
47
|
+
role: 'assistant',
|
|
48
|
+
content: null,
|
|
49
|
+
tool_calls: [{ id: 'tc_1', type: 'function', function: { name: 'Bash', arguments: '{}' } }],
|
|
50
|
+
},
|
|
51
|
+
{ role: 'tool', tool_call_id: 'tc_1', content: toolResult },
|
|
52
|
+
];
|
|
53
|
+
}
|
|
26
54
|
describe('estimateTokens', () => {
|
|
27
55
|
it('returns a positive number for non-empty data', () => {
|
|
28
56
|
expect(estimateTokens([{ role: 'user', content: 'hello' }])).toBeGreaterThan(0);
|
|
@@ -37,200 +65,315 @@ describe('estimateTokens', () => {
|
|
|
37
65
|
});
|
|
38
66
|
});
|
|
39
67
|
describe('compactAnthropicMessages', () => {
|
|
40
|
-
it('passes through unchanged when under threshold', () => {
|
|
68
|
+
it('passes through unchanged when under threshold', async () => {
|
|
41
69
|
const messages = anthropicExchange('short result');
|
|
42
|
-
const result = compactAnthropicMessages(messages, { maxContextTokens: 100_000 });
|
|
43
|
-
expect(result).toEqual(messages);
|
|
70
|
+
const result = await compactAnthropicMessages(messages, { maxContextTokens: 100_000 });
|
|
71
|
+
expect(result.messages).toEqual(messages);
|
|
72
|
+
expect(result.compacted).toBe(false);
|
|
44
73
|
});
|
|
45
|
-
it('returns same reference when no compaction needed', () => {
|
|
74
|
+
it('returns same reference when no compaction needed', async () => {
|
|
46
75
|
const messages = anthropicExchange('short result');
|
|
47
|
-
const result = compactAnthropicMessages(messages, { maxContextTokens: 100_000 });
|
|
48
|
-
expect(result).toBe(messages);
|
|
76
|
+
const result = await compactAnthropicMessages(messages, { maxContextTokens: 100_000 });
|
|
77
|
+
expect(result.messages).toBe(messages);
|
|
78
|
+
});
|
|
79
|
+
it('uses LLM summarization when fullConfig is provided', async () => {
|
|
80
|
+
const longResult = 'x'.repeat(10_000);
|
|
81
|
+
const messages = [];
|
|
82
|
+
for (let i = 0; i < 30; i++) {
|
|
83
|
+
messages.push(...anthropicExchange(longResult));
|
|
84
|
+
}
|
|
85
|
+
const result = await compactAnthropicMessages(messages, { maxContextTokens: 1_000 }, 1, fullConfig);
|
|
86
|
+
expect(result.compacted).toBe(true);
|
|
87
|
+
expect(result.method).toBe('llm');
|
|
88
|
+
expect(result.summary).toBeTruthy();
|
|
89
|
+
expect(result.tokensBefore).toBeGreaterThan(0);
|
|
90
|
+
expect(result.tokensAfter).toBeGreaterThan(0);
|
|
91
|
+
expect(result.tokensAfter).toBeLessThan(result.tokensBefore);
|
|
92
|
+
expect(mockChat).toHaveBeenCalledOnce();
|
|
93
|
+
// First message should be the summary
|
|
94
|
+
expect(result.messages[0].role).toBe('user');
|
|
95
|
+
expect(result.messages[0].content[0].text).toContain('[Conversation Summary]');
|
|
96
|
+
// Last 8 should be preserved
|
|
97
|
+
expect(result.messages.slice(-8)).toEqual(messages.slice(-8));
|
|
49
98
|
});
|
|
50
|
-
it('
|
|
99
|
+
it('falls back to truncation when LLM fails', async () => {
|
|
100
|
+
mockChat.mockRejectedValueOnce(new Error('API error'));
|
|
51
101
|
const longResult = 'x'.repeat(10_000);
|
|
52
|
-
// Build many exchanges to exceed threshold
|
|
53
102
|
const messages = [];
|
|
54
103
|
for (let i = 0; i < 30; i++) {
|
|
55
104
|
messages.push(...anthropicExchange(longResult));
|
|
56
105
|
}
|
|
57
|
-
const result = compactAnthropicMessages(messages, { maxContextTokens: 1_000 });
|
|
106
|
+
const result = await compactAnthropicMessages(messages, { maxContextTokens: 1_000 }, 1, fullConfig);
|
|
107
|
+
expect(result.compacted).toBe(true);
|
|
108
|
+
expect(result.method).toBe('truncation');
|
|
58
109
|
// Head messages should have truncated tool results
|
|
59
|
-
const headMessages = result.slice(0, -8);
|
|
60
|
-
const toolResultMessages = headMessages.filter(m => Array.isArray(m.content) && m.content.some((b) => b.type === 'tool_result'));
|
|
110
|
+
const headMessages = result.messages.slice(0, -8);
|
|
111
|
+
const toolResultMessages = headMessages.filter((m) => Array.isArray(m.content) && m.content.some((b) => b.type === 'tool_result'));
|
|
61
112
|
for (const msg of toolResultMessages) {
|
|
62
113
|
const block = msg.content.find((b) => b.type === 'tool_result');
|
|
63
114
|
expect(block.content).toContain('[truncated]');
|
|
64
115
|
expect(block.content.length).toBeLessThan(longResult.length);
|
|
65
116
|
}
|
|
66
117
|
});
|
|
67
|
-
it('
|
|
118
|
+
it('falls back to truncation without fullConfig', async () => {
|
|
119
|
+
const longResult = 'x'.repeat(10_000);
|
|
120
|
+
const messages = [];
|
|
121
|
+
for (let i = 0; i < 30; i++) {
|
|
122
|
+
messages.push(...anthropicExchange(longResult));
|
|
123
|
+
}
|
|
124
|
+
const result = await compactAnthropicMessages(messages, { maxContextTokens: 1_000 });
|
|
125
|
+
expect(result.compacted).toBe(true);
|
|
126
|
+
expect(result.method).toBe('truncation');
|
|
127
|
+
expect(mockChat).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
it('keeps last 8 messages intact when compacting', async () => {
|
|
68
130
|
const longResult = 'x'.repeat(10_000);
|
|
69
131
|
const messages = [];
|
|
70
132
|
for (let i = 0; i < 30; i++) {
|
|
71
133
|
messages.push(...anthropicExchange(longResult));
|
|
72
134
|
}
|
|
73
|
-
const result = compactAnthropicMessages(messages, { maxContextTokens: 1_000 });
|
|
135
|
+
const result = await compactAnthropicMessages(messages, { maxContextTokens: 1_000 }, 1, fullConfig);
|
|
74
136
|
// Last 8 messages should be untouched
|
|
75
|
-
const tail = result.slice(-8);
|
|
137
|
+
const tail = result.messages.slice(-8);
|
|
76
138
|
const originalTail = messages.slice(-8);
|
|
77
139
|
expect(tail).toEqual(originalTail);
|
|
78
140
|
});
|
|
79
|
-
it('does not mutate the input array', () => {
|
|
141
|
+
it('does not mutate the input array', async () => {
|
|
80
142
|
const longResult = 'x'.repeat(10_000);
|
|
81
143
|
const messages = [];
|
|
82
144
|
for (let i = 0; i < 30; i++) {
|
|
83
145
|
messages.push(...anthropicExchange(longResult));
|
|
84
146
|
}
|
|
85
147
|
const originalJson = JSON.stringify(messages);
|
|
86
|
-
compactAnthropicMessages(messages, { maxContextTokens: 1_000 });
|
|
148
|
+
await compactAnthropicMessages(messages, { maxContextTokens: 1_000 }, 1, fullConfig);
|
|
87
149
|
expect(JSON.stringify(messages)).toBe(originalJson);
|
|
88
150
|
});
|
|
89
|
-
it('
|
|
151
|
+
it('passes through unchanged when disabled', async () => {
|
|
90
152
|
const longResult = 'x'.repeat(10_000);
|
|
91
153
|
const messages = [];
|
|
92
154
|
for (let i = 0; i < 30; i++) {
|
|
93
155
|
messages.push(...anthropicExchange(longResult));
|
|
94
156
|
}
|
|
95
|
-
const result = compactAnthropicMessages(messages, { maxContextTokens:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
for (const msg of assistantMessages) {
|
|
99
|
-
const toolUse = msg.content.find((b) => b.type === 'tool_use');
|
|
100
|
-
expect(toolUse).toBeDefined();
|
|
101
|
-
expect(toolUse.name).toBe('Bash');
|
|
102
|
-
}
|
|
157
|
+
const result = await compactAnthropicMessages(messages, { enabled: false, maxContextTokens: 1 });
|
|
158
|
+
expect(result.messages).toBe(messages);
|
|
159
|
+
expect(result.compacted).toBe(false);
|
|
103
160
|
});
|
|
104
|
-
it('
|
|
161
|
+
it('includes token counts in result', async () => {
|
|
105
162
|
const longResult = 'x'.repeat(10_000);
|
|
106
163
|
const messages = [];
|
|
107
164
|
for (let i = 0; i < 30; i++) {
|
|
108
165
|
messages.push(...anthropicExchange(longResult));
|
|
109
166
|
}
|
|
110
|
-
const result = compactAnthropicMessages(messages, {
|
|
111
|
-
expect(result).
|
|
167
|
+
const result = await compactAnthropicMessages(messages, { maxContextTokens: 1_000 }, 1, fullConfig);
|
|
168
|
+
expect(result.tokensBefore).toBeGreaterThan(1_000);
|
|
169
|
+
expect(result.tokensAfter).toBeDefined();
|
|
112
170
|
});
|
|
113
171
|
});
|
|
114
|
-
// Helper: build an OpenAI-style tool exchange (assistant + tool result)
|
|
115
|
-
function openaiExchange(toolResult) {
|
|
116
|
-
return [
|
|
117
|
-
{
|
|
118
|
-
role: 'assistant',
|
|
119
|
-
content: null,
|
|
120
|
-
tool_calls: [{ id: 'tc_1', type: 'function', function: { name: 'Bash', arguments: '{}' } }],
|
|
121
|
-
},
|
|
122
|
-
{ role: 'tool', tool_call_id: 'tc_1', content: toolResult },
|
|
123
|
-
];
|
|
124
|
-
}
|
|
125
172
|
describe('compactOpenAIMessages', () => {
|
|
126
|
-
it('passes through unchanged when under threshold', () => {
|
|
173
|
+
it('passes through unchanged when under threshold', async () => {
|
|
127
174
|
const messages = openaiExchange('short result');
|
|
128
|
-
const result = compactOpenAIMessages(messages, { maxContextTokens: 100_000 });
|
|
129
|
-
expect(result).toBe(messages);
|
|
175
|
+
const result = await compactOpenAIMessages(messages, { maxContextTokens: 100_000 });
|
|
176
|
+
expect(result.messages).toBe(messages);
|
|
177
|
+
expect(result.compacted).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
it('uses LLM summarization when fullConfig is provided', async () => {
|
|
180
|
+
const longResult = 'x'.repeat(10_000);
|
|
181
|
+
const messages = [];
|
|
182
|
+
for (let i = 0; i < 30; i++) {
|
|
183
|
+
messages.push(...openaiExchange(longResult));
|
|
184
|
+
}
|
|
185
|
+
const result = await compactOpenAIMessages(messages, { maxContextTokens: 1_000 }, 1, fullConfig);
|
|
186
|
+
expect(result.compacted).toBe(true);
|
|
187
|
+
expect(result.method).toBe('llm');
|
|
188
|
+
expect(result.messages[0].role).toBe('user');
|
|
189
|
+
expect(result.messages[0].content).toContain('[Conversation Summary]');
|
|
130
190
|
});
|
|
131
|
-
it('
|
|
191
|
+
it('falls back to truncation when LLM fails', async () => {
|
|
192
|
+
mockChat.mockRejectedValueOnce(new Error('API error'));
|
|
132
193
|
const longResult = 'x'.repeat(10_000);
|
|
133
194
|
const messages = [];
|
|
134
195
|
for (let i = 0; i < 30; i++) {
|
|
135
196
|
messages.push(...openaiExchange(longResult));
|
|
136
197
|
}
|
|
137
|
-
const result = compactOpenAIMessages(messages, { maxContextTokens: 1_000 });
|
|
138
|
-
|
|
198
|
+
const result = await compactOpenAIMessages(messages, { maxContextTokens: 1_000 }, 1, fullConfig);
|
|
199
|
+
expect(result.method).toBe('truncation');
|
|
200
|
+
const headItems = result.messages.slice(0, -8);
|
|
139
201
|
const toolMessages = headItems.filter((m) => m.role === 'tool');
|
|
140
202
|
for (const msg of toolMessages) {
|
|
141
203
|
expect(msg.content).toContain('[truncated]');
|
|
142
|
-
expect(msg.content.length).toBeLessThan(longResult.length);
|
|
143
204
|
}
|
|
144
205
|
});
|
|
145
|
-
it('keeps last 8 messages intact', () => {
|
|
206
|
+
it('keeps last 8 messages intact', async () => {
|
|
146
207
|
const longResult = 'x'.repeat(10_000);
|
|
147
208
|
const messages = [];
|
|
148
209
|
for (let i = 0; i < 30; i++) {
|
|
149
210
|
messages.push(...openaiExchange(longResult));
|
|
150
211
|
}
|
|
151
|
-
const result = compactOpenAIMessages(messages, { maxContextTokens: 1_000 });
|
|
152
|
-
expect(result.slice(-8)).toEqual(messages.slice(-8));
|
|
212
|
+
const result = await compactOpenAIMessages(messages, { maxContextTokens: 1_000 }, 1, fullConfig);
|
|
213
|
+
expect(result.messages.slice(-8)).toEqual(messages.slice(-8));
|
|
153
214
|
});
|
|
154
|
-
it('does not mutate the input array', () => {
|
|
215
|
+
it('does not mutate the input array', async () => {
|
|
155
216
|
const longResult = 'x'.repeat(10_000);
|
|
156
217
|
const messages = [];
|
|
157
218
|
for (let i = 0; i < 30; i++) {
|
|
158
219
|
messages.push(...openaiExchange(longResult));
|
|
159
220
|
}
|
|
160
221
|
const original = JSON.stringify(messages);
|
|
161
|
-
compactOpenAIMessages(messages, { maxContextTokens: 1_000 });
|
|
222
|
+
await compactOpenAIMessages(messages, { maxContextTokens: 1_000 }, 1, fullConfig);
|
|
162
223
|
expect(JSON.stringify(messages)).toBe(original);
|
|
163
224
|
});
|
|
164
|
-
it('passes through unchanged when disabled', () => {
|
|
225
|
+
it('passes through unchanged when disabled', async () => {
|
|
165
226
|
const longResult = 'x'.repeat(10_000);
|
|
166
227
|
const messages = [];
|
|
167
228
|
for (let i = 0; i < 30; i++) {
|
|
168
229
|
messages.push(...openaiExchange(longResult));
|
|
169
230
|
}
|
|
170
|
-
const result = compactOpenAIMessages(messages, { enabled: false, maxContextTokens: 1 });
|
|
171
|
-
expect(result).toBe(messages);
|
|
231
|
+
const result = await compactOpenAIMessages(messages, { enabled: false, maxContextTokens: 1 });
|
|
232
|
+
expect(result.messages).toBe(messages);
|
|
233
|
+
expect(result.compacted).toBe(false);
|
|
172
234
|
});
|
|
173
235
|
});
|
|
174
236
|
describe('compactCodexMessages', () => {
|
|
175
|
-
it('passes through unchanged when under threshold', () => {
|
|
237
|
+
it('passes through unchanged when under threshold', async () => {
|
|
176
238
|
const items = codexExchange('short result');
|
|
177
|
-
const result = compactCodexMessages(items, { maxContextTokens: 100_000 });
|
|
178
|
-
expect(result).toEqual(items);
|
|
239
|
+
const result = await compactCodexMessages(items, { maxContextTokens: 100_000 });
|
|
240
|
+
expect(result.messages).toEqual(items);
|
|
241
|
+
expect(result.compacted).toBe(false);
|
|
179
242
|
});
|
|
180
|
-
it('
|
|
243
|
+
it('uses LLM summarization when fullConfig is provided', async () => {
|
|
181
244
|
const longOutput = 'x'.repeat(10_000);
|
|
182
245
|
const items = [];
|
|
183
246
|
for (let i = 0; i < 30; i++) {
|
|
184
247
|
items.push(...codexExchange(longOutput));
|
|
185
248
|
}
|
|
186
|
-
const result = compactCodexMessages(items, { maxContextTokens: 1_000 });
|
|
187
|
-
|
|
249
|
+
const result = await compactCodexMessages(items, { maxContextTokens: 1_000 }, 1, fullConfig);
|
|
250
|
+
expect(result.compacted).toBe(true);
|
|
251
|
+
expect(result.method).toBe('llm');
|
|
252
|
+
expect(result.messages[0].type).toBe('message');
|
|
253
|
+
expect(result.messages[0].content).toContain('[Conversation Summary]');
|
|
254
|
+
});
|
|
255
|
+
it('falls back to truncation when LLM fails', async () => {
|
|
256
|
+
mockChat.mockRejectedValueOnce(new Error('API error'));
|
|
257
|
+
const longOutput = 'x'.repeat(10_000);
|
|
258
|
+
const items = [];
|
|
259
|
+
for (let i = 0; i < 30; i++) {
|
|
260
|
+
items.push(...codexExchange(longOutput));
|
|
261
|
+
}
|
|
262
|
+
const result = await compactCodexMessages(items, { maxContextTokens: 1_000 }, 1, fullConfig);
|
|
263
|
+
expect(result.method).toBe('truncation');
|
|
264
|
+
const headItems = result.messages.slice(0, -8);
|
|
188
265
|
const outputItems = headItems.filter((item) => item.type === 'function_call_output');
|
|
189
266
|
for (const item of outputItems) {
|
|
190
267
|
expect(item.output).toContain('[truncated]');
|
|
191
|
-
expect(item.output.length).toBeLessThan(longOutput.length);
|
|
192
268
|
}
|
|
193
269
|
});
|
|
194
|
-
it('keeps last 8 items intact when compacting', () => {
|
|
270
|
+
it('keeps last 8 items intact when compacting', async () => {
|
|
195
271
|
const longOutput = 'x'.repeat(10_000);
|
|
196
272
|
const items = [];
|
|
197
273
|
for (let i = 0; i < 30; i++) {
|
|
198
274
|
items.push(...codexExchange(longOutput));
|
|
199
275
|
}
|
|
200
|
-
const result = compactCodexMessages(items, { maxContextTokens: 1_000 });
|
|
201
|
-
|
|
202
|
-
const originalTail = items.slice(-8);
|
|
203
|
-
expect(tail).toEqual(originalTail);
|
|
276
|
+
const result = await compactCodexMessages(items, { maxContextTokens: 1_000 }, 1, fullConfig);
|
|
277
|
+
expect(result.messages.slice(-8)).toEqual(items.slice(-8));
|
|
204
278
|
});
|
|
205
|
-
it('does not mutate the input array', () => {
|
|
279
|
+
it('does not mutate the input array', async () => {
|
|
206
280
|
const longOutput = 'x'.repeat(10_000);
|
|
207
281
|
const items = [];
|
|
208
282
|
for (let i = 0; i < 30; i++) {
|
|
209
283
|
items.push(...codexExchange(longOutput));
|
|
210
284
|
}
|
|
211
285
|
const originalJson = JSON.stringify(items);
|
|
212
|
-
compactCodexMessages(items, { maxContextTokens: 1_000 });
|
|
286
|
+
await compactCodexMessages(items, { maxContextTokens: 1_000 }, 1, fullConfig);
|
|
213
287
|
expect(JSON.stringify(items)).toBe(originalJson);
|
|
214
288
|
});
|
|
215
|
-
it('preserves function_call items
|
|
289
|
+
it('preserves function_call items unchanged', async () => {
|
|
290
|
+
mockChat.mockRejectedValueOnce(new Error('fail')); // force truncation
|
|
216
291
|
const longOutput = 'x'.repeat(10_000);
|
|
217
292
|
const items = [];
|
|
218
293
|
for (let i = 0; i < 30; i++) {
|
|
219
294
|
items.push(...codexExchange(longOutput));
|
|
220
295
|
}
|
|
221
|
-
const result = compactCodexMessages(items, { maxContextTokens: 1_000 });
|
|
222
|
-
const callItems = result.filter((item) => item.type === 'function_call');
|
|
296
|
+
const result = await compactCodexMessages(items, { maxContextTokens: 1_000 }, 1, fullConfig);
|
|
297
|
+
const callItems = result.messages.filter((item) => item.type === 'function_call');
|
|
223
298
|
for (const item of callItems) {
|
|
224
299
|
expect(item.name).toBe('Bash');
|
|
225
300
|
}
|
|
226
301
|
});
|
|
227
|
-
it('passes through unchanged when disabled', () => {
|
|
302
|
+
it('passes through unchanged when disabled', async () => {
|
|
228
303
|
const longOutput = 'x'.repeat(10_000);
|
|
229
304
|
const items = [];
|
|
230
305
|
for (let i = 0; i < 30; i++) {
|
|
231
306
|
items.push(...codexExchange(longOutput));
|
|
232
307
|
}
|
|
233
|
-
const result = compactCodexMessages(items, { enabled: false, maxContextTokens: 1 });
|
|
234
|
-
expect(result).toBe(items);
|
|
308
|
+
const result = await compactCodexMessages(items, { enabled: false, maxContextTokens: 1 });
|
|
309
|
+
expect(result.messages).toBe(items);
|
|
310
|
+
expect(result.compacted).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
describe('serializers', () => {
|
|
314
|
+
it('serializeAnthropicMessages produces readable transcript', () => {
|
|
315
|
+
const messages = [
|
|
316
|
+
{ role: 'user', content: [{ type: 'text', text: 'List files' }] },
|
|
317
|
+
{
|
|
318
|
+
role: 'assistant',
|
|
319
|
+
content: [
|
|
320
|
+
{ type: 'text', text: 'Let me check.' },
|
|
321
|
+
{ type: 'tool_use', id: 'tu_1', name: 'Bash', input: { command: 'ls' } },
|
|
322
|
+
],
|
|
323
|
+
},
|
|
324
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tu_1', content: 'file1.ts\nfile2.ts' }] },
|
|
325
|
+
];
|
|
326
|
+
const transcript = serializeAnthropicMessages(messages);
|
|
327
|
+
expect(transcript).toContain('[User]: List files');
|
|
328
|
+
expect(transcript).toContain('[Assistant]: Let me check.');
|
|
329
|
+
expect(transcript).toContain('[Assistant Tool Call: Bash]');
|
|
330
|
+
expect(transcript).toContain('[Tool Result]: file1.ts');
|
|
331
|
+
});
|
|
332
|
+
it('serializeOpenAIMessages produces readable transcript', () => {
|
|
333
|
+
const messages = [
|
|
334
|
+
{ role: 'user', content: 'List files' },
|
|
335
|
+
{
|
|
336
|
+
role: 'assistant',
|
|
337
|
+
content: 'Let me check.',
|
|
338
|
+
tool_calls: [{ id: 'tc_1', type: 'function', function: { name: 'Bash', arguments: '{"command":"ls"}' } }],
|
|
339
|
+
},
|
|
340
|
+
{ role: 'tool', tool_call_id: 'tc_1', content: 'file1.ts\nfile2.ts' },
|
|
341
|
+
];
|
|
342
|
+
const transcript = serializeOpenAIMessages(messages);
|
|
343
|
+
expect(transcript).toContain('[User]: List files');
|
|
344
|
+
expect(transcript).toContain('[Assistant]: Let me check.');
|
|
345
|
+
expect(transcript).toContain('[Assistant Tool Call: Bash]');
|
|
346
|
+
expect(transcript).toContain('[Tool Result (tc_1)]: file1.ts');
|
|
347
|
+
});
|
|
348
|
+
it('serializeCodexMessages produces readable transcript', () => {
|
|
349
|
+
const items = [
|
|
350
|
+
{ type: 'message', role: 'user', content: 'List files' },
|
|
351
|
+
{ type: 'function_call', call_id: 'fc_1', name: 'Bash', arguments: '{"command":"ls"}' },
|
|
352
|
+
{ type: 'function_call_output', call_id: 'fc_1', output: 'file1.ts\nfile2.ts' },
|
|
353
|
+
];
|
|
354
|
+
const transcript = serializeCodexMessages(items);
|
|
355
|
+
expect(transcript).toContain('[User]: List files');
|
|
356
|
+
expect(transcript).toContain('[Assistant Tool Call: Bash]');
|
|
357
|
+
expect(transcript).toContain('[Tool Result]: file1.ts');
|
|
358
|
+
});
|
|
359
|
+
it('truncates long tool inputs and results in serialization', () => {
|
|
360
|
+
const longContent = 'x'.repeat(2000);
|
|
361
|
+
const messages = [
|
|
362
|
+
{
|
|
363
|
+
role: 'assistant',
|
|
364
|
+
content: [
|
|
365
|
+
{ type: 'tool_use', id: 'tu_1', name: 'ReadFile', input: longContent },
|
|
366
|
+
],
|
|
367
|
+
},
|
|
368
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tu_1', content: longContent }] },
|
|
369
|
+
];
|
|
370
|
+
const transcript = serializeAnthropicMessages(messages);
|
|
371
|
+
// Tool input should be truncated to ~500 + ...
|
|
372
|
+
expect(transcript).toContain('...');
|
|
373
|
+
// Tool result should be truncated to ~1000 + ...
|
|
374
|
+
const lines = transcript.split('\n');
|
|
375
|
+
for (const line of lines) {
|
|
376
|
+
expect(line.length).toBeLessThan(2500);
|
|
377
|
+
}
|
|
235
378
|
});
|
|
236
379
|
});
|
|
@@ -42,6 +42,8 @@ describe('provider utils', () => {
|
|
|
42
42
|
expect(resolveModel('claude-3-5-haiku-20241022', cfg)).toBe('claude-haiku-4-5');
|
|
43
43
|
expect(resolveModel('anthropic/claude-3-5-haiku-20241022', cfg)).toBe('anthropic/claude-haiku-4-5');
|
|
44
44
|
expect(resolveModel('claude-3.5-haiku', cfg)).toBe('claude-haiku-4-5');
|
|
45
|
+
expect(resolveModel('claude-haiku', cfg)).toBe('claude-haiku-4-5');
|
|
46
|
+
expect(resolveModel('anthropic/claude-haiku', cfg)).toBe('anthropic/claude-haiku-4-5');
|
|
45
47
|
});
|
|
46
48
|
it('migrates deprecated claude opus 4 model ids', () => {
|
|
47
49
|
const cfg = { models: { aliases: {} } };
|
|
@@ -61,6 +61,31 @@ describe('sandbox/manager', () => {
|
|
|
61
61
|
expect(mockRemoveContainer).toHaveBeenCalledWith('skimpyclaw-sbx-default');
|
|
62
62
|
expect(mockCreateContainer).toHaveBeenCalledTimes(1);
|
|
63
63
|
});
|
|
64
|
+
it('expands ${VAR} references in env from process.env', async () => {
|
|
65
|
+
process.env.MY_SECRET = 'hunter2';
|
|
66
|
+
try {
|
|
67
|
+
const configWithEnv = {
|
|
68
|
+
...testConfig,
|
|
69
|
+
env: { TOKEN: '${MY_SECRET}', PLAIN: 'literal' },
|
|
70
|
+
};
|
|
71
|
+
await ensureContainer('env-test', configWithEnv, ['/p']);
|
|
72
|
+
const opts = mockCreateContainer.mock.calls[0][1];
|
|
73
|
+
expect(opts.env).toEqual({ TOKEN: 'hunter2', PLAIN: 'literal' });
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
delete process.env.MY_SECRET;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
it('expands unset ${VAR} to empty string', async () => {
|
|
80
|
+
delete process.env.NONEXISTENT_VAR_XYZ;
|
|
81
|
+
const configWithEnv = {
|
|
82
|
+
...testConfig,
|
|
83
|
+
env: { VAL: '${NONEXISTENT_VAR_XYZ}' },
|
|
84
|
+
};
|
|
85
|
+
await ensureContainer('env-test2', configWithEnv, ['/p']);
|
|
86
|
+
const opts = mockCreateContainer.mock.calls[0][1];
|
|
87
|
+
expect(opts.env).toEqual({ VAL: '' });
|
|
88
|
+
});
|
|
64
89
|
});
|
|
65
90
|
describe('releaseContainer', () => {
|
|
66
91
|
it('removes container and clears from map', async () => {
|
|
@@ -119,6 +119,14 @@ describe('sandbox/mount-security', () => {
|
|
|
119
119
|
it('returns original path if no mount matches', () => {
|
|
120
120
|
expect(translatePath('/tmp/random/file', mounts)).toBe('/tmp/random/file');
|
|
121
121
|
});
|
|
122
|
+
it('expands ~ to home directory before matching', () => {
|
|
123
|
+
const home = process.env.HOME || '/Users/katre';
|
|
124
|
+
const homeMounts = [
|
|
125
|
+
{ host: `${home}/.skimpyclaw`, container: '/workspace/config', readOnly: false },
|
|
126
|
+
];
|
|
127
|
+
expect(translatePath('~/.skimpyclaw/agents/main/HEARTBEAT.md', homeMounts))
|
|
128
|
+
.toBe('/workspace/config/agents/main/HEARTBEAT.md');
|
|
129
|
+
});
|
|
122
130
|
it('matches most specific mount first', () => {
|
|
123
131
|
const nestedMounts = [
|
|
124
132
|
{ host: '/Users/katre', container: '/workspace/home', readOnly: false },
|
|
@@ -135,7 +135,7 @@ describe('setup config generation', () => {
|
|
|
135
135
|
});
|
|
136
136
|
expect(config.cron.jobs).toHaveLength(3);
|
|
137
137
|
expect(config.cron.jobs[0].id).toBe('memory-trim');
|
|
138
|
-
expect(config.cron.jobs[0].model).toBe('claude-
|
|
138
|
+
expect(config.cron.jobs[0].model).toBe('claude-fast');
|
|
139
139
|
expect(config.cron.jobs[1].id).toBe('tech-digest');
|
|
140
140
|
expect(config.cron.jobs[2].id).toBe('weather');
|
|
141
141
|
expect(config.cron.jobs[2].schedule.tz).toBe('America/New_York');
|
|
@@ -60,11 +60,11 @@ describe('getToolDefinitions', () => {
|
|
|
60
60
|
expect(tools.map(t => t.name)).not.toContain('Browser');
|
|
61
61
|
});
|
|
62
62
|
describe('tool profiles', () => {
|
|
63
|
-
it('minimal returns
|
|
63
|
+
it('minimal returns built-in tools plus Fetch', async () => {
|
|
64
64
|
const config = { ...toolConfig, toolProfile: 'minimal' };
|
|
65
65
|
const tools = await getToolDefinitions(config, { includeAgentTools: true, includeMcp: true });
|
|
66
|
-
expect(tools).toHaveLength(
|
|
67
|
-
expect(tools.map(t => t.name)).toEqual(['Read', 'Write', 'Glob', 'Bash']);
|
|
66
|
+
expect(tools).toHaveLength(5);
|
|
67
|
+
expect(tools.map(t => t.name)).toEqual(['Read', 'Write', 'Glob', 'Bash', 'Fetch']);
|
|
68
68
|
});
|
|
69
69
|
it('minimal excludes Browser even when browser.enabled is true', async () => {
|
|
70
70
|
const config = { ...toolConfig, toolProfile: 'minimal', browser: { enabled: true } };
|
|
@@ -199,13 +199,15 @@ describe('bash', () => {
|
|
|
199
199
|
const result = await executeTool('Bash', { command: 'echo hello' }, toolConfig);
|
|
200
200
|
expect(result.trim()).toBe('hello');
|
|
201
201
|
});
|
|
202
|
-
it('blocks dangerous commands', async () => {
|
|
203
|
-
const result = await executeTool('Bash', { command:
|
|
204
|
-
expect(result).toContain('
|
|
202
|
+
it('blocks dangerous commands via exec approval', async () => {
|
|
203
|
+
const result = await executeTool('Bash', { command: `rm -rf ${TEST_DIR}` }, toolConfig);
|
|
204
|
+
expect(result).toContain('⛔');
|
|
205
|
+
expect(result).toContain('tier 3');
|
|
205
206
|
});
|
|
206
|
-
it('blocks sudo', async () => {
|
|
207
|
-
const result = await executeTool('Bash', { command:
|
|
208
|
-
expect(result).toContain('
|
|
207
|
+
it('blocks sudo via exec approval', async () => {
|
|
208
|
+
const result = await executeTool('Bash', { command: `sudo ls ${TEST_DIR}` }, toolConfig);
|
|
209
|
+
expect(result).toContain('⛔');
|
|
210
|
+
expect(result).toContain('tier 2');
|
|
209
211
|
});
|
|
210
212
|
it('respects cwd when in allowed paths', async () => {
|
|
211
213
|
const result = await executeTool('Bash', { command: 'ls', cwd: TEST_DIR }, toolConfig);
|
package/dist/agent.js
CHANGED
|
@@ -159,7 +159,7 @@ export async function runAgentTurn(agentId, userMessage, config, modelOverride,
|
|
|
159
159
|
const runTurn = async () => {
|
|
160
160
|
if (toolConfig?.enabled) {
|
|
161
161
|
// Provider-specific routing is centralized in providers/chatWithTools.
|
|
162
|
-
console.log(`[agent] Running with tools (provider: ${provider}, model: ${modelId}, paths: ${toolConfig.allowedPaths.join(', ')})`);
|
|
162
|
+
console.log(`[agent] Running with tools (provider: ${provider}, model: ${modelId}, paths: ${(toolConfig.allowedPaths ?? []).join(', ')})`);
|
|
163
163
|
const result = await chatWithTools(messages, chatOptions, config, toolConfig, toolCtx);
|
|
164
164
|
response = result.response;
|
|
165
165
|
toolCalls = result.toolCalls;
|
package/dist/api.js
CHANGED
|
@@ -619,6 +619,11 @@ export function registerDashboardAPI(fastify, config) {
|
|
|
619
619
|
return reply.code(500).send({ error: `Reload failed: ${msg}` });
|
|
620
620
|
}
|
|
621
621
|
});
|
|
622
|
+
fastify.post('/api/dashboard/mcp/reconnect', async () => {
|
|
623
|
+
const { reconnectMcp } = await import('./tools.js');
|
|
624
|
+
await reconnectMcp();
|
|
625
|
+
return { reconnected: true, timestamp: new Date().toISOString() };
|
|
626
|
+
});
|
|
622
627
|
// --- Audit Log ---
|
|
623
628
|
// Reads from ~/.skimpyclaw/logs/audit/YYYY-MM-DD.jsonl files
|
|
624
629
|
fastify.get('/api/dashboard/audit', async (request) => {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type Client, type Message, type Interaction } from 'discord.js';
|
|
2
|
+
import type { Config } from '../../types.js';
|
|
3
|
+
import { type PendingApproval } from '../../exec-approval.js';
|
|
4
|
+
export declare function handleCommand(message: Message, command: string, args: string[], config: Config, silenceUntil: Date | null, setSilenceUntil: (d: Date | null) => void): Promise<void>;
|
|
5
|
+
export declare function handleIncomingMessage(message: Message, config: Config): Promise<void>;
|
|
6
|
+
export declare function sendApprovalCard(client: Client, channelId: string, approval: PendingApproval): Promise<void>;
|
|
7
|
+
export declare function handleInteraction(interaction: Interaction): Promise<void>;
|