@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/serve/page.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Finding a better page than the built-in one, if the repo installed a package
|
|
2
|
+
// that offers it.
|
|
3
|
+
//
|
|
4
|
+
// The runtime does not know @chloejs/ui exists. It knows a convention: any
|
|
5
|
+
// installed package whose package.json has a "chloePage" naming a folder with
|
|
6
|
+
// an index.html in it is offering a page, and the first one found is served
|
|
7
|
+
// instead of site.ts's. That is what makes `npm install @chloejs/ui` upgrade the
|
|
8
|
+
// site with nothing configured, and what lets somebody else's dashboard take
|
|
9
|
+
// its place the same way.
|
|
10
|
+
//
|
|
11
|
+
// Looked for once, at startup. A package installed while the process is running
|
|
12
|
+
// is not picked up until it restarts, which is the same as every other
|
|
13
|
+
// dependency.
|
|
14
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
15
|
+
import { extname, join, resolve, sep } from "node:path";
|
|
16
|
+
import { readFile } from "node:fs/promises";
|
|
17
|
+
import type { ServerResponse } from "node:http";
|
|
18
|
+
|
|
19
|
+
import { ROOT } from "#chloe/core/paths.ts";
|
|
20
|
+
|
|
21
|
+
export interface Page {
|
|
22
|
+
/** The package that offered it, for saying so at startup. */
|
|
23
|
+
name: string;
|
|
24
|
+
/** The folder its index.html is in. */
|
|
25
|
+
dir: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const TYPES: Record<string, string> = {
|
|
29
|
+
".html": "text/html; charset=utf-8",
|
|
30
|
+
".js": "text/javascript; charset=utf-8",
|
|
31
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
32
|
+
".css": "text/css; charset=utf-8",
|
|
33
|
+
".json": "application/json; charset=utf-8",
|
|
34
|
+
".png": "image/png",
|
|
35
|
+
".jpg": "image/jpeg",
|
|
36
|
+
".svg": "image/svg+xml",
|
|
37
|
+
".ico": "image/x-icon",
|
|
38
|
+
".woff2": "font/woff2",
|
|
39
|
+
".map": "application/json; charset=utf-8",
|
|
40
|
+
".webmanifest": "application/manifest+json",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
let scanned: Page | null | undefined;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The installed page, or null when there is none. node_modules is walked once
|
|
47
|
+
* and the answer kept.
|
|
48
|
+
*
|
|
49
|
+
* CHLOE_PAGE=builtin ignores whatever is installed and serves the runtime's
|
|
50
|
+
* own site instead. That is how you tell a broken dashboard from a broken
|
|
51
|
+
* runtime without uninstalling anything.
|
|
52
|
+
*/
|
|
53
|
+
export function installedPage(): Page | null {
|
|
54
|
+
if (process.env.CHLOE_PAGE === "builtin") return null;
|
|
55
|
+
if (scanned === undefined) scanned = pageIn(`${ROOT}/node_modules`);
|
|
56
|
+
return scanned;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The first package in a node_modules folder that offers a page, or null. */
|
|
60
|
+
export function pageIn(modules: string): Page | null {
|
|
61
|
+
if (!existsSync(modules)) return null;
|
|
62
|
+
for (const entry of readdirSync(modules, { withFileTypes: true })) {
|
|
63
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
64
|
+
if (entry.name.startsWith(".")) continue;
|
|
65
|
+
// A scope holds packages rather than being one.
|
|
66
|
+
const packages = entry.name.startsWith("@")
|
|
67
|
+
? readdirSync(`${modules}/${entry.name}`).map((one) => `${entry.name}/${one}`)
|
|
68
|
+
: [entry.name];
|
|
69
|
+
for (const name of packages) {
|
|
70
|
+
const offered = offers(`${modules}/${name}`);
|
|
71
|
+
if (offered) return { name, dir: offered };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let head: string | undefined;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* What the installed page wants at the top of every HTML file shown from a
|
|
81
|
+
* memory: the contents of `note-head.html` in its folder, or "" when it has
|
|
82
|
+
* none. Its links should be root-relative, like `/notes.css`, since the page's
|
|
83
|
+
* own files are served at the root. Read once, like the page itself.
|
|
84
|
+
*/
|
|
85
|
+
export function noteHead(): string {
|
|
86
|
+
if (head !== undefined) return head;
|
|
87
|
+
const page = installedPage();
|
|
88
|
+
const file = page ? `${page.dir}/note-head.html` : "";
|
|
89
|
+
head = file && existsSync(file) ? readFileSync(file, "utf8").trim() : "";
|
|
90
|
+
return head;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The folder a package offers as a page, if it offers one and it is really there. */
|
|
94
|
+
function offers(dir: string): string | null {
|
|
95
|
+
let declared: unknown;
|
|
96
|
+
try {
|
|
97
|
+
declared = (JSON.parse(readFileSync(`${dir}/package.json`, "utf8")) as { chloePage?: unknown }).chloePage;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
if (typeof declared !== "string" || !declared) return null;
|
|
102
|
+
const at = resolve(dir, declared);
|
|
103
|
+
// A package that says it has a page but has not been built yet is not an
|
|
104
|
+
// error worth stopping for: the built-in page is still there.
|
|
105
|
+
if (!existsSync(`${at}/index.html`)) {
|
|
106
|
+
console.error(`${dir} offers a page at ${declared}, but there is no index.html in it. Has it been built?`);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
return at;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Serves one file out of the page's folder, or the page itself.
|
|
114
|
+
*
|
|
115
|
+
* A file that is there is that file. Anything else is one of the page's own
|
|
116
|
+
* addresses, so it gets index.html and the browser works out which view it is.
|
|
117
|
+
* That is a proxy's `try_files {path} /index.html`.
|
|
118
|
+
*
|
|
119
|
+
* It used to decide by whether the address had a dot in it, which is right for
|
|
120
|
+
* /page.js and wrong for /agents/chloe/memory/notes/curriculum.html: an address
|
|
121
|
+
* inside the page can name a file somewhere else, and a dot in it is not a
|
|
122
|
+
* reason to go looking for that file here. Every deep link to a memory file was
|
|
123
|
+
* a 404.
|
|
124
|
+
*/
|
|
125
|
+
export async function servePageFile(response: ServerResponse, page: Page, path: string): Promise<boolean> {
|
|
126
|
+
const asked = path.includes(".") ? inside(page.dir, path) : null;
|
|
127
|
+
const found = asked && existsSync(asked) && statSync(asked).isFile() ? asked : `${page.dir}/index.html`;
|
|
128
|
+
try {
|
|
129
|
+
const body = await readFile(found);
|
|
130
|
+
response.writeHead(200, { "content-type": TYPES[extname(found)] ?? "application/octet-stream" });
|
|
131
|
+
response.end(body);
|
|
132
|
+
} catch {
|
|
133
|
+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("Not here.\n");
|
|
134
|
+
}
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Keeps a browser's path inside the page's folder. */
|
|
139
|
+
function inside(dir: string, path: string): string | null {
|
|
140
|
+
const at = resolve(join(dir, path));
|
|
141
|
+
return at === dir || at.startsWith(dir + sep) ? at : null;
|
|
142
|
+
}
|
package/serve/pass.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// A pass to read one agent's memory, carried in the address rather than in a
|
|
2
|
+
// cookie, for the frame a memory file is shown in.
|
|
3
|
+
//
|
|
4
|
+
// Why not the cookie. A memory file is somebody's own HTML, and every one of
|
|
5
|
+
// them runs its own script. So it is served sandboxed (see memory.ts), which
|
|
6
|
+
// gives the document an origin of its own that is nobody's: its scripts run,
|
|
7
|
+
// but they cannot reach the page around them or call the API as the person
|
|
8
|
+
// signed in. That is the whole point. It also means the browser will not send
|
|
9
|
+
// the session cookie for the stylesheet and the script the file links, because
|
|
10
|
+
// that origin is not this site. So those need another way to show they may be
|
|
11
|
+
// served, and a pass in the path is it: the file's own relative and
|
|
12
|
+
// root-relative links resolve under the pass and carry it with them.
|
|
13
|
+
//
|
|
14
|
+
// What a pass can do is exactly what a frame needs and nothing else: read files
|
|
15
|
+
// in one agent's memory, for ten minutes. It cannot write, cannot reach another
|
|
16
|
+
// agent's memory, cannot make a token or talk to an agent. A script in a note
|
|
17
|
+
// can read the pass from its own address, which is fine: it is already running
|
|
18
|
+
// inside that agent's memory, and connect-src 'none' stops it sending the pass
|
|
19
|
+
// anywhere.
|
|
20
|
+
import { seal, unseal } from "./login.ts";
|
|
21
|
+
|
|
22
|
+
/** Long enough to load a frame and everything it links, short enough to be worthless later. */
|
|
23
|
+
const LASTS = 10 * 60;
|
|
24
|
+
|
|
25
|
+
const PURPOSE = "memory-pass";
|
|
26
|
+
|
|
27
|
+
interface Payload {
|
|
28
|
+
/** Whose memory. */
|
|
29
|
+
a: string;
|
|
30
|
+
/** Until when, in seconds since the epoch. */
|
|
31
|
+
u: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A pass to read that agent's memory. */
|
|
35
|
+
export function makePass(agent: string): string {
|
|
36
|
+
return seal(PURPOSE, { a: agent, u: Math.floor(Date.now() / 1000) + LASTS } satisfies Payload);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Which agent's memory this pass reads, or null when it is not a live pass. */
|
|
40
|
+
export function checkPass(value: string): string | null {
|
|
41
|
+
const payload = unseal<Payload>(PURPOSE, value);
|
|
42
|
+
if (!payload || typeof payload.a !== "string" || typeof payload.u !== "number") return null;
|
|
43
|
+
if (payload.u <= Math.floor(Date.now() / 1000)) return null;
|
|
44
|
+
return payload.a;
|
|
45
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// What each agent did recently, for the overview. The same job run again and
|
|
2
|
+
// again is one line with a count, or a check every quarter hour would be all
|
|
3
|
+
// there is to see.
|
|
4
|
+
import { db } from "#chloe/core/db.ts";
|
|
5
|
+
import type { Agent } from "#chloe/load/load.ts";
|
|
6
|
+
|
|
7
|
+
export interface RecentWork {
|
|
8
|
+
/** The newest run of the group, which is the one a click opens. */
|
|
9
|
+
id: string;
|
|
10
|
+
/** The channel it came in on, like "schedule" or "telegram". */
|
|
11
|
+
source: string;
|
|
12
|
+
/** The job it was, or null for a turn somebody started by talking to it. */
|
|
13
|
+
job: string | null;
|
|
14
|
+
started: string;
|
|
15
|
+
finished: string | null;
|
|
16
|
+
summary: string | null;
|
|
17
|
+
error: string | null;
|
|
18
|
+
/** How many runs in a row this line stands for, and how many of them failed. */
|
|
19
|
+
times: number;
|
|
20
|
+
failed: number;
|
|
21
|
+
cost: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Far enough back to find a few different things behind a busy job. */
|
|
25
|
+
const LOOK_BACK = 200;
|
|
26
|
+
|
|
27
|
+
interface Row {
|
|
28
|
+
id: string;
|
|
29
|
+
source: string;
|
|
30
|
+
job: string | null;
|
|
31
|
+
started: string;
|
|
32
|
+
finished: string | null;
|
|
33
|
+
summary: string | null;
|
|
34
|
+
error: string | null;
|
|
35
|
+
cost: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function recentWork(agent: Agent, count = 3): RecentWork[] {
|
|
39
|
+
const rows = db
|
|
40
|
+
.prepare(
|
|
41
|
+
"select id, source, job, started, finished, summary, error, cost from runs where agent = ? order by started desc limit ?",
|
|
42
|
+
)
|
|
43
|
+
.all(agent.name, LOOK_BACK) as unknown as Row[];
|
|
44
|
+
|
|
45
|
+
const out: RecentWork[] = [];
|
|
46
|
+
for (const run of rows) {
|
|
47
|
+
const last = out[out.length - 1];
|
|
48
|
+
if (last && last.source === run.source && last.job === run.job) {
|
|
49
|
+
last.times++;
|
|
50
|
+
last.failed += run.error ? 1 : 0;
|
|
51
|
+
last.cost += Number(run.cost ?? 0);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (out.length === count) break;
|
|
55
|
+
out.push({
|
|
56
|
+
id: run.id,
|
|
57
|
+
source: run.source,
|
|
58
|
+
job: run.job,
|
|
59
|
+
started: run.started,
|
|
60
|
+
finished: run.finished,
|
|
61
|
+
summary: run.summary,
|
|
62
|
+
error: run.error,
|
|
63
|
+
times: 1,
|
|
64
|
+
failed: run.error ? 1 : 0,
|
|
65
|
+
cost: Number(run.cost ?? 0),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
package/serve/site.ts
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// The runtime's own site: which agents are loaded, what each one is configured
|
|
2
|
+
// to do, the API docs, the tokens, and the notes folder if one is configured.
|
|
3
|
+
//
|
|
4
|
+
// Plain HTML written by hand, with no build step and no dependencies, because
|
|
5
|
+
// a build step in the runtime is the thing this site exists to avoid. It is
|
|
6
|
+
// deliberately basic. A package that offers a better page takes over every
|
|
7
|
+
// address here except the docs: see page.ts.
|
|
8
|
+
//
|
|
9
|
+
// Server rendered, so the only script on the page is the few lines a form
|
|
10
|
+
// needs. Everything it shows, it was given.
|
|
11
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
12
|
+
|
|
13
|
+
import { hasChannel, type Agent } from "#chloe/load/load.ts";
|
|
14
|
+
import { caller, from, hasAccount } from "./login.ts";
|
|
15
|
+
import { installedPage, servePageFile } from "./page.ts";
|
|
16
|
+
import { memoryLabel, memoryTree } from "./memory.ts";
|
|
17
|
+
import { makePass } from "./pass.ts";
|
|
18
|
+
import { describe } from "#chloe/timer/every.ts";
|
|
19
|
+
|
|
20
|
+
/** What the docs page needs to know about a route. http.ts's Route is this plus its handler. */
|
|
21
|
+
export interface RouteDoc {
|
|
22
|
+
method: string;
|
|
23
|
+
path: string;
|
|
24
|
+
does: string;
|
|
25
|
+
takes?: string;
|
|
26
|
+
open?: boolean;
|
|
27
|
+
token?: boolean;
|
|
28
|
+
needsApiChannel?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Context {
|
|
32
|
+
agent(name: string): Agent;
|
|
33
|
+
agents(): Map<string, Agent>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Everything that is not /api. The installed page gets first refusal, and the
|
|
38
|
+
* built-in one answers when there is none.
|
|
39
|
+
*/
|
|
40
|
+
export async function sitePage(
|
|
41
|
+
request: IncomingMessage,
|
|
42
|
+
response: ServerResponse,
|
|
43
|
+
path: string,
|
|
44
|
+
context: Context,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const page = installedPage();
|
|
47
|
+
if (page) return void (await servePageFile(response, page, path));
|
|
48
|
+
|
|
49
|
+
const who = caller(request);
|
|
50
|
+
// The site is the account's. A token is for the API, and giving it a browser
|
|
51
|
+
// session would quietly widen what it can reach.
|
|
52
|
+
if (!who || who.kind !== "account") {
|
|
53
|
+
if (path === "/login") return send(response, loginPage());
|
|
54
|
+
return away(response, "/login");
|
|
55
|
+
}
|
|
56
|
+
if (path === "/login") return away(response, "/");
|
|
57
|
+
|
|
58
|
+
const parts = path.split("/").filter(Boolean).map(decodeURIComponent);
|
|
59
|
+
|
|
60
|
+
if (path === "/") return send(response, homePage(context));
|
|
61
|
+
if (parts[0] === "agents" && parts[1] && parts.length === 2) {
|
|
62
|
+
return send(response, agentPage(context.agent(parts[1]), context));
|
|
63
|
+
}
|
|
64
|
+
if (path === "/tokens") return send(response, tokensPage(context));
|
|
65
|
+
// One agent's memory lives under that agent, the same as everything else of its.
|
|
66
|
+
if (parts[0] === "agents" && parts[1] && parts[2] === "memory") {
|
|
67
|
+
return send(response, await memoryPage(context.agent(parts[1]), parts.slice(3).join("/"), from(request)));
|
|
68
|
+
}
|
|
69
|
+
send(response, shell("Not here", `<h1>Not here</h1><p>No page at <code>${esc(path)}</code>.</p>`), 404);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function send(response: ServerResponse, body: string, status = 200): void {
|
|
73
|
+
response.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
|
74
|
+
response.end(body);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function away(response: ServerResponse, to: string): void {
|
|
78
|
+
response.writeHead(303, { location: to });
|
|
79
|
+
response.end();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function esc(value: unknown): string {
|
|
83
|
+
return String(value ?? "")
|
|
84
|
+
.replaceAll("&", "&")
|
|
85
|
+
.replaceAll("<", "<")
|
|
86
|
+
.replaceAll(">", ">")
|
|
87
|
+
.replaceAll('"', """);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const STYLE = `
|
|
91
|
+
:root { color-scheme: light dark; --ink:#1b1a18; --paper:#fbfaf8; --quiet:#6b6862; --line:#e4e0d9; --link:#1f5f8b; }
|
|
92
|
+
@media (prefers-color-scheme: dark) {
|
|
93
|
+
:root { --ink:#e8e5df; --paper:#171614; --quiet:#948f86; --line:#2e2c28; --link:#7fb5d8; }
|
|
94
|
+
}
|
|
95
|
+
* { box-sizing: border-box; }
|
|
96
|
+
body { margin:0; padding:2rem 1rem 4rem; background:var(--paper); color:var(--ink);
|
|
97
|
+
font:16px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
98
|
+
main { max-width: 52rem; margin: 0 auto; }
|
|
99
|
+
h1 { font-size:1.5rem; margin:0 0 .25rem; }
|
|
100
|
+
h2 { font-size:1.05rem; margin:2rem 0 .5rem; }
|
|
101
|
+
h3 { font-size:.95rem; margin:1.25rem 0 .35rem; }
|
|
102
|
+
a { color:var(--link); }
|
|
103
|
+
code, pre { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size:.875em; }
|
|
104
|
+
pre { background:color-mix(in srgb, var(--ink) 5%, transparent); padding:.75rem; border-radius:6px; overflow:auto; }
|
|
105
|
+
nav { display:flex; gap:1rem; flex-wrap:wrap; margin:0 0 2rem; padding-bottom:.75rem; border-bottom:1px solid var(--line); }
|
|
106
|
+
nav a { text-decoration:none; }
|
|
107
|
+
.quiet { color:var(--quiet); }
|
|
108
|
+
.card { border:1px solid var(--line); border-radius:8px; padding:1rem; margin:.75rem 0; }
|
|
109
|
+
.card h3 { margin-top:0; }
|
|
110
|
+
table { border-collapse:collapse; width:100%; margin:.5rem 0 1rem; }
|
|
111
|
+
th, td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--line); vertical-align:top; }
|
|
112
|
+
th { font-weight:600; font-size:.8rem; color:var(--quiet); text-transform:uppercase; letter-spacing:.04em; }
|
|
113
|
+
form { display:flex; gap:.5rem; flex-wrap:wrap; align-items:center; margin:.75rem 0; }
|
|
114
|
+
input, button { font:inherit; padding:.45rem .6rem; border:1px solid var(--line); border-radius:6px;
|
|
115
|
+
background:var(--paper); color:var(--ink); }
|
|
116
|
+
button { cursor:pointer; background:color-mix(in srgb, var(--ink) 8%, transparent); }
|
|
117
|
+
.tag { font-size:.75rem; padding:.1rem .4rem; border-radius:4px; border:1px solid var(--line); color:var(--quiet); }
|
|
118
|
+
ul.tree { list-style:none; padding-left:1rem; margin:.25rem 0; }
|
|
119
|
+
iframe { width:100%; height:32rem; border:1px solid var(--line); border-radius:6px; background:var(--paper); }
|
|
120
|
+
`;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Chloe's colours as a data URI, rather than a file and a route to serve it.
|
|
124
|
+
* A browser asks for /favicon.ico on every page whether one is offered or not,
|
|
125
|
+
* and an unanswered one is a 404 in the console of every page this site has.
|
|
126
|
+
*/
|
|
127
|
+
const ICON =
|
|
128
|
+
`<link rel="icon" href="data:image/svg+xml,` +
|
|
129
|
+
`%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E` +
|
|
130
|
+
`%3Crect width='32' height='32' rx='8' fill='%231d473b'/%3E` +
|
|
131
|
+
`%3Ccircle cx='16' cy='16' r='6' fill='%23f4efe1'/%3E%3C/svg%3E">`;
|
|
132
|
+
|
|
133
|
+
function shell(title: string, body: string, nav = true): string {
|
|
134
|
+
return `<!doctype html>
|
|
135
|
+
<html lang="en"><meta charset="utf-8">
|
|
136
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
137
|
+
<title>${esc(title)}</title>
|
|
138
|
+
${ICON}
|
|
139
|
+
<style>${STYLE}</style>
|
|
140
|
+
<main>
|
|
141
|
+
${nav ? `<nav>
|
|
142
|
+
<a href="/">Agents</a>
|
|
143
|
+
<a href="/api">API</a>
|
|
144
|
+
<a href="/tokens">Tokens</a>
|
|
145
|
+
<a href="#" onclick="fetch('/api/logout',{method:'POST'}).then(()=>location='/login');return false">Sign out</a>
|
|
146
|
+
</nav>` : ""}
|
|
147
|
+
${body}
|
|
148
|
+
</main>
|
|
149
|
+
</html>`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function loginPage(): string {
|
|
153
|
+
const making = !hasAccount();
|
|
154
|
+
return shell(
|
|
155
|
+
making ? "Make the account" : "Sign in",
|
|
156
|
+
`<h1>${making ? "Make the account" : "Sign in"}</h1>
|
|
157
|
+
<p class="quiet">${
|
|
158
|
+
making
|
|
159
|
+
? "This copy has no account yet. The first one is made here, or with <code>npm run account</code> on the box."
|
|
160
|
+
: "One account. Everything behind it is the agents, what they have run, and whatever folders they keep."
|
|
161
|
+
}</p>
|
|
162
|
+
<form onsubmit="return go(this)">
|
|
163
|
+
<input name="username" placeholder="Username" autocomplete="username" required>
|
|
164
|
+
<input name="password" type="password" placeholder="Password" autocomplete="current-password" required>
|
|
165
|
+
<button>${making ? "Make it" : "Sign in"}</button>
|
|
166
|
+
</form>
|
|
167
|
+
<p id="trouble" class="quiet"></p>
|
|
168
|
+
<script>
|
|
169
|
+
function go(form) {
|
|
170
|
+
fetch(${making ? "'/api/setup'" : "'/api/login'"}, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: { 'content-type': 'application/json' },
|
|
173
|
+
body: JSON.stringify({ username: form.username.value, password: form.password.value }),
|
|
174
|
+
})
|
|
175
|
+
.then((r) => r.json())
|
|
176
|
+
.then((a) => { if (a.ok) location = '/'; else document.getElementById('trouble').textContent = a.error; })
|
|
177
|
+
.catch((e) => { document.getElementById('trouble').textContent = String(e); });
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
</script>`,
|
|
181
|
+
false,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function homePage(context: Context): string {
|
|
186
|
+
const agents = [...context.agents().values()];
|
|
187
|
+
const body = `<h1>Chloe</h1>
|
|
188
|
+
<p class="quiet">${agents.length} agent${agents.length === 1 ? "" : "s"} loaded. This is the runtime's own site.
|
|
189
|
+
Install a package that offers a page and it takes over from here.</p>
|
|
190
|
+
|
|
191
|
+
${agents
|
|
192
|
+
.map(
|
|
193
|
+
(agent) => `<div class="card">
|
|
194
|
+
<h3><a href="/agents/${esc(agent.name)}">${esc(agent.label || agent.name)}</a>
|
|
195
|
+
${hasChannel(agent, "api") ? '<span class="tag">on the api</span>' : ""}</h3>
|
|
196
|
+
<p class="quiet">${esc(agent.description || "No description.")}</p>
|
|
197
|
+
<p class="quiet"><code>${esc(agent.model)}</code> · ${agent.jobs.length} job${
|
|
198
|
+
agent.jobs.length === 1 ? "" : "s"
|
|
199
|
+
} · ${agent.skills.length} skill${agent.skills.length === 1 ? "" : "s"}
|
|
200
|
+
· <a href="/agents/${esc(agent.name)}/memory">${esc(memoryLabel(agent))}</a></p>
|
|
201
|
+
</div>`,
|
|
202
|
+
)
|
|
203
|
+
.join("\n")}
|
|
204
|
+
|
|
205
|
+
<h2>Where things are</h2>
|
|
206
|
+
<table>
|
|
207
|
+
<tr><th>The API</th><td><a href="/api">/api</a> lists every route it answers.</td></tr>
|
|
208
|
+
<tr><th>Tokens</th><td><a href="/tokens">/tokens</a> makes and revokes them, for other systems.</td></tr>
|
|
209
|
+
</table>`;
|
|
210
|
+
return shell("Chloe", body);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function agentPage(agent: Agent, context: Context): string {
|
|
214
|
+
const body = `<h1>${esc(agent.label || agent.name)}</h1>
|
|
215
|
+
<p class="quiet">${esc(agent.description || "No description.")}</p>
|
|
216
|
+
<p class="quiet">This page is read only. Its folder is on disk, and an edit there is live in under a second.</p>
|
|
217
|
+
|
|
218
|
+
<table>
|
|
219
|
+
<tr><th>Name</th><td><code>${esc(agent.name)}</code></td></tr>
|
|
220
|
+
<tr><th>Model</th><td><code>${esc(agent.model)}</code></td></tr>
|
|
221
|
+
<tr><th>Channels</th><td>${
|
|
222
|
+
agent.channels.length
|
|
223
|
+
? agent.channels.map((one) => one.name).sort().map((one) => `<code>${esc(one)}</code>`).join(", ")
|
|
224
|
+
: '<span class="quiet">none</span>'
|
|
225
|
+
}</td></tr>
|
|
226
|
+
<tr><th>Tools</th><td>${
|
|
227
|
+
Object.keys(agent.tools ?? {}).length
|
|
228
|
+
? Object.keys(agent.tools ?? {}).sort().map((one) => `<code>${esc(one)}</code>`).join(", ")
|
|
229
|
+
: '<span class="quiet">none</span>'
|
|
230
|
+
}</td></tr>
|
|
231
|
+
<tr><th>Skills</th><td>${
|
|
232
|
+
agent.skills.length
|
|
233
|
+
? agent.skills.map((one) => `<code>${esc(one.name)}</code>`).join(", ")
|
|
234
|
+
: '<span class="quiet">none</span>'
|
|
235
|
+
}</td></tr>
|
|
236
|
+
</table>
|
|
237
|
+
|
|
238
|
+
<h2>Jobs</h2>
|
|
239
|
+
${
|
|
240
|
+
agent.jobs.length
|
|
241
|
+
? `<table>
|
|
242
|
+
<tr><th>Job</th><th>When</th><th>How</th><th>What it does</th></tr>
|
|
243
|
+
${agent.jobs
|
|
244
|
+
.map(
|
|
245
|
+
(job) => `<tr>
|
|
246
|
+
<td><code>${esc(job.id)}</code></td>
|
|
247
|
+
<td>${job.cron ? esc(describe(job.cron, job.timezone)) : '<span class="quiet">when started</span>'}</td>
|
|
248
|
+
<td>${job.run ? "code" : esc(job.model ?? agent.model)}</td>
|
|
249
|
+
<td class="quiet">${esc(job.description ?? "")}</td>
|
|
250
|
+
</tr>`,
|
|
251
|
+
)
|
|
252
|
+
.join("\n")}
|
|
253
|
+
</table>`
|
|
254
|
+
: '<p class="quiet">No jobs.</p>'
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
<h2>Reaching it</h2>
|
|
258
|
+
${
|
|
259
|
+
hasChannel(agent, "api")
|
|
260
|
+
? `<p>It binds an api channel, so a token may talk to it and run its jobs.</p>
|
|
261
|
+
<pre>curl -X POST http://127.0.0.1:3067/api/agents/${esc(agent.name)}/chat \\
|
|
262
|
+
-H "authorization: Bearer $CHLOE_TOKEN" \\
|
|
263
|
+
-H "content-type: application/json" \\
|
|
264
|
+
-d '{"prompt":"what is late?"}'</pre>`
|
|
265
|
+
: `<p class="quiet">It has no api channel, so only a signed-in account can talk to it.
|
|
266
|
+
Add <code>apiChannel()</code> to the channels in its definition to open it to a token.</p>`
|
|
267
|
+
}`;
|
|
268
|
+
return shell(agent.label || agent.name, body);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function tokensPage(context: Context): string {
|
|
272
|
+
const body = `<h1>Tokens</h1>
|
|
273
|
+
<p class="quiet">For another system to read this API and reach the agents that bind an api channel.
|
|
274
|
+
A token cannot write files, make tokens or revoke them. The secret is shown once and is not stored.</p>
|
|
275
|
+
|
|
276
|
+
<form onsubmit="return make(this)">
|
|
277
|
+
<input name="name" placeholder="What is it for" required>
|
|
278
|
+
<button>Make one</button>
|
|
279
|
+
</form>
|
|
280
|
+
<pre id="made" hidden></pre>
|
|
281
|
+
|
|
282
|
+
<table id="list"><tr><th>Name</th><th>Made</th><th>Last used</th><th></th></tr></table>
|
|
283
|
+
|
|
284
|
+
<script>
|
|
285
|
+
function draw(tokens) {
|
|
286
|
+
const rows = tokens.map((t) => {
|
|
287
|
+
const when = t.revoked
|
|
288
|
+
? '<span class="quiet">revoked ' + t.revoked.slice(0, 10) + '</span>'
|
|
289
|
+
: '<button onclick="revoke(\\'' + t.id + '\\')">Revoke</button>';
|
|
290
|
+
return '<tr><td>' + t.name + '</td><td class="quiet">' + t.created.slice(0, 10) + '</td><td class="quiet">' +
|
|
291
|
+
(t.lastUsed ? t.lastUsed.slice(0, 16).replace('T', ' ') : 'never') + '</td><td>' + when + '</td></tr>';
|
|
292
|
+
});
|
|
293
|
+
document.getElementById('list').innerHTML =
|
|
294
|
+
'<tr><th>Name</th><th>Made</th><th>Last used</th><th></th></tr>' + rows.join('');
|
|
295
|
+
}
|
|
296
|
+
function load() { fetch('/api/tokens').then((r) => r.json()).then(draw); }
|
|
297
|
+
function make(form) {
|
|
298
|
+
fetch('/api/tokens', {
|
|
299
|
+
method: 'POST',
|
|
300
|
+
headers: { 'content-type': 'application/json' },
|
|
301
|
+
body: JSON.stringify({ name: form.name.value }),
|
|
302
|
+
})
|
|
303
|
+
.then((r) => r.json())
|
|
304
|
+
.then((t) => {
|
|
305
|
+
const shown = document.getElementById('made');
|
|
306
|
+
shown.hidden = false;
|
|
307
|
+
shown.textContent = t.secret
|
|
308
|
+
? t.name + '\\n\\n' + t.secret + '\\n\\nCopy it now. It is not stored and cannot be shown again.'
|
|
309
|
+
: t.error;
|
|
310
|
+
form.reset();
|
|
311
|
+
load();
|
|
312
|
+
});
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
function revoke(id) {
|
|
316
|
+
fetch('/api/tokens/' + id + '/revoke', { method: 'POST' }).then(load);
|
|
317
|
+
}
|
|
318
|
+
load();
|
|
319
|
+
</script>`;
|
|
320
|
+
return shell("Tokens", body);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function memoryPage(agent: Agent, path: string, at: string): Promise<string> {
|
|
324
|
+
const label = memoryLabel(agent);
|
|
325
|
+
const here = `/agents/${agent.name}/memory`;
|
|
326
|
+
|
|
327
|
+
if (!path) {
|
|
328
|
+
const tree = await memoryTree(agent, at);
|
|
329
|
+
return shell(
|
|
330
|
+
`${agent.name}: ${label}`,
|
|
331
|
+
`<h1>${esc(agent.label || agent.name)}’s ${esc(label.toLowerCase())}</h1>
|
|
332
|
+
<p class="quiet"><code>${esc(agent.memory.folder)}</code>. Every file opened here is written to the audit log,
|
|
333
|
+
which reading the same file from a shell is not. That difference is deliberate.</p>
|
|
334
|
+
${drawTree(here, tree)}`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// In a frame, under a pass, sandboxed. See pass.ts and framed() in http.ts:
|
|
339
|
+
// the file runs its own script and cannot reach this page or the API.
|
|
340
|
+
const src = `/memory/${encodeURIComponent(makePass(agent.name))}/${path.split("/").map(encodeURIComponent).join("/")}`;
|
|
341
|
+
return shell(
|
|
342
|
+
path,
|
|
343
|
+
`<p class="quiet"><a href="${esc(here)}">${esc(label)}</a> / ${esc(path)}</p>
|
|
344
|
+
<iframe src="${esc(src)}" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox" title="${esc(path)}"></iframe>`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function drawTree(here: string, entries: { name: string; path: string; dir: boolean; children?: unknown }[]): string {
|
|
349
|
+
if (!entries.length) return '<p class="quiet">Nothing here.</p>';
|
|
350
|
+
return `<ul class="tree">${entries
|
|
351
|
+
.map(
|
|
352
|
+
(one) =>
|
|
353
|
+
`<li>${one.dir ? `<span class="quiet">${esc(one.name)}/</span>` : `<a href="${esc(here)}/${esc(one.path)}">${esc(one.name)}</a>`}${
|
|
354
|
+
one.dir && Array.isArray(one.children) && one.children.length
|
|
355
|
+
? drawTree(here, one.children as { name: string; path: string; dir: boolean }[])
|
|
356
|
+
: ""
|
|
357
|
+
}</li>`,
|
|
358
|
+
)
|
|
359
|
+
.join("")}</ul>`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** The API docs, generated from the same list the router dispatches on. */
|
|
363
|
+
export function docsPage(routes: RouteDoc[]): string {
|
|
364
|
+
const groups: [string, (one: RouteDoc) => boolean][] = [
|
|
365
|
+
["The ways in", (one) => Boolean(one.open)],
|
|
366
|
+
["Reading", (one) => !one.open && Boolean(one.token) && one.method === "GET"],
|
|
367
|
+
["Talking to an agent", (one) => Boolean(one.needsApiChannel)],
|
|
368
|
+
["The account's own", (one) => !one.open && !one.token],
|
|
369
|
+
];
|
|
370
|
+
const body = `<h1>The API</h1>
|
|
371
|
+
<p class="quiet">Every route this runtime answers. It binds <code>127.0.0.1:3067</code> and is not meant to be
|
|
372
|
+
put on a public name: reach it from another machine over a tunnel.</p>
|
|
373
|
+
|
|
374
|
+
<h2>Who may call what</h2>
|
|
375
|
+
<table>
|
|
376
|
+
<tr><th>anybody</th><td>The four ways in. Each says as little as it can.</td></tr>
|
|
377
|
+
<tr><th>account</th><td>Somebody signed in on this box, as the cookie or as
|
|
378
|
+
<code>Authorization: Bearer <the value /api/login returned></code>. Can do everything.</td></tr>
|
|
379
|
+
<tr><th>token</th><td>Another system, as <code>Authorization: Bearer chloe_...</code>. Reading, plus chat and
|
|
380
|
+
jobs for the agents that bind an api channel. Never writing, and never the tokens themselves.
|
|
381
|
+
Made at <a href="/tokens">/tokens</a>.</td></tr>
|
|
382
|
+
</table>
|
|
383
|
+
|
|
384
|
+
${groups
|
|
385
|
+
.map(([title, pick]) => {
|
|
386
|
+
const found = routes.filter(pick);
|
|
387
|
+
if (!found.length) return "";
|
|
388
|
+
return `<h2>${esc(title)}</h2>
|
|
389
|
+
<table>
|
|
390
|
+
${found
|
|
391
|
+
.map(
|
|
392
|
+
(one) => `<tr>
|
|
393
|
+
<td><code>${esc(one.method)} ${esc(one.path)}</code>${
|
|
394
|
+
one.takes ? `<br><span class="quiet"><code>${esc(one.takes)}</code></span>` : ""
|
|
395
|
+
}</td>
|
|
396
|
+
<td>${esc(one.does)}</td>
|
|
397
|
+
</tr>`,
|
|
398
|
+
)
|
|
399
|
+
.join("\n")}
|
|
400
|
+
</table>`;
|
|
401
|
+
})
|
|
402
|
+
.join("\n")}
|
|
403
|
+
|
|
404
|
+
<h2>From something that is not a browser</h2>
|
|
405
|
+
<pre>curl http://127.0.0.1:3067/api/agents -H "authorization: Bearer $CHLOE_TOKEN"</pre>
|
|
406
|
+
<p class="quiet">The same list as JSON is this page with no <code>Accept: text/html</code>:</p>
|
|
407
|
+
<pre>curl http://127.0.0.1:3067/api</pre>`;
|
|
408
|
+
return shell("The API", body);
|
|
409
|
+
}
|