@chloejs/core 0.2.3 → 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 +2 -1
- package/channels/shared.ts +20 -2
- package/channels/slack.ts +9 -6
- package/channels/telegram.ts +129 -14
- package/core/clock.ts +24 -7
- package/core/current.ts +19 -0
- package/core/db.ts +20 -0
- package/core/notes.ts +4 -4
- package/core/paths.ts +36 -5
- package/core/root.ts +1 -1
- package/core/settings.ts +46 -5
- package/core/steps.ts +16 -1
- package/core/turn.ts +49 -18
- package/index.ts +3 -3
- package/load/load.ts +172 -38
- package/model/tools/files.ts +18 -4
- package/model/tools/gmail.ts +3 -3
- package/model/tools/index.ts +2 -2
- package/model/tools/memory.ts +19 -14
- package/model/tools/own_files.ts +46 -0
- package/model/tools/run_script.ts +3 -3
- package/model/tools/send_email.ts +36 -9
- package/ops/test.ts +448 -15
- package/package.json +1 -1
- package/serve/changes.ts +61 -0
- package/serve/files.ts +26 -4
- package/serve/http.ts +77 -5
- package/serve/login.ts +46 -3
- package/serve/memory.ts +43 -34
- package/serve/site.ts +6 -1
- package/server.ts +39 -20
- package/services/emailService.ts +144 -8
- package/services/filesService.ts +28 -17
- package/services/gmailService.ts +3 -3
- package/services/historyService.ts +372 -0
- package/services/index.ts +3 -3
- package/services/ownFilesService.ts +141 -0
- package/services/scriptsService.ts +12 -7
- package/model/tools/write_skill.ts +0 -31
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 |
|
|
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. |
|
package/channels/shared.ts
CHANGED
|
@@ -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)
|
|
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
|
|
18
|
-
//
|
|
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
|
|
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 ||
|
|
111
|
-
const appToken = options.credentials?.appToken ||
|
|
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
|
-
|
|
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
|
}
|
package/channels/telegram.ts
CHANGED
|
@@ -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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
//
|
|
25
|
-
//
|
|
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 =
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
/**
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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, """)}">${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 |
|
|
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
|
-
*
|
|
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 |
|
|
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
|
}
|
package/core/current.ts
ADDED
|
@@ -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 {
|
|
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
|
|
25
|
-
*
|
|
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(
|
|
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.
|
|
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
|
-
*
|
|
26
|
-
* the run history
|
|
27
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
38
|
-
|
|
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
|
|
113
|
-
* `settings.
|
|
114
|
-
*
|
|
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.
|