pi-codebuddy-sdk 0.1.11 → 0.1.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codebuddy-sdk",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "CodeBuddy Agent SDK provider for Pi — native tool execution, 29+ models, thinking controls, session pool",
5
5
  "author": "slahser",
6
6
  "license": "MIT",
package/src/provider.ts CHANGED
@@ -1,3 +1,12 @@
1
+ /**
2
+ * CodeBuddy Provider — stateless, lightweight.
3
+ *
4
+ * Uses sdk.query() (one-shot) — no context building, no session pool,
5
+ * no tool boundary. Pi manages conversation, CodeBuddy is just a model backend.
6
+ *
7
+ * Architecture follows pi-openai-compatible / pi-anthropic pattern:
8
+ * extract latest user message → send to SDK → stream events back to Pi.
9
+ */
1
10
  import {
2
11
  createAssistantMessageEventStream,
3
12
  type Api,
@@ -16,8 +25,6 @@ import type {
16
25
  Effort,
17
26
  RawMessageStreamEvent,
18
27
  } from "@tencent-ai/agent-sdk";
19
- import { buildFullMessage, buildDeltaMessage } from "./context.js";
20
- import { acquireSession, evictSession } from "./session-pool.js";
21
28
 
22
29
  type CodebuddySdk = typeof import("@tencent-ai/agent-sdk");
23
30
 
@@ -70,13 +77,83 @@ function extractUsageUpdate(msg: CbMessage): Partial<AssistantMessage> | null {
70
77
  return null;
71
78
  }
72
79
 
80
+ // ── extract latest user message ──
81
+
82
+ function extractLatestUserText(context: Context): string {
83
+ const messages = context.messages;
84
+ for (let i = messages.length - 1; i >= 0; i--) {
85
+ if (messages[i].role === "user") {
86
+ let raw = extractRawContent((messages[i] as any).content);
87
+ if (!raw) continue;
88
+ // Strip context-mode session state injection prepended to user messages.
89
+ // Pi's context-mode adds its hierarchy/state before the actual user input.
90
+ raw = stripContextModeInjection(raw);
91
+ if (raw) return raw;
92
+ }
93
+ }
94
+ return "Continue.";
95
+ }
96
+
97
+ function extractRawContent(content: unknown): string {
98
+ if (typeof content === "string") return content;
99
+ if (!Array.isArray(content)) return "";
100
+ return content
101
+ .filter((c: any) => c?.type === "text")
102
+ .map((c: any) => c.text)
103
+ .join("\n");
104
+ }
105
+
106
+ /**
107
+ * Pi context-mode prepends its session state to user messages.
108
+ * Strip it to keep CodeBuddy from treating it as conversation content.
109
+ *
110
+ * Injected format:
111
+ * context-mode active. Hierarchy: ...
112
+ * <session_state source="compaction">
113
+ * <session_mode>investigate</session_mode>
114
+ * </session_state>
115
+ * [actual user message]
116
+ */
117
+ function stripContextModeInjection(text: string): string {
118
+ // Find the end of context-mode injection
119
+ // Pattern: </session_state> followed by newlines then actual message
120
+ const sessionEnd = text.lastIndexOf("</session_state>");
121
+ if (sessionEnd >= 0) {
122
+ const after = text.slice(sessionEnd + "</session_state>".length).trimStart();
123
+ if (after) return after;
124
+ }
125
+
126
+ // Fallback: look for "context-mode active" header and take everything after it + session_state
127
+ const cmStart = text.indexOf("context-mode active.");
128
+ if (cmStart >= 0) {
129
+ // Find first blank line after </session_state> or end of XML
130
+ const afterSession = afterSessionState(text);
131
+ if (afterSession) return afterSession;
132
+ }
133
+
134
+ return text;
135
+ }
136
+
137
+ function afterSessionState(text: string): string | null {
138
+ // Try to find the actual user message after context-mode XML injection
139
+ const patterns = [
140
+ /context-mode active\.[\s\S]*?<\/session_state>\s*\n+([\s\S]+)$/,
141
+ /<session_state[\s\S]*?<\/session_state>\s*\n+([\s\S]+)$/,
142
+ ];
143
+ for (const re of patterns) {
144
+ const m = text.match(re);
145
+ if (m?.[1]?.trim()) return m[1].trim();
146
+ }
147
+ return null;
148
+ }
149
+
73
150
  // ── thinking config mapping ──
74
151
 
75
152
  const DEFAULT_THINKING_BUDGETS: Record<string, number> = {
76
153
  minimal: 1600,
77
154
  low: 4000,
78
- medium: 0, // adaptive
79
- high: 0, // adaptive
155
+ medium: 0,
156
+ high: 0,
80
157
  xhigh: 32000,
81
158
  };
82
159
 
@@ -114,53 +191,35 @@ export function streamCodebuddy(
114
191
  const partial = makePartial(model);
115
192
 
116
193
  queueMicrotask(async () => {
117
- let session: Awaited<ReturnType<CodebuddySdk["unstable_v2_createSession"]>> | undefined;
118
- let isFirst = false;
119
-
120
194
  try {
121
195
  stream.push({ type: "start", partial });
122
196
 
123
197
  const sdk = await loadCodebuddySdk();
198
+ const userText = extractLatestUserText(context);
124
199
 
125
200
  const { thinking, effort } = buildThinkingConfig(
126
201
  options?.reasoning,
127
202
  options?.thinkingBudgets,
128
203
  );
129
204
 
130
- // Always request partial messages so we get stream_event deltas for real streaming.
131
- // Non-streaming-capable models will still emit assistant messages as fallback.
132
- const includePartial = true;
133
-
134
- const cwd = process.cwd();
135
-
136
- const { session: pooled, message: userMessage, isFirst: first } =
137
- await acquireSession(cwd, sdk, context, buildFullMessage, buildDeltaMessage, {
205
+ const q = sdk.query({
206
+ prompt: userText,
207
+ options: {
138
208
  model: model.id,
139
209
  permissionMode: "bypassPermissions",
140
210
  maxTurns: 100,
141
211
  thinking,
142
212
  effort,
143
- includePartialMessages: includePartial,
144
- });
145
-
146
- session = pooled;
147
- isFirst = first;
148
-
149
- let msgCount = 0; // track content blocks for empty-response detection
150
- let hasStreamEvents = false; // true once we see first stream_event
151
-
152
- // Per-index tracking for stream_event delta accumulation
153
- type BlockKind = 'text' | 'thinking' | 'tool_use' | 'redacted_thinking';
154
- const blockKind = new Map<number, BlockKind>();
155
- const blockAccum = new Map<number, string>();
156
- const blockMeta = new Map<number, { id: string; name: string; signature?: string }>();
213
+ includePartialMessages: true,
214
+ },
215
+ });
157
216
 
158
217
  // ── abort handling ──
159
218
  let aborted = false;
160
219
  const onAbort = async () => {
161
220
  if (aborted) return;
162
221
  aborted = true;
163
- try { await session!.interrupt(); } catch { /* best-effort */ }
222
+ try { await q.interrupt(); } catch { /* best-effort */ }
164
223
  partial.stopReason = "aborted";
165
224
  stream.push({ type: "error", reason: "aborted", error: partial });
166
225
  };
@@ -172,10 +231,14 @@ export function streamCodebuddy(
172
231
  }
173
232
  }
174
233
 
175
- // ── send prompt ──
176
- await session.send(userMessage);
234
+ // ── per-index block tracking ──
235
+ type BlockKind = 'text' | 'thinking' | 'tool_use' | 'redacted_thinking';
236
+ const blockKind = new Map<number, BlockKind>();
237
+ const blockAccum = new Map<number, string>();
238
+ const blockMeta = new Map<number, { id: string; name: string; signature?: string }>();
239
+
240
+ let hasStreamEvents = false;
177
241
 
178
- // ── helpers for stream_event block tracking ──
179
242
  function onBlockStart(idx: number, kind: BlockKind, initText: string, meta?: { id: string; name: string; signature?: string }) {
180
243
  blockKind.set(idx, kind);
181
244
  blockAccum.set(idx, initText);
@@ -186,22 +249,18 @@ export function streamCodebuddy(
186
249
  partial.content = [...partial.content, tc];
187
250
  stream.push({ type: 'thinking_start', contentIndex: idx, partial });
188
251
  stream.push({ type: 'thinking_end', contentIndex: idx, content: '[redacted]', partial });
189
- msgCount++;
190
252
  } else if (kind === 'thinking') {
191
253
  const tc: ThinkingContent = { type: 'thinking', thinking: initText, thinkingSignature: meta?.signature };
192
254
  partial.content = [...partial.content, tc];
193
255
  stream.push({ type: 'thinking_start', contentIndex: idx, partial });
194
- msgCount++;
195
256
  } else if (kind === 'text') {
196
257
  const tb: TextContent = { type: 'text', text: initText };
197
258
  partial.content = [...partial.content, tb];
198
259
  stream.push({ type: 'text_start', contentIndex: idx, partial });
199
- msgCount++;
200
260
  } else if (kind === 'tool_use' && meta) {
201
261
  const toolCall: ToolCall = { type: 'toolCall', id: meta.id, name: meta.name, arguments: {} };
202
262
  partial.content = [...partial.content, toolCall];
203
263
  stream.push({ type: 'toolcall_start', contentIndex: idx, partial });
204
- msgCount++;
205
264
  }
206
265
  }
207
266
 
@@ -229,7 +288,7 @@ export function streamCodebuddy(
229
288
  const blocks = [...partial.content];
230
289
  try {
231
290
  blocks[idx] = { ...blocks[idx], arguments: JSON.parse(newJson) };
232
- } catch { /* partial JSON — keep previous */ }
291
+ } catch { /* partial JSON */ }
233
292
  partial.content = blocks;
234
293
  stream.push({ type: 'toolcall_delta', contentIndex: idx, delta: deltaValue, partial } as any);
235
294
  }
@@ -248,8 +307,8 @@ export function streamCodebuddy(
248
307
  }
249
308
  }
250
309
 
251
- // ── iterate responses ──
252
- for await (const msg of session.stream()) {
310
+ // ── iterate ──
311
+ for await (const msg of q) {
253
312
  if (aborted) return;
254
313
 
255
314
  if (msg.type === "system") {
@@ -257,7 +316,6 @@ export function streamCodebuddy(
257
316
  continue;
258
317
  }
259
318
 
260
- // Primary path: token-level streaming via stream_event
261
319
  if (msg.type === "stream_event") {
262
320
  hasStreamEvents = true;
263
321
  const evt = msg.event;
@@ -279,21 +337,19 @@ export function streamCodebuddy(
279
337
  if (d.type === 'text_delta') onBlockDelta(evt.index, 'text_delta', d.text);
280
338
  else if (d.type === 'thinking_delta') onBlockDelta(evt.index, 'thinking_delta', d.thinking);
281
339
  else if (d.type === 'input_json_delta') onBlockDelta(evt.index, 'input_json_delta', d.partial_json);
282
- // ponytail: signature_delta not surfaced to Pi — signature only sent at block start
283
340
  } else if (evt.type === "content_block_stop") {
284
341
  onBlockStop(evt.index);
285
342
  }
286
343
  continue;
287
344
  }
288
345
 
289
- // Fallback: full assistant messages (used when model doesn't support streaming)
346
+ // Fallback: full assistant messages
290
347
  if (msg.type === "assistant" && !hasStreamEvents) {
291
- msgCount++;
292
348
  let ci = 0;
293
349
  for (const block of msg.message.content) {
294
350
  const idx = ci++;
295
351
  if (block.type === "thinking") {
296
- onBlockStart(idx, 'thinking', block.thinking, { id: '', name: '', signature: undefined });
352
+ onBlockStart(idx, 'thinking', block.thinking);
297
353
  onBlockStop(idx);
298
354
  } else if (block.type === "redacted_thinking") {
299
355
  onBlockStart(idx, 'redacted_thinking', '[redacted]', { id: '', name: '', signature: block.data });
@@ -319,24 +375,13 @@ export function streamCodebuddy(
319
375
  partial.stopReason = "error";
320
376
  partial.errorMessage = msg.errors?.join("; ") ?? `CodeBuddy error: ${msg.subtype}`;
321
377
  stream.push({ type: "error", reason: "error", error: { ...partial } });
322
- evictSession(cwd);
323
378
  }
324
379
  break;
325
380
  }
326
381
  }
327
-
328
- // Some models (Hunyuan) return empty on session reuse → evict for fresh session next turn
329
- if (msgCount === 0) {
330
- evictSession(cwd);
331
- }
332
382
  } catch (error) {
333
- evictSession(process.cwd());
334
383
  const errPartial = makeErrorPartial(model, error);
335
384
  stream.push({ type: "error", reason: "error", error: errPartial });
336
- } finally {
337
- // ponytail: do NOT close pooled sessions — they are reused across turns.
338
- // Only close when evicted (via evictSession).
339
- // session?.close() intentionally omitted here.
340
385
  }
341
386
  });
342
387
 
package/src/context.ts DELETED
@@ -1,238 +0,0 @@
1
- import type { Context, ImageContent, Message } from "@earendil-works/pi-ai/compat";
2
- import { convertToLlm } from "@earendil-works/pi-coding-agent";
3
- import type { UserMessage as CbUserMessage, ContentBlock } from "@tencent-ai/agent-sdk";
4
-
5
- // ── public API ──
6
-
7
- export function buildFullMessage(context: Context): string | CbUserMessage {
8
- return buildMessage(context, 0);
9
- }
10
-
11
- export function buildDeltaMessage(context: Context, startIndex: number): string | CbUserMessage {
12
- return buildMessage(context, startIndex);
13
- }
14
-
15
- // ── tool boundary (like pi-cursor-sdk's getCursorToolBoundaryText) ──
16
-
17
- const CODEBUDDY_TOOL_BOUNDARY = [
18
- "CodeBuddy SDK tool boundary:",
19
- "Call only CodeBuddy SDK native tools (Read, Write, Bash, Grep, Edit, Glob, etc.).",
20
- "Do not call any Pi-ecosystem tools — they are not registered in this session.",
21
- "Tool names from conversation history refer to a prior provider; they are unavailable now.",
22
- "CodeBuddy executes tools locally on the filesystem.",
23
- ].join("\n");
24
-
25
- const CODEBUDDY_TOOL_TAIL = [
26
- "Answer the latest user request above using CodeBuddy SDK tools and capabilities.",
27
- "Shell: cd to repo path for project commands; session cwd may differ from tool args.",
28
- "Exact-output requests: output exactly the requested text; no preamble or checks unless asked.",
29
- "Tools: call available CodeBuddy SDK tools; never print tool cards or names as assistant text.",
30
- ].join("\n");
31
-
32
- // ── system prompt sanitization (like pi-cursor-sdk's sanitizeSystemPromptForCursor) ──
33
-
34
- function sanitizeSystemPrompt(prompt: string): string {
35
- let s = prompt;
36
-
37
- // Strip Pi tool catalog section
38
- s = s.replace(
39
- /Available tools:\n[\s\S]*?\n\nIn addition to the tools above[\s\S]*?\n\n/g,
40
- "Pi tool catalog omitted: CodeBuddy can call only CodeBuddy SDK tools.\n\n",
41
- );
42
-
43
- // Strip Guidelines section (Pi-specific guidelines)
44
- s = s.replace(
45
- /Guidelines:\n[\s\S]*?\n\nPi documentation /g,
46
- "Guidelines:\n- Be concise in your responses.\n- Show file paths clearly when working with files.\n\nPi documentation ",
47
- );
48
-
49
- // Strip semantic code intelligence priority section
50
- s = s.replace(/\n+Semantic code intelligence priority:[\s\S]*$/g, "");
51
-
52
- return s.trim();
53
- }
54
-
55
- // ── message formatting ──
56
-
57
- function formatMessage(msg: Message): string | undefined {
58
- switch (msg.role) {
59
- case "user": {
60
- const text = extractTextContent(msg.content);
61
- const hasImages = extractImages(msg).length > 0;
62
- if (text) {
63
- return `User: ${text}${hasImages ? " [image omitted]" : ""}`;
64
- }
65
- if (hasImages) return "User: [image omitted]";
66
- return undefined;
67
- }
68
- case "assistant": {
69
- const blocks = Array.isArray(msg.content) ? msg.content : [];
70
- const parts: string[] = [];
71
- for (const block of blocks) {
72
- if (typeof block === "object" && block !== null && "type" in block) {
73
- if (block.type === "text") {
74
- parts.push((block as { text: string }).text);
75
- } else if (block.type === "toolCall") {
76
- const tc = block as { name: string; arguments: Record<string, unknown> };
77
- parts.push(`[Tool used: ${tc.name}(${JSON.stringify(tc.arguments)})]`);
78
- }
79
- // Skip thinking content blocks
80
- }
81
- }
82
- return parts.length > 0 ? `Assistant: ${parts.join("\n")}` : undefined;
83
- }
84
- case "toolResult": {
85
- const text = extractTextContent(msg.content);
86
- const label = msg.isError ? "Tool error" : "Tool result";
87
- const snippet = truncateToolResult(text);
88
- return `[${label} (${msg.toolName}): ${snippet}]`;
89
- }
90
- default:
91
- return undefined;
92
- }
93
- }
94
-
95
- // ── truncation ──
96
-
97
- const TOOL_RESULT_HEAD = 1200;
98
- const TOOL_RESULT_TAIL = 600;
99
-
100
- function truncateToolResult(text: string): string {
101
- if (!text) return "(empty)";
102
- if (text.length <= TOOL_RESULT_HEAD + TOOL_RESULT_TAIL + 50) return text;
103
- const head = text.slice(0, TOOL_RESULT_HEAD);
104
- const tail = text.slice(-TOOL_RESULT_TAIL);
105
- const skipped = text.length - TOOL_RESULT_HEAD - TOOL_RESULT_TAIL;
106
- return `${head}\n...[${skipped.toLocaleString()} chars skipped]...\n${tail}`;
107
- }
108
-
109
- // ── main builder ──
110
-
111
- function buildMessage(context: Context, startIndex: number): string | CbUserMessage {
112
- const parts: string[] = [];
113
-
114
- // 1. Tool boundary (always first)
115
- parts.push(CODEBUDDY_TOOL_BOUNDARY);
116
-
117
- // 2. Sanitized system prompt (first turn only — like pi-cursor-sdk)
118
- if (startIndex === 0 && context.systemPrompt) {
119
- const sanitized = sanitizeSystemPrompt(context.systemPrompt);
120
- if (sanitized) {
121
- parts.push(`System instructions from pi:\n${sanitized}`);
122
- }
123
- }
124
-
125
- // 3. Normalize and format messages (filter out non-LLM roles via convertToLlm)
126
- const rawMessages = context.messages as Parameters<typeof convertToLlm>[0];
127
- let llmMessages = convertToLlm(rawMessages);
128
-
129
- // ponytail: filter compaction/branch summary user messages on first turn.
130
- // convertToLlm wraps them as user messages with <summary> tags; Hunyuan
131
- // parrots context-mode state from them. Smarter models follow boundary text.
132
- if (startIndex === 0) {
133
- llmMessages = llmMessages.filter((m) => {
134
- if (m.role !== "user") return true;
135
- const text = extractTextContent(m.content);
136
- // Compaction summaries are large blocks with context-mode state
137
- if (text.includes("<summary>") || text.includes("compacted into")) return false;
138
- // context-mode session state injection
139
- if (text.includes("context-mode active") || text.includes("<session_state")) return false;
140
- return true;
141
- });
142
- }
143
- const latestUserIdx = findLatestUserIndex(llmMessages);
144
- const msgParts: string[] = [];
145
- for (let i = startIndex; i < llmMessages.length; i++) {
146
- if (i === latestUserIdx) continue; // handled separately (images etc.)
147
- const formatted = formatMessage(llmMessages[i]);
148
- if (formatted) msgParts.push(formatted);
149
- }
150
-
151
- // 4. Latest user message
152
- const lastUser = latestUserIdx >= 0 ? llmMessages[latestUserIdx] : null;
153
- const lastUserText = lastUser ? extractTextContent(lastUser.content) : "Continue.";
154
- const images = lastUser ? extractImages(lastUser) : [];
155
-
156
- // 5. Tool tail guard
157
- parts.push(
158
- msgParts.length > 0 ? msgParts.join("\n") : "(new conversation)",
159
- );
160
- parts.push(CODEBUDDY_TOOL_TAIL);
161
-
162
- const prefix = parts.join("\n\n");
163
-
164
- // Plain text (no images)
165
- if (images.length === 0) {
166
- return prefix + "\n\n" + lastUserText;
167
- }
168
-
169
- // Structured message with images
170
- const blocks: ContentBlock[] = [];
171
- blocks.push({ type: "text", text: prefix });
172
- blocks.push({ type: "text", text: lastUserText });
173
- for (const block of lastUser!.content) {
174
- if (typeof block === "object" && block !== null && "type" in block && (block as { type: string }).type === "image") {
175
- const img = block as ImageContent;
176
- blocks.push({
177
- type: "image",
178
- source: {
179
- type: "base64",
180
- media_type: parseImageMediaType(img.mimeType),
181
- data: stripBase64Prefix(img.data),
182
- },
183
- });
184
- }
185
- }
186
-
187
- return {
188
- type: "user",
189
- session_id: "",
190
- message: { role: "user", content: blocks },
191
- parent_tool_use_id: null,
192
- };
193
- }
194
-
195
- // ── helpers ──
196
-
197
- function findLatestUserIndex(messages: Message[]): number {
198
- for (let i = messages.length - 1; i >= 0; i--) {
199
- if (messages[i].role === "user") return i;
200
- }
201
- return -1;
202
- }
203
-
204
- function extractImages(msg: any): ImageContent[] {
205
- if (!msg || !Array.isArray(msg.content)) return [];
206
- return msg.content.filter(
207
- (c: any) => typeof c === "object" && c !== null && c.type === "image",
208
- );
209
- }
210
-
211
- function extractTextContent(content: unknown): string {
212
- if (typeof content === "string") return content;
213
- if (!Array.isArray(content)) return "";
214
- return content
215
- .filter(
216
- (c): c is { type: "text"; text: string } =>
217
- typeof c === "object" &&
218
- c !== null &&
219
- "type" in c &&
220
- c.type === "text" &&
221
- "text" in c,
222
- )
223
- .map((c) => c.text)
224
- .join("\n");
225
- }
226
-
227
- type ImageMediaType = "image/png" | "image/jpeg" | "image/gif" | "image/webp";
228
-
229
- function parseImageMediaType(mimeType: string): ImageMediaType {
230
- const valid: ImageMediaType[] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
231
- if (valid.includes(mimeType as ImageMediaType)) return mimeType as ImageMediaType;
232
- return "image/png";
233
- }
234
-
235
- function stripBase64Prefix(data: string): string {
236
- const comma = data.indexOf(",");
237
- return comma >= 0 ? data.slice(comma + 1) : data;
238
- }
@@ -1,99 +0,0 @@
1
- /**
2
- * Session Pool — CodeBuddy CLI subprocess reuse across Pi turns.
3
- *
4
- * ~3s CLI cold-start saved per turn after the first.
5
- *
6
- * Key: cwd + fingerprint of conversation's first user message.
7
- * Session change detected when the first message differs.
8
- *
9
- * Anti-double-history: on continuation, only send new messages
10
- * (the delta since last send). CodeBuddy session keeps internal context.
11
- */
12
- import type { Message as CbMessage, Session } from "@tencent-ai/agent-sdk";
13
- import type { Context } from "@earendil-works/pi-ai/compat";
14
-
15
- type CodebuddySdk = typeof import("@tencent-ai/agent-sdk");
16
-
17
- interface PoolEntry {
18
- session: Session;
19
- sentCount: number;
20
- firstFingerprint: string;
21
- }
22
-
23
- const pool = new Map<string, PoolEntry>();
24
-
25
- function fingerprintFirstMessage(context: Context): string {
26
- const first = context.messages[0];
27
- if (!first || first.role !== "user") return "";
28
- if (typeof first.content === "string") return first.content.slice(0, 80);
29
- if (Array.isArray(first.content)) {
30
- const texts = first.content
31
- .filter((c: any) => c?.type === "text")
32
- .map((c: any) => c.text)
33
- .join(" ");
34
- return texts.slice(0, 80);
35
- }
36
- return "";
37
- }
38
-
39
- export async function acquireSession(
40
- cwd: string,
41
- sdk: CodebuddySdk,
42
- context: Context,
43
- buildFull: (ctx: Context) => string | CbMessage,
44
- buildDelta: (ctx: Context, startIndex: number) => string | CbMessage,
45
- sessionOptions: Parameters<CodebuddySdk["unstable_v2_createSession"]>[0],
46
- ): Promise<{
47
- session: Session;
48
- message: string | CbMessage;
49
- isFirst: boolean;
50
- }> {
51
- const key = cwd || process.cwd();
52
- const fp = fingerprintFirstMessage(context);
53
- const existing = pool.get(key);
54
-
55
- const isNew = !existing || existing.firstFingerprint !== fp;
56
-
57
- if (isNew) {
58
- if (existing) {
59
- try { existing.session.close(); } catch { /* best-effort */ }
60
- }
61
-
62
- const session = sdk.unstable_v2_createSession(sessionOptions);
63
- try {
64
- await session.connect();
65
- } catch (e) {
66
- try { session.close(); } catch { /* best-effort, already broken */ }
67
- throw e;
68
- }
69
-
70
- const message = buildFull(context);
71
- pool.set(key, { session, sentCount: context.messages.length, firstFingerprint: fp });
72
- return { session, message, isFirst: true };
73
- }
74
-
75
- const deltaStart = existing.sentCount;
76
- const message =
77
- deltaStart < context.messages.length
78
- ? buildDelta(context, deltaStart)
79
- : "Continue.";
80
-
81
- existing.sentCount = context.messages.length;
82
- return { session: existing.session, message, isFirst: false };
83
- }
84
-
85
- export function evictSession(cwd: string): void {
86
- const key = cwd || process.cwd();
87
- const entry = pool.get(key);
88
- if (entry) {
89
- try { entry.session.close(); } catch { /* best-effort */ }
90
- pool.delete(key);
91
- }
92
- }
93
-
94
- export function closeAll(): void {
95
- for (const [key, entry] of pool) {
96
- try { entry.session.close(); } catch { /* best-effort */ }
97
- pool.delete(key);
98
- }
99
- }