@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,271 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ // ─── Pure logic (exported for tests) ─────────────────────────────────────────
6
+
7
+ export const PACKAGE_NAME = "@earendil-works/pi-coding-agent";
8
+
9
+ export const TRANSIENT_PATTERNS = [
10
+ /eai_again/i,
11
+ /etimedout/i,
12
+ /econnreset/i,
13
+ /econnrefused/i,
14
+ /socket hang up/i,
15
+ /network/i,
16
+ /timeout/i,
17
+ /temporar/i,
18
+ /too many requests/i,
19
+ /\b429\b/,
20
+ /\b502\b/,
21
+ /\b503\b/,
22
+ /\b504\b/,
23
+ ];
24
+
25
+ export type InstallMethod = "vp" | "bun" | "npm" | "brew" | "native";
26
+
27
+ export type CommandSpec = {
28
+ command: string;
29
+ args: string[];
30
+ label: string;
31
+ };
32
+
33
+ export function isTransient(output: string): boolean {
34
+ return TRANSIENT_PATTERNS.some((pattern) => pattern.test(output));
35
+ }
36
+
37
+ export function commandFor(method: InstallMethod): CommandSpec | undefined {
38
+ switch (method) {
39
+ case "vp":
40
+ return {
41
+ command: "vp",
42
+ args: ["add", "-g", `${PACKAGE_NAME}@latest`],
43
+ label: `vp add -g ${PACKAGE_NAME}@latest`,
44
+ };
45
+ case "bun":
46
+ return {
47
+ command: "bun",
48
+ args: ["add", "-g", `${PACKAGE_NAME}@latest`],
49
+ label: `bun add -g ${PACKAGE_NAME}@latest`,
50
+ };
51
+ case "npm":
52
+ return {
53
+ command: "npm",
54
+ args: ["install", "-g", `${PACKAGE_NAME}@latest`],
55
+ label: `npm install -g ${PACKAGE_NAME}@latest`,
56
+ };
57
+ case "brew":
58
+ return {
59
+ command: "/bin/sh",
60
+ args: ["-lc", "brew upgrade pi-coding-agent || brew upgrade pi"],
61
+ label: "brew upgrade pi-coding-agent || brew upgrade pi",
62
+ };
63
+ case "native":
64
+ return undefined;
65
+ }
66
+ }
67
+
68
+ export function formatUpdateSummary(
69
+ before: string,
70
+ after: string,
71
+ attempts: number,
72
+ ): string {
73
+ const changed =
74
+ before !== after && before !== "unknown" && after !== "unknown";
75
+ const summary = changed
76
+ ? `Pi updated: ${before} → ${after}`
77
+ : `Pi is up to date (${after}).`;
78
+ return attempts > 1
79
+ ? `${summary} Retried ${attempts - 1} transient failure(s).`
80
+ : summary;
81
+ }
82
+
83
+ async function resolveCommand(command: string, pi: ExtensionAPI) {
84
+ const result = await pi.exec(
85
+ "/bin/sh",
86
+ ["-lc", `command -v ${command} || true`],
87
+ { timeout: 10_000 },
88
+ );
89
+ return result.stdout.trim().split("\n")[0] || undefined;
90
+ }
91
+
92
+ async function currentVersion(pi: ExtensionAPI) {
93
+ const result = await pi.exec("pi", ["--version"], { timeout: 10_000 });
94
+ return result.stdout.trim() || result.stderr.trim() || "unknown";
95
+ }
96
+
97
+ async function detectInstallMethod(pi: ExtensionAPI): Promise<InstallMethod> {
98
+ const piPath = await resolveCommand("pi", pi);
99
+ const realPiPath = piPath
100
+ ? (
101
+ await pi.exec(
102
+ "/bin/sh",
103
+ ["-lc", `realpath ${piPath} 2>/dev/null || printf %s ${piPath}`],
104
+ { timeout: 10_000 },
105
+ )
106
+ ).stdout.trim()
107
+ : undefined;
108
+
109
+ if (piPath?.includes("/.vite-plus/") || realPiPath?.includes("/.vite-plus/"))
110
+ return "vp";
111
+ if (piPath?.includes("/.bun/") || realPiPath?.includes("/.bun/"))
112
+ return "bun";
113
+ if (
114
+ piPath?.includes("/Homebrew/") ||
115
+ piPath?.includes("/homebrew/") ||
116
+ realPiPath?.includes("/Homebrew/") ||
117
+ realPiPath?.includes("/homebrew/")
118
+ )
119
+ return "brew";
120
+
121
+ if (piPath) {
122
+ const hasGlobalNpm = await pi.exec(
123
+ "/bin/sh",
124
+ [
125
+ "-lc",
126
+ `p=${piPath}; i=0; while [ $i -lt 5 ]; do d=$(dirname "$p"); [ -d "$d/node_modules/${PACKAGE_NAME}" ] && exit 0; p=$d; i=$((i+1)); done; exit 1`,
127
+ ],
128
+ { timeout: 10_000 },
129
+ );
130
+ if ((hasGlobalNpm.exitCode ?? 1) === 0) return "npm";
131
+ }
132
+
133
+ if (await resolveCommand("vp", pi)) return "vp";
134
+ if (await resolveCommand("bun", pi)) return "bun";
135
+ if (await resolveCommand("npm", pi)) return "npm";
136
+ if (await resolveCommand("brew", pi)) return "brew";
137
+ return "native";
138
+ }
139
+
140
+ async function runWithRetry(pi: ExtensionAPI, spec: CommandSpec) {
141
+ let lastOutput = "";
142
+ for (let attempt = 1; attempt <= 3; attempt++) {
143
+ const result = await pi.exec(spec.command, spec.args, { timeout: 180_000 });
144
+ lastOutput = [result.stdout, result.stderr]
145
+ .filter(Boolean)
146
+ .join("\n")
147
+ .trim();
148
+ if ((result.exitCode ?? 0) === 0)
149
+ return { ok: true, output: lastOutput, attempts: attempt };
150
+ if (attempt === 3 || !isTransient(lastOutput))
151
+ return { ok: false, output: lastOutput, attempts: attempt };
152
+ await new Promise((resolve) => setTimeout(resolve, attempt * 1500));
153
+ }
154
+ return { ok: false, output: lastOutput, attempts: 3 };
155
+ }
156
+
157
+ async function updatePi(pi: ExtensionAPI, ctx: ExtensionCommandContext) {
158
+ await (
159
+ ctx as ExtensionCommandContext & { waitForIdle?: () => Promise<void> }
160
+ ).waitForIdle?.();
161
+
162
+ const before = await currentVersion(pi).catch(() => "unknown");
163
+ const method = await detectInstallMethod(pi);
164
+ const spec = commandFor(method);
165
+
166
+ if (!spec) {
167
+ ctx.ui.notify(
168
+ `Pi ${before}; install method appears native. Please update the native binary manually.`,
169
+ "warning",
170
+ );
171
+ return;
172
+ }
173
+
174
+ ctx.ui.notify(`Updating Pi via ${method}: ${spec.label}`, "info");
175
+ const result = await runWithRetry(pi, spec);
176
+ const after = await currentVersion(pi).catch(() => "unknown");
177
+
178
+ if (!result.ok) {
179
+ ctx.ui.notify(
180
+ `Pi update failed after ${result.attempts} attempt(s). ${result.output || "No output."}`,
181
+ "error",
182
+ );
183
+ return;
184
+ }
185
+
186
+ ctx.ui.notify(formatUpdateSummary(before, after, result.attempts), "info");
187
+ }
188
+
189
+ async function updateExtensions(
190
+ pi: ExtensionAPI,
191
+ ctx: ExtensionCommandContext,
192
+ ) {
193
+ ctx.ui.notify("Updating Pi extensions from dotfiles setup", "info");
194
+ const result = await pi.exec(
195
+ "/bin/sh",
196
+ ["-lc", '"$HOME/dotfiles/ai_config/pi/setup.sh"'],
197
+ { timeout: 240_000 },
198
+ );
199
+ const output = [result.stdout, result.stderr]
200
+ .filter(Boolean)
201
+ .join("\n")
202
+ .trim();
203
+ if ((result.exitCode ?? 0) !== 0) {
204
+ ctx.ui.notify(
205
+ `Pi extensions update failed. ${output || "No output."}`,
206
+ "error",
207
+ );
208
+ return;
209
+ }
210
+ ctx.ui.notify(
211
+ "Pi extensions updated. Please run /reload to apply changes.",
212
+ "warning",
213
+ );
214
+ }
215
+
216
+ async function updatePackages(pi: ExtensionAPI, ctx: ExtensionCommandContext) {
217
+ ctx.ui.notify("Updating pi packages (pi update --extensions)", "info");
218
+ const result = await pi.exec("pi", ["update", "--extensions"], {
219
+ timeout: 240_000,
220
+ });
221
+ const output = [result.stdout, result.stderr]
222
+ .filter(Boolean)
223
+ .join("\n")
224
+ .trim();
225
+ if ((result.exitCode ?? 0) !== 0) {
226
+ ctx.ui.notify(
227
+ `Pi package update failed. ${output || "No output."}`,
228
+ "error",
229
+ );
230
+ return;
231
+ }
232
+ ctx.ui.notify(
233
+ "Pi packages updated. Please run /reload to apply changes.",
234
+ "warning",
235
+ );
236
+ }
237
+
238
+ async function updateAll(pi: ExtensionAPI, ctx: ExtensionCommandContext) {
239
+ await updatePi(pi, ctx);
240
+ await updateExtensions(pi, ctx);
241
+ await updatePackages(pi, ctx);
242
+ }
243
+
244
+ export default function (pi: ExtensionAPI) {
245
+ (
246
+ pi as ExtensionAPI & {
247
+ registerFlag: (name: string, opts: unknown) => void;
248
+ }
249
+ ).registerFlag("update", {
250
+ description: "Update Pi, dotfiles extensions, and pi packages",
251
+ type: "boolean",
252
+ default: false,
253
+ });
254
+
255
+ pi.registerCommand("update", {
256
+ description: "Update Pi, dotfiles extensions, and pi packages",
257
+ handler: async (_args, ctx) => {
258
+ await updateAll(pi, ctx);
259
+ },
260
+ });
261
+
262
+ pi.on("session_start", async (_event, ctx) => {
263
+ const flags = pi as ExtensionAPI & {
264
+ getFlag?: (name: string) => boolean;
265
+ sendUserMessage?: (message: string, opts?: unknown) => void;
266
+ };
267
+ if (!flags.getFlag?.("update")) return;
268
+ flags.sendUserMessage?.("/update", { deliverAs: "followUp" });
269
+ ctx.ui.notify("Queued /update from --update", "info");
270
+ });
271
+ }
@@ -0,0 +1,29 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ const YEET_PROMPT = `Commit the current repository changes.
4
+
5
+ Steps:
6
+ 1. Add all unstaged changes with \`git add -A\`.
7
+ 2. Inspect the staged changes and write a concise commit message that accurately summarizes them.
8
+ 3. Commit the changes with that message.
9
+ - If there are no staged changes, output "Nothing to commit" and stop.
10
+
11
+ Keep the commit message concise. Do not push.`;
12
+
13
+ export default function (pi: ExtensionAPI) {
14
+ pi.registerCommand("yeet", {
15
+ description: "Add and commit current repo changes (no push)",
16
+ handler: async (args, ctx) => {
17
+ const prompt = args?.trim()
18
+ ? `${YEET_PROMPT}\n\nAdditional instructions from the user:\n${args.trim()}`
19
+ : YEET_PROMPT;
20
+
21
+ if (ctx.isIdle()) {
22
+ pi.sendUserMessage(prompt);
23
+ } else {
24
+ pi.sendUserMessage(prompt, { deliverAs: "followUp" });
25
+ ctx.ui.notify("Queued /yeet as a follow-up", "info");
26
+ }
27
+ },
28
+ });
29
+ }
package/src/index.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * pix-core — Pi extension bundle
3
+ *
4
+ * Layout (grouped by concern):
5
+ * - ui/ — welcome (π banner + health checks), footer (status bar)
6
+ * - commands/ — models (/models picker), update (/update self-update),
7
+ * lg (/lg), yeet (/yeet), copy-all (/copy-all), diff (/diff)
8
+ * - tool/ — todo (durable execution checklist),
9
+ * toolbox (/toolbox command — user toggles tools on/off),
10
+ * lazy (lazy tool exposure — gates schemas out of the prompt)
11
+ * - nudge/ — model-steering reminders (tools / capability+skills)
12
+ * - lib/ — shared data layer (models.dev + BenchLM)
13
+ *
14
+ * Depends on pix-data (github.com/xynogen/pix-data) for shared
15
+ * models.dev + BenchLM cache at ~/.cache/pi/.
16
+ */
17
+
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import registerWelcome from "./ui/welcome.ts";
20
+ import registerFooter from "./ui/footer.ts";
21
+ import registerDiagnostics from "./ui/diagnostics.ts";
22
+ import registerModels from "./commands/models/models.ts";
23
+ import registerUpdate from "./commands/update/update.ts";
24
+ import registerLg from "./commands/lg/lg.ts";
25
+ import registerYeet from "./commands/yeet/yeet.ts";
26
+ import registerCopyAll from "./commands/copy-all/copy-all.ts";
27
+ import registerDiff from "./commands/diff/diff.ts";
28
+ import registerClear from "./commands/clear/clear.ts";
29
+ import registerTodo from "./tool/todo/todo.ts";
30
+ import registerAsk from "./tool/ask/ask.ts";
31
+ import registerToolbox from "./tool/toolbox/toolbox.ts";
32
+ import registerNudges from "./nudge/index.ts";
33
+
34
+ export default function (pi: ExtensionAPI): void {
35
+ registerWelcome(pi);
36
+ registerFooter(pi);
37
+ registerDiagnostics(pi);
38
+ registerModels(pi);
39
+ registerUpdate(pi);
40
+ registerLg(pi);
41
+ registerYeet(pi);
42
+ registerCopyAll(pi);
43
+ registerDiff(pi);
44
+ registerClear(pi);
45
+ registerTodo(pi);
46
+ registerAsk(pi);
47
+ registerToolbox(pi);
48
+ registerNudges(pi);
49
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * data.ts — model data layer (shim)
3
+ *
4
+ * Re-exports shared data from pix-data (github.com/xynogen/pix-data).
5
+ * Cache lives at ~/.cache/pi/ and is shared across all Pi extensions.
6
+ *
7
+ * Consumers in this extension dir:
8
+ * footer.ts — lookupModelsDev, lookupBenchmark, ModelsDevModel
9
+ * models.ts — lookupModelsDev, lookupBenchmark
10
+ */
11
+
12
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { dirname, join } from "node:path";
16
+
17
+ // ── Types ─────────────────────────────────────────────────────────────────────
18
+
19
+ export interface ModelsDevModel {
20
+ id: string;
21
+ name?: string;
22
+ reasoning?: boolean;
23
+ modalities?: { input?: string[]; output?: string[] };
24
+ limit?: { context?: number; output?: number };
25
+ cost?: {
26
+ input?: number;
27
+ output?: number;
28
+ cache_read?: number;
29
+ cache_write?: number;
30
+ };
31
+ }
32
+
33
+ export type ModelsDevApi = Record<
34
+ string,
35
+ { models?: Record<string, ModelsDevModel> }
36
+ >;
37
+
38
+ export interface BenchmarkEntry {
39
+ rank: number;
40
+ model: string;
41
+ creator: string;
42
+ sourceType?: string;
43
+ overallScore: number | null;
44
+ categoryScores?: Record<string, number | null>;
45
+ inputPrice: number | null;
46
+ outputPrice: number | null;
47
+ }
48
+
49
+ interface BenchmarkResponse {
50
+ lastUpdated?: string;
51
+ mode?: string;
52
+ models: BenchmarkEntry[];
53
+ }
54
+
55
+ // ── DataSource ─────────────────────────────────────────────────────────────────
56
+
57
+ interface DataSourceOptions<T> {
58
+ url: string | (() => string);
59
+ headers?: () => Record<string, string> | undefined;
60
+ cachePath: string;
61
+ ttlMs?: number;
62
+ timeoutMs?: number;
63
+ parse: (raw: unknown) => T;
64
+ parseCache: (data: unknown) => T;
65
+ empty: T;
66
+ label: string;
67
+ skip?: () => boolean;
68
+ }
69
+
70
+ class DataSource<T> {
71
+ private _mem: T | null = null;
72
+ private _inflight: Promise<T> | null = null;
73
+ private readonly opts: Required<DataSourceOptions<T>>;
74
+
75
+ constructor(opts: DataSourceOptions<T>) {
76
+ this.opts = {
77
+ ttlMs: 24 * 60 * 60 * 1000,
78
+ timeoutMs: 10_000,
79
+ headers: () => undefined,
80
+ skip: () => false,
81
+ ...opts,
82
+ };
83
+ }
84
+
85
+ async get(): Promise<T> {
86
+ if (this._inflight) return this._inflight;
87
+ this._inflight = this._load().finally(() => {
88
+ this._inflight = null;
89
+ });
90
+ return this._inflight;
91
+ }
92
+
93
+ getCached(): T {
94
+ if (this._mem) return this._mem;
95
+ try {
96
+ if (existsSync(this.opts.cachePath)) {
97
+ const raw = JSON.parse(readFileSync(this.opts.cachePath, "utf-8")) as {
98
+ data: unknown;
99
+ };
100
+ this._mem = this.opts.parseCache(raw.data);
101
+ return this._mem;
102
+ }
103
+ } catch {
104
+ // No cache file or parse error — return empty
105
+ }
106
+ return this.opts.empty;
107
+ }
108
+
109
+ private async _load(): Promise<T> {
110
+ if (this.opts.skip()) {
111
+ this._mem = this.opts.empty;
112
+ return this.opts.empty;
113
+ }
114
+ const cached = await this._readCache();
115
+ if (cached !== undefined && Date.now() - cached.ts < this.opts.ttlMs) {
116
+ const val = this.opts.parseCache(cached.data);
117
+ this._mem = val;
118
+ return val;
119
+ }
120
+ try {
121
+ const url =
122
+ typeof this.opts.url === "function" ? this.opts.url() : this.opts.url;
123
+ const controller = new AbortController();
124
+ const timer = setTimeout(() => controller.abort(), this.opts.timeoutMs);
125
+ const response = await fetch(url, {
126
+ signal: controller.signal,
127
+ headers: this.opts.headers(),
128
+ }).finally(() => clearTimeout(timer));
129
+ if (!response.ok)
130
+ throw new Error(`${this.opts.label} fetch failed: ${response.status}`);
131
+ const raw = await response.json();
132
+ const val = this.opts.parse(raw);
133
+ this._mem = val;
134
+ void this._writeCache(raw);
135
+ return val;
136
+ } catch (error) {
137
+ const msg = error instanceof Error ? error.message : String(error);
138
+ if (cached !== undefined) {
139
+ console.warn(
140
+ `${this.opts.label} fetch failed, using stale cache: ${msg}`,
141
+ );
142
+ const val = this.opts.parseCache(cached.data);
143
+ this._mem = val;
144
+ return val;
145
+ }
146
+ console.warn(`${this.opts.label} unavailable: ${msg}`);
147
+ return this.opts.empty;
148
+ }
149
+ }
150
+
151
+ private async _readCache(): Promise<
152
+ { ts: number; data: unknown } | undefined
153
+ > {
154
+ try {
155
+ const raw = await readFile(this.opts.cachePath, "utf8");
156
+ const parsed = JSON.parse(raw) as { ts: number; data: unknown };
157
+ if (typeof parsed.ts !== "number") return undefined;
158
+ return parsed;
159
+ } catch {
160
+ return undefined;
161
+ }
162
+ }
163
+
164
+ private async _writeCache(data: unknown): Promise<void> {
165
+ try {
166
+ await mkdir(dirname(this.opts.cachePath), { recursive: true });
167
+ await writeFile(
168
+ this.opts.cachePath,
169
+ JSON.stringify({ ts: Date.now(), data }),
170
+ );
171
+ } catch {
172
+ // Write failure is non-fatal
173
+ }
174
+ }
175
+ }
176
+
177
+ // ── Cache dir ─────────────────────────────────────────────────────────────────
178
+
179
+ const CACHE_DIR = join(
180
+ process.env.XDG_CACHE_HOME || join(homedir(), ".cache"),
181
+ "pi",
182
+ );
183
+
184
+ // ── Data sources (shared cache with pix-data) ─────────────────────────────────
185
+
186
+ const modelsDev = new DataSource<ModelsDevApi>({
187
+ label: "models.dev",
188
+ url: "https://models.dev/api.json",
189
+ cachePath: join(CACHE_DIR, "models.json"),
190
+ parse: (raw) => raw as ModelsDevApi,
191
+ parseCache: (data) => (data as ModelsDevApi) ?? {},
192
+ empty: {},
193
+ });
194
+
195
+ const benchmark = new DataSource<BenchmarkEntry[]>({
196
+ label: "benchlm",
197
+ url: "https://benchlm.ai/api/data/leaderboard",
198
+ cachePath: join(CACHE_DIR, "benchlm.json"),
199
+ parse: (raw) => (raw as BenchmarkResponse).models ?? [],
200
+ parseCache: (data) => (data as BenchmarkResponse)?.models ?? [],
201
+ empty: [],
202
+ });
203
+
204
+ // ── Lookup helpers ─────────────────────────────────────────────────────────────
205
+
206
+ export function lookupModelsDev(
207
+ provider: string,
208
+ id: string,
209
+ ): ModelsDevModel | undefined {
210
+ const data = modelsDev.getCached();
211
+ const canonical = id.includes("/") ? id.slice(id.lastIndexOf("/") + 1) : id;
212
+ const exact = data[provider]?.models?.[canonical];
213
+ if (exact) return exact;
214
+ for (const p of Object.keys(data)) {
215
+ const hit = data[p]?.models?.[canonical];
216
+ if (hit) return hit;
217
+ }
218
+ return undefined;
219
+ }
220
+
221
+ function normBench(s: string): string {
222
+ return s
223
+ .toLowerCase()
224
+ .replace(/[-_.]+/g, " ")
225
+ .replace(/\s+/g, " ")
226
+ .trim();
227
+ }
228
+
229
+ export function lookupBenchmark(modelName: string): BenchmarkEntry | undefined {
230
+ const entries = benchmark.getCached();
231
+ const needle = normBench(modelName);
232
+ return (
233
+ entries.find((e) => normBench(e.model) === needle) ??
234
+ entries.find((e) => normBench(e.model).includes(needle)) ??
235
+ entries.find((e) => needle.includes(normBench(e.model)))
236
+ );
237
+ }
238
+
239
+ export default function (_pi: unknown): void {
240
+ // pix-data warms this cache on startup — nothing to do here
241
+ }