@gtrabanco/pi-nan-provider 1.2.1 → 1.3.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/AGENTS.md CHANGED
@@ -344,8 +344,26 @@ Since 1.0.0 the package version **aligns its MAJOR.MINOR with the pi release lin
344
344
  `src/reasoning-only-stream-guard.ts` (issue #21) keys on that marker;
345
345
  earlier comment/message text said "60,000 characters" and was corrected to
346
346
  the documented figures.
347
- - **pi 1.2.0 / pi-ai 1.2.0 compat** (verified 2026-10-14, the current verified
348
- baseline): the peer range stays `">=0.99.0 <2"` (1.2.0 is inside it). The pi-ai
347
+ - **pi 1.3.0 / pi-ai 1.3.0 compat** (verified 2026-10-15, the current verified
348
+ baseline): devDependencies bumped 1.2.0 → 1.3.0; the peer range stays
349
+ `">=0.99.0 <2"`. The pi-ai 1.3.0 `compat.d.ts` and `api/openai-completions.d.ts`
350
+ surfaces are byte-identical to 1.2.0; `index.d.ts` only drops the
351
+ `utils/assistant-message-frame` re-export (unused here), and the
352
+ `thinkingLevelMap.off` branch in `buildRequest` still fires. **pi-ai 1.3.0 lands
353
+ the upstream fix for issue #3**: `transformMessages` now DROPS cross-model
354
+ `thinking` blocks (`if (!isSameModel) return []`) instead of downgrading them to
355
+ unbounded text — the cross-model-thinking guard is therefore VERSION-GATED
356
+ (`CROSS_MODEL_GUARD_PI_AI_CEILING = "1.3.0"`; registers only on hosts older than
357
+ that, unknown version keeps it on) and is DEPRECATED for removal in the next
358
+ package release once the peer floor reaches `>=1.3.0`. `RETRYABLE_PROVIDER_ERROR_PATTERN`
359
+ expands with gateway/upstream wording ("service is temporarily unavailable" #10965,
360
+ upstream wrapper text #10842) and #10926 fixes OpenAI-compatible streams where
361
+ LiteLLM sends reasoning and content in the same chunk — both relevant to NaN's
362
+ LiteLLM gateway, both upstream-side. Package tests (319) and typecheck pass against
363
+ 1.3.0; the issue-3 acceptance tests were re-pointed at the new upstream reality
364
+ (thinking scenario pins pi-ai's drop; the opaque-400 reclassification contract is
365
+ exercised via oversized user content pi-ai cannot shrink).
366
+ - **pi 1.2.0 / pi-ai 1.2.0 compat** (verified 2026-10-14): the peer range stays `">=0.99.0 <2"` (1.2.0 is inside it). The pi-ai
349
367
  1.2.0 index/compat type surface is byte-identical to 1.0.0 (`index.d.ts` diff is
350
368
  empty); the only additions are `KnownProvider` gaining `"azure"` (renamed from
351
369
  `"azure-openai-responses"` in pi 1.0.3 — not used here), a new
package/README.es.md CHANGED
@@ -52,6 +52,8 @@ Al cambiar de modelo, pi-ai reenvía el razonamiento del modelo anterior como te
52
52
 
53
53
  Este paquete **elimina todos los bloques de razonamiento cross-model reenviados**, de modo que cambiar de un modelo de 1M de contexto a uno de 262K (`qwen3.6`) ya no desborda la ventana. Las respuestas y los tool results de los modelos no se tocan — solo se quitan sus trazas internas de razonamiento, así que `qwen3.6` puede seguir respondiendo sobre lo que hizo otro modelo. El razonamiento del mismo modelo no se toca nunca, y el guard solo actúa sobre peticiones dirigidas a los proveedores de este paquete. Pon `NAN_THINKING_GUARD=0` para desactivarlo.
54
54
 
55
+ > **pi 1.3.0+: este guard ya no se registra.** pi-ai 1.3.0 elimina el thinking cross-model por sí mismo (el fix upstream ya está publicado), así que el guard solo se activa en pi < 1.3.0 y está **deprecado — se eliminará en la próxima versión del paquete**. Si usas pi < 1.3.0, actualiza pi para mantener la mitigación; en pi ≥ 1.3.0 no hay que hacer nada.
56
+
55
57
  Si una petición sigue desbordando — guard desactivado, inflado que no es un bloque de razonamiento (tool outputs grandes, imágenes) o una ventana de destino más pequeña — NaN responde con el mismo 400 genérico en lugar de nombrar el desbordamiento, y la auto-compactación de pi no lo reconoce, así que la sesión se queda atascada en el techo. Por eso el proveedor **vuelve a comprobar el tamaño de la petición a la salida**: cuando llega ese 400 genérico para una petición estimada por encima de la ventana del modelo, el error se reescribe como un mensaje de context overflow que pi reconoce, de modo que compacta y reintenta en vez de bloquearse. Un 400 genérico en una petición dentro de la ventana no se toca, así que nunca se etiquetan mal errores no relacionados.
56
58
 
57
59
  ### ⏱️ Streams truncados intermitentes (auto-retry, sin stall silencioso)
@@ -239,6 +241,12 @@ bun run check-nan-mcp-server --json # JSON para máquinas
239
241
  bun run check-nan-mcp-server --issue # crea/refresca el issue
240
242
  ```
241
243
 
244
+ Las actualizaciones del SDK de pi (`@earendil-works/pi-ai` / `@earendil-works/pi-coding-agent`)
245
+ también están automatizadas: el workflow semanal `check-pi-sdk-versions` abre el issue
246
+ `sdk-monitor` **y** prepara un PR con el bump de devDependencies (`bun test` +
247
+ `bun run typecheck` + diff de superficie de tipos incluidos) para revisión humana — ver
248
+ `docs/sdk-version-monitoring.md`.
249
+
242
250
  ---
243
251
 
244
252
  ## 📊 Uso de Cuotas: `/nan-usage`
package/README.md CHANGED
@@ -52,6 +52,8 @@ When you switch models, pi-ai replays the previous model's reasoning as plain as
52
52
 
53
53
  This package **drops every replayed cross-model reasoning block**, so switching from a 1M-context model to a 262K one (`qwen3.6`) no longer overflows the window. The models' answers and tool results are untouched — only their internal reasoning traces are removed, so `qwen3.6` can still answer about what another model did. Same-model reasoning is never altered, and the guard only acts on requests targeting this package's providers. Set `NAN_THINKING_GUARD=0` to disable it.
54
54
 
55
+ > **pi 1.3.0+: this guard no longer registers.** pi-ai 1.3.0 drops cross-model thinking itself (the upstream fix landed), so the guard only activates on pi < 1.3.0 and is **deprecated — it will be removed in the next package release**. If you run pi < 1.3.0, update pi to keep the mitigation; no action is needed on pi ≥ 1.3.0.
56
+
55
57
  If a request still overflows — the guard is disabled, the inflation is not a reasoning block (large tool outputs, images), or the destination window is simply smaller — NaN answers the same generic 400 instead of naming the overflow, and pi's auto-compaction does not recognize it, so the session wedges at the ceiling. The provider therefore **re-checks the request size on the way out**: when that generic 400 arrives for a request estimated over the model's window, the error is rewritten into a context-overflow message pi recognizes, so it compacts and retries instead of stalling. A generic 400 on a within-window request is left untouched, so unrelated errors are never mislabelled.
56
58
 
57
59
  ### ⏱️ Intermittent truncated streams (auto-retry, no silent stall)
@@ -236,6 +238,11 @@ bun run check-nan-mcp-server --json # machine-readable JSON
236
238
  bun run check-nan-mcp-server --issue # create/refresh the issue
237
239
  ```
238
240
 
241
+ PI SDK updates (`@earendil-works/pi-ai` / `@earendil-works/pi-coding-agent`) are automated
242
+ too: the weekly `check-pi-sdk-versions` workflow files the `sdk-monitor` issue **and**
243
+ prepares a devDependencies-bump PR (`bun test` + `bun run typecheck` + type-surface diff
244
+ included) for human review — see `docs/sdk-version-monitoring.md`.
245
+
239
246
  ---
240
247
 
241
248
  ## 📊 Quota Usage: `/nan-usage`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtrabanco/pi-nan-provider",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "NaN Builders (api.nan.builders) model provider for pi - OpenAI-compatible registration with a models.dev-generated fallback, tier-aware live catalog, and an optional community media MCP bridge",
5
5
  "keywords": [
6
6
  "pi",
@@ -46,8 +46,8 @@
46
46
  "@earendil-works/pi-coding-agent": ">=0.99.0 <2"
47
47
  },
48
48
  "devDependencies": {
49
- "@earendil-works/pi-ai": "1.2.0",
50
- "@earendil-works/pi-coding-agent": "1.2.0",
49
+ "@earendil-works/pi-ai": "1.3.0",
50
+ "@earendil-works/pi-coding-agent": "1.3.0",
51
51
  "@types/bun": "1.4",
52
52
  "typescript": "7"
53
53
  },
@@ -1,7 +1,7 @@
1
1
  // This file is auto-generated by scripts/generate-models.ts
2
2
  // Do not edit manually — run `bun run generate-models` to update.
3
3
  //
4
- // Source: https://models.dev/api.json (provider "nan"), fetched 2026-10-16T17:43:45.590Z
4
+ // Source: https://models.dev/api.json (provider "nan"), fetched 2026-10-19T22:52:49.128Z
5
5
  // Provenance: every contextWindow/maxTokens/input/cost value traces to
6
6
  // models.dev or to the per-entry notes below. Nothing is invented; entries
7
7
  // models.dev documents incompletely are omitted and flagged instead.
@@ -459,7 +459,7 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
459
459
  export const GENERATED_CATALOG_META = {
460
460
  source: "https://models.dev/api.json",
461
461
  modelsDevProvider: "nan",
462
- fetchedAt: "2026-10-16T17:43:45.590Z",
462
+ fetchedAt: "2026-10-19T22:52:49.128Z",
463
463
  modelCount: 6,
464
464
  models: ["deepseek-v4-flash","gemma4","glm5.3-flash","mimo-v2.6-flash","qwen3.6","qwen3.8-flash"],
465
465
  notes: [
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Prepare an SDK update PR for the packages monitored by
4
+ * scripts/check-pi-sdk-versions.mjs: bump their devDependencies in
5
+ * package.json and emit the branch/PR metadata the
6
+ * `.github/workflows/check-pi-sdk-versions.yml` workflow needs to open the PR.
7
+ *
8
+ * This script ONLY edits package.json. Installing, testing, typechecking, the
9
+ * type-surface diff, git and `gh pr create` stay in the workflow so a failure
10
+ * anywhere aborts the PR.
11
+ *
12
+ * Usage:
13
+ * node scripts/prepare-sdk-update-pr.mjs --body-file /tmp/pr-body.md
14
+ *
15
+ * Output (stdout, single JSON line):
16
+ * {"updates":false}
17
+ * {"updates":true,"branch":"chore/pi-sdk-1.3.0","title":"...","bodyFile":"...",
18
+ * "bumps":[{"name":"@earendil-works/pi-ai","from":"1.2.0","to":"1.3.0"}]}
19
+ *
20
+ * Exit codes: 0 = nothing to do or prepared; 2 = unexpected failure.
21
+ */
22
+
23
+ import https from "node:https";
24
+ import { readFileSync, writeFileSync } from "node:fs";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const SDK_PACKAGES = ["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent"];
28
+
29
+ function npmLatest(packageName) {
30
+ return new Promise((resolve, reject) => {
31
+ const req = https.get(
32
+ `https://registry.npmjs.org/${encodeURIComponent(packageName)}`,
33
+ { headers: { accept: "application/json" } },
34
+ (res) => {
35
+ if (res.statusCode !== 200) {
36
+ reject(new Error(`npm registry returned ${res.statusCode} for ${packageName}`));
37
+ return;
38
+ }
39
+ let data = "";
40
+ res.on("data", (chunk) => (data += chunk));
41
+ res.on("end", () => {
42
+ const latest = JSON.parse(data)["dist-tags"]?.["latest"];
43
+ if (!latest) reject(new Error(`No "latest" dist-tag for ${packageName}`));
44
+ else resolve(latest);
45
+ });
46
+ },
47
+ );
48
+ req.on("error", reject);
49
+ req.setTimeout(15_000, () => {
50
+ req.destroy();
51
+ reject(new Error(`Timeout fetching ${packageName} from npm`));
52
+ });
53
+ });
54
+ }
55
+
56
+ async function main() {
57
+ const bodyFileArg = process.argv.indexOf("--body-file");
58
+ const bodyFile =
59
+ bodyFileArg !== -1 ? process.argv[bodyFileArg + 1] : "/tmp/sdk-update-pr-body.md";
60
+
61
+ const packageUrl = new URL("../package.json", import.meta.url);
62
+ const raw = readFileSync(packageUrl, "utf-8");
63
+ const pkg = JSON.parse(raw);
64
+
65
+ const bumps = [];
66
+ for (const name of SDK_PACKAGES) {
67
+ const from = pkg.devDependencies?.[name];
68
+ if (!from) throw new Error(`${name} is not a devDependency of this package`);
69
+ const to = await npmLatest(name);
70
+ if (to !== from) bumps.push({ name, from, to });
71
+ }
72
+
73
+ if (bumps.length === 0) {
74
+ console.log(JSON.stringify({ updates: false }));
75
+ return;
76
+ }
77
+
78
+ // Bump via exact-string replacement so the rest of package.json (ordering,
79
+ // indentation, other fields) is untouched.
80
+ let updated = raw;
81
+ for (const bump of bumps) {
82
+ const needle = `"${bump.name}": "${bump.from}"`;
83
+ if (!updated.includes(needle)) {
84
+ throw new Error(`Could not locate ${needle} in package.json to bump it`);
85
+ }
86
+ updated = updated.replace(needle, `"${bump.name}": "${bump.to}"`);
87
+ }
88
+ writeFileSync(packageUrl, updated);
89
+
90
+ const versionTag = bumps.map((b) => b.to).join("-");
91
+ const branch = `chore/pi-sdk-${versionTag}`;
92
+ const title = `chore: bump PI SDK to ${bumps.map((b) => `${b.name.split("/")[1]} ${b.to}`).join(" / ")}`;
93
+ const peer = pkg.peerDependencies ?? {};
94
+
95
+ const body = `## PI SDK update (automated)
96
+
97
+ Prepared from the weekly \`check-pi-sdk-versions\` run. Resolves the \`sdk-monitor\` issue
98
+ for this cycle. **A human must review the upstream changelog before merging** — a minor
99
+ pi release can change provider-relevant behavior (see \`AGENTS.md\` "Verified API facts").
100
+
101
+ ### Version bumps
102
+
103
+ | Package | From | To | Peer range |
104
+ | --- | --- | --- | --- |
105
+ ${bumps.map((b) => `| \`${b.name}\` | ${b.from} | **${b.to}** | \`${peer[b.name] ?? "n/a"}\` |`).join("\n")}
106
+
107
+ ### Review checklist
108
+
109
+ - [ ] Upstream release notes reviewed (https://pi.dev/changelog)
110
+ - [ ] \`bun test\` + \`bun run typecheck\` green (executed by CI before this PR was opened)
111
+ - [ ] Type-surface diff (below) contains no removals this package uses
112
+ - [ ] Version policy applied: package MAJOR.MINOR realigns with the pi release line
113
+ (\`package.json\` version bump included in this PR when the pi MINOR moved)
114
+
115
+ The results of the two sections below were produced by CI while preparing this PR.
116
+
117
+ ## Verification
118
+
119
+ <!-- VERIFICATION -->
120
+
121
+ ## Type-surface diff (previous → new)
122
+
123
+ <details><summary>Click to expand</summary>
124
+
125
+ <!-- SURFACE_DIFF -->
126
+
127
+ </details>
128
+ `;
129
+
130
+ writeFileSync(bodyFile, body);
131
+ console.log(
132
+ JSON.stringify({
133
+ updates: true,
134
+ branch,
135
+ title,
136
+ bodyFile,
137
+ bumps,
138
+ }),
139
+ );
140
+ }
141
+
142
+ main().catch((err) => {
143
+ console.error("Fatal:", err.message);
144
+ process.exit(2);
145
+ });
@@ -27,14 +27,53 @@
27
27
  * provider stream). It therefore sees the original `thinking` blocks and can
28
28
  * remove them before they become text.
29
29
  *
30
- * This is a bounded mitigation, not the fix: the unbounded conversion (and pi
31
- * not re-checking the context size on a model switch) belongs upstream. See
32
- * https://github.com/gtrabanco/pi-nan-provider/issues/3.
30
+ * UPSTREAM UPDATE (pi-ai 1.3.0, verified 2026-10-15): `transformMessages` now
31
+ * drops cross-model `thinking` blocks itself (`if (!isSameModel) return []`),
32
+ * which is the upstream fix this guard was mitigating. The guard is therefore
33
+ * GATED BY VERSION: it only registers when the host's pi-ai is older than
34
+ * {@link CROSS_MODEL_GUARD_PI_AI_CEILING} (1.3.0). An unknown host version
35
+ * keeps the guard on — the conservative default, since the guard is harmless
36
+ * where pi-ai already drops the blocks and essential where it still replays
37
+ * them.
38
+ *
39
+ * DEPRECATED — REMOVE THIS MODULE when the peer range floor rises to
40
+ * `>=1.3.0`: at that point every supported pi ships the upstream drop and this
41
+ * file (plus the `NAN_THINKING_GUARD` opt-out) is dead code. Users on
42
+ * pi < 1.3.0 should update pi to keep getting the mitigation for free.
33
43
  */
34
44
 
45
+ import { parsePiAiVersion } from "./pi-ai-version.ts";
46
+
35
47
  /** Env var that opts out of the guard (`0`, `false`, `no` or `off`). Default: enabled. */
36
48
  export const NAN_THINKING_GUARD_ENV = "NAN_THINKING_GUARD";
37
49
 
50
+ /**
51
+ * Exclusive pi-ai version ceiling for this guard: it registers only while the
52
+ * host's pi-ai is OLDER than this version. pi-ai 1.3.0 drops cross-model
53
+ * thinking in `transformMessages` (upstream fix for issue #3), so from 1.3.0
54
+ * on the guard is redundant. Remove this constant together with the module
55
+ * once the peer range floor reaches this version.
56
+ */
57
+ export const CROSS_MODEL_GUARD_PI_AI_CEILING = "1.3.0";
58
+
59
+ /**
60
+ * Whether the guard still applies to a host running the given pi-ai version.
61
+ * `undefined`/`null` (unknown host version) and unparseable versions return
62
+ * `true` — the conservative older-pi default. `false` means the host's pi-ai
63
+ * already drops cross-model thinking itself and the guard must not register.
64
+ */
65
+ export function crossModelThinkingGuardCovers(piAiVersion: string | null | undefined): boolean {
66
+ if (piAiVersion === null || piAiVersion === undefined) return true;
67
+ const parsed = parsePiAiVersion(piAiVersion);
68
+ if (!parsed) return true;
69
+ const ceiling = parsePiAiVersion(CROSS_MODEL_GUARD_PI_AI_CEILING);
70
+ if (!ceiling) return true;
71
+ return (
72
+ parsed.major < ceiling.major ||
73
+ (parsed.major === ceiling.major && parsed.minor < ceiling.minor)
74
+ );
75
+ }
76
+
38
77
  export interface CrossModelThinkingGuardOptions {
39
78
  /** Provider ids this guard applies to (the caller's registered providers). */
40
79
  providerIds: ReadonlySet<string>;
package/src/index.ts CHANGED
@@ -30,9 +30,11 @@ import type { Provider } from "@earendil-works/pi-ai";
30
30
  import { registerNanMcpCommand } from "./commands.ts";
31
31
  import { registerNanUsageCommand } from "./usage.ts";
32
32
  import {
33
+ crossModelThinkingGuardCovers,
33
34
  crossModelThinkingGuardEnabled,
34
35
  stripCrossModelThinking,
35
36
  } from "./cross-model-thinking-guard.ts";
37
+ import { resolvePiAiVersion } from "./pi-ai-version.ts";
36
38
  import { baselineModels } from "./fetch-models.ts";
37
39
  import { createNanCompatibleProvider, type OpenAICompatibleProviderConfig } from "./provider-factory.ts";
38
40
  import { PROVIDERS } from "./providers.ts";
@@ -146,21 +148,34 @@ async function registerMcpServersNative(pi: ExtensionAPI): Promise<void> {
146
148
  /**
147
149
  * Drop the reasoning pi-ai replays across a model switch.
148
150
  *
149
- * pi-ai's `transformMessages` downgrades a previous model's `thinking` blocks
150
- * to plain text with no size bound (verified on 0.87.1), and nothing
151
- * bounds the sum across messages — measured at 30–60% of the whole context on
152
- * real sessions. Switching from a 1M-context model to a 262K one (`qwen3.6`)
153
- * then overflows the window, and NaN's gateway answers a generic
151
+ * On pi-ai < 1.3.0, `transformMessages` downgrades a previous model's
152
+ * `thinking` blocks to plain text with no size bound (verified on 0.87.1), and
153
+ * nothing bounds the sum across messages — measured at 30–60% of the whole
154
+ * context on real sessions. Switching from a 1M-context model to a 262K one
155
+ * (`qwen3.6`) then overflows the window, and NaN's gateway answers a generic
154
156
  * `400 Invalid request. Check your request parameters.` This hook runs before
155
157
  * pi-ai converts the blocks, so removing the cross-model reasoning here keeps
156
158
  * the replayed context small. Answers and tool results are untouched. See
157
159
  * src/cross-model-thinking-guard.ts.
158
160
  *
161
+ * VERSION-GATED: pi-ai 1.3.0 drops cross-model thinking itself (upstream fix
162
+ * for issue #3), so the guard only registers when the host's resolved pi-ai is
163
+ * older than 1.3.0; an undeterminable host version keeps it on (conservative
164
+ * older-pi default). DEPRECATED — remove this whole guard once the peer range
165
+ * floor rises to `>=1.3.0`; users on pi < 1.3.0 should update pi.
166
+ *
159
167
  * Scope: only requests targeting this package's providers are touched, and
160
168
  * only messages from a DIFFERENT model — same-model reasoning is never removed.
161
169
  */
162
- export function registerCrossModelThinkingGuard(pi: ExtensionAPI): void {
170
+ export function registerCrossModelThinkingGuard(
171
+ pi: ExtensionAPI,
172
+ options: { piAiVersion?: string | null } = {},
173
+ ): void {
163
174
  if (typeof pi.on !== "function") return; // old pi without the context hook
175
+ // `null` forces the unknown-version path (guard on); omitted resolves the
176
+ // host's pi-ai version at runtime; a string pins the version (tests).
177
+ const piAiVersion = options.piAiVersion !== undefined ? options.piAiVersion : resolvePiAiVersion();
178
+ if (!crossModelThinkingGuardCovers(piAiVersion)) return;
164
179
  const providerIds = new Set(PROVIDERS.map((provider) => provider.id));
165
180
  pi.on("context", (event, ctx) => {
166
181
  if (!crossModelThinkingGuardEnabled()) return;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Runtime pi-ai version detection.
3
+ *
4
+ * Resolves the `@earendil-works/pi-ai` package instance the HOST loaded — the
5
+ * same host-anchored strategy `src/pi-ai-loader.ts` uses, because an
6
+ * extension-relative resolve can return a stale copy from the extension's own
7
+ * npm tree (issue #8) — and reads that copy's package.json version. The result
8
+ * feeds version-gated behavior, currently the cross-model thinking guard
9
+ * (registered only while the host's pi-ai is older than 1.3.0, where pi-ai
10
+ * still replays cross-model thinking as unbounded text).
11
+ *
12
+ * Failure is NOT an error: an unresolvable package or an unreadable version
13
+ * returns `undefined`, and callers fall back to the conservative older-pi
14
+ * behavior (the guard stays on — harmless on modern hosts, essential on old
15
+ * ones). Guarded by `test/pi-ai-version.test.ts`.
16
+ */
17
+
18
+ import { readFileSync } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { hostAnchorUrl, PI_AI_PACKAGE_SPECIFIER, resolvePiAiSpecifier } from "./pi-ai-loader.ts";
22
+
23
+ /** A parsed semver triple (`1.3.0-beta.1` → `{ major: 1, minor: 3, patch: 0 }`). */
24
+ export interface PiAiVersion {
25
+ major: number;
26
+ minor: number;
27
+ patch: number;
28
+ }
29
+
30
+ /** Parse a semver string; prerelease/build suffixes are ignored. */
31
+ export function parsePiAiVersion(version: string): PiAiVersion | undefined {
32
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version.trim());
33
+ if (!match) return undefined;
34
+ return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
35
+ }
36
+
37
+ /**
38
+ * Read the pi-ai version from a resolved specifier (path or `file://` URL) of
39
+ * any entry file inside the package. Walks up from the entry's directory until
40
+ * a package.json whose `name` is exactly `@earendil-works/pi-ai` is found —
41
+ * matching the name prevents picking up an unrelated package.json in a
42
+ * workspace layout — and returns its `version`. Bounded walk (8 levels);
43
+ * `undefined` when nothing matches.
44
+ */
45
+ export function piAiVersionFromResolvedEntry(entry: string): string | undefined {
46
+ let dir: string;
47
+ try {
48
+ dir = dirname(entry.startsWith("file://") ? fileURLToPath(entry) : entry);
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ for (let depth = 0; depth < 8; depth++) {
53
+ try {
54
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8")) as {
55
+ name?: unknown;
56
+ version?: unknown;
57
+ };
58
+ if (pkg.name === PI_AI_PACKAGE_SPECIFIER && typeof pkg.version === "string") {
59
+ return pkg.version;
60
+ }
61
+ } catch {
62
+ // No readable package.json at this level — keep walking up.
63
+ }
64
+ const parent = dirname(dir);
65
+ if (parent === dir) return undefined;
66
+ dir = parent;
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ /** Cache for {@link resolvePiAiVersion}; resolution runs once per process. */
72
+ let cachedVersion: string | undefined;
73
+ let resolved = false;
74
+
75
+ /**
76
+ * Version of the pi-ai instance the host loaded, or `undefined` when it cannot
77
+ * be determined (unresolvable specifier, unreadable package.json, odd runtime).
78
+ * Resolution happens once and is cached for the process lifetime.
79
+ */
80
+ export function resolvePiAiVersion(): string | undefined {
81
+ if (resolved) return cachedVersion;
82
+ resolved = true;
83
+ try {
84
+ const entry = resolvePiAiSpecifier(PI_AI_PACKAGE_SPECIFIER, {
85
+ anchorUrl: hostAnchorUrl(process.argv[1]),
86
+ });
87
+ cachedVersion = piAiVersionFromResolvedEntry(entry);
88
+ } catch {
89
+ cachedVersion = undefined;
90
+ }
91
+ return cachedVersion;
92
+ }