@gonrocca/zero-pi 0.1.9 → 0.1.11

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,399 @@
1
+ // zero-pi - conversation resume on shutdown.
2
+ //
3
+ // Pi already persists the full session in ~/.pi/agent/sessions. This extension
4
+ // leaves a small project-local handoff note when the user quits pi, with the
5
+ // exact command needed to restore that session plus a concise conversation tail.
6
+ //
7
+ // It intentionally avoids model calls during shutdown: quitting should stay
8
+ // fast and reliable, even offline or without an API key.
9
+
10
+ import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ const RESUME_DIR = ".pi";
14
+ const RESUME_FILE = "zero-resume.md";
15
+ const DEFAULT_MAX_ITEMS = 12;
16
+ const DEFAULT_MAX_CHARS = 1200;
17
+
18
+ type NotifyType = "info" | "warning" | "error";
19
+
20
+ interface ContentBlock {
21
+ type?: string;
22
+ text?: string;
23
+ thinking?: string;
24
+ name?: string;
25
+ arguments?: unknown;
26
+ mimeType?: string;
27
+ }
28
+
29
+ interface MessageLike {
30
+ role?: string;
31
+ content?: unknown;
32
+ command?: string;
33
+ output?: string;
34
+ summary?: string;
35
+ timestamp?: number;
36
+ customType?: string;
37
+ }
38
+
39
+ export interface SessionEntryLike {
40
+ type?: string;
41
+ id?: string;
42
+ timestamp?: string;
43
+ message?: MessageLike;
44
+ summary?: string;
45
+ tokensBefore?: number;
46
+ }
47
+
48
+ export interface ResumeMetadata {
49
+ cwd?: string;
50
+ generatedAt?: Date;
51
+ maxChars?: number;
52
+ maxItems?: number;
53
+ reason?: string;
54
+ sessionFile?: string;
55
+ sessionId?: string;
56
+ }
57
+
58
+ export interface ResumeItem {
59
+ label: string;
60
+ text: string;
61
+ timestamp?: string;
62
+ }
63
+
64
+ interface PiUI {
65
+ notify?(message: string, type?: NotifyType): void;
66
+ }
67
+
68
+ interface PiSessionManager {
69
+ getBranch?(): SessionEntryLike[];
70
+ getSessionFile?(): string | undefined;
71
+ getSessionId?(): string | undefined;
72
+ }
73
+
74
+ interface PiContext {
75
+ cwd?: string;
76
+ hasUI?: boolean;
77
+ ui?: PiUI;
78
+ sessionManager?: PiSessionManager;
79
+ }
80
+
81
+ interface PiCommandContext extends PiContext {}
82
+
83
+ interface PiAPI {
84
+ on?(event: string, handler: (event: unknown, ctx: PiContext) => Promise<void> | void): void;
85
+ registerCommand?(
86
+ name: string,
87
+ options: {
88
+ description?: string;
89
+ handler: (args: string, ctx: PiCommandContext) => Promise<void> | void;
90
+ },
91
+ ): void;
92
+ }
93
+
94
+ /** Convert a shell argument into a single-quoted literal for PowerShell/sh. */
95
+ export function quoteShellArg(value: string): string {
96
+ return `'${value.replace(/'/g, "''")}'`;
97
+ }
98
+
99
+ /** The project-local resume path. */
100
+ export function resumePath(cwd: string): string {
101
+ return join(cwd, RESUME_DIR, RESUME_FILE);
102
+ }
103
+
104
+ /** Extract readable text from pi message content blocks. */
105
+ export function contentToText(content: unknown): string {
106
+ if (typeof content === "string") return content.trim();
107
+ if (!Array.isArray(content)) return "";
108
+
109
+ const parts: string[] = [];
110
+ for (const raw of content) {
111
+ if (!raw || typeof raw !== "object") continue;
112
+ const block = raw as ContentBlock;
113
+ if (block.type === "text" && typeof block.text === "string") {
114
+ parts.push(block.text);
115
+ continue;
116
+ }
117
+ if (block.type === "toolCall" && typeof block.name === "string") {
118
+ parts.push(`[tool call: ${block.name}]`);
119
+ continue;
120
+ }
121
+ if (block.type === "image") {
122
+ parts.push(`[image${block.mimeType ? `: ${block.mimeType}` : ""}]`);
123
+ }
124
+ }
125
+ return parts.join("\n").trim();
126
+ }
127
+
128
+ function collapseWhitespace(text: string): string {
129
+ return text.replace(/\r\n/g, "\n").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
130
+ }
131
+
132
+ function truncateText(text: string, maxChars: number): string {
133
+ const clean = collapseWhitespace(text);
134
+ if (clean.length <= maxChars) return clean;
135
+ return `${clean.slice(0, Math.max(0, maxChars - 15)).trimEnd()}\n...[truncated]`;
136
+ }
137
+
138
+ function timestampFromMessage(entry: SessionEntryLike): string | undefined {
139
+ if (entry.timestamp) return entry.timestamp;
140
+ const ts = entry.message?.timestamp;
141
+ return typeof ts === "number" ? new Date(ts).toISOString() : undefined;
142
+ }
143
+
144
+ function entryToResumeItem(entry: SessionEntryLike, maxChars: number): ResumeItem | null {
145
+ if (entry.type === "compaction" && typeof entry.summary === "string") {
146
+ return {
147
+ label: "Compaction",
148
+ text: truncateText(entry.summary, maxChars),
149
+ timestamp: entry.timestamp,
150
+ };
151
+ }
152
+
153
+ if (entry.type === "branch_summary" && typeof entry.summary === "string") {
154
+ return {
155
+ label: "Branch summary",
156
+ text: truncateText(entry.summary, maxChars),
157
+ timestamp: entry.timestamp,
158
+ };
159
+ }
160
+
161
+ if (entry.type !== "message" || !entry.message) return null;
162
+ const message = entry.message;
163
+ const role = message.role ?? "message";
164
+
165
+ if (role === "toolResult") return null;
166
+
167
+ if (role === "bashExecution") {
168
+ const command = typeof message.command === "string" ? message.command : "";
169
+ const output = typeof message.output === "string" ? message.output : "";
170
+ const text = [`$ ${command}`, output].filter(Boolean).join("\n");
171
+ return text
172
+ ? { label: "Shell", text: truncateText(text, maxChars), timestamp: timestampFromMessage(entry) }
173
+ : null;
174
+ }
175
+
176
+ if (role === "compactionSummary" && typeof message.summary === "string") {
177
+ return {
178
+ label: "Compaction",
179
+ text: truncateText(message.summary, maxChars),
180
+ timestamp: timestampFromMessage(entry),
181
+ };
182
+ }
183
+
184
+ const text = contentToText(message.content);
185
+ if (!text) return null;
186
+
187
+ const label =
188
+ role === "user"
189
+ ? "User"
190
+ : role === "assistant"
191
+ ? "Assistant"
192
+ : role === "custom"
193
+ ? `Custom${message.customType ? ` (${message.customType})` : ""}`
194
+ : role;
195
+
196
+ return { label, text: truncateText(text, maxChars), timestamp: timestampFromMessage(entry) };
197
+ }
198
+
199
+ /** Convert branch entries into the concise items shown in the resume note. */
200
+ export function conversationItems(
201
+ entries: SessionEntryLike[],
202
+ maxChars = DEFAULT_MAX_CHARS,
203
+ ): ResumeItem[] {
204
+ return entries
205
+ .map((entry) => entryToResumeItem(entry, maxChars))
206
+ .filter((item): item is ResumeItem => item !== null);
207
+ }
208
+
209
+ function restoreCommands(sessionFile?: string, sessionId?: string): string[] {
210
+ const commands: string[] = [];
211
+ if (sessionFile) commands.push(`pi --session ${quoteShellArg(sessionFile)}`);
212
+ if (sessionId) commands.push(`pi --session ${sessionId}`);
213
+ commands.push("pi --resume");
214
+ return commands;
215
+ }
216
+
217
+ /** Render the project-local markdown resume. */
218
+ export function buildConversationResume(
219
+ entries: SessionEntryLike[],
220
+ metadata: ResumeMetadata = {},
221
+ ): string {
222
+ const generatedAt = metadata.generatedAt ?? new Date();
223
+ const maxItems = metadata.maxItems ?? DEFAULT_MAX_ITEMS;
224
+ const maxChars = metadata.maxChars ?? DEFAULT_MAX_CHARS;
225
+ const items = conversationItems(entries, maxChars).slice(-maxItems);
226
+ const commands = restoreCommands(metadata.sessionFile, metadata.sessionId);
227
+
228
+ const lines: string[] = [
229
+ "# ZERO Pi Resume",
230
+ "",
231
+ "> Local handoff note. It may contain conversation context; keep `.pi/` out of commits.",
232
+ "",
233
+ "## Restore",
234
+ "",
235
+ "Exact command:",
236
+ "",
237
+ "```powershell",
238
+ commands[0],
239
+ "```",
240
+ "",
241
+ ];
242
+
243
+ if (metadata.sessionId) {
244
+ lines.push("Same session by id:", "", "```powershell", `pi --session ${metadata.sessionId}`, "```", "");
245
+ }
246
+
247
+ lines.push(
248
+ "Open the interactive picker:",
249
+ "",
250
+ "```powershell",
251
+ "pi --resume",
252
+ "```",
253
+ "",
254
+ "## Session",
255
+ "",
256
+ `- Updated: ${generatedAt.toISOString()}`,
257
+ );
258
+
259
+ if (metadata.reason) lines.push(`- Shutdown reason: ${metadata.reason}`);
260
+ if (metadata.cwd) lines.push(`- CWD: ${metadata.cwd}`);
261
+ if (metadata.sessionFile) lines.push(`- Session file: ${metadata.sessionFile}`);
262
+ if (metadata.sessionId) lines.push(`- Session id: ${metadata.sessionId}`);
263
+
264
+ lines.push("", "## Conversation Tail", "");
265
+
266
+ if (items.length === 0) {
267
+ lines.push("_No conversation messages were available yet._", "");
268
+ } else {
269
+ for (const item of items) {
270
+ const suffix = item.timestamp ? ` - ${item.timestamp}` : "";
271
+ lines.push(`### ${item.label}${suffix}`, "", item.text, "");
272
+ }
273
+ }
274
+
275
+ lines.push(
276
+ "## Continue Prompt",
277
+ "",
278
+ "Continue from this ZERO Pi resume. If you need the full context, run the restore command above first.",
279
+ "",
280
+ );
281
+
282
+ return `${lines.join("\n").replace(/\n{3,}/g, "\n\n")}\n`;
283
+ }
284
+
285
+ function ensureResumeDir(cwd: string): string {
286
+ const dir = join(cwd, RESUME_DIR);
287
+ mkdirSync(dir, { recursive: true });
288
+
289
+ const ignorePath = join(dir, ".gitignore");
290
+ if (!existsSync(ignorePath)) {
291
+ writeFileSync(ignorePath, "*\n!.gitignore\n", "utf8");
292
+ }
293
+
294
+ return dir;
295
+ }
296
+
297
+ /** Write the resume atomically and return the path written. */
298
+ export function writeConversationResume(
299
+ cwd: string,
300
+ entries: SessionEntryLike[],
301
+ metadata: ResumeMetadata = {},
302
+ ): string {
303
+ const dir = ensureResumeDir(cwd);
304
+ const target = join(dir, RESUME_FILE);
305
+ const tmp = join(dir, `${RESUME_FILE}.tmp`);
306
+ const text = buildConversationResume(entries, metadata);
307
+ writeFileSync(tmp, text, "utf8");
308
+ renameSync(tmp, target);
309
+ return target;
310
+ }
311
+
312
+ function notify(ctx: PiContext, message: string, type: NotifyType): void {
313
+ try {
314
+ ctx.ui?.notify?.(message, type);
315
+ } catch {
316
+ // UI notifications are best-effort only.
317
+ }
318
+ }
319
+
320
+ function readBranch(ctx: PiContext): SessionEntryLike[] {
321
+ try {
322
+ return ctx.sessionManager?.getBranch?.() ?? [];
323
+ } catch {
324
+ return [];
325
+ }
326
+ }
327
+
328
+ function sessionFile(ctx: PiContext): string | undefined {
329
+ try {
330
+ return ctx.sessionManager?.getSessionFile?.();
331
+ } catch {
332
+ return undefined;
333
+ }
334
+ }
335
+
336
+ function sessionId(ctx: PiContext): string | undefined {
337
+ try {
338
+ return ctx.sessionManager?.getSessionId?.();
339
+ } catch {
340
+ return undefined;
341
+ }
342
+ }
343
+
344
+ function writeFromContext(ctx: PiContext, reason: string): string | null {
345
+ const cwd = ctx.cwd;
346
+ if (!cwd) return null;
347
+
348
+ const branch = readBranch(ctx);
349
+ if (branch.length === 0 && !sessionFile(ctx) && !sessionId(ctx)) return null;
350
+
351
+ return writeConversationResume(cwd, branch, {
352
+ cwd,
353
+ reason,
354
+ sessionFile: sessionFile(ctx),
355
+ sessionId: sessionId(ctx),
356
+ });
357
+ }
358
+
359
+ export default function register(pi?: PiAPI): void {
360
+ if (!pi) return;
361
+
362
+ if (typeof pi.on === "function") {
363
+ pi.on("session_shutdown", async (event, ctx) => {
364
+ const reason = (event as { reason?: string } | undefined)?.reason ?? "unknown";
365
+ if (reason !== "quit") return;
366
+ if (process.env.ZERO_RESUME === "off" || process.env.ZERO_RESUME === "0") return;
367
+
368
+ try {
369
+ const path = writeFromContext(ctx, reason);
370
+ if (path) notify(ctx, `zero resume written: ${path}`, "info");
371
+ } catch (err) {
372
+ notify(ctx, `zero resume failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
373
+ }
374
+ });
375
+ }
376
+
377
+ if (typeof pi.registerCommand === "function") {
378
+ pi.registerCommand("zero-resume", {
379
+ description: "Write .pi/zero-resume.md with restore command and conversation tail",
380
+ handler: async (_args, ctx) => {
381
+ try {
382
+ const path = writeFromContext(ctx, "manual");
383
+ if (!path) {
384
+ notify(ctx, "zero-resume: no persisted session context found", "warning");
385
+ return;
386
+ }
387
+ const command = sessionFile(ctx)
388
+ ? `pi --session ${quoteShellArg(sessionFile(ctx)!)}`
389
+ : sessionId(ctx)
390
+ ? `pi --session ${sessionId(ctx)}`
391
+ : "pi --resume";
392
+ notify(ctx, `zero-resume: wrote ${path}\nrestore: ${command}`, "info");
393
+ } catch (err) {
394
+ notify(ctx, `zero-resume: ${err instanceof Error ? err.message : String(err)}`, "error");
395
+ }
396
+ },
397
+ });
398
+ }
399
+ }
@@ -0,0 +1,195 @@
1
+ // zero-pi — metered-provider guard, pi wiring.
2
+ //
3
+ // A thin pi extension that wires the pure decision logic in `provider-guard.ts`
4
+ // to the `model_select` hook. Whenever pi reports a model switch, this handler
5
+ // classifies it (in the pure module) and executes the resulting `GuardAction`:
6
+ // a silent no-op for subscription providers, a non-blocking `warning` for a
7
+ // metered provider with no equivalent (or a session `restore`), or — for a
8
+ // deliberate switch to a metered provider that has an equivalent — a `confirm`
9
+ // dialog offering to redirect to the subscription provider.
10
+ //
11
+ // All decisions live in `provider-guard.ts`; this file only declares the
12
+ // minimal pi-API surface it uses, hooks `model_select`, and performs the I/O
13
+ // (`confirm`/`notify`/`setModel`). Both `register` and the handler are wrapped
14
+ // in a swallowing `try/catch` — exactly like `autotune-extension.ts`, a failure
15
+ // must never break a pi session. The package stays dependency-free: only
16
+ // `provider-guard.ts` is imported (no `node:*` is needed here, no third party).
17
+
18
+ import {
19
+ classifyModelSwitch,
20
+ redirectFailedMessage,
21
+ type ModelLike,
22
+ } from "./provider-guard.ts";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Minimal local pi-API interfaces
26
+ // ---------------------------------------------------------------------------
27
+ //
28
+ // `provider-guard-extension.ts` declares only the slice of pi's API it uses,
29
+ // exactly like `autotune-extension.ts` and `zero-models.ts`. The guard never
30
+ // depends on pi's full type surface — just the shapes below.
31
+
32
+ /** The minimum a `Model` of pi must expose for the guard. Mirrors `ModelLike`
33
+ * of the pure module — `setModel`/the registry both speak this shape. */
34
+ interface PiModel {
35
+ provider: string;
36
+ id: string;
37
+ }
38
+
39
+ /** The model registry slice the guard uses: a `find` that resolves a model by
40
+ * `(provider, id)` or returns `undefined` when no such model exists. */
41
+ interface PiModelRegistry {
42
+ find(provider: string, modelId: string): PiModel | undefined;
43
+ }
44
+
45
+ /** The slice of pi's UI surface the guard uses. `confirm` may be sync or async
46
+ * (pi versions differ); both are handled uniformly with `await`. */
47
+ interface PiUI {
48
+ confirm(title: string, message: string): Promise<boolean> | boolean;
49
+ notify(message: string, type?: "info" | "warning" | "error"): void;
50
+ }
51
+
52
+ /** The context pi passes to a `model_select` handler. `modelRegistry` is
53
+ * optional — if absent the guard degrades to warn-only (it can no longer
54
+ * resolve equivalents) rather than breaking. */
55
+ interface PiModelSelectContext {
56
+ ui: PiUI;
57
+ modelRegistry?: PiModelRegistry;
58
+ }
59
+
60
+ /** The `model_select` event pi emits on a model change. The guard only reads
61
+ * `model` and `source`; `source` is `set`/`cycle`/`restore` or, defensively,
62
+ * any other string a future pi version might report. */
63
+ interface PiModelSelectEvent {
64
+ type: "model_select";
65
+ model?: PiModel;
66
+ source?: "set" | "cycle" | "restore" | string;
67
+ }
68
+
69
+ /** The slice of pi's extension API the guard uses: `on` to hook events and
70
+ * `setModel` to apply a redirection. */
71
+ interface PiExtensionAPI {
72
+ on(
73
+ event: string,
74
+ handler: (event: PiModelSelectEvent, ctx: PiModelSelectContext) => void | Promise<void>,
75
+ ): void;
76
+ setModel(model: PiModel): Promise<boolean>;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Handler
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * The `model_select` guard handler.
85
+ *
86
+ * Runs entirely inside `register`'s swallowing `try/catch`, but is also wrapped
87
+ * again here so no failure of `find`/`confirm`/`notify`/`setModel` can ever
88
+ * propagate out of a pi session. The flow:
89
+ *
90
+ * 1. Validate `event`/`event.model` — a malformed event returns cleanly.
91
+ * 2. Build the injected `lookup` over `ctx.modelRegistry.find`, wrapped in a
92
+ * `try/catch` that returns `undefined`. If `modelRegistry` is absent,
93
+ * degrade to an always-`undefined` lookup (warn-only).
94
+ * 3. `classifyModelSwitch(model, source, lookup)` → a `GuardAction`.
95
+ * 4. Execute the action: `ignore` → no-op; `warn` → `notify(..., "warning")`;
96
+ * `offer-redirect` → `confirm`, and on a truthy answer `setModel` +
97
+ * `notify(..., "info")` (or the failure notice if `setModel` returns
98
+ * `false`). A falsy `confirm` leaves the user on the metered provider.
99
+ */
100
+ async function handleModelSelect(
101
+ event: PiModelSelectEvent,
102
+ ctx: PiModelSelectContext,
103
+ pi: PiExtensionAPI,
104
+ ): Promise<void> {
105
+ try {
106
+ // 1. Malformed event — no event, no model, or no UI to talk to: bail out.
107
+ if (!event || !event.model || !ctx || !ctx.ui || typeof ctx.ui.notify !== "function") {
108
+ return;
109
+ }
110
+
111
+ // 2. Build the injected lookup. `classifyModelSwitch` assumes a total
112
+ // lookup, so `find` is wrapped in a `try/catch` returning `undefined`.
113
+ // If `modelRegistry` is absent the guard cannot resolve equivalents —
114
+ // degrade to an always-`undefined` lookup so a metered provider still
115
+ // produces a warning rather than breaking.
116
+ const registry = ctx.modelRegistry;
117
+ const lookup = (provider: string, id: string): ModelLike | undefined => {
118
+ if (!registry || typeof registry.find !== "function") return undefined;
119
+ try {
120
+ return registry.find(provider, id);
121
+ } catch {
122
+ // A registry lookup failure degrades to "no equivalent", never throws.
123
+ return undefined;
124
+ }
125
+ };
126
+
127
+ // 3. Classify the switch in the pure module.
128
+ const action = classifyModelSwitch(event.model, event.source, lookup);
129
+
130
+ // 4. Execute the action.
131
+ if (action.kind === "ignore") {
132
+ // Non-metered provider or malformed event — nothing to do.
133
+ return;
134
+ }
135
+
136
+ if (action.kind === "warn") {
137
+ // Metered provider with no equivalent, or a session `restore` — a single
138
+ // non-blocking warning, no modal, no `setModel`.
139
+ ctx.ui.notify(action.message, "warning");
140
+ return;
141
+ }
142
+
143
+ // action.kind === "offer-redirect" — deliberate switch to a metered
144
+ // provider that has a subscription equivalent. Offer the redirect; the
145
+ // `confirm` dialog itself is the user's escape (AC 1.5).
146
+ const accepted = await ctx.ui.confirm(action.confirmTitle, action.confirmMessage);
147
+ if (!accepted) {
148
+ // User declined or cancelled — leave them on the metered provider, no
149
+ // second warning, no `setModel`.
150
+ return;
151
+ }
152
+
153
+ const applied = await pi.setModel(action.safeModel);
154
+ if (applied) {
155
+ // Redirection took effect — confirm it with an `info` notification.
156
+ ctx.ui.notify(action.redirectMessage, "info");
157
+ } else {
158
+ // `setModel` returned `false`: the switch did not apply. Do not claim
159
+ // "redirected" — emit the optional failure notice instead.
160
+ ctx.ui.notify(
161
+ redirectFailedMessage(action.safeModel.id, action.safeModel.provider),
162
+ "warning",
163
+ );
164
+ }
165
+ } catch {
166
+ // Any failure of the guard must never break a pi session.
167
+ }
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Registration
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * The pi extension entry point.
176
+ *
177
+ * pi calls this once when the extension loads. It wires the guard handler to
178
+ * the `model_select` event. The whole body is wrapped in a swallowing
179
+ * `try/catch`, and the handler is wrapped again, so neither registration nor a
180
+ * later failure can ever break a pi session. Called with no or an invalid
181
+ * `pi` (missing, or no `.on` function), it no-ops cleanly.
182
+ */
183
+ export default function register(pi?: unknown): void {
184
+ try {
185
+ if (!pi || typeof (pi as PiExtensionAPI).on !== "function") {
186
+ return;
187
+ }
188
+ const api = pi as PiExtensionAPI;
189
+ api.on("model_select", (event: PiModelSelectEvent, ctx: PiModelSelectContext): Promise<void> => {
190
+ return handleModelSelect(event, ctx, api);
191
+ });
192
+ } catch {
193
+ // Registration itself must never break a pi session.
194
+ }
195
+ }