pi-codebuddy-sdk 0.4.30 → 0.4.31

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 CHANGED
@@ -20,7 +20,7 @@ Restart `pi` if it was already running.
20
20
 
21
21
  ## Requirements
22
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)).
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)). That option selects the CLI entry point only; the JavaScript runtime used to run it comes from the host, and a Bun-compiled host without a `bun` on `PATH` needs `provider.executable` (see below).
24
24
  - **CodeBuddy auth already working** — if `codebuddy` works in your terminal, you do not need extra setup for this extension.
25
25
 
26
26
  ## Quick start (already using CodeBuddy)
@@ -101,7 +101,8 @@ File: `~/.pi/agent/codebuddy-sdk.json` or `.pi/codebuddy-sdk.json` in a project.
101
101
  | `askCodebuddy.allowFullMode` | `true` | Allow write-capable delegation mode |
102
102
  | `provider.appendSystemPrompt` | `true` | Use Pi's system prompt instead of CodeBuddy's default identity |
103
103
  | `provider.strictMcpConfig` | `true` | Use only Pi-bridged MCP tools |
104
- | `provider.pathToCodebuddyCode` | auto | Path to `codebuddy` when it is **not** on `PATH` |
104
+ | `provider.pathToCodebuddyCode` | auto | Path to the `codebuddy` **CLI entry point** when it is **not** on `PATH`; does not change the JS runtime |
105
+ | `provider.executable` | host runtime | Runtime used to spawn the CLI (e.g. `"node"`). Set it when the host is Bun-compiled (no `bun` on `PATH`); auto-detected otherwise |
105
106
  | `provider.contextWindow` | estimated | Override context window (tokens) for **all** models (e.g. `300000`) |
106
107
  | `provider.maxTokens` | estimated | Override max output tokens for **all** models (e.g. `32768`) |
107
108
  | `provider.modelOverrides` | — | Per-model overrides, keyed by model id or substring (see below) |
@@ -122,6 +123,12 @@ key wins and beats the global `contextWindow` / `maxTokens`:
122
123
 
123
124
  Supported per-model keys: `contextWindow`, `maxTokens`, `reasoning`, `images`.
124
125
 
126
+ Window values the backend reports for a model are remembered per model id in
127
+ `~/.pi/agent/codebuddy-sdk-learned-windows.json`, so the next start registers the
128
+ last served values instead of the name estimate. Precedence: `modelOverrides` >
129
+ learned > served metadata > estimate. Delete that file to start from estimates
130
+ again (ids the backend no longer serves are dropped automatically).
131
+
125
132
  ## Privacy
126
133
 
127
134
  - The extension does **not** send conversation data to this repository or any third-party telemetry endpoint.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codebuddy-sdk",
3
- "version": "0.4.30",
3
+ "version": "0.4.31",
4
4
  "description": "Pi extension that uses CodeBuddy (via Agent SDK) as a model provider and adds an AskCodebuddy tool.",
5
5
  "author": "RealAlexandreAI",
6
6
  "license": "MIT",
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Pick a runtime the host can actually spawn for the CodeBuddy CLI subprocess.
3
+ *
4
+ * The Agent SDK resolves the child runtime from the *host* runtime
5
+ * (`@tencent-ai/agent-sdk/lib/transport/process-transport.js`, `resolveRuntime()`):
6
+ * when `process.versions.bun` is set it always spawns `bun`, and `node` is only
7
+ * the final default. Bun-compiled hosts — `omp`, or any single-file Bun binary —
8
+ * ship no `bun` executable on `PATH`, so every spawn fails with
9
+ * `Executable not found in $PATH: "bun"` and neither model discovery nor a turn
10
+ * can run.
11
+ *
12
+ * When the host is Bun and `bun` is not resolvable, hand the SDK an explicit
13
+ * `executable` (its branch 2) pointing at `node`. `provider.executable` in
14
+ * `codebuddy-sdk.json` overrides the automatic choice.
15
+ */
16
+ import { accessSync, constants } from "node:fs";
17
+ import { delimiter, join } from "node:path";
18
+
19
+ export interface RuntimeHost {
20
+ /** `process.versions` */
21
+ versions?: Record<string, string | undefined>;
22
+ /** `process.env.PATH` */
23
+ path?: string;
24
+ }
25
+
26
+ function currentHost(): RuntimeHost {
27
+ return { versions: process.versions as Record<string, string | undefined>, path: process.env.PATH };
28
+ }
29
+
30
+ /** True when `name` is a runnable file in one of the `PATH` entries. */
31
+ export function isOnPath(name: string, pathValue: string | undefined): boolean {
32
+ if (!name || !pathValue) return false;
33
+ for (const dir of pathValue.split(delimiter)) {
34
+ if (!dir) continue;
35
+ try {
36
+ accessSync(join(dir, name), constants.X_OK);
37
+ return true;
38
+ } catch {
39
+ // Not here — keep looking.
40
+ }
41
+ }
42
+ return false;
43
+ }
44
+
45
+ /**
46
+ * The runtime the CLI subprocess needs, or `undefined` when the SDK's own choice
47
+ * works (a node/deno host, or a Bun host that has a `bun` on `PATH`).
48
+ */
49
+ export function resolveCliRuntime(host: RuntimeHost = currentHost()): string | undefined {
50
+ if (!host.versions?.bun) return undefined;
51
+ if (isOnPath("bun", host.path)) return undefined;
52
+ return isOnPath("node", host.path) ? "node" : undefined;
53
+ }
54
+
55
+ /**
56
+ * Return `args` with `options.executable` filled in when the host cannot spawn
57
+ * the SDK's default runtime. An explicit `executable` — on the call or in the
58
+ * config — always wins.
59
+ */
60
+ export function withCliRuntime<T extends { options?: { executable?: string } }>(
61
+ args: T,
62
+ configuredExecutable?: string,
63
+ host: RuntimeHost = currentHost(),
64
+ ): T {
65
+ const options = args?.options;
66
+ if (options?.executable) return args;
67
+ const executable = configuredExecutable?.trim() || resolveCliRuntime(host);
68
+ if (!executable) return args;
69
+ return { ...args, options: { ...options, executable } };
70
+ }
package/src/config.ts CHANGED
@@ -27,6 +27,11 @@ export interface Config {
27
27
  settingSources?: SettingSource[];
28
28
  strictMcpConfig?: boolean;
29
29
  pathToCodebuddyCode?: string;
30
+ /**
31
+ * Runtime used to spawn the CodeBuddy CLI (e.g. `"node"`). Defaults to the
32
+ * host runtime; set this when a Bun-compiled host has no `bun` on `PATH`.
33
+ */
34
+ executable?: string;
30
35
  /** Override the estimated context window (tokens) for ALL models. */
31
36
  contextWindow?: number;
32
37
  /** Override the estimated max output tokens for ALL models. */
package/src/index.ts CHANGED
@@ -9,10 +9,12 @@ import { appendFileSync, mkdirSync, realpathSync, statSync } from "fs";
9
9
  import { homedir } from "os";
10
10
  import { dirname, join } from "path";
11
11
  import { PROVIDER_ID, messageContentToText, convertPiMessages } from "./convert.js";
12
- import { buildModels, codebuddyModelId, FALLBACK_MODELS, rawModelsFromSdk, resolveModel as _resolveModel, type PiModel } from "./models.js";
12
+ import { buildModels, codebuddyModelId, FALLBACK_MODELS, matchesAnyOverrideKey, rawModelsFromSdk, resolveModel as _resolveModel, type PiModel } from "./models.js";
13
13
  import { readModelsCache, writeModelsCache } from "./models-cache.js";
14
+ import { applyLearnedWindows, pruneLearnedWindows, readLearnedWindows, recordServedWindows, writeLearnedWindows } from "./learned-windows.js";
14
15
  import { clearOwnedStream, providerRegistration } from "./provider-registration.js";
15
16
  import { syncServedWindow } from "./served-window.js";
17
+ import { withCliRuntime } from "./cli-runtime.js";
16
18
  import { readTranscript } from "./transcript-compat.js";
17
19
  import { MCP_SERVER_NAME, MCP_TOOL_PREFIX, buildCodebuddySystemPrompt } from "./skills.js";
18
20
  import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.js";
@@ -165,7 +167,7 @@ function installTransportTeardownGuard(sdkQuery: unknown): void {
165
167
 
166
168
  /** Create an SDK query and immediately arm the transport teardown guard. */
167
169
  function startQuery(args: Parameters<typeof query>[0]): ReturnType<typeof query> {
168
- const q = query(args);
170
+ const q = query(withCliRuntime(args, providerSettings.executable));
169
171
  try {
170
172
  installTransportTeardownGuard(q);
171
173
  } catch (err) {
@@ -896,6 +898,12 @@ function syncServedContextWindow(label: string, message: CbMessage, model: Model
896
898
  for (const change of changes) {
897
899
  debug(`${label}: ${change.id} ${change.field} ${change.from} -> ${change.to}`);
898
900
  }
901
+ // Remember the served values so the next process starts from them instead of
902
+ // re-registering the estimate (this backend reports no window metadata).
903
+ if (changes.length) {
904
+ const learned = readLearnedWindows();
905
+ if (recordServedWindows(learned, changes)) writeLearnedWindows(learned);
906
+ }
899
907
  }
900
908
 
901
909
  // --- Effort level mapping ---
@@ -1735,6 +1743,12 @@ async function discoverModels(pi: ExtensionAPI): Promise<void> {
1735
1743
  providerSettings.modelOverrides ? undefined : providerSettings,
1736
1744
  providerSettings.modelOverrides,
1737
1745
  );
1746
+ // Re-apply windows learned in earlier processes: they beat the name
1747
+ // estimate and the served metadata, but never a user override. Ids the
1748
+ // backend no longer serves are dropped from the store.
1749
+ const learned = pruneLearnedWindows(readLearnedWindows(), MODELS.map((m) => m.id));
1750
+ MODELS = applyLearnedWindows(MODELS, learned, (id) => matchesAnyOverrideKey(id, providerSettings.modelOverrides));
1751
+ writeLearnedWindows(learned);
1738
1752
  // Persist the successful discovery globally so a later session
1739
1753
  // resume (module reload) can recover the real model list even if
1740
1754
  // discovery fails that time (e.g. SDK subprocess gate is busy).
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Served context windows, remembered across processes.
3
+ *
4
+ * This backend reports no window metadata, so every model is registered from a
5
+ * name estimate. The served value only arrives per turn, inside `modelUsage`;
6
+ * `syncServedWindow` applies it to the catalog entry and the live model, but
7
+ * only in memory. Without persistence that knowledge dies with the process:
8
+ * the next `discoverModels` re-registers the estimate and rewrites the model
9
+ * cache, so each restart shows the guess again for every model the user has not
10
+ * overridden by hand.
11
+ *
12
+ * Keep the last served value per model id on disk and re-apply it after
13
+ * `buildModels()`. Precedence: user `modelOverrides` > learned > SDK metadata >
14
+ * name estimate.
15
+ */
16
+ import { homedir } from "os";
17
+ import { dirname, join } from "path";
18
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
19
+ import type { WindowSyncChange } from "./served-window.js";
20
+
21
+ export interface LearnedWindow {
22
+ contextWindow?: number;
23
+ maxTokens?: number;
24
+ }
25
+
26
+ export type LearnedWindows = Record<string, LearnedWindow>;
27
+
28
+ /** In-process cache so repeated reads in one session do not touch the disk. */
29
+ export const LEARNED_WINDOWS_KEY = Symbol.for("codebuddy-sdk:learnedWindows");
30
+
31
+ const defaultStoreFile = () => join(homedir(), ".pi", "agent", "codebuddy-sdk-learned-windows.json");
32
+
33
+ // Injectable for tests.
34
+ const storeFile = () => process.env.CODEBUDDY_SDK_LEARNED_WINDOWS_PATH || defaultStoreFile();
35
+
36
+ function sanitize(value: unknown): LearnedWindow | undefined {
37
+ if (!value || typeof value !== "object") return undefined;
38
+ const raw = value as LearnedWindow;
39
+ const entry: LearnedWindow = {};
40
+ if (Number.isSafeInteger(raw.contextWindow) && (raw.contextWindow as number) > 0) entry.contextWindow = raw.contextWindow;
41
+ if (Number.isSafeInteger(raw.maxTokens) && (raw.maxTokens as number) > 0) entry.maxTokens = raw.maxTokens;
42
+ return Object.keys(entry).length ? entry : undefined;
43
+ }
44
+
45
+ export function readLearnedWindows(): LearnedWindows {
46
+ const inProcess = (globalThis as Record<symbol, unknown>)[LEARNED_WINDOWS_KEY] as LearnedWindows | undefined;
47
+ if (inProcess) return inProcess;
48
+ try {
49
+ const parsed: unknown = JSON.parse(readFileSync(storeFile(), "utf8"));
50
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
51
+ const map: LearnedWindows = {};
52
+ for (const [id, value] of Object.entries(parsed as Record<string, unknown>)) {
53
+ const entry = sanitize(value);
54
+ if (entry) map[id] = entry;
55
+ }
56
+ return map;
57
+ }
58
+ } catch {
59
+ // No store yet, or unreadable — the caller falls back to estimates.
60
+ }
61
+ return {};
62
+ }
63
+
64
+ export function writeLearnedWindows(map: LearnedWindows): void {
65
+ (globalThis as Record<symbol, unknown>)[LEARNED_WINDOWS_KEY] = map;
66
+ try {
67
+ const path = storeFile();
68
+ mkdirSync(dirname(path), { recursive: true });
69
+ const tmp = `${path}.tmp`;
70
+ const sorted = Object.fromEntries(Object.entries(map).sort(([a], [b]) => a.localeCompare(b)));
71
+ writeFileSync(tmp, JSON.stringify(sorted));
72
+ renameSync(tmp, path);
73
+ } catch {
74
+ // Store is best-effort; the next turn re-learns the value.
75
+ }
76
+ }
77
+
78
+ /** Fold served-window changes into `map`. Returns true when something moved. */
79
+ export function recordServedWindows(map: LearnedWindows, changes: readonly WindowSyncChange[]): boolean {
80
+ let touched = false;
81
+ for (const change of changes) {
82
+ if (!Number.isSafeInteger(change.to) || change.to <= 0) continue;
83
+ const entry = (map[change.id] ??= {});
84
+ if (entry[change.field] === change.to) continue;
85
+ entry[change.field] = change.to;
86
+ touched = true;
87
+ }
88
+ return touched;
89
+ }
90
+
91
+ /** Drop ids the backend no longer serves. */
92
+ export function pruneLearnedWindows(map: LearnedWindows, ids: Iterable<string>): LearnedWindows {
93
+ const keep = new Set(ids);
94
+ const out: LearnedWindows = {};
95
+ for (const [id, value] of Object.entries(map)) {
96
+ if (keep.has(id)) out[id] = value;
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /**
102
+ * Overwrite estimated windows with learned ones. Matching is by exact id on
103
+ * purpose — override keys use substring matching, learned ids must not leak into
104
+ * neighbouring models. `skip` keeps user overrides authoritative.
105
+ */
106
+ export function applyLearnedWindows<T extends { id: string; contextWindow: number; maxTokens: number }>(
107
+ models: readonly T[],
108
+ learned: LearnedWindows,
109
+ skip?: (id: string) => boolean,
110
+ ): T[] {
111
+ return models.map((model) => {
112
+ const entry = learned[model.id];
113
+ if (!entry || skip?.(model.id)) return model;
114
+ return {
115
+ ...model,
116
+ contextWindow: entry.contextWindow ?? model.contextWindow,
117
+ maxTokens: entry.maxTokens ?? model.maxTokens,
118
+ };
119
+ });
120
+ }
package/src/models.ts CHANGED
@@ -24,12 +24,28 @@ type SdkModelMeta = {
24
24
  contextWindow?: { supportedLengths?: ReadonlyArray<number>; defaultLength?: number };
25
25
  };
26
26
 
27
+ // Capabilities are inferred from the model id: this backend returns `{id, name}`
28
+ // only, so there is no metadata to read (unlike the window, capabilities cannot
29
+ // be learned from `modelUsage` either). `modelOverrides.reasoning` / `.images`
30
+ // still win over every hint below.
31
+ //
32
+ // The families cover the CodeBuddy catalog, whose entries are reasoning-only and
33
+ // image-capable apart from the two text-only models called out in
34
+ // `TEXT_ONLY_MODELS`. Reasoning hints stay broad (`glm`, `deepseek`, …) and image
35
+ // hints are version-scoped so an unverified model keeps the conservative default.
36
+ const REASONING_HINTS = /claude|gemini|gpt-5|glm|hy[34]|deepseek|kimi|minimax|step|space/i;
37
+ const IMAGE_HINTS = /claude|gemini|gpt|glm-5[.v]|hy[34]|deepseek-v4|kimi-k[23]|minimax-m[23]|step|space/i;
38
+ // Verified text-only: the CodeBuddy catalog marks both `supportsImages: false`,
39
+ // and a 1x1 PNG probe came back with "the current model doesn't support image
40
+ // reading" from each.
41
+ const TEXT_ONLY_MODELS = new Set(["glm-5.1", "glm-5.2"]);
42
+
27
43
  function detectThinking(id: string): boolean {
28
- return /claude|gemini|gpt-5|hy3|deepseek|glm/i.test(id);
44
+ return REASONING_HINTS.test(id);
29
45
  }
30
46
 
31
47
  function detectImages(id: string): boolean {
32
- return /claude|gemini|gpt/i.test(id);
48
+ return !TEXT_ONLY_MODELS.has(id.toLowerCase()) && IMAGE_HINTS.test(id);
33
49
  }
34
50
 
35
51
  function estimateContext(id: string): number {
@@ -87,6 +103,21 @@ export type ModelOverrides = {
87
103
  * (matched by exact id first, then case-insensitive substring, longest key
88
104
  * wins) and beats the global defaults.
89
105
  */
106
+ /**
107
+ * True when a `modelOverrides` key selects `id`: exact match, or a
108
+ * case-insensitive substring.
109
+ */
110
+ export function overrideKeyMatches(id: string, key: string): boolean {
111
+ const k = key.toLowerCase();
112
+ return id === key || (k.length > 0 && id.toLowerCase().includes(k));
113
+ }
114
+
115
+ /** True when any `modelOverrides` key selects `id`. */
116
+ export function matchesAnyOverrideKey(id: string, perModel?: Record<string, unknown>): boolean {
117
+ if (!perModel) return false;
118
+ return Object.keys(perModel).some((key) => overrideKeyMatches(id, key));
119
+ }
120
+
90
121
  export function buildModels(
91
122
  models: PiModel[],
92
123
  globalOverrides?: ModelOverrides,
@@ -94,10 +125,8 @@ export function buildModels(
94
125
  ): PiModel[] {
95
126
  const sortedKeys = Object.keys(perModel ?? {}).sort((a, b) => b.length - a.length);
96
127
  const match = (id: string): ModelOverrides | undefined => {
97
- const lower = id.toLowerCase();
98
128
  for (const key of sortedKeys) {
99
- const k = key.toLowerCase();
100
- if (id === key || (lower.includes(k) && k.length > 0)) return perModel![key];
129
+ if (overrideKeyMatches(id, key)) return perModel![key];
101
130
  }
102
131
  return undefined;
103
132
  };