@kevin5251984/guild 0.2.18 → 0.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kevin5251984/guild",
3
- "version": "0.2.18",
3
+ "version": "0.2.19",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A local guild of adventurers. npx @kevin5251984/guild web",
@@ -20,7 +20,8 @@ sandbox_mode = "read-only"
20
20
  developer_instructions = """
21
21
  Role: codebase search specialist. Find files and code. Read-only.
22
22
 
23
- Answer "where is X / which files do Y" with every relevant absolute path and the actual need behind the request. Prefer parallel searches. Stop after two waves add nothing new.
23
+ Answer "where is X / which files do Y" with every relevant absolute path and the actual need behind the request.
24
+ Fire 3+ independent tool calls in the first round (they run in parallel). Stop after two waves add nothing new.
24
25
 
25
26
  Never edit, write, or apply patches. Findings are message text only.
26
27
 
package/src/chat-parts.ts CHANGED
@@ -40,11 +40,16 @@ export function assembleParts(input: {
40
40
  ? String(trace.args.prompt ?? "")
41
41
  : trace.name === "spawn"
42
42
  ? String(
43
- trace.args.description ||
43
+ trace.args.title ||
44
+ trace.args.description ||
45
+ trace.args.profile ||
44
46
  trace.args.name ||
47
+ trace.args.task ||
45
48
  trace.args.prompt ||
46
49
  "",
47
50
  )
51
+ : trace.name === "read_spawn"
52
+ ? String(trace.args.agent_id || trace.args.id || "")
48
53
  : String(trace.args.path ?? ""),
49
54
  output: trace.text,
50
55
  isError: trace.isError,
package/src/compact.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ChatPart, ModelRef } from "@guild/protocol";
2
2
  import { llmComplete } from "./llm.ts";
3
+ import type { ToolProgress } from "./tools.ts";
3
4
 
4
5
  /** Cheap char/4 estimate, same ballpark Codex uses before a real tokenizer. */
5
6
  export const CHARS_PER_TOKEN = 4;
@@ -251,6 +252,8 @@ async function summarizeOld(input: {
251
252
  dataDir: string;
252
253
  env?: NodeJS.ProcessEnv;
253
254
  prefer?: ModelRef | null;
255
+ onProgress?: (update: ToolProgress) => void;
256
+ signal?: AbortSignal;
254
257
  }): Promise<string> {
255
258
  const transcript = input.old
256
259
  .map((item) => {
@@ -275,6 +278,14 @@ async function summarizeOld(input: {
275
278
  prefer: input.prefer,
276
279
  tools: false,
277
280
  temperature: 0.1,
281
+ toolCtx: {
282
+ dataDir: input.dataDir,
283
+ env: input.env,
284
+ spawnDepth: 0,
285
+ allowWrite: false,
286
+ onProgress: input.onProgress,
287
+ signal: input.signal,
288
+ },
278
289
  system:
279
290
  "You compact a conversation so work can continue. Output only the summary.",
280
291
  messages: [
@@ -305,6 +316,8 @@ export async function packHistory(input: {
305
316
  prefer?: ModelRef | null;
306
317
  checkpoint?: CompactCheckpoint | null;
307
318
  tokenLimit?: number;
319
+ onProgress?: (update: ToolProgress) => void;
320
+ signal?: AbortSignal;
308
321
  }): Promise<PackedHistory> {
309
322
  const user = { role: "user" as const, content: input.userMessage };
310
323
  const history = input.history.map(clipHistoryItem);
@@ -328,12 +341,26 @@ export async function packHistory(input: {
328
341
  summary = input.checkpoint!.summary;
329
342
  checkpoint = input.checkpoint!;
330
343
  } else {
344
+ input.onProgress?.({
345
+ thinking: "整理上文…",
346
+ traces: [
347
+ {
348
+ name: "context",
349
+ args: {},
350
+ text: "",
351
+ isError: false,
352
+ running: true,
353
+ },
354
+ ],
355
+ });
331
356
  summary = await summarizeOld({
332
357
  old: plan.old,
333
358
  previous: input.checkpoint?.summary,
334
359
  dataDir: input.dataDir,
335
360
  env: input.env,
336
361
  prefer: input.prefer,
362
+ onProgress: input.onProgress,
363
+ signal: input.signal,
337
364
  });
338
365
  checkpoint = {
339
366
  throughId: lastId(plan.old),
package/src/db.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  import {
2
+ appendFileSync,
3
+ closeSync,
2
4
  existsSync,
3
5
  mkdirSync,
6
+ openSync,
4
7
  readdirSync,
5
8
  readFileSync,
9
+ readSync,
6
10
  rmSync,
11
+ statSync,
12
+ writeFileSync,
7
13
  } from "node:fs";
8
14
  import { dirname, join, resolve } from "node:path";
9
15
  import { DatabaseSync } from "node:sqlite";
@@ -17,7 +23,10 @@ import type {
17
23
  import type { TrajectoryDraft, TrajectoryEvent } from "./trajectory.ts";
18
24
 
19
25
  export const GUILD_DB_FILE = "guild.sqlite";
20
- const SCHEMA_VERSION = "1";
26
+ /** Per-room hot window in SQLite. Older rows spill to rooms/<id>/trajectory.jsonl. */
27
+ export const TRAJECTORY_HOT_CAP = 1000;
28
+ const SCHEMA_VERSION = "2";
29
+ const WAREHOUSE_TAIL = 1024 * 1024;
21
30
 
22
31
  const SCHEMA = `
23
32
  PRAGMA journal_mode = WAL;
@@ -34,7 +43,9 @@ CREATE TABLE IF NOT EXISTS rooms (
34
43
  kind TEXT NOT NULL CHECK (kind IN ('channel', 'dm')),
35
44
  name TEXT NOT NULL,
36
45
  member_ids TEXT NOT NULL DEFAULT '[]',
37
- created_at TEXT NOT NULL
46
+ created_at TEXT NOT NULL,
47
+ parent_id TEXT,
48
+ branch_from_id TEXT
38
49
  );
39
50
 
40
51
  CREATE TABLE IF NOT EXISTS messages (
@@ -51,6 +62,7 @@ CREATE TABLE IF NOT EXISTS messages (
51
62
  finished_at TEXT,
52
63
  steer INTEGER NOT NULL DEFAULT 0,
53
64
  steer_bot_id TEXT,
65
+ mentions TEXT,
54
66
  UNIQUE (room_id, seq)
55
67
  );
56
68
 
@@ -138,7 +150,11 @@ function parseJsonlTrajectory(raw: string): TrajectoryEvent[] {
138
150
  if (!trimmed) continue;
139
151
  try {
140
152
  const parsed = JSON.parse(trimmed) as TrajectoryEvent;
141
- if (parsed && typeof parsed.kind === "string") out.push(parsed);
153
+ if (!parsed || typeof parsed.kind !== "string") continue;
154
+ if (typeof parsed.seq !== "number" || !Number.isFinite(parsed.seq)) {
155
+ parsed.seq = out.length;
156
+ }
157
+ out.push(parsed);
142
158
  } catch {
143
159
  /* skip */
144
160
  }
@@ -146,6 +162,51 @@ function parseJsonlTrajectory(raw: string): TrajectoryEvent[] {
146
162
  return out;
147
163
  }
148
164
 
165
+ function lastJsonlSeq(path: string): number {
166
+ if (!existsSync(path)) return -1;
167
+ const stat = statSync(path);
168
+ if (stat.size === 0) return -1;
169
+ const fd = openSync(path, "r");
170
+ try {
171
+ const size = Math.min(stat.size, WAREHOUSE_TAIL);
172
+ const buf = Buffer.alloc(size);
173
+ readSync(fd, buf, 0, size, stat.size - size);
174
+ const lines = buf.toString("utf8").split("\n");
175
+ const start = stat.size > size ? 1 : 0;
176
+ for (let i = lines.length - 1; i >= start; i--) {
177
+ const trimmed = lines[i]!.trim();
178
+ if (!trimmed) continue;
179
+ try {
180
+ const parsed = JSON.parse(trimmed) as { seq?: unknown };
181
+ if (typeof parsed.seq === "number" && Number.isFinite(parsed.seq)) {
182
+ return parsed.seq;
183
+ }
184
+ } catch {
185
+ /* skip */
186
+ }
187
+ }
188
+ return -1;
189
+ } finally {
190
+ closeSync(fd);
191
+ }
192
+ }
193
+
194
+ function trajectoryValues(roomId: string, event: TrajectoryEvent) {
195
+ return [
196
+ roomId,
197
+ event.seq,
198
+ event.ts,
199
+ event.turnId,
200
+ event.botId ?? null,
201
+ event.kind,
202
+ event.summary,
203
+ event.payload === undefined ? null : JSON.stringify(event.payload),
204
+ event.result ?? null,
205
+ event.durationMs ?? null,
206
+ event.isError ? 1 : 0,
207
+ ] as const;
208
+ }
209
+
149
210
  function messageFromRow(row: Record<string, unknown>): ChatMessage {
150
211
  const parts = parseJson<ChatPart[] | null>(row.parts, null);
151
212
  const attachments = parseJson<ChatAttachment[] | null>(row.attachments, null);
@@ -167,12 +228,18 @@ function messageFromRow(row: Record<string, unknown>): ChatMessage {
167
228
  if (asNumber(row.steer) === 1) message.steer = true;
168
229
  const steerBotId = asString(row.steer_bot_id);
169
230
  if (steerBotId) message.steerBotId = steerBotId;
231
+ if (typeof row.mentions === "string" && row.mentions) {
232
+ const mentions = parseJson<string[]>(row.mentions, []);
233
+ if (Array.isArray(mentions)) {
234
+ message.mentions = mentions.filter((id) => typeof id === "string");
235
+ }
236
+ }
170
237
  return message;
171
238
  }
172
239
 
173
240
  function roomFromRow(row: Record<string, unknown>): Room {
174
241
  const memberIds = parseJson<string[]>(row.member_ids, []);
175
- return {
242
+ const room: Room = {
176
243
  id: asString(row.id),
177
244
  kind: asString(row.kind) === "dm" ? "dm" : "channel",
178
245
  name: asString(row.name),
@@ -181,6 +248,11 @@ function roomFromRow(row: Record<string, unknown>): Room {
181
248
  : [],
182
249
  createdAt: asString(row.created_at),
183
250
  };
251
+ const parentId = asString(row.parent_id);
252
+ if (parentId) room.parentId = parentId;
253
+ const branchFromId = asString(row.branch_from_id);
254
+ if (branchFromId) room.branchFromId = branchFromId;
255
+ return room;
184
256
  }
185
257
 
186
258
  function trajectoryFromRow(row: Record<string, unknown>): TrajectoryEvent {
@@ -215,8 +287,23 @@ export class GuildDb {
215
287
  } catch {
216
288
  /* column already exists on fresh schema */
217
289
  }
290
+ try {
291
+ this.sqlite.exec("ALTER TABLE messages ADD COLUMN mentions TEXT");
292
+ } catch {
293
+ /* column already exists on fresh schema */
294
+ }
295
+ try {
296
+ this.sqlite.exec("ALTER TABLE rooms ADD COLUMN parent_id TEXT");
297
+ } catch {
298
+ /* column already exists on fresh schema */
299
+ }
300
+ try {
301
+ this.sqlite.exec("ALTER TABLE rooms ADD COLUMN branch_from_id TEXT");
302
+ } catch {
303
+ /* column already exists on fresh schema */
304
+ }
218
305
  this.sqlite.prepare(
219
- "INSERT OR IGNORE INTO meta (key, value) VALUES ('schema', ?)",
306
+ "INSERT INTO meta (key, value) VALUES ('schema', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
220
307
  ).run(SCHEMA_VERSION);
221
308
  }
222
309
 
@@ -226,23 +313,27 @@ export class GuildDb {
226
313
 
227
314
  importLegacyFiles(dataDir: string): void {
228
315
  const root = join(dataDir, "rooms");
229
- if (!existsSync(root)) return;
230
- const ids = readdirSync(root, { withFileTypes: true })
231
- .filter((entry) => entry.isDirectory())
232
- .map((entry) => entry.name);
233
- for (const id of ids) this.importRoomDir(join(root, id), id);
316
+ if (existsSync(root)) {
317
+ const ids = readdirSync(root, { withFileTypes: true })
318
+ .filter((entry) => entry.isDirectory())
319
+ .map((entry) => entry.name);
320
+ for (const id of ids) this.importRoomDir(join(root, id), id);
321
+ }
322
+ for (const room of this.listRooms()) this.spillColdTrajectory(room.id);
234
323
  }
235
324
 
236
325
  upsertRoom(room: Room): void {
237
326
  this.sqlite
238
327
  .prepare(
239
- `INSERT INTO rooms (id, kind, name, member_ids, created_at)
240
- VALUES (?, ?, ?, ?, ?)
328
+ `INSERT INTO rooms (id, kind, name, member_ids, created_at, parent_id, branch_from_id)
329
+ VALUES (?, ?, ?, ?, ?, ?, ?)
241
330
  ON CONFLICT(id) DO UPDATE SET
242
331
  kind = excluded.kind,
243
332
  name = excluded.name,
244
333
  member_ids = excluded.member_ids,
245
- created_at = excluded.created_at`,
334
+ created_at = excluded.created_at,
335
+ parent_id = excluded.parent_id,
336
+ branch_from_id = excluded.branch_from_id`,
246
337
  )
247
338
  .run(
248
339
  room.id,
@@ -250,6 +341,8 @@ export class GuildDb {
250
341
  room.name,
251
342
  JSON.stringify(room.memberIds),
252
343
  room.createdAt,
344
+ room.parentId ?? null,
345
+ room.branchFromId ?? null,
253
346
  );
254
347
  }
255
348
 
@@ -304,10 +397,22 @@ export class GuildDb {
304
397
  }
305
398
  }
306
399
 
307
- updateMessageBody(roomId: string, messageId: string, body: string): ChatMessage | null {
400
+ updateMessageBody(
401
+ roomId: string,
402
+ messageId: string,
403
+ body: string,
404
+ mentions?: string[],
405
+ ): ChatMessage | null {
308
406
  const result = this.sqlite
309
- .prepare("UPDATE messages SET body = ? WHERE room_id = ? AND id = ?")
310
- .run(body, roomId, messageId);
407
+ .prepare(
408
+ "UPDATE messages SET body = ?, mentions = ? WHERE room_id = ? AND id = ?",
409
+ )
410
+ .run(
411
+ body,
412
+ mentions ? JSON.stringify(mentions) : null,
413
+ roomId,
414
+ messageId,
415
+ );
311
416
  if (!result.changes) return null;
312
417
  return this.getMessage(roomId, messageId);
313
418
  }
@@ -321,12 +426,13 @@ export class GuildDb {
321
426
  usage?: ChatUsage;
322
427
  createdAt: string;
323
428
  finishedAt: string;
429
+ mentions?: string[];
324
430
  },
325
431
  ): ChatMessage | null {
326
432
  const result = this.sqlite
327
433
  .prepare(
328
434
  `UPDATE messages
329
- SET body = ?, parts = ?, usage = ?, created_at = ?, finished_at = ?
435
+ SET body = ?, parts = ?, usage = ?, created_at = ?, finished_at = ?, mentions = ?
330
436
  WHERE room_id = ? AND id = ?`,
331
437
  )
332
438
  .run(
@@ -335,6 +441,7 @@ export class GuildDb {
335
441
  patch.usage ? JSON.stringify(patch.usage) : null,
336
442
  patch.createdAt,
337
443
  patch.finishedAt,
444
+ patch.mentions ? JSON.stringify(patch.mentions) : null,
338
445
  roomId,
339
446
  messageId,
340
447
  );
@@ -348,6 +455,7 @@ export class GuildDb {
348
455
  this.sqlite
349
456
  .prepare("DELETE FROM messages WHERE room_id = ? AND id = ?")
350
457
  .run(roomId, messageId);
458
+ /* Hot window only. Warehouse jsonl stays append-only. */
351
459
  this.sqlite
352
460
  .prepare("DELETE FROM trajectory WHERE room_id = ? AND turn_id = ?")
353
461
  .run(roomId, messageId);
@@ -385,19 +493,7 @@ export class GuildDb {
385
493
  for (const draft of drafts) {
386
494
  seq += 1;
387
495
  const event: TrajectoryEvent = { ...draft, seq };
388
- insert.run(
389
- roomId,
390
- event.seq,
391
- event.ts,
392
- event.turnId,
393
- event.botId ?? null,
394
- event.kind,
395
- event.summary,
396
- event.payload === undefined ? null : JSON.stringify(event.payload),
397
- event.result ?? null,
398
- event.durationMs ?? null,
399
- event.isError ? 1 : 0,
400
- );
496
+ insert.run(...trajectoryValues(roomId, event));
401
497
  written.push(event);
402
498
  }
403
499
  this.sqlite.exec("COMMIT");
@@ -462,7 +558,39 @@ export class GuildDb {
462
558
  const row = this.sqlite
463
559
  .prepare("SELECT COALESCE(MAX(seq), -1) AS seq FROM trajectory WHERE room_id = ?")
464
560
  .get(roomId) as { seq?: number } | undefined;
465
- return asNumber(row?.seq, -1);
561
+ return Math.max(asNumber(row?.seq, -1), lastJsonlSeq(this.warehousePath(roomId)));
562
+ }
563
+
564
+ private warehousePath(roomId: string): string {
565
+ return join(dirname(this.path), "rooms", roomId, "trajectory.jsonl");
566
+ }
567
+
568
+ spillColdTrajectory(roomId: string): void {
569
+ const extra = this.trajectoryCount(roomId) - TRAJECTORY_HOT_CAP;
570
+ if (extra <= 0) return;
571
+ const rows = this.sqlite
572
+ .prepare(
573
+ "SELECT * FROM trajectory WHERE room_id = ? ORDER BY seq ASC LIMIT ?",
574
+ )
575
+ .all(roomId, extra) as Record<string, unknown>[];
576
+ if (!rows.length) return;
577
+ const events = rows.map(trajectoryFromRow);
578
+ const path = this.warehousePath(roomId);
579
+ const lastArchived = lastJsonlSeq(path);
580
+ const fresh = events.filter((event) => event.seq > lastArchived);
581
+ if (fresh.length) {
582
+ mkdirSync(dirname(path), { recursive: true });
583
+ appendFileSync(
584
+ path,
585
+ fresh.map((event) => `${JSON.stringify(event)}\n`).join(""),
586
+ "utf8",
587
+ );
588
+ }
589
+ const lastSeq = events[events.length - 1]?.seq;
590
+ if (typeof lastSeq !== "number") return;
591
+ this.sqlite
592
+ .prepare("DELETE FROM trajectory WHERE room_id = ? AND seq <= ?")
593
+ .run(roomId, lastSeq);
466
594
  }
467
595
 
468
596
  private insertMessage(message: ChatMessage, seq: number): void {
@@ -470,8 +598,8 @@ export class GuildDb {
470
598
  .prepare(
471
599
  `INSERT INTO messages (
472
600
  id, room_id, seq, author, body, parts, reply_to, attachments, usage,
473
- created_at, finished_at, steer, steer_bot_id
474
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
601
+ created_at, finished_at, steer, steer_bot_id, mentions
602
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
475
603
  )
476
604
  .run(
477
605
  message.id,
@@ -487,6 +615,7 @@ export class GuildDb {
487
615
  message.finishedAt ?? null,
488
616
  message.steer ? 1 : 0,
489
617
  message.steerBotId ?? null,
618
+ message.mentions ? JSON.stringify(message.mentions) : null,
490
619
  );
491
620
  }
492
621
 
@@ -507,6 +636,12 @@ export class GuildDb {
507
636
  typeof parsed.createdAt === "string"
508
637
  ? parsed.createdAt
509
638
  : "2026-01-01T00:00:00.000Z",
639
+ ...(typeof parsed.parentId === "string" && parsed.parentId
640
+ ? { parentId: parsed.parentId }
641
+ : {}),
642
+ ...(typeof parsed.branchFromId === "string" && parsed.branchFromId
643
+ ? { branchFromId: parsed.branchFromId }
644
+ : {}),
510
645
  });
511
646
  }
512
647
  } catch {
@@ -536,34 +671,20 @@ export class GuildDb {
536
671
  const path = join(dir, "trajectory.jsonl");
537
672
  if (existsSync(path)) {
538
673
  const events = parseJsonlTrajectory(readFileSync(path, "utf8"));
539
- if (events.length) {
540
- this.sqlite.exec("BEGIN");
541
- try {
542
- const insert = this.sqlite.prepare(
543
- `INSERT INTO trajectory (
544
- room_id, seq, ts, turn_id, bot_id, kind, summary, payload, result, duration_ms, is_error
545
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
546
- );
547
- for (const event of events) {
548
- insert.run(
549
- id,
550
- event.seq,
551
- event.ts,
552
- event.turnId,
553
- event.botId ?? null,
554
- event.kind,
555
- event.summary,
556
- event.payload === undefined ? null : JSON.stringify(event.payload),
557
- event.result ?? null,
558
- event.durationMs ?? null,
559
- event.isError ? 1 : 0,
560
- );
561
- }
562
- this.sqlite.exec("COMMIT");
563
- } catch (error) {
564
- this.sqlite.exec("ROLLBACK");
565
- throw error;
566
- }
674
+ if (events.length <= TRAJECTORY_HOT_CAP) {
675
+ if (events.length) this.insertTrajectoryRows(id, events);
676
+ rmIfExists(path);
677
+ } else {
678
+ const cut = events.length - TRAJECTORY_HOT_CAP;
679
+ this.insertTrajectoryRows(id, events.slice(cut));
680
+ writeFileSync(
681
+ path,
682
+ events
683
+ .slice(0, cut)
684
+ .map((event) => `${JSON.stringify(event)}\n`)
685
+ .join(""),
686
+ "utf8",
687
+ );
567
688
  }
568
689
  }
569
690
  }
@@ -597,12 +718,26 @@ export class GuildDb {
597
718
  rmIfExists(join(dir, "messages.json"));
598
719
  rmIfExists(join(dir, "messages.jsonl"));
599
720
  }
600
- if (this.trajectoryCount(id) > 0) {
601
- rmIfExists(join(dir, "trajectory.jsonl"));
602
- }
603
721
  if (this.readCompact(id)) rmIfExists(join(dir, "compact.json"));
604
722
  }
605
723
 
724
+ private insertTrajectoryRows(roomId: string, events: TrajectoryEvent[]): void {
725
+ if (!events.length) return;
726
+ this.sqlite.exec("BEGIN");
727
+ try {
728
+ const insert = this.sqlite.prepare(
729
+ `INSERT INTO trajectory (
730
+ room_id, seq, ts, turn_id, bot_id, kind, summary, payload, result, duration_ms, is_error
731
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
732
+ );
733
+ for (const event of events) insert.run(...trajectoryValues(roomId, event));
734
+ this.sqlite.exec("COMMIT");
735
+ } catch (error) {
736
+ this.sqlite.exec("ROLLBACK");
737
+ throw error;
738
+ }
739
+ }
740
+
606
741
  private messageCount(roomId: string): number {
607
742
  const row = this.sqlite
608
743
  .prepare("SELECT COUNT(*) AS n FROM messages WHERE room_id = ?")
package/src/generate.ts CHANGED
@@ -350,16 +350,16 @@ When work belongs to someone else, put @handle at the start of a line with a wri
350
350
  - Done when
351
351
  - Constraints / out of scope
352
352
  - Files or evidence
353
- Each line-start @handle on this quest starts that seat. Mentions in the middle of a sentence do not dispatch.
353
+ Each line-start @handle on this quest starts that seat. A markdown numbered list that names a teammate (1. @design) also starts them, even if the handle is wrapped in backticks. Mentions that are only commentary in a sentence do not dispatch.
354
354
  Do not @all unless the human did. Do not recruit extra people; the human staffs the roster (max ${CHANNEL_ROSTER_CAP} on a quest).
355
- Only line-start @handle a seat the human already named this turn, or that they asked you to split the work to. Do not invent a third seat. If B must wait for A, do not @ B this turn.
355
+ You may @handle any staffed teammate whose job is the next step, even if the human only named you this turn. That is how the hall continues. Do not dump the same work on every seat. If two seats must run in order, only @ the seat that can start now — a numbered list that names later seats starts them this turn too. Do not write a plan and stop.
356
356
  Stay quiet: no status theater, no "I'll start now." Speak when you finish, block, or need a decision. Money, sends, and destructive actions wait for the human.
357
357
 
358
358
  Harness this turn (Memory → Plan → Skills → Act):
359
359
  - Memory: Channel.md is the task. MEMORY.md is standing notes. The compact log is working memory — do not recap the whole thread.
360
360
  - Plan: one local directive (goal + done when) before tools. Revise it when evidence changes.
361
361
  - Skills: the catalog is availability, not a todo. Call \`skill\` only when this directive matches. Do not load every skill.
362
- - Act: inspect, smallest change, verify, stop. Spawn is a specialist for a bounded slice of THIS seat's job (explore / review / implement) with a fresh context. Do not spawn to do another staffed bot's job — @handle them instead.`;
362
+ - Act: you coordinate this seat. Spawn first when the work is a repo survey (\`explorer\` / luna-explore), a critique (\`reviewer\`), or a bounded isolated patch (\`worker\` / luna-general); then verify the child's evidence and decide. Independent surveys: spawn background=true, keep working, then read_spawn before you answer. Sequential: background=false and wait. Do not spawn for one known file, a one-line change, or a question that needs no repo. Do not let children commit, push, or make the architecture call. Do not skip spawn just because you can do the work yourself. Do not spawn to do another staffed bot's job — @handle them instead.`;
363
363
 
364
364
  export function buildChatSystem(input: {
365
365
  botName: string;
@@ -397,29 +397,57 @@ export function buildChatSystem(input: {
397
397
  "</system-reminder>",
398
398
  ].join("\n")
399
399
  : "";
400
- const spawnLine = subagents.length
401
- ? [
402
- "<available_subagents>",
403
- ...subagents.slice(0, 40).map((item) => {
404
- const key = item.slug || item.name;
405
- const desc = (item.description || item.name)
406
- .replace(/\s+/g, " ")
407
- .trim()
408
- .slice(0, 220);
409
- const mode = item.readOnly ? "read-only" : "read-write";
410
- return `- \`${key}\` (${mode}): ${desc}`;
411
- }),
412
- "</available_subagents>",
413
- "Call spawn with the exact name (or slug) and a self-contained prompt. If the user writes /name matching a subagent, spawn that one. The child has a fresh context and returns a summary.",
414
- wantSpawn.length
415
- ? `This turn the user invoked ${wantSpawn
416
- .map((item) => "`/" + (item.slug || item.name) + "`")
417
- .join(", ")}. Call spawn with that exact name first, with a self-contained prompt covering their request. Do not skip this and do the work yourself.`
418
- : "",
419
- ]
420
- .filter(Boolean)
421
- .join("\n")
422
- : "";
400
+ const spawnCatalog: Array<{
401
+ slug?: string;
402
+ name: string;
403
+ description?: string;
404
+ readOnly?: boolean;
405
+ }> = subagents.length
406
+ ? subagents
407
+ : [
408
+ {
409
+ slug: "explorer",
410
+ name: "explorer",
411
+ description:
412
+ "Read-only codebase search. Returns absolute paths and a direct answer.",
413
+ readOnly: true,
414
+ },
415
+ {
416
+ slug: "reviewer",
417
+ name: "reviewer",
418
+ description: "Read-only review of correctness, risk, and missing tests.",
419
+ readOnly: true,
420
+ },
421
+ {
422
+ slug: "worker",
423
+ name: "worker",
424
+ description:
425
+ "Implementation executor. Smallest correct change, then verify.",
426
+ readOnly: false,
427
+ },
428
+ ];
429
+ const spawnLine = [
430
+ "<available_subagents>",
431
+ ...spawnCatalog.slice(0, 40).map((item) => {
432
+ const key = item.slug || item.name;
433
+ const desc = (item.description || item.name)
434
+ .replace(/\s+/g, " ")
435
+ .trim()
436
+ .slice(0, 220);
437
+ const mode = item.readOnly ? "read-only" : "read-write";
438
+ return `- \`${key}\` (${mode}): ${desc}`;
439
+ }),
440
+ "</available_subagents>",
441
+ "Call spawn with the exact name (or slug) and a self-contained prompt (Pi: agent+task). Default: explorer to orient across unknown files, reviewer to critique a change, worker for an isolated patch. Independent slices: several spawn calls in this round, or tasks: [{name, prompt}]. You stay this seat's coordinator. Skipping spawn and reading the whole tree yourself is the wrong default.",
442
+ "If the user writes /name matching a subagent, spawn that one. The child has a fresh context and returns a summary.",
443
+ wantSpawn.length
444
+ ? `This turn the user invoked ${wantSpawn
445
+ .map((item) => "`/" + (item.slug || item.name) + "`")
446
+ .join(", ")}. Call spawn with that exact name first, with a self-contained prompt covering their request. Do not skip this and do the work yourself.`
447
+ : "",
448
+ ]
449
+ .filter(Boolean)
450
+ .join("\n");
423
451
  const channel = (input.channelMd ?? "").trim();
424
452
  const channelBlock = channel
425
453
  ? `# Channel.md\nThis channel's operating notes written by the user. Follow them for this room. They outrank MEMORY.md.\n\n${channel.slice(0, 4000)}`
@@ -515,7 +543,7 @@ export function localChatReply(
515
543
  userMessage: string,
516
544
  ): string {
517
545
  const clip = userMessage.trim().slice(0, 120);
518
- return `【${botName} @${handle}】收到。「${clip}」\n\n沒有可用模型,本機工具還沒辦法跑。到私訊幫我選一個模型後再問。`;
546
+ return `【${botName} @${handle}】收到。「${clip}」\n\n沒有可用模型,本機工具還沒辦法跑。到模型頁(/settings)連接訂閱或填 API key,套用主模型後再問。`;
519
547
  }
520
548
 
521
549
  async function tryChatLlm(
@@ -569,6 +597,8 @@ async function tryChatLlm(
569
597
  env,
570
598
  prefer,
571
599
  checkpoint: input.compact,
600
+ onProgress: input.onProgress,
601
+ signal: input.signal,
572
602
  });
573
603
  if (packed.compacted && packed.checkpoint && input.onCompact) {
574
604
  input.onCompact(packed.checkpoint);
@@ -594,6 +624,7 @@ async function tryChatLlm(
594
624
  pullSteers: input.pullSteers,
595
625
  signal: input.signal,
596
626
  mcpTools: input.mcpTools ?? (await listMcpToolRefs(dataDir)),
627
+ spawnHandles: new Map(),
597
628
  ...policyFor(env, {
598
629
  sandbox: input.sandbox,
599
630
  workspace: input.workspace,