@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/mcp.ts ADDED
@@ -0,0 +1,563 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
5
+
6
+ export type McpLaunch = {
7
+ command: string;
8
+ args: string[];
9
+ env?: Record<string, string>;
10
+ cwd?: string;
11
+ url?: string;
12
+ };
13
+
14
+ export type McpServer = {
15
+ id: string;
16
+ name: string;
17
+ source: "user" | "host";
18
+ host?: string;
19
+ launch: McpLaunch;
20
+ enabled: boolean;
21
+ };
22
+
23
+ export type McpToolRef = {
24
+ callName: string;
25
+ server: string;
26
+ tool: string;
27
+ description: string;
28
+ inputSchema: Record<string, unknown>;
29
+ };
30
+
31
+ type Pending = {
32
+ resolve: (value: unknown) => void;
33
+ reject: (err: Error) => void;
34
+ };
35
+
36
+ class McpSession {
37
+ private readonly child: ChildProcessWithoutNullStreams;
38
+ private buf = Buffer.alloc(0);
39
+ private nextId = 1;
40
+ private readonly pending = new Map<number, Pending>();
41
+ private ready: Promise<void>;
42
+
43
+ constructor(launch: McpLaunch) {
44
+ const env: Record<string, string> = {};
45
+ for (const [key, value] of Object.entries(process.env)) {
46
+ if (typeof value === "string") env[key] = value;
47
+ }
48
+ Object.assign(env, launch.env || {});
49
+ env.PYTHONUNBUFFERED = "1";
50
+ this.child = spawn(launch.command, launch.args, {
51
+ cwd: launch.cwd || undefined,
52
+ env,
53
+ stdio: ["pipe", "pipe", "pipe"],
54
+ });
55
+ this.child.stdout.on("data", (chunk: Buffer) => this.onData(chunk));
56
+ this.child.stderr.on("data", () => {
57
+ /* MCP logs belong on stderr */
58
+ });
59
+ this.child.on("error", (err) => {
60
+ for (const wait of this.pending.values()) wait.reject(err);
61
+ this.pending.clear();
62
+ });
63
+ this.child.on("exit", () => {
64
+ const err = new Error("mcp server exited");
65
+ for (const wait of this.pending.values()) wait.reject(err);
66
+ this.pending.clear();
67
+ });
68
+ this.ready = new Promise((resolve, reject) => {
69
+ this.child.once("error", reject);
70
+ this.handshake().then(resolve, reject);
71
+ });
72
+ }
73
+
74
+ private async handshake(): Promise<void> {
75
+ await this.request("initialize", {
76
+ protocolVersion: "2024-11-05",
77
+ capabilities: {},
78
+ clientInfo: { name: "guild", version: "0.0.1" },
79
+ });
80
+ this.notify("notifications/initialized");
81
+ }
82
+
83
+ async ensure(): Promise<void> {
84
+ await this.ready;
85
+ }
86
+
87
+ async listTools(): Promise<
88
+ { name: string; description?: string; inputSchema?: Record<string, unknown> }[]
89
+ > {
90
+ await this.ready;
91
+ const result = (await this.request("tools/list", {})) as {
92
+ tools?: {
93
+ name: string;
94
+ description?: string;
95
+ inputSchema?: Record<string, unknown>;
96
+ }[];
97
+ };
98
+ return Array.isArray(result.tools) ? result.tools : [];
99
+ }
100
+
101
+ async callTool(
102
+ name: string,
103
+ args: Record<string, unknown>,
104
+ ): Promise<{ text: string; isError: boolean }> {
105
+ await this.ready;
106
+ const result = (await this.request("tools/call", {
107
+ name,
108
+ arguments: args,
109
+ })) as {
110
+ isError?: boolean;
111
+ content?: { type?: string; text?: string }[];
112
+ };
113
+ const text = (result.content || [])
114
+ .filter((part) => part && part.type === "text" && part.text)
115
+ .map((part) => part.text)
116
+ .join("\n")
117
+ .trim();
118
+ return {
119
+ text: text || (result.isError ? "mcp tool failed" : "(empty)"),
120
+ isError: Boolean(result.isError),
121
+ };
122
+ }
123
+
124
+ close(): void {
125
+ try {
126
+ this.child.kill("SIGTERM");
127
+ } catch {
128
+ /* ignore */
129
+ }
130
+ }
131
+
132
+ private notify(method: string, params?: unknown): void {
133
+ this.write({ jsonrpc: "2.0", method, params });
134
+ }
135
+
136
+ private request(method: string, params: unknown): Promise<unknown> {
137
+ const id = this.nextId++;
138
+ return new Promise((resolve, reject) => {
139
+ const timer = setTimeout(() => {
140
+ this.pending.delete(id);
141
+ reject(new Error(`mcp timeout: ${method}`));
142
+ }, method === "tools/call" ? 300_000 : 8_000);
143
+ this.pending.set(id, {
144
+ resolve: (value) => {
145
+ clearTimeout(timer);
146
+ resolve(value);
147
+ },
148
+ reject: (err) => {
149
+ clearTimeout(timer);
150
+ reject(err);
151
+ },
152
+ });
153
+ this.write({ jsonrpc: "2.0", id, method, params });
154
+ });
155
+ }
156
+
157
+ private write(msg: unknown): void {
158
+ const json = JSON.stringify(msg);
159
+ const payload = Buffer.from(json, "utf8");
160
+ this.child.stdin.write(`Content-Length: ${payload.length}\r\n\r\n`);
161
+ this.child.stdin.write(payload);
162
+ }
163
+
164
+ private onData(chunk: Buffer): void {
165
+ this.buf = Buffer.concat([this.buf, chunk]);
166
+ while (true) {
167
+ const framed = this.takeFrame();
168
+ if (framed === null) break;
169
+ this.dispatch(framed);
170
+ }
171
+ }
172
+
173
+ private takeFrame(): string | null {
174
+ const headerEnd = this.buf.indexOf("\r\n\r\n");
175
+ if (headerEnd >= 0) {
176
+ const header = this.buf.subarray(0, headerEnd).toString("utf8");
177
+ const match = header.match(/Content-Length:\s*(\d+)/i);
178
+ if (!match) {
179
+ this.buf = this.buf.subarray(headerEnd + 4);
180
+ return null;
181
+ }
182
+ const length = Number(match[1]);
183
+ const start = headerEnd + 4;
184
+ if (this.buf.length < start + length) return null;
185
+ const json = this.buf.subarray(start, start + length).toString("utf8");
186
+ this.buf = this.buf.subarray(start + length);
187
+ return json;
188
+ }
189
+ const nl = this.buf.indexOf("\n");
190
+ if (nl < 0) return null;
191
+ const line = this.buf.subarray(0, nl).toString("utf8").trim();
192
+ this.buf = this.buf.subarray(nl + 1);
193
+ if (!line.startsWith("{")) return this.takeFrame();
194
+ return line;
195
+ }
196
+
197
+ private dispatch(raw: string): void {
198
+ let msg: { id?: number; result?: unknown; error?: { message?: string } };
199
+ try {
200
+ msg = JSON.parse(raw) as typeof msg;
201
+ } catch {
202
+ return;
203
+ }
204
+ if (typeof msg.id !== "number") return;
205
+ const wait = this.pending.get(msg.id);
206
+ if (!wait) return;
207
+ this.pending.delete(msg.id);
208
+ if (msg.error) {
209
+ wait.reject(new Error(msg.error.message || "mcp error"));
210
+ return;
211
+ }
212
+ wait.resolve(msg.result);
213
+ }
214
+ }
215
+
216
+ const sessions = new Map<string, McpSession>();
217
+
218
+ export function mcpPath(dataDir: string): string {
219
+ return join(dataDir, "mcp.json");
220
+ }
221
+
222
+ export function readMcpFile(dataDir: string): Record<string, McpLaunch> {
223
+ const file = mcpPath(dataDir);
224
+ if (!existsSync(file)) return {};
225
+ try {
226
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as {
227
+ mcpServers?: Record<string, unknown>;
228
+ };
229
+ return normalizeMap(parsed.mcpServers || {});
230
+ } catch {
231
+ return {};
232
+ }
233
+ }
234
+
235
+ export function writeMcpFile(
236
+ dataDir: string,
237
+ servers: Record<string, McpLaunch>,
238
+ ): void {
239
+ mkdirSync(dataDir, { recursive: true });
240
+ writeFileSync(
241
+ mcpPath(dataDir),
242
+ `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`,
243
+ );
244
+ }
245
+
246
+ export function listGuildMcp(dataDir: string): McpServer[] {
247
+ return Object.entries(readMcpFile(dataDir)).map(([name, launch]) => ({
248
+ id: `guild:${name}`,
249
+ name,
250
+ source: "user" as const,
251
+ launch,
252
+ enabled: true,
253
+ }));
254
+ }
255
+
256
+ export function upsertGuildMcp(
257
+ dataDir: string,
258
+ name: string,
259
+ launch: McpLaunch,
260
+ ): McpServer {
261
+ const slug = sanitize(name) || "server";
262
+ if (launch.url && !launch.command.trim()) {
263
+ throw new Error("stdio MCP needs a command (HTTP MCP is not wired yet)");
264
+ }
265
+ if (!launch.command.trim()) throw new Error("command is required");
266
+ const current = readMcpFile(dataDir);
267
+ current[slug] = {
268
+ command: launch.command.trim(),
269
+ args: launch.args || [],
270
+ ...(launch.env && Object.keys(launch.env).length ? { env: launch.env } : {}),
271
+ ...(launch.cwd ? { cwd: launch.cwd } : {}),
272
+ };
273
+ writeMcpFile(dataDir, current);
274
+ dropSession(slug);
275
+ return {
276
+ id: `guild:${slug}`,
277
+ name: slug,
278
+ source: "user",
279
+ launch: current[slug],
280
+ enabled: true,
281
+ };
282
+ }
283
+
284
+ export function removeGuildMcp(dataDir: string, name: string): { ok: true } {
285
+ const current = readMcpFile(dataDir);
286
+ const slug = name.replace(/^guild:/, "");
287
+ if (!current[slug]) throw new Error("mcp server not found");
288
+ delete current[slug];
289
+ writeMcpFile(dataDir, current);
290
+ dropSession(slug);
291
+ return { ok: true };
292
+ }
293
+
294
+ export function listHostMcp(home = homedir()): McpServer[] {
295
+ const out: McpServer[] = [];
296
+ const seen = new Set<string>();
297
+ const push = (item: McpServer) => {
298
+ const key = item.name.toLowerCase();
299
+ if (seen.has(key)) return;
300
+ seen.add(key);
301
+ out.push({ ...item, enabled: true });
302
+ };
303
+ for (const item of readClaudeMcp(join(home, ".claude.json"), "Claude")) {
304
+ push(item);
305
+ }
306
+ for (const item of readJsonMcp(join(home, ".cursor", "mcp.json"), "Cursor")) {
307
+ push(item);
308
+ }
309
+ for (const item of readCodexMcp(join(home, ".codex", "config.toml"), "Codex")) {
310
+ push(item);
311
+ }
312
+ return out;
313
+ }
314
+
315
+ export function listActiveMcp(dataDir: string, home = homedir()): McpServer[] {
316
+ const guild = listGuildMcp(dataDir);
317
+ const taken = new Set(guild.map((server) => server.name.toLowerCase()));
318
+ const host = listHostMcp(home).filter(
319
+ (server) => !taken.has(server.name.toLowerCase()),
320
+ );
321
+ return guild.concat(host);
322
+ }
323
+
324
+ export function importHostMcp(dataDir: string, hostId: string): McpServer {
325
+ const hit = listHostMcp().find((item) => item.id === hostId);
326
+ if (!hit) throw new Error("host mcp not found");
327
+ return upsertGuildMcp(dataDir, hit.name, hit.launch);
328
+ }
329
+
330
+ export async function listMcpToolRefs(
331
+ dataDir: string,
332
+ home = homedir(),
333
+ ): Promise<McpToolRef[]> {
334
+ const listed = await Promise.all(
335
+ listActiveMcp(dataDir, home).map(async (server) => {
336
+ if (server.launch.url && !server.launch.command) return [];
337
+ try {
338
+ const session = await sessionFor(server);
339
+ const tools = await session.listTools();
340
+ return tools.slice(0, 40).map((tool) => ({
341
+ callName: callName(server.name, tool.name),
342
+ server: server.name,
343
+ tool: tool.name,
344
+ description: `[MCP ${server.name}] ${tool.description || tool.name}`,
345
+ inputSchema: asObjectSchema(tool.inputSchema),
346
+ }));
347
+ } catch {
348
+ return [];
349
+ }
350
+ }),
351
+ );
352
+ return listed.flat().slice(0, 80);
353
+ }
354
+
355
+ export async function callMcpTool(
356
+ dataDir: string,
357
+ call: string,
358
+ args: Record<string, unknown>,
359
+ catalog: McpToolRef[] = [],
360
+ home = homedir(),
361
+ ): Promise<{ text: string; isError: boolean }> {
362
+ const ref =
363
+ catalog.find((item) => item.callName === call) || parseCallName(call);
364
+ if (!ref) return { text: `unknown mcp tool: ${call}`, isError: true };
365
+ const server = listActiveMcp(dataDir, home).find(
366
+ (item) => item.name === ref.server,
367
+ );
368
+ if (!server) return { text: `mcp server not connected: ${ref.server}`, isError: true };
369
+ try {
370
+ const session = await sessionFor(server);
371
+ return await session.callTool(ref.tool, args);
372
+ } catch (error) {
373
+ dropSession(server.name);
374
+ return {
375
+ text: error instanceof Error ? error.message : String(error),
376
+ isError: true,
377
+ };
378
+ }
379
+ }
380
+
381
+ export function callName(server: string, tool: string): string {
382
+ const raw = `mcp__${sanitize(server)}__${sanitize(tool)}`;
383
+ return raw.slice(0, 64);
384
+ }
385
+
386
+ function parseCallName(
387
+ call: string,
388
+ ): { server: string; tool: string; callName: string } | null {
389
+ if (!call.startsWith("mcp__")) return null;
390
+ const rest = call.slice("mcp__".length);
391
+ const idx = rest.indexOf("__");
392
+ if (idx <= 0) return null;
393
+ return {
394
+ server: rest.slice(0, idx),
395
+ tool: rest.slice(idx + 2),
396
+ callName: call,
397
+ };
398
+ }
399
+
400
+ async function sessionFor(server: McpServer): Promise<McpSession> {
401
+ const hit = sessions.get(server.name);
402
+ if (hit) return hit;
403
+ const session = new McpSession(server.launch);
404
+ try {
405
+ await session.ensure();
406
+ } catch (error) {
407
+ session.close();
408
+ throw error;
409
+ }
410
+ sessions.set(server.name, session);
411
+ return session;
412
+ }
413
+
414
+ function dropSession(name: string): void {
415
+ const hit = sessions.get(name);
416
+ if (!hit) return;
417
+ sessions.delete(name);
418
+ hit.close();
419
+ }
420
+
421
+ export function closeMcpSessions(): void {
422
+ for (const name of [...sessions.keys()]) dropSession(name);
423
+ }
424
+
425
+ function asObjectSchema(
426
+ schema: Record<string, unknown> | undefined,
427
+ ): Record<string, unknown> {
428
+ if (schema && schema.type === "object") return schema;
429
+ return { type: "object", properties: {} };
430
+ }
431
+
432
+ function sanitize(value: string): string {
433
+ return value.replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "");
434
+ }
435
+
436
+ function normalizeMap(raw: Record<string, unknown>): Record<string, McpLaunch> {
437
+ const out: Record<string, McpLaunch> = {};
438
+ for (const [name, value] of Object.entries(raw)) {
439
+ const launch = asLaunch(value);
440
+ if (launch) out[sanitize(name) || name] = launch;
441
+ }
442
+ return out;
443
+ }
444
+
445
+ function asLaunch(value: unknown): McpLaunch | null {
446
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
447
+ const rec = value as Record<string, unknown>;
448
+ const command = typeof rec.command === "string" ? rec.command.trim() : "";
449
+ const url = typeof rec.url === "string" ? rec.url.trim() : "";
450
+ const args = Array.isArray(rec.args)
451
+ ? rec.args.filter((item): item is string => typeof item === "string")
452
+ : [];
453
+ const env =
454
+ rec.env && typeof rec.env === "object" && !Array.isArray(rec.env)
455
+ ? Object.fromEntries(
456
+ Object.entries(rec.env as Record<string, unknown>).filter(
457
+ (entry): entry is [string, string] => typeof entry[1] === "string",
458
+ ),
459
+ )
460
+ : undefined;
461
+ const cwd = typeof rec.cwd === "string" ? rec.cwd : undefined;
462
+ if (!command && !url) return null;
463
+ return { command, args, env, cwd, url: url || undefined };
464
+ }
465
+
466
+ function readJsonMcp(file: string, host: string): McpServer[] {
467
+ if (!existsSync(file)) return [];
468
+ try {
469
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as {
470
+ mcpServers?: Record<string, unknown>;
471
+ };
472
+ return Object.entries(normalizeMap(parsed.mcpServers || {})).map(
473
+ ([name, launch]) => ({
474
+ id: `${host.toLowerCase()}:${name}`,
475
+ name,
476
+ source: "host" as const,
477
+ host,
478
+ launch,
479
+ enabled: false,
480
+ }),
481
+ );
482
+ } catch {
483
+ return [];
484
+ }
485
+ }
486
+
487
+ function readClaudeMcp(file: string, host: string): McpServer[] {
488
+ return readJsonMcp(file, host);
489
+ }
490
+
491
+ function readCodexMcp(file: string, host: string): McpServer[] {
492
+ if (!existsSync(file)) return [];
493
+ let text = "";
494
+ try {
495
+ text = readFileSync(file, "utf8");
496
+ } catch {
497
+ return [];
498
+ }
499
+ const blocks = new Map<string, Record<string, string>>();
500
+ let current: string | null = null;
501
+ for (const raw of text.split(/\r?\n/)) {
502
+ const line = raw.trim();
503
+ const header = line.match(/^\[mcp_servers\.([^\].]+)\]$/);
504
+ if (header) {
505
+ current = header[1];
506
+ if (!blocks.has(current)) blocks.set(current, {});
507
+ continue;
508
+ }
509
+ const envHeader = line.match(/^\[mcp_servers\.([^\]]+)\.env\]$/);
510
+ if (envHeader) {
511
+ current = `${envHeader[1]}__env`;
512
+ if (!blocks.has(current)) blocks.set(current, {});
513
+ continue;
514
+ }
515
+ if (current && /^[a-zA-Z0-9_]+\s*=/.test(line)) {
516
+ const eq = line.indexOf("=");
517
+ const key = line.slice(0, eq).trim();
518
+ const val = line.slice(eq + 1).trim();
519
+ blocks.get(current)![key] = val;
520
+ }
521
+ if (line.startsWith("[") && !line.startsWith("[mcp_servers.")) current = null;
522
+ }
523
+ const out: McpServer[] = [];
524
+ for (const [name, fields] of blocks) {
525
+ if (name.endsWith("__env")) continue;
526
+ const envBlock = blocks.get(`${name}__env`) || {};
527
+ const command = unquote(fields.command || "");
528
+ const url = unquote(fields.url || "");
529
+ if (!command && !url) continue;
530
+ out.push({
531
+ id: `${host.toLowerCase()}:${name}`,
532
+ name,
533
+ source: "host",
534
+ host,
535
+ launch: {
536
+ command,
537
+ args: parseTomlArray(fields.args || ""),
538
+ env: Object.fromEntries(
539
+ Object.entries(envBlock).map(([key, value]) => [key, unquote(value)]),
540
+ ),
541
+ url: url || undefined,
542
+ },
543
+ enabled: false,
544
+ });
545
+ }
546
+ return out;
547
+ }
548
+
549
+ function unquote(value: string): string {
550
+ const trimmed = value.trim();
551
+ const m = trimmed.match(/^"(.*)"$/);
552
+ if (m) return m[1].replace(/\\"/g, '"');
553
+ return trimmed;
554
+ }
555
+
556
+ function parseTomlArray(value: string): string[] {
557
+ const inner = value.trim().replace(/^\[/, "").replace(/\]$/, "");
558
+ if (!inner.trim()) return [];
559
+ return inner
560
+ .split(",")
561
+ .map((item) => unquote(item.trim()))
562
+ .filter(Boolean);
563
+ }
package/src/memory.ts ADDED
@@ -0,0 +1,159 @@
1
+ import type { ModelRef } from "@guild/protocol";
2
+ import { llmComplete } from "./llm.ts";
3
+ import type { GuildStore } from "./store.ts";
4
+
5
+ export const MEMORY_FILE_CAP = 8_000;
6
+ export const MEMORY_INJECT_CAP = 3_500;
7
+
8
+ const GREETING =
9
+ /^(hi|hello|hey|yo|sup|早安|午安|晚安|大家好|哈囉|嗨|你好)[\s!!。.~…]*$/i;
10
+
11
+ export function clipMemory(text: string, cap = MEMORY_FILE_CAP): string {
12
+ const raw = String(text || "").replace(/\r\n/g, "\n").trim();
13
+ if (raw.length <= cap) return raw;
14
+ return raw.slice(0, cap - 1).trimEnd() + "…";
15
+ }
16
+
17
+ export function redactSecrets(text: string): string {
18
+ return String(text || "")
19
+ .replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "[redacted-key]")
20
+ .replace(/\bBearer\s+[A-Za-z0-9._\-]{8,}\b/gi, "Bearer [redacted]")
21
+ .replace(/\b(api[_-]?key|secret|token)\s*[:=]\s*\S+/gi, "$1=[redacted]");
22
+ }
23
+
24
+ export function shouldHarvestMemory(userMessage: string, reply = ""): boolean {
25
+ const user = String(userMessage || "").trim();
26
+ const assistant = String(reply || "").trim();
27
+ if (!user && !assistant) return false;
28
+ if (GREETING.test(user) && assistant.length < 80) return false;
29
+ if (user.length + assistant.length < 28) return false;
30
+ if (/沒有可用模型/.test(assistant)) return false;
31
+ return true;
32
+ }
33
+
34
+ export function applyMemoryUpdate(
35
+ current: string,
36
+ extracted: string | null | undefined,
37
+ ): string | null {
38
+ if (extracted == null) return null;
39
+ const text = redactSecrets(String(extracted).replace(/\r\n/g, "\n")).trim();
40
+ if (!text) return null;
41
+ const first = text.split("\n")[0].trim();
42
+ if (/^NO_CHANGE$/i.test(first) || /^NO_CHANGE$/i.test(text)) return null;
43
+ if (looksLikeError(text)) return null;
44
+ if (text.length < 8) return null;
45
+ const next = clipMemory(text);
46
+ const prev = String(current || "").trim();
47
+ if (next === prev) return null;
48
+ return next;
49
+ }
50
+
51
+ function looksLikeError(text: string): boolean {
52
+ return /模型請求|訂閱.*失效|unauthorized|login failed|ECONNREFUSED/i.test(
53
+ text.slice(0, 400),
54
+ );
55
+ }
56
+
57
+ function extractPrompt(scope: "bot" | "channel", current: string, turn: string): string {
58
+ const who =
59
+ scope === "bot"
60
+ ? "this bot and the user"
61
+ : "this channel (shared by everyone in the room)";
62
+ return `You maintain MEMORY.md for ${who}.
63
+ Standing notes only: names, preferences, decisions, recurring work, conventions, ownership, tech.
64
+ Do not record greetings, the current date/time, one-off questions, secrets, passwords, or API keys.
65
+ Keep useful old bullets. Drop stale or contradicted ones. Max 80 lines.
66
+
67
+ Current MEMORY.md:
68
+ <<<
69
+ ${current.trim() || "(empty)"}
70
+ >>>
71
+
72
+ New turn:
73
+ ${turn.trim()}
74
+
75
+ Reply with the complete updated MEMORY.md, or exactly NO_CHANGE.`;
76
+ }
77
+
78
+ export async function extractMemory(input: {
79
+ dataDir: string;
80
+ env?: NodeJS.ProcessEnv;
81
+ prefer?: ModelRef | null;
82
+ scope: "bot" | "channel";
83
+ current: string;
84
+ turn: string;
85
+ }): Promise<string | null> {
86
+ const result = await llmComplete({
87
+ dataDir: input.dataDir,
88
+ env: input.env,
89
+ role: "compression",
90
+ prefer: input.prefer,
91
+ tools: false,
92
+ temperature: 0.1,
93
+ system:
94
+ "You rewrite MEMORY.md. Output markdown or NO_CHANGE. No preamble.",
95
+ messages: [
96
+ {
97
+ role: "user",
98
+ content: extractPrompt(input.scope, input.current, input.turn),
99
+ },
100
+ ],
101
+ });
102
+ return result?.text ?? null;
103
+ }
104
+
105
+ export async function harvestBotMemory(input: {
106
+ store: GuildStore;
107
+ botId: string;
108
+ userMessage: string;
109
+ reply: string;
110
+ env?: NodeJS.ProcessEnv;
111
+ prefer?: ModelRef | null;
112
+ }): Promise<{ updated: boolean; body: string }> {
113
+ const current = input.store.readBotMemory(input.botId);
114
+ if (!shouldHarvestMemory(input.userMessage, input.reply)) {
115
+ return { updated: false, body: current };
116
+ }
117
+ const extracted = await extractMemory({
118
+ dataDir: input.store.dataDir,
119
+ env: input.env,
120
+ prefer: input.prefer,
121
+ scope: "bot",
122
+ current,
123
+ turn: `User: ${input.userMessage}\nAssistant: ${input.reply}`,
124
+ });
125
+ const next = applyMemoryUpdate(current, extracted);
126
+ if (next == null) return { updated: false, body: current };
127
+ return { updated: true, body: input.store.writeBotMemory(input.botId, next) };
128
+ }
129
+
130
+ export async function harvestChannelMemory(input: {
131
+ store: GuildStore;
132
+ roomId: string;
133
+ userMessage: string;
134
+ replies: { handle?: string; author: string; body: string }[];
135
+ env?: NodeJS.ProcessEnv;
136
+ prefer?: ModelRef | null;
137
+ }): Promise<{ updated: boolean; body: string }> {
138
+ const current = input.store.readChannelMemory(input.roomId);
139
+ const lines = input.replies
140
+ .map((item) => `@${item.handle || item.author}: ${item.body}`)
141
+ .join("\n");
142
+ if (!shouldHarvestMemory(input.userMessage, lines)) {
143
+ return { updated: false, body: current };
144
+ }
145
+ const extracted = await extractMemory({
146
+ dataDir: input.store.dataDir,
147
+ env: input.env,
148
+ prefer: input.prefer,
149
+ scope: "channel",
150
+ current,
151
+ turn: `User: ${input.userMessage}\n${lines}`,
152
+ });
153
+ const next = applyMemoryUpdate(current, extracted);
154
+ if (next == null) return { updated: false, body: current };
155
+ return {
156
+ updated: true,
157
+ body: input.store.writeChannelMemory(input.roomId, next),
158
+ };
159
+ }