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/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;