@narumitw/pi-todo 0.0.0 → 0.1.1

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,11 @@ 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 later tool call and result supersede the restored state at the conversation tail without rewriting the earlier provider prefix.
85
+ 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
86
 
85
87
  In TUI mode, updates appear immediately in a widget above the editor.
86
88
 
@@ -117,8 +119,9 @@ packages/pi-todo/
117
119
  │ ├── index.ts # Thin authoritative extension forwarder
118
120
  │ └── todo-widget.ts # Tool, lifecycle, state reconstruction, and rendering
119
121
  ├── test/
120
- │ ├── build-runtime.test.ts # Build, boundary, and Jiti loader coverage
121
- └── todo-widget.test.ts # Extension behavior coverage
122
+ │ ├── build-runtime.test.ts # Build, boundary, and Jiti loader coverage
123
+ ├── todo-cache-contract.test.ts # Normalized provider-prefix coverage
124
+ │ └── todo-widget.test.ts # Extension behavior coverage
122
125
  ├── LICENSE
123
126
  ├── README.md
124
127
  ├── package.json
package/dist/index.ts CHANGED
@@ -37,6 +37,7 @@ var TodoParameters = Type.Object({
37
37
  function todoWidgetExtension(pi) {
38
38
  let activeSession;
39
39
  let items = [];
40
+ let restoredBoundary;
40
41
  const ownsSession = (ctx) => ctx.sessionManager === activeSession;
41
42
  const publish = (ctx) => {
42
43
  if (!ownsSession(ctx) || ctx.mode !== "tui") return;
@@ -101,11 +102,20 @@ function todoWidgetExtension(pi) {
101
102
  pi.on("session_start", (_event, ctx) => {
102
103
  activeSession = ctx.sessionManager;
103
104
  items = reconstructItems(ctx.sessionManager.getBranch());
105
+ restoredBoundary = void 0;
104
106
  publish(ctx);
105
107
  });
106
108
  pi.on("context", (event, ctx) => {
107
109
  if (!ownsSession(ctx)) return;
108
- const messages = reconcileTodoContext(event.messages, items);
110
+ const summaryEpoch = leadingSummaryEpoch(event.messages);
111
+ if (restoredBoundary?.summaryEpoch !== summaryEpoch) restoredBoundary = void 0;
112
+ const messages = reconcileTodoContext(event.messages, items, restoredBoundary?.content);
113
+ if (restoredBoundary === void 0 && summaryEpoch) {
114
+ const boundaryMessage = messages[leadingSummaryBoundary(messages)];
115
+ if (isTodoContextMessage(boundaryMessage)) {
116
+ restoredBoundary = { summaryEpoch, content: boundaryMessage.content };
117
+ }
118
+ }
109
119
  if (messages !== event.messages) return { messages };
110
120
  });
111
121
  pi.on("session_tree", (_event, ctx) => {
@@ -117,6 +127,7 @@ function todoWidgetExtension(pi) {
117
127
  if (!ownsSession(ctx)) return;
118
128
  if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, void 0);
119
129
  items = [];
130
+ restoredBoundary = void 0;
120
131
  activeSession = void 0;
121
132
  });
122
133
  }
@@ -152,17 +163,19 @@ function renderTodoWidget(items, theme, width) {
152
163
  }
153
164
  return lines.map((line) => truncateToWidth(line, renderWidth, ""));
154
165
  }
155
- function reconcileTodoContext(messages, items) {
166
+ function reconcileTodoContext(messages, items, restoredBoundaryContent) {
156
167
  const existing = messages.filter(isTodoContextMessage);
157
168
  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) {
169
+ const summaryBoundary = leadingSummaryBoundary(withoutExisting);
170
+ const currentContent = items.length > 0 && !hasModelVisibleTodoState(withoutExisting, items) ? todoContextContent(items) : void 0;
171
+ const content = summaryBoundary > 0 ? restoredBoundaryContent ?? currentContent : void 0;
172
+ if (content !== void 0 && existing.length === 1 && messages[summaryBoundary] === existing[0] && existing[0]?.content === content && hasTodoContextVersion(existing[0])) {
160
173
  return messages;
161
174
  }
162
175
  if (existing.length === 0 && content === void 0) return messages;
163
176
  if (content === void 0) return withoutExisting;
164
177
  return [
165
- ...withoutExisting,
178
+ ...withoutExisting.slice(0, summaryBoundary),
166
179
  {
167
180
  role: "custom",
168
181
  customType: TODO_CONTEXT_MESSAGE_TYPE,
@@ -170,7 +183,8 @@ function reconcileTodoContext(messages, items) {
170
183
  display: false,
171
184
  details: { version: TODO_CONTEXT_VERSION },
172
185
  timestamp: 0
173
- }
186
+ },
187
+ ...withoutExisting.slice(summaryBoundary)
174
188
  ];
175
189
  }
176
190
  function sanitizeTodoText(value) {
@@ -208,6 +222,22 @@ function isTodoToolArguments(value) {
208
222
  function isTodoContextMessage(message) {
209
223
  return message.role === "custom" && message.customType === TODO_CONTEXT_MESSAGE_TYPE;
210
224
  }
225
+ function hasTodoContextVersion(message) {
226
+ return typeof message.details === "object" && message.details !== null && !Array.isArray(message.details) && message.details.version === TODO_CONTEXT_VERSION;
227
+ }
228
+ function leadingSummaryEpoch(messages) {
229
+ const boundary = leadingSummaryBoundary(messages);
230
+ return boundary === 0 ? void 0 : JSON.stringify(messages.slice(0, boundary));
231
+ }
232
+ function leadingSummaryBoundary(messages) {
233
+ let index = 0;
234
+ while (index < messages.length) {
235
+ const role = messages[index]?.role;
236
+ if (role !== "compactionSummary" && role !== "branchSummary") break;
237
+ index += 1;
238
+ }
239
+ return index;
240
+ }
211
241
  function validateItems(items) {
212
242
  for (const [index, item] of items.entries()) {
213
243
  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 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\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\tpi.on(\"session_start\", (_event, ctx) => {\n\t\tactiveSession = ctx.sessionManager;\n\t\titems = reconstructItems(ctx.sessionManager.getBranch());\n\t\trestoredBoundary = undefined;\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}\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\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\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 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.0.0",
3
+ "version": "0.1.1",
4
4
  "description": "Pi extension that gives coding agents a persistent session todo widget.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "repository": {
54
54
  "type": "git",
55
- "url": "git+https://github.com/narumiruna/pi-extensions.git",
55
+ "url": "https://github.com/narumiruna/pi-extensions",
56
56
  "directory": "packages/pi-todo"
57
57
  }
58
58
  }
@@ -56,6 +56,7 @@ const TodoParameters = Type.Object({
56
56
  export default function todoWidgetExtension(pi: ExtensionAPI): void {
57
57
  let activeSession: ExtensionContext["sessionManager"] | undefined;
58
58
  let items: TodoItem[] = [];
59
+ let restoredBoundary: { summaryEpoch: string; content: string } | undefined;
59
60
 
60
61
  const ownsSession = (ctx: ExtensionContext): boolean => ctx.sessionManager === activeSession;
61
62
 
@@ -128,12 +129,21 @@ export default function todoWidgetExtension(pi: ExtensionAPI): void {
128
129
  pi.on("session_start", (_event, ctx) => {
129
130
  activeSession = ctx.sessionManager;
130
131
  items = reconstructItems(ctx.sessionManager.getBranch());
132
+ restoredBoundary = undefined;
131
133
  publish(ctx);
132
134
  });
133
135
 
134
136
  pi.on("context", (event, ctx) => {
135
137
  if (!ownsSession(ctx)) return;
136
- const messages = reconcileTodoContext(event.messages, items);
138
+ const summaryEpoch = leadingSummaryEpoch(event.messages);
139
+ if (restoredBoundary?.summaryEpoch !== summaryEpoch) restoredBoundary = undefined;
140
+ const messages = reconcileTodoContext(event.messages, items, restoredBoundary?.content);
141
+ if (restoredBoundary === undefined && summaryEpoch) {
142
+ const boundaryMessage = messages[leadingSummaryBoundary(messages)];
143
+ if (isTodoContextMessage(boundaryMessage)) {
144
+ restoredBoundary = { summaryEpoch, content: boundaryMessage.content };
145
+ }
146
+ }
137
147
  if (messages !== event.messages) return { messages };
138
148
  });
139
149
 
@@ -147,6 +157,7 @@ export default function todoWidgetExtension(pi: ExtensionAPI): void {
147
157
  if (!ownsSession(ctx)) return;
148
158
  if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, undefined);
149
159
  items = [];
160
+ restoredBoundary = undefined;
150
161
  activeSession = undefined;
151
162
  });
152
163
  }
@@ -195,25 +206,30 @@ export function renderTodoWidget(
195
206
  export function reconcileTodoContext(
196
207
  messages: ContextEvent["messages"],
197
208
  items: readonly TodoItem[],
209
+ restoredBoundaryContent?: string,
198
210
  ): ContextEvent["messages"] {
199
211
  const existing = messages.filter(isTodoContextMessage);
200
212
  const withoutExisting = messages.filter((message) => !isTodoContextMessage(message));
201
- const content =
213
+ const summaryBoundary = leadingSummaryBoundary(withoutExisting);
214
+ const currentContent =
202
215
  items.length > 0 && !hasModelVisibleTodoState(withoutExisting, items)
203
216
  ? todoContextContent(items)
204
217
  : undefined;
218
+ const content = summaryBoundary > 0 ? (restoredBoundaryContent ?? currentContent) : undefined;
205
219
  if (
220
+ content !== undefined &&
206
221
  existing.length === 1 &&
207
- messages.at(-1) === existing[0] &&
208
- existing[0]?.content === content
222
+ messages[summaryBoundary] === existing[0] &&
223
+ existing[0]?.content === content &&
224
+ hasTodoContextVersion(existing[0])
209
225
  ) {
210
226
  return messages;
211
227
  }
212
228
  if (existing.length === 0 && content === undefined) return messages;
213
-
214
229
  if (content === undefined) return withoutExisting;
230
+
215
231
  return [
216
- ...withoutExisting,
232
+ ...withoutExisting.slice(0, summaryBoundary),
217
233
  {
218
234
  role: "custom",
219
235
  customType: TODO_CONTEXT_MESSAGE_TYPE,
@@ -222,6 +238,7 @@ export function reconcileTodoContext(
222
238
  details: { version: TODO_CONTEXT_VERSION },
223
239
  timestamp: 0,
224
240
  },
241
+ ...withoutExisting.slice(summaryBoundary),
225
242
  ];
226
243
  }
227
244
 
@@ -277,12 +294,40 @@ function isTodoToolArguments(value: unknown): value is { items: TodoItem[] } {
277
294
  return isTodoItems((value as Record<string, unknown>).items);
278
295
  }
279
296
 
297
+ type TodoContextMessage = Extract<ContextEvent["messages"][number], { role: "custom" }> & {
298
+ content: string;
299
+ };
300
+
280
301
  function isTodoContextMessage(
281
302
  message: ContextEvent["messages"][number],
282
- ): message is ContextEvent["messages"][number] & { content: string } {
303
+ ): message is TodoContextMessage {
283
304
  return message.role === "custom" && message.customType === TODO_CONTEXT_MESSAGE_TYPE;
284
305
  }
285
306
 
307
+ function hasTodoContextVersion(message: TodoContextMessage): boolean {
308
+ return (
309
+ typeof message.details === "object" &&
310
+ message.details !== null &&
311
+ !Array.isArray(message.details) &&
312
+ (message.details as Record<string, unknown>).version === TODO_CONTEXT_VERSION
313
+ );
314
+ }
315
+
316
+ function leadingSummaryEpoch(messages: ContextEvent["messages"]): string | undefined {
317
+ const boundary = leadingSummaryBoundary(messages);
318
+ return boundary === 0 ? undefined : JSON.stringify(messages.slice(0, boundary));
319
+ }
320
+
321
+ function leadingSummaryBoundary(messages: ContextEvent["messages"]): number {
322
+ let index = 0;
323
+ while (index < messages.length) {
324
+ const role = messages[index]?.role;
325
+ if (role !== "compactionSummary" && role !== "branchSummary") break;
326
+ index += 1;
327
+ }
328
+ return index;
329
+ }
330
+
286
331
  function validateItems(items: readonly TodoItem[]): void {
287
332
  for (const [index, item] of items.entries()) {
288
333
  if (item.text.trim().length === 0) {