@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/ops/evals.ts ADDED
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ // Running an agent's evals. `npm run evals`, or `npm run evals cc`.
3
+ //
4
+ // An eval file says what a good run looks like: the situation the agent woke
5
+ // up to, what every tool should answer, and what the agent should and should
6
+ // not have done about it. This runs the real agent on the real prompt, answers
7
+ // every tool from the file, and marks what came out.
8
+ //
9
+ // One thing makes it safe to run against a live agent: a tool the case does
10
+ // not answer is refused rather than run, so a case cannot restart a service or
11
+ // send an email however the run goes wrong.
12
+ //
13
+ // It runs the agent in this process rather than asking the server. The old
14
+ // version went over HTTP to whatever was serving, which meant an eval could
15
+ // not be run without the service up, and a case could not be stepped through.
16
+ import { readFile, readdir } from "node:fs/promises";
17
+ import { basename, join } from "node:path";
18
+
19
+ import { loadAll, type Agent, turn, type TurnResult, setting, settings } from "@chloejs/core";
20
+ import { settingsAndBody } from "#chloe/core/markdown.ts";
21
+ import { calls, type ExpectedCalls as Facts, expectations, type ExpectedOutcome as Judged } from "@chloejs/core/scorers";
22
+
23
+ /** One morning, one nightly run, one anything: a case in an eval file. */
24
+ interface Case extends Facts, Judged {
25
+ name: string;
26
+ prompt?: string;
27
+ answers?: {
28
+ tool: string;
29
+ args?: Record<string, unknown>;
30
+ /** Answer this tool however it was called, for one whose arguments do not change the answer. */
31
+ anyArgs?: boolean;
32
+ /** How many calls this answer covers. Default one, and the next call gets the next answer. */
33
+ times?: number;
34
+ returns: unknown;
35
+ }[];
36
+ }
37
+
38
+ interface EvalFile {
39
+ about?: string;
40
+ /** The job whose prompt these cases run, so the two cannot drift apart. */
41
+ job?: string;
42
+ /** Tools a run may reach for that this file does not care about. Answered with nothing. */
43
+ quiet?: string[];
44
+ cases: Case[];
45
+ }
46
+
47
+ /** What a tool the case does not answer says back, plainly rather than as an empty result. */
48
+ const NOTHING = "Nothing here. This is an eval, and the case does not answer this tool.";
49
+
50
+ /** What the marks have to be for a case to pass. */
51
+ const PASS = { calls: 1, expectations: 0.8 } as const;
52
+
53
+ /** Who marks the writing. Cheaper than the agent being marked, on purpose. */
54
+ const JUDGE = setting(settings.model.judge, "JUDGE_MODEL");
55
+
56
+ /**
57
+ * Answer one tool call from the case.
58
+ *
59
+ * Answers are used in the order written, per tool, so a case that checks a
60
+ * site, restarts it and checks again writes the two answers in that order.
61
+ * An answer with `anyArgs` matches however the tool was called; otherwise the
62
+ * arguments have to match, which is what catches an agent asking about one
63
+ * unit by name when the case answered about all of them.
64
+ */
65
+ function answerFrom(one: Case, quiet: string[], skills: Map<string, string>) {
66
+ const left = (one.answers ?? []).flatMap((a) =>
67
+ Array.from({ length: a.times ?? 1 }, () => ({ ...a })),
68
+ );
69
+
70
+ return (name: string, args: unknown): unknown => {
71
+ // A skill is answered from the real file rather than from the case. It
72
+ // reads something in this repo and changes nothing, and answering it for
73
+ // real is what makes rewriting a skill move the score.
74
+ if (name === "skill") {
75
+ const asked = (args as { name?: string })?.name ?? "";
76
+ const body = skills.get(asked);
77
+ if (body) return body;
78
+ throw new Error(`No skill called ${JSON.stringify(asked)}. You have: ${[...skills.keys()].join(", ")}`);
79
+ }
80
+
81
+ const at = left.findIndex(
82
+ (a) => a.tool === name && (a.anyArgs || JSON.stringify(a.args ?? {}) === JSON.stringify(args)),
83
+ );
84
+ if (at !== -1) return left.splice(at, 1)[0].returns;
85
+
86
+ // An agent may keep notes or reach for its folder on any run. None of that
87
+ // is what the case is about, so it is answered with nothing rather than
88
+ // stopping the case.
89
+ if (quiet.includes(name)) return NOTHING;
90
+
91
+ // Anything else is the point of the whole design: it does not run.
92
+ throw new Error(
93
+ `This is an eval and the case does not answer ${name}(${JSON.stringify(args)}). ` +
94
+ `Nothing runs for real here. Add an answer for it to the eval file, or list it under "quiet".`,
95
+ );
96
+ };
97
+ }
98
+
99
+ /** Every skill this agent has, by both the names a model might use for it. */
100
+ async function skillsOf(agent: Agent): Promise<Map<string, string>> {
101
+ const dir = join(agent.folder, "skills");
102
+ const skills = new Map<string, string>();
103
+ for (const file of (await readdir(dir).catch(() => [])).filter((f) => f.endsWith(".md"))) {
104
+ const skill = settingsAndBody(await readFile(join(dir, file), "utf8"));
105
+ const named = skill.settings.name;
106
+ for (const key of new Set([named, basename(file, ".md")].filter(Boolean) as string[])) {
107
+ skills.set(key, skill.body);
108
+ }
109
+ }
110
+ return skills;
111
+ }
112
+
113
+ function found(agent: Agent, job: string): string {
114
+ const one = agent.jobs.find((s) => s.id === job);
115
+ // A job made of code is checked by running it, not by scoring what it said.
116
+ if (one?.run) {
117
+ throw new Error(
118
+ `${agent.name}/${job} is code, not a prompt. Evals score what a model decided, and this one decides in code.`,
119
+ );
120
+ }
121
+ if (!one) {
122
+ throw new Error(
123
+ `${agent.name} has no job called ${JSON.stringify(job)}. It has: ${agent.jobs.map((s) => s.id).join(", ")}`,
124
+ );
125
+ }
126
+ return one.prompt;
127
+ }
128
+
129
+ async function runFile(loaded: Agent, file: string): Promise<{ passed: number; failed: number }> {
130
+ const agent = loaded.name;
131
+ const spec = JSON.parse(await readFile(file, "utf8")) as EvalFile;
132
+ const skills = await skillsOf(loaded);
133
+ // Through the loader, so a case runs the prompt the agent wakes up to
134
+ // whether that job is markdown or TypeScript.
135
+ const base = spec.job ? found(loaded, spec.job) : "";
136
+
137
+ console.log(`\n${agent}/${basename(file, ".json")} ${spec.cases.length} cases`);
138
+ if (spec.about) console.log(` ${spec.about}\n`);
139
+
140
+ let passed = 0;
141
+ let failed = 0;
142
+
143
+ for (const one of spec.cases) {
144
+ const prompt = one.prompt ?? base;
145
+ if (!prompt) throw new Error(`${file}: case ${one.name} has no prompt and the file names no job.`);
146
+
147
+ let result: TurnResult;
148
+ try {
149
+ result = await turn({
150
+ agent: loaded,
151
+ prompt,
152
+ source: "eval",
153
+ instead: answerFrom(one, spec.quiet ?? [], skills),
154
+ });
155
+ } catch (error) {
156
+ console.log(` ✗ ${one.name}: the run itself failed: ${error instanceof Error ? error.message : error}`);
157
+ failed++;
158
+ continue;
159
+ }
160
+
161
+ const fact = calls(result, one);
162
+ const judged = await expectations(prompt, result, one, JUDGE);
163
+ const ok = fact.score >= PASS.calls && judged.score >= PASS.expectations;
164
+ ok ? passed++ : failed++;
165
+
166
+ console.log(` ${ok ? "✓" : "✗"} ${one.name} calls ${fact.score.toFixed(2)} expectations ${judged.score.toFixed(2)} $${result.cost.toFixed(4)}`);
167
+ if (!ok) {
168
+ if (fact.score < PASS.calls) console.log(` calls: ${fact.reason}`);
169
+ if (judged.score < PASS.expectations) console.log(` expectations: ${judged.reason}`);
170
+ console.log(` the whole run: /api/runs/${result.runId}`);
171
+ }
172
+ }
173
+ return { passed, failed };
174
+ }
175
+
176
+ const wanted = process.argv[2];
177
+ const agents = [...(await loadAll()).values()].sort((a, b) => a.name.localeCompare(b.name));
178
+
179
+ let passed = 0;
180
+ let failed = 0;
181
+ let ran = 0;
182
+
183
+ for (const agent of agents) {
184
+ const evals = join(agent.folder, "evals");
185
+ if (wanted && wanted !== agent.name) {
186
+ // `npm run evals morning-check` runs one file whatever agent it belongs to.
187
+ const owns = (await readdir(evals).catch(() => [])).some((f) => f === `${wanted}.json`);
188
+ if (!owns) continue;
189
+ }
190
+ for (const file of (await readdir(evals).catch(() => []))
191
+ .filter((f) => f.endsWith(".json"))
192
+ .sort()) {
193
+ if (wanted && wanted !== agent.name && basename(file, ".json") !== wanted) continue;
194
+ const marks = await runFile(agent, join(evals, file));
195
+ passed += marks.passed;
196
+ failed += marks.failed;
197
+ ran++;
198
+ }
199
+ }
200
+
201
+ if (ran === 0) {
202
+ console.error(wanted ? `Nothing to run for ${JSON.stringify(wanted)}.` : "No eval files anywhere.");
203
+ process.exit(2);
204
+ }
205
+ console.log(`\n${passed} passed, ${failed} failed.`);
206
+ process.exit(failed === 0 ? 0 : 1);
package/ops/install.sh ADDED
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env bash
2
+ # Install the service that runs the agents, and start it. Run it from the top
3
+ # of your own project, the folder with chloe.config.ts in it:
4
+ #
5
+ # sh node_modules/@chloejs/core/ops/install.sh
6
+ #
7
+ # The unit is called chloe.service. One box runs one of these, so this replaces
8
+ # an existing one and points it at the folder it was run from.
9
+ #
10
+ # Why a service at all: because a process in a terminal dies with the terminal.
11
+ # The service survives a reboot and restarts if it crashes. Without it the agents only run while someone is watching,
12
+ # which is the opposite of the point.
13
+ set -euo pipefail
14
+
15
+ ROOT=$(pwd)
16
+ [ -f "$ROOT/chloe.config.ts" ] || {
17
+ echo "No chloe.config.ts in $ROOT. Run this from the top of your project." >&2
18
+ exit 1
19
+ }
20
+ [ -d "$ROOT/node_modules" ] || {
21
+ echo "No node_modules. Run npm install in $ROOT first." >&2
22
+ exit 1
23
+ }
24
+ # Node runs the TypeScript directly, which needs a version that strips types
25
+ # without being asked. Checked before the settings are read, because reading
26
+ # them is itself a TypeScript import.
27
+ node -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)' || {
28
+ echo "Node 22 or newer is needed: this runs .ts files directly." >&2
29
+ exit 1
30
+ }
31
+
32
+ # The settings are JSON, so they are read by node rather than sourced. Asking
33
+ # the same module the runtime asks means this script cannot disagree with it
34
+ # about a default. An empty setting comes back as "-" so that read gets two
35
+ # fields either way.
36
+ SETTINGS=$(node --input-type=module -e '
37
+ const { settings } = await import(process.argv[1] + "/node_modules/@chloejs/core/core/settings.ts");
38
+ console.log(settings.node || "-", settings.model.via || "-");
39
+ ' "$ROOT") || {
40
+ echo "settings.json could not be read. The error is above." >&2
41
+ exit 1
42
+ }
43
+ read -r NODEBIN MODELVIA <<<"$SETTINGS"
44
+ [ "$NODEBIN" != "-" ] || NODEBIN=$(dirname "$(command -v node)")
45
+ [ "$MODELVIA" != "-" ] || MODELVIA=""
46
+
47
+ # model.via "claude" runs model calls through the Claude Code CLI, so the unit
48
+ # needs it on the path. Found the same way as node, because it usually sits in
49
+ # ~/.local/bin and systemd starts with almost no path at all.
50
+ CLAUDEBIN=""
51
+ if command -v claude >/dev/null 2>&1; then CLAUDEBIN=":$(dirname "$(command -v claude)")"; fi
52
+
53
+ [ "$MODELVIA" != "claude" ] || [ -n "$CLAUDEBIN" ] || {
54
+ echo "model.via is \"claude\", but the claude command is not on the path. Install" >&2
55
+ echo "Claude Code, or set model.via to \"gateway\" and put credit on the key." >&2
56
+ exit 1
57
+ }
58
+
59
+ # Credentials live in settings.local.json, so nobody else on the box reads it.
60
+ [ ! -e "$ROOT/settings.local.json" ] || chmod 600 "$ROOT/settings.local.json"
61
+
62
+ mkdir -p ~/.config/systemd/user
63
+
64
+ # Written here rather than kept as a separate file, because systemd expands
65
+ # variables in ExecStart= only, never in WorkingDirectory= or EnvironmentFile=.
66
+ # A template plus an installer is two files that can disagree; this is one.
67
+ cat > ~/.config/systemd/user/chloe.service <<EOF
68
+ [Unit]
69
+ Description=Chloe, which runs the agents
70
+ After=network-online.target
71
+
72
+ [Service]
73
+ Type=simple
74
+ # The process watches each agent folder, so an edit there is live without a
75
+ # restart, including a new agent folder. A change to the runtime or this unit
76
+ # needs one.
77
+ Environment=PATH=$NODEBIN$CLAUDEBIN:/usr/local/bin:/usr/bin:/bin
78
+ WorkingDirectory=$ROOT
79
+ # server.ts is what is run. The package's index.ts is only its exports and
80
+ # starts nothing. The port and the loopback bind are in serve/http.ts.
81
+ ExecStart=$NODEBIN/node $ROOT/node_modules/@chloejs/core/server.ts
82
+ Restart=on-failure
83
+ RestartSec=15
84
+ Nice=5
85
+ StandardOutput=journal
86
+ StandardError=journal
87
+ SyslogIdentifier=chloe
88
+
89
+ [Install]
90
+ WantedBy=default.target
91
+ EOF
92
+
93
+ systemctl --user daemon-reload
94
+ systemctl --user enable chloe.service
95
+ systemctl --user restart chloe.service
96
+
97
+ echo "Installed chloe.service, node at $NODEBIN."
98
+ echo "Model calls go via ${MODELVIA:-whichever this box can}."
99
+ echo "The site and the API are on http://127.0.0.1:3067, loopback only."
100
+ echo "Make the one account with: npm run account"
101
+ echo "From another machine: ssh -L 3067:127.0.0.1:3067 you@thisbox"