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.
Files changed (52) 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 +11 -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/config.d.ts +7 -0
  24. package/dist/config.js +13 -0
  25. package/dist/cron.js +6 -3
  26. package/dist/heartbeat.js +11 -15
  27. package/dist/providers/anthropic.js +7 -1
  28. package/dist/providers/codex.js +8 -2
  29. package/dist/providers/context-manager.d.ts +37 -6
  30. package/dist/providers/context-manager.js +303 -47
  31. package/dist/providers/openai.js +8 -2
  32. package/dist/providers/utils.js +1 -1
  33. package/dist/sandbox/manager.js +11 -0
  34. package/dist/sandbox/mount-security.js +5 -1
  35. package/dist/sandbox/runtime.d.ts +1 -0
  36. package/dist/sandbox/runtime.js +5 -0
  37. package/dist/sandbox-utils.d.ts +6 -0
  38. package/dist/sandbox-utils.js +36 -0
  39. package/dist/security.js +4 -3
  40. package/dist/setup-templates.d.ts +14 -0
  41. package/dist/setup-templates.js +214 -0
  42. package/dist/setup.d.ts +1 -9
  43. package/dist/setup.js +3 -244
  44. package/dist/tools/bash-tool.js +11 -1
  45. package/dist/tools/definitions.d.ts +57 -0
  46. package/dist/tools/definitions.js +19 -1
  47. package/dist/tools/fetch-tool.d.ts +8 -0
  48. package/dist/tools/fetch-tool.js +80 -0
  49. package/dist/tools.d.ts +4 -2
  50. package/dist/tools.js +110 -62
  51. package/dist/types.d.ts +5 -0
  52. package/package.json +3 -4
@@ -0,0 +1,161 @@
1
+ import { resolveAllowedPaths } from '../../config.js';
2
+ import * as sessions from '../../sessions.js';
3
+ import { BOT_COMMANDS, MAX_HISTORY_PAIRS } from './types.js';
4
+ // ── State ───────────────────────────────────────────────────────────
5
+ const chatHistory = new Map();
6
+ const loadedFromDisk = new Set();
7
+ // ── History ─────────────────────────────────────────────────────────
8
+ export async function getHistory(key) {
9
+ if (!loadedFromDisk.has(key)) {
10
+ loadedFromDisk.add(key);
11
+ const diskHistory = await sessions.loadHistory('discord', key).catch(() => []);
12
+ if (diskHistory.length > 0 && !chatHistory.has(key)) {
13
+ chatHistory.set(key, diskHistory);
14
+ }
15
+ }
16
+ return chatHistory.get(key) || [];
17
+ }
18
+ export async function addToHistory(key, userMsg, assistantMsg) {
19
+ const history = await getHistory(key);
20
+ history.push({ role: 'user', content: userMsg });
21
+ history.push({ role: 'assistant', content: assistantMsg });
22
+ while (history.length > MAX_HISTORY_PAIRS * 2) {
23
+ history.shift();
24
+ history.shift();
25
+ }
26
+ chatHistory.set(key, history);
27
+ sessions.saveExchange('discord', key, userMsg, assistantMsg).catch(() => { });
28
+ }
29
+ export async function clearHistory(key) {
30
+ chatHistory.delete(key);
31
+ loadedFromDisk.delete(key);
32
+ await sessions.clearHistory('discord', key).catch(() => { });
33
+ }
34
+ /** Replace history with a compact summary (used by /compact). */
35
+ export function replaceHistory(key, summary) {
36
+ chatHistory.set(key, [
37
+ { role: 'user', content: 'Summary of our previous conversation:' },
38
+ { role: 'assistant', content: summary },
39
+ ]);
40
+ loadedFromDisk.add(key);
41
+ }
42
+ // ── Tool config ─────────────────────────────────────────────────────
43
+ export function getDiscordToolConfig(cfg) {
44
+ const discord = cfg.channels.discord;
45
+ if (discord?.tools) {
46
+ return {
47
+ ...discord.tools,
48
+ allowedPaths: discord.tools.allowedPaths?.length
49
+ ? discord.tools.allowedPaths
50
+ : resolveAllowedPaths(cfg),
51
+ };
52
+ }
53
+ return {
54
+ enabled: true,
55
+ allowedPaths: resolveAllowedPaths(cfg),
56
+ maxIterations: 100,
57
+ bashTimeout: 15000,
58
+ };
59
+ }
60
+ // ── Helpers ─────────────────────────────────────────────────────────
61
+ export function conversationKey(message) {
62
+ if (message.channel.isDMBased()) {
63
+ return `dm:${message.author.id}`;
64
+ }
65
+ return `channel:${message.channelId}`;
66
+ }
67
+ export function getDiscordRunContext(message) {
68
+ return {
69
+ userId: message.author.id,
70
+ sessionId: message.channel.id,
71
+ channel: 'discord',
72
+ trigger: 'discord',
73
+ metadata: {
74
+ username: message.author.username,
75
+ },
76
+ };
77
+ }
78
+ export function buildHelpText(cfg) {
79
+ const agentConfig = cfg.agents.list[cfg.agents.default];
80
+ const emoji = agentConfig?.identity?.emoji || '🦞';
81
+ const name = agentConfig?.identity?.name || 'SkimpyClaw';
82
+ const commandList = BOT_COMMANDS.map(c => `/${c.command} - ${c.description}`).join('\n');
83
+ return `${emoji} ${name} online.\n\nSend a message to chat, or use a command:\n\n${commandList}`;
84
+ }
85
+ export function splitToChunks(text, maxLength) {
86
+ if (text.length <= maxLength)
87
+ return [text];
88
+ const chunks = [];
89
+ let current = '';
90
+ for (const paragraph of text.split('\n\n')) {
91
+ if (current.length + paragraph.length + 2 > maxLength) {
92
+ if (current)
93
+ chunks.push(current.trim());
94
+ if (paragraph.length > maxLength) {
95
+ const lines = paragraph.split('\n');
96
+ let lineBuf = '';
97
+ for (const line of lines) {
98
+ if (lineBuf.length + line.length + 1 > maxLength) {
99
+ if (lineBuf)
100
+ chunks.push(lineBuf.trim());
101
+ if (line.length > maxLength) {
102
+ for (let i = 0; i < line.length; i += maxLength) {
103
+ chunks.push(line.slice(i, i + maxLength));
104
+ }
105
+ lineBuf = '';
106
+ }
107
+ else {
108
+ lineBuf = line;
109
+ }
110
+ }
111
+ else {
112
+ lineBuf += (lineBuf ? '\n' : '') + line;
113
+ }
114
+ }
115
+ current = lineBuf;
116
+ }
117
+ else {
118
+ current = paragraph;
119
+ }
120
+ }
121
+ else {
122
+ current += (current ? '\n\n' : '') + paragraph;
123
+ }
124
+ }
125
+ if (current)
126
+ chunks.push(current.trim());
127
+ return chunks.filter(c => c.length > 0);
128
+ }
129
+ export async function sendLongText(message, text) {
130
+ const chunks = splitToChunks(text, 1900);
131
+ for (const chunk of chunks) {
132
+ await message.reply(chunk);
133
+ }
134
+ }
135
+ export function startTypingIndicator(message) {
136
+ const maxDurationMs = 90_000;
137
+ let stopped = false;
138
+ const stop = () => {
139
+ if (stopped)
140
+ return;
141
+ stopped = true;
142
+ clearInterval(interval);
143
+ clearTimeout(watchdog);
144
+ };
145
+ const channel = message.channel;
146
+ if (typeof channel.sendTyping === 'function') {
147
+ void channel.sendTyping().catch(() => { });
148
+ }
149
+ const interval = setInterval(() => {
150
+ if (stopped)
151
+ return;
152
+ if (typeof channel.sendTyping === 'function') {
153
+ void channel.sendTyping().catch(() => { });
154
+ }
155
+ }, 4000);
156
+ const watchdog = setTimeout(() => {
157
+ console.warn('[discord] Typing indicator watchdog reached; auto-stopping.');
158
+ stop();
159
+ }, maxDurationMs);
160
+ return stop;
161
+ }
@@ -12,7 +12,7 @@ export declare function getHistory(chatId: number): Promise<ChatMessage[]>;
12
12
  export declare function addToHistory(chatId: number, userMsg: string, assistantMsg: string): Promise<void>;
13
13
  export declare function clearHistory(chatId: number): Promise<void>;
14
14
  export declare function getRunContext(ctx: Context): AgentRunContext;
15
- export declare function getDefaultTelegramToolConfig(cfg: Config): ToolConfig | undefined;
15
+ export declare function getDefaultTelegramToolConfig(cfg: Config): ToolConfig;
16
16
  /** Get Telegram default chat ID from config */
17
17
  export declare function getTelegramDefaultChatId(cfg: Config): number | null;
18
18
  /** Send a long message by splitting it into chunks */
@@ -2,6 +2,7 @@
2
2
  import { existsSync, readdirSync, statSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { homedir } from 'os';
5
+ import { resolveAllowedPaths } from '../../config.js';
5
6
  import { state, MAX_HISTORY_PAIRS, BOT_COMMANDS } from './types.js';
6
7
  import * as sessions from '../../sessions.js';
7
8
  /** Keep sending "typing..." every 4s until the returned stop function is called. */
@@ -102,20 +103,17 @@ export function getRunContext(ctx) {
102
103
  // Default tool config for Telegram — gives the agent file/bash access
103
104
  export function getDefaultTelegramToolConfig(cfg) {
104
105
  if (cfg.channels.telegram.tools) {
105
- return cfg.channels.telegram.tools;
106
- }
107
- if (cfg.channels.telegram.defaultAllowedPaths?.length) {
108
106
  return {
109
- enabled: true,
110
- allowedPaths: cfg.channels.telegram.defaultAllowedPaths,
111
- maxIterations: 100,
112
- bashTimeout: 15000,
107
+ ...cfg.channels.telegram.tools,
108
+ allowedPaths: cfg.channels.telegram.tools.allowedPaths?.length
109
+ ? cfg.channels.telegram.tools.allowedPaths
110
+ : resolveAllowedPaths(cfg),
113
111
  };
114
112
  }
115
113
  return {
116
114
  enabled: true,
117
- allowedPaths: [join(homedir(), '.skimpyclaw')],
118
- maxIterations: 30,
115
+ allowedPaths: resolveAllowedPaths(cfg),
116
+ maxIterations: 100,
119
117
  bashTimeout: 15000,
120
118
  };
121
119
  }
package/dist/channels.js CHANGED
@@ -38,7 +38,7 @@ async function loadAdapter(channel) {
38
38
  resolveDefaultTarget: telegram.getTelegramDefaultChatId,
39
39
  };
40
40
  }
41
- const discord = await import('./discord.js');
41
+ const discord = await import('./channels/discord/index.js');
42
42
  return {
43
43
  init: discord.initDiscord,
44
44
  start: discord.startDiscord,
package/dist/cli.js CHANGED
@@ -4,12 +4,13 @@ import { join } from 'path';
4
4
  import { homedir } from 'os';
5
5
  import { spawn, spawnSync } from 'child_process';
6
6
  import { fileURLToPath } from 'url';
7
- import { loadConfig, loadRawConfig, getConfigPath, saveConfig } from './config.js';
7
+ import { loadConfig, loadRawConfig, getConfigPath, saveConfig, resolveAllowedPaths } from './config.js';
8
8
  import { startRuntime } from './service.js';
9
9
  import { runSetup, renderGatewayPlist } from './setup.js';
10
10
  import { runDoctor as runDoctorCommand } from './doctor/index.js';
11
11
  import { executeTool, getToolDefinitions, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION } from './tools.js';
12
12
  import { formatModelSelectionError, getModelSelectionUsage, resolveModelSelection } from './model-selection.js';
13
+ import { detectSandboxRuntime, isSandboxRuntimeRunning, sandboxNetworkExists, defaultSandboxNetwork, sandboxImageExists, } from './sandbox-utils.js';
13
14
  const APP_NAME = 'skimpyclaw';
14
15
  const DEFAULT_PORT = 18790;
15
16
  const LAUNCHD_LABEL = 'com.skimpyclaw.gateway';
@@ -102,19 +103,17 @@ function hasFlag(args, flag) {
102
103
  return args.includes(flag);
103
104
  }
104
105
  function getCliToolConfig(config) {
105
- if (config.channels.telegram.tools)
106
- return config.channels.telegram.tools;
107
- if (config.channels.telegram.defaultAllowedPaths?.length) {
106
+ if (config.channels.telegram.tools) {
108
107
  return {
109
- enabled: true,
110
- allowedPaths: config.channels.telegram.defaultAllowedPaths,
111
- maxIterations: 100,
112
- bashTimeout: 15000,
108
+ ...config.channels.telegram.tools,
109
+ allowedPaths: config.channels.telegram.tools.allowedPaths?.length
110
+ ? config.channels.telegram.tools.allowedPaths
111
+ : resolveAllowedPaths(config),
113
112
  };
114
113
  }
115
114
  return {
116
115
  enabled: true,
117
- allowedPaths: [join(homedir(), '.skimpyclaw')],
116
+ allowedPaths: resolveAllowedPaths(config),
118
117
  maxIterations: 100,
119
118
  bashTimeout: 15000,
120
119
  };
@@ -771,40 +770,6 @@ const SANDBOX_CLI_BY_PROFILE = {
771
770
  dev: ['bash', 'curl', 'git', 'gh', 'jq', 'python3', 'rg', 'pnpm', 'gcc', 'g++', 'make'],
772
771
  full: ['bash', 'curl', 'git', 'gh', 'jq', 'python3', 'rg', 'pnpm', 'gcc', 'g++', 'make', 'pip3', 'sqlite3'],
773
772
  };
774
- function defaultSandboxNetwork(runtime) {
775
- return runtime === 'container' ? 'default' : 'bridge';
776
- }
777
- function detectSandboxRuntime(preferred) {
778
- if (preferred === 'container' || preferred === 'docker') {
779
- return spawnSync(preferred, ['--version'], { encoding: 'utf-8' }).status === 0 ? preferred : null;
780
- }
781
- if (spawnSync('container', ['--version'], { encoding: 'utf-8' }).status === 0) {
782
- return 'container';
783
- }
784
- if (spawnSync('docker', ['--version'], { encoding: 'utf-8' }).status === 0) {
785
- return 'docker';
786
- }
787
- return null;
788
- }
789
- function isSandboxRuntimeRunning(runtime) {
790
- if (runtime === 'container') {
791
- return spawnSync('container', ['system', 'status'], { encoding: 'utf-8' }).status === 0;
792
- }
793
- return spawnSync('docker', ['info'], { encoding: 'utf-8' }).status === 0;
794
- }
795
- function sandboxNetworkExists(runtime, network) {
796
- if (runtime === 'container') {
797
- const result = spawnSync('container', ['network', 'ls'], { encoding: 'utf-8' });
798
- if (result.status !== 0)
799
- return false;
800
- return result.stdout.split('\n').some((line) => line.trim().split(/\s+/)[0] === network);
801
- }
802
- const result = spawnSync('docker', ['network', 'inspect', network], { encoding: 'utf-8' });
803
- return result.status === 0;
804
- }
805
- function sandboxImageExists(runtime, image) {
806
- return spawnSync(runtime, ['image', 'inspect', image], { encoding: 'utf-8' }).status === 0;
807
- }
808
773
  function resolveSandboxDir() {
809
774
  // 1. Check CWD (user is in repo root)
810
775
  const cwdSandbox = join(process.cwd(), 'sandbox');
@@ -184,9 +184,14 @@ export function parseCodexOutput(stdout) {
184
184
  for (const line of lines) {
185
185
  try {
186
186
  const obj = JSON.parse(line);
187
+ // Standard output_text events
187
188
  if (obj.type === 'output_text' || obj.output_text) {
188
189
  outputs.push(obj.output_text || obj.text || '');
189
190
  }
191
+ // Codex stream-json: item.completed with agent_message
192
+ else if (obj.type === 'item.completed' && obj.item?.type === 'agent_message' && obj.item?.text) {
193
+ outputs.push(obj.item.text);
194
+ }
190
195
  }
191
196
  catch {
192
197
  if (line.trim())
package/dist/config.d.ts CHANGED
@@ -17,4 +17,11 @@ export declare function listMemoryFiles(agentId: string): {
17
17
  date: string;
18
18
  size: number;
19
19
  }[];
20
+ /**
21
+ * Resolve allowed paths for a given context. Priority:
22
+ * 1. Explicit toolConfig.allowedPaths (if provided)
23
+ * 2. Config top-level allowedPaths
24
+ * 3. Fallback: ~/.skimpyclaw only
25
+ */
26
+ export declare function resolveAllowedPaths(config: Config, overridePaths?: string[]): string[];
20
27
  export declare function readMemoryFile(agentId: string, filename: string): string;
package/dist/config.js CHANGED
@@ -109,6 +109,19 @@ export function listMemoryFiles(agentId) {
109
109
  };
110
110
  }).sort((a, b) => b.date.localeCompare(a.date));
111
111
  }
112
+ /**
113
+ * Resolve allowed paths for a given context. Priority:
114
+ * 1. Explicit toolConfig.allowedPaths (if provided)
115
+ * 2. Config top-level allowedPaths
116
+ * 3. Fallback: ~/.skimpyclaw only
117
+ */
118
+ export function resolveAllowedPaths(config, overridePaths) {
119
+ if (overridePaths?.length)
120
+ return overridePaths;
121
+ if (config.allowedPaths?.length)
122
+ return config.allowedPaths;
123
+ return [join(homedir(), '.skimpyclaw')];
124
+ }
112
125
  export function readMemoryFile(agentId, filename) {
113
126
  if (!isValidAgentId(agentId)) {
114
127
  throw new Error('Invalid agent ID');
package/dist/cron.js CHANGED
@@ -3,7 +3,7 @@ import { Cron } from 'croner';
3
3
  import { exec } from 'child_process';
4
4
  import { existsSync, mkdirSync, appendFileSync, readFileSync, watch } from 'fs';
5
5
  import { join } from 'path';
6
- import { getLogsDir, getConfigPath, loadConfig } from './config.js';
6
+ import { getLogsDir, getConfigPath, loadConfig, resolveAllowedPaths } from './config.js';
7
7
  import { homedir } from 'node:os';
8
8
  import { runAgentTurn } from './agent.js';
9
9
  import { startTrace, addEvent, endTrace } from './audit.js';
@@ -152,11 +152,14 @@ async function executeJobPayload(jobDef, config) {
152
152
  appendCronLogLine(jobDef.id, `Agent turn started (prompt: ${message.slice(0, 100)}...)`);
153
153
  const defaultTools = {
154
154
  enabled: true,
155
- allowedPaths: [`${homedir()}/.skimpyclaw`],
155
+ allowedPaths: resolveAllowedPaths(config),
156
156
  maxIterations: 30,
157
157
  bashTimeout: 15000,
158
158
  };
159
- const response = await runAgentTurn(config.agents.default, message, config, jobDef.model, jobDef.payload.tools || defaultTools, undefined, {
159
+ const tools = jobDef.payload.tools
160
+ ? { ...jobDef.payload.tools, allowedPaths: jobDef.payload.tools.allowedPaths?.length ? jobDef.payload.tools.allowedPaths : resolveAllowedPaths(config) }
161
+ : defaultTools;
162
+ const response = await runAgentTurn(config.agents.default, message, config, jobDef.model, tools, undefined, {
160
163
  channel: getActiveChannelId() || 'telegram',
161
164
  trigger: 'cron',
162
165
  sessionId: jobDef.id,
package/dist/heartbeat.js CHANGED
@@ -6,30 +6,26 @@
6
6
  import { join } from 'path';
7
7
  import { homedir } from 'os';
8
8
  import { runAgentTurn } from './agent.js';
9
+ import { resolveAllowedPaths } from './config.js';
9
10
  import { pruneIdle, SANDBOX_DEFAULTS } from './sandbox/index.js';
10
11
  import { getActiveChannelId, isActiveChannelSilenced, sendActiveChannelProactiveMessage, } from './channels.js';
11
12
  let heartbeatTimer = null;
12
13
  let running = false;
13
- const DEFAULT_HEARTBEAT_TOOLS = {
14
- enabled: true,
15
- allowedPaths: [join(homedir(), '.skimpyclaw')],
16
- maxIterations: 100,
17
- bashTimeout: 15000,
18
- };
19
14
  function getHeartbeatTools(config) {
20
15
  if (config.heartbeat.tools) {
21
- return config.heartbeat.tools;
22
- }
23
- const defaultAllowedPaths = config.channels.active === 'discord'
24
- ? config.channels.discord?.defaultAllowedPaths
25
- : config.channels.telegram.defaultAllowedPaths || config.channels.discord?.defaultAllowedPaths;
26
- if (defaultAllowedPaths?.length) {
27
16
  return {
28
- ...DEFAULT_HEARTBEAT_TOOLS,
29
- allowedPaths: defaultAllowedPaths,
17
+ ...config.heartbeat.tools,
18
+ allowedPaths: config.heartbeat.tools.allowedPaths?.length
19
+ ? config.heartbeat.tools.allowedPaths
20
+ : resolveAllowedPaths(config),
30
21
  };
31
22
  }
32
- return DEFAULT_HEARTBEAT_TOOLS;
23
+ return {
24
+ enabled: true,
25
+ allowedPaths: resolveAllowedPaths(config),
26
+ maxIterations: 100,
27
+ bashTimeout: 15000,
28
+ };
33
29
  }
34
30
  function getHeartbeatFilePath(config) {
35
31
  return join(homedir(), '.skimpyclaw', 'agents', config.agents.default, 'HEARTBEAT.md');
@@ -152,7 +152,13 @@ export async function chatWithToolsAnthropic(params) {
152
152
  };
153
153
  }
154
154
  // Compact old tool results if context is growing large
155
- const messagesForApi = compactAnthropicMessages(apiMessages, toolConfig.contextManagement, i + 1);
155
+ const compactionResult = await compactAnthropicMessages(apiMessages, toolConfig.contextManagement, i + 1, config);
156
+ const messagesForApi = compactionResult.messages;
157
+ if (compactionResult.compacted) {
158
+ const method = compactionResult.method === 'llm' ? 'LLM summary' : 'truncation';
159
+ const detail = `~${Math.round((compactionResult.tokensBefore || 0) / 1000)}k → ~${Math.round((compactionResult.tokensAfter || 0) / 1000)}k tokens`;
160
+ toolLog.push(`[context compacted via ${method}: ${detail}]`);
161
+ }
156
162
  const anthropicParams = {
157
163
  model: modelId,
158
164
  max_tokens: options.maxTokens || 16384,
@@ -265,7 +265,7 @@ export async function chatCodex(params) {
265
265
  }
266
266
  }
267
267
  export async function chatWithToolsCodex(params) {
268
- const { messages, options, toolConfig, toolContext } = params;
268
+ const { messages, options, config, toolConfig, toolContext } = params;
269
269
  const modelId = stripProvider(options.model);
270
270
  const maxIterations = toolConfig.maxIterations || 100;
271
271
  // Build input — system messages go to `instructions`, rest to `input`
@@ -308,7 +308,13 @@ export async function chatWithToolsCodex(params) {
308
308
  };
309
309
  }
310
310
  // Compact old tool results if context is growing large
311
- const inputForApi = compactCodexMessages(input, toolConfig.contextManagement, i + 1);
311
+ const compactionResult = await compactCodexMessages(input, toolConfig.contextManagement, i + 1, config);
312
+ const inputForApi = compactionResult.messages;
313
+ if (compactionResult.compacted) {
314
+ const method = compactionResult.method === 'llm' ? 'LLM summary' : 'truncation';
315
+ const detail = `~${Math.round((compactionResult.tokensBefore || 0) / 1000)}k → ~${Math.round((compactionResult.tokensAfter || 0) / 1000)}k tokens`;
316
+ toolLog.push(`[context compacted via ${method}: ${detail}]`);
317
+ }
312
318
  const body = {
313
319
  model: modelId,
314
320
  instructions,
@@ -1,22 +1,53 @@
1
1
  import type { ContextManagementConfig } from './types.js';
2
+ import type { Config } from '../types.js';
2
3
  export type { ContextManagementConfig };
4
+ /** Result of a compaction attempt, including metadata about what happened. */
5
+ export interface CompactionResult<T> {
6
+ messages: T[];
7
+ /** Whether any compaction was performed */
8
+ compacted: boolean;
9
+ /** 'llm' if LLM summarized, 'truncation' if mechanically truncated, undefined if no compaction */
10
+ method?: 'llm' | 'truncation';
11
+ /** The summary text (only when method === 'llm') */
12
+ summary?: string;
13
+ /** Estimated tokens before compaction */
14
+ tokensBefore?: number;
15
+ /** Estimated tokens after compaction */
16
+ tokensAfter?: number;
17
+ }
3
18
  /** Rough token estimate: 1 token ≈ 4 chars of JSON. */
4
19
  export declare function estimateTokens(data: any[]): number;
20
+ /**
21
+ * Serialize Anthropic-format messages into a human-readable conversation transcript
22
+ * suitable for LLM summarization.
23
+ */
24
+ declare function serializeAnthropicMessages(messages: any[]): string;
25
+ /**
26
+ * Serialize OpenAI-format messages into a human-readable transcript.
27
+ */
28
+ declare function serializeOpenAIMessages(messages: any[]): string;
29
+ /**
30
+ * Serialize Codex-format input items into a human-readable transcript.
31
+ */
32
+ declare function serializeCodexMessages(items: any[]): string;
5
33
  /**
6
34
  * Compact Anthropic-format apiMessages when over threshold.
7
- * Truncates content of old tool_result blocks; leaves last KEEP_TAIL messages intact.
35
+ * Uses LLM summarization for old messages; falls back to truncation on failure.
8
36
  * Does NOT mutate the input array — returns a new array.
9
37
  */
10
- export declare function compactAnthropicMessages(messages: any[], config?: ContextManagementConfig, iteration?: number): any[];
38
+ export declare function compactAnthropicMessages(messages: any[], config?: ContextManagementConfig, iteration?: number, fullConfig?: Config): Promise<CompactionResult<any>>;
11
39
  /**
12
40
  * Compact OpenAI-format apiMessages when over threshold.
13
- * Truncates content of old `role: 'tool'` messages; leaves last KEEP_TAIL messages intact.
41
+ * Uses LLM summarization for old messages; falls back to truncation on failure.
14
42
  * Does NOT mutate the input array — returns a new array.
15
43
  */
16
- export declare function compactOpenAIMessages(messages: any[], config?: ContextManagementConfig, iteration?: number): any[];
44
+ export declare function compactOpenAIMessages(messages: any[], config?: ContextManagementConfig, iteration?: number, fullConfig?: Config): Promise<CompactionResult<any>>;
17
45
  /**
18
46
  * Compact Codex-format input items when over threshold.
19
- * Truncates output of old function_call_output items; leaves last KEEP_TAIL items intact.
47
+ * Uses LLM summarization for old items; falls back to truncation on failure.
20
48
  * Does NOT mutate the input array — returns a new array.
21
49
  */
22
- export declare function compactCodexMessages(input: any[], config?: ContextManagementConfig, iteration?: number): any[];
50
+ export declare function compactCodexMessages(input: any[], config?: ContextManagementConfig, iteration?: number, fullConfig?: Config): Promise<CompactionResult<any>>;
51
+ export { serializeAnthropicMessages, serializeOpenAIMessages, serializeCodexMessages };
52
+ /** Reset compaction markers (for testing). */
53
+ export declare function resetCompactionState(): void;