@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.
package/extensions/handoff.ts
CHANGED
|
@@ -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
|
|
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<
|
|
148
|
-
return ctx.ui.custom<
|
|
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
|
-
|
|
178
|
+
const msg =
|
|
175
179
|
"errorMessage" in response && typeof (response as any).errorMessage === "string"
|
|
176
180
|
? (response as any).errorMessage
|
|
177
|
-
: "
|
|
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 :
|
|
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
|
-
|
|
194
|
-
done(
|
|
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
|
|
208
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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(
|
|
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
|
-
|
|
403
|
-
if (!
|
|
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(
|
|
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
|
|
477
|
+
const sessionResult = await ctx.newSession({ parentSession: currentSessionFile ?? undefined });
|
|
421
478
|
|
|
422
|
-
if (
|
|
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
|
-
|
|
454
|
-
if (!
|
|
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(
|
|
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
|
@@ -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
|
|