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.
- package/.env.example +26 -0
- package/LICENSE +21 -0
- package/README.md +408 -0
- package/bin/pipeline-moe.mjs +40 -0
- package/package.json +68 -0
- package/presets/2106BUILD.json +163 -0
- package/presets/CHEAPBUILD.json +97 -0
- package/presets/FREEROOM.json +96 -0
- package/presets/Versa.json +145 -0
- package/presets/cloud-main.json +76 -0
- package/presets/cloud-sprint.json +70 -0
- package/presets/local-default.json +152 -0
- package/presets/main.json +147 -0
- package/presets/mainmix.json +145 -0
- package/src/circuit-breaker.ts +141 -0
- package/src/config.ts +46 -0
- package/src/custom-tools/arxiv-search.ts +186 -0
- package/src/custom-tools/check-room.ts +45 -0
- package/src/custom-tools/destroy-room.ts +45 -0
- package/src/custom-tools/index.ts +75 -0
- package/src/custom-tools/spawn-room.ts +99 -0
- package/src/custom-tools/stop-room.ts +50 -0
- package/src/custom-tools/web-read.ts +104 -0
- package/src/custom-tools/web-search.ts +144 -0
- package/src/custom-tools/youcom-search.ts +230 -0
- package/src/custom-tools/youtube-transcript.ts +124 -0
- package/src/local-model-lock.ts +52 -0
- package/src/model.ts +131 -0
- package/src/orchestrator.ts +59 -0
- package/src/participant.ts +423 -0
- package/src/path-guard.ts +13 -0
- package/src/personas.ts +480 -0
- package/src/receipts.ts +120 -0
- package/src/registry.ts +317 -0
- package/src/room-manager.ts +505 -0
- package/src/room.ts +1657 -0
- package/src/sandbox-tools.ts +141 -0
- package/src/server.ts +1846 -0
- package/src/sse.ts +86 -0
- package/src/sshfs.ts +139 -0
- package/src/store.ts +79 -0
- package/src/types.ts +131 -0
- package/src/validation.ts +36 -0
- package/tsconfig.json +15 -0
- package/web/dist/assets/index-CmMGhMKG.css +1 -0
- package/web/dist/assets/index-LAGqbZII.js +41 -0
- package/web/dist/index.html +13 -0
- 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
|
+
}
|