@plannotator/pi-extension 0.15.0 → 0.15.2
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/README.md +1 -1
- package/generated/ai/base-session.ts +95 -0
- package/generated/ai/context.ts +212 -0
- package/generated/ai/endpoints.ts +309 -0
- package/generated/ai/index.ts +106 -0
- package/generated/ai/provider.ts +104 -0
- package/generated/ai/providers/claude-agent-sdk.ts +441 -0
- package/generated/ai/providers/codex-sdk.ts +430 -0
- package/generated/ai/providers/opencode-sdk.ts +491 -0
- package/generated/ai/providers/pi-events.ts +111 -0
- package/generated/ai/providers/pi-sdk-node.ts +377 -0
- package/generated/ai/providers/pi-sdk.ts +442 -0
- package/generated/ai/session-manager.ts +196 -0
- package/generated/ai/types.ts +370 -0
- package/generated/resolve-file.ts +28 -0
- package/index.ts +74 -45
- package/package.json +2 -2
- package/plannotator.html +70 -70
- package/review-editor.html +2 -2
- package/server/serverAnnotate.ts +2 -1
- package/server/serverReview.ts +5 -5
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ In plan mode the agent is restricted — destructive commands are blocked, write
|
|
|
55
55
|
- [ ] Update error messages in the UI
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
When the agent calls `
|
|
58
|
+
When the agent calls `plannotator_submit_plan`, the Plannotator UI opens in your browser. You can:
|
|
59
59
|
|
|
60
60
|
- **Approve** the plan to begin execution
|
|
61
61
|
- **Deny with annotations** to send structured feedback back to the agent
|
|
@@ -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>;
|