@ssweens/pi-handoff 1.1.0 → 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
 
@@ -170,18 +174,28 @@ async function generateHandoffPrompt(
170
174
  );
171
175
 
172
176
  if (response.stopReason === "aborted") return null;
177
+ if (response.stopReason === "error") {
178
+ const msg =
179
+ "errorMessage" in response && typeof (response as any).errorMessage === "string"
180
+ ? (response as any).errorMessage
181
+ : "LLM request failed";
182
+ return { type: "error" as const, message: msg };
183
+ }
173
184
 
174
- return response.content
185
+ const text = response.content
175
186
  .filter((c): c is { type: "text"; text: string } => c.type === "text")
176
187
  .map((c) => c.text)
177
- .join("\n");
188
+ .join("\n")
189
+ .trim();
190
+
191
+ return text.length > 0 ? { type: "prompt" as const, text } : { type: "error" as const, message: "LLM returned empty response" };
178
192
  };
179
193
 
180
194
  run()
181
195
  .then(done)
182
196
  .catch((err) => {
183
- console.error("Handoff generation failed:", err);
184
- done(null);
197
+ const message = err instanceof Error ? err.message : String(err);
198
+ done({ type: "error" as const, message });
185
199
  });
186
200
 
187
201
  return loader;
@@ -193,24 +207,78 @@ async function generateHandoffPrompt(
193
207
  * Returns serialized text + raw messages (for file op extraction), or null if empty.
194
208
  */
195
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.
196
214
  const branch = ctx.sessionManager.getBranch();
197
- const messages = branch
198
- .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
199
- .map((entry) => entry.message);
215
+ const leafId = ctx.sessionManager.getLeafId();
216
+ const { messages } = buildSessionContext(branch, leafId);
200
217
 
201
218
  if (messages.length === 0) return null;
202
219
 
203
220
  return { text: serializeConversation(convertToLlm(messages)), messages };
204
221
  }
205
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
+
206
261
  /**
207
262
  * Wrap a handoff prompt with the parent session reference and session-query skill.
208
- * 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.
209
264
  */
210
265
  function wrapWithParentSession(prompt: string, parentSessionFile: string | null): string {
211
266
  if (!parentSessionFile) return prompt;
212
267
 
213
- 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}`;
214
282
  }
215
283
 
216
284
  // ---------------------------------------------------------------------------
@@ -325,24 +393,19 @@ export default function (pi: ExtensionAPI) {
325
393
  contextForHandoff += `## Recent Conversation\n\n${conversationText}`;
326
394
 
327
395
  // Generate handoff prompt
328
- let prompt: string | null;
329
- try {
330
- prompt = await generateHandoffPrompt(contextForHandoff, "Continue current work", ctx);
331
- } catch (err) {
332
- ctx.ui.notify(
333
- `Handoff failed: ${err instanceof Error ? err.message : String(err)}. Compacting instead.`,
334
- "warning",
335
- );
336
- return;
337
- }
396
+ const handoffResult = await generateHandoffPrompt(contextForHandoff, "Continue current work", ctx);
338
397
 
339
- if (prompt === null) {
398
+ if (!handoffResult) {
340
399
  ctx.ui.notify("Handoff cancelled. Compacting instead.", "warning");
341
400
  return;
342
401
  }
402
+ if (handoffResult.type === "error") {
403
+ ctx.ui.notify(`Handoff failed: ${handoffResult.message}. Compacting instead.`, "warning");
404
+ return;
405
+ }
343
406
 
344
407
  // Append programmatic file tracking from the messages being summarized
345
- prompt = appendFileOperations(prompt, preparation.messagesToSummarize);
408
+ let prompt = appendFileOperations(handoffResult.text, preparation.messagesToSummarize);
346
409
 
347
410
  // Switch session via raw sessionManager (safe — no agent loop running)
348
411
  const currentSessionFile = ctx.sessionManager.getSessionFile();
@@ -389,14 +452,18 @@ export default function (pi: ExtensionAPI) {
389
452
  return;
390
453
  }
391
454
 
392
- let prompt = await generateHandoffPrompt(conv.text, goal, ctx);
393
- if (prompt === null) {
455
+ const result = await generateHandoffPrompt(conv.text, goal, ctx);
456
+ if (!result) {
394
457
  ctx.ui.notify("Handoff cancelled.", "info");
395
458
  return;
396
459
  }
460
+ if (result.type === "error") {
461
+ ctx.ui.notify(`Handoff failed: ${result.message}`, "error");
462
+ return;
463
+ }
397
464
 
398
465
  // Append programmatic file tracking (read/modified from tool calls)
399
- prompt = appendFileOperations(prompt, conv.messages);
466
+ let prompt = appendFileOperations(result.text, conv.messages);
400
467
 
401
468
  const currentSessionFile = ctx.sessionManager.getSessionFile();
402
469
 
@@ -407,9 +474,9 @@ export default function (pi: ExtensionAPI) {
407
474
  pendingHandoffText.set(currentSessionFile, prompt);
408
475
  }
409
476
 
410
- const result = await ctx.newSession({ parentSession: currentSessionFile ?? undefined });
477
+ const sessionResult = await ctx.newSession({ parentSession: currentSessionFile ?? undefined });
411
478
 
412
- if (result.cancelled) {
479
+ if (sessionResult.cancelled) {
413
480
  if (currentSessionFile) pendingHandoffText.delete(currentSessionFile);
414
481
  ctx.ui.notify("New session cancelled.", "info");
415
482
  return;
@@ -440,12 +507,15 @@ export default function (pi: ExtensionAPI) {
440
507
  return { content: [{ type: "text" as const, text: "No conversation to hand off." }] };
441
508
  }
442
509
 
443
- let prompt = await generateHandoffPrompt(conv.text, params.goal, ctx);
444
- if (prompt === null) {
510
+ const result = await generateHandoffPrompt(conv.text, params.goal, ctx);
511
+ if (!result) {
445
512
  return { content: [{ type: "text" as const, text: "Handoff cancelled." }] };
446
513
  }
514
+ if (result.type === "error") {
515
+ return { content: [{ type: "text" as const, text: `Handoff failed: ${result.message}` }] };
516
+ }
447
517
 
448
- prompt = appendFileOperations(prompt, conv.messages);
518
+ let prompt = appendFileOperations(result.text, conv.messages);
449
519
 
450
520
  const currentSessionFile = ctx.sessionManager.getSessionFile();
451
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.0",
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