@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.
- package/extensions/handoff.ts +104 -34
- package/package.json +1 -1
- package/skills/pi-session-query/SKILL.md +3 -2
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
184
|
-
done(
|
|
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
|
|
198
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
393
|
-
if (
|
|
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(
|
|
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
|
|
477
|
+
const sessionResult = await ctx.newSession({ parentSession: currentSessionFile ?? undefined });
|
|
411
478
|
|
|
412
|
-
if (
|
|
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
|
-
|
|
444
|
-
if (
|
|
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(
|
|
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
|
@@ -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
|
|