pi-codebuddy-sdk 0.1.19 → 0.3.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eli Dickinson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,104 +1,130 @@
1
1
  # pi-codebuddy-sdk
2
2
 
3
- Forked from [pi-claude-bridge](https://github.com/elidickinson/pi-claude-bridge). Replaces Claude Agent SDK with CodeBuddy Agent SDK.
3
+ Pi extension that registers **CodeBuddy** as a model provider. You keep using Pi for the TUI, tools, skills, and extensions; CodeBuddy Agent SDK runs inference locally via the `codebuddy` CLI. No HTTP proxy and no changes to how you work in Pi beyond picking a model.
4
4
 
5
- 回退到 `@tencent-ai/agent-sdk` 的 Pi provider 扩展。无反向代理,直接使用本地 CodeBuddy CLI,合规安全。
5
+ ## What it does
6
6
 
7
- ## 安装
7
+ - Exposes CodeBuddy models in Pi (`codebuddy/...` in `/model`)
8
+ - Bridges Pi tools to the SDK (Pi still executes tools; CodeBuddy only plans and calls them)
9
+ - Forwards Pi's system prompt, skills, and project `AGENTS.md` so the model acts as Pi—not as standalone CodeBuddy Code
10
+ - Supports session resume, compaction, streaming, thinking levels, and images
11
+ - Optional **AskCodebuddy** tool to delegate a focused sub-task to another CodeBuddy call
8
12
 
9
- ### 方式 1:全局安装(日常使用)
13
+ ## Install
10
14
 
11
15
  ```bash
12
- ln -s ~/Desktop/usaslahser/pi-codebuddy-sdk/src ~/.pi/agent/extensions/pi-codebuddy-sdk
13
- cd ~/Desktop/usaslahser/pi-codebuddy-sdk && npm install
16
+ pi install npm:pi-codebuddy-sdk
14
17
  ```
15
18
 
16
- 重启 Pi 即可自动发现。
19
+ Restart `pi` if it was already running.
17
20
 
18
- ### 方式 2:临时加载(测试)
21
+ ## Requirements
19
22
 
20
- ```bash
21
- pi -e ~/Desktop/usaslahser/pi-codebuddy-sdk/src/index.ts --provider codebuddy --model <model>
22
- ```
23
+ - **`codebuddy` on your `PATH`** — the extension spawns the same CLI you use standalone. If `which codebuddy` fails, either add it to `PATH` or set `pathToCodebuddyCode` in `codebuddy-sdk.json` (see [Configuration](#configuration)).
24
+ - **CodeBuddy auth already working** — if `codebuddy` works in your terminal, you do not need extra setup for this extension.
25
+
26
+ ## Quick start (already using CodeBuddy)
27
+
28
+ No `codebuddy-sdk.json` and no plugin-specific env vars are required.
29
+
30
+ 1. `pi install npm:pi-codebuddy-sdk`
31
+ 2. Restart `pi`
32
+ 3. `/model` → pick `codebuddy/...`
23
33
 
24
- ## 认证
34
+ Optional: set `defaultProvider` / `defaultModel` in `~/.pi/agent/settings.json` so you skip `/model` each time.
25
35
 
26
- CodeBuddy SDK 从本地环境读取凭证,优先级:
36
+ The first query after startup may take a few seconds while models are discovered from the SDK.
27
37
 
28
- ### 1. 已通过 CLI 登录(自动,无需配置)
38
+ ## Auth
39
+
40
+ The extension does not store credentials. The CodeBuddy CLI reads them from your machine. Use **one** of the following:
41
+
42
+ ### 1. CLI login (recommended)
29
43
 
30
44
  ```bash
31
- # 如果已登录 CodeBuddy CLI,SDK 自动复用凭证
32
45
  codebuddy login
33
46
  ```
34
47
 
35
- ### 2. API Key(手动设置)
48
+ Complete login in the browser. Credentials stay on your machine; Pi reuses them automatically.
49
+
50
+ ### 2. API key
36
51
 
37
52
  ```bash
38
- # 国际版
39
- export CODEBUDDY_API_KEY="your-key"
53
+ export CODEBUDDY_API_KEY="your-api-key"
54
+ ```
55
+
56
+ Obtain the key from your CodeBuddy account. Do not commit it or store it in repo files.
40
57
 
41
- # 中国版
42
- export CODEBUDDY_API_KEY="your-key"
43
- export CODEBUDDY_INTERNET_ENVIRONMENT=internal
58
+ ### 3. Tencent iOA (internal network)
44
59
 
45
- # iOA 版(企业内)
46
- export CODEBUDDY_API_KEY="your-key"
60
+ ```bash
47
61
  export CODEBUDDY_INTERNET_ENVIRONMENT=ioa
62
+ codebuddy login
48
63
  ```
49
64
 
50
- **获取 API Key:**
65
+ Use this when CodeBuddy must run on the iOA network. Add `CODEBUDDY_API_KEY` as well if your environment requires it.
51
66
 
52
- | 版本 | 地址 |
53
- |------|------|
54
- | 国际版 | https://www.codebuddy.ai/profile/keys |
55
- | 中国版 | https://copilot.tencent.com/profile/ |
56
- | iOA | https://tencent.sso.copilot.tencent.com/profile/keys |
67
+ ## Usage
57
68
 
58
- ## 使用
69
+ ```text
70
+ pi
71
+ /model
72
+ ```
59
73
 
60
- ```bash
61
- # 列出 codebuddy 所有模型
62
- pi --list-models
74
+ Pick any entry prefixed with `codebuddy/` (for example `codebuddy/hy3-preview-agent-ioa`).
75
+
76
+ Tools, skills, extensions, `/compact`, and steer behave the same as with other Pi providers.
63
77
 
64
- # 使用指定模型
65
- pi --provider codebuddy --model claude-sonnet-4.6
78
+ ## Configuration
66
79
 
67
- # 或进入 Pi 后切换
68
- /model codebuddy/claude-sonnet-4.6
80
+ **Optional.** Defaults work for most users.
81
+
82
+ File: `~/.pi/agent/codebuddy-sdk.json` or `.pi/codebuddy-sdk.json` in a project.
83
+
84
+ ```json
85
+ {
86
+ "askCodebuddy": {
87
+ "enabled": true,
88
+ "allowFullMode": true
89
+ },
90
+ "provider": {
91
+ "appendSystemPrompt": true,
92
+ "strictMcpConfig": true,
93
+ "pathToCodebuddyCode": "/path/to/codebuddy"
94
+ }
95
+ }
69
96
  ```
70
97
 
71
- ### 推荐模型
98
+ | Option | Default | Meaning |
99
+ |--------|---------|---------|
100
+ | `askCodebuddy.enabled` | `true` | Register the AskCodebuddy delegation tool |
101
+ | `askCodebuddy.allowFullMode` | `true` | Allow write-capable delegation mode |
102
+ | `provider.appendSystemPrompt` | `true` | Use Pi's system prompt instead of CodeBuddy's default identity |
103
+ | `provider.strictMcpConfig` | `true` | Use only Pi-bridged MCP tools |
104
+ | `provider.pathToCodebuddyCode` | auto | Path to `codebuddy` when it is **not** on `PATH` |
105
+
106
+ ## Privacy
72
107
 
73
- | 模型 | 说明 |
74
- |------|------|
75
- | `claude-sonnet-4.6` | Claude Sonnet 4.6,支持 thinking |
76
- | `hy3-preview-agent-ioa` | 混元 3 Preview,iOA 内免费无限额度 |
77
- | `deepseek-v4-pro-ioa` | DeepSeek V4 Pro |
78
- | `glm-5.2-ioa` | GLM 5.2 |
108
+ - The extension does **not** send conversation data to this repository or any third-party telemetry endpoint.
109
+ - Credentials are handled entirely by the CodeBuddy CLI on your machine.
110
+ - Optional debug mode (`CODEBUDDY_SDK_DEBUG=1`) writes **local** logs under `~/.pi/agent/`; paths are redacted, prompts and tool payloads are not logged. Delete logs when finished.
79
111
 
80
- ## 特性
112
+ ## Troubleshooting
81
113
 
82
- | 特性 | 说明 |
83
- |------|------|
84
- | **动态模型发现** | 启动时从 CodeBuddy SDK 拉取模型,自动标注 thinking/image 能力 |
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
- | **Stateless** | 每次调用独立 query(),无 session 复用,简单可靠 |
114
+ ```bash
115
+ export CODEBUDDY_SDK_DEBUG=1
116
+ ```
91
117
 
92
- ## FAQ
118
+ Default log: `~/.pi/agent/codebuddy-sdk.log`. See [CONTRIBUTING.md](CONTRIBUTING.md) for maintainer details.
93
119
 
94
- **Q: 和 pi-claude-bridge 有什么区别?**
120
+ ## Development
95
121
 
96
- pi-claude-bridge 使用 Anthropic Claude Agent SDK + MCP bridge,需要 session 管理和复杂的工具桥接。pi-codebuddy-sdk 使用腾讯 CodeBuddy Agent SDK,原生工具执行,代码量减少 60%+。
122
+ Maintainers: [CONTRIBUTING.md](CONTRIBUTING.md).
97
123
 
98
- **Q: maxTurns 限制?**
124
+ ## License
99
125
 
100
- 100(安全上限)。CodeBuddy agent 完成任务会自动停止,不会循环满。
126
+ MIT
101
127
 
102
- **Q: 支持哪些 CodeBuddy 版本?**
128
+ ## Inspiration
103
129
 
104
- CodeBuddy CLI >= 2.x(`@tencent-ai/agent-sdk` >= 0.3.0)。
130
+ Early MCP bridge patterns were inspired by [pi-claude-bridge](https://github.com/elidickinson/pi-claude-bridge). This package is a separate codebase on [@tencent-ai/agent-sdk](https://www.npmjs.com/package/@tencent-ai/agent-sdk).
package/package.json CHANGED
@@ -1,11 +1,17 @@
1
1
  {
2
2
  "name": "pi-codebuddy-sdk",
3
- "version": "0.1.19",
4
- "description": "CodeBuddy Agent SDK provider for Pi — native tool execution, thinking controls, stateless one-shot queries",
3
+ "version": "0.3.1",
4
+ "description": "Pi extension that uses CodeBuddy (via Agent SDK) as a model provider and adds an AskCodebuddy tool.",
5
5
  "author": "slahser",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
- "repository": "github:RealAlexandreAI/pi-codebuddy-sdk",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/RealAlexandreAI/pi-codebuddy-sdk.git"
11
+ },
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
9
15
  "keywords": [
10
16
  "pi-package",
11
17
  "pi",
@@ -16,35 +22,36 @@
16
22
  "provider"
17
23
  ],
18
24
  "files": [
19
- "src"
25
+ "src",
26
+ "README.md",
27
+ "LICENSE"
20
28
  ],
29
+ "scripts": {
30
+ "test:unit": "node --import tsx --test tests/unit-*.mjs",
31
+ "test": "set -a && [ -f .env.test ] && . .env.test; set +a && npm run test:unit && tests/int-smoke.sh && tests/int-multi-turn.sh && tests/int-cache.sh && node --import tsx --test tests/int-*.mjs",
32
+ "typecheck": "tsc --noEmit"
33
+ },
21
34
  "dependencies": {
22
- "@tencent-ai/agent-sdk": "^0.3.0"
35
+ "@tencent-ai/agent-sdk": "^0.3.0",
36
+ "change-case": "^5.4.4",
37
+ "typebox": "^1.3.0"
23
38
  },
24
39
  "peerDependencies": {
25
- "@earendil-works/pi-ai": "*",
26
- "@earendil-works/pi-coding-agent": "*",
27
- "typebox": "*"
40
+ "@earendil-works/pi-ai": ">=0.83.0",
41
+ "@earendil-works/pi-coding-agent": ">=0.83.0",
42
+ "@earendil-works/pi-tui": ">=0.83.0"
28
43
  },
29
- "peerDependenciesMeta": {
30
- "@earendil-works/pi-ai": {
31
- "optional": true
32
- },
33
- "@earendil-works/pi-coding-agent": {
34
- "optional": true
35
- },
36
- "typebox": {
37
- "optional": true
38
- }
44
+ "devDependencies": {
45
+ "@earendil-works/pi-ai": "^0.83.0",
46
+ "@earendil-works/pi-coding-agent": "^0.83.0",
47
+ "@earendil-works/pi-tui": "^0.83.0",
48
+ "@types/node": "^24.13.2",
49
+ "tsx": "^4.22.4",
50
+ "typescript": "^6.0.3"
39
51
  },
40
52
  "pi": {
41
53
  "extensions": [
42
54
  "./src/index.ts"
43
55
  ]
44
- },
45
- "devDependencies": {
46
- "@earendil-works/pi-ai": "^0.80.2",
47
- "@earendil-works/pi-coding-agent": "^0.80.2",
48
- "@earendil-works/pi-tui": "^0.80.2"
49
56
  }
50
57
  }
package/src/agents-md.ts CHANGED
@@ -1,4 +1,10 @@
1
- // AGENTS.md discovery for forwarding to CodeBuddy.
1
+ // AGENTS.md discovery and sanitization for forwarding to Claude Code.
2
+ //
3
+ // Pi uses AGENTS.md for long-lived instructions; Claude Code reads the same
4
+ // content under "# CLAUDE.md". We walk up from cwd looking for AGENTS.md,
5
+ // fall back to ~/.pi/agent/AGENTS.md, and rewrite pi-specific references
6
+ // (~/.pi, .pi/, .pi, pi) to their Claude Code equivalents so any paths or
7
+ // references in the file still resolve inside the CC subprocess.
2
8
 
3
9
  import { existsSync, readFileSync } from "fs";
4
10
  import { homedir } from "os";
@@ -7,33 +13,42 @@ import { dirname, join, resolve } from "path";
7
13
  const GLOBAL_AGENTS_PATH = join(homedir(), ".pi", "agent", "AGENTS.md");
8
14
 
9
15
  export function resolveAgentsMdPath(): string | undefined {
10
- const fromCwd = findAgentsMdInParents(process.cwd());
11
- if (fromCwd) return fromCwd;
12
- if (existsSync(GLOBAL_AGENTS_PATH)) return GLOBAL_AGENTS_PATH;
13
- return undefined;
16
+ const fromCwd = findAgentsMdInParents(process.cwd());
17
+ if (fromCwd) return fromCwd;
18
+ if (existsSync(GLOBAL_AGENTS_PATH)) return GLOBAL_AGENTS_PATH;
19
+ return undefined;
14
20
  }
15
21
 
16
22
  export function findAgentsMdInParents(startDir: string): string | undefined {
17
- let current = resolve(startDir);
18
- while (true) {
19
- const candidate = join(current, "AGENTS.md");
20
- if (existsSync(candidate)) return candidate;
21
- const parent = dirname(current);
22
- if (parent === current) break;
23
- current = parent;
24
- }
25
- return undefined;
23
+ let current = resolve(startDir);
24
+ while (true) {
25
+ const candidate = join(current, "AGENTS.md");
26
+ if (existsSync(candidate)) return candidate;
27
+ const parent = dirname(current);
28
+ if (parent === current) break;
29
+ current = parent;
30
+ }
31
+ return undefined;
26
32
  }
27
33
 
28
34
  export function extractAgentsAppend(): string | undefined {
29
- const agentsPath = resolveAgentsMdPath();
30
- if (!agentsPath) return undefined;
31
- try {
32
- const content = readFileSync(agentsPath, "utf-8").trim();
33
- if (!content) return undefined;
34
- const sanitized = content; // keep as-is for CodeBuddy
35
- return sanitized.length > 0 ? `# CODEBUDDY.md\n\n${sanitized}` : undefined;
36
- } catch {
37
- return undefined;
38
- }
35
+ const agentsPath = resolveAgentsMdPath();
36
+ if (!agentsPath) return undefined;
37
+ try {
38
+ const content = readFileSync(agentsPath, "utf-8").trim();
39
+ if (!content) return undefined;
40
+ const sanitized = sanitizeAgentsContent(content);
41
+ return sanitized.length > 0 ? `# CLAUDE.md\n\n${sanitized}` : undefined;
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+
47
+ export function sanitizeAgentsContent(content: string): string {
48
+ let sanitized = content;
49
+ sanitized = sanitized.replace(/~\/\.pi\b/gi, "~/.claude");
50
+ sanitized = sanitized.replace(/(^|[\s'"`])\.pi\//g, "$1.claude/");
51
+ sanitized = sanitized.replace(/\b\.pi\b/gi, ".claude");
52
+ sanitized = sanitized.replace(/\bpi\b/gi, "environment");
53
+ return sanitized;
39
54
  }
@@ -1,6 +1,6 @@
1
- // Status-line rendering helpers for the AskClaude tool.
1
+ // Status-line rendering helpers for the AskCodebuddy tool.
2
2
  //
3
- // While Claude Code runs inside an AskClaude call, the pi TUI can't surface
3
+ // While CodeBuddy runs inside an AskCodebuddy call, the pi TUI can't surface
4
4
  // each tool_use individually — there's only one status row for the whole
5
5
  // delegation. These helpers shape a tool_use record into a short, path-aware
6
6
  // label (e.g. "Read(src/foo.ts)", "Bash(git log --oneline…)") and collapse
@@ -65,7 +65,7 @@ export function formatToolAction(tc: ToolCallState): string | undefined {
65
65
  const label = current ? String(current.content ?? "").slice(0, 40) : "";
66
66
  return label || undefined;
67
67
  } else if (verb === "askclaude") {
68
- // Recursive — don't show AskClaude in its own action summary
68
+ // Recursive — don't show AskCodebuddy in its own action summary
69
69
  return undefined;
70
70
  }
71
71
  return tc.name;
@@ -0,0 +1,205 @@
1
+ // CodeBuddy session JSONL read/write for pi session sync (replaces cc-session-io).
2
+ // CodeBuddy stores sessions at ~/.codebuddy/projects/<sanitized-path>/<sessionId>.jsonl
3
+
4
+ import { randomUUID } from "crypto";
5
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
6
+ import { homedir } from "os";
7
+ import { join } from "path";
8
+ import type { Message as PiMessage } from "@earendil-works/pi-ai";
9
+ import { convertPiMessages, messageContentToText } from "./convert.js";
10
+
11
+ export interface CbMessage {
12
+ role: "user" | "assistant";
13
+ content: string;
14
+ }
15
+
16
+ export function getCodebuddyDir(codebuddyDir?: string): string {
17
+ return codebuddyDir ?? process.env.CODEBUDDY_CONFIG_DIR ?? join(homedir(), ".codebuddy");
18
+ }
19
+
20
+ export function normalizeProjectPath(projectPath: string): string {
21
+ return projectPath.replace(/\/+$/, "") || projectPath;
22
+ }
23
+
24
+ export function projectPathToHash(projectPath: string): string {
25
+ return normalizeProjectPath(projectPath).replace(/^\//, "").replace(/\//g, "-");
26
+ }
27
+
28
+ export function getProjectDir(projectPath: string, codebuddyDir?: string): string {
29
+ return join(getCodebuddyDir(codebuddyDir), "projects", projectPathToHash(projectPath));
30
+ }
31
+
32
+ export function getSessionPath(sessionId: string, projectPath: string, codebuddyDir?: string): string {
33
+ return join(getProjectDir(projectPath, codebuddyDir), `${sessionId}.jsonl`);
34
+ }
35
+
36
+ export function deleteSession(sessionId: string, projectPath: string, codebuddyDir?: string): void {
37
+ const jsonlPath = getSessionPath(sessionId, projectPath, codebuddyDir);
38
+ const dir = join(getProjectDir(projectPath, codebuddyDir), sessionId);
39
+ try { rmSync(jsonlPath, { force: true }); } catch { /* ignore */ }
40
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
41
+ }
42
+
43
+ function piToCbMessages(messages: PiMessage[]): CbMessage[] {
44
+ const { anthropicMessages } = convertPiMessages(messages);
45
+ const out: CbMessage[] = [];
46
+ for (const msg of anthropicMessages) {
47
+ if (msg.role === "user") {
48
+ const text = typeof msg.content === "string"
49
+ ? msg.content
50
+ : msg.content.map((b: any) => b.type === "text" ? b.text : b.type === "tool_result" ? `[tool_result:${b.tool_use_id}]` : `[${b.type}]`).join("\n");
51
+ out.push({ role: "user", content: text });
52
+ } else if (msg.role === "assistant") {
53
+ const text = typeof msg.content === "string"
54
+ ? msg.content
55
+ : msg.content.map((b: any) => b.type === "text" ? b.text : b.type === "tool_use" ? `[tool:${b.name}]` : `[${b.type}]`).join("\n");
56
+ out.push({ role: "assistant", content: text });
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+
62
+ function writeCbJsonl(sessionId: string, cwd: string, messages: CbMessage[]): string {
63
+ const jsonlPath = getSessionPath(sessionId, cwd);
64
+ mkdirSync(join(jsonlPath, ".."), { recursive: true });
65
+ const lines: string[] = [];
66
+ let parentId: string | undefined;
67
+ const ts = Date.now();
68
+ for (const msg of messages) {
69
+ const id = randomUUID();
70
+ if (msg.role === "user") {
71
+ lines.push(JSON.stringify({
72
+ id, timestamp: ts, type: "message", role: "user",
73
+ content: [{ type: "input_text", text: msg.content }],
74
+ providerData: { agent: "sdk" }, sessionId, cwd,
75
+ }));
76
+ parentId = id;
77
+ } else {
78
+ lines.push(JSON.stringify({
79
+ id, parentId, timestamp: ts, type: "message", role: "assistant",
80
+ status: "completed",
81
+ content: [{ type: "output_text", text: msg.content }],
82
+ providerData: { agent: "sdk" }, sessionId, cwd,
83
+ }));
84
+ parentId = id;
85
+ }
86
+ }
87
+ writeFileSync(jsonlPath, lines.join("\n") + (lines.length ? "\n" : ""));
88
+ return jsonlPath;
89
+ }
90
+
91
+ export class Session {
92
+ readonly sessionId: string;
93
+ readonly projectPath: string;
94
+ readonly codebuddyDir?: string;
95
+ readonly jsonlPath: string;
96
+ messages: CbMessage[] = [];
97
+
98
+ constructor(sessionId: string, projectPath: string, codebuddyDir?: string) {
99
+ this.sessionId = sessionId;
100
+ this.projectPath = projectPath;
101
+ this.codebuddyDir = codebuddyDir;
102
+ this.jsonlPath = getSessionPath(sessionId, projectPath, codebuddyDir);
103
+ }
104
+
105
+ importPiMessages(messages: PiMessage[]): void {
106
+ this.messages = piToCbMessages(messages);
107
+ }
108
+
109
+ save(): void {
110
+ writeCbJsonl(this.sessionId, this.projectPath, this.messages);
111
+ }
112
+ }
113
+
114
+ export interface CreateSessionOptions {
115
+ projectPath: string;
116
+ sessionId?: string;
117
+ model?: string;
118
+ codebuddyDir?: string;
119
+ }
120
+
121
+ export function createSession(opts: CreateSessionOptions): Session {
122
+ const sessionId = opts.sessionId ?? randomUUID();
123
+ return new Session(sessionId, opts.projectPath, opts.codebuddyDir);
124
+ }
125
+
126
+ export function repairToolPairing(messages: Array<{ role: string; content: unknown }>): Array<{ role: string; content: unknown }> {
127
+ const result: Array<{ role: string; content: unknown }> = [];
128
+ let pending: Set<string> | null = null;
129
+ const synthetic = (id: string) => ({
130
+ type: "tool_result",
131
+ tool_use_id: id,
132
+ content: "[no tool result recorded]",
133
+ is_error: true,
134
+ });
135
+ const flushPending = () => {
136
+ if (pending && pending.size > 0) {
137
+ result.push({ role: "user", content: [...pending].map(synthetic) });
138
+ }
139
+ pending = null;
140
+ };
141
+ for (const msg of messages) {
142
+ if (msg.role === "assistant") {
143
+ flushPending();
144
+ const ids = new Set<string>();
145
+ if (Array.isArray(msg.content)) {
146
+ for (const b of msg.content as Array<{ type?: string; id?: string }>) {
147
+ if (b.type === "tool_use" && typeof b.id === "string") ids.add(b.id);
148
+ }
149
+ }
150
+ result.push(msg);
151
+ pending = ids.size > 0 ? ids : null;
152
+ continue;
153
+ }
154
+ const blocks = Array.isArray(msg.content) ? msg.content as Array<{ type?: string; tool_use_id?: string }> : null;
155
+ const hasToolResults = blocks?.some((b) => b.type === "tool_result") ?? false;
156
+ if (!pending && !hasToolResults) {
157
+ result.push(msg);
158
+ continue;
159
+ }
160
+ const input = blocks ?? (typeof msg.content === "string" && msg.content ? [{ type: "text", text: msg.content }] : []);
161
+ const provided = new Set<string>();
162
+ const kept = input.filter((b: { type?: string; tool_use_id?: string }) => {
163
+ if (b.type !== "tool_result") return true;
164
+ if (b.tool_use_id && pending?.has(b.tool_use_id)) {
165
+ provided.add(b.tool_use_id);
166
+ return true;
167
+ }
168
+ return false;
169
+ });
170
+ if (pending) {
171
+ const missing = [...pending].filter((id) => !provided.has(id)).map(synthetic);
172
+ kept.unshift(...missing);
173
+ pending = null;
174
+ }
175
+ if (kept.length === 0) {
176
+ if (result.length === 0) {
177
+ result.push({ role: "user", content: [{ type: "text", text: "[orphaned tool result removed]" }] });
178
+ }
179
+ continue;
180
+ }
181
+ result.push({ ...msg, content: kept });
182
+ }
183
+ flushPending();
184
+ return result;
185
+ }
186
+
187
+ export function readSession(sessionId: string, projectPath: string, codebuddyDir?: string): Session | null {
188
+ const jsonlPath = getSessionPath(sessionId, projectPath, codebuddyDir);
189
+ if (!existsSync(jsonlPath)) return null;
190
+ const session = new Session(sessionId, projectPath, codebuddyDir);
191
+ const lines = readFileSync(jsonlPath, "utf-8").split("\n").filter(Boolean);
192
+ for (const line of lines) {
193
+ try {
194
+ const rec = JSON.parse(line);
195
+ if (rec.type === "message" && rec.role === "user") {
196
+ const text = rec.content?.find((b: any) => b.type === "input_text")?.text ?? messageContentToText(rec.content);
197
+ session.messages.push({ role: "user", content: text });
198
+ } else if (rec.type === "message" && rec.role === "assistant") {
199
+ const text = rec.content?.find((b: any) => b.type === "output_text")?.text ?? messageContentToText(rec.content);
200
+ session.messages.push({ role: "assistant", content: text });
201
+ }
202
+ } catch { /* skip malformed */ }
203
+ }
204
+ return session;
205
+ }