pi-codebuddy-sdk 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/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # pi-codebuddy-sdk
2
+
3
+ 回退到 `@tencent-ai/agent-sdk` 的 Pi provider 扩展。无反向代理,直接使用本地 CodeBuddy CLI,合规安全。
4
+
5
+ ## 安装
6
+
7
+ ### 方式 1:全局安装(日常使用)
8
+
9
+ ```bash
10
+ ln -s ~/Desktop/pi-codebuddy/src ~/.pi/agent/extensions/pi-codebuddy
11
+ cd ~/Desktop/pi-codebuddy && npm install
12
+ ```
13
+
14
+ 重启 Pi 即可自动发现。
15
+
16
+ ### 方式 2:临时加载(测试)
17
+
18
+ ```bash
19
+ pi -e ~/Desktop/pi-codebuddy/src/index.ts --provider codebuddy --model <model>
20
+ ```
21
+
22
+ ## 认证
23
+
24
+ CodeBuddy SDK 从本地环境读取凭证,优先级:
25
+
26
+ ### 1. 已通过 CLI 登录(自动,无需配置)
27
+
28
+ ```bash
29
+ # 如果已登录 CodeBuddy CLI,SDK 自动复用凭证
30
+ codebuddy login
31
+ ```
32
+
33
+ ### 2. API Key(手动设置)
34
+
35
+ ```bash
36
+ # 国际版
37
+ export CODEBUDDY_API_KEY="your-key"
38
+
39
+ # 中国版
40
+ export CODEBUDDY_API_KEY="your-key"
41
+ export CODEBUDDY_INTERNET_ENVIRONMENT=internal
42
+
43
+ # iOA 版(企业内)
44
+ export CODEBUDDY_API_KEY="your-key"
45
+ export CODEBUDDY_INTERNET_ENVIRONMENT=ioa
46
+ ```
47
+
48
+ **获取 API Key:**
49
+
50
+ | 版本 | 地址 |
51
+ |------|------|
52
+ | 国际版 | https://www.codebuddy.ai/profile/keys |
53
+ | 中国版 | https://copilot.tencent.com/profile/ |
54
+ | iOA | https://tencent.sso.copilot.tencent.com/profile/keys |
55
+
56
+ ## 使用
57
+
58
+ ```bash
59
+ # 列出 codebuddy 所有模型
60
+ pi --list-models
61
+
62
+ # 使用指定模型
63
+ pi --provider codebuddy --model claude-sonnet-4.6
64
+
65
+ # 或进入 Pi 后切换
66
+ /model codebuddy/claude-sonnet-4.6
67
+ ```
68
+
69
+ ### 推荐模型
70
+
71
+ | 模型 | 说明 |
72
+ |------|------|
73
+ | `claude-sonnet-4.6` | Claude Sonnet 4.6,支持 thinking |
74
+ | `claude-opus-4.8` | Claude Opus 4.8,支持 thinking + image |
75
+ | `gemini-3.1-pro` | Gemini 3.1 Pro,支持 thinking + image |
76
+ | `gpt-5.5` | GPT-5.5,支持 thinking + image |
77
+ | `hy3-preview-agent-ioa` | 混元 3 Preview,iOA 内免费无限额度 |
78
+
79
+ ## 特性
80
+
81
+ | 特性 | 说明 |
82
+ |------|------|
83
+ | **动态模型发现** | 启动时从 CodeBuddy SDK 拉取 29 个模型,自动标注 thinking/image 能力 |
84
+ | **Session 池复用** | 多轮对话复用同一 CodeBuddy CLI 进程,首轮后每轮省 ~6s 启动 |
85
+ | **Thinking 控制** | Pi 的 thinking level 映射到 CodeBuddy 的 thinking/effort 参数 |
86
+ | **Image 输入** | 原生支持 Claude/Gemini/GPT 的多模态图片输入 |
87
+ | **Abort 处理** | Esc 取消 → CodeBuddy `interrupt()`,干净终止 |
88
+ | **Token 统计** | 包含 cache_read / cache_write / total_cost_usd |
89
+ | **YOLO 模式** | `bypassPermissions`,所有工具自动批准(与 pi-cursor-sdk 一致) |
90
+
91
+ ## FAQ
92
+
93
+ **Q: 和 pi-cursor-sdk 有什么区别?**
94
+
95
+ pi-cursor-sdk 需要自建 MCP bridge 桥接 Cursor SDK 工具,pi-codebuddy SDK 原生处理工具执行,代码量减少 60%+。
96
+
97
+ **Q: 怎么查看当前用了多少 tokens?**
98
+
99
+ 每次回复后 Pi 会显示 usage,包含 input/output/cache/total 及费用。
100
+
101
+ **Q: maxTurns 限制?**
102
+
103
+ 100(安全上限)。CodeBuddy agent 完成任务会自动停止,不会循环满。
104
+
105
+ **Q: 支持哪些 CodeBuddy 版本?**
106
+
107
+ CodeBuddy CLI ≥ 2.x(`@tencent-ai/agent-sdk` ≥ 0.3.0)。
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "pi-codebuddy-sdk",
3
+ "version": "0.1.0",
4
+ "description": "CodeBuddy Agent SDK provider for Pi — native tool execution, 29+ models, thinking controls, session pool",
5
+ "author": "slahser",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "repository": "github:RealAlexandreAI/pi-codebuddy-sdk",
9
+ "keywords": [
10
+ "pi-package",
11
+ "pi",
12
+ "pi-coding-agent",
13
+ "extension",
14
+ "codebuddy",
15
+ "agent-sdk",
16
+ "provider"
17
+ ],
18
+ "files": [
19
+ "src"
20
+ ],
21
+ "dependencies": {
22
+ "@tencent-ai/agent-sdk": "^0.3.0"
23
+ },
24
+ "peerDependencies": {
25
+ "@earendil-works/pi-ai": "*",
26
+ "@earendil-works/pi-coding-agent": "*",
27
+ "typebox": "*"
28
+ },
29
+ "peerDependenciesMeta": {
30
+ "@earendil-works/pi-ai": { "optional": true },
31
+ "@earendil-works/pi-coding-agent": { "optional": true },
32
+ "typebox": { "optional": true }
33
+ },
34
+ "pi": {
35
+ "extensions": [
36
+ "./src/index.ts"
37
+ ]
38
+ },
39
+ "devDependencies": {
40
+ "@earendil-works/pi-ai": "^0.80.2",
41
+ "@earendil-works/pi-coding-agent": "^0.80.2"
42
+ }
43
+ }
package/src/context.ts ADDED
@@ -0,0 +1,190 @@
1
+ import type { Context, ImageContent } from "@earendil-works/pi-ai/compat";
2
+ import type { UserMessage as CbUserMessage, ContentBlock } from "@tencent-ai/agent-sdk";
3
+
4
+ /**
5
+ * Build a CodeBuddy UserMessage from the FULL Pi Context.
6
+ * Used on the first turn of a pooled session.
7
+ *
8
+ * Returns either a plain string (text-only) or a structured UserMessage
9
+ * with ContentBlock[] when images are present in the latest user message.
10
+ */
11
+ export function buildFullMessage(context: Context): string | CbUserMessage {
12
+ return buildMessage(context, 0);
13
+ }
14
+
15
+ /**
16
+ * Build a CodeBuddy UserMessage from only NEW messages since startIndex.
17
+ * Used on continuation turns of a pooled session (delta mode).
18
+ *
19
+ * Historical messages before startIndex are omitted — the CodeBuddy
20
+ * session already has them in its internal context.
21
+ */
22
+ export function buildDeltaMessage(context: Context, startIndex: number): string | CbUserMessage {
23
+ return buildMessage(context, startIndex);
24
+ }
25
+
26
+ // ── internal ──
27
+
28
+ function buildMessage(context: Context, startIndex: number): string | CbUserMessage {
29
+ const prefix = buildHistoryPrefix(context, startIndex);
30
+ const lastUserMsg = findLastUserMessage(context, startIndex);
31
+ const images = extractImages(lastUserMsg);
32
+
33
+ if (images.length === 0) {
34
+ return prefix + extractTextFromMessage(lastUserMsg);
35
+ }
36
+
37
+ // Image support: build ContentBlock[]
38
+ const blocks: ContentBlock[] = [];
39
+
40
+ if (prefix) {
41
+ blocks.push({ type: "text", text: prefix });
42
+ }
43
+
44
+ for (const block of lastUserMsg!.content) {
45
+ if (typeof block === "string") {
46
+ blocks.push({ type: "text", text: block });
47
+ } else if (block.type === "text") {
48
+ blocks.push({ type: "text", text: block.text });
49
+ } else if (block.type === "image") {
50
+ blocks.push({
51
+ type: "image",
52
+ source: {
53
+ type: "base64",
54
+ media_type: parseImageMediaType(block.mimeType),
55
+ data: stripBase64Prefix(block.data),
56
+ },
57
+ });
58
+ }
59
+ }
60
+
61
+ return {
62
+ type: "user",
63
+ session_id: "",
64
+ message: { role: "user", content: blocks },
65
+ parent_tool_use_id: null,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Build conversation history prefix for messages in [startIndex, end).
71
+ * The latest user message is always excluded (handled separately).
72
+ */
73
+ function buildHistoryPrefix(context: Context, startIndex: number): string {
74
+ const parts: string[] = [];
75
+
76
+ // System prompt only on first turn (startIndex === 0)
77
+ if (startIndex === 0 && context.systemPrompt) {
78
+ parts.push(context.systemPrompt);
79
+ parts.push("");
80
+ }
81
+
82
+ const messages = context.messages;
83
+
84
+ // All messages in range except the last user message
85
+ for (let i = startIndex; i < messages.length; i++) {
86
+ const msg = messages[i];
87
+ if (i === messages.length - 1 && msg.role === "user") continue;
88
+
89
+ if (msg.role === "user") {
90
+ const text = extractTextContent(msg.content);
91
+ const hasImages = extractImages(msg).length > 0;
92
+ if (text) {
93
+ parts.push(`User: ${text}${hasImages ? " [image omitted]" : ""}`);
94
+ } else if (hasImages) {
95
+ parts.push("User: [image omitted]");
96
+ }
97
+ } else if (msg.role === "assistant") {
98
+ const text = extractTextContent(msg.content);
99
+ if (text) parts.push(`Assistant: ${text}`);
100
+ for (const block of msg.content) {
101
+ if (block.type === "toolCall") {
102
+ parts.push(
103
+ `[Tool used: ${block.name}(${JSON.stringify(block.arguments)})]`,
104
+ );
105
+ }
106
+ }
107
+ } else if (msg.role === "toolResult") {
108
+ const text = extractTextContent(msg.content);
109
+ const status = msg.isError ? "Error" : "Result";
110
+ const snippet = truncateToolResult(text);
111
+ parts.push(`[Tool ${status} (${msg.toolName}): ${snippet}]`);
112
+ }
113
+ }
114
+
115
+ return parts.length > 0 ? parts.join("\n") + "\n\n" : "";
116
+ }
117
+
118
+ /**
119
+ * Truncate tool result with head + tail strategy (industry best practice).
120
+ * Head 1200 chars (captures output start), tail 600 chars (captures errors/summaries),
121
+ * marker in middle.
122
+ */
123
+ const TOOL_RESULT_HEAD = 1200;
124
+ const TOOL_RESULT_TAIL = 600;
125
+
126
+ function truncateToolResult(text: string): string {
127
+ if (!text) return "(empty)";
128
+ if (text.length <= TOOL_RESULT_HEAD + TOOL_RESULT_TAIL + 50) return text;
129
+
130
+ const head = text.slice(0, TOOL_RESULT_HEAD);
131
+ const tail = text.slice(-TOOL_RESULT_TAIL);
132
+ const skipped = text.length - TOOL_RESULT_HEAD - TOOL_RESULT_TAIL;
133
+ return `${head}\n...[${skipped.toLocaleString()} chars skipped]...\n${tail}`;
134
+ }
135
+
136
+ // ── helpers ──
137
+
138
+ function findLastUserMessage(context: Context, startIndex: number) {
139
+ for (let i = context.messages.length - 1; i >= startIndex; i--) {
140
+ if (context.messages[i].role === "user") return context.messages[i];
141
+ }
142
+ return null;
143
+ }
144
+
145
+ function extractImages(msg: any): ImageContent[] {
146
+ if (!msg || !Array.isArray(msg.content)) return [];
147
+ return msg.content.filter(
148
+ (c: any) => typeof c === "object" && c !== null && c.type === "image",
149
+ );
150
+ }
151
+
152
+ function extractTextFromMessage(msg: any): string {
153
+ if (!msg) return "Continue.";
154
+ return extractTextContent(msg.content) || "Continue.";
155
+ }
156
+
157
+ function extractTextContent(content: unknown): string {
158
+ if (typeof content === "string") return content;
159
+ if (!Array.isArray(content)) return "";
160
+ return content
161
+ .filter(
162
+ (c): c is { type: "text"; text: string } =>
163
+ typeof c === "object" &&
164
+ c !== null &&
165
+ "type" in c &&
166
+ c.type === "text" &&
167
+ "text" in c,
168
+ )
169
+ .map((c) => c.text)
170
+ .join("\n");
171
+ }
172
+
173
+ type ImageMediaType = "image/png" | "image/jpeg" | "image/gif" | "image/webp";
174
+
175
+ function parseImageMediaType(mimeType: string): ImageMediaType {
176
+ const valid: ImageMediaType[] = [
177
+ "image/png",
178
+ "image/jpeg",
179
+ "image/gif",
180
+ "image/webp",
181
+ ];
182
+ if (valid.includes(mimeType as ImageMediaType))
183
+ return mimeType as ImageMediaType;
184
+ return "image/png";
185
+ }
186
+
187
+ function stripBase64Prefix(data: string): string {
188
+ const comma = data.indexOf(",");
189
+ return comma >= 0 ? data.slice(comma + 1) : data;
190
+ }
package/src/index.ts ADDED
@@ -0,0 +1,115 @@
1
+ import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
2
+
3
+ // ponytail: hardcoded patterns for capability detection since supportedModels() returns
4
+ // only {id, name} without capability metadata.
5
+ const THINKING_PATTERNS = [/claude/i, /gemini/i, /gpt-5/i];
6
+ const IMAGE_PATTERNS = [/claude/i, /gemini/i, /gpt/i];
7
+ // ponytail: rough context window estimates by model family
8
+ const CONTEXT_ESTIMATES: Record<string, [number, number]> = {
9
+ // [contextWindow, maxTokens]
10
+ claude: [200000, 8192],
11
+ gemini: [1048576, 8192],
12
+ gpt: [200000, 16384],
13
+ deepseek: [131072, 8192],
14
+ glm: [131072, 8192],
15
+ minimax: [131072, 8192],
16
+ kimi: [131072, 8192],
17
+ hy: [131072, 8192], // Hunyuan
18
+ };
19
+
20
+ function detectCapabilities(modelId: string) {
21
+ const reasoning = THINKING_PATTERNS.some((p) => p.test(modelId));
22
+ const hasImage = IMAGE_PATTERNS.some((p) => p.test(modelId));
23
+
24
+ // Match family for context window estimate
25
+ const family = Object.keys(CONTEXT_ESTIMATES).find((k) =>
26
+ modelId.toLowerCase().startsWith(k),
27
+ );
28
+ const [contextWindow, maxTokens] = family
29
+ ? CONTEXT_ESTIMATES[family]
30
+ : [128000, 8192];
31
+
32
+ return {
33
+ reasoning,
34
+ input: (hasImage ? ["text", "image"] : ["text"]) as ("text" | "image")[],
35
+ contextWindow,
36
+ maxTokens,
37
+ };
38
+ }
39
+
40
+ async function discoverModels(): Promise<ProviderModelConfig[]> {
41
+ try {
42
+ const sdk = await import("@tencent-ai/agent-sdk");
43
+ const q = sdk.query({
44
+ prompt: "",
45
+ options: {
46
+ permissionMode: "bypassPermissions",
47
+ maxTurns: 0,
48
+ },
49
+ });
50
+ const models = await Promise.race([
51
+ q.supportedModels(),
52
+ new Promise<never>((_, reject) =>
53
+ setTimeout(() => reject(new Error("CodeBuddy model discovery timed out after 15s")), 15000),
54
+ ),
55
+ ]);
56
+ if (models && models.length > 0) {
57
+ return models.map((m) => {
58
+ const caps = detectCapabilities(m.id);
59
+ return {
60
+ id: m.id,
61
+ name: m.name || m.id,
62
+ reasoning: caps.reasoning,
63
+ input: caps.input,
64
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
65
+ contextWindow: caps.contextWindow,
66
+ maxTokens: caps.maxTokens,
67
+ };
68
+ });
69
+ }
70
+ } catch (e) {
71
+ // Discovery failed — SDK not installed, CLI missing, or network issue.
72
+ // Fall back to hardcoded minimal list so Pi can still show a provider.
73
+ console.warn(
74
+ `[pi-codebuddy-sdk] Model discovery failed: ${e instanceof Error ? e.message : String(e)}. Using ${3} fallback models.`,
75
+ );
76
+ }
77
+
78
+ // ponytail: fallback model list when discovery fails
79
+ return [
80
+ { id: "hy3-preview-agent-ioa", name: "Hunyuan 3 Preview", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 8192 },
81
+ { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", reasoning: true, input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 8192 },
82
+ { id: "deepseek-v4-pro-ioa", name: "DeepSeek V4 Pro", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 8192 },
83
+ ];
84
+ }
85
+
86
+ export default async function (pi: ExtensionAPI) {
87
+ const models = await discoverModels();
88
+
89
+ pi.registerProvider("codebuddy", {
90
+ name: "CodeBuddy",
91
+ baseUrl: "https://codebuddy.ai",
92
+ apiKey: "codebuddy-local",
93
+ api: "codebuddy-sdk",
94
+ models,
95
+ streamSimple: (await import("./provider.js")).streamCodebuddy,
96
+ });
97
+
98
+ pi.registerCommand("codebuddy-refresh-models", {
99
+ description: "Refresh CodeBuddy model list from SDK",
100
+ handler: async (_args, ctx) => {
101
+ const refreshed = await discoverModels();
102
+ pi.registerProvider("codebuddy", {
103
+ name: "CodeBuddy",
104
+ baseUrl: "https://codebuddy.ai",
105
+ apiKey: "codebuddy-local",
106
+ api: "codebuddy-sdk",
107
+ models: refreshed,
108
+ streamSimple: (await import("./provider.js")).streamCodebuddy,
109
+ });
110
+ if (ctx.hasUI) {
111
+ ctx.ui.notify(`CodeBuddy: ${refreshed.length} models loaded`, "info");
112
+ }
113
+ },
114
+ });
115
+ }
@@ -0,0 +1,266 @@
1
+ import {
2
+ createAssistantMessageEventStream,
3
+ type Api,
4
+ type AssistantMessage,
5
+ type AssistantMessageEventStream,
6
+ type Context,
7
+ type Model,
8
+ type SimpleStreamOptions,
9
+ type TextContent,
10
+ type ThinkingContent,
11
+ type ToolCall,
12
+ } from "@earendil-works/pi-ai/compat";
13
+ import type {
14
+ Message as CbMessage,
15
+ ThinkingConfig,
16
+ Effort,
17
+ } from "@tencent-ai/agent-sdk";
18
+ import { buildFullMessage, buildDeltaMessage } from "./context.js";
19
+ import { acquireSession, evictSession } from "./session-pool.js";
20
+
21
+ type CodebuddySdk = typeof import("@tencent-ai/agent-sdk");
22
+
23
+ async function loadCodebuddySdk(): Promise<CodebuddySdk> {
24
+ return import("@tencent-ai/agent-sdk");
25
+ }
26
+
27
+ // ── helpers ──
28
+
29
+ function makePartial(model: Model<Api>): AssistantMessage {
30
+ return {
31
+ role: "assistant",
32
+ content: [],
33
+ api: model.api,
34
+ provider: model.provider,
35
+ model: model.id,
36
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
37
+ stopReason: "stop",
38
+ timestamp: Date.now(),
39
+ };
40
+ }
41
+
42
+ function makeErrorPartial(model: Model<Api>, error: unknown): AssistantMessage {
43
+ return {
44
+ ...makePartial(model),
45
+ stopReason: "error",
46
+ errorMessage: `CodeBuddy SDK error: ${error instanceof Error ? error.message : String(error)}`,
47
+ };
48
+ }
49
+
50
+ function extractUsageUpdate(msg: CbMessage): Partial<AssistantMessage> | null {
51
+ if (msg.type === "result" && msg.usage) {
52
+ return {
53
+ usage: {
54
+ input: msg.usage.input_tokens ?? 0,
55
+ output: msg.usage.output_tokens ?? 0,
56
+ cacheRead: msg.usage.cache_read_input_tokens ?? 0,
57
+ cacheWrite: msg.usage.cache_creation_input_tokens ?? 0,
58
+ totalTokens: (msg.usage.input_tokens ?? 0) + (msg.usage.output_tokens ?? 0) + (msg.usage.cache_read_input_tokens ?? 0) + (msg.usage.cache_creation_input_tokens ?? 0),
59
+ cost: {
60
+ input: 0,
61
+ output: 0,
62
+ cacheRead: 0,
63
+ cacheWrite: 0,
64
+ total: msg.total_cost_usd ?? 0,
65
+ },
66
+ },
67
+ };
68
+ }
69
+ return null;
70
+ }
71
+
72
+ // ── thinking config mapping ──
73
+
74
+ const DEFAULT_THINKING_BUDGETS: Record<string, number> = {
75
+ minimal: 1600,
76
+ low: 4000,
77
+ medium: 0, // adaptive
78
+ high: 0, // adaptive
79
+ xhigh: 32000,
80
+ };
81
+
82
+ const DEFAULT_EFFORT: Record<string, Effort | undefined> = {
83
+ minimal: 'low',
84
+ low: 'low',
85
+ medium: 'medium',
86
+ high: 'high',
87
+ xhigh: 'xhigh',
88
+ };
89
+
90
+ function buildThinkingConfig(
91
+ reasoning: string | undefined,
92
+ thinkingBudgets?: SimpleStreamOptions["thinkingBudgets"],
93
+ ): { thinking?: ThinkingConfig; effort?: Effort } {
94
+ if (!reasoning) return {};
95
+ if (reasoning === "off") return { thinking: { type: "disabled" } };
96
+
97
+ const budget = thinkingBudgets?.[reasoning as keyof typeof thinkingBudgets]
98
+ ?? DEFAULT_THINKING_BUDGETS[reasoning] ?? 0;
99
+ const effort = DEFAULT_EFFORT[reasoning];
100
+
101
+ if (budget > 0) return { thinking: { type: "enabled", budgetTokens: budget }, effort };
102
+ return { thinking: { type: "adaptive" }, effort };
103
+ }
104
+
105
+ // ── main provider ──
106
+
107
+ export function streamCodebuddy(
108
+ model: Model<Api>,
109
+ context: Context,
110
+ options?: SimpleStreamOptions,
111
+ ): AssistantMessageEventStream {
112
+ const stream = createAssistantMessageEventStream();
113
+ const partial = makePartial(model);
114
+
115
+ queueMicrotask(async () => {
116
+ let session: Awaited<ReturnType<CodebuddySdk["unstable_v2_createSession"]>> | undefined;
117
+ let isFirst = false;
118
+
119
+ try {
120
+ stream.push({ type: "start", partial });
121
+
122
+ const sdk = await loadCodebuddySdk();
123
+
124
+ const { thinking, effort } = buildThinkingConfig(
125
+ options?.reasoning,
126
+ options?.thinkingBudgets,
127
+ );
128
+
129
+ const includePartial = !!(model.reasoning && options?.reasoning && options.reasoning !== "off");
130
+
131
+ const cwd = process.cwd();
132
+
133
+ const { session: pooled, message: userMessage, isFirst: first } =
134
+ await acquireSession(cwd, sdk, context, buildFullMessage, buildDeltaMessage, {
135
+ model: model.id,
136
+ permissionMode: "bypassPermissions",
137
+ maxTurns: 100,
138
+ thinking,
139
+ effort,
140
+ includePartialMessages: includePartial,
141
+ });
142
+
143
+ session = pooled;
144
+ isFirst = first;
145
+
146
+ let contentIndex = 0;
147
+ let msgCount = 0; // track assistant messages for empty-response detection
148
+
149
+ // ── abort handling ──
150
+ let aborted = false;
151
+ const onAbort = async () => {
152
+ if (aborted) return;
153
+ aborted = true;
154
+ try { await session!.interrupt(); } catch { /* best-effort */ }
155
+ partial.stopReason = "aborted";
156
+ stream.push({ type: "error", reason: "aborted", error: partial });
157
+ };
158
+ if (options?.signal) {
159
+ options.signal.addEventListener("abort", onAbort, { once: true });
160
+ if (options.signal.aborted) {
161
+ await onAbort();
162
+ return;
163
+ }
164
+ }
165
+
166
+ // ── send prompt ──
167
+ await session.send(userMessage);
168
+
169
+ // ── iterate responses ──
170
+ for await (const msg of session.stream()) {
171
+ if (aborted) return;
172
+
173
+ if (msg.type === "system") {
174
+ if (msg.model) partial.responseModel = msg.model;
175
+ continue;
176
+ }
177
+
178
+ if (msg.type === "assistant") {
179
+ msgCount++;
180
+ for (const block of msg.message.content) {
181
+ // Non-reasoning models may emit thinking blocks wrapping text (Hunyuan quirk).
182
+ // Treat them as text.
183
+ if (block.type === "thinking") {
184
+ if (model.reasoning) {
185
+ const ci = contentIndex++;
186
+ const tc: ThinkingContent = { type: "thinking", thinking: block.thinking };
187
+ partial.content = [...partial.content, tc];
188
+ stream.push({ type: "thinking_start", contentIndex: ci, partial });
189
+ stream.push({ type: "thinking_delta", contentIndex: ci, delta: block.thinking, partial });
190
+ stream.push({ type: "thinking_end", contentIndex: ci, content: block.thinking, partial });
191
+ } else {
192
+ // Fallback: emit as text for non-reasoning models
193
+ const ci = contentIndex++;
194
+ const textBlock: TextContent = { type: "text", text: block.thinking };
195
+ partial.content = [...partial.content, textBlock];
196
+ stream.push({ type: "text_start", contentIndex: ci, partial });
197
+ stream.push({ type: "text_delta", contentIndex: ci, delta: block.thinking, partial });
198
+ stream.push({ type: "text_end", contentIndex: ci, content: block.thinking, partial });
199
+ }
200
+ } else if (block.type === "redacted_thinking") {
201
+ const ci = contentIndex++;
202
+ const tc: ThinkingContent = {
203
+ type: "thinking", thinking: "[redacted]",
204
+ thinkingSignature: block.data, redacted: true,
205
+ };
206
+ partial.content = [...partial.content, tc];
207
+ stream.push({ type: "thinking_start", contentIndex: ci, partial });
208
+ stream.push({ type: "thinking_end", contentIndex: ci, content: "[redacted]", partial });
209
+ } else if (block.type === "text") {
210
+ const ci = contentIndex++;
211
+ const textBlock: TextContent = { type: "text", text: block.text };
212
+ partial.content = [...partial.content, textBlock];
213
+ stream.push({ type: "text_start", contentIndex: ci, partial });
214
+ stream.push({ type: "text_delta", contentIndex: ci, delta: block.text, partial });
215
+ stream.push({ type: "text_end", contentIndex: ci, content: block.text, partial });
216
+ } else if (block.type === "tool_use") {
217
+ const ci = contentIndex++;
218
+ const toolCall: ToolCall = {
219
+ type: "toolCall", id: block.id, name: block.name,
220
+ arguments: (block.input ?? {}) as Record<string, unknown>,
221
+ };
222
+ partial.content = [...partial.content, toolCall];
223
+ stream.push({ type: "toolcall_start", contentIndex: ci, partial });
224
+ stream.push({ type: "toolcall_end", contentIndex: ci, toolCall, partial });
225
+ }
226
+ }
227
+ }
228
+
229
+ if (msg.type === "result") {
230
+ const usageUpdate = extractUsageUpdate(msg);
231
+ if (usageUpdate) Object.assign(partial, usageUpdate);
232
+
233
+ if (msg.subtype === "success") {
234
+ partial.stopReason = "stop";
235
+ stream.push({ type: "done", reason: "stop", message: { ...partial } });
236
+ } else {
237
+ partial.stopReason = "error";
238
+ partial.errorMessage = msg.errors?.join("; ") ?? `CodeBuddy error: ${msg.subtype}`;
239
+ stream.push({ type: "error", reason: "error", error: { ...partial } });
240
+ // Error on a turn: evict session so next turn starts fresh
241
+ evictSession(cwd);
242
+ }
243
+ // break (not return) to let iterator drain naturally
244
+ break;
245
+ }
246
+ }
247
+
248
+ // Some models (Hunyuan) return empty on session reuse → evict for fresh session next turn
249
+ if (msgCount === 0) {
250
+ evictSession(cwd);
251
+ }
252
+
253
+ // Stream ended; no extra done emission (handled in result branch above)
254
+ } catch (error) {
255
+ evictSession(process.cwd());
256
+ const errPartial = makeErrorPartial(model, error);
257
+ stream.push({ type: "error", reason: "error", error: errPartial });
258
+ } finally {
259
+ // ponytail: do NOT close pooled sessions — they are reused across turns.
260
+ // Only close when evicted (via evictSession).
261
+ // session?.close() intentionally omitted here.
262
+ }
263
+ });
264
+
265
+ return stream;
266
+ }
@@ -0,0 +1,99 @@
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
+ }