@chloejs/core 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +221 -0
  3. package/channels/api.ts +41 -0
  4. package/channels/shared.ts +250 -0
  5. package/channels/slack.ts +390 -0
  6. package/channels/telegram.ts +396 -0
  7. package/core/clock.ts +126 -0
  8. package/core/confine.ts +45 -0
  9. package/core/db.ts +117 -0
  10. package/core/markdown.ts +95 -0
  11. package/core/notes.ts +44 -0
  12. package/core/paths.ts +29 -0
  13. package/core/root.ts +26 -0
  14. package/core/settings.ts +124 -0
  15. package/core/steps.ts +896 -0
  16. package/core/turn.ts +314 -0
  17. package/do/email.ts +45 -0
  18. package/do/files.ts +96 -0
  19. package/do/mail.ts +155 -0
  20. package/do/run.ts +56 -0
  21. package/do/scripts.ts +49 -0
  22. package/do/web.ts +192 -0
  23. package/index.ts +52 -0
  24. package/load/job.ts +84 -0
  25. package/load/load.ts +478 -0
  26. package/model/ask.ts +84 -0
  27. package/model/claude.ts +261 -0
  28. package/model/memory.ts +68 -0
  29. package/model/model.ts +185 -0
  30. package/model/tool.ts +53 -0
  31. package/model/tools/files.ts +71 -0
  32. package/model/tools/gmail.ts +43 -0
  33. package/model/tools/index.ts +28 -0
  34. package/model/tools/memory.ts +23 -0
  35. package/model/tools/run_script.ts +44 -0
  36. package/model/tools/send_email.ts +29 -0
  37. package/model/tools/web.ts +23 -0
  38. package/model/tools/write_skill.ts +31 -0
  39. package/ops/account.ts +109 -0
  40. package/ops/agent.ts +290 -0
  41. package/ops/check.ts +37 -0
  42. package/ops/evals.ts +206 -0
  43. package/ops/install.sh +101 -0
  44. package/ops/test.ts +1976 -0
  45. package/package.json +65 -0
  46. package/scorers/calls.ts +50 -0
  47. package/scorers/expectations.ts +118 -0
  48. package/scorers/index.ts +5 -0
  49. package/serve/alerts.ts +79 -0
  50. package/serve/errors.ts +10 -0
  51. package/serve/files.ts +70 -0
  52. package/serve/http.ts +767 -0
  53. package/serve/login.ts +299 -0
  54. package/serve/memory.ts +372 -0
  55. package/serve/page.ts +142 -0
  56. package/serve/pass.ts +45 -0
  57. package/serve/recentWork.ts +69 -0
  58. package/serve/site.ts +409 -0
  59. package/serve/tokens.ts +132 -0
  60. package/server.ts +170 -0
  61. package/timer/cron.ts +92 -0
  62. package/timer/every.ts +153 -0
  63. package/timer/index.ts +4 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Carlos Martinez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,221 @@
1
+ # Chloe
2
+
3
+ Chloe is a TypeScript agent framework that uses AI only when you need it.
4
+
5
+ You write the workflow in code, **and ask AI where a step needs judgement**. You
6
+ decide where deterministic work ends and where non-deterministic work begins.
7
+
8
+ ```sh
9
+ npm install @chloejs/core @chloejs/ui
10
+ ```
11
+
12
+ - **No build step**, and an edit to a job is live in under a second
13
+ - **One dependency**, zod, and one SQLite file
14
+ - **Node 22.18** or newer
15
+
16
+ [Get started](https://chloejs.org/docs/start) ·
17
+ [Examples](https://chloejs.org/examples) ·
18
+ [Primitives](https://chloejs.org/docs/primitives)
19
+
20
+ ## The least autonomy that does the job
21
+
22
+ Three ways to do a piece of work. Start at the top, and move down only when you
23
+ have to.
24
+
25
+ | | | |
26
+ | --- | --- | --- |
27
+ | `work.step()` | I know what to do. | Deterministic work. You control the workflow and the action. |
28
+ | `work.model()` | I know what to ask. | Judgement, in a shape. You control the workflow, the model controls the answer. |
29
+ | `work.agent()` | I know what I want. | Bounded autonomy. You control the goal and the boundaries, the model controls the order. |
30
+
31
+ There is a fourth. `work.ask()` stops the job and waits for a person.
32
+
33
+ ## Ordinary code, one model decision, optional autonomy
34
+
35
+ A whole job, from the example on chloejs.org. Code loads the inbox, a model says
36
+ what each message is about, and the ones that need looking into get an agent.
37
+
38
+ ```ts
39
+ export default defineJob({
40
+ id: "order-issues",
41
+ cron: every(15).minutes,
42
+ description: "Reads what customers wrote in and looks into the ones that need looking into.",
43
+ run: async (work) => {
44
+ const messages = await work.step("load messages", () => unread());
45
+
46
+ const looked: string[] = [];
47
+ for (const message of messages) {
48
+ const issue = await work.model("classify issue", {
49
+ prompt: message.text,
50
+ output: Issue,
51
+ });
52
+ if (!issue.needsInvestigation) continue;
53
+
54
+ const found = await work.agent("investigate issue", {
55
+ goal: `Find out what went wrong for customer ${message.customer}, and recommend what to do.`,
56
+ tools: [getOrders, pastMessages],
57
+ maxSteps: 6,
58
+ budget: 0.05,
59
+ });
60
+
61
+ looked.push(`${message.id}: ${found}`);
62
+ }
63
+
64
+ return { looked };
65
+ },
66
+ });
67
+ ```
68
+
69
+ ## Keep the application in charge
70
+
71
+ Your rules, loops, conditions and queries stay in TypeScript. The model is asked
72
+ one thing, and the `if` above it decides whether to ask at all.
73
+
74
+ ```ts
75
+ const late = orders.filter((order) => order.daysLate > 2);
76
+ if (late.length === 0) return;
77
+
78
+ const summary = await work.model("summarise the delays", {
79
+ prompt: late.map(describe).join("\n"),
80
+ output: Summary,
81
+ });
82
+ ```
83
+
84
+ A morning with nothing late costs nothing.
85
+
86
+ ## You can point at the line that asks a model
87
+
88
+ The run record has one line per step, with what it was, how long it took and
89
+ what it cost. A job that quietly grew a second model call shows up as a second
90
+ line and a bigger number.
91
+
92
+ ## Sometimes you know the goal, not the steps
93
+
94
+ The model chooses the order. You choose what it can reach and how far it can
95
+ go: `tools` is all it can reach, `approve` is which of those calls may run,
96
+ `maxSteps` and `budget` are how far it can go and what it may spend, and
97
+ `output` is the shape of the answer. Everything it did is on the run.
98
+
99
+ ## Some decisions should not belong to a model
100
+
101
+ The run stops, the question goes to whoever should answer it, and the job
102
+ carries on when they do. It can wait days, and it survives a restart while it
103
+ waits.
104
+
105
+ ```ts
106
+ const approved = await work.ask("refund $2,400?", {
107
+ question: "A-4417 arrived broken. Refund it?",
108
+ answer: z.boolean(),
109
+ within: "2d",
110
+ otherwise: false,
111
+ });
112
+
113
+ if (approved) await work.step("issue refund", () => refund(order));
114
+ ```
115
+
116
+ The answer is checked against the shape the ask named. No model is involved.
117
+
118
+ ## Jobs survive the real world
119
+
120
+ Steps, model calls, agent loops and human pauses are all part of one durable
121
+ run, and you can open any of them.
122
+
123
+ - A finished step replays from the record.
124
+ - A job resumes after a restart.
125
+ - An approval can wait for days.
126
+ - Every tool call an agent made is recorded.
127
+ - Cost is tracked per step and per run.
128
+ - Two runs of the same job never overlap.
129
+
130
+ One rule makes the replay safe: **work happens inside a step, and code outside a
131
+ step only decides.** A step is written down, so it never runs twice. A line
132
+ outside one runs again on every resume, so it must not send, write or spend.
133
+
134
+ ## What comes with it
135
+
136
+ | | |
137
+ | --- | --- |
138
+ | Durable jobs | Steps are written down as they finish, and replayed on a resume. |
139
+ | Schedules | Cron lines in TypeScript, with real time zones. |
140
+ | Models | Any model the gateway reaches, and a job can pick its own. |
141
+ | Agents | Tools, approvals and a budget, set where the step is written. |
142
+ | Tools | A description, a schema and one call. Typed at both ends. |
143
+ | Human approvals | A run parks for days and carries on when somebody answers. |
144
+ | Channels | Telegram and Slack, one file each. A question goes out where the person is. |
145
+ | Memory | Notes an agent keeps, and skills it can rewrite. |
146
+ | Run history | Every step of every run, with its arguments and its answer. |
147
+ | Cost tracking | Per step, per run, per job. |
148
+ | Structured output | Zod on every model and agent answer, retried once. |
149
+ | Testing and evals | Jobs run for real against a stand-in gateway. Prompts are scored. |
150
+
151
+ ## Code is tested. Words are scored.
152
+
153
+ Your deterministic logic gets ordinary tests. Your prompts get evals. Neither
154
+ spends real money: a test runs against a stand-in gateway, and an eval answers
155
+ every tool from the case.
156
+
157
+ ```sh
158
+ npm run test # the jobs: does it do the thing
159
+ npm run evals <name> # the prompts: did the model decide well
160
+ ```
161
+
162
+ ## Run it wherever Node runs
163
+
164
+ Your code, your models, your machine. One process serves the page, keeps every
165
+ cron line and answers the channels. The runtime's only dependency is zod and its
166
+ state is one SQLite file, so moving machine is copying a folder.
167
+
168
+ Three files, and you have an agent:
169
+
170
+ ```
171
+ chloe.config.ts the agents this copy runs
172
+ settings.json which model, and how to reach it
173
+ your-agent/agent.ts what the agent is: its jobs, tools and channels
174
+ ```
175
+
176
+ ```sh
177
+ npm run account # make the one account
178
+ npm start # the one process, on 127.0.0.1:3067
179
+ npm run agent <name> # talk to one agent
180
+ npm run agent <name> <job> # run one job now, without waiting for its cron line
181
+ ```
182
+
183
+ Without `@chloejs/ui` the runtime serves a plain page of its own. With it, that
184
+ page is the dashboard. The runtime never names that package: it serves whatever
185
+ installed package declares a page.
186
+
187
+ ## What is in here
188
+
189
+ The package is this repo: what is at the top is what is published.
190
+
191
+ ```
192
+ index.ts what "@chloejs/core" is when you import it
193
+ server.ts the server, and the only thing that is run
194
+ model/ asking a model, and tools/, the only thing a model can be handed
195
+ load/ what an agent and a job are, and reading them off disk
196
+ timer/ cron lines and every(), published as "@chloejs/core/timer"
197
+ serve/ the one port: every route, the login, tokens, the plain page
198
+ core/ the floor. steps.ts runs a job, turn.ts runs a prompt, clock.ts
199
+ starts each job when its cron line is due
200
+ scorers/ how a run is marked
201
+ do/ the work itself, called straight from a job
202
+ channels/ the ways in, for an agent to bind
203
+ ops/ the tests, the evals, talking to an agent, making the account,
204
+ and install.sh, which installs the service
205
+ test-agent/ the agent the tests load. Not published
206
+ ```
207
+
208
+ Six entrances and no others: `@chloejs/core`, `@chloejs/core/tools`,
209
+ `@chloejs/core/channels/<name>`, `@chloejs/core/scorers`, `@chloejs/core/timer` and
210
+ `@chloejs/core/test`.
211
+
212
+ ## Use code when you know what to do. Use AI when you do not.
213
+
214
+ Start with a job that asks nobody anything. Add the step that needs judgement
215
+ when you find it, and read what it cost.
216
+
217
+ The docs are at [chloejs.org](https://chloejs.org). Its reference pages are read
218
+ out of this source on every push to `main`, so they cannot describe a version of
219
+ the code that does not exist.
220
+
221
+ MIT.
@@ -0,0 +1,41 @@
1
+ // Opting one agent in to being reached by another system.
2
+ //
3
+ // // agents/<name>/agent.ts
4
+ // import { apiChannel } from "@chloejs/core/channels/api";
5
+ // channels: [apiChannel()],
6
+ //
7
+ // Binding it makes two routes answer for that agent when the caller holds a
8
+ // token, made at /tokens on the runtime site:
9
+ //
10
+ // POST /api/agents/<name>/chat one turn, and a reply
11
+ // POST /api/agents/<name>/job/<job> run one of its jobs now
12
+ //
13
+ // curl -X POST http://127.0.0.1:3067/api/agents/shop/chat \
14
+ // -H "authorization: Bearer $CHLOE_TOKEN" \
15
+ // -H "content-type: application/json" \
16
+ // -d '{"prompt":"how many orders are late?"}'
17
+ //
18
+ // {"runId":"...","text":"Three.","steps":2,"cost":0.0031}
19
+ //
20
+ // `thread` is optional and is the caller's own name for a conversation: send
21
+ // the same one again and the agent remembers what was said. A token's threads
22
+ // are kept apart from the ones a person started.
23
+ //
24
+ // An agent without this channel is not on the API. Somebody signed in on the
25
+ // box can still talk to it from the site, because that is the account and the
26
+ // account can do everything. A token cannot, and gets a 403 saying so.
27
+ //
28
+ // Why this is a channel and not a flag: a channel is the thing an agent's
29
+ // definition already lists to say how it can be reached, and being reachable
30
+ // by another system belongs in that list beside telegram. It listens to
31
+ // nothing and starts nothing, because the server already answers those two
32
+ // routes. What this adds is the permission.
33
+ //
34
+ // It does not carry a job's question out to anybody, because HTTP cannot push.
35
+ // A job that stops to ask waits in GET /api/parked like it always did.
36
+ import type { Channel, ChatHistory } from "#chloe/load/load.ts";
37
+
38
+ /** `chatHistory` is how much of a caller's thread a turn is shown. */
39
+ export function apiChannel(options: { chatHistory?: ChatHistory } = {}): Channel {
40
+ return { name: "api", chatHistory: options.chatHistory, start: () => ({ stop: () => {} }) };
41
+ }
@@ -0,0 +1,250 @@
1
+ // What every channel shares: what happens to a message, whichever channel it
2
+ // came in on. A channel turns its platform's message into an `Incoming`, calls
3
+ // receive(), and sends back the text it returns. Everything else is decided
4
+ // here, once, so Telegram, the API and any channel written later behave the
5
+ // same way. A channel written in an agent's own folder imports it too:
6
+ //
7
+ // import { receive, type Incoming } from "@chloejs/core/channels/shared";
8
+ //
9
+ // In order, and the first that applies decides:
10
+ //
11
+ // 1. Somebody not in allowFrom gets nothing. While allowFrom is empty, a
12
+ // private message is told the sender's id, which is what goes in it.
13
+ // 2. An answer to a job waiting on this chat goes to that job.
14
+ // 3. In a group, a message that is not for the agent is left alone, unless
15
+ // the channel's inGroups is "always".
16
+ // 4. "/<job id> ..." runs that job. "_" stands for "-", because some
17
+ // platforms allow no hyphens in a command.
18
+ // 5. A message one of the agent's jobs `answers` runs that job.
19
+ // 6. Anything else is a turn, shown the chat's recent conversation.
20
+ //
21
+ // Rules 4 and 5 are code, never a model: which job gets a message is a rule
22
+ // somebody can write down. What a job said in a chat is kept in that chat's
23
+ // conversation, so the next turn knows it happened.
24
+ import type { Agent, ChatHistory, Job } from "#chloe/load/load.ts";
25
+ import type { Attachment } from "#chloe/model/model.ts";
26
+ import { remember } from "#chloe/model/memory.ts";
27
+ import { clock, type Fired } from "#chloe/core/clock.ts";
28
+ import { answer, waitingOn, WrongInput } from "#chloe/core/steps.ts";
29
+ import { turn } from "#chloe/core/turn.ts";
30
+
31
+ /** One message, in the words every channel shares. */
32
+ export interface Incoming {
33
+ /** The channel's name, like "telegram". What the log shows, and the first half of an address. */
34
+ channel: string;
35
+ /** Where it was said, as the channel names it. `${channel}:${chat}` is how a job asks back here. */
36
+ chat: string;
37
+ /** The conversation it belongs to: one per chat, or per topic in a forum. Empty for none, so nothing is remembered. */
38
+ thread: string;
39
+ from: { id: string; name: string };
40
+ text: string;
41
+ /** A one-to-one chat, rather than a group. */
42
+ private: boolean;
43
+ /** In a group: it mentions the agent or replies to it. */
44
+ addressed?: boolean;
45
+ chatTitle?: string;
46
+ /** The message this one replies to, when it is one. */
47
+ replyTo?: string;
48
+ /**
49
+ * Facts about where it was said, handed to the model ahead of the message
50
+ * with who sent it: "chat_type", "chat_title". Without them the model is
51
+ * handed the message alone, which is right for a caller that is a program.
52
+ */
53
+ context?: Record<string, string>;
54
+ /** The files on it, fetched only when a turn is going to read them. */
55
+ files?: () => Promise<{ attachments?: Attachment[]; text?: string; notes?: string[] }>;
56
+ /** A model for this one turn, when the channel lets its caller pick. */
57
+ model?: string;
58
+ }
59
+
60
+ /** Who a channel answers. Each channel takes these as options and hands them over. */
61
+ export interface Rules {
62
+ /** Ids that may reach the agent. Unset, anybody who got this far may, which is right only behind a login. */
63
+ allowFrom?: (string | number)[];
64
+ /** In a group, "when-addressed" (the default) answers a command, a mention or a reply. "always" answers everything. */
65
+ inGroups?: "when-addressed" | "always";
66
+ /** How much of a conversation on this channel a turn is shown. */
67
+ chatHistory?: ChatHistory;
68
+ /**
69
+ * Send what the model writes on its way to an answer (a "let me check" line,
70
+ * or a draft it goes on to improve) as it writes it, rather than only the
71
+ * answer it ends on. Off unless true. Needs a channel that can send more
72
+ * than one reply, so it does nothing on the API.
73
+ */
74
+ sendWhileWorking?: boolean;
75
+ }
76
+
77
+ /** What a channel can do while a message is being dealt with. */
78
+ export interface While {
79
+ /** Called once there is work to do, for "typing..."; what it returns is called when the work is over. */
80
+ working?: () => () => void;
81
+ /** Sends one message to the chat. What sendWhileWorking uses. */
82
+ send?: (text: string) => Promise<void>;
83
+ }
84
+
85
+ /** What came of a message. Nothing at all means it was not for the agent. */
86
+ export interface Handled {
87
+ /** What to send back. Empty when there is nothing to send, because a question already went out. */
88
+ text: string;
89
+ runId?: string;
90
+ steps: number;
91
+ cost: number;
92
+ /** The job that took it, when one did. */
93
+ job?: string;
94
+ }
95
+
96
+ /**
97
+ * Decides what a message is, does it, and says what to send back. See While
98
+ * for what a channel can hand over to be used on the way.
99
+ */
100
+ export async function receive(agent: Agent, message: Incoming, rules: Rules = {}, whileWorking: While = {}): Promise<Handled | undefined> {
101
+ const working = whileWorking.working ?? (() => () => {});
102
+ const { channel, from, text } = message;
103
+ const said = (words: string): Handled => ({ text: words, steps: 0, cost: 0 });
104
+
105
+ if (rules.allowFrom) {
106
+ if (rules.allowFrom.length === 0) {
107
+ console.log(`${channel}: ${from.id} wrote to ${agent.name}. Add ${from.id} to allowFrom in ${agent.name}'s ${channel} channel.`);
108
+ const Channel = channel.charAt(0).toUpperCase() + channel.slice(1);
109
+ return message.private ? said(`Your ${Channel} user id is ${from.id}. Add it to allowFrom in ${agent.name}'s ${channel} channel.`) : undefined;
110
+ }
111
+ if (!rules.allowFrom.map(String).includes(from.id)) {
112
+ // In a group the agent sees everybody's messages, and most are not for it.
113
+ if (message.private) console.warn(`${channel}: ${agent.name} is ignoring ${from.id} (${from.name}), not in allowFrom`);
114
+ return undefined;
115
+ }
116
+ }
117
+
118
+ const waiting = text ? waitingOn(`${channel}:${message.chat}`, agent.name) : undefined;
119
+ if (waiting) return during(working, () => answered(agent, message, waiting.id, waiting.job));
120
+
121
+ const forAgent = message.private || message.addressed || rules.inGroups === "always" || text.startsWith("/");
122
+ if (!forAgent) return undefined;
123
+
124
+ const job = text ? jobFor(agent, text) : undefined;
125
+ if (job && clock()) return during(working, () => started(agent, message, job.job, job.text));
126
+
127
+ return during(working, () => chatted(agent, message, rules, whileWorking.send));
128
+ }
129
+
130
+ /** The commands a channel can offer in its own menu: each job, with "_" for "-". */
131
+ export function commands(agent: Agent): { command: string; description: string }[] {
132
+ return agent.jobs
133
+ .map((job) => ({ command: job.id.replace(/-/g, "_").toLowerCase(), description: (job.description || job.id).slice(0, 256) }))
134
+ .filter((one) => /^[a-z0-9_]{1,32}$/.test(one.command));
135
+ }
136
+
137
+ async function during(working: () => () => void, work: () => Promise<Handled>): Promise<Handled> {
138
+ const stop = working();
139
+ try {
140
+ return await work();
141
+ } finally {
142
+ stop();
143
+ }
144
+ }
145
+
146
+ /** The job a message is for, by command or by a job that answers it, and the text it is started with. */
147
+ function jobFor(agent: Agent, text: string): { job: Job; text: string } | undefined {
148
+ if (text.startsWith("/")) {
149
+ const [word, ...rest] = text.trim().split(/\s+/);
150
+ const asked = word.slice(1).split("@")[0].toLowerCase();
151
+ const job = agent.jobs.find((one) => one.id === asked || one.id === asked.replace(/_/g, "-"));
152
+ // Not one of this agent's jobs, so it is a message that starts with a
153
+ // slash, and goes on to be asked of the model like any other.
154
+ if (job) return { job, text: text.slice(word.length).trim() || rest.join(" ") };
155
+ }
156
+ const job = agent.jobs.find((one) => {
157
+ try {
158
+ return one.answers?.(text) === true;
159
+ } catch (error) {
160
+ console.error(`${agent.name}/${one.id}: its answers check failed:`, (error as Error).message);
161
+ return false;
162
+ }
163
+ });
164
+ return job && { job, text };
165
+ }
166
+
167
+ /** What a job said, as a reply: its own reply, its summary line, or what it returned. */
168
+ function replyOf(result: Fired, job: Job): string {
169
+ if ("parked" in result && result.parked) return "";
170
+ return result.reply || result.summary || result.text || `${job.id}: done.`;
171
+ }
172
+
173
+ /** A job's exchange, kept in the chat's conversation the way a turn keeps its own. */
174
+ function kept(message: Incoming, reply: string): void {
175
+ if (!message.thread) return;
176
+ remember(message.thread, "user", message.text);
177
+ remember(message.thread, "assistant", reply);
178
+ }
179
+
180
+ async function started(agent: Agent, message: Incoming, job: Job, text: string): Promise<Handled> {
181
+ const input = {
182
+ text,
183
+ from: message.channel,
184
+ chat: message.chat,
185
+ chatTitle: message.chatTitle ?? "",
186
+ user: message.from.name,
187
+ thread: message.thread,
188
+ replyTo: message.replyTo ?? "",
189
+ };
190
+ try {
191
+ 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 };
193
+ const reply = replyOf(result, job);
194
+ kept(message, reply);
195
+ return { text: reply, runId: result.runId, steps: result.steps, cost: result.cost, job: job.id };
196
+ } catch (error) {
197
+ // What was sent did not fit the job, which is worth saying where it was
198
+ // sent: it is the message that has to change.
199
+ const why = error instanceof WrongInput ? error.message : "It is in the logs on the box.";
200
+ if (!(error instanceof WrongInput)) console.error(`${agent.name}/${job.id}: failed`, error);
201
+ return { text: `I could not run ${job.id}. ${why}`, steps: 0, cost: 0, job: job.id };
202
+ }
203
+ }
204
+
205
+ async function answered(agent: Agent, message: Incoming, runId: string, job: string): Promise<Handled> {
206
+ try {
207
+ const result = await answer(runId, message.text, new Map([[agent.name, agent]]));
208
+ // Still parked means the answer did not fit, and the job has already asked again.
209
+ const reply = result.parked ? "" : result.reply || result.summary || result.text || "Done.";
210
+ kept(message, reply);
211
+ return { text: reply, runId: result.runId, steps: result.steps, cost: result.cost, job };
212
+ } catch (error) {
213
+ console.error(`${message.channel}: answering a waiting job failed`, error);
214
+ return { text: "I could not carry that job on. It is in the logs on the box.", steps: 0, cost: 0 };
215
+ }
216
+ }
217
+
218
+ async function chatted(agent: Agent, message: Incoming, rules: Rules, send?: (text: string) => Promise<void>): Promise<Handled> {
219
+ // One after another, and all of them out before the answer is.
220
+ let sending = Promise.resolve();
221
+ const said =
222
+ rules.sendWhileWorking && send
223
+ ? (text: string) => {
224
+ sending = sending.then(() => send(text)).catch((error) => console.error(`${message.channel}: sending on the way failed`, error));
225
+ // Sent, so kept: the next turn should know it was said.
226
+ if (message.thread) remember(message.thread, "assistant", text);
227
+ }
228
+ : undefined;
229
+ try {
230
+ const files = (await message.files?.()) ?? {};
231
+ const facts = message.context && { from: message.from.name, ...message.context };
232
+ const context = facts && [`<${message.channel}_context>`, ...Object.entries(facts).map(([k, v]) => `${k}: ${v}`), `</${message.channel}_context>`].join("\n");
233
+ const result = await turn({
234
+ agent,
235
+ prompt: [context, message.text, files.text, ...(files.notes ?? [])].filter(Boolean).join("\n\n"),
236
+ attachments: files.attachments?.length ? files.attachments : undefined,
237
+ thread: message.thread || undefined,
238
+ history: rules.chatHistory,
239
+ said,
240
+ model: message.model,
241
+ source: message.channel,
242
+ owner: `${message.channel}:${message.from.id}`,
243
+ });
244
+ await sending;
245
+ return { text: result.text || "(no reply)", runId: result.runId, steps: result.steps, cost: result.cost };
246
+ } catch (error) {
247
+ console.error(`${message.channel}: turn failed`, error);
248
+ return { text: "Something went wrong on my end. It is in the logs on the box.", steps: 0, cost: 0 };
249
+ }
250
+ }