@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/load/load.ts ADDED
@@ -0,0 +1,478 @@
1
+ // An agent is declared, not found. Each one is a defineAgent(...) with a name,
2
+ // and chloe.config.ts at the top of the repo lists them. Nothing is found by
3
+ // looking in a folder except an agent's skills/.
4
+ import { readFile, readdir } from "node:fs/promises";
5
+ import { existsSync } from "node:fs";
6
+ import { registerHooks } from "node:module";
7
+ import type { IncomingMessage, ServerResponse } from "node:http";
8
+ import { dirname, relative } from "node:path";
9
+ import { fileURLToPath, pathToFileURL } from "node:url";
10
+ import { getCallSites } from "node:util";
11
+
12
+ import type { z } from "zod";
13
+
14
+ import { ROOT, STATE, setAgentDirs } from "#chloe/core/paths.ts";
15
+ import { readPrompt, settingsAndBody, type Prompt } from "#chloe/core/markdown.ts";
16
+ import { parse } from "#chloe/timer/cron.ts";
17
+ import type { Definition as JobFile } from "./job.ts";
18
+ import type { Tool, Tools } from "#chloe/model/tool.ts";
19
+ import { memoryTools } from "#chloe/model/tools/memory.ts";
20
+ import { runScripts } from "#chloe/model/tools/run_script.ts";
21
+ import { selfImprovement } from "#chloe/model/tools/write_skill.ts";
22
+ import type { Work } from "#chloe/core/steps.ts";
23
+
24
+ export const CONFIG = `${ROOT}/chloe.config.ts`;
25
+
26
+ // Node keeps a module once it is imported, so a job file that an agent
27
+ // imports would be read once at boot and every later edit would look saved
28
+ // and do nothing. Everything outside the runtime and node_modules is imported
29
+ // afresh on each load.
30
+ const RUNTIME = new URL("../", import.meta.url).href;
31
+ let generation = 0;
32
+ registerHooks({
33
+ resolve(specifier, context, next) {
34
+ const found = next(specifier, context);
35
+ if (!found.url.startsWith("file:") || found.url.startsWith(RUNTIME) || found.url.includes("/node_modules/")) {
36
+ return found;
37
+ }
38
+ return { ...found, url: `${found.url.split("?")[0]}?v=${generation}` };
39
+ },
40
+ });
41
+
42
+ /** The agent, as far as a tool bound to it needs to know. */
43
+ export interface Home {
44
+ name: string;
45
+ folder: string;
46
+ /** This agent's memory as its definition says it, with the folder worked out. See Memory. */
47
+ memory: Memory & { folder: string };
48
+ }
49
+
50
+ /** A set of tools made for one agent as it loads, like readMail({ ... }). */
51
+ export type Binding = (agent: Home) => Tools;
52
+
53
+ /** What defineAgent is given. */
54
+ export interface Definition {
55
+ /** What the run history, its data folder and its pages are filed under. Do not change it once it has run. */
56
+ name: string;
57
+ /** What the page calls it, when that is not its name: "C.C.". Free to change. */
58
+ label?: string;
59
+ /**
60
+ * Where its skills, scripts, evals and prompts are. Defaults to the folder
61
+ * of the file that calls defineAgent.
62
+ */
63
+ folder?: string;
64
+ /** A gateway model id, like "anthropic/claude-sonnet-5". */
65
+ model: string;
66
+ /** One line, shown wherever agents are listed. */
67
+ description: string;
68
+ /**
69
+ * Where this agent remembers things: the folder it reads and writes between
70
+ * runs, browsable and editable from the site.
71
+ *
72
+ * Every agent has one, and always has list_notes, read_notes, search_notes
73
+ * and write_notes on it. Left unsaid it is that agent's own folder under the
74
+ * state directory, so this is only worth writing down when the agent shares
75
+ * a folder with a person. Every file served out of it is written to that
76
+ * agent's own audit log first. See serve/memory.ts for why that log is not
77
+ * optional.
78
+ */
79
+ memory?: Memory;
80
+ /** The tools the runtime can give any agent, each switched on or off here. */
81
+ features?: Features;
82
+ /** `prompt("instructions.md")`, a path inside the agent's folder, or the words themselves. */
83
+ instructions: string | Prompt;
84
+ /**
85
+ * Each tool, or a set of them like readMail({ ... }). A model calls one by
86
+ * its id. What `features` turns on is added to these and not listed here.
87
+ */
88
+ tools?: (Tool | Tools | Binding)[];
89
+ /** Each job, imported, or markdownJob("jobs/name.md") for one that is only words. */
90
+ jobs?: (JobFile<any, any> | MarkdownJob)[];
91
+ /** Each way in: `[telegramChannel({ ... }), apiChannel()]`. Each one carries its own name. */
92
+ channels?: Channel[];
93
+ /** Times round the tool loop before a turn is stopped. */
94
+ maxSteps?: number;
95
+ }
96
+
97
+ export interface Defined extends Definition {
98
+ folder: string;
99
+ }
100
+
101
+ /** Declares an agent. List it in chloe.config.ts for it to run. */
102
+ export function defineAgent(definition: Definition): Defined {
103
+ if (definition.folder) return { ...definition, folder: definition.folder };
104
+ // [0] is this function, [1] is whoever called it.
105
+ const caller = getCallSites()[1]?.scriptName ?? "";
106
+ if (!caller.startsWith("file:") && !caller.startsWith("/")) {
107
+ throw new Error(`defineAgent could not tell which file ${definition.name} is written in. Give it folder: import.meta.dirname.`);
108
+ }
109
+ const file = caller.startsWith("file:") ? fileURLToPath(caller) : caller;
110
+ return { ...definition, folder: dirname(file) };
111
+ }
112
+
113
+ /** What chloe.config.ts exports: every agent this box runs. */
114
+ export interface Config {
115
+ agents: Defined[];
116
+ }
117
+
118
+ /** The default export of chloe.config.ts: every agent to run. */
119
+ export function defineConfig(config: Config): Config {
120
+ return config;
121
+ }
122
+
123
+ /**
124
+ * A job that is only a prompt, kept whole in one markdown file with its
125
+ * settings (`cron`, `description`, `timezone`, `model`) at the top. The path
126
+ * is inside the agent's folder, and the file's name is the job's id.
127
+ */
128
+ export interface MarkdownJob {
129
+ markdownJob: string;
130
+ }
131
+
132
+ /**
133
+ * A job that is words and nothing else, named in `agent.ts` as
134
+ * `markdownJob("jobs/<id>.md")`. The file name is the job's id.
135
+ */
136
+ export function markdownJob(file: string): MarkdownJob {
137
+ return { markdownJob: file };
138
+ }
139
+
140
+ /** One markdown file out of an agent's `skills/` folder. */
141
+ export interface Skill {
142
+ name: string;
143
+ description: string;
144
+ body: string;
145
+ }
146
+
147
+ /**
148
+ * One job of an agent's, as the loader resolved it: where its words are, when
149
+ * it runs, and whether it is code.
150
+ */
151
+ export interface Job {
152
+ agent: string;
153
+ /** What the run history, the API and `npm run evals` call it. */
154
+ id: string;
155
+ /** One line on what it does. */
156
+ description?: string;
157
+ /** When it runs by itself. Without one it runs only when somebody starts it. */
158
+ cron?: string;
159
+ timezone: string;
160
+ /** When this job should not run on the agent's own model. */
161
+ model?: string;
162
+ /** A job is one of these two and never both. */
163
+ prompt: string;
164
+ /** The job, when it is code rather than a prompt. */
165
+ run?: (work: Work<Record<string, unknown>>) => Promise<unknown>;
166
+ /** One line from what `run` returned. See defineJob. */
167
+ summary?: (result: unknown) => string;
168
+ /** What a chat is sent, from what `run` returned. See defineJob. */
169
+ reply?: (result: unknown) => string;
170
+ /** Plain messages this job answers instead of the agent's chat. See defineJob. */
171
+ answers?: (text: string) => boolean;
172
+ /**
173
+ * The files it is written in, inside the agent's folder, words first. A
174
+ * job imported from code is found by its id: jobs/<id>.ts and jobs/<id>.md.
175
+ */
176
+ files: string[];
177
+ /** The shape of that job's state, when it keeps any. */
178
+ state?: z.ZodType;
179
+ /** The shape of what starting it by hand may send. See job.ts. */
180
+ input?: z.ZodType;
181
+ }
182
+
183
+ /** Tools the runtime brings, switched on per agent: `features: { selfImprovement: true }`. */
184
+ export interface Features {
185
+ /** list_notes, read_notes, search_notes and write_notes on its memory. On unless this says false. */
186
+ memory?: boolean;
187
+ /** write_skill, to rewrite its own skills. Every write is a commit. Off unless this says true. */
188
+ selfImprovement?: boolean;
189
+ /**
190
+ * run_script, to run a file in its own scripts/ folder. Off unless this says
191
+ * true, and refused as it loads when that folder has no scripts.
192
+ */
193
+ runScripts?: boolean;
194
+ }
195
+
196
+ /** Where an agent remembers things, shown on the site beside its own pages. */
197
+ export interface Memory {
198
+ /**
199
+ * An absolute path. Unset, it is this agent's own folder under the state
200
+ * directory.
201
+ */
202
+ folder?: string;
203
+ /** What the site calls it. "Memory" when nothing is said. */
204
+ label?: string;
205
+ /** Make every write a git commit, from the site and from write_notes. For a folder that is a repo. */
206
+ commit?: boolean;
207
+ }
208
+
209
+ /**
210
+ * How much of a conversation a turn is shown: the last `messages` (10 when
211
+ * unsaid, a question and its answer being two), and none older than `days`
212
+ * (no limit when unsaid). A conversation is one chat, or one topic in a forum,
213
+ * so this belongs to the channel it happens on: a job is never shown one.
214
+ * Nothing is deleted; what is left out is only not shown to the model.
215
+ */
216
+ export interface ChatHistory {
217
+ messages?: number;
218
+ days?: number;
219
+ }
220
+
221
+ /** A way in to an agent, listed in its definition: `channels: [telegramChannel({ ... })]`. */
222
+ export interface Channel {
223
+ /**
224
+ * What the log, the pages and the permissions call it, like "telegram" or
225
+ * "api". Two channels of one agent cannot share a name. "api" is the one
226
+ * that lets a token reach the agent.
227
+ */
228
+ name: string;
229
+ /** How much of a conversation on this channel a turn is shown. */
230
+ chatHistory?: ChatHistory;
231
+ /** Starts listening. `agent` is read again for every message, so an edit is live. */
232
+ start(agent: () => Agent | undefined): Running;
233
+ }
234
+
235
+ /** A channel that has been started, and how to stop it again. */
236
+ export interface Running {
237
+ stop(): void;
238
+ /** For a channel that is sent its messages: the paths it answers on the one port, outside the login. */
239
+ routes?: ChannelRoute[];
240
+ }
241
+
242
+ /** A path a running channel answers on the one port, outside the login. */
243
+ export interface ChannelRoute {
244
+ /** Always a POST. */
245
+ path: string;
246
+ handle(request: IncomingMessage, response: ServerResponse): Promise<void>;
247
+ }
248
+
249
+ /**
250
+ * One agent as the runtime holds it: the definition with its instructions
251
+ * read, its tools bound, its skills loaded and its jobs resolved.
252
+ */
253
+ export interface Agent extends Omit<Definition, "instructions" | "tools" | "jobs" | "channels" | "memory"> {
254
+ folder: string;
255
+ /** Always there once loaded, with its folder worked out. See memoryFolder. */
256
+ memory: Memory & { folder: string };
257
+ instructions: string;
258
+ tools?: Tools;
259
+ skills: Skill[];
260
+ jobs: Job[];
261
+ channels: Channel[];
262
+ }
263
+
264
+ /**
265
+ * Where an agent remembers things: what it said, or its own folder under the
266
+ * state directory. That default is where the memory tool has always written, so
267
+ * an agent that never mentions memory still has one and it is not empty.
268
+ */
269
+ export function memoryFolder(name: string, memory?: Memory): string {
270
+ return memory?.folder || `${STATE}/${name}`;
271
+ }
272
+
273
+ /** An agent's folder as the repo sees it, for saying where something is wrong. */
274
+ function shown(folder: string): string {
275
+ const inside = relative(ROOT, folder);
276
+ return inside && !inside.startsWith("..") ? inside : folder;
277
+ }
278
+
279
+ /** Every agent chloe.config.ts lists, by name. */
280
+ export async function loadAll(): Promise<Map<string, Agent>> {
281
+ generation++;
282
+ if (!existsSync(CONFIG)) throw new Error(`There is no chloe.config.ts in ${ROOT}. It lists the agents to run.`);
283
+ const module = (await import(pathToFileURL(CONFIG).href).catch((error: unknown) => {
284
+ // A job file runs as it is imported, so a mistake in one (an
285
+ // every(7).minutes) is thrown from here.
286
+ throw new Error(`chloe.config.ts: ${error instanceof Error ? error.message : String(error)}`);
287
+ })) as { default?: Config };
288
+ const listed = module.default?.agents;
289
+ if (!Array.isArray(listed)) throw new Error("chloe.config.ts does not export defineConfig({ agents: [...] }) as its default.");
290
+
291
+ const folders = new Map<string, string>();
292
+ for (const one of listed) {
293
+ if (!one?.name) throw new Error("chloe.config.ts lists an agent with no name.");
294
+ if (folders.has(one.name)) throw new Error(`chloe.config.ts lists two agents called ${one.name}.`);
295
+ folders.set(one.name, one.folder);
296
+ }
297
+ // Before anything is bound, so a tool that asks where its agent lives is told.
298
+ setAgentDirs(folders);
299
+
300
+ const all = new Map<string, Agent>();
301
+ for (const one of listed) all.set(one.name, await resolveAgent(one));
302
+ return all;
303
+ }
304
+
305
+ /** Every agent's name, in the order `chloe.config.ts` lists them. */
306
+ export async function names(): Promise<string[]> {
307
+ return [...(await loadAll()).keys()];
308
+ }
309
+
310
+ /** One agent by name, or a throw that names the agents there are. */
311
+ export async function load(name: string): Promise<Agent> {
312
+ const all = await loadAll();
313
+ const one = all.get(name);
314
+ if (!one) throw new Error(`chloe.config.ts has no agent called ${JSON.stringify(name)}. It has: ${[...all.keys()].join(", ")}.`);
315
+ return one;
316
+ }
317
+
318
+ async function resolveAgent(definition: Defined): Promise<Agent> {
319
+ const { name, folder } = definition;
320
+ const where = `${name} (${shown(folder)})`;
321
+ if (!definition.model) throw new Error(`${where} does not say which model.`);
322
+ if (!definition.instructions) throw new Error(`${where} has no instructions. Add instructions: prompt("instructions.md").`);
323
+
324
+ const { tools, jobs, channels, ...rest } = definition;
325
+ // Worked out once, here, so the site, the memory tool and a job's
326
+ // work.memory all mean the same folder without any of them saying it again.
327
+ const memory = { ...definition.memory, folder: memoryFolder(name, definition.memory) };
328
+ return {
329
+ ...rest,
330
+ memory,
331
+ instructions: await readPrompt(definition.instructions, { dir: folder, where }),
332
+ tools: toolsOf([...featureTools(definition.features, memory), ...(tools ?? [])], { name, folder, memory }, where),
333
+ skills: await skillsIn(`${folder}/skills`),
334
+ jobs: await jobsOf(name, folder, jobs ?? []),
335
+ channels: channelsOf(channels ?? [], where),
336
+ };
337
+ }
338
+
339
+ function channelsOf(channels: Channel[], where: string): Channel[] {
340
+ if (!Array.isArray(channels)) {
341
+ throw new Error(`${where}: channels is a list, like [telegramChannel({ ... }), apiChannel()].`);
342
+ }
343
+ const seen = new Set<string>();
344
+ channels.forEach((one, i) => {
345
+ if (typeof one?.start !== "function" || typeof one.name !== "string" || !one.name) {
346
+ throw new Error(`${where}: channels[${i}] is not a channel.`);
347
+ }
348
+ if (seen.has(one.name)) throw new Error(`${where}: two channels are called ${one.name}.`);
349
+ seen.add(one.name);
350
+ });
351
+ return channels;
352
+ }
353
+
354
+ /** Does this agent have a channel of this name? "api" is what lets a token reach it. */
355
+ export function hasChannel(agent: Pick<Agent, "channels">, name: string): boolean {
356
+ return agent.channels.some((one) => one.name === name);
357
+ }
358
+
359
+ /** What an agent's `features` turn on, as tools. The memory tools unless it says memory: false. */
360
+ function featureTools(features: Features = {}, memory: Memory & { folder: string }): (Tools | Binding)[] {
361
+ return [
362
+ ...(features.memory === false ? [] : [memoryTools(memory)]),
363
+ ...(features.selfImprovement ? [selfImprovement()] : []),
364
+ ...(features.runScripts ? [runScripts()] : []),
365
+ ];
366
+ }
367
+
368
+ function toolsOf(list: (Tool | Tools | Binding)[], home: Home, where: string): Tools {
369
+ const tools: Tools = {};
370
+ for (const one of list) {
371
+ const some: Tools =
372
+ typeof one === "function" ? one(home) : typeof (one as Tool).execute === "function" ? { [(one as Tool).id]: one as Tool } : (one as Tools);
373
+ for (const [id, each] of Object.entries(some)) {
374
+ if (typeof each?.execute !== "function") throw new Error(`${where}: tool ${id} is not a tool.`);
375
+ if (tools[id]) throw new Error(`${where}: two tools are called ${id}.`);
376
+ tools[id] = each;
377
+ }
378
+ }
379
+ return tools;
380
+ }
381
+
382
+ async function skillsIn(dir: string): Promise<Skill[]> {
383
+ const files = await readdir(dir).catch(() => [] as string[]);
384
+ const skills: Skill[] = [];
385
+ for (const file of files.filter((f) => f.endsWith(".md")).sort()) {
386
+ const { settings, body } = settingsAndBody(await readFile(`${dir}/${file}`, "utf8"));
387
+ skills.push({
388
+ name: settings.name ?? file.replace(/\.md$/, ""),
389
+ description: settings.description ?? "",
390
+ body,
391
+ });
392
+ }
393
+ return skills;
394
+ }
395
+
396
+ /** The jobs an agent names, in the order it names them. */
397
+ export async function jobsOf(agent: string, dir: string, list: (JobFile<any, any> | MarkdownJob)[]): Promise<Job[]> {
398
+ const jobs: Job[] = [];
399
+ for (const one of list) {
400
+ const job = "markdownJob" in one ? await fromMarkdown(agent, dir, one.markdownJob) : await fromCode(agent, dir, one);
401
+ if (jobs.some((other) => other.id === job.id)) throw new Error(`${agent}: two jobs are called ${job.id}.`);
402
+ jobs.push(job);
403
+ }
404
+ return jobs;
405
+ }
406
+
407
+ /**
408
+ * A cron line is read when the agent loads, so a bad one stops that agent with
409
+ * the job's name on it, rather than being found by the clock every minute.
410
+ */
411
+ function checked(cron: string | undefined, where: string): string | undefined {
412
+ if (!cron) return undefined;
413
+ try {
414
+ parse(cron);
415
+ } catch (error) {
416
+ throw new Error(`${where} has a cron line that does not read: ${error instanceof Error ? error.message : String(error)}`);
417
+ }
418
+ return cron;
419
+ }
420
+
421
+ async function fromMarkdown(agent: string, dir: string, file: string): Promise<Job> {
422
+ const path = file.replace(/^\.\//, "");
423
+ const where = `${shown(dir)}/${path}`;
424
+ const text = await readFile(`${dir}/${path}`, "utf8").catch(() => {
425
+ throw new Error(`${agent} names ${where} as a job, and it is not there.`);
426
+ });
427
+ const { settings, body } = settingsAndBody(text);
428
+ if (!body.trim()) throw new Error(`${where} has no prompt under its frontmatter.`);
429
+ const id = path.split("/").pop()!.replace(/\.md$/, "");
430
+ return {
431
+ agent,
432
+ id,
433
+ description: settings.description,
434
+ cron: checked(settings.cron, where),
435
+ timezone: settings.timezone ?? "UTC",
436
+ model: settings.model,
437
+ prompt: body,
438
+ files: [path],
439
+ };
440
+ }
441
+
442
+ async function fromCode(agent: string, dir: string, definition: JobFile<any, any>): Promise<Job> {
443
+ if (!definition?.id) throw new Error(`${agent} names a job with no id.`);
444
+ const { id } = definition;
445
+ const where = `${agent} job ${id}`;
446
+ if (definition.run && definition.markdown) {
447
+ throw new Error(`${where} has both run and markdown. A job is code or a prompt, never both.`);
448
+ }
449
+ if (!definition.run && !definition.markdown) {
450
+ throw new Error(`${where} has neither run nor markdown, so nothing happens when it runs.`);
451
+ }
452
+
453
+ const common = {
454
+ agent,
455
+ id,
456
+ description: definition.description,
457
+ cron: checked(definition.cron, where),
458
+ timezone: definition.timezone ?? "UTC",
459
+ model: definition.model,
460
+ files: [`jobs/${id}.md`, `jobs/${id}.ts`].filter((file) => existsSync(`${dir}/${file}`)),
461
+ };
462
+
463
+ // A job has no prompt, and the empty string is what says so everywhere else.
464
+ if (definition.run) {
465
+ return {
466
+ ...common,
467
+ prompt: "",
468
+ run: definition.run,
469
+ state: definition.state,
470
+ input: definition.input,
471
+ summary: definition.summary,
472
+ reply: definition.reply,
473
+ answers: definition.answers,
474
+ };
475
+ }
476
+
477
+ return { ...common, prompt: await readPrompt(definition.markdown, { dir, where }) };
478
+ }
package/model/ask.ts ADDED
@@ -0,0 +1,84 @@
1
+ // How a question reaches a person, and nothing else.
2
+ //
3
+ // An address is "channel:who", like "telegram:12345". The channel half is
4
+ // looked up here, so chloe/ knows that questions go out somewhere without
5
+ // knowing that Telegram exists: index.ts registers the ways in and out, the
6
+ // same way it binds a channel's routes.
7
+ //
8
+ // This is the piece the team version needs first. An address already names one
9
+ // person, so the day there are two, an ask goes to the right one without any
10
+ // of this changing.
11
+
12
+ import { setting } from "#chloe/core/settings.ts";
13
+
14
+ /** `choices` is every answer that fits, when there are few enough to list: a channel may show them as buttons. */
15
+ export type Send = (to: string, text: string, choices?: string[]) => Promise<void>;
16
+
17
+ const ways = new Map<string, Send>();
18
+
19
+ /**
20
+ * Called once per channel that can carry a question out. With an agent, it is
21
+ * that agent's way out only: two agents on Telegram are two bots, and a
22
+ * question from one must not arrive from the other.
23
+ */
24
+ export function reachBy(channel: string, send: Send, agent = ""): void {
25
+ ways.set(agent ? `${agent}/${channel}` : channel, send);
26
+ }
27
+
28
+ /** Taking a way out back, when a channel stops. */
29
+ export function unreach(channel: string, agent = ""): void {
30
+ ways.delete(agent ? `${agent}/${channel}` : channel);
31
+ }
32
+
33
+ function way(channel: string, agent: string): Send | undefined {
34
+ return (agent && ways.get(`${agent}/${channel}`)) || ways.get(channel);
35
+ }
36
+
37
+ /** An address, `channel:who`, as its two halves. */
38
+ export function split(address: string): { channel: string; to: string } {
39
+ const at = address.indexOf(":");
40
+ if (at < 1 || at === address.length - 1) {
41
+ throw new Error(`${JSON.stringify(address)} is not an address. Write it as "channel:who", like "telegram:12345".`);
42
+ }
43
+ return { channel: address.slice(0, at), to: address.slice(at + 1) };
44
+ }
45
+
46
+ /**
47
+ * Whether a channel that is running for that agent could deliver to that
48
+ * address.
49
+ */
50
+ export function canReach(address: string, agent = ""): boolean {
51
+ try {
52
+ return Boolean(way(split(address).channel, agent));
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Sends text to an address through whichever channel is running for that
60
+ * agent, with buttons when choices are given.
61
+ */
62
+ export async function deliver(address: string, text: string, agent = "", choices?: string[]): Promise<void> {
63
+ const { channel, to } = split(address);
64
+ const send = way(channel, agent);
65
+ // Refuse rather than park a run nobody will ever see a question from.
66
+ if (!send) {
67
+ throw new Error(
68
+ `Nothing here can reach ${JSON.stringify(channel)}. Registered: ${[...ways.keys()].join(", ") || "none"}.`,
69
+ );
70
+ }
71
+ await send(to, text, choices);
72
+ }
73
+
74
+ const owners = new Map<string, string>();
75
+
76
+ /** A channel says who an agent's person is: the first chat it lets in. */
77
+ export function ownedBy(agent: string, address: string): void {
78
+ owners.set(agent, address);
79
+ }
80
+
81
+ /** Who a run belongs to when nothing says otherwise. */
82
+ export function owner(agent = ""): string {
83
+ return setting(owners.get(agent) ?? "", "OWNER");
84
+ }