@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
@@ -0,0 +1,261 @@
1
+ // Asking the model through the Claude Code CLI rather than over HTTP.
2
+ //
3
+ // This exists for the credential, not the model. A Claude subscription
4
+ // authorises the CLI; it is not an API key and there is nothing in it to put in
5
+ // a bearer header. So a box with a subscription and no gateway credit runs the
6
+ // agents through here instead. Set MODEL_VIA=claude.
7
+ //
8
+ // The CLI brings its own tools and its own loop. Both are switched off, because
9
+ // chloe runs the tools itself, checks each one against its schema and writes
10
+ // every call down. A model that quietly read a file would leave nothing in the
11
+ // run record, which is the one thing this repo will not give up. So the tools
12
+ // are described in the prompt and asked for as JSON.
13
+ import { spawn } from "node:child_process";
14
+ import { randomUUID } from "node:crypto";
15
+
16
+ import { setting, settings } from "#chloe/core/settings.ts";
17
+
18
+ import type { Answer, Ask, Message, ToolCall, ToolSpec } from "./model.ts";
19
+
20
+ /**
21
+ * The CLI names a model without a provider in front of it, and writes a version
22
+ * with a dash where the gateway writes a dot: "anthropic/claude-haiku-4.5"
23
+ * there is "claude-haiku-4-5" here. A non-Anthropic model says so rather than
24
+ * being handed over and refused.
25
+ */
26
+ export function cliModel(model: string): string {
27
+ const at = model.indexOf("/");
28
+ const provider = at < 0 ? "anthropic" : model.slice(0, at);
29
+ if (provider !== "anthropic") {
30
+ throw new Error(
31
+ `MODEL_VIA=claude can only run Anthropic models, and this one asks for ${JSON.stringify(model)}. ` +
32
+ `Either change the model or set MODEL_VIA=gateway.`,
33
+ );
34
+ }
35
+ return model.slice(at + 1).replace(/\./g, "-");
36
+ }
37
+
38
+ /** What the model is told about tools it cannot call itself. */
39
+ function protocol(tools: ToolSpec[]): string {
40
+ const list = tools
41
+ .map((t) => `### ${t.name}\n${t.description}\n\nArguments, as JSON Schema:\n${JSON.stringify(t.parameters)}`)
42
+ .join("\n\n");
43
+
44
+ return [
45
+ "# Tools",
46
+ "",
47
+ "You cannot run a tool yourself. To use one, end your reply with this and nothing after it:",
48
+ "",
49
+ '{"tool": "<name>", "arguments": { ... }}',
50
+ "",
51
+ "It must start its own line and be the last thing you write. A sentence before it is",
52
+ "fine. The result comes back and you are asked again, so ask for one tool at a time.",
53
+ "To answer instead, reply in plain words and end with no such object.",
54
+ "",
55
+ list,
56
+ ].join("\n");
57
+ }
58
+
59
+ function render(message: Message): string {
60
+ if (message.role === "tool") {
61
+ return `[result]\n${message.content}`;
62
+ }
63
+ if (message.role === "assistant") {
64
+ const asked = (message.tool_calls ?? [])
65
+ .map((c) => `[asked for ${c.function.name} with ${c.function.arguments}]`)
66
+ .join("\n");
67
+ return [`[you]`, message.content, asked].filter(Boolean).join("\n");
68
+ }
69
+ return `[${message.role}]\n${message.content}`;
70
+ }
71
+
72
+ /**
73
+ * Split a reply into what it said and what it asked for.
74
+ *
75
+ * The object has to be last and has to start its own line. Models narrate
76
+ * before asking ("Let me check the site first."), and telling them not to does
77
+ * not stop it, so the narration is kept and passed on rather than thrown away.
78
+ * Requiring its own line at the end is what keeps a reply that merely writes
79
+ * about JSON from being read as a request.
80
+ */
81
+ export function readReply(text: string, tools: ToolSpec[] = []): { said: string; call?: ToolCall } {
82
+ const whole = text.trim();
83
+ const tagged = readTagged(whole, tools);
84
+ if (tagged) return tagged;
85
+ const fenced = whole.match(/^([\s\S]*?)```(?:json)?\s*\n([\s\S]*?)\n?```\s*$/);
86
+ const before = fenced ? fenced[1] : whole;
87
+ const tail = fenced ? fenced[2].trim() : "";
88
+
89
+ const candidates = tail ? [{ body: tail, said: before }] : [];
90
+ if (!tail) {
91
+ // Every line that opens an object, latest first: the last one that parses
92
+ // to the end of the reply is the request.
93
+ const lines = whole.split("\n");
94
+ for (let i = lines.length - 1; i >= 0; i--) {
95
+ if (lines[i].startsWith("{")) {
96
+ candidates.push({ body: lines.slice(i).join("\n").trim(), said: lines.slice(0, i).join("\n") });
97
+ }
98
+ }
99
+ }
100
+
101
+ for (const { body, said } of candidates) {
102
+ let parsed: unknown;
103
+ try {
104
+ parsed = JSON.parse(body);
105
+ } catch {
106
+ continue;
107
+ }
108
+ const asked = parsed as { tool?: unknown; arguments?: unknown };
109
+ if (typeof asked.tool !== "string" || !asked.tool) continue;
110
+ return {
111
+ said: said.trim(),
112
+ call: {
113
+ id: randomUUID(),
114
+ type: "function",
115
+ function: { name: asked.tool, arguments: JSON.stringify(asked.arguments ?? {}) },
116
+ },
117
+ };
118
+ }
119
+ return { said: whole };
120
+ }
121
+
122
+ /**
123
+ * The same request in the tag form Claude is trained on, which it sometimes
124
+ * writes despite being told the JSON one:
125
+ * `<invoke name="x"><parameter name="path">a.html</parameter></invoke>`.
126
+ * The first one is the request, the way one JSON object is, and anything after
127
+ * it is dropped: it is often the same call written again. Every value is text in
128
+ * this form, so one the tool's schema says is not a string is read as JSON,
129
+ * which is how a number or a list arrives as one.
130
+ */
131
+ function readTagged(whole: string, tools: ToolSpec[]): { said: string; call: ToolCall } | undefined {
132
+ const start = whole.search(/<(?:[\w-]+:)?invoke\s+name="/);
133
+ if (start < 0) return undefined;
134
+ const opened = whole.slice(start).match(/^<(?:[\w-]+:)?invoke\s+name="([^"]+)"\s*>([\s\S]*?)(?:<\/(?:[\w-]+:)?invoke>|$)/);
135
+ if (!opened) return undefined;
136
+ const wants = (tools.find((t) => t.name === opened[1])?.parameters as { properties?: Record<string, { type?: unknown }> })
137
+ ?.properties;
138
+ const args: Record<string, unknown> = {};
139
+ for (const [, key, raw] of opened[2].matchAll(/<(?:[\w-]+:)?parameter\s+name="([^"]+)"\s*>([\s\S]*?)<\/(?:[\w-]+:)?parameter>/g)) {
140
+ if (wants?.[key]?.type === "string") {
141
+ args[key] = raw;
142
+ continue;
143
+ }
144
+ try {
145
+ args[key] = JSON.parse(raw);
146
+ } catch {
147
+ args[key] = raw;
148
+ }
149
+ }
150
+ return {
151
+ said: whole.slice(0, start).replace(/<(?:[\w-]+:)?function_calls>\s*$/, "").trim(),
152
+ call: { id: randomUUID(), type: "function", function: { name: opened[1], arguments: JSON.stringify(args) } },
153
+ };
154
+ }
155
+
156
+ interface CliAnswer {
157
+ result?: string;
158
+ is_error?: boolean;
159
+ subtype?: string;
160
+ total_cost_usd?: number;
161
+ usage?: { input_tokens?: number; output_tokens?: number };
162
+ }
163
+
164
+ /**
165
+ * The prompt goes in on stdin, never as an argument: Linux caps one argument at
166
+ * 128KB and a long conversation goes past that.
167
+ */
168
+ function invoke(args: string[], input: string, signal?: AbortSignal): Promise<{ code: number; out: string; err: string }> {
169
+ return new Promise((done, fail) => {
170
+ const cli = setting("claude", "CLAUDE_BIN");
171
+ const child = spawn(cli, args, { stdio: ["pipe", "pipe", "pipe"], signal });
172
+ let out = "";
173
+ let err = "";
174
+ child.stdout.on("data", (d: Buffer) => (out += d.toString()));
175
+ child.stderr.on("data", (d: Buffer) => (err += d.toString()));
176
+ child.on("error", (error: NodeJS.ErrnoException) => {
177
+ fail(
178
+ error.code === "ENOENT"
179
+ ? new Error(`model.via is "claude", but ${JSON.stringify(cli)} is not on the path. Install Claude Code, or put it on the path.`)
180
+ : error,
181
+ );
182
+ });
183
+ child.on("close", (code) => done({ code: code ?? 0, out, err }));
184
+ child.stdin.end(input);
185
+ });
186
+ }
187
+
188
+ export async function viaClaude({ model, messages, tools, signal }: Ask): Promise<Answer> {
189
+ const system = [
190
+ ...messages.filter((m) => m.role === "system").map((m) => m.content),
191
+ ...(tools?.length ? [protocol(tools)] : []),
192
+ ].join("\n\n");
193
+
194
+ const transcript = messages.filter((m) => m.role !== "system").map(render).join("\n\n");
195
+
196
+ const args = [
197
+ "-p",
198
+ "--output-format",
199
+ "json",
200
+ "--model",
201
+ cliModel(model),
202
+ // No tools of its own, no settings from this machine, no MCP servers: the
203
+ // same prompt has to mean the same thing on anyone's box.
204
+ "--restricted",
205
+ "--tools",
206
+ "",
207
+ "--strict-mcp-config",
208
+ "--exclude-dynamic-system-prompt-sections",
209
+ "--system-prompt",
210
+ system,
211
+ ];
212
+
213
+ // Files cannot go in plain text, so a turn with any is sent as one message
214
+ // of blocks instead, which the CLI only takes as stream-json. Its last line
215
+ // is the same answer the plain call prints.
216
+ const files = messages.flatMap((m) => m.attachments ?? []);
217
+ const input = files.length
218
+ ? JSON.stringify({
219
+ type: "user",
220
+ message: {
221
+ role: "user",
222
+ content: [
223
+ { type: "text", text: transcript },
224
+ ...files.map((f) => ({
225
+ type: f.mediaType.startsWith("image/") ? "image" : "document",
226
+ source: { type: "base64", media_type: f.mediaType, data: f.data },
227
+ })),
228
+ ],
229
+ },
230
+ }) + "\n"
231
+ : transcript;
232
+ if (files.length) {
233
+ args.splice(args.indexOf("--output-format"), 2, "--input-format", "stream-json", "--output-format", "stream-json", "--verbose");
234
+ }
235
+
236
+ const { code, out, err } = await invoke(args, input, signal);
237
+ if (code !== 0) {
238
+ throw new Error(`Model call refused: claude exited ${code}: ${(err || out).slice(0, 500)}`);
239
+ }
240
+
241
+ let answer: CliAnswer;
242
+ try {
243
+ answer = JSON.parse(files.length ? out.trim().split("\n").pop()! : out) as CliAnswer;
244
+ } catch {
245
+ throw new Error(`Model call refused: claude did not answer with JSON: ${out.slice(0, 500)}`);
246
+ }
247
+ if (answer.is_error || typeof answer.result !== "string") {
248
+ throw new Error(`Model call refused: ${answer.subtype ?? "no result"}: ${String(answer.result ?? "").slice(0, 500)}`);
249
+ }
250
+
251
+ const { said, call } = tools?.length ? readReply(answer.result, tools) : { said: answer.result, call: undefined };
252
+ return {
253
+ text: said,
254
+ toolCalls: call ? [call] : [],
255
+ // What it would have cost on the API. A subscription is not billed per
256
+ // call, so this prices the run rather than charging it.
257
+ cost: answer.total_cost_usd ?? 0,
258
+ tokensIn: answer.usage?.input_tokens ?? 0,
259
+ tokensOut: answer.usage?.output_tokens ?? 0,
260
+ };
261
+ }
@@ -0,0 +1,68 @@
1
+ // The last few messages of one thread, and nothing else: no summarising and
2
+ // nothing kept between threads. What an agent should remember across runs it
3
+ // writes into its own folder, where it can be read and corrected.
4
+ import { db } from "#chloe/core/db.ts";
5
+ import type { Message } from "./model.ts";
6
+
7
+ /** How many messages of a conversation a turn is shown, when the agent does not say. */
8
+ export const RECALL = 10;
9
+
10
+ export interface Used {
11
+ tool: string;
12
+ args: unknown;
13
+ }
14
+
15
+ /** Long strings in a call's arguments, like a whole file being written, are cut. */
16
+ function clipArgs(args: unknown): unknown {
17
+ if (typeof args === "string") return args.length > 300 ? `${args.slice(0, 300)}...` : args;
18
+ if (Array.isArray(args)) return args.map(clipArgs);
19
+ if (args && typeof args === "object") return Object.fromEntries(Object.entries(args).map(([k, v]) => [k, clipArgs(v)]));
20
+ return args;
21
+ }
22
+
23
+ /** `used` is the tools a reply called, kept so a later turn can see how the reply was reached. */
24
+ export function remember(thread: string, role: "user" | "assistant", content: string, used: Used[] = []): void {
25
+ if (!content) return;
26
+ db.prepare("insert into messages (thread, role, content, at, used) values (?, ?, ?, ?, ?)").run(
27
+ thread,
28
+ role,
29
+ content,
30
+ new Date().toISOString(),
31
+ used.length ? JSON.stringify(used.map((one) => ({ tool: one.tool, args: clipArgs(one.args) }))) : null,
32
+ );
33
+ }
34
+
35
+ /**
36
+ * The last `limit` messages of a thread, oldest first, leaving out any older
37
+ * than `days` when it is given. Nothing is deleted: a message too old to be
38
+ * recalled is still in the database and still on the page.
39
+ *
40
+ * With `tools`, a reply that called tools is preceded by those calls, in the
41
+ * same shape a turn's own calls take, with results that were not kept.
42
+ * Without them a model reading its own earlier answer cannot tell a fact it
43
+ * looked up from one it made up.
44
+ */
45
+ export function recall(thread: string, { limit = RECALL, days, tools = false }: { limit?: number; days?: number; tools?: boolean } = {}): Message[] {
46
+ const since = days ? new Date(Date.now() - days * 86_400_000).toISOString() : "";
47
+ const rows = db
48
+ .prepare("select id, role, content, used from messages where thread = ? and at >= ? order by id desc limit ?")
49
+ .all(thread, since, limit) as { id: number; role: string; content: string; used: string | null }[];
50
+ return rows.reverse().flatMap((r): Message[] => {
51
+ const said: Message = { role: r.role as Message["role"], content: r.content };
52
+ if (!tools || !r.used) return [said];
53
+ const calls = (JSON.parse(r.used) as Used[]).map((one, i) => ({
54
+ id: `recalled-${r.id}-${i}`,
55
+ type: "function" as const,
56
+ function: { name: one.tool, arguments: JSON.stringify(one.args ?? {}) },
57
+ }));
58
+ return [
59
+ { role: "assistant", content: "", tool_calls: calls },
60
+ ...calls.map((call) => ({ role: "tool" as const, tool_call_id: call.id, content: "(done; the result was not kept)" })),
61
+ said,
62
+ ];
63
+ });
64
+ }
65
+
66
+ export function forget(thread: string): void {
67
+ db.prepare("delete from messages where thread = ?").run(thread);
68
+ }
package/model/model.ts ADDED
@@ -0,0 +1,185 @@
1
+ // Asking a model, two ways.
2
+ //
3
+ // A model is a string like "anthropic/claude-sonnet-5". By default it goes to
4
+ // the Vercel AI Gateway over HTTP, so changing provider is changing that
5
+ // string, and any gateway that speaks the same shape works by setting
6
+ // AI_GATEWAY_URL.
7
+ //
8
+ // MODEL_VIA=claude sends it through the Claude Code CLI instead, which is the
9
+ // only way to spend a Claude subscription: a subscription authorises the CLI
10
+ // and is not an API key. See claude.ts. Everything above this file is the same
11
+ // either way, which is the reason ask() is the only seam.
12
+
13
+ import { setting, settings } from "#chloe/core/settings.ts";
14
+
15
+ import { viaClaude } from "./claude.ts";
16
+
17
+ /**
18
+ * Which way a model call goes. A box with a key uses it; a box with only a
19
+ * subscription falls through to the CLI, so a fresh clone runs either way
20
+ * without being told. MODEL_VIA settles it when both are there.
21
+ */
22
+ export function via(): "gateway" | "claude" {
23
+ const chosen = setting(settings.model.via, "MODEL_VIA");
24
+ if (chosen === "gateway" || chosen === "claude") return chosen;
25
+ if (chosen) throw new Error(`model.via is ${JSON.stringify(chosen)}. It is "gateway" or "claude".`);
26
+ return gatewayKey() ? "gateway" : "claude";
27
+ }
28
+
29
+ function gatewayKey(): string {
30
+ return setting(settings.model.key, "AI_GATEWAY_API_KEY");
31
+ }
32
+
33
+ /** A file handed to the model with a message: a photo or a PDF. */
34
+ export interface Attachment {
35
+ /** Like "image/jpeg" or "application/pdf". */
36
+ mediaType: string;
37
+ /** The file, base64. */
38
+ data: string;
39
+ name?: string;
40
+ }
41
+
42
+ /** In the shape the gateway wants it, apart from attachments, which each way turns into its own. */
43
+ export interface Message {
44
+ role: "system" | "user" | "assistant" | "tool";
45
+ content: string;
46
+ /** On a user message only. */
47
+ attachments?: Attachment[];
48
+ /** On an assistant message: the tools it asked for. */
49
+ tool_calls?: ToolCall[];
50
+ /** On a tool message: which call this answers. */
51
+ tool_call_id?: string;
52
+ }
53
+
54
+ export interface ToolCall {
55
+ id: string;
56
+ type: "function";
57
+ function: { name: string; arguments: string };
58
+ }
59
+
60
+ export interface ToolSpec {
61
+ name: string;
62
+ description: string;
63
+ parameters: Record<string, unknown>;
64
+ }
65
+
66
+ export interface Answer {
67
+ text: string;
68
+ toolCalls: ToolCall[];
69
+ /** Dollars, when the gateway says. */
70
+ cost: number;
71
+ tokensIn: number;
72
+ tokensOut: number;
73
+ }
74
+
75
+ export interface Ask {
76
+ model: string;
77
+ messages: Message[];
78
+ tools?: ToolSpec[];
79
+ maxTokens?: number;
80
+ signal?: AbortSignal;
81
+ }
82
+
83
+ /** The chat-completions shape for a message with files: text first, then each file. */
84
+ function forGateway({ attachments, ...message }: Message): unknown {
85
+ if (!attachments?.length) return message;
86
+ const url = (a: Attachment) => `data:${a.mediaType};base64,${a.data}`;
87
+ return {
88
+ ...message,
89
+ content: [
90
+ { type: "text", text: message.content },
91
+ ...attachments.map((a) =>
92
+ a.mediaType.startsWith("image/")
93
+ ? { type: "image_url", image_url: { url: url(a) } }
94
+ : { type: "file", file: { filename: a.name ?? "file", file_data: url(a) } },
95
+ ),
96
+ ],
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Asks a model once and returns what it said, whichever way `model.via` sends
102
+ * it. The only seam between a key and a subscription.
103
+ */
104
+ export function ask(request: Ask): Promise<Answer> {
105
+ return via() === "claude" ? viaClaude(request) : viaGateway(request);
106
+ }
107
+
108
+ async function viaGateway({ model, messages, tools, maxTokens, signal }: Ask): Promise<Answer> {
109
+ const key = gatewayKey();
110
+ if (!key) {
111
+ throw new Error(
112
+ "No gateway key. Put it in settings.local.json as model.key. " +
113
+ 'To run on a Claude subscription instead, set model.via to "claude".',
114
+ );
115
+ }
116
+
117
+ const body = {
118
+ model,
119
+ messages: messages.map(forGateway),
120
+ max_tokens: maxTokens ?? 8000,
121
+ ...(tools?.length ? { tools: tools.map((t) => ({ type: "function", function: t })) } : {}),
122
+ };
123
+
124
+ let lastError = "";
125
+ for (let attempt = 0; attempt < 4; attempt++) {
126
+ if (attempt > 0) await wait(Math.min(2 ** attempt, 8) * 1000, signal);
127
+
128
+ const response = await fetch(setting(settings.model.gateway, "AI_GATEWAY_URL"), {
129
+ method: "POST",
130
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
131
+ body: JSON.stringify(body),
132
+ signal: signal ?? AbortSignal.timeout(600_000),
133
+ }).catch((error: unknown) => error as Error);
134
+
135
+ if (response instanceof Error) {
136
+ lastError = response.message;
137
+ continue;
138
+ }
139
+ if (!response.ok) {
140
+ const text = (await response.text()).slice(0, 500);
141
+ // A bad key or a missing model fails the same way forever: say so now.
142
+ const worthRetrying = response.status === 408 || response.status === 409 || response.status === 429 || response.status >= 500;
143
+ lastError = `the gateway answered ${response.status}: ${text}`;
144
+ if (!worthRetrying) throw new Error(`Model call refused: ${lastError}`);
145
+ continue;
146
+ }
147
+
148
+ const json = (await response.json()) as GatewayAnswer;
149
+ const choice = json.choices?.[0];
150
+ if (!choice) {
151
+ lastError = "the gateway answered with no choices";
152
+ continue;
153
+ }
154
+ return {
155
+ text: choice.message?.content ?? "",
156
+ toolCalls: choice.message?.tool_calls ?? [],
157
+ // The gateway reports cost 0 for a user's own provider key and puts the
158
+ // real number in upstream_inference_cost.
159
+ cost: json.usage?.cost || json.usage?.cost_details?.upstream_inference_cost || 0,
160
+ tokensIn: json.usage?.prompt_tokens ?? 0,
161
+ tokensOut: json.usage?.completion_tokens ?? 0,
162
+ };
163
+ }
164
+ throw new Error(`Model call failed after 4 attempts: ${lastError}`);
165
+ }
166
+
167
+ interface GatewayAnswer {
168
+ choices?: { message?: { content?: string; tool_calls?: ToolCall[] } }[];
169
+ usage?: {
170
+ cost?: number;
171
+ prompt_tokens?: number;
172
+ completion_tokens?: number;
173
+ cost_details?: { upstream_inference_cost?: number };
174
+ };
175
+ }
176
+
177
+ function wait(ms: number, signal?: AbortSignal): Promise<void> {
178
+ return new Promise((done, fail) => {
179
+ const timer = setTimeout(done, ms);
180
+ signal?.addEventListener("abort", () => {
181
+ clearTimeout(timer);
182
+ fail(new Error("stopped"));
183
+ });
184
+ });
185
+ }
package/model/tool.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { z } from "zod";
2
+
3
+ import type { ToolSpec } from "./model.ts";
4
+
5
+ /**
6
+ * What a model can be handed: an id, a description a model reads, a schema for
7
+ * its arguments, and one function.
8
+ */
9
+ export interface Tool<Input = any> {
10
+ id: string;
11
+ description: string;
12
+ inputSchema: z.ZodType<Input>;
13
+ execute: (input: Input) => Promise<unknown> | unknown;
14
+ }
15
+
16
+ /** The type of `execute`'s argument comes from the schema. */
17
+ export function tool<Schema extends z.ZodType>(definition: {
18
+ id: string;
19
+ description: string;
20
+ inputSchema: Schema;
21
+ execute: (input: z.infer<Schema>) => Promise<unknown> | unknown;
22
+ }): Tool<z.infer<Schema>> {
23
+ return definition as Tool<z.infer<Schema>>;
24
+ }
25
+
26
+ /** Keyed by the name the model calls them by. */
27
+ export type Tools = Record<string, Tool>;
28
+
29
+ /** One tool a model asked for: what it was called with, what came back, and whether it was allowed to run at all. */
30
+ export interface Call {
31
+ tool: string;
32
+ args: unknown;
33
+ result: unknown;
34
+ /** Set when `approve` would not let it run, in which case nothing ran and `result` is what the model was told. */
35
+ refused?: boolean;
36
+ }
37
+
38
+ /**
39
+ * Asked before a tool runs, with the arguments the model chose. Return `true`
40
+ * to let it run. Anything else stops that one call, and a string is the reason
41
+ * the model is told, so do not answer "yes" when you mean true. Nothing runs
42
+ * while it is deciding, and what it decides is written into the run beside the
43
+ * call. It is your code, so throwing out of it fails the step rather than
44
+ * refusing the call.
45
+ */
46
+ export type Approve = (call: { tool: string; args: unknown }) => Promise<boolean | string> | boolean | string;
47
+
48
+ export function describe(name: string, one: Tool): ToolSpec {
49
+ const schema = z.toJSONSchema(one.inputSchema, { io: "input" }) as Record<string, any>;
50
+ // $schema means nothing to a provider and some reject it.
51
+ delete schema.$schema;
52
+ return { name, description: one.description, parameters: schema };
53
+ }
@@ -0,0 +1,71 @@
1
+ // The tools over do/files.ts: one folder, offered to a model.
2
+ //
3
+ // An agent binds each one to a root it is allowed to see, and names that
4
+ // folder in plain words for the description.
5
+ import { z } from "zod";
6
+
7
+ import { list, read, search, write } from "#chloe/do/files.ts";
8
+ import { tool } from "#chloe/model/tool.ts";
9
+
10
+ /**
11
+ * `what` names the folder in the tool's description, e.g. "the shared notes".
12
+ * `id` renames the tool, for an agent that binds two different folders and
13
+ * would otherwise have two tools called the same thing.
14
+ */
15
+ interface Folder {
16
+ root: string;
17
+ what: string;
18
+ id?: string;
19
+ }
20
+
21
+ /** A tool that lists what is in one folder, and nothing outside it. */
22
+ export function listIn({ root, what, id = "list_notes" }: Folder) {
23
+ return tool({
24
+ id,
25
+ description: `List a folder in ${what}, so you can find the right file before reading it. Start here rather than guessing at a path.`,
26
+ inputSchema: z.object({
27
+ path: z.string().optional().describe("Folder to list. Omit for the top level."),
28
+ }),
29
+ execute: ({ path }) => list(root, path),
30
+ });
31
+ }
32
+
33
+ /** A tool that reads one file inside that folder. */
34
+ export function readIn({ root, what, id = "read_notes" }: Folder) {
35
+ return tool({
36
+ id,
37
+ description: `Read one file from ${what}. Read before answering, and read before writing: guessing from memory is how you end up confidently wrong.`,
38
+ inputSchema: z.object({ path: z.string() }),
39
+ execute: ({ path }) => read(root, path),
40
+ });
41
+ }
42
+
43
+ /** A tool that searches the text of the files in that folder. */
44
+ export function searchIn({ root, what, id = "search_notes" }: Folder) {
45
+ return tool({
46
+ id,
47
+ description: `Search ${what} for text, and return the matching files and lines. Search before answering anything you are not certain of.`,
48
+ inputSchema: z.object({
49
+ query: z.string().min(2).describe("Text to look for, case-insensitive."),
50
+ folder: z.string().optional().describe("Narrow to one folder. Omit to search everything."),
51
+ }),
52
+ execute: ({ query, folder }) => search(root, query, folder),
53
+ });
54
+ }
55
+
56
+ /** `commit` makes every write a git commit, for a folder that is a repo. */
57
+ export function writeIn({ root, what, id = "write_notes", commit = false }: Folder & { commit?: boolean }) {
58
+ return tool({
59
+ id,
60
+ description:
61
+ `Write one file in ${what}. This replaces the file, so include everything you want kept: ` +
62
+ `read it first unless it is new.` +
63
+ (commit ? " Committed as it is written, so `message` is required." : ""),
64
+ inputSchema: z.object({
65
+ path: z.string(),
66
+ content: z.string().min(1),
67
+ message: z.string().optional().describe("Commit message saying what changed. Required here."),
68
+ }),
69
+ execute: ({ path, content, message }) => write(root, path, content, { commit, message }),
70
+ });
71
+ }
@@ -0,0 +1,43 @@
1
+ // The tool over do/mail.ts: reading mail, bound to a fixed search.
2
+ //
3
+ // The binding lives in the agent's config, not in anything the model can
4
+ // write. All the model chooses is how far back and how many.
5
+ import { z } from "zod";
6
+
7
+ import { messages, oneMessage } from "#chloe/do/mail.ts";
8
+ import { tool } from "#chloe/model/tool.ts";
9
+
10
+ interface Options {
11
+ /** Gmail query this agent may see, and nothing else. Set in its config. */
12
+ search: string;
13
+ /** How to describe that mail in the tool's description, in plain words. */
14
+ what: string;
15
+ /** Days back when the agent does not say. */
16
+ days?: number;
17
+ id?: string;
18
+ }
19
+
20
+ /**
21
+ * A tool that reads the mail the agent is bound to. The search is the
22
+ * binding's, and the model chooses only how far back and how many.
23
+ */
24
+ export function readMail({ search, what, days = 7, id = "read_mail" }: Options) {
25
+ return tool({
26
+ id,
27
+ description:
28
+ `Read ${what}. Lists what is there; pass a messageId from that list to read one in full. ` +
29
+ `You cannot change which mail this searches.`,
30
+ inputSchema: z.object({
31
+ days: z.number().int().min(1).max(30).optional().describe("How far back to look. Default 7."),
32
+ limit: z.number().int().min(1).max(25).optional().describe("How many at most. Default 10."),
33
+ messageId: z
34
+ .string()
35
+ .optional()
36
+ .describe("Read this one in full. Must be an id this tool already listed."),
37
+ }),
38
+ execute: ({ days: back, limit, messageId }) =>
39
+ messageId
40
+ ? oneMessage({ search, what, days: back ?? days, limit: limit ?? 10, messageId })
41
+ : messages({ search, days: back ?? days, limit: limit ?? 10 }),
42
+ });
43
+ }