mini-coder 0.7.4 → 0.8.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.
Files changed (47) hide show
  1. package/AGENTS.md +114 -0
  2. package/README.md +53 -66
  3. package/bin/mini-coder.ts +2 -0
  4. package/demo.gif +0 -0
  5. package/package.json +17 -20
  6. package/src/agent.ts +181 -274
  7. package/src/cli.ts +101 -0
  8. package/src/config.ts +150 -0
  9. package/src/prompt.ts +54 -207
  10. package/src/session.ts +124 -69
  11. package/src/tools/bash.ts +89 -0
  12. package/src/tools/common.ts +32 -0
  13. package/src/tools/edit.ts +41 -0
  14. package/src/tools/index.ts +47 -0
  15. package/src/tools/read.ts +64 -0
  16. package/src/tui/commands.ts +63 -0
  17. package/src/tui/editor.ts +291 -0
  18. package/src/tui/highlight.ts +189 -0
  19. package/src/tui/stream.ts +142 -0
  20. package/src/tui/styles.ts +42 -0
  21. package/src/tui/term.ts +436 -0
  22. package/src/tui/theme.ts +121 -0
  23. package/src/tui/tui.ts +595 -0
  24. package/src/tui/usage.ts +67 -0
  25. package/tsconfig.json +8 -8
  26. package/bin/mc.ts +0 -11
  27. package/bun.lock +0 -350
  28. package/nono-mini-coder.json +0 -42
  29. package/src/args.ts +0 -252
  30. package/src/error-handling.test.ts +0 -163
  31. package/src/git.ts +0 -23
  32. package/src/headless.ts +0 -66
  33. package/src/index.ts +0 -43
  34. package/src/models.ts +0 -191
  35. package/src/oauth.ts +0 -147
  36. package/src/shared.ts +0 -119
  37. package/src/themes.ts +0 -234
  38. package/src/tool-bash.ts +0 -77
  39. package/src/tool-edit.ts +0 -121
  40. package/src/tool-read.ts +0 -100
  41. package/src/tui-components.ts +0 -127
  42. package/src/tui-conversation.ts +0 -218
  43. package/src/tui-editor.ts +0 -29
  44. package/src/tui-overlay.ts +0 -604
  45. package/src/tui.ts +0 -314
  46. package/src/types.ts +0 -194
  47. package/src/update.ts +0 -171
package/src/agent.ts CHANGED
@@ -1,302 +1,209 @@
1
- import type {
2
- AssistantMessage,
3
- Context,
4
- ImageContent,
5
- Message,
6
- TextContent,
7
- ToolCall,
8
- ToolResultMessage,
1
+ import {
2
+ type Api,
3
+ type AssistantMessage,
4
+ type JsonObject,
5
+ type Message,
6
+ type Model,
7
+ type ModelThinkingLevel,
8
+ type Models,
9
+ type Tool,
10
+ type ToolCall,
11
+ type ToolResultMessage,
12
+ type UserMessage,
9
13
  } from "@earendil-works/pi-ai";
10
- import { createAppModels } from "./models.ts";
11
- import { estimateTokens } from "./shared";
12
- import type {
13
- AgentContex,
14
- AgentEvent,
15
- AgentToolEvent,
16
- ToolAndRunner,
17
- } from "./types";
18
-
19
- // ### JetBrains Junie: Observation Masking
20
- // Published research found that **simply hiding old tool outputs** matched the quality of full LLM summarization with **zero extra compute**:
21
- // https://blog.jetbrains.com/research/2025/12/efficient-context-management/
22
- export function compactContext(messages: Message[]) {
23
- const KEEP_OBSERVATIONS = 10;
24
-
25
- const skillReadToolCallIds = new Set<string>();
26
- for (const message of messages) {
27
- if (message.role !== "assistant") continue;
14
+ import type { Session } from "./session.ts";
15
+ import { acceptsImages, executeTool, type ToolDetails, type ToolResult } from "./tools/index.ts";
16
+
17
+ export type Phase = "preparing" | "waitingModel" | "streaming" | "runningTool" | "pausing" | "idle";
18
+
19
+ export type AgentEvent =
20
+ | { type: "phase"; phase: Phase; detail?: string }
21
+ | { type: "text"; delta: string }
22
+ | { type: "reasoning"; delta: string }
23
+ | { type: "toolCallStart"; name: string }
24
+ | { type: "toolCall"; name: string; arguments: JsonObject }
25
+ | { type: "toolOutput"; chunk: string }
26
+ | { type: "toolResult"; name: string; text: string; isError: boolean }
27
+ | { type: "message"; message: AssistantMessage }
28
+ | { type: "error"; message: string }
29
+ | { type: "cancelled" }
30
+ | { type: "complete" };
31
+
32
+ interface Interaction {
33
+ isPauseRequested(): boolean;
34
+ clearPause(): void;
35
+ requestSteering(): Promise<string>;
36
+ }
28
37
 
29
- for (const content of message.content) {
30
- if (content.type !== "toolCall") continue;
31
- if (content.name !== "read") continue;
38
+ export const NO_INTERACTION: Interaction = {
39
+ isPauseRequested: () => false,
40
+ clearPause: () => {},
41
+ requestSteering: async () => "",
42
+ };
43
+
44
+ /** What one turn needs, shared by both projections: the TUI and `--print`. */
45
+ export interface AgentOptions {
46
+ models: Models;
47
+ model: Model<Api>;
48
+ systemPrompt: string;
49
+ tools: Tool[];
50
+ thinkingEffort: ModelThinkingLevel;
51
+ session: Session;
52
+ }
32
53
 
33
- const path = content.arguments.path;
34
- if (typeof path === "string" && path.endsWith("SKILL.md")) {
35
- skillReadToolCallIds.add(content.id);
36
- }
37
- }
38
- }
54
+ interface AgentRun extends AgentOptions {
55
+ messages: Message[];
56
+ signal: AbortSignal;
57
+ interaction: Interaction;
58
+ onEvent: (event: AgentEvent) => void;
59
+ }
39
60
 
40
- const toolResultIndices: number[] = [];
41
- for (let i = 0; i < messages.length; i++) {
42
- if (messages[i].role !== "toolResult") continue;
61
+ export async function runAgentTurn(run: AgentRun): Promise<void> {
62
+ const { messages, session, signal, interaction, onEvent } = run;
43
63
 
44
- const message = messages[i] as ToolResultMessage;
45
- if (skillReadToolCallIds.has(message.toolCallId)) continue;
64
+ /** Appends a user message the model reads at the next step boundary. */
65
+ const steer = (content: string): void => {
66
+ if (content === "") return;
67
+ const message: UserMessage = { role: "user", content, timestamp: Date.now() };
68
+ messages.push(message);
69
+ session.appendMessage(message);
70
+ };
46
71
 
47
- const content = message.content;
48
- if (
49
- content.length === 1 &&
50
- content[0].type === "text" &&
51
- content[0].text.startsWith("Old environment output:")
52
- ) {
53
- continue;
72
+ /**
73
+ * Returns the steering typed while paused, or ""; the caller decides where
74
+ * it lands, because a user message between an assistant turn and its tool
75
+ * results is rejected by the provider. Null once the turn has been aborted;
76
+ * every step boundary is a cancellation point.
77
+ */
78
+ const pauseStep = async (): Promise<string | null> => {
79
+ if (signal.aborted) return null;
80
+ if (interaction.isPauseRequested()) {
81
+ interaction.clearPause();
82
+ onEvent({ type: "phase", phase: "pausing" });
83
+ const steering = await interaction.requestSteering();
84
+ onEvent({ type: "phase", phase: "idle" });
85
+ if (signal.aborted) return null;
86
+ return steering;
54
87
  }
55
- toolResultIndices.push(i);
56
- }
57
-
58
- if (toolResultIndices.length <= KEEP_OBSERVATIONS) return;
59
-
60
- for (let i = 0; i < toolResultIndices.length - KEEP_OBSERVATIONS; i++) {
61
- const idx = toolResultIndices[i];
62
- const msg = messages[idx] as ToolResultMessage;
63
- let lines = 0;
64
- let images = 0;
65
- for (const c of msg.content) {
66
- if (c.type === "text") {
67
- lines += c.text.split(/\r?\n/).length;
68
- } else if (c.type === "image") {
69
- images++;
70
- }
71
- }
72
- let text = `Old environment output: (${lines} lines omitted)`;
73
- if (images > 0) {
74
- text += ` (${images} image${images > 1 ? "s" : ""} omitted)`;
75
- }
76
- msg.content = [{ type: "text", text }];
77
- }
78
- }
79
-
80
- export async function* streamAgent(
81
- agentCtx: AgentContex,
82
- ): AsyncGenerator<AgentEvent> {
83
- const llmCtx: Context = {
84
- systemPrompt: agentCtx.systemPrompt,
85
- tools: agentCtx.tools.map((t) => t.tool),
86
- messages: agentCtx.messages,
88
+ return "";
87
89
  };
88
90
 
89
- const models = createAppModels(agentCtx.options.customProviders);
90
-
91
- // Main agent loop, continues until llm sends a response other than toolCall or has no tool calls.
92
- while (true) {
93
- const estimate = estimateTokens(JSON.stringify(llmCtx));
94
- // 80k is the agreed uppon threshold to the DUMB ZONE
95
- if (estimate > 80000) compactContext(llmCtx.messages);
96
-
97
- const s = models.streamSimple(agentCtx.options.model, llmCtx, {
98
- reasoning: agentCtx.options.effort,
99
- signal: agentCtx.signal,
91
+ for (;;) {
92
+ const steering = await pauseStep();
93
+ if (steering === null) {
94
+ onEvent({ type: "cancelled" });
95
+ return;
96
+ }
97
+ steer(steering);
98
+
99
+ onEvent({ type: "phase", phase: "preparing" });
100
+ session.appendRequest({
101
+ provider: run.model.provider,
102
+ model: run.model.id,
103
+ api: run.model.api,
104
+ thinkingEffort: run.thinkingEffort,
105
+ systemPrompt: run.systemPrompt,
106
+ tools: run.tools,
100
107
  });
101
108
 
102
- let partial: AssistantMessage | null = null;
103
- let added = false;
104
-
105
- for await (const e of s) {
106
- switch (e.type) {
107
- case "start":
108
- partial = e.partial;
109
- llmCtx.messages.push(e.partial);
110
- added = true;
111
- yield { type: "message_start", partial };
112
- break;
113
-
114
- case "text_start":
115
- case "text_delta":
116
- case "text_end":
117
- case "thinking_start":
118
- case "thinking_delta":
119
- case "thinking_end":
120
- case "toolcall_start":
121
- case "toolcall_delta":
122
- case "toolcall_end": {
123
- if (partial) {
124
- partial = e.partial;
125
- llmCtx.messages[llmCtx.messages.length - 1] = partial;
126
- yield { type: "message_update", partial };
127
- }
128
-
129
- break;
130
- }
131
-
132
- case "error": {
133
- const finalMessage = await s.result();
134
- if (!finalMessage) throw new Error(e.error.errorMessage);
135
-
136
- if (added) {
137
- llmCtx.messages[llmCtx.messages.length - 1] = finalMessage;
138
- } else {
139
- llmCtx.messages.push(finalMessage);
140
- yield { type: "message_start", partial: { ...finalMessage } };
141
- }
109
+ onEvent({ type: "phase", phase: "waitingModel" });
110
+ const context = { systemPrompt: run.systemPrompt, tools: run.tools, messages };
111
+ let stream;
112
+ try {
113
+ stream = run.models.streamSimple(run.model, context, {
114
+ reasoning: run.thinkingEffort === "off" ? undefined : run.thinkingEffort,
115
+ signal,
116
+ sessionId: session.id ?? undefined,
117
+ });
118
+ } catch (error) {
119
+ onEvent({ type: "error", message: (error as Error).message });
120
+ return;
121
+ }
142
122
 
143
- yield { type: "message_end", message: finalMessage };
144
- return;
123
+ onEvent({ type: "phase", phase: "streaming" });
124
+ try {
125
+ for await (const event of stream) {
126
+ if (event.type === "text_delta") onEvent({ type: "text", delta: event.delta });
127
+ else if (event.type === "thinking_delta") onEvent({ type: "reasoning", delta: event.delta });
128
+ // The tool's name is known as soon as the call starts being written.
129
+ else if (event.type === "toolcall_start") {
130
+ const block = event.partial.content[event.contentIndex];
131
+ if (block.type === "toolCall") onEvent({ type: "toolCallStart", name: block.name });
132
+ } else if (event.type === "toolcall_end") {
133
+ onEvent({
134
+ type: "toolCall",
135
+ name: event.toolCall.name,
136
+ arguments: event.toolCall.arguments,
137
+ });
145
138
  }
146
139
  }
140
+ } catch (error) {
141
+ onEvent({ type: "error", message: (error as Error).message });
142
+ return;
147
143
  }
148
144
 
149
- const message = await s.result();
150
- if (!message) throw new Error("Stream finished without a message");
151
-
152
- if (added) {
153
- llmCtx.messages[llmCtx.messages.length - 1] = message;
154
- } else {
155
- llmCtx.messages.push(message);
156
- yield { type: "message_start", partial: { ...message } };
157
- }
158
- yield { type: "message_end", message };
159
-
160
- const toolCalls = message.content.filter(
161
- (c): c is ToolCall => c.type === "toolCall",
162
- );
145
+ const assistant = await stream.result();
146
+ messages.push(assistant);
147
+ session.appendMessage(assistant);
148
+ onEvent({ type: "message", message: assistant });
163
149
 
164
- // Stop on errors or no tools to call.
165
- if (message.stopReason !== "toolUse" || toolCalls.length === 0) {
166
- break;
150
+ if (assistant.stopReason === "aborted") {
151
+ onEvent({ type: "cancelled" });
152
+ return;
167
153
  }
168
-
169
- const seenToolIds = new Map<string, number>();
170
- if (toolCalls.length > 0) {
171
- const ts = toolRunner(toolCalls, agentCtx.tools, agentCtx.signal);
172
-
173
- for await (const e of ts) {
174
- if (e.type === "tool_update") {
175
- // Update contex with update or add new.
176
- const existing = seenToolIds.get(e.partial.toolCallId);
177
- if (existing && existing >= 0) {
178
- const m = llmCtx.messages[existing];
179
- if (m.role === "toolResult") {
180
- llmCtx.messages[existing] = {
181
- ...m,
182
- content: [...m.content, ...e.partial.content],
183
- isError: e.partial.isError,
184
- };
185
- }
186
-
187
- yield {
188
- type: "tool_message_update",
189
- partial: e.partial,
190
- };
191
- } else {
192
- llmCtx.messages.push(e.partial);
193
- seenToolIds.set(e.partial.toolCallId, llmCtx.messages.length - 1);
194
-
195
- yield {
196
- type: "tool_message_start",
197
- partial: e.partial,
198
- };
199
- }
200
- } else if (e.type === "tool_result") {
201
- // Update context with full message and yield
202
- const idx = llmCtx.messages.findIndex(
203
- (m) =>
204
- m.role === "toolResult" && m.toolCallId === e.message.toolCallId,
205
- );
206
- if (idx >= 0) {
207
- llmCtx.messages[idx] = e.message;
208
- } else {
209
- llmCtx.messages.push(e.message);
210
- }
211
-
212
- yield {
213
- type: "tool_message_end",
214
- message: e.message,
215
- };
216
- }
217
- }
154
+ if (assistant.stopReason === "error") {
155
+ onEvent({ type: "error", message: assistant.errorMessage ?? "provider error" });
156
+ return;
218
157
  }
219
- }
220
- }
221
-
222
- // This should stay stateless, don't accumulate anything at this layer, just proxy
223
- // wrapped runners events.
224
- async function* toolRunner(
225
- toolCalls: ToolCall[],
226
- tools: ToolAndRunner[],
227
- signal?: AbortSignal,
228
- ): AsyncGenerator<AgentToolEvent> {
229
- for (const call of toolCalls) {
230
- const timestamp = Date.now();
231
- const tool = tools.find((t) => t.tool.name === call.name);
232
158
 
233
- if (!tool) {
234
- yield {
235
- type: "tool_result",
236
- message: {
237
- role: "toolResult",
238
- toolCallId: call.id,
239
- toolName: call.name,
240
- content: [{ type: "text", text: "Error: Tool not found" }],
241
- isError: true,
242
- timestamp,
243
- },
244
- };
245
- continue;
159
+ const toolCalls = assistant.content.filter((block): block is ToolCall => block.type === "toolCall");
160
+ if (toolCalls.length === 0) {
161
+ onEvent({ type: "complete" });
162
+ return;
246
163
  }
247
164
 
248
- try {
249
- for await (const e of tool.runner(call.arguments, signal)) {
250
- // yield deltas for output, full output on result.
251
- const content: TextContent = { type: "text", text: e.text };
252
-
253
- if (e.type === "output") {
254
- // handle deltas
255
- yield {
256
- type: "tool_update",
257
- partial: {
258
- role: "toolResult",
259
- toolCallId: call.id,
260
- toolName: call.name,
261
- content: [content],
262
- isError: false,
263
- timestamp,
264
- },
265
- };
266
- } else if (e.type === "result") {
267
- // handle final message, and check for images
268
- let img: ImageContent | null = null;
269
- if (e.image) {
270
- img = { ...e.image, type: "image" };
271
- }
272
-
273
- yield {
274
- type: "tool_result",
275
- message: {
276
- role: "toolResult",
277
- toolCallId: call.id,
278
- toolName: call.name,
279
- content: img ? [content, img] : [content],
280
- isError: false,
281
- timestamp,
282
- },
283
- };
165
+ // Steering is held until every result of this assistant turn has landed:
166
+ // the provider rejects a user message between an assistant turn and its
167
+ // tool results.
168
+ const held: string[] = [];
169
+ for (const call of toolCalls) {
170
+ const steering = await pauseStep();
171
+ if (steering === null) {
172
+ onEvent({ type: "cancelled" });
173
+ return;
174
+ }
175
+ if (steering !== "") held.push(steering);
176
+ onEvent({ type: "phase", phase: "runningTool", detail: call.name });
177
+
178
+ const tool = run.tools.find((candidate) => candidate.name === call.name);
179
+ let result: ToolResult;
180
+ if (tool === undefined) {
181
+ result = { text: `unknown tool: ${call.name}`, isError: true };
182
+ } else {
183
+ try {
184
+ result = await executeTool(call, {
185
+ signal,
186
+ supportsImages: acceptsImages(run.model),
187
+ onOutput: (chunk) => onEvent({ type: "toolOutput", chunk }),
188
+ });
189
+ } catch (error) {
190
+ result = { text: (error as Error).message, isError: true };
284
191
  }
285
192
  }
286
- } catch (err) {
287
- // We don't validate arguments, so it's better to show the errors.
288
- const error = err instanceof Error ? err.message : "Unknown error";
289
- yield {
290
- type: "tool_result",
291
- message: {
292
- role: "toolResult",
293
- toolCallId: call.id,
294
- toolName: call.name,
295
- content: [{ type: "text", text: `Error: ${error}` }],
296
- isError: true,
297
- timestamp,
298
- },
193
+
194
+ const toolMessage: ToolResultMessage<ToolDetails> = {
195
+ role: "toolResult",
196
+ toolCallId: call.id,
197
+ toolName: call.name,
198
+ content: [{ type: "text", text: result.text }, ...(result.images ?? [])],
199
+ details: result.details,
200
+ isError: result.isError,
201
+ timestamp: Date.now(),
299
202
  };
203
+ messages.push(toolMessage);
204
+ session.appendMessage(toolMessage);
205
+ onEvent({ type: "toolResult", name: call.name, text: result.text, isError: result.isError });
300
206
  }
207
+ steer(held.join("\n"));
301
208
  }
302
209
  }
package/src/cli.ts ADDED
@@ -0,0 +1,101 @@
1
+ import process from "node:process";
2
+ import { clampThinkingLevel, type AssistantMessage, type Message, type UserMessage } from "@earendil-works/pi-ai";
3
+ import { loadConfig, resolveModel } from "./config.ts";
4
+ import { buildSystemPrompt } from "./prompt.ts";
5
+ import { acceptsImages, toolSchemas } from "./tools/index.ts";
6
+ import { Session } from "./session.ts";
7
+ import { NO_INTERACTION, runAgentTurn, type AgentOptions } from "./agent.ts";
8
+ import { runTui } from "./tui/tui.ts";
9
+
10
+ function parseArgs(argv: string[]): { print: string | null } {
11
+ let print: string | null = null;
12
+ for (let i = 0; i < argv.length; i++) {
13
+ const match = /^(?:-p|--print)(?:=(.*))?$/.exec(argv[i]);
14
+ if (match === null) throw new Error(`unknown argument: ${argv[i]}`);
15
+ const value = match[1] ?? argv[++i];
16
+ if (value === undefined) throw new Error(`${argv[i - 1]} requires a prompt`);
17
+ print = value;
18
+ }
19
+ return { print };
20
+ }
21
+
22
+ async function runPrint(prompt: string, ctx: AgentOptions): Promise<number> {
23
+ const messages: Message[] = [];
24
+ const user: UserMessage = { role: "user", content: prompt, timestamp: Date.now() };
25
+ messages.push(user);
26
+ ctx.session.appendMessage(user);
27
+
28
+ const controller = new AbortController();
29
+ const onSignal = () => controller.abort();
30
+ process.on("SIGINT", onSignal);
31
+ process.on("SIGTERM", onSignal);
32
+
33
+ let failed = false;
34
+ let cancelled = false;
35
+ try {
36
+ await runAgentTurn({
37
+ ...ctx,
38
+ messages,
39
+ signal: controller.signal,
40
+ interaction: NO_INTERACTION,
41
+ onEvent: (event) => {
42
+ if (event.type === "toolCall") process.stderr.write(`[tool] ${event.name}\n`);
43
+ else if (event.type === "toolOutput") process.stderr.write(event.chunk);
44
+ else if (event.type === "error") {
45
+ failed = true;
46
+ process.stderr.write(`[error] ${event.message}\n`);
47
+ } else if (event.type === "cancelled") {
48
+ cancelled = true;
49
+ process.stderr.write("[cancelled]\n");
50
+ }
51
+ },
52
+ });
53
+ } catch (error) {
54
+ failed = true;
55
+ process.stderr.write(`[error] ${(error as Error).message}\n`);
56
+ } finally {
57
+ process.off("SIGINT", onSignal);
58
+ process.off("SIGTERM", onSignal);
59
+ ctx.session.close();
60
+ }
61
+
62
+ const last = messages.filter((message): message is AssistantMessage => message.role === "assistant").at(-1);
63
+ if (!failed && !cancelled && last !== undefined) {
64
+ const text = last.content
65
+ .filter((block) => block.type === "text")
66
+ .map((block) => block.text)
67
+ .join("");
68
+ if (text !== "") process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
69
+ }
70
+ return failed || cancelled ? 1 : 0;
71
+ }
72
+
73
+ async function main(): Promise<void> {
74
+ const args = parseArgs(process.argv.slice(2));
75
+ const config = loadConfig();
76
+ const { models, model } = resolveModel(config);
77
+ const session = new Session(config.sessionsDir, process.cwd());
78
+ const ctx: AgentOptions = {
79
+ models,
80
+ model,
81
+ systemPrompt: buildSystemPrompt(config),
82
+ tools: toolSchemas(config.tools, acceptsImages(model)),
83
+ thinkingEffort: clampThinkingLevel(model, config.thinkingEffort),
84
+ session,
85
+ };
86
+
87
+ if (args.print !== null) {
88
+ process.exitCode = await runPrint(args.print, ctx);
89
+ return;
90
+ }
91
+
92
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
93
+ throw new Error("interactive mode requires a TTY; use -p for non-interactive mode");
94
+ }
95
+ await runTui(ctx);
96
+ }
97
+
98
+ main().catch((error) => {
99
+ process.stderr.write(`${(error as Error).message}\n`);
100
+ process.exit(1);
101
+ });