@narumitw/pi-todo 0.1.0 → 0.1.2

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
@@ -78,9 +78,12 @@ Branch reconstruction also accepts valid results stored under the previous `todo
78
78
 
79
79
  During ordinary turns, the model reads the complete list from the persisted `update_todo_list` assistant tool call, while its successful result confirms the active state and preserves append-only prompt history.
80
80
 
81
- If compaction or branch context construction removes that matching call/result pair, the extension appends one hidden, non-persistent state-only fallback containing the current list as JSON data.
81
+ If leading compaction or branch summaries remove that matching call/result pair, the extension inserts one hidden, non-persistent state-only fallback immediately after those summaries.
82
82
 
83
- The fallback stays at the end of model context until a later valid todo tool call and successful result become visible, and it is omitted when the list is cleared.
83
+ That restored message remains fixed for the current leading-summary epoch, including after a later valid todo update or clear.
84
+ The extension stores branch-local boundary metadata in the session so reload and branch navigation retain the established prefix without making the hidden fallback itself persistent model context.
85
+ The later tool call and result supersede the restored state at the conversation tail without rewriting the earlier provider prefix.
86
+ An ordinary context without a leading summary does not synthesize a fallback, and a new summary epoch restores only the then-current list when needed.
84
87
 
85
88
  In TUI mode, updates appear immediately in a widget above the editor.
86
89
 
@@ -117,8 +120,9 @@ packages/pi-todo/
117
120
  │ ├── index.ts # Thin authoritative extension forwarder
118
121
  │ └── todo-widget.ts # Tool, lifecycle, state reconstruction, and rendering
119
122
  ├── test/
120
- │ ├── build-runtime.test.ts # Build, boundary, and Jiti loader coverage
121
- └── todo-widget.test.ts # Extension behavior coverage
123
+ │ ├── build-runtime.test.ts # Build, boundary, and Jiti loader coverage
124
+ ├── todo-cache-contract.test.ts # Normalized provider-prefix coverage
125
+ │ └── todo-widget.test.ts # Extension behavior coverage
122
126
  ├── LICENSE
123
127
  ├── README.md
124
128
  ├── package.json
package/dist/index.ts CHANGED
@@ -3,6 +3,9 @@
3
3
 
4
4
  // src/todo-widget.ts
5
5
  import { StringEnum } from "@earendil-works/pi-ai";
6
+ import {
7
+ buildSessionContext
8
+ } from "@earendil-works/pi-coding-agent";
6
9
  import { stripTerminalSequences, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
7
10
  import { Type } from "typebox";
8
11
  var TOOL_NAME = "update_todo_list";
@@ -10,6 +13,8 @@ var WIDGET_KEY = "todo";
10
13
  var TODO_CONTEXT_MESSAGE_TYPE = "todo-list-status";
11
14
  var TODO_CONTEXT_VERSION = 1;
12
15
  var TODO_DETAILS_VERSION = 1;
16
+ var TODO_RESTORED_BOUNDARY_ENTRY_TYPE = "todo-restored-context-boundary";
17
+ var TODO_RESTORED_BOUNDARY_VERSION = 1;
13
18
  var MAX_TODO_ITEMS = 50;
14
19
  var MAX_TODO_TEXT_LENGTH = 300;
15
20
  var WIDGET_OPTIONS = { placement: "aboveEditor" };
@@ -37,6 +42,7 @@ var TodoParameters = Type.Object({
37
42
  function todoWidgetExtension(pi) {
38
43
  let activeSession;
39
44
  let items = [];
45
+ let restoredBoundary;
40
46
  const ownsSession = (ctx) => ctx.sessionManager === activeSession;
41
47
  const publish = (ctx) => {
42
48
  if (!ownsSession(ctx) || ctx.mode !== "tui") return;
@@ -98,25 +104,43 @@ function todoWidgetExtension(pi) {
98
104
  };
99
105
  }
100
106
  });
107
+ const restoreBranchState = (ctx) => {
108
+ const branch = ctx.sessionManager.getBranch();
109
+ items = reconstructItems(branch);
110
+ restoredBoundary = reconstructRestoredTodoBoundary(branch);
111
+ };
101
112
  pi.on("session_start", (_event, ctx) => {
102
113
  activeSession = ctx.sessionManager;
103
- items = reconstructItems(ctx.sessionManager.getBranch());
114
+ restoreBranchState(ctx);
104
115
  publish(ctx);
105
116
  });
106
117
  pi.on("context", (event, ctx) => {
107
118
  if (!ownsSession(ctx)) return;
108
- const messages = reconcileTodoContext(event.messages, items);
119
+ const summaryEpoch = leadingSummaryEpoch(event.messages);
120
+ if (restoredBoundary?.summaryEpoch !== summaryEpoch) restoredBoundary = void 0;
121
+ const messages = reconcileTodoContext(event.messages, items, restoredBoundary?.content);
122
+ if (restoredBoundary === void 0 && summaryEpoch) {
123
+ const boundaryMessage = messages[leadingSummaryBoundary(messages)];
124
+ if (isTodoContextMessage(boundaryMessage)) {
125
+ restoredBoundary = { summaryEpoch, content: boundaryMessage.content };
126
+ pi.appendEntry(TODO_RESTORED_BOUNDARY_ENTRY_TYPE, {
127
+ version: TODO_RESTORED_BOUNDARY_VERSION,
128
+ ...restoredBoundary
129
+ });
130
+ }
131
+ }
109
132
  if (messages !== event.messages) return { messages };
110
133
  });
111
134
  pi.on("session_tree", (_event, ctx) => {
112
135
  if (!ownsSession(ctx)) return;
113
- items = reconstructItems(ctx.sessionManager.getBranch());
136
+ restoreBranchState(ctx);
114
137
  publish(ctx);
115
138
  });
116
139
  pi.on("session_shutdown", (_event, ctx) => {
117
140
  if (!ownsSession(ctx)) return;
118
141
  if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, void 0);
119
142
  items = [];
143
+ restoredBoundary = void 0;
120
144
  activeSession = void 0;
121
145
  });
122
146
  }
@@ -152,17 +176,19 @@ function renderTodoWidget(items, theme, width) {
152
176
  }
153
177
  return lines.map((line) => truncateToWidth(line, renderWidth, ""));
154
178
  }
155
- function reconcileTodoContext(messages, items) {
179
+ function reconcileTodoContext(messages, items, restoredBoundaryContent) {
156
180
  const existing = messages.filter(isTodoContextMessage);
157
181
  const withoutExisting = messages.filter((message) => !isTodoContextMessage(message));
158
- const content = items.length > 0 && !hasModelVisibleTodoState(withoutExisting, items) ? todoContextContent(items) : void 0;
159
- if (existing.length === 1 && messages.at(-1) === existing[0] && existing[0]?.content === content) {
182
+ const summaryBoundary = leadingSummaryBoundary(withoutExisting);
183
+ const currentContent = items.length > 0 && !hasModelVisibleTodoState(withoutExisting, items) ? todoContextContent(items) : void 0;
184
+ const content = summaryBoundary > 0 ? restoredBoundaryContent ?? currentContent : void 0;
185
+ if (content !== void 0 && existing.length === 1 && messages[summaryBoundary] === existing[0] && existing[0]?.content === content && hasTodoContextVersion(existing[0])) {
160
186
  return messages;
161
187
  }
162
188
  if (existing.length === 0 && content === void 0) return messages;
163
189
  if (content === void 0) return withoutExisting;
164
190
  return [
165
- ...withoutExisting,
191
+ ...withoutExisting.slice(0, summaryBoundary),
166
192
  {
167
193
  role: "custom",
168
194
  customType: TODO_CONTEXT_MESSAGE_TYPE,
@@ -170,7 +196,8 @@ function reconcileTodoContext(messages, items) {
170
196
  display: false,
171
197
  details: { version: TODO_CONTEXT_VERSION },
172
198
  timestamp: 0
173
- }
199
+ },
200
+ ...withoutExisting.slice(summaryBoundary)
174
201
  ];
175
202
  }
176
203
  function sanitizeTodoText(value) {
@@ -187,6 +214,36 @@ function todoContextContent(items) {
187
214
  Current todo list as JSON data:
188
215
  ${JSON.stringify(items)}`;
189
216
  }
217
+ function reconstructRestoredTodoBoundary(entries) {
218
+ const summaryEpoch = leadingSummaryEpoch(buildSessionContext([...entries]).messages);
219
+ if (!summaryEpoch) return void 0;
220
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
221
+ const entry = entries[index];
222
+ if (entry?.type !== "custom" || entry.customType !== TODO_RESTORED_BOUNDARY_ENTRY_TYPE) {
223
+ continue;
224
+ }
225
+ if (!isRestoredTodoBoundaryData(entry.data, summaryEpoch)) continue;
226
+ return { summaryEpoch, content: entry.data.content };
227
+ }
228
+ return void 0;
229
+ }
230
+ function isRestoredTodoBoundaryData(value, summaryEpoch) {
231
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
232
+ const data = value;
233
+ if (data.version !== TODO_RESTORED_BOUNDARY_VERSION || data.summaryEpoch !== summaryEpoch || typeof data.content !== "string") {
234
+ return false;
235
+ }
236
+ const prefix = `[PI TODO STATUS v${TODO_CONTEXT_VERSION}]
237
+ Current todo list as JSON data:
238
+ `;
239
+ if (!data.content.startsWith(prefix)) return false;
240
+ try {
241
+ const restoredItems = JSON.parse(data.content.slice(prefix.length));
242
+ return isTodoItems(restoredItems) && restoredItems.length > 0 && todoContextContent(restoredItems) === data.content;
243
+ } catch {
244
+ return false;
245
+ }
246
+ }
190
247
  function hasModelVisibleTodoState(messages, items) {
191
248
  const currentResults = /* @__PURE__ */ new Map();
192
249
  for (const message of messages) {
@@ -208,6 +265,22 @@ function isTodoToolArguments(value) {
208
265
  function isTodoContextMessage(message) {
209
266
  return message.role === "custom" && message.customType === TODO_CONTEXT_MESSAGE_TYPE;
210
267
  }
268
+ function hasTodoContextVersion(message) {
269
+ return typeof message.details === "object" && message.details !== null && !Array.isArray(message.details) && message.details.version === TODO_CONTEXT_VERSION;
270
+ }
271
+ function leadingSummaryEpoch(messages) {
272
+ const boundary = leadingSummaryBoundary(messages);
273
+ return boundary === 0 ? void 0 : JSON.stringify(messages.slice(0, boundary));
274
+ }
275
+ function leadingSummaryBoundary(messages) {
276
+ let index = 0;
277
+ while (index < messages.length) {
278
+ const role = messages[index]?.role;
279
+ if (role !== "compactionSummary" && role !== "branchSummary") break;
280
+ index += 1;
281
+ }
282
+ return index;
283
+ }
211
284
  function validateItems(items) {
212
285
  for (const [index, item] of items.entries()) {
213
286
  if (item.text.trim().length === 0) {
package/dist/index.ts.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/todo-widget.ts"],
4
- "sourcesContent": ["import { StringEnum } from \"@earendil-works/pi-ai\";\nimport type {\n\tContextEvent,\n\tExtensionAPI,\n\tExtensionContext,\n\tSessionEntry,\n\tTheme,\n} from \"@earendil-works/pi-coding-agent\";\nimport { stripTerminalSequences, truncateToWidth, wrapTextWithAnsi } from \"@earendil-works/pi-tui\";\nimport { Type } from \"typebox\";\n\nexport const TOOL_NAME = \"update_todo_list\";\nexport const WIDGET_KEY = \"todo\";\nexport const TODO_CONTEXT_MESSAGE_TYPE = \"todo-list-status\";\nexport const TODO_CONTEXT_VERSION = 1;\nexport const TODO_DETAILS_VERSION = 1;\nexport const MAX_TODO_ITEMS = 50;\nexport const MAX_TODO_TEXT_LENGTH = 300;\n\nconst WIDGET_OPTIONS = { placement: \"aboveEditor\" } as const;\nconst LEGACY_TOOL_NAME = \"todo_widget\";\nconst TODO_STATUSES = [\"pending\", \"in_progress\", \"completed\"] as const;\nconst BIDI_CONTROLS = /[\\u061c\\u200e\\u200f\\u202a-\\u202e\\u2066-\\u2069]/gu;\n\ntype TodoStatus = (typeof TODO_STATUSES)[number];\n\nexport interface TodoItem {\n\ttext: string;\n\tstatus: TodoStatus;\n}\n\nexport interface TodoDetails {\n\tversion: typeof TODO_DETAILS_VERSION;\n\titems: TodoItem[];\n}\n\nconst TodoParameters = Type.Object({\n\titems: Type.Array(\n\t\tType.Object({\n\t\t\ttext: Type.String({\n\t\t\t\tminLength: 1,\n\t\t\t\tmaxLength: MAX_TODO_TEXT_LENGTH,\n\t\t\t\tdescription: \"A concise, action-oriented task\",\n\t\t\t}),\n\t\t\tstatus: StringEnum(TODO_STATUSES, {\n\t\t\t\tdescription: \"The task's current status\",\n\t\t\t}),\n\t\t}),\n\t\t{\n\t\t\tmaxItems: MAX_TODO_ITEMS,\n\t\t\tdescription: \"The complete current todo list; send an empty list to clear it\",\n\t\t},\n\t),\n});\n\nexport default function todoWidgetExtension(pi: ExtensionAPI): void {\n\tlet activeSession: ExtensionContext[\"sessionManager\"] | undefined;\n\tlet items: TodoItem[] = [];\n\n\tconst ownsSession = (ctx: ExtensionContext): boolean => ctx.sessionManager === activeSession;\n\n\tconst publish = (ctx: ExtensionContext): void => {\n\t\tif (!ownsSession(ctx) || ctx.mode !== \"tui\") return;\n\t\tif (items.length === 0) {\n\t\t\tctx.ui.setWidget(WIDGET_KEY, undefined);\n\t\t\treturn;\n\t\t}\n\n\t\tconst snapshot = cloneItems(items);\n\t\tctx.ui.setWidget(\n\t\t\tWIDGET_KEY,\n\t\t\t(_tui, theme) => ({\n\t\t\t\trender: (width) => renderTodoWidget(snapshot, theme, width),\n\t\t\t\tinvalidate: () => {},\n\t\t\t}),\n\t\t\tWIDGET_OPTIONS,\n\t\t);\n\t};\n\n\tpi.registerTool({\n\t\tname: TOOL_NAME,\n\t\tlabel: \"Todo List\",\n\t\tdescription:\n\t\t\t\"Replace the current session todo list with the complete supplied list. Call update_todo_list whenever actual task state changes; keep at most one item in_progress and send an empty list to clear it.\",\n\t\tpromptSnippet: \"Maintain the complete session todo list as multi-step work progresses\",\n\t\tpromptGuidelines: [\n\t\t\t\"Use update_todo_list to track work with multiple meaningful steps; skip it for simple, single-step tasks.\",\n\t\t\t\"Use update_todo_list to keep the list aligned with actual work: mark a task in_progress before starting it, mark it completed as soon as it finishes, and revise the list before continuing when the plan changes.\",\n\t\t\t\"Before a progress report or final response, call update_todo_list to reconcile every item with actual work; do not report completion while the list is stale.\",\n\t\t\t\"On every update_todo_list call, send the complete current list, keep at most one task in_progress, and send an empty list when no tracked work remains.\",\n\t\t],\n\t\tparameters: TodoParameters,\n\t\tasync execute(_toolCallId, params, signal, _onUpdate, ctx) {\n\t\t\tsignal?.throwIfAborted();\n\t\t\tif (!ownsSession(ctx)) {\n\t\t\t\tthrow new Error(\"Cannot update the todo list because the session changed.\");\n\t\t\t}\n\t\t\tvalidateItems(params.items);\n\n\t\t\titems = cloneItems(params.items);\n\t\t\tpublish(ctx);\n\n\t\t\tconst details: TodoDetails = {\n\t\t\t\tversion: TODO_DETAILS_VERSION,\n\t\t\t\titems: cloneItems(items),\n\t\t\t};\n\t\t\tif (items.length === 0) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: \"Todo list cleared.\" }],\n\t\t\t\t\tdetails,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tconst completed = items.filter((item) => item.status === \"completed\").length;\n\t\t\tconst inProgress = items.some((item) => item.status === \"in_progress\");\n\t\t\treturn {\n\t\t\t\tcontent: [\n\t\t\t\t\t{\n\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\ttext: `Todo list updated: ${completed} of ${items.length} complete${inProgress ? \"; 1 in progress\" : \"\"}.`,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\tdetails,\n\t\t\t};\n\t\t},\n\t});\n\n\tpi.on(\"session_start\", (_event, ctx) => {\n\t\tactiveSession = ctx.sessionManager;\n\t\titems = reconstructItems(ctx.sessionManager.getBranch());\n\t\tpublish(ctx);\n\t});\n\n\tpi.on(\"context\", (event, ctx) => {\n\t\tif (!ownsSession(ctx)) return;\n\t\tconst messages = reconcileTodoContext(event.messages, items);\n\t\tif (messages !== event.messages) return { messages };\n\t});\n\n\tpi.on(\"session_tree\", (_event, ctx) => {\n\t\tif (!ownsSession(ctx)) return;\n\t\titems = reconstructItems(ctx.sessionManager.getBranch());\n\t\tpublish(ctx);\n\t});\n\n\tpi.on(\"session_shutdown\", (_event, ctx) => {\n\t\tif (!ownsSession(ctx)) return;\n\t\tif (ctx.mode === \"tui\") ctx.ui.setWidget(WIDGET_KEY, undefined);\n\t\titems = [];\n\t\tactiveSession = undefined;\n\t});\n}\n\nexport function renderTodoWidget(\n\titems: readonly TodoItem[],\n\ttheme: Theme,\n\twidth: number,\n): string[] {\n\tconst completed = items.filter((item) => item.status === \"completed\").length;\n\tconst divider = theme.fg(\"borderMuted\", \"\u2500\".repeat(Math.max(0, width)));\n\tconst lines = [divider, theme.fg(\"muted\", `Todo \u00B7 ${completed}/${items.length} complete`)];\n\n\tconst renderWidth = Math.max(0, width);\n\tfor (const item of items) {\n\t\tconst text = sanitizeTodoText(item.text);\n\t\tlet prefix: string;\n\t\tlet styledText: string;\n\t\tswitch (item.status) {\n\t\t\tcase \"completed\":\n\t\t\t\tprefix = theme.fg(\"success\", \"\u2713 \");\n\t\t\t\tstyledText = theme.fg(\"muted\", theme.strikethrough(text));\n\t\t\t\tbreak;\n\t\t\tcase \"in_progress\":\n\t\t\t\tprefix = theme.fg(\"accent\", \"\u25B6 \");\n\t\t\t\tstyledText = theme.fg(\"accent\", theme.bold(text));\n\t\t\t\tbreak;\n\t\t\tcase \"pending\":\n\t\t\t\tprefix = theme.fg(\"dim\", \"\u25CB \");\n\t\t\t\tstyledText = theme.fg(\"text\", text);\n\t\t\t\tbreak;\n\t\t}\n\n\t\tif (renderWidth <= 2) {\n\t\t\tlines.push(prefix);\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst wrappedText = wrapTextWithAnsi(styledText, renderWidth - 2);\n\t\tlines.push(...wrappedText.map((line, index) => `${index === 0 ? prefix : \" \"}${line}`));\n\t}\n\n\treturn lines.map((line) => truncateToWidth(line, renderWidth, \"\"));\n}\n\nexport function reconcileTodoContext(\n\tmessages: ContextEvent[\"messages\"],\n\titems: readonly TodoItem[],\n): ContextEvent[\"messages\"] {\n\tconst existing = messages.filter(isTodoContextMessage);\n\tconst withoutExisting = messages.filter((message) => !isTodoContextMessage(message));\n\tconst content =\n\t\titems.length > 0 && !hasModelVisibleTodoState(withoutExisting, items)\n\t\t\t? todoContextContent(items)\n\t\t\t: undefined;\n\tif (\n\t\texisting.length === 1 &&\n\t\tmessages.at(-1) === existing[0] &&\n\t\texisting[0]?.content === content\n\t) {\n\t\treturn messages;\n\t}\n\tif (existing.length === 0 && content === undefined) return messages;\n\n\tif (content === undefined) return withoutExisting;\n\treturn [\n\t\t...withoutExisting,\n\t\t{\n\t\t\trole: \"custom\",\n\t\t\tcustomType: TODO_CONTEXT_MESSAGE_TYPE,\n\t\t\tcontent,\n\t\t\tdisplay: false,\n\t\t\tdetails: { version: TODO_CONTEXT_VERSION },\n\t\t\ttimestamp: 0,\n\t\t},\n\t];\n}\n\nexport function sanitizeTodoText(value: string): string {\n\tlet text = \"\";\n\tfor (const character of stripTerminalSequences(value).replace(BIDI_CONTROLS, \"\")) {\n\t\tconst codePoint = character.codePointAt(0) ?? 0;\n\t\tconst isControl = codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);\n\t\ttext += isControl ? \" \" : character;\n\t}\n\treturn text.replace(/\\s+/gu, \" \").trim();\n}\n\nfunction todoContextContent(items: readonly TodoItem[]): string {\n\treturn `[PI TODO STATUS v${TODO_CONTEXT_VERSION}]\nCurrent todo list as JSON data:\n${JSON.stringify(items)}`;\n}\n\nfunction hasModelVisibleTodoState(\n\tmessages: ContextEvent[\"messages\"],\n\titems: readonly TodoItem[],\n): boolean {\n\tconst currentResults = new Map<string, string>();\n\tfor (const message of messages) {\n\t\tif (\n\t\t\tmessage.role === \"toolResult\" &&\n\t\t\t!message.isError &&\n\t\t\t(message.toolName === TOOL_NAME || message.toolName === LEGACY_TOOL_NAME) &&\n\t\t\tisTodoDetails(message.details) &&\n\t\t\ttodoItemsEqual(message.details.items, items)\n\t\t) {\n\t\t\tcurrentResults.set(message.toolCallId, message.toolName);\n\t\t}\n\t}\n\tif (currentResults.size === 0) return false;\n\n\treturn messages.some(\n\t\t(message) =>\n\t\t\tmessage.role === \"assistant\" &&\n\t\t\tmessage.content.some(\n\t\t\t\t(content) =>\n\t\t\t\t\tcontent.type === \"toolCall\" &&\n\t\t\t\t\tcurrentResults.get(content.id) === content.name &&\n\t\t\t\t\tisTodoToolArguments(content.arguments) &&\n\t\t\t\t\ttodoItemsEqual(content.arguments.items, items),\n\t\t\t),\n\t);\n}\n\nfunction isTodoToolArguments(value: unknown): value is { items: TodoItem[] } {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) return false;\n\treturn isTodoItems((value as Record<string, unknown>).items);\n}\n\nfunction isTodoContextMessage(\n\tmessage: ContextEvent[\"messages\"][number],\n): message is ContextEvent[\"messages\"][number] & { content: string } {\n\treturn message.role === \"custom\" && message.customType === TODO_CONTEXT_MESSAGE_TYPE;\n}\n\nfunction validateItems(items: readonly TodoItem[]): void {\n\tfor (const [index, item] of items.entries()) {\n\t\tif (item.text.trim().length === 0) {\n\t\t\tthrow new Error(`Todo item ${index + 1} must contain non-whitespace text.`);\n\t\t}\n\t}\n\n\tconst currentCount = items.filter((item) => item.status === \"in_progress\").length;\n\tif (currentCount > 1) {\n\t\tthrow new Error(\"Todo list can contain at most one in_progress item.\");\n\t}\n}\n\nfunction reconstructItems(entries: readonly SessionEntry[]): TodoItem[] {\n\tlet restored: TodoItem[] = [];\n\tfor (const entry of entries) {\n\t\tif (entry.type !== \"message\") continue;\n\t\tconst message = entry.message;\n\t\tif (\n\t\t\tmessage.role !== \"toolResult\" ||\n\t\t\t(message.toolName !== TOOL_NAME && message.toolName !== LEGACY_TOOL_NAME)\n\t\t) {\n\t\t\tcontinue;\n\t\t}\n\t\tif (!isTodoDetails(message.details)) continue;\n\t\trestored = cloneItems(message.details.items);\n\t}\n\treturn restored;\n}\n\nfunction isTodoDetails(value: unknown): value is TodoDetails {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) return false;\n\tconst record = value as Record<string, unknown>;\n\treturn record.version === TODO_DETAILS_VERSION && isTodoItems(record.items);\n}\n\nfunction isTodoItems(value: unknown): value is TodoItem[] {\n\tif (!Array.isArray(value) || value.length > MAX_TODO_ITEMS) return false;\n\n\tlet currentCount = 0;\n\tfor (const item of value) {\n\t\tif (typeof item !== \"object\" || item === null || Array.isArray(item)) return false;\n\t\tconst candidate = item as Record<string, unknown>;\n\t\tif (\n\t\t\ttypeof candidate.text !== \"string\" ||\n\t\t\tcandidate.text.length === 0 ||\n\t\t\tcandidate.text.length > MAX_TODO_TEXT_LENGTH ||\n\t\t\tcandidate.text.trim().length === 0 ||\n\t\t\t!TODO_STATUSES.includes(candidate.status as TodoStatus)\n\t\t) {\n\t\t\treturn false;\n\t\t}\n\t\tif (candidate.status === \"in_progress\") currentCount += 1;\n\t}\n\treturn currentCount <= 1;\n}\n\nfunction todoItemsEqual(left: readonly TodoItem[], right: readonly TodoItem[]): boolean {\n\treturn (\n\t\tleft.length === right.length &&\n\t\tleft.every(\n\t\t\t(item, index) => item.text === right[index]?.text && item.status === right[index]?.status,\n\t\t)\n\t);\n}\n\nfunction cloneItems(items: readonly TodoItem[]): TodoItem[] {\n\treturn items.map((item) => ({ text: item.text, status: item.status }));\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { StringEnum } from \"@earendil-works/pi-ai\";\nimport {\n\tbuildSessionContext,\n\ttype ContextEvent,\n\ttype ExtensionAPI,\n\ttype ExtensionContext,\n\ttype SessionEntry,\n\ttype Theme,\n} from \"@earendil-works/pi-coding-agent\";\nimport { stripTerminalSequences, truncateToWidth, wrapTextWithAnsi } from \"@earendil-works/pi-tui\";\nimport { Type } from \"typebox\";\n\nexport const TOOL_NAME = \"update_todo_list\";\nexport const WIDGET_KEY = \"todo\";\nexport const TODO_CONTEXT_MESSAGE_TYPE = \"todo-list-status\";\nexport const TODO_CONTEXT_VERSION = 1;\nexport const TODO_DETAILS_VERSION = 1;\nexport const TODO_RESTORED_BOUNDARY_ENTRY_TYPE = \"todo-restored-context-boundary\";\nconst TODO_RESTORED_BOUNDARY_VERSION = 1;\nexport const MAX_TODO_ITEMS = 50;\nexport const MAX_TODO_TEXT_LENGTH = 300;\n\nconst WIDGET_OPTIONS = { placement: \"aboveEditor\" } as const;\nconst LEGACY_TOOL_NAME = \"todo_widget\";\nconst TODO_STATUSES = [\"pending\", \"in_progress\", \"completed\"] as const;\nconst BIDI_CONTROLS = /[\\u061c\\u200e\\u200f\\u202a-\\u202e\\u2066-\\u2069]/gu;\n\ntype TodoStatus = (typeof TODO_STATUSES)[number];\n\nexport interface TodoItem {\n\ttext: string;\n\tstatus: TodoStatus;\n}\n\nexport interface TodoDetails {\n\tversion: typeof TODO_DETAILS_VERSION;\n\titems: TodoItem[];\n}\n\nconst TodoParameters = Type.Object({\n\titems: Type.Array(\n\t\tType.Object({\n\t\t\ttext: Type.String({\n\t\t\t\tminLength: 1,\n\t\t\t\tmaxLength: MAX_TODO_TEXT_LENGTH,\n\t\t\t\tdescription: \"A concise, action-oriented task\",\n\t\t\t}),\n\t\t\tstatus: StringEnum(TODO_STATUSES, {\n\t\t\t\tdescription: \"The task's current status\",\n\t\t\t}),\n\t\t}),\n\t\t{\n\t\t\tmaxItems: MAX_TODO_ITEMS,\n\t\t\tdescription: \"The complete current todo list; send an empty list to clear it\",\n\t\t},\n\t),\n});\n\nexport default function todoWidgetExtension(pi: ExtensionAPI): void {\n\tlet activeSession: ExtensionContext[\"sessionManager\"] | undefined;\n\tlet items: TodoItem[] = [];\n\tlet restoredBoundary: { summaryEpoch: string; content: string } | undefined;\n\n\tconst ownsSession = (ctx: ExtensionContext): boolean => ctx.sessionManager === activeSession;\n\n\tconst publish = (ctx: ExtensionContext): void => {\n\t\tif (!ownsSession(ctx) || ctx.mode !== \"tui\") return;\n\t\tif (items.length === 0) {\n\t\t\tctx.ui.setWidget(WIDGET_KEY, undefined);\n\t\t\treturn;\n\t\t}\n\n\t\tconst snapshot = cloneItems(items);\n\t\tctx.ui.setWidget(\n\t\t\tWIDGET_KEY,\n\t\t\t(_tui, theme) => ({\n\t\t\t\trender: (width) => renderTodoWidget(snapshot, theme, width),\n\t\t\t\tinvalidate: () => {},\n\t\t\t}),\n\t\t\tWIDGET_OPTIONS,\n\t\t);\n\t};\n\n\tpi.registerTool({\n\t\tname: TOOL_NAME,\n\t\tlabel: \"Todo List\",\n\t\tdescription:\n\t\t\t\"Replace the current session todo list with the complete supplied list. Call update_todo_list whenever actual task state changes; keep at most one item in_progress and send an empty list to clear it.\",\n\t\tpromptSnippet: \"Maintain the complete session todo list as multi-step work progresses\",\n\t\tpromptGuidelines: [\n\t\t\t\"Use update_todo_list to track work with multiple meaningful steps; skip it for simple, single-step tasks.\",\n\t\t\t\"Use update_todo_list to keep the list aligned with actual work: mark a task in_progress before starting it, mark it completed as soon as it finishes, and revise the list before continuing when the plan changes.\",\n\t\t\t\"Before a progress report or final response, call update_todo_list to reconcile every item with actual work; do not report completion while the list is stale.\",\n\t\t\t\"On every update_todo_list call, send the complete current list, keep at most one task in_progress, and send an empty list when no tracked work remains.\",\n\t\t],\n\t\tparameters: TodoParameters,\n\t\tasync execute(_toolCallId, params, signal, _onUpdate, ctx) {\n\t\t\tsignal?.throwIfAborted();\n\t\t\tif (!ownsSession(ctx)) {\n\t\t\t\tthrow new Error(\"Cannot update the todo list because the session changed.\");\n\t\t\t}\n\t\t\tvalidateItems(params.items);\n\n\t\t\titems = cloneItems(params.items);\n\t\t\tpublish(ctx);\n\n\t\t\tconst details: TodoDetails = {\n\t\t\t\tversion: TODO_DETAILS_VERSION,\n\t\t\t\titems: cloneItems(items),\n\t\t\t};\n\t\t\tif (items.length === 0) {\n\t\t\t\treturn {\n\t\t\t\t\tcontent: [{ type: \"text\", text: \"Todo list cleared.\" }],\n\t\t\t\t\tdetails,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tconst completed = items.filter((item) => item.status === \"completed\").length;\n\t\t\tconst inProgress = items.some((item) => item.status === \"in_progress\");\n\t\t\treturn {\n\t\t\t\tcontent: [\n\t\t\t\t\t{\n\t\t\t\t\t\ttype: \"text\",\n\t\t\t\t\t\ttext: `Todo list updated: ${completed} of ${items.length} complete${inProgress ? \"; 1 in progress\" : \"\"}.`,\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\tdetails,\n\t\t\t};\n\t\t},\n\t});\n\n\tconst restoreBranchState = (ctx: ExtensionContext): void => {\n\t\tconst branch = ctx.sessionManager.getBranch();\n\t\titems = reconstructItems(branch);\n\t\trestoredBoundary = reconstructRestoredTodoBoundary(branch);\n\t};\n\n\tpi.on(\"session_start\", (_event, ctx) => {\n\t\tactiveSession = ctx.sessionManager;\n\t\trestoreBranchState(ctx);\n\t\tpublish(ctx);\n\t});\n\n\tpi.on(\"context\", (event, ctx) => {\n\t\tif (!ownsSession(ctx)) return;\n\t\tconst summaryEpoch = leadingSummaryEpoch(event.messages);\n\t\tif (restoredBoundary?.summaryEpoch !== summaryEpoch) restoredBoundary = undefined;\n\t\tconst messages = reconcileTodoContext(event.messages, items, restoredBoundary?.content);\n\t\tif (restoredBoundary === undefined && summaryEpoch) {\n\t\t\tconst boundaryMessage = messages[leadingSummaryBoundary(messages)];\n\t\t\tif (isTodoContextMessage(boundaryMessage)) {\n\t\t\t\trestoredBoundary = { summaryEpoch, content: boundaryMessage.content };\n\t\t\t\tpi.appendEntry(TODO_RESTORED_BOUNDARY_ENTRY_TYPE, {\n\t\t\t\t\tversion: TODO_RESTORED_BOUNDARY_VERSION,\n\t\t\t\t\t...restoredBoundary,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t\tif (messages !== event.messages) return { messages };\n\t});\n\n\tpi.on(\"session_tree\", (_event, ctx) => {\n\t\tif (!ownsSession(ctx)) return;\n\t\trestoreBranchState(ctx);\n\t\tpublish(ctx);\n\t});\n\n\tpi.on(\"session_shutdown\", (_event, ctx) => {\n\t\tif (!ownsSession(ctx)) return;\n\t\tif (ctx.mode === \"tui\") ctx.ui.setWidget(WIDGET_KEY, undefined);\n\t\titems = [];\n\t\trestoredBoundary = undefined;\n\t\tactiveSession = undefined;\n\t});\n}\n\nexport function renderTodoWidget(\n\titems: readonly TodoItem[],\n\ttheme: Theme,\n\twidth: number,\n): string[] {\n\tconst completed = items.filter((item) => item.status === \"completed\").length;\n\tconst divider = theme.fg(\"borderMuted\", \"\u2500\".repeat(Math.max(0, width)));\n\tconst lines = [divider, theme.fg(\"muted\", `Todo \u00B7 ${completed}/${items.length} complete`)];\n\n\tconst renderWidth = Math.max(0, width);\n\tfor (const item of items) {\n\t\tconst text = sanitizeTodoText(item.text);\n\t\tlet prefix: string;\n\t\tlet styledText: string;\n\t\tswitch (item.status) {\n\t\t\tcase \"completed\":\n\t\t\t\tprefix = theme.fg(\"success\", \"\u2713 \");\n\t\t\t\tstyledText = theme.fg(\"muted\", theme.strikethrough(text));\n\t\t\t\tbreak;\n\t\t\tcase \"in_progress\":\n\t\t\t\tprefix = theme.fg(\"accent\", \"\u25B6 \");\n\t\t\t\tstyledText = theme.fg(\"accent\", theme.bold(text));\n\t\t\t\tbreak;\n\t\t\tcase \"pending\":\n\t\t\t\tprefix = theme.fg(\"dim\", \"\u25CB \");\n\t\t\t\tstyledText = theme.fg(\"text\", text);\n\t\t\t\tbreak;\n\t\t}\n\n\t\tif (renderWidth <= 2) {\n\t\t\tlines.push(prefix);\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst wrappedText = wrapTextWithAnsi(styledText, renderWidth - 2);\n\t\tlines.push(...wrappedText.map((line, index) => `${index === 0 ? prefix : \" \"}${line}`));\n\t}\n\n\treturn lines.map((line) => truncateToWidth(line, renderWidth, \"\"));\n}\n\nexport function reconcileTodoContext(\n\tmessages: ContextEvent[\"messages\"],\n\titems: readonly TodoItem[],\n\trestoredBoundaryContent?: string,\n): ContextEvent[\"messages\"] {\n\tconst existing = messages.filter(isTodoContextMessage);\n\tconst withoutExisting = messages.filter((message) => !isTodoContextMessage(message));\n\tconst summaryBoundary = leadingSummaryBoundary(withoutExisting);\n\tconst currentContent =\n\t\titems.length > 0 && !hasModelVisibleTodoState(withoutExisting, items)\n\t\t\t? todoContextContent(items)\n\t\t\t: undefined;\n\tconst content = summaryBoundary > 0 ? (restoredBoundaryContent ?? currentContent) : undefined;\n\tif (\n\t\tcontent !== undefined &&\n\t\texisting.length === 1 &&\n\t\tmessages[summaryBoundary] === existing[0] &&\n\t\texisting[0]?.content === content &&\n\t\thasTodoContextVersion(existing[0])\n\t) {\n\t\treturn messages;\n\t}\n\tif (existing.length === 0 && content === undefined) return messages;\n\tif (content === undefined) return withoutExisting;\n\n\treturn [\n\t\t...withoutExisting.slice(0, summaryBoundary),\n\t\t{\n\t\t\trole: \"custom\",\n\t\t\tcustomType: TODO_CONTEXT_MESSAGE_TYPE,\n\t\t\tcontent,\n\t\t\tdisplay: false,\n\t\t\tdetails: { version: TODO_CONTEXT_VERSION },\n\t\t\ttimestamp: 0,\n\t\t},\n\t\t...withoutExisting.slice(summaryBoundary),\n\t];\n}\n\nexport function sanitizeTodoText(value: string): string {\n\tlet text = \"\";\n\tfor (const character of stripTerminalSequences(value).replace(BIDI_CONTROLS, \"\")) {\n\t\tconst codePoint = character.codePointAt(0) ?? 0;\n\t\tconst isControl = codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);\n\t\ttext += isControl ? \" \" : character;\n\t}\n\treturn text.replace(/\\s+/gu, \" \").trim();\n}\n\nfunction todoContextContent(items: readonly TodoItem[]): string {\n\treturn `[PI TODO STATUS v${TODO_CONTEXT_VERSION}]\nCurrent todo list as JSON data:\n${JSON.stringify(items)}`;\n}\n\nfunction reconstructRestoredTodoBoundary(\n\tentries: readonly SessionEntry[],\n): { summaryEpoch: string; content: string } | undefined {\n\tconst summaryEpoch = leadingSummaryEpoch(buildSessionContext([...entries]).messages);\n\tif (!summaryEpoch) return undefined;\n\tfor (let index = entries.length - 1; index >= 0; index -= 1) {\n\t\tconst entry = entries[index];\n\t\tif (entry?.type !== \"custom\" || entry.customType !== TODO_RESTORED_BOUNDARY_ENTRY_TYPE) {\n\t\t\tcontinue;\n\t\t}\n\t\tif (!isRestoredTodoBoundaryData(entry.data, summaryEpoch)) continue;\n\t\treturn { summaryEpoch, content: entry.data.content };\n\t}\n\treturn undefined;\n}\n\nfunction isRestoredTodoBoundaryData(\n\tvalue: unknown,\n\tsummaryEpoch: string,\n): value is { version: number; summaryEpoch: string; content: string } {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) return false;\n\tconst data = value as Record<string, unknown>;\n\tif (\n\t\tdata.version !== TODO_RESTORED_BOUNDARY_VERSION ||\n\t\tdata.summaryEpoch !== summaryEpoch ||\n\t\ttypeof data.content !== \"string\"\n\t) {\n\t\treturn false;\n\t}\n\tconst prefix = `[PI TODO STATUS v${TODO_CONTEXT_VERSION}]\\nCurrent todo list as JSON data:\\n`;\n\tif (!data.content.startsWith(prefix)) return false;\n\ttry {\n\t\tconst restoredItems: unknown = JSON.parse(data.content.slice(prefix.length));\n\t\treturn (\n\t\t\tisTodoItems(restoredItems) &&\n\t\t\trestoredItems.length > 0 &&\n\t\t\ttodoContextContent(restoredItems) === data.content\n\t\t);\n\t} catch {\n\t\treturn false;\n\t}\n}\n\nfunction hasModelVisibleTodoState(\n\tmessages: ContextEvent[\"messages\"],\n\titems: readonly TodoItem[],\n): boolean {\n\tconst currentResults = new Map<string, string>();\n\tfor (const message of messages) {\n\t\tif (\n\t\t\tmessage.role === \"toolResult\" &&\n\t\t\t!message.isError &&\n\t\t\t(message.toolName === TOOL_NAME || message.toolName === LEGACY_TOOL_NAME) &&\n\t\t\tisTodoDetails(message.details) &&\n\t\t\ttodoItemsEqual(message.details.items, items)\n\t\t) {\n\t\t\tcurrentResults.set(message.toolCallId, message.toolName);\n\t\t}\n\t}\n\tif (currentResults.size === 0) return false;\n\n\treturn messages.some(\n\t\t(message) =>\n\t\t\tmessage.role === \"assistant\" &&\n\t\t\tmessage.content.some(\n\t\t\t\t(content) =>\n\t\t\t\t\tcontent.type === \"toolCall\" &&\n\t\t\t\t\tcurrentResults.get(content.id) === content.name &&\n\t\t\t\t\tisTodoToolArguments(content.arguments) &&\n\t\t\t\t\ttodoItemsEqual(content.arguments.items, items),\n\t\t\t),\n\t);\n}\n\nfunction isTodoToolArguments(value: unknown): value is { items: TodoItem[] } {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) return false;\n\treturn isTodoItems((value as Record<string, unknown>).items);\n}\n\ntype TodoContextMessage = Extract<ContextEvent[\"messages\"][number], { role: \"custom\" }> & {\n\tcontent: string;\n};\n\nfunction isTodoContextMessage(\n\tmessage: ContextEvent[\"messages\"][number],\n): message is TodoContextMessage {\n\treturn message.role === \"custom\" && message.customType === TODO_CONTEXT_MESSAGE_TYPE;\n}\n\nfunction hasTodoContextVersion(message: TodoContextMessage): boolean {\n\treturn (\n\t\ttypeof message.details === \"object\" &&\n\t\tmessage.details !== null &&\n\t\t!Array.isArray(message.details) &&\n\t\t(message.details as Record<string, unknown>).version === TODO_CONTEXT_VERSION\n\t);\n}\n\nfunction leadingSummaryEpoch(messages: ContextEvent[\"messages\"]): string | undefined {\n\tconst boundary = leadingSummaryBoundary(messages);\n\treturn boundary === 0 ? undefined : JSON.stringify(messages.slice(0, boundary));\n}\n\nfunction leadingSummaryBoundary(messages: ContextEvent[\"messages\"]): number {\n\tlet index = 0;\n\twhile (index < messages.length) {\n\t\tconst role = messages[index]?.role;\n\t\tif (role !== \"compactionSummary\" && role !== \"branchSummary\") break;\n\t\tindex += 1;\n\t}\n\treturn index;\n}\n\nfunction validateItems(items: readonly TodoItem[]): void {\n\tfor (const [index, item] of items.entries()) {\n\t\tif (item.text.trim().length === 0) {\n\t\t\tthrow new Error(`Todo item ${index + 1} must contain non-whitespace text.`);\n\t\t}\n\t}\n\n\tconst currentCount = items.filter((item) => item.status === \"in_progress\").length;\n\tif (currentCount > 1) {\n\t\tthrow new Error(\"Todo list can contain at most one in_progress item.\");\n\t}\n}\n\nfunction reconstructItems(entries: readonly SessionEntry[]): TodoItem[] {\n\tlet restored: TodoItem[] = [];\n\tfor (const entry of entries) {\n\t\tif (entry.type !== \"message\") continue;\n\t\tconst message = entry.message;\n\t\tif (\n\t\t\tmessage.role !== \"toolResult\" ||\n\t\t\t(message.toolName !== TOOL_NAME && message.toolName !== LEGACY_TOOL_NAME)\n\t\t) {\n\t\t\tcontinue;\n\t\t}\n\t\tif (!isTodoDetails(message.details)) continue;\n\t\trestored = cloneItems(message.details.items);\n\t}\n\treturn restored;\n}\n\nfunction isTodoDetails(value: unknown): value is TodoDetails {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) return false;\n\tconst record = value as Record<string, unknown>;\n\treturn record.version === TODO_DETAILS_VERSION && isTodoItems(record.items);\n}\n\nfunction isTodoItems(value: unknown): value is TodoItem[] {\n\tif (!Array.isArray(value) || value.length > MAX_TODO_ITEMS) return false;\n\n\tlet currentCount = 0;\n\tfor (const item of value) {\n\t\tif (typeof item !== \"object\" || item === null || Array.isArray(item)) return false;\n\t\tconst candidate = item as Record<string, unknown>;\n\t\tif (\n\t\t\ttypeof candidate.text !== \"string\" ||\n\t\t\tcandidate.text.length === 0 ||\n\t\t\tcandidate.text.length > MAX_TODO_TEXT_LENGTH ||\n\t\t\tcandidate.text.trim().length === 0 ||\n\t\t\t!TODO_STATUSES.includes(candidate.status as TodoStatus)\n\t\t) {\n\t\t\treturn false;\n\t\t}\n\t\tif (candidate.status === \"in_progress\") currentCount += 1;\n\t}\n\treturn currentCount <= 1;\n}\n\nfunction todoItemsEqual(left: readonly TodoItem[], right: readonly TodoItem[]): boolean {\n\treturn (\n\t\tleft.length === right.length &&\n\t\tleft.every(\n\t\t\t(item, index) => item.text === right[index]?.text && item.status === right[index]?.status,\n\t\t)\n\t);\n}\n\nfunction cloneItems(items: readonly TodoItem[]): TodoItem[] {\n\treturn items.map((item) => ({ text: item.text, status: item.status }));\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-todo",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Pi extension that gives coding agents a persistent session todo widget.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,10 +1,11 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
- import type {
3
- ContextEvent,
4
- ExtensionAPI,
5
- ExtensionContext,
6
- SessionEntry,
7
- Theme,
2
+ import {
3
+ buildSessionContext,
4
+ type ContextEvent,
5
+ type ExtensionAPI,
6
+ type ExtensionContext,
7
+ type SessionEntry,
8
+ type Theme,
8
9
  } from "@earendil-works/pi-coding-agent";
9
10
  import { stripTerminalSequences, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
10
11
  import { Type } from "typebox";
@@ -14,6 +15,8 @@ export const WIDGET_KEY = "todo";
14
15
  export const TODO_CONTEXT_MESSAGE_TYPE = "todo-list-status";
15
16
  export const TODO_CONTEXT_VERSION = 1;
16
17
  export const TODO_DETAILS_VERSION = 1;
18
+ export const TODO_RESTORED_BOUNDARY_ENTRY_TYPE = "todo-restored-context-boundary";
19
+ const TODO_RESTORED_BOUNDARY_VERSION = 1;
17
20
  export const MAX_TODO_ITEMS = 50;
18
21
  export const MAX_TODO_TEXT_LENGTH = 300;
19
22
 
@@ -56,6 +59,7 @@ const TodoParameters = Type.Object({
56
59
  export default function todoWidgetExtension(pi: ExtensionAPI): void {
57
60
  let activeSession: ExtensionContext["sessionManager"] | undefined;
58
61
  let items: TodoItem[] = [];
62
+ let restoredBoundary: { summaryEpoch: string; content: string } | undefined;
59
63
 
60
64
  const ownsSession = (ctx: ExtensionContext): boolean => ctx.sessionManager === activeSession;
61
65
 
@@ -125,21 +129,39 @@ export default function todoWidgetExtension(pi: ExtensionAPI): void {
125
129
  },
126
130
  });
127
131
 
132
+ const restoreBranchState = (ctx: ExtensionContext): void => {
133
+ const branch = ctx.sessionManager.getBranch();
134
+ items = reconstructItems(branch);
135
+ restoredBoundary = reconstructRestoredTodoBoundary(branch);
136
+ };
137
+
128
138
  pi.on("session_start", (_event, ctx) => {
129
139
  activeSession = ctx.sessionManager;
130
- items = reconstructItems(ctx.sessionManager.getBranch());
140
+ restoreBranchState(ctx);
131
141
  publish(ctx);
132
142
  });
133
143
 
134
144
  pi.on("context", (event, ctx) => {
135
145
  if (!ownsSession(ctx)) return;
136
- const messages = reconcileTodoContext(event.messages, items);
146
+ const summaryEpoch = leadingSummaryEpoch(event.messages);
147
+ if (restoredBoundary?.summaryEpoch !== summaryEpoch) restoredBoundary = undefined;
148
+ const messages = reconcileTodoContext(event.messages, items, restoredBoundary?.content);
149
+ if (restoredBoundary === undefined && summaryEpoch) {
150
+ const boundaryMessage = messages[leadingSummaryBoundary(messages)];
151
+ if (isTodoContextMessage(boundaryMessage)) {
152
+ restoredBoundary = { summaryEpoch, content: boundaryMessage.content };
153
+ pi.appendEntry(TODO_RESTORED_BOUNDARY_ENTRY_TYPE, {
154
+ version: TODO_RESTORED_BOUNDARY_VERSION,
155
+ ...restoredBoundary,
156
+ });
157
+ }
158
+ }
137
159
  if (messages !== event.messages) return { messages };
138
160
  });
139
161
 
140
162
  pi.on("session_tree", (_event, ctx) => {
141
163
  if (!ownsSession(ctx)) return;
142
- items = reconstructItems(ctx.sessionManager.getBranch());
164
+ restoreBranchState(ctx);
143
165
  publish(ctx);
144
166
  });
145
167
 
@@ -147,6 +169,7 @@ export default function todoWidgetExtension(pi: ExtensionAPI): void {
147
169
  if (!ownsSession(ctx)) return;
148
170
  if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, undefined);
149
171
  items = [];
172
+ restoredBoundary = undefined;
150
173
  activeSession = undefined;
151
174
  });
152
175
  }
@@ -195,25 +218,30 @@ export function renderTodoWidget(
195
218
  export function reconcileTodoContext(
196
219
  messages: ContextEvent["messages"],
197
220
  items: readonly TodoItem[],
221
+ restoredBoundaryContent?: string,
198
222
  ): ContextEvent["messages"] {
199
223
  const existing = messages.filter(isTodoContextMessage);
200
224
  const withoutExisting = messages.filter((message) => !isTodoContextMessage(message));
201
- const content =
225
+ const summaryBoundary = leadingSummaryBoundary(withoutExisting);
226
+ const currentContent =
202
227
  items.length > 0 && !hasModelVisibleTodoState(withoutExisting, items)
203
228
  ? todoContextContent(items)
204
229
  : undefined;
230
+ const content = summaryBoundary > 0 ? (restoredBoundaryContent ?? currentContent) : undefined;
205
231
  if (
232
+ content !== undefined &&
206
233
  existing.length === 1 &&
207
- messages.at(-1) === existing[0] &&
208
- existing[0]?.content === content
234
+ messages[summaryBoundary] === existing[0] &&
235
+ existing[0]?.content === content &&
236
+ hasTodoContextVersion(existing[0])
209
237
  ) {
210
238
  return messages;
211
239
  }
212
240
  if (existing.length === 0 && content === undefined) return messages;
213
-
214
241
  if (content === undefined) return withoutExisting;
242
+
215
243
  return [
216
- ...withoutExisting,
244
+ ...withoutExisting.slice(0, summaryBoundary),
217
245
  {
218
246
  role: "custom",
219
247
  customType: TODO_CONTEXT_MESSAGE_TYPE,
@@ -222,6 +250,7 @@ export function reconcileTodoContext(
222
250
  details: { version: TODO_CONTEXT_VERSION },
223
251
  timestamp: 0,
224
252
  },
253
+ ...withoutExisting.slice(summaryBoundary),
225
254
  ];
226
255
  }
227
256
 
@@ -241,6 +270,49 @@ Current todo list as JSON data:
241
270
  ${JSON.stringify(items)}`;
242
271
  }
243
272
 
273
+ function reconstructRestoredTodoBoundary(
274
+ entries: readonly SessionEntry[],
275
+ ): { summaryEpoch: string; content: string } | undefined {
276
+ const summaryEpoch = leadingSummaryEpoch(buildSessionContext([...entries]).messages);
277
+ if (!summaryEpoch) return undefined;
278
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
279
+ const entry = entries[index];
280
+ if (entry?.type !== "custom" || entry.customType !== TODO_RESTORED_BOUNDARY_ENTRY_TYPE) {
281
+ continue;
282
+ }
283
+ if (!isRestoredTodoBoundaryData(entry.data, summaryEpoch)) continue;
284
+ return { summaryEpoch, content: entry.data.content };
285
+ }
286
+ return undefined;
287
+ }
288
+
289
+ function isRestoredTodoBoundaryData(
290
+ value: unknown,
291
+ summaryEpoch: string,
292
+ ): value is { version: number; summaryEpoch: string; content: string } {
293
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
294
+ const data = value as Record<string, unknown>;
295
+ if (
296
+ data.version !== TODO_RESTORED_BOUNDARY_VERSION ||
297
+ data.summaryEpoch !== summaryEpoch ||
298
+ typeof data.content !== "string"
299
+ ) {
300
+ return false;
301
+ }
302
+ const prefix = `[PI TODO STATUS v${TODO_CONTEXT_VERSION}]\nCurrent todo list as JSON data:\n`;
303
+ if (!data.content.startsWith(prefix)) return false;
304
+ try {
305
+ const restoredItems: unknown = JSON.parse(data.content.slice(prefix.length));
306
+ return (
307
+ isTodoItems(restoredItems) &&
308
+ restoredItems.length > 0 &&
309
+ todoContextContent(restoredItems) === data.content
310
+ );
311
+ } catch {
312
+ return false;
313
+ }
314
+ }
315
+
244
316
  function hasModelVisibleTodoState(
245
317
  messages: ContextEvent["messages"],
246
318
  items: readonly TodoItem[],
@@ -277,12 +349,40 @@ function isTodoToolArguments(value: unknown): value is { items: TodoItem[] } {
277
349
  return isTodoItems((value as Record<string, unknown>).items);
278
350
  }
279
351
 
352
+ type TodoContextMessage = Extract<ContextEvent["messages"][number], { role: "custom" }> & {
353
+ content: string;
354
+ };
355
+
280
356
  function isTodoContextMessage(
281
357
  message: ContextEvent["messages"][number],
282
- ): message is ContextEvent["messages"][number] & { content: string } {
358
+ ): message is TodoContextMessage {
283
359
  return message.role === "custom" && message.customType === TODO_CONTEXT_MESSAGE_TYPE;
284
360
  }
285
361
 
362
+ function hasTodoContextVersion(message: TodoContextMessage): boolean {
363
+ return (
364
+ typeof message.details === "object" &&
365
+ message.details !== null &&
366
+ !Array.isArray(message.details) &&
367
+ (message.details as Record<string, unknown>).version === TODO_CONTEXT_VERSION
368
+ );
369
+ }
370
+
371
+ function leadingSummaryEpoch(messages: ContextEvent["messages"]): string | undefined {
372
+ const boundary = leadingSummaryBoundary(messages);
373
+ return boundary === 0 ? undefined : JSON.stringify(messages.slice(0, boundary));
374
+ }
375
+
376
+ function leadingSummaryBoundary(messages: ContextEvent["messages"]): number {
377
+ let index = 0;
378
+ while (index < messages.length) {
379
+ const role = messages[index]?.role;
380
+ if (role !== "compactionSummary" && role !== "branchSummary") break;
381
+ index += 1;
382
+ }
383
+ return index;
384
+ }
385
+
286
386
  function validateItems(items: readonly TodoItem[]): void {
287
387
  for (const [index, item] of items.entries()) {
288
388
  if (item.text.trim().length === 0) {