@ian-pascoe/pi-todo 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -10
- package/package.json +2 -2
- package/src/pi-todo-extension.ts +34 -9
- package/src/todo-context.ts +18 -17
- package/src/todo-list.ts +112 -28
package/README.md
CHANGED
|
@@ -16,13 +16,16 @@ pi -e ./src/index.ts
|
|
|
16
16
|
|
|
17
17
|
The `todo` tool supports five actions:
|
|
18
18
|
|
|
19
|
-
| Action | Input
|
|
20
|
-
| -------- |
|
|
21
|
-
| `list` | —
|
|
22
|
-
| `add` | `title`, optional `description` and `status`
|
|
23
|
-
| `
|
|
24
|
-
| `
|
|
25
|
-
| `
|
|
19
|
+
| Action | Input | Result |
|
|
20
|
+
| -------- | ----------------------------------------------------- | --------------------------------- |
|
|
21
|
+
| `list` | — | Lists every Task |
|
|
22
|
+
| `add` | `title`, optional `description` and `status` | Adds a Task |
|
|
23
|
+
| `add` | `tasks: [{ title, description? }]`, optional `status` | Adds several Tasks at once |
|
|
24
|
+
| `update` | `id`, plus fields to change | Updates a Task |
|
|
25
|
+
| `remove` | `id` | Removes one Task |
|
|
26
|
+
| `clear` | — | Removes every Task and resets IDs |
|
|
27
|
+
|
|
28
|
+
A batch `add` creates every Task in `tasks` atomically, with sequential IDs, and the result lists them. It writes one `pi-todo-state` session entry, so the tool group that made it projects a single Todo List Snapshot. If any title or description is empty, or the Task IDs would run out, it creates no Task. `tasks` must hold at least one entry and replaces `title`/`description`: a request that gives both `tasks` and `title` or a non-null `description` is rejected rather than guessed at. Per-Task fields other than `title` and `description` are rejected too. A top-level `status` applies to every new Task.
|
|
26
29
|
|
|
27
30
|
A Pi `codemode` script receives the result as an object (the tool declares an `outputSchema`) instead of the text (its field names are already single words, so they match Pi's snake_case convention); the model still reads the same text, and failures still throw:
|
|
28
31
|
|
|
@@ -30,14 +33,15 @@ A Pi `codemode` script receives the result as an object (the tool declares an `o
|
|
|
30
33
|
| ---------------- | ---------------------------------------------------------------------------- |
|
|
31
34
|
| `list` | `{ action: "list", tasks }`, each Task `{ id, title, description?, status }` |
|
|
32
35
|
| `add` / `update` | `{ action, task }` with the resulting Task |
|
|
36
|
+
| batch `add` | `{ action: "add", tasks }` with the created Tasks in ID order |
|
|
33
37
|
| `remove` | `{ action: "remove", id }` |
|
|
34
38
|
| `clear` | `{ action: "clear", cleared }` with the number of Tasks removed |
|
|
35
39
|
|
|
36
40
|
Task status is `pending`, `active`, or `completed`; `add` defaults to `pending`. Tasks are flat, duplicate titles are allowed, and multiple Tasks may be active. Set `description` to `null` during `update` to remove it.
|
|
37
41
|
|
|
38
|
-
Each
|
|
42
|
+
Each tool group that changes the Todo List is projected from the immutable session state entries as one hidden full **Todo List Snapshot** of the group's final state, at a fixed conversation position after all of the group's sibling results. A tool group is one assistant turn's tool calls, including several `todo` calls inside one `codemode` script, so ten calls in a script add one Todo List Snapshot, not ten. A group that ends in the state it began with adds none. A change made outside a tool group, such as `/todo clear`, still projects its own Todo List Snapshot right away. Each Todo List Snapshot starts with the header `Todo List state from the pi-todo extension (not a user message):` so it does not read like user input. Later requests keep earlier Todo List Snapshots in place. Clearing the list produces an explicit empty Todo List Snapshot. The system prompt and tool description do not change.
|
|
39
43
|
|
|
40
|
-
After compaction, a fixed baseline immediately after the summary restores the state from before the retained Tail. Retained and newer
|
|
44
|
+
After compaction, a fixed baseline immediately after the summary restores the state from before the retained Tail. Retained and newer tool groups follow chronologically. This preserves previously written conversation cache prefixes between checkpoints; it does not guarantee provider cache hits.
|
|
41
45
|
|
|
42
46
|
`todo` declares MCP-style `annotations`: not read-only, but non-destructive (it only appends to the session's own journal), not idempotent, and closed-world. Pi reports them through `pi.getAllTools()` for permission extensions and does not send them to model providers.
|
|
43
47
|
|
|
@@ -49,6 +53,6 @@ State is stored only in `pi-todo-state` session entries, without a second messag
|
|
|
49
53
|
|
|
50
54
|
A failed journal write leaves acknowledged Todo state unchanged and disables Todo in that loaded session, including across `/reload`. Pi has an existing persistence limitation: subsequent entries can reference the unwritten entry, so reopening may omit earlier history from the selected branch even though it remains in the file. Todo does not repair session history; recovery is outside this cache-prefix change.
|
|
51
55
|
|
|
52
|
-
Missing or ambiguous conversation anchors abort the request instead of silently relocating
|
|
56
|
+
Missing or ambiguous conversation anchors abort the request instead of silently relocating Todo List Snapshots. Extensions that rewrite those anchors are unsupported.
|
|
53
57
|
|
|
54
58
|
This is privileged extension code: review it before installing it into an agent that can access local files, tools, or credentials.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ian-pascoe/pi-todo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Minimal session-native Todo List for Pi agents.",
|
|
6
6
|
"keywords": [
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
]
|
|
51
51
|
},
|
|
52
52
|
"scripts": {
|
|
53
|
-
"test": "vitest run
|
|
53
|
+
"test": "vitest run",
|
|
54
54
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
55
55
|
}
|
|
56
56
|
}
|
package/src/pi-todo-extension.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { projectTodoContext, todoStateFromEntry } from "./todo-context.js";
|
|
|
6
6
|
import {
|
|
7
7
|
applyTodoAction,
|
|
8
8
|
createEmptyTodoState,
|
|
9
|
+
formatTaskCount,
|
|
9
10
|
TODO_ACTIONS,
|
|
10
11
|
TODO_STATUSES,
|
|
11
12
|
TodoToolOutputSchema,
|
|
@@ -44,13 +45,34 @@ const TodoParameters = Type.Object({
|
|
|
44
45
|
description: "Task ID for update or remove",
|
|
45
46
|
}),
|
|
46
47
|
),
|
|
47
|
-
title: Type.Optional(
|
|
48
|
+
title: Type.Optional(
|
|
49
|
+
Type.String({ description: "Task title for add or update; not allowed with tasks" }),
|
|
50
|
+
),
|
|
48
51
|
description: Type.Optional(
|
|
49
52
|
Type.Union([Type.String(), Type.Null()], {
|
|
50
|
-
description:
|
|
53
|
+
description:
|
|
54
|
+
"Optional Task description; null removes it during update; not allowed with tasks",
|
|
51
55
|
}),
|
|
52
56
|
),
|
|
53
|
-
|
|
57
|
+
tasks: Type.Optional(
|
|
58
|
+
Type.Array(
|
|
59
|
+
Type.Object(
|
|
60
|
+
{
|
|
61
|
+
title: Type.String({ description: "Task title" }),
|
|
62
|
+
description: Type.Optional(Type.String({ description: "Optional Task description" })),
|
|
63
|
+
},
|
|
64
|
+
{ additionalProperties: false },
|
|
65
|
+
),
|
|
66
|
+
{
|
|
67
|
+
minItems: 1,
|
|
68
|
+
description:
|
|
69
|
+
"Tasks to add in one call, all or none, with sequential IDs; use instead of title and description",
|
|
70
|
+
},
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
status: Type.Optional(
|
|
74
|
+
StringEnum(TODO_STATUSES, { description: "Task status; for add, applies to every new Task" }),
|
|
75
|
+
),
|
|
54
76
|
});
|
|
55
77
|
|
|
56
78
|
function restoreTodoState(context: ExtensionContext): TodoStateSnapshot {
|
|
@@ -192,6 +214,10 @@ export default function piTodoExtension(pi: ExtensionAPI): void {
|
|
|
192
214
|
let text = theme.fg("toolTitle", theme.bold("todo")) + theme.fg("muted", ` ${params.action}`);
|
|
193
215
|
if (params.id !== undefined) text += theme.fg("accent", ` #${params.id}`);
|
|
194
216
|
if (params.action === "add" && params.title) text += theme.fg("dim", ` "${params.title}"`);
|
|
217
|
+
if (params.action === "add" && params.tasks) {
|
|
218
|
+
const count = params.tasks.length;
|
|
219
|
+
text += theme.fg("dim", ` ${formatTaskCount(count)}`);
|
|
220
|
+
}
|
|
195
221
|
return new Text(text, 0, 0);
|
|
196
222
|
},
|
|
197
223
|
renderResult: (result, { expanded }, theme) => {
|
|
@@ -199,13 +225,12 @@ export default function piTodoExtension(pi: ExtensionAPI): void {
|
|
|
199
225
|
if (!result.details) {
|
|
200
226
|
return new Text(theme.fg("error", text?.text ?? "Todo operation failed"), 0, 0);
|
|
201
227
|
}
|
|
202
|
-
if (result.details
|
|
203
|
-
const tasks = result.details
|
|
228
|
+
if ("tasks" in result.details) {
|
|
229
|
+
const { action, tasks } = result.details;
|
|
204
230
|
if (tasks.length === 0) return new Text(theme.fg("dim", "Todo List is empty"), 0, 0);
|
|
205
231
|
const visibleTasks = expanded ? tasks : tasks.slice(0, TODO_COLLAPSED_TASK_LIMIT);
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
];
|
|
232
|
+
const heading = `${action === "add" ? "Added " : ""}${formatTaskCount(tasks.length)}:`;
|
|
233
|
+
const lines = [theme.fg("muted", heading)];
|
|
209
234
|
for (const task of visibleTasks) {
|
|
210
235
|
lines.push(renderTodoTaskLine(task, theme));
|
|
211
236
|
if (expanded && task.description) {
|
|
@@ -244,7 +269,7 @@ export default function piTodoExtension(pi: ExtensionAPI): void {
|
|
|
244
269
|
}
|
|
245
270
|
const confirmed = await context.ui.confirm(
|
|
246
271
|
"Clear Todo List",
|
|
247
|
-
`Remove all ${state.tasks.length}
|
|
272
|
+
`Remove all ${formatTaskCount(state.tasks.length)}?`,
|
|
248
273
|
);
|
|
249
274
|
if (!confirmed) return;
|
|
250
275
|
const result = runTodoAction({ action: "clear" }, context);
|
package/src/todo-context.ts
CHANGED
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
} from "./todo-list.js";
|
|
15
15
|
|
|
16
16
|
type Message = ContextEvent["messages"][number];
|
|
17
|
+
/** Marks a snapshot as extension state so the model does not read it as user-authored. */
|
|
18
|
+
const SNAPSHOT_HEADER = "Todo List state from the pi-todo extension (not a user message):";
|
|
17
19
|
const ProjectionDetails = Type.Object(
|
|
18
20
|
{
|
|
19
21
|
version: Type.Literal(1),
|
|
@@ -50,7 +52,7 @@ function snapshotMessage(
|
|
|
50
52
|
role: "custom",
|
|
51
53
|
customType: "pi-todo-context",
|
|
52
54
|
display: false,
|
|
53
|
-
content:
|
|
55
|
+
content: `${SNAPSHOT_HEADER}\n${formatTodoList(state.tasks)}`,
|
|
54
56
|
timestamp: Date.parse(entry.timestamp),
|
|
55
57
|
details: { version: 1, stateEntryId: entry.id, checkpointId },
|
|
56
58
|
};
|
|
@@ -110,27 +112,26 @@ export function projectTodoContext(
|
|
|
110
112
|
}
|
|
111
113
|
}
|
|
112
114
|
const outstanding = new Map<string, number>();
|
|
113
|
-
|
|
115
|
+
// A tool group projects only its final state, once its last result has landed.
|
|
116
|
+
let pending: { entry: SessionEntry; state: TodoStateSnapshot } | undefined;
|
|
117
|
+
const project = (entry: SessionEntry, state: TodoStateSnapshot): void => {
|
|
118
|
+
const content = formatTodoList(state.tasks);
|
|
119
|
+
if (content !== previousContent && (previousContent !== undefined || state.tasks.length > 0))
|
|
120
|
+
insert(snapshotMessage(entry, state, null));
|
|
121
|
+
previousContent = content;
|
|
122
|
+
};
|
|
114
123
|
for (const entry of branch.slice(start)) {
|
|
115
124
|
if (entry === checkpoint) continue;
|
|
116
125
|
const state = todoStateFromEntry(entry);
|
|
117
126
|
if (state) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
content !== previousContent &&
|
|
121
|
-
(previousContent !== undefined || state.tasks.length > 0)
|
|
122
|
-
) {
|
|
123
|
-
const snapshot = snapshotMessage(entry, state, null);
|
|
124
|
-
if (outstanding.size > 0) pending.push(snapshot);
|
|
125
|
-
else insert(snapshot);
|
|
126
|
-
}
|
|
127
|
-
previousContent = content;
|
|
127
|
+
if (outstanding.size > 0) pending = { entry, state };
|
|
128
|
+
else project(entry, state);
|
|
128
129
|
continue;
|
|
129
130
|
}
|
|
130
131
|
const message = conversationMessage(entry);
|
|
131
132
|
if (!message) continue;
|
|
132
133
|
if (message.role === "assistant") {
|
|
133
|
-
if (outstanding.size > 0 && pending
|
|
134
|
+
if (outstanding.size > 0 && pending)
|
|
134
135
|
throw new Error("Todo mutation has an incomplete tool group");
|
|
135
136
|
outstanding.clear();
|
|
136
137
|
for (const block of message.content)
|
|
@@ -142,12 +143,12 @@ export function projectTodoContext(
|
|
|
142
143
|
else outstanding.delete(message.toolCallId);
|
|
143
144
|
}
|
|
144
145
|
anchor = message;
|
|
145
|
-
if (outstanding.size === 0 && pending
|
|
146
|
-
|
|
147
|
-
pending =
|
|
146
|
+
if (outstanding.size === 0 && pending) {
|
|
147
|
+
project(pending.entry, pending.state);
|
|
148
|
+
pending = undefined;
|
|
148
149
|
}
|
|
149
150
|
}
|
|
150
|
-
if (pending
|
|
151
|
+
if (pending) throw new Error("Todo mutation has an incomplete tool group");
|
|
151
152
|
return [
|
|
152
153
|
...(insertions.get(-1) ?? []),
|
|
153
154
|
...messages.flatMap((message, index) => [message, ...(insertions.get(index) ?? [])]),
|
package/src/todo-list.ts
CHANGED
|
@@ -32,21 +32,30 @@ export type TodoStateSnapshot = {
|
|
|
32
32
|
readonly tasks: readonly TodoTask[];
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
/** One Task to create within a batch `add`. */
|
|
36
|
+
export type TodoTaskDraft = {
|
|
37
|
+
readonly title: string;
|
|
38
|
+
readonly description?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
35
41
|
/** Boundary input shared by the Todo tool's five operations. */
|
|
36
42
|
export type TodoActionInput = {
|
|
37
43
|
readonly action: TodoAction;
|
|
38
44
|
readonly id?: number;
|
|
39
45
|
readonly title?: string;
|
|
40
46
|
readonly description?: string | null;
|
|
47
|
+
readonly tasks?: readonly TodoTaskDraft[];
|
|
41
48
|
readonly status?: TodoStatus;
|
|
42
49
|
};
|
|
43
50
|
|
|
44
51
|
/**
|
|
45
|
-
* Render details and the tool's structured result. The action
|
|
46
|
-
* Task a mutation added or updated, the ID it removed,
|
|
52
|
+
* Render details and the tool's structured result. The action, plus `tasks` versus `task` for
|
|
53
|
+
* `add`, discriminates the list from the Task(s) a mutation added or updated, the ID it removed,
|
|
54
|
+
* and the count it cleared. A batch `add` returns the Tasks it created as `tasks`; a single `add`
|
|
55
|
+
* returns `task`.
|
|
47
56
|
*/
|
|
48
57
|
export type TodoToolDetails =
|
|
49
|
-
| { readonly action: "list"; readonly tasks: readonly TodoTask[] }
|
|
58
|
+
| { readonly action: "list" | "add"; readonly tasks: readonly TodoTask[] }
|
|
50
59
|
| { readonly action: "add" | "update"; readonly task: TodoTask }
|
|
51
60
|
| { readonly action: "remove"; readonly id: TodoTaskId }
|
|
52
61
|
| { readonly action: "clear"; readonly cleared: number };
|
|
@@ -64,14 +73,16 @@ const TodoTaskRecord = Type.Object({
|
|
|
64
73
|
|
|
65
74
|
/**
|
|
66
75
|
* JSON Schema of the `todo` tool's `structuredContent`, which codemode scripts receive instead of
|
|
67
|
-
* the model-facing text. Flat so Pi's one-line script declaration stays compact; `action`
|
|
68
|
-
* which other field is present.
|
|
76
|
+
* the model-facing text. Flat so Pi's one-line script declaration stays compact; `action` (and, for
|
|
77
|
+
* `add`, whether the request carried `tasks`) says which other field is present.
|
|
69
78
|
*/
|
|
70
79
|
export const TodoToolOutputSchema = Type.Object(
|
|
71
80
|
{
|
|
72
81
|
action: StringEnum(TODO_ACTIONS, { description: "The operation that ran" }),
|
|
73
82
|
tasks: Type.Optional(
|
|
74
|
-
Type.Array(TodoTaskRecord, {
|
|
83
|
+
Type.Array(TodoTaskRecord, {
|
|
84
|
+
description: "list: every Task in ID order; add with tasks: the Tasks created, in ID order",
|
|
85
|
+
}),
|
|
75
86
|
),
|
|
76
87
|
task: Type.Optional(TodoTaskRecord),
|
|
77
88
|
id: Type.Optional(
|
|
@@ -157,6 +168,96 @@ function todoOperationFailure(action: TodoAction, message: string): TodoActionRe
|
|
|
157
168
|
return { ok: false, error: new TodoOperationError(action, message) };
|
|
158
169
|
}
|
|
159
170
|
|
|
171
|
+
function newTodoTask(
|
|
172
|
+
id: TodoTaskId,
|
|
173
|
+
title: string,
|
|
174
|
+
description: string | undefined,
|
|
175
|
+
status: TodoStatus,
|
|
176
|
+
): TodoTask {
|
|
177
|
+
return description ? { id, title, description, status } : { id, title, status };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Counts Tasks in model-facing text: "1 Task", "3 Tasks". */
|
|
181
|
+
export function formatTaskCount(count: number): string {
|
|
182
|
+
return `${count} ${count === 1 ? "Task" : "Tasks"}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
type ParsedTodoDraft = { readonly title: string; readonly description: string | undefined };
|
|
186
|
+
|
|
187
|
+
/** Trims and validates one Task to add; `field` prefixes the field name in the failure message. */
|
|
188
|
+
function parseTodoDraft(
|
|
189
|
+
title: string | undefined,
|
|
190
|
+
description: string | null | undefined,
|
|
191
|
+
field: string,
|
|
192
|
+
): ParsedTodoDraft | TodoActionResult {
|
|
193
|
+
const trimmedTitle = title?.trim();
|
|
194
|
+
if (!trimmedTitle) {
|
|
195
|
+
return todoOperationFailure("add", `Todo add failed: ${field}title must not be empty`);
|
|
196
|
+
}
|
|
197
|
+
const trimmedDescription = description?.trim();
|
|
198
|
+
if (description !== undefined && description !== null && !trimmedDescription) {
|
|
199
|
+
return todoOperationFailure("add", `Todo add failed: ${field}description must not be empty`);
|
|
200
|
+
}
|
|
201
|
+
return { title: trimmedTitle, description: trimmedDescription };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isTodoActionResult(value: ParsedTodoDraft | TodoActionResult): value is TodoActionResult {
|
|
205
|
+
return "ok" in value;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function addTask(state: TodoStateSnapshot, input: TodoActionInput): TodoActionResult {
|
|
209
|
+
const draft = parseTodoDraft(input.title, input.description, "");
|
|
210
|
+
if (isTodoActionResult(draft)) return draft;
|
|
211
|
+
const id = parseTodoTaskId(state.nextId);
|
|
212
|
+
if (id === undefined || state.nextId === Number.MAX_SAFE_INTEGER) {
|
|
213
|
+
return todoOperationFailure("add", "Todo add failed: Task ID limit reached");
|
|
214
|
+
}
|
|
215
|
+
const task = newTodoTask(id, draft.title, draft.description, input.status ?? "pending");
|
|
216
|
+
return {
|
|
217
|
+
ok: true,
|
|
218
|
+
state: { nextId: state.nextId + 1, tasks: [...state.tasks, task] },
|
|
219
|
+
message: `Added Task #${task.id}`,
|
|
220
|
+
details: { action: "add", task },
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Validates every draft before creating any Task, so a batch is all-or-nothing. */
|
|
225
|
+
function addTasks(
|
|
226
|
+
state: TodoStateSnapshot,
|
|
227
|
+
input: TodoActionInput & { readonly tasks: readonly TodoTaskDraft[] },
|
|
228
|
+
): TodoActionResult {
|
|
229
|
+
// A null description is "absent" for add, as it is for a single Task.
|
|
230
|
+
if (input.title !== undefined || (input.description ?? undefined) !== undefined) {
|
|
231
|
+
return todoOperationFailure("add", "Todo add failed: provide either title or tasks, not both");
|
|
232
|
+
}
|
|
233
|
+
if (input.tasks.length === 0) {
|
|
234
|
+
return todoOperationFailure("add", "Todo add failed: tasks must not be empty");
|
|
235
|
+
}
|
|
236
|
+
const drafts: ParsedTodoDraft[] = [];
|
|
237
|
+
for (const [index, task] of input.tasks.entries()) {
|
|
238
|
+
const draft = parseTodoDraft(task.title, task.description, `tasks[${index}].`);
|
|
239
|
+
if (isTodoActionResult(draft)) return draft;
|
|
240
|
+
drafts.push(draft);
|
|
241
|
+
}
|
|
242
|
+
// The last Task's successor ID must stay a safe integer so the saved state restores.
|
|
243
|
+
const exceedsIdLimit = state.nextId + drafts.length > Number.MAX_SAFE_INTEGER;
|
|
244
|
+
const status = input.status ?? "pending";
|
|
245
|
+
const added: TodoTask[] = [];
|
|
246
|
+
for (const [index, { title, description }] of drafts.entries()) {
|
|
247
|
+
const id = parseTodoTaskId(state.nextId + index);
|
|
248
|
+
if (id === undefined || exceedsIdLimit) {
|
|
249
|
+
return todoOperationFailure("add", "Todo add failed: Task ID limit reached");
|
|
250
|
+
}
|
|
251
|
+
added.push(newTodoTask(id, title, description, status));
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
ok: true,
|
|
255
|
+
state: { nextId: state.nextId + added.length, tasks: [...state.tasks, ...added] },
|
|
256
|
+
message: `Added ${formatTaskCount(added.length)}\n${formatTodoList(added)}`,
|
|
257
|
+
details: { action: "add", tasks: added },
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
160
261
|
/** Applies one validated-by-schema tool request without performing session or UI effects. */
|
|
161
262
|
export function applyTodoAction(
|
|
162
263
|
state: TodoStateSnapshot,
|
|
@@ -171,27 +272,10 @@ export function applyTodoAction(
|
|
|
171
272
|
details: { action: "list", tasks: state.tasks },
|
|
172
273
|
};
|
|
173
274
|
|
|
174
|
-
case "add":
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (input.description !== undefined && input.description !== null && !description) {
|
|
179
|
-
return todoOperationFailure("add", "Todo add failed: description must not be empty");
|
|
180
|
-
}
|
|
181
|
-
const id = parseTodoTaskId(state.nextId);
|
|
182
|
-
if (id === undefined || state.nextId === Number.MAX_SAFE_INTEGER) {
|
|
183
|
-
return todoOperationFailure("add", "Todo add failed: Task ID limit reached");
|
|
184
|
-
}
|
|
185
|
-
const task: TodoTask = description
|
|
186
|
-
? { id, title, description, status: input.status ?? "pending" }
|
|
187
|
-
: { id, title, status: input.status ?? "pending" };
|
|
188
|
-
return {
|
|
189
|
-
ok: true,
|
|
190
|
-
state: { nextId: state.nextId + 1, tasks: [...state.tasks, task] },
|
|
191
|
-
message: `Added Task #${task.id}`,
|
|
192
|
-
details: { action: "add", task },
|
|
193
|
-
};
|
|
194
|
-
}
|
|
275
|
+
case "add":
|
|
276
|
+
return input.tasks === undefined
|
|
277
|
+
? addTask(state, input)
|
|
278
|
+
: addTasks(state, { ...input, tasks: input.tasks });
|
|
195
279
|
|
|
196
280
|
case "update":
|
|
197
281
|
case "remove": {
|
|
@@ -264,7 +348,7 @@ export function applyTodoAction(
|
|
|
264
348
|
return {
|
|
265
349
|
ok: true,
|
|
266
350
|
state: count > 0 || state.nextId !== 1 ? createEmptyTodoState() : state,
|
|
267
|
-
message: `Cleared ${count}
|
|
351
|
+
message: `Cleared ${formatTaskCount(count)}`,
|
|
268
352
|
details: { action: "clear", cleared: count },
|
|
269
353
|
};
|
|
270
354
|
}
|