mini-coder 0.5.7 → 0.5.8

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/src/index.ts CHANGED
@@ -25,9 +25,11 @@ import { getEnvApiKey, getModels, getProviders } from "@mariozechner/pi-ai";
25
25
  import { getOAuthApiKey, getOAuthProviders } from "@mariozechner/pi-ai/oauth";
26
26
  import type { ToolHandler } from "./agent.ts";
27
27
  import {
28
+ type CliOptions,
28
29
  parseCliArgs,
29
30
  resolveHeadlessPrompt,
30
31
  shouldUseHeadlessMode,
32
+ type TtyState,
31
33
  } from "./cli.ts";
32
34
  import { getErrorMessage } from "./errors.ts";
33
35
  import { type GitState, getGitState } from "./git.ts";
@@ -71,8 +73,12 @@ import {
71
73
  executeEdit,
72
74
  executeReadImage,
73
75
  executeShell,
76
+ executeTodoRead,
77
+ executeTodoWrite,
74
78
  readImageTool,
75
79
  shellTool,
80
+ todoReadTool,
81
+ todoWriteTool,
76
82
  } from "./tools.ts";
77
83
  import { resolveAppVersionLabel } from "./version.ts";
78
84
 
@@ -382,11 +388,28 @@ const BUILTIN_HANDLERS: Record<string, ToolHandler> = {
382
388
  function buildTools(
383
389
  model: Model<string>,
384
390
  plugins: LoadedPlugin[],
391
+ messages: AppState["messages"],
385
392
  ): { tools: Tool[]; toolHandlers: Map<string, ToolHandler> } {
386
- const tools: Tool[] = [editTool, shellTool];
393
+ const tools: Tool[] = [editTool, shellTool, todoWriteTool, todoReadTool];
387
394
  const toolHandlers = new Map<string, ToolHandler>([
388
395
  [editTool.name, BUILTIN_HANDLERS.edit!],
389
396
  [shellTool.name, BUILTIN_HANDLERS.shell!],
397
+ [
398
+ todoWriteTool.name,
399
+ (args) =>
400
+ executeTodoWrite(
401
+ {
402
+ todos: Array.isArray(args.todos)
403
+ ? (args.todos as Array<{
404
+ content: string;
405
+ status: "pending" | "in_progress" | "completed" | "cancelled";
406
+ }>)
407
+ : [],
408
+ },
409
+ messages,
410
+ ),
411
+ ],
412
+ [todoReadTool.name, () => executeTodoRead(messages)],
390
413
  ]);
391
414
 
392
415
  // Conditionally register readImage for vision-capable models
@@ -542,8 +565,6 @@ export interface AppState {
542
565
  versionLabel: string;
543
566
  /** Current git state (null if not in a repo). */
544
567
  git: GitState | null;
545
- /** Optional git state loader override used by tests. */
546
- loadGitState?: (cwd: string) => Promise<GitState | null>;
547
568
  /** Available provider credentials (provider → API key). */
548
569
  providers: Map<string, string>;
549
570
  /** OAuth credentials on disk. */
@@ -671,8 +692,8 @@ function resolvePromptOs(): "linux" | "mac" | "docker" {
671
692
  /**
672
693
  * Build the system prompt for the current state.
673
694
  *
674
- * Separated from `init` because it's called on every turn (git state
675
- * may change between turns).
695
+ * Separated from `init` because turns still rebuild the assembled prompt
696
+ * from the session-stable prompt context plus the current runtime state.
676
697
  */
677
698
  export function buildPrompt(state: AppState): string {
678
699
  return buildSystemPrompt({
@@ -698,7 +719,7 @@ export function buildToolList(state: AppState): {
698
719
  toolHandlers: Map<string, ToolHandler>;
699
720
  } {
700
721
  if (!state.model) return { tools: [], toolHandlers: new Map() };
701
- return buildTools(state.model, state.plugins);
722
+ return buildTools(state.model, state.plugins, state.messages);
702
723
  }
703
724
 
704
725
  /**
@@ -765,6 +786,47 @@ export {
765
786
  saveOAuthCredentials,
766
787
  };
767
788
 
789
+ // ---------------------------------------------------------------------------
790
+ // Headless CLI
791
+ // ---------------------------------------------------------------------------
792
+
793
+ type HeadlessCliStopReason = "stop" | "length" | "error" | "aborted";
794
+
795
+ /**
796
+ * Run one headless CLI prompt using the output mode selected by the parsed CLI flags.
797
+ *
798
+ * Non-TTY detection only decides whether headless mode should run at all.
799
+ * Once headless mode is selected, `--json` is the only switch that chooses
800
+ * NDJSON streaming versus final-text output.
801
+ *
802
+ * @param state - Initialized application state for the run.
803
+ * @param cli - Parsed CLI options.
804
+ * @param tty - Current TTY availability.
805
+ * @param deps - Injected I/O and runner callbacks.
806
+ * @returns The terminal stop reason for the headless run.
807
+ */
808
+ export async function runHeadlessCli(
809
+ state: AppState,
810
+ cli: CliOptions,
811
+ tty: TtyState,
812
+ deps: {
813
+ readStdin: () => Promise<string>;
814
+ runJson: (
815
+ state: AppState,
816
+ rawPrompt: string,
817
+ ) => Promise<HeadlessCliStopReason>;
818
+ runText: (
819
+ state: AppState,
820
+ rawPrompt: string,
821
+ ) => Promise<HeadlessCliStopReason>;
822
+ },
823
+ ): Promise<HeadlessCliStopReason> {
824
+ const rawPrompt = await resolveHeadlessPrompt(cli, tty, deps.readStdin);
825
+ return cli.json
826
+ ? deps.runJson(state, rawPrompt)
827
+ : deps.runText(state, rawPrompt);
828
+ }
829
+
768
830
  // ---------------------------------------------------------------------------
769
831
  // Main
770
832
  // ---------------------------------------------------------------------------
@@ -787,11 +849,17 @@ export async function main(): Promise<void> {
787
849
 
788
850
  if (shouldUseHeadlessMode(cli, tty)) {
789
851
  try {
790
- const rawPrompt = await resolveHeadlessPrompt(cli, tty, async () => {
791
- return Bun.stdin.text();
852
+ const stopReason = await runHeadlessCli(state, cli, tty, {
853
+ readStdin: async () => Bun.stdin.text(),
854
+ runJson: async (headlessState, rawPrompt) => {
855
+ const { runHeadlessPrompt } = await import("./headless.ts");
856
+ return runHeadlessPrompt(headlessState, rawPrompt);
857
+ },
858
+ runText: async (headlessState, rawPrompt) => {
859
+ const { runHeadlessPromptText } = await import("./headless.ts");
860
+ return runHeadlessPromptText(headlessState, rawPrompt);
861
+ },
792
862
  });
793
- const { runHeadlessPrompt } = await import("./headless.ts");
794
- const stopReason = await runHeadlessPrompt(state, rawPrompt);
795
863
  if (stopReason === "aborted") {
796
864
  process.exitCode = 130;
797
865
  } else if (stopReason === "error") {
package/src/input.ts CHANGED
@@ -24,6 +24,7 @@ export const COMMANDS = [
24
24
  "undo",
25
25
  "reasoning",
26
26
  "verbose",
27
+ "todo",
27
28
  "login",
28
29
  "logout",
29
30
  "help",
package/src/prompt.ts CHANGED
@@ -252,11 +252,13 @@ function buildCorePrompt(opts: BuildSystemPromptOpts): string {
252
252
  "",
253
253
  "### Task management",
254
254
  "",
255
- "- Use the /tmp or equivalent temp directory to make a To-do list for your task.",
256
- "- This to-do list is **extremely** helpful for breaking down your task into smaller, less complex steps.",
257
- "- Not using a to-do list may cause you to forget important steps, or lose focus on the user's request; that would be unacceptable.",
258
- "- Keep the to-do list up-to-date above all, mark your completions as soon as you verify them.",
259
- "- A to-do item is only complete if verification was successful and thorough.",
255
+ "- Use `todoWrite` proactively for multi-step or non-trivial tasks.",
256
+ "- Capture new requirements in the todo list as soon as you understand them.",
257
+ "- Use `todoRead` when you need to inspect the current list before updating it or when the user asks for the current plan/status.",
258
+ "- Keep the todo list up-to-date above all; mark tasks `in_progress` before starting them and `completed` as soon as verification succeeds.",
259
+ "- A todo item is only complete if the requested work is actually finished and verified to the degree the task requires.",
260
+ "- Use `cancelled` to remove tasks that are no longer relevant.",
261
+ "- Skip todo tools for single trivial tasks and purely conversational/informational requests.",
260
262
  '- You have the option to delegate tasks to copies of yourself with `mc -p "subtask prompt"` in the shell.',
261
263
  "- Delegate when you are orchestrating a large to-do/plan execution.",
262
264
  "",
package/src/session.ts CHANGED
@@ -16,6 +16,7 @@ import type {
16
16
  ToolResultMessage,
17
17
  UserMessage,
18
18
  } from "@mariozechner/pi-ai";
19
+ import type { TodoItem } from "./tools.ts";
19
20
 
20
21
  // ---------------------------------------------------------------------------
21
22
  // Types
@@ -94,8 +95,8 @@ interface AppendPromptHistoryOpts {
94
95
  sessionId?: string;
95
96
  }
96
97
 
97
- /** A persisted UI-only message shown in the conversation log. */
98
- export interface UiMessage {
98
+ /** A persisted UI-only info message shown in the conversation log. */
99
+ export interface UiInfoMessage {
99
100
  /** Identifies this as an internal UI message. */
100
101
  role: "ui";
101
102
  /** UI message category for rendering and future behavior. */
@@ -106,6 +107,21 @@ export interface UiMessage {
106
107
  timestamp: number;
107
108
  }
108
109
 
110
+ /** A persisted UI-only todo snapshot shown in the conversation log. */
111
+ export interface UiTodoMessage {
112
+ /** Identifies this as an internal UI message. */
113
+ role: "ui";
114
+ /** UI message category for rendering and future behavior. */
115
+ kind: "todo";
116
+ /** Todo snapshot rendered in the conversation pane. */
117
+ todos: TodoItem[];
118
+ /** Unix timestamp in milliseconds. */
119
+ timestamp: number;
120
+ }
121
+
122
+ /** A persisted UI-only message shown in the conversation log. */
123
+ export type UiMessage = UiInfoMessage | UiTodoMessage;
124
+
109
125
  /** Any message persisted in session history. */
110
126
  export type PersistedMessage = Message | UiMessage;
111
127
 
@@ -491,10 +507,27 @@ function isUiMessageRecord(value: unknown): value is UiMessage {
491
507
  return false;
492
508
  }
493
509
 
510
+ const timestamp = readFiniteNumber(record, "timestamp");
511
+ if (timestamp === null) {
512
+ return false;
513
+ }
514
+
515
+ if (record.kind === "info") {
516
+ return typeof record.content === "string";
517
+ }
518
+
494
519
  return (
495
- record.kind === "info" &&
496
- typeof record.content === "string" &&
497
- readFiniteNumber(record, "timestamp") !== null
520
+ record.kind === "todo" &&
521
+ Array.isArray(record.todos) &&
522
+ record.todos.every(
523
+ (todo) =>
524
+ typeof todo === "object" &&
525
+ todo !== null &&
526
+ typeof (todo as { content?: unknown }).content === "string" &&
527
+ ((todo as { status?: unknown }).status === "pending" ||
528
+ (todo as { status?: unknown }).status === "in_progress" ||
529
+ (todo as { status?: unknown }).status === "completed"),
530
+ )
498
531
  );
499
532
  }
500
533
 
@@ -593,12 +626,12 @@ export function truncateSessions(
593
626
  // ---------------------------------------------------------------------------
594
627
 
595
628
  /**
596
- * Create a persisted UI message.
629
+ * Create a persisted UI info message.
597
630
  *
598
631
  * @param content - Display text shown in the conversation log.
599
- * @returns A new {@link UiMessage}.
632
+ * @returns A new {@link UiInfoMessage}.
600
633
  */
601
- export function createUiMessage(content: string): UiMessage {
634
+ export function createUiMessage(content: string): UiInfoMessage {
602
635
  return {
603
636
  role: "ui",
604
637
  kind: "info",
@@ -607,6 +640,21 @@ export function createUiMessage(content: string): UiMessage {
607
640
  };
608
641
  }
609
642
 
643
+ /**
644
+ * Create a persisted UI todo snapshot message.
645
+ *
646
+ * @param todos - Todo snapshot rendered in the conversation log.
647
+ * @returns A new {@link UiTodoMessage}.
648
+ */
649
+ export function createUiTodoMessage(todos: readonly TodoItem[]): UiTodoMessage {
650
+ return {
651
+ role: "ui",
652
+ kind: "todo",
653
+ todos: todos.map((todo) => ({ ...todo })),
654
+ timestamp: Date.now(),
655
+ };
656
+ }
657
+
610
658
  /**
611
659
  * Check whether a persisted message is a UI-only message.
612
660
  *
package/src/submit.ts CHANGED
@@ -9,7 +9,6 @@ import type { UserMessage } from "@mariozechner/pi-ai";
9
9
  import type { AgentEvent } from "./agent.ts";
10
10
  import { runAgentLoop } from "./agent.ts";
11
11
  import { getErrorMessage } from "./errors.ts";
12
- import { getGitState } from "./git.ts";
13
12
  import {
14
13
  type AppState,
15
14
  buildPrompt,
@@ -328,7 +327,6 @@ export async function submitResolvedInput(
328
327
  content,
329
328
  timestamp: Date.now(),
330
329
  } satisfies UserMessage;
331
- const loadGitState = state.loadGitState ?? getGitState;
332
330
 
333
331
  const turn = appendMessage(state.db, session.id, userMessage);
334
332
  state.messages.push(userMessage);
@@ -338,8 +336,6 @@ export async function submitResolvedInput(
338
336
  );
339
337
  hooks?.onUserMessage?.(state);
340
338
 
341
- state.git = await loadGitState(state.cwd);
342
-
343
339
  const systemPrompt = buildPrompt(state);
344
340
  const { tools, toolHandlers } = buildToolList(state);
345
341
  const modelMessages = filterModelMessages(state.messages);
@@ -371,7 +367,6 @@ export async function submitResolvedInput(
371
367
  },
372
368
  });
373
369
  stopReason = result.stopReason;
374
- state.git = await loadGitState(state.cwd);
375
370
  return result.stopReason;
376
371
  } finally {
377
372
  state.running = false;
package/src/tools.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Built-in tool implementations: `edit`, `shell`, and `readImage`.
2
+ * Built-in tool implementations: `edit`, `shell`, `todoWrite`, `todoRead`,
3
+ * and `readImage`.
3
4
  *
4
5
  * Each tool is exposed as a pure-ish execute function that takes typed
5
6
  * arguments and a working directory, returning a result object. The pi-ai
@@ -11,7 +12,13 @@
11
12
 
12
13
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
14
  import { dirname, extname, isAbsolute, join } from "node:path";
14
- import type { ImageContent, TextContent, Tool } from "@mariozechner/pi-ai";
15
+ import type {
16
+ ImageContent,
17
+ Message,
18
+ TextContent,
19
+ Tool,
20
+ ToolResultMessage,
21
+ } from "@mariozechner/pi-ai";
15
22
  import { Type } from "@mariozechner/pi-ai";
16
23
  import type { ToolUpdateCallback } from "./agent.ts";
17
24
 
@@ -37,6 +44,191 @@ function textResult(text: string, isError: boolean): ToolExecResult {
37
44
  return { content: [{ type: "text", text }], isError };
38
45
  }
39
46
 
47
+ /** Persisted todo status values shown to the user and stored in snapshots. */
48
+ export type TodoStatus = "pending" | "in_progress" | "completed";
49
+
50
+ /** Todo status values accepted by `todoWrite`. */
51
+ export type TodoWriteStatus = TodoStatus | "cancelled";
52
+
53
+ /** A single persisted todo item. */
54
+ export interface TodoItem {
55
+ /** Task description shown in the checklist. */
56
+ content: string;
57
+ /** Current persisted task status. */
58
+ status: TodoStatus;
59
+ }
60
+
61
+ interface TodoWriteInputItem {
62
+ /** Task description used as the matching key. */
63
+ content: string;
64
+ /** Requested next status for the task. */
65
+ status: TodoWriteStatus;
66
+ }
67
+
68
+ /** Arguments for the `todoWrite` tool. */
69
+ export interface TodoWriteArgs {
70
+ /** Todo items to create, update, or remove. */
71
+ todos: TodoWriteInputItem[];
72
+ }
73
+
74
+ const MAX_TODO_CONTENT_LENGTH = 1_000;
75
+
76
+ type TodoHistoryMessage = Message | { role: "ui" };
77
+
78
+ function isTodoStatus(value: unknown): value is TodoStatus {
79
+ return (
80
+ value === "pending" || value === "in_progress" || value === "completed"
81
+ );
82
+ }
83
+
84
+ function isTodoWriteStatus(value: unknown): value is TodoWriteStatus {
85
+ return value === "cancelled" || isTodoStatus(value);
86
+ }
87
+
88
+ function cloneTodoItems(todos: readonly TodoItem[]): TodoItem[] {
89
+ return todos.map((todo) => ({ ...todo }));
90
+ }
91
+
92
+ function getToolResultText(content: ToolResultMessage["content"]): string {
93
+ return content
94
+ .filter((entry): entry is TextContent => entry.type === "text")
95
+ .map((entry) => entry.text)
96
+ .join("\n");
97
+ }
98
+
99
+ /** Serialize a full todo snapshot for storage in a tool result. */
100
+ export function formatTodoSnapshot(todos: readonly TodoItem[]): string {
101
+ return JSON.stringify({ todos: cloneTodoItems(todos) }, null, 2);
102
+ }
103
+
104
+ /** Parse a serialized todo snapshot from tool-result text. */
105
+ export function parseTodoSnapshot(text: string): TodoItem[] | null {
106
+ let parsed: unknown;
107
+ try {
108
+ parsed = JSON.parse(text) as unknown;
109
+ } catch {
110
+ return null;
111
+ }
112
+
113
+ if (
114
+ typeof parsed !== "object" ||
115
+ parsed === null ||
116
+ !Array.isArray((parsed as { todos?: unknown }).todos)
117
+ ) {
118
+ return null;
119
+ }
120
+
121
+ const todos = (parsed as { todos: unknown[] }).todos;
122
+ if (
123
+ !todos.every((todo) => {
124
+ return (
125
+ typeof todo === "object" &&
126
+ todo !== null &&
127
+ typeof (todo as { content?: unknown }).content === "string" &&
128
+ isTodoStatus((todo as { status?: unknown }).status)
129
+ );
130
+ })
131
+ ) {
132
+ return null;
133
+ }
134
+
135
+ return todos.map((todo) => ({
136
+ content: (todo as { content: string }).content,
137
+ status: (todo as { status: TodoStatus }).status,
138
+ }));
139
+ }
140
+
141
+ function getTodoSnapshotFromToolResult(
142
+ message: ToolResultMessage,
143
+ ): TodoItem[] | null {
144
+ if (message.isError) {
145
+ return null;
146
+ }
147
+ if (message.toolName !== "todoWrite" && message.toolName !== "todoRead") {
148
+ return null;
149
+ }
150
+ return parseTodoSnapshot(getToolResultText(message.content));
151
+ }
152
+
153
+ /** Return the current todo list derived from persisted message history. */
154
+ export function getTodoItems(
155
+ messages: readonly TodoHistoryMessage[],
156
+ ): TodoItem[] {
157
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
158
+ const message = messages[index];
159
+ if (!message || message.role !== "toolResult") {
160
+ continue;
161
+ }
162
+
163
+ const snapshot = getTodoSnapshotFromToolResult(message);
164
+ if (snapshot) {
165
+ return snapshot;
166
+ }
167
+ }
168
+
169
+ return [];
170
+ }
171
+
172
+ function validateTodoContent(content: string): string | null {
173
+ if (content.trim().length === 0) {
174
+ return "Todo content cannot be empty";
175
+ }
176
+ if (content.length > MAX_TODO_CONTENT_LENGTH) {
177
+ return `Todo content exceeds maximum length of ${MAX_TODO_CONTENT_LENGTH} characters`;
178
+ }
179
+ return null;
180
+ }
181
+
182
+ /** Apply incremental todo changes and return the new full snapshot. */
183
+ export function executeTodoWrite(
184
+ args: TodoWriteArgs,
185
+ messages: readonly TodoHistoryMessage[],
186
+ ): ToolExecResult {
187
+ const nextTodos = cloneTodoItems(getTodoItems(messages));
188
+
189
+ for (const todo of args.todos) {
190
+ const validationError = validateTodoContent(todo.content);
191
+ if (validationError) {
192
+ return textResult(validationError, true);
193
+ }
194
+ if (!isTodoWriteStatus(todo.status)) {
195
+ return textResult(`Invalid todo status: ${String(todo.status)}`, true);
196
+ }
197
+
198
+ if (todo.status === "cancelled") {
199
+ const index = nextTodos.findIndex(
200
+ (existingTodo) => existingTodo.content === todo.content,
201
+ );
202
+ if (index !== -1) {
203
+ nextTodos.splice(index, 1);
204
+ }
205
+ continue;
206
+ }
207
+
208
+ const existingTodo = nextTodos.find(
209
+ (candidate) => candidate.content === todo.content,
210
+ );
211
+ if (existingTodo) {
212
+ existingTodo.status = todo.status;
213
+ continue;
214
+ }
215
+
216
+ nextTodos.push({
217
+ content: todo.content,
218
+ status: todo.status,
219
+ });
220
+ }
221
+
222
+ return textResult(formatTodoSnapshot(nextTodos), false);
223
+ }
224
+
225
+ /** Return the current full todo snapshot without mutating it. */
226
+ export function executeTodoRead(
227
+ messages: readonly TodoHistoryMessage[],
228
+ ): ToolExecResult {
229
+ return textResult(formatTodoSnapshot(getTodoItems(messages)), false);
230
+ }
231
+
40
232
  function detectLineEnding(content: string): "\n" | "\r\n" | null {
41
233
  if (content.includes("\r\n")) {
42
234
  return "\r\n";
@@ -1330,6 +1522,53 @@ export const editTool: Tool = {
1330
1522
  }),
1331
1523
  };
1332
1524
 
1525
+ /** pi-ai tool definition for `todoWrite`. */
1526
+ export const todoWriteTool: Tool = {
1527
+ name: "todoWrite",
1528
+ description:
1529
+ "Use this tool to create and manage a structured task list for your current coding session. " +
1530
+ "This helps you track progress, organize complex tasks, and keep the user informed. " +
1531
+ "Only send the items that changed; unchanged items stay as they are. " +
1532
+ "Each item must include `content` and `status`, where `status` is one of `pending`, `in_progress`, `completed`, or `cancelled`. " +
1533
+ "Use `cancelled` to remove an item from the list. " +
1534
+ "Mark tasks `in_progress` before starting them and `completed` immediately after verification succeeds.",
1535
+ parameters: Type.Object({
1536
+ todos: Type.Array(
1537
+ Type.Object({
1538
+ content: Type.String({
1539
+ description: "Task description used as the matching key",
1540
+ }),
1541
+ status: Type.Union(
1542
+ [
1543
+ Type.Literal("pending"),
1544
+ Type.Literal("in_progress"),
1545
+ Type.Literal("completed"),
1546
+ Type.Literal("cancelled"),
1547
+ ],
1548
+ {
1549
+ description:
1550
+ "Task status. Use `cancelled` to remove the item entirely.",
1551
+ },
1552
+ ),
1553
+ }),
1554
+ {
1555
+ description:
1556
+ "List of todo items to create, update, or remove. Only send the items that changed.",
1557
+ },
1558
+ ),
1559
+ }),
1560
+ };
1561
+
1562
+ /** pi-ai tool definition for `todoRead`. */
1563
+ export const todoReadTool: Tool = {
1564
+ name: "todoRead",
1565
+ description:
1566
+ "Retrieves the current todo list for this coding session. " +
1567
+ "Use this tool before updating todos when you need to inspect the current list, or when the user asks for the current plan or progress. " +
1568
+ "If no todos exist yet, it returns an empty list.",
1569
+ parameters: Type.Object({}),
1570
+ };
1571
+
1333
1572
  /** pi-ai tool definition for `shell`. */
1334
1573
  export const shellTool: Tool = {
1335
1574
  name: "shell",