@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/src/mcp.ts CHANGED
@@ -1,4 +1,10 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ writeFileSync,
7
+ } from "node:fs";
2
8
  import { homedir } from "node:os";
3
9
  import { join } from "node:path";
4
10
  import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
@@ -215,6 +221,8 @@ class McpSession {
215
221
 
216
222
  const sessions = new Map<string, McpSession>();
217
223
 
224
+ const REDACTED_ENV_VALUE = "***";
225
+
218
226
  export function mcpPath(dataDir: string): string {
219
227
  return join(dataDir, "mcp.json");
220
228
  }
@@ -237,10 +245,29 @@ export function writeMcpFile(
237
245
  servers: Record<string, McpLaunch>,
238
246
  ): void {
239
247
  mkdirSync(dataDir, { recursive: true });
240
- writeFileSync(
241
- mcpPath(dataDir),
242
- `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`,
243
- );
248
+ const file = mcpPath(dataDir);
249
+ writeFileSync(file, `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`, {
250
+ mode: 0o600,
251
+ });
252
+ chmodSync(file, 0o600);
253
+ }
254
+
255
+ /**
256
+ * The wire shape for HTTP. `launch.env` holds API keys, so every value becomes
257
+ * a placeholder; the keys stay so the UI can still show that env is set.
258
+ * Spawning keeps using the full launch from `listGuildMcp` / `listActiveMcp`.
259
+ */
260
+ export function publicMcpServer(server: McpServer): McpServer {
261
+ const env = server.launch?.env;
262
+ const launch: McpLaunch = { ...server.launch };
263
+ if (env && Object.keys(env).length) {
264
+ launch.env = Object.fromEntries(
265
+ Object.keys(env).map((key) => [key, REDACTED_ENV_VALUE]),
266
+ );
267
+ } else {
268
+ delete launch.env;
269
+ }
270
+ return { ...server, launch };
244
271
  }
245
272
 
246
273
  export function listGuildMcp(dataDir: string): McpServer[] {
package/src/memory.ts CHANGED
@@ -127,6 +127,75 @@ export async function harvestBotMemory(input: {
127
127
  return { updated: true, body: input.store.writeBotMemory(input.botId, next) };
128
128
  }
129
129
 
130
+ export function localMergeQuestMemory(
131
+ parent: string,
132
+ child: string,
133
+ questName: string,
134
+ ): string | null {
135
+ const from = redactSecrets(String(child || "").replace(/\r\n/g, "\n")).trim();
136
+ if (!from) return null;
137
+ const into = String(parent || "").replace(/\r\n/g, "\n").trim();
138
+ const heading = String(questName || "side quest").replace(/\s+/g, " ").trim() || "side quest";
139
+ if (!into) return clipMemory(from);
140
+ if (into.includes(from)) return null;
141
+ return clipMemory(`${into}\n\n## ${heading}\n\n${from}`);
142
+ }
143
+
144
+ function mergeQuestPrompt(parent: string, child: string, questName: string): string {
145
+ return `You merge a closed side quest's MEMORY.md into the parent channel MEMORY.md.
146
+ Standing notes only: names, preferences, decisions, recurring work, conventions, ownership, tech.
147
+ Keep useful bullets from both. Drop stale, duplicated, or contradicted ones. Max 80 lines.
148
+ Do not copy the whole transcript. Do not mention this merge.
149
+
150
+ Parent MEMORY.md:
151
+ <<<
152
+ ${parent.trim() || "(empty)"}
153
+ >>>
154
+
155
+ Closed quest "${questName}" MEMORY.md:
156
+ <<<
157
+ ${child.trim()}
158
+ >>>
159
+
160
+ Reply with the complete updated parent MEMORY.md, or exactly NO_CHANGE.`;
161
+ }
162
+
163
+ export async function mergeQuestMemory(input: {
164
+ store: GuildStore;
165
+ parentId: string;
166
+ childId: string;
167
+ questName: string;
168
+ env?: NodeJS.ProcessEnv;
169
+ prefer?: ModelRef | null;
170
+ }): Promise<{ updated: boolean; body: string }> {
171
+ const parent = input.store.readChannelMemory(input.parentId);
172
+ const child = input.store.readChannelMemory(input.childId);
173
+ if (!child.trim()) return { updated: false, body: parent };
174
+ const result = await llmComplete({
175
+ dataDir: input.store.dataDir,
176
+ env: input.env,
177
+ role: "compression",
178
+ prefer: input.prefer,
179
+ tools: false,
180
+ temperature: 0.1,
181
+ system:
182
+ "You rewrite MEMORY.md. Output markdown or NO_CHANGE. No preamble.",
183
+ messages: [
184
+ {
185
+ role: "user",
186
+ content: mergeQuestPrompt(parent, child, input.questName),
187
+ },
188
+ ],
189
+ });
190
+ const fromModel = applyMemoryUpdate(parent, result?.text ?? null);
191
+ const next = fromModel ?? localMergeQuestMemory(parent, child, input.questName);
192
+ if (next == null) return { updated: false, body: parent };
193
+ return {
194
+ updated: true,
195
+ body: input.store.writeChannelMemory(input.parentId, next),
196
+ };
197
+ }
198
+
130
199
  export async function harvestChannelMemory(input: {
131
200
  store: GuildStore;
132
201
  roomId: string;
package/src/mention.ts CHANGED
@@ -1,8 +1,22 @@
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]*((?:@[A-Za-z0-9_-]+[\s,、]*)+)/;
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;
11
+ /** Numbered/bullet item that names a later wave — do not start them this turn. */
12
+ const LIST_LATER =
13
+ /^(?:通過後|最後|然後|之後|完成後)\s*`?@([A-Za-z0-9_-]+)`?/i;
14
+ /** Prose: 四席完成後由 @infra / 才交 @infra. 再叫 is a handoff, not a defer. */
15
+ const DEFER_AT =
16
+ /(?:完成後|通過後|最後(?:才|由)?|才需要|才叫|才交)[^@\n]{0,24}@([A-Za-z0-9_-]+)/gi;
17
+ /** Bare handle after the same cues: 才需要call infra. */
18
+ const DEFER_BARE =
19
+ /(?:完成後|才需要|才叫)\s*(?:call|ask|由)\s*`?@?([A-Za-z0-9_-]+)`?/gi;
6
20
 
7
21
  /** Drop fenced / inline code so `@pm` in a snippet does not dispatch. */
8
22
  export function stripMentionNoise(text: string): string {
@@ -32,13 +46,26 @@ export type MentionBlock = {
32
46
  start: number;
33
47
  end: number;
34
48
  handles: string[];
49
+ kind: "lead" | "list";
35
50
  };
36
51
 
37
- /** Line-start @handle groups. Prose `@pm` in the middle of a line is not a block. */
52
+ export type MentionBot = { id: string; handle: string };
53
+
54
+ function knownHandlesIn(chunk: string, known: Set<string>): string[] {
55
+ const names: string[] = [];
56
+ for (const row of chunk.matchAll(HANDLE)) {
57
+ const key = row[1].toLowerCase();
58
+ if (!known.has(key) || names.includes(key)) continue;
59
+ names.push(key);
60
+ }
61
+ return names;
62
+ }
63
+
64
+ /** Line-start @handle groups, plus markdown list items that name a seat. */
38
65
  export function lineStartMentions(text: string, handles: string[]): MentionBlock[] {
39
66
  const raw = String(text ?? "");
40
67
  const known = new Set(handles.map((handle) => handle.toLowerCase()));
41
- const starts: { start: number; handles: string[] }[] = [];
68
+ const starts: { start: number; handles: string[]; kind: "lead" | "list" }[] = [];
42
69
  let fence = false;
43
70
  let offset = 0;
44
71
  for (const part of raw.split(/(\r?\n)/)) {
@@ -53,15 +80,22 @@ export function lineStartMentions(text: string, handles: string[]): MentionBlock
53
80
  continue;
54
81
  }
55
82
  if (!fence) {
83
+ const listed = LIST_MARK.test(part);
56
84
  const lead = part.match(LINE_LEAD);
57
- if (lead) {
58
- const names: string[] = [];
59
- for (const row of lead[1].matchAll(HANDLE)) {
60
- const key = row[1].toLowerCase();
61
- if (!known.has(key) || names.includes(key)) continue;
62
- names.push(key);
63
- }
64
- if (names.length) starts.push({ start: offset, handles: names });
85
+ let names: string[] = [];
86
+ if (lead) names = knownHandlesIn(lead[1], known);
87
+ if (!names.length && listed) {
88
+ const rest = part.replace(LIST_MARK, "");
89
+ const hit = rest.match(LIST_ASSIGN);
90
+ const key = (hit?.[1] || hit?.[2] || "").toLowerCase();
91
+ if (key && known.has(key)) names = [key];
92
+ }
93
+ if (names.length) {
94
+ starts.push({
95
+ start: offset,
96
+ handles: names,
97
+ kind: listed ? "list" : "lead",
98
+ });
65
99
  }
66
100
  }
67
101
  offset += part.length;
@@ -70,20 +104,79 @@ export function lineStartMentions(text: string, handles: string[]): MentionBlock
70
104
  start: row.start,
71
105
  end: i + 1 < starts.length ? starts[i + 1].start : raw.length,
72
106
  handles: row.handles,
107
+ kind: row.kind,
73
108
  }));
74
109
  }
75
110
 
111
+ /** Fenced samples only. Keep inline `code` so 「再叫 `@infra`」 still parses. */
112
+ function stripFences(text: string): string {
113
+ return String(text ?? "").replace(/```[\s\S]*?```/g, " ");
114
+ }
115
+
116
+ /** Seats the same message says to start after someone else finishes. */
117
+ export function deferredHandles(text: string, handles: string[]): string[] {
118
+ const raw = String(text ?? "");
119
+ const known = new Set(handles.map((handle) => handle.toLowerCase()));
120
+ const out: string[] = [];
121
+ const add = (name: string) => {
122
+ const key = name.toLowerCase();
123
+ if (!known.has(key) || out.includes(key)) return;
124
+ out.push(key);
125
+ };
126
+ const scan = stripFences(raw);
127
+ for (const row of scan.matchAll(new RegExp(DEFER_AT.source, "gi"))) {
128
+ if (row[1]) add(row[1]);
129
+ }
130
+ for (const row of scan.matchAll(new RegExp(DEFER_BARE.source, "gi"))) {
131
+ if (row[1]) add(row[1]);
132
+ }
133
+ let fence = false;
134
+ for (const part of raw.split(/(\r?\n)/)) {
135
+ if (part === "\n" || part === "\r\n") continue;
136
+ const trimmed = part.trim();
137
+ if (trimmed.startsWith("```")) {
138
+ fence = !fence;
139
+ continue;
140
+ }
141
+ if (fence || !LIST_MARK.test(part)) continue;
142
+ const hit = part.replace(LIST_MARK, "").match(LIST_LATER);
143
+ if (hit?.[1]) add(hit[1]);
144
+ }
145
+ return out;
146
+ }
147
+
148
+ export function withoutDeferredIds(
149
+ ids: string[],
150
+ text: string,
151
+ bots: MentionBot[],
152
+ ): string[] {
153
+ if (!ids.length) return ids;
154
+ const deferred = new Set(
155
+ deferredHandles(
156
+ text,
157
+ bots.map((bot) => bot.handle),
158
+ ),
159
+ );
160
+ if (!deferred.size) return ids;
161
+ return ids.filter((id) => {
162
+ const bot = bots.find((row) => row.id === id);
163
+ return !bot || !deferred.has(bot.handle.toLowerCase());
164
+ });
165
+ }
166
+
76
167
  /**
77
168
  * Fallback when the client did not pick an assignee.
78
169
  * Every line-start @handle group, otherwise the first @handle in prose.
170
+ * Seats named as a later wave (完成後 / 最後 / 才叫) stay quiet this turn.
79
171
  */
80
172
  export function summonedHandles(text: string, handles: string[]): string[] {
81
173
  const known = new Set(handles.map((handle) => handle.toLowerCase()));
174
+ const deferred = new Set(deferredHandles(text, handles));
82
175
  const take = (names: string[]): string[] => {
83
176
  const out: string[] = [];
84
177
  for (const name of names) {
85
178
  const key = name.toLowerCase();
86
- if (!known.has(key) || out.includes(key)) continue;
179
+ if (!known.has(key) || deferred.has(key) || out.includes(key)) continue;
87
180
  out.push(key);
88
181
  }
89
182
  return out;
@@ -93,7 +186,8 @@ export function summonedHandles(text: string, handles: string[]): string[] {
93
186
  const out: string[] = [];
94
187
  for (const block of blocks) {
95
188
  for (const handle of block.handles) {
96
- if (!out.includes(handle)) out.push(handle);
189
+ if (deferred.has(handle) || out.includes(handle)) continue;
190
+ out.push(handle);
97
191
  }
98
192
  }
99
193
  return out;
@@ -104,6 +198,85 @@ export function summonedHandles(text: string, handles: string[]): string[] {
104
198
  return [];
105
199
  }
106
200
 
201
+ /**
202
+ * Bot replies: line-start specs, else first prose @handle, plus assignment verbs
203
+ * even when the handle is wrapped in backticks (models quote @infra a lot).
204
+ * Fixture chatter like `(@pm / @rd)` has no verb, so it does not hop.
205
+ */
206
+ const HANDOFF_ASK =
207
+ /(?:(?:再叫|交給|交棒(?:給)?|指派)\s*|(?:call|ask|ping|notify|handoff(?:\s+to)?)\s+)`?@([A-Za-z0-9_-]+)`?/gi;
208
+
209
+ export function handoffHandles(text: string, handles: string[]): string[] {
210
+ const known = new Set(handles.map((handle) => handle.toLowerCase()));
211
+ const deferred = new Set(deferredHandles(text, handles));
212
+ const out = summonedHandles(text, handles);
213
+ const ask = new RegExp(HANDOFF_ASK.source, "gi");
214
+ for (const row of stripFences(text).matchAll(ask)) {
215
+ const key = row[1].toLowerCase();
216
+ if (!known.has(key) || deferred.has(key) || out.includes(key)) continue;
217
+ out.push(key);
218
+ }
219
+ return out;
220
+ }
221
+
222
+ export function sanitizeMentionIds(
223
+ raw: unknown,
224
+ bots: MentionBot[],
225
+ ): string[] {
226
+ if (!Array.isArray(raw)) return [];
227
+ const known = new Set(bots.map((bot) => bot.id));
228
+ const out: string[] = [];
229
+ for (const item of raw) {
230
+ if (typeof item !== "string") continue;
231
+ const id = item.trim();
232
+ if (!id || !known.has(id) || out.includes(id)) continue;
233
+ out.push(id);
234
+ }
235
+ return out;
236
+ }
237
+
238
+ export function parseMentionIds(
239
+ text: string,
240
+ bots: MentionBot[],
241
+ kind: "user" | "bot",
242
+ ): string[] {
243
+ if (isBroadcastMention(text)) return [];
244
+ const handles = bots.map((bot) => bot.handle);
245
+ const names =
246
+ kind === "bot" ? handoffHandles(text, handles) : summonedHandles(text, handles);
247
+ const out: string[] = [];
248
+ for (const name of names) {
249
+ const bot = bots.find(
250
+ (row) => row.handle.toLowerCase() === name.toLowerCase(),
251
+ );
252
+ if (!bot || out.includes(bot.id)) continue;
253
+ out.push(bot.id);
254
+ }
255
+ return withoutDeferredIds(out, text, bots);
256
+ }
257
+
258
+ /**
259
+ * Prefer the stored mention list. Older rows without the field still parse the body.
260
+ * An empty stored list means nobody.
261
+ */
262
+ export function messageMentionIds(
263
+ message: { author: string; body: string; mentions?: string[] },
264
+ bots: MentionBot[],
265
+ ): string[] {
266
+ const ids = Array.isArray(message.mentions)
267
+ ? sanitizeMentionIds(message.mentions, bots)
268
+ : parseMentionIds(
269
+ message.body,
270
+ bots,
271
+ message.author === "you" ? "user" : "bot",
272
+ );
273
+ return withoutDeferredIds(
274
+ ids.filter((id) => id !== message.author),
275
+ message.body,
276
+ bots,
277
+ );
278
+ }
279
+
107
280
  /**
108
281
  * Spec for one seat: shared preamble plus that @handle's line-start block.
109
282
  * Full text when the handle was only summoned in prose.
@@ -119,6 +292,8 @@ export function assignmentFor(
119
292
  if (!key || !blocks.length) return raw;
120
293
  const mine = blocks.filter((block) => block.handles.includes(key));
121
294
  if (!mine.length) return raw;
295
+ // Numbered plans share constraints ("don't ship until PM signs off"). Slice only classic specs.
296
+ if (!blocks.some((block) => block.kind === "lead")) return raw;
122
297
  const preamble = raw.slice(0, blocks[0].start).trim();
123
298
  const chunks = mine.map((block) => raw.slice(block.start, block.end).trim());
124
299
  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 stream = models.streamSimple(model, context, options);
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
- for await (const event of stream) {
1080
- if (event.type === "thinking_start" || event.type === "thinking_delta") {
1081
- flush(event.partial, event.type === "thinking_start");
1082
- } else if (event.type === "thinking_end") {
1083
- flush(event.partial, true);
1084
- } else if (event.type === "done") {
1085
- flush(event.message, true);
1086
- return event.message;
1087
- } else if (event.type === "error") {
1088
- return event.error;
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> {
@@ -1114,7 +1242,7 @@ export async function completeOAuth(input: {
1114
1242
  system: string;
1115
1243
  messages: { role: "user" | "assistant"; content: string }[];
1116
1244
  temperature?: number;
1117
- reasoning?: "minimal" | "low" | "medium" | "high";
1245
+ reasoning?: string;
1118
1246
  tools?: boolean;
1119
1247
  skills?: SkillRef[];
1120
1248
  toolCtx?: ToolContext;
@@ -1173,7 +1301,7 @@ export async function completeOAuth(input: {
1173
1301
  });
1174
1302
  const options: {
1175
1303
  temperature?: number;
1176
- reasoning?: "minimal" | "low" | "medium" | "high";
1304
+ reasoning?: string;
1177
1305
  signal?: AbortSignal;
1178
1306
  transformHeaders?: (
1179
1307
  headers: Record<string, string | null>,
@@ -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
- const result = await Promise.race([
1256
- streamOAuthMessage(
1257
- models,
1258
- model,
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
- systemPrompt: input.system,
1261
- messages: transcript,
1262
- ...(useTools ? { tools } : {}),
1427
+ signal: options.signal,
1428
+ onRetry: () => {
1429
+ emitProgress(
1430
+ toolCtx,
1431
+ traces,
1432
+ thinkingChunks.join("\n\n") || "連線中斷,重試中…",
1433
+ );
1434
+ },
1263
1435
  },
1264
- options,
1265
- toolCtx,
1266
- traces,
1267
- ),
1268
- abortWhen(options.signal),
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";