@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.
- package/LICENSE +21 -0
- package/README.md +221 -0
- package/channels/api.ts +41 -0
- package/channels/shared.ts +250 -0
- package/channels/slack.ts +390 -0
- package/channels/telegram.ts +396 -0
- package/core/clock.ts +126 -0
- package/core/confine.ts +45 -0
- package/core/db.ts +117 -0
- package/core/markdown.ts +95 -0
- package/core/notes.ts +44 -0
- package/core/paths.ts +29 -0
- package/core/root.ts +26 -0
- package/core/settings.ts +124 -0
- package/core/steps.ts +896 -0
- package/core/turn.ts +314 -0
- package/do/email.ts +45 -0
- package/do/files.ts +96 -0
- package/do/mail.ts +155 -0
- package/do/run.ts +56 -0
- package/do/scripts.ts +49 -0
- package/do/web.ts +192 -0
- package/index.ts +52 -0
- package/load/job.ts +84 -0
- package/load/load.ts +478 -0
- package/model/ask.ts +84 -0
- package/model/claude.ts +261 -0
- package/model/memory.ts +68 -0
- package/model/model.ts +185 -0
- package/model/tool.ts +53 -0
- package/model/tools/files.ts +71 -0
- package/model/tools/gmail.ts +43 -0
- package/model/tools/index.ts +28 -0
- package/model/tools/memory.ts +23 -0
- package/model/tools/run_script.ts +44 -0
- package/model/tools/send_email.ts +29 -0
- package/model/tools/web.ts +23 -0
- package/model/tools/write_skill.ts +31 -0
- package/ops/account.ts +109 -0
- package/ops/agent.ts +290 -0
- package/ops/check.ts +37 -0
- package/ops/evals.ts +206 -0
- package/ops/install.sh +101 -0
- package/ops/test.ts +1976 -0
- package/package.json +65 -0
- package/scorers/calls.ts +50 -0
- package/scorers/expectations.ts +118 -0
- package/scorers/index.ts +5 -0
- package/serve/alerts.ts +79 -0
- package/serve/errors.ts +10 -0
- package/serve/files.ts +70 -0
- package/serve/http.ts +767 -0
- package/serve/login.ts +299 -0
- package/serve/memory.ts +372 -0
- package/serve/page.ts +142 -0
- package/serve/pass.ts +45 -0
- package/serve/recentWork.ts +69 -0
- package/serve/site.ts +409 -0
- package/serve/tokens.ts +132 -0
- package/server.ts +170 -0
- package/timer/cron.ts +92 -0
- package/timer/every.ts +153 -0
- package/timer/index.ts +4 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// Talking to an agent from Telegram. It is one entry in the agent's channels:
|
|
2
|
+
//
|
|
3
|
+
// // agents/<name>/agent.ts
|
|
4
|
+
// import { telegramChannel } from "@chloejs/core/channels/telegram";
|
|
5
|
+
// channels: [telegramChannel({ allowFrom: [111111111] })],
|
|
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.
|
|
10
|
+
//
|
|
11
|
+
// allowFrom is who may talk to the agent, by Telegram user id, in any chat,
|
|
12
|
+
// including a group made later. Anyone can find a bot and message it, so
|
|
13
|
+
// leave it empty only the first time: until it has an entry, the bot answers a
|
|
14
|
+
// private message with the sender's user id, which is what goes here. The
|
|
15
|
+
// first id is also who the agent's jobs ask when they name nobody.
|
|
16
|
+
//
|
|
17
|
+
// Two ways for messages to arrive, and `mode` picks:
|
|
18
|
+
//
|
|
19
|
+
// "polling" chloe asks Telegram for them. Nothing is exposed, and a message
|
|
20
|
+
// sent while chloe is down is picked up when it comes back. The
|
|
21
|
+
// default.
|
|
22
|
+
// "webhook" Telegram sends each one to publicUrl + /chloe/v1/<agent>/telegram,
|
|
23
|
+
// 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.
|
|
26
|
+
//
|
|
27
|
+
// What happens to a message once it is read (allowFrom, a job waiting on an
|
|
28
|
+
// answer, /commands, jobs that answer plain messages, groups, the chat) is
|
|
29
|
+
// channels/shared.ts, the same for every channel. This file reads Telegram,
|
|
30
|
+
// sends to it, and nothing else. With `inGroups: "always"`, remember that
|
|
31
|
+
// Telegram only hands a bot every group message when its privacy mode is off
|
|
32
|
+
// (BotFather, /setprivacy) or it is a group admin.
|
|
33
|
+
import { randomBytes } from "node:crypto";
|
|
34
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
35
|
+
|
|
36
|
+
import { type Agent, type Channel, type ChatHistory, type Running } from "#chloe/load/load.ts";
|
|
37
|
+
import { ownedBy, reachBy, unreach } from "#chloe/model/ask.ts";
|
|
38
|
+
import type { Attachment } from "#chloe/model/model.ts";
|
|
39
|
+
import { commands, receive, type Incoming, type Rules } from "./shared.ts";
|
|
40
|
+
|
|
41
|
+
const MAX_MESSAGE = 4000; // Telegram rejects anything over 4096.
|
|
42
|
+
const WAIT = 50; // Seconds Telegram holds a poll open when there is nothing new.
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How an agent is put on Telegram: who may reach it, and whether messages are
|
|
46
|
+
* fetched or posted.
|
|
47
|
+
*/
|
|
48
|
+
export interface TelegramOptions {
|
|
49
|
+
/**
|
|
50
|
+
* "telegram" unless the agent has two bots. It is what the log shows a run
|
|
51
|
+
* came in on, and the start of every address on this bot, like "telegram:123".
|
|
52
|
+
*/
|
|
53
|
+
name?: string;
|
|
54
|
+
/** For spotting a mention in a group. Asked of Telegram when left out. */
|
|
55
|
+
botUsername?: string;
|
|
56
|
+
/** Instead of TELEGRAM_BOT_TOKEN and TELEGRAM_WEBHOOK_SECRET_TOKEN. */
|
|
57
|
+
credentials?: { botToken?: string; webhookSecretToken?: string };
|
|
58
|
+
/** Telegram user ids that may reach the agent. */
|
|
59
|
+
allowFrom?: number[];
|
|
60
|
+
/**
|
|
61
|
+
* In a group, "when-addressed" (the default) answers only a command, a
|
|
62
|
+
* mention or a reply to the bot. "always" answers every message from
|
|
63
|
+
* someone in allowFrom.
|
|
64
|
+
*/
|
|
65
|
+
inGroups?: "when-addressed" | "always";
|
|
66
|
+
/** How much of a chat's conversation a turn is shown: `{ messages, days }`. */
|
|
67
|
+
chatHistory?: ChatHistory;
|
|
68
|
+
/** Send what the model writes on its way to an answer as it writes it, not only the answer. Off unless true. */
|
|
69
|
+
sendWhileWorking?: boolean;
|
|
70
|
+
mode?: "polling" | "webhook";
|
|
71
|
+
/** Where this server is reachable from outside, for mode "webhook", like "https://agents.example.com". */
|
|
72
|
+
publicUrl?: string;
|
|
73
|
+
/** Which files are taken, and how big. Anything else is named to the agent but not handed over. */
|
|
74
|
+
uploadPolicy?: { allowedMediaTypes?: string[]; maxBytes?: number };
|
|
75
|
+
/** Where Telegram is. Only the tests change it. */
|
|
76
|
+
api?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** How far each bot has read. Outlives a reader, so one started after an edit is not handed the same messages again. */
|
|
80
|
+
const read = new Map<string, number>();
|
|
81
|
+
|
|
82
|
+
/** Which agent has each bot. Two readers of one bot would each get half the messages. */
|
|
83
|
+
const taken = new Map<string, string>();
|
|
84
|
+
|
|
85
|
+
interface User {
|
|
86
|
+
id: number;
|
|
87
|
+
is_bot?: boolean;
|
|
88
|
+
first_name?: string;
|
|
89
|
+
username?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface TgMessage {
|
|
93
|
+
message_id: number;
|
|
94
|
+
message_thread_id?: number;
|
|
95
|
+
is_topic_message?: boolean;
|
|
96
|
+
from?: User;
|
|
97
|
+
chat: { id: number; type: "private" | "group" | "supergroup" | "channel"; title?: string };
|
|
98
|
+
text?: string;
|
|
99
|
+
caption?: string;
|
|
100
|
+
entities?: { type: string; offset: number; length: number }[];
|
|
101
|
+
caption_entities?: { type: string; offset: number; length: number }[];
|
|
102
|
+
reply_to_message?: { from?: User; text?: string; caption?: string };
|
|
103
|
+
photo?: { file_id: string; file_size?: number }[];
|
|
104
|
+
document?: { file_id: string; file_name?: string; mime_type?: string; file_size?: number };
|
|
105
|
+
reply_markup?: { inline_keyboard?: { text: string; callback_data?: string }[][] };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface Update {
|
|
109
|
+
update_id: number;
|
|
110
|
+
message?: TgMessage;
|
|
111
|
+
callback_query?: { id: string; from: User; data?: string; message?: TgMessage };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** An agent on Telegram, as a channel its own `agent.ts` names. */
|
|
115
|
+
export function telegramChannel(options: TelegramOptions = {}): Channel {
|
|
116
|
+
return {
|
|
117
|
+
name: options.name ?? "telegram",
|
|
118
|
+
chatHistory: options.chatHistory,
|
|
119
|
+
start(agent) {
|
|
120
|
+
const name = agent()?.name ?? "";
|
|
121
|
+
const token = options.credentials?.botToken || process.env.TELEGRAM_BOT_TOKEN || "";
|
|
122
|
+
if (!token) {
|
|
123
|
+
console.error(
|
|
124
|
+
`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.",
|
|
126
|
+
);
|
|
127
|
+
return { stop: () => {} };
|
|
128
|
+
}
|
|
129
|
+
const holder = taken.get(token);
|
|
130
|
+
if (holder && holder !== name) {
|
|
131
|
+
console.error(`telegram: ${holder} already answers this bot, so ${name}'s channel does nothing. Give it its own bot.`);
|
|
132
|
+
return { stop: () => {} };
|
|
133
|
+
}
|
|
134
|
+
taken.set(token, name);
|
|
135
|
+
const running = listen({ ...options, name, channel: options.name, token, agent });
|
|
136
|
+
return {
|
|
137
|
+
routes: running.routes,
|
|
138
|
+
stop() {
|
|
139
|
+
running.stop();
|
|
140
|
+
taken.delete(token);
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Reads messages until stopped. Separate from the channel so the tests can point it somewhere else. */
|
|
148
|
+
export function listen(
|
|
149
|
+
options: Omit<TelegramOptions, "name"> & {
|
|
150
|
+
/** The agent's name. */
|
|
151
|
+
name: string;
|
|
152
|
+
/** The channel's name, "telegram" when left out. */
|
|
153
|
+
channel?: string;
|
|
154
|
+
token: string;
|
|
155
|
+
agent: () => Agent | undefined;
|
|
156
|
+
},
|
|
157
|
+
): Running {
|
|
158
|
+
const { name, token } = options;
|
|
159
|
+
const channel = options.channel ?? "telegram";
|
|
160
|
+
const api = options.api ?? "https://api.telegram.org";
|
|
161
|
+
const rules: Rules = { allowFrom: options.allowFrom ?? [], inGroups: options.inGroups, chatHistory: options.chatHistory, sendWhileWorking: options.sendWhileWorking };
|
|
162
|
+
const mode = options.mode ?? "polling";
|
|
163
|
+
const path = `/chloe/v1/${name}/${channel}`;
|
|
164
|
+
const secret = options.credentials?.webhookSecretToken || process.env.TELEGRAM_WEBHOOK_SECRET_TOKEN || randomBytes(24).toString("hex");
|
|
165
|
+
const allowedTypes = options.uploadPolicy?.allowedMediaTypes ?? ["image/*", "application/pdf", "text/*"];
|
|
166
|
+
const maxBytes = options.uploadPolicy?.maxBytes ?? 10 * 1024 * 1024;
|
|
167
|
+
const stopping = new AbortController();
|
|
168
|
+
let me: User | undefined;
|
|
169
|
+
|
|
170
|
+
async function call<T>(method: string, body: object, seconds = 30, signal: AbortSignal | null = stopping.signal): Promise<T> {
|
|
171
|
+
const timeout = AbortSignal.timeout(seconds * 1000);
|
|
172
|
+
const response = await fetch(`${api}/bot${token}/${method}`, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: { "Content-Type": "application/json" },
|
|
175
|
+
body: JSON.stringify(body),
|
|
176
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
177
|
+
});
|
|
178
|
+
const reply = (await response.json()) as { ok: boolean; result?: T; description?: string };
|
|
179
|
+
if (!reply.ok) throw new Error(`${method}: ${reply.description ?? response.status}`);
|
|
180
|
+
return reply.result as T;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Sending is not stopped with the reading: a turn already under way still answers. */
|
|
184
|
+
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);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// A job of this agent's that stops to ask somebody reaches them through this
|
|
192
|
+
// bot. Answers that can be listed become buttons, and anything
|
|
193
|
+
// else asks for a reply.
|
|
194
|
+
reachBy(
|
|
195
|
+
channel,
|
|
196
|
+
(to, text, choices) =>
|
|
197
|
+
send(Number(to), text, {
|
|
198
|
+
reply_markup: choices?.length
|
|
199
|
+
? { inline_keyboard: [choices.map((choice, i) => ({ text: choice, callback_data: `a:${i}` }))] }
|
|
200
|
+
: { force_reply: true },
|
|
201
|
+
}),
|
|
202
|
+
name,
|
|
203
|
+
);
|
|
204
|
+
// A private chat's id is the person's user id, so the first allowed person is reachable at it.
|
|
205
|
+
if (options.allowFrom?.[0]) ownedBy(name, `${channel}:${options.allowFrom[0]}`);
|
|
206
|
+
|
|
207
|
+
function wanted(mediaType: string): boolean {
|
|
208
|
+
return allowedTypes.some((one) => (one.endsWith("/*") ? mediaType.startsWith(one.slice(0, -1)) : one === mediaType));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The photo or document on a message, fetched, or a line saying why it was not. */
|
|
212
|
+
async function fileOn(message: TgMessage): Promise<{ attachment?: Attachment; text?: string; note?: string }> {
|
|
213
|
+
const photo = message.photo?.at(-1); // The largest size.
|
|
214
|
+
const doc = message.document;
|
|
215
|
+
if (!photo && !doc) return {};
|
|
216
|
+
const fileName = doc?.file_name ?? "photo.jpg";
|
|
217
|
+
const mediaType = doc ? (doc.mime_type ?? "application/octet-stream") : "image/jpeg";
|
|
218
|
+
const size = (photo ?? doc)!.file_size ?? 0;
|
|
219
|
+
if (!wanted(mediaType)) return { note: `(They sent ${fileName}, a ${mediaType}, which this channel does not take.)` };
|
|
220
|
+
if (size > maxBytes) return { note: `(They sent ${fileName}, which is over the ${Math.round(maxBytes / 1048576)}MB this channel takes.)` };
|
|
221
|
+
const file = await call<{ file_path: string }>("getFile", { file_id: (photo ?? doc)!.file_id });
|
|
222
|
+
const response = await fetch(`${api}/file/bot${token}/${file.file_path}`, { signal: AbortSignal.timeout(60_000) });
|
|
223
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
224
|
+
if (mediaType.startsWith("text/")) return { text: `<file name="${fileName}">\n${bytes.toString("utf8")}\n</file>` };
|
|
225
|
+
return { attachment: { mediaType, data: bytes.toString("base64"), name: fileName }, note: `(Attached: ${fileName})` };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Shows "typing..." until the returned function is called. Telegram clears
|
|
230
|
+
* it after about five seconds, so it is sent again every four.
|
|
231
|
+
*/
|
|
232
|
+
function typing(chatId: number, topic?: number): () => void {
|
|
233
|
+
const once = () =>
|
|
234
|
+
call("sendChatAction", { chat_id: chatId, action: "typing", ...(topic ? { message_thread_id: topic } : {}) }).catch(() => {});
|
|
235
|
+
void once();
|
|
236
|
+
const timer = setInterval(once, 4000);
|
|
237
|
+
return () => clearInterval(timer);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** A Telegram message in the words every channel shares. The rest is receive()'s. */
|
|
241
|
+
function incoming(message: TgMessage, from: User, text: string): Incoming {
|
|
242
|
+
const chatId = message.chat.id;
|
|
243
|
+
const username = options.botUsername ?? me?.username ?? "";
|
|
244
|
+
const entities = message.entities ?? message.caption_entities ?? [];
|
|
245
|
+
const mentioned =
|
|
246
|
+
!!username &&
|
|
247
|
+
entities.some((e) => e.type === "mention" && text.slice(e.offset, e.offset + e.length).toLowerCase() === `@${username.toLowerCase()}`);
|
|
248
|
+
const replyToBot = !!me && message.reply_to_message?.from?.id === me.id;
|
|
249
|
+
// A forum topic is its own conversation.
|
|
250
|
+
const topic = message.is_topic_message ? message.message_thread_id : undefined;
|
|
251
|
+
const quoted = message.reply_to_message;
|
|
252
|
+
return {
|
|
253
|
+
channel,
|
|
254
|
+
chat: String(chatId),
|
|
255
|
+
thread: `${name}/${channel}-${chatId}${topic ? `-${topic}` : ""}`,
|
|
256
|
+
from: { id: String(from.id), name: from.username ? `@${from.username}` : (from.first_name ?? String(from.id)) },
|
|
257
|
+
text,
|
|
258
|
+
private: message.chat.type === "private",
|
|
259
|
+
addressed: mentioned || replyToBot,
|
|
260
|
+
chatTitle: message.chat.title ?? "",
|
|
261
|
+
replyTo: quoted?.text ?? quoted?.caption ?? "",
|
|
262
|
+
context: {
|
|
263
|
+
chat_type: message.chat.type,
|
|
264
|
+
...(message.chat.title ? { chat_title: message.chat.title } : {}),
|
|
265
|
+
bot_username: username,
|
|
266
|
+
is_mentioned: String(mentioned),
|
|
267
|
+
},
|
|
268
|
+
files: async () => {
|
|
269
|
+
const file = await fileOn(message);
|
|
270
|
+
return { attachments: file.attachment ? [file.attachment] : [], text: file.text, notes: file.note ? [file.note] : [] };
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function onMessage(message: TgMessage): Promise<void> {
|
|
276
|
+
const agent = options.agent();
|
|
277
|
+
const text = message.text ?? message.caption ?? "";
|
|
278
|
+
if (!agent || !message.from || (!text && !message.photo && !message.document)) return;
|
|
279
|
+
const chatId = message.chat.id;
|
|
280
|
+
const topic = message.is_topic_message ? message.message_thread_id : undefined;
|
|
281
|
+
const extra = {
|
|
282
|
+
...(topic ? { message_thread_id: topic } : {}),
|
|
283
|
+
...(message.chat.type === "private" ? {} : { reply_parameters: { message_id: message.message_id } }),
|
|
284
|
+
};
|
|
285
|
+
const handled = await receive(agent, incoming(message, message.from, text), rules, {
|
|
286
|
+
working: () => typing(chatId, topic),
|
|
287
|
+
send: (words) => send(chatId, words, extra),
|
|
288
|
+
});
|
|
289
|
+
if (handled?.text) await send(chatId, handled.text, extra).catch((error) => console.error("telegram:", error.message));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** A pressed button is its text, sent by whoever pressed it, which is how it answers a waiting job. */
|
|
293
|
+
async function onButton(query: NonNullable<Update["callback_query"]>): Promise<void> {
|
|
294
|
+
await call("answerCallbackQuery", { callback_query_id: query.id }).catch(() => {});
|
|
295
|
+
const message = query.message;
|
|
296
|
+
const agent = options.agent();
|
|
297
|
+
if (!message || !agent) return;
|
|
298
|
+
const index = Number(query.data?.replace(/^a:/, ""));
|
|
299
|
+
const choice = message.reply_markup?.inline_keyboard?.flat()[index]?.text;
|
|
300
|
+
if (!choice) return;
|
|
301
|
+
const topic = message.message_thread_id;
|
|
302
|
+
const handled = await receive(agent, { ...incoming(message, query.from, choice), addressed: true }, rules);
|
|
303
|
+
if (!handled) return;
|
|
304
|
+
// Take the buttons away so the question cannot be answered twice, and say what was chosen.
|
|
305
|
+
await call("editMessageText", { chat_id: message.chat.id, message_id: message.message_id, text: `${message.text ?? ""}\n\n→ ${choice}` }).catch(() => {});
|
|
306
|
+
if (handled.text) await send(message.chat.id, handled.text, topic ? { message_thread_id: topic } : {}).catch(() => {});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The menu Telegram shows when somebody types "/" is this agent's jobs, each
|
|
311
|
+
* as its id with "_" for "-" (Telegram allows no hyphens) and its description.
|
|
312
|
+
* It replaces whatever the menu held before, including anything set in
|
|
313
|
+
* BotFather. Only sent when the list has changed, so an edited or new job is
|
|
314
|
+
* in the menu within one poll and no call is spent on a list that has not.
|
|
315
|
+
*/
|
|
316
|
+
let menu = "";
|
|
317
|
+
async function syncMenu(): Promise<void> {
|
|
318
|
+
const agent = options.agent();
|
|
319
|
+
if (!agent) return;
|
|
320
|
+
const listed = commands(agent);
|
|
321
|
+
const now = JSON.stringify(listed);
|
|
322
|
+
if (now === menu) return;
|
|
323
|
+
await call("setMyCommands", { commands: listed });
|
|
324
|
+
menu = now;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function handle(update: Update): void {
|
|
328
|
+
if (update.message) void onMessage(update.message).catch((error) => console.error("telegram:", error));
|
|
329
|
+
if (update.callback_query) void onButton(update.callback_query).catch((error) => console.error("telegram:", error));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
void (async () => {
|
|
333
|
+
me = await call<User>("getMe", {}).catch(() => undefined);
|
|
334
|
+
if (mode === "webhook") {
|
|
335
|
+
if (!options.publicUrl) {
|
|
336
|
+
console.error(`telegram: ${name} is in webhook mode with no publicUrl, so Telegram has nowhere to send messages.`);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
await call("setWebhook", {
|
|
340
|
+
url: `${options.publicUrl.replace(/\/+$/, "")}${path}`,
|
|
341
|
+
secret_token: secret,
|
|
342
|
+
allowed_updates: ["message", "callback_query"],
|
|
343
|
+
}).catch((error) => console.error("telegram:", error.message));
|
|
344
|
+
await syncMenu().catch((error) => console.error("telegram: setting the / menu failed:", error.message));
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
// Telegram will not hand out messages while a webhook is registered.
|
|
348
|
+
await call("deleteWebhook", {}).catch((error) => console.error("telegram:", error.message));
|
|
349
|
+
while (!stopping.signal.aborted) {
|
|
350
|
+
await syncMenu().catch((error) => console.error("telegram: setting the / menu failed:", error.message));
|
|
351
|
+
try {
|
|
352
|
+
const offset = read.get(token) ?? 0;
|
|
353
|
+
const updates = await call<Update[]>(
|
|
354
|
+
"getUpdates",
|
|
355
|
+
{ offset, timeout: WAIT, allowed_updates: ["message", "callback_query"] },
|
|
356
|
+
WAIT + 10,
|
|
357
|
+
);
|
|
358
|
+
// Asking with the next offset is what tells Telegram these arrived.
|
|
359
|
+
for (const update of updates) {
|
|
360
|
+
if (stopping.signal.aborted) break;
|
|
361
|
+
read.set(token, update.update_id + 1);
|
|
362
|
+
handle(update);
|
|
363
|
+
}
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (stopping.signal.aborted) break;
|
|
366
|
+
console.error(`telegram: ${name} could not read messages, trying again in 5s:`, (error as Error).message);
|
|
367
|
+
await new Promise((done) => setTimeout(done, 5000));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
})();
|
|
371
|
+
|
|
372
|
+
const webhook = async (request: IncomingMessage, response: ServerResponse): Promise<void> => {
|
|
373
|
+
// Nothing without the secret, and no hint about what was wrong.
|
|
374
|
+
if (request.headers["x-telegram-bot-api-secret-token"] !== secret) {
|
|
375
|
+
response.writeHead(401).end();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
let raw = "";
|
|
379
|
+
for await (const chunk of request) raw += chunk;
|
|
380
|
+
// Always 200 once the secret checks out: anything else makes Telegram send the same update again.
|
|
381
|
+
response.writeHead(200, { "content-type": "application/json" }).end('{"ok":true}');
|
|
382
|
+
try {
|
|
383
|
+
handle(JSON.parse(raw) as Update);
|
|
384
|
+
} catch {
|
|
385
|
+
// Not JSON: nothing to do, and Telegram has had its 200.
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
routes: mode === "webhook" ? [{ path, handle: webhook }] : [],
|
|
391
|
+
stop() {
|
|
392
|
+
stopping.abort();
|
|
393
|
+
unreach(channel, name);
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
}
|
package/core/clock.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The clock: anything due this minute runs. What is due comes from the files on disk, so
|
|
2
|
+
// an edited or deleted job takes effect on the next tick with no table to
|
|
3
|
+
// get out of step with the folder.
|
|
4
|
+
import type { Agent, Job } from "#chloe/load/load.ts";
|
|
5
|
+
import { due, parse } from "#chloe/timer/cron.ts";
|
|
6
|
+
import { turn } from "#chloe/core/turn.ts";
|
|
7
|
+
import { sweep, waitingFor, work, WrongInput } from "#chloe/core/steps.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What a run came to, in the part both kinds of job have: a job made of code
|
|
11
|
+
* can be waiting on a person, and a job made of a prompt cannot.
|
|
12
|
+
*/
|
|
13
|
+
export interface Fired {
|
|
14
|
+
runId: string;
|
|
15
|
+
text: string;
|
|
16
|
+
summary?: string | null;
|
|
17
|
+
reply?: string;
|
|
18
|
+
steps: number;
|
|
19
|
+
cost: number;
|
|
20
|
+
parked?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Clock {
|
|
24
|
+
stop(): void;
|
|
25
|
+
/**
|
|
26
|
+
* Run one now, whatever its cron line says, or when it has none. `input` is
|
|
27
|
+
* what a job that declares an `input` shape is started with, and is checked
|
|
28
|
+
* against it before the run exists: a caller that sent the wrong thing gets
|
|
29
|
+
* the error rather than a failed run. `channel` is what the log shows it
|
|
30
|
+
* came in on, and is "unknown" when left out.
|
|
31
|
+
*/
|
|
32
|
+
fire(agent: Agent, job: Job, input?: unknown, channel?: string): Promise<Fired | undefined>;
|
|
33
|
+
running(): string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The clock this process started, for a channel that needs to run a job.
|
|
38
|
+
*
|
|
39
|
+
* A module-level handle rather than another argument threaded through every
|
|
40
|
+
* channel, the same way model/ask.ts keeps the registry of who can be reached.
|
|
41
|
+
* Going through it rather than calling work() directly is what keeps the guard
|
|
42
|
+
* against two runs of one job overlapping.
|
|
43
|
+
*/
|
|
44
|
+
let current: Clock | undefined;
|
|
45
|
+
|
|
46
|
+
export function clock(): Clock | undefined {
|
|
47
|
+
return current;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function startClock(agents: () => Map<string, Agent>): Clock {
|
|
51
|
+
// Two runs of one job must never overlap: a morning run that takes
|
|
52
|
+
// eighty minutes would write over the next one's journal.
|
|
53
|
+
const busy = new Set<string>();
|
|
54
|
+
let lastMinute = "";
|
|
55
|
+
|
|
56
|
+
/**
|
|
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.
|
|
59
|
+
*
|
|
60
|
+
* It throws only for input that does not fit the job's shape, which is the
|
|
61
|
+
* caller's mistake and worth telling them about. Anything that goes wrong
|
|
62
|
+
* inside the run is that run's own record, and is logged rather than thrown:
|
|
63
|
+
* the clock has nobody to tell.
|
|
64
|
+
*/
|
|
65
|
+
async function fire(agent: Agent, job: Job, input?: unknown, channel = "unknown"): Promise<Fired | undefined> {
|
|
66
|
+
const key = `${agent.name}/${job.id}`;
|
|
67
|
+
if (busy.has(key)) {
|
|
68
|
+
console.warn(`${key}: still running from last time, skipping this one`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
// A job waiting on a person is still that job's turn. Starting a second
|
|
72
|
+
// one would ask the same question twice and act on whichever came back
|
|
73
|
+
// first.
|
|
74
|
+
if (job.run && waitingFor(agent.name, job.id)) {
|
|
75
|
+
console.warn(`${key}: still waiting on an answer, skipping this one`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
busy.add(key);
|
|
79
|
+
const began = Date.now();
|
|
80
|
+
try {
|
|
81
|
+
const result = job.run
|
|
82
|
+
? await work({ agent, job, source: channel, input })
|
|
83
|
+
: await turn({ agent, prompt: job.prompt, model: job.model, source: channel, job: job.id });
|
|
84
|
+
const seconds = Math.round((Date.now() - began) / 1000);
|
|
85
|
+
const how = "parked" in result && result.parked ? "waiting on an answer" : "done";
|
|
86
|
+
console.log(`${key}: ${how} in ${seconds}s, ${result.steps} steps, $${result.cost.toFixed(4)}`);
|
|
87
|
+
return result;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error instanceof WrongInput) throw error;
|
|
90
|
+
console.error(`${key}: failed`, error);
|
|
91
|
+
} finally {
|
|
92
|
+
busy.delete(key);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function tick(): void {
|
|
97
|
+
const now = new Date();
|
|
98
|
+
const minute = now.toISOString().slice(0, 16);
|
|
99
|
+
if (minute === lastMinute) return;
|
|
100
|
+
lastMinute = minute;
|
|
101
|
+
|
|
102
|
+
// Before anything is due: a question nobody answered holds its job,
|
|
103
|
+
// so giving up on it is what lets that job run again.
|
|
104
|
+
void sweep(agents()).catch((error: unknown) => console.error("sweep failed", error));
|
|
105
|
+
|
|
106
|
+
for (const agent of agents().values()) {
|
|
107
|
+
for (const job of agent.jobs) {
|
|
108
|
+
// No cron line: it runs only when somebody starts it. A bad one never
|
|
109
|
+
// gets here, because the loader refuses the file.
|
|
110
|
+
if (job.cron && due(parse(job.cron), now, job.timezone)) void fire(agent, job, undefined, "schedule");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Ten seconds, not sixty: a timer that drifts past a minute boundary would
|
|
116
|
+
// skip that minute's jobs, and lastMinute stops the extra ticks counting.
|
|
117
|
+
const timer = setInterval(tick, 10_000);
|
|
118
|
+
tick();
|
|
119
|
+
|
|
120
|
+
current = {
|
|
121
|
+
stop: () => clearInterval(timer),
|
|
122
|
+
fire,
|
|
123
|
+
running: () => [...busy],
|
|
124
|
+
};
|
|
125
|
+
return current;
|
|
126
|
+
}
|
package/core/confine.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Keeping a caller-supplied path inside one folder.
|
|
2
|
+
//
|
|
3
|
+
// This is the boundary, so it is code and not a sentence in a prompt: nothing
|
|
4
|
+
// a model says can talk its way past it. It knows nothing about what is in the
|
|
5
|
+
// folder, only where the folder ends.
|
|
6
|
+
import { realpathSync } from "node:fs";
|
|
7
|
+
import { resolve, sep } from "node:path";
|
|
8
|
+
|
|
9
|
+
/** Never reachable through a file tool, whatever the folder is. */
|
|
10
|
+
const NEVER = [".git", ".ssh", "secrets", "node_modules"];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether a name is one of those. For something listing a folder, which should
|
|
14
|
+
* leave them out rather than show a folder that cannot be opened, and should
|
|
15
|
+
* certainly not stop listing everything else because one of them is there.
|
|
16
|
+
*/
|
|
17
|
+
export function unreachable(name: string): boolean {
|
|
18
|
+
return NEVER.includes(name);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve `input` inside `root`, or throw. Accepts a path relative to the
|
|
23
|
+
* folder or the absolute form of the same file. Symlinks are resolved first, so
|
|
24
|
+
* a link inside the folder cannot point out of it. A path that does not exist
|
|
25
|
+
* yet is fine: a write needs one.
|
|
26
|
+
*/
|
|
27
|
+
export function confine(root: string, input: string): string {
|
|
28
|
+
const base = realpathSync(root);
|
|
29
|
+
const inside = (p: string) => p === base || p.startsWith(base + sep);
|
|
30
|
+
|
|
31
|
+
const normalized = resolve(input.startsWith(base) ? input : resolve(base, input));
|
|
32
|
+
if (!inside(normalized)) throw new Error(`Path is outside ${base}: ${input}`);
|
|
33
|
+
|
|
34
|
+
const hit = normalized.slice(base.length).split(sep).find((part) => NEVER.includes(part));
|
|
35
|
+
if (hit) throw new Error(`${hit} is not readable or writable through this tool.`);
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const real = realpathSync(normalized);
|
|
39
|
+
if (!inside(real)) throw new Error(`Path resolves outside ${base}: ${input}`);
|
|
40
|
+
return real;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return normalized;
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
package/core/db.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { mkdir, stat } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { backup, DatabaseSync } from "node:sqlite";
|
|
5
|
+
|
|
6
|
+
import { STATE } from "./paths.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The run history and the conversations, in one file inside the state folder.
|
|
10
|
+
* The tests set AGENTS_DB to ":memory:" so they never write into the real one.
|
|
11
|
+
*/
|
|
12
|
+
export const DATABASE = process.env.AGENTS_DB || `${STATE}/agents.db`;
|
|
13
|
+
if (DATABASE !== ":memory:") mkdirSync(STATE, { recursive: true });
|
|
14
|
+
|
|
15
|
+
/** The SQLite handle every run, step and conversation is written to. */
|
|
16
|
+
export const db = new DatabaseSync(DATABASE);
|
|
17
|
+
|
|
18
|
+
// Without WAL, a long turn blocks the web page's reads.
|
|
19
|
+
db.exec("pragma journal_mode = wal");
|
|
20
|
+
db.exec("pragma busy_timeout = 5000");
|
|
21
|
+
|
|
22
|
+
db.exec(`
|
|
23
|
+
create table if not exists messages (
|
|
24
|
+
id integer primary key autoincrement,
|
|
25
|
+
thread text not null,
|
|
26
|
+
role text not null,
|
|
27
|
+
content text not null,
|
|
28
|
+
at text not null
|
|
29
|
+
);
|
|
30
|
+
create index if not exists messages_thread on messages (thread, id);
|
|
31
|
+
|
|
32
|
+
create table if not exists runs (
|
|
33
|
+
id text primary key,
|
|
34
|
+
agent text not null,
|
|
35
|
+
started text not null,
|
|
36
|
+
finished text,
|
|
37
|
+
source text not null,
|
|
38
|
+
model text not null,
|
|
39
|
+
prompt text not null,
|
|
40
|
+
reply text,
|
|
41
|
+
error text,
|
|
42
|
+
steps integer not null default 0,
|
|
43
|
+
cost real not null default 0,
|
|
44
|
+
trace text not null default '[]'
|
|
45
|
+
);
|
|
46
|
+
create index if not exists runs_agent on runs (agent, started desc);
|
|
47
|
+
`);
|
|
48
|
+
|
|
49
|
+
// Added after the first runs existed, so they are added rather than declared.
|
|
50
|
+
// A column that is already there is left alone, which is what makes this safe
|
|
51
|
+
// to run on every start.
|
|
52
|
+
added("runs", "kind", "text not null default 'turn'");
|
|
53
|
+
added("runs", "owner", "text");
|
|
54
|
+
added("runs", "state", "text");
|
|
55
|
+
added("runs", "parked", "text");
|
|
56
|
+
// One line saying what the run did, for the overview. A job writes its own.
|
|
57
|
+
added("runs", "summary", "text");
|
|
58
|
+
// What the run was started with, when it was started by hand. Kept whole so a
|
|
59
|
+
// run that parks for a person comes back to the same input it began with.
|
|
60
|
+
added("runs", "input", "text");
|
|
61
|
+
// The job this run was, or null for a turn somebody started by talking to it.
|
|
62
|
+
// `source` is then only the channel it came in on.
|
|
63
|
+
if (added("runs", "job", "text")) {
|
|
64
|
+
// Before this column a job's run kept its id in `source`, and where it came
|
|
65
|
+
// from is only known from what it was started with.
|
|
66
|
+
db.exec(`
|
|
67
|
+
update runs set job = source,
|
|
68
|
+
source = case
|
|
69
|
+
when json_valid(input) and json_extract(input, '$.from') = 'telegram' then 'telegram'
|
|
70
|
+
when json_valid(input) and json_extract(input, '$.from') = 'terminal' then 'terminal'
|
|
71
|
+
when json_valid(input) and coalesce(json_extract(input, '$.from'), '') != '' then 'api'
|
|
72
|
+
else 'schedule'
|
|
73
|
+
end
|
|
74
|
+
where kind = 'job' and source != 'eval'
|
|
75
|
+
`);
|
|
76
|
+
db.exec("update runs set job = source, source = 'schedule' where kind = 'turn' and source not in ('telegram', 'chat', 'api', 'eval', 'studio')");
|
|
77
|
+
}
|
|
78
|
+
db.exec("update runs set source = 'terminal' where source = 'npm run'");
|
|
79
|
+
// The tools a reply called, as JSON, so the next turn knows how it was reached.
|
|
80
|
+
added("messages", "used", "text");
|
|
81
|
+
db.exec("create index if not exists runs_parked on runs (parked) where parked is not null");
|
|
82
|
+
|
|
83
|
+
/** Adds the column when it is missing, and says whether it did. */
|
|
84
|
+
function added(table: string, column: string, declaration: string): boolean {
|
|
85
|
+
const there = db.prepare("select 1 from pragma_table_info(?) where name = ?").get(table, column);
|
|
86
|
+
if (!there) db.exec(`alter table ${table} add column ${column} ${declaration}`);
|
|
87
|
+
return !there;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A whole copy of the database in the file `to`, taken from the open
|
|
92
|
+
* connection, so it is consistent even while runs are writing to it.
|
|
93
|
+
*/
|
|
94
|
+
export async function copyDatabase(to: string): Promise<{ path: string; bytes: number }> {
|
|
95
|
+
await mkdir(dirname(to), { recursive: true });
|
|
96
|
+
await backup(db, to);
|
|
97
|
+
return { path: to, bytes: (await stat(to)).size };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Close the runs a stop cut off. Every run happens in the service, so at
|
|
102
|
+
* startup nothing is really running, and one with no end that is not waiting
|
|
103
|
+
* on a person never gets one. Called once at startup.
|
|
104
|
+
*/
|
|
105
|
+
export function closeCutOff(): number {
|
|
106
|
+
return Number(
|
|
107
|
+
db
|
|
108
|
+
.prepare("update runs set finished = ?, error = ? where finished is null and parked is null")
|
|
109
|
+
.run(new Date().toISOString(), "Cut off: the service stopped while this was running.").changes,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Drop runs older than this. Called once at startup. */
|
|
114
|
+
export function trim(keepDays = 60): void {
|
|
115
|
+
const cutoff = new Date(Date.now() - keepDays * 86_400_000).toISOString();
|
|
116
|
+
db.prepare("delete from runs where started < ?").run(cutoff);
|
|
117
|
+
}
|