codemini-cli 0.2.1 → 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 +1 -1
- package/skills/superpowers-lite/SKILL.md +5 -2
- package/src/cli.js +1 -1
- package/src/commands/run.js +3 -1
- package/src/core/agent-loop.js +308 -67
- package/src/core/chat-runtime.js +10 -3
- package/src/core/context-compact.js +32 -2
- package/src/core/default-system-prompt.js +22 -1
- package/src/core/shell-profile.js +37 -1
- package/src/core/tools.js +323 -82
- package/src/tui/chat-app.js +111 -35
package/package.json
CHANGED
|
@@ -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
|
-
-
|
|
24
|
-
- do not
|
|
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.
|
|
7
|
+
const VERSION = '0.2.3';
|
|
8
8
|
|
|
9
9
|
function printHelp() {
|
|
10
10
|
console.log(`codemini ${VERSION}
|
package/src/commands/run.js
CHANGED
|
@@ -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({
|
package/src/core/agent-loop.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
493
|
+
if (!approvalResults.get(call.id)) {
|
|
265
494
|
if (onEvent) onEvent({ type: 'tool:blocked', name: displayName, id: call.id, arguments: args });
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
517
|
+
return {
|
|
518
|
+
callId: call.id,
|
|
519
|
+
content: clipToolResult({ error: message }, toolResultMaxChars),
|
|
520
|
+
error: true
|
|
310
521
|
};
|
|
311
|
-
|
|
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
|
-
|
|
325
|
-
if (
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
name: displayName,
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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
|
}
|
package/src/core/chat-runtime.js
CHANGED
|
@@ -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 =
|
|
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,
|
|
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)}
|
|
23
|
+
return `${getShellSystemPrompt(config?.shell?.default)}
|
|
24
|
+
|
|
25
|
+
${getEnvBlock()}`;
|
|
5
26
|
}
|