@chloejs/core 0.2.4 → 0.3.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.
package/README.md CHANGED
@@ -146,7 +146,8 @@ outside one runs again on every resume, so it must not send, write or spend.
146
146
  | Tools | A description, a schema and one call. Typed at both ends. |
147
147
  | Human approvals | A run parks for days and carries on when somebody answers. |
148
148
  | Channels | Telegram and Slack, one file each. A question goes out where the person is. |
149
- | Memory | Notes an agent keeps, and skills it can rewrite. |
149
+ | Memory | A folder of notes per agent, in one git repository of their own: one commit per run, under the agent's name. |
150
+ | Self-improvement | An agent can rewrite its skills, jobs and instructions if you let it, never its code. Every change can be undone. |
150
151
  | Run history | Every step of every run, with its arguments and its answer. |
151
152
  | Cost tracking | Per step, per run, per job. |
152
153
  | Structured output | Zod on every model and agent answer, retried once. |
@@ -24,7 +24,7 @@
24
24
  import type { Agent, ChatHistory, Job } from "#chloe/load/load.ts";
25
25
  import type { Attachment } from "#chloe/model/model.ts";
26
26
  import { remember } from "#chloe/model/memory.ts";
27
- import { clock, type Fired } from "#chloe/core/clock.ts";
27
+ import { clock, type Fired, ran } from "#chloe/core/clock.ts";
28
28
  import { answer, waitingOn, WrongInput } from "#chloe/core/steps.ts";
29
29
  import { turn } from "#chloe/core/turn.ts";
30
30
 
@@ -177,6 +177,18 @@ function kept(message: Incoming, reply: string): void {
177
177
  remember(message.thread, "assistant", reply);
178
178
  }
179
179
 
180
+ /**
181
+ * A message that is for a job: start that job and hand back what to say.
182
+ *
183
+ * The message becomes the job's input (the text, and who said it and where, so
184
+ * the job can write back to the same chat), the clock runs it, and the reply is
185
+ * the job's own words. Three things can come back and each is said plainly: the
186
+ * job ran, the job was already running, or the job failed.
187
+ *
188
+ * It goes through the clock rather than calling the job itself, because the
189
+ * clock is the one place that knows what is running and will not start a second
190
+ * run of the same job.
191
+ */
180
192
  async function started(agent: Agent, message: Incoming, job: Job, text: string): Promise<Handled> {
181
193
  const input = {
182
194
  text,
@@ -189,7 +201,12 @@ async function started(agent: Agent, message: Incoming, job: Job, text: string):
189
201
  };
190
202
  try {
191
203
  const result = await clock()!.fire(agent, job, input, message.channel);
192
- if (!result) return { text: `${job.id} is already running. I will not start a second one.`, steps: 0, cost: 0, job: job.id };
204
+ if (!ran(result)) {
205
+ const text = result.skipped
206
+ ? `${job.id} is already running. I will not start a second one.`
207
+ : `${job.id} failed. ${result.failed ?? "It is in the logs on the box."}`;
208
+ return { text, steps: 0, cost: 0, job: job.id };
209
+ }
193
210
  const reply = replyOf(result, job);
194
211
  kept(message, reply);
195
212
  return { text: reply, runId: result.runId, steps: result.steps, cost: result.cost, job: job.id };
@@ -237,6 +254,7 @@ async function chatted(agent: Agent, message: Incoming, rules: Rules, send?: (te
237
254
  thread: message.thread || undefined,
238
255
  history: rules.chatHistory,
239
256
  said,
257
+ talkingTo: message.from.name,
240
258
  model: message.model,
241
259
  source: message.channel,
242
260
  owner: `${message.channel}:${message.from.id}`,
package/channels/slack.ts CHANGED
@@ -14,8 +14,9 @@
14
14
  // Under Event Subscriptions subscribe the bot to message.im, message.channels,
15
15
  // message.groups and message.mpim, and under App Home allow messages from the
16
16
  // Messages tab. Install it to the workspace, which makes the bot token
17
- // ("xoxb-..."). The two tokens are SLACK_BOT_TOKEN and SLACK_APP_TOKEN in
18
- // .env, or `credentials: { botToken, appToken }`. In a channel, invite the bot
17
+ // ("xoxb-..."). The two tokens are in settings.local.json under the agent's
18
+ // name, as `"agents": { "<name>": { "slack": { "bot_token", "app_token" } } }`,
19
+ // or `credentials: { botToken, appToken }` here. In a channel, invite the bot
19
20
  // (/invite @name) before it can read anything there.
20
21
  //
21
22
  // allowFrom is who may talk to the agent, by Slack member id ("U0123ABCD", in
@@ -38,6 +39,7 @@
38
39
  import { ownedBy, reachBy, unreach } from "#chloe/model/ask.ts";
39
40
  import type { Agent, Channel, ChatHistory, Running } from "#chloe/load/load.ts";
40
41
  import type { Attachment } from "#chloe/model/model.ts";
42
+ import { settings } from "#chloe/core/settings.ts";
41
43
  import { receive, type Incoming, type Rules } from "./shared.ts";
42
44
 
43
45
  const MAX_MESSAGE = 4000; // Slack cuts a message's text at 40000, and advises under 4000.
@@ -50,7 +52,7 @@ export interface SlackOptions {
50
52
  * run came in on, and the start of every address on this app, like "slack:U0123ABCD".
51
53
  */
52
54
  name?: string;
53
- /** Instead of SLACK_BOT_TOKEN and SLACK_APP_TOKEN. */
55
+ /** Instead of the tokens in settings. */
54
56
  credentials?: { botToken?: string; appToken?: string };
55
57
  /** Slack member ids that may reach the agent. */
56
58
  allowFrom?: string[];
@@ -105,14 +107,15 @@ export function slackChannel(options: SlackOptions = {}): Channel {
105
107
  return {
106
108
  name: options.name ?? "slack",
107
109
  chatHistory: options.chatHistory,
110
+ madeWith: JSON.stringify(options),
108
111
  start(agent) {
109
112
  const name = agent()?.name ?? "";
110
- const token = options.credentials?.botToken || process.env.SLACK_BOT_TOKEN || "";
111
- const appToken = options.credentials?.appToken || process.env.SLACK_APP_TOKEN || "";
113
+ const token = options.credentials?.botToken || settings.agents[name]?.slack.bot_token || "";
114
+ const appToken = options.credentials?.appToken || settings.agents[name]?.slack.app_token || "";
112
115
  if (!token || !appToken) {
113
116
  console.error(
114
117
  `slack: ${name} has a Slack channel but no ${token ? "app token" : "bot token"}. Make an app at api.slack.com/apps ` +
115
- "with Socket Mode on, and put its tokens in .env as SLACK_BOT_TOKEN (xoxb-...) and SLACK_APP_TOKEN (xapp-...). Then restart.",
118
+ `with Socket Mode on, and put its tokens in settings.local.json as "agents": { "${name}": { "slack": { "bot_token": "xoxb-...", "app_token": "xapp-..." } } }.`,
116
119
  );
117
120
  return { stop: () => {} };
118
121
  }
@@ -4,9 +4,10 @@
4
4
  // import { telegramChannel } from "@chloejs/core/channels";
5
5
  // channels: [telegramChannel({ allowFrom: [111111111] })],
6
6
  //
7
- // The bot's token is TELEGRAM_BOT_TOKEN, or `credentials: { botToken }`. To
8
- // make a bot, message @BotFather in Telegram, send /newbot, and pick a name
9
- // and a username. It replies with the token.
7
+ // The bot's token is in settings.local.json under the agent's name, as
8
+ // `"agents": { "<name>": { "telegram": "..." } }`, or `credentials: { botToken }`
9
+ // here. To make a bot, message @BotFather in Telegram, send /newbot, and pick
10
+ // a name and a username. It replies with the token.
10
11
  //
11
12
  // allowFrom is who may talk to the agent, by Telegram user id, in any chat,
12
13
  // including a group made later. Anyone can find a bot and message it, so
@@ -21,8 +22,8 @@
21
22
  // default.
22
23
  // "webhook" Telegram sends each one to publicUrl + /chloe/v1/<agent>/telegram,
23
24
  // which has to be reachable past the login, and checks
24
- // TELEGRAM_WEBHOOK_SECRET_TOKEN (or credentials.webhookSecretToken)
25
- // on every call. chloe registers the address itself on start.
25
+ // credentials.webhookSecretToken, or one made on start, on every
26
+ // call. chloe registers the address itself on start.
26
27
  //
27
28
  // What happens to a message once it is read (allowFrom, a job waiting on an
28
29
  // answer, /commands, jobs that answer plain messages, groups, the chat) is
@@ -36,9 +37,10 @@ import type { IncomingMessage, ServerResponse } from "node:http";
36
37
  import { type Agent, type Channel, type ChatHistory, type Running } from "#chloe/load/load.ts";
37
38
  import { ownedBy, reachBy, unreach } from "#chloe/model/ask.ts";
38
39
  import type { Attachment } from "#chloe/model/model.ts";
40
+ import { settings } from "#chloe/core/settings.ts";
39
41
  import { commands, receive, type Incoming, type Rules } from "./shared.ts";
40
42
 
41
- const MAX_MESSAGE = 4000; // Telegram rejects anything over 4096.
43
+ const MAX_MESSAGE = 3500; // Telegram rejects anything over 4096, and the tags added below count.
42
44
  const WAIT = 50; // Seconds Telegram holds a poll open when there is nothing new.
43
45
 
44
46
  /**
@@ -53,7 +55,7 @@ export interface TelegramOptions {
53
55
  name?: string;
54
56
  /** For spotting a mention in a group. Asked of Telegram when left out. */
55
57
  botUsername?: string;
56
- /** Instead of TELEGRAM_BOT_TOKEN and TELEGRAM_WEBHOOK_SECRET_TOKEN. */
58
+ /** Instead of the token in settings. Without a secret, webhook mode makes a new one each start. */
57
59
  credentials?: { botToken?: string; webhookSecretToken?: string };
58
60
  /** Telegram user ids that may reach the agent. */
59
61
  allowFrom?: number[];
@@ -67,6 +69,14 @@ export interface TelegramOptions {
67
69
  chatHistory?: ChatHistory;
68
70
  /** Send what the model writes on its way to an answer as it writes it, not only the answer. Off unless true. */
69
71
  sendWhileWorking?: boolean;
72
+ /**
73
+ * Seconds to wait before handling a text message, so that anything else sent
74
+ * in the same chat inside that time is handled as one message, joined by a
75
+ * blank line in the order it arrived. Zero, the default, handles each one on
76
+ * its own. A share that arrives as two messages (a quote and a comment) is
77
+ * what this is for. A message carrying a file is never held.
78
+ */
79
+ stackWithin?: number;
70
80
  mode?: "polling" | "webhook";
71
81
  /** Where this server is reachable from outside, for mode "webhook", like "https://agents.example.com". */
72
82
  publicUrl?: string;
@@ -116,13 +126,14 @@ export function telegramChannel(options: TelegramOptions = {}): Channel {
116
126
  return {
117
127
  name: options.name ?? "telegram",
118
128
  chatHistory: options.chatHistory,
129
+ madeWith: JSON.stringify(options),
119
130
  start(agent) {
120
131
  const name = agent()?.name ?? "";
121
- const token = options.credentials?.botToken || process.env.TELEGRAM_BOT_TOKEN || "";
132
+ const token = options.credentials?.botToken || settings.agents[name]?.telegram || "";
122
133
  if (!token) {
123
134
  console.error(
124
135
  `telegram: ${name} has a Telegram channel but no bot. Message @BotFather in Telegram, send /newbot, ` +
125
- "and put the token it gives you in .env as TELEGRAM_BOT_TOKEN. Then restart.",
136
+ `and put the token it gives you in settings.local.json as "agents": { "${name}": { "telegram": "..." } }.`,
126
137
  );
127
138
  return { stop: () => {} };
128
139
  }
@@ -161,7 +172,7 @@ export function listen(
161
172
  const rules: Rules = { allowFrom: options.allowFrom ?? [], inGroups: options.inGroups, chatHistory: options.chatHistory, sendWhileWorking: options.sendWhileWorking };
162
173
  const mode = options.mode ?? "polling";
163
174
  const path = `/chloe/v1/${name}/${channel}`;
164
- const secret = options.credentials?.webhookSecretToken || process.env.TELEGRAM_WEBHOOK_SECRET_TOKEN || randomBytes(24).toString("hex");
175
+ const secret = options.credentials?.webhookSecretToken || randomBytes(24).toString("hex");
165
176
  const allowedTypes = options.uploadPolicy?.allowedMediaTypes ?? ["image/*", "application/pdf", "text/*"];
166
177
  const maxBytes = options.uploadPolicy?.maxBytes ?? 10 * 1024 * 1024;
167
178
  const stopping = new AbortController();
@@ -180,11 +191,21 @@ export function listen(
180
191
  return reply.result as T;
181
192
  }
182
193
 
183
- /** Sending is not stopped with the reading: a turn already under way still answers. */
194
+ /**
195
+ * Sending is not stopped with the reading: a turn already under way still
196
+ * answers. Each piece goes as Telegram's HTML, made from the Markdown the
197
+ * model wrote, and as the words themselves if Telegram refuses that, so a
198
+ * formatting slip never loses a reply.
199
+ */
184
200
  async function send(chatId: number, text: string, extra: object = {}): Promise<void> {
185
- for (let i = 0; i < text.length; i += MAX_MESSAGE) {
186
- const last = i + MAX_MESSAGE >= text.length;
187
- await call("sendMessage", { chat_id: chatId, text: text.slice(i, i + MAX_MESSAGE), ...(last ? extra : {}) }, 30, null);
201
+ const pieces = inPieces(text, MAX_MESSAGE);
202
+ for (const [i, piece] of pieces.entries()) {
203
+ const rest = i === pieces.length - 1 ? extra : {};
204
+ try {
205
+ await call("sendMessage", { chat_id: chatId, text: telegramHtml(piece), parse_mode: "HTML", ...rest }, 30, null);
206
+ } catch {
207
+ await call("sendMessage", { chat_id: chatId, text: piece, ...rest }, 30, null);
208
+ }
188
209
  }
189
210
  }
190
211
 
@@ -272,10 +293,39 @@ export function listen(
272
293
  };
273
294
  }
274
295
 
296
+ /** Text waiting for the stacking window to close, by chat: what was said, and the first message it was said in. */
297
+ const stacking = new Map<string, { parts: string[]; first: TgMessage; timer: ReturnType<typeof setTimeout> }>();
298
+
275
299
  async function onMessage(message: TgMessage): Promise<void> {
276
300
  const agent = options.agent();
277
301
  const text = message.text ?? message.caption ?? "";
278
302
  if (!agent || !message.from || (!text && !message.photo && !message.document)) return;
303
+ const wait = options.stackWithin ?? 0;
304
+ if (wait > 0 && text && !message.photo && !message.document) {
305
+ const key = `${message.chat.id}${message.is_topic_message ? `-${message.message_thread_id}` : ""}`;
306
+ const held = stacking.get(key);
307
+ if (held) clearTimeout(held.timer);
308
+ const parts = held ? [...held.parts, text] : [text];
309
+ // The first message is the one answered, so the reply sits under the
310
+ // start of what was sent rather than under its last line.
311
+ const first = held?.first ?? message;
312
+ stacking.set(key, {
313
+ parts,
314
+ first,
315
+ timer: setTimeout(() => {
316
+ stacking.delete(key);
317
+ void handled(first, parts.join("\n\n")).catch((error) => console.error("telegram:", error));
318
+ }, wait * 1000),
319
+ });
320
+ return;
321
+ }
322
+ await handled(message, text);
323
+ }
324
+
325
+ /** One message, or everything stacked into it, from the point the text is settled. */
326
+ async function handled(message: TgMessage, text: string): Promise<void> {
327
+ const agent = options.agent();
328
+ if (!agent || !message.from) return;
279
329
  const chatId = message.chat.id;
280
330
  const topic = message.is_topic_message ? message.message_thread_id : undefined;
281
331
  const extra = {
@@ -394,3 +444,68 @@ export function listen(
394
444
  },
395
445
  };
396
446
  }
447
+
448
+ /**
449
+ * A long reply cut into pieces Telegram will take, at a line break where there
450
+ * is one, so a tag is never cut in half.
451
+ */
452
+ export function inPieces(text: string, max: number): string[] {
453
+ const pieces: string[] = [];
454
+ let rest = text;
455
+ while (rest.length > max) {
456
+ const cut = rest.lastIndexOf("\n", max);
457
+ const at = cut > max / 2 ? cut : max;
458
+ pieces.push(rest.slice(0, at));
459
+ rest = rest.slice(at).replace(/^\n/, "");
460
+ }
461
+ return [...pieces, rest];
462
+ }
463
+
464
+ const escape = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
465
+
466
+ /** **bold**, *italic*, ~~struck~~, `code` and [links](https://...) in one line, everything else escaped. */
467
+ function inline(line: string): string {
468
+ // Code first, and put aside, so nothing inside it is read as bold or a link.
469
+ const kept: string[] = [];
470
+ const keep = (html: string) => `\u0000${kept.push(html) - 1}\u0000`;
471
+ const marked = line
472
+ .replace(/`([^`]+)`/g, (_, code: string) => keep(`<code>${escape(code)}</code>`))
473
+ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (all, words: string, to: string) =>
474
+ // Only a web or mail address becomes a link, never javascript: or data:.
475
+ /^(https?:|mailto:)/i.test(to) ? keep(`<a href="${escape(to).replace(/"/g, "&quot;")}">${escape(words)}</a>`) : all,
476
+ );
477
+ return escape(marked)
478
+ .replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
479
+ .replace(/(^|[^*\w])\*(?!\s)([^*]+?)\*(?![*\w])/g, "$1<i>$2</i>")
480
+ .replace(/~~(.+?)~~/g, "<s>$1</s>")
481
+ .replace(/\u0000(\d+)\u0000/g, (_, i: string) => kept[Number(i)]);
482
+ }
483
+
484
+ /**
485
+ * Markdown as the HTML Telegram takes, which is only bold, italics, struck,
486
+ * code, code blocks, links and quotes. A heading becomes a bold line and a
487
+ * list item starts with a bullet. Anything else is escaped and shown as
488
+ * written, so no reply can be refused for a tag Telegram does not know.
489
+ */
490
+ export function telegramHtml(markdown: string): string {
491
+ const out: string[] = [];
492
+ const lines = markdown.split("\n");
493
+ for (let i = 0; i < lines.length; i++) {
494
+ const line = lines[i];
495
+ if (line.trim().startsWith("```")) {
496
+ const code: string[] = [];
497
+ for (i++; i < lines.length && !lines[i].trim().startsWith("```"); i++) code.push(lines[i]);
498
+ out.push(`<pre>${escape(code.join("\n"))}</pre>`);
499
+ } else if (line.startsWith(">")) {
500
+ const quote: string[] = [];
501
+ for (; i < lines.length && lines[i].startsWith(">"); i++) quote.push(inline(lines[i].replace(/^>\s?/, "")));
502
+ i--;
503
+ out.push(`<blockquote>${quote.join("\n")}</blockquote>`);
504
+ } else if (/^#{1,6}\s/.test(line)) {
505
+ out.push(`<b>${inline(line.replace(/^#+\s*/, "").replace(/\*\*/g, ""))}</b>`);
506
+ } else {
507
+ out.push(inline(line.replace(/^(\s*)[-*]\s/, "$1• ")));
508
+ }
509
+ }
510
+ return out.join("\n");
511
+ }
package/core/clock.ts CHANGED
@@ -20,6 +20,20 @@ export interface Fired {
20
20
  parked?: boolean;
21
21
  }
22
22
 
23
+ /**
24
+ * A run that did not happen, and which of the two it was. `skipped` is the
25
+ * same job already going or already waiting on an answer; `failed` is a run
26
+ * that started and ended in an error, which is written into its own record.
27
+ */
28
+ export interface NotRun {
29
+ skipped?: "busy" | "waiting";
30
+ failed?: string;
31
+ }
32
+
33
+ export function ran(result: Fired | NotRun): result is Fired {
34
+ return "runId" in result;
35
+ }
36
+
23
37
  export interface Clock {
24
38
  stop(): void;
25
39
  /**
@@ -27,9 +41,10 @@ export interface Clock {
27
41
  * what a job that declares an `input` shape is started with, and is checked
28
42
  * against it before the run exists: a caller that sent the wrong thing gets
29
43
  * the error rather than a failed run. `channel` is what the log shows it
30
- * came in on, and is "unknown" when left out.
44
+ * came in on, and is "unknown" when left out. A run that did not happen
45
+ * hands back why, so a caller can say which of the two it was.
31
46
  */
32
- fire(agent: Agent, job: Job, input?: unknown, channel?: string): Promise<Fired | undefined>;
47
+ fire(agent: Agent, job: Job, input?: unknown, channel?: string): Promise<Fired | NotRun>;
33
48
  running(): string[];
34
49
  }
35
50
 
@@ -54,26 +69,27 @@ export function startClock(agents: () => Map<string, Agent>): Clock {
54
69
  let lastMinute = "";
55
70
 
56
71
  /**
57
- * Hands back what the run came to, so whoever started it can answer with it.
58
- * Nothing when it was skipped, because the same job was already going.
72
+ * Hands back what the run came to, so whoever started it can answer with it,
73
+ * or why there is nothing: the same job was already going, or the run
74
+ * failed.
59
75
  *
60
76
  * It throws only for input that does not fit the job's shape, which is the
61
77
  * caller's mistake and worth telling them about. Anything that goes wrong
62
78
  * inside the run is that run's own record, and is logged rather than thrown:
63
79
  * the clock has nobody to tell.
64
80
  */
65
- async function fire(agent: Agent, job: Job, input?: unknown, channel = "unknown"): Promise<Fired | undefined> {
81
+ async function fire(agent: Agent, job: Job, input?: unknown, channel = "unknown"): Promise<Fired | NotRun> {
66
82
  const key = `${agent.name}/${job.id}`;
67
83
  if (busy.has(key)) {
68
84
  console.warn(`${key}: still running from last time, skipping this one`);
69
- return;
85
+ return { skipped: "busy" };
70
86
  }
71
87
  // A job waiting on a person is still that job's turn. Starting a second
72
88
  // one would ask the same question twice and act on whichever came back
73
89
  // first.
74
90
  if (job.run && waitingFor(agent.name, job.id)) {
75
91
  console.warn(`${key}: still waiting on an answer, skipping this one`);
76
- return;
92
+ return { skipped: "waiting" };
77
93
  }
78
94
  busy.add(key);
79
95
  const began = Date.now();
@@ -88,6 +104,7 @@ export function startClock(agents: () => Map<string, Agent>): Clock {
88
104
  } catch (error) {
89
105
  if (error instanceof WrongInput) throw error;
90
106
  console.error(`${key}: failed`, error);
107
+ return { failed: (error as Error).message };
91
108
  } finally {
92
109
  busy.delete(key);
93
110
  }
@@ -0,0 +1,19 @@
1
+ // Which run the code running right now belongs to.
2
+ //
3
+ // A tool is called deep inside a turn and is never handed the run it is part
4
+ // of. A write that becomes a commit still has to say which run made it, so
5
+ // the two runners start each run inside `duringRun` and anything below can
6
+ // ask `currentRun()`.
7
+ import { AsyncLocalStorage } from "node:async_hooks";
8
+
9
+ const current = new AsyncLocalStorage<string>();
10
+
11
+ /** Runs `work` as part of run `runId`, for everything it awaits. */
12
+ export function duringRun<T>(runId: string, work: () => Promise<T>): Promise<T> {
13
+ return current.run(runId, work);
14
+ }
15
+
16
+ /** The run this code is part of, or undefined outside one. */
17
+ export function currentRun(): string | undefined {
18
+ return current.getStore();
19
+ }
package/core/db.ts CHANGED
@@ -76,10 +76,30 @@ if (added("runs", "job", "text")) {
76
76
  db.exec("update runs set job = source, source = 'schedule' where kind = 'turn' and source not in ('telegram', 'chat', 'api', 'eval', 'studio')");
77
77
  }
78
78
  db.exec("update runs set source = 'terminal' where source = 'npm run'");
79
+ // The commits the run made, as JSON: [{ "in": "memory" | "folder", "id", "subject" }].
80
+ added("runs", "commits", "text");
79
81
  // The tools a reply called, as JSON, so the next turn knows how it was reached.
80
82
  added("messages", "used", "text");
81
83
  db.exec("create index if not exists runs_parked on runs (parked) where parked is not null");
82
84
 
85
+ // When somebody last looked at an agent's changes. One row per agent.
86
+ db.exec("create table if not exists seen (agent text primary key, at text not null)");
87
+
88
+ /** One commit a run made: in the agent's memory, or in the repo its own folder is in. */
89
+ export interface RunCommit {
90
+ in: "memory" | "folder";
91
+ id: string;
92
+ subject: string;
93
+ }
94
+
95
+ /** Adds a commit to the run that made it. */
96
+ export function addCommit(runId: string, commit: RunCommit): void {
97
+ db.prepare("update runs set commits = json_insert(coalesce(commits, '[]'), '$[#]', json(?)) where id = ?").run(
98
+ JSON.stringify(commit),
99
+ runId,
100
+ );
101
+ }
102
+
83
103
  /** Adds the column when it is missing, and says whether it did. */
84
104
  function added(table: string, column: string, declaration: string): boolean {
85
105
  const there = db.prepare("select 1 from pragma_table_info(?) where name = ?").get(table, column);
package/core/notes.ts CHANGED
@@ -11,7 +11,7 @@ import { dirname, join } from "node:path";
11
11
 
12
12
  import type { z } from "zod";
13
13
 
14
- import { STATE } from "./paths.ts";
14
+ import { memoryDir, memoryFolderOf } from "./paths.ts";
15
15
 
16
16
  /** One JSON file an agent keeps, read and written against a schema. */
17
17
  export interface Note<T> {
@@ -21,11 +21,11 @@ export interface Note<T> {
21
21
  }
22
22
 
23
23
  /**
24
- * A note by name, in that agent's own state folder. A shape with a `catch`
25
- * makes a note that is not there read as its default.
24
+ * A note by name, in that agent's memory. A shape with a `catch` makes a note
25
+ * that is not there read as its default.
26
26
  */
27
27
  export function note<T>(agent: string, name: string, shape: z.ZodType<T>): Note<T> {
28
- const path = join(STATE, agent, `${name}.json`);
28
+ const path = join(memoryFolderOf(agent), `${name}.json`);
29
29
  return {
30
30
  path,
31
31
  async read() {
package/core/paths.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  // The paths the runtime itself needs. Nothing here names a person, a home
2
2
  // directory or a machine: the repo finds itself, and everything else is either
3
3
  // relative to that or said in settings.local.json, which is not in source
4
- // control. Paths that belong to one agent live in that agent's folder.
4
+ // control. What one agent writes is in its memory, and never here.
5
+ import { join } from "node:path";
6
+
5
7
  import { ROOT } from "./root.ts";
6
8
  import { setting, settings } from "./settings.ts";
7
9
 
@@ -9,9 +11,26 @@ export { ROOT };
9
11
 
10
12
  // Filled by the loader from what each agent declared, before any tool is bound.
11
13
  let folders = new Map<string, string>();
14
+ let memories = new Map<string, string>();
12
15
 
13
- export function setAgentDirs(declared: Map<string, string>): void {
16
+ export function setAgentDirs(declared: Map<string, string>, memory = new Map<string, string>()): void {
14
17
  folders = declared;
18
+ memories = memory;
19
+ }
20
+
21
+ /** One agent's memory folder, as the loader worked it out. Undefined for an agent not loaded. */
22
+ export function memoryDir(name: string): string | undefined {
23
+ return memories.get(name);
24
+ }
25
+
26
+ /**
27
+ * Where an agent's notes go: its memory folder as the loader worked it out, or
28
+ * where that folder would be for an agent that has not been loaded. Nothing an
29
+ * agent writes goes anywhere but its memory, which is why this never falls back
30
+ * to the state directory.
31
+ */
32
+ export function memoryFolderOf(name: string): string {
33
+ return memories.get(name) ?? join(MEMORIES, name);
15
34
  }
16
35
 
17
36
  /** One agent's own folder: its skills, scripts, evals and prompts. */
@@ -22,8 +41,20 @@ export function agentDir(name: string): string {
22
41
  }
23
42
 
24
43
  /**
25
- * Everything the agents keep: their own folders (journals, metrics, notes) and
26
- * the run history. Unset, it is `data/` inside the repo, which git ignores, so
27
- * a second clone keeps its own state. `git clean -x` would delete it.
44
+ * What the runtime keeps about the agents, as against what they write, which is
45
+ * MEMORIES: the run history, the account, the tokens and the log of what each
46
+ * memory served. None of it is ever in git, and two of those are why: a secret
47
+ * in a repository stays in its history, and the run history is one SQLite file
48
+ * rewritten every run. Unset, this is `data/` inside the repo, which git
49
+ * ignores, so a second clone keeps its own state. `git clean -x` would delete it.
28
50
  */
29
51
  export const STATE = setting(settings.state, "AGENTS_STATE") || `${ROOT}/data`;
52
+
53
+ /**
54
+ * Where the memories are: one folder per agent, and one git repository holding
55
+ * all of them, so an agent's folder stays source and its notes stay out of the
56
+ * repository that source is in. Unset, this is `memory/` inside STATE, so one
57
+ * ignored folder holds everything this box keeps. It is a repository of its own
58
+ * even so: what the agents write is in git, and nothing else in STATE ever is.
59
+ */
60
+ export const MEMORIES = setting(settings.memory, "AGENTS_MEMORY") || `${STATE}/memory`;
package/core/root.ts CHANGED
@@ -6,7 +6,7 @@ import { dirname, resolve } from "node:path";
6
6
 
7
7
  /**
8
8
  * The repo root: the folder that holds chloe.config.ts, walking up from the
9
- * running process. data/, .env and the settings files are found beside it.
9
+ * running process. data/ and the settings files are found beside it.
10
10
  */
11
11
  function findRoot(): string {
12
12
  let dir = process.cwd();
package/core/settings.ts CHANGED
@@ -34,8 +34,13 @@ const schema = z.object({
34
34
  .prefault({}),
35
35
  email: z
36
36
  .object({
37
- /** Who carries an agent's mail. Its key is in that provider's own section. */
38
- provider: z.enum(["resend"]).default("resend"),
37
+ /**
38
+ * Who carries an agent's mail. Its key is in that provider's own
39
+ * section. "none" writes the message to the log and sends nothing, which
40
+ * is what a test run and a box with no mail account use. EMAIL_PROVIDER
41
+ * overrides it for one run.
42
+ */
43
+ provider: z.enum(["resend", "none"]).default("resend"),
39
44
  })
40
45
  .prefault({}),
41
46
  /** Sending mail through Resend. */
@@ -64,8 +69,29 @@ const schema = z.object({
64
69
  email_from: z.string().default(""),
65
70
  })
66
71
  .prefault({}),
72
+ /**
73
+ * Each agent's own settings, under the name in its `agent.ts`: its channels'
74
+ * tokens. Read by that name when the channel starts, so renaming an agent
75
+ * means renaming its entry here, and the server says so when an entry names
76
+ * no agent.
77
+ */
78
+ agents: z
79
+ .record(
80
+ z.string(),
81
+ z
82
+ .object({
83
+ /** Its Telegram bot's token, from @BotFather. */
84
+ telegram: z.string().default(""),
85
+ /** Its Slack app's two tokens: the bot token (xoxb-...) and the app token (xapp-...). */
86
+ slack: z.object({ bot_token: z.string().default(""), app_token: z.string().default("") }).strict().prefault({}),
87
+ })
88
+ .strict(),
89
+ )
90
+ .default({}),
67
91
  /** Everything the agents keep: their folders and the run history. Empty means data/ inside the repo. */
68
92
  state: z.string().default(""),
93
+ /** Where the memories are, one folder per agent. Empty means memory/ inside the state folder. */
94
+ memory: z.string().default(""),
69
95
  /** Which node the unit runs. Empty means whichever is on the path at install. */
70
96
  node: z.string().default(""),
71
97
  });
@@ -109,12 +135,27 @@ export function readSettings(tracked: unknown, local: unknown): Settings {
109
135
  }
110
136
 
111
137
  /**
112
- * The settings this process started with: the schema's defaults, then
113
- * `settings.json`, then `settings.local.json`. One value can still be beaten
114
- * by an environment variable, through `setting()`.
138
+ * The settings in force: the schema's defaults, then `settings.json`, then
139
+ * `settings.local.json`. One value can still be beaten by an environment
140
+ * variable, through `setting()`. The server calls `reloadSettings` when either
141
+ * file changes, so read a value when it is needed rather than keeping a copy.
142
+ * `state` and `memory` are the exceptions: where things are kept needs a restart.
115
143
  */
116
144
  export const settings: Settings = readSettings(read("settings.json"), read("settings.local.json"));
117
145
 
146
+ /**
147
+ * Read both files again, into the same `settings` everything already holds.
148
+ * Throws, and changes nothing, when the files are not valid.
149
+ */
150
+ export function reloadSettings(): void {
151
+ Object.assign(settings, readSettings(read("settings.json"), read("settings.local.json")));
152
+ }
153
+
154
+ /** The entries in `agents` that name none of these agents: usually one that was renamed. */
155
+ export function unclaimed(names: string[]): string[] {
156
+ return Object.keys(settings.agents).filter((name) => !names.includes(name));
157
+ }
158
+
118
159
  /**
119
160
  * A setting, with an environment variable winning if there is one. Reading it
120
161
  * here rather than at import time is what lets a test set one.