pi-codebuddy-sdk 0.4.24 → 0.4.26

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.4.24",
3
+ "version": "0.4.26",
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",
@@ -40,7 +40,7 @@
40
40
  "@earendil-works/pi-ai": "^1.1.0",
41
41
  "@earendil-works/pi-coding-agent": "^1.1.0",
42
42
  "@earendil-works/pi-tui": "^1.1.0",
43
- "typebox": "1.3.36"
43
+ "typebox": "1.3.27"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@earendil-works/pi-ai": "^1.1.0",
package/src/index.ts CHANGED
@@ -11,6 +11,8 @@ import { dirname, join } from "path";
11
11
  import { PROVIDER_ID, messageContentToText, convertPiMessages } from "./convert.js";
12
12
  import { buildModels, codebuddyModelId, FALLBACK_MODELS, rawModelsFromSdk, resolveModel as _resolveModel, type PiModel } from "./models.js";
13
13
  import { readModelsCache, writeModelsCache } from "./models-cache.js";
14
+ import { clearOwnedStream, providerRegistration } from "./provider-registration.js";
15
+ import { syncServedWindow } from "./served-window.js";
14
16
  import { readTranscript } from "./transcript-compat.js";
15
17
  import { MCP_SERVER_NAME, MCP_TOOL_PREFIX, buildCodebuddySystemPrompt } from "./skills.js";
16
18
  import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.js";
@@ -174,7 +176,7 @@ function startQuery(args: Parameters<typeof query>[0]): ReturnType<typeof query>
174
176
 
175
177
  // --- Constants ---
176
178
 
177
- // Global key to prevent re-registration of the provider across module reloads.
179
+ // Global key for the first streamSimple that owns the shared ModelRegistry.
178
180
  //
179
181
  // Extensions like pi-subagents spawn a subagent and it loads this module
180
182
  // again. Without this guard, the subagent's call to registerProvider() would
@@ -182,13 +184,14 @@ function startQuery(args: Parameters<typeof query>[0]): ReturnType<typeof query>
182
184
  // ModelRegistry. When the parent later delivers a tool result, it would call
183
185
  // the subagent's `streamSimple` (which has empty state) instead of its own.
184
186
  //
185
- // By storing the active streamSimple in a Symbol.for() global (shared across all
186
- // module instances), we ensure only the FIRST instance to register takes effect.
187
- // Subsequent instances wrap the stored function instead of overwriting it.
187
+ // The first instance stores its streamSimple in a Symbol.for() global, keyed
188
+ // by the ModelRegistry seen at session start. The same registry reuses that
189
+ // stream. A different registry still registers, with its own stream, because
190
+ // a host such as pi-web gives each session its own ModelRuntime.
188
191
  //
189
192
  // On session_shutdown (including /reload), clearSession() resets this so a fresh
190
193
  // registration can occur for the next session.
191
- const ACTIVE_STREAM_SIMPLE_KEY = Symbol.for("codebuddy-sdk:activeStreamSimple");
194
+ const PROVIDER_NAME = "CodeBuddy";
192
195
 
193
196
  const SDK_TO_PI_TOOL_NAME: Record<string, string> = {
194
197
  read: "read", write: "write", edit: "edit", bash: "bash",
@@ -206,6 +209,22 @@ function resolveModel(input: string) {
206
209
  return _resolveModel(MODELS, input);
207
210
  }
208
211
 
212
+ function registerCodebuddyProvider(pi: ExtensionAPI, globals: Record<symbol, any> = globalThis as Record<symbol, any>): void {
213
+ const { streamFn, claimStream } = providerRegistration(globals, streamCodebuddySdk, boundRuntime);
214
+ pi.registerProvider(PROVIDER_ID, {
215
+ name: PROVIDER_NAME,
216
+ baseUrl: PROVIDER_ID,
217
+ apiKey: "not-used",
218
+ api: "codebuddy-sdk",
219
+ models: MODELS as any,
220
+ // Cast: pi-ai AssistantMessageEventStream diamond dep between pi-coding-agent and pi-agent-core
221
+ streamSimple: streamFn as any,
222
+ });
223
+ if (!claimStream) {
224
+ debug(`provider: reusing parent streamSimple (module=${moduleInstanceId})`);
225
+ }
226
+ }
227
+
209
228
  // --- Error handling ---
210
229
 
211
230
  function errorMessage(err: unknown): string {
@@ -461,7 +480,7 @@ async function runIsolatedSummary(
461
480
  if (block.type === "text" && typeof block.text === "string") assistantText += block.text;
462
481
  }
463
482
  } else if (message.type === "result") {
464
- logServedContextWindow("compact summary", message, model);
483
+ syncServedContextWindow("compact summary", message, model);
465
484
  if (message.subtype === "success") {
466
485
  finalText = message.result || assistantText;
467
486
  } else {
@@ -761,6 +780,7 @@ function mapToolArgs(
761
780
  // Global (not query state):
762
781
  let piUI: ExtensionUIContext | null = null;
763
782
  const activeQueryContexts = new Set<QueryContext>();
783
+ let boundRuntime: object | undefined;
764
784
 
765
785
  function contextForToolResults(results: McpResult[]): QueryContext | undefined {
766
786
  for (const result of results) {
@@ -848,18 +868,26 @@ function updateUsage(output: AssistantMessage, usage: Record<string, number | un
848
868
  debug(`usage: in=${output.usage.input} out=${output.usage.output} cacheRead=${output.usage.cacheRead} cacheWrite=${output.usage.cacheWrite} total=${output.usage.totalTokens}${reasoningText} cachePct=${cachePct}% model=${model.id}`);
849
869
  }
850
870
 
851
- // Log the *served* context window reported by an SDK result message
852
- // (modelUsage[id].contextWindow), which can differ from the window pi
853
- // registered (model.contextWindow) when the runtime entitlement doesn't
854
- // match the docs — e.g. bare Opus served 200K on Pro, or [1m] not honored.
855
- // The result message's modelUsage is otherwise discarded; this makes the
856
- // gap observable. See issue #18.
857
- function logServedContextWindow(label: string, message: CbMessage, model: Model<any>): void {
871
+ // Apply the served window from an SDK result (modelUsage[id]) onto the
872
+ // registered catalog and the live model pi reads for context usage. The
873
+ // served value wins over the registration estimate when the entitlement
874
+ // differs, e.g. bare Opus served 200K on Pro.
875
+ function syncServedContextWindow(label: string, message: CbMessage, model: Model<any>): void {
858
876
  const modelUsage = (message as any).modelUsage as Record<string, { contextWindow?: number; maxOutputTokens?: number }> | undefined;
859
877
  if (!modelUsage) return;
860
878
  for (const [k, v] of Object.entries(modelUsage)) {
861
879
  debug(`${label}: served contextWindow=${v.contextWindow ?? "?"} maxOutputTokens=${v.maxOutputTokens ?? "?"} servedModel=${k} registered=${model.contextWindow}`);
862
880
  }
881
+ const changes = syncServedWindow(modelUsage, (id) => {
882
+ const targets: Array<{ contextWindow?: number; maxTokens?: number }> = [];
883
+ const registered = MODELS.find((m) => m.id === id);
884
+ if (registered) targets.push(registered);
885
+ if (model.id === id) targets.push(model);
886
+ return targets;
887
+ });
888
+ for (const change of changes) {
889
+ debug(`${label}: ${change.id} ${change.field} ${change.from} -> ${change.to}`);
890
+ }
863
891
  }
864
892
 
865
893
  // --- Effort level mapping ---
@@ -1142,7 +1170,7 @@ async function consumeQuery(
1142
1170
  break;
1143
1171
  case "result": {
1144
1172
  const resultMsg = message as Extract<CbMessage, { type: "result" }>;
1145
- logServedContextWindow("result", message, model);
1173
+ syncServedContextWindow("result", message, model);
1146
1174
  if (!queryCtx.turnSawStreamEvent && resultMsg.subtype === "success") {
1147
1175
  ensureTurnStarted(queryCtx);
1148
1176
  const text = resultMsg.result || "";
@@ -1706,16 +1734,7 @@ async function discoverModels(pi: ExtensionAPI): Promise<void> {
1706
1734
  // recover it synchronously at module load — the in-process global
1707
1735
  // alone dies with the old process.
1708
1736
  writeModelsCache(MODELS);
1709
- const g = globalThis as Record<symbol, any>;
1710
- const streamFn = g[ACTIVE_STREAM_SIMPLE_KEY] ?? streamCodebuddySdk;
1711
- pi.registerProvider(PROVIDER_ID, {
1712
- name: "CodeBuddy",
1713
- baseUrl: PROVIDER_ID,
1714
- apiKey: "not-used",
1715
- api: "codebuddy-sdk",
1716
- models: MODELS as any,
1717
- streamSimple: streamFn as any,
1718
- });
1737
+ registerCodebuddyProvider(pi);
1719
1738
  modelsDiscovered = true;
1720
1739
  debug(`discoverModels: registered ${MODELS.length} models from SDK`);
1721
1740
  } catch (err) {
@@ -1728,16 +1747,7 @@ async function discoverModels(pi: ExtensionAPI): Promise<void> {
1728
1747
  if (cached && cached.length) {
1729
1748
  MODELS = cached;
1730
1749
  debug(`discoverModels: failed, recovered ${MODELS.length} cached models`, err);
1731
- const g = globalThis as Record<symbol, any>;
1732
- const streamFn = g[ACTIVE_STREAM_SIMPLE_KEY] ?? streamCodebuddySdk;
1733
- pi.registerProvider(PROVIDER_ID, {
1734
- name: "CodeBuddy",
1735
- baseUrl: PROVIDER_ID,
1736
- apiKey: "not-used",
1737
- api: "codebuddy-sdk",
1738
- models: MODELS as any,
1739
- streamSimple: streamFn as any,
1740
- });
1750
+ registerCodebuddyProvider(pi);
1741
1751
  modelsDiscovered = true;
1742
1752
  } else {
1743
1753
  debug("discoverModels: failed, using fallback models", err);
@@ -1787,14 +1797,14 @@ export default async function (pi: ExtensionAPI) {
1787
1797
  // Clear the global streamSimple if this instance registered it.
1788
1798
  // This allows /reload to work — the old instance clears the flag so
1789
1799
  // the new instance can register fresh without wrapping stale state.
1790
- const g = globalThis as Record<symbol, any>;
1791
- if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamCodebuddySdk) {
1800
+ if (clearOwnedStream(globalThis as Record<symbol, any>, streamCodebuddySdk)) {
1792
1801
  debug(`${event}: clearing ACTIVE_STREAM_SIMPLE_KEY`);
1793
- g[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
1794
1802
  }
1795
1803
  };
1796
1804
  pi.on("session_start", (event, ctx) => {
1797
1805
  piUI = ctx.ui;
1806
+ boundRuntime = ctx.modelRegistry;
1807
+ registerCodebuddyProvider(pi);
1798
1808
  if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
1799
1809
  clearSession(`session_start:${event.reason}`);
1800
1810
  }
@@ -1852,31 +1862,10 @@ export default async function (pi: ExtensionAPI) {
1852
1862
 
1853
1863
  // --- Provider ---
1854
1864
  //
1855
- // Guard against re-registration when the module is loaded multiple times
1856
- // (e.g., when spawning subagents). The shared ModelRegistry would otherwise
1857
- // overwrite the parent's streamSimple, breaking tool result delivery.
1858
- // See ACTIVE_STREAM_SIMPLE_KEY for the full mechanism.
1859
-
1860
- const g = globalThis as Record<symbol, any>;
1861
- if (!g[ACTIVE_STREAM_SIMPLE_KEY]) {
1862
- // First instance: store our streamSimple and register.
1863
- g[ACTIVE_STREAM_SIMPLE_KEY] = streamCodebuddySdk;
1864
- pi.registerProvider(PROVIDER_ID, {
1865
- name: "CodeBuddy",
1866
- baseUrl: PROVIDER_ID,
1867
- apiKey: "not-used",
1868
- api: "codebuddy-sdk",
1869
- models: MODELS as any,
1870
- // Cast: pi-ai AssistantMessageEventStream diamond dep between pi-coding-agent and pi-agent-core
1871
- streamSimple: streamCodebuddySdk as any,
1872
- });
1873
- } else {
1874
- // Subsequent instance (subagent session): skip registration entirely.
1875
- // The subagent already has access to codebuddy-sdk models via the shared
1876
- // ModelRegistry from the parent's registration. Calls to those models
1877
- // route through the parent's streamSimple via reentrant QueryContexts.
1878
- debug(`provider: skipping re-registration, parent instance active (module=${moduleInstanceId})`);
1879
- }
1865
+ // Register on every runtime. The same ModelRegistry keeps the first
1866
+ // streamSimple; a different runtime gets its own.
1867
+
1868
+ registerCodebuddyProvider(pi);
1880
1869
 
1881
1870
  // --- AskCodebuddy tool ---
1882
1871
 
package/src/models.ts CHANGED
@@ -16,6 +16,14 @@ export type PiModel = {
16
16
  const DEFAULT_CONTEXT = 131_072;
17
17
  const DEFAULT_MAX_TOKENS = 8192;
18
18
 
19
+ type SdkModelMeta = {
20
+ id?: string;
21
+ name?: string;
22
+ maxInputTokens?: number;
23
+ maxOutputTokens?: number;
24
+ contextWindow?: { supportedLengths?: ReadonlyArray<number>; defaultLength?: number };
25
+ };
26
+
19
27
  function detectThinking(id: string): boolean {
20
28
  return /claude|gemini|gpt-5|hy3|deepseek|glm/i.test(id);
21
29
  }
@@ -36,17 +44,28 @@ function estimateMaxTokens(id: string): number {
36
44
  return DEFAULT_MAX_TOKENS;
37
45
  }
38
46
 
39
- export function rawModelsFromSdk(supported: Array<ModelInfo & { id?: string; name?: string }>): PiModel[] {
47
+ function positive(value: number | undefined): number | undefined {
48
+ return value && value > 0 ? value : undefined;
49
+ }
50
+
51
+ function contextFromMetadata(model: SdkModelMeta): number | undefined {
52
+ const lengths = model.contextWindow?.supportedLengths?.filter((n) => n > 0) ?? [];
53
+ return positive(model.maxInputTokens)
54
+ ?? positive(model.contextWindow?.defaultLength)
55
+ ?? (lengths.length ? Math.max(...lengths) : undefined);
56
+ }
57
+
58
+ export function rawModelsFromSdk(supported: Array<ModelInfo & SdkModelMeta>): PiModel[] {
40
59
  return supported
41
- .map((m) => ({ id: m.id ?? m.value, name: m.name ?? m.displayName ?? m.id ?? m.value }))
60
+ .map((m) => ({ ...m, id: m.id ?? m.value, name: m.name ?? m.displayName ?? m.id ?? m.value }))
42
61
  .filter((m) => m.id)
43
62
  .map((m) => ({
44
63
  id: m.id!,
45
64
  name: m.name || m.id!,
46
65
  reasoning: detectThinking(m.id!),
47
66
  input: detectImages(m.id!) ? ["text", "image"] as const : ["text"] as const,
48
- contextWindow: estimateContext(m.id!),
49
- maxTokens: estimateMaxTokens(m.id!),
67
+ contextWindow: contextFromMetadata(m) ?? estimateContext(m.id!),
68
+ maxTokens: positive(m.maxOutputTokens) ?? estimateMaxTokens(m.id!),
50
69
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
51
70
  }));
52
71
  }
@@ -0,0 +1,40 @@
1
+ // Process-wide stream owner for the shared-registry case (CLI subagents).
2
+ // A fresh ModelRuntime (pi-web per chat) does not see the first registration,
3
+ // so each runtime still needs its own provider entry.
4
+ export const ACTIVE_STREAM_SIMPLE_KEY = Symbol.for("codebuddy-sdk:activeStreamSimple");
5
+
6
+ export interface ProviderRegistration {
7
+ streamFn: unknown;
8
+ claimStream: boolean;
9
+ }
10
+
11
+ interface ActiveStream {
12
+ runtime: object | undefined;
13
+ streamFn: unknown;
14
+ }
15
+
16
+ export function providerRegistration(
17
+ globals: Record<symbol, unknown>,
18
+ ownStream: unknown,
19
+ runtime: object | undefined,
20
+ ): ProviderRegistration {
21
+ const active = globals[ACTIVE_STREAM_SIMPLE_KEY] as ActiveStream | undefined;
22
+ if (!active) {
23
+ globals[ACTIVE_STREAM_SIMPLE_KEY] = { runtime, streamFn: ownStream };
24
+ return { streamFn: ownStream, claimStream: true };
25
+ }
26
+ if (runtime !== undefined && active.runtime === runtime) {
27
+ return { streamFn: active.streamFn, claimStream: false };
28
+ }
29
+ if (runtime === undefined || active.runtime === undefined) {
30
+ return { streamFn: active.streamFn, claimStream: false };
31
+ }
32
+ return { streamFn: ownStream, claimStream: false };
33
+ }
34
+
35
+ export function clearOwnedStream(globals: Record<symbol, unknown>, ownStream: unknown): boolean {
36
+ const active = globals[ACTIVE_STREAM_SIMPLE_KEY] as ActiveStream | undefined;
37
+ if (!active || active.streamFn !== ownStream) return false;
38
+ globals[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
39
+ return true;
40
+ }
@@ -0,0 +1,37 @@
1
+ export interface ServedWindow {
2
+ contextWindow?: number;
3
+ maxOutputTokens?: number;
4
+ }
5
+
6
+ export interface WindowTarget {
7
+ contextWindow?: number;
8
+ maxTokens?: number;
9
+ }
10
+
11
+ export interface WindowSyncChange {
12
+ id: string;
13
+ field: "contextWindow" | "maxTokens";
14
+ from: number | undefined;
15
+ to: number;
16
+ }
17
+
18
+ export function syncServedWindow(
19
+ usage: Record<string, ServedWindow> | undefined,
20
+ targetsFor: (id: string) => WindowTarget[],
21
+ ): WindowSyncChange[] {
22
+ if (!usage) return [];
23
+ const changes: WindowSyncChange[] = [];
24
+ for (const [id, served] of Object.entries(usage)) {
25
+ for (const target of targetsFor(id)) {
26
+ if (served.contextWindow && served.contextWindow > 0 && target.contextWindow !== served.contextWindow) {
27
+ changes.push({ id, field: "contextWindow", from: target.contextWindow, to: served.contextWindow });
28
+ target.contextWindow = served.contextWindow;
29
+ }
30
+ if (served.maxOutputTokens && served.maxOutputTokens > 0 && target.maxTokens !== served.maxOutputTokens) {
31
+ changes.push({ id, field: "maxTokens", from: target.maxTokens, to: served.maxOutputTokens });
32
+ target.maxTokens = served.maxOutputTokens;
33
+ }
34
+ }
35
+ }
36
+ return changes;
37
+ }