@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/store.ts ADDED
@@ -0,0 +1,1208 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import type { TrajectoryDraft, TrajectoryEvent } from "./trajectory.ts";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { randomUUID } from "node:crypto";
13
+ import { closeGuildDb, openGuildDb, type GuildDb } from "./db.ts";
14
+ import type {
15
+ Bot,
16
+ ChatAttachment,
17
+ ChatMessage,
18
+ ChatPart,
19
+ ChatUsage,
20
+ LibraryItem,
21
+ LibraryKind,
22
+ ModelRef,
23
+ Room,
24
+ } from "@guild/protocol";
25
+ import { DEFAULT_BOTS } from "./catalog/default-bots.ts";
26
+ import { CATALOG_SKILLS } from "./catalog/skills.ts";
27
+ import { CATALOG_SUBAGENTS } from "./catalog/subagents.ts";
28
+ import { parseAgentFile } from "./agent-file.ts";
29
+
30
+ const MARKDOWN: Record<LibraryKind, string> = {
31
+ souls: "SOUL.md",
32
+ agents: "AGENTS.md",
33
+ skills: "SKILL.md",
34
+ positions: "POSITION.md",
35
+ subagents: "SUBAGENT.toml",
36
+ };
37
+
38
+ const GENERAL_CHANNEL_ID = "channel-general";
39
+ const NAV_PREVIEW_CAP = 120;
40
+ /** Project channels (not #general). Reuse seats first; human adds specialists. */
41
+ export const CHANNEL_ROSTER_CAP = 6;
42
+
43
+ export function isGeneralChannel(room: { id: string; name: string }): boolean {
44
+ return room.id === GENERAL_CHANNEL_ID || room.name === "general";
45
+ }
46
+
47
+ export function clipNavPreview(body: string): string {
48
+ return String(body || "")
49
+ .replace(/\s+/g, " ")
50
+ .trim()
51
+ .slice(0, NAV_PREVIEW_CAP);
52
+ }
53
+
54
+ /** Transport / login failures that should not stay in the thread once a new turn starts. */
55
+ export function isFailedAssistantReply(body: string): boolean {
56
+ const text = String(body || "").trim();
57
+ if (!text) return false;
58
+ if (
59
+ /^(connection error\.?|failed to fetch|load failed|networkerror\b.*)$/i.test(
60
+ text,
61
+ )
62
+ ) {
63
+ return true;
64
+ }
65
+ return /登入已失效|模型請求失敗|模型請求逾時|不是訂閱失效|這個 GitHub Copilot 帳號不支援|unauthorized|not logged in|econnrefused|econnreset|login failed/i.test(
66
+ text.slice(0, 400),
67
+ );
68
+ }
69
+
70
+ export function defaultDataDir(env: NodeJS.ProcessEnv = process.env): string {
71
+ return env.GUILD_HOME ?? join(homedir(), ".guild");
72
+ }
73
+
74
+ export type LiveStep = {
75
+ name: string;
76
+ detail: string;
77
+ running?: boolean;
78
+ };
79
+
80
+ export type LiveTrace = {
81
+ name: string;
82
+ args?: Record<string, unknown>;
83
+ text?: string;
84
+ isError?: boolean;
85
+ running?: boolean;
86
+ };
87
+
88
+ export type LiveTurn = {
89
+ botId: string;
90
+ thinking: string;
91
+ steps: LiveStep[];
92
+ startedAt?: string;
93
+ /** Full-ish tool history for Trajectory. Stripped from GET /live. */
94
+ traces?: LiveTrace[];
95
+ };
96
+
97
+ export class GuildStore {
98
+ private readonly liveTurns = new Map<string, Map<string, LiveTurn>>();
99
+ private readonly pendingSteers = new Map<string, Map<string, string[]>>();
100
+ private readonly botAborts = new Map<string, Map<string, AbortController>>();
101
+ private readonly turnGroups = new Map<
102
+ AbortSignal,
103
+ { roomId: string; botIds: Set<string>; controller: AbortController }
104
+ >();
105
+ private readonly db: GuildDb;
106
+ private closed = false;
107
+
108
+ constructor(readonly dataDir: string) {
109
+ mkdirSync(join(dataDir, "library", "souls"), { recursive: true });
110
+ mkdirSync(join(dataDir, "library", "agents"), { recursive: true });
111
+ mkdirSync(join(dataDir, "library", "skills"), { recursive: true });
112
+ mkdirSync(join(dataDir, "library", "positions"), { recursive: true });
113
+ mkdirSync(join(dataDir, "library", "subagents"), { recursive: true });
114
+ mkdirSync(join(dataDir, "bots"), { recursive: true });
115
+ mkdirSync(join(dataDir, "rooms"), { recursive: true });
116
+ this.db = openGuildDb(dataDir);
117
+ this.db.importLegacyFiles(dataDir);
118
+ this.seedCatalog();
119
+ this.seedDefaultBots();
120
+ this.ensureGeneralChannel();
121
+ }
122
+
123
+ close(): void {
124
+ if (this.closed) return;
125
+ this.closed = true;
126
+ closeGuildDb(this.db);
127
+ }
128
+
129
+ private seedCatalog(): void {
130
+ for (const skill of CATALOG_SKILLS) {
131
+ const item: LibraryItem = {
132
+ id: `catalog-${skill.slug}`,
133
+ slug: skill.slug,
134
+ name: skill.name,
135
+ body: skill.body,
136
+ description: skill.description,
137
+ tags: skill.tags,
138
+ source: "catalog",
139
+ featured: skill.featured,
140
+ createdAt: "2026-01-01T00:00:00.000Z",
141
+ };
142
+ this.writeLibraryItem("skills", item);
143
+ }
144
+ for (const agent of CATALOG_SUBAGENTS) {
145
+ const item: LibraryItem = {
146
+ id: `catalog-subagent-${agent.slug}`,
147
+ slug: agent.slug,
148
+ name: agent.name,
149
+ body: agent.body,
150
+ description: agent.description,
151
+ tags: agent.tags,
152
+ source: "catalog",
153
+ featured: agent.featured,
154
+ createdAt: "2026-01-01T00:00:00.000Z",
155
+ };
156
+ this.writeLibraryItem("subagents", item);
157
+ }
158
+ }
159
+
160
+ private seedDefaultBots(): void {
161
+ const skillBySlug = new Map(
162
+ this.listLibrary("skills").map((item) => [item.slug, item.id]),
163
+ );
164
+ const existingByHandle = new Map(
165
+ this.listBots().map((bot) => [bot.handle, bot]),
166
+ );
167
+ const retired = this.readRetired();
168
+ for (const seed of DEFAULT_BOTS) {
169
+ if (retired.has(seed.handle)) continue;
170
+ const existing = existingByHandle.get(seed.handle);
171
+ if (existing) {
172
+ if (!existing.oneLiner) {
173
+ this.writeBot({ ...existing, oneLiner: seed.oneLiner });
174
+ }
175
+ continue;
176
+ }
177
+ const soulId = `soul-${seed.handle}`;
178
+ const agentId = `agent-${seed.handle}`;
179
+ const positionId = `position-${seed.handle}`;
180
+ if (!this.getLibrary("souls", soulId)) {
181
+ this.writeLibraryItem("souls", {
182
+ id: soulId,
183
+ slug: `soul-${seed.handle}`,
184
+ name: `${seed.name} · Soul`,
185
+ body: seed.soul,
186
+ source: "catalog",
187
+ createdAt: "2026-01-01T00:00:00.000Z",
188
+ });
189
+ }
190
+ if (!this.getLibrary("agents", agentId)) {
191
+ this.writeLibraryItem("agents", {
192
+ id: agentId,
193
+ slug: `agent-${seed.handle}`,
194
+ name: `${seed.name} · Agent`,
195
+ body: seed.agent,
196
+ source: "catalog",
197
+ createdAt: "2026-01-01T00:00:00.000Z",
198
+ });
199
+ }
200
+ if (!this.getLibrary("positions", positionId)) {
201
+ this.writeLibraryItem("positions", {
202
+ id: positionId,
203
+ slug: `position-${seed.handle}`,
204
+ name: seed.name,
205
+ body: seed.position,
206
+ source: "catalog",
207
+ createdAt: "2026-01-01T00:00:00.000Z",
208
+ });
209
+ }
210
+ const skillIds = seed.skillSlugs.map((slug) => {
211
+ const id = skillBySlug.get(slug);
212
+ if (!id) throw new Error(`missing catalog skill: ${slug}`);
213
+ return id;
214
+ });
215
+ const bot: Bot = {
216
+ id: `bot-${seed.handle}`,
217
+ handle: seed.handle,
218
+ name: seed.name,
219
+ status: "bench",
220
+ soulId,
221
+ agentTemplateId: agentId,
222
+ skillIds,
223
+ defaultPositionId: positionId,
224
+ oneLiner: seed.oneLiner,
225
+ createdAt: "2026-01-01T00:00:00.000Z",
226
+ };
227
+ const dir = join(this.dataDir, "bots", bot.id);
228
+ mkdirSync(dir, { recursive: true });
229
+ writeFileSync(join(dir, "bot.json"), `${JSON.stringify(bot, null, 2)}\n`);
230
+ writeFileSync(
231
+ join(dir, "IDENTITY.md"),
232
+ `# ${bot.name}\n\nhandle: @${bot.handle}\n\n${seed.oneLiner}\n`,
233
+ );
234
+ }
235
+ }
236
+
237
+ listLibrary(kind: LibraryKind): LibraryItem[] {
238
+ const root = join(this.dataDir, "library", kind);
239
+ const ids = readdirSync(root, { withFileTypes: true })
240
+ .filter((entry) => entry.isDirectory())
241
+ .map((entry) => entry.name);
242
+ return ids
243
+ .map((id) => this.readLibraryItem(kind, id))
244
+ .filter((item): item is LibraryItem => item !== null)
245
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
246
+ }
247
+
248
+ getLibrary(kind: LibraryKind, id: string): LibraryItem | null {
249
+ return this.readLibraryItem(kind, id);
250
+ }
251
+
252
+ createLibrary(
253
+ kind: LibraryKind,
254
+ input: {
255
+ name: string;
256
+ body?: string;
257
+ slug?: string;
258
+ description?: string;
259
+ tags?: string[];
260
+ },
261
+ ): LibraryItem {
262
+ const name = input.name.trim();
263
+ if (!name) {
264
+ throw new StoreError(400, "name is required");
265
+ }
266
+ const id = randomUUID();
267
+ const slug = uniqueSlug(
268
+ input.slug?.trim() || slugify(name),
269
+ this.listLibrary(kind).map((item) => item.slug),
270
+ );
271
+ let description = input.description;
272
+ let displayName = name;
273
+ const body = input.body ?? "";
274
+ if (kind === "subagents" && !body.trim()) {
275
+ throw new StoreError(400, "subagent TOML is required");
276
+ }
277
+ if (kind === "subagents" && body.trim()) {
278
+ const parsed = parseAgentFile(body, slug);
279
+ if (!description) description = parsed.description;
280
+ if (parsed.name) displayName = parsed.name;
281
+ }
282
+ const item: LibraryItem = {
283
+ id,
284
+ slug,
285
+ name: displayName,
286
+ body,
287
+ description,
288
+ tags: input.tags,
289
+ source: "user",
290
+ createdAt: new Date().toISOString(),
291
+ };
292
+ this.writeLibraryItem(kind, item);
293
+ return item;
294
+ }
295
+
296
+ setLiveTurn(roomId: string, turn: LiveTurn): void {
297
+ let room = this.liveTurns.get(roomId);
298
+ if (!room) {
299
+ room = new Map();
300
+ this.liveTurns.set(roomId, room);
301
+ }
302
+ room.set(turn.botId || "", turn);
303
+ }
304
+
305
+ clearLiveTurn(roomId: string): void {
306
+ this.liveTurns.delete(roomId);
307
+ this.pendingSteers.delete(roomId);
308
+ }
309
+
310
+ dropLiveBotTurn(roomId: string, botId: string): void {
311
+ const live = this.liveTurns.get(roomId);
312
+ if (!live) return;
313
+ live.delete(botId);
314
+ if (live.size === 0) this.liveTurns.delete(roomId);
315
+ const steers = this.pendingSteers.get(roomId);
316
+ steers?.delete(botId);
317
+ if (steers && steers.size === 0) this.pendingSteers.delete(roomId);
318
+ }
319
+
320
+ getLiveTurn(roomId: string): LiveTurn | null {
321
+ const room = this.liveTurns.get(roomId);
322
+ if (!room || room.size === 0) return null;
323
+ return [...room.values()][room.size - 1] ?? null;
324
+ }
325
+
326
+ getLiveBotTurn(roomId: string, botId: string): LiveTurn | null {
327
+ return this.liveTurns.get(roomId)?.get(botId) ?? null;
328
+ }
329
+
330
+ listLiveRoomTurns(roomId: string): LiveTurn[] {
331
+ return [...(this.liveTurns.get(roomId)?.values() ?? [])];
332
+ }
333
+
334
+ listLiveTurns(): { roomId: string; turn: LiveTurn }[] {
335
+ const out: { roomId: string; turn: LiveTurn }[] = [];
336
+ for (const [roomId, room] of this.liveTurns) {
337
+ for (const turn of room.values()) out.push({ roomId, turn });
338
+ }
339
+ return out;
340
+ }
341
+
342
+ private bindBotAbort(
343
+ roomId: string,
344
+ botId: string,
345
+ controller: AbortController,
346
+ ): void {
347
+ let room = this.botAborts.get(roomId);
348
+ if (!room) {
349
+ room = new Map();
350
+ this.botAborts.set(roomId, room);
351
+ }
352
+ const prev = room.get(botId);
353
+ if (prev && prev !== controller && !prev.signal.aborted) prev.abort();
354
+ room.set(botId, controller);
355
+ }
356
+
357
+ beginTurn(roomId: string, botIds: string[] = [""]): AbortSignal {
358
+ const controller = new AbortController();
359
+ const ids = botIds.length ? botIds : [""];
360
+ for (const botId of ids) this.bindBotAbort(roomId, botId, controller);
361
+ this.turnGroups.set(controller.signal, {
362
+ roomId,
363
+ botIds: new Set(ids),
364
+ controller,
365
+ });
366
+ return controller.signal;
367
+ }
368
+
369
+ adoptTurn(roomId: string, botId: string, signal: AbortSignal): void {
370
+ const group = this.turnGroups.get(signal);
371
+ if (!group || group.roomId !== roomId) return;
372
+ this.bindBotAbort(roomId, botId, group.controller);
373
+ group.botIds.add(botId);
374
+ }
375
+
376
+ abortTurn(roomId: string, botId?: string): boolean {
377
+ if (botId) {
378
+ const room = this.botAborts.get(roomId);
379
+ const controller = room?.get(botId);
380
+ if (!controller) {
381
+ const live = this.liveTurns.get(roomId);
382
+ const steers = this.pendingSteers.get(roomId);
383
+ const hadLive = Boolean(live?.delete(botId));
384
+ steers?.delete(botId);
385
+ if (live && live.size === 0) this.liveTurns.delete(roomId);
386
+ if (steers && steers.size === 0) this.pendingSteers.delete(roomId);
387
+ return hadLive;
388
+ }
389
+ const group = this.turnGroups.get(controller.signal);
390
+ const ids = group ? [...group.botIds] : [botId];
391
+ this.turnGroups.delete(controller.signal);
392
+ const live = this.liveTurns.get(roomId);
393
+ const steers = this.pendingSteers.get(roomId);
394
+ for (const id of ids) {
395
+ room?.delete(id);
396
+ live?.delete(id);
397
+ steers?.delete(id);
398
+ }
399
+ if (room && room.size === 0) this.botAborts.delete(roomId);
400
+ if (live && live.size === 0) this.liveTurns.delete(roomId);
401
+ if (steers && steers.size === 0) this.pendingSteers.delete(roomId);
402
+ if (!controller.signal.aborted) controller.abort();
403
+ return true;
404
+ }
405
+ const room = this.botAborts.get(roomId);
406
+ this.botAborts.delete(roomId);
407
+ this.liveTurns.delete(roomId);
408
+ this.pendingSteers.delete(roomId);
409
+ let aborted = false;
410
+ const seen = new Set<AbortController>();
411
+ for (const controller of room?.values() ?? []) {
412
+ if (seen.has(controller)) continue;
413
+ seen.add(controller);
414
+ this.turnGroups.delete(controller.signal);
415
+ if (!controller.signal.aborted) {
416
+ controller.abort();
417
+ aborted = true;
418
+ }
419
+ }
420
+ return aborted || Boolean(room);
421
+ }
422
+
423
+ endTurn(roomId: string, signal?: AbortSignal): void {
424
+ const group = signal ? this.turnGroups.get(signal) : undefined;
425
+ if (group) {
426
+ this.turnGroups.delete(signal);
427
+ const room = this.botAborts.get(roomId);
428
+ const live = this.liveTurns.get(roomId);
429
+ const steers = this.pendingSteers.get(roomId);
430
+ for (const botId of group.botIds) {
431
+ if (room?.get(botId) === group.controller) room.delete(botId);
432
+ live?.delete(botId);
433
+ steers?.delete(botId);
434
+ }
435
+ if (room && room.size === 0) this.botAborts.delete(roomId);
436
+ if (live && live.size === 0) this.liveTurns.delete(roomId);
437
+ if (steers && steers.size === 0) this.pendingSteers.delete(roomId);
438
+ return;
439
+ }
440
+ this.botAborts.delete(roomId);
441
+ this.clearLiveTurn(roomId);
442
+ }
443
+
444
+ pushSteer(roomId: string, text: string, botId?: string): void {
445
+ const body = text.trim();
446
+ if (!body) return;
447
+ let room = this.pendingSteers.get(roomId);
448
+ if (!room) {
449
+ room = new Map();
450
+ this.pendingSteers.set(roomId, room);
451
+ }
452
+ const ids = botId
453
+ ? [botId]
454
+ : this.listLiveRoomTurns(roomId).map((turn) => turn.botId).filter(Boolean);
455
+ const targets = ids.length ? ids : [""];
456
+ for (const id of targets) {
457
+ const list = room.get(id) ?? [];
458
+ list.push(body);
459
+ room.set(id, list);
460
+ }
461
+ }
462
+
463
+ drainSteers(roomId: string, botId?: string): string[] {
464
+ const room = this.pendingSteers.get(roomId);
465
+ if (!room) return [];
466
+ if (botId !== undefined) {
467
+ const list = room.get(botId) ?? (botId ? room.get("") ?? [] : []);
468
+ room.delete(botId);
469
+ if (botId) room.delete("");
470
+ if (room.size === 0) this.pendingSteers.delete(roomId);
471
+ return list;
472
+ }
473
+ const all = [...room.values()].flat();
474
+ this.pendingSteers.delete(roomId);
475
+ return all;
476
+ }
477
+
478
+ peekSteers(roomId: string, botId?: string): string[] {
479
+ const room = this.pendingSteers.get(roomId);
480
+ if (!room) return [];
481
+ if (botId !== undefined) return room.get(botId) ?? [];
482
+ return [...room.values()].flat();
483
+ }
484
+
485
+ listBots(): Bot[] {
486
+ const root = join(this.dataDir, "bots");
487
+ const ids = readdirSync(root, { withFileTypes: true })
488
+ .filter((entry) => entry.isDirectory())
489
+ .map((entry) => entry.name);
490
+ return ids
491
+ .map((id) => this.readBot(id))
492
+ .filter((bot): bot is Bot => bot !== null)
493
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
494
+ }
495
+
496
+ createBot(input: {
497
+ name: string;
498
+ handle: string;
499
+ oneLiner?: string;
500
+ soulId?: string;
501
+ agentTemplateId?: string;
502
+ defaultPositionId?: string;
503
+ soul?: { name: string; body: string };
504
+ agent?: { name: string; body: string };
505
+ position?: { name: string; body: string };
506
+ skillIds: string[];
507
+ model?: ModelRef | null;
508
+ }): Bot {
509
+ const name = input.name.trim();
510
+ const handle = input.handle.trim().replace(/^@/, "");
511
+ if (!name) throw new StoreError(400, "name is required");
512
+ if (!handle) throw new StoreError(400, "handle is required");
513
+ if (this.listBots().some((bot) => bot.handle === handle)) {
514
+ throw new StoreError(409, `handle already taken: ${handle}`);
515
+ }
516
+
517
+ const soulId = this.resolveMarkdownRef("souls", input.soulId, input.soul);
518
+ const agentTemplateId = this.resolveMarkdownRef(
519
+ "agents",
520
+ input.agentTemplateId,
521
+ input.agent,
522
+ );
523
+ const defaultPositionId = this.resolveMarkdownRef(
524
+ "positions",
525
+ input.defaultPositionId,
526
+ input.position,
527
+ );
528
+
529
+ if (input.skillIds.length === 0) {
530
+ throw new StoreError(400, "at least one skill is required");
531
+ }
532
+ for (const skillId of input.skillIds) {
533
+ if (!this.getLibrary("skills", skillId)) {
534
+ throw new StoreError(400, `skillId does not exist: ${skillId}`);
535
+ }
536
+ }
537
+ const id = randomUUID();
538
+ const bot: Bot = {
539
+ id,
540
+ handle,
541
+ name,
542
+ status: "bench",
543
+ soulId,
544
+ agentTemplateId,
545
+ skillIds: input.skillIds,
546
+ defaultPositionId,
547
+ oneLiner: input.oneLiner?.trim() || undefined,
548
+ model: input.model ?? null,
549
+ createdAt: new Date().toISOString(),
550
+ };
551
+ this.writeBot(bot);
552
+ this.addMember(GENERAL_CHANNEL_ID, bot.id);
553
+ this.clearRetired(handle);
554
+ return bot;
555
+ }
556
+
557
+ deleteBot(id: string): { ok: true; id: string } {
558
+ const bot = this.getBot(id);
559
+ if (!bot) throw new StoreError(404, "bot not found");
560
+ for (const channel of this.listChannels()) {
561
+ if (!channel.memberIds.includes(id)) continue;
562
+ this.writeRoom({
563
+ ...channel,
564
+ memberIds: channel.memberIds.filter((memberId) => memberId !== id),
565
+ });
566
+ }
567
+ const dm = this.getRoom(`dm-${id}`);
568
+ if (dm) this.removeRoomDir(dm.id);
569
+ this.removeBotDir(bot.id);
570
+ this.markRetired(bot.handle);
571
+ return { ok: true, id: bot.id };
572
+ }
573
+
574
+ deleteChannel(id: string): { ok: true; id: string } {
575
+ const room = this.getRoom(id);
576
+ if (!room) throw new StoreError(404, "channel not found");
577
+ if (room.kind !== "channel") {
578
+ throw new StoreError(400, "not a channel");
579
+ }
580
+ if (room.id === GENERAL_CHANNEL_ID || room.name === "general") {
581
+ throw new StoreError(400, "cannot delete #general");
582
+ }
583
+ this.removeRoomDir(room.id);
584
+ return { ok: true, id: room.id };
585
+ }
586
+
587
+ getBot(id: string): Bot | null {
588
+ return this.readBot(id);
589
+ }
590
+
591
+ botDetail(id: string) {
592
+ const bot = this.readBot(id);
593
+ if (!bot) throw new StoreError(404, "bot not found");
594
+ return {
595
+ ...bot,
596
+ soul: this.getLibrary("souls", bot.soulId),
597
+ agent: this.getLibrary("agents", bot.agentTemplateId),
598
+ position: this.getLibrary("positions", bot.defaultPositionId),
599
+ };
600
+ }
601
+
602
+ updateBot(
603
+ id: string,
604
+ input: {
605
+ name?: string;
606
+ handle?: string;
607
+ oneLiner?: string;
608
+ portrait?: string | null;
609
+ skillIds?: string[];
610
+ soul?: { name: string; body: string };
611
+ agent?: { name: string; body: string };
612
+ position?: { name: string; body: string };
613
+ model?: ModelRef | null;
614
+ },
615
+ ): Bot {
616
+ const bot = this.readBot(id);
617
+ if (!bot) throw new StoreError(404, "bot not found");
618
+ const name = input.name?.trim() || bot.name;
619
+ const handle = (input.handle?.trim().replace(/^@/, "") || bot.handle);
620
+ if (
621
+ handle !== bot.handle &&
622
+ this.listBots().some((other) => other.handle === handle)
623
+ ) {
624
+ throw new StoreError(409, `handle already taken: ${handle}`);
625
+ }
626
+ const skillIds = input.skillIds ?? bot.skillIds;
627
+ if (skillIds.length === 0) {
628
+ throw new StoreError(400, "at least one skill is required");
629
+ }
630
+ for (const skillId of skillIds) {
631
+ if (!this.getLibrary("skills", skillId)) {
632
+ throw new StoreError(400, `skillId does not exist: ${skillId}`);
633
+ }
634
+ }
635
+ if (input.soul?.body.trim()) {
636
+ this.patchLibrary("souls", bot.soulId, input.soul);
637
+ }
638
+ if (input.agent?.body.trim()) {
639
+ this.patchLibrary("agents", bot.agentTemplateId, input.agent);
640
+ }
641
+ if (input.position?.body.trim()) {
642
+ this.patchLibrary("positions", bot.defaultPositionId, input.position);
643
+ }
644
+ const next: Bot = {
645
+ ...bot,
646
+ name,
647
+ handle,
648
+ skillIds,
649
+ oneLiner: input.oneLiner?.trim() || bot.oneLiner,
650
+ portrait: Object.hasOwn(input, "portrait")
651
+ ? normalizePortrait(input.portrait)
652
+ : bot.portrait,
653
+ model: Object.hasOwn(input, "model") ? input.model ?? null : bot.model,
654
+ };
655
+ this.writeBot(next);
656
+ return next;
657
+ }
658
+
659
+ private patchLibrary(
660
+ kind: "souls" | "agents" | "positions",
661
+ id: string,
662
+ draft: { name: string; body: string },
663
+ ): void {
664
+ const existing = this.getLibrary(kind, id);
665
+ if (!existing) {
666
+ throw new StoreError(400, `${kind} id does not exist`);
667
+ }
668
+ this.writeLibraryItem(kind, {
669
+ ...existing,
670
+ name: draft.name.trim() || existing.name,
671
+ body: draft.body,
672
+ });
673
+ }
674
+
675
+ private writeBot(bot: Bot): void {
676
+ const dir = join(this.dataDir, "bots", bot.id);
677
+ mkdirSync(dir, { recursive: true });
678
+ writeFileSync(join(dir, "bot.json"), `${JSON.stringify(bot, null, 2)}\n`);
679
+ writeFileSync(
680
+ join(dir, "IDENTITY.md"),
681
+ `# ${bot.name}\n\nhandle: @${bot.handle}\n\n${bot.oneLiner ?? ""}\n`,
682
+ );
683
+ }
684
+
685
+ private resolveMarkdownRef(
686
+ kind: "souls" | "agents" | "positions",
687
+ existingId: string | undefined,
688
+ draft: { name: string; body: string } | undefined,
689
+ ): string {
690
+ if (existingId) {
691
+ if (!this.getLibrary(kind, existingId)) {
692
+ throw new StoreError(400, `${kind} id does not exist`);
693
+ }
694
+ return existingId;
695
+ }
696
+ if (draft && draft.body.trim()) {
697
+ const created = this.createLibrary(kind, {
698
+ name: draft.name.trim() || kind,
699
+ body: draft.body,
700
+ });
701
+ return created.id;
702
+ }
703
+ throw new StoreError(400, `${kind} markdown is required`);
704
+ }
705
+
706
+ private readLibraryItem(kind: LibraryKind, id: string): LibraryItem | null {
707
+ try {
708
+ const raw = readFileSync(
709
+ join(this.dataDir, "library", kind, id, "item.json"),
710
+ "utf8",
711
+ );
712
+ return JSON.parse(raw) as LibraryItem;
713
+ } catch {
714
+ return null;
715
+ }
716
+ }
717
+
718
+ private writeLibraryItem(kind: LibraryKind, item: LibraryItem): void {
719
+ const dir = join(this.dataDir, "library", kind, item.id);
720
+ mkdirSync(dir, { recursive: true });
721
+ writeFileSync(join(dir, "item.json"), `${JSON.stringify(item, null, 2)}\n`);
722
+ writeFileSync(join(dir, MARKDOWN[kind]), `${item.body}\n`);
723
+ }
724
+
725
+ private readBot(id: string): Bot | null {
726
+ try {
727
+ const raw = readFileSync(join(this.dataDir, "bots", id, "bot.json"), "utf8");
728
+ return JSON.parse(raw) as Bot;
729
+ } catch {
730
+ return null;
731
+ }
732
+ }
733
+
734
+ private ensureGeneralChannel(): void {
735
+ let room = this.getRoom(GENERAL_CHANNEL_ID);
736
+ if (!room) {
737
+ room = {
738
+ id: GENERAL_CHANNEL_ID,
739
+ kind: "channel",
740
+ name: "general",
741
+ memberIds: [],
742
+ createdAt: "2026-01-01T00:00:00.000Z",
743
+ };
744
+ this.writeRoom(room);
745
+ this.writeMessages(GENERAL_CHANNEL_ID, []);
746
+ }
747
+ this.syncGeneralMembers(room);
748
+ }
749
+
750
+ private syncGeneralMembers(room: Room): void {
751
+ const botIds = this.listBots().map((bot) => bot.id);
752
+ const same =
753
+ botIds.length === room.memberIds.length &&
754
+ botIds.every((id) => room.memberIds.includes(id));
755
+ if (same) return;
756
+ this.writeRoom({ ...room, memberIds: botIds });
757
+ }
758
+
759
+ listChannels(): Room[] {
760
+ return this.listRooms().filter((room) => room.kind === "channel");
761
+ }
762
+
763
+ getRoom(id: string): Room | null {
764
+ return this.db.getRoom(id);
765
+ }
766
+
767
+ createChannel(name: string): Room {
768
+ const trimmed = name.replace(/^#/, "").trim();
769
+ if (!trimmed) {
770
+ throw new StoreError(400, "channel name is required");
771
+ }
772
+ if (trimmed === "general") {
773
+ throw new StoreError(400, "cannot create #general");
774
+ }
775
+ if (this.listChannels().some((room) => room.name === trimmed)) {
776
+ throw new StoreError(409, `channel already exists: ${trimmed}`);
777
+ }
778
+ const slug = slugify(trimmed);
779
+ const existingIds = this.listChannels().map((room) => room.id);
780
+ const baseId =
781
+ slug && slug !== "item" && slug !== "general"
782
+ ? `channel-${slug}`
783
+ : `channel-${randomUUID().slice(0, 8)}`;
784
+ const room: Room = {
785
+ id: uniqueSlug(baseId, existingIds),
786
+ kind: "channel",
787
+ name: trimmed,
788
+ memberIds: [],
789
+ createdAt: new Date().toISOString(),
790
+ };
791
+ this.writeRoom(room);
792
+ this.writeMessages(room.id, []);
793
+ return room;
794
+ }
795
+
796
+ renameChannel(id: string, name: string): Room {
797
+ const trimmed = String(name || "").trim();
798
+ if (!trimmed) throw new StoreError(400, "channel name is required");
799
+ const room = this.getRoom(id);
800
+ if (!room) throw new StoreError(404, "channel not found");
801
+ if (room.kind !== "channel") {
802
+ throw new StoreError(400, "not a channel");
803
+ }
804
+ if (room.id === GENERAL_CHANNEL_ID || room.name === "general") {
805
+ throw new StoreError(400, "cannot rename #general");
806
+ }
807
+ if (trimmed === "general") {
808
+ throw new StoreError(400, "cannot rename #general");
809
+ }
810
+ if (
811
+ this.listChannels().some((other) => other.id !== room.id && other.name === trimmed)
812
+ ) {
813
+ throw new StoreError(409, `channel already exists: ${trimmed}`);
814
+ }
815
+ if (room.name === trimmed) return room;
816
+ const next = { ...room, name: trimmed };
817
+ this.writeRoom(next);
818
+ return next;
819
+ }
820
+
821
+ openDm(botId: string): Room {
822
+ const bot = this.getBot(botId);
823
+ if (!bot) throw new StoreError(404, "bot not found");
824
+ const id = `dm-${botId}`;
825
+ const existing = this.getRoom(id);
826
+ if (existing) return existing;
827
+ const room: Room = {
828
+ id,
829
+ kind: "dm",
830
+ name: bot.handle,
831
+ memberIds: [botId],
832
+ createdAt: new Date().toISOString(),
833
+ };
834
+ this.writeRoom(room);
835
+ this.writeMessages(id, []);
836
+ return room;
837
+ }
838
+
839
+ addMember(roomId: string, botId: string): Room {
840
+ const room = this.getRoom(roomId);
841
+ if (!room) throw new StoreError(404, "channel not found");
842
+ if (room.kind !== "channel") {
843
+ throw new StoreError(400, "can only add bots to a channel");
844
+ }
845
+ if (!this.getBot(botId)) throw new StoreError(400, "bot not found");
846
+ if (room.memberIds.includes(botId)) return room;
847
+ if (
848
+ !isGeneralChannel(room) &&
849
+ room.memberIds.length >= CHANNEL_ROSTER_CAP
850
+ ) {
851
+ throw new StoreError(
852
+ 400,
853
+ `這個據點最多 ${CHANNEL_ROSTER_CAP} 席。先移出一位,或改 @ 現有編制。`,
854
+ );
855
+ }
856
+ const next = { ...room, memberIds: [...room.memberIds, botId] };
857
+ this.writeRoom(next);
858
+ return next;
859
+ }
860
+
861
+ removeMember(roomId: string, botId: string): Room {
862
+ const room = this.getRoom(roomId);
863
+ if (!room) throw new StoreError(404, "channel not found");
864
+ if (room.kind !== "channel") {
865
+ throw new StoreError(400, "can only remove bots from a channel");
866
+ }
867
+ if (room.id === GENERAL_CHANNEL_ID || room.name === "general") {
868
+ throw new StoreError(400, "bots cannot leave #general");
869
+ }
870
+ const next = {
871
+ ...room,
872
+ memberIds: room.memberIds.filter((id) => id !== botId),
873
+ };
874
+ this.writeRoom(next);
875
+ return next;
876
+ }
877
+
878
+ listMessages(roomId: string): ChatMessage[] {
879
+ if (!this.getRoom(roomId)) throw new StoreError(404, "room not found");
880
+ return this.db.listMessages(roomId);
881
+ }
882
+
883
+ lastMessageAt(roomId: string): string | undefined {
884
+ return this.lastMessagePreview(roomId)?.createdAt;
885
+ }
886
+
887
+ lastMessagePreview(roomId: string): {
888
+ author: string;
889
+ body: string;
890
+ createdAt: string;
891
+ } | undefined {
892
+ const message = this.db.peekLastMessage(roomId);
893
+ if (!message) return undefined;
894
+ return {
895
+ author: message.author,
896
+ body: clipNavPreview(message.body),
897
+ createdAt: message.finishedAt || message.createdAt,
898
+ };
899
+ }
900
+
901
+ listTrajectory(roomId: string): TrajectoryEvent[] {
902
+ return this.db.listTrajectory(roomId);
903
+ }
904
+
905
+ appendTrajectory(roomId: string, drafts: TrajectoryDraft[]): TrajectoryEvent[] {
906
+ if (!drafts.length) return [];
907
+ if (!this.getRoom(roomId)) return [];
908
+ return this.db.appendTrajectory(roomId, drafts);
909
+ }
910
+
911
+ appendMessage(
912
+ roomId: string,
913
+ author: "you" | string,
914
+ body: string,
915
+ parts?: ChatPart[],
916
+ replyTo?: string,
917
+ attachments?: ChatAttachment[],
918
+ usage?: ChatUsage,
919
+ steer?: boolean,
920
+ steerBotId?: string,
921
+ ): ChatMessage {
922
+ const room = this.getRoom(roomId);
923
+ if (!room) throw new StoreError(404, "room not found");
924
+ const text = body.trim();
925
+ if (!text) throw new StoreError(400, "message is required");
926
+ const now = new Date().toISOString();
927
+ const startedAt = author !== "you" ? usage?.startedAt : undefined;
928
+ const message: ChatMessage = {
929
+ id: randomUUID(),
930
+ roomId,
931
+ author,
932
+ body: text,
933
+ ...(parts && parts.length ? { parts } : {}),
934
+ ...(replyTo ? { replyTo } : {}),
935
+ ...(attachments && attachments.length ? { attachments } : {}),
936
+ ...(usage ? { usage } : {}),
937
+ createdAt: startedAt || now,
938
+ ...(author !== "you" ? { finishedAt: now } : {}),
939
+ ...(steer ? { steer: true } : {}),
940
+ ...(steer && steerBotId ? { steerBotId } : {}),
941
+ };
942
+ this.db.appendMessage(message);
943
+ return message;
944
+ }
945
+
946
+ updateMessage(roomId: string, messageId: string, body: string): ChatMessage {
947
+ const text = body.trim();
948
+ if (!text) throw new StoreError(400, "message is required");
949
+ const next = this.db.updateMessageBody(roomId, messageId, text);
950
+ if (!next) throw new StoreError(404, "message not found");
951
+ return next;
952
+ }
953
+
954
+ replaceMessage(
955
+ roomId: string,
956
+ messageId: string,
957
+ body: string,
958
+ parts?: ChatPart[],
959
+ usage?: ChatUsage,
960
+ ): ChatMessage {
961
+ const text = body.trim();
962
+ if (!text) throw new StoreError(400, "message is required");
963
+ const now = new Date().toISOString();
964
+ const next = this.db.replaceMessage(roomId, messageId, {
965
+ body: text,
966
+ parts: parts && parts.length ? parts : undefined,
967
+ usage,
968
+ createdAt: usage?.startedAt || now,
969
+ finishedAt: now,
970
+ });
971
+ if (!next) throw new StoreError(404, "message not found");
972
+ return next;
973
+ }
974
+
975
+ dropLastFailedReply(roomId: string, botId: string): ChatMessage | null {
976
+ if (!botId || !this.getRoom(roomId)) return null;
977
+ const messages = this.listMessages(roomId);
978
+ for (let i = messages.length - 1; i >= 0; i--) {
979
+ const item = messages[i];
980
+ if (item.author !== botId) continue;
981
+ if (!isFailedAssistantReply(item.body)) return null;
982
+ return this.db.deleteMessage(roomId, item.id);
983
+ }
984
+ return null;
985
+ }
986
+
987
+ deleteMessage(roomId: string, messageId: string): ChatMessage {
988
+ if (!this.getRoom(roomId)) throw new StoreError(404, "room not found");
989
+ const messages = this.listMessages(roomId);
990
+ const current = messages.find((item) => item.id === messageId);
991
+ if (!current) throw new StoreError(404, "message not found");
992
+ if (current.author !== "you") {
993
+ this.abortTurn(roomId, current.author);
994
+ } else {
995
+ const laterYou = messages.some(
996
+ (item, index) =>
997
+ index > messages.indexOf(current) && item.author === "you",
998
+ );
999
+ if (!laterYou) this.abortTurn(roomId);
1000
+ }
1001
+ const removed = this.db.deleteMessage(roomId, messageId);
1002
+ if (!removed) throw new StoreError(404, "message not found");
1003
+ return removed;
1004
+ }
1005
+
1006
+ truncateAfter(roomId: string, messageId: string): ChatMessage[] {
1007
+ const kept = this.db.truncateAfter(roomId, messageId);
1008
+ if (!kept) throw new StoreError(404, "message not found");
1009
+ return kept;
1010
+ }
1011
+
1012
+ private listRooms(): Room[] {
1013
+ return this.db.listRooms();
1014
+ }
1015
+
1016
+ private writeRoom(room: Room): void {
1017
+ this.db.upsertRoom(room);
1018
+ mkdirSync(join(this.dataDir, "rooms", room.id), { recursive: true });
1019
+ }
1020
+
1021
+ private retiredPath(): string {
1022
+ return join(this.dataDir, "retired.json");
1023
+ }
1024
+
1025
+ private readRetired(): Set<string> {
1026
+ try {
1027
+ const raw = JSON.parse(readFileSync(this.retiredPath(), "utf8")) as unknown;
1028
+ if (!Array.isArray(raw)) return new Set();
1029
+ return new Set(
1030
+ raw.filter((item): item is string => typeof item === "string" && item.trim() !== ""),
1031
+ );
1032
+ } catch {
1033
+ return new Set();
1034
+ }
1035
+ }
1036
+
1037
+ private writeRetired(handles: Set<string>): void {
1038
+ writeFileSync(
1039
+ this.retiredPath(),
1040
+ `${JSON.stringify([...handles].sort(), null, 2)}\n`,
1041
+ );
1042
+ }
1043
+
1044
+ private markRetired(handle: string): void {
1045
+ const next = this.readRetired();
1046
+ next.add(handle);
1047
+ this.writeRetired(next);
1048
+ }
1049
+
1050
+ private clearRetired(handle: string): void {
1051
+ const next = this.readRetired();
1052
+ if (!next.has(handle)) return;
1053
+ next.delete(handle);
1054
+ this.writeRetired(next);
1055
+ }
1056
+
1057
+ private removeRoomDir(roomId: string): void {
1058
+ if (!roomId || /[\\/]/.test(roomId) || roomId.includes("..")) {
1059
+ throw new StoreError(400, "bad room id");
1060
+ }
1061
+ this.db.deleteRoom(roomId);
1062
+ rmSync(join(this.dataDir, "rooms", roomId), { recursive: true, force: true });
1063
+ }
1064
+
1065
+ private removeBotDir(botId: string): void {
1066
+ if (!botId || /[\\/]/.test(botId) || botId.includes("..")) {
1067
+ throw new StoreError(400, "bad bot id");
1068
+ }
1069
+ rmSync(join(this.dataDir, "bots", botId), { recursive: true, force: true });
1070
+ }
1071
+
1072
+ private channelMdPath(roomId: string): string {
1073
+ return join(this.dataDir, "rooms", roomId, "CHANNEL.md");
1074
+ }
1075
+
1076
+ readChannelMd(roomId: string): string {
1077
+ const room = this.getRoom(roomId);
1078
+ if (!room) throw new StoreError(404, "room not found");
1079
+ if (room.kind !== "channel") {
1080
+ throw new StoreError(400, "Channel.md is only for channels");
1081
+ }
1082
+ const path = this.channelMdPath(roomId);
1083
+ if (!existsSync(path)) return "";
1084
+ return readFileSync(path, "utf8");
1085
+ }
1086
+
1087
+ writeChannelMd(roomId: string, body: string): string {
1088
+ const room = this.getRoom(roomId);
1089
+ if (!room) throw new StoreError(404, "room not found");
1090
+ if (room.kind !== "channel") {
1091
+ throw new StoreError(400, "Channel.md is only for channels");
1092
+ }
1093
+ const text = typeof body === "string" ? body : "";
1094
+ const dir = join(this.dataDir, "rooms", roomId);
1095
+ mkdirSync(dir, { recursive: true });
1096
+ writeFileSync(this.channelMdPath(roomId), text);
1097
+ return text;
1098
+ }
1099
+
1100
+ private botMemoryPath(botId: string): string {
1101
+ return join(this.dataDir, "bots", botId, "MEMORY.md");
1102
+ }
1103
+
1104
+ readBotMemory(botId: string): string {
1105
+ if (!this.getBot(botId)) throw new StoreError(404, "bot not found");
1106
+ const path = this.botMemoryPath(botId);
1107
+ if (!existsSync(path)) return "";
1108
+ return readFileSync(path, "utf8");
1109
+ }
1110
+
1111
+ writeBotMemory(botId: string, body: string): string {
1112
+ if (!this.getBot(botId)) throw new StoreError(404, "bot not found");
1113
+ const text = typeof body === "string" ? body : "";
1114
+ const dir = join(this.dataDir, "bots", botId);
1115
+ mkdirSync(dir, { recursive: true });
1116
+ writeFileSync(this.botMemoryPath(botId), text);
1117
+ return text;
1118
+ }
1119
+
1120
+ private channelMemoryPath(roomId: string): string {
1121
+ return join(this.dataDir, "rooms", roomId, "MEMORY.md");
1122
+ }
1123
+
1124
+ readChannelMemory(roomId: string): string {
1125
+ const room = this.getRoom(roomId);
1126
+ if (!room) throw new StoreError(404, "room not found");
1127
+ if (room.kind !== "channel") {
1128
+ throw new StoreError(400, "Channel MEMORY.md is only for channels");
1129
+ }
1130
+ const path = this.channelMemoryPath(roomId);
1131
+ if (!existsSync(path)) return "";
1132
+ return readFileSync(path, "utf8");
1133
+ }
1134
+
1135
+ writeChannelMemory(roomId: string, body: string): string {
1136
+ const room = this.getRoom(roomId);
1137
+ if (!room) throw new StoreError(404, "room not found");
1138
+ if (room.kind !== "channel") {
1139
+ throw new StoreError(400, "Channel MEMORY.md is only for channels");
1140
+ }
1141
+ const text = typeof body === "string" ? body : "";
1142
+ const dir = join(this.dataDir, "rooms", roomId);
1143
+ mkdirSync(dir, { recursive: true });
1144
+ writeFileSync(this.channelMemoryPath(roomId), text);
1145
+ return text;
1146
+ }
1147
+
1148
+ private writeMessages(roomId: string, messages: ChatMessage[]): void {
1149
+ this.db.replaceMessages(roomId, messages);
1150
+ }
1151
+
1152
+ readCompact(roomId: string): {
1153
+ throughId: string;
1154
+ summary: string;
1155
+ updatedAt: string;
1156
+ messageCount: number;
1157
+ } | null {
1158
+ if (!this.getRoom(roomId)) throw new StoreError(404, "room not found");
1159
+ return this.db.readCompact(roomId);
1160
+ }
1161
+
1162
+ writeCompact(
1163
+ roomId: string,
1164
+ compact: {
1165
+ throughId: string;
1166
+ summary: string;
1167
+ updatedAt: string;
1168
+ messageCount: number;
1169
+ },
1170
+ ): void {
1171
+ if (!this.getRoom(roomId)) throw new StoreError(404, "room not found");
1172
+ this.db.writeCompact(roomId, compact);
1173
+ }
1174
+ }
1175
+
1176
+ function normalizePortrait(raw: string | null | undefined): string | undefined {
1177
+ if (raw == null) return undefined;
1178
+ const value = raw.trim();
1179
+ if (!value) return undefined;
1180
+ if (!/^\/generated\/[A-Za-z0-9._-]+$/.test(value)) {
1181
+ throw new StoreError(400, "invalid portrait");
1182
+ }
1183
+ return value;
1184
+ }
1185
+
1186
+ export class StoreError extends Error {
1187
+ constructor(
1188
+ readonly status: number,
1189
+ message: string,
1190
+ ) {
1191
+ super(message);
1192
+ this.name = "StoreError";
1193
+ }
1194
+ }
1195
+
1196
+ export function slugify(name: string): string {
1197
+ const slug = name
1198
+ .toLowerCase()
1199
+ .trim()
1200
+ .replace(/[^a-z0-9]+/g, "-")
1201
+ .replace(/^-+|-+$/g, "");
1202
+ return slug || "item";
1203
+ }
1204
+
1205
+ function uniqueSlug(base: string, existing: string[]): string {
1206
+ if (!existing.includes(base)) return base;
1207
+ return `${base}-${randomUUID().slice(0, 8)}`;
1208
+ }