pipeline-moe 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of pipeline-moe might be problematic. Click here for more details.

Files changed (48) hide show
  1. package/.env.example +26 -0
  2. package/LICENSE +21 -0
  3. package/README.md +408 -0
  4. package/bin/pipeline-moe.mjs +40 -0
  5. package/package.json +68 -0
  6. package/presets/2106BUILD.json +163 -0
  7. package/presets/CHEAPBUILD.json +97 -0
  8. package/presets/FREEROOM.json +96 -0
  9. package/presets/Versa.json +145 -0
  10. package/presets/cloud-main.json +76 -0
  11. package/presets/cloud-sprint.json +70 -0
  12. package/presets/local-default.json +152 -0
  13. package/presets/main.json +147 -0
  14. package/presets/mainmix.json +145 -0
  15. package/src/circuit-breaker.ts +141 -0
  16. package/src/config.ts +46 -0
  17. package/src/custom-tools/arxiv-search.ts +186 -0
  18. package/src/custom-tools/check-room.ts +45 -0
  19. package/src/custom-tools/destroy-room.ts +45 -0
  20. package/src/custom-tools/index.ts +75 -0
  21. package/src/custom-tools/spawn-room.ts +99 -0
  22. package/src/custom-tools/stop-room.ts +50 -0
  23. package/src/custom-tools/web-read.ts +104 -0
  24. package/src/custom-tools/web-search.ts +144 -0
  25. package/src/custom-tools/youcom-search.ts +230 -0
  26. package/src/custom-tools/youtube-transcript.ts +124 -0
  27. package/src/local-model-lock.ts +52 -0
  28. package/src/model.ts +131 -0
  29. package/src/orchestrator.ts +59 -0
  30. package/src/participant.ts +423 -0
  31. package/src/path-guard.ts +13 -0
  32. package/src/personas.ts +480 -0
  33. package/src/receipts.ts +120 -0
  34. package/src/registry.ts +317 -0
  35. package/src/room-manager.ts +505 -0
  36. package/src/room.ts +1657 -0
  37. package/src/sandbox-tools.ts +141 -0
  38. package/src/server.ts +1846 -0
  39. package/src/sse.ts +86 -0
  40. package/src/sshfs.ts +139 -0
  41. package/src/store.ts +79 -0
  42. package/src/types.ts +131 -0
  43. package/src/validation.ts +36 -0
  44. package/tsconfig.json +15 -0
  45. package/web/dist/assets/index-CmMGhMKG.css +1 -0
  46. package/web/dist/assets/index-LAGqbZII.js +41 -0
  47. package/web/dist/index.html +13 -0
  48. package/web/tsconfig.json +21 -0
@@ -0,0 +1,144 @@
1
+ // web_search — SearXNG tool definition
2
+ // Wraps a SearXNG instance (SEARXNG_URL env var — self-hosted or public).
3
+ // Returns formatted results as text content.
4
+
5
+ import { Type } from "typebox"
6
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent"
7
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent"
8
+
9
+ // Minimal text content type (mirrors pi-ai TextContent — not re-exported).
10
+ interface TextContent {
11
+ type: "text"
12
+ text: string
13
+ }
14
+
15
+ const SearxngResult = Type.Object({
16
+ title: Type.String(),
17
+ url: Type.String(),
18
+ content: Type.Optional(Type.String()),
19
+ })
20
+
21
+ const SearxngResponse = Type.Object({
22
+ results: Type.Array(SearxngResult),
23
+ query: Type.Optional(Type.String()),
24
+ })
25
+
26
+ const webSearchSchema = Type.Object({
27
+ query: Type.String({
28
+ description: "The search query",
29
+ }),
30
+ limit: Type.Optional(
31
+ Type.Number({
32
+ default: 5,
33
+ minimum: 1,
34
+ maximum: 20,
35
+ description: "Maximum number of results to return (1-20, default 5)",
36
+ }),
37
+ ),
38
+ categories: Type.Optional(
39
+ Type.Array(Type.String(), {
40
+ description:
41
+ "SearXNG categories to search: general, it, science, news, images, videos, academic",
42
+ }),
43
+ ),
44
+ })
45
+
46
+ // Read per call so tests (and long-lived processes) see env changes.
47
+ const searxngUrl = () => process.env.SEARXNG_URL ?? ""
48
+ const TIMEOUT_MS = 15_000
49
+
50
+ async function searchSearxng(
51
+ query: string,
52
+ limit: number,
53
+ categories?: string[],
54
+ ): Promise<AgentToolResult<undefined>> {
55
+ const base = searxngUrl()
56
+ if (!base) {
57
+ return {
58
+ content: [{
59
+ type: "text",
60
+ text: "web_search error: SEARXNG_URL is not configured. Set it in .env to your SearXNG instance (self-hosted or public, JSON format enabled).",
61
+ }],
62
+ details: undefined,
63
+ }
64
+ }
65
+ const url = new URL("/search", base)
66
+ url.searchParams.set("q", query)
67
+ url.searchParams.set("format", "json")
68
+ url.searchParams.set("limit", String(limit))
69
+ if (categories && categories.length > 0) {
70
+ url.searchParams.set("categories", categories.join(","))
71
+ }
72
+
73
+ const controller = new AbortController()
74
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
75
+ try {
76
+ const response = await fetch(url.toString(), {
77
+ signal: controller.signal,
78
+ headers: { "Accept": "application/json" },
79
+ })
80
+
81
+ if (!response.ok) {
82
+ throw new Error(`SearXNG returned ${response.status} ${response.statusText}`)
83
+ }
84
+
85
+ const body = await response.json()
86
+ const results = (body as { results: Array<{ title: string; url: string; content?: string }> }).results ?? []
87
+
88
+ if (results.length === 0) {
89
+ return {
90
+ content: [{
91
+ type: "text",
92
+ text: `No results found for: "${query}"`,
93
+ }],
94
+ details: undefined,
95
+ }
96
+ }
97
+
98
+ // Format results as structured text.
99
+ const lines = [`Search results for "${query}" (${results.length} results):`]
100
+ for (let i = 0; i < results.length; i++) {
101
+ const r = results[i]
102
+ lines.push(`\n${i + 1}. ${r.title}`)
103
+ lines.push(` URL: ${r.url}`)
104
+ if (r.content) {
105
+ // Truncate long snippets to avoid context bloat.
106
+ const snippet = r.content.length > 200 ? r.content.slice(0, 200) + "…" : r.content
107
+ lines.push(` ${snippet}`)
108
+ }
109
+ }
110
+
111
+ return {
112
+ content: [{ type: "text", text: lines.join("\n") }],
113
+ details: undefined,
114
+ }
115
+ } catch (err) {
116
+ const msg = err instanceof Error ? err.message : String(err)
117
+ return {
118
+ content: [{
119
+ type: "text",
120
+ text: `web_search error: ${msg}. Ensure your SearXNG instance is reachable at ${base}.`,
121
+ }],
122
+ details: undefined,
123
+ }
124
+ } finally {
125
+ clearTimeout(timeout)
126
+ }
127
+ }
128
+
129
+ export function createWebSearchToolDefinition(): ToolDefinition<typeof webSearchSchema, undefined> {
130
+ return {
131
+ name: "web_search",
132
+ label: "Web Search",
133
+ description:
134
+ "Search the web via SearXNG. Returns titles, URLs, and snippets for each result. " +
135
+ "Use this to find current information, verify facts, or discover resources outside the workspace.",
136
+ parameters: webSearchSchema,
137
+ execute: async (_toolCallId, params) => {
138
+ const query = params.query
139
+ const limit = Math.min(params.limit ?? 5, 20)
140
+ const categories = params.categories
141
+ return searchSearxng(query, limit, categories)
142
+ },
143
+ }
144
+ }
@@ -0,0 +1,230 @@
1
+ // youcom_search — You.com enriched web search tool definition
2
+ // Two modes: "search" (fast snippets) and "research" (autonomous synthesis).
3
+ // API key from ~/.config/you.com/credentials.json — never exposed in results.
4
+
5
+ import { homedir } from "node:os"
6
+ import { join } from "node:path"
7
+ import { Type } from "typebox"
8
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent"
9
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent"
10
+
11
+ // Minimal text content type.
12
+ interface TextContent {
13
+ type: "text"
14
+ text: string
15
+ }
16
+
17
+ const youcomSearchSchema = Type.Object({
18
+ query: Type.String({
19
+ description: "Search query (supports operators: site:, filetype:, +term, -term)",
20
+ }),
21
+ mode: Type.Optional(
22
+ Type.Enum({ search: "search", research: "research" }, {
23
+ default: "search",
24
+ description: "search = fast snippets, research = autonomous synthesis with citations",
25
+ })
26
+ ),
27
+ count: Type.Optional(
28
+ Type.Integer({
29
+ minimum: 1,
30
+ maximum: 20,
31
+ default: 5,
32
+ description: "Number of results for search mode (1-20, default 5)",
33
+ })
34
+ ),
35
+ freshness: Type.Optional(
36
+ Type.Enum({ day: "day", week: "week", month: "month", year: "year" }, {
37
+ description: "Freshness filter for search mode",
38
+ })
39
+ ),
40
+ })
41
+
42
+ const YDC_API = "https://ydc-index.io"
43
+ const API_KEY_FILE = join(homedir(), ".config", "you.com", "credentials.json")
44
+ const TIMEOUT_SEARCH_MS = 15_000
45
+ const TIMEOUT_RESEARCH_MS = 60_000
46
+ const MAX_CONTENT_LENGTH = 8000
47
+
48
+ import { readFileSync } from "node:fs"
49
+
50
+ // Read API key from file — cached at module level after first read.
51
+ let apiKeyCache: { key: string | null; error: string | null } | null = null
52
+
53
+ function readApiKey(): { key: string | null; error: string | null } {
54
+ if (apiKeyCache) return apiKeyCache
55
+
56
+ try {
57
+ const raw = readFileSync(API_KEY_FILE, "utf-8")
58
+ const parsed = JSON.parse(raw) as { api_key?: string }
59
+ if (!parsed.api_key) {
60
+ apiKeyCache = { key: null, error: "No api_key field in credentials file" }
61
+ } else {
62
+ apiKeyCache = { key: parsed.api_key, error: null }
63
+ }
64
+ } catch (err) {
65
+ const msg = err instanceof Error ? err.message : String(err)
66
+ apiKeyCache = { key: null, error: `Failed to read ${API_KEY_FILE}: ${msg}` }
67
+ }
68
+
69
+ return apiKeyCache
70
+ }
71
+
72
+ async function searchMode(query: string, count: number, freshness?: string): Promise<AgentToolResult<undefined>> {
73
+ const { key, error } = readApiKey()
74
+ if (error) {
75
+ return {
76
+ content: [{
77
+ type: "text",
78
+ text: `youcom_search error: ${error}. Check that ${API_KEY_FILE} exists and contains an api_key field.`,
79
+ }],
80
+ details: undefined,
81
+ }
82
+ }
83
+
84
+ const params = new URLSearchParams({
85
+ query,
86
+ count: String(count),
87
+ })
88
+ if (freshness) params.set("freshness", freshness)
89
+
90
+ const controller = new AbortController()
91
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_SEARCH_MS)
92
+ try {
93
+ const response = await fetch(`${YDC_API}/v1/search?${params}`, {
94
+ signal: controller.signal,
95
+ headers: { "X-API-Key": key! },
96
+ })
97
+
98
+ if (!response.ok) {
99
+ throw new Error(`You.com API returned ${response.status} ${response.statusText}`)
100
+ }
101
+
102
+ const body = await response.json() as { hits?: Array<{ title?: string; url?: string; snippet?: string }> }
103
+ const hits = body.hits ?? []
104
+
105
+ if (hits.length === 0) {
106
+ return {
107
+ content: [{ type: "text", text: `No results found for: "${query}"` }],
108
+ details: undefined,
109
+ }
110
+ }
111
+
112
+ const results = hits.map((hit, i) => {
113
+ const lines: string[] = []
114
+ lines.push(`${i + 1}. ${hit.title ?? "No title"}`)
115
+ if (hit.url) lines.push(` URL: ${hit.url}`)
116
+ if (hit.snippet) lines.push(` ${hit.snippet}`)
117
+ return lines.join("\n")
118
+ })
119
+
120
+ const header = `You.com search results for "${query}" (${results.length} results)`
121
+ return {
122
+ content: [{ type: "text", text: header + "\n\n" + results.join("\n\n") }],
123
+ details: undefined,
124
+ }
125
+ } catch (err) {
126
+ const msg = err instanceof Error ? err.message : String(err)
127
+ return {
128
+ content: [{
129
+ type: "text",
130
+ text: `youcom_search error: ${msg}. Ensure You.com API is reachable at ${YDC_API}.`,
131
+ }],
132
+ details: undefined,
133
+ }
134
+ } finally {
135
+ clearTimeout(timeout)
136
+ }
137
+ }
138
+
139
+ async function researchMode(query: string): Promise<AgentToolResult<undefined>> {
140
+ const { key, error } = readApiKey()
141
+ if (error) {
142
+ return {
143
+ content: [{
144
+ type: "text",
145
+ text: `youcom_search error: ${error}. Check that ${API_KEY_FILE} exists and contains an api_key field.`,
146
+ }],
147
+ details: undefined,
148
+ }
149
+ }
150
+
151
+ const controller = new AbortController()
152
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_RESEARCH_MS)
153
+ try {
154
+ const response = await fetch(`${YDC_API}/v1/research`, {
155
+ signal: controller.signal,
156
+ method: "POST",
157
+ headers: {
158
+ "X-API-Key": key!,
159
+ "Content-Type": "application/json",
160
+ },
161
+ body: JSON.stringify({ query, effort: "standard" }),
162
+ })
163
+
164
+ if (!response.ok) {
165
+ throw new Error(`You.com API returned ${response.status} ${response.statusText}`)
166
+ }
167
+
168
+ const body = await response.json() as { answer?: string; sources?: Array<{ title?: string; url?: string }> }
169
+ const answer = body.answer ?? "No answer provided"
170
+ const sources = body.sources ?? []
171
+
172
+ const content = answer.length > MAX_CONTENT_LENGTH
173
+ ? answer.slice(0, MAX_CONTENT_LENGTH) + "\n\n[research truncated — " + answer.length + " chars total]"
174
+ : answer
175
+
176
+ const lines: string[] = [
177
+ `You.com research answer for "${query}"`,
178
+ "",
179
+ content,
180
+ ]
181
+
182
+ if (sources.length > 0) {
183
+ lines.push("")
184
+ lines.push("Sources:")
185
+ sources.forEach((src, i) => {
186
+ lines.push(` ${i + 1}. ${src.title ?? "No title"} — ${src.url ?? "No URL"}`)
187
+ })
188
+ }
189
+
190
+ return {
191
+ content: [{ type: "text", text: lines.join("\n") }],
192
+ details: undefined,
193
+ }
194
+ } catch (err) {
195
+ const msg = err instanceof Error ? err.message : String(err)
196
+ return {
197
+ content: [{
198
+ type: "text",
199
+ text: `youcom_search error: ${msg}. Ensure You.com API is reachable at ${YDC_API}.`,
200
+ }],
201
+ details: undefined,
202
+ }
203
+ } finally {
204
+ clearTimeout(timeout)
205
+ }
206
+ }
207
+
208
+ export function createYoucomSearchToolDefinition(): ToolDefinition<typeof youcomSearchSchema, undefined> {
209
+ return {
210
+ name: "youcom_search",
211
+ label: "You.com Search",
212
+ description:
213
+ "Enriched web search via You.com API. " +
214
+ "mode='search' for fast snippets with domain/freshness filtering. " +
215
+ "mode='research' for autonomous synthesis with citations (standard effort, ~$0.05/query). " +
216
+ "Supports query operators: site:, filetype:, +term, -term.",
217
+ parameters: youcomSearchSchema,
218
+ execute: async (_toolCallId, params) => {
219
+ const query = params.query
220
+ const mode = params.mode ?? "search"
221
+ const count = params.count ?? 5
222
+ const freshness = params.freshness
223
+
224
+ if (mode === "research") {
225
+ return researchMode(query)
226
+ }
227
+ return searchMode(query, count, freshness)
228
+ },
229
+ }
230
+ }
@@ -0,0 +1,124 @@
1
+ // youtube_transcript — YouTube transcript tool definition
2
+ // Fetches timestamped transcripts from YouTube videos.
3
+ // Uses youtube-transcript-plus package.
4
+
5
+ import { Type } from "typebox"
6
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent"
7
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent"
8
+ import { YoutubeTranscript } from "youtube-transcript-plus"
9
+
10
+ // Minimal text content type (mirrors pi-ai TextContent — not re-exported).
11
+ interface TextContent {
12
+ type: "text"
13
+ text: string
14
+ }
15
+
16
+ const youtubeTranscriptSchema = Type.Object({
17
+ video: Type.String({
18
+ description: "YouTube video ID (11 chars) or full URL (youtube.com/watch?v=... or youtu.be/...)",
19
+ }),
20
+ })
21
+
22
+ const MAX_CONTENT_LENGTH = 8000
23
+
24
+ // Extract video ID from various YouTube URL formats.
25
+ function extractVideoId(input: string): string {
26
+ // Already a bare ID (11 chars, alphanumeric + _ -)
27
+ if (/^[a-zA-Z0-9_-]{11}$/.test(input.trim())) {
28
+ return input.trim()
29
+ }
30
+
31
+ // youtube.com/watch?v=VIDEO_ID
32
+ const watchMatch = input.match(/[?&]v=([a-zA-Z0-9_-]{11})/)
33
+ if (watchMatch) return watchMatch[1]
34
+
35
+ // youtu.be/VIDEO_ID
36
+ const shortMatch = input.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/)
37
+ if (shortMatch) return shortMatch[1]
38
+
39
+ // youtube.com/embed/VIDEO_ID
40
+ const embedMatch = input.match(/embed\/([a-zA-Z0-9_-]{11})/)
41
+ if (embedMatch) return embedMatch[1]
42
+
43
+ throw new Error(`Could not extract video ID from: ${input}`)
44
+ }
45
+
46
+ // Format seconds as MM:SS.
47
+ function formatTime(seconds: number): string {
48
+ const m = Math.floor(seconds / 60)
49
+ const s = Math.floor(seconds % 60)
50
+ return `${m}:${s.toString().padStart(2, "0")}`
51
+ }
52
+
53
+ async function fetchTranscript(videoInput: string): Promise<AgentToolResult<undefined>> {
54
+ let videoId: string
55
+ try {
56
+ videoId = extractVideoId(videoInput)
57
+ } catch (err) {
58
+ return {
59
+ content: [{
60
+ type: "text",
61
+ text: `youtube_transcript error: ${(err as Error).message}.`,
62
+ }],
63
+ details: undefined,
64
+ }
65
+ }
66
+
67
+ try {
68
+ const segments = await YoutubeTranscript.fetchTranscript(videoId)
69
+
70
+ if (!segments || segments.length === 0) {
71
+ return {
72
+ content: [{
73
+ type: "text",
74
+ text: `No transcript available for video ${videoId}. The video may not have captions or they may be disabled.`,
75
+ }],
76
+ details: undefined,
77
+ }
78
+ }
79
+
80
+ // Format as timestamped text.
81
+ const lines = segments.map((seg) => {
82
+ const time = formatTime(seg.offset)
83
+ return `[${time}] ${seg.text}`
84
+ })
85
+
86
+ const content = lines.join("\n")
87
+
88
+ // Truncate to avoid context explosion.
89
+ const text = content.length > MAX_CONTENT_LENGTH
90
+ ? content.slice(0, MAX_CONTENT_LENGTH) + "\n\n[transcript truncated — " + content.length + " chars total]"
91
+ : content
92
+
93
+ return {
94
+ content: [{ type: "text", text }],
95
+ details: undefined,
96
+ }
97
+ } catch (err) {
98
+ const msg = err instanceof Error ? err.message : String(err)
99
+ return {
100
+ content: [{
101
+ type: "text",
102
+ text: `youtube_transcript error: ${msg}.`,
103
+ }],
104
+ details: undefined,
105
+ }
106
+ }
107
+ }
108
+
109
+ export function createYoutubeTranscriptToolDefinition(): ToolDefinition<typeof youtubeTranscriptSchema, undefined> {
110
+ return {
111
+ name: "youtube_transcript",
112
+ label: "YouTube Transcript",
113
+ description:
114
+ "Fetch timestamped transcript from a YouTube video. " +
115
+ "Accepts video ID (11 chars) or full URL (youtube.com/watch?v=... or youtu.be/...). " +
116
+ "Returns text formatted as [MM:SS] text. " +
117
+ "Truncated to ~8000 chars to avoid context explosion.",
118
+ parameters: youtubeTranscriptSchema,
119
+ execute: async (_toolCallId, params) => {
120
+ const video = params.video
121
+ return fetchTranscript(video)
122
+ },
123
+ }
124
+ }
@@ -0,0 +1,52 @@
1
+ // LocalModelLock — process-global semaphore(1) for llama-server inference.
2
+ //
3
+ // llama-server runs with --parallel 1: only one inference slot. When multiple
4
+ // rooms are active, local-model agents must be serialized. Cloud agents bypass
5
+ // this lock entirely (they hit independent endpoints).
6
+ //
7
+ // The lock is held by RoomManager and injected into each Room at creation.
8
+ // Room.executeAgent() acquires before inference, releases in `finally`.
9
+
10
+ /** Async semaphore with capacity 1 for serializing local-model inference. */
11
+ export class LocalModelLock {
12
+ private held = false
13
+ private readonly queue: Array<() => void> = []
14
+
15
+ /**
16
+ * Acquire the lock. Resolves immediately if free; otherwise queues and waits
17
+ * until the current holder calls release().
18
+ */
19
+ async acquire(): Promise<void> {
20
+ if (!this.held) {
21
+ this.held = true
22
+ return
23
+ }
24
+ return new Promise<void>((resolve) => {
25
+ this.queue.push(resolve)
26
+ })
27
+ }
28
+
29
+ /**
30
+ * Release the lock. If there are waiting callers, the next one is unblocked.
31
+ * Calling release() when nothing was acquired is a no-op (safe).
32
+ */
33
+ release(): void {
34
+ const next = this.queue.shift()
35
+ if (next) {
36
+ // Hand ownership directly to the next waiter — held stays true.
37
+ next()
38
+ } else {
39
+ this.held = false
40
+ }
41
+ }
42
+
43
+ /** Whether the lock is currently held (useful for testing). */
44
+ get isHeld(): boolean {
45
+ return this.held
46
+ }
47
+
48
+ /** Number of callers waiting to acquire (useful for testing). */
49
+ get waitCount(): number {
50
+ return this.queue.length
51
+ }
52
+ }
package/src/model.ts ADDED
@@ -0,0 +1,131 @@
1
+ // Resolve the shared model registry / auth storage and pick a model once at
2
+ // startup. All participants reuse these so we don't re-scan providers per agent.
3
+
4
+ import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"
5
+ import { config } from "./config.js"
6
+
7
+ export interface ResolvedModel {
8
+ authStorage: AuthStorage
9
+ modelRegistry: ModelRegistry
10
+ /** undefined → let createAgentSession use pi's default resolution. */
11
+ model: Awaited<ReturnType<ModelRegistry["getAvailable"]>>[number] | undefined
12
+ }
13
+
14
+ export async function resolveModel(): Promise<ResolvedModel> {
15
+ const authStorage = AuthStorage.create()
16
+ const modelRegistry = ModelRegistry.create(authStorage)
17
+
18
+ let model: ResolvedModel["model"]
19
+
20
+ if (config.modelOverride) {
21
+ const slash = config.modelOverride.indexOf("/")
22
+ const provider = slash >= 0 ? config.modelOverride.slice(0, slash) : ""
23
+ const id = slash >= 0 ? config.modelOverride.slice(slash + 1) : config.modelOverride
24
+ model = (modelRegistry.find(provider, id) as ResolvedModel["model"]) ?? undefined
25
+ if (!model) {
26
+ console.warn(`[model] override "${config.modelOverride}" not found; falling back to default`)
27
+ }
28
+ }
29
+
30
+ if (!model) {
31
+ const available = await modelRegistry.getAvailable()
32
+ // This stack is local-only by policy: always prefer the `local` provider
33
+ // (llama-server :5000) over any cloud model that happens to have a key.
34
+ model = available.find((m) => m.provider === "local") ?? available[0]
35
+ }
36
+
37
+ if (model && model.provider !== "local") {
38
+ console.warn(
39
+ `[model] WARNING: resolved a non-local model (${model.provider}/${model.id}). ` +
40
+ `This stack is meant to be local-only. Set PIPELINE_MODEL=local/<id> or check ~/.pi/agent/models.json.`,
41
+ )
42
+ }
43
+
44
+ console.log(
45
+ model
46
+ ? `[model] using ${model.provider}/${model.id}`
47
+ : `[model] no explicit model resolved; relying on pi defaults`,
48
+ )
49
+
50
+ return { authStorage, modelRegistry, model }
51
+ }
52
+
53
+ /** A model the UI can offer for per-agent selection. */
54
+ export interface ModelInfo {
55
+ provider: string
56
+ id: string
57
+ /** "provider/id" — the value stored on a persona. */
58
+ ref: string
59
+ name: string
60
+ local: boolean
61
+ }
62
+
63
+ /** Parse a "provider/id" ref into its parts. */
64
+ function splitRef(ref: string): { provider: string; id: string } {
65
+ const slash = ref.indexOf("/")
66
+ return slash >= 0
67
+ ? { provider: ref.slice(0, slash), id: ref.slice(slash + 1) }
68
+ : { provider: "", id: ref }
69
+ }
70
+
71
+ /** Models offered for per-agent selection: those with auth configured, filtered
72
+ * to local unless cloud is explicitly allowed, or the provider has been
73
+ * explicitly enabled by the user via /api/providers. */
74
+ export function listModels(resolved: ResolvedModel, allowCloud: boolean, explicitlyEnabled?: Set<string>): ModelInfo[] {
75
+ return resolved.modelRegistry
76
+ .getAvailable()
77
+ .filter((m) =>
78
+ allowCloud || m.provider === "local" || (explicitlyEnabled?.has(m.provider) ?? false),
79
+ )
80
+ .map((m) => ({
81
+ provider: m.provider,
82
+ id: m.id,
83
+ ref: `${m.provider}/${m.id}`,
84
+ name: (m as { name?: string }).name ?? m.id,
85
+ local: m.provider === "local",
86
+ }))
87
+ }
88
+
89
+ /** True if a "provider/id" ref is a model the UI is allowed to assign. */
90
+ export function isAllowedModel(resolved: ResolvedModel, allowCloud: boolean, ref: string, explicitlyEnabled?: Set<string>): boolean {
91
+ return listModels(resolved, allowCloud, explicitlyEnabled).some((m) => m.ref === ref)
92
+ }
93
+
94
+ /** Downgrade preset personas whose model isn't currently available to the
95
+ * process default (model → undefined) instead of blocking the whole load.
96
+ * Mutates `personas` in place and returns what was downgraded so the caller can
97
+ * surface it. Mirrors resolveModelRef's runtime fallback — a stale cloud id or a
98
+ * swapped local quant degrades gracefully rather than bricking every preset. */
99
+ export function downgradeUnavailableModels(
100
+ personas: Array<{ name: string; model?: string }>,
101
+ isAllowed: (ref: string) => boolean,
102
+ ): Array<{ agent: string; model: string }> {
103
+ const downgraded: Array<{ agent: string; model: string }> = []
104
+ for (const p of personas) {
105
+ if (p.model && !isAllowed(p.model)) {
106
+ downgraded.push({ agent: p.name, model: p.model })
107
+ p.model = undefined
108
+ }
109
+ }
110
+ return downgraded
111
+ }
112
+
113
+ /** Resolve a persona's "provider/id" ref to a concrete model. Enforces the
114
+ * local-only policy: a non-local ref (or an unknown one) falls back to the
115
+ * process default rather than silently reaching the cloud. */
116
+ export function resolveModelRef(resolved: ResolvedModel, allowCloud: boolean, ref?: string): ResolvedModel["model"] {
117
+ if (!ref) return resolved.model
118
+ const { provider, id } = splitRef(ref)
119
+ if (provider !== "local" && !allowCloud) {
120
+ console.warn(
121
+ `[model] persona model "${ref}" is non-local and cloud is disabled — using default instead.`,
122
+ )
123
+ return resolved.model
124
+ }
125
+ const found = resolved.modelRegistry.find(provider, id) as ResolvedModel["model"]
126
+ if (!found) {
127
+ console.warn(`[model] persona model "${ref}" not found — using default.`)
128
+ return resolved.model
129
+ }
130
+ return found
131
+ }