@ssweens/pi-handoff 1.1.1 → 1.3.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.
@@ -13,21 +13,23 @@
13
13
  * User presses Enter to send it.
14
14
  */
15
15
 
16
+ import { existsSync, readFileSync } from "node:fs";
16
17
  import { complete, type Message } from "@mariozechner/pi-ai";
17
18
  import type {
18
19
  ExtensionAPI,
19
20
  ExtensionCommandContext,
20
21
  ExtensionContext,
21
22
  SessionEntry,
23
+ SessionHeader,
22
24
  } from "@mariozechner/pi-coding-agent";
23
- import { BorderedLoader, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
25
+ import { BorderedLoader, buildSessionContext, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
24
26
  import { Type } from "@sinclair/typebox";
25
27
 
26
28
  // ---------------------------------------------------------------------------
27
29
  // System prompts
28
30
  // ---------------------------------------------------------------------------
29
31
 
30
- const SYSTEM_PROMPT = `You are a context transfer assistant. Read the conversation and produce a structured handoff summary for the stated goal. The new thread must be able to proceed without the old conversation.
32
+ export const SYSTEM_PROMPT = `You are a context transfer assistant. Read the conversation and produce a structured handoff summary for the stated goal. The new thread must be able to proceed without the old conversation.
31
33
 
32
34
  Do NOT continue the conversation. Do NOT respond to any questions in the history. ONLY output the structured summary.
33
35
 
@@ -112,8 +114,25 @@ function extractFileOpsFromMessage(message: any, fileOps: FileOps): void {
112
114
  }
113
115
  }
114
116
 
115
- /** Compute read-only and modified file lists, append to summary as XML tags. */
116
- function appendFileOperations(summary: string, messages: any[]): string {
117
+ // ---------------------------------------------------------------------------
118
+ // Collapsed file markers
119
+ // ---------------------------------------------------------------------------
120
+ // File lists are shown as compact markers in the editor (e.g. "[📂 12 read files]")
121
+ // and expanded to full XML tags when the user submits via the input event hook.
122
+
123
+ /** Pending file lists keyed by marker text → expanded XML content. */
124
+ type FileMarkerStore = Map<string, string>;
125
+
126
+ function createReadMarker(count: number): string {
127
+ return `[+${count} read filename${count === 1 ? "" : "s"}]`;
128
+ }
129
+
130
+ function createModifiedMarker(count: number): string {
131
+ return `[+${count} modified filename${count === 1 ? "" : "s"}]`;
132
+ }
133
+
134
+ /** Build collapsed markers + expansion map from tool-call messages. */
135
+ function buildFileOperations(messages: any[]): { markers: string; expansions: FileMarkerStore } | null {
117
136
  const fileOps = createFileOps();
118
137
  for (const msg of messages) extractFileOpsFromMessage(msg, fileOps);
119
138
 
@@ -121,31 +140,50 @@ function appendFileOperations(summary: string, messages: any[]): string {
121
140
  const readFiles = [...fileOps.read].filter((f) => !modified.has(f)).sort();
122
141
  const modifiedFiles = [...modified].sort();
123
142
 
124
- const sections: string[] = [];
143
+ if (readFiles.length === 0 && modifiedFiles.length === 0) return null;
144
+
145
+ const expansions: FileMarkerStore = new Map();
146
+ const markerLines: string[] = [];
147
+
125
148
  if (readFiles.length > 0) {
126
- sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);
149
+ const marker = createReadMarker(readFiles.length);
150
+ expansions.set(marker, `<read-files>\n${readFiles.join("\n")}\n</read-files>`);
151
+ markerLines.push(marker);
127
152
  }
128
153
  if (modifiedFiles.length > 0) {
129
- sections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);
154
+ const marker = createModifiedMarker(modifiedFiles.length);
155
+ expansions.set(marker, `<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);
156
+ markerLines.push(marker);
130
157
  }
131
158
 
132
- return sections.length > 0 ? `${summary}\n\n${sections.join("\n\n")}` : summary;
159
+ return { markers: markerLines.join("\n"), expansions };
160
+ }
161
+
162
+ /** Expand all file markers in text using the stored expansions. */
163
+ function expandFileMarkers(text: string, store: FileMarkerStore): string {
164
+ let result = text;
165
+ for (const [marker, expanded] of store) {
166
+ result = result.replaceAll(marker, expanded);
167
+ }
168
+ return result;
133
169
  }
134
170
 
135
171
  // ---------------------------------------------------------------------------
136
172
  // Shared helpers
137
173
  // ---------------------------------------------------------------------------
138
174
 
175
+ type HandoffResult = { type: "prompt"; text: string } | { type: "error"; message: string } | null;
176
+
139
177
  /**
140
178
  * Generate a handoff prompt via LLM with a loader UI.
141
- * Returns the prompt text, or null if cancelled/failed.
179
+ * Returns { type: "prompt", text } on success, { type: "error", message } on failure, or null if user cancelled.
142
180
  */
143
181
  async function generateHandoffPrompt(
144
182
  conversationText: string,
145
183
  goal: string,
146
184
  ctx: ExtensionContext,
147
- ): Promise<string | null> {
148
- return ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
185
+ ): Promise<HandoffResult> {
186
+ return ctx.ui.custom<HandoffResult>((tui, theme, _kb, done) => {
149
187
  const loader = new BorderedLoader(tui, theme, "Generating handoff prompt...");
150
188
  loader.onAbort = () => done(null);
151
189
 
@@ -171,11 +209,11 @@ async function generateHandoffPrompt(
171
209
 
172
210
  if (response.stopReason === "aborted") return null;
173
211
  if (response.stopReason === "error") {
174
- throw new Error(
212
+ const msg =
175
213
  "errorMessage" in response && typeof (response as any).errorMessage === "string"
176
214
  ? (response as any).errorMessage
177
- : "Handoff generation failed",
178
- );
215
+ : "LLM request failed";
216
+ return { type: "error" as const, message: msg };
179
217
  }
180
218
 
181
219
  const text = response.content
@@ -184,14 +222,14 @@ async function generateHandoffPrompt(
184
222
  .join("\n")
185
223
  .trim();
186
224
 
187
- return text.length > 0 ? text : null;
225
+ return text.length > 0 ? { type: "prompt" as const, text } : { type: "error" as const, message: "LLM returned empty response" };
188
226
  };
189
227
 
190
228
  run()
191
229
  .then(done)
192
230
  .catch((err) => {
193
- console.error("Handoff generation failed:", err);
194
- done(null);
231
+ const message = err instanceof Error ? err.message : String(err);
232
+ done({ type: "error" as const, message });
195
233
  });
196
234
 
197
235
  return loader;
@@ -203,24 +241,78 @@ async function generateHandoffPrompt(
203
241
  * Returns serialized text + raw messages (for file op extraction), or null if empty.
204
242
  */
205
243
  function gatherConversation(ctx: ExtensionContext): { text: string; messages: any[] } | null {
244
+ // Use buildSessionContext instead of raw getBranch so we only get what the
245
+ // agent actually sees: compaction summary + kept/recent messages.
246
+ // Raw getBranch returns the entire session history including messages that
247
+ // were already compacted away, which can exceed the model's context window.
206
248
  const branch = ctx.sessionManager.getBranch();
207
- const messages = branch
208
- .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
209
- .map((entry) => entry.message);
249
+ const leafId = ctx.sessionManager.getLeafId();
250
+ const { messages } = buildSessionContext(branch, leafId);
210
251
 
211
252
  if (messages.length === 0) return null;
212
253
 
213
254
  return { text: serializeConversation(convertToLlm(messages)), messages };
214
255
  }
215
256
 
257
+ /**
258
+ * Read a session file's header to extract parentSession.
259
+ * Only reads the first line (the header is always line 1 in a .jsonl session file).
260
+ */
261
+ function getSessionHeader(sessionFile: string): SessionHeader | null {
262
+ try {
263
+ if (!existsSync(sessionFile)) return null;
264
+ const content = readFileSync(sessionFile, "utf-8");
265
+ const firstLine = content.slice(0, content.indexOf("\n")).trim();
266
+ if (!firstLine) return null;
267
+ const parsed = JSON.parse(firstLine);
268
+ return parsed.type === "session" ? parsed : null;
269
+ } catch {
270
+ return null;
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Walk the session ancestry chain (parent → grandparent → …).
276
+ * Returns an ordered list of session file paths, starting with the immediate parent.
277
+ * Stops at the first missing/unreadable file or when there's no parentSession.
278
+ * Guards against cycles with a visited set.
279
+ */
280
+ function getSessionAncestry(parentSessionFile: string): string[] {
281
+ const ancestry: string[] = [];
282
+ const visited = new Set<string>();
283
+ let current: string | undefined = parentSessionFile;
284
+
285
+ while (current && !visited.has(current)) {
286
+ visited.add(current);
287
+ ancestry.push(current);
288
+ const header = getSessionHeader(current);
289
+ current = header?.parentSession;
290
+ }
291
+
292
+ return ancestry;
293
+ }
294
+
216
295
  /**
217
296
  * Wrap a handoff prompt with the parent session reference and session-query skill.
218
- * Enables the new session to query the old one for details not in the summary.
297
+ * Includes the full ancestry chain so the new session can query any ancestor.
219
298
  */
220
299
  function wrapWithParentSession(prompt: string, parentSessionFile: string | null): string {
221
300
  if (!parentSessionFile) return prompt;
222
301
 
223
- return `/skill:pi-session-query\n\n**Parent session:** \`${parentSessionFile}\`\n\n${prompt}`;
302
+ const ancestry = getSessionAncestry(parentSessionFile);
303
+
304
+ const lines = [`/skill:pi-session-query`, ""];
305
+ lines.push(`**Parent session:** \`${ancestry[0]}\``);
306
+ if (ancestry.length > 1) {
307
+ lines.push("");
308
+ lines.push(`**Ancestor sessions:**`);
309
+ for (let i = 1; i < ancestry.length; i++) {
310
+ lines.push(`- \`${ancestry[i]}\``);
311
+ }
312
+ }
313
+ lines.push("");
314
+
315
+ return `${lines.join("\n")}${prompt}`;
224
316
  }
225
317
 
226
318
  // ---------------------------------------------------------------------------
@@ -250,6 +342,10 @@ export default function (pi: ExtensionAPI) {
250
342
  // Store prompt keyed by parent session for the session_switch handler.
251
343
  const pendingHandoffText = new Map<string, string>();
252
344
 
345
+ // -- Collapsed file marker expansion state --------------------------------
346
+ // Stores marker→XML mappings so the input hook can expand them on submit.
347
+ let activeFileMarkers: FileMarkerStore = new Map();
348
+
253
349
  // ── session_switch ──────────────────────────────────────────────────────
254
350
  // Set editor text for command-path handoffs + clear context filter.
255
351
  pi.on("session_switch", async (event, ctx) => {
@@ -283,6 +379,26 @@ export default function (pi: ExtensionAPI) {
283
379
  }
284
380
  });
285
381
 
382
+ // ── input: expand collapsed file markers before LLM sees the text ───────
383
+ pi.on("input", (event) => {
384
+ if (activeFileMarkers.size === 0) return;
385
+
386
+ // Check if any markers are present in the input text
387
+ let hasMarkers = false;
388
+ for (const marker of activeFileMarkers.keys()) {
389
+ if (event.text.includes(marker)) {
390
+ hasMarkers = true;
391
+ break;
392
+ }
393
+ }
394
+ if (!hasMarkers) return;
395
+
396
+ const expanded = expandFileMarkers(event.text, activeFileMarkers);
397
+ // Clear after first expansion — markers are single-use (one handoff prompt)
398
+ activeFileMarkers = new Map();
399
+ return { action: "transform" as const, text: expanded, images: event.images };
400
+ });
401
+
286
402
  // ── agent_end: deferred session switch for tool path ────────────────────
287
403
  pi.on("agent_end", (_event, ctx) => {
288
404
  if (!pendingHandoff) return;
@@ -335,24 +451,22 @@ export default function (pi: ExtensionAPI) {
335
451
  contextForHandoff += `## Recent Conversation\n\n${conversationText}`;
336
452
 
337
453
  // Generate handoff prompt
338
- let prompt: string | null;
339
- try {
340
- prompt = await generateHandoffPrompt(contextForHandoff, "Continue current work", ctx);
341
- } catch (err) {
342
- ctx.ui.notify(
343
- `Handoff failed: ${err instanceof Error ? err.message : String(err)}. Compacting instead.`,
344
- "warning",
345
- );
346
- return;
347
- }
454
+ const handoffResult = await generateHandoffPrompt(contextForHandoff, "Continue current work", ctx);
348
455
 
349
- if (!prompt) {
456
+ if (!handoffResult) {
350
457
  ctx.ui.notify("Handoff cancelled. Compacting instead.", "warning");
351
458
  return;
352
459
  }
460
+ if (handoffResult.type === "error") {
461
+ ctx.ui.notify(`Handoff failed: ${handoffResult.message}. Compacting instead.`, "warning");
462
+ return;
463
+ }
353
464
 
354
- // Append programmatic file tracking from the messages being summarized
355
- prompt = appendFileOperations(prompt, preparation.messagesToSummarize);
465
+ // Build collapsed file markers from the messages being summarized
466
+ const fileOps = buildFileOperations(preparation.messagesToSummarize);
467
+ let prompt = fileOps
468
+ ? `${handoffResult.text}\n\n${fileOps.markers}`
469
+ : handoffResult.text;
356
470
 
357
471
  // Switch session via raw sessionManager (safe — no agent loop running)
358
472
  const currentSessionFile = ctx.sessionManager.getSessionFile();
@@ -372,6 +486,8 @@ export default function (pi: ExtensionAPI) {
372
486
  return;
373
487
  }
374
488
 
489
+ // Activate markers for input hook expansion, then set editor text
490
+ if (fileOps) activeFileMarkers = fileOps.expansions;
375
491
  ctx.ui.setEditorText(prompt);
376
492
  ctx.ui.notify("Handoff ready — edit if needed, press Enter to send", "info");
377
493
 
@@ -399,14 +515,21 @@ export default function (pi: ExtensionAPI) {
399
515
  return;
400
516
  }
401
517
 
402
- let prompt = await generateHandoffPrompt(conv.text, goal, ctx);
403
- if (!prompt) {
518
+ const result = await generateHandoffPrompt(conv.text, goal, ctx);
519
+ if (!result) {
404
520
  ctx.ui.notify("Handoff cancelled.", "info");
405
521
  return;
406
522
  }
523
+ if (result.type === "error") {
524
+ ctx.ui.notify(`Handoff failed: ${result.message}`, "error");
525
+ return;
526
+ }
407
527
 
408
- // Append programmatic file tracking (read/modified from tool calls)
409
- prompt = appendFileOperations(prompt, conv.messages);
528
+ // Build collapsed file markers from tool calls
529
+ const fileOps = buildFileOperations(conv.messages);
530
+ let prompt = fileOps
531
+ ? `${result.text}\n\n${fileOps.markers}`
532
+ : result.text;
410
533
 
411
534
  const currentSessionFile = ctx.sessionManager.getSessionFile();
412
535
 
@@ -416,14 +539,19 @@ export default function (pi: ExtensionAPI) {
416
539
  if (currentSessionFile) {
417
540
  pendingHandoffText.set(currentSessionFile, prompt);
418
541
  }
542
+ // Stage markers — they'll be activated in session_switch after editor text is set
543
+ const pendingMarkers = fileOps?.expansions;
419
544
 
420
- const result = await ctx.newSession({ parentSession: currentSessionFile ?? undefined });
545
+ const sessionResult = await ctx.newSession({ parentSession: currentSessionFile ?? undefined });
421
546
 
422
- if (result.cancelled) {
547
+ if (sessionResult.cancelled) {
423
548
  if (currentSessionFile) pendingHandoffText.delete(currentSessionFile);
424
549
  ctx.ui.notify("New session cancelled.", "info");
425
550
  return;
426
551
  }
552
+
553
+ // Activate markers for the new session's input hook
554
+ if (pendingMarkers) activeFileMarkers = pendingMarkers;
427
555
  },
428
556
  });
429
557
 
@@ -450,16 +578,25 @@ export default function (pi: ExtensionAPI) {
450
578
  return { content: [{ type: "text" as const, text: "No conversation to hand off." }] };
451
579
  }
452
580
 
453
- let prompt = await generateHandoffPrompt(conv.text, params.goal, ctx);
454
- if (!prompt) {
581
+ const result = await generateHandoffPrompt(conv.text, params.goal, ctx);
582
+ if (!result) {
455
583
  return { content: [{ type: "text" as const, text: "Handoff cancelled." }] };
456
584
  }
585
+ if (result.type === "error") {
586
+ return { content: [{ type: "text" as const, text: `Handoff failed: ${result.message}` }] };
587
+ }
457
588
 
458
- prompt = appendFileOperations(prompt, conv.messages);
589
+ const fileOps = buildFileOperations(conv.messages);
590
+ let prompt = fileOps
591
+ ? `${result.text}\n\n${fileOps.markers}`
592
+ : result.text;
459
593
 
460
594
  const currentSessionFile = ctx.sessionManager.getSessionFile();
461
595
  prompt = wrapWithParentSession(prompt, currentSessionFile ?? null);
462
596
 
597
+ // Stage markers for activation after session switch
598
+ if (fileOps) activeFileMarkers = fileOps.expansions;
599
+
463
600
  // Defer session switch to agent_end
464
601
  pendingHandoff = {
465
602
  prompt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssweens/pi-handoff",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "scripts": {
5
5
  "test": "bun test tests/",
6
6
  "test:watch": "bun test --watch tests/"
@@ -12,9 +12,9 @@ This skill is automatically invoked in handed-off sessions when you need to look
12
12
 
13
13
  ## When to Use
14
14
 
15
- - When the handoff summary references a "Parent session" path
15
+ - When the handoff summary references a "Parent session" or "Ancestor sessions" path
16
16
  - When you need specific details not included in the handoff summary
17
- - When you need to verify a decision or approach from the parent session
17
+ - When you need to verify a decision or approach from the parent or an ancestor session
18
18
  - When you need file paths or code snippets from earlier work
19
19
 
20
20
  ## Usage
@@ -51,6 +51,7 @@ session_query("/path/to/session.jsonl", "Summarize the key decisions made")
51
51
  2. **Reference code** - Ask about specific files or functions when relevant
52
52
  3. **Verify before assuming** - If the handoff summary seems incomplete, query for details
53
53
  4. **Don't over-query** - The handoff summary should have most context; query only when needed
54
+ 5. **Check ancestors** - If the parent session doesn't have the info, try ancestor sessions listed in the handoff
54
55
 
55
56
  ## How It Works
56
57