@plannotator/pi-extension 0.15.0 → 0.15.1

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.
@@ -0,0 +1,95 @@
1
+ // @generated — DO NOT EDIT. Source: packages/ai/base-session.ts
2
+ /**
3
+ * Shared session base class — extracts the common lifecycle, abort, and
4
+ * ID-resolution logic that every AIProvider session needs.
5
+ *
6
+ * Concrete providers extend this and implement query().
7
+ */
8
+
9
+ import type { AIMessage, AISession } from "./types.ts";
10
+
11
+ export abstract class BaseSession implements AISession {
12
+ readonly parentSessionId: string | null;
13
+ onIdResolved?: (oldId: string, newId: string) => void;
14
+
15
+ protected _placeholderId: string;
16
+ protected _resolvedId: string | null = null;
17
+ protected _isActive = false;
18
+ protected _currentAbort: AbortController | null = null;
19
+ protected _queryGen = 0;
20
+ protected _firstQuerySent = false;
21
+
22
+ constructor(opts: { parentSessionId: string | null; initialId?: string }) {
23
+ this.parentSessionId = opts.parentSessionId;
24
+ this._placeholderId = opts.initialId ?? crypto.randomUUID();
25
+ }
26
+
27
+ get id(): string {
28
+ return this._resolvedId ?? this._placeholderId;
29
+ }
30
+
31
+ get isActive(): boolean {
32
+ return this._isActive;
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Query lifecycle helpers — call from concrete query() implementations
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /** Error message returned when a query is already active. */
40
+ static readonly BUSY_ERROR: AIMessage = {
41
+ type: "error",
42
+ error:
43
+ "A query is already in progress. Abort the current query before sending a new one.",
44
+ code: "session_busy",
45
+ };
46
+
47
+ /**
48
+ * Call at the start of query(). Returns the generation number and abort
49
+ * signal, or null if the session is busy.
50
+ */
51
+ protected startQuery(): { gen: number; signal: AbortSignal } | null {
52
+ if (this._isActive) return null;
53
+
54
+ const gen = ++this._queryGen;
55
+ this._isActive = true;
56
+ this._currentAbort = new AbortController();
57
+ return { gen, signal: this._currentAbort.signal };
58
+ }
59
+
60
+ /**
61
+ * Call in the finally block of query(). Only clears state if the
62
+ * generation matches (prevents a stale finally from clobbering a newer query).
63
+ */
64
+ protected endQuery(gen: number): void {
65
+ if (this._queryGen === gen) {
66
+ this._isActive = false;
67
+ this._currentAbort = null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Call when the provider resolves the real session ID from the backend.
73
+ * Fires the onIdResolved callback so the SessionManager can remap its key.
74
+ */
75
+ protected resolveId(newId: string): void {
76
+ if (this._resolvedId) return; // Already resolved
77
+ const oldId = this._placeholderId;
78
+ this._resolvedId = newId;
79
+ this.onIdResolved?.(oldId, newId);
80
+ }
81
+
82
+ /**
83
+ * Abort the current in-flight query. Subclasses should call super.abort()
84
+ * after any provider-specific cleanup.
85
+ */
86
+ abort(): void {
87
+ if (this._currentAbort) {
88
+ this._currentAbort.abort();
89
+ this._isActive = false;
90
+ this._currentAbort = null;
91
+ }
92
+ }
93
+
94
+ abstract query(prompt: string): AsyncIterable<AIMessage>;
95
+ }
@@ -0,0 +1,212 @@
1
+ // @generated — DO NOT EDIT. Source: packages/ai/context.ts
2
+ /**
3
+ * Context builders — translate Plannotator review state into system prompts
4
+ * that give the AI session the right background for answering questions.
5
+ *
6
+ * These are provider-agnostic: any AIProvider implementation can use them
7
+ * to build the system prompt it needs.
8
+ */
9
+
10
+ import type { AIContext } from "./types.ts";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Public API
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /**
17
+ * Build a system prompt from the given context.
18
+ *
19
+ * The prompt tells the AI:
20
+ * - What role it plays (plan reviewer, code reviewer, etc.)
21
+ * - The content it should reference (plan markdown, diff patch, file)
22
+ * - Any annotations the user has already made
23
+ * - That it's operating inside Plannotator (not a general coding session)
24
+ */
25
+ export function buildSystemPrompt(ctx: AIContext): string {
26
+ switch (ctx.mode) {
27
+ case "plan-review":
28
+ return buildPlanReviewPrompt(ctx);
29
+ case "code-review":
30
+ return buildCodeReviewPrompt(ctx);
31
+ case "annotate":
32
+ return buildAnnotatePrompt(ctx);
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Build a compact context summary suitable for injecting into a fork prompt.
38
+ *
39
+ * When forking from a parent session, we don't need a full system prompt
40
+ * (the parent's history already provides context). Instead, we inject a
41
+ * short "you are now in Plannotator" preamble with the relevant content.
42
+ */
43
+ export function buildForkPreamble(ctx: AIContext): string {
44
+ const lines: string[] = [
45
+ "The user is now reviewing your work in Plannotator and has a question.",
46
+ "Answer concisely based on the conversation history and the context below.",
47
+ "",
48
+ ];
49
+
50
+ switch (ctx.mode) {
51
+ case "plan-review": {
52
+ lines.push("## Current Plan Under Review");
53
+ lines.push("");
54
+ lines.push(truncate(ctx.plan.plan, MAX_PLAN_CHARS));
55
+ if (ctx.plan.annotations) {
56
+ lines.push("");
57
+ lines.push("## User Annotations So Far");
58
+ lines.push(ctx.plan.annotations);
59
+ }
60
+ break;
61
+ }
62
+ case "code-review": {
63
+ if (ctx.review.filePath) {
64
+ lines.push(`## Reviewing: ${ctx.review.filePath}`);
65
+ }
66
+ if (ctx.review.selectedCode) {
67
+ lines.push("");
68
+ lines.push("### Selected Code");
69
+ lines.push("```");
70
+ lines.push(ctx.review.selectedCode);
71
+ lines.push("```");
72
+ }
73
+ if (ctx.review.lineRange) {
74
+ const { start, end, side } = ctx.review.lineRange;
75
+ lines.push(`Lines ${start}-${end} (${side} side)`);
76
+ }
77
+ lines.push("");
78
+ lines.push("## Diff Patch");
79
+ lines.push("```diff");
80
+ lines.push(truncate(ctx.review.patch, MAX_DIFF_CHARS));
81
+ lines.push("```");
82
+ if (ctx.review.annotations) {
83
+ lines.push("");
84
+ lines.push("## User Annotations So Far");
85
+ lines.push(ctx.review.annotations);
86
+ }
87
+ break;
88
+ }
89
+ case "annotate": {
90
+ lines.push(`## Annotating: ${ctx.annotate.filePath}`);
91
+ lines.push("");
92
+ lines.push(truncate(ctx.annotate.content, MAX_PLAN_CHARS));
93
+ if (ctx.annotate.annotations) {
94
+ lines.push("");
95
+ lines.push("## User Annotations So Far");
96
+ lines.push(ctx.annotate.annotations);
97
+ }
98
+ break;
99
+ }
100
+ }
101
+
102
+ return lines.join("\n");
103
+ }
104
+
105
+ /**
106
+ * Build the effective prompt for a query, prepending a preamble on the first
107
+ * message. Used by providers that inject context via the prompt itself (Codex,
108
+ * Pi) rather than a separate system-prompt channel (Claude).
109
+ */
110
+ export function buildEffectivePrompt(
111
+ userPrompt: string,
112
+ preamble: string | null,
113
+ firstQuerySent: boolean,
114
+ ): string {
115
+ if (!firstQuerySent && preamble) {
116
+ return `${preamble}\n\n---\n\nUser question: ${userPrompt}`;
117
+ }
118
+ return userPrompt;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Internals
123
+ // ---------------------------------------------------------------------------
124
+
125
+ const MAX_PLAN_CHARS = 60_000;
126
+ const MAX_DIFF_CHARS = 40_000;
127
+
128
+ function truncate(text: string, max: number): string {
129
+ if (text.length <= max) return text;
130
+ return `${text.slice(0, max)}\n\n... [truncated for context window]`;
131
+ }
132
+
133
+ function buildPlanReviewPrompt(
134
+ ctx: Extract<AIContext, { mode: "plan-review" }>
135
+ ): string {
136
+ const sections: string[] = [
137
+ "The user is reviewing an implementation plan in Plannotator.",
138
+ "",
139
+ "## Plan Under Review",
140
+ "",
141
+ truncate(ctx.plan.plan, MAX_PLAN_CHARS),
142
+ ];
143
+
144
+ if (ctx.plan.previousPlan) {
145
+ sections.push("");
146
+ sections.push("## Previous Plan Version (for reference)");
147
+ sections.push(truncate(ctx.plan.previousPlan, MAX_PLAN_CHARS / 2));
148
+ }
149
+
150
+ if (ctx.plan.annotations) {
151
+ sections.push("");
152
+ sections.push("## User Annotations");
153
+ sections.push(ctx.plan.annotations);
154
+ }
155
+
156
+ return sections.join("\n");
157
+ }
158
+
159
+ function buildCodeReviewPrompt(
160
+ ctx: Extract<AIContext, { mode: "code-review" }>
161
+ ): string {
162
+ const sections: string[] = [
163
+ "The user is reviewing a code diff in Plannotator.",
164
+ ];
165
+
166
+ if (ctx.review.filePath) {
167
+ sections.push("");
168
+ sections.push(`## Currently Viewing: ${ctx.review.filePath}`);
169
+ }
170
+
171
+ if (ctx.review.selectedCode) {
172
+ sections.push("");
173
+ sections.push("## Selected Code");
174
+ sections.push("```");
175
+ sections.push(ctx.review.selectedCode);
176
+ sections.push("```");
177
+ }
178
+
179
+ sections.push("");
180
+ sections.push("## Diff");
181
+ sections.push("```diff");
182
+ sections.push(truncate(ctx.review.patch, MAX_DIFF_CHARS));
183
+ sections.push("```");
184
+
185
+ if (ctx.review.annotations) {
186
+ sections.push("");
187
+ sections.push("## User Annotations");
188
+ sections.push(ctx.review.annotations);
189
+ }
190
+
191
+ return sections.join("\n");
192
+ }
193
+
194
+ function buildAnnotatePrompt(
195
+ ctx: Extract<AIContext, { mode: "annotate" }>
196
+ ): string {
197
+ const sections: string[] = [
198
+ "The user is annotating a markdown document in Plannotator.",
199
+ "",
200
+ `## Document: ${ctx.annotate.filePath}`,
201
+ "",
202
+ truncate(ctx.annotate.content, MAX_PLAN_CHARS),
203
+ ];
204
+
205
+ if (ctx.annotate.annotations) {
206
+ sections.push("");
207
+ sections.push("## User Annotations");
208
+ sections.push(ctx.annotate.annotations);
209
+ }
210
+
211
+ return sections.join("\n");
212
+ }
@@ -0,0 +1,309 @@
1
+ // @generated — DO NOT EDIT. Source: packages/ai/endpoints.ts
2
+ /**
3
+ * HTTP endpoint handlers for AI features.
4
+ *
5
+ * These handlers are provider-agnostic — they work with whatever AIProvider
6
+ * is registered in the provided ProviderRegistry. They're designed to be
7
+ * mounted into any Plannotator server (plan review, code review, annotate).
8
+ *
9
+ * Endpoints:
10
+ * POST /api/ai/session — Create or fork an AI session
11
+ * POST /api/ai/query — Send a message and stream the response
12
+ * POST /api/ai/abort — Abort the current query
13
+ * GET /api/ai/sessions — List active sessions
14
+ * GET /api/ai/capabilities — Check if AI features are available
15
+ */
16
+
17
+ import type { AIContext, AIMessage, CreateSessionOptions } from "./types.ts";
18
+ import type { ProviderRegistry } from "./provider.ts";
19
+ import type { SessionManager } from "./session-manager.ts";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Types for request/response
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export interface CreateSessionRequest {
26
+ /** The context mode and content for the session. */
27
+ context: AIContext;
28
+ /** Instance ID of the provider to use (optional — uses default if omitted). */
29
+ providerId?: string;
30
+ /** Optional model override. */
31
+ model?: string;
32
+ /** Max agentic turns. */
33
+ maxTurns?: number;
34
+ /** Max budget in USD. */
35
+ maxBudgetUsd?: number;
36
+ /** Reasoning effort (Codex only). */
37
+ reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
38
+ }
39
+
40
+ export interface QueryRequest {
41
+ /** The session ID to query. */
42
+ sessionId: string;
43
+ /** The user's prompt/question. */
44
+ prompt: string;
45
+ /** Optional context update (e.g., new annotations since session was created). */
46
+ contextUpdate?: string;
47
+ }
48
+
49
+ export interface AbortRequest {
50
+ /** The session ID to abort. */
51
+ sessionId: string;
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Handler factory
56
+ // ---------------------------------------------------------------------------
57
+
58
+ export interface AIEndpointDeps {
59
+ /** Provider registry (one per server or shared). */
60
+ registry: ProviderRegistry;
61
+ /** Session manager instance (one per server). */
62
+ sessionManager: SessionManager;
63
+ /** Resolve the current working directory for new AI sessions. */
64
+ getCwd?: () => string;
65
+ }
66
+
67
+ /**
68
+ * Create the route handler map for AI endpoints.
69
+ *
70
+ * Usage in a Bun server:
71
+ * ```ts
72
+ * const aiHandlers = createAIEndpoints({ registry, sessionManager });
73
+ *
74
+ * // In your request handler:
75
+ * if (url.pathname.startsWith('/api/ai/')) {
76
+ * const handler = aiHandlers[url.pathname];
77
+ * if (handler) return handler(req);
78
+ * }
79
+ * ```
80
+ */
81
+ export function createAIEndpoints(deps: AIEndpointDeps) {
82
+ const { registry, sessionManager, getCwd } = deps;
83
+
84
+ return {
85
+ "/api/ai/capabilities": async (_req: Request) => {
86
+ const defaultEntry = registry.getDefault();
87
+ const providerDetails = registry.list().map(id => {
88
+ const p = registry.get(id)!;
89
+ return {
90
+ id,
91
+ name: p.name,
92
+ capabilities: p.capabilities,
93
+ models: p.models ?? [],
94
+ };
95
+ });
96
+ return Response.json({
97
+ available: !!defaultEntry,
98
+ providers: providerDetails,
99
+ defaultProvider: defaultEntry?.id ?? null,
100
+ });
101
+ },
102
+
103
+ "/api/ai/session": async (req: Request) => {
104
+ if (req.method !== "POST") {
105
+ return new Response("Method not allowed", { status: 405 });
106
+ }
107
+
108
+ const body = (await req.json()) as CreateSessionRequest;
109
+ const { context, providerId, model, maxTurns, maxBudgetUsd, reasoningEffort } = body;
110
+
111
+ if (!context?.mode) {
112
+ return Response.json(
113
+ { error: "Missing context.mode" },
114
+ { status: 400 }
115
+ );
116
+ }
117
+
118
+ // Resolve provider: by ID, or default
119
+ const provider = providerId
120
+ ? registry.get(providerId)
121
+ : registry.getDefault()?.provider;
122
+
123
+ if (!provider) {
124
+ return Response.json(
125
+ { error: providerId ? `Provider "${providerId}" not found` : "No AI provider available" },
126
+ { status: 503 }
127
+ );
128
+ }
129
+
130
+ try {
131
+ const options: CreateSessionOptions = {
132
+ context,
133
+ cwd: getCwd?.(),
134
+ model,
135
+ maxTurns,
136
+ maxBudgetUsd,
137
+ reasoningEffort,
138
+ };
139
+
140
+ // Fork if parent session is provided AND provider supports it.
141
+ // Providers that can't fork (e.g. Codex) fall back to a fresh
142
+ // session with the full system prompt — no fake history.
143
+ const shouldFork = context.parent && provider.capabilities.fork;
144
+ const session = shouldFork
145
+ ? await provider.forkSession(options)
146
+ : await provider.createSession(options);
147
+
148
+ const entry = sessionManager.track(session, context.mode);
149
+
150
+ return Response.json({
151
+ sessionId: session.id,
152
+ parentSessionId: session.parentSessionId,
153
+ mode: context.mode,
154
+ createdAt: entry.createdAt,
155
+ });
156
+ } catch (err) {
157
+ return Response.json(
158
+ {
159
+ error:
160
+ err instanceof Error ? err.message : "Failed to create session",
161
+ },
162
+ { status: 500 }
163
+ );
164
+ }
165
+ },
166
+
167
+ "/api/ai/query": async (req: Request) => {
168
+ if (req.method !== "POST") {
169
+ return new Response("Method not allowed", { status: 405 });
170
+ }
171
+
172
+ const body = (await req.json()) as QueryRequest;
173
+ const { sessionId, prompt, contextUpdate } = body;
174
+
175
+ if (!sessionId || !prompt) {
176
+ return Response.json(
177
+ { error: "Missing sessionId or prompt" },
178
+ { status: 400 }
179
+ );
180
+ }
181
+
182
+ const entry = sessionManager.get(sessionId);
183
+ if (!entry) {
184
+ return Response.json(
185
+ { error: "Session not found" },
186
+ { status: 404 }
187
+ );
188
+ }
189
+
190
+ sessionManager.touch(sessionId);
191
+
192
+ // If context update provided, prepend it to the prompt
193
+ const effectivePrompt = contextUpdate
194
+ ? `[Context update: the user has made changes since this conversation started]\n${contextUpdate}\n\n${prompt}`
195
+ : prompt;
196
+
197
+ // Set label from first query if not already set
198
+ if (!entry.label) {
199
+ entry.label = prompt.slice(0, 80);
200
+ }
201
+
202
+ // Stream the response using Server-Sent Events (SSE)
203
+ const encoder = new TextEncoder();
204
+ const stream = new ReadableStream({
205
+ async start(controller) {
206
+ try {
207
+ for await (const message of entry.session.query(effectivePrompt)) {
208
+ const data = JSON.stringify(message);
209
+ controller.enqueue(
210
+ encoder.encode(`data: ${data}\n\n`)
211
+ );
212
+ }
213
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
214
+ } catch (err) {
215
+ const errorMsg: AIMessage = {
216
+ type: "error",
217
+ error: err instanceof Error ? err.message : String(err),
218
+ code: "stream_error",
219
+ };
220
+ controller.enqueue(
221
+ encoder.encode(`data: ${JSON.stringify(errorMsg)}\n\n`)
222
+ );
223
+ } finally {
224
+ controller.close();
225
+ }
226
+ },
227
+ });
228
+
229
+ return new Response(stream, {
230
+ headers: {
231
+ "Content-Type": "text/event-stream",
232
+ "Cache-Control": "no-cache",
233
+ Connection: "keep-alive",
234
+ },
235
+ });
236
+ },
237
+
238
+ "/api/ai/abort": async (req: Request) => {
239
+ if (req.method !== "POST") {
240
+ return new Response("Method not allowed", { status: 405 });
241
+ }
242
+
243
+ const body = (await req.json()) as AbortRequest;
244
+ const entry = sessionManager.get(body.sessionId);
245
+ if (!entry) {
246
+ return Response.json(
247
+ { error: "Session not found" },
248
+ { status: 404 }
249
+ );
250
+ }
251
+
252
+ entry.session.abort();
253
+ return Response.json({ ok: true });
254
+ },
255
+
256
+ "/api/ai/permission": async (req: Request) => {
257
+ if (req.method !== "POST") {
258
+ return new Response("Method not allowed", { status: 405 });
259
+ }
260
+
261
+ const body = (await req.json()) as {
262
+ sessionId: string;
263
+ requestId: string;
264
+ allow: boolean;
265
+ message?: string;
266
+ };
267
+
268
+ if (!body.sessionId || !body.requestId) {
269
+ return Response.json(
270
+ { error: "Missing sessionId or requestId" },
271
+ { status: 400 }
272
+ );
273
+ }
274
+
275
+ const entry = sessionManager.get(body.sessionId);
276
+ if (!entry) {
277
+ return Response.json(
278
+ { error: "Session not found" },
279
+ { status: 404 }
280
+ );
281
+ }
282
+
283
+ entry.session.respondToPermission?.(
284
+ body.requestId,
285
+ body.allow,
286
+ body.message
287
+ );
288
+
289
+ return Response.json({ ok: true });
290
+ },
291
+
292
+ "/api/ai/sessions": async (_req: Request) => {
293
+ const entries = sessionManager.list();
294
+ return Response.json(
295
+ entries.map((e) => ({
296
+ sessionId: e.session.id,
297
+ mode: e.mode,
298
+ parentSessionId: e.parentSessionId,
299
+ createdAt: e.createdAt,
300
+ lastActiveAt: e.lastActiveAt,
301
+ isActive: e.session.isActive,
302
+ label: e.label,
303
+ }))
304
+ );
305
+ },
306
+ } as const;
307
+ }
308
+
309
+ export type AIEndpoints = ReturnType<typeof createAIEndpoints>;