skimpyclaw 0.3.10 → 0.3.15

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 (53) 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__/tools.test.js +12 -9
  8. package/dist/agent.js +1 -1
  9. package/dist/api.js +5 -0
  10. package/dist/channels/discord/handlers.d.ts +7 -0
  11. package/dist/channels/discord/handlers.js +479 -0
  12. package/dist/channels/discord/index.d.ts +8 -0
  13. package/dist/channels/discord/index.js +149 -0
  14. package/dist/channels/discord/types.d.ts +6 -0
  15. package/dist/channels/discord/types.js +17 -0
  16. package/dist/channels/discord/utils.d.ts +14 -0
  17. package/dist/channels/discord/utils.js +161 -0
  18. package/dist/channels/telegram/utils.d.ts +1 -1
  19. package/dist/channels/telegram/utils.js +7 -9
  20. package/dist/channels.js +1 -1
  21. package/dist/cli.js +8 -43
  22. package/dist/code-agents/parser.js +5 -0
  23. package/dist/code-agents/utils.js +1 -0
  24. package/dist/config.d.ts +7 -0
  25. package/dist/config.js +13 -0
  26. package/dist/cron.js +6 -3
  27. package/dist/heartbeat.js +11 -15
  28. package/dist/providers/anthropic.js +7 -1
  29. package/dist/providers/codex.js +8 -2
  30. package/dist/providers/context-manager.d.ts +37 -6
  31. package/dist/providers/context-manager.js +303 -47
  32. package/dist/providers/openai.js +8 -2
  33. package/dist/providers/utils.js +1 -1
  34. package/dist/sandbox/manager.js +11 -0
  35. package/dist/sandbox/mount-security.js +5 -1
  36. package/dist/sandbox/runtime.d.ts +1 -0
  37. package/dist/sandbox/runtime.js +5 -0
  38. package/dist/sandbox-utils.d.ts +6 -0
  39. package/dist/sandbox-utils.js +36 -0
  40. package/dist/security.js +4 -3
  41. package/dist/setup-templates.d.ts +14 -0
  42. package/dist/setup-templates.js +214 -0
  43. package/dist/setup.d.ts +1 -9
  44. package/dist/setup.js +3 -244
  45. package/dist/tools/bash-tool.js +11 -1
  46. package/dist/tools/definitions.d.ts +57 -0
  47. package/dist/tools/definitions.js +19 -1
  48. package/dist/tools/fetch-tool.d.ts +8 -0
  49. package/dist/tools/fetch-tool.js +80 -0
  50. package/dist/tools.d.ts +4 -2
  51. package/dist/tools.js +110 -62
  52. package/dist/types.d.ts +5 -0
  53. 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('truncates old tool_result content when over threshold', () => {
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('keeps last 8 messages intact when compacting', () => {
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('preserves non-tool_result blocks unchanged', () => {
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: 1_000 });
96
- // Assistant messages (tool_use blocks) should be untouched
97
- const assistantMessages = result.filter(m => m.role === 'assistant');
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('passes through unchanged when disabled', () => {
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, { enabled: false, maxContextTokens: 1 });
111
- expect(result).toBe(messages);
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('truncates old tool content when over threshold', () => {
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
- const headItems = result.slice(0, -8);
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('truncates old function_call_output when over threshold', () => {
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
- const headItems = result.slice(0, -8);
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
- const tail = result.slice(-8);
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 (not just outputs) unchanged', () => {
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-haiku');
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 exactly 4 built-in tools', async () => {
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(4);
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: 'rm -rf /' }, toolConfig);
204
- expect(result).toContain('Error: Command blocked');
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: 'sudo cat /etc/passwd' }, toolConfig);
208
- expect(result).toContain('Error: Command blocked');
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);
@@ -384,6 +386,7 @@ describe('code_with_agent', () => {
384
386
  expect(cmd).toContain('codex');
385
387
  expect(args[0]).toBe('exec');
386
388
  expect(args).toContain('--full-auto');
389
+ expect(args).toContain('--skip-git-repo-check');
387
390
  expect(args).toContain('--json');
388
391
  expect(args).toContain('--color');
389
392
  expect(args).toContain('never');
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>;