@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/core/turn.ts ADDED
@@ -0,0 +1,314 @@
1
+ // Ask the model, run the tools it asked for, put the answers back, ask again.
2
+ // Every step is written to the run as it happens, because a job that goes
3
+ // wrong at four in the morning is only debuggable if that record exists.
4
+ import { randomUUID } from "node:crypto";
5
+
6
+ import { z } from "zod";
7
+
8
+ import { db } from "#chloe/core/db.ts";
9
+ import { oneLineSummary } from "#chloe/core/markdown.ts";
10
+ import type { Agent, ChatHistory, Skill } from "#chloe/load/load.ts";
11
+ import { ask, type Attachment, type Message, type ToolCall } from "#chloe/model/model.ts";
12
+ import { recall, remember } from "#chloe/model/memory.ts";
13
+ import { describe, type Approve, type Call, type Tool, type Tools } from "#chloe/model/tool.ts";
14
+
15
+ export interface Ask {
16
+ agent: Agent;
17
+ prompt: string;
18
+ /** Photos and PDFs that came with the prompt. Seen this turn only: the thread keeps the words. */
19
+ attachments?: Attachment[];
20
+ /** When this job wants one the agent does not normally use. */
21
+ model?: string;
22
+ /** Without one, the turn starts fresh. */
23
+ thread?: string;
24
+ /**
25
+ * The channel it came in on: "telegram", "chat", "api", "terminal",
26
+ * "schedule", "eval", or the name of a channel an agent brings.
27
+ */
28
+ source: string;
29
+ /** The job this turn is, when it is one. */
30
+ job?: string;
31
+ /** How much of `thread` to show: the channel's `chatHistory`. The last 10 messages when unsaid. */
32
+ history?: ChatHistory;
33
+ /**
34
+ * Handed what the model writes before it asks for a tool, as soon as it is
35
+ * written. Never the final answer, which is what turn() returns.
36
+ */
37
+ said?: (text: string) => void;
38
+ /** Who this run is for, as an address. One column, and the team version reads it. */
39
+ owner?: string;
40
+ /** Answer tools from here instead of running them. For evals. */
41
+ instead?: (name: string, args: unknown) => Promise<unknown> | unknown;
42
+ signal?: AbortSignal;
43
+ }
44
+
45
+ /** What one turn came back with, including every tool call it made on the way. */
46
+ export interface Result {
47
+ runId: string;
48
+ text: string;
49
+ steps: number;
50
+ cost: number;
51
+ calls: Call[];
52
+ }
53
+
54
+ function shown(history: ChatHistory = {}): { limit?: number; days?: number } {
55
+ return { limit: history.messages, days: history.days };
56
+ }
57
+
58
+ /** Dollars, at the size these numbers actually are: $0.0004 and $0.10, not $0.00 and $0.1. */
59
+ export function money(amount: number): string {
60
+ return `$${amount.toFixed(4).replace(/(\.\d\d)0+$/, "$1")}`;
61
+ }
62
+
63
+ const MAX_STEPS = 40;
64
+
65
+ /**
66
+ * Runs a prompt: ask a model, run the tools it asked for, put the answers
67
+ * back, ask again, until it stops asking.
68
+ */
69
+ export async function turn({ agent, prompt, attachments, model, thread, source, job, history, said, owner, instead, signal }: Ask): Promise<Result> {
70
+ const runId = randomUUID();
71
+ const using = model ?? agent.model;
72
+ const tools = { ...(agent.tools ?? {}), skill: skillTool(agent.skills) };
73
+
74
+ const started = new Date().toISOString();
75
+ db.prepare(
76
+ "insert into runs (id, agent, started, source, job, model, prompt, kind, owner) values (?, ?, ?, ?, ?, ?, ?, 'turn', ?)",
77
+ ).run(runId, agent.name, started, source, job ?? null, using, prompt, owner ?? null);
78
+
79
+ const messages: Message[] = [
80
+ { role: "system", content: systemPrompt(agent) },
81
+ ...(thread ? recall(thread, { ...shown(history), tools: true }) : []),
82
+ { role: "user", content: prompt, attachments },
83
+ ];
84
+ if (thread) remember(thread, "user", prompt);
85
+
86
+ const trace: LoopStep[] = [];
87
+ const calls: Result["calls"] = [];
88
+ let cost = 0;
89
+ let steps = 0;
90
+
91
+ try {
92
+ const done = await loop({
93
+ model: using,
94
+ messages,
95
+ tools,
96
+ maxSteps: agent.maxSteps ?? MAX_STEPS,
97
+ signal,
98
+ instead,
99
+ onStep: (line) => {
100
+ trace.push(line);
101
+ if (said && line.say?.trim() && line.wants?.length) said(line.say);
102
+ if (line.tool) calls.push({ tool: line.tool, args: line.args, result: line.result });
103
+ save(runId, trace.filter((one) => (one as { say?: string }).say !== undefined).length, costOf(trace), trace);
104
+ },
105
+ });
106
+ cost = done.cost;
107
+ steps = done.steps;
108
+ if (done.stopped) {
109
+ fail(runId, done.text, steps, cost, trace);
110
+ return { runId, text: done.text, steps, cost, calls };
111
+ }
112
+ finish(runId, done.text, steps, cost, trace);
113
+ if (thread) remember(thread, "assistant", done.text, calls);
114
+ return { runId, text: done.text, steps, cost, calls };
115
+ } catch (error) {
116
+ fail(runId, String(error instanceof Error ? error.message : error), steps, cost, trace);
117
+ throw error;
118
+ }
119
+ }
120
+
121
+ /** What one turn of the loop did: what the model said, or one tool it ran. */
122
+ export interface LoopStep {
123
+ step: number;
124
+ /** When it happened: when the model answered, or when the tool was called. */
125
+ at: string;
126
+ say?: string;
127
+ wants?: string[];
128
+ tool?: string;
129
+ args?: unknown;
130
+ result?: unknown;
131
+ failed?: boolean;
132
+ /** Set on a call the job would not allow. It never ran. */
133
+ refused?: boolean;
134
+ cost?: number;
135
+ }
136
+
137
+ /**
138
+ * Ask, run what it asked for, ask again, until it stops asking or hits a limit.
139
+ * Nothing here writes to the database: `turn` records a conversation and a
140
+ * job's agent step records one line, and they both run this.
141
+ *
142
+ * `stopped` says which limit ended it, "steps" or "budget", and is false when
143
+ * it finished. Hitting a limit is an answer and not a crash.
144
+ */
145
+ export async function loop(options: {
146
+ model: string;
147
+ messages: Message[];
148
+ tools: Tools;
149
+ maxSteps: number;
150
+ /**
151
+ * The most this may spend, in dollars. Checked between turns, because what a
152
+ * turn costs is only known once it has been paid for, so the turn that goes
153
+ * over the line is paid for.
154
+ */
155
+ budget?: number;
156
+ signal?: AbortSignal;
157
+ instead?: Ask["instead"];
158
+ /** Asked before each tool runs. Without one, everything it was given may run. */
159
+ approve?: Approve;
160
+ onStep?: (line: LoopStep) => void;
161
+ }): Promise<{ text: string; steps: number; cost: number; calls: Result["calls"]; stopped: false | "steps" | "budget" }> {
162
+ const specs = Object.entries(options.tools).map(([name, one]) => describe(name, one));
163
+ const calls: Result["calls"] = [];
164
+ let cost = 0;
165
+ let steps = 0;
166
+
167
+ for (; steps < options.maxSteps; steps++) {
168
+ const answer = await ask({ model: options.model, messages: options.messages, tools: specs, signal: options.signal });
169
+ cost += answer.cost;
170
+ options.onStep?.({ step: steps, at: new Date().toISOString(), say: answer.text, wants: answer.toolCalls.map((c) => c.function.name), cost: answer.cost });
171
+
172
+ if (answer.toolCalls.length === 0) {
173
+ return { text: answer.text, steps: steps + 1, cost, calls, stopped: false };
174
+ }
175
+
176
+ // Out of money before running what it asked for, because running the tools
177
+ // only leads to a turn there is nothing left to pay for.
178
+ if (options.budget !== undefined && cost >= options.budget) {
179
+ return { text: `Stopped after spending ${money(cost)} without finishing.`, steps: steps + 1, cost, calls, stopped: "budget" };
180
+ }
181
+
182
+ // Has to go back exactly as it came, or the provider rejects the tool
183
+ // answers that follow it.
184
+ options.messages.push({ role: "assistant", content: answer.text, tool_calls: answer.toolCalls });
185
+
186
+ for (const call of answer.toolCalls) {
187
+ const at = new Date().toISOString();
188
+ const { output, args, failed, refused } = await runTool(options.tools, call, options.instead, options.approve);
189
+ calls.push({ tool: call.function.name, args, result: output, ...(refused && { refused }) });
190
+ options.onStep?.({ step: steps, at, tool: call.function.name, args, result: clip(output), ...(failed && { failed }), ...(refused && { refused }) });
191
+ options.messages.push({
192
+ role: "tool",
193
+ tool_call_id: call.id,
194
+ content: typeof output === "string" ? output : JSON.stringify(output),
195
+ });
196
+ }
197
+ }
198
+
199
+ // Out of steps is an answer, not a crash: an empty string here would read as
200
+ // nothing being wrong.
201
+ return { text: `Stopped after ${steps} steps without finishing.`, steps, cost, calls, stopped: "steps" };
202
+ }
203
+
204
+ /** What the trace says has been spent so far, for the record written as it goes. */
205
+ function costOf(trace: unknown[]): number {
206
+ return trace.reduce((sum: number, one) => sum + (((one as { cost?: number }).cost) ?? 0), 0);
207
+ }
208
+
209
+ // A missing tool, bad arguments and a tool that threw all go back to the model
210
+ // as text it can act on. Dying here loses the work the turn had already done.
211
+ async function runTool(
212
+ tools: Tools,
213
+ call: ToolCall,
214
+ instead?: Ask["instead"],
215
+ approve?: Approve,
216
+ ): Promise<{ output: unknown; args: unknown; failed?: boolean; refused?: boolean }> {
217
+ const name = call.function.name;
218
+ let args: unknown;
219
+ try {
220
+ args = call.function.arguments ? JSON.parse(call.function.arguments) : {};
221
+ } catch {
222
+ return { output: `${name} was called with arguments that are not valid JSON.`, args: call.function.arguments, failed: true };
223
+ }
224
+
225
+ const one = tools[name];
226
+ if (!one) return { output: `There is no tool called ${name}. You have: ${Object.keys(tools).join(", ")}`, args, failed: true };
227
+
228
+ const checked = one.inputSchema.safeParse(args);
229
+ if (!checked.success) {
230
+ return { output: `${name} was called wrongly: ${checked.error.issues.map((i) => `${i.path.join(".") || "input"} ${i.message}`).join("; ")}`, args, failed: true };
231
+ }
232
+
233
+ // Asked once the arguments are known and before anything runs, because what
234
+ // makes a call worth stopping is usually the arguments rather than the tool.
235
+ if (approve) {
236
+ let allowed: boolean | string;
237
+ try {
238
+ allowed = await approve({ tool: name, args: checked.data });
239
+ } catch (error) {
240
+ throw new Error(`Deciding whether ${name} could run failed: ${error instanceof Error ? error.message : String(error)}`);
241
+ }
242
+ if (allowed !== true) {
243
+ const why = typeof allowed === "string" && allowed.trim() !== "" ? allowed : "the job did not allow it";
244
+ return {
245
+ output: `${name} was not allowed: ${why}. Try another way, or finish with what you have.`,
246
+ args: checked.data,
247
+ refused: true,
248
+ };
249
+ }
250
+ }
251
+
252
+ try {
253
+ const output = instead ? await instead(name, checked.data) : await one.execute(checked.data);
254
+ return { output: output ?? { ok: true }, args: checked.data };
255
+ } catch (error) {
256
+ return { output: `${name} failed: ${error instanceof Error ? error.message : String(error)}`, args: checked.data, failed: true };
257
+ }
258
+ }
259
+
260
+ // The model sees each skill's name and one sentence, and opens the body only
261
+ // when it applies. In the system prompt instead, every skill would cost its
262
+ // full text on every step of every turn.
263
+ function skillTool(skills: Skill[]): Tool {
264
+ const byName = new Map(skills.map((s) => [s.name, s]));
265
+ return {
266
+ id: "skill",
267
+ description:
268
+ "Open one of your skills and read what it says. A skill tells you when to do something and " +
269
+ "how. Open the skill before doing the thing it covers.",
270
+ inputSchema: z.object({ name: z.string().describe("The skill's name, from the list in your instructions.") }),
271
+ execute: ({ name }: { name: string }) => {
272
+ const found = byName.get(name);
273
+ if (!found) throw new Error(`No skill called ${JSON.stringify(name)}. You have: ${[...byName.keys()].join(", ")}`);
274
+ return found.body;
275
+ },
276
+ };
277
+ }
278
+
279
+ function systemPrompt(agent: Agent): string {
280
+ const parts = [agent.instructions];
281
+ if (agent.skills.length > 0) {
282
+ parts.push(
283
+ "## Your skills\n\n" +
284
+ "Open one with the `skill` tool before doing the thing it covers.\n\n" +
285
+ agent.skills.map((s) => `- **${s.name}**: ${s.description}`).join("\n"),
286
+ );
287
+ }
288
+ parts.push(`Today is ${new Date().toISOString().slice(0, 10)}.`);
289
+ return parts.join("\n\n");
290
+ }
291
+
292
+ function save(runId: string, steps: number, cost: number, trace: unknown[]): void {
293
+ db.prepare("update runs set steps = ?, cost = ?, trace = ? where id = ?").run(
294
+ steps, cost, JSON.stringify(trace), runId,
295
+ );
296
+ }
297
+
298
+ function finish(runId: string, reply: string, steps: number, cost: number, trace: unknown[]): void {
299
+ db.prepare("update runs set finished = ?, reply = ?, summary = ?, steps = ?, cost = ?, trace = ? where id = ?").run(
300
+ new Date().toISOString(), reply, oneLineSummary(reply), steps, cost, JSON.stringify(trace), runId,
301
+ );
302
+ }
303
+
304
+ function fail(runId: string, error: string, steps: number, cost: number, trace: unknown[]): void {
305
+ db.prepare("update runs set finished = ?, error = ?, steps = ?, cost = ?, trace = ? where id = ?").run(
306
+ new Date().toISOString(), error, steps, cost, JSON.stringify(trace), runId,
307
+ );
308
+ }
309
+
310
+ /** So one tool answer in the record is not a megabyte of HTML. */
311
+ function clip(output: unknown): unknown {
312
+ const text = typeof output === "string" ? output : JSON.stringify(output) ?? "";
313
+ return text.length > 4000 ? `${text.slice(0, 4000)}...[${text.length} bytes]` : output;
314
+ }
package/do/email.ts ADDED
@@ -0,0 +1,45 @@
1
+ // Sending one email.
2
+ //
3
+ // The caller supplies who it is from and who it is to; this file only knows
4
+ // how to send. The key is resend.api_key in settings.local.json.
5
+ //
6
+ // The tool a model reaches is model/tools/send_email.ts, which calls
7
+ // this. A job calls this directly, from a step.
8
+
9
+ import { setting, settings } from "#chloe/core/settings.ts";
10
+
11
+ /**
12
+ * Who an agent's mail comes from, who it goes to, and the tag in front of
13
+ * every subject.
14
+ */
15
+ export interface Address {
16
+ /** The From line, e.g. "Backups <info@example.com>". */
17
+ from: string;
18
+ /** Who it goes to. */
19
+ to: string[];
20
+ /** Prefix put in front of every subject, so an inbox can be filtered. */
21
+ tag?: string;
22
+ }
23
+
24
+ /** Sends one email and returns its id. The tag is put in front of the subject. */
25
+ export async function send(
26
+ { from, to, tag }: Address,
27
+ subject: string,
28
+ body: string,
29
+ ): Promise<{ sent: true; id?: string; subject: string }> {
30
+ const key = setting(settings.resend.api_key, "RESEND_API_KEY");
31
+ if (!key) throw new Error("No Resend key. Put it in settings.local.json as resend.api_key.");
32
+ // Refuse rather than send nowhere.
33
+ if (to.length === 0) throw new Error("Nobody to send to. The address belongs in settings.local.json.");
34
+ const response = await fetch("https://api.resend.com/emails", {
35
+ method: "POST",
36
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
37
+ body: JSON.stringify({ from, to, subject: tag ? `[${tag}] ${subject}` : subject, text: body }),
38
+ signal: AbortSignal.timeout(30_000),
39
+ });
40
+ if (!response.ok) {
41
+ throw new Error(`Resend refused the message (${response.status}): ${await response.text()}`);
42
+ }
43
+ const sent = (await response.json()) as { id?: string };
44
+ return { sent: true, id: sent.id, subject };
45
+ }
package/do/files.ts ADDED
@@ -0,0 +1,96 @@
1
+ // Reading and writing files inside one folder.
2
+ //
3
+ // `list`, `read`, `search` and `write` are what a job calls from a step. A job
4
+ // that knows which file it wants should call one of these: going through a
5
+ // model to read a path you already know is two seconds and a price for
6
+ // nothing. The tools over them are model/tools/files.ts.
7
+ //
8
+ // They know nothing about what is in the folder: no list of subfolders, no
9
+ // file format, no house rules. All of that is an agent's instructions or a
10
+ // skill, which is text you can edit, not code that needs a restart.
11
+ //
12
+ // What they do enforce is the edge of the folder, through confine().
13
+ import { execFile } from "node:child_process";
14
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
15
+ import { dirname } from "node:path";
16
+
17
+ import { confine } from "#chloe/core/confine.ts";
18
+ import { run } from "./run.ts";
19
+
20
+ /** List a folder. `path` is relative to `root`, and omitting it means the top. */
21
+ export async function list(root: string, path?: string) {
22
+ const resolved = path ? confine(root, path) : root;
23
+ const entries = await readdir(resolved, { withFileTypes: true });
24
+ return {
25
+ path: resolved,
26
+ entries: entries
27
+ .filter((e) => !e.name.startsWith("."))
28
+ .map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
29
+ .sort(),
30
+ };
31
+ }
32
+
33
+ /** Read one file. `path` is relative to `root` and cannot leave it. */
34
+ export async function read(root: string, path: string) {
35
+ const resolved = confine(root, path);
36
+ const content = await readFile(resolved, "utf8");
37
+ return { path: resolved, bytes: content.length, content };
38
+ }
39
+
40
+ /** Search a folder for text, case-insensitive. `folder` narrows it. */
41
+ export async function search(root: string, query: string, folder?: string) {
42
+ const target = folder ? confine(root, folder) : root;
43
+ // ripgrep if the box has it, grep otherwise. An earlier version assumed
44
+ // ripgrep, and when it was not installed every search quietly answered
45
+ // "nothing matched", which reads exactly like a subject he never wrote
46
+ // about. A search that cannot run has to say so.
47
+ const attempts: Array<[string, string[]]> = [
48
+ ["rg", ["-i", "--no-heading", "--line-number", "--max-count", "5", "--glob", "!.git", "--", query, target]],
49
+ ["grep", ["-rIin", "--exclude-dir=.git", "--max-count=5", "-e", query, target]],
50
+ ];
51
+ for (const [file, args] of attempts) {
52
+ const r = await run(file, args, { timeoutMs: 60_000 });
53
+ // grep and rg both exit 1 for "no matches", which is an answer. Only a
54
+ // missing binary (127) means try the next one.
55
+ if (r.exitCode === 127) continue;
56
+ const lines = r.stdout.split("\n").filter(Boolean).slice(0, 80);
57
+ return {
58
+ searchedWith: file,
59
+ matches: lines.length,
60
+ results: lines,
61
+ note: lines.length === 0 ? "Nothing matched. Try the words he would have written." : undefined,
62
+ };
63
+ }
64
+ throw new Error("Neither rg nor grep is on this box, so nothing can be searched.");
65
+ }
66
+
67
+ /**
68
+ * Write one file, replacing it. `commit` makes the write a git commit, for a
69
+ * folder that is a repo, and then `message` is required.
70
+ */
71
+ export async function write(
72
+ root: string,
73
+ path: string,
74
+ content: string,
75
+ { commit = false, message }: { commit?: boolean; message?: string } = {},
76
+ ) {
77
+ if (commit && (message ?? "").length < 10) {
78
+ throw new Error("This folder is a repo, so every write needs a commit message.");
79
+ }
80
+ const resolved = confine(root, path);
81
+ await mkdir(dirname(resolved), { recursive: true });
82
+ await writeFile(resolved, content, "utf8");
83
+ if (!commit) return { path: resolved, bytes: content.length };
84
+ const committed = await new Promise<string>((done) => {
85
+ execFile("git", ["-C", root, "add", "--", resolved], { timeout: 30_000 }, () =>
86
+ execFile(
87
+ "git",
88
+ ["-C", root, "commit", "-m", message!, "--", resolved],
89
+ { timeout: 30_000, encoding: "utf8" },
90
+ (error: unknown, out: string, err: string) =>
91
+ done(error ? `not committed: ${err || out}` : out.trim()),
92
+ ),
93
+ );
94
+ });
95
+ return { path: resolved, bytes: content.length, commit: committed };
96
+ }
package/do/mail.ts ADDED
@@ -0,0 +1,155 @@
1
+ // Reading mail, bound to a fixed search.
2
+ //
3
+ // The point of this file is the thing it does NOT let a caller do lightly. The
4
+ // token `gog` holds can read the whole mailbox, so a query written at the call
5
+ // site is a filter, not a boundary: one prompt injection or one sloppy turn
6
+ // and the filter widens. The search comes from the binding in the agent's own
7
+ // config, the caller may only choose how far back and how many, and fetching a
8
+ // single message re-runs the same search first and refuses an id that is not
9
+ // in it.
10
+ //
11
+ // The tool a model reaches is model/tools/gmail.ts, which calls this
12
+ // with the same binding, so a job does not get a wider search for skipping
13
+ // the model.
14
+ import { run } from "./run.ts";
15
+ import { setting, settings } from "#chloe/core/settings.ts";
16
+
17
+ const GOG = "gog";
18
+
19
+ /** gog reads its account and the password to its saved login from these two. */
20
+ function gog(): Record<string, string> {
21
+ return {
22
+ GOG_ACCOUNT: setting(settings.google.account, "GOG_ACCOUNT"),
23
+ GOG_KEYRING_PASSWORD: setting(settings.google.password, "GOG_KEYRING_PASSWORD"),
24
+ };
25
+ }
26
+
27
+ /** One message from a mailbox, as a search hands it back. */
28
+ export interface Message {
29
+ id: string;
30
+ threadId?: string;
31
+ subject?: string;
32
+ from?: string;
33
+ date?: string;
34
+ snippet?: string;
35
+ }
36
+
37
+ /** What the bound search matches. Nothing here widens it. */
38
+ export async function messages({
39
+ search,
40
+ days = 7,
41
+ limit = 10,
42
+ }: {
43
+ search: string;
44
+ days?: number;
45
+ limit?: number;
46
+ }): Promise<{ query: string; count: number; messages: Message[] }> {
47
+ const query = `${search} newer_than:${days}d`;
48
+ const messages = await search_(query, limit);
49
+ return { query, count: messages.length, messages };
50
+ }
51
+
52
+ /**
53
+ * One message in full. The search runs again first and an id it does not
54
+ * return is refused, which is what makes the binding a boundary rather than a
55
+ * filter. Same check as the tool, because a job is not more trusted than a
56
+ * model here: it is only more predictable.
57
+ */
58
+ export async function oneMessage({
59
+ search,
60
+ what,
61
+ days = 7,
62
+ limit = 10,
63
+ messageId,
64
+ }: {
65
+ search: string;
66
+ what: string;
67
+ days?: number;
68
+ limit?: number;
69
+ messageId: string;
70
+ }): Promise<{ query: string; message: unknown }> {
71
+ const { query, messages: found } = await messages({ search, days, limit });
72
+ if (!found.some((m) => m.id === messageId || m.threadId === messageId)) {
73
+ throw new Error(
74
+ `That message is not in ${what}. You can only read what this search listed. ` +
75
+ `If it is older than ${days} days, ask for more days.`,
76
+ );
77
+ }
78
+ const result = await run(GOG, ["gmail", "get", messageId, "--format", "full", "--json"], {
79
+ timeoutMs: 60_000,
80
+ env: gog(),
81
+ });
82
+ if (result.exitCode !== 0) throw new Error(explain(result.stderr || result.stdout));
83
+ return { query, message: JSON.parse(result.stdout) };
84
+ }
85
+
86
+ async function search_(query: string, max: number): Promise<Message[]> {
87
+ const result = await run(GOG, ["gmail", "search", query, "--max", String(max), "--json"], {
88
+ timeoutMs: 60_000,
89
+ env: gog(),
90
+ });
91
+ if (result.exitCode !== 0) throw new Error(explain(result.stderr || result.stdout));
92
+
93
+ // gog wraps results in an envelope on some commands and not others, so take
94
+ // whichever shape came back rather than assuming one.
95
+ const parsed: unknown = JSON.parse(result.stdout || "[]");
96
+ const rows = Array.isArray(parsed)
97
+ ? parsed
98
+ : ((parsed as Record<string, unknown>)?.messages ??
99
+ (parsed as Record<string, unknown>)?.threads ??
100
+ (parsed as Record<string, unknown>)?.results ??
101
+ []);
102
+ return (Array.isArray(rows) ? rows : []) as Message[];
103
+ }
104
+
105
+ /**
106
+ * The command a person runs to sign in again, built from the account that is
107
+ * actually configured rather than written down: no file in this repo may name
108
+ * a person. Signing in is the one fix an agent cannot do for itself, so the
109
+ * message carries the command ready to paste.
110
+ */
111
+ function signIn(): string {
112
+ const account = gog().GOG_ACCOUNT;
113
+ return account ? `${GOG} auth login --account ${account}` : `${GOG} auth login`;
114
+ }
115
+
116
+ /**
117
+ * Turn the CLI's own failures into something an agent can act on, rather than
118
+ * letting "integrity check failed" reach a model that will retry it forever.
119
+ *
120
+ * Where a person has to do something, say exactly what, as a command they can
121
+ * paste. An agent that answers "you need to re-authenticate" has made someone
122
+ * go and look up how.
123
+ */
124
+ export function explain(text: string): string {
125
+ if (/integrity check failed|KeyUnwrap/i.test(text)) {
126
+ return (
127
+ "Mail is not reachable: the saved Google login could not be opened, " +
128
+ "usually because google.password in settings.local.json does not match the one it was saved with. " +
129
+ `A person has to sign in again on the box, with this command exactly as written: ${signIn()} ` +
130
+ "Give them that command. Do not retry."
131
+ );
132
+ }
133
+ // The keyring opened but Google refused the saved refresh token: it expired,
134
+ // was revoked, or the account's password changed. Nothing an agent can do.
135
+ if (/invalid_grant|token has been expired or revoked/i.test(text)) {
136
+ return (
137
+ "Mail is not reachable: the saved Google sign-in has expired or was revoked. " +
138
+ `A person has to sign in again on the box, with this command exactly as written: ${signIn()} ` +
139
+ "Give them that command. Do not retry."
140
+ );
141
+ }
142
+ if (/no TTY|GOG_KEYRING_PASSWORD/i.test(text)) {
143
+ return (
144
+ "Mail is not reachable: google.password is not set, so the saved login cannot be opened. " +
145
+ "A person has to set it in settings.local.json and restart the service. Do not retry."
146
+ );
147
+ }
148
+ if (/missing --account|GOG_ACCOUNT/i.test(text)) {
149
+ return (
150
+ "Mail is not reachable: google.account is not set, so there is no account to read. " +
151
+ "A person has to set it in settings.local.json and restart the service. Do not retry."
152
+ );
153
+ }
154
+ return `Mail could not be read: ${text.trim().slice(0, 300)}`;
155
+ }
package/do/run.ts ADDED
@@ -0,0 +1,56 @@
1
+ // Running one command on this machine.
2
+ //
3
+ // Nothing here ever takes a shell string. The caller builds its own argument
4
+ // list, so nothing a model says can be spliced into a command.
5
+ import { execFile } from "node:child_process";
6
+
7
+ /** What a command came back with. */
8
+ export interface Result {
9
+ exitCode: number;
10
+ stdout: string;
11
+ stderr: string;
12
+ }
13
+
14
+ const MAX_OUTPUT = 80_000;
15
+
16
+ function clip(s: string): string {
17
+ return s.length > MAX_OUTPUT
18
+ ? `${s.slice(0, MAX_OUTPUT)}\n...[cut here, ${s.length} bytes in total]`
19
+ : s;
20
+ }
21
+
22
+ /**
23
+ * Runs one command with its arguments, never through a shell, and cuts output
24
+ * that is very long.
25
+ */
26
+ export function run(
27
+ file: string,
28
+ args: string[],
29
+ options: { timeoutMs?: number; cwd?: string; env?: Record<string, string> } = {},
30
+ ): Promise<Result> {
31
+ return new Promise((done) => {
32
+ execFile(
33
+ file,
34
+ args,
35
+ {
36
+ cwd: options.cwd,
37
+ env: options.env ? { ...process.env, ...options.env } : undefined,
38
+ timeout: options.timeoutMs ?? 60_000,
39
+ maxBuffer: 1024 * 1024 * 16,
40
+ encoding: "utf8",
41
+ },
42
+ (error, stdout, stderr) => {
43
+ // execFile reports a missing binary as ENOENT, not as an exit code.
44
+ // Turn it into 127, the shell's "command not found", so a caller can
45
+ // tell "this program is not here" from "it ran and failed".
46
+ const code = (error as NodeJS.ErrnoException | null)?.code;
47
+ done({
48
+ exitCode:
49
+ typeof code === "number" ? code : code === "ENOENT" ? 127 : error ? 1 : 0,
50
+ stdout: clip(stdout ?? ""),
51
+ stderr: clip(stderr ?? "") || (code === "ENOENT" ? `${file}: not found` : ""),
52
+ });
53
+ },
54
+ );
55
+ });
56
+ }