@kevin5251984/guild 0.2.19 → 0.2.21

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/llm.ts CHANGED
@@ -39,6 +39,16 @@ import {
39
39
  fromOpenAiUsage,
40
40
  withDuration,
41
41
  } from "./usage.ts";
42
+ import {
43
+ attachReasoning,
44
+ clampEffort,
45
+ reasoningFor,
46
+ reasoningPayload,
47
+ refreshReasoningCatalog,
48
+ sanitizeEffort,
49
+ } from "./reasoning-catalog.ts";
50
+
51
+ export { refreshReasoningCatalog };
42
52
  import {
43
53
  OPENCODE_FREE_DEFAULT_MODEL,
44
54
  OPENCODE_FREE_PROVIDER_ID,
@@ -291,6 +301,7 @@ export function publicModels(dataDir: string, env: NodeJS.ProcessEnv = process.e
291
301
  return {
292
302
  id,
293
303
  ...provider,
304
+ models: attachReasoning(id, provider.models),
294
305
  apiKey: stored === "literal" ? "" : key,
295
306
  apiKeyPreview: key ? maskApiKey(key) : "",
296
307
  stored,
@@ -307,19 +318,19 @@ export function publicModels(dataDir: string, env: NodeJS.ProcessEnv = process.e
307
318
  ready:
308
319
  isKeylessProvider(p.id) ||
309
320
  Boolean(resolveApiKey(p.apiKey, env) || p.stored === "literal"),
310
- models: p.models,
321
+ models: attachReasoning(p.id, p.models),
311
322
  })),
312
323
  ...subscriptions.map((s) => ({
313
324
  id: s.pickerId,
314
325
  name: s.name,
315
326
  kind: "oauth" as const,
316
327
  ready: s.ready,
317
- models: s.models ?? [],
328
+ models: attachReasoning(s.pickerId, s.models ?? []),
318
329
  })),
319
330
  ];
320
331
  return {
321
332
  default: file.default ?? null,
322
- reasoning: file.reasoning ?? "medium",
333
+ reasoning: file.reasoning ?? "",
323
334
  fast: Boolean(file.fast),
324
335
  aux: file.aux ?? {},
325
336
  auxRoles: AUX_ROLES,
@@ -483,9 +494,14 @@ export async function llmComplete(input: {
483
494
  spawnDepth: 0,
484
495
  allowWrite: true,
485
496
  };
497
+ const file = readModelsFile(input.dataDir);
498
+ const effort = clampEffort(
499
+ file.fast ? "low" : input.prefer ? input.prefer.reasoning : file.reasoning,
500
+ reasoningFor(target.providerId, target.model),
501
+ Boolean(file.fast),
502
+ );
486
503
  if (OAUTH_PICKER_IDS.has(target.providerId)) {
487
504
  try {
488
- const file = readModelsFile(input.dataDir);
489
505
  return await completeOAuth({
490
506
  dataDir: input.dataDir,
491
507
  pickerId: target.providerId,
@@ -493,7 +509,7 @@ export async function llmComplete(input: {
493
509
  system: input.system,
494
510
  messages: input.messages,
495
511
  temperature: input.temperature ?? 0.4,
496
- reasoning: file.fast ? "low" : file.reasoning,
512
+ reasoning: effort,
497
513
  tools: useTools,
498
514
  skills: input.skills,
499
515
  toolCtx,
@@ -521,6 +537,7 @@ export async function llmComplete(input: {
521
537
  input.temperature ?? 0.4,
522
538
  useTools,
523
539
  toolCtx,
540
+ effort,
524
541
  );
525
542
  if (!done) return null;
526
543
  return {
@@ -554,11 +571,12 @@ async function dispatchComplete(
554
571
  temperature: number,
555
572
  tools: boolean,
556
573
  ctx: ToolContext,
574
+ effort?: string,
557
575
  ): Promise<DispatchResult | null> {
558
576
  if (isKeylessProvider(target.providerId) && usesZenResponses(target.model)) {
559
577
  return tools
560
- ? completeZenResponsesTools(target, system, messages, ctx)
561
- : wrapText(await completeZenResponses(target, system, messages));
578
+ ? completeZenResponsesTools(target, system, messages, ctx, effort)
579
+ : wrapText(await completeZenResponses(target, system, messages, effort));
562
580
  }
563
581
  if (target.api === "openai-responses") {
564
582
  const text = await completeCodex(target, system, messages);
@@ -570,8 +588,8 @@ async function dispatchComplete(
570
588
  : wrapText(await completeAnthropic(target, system, messages));
571
589
  }
572
590
  return tools
573
- ? completeOpenAiTools(target, system, messages, temperature, ctx)
574
- : wrapText(await completeOpenAi(target, system, messages, temperature));
591
+ ? completeOpenAiTools(target, system, messages, temperature, ctx, effort)
592
+ : wrapText(await completeOpenAi(target, system, messages, temperature, effort));
575
593
  }
576
594
 
577
595
  function wrapText(text: string | null): DispatchResult | null {
@@ -584,6 +602,7 @@ async function completeOpenAiTools(
584
602
  messages: { role: "user" | "assistant"; content: string }[],
585
603
  temperature: number,
586
604
  ctx: ToolContext,
605
+ effort?: string,
587
606
  ): Promise<DispatchResult | null> {
588
607
  type ChatMsg = {
589
608
  role: string;
@@ -635,6 +654,7 @@ async function completeOpenAiTools(
635
654
  messages: msgs,
636
655
  tools: catalog,
637
656
  tool_choice: "auto",
657
+ ...reasoningPayload(target.providerId, target.baseUrl, effort),
638
658
  }),
639
659
  signal: roundSignal(ctx),
640
660
  });
@@ -901,6 +921,7 @@ async function completeZenResponses(
901
921
  target: LlmTarget,
902
922
  system: string,
903
923
  messages: { role: "user" | "assistant"; content: string }[],
924
+ effort?: string,
904
925
  ): Promise<string | null> {
905
926
  const response = await postZenResponses(
906
927
  target,
@@ -908,6 +929,7 @@ async function completeZenResponses(
908
929
  model: target.model,
909
930
  instructions: system,
910
931
  input: messages,
932
+ ...reasoningPayload(target.providerId, target.baseUrl, effort),
911
933
  },
912
934
  AbortSignal.timeout(STREAM_IDLE_TIMEOUT_MS),
913
935
  );
@@ -921,6 +943,7 @@ async function completeZenResponsesTools(
921
943
  system: string,
922
944
  messages: { role: "user" | "assistant"; content: string }[],
923
945
  ctx: ToolContext,
946
+ effort?: string,
924
947
  ): Promise<DispatchResult | null> {
925
948
  const input: ZenInput[] = messages.map((item) => ({
926
949
  role: item.role,
@@ -960,6 +983,7 @@ async function completeZenResponsesTools(
960
983
  input,
961
984
  tools,
962
985
  tool_choice: "auto",
986
+ ...reasoningPayload(target.providerId, target.baseUrl, effort),
963
987
  },
964
988
  roundSignal(ctx),
965
989
  );
@@ -1070,6 +1094,7 @@ async function completeOpenAi(
1070
1094
  system: string,
1071
1095
  messages: { role: "user" | "assistant"; content: string }[],
1072
1096
  temperature: number,
1097
+ effort?: string,
1073
1098
  ): Promise<string | null> {
1074
1099
  const url = `${target.baseUrl}/chat/completions`;
1075
1100
  const response = await fetch(url, {
@@ -1079,6 +1104,7 @@ async function completeOpenAi(
1079
1104
  model: target.model,
1080
1105
  temperature,
1081
1106
  messages: [{ role: "system", content: system }, ...messages],
1107
+ ...reasoningPayload(target.providerId, target.baseUrl, effort),
1082
1108
  }),
1083
1109
  signal: AbortSignal.timeout(STREAM_IDLE_TIMEOUT_MS),
1084
1110
  });
@@ -1278,6 +1304,7 @@ function sanitizeModels(file: ModelsFile): ModelsFile {
1278
1304
  .map((model) => ({
1279
1305
  id: model.id.trim(),
1280
1306
  name: model.name?.trim() || undefined,
1307
+ ...(model.reasoning ? { reasoning: model.reasoning } : {}),
1281
1308
  }));
1282
1309
  if (models.length === 0) {
1283
1310
  throw new StoreError(400, `provider ${id} needs at least one model`);
@@ -1307,12 +1334,7 @@ function sanitizeModels(file: ModelsFile): ModelsFile {
1307
1334
  for (const [role, ref] of Object.entries(file.aux ?? {})) {
1308
1335
  aux[role as AuxRole] = validRef(ref as ModelRef | null);
1309
1336
  }
1310
- const reasoning =
1311
- file.reasoning === "minimal" ||
1312
- file.reasoning === "low" ||
1313
- file.reasoning === "high"
1314
- ? file.reasoning
1315
- : "medium";
1337
+ const reasoning = sanitizeEffort(file.reasoning);
1316
1338
  const recent = (file.recent ?? [])
1317
1339
  .map((ref) => validRef(ref))
1318
1340
  .filter((ref): ref is ModelRef => Boolean(ref))
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/mention.ts CHANGED
@@ -8,6 +8,15 @@ const LIST_MARK = /^[ \t]*(?:\d+[.)、]\s*|[-*•]\s+)/;
8
8
  /** Assignee right after the marker, or after 通過後/最後/再叫… — not the first @ anywhere on the line. */
9
9
  const LIST_ASSIGN =
10
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;
11
20
 
12
21
  /** Drop fenced / inline code so `@pm` in a snippet does not dispatch. */
13
22
  export function stripMentionNoise(text: string): string {
@@ -40,6 +49,8 @@ export type MentionBlock = {
40
49
  kind: "lead" | "list";
41
50
  };
42
51
 
52
+ export type MentionBot = { id: string; handle: string };
53
+
43
54
  function knownHandlesIn(chunk: string, known: Set<string>): string[] {
44
55
  const names: string[] = [];
45
56
  for (const row of chunk.matchAll(HANDLE)) {
@@ -97,17 +108,75 @@ export function lineStartMentions(text: string, handles: string[]): MentionBlock
97
108
  }));
98
109
  }
99
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
+
100
167
  /**
101
168
  * Fallback when the client did not pick an assignee.
102
169
  * Every line-start @handle group, otherwise the first @handle in prose.
170
+ * Seats named as a later wave (完成後 / 最後 / 才叫) stay quiet this turn.
103
171
  */
104
172
  export function summonedHandles(text: string, handles: string[]): string[] {
105
173
  const known = new Set(handles.map((handle) => handle.toLowerCase()));
174
+ const deferred = new Set(deferredHandles(text, handles));
106
175
  const take = (names: string[]): string[] => {
107
176
  const out: string[] = [];
108
177
  for (const name of names) {
109
178
  const key = name.toLowerCase();
110
- if (!known.has(key) || out.includes(key)) continue;
179
+ if (!known.has(key) || deferred.has(key) || out.includes(key)) continue;
111
180
  out.push(key);
112
181
  }
113
182
  return out;
@@ -117,7 +186,8 @@ export function summonedHandles(text: string, handles: string[]): string[] {
117
186
  const out: string[] = [];
118
187
  for (const block of blocks) {
119
188
  for (const handle of block.handles) {
120
- if (!out.includes(handle)) out.push(handle);
189
+ if (deferred.has(handle) || out.includes(handle)) continue;
190
+ out.push(handle);
121
191
  }
122
192
  }
123
193
  return out;
@@ -128,11 +198,6 @@ export function summonedHandles(text: string, handles: string[]): string[] {
128
198
  return [];
129
199
  }
130
200
 
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
201
  /**
137
202
  * Bot replies: line-start specs, else first prose @handle, plus assignment verbs
138
203
  * even when the handle is wrapped in backticks (models quote @infra a lot).
@@ -143,18 +208,17 @@ const HANDOFF_ASK =
143
208
 
144
209
  export function handoffHandles(text: string, handles: string[]): string[] {
145
210
  const known = new Set(handles.map((handle) => handle.toLowerCase()));
211
+ const deferred = new Set(deferredHandles(text, handles));
146
212
  const out = summonedHandles(text, handles);
147
213
  const ask = new RegExp(HANDOFF_ASK.source, "gi");
148
214
  for (const row of stripFences(text).matchAll(ask)) {
149
215
  const key = row[1].toLowerCase();
150
- if (!known.has(key) || out.includes(key)) continue;
216
+ if (!known.has(key) || deferred.has(key) || out.includes(key)) continue;
151
217
  out.push(key);
152
218
  }
153
219
  return out;
154
220
  }
155
221
 
156
- export type MentionBot = { id: string; handle: string };
157
-
158
222
  export function sanitizeMentionIds(
159
223
  raw: unknown,
160
224
  bots: MentionBot[],
@@ -188,7 +252,7 @@ export function parseMentionIds(
188
252
  if (!bot || out.includes(bot.id)) continue;
189
253
  out.push(bot.id);
190
254
  }
191
- return out;
255
+ return withoutDeferredIds(out, text, bots);
192
256
  }
193
257
 
194
258
  /**
@@ -206,7 +270,11 @@ export function messageMentionIds(
206
270
  bots,
207
271
  message.author === "you" ? "user" : "bot",
208
272
  );
209
- return ids.filter((id) => id !== message.author);
273
+ return withoutDeferredIds(
274
+ ids.filter((id) => id !== message.author),
275
+ message.body,
276
+ bots,
277
+ );
210
278
  }
211
279
 
212
280
  /**
package/src/oauth.ts CHANGED
@@ -1242,7 +1242,7 @@ export async function completeOAuth(input: {
1242
1242
  system: string;
1243
1243
  messages: { role: "user" | "assistant"; content: string }[];
1244
1244
  temperature?: number;
1245
- reasoning?: "minimal" | "low" | "medium" | "high";
1245
+ reasoning?: string;
1246
1246
  tools?: boolean;
1247
1247
  skills?: SkillRef[];
1248
1248
  toolCtx?: ToolContext;
@@ -1301,7 +1301,7 @@ export async function completeOAuth(input: {
1301
1301
  });
1302
1302
  const options: {
1303
1303
  temperature?: number;
1304
- reasoning?: "minimal" | "low" | "medium" | "high";
1304
+ reasoning?: string;
1305
1305
  signal?: AbortSignal;
1306
1306
  transformHeaders?: (
1307
1307
  headers: Record<string, string | null>,
@@ -1,4 +1,5 @@
1
1
  import type { ModelEntry, ProviderEntry } from "@guild/protocol";
2
+ import { guildUserAgent } from "./version.ts";
2
3
 
3
4
  /** Built-in alias `free` → this provider, matching Hermes Agent (2026-08). */
4
5
  export const OPENCODE_FREE_PROVIDER_ID = "opencode-free";
@@ -94,7 +95,7 @@ export function llmRequestHeaders(target: {
94
95
  "content-type": "application/json",
95
96
  "http-referer": "https://github.com/Jakevin/guild",
96
97
  "x-title": "Guild",
97
- "user-agent": "Guild/0.2.19",
98
+ "user-agent": guildUserAgent(),
98
99
  ...extra,
99
100
  };
100
101
  delete headers.authorization;
@@ -126,7 +127,7 @@ export async function fetchOpenCodeFreeModels(
126
127
  const res = await fetch(`${OPENCODE_FREE_BASE_URL}/models`, {
127
128
  headers: {
128
129
  accept: "application/json",
129
- "user-agent": "Guild/0.2.19",
130
+ "user-agent": guildUserAgent(),
130
131
  "http-referer": "https://github.com/Jakevin/guild",
131
132
  "x-title": "Guild",
132
133
  },
@@ -1894,6 +1894,10 @@ a.invite-btn:hover { text-decoration: none; }
1894
1894
  .turn-actions .turn-stop {
1895
1895
  color: var(--danger);
1896
1896
  }
1897
+ .turn-actions .turn-pause,
1898
+ .turn-actions .turn-continue {
1899
+ color: var(--steel);
1900
+ }
1897
1901
  .turn-actions .turn-steer {
1898
1902
  color: #b4c8e8;
1899
1903
  }
@@ -1907,6 +1911,8 @@ body.grok .turn-actions button:hover {
1907
1911
  color: #f2eadf;
1908
1912
  }
1909
1913
  body.grok .turn-actions .turn-stop { color: #d4a090; }
1914
+ body.grok .turn-actions .turn-pause,
1915
+ body.grok .turn-actions .turn-continue { color: #c4b090; }
1910
1916
  body.grok .turn-actions .turn-steer { color: #c4b090; }
1911
1917
  .turn-status .clock {
1912
1918
  margin-left: 8px;