@kevin5251984/guild 0.2.12

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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/bin/guildd.mjs +20 -0
  3. package/cordis.yml +24 -0
  4. package/package.json +52 -0
  5. package/src/agent-file.ts +125 -0
  6. package/src/browser.ts +668 -0
  7. package/src/catalog/default-bots.ts +263 -0
  8. package/src/catalog/skills.ts +128 -0
  9. package/src/catalog/subagents.ts +70 -0
  10. package/src/chat-parts.ts +71 -0
  11. package/src/cli-args.ts +75 -0
  12. package/src/cli.ts +60 -0
  13. package/src/compact.ts +355 -0
  14. package/src/cordis.d.ts +40 -0
  15. package/src/db.ts +653 -0
  16. package/src/generate.ts +673 -0
  17. package/src/handlers.ts +1623 -0
  18. package/src/harness.ts +326 -0
  19. package/src/host-agents.ts +137 -0
  20. package/src/host-browse.ts +199 -0
  21. package/src/host-skills.ts +150 -0
  22. package/src/image-gen.ts +270 -0
  23. package/src/index.ts +12 -0
  24. package/src/llm.ts +993 -0
  25. package/src/mcp.ts +563 -0
  26. package/src/memory.ts +159 -0
  27. package/src/mention.ts +176 -0
  28. package/src/oauth.ts +1474 -0
  29. package/src/plugins/api.ts +8 -0
  30. package/src/plugins/chat.ts +31 -0
  31. package/src/plugins/harness.ts +77 -0
  32. package/src/plugins/llm.ts +50 -0
  33. package/src/plugins/mcp.ts +58 -0
  34. package/src/plugins/memory.ts +42 -0
  35. package/src/plugins/oauth.ts +47 -0
  36. package/src/plugins/server.ts +126 -0
  37. package/src/plugins/store.ts +29 -0
  38. package/src/plugins/tools.ts +79 -0
  39. package/src/public/buddy.js +432 -0
  40. package/src/public/chat.css +3045 -0
  41. package/src/public/chat.html +5834 -0
  42. package/src/public/favicon-16.png +0 -0
  43. package/src/public/favicon-16.svg +10 -0
  44. package/src/public/favicon-32.png +0 -0
  45. package/src/public/favicon.ico +0 -0
  46. package/src/public/favicon.svg +13 -0
  47. package/src/public/i18n.js +663 -0
  48. package/src/public/index.html +143 -0
  49. package/src/public/library.html +678 -0
  50. package/src/public/mcp-add.html +126 -0
  51. package/src/public/md.js +332 -0
  52. package/src/public/rpg/inn-street.jpg +0 -0
  53. package/src/public/settings.html +795 -0
  54. package/src/public/skills-add.html +212 -0
  55. package/src/public/studio.html +1181 -0
  56. package/src/public/style.css +1678 -0
  57. package/src/public/subagents-add.html +152 -0
  58. package/src/router.ts +978 -0
  59. package/src/send-budget.ts +52 -0
  60. package/src/server.ts +1 -0
  61. package/src/skill-import.ts +250 -0
  62. package/src/slash.ts +15 -0
  63. package/src/start.ts +103 -0
  64. package/src/store.ts +1208 -0
  65. package/src/subagent.ts +355 -0
  66. package/src/tools.ts +818 -0
  67. package/src/trajectory.ts +339 -0
  68. package/src/usage.ts +111 -0
  69. package/vendor/protocol/package.json +19 -0
  70. package/vendor/protocol/src/index.ts +159 -0
package/src/tools.ts ADDED
@@ -0,0 +1,818 @@
1
+ import { execFile } from "node:child_process";
2
+ import { homedir } from "node:os";
3
+ import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
4
+ import { dirname, resolve } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { Type, type Tool } from "@earendil-works/pi-ai";
7
+ import { listHostSkills } from "./host-skills.ts";
8
+ import type { McpToolRef } from "./mcp.ts";
9
+ import {
10
+ gateTool,
11
+ parseSandbox,
12
+ resolveToolPath,
13
+ type Sandbox,
14
+ } from "./harness.ts";
15
+
16
+ const execFileAsync = promisify(execFile);
17
+ const HOME = homedir();
18
+ const OUTPUT_CAP = 16_000;
19
+ const RUN_TIMEOUT_MS = 45_000;
20
+ const TRACE_CAP = 1_200;
21
+
22
+ export type SkillRef = {
23
+ name: string;
24
+ slug?: string;
25
+ body: string;
26
+ description?: string;
27
+ path?: string;
28
+ };
29
+
30
+ export type SubAgentRef = {
31
+ name: string;
32
+ slug: string;
33
+ description?: string;
34
+ instructions: string;
35
+ readOnly: boolean;
36
+ path?: string;
37
+ source?: "catalog" | "user" | "host";
38
+ };
39
+
40
+ export type ToolTrace = {
41
+ name: string;
42
+ args: Record<string, unknown>;
43
+ text: string;
44
+ isError: boolean;
45
+ running?: boolean;
46
+ };
47
+
48
+ export type ToolProgress = {
49
+ thinking: string;
50
+ traces: ToolTrace[];
51
+ };
52
+
53
+ export type ToolOutcome = { text: string; isError: boolean };
54
+
55
+ export type ToolContext = {
56
+ skills?: SkillRef[];
57
+ subagents?: SubAgentRef[];
58
+ dataDir?: string;
59
+ env?: NodeJS.ProcessEnv;
60
+ /** Grok depth-1: children cannot spawn. */
61
+ spawnDepth?: number;
62
+ allowWrite?: boolean;
63
+ onProgress?: (update: ToolProgress) => void;
64
+ /** Drain user steers injected mid-turn (Codex-style). */
65
+ pullSteers?: () => string[];
66
+ signal?: AbortSignal;
67
+ mcpTools?: McpToolRef[];
68
+ /** Codex-shaped. Default full_access. */
69
+ sandbox?: Sandbox;
70
+ /** Root for workspace_write. Relative tool paths resolve here. */
71
+ workspace?: string;
72
+ /** DSH-style: live daemon routes through ctx.tools.execute. */
73
+ dispatch?: (
74
+ name: string,
75
+ args: Record<string, unknown>,
76
+ ctx: ToolContext,
77
+ ) => Promise<ToolOutcome>;
78
+ /** Devin-style background spawn handles for this turn. */
79
+ spawnHandles?: Map<
80
+ string,
81
+ {
82
+ id: string;
83
+ title: string;
84
+ profile: string;
85
+ done: Promise<ToolOutcome>;
86
+ outcome?: ToolOutcome;
87
+ }
88
+ >;
89
+ };
90
+
91
+ const BASE_TOOLS: Tool[] = [
92
+ {
93
+ name: "run",
94
+ description:
95
+ "Run a shell command on the user's local computer. Prefer workdir over cd. Check the [exit code: N] marker after every call; nonzero is a command failure, not a tool crash. Do not tell the user to run the command themselves.",
96
+ parameters: Type.Object({
97
+ command: Type.String({ description: "Shell command" }),
98
+ description: Type.Optional(
99
+ Type.String({
100
+ description: "One-line, 5–10 word summary of what this command does, for the UI only",
101
+ }),
102
+ ),
103
+ workdir: Type.Optional(
104
+ Type.String({
105
+ description: "Working directory. Absolute, or ~. Defaults to the user's home.",
106
+ }),
107
+ ),
108
+ }),
109
+ },
110
+ {
111
+ name: "read",
112
+ description: "Read a UTF-8 text file from the user's computer.",
113
+ parameters: Type.Object({
114
+ path: Type.String({ description: "Absolute path, or ~ for home" }),
115
+ }),
116
+ },
117
+ {
118
+ name: "write",
119
+ description: "Write a UTF-8 text file on the user's computer. Creates parent folders.",
120
+ parameters: Type.Object({
121
+ path: Type.String({ description: "Absolute path, or ~ for home" }),
122
+ content: Type.String({ description: "File contents" }),
123
+ }),
124
+ },
125
+ {
126
+ name: "list",
127
+ description: "List files in a directory on the user's computer.",
128
+ parameters: Type.Object({
129
+ path: Type.String({ description: "Directory path" }),
130
+ }),
131
+ },
132
+ {
133
+ name: "image_gen",
134
+ description:
135
+ "Generate an image from a text prompt (Grok Imagine / OpenAI Images). Use this instead of searching for an image_gen skill. Returns a local file and markdown the user can see in chat.",
136
+ parameters: Type.Object({
137
+ prompt: Type.String({
138
+ description: "Image description. Be specific about subject, style, and composition.",
139
+ }),
140
+ aspect_ratio: Type.Optional(
141
+ Type.String({
142
+ description:
143
+ "auto, 1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3. Default auto.",
144
+ }),
145
+ ),
146
+ }),
147
+ },
148
+ {
149
+ name: "browser",
150
+ description:
151
+ "Drive a local Chromium-family browser via CDP. Default snapshots the user's active Chrome profile (Local State last_used; cookies/logins via sqlite backup) into ~/.guild/browser-profile/chrome and drives that copy — never the live profile. Set GUILD_BROWSER_REAL_PROFILE=0 for a throwaway profile (logged into nothing); off deletes the snapshot. Actions: open, snapshot, click, type, press, screenshot, close.",
152
+ parameters: Type.Object({
153
+ action: Type.String({
154
+ description: "open | snapshot | click | type | press | screenshot | close",
155
+ }),
156
+ url: Type.Optional(Type.String({ description: "URL for action=open" })),
157
+ ref: Type.Optional(
158
+ Type.String({ description: "Snapshot ref like @e1 for click/type" }),
159
+ ),
160
+ text: Type.Optional(
161
+ Type.String({ description: "Text to type, or key name for press" }),
162
+ ),
163
+ }),
164
+ },
165
+ ];
166
+
167
+ export function guildTools(
168
+ skills: SkillRef[] = [],
169
+ ctx: ToolContext = {},
170
+ ): Tool[] {
171
+ const names = skills.map((item) => item.name).filter(Boolean);
172
+ const available = names.length ? ` Available: ${names.join(", ")}.` : "";
173
+ const allowWrite = ctx.allowWrite !== false;
174
+ const sandbox = parseSandbox(ctx.sandbox);
175
+ let tools: Tool[] = allowWrite
176
+ ? [...BASE_TOOLS]
177
+ : BASE_TOOLS.filter((tool) => tool.name !== "write");
178
+ if (sandbox === "read_only") {
179
+ tools = tools.filter(
180
+ (tool) => tool.name === "read" || tool.name === "list",
181
+ );
182
+ } else if (sandbox === "workspace_write") {
183
+ tools = tools.filter((tool) => tool.name !== "image_gen");
184
+ }
185
+ tools.push({
186
+ name: "skill",
187
+ description: `Load a staffed skill's full instructions by name.${available}`,
188
+ parameters: Type.Object({
189
+ name: Type.String({ description: "Skill name or slug" }),
190
+ }),
191
+ });
192
+ if ((ctx.spawnDepth ?? 0) < 1) {
193
+ const agents = ctx.subagents ?? [];
194
+ const listed = agents
195
+ .slice(0, 40)
196
+ .map((item) => {
197
+ const hint = item.description ? ` — ${item.description}` : "";
198
+ return `${item.name}${hint}`;
199
+ })
200
+ .join("; ");
201
+ const catalog = listed ? ` Available: ${listed}.` : "";
202
+ tools.push({
203
+ name: "spawn",
204
+ description: `Delegate to a specialist (Devin run_subagent / Pi subagent / Codex spawn_agent). Fresh context; returns a summary, not a transcript. You stay coordinator. Single: title + task + profile (aliases: description/prompt, name/agent). Profiles: explorer (read-only survey), reviewer (read-only critique), worker (bounded patch). luna-explore maps to explorer, luna-general to worker. Independent surveys: background=true (is_background), then read_spawn with the agent_id before the final reply. Parallel: several spawn calls this round, or tasks: [{title, task, profile}, ...] (max 8, 4 at a time). Do not spawn for one known file or a one-line change.${catalog} A read_only parent still spawns; the child stays read_only. Subagents cannot spawn children.`,
205
+ parameters: Type.Object({
206
+ prompt: Type.Optional(
207
+ Type.String({
208
+ description: "Self-contained task. Same as task.",
209
+ }),
210
+ ),
211
+ task: Type.Optional(
212
+ Type.String({ description: "Alias of prompt (Devin/Pi)" }),
213
+ ),
214
+ name: Type.Optional(
215
+ Type.String({
216
+ description: "Subagent name or slug. Default worker.",
217
+ }),
218
+ ),
219
+ agent: Type.Optional(
220
+ Type.String({ description: "Alias of name (Pi)" }),
221
+ ),
222
+ profile: Type.Optional(
223
+ Type.String({
224
+ description:
225
+ "Devin profile: explorer | reviewer | worker. luna-explore → explorer, luna-general → worker.",
226
+ }),
227
+ ),
228
+ description: Type.Optional(
229
+ Type.String({
230
+ description: "Short 3–8 word label for the chat UI",
231
+ }),
232
+ ),
233
+ title: Type.Optional(
234
+ Type.String({ description: "Alias of description (Devin title)" }),
235
+ ),
236
+ background: Type.Optional(
237
+ Type.Boolean({
238
+ description:
239
+ "If true, return agent_id immediately and keep working. Then call read_spawn.",
240
+ }),
241
+ ),
242
+ is_background: Type.Optional(
243
+ Type.Boolean({ description: "Alias of background (Devin)" }),
244
+ ),
245
+ tasks: Type.Optional(
246
+ Type.Array(
247
+ Type.Object({
248
+ prompt: Type.Optional(Type.String()),
249
+ task: Type.Optional(Type.String()),
250
+ name: Type.Optional(Type.String()),
251
+ agent: Type.Optional(Type.String()),
252
+ profile: Type.Optional(Type.String()),
253
+ description: Type.Optional(Type.String()),
254
+ title: Type.Optional(Type.String()),
255
+ }),
256
+ {
257
+ description:
258
+ "Pi parallel: run these subagents concurrently (max 8, 4 at a time).",
259
+ },
260
+ ),
261
+ ),
262
+ }),
263
+ });
264
+ tools.push({
265
+ name: "read_spawn",
266
+ description:
267
+ "Read a background spawn started with background=true (Devin read_subagent). Pass agent_id from spawn. block=true (default) waits; block=false returns running or the summary.",
268
+ parameters: Type.Object({
269
+ agent_id: Type.Optional(
270
+ Type.String({ description: "Id returned by background spawn" }),
271
+ ),
272
+ id: Type.Optional(Type.String({ description: "Alias of agent_id" })),
273
+ block: Type.Optional(
274
+ Type.Boolean({
275
+ description: "Wait for the child. Default true.",
276
+ }),
277
+ ),
278
+ }),
279
+ });
280
+ }
281
+ for (const mcp of sandbox === "full_access" ? ctx.mcpTools ?? [] : []) {
282
+ tools.push({
283
+ name: mcp.callName,
284
+ description: mcp.description,
285
+ parameters: Type.Object({}, { additionalProperties: true }),
286
+ });
287
+ }
288
+ return tools;
289
+ }
290
+
291
+ export const GUILD_TOOLS: Tool[] = guildTools();
292
+
293
+ function openaiParameters(name: string): {
294
+ type: "object";
295
+ properties: Record<string, unknown>;
296
+ required: string[];
297
+ } {
298
+ if (name === "run") {
299
+ return {
300
+ type: "object",
301
+ properties: {
302
+ command: { type: "string", description: "Shell command" },
303
+ description: {
304
+ type: "string",
305
+ description: "One-line summary of the command, UI only",
306
+ },
307
+ workdir: { type: "string", description: "Working directory" },
308
+ },
309
+ required: ["command"],
310
+ };
311
+ }
312
+ if (name === "write") {
313
+ return {
314
+ type: "object",
315
+ properties: {
316
+ path: { type: "string" },
317
+ content: { type: "string" },
318
+ },
319
+ required: ["path", "content"],
320
+ };
321
+ }
322
+ if (name === "skill") {
323
+ return {
324
+ type: "object",
325
+ properties: { name: { type: "string", description: "Skill name or slug" } },
326
+ required: ["name"],
327
+ };
328
+ }
329
+ if (name === "spawn") {
330
+ const job = {
331
+ type: "object",
332
+ properties: {
333
+ prompt: { type: "string", description: "Self-contained task. Same as task." },
334
+ task: { type: "string", description: "Alias of prompt" },
335
+ name: { type: "string", description: "Subagent name or slug" },
336
+ agent: { type: "string", description: "Alias of name" },
337
+ profile: { type: "string", description: "explorer | reviewer | worker" },
338
+ description: { type: "string", description: "Short UI label" },
339
+ title: { type: "string", description: "Alias of description" },
340
+ },
341
+ };
342
+ return {
343
+ type: "object",
344
+ properties: {
345
+ ...job.properties,
346
+ background: { type: "boolean", description: "Return agent_id immediately" },
347
+ is_background: { type: "boolean", description: "Alias of background" },
348
+ tasks: {
349
+ type: "array",
350
+ description: "Pi parallel: [{name, prompt}, ...] max 8, 4 at a time",
351
+ items: job,
352
+ },
353
+ },
354
+ required: [],
355
+ };
356
+ }
357
+ if (name === "read_spawn") {
358
+ return {
359
+ type: "object",
360
+ properties: {
361
+ agent_id: { type: "string", description: "Id from background spawn" },
362
+ id: { type: "string", description: "Alias of agent_id" },
363
+ block: { type: "boolean", description: "Wait. Default true." },
364
+ },
365
+ required: [],
366
+ };
367
+ }
368
+ if (name === "image_gen") {
369
+ return {
370
+ type: "object",
371
+ properties: {
372
+ prompt: { type: "string", description: "Image description" },
373
+ aspect_ratio: {
374
+ type: "string",
375
+ description: "auto, 1:1, 16:9, 9:16, …",
376
+ },
377
+ },
378
+ required: ["prompt"],
379
+ };
380
+ }
381
+ if (name === "browser") {
382
+ return {
383
+ type: "object",
384
+ properties: {
385
+ action: {
386
+ type: "string",
387
+ description: "open | snapshot | click | type | press | screenshot | close",
388
+ },
389
+ url: { type: "string", description: "URL for open" },
390
+ ref: { type: "string", description: "Snapshot ref @e1" },
391
+ text: { type: "string", description: "Typed text or key name" },
392
+ },
393
+ required: ["action"],
394
+ };
395
+ }
396
+ return {
397
+ type: "object",
398
+ properties: { path: { type: "string" } },
399
+ required: ["path"],
400
+ };
401
+ }
402
+
403
+ export function openaiTools(skills: SkillRef[] = [], ctx: ToolContext = {}) {
404
+ return guildTools(skills, ctx).map((tool) => {
405
+ const mcp = (ctx.mcpTools ?? []).find((item) => item.callName === tool.name);
406
+ return {
407
+ type: "function" as const,
408
+ function: {
409
+ name: tool.name,
410
+ description: tool.description,
411
+ parameters: mcp
412
+ ? mcp.inputSchema
413
+ : openaiParameters(tool.name),
414
+ },
415
+ };
416
+ });
417
+ }
418
+
419
+ export const OPENAI_TOOLS = openaiTools();
420
+
421
+ export const BUILTIN_TOOL_NAMES = [
422
+ "run",
423
+ "read",
424
+ "write",
425
+ "list",
426
+ "skill",
427
+ "spawn",
428
+ "read_spawn",
429
+ "image_gen",
430
+ "browser",
431
+ ] as const;
432
+
433
+ export async function executeTool(
434
+ name: string,
435
+ args: Record<string, unknown>,
436
+ ctx: ToolContext = {},
437
+ ): Promise<ToolOutcome> {
438
+ try {
439
+ const refused = gateTool(name, args, ctx);
440
+ if (refused) return refused;
441
+ if (ctx.dispatch) {
442
+ const { dispatch, ...rest } = ctx;
443
+ return await dispatch(name, args, rest);
444
+ }
445
+ return await builtinExecute(name, args, ctx);
446
+ } catch (error) {
447
+ if (error instanceof Error && error.name === "AbortError") throw error;
448
+ return {
449
+ text: error instanceof Error ? error.message : String(error),
450
+ isError: true,
451
+ };
452
+ }
453
+ }
454
+
455
+ export async function builtinExecute(
456
+ name: string,
457
+ args: Record<string, unknown>,
458
+ ctx: ToolContext = {},
459
+ ): Promise<ToolOutcome> {
460
+ try {
461
+ const pathBase =
462
+ parseSandbox(ctx.sandbox) === "workspace_write" && ctx.workspace
463
+ ? resolveToolPath(ctx.workspace)
464
+ : HOME;
465
+ if (name === "run") {
466
+ return await runCommand(
467
+ asString(args.command),
468
+ typeof args.workdir === "string" ? args.workdir : "",
469
+ pathBase,
470
+ );
471
+ }
472
+ if (name === "read") return readFile(asString(args.path), pathBase);
473
+ if (name === "write") {
474
+ if (ctx.allowWrite === false) {
475
+ return { text: "this subagent is read-only; write is disabled", isError: true };
476
+ }
477
+ return writeFile(
478
+ asString(args.path),
479
+ asString(args.content, true),
480
+ pathBase,
481
+ );
482
+ }
483
+ if (name === "list") return listDir(asString(args.path), pathBase);
484
+ if (name === "skill") return loadSkill(asString(args.name), ctx.skills ?? []);
485
+ if (name === "spawn") {
486
+ const { runSpawnJobs } = await import("./subagent.ts");
487
+ return runSpawnJobs(args, ctx);
488
+ }
489
+ if (name === "read_spawn") {
490
+ const { readSpawn } = await import("./subagent.ts");
491
+ return readSpawn(args, ctx);
492
+ }
493
+ if (name === "image_gen") {
494
+ const { generateImage } = await import("./image-gen.ts");
495
+ return generateImage({
496
+ prompt: asString(args.prompt),
497
+ aspectRatio:
498
+ typeof args.aspect_ratio === "string" ? args.aspect_ratio : "",
499
+ dataDir: ctx.dataDir,
500
+ env: ctx.env,
501
+ });
502
+ }
503
+ if (name === "browser") {
504
+ const { runBrowser } = await import("./browser.ts");
505
+ return runBrowser(args, {
506
+ dataDir: ctx.dataDir,
507
+ env: ctx.env,
508
+ signal: ctx.signal,
509
+ });
510
+ }
511
+ if (name.startsWith("mcp__")) {
512
+ const { callMcpTool } = await import("./mcp.ts");
513
+ if (!ctx.dataDir) return { text: "mcp needs a dataDir", isError: true };
514
+ return callMcpTool(ctx.dataDir, name, args, ctx.mcpTools ?? []);
515
+ }
516
+ return { text: `unknown tool: ${name}`, isError: true };
517
+ } catch (error) {
518
+ if (error instanceof Error && error.name === "AbortError") throw error;
519
+ return {
520
+ text: error instanceof Error ? error.message : String(error),
521
+ isError: true,
522
+ };
523
+ }
524
+ }
525
+
526
+ export function emitProgress(
527
+ ctx: ToolContext,
528
+ traces: ToolTrace[],
529
+ thinking = "",
530
+ ): void {
531
+ ctx.onProgress?.({ traces, thinking });
532
+ }
533
+
534
+ export function throwIfAborted(ctx: ToolContext): void {
535
+ if (!ctx.signal?.aborted) return;
536
+ const err = new Error("aborted");
537
+ err.name = "AbortError";
538
+ throw err;
539
+ }
540
+
541
+ /** User Stop only. No wall-clock round fuse — Pi/Codex/Hermes wait on the stream. */
542
+ export function roundSignal(ctx: ToolContext): AbortSignal | undefined {
543
+ return ctx.signal;
544
+ }
545
+
546
+ export function takeSteers(ctx: ToolContext): string | null {
547
+ const kept = (ctx.pullSteers?.() ?? [])
548
+ .map((item) => item.trim())
549
+ .filter(Boolean);
550
+ if (!kept.length) return null;
551
+ return [
552
+ "The user sent this while you were already working. Incorporate it without dropping the unfinished task. If it changes priority, follow it; otherwise address it and then resume.",
553
+ "",
554
+ "<user_steer>",
555
+ kept.join("\n\n"),
556
+ "</user_steer>",
557
+ ].join("\n");
558
+ }
559
+
560
+ export async function executeToolTraced(
561
+ name: string,
562
+ args: Record<string, unknown>,
563
+ ctx: ToolContext,
564
+ traces: ToolTrace[],
565
+ thinking = "",
566
+ ): Promise<ToolOutcome> {
567
+ throwIfAborted(ctx);
568
+ const row: ToolTrace = {
569
+ name,
570
+ args,
571
+ text: "",
572
+ isError: false,
573
+ running: true,
574
+ };
575
+ traces.push(row);
576
+ emitProgress(ctx, traces, thinking);
577
+ const outcome = await executeTool(name, args, ctx);
578
+ row.text = outcome.text;
579
+ row.isError = outcome.isError;
580
+ delete row.running;
581
+ emitProgress(ctx, traces, thinking);
582
+ return outcome;
583
+ }
584
+
585
+ function asString(value: unknown, allowEmpty = false): string {
586
+ if (typeof value !== "string") throw new Error("expected a string argument");
587
+ if (!allowEmpty && !value.trim()) throw new Error("empty argument");
588
+ return value;
589
+ }
590
+
591
+ function resolveUserPath(input: string, base = HOME): string {
592
+ return resolveToolPath(input, base);
593
+ }
594
+
595
+ function clip(text: string): string {
596
+ if (text.length <= OUTPUT_CAP) return text;
597
+ return `${text.slice(0, OUTPUT_CAP)}\n… truncated …`;
598
+ }
599
+
600
+ function formatRunOutput(input: {
601
+ stdout?: string;
602
+ stderr?: string;
603
+ extra?: string[];
604
+ }): string {
605
+ const stdout = String(input.stdout ?? "").trim();
606
+ const stderr = String(input.stderr ?? "").trim();
607
+ const chunks = [stdout || ""];
608
+ if (stderr) chunks.push(`[stderr]\n${stderr}`);
609
+ const body = chunks.join("\n").trim() || "(no output)";
610
+ const extra = (input.extra ?? []).filter(Boolean);
611
+ return clip([body, ...extra].join("\n"));
612
+ }
613
+
614
+ async function runCommand(
615
+ command: string,
616
+ workdir = "",
617
+ defaultCwd = HOME,
618
+ ): Promise<ToolOutcome> {
619
+ const cmd = command.trim();
620
+ if (!cmd) return { text: "empty command", isError: true };
621
+ if (/rm\s+-[a-zA-Z]*r[a-zA-Z]*f\s+\/(\s|$)/.test(cmd) || /^mkfs\b/.test(cmd)) {
622
+ return { text: "refused destructive command", isError: true };
623
+ }
624
+ const cwd = workdir.trim() ? resolveUserPath(workdir, defaultCwd) : defaultCwd;
625
+ const shell = process.env.SHELL || "/bin/zsh";
626
+ try {
627
+ const { stdout, stderr } = await execFileAsync(shell, ["-lc", cmd], {
628
+ cwd,
629
+ timeout: RUN_TIMEOUT_MS,
630
+ maxBuffer: OUTPUT_CAP * 2,
631
+ env: process.env,
632
+ });
633
+ return {
634
+ text: formatRunOutput({ stdout, stderr, extra: ["[exit code: 0]"] }),
635
+ isError: false,
636
+ };
637
+ } catch (error) {
638
+ const err = error as {
639
+ stdout?: string;
640
+ stderr?: string;
641
+ message?: string;
642
+ killed?: boolean;
643
+ code?: number | string;
644
+ };
645
+ if (err.killed) {
646
+ return {
647
+ text: formatRunOutput({
648
+ stdout: err.stdout,
649
+ stderr: err.stderr,
650
+ extra: [`[timed out after ${RUN_TIMEOUT_MS}ms]`],
651
+ }),
652
+ isError: true,
653
+ };
654
+ }
655
+ if (typeof err.code === "number") {
656
+ return {
657
+ text: formatRunOutput({
658
+ stdout: err.stdout,
659
+ stderr: err.stderr,
660
+ extra: [`[exit code: ${err.code}]`],
661
+ }),
662
+ isError: false,
663
+ };
664
+ }
665
+ return {
666
+ text: clip(err.message || "command failed"),
667
+ isError: true,
668
+ };
669
+ }
670
+ }
671
+
672
+ function readFile(path: string, base = HOME): ToolOutcome {
673
+ const target = resolveUserPath(path, base);
674
+ const raw = readFileSync(target);
675
+ if (raw.includes(0)) {
676
+ return { text: "binary file", isError: true };
677
+ }
678
+ return { text: clip(raw.toString("utf8")), isError: false };
679
+ }
680
+
681
+ function writeFile(path: string, content: string, base = HOME): ToolOutcome {
682
+ const target = resolveUserPath(path, base);
683
+ mkdirSync(dirname(target), { recursive: true });
684
+ writeFileSync(target, content);
685
+ return { text: `wrote ${target} (${content.length} bytes)`, isError: false };
686
+ }
687
+
688
+ function listDir(path: string, base = HOME): ToolOutcome {
689
+ const target = resolveUserPath(path, base);
690
+ const entries = readdirSync(target, { withFileTypes: true }).slice(0, 200);
691
+ const lines = entries.map((entry) => {
692
+ const kind = entry.isDirectory() ? "dir" : entry.isSymbolicLink() ? "link" : "file";
693
+ let extra = "";
694
+ try {
695
+ if (entry.isFile()) extra = ` ${statSync(resolve(target, entry.name)).size}`;
696
+ } catch {
697
+ /* ignore */
698
+ }
699
+ return `${kind}\t${entry.name}${extra}`;
700
+ });
701
+ return { text: lines.join("\n") || "(empty)", isError: false };
702
+ }
703
+
704
+ export function hostContext(): string {
705
+ return `Runtime: ${process.platform}/${process.arch} Node ${process.versions.node}, home=${HOME}.`;
706
+ }
707
+
708
+ export function formatToolTranscript(traces: ToolTrace[]): string {
709
+ if (!traces.length) return "";
710
+ const blocks = traces.map((trace) => {
711
+ const header =
712
+ trace.name === "run"
713
+ ? `$ ${String(trace.args.command ?? "").trim()}`
714
+ : trace.name === "write"
715
+ ? `write ${String(trace.args.path ?? "")}`
716
+ : `${trace.name} ${String(trace.args.path ?? "")}`;
717
+ const body =
718
+ trace.text.length > TRACE_CAP
719
+ ? `${trace.text.slice(0, TRACE_CAP)}\n… truncated …`
720
+ : trace.text;
721
+ return `${header}\n${body}`;
722
+ });
723
+ return `本機\n${blocks.join("\n\n")}`;
724
+ }
725
+
726
+ function skillKey(value: string): string {
727
+ return value.trim().replace(/^\/+/, "").toLowerCase();
728
+ }
729
+
730
+ function xmlEscape(value: string): string {
731
+ return value
732
+ .replace(/&/g, "&amp;")
733
+ .replace(/</g, "&lt;")
734
+ .replace(/>/g, "&gt;")
735
+ .replace(/"/g, "&quot;");
736
+ }
737
+
738
+ function skillBundleDir(filePath: string): string {
739
+ return dirname(resolveUserPath(filePath));
740
+ }
741
+
742
+ function renderSkillContent(name: string, body: string, resourceDir?: string): string {
743
+ const resources = resourceDir
744
+ ? `Base directory for this skill: ${resourceDir}\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.`
745
+ : `Resources for this skill are managed by Guild.\nLoad referenced resources only as needed.`;
746
+ return [
747
+ `<skill_content name="${xmlEscape(name)}">`,
748
+ "<skill_resources>",
749
+ resources,
750
+ "</skill_resources>",
751
+ "<skill_instructions>",
752
+ body.trim(),
753
+ "</skill_instructions>",
754
+ "</skill_content>",
755
+ ].join("\n");
756
+ }
757
+
758
+ function liveHostSkill(staffed: SkillRef, want: string) {
759
+ try {
760
+ const slug = skillKey(staffed.slug || staffed.name);
761
+ return listHostSkills().find((item) => {
762
+ const id = skillKey(item.slug);
763
+ const name = skillKey(item.name);
764
+ return id === slug || name === slug || id === want || name === want;
765
+ });
766
+ } catch {
767
+ return undefined;
768
+ }
769
+ }
770
+
771
+ function loadSkill(name: string, skills: SkillRef[]): ToolOutcome {
772
+ const want = skillKey(name);
773
+ const hit = skills.find(
774
+ (item) => skillKey(item.name) === want || skillKey(item.slug || "") === want,
775
+ );
776
+ if (!hit) {
777
+ const available = skills.map((item) => item.name).join(", ") || "(none)";
778
+ return { text: `unknown skill: ${name}. Available: ${available}`, isError: true };
779
+ }
780
+ const host = liveHostSkill(hit, want);
781
+ const body = host?.body || hit.body;
782
+ const dir = host?.path ? skillBundleDir(host.path) : undefined;
783
+ return { text: clip(renderSkillContent(hit.name, body, dir)), isError: false };
784
+ }
785
+
786
+ /**
787
+ * Codex interactive has no tool-round budget: a turn samples until the model
788
+ * emits an assistant message (context is managed by compact, not a round cap).
789
+ * This number is only a runaway fuse so a stuck tool loop cannot hang guildd.
790
+ */
791
+ export const MAX_TOOL_ROUNDS = 128;
792
+
793
+ export const TOOL_LOOP_WRAP =
794
+ "Stop calling tools now and write the user a final reply with what you already have. If you cannot finish, say what is still missing.";
795
+
796
+ export const TOOL_LOOP_EXHAUSTED =
797
+ "這輪工具還在繼續,先停在這裡以免卡住。再送一次即可接著做。";
798
+
799
+ export type ToolRoundPhase = "continue" | "wrap" | "stop";
800
+
801
+ export function nextToolRound(round: number): ToolRoundPhase {
802
+ if (round >= MAX_TOOL_ROUNDS) return "stop";
803
+ if (round === MAX_TOOL_ROUNDS - 1) return "wrap";
804
+ return "continue";
805
+ }
806
+
807
+ export const TOOL_SYSTEM = `You ARE already running on the user's local computer (Guild, same design as Pi / DeepSeek Harness).
808
+ Tools: run, read, write, list, skill, spawn, image_gen, browser, plus any connected MCP tools (names start with mcp__).
809
+ You can inspect RAM, disk, CPU, processes, files, and run shell commands.
810
+ Never say you cannot access this machine. Never tell the user to run the command themselves.
811
+ When the question is about this computer, call tools first, then answer with evidence from the output.
812
+ To generate an image, call image_gen with a prompt. Do not search the disk or load skills looking for Imagine. After it returns, include the markdown image in your reply.
813
+ To use a real website in a browser, call browser with action=open and a url, then snapshot/click/type using refs like @e1. Default is a Hermes-shaped snapshot of the user's last_used Chrome profile (never the live profile). Set GUILD_BROWSER_REAL_PROFILE=0 for a throwaway empty profile.
814
+ You stay coordinator. Spawn is the specialist, not a last resort (Devin run_subagent / Pi subagent / Codex spawn_agent). Call spawn for a survey (explorer / luna-explore), a critique (reviewer), or a bounded patch (worker / luna-general) instead of stuffing that work into this turn with list/read/run. Do not spawn for one known file or a one-line change. Independent surveys: spawn with background=true, keep working, then read_spawn {agent_id, block:true} before the final reply. Or several spawn calls / tasks: [{title, task, profile}] this round. Task must be self-contained (child has a fresh context). Do not let a child commit, push, or decide architecture. A read_only seat can still spawn; the child stays read_only. Subagents cannot spawn children.
815
+ Independent tool calls in one round also run in parallel — fire several reads/searches together.
816
+ Check the [exit code: N] marker on every run result; investigate failures before moving on. Prefer the workdir argument over cd.
817
+ To follow a staffed skill, call skill with its exact name (or slug) before applying it. Relative paths in a skill resolve against that skill's base directory.
818
+ Prefer small commands. macOS RAM: sysctl hw.memsize ; memory_pressure. Disk: df -h.`;