@xynogen/pix-core 0.2.4 → 0.3.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.
Files changed (38) hide show
  1. package/README.md +24 -21
  2. package/package.json +11 -17
  3. package/skills/ask-user/SKILL.md +0 -48
  4. package/src/commands/agent-sop/agent-sop.ts +0 -58
  5. package/src/commands/clear/clear.ts +0 -32
  6. package/src/commands/diff/diff.ts +0 -32
  7. package/src/commands/models/models.test.ts +0 -95
  8. package/src/commands/models/models.ts +0 -367
  9. package/src/commands/models/patch-builtin.test.ts +0 -66
  10. package/src/commands/models/patch-builtin.ts +0 -120
  11. package/src/commands/tools.test.ts +0 -15
  12. package/src/commands/update/update.test.ts +0 -112
  13. package/src/commands/update/update.ts +0 -271
  14. package/src/index.ts +0 -45
  15. package/src/lib/data.ts +0 -33
  16. package/src/nudge/capability.test.ts +0 -258
  17. package/src/nudge/capability.ts +0 -189
  18. package/src/nudge/index.ts +0 -17
  19. package/src/nudge/tools.test.ts +0 -157
  20. package/src/nudge/tools.ts +0 -212
  21. package/src/tool/ask/ask.test.ts +0 -243
  22. package/src/tool/ask/components.ts +0 -55
  23. package/src/tool/ask/helpers.ts +0 -77
  24. package/src/tool/ask/index.ts +0 -130
  25. package/src/tool/ask/questionnaire.ts +0 -693
  26. package/src/tool/ask/rpc.ts +0 -84
  27. package/src/tool/ask/schema.ts +0 -69
  28. package/src/tool/ask/single-select-layout.test.ts +0 -124
  29. package/src/tool/ask/single-select-layout.ts +0 -237
  30. package/src/tool/ask/types.ts +0 -17
  31. package/src/tool/todo/todo.test.ts +0 -646
  32. package/src/tool/todo/todo.ts +0 -218
  33. package/src/tool/toolbox/toolbox.test.ts +0 -314
  34. package/src/tool/toolbox/toolbox.ts +0 -570
  35. package/src/ui/diagnostics.ts +0 -145
  36. package/src/ui/footer.ts +0 -513
  37. package/src/ui/welcome.test.ts +0 -124
  38. package/src/ui/welcome.ts +0 -369
@@ -1,218 +0,0 @@
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 { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
- import { Text } from "@earendil-works/pi-tui";
14
- import { Type } from "typebox";
15
-
16
- export type TodoStatus = "pending" | "in_progress" | "done" | "blocked";
17
-
18
- export interface TodoItem {
19
- id: number;
20
- text: string;
21
- status: TodoStatus;
22
- }
23
-
24
- const TODO_GLYPH: Record<TodoStatus, string> = {
25
- pending: "○",
26
- in_progress: "◐",
27
- done: "●",
28
- blocked: "⊘",
29
- };
30
-
31
- /** Theme color key per status — drives both glyph and (for active) row tint. */
32
- const TODO_COLOR: Record<TodoStatus, string> = {
33
- pending: "muted",
34
- in_progress: "accent",
35
- done: "success",
36
- blocked: "error",
37
- };
38
-
39
- export type TodoTheme = {
40
- fg: (color: string, text: string) => string;
41
- bold: (text: string) => string;
42
- };
43
-
44
- /** Colored checklist for the TUI: glyphs tinted by status, active row bold. */
45
- export function renderTodoLines(items: TodoItem[], theme: TodoTheme): string {
46
- if (!items.length) return theme.fg("muted", "(no todos)");
47
- const done = items.filter((t) => t.status === "done").length;
48
- const head = theme.fg("muted", `Todos ${done}/${items.length} done:`);
49
- const lines = items.map((t) => {
50
- const color = TODO_COLOR[t.status];
51
- const glyph = theme.fg(color, TODO_GLYPH[t.status]);
52
- const body = `${t.id}. ${t.text}`;
53
- // Highlight the in-flight task so the eye lands on it first.
54
- const label =
55
- t.status === "in_progress"
56
- ? theme.bold(theme.fg("accent", body))
57
- : theme.fg(t.status === "done" ? "muted" : "text", body);
58
- return `${glyph} ${label}`;
59
- });
60
- return `${head}\n${lines.join("\n")}`;
61
- }
62
-
63
- const parseItems = (raw: string): string[] =>
64
- raw
65
- .split("\n")
66
- .map((l) => l.replace(/^\s*(?:\d+[.)]|[-*•])\s*/, "").trim())
67
- .filter(Boolean);
68
-
69
- export default function registerTodo(pi: ExtensionAPI): void {
70
- let todos: TodoItem[] = [];
71
- let nextTodoId = 1;
72
-
73
- function persistTodos() {
74
- pi.appendEntry("todo-state", { todos, nextTodoId });
75
- }
76
-
77
- function todoSummary(): string {
78
- if (!todos.length) return "(no todos)";
79
- const done = todos.filter((t) => t.status === "done").length;
80
- const lines = todos.map(
81
- (t) => `${TODO_GLYPH[t.status]} ${t.id}. ${t.text}`,
82
- );
83
- return `Todos ${done}/${todos.length} done:\n${lines.join("\n")}`;
84
- }
85
-
86
- // Durable execution checklist for BUILD mode. Survives context compaction
87
- // and session restore. Workflows like plan instruct the model to seed it
88
- // from a plan's "Implementation Phases" so it stays anchored to plan.md.
89
- pi.registerTool({
90
- name: "todo",
91
- label: "Todo",
92
- description:
93
- "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.",
94
- promptSnippet:
95
- "todo(action, items?, id?, status?, text?) — action: list|set|add|update|clear. Use to track implementation progress, especially when executing a plan.",
96
- promptGuidelines: [
97
- "When you start executing a multi-step plan in BUILD mode, seed the todo list with `todo(action:'set', items: <plan Implementation Phases>)`.",
98
- "Mark each item in_progress before working it and done when finished via `todo(action:'update', id, status)`.",
99
- "Call `todo(action:'list')` to recover your place after long runs or context compaction.",
100
- ],
101
- parameters: Type.Object({
102
- action: Type.Union(
103
- [
104
- Type.Literal("list"),
105
- Type.Literal("set"),
106
- Type.Literal("add"),
107
- Type.Literal("update"),
108
- Type.Literal("clear"),
109
- ],
110
- { description: "Operation to perform" },
111
- ),
112
- items: Type.Optional(
113
- Type.String({
114
- description:
115
- "For set/add: newline-separated or numbered list of todo texts.",
116
- }),
117
- ),
118
- id: Type.Optional(
119
- Type.Number({ description: "For update: target todo id." }),
120
- ),
121
- status: Type.Optional(
122
- Type.Union(
123
- [
124
- Type.Literal("pending"),
125
- Type.Literal("in_progress"),
126
- Type.Literal("done"),
127
- Type.Literal("blocked"),
128
- ],
129
- { description: "For update: new status." },
130
- ),
131
- ),
132
- text: Type.Optional(
133
- Type.String({
134
- description: "For update: replacement text (optional).",
135
- }),
136
- ),
137
- }),
138
- renderResult(_result, _options, theme) {
139
- return new Text(renderTodoLines(todos, theme as TodoTheme), 0, 0);
140
- },
141
-
142
- async execute(_id, params) {
143
- // AgentToolResult now requires a `details` field. These todo results have
144
- // no structured details, so emit `undefined` via small local helpers.
145
- const ok = (text: string) => ({
146
- content: [{ type: "text" as const, text }],
147
- details: undefined,
148
- });
149
- const fail = (text: string) => ({
150
- content: [{ type: "text" as const, text }],
151
- details: undefined,
152
- isError: true,
153
- });
154
- switch (params.action) {
155
- case "list":
156
- return ok(todoSummary());
157
-
158
- case "set": {
159
- const texts = parseItems(params.items ?? "");
160
- if (!texts.length) return fail("set requires non-empty `items`.");
161
- nextTodoId = 1;
162
- todos = texts.map((text) => ({
163
- id: nextTodoId++,
164
- text,
165
- status: "pending" as TodoStatus,
166
- }));
167
- persistTodos();
168
- return ok(todoSummary());
169
- }
170
-
171
- case "add": {
172
- const texts = parseItems(params.items ?? "");
173
- if (!texts.length) return fail("add requires non-empty `items`.");
174
- for (const text of texts)
175
- todos.push({ id: nextTodoId++, text, status: "pending" });
176
- persistTodos();
177
- return ok(todoSummary());
178
- }
179
-
180
- case "update": {
181
- const t = todos.find((x) => x.id === params.id);
182
- if (!t) return fail(`No todo with id ${params.id}.`);
183
- if (params.status) t.status = params.status;
184
- if (params.text) t.text = params.text;
185
- persistTodos();
186
- return ok(todoSummary());
187
- }
188
-
189
- case "clear":
190
- todos = [];
191
- nextTodoId = 1;
192
- persistTodos();
193
- return ok("Todos cleared.");
194
-
195
- default:
196
- return fail(`Unknown action: ${String(params.action)}`);
197
- }
198
- },
199
- });
200
-
201
- // Restore the checklist from session entries so it survives restart.
202
- pi.on("session_start", async (_event, ctx) => {
203
- const entries = ctx.sessionManager.getEntries() as Array<{
204
- type: string;
205
- customType?: string;
206
- data?: { todos?: TodoItem[]; nextTodoId?: number };
207
- }>;
208
- const lastTodo = entries
209
- .filter((e) => e.type === "custom" && e.customType === "todo-state")
210
- .pop();
211
- if (Array.isArray(lastTodo?.data?.todos)) {
212
- todos = lastTodo.data.todos;
213
- nextTodoId =
214
- lastTodo.data.nextTodoId ??
215
- todos.reduce((m, t) => Math.max(m, t.id + 1), 1);
216
- }
217
- });
218
- }
@@ -1,314 +0,0 @@
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
- type ToggleOps,
10
- type ToolRow,
11
- toggleTool,
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
- // temp dir may already be gone — safe to ignore
188
- }
189
- });
190
-
191
- function makeHost(toolNames: string[]) {
192
- const handlers: Record<string, Array<(p: unknown) => unknown>> = {};
193
- let active: string[] = [...toolNames];
194
- const commands: Array<{ name: string; handler: Function }> = [];
195
- const pi = {
196
- on(ev: string, fn: (p: unknown) => unknown) {
197
- (handlers[ev] ??= []).push(fn);
198
- },
199
- emit(ev: string, payload: unknown, ctx?: unknown) {
200
- return Promise.all(
201
- (handlers[ev] ?? []).map((f) => (f as Function)(payload, ctx)),
202
- );
203
- },
204
- getAllTools() {
205
- return toolNames.map((name) => ({
206
- name,
207
- description: `${name} does things.`,
208
- parameters: {},
209
- sourceInfo: { source: "builtin" },
210
- }));
211
- },
212
- getActiveTools() {
213
- return active;
214
- },
215
- setActiveTools(names: string[]) {
216
- active = [...names];
217
- },
218
- getCommands() {
219
- return [];
220
- },
221
- appendEntry() {},
222
- registerTool() {},
223
- registerCommand(name: string, def: { handler: Function }) {
224
- commands.push({ name, handler: def.handler });
225
- },
226
- } as never;
227
- const emit = (ev: string, payload: unknown, ctx?: unknown) =>
228
- Promise.all((handlers[ev] ?? []).map((f) => (f as Function)(payload, ctx)));
229
- return {
230
- pi,
231
- emit,
232
- getActive: () => active,
233
- command: (name: string) => commands.find((c) => c.name === name),
234
- };
235
- }
236
-
237
- function makeCtx() {
238
- const notes: Array<{ text: string; level?: string }> = [];
239
- const ctx = {
240
- ui: {
241
- notify(text: string, level?: string) {
242
- notes.push({ text, level });
243
- },
244
- },
245
- } as never;
246
- return { ctx, notes };
247
- }
248
-
249
- describe("/toolbox command", () => {
250
- const ALL = ["read", "write", "bash", "grep", "find"];
251
-
252
- async function boot() {
253
- const host = makeHost(ALL);
254
- registerToolbox(host.pi);
255
- // session_start triggers init
256
- await host.emit("session_start", {}, {});
257
- return host;
258
- }
259
-
260
- test("registers a /toolbox command", async () => {
261
- const host = await boot();
262
- expect(host.command("toolbox")).toBeDefined();
263
- });
264
-
265
- test("bare /toolbox falls back to listing when no custom UI", async () => {
266
- const host = await boot();
267
- const { ctx, notes } = makeCtx();
268
- await host.command("toolbox")?.handler("", ctx);
269
- expect(notes.length).toBe(1);
270
- // only non-core tools shown, all start active
271
- expect(notes[0].text).toContain("✓ active grep");
272
- expect(notes[0].text).toContain("✓ active find");
273
- // core tools excluded from toolbox
274
- expect(notes[0].text).not.toContain(" read");
275
- expect(notes[0].text).not.toContain(" bash");
276
- });
277
-
278
- test("/toolbox list shows non-core tools with status", async () => {
279
- const host = await boot();
280
- const { ctx, notes } = makeCtx();
281
- await host.command("toolbox")?.handler("list", ctx);
282
- expect(notes[0].text).toContain("grep");
283
- expect(notes[0].text).toContain("find");
284
- expect(notes[0].text).not.toContain(" bash");
285
- });
286
-
287
- test("/toolbox list <query> filters", async () => {
288
- const host = await boot();
289
- const { ctx, notes } = makeCtx();
290
- await host.command("toolbox")?.handler("list fin", ctx);
291
- expect(notes[0].text).toContain("find");
292
- expect(notes[0].text).not.toContain("✓ active read");
293
- });
294
-
295
- test("opens interactive picker when ctx.ui.custom exists", async () => {
296
- const host = await boot();
297
- let customCalled = 0;
298
- const notes: Array<{ text: string; level?: string }> = [];
299
- const ctx = {
300
- ui: {
301
- notify(text: string, level?: string) {
302
- notes.push({ text, level });
303
- },
304
- async custom() {
305
- customCalled++;
306
- return null;
307
- },
308
- },
309
- } as never;
310
- await host.command("toolbox")?.handler("", ctx);
311
- expect(customCalled).toBe(1);
312
- expect(notes.length).toBe(0);
313
- });
314
- });