@ian-pascoe/pi-todo 0.0.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/LICENSE +21 -0
- package/README.md +37 -0
- package/package.json +56 -0
- package/skills/pi-todo/SKILL.md +14 -0
- package/src/index.ts +1 -0
- package/src/pi-todo-extension.ts +228 -0
- package/src/todo-list.ts +291 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright 2026 Ian Pascoe
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Pi Todo
|
|
2
|
+
|
|
3
|
+
`@ian-pascoe/pi-todo` gives Pi agents a minimal, session-native **Todo List** without imposing a planning workflow.
|
|
4
|
+
|
|
5
|
+
Requires Node `>=22.19.0` and Pi `>=0.84.1`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install npm:@ian-pascoe/pi-todo
|
|
11
|
+
# or from this checkout
|
|
12
|
+
pi -e ./src/index.ts
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Tool
|
|
16
|
+
|
|
17
|
+
The `todo` tool supports five actions:
|
|
18
|
+
|
|
19
|
+
| Action | Input | Result |
|
|
20
|
+
| -------- | -------------------------------------------- | --------------------------------- |
|
|
21
|
+
| `list` | — | Lists every Task |
|
|
22
|
+
| `add` | `title`, optional `description` and `status` | Adds a Task |
|
|
23
|
+
| `update` | `id`, plus fields to change | Updates a Task |
|
|
24
|
+
| `remove` | `id` | Removes one Task |
|
|
25
|
+
| `clear` | — | Removes every Task and resets IDs |
|
|
26
|
+
|
|
27
|
+
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.
|
|
28
|
+
|
|
29
|
+
The extension appends the current Todo List as hidden tail context before each model request. It does not modify the system prompt or tool description as the list changes, preserving the cacheable prompt prefix.
|
|
30
|
+
|
|
31
|
+
## UI and persistence
|
|
32
|
+
|
|
33
|
+
In interactive mode, a compact widget above the editor shows status counts and up to five Tasks. Tool calls and results use custom transcript rendering. `/todo clear` confirms before manually clearing the list.
|
|
34
|
+
|
|
35
|
+
State is stored only in Pi session entries. It follows the active session branch and survives reloads, restarts, and compaction. The package has no settings or external state.
|
|
36
|
+
|
|
37
|
+
This is privileged extension code: review it before installing it into an agent that can access local files, tools, or credentials.
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ian-pascoe/pi-todo",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Minimal session-native Todo List for Pi agents.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"pi-package",
|
|
10
|
+
"tasks",
|
|
11
|
+
"todo"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/ian-pascoe/pi-extensions/tree/main/packages/pi-todo",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/ian-pascoe/pi-extensions/issues"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Ian Pascoe <ian.g.pascoe@gmail.com>",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/ian-pascoe/pi-extensions.git",
|
|
22
|
+
"directory": "packages/pi-todo"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"skills",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"type": "module",
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public",
|
|
33
|
+
"provenance": true
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "vitest run --config ../../vitest.config.ts --root .",
|
|
37
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@earendil-works/pi-ai": "*",
|
|
41
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
42
|
+
"@earendil-works/pi-tui": "*",
|
|
43
|
+
"typebox": "*"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=22.19.0"
|
|
47
|
+
},
|
|
48
|
+
"pi": {
|
|
49
|
+
"extensions": [
|
|
50
|
+
"./src/index.ts"
|
|
51
|
+
],
|
|
52
|
+
"skills": [
|
|
53
|
+
"./skills"
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pi-todo
|
|
3
|
+
description: Diagnose Pi Todo when its tool, restored Todo List, model context, clear command, transcript rendering, or widget is missing or incorrect.
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Pi Todo
|
|
8
|
+
|
|
9
|
+
1. Read [`../../README.md`](../../README.md) for the expected tool, persistence, context, command, and widget behavior.
|
|
10
|
+
2. Call `todo` with `action: "list"`; classify a missing tool as an extension-loading failure.
|
|
11
|
+
3. Add one temporary Task, reload Pi, and list again. If it disappears, inspect the active branch for `pi-todo-state` custom entries.
|
|
12
|
+
4. Compare the latest valid state entry with the model's hidden Todo List context and the interactive widget, then remove the temporary Task.
|
|
13
|
+
5. Test `/todo clear` only after preserving any Tasks the user still needs.
|
|
14
|
+
6. Treat the extension as configuration-free. Finish when tool state, active-branch restoration, model context, and interactive UI agree, or one named load, session, mode, or corrupted-entry boundary explains the mismatch.
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./pi-todo-extension.js";
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { type Component, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { Value } from "typebox/value";
|
|
6
|
+
import {
|
|
7
|
+
applyTodoAction,
|
|
8
|
+
createEmptyTodoState,
|
|
9
|
+
formatTodoList,
|
|
10
|
+
parseTodoStateSnapshot,
|
|
11
|
+
TODO_ACTIONS,
|
|
12
|
+
TODO_STATUSES,
|
|
13
|
+
TodoStateRecord,
|
|
14
|
+
todoStatusMarker,
|
|
15
|
+
type TodoActionInput,
|
|
16
|
+
type TodoStateSnapshot,
|
|
17
|
+
type TodoStatus,
|
|
18
|
+
type TodoTask,
|
|
19
|
+
type TodoToolDetails,
|
|
20
|
+
} from "./todo-list.js";
|
|
21
|
+
|
|
22
|
+
const TODO_STATE_ENTRY_TYPE = "pi-todo-state";
|
|
23
|
+
const TODO_WIDGET_ID = "pi-todo";
|
|
24
|
+
const TODO_WIDGET_STATUS_ORDER: readonly TodoStatus[] = ["active", "pending", "completed"];
|
|
25
|
+
const TODO_COLLAPSED_TASK_LIMIT = 5;
|
|
26
|
+
|
|
27
|
+
const TodoParameters = Type.Object({
|
|
28
|
+
action: StringEnum(TODO_ACTIONS),
|
|
29
|
+
id: Type.Optional(
|
|
30
|
+
Type.Integer({
|
|
31
|
+
minimum: 1,
|
|
32
|
+
maximum: Number.MAX_SAFE_INTEGER,
|
|
33
|
+
description: "Task ID for update or remove",
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
36
|
+
title: Type.Optional(Type.String({ description: "Task title for add or update" })),
|
|
37
|
+
description: Type.Optional(
|
|
38
|
+
Type.Union([Type.String(), Type.Null()], {
|
|
39
|
+
description: "Optional Task description; null removes it during update",
|
|
40
|
+
}),
|
|
41
|
+
),
|
|
42
|
+
status: Type.Optional(StringEnum(TODO_STATUSES)),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function restoreTodoState(context: ExtensionContext): TodoStateSnapshot {
|
|
46
|
+
for (const entry of context.sessionManager.getBranch().toReversed()) {
|
|
47
|
+
if (
|
|
48
|
+
entry.type !== "custom" ||
|
|
49
|
+
entry.customType !== TODO_STATE_ENTRY_TYPE ||
|
|
50
|
+
!Value.Check(TodoStateRecord, entry.data)
|
|
51
|
+
) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const snapshot = parseTodoStateSnapshot(entry.data);
|
|
55
|
+
if (snapshot) return snapshot;
|
|
56
|
+
}
|
|
57
|
+
return createEmptyTodoState();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderTodoTaskLine(task: TodoTask, theme: Theme): string {
|
|
61
|
+
const markerColor =
|
|
62
|
+
task.status === "active" ? "accent" : task.status === "completed" ? "success" : "muted";
|
|
63
|
+
const title =
|
|
64
|
+
task.status === "active"
|
|
65
|
+
? theme.bold(task.title)
|
|
66
|
+
: task.status === "completed"
|
|
67
|
+
? theme.strikethrough(task.title)
|
|
68
|
+
: task.title;
|
|
69
|
+
const titleColor = task.status === "completed" ? "dim" : "text";
|
|
70
|
+
return `${theme.fg(markerColor, todoStatusMarker(task.status))} ${theme.fg("accent", `#${task.id}`)} ${theme.fg(titleColor, title)}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
class TodoWidget implements Component {
|
|
74
|
+
constructor(
|
|
75
|
+
private readonly tasks: readonly TodoTask[],
|
|
76
|
+
private readonly theme: Theme,
|
|
77
|
+
) {}
|
|
78
|
+
|
|
79
|
+
invalidate(): void {}
|
|
80
|
+
|
|
81
|
+
render(width: number): string[] {
|
|
82
|
+
const active = this.tasks.filter((task) => task.status === "active").length;
|
|
83
|
+
const pending = this.tasks.filter((task) => task.status === "pending").length;
|
|
84
|
+
const completed = this.tasks.length - active - pending;
|
|
85
|
+
const header =
|
|
86
|
+
this.theme.fg("toolTitle", this.theme.bold("TODO")) +
|
|
87
|
+
this.theme.fg("muted", ` ${active} active · ${pending} pending · ${completed} completed`);
|
|
88
|
+
const orderedTasks = this.tasks.toSorted((left, right) => {
|
|
89
|
+
const statusDifference =
|
|
90
|
+
TODO_WIDGET_STATUS_ORDER.indexOf(left.status) -
|
|
91
|
+
TODO_WIDGET_STATUS_ORDER.indexOf(right.status);
|
|
92
|
+
return statusDifference === 0 ? left.id - right.id : statusDifference;
|
|
93
|
+
});
|
|
94
|
+
const lines = [header];
|
|
95
|
+
for (const task of orderedTasks.slice(0, TODO_COLLAPSED_TASK_LIMIT)) {
|
|
96
|
+
lines.push(renderTodoTaskLine(task, this.theme));
|
|
97
|
+
}
|
|
98
|
+
const remaining = orderedTasks.length - TODO_COLLAPSED_TASK_LIMIT;
|
|
99
|
+
if (remaining > 0) lines.push(this.theme.fg("dim", `… ${remaining} more`));
|
|
100
|
+
return lines.map((line) => truncateToWidth(line, width, "…"));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Installs the session-native Todo List tool into Pi. */
|
|
105
|
+
export default function piTodoExtension(pi: ExtensionAPI): void {
|
|
106
|
+
let state = createEmptyTodoState();
|
|
107
|
+
|
|
108
|
+
const updateTodoWidget = (context: ExtensionContext): void => {
|
|
109
|
+
if (context.mode !== "tui") return;
|
|
110
|
+
context.ui.setWidget(
|
|
111
|
+
TODO_WIDGET_ID,
|
|
112
|
+
state.tasks.length === 0 ? undefined : (_tui, theme) => new TodoWidget(state.tasks, theme),
|
|
113
|
+
);
|
|
114
|
+
};
|
|
115
|
+
const commitTodoState = (snapshot: TodoStateSnapshot, context: ExtensionContext): void => {
|
|
116
|
+
state = snapshot;
|
|
117
|
+
pi.appendEntry(TODO_STATE_ENTRY_TYPE, state);
|
|
118
|
+
updateTodoWidget(context);
|
|
119
|
+
};
|
|
120
|
+
const runTodoAction = (input: TodoActionInput, context: ExtensionContext) => {
|
|
121
|
+
const result = applyTodoAction(state, input);
|
|
122
|
+
if (!result.ok) throw result.error;
|
|
123
|
+
if (result.changed) commitTodoState(result.state, context);
|
|
124
|
+
return result;
|
|
125
|
+
};
|
|
126
|
+
const restoreState = (context: ExtensionContext): void => {
|
|
127
|
+
state = restoreTodoState(context);
|
|
128
|
+
updateTodoWidget(context);
|
|
129
|
+
};
|
|
130
|
+
pi.on("session_start", (_event, context) => restoreState(context));
|
|
131
|
+
pi.on("session_tree", (_event, context) => restoreState(context));
|
|
132
|
+
pi.on("context", (event) => {
|
|
133
|
+
if (state.tasks.length === 0) return;
|
|
134
|
+
const todoListMessage = {
|
|
135
|
+
role: "custom",
|
|
136
|
+
customType: "pi-todo-context",
|
|
137
|
+
content: `Todo List:\n${formatTodoList(state.tasks)}`,
|
|
138
|
+
display: false,
|
|
139
|
+
timestamp: 0,
|
|
140
|
+
} as const;
|
|
141
|
+
return { messages: [...event.messages, todoListMessage] };
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
pi.registerTool<typeof TodoParameters, TodoToolDetails | undefined>({
|
|
145
|
+
name: "todo",
|
|
146
|
+
label: "Todo",
|
|
147
|
+
description: "Manage the current session branch's Todo List.",
|
|
148
|
+
parameters: TodoParameters,
|
|
149
|
+
executionMode: "sequential",
|
|
150
|
+
async execute(_toolCallId, params, _signal, _onUpdate, context) {
|
|
151
|
+
const result = runTodoAction(params, context);
|
|
152
|
+
return {
|
|
153
|
+
content: [{ type: "text", text: result.message }],
|
|
154
|
+
details: result.details,
|
|
155
|
+
};
|
|
156
|
+
},
|
|
157
|
+
renderCall: (params, theme) => {
|
|
158
|
+
let text = theme.fg("toolTitle", theme.bold("todo")) + theme.fg("muted", ` ${params.action}`);
|
|
159
|
+
if (params.id !== undefined) text += theme.fg("accent", ` #${params.id}`);
|
|
160
|
+
if (params.action === "add" && params.title) text += theme.fg("dim", ` "${params.title}"`);
|
|
161
|
+
return new Text(text, 0, 0);
|
|
162
|
+
},
|
|
163
|
+
renderResult: (result, { expanded }, theme) => {
|
|
164
|
+
const text = result.content.find((item) => item.type === "text");
|
|
165
|
+
if (!result.details) {
|
|
166
|
+
return new Text(
|
|
167
|
+
theme.fg("error", text?.type === "text" ? text.text : "Todo operation failed"),
|
|
168
|
+
0,
|
|
169
|
+
0,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
if (result.details.action === "list") {
|
|
173
|
+
const tasks = result.details.tasks;
|
|
174
|
+
if (tasks.length === 0) return new Text(theme.fg("dim", "Todo List is empty"), 0, 0);
|
|
175
|
+
const visibleTasks = expanded ? tasks : tasks.slice(0, TODO_COLLAPSED_TASK_LIMIT);
|
|
176
|
+
const lines = [
|
|
177
|
+
theme.fg("muted", `${tasks.length} ${tasks.length === 1 ? "Task" : "Tasks"}:`),
|
|
178
|
+
];
|
|
179
|
+
for (const task of visibleTasks) {
|
|
180
|
+
lines.push(renderTodoTaskLine(task, theme));
|
|
181
|
+
if (expanded && task.description) {
|
|
182
|
+
lines.push(
|
|
183
|
+
...task.description.split("\n").map((line) => theme.fg("dim", ` ${line}`)),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const remaining = tasks.length - visibleTasks.length;
|
|
188
|
+
if (remaining > 0) lines.push(theme.fg("dim", `… ${remaining} more`));
|
|
189
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
190
|
+
}
|
|
191
|
+
return new Text(
|
|
192
|
+
theme.fg("success", "✓ ") + theme.fg("muted", text?.type === "text" ? text.text : "Done"),
|
|
193
|
+
0,
|
|
194
|
+
0,
|
|
195
|
+
);
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
pi.registerCommand("todo", {
|
|
200
|
+
description: "Manage the Todo List",
|
|
201
|
+
getArgumentCompletions: (argumentPrefix) =>
|
|
202
|
+
"clear".startsWith(argumentPrefix.trim())
|
|
203
|
+
? [{ value: "clear", label: "clear", description: "Clear the Todo List" }]
|
|
204
|
+
: null,
|
|
205
|
+
handler: async (args, context) => {
|
|
206
|
+
if (args.trim() !== "clear") {
|
|
207
|
+
context.ui.notify("Usage: /todo clear", "info");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (context.mode !== "tui") {
|
|
211
|
+
context.ui.notify("/todo clear requires interactive mode", "error");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
await context.waitForIdle();
|
|
215
|
+
if (state.tasks.length === 0) {
|
|
216
|
+
context.ui.notify("Todo List is already empty", "info");
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const confirmed = await context.ui.confirm(
|
|
220
|
+
"Clear Todo List",
|
|
221
|
+
`Remove all ${state.tasks.length} ${state.tasks.length === 1 ? "Task" : "Tasks"}?`,
|
|
222
|
+
);
|
|
223
|
+
if (!confirmed) return;
|
|
224
|
+
const result = runTodoAction({ action: "clear" }, context);
|
|
225
|
+
context.ui.notify(result.message, "info");
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
}
|
package/src/todo-list.ts
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import { type Static, Type } from "typebox";
|
|
3
|
+
|
|
4
|
+
/** Agent-visible Todo List operations. */
|
|
5
|
+
export const TODO_ACTIONS = ["list", "add", "update", "remove", "clear"] as const;
|
|
6
|
+
|
|
7
|
+
/** Unconstrained Task lifecycle states. */
|
|
8
|
+
export const TODO_STATUSES = ["pending", "active", "completed"] as const;
|
|
9
|
+
|
|
10
|
+
/** One operation accepted by the Todo List. */
|
|
11
|
+
export type TodoAction = (typeof TODO_ACTIONS)[number];
|
|
12
|
+
|
|
13
|
+
/** One Task lifecycle state. */
|
|
14
|
+
export type TodoStatus = (typeof TODO_STATUSES)[number];
|
|
15
|
+
|
|
16
|
+
declare const todoTaskIdBrand: unique symbol;
|
|
17
|
+
|
|
18
|
+
/** Stable numeric identity for a Task while it exists. */
|
|
19
|
+
export type TodoTaskId = number & { readonly [todoTaskIdBrand]: true };
|
|
20
|
+
|
|
21
|
+
/** One flat unit of work in the Todo List. */
|
|
22
|
+
export type TodoTask = {
|
|
23
|
+
readonly id: TodoTaskId;
|
|
24
|
+
readonly title: string;
|
|
25
|
+
readonly description?: string;
|
|
26
|
+
readonly status: TodoStatus;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Complete session-persisted Todo List state. */
|
|
30
|
+
export type TodoStateSnapshot = {
|
|
31
|
+
readonly nextId: number;
|
|
32
|
+
readonly tasks: readonly TodoTask[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Boundary input shared by the Todo tool's five operations. */
|
|
36
|
+
export type TodoActionInput = {
|
|
37
|
+
readonly action: TodoAction;
|
|
38
|
+
readonly id?: number;
|
|
39
|
+
readonly title?: string;
|
|
40
|
+
readonly description?: string | null;
|
|
41
|
+
readonly status?: TodoStatus;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Render details whose action discriminates list data from mutation acknowledgements. */
|
|
45
|
+
export type TodoToolDetails =
|
|
46
|
+
| { readonly action: "list"; readonly tasks: readonly TodoTask[] }
|
|
47
|
+
| { readonly action: Exclude<TodoAction, "list"> };
|
|
48
|
+
|
|
49
|
+
const PositiveSafeIntegerRecord = Type.Integer({
|
|
50
|
+
minimum: 1,
|
|
51
|
+
maximum: Number.MAX_SAFE_INTEGER,
|
|
52
|
+
});
|
|
53
|
+
const TodoTaskRecord = Type.Object({
|
|
54
|
+
id: PositiveSafeIntegerRecord,
|
|
55
|
+
title: Type.String({ minLength: 1 }),
|
|
56
|
+
description: Type.Optional(Type.String({ minLength: 1 })),
|
|
57
|
+
status: StringEnum(TODO_STATUSES),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** Serialized shape stored in a `pi-todo-state` session entry. */
|
|
61
|
+
export const TodoStateRecord = Type.Object({
|
|
62
|
+
nextId: PositiveSafeIntegerRecord,
|
|
63
|
+
tasks: Type.Array(TodoTaskRecord),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/** Expected Task-operation failure translated to a Pi tool error at the extension boundary. */
|
|
67
|
+
export class TodoOperationError extends Error {
|
|
68
|
+
readonly _tag = "TodoOperationError" as const;
|
|
69
|
+
|
|
70
|
+
constructor(
|
|
71
|
+
readonly action: TodoAction,
|
|
72
|
+
message: string,
|
|
73
|
+
) {
|
|
74
|
+
super(message);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Pure outcome of applying one Todo List operation. */
|
|
79
|
+
export type TodoActionResult =
|
|
80
|
+
| {
|
|
81
|
+
readonly ok: true;
|
|
82
|
+
readonly changed: boolean;
|
|
83
|
+
readonly state: TodoStateSnapshot;
|
|
84
|
+
readonly message: string;
|
|
85
|
+
readonly details: TodoToolDetails;
|
|
86
|
+
}
|
|
87
|
+
| { readonly ok: false; readonly error: TodoOperationError };
|
|
88
|
+
|
|
89
|
+
function parseTodoTaskId(value: number): TodoTaskId | undefined {
|
|
90
|
+
if (!Number.isSafeInteger(value) || value < 1) return undefined;
|
|
91
|
+
// SAFETY: The checks above establish the positive safe-integer Task ID invariant.
|
|
92
|
+
return value as TodoTaskId;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Creates an empty Todo List whose first Task receives ID 1. */
|
|
96
|
+
export function createEmptyTodoState(): TodoStateSnapshot {
|
|
97
|
+
return { nextId: 1, tasks: [] };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Parses a structurally checked session record into ordered, immutable Todo List state. */
|
|
101
|
+
export function parseTodoStateSnapshot(
|
|
102
|
+
input: Static<typeof TodoStateRecord>,
|
|
103
|
+
): TodoStateSnapshot | undefined {
|
|
104
|
+
let previousId = 0;
|
|
105
|
+
const tasks: TodoTask[] = [];
|
|
106
|
+
for (const task of input.tasks) {
|
|
107
|
+
const id = parseTodoTaskId(task.id);
|
|
108
|
+
if (
|
|
109
|
+
id === undefined ||
|
|
110
|
+
id <= previousId ||
|
|
111
|
+
task.title.trim() !== task.title ||
|
|
112
|
+
(task.description !== undefined && task.description.trim() !== task.description)
|
|
113
|
+
) {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
tasks.push(
|
|
117
|
+
task.description === undefined
|
|
118
|
+
? { id, title: task.title, status: task.status }
|
|
119
|
+
: { id, title: task.title, description: task.description, status: task.status },
|
|
120
|
+
);
|
|
121
|
+
previousId = id;
|
|
122
|
+
}
|
|
123
|
+
return input.nextId > previousId ? { nextId: input.nextId, tasks } : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function todoOperationFailure(action: TodoAction, message: string): TodoActionResult {
|
|
127
|
+
return { ok: false, error: new TodoOperationError(action, message) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Applies one validated-by-schema tool request without performing session or UI effects. */
|
|
131
|
+
export function applyTodoAction(
|
|
132
|
+
state: TodoStateSnapshot,
|
|
133
|
+
input: TodoActionInput,
|
|
134
|
+
): TodoActionResult {
|
|
135
|
+
switch (input.action) {
|
|
136
|
+
case "list":
|
|
137
|
+
return {
|
|
138
|
+
ok: true,
|
|
139
|
+
changed: false,
|
|
140
|
+
state,
|
|
141
|
+
message: formatTodoList(state.tasks),
|
|
142
|
+
details: { action: "list", tasks: state.tasks },
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
case "add": {
|
|
146
|
+
const title = input.title?.trim();
|
|
147
|
+
if (!title) return todoOperationFailure("add", "Todo add failed: title must not be empty");
|
|
148
|
+
const description = input.description?.trim();
|
|
149
|
+
if (input.description !== undefined && input.description !== null && !description) {
|
|
150
|
+
return todoOperationFailure("add", "Todo add failed: description must not be empty");
|
|
151
|
+
}
|
|
152
|
+
const id = parseTodoTaskId(state.nextId);
|
|
153
|
+
if (id === undefined || state.nextId === Number.MAX_SAFE_INTEGER) {
|
|
154
|
+
return todoOperationFailure("add", "Todo add failed: Task ID limit reached");
|
|
155
|
+
}
|
|
156
|
+
const task: TodoTask = description
|
|
157
|
+
? { id, title, description, status: input.status ?? "pending" }
|
|
158
|
+
: { id, title, status: input.status ?? "pending" };
|
|
159
|
+
return {
|
|
160
|
+
ok: true,
|
|
161
|
+
changed: true,
|
|
162
|
+
state: { nextId: state.nextId + 1, tasks: [...state.tasks, task] },
|
|
163
|
+
message: `Added Task #${task.id}`,
|
|
164
|
+
details: { action: "add" },
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
case "update": {
|
|
169
|
+
if (input.id === undefined) {
|
|
170
|
+
return todoOperationFailure("update", "Todo update failed: id is required");
|
|
171
|
+
}
|
|
172
|
+
const id = parseTodoTaskId(input.id);
|
|
173
|
+
if (id === undefined) {
|
|
174
|
+
return todoOperationFailure(
|
|
175
|
+
"update",
|
|
176
|
+
"Todo update failed: id must be a positive safe integer",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
const task = state.tasks.find((candidate) => candidate.id === id);
|
|
180
|
+
if (!task) {
|
|
181
|
+
return todoOperationFailure(
|
|
182
|
+
"update",
|
|
183
|
+
`Todo update failed: Task #${input.id} was not found`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
if (
|
|
187
|
+
input.title === undefined &&
|
|
188
|
+
input.description === undefined &&
|
|
189
|
+
input.status === undefined
|
|
190
|
+
) {
|
|
191
|
+
return todoOperationFailure(
|
|
192
|
+
"update",
|
|
193
|
+
"Todo update failed: provide a title, description, or status",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const title = input.title === undefined ? task.title : input.title.trim();
|
|
197
|
+
if (!title) {
|
|
198
|
+
return todoOperationFailure("update", "Todo update failed: title must not be empty");
|
|
199
|
+
}
|
|
200
|
+
const description =
|
|
201
|
+
input.description === undefined
|
|
202
|
+
? task.description
|
|
203
|
+
: input.description === null
|
|
204
|
+
? undefined
|
|
205
|
+
: input.description.trim();
|
|
206
|
+
if (input.description !== undefined && input.description !== null && !description) {
|
|
207
|
+
return todoOperationFailure("update", "Todo update failed: description must not be empty");
|
|
208
|
+
}
|
|
209
|
+
const updatedTask: TodoTask = description
|
|
210
|
+
? { id: task.id, title, description, status: input.status ?? task.status }
|
|
211
|
+
: { id: task.id, title, status: input.status ?? task.status };
|
|
212
|
+
return {
|
|
213
|
+
ok: true,
|
|
214
|
+
changed: true,
|
|
215
|
+
state: {
|
|
216
|
+
nextId: state.nextId,
|
|
217
|
+
tasks: state.tasks.map((candidate) =>
|
|
218
|
+
candidate.id === updatedTask.id ? updatedTask : candidate,
|
|
219
|
+
),
|
|
220
|
+
},
|
|
221
|
+
message: `Updated Task #${task.id}`,
|
|
222
|
+
details: { action: "update" },
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
case "remove": {
|
|
227
|
+
if (input.id === undefined) {
|
|
228
|
+
return todoOperationFailure("remove", "Todo remove failed: id is required");
|
|
229
|
+
}
|
|
230
|
+
const id = parseTodoTaskId(input.id);
|
|
231
|
+
if (id === undefined) {
|
|
232
|
+
return todoOperationFailure(
|
|
233
|
+
"remove",
|
|
234
|
+
"Todo remove failed: id must be a positive safe integer",
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
const task = state.tasks.find((candidate) => candidate.id === id);
|
|
238
|
+
if (!task) {
|
|
239
|
+
return todoOperationFailure(
|
|
240
|
+
"remove",
|
|
241
|
+
`Todo remove failed: Task #${input.id} was not found`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
ok: true,
|
|
246
|
+
changed: true,
|
|
247
|
+
state: {
|
|
248
|
+
nextId: state.nextId,
|
|
249
|
+
tasks: state.tasks.filter((candidate) => candidate.id !== task.id),
|
|
250
|
+
},
|
|
251
|
+
message: `Removed Task #${task.id}`,
|
|
252
|
+
details: { action: "remove" },
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
case "clear": {
|
|
257
|
+
const count = state.tasks.length;
|
|
258
|
+
return {
|
|
259
|
+
ok: true,
|
|
260
|
+
changed: count > 0 || state.nextId !== 1,
|
|
261
|
+
state: createEmptyTodoState(),
|
|
262
|
+
message: `Cleared ${count} ${count === 1 ? "Task" : "Tasks"}`,
|
|
263
|
+
details: { action: "clear" },
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Returns the status marker shared by model context, transcript, and widget output. */
|
|
270
|
+
export function todoStatusMarker(status: TodoStatus): "[ ]" | "[>]" | "[x]" {
|
|
271
|
+
if (status === "pending") return "[ ]";
|
|
272
|
+
return status === "active" ? "[>]" : "[x]";
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function formatTodoDescription(description: string): string {
|
|
276
|
+
return description
|
|
277
|
+
.split("\n")
|
|
278
|
+
.map((line) => ` ${line}`)
|
|
279
|
+
.join("\n");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Formats the complete numeric-ID-ordered Todo List for tools and model context. */
|
|
283
|
+
export function formatTodoList(tasks: readonly TodoTask[]): string {
|
|
284
|
+
if (tasks.length === 0) return "Todo List is empty";
|
|
285
|
+
return tasks
|
|
286
|
+
.map((task) => {
|
|
287
|
+
const description = task.description ? `\n${formatTodoDescription(task.description)}` : "";
|
|
288
|
+
return `${todoStatusMarker(task.status)} #${task.id} ${task.title}${description}`;
|
|
289
|
+
})
|
|
290
|
+
.join("\n");
|
|
291
|
+
}
|