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/README.md +5 -2
- package/package.json +1 -1
- package/src/agent.ts +206 -60
- package/src/cli.ts +11 -2
- package/src/headless.ts +185 -47
- package/src/index.ts +78 -10
- package/src/input.ts +1 -0
- package/src/prompt.ts +7 -5
- package/src/session.ts +56 -8
- package/src/submit.ts +0 -5
- package/src/tools.ts +241 -2
- package/src/ui/commands.test.ts +71 -0
- package/src/ui/commands.ts +13 -0
- package/src/ui/conversation.test.ts +91 -0
- package/src/ui/conversation.ts +122 -3
- package/src/ui/help.test.ts +1 -0
- package/src/ui/help.ts +1 -0
- package/src/ui.ts +35 -8
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
|
|
675
|
-
*
|
|
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
|
|
791
|
-
|
|
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
package/src/prompt.ts
CHANGED
|
@@ -252,11 +252,13 @@ function buildCorePrompt(opts: BuildSystemPromptOpts): string {
|
|
|
252
252
|
"",
|
|
253
253
|
"### Task management",
|
|
254
254
|
"",
|
|
255
|
-
"- Use
|
|
256
|
-
"-
|
|
257
|
-
"-
|
|
258
|
-
"- Keep the
|
|
259
|
-
"- A
|
|
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
|
|
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 === "
|
|
496
|
-
|
|
497
|
-
|
|
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
|
|
632
|
+
* @returns A new {@link UiInfoMessage}.
|
|
600
633
|
*/
|
|
601
|
-
export function createUiMessage(content: string):
|
|
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`,
|
|
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 {
|
|
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",
|