@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
@@ -0,0 +1,673 @@
1
+ import type { ChatPart, ChatUsage, ModelRef } from "@guild/protocol";
2
+ import { assembleParts, bodyFromParts } from "./chat-parts.ts";
3
+ import {
4
+ packHistory,
5
+ type CompactCheckpoint,
6
+ type HistoryItem,
7
+ } from "./compact.ts";
8
+ import { MEMORY_INJECT_CAP } from "./memory.ts";
9
+ import { llmComplete } from "./llm.ts";
10
+ import { policyFor, type Sandbox } from "./harness.ts";
11
+ import { listMcpToolRefs, type McpToolRef } from "./mcp.ts";
12
+ import { CHANNEL_ROSTER_CAP, StoreError } from "./store.ts";
13
+ import {
14
+ hostContext,
15
+ TOOL_SYSTEM,
16
+ type SkillRef,
17
+ type SubAgentRef,
18
+ type ToolContext,
19
+ type ToolProgress,
20
+ type ToolTrace,
21
+ } from "./tools.ts";
22
+
23
+ export type ChatReply = {
24
+ body: string;
25
+ parts: ChatPart[];
26
+ source: "llm" | "local";
27
+ system: string;
28
+ thinking?: string;
29
+ traces?: ToolTrace[];
30
+ model?: { provider: string; model: string } | null;
31
+ usage?: ChatUsage;
32
+ };
33
+
34
+ export type GenerateKind = "soul" | "agent" | "position" | "skill" | "subagent";
35
+
36
+ export type GeneratedMarkdown = {
37
+ name: string;
38
+ body: string;
39
+ source: "llm" | "local";
40
+ };
41
+
42
+ const TITLES: Record<GenerateKind, string> = {
43
+ soul: "SOUL.md",
44
+ agent: "AGENTS.md",
45
+ position: "POSITION.md",
46
+ skill: "SKILL.md",
47
+ subagent: "SUBAGENT.toml",
48
+ };
49
+
50
+ export function localGenerate(
51
+ kind: GenerateKind,
52
+ prompt: string,
53
+ ): GeneratedMarkdown {
54
+ const idea = prompt.trim();
55
+ if (!idea) {
56
+ throw new StoreError(400, "prompt is required");
57
+ }
58
+ const name = nameFromPrompt(idea);
59
+ if (kind === "soul") {
60
+ return {
61
+ name,
62
+ source: "local",
63
+ body: `# ${name}\n\n${idea}\n\n## Voice\n- Speak in this stance: ${idea}\n- Be specific. No filler.\n\n## Values\n- Prefer truth over comfort.\n- Leave the workspace better than you found it.\n\n## Boundaries\n- Do not invent facts.\n- Ask before destructive actions.\n- Do not do another seat's job. Hand off with a spec.\n`,
64
+ };
65
+ }
66
+ if (kind === "agent") {
67
+ return {
68
+ name,
69
+ source: "local",
70
+ body: `# ${name}\n\nOperating procedure for: ${idea}\n\n## Memory\n- Channel.md is the task. MEMORY.md is standing notes. Do not recap the whole thread.\n\n## Plan\n- One local directive: goal + done when + a short checklist. Revise it when evidence changes.\n\n## Act\n- Inspect the workspace, make the smallest change, verify, stop.\n- Work that belongs to another seat: line-start @handle with Goal / Done when / out of scope / files.\n\n## Skills\n- The catalog is availability, not a todo. Call \`skill\` only when this turn's directive matches.\n\n## Quality bar\n- No untested guesses.\n- Cite files you touched.\n- No status theater.\n`,
71
+ };
72
+ }
73
+ if (kind === "skill") {
74
+ const slug = name
75
+ .toLowerCase()
76
+ .replace(/[^a-z0-9]+/g, "-")
77
+ .replace(/^-+|-+$/g, "")
78
+ .slice(0, 64) || "skill";
79
+ return {
80
+ name,
81
+ source: "local",
82
+ body: `---\nname: ${slug}\ndescription: ${idea.replace(/\n/g, " ").slice(0, 280)}\n---\n\n# ${name}\n\n${idea}\n\n## When to use\nUse this skill when the task matches: ${idea}\n\n## Steps\n1. Restate the user goal.\n2. Follow the procedure above.\n3. Return a concise result with evidence.\n`,
83
+ };
84
+ }
85
+ if (kind === "subagent") {
86
+ const slug = name
87
+ .toLowerCase()
88
+ .replace(/[^a-z0-9]+/g, "-")
89
+ .replace(/^-+|-+$/g, "")
90
+ .slice(0, 64) || "agent";
91
+ return {
92
+ name,
93
+ source: "local",
94
+ body: `name = "${slug}"\ndescription = "${idea.replace(/\n/g, " ").replace(/"/g, '\\"').slice(0, 280)}"\ndeveloper_instructions = """\nRole: ${name}.\n\n${idea}\n\nStay inside the assignment. Return a concise summary with evidence (paths, commands, outcomes).\n"""\n`,
95
+ };
96
+ }
97
+ return {
98
+ name,
99
+ source: "local",
100
+ body: `# ${name}\n\nJob: ${idea}\n\n## Duties\n- Own this role: ${idea}\n- Do not cover another seat. Hand off with a spec (goal, done when, constraints, files).\n\n## Definition of done\n- The assigned task is complete or blocked with a reason.\n- Reviewer (if any) can reproduce the result.\n\n## Tools\nsandbox: workspace_write\n`,
101
+ };
102
+ }
103
+
104
+ export async function generateMarkdown(
105
+ kind: GenerateKind,
106
+ prompt: string,
107
+ env: NodeJS.ProcessEnv = process.env,
108
+ dataDir?: string,
109
+ ): Promise<GeneratedMarkdown> {
110
+ if (!prompt.trim()) {
111
+ throw new StoreError(400, "prompt is required");
112
+ }
113
+ const llm = await tryLlmGenerate(kind, prompt, env, dataDir);
114
+ if (llm) return llm;
115
+ return localGenerate(kind, prompt);
116
+ }
117
+
118
+ function nameFromPrompt(prompt: string): string {
119
+ const line = prompt.split(/\n/)[0]?.trim() ?? "Untitled";
120
+ return line.slice(0, 48);
121
+ }
122
+
123
+ async function tryLlmGenerate(
124
+ kind: GenerateKind,
125
+ prompt: string,
126
+ env: NodeJS.ProcessEnv,
127
+ dataDir?: string,
128
+ ): Promise<GeneratedMarkdown | null> {
129
+ if (!dataDir) return null;
130
+ const bodyHint =
131
+ kind === "subagent"
132
+ ? "body must be Codex-style TOML with name, description, and developer_instructions."
133
+ : kind === "agent"
134
+ ? `body must be Markdown with sections:
135
+ ## Memory — Channel.md is the task; MEMORY.md is standing notes; do not recap the whole thread
136
+ ## Plan — one local directive: goal, done when, short checklist
137
+ ## Act — inspect, smallest change, verify, stop; hand off other seats with a line-start @handle spec
138
+ ## Skills — catalog is availability, not a todo; call skill only when this turn matches`
139
+ : kind === "soul"
140
+ ? "body must be Markdown: voice, values, boundaries. Not an operating procedure."
141
+ : kind === "position"
142
+ ? "body must be Markdown: duties, definition of done, and a Tools sandbox: line."
143
+ : "body must be Markdown.";
144
+ const system = `You write ${TITLES[kind]} for an AI bot in Guild.
145
+ Return JSON only: {"name": string, "body": string}.
146
+ ${bodyHint}
147
+ name is a short title. Keep it short. Language: follow the user's prompt.`;
148
+ const result = await llmComplete({
149
+ dataDir,
150
+ env,
151
+ system,
152
+ messages: [{ role: "user", content: prompt }],
153
+ temperature: 0.4,
154
+ role: "generate",
155
+ });
156
+ if (!result) return null;
157
+ const parsed = extractJson(result.text);
158
+ if (!parsed?.name || !parsed?.body) return null;
159
+ return { name: parsed.name, body: parsed.body, source: "llm" };
160
+ }
161
+
162
+ export type SkillPickItem = {
163
+ id: string;
164
+ name: string;
165
+ description?: string;
166
+ tags?: string[];
167
+ slug?: string;
168
+ };
169
+
170
+ export type SkillPickInput = {
171
+ name?: string;
172
+ handle?: string;
173
+ oneLiner?: string;
174
+ soul?: string;
175
+ agent?: string;
176
+ position?: string;
177
+ skills: SkillPickItem[];
178
+ };
179
+
180
+ export type SkillPickResult = {
181
+ skillIds: string[];
182
+ source: "llm" | "local";
183
+ };
184
+
185
+ const PICK_MAX = 8;
186
+ const PICK_CATALOG_MAX = 200;
187
+ const PICK_DESC_CAP = 180;
188
+ const PICK_MD_CAP = 1600;
189
+
190
+ export async function pickSkills(
191
+ input: SkillPickInput,
192
+ env: NodeJS.ProcessEnv = process.env,
193
+ dataDir?: string,
194
+ ): Promise<SkillPickResult> {
195
+ const catalog = normalizePickCatalog(input.skills);
196
+ if (!catalog.length) {
197
+ throw new StoreError(400, "skills catalog is required");
198
+ }
199
+ const brief = seatBrief(input);
200
+ if (!brief.trim()) {
201
+ throw new StoreError(400, "markdown is required");
202
+ }
203
+ if (dataDir) {
204
+ const llm = await tryLlmPick(brief, catalog, env, dataDir);
205
+ if (llm?.skillIds.length) return llm;
206
+ }
207
+ return localPick(brief, catalog);
208
+ }
209
+
210
+ function normalizePickCatalog(skills: SkillPickItem[]): SkillPickItem[] {
211
+ const out: SkillPickItem[] = [];
212
+ const seen = new Set<string>();
213
+ for (const item of skills || []) {
214
+ const id = String(item?.id || "").trim();
215
+ const name = String(item?.name || "").trim();
216
+ if (!id || !name || seen.has(id)) continue;
217
+ seen.add(id);
218
+ out.push({
219
+ id,
220
+ name,
221
+ slug: String(item.slug || "").trim(),
222
+ description: String(item.description || "").slice(0, PICK_DESC_CAP),
223
+ tags: Array.isArray(item.tags)
224
+ ? item.tags.filter((tag): tag is string => typeof tag === "string").slice(0, 8)
225
+ : [],
226
+ });
227
+ if (out.length >= PICK_CATALOG_MAX) break;
228
+ }
229
+ return out;
230
+ }
231
+
232
+ function seatBrief(input: SkillPickInput): string {
233
+ const clip = (value: string, cap: number) => value.trim().slice(0, cap);
234
+ const parts = [
235
+ input.name?.trim() ? `Name: ${clip(input.name, 80)}` : "",
236
+ input.handle?.trim() ? `Handle: ${clip(input.handle, 40)}` : "",
237
+ input.oneLiner?.trim() ? `One-liner: ${clip(input.oneLiner, 240)}` : "",
238
+ input.soul?.trim() ? `SOUL.md\n${clip(input.soul, PICK_MD_CAP)}` : "",
239
+ input.agent?.trim() ? `AGENTS.md\n${clip(input.agent, PICK_MD_CAP)}` : "",
240
+ input.position?.trim() ? `POSITION.md\n${clip(input.position, PICK_MD_CAP)}` : "",
241
+ ];
242
+ return parts.filter(Boolean).join("\n\n");
243
+ }
244
+
245
+ function filterPickIds(ids: string[], catalog: SkillPickItem[]): string[] {
246
+ const allowed = new Set(catalog.map((item) => item.id));
247
+ const seen = new Set<string>();
248
+ const out: string[] = [];
249
+ for (const raw of ids) {
250
+ const id = String(raw || "").trim();
251
+ if (!id || !allowed.has(id) || seen.has(id)) continue;
252
+ seen.add(id);
253
+ out.push(id);
254
+ if (out.length >= PICK_MAX) break;
255
+ }
256
+ return out;
257
+ }
258
+
259
+ async function tryLlmPick(
260
+ brief: string,
261
+ catalog: SkillPickItem[],
262
+ env: NodeJS.ProcessEnv,
263
+ dataDir: string,
264
+ ): Promise<SkillPickResult | null> {
265
+ const compact = catalog.map((item) => ({
266
+ id: item.id,
267
+ name: item.name,
268
+ description: item.description || "",
269
+ tags: item.tags || [],
270
+ }));
271
+ const system = `You staff skills onto one AI bot seat.
272
+ Return JSON only: {"skillIds": string[]}.
273
+ Pick 3-8 ids from the catalog this seat actually needs, matching Soul / Agent / Position.
274
+ Prefer specific skills over generic ones. Do not invent ids. Do not pick everything.`;
275
+ const result = await llmComplete({
276
+ dataDir,
277
+ env,
278
+ system,
279
+ messages: [
280
+ {
281
+ role: "user",
282
+ content: `${brief}\n\nCatalog:\n${JSON.stringify(compact)}`,
283
+ },
284
+ ],
285
+ temperature: 0.2,
286
+ role: "skills",
287
+ });
288
+ if (!result) return null;
289
+ const ids = extractSkillIds(result.text);
290
+ if (!ids) return null;
291
+ const skillIds = filterPickIds(ids, catalog);
292
+ if (!skillIds.length) return null;
293
+ return { skillIds, source: "llm" };
294
+ }
295
+
296
+ function localPick(brief: string, catalog: SkillPickItem[]): SkillPickResult {
297
+ const toks = pickTokens(brief);
298
+ const ranked = catalog
299
+ .map((item) => ({ id: item.id, n: pickScore(item, toks) }))
300
+ .filter((item) => item.n > 0)
301
+ .sort((a, b) => b.n - a.n || a.id.localeCompare(b.id));
302
+ return {
303
+ skillIds: ranked.slice(0, PICK_MAX).map((item) => item.id),
304
+ source: "local",
305
+ };
306
+ }
307
+
308
+ function pickTokens(text: string): string[] {
309
+ const lower = text.toLowerCase();
310
+ const out = new Set<string>();
311
+ for (const word of lower.match(/[a-z][a-z0-9-]{1,}|[0-9]{2,}/g) || []) {
312
+ out.add(word);
313
+ }
314
+ const cjk = lower.match(/[\u3400-\u9fff]+/g) || [];
315
+ for (const run of cjk) {
316
+ if (run.length === 1) out.add(run);
317
+ for (let i = 0; i < run.length - 1; i++) out.add(run.slice(i, i + 2));
318
+ }
319
+ return [...out];
320
+ }
321
+
322
+ function pickScore(item: SkillPickItem, toks: string[]): number {
323
+ const hay = (
324
+ `${item.name} ${item.slug || ""} ${item.description || ""} ${(item.tags || []).join(" ")}`
325
+ ).toLowerCase();
326
+ let n = 0;
327
+ for (const tok of toks) {
328
+ if (hay.includes(tok)) n += tok.length > 3 ? 2 : 1;
329
+ }
330
+ return n;
331
+ }
332
+
333
+ function extractSkillIds(content: string): string[] | null {
334
+ const fenced = content.match(/\{[\s\S]*\}/);
335
+ if (!fenced) return null;
336
+ try {
337
+ const value = JSON.parse(fenced[0]) as { skillIds?: unknown };
338
+ if (!Array.isArray(value.skillIds)) return null;
339
+ return value.skillIds.filter((id): id is string => typeof id === "string");
340
+ } catch {
341
+ return null;
342
+ }
343
+ }
344
+
345
+ /** Seat exclusivity, spec handoffs, quiet unless blocked. */
346
+ export const HALL_RULES = `# Hall
347
+ Own this seat. Do not do another staffed bot's job.
348
+ When work belongs to someone else, put @handle at the start of a line with a written spec, not a suggestion in prose:
349
+ - Goal (one sentence)
350
+ - Done when
351
+ - Constraints / out of scope
352
+ - Files or evidence
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
+ 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
+ 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
+ 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
+
358
+ Harness this turn (Memory → Plan → Skills → Act):
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
+ - Plan: one local directive (goal + done when) before tools. Revise it when evidence changes.
361
+ - Skills: the catalog is availability, not a todo. Call \`skill\` only when this directive matches. Do not load every skill.
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
+
364
+ export function buildChatSystem(input: {
365
+ botName: string;
366
+ handle: string;
367
+ soul: string;
368
+ agent: string;
369
+ position: string;
370
+ skills?: SkillRef[];
371
+ subagents?: SubAgentRef[];
372
+ wantSpawn?: SubAgentRef[];
373
+ channelMd?: string;
374
+ botMemory?: string;
375
+ channelMemory?: string;
376
+ }): string {
377
+ const skills = input.skills ?? [];
378
+ const subagents = input.subagents ?? [];
379
+ const wantSpawn = input.wantSpawn ?? [];
380
+ const skillLine = skills.length
381
+ ? [
382
+ "<system-reminder>",
383
+ "A skill is a reusable set of task-specific instructions. The following skills are available this turn (staffed on this bot, or invoked with /name):",
384
+ "",
385
+ "<available_skills>",
386
+ ...skills.map((item) => {
387
+ const key = item.slug || item.name;
388
+ const desc = (item.description || item.name)
389
+ .replace(/\s+/g, " ")
390
+ .trim()
391
+ .slice(0, 500);
392
+ return `- \`${key}\`: ${desc}`;
393
+ }),
394
+ "</available_skills>",
395
+ "",
396
+ "If the user names a skill, or the task clearly matches a description, call the skill tool with the exact name before taking task actions. Load applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer a skill's steps until it has been loaded.",
397
+ "</system-reminder>",
398
+ ].join("\n")
399
+ : "";
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");
451
+ const channel = (input.channelMd ?? "").trim();
452
+ const channelBlock = channel
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)}`
454
+ : "";
455
+ const botMem = (input.botMemory ?? "").trim();
456
+ const botMemBlock = botMem
457
+ ? `# MEMORY.md\nStanding notes this bot has learned. Auto-updated after useful turns. Not a transcript.\n\n${botMem.slice(0, MEMORY_INJECT_CAP)}`
458
+ : "";
459
+ const roomMem = (input.channelMemory ?? "").trim();
460
+ const roomMemBlock = roomMem
461
+ ? `# Channel MEMORY.md\nStanding notes for this channel, shared by everyone here. Auto-updated.\n\n${roomMem.slice(0, MEMORY_INJECT_CAP)}`
462
+ : "";
463
+ return [
464
+ `You are ${input.botName} (@${input.handle}), a staffed bot in Guild.`,
465
+ "Reply in the user's language. Be brief. Stay in character.",
466
+ HALL_RULES,
467
+ hostContext(),
468
+ TOOL_SYSTEM,
469
+ skillLine,
470
+ spawnLine,
471
+ channelBlock,
472
+ botMemBlock,
473
+ roomMemBlock,
474
+ input.soul.slice(0, 2400),
475
+ input.agent.slice(0, 1600),
476
+ input.position.slice(0, 800),
477
+ ]
478
+ .filter(Boolean)
479
+ .join("\n\n");
480
+ }
481
+
482
+ export async function chatReply(input: {
483
+ botName: string;
484
+ handle: string;
485
+ soul: string;
486
+ agent: string;
487
+ position: string;
488
+ history: HistoryItem[];
489
+ userMessage: string;
490
+ env?: NodeJS.ProcessEnv;
491
+ dataDir?: string;
492
+ model?: ModelRef | null;
493
+ skills?: SkillRef[];
494
+ subagents?: SubAgentRef[];
495
+ wantSpawn?: SubAgentRef[];
496
+ channelMd?: string;
497
+ botMemory?: string;
498
+ channelMemory?: string;
499
+ compact?: CompactCheckpoint | null;
500
+ onCompact?: (checkpoint: CompactCheckpoint) => void;
501
+ onProgress?: (update: ToolProgress) => void;
502
+ pullSteers?: () => string[];
503
+ signal?: AbortSignal;
504
+ mcpTools?: McpToolRef[];
505
+ sandbox?: Sandbox;
506
+ workspace?: string;
507
+ dispatch?: ToolContext["dispatch"];
508
+ }): Promise<ChatReply> {
509
+ const env = input.env ?? process.env;
510
+ const system = buildChatSystem({
511
+ botName: input.botName,
512
+ handle: input.handle,
513
+ soul: input.soul,
514
+ agent: input.agent,
515
+ position: input.position,
516
+ skills: input.skills ?? [],
517
+ subagents: input.subagents ?? [],
518
+ wantSpawn: input.wantSpawn ?? [],
519
+ channelMd: input.channelMd,
520
+ botMemory: input.botMemory,
521
+ channelMemory: input.channelMemory,
522
+ });
523
+ const llm = input.dataDir
524
+ ? await tryChatLlm(input, env, input.dataDir, input.model, input.skills ?? [])
525
+ : null;
526
+ if (llm) return { ...llm, source: "llm", system: llm.system || system };
527
+ const body = localChatReply(input.botName, input.handle, input.userMessage);
528
+ return {
529
+ body,
530
+ parts: [{ type: "text", text: body }],
531
+ source: "local",
532
+ system,
533
+ traces: [],
534
+ thinking: "",
535
+ model: null,
536
+ usage: { estimated: true, rounds: 0, durationMs: 0, totalTokens: 0 },
537
+ };
538
+ }
539
+
540
+ export function localChatReply(
541
+ botName: string,
542
+ handle: string,
543
+ userMessage: string,
544
+ ): string {
545
+ const clip = userMessage.trim().slice(0, 120);
546
+ return `【${botName} @${handle}】收到。「${clip}」\n\n沒有可用模型,本機工具還沒辦法跑。到模型頁(/settings)連接訂閱或填 API key,套用主模型後再問。`;
547
+ }
548
+
549
+ async function tryChatLlm(
550
+ input: {
551
+ botName: string;
552
+ handle: string;
553
+ soul: string;
554
+ agent: string;
555
+ position: string;
556
+ history: HistoryItem[];
557
+ userMessage: string;
558
+ skills?: SkillRef[];
559
+ subagents?: SubAgentRef[];
560
+ wantSpawn?: SubAgentRef[];
561
+ channelMd?: string;
562
+ botMemory?: string;
563
+ channelMemory?: string;
564
+ compact?: CompactCheckpoint | null;
565
+ onCompact?: (checkpoint: CompactCheckpoint) => void;
566
+ onProgress?: (update: ToolProgress) => void;
567
+ pullSteers?: () => string[];
568
+ signal?: AbortSignal;
569
+ mcpTools?: McpToolRef[];
570
+ sandbox?: Sandbox;
571
+ workspace?: string;
572
+ dispatch?: ToolContext["dispatch"];
573
+ },
574
+ env: NodeJS.ProcessEnv,
575
+ dataDir: string,
576
+ prefer: ModelRef | null | undefined,
577
+ skills: SkillRef[],
578
+ ): Promise<Omit<ChatReply, "source"> | null> {
579
+ const system = buildChatSystem({
580
+ botName: input.botName,
581
+ handle: input.handle,
582
+ soul: input.soul,
583
+ agent: input.agent,
584
+ position: input.position,
585
+ skills,
586
+ subagents: input.subagents ?? [],
587
+ wantSpawn: input.wantSpawn ?? [],
588
+ channelMd: input.channelMd,
589
+ botMemory: input.botMemory,
590
+ channelMemory: input.channelMemory,
591
+ });
592
+ const packed = await packHistory({
593
+ system,
594
+ history: input.history,
595
+ userMessage: input.userMessage,
596
+ dataDir,
597
+ env,
598
+ prefer,
599
+ checkpoint: input.compact,
600
+ });
601
+ if (packed.compacted && packed.checkpoint && input.onCompact) {
602
+ input.onCompact(packed.checkpoint);
603
+ }
604
+ const result = await llmComplete({
605
+ dataDir,
606
+ env,
607
+ system,
608
+ messages: packed.messages,
609
+ temperature: 0.5,
610
+ role: "chat",
611
+ prefer,
612
+ tools: true,
613
+ skills,
614
+ toolCtx: {
615
+ skills,
616
+ subagents: input.subagents ?? [],
617
+ dataDir,
618
+ env,
619
+ spawnDepth: 0,
620
+ allowWrite: true,
621
+ onProgress: input.onProgress,
622
+ pullSteers: input.pullSteers,
623
+ signal: input.signal,
624
+ mcpTools: input.mcpTools ?? (await listMcpToolRefs(dataDir)),
625
+ ...policyFor(env, {
626
+ sandbox: input.sandbox,
627
+ workspace: input.workspace,
628
+ position: input.position,
629
+ }),
630
+ ...(input.dispatch ? { dispatch: input.dispatch } : {}),
631
+ },
632
+ });
633
+ if (!result) return null;
634
+ const parts = assembleParts({
635
+ thinking: result.thinking,
636
+ traces: result.traces,
637
+ text: result.text,
638
+ });
639
+ return {
640
+ body: bodyFromParts(parts, result.text),
641
+ parts,
642
+ system,
643
+ thinking: result.thinking,
644
+ traces: result.traces,
645
+ model: { provider: result.provider, model: result.model },
646
+ usage: result.usage
647
+ ? {
648
+ ...result.usage,
649
+ provider: result.provider,
650
+ model: result.model,
651
+ }
652
+ : {
653
+ provider: result.provider,
654
+ model: result.model,
655
+ },
656
+ };
657
+ }
658
+
659
+ function extractJson(
660
+ content: string,
661
+ ): { name: string; body: string } | null {
662
+ const fenced = content.match(/\{[\s\S]*\}/);
663
+ if (!fenced) return null;
664
+ try {
665
+ const value = JSON.parse(fenced[0]) as { name?: unknown; body?: unknown };
666
+ if (typeof value.name !== "string" || typeof value.body !== "string") {
667
+ return null;
668
+ }
669
+ return { name: value.name, body: value.body };
670
+ } catch {
671
+ return null;
672
+ }
673
+ }