@xynogen/pix-core 0.1.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.
@@ -0,0 +1,194 @@
1
+ /**
2
+ * todo.ts — durable execution checklist tool
3
+ *
4
+ * Extracted from the plan extension: the checklist is BUILD-phase execution
5
+ * state that survives context compaction and session restore (persisted via
6
+ * appendEntry("todo-state")). It is universal — other tools and workflow
7
+ * extensions (like plan) drive it — so it lives in pix-core and registers the
8
+ * `todo` tool. State, persistence, and restore are owned end to end here; the
9
+ * checklist is seeded by the model via the tool's `set` action.
10
+ */
11
+
12
+ import { Type } from "typebox";
13
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+
15
+ export type TodoStatus = "pending" | "in_progress" | "done" | "blocked";
16
+
17
+ export interface TodoItem {
18
+ id: number;
19
+ text: string;
20
+ status: TodoStatus;
21
+ }
22
+
23
+ const TODO_GLYPH: Record<TodoStatus, string> = {
24
+ pending: "○",
25
+ in_progress: "◐",
26
+ done: "●",
27
+ blocked: "⊘",
28
+ };
29
+
30
+ const parseItems = (raw: string): string[] =>
31
+ raw
32
+ .split("\n")
33
+ .map((l) => l.replace(/^\s*(?:\d+[.)]|[-*•])\s*/, "").trim())
34
+ .filter(Boolean);
35
+
36
+ export default function registerTodo(pi: ExtensionAPI): void {
37
+ let todos: TodoItem[] = [];
38
+ let nextTodoId = 1;
39
+
40
+ function persistTodos() {
41
+ pi.appendEntry("todo-state", { todos, nextTodoId });
42
+ }
43
+
44
+ function todoSummary(): string {
45
+ if (!todos.length) return "(no todos)";
46
+ const done = todos.filter((t) => t.status === "done").length;
47
+ const lines = todos.map((t) => `${TODO_GLYPH[t.status]} ${t.id}. ${t.text}`);
48
+ return `Todos ${done}/${todos.length} done:\n${lines.join("\n")}`;
49
+ }
50
+
51
+ // Durable execution checklist for BUILD mode. Survives context compaction
52
+ // and session restore. Workflows like plan instruct the model to seed it
53
+ // from a plan's "Implementation Phases" so it stays anchored to plan.md.
54
+ pi.registerTool({
55
+ name: "todo",
56
+ label: "Todo",
57
+ description:
58
+ "Track BUILD-phase execution progress. Durable across context compaction. Actions: list, set (replace all items from newline/numbered text), add, update (change one item's status), clear.",
59
+ promptSnippet:
60
+ "todo(action, items?, id?, status?, text?) — action: list|set|add|update|clear. Use to track implementation progress, especially when executing a plan.",
61
+ promptGuidelines: [
62
+ "When you start executing a multi-step plan in BUILD mode, seed the todo list with `todo(action:'set', items: <plan Implementation Phases>)`.",
63
+ "Mark each item in_progress before working it and done when finished via `todo(action:'update', id, status)`.",
64
+ "Call `todo(action:'list')` to recover your place after long runs or context compaction.",
65
+ ],
66
+ parameters: Type.Object({
67
+ action: Type.Union(
68
+ [
69
+ Type.Literal("list"),
70
+ Type.Literal("set"),
71
+ Type.Literal("add"),
72
+ Type.Literal("update"),
73
+ Type.Literal("clear"),
74
+ ],
75
+ { description: "Operation to perform" },
76
+ ),
77
+ items: Type.Optional(
78
+ Type.String({
79
+ description:
80
+ "For set/add: newline-separated or numbered list of todo texts.",
81
+ }),
82
+ ),
83
+ id: Type.Optional(
84
+ Type.Number({ description: "For update: target todo id." }),
85
+ ),
86
+ status: Type.Optional(
87
+ Type.Union(
88
+ [
89
+ Type.Literal("pending"),
90
+ Type.Literal("in_progress"),
91
+ Type.Literal("done"),
92
+ Type.Literal("blocked"),
93
+ ],
94
+ { description: "For update: new status." },
95
+ ),
96
+ ),
97
+ text: Type.Optional(
98
+ Type.String({
99
+ description: "For update: replacement text (optional).",
100
+ }),
101
+ ),
102
+ }),
103
+ async execute(_id, params) {
104
+ switch (params.action) {
105
+ case "list":
106
+ return { content: [{ type: "text", text: todoSummary() }] };
107
+
108
+ case "set": {
109
+ const texts = parseItems(params.items ?? "");
110
+ if (!texts.length)
111
+ return {
112
+ content: [
113
+ { type: "text", text: "set requires non-empty `items`." },
114
+ ],
115
+ isError: true,
116
+ };
117
+ nextTodoId = 1;
118
+ todos = texts.map((text) => ({
119
+ id: nextTodoId++,
120
+ text,
121
+ status: "pending" as TodoStatus,
122
+ }));
123
+ persistTodos();
124
+ return { content: [{ type: "text", text: todoSummary() }] };
125
+ }
126
+
127
+ case "add": {
128
+ const texts = parseItems(params.items ?? "");
129
+ if (!texts.length)
130
+ return {
131
+ content: [
132
+ { type: "text", text: "add requires non-empty `items`." },
133
+ ],
134
+ isError: true,
135
+ };
136
+ for (const text of texts)
137
+ todos.push({ id: nextTodoId++, text, status: "pending" });
138
+ persistTodos();
139
+ return { content: [{ type: "text", text: todoSummary() }] };
140
+ }
141
+
142
+ case "update": {
143
+ const t = todos.find((x) => x.id === params.id);
144
+ if (!t)
145
+ return {
146
+ content: [
147
+ { type: "text", text: `No todo with id ${params.id}.` },
148
+ ],
149
+ isError: true,
150
+ };
151
+ if (params.status) t.status = params.status;
152
+ if (params.text) t.text = params.text;
153
+ persistTodos();
154
+ return { content: [{ type: "text", text: todoSummary() }] };
155
+ }
156
+
157
+ case "clear":
158
+ todos = [];
159
+ nextTodoId = 1;
160
+ persistTodos();
161
+ return { content: [{ type: "text", text: "Todos cleared." }] };
162
+
163
+ default:
164
+ return {
165
+ content: [
166
+ {
167
+ type: "text",
168
+ text: `Unknown action: ${String(params.action)}`,
169
+ },
170
+ ],
171
+ isError: true,
172
+ };
173
+ }
174
+ },
175
+ });
176
+
177
+ // Restore the checklist from session entries so it survives restart.
178
+ pi.on("session_start", async (_event, ctx) => {
179
+ const entries = ctx.sessionManager.getEntries() as Array<{
180
+ type: string;
181
+ customType?: string;
182
+ data?: { todos?: TodoItem[]; nextTodoId?: number };
183
+ }>;
184
+ const lastTodo = entries
185
+ .filter((e) => e.type === "custom" && e.customType === "todo-state")
186
+ .pop();
187
+ if (Array.isArray(lastTodo?.data?.todos)) {
188
+ todos = lastTodo.data.todos;
189
+ nextTodoId =
190
+ lastTodo.data.nextTodoId ??
191
+ todos.reduce((m, t) => Math.max(m, t.id + 1), 1);
192
+ }
193
+ });
194
+ }
@@ -0,0 +1,312 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import registerToolbox, {
6
+ buildRows,
7
+ parseTargets,
8
+ renderList,
9
+ toggleTool,
10
+ type ToolRow,
11
+ type ToggleOps,
12
+ } from "./toolbox.ts";
13
+
14
+ // ─── Fixtures ───────────────────────────────────────────────────────────────
15
+
16
+ const toolInfo = (name: string, source = "builtin") =>
17
+ ({
18
+ name,
19
+ description: `${name} does things.`,
20
+ parameters: {},
21
+ sourceInfo: { source, path: "", scope: "user", origin: "package" },
22
+ }) as never;
23
+
24
+ const mcpToolInfo = (name: string) => toolInfo(name, "mcp:context7");
25
+
26
+ // ─── buildRows ──────────────────────────────────────────────────────────────
27
+
28
+ describe("buildRows", () => {
29
+ test("excludes core tools (bash, edit, read, write)", () => {
30
+ const rows = buildRows([
31
+ toolInfo("bash"),
32
+ toolInfo("edit"),
33
+ toolInfo("read"),
34
+ toolInfo("write"),
35
+ toolInfo("grep"),
36
+ toolInfo("ls"),
37
+ ]);
38
+ expect(rows.map((r) => r.name)).toEqual(["grep", "ls"]);
39
+ expect(rows.every((r) => !r.mcp)).toBe(true);
40
+ });
41
+
42
+ test("flags MCP tools", () => {
43
+ const rows = buildRows([mcpToolInfo("ctx_search")]);
44
+ expect(rows[0].mcp).toBe(true);
45
+ });
46
+
47
+ test("empty input returns empty", () => {
48
+ expect(buildRows([])).toEqual([]);
49
+ });
50
+ });
51
+
52
+ // ─── parseTargets ───────────────────────────────────────────────────────────
53
+
54
+ describe("parseTargets", () => {
55
+ test("splits on commas, spaces, newlines", () => {
56
+ expect(parseTargets("ls, find grep\nfetch")).toEqual([
57
+ "ls",
58
+ "find",
59
+ "grep",
60
+ "fetch",
61
+ ]);
62
+ });
63
+
64
+ test("dedupes", () => {
65
+ expect(parseTargets("ls, ls")).toEqual(["ls"]);
66
+ });
67
+
68
+ test("empty yields empty", () => {
69
+ expect(parseTargets("")).toEqual([]);
70
+ expect(parseTargets(" , ")).toEqual([]);
71
+ });
72
+ });
73
+
74
+ // ─── renderList ─────────────────────────────────────────────────────────────
75
+
76
+ describe("renderList", () => {
77
+ const rows: ToolRow[] = [
78
+ { name: "read", description: "Read files.", mcp: false },
79
+ { name: "grep", description: "Search files.", mcp: false },
80
+ { name: "ctx", description: "MCP search.", mcp: true },
81
+ ];
82
+ const isActive = (n: string) => n === "read";
83
+
84
+ test("shows status for each tool", () => {
85
+ const out = renderList(rows, isActive);
86
+ expect(out).toContain("✓ active read");
87
+ expect(out).toContain("# gated grep");
88
+ expect(out).toContain("# gated ctx");
89
+ expect(out).toContain("[MCP]");
90
+ });
91
+
92
+ test("filters by query", () => {
93
+ const out = renderList(rows, isActive, "grep");
94
+ expect(out).toContain("grep");
95
+ expect(out).not.toContain("read");
96
+ });
97
+
98
+ test("no match message", () => {
99
+ const out = renderList(rows, isActive, "zzz");
100
+ expect(out).toContain('No tools matched "zzz"');
101
+ });
102
+
103
+ test("empty rows", () => {
104
+ expect(renderList([], isActive)).toContain("No tools registered");
105
+ });
106
+ });
107
+
108
+ // ─── toggleTool ─────────────────────────────────────────────────────────────
109
+
110
+ describe("toggleTool", () => {
111
+ const rows: ToolRow[] = [
112
+ { name: "read", description: "Read.", mcp: false },
113
+ { name: "grep", description: "Search.", mcp: false },
114
+ ];
115
+
116
+ test("enable calls onActivate", () => {
117
+ let called = "";
118
+ const ops: ToggleOps = {
119
+ onActivate: (n) => {
120
+ called = n;
121
+ return true;
122
+ },
123
+ onDeactivate: () => false,
124
+ isActive: () => false,
125
+ };
126
+ const msg = toggleTool("enable", "grep", rows, ops);
127
+ expect(called).toBe("grep");
128
+ expect(msg).toContain("Enabled grep");
129
+ });
130
+
131
+ test("disable calls onDeactivate", () => {
132
+ let called = "";
133
+ const ops: ToggleOps = {
134
+ onActivate: () => false,
135
+ onDeactivate: (n) => {
136
+ called = n;
137
+ return true;
138
+ },
139
+ isActive: () => true,
140
+ };
141
+ const msg = toggleTool("disable", "read", rows, ops);
142
+ expect(called).toBe("read");
143
+ expect(msg).toContain("Disabled read");
144
+ });
145
+
146
+ test("unknown tool returns error", () => {
147
+ const ops: ToggleOps = {
148
+ onActivate: () => false,
149
+ onDeactivate: () => false,
150
+ isActive: () => false,
151
+ };
152
+ expect(toggleTool("enable", "nope", rows, ops)).toContain("Unknown");
153
+ });
154
+
155
+ test("already active returns already message", () => {
156
+ const ops: ToggleOps = {
157
+ onActivate: () => false,
158
+ onDeactivate: () => false,
159
+ isActive: () => true,
160
+ };
161
+ expect(toggleTool("enable", "read", rows, ops)).toContain("already");
162
+ });
163
+
164
+ test("already gated returns already message", () => {
165
+ const ops: ToggleOps = {
166
+ onActivate: () => false,
167
+ onDeactivate: () => false,
168
+ isActive: () => false,
169
+ };
170
+ expect(toggleTool("disable", "read", rows, ops)).toContain("already");
171
+ });
172
+ });
173
+
174
+ // ─── Integration: /toolbox command ──────────────────────────────────────────
175
+
176
+ // Isolate from real ~/.pi/agent/toolbox.json on disk
177
+ let tmpAgentDir: string;
178
+ beforeAll(() => {
179
+ tmpAgentDir = mkdtempSync(join(tmpdir(), "toolbox-test-"));
180
+ process.env.PI_CODING_AGENT_DIR = tmpAgentDir;
181
+ });
182
+ afterAll(() => {
183
+ delete process.env.PI_CODING_AGENT_DIR;
184
+ try {
185
+ rmSync(tmpAgentDir, { recursive: true });
186
+ } catch {}
187
+ });
188
+
189
+ function makeHost(toolNames: string[]) {
190
+ const handlers: Record<string, Array<(p: unknown) => unknown>> = {};
191
+ let active: string[] = [...toolNames];
192
+ const commands: Array<{ name: string; handler: Function }> = [];
193
+ const pi = {
194
+ on(ev: string, fn: (p: unknown) => unknown) {
195
+ (handlers[ev] ??= []).push(fn);
196
+ },
197
+ emit(ev: string, payload: unknown, ctx?: unknown) {
198
+ return Promise.all(
199
+ (handlers[ev] ?? []).map((f) => (f as Function)(payload, ctx)),
200
+ );
201
+ },
202
+ getAllTools() {
203
+ return toolNames.map((name) => ({
204
+ name,
205
+ description: `${name} does things.`,
206
+ parameters: {},
207
+ sourceInfo: { source: "builtin" },
208
+ }));
209
+ },
210
+ getActiveTools() {
211
+ return active;
212
+ },
213
+ setActiveTools(names: string[]) {
214
+ active = [...names];
215
+ },
216
+ getCommands() {
217
+ return [];
218
+ },
219
+ appendEntry() {},
220
+ registerTool() {},
221
+ registerCommand(name: string, def: { handler: Function }) {
222
+ commands.push({ name, handler: def.handler });
223
+ },
224
+ } as never;
225
+ const emit = (ev: string, payload: unknown, ctx?: unknown) =>
226
+ Promise.all((handlers[ev] ?? []).map((f) => (f as Function)(payload, ctx)));
227
+ return {
228
+ pi,
229
+ emit,
230
+ getActive: () => active,
231
+ command: (name: string) => commands.find((c) => c.name === name),
232
+ };
233
+ }
234
+
235
+ function makeCtx() {
236
+ const notes: Array<{ text: string; level?: string }> = [];
237
+ const ctx = {
238
+ ui: {
239
+ notify(text: string, level?: string) {
240
+ notes.push({ text, level });
241
+ },
242
+ },
243
+ } as never;
244
+ return { ctx, notes };
245
+ }
246
+
247
+ describe("/toolbox command", () => {
248
+ const ALL = ["read", "write", "bash", "grep", "ast_grep_search"];
249
+
250
+ async function boot() {
251
+ const host = makeHost(ALL);
252
+ registerToolbox(host.pi);
253
+ // session_start triggers init
254
+ await host.emit("session_start", {}, {});
255
+ return host;
256
+ }
257
+
258
+ test("registers a /toolbox command", async () => {
259
+ const host = await boot();
260
+ expect(host.command("toolbox")).toBeDefined();
261
+ });
262
+
263
+ test("bare /toolbox falls back to listing when no custom UI", async () => {
264
+ const host = await boot();
265
+ const { ctx, notes } = makeCtx();
266
+ await host.command("toolbox")?.handler("", ctx);
267
+ expect(notes.length).toBe(1);
268
+ // only non-core tools shown, all start active
269
+ expect(notes[0].text).toContain("✓ active grep");
270
+ expect(notes[0].text).toContain("✓ active ast_grep_search");
271
+ // core tools excluded from toolbox
272
+ expect(notes[0].text).not.toContain(" read");
273
+ expect(notes[0].text).not.toContain(" bash");
274
+ });
275
+
276
+ test("/toolbox list shows non-core tools with status", async () => {
277
+ const host = await boot();
278
+ const { ctx, notes } = makeCtx();
279
+ await host.command("toolbox")?.handler("list", ctx);
280
+ expect(notes[0].text).toContain("grep");
281
+ expect(notes[0].text).toContain("ast_grep_search");
282
+ expect(notes[0].text).not.toContain(" bash");
283
+ });
284
+
285
+ test("/toolbox list <query> filters", async () => {
286
+ const host = await boot();
287
+ const { ctx, notes } = makeCtx();
288
+ await host.command("toolbox")?.handler("list ast", ctx);
289
+ expect(notes[0].text).toContain("ast_grep_search");
290
+ expect(notes[0].text).not.toContain("✓ active read");
291
+ });
292
+
293
+ test("opens interactive picker when ctx.ui.custom exists", async () => {
294
+ const host = await boot();
295
+ let customCalled = 0;
296
+ const notes: Array<{ text: string; level?: string }> = [];
297
+ const ctx = {
298
+ ui: {
299
+ notify(text: string, level?: string) {
300
+ notes.push({ text, level });
301
+ },
302
+ async custom() {
303
+ customCalled++;
304
+ return null;
305
+ },
306
+ },
307
+ } as never;
308
+ await host.command("toolbox")?.handler("", ctx);
309
+ expect(customCalled).toBe(1);
310
+ expect(notes.length).toBe(0);
311
+ });
312
+ });