@ian-pascoe/pi-todo 0.1.4 → 0.3.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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `@ian-pascoe/pi-todo` gives Pi agents a minimal, session-native **Todo List** without imposing a planning workflow.
4
4
 
5
- Requires Node `>=22.19.0` and Pi `>=0.84.1`.
5
+ Requires Node `>=22.19.0` and Pi `>=0.99.0`.
6
6
 
7
7
  ## Install
8
8
 
@@ -24,11 +24,22 @@ The `todo` tool supports five actions:
24
24
  | `remove` | `id` | Removes one Task |
25
25
  | `clear` | — | Removes every Task and resets IDs |
26
26
 
27
+ 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
+
29
+ | Action | Script value |
30
+ | ---------------- | ---------------------------------------------------------------------------- |
31
+ | `list` | `{ action: "list", tasks }`, each Task `{ id, title, description?, status }` |
32
+ | `add` / `update` | `{ action, task }` with the resulting Task |
33
+ | `remove` | `{ action: "remove", id }` |
34
+ | `clear` | `{ action: "clear", cleared }` with the number of Tasks removed |
35
+
27
36
  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
37
 
29
- Each changed Todo List is projected from its immutable session state entry as a hidden full snapshot at a fixed conversation position. Mutations inside a tool group appear after all sibling results; later requests keep earlier snapshots in place. Clearing the list produces an explicit empty snapshot. The system prompt and tool description do not change.
38
+ 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
+
40
+ 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.
30
41
 
31
- After compaction, a fixed baseline immediately after the summary restores the state from before the retained Tail. Retained and newer mutations follow chronologically. This preserves previously written conversation cache prefixes between checkpoints; it does not guarantee provider cache hits.
42
+ `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.
32
43
 
33
44
  ## UI and persistence
34
45
 
@@ -38,6 +49,6 @@ State is stored only in `pi-todo-state` session entries, without a second messag
38
49
 
39
50
  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.
40
51
 
41
- Missing or ambiguous conversation anchors abort the request instead of silently relocating snapshots. Extensions that rewrite those anchors are unsupported.
52
+ Missing or ambiguous conversation anchors abort the request instead of silently relocating Todo List Snapshots. Extensions that rewrite those anchors are unsupported.
42
53
 
43
54
  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.1.4",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "Minimal session-native Todo List for Pi agents.",
6
6
  "keywords": [
@@ -33,9 +33,9 @@
33
33
  "provenance": true
34
34
  },
35
35
  "peerDependencies": {
36
- "@earendil-works/pi-ai": "*",
37
- "@earendil-works/pi-coding-agent": "*",
38
- "@earendil-works/pi-tui": "*",
36
+ "@earendil-works/pi-ai": ">=0.99.0",
37
+ "@earendil-works/pi-coding-agent": ">=0.99.0",
38
+ "@earendil-works/pi-tui": ">=0.99.0",
39
39
  "typebox": "*"
40
40
  },
41
41
  "engines": {
@@ -50,7 +50,7 @@
50
50
  ]
51
51
  },
52
52
  "scripts": {
53
- "test": "vitest run --config ../../vitest.config.ts --root .",
53
+ "test": "vitest run",
54
54
  "typecheck": "tsc --noEmit -p tsconfig.json"
55
55
  }
56
56
  }
@@ -8,6 +8,7 @@ import {
8
8
  createEmptyTodoState,
9
9
  TODO_ACTIONS,
10
10
  TODO_STATUSES,
11
+ TodoToolOutputSchema,
11
12
  todoStatusMarker,
12
13
  type TodoActionInput,
13
14
  type TodoStateSnapshot,
@@ -160,6 +161,13 @@ export default function piTodoExtension(pi: ExtensionAPI): void {
160
161
  label: "Todo",
161
162
  description: "Manage the current session branch's Todo List.",
162
163
  parameters: TodoParameters,
164
+ annotations: {
165
+ readOnlyHint: false,
166
+ destructiveHint: false,
167
+ idempotentHint: false,
168
+ openWorldHint: false,
169
+ },
170
+ outputSchema: TodoToolOutputSchema,
163
171
  executionMode: "sequential",
164
172
  async execute(_toolCallId, params, signal, _onUpdate, context) {
165
173
  signal?.throwIfAborted();
@@ -175,6 +183,9 @@ export default function piTodoExtension(pi: ExtensionAPI): void {
175
183
  return {
176
184
  content: [{ type: "text", text: result.message }],
177
185
  details: result.details,
186
+ // Separate from `details` so the session's render shape and the script-facing value can
187
+ // evolve independently; today both use the same snake_case-compatible field names.
188
+ structuredContent: structuredClone(result.details),
178
189
  };
179
190
  },
180
191
  renderCall: (params, theme) => {
@@ -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: `Todo List:\n${formatTodoList(state.tasks)}`,
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
- let pending: Message[] = [];
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
- const content = formatTodoList(state.tasks);
119
- if (
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.length > 0)
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.length > 0) {
146
- for (const snapshot of pending) insert(snapshot);
147
- pending = [];
146
+ if (outstanding.size === 0 && pending) {
147
+ project(pending.entry, pending.state);
148
+ pending = undefined;
148
149
  }
149
150
  }
150
- if (pending.length > 0) throw new Error("Todo mutation has an incomplete tool group");
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
@@ -41,10 +41,15 @@ export type TodoActionInput = {
41
41
  readonly status?: TodoStatus;
42
42
  };
43
43
 
44
- /** Render details whose action discriminates list data from mutation acknowledgements. */
44
+ /**
45
+ * Render details and the tool's structured result. The action discriminates the list from the
46
+ * Task a mutation added or updated, the ID it removed, and the count it cleared.
47
+ */
45
48
  export type TodoToolDetails =
46
49
  | { readonly action: "list"; readonly tasks: readonly TodoTask[] }
47
- | { readonly action: Exclude<TodoAction, "list"> };
50
+ | { readonly action: "add" | "update"; readonly task: TodoTask }
51
+ | { readonly action: "remove"; readonly id: TodoTaskId }
52
+ | { readonly action: "clear"; readonly cleared: number };
48
53
 
49
54
  const PositiveSafeIntegerRecord = Type.Integer({
50
55
  minimum: 1,
@@ -57,6 +62,32 @@ const TodoTaskRecord = Type.Object({
57
62
  status: StringEnum(TODO_STATUSES),
58
63
  });
59
64
 
65
+ /**
66
+ * 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` says
68
+ * which other field is present.
69
+ */
70
+ export const TodoToolOutputSchema = Type.Object(
71
+ {
72
+ action: StringEnum(TODO_ACTIONS, { description: "The operation that ran" }),
73
+ tasks: Type.Optional(
74
+ Type.Array(TodoTaskRecord, { description: "list: every Task in ID order" }),
75
+ ),
76
+ task: Type.Optional(TodoTaskRecord),
77
+ id: Type.Optional(
78
+ Type.Integer({
79
+ minimum: 1,
80
+ maximum: Number.MAX_SAFE_INTEGER,
81
+ description: "remove: ID of the removed Task",
82
+ }),
83
+ ),
84
+ cleared: Type.Optional(
85
+ Type.Integer({ minimum: 0, description: "clear: number of Tasks removed" }),
86
+ ),
87
+ },
88
+ { additionalProperties: false },
89
+ );
90
+
60
91
  /** Serialized shape stored in a `pi-todo-state` session entry. */
61
92
  export const TodoStateRecord = Type.Object({
62
93
  nextId: PositiveSafeIntegerRecord,
@@ -158,7 +189,7 @@ export function applyTodoAction(
158
189
  ok: true,
159
190
  state: { nextId: state.nextId + 1, tasks: [...state.tasks, task] },
160
191
  message: `Added Task #${task.id}`,
161
- details: { action: "add" },
192
+ details: { action: "add", task },
162
193
  };
163
194
  }
164
195
 
@@ -190,7 +221,7 @@ export function applyTodoAction(
190
221
  tasks: state.tasks.filter((candidate) => candidate.id !== task.id),
191
222
  },
192
223
  message: `Removed Task #${task.id}`,
193
- details: { action: "remove" },
224
+ details: { action: "remove", id: task.id },
194
225
  };
195
226
  }
196
227
  if (
@@ -224,7 +255,7 @@ export function applyTodoAction(
224
255
  ),
225
256
  },
226
257
  message: `Updated Task #${task.id}`,
227
- details: { action: "update" },
258
+ details: { action: "update", task: updatedTask },
228
259
  };
229
260
  }
230
261
 
@@ -234,7 +265,7 @@ export function applyTodoAction(
234
265
  ok: true,
235
266
  state: count > 0 || state.nextId !== 1 ? createEmptyTodoState() : state,
236
267
  message: `Cleared ${count} ${count === 1 ? "Task" : "Tasks"}`,
237
- details: { action: "clear" },
268
+ details: { action: "clear", cleared: count },
238
269
  };
239
270
  }
240
271
  }