@lincoln504/pi-research 1.6.6 → 1.6.8

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
@@ -44,21 +44,20 @@ npm install -g @lincoln504/pi-research
44
44
  pi-research skill install
45
45
  ```
46
46
 
47
- On npm ≥11.19 (and npm 12), dependency install scripts are skipped by default. This
48
- package needs none of them: better-sqlite3 13 ships prebuilt bindings for every
49
- supported platform inside its own tarball, and the stealth browser (~500MB)
50
- self-provisions on its first use instead of at install time — so the first scrape
51
- takes a few minutes. Nothing needs approving. (On older npm, which still runs
52
- scripts, a Windows machine without Visual Studio Build Tools can fail while npm
53
- needlessly recompiles better-sqlite3; upgrading npm fixes that too.)
47
+ No extra setup is needed. The package uses no install scripts: it ships
48
+ ready-made bindings for every platform, and the stealth browser (~500MB)
49
+ downloads on first use, so the first scrape takes a few minutes. npm ≥11.19
50
+ skips install scripts by default; leave it that way, nothing needs approving
51
+ or building. Only Windows with npm older than 11.19 can fail when the install
52
+ tries to compile from source; upgrading npm fixes it.
54
53
 
55
54
  In pi it works out of the box on the session's model and pi's configuration. Standalone use ([agent skill](docs/AGENT-SKILL.md) or [SDK](docs/SDK.md)) needs a model configured. See [Configuration](docs/CONFIGURATION.md).
56
55
 
57
56
  ### How it works
58
57
 
59
- A research run loops through agent teams: a coordinator plans and starts with a search, researcher agents scrape and read in parallel, and a research lead decides whether to go another round — then writes the report from every report collected. The result is one cited Markdown report with findings optionally saved to the knowledge store.
58
+ A research run loops through agent teams: a coordinator plans and starts with a search, researcher agents scrape and read in parallel, and a research lead decides whether to go another round, then writes the report from every report collected. The result is one cited Markdown report with findings optionally saved to the knowledge store.
60
59
 
61
- Three depth levels (normal, deep, ultra) set the team size and number of rounds. A natural-language request is enough — the tool picks the right one:
60
+ Three depth levels (normal, deep, ultra) set the team size and number of rounds. A natural-language request is enough; the tool picks the right one:
62
61
 
63
62
  ![Prompt-driven multi-round research in the pi TUI](https://raw.githubusercontent.com/Lincoln504/pi-research/main/docs/media/02-prompt-research.gif)
64
63
 
@@ -73,7 +72,7 @@ Three depth levels (normal, deep, ultra) set the team size and number of rounds.
73
72
 
74
73
  - **No quota and no monthly fee.** Most AI search providers cap free searches and then charge, with results served from their index on their servers. pi-research searches and scrapes locally through DuckDuckGo in a stealth browser.
75
74
  - **The index is local.** Every finding can be saved to a local [LanceDB](https://lancedb.com) knowledge store that seeds future runs, so repeat questions get faster and cheaper answers.
76
- - **Read-only by design.** The research agent cannot run shell commands or write, edit, or delete anything — the right shape for an agent whose whole job is reading untrusted web pages. Prompt injection picked up mid-run has nothing to act on.
75
+ - **Read-only by design.** The research agent cannot run shell commands or write, edit, or delete anything, which is the right shape for an agent whose whole job is reading untrusted web pages. Prompt injection picked up mid-run has nothing to act on.
77
76
  - **Search a little or a lot.** Depth levels range from a quick pass to a large-scale investigation.
78
77
 
79
78
  ### Requirements / limitations
@@ -82,6 +81,7 @@ Three depth levels (normal, deep, ultra) set the team size and number of rounds.
82
81
  - An LLM with a 100k+ context window (an API key or a local model)
83
82
  - Internet access on a residential IP, since search, scraping, and YouTube transcripts get bot-blocked from datacenter/VPS/cloud IPs
84
83
  - pi. The pi extension uses the host's copy while the standalone CLI and agent skill install it as a dependency.
84
+ - Local knowledge-store embeddings need `@huggingface/transformers`, which is an **optional dependency**: if its native image chain (sharp) cannot install on your machine, npm skips it with a warning, the install still succeeds, and everything except local embeddings works. The store disables embeddings gracefully with a message telling you how to restore them.
85
85
  - Cloudflare Turnstile and similar systems block scraping on some sites. A run compensates with a wide pool of search results to scrape.
86
86
 
87
87
  ### Channels
@@ -2,7 +2,7 @@
2
2
  name: pi-research
3
3
  description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
4
4
  license: MIT
5
- metadata: { "author": "Lincoln Deen", "version": "1.6.6", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.6.8", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
package/dist/cli.mjs CHANGED
@@ -4196,10 +4196,11 @@ function isNativeStackUnavailableError(err) {
4196
4196
  const msg2 = (err instanceof Error ? `${err.message} ${err.stack ?? ""}` : String(err)).toLowerCase();
4197
4197
  const code = typeof err === "object" && "code" in err ? String(err.code) : "";
4198
4198
  if (code === "ERR_DLOPEN_FAILED") return true;
4199
- if ((code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") && (msg2.includes("onnxruntime") || msg2.includes("lancedb"))) {
4199
+ if (code === "TRANSFORMERS_UNAVAILABLE") return true;
4200
+ if ((code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") && (msg2.includes("onnxruntime") || msg2.includes("lancedb") || msg2.includes("@huggingface/transformers"))) {
4200
4201
  return true;
4201
4202
  }
4202
- return msg2.includes("cannot find native binding") || msg2.includes("onnxruntime_binding.node") || /cannot find module '[^']*onnxruntime/.test(msg2) || /cannot find module '[^']*lancedb/.test(msg2) || msg2.includes("knowledgestoreservice is not a constructor");
4203
+ return msg2.includes("cannot find native binding") || msg2.includes("onnxruntime_binding.node") || /cannot find module '[^']*onnxruntime/.test(msg2) || /cannot find module '[^']*lancedb/.test(msg2) || /cannot find (?:package|module) ['"]@huggingface[/\\]transformers['"]/i.test(msg2) || msg2.includes("knowledgestoreservice is not a constructor");
4203
4204
  }
4204
4205
  function getModelCacheDir() {
4205
4206
  const xdgCache = process.env["XDG_CACHE_HOME"];
@@ -4292,13 +4293,37 @@ var init_embedder_utils = __esm({
4292
4293
  });
4293
4294
 
4294
4295
  // src/knowledge/transformers-loader.ts
4296
+ function isTransformersMissingError(err) {
4297
+ if (!err || typeof err !== "object") return false;
4298
+ const code = "code" in err ? String(err.code) : "";
4299
+ const msg2 = err instanceof Error ? err.message : String(err);
4300
+ return (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") && /@huggingface[/\\]transformers/.test(msg2);
4301
+ }
4302
+ function transformersUnavailableError(cause) {
4303
+ const err = new Error(
4304
+ "Optional dependency '@huggingface/transformers' is not installed, so local embeddings are unavailable. npm skips this package when its native image chain cannot install (most often a failing sharp platform binary). The knowledge store runs without embeddings until it is present \u2014 reinstall this package to re-resolve its optional dependencies (pi install npm:@lincoln504/pi-research), or install it directly (npm install @huggingface/transformers).",
4305
+ { cause }
4306
+ );
4307
+ err.code = TRANSFORMERS_UNAVAILABLE;
4308
+ return err;
4309
+ }
4295
4310
  async function getTransformers() {
4296
- return _transformers ??= await import("@huggingface/transformers");
4311
+ if (_transformers) return _transformers;
4312
+ try {
4313
+ _transformers = await import("@huggingface/transformers");
4314
+ } catch (err) {
4315
+ if (isTransformersMissingError(err)) {
4316
+ throw transformersUnavailableError(err);
4317
+ }
4318
+ throw err;
4319
+ }
4320
+ return _transformers;
4297
4321
  }
4298
- var _transformers;
4322
+ var TRANSFORMERS_UNAVAILABLE, _transformers;
4299
4323
  var init_transformers_loader = __esm({
4300
4324
  "src/knowledge/transformers-loader.ts"() {
4301
4325
  "use strict";
4326
+ TRANSFORMERS_UNAVAILABLE = "TRANSFORMERS_UNAVAILABLE";
4302
4327
  }
4303
4328
  });
4304
4329
 
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.6.8] - 2026-08-31
9
+
10
+ ### Changed
11
+
12
+ - README npm install note rewritten for clarity (no behavior or claim changes): the three tangled topics (script skipping on npm ≥11.19, better-sqlite3's bundled prebuilds, the browser's first-use download) are now three plain sentences with an explicit do and don't, and the em dashes are gone. This is the README the package site renders, so the entry exists to timestamp that refresh.
13
+
14
+ ## [1.6.7] - 2026-08-31
15
+
16
+ Two threads from the issue tracker collide here: #10 ("Installation via NPM fails")
17
+ turned out to be an install-time sharp build failure we could — and now did — design
18
+ away, while the skew guard shipped across 1.5.x–1.6.6 rested on a premise about pi's
19
+ module loading that is not how pi works.
20
+
21
+ ### Fixed
22
+
23
+ - **`pi install` no longer fails outright on machines where sharp's native image chain cannot install (#10).** The failure chain, reproduced and pinned: `@huggingface/transformers` (the local-embeddings backend) declared sharp `^0.34.5` as a regular dependency; when npm skipped sharp's platform binary (`@img/sharp-*` optionals — the npm/cli#4828 pruning family on existing trees for npm 10.3.0–11.2.x, or any interrupted install, which the re-resolve then will NOT heal because skipped optionals stay inert), sharp's install script fell back to a source build that itself is broken (node-addon-api is only a devDependency), and the whole install died with `npm error code 1` — the extension never arrived. `@huggingface/transformers` is now an **optional dependency** (the same pattern `@lancedb/lancedb` already uses for it): npm drops a failing optional subtree with a warning and the install succeeds; research, scraping, and the CLI all work without embeddings. The absence is a first-class state at runtime too: `getTransformers()` reclassifies the missing-module rejection as a typed `TRANSFORMERS_UNAVAILABLE` error carrying remediation (`src/knowledge/transformers-loader.ts`), `isNativeStackUnavailableError()` recognizes it (`src/knowledge/embedder-utils.ts`), and the knowledge store therefore memoizes DISABLED on first touch instead of re-running the full init + 5-attempt backoff storm (~15s) on every store use. A transformers-INTERNAL failure (its own onnxruntime import, a corrupt install) still routes through the pre-existing onnxruntime/lancedb arms — a broken install of a PRESENT package is not "the optional was skipped". Retry storms, probe behavior, and startup were verified: the WebGPU probe subprocess already treats a missing module as "no WebGPU" and is unreachable before the env init anyway.
24
+
25
+ ### Removed
26
+
27
+ - **The pi-ai/pi-coding-agent "skew guard" is gone** (`src/core/pi-ai-skew.ts`; shipped in 1.6.5 as fail-fast, reworked in 1.6.6 to walk node_modules ancestors). The premise does not survive contact with pi's loader: pi resolves EVERY `@earendil-works/*` and `typebox` specifier in extension code to the HOST's own copies via jiti `virtualModules`/`alias` (verified in the 0.84.2 and 0.84.4 loader source — coverage includes the exact four specifiers this package imports), so the on-disk copies the guard compared are never loaded in-host. Under the host the guard measured dependencies nobody executes; its FATAL 'stale' refusal would have blocked a healthy extension after every host upgrade the moment the managed tree's inert copies lagged — and the 1.6.6 ancestor-walk made that state reachable on the primary `pi install npm:` layout (pi's own install code uses `--legacy-peer-deps` precisely because it expects extensions to declare these packages as peers, not dependencies). Host-version compatibility stays where it belongs: `checkPiCompatibility()` against the running host's VERSION — refuse below the 0.84.0 floor, warn once above the tested line — which is now the only startup check. The standalone CLI/SDK never ran the guard (it lived solely in the extension entry), so removal changes nothing there. What actually caused the original 2026-08-30 named-export crash remains unproven — in-host specifier mixing should have been impossible under 0.84.2's alias table — and is recorded here honestly rather than preserved as a false safety net.
28
+
29
+ ### Verified
30
+
31
+ - 2993 unit tests over 236 files (11 new: the transformers-absent typed error and its classification — ESM and CJS not-found forms, transformers-internal failures passing through untouched, unrelated-package precision, and the DISABLED-memoization handoff to `isNativeStackUnavailableError`), plus lint, all four type-checks (TS6 and TS7 native, src and tests), dependency-cruiser, the audit gate, the full integration suite (57 passed / 8 skipped parallel, 103 serial), and the tarball manifest gate (235 files — one fewer with the deleted guard module). CLI boot verified end-to-end with the entire transformers/sharp/@img subtree physically absent from the install tree.
32
+
8
33
  ## [1.6.6] - 2026-08-31
9
34
 
10
35
  Issue #9 reported deep-research runs stalling for minutes inside planning salvage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.6.6",
3
+ "version": "1.6.8",
4
4
  "description": "Web research for your agents with smart and safe tooling + knowledge store",
5
5
  "type": "module",
6
6
  "private": false,
@@ -117,7 +117,6 @@
117
117
  "@earendil-works/pi-ai": ">=0.84.0 <1",
118
118
  "@earendil-works/pi-coding-agent": ">=0.84.0 <1",
119
119
  "@earendil-works/pi-tui": ">=0.84.0 <1",
120
- "@huggingface/transformers": "4.2.0",
121
120
  "@kreuzberg/html-to-markdown-node": "^3.7.2",
122
121
  "@lancedb/lancedb": "0.37.1",
123
122
  "apache-arrow": "21.1.0",
@@ -135,6 +134,7 @@
135
134
  "youtubei.js": "^18.0.0"
136
135
  },
137
136
  "optionalDependencies": {
137
+ "@huggingface/transformers": "4.2.0",
138
138
  "webgpu": "^0.4.0"
139
139
  },
140
140
  "overrides": {
package/src/index.ts CHANGED
@@ -12,7 +12,6 @@ import { createResearchTool, createHealthTool } from './tool.ts';
12
12
  import { createResearchKnowledgeSearchTool } from './tools/research-knowledge-search.ts';
13
13
  import { logger } from './logger.ts';
14
14
  import { checkPiCompatibility } from './core/pi-version.ts';
15
- import { checkPiAiSkew } from './core/pi-ai-skew.ts';
16
15
  import { randomUUID } from 'node:crypto';
17
16
  import { shutdownManager } from './utils/shutdown-manager.ts';
18
17
  import { healthRegistry } from './healthcheck/index.ts';
@@ -102,11 +101,14 @@ export default async function (pi: ExtensionAPI) {
102
101
  // host auto-building its ModelRuntime. This in-host check is the real
103
102
  // enforcement point: when running as an extension the host's version is NOT
104
103
  // constrained by npm's resolution of our own package.json range.
105
- // Two thresholds, not one — see src/core/pi-version.ts. Below the floor the APIs
106
- // we call do not exist, so we refuse. Above the last TESTED line we warn and
107
- // continue: pi is pre-1.0, a minor bump can break anything under semver (0.83.0
108
- // already extended the ResourceLoader contract), and our own dependency range
109
- // cannot constrain the host at all when running as an extension.
104
+ //
105
+ // This is the ONLY version check at startup, and it needs to be: pi's extension
106
+ // loader aliases every @earendil-works/* and typebox specifier to the HOST's own
107
+ // copies (virtualModules/alias in the loader), so the copies in our install tree
108
+ // are never loaded in-host and comparing them against the host version — as the
109
+ // 1.5.x–1.6.6 skew guard did — measures dependencies nobody executes. The 1.6.6
110
+ // form of that guard false-fataled healthy installs after every host upgrade and
111
+ // was removed; see docs/CHANGELOG.md [1.6.7].
110
112
  const compat = checkPiCompatibility(PI_VERSION);
111
113
  if (compat.fatal) {
112
114
  throw new Error(compat.message ?? `[pi-research] Unsupported pi-coding-agent version "${PI_VERSION}".`);
@@ -115,19 +117,6 @@ export default async function (pi: ExtensionAPI) {
115
117
  logger.warn(compat.message);
116
118
  }
117
119
 
118
- // The gate above classifies the HOST version but cannot see the copies THIS
119
- // extension resolves from its own node_modules — the 2026-08-30 incident
120
- // (host 0.84.4 vs extension-resolved pi-ai 0.84.2) passed it as 'ok' while
121
- // every researcher died at provider load. Detect that skew here and refuse
122
- // to start with the actual remediation instead.
123
- const skew = checkPiAiSkew();
124
- if (skew.fatal) {
125
- throw new Error(skew.message ?? '[pi-research] pi-ai version skew.');
126
- }
127
- if (skew.message) {
128
- logger.warn(skew.message);
129
- }
130
-
131
120
  // Re-register the beforeExit safety net (deactivate() strips event listeners during reload).
132
121
  // This ensures the ONNX pipeline is disposed even after extension reloads.
133
122
  registerBeforeExitSafetyNet();
@@ -73,6 +73,9 @@ export function isEmbedderUnreachable(err: unknown): boolean {
73
73
  * Matched signatures (observed in the wild):
74
74
  * - onnxruntime-node: "Cannot find module '.../bin/napi-v6/darwin/x64/onnxruntime_binding.node'"
75
75
  * - @lancedb/lancedb: "Cannot find native binding. npm has a bug related to optional dependencies"
76
+ * - @huggingface/transformers (optional dep skipped at install time, #10): the typed
77
+ * TRANSFORMERS_UNAVAILABLE error from transformers-loader.ts, or its
78
+ * raw form "Cannot find package '@huggingface/transformers'"
76
79
  * - jiti-masked module-eval failure of the above surfaces as:
77
80
  * "KnowledgeStoreService is not a constructor"
78
81
  */
@@ -89,8 +92,10 @@ export function isNativeStackUnavailableError(err: unknown): boolean {
89
92
  // `err` is already known truthy (guarded above), so no `err &&` here.
90
93
  const code = (typeof err === 'object' && 'code' in err) ? String((err as { code?: unknown }).code) : '';
91
94
  if (code === 'ERR_DLOPEN_FAILED') return true;
95
+ // The typed error from transformers-loader.ts — unambiguous by construction.
96
+ if (code === 'TRANSFORMERS_UNAVAILABLE') return true;
92
97
  if ((code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') &&
93
- (msg.includes('onnxruntime') || msg.includes('lancedb'))) {
98
+ (msg.includes('onnxruntime') || msg.includes('lancedb') || msg.includes('@huggingface/transformers'))) {
94
99
  return true;
95
100
  }
96
101
 
@@ -99,6 +104,7 @@ export function isNativeStackUnavailableError(err: unknown): boolean {
99
104
  msg.includes('onnxruntime_binding.node') ||
100
105
  /cannot find module '[^']*onnxruntime/.test(msg) ||
101
106
  /cannot find module '[^']*lancedb/.test(msg) ||
107
+ /cannot find (?:package|module) ['"]@huggingface[/\\]transformers['"]/i.test(msg) ||
102
108
  msg.includes('knowledgestoreservice is not a constructor')
103
109
  );
104
110
  }
@@ -18,14 +18,71 @@
18
18
  * (isNativeStackUnavailableError) and degrades the store to DISABLED. Help, version,
19
19
  * status, and browser-only research then work with no binding present.
20
20
  *
21
+ * The package itself is an OPTIONAL dependency (it can be skipped entirely at
22
+ * install time when its native chain cannot build — sharp's platform binary is
23
+ * the historic offender, #10). A genuinely absent module is therefore a SUPPORTED
24
+ * state, not a crash: getTransformers() reclassifies it as a typed
25
+ * TRANSFORMERS_UNAVAILABLE error carrying remediation, which the knowledge store's
26
+ * native-unavailable classifier memoizes as DISABLED instead of re-running the
27
+ * init + backoff storm on every store touch. A transformers-INTERNAL load failure
28
+ * (its own onnxruntime import, a corrupt install) is re-thrown untouched — that is
29
+ * the pre-existing native-stack classification's job.
30
+ *
21
31
  * Modules that need transformers *types* should keep `import type { … } from
22
32
  * '@huggingface/transformers'` (type imports are erased and never hoisted); only
23
33
  * runtime value access (pipeline, env, …) must go through getTransformers().
24
34
  */
25
35
 
36
+ /** Error.code on the typed error thrown when the optional module is not installed. */
37
+ export const TRANSFORMERS_UNAVAILABLE = 'TRANSFORMERS_UNAVAILABLE';
38
+
39
+ /**
40
+ * True when `err` is Node's "the module itself is not installed" rejection for
41
+ * OUR specifier — i.e. the optional dependency was skipped at install time.
42
+ * Deliberately narrow: a transformers-INTERNAL missing module (onnxruntime
43
+ * binding, corrupt install) resolves to a DIFFERENT file path and must stay
44
+ * unclassified here so the native-stack classifier handles it instead.
45
+ */
46
+ export function isTransformersMissingError(err: unknown): boolean {
47
+ if (!err || typeof err !== 'object') return false;
48
+ const code = 'code' in err ? String((err as { code?: unknown }).code) : '';
49
+ const msg = err instanceof Error ? err.message : String(err);
50
+ // Node's ESM form is "Cannot find package '@huggingface/transformers' imported
51
+ // from …"; the CJS/child-process form is "Cannot find module '…'".
52
+ return (
53
+ code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND'
54
+ ) && /@huggingface[/\\]transformers/.test(msg);
55
+ }
56
+
57
+ /**
58
+ * Build the typed, remediation-carrying error for an absent optional package.
59
+ * Exported for tests; the code is what isNativeStackUnavailableError() keys on.
60
+ */
61
+ export function transformersUnavailableError(cause: unknown): Error {
62
+ const err = new Error(
63
+ "Optional dependency '@huggingface/transformers' is not installed, so local embeddings are unavailable. "
64
+ + 'npm skips this package when its native image chain cannot install (most often a failing sharp platform binary). '
65
+ + 'The knowledge store runs without embeddings until it is present — reinstall this package to re-resolve its '
66
+ + 'optional dependencies (pi install npm:@lincoln504/pi-research), or install it directly '
67
+ + '(npm install @huggingface/transformers).',
68
+ { cause },
69
+ );
70
+ (err as { code?: string }).code = TRANSFORMERS_UNAVAILABLE;
71
+ return err;
72
+ }
73
+
26
74
  let _transformers: typeof import('@huggingface/transformers') | undefined;
27
75
 
28
76
  /** Resolve the transformers module, memoizing after the first load. */
29
77
  export async function getTransformers(): Promise<typeof import('@huggingface/transformers')> {
30
- return (_transformers ??= await import('@huggingface/transformers'));
78
+ if (_transformers) return _transformers;
79
+ try {
80
+ _transformers = await import('@huggingface/transformers');
81
+ } catch (err) {
82
+ if (isTransformersMissingError(err)) {
83
+ throw transformersUnavailableError(err);
84
+ }
85
+ throw err;
86
+ }
87
+ return _transformers;
31
88
  }
@@ -1,351 +0,0 @@
1
- /**
2
- * pi-ai module-graph skew detection.
3
- *
4
- * The version gate in pi-version.ts classifies the HOST pi-coding-agent
5
- * version against our supported window — but it cannot see the failure that
6
- * actually took pi-research down on 2026-08-30: as an extension we resolve
7
- * `@earendil-works/*` from our OWN node_modules, and when those copies lag the
8
- * host (host 0.84.4, extension-resolved pi-ai 0.84.2), newer host-era modules
9
- * and older extension-era modules meet in one process graph. The visible
10
- * symptom was researchers dying at provider load with
11
- * "The requested module './simple-options.js' does not provide an export named
12
- * 'clampThinkingBudgetToAnswerRoom'" (first exported in pi-ai 0.84.3), with
13
- * the knowledge store's triage/extraction LLM calls failing the same way —
14
- * every LLM path, wrapped as unrelated-looking "Provider error" strings.
15
- *
16
- * Neither npm nor pi enforces agreement here: our declared range
17
- * (">=0.84.0 <1") cannot constrain the host, and a dev-symlinked or
18
- * prefix-installed extension keeps whatever node_modules it was installed
19
- * with while the host updates independently. The only enforcement point is
20
- * in-process, at load — this module.
21
- *
22
- * Detection is deliberately filesystem-based rather than module-resolution-
23
- * based: the @earendil-works packages' exports maps are import-only, so
24
- * `createRequire(...).resolve()` fails with ERR_PACKAGE_PATH_NOT_EXPORTED,
25
- * and resolution would find the same (possibly stale) copy either way. Instead
26
- * we walk directories to package.json files directly, reproducing the node_modules
27
- * walk Node's bare-specifier resolution performs:
28
- *
29
- * - EXTENSION side: from this module's own directory, walk ancestors and at
30
- * each level read node_modules/@earendil-works/{pi-ai,pi-coding-agent}/package.json;
31
- * the FIRST hit is the copy our bare imports actually bind. The package's own
32
- * node_modules wins when present (git installs clone + `npm install` inside the
33
- * checkout; the dev repo has its own), but on the PRIMARY layout — `pi install
34
- * npm:` — pi runs one flat managed npm tree at ~/.pi/agent/npm/ where the
35
- * extension sits at node_modules/@lincoln504/pi-research with NO nested copies
36
- * (measured) and its @earendil-works deps hoist to the managed root. A
37
- * nested-only lookup misses those entirely and would false-warn "missing" on
38
- * every healthy startup — so the walk must continue past the package root,
39
- * exactly like Node's does. The package root (nearest package.json) is still
40
- * recorded as the remedy target for genuinely-broken installs.
41
- * - HOST side: from the process entry script (process.argv[1], realpath'd —
42
- * Node resolves the main entry by default, so an nvm bin symlink anchors
43
- * inside the host package) up to the package.json whose name IS
44
- * '@earendil-works/pi-coding-agent', then its nested pi-ai. In a pi run
45
- * that is the host's own tree; standalone (our CLI/SDK, tests) the walk
46
- * finds no such package and the check no-ops rather than guessing.
47
- *
48
- * The comparison relies on the pi monorepo's lockstep versioning — every
49
- * observed release ships pi-coding-agent and pi-ai (and the other
50
- * @earendil-works/* packages) at the same version — so a mismatch against
51
- * the host's pi-coding-agent version is a reliable skew signal for pi-ai too.
52
- * A STALE extension copy (older than the host) is FATAL: that is the proven
53
- * crash direction. A NEWER extension copy only warns: untested, but no known
54
- * failure mode. Internal disagreement between the extension's own pi-ai and
55
- * pi-coding-agent warns as well — a half-updated node_modules.
56
- */
57
-
58
- import { readFileSync, realpathSync } from 'node:fs';
59
- import * as path from 'node:path';
60
- import { fileURLToPath } from 'node:url';
61
- import { compareVersions, parsePiVersion } from './pi-version.ts';
62
-
63
- /** Minimal package.json shape this module reads. */
64
- interface PkgJson {
65
- name?: string;
66
- version?: string;
67
- }
68
-
69
- function readPkgJson(file: string): PkgJson | null {
70
- try {
71
- return JSON.parse(readFileSync(file, 'utf8')) as PkgJson;
72
- } catch {
73
- return null;
74
- }
75
- }
76
-
77
- /**
78
- * Walk up from `startFile` to the nearest package.json.
79
- * When `name` is given, keep walking past non-matching package.json files
80
- * (scoped packages nest: pi-coding-agent/dist/bundle/cli.js must climb three
81
- * levels) and stop at the first match; returns null when never found.
82
- */
83
- function findPackageRoot(startFile: string, name?: string): { dir: string; pkg: PkgJson } | null {
84
- let dir = path.dirname(path.resolve(startFile));
85
- for (let i = 0; i < 16; i++) {
86
- const pkg = readPkgJson(path.join(dir, 'package.json'));
87
- if (pkg && (name === undefined || pkg.name === name)) {
88
- return { dir, pkg };
89
- }
90
- const up = path.dirname(dir);
91
- if (up === dir) return null;
92
- dir = up;
93
- }
94
- return null;
95
- }
96
-
97
- /** A resolved @earendil-works copy: its version and the install root that provided it. */
98
- export interface ResolvedSdkCopy {
99
- version: string;
100
- /** The directory whose node_modules produced this copy — the `cd` target for the install remedy. */
101
- installRoot: string;
102
- }
103
-
104
- /**
105
- * Resolve `@earendil-works/<pkg>` from `startDir` exactly as Node's bare-specifier
106
- * resolution binds it: walk ancestors, and at each level take the FIRST
107
- * `<dir>/node_modules/@earendil-works/<pkg>/package.json` found. Covers the nested
108
- * copies of a git checkout, the dev repo's own node_modules, and — critically — the
109
- * flat managed tree `pi install npm:` produces, where the copies hoist to the
110
- * managed root ABOVE the package and a nested-only lookup would miss them.
111
- */
112
- function resolveSdkCopy(startDir: string, pkg: string): ResolvedSdkCopy | null {
113
- let dir = path.resolve(startDir);
114
- for (let i = 0; i < 32; i++) {
115
- const pkgDir = path.join(dir, 'node_modules', '@earendil-works', pkg);
116
- const p = readPkgJson(path.join(pkgDir, 'package.json'));
117
- if (p?.version) return { version: p.version, installRoot: dir };
118
- const up = path.dirname(dir);
119
- if (up === dir) return null;
120
- dir = up;
121
- }
122
- return null;
123
- }
124
-
125
- export interface PiAiSkewInput {
126
- /** Host pi-coding-agent version (its package.json), or null when not running under pi. */
127
- hostVersion: string | null;
128
- /** Extension-resolved pi-ai version, or null when our node_modules lacks it. */
129
- extPiAi: string | null;
130
- /** Extension-resolved pi-coding-agent version, or null. */
131
- extPiCodingAgent: string | null;
132
- /** Where the pi-ai copy resolved FROM (the install root whose node_modules has it).
133
- * Lets the remedy `cd` into the tree that actually owns a stale copy — the flat
134
- * managed tree of `pi install npm:`, not the package dir. Falls back to extRoot. */
135
- piAiRoot?: string;
136
- /** Same, for the pi-coding-agent copy. */
137
- piCodingAgentRoot?: string;
138
- }
139
-
140
- export type PiAiSkewLevel = 'ok' | 'standalone' | 'incomplete' | 'stale' | 'newer' | 'internal';
141
-
142
- export interface PiAiSkewResult {
143
- level: PiAiSkewLevel;
144
- /** True when the extension must refuse to start (proven crash direction). */
145
- fatal: boolean;
146
- message: string | null;
147
- }
148
-
149
- /**
150
- * Classify a skew state. Pure — no filesystem access — so the policy is
151
- * testable without fixtures, mirroring checkPiCompatibility's design.
152
- */
153
- export function classifyPiAiSkew(input: PiAiSkewInput, extRoot?: string): PiAiSkewResult {
154
- const { hostVersion, extPiAi, extPiCodingAgent } = input;
155
-
156
- // Standalone CLI/SDK/tests: no host pi package above us — nothing to skew
157
- // against; our own imports are the only copies in play.
158
- if (!hostVersion) {
159
- return { level: 'standalone', fatal: false, message: null };
160
- }
161
-
162
- // The remedy must `cd` into the tree that OWNS the copy being replaced: the
163
- // package's own checkout when the copy resolved nested there, or — on the flat
164
- // `pi install npm:` managed tree — the managed root its deps hoisted to.
165
- const fixRootOf = (copyRoot: string | undefined) => copyRoot ?? extRoot;
166
- const remedyFor = (copyRoot: string | undefined) => {
167
- const root = fixRootOf(copyRoot);
168
- return root
169
- ? `Fix: cd ${root} && npm install (align this install's @earendil-works copies with host pi ${hostVersion}), then reload pi.`
170
- : `Fix: reinstall this extension so its node_modules matches host pi ${hostVersion}, then reload pi.`;
171
- };
172
- // Default remedy when no specific copy is implicated (absent-copy warns).
173
- const remedy = remedyFor(undefined);
174
-
175
- if (!extPiAi || !extPiCodingAgent) {
176
- // A PRESENT copy that already lags the host is the proven crash direction
177
- // even in a half-hoisted layout (the 2026-08-30 failure had exactly this
178
- // shape: stale nested pi-ai beside a hoisted pi-coding-agent) — classify it
179
- // stale/fatal, not the softer incomplete warn. Only a genuinely-absent
180
- // copy (nothing to compare) stays warn.
181
- const presentVersion = extPiAi ?? extPiCodingAgent;
182
- const presentPkg = extPiAi ? 'pi-ai' : 'pi-coding-agent';
183
- const present = presentVersion ? parsePiVersion(presentVersion) : null;
184
- const host = parsePiVersion(hostVersion);
185
- if (present && host && compareVersions(present, host) < 0) {
186
- const presentRemedy = remedyFor(extPiAi ? input.piAiRoot : input.piCodingAgentRoot);
187
- return {
188
- level: 'stale',
189
- fatal: true,
190
- message:
191
- `[pi-research] Extension node_modules has @earendil-works/${presentPkg} ${presentVersion}, which LAGS host pi ${hostVersion} ` +
192
- `(${extPiAi ? 'pi-coding-agent' : 'pi-ai'} is not resolvable here to compare against) — a copy lagging the host crashes every LLM call at provider load ` +
193
- `(ESM export errors like the missing 'clampThinkingBudgetToAnswerRoom' of pi-ai 0.84.2-vs-0.84.3+). ${presentRemedy}`,
194
- };
195
- }
196
- return {
197
- level: 'incomplete',
198
- fatal: false,
199
- message:
200
- `[pi-research] This extension cannot resolve @earendil-works/` +
201
- `${!extPiAi ? 'pi-ai' : 'pi-coding-agent'} from its install location (walked node_modules up the tree) — LLM calls may fail at module load. ${remedy}`,
202
- };
203
- }
204
-
205
- const host = parsePiVersion(hostVersion);
206
- const ai = parsePiVersion(extPiAi);
207
- const ca = parsePiVersion(extPiCodingAgent);
208
- if (!host || !ai || !ca) {
209
- // Unparseable versions: warn, never brick — this guard exists to make
210
- // failures LOUD, not to invent new startup failures.
211
- return {
212
- level: 'incomplete',
213
- fatal: false,
214
- message: `[pi-research] Could not parse pi-ai skew versions (host=${hostVersion}, pi-ai=${extPiAi}, pi-coding-agent=${extPiCodingAgent}); skipping skew check.`,
215
- };
216
- }
217
-
218
- const aiOlder = compareVersions(ai, host) < 0;
219
- const caOlder = compareVersions(ca, host) < 0;
220
-
221
- // Internal disagreement (a half-updated node_modules) is the root state and
222
- // the more precise diagnosis, so it is classified BEFORE the direction
223
- // checks — otherwise it is unreachable (ai !== ca implies one of them
224
- // differs from the host). Severity still follows the proven crash
225
- // direction: fatal when either copy lags the host.
226
- if (compareVersions(ai, ca) !== 0) {
227
- const anyOlder = aiOlder || caOlder;
228
- const base =
229
- `[pi-research] Extension node_modules is internally inconsistent (pi-ai ${extPiAi} vs ` +
230
- `pi-coding-agent ${extPiCodingAgent}) — a half-updated install against host pi ${hostVersion}. `;
231
- // Aim the remedy at the tree owning the LAGGING copy — that is the one to update.
232
- const laggingRemedy = remedyFor(aiOlder ? input.piAiRoot : input.piCodingAgentRoot);
233
- if (anyOlder) {
234
- return {
235
- level: 'internal',
236
- fatal: true,
237
- message:
238
- base +
239
- `A copy lagging the host crashes every LLM call at provider load ` +
240
- `(ESM export errors like the missing 'clampThinkingBudgetToAnswerRoom' ` +
241
- `of pi-ai 0.84.2-vs-0.84.3+). ${laggingRemedy}`,
242
- };
243
- }
244
- return {
245
- level: 'internal',
246
- fatal: false,
247
- message: base + `Untested direction — if LLM calls fail at module load, this is the first suspect. ${remedy}`,
248
- };
249
- }
250
-
251
- if (aiOlder || caOlder) {
252
- // The 2026-08-30 incident: host 0.84.4 + extension pi-ai 0.84.2. Newer
253
- // host-era modules import symbols the stale copies never exported, and
254
- // every researcher/knowledge LLM call dies at provider load with a
255
- // confusing ESM named-export error. Refusing to start with THIS message
256
- // is strictly better than five silent researcher deaths.
257
- const staleRemedy = remedyFor(aiOlder ? input.piAiRoot : input.piCodingAgentRoot);
258
- return {
259
- level: 'stale',
260
- fatal: true,
261
- message:
262
- `[pi-research] Fatal version skew: host pi ${hostVersion} but this extension resolves ` +
263
- `pi-ai ${extPiAi} / pi-coding-agent ${extPiCodingAgent}. Mixed-version module graphs crash ` +
264
- `every LLM call at provider load (ESM export errors like the missing ` +
265
- `'clampThinkingBudgetToAnswerRoom' of pi-ai 0.84.2-vs-0.84.3+). ${staleRemedy}`,
266
- };
267
- }
268
-
269
- const aiNewer = compareVersions(ai, host) > 0;
270
- const caNewer = compareVersions(ca, host) > 0;
271
- if (aiNewer || caNewer) {
272
- return {
273
- level: 'newer',
274
- fatal: false,
275
- message:
276
- `[pi-research] Extension resolves pi-ai ${extPiAi} / pi-coding-agent ${extPiCodingAgent}, ` +
277
- `newer than host pi ${hostVersion}. Untested direction — if LLM calls fail at module load, ` +
278
- `this mismatch is the first suspect. ${remedy}`,
279
- };
280
- }
281
-
282
- return { level: 'ok', fatal: false, message: null };
283
- }
284
-
285
- /**
286
- * Extension-side resolution, exported for fixture tests: resolves both SDK copies
287
- * from `fromFile`'s location the way Node would bind them, plus the package root
288
- * (the remedy target of last resort for genuinely-broken installs).
289
- */
290
- export interface ExtensionCopies {
291
- extRoot: string | null;
292
- piAi: string | null;
293
- piCodingAgent: string | null;
294
- piAiRoot: string | null;
295
- piCodingAgentRoot: string | null;
296
- }
297
-
298
- export function resolveExtensionCopies(fromFile: string): ExtensionCopies {
299
- // jiti hands us a file:// URL; real-path strings also accepted.
300
- const anchor = fromFile.startsWith('file:') ? fileURLToPath(fromFile) : fromFile;
301
- const ext = findPackageRoot(anchor);
302
- // Walk from the MODULE's directory (not the package root): Node checks
303
- // <dir>/node_modules at every ancestor of the importing file, so the package's
304
- // own node_modules is covered AND the walk continues past it into whatever
305
- // install root the package lives in — the flat managed tree `pi install npm:`
306
- // produces, a project's hoisted tree, or the monorepo root.
307
- const startDir = path.dirname(path.resolve(anchor));
308
- const ai = resolveSdkCopy(startDir, 'pi-ai');
309
- const ca = resolveSdkCopy(startDir, 'pi-coding-agent');
310
- return {
311
- extRoot: ext?.dir ?? null,
312
- piAi: ai?.version ?? null,
313
- piCodingAgent: ca?.version ?? null,
314
- piAiRoot: ai?.installRoot ?? null,
315
- piCodingAgentRoot: ca?.installRoot ?? null,
316
- };
317
- }
318
-
319
- /**
320
- * Collect the real skew state from this process and classify it.
321
- *
322
- * Never throws: any read/walk failure degrades to null inputs (classified as
323
- * 'standalone'/'incomplete'), because a broken guard must not take the
324
- * extension down with it.
325
- */
326
- export function checkPiAiSkew(fromFile: string = import.meta.url): PiAiSkewResult {
327
- const copies = resolveExtensionCopies(fromFile);
328
-
329
- // Host side: the process entry script, realpath'd. In a pi run this anchors
330
- // inside the host's own package; anywhere else the named walk finds nothing.
331
- let hostVersion: string | null = null;
332
- try {
333
- const entry = realpathSync(process.argv[1] ?? '');
334
- const host = findPackageRoot(entry, '@earendil-works/pi-coding-agent');
335
- hostVersion = host?.pkg.version ?? null;
336
- } catch {
337
- // Entry missing/unreadable (standalone context) — hostVersion stays null
338
- // and classifyPiAiSkew treats the host side as undetectable.
339
- }
340
-
341
- return classifyPiAiSkew(
342
- {
343
- hostVersion,
344
- extPiAi: copies.piAi,
345
- extPiCodingAgent: copies.piCodingAgent,
346
- piAiRoot: copies.piAiRoot ?? undefined,
347
- piCodingAgentRoot: copies.piCodingAgentRoot ?? undefined,
348
- },
349
- copies.extRoot ?? undefined,
350
- );
351
- }