@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,28 @@
1
+ // What a model can be given, to the repo that installs chloe.
2
+ //
3
+ // Everything here is a tool: a wrapper that lets a model reach work it could
4
+ // not have been told the rule for. Each one is a function an agent calls with
5
+ // its own folder, its own mailbox, its own From line, so nothing here names an
6
+ // agent or a person.
7
+ //
8
+ // import { readMail, readWeb } from "@chloejs/core/tools";
9
+ //
10
+ // The notes tools, write_skill and run_script are not here: an agent turns
11
+ // them on with `features` in its definition.
12
+ //
13
+ // The work itself is in do/, published from "@chloejs/core", and a job calls it
14
+ // from a step rather than coming through here. If a job imports this file,
15
+ // something is in the wrong place.
16
+ //
17
+ // Same rule as `index.ts`: adding a name here is publishing it.
18
+
19
+
20
+ // One folder, as tools, for an agent that needs a different set.
21
+ export { listIn, readIn, searchIn, writeIn } from "./files.ts";
22
+
23
+ // Mail in, mail out.
24
+ export { readMail } from "./gmail.ts";
25
+ export { sendEmail } from "./send_email.ts";
26
+
27
+ // Reading a public web page.
28
+ export { readWeb } from "./web.ts";
@@ -0,0 +1,23 @@
1
+ // An agent's memory, as tools: list_notes, read_notes, search_notes and
2
+ // write_notes, all inside the one folder its definition's `memory` names
3
+ // (its own folder under the state directory when it names none). Every agent
4
+ // has them: the loader adds them, so an agent's `tools` never lists them.
5
+ import { mkdirSync } from "node:fs";
6
+
7
+ import { listIn, readIn, searchIn, writeIn } from "./files.ts";
8
+ import type { Tools } from "../tool.ts";
9
+
10
+ /** The four notes tools for one agent's memory. A write is a git commit when `commit` says so. */
11
+ export function memoryTools(memory: { folder: string; commit?: boolean }): Tools {
12
+ const { folder, commit } = memory;
13
+ const what = "your memory";
14
+ // A new agent has no folder yet, and the first thing it does should not be
15
+ // to fail on one missing.
16
+ mkdirSync(folder, { recursive: true });
17
+ return {
18
+ list_notes: listIn({ root: folder, what }),
19
+ read_notes: readIn({ root: folder, what }),
20
+ search_notes: searchIn({ root: folder, what }),
21
+ write_notes: writeIn({ root: folder, what, commit }),
22
+ };
23
+ }
@@ -0,0 +1,44 @@
1
+ // The tool over do/scripts.ts: an agent running one of its own scripts.
2
+ //
3
+ // This is the plug-and-play half of the system. A capability is a script in
4
+ // the agent's `scripts/` folder plus a short file in its `skills/` folder
5
+ // saying when to use it. Neither is TypeScript and neither needs a restart.
6
+ import { existsSync, readdirSync } from "node:fs";
7
+
8
+ import { z } from "zod";
9
+
10
+ import { agentDir } from "#chloe/core/paths.ts";
11
+ import { script, scripts } from "#chloe/do/scripts.ts";
12
+ import { tool, type Tools } from "#chloe/model/tool.ts";
13
+
14
+ /** A tool that runs one file from that agent's own `scripts/` folder. */
15
+ export function runScript(agent: string) {
16
+ return tool({
17
+ id: "run_script",
18
+ description:
19
+ "Run one of your own scripts and return what it printed. Your skills say which script to " +
20
+ "use and what its arguments mean. Use `list` to see what you have.",
21
+ inputSchema: z.object({
22
+ script: z.string().describe(`A file in scripts/, or "list" to see them all.`),
23
+ args: z.array(z.string()).optional().describe("Arguments, one per item."),
24
+ timeoutSeconds: z.number().int().min(5).max(1800).optional(),
25
+ }),
26
+ execute: async ({ script: name, args = [], timeoutSeconds }) =>
27
+ name === "list"
28
+ ? { scripts: await scripts(agent) }
29
+ : script(agent, name, args, { timeoutMs: (timeoutSeconds ?? 300) * 1000 }),
30
+ });
31
+ }
32
+
33
+ /**
34
+ * run_script: the agent runs any file in its scripts/ folder, and nothing
35
+ * else. Refused as the agent loads when that folder has nothing in it.
36
+ */
37
+ export function runScripts(): (agent: { name: string }) => Tools {
38
+ return ({ name }) => {
39
+ const dir = `${agentDir(name)}/scripts`;
40
+ const any = existsSync(dir) && readdirSync(dir, { withFileTypes: true }).some((e) => e.isFile() && !e.name.startsWith("."));
41
+ if (!any) throw new Error(`${name} has features.runScripts on, and ${dir} has no scripts in it.`);
42
+ return { run_script: runScript(name) };
43
+ };
44
+ }
@@ -0,0 +1,29 @@
1
+ // The tool over do/email.ts: sending one email.
2
+ //
3
+ // The agent binds its own From line and its own recipients. All the model
4
+ // writes is the subject and the body.
5
+ import { z } from "zod";
6
+
7
+ import { type Address, send } from "#chloe/do/email.ts";
8
+ import { tool } from "#chloe/model/tool.ts";
9
+
10
+ interface Sender extends Address {
11
+ /** Who it reaches and when to use it, in the agent's own words. Shown to the model. */
12
+ when: string;
13
+ }
14
+
15
+ /**
16
+ * A tool that sends mail from the address the agent was given, to the address
17
+ * it was given.
18
+ */
19
+ export function sendEmail({ when, ...address }: Sender) {
20
+ return tool({
21
+ id: "send_email",
22
+ description: `Send an email. ${when}`,
23
+ inputSchema: z.object({
24
+ subject: z.string().min(5).max(120),
25
+ body: z.string().min(20).describe("Plain text. Lead with what happened and what you did."),
26
+ }),
27
+ execute: ({ subject, body }) => send(address, subject, body),
28
+ });
29
+ }
@@ -0,0 +1,23 @@
1
+ // The tool over do/web.ts: reading one public web page.
2
+ import { z } from "zod";
3
+
4
+ import { readPage } from "#chloe/do/web.ts";
5
+ import { tool } from "#chloe/model/tool.ts";
6
+
7
+ /** A tool that reads one public web page as plain text. */
8
+ export function readWeb() {
9
+ return tool({
10
+ id: "read_page",
11
+ description:
12
+ "Read a public web page as plain text. Links come back as `[text](url)`: to follow one, pass that url " +
13
+ "exactly as it came back, never one you rebuilt by hand, because one changed character can make a site " +
14
+ "quietly show a different page. A long page comes back in slices: pass `from` as the `next` it gave you. " +
15
+ "Only a page that came back nearly empty was built by JavaScript in the browser; if a page came back " +
16
+ "full but wrong, say what you asked for and what you got, and do not guess why.",
17
+ inputSchema: z.object({
18
+ url: z.url(),
19
+ from: z.number().int().min(0).optional().describe("Where to start, from the last slice's `next`."),
20
+ }),
21
+ execute: ({ url, from }) => readPage(url, from ?? 0),
22
+ });
23
+ }
@@ -0,0 +1,31 @@
1
+ // An agent rewriting one of its own skills.
2
+ //
3
+ // This is the self-improving part, and it is deliberately the only part. A
4
+ // skill is markdown: when to do something, which script does it, and what the
5
+ // output means. An agent that learns something about its own job can put it
6
+ // where the next run will read it, and the change is live in about fifteen
7
+ // seconds with no restart.
8
+ //
9
+ // What an agent may NOT write is its tools and its scripts/ folder. Those are
10
+ // code, and code it writes is code it then runs as itself. A skill can only
11
+ // point at a script that a person already put there.
12
+ //
13
+ // Every write is a git commit, so self-improvement always leaves a diff.
14
+ import { writeIn } from "./files.ts";
15
+ import { agentDir } from "#chloe/core/paths.ts";
16
+ import type { Tools } from "../tool.ts";
17
+
18
+ /** A tool that rewrites one of the agent's own skills. Every write is a commit. */
19
+ export function writeSkill(agent: string) {
20
+ return writeIn({
21
+ root: `${agentDir(agent)}/skills`,
22
+ what: "your own skills",
23
+ id: "write_skill",
24
+ commit: true,
25
+ });
26
+ }
27
+
28
+ /** write_skill: the agent rewrites its own skills, and every write is a commit. */
29
+ export function selfImprovement(): (agent: { name: string }) => Tools {
30
+ return ({ name }) => ({ write_skill: writeSkill(name) });
31
+ }
package/ops/account.ts ADDED
@@ -0,0 +1,109 @@
1
+ // Making the one account, from a shell on the box that runs chloe.
2
+ //
3
+ // npm run account asks for a username and a password
4
+ // npm run account carlos asks for the password only
5
+ //
6
+ // There used to be a setup form on the first visit. There is no page on this
7
+ // port any more, so the first account is made here instead, which is better
8
+ // anyway: it takes a shell rather than whoever reaches the address first.
9
+ //
10
+ // Changing the account later means deleting data/login.json and running this
11
+ // again, which is deliberate and takes the same shell.
12
+ import { createAccount, hasAccount } from "#chloe/serve/login.ts";
13
+
14
+ if (hasAccount()) {
15
+ console.error("There is already an account. To change it, delete data/login.json and run this again.");
16
+ process.exit(1);
17
+ }
18
+
19
+ /**
20
+ * Whatever was typed past the end of the line just read, kept for the next
21
+ * question. Piped input arrives as one chunk holding every answer at once, so a
22
+ * reader that dropped the remainder would lose the second question's answer.
23
+ */
24
+ let spare = "";
25
+
26
+ /**
27
+ * One line from the terminal. A hidden one is not printed back, so a password is
28
+ * not left on the screen or in the scrollback. Read a character at a time rather
29
+ * than with readline, because readline reads ahead and the next question would
30
+ * find its answer already eaten.
31
+ */
32
+ function line(question: string, hide: boolean): Promise<string> {
33
+ process.stdout.write(question);
34
+ return new Promise((done, fail) => {
35
+ const input = process.stdin;
36
+ const wasRaw = input.isRaw;
37
+ let said = "";
38
+
39
+ function finish(error?: Error): void {
40
+ input.off("data", more);
41
+ if (input.isTTY) input.setRawMode(Boolean(wasRaw));
42
+ input.pause();
43
+ process.stdout.write("\n");
44
+ if (error) fail(error);
45
+ else done(said.trim());
46
+ }
47
+
48
+ /** Takes one line out of `text`. True when it found the end of one. */
49
+ function take(text: string): boolean {
50
+ for (let at = 0; at < text.length; at++) {
51
+ const one = text[at];
52
+ if (one === "\r" || one === "\n") {
53
+ spare = text.slice(at + 1).replace(/^\n/, "");
54
+ return true;
55
+ }
56
+ if (one === "") throw new Error("Stopped.");
57
+ if (one === "" || one === "\b") {
58
+ said = said.slice(0, -1);
59
+ if (!hide && input.isTTY) process.stdout.write("\b \b");
60
+ } else {
61
+ said += one;
62
+ // Raw mode turns the terminal's own echo off, so an answer that is
63
+ // meant to be seen has to be written back here.
64
+ if (!hide && input.isTTY) process.stdout.write(one);
65
+ }
66
+ }
67
+ return false;
68
+ }
69
+
70
+ function more(chunk: string): void {
71
+ try {
72
+ if (take(chunk)) finish();
73
+ } catch (error) {
74
+ finish(error as Error);
75
+ }
76
+ }
77
+
78
+ const held = spare;
79
+ spare = "";
80
+ try {
81
+ if (take(held)) return void finish();
82
+ } catch (error) {
83
+ return void finish(error as Error);
84
+ }
85
+
86
+ if (input.isTTY) input.setRawMode(true);
87
+ input.setEncoding("utf8");
88
+ input.resume();
89
+ input.on("data", more);
90
+ });
91
+ }
92
+
93
+ const username = process.argv[2] ?? (await line("Username: ", false));
94
+ const password = await line("Password: ", true);
95
+ const again = await line("Again: ", true);
96
+
97
+ if (password !== again) {
98
+ console.error("Those two are not the same.");
99
+ process.exit(1);
100
+ }
101
+
102
+ try {
103
+ createAccount(username, password);
104
+ } catch (error) {
105
+ console.error((error as Error).message);
106
+ process.exit(1);
107
+ }
108
+
109
+ console.log(`\nDone. ${username} can sign in at whatever serves the page.`);
package/ops/agent.ts ADDED
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env node
2
+ // One agent, from the terminal. Run by a person, to find out what an agent
3
+ // actually knows and what its jobs actually do:
4
+ //
5
+ // npm run agent <agent> a conversation, until /exit
6
+ // npm run agent <agent> "question" one question, then out
7
+ // npm run agent <agent> < draft.md the same, piped
8
+ // npm run agent <agent> <job> run one of its jobs now, and
9
+ // follow it step by step
10
+ //
11
+ // The last one is how a job is tried without waiting for its cron line. What
12
+ // it is depends on what it matches: anything that is the id of one
13
+ // of that agent's jobs runs that job, and everything else is a
14
+ // question. So `npm run agent cc check-sites` is a run and `npm run agent cc
15
+ // "is the disk full?"` is a question.
16
+ //
17
+ // It asks the running service over loopback rather than loading the runtime
18
+ // itself. A second runtime would be a second writer on the database and a
19
+ // second clock firing the same cron lines, so the nightly backup could go
20
+ // twice.
21
+ //
22
+ // Every turn lands in the run history with its tool calls and its cost, the
23
+ // same as one from the page or a job.
24
+ import { readFileSync } from "node:fs";
25
+ import { createInterface } from "node:readline/promises";
26
+
27
+ import { ownCookie } from "#chloe/serve/login.ts";
28
+
29
+ // Matches HOST and PORT in serve/http.ts, which are deliberately not settable.
30
+ const BASE = "http://127.0.0.1:3067";
31
+
32
+ interface Result {
33
+ runId: string;
34
+ text: string;
35
+ steps: number;
36
+ cost: number;
37
+ calls: { tool: string; args: unknown; result: unknown }[];
38
+ }
39
+
40
+ interface Listed {
41
+ name: string;
42
+ description?: string;
43
+ model: string;
44
+ tools: string[];
45
+ skills: string[];
46
+ jobs: ListedJob[];
47
+ }
48
+
49
+ interface ListedJob {
50
+ id: string;
51
+ description?: string;
52
+ cron?: string;
53
+ timezone: string;
54
+ /** "code" for a job, or the model a prompt asks. */
55
+ model: string;
56
+ code: boolean;
57
+ }
58
+
59
+ /** One finished step of a job, as the run record keeps it. */
60
+ interface Step {
61
+ seq: number;
62
+ name: string;
63
+ kind: "step" | "model" | "ask";
64
+ ms: number;
65
+ cost: number;
66
+ }
67
+
68
+ interface Run {
69
+ id: string;
70
+ agent: string;
71
+ started: string;
72
+ finished?: string | null;
73
+ source?: string;
74
+ job?: string | null;
75
+ steps?: number;
76
+ cost?: number;
77
+ error?: string | null;
78
+ reply?: string | null;
79
+ parked?: string | null;
80
+ trace?: Step[];
81
+ }
82
+
83
+ async function api<T>(path: string, body?: unknown): Promise<T> {
84
+ // The API is behind the same login as the page. This signs itself in by
85
+ // reading the account file, which is the same permission as running this.
86
+ // The channel header is what makes the log say "terminal" rather than "api".
87
+ const headers: Record<string, string> = { cookie: ownCookie(), "x-chloe-channel": "terminal" };
88
+ const response = await fetch(
89
+ `${BASE}${path}`,
90
+ body === undefined
91
+ ? { headers }
92
+ : { method: "POST", headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify(body) },
93
+ ).catch((error: unknown) => error as Error);
94
+
95
+ if (response instanceof Error) {
96
+ throw new Error(`Nothing is answering on ${BASE}. Start it with: systemctl --user start chloe.service`);
97
+ }
98
+ const text = await response.text();
99
+ let parsed: unknown;
100
+ try {
101
+ parsed = JSON.parse(text);
102
+ } catch {
103
+ throw new Error(`The service answered ${response.status}: ${text.slice(0, 200)}`);
104
+ }
105
+ const value = parsed as { error?: string };
106
+ if (!response.ok) throw new Error(value.error ?? `The service answered ${response.status}.`);
107
+ return parsed as T;
108
+ }
109
+
110
+ const ESC = String.fromCharCode(27);
111
+ const dim = (s: string) => (process.stdout.isTTY ? `${ESC}[2m${s}${ESC}[0m` : s);
112
+ const bold = (s: string) => (process.stdout.isTTY ? `${ESC}[1m${s}${ESC}[0m` : s);
113
+
114
+ function show(result: Result): void {
115
+ console.log(`\n${result.text.trim()}\n`);
116
+ const parts = [`${result.steps} step${result.steps === 1 ? "" : "s"}`, `$${result.cost.toFixed(4)}`];
117
+ const used = result.calls.map((c) => c.tool);
118
+ if (used.length > 0) parts.push(`used ${used.join(", ")}`);
119
+ console.log(dim(`(${parts.join(", ")})\n`));
120
+ }
121
+
122
+ const [name, ...rest] = process.argv.slice(2);
123
+
124
+ const agents = await api<Listed[]>("/api/agents").catch((error: unknown) => {
125
+ console.error(error instanceof Error ? error.message : String(error));
126
+ process.exit(1);
127
+ });
128
+ if (!name) {
129
+ console.error(`Which agent? One of: ${agents.map((a) => a.name).join(", ")}`);
130
+ process.exit(2);
131
+ }
132
+ const agent = agents.find((a) => a.name === name);
133
+ if (!agent) {
134
+ console.error(
135
+ `There is no agent called ${JSON.stringify(name)}. There is: ${agents.map((a) => a.name).join(", ")}`,
136
+ );
137
+ process.exit(2);
138
+ }
139
+
140
+ // A thread is what gives the conversation a memory: core/turn.ts recalls it
141
+ // before asking and writes to it after. One per session, so a new terminal
142
+ // starts clean.
143
+ let thread = `terminal:${name}:${new Date().toISOString()}`;
144
+ let last: Result | undefined;
145
+
146
+ async function ask(prompt: string): Promise<void> {
147
+ last = await api<Result>(`/api/agents/${encodeURIComponent(name)}/chat`, { prompt, thread });
148
+ show(last);
149
+ }
150
+
151
+ // A job, if what was typed is the id of one. Anything else is
152
+ // a question, because a question is the common case and a job is spelled
153
+ // exactly.
154
+ // The first word is the job, when it is one, and the rest is what to start it
155
+ // with: `npm run agent chloe reading-companion "a highlight"`. A job that takes
156
+ // nothing ignores the rest, and anything that is not a job id is a question.
157
+ const [head = "", ...said] = rest;
158
+ const asked = rest.join(" ").trim();
159
+ const wanted = head.replace(/\.(ts|md)$/i, "").toLowerCase();
160
+ const job = agent.jobs.find(
161
+ (one) => one.id.toLowerCase() === wanted,
162
+ );
163
+
164
+ if (!job && /\.(ts|md)$/i.test(asked)) {
165
+ const has = agent.jobs.map((one) => one.id).join(", ");
166
+ console.error(
167
+ `${name} has no job called ${JSON.stringify(asked)}. It has: ${has || "none"}.\n` +
168
+ `Leave the name off to talk to it instead.`,
169
+ );
170
+ process.exit(2);
171
+ }
172
+
173
+ if (job) {
174
+ console.log(
175
+ `${bold(`${name}/${job.id}`)}${job.description ? ` (${job.description})` : ""}, ${job.cron ? `${job.cron} ${job.timezone}` : "when started"}, ` +
176
+ `${job.code ? "code" : `a prompt on ${job.model}`}.`,
177
+ );
178
+ console.log(dim("Running it now. This is the real thing: it sends, writes and spends.\n"));
179
+
180
+ const firedAt = Date.now();
181
+ // The same envelope a channel sends, so a job written for Telegram can be
182
+ // tried from here without being written for here as well.
183
+ const text = said.join(" ").trim();
184
+ await api(
185
+ `/api/agents/${encodeURIComponent(name)}/job/${encodeURIComponent(job.id)}`,
186
+ text ? { text, from: "terminal", user: "terminal" } : {},
187
+ ).catch((error: unknown) => {
188
+ console.error(error instanceof Error ? error.message : String(error));
189
+ process.exit(1);
190
+ });
191
+
192
+ // The row appears as the run starts and its steps are written as they
193
+ // finish, so following it is reading the same row again. Nothing here holds
194
+ // the request open: a backup takes minutes and an ask waits for a person.
195
+ const wait = (ms: number) => new Promise((done) => setTimeout(done, ms));
196
+ let runId: string | undefined;
197
+ for (let i = 0; i < 50 && !runId; i++) {
198
+ const runs = await api<Run[]>(`/api/runs?agent=${encodeURIComponent(name)}&limit=20`);
199
+ runId = runs.find(
200
+ (one) => one.job === job.id && Date.parse(one.started) >= firedAt - 2000,
201
+ )?.id;
202
+ if (!runId) await wait(200);
203
+ }
204
+ if (!runId) {
205
+ console.error(
206
+ "It was started but no run appeared. Something refused it before it began: " +
207
+ "systemctl --user status chloe.service",
208
+ );
209
+ process.exit(1);
210
+ }
211
+
212
+ let shown = 0;
213
+ for (;;) {
214
+ const run = await api<Run>(`/api/runs/${runId}`);
215
+ for (const step of (run.trace ?? []).slice(shown)) {
216
+ const price = step.cost > 0 ? ` $${step.cost.toFixed(4)}` : "";
217
+ const kind = step.kind === "step" ? "" : ` ${step.kind}`;
218
+ console.log(` ${step.name}${dim(` ${(step.ms / 1000).toFixed(1)}s${price}${kind}`)}`);
219
+ }
220
+ shown = (run.trace ?? []).length;
221
+
222
+ if (run.parked) {
223
+ const waiting = JSON.parse(run.parked) as { who: string; question: string };
224
+ console.log(`\n${bold("Waiting on")} ${waiting.who}: ${waiting.question}`);
225
+ console.log(dim(`Answer it on the page, or leave it: ${runId}\n`));
226
+ break;
227
+ }
228
+ if (run.finished) {
229
+ if (run.error) console.error(`\n${bold("Failed")}: ${run.error}\n`);
230
+ else console.log(`\n${(run.reply ?? "").trim()}\n`);
231
+ const seconds = (Date.parse(run.finished) - Date.parse(run.started)) / 1000;
232
+ console.log(
233
+ dim(`(${run.steps ?? 0} steps, ${seconds.toFixed(1)}s, $${(run.cost ?? 0).toFixed(4)}, run ${runId})\n`),
234
+ );
235
+ process.exit(run.error ? 1 : 0);
236
+ }
237
+ await wait(400);
238
+ }
239
+ process.exit(0);
240
+ }
241
+
242
+ // Piped in, or a question on the command line: one turn and out, so it can be
243
+ // used in a script.
244
+ const piped = rest.length === 0 && !process.stdin.isTTY;
245
+ if (rest.length > 0 || piped) {
246
+ const prompt = (rest.length > 0 ? asked : readFileSync(0, "utf8")).trim();
247
+ if (!prompt) {
248
+ console.error("The question is empty.");
249
+ process.exit(2);
250
+ }
251
+ await ask(prompt).catch((error: unknown) => {
252
+ console.error(error instanceof Error ? error.message : String(error));
253
+ process.exit(1);
254
+ });
255
+ process.exit(0);
256
+ }
257
+
258
+ console.log(
259
+ `${bold(agent.name)} on ${agent.model}, ${agent.tools.length} tools, ${agent.skills.length} skills, ` +
260
+ `${agent.jobs.length} jobs.`,
261
+ );
262
+ console.log(dim("/exit to leave, /new to forget this conversation, /tools for the last turn's calls.\n"));
263
+
264
+ const lines = createInterface({ input: process.stdin, output: process.stdout });
265
+ for (;;) {
266
+ const line = (await lines.question("> ")).trim();
267
+ if (!line) continue;
268
+
269
+ if (line === "/exit" || line === "/quit") break;
270
+ if (line === "/new") {
271
+ await api(`/api/threads/${encodeURIComponent(thread)}/forget`, {});
272
+ thread = `terminal:${name}:${new Date().toISOString()}`;
273
+ console.log(dim("Forgotten. Starting fresh.\n"));
274
+ continue;
275
+ }
276
+ if (line === "/tools") {
277
+ if (!last || last.calls.length === 0) console.log(dim("No tool calls in the last turn.\n"));
278
+ else console.log(`${JSON.stringify(last.calls, null, 2)}\n`);
279
+ continue;
280
+ }
281
+
282
+ // A failed turn does not end the session: the gateway running out of credit
283
+ // is the common one, and it is fixable without losing the conversation.
284
+ try {
285
+ await ask(line);
286
+ } catch (error) {
287
+ console.error(`\n${error instanceof Error ? error.message : String(error)}\n`);
288
+ }
289
+ }
290
+ lines.close();
package/ops/check.ts ADDED
@@ -0,0 +1,37 @@
1
+ // Saying whether one thing came out right, and counting what did not.
2
+ //
3
+ // This is what `@chloejs/core/test` means. It is its own file rather than part of
4
+ // test.ts because test.ts runs its cases as it loads, and a test file that
5
+ // imported it to get these two would run the whole suite again.
6
+ //
7
+ // A job is code, so it is tested rather than scored. A test file sits beside
8
+ // the job it is about, is named `<job>.test.ts`, and runs its cases as it
9
+ // loads:
10
+ //
11
+ // import { about, is } from "@chloejs/core/test";
12
+ //
13
+ // about("what the nightly backup calls wrong");
14
+ // is("a night like the last one says nothing", whatLooksWrong(tonight, good, 10), []);
15
+ //
16
+ // The runner finds it, so there is nothing to add anywhere else.
17
+
18
+ let failures = 0;
19
+
20
+ /** The heading a group of cases runs under. */
21
+ export function about(what: string): void {
22
+ console.log(`\n${what}`);
23
+ }
24
+
25
+ /** Compared as JSON, so two objects of the same shape are the same answer. */
26
+ export function is(what: string, got: unknown, want: unknown): void {
27
+ const a = JSON.stringify(got);
28
+ const b = JSON.stringify(want);
29
+ if (a === b) return void console.log(` ok ${what}`);
30
+ failures++;
31
+ console.log(` FAIL ${what}\n got ${a}\n want ${b}`);
32
+ }
33
+
34
+ /** How many cases failed, across every file the runner loaded. */
35
+ export function failed(): number {
36
+ return failures;
37
+ }