@caupulican/pi-adaptative 0.81.19 → 0.81.21

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/core/agent-session.d.ts.map +1 -1
  3. package/dist/core/agent-session.js +17 -0
  4. package/dist/core/agent-session.js.map +1 -1
  5. package/dist/core/doctor.d.ts.map +1 -1
  6. package/dist/core/doctor.js +4 -1
  7. package/dist/core/doctor.js.map +1 -1
  8. package/dist/core/local-runtime-controller.d.ts +2 -0
  9. package/dist/core/local-runtime-controller.d.ts.map +1 -1
  10. package/dist/core/local-runtime-controller.js +92 -6
  11. package/dist/core/local-runtime-controller.js.map +1 -1
  12. package/dist/core/models/context-sizing.d.ts.map +1 -1
  13. package/dist/core/models/context-sizing.js +18 -26
  14. package/dist/core/models/context-sizing.js.map +1 -1
  15. package/dist/core/models/local-runtime.d.ts +57 -4
  16. package/dist/core/models/local-runtime.d.ts.map +1 -1
  17. package/dist/core/models/local-runtime.js +307 -30
  18. package/dist/core/models/local-runtime.js.map +1 -1
  19. package/dist/core/models/perf-profile.d.ts +3 -0
  20. package/dist/core/models/perf-profile.d.ts.map +1 -1
  21. package/dist/core/models/perf-profile.js +42 -13
  22. package/dist/core/models/perf-profile.js.map +1 -1
  23. package/dist/core/models/runtime-arbiter.d.ts +45 -0
  24. package/dist/core/models/runtime-arbiter.d.ts.map +1 -1
  25. package/dist/core/models/runtime-arbiter.js +115 -0
  26. package/dist/core/models/runtime-arbiter.js.map +1 -1
  27. package/dist/modes/interactive/local-model-commands.d.ts.map +1 -1
  28. package/dist/modes/interactive/local-model-commands.js +24 -6
  29. package/dist/modes/interactive/local-model-commands.js.map +1 -1
  30. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  31. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  32. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  33. package/examples/extensions/sandbox/package-lock.json +2 -2
  34. package/examples/extensions/sandbox/package.json +1 -1
  35. package/examples/extensions/with-deps/package-lock.json +2 -2
  36. package/examples/extensions/with-deps/package.json +1 -1
  37. package/npm-shrinkwrap.json +12 -12
  38. package/package.json +4 -4
@@ -1 +1 @@
1
- {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../../src/core/doctor.ts"],"names":[],"mappings":"AAEA,OAAO,EAGN,KAAK,iBAAiB,EAKtB,KAAK,gBAAgB,EACrB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAE1D;;;;;;;;;;;;;;;GAeG;AAEH,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,QAAQ,CAAC;AAElD,MAAM,WAAW,WAAW;IAC3B,yEAAyE;IACzE,EAAE,EAAE,MAAM,CAAC;IACX,2CAA2C;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,cAAc,CAAC;IACrB,OAAO,EAAE,OAAO,CAAC;IACjB,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,uFAAuF;IACvF,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,YAAY;IAC5B,MAAM,EAAE,WAAW,EAAE,CAAC;CACtB;AAED,qHAAqH;AACrH,MAAM,MAAM,mBAAmB,GAAG,IAAI,CAAC,aAAa,EAAE,QAAQ,GAAG,cAAc,CAAC,CAAC;AAEjF,MAAM,WAAW,UAAU;IAC1B,2BAA2B,EAAE,MAAM,OAAO,GAAG,SAAS,CAAC;IACvD,oBAAoB,EAAE,CAAC,MAAM,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC,CAAC;IACzE,wBAAwB,EAAE,MAAM,iBAAiB,GAAG,SAAS,CAAC;IAC9D,SAAS,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IAC/B,YAAY,EAAE,MAAM,gBAAgB,CAAC;IACrC,8IAA8I;IAC9I,YAAY,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,KAAK,MAAM,GAAG,SAAS,CAAC;IACvF,aAAa,EAAE,mBAAmB,CAAC;CACnC;AAED,MAAM,WAAW,gBAAgB;IAChC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CACjB;AAsHD,wBAAsB,SAAS,CAC9B,IAAI,GAAE,UAA2B,EACjC,OAAO,GAAE,gBAAqB,GAC5B,OAAO,CAAC,YAAY,CAAC,CAIvB;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,CAW/D;AAED;;;;GAIG;AACH,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC,CAOxD","sourcesContent":["import chalk from \"chalk\";\nimport { getAgentDir } from \"../config.ts\";\nimport {\n\tdetectPython,\n\tensureFffNodePackage,\n\ttype FffInstallOutcome,\n\tgetLastFffInstallOutcome,\n\tgetToolPath,\n\tloadAvailableFffNodePackage,\n\tprobeVersion,\n\ttype SystemToolStatus,\n} from \"../utils/tools-manager.ts\";\nimport { OllamaRuntime } from \"./models/local-runtime.ts\";\n\n/**\n * Environment doctor: verifies required tooling and installs what it safely\n * can, instead of leaving provisioning entirely to lazy first-use (the exact\n * gap behind \"ran `pi-adaptative update` on another machine and fff-node\n * never got installed\" -- see fff-lazy-install.test.ts/fff-search-tools.test.ts\n * for the underlying lazy-install fix; this module is the proactive half).\n *\n * Two tool kinds, two different postures:\n * - \"managed\": pi already owns the install path (fff-node into\n * ~/.pi/agent/bin via tools-manager.ts's ensureFffNodePackage). The doctor\n * actually attempts the install when missing.\n * - \"system\": pi does not own the install (ripgrep, ollama, python). GUIDE\n * MODE only -- exact manual steps are reported, never executed, never\n * `curl | sh`, never `sudo`. This matches the harness's deliberate\n * no-silent-installer stance for tooling outside its own managed bin dir.\n */\n\nexport type DoctorToolKind = \"managed\" | \"system\";\n\nexport interface DoctorCheck {\n\t/** Stable identifier, e.g. \"fff-node\", \"ripgrep\", \"ollama\", \"python\". */\n\tid: string;\n\t/** Human-readable label for the report. */\n\tlabel: string;\n\tkind: DoctorToolKind;\n\tpresent: boolean;\n\t/** Version/path/status summary, present or not. */\n\tdetail?: string;\n\t/** Only set for a \"managed\" tool: whether an install was just attempted. */\n\tinstallAttempted?: boolean;\n\t/** Only set for a \"system\" tool that's missing: exact manual steps, never executed. */\n\tguide?: string[];\n}\n\nexport interface DoctorReport {\n\tchecks: DoctorCheck[];\n}\n\n/** Minimal slice of OllamaRuntime the doctor needs -- lets tests inject a fake without touching the real runtime. */\nexport type DoctorOllamaRuntime = Pick<OllamaRuntime, \"detect\" | \"installGuide\">;\n\nexport interface DoctorDeps {\n\tloadAvailableFffNodePackage: () => unknown | undefined;\n\tensureFffNodePackage: (silent?: boolean) => Promise<unknown | undefined>;\n\tgetLastFffInstallOutcome: () => FffInstallOutcome | undefined;\n\tgetRgPath: () => string | null;\n\tdetectPython: () => SystemToolStatus;\n\t/** Best-effort `<command> --version` probe; undefined if it can't be run. Used only to enrich a status line, never for presence detection. */\n\tprobeVersion: (command: string, versionArgs?: readonly string[]) => string | undefined;\n\tollamaRuntime: DoctorOllamaRuntime;\n}\n\nexport interface RunDoctorOptions {\n\t/**\n\t * Whether the fff-node managed install (if one is attempted) stays quiet.\n\t * Default true, matching runUpdatePreflight's existing background\n\t * behavior. The interactive `doctor` command passes `false` so a\n\t * multi-second install doesn't read as a silent hang.\n\t */\n\tsilent?: boolean;\n}\n\nconst realDoctorDeps: DoctorDeps = {\n\tloadAvailableFffNodePackage,\n\tensureFffNodePackage,\n\tgetLastFffInstallOutcome,\n\tgetRgPath: () => getToolPath(\"rg\"),\n\tdetectPython: () => detectPython(),\n\tprobeVersion,\n\tollamaRuntime: new OllamaRuntime({ agentDir: getAgentDir() }),\n};\n\nconst RIPGREP_GUIDE = [\n\t\"ripgrep (rg) was not found. Pi never runs installers itself -- manual steps (user-level, no sudo):\",\n\t\" - macOS (Homebrew): brew install ripgrep\",\n\t\" - Debian/Ubuntu: apt install ripgrep\",\n\t\" - Arch: pacman -S ripgrep\",\n\t\" - Or download a release: https://github.com/BurntSushi/ripgrep/releases\",\n];\n\nconst PYTHON_GUIDE = [\n\t\"python3 was not found. Pi never runs installers itself -- manual steps (user-level, no sudo):\",\n\t\" - macOS (Homebrew): brew install python3\",\n\t\" - Debian/Ubuntu: apt install python3\",\n\t\" - Arch: pacman -S python\",\n\t\" - Or download from https://www.python.org/downloads/\",\n];\n\nfunction describeFffOutcome(outcome: FffInstallOutcome | undefined): string {\n\tswitch (outcome?.status) {\n\t\tcase \"already-available\":\n\t\t\treturn \"already available\";\n\t\tcase \"installed\":\n\t\t\treturn \"installed just now\";\n\t\tcase \"offline\":\n\t\t\treturn \"offline mode enabled, skipped\";\n\t\tcase \"unsupported-platform\":\n\t\t\treturn \"no native build for this platform\";\n\t\tcase \"install-failed\":\n\t\t\treturn `install failed: ${outcome.reason}`;\n\t\tdefault:\n\t\t\treturn \"unavailable\";\n\t}\n}\n\n/** First line only -- a compiled-features/build-flags tail (e.g. rg --version) is noise in a one-line status report. */\nfunction firstLine(text: string | undefined): string | undefined {\n\treturn text?.split(\"\\n\")[0];\n}\n\n/** MANAGED tool: pi owns this install path, so the doctor actually attempts it when missing. */\nasync function checkFffNode(deps: DoctorDeps, silent: boolean): Promise<DoctorCheck> {\n\t// Delegate entirely to ensureFffNodePackage rather than re-deriving \"is it\n\t// already there\" here: that function (and its offline/unsupported-platform/\n\t// cooldown handling, see tools-manager.ts) is the single source of truth for\n\t// whether an install should even be attempted -- the doctor must not\n\t// duplicate or second-guess that logic, only report its outcome honestly.\n\tconst installed = await deps.ensureFffNodePackage(silent);\n\tconst outcome = deps.getLastFffInstallOutcome();\n\treturn {\n\t\tid: \"fff-node\",\n\t\tlabel: \"FFF native search (@ff-labs/fff-node)\",\n\t\tkind: \"managed\",\n\t\tpresent: Boolean(installed),\n\t\tinstallAttempted: outcome !== undefined && outcome.status !== \"already-available\",\n\t\tdetail: describeFffOutcome(outcome),\n\t};\n}\n\n/** SYSTEM tool: guide mode only, never installed by the doctor. */\nfunction checkRipgrep(deps: DoctorDeps): DoctorCheck {\n\tconst path = deps.getRgPath();\n\tif (!path) {\n\t\treturn { id: \"ripgrep\", label: \"ripgrep (rg)\", kind: \"system\", present: false, guide: RIPGREP_GUIDE };\n\t}\n\tconst version = firstLine(deps.probeVersion(path));\n\treturn {\n\t\tid: \"ripgrep\",\n\t\tlabel: \"ripgrep (rg)\",\n\t\tkind: \"system\",\n\t\tpresent: true,\n\t\tdetail: version ? `${path} (${version})` : path,\n\t};\n}\n\n/** SYSTEM tool: guide mode only, never installed by the doctor (OllamaRuntime.installGuide() is itself already guide-mode-only). */\nasync function checkOllama(deps: DoctorDeps): Promise<DoctorCheck> {\n\tconst status = await deps.ollamaRuntime.detect();\n\tif (!status.binaryPath) {\n\t\treturn {\n\t\t\tid: \"ollama\",\n\t\t\tlabel: \"Ollama (local model runtime)\",\n\t\t\tkind: \"system\",\n\t\t\tpresent: false,\n\t\t\tguide: deps.ollamaRuntime.installGuide(),\n\t\t};\n\t}\n\tconst version = firstLine(deps.probeVersion(status.binaryPath));\n\tconst versionSuffix = version ? `, ${version}` : \"\";\n\treturn {\n\t\tid: \"ollama\",\n\t\tlabel: \"Ollama (local model runtime)\",\n\t\tkind: \"system\",\n\t\tpresent: true,\n\t\tdetail: `binary: ${status.binaryPath} [${status.binarySource}]${versionSuffix}; server: ${status.serverUp ? \"up\" : \"down\"} at ${status.serverUrl}`,\n\t};\n}\n\n/** SYSTEM tool: guide mode only, never installed by the doctor. */\nfunction checkPython(deps: DoctorDeps): DoctorCheck {\n\tconst status = deps.detectPython();\n\tif (!status.present) {\n\t\treturn { id: \"python\", label: \"Python (python3)\", kind: \"system\", present: false, guide: PYTHON_GUIDE };\n\t}\n\tconst detail = status.version ? `${status.command} (${status.version})` : status.command;\n\treturn { id: \"python\", label: \"Python (python3)\", kind: \"system\", present: true, detail };\n}\n\nexport async function runDoctor(\n\tdeps: DoctorDeps = realDoctorDeps,\n\toptions: RunDoctorOptions = {},\n): Promise<DoctorReport> {\n\tconst silent = options.silent ?? true;\n\tconst [fffNode, ollama] = await Promise.all([checkFffNode(deps, silent), checkOllama(deps)]);\n\treturn { checks: [fffNode, checkRipgrep(deps), ollama, checkPython(deps)] };\n}\n\nexport function formatDoctorReport(report: DoctorReport): string {\n\tconst lines: string[] = [];\n\tfor (const check of report.checks) {\n\t\tconst status = check.present ? chalk.green(\"[OK]\") : chalk.yellow(\"[MISSING]\");\n\t\tconst detail = check.detail ? ` -- ${check.detail}` : \"\";\n\t\tlines.push(`${status} ${check.label}${detail}`);\n\t\tif (!check.present && check.guide) {\n\t\t\tfor (const guideLine of check.guide) lines.push(` ${guideLine}`);\n\t\t}\n\t}\n\treturn lines.join(\"\\n\");\n}\n\n/**\n * Best-effort preflight meant to be called right after `pi-adaptative update`\n * succeeds. Must never fail the update itself: any error here is swallowed\n * and reported as a skipped check, not surfaced as an update failure.\n */\nexport async function runUpdatePreflight(): Promise<void> {\n\ttry {\n\t\tconst report = await runDoctor();\n\t\tconsole.log(`\\n${formatDoctorReport(report)}\\n`);\n\t} catch (error) {\n\t\tconsole.log(chalk.dim(`(environment check skipped: ${error instanceof Error ? error.message : String(error)})`));\n\t}\n}\n"]}
1
+ {"version":3,"file":"doctor.d.ts","sourceRoot":"","sources":["../../src/core/doctor.ts"],"names":[],"mappings":"AAEA,OAAO,EAGN,KAAK,iBAAiB,EAKtB,KAAK,gBAAgB,EACrB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAE1D;;;;;;;;;;;;;;;GAeG;AAEH,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,QAAQ,CAAC;AAElD,MAAM,WAAW,WAAW;IAC3B,yEAAyE;IACzE,EAAE,EAAE,MAAM,CAAC;IACX,2CAA2C;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,cAAc,CAAC;IACrB,OAAO,EAAE,OAAO,CAAC;IACjB,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,uFAAuF;IACvF,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,YAAY;IAC5B,MAAM,EAAE,WAAW,EAAE,CAAC;CACtB;AAED,qHAAqH;AACrH,MAAM,MAAM,mBAAmB,GAAG,IAAI,CAAC,aAAa,EAAE,QAAQ,GAAG,cAAc,CAAC,CAAC;AAEjF,MAAM,WAAW,UAAU;IAC1B,2BAA2B,EAAE,MAAM,OAAO,GAAG,SAAS,CAAC;IACvD,oBAAoB,EAAE,CAAC,MAAM,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC,CAAC;IACzE,wBAAwB,EAAE,MAAM,iBAAiB,GAAG,SAAS,CAAC;IAC9D,SAAS,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IAC/B,YAAY,EAAE,MAAM,gBAAgB,CAAC;IACrC,8IAA8I;IAC9I,YAAY,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,KAAK,MAAM,GAAG,SAAS,CAAC;IACvF,aAAa,EAAE,mBAAmB,CAAC;CACnC;AAED,MAAM,WAAW,gBAAgB;IAChC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CACjB;AAyHD,wBAAsB,SAAS,CAC9B,IAAI,GAAE,UAA2B,EACjC,OAAO,GAAE,gBAAqB,GAC5B,OAAO,CAAC,YAAY,CAAC,CAIvB;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,CAW/D;AAED;;;;GAIG;AACH,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC,CAOxD","sourcesContent":["import chalk from \"chalk\";\nimport { getAgentDir } from \"../config.ts\";\nimport {\n\tdetectPython,\n\tensureFffNodePackage,\n\ttype FffInstallOutcome,\n\tgetLastFffInstallOutcome,\n\tgetToolPath,\n\tloadAvailableFffNodePackage,\n\tprobeVersion,\n\ttype SystemToolStatus,\n} from \"../utils/tools-manager.ts\";\nimport { OllamaRuntime } from \"./models/local-runtime.ts\";\n\n/**\n * Environment doctor: verifies required tooling and installs what it safely\n * can, instead of leaving provisioning entirely to lazy first-use (the exact\n * gap behind \"ran `pi-adaptative update` on another machine and fff-node\n * never got installed\" -- see fff-lazy-install.test.ts/fff-search-tools.test.ts\n * for the underlying lazy-install fix; this module is the proactive half).\n *\n * Two tool kinds, two different postures:\n * - \"managed\": pi already owns the install path (fff-node into\n * ~/.pi/agent/bin via tools-manager.ts's ensureFffNodePackage). The doctor\n * actually attempts the install when missing.\n * - \"system\": pi does not own the install (ripgrep, ollama, python). GUIDE\n * MODE only -- exact manual steps are reported, never executed, never\n * `curl | sh`, never `sudo`. This matches the harness's deliberate\n * no-silent-installer stance for tooling outside its own managed bin dir.\n */\n\nexport type DoctorToolKind = \"managed\" | \"system\";\n\nexport interface DoctorCheck {\n\t/** Stable identifier, e.g. \"fff-node\", \"ripgrep\", \"ollama\", \"python\". */\n\tid: string;\n\t/** Human-readable label for the report. */\n\tlabel: string;\n\tkind: DoctorToolKind;\n\tpresent: boolean;\n\t/** Version/path/status summary, present or not. */\n\tdetail?: string;\n\t/** Only set for a \"managed\" tool: whether an install was just attempted. */\n\tinstallAttempted?: boolean;\n\t/** Only set for a \"system\" tool that's missing: exact manual steps, never executed. */\n\tguide?: string[];\n}\n\nexport interface DoctorReport {\n\tchecks: DoctorCheck[];\n}\n\n/** Minimal slice of OllamaRuntime the doctor needs -- lets tests inject a fake without touching the real runtime. */\nexport type DoctorOllamaRuntime = Pick<OllamaRuntime, \"detect\" | \"installGuide\">;\n\nexport interface DoctorDeps {\n\tloadAvailableFffNodePackage: () => unknown | undefined;\n\tensureFffNodePackage: (silent?: boolean) => Promise<unknown | undefined>;\n\tgetLastFffInstallOutcome: () => FffInstallOutcome | undefined;\n\tgetRgPath: () => string | null;\n\tdetectPython: () => SystemToolStatus;\n\t/** Best-effort `<command> --version` probe; undefined if it can't be run. Used only to enrich a status line, never for presence detection. */\n\tprobeVersion: (command: string, versionArgs?: readonly string[]) => string | undefined;\n\tollamaRuntime: DoctorOllamaRuntime;\n}\n\nexport interface RunDoctorOptions {\n\t/**\n\t * Whether the fff-node managed install (if one is attempted) stays quiet.\n\t * Default true, matching runUpdatePreflight's existing background\n\t * behavior. The interactive `doctor` command passes `false` so a\n\t * multi-second install doesn't read as a silent hang.\n\t */\n\tsilent?: boolean;\n}\n\nconst realDoctorDeps: DoctorDeps = {\n\tloadAvailableFffNodePackage,\n\tensureFffNodePackage,\n\tgetLastFffInstallOutcome,\n\tgetRgPath: () => getToolPath(\"rg\"),\n\tdetectPython: () => detectPython(),\n\tprobeVersion,\n\tollamaRuntime: new OllamaRuntime({ agentDir: getAgentDir() }),\n};\n\nconst RIPGREP_GUIDE = [\n\t\"ripgrep (rg) was not found. Pi never runs installers itself -- manual steps (user-level, no sudo):\",\n\t\" - macOS (Homebrew): brew install ripgrep\",\n\t\" - Debian/Ubuntu: apt install ripgrep\",\n\t\" - Arch: pacman -S ripgrep\",\n\t\" - Or download a release: https://github.com/BurntSushi/ripgrep/releases\",\n];\n\nconst PYTHON_GUIDE = [\n\t\"python3 was not found. Pi never runs installers itself -- manual steps (user-level, no sudo):\",\n\t\" - macOS (Homebrew): brew install python3\",\n\t\" - Debian/Ubuntu: apt install python3\",\n\t\" - Arch: pacman -S python\",\n\t\" - Or download from https://www.python.org/downloads/\",\n];\n\nfunction describeFffOutcome(outcome: FffInstallOutcome | undefined): string {\n\tswitch (outcome?.status) {\n\t\tcase \"already-available\":\n\t\t\treturn \"already available\";\n\t\tcase \"installed\":\n\t\t\treturn \"installed just now\";\n\t\tcase \"offline\":\n\t\t\treturn \"offline mode enabled, skipped\";\n\t\tcase \"unsupported-platform\":\n\t\t\treturn \"no native build for this platform\";\n\t\tcase \"install-failed\":\n\t\t\treturn `install failed: ${outcome.reason}`;\n\t\tdefault:\n\t\t\treturn \"unavailable\";\n\t}\n}\n\n/** First line only -- a compiled-features/build-flags tail (e.g. rg --version) is noise in a one-line status report. */\nfunction firstLine(text: string | undefined): string | undefined {\n\treturn text?.split(\"\\n\")[0];\n}\n\n/** MANAGED tool: pi owns this install path, so the doctor actually attempts it when missing. */\nasync function checkFffNode(deps: DoctorDeps, silent: boolean): Promise<DoctorCheck> {\n\t// Delegate entirely to ensureFffNodePackage rather than re-deriving \"is it\n\t// already there\" here: that function (and its offline/unsupported-platform/\n\t// cooldown handling, see tools-manager.ts) is the single source of truth for\n\t// whether an install should even be attempted -- the doctor must not\n\t// duplicate or second-guess that logic, only report its outcome honestly.\n\tconst installed = await deps.ensureFffNodePackage(silent);\n\tconst outcome = deps.getLastFffInstallOutcome();\n\treturn {\n\t\tid: \"fff-node\",\n\t\tlabel: \"FFF native search (@ff-labs/fff-node)\",\n\t\tkind: \"managed\",\n\t\tpresent: Boolean(installed),\n\t\tinstallAttempted: outcome !== undefined && outcome.status !== \"already-available\",\n\t\tdetail: describeFffOutcome(outcome),\n\t};\n}\n\n/** SYSTEM tool: guide mode only, never installed by the doctor. */\nfunction checkRipgrep(deps: DoctorDeps): DoctorCheck {\n\tconst path = deps.getRgPath();\n\tif (!path) {\n\t\treturn { id: \"ripgrep\", label: \"ripgrep (rg)\", kind: \"system\", present: false, guide: RIPGREP_GUIDE };\n\t}\n\tconst version = firstLine(deps.probeVersion(path));\n\treturn {\n\t\tid: \"ripgrep\",\n\t\tlabel: \"ripgrep (rg)\",\n\t\tkind: \"system\",\n\t\tpresent: true,\n\t\tdetail: version ? `${path} (${version})` : path,\n\t};\n}\n\n/** SYSTEM tool: guide mode only, never installed by the doctor (OllamaRuntime.installGuide() is itself already guide-mode-only). */\nasync function checkOllama(deps: DoctorDeps): Promise<DoctorCheck> {\n\tconst status = await deps.ollamaRuntime.detect();\n\tif (!status.binaryPath) {\n\t\treturn {\n\t\t\tid: \"ollama\",\n\t\t\tlabel: \"Ollama (local model runtime)\",\n\t\t\tkind: \"system\",\n\t\t\tpresent: false,\n\t\t\tguide: deps.ollamaRuntime.installGuide(),\n\t\t};\n\t}\n\tconst version = firstLine(deps.probeVersion(status.binaryPath));\n\tconst versionSuffix = version ? `, ${version}` : \"\";\n\tconst activeStore = status.activeStore\n\t\t? `${status.activeStore.path} [${status.activeStore.kind}, ${status.activeStore.modelCount} model(s)]`\n\t\t: `none; pi-owned store ${status.ownedModelsDir} has ${status.ownedStore.modelCount} model(s)`;\n\treturn {\n\t\tid: \"ollama\",\n\t\tlabel: \"Ollama (local model runtime)\",\n\t\tkind: \"system\",\n\t\tpresent: true,\n\t\tdetail: `binary: ${status.binaryPath} [${status.binarySource}]${versionSuffix}; server: ${status.serverUp ? \"up\" : \"down\"} at ${status.serverUrl}; active store: ${activeStore}`,\n\t};\n}\n\n/** SYSTEM tool: guide mode only, never installed by the doctor. */\nfunction checkPython(deps: DoctorDeps): DoctorCheck {\n\tconst status = deps.detectPython();\n\tif (!status.present) {\n\t\treturn { id: \"python\", label: \"Python (python3)\", kind: \"system\", present: false, guide: PYTHON_GUIDE };\n\t}\n\tconst detail = status.version ? `${status.command} (${status.version})` : status.command;\n\treturn { id: \"python\", label: \"Python (python3)\", kind: \"system\", present: true, detail };\n}\n\nexport async function runDoctor(\n\tdeps: DoctorDeps = realDoctorDeps,\n\toptions: RunDoctorOptions = {},\n): Promise<DoctorReport> {\n\tconst silent = options.silent ?? true;\n\tconst [fffNode, ollama] = await Promise.all([checkFffNode(deps, silent), checkOllama(deps)]);\n\treturn { checks: [fffNode, checkRipgrep(deps), ollama, checkPython(deps)] };\n}\n\nexport function formatDoctorReport(report: DoctorReport): string {\n\tconst lines: string[] = [];\n\tfor (const check of report.checks) {\n\t\tconst status = check.present ? chalk.green(\"[OK]\") : chalk.yellow(\"[MISSING]\");\n\t\tconst detail = check.detail ? ` -- ${check.detail}` : \"\";\n\t\tlines.push(`${status} ${check.label}${detail}`);\n\t\tif (!check.present && check.guide) {\n\t\t\tfor (const guideLine of check.guide) lines.push(` ${guideLine}`);\n\t\t}\n\t}\n\treturn lines.join(\"\\n\");\n}\n\n/**\n * Best-effort preflight meant to be called right after `pi-adaptative update`\n * succeeds. Must never fail the update itself: any error here is swallowed\n * and reported as a skipped check, not surfaced as an update failure.\n */\nexport async function runUpdatePreflight(): Promise<void> {\n\ttry {\n\t\tconst report = await runDoctor();\n\t\tconsole.log(`\\n${formatDoctorReport(report)}\\n`);\n\t} catch (error) {\n\t\tconsole.log(chalk.dim(`(environment check skipped: ${error instanceof Error ? error.message : String(error)})`));\n\t}\n}\n"]}
@@ -92,12 +92,15 @@ async function checkOllama(deps) {
92
92
  }
93
93
  const version = firstLine(deps.probeVersion(status.binaryPath));
94
94
  const versionSuffix = version ? `, ${version}` : "";
95
+ const activeStore = status.activeStore
96
+ ? `${status.activeStore.path} [${status.activeStore.kind}, ${status.activeStore.modelCount} model(s)]`
97
+ : `none; pi-owned store ${status.ownedModelsDir} has ${status.ownedStore.modelCount} model(s)`;
95
98
  return {
96
99
  id: "ollama",
97
100
  label: "Ollama (local model runtime)",
98
101
  kind: "system",
99
102
  present: true,
100
- detail: `binary: ${status.binaryPath} [${status.binarySource}]${versionSuffix}; server: ${status.serverUp ? "up" : "down"} at ${status.serverUrl}`,
103
+ detail: `binary: ${status.binaryPath} [${status.binarySource}]${versionSuffix}; server: ${status.serverUp ? "up" : "down"} at ${status.serverUrl}; active store: ${activeStore}`,
101
104
  };
102
105
  }
103
106
  /** SYSTEM tool: guide mode only, never installed by the doctor. */
@@ -1 +1 @@
1
- {"version":3,"file":"doctor.js","sourceRoot":"","sources":["../../src/core/doctor.ts"],"names":[],"mappings":"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","sourcesContent":["import chalk from \"chalk\";\nimport { getAgentDir } from \"../config.ts\";\nimport {\n\tdetectPython,\n\tensureFffNodePackage,\n\ttype FffInstallOutcome,\n\tgetLastFffInstallOutcome,\n\tgetToolPath,\n\tloadAvailableFffNodePackage,\n\tprobeVersion,\n\ttype SystemToolStatus,\n} from \"../utils/tools-manager.ts\";\nimport { OllamaRuntime } from \"./models/local-runtime.ts\";\n\n/**\n * Environment doctor: verifies required tooling and installs what it safely\n * can, instead of leaving provisioning entirely to lazy first-use (the exact\n * gap behind \"ran `pi-adaptative update` on another machine and fff-node\n * never got installed\" -- see fff-lazy-install.test.ts/fff-search-tools.test.ts\n * for the underlying lazy-install fix; this module is the proactive half).\n *\n * Two tool kinds, two different postures:\n * - \"managed\": pi already owns the install path (fff-node into\n * ~/.pi/agent/bin via tools-manager.ts's ensureFffNodePackage). The doctor\n * actually attempts the install when missing.\n * - \"system\": pi does not own the install (ripgrep, ollama, python). GUIDE\n * MODE only -- exact manual steps are reported, never executed, never\n * `curl | sh`, never `sudo`. This matches the harness's deliberate\n * no-silent-installer stance for tooling outside its own managed bin dir.\n */\n\nexport type DoctorToolKind = \"managed\" | \"system\";\n\nexport interface DoctorCheck {\n\t/** Stable identifier, e.g. \"fff-node\", \"ripgrep\", \"ollama\", \"python\". */\n\tid: string;\n\t/** Human-readable label for the report. */\n\tlabel: string;\n\tkind: DoctorToolKind;\n\tpresent: boolean;\n\t/** Version/path/status summary, present or not. */\n\tdetail?: string;\n\t/** Only set for a \"managed\" tool: whether an install was just attempted. */\n\tinstallAttempted?: boolean;\n\t/** Only set for a \"system\" tool that's missing: exact manual steps, never executed. */\n\tguide?: string[];\n}\n\nexport interface DoctorReport {\n\tchecks: DoctorCheck[];\n}\n\n/** Minimal slice of OllamaRuntime the doctor needs -- lets tests inject a fake without touching the real runtime. */\nexport type DoctorOllamaRuntime = Pick<OllamaRuntime, \"detect\" | \"installGuide\">;\n\nexport interface DoctorDeps {\n\tloadAvailableFffNodePackage: () => unknown | undefined;\n\tensureFffNodePackage: (silent?: boolean) => Promise<unknown | undefined>;\n\tgetLastFffInstallOutcome: () => FffInstallOutcome | undefined;\n\tgetRgPath: () => string | null;\n\tdetectPython: () => SystemToolStatus;\n\t/** Best-effort `<command> --version` probe; undefined if it can't be run. Used only to enrich a status line, never for presence detection. */\n\tprobeVersion: (command: string, versionArgs?: readonly string[]) => string | undefined;\n\tollamaRuntime: DoctorOllamaRuntime;\n}\n\nexport interface RunDoctorOptions {\n\t/**\n\t * Whether the fff-node managed install (if one is attempted) stays quiet.\n\t * Default true, matching runUpdatePreflight's existing background\n\t * behavior. The interactive `doctor` command passes `false` so a\n\t * multi-second install doesn't read as a silent hang.\n\t */\n\tsilent?: boolean;\n}\n\nconst realDoctorDeps: DoctorDeps = {\n\tloadAvailableFffNodePackage,\n\tensureFffNodePackage,\n\tgetLastFffInstallOutcome,\n\tgetRgPath: () => getToolPath(\"rg\"),\n\tdetectPython: () => detectPython(),\n\tprobeVersion,\n\tollamaRuntime: new OllamaRuntime({ agentDir: getAgentDir() }),\n};\n\nconst RIPGREP_GUIDE = [\n\t\"ripgrep (rg) was not found. Pi never runs installers itself -- manual steps (user-level, no sudo):\",\n\t\" - macOS (Homebrew): brew install ripgrep\",\n\t\" - Debian/Ubuntu: apt install ripgrep\",\n\t\" - Arch: pacman -S ripgrep\",\n\t\" - Or download a release: https://github.com/BurntSushi/ripgrep/releases\",\n];\n\nconst PYTHON_GUIDE = [\n\t\"python3 was not found. Pi never runs installers itself -- manual steps (user-level, no sudo):\",\n\t\" - macOS (Homebrew): brew install python3\",\n\t\" - Debian/Ubuntu: apt install python3\",\n\t\" - Arch: pacman -S python\",\n\t\" - Or download from https://www.python.org/downloads/\",\n];\n\nfunction describeFffOutcome(outcome: FffInstallOutcome | undefined): string {\n\tswitch (outcome?.status) {\n\t\tcase \"already-available\":\n\t\t\treturn \"already available\";\n\t\tcase \"installed\":\n\t\t\treturn \"installed just now\";\n\t\tcase \"offline\":\n\t\t\treturn \"offline mode enabled, skipped\";\n\t\tcase \"unsupported-platform\":\n\t\t\treturn \"no native build for this platform\";\n\t\tcase \"install-failed\":\n\t\t\treturn `install failed: ${outcome.reason}`;\n\t\tdefault:\n\t\t\treturn \"unavailable\";\n\t}\n}\n\n/** First line only -- a compiled-features/build-flags tail (e.g. rg --version) is noise in a one-line status report. */\nfunction firstLine(text: string | undefined): string | undefined {\n\treturn text?.split(\"\\n\")[0];\n}\n\n/** MANAGED tool: pi owns this install path, so the doctor actually attempts it when missing. */\nasync function checkFffNode(deps: DoctorDeps, silent: boolean): Promise<DoctorCheck> {\n\t// Delegate entirely to ensureFffNodePackage rather than re-deriving \"is it\n\t// already there\" here: that function (and its offline/unsupported-platform/\n\t// cooldown handling, see tools-manager.ts) is the single source of truth for\n\t// whether an install should even be attempted -- the doctor must not\n\t// duplicate or second-guess that logic, only report its outcome honestly.\n\tconst installed = await deps.ensureFffNodePackage(silent);\n\tconst outcome = deps.getLastFffInstallOutcome();\n\treturn {\n\t\tid: \"fff-node\",\n\t\tlabel: \"FFF native search (@ff-labs/fff-node)\",\n\t\tkind: \"managed\",\n\t\tpresent: Boolean(installed),\n\t\tinstallAttempted: outcome !== undefined && outcome.status !== \"already-available\",\n\t\tdetail: describeFffOutcome(outcome),\n\t};\n}\n\n/** SYSTEM tool: guide mode only, never installed by the doctor. */\nfunction checkRipgrep(deps: DoctorDeps): DoctorCheck {\n\tconst path = deps.getRgPath();\n\tif (!path) {\n\t\treturn { id: \"ripgrep\", label: \"ripgrep (rg)\", kind: \"system\", present: false, guide: RIPGREP_GUIDE };\n\t}\n\tconst version = firstLine(deps.probeVersion(path));\n\treturn {\n\t\tid: \"ripgrep\",\n\t\tlabel: \"ripgrep (rg)\",\n\t\tkind: \"system\",\n\t\tpresent: true,\n\t\tdetail: version ? `${path} (${version})` : path,\n\t};\n}\n\n/** SYSTEM tool: guide mode only, never installed by the doctor (OllamaRuntime.installGuide() is itself already guide-mode-only). */\nasync function checkOllama(deps: DoctorDeps): Promise<DoctorCheck> {\n\tconst status = await deps.ollamaRuntime.detect();\n\tif (!status.binaryPath) {\n\t\treturn {\n\t\t\tid: \"ollama\",\n\t\t\tlabel: \"Ollama (local model runtime)\",\n\t\t\tkind: \"system\",\n\t\t\tpresent: false,\n\t\t\tguide: deps.ollamaRuntime.installGuide(),\n\t\t};\n\t}\n\tconst version = firstLine(deps.probeVersion(status.binaryPath));\n\tconst versionSuffix = version ? `, ${version}` : \"\";\n\treturn {\n\t\tid: \"ollama\",\n\t\tlabel: \"Ollama (local model runtime)\",\n\t\tkind: \"system\",\n\t\tpresent: true,\n\t\tdetail: `binary: ${status.binaryPath} [${status.binarySource}]${versionSuffix}; server: ${status.serverUp ? \"up\" : \"down\"} at ${status.serverUrl}`,\n\t};\n}\n\n/** SYSTEM tool: guide mode only, never installed by the doctor. */\nfunction checkPython(deps: DoctorDeps): DoctorCheck {\n\tconst status = deps.detectPython();\n\tif (!status.present) {\n\t\treturn { id: \"python\", label: \"Python (python3)\", kind: \"system\", present: false, guide: PYTHON_GUIDE };\n\t}\n\tconst detail = status.version ? `${status.command} (${status.version})` : status.command;\n\treturn { id: \"python\", label: \"Python (python3)\", kind: \"system\", present: true, detail };\n}\n\nexport async function runDoctor(\n\tdeps: DoctorDeps = realDoctorDeps,\n\toptions: RunDoctorOptions = {},\n): Promise<DoctorReport> {\n\tconst silent = options.silent ?? true;\n\tconst [fffNode, ollama] = await Promise.all([checkFffNode(deps, silent), checkOllama(deps)]);\n\treturn { checks: [fffNode, checkRipgrep(deps), ollama, checkPython(deps)] };\n}\n\nexport function formatDoctorReport(report: DoctorReport): string {\n\tconst lines: string[] = [];\n\tfor (const check of report.checks) {\n\t\tconst status = check.present ? chalk.green(\"[OK]\") : chalk.yellow(\"[MISSING]\");\n\t\tconst detail = check.detail ? ` -- ${check.detail}` : \"\";\n\t\tlines.push(`${status} ${check.label}${detail}`);\n\t\tif (!check.present && check.guide) {\n\t\t\tfor (const guideLine of check.guide) lines.push(` ${guideLine}`);\n\t\t}\n\t}\n\treturn lines.join(\"\\n\");\n}\n\n/**\n * Best-effort preflight meant to be called right after `pi-adaptative update`\n * succeeds. Must never fail the update itself: any error here is swallowed\n * and reported as a skipped check, not surfaced as an update failure.\n */\nexport async function runUpdatePreflight(): Promise<void> {\n\ttry {\n\t\tconst report = await runDoctor();\n\t\tconsole.log(`\\n${formatDoctorReport(report)}\\n`);\n\t} catch (error) {\n\t\tconsole.log(chalk.dim(`(environment check skipped: ${error instanceof Error ? error.message : String(error)})`));\n\t}\n}\n"]}
1
+ {"version":3,"file":"doctor.js","sourceRoot":"","sources":["../../src/core/doctor.ts"],"names":[],"mappings":"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","sourcesContent":["import chalk from \"chalk\";\nimport { getAgentDir } from \"../config.ts\";\nimport {\n\tdetectPython,\n\tensureFffNodePackage,\n\ttype FffInstallOutcome,\n\tgetLastFffInstallOutcome,\n\tgetToolPath,\n\tloadAvailableFffNodePackage,\n\tprobeVersion,\n\ttype SystemToolStatus,\n} from \"../utils/tools-manager.ts\";\nimport { OllamaRuntime } from \"./models/local-runtime.ts\";\n\n/**\n * Environment doctor: verifies required tooling and installs what it safely\n * can, instead of leaving provisioning entirely to lazy first-use (the exact\n * gap behind \"ran `pi-adaptative update` on another machine and fff-node\n * never got installed\" -- see fff-lazy-install.test.ts/fff-search-tools.test.ts\n * for the underlying lazy-install fix; this module is the proactive half).\n *\n * Two tool kinds, two different postures:\n * - \"managed\": pi already owns the install path (fff-node into\n * ~/.pi/agent/bin via tools-manager.ts's ensureFffNodePackage). The doctor\n * actually attempts the install when missing.\n * - \"system\": pi does not own the install (ripgrep, ollama, python). GUIDE\n * MODE only -- exact manual steps are reported, never executed, never\n * `curl | sh`, never `sudo`. This matches the harness's deliberate\n * no-silent-installer stance for tooling outside its own managed bin dir.\n */\n\nexport type DoctorToolKind = \"managed\" | \"system\";\n\nexport interface DoctorCheck {\n\t/** Stable identifier, e.g. \"fff-node\", \"ripgrep\", \"ollama\", \"python\". */\n\tid: string;\n\t/** Human-readable label for the report. */\n\tlabel: string;\n\tkind: DoctorToolKind;\n\tpresent: boolean;\n\t/** Version/path/status summary, present or not. */\n\tdetail?: string;\n\t/** Only set for a \"managed\" tool: whether an install was just attempted. */\n\tinstallAttempted?: boolean;\n\t/** Only set for a \"system\" tool that's missing: exact manual steps, never executed. */\n\tguide?: string[];\n}\n\nexport interface DoctorReport {\n\tchecks: DoctorCheck[];\n}\n\n/** Minimal slice of OllamaRuntime the doctor needs -- lets tests inject a fake without touching the real runtime. */\nexport type DoctorOllamaRuntime = Pick<OllamaRuntime, \"detect\" | \"installGuide\">;\n\nexport interface DoctorDeps {\n\tloadAvailableFffNodePackage: () => unknown | undefined;\n\tensureFffNodePackage: (silent?: boolean) => Promise<unknown | undefined>;\n\tgetLastFffInstallOutcome: () => FffInstallOutcome | undefined;\n\tgetRgPath: () => string | null;\n\tdetectPython: () => SystemToolStatus;\n\t/** Best-effort `<command> --version` probe; undefined if it can't be run. Used only to enrich a status line, never for presence detection. */\n\tprobeVersion: (command: string, versionArgs?: readonly string[]) => string | undefined;\n\tollamaRuntime: DoctorOllamaRuntime;\n}\n\nexport interface RunDoctorOptions {\n\t/**\n\t * Whether the fff-node managed install (if one is attempted) stays quiet.\n\t * Default true, matching runUpdatePreflight's existing background\n\t * behavior. The interactive `doctor` command passes `false` so a\n\t * multi-second install doesn't read as a silent hang.\n\t */\n\tsilent?: boolean;\n}\n\nconst realDoctorDeps: DoctorDeps = {\n\tloadAvailableFffNodePackage,\n\tensureFffNodePackage,\n\tgetLastFffInstallOutcome,\n\tgetRgPath: () => getToolPath(\"rg\"),\n\tdetectPython: () => detectPython(),\n\tprobeVersion,\n\tollamaRuntime: new OllamaRuntime({ agentDir: getAgentDir() }),\n};\n\nconst RIPGREP_GUIDE = [\n\t\"ripgrep (rg) was not found. Pi never runs installers itself -- manual steps (user-level, no sudo):\",\n\t\" - macOS (Homebrew): brew install ripgrep\",\n\t\" - Debian/Ubuntu: apt install ripgrep\",\n\t\" - Arch: pacman -S ripgrep\",\n\t\" - Or download a release: https://github.com/BurntSushi/ripgrep/releases\",\n];\n\nconst PYTHON_GUIDE = [\n\t\"python3 was not found. Pi never runs installers itself -- manual steps (user-level, no sudo):\",\n\t\" - macOS (Homebrew): brew install python3\",\n\t\" - Debian/Ubuntu: apt install python3\",\n\t\" - Arch: pacman -S python\",\n\t\" - Or download from https://www.python.org/downloads/\",\n];\n\nfunction describeFffOutcome(outcome: FffInstallOutcome | undefined): string {\n\tswitch (outcome?.status) {\n\t\tcase \"already-available\":\n\t\t\treturn \"already available\";\n\t\tcase \"installed\":\n\t\t\treturn \"installed just now\";\n\t\tcase \"offline\":\n\t\t\treturn \"offline mode enabled, skipped\";\n\t\tcase \"unsupported-platform\":\n\t\t\treturn \"no native build for this platform\";\n\t\tcase \"install-failed\":\n\t\t\treturn `install failed: ${outcome.reason}`;\n\t\tdefault:\n\t\t\treturn \"unavailable\";\n\t}\n}\n\n/** First line only -- a compiled-features/build-flags tail (e.g. rg --version) is noise in a one-line status report. */\nfunction firstLine(text: string | undefined): string | undefined {\n\treturn text?.split(\"\\n\")[0];\n}\n\n/** MANAGED tool: pi owns this install path, so the doctor actually attempts it when missing. */\nasync function checkFffNode(deps: DoctorDeps, silent: boolean): Promise<DoctorCheck> {\n\t// Delegate entirely to ensureFffNodePackage rather than re-deriving \"is it\n\t// already there\" here: that function (and its offline/unsupported-platform/\n\t// cooldown handling, see tools-manager.ts) is the single source of truth for\n\t// whether an install should even be attempted -- the doctor must not\n\t// duplicate or second-guess that logic, only report its outcome honestly.\n\tconst installed = await deps.ensureFffNodePackage(silent);\n\tconst outcome = deps.getLastFffInstallOutcome();\n\treturn {\n\t\tid: \"fff-node\",\n\t\tlabel: \"FFF native search (@ff-labs/fff-node)\",\n\t\tkind: \"managed\",\n\t\tpresent: Boolean(installed),\n\t\tinstallAttempted: outcome !== undefined && outcome.status !== \"already-available\",\n\t\tdetail: describeFffOutcome(outcome),\n\t};\n}\n\n/** SYSTEM tool: guide mode only, never installed by the doctor. */\nfunction checkRipgrep(deps: DoctorDeps): DoctorCheck {\n\tconst path = deps.getRgPath();\n\tif (!path) {\n\t\treturn { id: \"ripgrep\", label: \"ripgrep (rg)\", kind: \"system\", present: false, guide: RIPGREP_GUIDE };\n\t}\n\tconst version = firstLine(deps.probeVersion(path));\n\treturn {\n\t\tid: \"ripgrep\",\n\t\tlabel: \"ripgrep (rg)\",\n\t\tkind: \"system\",\n\t\tpresent: true,\n\t\tdetail: version ? `${path} (${version})` : path,\n\t};\n}\n\n/** SYSTEM tool: guide mode only, never installed by the doctor (OllamaRuntime.installGuide() is itself already guide-mode-only). */\nasync function checkOllama(deps: DoctorDeps): Promise<DoctorCheck> {\n\tconst status = await deps.ollamaRuntime.detect();\n\tif (!status.binaryPath) {\n\t\treturn {\n\t\t\tid: \"ollama\",\n\t\t\tlabel: \"Ollama (local model runtime)\",\n\t\t\tkind: \"system\",\n\t\t\tpresent: false,\n\t\t\tguide: deps.ollamaRuntime.installGuide(),\n\t\t};\n\t}\n\tconst version = firstLine(deps.probeVersion(status.binaryPath));\n\tconst versionSuffix = version ? `, ${version}` : \"\";\n\tconst activeStore = status.activeStore\n\t\t? `${status.activeStore.path} [${status.activeStore.kind}, ${status.activeStore.modelCount} model(s)]`\n\t\t: `none; pi-owned store ${status.ownedModelsDir} has ${status.ownedStore.modelCount} model(s)`;\n\treturn {\n\t\tid: \"ollama\",\n\t\tlabel: \"Ollama (local model runtime)\",\n\t\tkind: \"system\",\n\t\tpresent: true,\n\t\tdetail: `binary: ${status.binaryPath} [${status.binarySource}]${versionSuffix}; server: ${status.serverUp ? \"up\" : \"down\"} at ${status.serverUrl}; active store: ${activeStore}`,\n\t};\n}\n\n/** SYSTEM tool: guide mode only, never installed by the doctor. */\nfunction checkPython(deps: DoctorDeps): DoctorCheck {\n\tconst status = deps.detectPython();\n\tif (!status.present) {\n\t\treturn { id: \"python\", label: \"Python (python3)\", kind: \"system\", present: false, guide: PYTHON_GUIDE };\n\t}\n\tconst detail = status.version ? `${status.command} (${status.version})` : status.command;\n\treturn { id: \"python\", label: \"Python (python3)\", kind: \"system\", present: true, detail };\n}\n\nexport async function runDoctor(\n\tdeps: DoctorDeps = realDoctorDeps,\n\toptions: RunDoctorOptions = {},\n): Promise<DoctorReport> {\n\tconst silent = options.silent ?? true;\n\tconst [fffNode, ollama] = await Promise.all([checkFffNode(deps, silent), checkOllama(deps)]);\n\treturn { checks: [fffNode, checkRipgrep(deps), ollama, checkPython(deps)] };\n}\n\nexport function formatDoctorReport(report: DoctorReport): string {\n\tconst lines: string[] = [];\n\tfor (const check of report.checks) {\n\t\tconst status = check.present ? chalk.green(\"[OK]\") : chalk.yellow(\"[MISSING]\");\n\t\tconst detail = check.detail ? ` -- ${check.detail}` : \"\";\n\t\tlines.push(`${status} ${check.label}${detail}`);\n\t\tif (!check.present && check.guide) {\n\t\t\tfor (const guideLine of check.guide) lines.push(` ${guideLine}`);\n\t\t}\n\t}\n\treturn lines.join(\"\\n\");\n}\n\n/**\n * Best-effort preflight meant to be called right after `pi-adaptative update`\n * succeeds. Must never fail the update itself: any error here is swallowed\n * and reported as a skipped check, not surfaced as an update failure.\n */\nexport async function runUpdatePreflight(): Promise<void> {\n\ttry {\n\t\tconst report = await runDoctor();\n\t\tconsole.log(`\\n${formatDoctorReport(report)}\\n`);\n\t} catch (error) {\n\t\tconsole.log(chalk.dim(`(environment check skipped: ${error instanceof Error ? error.message : String(error)})`));\n\t}\n}\n"]}
@@ -61,6 +61,8 @@ export declare class LocalRuntimeController {
61
61
  */
62
62
  private confirmationKey;
63
63
  private invalidateIfLastCallFailed;
64
+ private ensureOllamaResident;
65
+ private ensureTransformersResident;
64
66
  /**
65
67
  * Ensure a routed managed-local model is actually reachable before the turn calls it. No-op (and
66
68
  * free) for non-local/API models. Caches a "confirmed up this session" flag per server (and per
@@ -1 +1 @@
1
- {"version":3,"file":"local-runtime-controller.d.ts","sourceRoot":"","sources":["../../src/core/local-runtime-controller.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,GAAG,EAAE,gBAAgB,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AACtE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC5D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAC7D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAEhE,OAAO,EACN,KAAK,gBAAgB,EACrB,aAAa,EAEb,mBAAmB,EACnB,MAAM,2BAA2B,CAAC;AAUnC,MAAM,WAAW,0BAA0B;IAC1C,oGAAkG;IAClG,QAAQ,EAAE,MAAM,CAAC;IACjB,mGAAmG;IACnG,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IACpC;8BAC0B;IAC1B,uBAAuB,IAAI,gBAAgB,GAAG,SAAS,CAAC;IACxD,mGAAiG;IACjG,YAAY,IAAI,kBAAkB,GAAG,SAAS,CAAC;IAC/C,0GAA0G;IAC1G,IAAI,CAAC,KAAK,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACrC;6CACyC;IACzC,0BAA0B,CAAC,IAAI,EAAE,QAAQ,GAAG,WAAW,GAAG,KAAK,CAAC,GAAG,CAAC,GAAG,SAAS,CAAC;IACjF,uEAAuE;IACvE,WAAW,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC;CACvC;AAED,qBAAa,sBAAsB;IAClC,qGAAqG;IACrG,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAoC;IAC9D,uGAAuG;IACvG,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAA0C;IAChF;uEACmE;IACnE,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAqB;IAElD,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA6B;IAElD,YAAY,IAAI,EAAE,0BAA0B,EAE3C;IAED;;;;;OAKG;IACH,eAAe,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,aAAa,CAQ/C;IAED,sBAAsB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,mBAAmB,CAc7E;IAED;yEACqE;IACrE,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAElD;IAED,OAAO,CAAC,2BAA2B;IAInC,OAAO,CAAC,sBAAsB;IAI9B;;;;OAIG;IACH,OAAO,CAAC,eAAe;IAIvB,OAAO,CAAC,0BAA0B;IAWlC;;;;;OAKG;IACG,qBAAqB,CAC1B,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,GACf,OAAO,CAAC;QAAE,KAAK,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAwBtE;IAEK,4BAA4B,CACjC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,GACf,OAAO,CAAC;QAAE,KAAK,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAyBtE;YAgBa,2BAA2B;YAsC3B,iCAAiC;IA+C/C;;;;;;;;;;;;;;;;OAgBG;IACG,qBAAqB,CAC1B,QAAQ,EAAE;QAAE,QAAQ,EAAE,aAAa,CAAC;QAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,CAAA;KAAE,GAAG,SAAS,GAClE,OAAO,CAAC;QAAE,QAAQ,EAAE,aAAa,CAAC;QAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,CAAA;KAAE,GAAG,SAAS,CAAC,CAoErE;CACD","sourcesContent":["/**\n * Local-runtime (Ollama) lifecycle controller.\n *\n * Extracted verbatim from agent-session.ts (god-file decomposition). Owns the cached, per-server\n * {@link OllamaRuntime} instances, the \"confirmed up this session\" flag, and the router's readiness\n * gate for a turn routed to a local (`ollama`) model — including the #31 install-on-consent flow and\n * the #27 graceful tier-escalation fallback. Takes narrow deps (agent dir, a last-assistant-message\n * accessor, the session's UI context/event emitter, and the router's own tier resolver) rather than\n * the whole AgentSession.\n */\n\nimport type { Api, AssistantMessage, Model } from \"@caupulican/pi-ai\";\nimport type { AgentSessionEvent } from \"./agent-session.ts\";\nimport type { RouteDecision } from \"./autonomy/contracts.ts\";\nimport type { ExtensionUIContext } from \"./extensions/index.ts\";\nimport { HF_TRANSFORMERS_PROVIDER, OLLAMA_PROVIDER } from \"./models/local-registration.ts\";\nimport {\n\ttype LocalRuntimeDeps,\n\tOllamaRuntime,\n\tresolveTransformersBaseUrl,\n\tTransformersRuntime,\n} from \"./models/local-runtime.ts\";\n\n/** User-facing router tiers in ascending order — \"learning\" is never selected for a user turn, so\n * it has no place in the escalation ladder (#27's ensureRouteModelReady walks this forward only). */\nconst MODEL_ROUTER_TIER_ORDER: readonly (\"cheap\" | \"medium\" | \"expensive\")[] = [\"cheap\", \"medium\", \"expensive\"];\n\n/** How long the #31 \"install ollama now?\" confirm waits before auto-dismissing (same as a \"No\") —\n * long enough to read and decide, short enough that an unattended session doesn't hang a turn on it. */\nconst OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS = 30_000;\n\nexport interface LocalRuntimeControllerDeps {\n\t/** Root directory OllamaRuntime instances are scoped under — fixed for the session's lifetime. */\n\tagentDir: string;\n\t/** Test-injectable seams for OllamaRuntime's own fetch/spawn/exists calls; unset in production. */\n\tlocalRuntimeDeps?: LocalRuntimeDeps;\n\t/** The session's last assistant message, to detect a just-failed local call and drop a stale\n\t * \"confirmed up\" flag. */\n\tgetLastAssistantMessage(): AssistantMessage | undefined;\n\t/** The session's live interactive UI context, if any — undefined in headless/RPC/print modes. */\n\tgetUIContext(): ExtensionUIContext | undefined;\n\t/** Emits a session event (only ever `warning` / `routing_start` / `routing_end` from this controller). */\n\temit(event: AgentSessionEvent): void;\n\t/** Resolves the model configured for a router tier, respecting configured auth — owned by the\n\t * router itself, not this controller. */\n\tresolveConfiguredTierModel(tier: \"medium\" | \"expensive\"): Model<Api> | undefined;\n\t/** `${provider}/${id}` label for a model, for warning/confirm text. */\n\tformatModel(model: Model<Api>): string;\n}\n\nexport class LocalRuntimeController {\n\t/** Lazy, cached by baseUrl so the router path and any other caller share one instance per server. */\n\tprivate readonly _runtimes = new Map<string, OllamaRuntime>();\n\t/** Lazy, cached by model+baseUrl so the router and `/models` share one sidecar handle per HF model. */\n\tprivate readonly _transformersRuntimes = new Map<string, TransformersRuntime>();\n\t/** Server URLs confirmed reachable THIS session — skips the health-check round trip on every\n\t * local-routed turn once warm. Keyed the same way as _runtimes. */\n\tprivate readonly _confirmedUp = new Set<string>();\n\n\tprivate readonly deps: LocalRuntimeControllerDeps;\n\n\tconstructor(deps: LocalRuntimeControllerDeps) {\n\t\tthis.deps = deps;\n\t}\n\n\t/**\n\t * Shared {@link OllamaRuntime} for a given server, lazily created and cached by baseUrl so every\n\t * caller — the router's readiness gate below and any host UI's own model-lifecycle commands\n\t * (e.g. `/models`) — sees and can stop the SAME pi-managed process instead of each tracking its\n\t * own untracked child.\n\t */\n\tgetLocalRuntime(baseUrl?: string): OllamaRuntime {\n\t\tconst key = baseUrl ?? \"default\";\n\t\tlet runtime = this._runtimes.get(key);\n\t\tif (!runtime) {\n\t\t\truntime = new OllamaRuntime({ agentDir: this.deps.agentDir, baseUrl, deps: this.deps.localRuntimeDeps });\n\t\t\tthis._runtimes.set(key, runtime);\n\t\t}\n\t\treturn runtime;\n\t}\n\n\tgetTransformersRuntime(modelId: string, baseUrl?: string): TransformersRuntime {\n\t\tconst resolvedBaseUrl = baseUrl?.replace(/\\/$/, \"\") ?? resolveTransformersBaseUrl(modelId);\n\t\tconst key = `${modelId}\\0${resolvedBaseUrl}`;\n\t\tlet runtime = this._transformersRuntimes.get(key);\n\t\tif (!runtime) {\n\t\t\truntime = new TransformersRuntime({\n\t\t\t\tagentDir: this.deps.agentDir,\n\t\t\t\tmodelId,\n\t\t\t\tbaseUrl: resolvedBaseUrl,\n\t\t\t\tdeps: this.deps.localRuntimeDeps,\n\t\t\t});\n\t\t\tthis._transformersRuntimes.set(key, runtime);\n\t\t}\n\t\treturn runtime;\n\t}\n\n\t/** models.json registers a local model's baseUrl as `<server>/v1` (OpenAI-compat); the runtime's\n\t * own health/boot endpoints are on the Ollama-native server root. */\n\tderiveOllamaServerUrl(modelBaseUrl: string): string {\n\t\treturn modelBaseUrl.replace(/\\/v1\\/?$/, \"\");\n\t}\n\n\tprivate deriveOpenAICompatServerUrl(modelBaseUrl: string): string {\n\t\treturn modelBaseUrl.replace(/\\/v1\\/?$/, \"\");\n\t}\n\n\tprivate isManagedLocalProvider(provider: string): boolean {\n\t\treturn provider === OLLAMA_PROVIDER || provider === HF_TRANSFORMERS_PROVIDER;\n\t}\n\n\t/**\n\t * If the last assistant message in this session was an error from THIS exact local server, a\n\t * cached \"confirmed up\" flag would be stale (the server may have died mid-session) — drop it so\n\t * the next ensure-check is a real one instead of trusting stale state.\n\t */\n\tprivate confirmationKey(model: Model<Api>, serverUrl: string): string {\n\t\treturn model.provider === HF_TRANSFORMERS_PROVIDER ? `${serverUrl}\\0${model.id}` : serverUrl;\n\t}\n\n\tprivate invalidateIfLastCallFailed(model: Model<Api>, serverUrl: string): void {\n\t\tconst lastAssistant = this.deps.getLastAssistantMessage();\n\t\tif (\n\t\t\tlastAssistant?.stopReason === \"error\" &&\n\t\t\tlastAssistant.provider === model.provider &&\n\t\t\tlastAssistant.model === model.id\n\t\t) {\n\t\t\tthis._confirmedUp.delete(this.confirmationKey(model, serverUrl));\n\t\t}\n\t}\n\n\t/**\n\t * Ensure a routed managed-local model is actually reachable before the turn calls it. No-op (and\n\t * free) for non-local/API models. Caches a \"confirmed up this session\" flag per server (and per\n\t * Transformers model) so steady-state routing pays the health-check round trip once; invalidated\n\t * above when a prior local call failed so a dead sidecar gets re-detected instead of trusted.\n\t */\n\tasync ensureLocalModelReady(\n\t\tmodel: Model<Api>,\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[] }> {\n\t\tif (model.provider !== OLLAMA_PROVIDER) {\n\t\t\treturn { ready: true, reason: \"not_local\" };\n\t\t}\n\t\tconst serverUrl = this.deriveOllamaServerUrl(model.baseUrl);\n\t\tconst confirmedKey = this.confirmationKey(model, serverUrl);\n\t\tthis.invalidateIfLastCallFailed(model, serverUrl);\n\t\tif (this._confirmedUp.has(confirmedKey)) {\n\t\t\treturn { ready: true, reason: \"confirmed_up_cached\" };\n\t\t}\n\t\tconst runtime = this.getLocalRuntime(serverUrl);\n\t\tconst status = await runtime.detect();\n\t\tif (status.serverUp) {\n\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t\treturn { ready: true, reason: \"already_running\" };\n\t\t}\n\t\tif (!status.binaryPath) {\n\t\t\treturn { ready: false, reason: \"binary_missing\", installGuide: runtime.installGuide() };\n\t\t}\n\t\tconst started = await runtime.startReuseExisting();\n\t\tif (started.started) {\n\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t}\n\t\treturn { ready: started.started, reason: started.reason };\n\t}\n\n\tasync ensureTransformersModelReady(\n\t\tmodel: Model<Api>,\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[] }> {\n\t\tif (model.provider !== HF_TRANSFORMERS_PROVIDER) {\n\t\t\treturn { ready: true, reason: \"not_transformers\" };\n\t\t}\n\t\tconst serverUrl = this.deriveOpenAICompatServerUrl(model.baseUrl);\n\t\tconst confirmedKey = this.confirmationKey(model, serverUrl);\n\t\tthis.invalidateIfLastCallFailed(model, serverUrl);\n\t\tif (this._confirmedUp.has(confirmedKey)) {\n\t\t\treturn { ready: true, reason: \"confirmed_up_cached\" };\n\t\t}\n\t\tconst runtime = this.getTransformersRuntime(model.id, serverUrl);\n\t\tconst status = await runtime.detect();\n\t\tif (status.serverUp) {\n\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t\treturn { ready: true, reason: \"already_running\" };\n\t\t}\n\t\tif (!status.runtimeInstalled) {\n\t\t\treturn { ready: false, reason: \"runtime_missing\", installGuide: runtime.installGuide() };\n\t\t}\n\t\tconst started = await runtime.start();\n\t\tif (started.started || started.reason === \"already_running\") {\n\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t\treturn { ready: true, reason: started.reason };\n\t\t}\n\t\treturn { ready: false, reason: started.reason };\n\t}\n\n\t/**\n\t * #31: the ONE case a routed local model's unreadiness can be fixed automatically is a missing\n\t * ollama binary — an unreachable server can't be helped by installing, so that reason is left to\n\t * the graceful-fallback warning below unchanged. Only offered when there's an interactive UI to\n\t * ask through: headless/RPC/print sessions have no UI context and fall straight through, same as\n\t * declining or timing out (both resolve confirm() to false). Reverses \"pi never runs installers\n\t * itself\" specifically for this one path — the user is asked first, the download is pi's own\n\t * (never curl|sh), and it lands in pi's own runtimes dir (see OllamaRuntime.installManaged).\n\t *\n\t * Pauses/resumes the routing working-indicator around the confirm dialog itself (re-emitting\n\t * routing_end/routing_start — both already idempotent, see interactive-mode.ts's handlers) so an\n\t * animated spinner doesn't fight a dialog the user is trying to read and answer; the indicator\n\t * comes back for the download/extract that follows a \"yes\", which is genuine processing feedback.\n\t */\n\tprivate async maybeInstallOllamaOnConsent(\n\t\tmodel: Model<Api>,\n\t\treadiness: { ready: boolean; reason: string; installGuide?: string[] },\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[]; installAttemptError?: string }> {\n\t\tconst ui = this.deps.getUIContext();\n\t\tif (!ui || readiness.ready || readiness.reason !== \"binary_missing\") return readiness;\n\n\t\tconst modelLabel = this.deps.formatModel(model);\n\t\tthis.deps.emit({ type: \"routing_end\" });\n\t\tlet confirmed: boolean;\n\t\ttry {\n\t\t\tconfirmed = await ui.confirm(\n\t\t\t\t\"Install Ollama?\",\n\t\t\t\t`Ollama isn't installed, so the local model \"${modelLabel}\" can't run. Pi can download and ` +\n\t\t\t\t\t\"install it now (a large one-time download, possibly over 1 GB depending on your platform) \" +\n\t\t\t\t\t\"into its own runtimes folder — never curl|sh, never touching anything outside pi's own \" +\n\t\t\t\t\t\"directory. Install it now?\",\n\t\t\t\t{ timeout: OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS },\n\t\t\t);\n\t\t} finally {\n\t\t\tthis.deps.emit({ type: \"routing_start\" });\n\t\t}\n\t\tif (!confirmed) return readiness;\n\n\t\tconst serverUrl = this.deriveOllamaServerUrl(model.baseUrl);\n\t\tconst runtime = this.getLocalRuntime(serverUrl);\n\t\tlet installResult: { ok: boolean; error?: string };\n\t\ttry {\n\t\t\tinstallResult = await runtime.installManaged((status) => ui.setStatus(\"ollama-install\", status));\n\t\t} finally {\n\t\t\tui.setStatus(\"ollama-install\", undefined);\n\t\t}\n\t\tif (!installResult.ok) {\n\t\t\treturn { ready: false, reason: \"install_failed\", installAttemptError: installResult.error };\n\t\t}\n\t\treturn this.ensureLocalModelReady(model);\n\t}\n\n\tprivate async maybeInstallTransformersOnConsent(\n\t\tmodel: Model<Api>,\n\t\treadiness: { ready: boolean; reason: string; installGuide?: string[] },\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[]; installAttemptError?: string }> {\n\t\tconst ui = this.deps.getUIContext();\n\t\tif (!ui || readiness.ready || readiness.reason !== \"runtime_missing\") return readiness;\n\n\t\tconst modelLabel = this.deps.formatModel(model);\n\t\tthis.deps.emit({ type: \"routing_end\" });\n\t\tlet confirmed: boolean;\n\t\ttry {\n\t\t\tconfirmed = await ui.confirm(\n\t\t\t\t\"Install Transformers runtime?\",\n\t\t\t\t`The Hugging Face model \"${modelLabel}\" needs a pi-managed Python venv with Transformers ` +\n\t\t\t\t\t\"and CPU PyTorch before it can run. Pi will install those packages into its own runtimes \" +\n\t\t\t\t\t\"folder, download the model into a pi-owned Hugging Face cache, and leave system Python, \" +\n\t\t\t\t\t\"your Ollama models, and your user HF cache untouched. Install it now?\",\n\t\t\t\t{ timeout: OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS },\n\t\t\t);\n\t\t} finally {\n\t\t\tthis.deps.emit({ type: \"routing_start\" });\n\t\t}\n\t\tif (!confirmed) return readiness;\n\n\t\tconst serverUrl = this.deriveOpenAICompatServerUrl(model.baseUrl);\n\t\tconst runtime = this.getTransformersRuntime(model.id, serverUrl);\n\t\tlet installResult: { ok: boolean; error?: string };\n\t\ttry {\n\t\t\tinstallResult = await runtime.installManaged((status) => ui.setStatus(\"transformers-install\", status));\n\t\t} finally {\n\t\t\tui.setStatus(\"transformers-install\", undefined);\n\t\t}\n\t\tif (!installResult.ok) {\n\t\t\treturn { ready: false, reason: \"install_failed\", installAttemptError: installResult.error };\n\t\t}\n\t\tlet downloadResult: { ok: boolean; error?: string };\n\t\ttry {\n\t\t\tdownloadResult = await runtime.downloadModel((status) => ui.setStatus(\"transformers-download\", status));\n\t\t} finally {\n\t\t\tui.setStatus(\"transformers-download\", undefined);\n\t\t}\n\t\tif (!downloadResult.ok) {\n\t\t\treturn { ready: false, reason: \"download_failed\", installAttemptError: downloadResult.error };\n\t\t}\n\t\treturn this.ensureTransformersModelReady(model);\n\t}\n\n\t/**\n\t * Router-swap gate (#27): a turn routed to a local model (any tier, including an executor-direct\n\t * route — both carry tier \"cheap\") must not dead-end the turn just because ollama isn't up.\n\t * Never a SILENT swap: every fallback is announced in a warning that states (i) the local model\n\t * was unavailable and WHY — binary missing surfaces the install guide inline; any other reason\n\t * gets a \"check that ollama is running\" hint — and (ii) which tier is now handling the turn, so\n\t * the cost shift is never a surprise. Escalates cheap -> medium -> expensive, skipping any\n\t * unconfigured intermediate tier, reusing the router's own existing \"model unavailable\"\n\t * resolution (resolveConfiguredTierModel) rather than inventing a new fallback mechanism.\n\t * Escalation is bounded: tier strictly increases each hop, so it terminates within two hops.\n\t *\n\t * Before the warning/escalation below: #31's consent gate gets one shot at fixing a missing\n\t * binary interactively (see maybeInstallOllamaOnConsent) — declining, timing out, running\n\t * headless, or the install attempt itself failing all fall through here unchanged, just with an\n\t * honest reason (an install that failed is worded as a failed install, not re-labeled as if\n\t * nothing was ever tried).\n\t */\n\tasync ensureRouteModelReady(\n\t\tresolved: { decision: RouteDecision; model: Model<Api> } | undefined,\n\t): Promise<{ decision: RouteDecision; model: Model<Api> } | undefined> {\n\t\tlet current = resolved;\n\t\twhile (current && this.isManagedLocalProvider(current.model.provider)) {\n\t\t\tlet readiness: { ready: boolean; reason: string; installGuide?: string[]; installAttemptError?: string } =\n\t\t\t\tcurrent.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t? await this.ensureLocalModelReady(current.model)\n\t\t\t\t\t: await this.ensureTransformersModelReady(current.model);\n\t\t\tif (!readiness.ready) {\n\t\t\t\treadiness =\n\t\t\t\t\tcurrent.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t\t? await this.maybeInstallOllamaOnConsent(current.model, readiness)\n\t\t\t\t\t\t: await this.maybeInstallTransformersOnConsent(current.model, readiness);\n\t\t\t}\n\t\t\tif (readiness.ready) return current;\n\n\t\t\t// Walk the remaining tiers in order (never back down to cheap) and take the first one that\n\t\t\t// actually resolves — an unconfigured intermediate tier (e.g. no mediumModel set) must be\n\t\t\t// skipped, not treated as \"no fallback available\".\n\t\t\tconst startIndex = MODEL_ROUTER_TIER_ORDER.indexOf(current.decision.tier as \"cheap\" | \"medium\" | \"expensive\");\n\t\t\tlet escalated: { tier: \"medium\" | \"expensive\"; model: Model<Api> } | undefined;\n\t\t\tfor (let i = startIndex + 1; startIndex !== -1 && i < MODEL_ROUTER_TIER_ORDER.length; i++) {\n\t\t\t\tconst tier = MODEL_ROUTER_TIER_ORDER[i] as \"medium\" | \"expensive\";\n\t\t\t\tconst model = this.deps.resolveConfiguredTierModel(tier);\n\t\t\t\tif (model) {\n\t\t\t\t\tescalated = { tier, model };\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst modelLabel = this.deps.formatModel(current.model);\n\t\t\tconst localRuntimeName = current.model.provider === OLLAMA_PROVIDER ? \"ollama\" : \"Transformers\";\n\t\t\tconst whyText = readiness.installAttemptError\n\t\t\t\t? `pi tried to install it just now, but the install attempt failed: ${readiness.installAttemptError}`\n\t\t\t\t: readiness.installGuide\n\t\t\t\t\t? [\n\t\t\t\t\t\t\tcurrent.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t\t\t\t? \"the ollama binary is not installed.\"\n\t\t\t\t\t\t\t\t: \"the pi-managed Transformers runtime is not installed.\",\n\t\t\t\t\t\t\t...readiness.installGuide,\n\t\t\t\t\t\t].join(\"\\n\")\n\t\t\t\t\t: current.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t\t? `its ${localRuntimeName} server is not reachable (${readiness.reason}) — check that ollama is running.`\n\t\t\t\t\t\t: `its ${localRuntimeName} server is not reachable (${readiness.reason}) — check that the runtime is running.`;\n\t\t\tconst fallbackText = escalated\n\t\t\t\t? `Falling back to the ${escalated.tier} tier for this turn.`\n\t\t\t\t: \"No other tier is configured — falling back to the session's default model.\";\n\t\t\tthis.deps.emit({\n\t\t\t\ttype: \"warning\",\n\t\t\t\tmessage: `Local model \"${modelLabel}\" is unavailable: ${whyText}\\n${fallbackText}`,\n\t\t\t});\n\n\t\t\tif (!escalated) return undefined; // no higher tier resolves — caller falls back to the session default\n\t\t\tcurrent = {\n\t\t\t\tmodel: escalated.model,\n\t\t\t\tdecision: {\n\t\t\t\t\t...current.decision,\n\t\t\t\t\ttier: escalated.tier,\n\t\t\t\t\tfallbackFrom: current.decision.tier,\n\t\t\t\t\treasonCode: \"local_model_not_ready_fallback\",\n\t\t\t\t\treasons: [\n\t\t\t\t\t\t...current.decision.reasons,\n\t\t\t\t\t\t`Local model not ready (${readiness.reason}); escalated to ${escalated.tier}`,\n\t\t\t\t\t],\n\t\t\t\t\tmodel: this.deps.formatModel(escalated.model),\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\t\treturn current;\n\t}\n}\n"]}
1
+ {"version":3,"file":"local-runtime-controller.d.ts","sourceRoot":"","sources":["../../src/core/local-runtime-controller.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,KAAK,EAAE,GAAG,EAAE,gBAAgB,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AACtE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC5D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAC7D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAEhE,OAAO,EACN,KAAK,gBAAgB,EACrB,aAAa,EAEb,mBAAmB,EACnB,MAAM,2BAA2B,CAAC;AA+BnC,MAAM,WAAW,0BAA0B;IAC1C,oGAAkG;IAClG,QAAQ,EAAE,MAAM,CAAC;IACjB,mGAAmG;IACnG,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IACpC;8BAC0B;IAC1B,uBAAuB,IAAI,gBAAgB,GAAG,SAAS,CAAC;IACxD,mGAAiG;IACjG,YAAY,IAAI,kBAAkB,GAAG,SAAS,CAAC;IAC/C,0GAA0G;IAC1G,IAAI,CAAC,KAAK,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACrC;6CACyC;IACzC,0BAA0B,CAAC,IAAI,EAAE,QAAQ,GAAG,WAAW,GAAG,KAAK,CAAC,GAAG,CAAC,GAAG,SAAS,CAAC;IACjF,uEAAuE;IACvE,WAAW,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC;CACvC;AAED,qBAAa,sBAAsB;IAClC,qGAAqG;IACrG,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAoC;IAC9D,uGAAuG;IACvG,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAA0C;IAChF;uEACmE;IACnE,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAqB;IAElD,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA6B;IAElD,YAAY,IAAI,EAAE,0BAA0B,EAE3C;IAED;;;;;OAKG;IACH,eAAe,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,aAAa,CAQ/C;IAED,sBAAsB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,mBAAmB,CAc7E;IAED;yEACqE;IACrE,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAElD;IAED,OAAO,CAAC,2BAA2B;IAInC,OAAO,CAAC,sBAAsB;IAI9B;;;;OAIG;IACH,OAAO,CAAC,eAAe;IAIvB,OAAO,CAAC,0BAA0B;YAWpB,oBAAoB;YA8BpB,0BAA0B;IAuBxC;;;;;OAKG;IACG,qBAAqB,CAC1B,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,GACf,OAAO,CAAC;QAAE,KAAK,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAkCtE;IAEK,4BAA4B,CACjC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,GACf,OAAO,CAAC;QAAE,KAAK,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CA6BtE;YAgBa,2BAA2B;YAsC3B,iCAAiC;IA+C/C;;;;;;;;;;;;;;;;OAgBG;IACG,qBAAqB,CAC1B,QAAQ,EAAE;QAAE,QAAQ,EAAE,aAAa,CAAC;QAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,CAAA;KAAE,GAAG,SAAS,GAClE,OAAO,CAAC;QAAE,QAAQ,EAAE,aAAa,CAAC;QAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,CAAA;KAAE,GAAG,SAAS,CAAC,CAuErE;CACD","sourcesContent":["/**\n * Local-runtime (Ollama) lifecycle controller.\n *\n * Extracted verbatim from agent-session.ts (god-file decomposition). Owns the cached, per-server\n * {@link OllamaRuntime} instances, the \"confirmed up this session\" flag, and the router's readiness\n * gate for a turn routed to a local (`ollama`) model — including the #31 install-on-consent flow and\n * the #27 graceful tier-escalation fallback. Takes narrow deps (agent dir, a last-assistant-message\n * accessor, the session's UI context/event emitter, and the router's own tier resolver) rather than\n * the whole AgentSession.\n */\n\nimport { totalmem } from \"node:os\";\nimport type { Api, AssistantMessage, Model } from \"@caupulican/pi-ai\";\nimport type { AgentSessionEvent } from \"./agent-session.ts\";\nimport type { RouteDecision } from \"./autonomy/contracts.ts\";\nimport type { ExtensionUIContext } from \"./extensions/index.ts\";\nimport { HF_TRANSFORMERS_PROVIDER, OLLAMA_PROVIDER } from \"./models/local-registration.ts\";\nimport {\n\ttype LocalRuntimeDeps,\n\tOllamaRuntime,\n\tresolveTransformersBaseUrl,\n\tTransformersRuntime,\n} from \"./models/local-runtime.ts\";\nimport { matchesInstalledLocalModel } from \"./models/model-ref.ts\";\nimport {\n\tOllamaRuntimeResidencyAdapter,\n\tRuntimeResidencyArbiter,\n\tTransformersRuntimeResidencyAdapter,\n} from \"./models/runtime-arbiter.ts\";\n\n/** User-facing router tiers in ascending order — \"learning\" is never selected for a user turn, so\n * it has no place in the escalation ladder (#27's ensureRouteModelReady walks this forward only). */\nconst MODEL_ROUTER_TIER_ORDER: readonly (\"cheap\" | \"medium\" | \"expensive\")[] = [\"cheap\", \"medium\", \"expensive\"];\n\n/** How long the #31 \"install ollama now?\" confirm waits before auto-dismissing (same as a \"No\") —\n * long enough to read and decide, short enough that an unattended session doesn't hang a turn on it. */\nconst OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS = 30_000;\n\nfunction parseWrongOllamaStoreReason(\n\treason: string,\n): { activePath: string; modelCount: number; ownedPath: string } | undefined {\n\tif (!reason.startsWith(\"wrong_store:\")) return undefined;\n\tconst parts = reason.slice(\"wrong_store:\".length).split(\":\");\n\tif (parts.length < 3) return undefined;\n\tconst [activePath, countText, ...ownedPathParts] = parts;\n\tconst modelCount = Number(countText);\n\treturn {\n\t\tactivePath,\n\t\tmodelCount: Number.isFinite(modelCount) ? modelCount : 0,\n\t\townedPath: ownedPathParts.join(\":\"),\n\t};\n}\n\nexport interface LocalRuntimeControllerDeps {\n\t/** Root directory OllamaRuntime instances are scoped under — fixed for the session's lifetime. */\n\tagentDir: string;\n\t/** Test-injectable seams for OllamaRuntime's own fetch/spawn/exists calls; unset in production. */\n\tlocalRuntimeDeps?: LocalRuntimeDeps;\n\t/** The session's last assistant message, to detect a just-failed local call and drop a stale\n\t * \"confirmed up\" flag. */\n\tgetLastAssistantMessage(): AssistantMessage | undefined;\n\t/** The session's live interactive UI context, if any — undefined in headless/RPC/print modes. */\n\tgetUIContext(): ExtensionUIContext | undefined;\n\t/** Emits a session event (only ever `warning` / `routing_start` / `routing_end` from this controller). */\n\temit(event: AgentSessionEvent): void;\n\t/** Resolves the model configured for a router tier, respecting configured auth — owned by the\n\t * router itself, not this controller. */\n\tresolveConfiguredTierModel(tier: \"medium\" | \"expensive\"): Model<Api> | undefined;\n\t/** `${provider}/${id}` label for a model, for warning/confirm text. */\n\tformatModel(model: Model<Api>): string;\n}\n\nexport class LocalRuntimeController {\n\t/** Lazy, cached by baseUrl so the router path and any other caller share one instance per server. */\n\tprivate readonly _runtimes = new Map<string, OllamaRuntime>();\n\t/** Lazy, cached by model+baseUrl so the router and `/models` share one sidecar handle per HF model. */\n\tprivate readonly _transformersRuntimes = new Map<string, TransformersRuntime>();\n\t/** Server URLs confirmed reachable THIS session — skips the health-check round trip on every\n\t * local-routed turn once warm. Keyed the same way as _runtimes. */\n\tprivate readonly _confirmedUp = new Set<string>();\n\n\tprivate readonly deps: LocalRuntimeControllerDeps;\n\n\tconstructor(deps: LocalRuntimeControllerDeps) {\n\t\tthis.deps = deps;\n\t}\n\n\t/**\n\t * Shared {@link OllamaRuntime} for a given server, lazily created and cached by baseUrl so every\n\t * caller — the router's readiness gate below and any host UI's own model-lifecycle commands\n\t * (e.g. `/models`) — sees and can stop the SAME pi-managed process instead of each tracking its\n\t * own untracked child.\n\t */\n\tgetLocalRuntime(baseUrl?: string): OllamaRuntime {\n\t\tconst key = baseUrl ?? \"default\";\n\t\tlet runtime = this._runtimes.get(key);\n\t\tif (!runtime) {\n\t\t\truntime = new OllamaRuntime({ agentDir: this.deps.agentDir, baseUrl, deps: this.deps.localRuntimeDeps });\n\t\t\tthis._runtimes.set(key, runtime);\n\t\t}\n\t\treturn runtime;\n\t}\n\n\tgetTransformersRuntime(modelId: string, baseUrl?: string): TransformersRuntime {\n\t\tconst resolvedBaseUrl = baseUrl?.replace(/\\/$/, \"\") ?? resolveTransformersBaseUrl(modelId);\n\t\tconst key = `${modelId}\\0${resolvedBaseUrl}`;\n\t\tlet runtime = this._transformersRuntimes.get(key);\n\t\tif (!runtime) {\n\t\t\truntime = new TransformersRuntime({\n\t\t\t\tagentDir: this.deps.agentDir,\n\t\t\t\tmodelId,\n\t\t\t\tbaseUrl: resolvedBaseUrl,\n\t\t\t\tdeps: this.deps.localRuntimeDeps,\n\t\t\t});\n\t\t\tthis._transformersRuntimes.set(key, runtime);\n\t\t}\n\t\treturn runtime;\n\t}\n\n\t/** models.json registers a local model's baseUrl as `<server>/v1` (OpenAI-compat); the runtime's\n\t * own health/boot endpoints are on the Ollama-native server root. */\n\tderiveOllamaServerUrl(modelBaseUrl: string): string {\n\t\treturn modelBaseUrl.replace(/\\/v1\\/?$/, \"\");\n\t}\n\n\tprivate deriveOpenAICompatServerUrl(modelBaseUrl: string): string {\n\t\treturn modelBaseUrl.replace(/\\/v1\\/?$/, \"\");\n\t}\n\n\tprivate isManagedLocalProvider(provider: string): boolean {\n\t\treturn provider === OLLAMA_PROVIDER || provider === HF_TRANSFORMERS_PROVIDER;\n\t}\n\n\t/**\n\t * If the last assistant message in this session was an error from THIS exact local server, a\n\t * cached \"confirmed up\" flag would be stale (the server may have died mid-session) — drop it so\n\t * the next ensure-check is a real one instead of trusting stale state.\n\t */\n\tprivate confirmationKey(model: Model<Api>, serverUrl: string): string {\n\t\treturn model.provider === HF_TRANSFORMERS_PROVIDER ? `${serverUrl}\\0${model.id}` : serverUrl;\n\t}\n\n\tprivate invalidateIfLastCallFailed(model: Model<Api>, serverUrl: string): void {\n\t\tconst lastAssistant = this.deps.getLastAssistantMessage();\n\t\tif (\n\t\t\tlastAssistant?.stopReason === \"error\" &&\n\t\t\tlastAssistant.provider === model.provider &&\n\t\t\tlastAssistant.model === model.id\n\t\t) {\n\t\t\tthis._confirmedUp.delete(this.confirmationKey(model, serverUrl));\n\t\t}\n\t}\n\n\tprivate async ensureOllamaResident(\n\t\tmodel: Model<Api>,\n\t\truntime: OllamaRuntime,\n\t): Promise<{ ok: boolean; reason?: string }> {\n\t\tlet bytes = 0;\n\t\ttry {\n\t\t\tconst installed = await runtime.list();\n\t\t\tbytes = installed.find((entry) => matchesInstalledLocalModel(model.id, entry.name))?.sizeBytes ?? 0;\n\t\t} catch {\n\t\t\t// Residency is best-effort; a list failure falls back to a zero-byte admission request.\n\t\t}\n\t\tconst arbiter = new RuntimeResidencyArbiter({\n\t\t\tbudgetBytes: Math.floor(totalmem() * 0.85),\n\t\t\tadapters: [new OllamaRuntimeResidencyAdapter(\"ollama\", runtime)],\n\t\t});\n\t\ttry {\n\t\t\tconst plan = await arbiter.ensureResident(\"ollama\", {\n\t\t\t\tmodel: model.id,\n\t\t\t\tbytes,\n\t\t\t\trole: \"active\",\n\t\t\t\tpriority: 100,\n\t\t\t\tnowMs: Date.now(),\n\t\t\t\tpinActiveModel: model.id,\n\t\t\t});\n\t\t\treturn plan.status === \"fits\" ? { ok: true } : { ok: false, reason: `residency_refused:${plan.reason}` };\n\t\t} catch (error) {\n\t\t\treturn { ok: false, reason: `residency_error:${error instanceof Error ? error.message : String(error)}` };\n\t\t}\n\t}\n\n\tprivate async ensureTransformersResident(\n\t\tmodel: Model<Api>,\n\t\truntime: TransformersRuntime,\n\t): Promise<{ ok: boolean; reason?: string }> {\n\t\tconst arbiter = new RuntimeResidencyArbiter({\n\t\t\tbudgetBytes: Math.floor(totalmem() * 0.85),\n\t\t\tadapters: [new TransformersRuntimeResidencyAdapter(`transformers:${model.id}`, runtime, model.id, 0)],\n\t\t});\n\t\ttry {\n\t\t\tconst plan = await arbiter.ensureResident(`transformers:${model.id}`, {\n\t\t\t\tmodel: model.id,\n\t\t\t\tbytes: 0,\n\t\t\t\trole: \"active\",\n\t\t\t\tpriority: 100,\n\t\t\t\tnowMs: Date.now(),\n\t\t\t\tpinActiveModel: model.id,\n\t\t\t});\n\t\t\treturn plan.status === \"fits\" ? { ok: true } : { ok: false, reason: `residency_refused:${plan.reason}` };\n\t\t} catch (error) {\n\t\t\treturn { ok: false, reason: `residency_error:${error instanceof Error ? error.message : String(error)}` };\n\t\t}\n\t}\n\n\t/**\n\t * Ensure a routed managed-local model is actually reachable before the turn calls it. No-op (and\n\t * free) for non-local/API models. Caches a \"confirmed up this session\" flag per server (and per\n\t * Transformers model) so steady-state routing pays the health-check round trip once; invalidated\n\t * above when a prior local call failed so a dead sidecar gets re-detected instead of trusted.\n\t */\n\tasync ensureLocalModelReady(\n\t\tmodel: Model<Api>,\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[] }> {\n\t\tif (model.provider !== OLLAMA_PROVIDER) {\n\t\t\treturn { ready: true, reason: \"not_local\" };\n\t\t}\n\t\tconst serverUrl = this.deriveOllamaServerUrl(model.baseUrl);\n\t\tconst confirmedKey = this.confirmationKey(model, serverUrl);\n\t\tthis.invalidateIfLastCallFailed(model, serverUrl);\n\t\tif (this._confirmedUp.has(confirmedKey)) {\n\t\t\treturn { ready: true, reason: \"confirmed_up_cached\" };\n\t\t}\n\t\tconst runtime = this.getLocalRuntime(serverUrl);\n\t\tconst status = await runtime.detect();\n\t\tif (status.serverUp) {\n\t\t\tif (status.activeStore?.kind === \"pi-owned\") {\n\t\t\t\tconst resident = await this.ensureOllamaResident(model, runtime);\n\t\t\t\tif (!resident.ok) return { ready: false, reason: resident.reason ?? \"residency_refused\" };\n\t\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t\t\treturn { ready: true, reason: \"already_running\" };\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tready: false,\n\t\t\t\treason: `wrong_store:${status.activeStore?.path ?? \"external/unknown\"}:${status.activeStore?.modelCount ?? 0}:${status.ownedModelsDir}`,\n\t\t\t};\n\t\t}\n\t\tif (!status.binaryPath) {\n\t\t\treturn { ready: false, reason: \"binary_missing\", installGuide: runtime.installGuide() };\n\t\t}\n\t\tconst started = await runtime.start();\n\t\tif (started.started) {\n\t\t\tconst resident = await this.ensureOllamaResident(model, runtime);\n\t\t\tif (!resident.ok) return { ready: false, reason: resident.reason ?? \"residency_refused\" };\n\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t}\n\t\treturn { ready: started.started, reason: started.reason };\n\t}\n\n\tasync ensureTransformersModelReady(\n\t\tmodel: Model<Api>,\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[] }> {\n\t\tif (model.provider !== HF_TRANSFORMERS_PROVIDER) {\n\t\t\treturn { ready: true, reason: \"not_transformers\" };\n\t\t}\n\t\tconst serverUrl = this.deriveOpenAICompatServerUrl(model.baseUrl);\n\t\tconst confirmedKey = this.confirmationKey(model, serverUrl);\n\t\tthis.invalidateIfLastCallFailed(model, serverUrl);\n\t\tif (this._confirmedUp.has(confirmedKey)) {\n\t\t\treturn { ready: true, reason: \"confirmed_up_cached\" };\n\t\t}\n\t\tconst runtime = this.getTransformersRuntime(model.id, serverUrl);\n\t\tconst status = await runtime.detect();\n\t\tif (status.serverUp) {\n\t\t\tconst resident = await this.ensureTransformersResident(model, runtime);\n\t\t\tif (!resident.ok) return { ready: false, reason: resident.reason ?? \"residency_refused\" };\n\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t\treturn { ready: true, reason: \"already_running\" };\n\t\t}\n\t\tif (!status.runtimeInstalled) {\n\t\t\treturn { ready: false, reason: \"runtime_missing\", installGuide: runtime.installGuide() };\n\t\t}\n\t\tconst started = await runtime.start();\n\t\tif (started.started || started.reason === \"already_running\") {\n\t\t\tconst resident = await this.ensureTransformersResident(model, runtime);\n\t\t\tif (!resident.ok) return { ready: false, reason: resident.reason ?? \"residency_refused\" };\n\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t\treturn { ready: true, reason: started.reason };\n\t\t}\n\t\treturn { ready: false, reason: started.reason };\n\t}\n\n\t/**\n\t * #31: the ONE case a routed local model's unreadiness can be fixed automatically is a missing\n\t * ollama binary — an unreachable server can't be helped by installing, so that reason is left to\n\t * the graceful-fallback warning below unchanged. Only offered when there's an interactive UI to\n\t * ask through: headless/RPC/print sessions have no UI context and fall straight through, same as\n\t * declining or timing out (both resolve confirm() to false). Reverses \"pi never runs installers\n\t * itself\" specifically for this one path — the user is asked first, the download is pi's own\n\t * (never curl|sh), and it lands in pi's own runtimes dir (see OllamaRuntime.installManaged).\n\t *\n\t * Pauses/resumes the routing working-indicator around the confirm dialog itself (re-emitting\n\t * routing_end/routing_start — both already idempotent, see interactive-mode.ts's handlers) so an\n\t * animated spinner doesn't fight a dialog the user is trying to read and answer; the indicator\n\t * comes back for the download/extract that follows a \"yes\", which is genuine processing feedback.\n\t */\n\tprivate async maybeInstallOllamaOnConsent(\n\t\tmodel: Model<Api>,\n\t\treadiness: { ready: boolean; reason: string; installGuide?: string[] },\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[]; installAttemptError?: string }> {\n\t\tconst ui = this.deps.getUIContext();\n\t\tif (!ui || readiness.ready || readiness.reason !== \"binary_missing\") return readiness;\n\n\t\tconst modelLabel = this.deps.formatModel(model);\n\t\tthis.deps.emit({ type: \"routing_end\" });\n\t\tlet confirmed: boolean;\n\t\ttry {\n\t\t\tconfirmed = await ui.confirm(\n\t\t\t\t\"Install Ollama?\",\n\t\t\t\t`Ollama isn't installed, so the local model \"${modelLabel}\" can't run. Pi can download and ` +\n\t\t\t\t\t\"install it now (a large one-time download, possibly over 1 GB depending on your platform) \" +\n\t\t\t\t\t\"into its own runtimes folder — never curl|sh, never touching anything outside pi's own \" +\n\t\t\t\t\t\"directory. Install it now?\",\n\t\t\t\t{ timeout: OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS },\n\t\t\t);\n\t\t} finally {\n\t\t\tthis.deps.emit({ type: \"routing_start\" });\n\t\t}\n\t\tif (!confirmed) return readiness;\n\n\t\tconst serverUrl = this.deriveOllamaServerUrl(model.baseUrl);\n\t\tconst runtime = this.getLocalRuntime(serverUrl);\n\t\tlet installResult: { ok: boolean; error?: string };\n\t\ttry {\n\t\t\tinstallResult = await runtime.installManaged((status) => ui.setStatus(\"ollama-install\", status));\n\t\t} finally {\n\t\t\tui.setStatus(\"ollama-install\", undefined);\n\t\t}\n\t\tif (!installResult.ok) {\n\t\t\treturn { ready: false, reason: \"install_failed\", installAttemptError: installResult.error };\n\t\t}\n\t\treturn this.ensureLocalModelReady(model);\n\t}\n\n\tprivate async maybeInstallTransformersOnConsent(\n\t\tmodel: Model<Api>,\n\t\treadiness: { ready: boolean; reason: string; installGuide?: string[] },\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[]; installAttemptError?: string }> {\n\t\tconst ui = this.deps.getUIContext();\n\t\tif (!ui || readiness.ready || readiness.reason !== \"runtime_missing\") return readiness;\n\n\t\tconst modelLabel = this.deps.formatModel(model);\n\t\tthis.deps.emit({ type: \"routing_end\" });\n\t\tlet confirmed: boolean;\n\t\ttry {\n\t\t\tconfirmed = await ui.confirm(\n\t\t\t\t\"Install Transformers runtime?\",\n\t\t\t\t`The Hugging Face model \"${modelLabel}\" needs a pi-managed Python venv with Transformers ` +\n\t\t\t\t\t\"and CPU PyTorch before it can run. Pi will install those packages into its own runtimes \" +\n\t\t\t\t\t\"folder, download the model into a pi-owned Hugging Face cache, and leave system Python, \" +\n\t\t\t\t\t\"your Ollama models, and your user HF cache untouched. Install it now?\",\n\t\t\t\t{ timeout: OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS },\n\t\t\t);\n\t\t} finally {\n\t\t\tthis.deps.emit({ type: \"routing_start\" });\n\t\t}\n\t\tif (!confirmed) return readiness;\n\n\t\tconst serverUrl = this.deriveOpenAICompatServerUrl(model.baseUrl);\n\t\tconst runtime = this.getTransformersRuntime(model.id, serverUrl);\n\t\tlet installResult: { ok: boolean; error?: string };\n\t\ttry {\n\t\t\tinstallResult = await runtime.installManaged((status) => ui.setStatus(\"transformers-install\", status));\n\t\t} finally {\n\t\t\tui.setStatus(\"transformers-install\", undefined);\n\t\t}\n\t\tif (!installResult.ok) {\n\t\t\treturn { ready: false, reason: \"install_failed\", installAttemptError: installResult.error };\n\t\t}\n\t\tlet downloadResult: { ok: boolean; error?: string };\n\t\ttry {\n\t\t\tdownloadResult = await runtime.downloadModel((status) => ui.setStatus(\"transformers-download\", status));\n\t\t} finally {\n\t\t\tui.setStatus(\"transformers-download\", undefined);\n\t\t}\n\t\tif (!downloadResult.ok) {\n\t\t\treturn { ready: false, reason: \"download_failed\", installAttemptError: downloadResult.error };\n\t\t}\n\t\treturn this.ensureTransformersModelReady(model);\n\t}\n\n\t/**\n\t * Router-swap gate (#27): a turn routed to a local model (any tier, including an executor-direct\n\t * route — both carry tier \"cheap\") must not dead-end the turn just because ollama isn't up.\n\t * Never a SILENT swap: every fallback is announced in a warning that states (i) the local model\n\t * was unavailable and WHY — binary missing surfaces the install guide inline; any other reason\n\t * gets a \"check that ollama is running\" hint — and (ii) which tier is now handling the turn, so\n\t * the cost shift is never a surprise. Escalates cheap -> medium -> expensive, skipping any\n\t * unconfigured intermediate tier, reusing the router's own existing \"model unavailable\"\n\t * resolution (resolveConfiguredTierModel) rather than inventing a new fallback mechanism.\n\t * Escalation is bounded: tier strictly increases each hop, so it terminates within two hops.\n\t *\n\t * Before the warning/escalation below: #31's consent gate gets one shot at fixing a missing\n\t * binary interactively (see maybeInstallOllamaOnConsent) — declining, timing out, running\n\t * headless, or the install attempt itself failing all fall through here unchanged, just with an\n\t * honest reason (an install that failed is worded as a failed install, not re-labeled as if\n\t * nothing was ever tried).\n\t */\n\tasync ensureRouteModelReady(\n\t\tresolved: { decision: RouteDecision; model: Model<Api> } | undefined,\n\t): Promise<{ decision: RouteDecision; model: Model<Api> } | undefined> {\n\t\tlet current = resolved;\n\t\twhile (current && this.isManagedLocalProvider(current.model.provider)) {\n\t\t\tlet readiness: { ready: boolean; reason: string; installGuide?: string[]; installAttemptError?: string } =\n\t\t\t\tcurrent.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t? await this.ensureLocalModelReady(current.model)\n\t\t\t\t\t: await this.ensureTransformersModelReady(current.model);\n\t\t\tif (!readiness.ready) {\n\t\t\t\treadiness =\n\t\t\t\t\tcurrent.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t\t? await this.maybeInstallOllamaOnConsent(current.model, readiness)\n\t\t\t\t\t\t: await this.maybeInstallTransformersOnConsent(current.model, readiness);\n\t\t\t}\n\t\t\tif (readiness.ready) return current;\n\n\t\t\t// Walk the remaining tiers in order (never back down to cheap) and take the first one that\n\t\t\t// actually resolves — an unconfigured intermediate tier (e.g. no mediumModel set) must be\n\t\t\t// skipped, not treated as \"no fallback available\".\n\t\t\tconst startIndex = MODEL_ROUTER_TIER_ORDER.indexOf(current.decision.tier as \"cheap\" | \"medium\" | \"expensive\");\n\t\t\tlet escalated: { tier: \"medium\" | \"expensive\"; model: Model<Api> } | undefined;\n\t\t\tfor (let i = startIndex + 1; startIndex !== -1 && i < MODEL_ROUTER_TIER_ORDER.length; i++) {\n\t\t\t\tconst tier = MODEL_ROUTER_TIER_ORDER[i] as \"medium\" | \"expensive\";\n\t\t\t\tconst model = this.deps.resolveConfiguredTierModel(tier);\n\t\t\t\tif (model) {\n\t\t\t\t\tescalated = { tier, model };\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst modelLabel = this.deps.formatModel(current.model);\n\t\t\tconst localRuntimeName = current.model.provider === OLLAMA_PROVIDER ? \"ollama\" : \"Transformers\";\n\t\t\tconst wrongStore = parseWrongOllamaStoreReason(readiness.reason);\n\t\t\tconst whyText = readiness.installAttemptError\n\t\t\t\t? `pi tried to install it just now, but the install attempt failed: ${readiness.installAttemptError}`\n\t\t\t\t: readiness.installGuide\n\t\t\t\t\t? [\n\t\t\t\t\t\t\tcurrent.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t\t\t\t? \"the ollama binary is not installed.\"\n\t\t\t\t\t\t\t\t: \"the pi-managed Transformers runtime is not installed.\",\n\t\t\t\t\t\t\t...readiness.installGuide,\n\t\t\t\t\t\t].join(\"\\n\")\n\t\t\t\t\t: wrongStore\n\t\t\t\t\t\t? `an Ollama server is already running with store ${wrongStore.activePath} (${wrongStore.modelCount} model(s)); pi's owned store is ${wrongStore.ownedPath}. Stop the other server or run /models import, then retry.`\n\t\t\t\t\t\t: current.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t\t\t? `its ${localRuntimeName} server is not reachable (${readiness.reason}) — check that ollama is running.`\n\t\t\t\t\t\t\t: `its ${localRuntimeName} server is not reachable (${readiness.reason}) — check that the runtime is running.`;\n\t\t\tconst fallbackText = escalated\n\t\t\t\t? `Falling back to the ${escalated.tier} tier for this turn.`\n\t\t\t\t: \"No other tier is configured — falling back to the session's default model.\";\n\t\t\tthis.deps.emit({\n\t\t\t\ttype: \"warning\",\n\t\t\t\tmessage: `Local model \"${modelLabel}\" is unavailable: ${whyText}\\n${fallbackText}`,\n\t\t\t});\n\n\t\t\tif (!escalated) return undefined; // no higher tier resolves — caller falls back to the session default\n\t\t\tcurrent = {\n\t\t\t\tmodel: escalated.model,\n\t\t\t\tdecision: {\n\t\t\t\t\t...current.decision,\n\t\t\t\t\ttier: escalated.tier,\n\t\t\t\t\tfallbackFrom: current.decision.tier,\n\t\t\t\t\treasonCode: \"local_model_not_ready_fallback\",\n\t\t\t\t\treasons: [\n\t\t\t\t\t\t...current.decision.reasons,\n\t\t\t\t\t\t`Local model not ready (${readiness.reason}); escalated to ${escalated.tier}`,\n\t\t\t\t\t],\n\t\t\t\t\tmodel: this.deps.formatModel(escalated.model),\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\t\treturn current;\n\t}\n}\n"]}
@@ -8,14 +8,31 @@
8
8
  * accessor, the session's UI context/event emitter, and the router's own tier resolver) rather than
9
9
  * the whole AgentSession.
10
10
  */
11
+ import { totalmem } from "node:os";
11
12
  import { HF_TRANSFORMERS_PROVIDER, OLLAMA_PROVIDER } from "./models/local-registration.js";
12
13
  import { OllamaRuntime, resolveTransformersBaseUrl, TransformersRuntime, } from "./models/local-runtime.js";
14
+ import { matchesInstalledLocalModel } from "./models/model-ref.js";
15
+ import { OllamaRuntimeResidencyAdapter, RuntimeResidencyArbiter, TransformersRuntimeResidencyAdapter, } from "./models/runtime-arbiter.js";
13
16
  /** User-facing router tiers in ascending order — "learning" is never selected for a user turn, so
14
17
  * it has no place in the escalation ladder (#27's ensureRouteModelReady walks this forward only). */
15
18
  const MODEL_ROUTER_TIER_ORDER = ["cheap", "medium", "expensive"];
16
19
  /** How long the #31 "install ollama now?" confirm waits before auto-dismissing (same as a "No") —
17
20
  * long enough to read and decide, short enough that an unattended session doesn't hang a turn on it. */
18
21
  const OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS = 30_000;
22
+ function parseWrongOllamaStoreReason(reason) {
23
+ if (!reason.startsWith("wrong_store:"))
24
+ return undefined;
25
+ const parts = reason.slice("wrong_store:".length).split(":");
26
+ if (parts.length < 3)
27
+ return undefined;
28
+ const [activePath, countText, ...ownedPathParts] = parts;
29
+ const modelCount = Number(countText);
30
+ return {
31
+ activePath,
32
+ modelCount: Number.isFinite(modelCount) ? modelCount : 0,
33
+ ownedPath: ownedPathParts.join(":"),
34
+ };
35
+ }
19
36
  export class LocalRuntimeController {
20
37
  /** Lazy, cached by baseUrl so the router path and any other caller share one instance per server. */
21
38
  _runtimes = new Map();
@@ -85,6 +102,54 @@ export class LocalRuntimeController {
85
102
  this._confirmedUp.delete(this.confirmationKey(model, serverUrl));
86
103
  }
87
104
  }
105
+ async ensureOllamaResident(model, runtime) {
106
+ let bytes = 0;
107
+ try {
108
+ const installed = await runtime.list();
109
+ bytes = installed.find((entry) => matchesInstalledLocalModel(model.id, entry.name))?.sizeBytes ?? 0;
110
+ }
111
+ catch {
112
+ // Residency is best-effort; a list failure falls back to a zero-byte admission request.
113
+ }
114
+ const arbiter = new RuntimeResidencyArbiter({
115
+ budgetBytes: Math.floor(totalmem() * 0.85),
116
+ adapters: [new OllamaRuntimeResidencyAdapter("ollama", runtime)],
117
+ });
118
+ try {
119
+ const plan = await arbiter.ensureResident("ollama", {
120
+ model: model.id,
121
+ bytes,
122
+ role: "active",
123
+ priority: 100,
124
+ nowMs: Date.now(),
125
+ pinActiveModel: model.id,
126
+ });
127
+ return plan.status === "fits" ? { ok: true } : { ok: false, reason: `residency_refused:${plan.reason}` };
128
+ }
129
+ catch (error) {
130
+ return { ok: false, reason: `residency_error:${error instanceof Error ? error.message : String(error)}` };
131
+ }
132
+ }
133
+ async ensureTransformersResident(model, runtime) {
134
+ const arbiter = new RuntimeResidencyArbiter({
135
+ budgetBytes: Math.floor(totalmem() * 0.85),
136
+ adapters: [new TransformersRuntimeResidencyAdapter(`transformers:${model.id}`, runtime, model.id, 0)],
137
+ });
138
+ try {
139
+ const plan = await arbiter.ensureResident(`transformers:${model.id}`, {
140
+ model: model.id,
141
+ bytes: 0,
142
+ role: "active",
143
+ priority: 100,
144
+ nowMs: Date.now(),
145
+ pinActiveModel: model.id,
146
+ });
147
+ return plan.status === "fits" ? { ok: true } : { ok: false, reason: `residency_refused:${plan.reason}` };
148
+ }
149
+ catch (error) {
150
+ return { ok: false, reason: `residency_error:${error instanceof Error ? error.message : String(error)}` };
151
+ }
152
+ }
88
153
  /**
89
154
  * Ensure a routed managed-local model is actually reachable before the turn calls it. No-op (and
90
155
  * free) for non-local/API models. Caches a "confirmed up this session" flag per server (and per
@@ -104,14 +169,26 @@ export class LocalRuntimeController {
104
169
  const runtime = this.getLocalRuntime(serverUrl);
105
170
  const status = await runtime.detect();
106
171
  if (status.serverUp) {
107
- this._confirmedUp.add(confirmedKey);
108
- return { ready: true, reason: "already_running" };
172
+ if (status.activeStore?.kind === "pi-owned") {
173
+ const resident = await this.ensureOllamaResident(model, runtime);
174
+ if (!resident.ok)
175
+ return { ready: false, reason: resident.reason ?? "residency_refused" };
176
+ this._confirmedUp.add(confirmedKey);
177
+ return { ready: true, reason: "already_running" };
178
+ }
179
+ return {
180
+ ready: false,
181
+ reason: `wrong_store:${status.activeStore?.path ?? "external/unknown"}:${status.activeStore?.modelCount ?? 0}:${status.ownedModelsDir}`,
182
+ };
109
183
  }
110
184
  if (!status.binaryPath) {
111
185
  return { ready: false, reason: "binary_missing", installGuide: runtime.installGuide() };
112
186
  }
113
- const started = await runtime.startReuseExisting();
187
+ const started = await runtime.start();
114
188
  if (started.started) {
189
+ const resident = await this.ensureOllamaResident(model, runtime);
190
+ if (!resident.ok)
191
+ return { ready: false, reason: resident.reason ?? "residency_refused" };
115
192
  this._confirmedUp.add(confirmedKey);
116
193
  }
117
194
  return { ready: started.started, reason: started.reason };
@@ -129,6 +206,9 @@ export class LocalRuntimeController {
129
206
  const runtime = this.getTransformersRuntime(model.id, serverUrl);
130
207
  const status = await runtime.detect();
131
208
  if (status.serverUp) {
209
+ const resident = await this.ensureTransformersResident(model, runtime);
210
+ if (!resident.ok)
211
+ return { ready: false, reason: resident.reason ?? "residency_refused" };
132
212
  this._confirmedUp.add(confirmedKey);
133
213
  return { ready: true, reason: "already_running" };
134
214
  }
@@ -137,6 +217,9 @@ export class LocalRuntimeController {
137
217
  }
138
218
  const started = await runtime.start();
139
219
  if (started.started || started.reason === "already_running") {
220
+ const resident = await this.ensureTransformersResident(model, runtime);
221
+ if (!resident.ok)
222
+ return { ready: false, reason: resident.reason ?? "residency_refused" };
140
223
  this._confirmedUp.add(confirmedKey);
141
224
  return { ready: true, reason: started.reason };
142
225
  }
@@ -276,6 +359,7 @@ export class LocalRuntimeController {
276
359
  }
277
360
  const modelLabel = this.deps.formatModel(current.model);
278
361
  const localRuntimeName = current.model.provider === OLLAMA_PROVIDER ? "ollama" : "Transformers";
362
+ const wrongStore = parseWrongOllamaStoreReason(readiness.reason);
279
363
  const whyText = readiness.installAttemptError
280
364
  ? `pi tried to install it just now, but the install attempt failed: ${readiness.installAttemptError}`
281
365
  : readiness.installGuide
@@ -285,9 +369,11 @@ export class LocalRuntimeController {
285
369
  : "the pi-managed Transformers runtime is not installed.",
286
370
  ...readiness.installGuide,
287
371
  ].join("\n")
288
- : current.model.provider === OLLAMA_PROVIDER
289
- ? `its ${localRuntimeName} server is not reachable (${readiness.reason}) check that ollama is running.`
290
- : `its ${localRuntimeName} server is not reachable (${readiness.reason}) check that the runtime is running.`;
372
+ : wrongStore
373
+ ? `an Ollama server is already running with store ${wrongStore.activePath} (${wrongStore.modelCount} model(s)); pi's owned store is ${wrongStore.ownedPath}. Stop the other server or run /models import, then retry.`
374
+ : current.model.provider === OLLAMA_PROVIDER
375
+ ? `its ${localRuntimeName} server is not reachable (${readiness.reason}) — check that ollama is running.`
376
+ : `its ${localRuntimeName} server is not reachable (${readiness.reason}) — check that the runtime is running.`;
291
377
  const fallbackText = escalated
292
378
  ? `Falling back to the ${escalated.tier} tier for this turn.`
293
379
  : "No other tier is configured — falling back to the session's default model.";
@@ -1 +1 @@
1
- {"version":3,"file":"local-runtime-controller.js","sourceRoot":"","sources":["../../src/core/local-runtime-controller.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Local-runtime (Ollama) lifecycle controller.\n *\n * Extracted verbatim from agent-session.ts (god-file decomposition). Owns the cached, per-server\n * {@link OllamaRuntime} instances, the \"confirmed up this session\" flag, and the router's readiness\n * gate for a turn routed to a local (`ollama`) model — including the #31 install-on-consent flow and\n * the #27 graceful tier-escalation fallback. Takes narrow deps (agent dir, a last-assistant-message\n * accessor, the session's UI context/event emitter, and the router's own tier resolver) rather than\n * the whole AgentSession.\n */\n\nimport type { Api, AssistantMessage, Model } from \"@caupulican/pi-ai\";\nimport type { AgentSessionEvent } from \"./agent-session.ts\";\nimport type { RouteDecision } from \"./autonomy/contracts.ts\";\nimport type { ExtensionUIContext } from \"./extensions/index.ts\";\nimport { HF_TRANSFORMERS_PROVIDER, OLLAMA_PROVIDER } from \"./models/local-registration.ts\";\nimport {\n\ttype LocalRuntimeDeps,\n\tOllamaRuntime,\n\tresolveTransformersBaseUrl,\n\tTransformersRuntime,\n} from \"./models/local-runtime.ts\";\n\n/** User-facing router tiers in ascending order — \"learning\" is never selected for a user turn, so\n * it has no place in the escalation ladder (#27's ensureRouteModelReady walks this forward only). */\nconst MODEL_ROUTER_TIER_ORDER: readonly (\"cheap\" | \"medium\" | \"expensive\")[] = [\"cheap\", \"medium\", \"expensive\"];\n\n/** How long the #31 \"install ollama now?\" confirm waits before auto-dismissing (same as a \"No\") —\n * long enough to read and decide, short enough that an unattended session doesn't hang a turn on it. */\nconst OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS = 30_000;\n\nexport interface LocalRuntimeControllerDeps {\n\t/** Root directory OllamaRuntime instances are scoped under — fixed for the session's lifetime. */\n\tagentDir: string;\n\t/** Test-injectable seams for OllamaRuntime's own fetch/spawn/exists calls; unset in production. */\n\tlocalRuntimeDeps?: LocalRuntimeDeps;\n\t/** The session's last assistant message, to detect a just-failed local call and drop a stale\n\t * \"confirmed up\" flag. */\n\tgetLastAssistantMessage(): AssistantMessage | undefined;\n\t/** The session's live interactive UI context, if any — undefined in headless/RPC/print modes. */\n\tgetUIContext(): ExtensionUIContext | undefined;\n\t/** Emits a session event (only ever `warning` / `routing_start` / `routing_end` from this controller). */\n\temit(event: AgentSessionEvent): void;\n\t/** Resolves the model configured for a router tier, respecting configured auth — owned by the\n\t * router itself, not this controller. */\n\tresolveConfiguredTierModel(tier: \"medium\" | \"expensive\"): Model<Api> | undefined;\n\t/** `${provider}/${id}` label for a model, for warning/confirm text. */\n\tformatModel(model: Model<Api>): string;\n}\n\nexport class LocalRuntimeController {\n\t/** Lazy, cached by baseUrl so the router path and any other caller share one instance per server. */\n\tprivate readonly _runtimes = new Map<string, OllamaRuntime>();\n\t/** Lazy, cached by model+baseUrl so the router and `/models` share one sidecar handle per HF model. */\n\tprivate readonly _transformersRuntimes = new Map<string, TransformersRuntime>();\n\t/** Server URLs confirmed reachable THIS session — skips the health-check round trip on every\n\t * local-routed turn once warm. Keyed the same way as _runtimes. */\n\tprivate readonly _confirmedUp = new Set<string>();\n\n\tprivate readonly deps: LocalRuntimeControllerDeps;\n\n\tconstructor(deps: LocalRuntimeControllerDeps) {\n\t\tthis.deps = deps;\n\t}\n\n\t/**\n\t * Shared {@link OllamaRuntime} for a given server, lazily created and cached by baseUrl so every\n\t * caller — the router's readiness gate below and any host UI's own model-lifecycle commands\n\t * (e.g. `/models`) — sees and can stop the SAME pi-managed process instead of each tracking its\n\t * own untracked child.\n\t */\n\tgetLocalRuntime(baseUrl?: string): OllamaRuntime {\n\t\tconst key = baseUrl ?? \"default\";\n\t\tlet runtime = this._runtimes.get(key);\n\t\tif (!runtime) {\n\t\t\truntime = new OllamaRuntime({ agentDir: this.deps.agentDir, baseUrl, deps: this.deps.localRuntimeDeps });\n\t\t\tthis._runtimes.set(key, runtime);\n\t\t}\n\t\treturn runtime;\n\t}\n\n\tgetTransformersRuntime(modelId: string, baseUrl?: string): TransformersRuntime {\n\t\tconst resolvedBaseUrl = baseUrl?.replace(/\\/$/, \"\") ?? resolveTransformersBaseUrl(modelId);\n\t\tconst key = `${modelId}\\0${resolvedBaseUrl}`;\n\t\tlet runtime = this._transformersRuntimes.get(key);\n\t\tif (!runtime) {\n\t\t\truntime = new TransformersRuntime({\n\t\t\t\tagentDir: this.deps.agentDir,\n\t\t\t\tmodelId,\n\t\t\t\tbaseUrl: resolvedBaseUrl,\n\t\t\t\tdeps: this.deps.localRuntimeDeps,\n\t\t\t});\n\t\t\tthis._transformersRuntimes.set(key, runtime);\n\t\t}\n\t\treturn runtime;\n\t}\n\n\t/** models.json registers a local model's baseUrl as `<server>/v1` (OpenAI-compat); the runtime's\n\t * own health/boot endpoints are on the Ollama-native server root. */\n\tderiveOllamaServerUrl(modelBaseUrl: string): string {\n\t\treturn modelBaseUrl.replace(/\\/v1\\/?$/, \"\");\n\t}\n\n\tprivate deriveOpenAICompatServerUrl(modelBaseUrl: string): string {\n\t\treturn modelBaseUrl.replace(/\\/v1\\/?$/, \"\");\n\t}\n\n\tprivate isManagedLocalProvider(provider: string): boolean {\n\t\treturn provider === OLLAMA_PROVIDER || provider === HF_TRANSFORMERS_PROVIDER;\n\t}\n\n\t/**\n\t * If the last assistant message in this session was an error from THIS exact local server, a\n\t * cached \"confirmed up\" flag would be stale (the server may have died mid-session) — drop it so\n\t * the next ensure-check is a real one instead of trusting stale state.\n\t */\n\tprivate confirmationKey(model: Model<Api>, serverUrl: string): string {\n\t\treturn model.provider === HF_TRANSFORMERS_PROVIDER ? `${serverUrl}\\0${model.id}` : serverUrl;\n\t}\n\n\tprivate invalidateIfLastCallFailed(model: Model<Api>, serverUrl: string): void {\n\t\tconst lastAssistant = this.deps.getLastAssistantMessage();\n\t\tif (\n\t\t\tlastAssistant?.stopReason === \"error\" &&\n\t\t\tlastAssistant.provider === model.provider &&\n\t\t\tlastAssistant.model === model.id\n\t\t) {\n\t\t\tthis._confirmedUp.delete(this.confirmationKey(model, serverUrl));\n\t\t}\n\t}\n\n\t/**\n\t * Ensure a routed managed-local model is actually reachable before the turn calls it. No-op (and\n\t * free) for non-local/API models. Caches a \"confirmed up this session\" flag per server (and per\n\t * Transformers model) so steady-state routing pays the health-check round trip once; invalidated\n\t * above when a prior local call failed so a dead sidecar gets re-detected instead of trusted.\n\t */\n\tasync ensureLocalModelReady(\n\t\tmodel: Model<Api>,\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[] }> {\n\t\tif (model.provider !== OLLAMA_PROVIDER) {\n\t\t\treturn { ready: true, reason: \"not_local\" };\n\t\t}\n\t\tconst serverUrl = this.deriveOllamaServerUrl(model.baseUrl);\n\t\tconst confirmedKey = this.confirmationKey(model, serverUrl);\n\t\tthis.invalidateIfLastCallFailed(model, serverUrl);\n\t\tif (this._confirmedUp.has(confirmedKey)) {\n\t\t\treturn { ready: true, reason: \"confirmed_up_cached\" };\n\t\t}\n\t\tconst runtime = this.getLocalRuntime(serverUrl);\n\t\tconst status = await runtime.detect();\n\t\tif (status.serverUp) {\n\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t\treturn { ready: true, reason: \"already_running\" };\n\t\t}\n\t\tif (!status.binaryPath) {\n\t\t\treturn { ready: false, reason: \"binary_missing\", installGuide: runtime.installGuide() };\n\t\t}\n\t\tconst started = await runtime.startReuseExisting();\n\t\tif (started.started) {\n\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t}\n\t\treturn { ready: started.started, reason: started.reason };\n\t}\n\n\tasync ensureTransformersModelReady(\n\t\tmodel: Model<Api>,\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[] }> {\n\t\tif (model.provider !== HF_TRANSFORMERS_PROVIDER) {\n\t\t\treturn { ready: true, reason: \"not_transformers\" };\n\t\t}\n\t\tconst serverUrl = this.deriveOpenAICompatServerUrl(model.baseUrl);\n\t\tconst confirmedKey = this.confirmationKey(model, serverUrl);\n\t\tthis.invalidateIfLastCallFailed(model, serverUrl);\n\t\tif (this._confirmedUp.has(confirmedKey)) {\n\t\t\treturn { ready: true, reason: \"confirmed_up_cached\" };\n\t\t}\n\t\tconst runtime = this.getTransformersRuntime(model.id, serverUrl);\n\t\tconst status = await runtime.detect();\n\t\tif (status.serverUp) {\n\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t\treturn { ready: true, reason: \"already_running\" };\n\t\t}\n\t\tif (!status.runtimeInstalled) {\n\t\t\treturn { ready: false, reason: \"runtime_missing\", installGuide: runtime.installGuide() };\n\t\t}\n\t\tconst started = await runtime.start();\n\t\tif (started.started || started.reason === \"already_running\") {\n\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t\treturn { ready: true, reason: started.reason };\n\t\t}\n\t\treturn { ready: false, reason: started.reason };\n\t}\n\n\t/**\n\t * #31: the ONE case a routed local model's unreadiness can be fixed automatically is a missing\n\t * ollama binary — an unreachable server can't be helped by installing, so that reason is left to\n\t * the graceful-fallback warning below unchanged. Only offered when there's an interactive UI to\n\t * ask through: headless/RPC/print sessions have no UI context and fall straight through, same as\n\t * declining or timing out (both resolve confirm() to false). Reverses \"pi never runs installers\n\t * itself\" specifically for this one path — the user is asked first, the download is pi's own\n\t * (never curl|sh), and it lands in pi's own runtimes dir (see OllamaRuntime.installManaged).\n\t *\n\t * Pauses/resumes the routing working-indicator around the confirm dialog itself (re-emitting\n\t * routing_end/routing_start — both already idempotent, see interactive-mode.ts's handlers) so an\n\t * animated spinner doesn't fight a dialog the user is trying to read and answer; the indicator\n\t * comes back for the download/extract that follows a \"yes\", which is genuine processing feedback.\n\t */\n\tprivate async maybeInstallOllamaOnConsent(\n\t\tmodel: Model<Api>,\n\t\treadiness: { ready: boolean; reason: string; installGuide?: string[] },\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[]; installAttemptError?: string }> {\n\t\tconst ui = this.deps.getUIContext();\n\t\tif (!ui || readiness.ready || readiness.reason !== \"binary_missing\") return readiness;\n\n\t\tconst modelLabel = this.deps.formatModel(model);\n\t\tthis.deps.emit({ type: \"routing_end\" });\n\t\tlet confirmed: boolean;\n\t\ttry {\n\t\t\tconfirmed = await ui.confirm(\n\t\t\t\t\"Install Ollama?\",\n\t\t\t\t`Ollama isn't installed, so the local model \"${modelLabel}\" can't run. Pi can download and ` +\n\t\t\t\t\t\"install it now (a large one-time download, possibly over 1 GB depending on your platform) \" +\n\t\t\t\t\t\"into its own runtimes folder — never curl|sh, never touching anything outside pi's own \" +\n\t\t\t\t\t\"directory. Install it now?\",\n\t\t\t\t{ timeout: OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS },\n\t\t\t);\n\t\t} finally {\n\t\t\tthis.deps.emit({ type: \"routing_start\" });\n\t\t}\n\t\tif (!confirmed) return readiness;\n\n\t\tconst serverUrl = this.deriveOllamaServerUrl(model.baseUrl);\n\t\tconst runtime = this.getLocalRuntime(serverUrl);\n\t\tlet installResult: { ok: boolean; error?: string };\n\t\ttry {\n\t\t\tinstallResult = await runtime.installManaged((status) => ui.setStatus(\"ollama-install\", status));\n\t\t} finally {\n\t\t\tui.setStatus(\"ollama-install\", undefined);\n\t\t}\n\t\tif (!installResult.ok) {\n\t\t\treturn { ready: false, reason: \"install_failed\", installAttemptError: installResult.error };\n\t\t}\n\t\treturn this.ensureLocalModelReady(model);\n\t}\n\n\tprivate async maybeInstallTransformersOnConsent(\n\t\tmodel: Model<Api>,\n\t\treadiness: { ready: boolean; reason: string; installGuide?: string[] },\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[]; installAttemptError?: string }> {\n\t\tconst ui = this.deps.getUIContext();\n\t\tif (!ui || readiness.ready || readiness.reason !== \"runtime_missing\") return readiness;\n\n\t\tconst modelLabel = this.deps.formatModel(model);\n\t\tthis.deps.emit({ type: \"routing_end\" });\n\t\tlet confirmed: boolean;\n\t\ttry {\n\t\t\tconfirmed = await ui.confirm(\n\t\t\t\t\"Install Transformers runtime?\",\n\t\t\t\t`The Hugging Face model \"${modelLabel}\" needs a pi-managed Python venv with Transformers ` +\n\t\t\t\t\t\"and CPU PyTorch before it can run. Pi will install those packages into its own runtimes \" +\n\t\t\t\t\t\"folder, download the model into a pi-owned Hugging Face cache, and leave system Python, \" +\n\t\t\t\t\t\"your Ollama models, and your user HF cache untouched. Install it now?\",\n\t\t\t\t{ timeout: OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS },\n\t\t\t);\n\t\t} finally {\n\t\t\tthis.deps.emit({ type: \"routing_start\" });\n\t\t}\n\t\tif (!confirmed) return readiness;\n\n\t\tconst serverUrl = this.deriveOpenAICompatServerUrl(model.baseUrl);\n\t\tconst runtime = this.getTransformersRuntime(model.id, serverUrl);\n\t\tlet installResult: { ok: boolean; error?: string };\n\t\ttry {\n\t\t\tinstallResult = await runtime.installManaged((status) => ui.setStatus(\"transformers-install\", status));\n\t\t} finally {\n\t\t\tui.setStatus(\"transformers-install\", undefined);\n\t\t}\n\t\tif (!installResult.ok) {\n\t\t\treturn { ready: false, reason: \"install_failed\", installAttemptError: installResult.error };\n\t\t}\n\t\tlet downloadResult: { ok: boolean; error?: string };\n\t\ttry {\n\t\t\tdownloadResult = await runtime.downloadModel((status) => ui.setStatus(\"transformers-download\", status));\n\t\t} finally {\n\t\t\tui.setStatus(\"transformers-download\", undefined);\n\t\t}\n\t\tif (!downloadResult.ok) {\n\t\t\treturn { ready: false, reason: \"download_failed\", installAttemptError: downloadResult.error };\n\t\t}\n\t\treturn this.ensureTransformersModelReady(model);\n\t}\n\n\t/**\n\t * Router-swap gate (#27): a turn routed to a local model (any tier, including an executor-direct\n\t * route — both carry tier \"cheap\") must not dead-end the turn just because ollama isn't up.\n\t * Never a SILENT swap: every fallback is announced in a warning that states (i) the local model\n\t * was unavailable and WHY — binary missing surfaces the install guide inline; any other reason\n\t * gets a \"check that ollama is running\" hint — and (ii) which tier is now handling the turn, so\n\t * the cost shift is never a surprise. Escalates cheap -> medium -> expensive, skipping any\n\t * unconfigured intermediate tier, reusing the router's own existing \"model unavailable\"\n\t * resolution (resolveConfiguredTierModel) rather than inventing a new fallback mechanism.\n\t * Escalation is bounded: tier strictly increases each hop, so it terminates within two hops.\n\t *\n\t * Before the warning/escalation below: #31's consent gate gets one shot at fixing a missing\n\t * binary interactively (see maybeInstallOllamaOnConsent) — declining, timing out, running\n\t * headless, or the install attempt itself failing all fall through here unchanged, just with an\n\t * honest reason (an install that failed is worded as a failed install, not re-labeled as if\n\t * nothing was ever tried).\n\t */\n\tasync ensureRouteModelReady(\n\t\tresolved: { decision: RouteDecision; model: Model<Api> } | undefined,\n\t): Promise<{ decision: RouteDecision; model: Model<Api> } | undefined> {\n\t\tlet current = resolved;\n\t\twhile (current && this.isManagedLocalProvider(current.model.provider)) {\n\t\t\tlet readiness: { ready: boolean; reason: string; installGuide?: string[]; installAttemptError?: string } =\n\t\t\t\tcurrent.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t? await this.ensureLocalModelReady(current.model)\n\t\t\t\t\t: await this.ensureTransformersModelReady(current.model);\n\t\t\tif (!readiness.ready) {\n\t\t\t\treadiness =\n\t\t\t\t\tcurrent.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t\t? await this.maybeInstallOllamaOnConsent(current.model, readiness)\n\t\t\t\t\t\t: await this.maybeInstallTransformersOnConsent(current.model, readiness);\n\t\t\t}\n\t\t\tif (readiness.ready) return current;\n\n\t\t\t// Walk the remaining tiers in order (never back down to cheap) and take the first one that\n\t\t\t// actually resolves — an unconfigured intermediate tier (e.g. no mediumModel set) must be\n\t\t\t// skipped, not treated as \"no fallback available\".\n\t\t\tconst startIndex = MODEL_ROUTER_TIER_ORDER.indexOf(current.decision.tier as \"cheap\" | \"medium\" | \"expensive\");\n\t\t\tlet escalated: { tier: \"medium\" | \"expensive\"; model: Model<Api> } | undefined;\n\t\t\tfor (let i = startIndex + 1; startIndex !== -1 && i < MODEL_ROUTER_TIER_ORDER.length; i++) {\n\t\t\t\tconst tier = MODEL_ROUTER_TIER_ORDER[i] as \"medium\" | \"expensive\";\n\t\t\t\tconst model = this.deps.resolveConfiguredTierModel(tier);\n\t\t\t\tif (model) {\n\t\t\t\t\tescalated = { tier, model };\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst modelLabel = this.deps.formatModel(current.model);\n\t\t\tconst localRuntimeName = current.model.provider === OLLAMA_PROVIDER ? \"ollama\" : \"Transformers\";\n\t\t\tconst whyText = readiness.installAttemptError\n\t\t\t\t? `pi tried to install it just now, but the install attempt failed: ${readiness.installAttemptError}`\n\t\t\t\t: readiness.installGuide\n\t\t\t\t\t? [\n\t\t\t\t\t\t\tcurrent.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t\t\t\t? \"the ollama binary is not installed.\"\n\t\t\t\t\t\t\t\t: \"the pi-managed Transformers runtime is not installed.\",\n\t\t\t\t\t\t\t...readiness.installGuide,\n\t\t\t\t\t\t].join(\"\\n\")\n\t\t\t\t\t: current.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t\t? `its ${localRuntimeName} server is not reachable (${readiness.reason}) — check that ollama is running.`\n\t\t\t\t\t\t: `its ${localRuntimeName} server is not reachable (${readiness.reason}) — check that the runtime is running.`;\n\t\t\tconst fallbackText = escalated\n\t\t\t\t? `Falling back to the ${escalated.tier} tier for this turn.`\n\t\t\t\t: \"No other tier is configured — falling back to the session's default model.\";\n\t\t\tthis.deps.emit({\n\t\t\t\ttype: \"warning\",\n\t\t\t\tmessage: `Local model \"${modelLabel}\" is unavailable: ${whyText}\\n${fallbackText}`,\n\t\t\t});\n\n\t\t\tif (!escalated) return undefined; // no higher tier resolves — caller falls back to the session default\n\t\t\tcurrent = {\n\t\t\t\tmodel: escalated.model,\n\t\t\t\tdecision: {\n\t\t\t\t\t...current.decision,\n\t\t\t\t\ttier: escalated.tier,\n\t\t\t\t\tfallbackFrom: current.decision.tier,\n\t\t\t\t\treasonCode: \"local_model_not_ready_fallback\",\n\t\t\t\t\treasons: [\n\t\t\t\t\t\t...current.decision.reasons,\n\t\t\t\t\t\t`Local model not ready (${readiness.reason}); escalated to ${escalated.tier}`,\n\t\t\t\t\t],\n\t\t\t\t\tmodel: this.deps.formatModel(escalated.model),\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\t\treturn current;\n\t}\n}\n"]}
1
+ {"version":3,"file":"local-runtime-controller.js","sourceRoot":"","sources":["../../src/core/local-runtime-controller.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Local-runtime (Ollama) lifecycle controller.\n *\n * Extracted verbatim from agent-session.ts (god-file decomposition). Owns the cached, per-server\n * {@link OllamaRuntime} instances, the \"confirmed up this session\" flag, and the router's readiness\n * gate for a turn routed to a local (`ollama`) model — including the #31 install-on-consent flow and\n * the #27 graceful tier-escalation fallback. Takes narrow deps (agent dir, a last-assistant-message\n * accessor, the session's UI context/event emitter, and the router's own tier resolver) rather than\n * the whole AgentSession.\n */\n\nimport { totalmem } from \"node:os\";\nimport type { Api, AssistantMessage, Model } from \"@caupulican/pi-ai\";\nimport type { AgentSessionEvent } from \"./agent-session.ts\";\nimport type { RouteDecision } from \"./autonomy/contracts.ts\";\nimport type { ExtensionUIContext } from \"./extensions/index.ts\";\nimport { HF_TRANSFORMERS_PROVIDER, OLLAMA_PROVIDER } from \"./models/local-registration.ts\";\nimport {\n\ttype LocalRuntimeDeps,\n\tOllamaRuntime,\n\tresolveTransformersBaseUrl,\n\tTransformersRuntime,\n} from \"./models/local-runtime.ts\";\nimport { matchesInstalledLocalModel } from \"./models/model-ref.ts\";\nimport {\n\tOllamaRuntimeResidencyAdapter,\n\tRuntimeResidencyArbiter,\n\tTransformersRuntimeResidencyAdapter,\n} from \"./models/runtime-arbiter.ts\";\n\n/** User-facing router tiers in ascending order — \"learning\" is never selected for a user turn, so\n * it has no place in the escalation ladder (#27's ensureRouteModelReady walks this forward only). */\nconst MODEL_ROUTER_TIER_ORDER: readonly (\"cheap\" | \"medium\" | \"expensive\")[] = [\"cheap\", \"medium\", \"expensive\"];\n\n/** How long the #31 \"install ollama now?\" confirm waits before auto-dismissing (same as a \"No\") —\n * long enough to read and decide, short enough that an unattended session doesn't hang a turn on it. */\nconst OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS = 30_000;\n\nfunction parseWrongOllamaStoreReason(\n\treason: string,\n): { activePath: string; modelCount: number; ownedPath: string } | undefined {\n\tif (!reason.startsWith(\"wrong_store:\")) return undefined;\n\tconst parts = reason.slice(\"wrong_store:\".length).split(\":\");\n\tif (parts.length < 3) return undefined;\n\tconst [activePath, countText, ...ownedPathParts] = parts;\n\tconst modelCount = Number(countText);\n\treturn {\n\t\tactivePath,\n\t\tmodelCount: Number.isFinite(modelCount) ? modelCount : 0,\n\t\townedPath: ownedPathParts.join(\":\"),\n\t};\n}\n\nexport interface LocalRuntimeControllerDeps {\n\t/** Root directory OllamaRuntime instances are scoped under — fixed for the session's lifetime. */\n\tagentDir: string;\n\t/** Test-injectable seams for OllamaRuntime's own fetch/spawn/exists calls; unset in production. */\n\tlocalRuntimeDeps?: LocalRuntimeDeps;\n\t/** The session's last assistant message, to detect a just-failed local call and drop a stale\n\t * \"confirmed up\" flag. */\n\tgetLastAssistantMessage(): AssistantMessage | undefined;\n\t/** The session's live interactive UI context, if any — undefined in headless/RPC/print modes. */\n\tgetUIContext(): ExtensionUIContext | undefined;\n\t/** Emits a session event (only ever `warning` / `routing_start` / `routing_end` from this controller). */\n\temit(event: AgentSessionEvent): void;\n\t/** Resolves the model configured for a router tier, respecting configured auth — owned by the\n\t * router itself, not this controller. */\n\tresolveConfiguredTierModel(tier: \"medium\" | \"expensive\"): Model<Api> | undefined;\n\t/** `${provider}/${id}` label for a model, for warning/confirm text. */\n\tformatModel(model: Model<Api>): string;\n}\n\nexport class LocalRuntimeController {\n\t/** Lazy, cached by baseUrl so the router path and any other caller share one instance per server. */\n\tprivate readonly _runtimes = new Map<string, OllamaRuntime>();\n\t/** Lazy, cached by model+baseUrl so the router and `/models` share one sidecar handle per HF model. */\n\tprivate readonly _transformersRuntimes = new Map<string, TransformersRuntime>();\n\t/** Server URLs confirmed reachable THIS session — skips the health-check round trip on every\n\t * local-routed turn once warm. Keyed the same way as _runtimes. */\n\tprivate readonly _confirmedUp = new Set<string>();\n\n\tprivate readonly deps: LocalRuntimeControllerDeps;\n\n\tconstructor(deps: LocalRuntimeControllerDeps) {\n\t\tthis.deps = deps;\n\t}\n\n\t/**\n\t * Shared {@link OllamaRuntime} for a given server, lazily created and cached by baseUrl so every\n\t * caller — the router's readiness gate below and any host UI's own model-lifecycle commands\n\t * (e.g. `/models`) — sees and can stop the SAME pi-managed process instead of each tracking its\n\t * own untracked child.\n\t */\n\tgetLocalRuntime(baseUrl?: string): OllamaRuntime {\n\t\tconst key = baseUrl ?? \"default\";\n\t\tlet runtime = this._runtimes.get(key);\n\t\tif (!runtime) {\n\t\t\truntime = new OllamaRuntime({ agentDir: this.deps.agentDir, baseUrl, deps: this.deps.localRuntimeDeps });\n\t\t\tthis._runtimes.set(key, runtime);\n\t\t}\n\t\treturn runtime;\n\t}\n\n\tgetTransformersRuntime(modelId: string, baseUrl?: string): TransformersRuntime {\n\t\tconst resolvedBaseUrl = baseUrl?.replace(/\\/$/, \"\") ?? resolveTransformersBaseUrl(modelId);\n\t\tconst key = `${modelId}\\0${resolvedBaseUrl}`;\n\t\tlet runtime = this._transformersRuntimes.get(key);\n\t\tif (!runtime) {\n\t\t\truntime = new TransformersRuntime({\n\t\t\t\tagentDir: this.deps.agentDir,\n\t\t\t\tmodelId,\n\t\t\t\tbaseUrl: resolvedBaseUrl,\n\t\t\t\tdeps: this.deps.localRuntimeDeps,\n\t\t\t});\n\t\t\tthis._transformersRuntimes.set(key, runtime);\n\t\t}\n\t\treturn runtime;\n\t}\n\n\t/** models.json registers a local model's baseUrl as `<server>/v1` (OpenAI-compat); the runtime's\n\t * own health/boot endpoints are on the Ollama-native server root. */\n\tderiveOllamaServerUrl(modelBaseUrl: string): string {\n\t\treturn modelBaseUrl.replace(/\\/v1\\/?$/, \"\");\n\t}\n\n\tprivate deriveOpenAICompatServerUrl(modelBaseUrl: string): string {\n\t\treturn modelBaseUrl.replace(/\\/v1\\/?$/, \"\");\n\t}\n\n\tprivate isManagedLocalProvider(provider: string): boolean {\n\t\treturn provider === OLLAMA_PROVIDER || provider === HF_TRANSFORMERS_PROVIDER;\n\t}\n\n\t/**\n\t * If the last assistant message in this session was an error from THIS exact local server, a\n\t * cached \"confirmed up\" flag would be stale (the server may have died mid-session) — drop it so\n\t * the next ensure-check is a real one instead of trusting stale state.\n\t */\n\tprivate confirmationKey(model: Model<Api>, serverUrl: string): string {\n\t\treturn model.provider === HF_TRANSFORMERS_PROVIDER ? `${serverUrl}\\0${model.id}` : serverUrl;\n\t}\n\n\tprivate invalidateIfLastCallFailed(model: Model<Api>, serverUrl: string): void {\n\t\tconst lastAssistant = this.deps.getLastAssistantMessage();\n\t\tif (\n\t\t\tlastAssistant?.stopReason === \"error\" &&\n\t\t\tlastAssistant.provider === model.provider &&\n\t\t\tlastAssistant.model === model.id\n\t\t) {\n\t\t\tthis._confirmedUp.delete(this.confirmationKey(model, serverUrl));\n\t\t}\n\t}\n\n\tprivate async ensureOllamaResident(\n\t\tmodel: Model<Api>,\n\t\truntime: OllamaRuntime,\n\t): Promise<{ ok: boolean; reason?: string }> {\n\t\tlet bytes = 0;\n\t\ttry {\n\t\t\tconst installed = await runtime.list();\n\t\t\tbytes = installed.find((entry) => matchesInstalledLocalModel(model.id, entry.name))?.sizeBytes ?? 0;\n\t\t} catch {\n\t\t\t// Residency is best-effort; a list failure falls back to a zero-byte admission request.\n\t\t}\n\t\tconst arbiter = new RuntimeResidencyArbiter({\n\t\t\tbudgetBytes: Math.floor(totalmem() * 0.85),\n\t\t\tadapters: [new OllamaRuntimeResidencyAdapter(\"ollama\", runtime)],\n\t\t});\n\t\ttry {\n\t\t\tconst plan = await arbiter.ensureResident(\"ollama\", {\n\t\t\t\tmodel: model.id,\n\t\t\t\tbytes,\n\t\t\t\trole: \"active\",\n\t\t\t\tpriority: 100,\n\t\t\t\tnowMs: Date.now(),\n\t\t\t\tpinActiveModel: model.id,\n\t\t\t});\n\t\t\treturn plan.status === \"fits\" ? { ok: true } : { ok: false, reason: `residency_refused:${plan.reason}` };\n\t\t} catch (error) {\n\t\t\treturn { ok: false, reason: `residency_error:${error instanceof Error ? error.message : String(error)}` };\n\t\t}\n\t}\n\n\tprivate async ensureTransformersResident(\n\t\tmodel: Model<Api>,\n\t\truntime: TransformersRuntime,\n\t): Promise<{ ok: boolean; reason?: string }> {\n\t\tconst arbiter = new RuntimeResidencyArbiter({\n\t\t\tbudgetBytes: Math.floor(totalmem() * 0.85),\n\t\t\tadapters: [new TransformersRuntimeResidencyAdapter(`transformers:${model.id}`, runtime, model.id, 0)],\n\t\t});\n\t\ttry {\n\t\t\tconst plan = await arbiter.ensureResident(`transformers:${model.id}`, {\n\t\t\t\tmodel: model.id,\n\t\t\t\tbytes: 0,\n\t\t\t\trole: \"active\",\n\t\t\t\tpriority: 100,\n\t\t\t\tnowMs: Date.now(),\n\t\t\t\tpinActiveModel: model.id,\n\t\t\t});\n\t\t\treturn plan.status === \"fits\" ? { ok: true } : { ok: false, reason: `residency_refused:${plan.reason}` };\n\t\t} catch (error) {\n\t\t\treturn { ok: false, reason: `residency_error:${error instanceof Error ? error.message : String(error)}` };\n\t\t}\n\t}\n\n\t/**\n\t * Ensure a routed managed-local model is actually reachable before the turn calls it. No-op (and\n\t * free) for non-local/API models. Caches a \"confirmed up this session\" flag per server (and per\n\t * Transformers model) so steady-state routing pays the health-check round trip once; invalidated\n\t * above when a prior local call failed so a dead sidecar gets re-detected instead of trusted.\n\t */\n\tasync ensureLocalModelReady(\n\t\tmodel: Model<Api>,\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[] }> {\n\t\tif (model.provider !== OLLAMA_PROVIDER) {\n\t\t\treturn { ready: true, reason: \"not_local\" };\n\t\t}\n\t\tconst serverUrl = this.deriveOllamaServerUrl(model.baseUrl);\n\t\tconst confirmedKey = this.confirmationKey(model, serverUrl);\n\t\tthis.invalidateIfLastCallFailed(model, serverUrl);\n\t\tif (this._confirmedUp.has(confirmedKey)) {\n\t\t\treturn { ready: true, reason: \"confirmed_up_cached\" };\n\t\t}\n\t\tconst runtime = this.getLocalRuntime(serverUrl);\n\t\tconst status = await runtime.detect();\n\t\tif (status.serverUp) {\n\t\t\tif (status.activeStore?.kind === \"pi-owned\") {\n\t\t\t\tconst resident = await this.ensureOllamaResident(model, runtime);\n\t\t\t\tif (!resident.ok) return { ready: false, reason: resident.reason ?? \"residency_refused\" };\n\t\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t\t\treturn { ready: true, reason: \"already_running\" };\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tready: false,\n\t\t\t\treason: `wrong_store:${status.activeStore?.path ?? \"external/unknown\"}:${status.activeStore?.modelCount ?? 0}:${status.ownedModelsDir}`,\n\t\t\t};\n\t\t}\n\t\tif (!status.binaryPath) {\n\t\t\treturn { ready: false, reason: \"binary_missing\", installGuide: runtime.installGuide() };\n\t\t}\n\t\tconst started = await runtime.start();\n\t\tif (started.started) {\n\t\t\tconst resident = await this.ensureOllamaResident(model, runtime);\n\t\t\tif (!resident.ok) return { ready: false, reason: resident.reason ?? \"residency_refused\" };\n\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t}\n\t\treturn { ready: started.started, reason: started.reason };\n\t}\n\n\tasync ensureTransformersModelReady(\n\t\tmodel: Model<Api>,\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[] }> {\n\t\tif (model.provider !== HF_TRANSFORMERS_PROVIDER) {\n\t\t\treturn { ready: true, reason: \"not_transformers\" };\n\t\t}\n\t\tconst serverUrl = this.deriveOpenAICompatServerUrl(model.baseUrl);\n\t\tconst confirmedKey = this.confirmationKey(model, serverUrl);\n\t\tthis.invalidateIfLastCallFailed(model, serverUrl);\n\t\tif (this._confirmedUp.has(confirmedKey)) {\n\t\t\treturn { ready: true, reason: \"confirmed_up_cached\" };\n\t\t}\n\t\tconst runtime = this.getTransformersRuntime(model.id, serverUrl);\n\t\tconst status = await runtime.detect();\n\t\tif (status.serverUp) {\n\t\t\tconst resident = await this.ensureTransformersResident(model, runtime);\n\t\t\tif (!resident.ok) return { ready: false, reason: resident.reason ?? \"residency_refused\" };\n\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t\treturn { ready: true, reason: \"already_running\" };\n\t\t}\n\t\tif (!status.runtimeInstalled) {\n\t\t\treturn { ready: false, reason: \"runtime_missing\", installGuide: runtime.installGuide() };\n\t\t}\n\t\tconst started = await runtime.start();\n\t\tif (started.started || started.reason === \"already_running\") {\n\t\t\tconst resident = await this.ensureTransformersResident(model, runtime);\n\t\t\tif (!resident.ok) return { ready: false, reason: resident.reason ?? \"residency_refused\" };\n\t\t\tthis._confirmedUp.add(confirmedKey);\n\t\t\treturn { ready: true, reason: started.reason };\n\t\t}\n\t\treturn { ready: false, reason: started.reason };\n\t}\n\n\t/**\n\t * #31: the ONE case a routed local model's unreadiness can be fixed automatically is a missing\n\t * ollama binary — an unreachable server can't be helped by installing, so that reason is left to\n\t * the graceful-fallback warning below unchanged. Only offered when there's an interactive UI to\n\t * ask through: headless/RPC/print sessions have no UI context and fall straight through, same as\n\t * declining or timing out (both resolve confirm() to false). Reverses \"pi never runs installers\n\t * itself\" specifically for this one path — the user is asked first, the download is pi's own\n\t * (never curl|sh), and it lands in pi's own runtimes dir (see OllamaRuntime.installManaged).\n\t *\n\t * Pauses/resumes the routing working-indicator around the confirm dialog itself (re-emitting\n\t * routing_end/routing_start — both already idempotent, see interactive-mode.ts's handlers) so an\n\t * animated spinner doesn't fight a dialog the user is trying to read and answer; the indicator\n\t * comes back for the download/extract that follows a \"yes\", which is genuine processing feedback.\n\t */\n\tprivate async maybeInstallOllamaOnConsent(\n\t\tmodel: Model<Api>,\n\t\treadiness: { ready: boolean; reason: string; installGuide?: string[] },\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[]; installAttemptError?: string }> {\n\t\tconst ui = this.deps.getUIContext();\n\t\tif (!ui || readiness.ready || readiness.reason !== \"binary_missing\") return readiness;\n\n\t\tconst modelLabel = this.deps.formatModel(model);\n\t\tthis.deps.emit({ type: \"routing_end\" });\n\t\tlet confirmed: boolean;\n\t\ttry {\n\t\t\tconfirmed = await ui.confirm(\n\t\t\t\t\"Install Ollama?\",\n\t\t\t\t`Ollama isn't installed, so the local model \"${modelLabel}\" can't run. Pi can download and ` +\n\t\t\t\t\t\"install it now (a large one-time download, possibly over 1 GB depending on your platform) \" +\n\t\t\t\t\t\"into its own runtimes folder — never curl|sh, never touching anything outside pi's own \" +\n\t\t\t\t\t\"directory. Install it now?\",\n\t\t\t\t{ timeout: OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS },\n\t\t\t);\n\t\t} finally {\n\t\t\tthis.deps.emit({ type: \"routing_start\" });\n\t\t}\n\t\tif (!confirmed) return readiness;\n\n\t\tconst serverUrl = this.deriveOllamaServerUrl(model.baseUrl);\n\t\tconst runtime = this.getLocalRuntime(serverUrl);\n\t\tlet installResult: { ok: boolean; error?: string };\n\t\ttry {\n\t\t\tinstallResult = await runtime.installManaged((status) => ui.setStatus(\"ollama-install\", status));\n\t\t} finally {\n\t\t\tui.setStatus(\"ollama-install\", undefined);\n\t\t}\n\t\tif (!installResult.ok) {\n\t\t\treturn { ready: false, reason: \"install_failed\", installAttemptError: installResult.error };\n\t\t}\n\t\treturn this.ensureLocalModelReady(model);\n\t}\n\n\tprivate async maybeInstallTransformersOnConsent(\n\t\tmodel: Model<Api>,\n\t\treadiness: { ready: boolean; reason: string; installGuide?: string[] },\n\t): Promise<{ ready: boolean; reason: string; installGuide?: string[]; installAttemptError?: string }> {\n\t\tconst ui = this.deps.getUIContext();\n\t\tif (!ui || readiness.ready || readiness.reason !== \"runtime_missing\") return readiness;\n\n\t\tconst modelLabel = this.deps.formatModel(model);\n\t\tthis.deps.emit({ type: \"routing_end\" });\n\t\tlet confirmed: boolean;\n\t\ttry {\n\t\t\tconfirmed = await ui.confirm(\n\t\t\t\t\"Install Transformers runtime?\",\n\t\t\t\t`The Hugging Face model \"${modelLabel}\" needs a pi-managed Python venv with Transformers ` +\n\t\t\t\t\t\"and CPU PyTorch before it can run. Pi will install those packages into its own runtimes \" +\n\t\t\t\t\t\"folder, download the model into a pi-owned Hugging Face cache, and leave system Python, \" +\n\t\t\t\t\t\"your Ollama models, and your user HF cache untouched. Install it now?\",\n\t\t\t\t{ timeout: OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS },\n\t\t\t);\n\t\t} finally {\n\t\t\tthis.deps.emit({ type: \"routing_start\" });\n\t\t}\n\t\tif (!confirmed) return readiness;\n\n\t\tconst serverUrl = this.deriveOpenAICompatServerUrl(model.baseUrl);\n\t\tconst runtime = this.getTransformersRuntime(model.id, serverUrl);\n\t\tlet installResult: { ok: boolean; error?: string };\n\t\ttry {\n\t\t\tinstallResult = await runtime.installManaged((status) => ui.setStatus(\"transformers-install\", status));\n\t\t} finally {\n\t\t\tui.setStatus(\"transformers-install\", undefined);\n\t\t}\n\t\tif (!installResult.ok) {\n\t\t\treturn { ready: false, reason: \"install_failed\", installAttemptError: installResult.error };\n\t\t}\n\t\tlet downloadResult: { ok: boolean; error?: string };\n\t\ttry {\n\t\t\tdownloadResult = await runtime.downloadModel((status) => ui.setStatus(\"transformers-download\", status));\n\t\t} finally {\n\t\t\tui.setStatus(\"transformers-download\", undefined);\n\t\t}\n\t\tif (!downloadResult.ok) {\n\t\t\treturn { ready: false, reason: \"download_failed\", installAttemptError: downloadResult.error };\n\t\t}\n\t\treturn this.ensureTransformersModelReady(model);\n\t}\n\n\t/**\n\t * Router-swap gate (#27): a turn routed to a local model (any tier, including an executor-direct\n\t * route — both carry tier \"cheap\") must not dead-end the turn just because ollama isn't up.\n\t * Never a SILENT swap: every fallback is announced in a warning that states (i) the local model\n\t * was unavailable and WHY — binary missing surfaces the install guide inline; any other reason\n\t * gets a \"check that ollama is running\" hint — and (ii) which tier is now handling the turn, so\n\t * the cost shift is never a surprise. Escalates cheap -> medium -> expensive, skipping any\n\t * unconfigured intermediate tier, reusing the router's own existing \"model unavailable\"\n\t * resolution (resolveConfiguredTierModel) rather than inventing a new fallback mechanism.\n\t * Escalation is bounded: tier strictly increases each hop, so it terminates within two hops.\n\t *\n\t * Before the warning/escalation below: #31's consent gate gets one shot at fixing a missing\n\t * binary interactively (see maybeInstallOllamaOnConsent) — declining, timing out, running\n\t * headless, or the install attempt itself failing all fall through here unchanged, just with an\n\t * honest reason (an install that failed is worded as a failed install, not re-labeled as if\n\t * nothing was ever tried).\n\t */\n\tasync ensureRouteModelReady(\n\t\tresolved: { decision: RouteDecision; model: Model<Api> } | undefined,\n\t): Promise<{ decision: RouteDecision; model: Model<Api> } | undefined> {\n\t\tlet current = resolved;\n\t\twhile (current && this.isManagedLocalProvider(current.model.provider)) {\n\t\t\tlet readiness: { ready: boolean; reason: string; installGuide?: string[]; installAttemptError?: string } =\n\t\t\t\tcurrent.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t? await this.ensureLocalModelReady(current.model)\n\t\t\t\t\t: await this.ensureTransformersModelReady(current.model);\n\t\t\tif (!readiness.ready) {\n\t\t\t\treadiness =\n\t\t\t\t\tcurrent.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t\t? await this.maybeInstallOllamaOnConsent(current.model, readiness)\n\t\t\t\t\t\t: await this.maybeInstallTransformersOnConsent(current.model, readiness);\n\t\t\t}\n\t\t\tif (readiness.ready) return current;\n\n\t\t\t// Walk the remaining tiers in order (never back down to cheap) and take the first one that\n\t\t\t// actually resolves — an unconfigured intermediate tier (e.g. no mediumModel set) must be\n\t\t\t// skipped, not treated as \"no fallback available\".\n\t\t\tconst startIndex = MODEL_ROUTER_TIER_ORDER.indexOf(current.decision.tier as \"cheap\" | \"medium\" | \"expensive\");\n\t\t\tlet escalated: { tier: \"medium\" | \"expensive\"; model: Model<Api> } | undefined;\n\t\t\tfor (let i = startIndex + 1; startIndex !== -1 && i < MODEL_ROUTER_TIER_ORDER.length; i++) {\n\t\t\t\tconst tier = MODEL_ROUTER_TIER_ORDER[i] as \"medium\" | \"expensive\";\n\t\t\t\tconst model = this.deps.resolveConfiguredTierModel(tier);\n\t\t\t\tif (model) {\n\t\t\t\t\tescalated = { tier, model };\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst modelLabel = this.deps.formatModel(current.model);\n\t\t\tconst localRuntimeName = current.model.provider === OLLAMA_PROVIDER ? \"ollama\" : \"Transformers\";\n\t\t\tconst wrongStore = parseWrongOllamaStoreReason(readiness.reason);\n\t\t\tconst whyText = readiness.installAttemptError\n\t\t\t\t? `pi tried to install it just now, but the install attempt failed: ${readiness.installAttemptError}`\n\t\t\t\t: readiness.installGuide\n\t\t\t\t\t? [\n\t\t\t\t\t\t\tcurrent.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t\t\t\t? \"the ollama binary is not installed.\"\n\t\t\t\t\t\t\t\t: \"the pi-managed Transformers runtime is not installed.\",\n\t\t\t\t\t\t\t...readiness.installGuide,\n\t\t\t\t\t\t].join(\"\\n\")\n\t\t\t\t\t: wrongStore\n\t\t\t\t\t\t? `an Ollama server is already running with store ${wrongStore.activePath} (${wrongStore.modelCount} model(s)); pi's owned store is ${wrongStore.ownedPath}. Stop the other server or run /models import, then retry.`\n\t\t\t\t\t\t: current.model.provider === OLLAMA_PROVIDER\n\t\t\t\t\t\t\t? `its ${localRuntimeName} server is not reachable (${readiness.reason}) — check that ollama is running.`\n\t\t\t\t\t\t\t: `its ${localRuntimeName} server is not reachable (${readiness.reason}) — check that the runtime is running.`;\n\t\t\tconst fallbackText = escalated\n\t\t\t\t? `Falling back to the ${escalated.tier} tier for this turn.`\n\t\t\t\t: \"No other tier is configured — falling back to the session's default model.\";\n\t\t\tthis.deps.emit({\n\t\t\t\ttype: \"warning\",\n\t\t\t\tmessage: `Local model \"${modelLabel}\" is unavailable: ${whyText}\\n${fallbackText}`,\n\t\t\t});\n\n\t\t\tif (!escalated) return undefined; // no higher tier resolves — caller falls back to the session default\n\t\t\tcurrent = {\n\t\t\t\tmodel: escalated.model,\n\t\t\t\tdecision: {\n\t\t\t\t\t...current.decision,\n\t\t\t\t\ttier: escalated.tier,\n\t\t\t\t\tfallbackFrom: current.decision.tier,\n\t\t\t\t\treasonCode: \"local_model_not_ready_fallback\",\n\t\t\t\t\treasons: [\n\t\t\t\t\t\t...current.decision.reasons,\n\t\t\t\t\t\t`Local model not ready (${readiness.reason}); escalated to ${escalated.tier}`,\n\t\t\t\t\t],\n\t\t\t\t\tmodel: this.deps.formatModel(escalated.model),\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\t\treturn current;\n\t}\n}\n"]}