@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kevin5251984/guild",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A local guild of adventurers. npx @kevin5251984/guild web",
@@ -31,7 +31,7 @@
31
31
  "scripts": {
32
32
  "dev": "tsx src/cli.ts",
33
33
  "start": "node ./bin/guildd.mjs",
34
- "build": "tsc -p tsconfig.json",
34
+ "build": "node --check ./bin/guildd.mjs",
35
35
  "test": "tsx --test test/*.test.ts",
36
36
  "prepack": "node ./scripts/vendor-protocol.mjs prepack",
37
37
  "postpack": "node ./scripts/vendor-protocol.mjs postpack"
@@ -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/cli.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { readFileSync } from "node:fs";
2
1
  import { execFile } from "node:child_process";
3
2
  import { startGuildDaemon } from "./start.ts";
4
3
  import {
@@ -6,6 +5,7 @@ import {
6
5
  parseGuildCli,
7
6
  shouldOpenBrowser,
8
7
  } from "./cli-args.ts";
8
+ import { guildVersion } from "./version.ts";
9
9
 
10
10
  const opts = parseGuildCli(process.argv);
11
11
  if (opts.error) {
@@ -15,10 +15,7 @@ if (opts.error) {
15
15
  } else if (opts.help) {
16
16
  process.stdout.write(guildCliHelp());
17
17
  } else if (opts.version) {
18
- const pkg = JSON.parse(
19
- readFileSync(new URL("../package.json", import.meta.url), "utf8"),
20
- ) as { version: string };
21
- process.stdout.write(`${pkg.version}\n`);
18
+ process.stdout.write(`${guildVersion()}\n`);
22
19
  } else {
23
20
  if (opts.port !== undefined) process.env.GUILD_PORT = String(opts.port);
24
21
  const started = startGuildDaemon();
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,17 @@ 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
+ /** Bump when this guildd writes a shape an older guildd cannot read. */
29
+ export const SCHEMA_VERSION = "2";
30
+
31
+ /** Numeric view of a `meta.schema` value. Missing/garbage = 0 (migrate). */
32
+ function schemaVersionOf(raw: unknown): number {
33
+ const value = Number(typeof raw === "string" ? raw.trim() : raw);
34
+ return Number.isFinite(value) ? value : 0;
35
+ }
36
+ const WAREHOUSE_TAIL = 1024 * 1024;
21
37
 
22
38
  const SCHEMA = `
23
39
  PRAGMA journal_mode = WAL;
@@ -34,7 +50,9 @@ CREATE TABLE IF NOT EXISTS rooms (
34
50
  kind TEXT NOT NULL CHECK (kind IN ('channel', 'dm')),
35
51
  name TEXT NOT NULL,
36
52
  member_ids TEXT NOT NULL DEFAULT '[]',
37
- created_at TEXT NOT NULL
53
+ created_at TEXT NOT NULL,
54
+ parent_id TEXT,
55
+ branch_from_id TEXT
38
56
  );
39
57
 
40
58
  CREATE TABLE IF NOT EXISTS messages (
@@ -51,6 +69,7 @@ CREATE TABLE IF NOT EXISTS messages (
51
69
  finished_at TEXT,
52
70
  steer INTEGER NOT NULL DEFAULT 0,
53
71
  steer_bot_id TEXT,
72
+ mentions TEXT,
54
73
  UNIQUE (room_id, seq)
55
74
  );
56
75
 
@@ -138,7 +157,11 @@ function parseJsonlTrajectory(raw: string): TrajectoryEvent[] {
138
157
  if (!trimmed) continue;
139
158
  try {
140
159
  const parsed = JSON.parse(trimmed) as TrajectoryEvent;
141
- if (parsed && typeof parsed.kind === "string") out.push(parsed);
160
+ if (!parsed || typeof parsed.kind !== "string") continue;
161
+ if (typeof parsed.seq !== "number" || !Number.isFinite(parsed.seq)) {
162
+ parsed.seq = out.length;
163
+ }
164
+ out.push(parsed);
142
165
  } catch {
143
166
  /* skip */
144
167
  }
@@ -146,6 +169,51 @@ function parseJsonlTrajectory(raw: string): TrajectoryEvent[] {
146
169
  return out;
147
170
  }
148
171
 
172
+ function lastJsonlSeq(path: string): number {
173
+ if (!existsSync(path)) return -1;
174
+ const stat = statSync(path);
175
+ if (stat.size === 0) return -1;
176
+ const fd = openSync(path, "r");
177
+ try {
178
+ const size = Math.min(stat.size, WAREHOUSE_TAIL);
179
+ const buf = Buffer.alloc(size);
180
+ readSync(fd, buf, 0, size, stat.size - size);
181
+ const lines = buf.toString("utf8").split("\n");
182
+ const start = stat.size > size ? 1 : 0;
183
+ for (let i = lines.length - 1; i >= start; i--) {
184
+ const trimmed = lines[i]!.trim();
185
+ if (!trimmed) continue;
186
+ try {
187
+ const parsed = JSON.parse(trimmed) as { seq?: unknown };
188
+ if (typeof parsed.seq === "number" && Number.isFinite(parsed.seq)) {
189
+ return parsed.seq;
190
+ }
191
+ } catch {
192
+ /* skip */
193
+ }
194
+ }
195
+ return -1;
196
+ } finally {
197
+ closeSync(fd);
198
+ }
199
+ }
200
+
201
+ function trajectoryValues(roomId: string, event: TrajectoryEvent) {
202
+ return [
203
+ roomId,
204
+ event.seq,
205
+ event.ts,
206
+ event.turnId,
207
+ event.botId ?? null,
208
+ event.kind,
209
+ event.summary,
210
+ event.payload === undefined ? null : JSON.stringify(event.payload),
211
+ event.result ?? null,
212
+ event.durationMs ?? null,
213
+ event.isError ? 1 : 0,
214
+ ] as const;
215
+ }
216
+
149
217
  function messageFromRow(row: Record<string, unknown>): ChatMessage {
150
218
  const parts = parseJson<ChatPart[] | null>(row.parts, null);
151
219
  const attachments = parseJson<ChatAttachment[] | null>(row.attachments, null);
@@ -167,12 +235,18 @@ function messageFromRow(row: Record<string, unknown>): ChatMessage {
167
235
  if (asNumber(row.steer) === 1) message.steer = true;
168
236
  const steerBotId = asString(row.steer_bot_id);
169
237
  if (steerBotId) message.steerBotId = steerBotId;
238
+ if (typeof row.mentions === "string" && row.mentions) {
239
+ const mentions = parseJson<string[]>(row.mentions, []);
240
+ if (Array.isArray(mentions)) {
241
+ message.mentions = mentions.filter((id) => typeof id === "string");
242
+ }
243
+ }
170
244
  return message;
171
245
  }
172
246
 
173
247
  function roomFromRow(row: Record<string, unknown>): Room {
174
248
  const memberIds = parseJson<string[]>(row.member_ids, []);
175
- return {
249
+ const room: Room = {
176
250
  id: asString(row.id),
177
251
  kind: asString(row.kind) === "dm" ? "dm" : "channel",
178
252
  name: asString(row.name),
@@ -181,6 +255,11 @@ function roomFromRow(row: Record<string, unknown>): Room {
181
255
  : [],
182
256
  createdAt: asString(row.created_at),
183
257
  };
258
+ const parentId = asString(row.parent_id);
259
+ if (parentId) room.parentId = parentId;
260
+ const branchFromId = asString(row.branch_from_id);
261
+ if (branchFromId) room.branchFromId = branchFromId;
262
+ return room;
184
263
  }
185
264
 
186
265
  function trajectoryFromRow(row: Record<string, unknown>): TrajectoryEvent {
@@ -209,40 +288,85 @@ export class GuildDb {
209
288
  constructor(readonly path: string) {
210
289
  mkdirSync(dirname(path), { recursive: true });
211
290
  this.sqlite = new DatabaseSync(path, { timeout: 5000 });
212
- this.sqlite.exec(SCHEMA);
291
+ try {
292
+ this.sqlite.exec(SCHEMA);
293
+ // Refuse to open a DB written by a newer guildd: the ALTERs below and the
294
+ // version upsert would silently migrate the file back down.
295
+ const row = this.sqlite
296
+ .prepare("SELECT value FROM meta WHERE key = 'schema'")
297
+ .get() as { value?: unknown } | undefined;
298
+ const stored = schemaVersionOf(row?.value);
299
+ const current = schemaVersionOf(SCHEMA_VERSION);
300
+ if (stored > current) {
301
+ throw new Error(
302
+ `guild.sqlite schema ${stored} is newer than this guildd (${current}); upgrade guildd`,
303
+ );
304
+ }
305
+ } catch (error) {
306
+ this.close();
307
+ throw error;
308
+ }
213
309
  try {
214
310
  this.sqlite.exec("ALTER TABLE messages ADD COLUMN steer_bot_id TEXT");
215
311
  } catch {
216
312
  /* column already exists on fresh schema */
217
313
  }
218
- this.sqlite.prepare(
219
- "INSERT OR IGNORE INTO meta (key, value) VALUES ('schema', ?)",
220
- ).run(SCHEMA_VERSION);
314
+ try {
315
+ this.sqlite.exec("ALTER TABLE messages ADD COLUMN mentions TEXT");
316
+ } catch {
317
+ /* column already exists on fresh schema */
318
+ }
319
+ try {
320
+ this.sqlite.exec("ALTER TABLE rooms ADD COLUMN parent_id TEXT");
321
+ } catch {
322
+ /* column already exists on fresh schema */
323
+ }
324
+ try {
325
+ this.sqlite.exec("ALTER TABLE rooms ADD COLUMN branch_from_id TEXT");
326
+ } catch {
327
+ /* column already exists on fresh schema */
328
+ }
329
+ try {
330
+ this.sqlite.prepare(
331
+ "INSERT INTO meta (key, value) VALUES ('schema', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
332
+ ).run(SCHEMA_VERSION);
333
+ } catch (error) {
334
+ this.close();
335
+ throw error;
336
+ }
221
337
  }
222
338
 
223
339
  close(): void {
224
- this.sqlite.close();
340
+ try {
341
+ this.sqlite.close();
342
+ } catch {
343
+ /* already closed */
344
+ }
225
345
  }
226
346
 
227
347
  importLegacyFiles(dataDir: string): void {
228
348
  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);
349
+ if (existsSync(root)) {
350
+ const ids = readdirSync(root, { withFileTypes: true })
351
+ .filter((entry) => entry.isDirectory())
352
+ .map((entry) => entry.name);
353
+ for (const id of ids) this.importRoomDir(join(root, id), id);
354
+ }
355
+ for (const room of this.listRooms()) this.spillColdTrajectory(room.id);
234
356
  }
235
357
 
236
358
  upsertRoom(room: Room): void {
237
359
  this.sqlite
238
360
  .prepare(
239
- `INSERT INTO rooms (id, kind, name, member_ids, created_at)
240
- VALUES (?, ?, ?, ?, ?)
361
+ `INSERT INTO rooms (id, kind, name, member_ids, created_at, parent_id, branch_from_id)
362
+ VALUES (?, ?, ?, ?, ?, ?, ?)
241
363
  ON CONFLICT(id) DO UPDATE SET
242
364
  kind = excluded.kind,
243
365
  name = excluded.name,
244
366
  member_ids = excluded.member_ids,
245
- created_at = excluded.created_at`,
367
+ created_at = excluded.created_at,
368
+ parent_id = excluded.parent_id,
369
+ branch_from_id = excluded.branch_from_id`,
246
370
  )
247
371
  .run(
248
372
  room.id,
@@ -250,6 +374,8 @@ export class GuildDb {
250
374
  room.name,
251
375
  JSON.stringify(room.memberIds),
252
376
  room.createdAt,
377
+ room.parentId ?? null,
378
+ room.branchFromId ?? null,
253
379
  );
254
380
  }
255
381
 
@@ -304,10 +430,22 @@ export class GuildDb {
304
430
  }
305
431
  }
306
432
 
307
- updateMessageBody(roomId: string, messageId: string, body: string): ChatMessage | null {
433
+ updateMessageBody(
434
+ roomId: string,
435
+ messageId: string,
436
+ body: string,
437
+ mentions?: string[],
438
+ ): ChatMessage | null {
308
439
  const result = this.sqlite
309
- .prepare("UPDATE messages SET body = ? WHERE room_id = ? AND id = ?")
310
- .run(body, roomId, messageId);
440
+ .prepare(
441
+ "UPDATE messages SET body = ?, mentions = ? WHERE room_id = ? AND id = ?",
442
+ )
443
+ .run(
444
+ body,
445
+ mentions ? JSON.stringify(mentions) : null,
446
+ roomId,
447
+ messageId,
448
+ );
311
449
  if (!result.changes) return null;
312
450
  return this.getMessage(roomId, messageId);
313
451
  }
@@ -321,12 +459,13 @@ export class GuildDb {
321
459
  usage?: ChatUsage;
322
460
  createdAt: string;
323
461
  finishedAt: string;
462
+ mentions?: string[];
324
463
  },
325
464
  ): ChatMessage | null {
326
465
  const result = this.sqlite
327
466
  .prepare(
328
467
  `UPDATE messages
329
- SET body = ?, parts = ?, usage = ?, created_at = ?, finished_at = ?
468
+ SET body = ?, parts = ?, usage = ?, created_at = ?, finished_at = ?, mentions = ?
330
469
  WHERE room_id = ? AND id = ?`,
331
470
  )
332
471
  .run(
@@ -335,6 +474,7 @@ export class GuildDb {
335
474
  patch.usage ? JSON.stringify(patch.usage) : null,
336
475
  patch.createdAt,
337
476
  patch.finishedAt,
477
+ patch.mentions ? JSON.stringify(patch.mentions) : null,
338
478
  roomId,
339
479
  messageId,
340
480
  );
@@ -348,6 +488,7 @@ export class GuildDb {
348
488
  this.sqlite
349
489
  .prepare("DELETE FROM messages WHERE room_id = ? AND id = ?")
350
490
  .run(roomId, messageId);
491
+ /* Hot window only. Warehouse jsonl stays append-only. */
351
492
  this.sqlite
352
493
  .prepare("DELETE FROM trajectory WHERE room_id = ? AND turn_id = ?")
353
494
  .run(roomId, messageId);
@@ -385,19 +526,7 @@ export class GuildDb {
385
526
  for (const draft of drafts) {
386
527
  seq += 1;
387
528
  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
- );
529
+ insert.run(...trajectoryValues(roomId, event));
401
530
  written.push(event);
402
531
  }
403
532
  this.sqlite.exec("COMMIT");
@@ -462,7 +591,39 @@ export class GuildDb {
462
591
  const row = this.sqlite
463
592
  .prepare("SELECT COALESCE(MAX(seq), -1) AS seq FROM trajectory WHERE room_id = ?")
464
593
  .get(roomId) as { seq?: number } | undefined;
465
- return asNumber(row?.seq, -1);
594
+ return Math.max(asNumber(row?.seq, -1), lastJsonlSeq(this.warehousePath(roomId)));
595
+ }
596
+
597
+ private warehousePath(roomId: string): string {
598
+ return join(dirname(this.path), "rooms", roomId, "trajectory.jsonl");
599
+ }
600
+
601
+ spillColdTrajectory(roomId: string): void {
602
+ const extra = this.trajectoryCount(roomId) - TRAJECTORY_HOT_CAP;
603
+ if (extra <= 0) return;
604
+ const rows = this.sqlite
605
+ .prepare(
606
+ "SELECT * FROM trajectory WHERE room_id = ? ORDER BY seq ASC LIMIT ?",
607
+ )
608
+ .all(roomId, extra) as Record<string, unknown>[];
609
+ if (!rows.length) return;
610
+ const events = rows.map(trajectoryFromRow);
611
+ const path = this.warehousePath(roomId);
612
+ const lastArchived = lastJsonlSeq(path);
613
+ const fresh = events.filter((event) => event.seq > lastArchived);
614
+ if (fresh.length) {
615
+ mkdirSync(dirname(path), { recursive: true });
616
+ appendFileSync(
617
+ path,
618
+ fresh.map((event) => `${JSON.stringify(event)}\n`).join(""),
619
+ "utf8",
620
+ );
621
+ }
622
+ const lastSeq = events[events.length - 1]?.seq;
623
+ if (typeof lastSeq !== "number") return;
624
+ this.sqlite
625
+ .prepare("DELETE FROM trajectory WHERE room_id = ? AND seq <= ?")
626
+ .run(roomId, lastSeq);
466
627
  }
467
628
 
468
629
  private insertMessage(message: ChatMessage, seq: number): void {
@@ -470,8 +631,8 @@ export class GuildDb {
470
631
  .prepare(
471
632
  `INSERT INTO messages (
472
633
  id, room_id, seq, author, body, parts, reply_to, attachments, usage,
473
- created_at, finished_at, steer, steer_bot_id
474
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
634
+ created_at, finished_at, steer, steer_bot_id, mentions
635
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
475
636
  )
476
637
  .run(
477
638
  message.id,
@@ -487,6 +648,7 @@ export class GuildDb {
487
648
  message.finishedAt ?? null,
488
649
  message.steer ? 1 : 0,
489
650
  message.steerBotId ?? null,
651
+ message.mentions ? JSON.stringify(message.mentions) : null,
490
652
  );
491
653
  }
492
654
 
@@ -507,6 +669,12 @@ export class GuildDb {
507
669
  typeof parsed.createdAt === "string"
508
670
  ? parsed.createdAt
509
671
  : "2026-01-01T00:00:00.000Z",
672
+ ...(typeof parsed.parentId === "string" && parsed.parentId
673
+ ? { parentId: parsed.parentId }
674
+ : {}),
675
+ ...(typeof parsed.branchFromId === "string" && parsed.branchFromId
676
+ ? { branchFromId: parsed.branchFromId }
677
+ : {}),
510
678
  });
511
679
  }
512
680
  } catch {
@@ -536,34 +704,20 @@ export class GuildDb {
536
704
  const path = join(dir, "trajectory.jsonl");
537
705
  if (existsSync(path)) {
538
706
  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
- }
707
+ if (events.length <= TRAJECTORY_HOT_CAP) {
708
+ if (events.length) this.insertTrajectoryRows(id, events);
709
+ rmIfExists(path);
710
+ } else {
711
+ const cut = events.length - TRAJECTORY_HOT_CAP;
712
+ this.insertTrajectoryRows(id, events.slice(cut));
713
+ writeFileSync(
714
+ path,
715
+ events
716
+ .slice(0, cut)
717
+ .map((event) => `${JSON.stringify(event)}\n`)
718
+ .join(""),
719
+ "utf8",
720
+ );
567
721
  }
568
722
  }
569
723
  }
@@ -597,12 +751,26 @@ export class GuildDb {
597
751
  rmIfExists(join(dir, "messages.json"));
598
752
  rmIfExists(join(dir, "messages.jsonl"));
599
753
  }
600
- if (this.trajectoryCount(id) > 0) {
601
- rmIfExists(join(dir, "trajectory.jsonl"));
602
- }
603
754
  if (this.readCompact(id)) rmIfExists(join(dir, "compact.json"));
604
755
  }
605
756
 
757
+ private insertTrajectoryRows(roomId: string, events: TrajectoryEvent[]): void {
758
+ if (!events.length) return;
759
+ this.sqlite.exec("BEGIN");
760
+ try {
761
+ const insert = this.sqlite.prepare(
762
+ `INSERT INTO trajectory (
763
+ room_id, seq, ts, turn_id, bot_id, kind, summary, payload, result, duration_ms, is_error
764
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
765
+ );
766
+ for (const event of events) insert.run(...trajectoryValues(roomId, event));
767
+ this.sqlite.exec("COMMIT");
768
+ } catch (error) {
769
+ this.sqlite.exec("ROLLBACK");
770
+ throw error;
771
+ }
772
+ }
773
+
606
774
  private messageCount(roomId: string): number {
607
775
  const row = this.sqlite
608
776
  .prepare("SELECT COUNT(*) AS n FROM messages WHERE room_id = ?")