@chloejs/core 0.2.0

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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +221 -0
  3. package/channels/api.ts +41 -0
  4. package/channels/shared.ts +250 -0
  5. package/channels/slack.ts +390 -0
  6. package/channels/telegram.ts +396 -0
  7. package/core/clock.ts +126 -0
  8. package/core/confine.ts +45 -0
  9. package/core/db.ts +117 -0
  10. package/core/markdown.ts +95 -0
  11. package/core/notes.ts +44 -0
  12. package/core/paths.ts +29 -0
  13. package/core/root.ts +26 -0
  14. package/core/settings.ts +124 -0
  15. package/core/steps.ts +896 -0
  16. package/core/turn.ts +314 -0
  17. package/do/email.ts +45 -0
  18. package/do/files.ts +96 -0
  19. package/do/mail.ts +155 -0
  20. package/do/run.ts +56 -0
  21. package/do/scripts.ts +49 -0
  22. package/do/web.ts +192 -0
  23. package/index.ts +52 -0
  24. package/load/job.ts +84 -0
  25. package/load/load.ts +478 -0
  26. package/model/ask.ts +84 -0
  27. package/model/claude.ts +261 -0
  28. package/model/memory.ts +68 -0
  29. package/model/model.ts +185 -0
  30. package/model/tool.ts +53 -0
  31. package/model/tools/files.ts +71 -0
  32. package/model/tools/gmail.ts +43 -0
  33. package/model/tools/index.ts +28 -0
  34. package/model/tools/memory.ts +23 -0
  35. package/model/tools/run_script.ts +44 -0
  36. package/model/tools/send_email.ts +29 -0
  37. package/model/tools/web.ts +23 -0
  38. package/model/tools/write_skill.ts +31 -0
  39. package/ops/account.ts +109 -0
  40. package/ops/agent.ts +290 -0
  41. package/ops/check.ts +37 -0
  42. package/ops/evals.ts +206 -0
  43. package/ops/install.sh +101 -0
  44. package/ops/test.ts +1976 -0
  45. package/package.json +65 -0
  46. package/scorers/calls.ts +50 -0
  47. package/scorers/expectations.ts +118 -0
  48. package/scorers/index.ts +5 -0
  49. package/serve/alerts.ts +79 -0
  50. package/serve/errors.ts +10 -0
  51. package/serve/files.ts +70 -0
  52. package/serve/http.ts +767 -0
  53. package/serve/login.ts +299 -0
  54. package/serve/memory.ts +372 -0
  55. package/serve/page.ts +142 -0
  56. package/serve/pass.ts +45 -0
  57. package/serve/recentWork.ts +69 -0
  58. package/serve/site.ts +409 -0
  59. package/serve/tokens.ts +132 -0
  60. package/server.ts +170 -0
  61. package/timer/cron.ts +92 -0
  62. package/timer/every.ts +153 -0
  63. package/timer/index.ts +4 -0
package/serve/http.ts ADDED
@@ -0,0 +1,767 @@
1
+ // Every route is in this file, so what is reachable from outside is this file
2
+ // and nothing else, plus whatever paths a running channel answers. It binds
3
+ // loopback. What answers the addresses that are not /api is site.ts.
4
+ //
5
+ // The routes are a list rather than a run of ifs, because the docs at GET /api
6
+ // are generated from that list. A route nobody wrote down is a route nobody
7
+ // documented, and a documented route that does not exist is worse than either.
8
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
9
+
10
+ import { z } from "zod";
11
+
12
+ import { db } from "#chloe/core/db.ts";
13
+ import { hasChannel, type Agent, type ChannelRoute, type Job } from "#chloe/load/load.ts";
14
+ import type { Clock } from "#chloe/core/clock.ts";
15
+ import { forget, recall } from "#chloe/model/memory.ts";
16
+ import { editable, open, save, tree } from "./files.ts";
17
+ import {
18
+ memoryCommit,
19
+ memoryDelete,
20
+ memoryGit,
21
+ memoryLabel,
22
+ memoryLog,
23
+ memoryOpen,
24
+ memoryPull,
25
+ memoryPush,
26
+ memoryRaw,
27
+ memoryRename,
28
+ memorySave,
29
+ memoryTree,
30
+ } from "./memory.ts";
31
+ import { checkPass, makePass } from "./pass.ts";
32
+ import { BadRequest, NotFound } from "./errors.ts";
33
+ import { recentWork } from "./recentWork.ts";
34
+ import { describe } from "#chloe/timer/every.ts";
35
+ import { type Caller, caller, createAccount, from, hasAccount, overHttps, setCookie, signIn } from "./login.ts";
36
+ import { makeToken, revokeToken, tokens } from "./tokens.ts";
37
+ import { signedInFrom } from "./alerts.ts";
38
+ import { docsPage, type RouteDoc, sitePage } from "./site.ts";
39
+ import { receive } from "#chloe/channels/shared.ts";
40
+ import { answer, checkInput, parkedRuns } from "#chloe/core/steps.ts";
41
+
42
+ /**
43
+ * Where this server listens. Loopback, and one port for the agents, the API
44
+ * and the site alike. Written down here, beside the only thing that binds it,
45
+ * and not settable: a port that moves is a tunnel that stops finding it.
46
+ */
47
+ export const HOST = "127.0.0.1";
48
+ export const PORT = 3067;
49
+
50
+ export interface Context {
51
+ agent(name: string): Agent;
52
+ agents(): Map<string, Agent>;
53
+ clock: Clock;
54
+ body: typeof body;
55
+ json(response: ServerResponse, value: unknown, status?: number): void;
56
+ }
57
+
58
+ /** Everything one route handler is given. */
59
+ export interface At {
60
+ request: IncomingMessage;
61
+ response: ServerResponse;
62
+ url: URL;
63
+ /** Whatever the `:parts` of this route's path caught. */
64
+ params: Record<string, string>;
65
+ context: Context;
66
+ who: Caller;
67
+ }
68
+
69
+ /**
70
+ * One route. It carries its own documentation because GET /api is generated
71
+ * from this list: a route and the line describing it cannot drift apart when
72
+ * they are the same object.
73
+ *
74
+ * path like `/api/agents/:name/log`. A `:part` matches one segment.
75
+ * open answered without a session or a token.
76
+ * token a token may call it. Without this, only the account may.
77
+ * needsApiChannel for a token, only when that agent binds an api channel.
78
+ */
79
+ export interface Route extends RouteDoc {
80
+ method: "GET" | "POST";
81
+ handle(at: At): Promise<void> | void;
82
+ }
83
+
84
+ /** Whether an agent has opted in to being reached by another system. */
85
+ function onTheApi(agent: Agent): boolean {
86
+ return hasChannel(agent, "api");
87
+ }
88
+
89
+ /** One agent's configuration, the same shape from the list and from its own route. */
90
+ function summary(agent: Agent) {
91
+ return {
92
+ name: agent.name,
93
+ label: agent.label,
94
+ description: agent.description,
95
+ model: agent.model,
96
+ channels: agent.channels.map((one) => one.name).sort(),
97
+ /** Whether a token may chat to it or run its jobs. */
98
+ api: onTheApi(agent),
99
+ /** What the site calls its memory. Every agent has one. */
100
+ memory: memoryLabel(agent),
101
+ tools: Object.keys(agent.tools ?? {}).sort(),
102
+ skills: agent.skills.map((s) => s.name),
103
+ jobs: agent.jobs.map((s) => ({
104
+ id: s.id,
105
+ description: s.description,
106
+ cron: s.cron,
107
+ // In words when it is one every() could have written, like "weekdays at 09:30 UTC".
108
+ when: s.cron ? describe(s.cron, s.timezone) : undefined,
109
+ timezone: s.timezone,
110
+ // A job made of code has no model until one of its steps asks for one.
111
+ model: s.run ? "code" : s.model ?? agent.model,
112
+ code: Boolean(s.run),
113
+ files: s.files,
114
+ })),
115
+ };
116
+ }
117
+
118
+ /**
119
+ * `npm run agent` comes in over the API too, and says so in a header, so the
120
+ * log can tell a person at a terminal from another system. Anything else is "api".
121
+ */
122
+ function channelOf(request: IncomingMessage): string {
123
+ return request.headers["x-chloe-channel"] === "terminal" ? "terminal" : "api";
124
+ }
125
+
126
+ const RUN_COLUMNS = "id, agent, started, finished, source, job, model, steps, cost, error, reply, summary";
127
+
128
+ export const routes: Route[] = [
129
+ {
130
+ method: "GET",
131
+ path: "/api",
132
+ does: "This list: every route, what it does and who may call it.",
133
+ open: true,
134
+ handle: ({ request, response }) => {
135
+ // A browser gets the page. Anything else gets the same thing as JSON.
136
+ if ((request.headers.accept ?? "").includes("text/html")) return void html(response, docsPage(routes));
137
+ json(
138
+ response,
139
+ routes.map((one) => ({
140
+ method: one.method,
141
+ path: one.path,
142
+ does: one.does,
143
+ takes: one.takes,
144
+ who: one.open ? "anybody" : one.token ? "account or token" : "account",
145
+ needsApiChannel: one.needsApiChannel || undefined,
146
+ })),
147
+ );
148
+ },
149
+ },
150
+
151
+ // The ways in. These four are the whole of what is answered without a
152
+ // session, and each says as little as it can.
153
+ {
154
+ method: "GET",
155
+ path: "/api/account",
156
+ does: "Whether this copy has an account yet, so a page knows which form to show.",
157
+ open: true,
158
+ handle: ({ response }) => json(response, { exists: hasAccount() }),
159
+ },
160
+ {
161
+ method: "POST",
162
+ path: "/api/login",
163
+ does: "Sign in. Sets the cookie, and hands back the same value for anything that is not a browser.",
164
+ takes: '{"username": "...", "password": "..."}',
165
+ open: true,
166
+ handle: (at) => wayIn(at, false),
167
+ },
168
+ {
169
+ method: "POST",
170
+ path: "/api/setup",
171
+ does: "Make the one account, when there is none. Refused once one exists.",
172
+ takes: '{"username": "...", "password": "..."}',
173
+ open: true,
174
+ handle: (at) => wayIn(at, true),
175
+ },
176
+ {
177
+ method: "POST",
178
+ path: "/api/logout",
179
+ does: "End the session.",
180
+ open: true,
181
+ handle: ({ request, response }) => {
182
+ response.setHeader("set-cookie", setCookie("", overHttps(request)));
183
+ json(response, { ok: true });
184
+ },
185
+ },
186
+
187
+ // Reading. A token may do all of this.
188
+ {
189
+ method: "GET",
190
+ path: "/api/agents",
191
+ does: "Every agent that is loaded, with its configuration.",
192
+ token: true,
193
+ handle: ({ response, context }) => json(response, [...context.agents().values()].map(summary)),
194
+ },
195
+ {
196
+ method: "GET",
197
+ path: "/api/agents/:name",
198
+ does: "One agent's configuration: its model, tools, skills, channels and jobs.",
199
+ token: true,
200
+ handle: ({ response, context, params }) => json(response, summary(context.agent(params.name))),
201
+ },
202
+ {
203
+ method: "GET",
204
+ path: "/api/agents/:name/log",
205
+ does: "That agent's runs, newest first. Takes ?limit=, at most 200.",
206
+ token: true,
207
+ handle: ({ response, context, params, url }) => {
208
+ context.agent(params.name);
209
+ const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), 200);
210
+ json(
211
+ response,
212
+ db
213
+ .prepare(`select ${RUN_COLUMNS} from runs where agent = ? order by started desc limit ?`)
214
+ .all(params.name, limit),
215
+ );
216
+ },
217
+ },
218
+ {
219
+ method: "GET",
220
+ path: "/api/agents/:name/files",
221
+ does: "That agent's own folder as a tree: its instructions, skills, scripts and jobs.",
222
+ token: true,
223
+ handle: async ({ response, context, params }) => {
224
+ context.agent(params.name);
225
+ json(response, await tree(params.name));
226
+ },
227
+ },
228
+ {
229
+ method: "GET",
230
+ path: "/api/agents/:name/file",
231
+ does: "One file in that folder. Takes ?path=, and a folder answers with what is in it.",
232
+ token: true,
233
+ handle: async ({ response, context, params, url }) => {
234
+ context.agent(params.name);
235
+ const path = url.searchParams.get("path");
236
+ if (!path) return json(response, { error: "No path." }, 400);
237
+ const found = await open(params.name, path);
238
+ if (!found) throw new NotFound(`${params.name} has no ${path}.`);
239
+ json(response, found);
240
+ },
241
+ },
242
+ {
243
+ method: "GET",
244
+ path: "/api/agents/:name/threads",
245
+ does: "That agent's conversations, newest first, however they were started.",
246
+ token: true,
247
+ handle: ({ response, context, params }) => {
248
+ context.agent(params.name);
249
+ json(
250
+ response,
251
+ db
252
+ .prepare(
253
+ "select thread, count(*) as messages, max(at) as last from messages where thread like ? group by thread order by last desc limit 20",
254
+ )
255
+ .all(`${params.name}/%`),
256
+ );
257
+ },
258
+ },
259
+ {
260
+ method: "GET",
261
+ path: "/api/runs",
262
+ does: "Every agent's runs, newest first. Takes ?agent= and ?limit=.",
263
+ token: true,
264
+ handle: ({ response, url }) => {
265
+ const agent = url.searchParams.get("agent");
266
+ const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), 200);
267
+ json(
268
+ response,
269
+ agent
270
+ ? db.prepare(`select ${RUN_COLUMNS} from runs where agent = ? order by started desc limit ?`).all(agent, limit)
271
+ : db.prepare(`select ${RUN_COLUMNS} from runs order by started desc limit ?`).all(limit),
272
+ );
273
+ },
274
+ },
275
+ {
276
+ method: "GET",
277
+ path: "/api/runs/:id",
278
+ does: "One run in full, with every step it took.",
279
+ token: true,
280
+ handle: ({ response, params }) => {
281
+ const row = db.prepare("select * from runs where id = ?").get(params.id) as { trace: string } | undefined;
282
+ if (!row) throw new NotFound("No run with that id.");
283
+ json(response, { ...row, trace: JSON.parse(row.trace) });
284
+ },
285
+ },
286
+ {
287
+ method: "GET",
288
+ path: "/api/threads/:thread",
289
+ does: "What was said in one conversation.",
290
+ token: true,
291
+ handle: ({ response, params }) => json(response, recall(decodeURIComponent(params.thread), { limit: 100 })),
292
+ },
293
+ {
294
+ method: "GET",
295
+ path: "/api/parked",
296
+ does: "Every job waiting on an answer. A question nobody answers is a job that never finishes.",
297
+ token: true,
298
+ handle: ({ response }) => json(response, parkedRuns()),
299
+ },
300
+ {
301
+ method: "GET",
302
+ path: "/api/recent-work",
303
+ does: "What each agent has done lately, as its own jobs record it.",
304
+ token: true,
305
+ handle: ({ response, context }) =>
306
+ json(response, Object.fromEntries([...context.agents().values()].map((a) => [a.name, recentWork(a)]))),
307
+ },
308
+ {
309
+ method: "GET",
310
+ path: "/api/health",
311
+ does: "Which agents are loaded and which jobs are running right now.",
312
+ token: true,
313
+ handle: ({ response, context }) =>
314
+ json(response, { ok: true, agents: [...context.agents().keys()], running: context.clock.running() }),
315
+ },
316
+
317
+ // Doing. A token may do these, and only to an agent that binds an api channel.
318
+ {
319
+ method: "POST",
320
+ path: "/api/agents/:name/chat",
321
+ does: "One turn with the agent. Send the same thread again and it remembers what was said.",
322
+ takes: '{"prompt": "...", "thread": "a name of your own, optional", "model": "optional"}',
323
+ token: true,
324
+ needsApiChannel: true,
325
+ handle: async ({ request, response, context, params, who }) => {
326
+ const agent = context.agent(params.name);
327
+ const { prompt, thread, model } = await body(
328
+ request,
329
+ z.object({ prompt: z.string().trim().min(1), thread: z.string().optional(), model: z.string().optional() }),
330
+ );
331
+ // A token's threads are kept apart from the ones a person started, so two
332
+ // callers cannot land in each other's conversation.
333
+ const token = who?.kind === "token";
334
+ const under = token && thread ? `${agent.name}/api-${thread}` : (thread ?? "");
335
+ // The same path as every channel's message, so a /command or a message a
336
+ // job answers goes to that job here too. Who may call this is already
337
+ // settled by the login or the token, so there is no allowFrom.
338
+ // Somebody signed in with a thread is the page's own chat, which the api
339
+ // channel's settings are not for.
340
+ const channel = !token && thread ? "chat" : channelOf(request);
341
+ const handled = await receive(agent, {
342
+ channel,
343
+ chat: under,
344
+ thread: under,
345
+ from: token ? { id: who.token.id, name: who.token.name } : { id: "account", name: "the account" },
346
+ text: prompt,
347
+ private: true,
348
+ model,
349
+ }, { chatHistory: channel === "chat" ? undefined : agent.channels.find((one) => one.name === "api")?.chatHistory });
350
+ json(response, handled);
351
+ },
352
+ },
353
+ {
354
+ method: "POST",
355
+ path: "/api/agents/:name/job/:job",
356
+ does: "Run one of that agent's jobs now, rather than waiting for its cron line. Takes what that job's input shape says.",
357
+ takes: 'whatever the job declares, as JSON or as a query string: {"text": "..."}',
358
+ token: true,
359
+ needsApiChannel: true,
360
+ handle: async ({ request, response, context, params, url }) => {
361
+ const agent = context.agent(params.name);
362
+ const job = agent.jobs.find((one: Job) => one.id === params.job);
363
+ if (!job) throw new NotFound(`${agent.name} has no job called ${JSON.stringify(params.job)}.`);
364
+
365
+ // A query string for the one-liner case, a body for anything with
366
+ // newlines or numbers in it, and the body wins where they overlap.
367
+ // Everything in a query string is a string, so a job that wants a number
368
+ // says z.coerce.number().
369
+ const sent = {
370
+ ...Object.fromEntries(url.searchParams),
371
+ ...(await body(request, z.record(z.string(), z.unknown()))),
372
+ };
373
+
374
+ // Checked here rather than left to the run, because the run is started
375
+ // and not awaited: a caller that sent the wrong thing would otherwise get
376
+ // "started" and have to go and read a failed run to find out it was not.
377
+ try {
378
+ checkInput(job, sent);
379
+ } catch (error) {
380
+ return json(response, { error: (error as Error).message }, 400);
381
+ }
382
+
383
+ void context.clock.fire(agent, job, sent, channelOf(request));
384
+ json(response, { started: `${agent.name}/${job.id}`, log: `/api/agents/${agent.name}/log` });
385
+ },
386
+ },
387
+
388
+ // Writing, and the tokens themselves. The account and nothing else.
389
+ {
390
+ method: "POST",
391
+ path: "/api/agents/:name/file",
392
+ does: "Write a file in that agent's folder back. Markdown only: code is edited where the type checker runs.",
393
+ takes: '{"path": "skills/x.md", "content": "..."}',
394
+ handle: async ({ request, response, context, params }) => {
395
+ context.agent(params.name);
396
+ const { path, content } = await body(request, z.object({ path: z.string(), content: z.string() }));
397
+ if (!editable(path)) {
398
+ return json(response, { error: `${path} is not markdown, so it cannot be written from here.` }, 400);
399
+ }
400
+ json(response, await save(params.name, path, content));
401
+ },
402
+ },
403
+ {
404
+ method: "POST",
405
+ path: "/api/runs/:id/answer",
406
+ does: "Answer a job that stopped to ask something, from here rather than on the channel it asked on.",
407
+ takes: '{"text": "..."}',
408
+ handle: async ({ request, response, context, params }) => {
409
+ const { text } = await body(request, z.object({ text: z.string().trim().min(1) }));
410
+ json(response, await answer(params.id, text, context.agents()));
411
+ },
412
+ },
413
+ {
414
+ method: "POST",
415
+ path: "/api/threads/:thread/forget",
416
+ does: "Forget one conversation.",
417
+ handle: ({ response, params }) => {
418
+ forget(decodeURIComponent(params.thread));
419
+ json(response, { forgotten: decodeURIComponent(params.thread) });
420
+ },
421
+ },
422
+ {
423
+ method: "GET",
424
+ path: "/api/tokens",
425
+ does: "Every token, revoked ones included. Never the secrets: they were not kept.",
426
+ handle: ({ response }) => json(response, tokens()),
427
+ },
428
+ {
429
+ method: "POST",
430
+ path: "/api/tokens",
431
+ does: "Make a token. The only time the secret exists in one piece is in this reply.",
432
+ takes: '{"name": "what it is for"}',
433
+ handle: async ({ request, response }) => {
434
+ const { name } = await body(request, z.object({ name: z.string().trim().min(1) }));
435
+ const { secret, token } = makeToken(name);
436
+ json(response, { ...token, secret });
437
+ },
438
+ },
439
+ {
440
+ method: "POST",
441
+ path: "/api/tokens/:id/revoke",
442
+ does: "Stop a token working, now.",
443
+ handle: ({ response, params }) => json(response, revokeToken(params.id)),
444
+ },
445
+
446
+ // Where an agent remembers things. Every agent has one, and none of these
447
+ // takes a token: see memory.ts for why every read is written down first.
448
+ {
449
+ method: "GET",
450
+ path: "/api/agents/:name/memory",
451
+ does: "That agent's memory as a tree. Empty when it has never written anything. Recorded like a read.",
452
+ handle: async ({ request, response, context, params }) =>
453
+ json(response, await memoryTree(context.agent(params.name), from(request))),
454
+ },
455
+ {
456
+ method: "GET",
457
+ path: "/api/agents/:name/memory/file",
458
+ does: "One file as text, for editing. Takes ?path=. Written to the audit log before it is sent.",
459
+ handle: async ({ request, response, context, params, url }) => {
460
+ const path = url.searchParams.get("path");
461
+ if (!path) return json(response, { error: "No path." }, 400);
462
+ const found = await memoryOpen(context.agent(params.name), path, from(request));
463
+ if (!found) throw new NotFound(`Nothing at ${path}.`);
464
+ json(response, found);
465
+ },
466
+ },
467
+ {
468
+ method: "POST",
469
+ path: "/api/agents/:name/memory/file",
470
+ does: "Write a file back. A commit too, when the memory is a repo and the agent says to commit.",
471
+ takes: '{"path": "01_projects/x.html", "content": "..."}',
472
+ handle: async ({ request, response, context, params }) => {
473
+ const { path, content } = await body(request, z.object({ path: z.string(), content: z.string() }));
474
+ json(response, await memorySave(context.agent(params.name), path, content, from(request)));
475
+ },
476
+ },
477
+ {
478
+ method: "POST",
479
+ path: "/api/agents/:name/memory/rename",
480
+ does: "Move a file or a folder inside that memory.",
481
+ takes: '{"from": "a.html", "to": "b/a.html"}',
482
+ handle: async ({ request, response, context, params }) => {
483
+ const moved = await body(request, z.object({ from: z.string().min(1), to: z.string().min(1) }));
484
+ json(response, await memoryRename(context.agent(params.name), moved.from, moved.to, from(request)));
485
+ },
486
+ },
487
+ {
488
+ method: "POST",
489
+ path: "/api/agents/:name/memory/delete",
490
+ does: "Delete a file or a folder. In a repo, git still has it.",
491
+ takes: '{"path": "a.html"}',
492
+ handle: async ({ request, response, context, params }) => {
493
+ const { path } = await body(request, z.object({ path: z.string().min(1) }));
494
+ json(response, await memoryDelete(context.agent(params.name), path, from(request)));
495
+ },
496
+ },
497
+ {
498
+ method: "GET",
499
+ path: "/api/agents/:name/memory/pass",
500
+ does: "A pass to show that memory's files in a frame, for ten minutes. See pass.ts.",
501
+ handle: ({ response, context, params }) => {
502
+ const pass = makePass(context.agent(params.name).name);
503
+ json(response, { pass, at: `/memory/${encodeURIComponent(pass)}` });
504
+ },
505
+ },
506
+ {
507
+ method: "GET",
508
+ path: "/api/agents/:name/memory/log",
509
+ does: "That agent's audit log: every file read, served, written, moved or deleted, when, and from where.",
510
+ handle: async ({ response, context, params, url }) =>
511
+ json(
512
+ response,
513
+ await memoryLog(context.agent(params.name), Math.min(Number(url.searchParams.get("limit") ?? 200), 1000)),
514
+ ),
515
+ },
516
+ {
517
+ method: "GET",
518
+ path: "/api/agents/:name/memory/git",
519
+ does: "Source control for that memory, when it is a repo: what changed, the branch, and the recent history.",
520
+ handle: async ({ response, context, params }) => json(response, await memoryGit(context.agent(params.name))),
521
+ },
522
+ {
523
+ method: "POST",
524
+ path: "/api/agents/:name/memory/git/commit",
525
+ does: "Commit everything that changed in that memory.",
526
+ takes: '{"message": "..."}',
527
+ handle: async ({ request, response, context, params }) => {
528
+ const { message } = await body(request, z.object({ message: z.string() }));
529
+ json(response, await memoryCommit(context.agent(params.name), message, from(request)));
530
+ },
531
+ },
532
+ {
533
+ method: "POST",
534
+ path: "/api/agents/:name/memory/git/push",
535
+ does: "Push that memory's commits. Says where they went, because this is what sends them off the box.",
536
+ handle: async ({ request, response, context, params }) =>
537
+ json(response, await memoryPush(context.agent(params.name), from(request))),
538
+ },
539
+ {
540
+ method: "POST",
541
+ path: "/api/agents/:name/memory/git/pull",
542
+ does: "Pull, fast-forward only.",
543
+ handle: async ({ request, response, context, params }) =>
544
+ json(response, await memoryPull(context.agent(params.name), from(request))),
545
+ },
546
+ ];
547
+
548
+ /** Signing in, and making the account the first time. One body, two doors. */
549
+ async function wayIn({ request, response }: At, making: boolean): Promise<void> {
550
+ const { username, password } = await body(request, z.object({ username: z.string(), password: z.string() }));
551
+ try {
552
+ if (making) createAccount(username, password);
553
+ // The same value twice: the cookie for a browser, and the body for
554
+ // anything that is not one.
555
+ const at = from(request);
556
+ const token = signIn(username, password, at);
557
+ signedInFrom(username, at);
558
+ response.setHeader("set-cookie", setCookie(token, overHttps(request)));
559
+ json(response, { ok: true, token });
560
+ } catch (error) {
561
+ json(response, { error: (error as Error).message }, 401);
562
+ }
563
+ }
564
+
565
+ /** The route whose path matches, with whatever its `:parts` caught. */
566
+ function match(method: string, path: string): { route: Route; params: Record<string, string> } | undefined {
567
+ const parts = path.split("/").filter(Boolean);
568
+ for (const route of routes) {
569
+ if (route.method !== method) continue;
570
+ const wanted = route.path.split("/").filter(Boolean);
571
+ if (wanted.length !== parts.length) continue;
572
+ const params: Record<string, string> = {};
573
+ let ok = true;
574
+ for (let at = 0; at < wanted.length; at++) {
575
+ if (wanted[at].startsWith(":")) params[wanted[at].slice(1)] = parts[at];
576
+ else if (wanted[at] !== parts[at]) {
577
+ ok = false;
578
+ break;
579
+ }
580
+ }
581
+ if (ok) return { route, params };
582
+ }
583
+ }
584
+
585
+ export function serve(options: {
586
+ host: string;
587
+ port: number;
588
+ agents: () => Map<string, Agent>;
589
+ clock: Clock;
590
+ /** What the running channels answer, asked again on every request because a channel can start or stop. */
591
+ channels: () => ChannelRoute[];
592
+ }) {
593
+ const context: Context = {
594
+ agents: options.agents,
595
+ agent(name) {
596
+ const found = options.agents().get(name);
597
+ if (!found) throw new NotFound(`There is no agent called ${JSON.stringify(name)}.`);
598
+ return found;
599
+ },
600
+ clock: options.clock,
601
+ body,
602
+ json,
603
+ };
604
+
605
+ const server = createServer(async (request, response) => {
606
+ const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
607
+ const path = url.pathname.replace(/\/+$/, "") || "/";
608
+ try {
609
+ const channel = request.method === "POST" ? options.channels().find((one) => one.path === path) : undefined;
610
+ if (channel) return await channel.handle(request, response);
611
+ if (path === "/api" || path.startsWith("/api/")) return await api(request, response, path, url, context);
612
+ if (path.startsWith("/memory/") && request.method === "GET") return await framed(request, response, url, context);
613
+ await sitePage(request, response, path, context);
614
+ } catch (error) {
615
+ if (error instanceof NotFound) return json(response, { error: error.message }, 404);
616
+ if (error instanceof BadRequest) return json(response, { error: error.message }, 400);
617
+ console.error(`${request.method} ${path}:`, error);
618
+ json(response, { error: error instanceof Error ? error.message : String(error) }, 500);
619
+ }
620
+ });
621
+
622
+ server.listen(options.port, options.host);
623
+ return server;
624
+ }
625
+
626
+ /**
627
+ * A memory file, for the frame it is shown in: `/memory/<pass>/<path>`.
628
+ *
629
+ * The pass says whose memory and that it is still good, and it is the only
630
+ * thing that does: no cookie is looked at. That is deliberate, because the
631
+ * document is sandboxed and its own requests carry no cookie anyway.
632
+ *
633
+ * The headers are the security of the whole viewer, so each earns its line:
634
+ *
635
+ * sandbox allow-scripts the file runs its own script, in an origin of its
636
+ * own. It cannot touch the page around it, and it
637
+ * cannot call the API as the person signed in, even
638
+ * opened on its own in a tab. Without this, a note an
639
+ * agent wrote from somebody's email could make a
640
+ * token and walk off with the account.
641
+ * allow-popups... so a link in a note still opens.
642
+ * connect-src 'none' no fetch, no XHR, no socket: a script can read the
643
+ * pass in its own address, but cannot send it
644
+ * anywhere.
645
+ * img-src, style-src... what the notes actually use, and only from here,
646
+ * so an image cannot be a way out either.
647
+ * frame-ancestors 'self' only this site may frame it.
648
+ */
649
+ async function framed(request: IncomingMessage, response: ServerResponse, url: URL, context: Context): Promise<void> {
650
+ const [, , raw = "", ...rest] = url.pathname.split("/");
651
+ const pass = decodeURIComponent(raw);
652
+ const whose = checkPass(pass);
653
+ const refuse = (status: number, text: string): void =>
654
+ void response.writeHead(status, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }).end(text);
655
+ if (!whose) return refuse(403, "This pass has run out. Open the file again.");
656
+ const agent = context.agents().get(whose);
657
+ if (!agent) return refuse(404, "Not here.");
658
+
659
+ const path = rest.map(decodeURIComponent).join("/");
660
+ const found = await memoryRaw(agent, path, from(request), `/memory/${raw}`);
661
+ if (!found) return refuse(404, "Not here.");
662
+
663
+ const here = `${overHttps(request) ? "https" : "http"}://${request.headers.host ?? "localhost"}`;
664
+ // 'self' and the site's own origin both, because what 'self' means for a
665
+ // sandboxed document is the one thing browsers have not always agreed on.
666
+ const own = `'self' ${here}`;
667
+ response.writeHead(200, {
668
+ "content-type": found.type,
669
+ "cache-control": "no-store",
670
+ "x-content-type-options": "nosniff",
671
+ "x-robots-tag": "noindex, nofollow, noarchive",
672
+ "referrer-policy": "no-referrer",
673
+ "content-security-policy": [
674
+ "sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox",
675
+ `default-src ${own}`,
676
+ `script-src ${own} 'unsafe-inline' https://cdnjs.cloudflare.com`,
677
+ `style-src ${own} 'unsafe-inline' https://fonts.googleapis.com`,
678
+ `font-src ${own} https://fonts.gstatic.com`,
679
+ `img-src ${own} data:`,
680
+ "connect-src 'none'",
681
+ "form-action 'none'",
682
+ "base-uri 'none'",
683
+ "object-src 'none'",
684
+ "frame-ancestors 'self'",
685
+ ].join("; "),
686
+ });
687
+ response.end(found.body);
688
+ }
689
+
690
+ /**
691
+ * One API call: find the route, work out whether this caller may have it, and
692
+ * hand it over. The whole of the authorisation is here, so there is one place
693
+ * to read rather than one check per handler.
694
+ */
695
+ async function api(
696
+ request: IncomingMessage,
697
+ response: ServerResponse,
698
+ path: string,
699
+ url: URL,
700
+ context: Context,
701
+ ): Promise<void> {
702
+ const found = match(request.method ?? "GET", path);
703
+ if (!found) throw new NotFound(`No route for ${request.method} ${path}. GET /api lists them.`);
704
+ const { route, params } = found;
705
+
706
+ const who = route.open ? null : caller(request);
707
+ if (!route.open) {
708
+ if (!who) return json(response, { error: "Sign in first." }, 401);
709
+ if (who.kind === "token" && !route.token) {
710
+ return json(response, { error: "A token cannot do that. That one is the account's." }, 403);
711
+ }
712
+ if (who.kind === "token" && route.needsApiChannel && !onTheApi(context.agent(params.name))) {
713
+ return json(
714
+ response,
715
+ { error: `${params.name} has no api channel, so a token cannot reach it. Bind one to open it.` },
716
+ 403,
717
+ );
718
+ }
719
+ }
720
+
721
+ await route.handle({ request, response, url, params, context, who });
722
+ }
723
+
724
+ export function json(response: ServerResponse, value: unknown, status = 200): void {
725
+ const text = JSON.stringify(value, null, 2);
726
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
727
+ response.end(text);
728
+ }
729
+
730
+ export function html(response: ServerResponse, value: string, status = 200): void {
731
+ response.writeHead(status, { "content-type": "text/html; charset=utf-8" });
732
+ response.end(value);
733
+ }
734
+
735
+ /**
736
+ * A request's JSON, in the shape the route says it takes, or a 400 saying
737
+ * what did not fit. Capped, so a bad caller cannot fill memory.
738
+ */
739
+ export async function body<Shape extends z.ZodType>(request: IncomingMessage, shape: Shape): Promise<z.infer<Shape>> {
740
+ const text = await new Promise<string>((done, fail) => {
741
+ let text = "";
742
+ request.on("data", (chunk: Buffer) => {
743
+ text += chunk;
744
+ if (text.length > 1_000_000) {
745
+ fail(new BadRequest("Body too large."));
746
+ request.destroy();
747
+ }
748
+ });
749
+ request.on("end", () => done(text));
750
+ request.on("error", fail);
751
+ });
752
+ let value: unknown;
753
+ try {
754
+ value = text ? JSON.parse(text) : {};
755
+ } catch {
756
+ throw new BadRequest("Body is not valid JSON.");
757
+ }
758
+ const checked = shape.safeParse(value);
759
+ if (!checked.success) {
760
+ throw new BadRequest(checked.error.issues.map((i) => `${i.path.join(".") || "body"} ${i.message}`).join("; "));
761
+ }
762
+ return checked.data;
763
+ }
764
+
765
+ // Where a route author is already looking, so throwing one does not need a
766
+ // second import.
767
+ export { BadRequest, NotFound };