ikie-cli 0.1.3 → 0.1.5
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/README.md +13 -5
- package/dist/agent.d.ts +6 -0
- package/dist/agent.js +110 -33
- package/dist/auth.js +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +3 -7
- package/dist/index.js +6 -24
- package/dist/repl.js +192 -34
- package/dist/theme.d.ts +2 -1
- package/dist/theme.js +53 -24
- package/dist/tools.d.ts +1 -0
- package/dist/tools.js +149 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npm install -g ikie-cli
|
|
7
|
+
ikie login
|
|
7
8
|
ikie "write a rust web server"
|
|
8
9
|
```
|
|
9
10
|
|
|
10
11
|
## Features
|
|
11
12
|
|
|
12
|
-
-
|
|
13
|
-
- 10 built-in tools: read/write/edit files, bash, search, grep, memory, agent spawn
|
|
13
|
+
- 12 built-in tools: read/write/edit files, bash, search, grep, memory, agent spawn, web fetch & search
|
|
14
14
|
- Interactive REPL with slash commands, markdown rendering, and session management
|
|
15
15
|
- One-shot mode: `ikie "your prompt"`
|
|
16
16
|
- Image paste support (Ctrl+V)
|
|
@@ -22,14 +22,22 @@ ikie "write a rust web server"
|
|
|
22
22
|
# Install
|
|
23
23
|
npm install -g ikie
|
|
24
24
|
|
|
25
|
-
#
|
|
26
|
-
|
|
25
|
+
# Sign in to your ikie account
|
|
26
|
+
ikie login
|
|
27
27
|
|
|
28
28
|
# Start coding
|
|
29
29
|
ikie "refactor this file and add tests"
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
## Web access
|
|
33
|
+
|
|
34
|
+
ikie can read pages and search the web:
|
|
35
|
+
|
|
36
|
+
- **`fetch_url`** — read any page or URL (HTML is stripped to plain text).
|
|
37
|
+
- **`web_search`** — search the web and get back titles, URLs, and snippets. Powered by the ikie
|
|
38
|
+
server — no separate search API key needed; your ikie login is enough.
|
|
39
|
+
|
|
32
40
|
## Requirements
|
|
33
41
|
|
|
34
42
|
- Node.js 20+
|
|
35
|
-
-
|
|
43
|
+
- An ikie account — sign in with `ikie login`
|
package/dist/agent.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export interface AgentOptions {
|
|
|
7
7
|
signal?: AbortSignal;
|
|
8
8
|
startedAt?: number;
|
|
9
9
|
}
|
|
10
|
+
/** Plan = read-only research + propose; Agent = full execution. */
|
|
11
|
+
export type AgentMode = 'agent' | 'plan';
|
|
10
12
|
export interface AgentTurnStats {
|
|
11
13
|
modelCalls: number;
|
|
12
14
|
toolCalls: number;
|
|
@@ -37,11 +39,14 @@ export declare class Agent {
|
|
|
37
39
|
private activeTurnStats;
|
|
38
40
|
private activeChangedFiles;
|
|
39
41
|
private lastTurnStats;
|
|
42
|
+
private mode;
|
|
40
43
|
constructor(client: OpenAI, config: IkieConfig, systemPrompt: string, depth?: number);
|
|
41
44
|
clearConversation(): void;
|
|
42
45
|
getConversation(): ChatCompletionMessageParam[];
|
|
43
46
|
setConversation(messages: ChatCompletionMessageParam[]): void;
|
|
44
47
|
getLastTurnStats(): AgentTurnStats;
|
|
48
|
+
getMode(): AgentMode;
|
|
49
|
+
setMode(mode: AgentMode): void;
|
|
45
50
|
send(userMessage: string | UserContentPart[], opts?: AgentOptions): Promise<void>;
|
|
46
51
|
private recordChangedFile;
|
|
47
52
|
private groupToolCalls;
|
|
@@ -59,4 +64,5 @@ export declare class Agent {
|
|
|
59
64
|
private checkPermission;
|
|
60
65
|
}
|
|
61
66
|
export declare const SUBAGENT_FRAMING = "You are a focused sub-agent spawned by Ikie to autonomously complete ONE specific task.\nWork independently \u2014 do not ask the user questions. Use your tools to gather what you\nneed, do the work, and verify it. When finished, your FINAL message must be a concise\nsummary of what you did and any key results (paths changed, findings, answers). That\nsummary is the only thing returned to the main agent, so make it self-contained.";
|
|
67
|
+
export declare const PLAN_MODE_ADDENDUM = "\n\n## PLAN MODE (read-only)\nYou are currently in **plan mode**. You have ONLY read-only tools (read_file,\nlist_dir, search_files, grep, fetch_url, web_search, spawn_agent, ask_user). You CANNOT write files, edit\nfiles, run shell commands, or change anything \u2014 those tools are unavailable and any\nattempt will be blocked.\n\nYour job is to investigate and produce a clear, actionable plan:\n- Explore the relevant files and understand the current state before proposing anything.\n- Do NOT describe changes as if you already made them. You haven't.\n- End your response with a concise plan: a short **## Plan** heading followed by\n numbered steps. Name the specific files to change and what to change in each. Call\n out risks, assumptions, or open questions.\n- Keep it tight \u2014 enough to execute from, not an essay.\n\nAfter you present the plan, the user will be asked whether to execute it. On approval,\nyou'll be switched to agent mode and asked to carry out exactly this plan.";
|
|
62
68
|
export declare function buildSystemPrompt(projectContext: string, memoryContext: string): string;
|
package/dist/agent.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import * as readline from 'node:readline';
|
|
3
|
-
import { TOOL_DEFS, SAFE_TOOLS, formatToolArgs, executeTool } from './tools.js';
|
|
3
|
+
import { TOOL_DEFS, SAFE_TOOLS, PLAN_TOOLS, formatToolArgs, executeTool } from './tools.js';
|
|
4
4
|
import { renderMarkdown, extractThinkTags } from './renderer.js';
|
|
5
5
|
import { c, toolLine, permissionPrompt, toolSuccessLine, toolErrorLine, InlineSpinner } from './theme.js';
|
|
6
6
|
export function estimateTokens(chars) {
|
|
@@ -47,6 +47,8 @@ function toolPhaseLabel(name) {
|
|
|
47
47
|
case 'list_dir': return 'Listing directory';
|
|
48
48
|
case 'search_files': return 'Searching';
|
|
49
49
|
case 'grep': return 'Searching';
|
|
50
|
+
case 'fetch_url': return 'Fetching';
|
|
51
|
+
case 'web_search': return 'Searching web';
|
|
50
52
|
default: return `Preparing ${name}`;
|
|
51
53
|
}
|
|
52
54
|
}
|
|
@@ -62,6 +64,7 @@ export class Agent {
|
|
|
62
64
|
activeTurnStats = null;
|
|
63
65
|
activeChangedFiles = new Set();
|
|
64
66
|
lastTurnStats = { modelCalls: 0, toolCalls: 0, filesChanged: 0 };
|
|
67
|
+
mode = 'agent';
|
|
65
68
|
constructor(client, config, systemPrompt, depth = 0) {
|
|
66
69
|
this.client = client;
|
|
67
70
|
this.config = config;
|
|
@@ -81,6 +84,12 @@ export class Agent {
|
|
|
81
84
|
getLastTurnStats() {
|
|
82
85
|
return { ...this.lastTurnStats };
|
|
83
86
|
}
|
|
87
|
+
getMode() {
|
|
88
|
+
return this.mode;
|
|
89
|
+
}
|
|
90
|
+
setMode(mode) {
|
|
91
|
+
this.mode = mode;
|
|
92
|
+
}
|
|
84
93
|
async send(userMessage, opts = {}) {
|
|
85
94
|
this.activeTurnStats = { modelCalls: 0, toolCalls: 0, filesChanged: 0 };
|
|
86
95
|
this.activeChangedFiles = new Set();
|
|
@@ -170,6 +179,21 @@ export class Agent {
|
|
|
170
179
|
return {};
|
|
171
180
|
}
|
|
172
181
|
});
|
|
182
|
+
// Plan mode is read-only. The model normally isn't even offered mutating
|
|
183
|
+
// tools (see buildParams), but refuse here too as defense-in-depth.
|
|
184
|
+
if (this.mode === 'plan' && !PLAN_TOOLS.has(group[0].name)) {
|
|
185
|
+
if (this.activeTurnStats)
|
|
186
|
+
this.activeTurnStats.toolCalls += group.length;
|
|
187
|
+
process.stdout.write(`\n${this.indent}${toolLine(group[0].name, formatToolArgs(group[0].name, inputs[0])).trimStart()}\n`);
|
|
188
|
+
process.stdout.write(`${this.indent}${toolErrorLine('blocked · plan mode is read-only')}\n`);
|
|
189
|
+
for (const tc of group) {
|
|
190
|
+
this.conversation.push({
|
|
191
|
+
role: 'tool', tool_call_id: tc.id,
|
|
192
|
+
content: 'Blocked: plan mode is read-only. Do not attempt changes — propose a plan instead. The user can switch to agent mode to apply it.',
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
173
197
|
if (group.length === 1) {
|
|
174
198
|
if (this.activeTurnStats)
|
|
175
199
|
this.activeTurnStats.toolCalls++;
|
|
@@ -220,8 +244,8 @@ export class Agent {
|
|
|
220
244
|
}
|
|
221
245
|
const ms = Date.now() - t0;
|
|
222
246
|
const lineStr = errors === 0
|
|
223
|
-
? toolSuccessLine(ms,
|
|
224
|
-
: toolErrorLine(`${errors} of ${group.length} operations
|
|
247
|
+
? toolSuccessLine(ms, `${group.length} operations`)
|
|
248
|
+
: toolErrorLine(`${errors} of ${group.length} operations`);
|
|
225
249
|
process.stdout.write(`${this.indent}${lineStr}\n`);
|
|
226
250
|
}
|
|
227
251
|
}
|
|
@@ -240,9 +264,15 @@ export class Agent {
|
|
|
240
264
|
}
|
|
241
265
|
}
|
|
242
266
|
buildParams() {
|
|
243
|
-
|
|
267
|
+
let tools = this.depth >= 1
|
|
244
268
|
? TOOL_DEFS.filter(t => t.function.name !== 'spawn_agent')
|
|
245
269
|
: TOOL_DEFS;
|
|
270
|
+
// Plan mode: only offer read-only tools, and steer toward proposing a plan.
|
|
271
|
+
let systemContent = this.systemPrompt;
|
|
272
|
+
if (this.mode === 'plan') {
|
|
273
|
+
tools = tools.filter(t => PLAN_TOOLS.has(t.function.name));
|
|
274
|
+
systemContent += PLAN_MODE_ADDENDUM;
|
|
275
|
+
}
|
|
246
276
|
return {
|
|
247
277
|
model: this.config.model,
|
|
248
278
|
max_tokens: this.config.maxTokens,
|
|
@@ -252,7 +282,7 @@ export class Agent {
|
|
|
252
282
|
presence_penalty: this.config.presencePenalty,
|
|
253
283
|
frequency_penalty: this.config.frequencyPenalty,
|
|
254
284
|
messages: [
|
|
255
|
-
{ role: 'system', content:
|
|
285
|
+
{ role: 'system', content: systemContent },
|
|
256
286
|
...this.conversation,
|
|
257
287
|
],
|
|
258
288
|
tools,
|
|
@@ -587,9 +617,29 @@ Work independently — do not ask the user questions. Use your tools to gather w
|
|
|
587
617
|
need, do the work, and verify it. When finished, your FINAL message must be a concise
|
|
588
618
|
summary of what you did and any key results (paths changed, findings, answers). That
|
|
589
619
|
summary is the only thing returned to the main agent, so make it self-contained.`;
|
|
620
|
+
// Appended to the system prompt while in PLAN mode. The model is given only
|
|
621
|
+
// read-only tools (see buildParams); this steers it to research and propose.
|
|
622
|
+
export const PLAN_MODE_ADDENDUM = `
|
|
623
|
+
|
|
624
|
+
## PLAN MODE (read-only)
|
|
625
|
+
You are currently in **plan mode**. You have ONLY read-only tools (read_file,
|
|
626
|
+
list_dir, search_files, grep, fetch_url, web_search, spawn_agent, ask_user). You CANNOT write files, edit
|
|
627
|
+
files, run shell commands, or change anything — those tools are unavailable and any
|
|
628
|
+
attempt will be blocked.
|
|
629
|
+
|
|
630
|
+
Your job is to investigate and produce a clear, actionable plan:
|
|
631
|
+
- Explore the relevant files and understand the current state before proposing anything.
|
|
632
|
+
- Do NOT describe changes as if you already made them. You haven't.
|
|
633
|
+
- End your response with a concise plan: a short **## Plan** heading followed by
|
|
634
|
+
numbered steps. Name the specific files to change and what to change in each. Call
|
|
635
|
+
out risks, assumptions, or open questions.
|
|
636
|
+
- Keep it tight — enough to execute from, not an essay.
|
|
637
|
+
|
|
638
|
+
After you present the plan, the user will be asked whether to execute it. On approval,
|
|
639
|
+
you'll be switched to agent mode and asked to carry out exactly this plan.`;
|
|
590
640
|
export function buildSystemPrompt(projectContext, memoryContext) {
|
|
591
641
|
const parts = [
|
|
592
|
-
`You are Ikie, an
|
|
642
|
+
`You are Ikie, an elite agentic software engineer running in the terminal.
|
|
593
643
|
|
|
594
644
|
## Identity
|
|
595
645
|
Your name is Ikie. If asked what you are, who made you, or what model powers you,
|
|
@@ -598,42 +648,64 @@ Claude, ChatGPT, GPT, Gemini, Llama, or any other named model or company's assis
|
|
|
598
648
|
Do not speculate about your underlying model.
|
|
599
649
|
|
|
600
650
|
You help developers write, debug, understand, and refactor code. You work autonomously
|
|
601
|
-
using your tools to accomplish tasks. Be direct, concise, and
|
|
651
|
+
using your tools to accomplish tasks. Be direct, concise, and pragmatic. Optimize for
|
|
652
|
+
the user's actual goal, not the literal letter of the request — but never overstep into
|
|
653
|
+
changes they didn't ask for.
|
|
602
654
|
|
|
603
655
|
## Response Formatting
|
|
604
656
|
- Use **markdown formatting** in all your responses for better readability
|
|
605
|
-
- Use **bold** for
|
|
606
|
-
- Use
|
|
607
|
-
- Use
|
|
608
|
-
- Use
|
|
609
|
-
-
|
|
610
|
-
|
|
611
|
-
- Use tables for structured data comparisons
|
|
612
|
-
- Keep responses **concise but well-formatted** - the terminal renderer supports rich markdown
|
|
657
|
+
- Use **bold** for key takeaways, \`inline code\` for identifiers, file names, commands
|
|
658
|
+
- Use fenced code blocks with language tags for multi-line code
|
|
659
|
+
- Use bullet/numbered lists for steps and options; headers (##, ###) to organize long replies
|
|
660
|
+
- Use tables for structured comparisons
|
|
661
|
+
- Keep responses **concise but well-formatted** — lead with the answer, then detail.
|
|
662
|
+
Don't narrate routine tool calls; show results. The terminal renders rich markdown.
|
|
613
663
|
|
|
614
664
|
## Working Style
|
|
615
665
|
- ALWAYS provide complete, valid arguments to tools. Never omit required fields like \`path\`.
|
|
616
|
-
- When creating files, use the FULL file path
|
|
617
|
-
- Read files before editing them
|
|
618
|
-
-
|
|
619
|
-
-
|
|
620
|
-
-
|
|
621
|
-
-
|
|
622
|
-
|
|
623
|
-
|
|
666
|
+
- When creating files, use the FULL file path — not just a filename.
|
|
667
|
+
- Read files before editing them. Prefer \`edit_file\` (surgical) over \`write_file\` (full rewrite).
|
|
668
|
+
- When writing files, write the COMPLETE content. Never truncate or leave \`// ...\` placeholders.
|
|
669
|
+
- For complex tasks, break them down and tackle one step at a time.
|
|
670
|
+
- Acknowledge errors and self-correct; never pretend a failure didn't happen.
|
|
671
|
+
- For long-running servers/processes, use \`nohup cmd &\` or \`setsid cmd\` so they survive the session.
|
|
672
|
+
|
|
673
|
+
## Engineering Principles
|
|
674
|
+
- **Match the codebase.** Before writing code, infer the project's conventions — language,
|
|
675
|
+
framework, formatting, naming, error handling, file layout — and follow them. Check
|
|
676
|
+
imports/neighboring files for the libraries actually in use; never assume a dependency exists.
|
|
677
|
+
- **Smallest correct change.** Solve the problem at its root, not with a patch over a symptom.
|
|
678
|
+
Avoid unrelated refactors and scope creep. Don't reformat code you aren't changing.
|
|
679
|
+
- **Type safety & correctness.** Honor existing types; avoid \`any\`/unsafe casts. Handle the
|
|
680
|
+
error and edge cases (empty, null, boundary, concurrency), not just the happy path.
|
|
681
|
+
- **No secrets in code.** Never hardcode keys, tokens, or credentials. Read from env/config.
|
|
682
|
+
Never log or echo secrets. Validate and sanitize all external input.
|
|
683
|
+
- **Clarity over cleverness.** Write code a teammate can read. Name things well. Add comments
|
|
684
|
+
only for non-obvious *why*, not to restate the *what*.
|
|
685
|
+
- **Leave it green.** Don't introduce dead code, unused imports, or commented-out blocks.
|
|
686
|
+
|
|
687
|
+
## Modern Design (when building UI / frontend)
|
|
688
|
+
- **Aesthetic by default.** Produce clean, modern, professional interfaces — generous
|
|
689
|
+
whitespace, clear visual hierarchy, consistent spacing scale, restrained palette.
|
|
690
|
+
- **Design tokens, not magic numbers.** Reuse the project's theme/variables (Tailwind config,
|
|
691
|
+
CSS custom properties, design system). Don't scatter ad-hoc hex colors or pixel values.
|
|
692
|
+
- **Responsive & accessible.** Mobile-first; fluid layouts. Semantic HTML, proper
|
|
693
|
+
labels/alt text, keyboard focus states, sufficient contrast (WCAG AA), respect reduced-motion.
|
|
694
|
+
- **Layout with flex/grid.** Use flexbox and CSS grid for fluid, mobile-first layouts.
|
|
695
|
+
- **Polish the details.** Hover/focus/active/disabled states, loading and empty states, smooth
|
|
696
|
+
but subtle transitions, sensible defaults. Avoid layout shift.
|
|
697
|
+
- **Reuse before you build.** Prefer existing components and utilities over new one-offs.
|
|
624
698
|
|
|
625
699
|
## Approach
|
|
626
|
-
- Understand before acting: read the relevant files and explore the structure
|
|
627
|
-
|
|
628
|
-
- For multi-step work, form a short plan, then execute it step by step and adapt
|
|
629
|
-
|
|
630
|
-
- Verify your work: after edits, re-read the changed regions and run the build,
|
|
631
|
-
|
|
700
|
+
- Understand before acting: read the relevant files and explore the structure instead of
|
|
701
|
+
guessing. A few targeted reads beat one wrong edit.
|
|
702
|
+
- For multi-step work, form a short plan, then execute it step by step and adapt as you learn.
|
|
703
|
+
Keep momentum — don't stall on decisions you can reverse later.
|
|
704
|
+
- Verify your work: after edits, re-read the changed regions and run the build, tests, or
|
|
705
|
+
linter. Fix what you broke before you call a task done.
|
|
632
706
|
- Delegate isolated or parallelizable investigation to \`spawn_agent\` to stay focused.
|
|
633
|
-
-
|
|
634
|
-
code
|
|
635
|
-
- Be concise but clear: explain what you did and why in well-formatted points.
|
|
636
|
-
Show code/results rather than narrating. Use \`ask_user\` only when genuinely blocked.
|
|
707
|
+
- Be concise but clear: explain what you did and why in well-formatted points. Show
|
|
708
|
+
code/results rather than narrating. Use \`ask_user\` only when genuinely blocked.
|
|
637
709
|
- Never leave a task half-finished or claim a success you have not verified.
|
|
638
710
|
|
|
639
711
|
## Tools Available
|
|
@@ -645,6 +717,11 @@ using your tools to accomplish tasks. Be direct, concise, and practical.
|
|
|
645
717
|
- \`search_files\`: Find files by glob pattern
|
|
646
718
|
- \`grep\`: Search file contents by regex
|
|
647
719
|
- \`memory_write\`: Persist important notes across sessions
|
|
720
|
+
- \`fetch_url\`: Fetch a web page or URL and return its readable text (HTML is stripped to plain text).
|
|
721
|
+
Use it to read docs, articles, or API responses when you have a URL.
|
|
722
|
+
- \`web_search\`: Search the web for a query and get back titles, URLs, and snippets. Works
|
|
723
|
+
automatically for logged-in ikie users — no separate API key needed. Combine with \`fetch_url\`
|
|
724
|
+
to read a promising result in full.
|
|
648
725
|
- \`ask_user\`: Ask the user a clarifying question when you need more info to proceed.
|
|
649
726
|
The user's answer is returned as the tool result. Use sparingly — only when genuinely
|
|
650
727
|
unsure. Don't ask for confirmation on safe operations.
|
package/dist/auth.js
CHANGED
package/dist/config.d.ts
CHANGED
|
@@ -37,5 +37,5 @@ export declare function getApiKey(cfg: IkieConfig): string | undefined;
|
|
|
37
37
|
export declare function isLoggedIn(cfg: IkieConfig): boolean;
|
|
38
38
|
/** Persist the hosted account login (ik_live_ key from `ikie login`). */
|
|
39
39
|
export declare function saveLogin(apiKey: string): void;
|
|
40
|
-
/** Clear the hosted account login
|
|
40
|
+
/** Clear the hosted account login. */
|
|
41
41
|
export declare function clearLogin(): void;
|
package/dist/config.js
CHANGED
|
@@ -17,7 +17,7 @@ const DEFAULTS = {
|
|
|
17
17
|
model: DEFAULT_MODEL,
|
|
18
18
|
maxTokens: 32768,
|
|
19
19
|
autoApprove: false,
|
|
20
|
-
baseURL:
|
|
20
|
+
baseURL: IKIE_API_BASE,
|
|
21
21
|
temperature: 0.6,
|
|
22
22
|
topP: 0.95,
|
|
23
23
|
topK: 40,
|
|
@@ -47,10 +47,7 @@ export function saveConfig(patch) {
|
|
|
47
47
|
writeFileSync(CONFIG_FILE, JSON.stringify({ ...current, ...patch }, null, 2));
|
|
48
48
|
}
|
|
49
49
|
export function getApiKey(cfg) {
|
|
50
|
-
return
|
|
51
|
-
process.env.IKIE_API_KEY ??
|
|
52
|
-
cfg.account?.apiKey ??
|
|
53
|
-
cfg.apiKey);
|
|
50
|
+
return cfg.account?.apiKey;
|
|
54
51
|
}
|
|
55
52
|
/** True when signed into a hosted ikie account. */
|
|
56
53
|
export function isLoggedIn(cfg) {
|
|
@@ -60,10 +57,9 @@ export function isLoggedIn(cfg) {
|
|
|
60
57
|
export function saveLogin(apiKey) {
|
|
61
58
|
saveConfig({ account: { apiKey, loggedInAt: Date.now() }, baseURL: IKIE_API_BASE });
|
|
62
59
|
}
|
|
63
|
-
/** Clear the hosted account login
|
|
60
|
+
/** Clear the hosted account login. */
|
|
64
61
|
export function clearLogin() {
|
|
65
62
|
const current = loadConfig();
|
|
66
63
|
delete current.account;
|
|
67
|
-
current.baseURL = FIREWORKS_BASE_URL;
|
|
68
64
|
writeFileSync(CONFIG_FILE, JSON.stringify(current, null, 2));
|
|
69
65
|
}
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import process from 'process';
|
|
3
3
|
import OpenAI from 'openai';
|
|
4
4
|
import minimist from 'minimist';
|
|
5
|
-
import { loadConfig, getApiKey,
|
|
5
|
+
import { loadConfig, getApiKey, IKIE_API_BASE, DEFAULT_MODEL } from './config.js';
|
|
6
6
|
import { detectProjectContext, formatContextForPrompt } from './context.js';
|
|
7
7
|
import { loadAllMemory, formatMemoryForPrompt } from './memory.js';
|
|
8
8
|
import { buildSystemPrompt, Agent } from './agent.js';
|
|
@@ -10,7 +10,7 @@ import { startREPL } from './repl.js';
|
|
|
10
10
|
import { login, logout } from './auth.js';
|
|
11
11
|
import { c, errorLine } from './theme.js';
|
|
12
12
|
const argv = minimist(process.argv.slice(2), {
|
|
13
|
-
string: ['model', '
|
|
13
|
+
string: ['model', 'rpm'],
|
|
14
14
|
boolean: ['help', 'version', 'yes', 'verbose'],
|
|
15
15
|
alias: { h: 'help', v: 'version', y: 'yes', m: 'model' },
|
|
16
16
|
});
|
|
@@ -28,19 +28,14 @@ ${c.primary('Usage:')}
|
|
|
28
28
|
${c.primary('Options:')}
|
|
29
29
|
${c.accent('-m, --model')} ${c.muted('<id>')} Model ID (default: ${c.dim(DEFAULT_MODEL)})
|
|
30
30
|
${c.accent('-y, --yes')} Auto-approve all tool executions
|
|
31
|
-
${c.accent('--api-key')} ${c.muted('<key>')} Fireworks API key
|
|
32
|
-
${c.accent('--base-url')} ${c.muted('<url>')} API base URL (default: Fireworks AI)
|
|
33
31
|
${c.accent('--rpm')} ${c.muted('<number>')} Max model requests per minute (default: 10)
|
|
34
32
|
${c.accent('--verbose')} Debug output
|
|
35
33
|
${c.accent('-h, --help')} Show this help
|
|
36
34
|
${c.accent('-v, --version')} Show version
|
|
37
35
|
|
|
38
|
-
${c.primary('Environment:')}
|
|
39
|
-
${c.muted('FIREWORKS_API_KEY')} Fireworks AI API key
|
|
40
|
-
${c.muted('IKIE_API_KEY')} Alternative key env var
|
|
41
|
-
|
|
42
36
|
${c.primary('In-session commands:')}
|
|
43
|
-
${c.muted('/help /clear /memory /session /
|
|
37
|
+
${c.muted('/help /clear /memory /session /plan /agent /usage /model /exit')}
|
|
38
|
+
${c.muted('Shift+Tab Toggle plan ⇄ agent mode')}
|
|
44
39
|
${c.muted('!<cmd> Run shell command directly')}
|
|
45
40
|
${c.muted('Ctrl+V Paste image from clipboard')}
|
|
46
41
|
`);
|
|
@@ -78,37 +73,24 @@ async function main() {
|
|
|
78
73
|
config.model = argv.model;
|
|
79
74
|
if (argv.yes)
|
|
80
75
|
config.autoApprove = true;
|
|
81
|
-
if (argv['api-key'])
|
|
82
|
-
config.apiKey = argv['api-key'];
|
|
83
|
-
if (argv['base-url'])
|
|
84
|
-
config.baseURL = argv['base-url'];
|
|
85
76
|
if (argv.rpm) {
|
|
86
77
|
const rpm = Number(argv.rpm);
|
|
87
78
|
if (Number.isFinite(rpm) && rpm > 0)
|
|
88
79
|
config.requestsPerMinute = Math.floor(rpm);
|
|
89
80
|
}
|
|
90
|
-
// When signed into a hosted account, default to the ikie API endpoint
|
|
91
|
-
// unless the user explicitly overrode it on the CLI.
|
|
92
|
-
if (isLoggedIn(config) && !argv['base-url']) {
|
|
93
|
-
config.baseURL = IKIE_API_BASE;
|
|
94
|
-
}
|
|
95
81
|
const apiKey = getApiKey(config);
|
|
96
82
|
if (!apiKey) {
|
|
97
83
|
console.error(`
|
|
98
84
|
${errorLine('Not signed in.')}
|
|
99
85
|
|
|
100
|
-
Sign in to
|
|
86
|
+
Sign in to continue:
|
|
101
87
|
${c.accent('ikie login')}
|
|
102
|
-
|
|
103
|
-
Or bring your own Fireworks key:
|
|
104
|
-
${c.accent('export FIREWORKS_API_KEY=fw_...')}
|
|
105
|
-
${c.accent('ikie --api-key fw_...')}
|
|
106
88
|
`);
|
|
107
89
|
process.exit(1);
|
|
108
90
|
}
|
|
109
91
|
const client = new OpenAI({
|
|
110
92
|
apiKey,
|
|
111
|
-
baseURL: config.baseURL ??
|
|
93
|
+
baseURL: config.baseURL ?? IKIE_API_BASE,
|
|
112
94
|
});
|
|
113
95
|
const projectCtx = detectProjectContext();
|
|
114
96
|
const projectContextStr = formatContextForPrompt(projectCtx);
|
package/dist/repl.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import * as readline from 'node:readline';
|
|
2
2
|
import { execSync } from 'child_process';
|
|
3
3
|
import { restoreStdinListeners } from './agent.js';
|
|
4
|
-
import { c, PROMPT, CONTINUE_PROMPT, printPromptHeader, drawBanner, infoLine, successLine, errorLine, THEMES, setTheme, stripAnsi, } from './theme.js';
|
|
4
|
+
import { c, PROMPT, CONTINUE_PROMPT, printPromptHeader, modeTag, drawBanner, infoLine, successLine, errorLine, THEMES, setTheme, stripAnsi, } from './theme.js';
|
|
5
5
|
import { renderMarkdown } from './renderer.js';
|
|
6
6
|
import { loadAllMemory } from './memory.js';
|
|
7
|
-
import { HOME_DIR, saveConfig, DEFAULT_MODEL,
|
|
7
|
+
import { HOME_DIR, saveConfig, DEFAULT_MODEL, IKIE_HOST, IKIE_API_BASE, isLoggedIn, getApiKey } from './config.js';
|
|
8
8
|
import { login, logout } from './auth.js';
|
|
9
9
|
import { join as pathJoin } from 'path';
|
|
10
10
|
import { deleteSession, listSessions, loadSession, normalizeSessionName, saveSession } from './session.js';
|
|
11
11
|
import { buildUserContent, formatBytes, loadClipboardImageAttachment, loadImageAttachment, hasClipboardImage } from './attachments.js';
|
|
12
12
|
async function fetchModelsFromServer(config) {
|
|
13
|
-
const baseUrl = config.baseURL ||
|
|
13
|
+
const baseUrl = config.baseURL || IKIE_API_BASE;
|
|
14
14
|
const apiUrl = baseUrl.includes('/api/v1')
|
|
15
15
|
? baseUrl.replace('/api/v1', '/api/models')
|
|
16
16
|
: `${IKIE_HOST}/api/models`;
|
|
@@ -63,10 +63,9 @@ const SLASH_CMDS = [
|
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
name: 'session',
|
|
66
|
-
desc: '
|
|
66
|
+
desc: 'Load/manage chat sessions',
|
|
67
67
|
subs: [
|
|
68
68
|
{ name: 'list', desc: 'List sessions' },
|
|
69
|
-
{ name: 'save', desc: 'Save current session' },
|
|
70
69
|
{ name: 'load', desc: 'Load a session' },
|
|
71
70
|
{ name: 'new', desc: 'Start fresh session' },
|
|
72
71
|
{ name: 'delete', desc: 'Delete a session' },
|
|
@@ -94,6 +93,10 @@ const SLASH_CMDS = [
|
|
|
94
93
|
{ name: 'theme', desc: 'Change visual theme', args: '[name]' },
|
|
95
94
|
{ name: 'rpm', desc: 'Set request limit', args: '[number]' },
|
|
96
95
|
{ name: 'tokens', desc: 'Token estimate' },
|
|
96
|
+
{ name: 'usage', desc: 'Show account usage & credit' },
|
|
97
|
+
{ name: 'mode', desc: 'Show/set mode (plan|agent)', args: '[plan|agent]' },
|
|
98
|
+
{ name: 'plan', desc: 'Switch to plan mode (read-only)' },
|
|
99
|
+
{ name: 'agent', desc: 'Switch to agent mode (full)' },
|
|
97
100
|
{ name: 'login', desc: 'Sign in to ikie account' },
|
|
98
101
|
{ name: 'logout', desc: 'Sign out of ikie account' },
|
|
99
102
|
{ name: 'exit', desc: 'Exit Ikie' },
|
|
@@ -128,9 +131,8 @@ ${c.primary.bold('Ikie Commands')}
|
|
|
128
131
|
${c.warning('/image list')} List queued images
|
|
129
132
|
${c.warning('/image clear')} Clear queued images
|
|
130
133
|
${c.warning('/session list')} List saved sessions
|
|
131
|
-
${c.warning('/session save')} Save current chat
|
|
132
134
|
${c.warning('/session load')} Load saved chat
|
|
133
|
-
${c.warning('/session new')} Start a fresh chat
|
|
135
|
+
${c.warning('/session new')} Start a fresh chat (auto-saved)
|
|
134
136
|
${c.warning('/context')} Show current project context
|
|
135
137
|
${c.warning('/models')} List available models
|
|
136
138
|
${c.warning('/model <name>')} Switch AI model
|
|
@@ -148,6 +150,10 @@ ${c.primary.bold('Ikie Commands')}
|
|
|
148
150
|
${c.warning('/theme')} Pick a theme interactively
|
|
149
151
|
${c.warning('/rpm [number]')} Show or set request limit
|
|
150
152
|
${c.warning('/tokens')} Show conversation token estimate
|
|
153
|
+
${c.warning('/usage')} Show account usage & credit balance
|
|
154
|
+
${c.warning('/plan')} Plan mode — research & propose, no changes
|
|
155
|
+
${c.warning('/agent')} Agent mode — full execution (default)
|
|
156
|
+
${c.warning('/mode')} Show or set mode ${c.muted('(Shift+Tab toggles)')}
|
|
151
157
|
${c.warning('/login')} Sign in to ikie account
|
|
152
158
|
${c.warning('/logout')} Sign out of ikie account
|
|
153
159
|
${c.warning('/exit')} Exit Ikie
|
|
@@ -258,7 +264,7 @@ async function handleSlashCommand(input, agent, config, projectContext, rl, sess
|
|
|
258
264
|
if (sub === 'list' || sub === 'ls') {
|
|
259
265
|
const sessions = listSessions();
|
|
260
266
|
if (!sessions.length) {
|
|
261
|
-
console.log(infoLine('No saved sessions yet.
|
|
267
|
+
console.log(infoLine('No saved sessions yet. Every chat is saved automatically.'));
|
|
262
268
|
return true;
|
|
263
269
|
}
|
|
264
270
|
console.log(`\n${c.primary.bold('Sessions')}`);
|
|
@@ -268,13 +274,7 @@ async function handleSlashCommand(input, agent, config, projectContext, rl, sess
|
|
|
268
274
|
const model = s.model ? ` ${c.muted(s.model.split('/').pop() ?? s.model)}` : '';
|
|
269
275
|
console.log(` ${active} ${c.secondary(s.name.padEnd(24))} ${c.muted(String(s.messageCount).padStart(3) + ' msgs')} ${c.dim(when)}${model}`);
|
|
270
276
|
}
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
if (sub === 'save') {
|
|
274
|
-
const name = normalizeSessionName(nameArg || sessionState.activeName);
|
|
275
|
-
const saved = saveSession(name, config.model, agent.getConversation());
|
|
276
|
-
sessionState.activeName = saved.name;
|
|
277
|
-
console.log(successLine(`Session saved as "${saved.name}".`));
|
|
277
|
+
console.log(`\n ${c.muted('Resume with')} ${c.warning('/session load <name>')}\n`);
|
|
278
278
|
return true;
|
|
279
279
|
}
|
|
280
280
|
if (sub === 'load') {
|
|
@@ -297,8 +297,8 @@ async function handleSlashCommand(input, agent, config, projectContext, rl, sess
|
|
|
297
297
|
}
|
|
298
298
|
if (sub === 'new') {
|
|
299
299
|
agent.clearConversation();
|
|
300
|
-
sessionState.activeName = nameArg ? normalizeSessionName(nameArg) :
|
|
301
|
-
console.log(successLine(
|
|
300
|
+
sessionState.activeName = nameArg ? normalizeSessionName(nameArg) : normalizeSessionName();
|
|
301
|
+
console.log(successLine(`Started new session "${sessionState.activeName}" (auto-saved).`));
|
|
302
302
|
return true;
|
|
303
303
|
}
|
|
304
304
|
if (sub === 'delete' || sub === 'rm') {
|
|
@@ -318,7 +318,7 @@ async function handleSlashCommand(input, agent, config, projectContext, rl, sess
|
|
|
318
318
|
}
|
|
319
319
|
return true;
|
|
320
320
|
}
|
|
321
|
-
console.log(errorLine('Usage: /session list|
|
|
321
|
+
console.log(errorLine('Usage: /session list|load <name>|new [name]|delete <name>'));
|
|
322
322
|
return true;
|
|
323
323
|
}
|
|
324
324
|
case 'context':
|
|
@@ -371,7 +371,7 @@ async function handleSlashCommand(input, agent, config, projectContext, rl, sess
|
|
|
371
371
|
console.log(` ${c.secondary('Auto Approve:')} ${c.white(config.autoApprove ? 'Yes' : 'No')}`);
|
|
372
372
|
console.log(` ${c.secondary('Requests/min:')} ${c.white(config.requestsPerMinute)}`);
|
|
373
373
|
console.log(` ${c.secondary('Theme:')} ${c.white(config.theme)}`);
|
|
374
|
-
console.log(` ${c.secondary('Base URL:')} ${c.white(config.baseURL ??
|
|
374
|
+
console.log(` ${c.secondary('Base URL:')} ${c.white(config.baseURL ?? IKIE_API_BASE)}`);
|
|
375
375
|
console.log(`\n ${c.muted('Use')} ${c.warning('/settings <option> <value>')} ${c.muted('to change settings')}`);
|
|
376
376
|
console.log(` ${c.muted('Options:')} model, temperature, max-tokens, top-p, top-k, auto-approve, rpm, theme`);
|
|
377
377
|
return true;
|
|
@@ -530,6 +530,56 @@ async function handleSlashCommand(input, agent, config, projectContext, rl, sess
|
|
|
530
530
|
console.log(infoLine(`~${Math.round(approxTokens).toLocaleString()} tokens in conversation`));
|
|
531
531
|
return true;
|
|
532
532
|
}
|
|
533
|
+
case 'plan': {
|
|
534
|
+
agent.setMode('plan');
|
|
535
|
+
console.log(successLine(`Switched to ${modeTag('plan')} mode — read-only. Ikie will research and propose a plan.`));
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
case 'agent': {
|
|
539
|
+
agent.setMode('agent');
|
|
540
|
+
console.log(successLine(`Switched to ${modeTag('agent')} mode — full execution.`));
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
case 'mode': {
|
|
544
|
+
const want = (args[0] ?? '').toLowerCase();
|
|
545
|
+
if (want === 'plan' || want === 'agent') {
|
|
546
|
+
agent.setMode(want);
|
|
547
|
+
console.log(successLine(`Mode set to ${modeTag(want)}.`));
|
|
548
|
+
}
|
|
549
|
+
else if (want) {
|
|
550
|
+
console.log(errorLine('Usage: /mode [plan|agent]'));
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
console.log(infoLine(`Current mode: ${modeTag(agent.getMode())} ${c.muted('· Shift+Tab to toggle')}`));
|
|
554
|
+
}
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
case 'usage': {
|
|
558
|
+
if (!isLoggedIn(config)) {
|
|
559
|
+
console.log(infoLine('Not signed in. Run /login to connect your ikie account.'));
|
|
560
|
+
return true;
|
|
561
|
+
}
|
|
562
|
+
const apiKey = getApiKey(config);
|
|
563
|
+
if (!apiKey) {
|
|
564
|
+
console.log(errorLine('No API key found. Run /login again.'));
|
|
565
|
+
return true;
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
const res = await fetch(`${IKIE_HOST}/api/usage`, {
|
|
569
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
570
|
+
});
|
|
571
|
+
if (!res.ok) {
|
|
572
|
+
console.log(errorLine(`Couldn't fetch usage (HTTP ${res.status}).`));
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
const data = (await res.json());
|
|
576
|
+
printUsage(data);
|
|
577
|
+
}
|
|
578
|
+
catch (err) {
|
|
579
|
+
console.log(errorLine(`Couldn't reach ikie at ${IKIE_HOST}: ${err instanceof Error ? err.message : String(err)}`));
|
|
580
|
+
}
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
533
583
|
case 'exit':
|
|
534
584
|
case 'quit':
|
|
535
585
|
case 'q':
|
|
@@ -540,6 +590,43 @@ async function handleSlashCommand(input, agent, config, projectContext, rl, sess
|
|
|
540
590
|
return true;
|
|
541
591
|
}
|
|
542
592
|
}
|
|
593
|
+
function fmtUsd(n) {
|
|
594
|
+
if (n === 0)
|
|
595
|
+
return '$0.00';
|
|
596
|
+
if (n < 0.01)
|
|
597
|
+
return `$${n.toFixed(6)}`;
|
|
598
|
+
return `$${n.toFixed(2)}`;
|
|
599
|
+
}
|
|
600
|
+
function fmtTokens(n) {
|
|
601
|
+
if (n < 1000)
|
|
602
|
+
return String(n);
|
|
603
|
+
if (n < 1_000_000)
|
|
604
|
+
return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
|
|
605
|
+
return (n / 1_000_000).toFixed(2).replace(/\.00$/, '') + 'M';
|
|
606
|
+
}
|
|
607
|
+
function printUsage(data) {
|
|
608
|
+
const s = data.stats;
|
|
609
|
+
const row = (label, value) => console.log(` ${c.secondary(label.padEnd(18))} ${c.white(value)}`);
|
|
610
|
+
console.log(`\n${c.primary.bold('Account Usage')}\n`);
|
|
611
|
+
row('Credit balance', c.success.bold(fmtUsd(data.balance)).toString());
|
|
612
|
+
row('Total spent', fmtUsd(s.totalCost));
|
|
613
|
+
console.log();
|
|
614
|
+
row('Requests · 30d', s.requests30d.toLocaleString());
|
|
615
|
+
row('Requests · 24h', s.requests24h.toLocaleString());
|
|
616
|
+
row('Requests · all', s.totalRequests.toLocaleString());
|
|
617
|
+
console.log();
|
|
618
|
+
row('Total tokens', fmtTokens(s.totalTokens));
|
|
619
|
+
row('Read / Write', `${fmtTokens(s.readTokens)} / ${fmtTokens(s.writeTokens)}`);
|
|
620
|
+
row('Cached tokens', fmtTokens(s.cachedTokens));
|
|
621
|
+
console.log();
|
|
622
|
+
row('Avg latency', s.avgLatencyMs ? `${s.avgLatencyMs}ms` : '—');
|
|
623
|
+
row('Cache hit rate', `${(s.cacheHitRate * 100).toFixed(1)}%`);
|
|
624
|
+
if (data.model) {
|
|
625
|
+
row('Model', data.model.name);
|
|
626
|
+
row('Context window', `${data.model.context_window.toLocaleString()} tokens`);
|
|
627
|
+
}
|
|
628
|
+
console.log(`\n ${c.muted('Full dashboard:')} ${c.info(`${IKIE_HOST}/dashboard`)}\n`);
|
|
629
|
+
}
|
|
543
630
|
function promptLine(prompt) {
|
|
544
631
|
return new Promise((resolve) => {
|
|
545
632
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -549,6 +636,30 @@ function promptLine(prompt) {
|
|
|
549
636
|
});
|
|
550
637
|
});
|
|
551
638
|
}
|
|
639
|
+
/**
|
|
640
|
+
* After a plan-mode turn, ask whether to execute the proposed plan.
|
|
641
|
+
* Reads a single keypress. Assumes stdin is already in raw mode (it is, during
|
|
642
|
+
* a turn). Returns true on y / Enter, false otherwise.
|
|
643
|
+
*/
|
|
644
|
+
function confirmExecutePlan() {
|
|
645
|
+
return new Promise((resolve) => {
|
|
646
|
+
if (!process.stdin.isTTY) {
|
|
647
|
+
resolve(false);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
process.stdout.write(`\n ${c.primary('▸')} ${c.white.bold('Plan ready.')} ${c.muted('Execute it now?')} ` +
|
|
651
|
+
`${c.success.bold('y')} ${c.muted('yes')} ${c.error.bold('n')} ${c.muted('keep planning')}\n` +
|
|
652
|
+
` ${c.muted('❯')} `);
|
|
653
|
+
const onKey = (d) => {
|
|
654
|
+
process.stdin.removeListener('data', onKey);
|
|
655
|
+
const k = d.toString().toLowerCase();
|
|
656
|
+
const yes = k === 'y' || k === '\r' || k === '\n';
|
|
657
|
+
process.stdout.write(yes ? c.success('y\n') : c.muted('n\n'));
|
|
658
|
+
resolve(yes);
|
|
659
|
+
};
|
|
660
|
+
process.stdin.on('data', onKey);
|
|
661
|
+
});
|
|
662
|
+
}
|
|
552
663
|
function printGoodbye(sessionState, agent, config) {
|
|
553
664
|
const msgs = agent.getConversation();
|
|
554
665
|
const hasConversation = msgs.length > 0;
|
|
@@ -562,15 +673,11 @@ function printGoodbye(sessionState, agent, config) {
|
|
|
562
673
|
}
|
|
563
674
|
}
|
|
564
675
|
console.log(`\n${c.primary.bold('Goodbye!')}\n`);
|
|
565
|
-
if (sessionState.activeName) {
|
|
566
|
-
console.log(` ${c.muted('Your session')} ${c.secondary.bold(sessionState.activeName)} ${c.muted('
|
|
567
|
-
console.log(`\n ${c.muted('To
|
|
568
|
-
console.log(` ${c.accent('ikie')} ${c.warning(`/session load ${sessionState.activeName}`)}
|
|
569
|
-
|
|
570
|
-
else if (hasConversation) {
|
|
571
|
-
console.log(` ${c.warning('\u26A0')} ${c.muted('Your current conversation was not saved.')}`);
|
|
572
|
-
console.log(`\n ${c.muted('To save next time:')}`);
|
|
573
|
-
console.log(` ${c.warning('/session save')} ${c.muted('<name>')}\n`);
|
|
676
|
+
if (sessionState.activeName && hasConversation) {
|
|
677
|
+
console.log(` ${c.muted('Your session')} ${c.secondary.bold(sessionState.activeName)} ${c.muted('was saved.')}`);
|
|
678
|
+
console.log(`\n ${c.muted('To pick up where you left off:')}`);
|
|
679
|
+
console.log(` ${c.accent('ikie')} ${c.warning(`/session load ${sessionState.activeName}`)}`);
|
|
680
|
+
console.log(`\n ${c.muted('Or list everything with')} ${c.warning('/session list')}\n`);
|
|
574
681
|
}
|
|
575
682
|
console.log(` ${c.muted('Happy coding!')}\n`);
|
|
576
683
|
}
|
|
@@ -677,7 +784,11 @@ function selectThemeInteractively(rl, config) {
|
|
|
677
784
|
}
|
|
678
785
|
export async function startREPL(agent, config, projectContext, oneShot) {
|
|
679
786
|
void HISTORY_FILE;
|
|
680
|
-
|
|
787
|
+
// A `/`-prefixed launch arg (e.g. `ikie /session load foo`) is a slash
|
|
788
|
+
// COMMAND, not a prompt — run it after setup, then stay interactive.
|
|
789
|
+
// A plain launch arg is a one-shot prompt: run it once and exit.
|
|
790
|
+
const initialCommand = oneShot && oneShot.trim().startsWith('/') ? oneShot.trim() : undefined;
|
|
791
|
+
if (oneShot && !initialCommand) {
|
|
681
792
|
try {
|
|
682
793
|
await agent.send(oneShot, { autoApprove: config.autoApprove });
|
|
683
794
|
}
|
|
@@ -699,7 +810,9 @@ export async function startREPL(agent, config, projectContext, oneShot) {
|
|
|
699
810
|
let multilineBuffer = '';
|
|
700
811
|
let busy = false;
|
|
701
812
|
let ctrlCCount = 0;
|
|
702
|
-
|
|
813
|
+
// Every session auto-saves. Start with a generated name so there's always
|
|
814
|
+
// somewhere to persist to; /session load can resume it later.
|
|
815
|
+
const sessionState = { activeName: normalizeSessionName() };
|
|
703
816
|
const imageState = { nextId: 1, pending: [] };
|
|
704
817
|
let pasteSeq = 0;
|
|
705
818
|
let pasteMode = false;
|
|
@@ -731,6 +844,18 @@ export async function startREPL(agent, config, projectContext, oneShot) {
|
|
|
731
844
|
};
|
|
732
845
|
const routeInputData = (data) => {
|
|
733
846
|
const text = data.toString('utf8');
|
|
847
|
+
// Shift+Tab (CSI Z) → cycle agent⇄plan mode live at the prompt.
|
|
848
|
+
if (text === '\x1b[Z') {
|
|
849
|
+
const next = agent.getMode() === 'agent' ? 'plan' : 'agent';
|
|
850
|
+
agent.setMode(next);
|
|
851
|
+
const saved = rl.line || '';
|
|
852
|
+
process.stdout.write('\r\x1b[2K'); // clear the current prompt line
|
|
853
|
+
process.stdout.write(` ${c.muted('▸')} ${modeTag(next)} ${c.muted('mode')}\n`);
|
|
854
|
+
printPromptHeader(next);
|
|
855
|
+
rl.setPrompt(PROMPT);
|
|
856
|
+
process.stdout.write(rl.getPrompt() + saved);
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
734
859
|
// Check for Ctrl+V (0x16) - try to paste image from clipboard
|
|
735
860
|
if (data.length === 1 && data[0] === 0x16) {
|
|
736
861
|
// Check if clipboard has an image
|
|
@@ -813,7 +938,7 @@ export async function startREPL(agent, config, projectContext, oneShot) {
|
|
|
813
938
|
rl.setPrompt(CONTINUE_PROMPT);
|
|
814
939
|
}
|
|
815
940
|
else {
|
|
816
|
-
printPromptHeader();
|
|
941
|
+
printPromptHeader(agent.getMode());
|
|
817
942
|
if (imageState.pending.length) {
|
|
818
943
|
const labels = imageState.pending.map(image => `[Image #${image.id}]`).join(' ');
|
|
819
944
|
process.stdout.write(`${c.muted(' attached')} ${c.secondary(labels)}\n`);
|
|
@@ -883,8 +1008,10 @@ export async function startREPL(agent, config, projectContext, oneShot) {
|
|
|
883
1008
|
const userContent = buildUserContent(agentInput, imagesForTurn);
|
|
884
1009
|
imageState.pending = [];
|
|
885
1010
|
let restoredImages = false;
|
|
1011
|
+
// Clear the (now-submitted) prompt line. The agent's InlineSpinner provides
|
|
1012
|
+
// the "Working… · Esc to interrupt" feedback and clears itself when done, so
|
|
1013
|
+
// we don't print a static line here (it would never get cleared).
|
|
886
1014
|
process.stdout.write('\r\x1b[2K');
|
|
887
|
-
process.stdout.write(`${c.muted(' Working. Press Esc to interrupt.')}\n`);
|
|
888
1015
|
busy = true;
|
|
889
1016
|
rl.pause();
|
|
890
1017
|
const taskStartedAt = Date.now();
|
|
@@ -926,6 +1053,24 @@ export async function startREPL(agent, config, projectContext, oneShot) {
|
|
|
926
1053
|
if (sessionState.activeName && !abortController.signal.aborted) {
|
|
927
1054
|
saveSession(sessionState.activeName, config.model, agent.getConversation());
|
|
928
1055
|
}
|
|
1056
|
+
// Plan mode: a plan was just proposed — offer to execute it.
|
|
1057
|
+
if (agent.getMode() === 'plan' && !abortController.signal.aborted) {
|
|
1058
|
+
process.stdin.removeListener('data', cancelHandler);
|
|
1059
|
+
const go = await confirmExecutePlan();
|
|
1060
|
+
process.stdin.on('data', cancelHandler);
|
|
1061
|
+
if (go && !abortController.signal.aborted) {
|
|
1062
|
+
agent.setMode('agent');
|
|
1063
|
+
process.stdout.write(` ${c.muted('→ switching to')} ${modeTag('agent')} ${c.muted('mode, executing…')}\n`);
|
|
1064
|
+
await agent.send('Execute the plan you just proposed. Make the changes now.', {
|
|
1065
|
+
autoApprove: config.autoApprove,
|
|
1066
|
+
signal: abortController.signal,
|
|
1067
|
+
startedAt: taskStartedAt,
|
|
1068
|
+
});
|
|
1069
|
+
if (sessionState.activeName && !abortController.signal.aborted) {
|
|
1070
|
+
saveSession(sessionState.activeName, config.model, agent.getConversation());
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
929
1074
|
}
|
|
930
1075
|
catch (err) {
|
|
931
1076
|
if (!abortController.signal.aborted) {
|
|
@@ -944,13 +1089,16 @@ export async function startREPL(agent, config, projectContext, oneShot) {
|
|
|
944
1089
|
}
|
|
945
1090
|
const elapsed = formatElapsed(Date.now() - taskStartedAt);
|
|
946
1091
|
process.stdin.removeListener('data', cancelHandler);
|
|
947
|
-
if (process.stdin.isTTY)
|
|
948
|
-
process.stdin.setRawMode(false);
|
|
949
1092
|
process.stdin.pause();
|
|
950
1093
|
restoreStdinListeners(savedDataListeners, savedKeypressListeners);
|
|
951
1094
|
busy = false;
|
|
952
1095
|
ctrlCCount = 0;
|
|
953
1096
|
rl.resume();
|
|
1097
|
+
// readline was created with terminal:true and owns echo while in raw mode.
|
|
1098
|
+
// Re-enable raw mode (the turn's cancel handler may have toggled it) so the
|
|
1099
|
+
// TTY doesn't also echo input — otherwise every line is echoed twice.
|
|
1100
|
+
if (process.stdin.isTTY)
|
|
1101
|
+
process.stdin.setRawMode(true);
|
|
954
1102
|
const status = abortController.signal.aborted
|
|
955
1103
|
? c.warning(formatTaskTimeline(agent, elapsed, 'cancelled'))
|
|
956
1104
|
: taskFailed
|
|
@@ -964,5 +1112,15 @@ export async function startREPL(agent, config, projectContext, oneShot) {
|
|
|
964
1112
|
rl.on('line', (raw) => {
|
|
965
1113
|
chain = chain.then(() => handleLine(raw));
|
|
966
1114
|
});
|
|
1115
|
+
// If launched with a slash command (e.g. `ikie /session load foo`), run it
|
|
1116
|
+
// once now, then hand control to the user with the result already applied.
|
|
1117
|
+
if (initialCommand) {
|
|
1118
|
+
try {
|
|
1119
|
+
await handleSlashCommand(initialCommand, agent, config, projectContext, rl, sessionState, imageState);
|
|
1120
|
+
}
|
|
1121
|
+
catch (err) {
|
|
1122
|
+
console.error(errorLine(err instanceof Error ? err.message : String(err)));
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
967
1125
|
showPrompt();
|
|
968
1126
|
}
|
package/dist/theme.d.ts
CHANGED
|
@@ -38,7 +38,8 @@ export declare const c: {
|
|
|
38
38
|
export declare function stripAnsi(str: string): string;
|
|
39
39
|
export declare const BANNER_ROWS = 9;
|
|
40
40
|
export declare function drawBanner(model: string): void;
|
|
41
|
-
export declare function
|
|
41
|
+
export declare function modeTag(mode: 'agent' | 'plan'): string;
|
|
42
|
+
export declare function printPromptHeader(mode?: 'agent' | 'plan'): void;
|
|
42
43
|
export declare const PROMPT: string;
|
|
43
44
|
export declare const CONTINUE_PROMPT: string;
|
|
44
45
|
export declare class InlineSpinner {
|
package/dist/theme.js
CHANGED
|
@@ -182,6 +182,8 @@ function truncate(str, max) {
|
|
|
182
182
|
return str.slice(0, Math.max(0, max - 3)) + '...';
|
|
183
183
|
}
|
|
184
184
|
function formatModel(model) {
|
|
185
|
+
if (!model)
|
|
186
|
+
return 'none';
|
|
185
187
|
const parts = model.split('/');
|
|
186
188
|
return parts[parts.length - 1] || model;
|
|
187
189
|
}
|
|
@@ -248,22 +250,24 @@ export function drawBanner(model) {
|
|
|
248
250
|
'',
|
|
249
251
|
`${c.muted('/help')} ${c.muted('·')} ${c.muted('/theme')} ${c.muted('·')} ${c.muted('Esc cancels running work')}`,
|
|
250
252
|
];
|
|
251
|
-
process.stdout.write(
|
|
253
|
+
process.stdout.write('\n');
|
|
252
254
|
for (let i = 0; i < Math.max(art.length, meta.length); i++) {
|
|
253
255
|
const artLine = art[i] ?? '';
|
|
254
256
|
const artColored = chalk.hex(colors[i % colors.length]).bold(padVisible(artLine, artWidth));
|
|
255
|
-
const metaLine = meta[i] ? `
|
|
256
|
-
|
|
257
|
-
process.stdout.write(`${c.muted('│')} ${padVisible(row, innerWidth - 1)}${c.muted('│')}\n`);
|
|
257
|
+
const metaLine = meta[i] ? ` ${meta[i]}` : '';
|
|
258
|
+
process.stdout.write(` ${artColored}${metaLine}\n`);
|
|
258
259
|
}
|
|
259
|
-
process.stdout.write(
|
|
260
|
+
process.stdout.write('\n');
|
|
260
261
|
}
|
|
261
|
-
export function
|
|
262
|
+
export function modeTag(mode) {
|
|
263
|
+
return mode === 'plan' ? c.warning.bold('plan') : c.success('agent');
|
|
264
|
+
}
|
|
265
|
+
export function printPromptHeader(mode = 'agent') {
|
|
262
266
|
const cwdName = basename(process.cwd()) || '/';
|
|
263
267
|
const branch = getGitBranchFast();
|
|
264
268
|
const gitSegment = branch ? ` ${c.muted('on')} ${c.secondary(branch)}` : '';
|
|
265
269
|
const themeSegment = ` ${c.muted('theme')} ${c.secondary(activeTheme.name)}`;
|
|
266
|
-
process.stdout.write(`\n${c.primary('╭─')} ${c.primary.bold('ikie')}${gitSegment}${themeSegment} ${c.muted('in')} ${c.accent(cwdName)}\n`);
|
|
270
|
+
process.stdout.write(`\n${c.primary('╭─')} ${c.primary.bold('ikie')} ${c.muted('·')} ${modeTag(mode)}${gitSegment}${themeSegment} ${c.muted('in')} ${c.accent(cwdName)}\n`);
|
|
267
271
|
}
|
|
268
272
|
export const PROMPT = c.primary('╰─❯ ');
|
|
269
273
|
export const CONTINUE_PROMPT = c.primary('│ ');
|
|
@@ -326,21 +330,45 @@ export class InlineSpinner {
|
|
|
326
330
|
this.draw();
|
|
327
331
|
}
|
|
328
332
|
}
|
|
333
|
+
// Maps a tool name to a clean verb + a dot color reflecting its effect.
|
|
334
|
+
// Read-only tools are calm (info), mutating tools warn, exec/agent stand out.
|
|
335
|
+
function toolMeta(rawName) {
|
|
336
|
+
const base = rawName.split(/\s|×/)[0];
|
|
337
|
+
switch (base) {
|
|
338
|
+
case 'read_file': return { verb: 'read', tint: c.info };
|
|
339
|
+
case 'write_file': return { verb: 'write', tint: c.warning };
|
|
340
|
+
case 'edit_file': return { verb: 'edit', tint: c.warning };
|
|
341
|
+
case 'bash': return { verb: 'run', tint: c.accent };
|
|
342
|
+
case 'list_dir': return { verb: 'list', tint: c.info };
|
|
343
|
+
case 'grep': return { verb: 'grep', tint: c.info };
|
|
344
|
+
case 'search_files': return { verb: 'find', tint: c.info };
|
|
345
|
+
case 'memory_write': return { verb: 'memory', tint: c.secondary };
|
|
346
|
+
case 'spawn_agent': return { verb: 'agent', tint: c.secondary };
|
|
347
|
+
case 'ask_user': return { verb: 'ask', tint: c.info };
|
|
348
|
+
default: return { verb: base, tint: c.primary };
|
|
349
|
+
}
|
|
350
|
+
}
|
|
329
351
|
export function toolLine(name, args) {
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
return `${c.primary('╭─')} ${c.accent.bold(`[${tag}]`)} ${c.warning.bold(name)}${c.muted('(')}${c.accent(args)}${c.muted(')')}`;
|
|
352
|
+
const { verb, tint } = toolMeta(name);
|
|
353
|
+
// Surface a "×N" batch suffix as a subtle count badge.
|
|
354
|
+
const countMatch = name.match(/×(\d+)/);
|
|
355
|
+
const badge = countMatch ? ` ${c.muted(`×${countMatch[1]}`)}` : '';
|
|
356
|
+
const label = c.white.bold(verb.padEnd(6));
|
|
357
|
+
return `${tint('●')} ${label}${badge} ${c.dim(args)}`;
|
|
337
358
|
}
|
|
338
359
|
export function toolSuccessLine(ms, preview) {
|
|
339
|
-
const
|
|
340
|
-
|
|
360
|
+
const time = c.muted(formatDuration(ms));
|
|
361
|
+
const tail = preview ? ` ${c.dim(preview)}` : '';
|
|
362
|
+
return ` ${c.muted('⎿')} ${time}${tail}`;
|
|
341
363
|
}
|
|
342
364
|
export function toolErrorLine(msg) {
|
|
343
|
-
return
|
|
365
|
+
return ` ${c.muted('⎿')} ${c.error('failed')} ${c.error(msg)}`;
|
|
366
|
+
}
|
|
367
|
+
function formatDuration(ms) {
|
|
368
|
+
if (ms < 1000)
|
|
369
|
+
return `${ms}ms`;
|
|
370
|
+
const s = ms / 1000;
|
|
371
|
+
return s < 10 ? `${s.toFixed(1)}s` : `${Math.round(s)}s`;
|
|
344
372
|
}
|
|
345
373
|
export function successLine(msg) {
|
|
346
374
|
return ` ${c.success('ok')} ${c.muted(msg)}`;
|
|
@@ -355,11 +383,12 @@ export function infoLine(msg) {
|
|
|
355
383
|
return ` ${c.info('info')} ${c.muted(msg)}`;
|
|
356
384
|
}
|
|
357
385
|
export function permissionPrompt(toolName, preview) {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
` ${c.
|
|
361
|
-
`${c.
|
|
362
|
-
`${c.
|
|
363
|
-
`${c.
|
|
364
|
-
|
|
386
|
+
const { verb, tint } = toolMeta(toolName);
|
|
387
|
+
return (`\n ${tint('●')} ${c.white.bold('permission')} ${c.muted('·')} ${c.white(verb)} ${c.dim(preview)}\n` +
|
|
388
|
+
` ${c.muted('⎿')} ` +
|
|
389
|
+
`${c.success.bold('y')} ${c.muted('allow')} ` +
|
|
390
|
+
`${c.error.bold('n')} ${c.muted('deny')} ` +
|
|
391
|
+
`${c.info.bold('a')} ${c.muted('always')} ` +
|
|
392
|
+
`${c.muted.bold('!')} ${c.muted('never')}\n` +
|
|
393
|
+
` ${c.muted('❯')} `);
|
|
365
394
|
}
|
package/dist/tools.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type OpenAI from 'openai';
|
|
2
2
|
export declare const TOOL_DEFS: OpenAI.Chat.ChatCompletionTool[];
|
|
3
3
|
export declare const SAFE_TOOLS: Set<string>;
|
|
4
|
+
export declare const PLAN_TOOLS: Set<string>;
|
|
4
5
|
export declare function formatToolArgs(name: string, input: Record<string, unknown>): string;
|
|
5
6
|
export declare function executeTool(name: string, input: Record<string, unknown>): Promise<string>;
|
package/dist/tools.js
CHANGED
|
@@ -167,11 +167,45 @@ export const TOOL_DEFS = [
|
|
|
167
167
|
},
|
|
168
168
|
},
|
|
169
169
|
},
|
|
170
|
+
{
|
|
171
|
+
type: 'function',
|
|
172
|
+
function: {
|
|
173
|
+
name: 'fetch_url',
|
|
174
|
+
description: 'Fetch a web page (or any URL) over HTTP(S) and return its readable text content. HTML is stripped to plain text; JSON and plain-text responses are returned as-is. Use this to read documentation, articles, or API responses from the web.',
|
|
175
|
+
parameters: {
|
|
176
|
+
type: 'object',
|
|
177
|
+
properties: {
|
|
178
|
+
url: { type: 'string', description: 'The URL to fetch. If no scheme is given, https:// is assumed.' },
|
|
179
|
+
max_chars: { type: 'number', description: 'Maximum characters to return (default 10000). The result is truncated past this.' },
|
|
180
|
+
},
|
|
181
|
+
required: ['url'],
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
type: 'function',
|
|
187
|
+
function: {
|
|
188
|
+
name: 'web_search',
|
|
189
|
+
description: 'Search the web and return a list of results (title, URL, snippet). Requires a search API key configured in the environment (BRAVE_API_KEY, or GOOGLE_API_KEY + GOOGLE_CX). If no key is configured the tool reports that and returns no results — pair it with fetch_url to read a result page in full.',
|
|
190
|
+
parameters: {
|
|
191
|
+
type: 'object',
|
|
192
|
+
properties: {
|
|
193
|
+
query: { type: 'string', description: 'The search query.' },
|
|
194
|
+
count: { type: 'number', description: 'Number of results to return (default 5, max 10).' },
|
|
195
|
+
},
|
|
196
|
+
required: ['query'],
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
170
200
|
];
|
|
171
201
|
// ─── Safe tools (auto-approved) ───────────────────────────────────────────────
|
|
172
202
|
// spawn_agent is "safe" at the dispatch layer — the tools the sub-agent itself
|
|
173
203
|
// runs go through their own approval inside the sub-agent loop.
|
|
174
|
-
export const SAFE_TOOLS = new Set(['read_file', 'list_dir', 'search_files', 'grep', 'memory_write', 'spawn_agent', 'ask_user']);
|
|
204
|
+
export const SAFE_TOOLS = new Set(['read_file', 'list_dir', 'search_files', 'grep', 'memory_write', 'spawn_agent', 'ask_user', 'fetch_url', 'web_search']);
|
|
205
|
+
// Tools available in PLAN mode — read-only exploration plus delegation/questions.
|
|
206
|
+
// Everything that mutates the filesystem or runs commands (write_file, edit_file,
|
|
207
|
+
// bash, memory_write) is intentionally excluded so plan mode can only research.
|
|
208
|
+
export const PLAN_TOOLS = new Set(['read_file', 'list_dir', 'search_files', 'grep', 'spawn_agent', 'ask_user', 'fetch_url', 'web_search']);
|
|
175
209
|
// ─── Display helpers ──────────────────────────────────────────────────────────
|
|
176
210
|
export function formatToolArgs(name, input) {
|
|
177
211
|
const p = (v) => v != null && v !== 'undefined' ? String(v) : '(missing)';
|
|
@@ -202,6 +236,12 @@ export function formatToolArgs(name, input) {
|
|
|
202
236
|
const task = String(input.task ?? '');
|
|
203
237
|
return `"${task.length > 56 ? task.slice(0, 56) + '…' : task}"`;
|
|
204
238
|
}
|
|
239
|
+
case 'fetch_url':
|
|
240
|
+
return `"${p(input.url)}"`;
|
|
241
|
+
case 'web_search': {
|
|
242
|
+
const q = String(input.query ?? '');
|
|
243
|
+
return `"${q.length > 56 ? q.slice(0, 56) + '…' : q}"`;
|
|
244
|
+
}
|
|
205
245
|
default:
|
|
206
246
|
return JSON.stringify(input).slice(0, 80);
|
|
207
247
|
}
|
|
@@ -466,6 +506,112 @@ async function memoryWrite(input) {
|
|
|
466
506
|
return `Error saving to ${scope} memory: ${msg}`;
|
|
467
507
|
}
|
|
468
508
|
}
|
|
509
|
+
// ─── Web ──────────────────────────────────────────────────────────────────────
|
|
510
|
+
function htmlToText(html) {
|
|
511
|
+
return html
|
|
512
|
+
// Drop non-content blocks entirely.
|
|
513
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
514
|
+
.replace(/<(script|style|noscript|head|svg)[\s\S]*?<\/\1>/gi, '')
|
|
515
|
+
// Turn block-level boundaries into newlines so text doesn't run together.
|
|
516
|
+
.replace(/<\/(p|div|section|article|header|footer|li|tr|h[1-6]|ul|ol|table|blockquote)>/gi, '\n')
|
|
517
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
518
|
+
.replace(/<li[^>]*>/gi, '\n• ')
|
|
519
|
+
// Strip all remaining tags.
|
|
520
|
+
.replace(/<[^>]+>/g, '')
|
|
521
|
+
// Decode the handful of entities that actually matter.
|
|
522
|
+
.replace(/ /gi, ' ')
|
|
523
|
+
.replace(/&/gi, '&')
|
|
524
|
+
.replace(/</gi, '<')
|
|
525
|
+
.replace(/>/gi, '>')
|
|
526
|
+
.replace(/"/gi, '"')
|
|
527
|
+
.replace(/'|'/gi, "'")
|
|
528
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
|
|
529
|
+
// Collapse whitespace and excess blank lines.
|
|
530
|
+
.replace(/[ \t]+/g, ' ')
|
|
531
|
+
.replace(/\n[ \t]+/g, '\n')
|
|
532
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
533
|
+
.trim();
|
|
534
|
+
}
|
|
535
|
+
async function fetchUrl(input) {
|
|
536
|
+
let url = (input.url ?? '').trim();
|
|
537
|
+
if (!url || url === 'undefined')
|
|
538
|
+
return 'Error: url is required for fetch_url';
|
|
539
|
+
if (!/^[a-z]+:\/\//i.test(url))
|
|
540
|
+
url = `https://${url}`;
|
|
541
|
+
const maxChars = input.max_chars && input.max_chars > 0 ? input.max_chars : 10000;
|
|
542
|
+
const controller = new AbortController();
|
|
543
|
+
const timer = setTimeout(() => controller.abort(), 15000);
|
|
544
|
+
try {
|
|
545
|
+
const res = await fetch(url, {
|
|
546
|
+
redirect: 'follow',
|
|
547
|
+
signal: controller.signal,
|
|
548
|
+
headers: {
|
|
549
|
+
'User-Agent': 'Mozilla/5.0 (compatible; IkieBot/1.0; +https://ikie.dev)',
|
|
550
|
+
Accept: 'text/html,application/xhtml+xml,application/json;q=0.9,text/plain;q=0.8,*/*;q=0.5',
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
if (!res.ok)
|
|
554
|
+
return `Error: HTTP ${res.status} ${res.statusText} for ${url}`;
|
|
555
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
556
|
+
const body = await res.text();
|
|
557
|
+
const text = /text\/html|application\/xhtml/i.test(contentType) ? htmlToText(body) : body.trim();
|
|
558
|
+
const header = `URL: ${res.url}\nContent-Type: ${contentType || 'unknown'}\n\n`;
|
|
559
|
+
if (!text)
|
|
560
|
+
return `${header}(empty response)`;
|
|
561
|
+
if (text.length > maxChars) {
|
|
562
|
+
return `${header}${text.slice(0, maxChars)}\n\n…[truncated ${text.length - maxChars} more chars]`;
|
|
563
|
+
}
|
|
564
|
+
return `${header}${text}`;
|
|
565
|
+
}
|
|
566
|
+
catch (err) {
|
|
567
|
+
const e = err;
|
|
568
|
+
if (e.name === 'AbortError')
|
|
569
|
+
return `Error: request to ${url} timed out after 15s`;
|
|
570
|
+
return `Error fetching ${url}: ${e.message ?? err}`;
|
|
571
|
+
}
|
|
572
|
+
finally {
|
|
573
|
+
clearTimeout(timer);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
async function webSearch(input) {
|
|
577
|
+
const query = (input.query ?? '').trim();
|
|
578
|
+
if (!query || query === 'undefined')
|
|
579
|
+
return 'Error: query is required for web_search';
|
|
580
|
+
const count = Math.min(Math.max(input.count ?? 5, 1), 10);
|
|
581
|
+
// Load account key at call time (same pattern as memoryWrite's dynamic import).
|
|
582
|
+
const { loadConfig, getApiKey, IKIE_API_BASE } = await import('./config.js');
|
|
583
|
+
const apiKey = getApiKey(loadConfig());
|
|
584
|
+
if (!apiKey) {
|
|
585
|
+
return 'web_search requires an ikie account. Sign in with `ikie login` — search is included with your account.';
|
|
586
|
+
}
|
|
587
|
+
try {
|
|
588
|
+
const u = new URL(`${IKIE_API_BASE}/search`);
|
|
589
|
+
u.searchParams.set('q', query);
|
|
590
|
+
u.searchParams.set('count', String(count));
|
|
591
|
+
const res = await fetch(u, {
|
|
592
|
+
headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' },
|
|
593
|
+
});
|
|
594
|
+
if (!res.ok) {
|
|
595
|
+
const body = await res.text().catch(() => res.statusText);
|
|
596
|
+
return `Error: web_search HTTP ${res.status}: ${body}`;
|
|
597
|
+
}
|
|
598
|
+
const data = await res.json();
|
|
599
|
+
return formatSearchResults(query, data.results ?? []);
|
|
600
|
+
}
|
|
601
|
+
catch (err) {
|
|
602
|
+
const e = err;
|
|
603
|
+
return `Error during web_search: ${e.message ?? err}`;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
function formatSearchResults(query, results) {
|
|
607
|
+
if (!results.length)
|
|
608
|
+
return `No results for "${query}".`;
|
|
609
|
+
const lines = results.map((r, i) => {
|
|
610
|
+
const snippet = (r.snippet ?? '').replace(/\s+/g, ' ').trim();
|
|
611
|
+
return `${i + 1}. ${r.title ?? '(untitled)'}\n ${r.url ?? ''}${snippet ? `\n ${snippet}` : ''}`;
|
|
612
|
+
});
|
|
613
|
+
return `Results for "${query}":\n\n${lines.join('\n\n')}`;
|
|
614
|
+
}
|
|
469
615
|
// ─── Dispatcher ───────────────────────────────────────────────────────────────
|
|
470
616
|
export async function executeTool(name, input) {
|
|
471
617
|
switch (name) {
|
|
@@ -477,6 +623,8 @@ export async function executeTool(name, input) {
|
|
|
477
623
|
case 'search_files': return searchFiles(input);
|
|
478
624
|
case 'grep': return grepFiles(input);
|
|
479
625
|
case 'memory_write': return memoryWrite(input);
|
|
626
|
+
case 'fetch_url': return fetchUrl(input);
|
|
627
|
+
case 'web_search': return webSearch(input);
|
|
480
628
|
default: return `Unknown tool: ${name}`;
|
|
481
629
|
}
|
|
482
630
|
}
|