codemini-cli 0.2.2 → 0.2.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codemini-cli",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Coding CLI optimized for small-model workflows and Windows PowerShell",
5
5
  "keywords": [
6
6
  "cli",
@@ -20,18 +20,21 @@ Routing:
20
20
 
21
21
  2. If the goal is clear but there are multiple reasonable implementation paths:
22
22
  - use `brainstorm`
23
- - give 2-3 short options
24
- - do not choose for the user unless the user explicitly asks for a recommendation
23
+ - ask exactly one clarifying question first
24
+ - do not give options, recommendations, or a tentative solution in the same response
25
+ - stop after the question and wait for the user's answer before continuing
25
26
 
26
27
  3. If the request is still missing a key constraint or success condition:
27
28
  - ask exactly one clarifying question
28
29
  - do not give options yet
29
30
  - do not write code yet
31
+ - stop after the question and wait for the user's answer
30
32
 
31
33
  4. If the request is greenfield and underspecified, such as "build a page", "make a site", "generate an app", or similar:
32
34
  - treat it as missing key constraints by default
33
35
  - ask one high-value question before coding
34
36
  - do not assume features, storage model, or scope unless the user already gave them
37
+ - stop after the question and wait for the user's answer
35
38
 
36
39
  Tool order:
37
40
  - prefer `grep` first for content search and candidate discovery
package/src/cli.js CHANGED
@@ -4,7 +4,7 @@ import { handleConfig } from './commands/config.js';
4
4
  import { handleDoctor } from './commands/doctor.js';
5
5
  import { handleSkill } from './commands/skill.js';
6
6
 
7
- const VERSION = '0.2.2';
7
+ const VERSION = '0.2.3';
8
8
 
9
9
  function printHelp() {
10
10
  console.log(`codemini ${VERSION}
@@ -35,7 +35,7 @@ export async function handleRun(args) {
35
35
  }
36
36
 
37
37
  const config = await loadConfig();
38
- const { definitions, handlers } = getBuiltinTools({
38
+ const { definitions, handlers, formatters, deferredDefinitions } = getBuiltinTools({
39
39
  workspaceRoot: process.cwd(),
40
40
  config
41
41
  });
@@ -47,6 +47,8 @@ export async function handleRun(args) {
47
47
  model: parsed.model || config.model.name,
48
48
  toolDefinitions: definitions,
49
49
  toolHandlers: handlers,
50
+ toolFormatters: formatters,
51
+ deferredDefinitions,
50
52
  maxSteps: parsed.maxSteps,
51
53
  requestCompletion: async ({ messages, tools, model }) =>
52
54
  createChatCompletion({
@@ -1,3 +1,7 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs/promises';
4
+
1
5
  function safeJsonParse(raw) {
2
6
  if (!raw || typeof raw !== 'string') return {};
3
7
  try {
@@ -13,7 +17,198 @@ function clipToolResult(result, maxChars = 12000) {
13
17
  return `${raw.slice(0, maxChars)}\n... [tool result truncated ${raw.length - maxChars} chars]`;
14
18
  }
15
19
 
16
- function summarizeToolResult(result) {
20
+ function compactToolResult(result, toolName, args, maxChars = 12000) {
21
+ if (result === null || result === undefined) return 'no output';
22
+ if (typeof result === 'string') {
23
+ if (result.length <= maxChars) return result;
24
+ return `${result.slice(0, maxChars)}\n... [tool result truncated ${result.length - maxChars} chars, original: ${result.length}]`;
25
+ }
26
+ if (typeof result !== 'object') return String(result);
27
+
28
+ const obj = result;
29
+ const rawLen = JSON.stringify(obj).length;
30
+
31
+ // Read file result: { path, phase, content, ... }
32
+ if ('path' in obj && 'phase' in obj && obj.phase === 'content') {
33
+ const header = `[File: ${obj.path}, lines ${obj.start_line || 1}-${obj.end_line || '?'}${obj.total_lines ? ` of ${obj.total_lines}` : ''}${obj.truncated ? ', truncated' : ''}]`;
34
+ const content = obj.content || obj.text || '';
35
+ if (typeof content !== 'string' || content.length <= maxChars) {
36
+ const body = typeof content === 'string' ? content : JSON.stringify(content);
37
+ return body.length <= maxChars ? `${header}\n${body}` : `${header}\n${body.slice(0, maxChars)}\n... [omitted ${body.length - maxChars} chars, original: ${rawLen}]`;
38
+ }
39
+ // Keep head + tail
40
+ const headLen = Math.floor(maxChars * 0.6);
41
+ const tailLen = Math.floor(maxChars * 0.3);
42
+ return `${header}\n${content.slice(0, headLen)}\n... [omitted ${content.length - headLen - tailLen} chars] ...\n${content.slice(-tailLen)}\n[original: ${rawLen} chars]`;
43
+ }
44
+
45
+ // File edit/write result: { path, action, ... }
46
+ if ('path' in obj && 'action' in obj) {
47
+ const summary = summarizeToolResult(obj);
48
+ const diff = obj.diff || obj.patch || obj.content_preview || '';
49
+ if (diff && typeof diff === 'string' && diff.length <= 800) {
50
+ return `${summary}\n${diff}`;
51
+ }
52
+ if (diff) {
53
+ return `${summary}\n${diff.slice(0, 800)}\n... [diff truncated, original: ${rawLen}]`;
54
+ }
55
+ return `${summary} [original: ${rawLen} chars]`;
56
+ }
57
+
58
+ // Shell command result: { stdout, stderr, code, ... }
59
+ if ('stdout' in obj || 'stderr' in obj || 'code' in obj) {
60
+ const command = String(obj.command || '').slice(0, 200);
61
+ const stdout = String(obj.stdout || '').slice(0, 500);
62
+ const stderr = String(obj.stderr || '').slice(0, 500);
63
+ const code = obj.code ?? 0;
64
+ const parts = [`[exit: ${code}]`];
65
+ if (command) parts.push(`command: ${command}`);
66
+ if (stdout) parts.push(`stdout:\n${stdout}`);
67
+ if (stderr) parts.push(`stderr:\n${stderr}`);
68
+ if (rawLen > 2000) parts.push(`[original: ${rawLen} chars]`);
69
+ return parts.join('\n');
70
+ }
71
+
72
+ // Array results (file lists, grep results, etc.)
73
+ if (Array.isArray(obj)) {
74
+ const maxItems = 50;
75
+ if (obj.length <= maxItems) {
76
+ const serialized = JSON.stringify(obj);
77
+ return serialized.length <= maxChars ? serialized : clipToolResult(obj, maxChars);
78
+ }
79
+ const kept = obj.slice(0, maxItems);
80
+ const items = typeof kept[0] === 'string'
81
+ ? kept.join('\n')
82
+ : kept.map((item) => JSON.stringify(item)).join('\n');
83
+ return `${items}\n... and ${obj.length - maxItems} more items [total: ${obj.length}, original: ${rawLen} chars]`;
84
+ }
85
+
86
+ // Patch result: { files: [...] }
87
+ if ('files' in obj && Array.isArray(obj.files)) {
88
+ return `patched ${obj.files.length} file(s): ${obj.files.slice(0, 10).join(', ')}${obj.files.length > 10 ? ` ... and ${obj.files.length - 10} more` : ''} [original: ${rawLen}]`;
89
+ }
90
+
91
+ // Task results
92
+ if ('created' in obj && Array.isArray(obj.created)) {
93
+ return `created ${obj.created.length} task(s)`;
94
+ }
95
+ if ('tasks' in obj && Array.isArray(obj.tasks)) {
96
+ return `${obj.tasks.length} task(s)`;
97
+ }
98
+
99
+ // Fallback: clip with reduced limit
100
+ return clipToolResult(obj, Math.min(maxChars, 4000));
101
+ }
102
+
103
+ // ─── P0: Large result disk store ─────────────────────────────────────
104
+
105
+ const TOOL_RESULT_DISK_THRESHOLD = 6000;
106
+ const PREVIEW_SIZE_BYTES = 2000;
107
+ const TOOL_RESULTS_SUBDIR = 'tool-results';
108
+
109
+ let currentResultDir = null;
110
+ let resultDirReady = false;
111
+ const storedResults = new Map(); // callId -> { filePath, summary }
112
+ const readCache = new Map(); // "path:startLine:endLine:mtimeMs" -> true
113
+
114
+ function generatePreview(content) {
115
+ if (content.length <= PREVIEW_SIZE_BYTES) {
116
+ return { preview: content, hasMore: false };
117
+ }
118
+ const truncated = content.slice(0, PREVIEW_SIZE_BYTES);
119
+ const lastNewline = truncated.lastIndexOf('\n');
120
+ const cutPoint = lastNewline > PREVIEW_SIZE_BYTES * 0.5 ? lastNewline : PREVIEW_SIZE_BYTES;
121
+ return { preview: content.slice(0, cutPoint), hasMore: true };
122
+ }
123
+
124
+ function formatFileSize(chars) {
125
+ if (chars < 1024) return `${chars} B`;
126
+ return `${(chars / 1024).toFixed(1)} KB`;
127
+ }
128
+
129
+ export function setResultDir(dir) {
130
+ currentResultDir = dir ? path.join(dir, TOOL_RESULTS_SUBDIR) : null;
131
+ resultDirReady = false;
132
+ }
133
+
134
+ async function ensureResultDir() {
135
+ if (!currentResultDir) return false;
136
+ if (!resultDirReady) {
137
+ await fs.mkdir(currentResultDir, { recursive: true });
138
+ resultDirReady = true;
139
+ }
140
+ return true;
141
+ }
142
+
143
+ async function storeResultIfNeeded(callId, formattedContent, rawResult) {
144
+ if (formattedContent.length <= TOOL_RESULT_DISK_THRESHOLD) {
145
+ return formattedContent;
146
+ }
147
+ try {
148
+ const ready = await ensureResultDir();
149
+ const dir = ready ? currentResultDir : path.join(os.tmpdir(), 'codemini-results');
150
+ if (!resultDirReady && dir === currentResultDir) {
151
+ await fs.mkdir(dir, { recursive: true });
152
+ } else if (!resultDirReady) {
153
+ await fs.mkdir(dir, { recursive: true });
154
+ }
155
+ const filePath = path.join(dir, `${callId}.txt`);
156
+ const payload = typeof rawResult === 'string' ? rawResult : JSON.stringify(rawResult, null, 2);
157
+ await fs.writeFile(filePath, payload, 'utf-8');
158
+ const summary = summarizeToolResult(rawResult);
159
+ const { preview, hasMore } = generatePreview(payload);
160
+ storedResults.set(callId, { filePath, summary });
161
+
162
+ return `<persisted-output>
163
+ Output too large (${formatFileSize(payload.length)}). Full output saved to: ${filePath}
164
+
165
+ Preview (first ${formatFileSize(PREVIEW_SIZE_BYTES)}):
166
+ ${preview}${hasMore ? '\n...' : ''}
167
+
168
+ Summary: ${summary}
169
+ </persisted-output>`;
170
+ } catch {
171
+ return formattedContent;
172
+ }
173
+ }
174
+
175
+ export function clearResultStore() {
176
+ const files = [];
177
+ for (const [, val] of storedResults) {
178
+ files.push(val.filePath);
179
+ }
180
+ storedResults.clear();
181
+ readCache.clear();
182
+ return Promise.allSettled(files.map((f) => fs.unlink(f).catch(() => {})));
183
+ }
184
+
185
+ // ─── Read deduplication ─────────────────────────────────────────────
186
+
187
+ export function checkReadDedup(filePath, startLine, endLine, mtimeMs) {
188
+ const key = `${filePath}:${startLine || 0}:${endLine || 0}:${mtimeMs}`;
189
+ if (readCache.has(key)) {
190
+ return true;
191
+ }
192
+ readCache.set(key, true);
193
+ // Keep cache bounded
194
+ if (readCache.size > 100) {
195
+ const firstKey = readCache.keys().next().value;
196
+ readCache.delete(firstKey);
197
+ }
198
+ return false;
199
+ }
200
+
201
+ // ─── P1a: Read-only tool classification ──────────────────────────────
202
+
203
+ const READ_ONLY_TOOLS = new Set([
204
+ 'read', 'grep', 'glob', 'list',
205
+ 'ast_query', 'read_ast_node', 'generate_diff',
206
+ 'list_services', 'get_service_status', 'get_service_logs'
207
+ ]);
208
+
209
+ // ─── Exported helpers ────────────────────────────────────────────────
210
+
211
+ export function summarizeToolResult(result) {
17
212
  if (result === null || result === undefined) return 'no output';
18
213
  if (typeof result === 'string') {
19
214
  const oneLine = result.replace(/\s+/g, ' ').trim();
@@ -106,7 +301,7 @@ function summarizeToolResult(result) {
106
301
  return String(result);
107
302
  }
108
303
 
109
- function trimInline(value, maxLen = 72) {
304
+ export function trimInline(value, maxLen = 72) {
110
305
  const s = String(value || '').replace(/\s+/g, ' ').trim();
111
306
  if (!s) return '';
112
307
  if (s.length <= maxLen) return s;
@@ -171,6 +366,18 @@ function formatToolDisplayName(name, args) {
171
366
  return name;
172
367
  }
173
368
 
369
+ // ─── Format a single tool result using per-tool formatter or fallback ──
370
+
371
+ function formatToolResult(toolResult, toolName, args, toolFormatters, toolResultMaxChars) {
372
+ if (toolFormatters && typeof toolFormatters[toolName] === 'function') {
373
+ const formatted = toolFormatters[toolName](toolResult, args);
374
+ if (typeof formatted === 'string') return formatted;
375
+ }
376
+ return compactToolResult(toolResult, toolName, args, toolResultMaxChars);
377
+ }
378
+
379
+ // ─── Main agent loop ────────────────────────────────────────────────
380
+
174
381
  export async function runAgentLoop({
175
382
  systemPrompt,
176
383
  userPrompt,
@@ -184,7 +391,9 @@ export async function runAgentLoop({
184
391
  executionMode = 'auto',
185
392
  alwaysAllowTools = [],
186
393
  requestToolApproval,
187
- toolResultMaxChars = 12000
394
+ toolResultMaxChars = 12000,
395
+ toolFormatters = {},
396
+ deferredDefinitions = {}
188
397
  }) {
189
398
  const messages = [];
190
399
  if (systemPrompt) {
@@ -201,12 +410,15 @@ export async function runAgentLoop({
201
410
  let lastAssistantText = '';
202
411
  const alwaysAllowSet = new Set((Array.isArray(alwaysAllowTools) ? alwaysAllowTools : []).map((t) => String(t)));
203
412
 
413
+ // Mutable tool list — grows as tool_search loads deferred tools
414
+ const activeTools = [...toolDefinitions];
415
+
204
416
  for (let step = 0; step < maxSteps; step += 1) {
205
417
  if (onEvent) onEvent({ type: 'step:start', step: step + 1 });
206
418
  const completion = await requestCompletion({
207
419
  model,
208
420
  messages,
209
- tools: toolDefinitions
421
+ tools: activeTools
210
422
  });
211
423
 
212
424
  const toolCalls = Array.isArray(completion.toolCalls) ? completion.toolCalls : [];
@@ -242,11 +454,19 @@ export async function runAgentLoop({
242
454
  return { text: finalText.trim(), messages, steps: step + 1 };
243
455
  }
244
456
 
245
- for (const call of toolCalls) {
457
+ // ─── P1a: Partition into read-only (parallel) and write (serial) ──
458
+
459
+ const callsWithMeta = toolCalls.map((call) => {
246
460
  const args = safeJsonParse(call.arguments);
247
461
  const toolName = normalizeToolCallName(call.name);
248
462
  const displayName = formatToolDisplayName(toolName, args);
249
- const startedAt = Date.now();
463
+ const isReadOnly = READ_ONLY_TOOLS.has(toolName);
464
+ return { call, args, toolName, displayName, isReadOnly };
465
+ });
466
+
467
+ // Approval checks first — must be done synchronously before any execution
468
+ const approvalResults = new Map();
469
+ for (const { call, toolName, displayName, args } of callsWithMeta) {
250
470
  let approved = true;
251
471
  if (executionMode === 'normal' && !alwaysAllowSet.has(toolName)) {
252
472
  approved = false;
@@ -260,26 +480,23 @@ export async function runAgentLoop({
260
480
  approved = Boolean(decision?.approved);
261
481
  }
262
482
  }
483
+ approvalResults.set(call.id, approved);
484
+ }
485
+
486
+ // Collect results keyed by call.id, then write to messages in original order
487
+ const resultEntries = new Map(); // call.id -> { content, error? }
488
+
489
+ // Helper to execute a single tool call
490
+ async function executeOne({ call, args, toolName, displayName, isReadOnly }) {
491
+ const startedAt = Date.now();
263
492
 
264
- if (!approved) {
493
+ if (!approvalResults.get(call.id)) {
265
494
  if (onEvent) onEvent({ type: 'tool:blocked', name: displayName, id: call.id, arguments: args });
266
- const blockedMessage = {
267
- role: 'tool',
268
- tool_call_id: call.id,
269
- content: JSON.stringify({ blocked: true, reason: 'Tool call requires approval in normal mode' })
495
+ return {
496
+ callId: call.id,
497
+ content: JSON.stringify({ blocked: true, reason: 'Tool call requires approval in normal mode' }),
498
+ blocked: true
270
499
  };
271
- messages.push(blockedMessage);
272
- if (onEvent) {
273
- onEvent({
274
- type: 'tool:result',
275
- name: displayName,
276
- id: call.id,
277
- arguments: args,
278
- content: blockedMessage.content,
279
- blocked: true
280
- });
281
- }
282
- continue;
283
500
  }
284
501
 
285
502
  if (onEvent) onEvent({ type: 'tool:start', name: displayName, id: call.id, arguments: args });
@@ -287,6 +504,7 @@ export async function runAgentLoop({
287
504
  if (!handler) {
288
505
  throw new Error(`Unknown tool: ${call.name}`);
289
506
  }
507
+
290
508
  let toolResult;
291
509
  try {
292
510
  toolResult = await handler(args);
@@ -294,58 +512,81 @@ export async function runAgentLoop({
294
512
  const durationMs = Date.now() - startedAt;
295
513
  const message = error instanceof Error ? error.message : String(error);
296
514
  if (onEvent) {
297
- onEvent({
298
- type: 'tool:error',
299
- name: displayName,
300
- id: call.id,
301
- arguments: args,
302
- durationMs,
303
- summary: trimInline(message, 120)
304
- });
515
+ onEvent({ type: 'tool:error', name: displayName, id: call.id, arguments: args, durationMs, summary: trimInline(message, 120) });
305
516
  }
306
- const toolMessage = {
307
- role: 'tool',
308
- tool_call_id: call.id,
309
- content: clipToolResult({ error: message }, toolResultMaxChars)
517
+ return {
518
+ callId: call.id,
519
+ content: clipToolResult({ error: message }, toolResultMaxChars),
520
+ error: true
310
521
  };
311
- messages.push(toolMessage);
522
+ }
523
+
524
+ const durationMs = Date.now() - startedAt;
525
+ if (onEvent) {
526
+ onEvent({ type: 'tool:end', name: displayName, id: call.id, arguments: args, durationMs, summary: summarizeToolResult(toolResult) });
527
+ }
528
+
529
+ // P1b: Use per-tool formatter if available, else fallback
530
+ let formatted = formatToolResult(toolResult, toolName, args, toolFormatters, toolResultMaxChars);
531
+
532
+ // P2: If tool_search loaded deferred tools, inject their schemas into activeTools
533
+ if (toolName === 'tool_search' && toolResult && Array.isArray(toolResult.schemas)) {
534
+ for (const schema of toolResult.schemas) {
535
+ const name = schema?.function?.name;
536
+ if (name && !activeTools.some((t) => t?.function?.name === name)) {
537
+ activeTools.push(schema);
538
+ }
539
+ }
540
+ }
541
+
542
+ // P0: Persist to disk if still large
543
+ formatted = await storeResultIfNeeded(call.id, formatted, toolResult);
544
+
545
+ return { callId: call.id, content: formatted };
546
+ }
547
+
548
+ // Separate read-only and write calls, preserving order
549
+ const readOnlyCalls = callsWithMeta.filter((c) => c.isReadOnly && approvalResults.get(c.call.id));
550
+ const writeCalls = callsWithMeta.filter((c) => !c.isReadOnly || !approvalResults.get(c.call.id));
551
+
552
+ // Execute read-only calls in parallel
553
+ if (readOnlyCalls.length > 0) {
554
+ const readOnlyResults = await Promise.all(readOnlyCalls.map((c) => executeOne(c)));
555
+ for (const r of readOnlyResults) {
556
+ resultEntries.set(r.callId, r);
557
+ }
558
+ }
559
+
560
+ // Execute write calls serially
561
+ for (const c of writeCalls) {
562
+ const r = await executeOne(c);
563
+ resultEntries.set(r.callId, r);
564
+ }
565
+
566
+ // Write results to messages in original tool call order
567
+ for (const { call, displayName, args } of callsWithMeta) {
568
+ const entry = resultEntries.get(call.id);
569
+ if (!entry) continue;
570
+
571
+ if (entry.blocked) {
572
+ messages.push({ role: 'tool', tool_call_id: call.id, content: entry.content });
312
573
  if (onEvent) {
313
- onEvent({
314
- type: 'tool:result',
315
- name: displayName,
316
- id: call.id,
317
- arguments: args,
318
- content: toolMessage.content,
319
- error: true
320
- });
574
+ onEvent({ type: 'tool:result', name: displayName, id: call.id, arguments: args, content: entry.content, blocked: true });
321
575
  }
322
576
  continue;
323
577
  }
324
- const durationMs = Date.now() - startedAt;
325
- if (onEvent) {
326
- onEvent({
327
- type: 'tool:end',
328
- name: displayName,
329
- id: call.id,
330
- arguments: args,
331
- durationMs,
332
- summary: summarizeToolResult(toolResult)
333
- });
578
+
579
+ if (entry.error) {
580
+ messages.push({ role: 'tool', tool_call_id: call.id, content: entry.content });
581
+ if (onEvent) {
582
+ onEvent({ type: 'tool:result', name: displayName, id: call.id, arguments: args, content: entry.content, error: true });
583
+ }
584
+ continue;
334
585
  }
335
- const toolMessage = {
336
- role: 'tool',
337
- tool_call_id: call.id,
338
- content: clipToolResult(toolResult, toolResultMaxChars)
339
- };
340
- messages.push(toolMessage);
586
+
587
+ messages.push({ role: 'tool', tool_call_id: call.id, content: entry.content });
341
588
  if (onEvent) {
342
- onEvent({
343
- type: 'tool:result',
344
- name: displayName,
345
- id: call.id,
346
- arguments: args,
347
- content: toolMessage.content
348
- });
589
+ onEvent({ type: 'tool:result', name: displayName, id: call.id, arguments: args, content: entry.content });
349
590
  }
350
591
  }
351
592
  }
@@ -1,6 +1,6 @@
1
1
  import { parseInput } from './input-parser.js';
2
2
  import { loadCommandsAndSkills, renderCommandPrompt } from './command-loader.js';
3
- import { runAgentLoop } from './agent-loop.js';
3
+ import { runAgentLoop, setResultDir, clearResultStore } from './agent-loop.js';
4
4
  import fs from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
  import {
@@ -28,7 +28,7 @@ import {
28
28
  } from './context-compact.js';
29
29
  import { buildSystemPromptWithReplyLanguage } from './reply-language.js';
30
30
  import { buildSystemPromptWithSoul } from './soul.js';
31
- import { getProjectPlansDir, getProjectSpecsDir, getProjectWorkspaceDir } from './paths.js';
31
+ import { getProjectPlansDir, getProjectSpecsDir, getProjectWorkspaceDir, getSessionsDir } from './paths.js';
32
32
  import { buildProjectContextSnippet, initializeProjectIndex } from './project-index.js';
33
33
 
34
34
  function toOpenAIMessages(sessionMessages) {
@@ -1314,7 +1314,7 @@ async function askModel({
1314
1314
  ? `${systemPrompt}\n\n${projectContextSnippet}\n\nUse this project context as lightweight guidance. Prefer tools for fresh verification before assuming details.`
1315
1315
  : systemPrompt;
1316
1316
 
1317
- const { definitions, handlers } = getBuiltinTools({
1317
+ const { definitions, handlers, formatters, deferredDefinitions } = getBuiltinTools({
1318
1318
  workspaceRoot: process.cwd(),
1319
1319
  config,
1320
1320
  sessionId: session.id,
@@ -1376,6 +1376,8 @@ async function askModel({
1376
1376
  alwaysAllowTools:
1377
1377
  alwaysAllowTools || config.execution?.always_allow_tools || ['run', 'read', 'write'],
1378
1378
  toolResultMaxChars: config.context?.tool_result_max_chars || 12000,
1379
+ toolFormatters: formatters,
1380
+ deferredDefinitions,
1379
1381
  requestCompletion: async ({ messages, tools, model: selectedModel }) => {
1380
1382
  if (onAgentEvent) onAgentEvent({ type: 'assistant:start' });
1381
1383
  return createChatCompletionStream({
@@ -1727,6 +1729,10 @@ export async function createChatRuntime({
1727
1729
  const baseSystemPrompt = systemPrompt;
1728
1730
  let executionMode = config.execution?.mode || 'auto';
1729
1731
  const commands = await loadCommandsAndSkills();
1732
+
1733
+ // Set up tool result store under session directory
1734
+ const sessionResultsDir = path.join(getSessionsDir(), String(currentSession.id));
1735
+ setResultDir(sessionResultsDir);
1730
1736
  const compactState = {
1731
1737
  backupMessages: null,
1732
1738
  autoEnabled: true,
@@ -2492,6 +2498,7 @@ export async function createChatRuntime({
2492
2498
  if (!targetId) return { type: 'system', text: 'Usage: /history resume <session_id>' };
2493
2499
  const loaded = await loadSession(targetId);
2494
2500
  currentSession = loaded;
2501
+ setResultDir(path.join(getSessionsDir(), String(targetId)));
2495
2502
  if (!historyIdCache.includes(targetId)) historyIdCache.unshift(targetId);
2496
2503
  historySessionCache = [
2497
2504
  { id: targetId, messageCount: Array.isArray(loaded.messages) ? loaded.messages.length : 0 },
@@ -1,3 +1,5 @@
1
+ import { summarizeToolResult, trimInline } from './agent-loop.js';
2
+
1
3
  function textFromContent(content) {
2
4
  if (typeof content === 'string') return content;
3
5
  if (Array.isArray(content)) {
@@ -30,11 +32,39 @@ function modeToKeepRecent(mode) {
30
32
 
31
33
  function buildLocalSummary(messages) {
32
34
  const lines = [];
33
- const limit = 12;
35
+ const limit = 16;
34
36
  for (const msg of messages.slice(-limit)) {
37
+ if (msg.role === 'tool') {
38
+ // Try to parse tool result as JSON for semantic summary
39
+ const text = textFromContent(msg.content);
40
+ let parsed;
41
+ try { parsed = JSON.parse(text); } catch { parsed = null; }
42
+ if (parsed && typeof parsed === 'object') {
43
+ const summary = summarizeToolResult(parsed);
44
+ lines.push(`- tool_result: ${summary}`);
45
+ } else {
46
+ const clipped = text.length > 120 ? `${text.slice(0, 117)}...` : text;
47
+ lines.push(`- tool_result: ${clipped}`);
48
+ }
49
+ continue;
50
+ }
51
+ if (msg.role === 'assistant') {
52
+ const text = textFromContent(msg.content).replace(/\s+/g, ' ').trim();
53
+ const toolCallCount = Array.isArray(msg.tool_calls) ? msg.tool_calls.length : 0;
54
+ const toolInfo = toolCallCount > 0 ? ` [called ${toolCallCount} tool(s)]` : '';
55
+ const clipped = text.length > 300 ? `${text.slice(0, 297)}...` : text;
56
+ lines.push(`- assistant: ${clipped}${toolInfo}`);
57
+ continue;
58
+ }
59
+ if (msg.role === 'user') {
60
+ const text = textFromContent(msg.content).replace(/\s+/g, ' ').trim();
61
+ const clipped = text.length > 200 ? `${text.slice(0, 197)}...` : text;
62
+ lines.push(`- user: ${clipped}`);
63
+ continue;
64
+ }
35
65
  const text = textFromContent(msg.content).replace(/\s+/g, ' ').trim();
36
66
  if (!text) continue;
37
- const clipped = text.length > 160 ? `${text.slice(0, 160)}...` : text;
67
+ const clipped = text.length > 160 ? `${text.slice(0, 157)}...` : text;
38
68
  lines.push(`- ${msg.role}: ${clipped}`);
39
69
  }
40
70
  return `Context Summary\n${lines.join('\n')}`.trim();
@@ -1,5 +1,26 @@
1
+ import os from 'node:os';
2
+ import fs from 'node:fs';
1
3
  import { getShellSystemPrompt } from './shell-profile.js';
2
4
 
5
+ function getEnvBlock() {
6
+ const cwd = process.cwd();
7
+ let isGitRepo = false;
8
+ try {
9
+ fs.accessSync(`${cwd}/.git`);
10
+ isGitRepo = true;
11
+ } catch {}
12
+
13
+ return `<env>
14
+ Working directory: ${cwd}
15
+ Is directory a git repo: ${isGitRepo ? 'Yes' : 'No'}
16
+ Platform: ${process.platform}
17
+ Shell: ${os.userInfo().shell || 'unknown'}
18
+ OS Version: ${os.version || os.release()}
19
+ </env>`;
20
+ }
21
+
3
22
  export function buildDefaultSystemPrompt(config = {}) {
4
- return `${getShellSystemPrompt(config?.shell?.default)} If a command or tool is blocked or fails, inspect the error and retry with allowed commands or tools. For AST-scoped edits, if edit rejects a call because kind=replace_block or ast_target is missing or stale, fix the tool arguments and retry instead of switching to a broader text edit. Do not claim filesystem access is impossible unless the allowed search/read tools also fail.`;
23
+ return `${getShellSystemPrompt(config?.shell?.default)}
24
+
25
+ ${getEnvBlock()}`;
5
26
  }
@@ -118,5 +118,41 @@ export function getEffectivePolicy(config) {
118
118
 
119
119
  export function getShellSystemPrompt(value) {
120
120
  const profile = getShellProfile(value);
121
- return `You are CodeMini CLI working in a ${profile.label} shell environment. Prefer OpenCode-style primary tools first: use read to inspect files, grep to search file contents, glob to find files by pattern, list to inspect directories, edit to modify existing files, write to create or fully rewrite files when appropriate, patch to apply unified diffs, and run for one-shot shell commands like install, build, test, or other finite tasks. For structural code edits such as changing a function, method, or class, prefer the AST-first workflow: use ast_query to select the syntax node, use read_ast_node to inspect that node, then use edit with ast_target and kind=replace_block so the write stays constrained to the selected node. Fall back to plain grep/read/edit only when AST selection is not appropriate. Classify frontend, backend, database, and Docker work carefully: use run for finite commands, and use start_service, list_services, get_service_status, get_service_logs, and stop_service for long-running servers, watchers, and dev processes. Treat edit as the default editing path for existing code. Internal low-level edit strategies such as target resolution, block replacement, exact text replacement, and anchored inserts are handled inside edit rather than exposed as separate tools. Use generate_diff when you need a structured preview of a proposed file change. For existing code files, prefer grep/read/edit and only use write with full_file_rewrite=true when a whole-file rewrite is truly intended. Avoid unnecessary tool calls.`;
121
+ return `You are CodeMini CLI, an AI coding assistant running in a ${profile.label} shell environment.
122
+
123
+ # Using your tools
124
+
125
+ ALWAYS prefer dedicated tools over raw shell commands:
126
+ - Use read to inspect files — NEVER use cat, head, or tail via run
127
+ - Use grep to search file contents — NEVER use grep or rg via run
128
+ - Use glob to find files by pattern — NEVER use find via run
129
+ - Use edit to modify existing files — this is the DEFAULT path for code changes
130
+ - Use write only for creating new files or complete rewrites (set full_file_rewrite=true for existing code files)
131
+ - Use patch to apply unified diffs
132
+ - Use run for one-shot shell commands: install, build, test, or other finite tasks
133
+ - For long-running processes (dev servers, watchers), use start_service instead of run
134
+
135
+ For structural code edits (functions, classes, methods), use the AST-first workflow:
136
+ ast_query → read_ast_node → edit with ast_target and kind=replace_block.
137
+ Fall back to plain grep/read/edit only when AST is not appropriate.
138
+
139
+ For services: use start_service to launch, list_services/get_service_status/get_service_logs to monitor, stop_service to stop.
140
+
141
+ Some tools are loaded on demand. If a needed tool is not listed, call tool_search first to load it.
142
+
143
+ # Doing tasks
144
+
145
+ - If a command or tool is blocked or fails, inspect the error and retry with allowed commands or tools
146
+ - For AST-scoped edits, if edit rejects due to missing or stale ast_target, fix arguments and retry
147
+ - Do not claim filesystem access is impossible unless search/read tools also fail
148
+ - Prefer editing existing files over creating new ones
149
+ - Do not add comments, docstrings, or type annotations to code you did not change
150
+ - Do not add features or refactor code beyond what was asked
151
+
152
+ # Tone and style
153
+
154
+ - Be concise. Go straight to the point
155
+ - Do not restate what the user said
156
+ - When referencing code, use file_path:line_number format
157
+ - Only use emojis if the user explicitly requests it`;
122
158
  }
package/src/core/tools.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  import { evaluateCommandPolicy } from './command-policy.js';
16
16
  import { queryAst, readAstNode, resolveAstTarget } from './ast.js';
17
17
  import { initializeProjectIndex, refreshIndexedFile } from './project-index.js';
18
+ import { checkReadDedup } from './agent-loop.js';
18
19
 
19
20
  const SKIP_DIRS = new Set(['.git', 'node_modules', '.codemini', '.codemini-global', 'dist', 'coverage']);
20
21
  const TEXT_EXTENSIONS = new Set([
@@ -722,6 +723,26 @@ async function readFile(root, args) {
722
723
  truncated = true;
723
724
  }
724
725
 
726
+ // Read deduplication: if same path+range+mtime was read before, return a short stub
727
+ const isDuplicate = checkReadDedup(
728
+ args?.path,
729
+ startLine,
730
+ endLine,
731
+ stat.mtimeMs
732
+ );
733
+ if (isDuplicate) {
734
+ return {
735
+ path: args?.path,
736
+ phase: 'content',
737
+ start_line: startLine,
738
+ end_line: endLine,
739
+ total_lines: totalLines,
740
+ truncated: false,
741
+ unchanged: true,
742
+ content: `File unchanged since last read. The content from the earlier read tool_result in this conversation is still current -- refer to that instead of re-reading.`
743
+ };
744
+ }
745
+
725
746
  return {
726
747
  path: args?.path,
727
748
  phase: 'content',
@@ -1711,22 +1732,22 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1711
1732
  return null;
1712
1733
  }
1713
1734
  };
1714
- const definitions = [
1735
+ const primaryDefinitions = [
1715
1736
  {
1716
1737
  type: 'function',
1717
1738
  function: {
1718
1739
  name: 'read',
1719
1740
  description:
1720
- 'Primary read tool. First call returns metadata+read_token, second call with include_content=true and matching read_token returns content',
1741
+ 'Read a file. Call once for metadata and a read_token, then again with include_content=true and the same token to get content. Use this before editing.',
1721
1742
  parameters: {
1722
1743
  type: 'object',
1723
1744
  properties: {
1724
- path: { type: 'string' },
1725
- start_line: { type: 'number' },
1726
- end_line: { type: 'number' },
1727
- max_chars: { type: 'number' },
1728
- include_content: { type: 'boolean' },
1729
- read_token: { type: 'string' }
1745
+ path: { type: 'string', description: 'File path to read' },
1746
+ start_line: { type: 'number', description: '1-based start line' },
1747
+ end_line: { type: 'number', description: 'Inclusive end line' },
1748
+ max_chars: { type: 'number', description: 'Max chars to return' },
1749
+ include_content: { type: 'boolean', description: 'Set true on the second call' },
1750
+ read_token: { type: 'string', description: 'Token from the first call' }
1730
1751
  },
1731
1752
  required: ['path']
1732
1753
  }
@@ -1736,18 +1757,19 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1736
1757
  type: 'function',
1737
1758
  function: {
1738
1759
  name: 'grep',
1739
- description: 'Search file contents using a plain string or regex pattern and return compact matches',
1760
+ description:
1761
+ 'Search file contents. Use this for code search instead of grep or rg in run.',
1740
1762
  parameters: {
1741
1763
  type: 'object',
1742
1764
  properties: {
1743
- pattern: { type: 'string' },
1744
- query: { type: 'string' },
1745
- path: { type: 'string' },
1746
- regex: { type: 'boolean' },
1747
- case_sensitive: { type: 'boolean' },
1748
- max_results: { type: 'number' },
1749
- language: { type: 'string' },
1750
- file_types: { type: 'array', items: { type: 'string' } }
1765
+ pattern: { type: 'string', description: 'Search pattern' },
1766
+ query: { type: 'string', description: 'Alias for pattern' },
1767
+ path: { type: 'string', description: 'Directory or file to search' },
1768
+ regex: { type: 'boolean', description: 'Treat pattern as regex' },
1769
+ case_sensitive: { type: 'boolean', description: 'Case-sensitive matching' },
1770
+ max_results: { type: 'number', description: 'Max matches to return' },
1771
+ language: { type: 'string', description: 'Filter by language' },
1772
+ file_types: { type: 'array', items: { type: 'string' }, description: 'Filter by file glob' }
1751
1773
  },
1752
1774
  required: ['pattern']
1753
1775
  }
@@ -1757,14 +1779,15 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1757
1779
  type: 'function',
1758
1780
  function: {
1759
1781
  name: 'glob',
1760
- description: 'Find files by glob pattern such as **/*.ts or src/**/*.tsx',
1782
+ description:
1783
+ 'Find files by glob pattern. Use this for file discovery instead of find in run.',
1761
1784
  parameters: {
1762
1785
  type: 'object',
1763
1786
  properties: {
1764
- pattern: { type: 'string' },
1765
- path: { type: 'string' },
1766
- include_hidden: { type: 'boolean' },
1767
- max_results: { type: 'number' }
1787
+ pattern: { type: 'string', description: 'Glob pattern' },
1788
+ path: { type: 'string', description: 'Directory to search' },
1789
+ include_hidden: { type: 'boolean', description: 'Include dotfiles' },
1790
+ max_results: { type: 'number', description: 'Max results' }
1768
1791
  },
1769
1792
  required: ['pattern']
1770
1793
  }
@@ -1774,12 +1797,12 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1774
1797
  type: 'function',
1775
1798
  function: {
1776
1799
  name: 'list',
1777
- description: 'List files and directories in a workspace path',
1800
+ description: 'List files and directories in a workspace path.',
1778
1801
  parameters: {
1779
1802
  type: 'object',
1780
1803
  properties: {
1781
- path: { type: 'string' },
1782
- include_hidden: { type: 'boolean' }
1804
+ path: { type: 'string', description: 'Directory path to list' },
1805
+ include_hidden: { type: 'boolean', description: 'Include dotfiles' }
1783
1806
  }
1784
1807
  }
1785
1808
  }
@@ -1789,24 +1812,24 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1789
1812
  function: {
1790
1813
  name: 'edit',
1791
1814
  description:
1792
- 'Preferred edit tool for existing files. Accepts natural forms such as file + new_content for whole-file rewrites, file + symbol/line + new_content for block edits, file + old_text + new_text for exact replacements, and file + anchor_text + content for anchored inserts. When ast_target is provided, only replace_block is allowed and the write is constrained to that exact syntax node. If a file has just been selected via ast_query, the cached ast_target may be reused when omitted.',
1815
+ 'Edit existing files. Use block edits, exact replacements, or anchored inserts. When ast_target is provided, keep the edit constrained to that node. Prefer this over write for code changes.',
1793
1816
  parameters: {
1794
1817
  type: 'object',
1795
1818
  properties: {
1796
- file: { type: 'string' },
1797
- path: { type: 'string' },
1798
- new_content: { type: 'string' },
1799
- old_text: { type: 'string' },
1800
- new_text: { type: 'string' },
1801
- anchor_text: { type: 'string' },
1802
- content: { type: 'string' },
1803
- position: { type: 'string' },
1804
- kind: { type: 'string' },
1805
- target: { type: 'object' },
1806
- ast_target: { type: 'object' },
1807
- symbol: { type: 'string' },
1808
- line: { type: 'number' },
1809
- edit: { type: 'object' },
1819
+ file: { type: 'string', description: 'File path to edit' },
1820
+ path: { type: 'string', description: 'Alias for file' },
1821
+ new_content: { type: 'string', description: 'Replacement content' },
1822
+ old_text: { type: 'string', description: 'Exact text to replace' },
1823
+ new_text: { type: 'string', description: 'Replacement text' },
1824
+ anchor_text: { type: 'string', description: 'Anchor text for inserts' },
1825
+ content: { type: 'string', description: 'Content to insert or append' },
1826
+ position: { type: 'string', description: 'before or after' },
1827
+ kind: { type: 'string', description: 'replace_block, replace_text, insert_before, insert_after, or rewrite_file' },
1828
+ target: { type: 'object', description: 'Location object with symbol or line info' },
1829
+ ast_target: { type: 'object', description: 'AST target from ast_query' },
1830
+ symbol: { type: 'string', description: 'Symbol to target' },
1831
+ line: { type: 'number', description: 'Line to target' },
1832
+ edit: { type: 'object', description: 'Structured edit input' }
1810
1833
  },
1811
1834
  required: ['file']
1812
1835
  }
@@ -1815,77 +1838,96 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1815
1838
  {
1816
1839
  type: 'function',
1817
1840
  function: {
1818
- name: 'ast_query',
1841
+ name: 'write',
1819
1842
  description:
1820
- 'Run a Tree-sitter query against a code file and return explicit ast_target objects that can be passed into read_ast_node or edit for node-scoped changes. Prefer the returned ast_target verbatim in the next read_ast_node or edit call.',
1843
+ 'Create a new file or overwrite a file. Use this for new files or full rewrites. Prefer edit for existing code.',
1821
1844
  parameters: {
1822
1845
  type: 'object',
1823
1846
  properties: {
1824
- path: { type: 'string' },
1825
- language: { type: 'string' },
1826
- query: { type: 'string' },
1827
- capture_name: { type: 'string' },
1828
- max_results: { type: 'number' }
1847
+ path: { type: 'string', description: 'File path to create or overwrite' },
1848
+ content: { type: 'string', description: 'Content to write' },
1849
+ append: { type: 'boolean', description: 'Append instead of overwrite' },
1850
+ full_file_rewrite: { type: 'boolean', description: 'Set true for whole-file rewrites' }
1829
1851
  },
1830
- required: ['path', 'query']
1852
+ required: ['path', 'content']
1831
1853
  }
1832
1854
  }
1833
1855
  },
1834
1856
  {
1835
1857
  type: 'function',
1836
1858
  function: {
1837
- name: 'read_ast_node',
1859
+ name: 'run',
1838
1860
  description:
1839
- 'Read the current source and compact structural context for a previously selected AST node using ast_target. If omitted, the most recent ast_query selection for the same file may be reused.',
1861
+ 'Run a one-shot shell command such as install, build, or test. Do not use for long-running services or file search.',
1840
1862
  parameters: {
1841
1863
  type: 'object',
1842
1864
  properties: {
1843
- path: { type: 'string' },
1844
- language: { type: 'string' },
1845
- ast_target: { type: 'object' }
1865
+ command: { type: 'string', description: 'Shell command to execute' },
1866
+ timeout: { type: 'number', description: 'Timeout in milliseconds' }
1846
1867
  },
1847
- required: ['path', 'ast_target']
1868
+ required: ['command']
1848
1869
  }
1849
1870
  }
1850
1871
  },
1851
1872
  {
1852
1873
  type: 'function',
1853
1874
  function: {
1854
- name: 'write',
1875
+ name: 'tool_search',
1876
+ description:
1877
+ 'Load one deferred tool schema by name. Use this when a needed tool is not in the current tool list.',
1878
+ parameters: {
1879
+ type: 'object',
1880
+ properties: {
1881
+ query: { type: 'string', description: 'Tool name to load, or "all"' }
1882
+ },
1883
+ required: ['query']
1884
+ }
1885
+ }
1886
+ }
1887
+ ];
1888
+
1889
+ const deferredDefinitions = {
1890
+ ast_query: {
1891
+ type: 'function',
1892
+ function: {
1893
+ name: 'ast_query',
1855
1894
  description:
1856
- 'Primary write tool. Create a UTF-8 text file or overwrite an existing file. Existing code files require full_file_rewrite=true for whole-file overwrites.',
1895
+ 'Run a Tree-sitter query on a code file and return ast_target objects for node-scoped reads or edits.',
1857
1896
  parameters: {
1858
1897
  type: 'object',
1859
1898
  properties: {
1860
1899
  path: { type: 'string' },
1861
- content: { type: 'string' },
1862
- append: { type: 'boolean' },
1863
- full_file_rewrite: { type: 'boolean' }
1900
+ language: { type: 'string' },
1901
+ query: { type: 'string' },
1902
+ capture_name: { type: 'string' },
1903
+ max_results: { type: 'number' }
1864
1904
  },
1865
- required: ['path', 'content']
1905
+ required: ['path', 'query']
1866
1906
  }
1867
1907
  }
1868
1908
  },
1869
- {
1909
+ read_ast_node: {
1870
1910
  type: 'function',
1871
1911
  function: {
1872
- name: 'run',
1912
+ name: 'read_ast_node',
1873
1913
  description:
1874
- 'Primary run tool. Execute a one-shot shell command in workspace such as install, build, test, or other finite tasks. Do not use for long-running services or watchers.',
1914
+ 'Read a previously selected AST node with compact structural context.',
1875
1915
  parameters: {
1876
1916
  type: 'object',
1877
1917
  properties: {
1878
- command: { type: 'string' }
1918
+ path: { type: 'string' },
1919
+ language: { type: 'string' },
1920
+ ast_target: { type: 'object' }
1879
1921
  },
1880
- required: ['command']
1922
+ required: ['path', 'ast_target']
1881
1923
  }
1882
1924
  }
1883
1925
  },
1884
- {
1926
+ generate_diff: {
1885
1927
  type: 'function',
1886
1928
  function: {
1887
1929
  name: 'generate_diff',
1888
- description: 'Generate a unified diff between the current file and proposed content',
1930
+ description: 'Generate a unified diff for proposed content',
1889
1931
  parameters: {
1890
1932
  type: 'object',
1891
1933
  properties: {
@@ -1896,11 +1938,11 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1896
1938
  }
1897
1939
  }
1898
1940
  },
1899
- {
1941
+ patch: {
1900
1942
  type: 'function',
1901
1943
  function: {
1902
1944
  name: 'patch',
1903
- description: 'Apply one or more unified diff hunks to files in the workspace',
1945
+ description: 'Apply one or more unified diff hunks to workspace files',
1904
1946
  parameters: {
1905
1947
  type: 'object',
1906
1948
  properties: {
@@ -1911,12 +1953,12 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1911
1953
  }
1912
1954
  }
1913
1955
  },
1914
- {
1956
+ start_service: {
1915
1957
  type: 'function',
1916
1958
  function: {
1917
1959
  name: 'start_service',
1918
1960
  description:
1919
- 'Start a long-running local service, such as a frontend, backend, database, or dev watcher, and return a compact service handle instead of blocking on process exit.',
1961
+ 'Start a long-running local service and return a compact handle.',
1920
1962
  parameters: {
1921
1963
  type: 'object',
1922
1964
  properties: {
@@ -1939,22 +1981,22 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1939
1981
  }
1940
1982
  }
1941
1983
  },
1942
- {
1984
+ list_services: {
1943
1985
  type: 'function',
1944
1986
  function: {
1945
1987
  name: 'list_services',
1946
- description: 'List all tracked local services and their compact current status.',
1988
+ description: 'List tracked local services and their current status.',
1947
1989
  parameters: {
1948
1990
  type: 'object',
1949
1991
  properties: {}
1950
1992
  }
1951
1993
  }
1952
1994
  },
1953
- {
1995
+ get_service_status: {
1954
1996
  type: 'function',
1955
1997
  function: {
1956
1998
  name: 'get_service_status',
1957
- description: 'Get the current status of a previously started service.',
1999
+ description: 'Get the status of a started service.',
1958
2000
  parameters: {
1959
2001
  type: 'object',
1960
2002
  properties: {
@@ -1964,11 +2006,11 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1964
2006
  }
1965
2007
  }
1966
2008
  },
1967
- {
2009
+ get_service_logs: {
1968
2010
  type: 'function',
1969
2011
  function: {
1970
2012
  name: 'get_service_logs',
1971
- description: 'Read recent logs from a previously started service.',
2013
+ description: 'Read recent logs from a started service.',
1972
2014
  parameters: {
1973
2015
  type: 'object',
1974
2016
  properties: {
@@ -1980,11 +2022,11 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1980
2022
  }
1981
2023
  }
1982
2024
  },
1983
- {
2025
+ stop_service: {
1984
2026
  type: 'function',
1985
2027
  function: {
1986
2028
  name: 'stop_service',
1987
- description: 'Stop a previously started service.',
2029
+ description: 'Stop a started service.',
1988
2030
  parameters: {
1989
2031
  type: 'object',
1990
2032
  properties: {
@@ -1994,7 +2036,9 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1994
2036
  }
1995
2037
  }
1996
2038
  }
1997
- ].filter(Boolean);
2039
+ };
2040
+
2041
+ const definitions = [...primaryDefinitions];
1998
2042
 
1999
2043
  const handlers = {
2000
2044
  read: (args) =>
@@ -2052,8 +2096,205 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2052
2096
  list_services: () => listServices(workspaceRoot),
2053
2097
  get_service_status: (args) => getServiceStatus(workspaceRoot, args),
2054
2098
  get_service_logs: (args) => getServiceLogs(workspaceRoot, args),
2055
- stop_service: (args) => stopService(workspaceRoot, args)
2099
+ stop_service: (args) => stopService(workspaceRoot, args),
2100
+ tool_search: (args) => {
2101
+ const query = String(args?.query || '').trim().toLowerCase();
2102
+ if (query === 'all') {
2103
+ const all = Object.values(deferredDefinitions);
2104
+ return {
2105
+ loaded: Object.keys(deferredDefinitions),
2106
+ schemas: all,
2107
+ message: `Loaded all ${all.length} deferred tools. You can now call them directly.`
2108
+ };
2109
+ }
2110
+ const match = Object.entries(deferredDefinitions).find(([name]) => name === query);
2111
+ if (!match) {
2112
+ const available = Object.keys(deferredDefinitions).join(', ');
2113
+ return { error: `Unknown tool: "${query}". Available deferred tools: ${available}` };
2114
+ }
2115
+ return {
2116
+ loaded: [match[0]],
2117
+ schemas: [match[1]],
2118
+ message: `Loaded tool "${match[0]}". You can now call it in your next response.`
2119
+ };
2120
+ }
2121
+ };
2122
+
2123
+ const formatters = {
2124
+ read(result) {
2125
+ if (typeof result === 'string') return result;
2126
+ if (!result || typeof result !== 'object') return String(result);
2127
+ // Phase 1 metadata: small, return as-is
2128
+ if (result.phase === 'metadata') {
2129
+ return JSON.stringify(result);
2130
+ }
2131
+ // Phase 2 content: structured header + head/tail content
2132
+ if (result.phase === 'content') {
2133
+ const header = `[File: ${result.path}, lines ${result.start_line || 1}-${result.end_line || '?'}${result.total_lines ? ` of ${result.total_lines}` : ''}${result.truncated ? ', truncated' : ''}]`;
2134
+ const content = result.content || '';
2135
+ if (typeof content !== 'string' || content.length <= 3000) {
2136
+ return `${header}\n${content}`;
2137
+ }
2138
+ const headLen = 1800;
2139
+ const tailLen = 800;
2140
+ return `${header}\n${content.slice(0, headLen)}\n... [omitted ${content.length - headLen - tailLen} chars] ...\n${content.slice(-tailLen)}`;
2141
+ }
2142
+ return JSON.stringify(result);
2143
+ },
2144
+
2145
+ grep(result) {
2146
+ if (!result || typeof result !== 'object') return String(result);
2147
+ const { pattern, matches, truncated } = result;
2148
+ const header = pattern ? `[grep: "${pattern}"]` : '';
2149
+ if (!Array.isArray(matches) || matches.length === 0) return `${header}\nNo matches found.`;
2150
+ if (matches.length <= 30) {
2151
+ const lines = matches.map((m) => `${m.path}:${m.line}: ${String(m.preview || '').slice(0, 120)}`);
2152
+ return `${header}\n${lines.join('\n')}`;
2153
+ }
2154
+ const shown = matches.slice(0, 30).map((m) => `${m.path}:${m.line}: ${String(m.preview || '').slice(0, 120)}`);
2155
+ return `${header}\n${shown.join('\n')}\n... and ${matches.length - 30} more matches [total: ${matches.length}${truncated ? ', results were truncated' : ''}]`;
2156
+ },
2157
+
2158
+ glob(result) {
2159
+ if (!result || typeof result !== 'object') return String(result);
2160
+ const { pattern, matches, truncated } = result;
2161
+ const header = pattern ? `[glob: "${pattern}"]` : '';
2162
+ if (!Array.isArray(matches) || matches.length === 0) return `${header}\nNo files found.`;
2163
+ if (matches.length <= 50) {
2164
+ return `${header}\n${matches.join('\n')}`;
2165
+ }
2166
+ const shown = matches.slice(0, 50);
2167
+ return `${header}\n${shown.join('\n')}\n... and ${matches.length - 50} more files [total: ${matches.length}${truncated ? ', results were truncated' : ''}]`;
2168
+ },
2169
+
2170
+ list(result) {
2171
+ if (!result || typeof result !== 'object') return String(result);
2172
+ if (!Array.isArray(result.items)) return JSON.stringify(result);
2173
+ const header = result.path ? `[${result.path}]` : '';
2174
+ const dirs = result.items.filter((i) => i.type === 'dir').map((i) => `${i.name}/`);
2175
+ const files = result.items.filter((i) => i.type === 'file').map((i) => i.name);
2176
+ return `${header}\n${dirs.join('\n')}${dirs.length && files.length ? '\n' : ''}${files.join('\n')}`;
2177
+ },
2178
+
2179
+ edit(result) {
2180
+ if (!result || typeof result !== 'object') return String(result);
2181
+ const p = result.path || '';
2182
+ const action = result.action || '';
2183
+ const line = result.changed_line || 0;
2184
+ const summary = `${action} ${p}${line > 0 ? ` @L${line}` : ''}`;
2185
+ const diffPreview = result.diff_preview || '';
2186
+ if (diffPreview) {
2187
+ const trimmed = diffPreview.length > 600 ? `${diffPreview.slice(0, 597)}...` : diffPreview;
2188
+ return `${summary}\n${trimmed}`;
2189
+ }
2190
+ return summary + (result.ok !== false ? '' : ` [FAILED: ${result.error || 'unknown'}]`);
2191
+ },
2192
+
2193
+ write(result) {
2194
+ if (!result || typeof result !== 'object') return String(result);
2195
+ const p = result.path || '';
2196
+ const action = result.action || 'write';
2197
+ const line = result.changed_line || 0;
2198
+ const summary = `${action} ${p}${line > 0 ? ` @L${line}` : ''}`;
2199
+ const diffPreview = result.diff_preview || '';
2200
+ if (diffPreview) {
2201
+ const trimmed = diffPreview.length > 600 ? `${diffPreview.slice(0, 597)}...` : diffPreview;
2202
+ return `${summary}\n${trimmed}`;
2203
+ }
2204
+ return summary;
2205
+ },
2206
+
2207
+ run(result) {
2208
+ if (!result || typeof result !== 'object') return String(result);
2209
+ const command = String(result.command || '').slice(0, 200);
2210
+ const stdout = String(result.stdout || '').slice(0, 500);
2211
+ const stderr = String(result.stderr || '').slice(0, 500);
2212
+ const code = result.code ?? 0;
2213
+ const parts = [`[exit: ${code}]`];
2214
+ if (command) parts.push(`command: ${command}`);
2215
+ if (stdout) parts.push(`stdout:\n${stdout}`);
2216
+ if (stderr) parts.push(`stderr:\n${stderr}`);
2217
+ return parts.join('\n');
2218
+ },
2219
+
2220
+ generate_diff(result) {
2221
+ if (!result || typeof result !== 'object') return String(result);
2222
+ const p = result.path || '';
2223
+ const diff = result.diff || '';
2224
+ if (diff.length <= 2000) return `${p ? `[${p}]\n` : ''}${diff}`;
2225
+ return `${p ? `[${p}]\n` : ''}${diff.slice(0, 1997)}...\n[diff truncated: ${diff.length} chars total]`;
2226
+ },
2227
+
2228
+ patch(result) {
2229
+ if (!result || typeof result !== 'object') return String(result);
2230
+ if (Array.isArray(result.files)) {
2231
+ const names = result.files.slice(0, 10).map((f) => typeof f === 'string' ? f : f.path || '?');
2232
+ return `patched ${result.files.length} file(s): ${names.join(', ')}${result.files.length > 10 ? ` ... +${result.files.length - 10} more` : ''}`;
2233
+ }
2234
+ const p = result.path || '';
2235
+ const line = result.changed_line || 0;
2236
+ return `patched ${p}${line > 0 ? ` @L${line}` : ''}${result.ok === false ? ` [FAILED: ${result.error || ''}]` : ''}`;
2237
+ },
2238
+
2239
+ ast_query(result) {
2240
+ if (!result || typeof result !== 'object') return String(result);
2241
+ if (!Array.isArray(result.matches)) return JSON.stringify(result);
2242
+ const header = `[ast_query: ${result.matches.length} match(es)]`;
2243
+ const lines = result.matches.slice(0, 20).map((m) => {
2244
+ const name = m.name || m.ast_target?.name || '?';
2245
+ const kind = m.kind || m.ast_target?.kind || '?';
2246
+ return ` ${kind} ${name}`;
2247
+ });
2248
+ return `${header}\n${lines.join('\n')}${result.matches.length > 20 ? `\n... +${result.matches.length - 20} more` : ''}`;
2249
+ },
2250
+
2251
+ read_ast_node(result) {
2252
+ if (typeof result === 'string') return result;
2253
+ if (!result || typeof result !== 'object') return String(result);
2254
+ const name = result.name || '';
2255
+ const kind = result.kind || '';
2256
+ const content = result.content || result.source || '';
2257
+ const header = `${kind} ${name}`;
2258
+ if (typeof content !== 'string' || content.length <= 2000) return `${header}\n${content}`;
2259
+ return `${header}\n${content.slice(0, 1200)}\n... [omitted ${content.length - 1600} chars] ...\n${content.slice(-400)}`;
2260
+ },
2261
+
2262
+ start_service(result) {
2263
+ if (!result || typeof result !== 'object') return String(result);
2264
+ const tid = result.task_id || '';
2265
+ const status = result.status || 'unknown';
2266
+ const confirmed = result.startup_confirmed ? 'ready' : 'starting';
2267
+ const url = result.url || '';
2268
+ return `${tid} ${status} (${confirmed})${url ? ` -> ${url}` : ''}`;
2269
+ },
2270
+
2271
+ list_services(result) {
2272
+ if (!result || typeof result !== 'object') return String(result);
2273
+ if (!Array.isArray(result.services)) return JSON.stringify(result);
2274
+ if (result.services.length === 0) return 'No services running.';
2275
+ return result.services.map((s) => `${s.task_id || '?'} ${s.status || 'unknown'}${s.command ? ` (${s.command.slice(0, 60)})` : ''}`).join('\n');
2276
+ },
2277
+
2278
+ get_service_status(result) {
2279
+ if (!result || typeof result !== 'object') return String(result);
2280
+ const tid = result.task_id || '';
2281
+ const status = result.status || 'unknown';
2282
+ const url = result.url || '';
2283
+ const logs = Array.isArray(result.recent_logs) ? result.recent_logs.slice(-3).join('\n') : '';
2284
+ return `${tid} ${status}${url ? ` -> ${url}` : ''}${logs ? `\n${logs}` : ''}`;
2285
+ },
2286
+
2287
+ get_service_logs(result) {
2288
+ if (!result || typeof result !== 'object') return String(result);
2289
+ const logs = Array.isArray(result.recent_logs) ? result.recent_logs.join('\n') : '';
2290
+ return logs || 'No recent logs.';
2291
+ },
2292
+
2293
+ stop_service(result) {
2294
+ if (!result || typeof result !== 'object') return String(result);
2295
+ return `${result.task_id || '?'} stopped${result.exit_code != null ? ` (exit ${result.exit_code})` : ''}`;
2296
+ }
2056
2297
  };
2057
2298
 
2058
- return { definitions, handlers };
2299
+ return { definitions, handlers, formatters, deferredDefinitions };
2059
2300
  }