@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.
- package/LICENSE +21 -0
- package/README.md +221 -0
- package/channels/api.ts +41 -0
- package/channels/shared.ts +250 -0
- package/channels/slack.ts +390 -0
- package/channels/telegram.ts +396 -0
- package/core/clock.ts +126 -0
- package/core/confine.ts +45 -0
- package/core/db.ts +117 -0
- package/core/markdown.ts +95 -0
- package/core/notes.ts +44 -0
- package/core/paths.ts +29 -0
- package/core/root.ts +26 -0
- package/core/settings.ts +124 -0
- package/core/steps.ts +896 -0
- package/core/turn.ts +314 -0
- package/do/email.ts +45 -0
- package/do/files.ts +96 -0
- package/do/mail.ts +155 -0
- package/do/run.ts +56 -0
- package/do/scripts.ts +49 -0
- package/do/web.ts +192 -0
- package/index.ts +52 -0
- package/load/job.ts +84 -0
- package/load/load.ts +478 -0
- package/model/ask.ts +84 -0
- package/model/claude.ts +261 -0
- package/model/memory.ts +68 -0
- package/model/model.ts +185 -0
- package/model/tool.ts +53 -0
- package/model/tools/files.ts +71 -0
- package/model/tools/gmail.ts +43 -0
- package/model/tools/index.ts +28 -0
- package/model/tools/memory.ts +23 -0
- package/model/tools/run_script.ts +44 -0
- package/model/tools/send_email.ts +29 -0
- package/model/tools/web.ts +23 -0
- package/model/tools/write_skill.ts +31 -0
- package/ops/account.ts +109 -0
- package/ops/agent.ts +290 -0
- package/ops/check.ts +37 -0
- package/ops/evals.ts +206 -0
- package/ops/install.sh +101 -0
- package/ops/test.ts +1976 -0
- package/package.json +65 -0
- package/scorers/calls.ts +50 -0
- package/scorers/expectations.ts +118 -0
- package/scorers/index.ts +5 -0
- package/serve/alerts.ts +79 -0
- package/serve/errors.ts +10 -0
- package/serve/files.ts +70 -0
- package/serve/http.ts +767 -0
- package/serve/login.ts +299 -0
- package/serve/memory.ts +372 -0
- package/serve/page.ts +142 -0
- package/serve/pass.ts +45 -0
- package/serve/recentWork.ts +69 -0
- package/serve/site.ts +409 -0
- package/serve/tokens.ts +132 -0
- package/server.ts +170 -0
- package/timer/cron.ts +92 -0
- package/timer/every.ts +153 -0
- 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"
|