codebot-ai 1.4.2 → 1.5.0

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/agent.d.ts CHANGED
@@ -7,6 +7,8 @@ export declare class Agent {
7
7
  private maxIterations;
8
8
  private autoApprove;
9
9
  private model;
10
+ private cache;
11
+ private rateLimiter;
10
12
  private askPermission;
11
13
  private onMessage?;
12
14
  constructor(opts: {
package/dist/agent.js CHANGED
@@ -43,6 +43,51 @@ const repo_map_1 = require("./context/repo-map");
43
43
  const memory_1 = require("./memory");
44
44
  const registry_1 = require("./providers/registry");
45
45
  const plugins_1 = require("./plugins");
46
+ const cache_1 = require("./cache");
47
+ const rate_limiter_1 = require("./rate-limiter");
48
+ /** Lightweight schema validation — returns error string or null if valid */
49
+ function validateToolArgs(args, schema) {
50
+ const props = schema.properties;
51
+ const required = schema.required;
52
+ if (!props)
53
+ return null;
54
+ // Check required fields exist
55
+ if (required) {
56
+ for (const field of required) {
57
+ if (args[field] === undefined || args[field] === null) {
58
+ return `missing required field '${field}'`;
59
+ }
60
+ }
61
+ }
62
+ // Check types match for provided fields
63
+ for (const [key, value] of Object.entries(args)) {
64
+ const propSchema = props[key];
65
+ if (!propSchema)
66
+ continue; // extra fields are OK
67
+ const expectedType = propSchema.type;
68
+ if (!expectedType)
69
+ continue;
70
+ const actualType = Array.isArray(value) ? 'array' : typeof value;
71
+ if (expectedType === 'string' && actualType !== 'string') {
72
+ return `field '${key}' expected string, got ${actualType}`;
73
+ }
74
+ if (expectedType === 'number' && actualType !== 'number') {
75
+ return `field '${key}' expected number, got ${actualType}`;
76
+ }
77
+ if (expectedType === 'boolean' && actualType !== 'boolean') {
78
+ return `field '${key}' expected boolean, got ${actualType}`;
79
+ }
80
+ if (expectedType === 'array' && !Array.isArray(value)) {
81
+ return `field '${key}' expected array, got ${actualType}`;
82
+ }
83
+ if (expectedType === 'object' && (actualType !== 'object' || Array.isArray(value))) {
84
+ return `field '${key}' expected object, got ${actualType}`;
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+ /** Tools that use shared global state and must not run concurrently */
90
+ const SEQUENTIAL_TOOLS = new Set(['browser']);
46
91
  class Agent {
47
92
  provider;
48
93
  tools;
@@ -51,6 +96,8 @@ class Agent {
51
96
  maxIterations;
52
97
  autoApprove;
53
98
  model;
99
+ cache;
100
+ rateLimiter;
54
101
  askPermission;
55
102
  onMessage;
56
103
  constructor(opts) {
@@ -62,6 +109,8 @@ class Agent {
62
109
  this.autoApprove = opts.autoApprove || false;
63
110
  this.askPermission = opts.askPermission || defaultAskPermission;
64
111
  this.onMessage = opts.onMessage;
112
+ this.cache = new cache_1.ToolCache();
113
+ this.rateLimiter = new rate_limiter_1.RateLimiter();
65
114
  // Load plugins
66
115
  try {
67
116
  const plugins = (0, plugins_1.loadPlugins)(process.cwd());
@@ -179,16 +228,12 @@ class Agent {
179
228
  yield { type: 'done' };
180
229
  return;
181
230
  }
182
- // Execute each tool call
231
+ const prepared = [];
183
232
  for (const tc of toolCalls) {
184
233
  const toolName = tc.function.name;
185
234
  const tool = this.tools.get(toolName);
186
235
  if (!tool) {
187
- const errResult = `Error: Unknown tool "${toolName}"`;
188
- const toolMsg = { role: 'tool', content: errResult, tool_call_id: tc.id };
189
- this.messages.push(toolMsg);
190
- this.onMessage?.(toolMsg);
191
- yield { type: 'tool_result', toolResult: { name: toolName, result: errResult, is_error: true } };
236
+ prepared.push({ tc, tool: null, args: {}, denied: false, error: `Error: Unknown tool "${toolName}"` });
192
237
  continue;
193
238
  }
194
239
  let args;
@@ -196,41 +241,111 @@ class Agent {
196
241
  args = JSON.parse(tc.function.arguments);
197
242
  }
198
243
  catch {
199
- const errResult = `Error: Invalid JSON arguments for ${toolName}`;
200
- const toolMsg = { role: 'tool', content: errResult, tool_call_id: tc.id };
201
- this.messages.push(toolMsg);
202
- this.onMessage?.(toolMsg);
203
- yield { type: 'tool_result', toolResult: { name: toolName, result: errResult, is_error: true } };
244
+ prepared.push({ tc, tool, args: {}, denied: false, error: `Error: Invalid JSON arguments for ${toolName}` });
245
+ continue;
246
+ }
247
+ // Arg validation against schema
248
+ const validationError = validateToolArgs(args, tool.parameters);
249
+ if (validationError) {
250
+ prepared.push({ tc, tool, args, denied: false, error: `Error: ${validationError} for ${toolName}` });
204
251
  continue;
205
252
  }
206
253
  yield { type: 'tool_call', toolCall: { name: toolName, args } };
207
- // Permission check
254
+ // Permission check (sequential — needs user interaction)
208
255
  const needsPermission = tool.permission === 'always-ask' ||
209
256
  (tool.permission === 'prompt' && !this.autoApprove);
257
+ let denied = false;
210
258
  if (needsPermission) {
211
259
  const approved = await this.askPermission(toolName, args);
212
260
  if (!approved) {
213
- const toolMsg = { role: 'tool', content: 'Permission denied by user.', tool_call_id: tc.id };
214
- this.messages.push(toolMsg);
215
- this.onMessage?.(toolMsg);
216
- yield { type: 'tool_result', toolResult: { name: toolName, result: 'Permission denied.' } };
217
- continue;
261
+ denied = true;
218
262
  }
219
263
  }
220
- // Execute
264
+ prepared.push({ tc, tool, args, denied });
265
+ }
266
+ const results = new Array(prepared.length);
267
+ // Immediately resolve errors and denials
268
+ const toExecute = [];
269
+ for (let idx = 0; idx < prepared.length; idx++) {
270
+ const prep = prepared[idx];
271
+ if (prep.error) {
272
+ results[idx] = { content: prep.error, is_error: true };
273
+ }
274
+ else if (prep.denied) {
275
+ results[idx] = { content: 'Permission denied by user.' };
276
+ }
277
+ else {
278
+ toExecute.push({ index: idx, prep });
279
+ }
280
+ }
281
+ // Split into parallel-safe and sequential (browser) groups
282
+ const parallelBatch = [];
283
+ const sequentialBatch = [];
284
+ for (const item of toExecute) {
285
+ if (SEQUENTIAL_TOOLS.has(item.prep.tc.function.name)) {
286
+ sequentialBatch.push(item);
287
+ }
288
+ else {
289
+ parallelBatch.push(item);
290
+ }
291
+ }
292
+ // Helper to execute a single tool with cache + rate limiting
293
+ const executeTool = async (prep) => {
294
+ const toolName = prep.tc.function.name;
295
+ // Check cache first
296
+ if (prep.tool.cacheable) {
297
+ const cacheKey = cache_1.ToolCache.key(toolName, prep.args);
298
+ const cached = this.cache.get(cacheKey);
299
+ if (cached !== null) {
300
+ return { content: cached };
301
+ }
302
+ }
303
+ // Rate limit
304
+ await this.rateLimiter.throttle(toolName);
221
305
  try {
222
- const output = await tool.execute(args);
223
- const toolMsg = { role: 'tool', content: output, tool_call_id: tc.id };
224
- this.messages.push(toolMsg);
225
- this.onMessage?.(toolMsg);
226
- yield { type: 'tool_result', toolResult: { name: toolName, result: output } };
306
+ const output = await prep.tool.execute(prep.args);
307
+ // Store in cache for cacheable tools
308
+ if (prep.tool.cacheable) {
309
+ const ttl = cache_1.ToolCache.TTL[toolName] || 30_000;
310
+ this.cache.set(cache_1.ToolCache.key(toolName, prep.args), output, ttl);
311
+ }
312
+ // Invalidate cache on write operations
313
+ if (toolName === 'write_file' || toolName === 'edit_file' || toolName === 'batch_edit') {
314
+ const filePath = prep.args.path;
315
+ if (filePath)
316
+ this.cache.invalidate(filePath);
317
+ }
318
+ return { content: output };
227
319
  }
228
320
  catch (err) {
229
321
  const errMsg = err instanceof Error ? err.message : String(err);
230
- const toolMsg = { role: 'tool', content: `Error: ${errMsg}`, tool_call_id: tc.id };
231
- this.messages.push(toolMsg);
232
- this.onMessage?.(toolMsg);
233
- yield { type: 'tool_result', toolResult: { name: toolName, result: errMsg, is_error: true } };
322
+ return { content: `Error: ${errMsg}`, is_error: true };
323
+ }
324
+ };
325
+ // Execute parallel batch concurrently
326
+ if (parallelBatch.length > 0) {
327
+ const promises = parallelBatch.map(async ({ index, prep }) => {
328
+ results[index] = await executeTool(prep);
329
+ });
330
+ await Promise.allSettled(promises);
331
+ }
332
+ // Execute sequential batch one at a time
333
+ for (const { index, prep } of sequentialBatch) {
334
+ results[index] = await executeTool(prep);
335
+ }
336
+ // ── Phase 3: Push results in original order + yield events ──
337
+ for (let idx = 0; idx < prepared.length; idx++) {
338
+ const prep = prepared[idx];
339
+ const output = results[idx] || { content: 'Error: execution failed', is_error: true };
340
+ const toolName = prep.tc.function.name;
341
+ const toolMsg = { role: 'tool', content: output.content, tool_call_id: prep.tc.id };
342
+ this.messages.push(toolMsg);
343
+ this.onMessage?.(toolMsg);
344
+ if (prep.denied) {
345
+ yield { type: 'tool_result', toolResult: { name: toolName, result: 'Permission denied.' } };
346
+ }
347
+ else {
348
+ yield { type: 'tool_result', toolResult: { name: toolName, result: output.content, is_error: output.is_error } };
234
349
  }
235
350
  }
236
351
  // Compact after tool results if needed
@@ -391,6 +506,7 @@ ${this.tools.all().map(t => `- ${t.name}: ${t.description}`).join('\n')}`;
391
506
  }
392
507
  }
393
508
  exports.Agent = Agent;
509
+ const PERMISSION_TIMEOUT_MS = 30_000;
394
510
  async function defaultAskPermission(tool, args) {
395
511
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
396
512
  const summary = Object.entries(args)
@@ -399,11 +515,19 @@ async function defaultAskPermission(tool, args) {
399
515
  return ` ${k}: ${val}`;
400
516
  })
401
517
  .join('\n');
402
- return new Promise(resolve => {
403
- rl.question(`\n⚡ ${tool}\n${summary}\nAllow? [y/N] `, answer => {
518
+ const userResponse = new Promise(resolve => {
519
+ rl.question(`\n⚡ ${tool}\n${summary}\nAllow? [y/N] (${PERMISSION_TIMEOUT_MS / 1000}s timeout) `, answer => {
404
520
  rl.close();
405
521
  resolve(answer.toLowerCase().startsWith('y'));
406
522
  });
407
523
  });
524
+ const timeout = new Promise(resolve => {
525
+ setTimeout(() => {
526
+ rl.close();
527
+ process.stdout.write('\n⏱ Permission timed out — denied by default.\n');
528
+ resolve(false);
529
+ }, PERMISSION_TIMEOUT_MS);
530
+ });
531
+ return Promise.race([userResponse, timeout]);
408
532
  }
409
533
  //# sourceMappingURL=agent.js.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * LRU Tool Result Cache with TTL
3
+ *
4
+ * Caches results from read-only tools (read_file, grep, glob, etc.)
5
+ * to avoid redundant I/O. Invalidates on writes to affected paths.
6
+ *
7
+ * @since v1.5.0
8
+ */
9
+ export declare class ToolCache {
10
+ private cache;
11
+ private maxSize;
12
+ private currentSize;
13
+ /** Default TTLs per tool (ms) */
14
+ static readonly TTL: Record<string, number>;
15
+ constructor(maxSizeBytes?: number);
16
+ /** Build a deterministic cache key from tool name + sorted args */
17
+ static key(toolName: string, args: Record<string, unknown>): string;
18
+ /** Get a cached value, or null if expired/missing */
19
+ get(key: string): string | null;
20
+ /** Store a value with TTL */
21
+ set(key: string, value: string, ttlMs: number): void;
22
+ /** Remove a specific key */
23
+ private delete;
24
+ /**
25
+ * Invalidate all cache entries whose key contains the given substring.
26
+ * Used when a file is written/edited — invalidate any cached reads for that path.
27
+ */
28
+ invalidate(pattern: string): void;
29
+ /** Clear entire cache */
30
+ clear(): void;
31
+ /** Number of entries currently cached */
32
+ get size(): number;
33
+ /** Total bytes currently cached */
34
+ get bytes(): number;
35
+ }
36
+ //# sourceMappingURL=cache.d.ts.map
package/dist/cache.js ADDED
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ /**
3
+ * LRU Tool Result Cache with TTL
4
+ *
5
+ * Caches results from read-only tools (read_file, grep, glob, etc.)
6
+ * to avoid redundant I/O. Invalidates on writes to affected paths.
7
+ *
8
+ * @since v1.5.0
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.ToolCache = void 0;
12
+ class ToolCache {
13
+ cache = new Map();
14
+ maxSize;
15
+ currentSize = 0;
16
+ /** Default TTLs per tool (ms) */
17
+ static TTL = {
18
+ read_file: 30_000,
19
+ grep: 30_000,
20
+ glob: 30_000,
21
+ code_analysis: 60_000,
22
+ code_review: 60_000,
23
+ image_info: 60_000,
24
+ };
25
+ constructor(maxSizeBytes = 50 * 1024 * 1024) {
26
+ this.maxSize = maxSizeBytes;
27
+ }
28
+ /** Build a deterministic cache key from tool name + sorted args */
29
+ static key(toolName, args) {
30
+ const sorted = Object.keys(args)
31
+ .sort()
32
+ .map(k => `${k}=${JSON.stringify(args[k])}`)
33
+ .join('&');
34
+ return `${toolName}:${sorted}`;
35
+ }
36
+ /** Get a cached value, or null if expired/missing */
37
+ get(key) {
38
+ const entry = this.cache.get(key);
39
+ if (!entry)
40
+ return null;
41
+ if (Date.now() > entry.expires) {
42
+ this.delete(key);
43
+ return null;
44
+ }
45
+ // Move to end (most recently used) — Map preserves insertion order
46
+ this.cache.delete(key);
47
+ this.cache.set(key, entry);
48
+ return entry.value;
49
+ }
50
+ /** Store a value with TTL */
51
+ set(key, value, ttlMs) {
52
+ // Delete existing entry first (update size tracking)
53
+ this.delete(key);
54
+ const size = key.length + value.length;
55
+ // Don't cache if single entry exceeds 10% of max
56
+ if (size > this.maxSize * 0.1)
57
+ return;
58
+ // Evict LRU entries until we have room
59
+ while (this.currentSize + size > this.maxSize && this.cache.size > 0) {
60
+ const oldest = this.cache.keys().next().value;
61
+ if (oldest !== undefined) {
62
+ this.delete(oldest);
63
+ }
64
+ }
65
+ this.cache.set(key, {
66
+ value,
67
+ expires: Date.now() + ttlMs,
68
+ size,
69
+ });
70
+ this.currentSize += size;
71
+ }
72
+ /** Remove a specific key */
73
+ delete(key) {
74
+ const entry = this.cache.get(key);
75
+ if (entry) {
76
+ this.currentSize -= entry.size;
77
+ this.cache.delete(key);
78
+ }
79
+ }
80
+ /**
81
+ * Invalidate all cache entries whose key contains the given substring.
82
+ * Used when a file is written/edited — invalidate any cached reads for that path.
83
+ */
84
+ invalidate(pattern) {
85
+ for (const key of [...this.cache.keys()]) {
86
+ if (key.includes(pattern)) {
87
+ this.delete(key);
88
+ }
89
+ }
90
+ }
91
+ /** Clear entire cache */
92
+ clear() {
93
+ this.cache.clear();
94
+ this.currentSize = 0;
95
+ }
96
+ /** Number of entries currently cached */
97
+ get size() {
98
+ return this.cache.size;
99
+ }
100
+ /** Total bytes currently cached */
101
+ get bytes() {
102
+ return this.currentSize;
103
+ }
104
+ }
105
+ exports.ToolCache = ToolCache;
106
+ //# sourceMappingURL=cache.js.map
package/dist/cli.js CHANGED
@@ -44,7 +44,7 @@ const setup_1 = require("./setup");
44
44
  const banner_1 = require("./banner");
45
45
  const tools_1 = require("./tools");
46
46
  const scheduler_1 = require("./scheduler");
47
- const VERSION = '1.4.2';
47
+ const VERSION = '1.5.0';
48
48
  // Session-wide token tracking
49
49
  let sessionTokens = { input: 0, output: 0, total: 0 };
50
50
  const C = {
@@ -14,9 +14,16 @@ export declare class ContextManager {
14
14
  availableTokens(): number;
15
15
  /** Check if messages fit within budget */
16
16
  fitsInBudget(messages: Message[]): boolean;
17
- /** Compact conversation by dropping old messages and inserting a summary placeholder */
17
+ /**
18
+ * Group messages into atomic blocks that must never be split.
19
+ * An assistant message with tool_calls + its following tool responses = one block.
20
+ * All other messages are individual blocks.
21
+ * This prevents compaction from creating orphaned tool messages.
22
+ */
23
+ private groupMessages;
24
+ /** Compact conversation by dropping old messages. Never splits tool_call groups. */
18
25
  compact(messages: Message[], force?: boolean): Message[];
19
- /** Smart compaction: use LLM to summarize dropped messages instead of just discarding */
26
+ /** Smart compaction: use LLM to summarize dropped messages. Never splits tool_call groups. */
20
27
  compactWithSummary(messages: Message[]): Promise<{
21
28
  messages: Message[];
22
29
  summary: string;
@@ -29,23 +29,55 @@ class ContextManager {
29
29
  const total = messages.reduce((sum, m) => sum + this.estimateTokens(m.content), 0);
30
30
  return total <= this.availableTokens();
31
31
  }
32
- /** Compact conversation by dropping old messages and inserting a summary placeholder */
32
+ /**
33
+ * Group messages into atomic blocks that must never be split.
34
+ * An assistant message with tool_calls + its following tool responses = one block.
35
+ * All other messages are individual blocks.
36
+ * This prevents compaction from creating orphaned tool messages.
37
+ */
38
+ groupMessages(messages) {
39
+ const groups = [];
40
+ let i = 0;
41
+ while (i < messages.length) {
42
+ const msg = messages[i];
43
+ if (msg.role === 'assistant' && msg.tool_calls?.length) {
44
+ // Start of a tool_call group — keep assistant + all following tool messages together
45
+ const group = [msg];
46
+ i++;
47
+ while (i < messages.length && messages[i].role === 'tool') {
48
+ group.push(messages[i]);
49
+ i++;
50
+ }
51
+ groups.push(group);
52
+ }
53
+ else {
54
+ groups.push([msg]);
55
+ i++;
56
+ }
57
+ }
58
+ return groups;
59
+ }
60
+ /** Compact conversation by dropping old messages. Never splits tool_call groups. */
33
61
  compact(messages, force = false) {
34
62
  if (!force && this.fitsInBudget(messages))
35
63
  return messages;
36
64
  const system = messages[0]?.role === 'system' ? messages[0] : null;
37
65
  const rest = system ? messages.slice(1) : [...messages];
38
- // Keep recent messages that fit within 80% of budget
39
- const kept = [];
66
+ // Group messages into atomic blocks (assistant + tool responses stay together)
67
+ const groups = this.groupMessages(rest);
68
+ // Keep recent groups that fit within 80% of budget
69
+ const keptGroups = [];
40
70
  let tokenCount = 0;
41
71
  const budget = this.availableTokens();
42
- for (let i = rest.length - 1; i >= 0; i--) {
43
- const msgTokens = this.estimateTokens(rest[i].content);
44
- if (tokenCount + msgTokens > budget * 0.8)
72
+ for (let i = groups.length - 1; i >= 0; i--) {
73
+ const group = groups[i];
74
+ const groupTokens = group.reduce((sum, m) => sum + this.estimateTokens(m.content), 0);
75
+ if (tokenCount + groupTokens > budget * 0.8)
45
76
  break;
46
- kept.unshift(rest[i]);
47
- tokenCount += msgTokens;
77
+ keptGroups.unshift(group);
78
+ tokenCount += groupTokens;
48
79
  }
80
+ const kept = keptGroups.flat();
49
81
  const dropped = rest.length - kept.length;
50
82
  if (dropped > 0) {
51
83
  kept.unshift({
@@ -57,21 +89,25 @@ class ContextManager {
57
89
  kept.unshift(system);
58
90
  return kept;
59
91
  }
60
- /** Smart compaction: use LLM to summarize dropped messages instead of just discarding */
92
+ /** Smart compaction: use LLM to summarize dropped messages. Never splits tool_call groups. */
61
93
  async compactWithSummary(messages) {
62
94
  const system = messages[0]?.role === 'system' ? messages[0] : null;
63
95
  const rest = system ? messages.slice(1) : [...messages];
64
- // Determine which messages to keep vs summarize
65
- const kept = [];
96
+ // Group messages into atomic blocks
97
+ const groups = this.groupMessages(rest);
98
+ // Keep recent groups that fit within 80% of budget
99
+ const keptGroups = [];
66
100
  let tokenCount = 0;
67
101
  const budget = this.availableTokens();
68
- for (let i = rest.length - 1; i >= 0; i--) {
69
- const msgTokens = this.estimateTokens(rest[i].content);
70
- if (tokenCount + msgTokens > budget * 0.8)
102
+ for (let i = groups.length - 1; i >= 0; i--) {
103
+ const group = groups[i];
104
+ const groupTokens = group.reduce((sum, m) => sum + this.estimateTokens(m.content), 0);
105
+ if (tokenCount + groupTokens > budget * 0.8)
71
106
  break;
72
- kept.unshift(rest[i]);
73
- tokenCount += msgTokens;
107
+ keptGroups.unshift(group);
108
+ tokenCount += groupTokens;
74
109
  }
110
+ const kept = keptGroups.flat();
75
111
  const droppedCount = rest.length - kept.length;
76
112
  if (droppedCount === 0) {
77
113
  return { messages, summary: '' };
package/dist/history.d.ts CHANGED
@@ -15,9 +15,9 @@ export declare class SessionManager {
15
15
  getId(): string;
16
16
  /** Append a message to the session file */
17
17
  save(message: Message): void;
18
- /** Save all messages (overwrite) */
18
+ /** Save all messages (atomic overwrite via temp file + rename) */
19
19
  saveAll(messages: Message[]): void;
20
- /** Load messages from a session file */
20
+ /** Load messages from a session file (skips malformed lines) */
21
21
  load(): Message[];
22
22
  /** List recent sessions */
23
23
  static list(limit?: number): SessionMeta[];
package/dist/history.js CHANGED
@@ -54,31 +54,57 @@ class SessionManager {
54
54
  }
55
55
  /** Append a message to the session file */
56
56
  save(message) {
57
- const line = JSON.stringify({
58
- ...message,
59
- _ts: new Date().toISOString(),
60
- _model: this.model,
61
- });
62
- fs.appendFileSync(this.filePath, line + '\n');
57
+ try {
58
+ const line = JSON.stringify({
59
+ ...message,
60
+ _ts: new Date().toISOString(),
61
+ _model: this.model,
62
+ });
63
+ fs.appendFileSync(this.filePath, line + '\n');
64
+ }
65
+ catch {
66
+ // Don't crash on write failure — session persistence is best-effort
67
+ }
63
68
  }
64
- /** Save all messages (overwrite) */
69
+ /** Save all messages (atomic overwrite via temp file + rename) */
65
70
  saveAll(messages) {
66
- const lines = messages.map(m => JSON.stringify({ ...m, _ts: new Date().toISOString(), _model: this.model }));
67
- fs.writeFileSync(this.filePath, lines.join('\n') + '\n');
71
+ try {
72
+ const lines = messages.map(m => JSON.stringify({ ...m, _ts: new Date().toISOString(), _model: this.model }));
73
+ const tmpPath = this.filePath + '.tmp';
74
+ fs.writeFileSync(tmpPath, lines.join('\n') + '\n');
75
+ fs.renameSync(tmpPath, this.filePath);
76
+ }
77
+ catch {
78
+ // Don't crash — session persistence is best-effort
79
+ }
68
80
  }
69
- /** Load messages from a session file */
81
+ /** Load messages from a session file (skips malformed lines) */
70
82
  load() {
71
83
  if (!fs.existsSync(this.filePath))
72
84
  return [];
73
- const content = fs.readFileSync(this.filePath, 'utf-8').trim();
85
+ let content;
86
+ try {
87
+ content = fs.readFileSync(this.filePath, 'utf-8').trim();
88
+ }
89
+ catch {
90
+ return [];
91
+ }
74
92
  if (!content)
75
93
  return [];
76
- return content.split('\n').map(line => {
77
- const obj = JSON.parse(line);
78
- delete obj._ts;
79
- delete obj._model;
80
- return obj;
81
- });
94
+ const messages = [];
95
+ for (const line of content.split('\n')) {
96
+ try {
97
+ const obj = JSON.parse(line);
98
+ delete obj._ts;
99
+ delete obj._model;
100
+ messages.push(obj);
101
+ }
102
+ catch {
103
+ // Skip malformed line — don't crash the whole load
104
+ continue;
105
+ }
106
+ }
107
+ return messages;
82
108
  }
83
109
  /** List recent sessions */
84
110
  static list(limit = 10) {
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Per-tool Rate Limiter
3
+ *
4
+ * Simple sliding-window throttle that enforces minimum intervals between
5
+ * calls to the same tool. Prevents hammering web services and self-DOS.
6
+ *
7
+ * @since v1.5.0
8
+ */
9
+ export declare class RateLimiter {
10
+ private lastCall;
11
+ /** Minimum interval (ms) between calls per tool */
12
+ private limits;
13
+ constructor(overrides?: Record<string, number>);
14
+ /** Wait if needed to respect the tool's rate limit */
15
+ throttle(toolName: string): Promise<void>;
16
+ /** Get the configured limit for a tool (0 = no limit) */
17
+ getLimit(toolName: string): number;
18
+ /** Update a tool's rate limit at runtime */
19
+ setLimit(toolName: string, intervalMs: number): void;
20
+ /** Reset all tracking (useful for tests) */
21
+ reset(): void;
22
+ }
23
+ //# sourceMappingURL=rate-limiter.d.ts.map
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ /**
3
+ * Per-tool Rate Limiter
4
+ *
5
+ * Simple sliding-window throttle that enforces minimum intervals between
6
+ * calls to the same tool. Prevents hammering web services and self-DOS.
7
+ *
8
+ * @since v1.5.0
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.RateLimiter = void 0;
12
+ class RateLimiter {
13
+ lastCall = new Map();
14
+ /** Minimum interval (ms) between calls per tool */
15
+ limits = {
16
+ browser: 200,
17
+ web_fetch: 500,
18
+ web_search: 1000,
19
+ execute: 100,
20
+ };
21
+ constructor(overrides) {
22
+ if (overrides) {
23
+ Object.assign(this.limits, overrides);
24
+ }
25
+ }
26
+ /** Wait if needed to respect the tool's rate limit */
27
+ async throttle(toolName) {
28
+ const limit = this.limits[toolName];
29
+ if (!limit)
30
+ return; // no limit for this tool
31
+ const last = this.lastCall.get(toolName) || 0;
32
+ const elapsed = Date.now() - last;
33
+ if (elapsed < limit) {
34
+ await new Promise(resolve => setTimeout(resolve, limit - elapsed));
35
+ }
36
+ this.lastCall.set(toolName, Date.now());
37
+ }
38
+ /** Get the configured limit for a tool (0 = no limit) */
39
+ getLimit(toolName) {
40
+ return this.limits[toolName] || 0;
41
+ }
42
+ /** Update a tool's rate limit at runtime */
43
+ setLimit(toolName, intervalMs) {
44
+ this.limits[toolName] = intervalMs;
45
+ }
46
+ /** Reset all tracking (useful for tests) */
47
+ reset() {
48
+ this.lastCall.clear();
49
+ }
50
+ }
51
+ exports.RateLimiter = RateLimiter;
52
+ //# sourceMappingURL=rate-limiter.js.map
@@ -42,6 +42,7 @@ const fs = __importStar(require("fs"));
42
42
  // Shared browser instance across tool calls
43
43
  let client = null;
44
44
  let debugPort = 9222;
45
+ let connectingPromise = null;
45
46
  const CHROME_DATA_DIR = path.join(os.homedir(), '.codebot', 'chrome-profile');
46
47
  /** Kill any Chrome using our debug port or data dir (but NEVER kill ourselves) */
47
48
  function killExistingChrome() {
@@ -71,8 +72,21 @@ function killExistingChrome() {
71
72
  }
72
73
  }
73
74
  async function ensureConnected() {
75
+ // Fast path: already connected
74
76
  if (client?.isConnected())
75
77
  return client;
78
+ // Mutex: if another call is already connecting, reuse that promise
79
+ if (connectingPromise)
80
+ return connectingPromise;
81
+ connectingPromise = doConnect();
82
+ try {
83
+ return await connectingPromise;
84
+ }
85
+ finally {
86
+ connectingPromise = null;
87
+ }
88
+ }
89
+ async function doConnect() {
76
90
  // Try connecting to existing Chrome with debug port
77
91
  try {
78
92
  const wsUrl = await (0, cdp_1.getDebuggerUrl)(debugPort);
@@ -172,10 +186,11 @@ async function ensureConnected() {
172
186
  'Or on macOS:\n' +
173
187
  ` /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=${debugPort}`);
174
188
  }
175
- // Wait for Chrome to start
176
- for (let i = 0; i < 10; i++) {
189
+ // Wait for Chrome to start — exponential backoff: 500ms, 1s, 2s, 4s
190
+ const backoffDelays = [500, 1000, 2000, 4000, 4000, 4000];
191
+ for (let i = 0; i < backoffDelays.length; i++) {
177
192
  try {
178
- await new Promise(r => setTimeout(r, 500));
193
+ await new Promise(r => setTimeout(r, backoffDelays[i]));
179
194
  const wsUrl = await (0, cdp_1.getDebuggerUrl)(debugPort);
180
195
  client = new cdp_1.CDPClient();
181
196
  await client.connect(wsUrl);
@@ -3,6 +3,7 @@ export declare class CodeAnalysisTool implements Tool {
3
3
  name: string;
4
4
  description: string;
5
5
  permission: Tool['permission'];
6
+ cacheable: boolean;
6
7
  parameters: {
7
8
  type: string;
8
9
  properties: {
@@ -40,6 +40,7 @@ class CodeAnalysisTool {
40
40
  name = 'code_analysis';
41
41
  description = 'Analyze code structure. Actions: symbols (list classes/functions/exports), imports (list imports), outline (file structure), references (find where a symbol is used).';
42
42
  permission = 'auto';
43
+ cacheable = true;
43
44
  parameters = {
44
45
  type: 'object',
45
46
  properties: {
@@ -3,6 +3,7 @@ export declare class CodeReviewTool implements Tool {
3
3
  name: string;
4
4
  description: string;
5
5
  permission: Tool['permission'];
6
+ cacheable: boolean;
6
7
  parameters: {
7
8
  type: string;
8
9
  properties: {
@@ -52,6 +52,7 @@ class CodeReviewTool {
52
52
  name = 'code_review';
53
53
  description = 'Review code for security issues, complexity, and code smells. Actions: security, complexity, review (full).';
54
54
  permission = 'auto';
55
+ cacheable = true;
55
56
  parameters = {
56
57
  type: 'object',
57
58
  properties: {
@@ -3,6 +3,7 @@ export declare class GlobTool implements Tool {
3
3
  name: string;
4
4
  description: string;
5
5
  permission: Tool['permission'];
6
+ cacheable: boolean;
6
7
  parameters: {
7
8
  type: string;
8
9
  properties: {
@@ -40,6 +40,7 @@ class GlobTool {
40
40
  name = 'glob';
41
41
  description = 'Find files matching a glob pattern. Returns matching file paths relative to the search directory.';
42
42
  permission = 'auto';
43
+ cacheable = true;
43
44
  parameters = {
44
45
  type: 'object',
45
46
  properties: {
@@ -3,6 +3,7 @@ export declare class GrepTool implements Tool {
3
3
  name: string;
4
4
  description: string;
5
5
  permission: Tool['permission'];
6
+ cacheable: boolean;
6
7
  parameters: {
7
8
  type: string;
8
9
  properties: {
@@ -40,6 +40,7 @@ class GrepTool {
40
40
  name = 'grep';
41
41
  description = 'Search file contents for a regex pattern. Returns matching lines with file paths and line numbers.';
42
42
  permission = 'auto';
43
+ cacheable = true;
43
44
  parameters = {
44
45
  type: 'object',
45
46
  properties: {
@@ -3,6 +3,7 @@ export declare class ImageInfoTool implements Tool {
3
3
  name: string;
4
4
  description: string;
5
5
  permission: Tool['permission'];
6
+ cacheable: boolean;
6
7
  parameters: {
7
8
  type: string;
8
9
  properties: {
@@ -40,6 +40,7 @@ class ImageInfoTool {
40
40
  name = 'image_info';
41
41
  description = 'Get image file information — dimensions, format, file size. Supports PNG, JPEG, GIF, BMP, SVG.';
42
42
  permission = 'auto';
43
+ cacheable = true;
43
44
  parameters = {
44
45
  type: 'object',
45
46
  properties: {
@@ -3,6 +3,7 @@ export declare class ReadFileTool implements Tool {
3
3
  name: string;
4
4
  description: string;
5
5
  permission: Tool['permission'];
6
+ cacheable: boolean;
6
7
  parameters: {
7
8
  type: string;
8
9
  properties: {
@@ -40,6 +40,7 @@ class ReadFileTool {
40
40
  name = 'read_file';
41
41
  description = 'Read the contents of a file. Returns file content with line numbers.';
42
42
  permission = 'auto';
43
+ cacheable = true;
43
44
  parameters = {
44
45
  type: 'object',
45
46
  properties: {
package/dist/types.d.ts CHANGED
@@ -23,6 +23,7 @@ export interface Tool {
23
23
  description: string;
24
24
  parameters: Record<string, unknown>;
25
25
  permission: 'auto' | 'prompt' | 'always-ask';
26
+ cacheable?: boolean;
26
27
  execute(args: Record<string, unknown>): Promise<string>;
27
28
  }
28
29
  export interface ProviderConfig {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebot-ai",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
4
4
  "description": "Zero-dependency autonomous AI agent. Code, browse, search, automate. Works with any LLM — Ollama, Claude, GPT, Gemini, DeepSeek, Groq, Mistral, Grok.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",