@caupulican/pi-adaptative 0.80.94 → 0.80.95

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 (33) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/core/models/fitness-store.d.ts +2 -0
  3. package/dist/core/models/fitness-store.d.ts.map +1 -1
  4. package/dist/core/models/fitness-store.js +10 -0
  5. package/dist/core/models/fitness-store.js.map +1 -1
  6. package/dist/core/models/local-registration.d.ts +18 -0
  7. package/dist/core/models/local-registration.d.ts.map +1 -0
  8. package/dist/core/models/local-registration.js +83 -0
  9. package/dist/core/models/local-registration.js.map +1 -0
  10. package/dist/core/models/local-runtime.d.ts +84 -0
  11. package/dist/core/models/local-runtime.d.ts.map +1 -0
  12. package/dist/core/models/local-runtime.js +219 -0
  13. package/dist/core/models/local-runtime.js.map +1 -0
  14. package/dist/core/models/model-ref.d.ts +19 -0
  15. package/dist/core/models/model-ref.d.ts.map +1 -0
  16. package/dist/core/models/model-ref.js +61 -0
  17. package/dist/core/models/model-ref.js.map +1 -0
  18. package/dist/core/slash-commands.d.ts.map +1 -1
  19. package/dist/core/slash-commands.js +4 -0
  20. package/dist/core/slash-commands.js.map +1 -1
  21. package/dist/modes/interactive/interactive-mode.d.ts +7 -0
  22. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  23. package/dist/modes/interactive/interactive-mode.js +175 -0
  24. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  25. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  26. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  27. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  28. package/examples/extensions/sandbox/package-lock.json +2 -2
  29. package/examples/extensions/sandbox/package.json +1 -1
  30. package/examples/extensions/with-deps/package-lock.json +2 -2
  31. package/examples/extensions/with-deps/package.json +1 -1
  32. package/npm-shrinkwrap.json +12 -12
  33. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## [0.80.95] - 2026-07-02
2
+
3
+ ### Added
4
+
5
+ - Added `/models`, the local model lifecycle (install -> spawn -> probe -> consume -> uninstall):
6
+ `/models add <ref>` accepts an ollama tag, an `hf.co/org/repo[:quant]` GGUF ref, a full
7
+ HuggingFace URL, or a pasted `ollama pull ...` install command (parsed for its reference, never
8
+ executed as shell) — pi starts its own health-checked server with OWNED model storage under
9
+ `<agentDir>/models/ollama` (hardened env; an already-running system server is used instead, with
10
+ the storage tradeoff surfaced), pulls with streamed progress, registers the model in models.json
11
+ so `ollama/<ref>` resolves everywhere (session, lanes, judge, curator) across sessions, and
12
+ auto-runs the fitness probe with one-step role assignment. `/models list` shows installed models
13
+ with real sizes and cached fitness summaries; `/models stop` stops the pi-managed server
14
+ (resource hygiene, deletes nothing). Removal is an EXPLICIT user action only — `/models remove
15
+ <ref>` first discloses exactly what gets deleted (weights + size, registration, fitness report)
16
+ and requires the confirm token; pi never removes a model on its own. Hand-authored models.json
17
+ files with comments are never rewritten (a manual snippet is offered instead).
18
+
1
19
  ## [0.80.94] - 2026-07-02
2
20
 
3
21
  ### Fixed
@@ -32,6 +32,8 @@ export declare class FitnessStore {
32
32
  private load;
33
33
  /** Persist the latest report for a model on the CURRENT host. Best-effort, returns the entry. */
34
34
  save(model: string, report: ModelFitnessReport, at?: string): StoredFitnessReport;
35
+ /** Drop a model's report for the CURRENT host (uninstall cleanup). No-op when absent. */
36
+ remove(model: string): void;
35
37
  /** Reports for the current host (default) or an explicit host id. */
36
38
  getForHost(hostId?: string): StoredFitnessReport[];
37
39
  /** Every stored report across all hosts (for cross-machine comparisons). */
@@ -1 +1 @@
1
- {"version":3,"file":"fitness-store.d.ts","sourceRoot":"","sources":["../../../src/core/models/fitness-store.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,8BAA8B,CAAC;AAEvE;;;;;;GAMG;AAEH,MAAM,WAAW,eAAe;IAC/B,8DAA8D;IAC9D,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,sBAAsB,IAAI,eAAe,CAWxD;AAED,MAAM,WAAW,mBAAmB;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,kBAAkB,CAAC;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,eAAe,CAAC;CACtB;AAQD,qBAAa,YAAY;IACxB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAwB;IAEpD,YAAY,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,eAAe,CAAA;KAAE,EAG9E;IAED,MAAM,CAAC,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,eAAe,CAAA;KAAE,GAAG,YAAY,CAEpG;IAED,OAAO,CAAC,IAAI;IAaZ,iGAAiG;IACjG,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,kBAAkB,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,mBAAmB,CAQhF;IAED,qEAAqE;IACrE,UAAU,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,mBAAmB,EAAE,CAGjD;IAED,4EAA4E;IAC5E,MAAM,IAAI,mBAAmB,EAAE,CAG9B;CACD","sourcesContent":["import { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { cpus, totalmem } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport type { ModelFitnessReport } from \"../research/model-fitness.ts\";\n\n/**\n * Durable, HOST-KEYED storage for model fitness reports. Fitness is a property of a model ON a\n * host (tok/s and latency-driven failures do not travel between machines), so reports are keyed\n * by a hardware fingerprint: the same model can be \"the heavy lifter\" on one machine and\n * \"waiting for better hardware\" on another, and pi remembers both without confusing them —\n * including when settings/dotfiles are synced across machines.\n */\n\nexport interface HostFingerprint {\n\t/** Stable, human-readable id derived from the specs below. */\n\tid: string;\n\tcpu: string;\n\tcores: number;\n\ttotalMemGb: number;\n}\n\nexport function currentHostFingerprint(): HostFingerprint {\n\tconst cpuList = cpus();\n\tconst cpu = (cpuList[0]?.model ?? \"unknown-cpu\").trim();\n\tconst cores = cpuList.length;\n\tconst totalMemGb = Math.round(totalmem() / 1024 ** 3);\n\tconst id = `${cpu\n\t\t.toLowerCase()\n\t\t.replace(/[^a-z0-9]+/g, \"-\")\n\t\t.replace(/^-+|-+$/g, \"\")\n\t\t.slice(0, 48)}-${cores}c-${totalMemGb}g`;\n\treturn { id, cpu, cores, totalMemGb };\n}\n\nexport interface StoredFitnessReport {\n\tmodel: string;\n\treport: ModelFitnessReport;\n\tat: string;\n\thost: HostFingerprint;\n}\n\ninterface FitnessStoreFile {\n\tversion: 1;\n\t/** hostId -> modelRef -> latest stored report. */\n\thosts: Record<string, Record<string, StoredFitnessReport>>;\n}\n\nexport class FitnessStore {\n\tprivate readonly filePath: string;\n\tprivate readonly fingerprint: () => HostFingerprint;\n\n\tconstructor(filePath: string, options?: { fingerprint?: () => HostFingerprint }) {\n\t\tthis.filePath = filePath;\n\t\tthis.fingerprint = options?.fingerprint ?? currentHostFingerprint;\n\t}\n\n\tstatic forAgentDir(agentDir: string, options?: { fingerprint?: () => HostFingerprint }): FitnessStore {\n\t\treturn new FitnessStore(join(agentDir, \"state\", \"model-fitness.json\"), options);\n\t}\n\n\tprivate load(): FitnessStoreFile {\n\t\ttry {\n\t\t\tif (!existsSync(this.filePath)) return { version: 1, hosts: {} };\n\t\t\tconst parsed = JSON.parse(readFileSync(this.filePath, \"utf-8\")) as FitnessStoreFile;\n\t\t\tif (parsed && parsed.version === 1 && parsed.hosts && typeof parsed.hosts === \"object\") {\n\t\t\t\treturn parsed;\n\t\t\t}\n\t\t} catch {\n\t\t\t// Unreadable/corrupt store: start fresh in memory; the next save rewrites the file.\n\t\t}\n\t\treturn { version: 1, hosts: {} };\n\t}\n\n\t/** Persist the latest report for a model on the CURRENT host. Best-effort, returns the entry. */\n\tsave(model: string, report: ModelFitnessReport, at?: string): StoredFitnessReport {\n\t\tconst host = this.fingerprint();\n\t\tconst entry: StoredFitnessReport = { model, report, at: at ?? new Date().toISOString(), host };\n\t\tconst file = this.load();\n\t\tfile.hosts[host.id] = { ...(file.hosts[host.id] ?? {}), [model]: entry };\n\t\tmkdirSync(dirname(this.filePath), { recursive: true });\n\t\twriteFileSync(this.filePath, `${JSON.stringify(file, null, \"\\t\")}\\n`, \"utf-8\");\n\t\treturn entry;\n\t}\n\n\t/** Reports for the current host (default) or an explicit host id. */\n\tgetForHost(hostId?: string): StoredFitnessReport[] {\n\t\tconst file = this.load();\n\t\treturn Object.values(file.hosts[hostId ?? this.fingerprint().id] ?? {});\n\t}\n\n\t/** Every stored report across all hosts (for cross-machine comparisons). */\n\tgetAll(): StoredFitnessReport[] {\n\t\tconst file = this.load();\n\t\treturn Object.values(file.hosts).flatMap((models) => Object.values(models));\n\t}\n}\n"]}
1
+ {"version":3,"file":"fitness-store.d.ts","sourceRoot":"","sources":["../../../src/core/models/fitness-store.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,8BAA8B,CAAC;AAEvE;;;;;;GAMG;AAEH,MAAM,WAAW,eAAe;IAC/B,8DAA8D;IAC9D,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,sBAAsB,IAAI,eAAe,CAWxD;AAED,MAAM,WAAW,mBAAmB;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,kBAAkB,CAAC;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,eAAe,CAAC;CACtB;AAQD,qBAAa,YAAY;IACxB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAwB;IAEpD,YAAY,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,eAAe,CAAA;KAAE,EAG9E;IAED,MAAM,CAAC,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,eAAe,CAAA;KAAE,GAAG,YAAY,CAEpG;IAED,OAAO,CAAC,IAAI;IAaZ,iGAAiG;IACjG,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,kBAAkB,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,mBAAmB,CAQhF;IAED,yFAAyF;IACzF,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAO1B;IAED,qEAAqE;IACrE,UAAU,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,mBAAmB,EAAE,CAGjD;IAED,4EAA4E;IAC5E,MAAM,IAAI,mBAAmB,EAAE,CAG9B;CACD","sourcesContent":["import { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { cpus, totalmem } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport type { ModelFitnessReport } from \"../research/model-fitness.ts\";\n\n/**\n * Durable, HOST-KEYED storage for model fitness reports. Fitness is a property of a model ON a\n * host (tok/s and latency-driven failures do not travel between machines), so reports are keyed\n * by a hardware fingerprint: the same model can be \"the heavy lifter\" on one machine and\n * \"waiting for better hardware\" on another, and pi remembers both without confusing them —\n * including when settings/dotfiles are synced across machines.\n */\n\nexport interface HostFingerprint {\n\t/** Stable, human-readable id derived from the specs below. */\n\tid: string;\n\tcpu: string;\n\tcores: number;\n\ttotalMemGb: number;\n}\n\nexport function currentHostFingerprint(): HostFingerprint {\n\tconst cpuList = cpus();\n\tconst cpu = (cpuList[0]?.model ?? \"unknown-cpu\").trim();\n\tconst cores = cpuList.length;\n\tconst totalMemGb = Math.round(totalmem() / 1024 ** 3);\n\tconst id = `${cpu\n\t\t.toLowerCase()\n\t\t.replace(/[^a-z0-9]+/g, \"-\")\n\t\t.replace(/^-+|-+$/g, \"\")\n\t\t.slice(0, 48)}-${cores}c-${totalMemGb}g`;\n\treturn { id, cpu, cores, totalMemGb };\n}\n\nexport interface StoredFitnessReport {\n\tmodel: string;\n\treport: ModelFitnessReport;\n\tat: string;\n\thost: HostFingerprint;\n}\n\ninterface FitnessStoreFile {\n\tversion: 1;\n\t/** hostId -> modelRef -> latest stored report. */\n\thosts: Record<string, Record<string, StoredFitnessReport>>;\n}\n\nexport class FitnessStore {\n\tprivate readonly filePath: string;\n\tprivate readonly fingerprint: () => HostFingerprint;\n\n\tconstructor(filePath: string, options?: { fingerprint?: () => HostFingerprint }) {\n\t\tthis.filePath = filePath;\n\t\tthis.fingerprint = options?.fingerprint ?? currentHostFingerprint;\n\t}\n\n\tstatic forAgentDir(agentDir: string, options?: { fingerprint?: () => HostFingerprint }): FitnessStore {\n\t\treturn new FitnessStore(join(agentDir, \"state\", \"model-fitness.json\"), options);\n\t}\n\n\tprivate load(): FitnessStoreFile {\n\t\ttry {\n\t\t\tif (!existsSync(this.filePath)) return { version: 1, hosts: {} };\n\t\t\tconst parsed = JSON.parse(readFileSync(this.filePath, \"utf-8\")) as FitnessStoreFile;\n\t\t\tif (parsed && parsed.version === 1 && parsed.hosts && typeof parsed.hosts === \"object\") {\n\t\t\t\treturn parsed;\n\t\t\t}\n\t\t} catch {\n\t\t\t// Unreadable/corrupt store: start fresh in memory; the next save rewrites the file.\n\t\t}\n\t\treturn { version: 1, hosts: {} };\n\t}\n\n\t/** Persist the latest report for a model on the CURRENT host. Best-effort, returns the entry. */\n\tsave(model: string, report: ModelFitnessReport, at?: string): StoredFitnessReport {\n\t\tconst host = this.fingerprint();\n\t\tconst entry: StoredFitnessReport = { model, report, at: at ?? new Date().toISOString(), host };\n\t\tconst file = this.load();\n\t\tfile.hosts[host.id] = { ...(file.hosts[host.id] ?? {}), [model]: entry };\n\t\tmkdirSync(dirname(this.filePath), { recursive: true });\n\t\twriteFileSync(this.filePath, `${JSON.stringify(file, null, \"\\t\")}\\n`, \"utf-8\");\n\t\treturn entry;\n\t}\n\n\t/** Drop a model's report for the CURRENT host (uninstall cleanup). No-op when absent. */\n\tremove(model: string): void {\n\t\tconst host = this.fingerprint();\n\t\tconst file = this.load();\n\t\tif (!file.hosts[host.id]?.[model]) return;\n\t\tdelete file.hosts[host.id][model];\n\t\tmkdirSync(dirname(this.filePath), { recursive: true });\n\t\twriteFileSync(this.filePath, `${JSON.stringify(file, null, \"\\t\")}\\n`, \"utf-8\");\n\t}\n\n\t/** Reports for the current host (default) or an explicit host id. */\n\tgetForHost(hostId?: string): StoredFitnessReport[] {\n\t\tconst file = this.load();\n\t\treturn Object.values(file.hosts[hostId ?? this.fingerprint().id] ?? {});\n\t}\n\n\t/** Every stored report across all hosts (for cross-machine comparisons). */\n\tgetAll(): StoredFitnessReport[] {\n\t\tconst file = this.load();\n\t\treturn Object.values(file.hosts).flatMap((models) => Object.values(models));\n\t}\n}\n"]}
@@ -47,6 +47,16 @@ export class FitnessStore {
47
47
  writeFileSync(this.filePath, `${JSON.stringify(file, null, "\t")}\n`, "utf-8");
48
48
  return entry;
49
49
  }
50
+ /** Drop a model's report for the CURRENT host (uninstall cleanup). No-op when absent. */
51
+ remove(model) {
52
+ const host = this.fingerprint();
53
+ const file = this.load();
54
+ if (!file.hosts[host.id]?.[model])
55
+ return;
56
+ delete file.hosts[host.id][model];
57
+ mkdirSync(dirname(this.filePath), { recursive: true });
58
+ writeFileSync(this.filePath, `${JSON.stringify(file, null, "\t")}\n`, "utf-8");
59
+ }
50
60
  /** Reports for the current host (default) or an explicit host id. */
51
61
  getForHost(hostId) {
52
62
  const file = this.load();
@@ -1 +1 @@
1
- {"version":3,"file":"fitness-store.js","sourceRoot":"","sources":["../../../src/core/models/fitness-store.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { cpus, totalmem } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport type { ModelFitnessReport } from \"../research/model-fitness.ts\";\n\n/**\n * Durable, HOST-KEYED storage for model fitness reports. Fitness is a property of a model ON a\n * host (tok/s and latency-driven failures do not travel between machines), so reports are keyed\n * by a hardware fingerprint: the same model can be \"the heavy lifter\" on one machine and\n * \"waiting for better hardware\" on another, and pi remembers both without confusing them —\n * including when settings/dotfiles are synced across machines.\n */\n\nexport interface HostFingerprint {\n\t/** Stable, human-readable id derived from the specs below. */\n\tid: string;\n\tcpu: string;\n\tcores: number;\n\ttotalMemGb: number;\n}\n\nexport function currentHostFingerprint(): HostFingerprint {\n\tconst cpuList = cpus();\n\tconst cpu = (cpuList[0]?.model ?? \"unknown-cpu\").trim();\n\tconst cores = cpuList.length;\n\tconst totalMemGb = Math.round(totalmem() / 1024 ** 3);\n\tconst id = `${cpu\n\t\t.toLowerCase()\n\t\t.replace(/[^a-z0-9]+/g, \"-\")\n\t\t.replace(/^-+|-+$/g, \"\")\n\t\t.slice(0, 48)}-${cores}c-${totalMemGb}g`;\n\treturn { id, cpu, cores, totalMemGb };\n}\n\nexport interface StoredFitnessReport {\n\tmodel: string;\n\treport: ModelFitnessReport;\n\tat: string;\n\thost: HostFingerprint;\n}\n\ninterface FitnessStoreFile {\n\tversion: 1;\n\t/** hostId -> modelRef -> latest stored report. */\n\thosts: Record<string, Record<string, StoredFitnessReport>>;\n}\n\nexport class FitnessStore {\n\tprivate readonly filePath: string;\n\tprivate readonly fingerprint: () => HostFingerprint;\n\n\tconstructor(filePath: string, options?: { fingerprint?: () => HostFingerprint }) {\n\t\tthis.filePath = filePath;\n\t\tthis.fingerprint = options?.fingerprint ?? currentHostFingerprint;\n\t}\n\n\tstatic forAgentDir(agentDir: string, options?: { fingerprint?: () => HostFingerprint }): FitnessStore {\n\t\treturn new FitnessStore(join(agentDir, \"state\", \"model-fitness.json\"), options);\n\t}\n\n\tprivate load(): FitnessStoreFile {\n\t\ttry {\n\t\t\tif (!existsSync(this.filePath)) return { version: 1, hosts: {} };\n\t\t\tconst parsed = JSON.parse(readFileSync(this.filePath, \"utf-8\")) as FitnessStoreFile;\n\t\t\tif (parsed && parsed.version === 1 && parsed.hosts && typeof parsed.hosts === \"object\") {\n\t\t\t\treturn parsed;\n\t\t\t}\n\t\t} catch {\n\t\t\t// Unreadable/corrupt store: start fresh in memory; the next save rewrites the file.\n\t\t}\n\t\treturn { version: 1, hosts: {} };\n\t}\n\n\t/** Persist the latest report for a model on the CURRENT host. Best-effort, returns the entry. */\n\tsave(model: string, report: ModelFitnessReport, at?: string): StoredFitnessReport {\n\t\tconst host = this.fingerprint();\n\t\tconst entry: StoredFitnessReport = { model, report, at: at ?? new Date().toISOString(), host };\n\t\tconst file = this.load();\n\t\tfile.hosts[host.id] = { ...(file.hosts[host.id] ?? {}), [model]: entry };\n\t\tmkdirSync(dirname(this.filePath), { recursive: true });\n\t\twriteFileSync(this.filePath, `${JSON.stringify(file, null, \"\\t\")}\\n`, \"utf-8\");\n\t\treturn entry;\n\t}\n\n\t/** Reports for the current host (default) or an explicit host id. */\n\tgetForHost(hostId?: string): StoredFitnessReport[] {\n\t\tconst file = this.load();\n\t\treturn Object.values(file.hosts[hostId ?? this.fingerprint().id] ?? {});\n\t}\n\n\t/** Every stored report across all hosts (for cross-machine comparisons). */\n\tgetAll(): StoredFitnessReport[] {\n\t\tconst file = this.load();\n\t\treturn Object.values(file.hosts).flatMap((models) => Object.values(models));\n\t}\n}\n"]}
1
+ {"version":3,"file":"fitness-store.js","sourceRoot":"","sources":["../../../src/core/models/fitness-store.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { cpus, totalmem } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport type { ModelFitnessReport } from \"../research/model-fitness.ts\";\n\n/**\n * Durable, HOST-KEYED storage for model fitness reports. Fitness is a property of a model ON a\n * host (tok/s and latency-driven failures do not travel between machines), so reports are keyed\n * by a hardware fingerprint: the same model can be \"the heavy lifter\" on one machine and\n * \"waiting for better hardware\" on another, and pi remembers both without confusing them —\n * including when settings/dotfiles are synced across machines.\n */\n\nexport interface HostFingerprint {\n\t/** Stable, human-readable id derived from the specs below. */\n\tid: string;\n\tcpu: string;\n\tcores: number;\n\ttotalMemGb: number;\n}\n\nexport function currentHostFingerprint(): HostFingerprint {\n\tconst cpuList = cpus();\n\tconst cpu = (cpuList[0]?.model ?? \"unknown-cpu\").trim();\n\tconst cores = cpuList.length;\n\tconst totalMemGb = Math.round(totalmem() / 1024 ** 3);\n\tconst id = `${cpu\n\t\t.toLowerCase()\n\t\t.replace(/[^a-z0-9]+/g, \"-\")\n\t\t.replace(/^-+|-+$/g, \"\")\n\t\t.slice(0, 48)}-${cores}c-${totalMemGb}g`;\n\treturn { id, cpu, cores, totalMemGb };\n}\n\nexport interface StoredFitnessReport {\n\tmodel: string;\n\treport: ModelFitnessReport;\n\tat: string;\n\thost: HostFingerprint;\n}\n\ninterface FitnessStoreFile {\n\tversion: 1;\n\t/** hostId -> modelRef -> latest stored report. */\n\thosts: Record<string, Record<string, StoredFitnessReport>>;\n}\n\nexport class FitnessStore {\n\tprivate readonly filePath: string;\n\tprivate readonly fingerprint: () => HostFingerprint;\n\n\tconstructor(filePath: string, options?: { fingerprint?: () => HostFingerprint }) {\n\t\tthis.filePath = filePath;\n\t\tthis.fingerprint = options?.fingerprint ?? currentHostFingerprint;\n\t}\n\n\tstatic forAgentDir(agentDir: string, options?: { fingerprint?: () => HostFingerprint }): FitnessStore {\n\t\treturn new FitnessStore(join(agentDir, \"state\", \"model-fitness.json\"), options);\n\t}\n\n\tprivate load(): FitnessStoreFile {\n\t\ttry {\n\t\t\tif (!existsSync(this.filePath)) return { version: 1, hosts: {} };\n\t\t\tconst parsed = JSON.parse(readFileSync(this.filePath, \"utf-8\")) as FitnessStoreFile;\n\t\t\tif (parsed && parsed.version === 1 && parsed.hosts && typeof parsed.hosts === \"object\") {\n\t\t\t\treturn parsed;\n\t\t\t}\n\t\t} catch {\n\t\t\t// Unreadable/corrupt store: start fresh in memory; the next save rewrites the file.\n\t\t}\n\t\treturn { version: 1, hosts: {} };\n\t}\n\n\t/** Persist the latest report for a model on the CURRENT host. Best-effort, returns the entry. */\n\tsave(model: string, report: ModelFitnessReport, at?: string): StoredFitnessReport {\n\t\tconst host = this.fingerprint();\n\t\tconst entry: StoredFitnessReport = { model, report, at: at ?? new Date().toISOString(), host };\n\t\tconst file = this.load();\n\t\tfile.hosts[host.id] = { ...(file.hosts[host.id] ?? {}), [model]: entry };\n\t\tmkdirSync(dirname(this.filePath), { recursive: true });\n\t\twriteFileSync(this.filePath, `${JSON.stringify(file, null, \"\\t\")}\\n`, \"utf-8\");\n\t\treturn entry;\n\t}\n\n\t/** Drop a model's report for the CURRENT host (uninstall cleanup). No-op when absent. */\n\tremove(model: string): void {\n\t\tconst host = this.fingerprint();\n\t\tconst file = this.load();\n\t\tif (!file.hosts[host.id]?.[model]) return;\n\t\tdelete file.hosts[host.id][model];\n\t\tmkdirSync(dirname(this.filePath), { recursive: true });\n\t\twriteFileSync(this.filePath, `${JSON.stringify(file, null, \"\\t\")}\\n`, \"utf-8\");\n\t}\n\n\t/** Reports for the current host (default) or an explicit host id. */\n\tgetForHost(hostId?: string): StoredFitnessReport[] {\n\t\tconst file = this.load();\n\t\treturn Object.values(file.hosts[hostId ?? this.fingerprint().id] ?? {});\n\t}\n\n\t/** Every stored report across all hosts (for cross-machine comparisons). */\n\tgetAll(): StoredFitnessReport[] {\n\t\tconst file = this.load();\n\t\treturn Object.values(file.hosts).flatMap((models) => Object.values(models));\n\t}\n}\n"]}
@@ -0,0 +1,18 @@
1
+ export interface LocalRegistrationResult {
2
+ ok: boolean;
3
+ modelsJsonPath: string;
4
+ reason?: string;
5
+ /** When the file cannot be safely rewritten: the entry the user should add by hand. */
6
+ manualSnippet?: string;
7
+ }
8
+ export declare function registerLocalModel(args: {
9
+ agentDir: string;
10
+ ref: string;
11
+ baseUrl: string;
12
+ contextWindow?: number;
13
+ }): LocalRegistrationResult;
14
+ export declare function unregisterLocalModel(args: {
15
+ agentDir: string;
16
+ ref: string;
17
+ }): LocalRegistrationResult;
18
+ //# sourceMappingURL=local-registration.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-registration.d.ts","sourceRoot":"","sources":["../../../src/core/models/local-registration.ts"],"names":[],"mappings":"AAsCA,MAAM,WAAW,uBAAuB;IACvC,EAAE,EAAE,OAAO,CAAC;IACZ,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uFAAuF;IACvF,aAAa,CAAC,EAAE,MAAM,CAAC;CACvB;AA4BD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE;IACxC,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,CAAC,EAAE,MAAM,CAAC;CACvB,GAAG,uBAAuB,CAoC1B;AAED,wBAAgB,oBAAoB,CAAC,IAAI,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,uBAAuB,CAgBrG","sourcesContent":["import { existsSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\n/**\n * Persistent registration for pulled local models: merges an \"ollama\" provider entry into the\n * user's `<agentDir>/models.json` — the exact file ModelRegistry loads at startup — so a pulled\n * model resolves as `ollama/<ref>` immediately AND across sessions (usable as session model,\n * lane model, judge, or curator).\n *\n * Non-destructive contract: the file is parsed with STRICT JSON first; a file that only parses\n * with comments/relaxed syntax is the user's hand-authored config and is never rewritten — the\n * caller gets `manualSnippet` to show instead.\n */\n\ninterface ModelsJsonModel {\n\tid: string;\n\tname?: string;\n\tcontextWindow?: number;\n\tmaxTokens?: number;\n\treasoning?: boolean;\n\tinput?: string[];\n\tcost?: { input: number; output: number; cacheRead: number; cacheWrite: number };\n}\n\ninterface ModelsJson {\n\tproviders: Record<\n\t\tstring,\n\t\t{\n\t\t\tbaseUrl?: string;\n\t\t\tapi?: string;\n\t\t\tapiKey?: string;\n\t\t\tmodels?: ModelsJsonModel[];\n\t\t\t[key: string]: unknown;\n\t\t}\n\t>;\n\t[key: string]: unknown;\n}\n\nexport interface LocalRegistrationResult {\n\tok: boolean;\n\tmodelsJsonPath: string;\n\treason?: string;\n\t/** When the file cannot be safely rewritten: the entry the user should add by hand. */\n\tmanualSnippet?: string;\n}\n\nconst OLLAMA_PROVIDER = \"ollama\";\n\nfunction localModelEntry(ref: string, contextWindow: number): ModelsJsonModel {\n\treturn {\n\t\tid: ref,\n\t\tname: ref,\n\t\tcontextWindow,\n\t\tmaxTokens: 2048,\n\t\treasoning: false,\n\t\tinput: [\"text\"],\n\t\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t};\n}\n\nfunction loadStrict(path: string): { json?: ModelsJson; reason?: string } {\n\tif (!existsSync(path)) return { json: { providers: {} } };\n\ttry {\n\t\tconst parsed = JSON.parse(readFileSync(path, \"utf-8\")) as ModelsJson;\n\t\tif (!parsed || typeof parsed !== \"object\") return { reason: \"models.json is not a JSON object\" };\n\t\tparsed.providers = parsed.providers ?? {};\n\t\treturn { json: parsed };\n\t} catch {\n\t\treturn { reason: \"models.json uses comments/relaxed JSON — pi will not rewrite a hand-authored file\" };\n\t}\n}\n\nexport function registerLocalModel(args: {\n\tagentDir: string;\n\tref: string;\n\tbaseUrl: string;\n\tcontextWindow?: number;\n}): LocalRegistrationResult {\n\tconst modelsJsonPath = join(args.agentDir, \"models.json\");\n\tconst contextWindow = args.contextWindow ?? 8192;\n\tconst entry = localModelEntry(args.ref, contextWindow);\n\tconst providerBase = {\n\t\tbaseUrl: `${args.baseUrl.replace(/\\/$/, \"\")}/v1`,\n\t\tapi: \"openai-completions\",\n\t\tapiKey: \"ollama\",\n\t};\n\tconst { json, reason } = loadStrict(modelsJsonPath);\n\tif (!json) {\n\t\treturn {\n\t\t\tok: false,\n\t\t\tmodelsJsonPath,\n\t\t\treason,\n\t\t\tmanualSnippet: JSON.stringify(\n\t\t\t\t{ providers: { [OLLAMA_PROVIDER]: { ...providerBase, models: [entry] } } },\n\t\t\t\tnull,\n\t\t\t\t\"\\t\",\n\t\t\t),\n\t\t};\n\t}\n\tjson.providers[OLLAMA_PROVIDER] ??= { ...providerBase, models: [] };\n\tconst provider = json.providers[OLLAMA_PROVIDER];\n\tprovider.baseUrl ??= providerBase.baseUrl;\n\tprovider.api ??= providerBase.api;\n\tprovider.apiKey ??= providerBase.apiKey;\n\tprovider.models ??= [];\n\tconst existing = provider.models.findIndex((model) => model.id === args.ref);\n\tif (existing >= 0) {\n\t\tprovider.models[existing] = { ...provider.models[existing], ...entry };\n\t} else {\n\t\tprovider.models.push(entry);\n\t}\n\twriteFileSync(modelsJsonPath, `${JSON.stringify(json, null, \"\\t\")}\\n`, \"utf-8\");\n\treturn { ok: true, modelsJsonPath };\n}\n\nexport function unregisterLocalModel(args: { agentDir: string; ref: string }): LocalRegistrationResult {\n\tconst modelsJsonPath = join(args.agentDir, \"models.json\");\n\tconst { json, reason } = loadStrict(modelsJsonPath);\n\tif (!json) return { ok: false, modelsJsonPath, reason };\n\tconst provider = json.providers[OLLAMA_PROVIDER];\n\tif (!provider?.models) return { ok: true, modelsJsonPath };\n\tconst before = provider.models.length;\n\tprovider.models = provider.models.filter((model) => model.id !== args.ref);\n\tif (provider.models.length === before) return { ok: true, modelsJsonPath };\n\t// Drop the whole provider entry when its last pi-registered model goes (leave user fields alone\n\t// if they added any models themselves — only an all-pi-managed empty list is removed).\n\tif (provider.models.length === 0) {\n\t\tdelete json.providers[OLLAMA_PROVIDER];\n\t}\n\twriteFileSync(modelsJsonPath, `${JSON.stringify(json, null, \"\\t\")}\\n`, \"utf-8\");\n\treturn { ok: true, modelsJsonPath };\n}\n"]}
@@ -0,0 +1,83 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const OLLAMA_PROVIDER = "ollama";
4
+ function localModelEntry(ref, contextWindow) {
5
+ return {
6
+ id: ref,
7
+ name: ref,
8
+ contextWindow,
9
+ maxTokens: 2048,
10
+ reasoning: false,
11
+ input: ["text"],
12
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
13
+ };
14
+ }
15
+ function loadStrict(path) {
16
+ if (!existsSync(path))
17
+ return { json: { providers: {} } };
18
+ try {
19
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
20
+ if (!parsed || typeof parsed !== "object")
21
+ return { reason: "models.json is not a JSON object" };
22
+ parsed.providers = parsed.providers ?? {};
23
+ return { json: parsed };
24
+ }
25
+ catch {
26
+ return { reason: "models.json uses comments/relaxed JSON — pi will not rewrite a hand-authored file" };
27
+ }
28
+ }
29
+ export function registerLocalModel(args) {
30
+ const modelsJsonPath = join(args.agentDir, "models.json");
31
+ const contextWindow = args.contextWindow ?? 8192;
32
+ const entry = localModelEntry(args.ref, contextWindow);
33
+ const providerBase = {
34
+ baseUrl: `${args.baseUrl.replace(/\/$/, "")}/v1`,
35
+ api: "openai-completions",
36
+ apiKey: "ollama",
37
+ };
38
+ const { json, reason } = loadStrict(modelsJsonPath);
39
+ if (!json) {
40
+ return {
41
+ ok: false,
42
+ modelsJsonPath,
43
+ reason,
44
+ manualSnippet: JSON.stringify({ providers: { [OLLAMA_PROVIDER]: { ...providerBase, models: [entry] } } }, null, "\t"),
45
+ };
46
+ }
47
+ json.providers[OLLAMA_PROVIDER] ??= { ...providerBase, models: [] };
48
+ const provider = json.providers[OLLAMA_PROVIDER];
49
+ provider.baseUrl ??= providerBase.baseUrl;
50
+ provider.api ??= providerBase.api;
51
+ provider.apiKey ??= providerBase.apiKey;
52
+ provider.models ??= [];
53
+ const existing = provider.models.findIndex((model) => model.id === args.ref);
54
+ if (existing >= 0) {
55
+ provider.models[existing] = { ...provider.models[existing], ...entry };
56
+ }
57
+ else {
58
+ provider.models.push(entry);
59
+ }
60
+ writeFileSync(modelsJsonPath, `${JSON.stringify(json, null, "\t")}\n`, "utf-8");
61
+ return { ok: true, modelsJsonPath };
62
+ }
63
+ export function unregisterLocalModel(args) {
64
+ const modelsJsonPath = join(args.agentDir, "models.json");
65
+ const { json, reason } = loadStrict(modelsJsonPath);
66
+ if (!json)
67
+ return { ok: false, modelsJsonPath, reason };
68
+ const provider = json.providers[OLLAMA_PROVIDER];
69
+ if (!provider?.models)
70
+ return { ok: true, modelsJsonPath };
71
+ const before = provider.models.length;
72
+ provider.models = provider.models.filter((model) => model.id !== args.ref);
73
+ if (provider.models.length === before)
74
+ return { ok: true, modelsJsonPath };
75
+ // Drop the whole provider entry when its last pi-registered model goes (leave user fields alone
76
+ // if they added any models themselves — only an all-pi-managed empty list is removed).
77
+ if (provider.models.length === 0) {
78
+ delete json.providers[OLLAMA_PROVIDER];
79
+ }
80
+ writeFileSync(modelsJsonPath, `${JSON.stringify(json, null, "\t")}\n`, "utf-8");
81
+ return { ok: true, modelsJsonPath };
82
+ }
83
+ //# sourceMappingURL=local-registration.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-registration.js","sourceRoot":"","sources":["../../../src/core/models/local-registration.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\n/**\n * Persistent registration for pulled local models: merges an \"ollama\" provider entry into the\n * user's `<agentDir>/models.json` — the exact file ModelRegistry loads at startup — so a pulled\n * model resolves as `ollama/<ref>` immediately AND across sessions (usable as session model,\n * lane model, judge, or curator).\n *\n * Non-destructive contract: the file is parsed with STRICT JSON first; a file that only parses\n * with comments/relaxed syntax is the user's hand-authored config and is never rewritten — the\n * caller gets `manualSnippet` to show instead.\n */\n\ninterface ModelsJsonModel {\n\tid: string;\n\tname?: string;\n\tcontextWindow?: number;\n\tmaxTokens?: number;\n\treasoning?: boolean;\n\tinput?: string[];\n\tcost?: { input: number; output: number; cacheRead: number; cacheWrite: number };\n}\n\ninterface ModelsJson {\n\tproviders: Record<\n\t\tstring,\n\t\t{\n\t\t\tbaseUrl?: string;\n\t\t\tapi?: string;\n\t\t\tapiKey?: string;\n\t\t\tmodels?: ModelsJsonModel[];\n\t\t\t[key: string]: unknown;\n\t\t}\n\t>;\n\t[key: string]: unknown;\n}\n\nexport interface LocalRegistrationResult {\n\tok: boolean;\n\tmodelsJsonPath: string;\n\treason?: string;\n\t/** When the file cannot be safely rewritten: the entry the user should add by hand. */\n\tmanualSnippet?: string;\n}\n\nconst OLLAMA_PROVIDER = \"ollama\";\n\nfunction localModelEntry(ref: string, contextWindow: number): ModelsJsonModel {\n\treturn {\n\t\tid: ref,\n\t\tname: ref,\n\t\tcontextWindow,\n\t\tmaxTokens: 2048,\n\t\treasoning: false,\n\t\tinput: [\"text\"],\n\t\tcost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t};\n}\n\nfunction loadStrict(path: string): { json?: ModelsJson; reason?: string } {\n\tif (!existsSync(path)) return { json: { providers: {} } };\n\ttry {\n\t\tconst parsed = JSON.parse(readFileSync(path, \"utf-8\")) as ModelsJson;\n\t\tif (!parsed || typeof parsed !== \"object\") return { reason: \"models.json is not a JSON object\" };\n\t\tparsed.providers = parsed.providers ?? {};\n\t\treturn { json: parsed };\n\t} catch {\n\t\treturn { reason: \"models.json uses comments/relaxed JSON — pi will not rewrite a hand-authored file\" };\n\t}\n}\n\nexport function registerLocalModel(args: {\n\tagentDir: string;\n\tref: string;\n\tbaseUrl: string;\n\tcontextWindow?: number;\n}): LocalRegistrationResult {\n\tconst modelsJsonPath = join(args.agentDir, \"models.json\");\n\tconst contextWindow = args.contextWindow ?? 8192;\n\tconst entry = localModelEntry(args.ref, contextWindow);\n\tconst providerBase = {\n\t\tbaseUrl: `${args.baseUrl.replace(/\\/$/, \"\")}/v1`,\n\t\tapi: \"openai-completions\",\n\t\tapiKey: \"ollama\",\n\t};\n\tconst { json, reason } = loadStrict(modelsJsonPath);\n\tif (!json) {\n\t\treturn {\n\t\t\tok: false,\n\t\t\tmodelsJsonPath,\n\t\t\treason,\n\t\t\tmanualSnippet: JSON.stringify(\n\t\t\t\t{ providers: { [OLLAMA_PROVIDER]: { ...providerBase, models: [entry] } } },\n\t\t\t\tnull,\n\t\t\t\t\"\\t\",\n\t\t\t),\n\t\t};\n\t}\n\tjson.providers[OLLAMA_PROVIDER] ??= { ...providerBase, models: [] };\n\tconst provider = json.providers[OLLAMA_PROVIDER];\n\tprovider.baseUrl ??= providerBase.baseUrl;\n\tprovider.api ??= providerBase.api;\n\tprovider.apiKey ??= providerBase.apiKey;\n\tprovider.models ??= [];\n\tconst existing = provider.models.findIndex((model) => model.id === args.ref);\n\tif (existing >= 0) {\n\t\tprovider.models[existing] = { ...provider.models[existing], ...entry };\n\t} else {\n\t\tprovider.models.push(entry);\n\t}\n\twriteFileSync(modelsJsonPath, `${JSON.stringify(json, null, \"\\t\")}\\n`, \"utf-8\");\n\treturn { ok: true, modelsJsonPath };\n}\n\nexport function unregisterLocalModel(args: { agentDir: string; ref: string }): LocalRegistrationResult {\n\tconst modelsJsonPath = join(args.agentDir, \"models.json\");\n\tconst { json, reason } = loadStrict(modelsJsonPath);\n\tif (!json) return { ok: false, modelsJsonPath, reason };\n\tconst provider = json.providers[OLLAMA_PROVIDER];\n\tif (!provider?.models) return { ok: true, modelsJsonPath };\n\tconst before = provider.models.length;\n\tprovider.models = provider.models.filter((model) => model.id !== args.ref);\n\tif (provider.models.length === before) return { ok: true, modelsJsonPath };\n\t// Drop the whole provider entry when its last pi-registered model goes (leave user fields alone\n\t// if they added any models themselves — only an all-pi-managed empty list is removed).\n\tif (provider.models.length === 0) {\n\t\tdelete json.providers[OLLAMA_PROVIDER];\n\t}\n\twriteFileSync(modelsJsonPath, `${JSON.stringify(json, null, \"\\t\")}\\n`, \"utf-8\");\n\treturn { ok: true, modelsJsonPath };\n}\n"]}
@@ -0,0 +1,84 @@
1
+ import { type ChildProcess } from "node:child_process";
2
+ /**
3
+ * Local model runtime manager (local-model-lifecycle-design.md): Ollama first, interface kept
4
+ * runtime-agnostic. Pi SPAWNS the serve process itself with OWNED model storage
5
+ * (`OLLAMA_MODELS=<agentDir>/models/ollama`) so every downloaded weight lives inside pi's tree —
6
+ * per-model disk accounting is trivial and full cleanup is one directory. If a system server is
7
+ * already running, pi uses it instead of double-installing, with the honest tradeoff surfaced:
8
+ * storage then lives in the system daemon's dir and removal control is limited to `ollama rm`.
9
+ *
10
+ * Hard boundaries (design "Hard boundaries"): lifecycle actions are USER commands only — this
11
+ * module is never exposed as a model-invokable tool; install is GUIDE MODE (exact manual steps,
12
+ * never `curl | sh`); removal is explicit, disclosed, and never automatic.
13
+ */
14
+ export interface LocalRuntimeStatus {
15
+ binaryPath?: string;
16
+ binarySource?: "system" | "user" | "pi-owned";
17
+ serverUp: boolean;
18
+ serverUrl: string;
19
+ /** True when the responding server is a child process pi spawned (owned storage applies). */
20
+ managedByPi: boolean;
21
+ /** Owned weights directory (only meaningful for pi-managed serves). */
22
+ ownedModelsDir: string;
23
+ }
24
+ export interface InstalledLocalModel {
25
+ name: string;
26
+ sizeBytes: number;
27
+ }
28
+ export interface LocalRuntimeDeps {
29
+ fetchFn?: typeof fetch;
30
+ spawnFn?: (command: string, args: string[], options: {
31
+ env: NodeJS.ProcessEnv;
32
+ }) => Pick<ChildProcess, "pid" | "kill" | "unref" | "on">;
33
+ existsFn?: (path: string) => boolean;
34
+ envPath?: string;
35
+ homeDir?: string;
36
+ sleepFn?: (ms: number) => Promise<void>;
37
+ }
38
+ export declare class OllamaRuntime {
39
+ private readonly _agentDir;
40
+ private readonly _baseUrl;
41
+ private readonly _fetch;
42
+ private readonly _spawn;
43
+ private readonly _exists;
44
+ private readonly _envPath;
45
+ private readonly _homeDir;
46
+ private readonly _sleep;
47
+ private _child;
48
+ constructor(args: {
49
+ agentDir: string;
50
+ baseUrl?: string;
51
+ deps?: LocalRuntimeDeps;
52
+ });
53
+ get baseUrl(): string;
54
+ ownedModelsDir(): string;
55
+ private _findBinary;
56
+ private _serverUp;
57
+ detect(): Promise<LocalRuntimeStatus>;
58
+ /** GUIDE MODE: exact manual steps, printed, never executed (no `curl | sh`, no sudo). */
59
+ installGuide(): string[];
60
+ /**
61
+ * Start a pi-managed serve with OWNED storage and the hardened env verified on this class of
62
+ * hardware. No-op (reported) when a server already responds — pi never double-serves.
63
+ */
64
+ start(): Promise<{
65
+ started: boolean;
66
+ reason: string;
67
+ }>;
68
+ /** Resource hygiene only: stops the pi-managed serve process; never deletes anything. */
69
+ stop(): {
70
+ stopped: boolean;
71
+ };
72
+ list(): Promise<InstalledLocalModel[]>;
73
+ /** Pull a model through the server API (weights land in the SERVER's models dir). */
74
+ pull(ref: string, onProgress?: (status: string) => void): Promise<{
75
+ ok: boolean;
76
+ error?: string;
77
+ }>;
78
+ /** EXPLICIT user action only — callers must have shown what gets deleted and confirmed. */
79
+ remove(ref: string): Promise<{
80
+ ok: boolean;
81
+ error?: string;
82
+ }>;
83
+ }
84
+ //# sourceMappingURL=local-runtime.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-runtime.d.ts","sourceRoot":"","sources":["../../../src/core/models/local-runtime.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,YAAY,EAAS,MAAM,oBAAoB,CAAC;AAK9D;;;;;;;;;;;GAWG;AAEH,MAAM,WAAW,kBAAkB;IAClC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,QAAQ,GAAG,MAAM,GAAG,UAAU,CAAC;IAC9C,QAAQ,EAAE,OAAO,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,6FAA6F;IAC7F,WAAW,EAAE,OAAO,CAAC;IACrB,uEAAuE;IACvE,cAAc,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,mBAAmB;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,gBAAgB;IAChC,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;IACvB,OAAO,CAAC,EAAE,CACT,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC,UAAU,CAAA;KAAE,KAC/B,IAAI,CAAC,YAAY,EAAE,KAAK,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,CAAC,CAAC;IACzD,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC;AAOD,qBAAa,aAAa;IACzB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAe;IACtC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA2C;IAClE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA4B;IACpD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAgC;IACvD,OAAO,CAAC,MAAM,CAAkE;IAEhF,YAAY,IAAI,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,gBAAgB,CAAA;KAAE,EAShF;IAED,IAAI,OAAO,IAAI,MAAM,CAEpB;IAED,cAAc,IAAI,MAAM,CAEvB;IAED,OAAO,CAAC,WAAW;YAaL,SAAS;IAWjB,MAAM,IAAI,OAAO,CAAC,kBAAkB,CAAC,CAW1C;IAED,yFAAyF;IACzF,YAAY,IAAI,MAAM,EAAE,CASvB;IAED;;;OAGG;IACG,KAAK,IAAI,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CA0B3D;IAED,yFAAyF;IACzF,IAAI,IAAI;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,CAS3B;IAEK,IAAI,IAAI,OAAO,CAAC,mBAAmB,EAAE,CAAC,CAO3C;IAED,qFAAqF;IAC/E,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAiDvG;IAED,6FAA2F;IACrF,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAiBlE;CACD","sourcesContent":["import { type ChildProcess, spawn } from \"node:child_process\";\nimport { existsSync, mkdirSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { delimiter, join } from \"node:path\";\n\n/**\n * Local model runtime manager (local-model-lifecycle-design.md): Ollama first, interface kept\n * runtime-agnostic. Pi SPAWNS the serve process itself with OWNED model storage\n * (`OLLAMA_MODELS=<agentDir>/models/ollama`) so every downloaded weight lives inside pi's tree —\n * per-model disk accounting is trivial and full cleanup is one directory. If a system server is\n * already running, pi uses it instead of double-installing, with the honest tradeoff surfaced:\n * storage then lives in the system daemon's dir and removal control is limited to `ollama rm`.\n *\n * Hard boundaries (design \"Hard boundaries\"): lifecycle actions are USER commands only — this\n * module is never exposed as a model-invokable tool; install is GUIDE MODE (exact manual steps,\n * never `curl | sh`); removal is explicit, disclosed, and never automatic.\n */\n\nexport interface LocalRuntimeStatus {\n\tbinaryPath?: string;\n\tbinarySource?: \"system\" | \"user\" | \"pi-owned\";\n\tserverUp: boolean;\n\tserverUrl: string;\n\t/** True when the responding server is a child process pi spawned (owned storage applies). */\n\tmanagedByPi: boolean;\n\t/** Owned weights directory (only meaningful for pi-managed serves). */\n\townedModelsDir: string;\n}\n\nexport interface InstalledLocalModel {\n\tname: string;\n\tsizeBytes: number;\n}\n\nexport interface LocalRuntimeDeps {\n\tfetchFn?: typeof fetch;\n\tspawnFn?: (\n\t\tcommand: string,\n\t\targs: string[],\n\t\toptions: { env: NodeJS.ProcessEnv },\n\t) => Pick<ChildProcess, \"pid\" | \"kill\" | \"unref\" | \"on\">;\n\texistsFn?: (path: string) => boolean;\n\tenvPath?: string;\n\thomeDir?: string;\n\tsleepFn?: (ms: number) => Promise<void>;\n}\n\nconst DEFAULT_BASE_URL = \"http://127.0.0.1:11434\";\nconst HEALTH_TIMEOUT_MS = 2_000;\nconst START_POLL_ATTEMPTS = 20;\nconst START_POLL_INTERVAL_MS = 500;\n\nexport class OllamaRuntime {\n\tprivate readonly _agentDir: string;\n\tprivate readonly _baseUrl: string;\n\tprivate readonly _fetch: typeof fetch;\n\tprivate readonly _spawn: NonNullable<LocalRuntimeDeps[\"spawnFn\"]>;\n\tprivate readonly _exists: (path: string) => boolean;\n\tprivate readonly _envPath: string;\n\tprivate readonly _homeDir: string;\n\tprivate readonly _sleep: (ms: number) => Promise<void>;\n\tprivate _child: Pick<ChildProcess, \"pid\" | \"kill\" | \"unref\" | \"on\"> | undefined;\n\n\tconstructor(args: { agentDir: string; baseUrl?: string; deps?: LocalRuntimeDeps }) {\n\t\tthis._agentDir = args.agentDir;\n\t\tthis._baseUrl = (args.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/$/, \"\");\n\t\tthis._fetch = args.deps?.fetchFn ?? fetch;\n\t\tthis._spawn = args.deps?.spawnFn ?? ((command, argv, options) => spawn(command, argv, options));\n\t\tthis._exists = args.deps?.existsFn ?? existsSync;\n\t\tthis._envPath = args.deps?.envPath ?? process.env.PATH ?? \"\";\n\t\tthis._homeDir = args.deps?.homeDir ?? homedir();\n\t\tthis._sleep = args.deps?.sleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));\n\t}\n\n\tget baseUrl(): string {\n\t\treturn this._baseUrl;\n\t}\n\n\townedModelsDir(): string {\n\t\treturn join(this._agentDir, \"models\", \"ollama\");\n\t}\n\n\tprivate _findBinary(): { path: string; source: \"system\" | \"user\" | \"pi-owned\" } | undefined {\n\t\tconst piOwned = join(this._agentDir, \"runtimes\", \"ollama\", \"bin\", \"ollama\");\n\t\tif (this._exists(piOwned)) return { path: piOwned, source: \"pi-owned\" };\n\t\tconst userLevel = join(this._homeDir, \".local\", \"share\", \"ollama-dist\", \"bin\", \"ollama\");\n\t\tif (this._exists(userLevel)) return { path: userLevel, source: \"user\" };\n\t\tfor (const dir of this._envPath.split(delimiter)) {\n\t\t\tif (!dir) continue;\n\t\t\tconst candidate = join(dir, \"ollama\");\n\t\t\tif (this._exists(candidate)) return { path: candidate, source: \"system\" };\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tprivate async _serverUp(): Promise<boolean> {\n\t\ttry {\n\t\t\tconst response = await this._fetch(`${this._baseUrl}/api/tags`, {\n\t\t\t\tsignal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),\n\t\t\t});\n\t\t\treturn response.ok;\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tasync detect(): Promise<LocalRuntimeStatus> {\n\t\tconst binary = this._findBinary();\n\t\tconst serverUp = await this._serverUp();\n\t\treturn {\n\t\t\tbinaryPath: binary?.path,\n\t\t\tbinarySource: binary?.source,\n\t\t\tserverUp,\n\t\t\tserverUrl: this._baseUrl,\n\t\t\tmanagedByPi: this._child !== undefined && serverUp,\n\t\t\townedModelsDir: this.ownedModelsDir(),\n\t\t};\n\t}\n\n\t/** GUIDE MODE: exact manual steps, printed, never executed (no `curl | sh`, no sudo). */\n\tinstallGuide(): string[] {\n\t\treturn [\n\t\t\t\"Ollama is not installed. Pi never runs installers itself — manual steps (user-level, no sudo):\",\n\t\t\t\" 1. Download the pinned release archive for your platform:\",\n\t\t\t\" https://github.com/ollama/ollama/releases (asset: ollama-linux-amd64.tar.zst or your platform)\",\n\t\t\t` 2. Extract it to ${join(this._homeDir, \".local\", \"share\", \"ollama-dist\")}`,\n\t\t\t\" 3. Re-run your /models command - pi detects the binary automatically.\",\n\t\t\t\"Alternatively install system-wide from https://ollama.com/download and pi will use the system server.\",\n\t\t];\n\t}\n\n\t/**\n\t * Start a pi-managed serve with OWNED storage and the hardened env verified on this class of\n\t * hardware. No-op (reported) when a server already responds — pi never double-serves.\n\t */\n\tasync start(): Promise<{ started: boolean; reason: string }> {\n\t\tif (await this._serverUp()) {\n\t\t\treturn { started: false, reason: this._child ? \"already_running_managed\" : \"already_running_system\" };\n\t\t}\n\t\tconst binary = this._findBinary();\n\t\tif (!binary) return { started: false, reason: \"binary_missing\" };\n\t\tmkdirSync(this.ownedModelsDir(), { recursive: true });\n\t\tconst host = this._baseUrl.replace(/^https?:\\/\\//, \"\");\n\t\tthis._child = this._spawn(binary.path, [\"serve\"], {\n\t\t\tenv: {\n\t\t\t\t...process.env,\n\t\t\t\tOLLAMA_HOST: host,\n\t\t\t\tOLLAMA_MODELS: this.ownedModelsDir(),\n\t\t\t\tOLLAMA_FLASH_ATTENTION: \"1\",\n\t\t\t\tOLLAMA_KV_CACHE_TYPE: \"q8_0\",\n\t\t\t\tOLLAMA_NUM_PARALLEL: \"1\",\n\t\t\t\tOLLAMA_MAX_LOADED_MODELS: \"3\",\n\t\t\t},\n\t\t});\n\t\tthis._child.unref?.();\n\t\tfor (let attempt = 0; attempt < START_POLL_ATTEMPTS; attempt++) {\n\t\t\tif (await this._serverUp()) return { started: true, reason: \"started\" };\n\t\t\tawait this._sleep(START_POLL_INTERVAL_MS);\n\t\t}\n\t\tthis.stop();\n\t\treturn { started: false, reason: \"health_check_timeout\" };\n\t}\n\n\t/** Resource hygiene only: stops the pi-managed serve process; never deletes anything. */\n\tstop(): { stopped: boolean } {\n\t\tif (!this._child) return { stopped: false };\n\t\ttry {\n\t\t\tthis._child.kill(\"SIGTERM\");\n\t\t} catch {\n\t\t\t// already gone\n\t\t}\n\t\tthis._child = undefined;\n\t\treturn { stopped: true };\n\t}\n\n\tasync list(): Promise<InstalledLocalModel[]> {\n\t\tconst response = await this._fetch(`${this._baseUrl}/api/tags`, { signal: AbortSignal.timeout(10_000) });\n\t\tif (!response.ok) throw new Error(`ollama list failed: HTTP ${response.status}`);\n\t\tconst data = (await response.json()) as { models?: Array<{ name?: string; size?: number }> };\n\t\treturn (data.models ?? [])\n\t\t\t.filter((model): model is { name: string; size?: number } => typeof model.name === \"string\")\n\t\t\t.map((model) => ({ name: model.name, sizeBytes: model.size ?? 0 }));\n\t}\n\n\t/** Pull a model through the server API (weights land in the SERVER's models dir). */\n\tasync pull(ref: string, onProgress?: (status: string) => void): Promise<{ ok: boolean; error?: string }> {\n\t\ttry {\n\t\t\tconst response = await this._fetch(`${this._baseUrl}/api/pull`, {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\theaders: { \"content-type\": \"application/json\" },\n\t\t\t\tbody: JSON.stringify({ model: ref, stream: true }),\n\t\t\t});\n\t\t\tif (!response.ok || !response.body) {\n\t\t\t\treturn {\n\t\t\t\t\tok: false,\n\t\t\t\t\terror: `pull failed: HTTP ${response.status} ${await response.text().catch(() => \"\")}`,\n\t\t\t\t};\n\t\t\t}\n\t\t\tconst reader = response.body.getReader();\n\t\t\tconst decoder = new TextDecoder();\n\t\t\tlet buffer = \"\";\n\t\t\tlet lastStatus = \"\";\n\t\t\tlet errorMessage: string | undefined;\n\t\t\tconst handleLine = (line: string): void => {\n\t\t\t\tif (!line.trim()) return;\n\t\t\t\ttry {\n\t\t\t\t\tconst event = JSON.parse(line) as { status?: string; error?: string };\n\t\t\t\t\tif (event.error) errorMessage = event.error;\n\t\t\t\t\tif (event.status && event.status !== lastStatus) {\n\t\t\t\t\t\tlastStatus = event.status;\n\t\t\t\t\t\tonProgress?.(event.status);\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\t// partial line noise\n\t\t\t\t}\n\t\t\t};\n\t\t\tfor (;;) {\n\t\t\t\tconst { done, value } = await reader.read();\n\t\t\t\tif (done) break;\n\t\t\t\tbuffer += decoder.decode(value, { stream: true });\n\t\t\t\tconst lines = buffer.split(\"\\n\");\n\t\t\t\tbuffer = lines.pop() ?? \"\";\n\t\t\t\tfor (const line of lines) handleLine(line);\n\t\t\t}\n\t\t\t// The stream's FINAL line has no trailing newline — it stays in the buffer and holds\n\t\t\t// the terminal \"success\"/error event; dropping it misreports every completed pull.\n\t\t\thandleLine(buffer);\n\t\t\tif (errorMessage) return { ok: false, error: errorMessage };\n\t\t\treturn lastStatus === \"success\"\n\t\t\t\t? { ok: true }\n\t\t\t\t: { ok: false, error: `pull ended with: ${lastStatus || \"unknown\"}` };\n\t\t} catch (error) {\n\t\t\treturn { ok: false, error: error instanceof Error ? error.message : String(error) };\n\t\t}\n\t}\n\n\t/** EXPLICIT user action only — callers must have shown what gets deleted and confirmed. */\n\tasync remove(ref: string): Promise<{ ok: boolean; error?: string }> {\n\t\ttry {\n\t\t\tconst response = await this._fetch(`${this._baseUrl}/api/delete`, {\n\t\t\t\tmethod: \"DELETE\",\n\t\t\t\theaders: { \"content-type\": \"application/json\" },\n\t\t\t\tbody: JSON.stringify({ model: ref }),\n\t\t\t});\n\t\t\tif (!response.ok) {\n\t\t\t\treturn {\n\t\t\t\t\tok: false,\n\t\t\t\t\terror: `delete failed: HTTP ${response.status} ${await response.text().catch(() => \"\")}`,\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn { ok: true };\n\t\t} catch (error) {\n\t\t\treturn { ok: false, error: error instanceof Error ? error.message : String(error) };\n\t\t}\n\t}\n}\n"]}
@@ -0,0 +1,219 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { delimiter, join } from "node:path";
5
+ const DEFAULT_BASE_URL = "http://127.0.0.1:11434";
6
+ const HEALTH_TIMEOUT_MS = 2_000;
7
+ const START_POLL_ATTEMPTS = 20;
8
+ const START_POLL_INTERVAL_MS = 500;
9
+ export class OllamaRuntime {
10
+ _agentDir;
11
+ _baseUrl;
12
+ _fetch;
13
+ _spawn;
14
+ _exists;
15
+ _envPath;
16
+ _homeDir;
17
+ _sleep;
18
+ _child;
19
+ constructor(args) {
20
+ this._agentDir = args.agentDir;
21
+ this._baseUrl = (args.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
22
+ this._fetch = args.deps?.fetchFn ?? fetch;
23
+ this._spawn = args.deps?.spawnFn ?? ((command, argv, options) => spawn(command, argv, options));
24
+ this._exists = args.deps?.existsFn ?? existsSync;
25
+ this._envPath = args.deps?.envPath ?? process.env.PATH ?? "";
26
+ this._homeDir = args.deps?.homeDir ?? homedir();
27
+ this._sleep = args.deps?.sleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
28
+ }
29
+ get baseUrl() {
30
+ return this._baseUrl;
31
+ }
32
+ ownedModelsDir() {
33
+ return join(this._agentDir, "models", "ollama");
34
+ }
35
+ _findBinary() {
36
+ const piOwned = join(this._agentDir, "runtimes", "ollama", "bin", "ollama");
37
+ if (this._exists(piOwned))
38
+ return { path: piOwned, source: "pi-owned" };
39
+ const userLevel = join(this._homeDir, ".local", "share", "ollama-dist", "bin", "ollama");
40
+ if (this._exists(userLevel))
41
+ return { path: userLevel, source: "user" };
42
+ for (const dir of this._envPath.split(delimiter)) {
43
+ if (!dir)
44
+ continue;
45
+ const candidate = join(dir, "ollama");
46
+ if (this._exists(candidate))
47
+ return { path: candidate, source: "system" };
48
+ }
49
+ return undefined;
50
+ }
51
+ async _serverUp() {
52
+ try {
53
+ const response = await this._fetch(`${this._baseUrl}/api/tags`, {
54
+ signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
55
+ });
56
+ return response.ok;
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
62
+ async detect() {
63
+ const binary = this._findBinary();
64
+ const serverUp = await this._serverUp();
65
+ return {
66
+ binaryPath: binary?.path,
67
+ binarySource: binary?.source,
68
+ serverUp,
69
+ serverUrl: this._baseUrl,
70
+ managedByPi: this._child !== undefined && serverUp,
71
+ ownedModelsDir: this.ownedModelsDir(),
72
+ };
73
+ }
74
+ /** GUIDE MODE: exact manual steps, printed, never executed (no `curl | sh`, no sudo). */
75
+ installGuide() {
76
+ return [
77
+ "Ollama is not installed. Pi never runs installers itself — manual steps (user-level, no sudo):",
78
+ " 1. Download the pinned release archive for your platform:",
79
+ " https://github.com/ollama/ollama/releases (asset: ollama-linux-amd64.tar.zst or your platform)",
80
+ ` 2. Extract it to ${join(this._homeDir, ".local", "share", "ollama-dist")}`,
81
+ " 3. Re-run your /models command - pi detects the binary automatically.",
82
+ "Alternatively install system-wide from https://ollama.com/download and pi will use the system server.",
83
+ ];
84
+ }
85
+ /**
86
+ * Start a pi-managed serve with OWNED storage and the hardened env verified on this class of
87
+ * hardware. No-op (reported) when a server already responds — pi never double-serves.
88
+ */
89
+ async start() {
90
+ if (await this._serverUp()) {
91
+ return { started: false, reason: this._child ? "already_running_managed" : "already_running_system" };
92
+ }
93
+ const binary = this._findBinary();
94
+ if (!binary)
95
+ return { started: false, reason: "binary_missing" };
96
+ mkdirSync(this.ownedModelsDir(), { recursive: true });
97
+ const host = this._baseUrl.replace(/^https?:\/\//, "");
98
+ this._child = this._spawn(binary.path, ["serve"], {
99
+ env: {
100
+ ...process.env,
101
+ OLLAMA_HOST: host,
102
+ OLLAMA_MODELS: this.ownedModelsDir(),
103
+ OLLAMA_FLASH_ATTENTION: "1",
104
+ OLLAMA_KV_CACHE_TYPE: "q8_0",
105
+ OLLAMA_NUM_PARALLEL: "1",
106
+ OLLAMA_MAX_LOADED_MODELS: "3",
107
+ },
108
+ });
109
+ this._child.unref?.();
110
+ for (let attempt = 0; attempt < START_POLL_ATTEMPTS; attempt++) {
111
+ if (await this._serverUp())
112
+ return { started: true, reason: "started" };
113
+ await this._sleep(START_POLL_INTERVAL_MS);
114
+ }
115
+ this.stop();
116
+ return { started: false, reason: "health_check_timeout" };
117
+ }
118
+ /** Resource hygiene only: stops the pi-managed serve process; never deletes anything. */
119
+ stop() {
120
+ if (!this._child)
121
+ return { stopped: false };
122
+ try {
123
+ this._child.kill("SIGTERM");
124
+ }
125
+ catch {
126
+ // already gone
127
+ }
128
+ this._child = undefined;
129
+ return { stopped: true };
130
+ }
131
+ async list() {
132
+ const response = await this._fetch(`${this._baseUrl}/api/tags`, { signal: AbortSignal.timeout(10_000) });
133
+ if (!response.ok)
134
+ throw new Error(`ollama list failed: HTTP ${response.status}`);
135
+ const data = (await response.json());
136
+ return (data.models ?? [])
137
+ .filter((model) => typeof model.name === "string")
138
+ .map((model) => ({ name: model.name, sizeBytes: model.size ?? 0 }));
139
+ }
140
+ /** Pull a model through the server API (weights land in the SERVER's models dir). */
141
+ async pull(ref, onProgress) {
142
+ try {
143
+ const response = await this._fetch(`${this._baseUrl}/api/pull`, {
144
+ method: "POST",
145
+ headers: { "content-type": "application/json" },
146
+ body: JSON.stringify({ model: ref, stream: true }),
147
+ });
148
+ if (!response.ok || !response.body) {
149
+ return {
150
+ ok: false,
151
+ error: `pull failed: HTTP ${response.status} ${await response.text().catch(() => "")}`,
152
+ };
153
+ }
154
+ const reader = response.body.getReader();
155
+ const decoder = new TextDecoder();
156
+ let buffer = "";
157
+ let lastStatus = "";
158
+ let errorMessage;
159
+ const handleLine = (line) => {
160
+ if (!line.trim())
161
+ return;
162
+ try {
163
+ const event = JSON.parse(line);
164
+ if (event.error)
165
+ errorMessage = event.error;
166
+ if (event.status && event.status !== lastStatus) {
167
+ lastStatus = event.status;
168
+ onProgress?.(event.status);
169
+ }
170
+ }
171
+ catch {
172
+ // partial line noise
173
+ }
174
+ };
175
+ for (;;) {
176
+ const { done, value } = await reader.read();
177
+ if (done)
178
+ break;
179
+ buffer += decoder.decode(value, { stream: true });
180
+ const lines = buffer.split("\n");
181
+ buffer = lines.pop() ?? "";
182
+ for (const line of lines)
183
+ handleLine(line);
184
+ }
185
+ // The stream's FINAL line has no trailing newline — it stays in the buffer and holds
186
+ // the terminal "success"/error event; dropping it misreports every completed pull.
187
+ handleLine(buffer);
188
+ if (errorMessage)
189
+ return { ok: false, error: errorMessage };
190
+ return lastStatus === "success"
191
+ ? { ok: true }
192
+ : { ok: false, error: `pull ended with: ${lastStatus || "unknown"}` };
193
+ }
194
+ catch (error) {
195
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
196
+ }
197
+ }
198
+ /** EXPLICIT user action only — callers must have shown what gets deleted and confirmed. */
199
+ async remove(ref) {
200
+ try {
201
+ const response = await this._fetch(`${this._baseUrl}/api/delete`, {
202
+ method: "DELETE",
203
+ headers: { "content-type": "application/json" },
204
+ body: JSON.stringify({ model: ref }),
205
+ });
206
+ if (!response.ok) {
207
+ return {
208
+ ok: false,
209
+ error: `delete failed: HTTP ${response.status} ${await response.text().catch(() => "")}`,
210
+ };
211
+ }
212
+ return { ok: true };
213
+ }
214
+ catch (error) {
215
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
216
+ }
217
+ }
218
+ }
219
+ //# sourceMappingURL=local-runtime.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-runtime.js","sourceRoot":"","sources":["../../../src/core/models/local-runtime.ts"],"names":[],"mappings":"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","sourcesContent":["import { type ChildProcess, spawn } from \"node:child_process\";\nimport { existsSync, mkdirSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { delimiter, join } from \"node:path\";\n\n/**\n * Local model runtime manager (local-model-lifecycle-design.md): Ollama first, interface kept\n * runtime-agnostic. Pi SPAWNS the serve process itself with OWNED model storage\n * (`OLLAMA_MODELS=<agentDir>/models/ollama`) so every downloaded weight lives inside pi's tree —\n * per-model disk accounting is trivial and full cleanup is one directory. If a system server is\n * already running, pi uses it instead of double-installing, with the honest tradeoff surfaced:\n * storage then lives in the system daemon's dir and removal control is limited to `ollama rm`.\n *\n * Hard boundaries (design \"Hard boundaries\"): lifecycle actions are USER commands only — this\n * module is never exposed as a model-invokable tool; install is GUIDE MODE (exact manual steps,\n * never `curl | sh`); removal is explicit, disclosed, and never automatic.\n */\n\nexport interface LocalRuntimeStatus {\n\tbinaryPath?: string;\n\tbinarySource?: \"system\" | \"user\" | \"pi-owned\";\n\tserverUp: boolean;\n\tserverUrl: string;\n\t/** True when the responding server is a child process pi spawned (owned storage applies). */\n\tmanagedByPi: boolean;\n\t/** Owned weights directory (only meaningful for pi-managed serves). */\n\townedModelsDir: string;\n}\n\nexport interface InstalledLocalModel {\n\tname: string;\n\tsizeBytes: number;\n}\n\nexport interface LocalRuntimeDeps {\n\tfetchFn?: typeof fetch;\n\tspawnFn?: (\n\t\tcommand: string,\n\t\targs: string[],\n\t\toptions: { env: NodeJS.ProcessEnv },\n\t) => Pick<ChildProcess, \"pid\" | \"kill\" | \"unref\" | \"on\">;\n\texistsFn?: (path: string) => boolean;\n\tenvPath?: string;\n\thomeDir?: string;\n\tsleepFn?: (ms: number) => Promise<void>;\n}\n\nconst DEFAULT_BASE_URL = \"http://127.0.0.1:11434\";\nconst HEALTH_TIMEOUT_MS = 2_000;\nconst START_POLL_ATTEMPTS = 20;\nconst START_POLL_INTERVAL_MS = 500;\n\nexport class OllamaRuntime {\n\tprivate readonly _agentDir: string;\n\tprivate readonly _baseUrl: string;\n\tprivate readonly _fetch: typeof fetch;\n\tprivate readonly _spawn: NonNullable<LocalRuntimeDeps[\"spawnFn\"]>;\n\tprivate readonly _exists: (path: string) => boolean;\n\tprivate readonly _envPath: string;\n\tprivate readonly _homeDir: string;\n\tprivate readonly _sleep: (ms: number) => Promise<void>;\n\tprivate _child: Pick<ChildProcess, \"pid\" | \"kill\" | \"unref\" | \"on\"> | undefined;\n\n\tconstructor(args: { agentDir: string; baseUrl?: string; deps?: LocalRuntimeDeps }) {\n\t\tthis._agentDir = args.agentDir;\n\t\tthis._baseUrl = (args.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/$/, \"\");\n\t\tthis._fetch = args.deps?.fetchFn ?? fetch;\n\t\tthis._spawn = args.deps?.spawnFn ?? ((command, argv, options) => spawn(command, argv, options));\n\t\tthis._exists = args.deps?.existsFn ?? existsSync;\n\t\tthis._envPath = args.deps?.envPath ?? process.env.PATH ?? \"\";\n\t\tthis._homeDir = args.deps?.homeDir ?? homedir();\n\t\tthis._sleep = args.deps?.sleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));\n\t}\n\n\tget baseUrl(): string {\n\t\treturn this._baseUrl;\n\t}\n\n\townedModelsDir(): string {\n\t\treturn join(this._agentDir, \"models\", \"ollama\");\n\t}\n\n\tprivate _findBinary(): { path: string; source: \"system\" | \"user\" | \"pi-owned\" } | undefined {\n\t\tconst piOwned = join(this._agentDir, \"runtimes\", \"ollama\", \"bin\", \"ollama\");\n\t\tif (this._exists(piOwned)) return { path: piOwned, source: \"pi-owned\" };\n\t\tconst userLevel = join(this._homeDir, \".local\", \"share\", \"ollama-dist\", \"bin\", \"ollama\");\n\t\tif (this._exists(userLevel)) return { path: userLevel, source: \"user\" };\n\t\tfor (const dir of this._envPath.split(delimiter)) {\n\t\t\tif (!dir) continue;\n\t\t\tconst candidate = join(dir, \"ollama\");\n\t\t\tif (this._exists(candidate)) return { path: candidate, source: \"system\" };\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tprivate async _serverUp(): Promise<boolean> {\n\t\ttry {\n\t\t\tconst response = await this._fetch(`${this._baseUrl}/api/tags`, {\n\t\t\t\tsignal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),\n\t\t\t});\n\t\t\treturn response.ok;\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tasync detect(): Promise<LocalRuntimeStatus> {\n\t\tconst binary = this._findBinary();\n\t\tconst serverUp = await this._serverUp();\n\t\treturn {\n\t\t\tbinaryPath: binary?.path,\n\t\t\tbinarySource: binary?.source,\n\t\t\tserverUp,\n\t\t\tserverUrl: this._baseUrl,\n\t\t\tmanagedByPi: this._child !== undefined && serverUp,\n\t\t\townedModelsDir: this.ownedModelsDir(),\n\t\t};\n\t}\n\n\t/** GUIDE MODE: exact manual steps, printed, never executed (no `curl | sh`, no sudo). */\n\tinstallGuide(): string[] {\n\t\treturn [\n\t\t\t\"Ollama is not installed. Pi never runs installers itself — manual steps (user-level, no sudo):\",\n\t\t\t\" 1. Download the pinned release archive for your platform:\",\n\t\t\t\" https://github.com/ollama/ollama/releases (asset: ollama-linux-amd64.tar.zst or your platform)\",\n\t\t\t` 2. Extract it to ${join(this._homeDir, \".local\", \"share\", \"ollama-dist\")}`,\n\t\t\t\" 3. Re-run your /models command - pi detects the binary automatically.\",\n\t\t\t\"Alternatively install system-wide from https://ollama.com/download and pi will use the system server.\",\n\t\t];\n\t}\n\n\t/**\n\t * Start a pi-managed serve with OWNED storage and the hardened env verified on this class of\n\t * hardware. No-op (reported) when a server already responds — pi never double-serves.\n\t */\n\tasync start(): Promise<{ started: boolean; reason: string }> {\n\t\tif (await this._serverUp()) {\n\t\t\treturn { started: false, reason: this._child ? \"already_running_managed\" : \"already_running_system\" };\n\t\t}\n\t\tconst binary = this._findBinary();\n\t\tif (!binary) return { started: false, reason: \"binary_missing\" };\n\t\tmkdirSync(this.ownedModelsDir(), { recursive: true });\n\t\tconst host = this._baseUrl.replace(/^https?:\\/\\//, \"\");\n\t\tthis._child = this._spawn(binary.path, [\"serve\"], {\n\t\t\tenv: {\n\t\t\t\t...process.env,\n\t\t\t\tOLLAMA_HOST: host,\n\t\t\t\tOLLAMA_MODELS: this.ownedModelsDir(),\n\t\t\t\tOLLAMA_FLASH_ATTENTION: \"1\",\n\t\t\t\tOLLAMA_KV_CACHE_TYPE: \"q8_0\",\n\t\t\t\tOLLAMA_NUM_PARALLEL: \"1\",\n\t\t\t\tOLLAMA_MAX_LOADED_MODELS: \"3\",\n\t\t\t},\n\t\t});\n\t\tthis._child.unref?.();\n\t\tfor (let attempt = 0; attempt < START_POLL_ATTEMPTS; attempt++) {\n\t\t\tif (await this._serverUp()) return { started: true, reason: \"started\" };\n\t\t\tawait this._sleep(START_POLL_INTERVAL_MS);\n\t\t}\n\t\tthis.stop();\n\t\treturn { started: false, reason: \"health_check_timeout\" };\n\t}\n\n\t/** Resource hygiene only: stops the pi-managed serve process; never deletes anything. */\n\tstop(): { stopped: boolean } {\n\t\tif (!this._child) return { stopped: false };\n\t\ttry {\n\t\t\tthis._child.kill(\"SIGTERM\");\n\t\t} catch {\n\t\t\t// already gone\n\t\t}\n\t\tthis._child = undefined;\n\t\treturn { stopped: true };\n\t}\n\n\tasync list(): Promise<InstalledLocalModel[]> {\n\t\tconst response = await this._fetch(`${this._baseUrl}/api/tags`, { signal: AbortSignal.timeout(10_000) });\n\t\tif (!response.ok) throw new Error(`ollama list failed: HTTP ${response.status}`);\n\t\tconst data = (await response.json()) as { models?: Array<{ name?: string; size?: number }> };\n\t\treturn (data.models ?? [])\n\t\t\t.filter((model): model is { name: string; size?: number } => typeof model.name === \"string\")\n\t\t\t.map((model) => ({ name: model.name, sizeBytes: model.size ?? 0 }));\n\t}\n\n\t/** Pull a model through the server API (weights land in the SERVER's models dir). */\n\tasync pull(ref: string, onProgress?: (status: string) => void): Promise<{ ok: boolean; error?: string }> {\n\t\ttry {\n\t\t\tconst response = await this._fetch(`${this._baseUrl}/api/pull`, {\n\t\t\t\tmethod: \"POST\",\n\t\t\t\theaders: { \"content-type\": \"application/json\" },\n\t\t\t\tbody: JSON.stringify({ model: ref, stream: true }),\n\t\t\t});\n\t\t\tif (!response.ok || !response.body) {\n\t\t\t\treturn {\n\t\t\t\t\tok: false,\n\t\t\t\t\terror: `pull failed: HTTP ${response.status} ${await response.text().catch(() => \"\")}`,\n\t\t\t\t};\n\t\t\t}\n\t\t\tconst reader = response.body.getReader();\n\t\t\tconst decoder = new TextDecoder();\n\t\t\tlet buffer = \"\";\n\t\t\tlet lastStatus = \"\";\n\t\t\tlet errorMessage: string | undefined;\n\t\t\tconst handleLine = (line: string): void => {\n\t\t\t\tif (!line.trim()) return;\n\t\t\t\ttry {\n\t\t\t\t\tconst event = JSON.parse(line) as { status?: string; error?: string };\n\t\t\t\t\tif (event.error) errorMessage = event.error;\n\t\t\t\t\tif (event.status && event.status !== lastStatus) {\n\t\t\t\t\t\tlastStatus = event.status;\n\t\t\t\t\t\tonProgress?.(event.status);\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\t// partial line noise\n\t\t\t\t}\n\t\t\t};\n\t\t\tfor (;;) {\n\t\t\t\tconst { done, value } = await reader.read();\n\t\t\t\tif (done) break;\n\t\t\t\tbuffer += decoder.decode(value, { stream: true });\n\t\t\t\tconst lines = buffer.split(\"\\n\");\n\t\t\t\tbuffer = lines.pop() ?? \"\";\n\t\t\t\tfor (const line of lines) handleLine(line);\n\t\t\t}\n\t\t\t// The stream's FINAL line has no trailing newline — it stays in the buffer and holds\n\t\t\t// the terminal \"success\"/error event; dropping it misreports every completed pull.\n\t\t\thandleLine(buffer);\n\t\t\tif (errorMessage) return { ok: false, error: errorMessage };\n\t\t\treturn lastStatus === \"success\"\n\t\t\t\t? { ok: true }\n\t\t\t\t: { ok: false, error: `pull ended with: ${lastStatus || \"unknown\"}` };\n\t\t} catch (error) {\n\t\t\treturn { ok: false, error: error instanceof Error ? error.message : String(error) };\n\t\t}\n\t}\n\n\t/** EXPLICIT user action only — callers must have shown what gets deleted and confirmed. */\n\tasync remove(ref: string): Promise<{ ok: boolean; error?: string }> {\n\t\ttry {\n\t\t\tconst response = await this._fetch(`${this._baseUrl}/api/delete`, {\n\t\t\t\tmethod: \"DELETE\",\n\t\t\t\theaders: { \"content-type\": \"application/json\" },\n\t\t\t\tbody: JSON.stringify({ model: ref }),\n\t\t\t});\n\t\t\tif (!response.ok) {\n\t\t\t\treturn {\n\t\t\t\t\tok: false,\n\t\t\t\t\terror: `delete failed: HTTP ${response.status} ${await response.text().catch(() => \"\")}`,\n\t\t\t\t};\n\t\t\t}\n\t\t\treturn { ok: true };\n\t\t} catch (error) {\n\t\t\treturn { ok: false, error: error instanceof Error ? error.message : String(error) };\n\t\t}\n\t}\n}\n"]}