@ssweens/pi-handoff 1.1.1 → 1.2.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
 
@@ -136,16 +138,18 @@ function appendFileOperations(summary: string, messages: any[]): string {
136
138
  // Shared helpers
137
139
  // ---------------------------------------------------------------------------
138
140
 
141
+ type HandoffResult = { type: "prompt"; text: string } | { type: "error"; message: string } | null;
142
+
139
143
  /**
140
144
  * Generate a handoff prompt via LLM with a loader UI.
141
- * Returns the prompt text, or null if cancelled/failed.
145
+ * Returns { type: "prompt", text } on success, { type: "error", message } on failure, or null if user cancelled.
142
146
  */
143
147
  async function generateHandoffPrompt(
144
148
  conversationText: string,
145
149
  goal: string,
146
150
  ctx: ExtensionContext,
147
- ): Promise<string | null> {
148
- return ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
151
+ ): Promise<HandoffResult> {
152
+ return ctx.ui.custom<HandoffResult>((tui, theme, _kb, done) => {
149
153
  const loader = new BorderedLoader(tui, theme, "Generating handoff prompt...");
150
154
  loader.onAbort = () => done(null);
151
155
 
@@ -171,11 +175,11 @@ async function generateHandoffPrompt(
171
175
 
172
176
  if (response.stopReason === "aborted") return null;
173
177
  if (response.stopReason === "error") {
174
- throw new Error(
178
+ const msg =
175
179
  "errorMessage" in response && typeof (response as any).errorMessage === "string"
176
180
  ? (response as any).errorMessage
177
- : "Handoff generation failed",
178
- );
181
+ : "LLM request failed";
182
+ return { type: "error" as const, message: msg };
179
183
  }
180
184
 
181
185
  const text = response.content
@@ -184,14 +188,14 @@ async function generateHandoffPrompt(
184
188
  .join("\n")
185
189
  .trim();
186
190
 
187
- return text.length > 0 ? text : null;
191
+ return text.length > 0 ? { type: "prompt" as const, text } : { type: "error" as const, message: "LLM returned empty response" };
188
192
  };
189
193
 
190
194
  run()
191
195
  .then(done)
192
196
  .catch((err) => {
193
- console.error("Handoff generation failed:", err);
194
- done(null);
197
+ const message = err instanceof Error ? err.message : String(err);
198
+ done({ type: "error" as const, message });
195
199
  });
196
200
 
197
201
  return loader;
@@ -203,24 +207,78 @@ async function generateHandoffPrompt(
203
207
  * Returns serialized text + raw messages (for file op extraction), or null if empty.
204
208
  */
205
209
  function gatherConversation(ctx: ExtensionContext): { text: string; messages: any[] } | null {
210
+ // Use buildSessionContext instead of raw getBranch so we only get what the
211
+ // agent actually sees: compaction summary + kept/recent messages.
212
+ // Raw getBranch returns the entire session history including messages that
213
+ // were already compacted away, which can exceed the model's context window.
206
214
  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);
215
+ const leafId = ctx.sessionManager.getLeafId();
216
+ const { messages } = buildSessionContext(branch, leafId);
210
217
 
211
218
  if (messages.length === 0) return null;
212
219
 
213
220
  return { text: serializeConversation(convertToLlm(messages)), messages };
214
221
  }
215
222
 
223
+ /**
224
+ * Read a session file's header to extract parentSession.
225
+ * Only reads the first line (the header is always line 1 in a .jsonl session file).
226
+ */
227
+ function getSessionHeader(sessionFile: string): SessionHeader | null {
228
+ try {
229
+ if (!existsSync(sessionFile)) return null;
230
+ const content = readFileSync(sessionFile, "utf-8");
231
+ const firstLine = content.slice(0, content.indexOf("\n")).trim();
232
+ if (!firstLine) return null;
233
+ const parsed = JSON.parse(firstLine);
234
+ return parsed.type === "session" ? parsed : null;
235
+ } catch {
236
+ return null;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Walk the session ancestry chain (parent → grandparent → …).
242
+ * Returns an ordered list of session file paths, starting with the immediate parent.
243
+ * Stops at the first missing/unreadable file or when there's no parentSession.
244
+ * Guards against cycles with a visited set.
245
+ */
246
+ function getSessionAncestry(parentSessionFile: string): string[] {
247
+ const ancestry: string[] = [];
248
+ const visited = new Set<string>();
249
+ let current: string | undefined = parentSessionFile;
250
+
251
+ while (current && !visited.has(current)) {
252
+ visited.add(current);
253
+ ancestry.push(current);
254
+ const header = getSessionHeader(current);
255
+ current = header?.parentSession;
256
+ }
257
+
258
+ return ancestry;
259
+ }
260
+
216
261
  /**
217
262
  * 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.
263
+ * Includes the full ancestry chain so the new session can query any ancestor.
219
264
  */
220
265
  function wrapWithParentSession(prompt: string, parentSessionFile: string | null): string {
221
266
  if (!parentSessionFile) return prompt;
222
267
 
223
- return `/skill:pi-session-query\n\n**Parent session:** \`${parentSessionFile}\`\n\n${prompt}`;
268
+ const ancestry = getSessionAncestry(parentSessionFile);
269
+
270
+ const lines = [`/skill:pi-session-query`, ""];
271
+ lines.push(`**Parent session:** \`${ancestry[0]}\``);
272
+ if (ancestry.length > 1) {
273
+ lines.push("");
274
+ lines.push(`**Ancestor sessions:**`);
275
+ for (let i = 1; i < ancestry.length; i++) {
276
+ lines.push(`- \`${ancestry[i]}\``);
277
+ }
278
+ }
279
+ lines.push("");
280
+
281
+ return `${lines.join("\n")}${prompt}`;
224
282
  }
225
283
 
226
284
  // ---------------------------------------------------------------------------
@@ -335,24 +393,19 @@ export default function (pi: ExtensionAPI) {
335
393
  contextForHandoff += `## Recent Conversation\n\n${conversationText}`;
336
394
 
337
395
  // 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
- }
396
+ const handoffResult = await generateHandoffPrompt(contextForHandoff, "Continue current work", ctx);
348
397
 
349
- if (!prompt) {
398
+ if (!handoffResult) {
350
399
  ctx.ui.notify("Handoff cancelled. Compacting instead.", "warning");
351
400
  return;
352
401
  }
402
+ if (handoffResult.type === "error") {
403
+ ctx.ui.notify(`Handoff failed: ${handoffResult.message}. Compacting instead.`, "warning");
404
+ return;
405
+ }
353
406
 
354
407
  // Append programmatic file tracking from the messages being summarized
355
- prompt = appendFileOperations(prompt, preparation.messagesToSummarize);
408
+ let prompt = appendFileOperations(handoffResult.text, preparation.messagesToSummarize);
356
409
 
357
410
  // Switch session via raw sessionManager (safe — no agent loop running)
358
411
  const currentSessionFile = ctx.sessionManager.getSessionFile();
@@ -399,14 +452,18 @@ export default function (pi: ExtensionAPI) {
399
452
  return;
400
453
  }
401
454
 
402
- let prompt = await generateHandoffPrompt(conv.text, goal, ctx);
403
- if (!prompt) {
455
+ const result = await generateHandoffPrompt(conv.text, goal, ctx);
456
+ if (!result) {
404
457
  ctx.ui.notify("Handoff cancelled.", "info");
405
458
  return;
406
459
  }
460
+ if (result.type === "error") {
461
+ ctx.ui.notify(`Handoff failed: ${result.message}`, "error");
462
+ return;
463
+ }
407
464
 
408
465
  // Append programmatic file tracking (read/modified from tool calls)
409
- prompt = appendFileOperations(prompt, conv.messages);
466
+ let prompt = appendFileOperations(result.text, conv.messages);
410
467
 
411
468
  const currentSessionFile = ctx.sessionManager.getSessionFile();
412
469
 
@@ -417,9 +474,9 @@ export default function (pi: ExtensionAPI) {
417
474
  pendingHandoffText.set(currentSessionFile, prompt);
418
475
  }
419
476
 
420
- const result = await ctx.newSession({ parentSession: currentSessionFile ?? undefined });
477
+ const sessionResult = await ctx.newSession({ parentSession: currentSessionFile ?? undefined });
421
478
 
422
- if (result.cancelled) {
479
+ if (sessionResult.cancelled) {
423
480
  if (currentSessionFile) pendingHandoffText.delete(currentSessionFile);
424
481
  ctx.ui.notify("New session cancelled.", "info");
425
482
  return;
@@ -450,12 +507,15 @@ export default function (pi: ExtensionAPI) {
450
507
  return { content: [{ type: "text" as const, text: "No conversation to hand off." }] };
451
508
  }
452
509
 
453
- let prompt = await generateHandoffPrompt(conv.text, params.goal, ctx);
454
- if (!prompt) {
510
+ const result = await generateHandoffPrompt(conv.text, params.goal, ctx);
511
+ if (!result) {
455
512
  return { content: [{ type: "text" as const, text: "Handoff cancelled." }] };
456
513
  }
514
+ if (result.type === "error") {
515
+ return { content: [{ type: "text" as const, text: `Handoff failed: ${result.message}` }] };
516
+ }
457
517
 
458
- prompt = appendFileOperations(prompt, conv.messages);
518
+ let prompt = appendFileOperations(result.text, conv.messages);
459
519
 
460
520
  const currentSessionFile = ctx.sessionManager.getSessionFile();
461
521
  prompt = wrapWithParentSession(prompt, currentSessionFile ?? null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssweens/pi-handoff",
3
- "version": "1.1.1",
3
+ "version": "1.2.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