pi-sessions 0.1.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/LICENSE +21 -0
- package/README.md +173 -0
- package/extensions/session-ask.ts +297 -0
- package/extensions/session-auto-title/command.ts +109 -0
- package/extensions/session-auto-title/context.ts +41 -0
- package/extensions/session-auto-title/controller.ts +298 -0
- package/extensions/session-auto-title/model.ts +61 -0
- package/extensions/session-auto-title/prompt.ts +54 -0
- package/extensions/session-auto-title/retitle.ts +641 -0
- package/extensions/session-auto-title/state.ts +69 -0
- package/extensions/session-auto-title/wizard.ts +583 -0
- package/extensions/session-auto-title.ts +209 -0
- package/extensions/session-handoff/extract.ts +278 -0
- package/extensions/session-handoff/metadata.ts +96 -0
- package/extensions/session-handoff/picker.ts +394 -0
- package/extensions/session-handoff/query.ts +318 -0
- package/extensions/session-handoff/refs.ts +151 -0
- package/extensions/session-handoff/review.ts +268 -0
- package/extensions/session-handoff.ts +203 -0
- package/extensions/session-hooks.ts +55 -0
- package/extensions/session-index.ts +159 -0
- package/extensions/session-search/extract.ts +997 -0
- package/extensions/session-search/hooks.ts +350 -0
- package/extensions/session-search/normalize.ts +170 -0
- package/extensions/session-search/reindex.ts +93 -0
- package/extensions/session-search.ts +390 -0
- package/extensions/shared/search-snippet.ts +40 -0
- package/extensions/shared/session-index/common.ts +222 -0
- package/extensions/shared/session-index/index.ts +5 -0
- package/extensions/shared/session-index/lineage.ts +417 -0
- package/extensions/shared/session-index/schema.ts +178 -0
- package/extensions/shared/session-index/search.ts +688 -0
- package/extensions/shared/session-index/store.ts +173 -0
- package/extensions/shared/session-ui.ts +15 -0
- package/extensions/shared/settings.ts +141 -0
- package/extensions/shared/time.ts +38 -0
- package/extensions/shared/typebox.ts +61 -0
- package/images/handoff.png +0 -0
- package/images/session-title.png +0 -0
- package/images/session_ask.png +0 -0
- package/images/session_picker.png +0 -0
- package/images/session_search.png +0 -0
- package/package.json +64 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { Api, Model } from "@mariozechner/pi-ai";
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionCommandContext,
|
|
5
|
+
ExtensionContext,
|
|
6
|
+
SessionStartEvent,
|
|
7
|
+
TurnEndEvent,
|
|
8
|
+
} from "@mariozechner/pi-coding-agent";
|
|
9
|
+
import {
|
|
10
|
+
createSessionAutoTitleCommandHandler,
|
|
11
|
+
getRetitleArgumentCompletions,
|
|
12
|
+
type RetitleCommandInvocation,
|
|
13
|
+
type RetitleCommandOutcome,
|
|
14
|
+
} from "./session-auto-title/command.js";
|
|
15
|
+
import {
|
|
16
|
+
createSessionAutoTitleController,
|
|
17
|
+
formatAutoTitleFailureSummary,
|
|
18
|
+
type SessionAutoTitleController,
|
|
19
|
+
} from "./session-auto-title/controller.js";
|
|
20
|
+
import { resolveAutoTitleModel } from "./session-auto-title/model.js";
|
|
21
|
+
import {
|
|
22
|
+
buildRetitleScopeScan,
|
|
23
|
+
notifyBulkRetitleResult,
|
|
24
|
+
persistAutoTitleState,
|
|
25
|
+
runBulkRetitle,
|
|
26
|
+
runRetitlePlan,
|
|
27
|
+
} from "./session-auto-title/retitle.js";
|
|
28
|
+
import { showRetitleWizard } from "./session-auto-title/wizard.js";
|
|
29
|
+
import { loadSettings } from "./shared/settings.js";
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
createSessionAutoTitleCommandHandler,
|
|
33
|
+
getRetitleArgumentCompletions,
|
|
34
|
+
parseRetitleCommand,
|
|
35
|
+
TITLE_USAGE,
|
|
36
|
+
} from "./session-auto-title/command.js";
|
|
37
|
+
|
|
38
|
+
interface TitleRunState {
|
|
39
|
+
controller: SessionAutoTitleController;
|
|
40
|
+
getSessionEpoch: () => number;
|
|
41
|
+
setInFlight: (work: Promise<void>) => void;
|
|
42
|
+
clearInFlight: () => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default function sessionAutoTitleExtension(pi: ExtensionAPI): void {
|
|
46
|
+
const settings = loadSettings();
|
|
47
|
+
const controller = createSessionAutoTitleController(settings.autoTitle);
|
|
48
|
+
let sessionEpoch = 0;
|
|
49
|
+
let titleWorkInFlight: Promise<void> | undefined;
|
|
50
|
+
let resolvedModel: Model<Api> | undefined;
|
|
51
|
+
|
|
52
|
+
pi.registerCommand("title", {
|
|
53
|
+
description: "Generate titles for this session, this folder, or all of Pi",
|
|
54
|
+
getArgumentCompletions: getRetitleArgumentCompletions,
|
|
55
|
+
handler: createSessionAutoTitleCommandHandler(
|
|
56
|
+
async (invocation, ctx): Promise<RetitleCommandOutcome> => {
|
|
57
|
+
if (titleWorkInFlight) {
|
|
58
|
+
await titleWorkInFlight;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const model = resolvedModel ?? resolveAutoTitleModel(ctx, settings.autoTitle.model)?.model;
|
|
62
|
+
return handleTitleInvocation(
|
|
63
|
+
pi,
|
|
64
|
+
{
|
|
65
|
+
controller,
|
|
66
|
+
getSessionEpoch: () => sessionEpoch,
|
|
67
|
+
setInFlight: (work) => {
|
|
68
|
+
titleWorkInFlight = work;
|
|
69
|
+
},
|
|
70
|
+
clearInFlight: () => {
|
|
71
|
+
titleWorkInFlight = undefined;
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
ctx,
|
|
75
|
+
model,
|
|
76
|
+
invocation,
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
pi.on("session_start", async (_event: SessionStartEvent, ctx: ExtensionContext) => {
|
|
83
|
+
sessionEpoch += 1;
|
|
84
|
+
resolvedModel = resolveAutoTitleModel(ctx, settings.autoTitle.model)?.model;
|
|
85
|
+
persistAutoTitleState(pi, controller.handleSessionStart(ctx));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
pi.on("turn_end", async (_event: TurnEndEvent, ctx: ExtensionContext) => {
|
|
89
|
+
const result = controller.handleTurnEnd(ctx);
|
|
90
|
+
persistAutoTitleState(pi, result.persistedState);
|
|
91
|
+
|
|
92
|
+
if (!result.plan || titleWorkInFlight) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
titleWorkInFlight = runRetitlePlan({
|
|
97
|
+
pi,
|
|
98
|
+
controller,
|
|
99
|
+
ctx,
|
|
100
|
+
model: resolvedModel,
|
|
101
|
+
isManual: false,
|
|
102
|
+
existingPlan: result.plan,
|
|
103
|
+
getSessionEpoch: () => sessionEpoch,
|
|
104
|
+
notifyOnSuccess: false,
|
|
105
|
+
})
|
|
106
|
+
.then((outcome) => {
|
|
107
|
+
if (outcome.ok) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const shouldNotify = controller.handleTitleFailed(ctx, outcome.failure);
|
|
112
|
+
if (shouldNotify && ctx.hasUI) {
|
|
113
|
+
ctx.ui.notify(
|
|
114
|
+
`Auto-title failed: ${formatAutoTitleFailureSummary(outcome.failure)}. Open /title for details.`,
|
|
115
|
+
"warning",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
.catch(() => {})
|
|
120
|
+
.finally(() => {
|
|
121
|
+
titleWorkInFlight = undefined;
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
pi.on("session_shutdown", async () => {
|
|
126
|
+
sessionEpoch += 1;
|
|
127
|
+
controller.handleSessionShutdown();
|
|
128
|
+
titleWorkInFlight = undefined;
|
|
129
|
+
resolvedModel = undefined;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function handleTitleInvocation(
|
|
134
|
+
pi: ExtensionAPI,
|
|
135
|
+
state: TitleRunState,
|
|
136
|
+
ctx: ExtensionCommandContext,
|
|
137
|
+
model: Model<Api> | undefined,
|
|
138
|
+
invocation: RetitleCommandInvocation,
|
|
139
|
+
): Promise<RetitleCommandOutcome> {
|
|
140
|
+
const retitleOpts = {
|
|
141
|
+
pi,
|
|
142
|
+
controller: state.controller,
|
|
143
|
+
ctx,
|
|
144
|
+
model,
|
|
145
|
+
isManual: true,
|
|
146
|
+
getSessionEpoch: state.getSessionEpoch,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const retitleCurrentSession = async (): Promise<RetitleCommandOutcome> => {
|
|
150
|
+
const result = await runRetitlePlan(retitleOpts);
|
|
151
|
+
if (result.ok) {
|
|
152
|
+
return "success";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
state.controller.handleTitleFailed(ctx, result.failure);
|
|
156
|
+
return "failed";
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (invocation.kind === "open-pane") {
|
|
160
|
+
if (!ctx.hasUI) {
|
|
161
|
+
return retitleCurrentSession();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return showRetitleWizard(pi, state.controller, ctx, model, state.getSessionEpoch);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (invocation.scope === "this") {
|
|
168
|
+
return runWithInFlightTracking(state, retitleCurrentSession);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (ctx.hasUI && !invocation.force) {
|
|
172
|
+
return showRetitleWizard(pi, state.controller, ctx, model, state.getSessionEpoch, {
|
|
173
|
+
initialInvocation: {
|
|
174
|
+
scope: invocation.scope,
|
|
175
|
+
mode: invocation.mode ?? "backfill",
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const scan = await buildRetitleScopeScan(ctx, invocation.scope);
|
|
181
|
+
const mode = invocation.mode ?? "backfill";
|
|
182
|
+
return runWithInFlightTracking(state, async () => {
|
|
183
|
+
const result = await runBulkRetitle(
|
|
184
|
+
pi,
|
|
185
|
+
state.controller,
|
|
186
|
+
ctx,
|
|
187
|
+
model,
|
|
188
|
+
scan,
|
|
189
|
+
mode,
|
|
190
|
+
state.getSessionEpoch,
|
|
191
|
+
);
|
|
192
|
+
notifyBulkRetitleResult(ctx, scan, mode, result);
|
|
193
|
+
return "success";
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function runWithInFlightTracking(
|
|
198
|
+
state: TitleRunState,
|
|
199
|
+
work: () => Promise<RetitleCommandOutcome>,
|
|
200
|
+
): Promise<RetitleCommandOutcome> {
|
|
201
|
+
const outcomePromise = work().catch(() => "failed" as const);
|
|
202
|
+
state.setInFlight(outcomePromise.then(() => {}));
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
return await outcomePromise;
|
|
206
|
+
} finally {
|
|
207
|
+
state.clearInFlight();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AssistantMessage,
|
|
3
|
+
complete,
|
|
4
|
+
type Message,
|
|
5
|
+
type TextContent,
|
|
6
|
+
type ThinkingContent,
|
|
7
|
+
type Tool,
|
|
8
|
+
type ToolCall,
|
|
9
|
+
} from "@mariozechner/pi-ai";
|
|
10
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
11
|
+
import {
|
|
12
|
+
buildSessionContext,
|
|
13
|
+
convertToLlm,
|
|
14
|
+
serializeConversation,
|
|
15
|
+
} from "@mariozechner/pi-coding-agent";
|
|
16
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
17
|
+
import { parseTypeBoxValue } from "../shared/typebox.js";
|
|
18
|
+
|
|
19
|
+
const MAX_RELEVANT_FILES = 12;
|
|
20
|
+
const MAX_OPEN_QUESTIONS = 8;
|
|
21
|
+
|
|
22
|
+
const HANDOFF_SYSTEM_PROMPT = `You extract context for a deliberate session handoff.
|
|
23
|
+
|
|
24
|
+
You must call create_handoff_context exactly once.
|
|
25
|
+
|
|
26
|
+
Rules:
|
|
27
|
+
- Extract only context that is relevant to the next task.
|
|
28
|
+
- Keep the summary compact and concrete.
|
|
29
|
+
- Prefer workspace-relative file paths when possible.
|
|
30
|
+
- nextTask must be the concrete next action for the new session.
|
|
31
|
+
- openQuestions should contain only unresolved items that materially affect the next task.
|
|
32
|
+
- If there are no meaningful open questions, omit openQuestions entirely.
|
|
33
|
+
- Do not write the final handoff prompt yourself.`;
|
|
34
|
+
|
|
35
|
+
const HANDOFF_EXTRACTION_PARAMETERS = Type.Object({
|
|
36
|
+
summary: Type.String({
|
|
37
|
+
description: "Only the context relevant to the next task.",
|
|
38
|
+
}),
|
|
39
|
+
relevantFiles: Type.Array(Type.String(), {
|
|
40
|
+
description: "Relevant workspace-relative file paths when possible.",
|
|
41
|
+
}),
|
|
42
|
+
nextTask: Type.String({
|
|
43
|
+
description: "The concrete next task for the new session.",
|
|
44
|
+
}),
|
|
45
|
+
openQuestions: Type.Optional(
|
|
46
|
+
Type.Array(Type.String(), {
|
|
47
|
+
description: "Open questions that matter to the next task. Omit when there are none.",
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const HANDOFF_EXTRACTION_TOOL: Tool<typeof HANDOFF_EXTRACTION_PARAMETERS> = {
|
|
53
|
+
name: "create_handoff_context",
|
|
54
|
+
description: "Extract the structured handoff context for the next session.",
|
|
55
|
+
parameters: HANDOFF_EXTRACTION_PARAMETERS,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type RequiredHandoffExtractionArgs = Static<typeof REQUIRED_HANDOFF_EXTRACTION_PARAMETERS>;
|
|
59
|
+
|
|
60
|
+
const REQUIRED_HANDOFF_EXTRACTION_PARAMETERS = Type.Object({
|
|
61
|
+
summary: HANDOFF_EXTRACTION_PARAMETERS.properties.summary,
|
|
62
|
+
nextTask: HANDOFF_EXTRACTION_PARAMETERS.properties.nextTask,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export interface HandoffContext {
|
|
66
|
+
summary: string;
|
|
67
|
+
relevantFiles: string[];
|
|
68
|
+
nextTask: string;
|
|
69
|
+
openQuestions: string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface HandoffDraftResult {
|
|
73
|
+
draft: string;
|
|
74
|
+
context: HandoffContext;
|
|
75
|
+
sessionId: string;
|
|
76
|
+
sessionPath?: string | undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function generateHandoffDraft(
|
|
80
|
+
ctx: ExtensionContext,
|
|
81
|
+
goal: string,
|
|
82
|
+
signal?: AbortSignal,
|
|
83
|
+
): Promise<HandoffDraftResult | undefined> {
|
|
84
|
+
if (!ctx.model) {
|
|
85
|
+
throw new Error("No model is available for handoff.");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
|
|
89
|
+
if (!auth.ok || !auth.apiKey) {
|
|
90
|
+
throw new Error(`No API key is available for ${ctx.model.provider}/${ctx.model.id}.`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const sessionContext = buildSessionContext(
|
|
94
|
+
ctx.sessionManager.getEntries(),
|
|
95
|
+
ctx.sessionManager.getLeafId(),
|
|
96
|
+
);
|
|
97
|
+
if (sessionContext.messages.length === 0) {
|
|
98
|
+
throw new Error("No conversation is available to hand off.");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const conversationText = serializeConversation(convertToLlm(sessionContext.messages));
|
|
102
|
+
const userMessage: Message = {
|
|
103
|
+
role: "user",
|
|
104
|
+
content: [
|
|
105
|
+
{
|
|
106
|
+
type: "text",
|
|
107
|
+
text: buildExtractionPrompt(conversationText, goal),
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
timestamp: Date.now(),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const response = await complete(
|
|
114
|
+
ctx.model,
|
|
115
|
+
{
|
|
116
|
+
systemPrompt: HANDOFF_SYSTEM_PROMPT,
|
|
117
|
+
messages: [userMessage],
|
|
118
|
+
tools: [HANDOFF_EXTRACTION_TOOL],
|
|
119
|
+
},
|
|
120
|
+
signal
|
|
121
|
+
? {
|
|
122
|
+
apiKey: auth.apiKey,
|
|
123
|
+
...(auth.headers ? { headers: auth.headers } : {}),
|
|
124
|
+
signal,
|
|
125
|
+
toolChoice: "any",
|
|
126
|
+
}
|
|
127
|
+
: {
|
|
128
|
+
apiKey: auth.apiKey,
|
|
129
|
+
...(auth.headers ? { headers: auth.headers } : {}),
|
|
130
|
+
toolChoice: "any",
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
if (response.stopReason === "aborted") {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (response.stopReason === "error") {
|
|
139
|
+
throw new Error(response.errorMessage ?? "Handoff generation failed.");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const handoffContext = extractHandoffContext(response, goal);
|
|
143
|
+
if (!handoffContext) {
|
|
144
|
+
throw new Error("Handoff extraction did not return structured context.");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
148
|
+
const sessionPath = ctx.sessionManager.getSessionFile();
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
draft: assembleHandoffDraft(sessionId, sessionPath, handoffContext, goal),
|
|
152
|
+
context: handoffContext,
|
|
153
|
+
sessionId,
|
|
154
|
+
sessionPath,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function buildExtractionPrompt(conversationText: string, goal: string): string {
|
|
159
|
+
return [
|
|
160
|
+
"## Conversation",
|
|
161
|
+
conversationText,
|
|
162
|
+
"",
|
|
163
|
+
"## Goal",
|
|
164
|
+
goal,
|
|
165
|
+
"",
|
|
166
|
+
"Call create_handoff_context exactly once.",
|
|
167
|
+
].join("\n");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function assembleHandoffDraft(
|
|
171
|
+
sessionId: string,
|
|
172
|
+
sessionPath: string | undefined,
|
|
173
|
+
handoffContext: HandoffContext,
|
|
174
|
+
goal: string,
|
|
175
|
+
): string {
|
|
176
|
+
const sections = [buildContinuityLine(sessionId, sessionPath)];
|
|
177
|
+
const nextTask = handoffContext.nextTask.trim() || goal.trim();
|
|
178
|
+
|
|
179
|
+
if (nextTask) {
|
|
180
|
+
sections.push(["## Task", nextTask].join("\n"));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (handoffContext.relevantFiles.length > 0) {
|
|
184
|
+
sections.push(
|
|
185
|
+
[
|
|
186
|
+
"## Relevant Files",
|
|
187
|
+
...handoffContext.relevantFiles.map((filePath) => `- ${filePath}`),
|
|
188
|
+
].join("\n"),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (handoffContext.summary) {
|
|
193
|
+
sections.push(["## Context", handoffContext.summary].join("\n"));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (handoffContext.openQuestions.length > 0) {
|
|
197
|
+
sections.push(
|
|
198
|
+
[
|
|
199
|
+
"## Open Questions",
|
|
200
|
+
...handoffContext.openQuestions.map((question) => `- ${question}`),
|
|
201
|
+
].join("\n"),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return sections.join("\n\n").trim();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function extractHandoffContext(
|
|
209
|
+
response: AssistantMessage,
|
|
210
|
+
goal: string,
|
|
211
|
+
): HandoffContext | undefined {
|
|
212
|
+
const toolCall = response.content.find(isCreateHandoffContextToolCall);
|
|
213
|
+
if (!toolCall) {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let requiredArguments: RequiredHandoffExtractionArgs;
|
|
218
|
+
try {
|
|
219
|
+
requiredArguments = parseTypeBoxValue(
|
|
220
|
+
REQUIRED_HANDOFF_EXTRACTION_PARAMETERS,
|
|
221
|
+
toolCall.arguments,
|
|
222
|
+
"Invalid create_handoff_context arguments",
|
|
223
|
+
);
|
|
224
|
+
} catch {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const summary = normalizeText(requiredArguments.summary);
|
|
229
|
+
const relevantFiles = normalizeStringArray(toolCall.arguments.relevantFiles, MAX_RELEVANT_FILES);
|
|
230
|
+
const nextTask = normalizeText(requiredArguments.nextTask) || goal.trim();
|
|
231
|
+
const openQuestions = normalizeStringArray(toolCall.arguments.openQuestions, MAX_OPEN_QUESTIONS);
|
|
232
|
+
|
|
233
|
+
if (!summary || !nextTask) {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
summary,
|
|
239
|
+
relevantFiles,
|
|
240
|
+
nextTask,
|
|
241
|
+
openQuestions,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function buildContinuityLine(sessionId: string, _sessionPath: string | undefined): string {
|
|
246
|
+
return `Continuing work from session ${sessionId}. When you lack specific information you can use session_ask.`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function normalizeStringArray(value: unknown, limit: number): string[] {
|
|
250
|
+
if (!Array.isArray(value)) {
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const uniqueValues = new Set<string>();
|
|
255
|
+
for (const item of value) {
|
|
256
|
+
const normalized = normalizeText(item);
|
|
257
|
+
if (!normalized) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
uniqueValues.add(normalized);
|
|
262
|
+
if (uniqueValues.size >= limit) {
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return [...uniqueValues];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function normalizeText(value: unknown): string {
|
|
271
|
+
return typeof value === "string" ? value.trim() : "";
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function isCreateHandoffContextToolCall(
|
|
275
|
+
content: TextContent | ThinkingContent | ToolCall,
|
|
276
|
+
): content is ToolCall {
|
|
277
|
+
return content.type === "toolCall" && content.name === "create_handoff_context";
|
|
278
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { CustomEntry, SessionEntry } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
4
|
+
import { safeParseTypeBoxValue } from "../shared/typebox.js";
|
|
5
|
+
|
|
6
|
+
export const HANDOFF_METADATA_CUSTOM_TYPE = "pi-sessions.handoff";
|
|
7
|
+
export const PENDING_SEND_CONSUMED_CUSTOM_TYPE = "pi-sessions.pending-send-consumed";
|
|
8
|
+
|
|
9
|
+
export const HANDOFF_SESSION_METADATA_SCHEMA = Type.Object({
|
|
10
|
+
origin: Type.Literal("handoff"),
|
|
11
|
+
goal: Type.String(),
|
|
12
|
+
nextTask: Type.String(),
|
|
13
|
+
initial_prompt: Type.String(),
|
|
14
|
+
initial_prompt_nonce: Type.String(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const PENDING_SEND_CONSUMED_ENTRY_SCHEMA = Type.Object({
|
|
18
|
+
nonce: Type.String(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type HandoffSessionMetadata = Static<typeof HANDOFF_SESSION_METADATA_SCHEMA>;
|
|
22
|
+
export type PendingSendConsumedEntryData = Static<typeof PENDING_SEND_CONSUMED_ENTRY_SCHEMA>;
|
|
23
|
+
|
|
24
|
+
export function createHandoffSessionMetadata(
|
|
25
|
+
goal: string,
|
|
26
|
+
nextTask: string,
|
|
27
|
+
initialPrompt: string,
|
|
28
|
+
): HandoffSessionMetadata {
|
|
29
|
+
const normalizedGoal = goal.trim();
|
|
30
|
+
const normalizedNextTask = nextTask.trim() || normalizedGoal;
|
|
31
|
+
const normalizedInitialPrompt = initialPrompt.trim();
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
origin: "handoff",
|
|
35
|
+
goal: normalizedGoal,
|
|
36
|
+
nextTask: normalizedNextTask,
|
|
37
|
+
initial_prompt: normalizedInitialPrompt,
|
|
38
|
+
initial_prompt_nonce: randomUUID(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createPendingSendConsumedEntry(nonce: string): PendingSendConsumedEntryData {
|
|
43
|
+
return { nonce };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parsePendingSendConsumedEntry(
|
|
47
|
+
value: unknown,
|
|
48
|
+
): PendingSendConsumedEntryData | undefined {
|
|
49
|
+
return safeParseTypeBoxValue(PENDING_SEND_CONSUMED_ENTRY_SCHEMA, value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseHandoffSessionMetadata(value: unknown): HandoffSessionMetadata | undefined {
|
|
53
|
+
return safeParseTypeBoxValue(HANDOFF_SESSION_METADATA_SCHEMA, value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getPendingInitialPromptFromEntries(
|
|
57
|
+
entries: readonly SessionEntry[],
|
|
58
|
+
): HandoffSessionMetadata | undefined {
|
|
59
|
+
const consumed = new Set<string>();
|
|
60
|
+
let pending: HandoffSessionMetadata | undefined;
|
|
61
|
+
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (entry.type !== "custom") {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const customEntry = entry as CustomEntry;
|
|
68
|
+
if (customEntry.customType === PENDING_SEND_CONSUMED_CUSTOM_TYPE) {
|
|
69
|
+
const consumedEntry = parsePendingSendConsumedEntry(customEntry.data);
|
|
70
|
+
if (consumedEntry) {
|
|
71
|
+
consumed.add(consumedEntry.nonce);
|
|
72
|
+
if (pending?.initial_prompt_nonce === consumedEntry.nonce) {
|
|
73
|
+
pending = undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (customEntry.customType !== HANDOFF_METADATA_CUSTOM_TYPE) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const metadata = parseHandoffSessionMetadata(customEntry.data);
|
|
84
|
+
if (!metadata) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (consumed.has(metadata.initial_prompt_nonce)) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
pending = metadata;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return pending;
|
|
96
|
+
}
|