@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/core/markdown.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Reading the markdown that agents are written in and answer in.
|
|
2
|
+
//
|
|
3
|
+
// A job or a skill is a markdown file that can open with a block of
|
|
4
|
+
// settings between two `---` lines (`cron:`, `name:`), known elsewhere as
|
|
5
|
+
// frontmatter. settingsAndBody() splits one into those settings and the words
|
|
6
|
+
// under them. prompt() points at such a file from code, and readPrompt()
|
|
7
|
+
// reads its words. oneLineSummary() does the other job: it turns a reply
|
|
8
|
+
// written in markdown into one plain line for the overview.
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
|
|
12
|
+
export interface MarkdownFile {
|
|
13
|
+
/** What the block at the top says, `cron: "0 23 * * *"` read as { cron: "0 23 * * *" }. */
|
|
14
|
+
settings: Record<string, string>;
|
|
15
|
+
/** Everything under that block: the words themselves. */
|
|
16
|
+
body: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Not YAML on purpose: keys, one-line values, and `#` comments, nothing else.
|
|
20
|
+
export function settingsAndBody(text: string): MarkdownFile {
|
|
21
|
+
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
22
|
+
if (!match) return { settings: {}, body: text.trim() };
|
|
23
|
+
|
|
24
|
+
const settings: Record<string, string> = {};
|
|
25
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
26
|
+
const trimmed = line.trim();
|
|
27
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
28
|
+
const at = trimmed.indexOf(":");
|
|
29
|
+
if (at === -1) continue;
|
|
30
|
+
settings[trimmed.slice(0, at).trim()] = unquote(trimmed.slice(at + 1).trim());
|
|
31
|
+
}
|
|
32
|
+
return { settings, body: match[2].trim() };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function unquote(value: string): string {
|
|
36
|
+
const quoted = value.match(/^"(.*)"$/) ?? value.match(/^'(.*)'$/);
|
|
37
|
+
return quoted ? quoted[1] : value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Words kept in a markdown file, pointed at from code: an agent's instructions
|
|
41
|
+
// or a job's prompt. The path is inside the agent's folder, "instructions.md"
|
|
42
|
+
// or "jobs/morning-run.md", whichever file names it.
|
|
43
|
+
|
|
44
|
+
/** Words in a file, read when they are needed rather than as the agent loads. */
|
|
45
|
+
export interface Prompt {
|
|
46
|
+
file: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Declares words in a markdown file inside the agent's folder: its
|
|
51
|
+
* instructions, or a job's prompt.
|
|
52
|
+
*/
|
|
53
|
+
export function prompt(file: string): Prompt {
|
|
54
|
+
return { file };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Whether a value is a declared prompt rather than words written inline. */
|
|
58
|
+
export function isPrompt(value: unknown): value is Prompt {
|
|
59
|
+
return typeof value === "object" && value !== null && typeof (value as Prompt).file === "string";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The words themselves, from a plain string or from the file a marker names.
|
|
64
|
+
* `where` is what to call the declaring file when something is wrong.
|
|
65
|
+
*/
|
|
66
|
+
export async function readPrompt(
|
|
67
|
+
from: string | Prompt | undefined,
|
|
68
|
+
options: { dir: string; where: string },
|
|
69
|
+
): Promise<string> {
|
|
70
|
+
if (typeof from === "string") return from.trim();
|
|
71
|
+
if (!isPrompt(from)) throw new Error(`${options.where} has no words. Give it a string or prompt("./name.md").`);
|
|
72
|
+
|
|
73
|
+
const text = await readFile(resolve(options.dir, from.file), "utf8").catch(() => {
|
|
74
|
+
throw new Error(`${options.where} points at ${JSON.stringify(from.file)}, which is not there.`);
|
|
75
|
+
});
|
|
76
|
+
// Only the body, so a file that kept a settings block at the top does not
|
|
77
|
+
// read it out to a model as if it were instructions.
|
|
78
|
+
const words = settingsAndBody(text).body;
|
|
79
|
+
if (!words.trim()) throw new Error(`${options.where} points at ${JSON.stringify(from.file)}, which is empty.`);
|
|
80
|
+
return words;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The start of a reply as one plain line: list marks, emphasis and headings
|
|
85
|
+
* gone, lines joined, cut at `max`. It is how the reply begins, not what it
|
|
86
|
+
* means: nothing here reads it.
|
|
87
|
+
*/
|
|
88
|
+
export function oneLineSummary(text: string, max = 200): string {
|
|
89
|
+
const line = text
|
|
90
|
+
.split(/\r?\n/)
|
|
91
|
+
.map((one) => one.replace(/[*`#>]|__/g, "").replace(/^\s*(-|\d+\.)\s+/, "").trim())
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.join(" ");
|
|
94
|
+
return line.length > max ? `${line.slice(0, max - 1).trimEnd()}…` : line;
|
|
95
|
+
}
|
package/core/notes.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// A small JSON file one agent writes and reads back next time.
|
|
2
|
+
//
|
|
3
|
+
// The shape is a zod schema ending in `.catch(...)`, so a missing file and an
|
|
4
|
+
// unreadable one both come back as the default rather than stopping the run.
|
|
5
|
+
// That forgiveness is why the write has to be atomic: two jobs can want the
|
|
6
|
+
// same note at the same moment, one writing and one reading, and a reader that
|
|
7
|
+
// caught half a file would not fail. It would quietly get the default and
|
|
8
|
+
// report on an empty world.
|
|
9
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import type { z } from "zod";
|
|
13
|
+
|
|
14
|
+
import { STATE } from "./paths.ts";
|
|
15
|
+
|
|
16
|
+
/** One JSON file an agent keeps, read and written against a schema. */
|
|
17
|
+
export interface Note<T> {
|
|
18
|
+
path: string;
|
|
19
|
+
read(): Promise<T>;
|
|
20
|
+
write(value: T): Promise<T>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A note by name, in that agent's own state folder. A shape with a `catch`
|
|
25
|
+
* makes a note that is not there read as its default.
|
|
26
|
+
*/
|
|
27
|
+
export function note<T>(agent: string, name: string, shape: z.ZodType<T>): Note<T> {
|
|
28
|
+
const path = join(STATE, agent, `${name}.json`);
|
|
29
|
+
return {
|
|
30
|
+
path,
|
|
31
|
+
async read() {
|
|
32
|
+
return shape.parse(await readFile(path, "utf8").then(JSON.parse).catch(() => undefined));
|
|
33
|
+
},
|
|
34
|
+
async write(value) {
|
|
35
|
+
await mkdir(dirname(path), { recursive: true });
|
|
36
|
+
// Beside the note, so the rename stays on one filesystem and is atomic:
|
|
37
|
+
// a reader sees the whole of the old file or the whole of the new one.
|
|
38
|
+
const part = `${path}.${process.pid}.part`;
|
|
39
|
+
await writeFile(part, JSON.stringify(value, null, 2));
|
|
40
|
+
await rename(part, path);
|
|
41
|
+
return value;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
package/core/paths.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The paths the runtime itself needs. Nothing here names a person, a home
|
|
2
|
+
// directory or a machine: the repo finds itself, and everything else is either
|
|
3
|
+
// relative to that or said in settings.local.json, which is not in source
|
|
4
|
+
// control. Paths that belong to one agent live in that agent's folder.
|
|
5
|
+
import { ROOT } from "./root.ts";
|
|
6
|
+
import { setting, settings } from "./settings.ts";
|
|
7
|
+
|
|
8
|
+
export { ROOT };
|
|
9
|
+
|
|
10
|
+
// Filled by the loader from what each agent declared, before any tool is bound.
|
|
11
|
+
let folders = new Map<string, string>();
|
|
12
|
+
|
|
13
|
+
export function setAgentDirs(declared: Map<string, string>): void {
|
|
14
|
+
folders = declared;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** One agent's own folder: its skills, scripts, evals and prompts. */
|
|
18
|
+
export function agentDir(name: string): string {
|
|
19
|
+
const folder = folders.get(name);
|
|
20
|
+
if (!folder) throw new Error(`No agent called ${JSON.stringify(name)} has been loaded.`);
|
|
21
|
+
return folder;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Everything the agents keep: their own folders (journals, metrics, notes) and
|
|
26
|
+
* the run history. Unset, it is `data/` inside the repo, which git ignores, so
|
|
27
|
+
* a second clone keeps its own state. `git clean -x` would delete it.
|
|
28
|
+
*/
|
|
29
|
+
export const STATE = setting(settings.state, "AGENTS_STATE") || `${ROOT}/data`;
|
package/core/root.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Where the repo is. Its own file so that paths.ts can read settings: settings
|
|
2
|
+
// needs ROOT to find the two settings files, and would otherwise import the
|
|
3
|
+
// file that imports it.
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { dirname, resolve } from "node:path";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The repo root: the folder that holds chloe.config.ts, walking up from the
|
|
9
|
+
* running process. data/, .env and the settings files are found beside it.
|
|
10
|
+
*/
|
|
11
|
+
function findRoot(): string {
|
|
12
|
+
let dir = process.cwd();
|
|
13
|
+
for (let i = 0; i < 10; i++) {
|
|
14
|
+
if (existsSync(resolve(dir, "chloe.config.ts"))) return dir;
|
|
15
|
+
const up = dirname(dir);
|
|
16
|
+
if (up === dir) break;
|
|
17
|
+
dir = up;
|
|
18
|
+
}
|
|
19
|
+
throw new Error(`No chloe.config.ts at or above ${process.cwd()}. Start the agents from inside the repo.`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The folder that holds `chloe.config.ts`, found by walking up from where the
|
|
24
|
+
* process was started.
|
|
25
|
+
*/
|
|
26
|
+
export const ROOT = findRoot();
|
package/core/settings.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Every setting, and what it is when nobody says.
|
|
2
|
+
//
|
|
3
|
+
// Three files, read in this order, each one winning over the one before:
|
|
4
|
+
//
|
|
5
|
+
// the schema below the default, and the documentation
|
|
6
|
+
// settings.json in source control: true for everyone who clones this
|
|
7
|
+
// settings.local.json not in source control: true for this box only
|
|
8
|
+
//
|
|
9
|
+
// An environment variable beats all three. That is for a one-off run
|
|
10
|
+
// (MODEL_VIA=gateway npm run evals) and for the tests, not for keeping
|
|
11
|
+
// settings in.
|
|
12
|
+
//
|
|
13
|
+
// A value that names a home directory, a machine, a person, or is a
|
|
14
|
+
// credential (a bot token, a chat id) belongs in settings.local.json, so
|
|
15
|
+
// setting chloe up is filling in that one file. Nothing in source control may
|
|
16
|
+
// hold any of them.
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
|
|
20
|
+
import { ROOT } from "./root.ts";
|
|
21
|
+
|
|
22
|
+
const schema = z.object({
|
|
23
|
+
model: z
|
|
24
|
+
.object({
|
|
25
|
+
/** "gateway" for HTTP, "claude" for the CLI. Empty picks by what the machine has. */
|
|
26
|
+
via: z.enum(["gateway", "claude", ""]).default(""),
|
|
27
|
+
/** Any gateway that speaks the OpenAI chat-completions shape. */
|
|
28
|
+
gateway: z.string().default("https://ai-gateway.vercel.sh/v1/chat/completions"),
|
|
29
|
+
/** The gateway's key. Empty means no gateway, so via "" picks the CLI. */
|
|
30
|
+
key: z.string().default(""),
|
|
31
|
+
/** Who marks an eval. Cheaper than the agent being marked, on purpose. */
|
|
32
|
+
judge: z.string().default("anthropic/claude-sonnet-5"),
|
|
33
|
+
})
|
|
34
|
+
.prefault({}),
|
|
35
|
+
app: z
|
|
36
|
+
.object({
|
|
37
|
+
/** Where an agent's mail goes. Comma separated for more than one. */
|
|
38
|
+
send_email_to: z.string().default(""),
|
|
39
|
+
})
|
|
40
|
+
.prefault({}),
|
|
41
|
+
/** Sending mail. */
|
|
42
|
+
resend: z
|
|
43
|
+
.object({
|
|
44
|
+
/** The key an agent's mail is sent with. Without one, nothing is sent. */
|
|
45
|
+
api_key: z.string().default(""),
|
|
46
|
+
})
|
|
47
|
+
.prefault({}),
|
|
48
|
+
google: z
|
|
49
|
+
.object({
|
|
50
|
+
/** The account a mail tool reads from. */
|
|
51
|
+
account: z.string().default(""),
|
|
52
|
+
/** Opens the saved login, from when a person signed in to that account. */
|
|
53
|
+
password: z.string().default(""),
|
|
54
|
+
/** The Analytics service account's key, handed to scripts as GA_KEY_FILE. */
|
|
55
|
+
GA_KEY_FILE: z.string().default(""),
|
|
56
|
+
})
|
|
57
|
+
.prefault({}),
|
|
58
|
+
/** Mail sent when somebody signs in from an address this copy has not seen. */
|
|
59
|
+
alerts: z
|
|
60
|
+
.object({
|
|
61
|
+
/** Where it goes. Empty means nothing is sent, and the sign-in is still recorded. */
|
|
62
|
+
email_to: z.string().default(""),
|
|
63
|
+
/** The From line, e.g. "Chloe <info@example.com>". */
|
|
64
|
+
email_from: z.string().default(""),
|
|
65
|
+
})
|
|
66
|
+
.prefault({}),
|
|
67
|
+
/** Everything the agents keep: their folders and the run history. Empty means data/ inside the repo. */
|
|
68
|
+
state: z.string().default(""),
|
|
69
|
+
/** Which node the unit runs. Empty means whichever is on the path at install. */
|
|
70
|
+
node: z.string().default(""),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** Every setting there is, as the schema defines it. */
|
|
74
|
+
export type Settings = z.infer<typeof schema>;
|
|
75
|
+
|
|
76
|
+
function read(name: string): unknown {
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(readFileSync(`${ROOT}/${name}`, "utf8"));
|
|
79
|
+
} catch (error) {
|
|
80
|
+
// Missing is normal: the whole file is optional. Malformed is not, because
|
|
81
|
+
// silently falling back to the defaults is how a box runs for a week on
|
|
82
|
+
// settings nobody chose.
|
|
83
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
84
|
+
throw new Error(`${name} could not be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One level down, so a file can set model.via without restating model.gateway. */
|
|
89
|
+
function merge(base: Record<string, unknown>, over: Record<string, unknown>): Record<string, unknown> {
|
|
90
|
+
const out = { ...base };
|
|
91
|
+
for (const [key, value] of Object.entries(over)) {
|
|
92
|
+
const mine = out[key];
|
|
93
|
+
out[key] =
|
|
94
|
+
value && typeof value === "object" && !Array.isArray(value) && mine && typeof mine === "object" && !Array.isArray(mine)
|
|
95
|
+
? { ...(mine as object), ...(value as object) }
|
|
96
|
+
: value;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The two files merged and checked. Separate from reading them so it can be
|
|
103
|
+
* tested without a disk, and so the order that wins is one readable line.
|
|
104
|
+
*/
|
|
105
|
+
export function readSettings(tracked: unknown, local: unknown): Settings {
|
|
106
|
+
const found = schema.safeParse(merge(tracked as Record<string, unknown>, local as Record<string, unknown>));
|
|
107
|
+
if (!found.success) throw new Error(`settings are not valid:\n${z.prettifyError(found.error)}`);
|
|
108
|
+
return found.data;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The settings this process started with: the schema's defaults, then
|
|
113
|
+
* `settings.json`, then `settings.local.json`. One value can still be beaten
|
|
114
|
+
* by an environment variable, through `setting()`.
|
|
115
|
+
*/
|
|
116
|
+
export const settings: Settings = readSettings(read("settings.json"), read("settings.local.json"));
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A setting, with an environment variable winning if there is one. Reading it
|
|
120
|
+
* here rather than at import time is what lets a test set one.
|
|
121
|
+
*/
|
|
122
|
+
export function setting(value: string, fromEnv: string): string {
|
|
123
|
+
return process.env[fromEnv] || value;
|
|
124
|
+
}
|