@kevin5251984/guild 0.2.18 → 0.2.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/catalog/subagents.ts +2 -1
- package/src/chat-parts.ts +6 -1
- package/src/cli.ts +2 -5
- package/src/compact.ts +27 -0
- package/src/db.ts +236 -68
- package/src/generate.ts +59 -28
- package/src/handlers.ts +355 -84
- package/src/harness.ts +39 -10
- package/src/host-browse.ts +149 -4
- package/src/image-gen.ts +2 -1
- package/src/llm.ts +435 -71
- package/src/mcp.ts +32 -5
- package/src/memory.ts +69 -0
- package/src/mention.ts +189 -14
- package/src/oauth.ts +204 -27
- package/src/opencode-free.ts +248 -0
- package/src/public/buddy.js +432 -0
- package/src/public/chat.css +174 -72
- package/src/public/chat.html +354 -69
- package/src/public/i18n.js +44 -6
- package/src/public/index.html +1 -46
- package/src/public/mobile.css +597 -0
- package/src/public/mobile.html +954 -0
- package/src/public/settings.html +239 -35
- package/src/public/skills-add.html +8 -0
- package/src/public/studio.html +171 -117
- package/src/public/style.css +220 -26
- package/src/public/subagents-add.html +8 -0
- package/src/reasoning-catalog.ts +346 -0
- package/src/router.ts +184 -13
- package/src/store.ts +170 -5
- package/src/subagent.ts +239 -7
- package/src/tools.ts +132 -21
- package/src/trajectory.ts +72 -9
- package/src/usage.ts +10 -0
- package/src/version.ts +25 -0
- package/vendor/protocol/src/index.ts +27 -2
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import type { ModelReasoning } from "@guild/protocol";
|
|
2
|
+
import { guildUserAgent } from "./version.ts";
|
|
3
|
+
|
|
4
|
+
const MODELS_DEV = "https://models.dev/api.json";
|
|
5
|
+
const OPENROUTER_MODELS = "https://openrouter.ai/api/v1/models";
|
|
6
|
+
const TTL_MS = 6 * 60 * 60 * 1000;
|
|
7
|
+
const FETCH_MS = 4_000;
|
|
8
|
+
|
|
9
|
+
const DEV_PROVIDER: Record<string, string> = {
|
|
10
|
+
xai: "xai",
|
|
11
|
+
"xai-oauth": "xai",
|
|
12
|
+
openai: "openai",
|
|
13
|
+
"openai-codex": "openai",
|
|
14
|
+
anthropic: "anthropic",
|
|
15
|
+
"anthropic-oauth": "anthropic",
|
|
16
|
+
"github-copilot": "github-copilot",
|
|
17
|
+
"opencode-free": "opencode",
|
|
18
|
+
openrouter: "openrouter",
|
|
19
|
+
"openrouter-oauth": "openrouter",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const OPENROUTER_PREFIX: Record<string, string> = {
|
|
23
|
+
xai: "x-ai",
|
|
24
|
+
openai: "openai",
|
|
25
|
+
anthropic: "anthropic",
|
|
26
|
+
"opencode-free": "opencode",
|
|
27
|
+
"github-copilot": "github-copilot",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Sort key only — never used as the displayed list. */
|
|
31
|
+
const EFFORT_RANK = [
|
|
32
|
+
"none",
|
|
33
|
+
"minimal",
|
|
34
|
+
"low",
|
|
35
|
+
"medium",
|
|
36
|
+
"high",
|
|
37
|
+
"xhigh",
|
|
38
|
+
"max",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
type CatalogMaps = {
|
|
42
|
+
at: number;
|
|
43
|
+
dev: Map<string, ModelReasoning>;
|
|
44
|
+
openrouter: Map<string, ModelReasoning>;
|
|
45
|
+
gatewayEfforts: string[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
let maps: CatalogMaps | null = null;
|
|
49
|
+
|
|
50
|
+
export function parseEffortList(raw: unknown): string[] {
|
|
51
|
+
if (!Array.isArray(raw)) return [];
|
|
52
|
+
const out: string[] = [];
|
|
53
|
+
for (const item of raw) {
|
|
54
|
+
if (typeof item !== "string") continue;
|
|
55
|
+
const key = item.trim().toLowerCase();
|
|
56
|
+
if (!/^[a-z][a-z0-9_-]{0,31}$/.test(key) || out.includes(key)) continue;
|
|
57
|
+
out.push(key);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function sanitizeEffort(raw: unknown): string | undefined {
|
|
63
|
+
if (typeof raw !== "string") return undefined;
|
|
64
|
+
const key = raw.trim().toLowerCase();
|
|
65
|
+
if (!/^[a-z][a-z0-9_-]{0,31}$/.test(key)) return undefined;
|
|
66
|
+
return key;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function fromModelsDevModel(raw: unknown): ModelReasoning | undefined {
|
|
70
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
71
|
+
const row = raw as Record<string, unknown>;
|
|
72
|
+
if (row.reasoning === false) return undefined;
|
|
73
|
+
const options = Array.isArray(row.reasoning_options)
|
|
74
|
+
? row.reasoning_options
|
|
75
|
+
: [];
|
|
76
|
+
let efforts: string[] = [];
|
|
77
|
+
let supportsMaxTokens = false;
|
|
78
|
+
for (const opt of options) {
|
|
79
|
+
if (!opt || typeof opt !== "object" || Array.isArray(opt)) continue;
|
|
80
|
+
const rec = opt as Record<string, unknown>;
|
|
81
|
+
if (rec.type === "effort") efforts = parseEffortList(rec.values);
|
|
82
|
+
if (rec.type === "budget_tokens") supportsMaxTokens = true;
|
|
83
|
+
}
|
|
84
|
+
if (row.reasoning !== true && !efforts.length && !supportsMaxTokens) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
const spec: ModelReasoning = {};
|
|
88
|
+
if (efforts.length) spec.supportedEfforts = efforts;
|
|
89
|
+
if (supportsMaxTokens) spec.supportsMaxTokens = true;
|
|
90
|
+
spec.defaultEnabled = true;
|
|
91
|
+
if (efforts.length) spec.mandatory = !efforts.includes("none");
|
|
92
|
+
if (efforts.includes("high")) spec.defaultEffort = "high";
|
|
93
|
+
else if (efforts.includes("medium")) spec.defaultEffort = "medium";
|
|
94
|
+
else if (efforts.length) spec.defaultEffort = efforts[0];
|
|
95
|
+
return spec;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function fromOpenRouterModel(raw: unknown): ModelReasoning | undefined {
|
|
99
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
100
|
+
const row = raw as Record<string, unknown>;
|
|
101
|
+
const block = row.reasoning;
|
|
102
|
+
if (block === undefined) return undefined;
|
|
103
|
+
if (!block || typeof block !== "object" || Array.isArray(block)) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
const rec = block as Record<string, unknown>;
|
|
107
|
+
const spec: ModelReasoning = {};
|
|
108
|
+
if (rec.supported_efforts === null) {
|
|
109
|
+
spec.supportedEfforts = undefined;
|
|
110
|
+
} else {
|
|
111
|
+
const efforts = parseEffortList(rec.supported_efforts);
|
|
112
|
+
if (efforts.length) spec.supportedEfforts = efforts;
|
|
113
|
+
}
|
|
114
|
+
const def = sanitizeEffort(rec.default_effort);
|
|
115
|
+
if (def) spec.defaultEffort = def;
|
|
116
|
+
if (typeof rec.mandatory === "boolean") spec.mandatory = rec.mandatory;
|
|
117
|
+
if (typeof rec.default_enabled === "boolean") {
|
|
118
|
+
spec.defaultEnabled = rec.default_enabled;
|
|
119
|
+
}
|
|
120
|
+
if (rec.supports_max_tokens === true) spec.supportsMaxTokens = true;
|
|
121
|
+
if (
|
|
122
|
+
!spec.supportedEfforts &&
|
|
123
|
+
spec.defaultEffort === undefined &&
|
|
124
|
+
spec.mandatory === undefined &&
|
|
125
|
+
spec.supportsMaxTokens === undefined
|
|
126
|
+
) {
|
|
127
|
+
return { defaultEnabled: spec.defaultEnabled ?? true };
|
|
128
|
+
}
|
|
129
|
+
return spec;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function indexDev(data: unknown): Map<string, ModelReasoning> {
|
|
133
|
+
const out = new Map<string, ModelReasoning>();
|
|
134
|
+
if (!data || typeof data !== "object") return out;
|
|
135
|
+
for (const [pid, prov] of Object.entries(data as Record<string, unknown>)) {
|
|
136
|
+
if (!prov || typeof prov !== "object") continue;
|
|
137
|
+
const models = (prov as { models?: Record<string, unknown> }).models;
|
|
138
|
+
if (!models || typeof models !== "object") continue;
|
|
139
|
+
for (const [mid, model] of Object.entries(models)) {
|
|
140
|
+
const spec = fromModelsDevModel(model);
|
|
141
|
+
if (!spec) continue;
|
|
142
|
+
out.set(`${pid}/${mid}`, spec);
|
|
143
|
+
const bare = mid.split("/").pop() || mid;
|
|
144
|
+
if (bare !== mid) out.set(`${pid}/${bare}`, spec);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function indexOpenRouter(data: unknown): {
|
|
151
|
+
map: Map<string, ModelReasoning>;
|
|
152
|
+
gateway: string[];
|
|
153
|
+
} {
|
|
154
|
+
const map = new Map<string, ModelReasoning>();
|
|
155
|
+
const seen = new Set<string>();
|
|
156
|
+
const rows = Array.isArray(data)
|
|
157
|
+
? data
|
|
158
|
+
: data && typeof data === "object" && Array.isArray((data as { data?: unknown }).data)
|
|
159
|
+
? ((data as { data: unknown[] }).data)
|
|
160
|
+
: [];
|
|
161
|
+
for (const row of rows) {
|
|
162
|
+
const spec = fromOpenRouterModel(row);
|
|
163
|
+
if (!spec) continue;
|
|
164
|
+
const id =
|
|
165
|
+
row && typeof row === "object" && typeof (row as { id?: unknown }).id === "string"
|
|
166
|
+
? (row as { id: string }).id
|
|
167
|
+
: "";
|
|
168
|
+
if (!id) continue;
|
|
169
|
+
map.set(id, spec);
|
|
170
|
+
const bare = id.split("/").pop() || id;
|
|
171
|
+
if (bare !== id && !map.has(bare)) map.set(bare, spec);
|
|
172
|
+
for (const effort of spec.supportedEfforts ?? []) seen.add(effort);
|
|
173
|
+
}
|
|
174
|
+
const gateway = EFFORT_RANK.filter((key) => seen.has(key));
|
|
175
|
+
for (const key of seen) {
|
|
176
|
+
if (!gateway.includes(key)) gateway.push(key);
|
|
177
|
+
}
|
|
178
|
+
return { map, gateway };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function setReasoningCatalogForTests(input: {
|
|
182
|
+
dev?: unknown;
|
|
183
|
+
openrouter?: unknown;
|
|
184
|
+
}): void {
|
|
185
|
+
const openrouter = indexOpenRouter(input.openrouter ?? { data: [] });
|
|
186
|
+
maps = {
|
|
187
|
+
at: Date.now(),
|
|
188
|
+
dev: indexDev(input.dev ?? {}),
|
|
189
|
+
openrouter: openrouter.map,
|
|
190
|
+
gatewayEfforts: openrouter.gateway,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function resetReasoningCatalog(): void {
|
|
195
|
+
maps = null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function skipLiveFetch(): boolean {
|
|
199
|
+
return Boolean(
|
|
200
|
+
process.env.NODE_TEST_CONTEXT ||
|
|
201
|
+
process.argv.some((arg) => arg === "--test" || arg.includes("--test=")),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function refreshReasoningCatalog(
|
|
206
|
+
force = false,
|
|
207
|
+
fetcher: typeof fetch = fetch,
|
|
208
|
+
): Promise<void> {
|
|
209
|
+
if (!force && maps && Date.now() - maps.at < TTL_MS) return;
|
|
210
|
+
if (!force && skipLiveFetch() && maps) return;
|
|
211
|
+
if (skipLiveFetch() && !force) return;
|
|
212
|
+
const ctrl = AbortSignal.timeout(FETCH_MS);
|
|
213
|
+
const [devRes, orRes] = await Promise.allSettled([
|
|
214
|
+
fetcher(MODELS_DEV, {
|
|
215
|
+
headers: { accept: "application/json", "user-agent": guildUserAgent() },
|
|
216
|
+
signal: ctrl,
|
|
217
|
+
}),
|
|
218
|
+
fetcher(OPENROUTER_MODELS, {
|
|
219
|
+
headers: { accept: "application/json", "user-agent": guildUserAgent() },
|
|
220
|
+
signal: ctrl,
|
|
221
|
+
}),
|
|
222
|
+
]);
|
|
223
|
+
let dev = maps?.dev ?? new Map<string, ModelReasoning>();
|
|
224
|
+
let openrouter = maps?.openrouter ?? new Map<string, ModelReasoning>();
|
|
225
|
+
let gateway = maps?.gatewayEfforts ?? [];
|
|
226
|
+
let got = false;
|
|
227
|
+
if (devRes.status === "fulfilled" && devRes.value.ok) {
|
|
228
|
+
try {
|
|
229
|
+
dev = indexDev(await devRes.value.json());
|
|
230
|
+
got = true;
|
|
231
|
+
} catch {
|
|
232
|
+
/* keep previous */
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (orRes.status === "fulfilled" && orRes.value.ok) {
|
|
236
|
+
try {
|
|
237
|
+
const indexed = indexOpenRouter(await orRes.value.json());
|
|
238
|
+
openrouter = indexed.map;
|
|
239
|
+
gateway = indexed.gateway;
|
|
240
|
+
got = true;
|
|
241
|
+
} catch {
|
|
242
|
+
/* keep previous */
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (!got && !maps) return;
|
|
246
|
+
if (!got) return;
|
|
247
|
+
maps = {
|
|
248
|
+
at: Date.now(),
|
|
249
|
+
dev,
|
|
250
|
+
openrouter,
|
|
251
|
+
gatewayEfforts: gateway,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function fillNullEfforts(spec: ModelReasoning): ModelReasoning {
|
|
256
|
+
if (spec.supportedEfforts?.length) return spec;
|
|
257
|
+
if (!maps?.gatewayEfforts.length) return spec;
|
|
258
|
+
return { ...spec, supportedEfforts: [...maps.gatewayEfforts] };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function reasoningFor(
|
|
262
|
+
providerId: string,
|
|
263
|
+
modelId: string,
|
|
264
|
+
): ModelReasoning | undefined {
|
|
265
|
+
if (!maps) return undefined;
|
|
266
|
+
const provider = String(providerId || "").trim().toLowerCase();
|
|
267
|
+
const model = String(modelId || "").trim();
|
|
268
|
+
if (!provider || !model) return undefined;
|
|
269
|
+
const bare = model.split("/").pop() || model;
|
|
270
|
+
const devId = DEV_PROVIDER[provider] || provider.replace(/-oauth$/, "");
|
|
271
|
+
|
|
272
|
+
if (devId === "openrouter") {
|
|
273
|
+
const hit =
|
|
274
|
+
maps.openrouter.get(model) ||
|
|
275
|
+
maps.openrouter.get(bare) ||
|
|
276
|
+
maps.openrouter.get(model.toLowerCase());
|
|
277
|
+
return hit ? fillNullEfforts(hit) : undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const devHit =
|
|
281
|
+
maps.dev.get(`${devId}/${model}`) ||
|
|
282
|
+
maps.dev.get(`${devId}/${bare}`) ||
|
|
283
|
+
maps.dev.get(`${devId}/${bare.toLowerCase()}`);
|
|
284
|
+
if (devHit) return devHit;
|
|
285
|
+
|
|
286
|
+
const prefix = OPENROUTER_PREFIX[devId] || OPENROUTER_PREFIX[provider];
|
|
287
|
+
if (prefix) {
|
|
288
|
+
const slug = `${prefix}/${bare}`;
|
|
289
|
+
const orHit = maps.openrouter.get(slug) || maps.openrouter.get(model);
|
|
290
|
+
if (orHit) return fillNullEfforts(orHit);
|
|
291
|
+
}
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function attachReasoning(
|
|
296
|
+
providerId: string,
|
|
297
|
+
models: { id: string; name?: string; reasoning?: ModelReasoning }[],
|
|
298
|
+
): { id: string; name?: string; reasoning?: ModelReasoning }[] {
|
|
299
|
+
return models.map((model) => {
|
|
300
|
+
const spec = reasoningFor(providerId, model.id);
|
|
301
|
+
if (!spec) return model;
|
|
302
|
+
return { ...model, reasoning: spec };
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function clampEffort(
|
|
307
|
+
want: string | undefined,
|
|
308
|
+
spec: ModelReasoning | undefined,
|
|
309
|
+
fast = false,
|
|
310
|
+
): string | undefined {
|
|
311
|
+
const efforts = spec?.supportedEfforts;
|
|
312
|
+
if (!efforts?.length) return undefined;
|
|
313
|
+
const usable = spec?.mandatory
|
|
314
|
+
? efforts.filter((key) => key !== "none")
|
|
315
|
+
: efforts;
|
|
316
|
+
if (!usable.length) return undefined;
|
|
317
|
+
if (fast) {
|
|
318
|
+
if (usable.includes("low")) return "low";
|
|
319
|
+
if (usable.includes("minimal")) return "minimal";
|
|
320
|
+
const ranked = EFFORT_RANK.filter((key) => usable.includes(key));
|
|
321
|
+
return ranked[0] || usable[usable.length - 1];
|
|
322
|
+
}
|
|
323
|
+
const picked = sanitizeEffort(want);
|
|
324
|
+
if (picked && usable.includes(picked)) return picked;
|
|
325
|
+
if (spec?.defaultEffort && usable.includes(spec.defaultEffort)) {
|
|
326
|
+
return spec.defaultEffort;
|
|
327
|
+
}
|
|
328
|
+
return usable.includes("high")
|
|
329
|
+
? "high"
|
|
330
|
+
: usable.includes("medium")
|
|
331
|
+
? "medium"
|
|
332
|
+
: usable[0];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function reasoningPayload(
|
|
336
|
+
providerId: string,
|
|
337
|
+
baseUrl: string,
|
|
338
|
+
effort: string | undefined,
|
|
339
|
+
): Record<string, unknown> {
|
|
340
|
+
if (!effort) return {};
|
|
341
|
+
const viaOpenRouter =
|
|
342
|
+
providerId.includes("openrouter") ||
|
|
343
|
+
/openrouter\.ai/i.test(baseUrl);
|
|
344
|
+
if (viaOpenRouter) return { reasoning: { effort } };
|
|
345
|
+
return { reasoning_effort: effort, reasoning: { effort } };
|
|
346
|
+
}
|
package/src/router.ts
CHANGED
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
addChannelMember,
|
|
7
7
|
createBot,
|
|
8
8
|
createChannel,
|
|
9
|
+
createBranch,
|
|
10
|
+
closeBranch,
|
|
9
11
|
deleteBot,
|
|
10
12
|
deleteChannel,
|
|
11
13
|
renameChannel,
|
|
@@ -18,6 +20,7 @@ import {
|
|
|
18
20
|
setChannelMemory,
|
|
19
21
|
generateKind,
|
|
20
22
|
pickBotSkills,
|
|
23
|
+
generateBotLook,
|
|
21
24
|
getBotDetail,
|
|
22
25
|
getLiveTurn,
|
|
23
26
|
abortLiveTurn,
|
|
@@ -25,6 +28,8 @@ import {
|
|
|
25
28
|
importSkills,
|
|
26
29
|
mergeModelsFile,
|
|
27
30
|
publicModels,
|
|
31
|
+
refreshOpenCodeFreeCatalog,
|
|
32
|
+
refreshReasoningCatalog,
|
|
28
33
|
listBench,
|
|
29
34
|
listLibrary,
|
|
30
35
|
listMcpServers,
|
|
@@ -73,10 +78,13 @@ const PAGES: Record<string, { file: string; type: string }> = {
|
|
|
73
78
|
"/studio": { file: "studio.html", type: "text/html; charset=utf-8" },
|
|
74
79
|
"/skills/add": { file: "skills-add.html", type: "text/html; charset=utf-8" },
|
|
75
80
|
"/chat": { file: "chat.html", type: "text/html; charset=utf-8" },
|
|
81
|
+
"/m": { file: "mobile.html", type: "text/html; charset=utf-8" },
|
|
76
82
|
"/style.css": { file: "style.css", type: "text/css; charset=utf-8" },
|
|
77
83
|
"/chat.css": { file: "chat.css", type: "text/css; charset=utf-8" },
|
|
84
|
+
"/mobile.css": { file: "mobile.css", type: "text/css; charset=utf-8" },
|
|
78
85
|
"/md.js": { file: "md.js", type: "text/javascript; charset=utf-8" },
|
|
79
86
|
"/i18n.js": { file: "i18n.js", type: "text/javascript; charset=utf-8" },
|
|
87
|
+
"/buddy.js": { file: "buddy.js", type: "text/javascript; charset=utf-8" },
|
|
80
88
|
"/favicon.ico": { file: "favicon.ico", type: "image/x-icon" },
|
|
81
89
|
"/favicon.svg": { file: "favicon.svg", type: "image/svg+xml" },
|
|
82
90
|
"/favicon-16.svg": { file: "favicon-16.svg", type: "image/svg+xml" },
|
|
@@ -95,11 +103,107 @@ const LIBRARY_KINDS = new Set<LibraryKind>([
|
|
|
95
103
|
"subagents",
|
|
96
104
|
]);
|
|
97
105
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Same-origin guard. The hall UI is served by this daemon (`http://127.0.0.1:7420/`
|
|
108
|
+
* and the Tailscale address), so it never needs CORS. A request that carries an
|
|
109
|
+
* `Origin` from somewhere else is a foreign page trying to read local files
|
|
110
|
+
* (`/host/read`), so it is refused before any route runs.
|
|
111
|
+
*/
|
|
112
|
+
const LOCAL_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
113
|
+
/** Tailscale MagicDNS names: `machine.tailnet.ts.net`. */
|
|
114
|
+
const MAGIC_DNS_SUFFIX = ".ts.net";
|
|
115
|
+
|
|
116
|
+
function headerLine(value: string | string[] | undefined): string {
|
|
117
|
+
const first = Array.isArray(value) ? value[0] : value;
|
|
118
|
+
return typeof first === "string" ? first.trim() : "";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeHost(host: string): string {
|
|
122
|
+
const lower = host.trim().toLowerCase().replace(/\.$/, "");
|
|
123
|
+
return LOCAL_HOSTS.has(lower) ? "127.0.0.1" : lower;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Host header is an authority, not a URL: `127.0.0.1:7420`, `[::1]:7420`, `bot.local`. */
|
|
127
|
+
function hostPortOf(authority: string): { host: string; port: string } {
|
|
128
|
+
const value = authority.trim();
|
|
129
|
+
if (!value) return { host: "", port: "" };
|
|
130
|
+
if (value.startsWith("[")) {
|
|
131
|
+
const end = value.indexOf("]");
|
|
132
|
+
const host = end === -1 ? value : value.slice(0, end + 1);
|
|
133
|
+
const rest = end === -1 ? "" : value.slice(end + 1);
|
|
134
|
+
const colon = rest.indexOf(":");
|
|
135
|
+
return { host, port: colon === -1 ? "" : rest.slice(colon + 1) };
|
|
136
|
+
}
|
|
137
|
+
const colon = value.indexOf(":");
|
|
138
|
+
if (colon === -1) return { host: value, port: "" };
|
|
139
|
+
return { host: value.slice(0, colon), port: value.slice(colon + 1) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Dotted-quad octets, or null when this is not an IPv4 literal. */
|
|
143
|
+
function ipv4Octets(host: string): number[] | null {
|
|
144
|
+
const parts = host.split(".");
|
|
145
|
+
if (parts.length !== 4) return null;
|
|
146
|
+
const octets: number[] = [];
|
|
147
|
+
for (const part of parts) {
|
|
148
|
+
if (!/^\d{1,3}$/.test(part)) return null;
|
|
149
|
+
const value = Number(part);
|
|
150
|
+
if (!Number.isInteger(value) || value > 255) return null;
|
|
151
|
+
octets.push(value);
|
|
152
|
+
}
|
|
153
|
+
return octets;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* True only for authorities that cannot be a public name: loopback, RFC1918,
|
|
158
|
+
* CGNAT / Tailscale 100.64/10, and MagicDNS `*.ts.net`. A rebinding page
|
|
159
|
+
* (`evil.com` resolving to 127.0.0.1) matches Origin against Host, so the
|
|
160
|
+
* authority itself has to be checked too.
|
|
161
|
+
*/
|
|
162
|
+
export function isLocalAuthority(raw: string): boolean {
|
|
163
|
+
let host = String(raw || "").trim().toLowerCase().replace(/\.$/, "");
|
|
164
|
+
if (!host) return false;
|
|
165
|
+
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
166
|
+
if (host === "::1" || host === "0:0:0:0:0:0:0:1") return true;
|
|
167
|
+
if (LOCAL_HOSTS.has(host)) return true;
|
|
168
|
+
const octets = ipv4Octets(host);
|
|
169
|
+
if (octets) {
|
|
170
|
+
const [first, second] = octets as [number, number, number, number];
|
|
171
|
+
if (first === 127) return true; // 127/8 loopback
|
|
172
|
+
if (first === 10) return true; // RFC1918
|
|
173
|
+
if (first === 172 && second >= 16 && second <= 31) return true;
|
|
174
|
+
if (first === 192 && second === 168) return true;
|
|
175
|
+
if (first === 100 && second >= 64 && second <= 127) return true; // CGNAT
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
return host.endsWith(MAGIC_DNS_SUFFIX);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** True when there is no Origin (curl, Node fetch, same-origin opaque) or it matches Host. */
|
|
182
|
+
export function sameOrigin(req: IncomingMessage): boolean {
|
|
183
|
+
const raw = headerLine(req.headers.origin);
|
|
184
|
+
if (!raw) return true;
|
|
185
|
+
let origin: URL;
|
|
186
|
+
try {
|
|
187
|
+
origin = new URL(raw);
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
const secure = origin.protocol === "https:";
|
|
192
|
+
if (!secure && origin.protocol !== "http:") return false;
|
|
193
|
+
const host = headerLine(req.headers.host);
|
|
194
|
+
if (!host) return false;
|
|
195
|
+
const wanted = hostPortOf(host);
|
|
196
|
+
// Origin == Host is not enough: DNS rebinding makes a public name resolve to
|
|
197
|
+
// this machine, so both authorities must be loopback / private.
|
|
198
|
+
if (!isLocalAuthority(wanted.host) || !isLocalAuthority(origin.hostname)) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
return (
|
|
202
|
+
normalizeHost(wanted.host) === normalizeHost(origin.hostname) &&
|
|
203
|
+
(wanted.port || (secure ? "443" : "80")) ===
|
|
204
|
+
(origin.port || (secure ? "443" : "80"))
|
|
205
|
+
);
|
|
206
|
+
}
|
|
103
207
|
|
|
104
208
|
function send(
|
|
105
209
|
res: ServerResponse,
|
|
@@ -112,7 +216,6 @@ function send(
|
|
|
112
216
|
"content-type": type,
|
|
113
217
|
"content-length": Buffer.byteLength(body),
|
|
114
218
|
"cache-control": "no-store",
|
|
115
|
-
...CORS_HEADERS,
|
|
116
219
|
...extra,
|
|
117
220
|
});
|
|
118
221
|
res.end(body);
|
|
@@ -180,6 +283,16 @@ function strList(record: Record<string, unknown>, key: string): string[] {
|
|
|
180
283
|
return [];
|
|
181
284
|
}
|
|
182
285
|
|
|
286
|
+
function extrasWithMentions(
|
|
287
|
+
extras: HandlerExtras,
|
|
288
|
+
body: Record<string, unknown>,
|
|
289
|
+
): HandlerExtras {
|
|
290
|
+
const mentions = strList(body, "mentions")
|
|
291
|
+
.map((id) => id.trim())
|
|
292
|
+
.filter(Boolean);
|
|
293
|
+
return mentions.length ? { ...extras, mentions } : extras;
|
|
294
|
+
}
|
|
295
|
+
|
|
183
296
|
function skillPickCatalog(
|
|
184
297
|
record: Record<string, unknown>,
|
|
185
298
|
): { id: string; name: string; description?: string; tags?: string[]; slug?: string }[] {
|
|
@@ -244,8 +357,12 @@ export async function handleRequest(
|
|
|
244
357
|
const path = pathname(req);
|
|
245
358
|
|
|
246
359
|
try {
|
|
360
|
+
if (!sameOrigin(req)) {
|
|
361
|
+
throw new StoreError(403, "cross-origin refused");
|
|
362
|
+
}
|
|
363
|
+
|
|
247
364
|
if (method === "OPTIONS") {
|
|
248
|
-
res.writeHead(204
|
|
365
|
+
res.writeHead(204);
|
|
249
366
|
res.end();
|
|
250
367
|
return;
|
|
251
368
|
}
|
|
@@ -258,7 +375,12 @@ export async function handleRequest(
|
|
|
258
375
|
}
|
|
259
376
|
|
|
260
377
|
if (method === "GET" && page) {
|
|
261
|
-
const
|
|
378
|
+
const file = `${PUBLIC}${page.file}`;
|
|
379
|
+
if (!existsSync(file)) {
|
|
380
|
+
json(res, 404, { error: "not_found", path });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const body = readFileSync(file);
|
|
262
384
|
const extra = page.type.startsWith("image/")
|
|
263
385
|
? { "cache-control": "public, max-age=86400" }
|
|
264
386
|
: {};
|
|
@@ -289,7 +411,6 @@ export async function handleRequest(
|
|
|
289
411
|
"content-type": type,
|
|
290
412
|
"content-length": bytes.length,
|
|
291
413
|
"cache-control": "private, max-age=86400",
|
|
292
|
-
...CORS_HEADERS,
|
|
293
414
|
});
|
|
294
415
|
res.end(bytes);
|
|
295
416
|
return;
|
|
@@ -352,6 +473,36 @@ export async function handleRequest(
|
|
|
352
473
|
json(res, 201, createChannel(store, str(body, "name")));
|
|
353
474
|
return;
|
|
354
475
|
}
|
|
476
|
+
const channelBranch = path.match(/^\/channels\/([^/]+)\/branches$/);
|
|
477
|
+
if (channelBranch && method === "POST") {
|
|
478
|
+
const body = asRecord(await readJson(req));
|
|
479
|
+
json(
|
|
480
|
+
res,
|
|
481
|
+
201,
|
|
482
|
+
createBranch(
|
|
483
|
+
store,
|
|
484
|
+
decodeURIComponent(channelBranch[1]),
|
|
485
|
+
str(body, "messageId"),
|
|
486
|
+
str(body, "name") || undefined,
|
|
487
|
+
),
|
|
488
|
+
);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const channelClose = path.match(/^\/channels\/([^/]+)\/close$/);
|
|
492
|
+
if (channelClose && method === "POST") {
|
|
493
|
+
const body = asRecord(await readJson(req));
|
|
494
|
+
json(
|
|
495
|
+
res,
|
|
496
|
+
200,
|
|
497
|
+
await closeBranch(
|
|
498
|
+
store,
|
|
499
|
+
decodeURIComponent(channelClose[1]),
|
|
500
|
+
body.merge === true,
|
|
501
|
+
env,
|
|
502
|
+
),
|
|
503
|
+
);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
355
506
|
const channelOne = path.match(/^\/channels\/([^/]+)$/);
|
|
356
507
|
if (channelOne && method === "DELETE") {
|
|
357
508
|
json(res, 200, deleteChannel(store, decodeURIComponent(channelOne[1])));
|
|
@@ -480,7 +631,7 @@ export async function handleRequest(
|
|
|
480
631
|
str(body, "body") || undefined,
|
|
481
632
|
env,
|
|
482
633
|
str(body, "assigneeId") || undefined,
|
|
483
|
-
extras,
|
|
634
|
+
extrasWithMentions(extras, body),
|
|
484
635
|
),
|
|
485
636
|
);
|
|
486
637
|
return;
|
|
@@ -502,7 +653,7 @@ export async function handleRequest(
|
|
|
502
653
|
str(body, "body") || undefined,
|
|
503
654
|
env,
|
|
504
655
|
str(body, "assigneeId") || undefined,
|
|
505
|
-
extras,
|
|
656
|
+
extrasWithMentions(extras, body),
|
|
506
657
|
),
|
|
507
658
|
);
|
|
508
659
|
return;
|
|
@@ -639,7 +790,7 @@ export async function handleRequest(
|
|
|
639
790
|
str(body, "replyTo") || undefined,
|
|
640
791
|
parseAttachments(body.attachments),
|
|
641
792
|
str(body, "assigneeId") || undefined,
|
|
642
|
-
extras,
|
|
793
|
+
extrasWithMentions(extras, body),
|
|
643
794
|
),
|
|
644
795
|
);
|
|
645
796
|
return;
|
|
@@ -665,7 +816,7 @@ export async function handleRequest(
|
|
|
665
816
|
str(body, "replyTo") || undefined,
|
|
666
817
|
parseAttachments(body.attachments),
|
|
667
818
|
str(body, "assigneeId") || undefined,
|
|
668
|
-
extras,
|
|
819
|
+
extrasWithMentions(extras, body),
|
|
669
820
|
),
|
|
670
821
|
);
|
|
671
822
|
return;
|
|
@@ -806,9 +957,19 @@ export async function handleRequest(
|
|
|
806
957
|
|
|
807
958
|
if (method === "GET" && path === "/settings/models") {
|
|
808
959
|
await refreshCopilotCatalog(store.dataDir);
|
|
960
|
+
await refreshOpenCodeFreeCatalog(store.dataDir);
|
|
961
|
+
await refreshReasoningCatalog().catch(() => {});
|
|
809
962
|
json(res, 200, publicModels(store.dataDir));
|
|
810
963
|
return;
|
|
811
964
|
}
|
|
965
|
+
if (method === "POST" && path === "/settings/models/opencode-free/sync") {
|
|
966
|
+
const refreshed = await refreshOpenCodeFreeCatalog(store.dataDir, true);
|
|
967
|
+
json(res, 200, {
|
|
968
|
+
...publicModels(store.dataDir),
|
|
969
|
+
probe: refreshed.probe ?? [],
|
|
970
|
+
});
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
812
973
|
if (method === "PUT" && path === "/settings/models") {
|
|
813
974
|
const body = asRecord(await readJson(req));
|
|
814
975
|
const providers = body.providers;
|
|
@@ -891,6 +1052,12 @@ export async function handleRequest(
|
|
|
891
1052
|
return;
|
|
892
1053
|
}
|
|
893
1054
|
|
|
1055
|
+
const lookId = path.match(/^\/bots\/([^/]+)\/look$/)?.[1];
|
|
1056
|
+
if (lookId && method === "POST") {
|
|
1057
|
+
json(res, 200, await generateBotLook(store, decodeURIComponent(lookId), env));
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
894
1061
|
const botId = path.match(/^\/bots\/([^/]+)$/)?.[1];
|
|
895
1062
|
if (botId && method === "GET") {
|
|
896
1063
|
json(res, 200, getBotDetail(store, botId));
|
|
@@ -906,6 +1073,9 @@ export async function handleRequest(
|
|
|
906
1073
|
name: str(body, "name") || undefined,
|
|
907
1074
|
handle: str(body, "handle") || undefined,
|
|
908
1075
|
oneLiner: str(body, "oneLiner") || undefined,
|
|
1076
|
+
...(Object.hasOwn(body, "portrait")
|
|
1077
|
+
? { portrait: body.portrait == null ? null : str(body, "portrait") }
|
|
1078
|
+
: {}),
|
|
909
1079
|
skillIds: Object.hasOwn(body, "skillIds")
|
|
910
1080
|
? strList(body, "skillIds")
|
|
911
1081
|
: undefined,
|
|
@@ -962,6 +1132,7 @@ export async function handleRequest(
|
|
|
962
1132
|
json(res, error.status, { error: error.message });
|
|
963
1133
|
return;
|
|
964
1134
|
}
|
|
1135
|
+
console.error(error);
|
|
965
1136
|
json(res, 500, { error: "internal_error" });
|
|
966
1137
|
}
|
|
967
1138
|
}
|