ikie-cli 0.1.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/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/agent.d.ts +62 -0
- package/dist/agent.js +634 -0
- package/dist/attachments.d.ts +33 -0
- package/dist/attachments.js +239 -0
- package/dist/auth.d.ts +8 -0
- package/dist/auth.js +89 -0
- package/dist/config.d.ts +41 -0
- package/dist/config.js +69 -0
- package/dist/context.d.ts +13 -0
- package/dist/context.js +126 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +132 -0
- package/dist/memory.d.ts +14 -0
- package/dist/memory.js +71 -0
- package/dist/renderer.d.ts +7 -0
- package/dist/renderer.js +203 -0
- package/dist/repl.d.ts +3 -0
- package/dist/repl.js +948 -0
- package/dist/session.d.ts +21 -0
- package/dist/session.js +85 -0
- package/dist/theme.d.ts +66 -0
- package/dist/theme.js +365 -0
- package/dist/tools.d.ts +5 -0
- package/dist/tools.js +477 -0
- package/package.json +47 -0
package/dist/agent.js
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { TOOL_DEFS, SAFE_TOOLS, formatToolArgs, executeTool } from './tools.js';
|
|
3
|
+
import { renderMarkdown, extractThinkTags } from './renderer.js';
|
|
4
|
+
import { c, toolLine, permissionPrompt, toolSuccessLine, toolErrorLine, InlineSpinner } from './theme.js';
|
|
5
|
+
export function estimateTokens(chars) {
|
|
6
|
+
return Math.max(1, Math.round(chars / 4));
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Safely restore previously-saved stdin listeners after a raw-mode interaction
|
|
10
|
+
* (permission prompt, ask_user, theme picker, agent turn).
|
|
11
|
+
*
|
|
12
|
+
* Re-adding 'keypress' listeners makes Node's readline re-attach its OWN 'data'
|
|
13
|
+
* decoder via emitKeypressEvents' `newListener` hook. The REPL already forwards
|
|
14
|
+
* raw bytes to that decoder manually, so a freshly auto-attached decoder is a
|
|
15
|
+
* duplicate — and two decoders for the same bytes double-echo every keystroke.
|
|
16
|
+
* To avoid that, we add keypress listeners first, then reset the 'data' listener
|
|
17
|
+
* set to EXACTLY the saved set, dropping any stray decoder.
|
|
18
|
+
*/
|
|
19
|
+
export function restoreStdinListeners(savedDataListeners, savedKeypressListeners) {
|
|
20
|
+
for (const fn of savedKeypressListeners) {
|
|
21
|
+
process.stdin.on('keypress', fn);
|
|
22
|
+
}
|
|
23
|
+
process.stdin.removeAllListeners('data');
|
|
24
|
+
for (const fn of savedDataListeners) {
|
|
25
|
+
process.stdin.on('data', fn);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const requestTimestamps = [];
|
|
29
|
+
function sleep(ms) {
|
|
30
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
31
|
+
}
|
|
32
|
+
function printResponse(text, indentStr = ' ') {
|
|
33
|
+
const indent = (s) => s.split('\n').map(l => indentStr + l).join('\n');
|
|
34
|
+
const { response } = extractThinkTags(text);
|
|
35
|
+
if (response.trim()) {
|
|
36
|
+
process.stdout.write(indent(renderMarkdown(response)) + '\n');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function toolPhaseLabel(name) {
|
|
40
|
+
switch (name) {
|
|
41
|
+
case 'write_file': return 'Writing file';
|
|
42
|
+
case 'edit_file': return 'Editing file';
|
|
43
|
+
case 'read_file': return 'Reading';
|
|
44
|
+
case 'bash': return 'Preparing command';
|
|
45
|
+
case 'spawn_agent': return 'Spawning agent';
|
|
46
|
+
case 'list_dir': return 'Listing directory';
|
|
47
|
+
case 'search_files': return 'Searching';
|
|
48
|
+
case 'grep': return 'Searching';
|
|
49
|
+
default: return `Preparing ${name}`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export class Agent {
|
|
53
|
+
client;
|
|
54
|
+
config;
|
|
55
|
+
conversation = [];
|
|
56
|
+
systemPrompt;
|
|
57
|
+
sessionAllowList = new Set();
|
|
58
|
+
sessionDenyList = new Set();
|
|
59
|
+
depth;
|
|
60
|
+
indent;
|
|
61
|
+
activeTurnStats = null;
|
|
62
|
+
activeChangedFiles = new Set();
|
|
63
|
+
lastTurnStats = { modelCalls: 0, toolCalls: 0, filesChanged: 0 };
|
|
64
|
+
constructor(client, config, systemPrompt, depth = 0) {
|
|
65
|
+
this.client = client;
|
|
66
|
+
this.config = config;
|
|
67
|
+
this.systemPrompt = systemPrompt;
|
|
68
|
+
this.depth = depth;
|
|
69
|
+
this.indent = ' '.repeat(depth + 1);
|
|
70
|
+
}
|
|
71
|
+
clearConversation() {
|
|
72
|
+
this.conversation = [];
|
|
73
|
+
}
|
|
74
|
+
getConversation() {
|
|
75
|
+
return this.conversation;
|
|
76
|
+
}
|
|
77
|
+
setConversation(messages) {
|
|
78
|
+
this.conversation = [...messages];
|
|
79
|
+
}
|
|
80
|
+
getLastTurnStats() {
|
|
81
|
+
return { ...this.lastTurnStats };
|
|
82
|
+
}
|
|
83
|
+
async send(userMessage, opts = {}) {
|
|
84
|
+
this.activeTurnStats = { modelCalls: 0, toolCalls: 0, filesChanged: 0 };
|
|
85
|
+
this.activeChangedFiles = new Set();
|
|
86
|
+
this.conversation.push({ role: 'user', content: userMessage });
|
|
87
|
+
try {
|
|
88
|
+
await this.runLoop(opts);
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
this.lastTurnStats = this.activeTurnStats
|
|
92
|
+
? { ...this.activeTurnStats, filesChanged: this.activeChangedFiles.size }
|
|
93
|
+
: { modelCalls: 0, toolCalls: 0, filesChanged: 0 };
|
|
94
|
+
this.activeTurnStats = null;
|
|
95
|
+
this.activeChangedFiles = new Set();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
recordChangedFile(name, input, result) {
|
|
99
|
+
if (name !== 'write_file' && name !== 'edit_file')
|
|
100
|
+
return;
|
|
101
|
+
if (result.startsWith('Error'))
|
|
102
|
+
return;
|
|
103
|
+
const path = input.path;
|
|
104
|
+
if (typeof path === 'string' && path.trim())
|
|
105
|
+
this.activeChangedFiles.add(path);
|
|
106
|
+
}
|
|
107
|
+
// ── Tool call grouping ────────────────────────────────────────────────────
|
|
108
|
+
groupToolCalls(calls) {
|
|
109
|
+
const groups = [];
|
|
110
|
+
for (const tc of calls) {
|
|
111
|
+
const last = groups[groups.length - 1];
|
|
112
|
+
if (last && last[0].name === tc.name) {
|
|
113
|
+
last.push(tc);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
groups.push([tc]);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return groups;
|
|
120
|
+
}
|
|
121
|
+
formatGroupSummary(name, inputs) {
|
|
122
|
+
switch (name) {
|
|
123
|
+
case 'read_file': {
|
|
124
|
+
const paths = [...new Set(inputs.map(i => String(i.path ?? '')))];
|
|
125
|
+
const mins = [];
|
|
126
|
+
const maxs = [];
|
|
127
|
+
for (const i of inputs) {
|
|
128
|
+
mins.push(Number(i.start_line ?? 1));
|
|
129
|
+
if (i.end_line != null)
|
|
130
|
+
maxs.push(Number(i.end_line));
|
|
131
|
+
}
|
|
132
|
+
const lo = Math.min(...mins);
|
|
133
|
+
const hi = maxs.length ? Math.max(...maxs) : '?';
|
|
134
|
+
const pathStr = paths.length === 1 ? `"${paths[0]}"` : `${paths.length} files`;
|
|
135
|
+
return `${pathStr} lines ${lo}-${hi}`;
|
|
136
|
+
}
|
|
137
|
+
case 'bash': {
|
|
138
|
+
const first = String(inputs[0]?.command ?? '').slice(0, 40);
|
|
139
|
+
return inputs.length > 1 ? `${first}… (+${inputs.length - 1} more)` : first;
|
|
140
|
+
}
|
|
141
|
+
case 'write_file': {
|
|
142
|
+
const paths = inputs.map(i => String(i.path ?? '(?)'));
|
|
143
|
+
return paths.length <= 3 ? paths.map(p => `"${p}"`).join(', ') : `${paths.length} files`;
|
|
144
|
+
}
|
|
145
|
+
default:
|
|
146
|
+
return `${inputs.length} calls`;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// ── Main agent loop ───────────────────────────────────────────────────────
|
|
150
|
+
async runLoop(opts) {
|
|
151
|
+
while (true) {
|
|
152
|
+
if (opts.signal?.aborted)
|
|
153
|
+
break;
|
|
154
|
+
const { assistantMsg, toolCalls, finishReason } = await this.callModel(opts);
|
|
155
|
+
this.conversation.push(assistantMsg);
|
|
156
|
+
if (opts.signal?.aborted)
|
|
157
|
+
break;
|
|
158
|
+
if (finishReason !== 'tool_calls' || !toolCalls.length)
|
|
159
|
+
break;
|
|
160
|
+
const groups = this.groupToolCalls(toolCalls);
|
|
161
|
+
for (const group of groups) {
|
|
162
|
+
if (opts.signal?.aborted)
|
|
163
|
+
break;
|
|
164
|
+
const inputs = group.map(tc => {
|
|
165
|
+
try {
|
|
166
|
+
return JSON.parse(tc.argsStr || '{}');
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return {};
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
if (group.length === 1) {
|
|
173
|
+
if (this.activeTurnStats)
|
|
174
|
+
this.activeTurnStats.toolCalls++;
|
|
175
|
+
process.stdout.write(`\n${this.indent}${toolLine(group[0].name, formatToolArgs(group[0].name, inputs[0])).trimStart()}\n`);
|
|
176
|
+
const result = await this.handleToolCall(group[0].name, group[0].id, inputs[0], opts);
|
|
177
|
+
this.conversation.push({ role: 'tool', tool_call_id: group[0].id, content: result });
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
if (this.activeTurnStats)
|
|
181
|
+
this.activeTurnStats.toolCalls += group.length;
|
|
182
|
+
const summary = this.formatGroupSummary(group[0].name, inputs);
|
|
183
|
+
process.stdout.write(`\n${this.indent}${toolLine(`${group[0].name} ×${group.length}`, summary).trimStart()}\n`);
|
|
184
|
+
if (!opts.autoApprove && !this.config.autoApprove && !SAFE_TOOLS.has(group[0].name)) {
|
|
185
|
+
const allowed = await this.checkPermission(group[0].name, inputs[0]);
|
|
186
|
+
if (!allowed) {
|
|
187
|
+
for (const tc of group) {
|
|
188
|
+
this.conversation.push({
|
|
189
|
+
role: 'tool', tool_call_id: tc.id,
|
|
190
|
+
content: `Tool execution denied by user: ${tc.name}`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const t0 = Date.now();
|
|
197
|
+
let errors = 0;
|
|
198
|
+
for (let i = 0; i < group.length; i++) {
|
|
199
|
+
if (opts.signal?.aborted)
|
|
200
|
+
break;
|
|
201
|
+
const tc = group[i];
|
|
202
|
+
if (tc.name === 'spawn_agent') {
|
|
203
|
+
const result = await this.runSubagent(inputs[i], opts);
|
|
204
|
+
this.conversation.push({ role: 'tool', tool_call_id: tc.id, content: result });
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
try {
|
|
208
|
+
const result = await executeTool(tc.name, inputs[i]);
|
|
209
|
+
if (result.startsWith('Error'))
|
|
210
|
+
errors++;
|
|
211
|
+
this.recordChangedFile(tc.name, inputs[i], result);
|
|
212
|
+
this.conversation.push({ role: 'tool', tool_call_id: tc.id, content: result });
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
errors++;
|
|
216
|
+
this.conversation.push({ role: 'tool', tool_call_id: tc.id, content: `Tool error: ${err}` });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const ms = Date.now() - t0;
|
|
221
|
+
const lineStr = errors === 0
|
|
222
|
+
? toolSuccessLine(ms, `Executed ${group.length} operations`)
|
|
223
|
+
: toolErrorLine(`${errors} of ${group.length} operations failed`);
|
|
224
|
+
process.stdout.write(`${this.indent}${lineStr}\n`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
process.stdout.write('\n');
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// ── Model calls ───────────────────────────────────────────────────────────
|
|
231
|
+
async callModel(opts) {
|
|
232
|
+
try {
|
|
233
|
+
return await this.callModelStreaming(opts);
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
if (opts.signal?.aborted)
|
|
237
|
+
throw err;
|
|
238
|
+
return await this.callModelNonStreaming(opts);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
buildParams() {
|
|
242
|
+
const tools = this.depth >= 1
|
|
243
|
+
? TOOL_DEFS.filter(t => t.function.name !== 'spawn_agent')
|
|
244
|
+
: TOOL_DEFS;
|
|
245
|
+
return {
|
|
246
|
+
model: this.config.model,
|
|
247
|
+
max_tokens: this.config.maxTokens,
|
|
248
|
+
temperature: this.config.temperature,
|
|
249
|
+
top_p: this.config.topP,
|
|
250
|
+
top_k: this.config.topK,
|
|
251
|
+
presence_penalty: this.config.presencePenalty,
|
|
252
|
+
frequency_penalty: this.config.frequencyPenalty,
|
|
253
|
+
messages: [
|
|
254
|
+
{ role: 'system', content: this.systemPrompt },
|
|
255
|
+
...this.conversation,
|
|
256
|
+
],
|
|
257
|
+
tools,
|
|
258
|
+
tool_choice: 'auto',
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
async throttleModelRequest() {
|
|
262
|
+
const rpm = Math.max(1, Math.floor(this.config.requestsPerMinute || 10));
|
|
263
|
+
const prune = () => {
|
|
264
|
+
const now = Date.now();
|
|
265
|
+
while (requestTimestamps.length && now - requestTimestamps[0] >= 60_000) {
|
|
266
|
+
requestTimestamps.shift();
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
prune();
|
|
270
|
+
if (requestTimestamps.length >= rpm) {
|
|
271
|
+
const waitMs = Math.max(0, 60_000 - (Date.now() - requestTimestamps[0]) + 25);
|
|
272
|
+
process.stdout.write(`\n${this.indent}${c.muted(`Rate limit: waiting ${(waitMs / 1000).toFixed(1)}s (${rpm} rpm)`)}`);
|
|
273
|
+
await sleep(waitMs);
|
|
274
|
+
process.stdout.write('\r\x1b[2K');
|
|
275
|
+
prune();
|
|
276
|
+
}
|
|
277
|
+
requestTimestamps.push(Date.now());
|
|
278
|
+
}
|
|
279
|
+
async callModelStreaming(opts) {
|
|
280
|
+
const spinner = new InlineSpinner('Working', opts.startedAt);
|
|
281
|
+
spinner.start();
|
|
282
|
+
const requestOpts = opts.signal ? { signal: opts.signal } : undefined;
|
|
283
|
+
let stream;
|
|
284
|
+
try {
|
|
285
|
+
if (this.activeTurnStats)
|
|
286
|
+
this.activeTurnStats.modelCalls++;
|
|
287
|
+
await this.throttleModelRequest();
|
|
288
|
+
stream = await this.client.chat.completions.create({
|
|
289
|
+
...this.buildParams(),
|
|
290
|
+
stream: true,
|
|
291
|
+
}, requestOpts);
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
spinner.stop();
|
|
295
|
+
throw err;
|
|
296
|
+
}
|
|
297
|
+
let textContent = '';
|
|
298
|
+
let thinkingContent = '';
|
|
299
|
+
let totalArgsChars = 0;
|
|
300
|
+
let finishReason = 'stop';
|
|
301
|
+
let phase = 'Working';
|
|
302
|
+
const toolCallsMap = new Map();
|
|
303
|
+
try {
|
|
304
|
+
for await (const chunk of stream) {
|
|
305
|
+
if (opts.signal?.aborted)
|
|
306
|
+
break;
|
|
307
|
+
const choice = chunk.choices[0];
|
|
308
|
+
if (!choice)
|
|
309
|
+
continue;
|
|
310
|
+
const { delta, finish_reason } = choice;
|
|
311
|
+
if (finish_reason)
|
|
312
|
+
finishReason = finish_reason;
|
|
313
|
+
const raw = delta;
|
|
314
|
+
if (typeof raw.reasoning_content === 'string' && raw.reasoning_content) {
|
|
315
|
+
thinkingContent += raw.reasoning_content;
|
|
316
|
+
phase = 'Working';
|
|
317
|
+
}
|
|
318
|
+
if (delta.content) {
|
|
319
|
+
textContent += delta.content;
|
|
320
|
+
phase = 'Generating';
|
|
321
|
+
}
|
|
322
|
+
if (delta.tool_calls) {
|
|
323
|
+
for (const tc of delta.tool_calls) {
|
|
324
|
+
const idx = tc.index;
|
|
325
|
+
if (!toolCallsMap.has(idx)) {
|
|
326
|
+
toolCallsMap.set(idx, {
|
|
327
|
+
id: tc.id ?? `call_${idx}`,
|
|
328
|
+
name: tc.function?.name ?? '',
|
|
329
|
+
argsStr: tc.function?.arguments ?? '',
|
|
330
|
+
});
|
|
331
|
+
if (tc.function?.name) {
|
|
332
|
+
phase = toolPhaseLabel(tc.function.name);
|
|
333
|
+
}
|
|
334
|
+
if (tc.function?.arguments)
|
|
335
|
+
totalArgsChars += tc.function.arguments.length;
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
const acc = toolCallsMap.get(idx);
|
|
339
|
+
if (tc.id)
|
|
340
|
+
acc.id = tc.id;
|
|
341
|
+
if (tc.function?.name && !acc.name) {
|
|
342
|
+
acc.name = tc.function.name;
|
|
343
|
+
phase = toolPhaseLabel(tc.function.name);
|
|
344
|
+
}
|
|
345
|
+
if (tc.function?.arguments) {
|
|
346
|
+
acc.argsStr += tc.function.arguments;
|
|
347
|
+
totalArgsChars += tc.function.arguments.length;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const totalTokens = estimateTokens(thinkingContent.length + textContent.length + totalArgsChars);
|
|
353
|
+
spinner.updateLabel(`${phase} (${totalTokens.toLocaleString()} tokens)`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
if (opts.signal?.aborted) {
|
|
358
|
+
// user cancelled — swallow
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
throw err;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
spinner.stop();
|
|
366
|
+
}
|
|
367
|
+
if (textContent) {
|
|
368
|
+
printResponse(textContent, this.indent);
|
|
369
|
+
}
|
|
370
|
+
const toolCalls = [...toolCallsMap.values()];
|
|
371
|
+
const assistantMsg = {
|
|
372
|
+
role: 'assistant',
|
|
373
|
+
content: textContent || null,
|
|
374
|
+
...(toolCalls.length ? {
|
|
375
|
+
tool_calls: toolCalls.map(tc => ({
|
|
376
|
+
id: tc.id,
|
|
377
|
+
type: 'function',
|
|
378
|
+
function: { name: tc.name, arguments: tc.argsStr },
|
|
379
|
+
})),
|
|
380
|
+
} : {}),
|
|
381
|
+
};
|
|
382
|
+
return { assistantMsg, toolCalls, finishReason };
|
|
383
|
+
}
|
|
384
|
+
async callModelNonStreaming(opts) {
|
|
385
|
+
const spinner = new InlineSpinner('Working', opts.startedAt);
|
|
386
|
+
spinner.start();
|
|
387
|
+
const requestOpts = opts.signal ? { signal: opts.signal } : undefined;
|
|
388
|
+
let resp;
|
|
389
|
+
try {
|
|
390
|
+
if (this.activeTurnStats)
|
|
391
|
+
this.activeTurnStats.modelCalls++;
|
|
392
|
+
await this.throttleModelRequest();
|
|
393
|
+
resp = await this.client.chat.completions.create({
|
|
394
|
+
...this.buildParams(),
|
|
395
|
+
}, requestOpts);
|
|
396
|
+
}
|
|
397
|
+
finally {
|
|
398
|
+
spinner.stop();
|
|
399
|
+
}
|
|
400
|
+
const choice = resp.choices[0];
|
|
401
|
+
const msg = choice.message;
|
|
402
|
+
const finishReason = choice.finish_reason ?? 'stop';
|
|
403
|
+
const textContent = msg.content ?? '';
|
|
404
|
+
if (textContent) {
|
|
405
|
+
printResponse(textContent, this.indent);
|
|
406
|
+
}
|
|
407
|
+
const toolCalls = (msg.tool_calls ?? []).map(tc => ({
|
|
408
|
+
id: tc.id, name: tc.function.name, argsStr: tc.function.arguments,
|
|
409
|
+
}));
|
|
410
|
+
const assistantMsg = {
|
|
411
|
+
role: 'assistant',
|
|
412
|
+
content: textContent || null,
|
|
413
|
+
...(toolCalls.length ? {
|
|
414
|
+
tool_calls: (msg.tool_calls ?? []).map(tc => ({
|
|
415
|
+
id: tc.id,
|
|
416
|
+
type: 'function',
|
|
417
|
+
function: tc.function,
|
|
418
|
+
})),
|
|
419
|
+
} : {}),
|
|
420
|
+
};
|
|
421
|
+
return { assistantMsg, toolCalls, finishReason };
|
|
422
|
+
}
|
|
423
|
+
// ── Tool execution ────────────────────────────────────────────────────────
|
|
424
|
+
async handleToolCall(name, id, input, opts) {
|
|
425
|
+
if (name === 'spawn_agent') {
|
|
426
|
+
return this.runSubagent(input, opts);
|
|
427
|
+
}
|
|
428
|
+
if (name === 'ask_user') {
|
|
429
|
+
return this.askUser(input);
|
|
430
|
+
}
|
|
431
|
+
if (!opts.autoApprove && !this.config.autoApprove && !SAFE_TOOLS.has(name)) {
|
|
432
|
+
const allowed = await this.checkPermission(name, input);
|
|
433
|
+
if (!allowed)
|
|
434
|
+
return `Tool execution denied by user: ${name}`;
|
|
435
|
+
}
|
|
436
|
+
const t0 = Date.now();
|
|
437
|
+
try {
|
|
438
|
+
const result = await executeTool(name, input);
|
|
439
|
+
this.recordChangedFile(name, input, result);
|
|
440
|
+
const ms = Date.now() - t0;
|
|
441
|
+
const preview = result.split('\n')[0].slice(0, 80);
|
|
442
|
+
process.stdout.write(`${this.indent}${toolSuccessLine(ms, preview)}\n`);
|
|
443
|
+
return result;
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
447
|
+
process.stdout.write(`${this.indent}${toolErrorLine(msg)}\n`);
|
|
448
|
+
return msg;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
async askUser(input) {
|
|
452
|
+
const question = (input.question ?? '').trim();
|
|
453
|
+
if (!question)
|
|
454
|
+
return 'Error: ask_user requires a question.';
|
|
455
|
+
process.stdout.write(`\n${this.indent}${c.info('[?]')} ${c.white.bold(question)}\n` +
|
|
456
|
+
`${this.indent}${c.primary('╰─❯')} `);
|
|
457
|
+
return new Promise((resolve) => {
|
|
458
|
+
if (!process.stdin.isTTY) {
|
|
459
|
+
process.stdout.write(chalk.dim('(non-interactive, skipping)\n'));
|
|
460
|
+
resolve('(no answer — non-interactive mode)');
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const savedDataListeners = process.stdin.rawListeners('data').slice();
|
|
464
|
+
const savedKeypressListeners = process.stdin.rawListeners('keypress').slice();
|
|
465
|
+
process.stdin.removeAllListeners('data');
|
|
466
|
+
process.stdin.removeAllListeners('keypress');
|
|
467
|
+
const wasRaw = process.stdin.isRaw ?? false;
|
|
468
|
+
if (wasRaw)
|
|
469
|
+
process.stdin.setRawMode(false);
|
|
470
|
+
const readline = require('readline');
|
|
471
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
472
|
+
rl.on('line', (answer) => {
|
|
473
|
+
rl.close();
|
|
474
|
+
if (wasRaw)
|
|
475
|
+
process.stdin.setRawMode(true);
|
|
476
|
+
restoreStdinListeners(savedDataListeners, savedKeypressListeners);
|
|
477
|
+
const trimmed = answer.trim();
|
|
478
|
+
resolve(trimmed || '(no answer)');
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
// ── Subagent ──────────────────────────────────────────────────────────────
|
|
483
|
+
async runSubagent(input, opts) {
|
|
484
|
+
if (this.depth >= 2) {
|
|
485
|
+
return 'Error: subagents cannot spawn further subagents (max depth reached).';
|
|
486
|
+
}
|
|
487
|
+
const task = (input.task ?? '').trim();
|
|
488
|
+
if (!task)
|
|
489
|
+
return 'Error: spawn_agent requires a "task" describing what to do.';
|
|
490
|
+
const label = task.length > 64 ? task.slice(0, 64) + '…' : task;
|
|
491
|
+
process.stdout.write(`\n${this.indent}${c.primary('◆')} ${c.primary.bold('subagent')} ${c.muted('»')} ${c.white(label)}\n`);
|
|
492
|
+
const subPrompt = SUBAGENT_FRAMING + '\n\n' + this.systemPrompt;
|
|
493
|
+
const sub = new Agent(this.client, this.config, subPrompt, this.depth + 1);
|
|
494
|
+
const message = input.context
|
|
495
|
+
? `Context from the main agent:\n${input.context}\n\nYour task: ${task}`
|
|
496
|
+
: `Your task: ${task}`;
|
|
497
|
+
try {
|
|
498
|
+
await sub.send(message, { autoApprove: true, signal: opts.signal });
|
|
499
|
+
}
|
|
500
|
+
catch (err) {
|
|
501
|
+
process.stdout.write(`${this.indent}${c.error('✗')} ${c.muted('subagent failed')}\n`);
|
|
502
|
+
return `Subagent error: ${err instanceof Error ? err.message : String(err)}`;
|
|
503
|
+
}
|
|
504
|
+
const result = sub.getLastAssistantText();
|
|
505
|
+
process.stdout.write(`${this.indent}${c.success('✓')} ${c.muted('subagent done')}\n\n`);
|
|
506
|
+
return result || '(subagent completed but produced no summary)';
|
|
507
|
+
}
|
|
508
|
+
getLastAssistantText() {
|
|
509
|
+
for (let i = this.conversation.length - 1; i >= 0; i--) {
|
|
510
|
+
const m = this.conversation[i];
|
|
511
|
+
if (m.role === 'assistant' && typeof m.content === 'string' && m.content.trim()) {
|
|
512
|
+
return m.content;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return '';
|
|
516
|
+
}
|
|
517
|
+
// ── Permission prompt ─────────────────────────────────────────────────────
|
|
518
|
+
async checkPermission(toolName, input) {
|
|
519
|
+
if (this.sessionDenyList.has(toolName))
|
|
520
|
+
return false;
|
|
521
|
+
if (this.sessionAllowList.has(toolName))
|
|
522
|
+
return true;
|
|
523
|
+
process.stdout.write(permissionPrompt(toolName, formatToolArgs(toolName, input)));
|
|
524
|
+
return new Promise((resolve) => {
|
|
525
|
+
if (!process.stdin.isTTY) {
|
|
526
|
+
process.stdout.write(chalk.dim('(non-interactive, denying)\n'));
|
|
527
|
+
resolve(false);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const wasRaw = process.stdin.isRaw ?? false;
|
|
531
|
+
const savedDataListeners = process.stdin.rawListeners('data').slice();
|
|
532
|
+
const savedKeypressListeners = process.stdin.rawListeners('keypress').slice();
|
|
533
|
+
process.stdin.removeAllListeners('data');
|
|
534
|
+
process.stdin.removeAllListeners('keypress');
|
|
535
|
+
process.stdin.setRawMode(true);
|
|
536
|
+
process.stdin.resume();
|
|
537
|
+
const onData = (data) => {
|
|
538
|
+
process.stdin.removeListener('data', onData);
|
|
539
|
+
// Restore raw mode to what it was (keeps REPL's ESC handler working)
|
|
540
|
+
process.stdin.setRawMode(wasRaw);
|
|
541
|
+
restoreStdinListeners(savedDataListeners, savedKeypressListeners);
|
|
542
|
+
// Only pause if nobody else was listening (no REPL ESC handler)
|
|
543
|
+
if (!savedDataListeners.length) {
|
|
544
|
+
process.stdin.pause();
|
|
545
|
+
}
|
|
546
|
+
const key = data.toString().toLowerCase();
|
|
547
|
+
if (key === 'y' || key === '\r' || key === '\n') {
|
|
548
|
+
process.stdout.write(chalk.green('y\n'));
|
|
549
|
+
resolve(true);
|
|
550
|
+
}
|
|
551
|
+
else if (key === 'a') {
|
|
552
|
+
process.stdout.write(chalk.blue('a (always)\n'));
|
|
553
|
+
this.sessionAllowList.add(toolName);
|
|
554
|
+
resolve(true);
|
|
555
|
+
}
|
|
556
|
+
else if (key === '!') {
|
|
557
|
+
process.stdout.write(chalk.dim('! (always deny)\n'));
|
|
558
|
+
this.sessionDenyList.add(toolName);
|
|
559
|
+
resolve(false);
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
process.stdout.write(chalk.red('n\n'));
|
|
563
|
+
resolve(false);
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
process.stdin.on('data', onData);
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
export const SUBAGENT_FRAMING = `You are a focused sub-agent spawned by Ikie to autonomously complete ONE specific task.
|
|
571
|
+
Work independently — do not ask the user questions. Use your tools to gather what you
|
|
572
|
+
need, do the work, and verify it. When finished, your FINAL message must be a concise
|
|
573
|
+
summary of what you did and any key results (paths changed, findings, answers). That
|
|
574
|
+
summary is the only thing returned to the main agent, so make it self-contained.`;
|
|
575
|
+
export function buildSystemPrompt(projectContext, memoryContext) {
|
|
576
|
+
const parts = [
|
|
577
|
+
`You are Ikie, an expert agentic coding assistant running in the terminal.
|
|
578
|
+
|
|
579
|
+
## Identity
|
|
580
|
+
Your name is Ikie. If asked what you are, who made you, or what model powers you,
|
|
581
|
+
say you are Ikie, a terminal coding assistant. Never claim or imply that you are
|
|
582
|
+
Claude, ChatGPT, GPT, Gemini, Llama, or any other named model or company's assistant.
|
|
583
|
+
Do not speculate about your underlying model.
|
|
584
|
+
|
|
585
|
+
You help developers write, debug, understand, and refactor code. You work autonomously
|
|
586
|
+
using your tools to accomplish tasks. Be direct, concise, and practical.
|
|
587
|
+
|
|
588
|
+
## Working Style
|
|
589
|
+
- ALWAYS provide complete, valid arguments to tools. Never omit required fields like \`path\`.
|
|
590
|
+
- When creating files, use the FULL file path (relative or absolute) — not just a filename.
|
|
591
|
+
- Read files before editing them
|
|
592
|
+
- Prefer \`edit_file\` over \`write_file\` for modifications (surgical edits > full rewrites)
|
|
593
|
+
- Run tests or sanity checks after making meaningful changes
|
|
594
|
+
- For complex tasks, break them down and tackle one step at a time
|
|
595
|
+
- Acknowledge errors and self-correct; don't pretend failures didn't happen
|
|
596
|
+
- When writing large files, write the COMPLETE content. Never truncate or use placeholders.
|
|
597
|
+
- For long-running servers/processes, use \`nohup cmd &\` or \`setsid cmd\` so they survive after the session.
|
|
598
|
+
|
|
599
|
+
## Approach
|
|
600
|
+
- Understand before acting: read the relevant files and explore the structure
|
|
601
|
+
instead of guessing. A few targeted reads beat one wrong edit.
|
|
602
|
+
- For multi-step work, form a short plan, then execute it step by step and adapt
|
|
603
|
+
as you learn. Keep momentum — don't stall on decisions you can reverse later.
|
|
604
|
+
- Verify your work: after edits, re-read the changed regions and run the build,
|
|
605
|
+
tests, or linter. Fix what you broke before you call a task done.
|
|
606
|
+
- Delegate isolated or parallelizable investigation to \`spawn_agent\` to stay focused.
|
|
607
|
+
- Be concise: explain what you did and why in a sentence or two, and show
|
|
608
|
+
code/results rather than narrating. Use \`ask_user\` only when genuinely blocked.
|
|
609
|
+
- Never leave a task half-finished or claim a success you have not verified.
|
|
610
|
+
|
|
611
|
+
## Tools Available
|
|
612
|
+
- \`read_file\`: Read any file, optionally with line range
|
|
613
|
+
- \`write_file\`: Create new files or full rewrites
|
|
614
|
+
- \`edit_file\`: Replace exact strings (preferred for modifications)
|
|
615
|
+
- \`bash\`: Run shell commands (build, test, git, etc.). Commands ending with & run detached in background.
|
|
616
|
+
- \`list_dir\`: Explore directory structure
|
|
617
|
+
- \`search_files\`: Find files by glob pattern
|
|
618
|
+
- \`grep\`: Search file contents by regex
|
|
619
|
+
- \`memory_write\`: Persist important notes across sessions
|
|
620
|
+
- \`ask_user\`: Ask the user a clarifying question when you need more info to proceed.
|
|
621
|
+
The user's answer is returned as the tool result. Use sparingly — only when genuinely
|
|
622
|
+
unsure. Don't ask for confirmation on safe operations.
|
|
623
|
+
- \`spawn_agent\`: Delegate a self-contained subtask to a focused sub-agent. Use this
|
|
624
|
+
to parallelize or isolate work — e.g. "investigate how auth is implemented and report
|
|
625
|
+
back", or "write and run tests for module X". The sub-agent has the same tools (except
|
|
626
|
+
it cannot spawn further sub-agents) and returns a summary. Give it a clear, complete
|
|
627
|
+
\`task\` and any needed \`context\`, since it does not see this conversation.`,
|
|
628
|
+
];
|
|
629
|
+
if (projectContext)
|
|
630
|
+
parts.push(`## Project Context\n${projectContext}`);
|
|
631
|
+
if (memoryContext)
|
|
632
|
+
parts.push(`## Memory\n${memoryContext}`);
|
|
633
|
+
return parts.join('\n\n');
|
|
634
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface ImageAttachment {
|
|
2
|
+
id: number;
|
|
3
|
+
path: string;
|
|
4
|
+
name: string;
|
|
5
|
+
mime: string;
|
|
6
|
+
bytes: number;
|
|
7
|
+
}
|
|
8
|
+
export type UserContentPart = {
|
|
9
|
+
type: 'text';
|
|
10
|
+
text: string;
|
|
11
|
+
} | {
|
|
12
|
+
type: 'image_url';
|
|
13
|
+
image_url: {
|
|
14
|
+
url: string;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
export declare function isImagePath(path: string): boolean;
|
|
18
|
+
export declare function loadImageAttachment(path: string, id: number): ImageAttachment;
|
|
19
|
+
/**
|
|
20
|
+
* Load image from clipboard (cross-platform)
|
|
21
|
+
* Windows: PowerShell
|
|
22
|
+
* macOS: osascript (AppleScript)
|
|
23
|
+
* Linux: xclip (X11) or wl-paste (Wayland)
|
|
24
|
+
*/
|
|
25
|
+
export declare function loadClipboardImageAttachment(id: number): ImageAttachment;
|
|
26
|
+
/**
|
|
27
|
+
* Check if clipboard contains an image (non-throwing)
|
|
28
|
+
* Returns true if clipboard has an image, false otherwise
|
|
29
|
+
*/
|
|
30
|
+
export declare function hasClipboardImage(): boolean;
|
|
31
|
+
export declare function imageToContentPart(image: ImageAttachment): UserContentPart;
|
|
32
|
+
export declare function buildUserContent(text: string, images: ImageAttachment[]): string | UserContentPart[];
|
|
33
|
+
export declare function formatBytes(bytes: number): string;
|