@kevin5251984/guild 0.2.18 → 0.2.19
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 +1 -1
- package/src/catalog/subagents.ts +2 -1
- package/src/chat-parts.ts +6 -1
- package/src/compact.ts +27 -0
- package/src/db.ts +199 -64
- package/src/generate.ts +58 -27
- package/src/handlers.ts +341 -54
- package/src/harness.ts +21 -8
- package/src/image-gen.ts +2 -1
- package/src/llm.ts +400 -58
- package/src/memory.ts +69 -0
- package/src/mention.ts +119 -12
- package/src/oauth.ts +202 -25
- package/src/opencode-free.ts +247 -0
- package/src/public/buddy.js +432 -0
- package/src/public/chat.css +174 -72
- package/src/public/chat.html +212 -43
- package/src/public/i18n.js +41 -6
- package/src/public/index.html +1 -46
- package/src/public/mobile.css +491 -0
- package/src/public/mobile.html +784 -0
- package/src/public/settings.html +188 -27
- 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/router.ts +76 -5
- package/src/store.ts +161 -5
- package/src/subagent.ts +233 -6
- package/src/tools.ts +124 -18
- package/src/trajectory.ts +72 -9
- package/src/usage.ts +10 -0
- package/vendor/protocol/src/index.ts +14 -1
package/src/mention.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
/** @channel / @quest / @here / @all still mean the whole quest. */
|
|
2
2
|
const BROADCAST = /(^|\s)@(channel|quest|here|all)\b/i;
|
|
3
3
|
const HANDLE = /@([A-Za-z0-9_-]+)/g;
|
|
4
|
-
/** Consecutive @handles at the start of a line. */
|
|
5
|
-
const LINE_LEAD = /^[ \t]*((
|
|
4
|
+
/** Consecutive @handles at the start of a line (optional backticks). */
|
|
5
|
+
const LINE_LEAD = /^[ \t]*((?:`?@[A-Za-z0-9_-]+`?[\s,、]*)+)/;
|
|
6
|
+
/** `1. @design` / `- @infra`. */
|
|
7
|
+
const LIST_MARK = /^[ \t]*(?:\d+[.)、]\s*|[-*•]\s+)/;
|
|
8
|
+
/** Assignee right after the marker, or after 通過後/最後/再叫… — not the first @ anywhere on the line. */
|
|
9
|
+
const LIST_ASSIGN =
|
|
10
|
+
/^(?:`?@([A-Za-z0-9_-]+)`?|(?:通過後|最後|再叫|交給|交棒(?:給)?|指派|(?:call|ask)\s+)\s*`?@([A-Za-z0-9_-]+)`?)/i;
|
|
6
11
|
|
|
7
12
|
/** Drop fenced / inline code so `@pm` in a snippet does not dispatch. */
|
|
8
13
|
export function stripMentionNoise(text: string): string {
|
|
@@ -32,13 +37,24 @@ export type MentionBlock = {
|
|
|
32
37
|
start: number;
|
|
33
38
|
end: number;
|
|
34
39
|
handles: string[];
|
|
40
|
+
kind: "lead" | "list";
|
|
35
41
|
};
|
|
36
42
|
|
|
37
|
-
|
|
43
|
+
function knownHandlesIn(chunk: string, known: Set<string>): string[] {
|
|
44
|
+
const names: string[] = [];
|
|
45
|
+
for (const row of chunk.matchAll(HANDLE)) {
|
|
46
|
+
const key = row[1].toLowerCase();
|
|
47
|
+
if (!known.has(key) || names.includes(key)) continue;
|
|
48
|
+
names.push(key);
|
|
49
|
+
}
|
|
50
|
+
return names;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Line-start @handle groups, plus markdown list items that name a seat. */
|
|
38
54
|
export function lineStartMentions(text: string, handles: string[]): MentionBlock[] {
|
|
39
55
|
const raw = String(text ?? "");
|
|
40
56
|
const known = new Set(handles.map((handle) => handle.toLowerCase()));
|
|
41
|
-
const starts: { start: number; handles: string[] }[] = [];
|
|
57
|
+
const starts: { start: number; handles: string[]; kind: "lead" | "list" }[] = [];
|
|
42
58
|
let fence = false;
|
|
43
59
|
let offset = 0;
|
|
44
60
|
for (const part of raw.split(/(\r?\n)/)) {
|
|
@@ -53,15 +69,22 @@ export function lineStartMentions(text: string, handles: string[]): MentionBlock
|
|
|
53
69
|
continue;
|
|
54
70
|
}
|
|
55
71
|
if (!fence) {
|
|
72
|
+
const listed = LIST_MARK.test(part);
|
|
56
73
|
const lead = part.match(LINE_LEAD);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
74
|
+
let names: string[] = [];
|
|
75
|
+
if (lead) names = knownHandlesIn(lead[1], known);
|
|
76
|
+
if (!names.length && listed) {
|
|
77
|
+
const rest = part.replace(LIST_MARK, "");
|
|
78
|
+
const hit = rest.match(LIST_ASSIGN);
|
|
79
|
+
const key = (hit?.[1] || hit?.[2] || "").toLowerCase();
|
|
80
|
+
if (key && known.has(key)) names = [key];
|
|
81
|
+
}
|
|
82
|
+
if (names.length) {
|
|
83
|
+
starts.push({
|
|
84
|
+
start: offset,
|
|
85
|
+
handles: names,
|
|
86
|
+
kind: listed ? "list" : "lead",
|
|
87
|
+
});
|
|
65
88
|
}
|
|
66
89
|
}
|
|
67
90
|
offset += part.length;
|
|
@@ -70,6 +93,7 @@ export function lineStartMentions(text: string, handles: string[]): MentionBlock
|
|
|
70
93
|
start: row.start,
|
|
71
94
|
end: i + 1 < starts.length ? starts[i + 1].start : raw.length,
|
|
72
95
|
handles: row.handles,
|
|
96
|
+
kind: row.kind,
|
|
73
97
|
}));
|
|
74
98
|
}
|
|
75
99
|
|
|
@@ -104,6 +128,87 @@ export function summonedHandles(text: string, handles: string[]): string[] {
|
|
|
104
128
|
return [];
|
|
105
129
|
}
|
|
106
130
|
|
|
131
|
+
/** Fenced samples only. Keep inline `code` so 「再叫 `@infra`」 still parses. */
|
|
132
|
+
function stripFences(text: string): string {
|
|
133
|
+
return String(text ?? "").replace(/```[\s\S]*?```/g, " ");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Bot replies: line-start specs, else first prose @handle, plus assignment verbs
|
|
138
|
+
* even when the handle is wrapped in backticks (models quote @infra a lot).
|
|
139
|
+
* Fixture chatter like `(@pm / @rd)` has no verb, so it does not hop.
|
|
140
|
+
*/
|
|
141
|
+
const HANDOFF_ASK =
|
|
142
|
+
/(?:(?:再叫|交給|交棒(?:給)?|指派)\s*|(?:call|ask|ping|notify|handoff(?:\s+to)?)\s+)`?@([A-Za-z0-9_-]+)`?/gi;
|
|
143
|
+
|
|
144
|
+
export function handoffHandles(text: string, handles: string[]): string[] {
|
|
145
|
+
const known = new Set(handles.map((handle) => handle.toLowerCase()));
|
|
146
|
+
const out = summonedHandles(text, handles);
|
|
147
|
+
const ask = new RegExp(HANDOFF_ASK.source, "gi");
|
|
148
|
+
for (const row of stripFences(text).matchAll(ask)) {
|
|
149
|
+
const key = row[1].toLowerCase();
|
|
150
|
+
if (!known.has(key) || out.includes(key)) continue;
|
|
151
|
+
out.push(key);
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type MentionBot = { id: string; handle: string };
|
|
157
|
+
|
|
158
|
+
export function sanitizeMentionIds(
|
|
159
|
+
raw: unknown,
|
|
160
|
+
bots: MentionBot[],
|
|
161
|
+
): string[] {
|
|
162
|
+
if (!Array.isArray(raw)) return [];
|
|
163
|
+
const known = new Set(bots.map((bot) => bot.id));
|
|
164
|
+
const out: string[] = [];
|
|
165
|
+
for (const item of raw) {
|
|
166
|
+
if (typeof item !== "string") continue;
|
|
167
|
+
const id = item.trim();
|
|
168
|
+
if (!id || !known.has(id) || out.includes(id)) continue;
|
|
169
|
+
out.push(id);
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function parseMentionIds(
|
|
175
|
+
text: string,
|
|
176
|
+
bots: MentionBot[],
|
|
177
|
+
kind: "user" | "bot",
|
|
178
|
+
): string[] {
|
|
179
|
+
if (isBroadcastMention(text)) return [];
|
|
180
|
+
const handles = bots.map((bot) => bot.handle);
|
|
181
|
+
const names =
|
|
182
|
+
kind === "bot" ? handoffHandles(text, handles) : summonedHandles(text, handles);
|
|
183
|
+
const out: string[] = [];
|
|
184
|
+
for (const name of names) {
|
|
185
|
+
const bot = bots.find(
|
|
186
|
+
(row) => row.handle.toLowerCase() === name.toLowerCase(),
|
|
187
|
+
);
|
|
188
|
+
if (!bot || out.includes(bot.id)) continue;
|
|
189
|
+
out.push(bot.id);
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Prefer the stored mention list. Older rows without the field still parse the body.
|
|
196
|
+
* An empty stored list means nobody.
|
|
197
|
+
*/
|
|
198
|
+
export function messageMentionIds(
|
|
199
|
+
message: { author: string; body: string; mentions?: string[] },
|
|
200
|
+
bots: MentionBot[],
|
|
201
|
+
): string[] {
|
|
202
|
+
const ids = Array.isArray(message.mentions)
|
|
203
|
+
? sanitizeMentionIds(message.mentions, bots)
|
|
204
|
+
: parseMentionIds(
|
|
205
|
+
message.body,
|
|
206
|
+
bots,
|
|
207
|
+
message.author === "you" ? "user" : "bot",
|
|
208
|
+
);
|
|
209
|
+
return ids.filter((id) => id !== message.author);
|
|
210
|
+
}
|
|
211
|
+
|
|
107
212
|
/**
|
|
108
213
|
* Spec for one seat: shared preamble plus that @handle's line-start block.
|
|
109
214
|
* Full text when the handle was only summoned in prose.
|
|
@@ -119,6 +224,8 @@ export function assignmentFor(
|
|
|
119
224
|
if (!key || !blocks.length) return raw;
|
|
120
225
|
const mine = blocks.filter((block) => block.handles.includes(key));
|
|
121
226
|
if (!mine.length) return raw;
|
|
227
|
+
// Numbered plans share constraints ("don't ship until PM signs off"). Slice only classic specs.
|
|
228
|
+
if (!blocks.some((block) => block.kind === "lead")) return raw;
|
|
122
229
|
const preamble = raw.slice(0, blocks[0].start).trim();
|
|
123
230
|
const chunks = mine.map((block) => raw.slice(block.start, block.end).trim());
|
|
124
231
|
return [preamble, ...chunks].filter(Boolean).join("\n\n");
|
package/src/oauth.ts
CHANGED
|
@@ -1057,6 +1057,109 @@ function thinkingText(message: AssistantMessage | undefined): string {
|
|
|
1057
1057
|
.trim();
|
|
1058
1058
|
}
|
|
1059
1059
|
|
|
1060
|
+
/**
|
|
1061
|
+
* Codex `DEFAULT_STREAM_IDLE_TIMEOUT_MS`. No tokens on the stream — not a
|
|
1062
|
+
* wall clock on the whole turn. Do not pass this as Pi `timeoutMs` for xAI:
|
|
1063
|
+
* OpenAI-completions maps that to the SDK request timeout and kills thinking.
|
|
1064
|
+
*/
|
|
1065
|
+
export const STREAM_IDLE_TIMEOUT_MS = 300_000;
|
|
1066
|
+
|
|
1067
|
+
export function isTransientLlmError(err: unknown): boolean {
|
|
1068
|
+
if (err instanceof StreamIdleError) return false;
|
|
1069
|
+
const text = err instanceof Error ? `${err.name} ${err.message}` : String(err);
|
|
1070
|
+
return /econnreset|econnrefused|etimedout|enotfound|eai_again|fetch failed|socket|network|empty reply|429|502|503|504|5\d\d/i.test(
|
|
1071
|
+
text,
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
export async function withTransientRetries<T>(
|
|
1076
|
+
run: () => Promise<T>,
|
|
1077
|
+
opts: {
|
|
1078
|
+
signal?: AbortSignal;
|
|
1079
|
+
attempts?: number;
|
|
1080
|
+
onRetry?: (attempt: number, err: unknown) => void;
|
|
1081
|
+
} = {},
|
|
1082
|
+
): Promise<T> {
|
|
1083
|
+
const attempts = opts.attempts ?? 3;
|
|
1084
|
+
let last: unknown;
|
|
1085
|
+
for (let i = 0; i < attempts; i++) {
|
|
1086
|
+
if (opts.signal?.aborted) {
|
|
1087
|
+
throw userAborted(opts.signal);
|
|
1088
|
+
}
|
|
1089
|
+
try {
|
|
1090
|
+
return await run();
|
|
1091
|
+
} catch (err) {
|
|
1092
|
+
last = err;
|
|
1093
|
+
if (err instanceof Error && err.name === "AbortError") throw err;
|
|
1094
|
+
if (opts.signal?.aborted) throw err;
|
|
1095
|
+
if (!isTransientLlmError(err) || i === attempts - 1) throw err;
|
|
1096
|
+
opts.onRetry?.(i + 1, err);
|
|
1097
|
+
await new Promise((resolve) => setTimeout(resolve, 400 * (i + 1)));
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
throw last instanceof Error ? last : new Error(String(last));
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
export class StreamIdleError extends Error {
|
|
1104
|
+
readonly idleMs: number;
|
|
1105
|
+
constructor(idleMs: number) {
|
|
1106
|
+
super(
|
|
1107
|
+
`stream idle: no tokens for ${Math.round(idleMs / 1000)}s (Codex-style; not a turn wall clock). Resend or switch models.`,
|
|
1108
|
+
);
|
|
1109
|
+
this.name = "StreamIdleError";
|
|
1110
|
+
this.idleMs = idleMs;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
export function startStreamIdle(
|
|
1115
|
+
idleMs: number,
|
|
1116
|
+
parent?: AbortSignal,
|
|
1117
|
+
): {
|
|
1118
|
+
signal: AbortSignal;
|
|
1119
|
+
bump: () => void;
|
|
1120
|
+
dispose: () => void;
|
|
1121
|
+
timedOut: () => boolean;
|
|
1122
|
+
} {
|
|
1123
|
+
const ctrl = new AbortController();
|
|
1124
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1125
|
+
let timedOut = false;
|
|
1126
|
+
const fire = () => {
|
|
1127
|
+
if (timedOut || ctrl.signal.aborted) return;
|
|
1128
|
+
timedOut = true;
|
|
1129
|
+
ctrl.abort();
|
|
1130
|
+
};
|
|
1131
|
+
const bump = () => {
|
|
1132
|
+
if (timedOut || parent?.aborted) return;
|
|
1133
|
+
if (timer) clearTimeout(timer);
|
|
1134
|
+
timer = setTimeout(fire, idleMs);
|
|
1135
|
+
};
|
|
1136
|
+
const onParent = () => {
|
|
1137
|
+
if (timer) clearTimeout(timer);
|
|
1138
|
+
ctrl.abort();
|
|
1139
|
+
};
|
|
1140
|
+
if (parent?.aborted) {
|
|
1141
|
+
ctrl.abort();
|
|
1142
|
+
} else {
|
|
1143
|
+
parent?.addEventListener("abort", onParent, { once: true });
|
|
1144
|
+
}
|
|
1145
|
+
bump();
|
|
1146
|
+
return {
|
|
1147
|
+
signal: ctrl.signal,
|
|
1148
|
+
bump,
|
|
1149
|
+
dispose: () => {
|
|
1150
|
+
if (timer) clearTimeout(timer);
|
|
1151
|
+
parent?.removeEventListener("abort", onParent);
|
|
1152
|
+
},
|
|
1153
|
+
timedOut: () => timedOut,
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function userAborted(signal?: AbortSignal): Error {
|
|
1158
|
+
const err = new Error("aborted");
|
|
1159
|
+
err.name = "AbortError";
|
|
1160
|
+
return err;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1060
1163
|
/** Pi streamSimple: Think chips only from thinking_delta, not a planted placeholder. */
|
|
1061
1164
|
async function streamOAuthMessage(
|
|
1062
1165
|
models: MutableModels,
|
|
@@ -1066,7 +1169,11 @@ async function streamOAuthMessage(
|
|
|
1066
1169
|
toolCtx: ToolContext,
|
|
1067
1170
|
traces: ToolTrace[],
|
|
1068
1171
|
): Promise<AssistantMessage> {
|
|
1069
|
-
const
|
|
1172
|
+
const idle = startStreamIdle(STREAM_IDLE_TIMEOUT_MS, options?.signal);
|
|
1173
|
+
const stream = models.streamSimple(model, context, {
|
|
1174
|
+
...options,
|
|
1175
|
+
signal: idle.signal,
|
|
1176
|
+
});
|
|
1070
1177
|
let lastEmit = 0;
|
|
1071
1178
|
const flush = (partial: AssistantMessage | undefined, force: boolean) => {
|
|
1072
1179
|
const think = thinkingText(partial);
|
|
@@ -1076,19 +1183,40 @@ async function streamOAuthMessage(
|
|
|
1076
1183
|
lastEmit = now;
|
|
1077
1184
|
emitProgress(toolCtx, traces, think);
|
|
1078
1185
|
};
|
|
1079
|
-
|
|
1080
|
-
if (
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1186
|
+
const failIfIdle = (): never => {
|
|
1187
|
+
if (options?.signal?.aborted) throw userAborted(options.signal);
|
|
1188
|
+
throw new StreamIdleError(STREAM_IDLE_TIMEOUT_MS);
|
|
1189
|
+
};
|
|
1190
|
+
try {
|
|
1191
|
+
for await (const event of stream) {
|
|
1192
|
+
idle.bump();
|
|
1193
|
+
if (event.type === "thinking_start" || event.type === "thinking_delta") {
|
|
1194
|
+
flush(event.partial, event.type === "thinking_start");
|
|
1195
|
+
} else if (event.type === "thinking_end") {
|
|
1196
|
+
flush(event.partial, true);
|
|
1197
|
+
} else if (event.type === "done") {
|
|
1198
|
+
flush(event.message, true);
|
|
1199
|
+
return event.message;
|
|
1200
|
+
} else if (event.type === "error") {
|
|
1201
|
+
if (idle.timedOut() || options?.signal?.aborted) failIfIdle();
|
|
1202
|
+
return event.error;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
if (idle.timedOut() || options?.signal?.aborted) failIfIdle();
|
|
1206
|
+
return stream.result();
|
|
1207
|
+
} catch (err) {
|
|
1208
|
+
if (err instanceof StreamIdleError) throw err;
|
|
1209
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
1210
|
+
if (options?.signal?.aborted) throw err;
|
|
1211
|
+
if (idle.timedOut()) throw new StreamIdleError(STREAM_IDLE_TIMEOUT_MS);
|
|
1212
|
+
}
|
|
1213
|
+
if (idle.timedOut() && !(options?.signal?.aborted)) {
|
|
1214
|
+
throw new StreamIdleError(STREAM_IDLE_TIMEOUT_MS);
|
|
1089
1215
|
}
|
|
1216
|
+
throw err;
|
|
1217
|
+
} finally {
|
|
1218
|
+
idle.dispose();
|
|
1090
1219
|
}
|
|
1091
|
-
return stream.result();
|
|
1092
1220
|
}
|
|
1093
1221
|
|
|
1094
1222
|
function abortWhen(signal?: AbortSignal): Promise<never> {
|
|
@@ -1252,21 +1380,70 @@ export async function completeOAuth(input: {
|
|
|
1252
1380
|
if (fitted.length < transcript.length) {
|
|
1253
1381
|
transcript.splice(0, transcript.length, ...fitted);
|
|
1254
1382
|
}
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1383
|
+
let result: AssistantMessage;
|
|
1384
|
+
try {
|
|
1385
|
+
result = await withTransientRetries(
|
|
1386
|
+
async () => {
|
|
1387
|
+
const got = await Promise.race([
|
|
1388
|
+
streamOAuthMessage(
|
|
1389
|
+
models,
|
|
1390
|
+
model,
|
|
1391
|
+
{
|
|
1392
|
+
systemPrompt: input.system,
|
|
1393
|
+
messages: transcript,
|
|
1394
|
+
...(useTools ? { tools } : {}),
|
|
1395
|
+
},
|
|
1396
|
+
options,
|
|
1397
|
+
toolCtx,
|
|
1398
|
+
traces,
|
|
1399
|
+
),
|
|
1400
|
+
abortWhen(options.signal),
|
|
1401
|
+
]);
|
|
1402
|
+
if (got.stopReason === "aborted") {
|
|
1403
|
+
const err = new Error("aborted");
|
|
1404
|
+
err.name = "AbortError";
|
|
1405
|
+
throw err;
|
|
1406
|
+
}
|
|
1407
|
+
if (got.stopReason === "error") {
|
|
1408
|
+
throw new Error(
|
|
1409
|
+
formatOAuthError(sub.id, got.errorMessage) ||
|
|
1410
|
+
`${sub.id} request failed`,
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1413
|
+
const text = contentText(got.content).trim();
|
|
1414
|
+
const toolCalls =
|
|
1415
|
+
got.stopReason === "toolUse"
|
|
1416
|
+
? got.content.filter(
|
|
1417
|
+
(part): part is Extract<typeof part, { type: "toolCall" }> =>
|
|
1418
|
+
part.type === "toolCall",
|
|
1419
|
+
)
|
|
1420
|
+
: [];
|
|
1421
|
+
if (!toolCalls.length && !text && traces.length === 0) {
|
|
1422
|
+
throw new Error(`${sub.id} returned an empty reply`);
|
|
1423
|
+
}
|
|
1424
|
+
return got;
|
|
1425
|
+
},
|
|
1259
1426
|
{
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1427
|
+
signal: options.signal,
|
|
1428
|
+
onRetry: () => {
|
|
1429
|
+
emitProgress(
|
|
1430
|
+
toolCtx,
|
|
1431
|
+
traces,
|
|
1432
|
+
thinkingChunks.join("\n\n") || "連線中斷,重試中…",
|
|
1433
|
+
);
|
|
1434
|
+
},
|
|
1263
1435
|
},
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1436
|
+
);
|
|
1437
|
+
} catch (err) {
|
|
1438
|
+
if (err instanceof StreamIdleError) {
|
|
1439
|
+
return {
|
|
1440
|
+
calls: [],
|
|
1441
|
+
text: err.message,
|
|
1442
|
+
thinking: thinkingChunks.join("\n\n"),
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
throw err;
|
|
1446
|
+
}
|
|
1270
1447
|
if (result.stopReason === "aborted" || toolCtx.signal?.aborted || options.signal?.aborted) {
|
|
1271
1448
|
const err = new Error("aborted");
|
|
1272
1449
|
err.name = "AbortError";
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import type { ModelEntry, ProviderEntry } from "@guild/protocol";
|
|
2
|
+
|
|
3
|
+
/** Built-in alias `free` → this provider, matching Hermes Agent (2026-08). */
|
|
4
|
+
export const OPENCODE_FREE_PROVIDER_ID = "opencode-free";
|
|
5
|
+
export const OPENCODE_FREE_BASE_URL = "https://opencode.ai/zen/v1";
|
|
6
|
+
|
|
7
|
+
const ALIASES = new Set(["opencode-free", "free", "opencode_free"]);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Offline floor — Hermes Agent's curated catalog.
|
|
11
|
+
* Live GET /zen/v1/models is the source of truth when reachable.
|
|
12
|
+
* Known-delisted slugs must not stay here (they 401 keyless).
|
|
13
|
+
*/
|
|
14
|
+
export const OPENCODE_FREE_FLOOR = [
|
|
15
|
+
"laguna-s-2.1-free",
|
|
16
|
+
"mimo-v2.5-free",
|
|
17
|
+
"nemotron-3.5-lightning-free",
|
|
18
|
+
"nemotron-3-ultra-free",
|
|
19
|
+
"muse-spark-1.2-contributor-free",
|
|
20
|
+
"ling-3.0-flash-fin-free",
|
|
21
|
+
"deepseek-v4-flash-free",
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
/** Default pick — user-facing Guild default, routed to `/v1/responses`. */
|
|
25
|
+
export const OPENCODE_FREE_DEFAULT_MODEL = "muse-spark-1.2-contributor-free";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Hermes `opencode_model_api_mode`: Muse Spark on Zen/Go is Responses-only.
|
|
29
|
+
* `/v1/chat/completions` 500s; `/v1/responses` completes.
|
|
30
|
+
*/
|
|
31
|
+
export function usesZenResponses(model: string): boolean {
|
|
32
|
+
return String(model || "")
|
|
33
|
+
.trim()
|
|
34
|
+
.toLowerCase()
|
|
35
|
+
.startsWith("muse-spark");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Free slugs that do not end in `-free` (OpenCode's rotating stealth slot). */
|
|
39
|
+
const EXTRA_SLUGS = new Set(["big-pickle"]);
|
|
40
|
+
|
|
41
|
+
/** `-free` suffix but KEYED (Go subscription), not anonymous. */
|
|
42
|
+
const KEYED_FREE_SUFFIX = new Set(["ox-alpha-free"]);
|
|
43
|
+
|
|
44
|
+
export function isKeylessProvider(id: string): boolean {
|
|
45
|
+
return ALIASES.has(String(id || "").trim().toLowerCase());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function prettyOpenCodeFreeName(id: string): string {
|
|
49
|
+
const bare = String(id || "").trim().split("/").pop() || String(id || "");
|
|
50
|
+
const slug = bare.replace(/-/g, " ").replace(/\s+/g, " ").trim();
|
|
51
|
+
if (!slug) return id;
|
|
52
|
+
return slug.replace(/\b([a-z])/g, (ch) => ch.toUpperCase());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function openCodeFreeModels(ids: readonly string[] = OPENCODE_FREE_FLOOR): ModelEntry[] {
|
|
56
|
+
return ids.map((id) => ({ id, name: prettyOpenCodeFreeName(id) }));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function openCodeFreeProvider(): ProviderEntry {
|
|
60
|
+
return {
|
|
61
|
+
name: "OpenCode Free",
|
|
62
|
+
baseUrl: OPENCODE_FREE_BASE_URL,
|
|
63
|
+
api: "openai-completions",
|
|
64
|
+
apiKey: "",
|
|
65
|
+
models: openCodeFreeModels(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function filterOpenCodeFreeIds(ids: string[]): string[] {
|
|
70
|
+
const out: string[] = [];
|
|
71
|
+
const seen = new Set<string>();
|
|
72
|
+
for (const raw of ids) {
|
|
73
|
+
const id = String(raw || "").trim();
|
|
74
|
+
if (!id) continue;
|
|
75
|
+
const bare = (id.split("/").pop() || id).toLowerCase();
|
|
76
|
+
const ok =
|
|
77
|
+
(bare.endsWith("-free") && !KEYED_FREE_SUFFIX.has(bare)) ||
|
|
78
|
+
EXTRA_SLUGS.has(bare);
|
|
79
|
+
if (!ok || seen.has(bare)) continue;
|
|
80
|
+
seen.add(bare);
|
|
81
|
+
out.push(id.includes("/") ? bare : id);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function llmRequestHeaders(target: {
|
|
87
|
+
providerId: string;
|
|
88
|
+
apiKey: string;
|
|
89
|
+
headers?: Record<string, string>;
|
|
90
|
+
}): Record<string, string> {
|
|
91
|
+
const extra = { ...(target.headers ?? {}) };
|
|
92
|
+
if (isKeylessProvider(target.providerId)) {
|
|
93
|
+
const headers: Record<string, string> = {
|
|
94
|
+
"content-type": "application/json",
|
|
95
|
+
"http-referer": "https://github.com/Jakevin/guild",
|
|
96
|
+
"x-title": "Guild",
|
|
97
|
+
"user-agent": "Guild/0.2.19",
|
|
98
|
+
...extra,
|
|
99
|
+
};
|
|
100
|
+
delete headers.authorization;
|
|
101
|
+
delete headers.Authorization;
|
|
102
|
+
return headers;
|
|
103
|
+
}
|
|
104
|
+
const headers: Record<string, string> = {
|
|
105
|
+
"content-type": "application/json",
|
|
106
|
+
...extra,
|
|
107
|
+
};
|
|
108
|
+
if (!headers.authorization && !headers.Authorization) {
|
|
109
|
+
headers.authorization = `Bearer ${target.apiKey}`;
|
|
110
|
+
}
|
|
111
|
+
return headers;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let liveMemo: { at: number; ids: string[] | null } | null = null;
|
|
115
|
+
const LIVE_TTL_MS = 300_000;
|
|
116
|
+
|
|
117
|
+
export async function fetchOpenCodeFreeModels(
|
|
118
|
+
timeoutMs = 4_000,
|
|
119
|
+
force = false,
|
|
120
|
+
): Promise<string[] | null> {
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
if (!force && liveMemo && now - liveMemo.at < LIVE_TTL_MS) {
|
|
123
|
+
return liveMemo.ids ? [...liveMemo.ids] : null;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const res = await fetch(`${OPENCODE_FREE_BASE_URL}/models`, {
|
|
127
|
+
headers: {
|
|
128
|
+
accept: "application/json",
|
|
129
|
+
"user-agent": "Guild/0.2.19",
|
|
130
|
+
"http-referer": "https://github.com/Jakevin/guild",
|
|
131
|
+
"x-title": "Guild",
|
|
132
|
+
},
|
|
133
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
134
|
+
});
|
|
135
|
+
if (!res.ok) {
|
|
136
|
+
liveMemo = { at: now, ids: null };
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const body = (await res.json()) as { data?: { id?: string }[] } | { id?: string }[];
|
|
140
|
+
const rows = Array.isArray(body) ? body : body.data;
|
|
141
|
+
const ids = filterOpenCodeFreeIds(
|
|
142
|
+
(rows ?? [])
|
|
143
|
+
.map((row) => (row && typeof row.id === "string" ? row.id : ""))
|
|
144
|
+
.filter(Boolean),
|
|
145
|
+
);
|
|
146
|
+
const result = ids.length ? ids : null;
|
|
147
|
+
liveMemo = { at: now, ids: result };
|
|
148
|
+
return result ? [...result] : null;
|
|
149
|
+
} catch {
|
|
150
|
+
liveMemo = { at: now, ids: null };
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Tests only. */
|
|
156
|
+
export function resetOpenCodeFreeMemo(): void {
|
|
157
|
+
liveMemo = null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type OpenCodeFreeProbe = {
|
|
161
|
+
id: string;
|
|
162
|
+
ok: boolean;
|
|
163
|
+
status: number;
|
|
164
|
+
reason?: string;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** Health ping — not a turn timer. Muse /responses often needs ~7s. */
|
|
168
|
+
export const OPENCODE_FREE_PROBE_TIMEOUT_MS = 12_000;
|
|
169
|
+
const PROBE_CONCURRENCY = 4;
|
|
170
|
+
|
|
171
|
+
type FetchLike = typeof fetch;
|
|
172
|
+
|
|
173
|
+
export function selectOpenCodeFreeIds(
|
|
174
|
+
live: string[],
|
|
175
|
+
probe: OpenCodeFreeProbe[],
|
|
176
|
+
keepId?: string,
|
|
177
|
+
): string[] {
|
|
178
|
+
const ok = new Set(probe.filter((row) => row.ok).map((row) => row.id));
|
|
179
|
+
const picked = live.filter((id) => ok.has(id));
|
|
180
|
+
if (keepId && live.includes(keepId) && !picked.includes(keepId) && picked.length) {
|
|
181
|
+
picked.push(keepId);
|
|
182
|
+
}
|
|
183
|
+
return picked;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function probeOpenCodeFreeModel(
|
|
187
|
+
id: string,
|
|
188
|
+
fetcher: FetchLike = fetch,
|
|
189
|
+
): Promise<OpenCodeFreeProbe> {
|
|
190
|
+
const model = String(id || "").trim();
|
|
191
|
+
if (!model) return { id: model, ok: false, status: 0, reason: "empty" };
|
|
192
|
+
const responses = usesZenResponses(model);
|
|
193
|
+
const url = `${OPENCODE_FREE_BASE_URL}${responses ? "/responses" : "/chat/completions"}`;
|
|
194
|
+
const body = responses
|
|
195
|
+
? { model, input: [{ role: "user", content: "ping" }] }
|
|
196
|
+
: {
|
|
197
|
+
model,
|
|
198
|
+
messages: [{ role: "user", content: "ping" }],
|
|
199
|
+
max_tokens: 16,
|
|
200
|
+
};
|
|
201
|
+
try {
|
|
202
|
+
const res = await fetcher(url, {
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers: llmRequestHeaders({
|
|
205
|
+
providerId: OPENCODE_FREE_PROVIDER_ID,
|
|
206
|
+
apiKey: "",
|
|
207
|
+
}),
|
|
208
|
+
body: JSON.stringify(body),
|
|
209
|
+
signal: AbortSignal.timeout(OPENCODE_FREE_PROBE_TIMEOUT_MS),
|
|
210
|
+
});
|
|
211
|
+
if (res.status === 429) {
|
|
212
|
+
return { id: model, ok: true, status: 429, reason: "rate-limited" };
|
|
213
|
+
}
|
|
214
|
+
if (!res.ok) {
|
|
215
|
+
return { id: model, ok: false, status: res.status, reason: `HTTP ${res.status}` };
|
|
216
|
+
}
|
|
217
|
+
return { id: model, ok: true, status: res.status };
|
|
218
|
+
} catch (err) {
|
|
219
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
220
|
+
const timeout = /timeout|aborted/i.test(message);
|
|
221
|
+
return {
|
|
222
|
+
id: model,
|
|
223
|
+
ok: false,
|
|
224
|
+
status: 0,
|
|
225
|
+
reason: timeout ? "timeout" : message || "error",
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function probeOpenCodeFreeModels(
|
|
231
|
+
ids: string[],
|
|
232
|
+
fetcher: FetchLike = fetch,
|
|
233
|
+
): Promise<OpenCodeFreeProbe[]> {
|
|
234
|
+
const list = ids.filter(Boolean);
|
|
235
|
+
const results: OpenCodeFreeProbe[] = new Array(list.length);
|
|
236
|
+
let next = 0;
|
|
237
|
+
const worker = async () => {
|
|
238
|
+
while (true) {
|
|
239
|
+
const i = next++;
|
|
240
|
+
if (i >= list.length) return;
|
|
241
|
+
results[i] = await probeOpenCodeFreeModel(list[i], fetcher);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
const n = Math.min(PROBE_CONCURRENCY, Math.max(list.length, 0));
|
|
245
|
+
await Promise.all(Array.from({ length: n }, () => worker()));
|
|
246
|
+
return results;
|
|
247
|
+
}
|