@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/tools.ts CHANGED
@@ -7,6 +7,7 @@ import { Type, type Tool } from "@earendil-works/pi-ai";
7
7
  import { listHostSkills } from "./host-skills.ts";
8
8
  import type { McpToolRef } from "./mcp.ts";
9
9
  import {
10
+ defaultWorkspace,
10
11
  gateTool,
11
12
  parseSandbox,
12
13
  resolveToolPath,
@@ -75,8 +76,25 @@ export type ToolContext = {
75
76
  args: Record<string, unknown>,
76
77
  ctx: ToolContext,
77
78
  ) => Promise<ToolOutcome>;
79
+ /** Devin-style background spawn handles for this turn. Same Map across dispatch clones. */
80
+ spawnHandles?: Map<string, SpawnHandle>;
78
81
  };
79
82
 
83
+ export type SpawnHandle = {
84
+ id: string;
85
+ title: string;
86
+ profile: string;
87
+ done: Promise<ToolOutcome>;
88
+ outcome?: ToolOutcome;
89
+ abort?: AbortController;
90
+ };
91
+
92
+ /** Pin the turn's handle Map before dispatch spreads a rest clone. */
93
+ export function attachSpawnHandles(ctx: ToolContext): Map<string, SpawnHandle> {
94
+ if (!ctx.spawnHandles) ctx.spawnHandles = new Map();
95
+ return ctx.spawnHandles;
96
+ }
97
+
80
98
  const BASE_TOOLS: Tool[] = [
81
99
  {
82
100
  name: "run",
@@ -169,7 +187,9 @@ export function guildTools(
169
187
  (tool) => tool.name === "read" || tool.name === "list",
170
188
  );
171
189
  } else if (sandbox === "workspace_write") {
172
- tools = tools.filter((tool) => tool.name !== "image_gen");
190
+ tools = tools.filter(
191
+ (tool) => tool.name !== "image_gen" && tool.name !== "browser",
192
+ );
173
193
  }
174
194
  tools.push({
175
195
  name: "skill",
@@ -178,7 +198,7 @@ export function guildTools(
178
198
  name: Type.String({ description: "Skill name or slug" }),
179
199
  }),
180
200
  });
181
- if ((ctx.spawnDepth ?? 0) < 1 && sandbox !== "read_only") {
201
+ if ((ctx.spawnDepth ?? 0) < 1) {
182
202
  const agents = ctx.subagents ?? [];
183
203
  const listed = agents
184
204
  .slice(0, 40)
@@ -190,14 +210,28 @@ export function guildTools(
190
210
  const catalog = listed ? ` Available: ${listed}.` : "";
191
211
  tools.push({
192
212
  name: "spawn",
193
- description: `Spawn a subagent with a fresh context. It returns a summary, not a transcript. Use for parallel exploration, review, or a bounded implementation slice.${catalog} Subagents cannot spawn children.`,
213
+ 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.`,
194
214
  parameters: Type.Object({
195
- prompt: Type.String({
196
- description: "Self-contained task for the subagent. Include paths, constraints, and the deliverable.",
197
- }),
215
+ prompt: Type.Optional(
216
+ Type.String({
217
+ description: "Self-contained task. Same as task.",
218
+ }),
219
+ ),
220
+ task: Type.Optional(
221
+ Type.String({ description: "Alias of prompt (Devin/Pi)" }),
222
+ ),
198
223
  name: Type.Optional(
199
224
  Type.String({
200
- description: "Subagent name or slug from the library. Default worker.",
225
+ description: "Subagent name or slug. Default worker.",
226
+ }),
227
+ ),
228
+ agent: Type.Optional(
229
+ Type.String({ description: "Alias of name (Pi)" }),
230
+ ),
231
+ profile: Type.Optional(
232
+ Type.String({
233
+ description:
234
+ "Devin profile: explorer | reviewer | worker. luna-explore → explorer, luna-general → worker.",
201
235
  }),
202
236
  ),
203
237
  description: Type.Optional(
@@ -205,6 +239,51 @@ export function guildTools(
205
239
  description: "Short 3–8 word label for the chat UI",
206
240
  }),
207
241
  ),
242
+ title: Type.Optional(
243
+ Type.String({ description: "Alias of description (Devin title)" }),
244
+ ),
245
+ background: Type.Optional(
246
+ Type.Boolean({
247
+ description:
248
+ "If true, return agent_id immediately and keep working. Then call read_spawn.",
249
+ }),
250
+ ),
251
+ is_background: Type.Optional(
252
+ Type.Boolean({ description: "Alias of background (Devin)" }),
253
+ ),
254
+ tasks: Type.Optional(
255
+ Type.Array(
256
+ Type.Object({
257
+ prompt: Type.Optional(Type.String()),
258
+ task: Type.Optional(Type.String()),
259
+ name: Type.Optional(Type.String()),
260
+ agent: Type.Optional(Type.String()),
261
+ profile: Type.Optional(Type.String()),
262
+ description: Type.Optional(Type.String()),
263
+ title: Type.Optional(Type.String()),
264
+ }),
265
+ {
266
+ description:
267
+ "Pi parallel: run these subagents concurrently (max 8, 4 at a time).",
268
+ },
269
+ ),
270
+ ),
271
+ }),
272
+ });
273
+ tools.push({
274
+ name: "read_spawn",
275
+ description:
276
+ "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.",
277
+ parameters: Type.Object({
278
+ agent_id: Type.Optional(
279
+ Type.String({ description: "Id returned by background spawn" }),
280
+ ),
281
+ id: Type.Optional(Type.String({ description: "Alias of agent_id" })),
282
+ block: Type.Optional(
283
+ Type.Boolean({
284
+ description: "Wait for the child. Default true.",
285
+ }),
286
+ ),
208
287
  }),
209
288
  });
210
289
  }
@@ -222,7 +301,7 @@ export const GUILD_TOOLS: Tool[] = guildTools();
222
301
 
223
302
  function openaiParameters(name: string): {
224
303
  type: "object";
225
- properties: Record<string, { type: "string"; description?: string }>;
304
+ properties: Record<string, unknown>;
226
305
  required: string[];
227
306
  } {
228
307
  if (name === "run") {
@@ -257,14 +336,42 @@ function openaiParameters(name: string): {
257
336
  };
258
337
  }
259
338
  if (name === "spawn") {
260
- return {
339
+ const job = {
261
340
  type: "object",
262
341
  properties: {
263
- prompt: { type: "string", description: "Self-contained task" },
342
+ prompt: { type: "string", description: "Self-contained task. Same as task." },
343
+ task: { type: "string", description: "Alias of prompt" },
264
344
  name: { type: "string", description: "Subagent name or slug" },
345
+ agent: { type: "string", description: "Alias of name" },
346
+ profile: { type: "string", description: "explorer | reviewer | worker" },
265
347
  description: { type: "string", description: "Short UI label" },
348
+ title: { type: "string", description: "Alias of description" },
266
349
  },
267
- required: ["prompt"],
350
+ };
351
+ return {
352
+ type: "object",
353
+ properties: {
354
+ ...job.properties,
355
+ background: { type: "boolean", description: "Return agent_id immediately" },
356
+ is_background: { type: "boolean", description: "Alias of background" },
357
+ tasks: {
358
+ type: "array",
359
+ description: "Pi parallel: [{name, prompt}, ...] max 8, 4 at a time",
360
+ items: job,
361
+ },
362
+ },
363
+ required: [],
364
+ };
365
+ }
366
+ if (name === "read_spawn") {
367
+ return {
368
+ type: "object",
369
+ properties: {
370
+ agent_id: { type: "string", description: "Id from background spawn" },
371
+ id: { type: "string", description: "Alias of agent_id" },
372
+ block: { type: "boolean", description: "Wait. Default true." },
373
+ },
374
+ required: [],
268
375
  };
269
376
  }
270
377
  if (name === "image_gen") {
@@ -327,6 +434,7 @@ export const BUILTIN_TOOL_NAMES = [
327
434
  "list",
328
435
  "skill",
329
436
  "spawn",
437
+ "read_spawn",
330
438
  "image_gen",
331
439
  "browser",
332
440
  ] as const;
@@ -339,6 +447,7 @@ export async function executeTool(
339
447
  try {
340
448
  const refused = gateTool(name, args, ctx);
341
449
  if (refused) return refused;
450
+ attachSpawnHandles(ctx);
342
451
  if (ctx.dispatch) {
343
452
  const { dispatch, ...rest } = ctx;
344
453
  return await dispatch(name, args, rest);
@@ -359,9 +468,11 @@ export async function builtinExecute(
359
468
  ctx: ToolContext = {},
360
469
  ): Promise<ToolOutcome> {
361
470
  try {
471
+ // workspace_write resolves relative paths from the workspace root, which
472
+ // defaults to the guild checkout (same root gateTool checks against).
362
473
  const pathBase =
363
- parseSandbox(ctx.sandbox) === "workspace_write" && ctx.workspace
364
- ? resolveToolPath(ctx.workspace)
474
+ parseSandbox(ctx.sandbox) === "workspace_write"
475
+ ? resolveToolPath(ctx.workspace?.trim() || defaultWorkspace())
365
476
  : HOME;
366
477
  if (name === "run") {
367
478
  return await runCommand(
@@ -384,13 +495,12 @@ export async function builtinExecute(
384
495
  if (name === "list") return listDir(asString(args.path), pathBase);
385
496
  if (name === "skill") return loadSkill(asString(args.name), ctx.skills ?? []);
386
497
  if (name === "spawn") {
387
- const { spawnSubagent } = await import("./subagent.ts");
388
- return spawnSubagent({
389
- prompt: asString(args.prompt),
390
- name: typeof args.name === "string" ? args.name : "",
391
- description: typeof args.description === "string" ? args.description : "",
392
- ctx,
393
- });
498
+ const { runSpawnJobs } = await import("./subagent.ts");
499
+ return runSpawnJobs(args, ctx);
500
+ }
501
+ if (name === "read_spawn") {
502
+ const { readSpawn } = await import("./subagent.ts");
503
+ return readSpawn(args, ctx);
394
504
  }
395
505
  if (name === "image_gen") {
396
506
  const { generateImage } = await import("./image-gen.ts");
@@ -713,7 +823,8 @@ Never say you cannot access this machine. Never tell the user to run the command
713
823
  When the question is about this computer, call tools first, then answer with evidence from the output.
714
824
  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.
715
825
  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.
716
- To delegate a bounded slice (explore, review, implement) to a specialist with its own context, call spawn. Pass a self-contained prompt. The subagent returns a summary. Subagents cannot spawn children.
826
+ 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.
827
+ Independent tool calls in one round also run in parallel — fire several reads/searches together.
717
828
  Check the [exit code: N] marker on every run result; investigate failures before moving on. Prefer the workdir argument over cd.
718
829
  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.
719
830
  Prefer small commands. macOS RAM: sysctl hw.memsize ; memory_pressure. Disk: df -h.`;
package/src/trajectory.ts CHANGED
@@ -38,6 +38,49 @@ export function clip(text: string, n = CLIP): string {
38
38
  return one.slice(0, n - 1) + "…";
39
39
  }
40
40
 
41
+ function recordOf(value: unknown): Record<string, unknown> | null {
42
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
43
+ return value as Record<string, unknown>;
44
+ }
45
+
46
+ /** Old rows logged spawn as TOOL "spawn spawn". Present them as spawn. */
47
+ export function promoteSpawnEvent<T extends { kind: string; summary: string; payload?: unknown }>(
48
+ event: T,
49
+ ): T & { kind: TrajectoryKind; summary: string } {
50
+ if (event.kind === "spawn") return event as T & { kind: TrajectoryKind; summary: string };
51
+ const payload = recordOf(event.payload);
52
+ const toolName = String(payload?.name || "");
53
+ const looksSpawn =
54
+ event.kind === "tool" &&
55
+ (toolName === "spawn" ||
56
+ toolName === "read_spawn" ||
57
+ /^\s*spawn(\s+spawn)?\s*$/i.test(event.summary || ""));
58
+ if (!looksSpawn) return event as T & { kind: TrajectoryKind; summary: string };
59
+ const args = recordOf(payload?.args) || payload || {};
60
+ if (toolName === "read_spawn") {
61
+ const waitId = String(args.agent_id || args.id || "").trim();
62
+ return {
63
+ ...event,
64
+ kind: "spawn",
65
+ summary: clip(`read ${waitId}`),
66
+ };
67
+ }
68
+ const agent = String(args.name || args.profile || "worker").trim() || "worker";
69
+ const label = String(args.title || args.description || "")
70
+ .replace(/\s+/g, " ")
71
+ .trim();
72
+ const prompt = String(args.prompt || args.task || "")
73
+ .replace(/\s+/g, " ")
74
+ .trim();
75
+ return {
76
+ ...event,
77
+ kind: "spawn",
78
+ summary: clip(
79
+ `${agent}${label ? " · " + label : ""}${prompt ? " — " + prompt : ""}`,
80
+ ),
81
+ };
82
+ }
83
+
41
84
  function cap(value: unknown): unknown {
42
85
  if (typeof value === "string") {
43
86
  return value.length > FIELD_CAP ? value.slice(0, FIELD_CAP) : value;
@@ -140,17 +183,34 @@ export function turnTrajectoryEvents(input: {
140
183
  });
141
184
  continue;
142
185
  }
143
- if (trace.name === "spawn") {
144
- const agent = String(trace.args.name || "worker").trim() || "worker";
145
- const label = String(trace.args.description || "").replace(/\s+/g, " ").trim();
146
- const prompt = String(trace.args.prompt || "").replace(/\s+/g, " ").trim();
186
+ if (trace.name === "spawn" || trace.name === "read_spawn") {
187
+ const agent =
188
+ String(
189
+ trace.args.profile ||
190
+ trace.args.name ||
191
+ trace.args.agent ||
192
+ "worker",
193
+ ).trim() || "worker";
194
+ const label = String(trace.args.title || trace.args.description || "")
195
+ .replace(/\s+/g, " ")
196
+ .trim();
197
+ const prompt = String(trace.args.prompt || trace.args.task || "")
198
+ .replace(/\s+/g, " ")
199
+ .trim();
200
+ const bg =
201
+ trace.args.background === true || trace.args.is_background === true
202
+ ? " (bg)"
203
+ : "";
204
+ const waitId = String(trace.args.agent_id || trace.args.id || "").trim();
147
205
  events.push({
148
206
  ts,
149
207
  turnId,
150
208
  botId,
151
209
  kind: "spawn",
152
210
  summary: clip(
153
- `${agent}${label ? " · " + label : ""}${prompt ? " — " + prompt : ""}${trace.running ? "" : ""}`,
211
+ trace.name === "read_spawn"
212
+ ? `read ${waitId || agent}${trace.running ? " …" : ""}`
213
+ : `${agent}${bg}${label ? " · " + label : ""}${prompt ? " — " + prompt : ""}${trace.running ? " …" : ""}`,
154
214
  ),
155
215
  payload: cap(trace.args),
156
216
  result: String(cap(trace.text ?? "")),
@@ -261,16 +321,19 @@ export function synthesizeTrajectory(messages: ChatMessage[]): TrajectoryEvent[]
261
321
  continue;
262
322
  }
263
323
  if (part.type === "tool") {
324
+ const spawnish = part.name === "spawn" || part.name === "read_spawn";
264
325
  events.push({
265
326
  seq: seq++,
266
327
  ts,
267
328
  turnId,
268
329
  botId: msg.author,
269
- kind: part.name === "spawn" ? "spawn" : "tool",
330
+ kind: spawnish ? "spawn" : "tool",
270
331
  summary: clip(
271
- part.name === "spawn"
272
- ? part.detail || part.label || "spawn"
273
- : `${part.name} ${part.detail}`,
332
+ part.name === "read_spawn"
333
+ ? `read ${part.detail || ""}`
334
+ : part.name === "spawn"
335
+ ? part.detail || part.label || "spawn"
336
+ : `${part.name} ${part.detail}`,
274
337
  ),
275
338
  payload: { name: part.name, detail: part.detail },
276
339
  result: part.output,
package/src/usage.ts CHANGED
@@ -59,13 +59,19 @@ export function fromOpenAiUsage(usage?: {
59
59
  prompt_tokens?: number;
60
60
  completion_tokens?: number;
61
61
  total_tokens?: number;
62
+ prompt_tokens_details?: { cached_tokens?: number };
63
+ input_tokens_details?: { cached_tokens?: number };
62
64
  } | null): ChatUsage {
63
65
  if (!usage) return { rounds: 1 };
64
66
  const input = num(usage.prompt_tokens);
65
67
  const output = num(usage.completion_tokens);
68
+ const cacheRead =
69
+ num(usage.prompt_tokens_details?.cached_tokens) ??
70
+ num(usage.input_tokens_details?.cached_tokens);
66
71
  return {
67
72
  input,
68
73
  output,
74
+ cacheRead,
69
75
  totalTokens: num(usage.total_tokens) || sum(input, output),
70
76
  rounds: 1,
71
77
  };
@@ -74,6 +80,8 @@ export function fromOpenAiUsage(usage?: {
74
80
  export function fromAnthropicUsage(usage?: {
75
81
  input_tokens?: number;
76
82
  output_tokens?: number;
83
+ cache_read_input_tokens?: number;
84
+ cache_creation_input_tokens?: number;
77
85
  } | null): ChatUsage {
78
86
  if (!usage) return { rounds: 1 };
79
87
  const input = num(usage.input_tokens);
@@ -81,6 +89,8 @@ export function fromAnthropicUsage(usage?: {
81
89
  return {
82
90
  input,
83
91
  output,
92
+ cacheRead: num(usage.cache_read_input_tokens),
93
+ cacheWrite: num(usage.cache_creation_input_tokens),
84
94
  totalTokens: sum(input, output),
85
95
  rounds: 1,
86
96
  };
package/src/version.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ /**
4
+ * Outbound User-Agent. npm ships this package's source, so the manifest always
5
+ * sits one level above `src/` — same lookup as `--version` in cli.ts.
6
+ */
7
+ export function guildUserAgent(): string {
8
+ return `Guild/${guildVersion()}`;
9
+ }
10
+
11
+ let cached: string | null = null;
12
+
13
+ export function guildVersion(): string {
14
+ if (cached !== null) return cached;
15
+ try {
16
+ const pkg = JSON.parse(
17
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
18
+ ) as { version?: unknown };
19
+ const version = typeof pkg.version === "string" ? pkg.version.trim() : "";
20
+ cached = version || "0";
21
+ } catch {
22
+ cached = "0";
23
+ }
24
+ return cached;
25
+ }
@@ -37,6 +37,8 @@ export type Bot = {
37
37
  skillIds: string[];
38
38
  defaultPositionId: string;
39
39
  oneLiner?: string;
40
+ /** Public /generated/… sprite. Missing → procedural tavern buddy. */
41
+ portrait?: string;
40
42
  /** Chat model for this bot. Missing → guild default. */
41
43
  model?: ModelRef | null;
42
44
  createdAt: string;
@@ -53,6 +55,10 @@ export type Room = {
53
55
  name: string;
54
56
  memberIds: string[];
55
57
  createdAt: string;
58
+ /** Parent channel id when this is a branched side quest. */
59
+ parentId?: string;
60
+ /** Message id in the parent room that opened this branch. */
61
+ branchFromId?: string;
56
62
  };
57
63
 
58
64
  export type ChatPart =
@@ -114,6 +120,12 @@ export type ChatMessage = {
114
120
  steer?: boolean;
115
121
  /** Live bot this steer was aimed at. */
116
122
  steerBotId?: string;
123
+ /**
124
+ * Bot ids this message dispatches.
125
+ * User @mentions and bot handoffs. Missing on older rows (parse the body).
126
+ * Empty array means nobody — do not fall back to parsing.
127
+ */
128
+ mentions?: string[];
117
129
  };
118
130
 
119
131
  export type LlmApi =
@@ -121,9 +133,20 @@ export type LlmApi =
121
133
  | "anthropic-messages"
122
134
  | "openai-responses";
123
135
 
136
+ export type ModelReasoning = {
137
+ /** Effort strings this model accepts, catalog order. Missing = no effort picker. */
138
+ supportedEfforts?: string[];
139
+ defaultEffort?: string;
140
+ /** When true, do not send `none` — reasoning cannot be turned off. */
141
+ mandatory?: boolean;
142
+ defaultEnabled?: boolean;
143
+ supportsMaxTokens?: boolean;
144
+ };
145
+
124
146
  export type ModelEntry = {
125
147
  id: string;
126
148
  name?: string;
149
+ reasoning?: ModelReasoning;
127
150
  };
128
151
 
129
152
  export type ProviderEntry = {
@@ -141,11 +164,13 @@ export type AuxRole =
141
164
  | "skills"
142
165
  | "approval"
143
166
  | "title"
144
- | "generate";
167
+ | "generate"
168
+ | "spawn";
145
169
 
146
170
  export type ModelsFile = {
147
171
  default?: ModelRef | null;
148
- reasoning?: "minimal" | "low" | "medium" | "high";
172
+ /** Last chosen effort string (catalog-defined: low, high, xhigh, …). */
173
+ reasoning?: string;
149
174
  fast?: boolean;
150
175
  aux?: Partial<Record<AuxRole, ModelRef | null>>;
151
176
  recent?: ModelRef[];