@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/login.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// The login in front of the page: one account, made on the first visit.
|
|
2
|
+
//
|
|
3
|
+
// Nothing is configured to set this up. A fresh copy of chloe has no account,
|
|
4
|
+
// so the first person to open the page is asked to choose a username and a
|
|
5
|
+
// password, and once one exists that door is shut. Changing it afterwards means
|
|
6
|
+
// deleting the file below, which is deliberate: it takes a shell on the box.
|
|
7
|
+
//
|
|
8
|
+
// Hand rolled on node:crypto, scrypt for the password and HMAC-SHA256 for the
|
|
9
|
+
// cookie, because the alternative is a dependency for forty lines.
|
|
10
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import crypto from "node:crypto";
|
|
12
|
+
import type { IncomingMessage } from "node:http";
|
|
13
|
+
|
|
14
|
+
import { STATE } from "#chloe/core/paths.ts";
|
|
15
|
+
import { checkToken, type Token } from "./tokens.ts";
|
|
16
|
+
import { lockedOut } from "./alerts.ts";
|
|
17
|
+
|
|
18
|
+
/** The one account, beside the run history, mode 600. Not in source control. */
|
|
19
|
+
const FILE = `${STATE}/login.json`;
|
|
20
|
+
|
|
21
|
+
export const COOKIE = "chloe_session";
|
|
22
|
+
|
|
23
|
+
/** A week. Long enough not to be a chore, short enough that a stolen cookie dies. */
|
|
24
|
+
const LASTS = 7 * 24 * 60 * 60;
|
|
25
|
+
|
|
26
|
+
/** Five wrong passwords from one address buys fifteen minutes of nothing. */
|
|
27
|
+
const TRIES = 5;
|
|
28
|
+
const WINDOW = 15 * 60 * 1000;
|
|
29
|
+
const LOCKED = 15 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
interface Account {
|
|
32
|
+
username: string;
|
|
33
|
+
password: string;
|
|
34
|
+
/** Signs the session cookie. Made with the account, so a restart keeps people signed in. */
|
|
35
|
+
secret: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let account: Account | null | undefined;
|
|
39
|
+
|
|
40
|
+
function read(): Account | null {
|
|
41
|
+
if (account !== undefined) return account;
|
|
42
|
+
try {
|
|
43
|
+
account = JSON.parse(readFileSync(FILE, "utf8")) as Account;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
46
|
+
account = null;
|
|
47
|
+
}
|
|
48
|
+
return account;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function hasAccount(): boolean {
|
|
52
|
+
return Boolean(read());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The account this copy will have, from the setup page. Throws if one already
|
|
57
|
+
* exists: the first visit wins, and every visit after it is refused.
|
|
58
|
+
*/
|
|
59
|
+
export function createAccount(username: string, password: string): void {
|
|
60
|
+
if (hasAccount()) throw new Error("An account already exists.");
|
|
61
|
+
const who = username.trim();
|
|
62
|
+
if (!who) throw new Error("Choose a username.");
|
|
63
|
+
if (password.length < 8) throw new Error("The password must be at least 8 characters.");
|
|
64
|
+
const made: Account = {
|
|
65
|
+
username: who,
|
|
66
|
+
password: hash(password),
|
|
67
|
+
secret: crypto.randomBytes(32).toString("base64url"),
|
|
68
|
+
};
|
|
69
|
+
mkdirSync(STATE, { recursive: true });
|
|
70
|
+
writeFileSync(FILE, JSON.stringify(made, null, 2), { mode: 0o600 });
|
|
71
|
+
chmodSync(FILE, 0o600);
|
|
72
|
+
account = made;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The cookie value for a correct username and password, or the reason it was
|
|
77
|
+
* refused. The address is what the lockout counts, so one person guessing does
|
|
78
|
+
* not lock everybody out.
|
|
79
|
+
*/
|
|
80
|
+
export function signIn(username: string, password: string, from: string): string {
|
|
81
|
+
const held = read();
|
|
82
|
+
if (!held) throw new Error("There is no account yet.");
|
|
83
|
+
const wait = lockedFor(from);
|
|
84
|
+
if (wait) throw new Error(`Too many tries. Try again in ${Math.ceil(wait / 60)} minutes.`);
|
|
85
|
+
if (!same(username.trim(), held.username) || !check(password, held.password)) {
|
|
86
|
+
countFailure(from);
|
|
87
|
+
throw new Error("Wrong username or password.");
|
|
88
|
+
}
|
|
89
|
+
failures.delete(from);
|
|
90
|
+
return sign(held);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Who is making this request. Two different things can be true, and the
|
|
95
|
+
* difference is the whole of the authorisation this runtime has:
|
|
96
|
+
*
|
|
97
|
+
* account somebody signed in on this box. Can do everything.
|
|
98
|
+
* token another system holding a token. Read, plus the agents that bind
|
|
99
|
+
* an api channel. Cannot write files, make tokens or revoke them.
|
|
100
|
+
*
|
|
101
|
+
* A browser carries the account session as a cookie. Anything that is not a
|
|
102
|
+
* browser sends either the same session value or a token as
|
|
103
|
+
* `Authorization: Bearer`, and which one it is decides what it may do.
|
|
104
|
+
*/
|
|
105
|
+
export type Caller = { kind: "account" } | { kind: "token"; token: Token } | null;
|
|
106
|
+
|
|
107
|
+
export function caller(request: IncomingMessage): Caller {
|
|
108
|
+
const held = read();
|
|
109
|
+
const values = carried(request);
|
|
110
|
+
if (held && values.some((value) => holds(value, held))) return { kind: "account" };
|
|
111
|
+
for (const value of values) {
|
|
112
|
+
const token = checkToken(value);
|
|
113
|
+
if (token) return { kind: "token", token };
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Whether this request carries anything at all this copy will accept. */
|
|
119
|
+
export function signedIn(request: IncomingMessage): boolean {
|
|
120
|
+
return caller(request) !== null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The session values on a request: the cookie, then the bearer header. */
|
|
124
|
+
function carried(request: IncomingMessage): string[] {
|
|
125
|
+
const found: string[] = [];
|
|
126
|
+
const cookie = cookies(request.headers.cookie)[COOKIE];
|
|
127
|
+
if (cookie) found.push(cookie);
|
|
128
|
+
const header = request.headers.authorization ?? "";
|
|
129
|
+
if (header.toLowerCase().startsWith("bearer ")) found.push(header.slice(7).trim());
|
|
130
|
+
return found;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Whether one value is a session this copy signed, for this account, still in date. */
|
|
134
|
+
function holds(value: string, held: Account): boolean {
|
|
135
|
+
const dot = value.lastIndexOf(".");
|
|
136
|
+
if (dot <= 0) return false;
|
|
137
|
+
const body = value.slice(0, dot);
|
|
138
|
+
const expected = crypto.createHmac("sha256", held.secret).update(body).digest("base64url");
|
|
139
|
+
if (!same(value.slice(dot + 1), expected)) return false;
|
|
140
|
+
try {
|
|
141
|
+
const inside = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as { who: string; until: number };
|
|
142
|
+
return inside.until > Math.floor(Date.now() / 1000) && same(inside.who, held.username);
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The Set-Cookie for a session, or for ending one when the value is empty. */
|
|
149
|
+
export function setCookie(value: string, secure: boolean): string {
|
|
150
|
+
const rest = `Path=/; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
|
|
151
|
+
return value ? `${COOKIE}=${value}; Max-Age=${LASTS}; ${rest}` : `${COOKIE}=; Max-Age=0; ${rest}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A cookie for something already running on this box, like `npm run agent`.
|
|
156
|
+
* It reads the account file, which needs the account's own user, so this is
|
|
157
|
+
* not a way past the login: it is the same permission, said over HTTP.
|
|
158
|
+
*/
|
|
159
|
+
export function ownCookie(): string {
|
|
160
|
+
const held = read();
|
|
161
|
+
if (!held) throw new Error(`There is no account yet. Open the page and make one.`);
|
|
162
|
+
return `${COOKIE}=${sign(held)}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Something signed with the account's secret, for a purpose other than a
|
|
167
|
+
* session. The purpose is part of what is signed, so a value made for one
|
|
168
|
+
* purpose can never be passed off as another, and in particular never as a
|
|
169
|
+
* session. Dies with the account, like everything signed here.
|
|
170
|
+
*/
|
|
171
|
+
export function seal(purpose: string, payload: object): string {
|
|
172
|
+
const held = read();
|
|
173
|
+
if (!held) throw new Error("There is no account yet, so nothing can be signed.");
|
|
174
|
+
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
175
|
+
return `${body}.${crypto.createHmac("sha256", held.secret).update(`${purpose}:${body}`).digest("base64url")}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The payload a `seal` made for this purpose, or null when it is not one. */
|
|
179
|
+
export function unseal<T>(purpose: string, value: string): T | null {
|
|
180
|
+
const held = read();
|
|
181
|
+
if (!held) return null;
|
|
182
|
+
const dot = value.lastIndexOf(".");
|
|
183
|
+
if (dot <= 0) return null;
|
|
184
|
+
const body = value.slice(0, dot);
|
|
185
|
+
const expected = crypto.createHmac("sha256", held.secret).update(`${purpose}:${body}`).digest("base64url");
|
|
186
|
+
if (!same(value.slice(dot + 1), expected)) return null;
|
|
187
|
+
try {
|
|
188
|
+
return JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as T;
|
|
189
|
+
} catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function sign(held: Account): string {
|
|
195
|
+
const body = Buffer.from(
|
|
196
|
+
JSON.stringify({ who: held.username, until: Math.floor(Date.now() / 1000) + LASTS }),
|
|
197
|
+
).toString("base64url");
|
|
198
|
+
return `${body}.${crypto.createHmac("sha256", held.secret).update(body).digest("base64url")}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* scrypt:N:r:p:salt:hash, colon separated rather than the usual $ separated
|
|
203
|
+
* form: some environment loaders read $16384 as a variable and quietly cut the
|
|
204
|
+
* hash in half, which is a login that always fails and never says why.
|
|
205
|
+
*/
|
|
206
|
+
function hash(password: string): string {
|
|
207
|
+
const N = 16384, r = 8, p = 1;
|
|
208
|
+
const salt = crypto.randomBytes(16);
|
|
209
|
+
const made = crypto.scryptSync(password.normalize("NFKC"), salt, 32, { N, r, p });
|
|
210
|
+
return `scrypt:${N}:${r}:${p}:${salt.toString("base64")}:${made.toString("base64")}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function check(password: string, stored: string): boolean {
|
|
214
|
+
try {
|
|
215
|
+
const [scheme, N, r, p, salt, expected] = stored.split(":");
|
|
216
|
+
if (scheme !== "scrypt") return false;
|
|
217
|
+
const want = Buffer.from(expected, "base64");
|
|
218
|
+
const got = crypto.scryptSync(password.normalize("NFKC"), Buffer.from(salt, "base64"), want.length, {
|
|
219
|
+
N: Number(N), r: Number(r), p: Number(p),
|
|
220
|
+
});
|
|
221
|
+
return crypto.timingSafeEqual(got, want);
|
|
222
|
+
} catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Compares without letting the time it takes say how much of it matched. */
|
|
228
|
+
function same(a: string, b: string): boolean {
|
|
229
|
+
const one = Buffer.from(a), two = Buffer.from(b);
|
|
230
|
+
if (one.length !== two.length) return false;
|
|
231
|
+
return crypto.timingSafeEqual(one, two);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// In this process, so a restart clears it.
|
|
235
|
+
const failures = new Map<string, { count: number; first: number; until: number }>();
|
|
236
|
+
|
|
237
|
+
function lockedFor(from: string): number {
|
|
238
|
+
const seen = failures.get(from);
|
|
239
|
+
if (!seen) return 0;
|
|
240
|
+
return seen.until > Date.now() ? Math.ceil((seen.until - Date.now()) / 1000) : 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function countFailure(from: string): void {
|
|
244
|
+
const now = Date.now();
|
|
245
|
+
const seen = failures.get(from);
|
|
246
|
+
if (!seen || now - seen.first > WINDOW) return void failures.set(from, { count: 1, first: now, until: 0 });
|
|
247
|
+
seen.count += 1;
|
|
248
|
+
if (seen.count >= TRIES) {
|
|
249
|
+
Object.assign(seen, { count: 0, first: now, until: now + LOCKED });
|
|
250
|
+
lockedOut(from);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Who this request is really from, for the lockout and the audit log.
|
|
256
|
+
*
|
|
257
|
+
* The socket address is the proxy's, because this binds loopback and something
|
|
258
|
+
* is always in front. So it comes from a header, and the only question that
|
|
259
|
+
* matters is which part of which header the caller cannot write.
|
|
260
|
+
*
|
|
261
|
+
* `x-forwarded-for` is a list that every hop APPENDS to. So whatever the client
|
|
262
|
+
* sent arrives at the front, and the entry the proxy in front added is at the
|
|
263
|
+
* end. The first entry is therefore the one part of it a stranger controls: a
|
|
264
|
+
* caller sending `x-forwarded-for: 1.2.3.4` and changing it each time would
|
|
265
|
+
* never be locked out, and could lock anybody out by borrowing their address.
|
|
266
|
+
* Read the last entry, which is the address the proxy in front actually saw.
|
|
267
|
+
*
|
|
268
|
+
* `cf-connecting-ip` is better still where it exists, because Cloudflare
|
|
269
|
+
* overwrites rather than appends, so a client cannot put anything in it. It is
|
|
270
|
+
* preferred, and without it the last forwarded entry is the fallback: behind a
|
|
271
|
+
* CDN that is the CDN's own address, which makes the lockout coarse, but coarse
|
|
272
|
+
* and honest beats precise and forgeable.
|
|
273
|
+
*
|
|
274
|
+
* All of this is only as good as the proxy in front, which is the point of
|
|
275
|
+
* binding loopback: nothing else can reach this port to set these headers.
|
|
276
|
+
*/
|
|
277
|
+
export function from(request: IncomingMessage): string {
|
|
278
|
+
const head = (name: string): string[] => {
|
|
279
|
+
const value = request.headers[name];
|
|
280
|
+
return (Array.isArray(value) ? value[0] : value)?.split(",").map((one) => one.trim()).filter(Boolean) ?? [];
|
|
281
|
+
};
|
|
282
|
+
const cloudflare = head("cf-connecting-ip")[0];
|
|
283
|
+
const forwarded = head("x-forwarded-for").at(-1);
|
|
284
|
+
return cloudflare || forwarded || request.socket.remoteAddress || "unknown";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** True when the proxy in front is speaking HTTPS, so the cookie can be Secure. */
|
|
288
|
+
export function overHttps(request: IncomingMessage): boolean {
|
|
289
|
+
return request.headers["x-forwarded-proto"] === "https";
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function cookies(header: string | undefined): Record<string, string> {
|
|
293
|
+
const out: Record<string, string> = {};
|
|
294
|
+
for (const one of (header ?? "").split(";")) {
|
|
295
|
+
const at = one.indexOf("=");
|
|
296
|
+
if (at > 0) out[one.slice(0, at).trim()] = one.slice(at + 1).trim();
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
}
|
package/serve/memory.ts
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// Where an agent remembers things, browsed and edited from the site.
|
|
2
|
+
//
|
|
3
|
+
// Not to be confused with model/memory.ts, which is the last few messages of a
|
|
4
|
+
// conversation. This is a folder: the one an agent reads and writes between
|
|
5
|
+
// runs. Every agent has one, and unless its definition says otherwise it is
|
|
6
|
+
// that agent's own folder under the state directory. An agent that shares a
|
|
7
|
+
// folder with a person, like one that keeps somebody's notes, says where.
|
|
8
|
+
//
|
|
9
|
+
// Every file served is written down first, and that is the point rather than a
|
|
10
|
+
// detail. Reading these files from a shell is not recorded, because a shell on
|
|
11
|
+
// the box is already the whole of the box. Reading them over HTTP is recorded,
|
|
12
|
+
// because HTTP is the part somebody else could come through, and a log of what
|
|
13
|
+
// was served is the only way to answer afterwards what was taken. Do not make
|
|
14
|
+
// the log optional or best effort: a read that could not be recorded is
|
|
15
|
+
// refused instead.
|
|
16
|
+
import { execFile } from "node:child_process";
|
|
17
|
+
import { appendFile, mkdir, readFile, rename as move, rm, stat } from "node:fs/promises";
|
|
18
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
19
|
+
import { dirname, extname } from "node:path";
|
|
20
|
+
import { promisify } from "node:util";
|
|
21
|
+
|
|
22
|
+
import { confine, unreachable } from "#chloe/core/confine.ts";
|
|
23
|
+
import type { Agent } from "#chloe/load/load.ts";
|
|
24
|
+
import { list, read, write } from "#chloe/do/files.ts";
|
|
25
|
+
import { STATE } from "#chloe/core/paths.ts";
|
|
26
|
+
import { BadRequest } from "./errors.ts";
|
|
27
|
+
import { noteHead } from "./page.ts";
|
|
28
|
+
|
|
29
|
+
const git = promisify(execFile);
|
|
30
|
+
|
|
31
|
+
/** Deep enough for a folder of writing, and an end to it if something links in a circle. */
|
|
32
|
+
const DEPTH = 8;
|
|
33
|
+
|
|
34
|
+
const JUNK = ["node_modules", "__pycache__"];
|
|
35
|
+
|
|
36
|
+
export interface Entry {
|
|
37
|
+
name: string;
|
|
38
|
+
path: string;
|
|
39
|
+
dir: boolean;
|
|
40
|
+
/** Bytes, for a file. */
|
|
41
|
+
size?: number;
|
|
42
|
+
/** When it last changed, as an ISO date. */
|
|
43
|
+
modified?: string;
|
|
44
|
+
children?: Entry[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What the site calls it. */
|
|
48
|
+
export function memoryLabel(agent: Agent): string {
|
|
49
|
+
return agent.memory.label || "Memory";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Beside the state folder rather than inside the memory it records. For most
|
|
54
|
+
* agents the memory IS their state folder, and a log inside the folder it logs
|
|
55
|
+
* would show up in its own tree and change it every time it was read.
|
|
56
|
+
*/
|
|
57
|
+
function logFor(agent: Agent): string {
|
|
58
|
+
return `${STATE}/memory-audit/${agent.name}.jsonl`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function folder(agent: Agent): string {
|
|
62
|
+
return agent.memory.folder;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One line in the audit log. Awaited rather than left to finish on its own:
|
|
67
|
+
* the read that follows it must not happen if this could not be written.
|
|
68
|
+
*/
|
|
69
|
+
export async function record(
|
|
70
|
+
agent: Agent,
|
|
71
|
+
what: "read" | "write" | "list" | "serve" | "rename" | "delete" | "commit" | "push" | "pull",
|
|
72
|
+
path: string,
|
|
73
|
+
from: string,
|
|
74
|
+
extra: Record<string, unknown> = {},
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
await mkdir(`${STATE}/memory-audit`, { recursive: true });
|
|
77
|
+
await appendFile(logFor(agent), JSON.stringify({ at: new Date().toISOString(), what, path, from, ...extra }) + "\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The whole folder as a tree. An agent that has never written anything has no
|
|
82
|
+
* folder yet, which is an empty memory and not an error.
|
|
83
|
+
*
|
|
84
|
+
* Recorded like a read is. A listing carries no file contents, but the names in
|
|
85
|
+
* a folder of personal writing say plenty on their own.
|
|
86
|
+
*/
|
|
87
|
+
export async function memoryTree(agent: Agent, from = "unknown"): Promise<Entry[]> {
|
|
88
|
+
if (!existsSync(folder(agent))) return [];
|
|
89
|
+
await record(agent, "list", "/", from);
|
|
90
|
+
return walk(agent, "");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function walk(agent: Agent, path: string, depth = 0): Promise<Entry[]> {
|
|
94
|
+
const { entries } = await list(folder(agent), path || undefined);
|
|
95
|
+
const out: Entry[] = [];
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
const dir = entry.endsWith("/");
|
|
98
|
+
const name = dir ? entry.slice(0, -1) : entry;
|
|
99
|
+
// Left out rather than shown: a folder like secrets/ could never be opened
|
|
100
|
+
// from here, and trying to list it would stop the whole tree at the first
|
|
101
|
+
// one it met.
|
|
102
|
+
if (JUNK.includes(name) || unreachable(name)) continue;
|
|
103
|
+
const at = path ? `${path}/${name}` : name;
|
|
104
|
+
const about = await stat(`${folder(agent)}/${at}`).catch(() => null);
|
|
105
|
+
out.push({
|
|
106
|
+
name,
|
|
107
|
+
path: at,
|
|
108
|
+
dir,
|
|
109
|
+
size: dir ? undefined : about?.size,
|
|
110
|
+
modified: about?.mtime.toISOString(),
|
|
111
|
+
children: dir && depth < DEPTH ? await walk(agent, at, depth + 1) : undefined,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return out.sort((a, b) => Number(b.dir) - Number(a.dir) || a.name.localeCompare(b.name));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Where a caller's path lands, or null when it is not inside the folder.
|
|
119
|
+
*
|
|
120
|
+
* A path that tries to leave is answered the same way as a file that is not
|
|
121
|
+
* there, so whoever sent it learns nothing, and in particular not where on disk
|
|
122
|
+
* the folder is. Worth one line in the log, because somebody sent it on purpose.
|
|
123
|
+
*/
|
|
124
|
+
function inside(agent: Agent, path: string, from: string): string | null {
|
|
125
|
+
if (!existsSync(folder(agent))) return null;
|
|
126
|
+
try {
|
|
127
|
+
return confine(folder(agent), path);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.error(`memory: ${agent.name} refused ${JSON.stringify(path)} from ${from}: ${(error as Error).message}`);
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** One file as text, for editing. A folder answers with what is in it. */
|
|
135
|
+
export async function memoryOpen(agent: Agent, path: string, from: string) {
|
|
136
|
+
const resolved = inside(agent, path, from);
|
|
137
|
+
if (!resolved || !existsSync(resolved)) return null;
|
|
138
|
+
if (statSync(resolved).isDirectory()) {
|
|
139
|
+
await record(agent, "list", path, from);
|
|
140
|
+
return { path, dir: true as const, entries: await walk(agent, path) };
|
|
141
|
+
}
|
|
142
|
+
const file = await read(folder(agent), path);
|
|
143
|
+
// After the read and before the reply, so nothing is handed over unrecorded.
|
|
144
|
+
await record(agent, "read", path, from, { bytes: file.bytes });
|
|
145
|
+
return { path, dir: false as const, content: file.content, bytes: file.bytes };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const TYPES: Record<string, string> = {
|
|
149
|
+
".html": "text/html; charset=utf-8",
|
|
150
|
+
".htm": "text/html; charset=utf-8",
|
|
151
|
+
".css": "text/css; charset=utf-8",
|
|
152
|
+
".js": "text/javascript; charset=utf-8",
|
|
153
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
154
|
+
".json": "application/json; charset=utf-8",
|
|
155
|
+
".md": "text/markdown; charset=utf-8",
|
|
156
|
+
".txt": "text/plain; charset=utf-8",
|
|
157
|
+
".png": "image/png",
|
|
158
|
+
".jpg": "image/jpeg",
|
|
159
|
+
".jpeg": "image/jpeg",
|
|
160
|
+
".gif": "image/gif",
|
|
161
|
+
".webp": "image/webp",
|
|
162
|
+
".svg": "image/svg+xml",
|
|
163
|
+
".pdf": "application/pdf",
|
|
164
|
+
".woff2": "font/woff2",
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* One file as its own bytes, for the frame it is shown in and whatever it
|
|
169
|
+
* links. Null when it is not there or not inside the folder.
|
|
170
|
+
*
|
|
171
|
+
* An HTML file has its root-relative links pointed back into this memory,
|
|
172
|
+
* under the same pass, because that is where they mean. A note that says
|
|
173
|
+
* `<link href="/static/style.css">` means the stylesheet at the top of its own
|
|
174
|
+
* folder, not one at the top of this website. Only an href or a src that
|
|
175
|
+
* starts with a single slash is touched: `//host` is somewhere else entirely.
|
|
176
|
+
*/
|
|
177
|
+
export async function memoryRaw(
|
|
178
|
+
agent: Agent,
|
|
179
|
+
path: string,
|
|
180
|
+
from: string,
|
|
181
|
+
under: string,
|
|
182
|
+
): Promise<{ body: Buffer; type: string } | null> {
|
|
183
|
+
const resolved = inside(agent, path, from);
|
|
184
|
+
if (!resolved || !existsSync(resolved) || (await stat(resolved)).isDirectory()) return null;
|
|
185
|
+
const type = TYPES[extname(resolved).toLowerCase()] ?? "application/octet-stream";
|
|
186
|
+
let body = await readFile(resolved);
|
|
187
|
+
if (type.startsWith("text/html")) {
|
|
188
|
+
const html = body.toString("utf8").replace(/(\s(?:href|src)=["'])\/(?!\/)/gi, `$1${under}/`);
|
|
189
|
+
body = Buffer.from(withHead(html, noteHead()));
|
|
190
|
+
}
|
|
191
|
+
await record(agent, "serve", path, from, { bytes: body.length });
|
|
192
|
+
return { body, type };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* An HTML file with `head` put first inside its own head, or where the browser
|
|
197
|
+
* will build one when it has none. Nothing the file wrote is changed, and what
|
|
198
|
+
* is added comes before it, so the file's own styles still win.
|
|
199
|
+
*/
|
|
200
|
+
export function withHead(html: string, head: string): string {
|
|
201
|
+
if (!head) return html;
|
|
202
|
+
// The lookahead matters: `<head[^>]*>` alone also matches `<header>`.
|
|
203
|
+
const open = /<head(?=[\s>])[^>]*>/i;
|
|
204
|
+
if (open.test(html)) return html.replace(open, (tag) => `${tag}\n${head}`);
|
|
205
|
+
const root = /<html(?=[\s>])[^>]*>/i;
|
|
206
|
+
if (root.test(html)) return html.replace(root, (tag) => `${tag}\n<head>${head}</head>`);
|
|
207
|
+
// After a doctype, never before it, or the page renders in quirks mode.
|
|
208
|
+
const doctype = /^\s*<!doctype[^>]*>/i;
|
|
209
|
+
if (doctype.test(html)) return html.replace(doctype, (tag) => `${tag}\n${head}`);
|
|
210
|
+
return `${head}\n${html}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function memorySave(agent: Agent, path: string, content: string, from: string) {
|
|
214
|
+
if (!inside(agent, path, from)) throw new BadRequest(`${path} is not somewhere in this memory.`);
|
|
215
|
+
const commit = Boolean(agent.memory.commit) && (await isRepo(agent));
|
|
216
|
+
const written = await write(folder(agent), path, content, {
|
|
217
|
+
commit,
|
|
218
|
+
message: commit ? `memory: ${path} from the site` : undefined,
|
|
219
|
+
});
|
|
220
|
+
await record(agent, "write", path, from, { bytes: content.length });
|
|
221
|
+
return { path, bytes: written.bytes };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Moves a file or a folder. Both ends have to be inside, and the new one must not exist. */
|
|
225
|
+
export async function memoryRename(agent: Agent, from: string, to: string, who: string) {
|
|
226
|
+
const here = inside(agent, from, who);
|
|
227
|
+
const there = inside(agent, to, who);
|
|
228
|
+
if (!here || !there || !existsSync(here)) throw new BadRequest(`${from} is not somewhere in this memory.`);
|
|
229
|
+
if (existsSync(there)) throw new BadRequest(`${to} is already there.`);
|
|
230
|
+
await mkdir(dirname(there), { recursive: true });
|
|
231
|
+
await move(here, there);
|
|
232
|
+
await record(agent, "rename", from, who, { to });
|
|
233
|
+
return { from, to };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Deletes a file or a folder. In a repo that is recoverable from git, which is
|
|
238
|
+
* why the memory of an agent that keeps a person's notes should be one.
|
|
239
|
+
*/
|
|
240
|
+
export async function memoryDelete(agent: Agent, path: string, who: string) {
|
|
241
|
+
const here = inside(agent, path, who);
|
|
242
|
+
if (!here || !existsSync(here) || here === folder(agent)) {
|
|
243
|
+
throw new BadRequest(`${path} is not something in this memory that can be deleted.`);
|
|
244
|
+
}
|
|
245
|
+
await rm(here, { recursive: true });
|
|
246
|
+
await record(agent, "delete", path, who);
|
|
247
|
+
return { deleted: path };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** That agent's audit log, newest first. */
|
|
251
|
+
export async function memoryLog(agent: Agent, limit = 200): Promise<unknown[]> {
|
|
252
|
+
const text = await readFile(logFor(agent), "utf8").catch(() => "");
|
|
253
|
+
return text
|
|
254
|
+
.split("\n")
|
|
255
|
+
.filter(Boolean)
|
|
256
|
+
.slice(-limit)
|
|
257
|
+
.reverse()
|
|
258
|
+
.map((line) => {
|
|
259
|
+
try {
|
|
260
|
+
return JSON.parse(line) as unknown;
|
|
261
|
+
} catch {
|
|
262
|
+
return { at: "", what: "unreadable", line };
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Source control, when the memory is a repo. Enough to mirror the panel an
|
|
268
|
+
// editor puts beside its file tree: what changed, commit it, push, pull, and the
|
|
269
|
+
// recent history. Every call is `git` with an argument array and never a shell
|
|
270
|
+
// string, and nothing here takes a path from the browser: a commit is the whole
|
|
271
|
+
// tree, which is the only shape of commit this offers.
|
|
272
|
+
|
|
273
|
+
async function inRepo(agent: Agent, ...args: string[]): Promise<string> {
|
|
274
|
+
const { stdout } = await git("git", args, { cwd: folder(agent), maxBuffer: 8 << 20, timeout: 60_000 });
|
|
275
|
+
return stdout;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Whether this memory is itself a git repository: its folder is the top of one.
|
|
280
|
+
*
|
|
281
|
+
* Being inside one is not enough, and that difference is the whole of this
|
|
282
|
+
* function. An agent's memory defaults to its folder under the state
|
|
283
|
+
* directory, and that is usually inside the repo the agents are written in.
|
|
284
|
+
* Asked from there, git walks up and answers for that repo: its branch, its
|
|
285
|
+
* changes, and a "commit all" that stages every file in it from wherever it is
|
|
286
|
+
* run. So a memory panel would show somebody's unrelated work in progress as
|
|
287
|
+
* the memory's own changes, one click would commit it under a message written
|
|
288
|
+
* about something else, and push would send it off the box.
|
|
289
|
+
*/
|
|
290
|
+
async function isRepo(agent: Agent): Promise<boolean> {
|
|
291
|
+
if (!existsSync(folder(agent))) return false;
|
|
292
|
+
try {
|
|
293
|
+
const top = (await inRepo(agent, "rev-parse", "--show-toplevel")).trim();
|
|
294
|
+
return realpathSync(top) === realpathSync(folder(agent));
|
|
295
|
+
} catch {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function memoryGit(agent: Agent) {
|
|
301
|
+
if (!(await isRepo(agent))) return { repo: false as const };
|
|
302
|
+
const [porcelain, branch, history] = await Promise.all([
|
|
303
|
+
inRepo(agent, "status", "--porcelain=v1"),
|
|
304
|
+
inRepo(agent, "rev-parse", "--abbrev-ref", "HEAD").catch(() => "HEAD\n"),
|
|
305
|
+
inRepo(agent, "log", "-20", "--date=short", "--format=%h%x00%ad%x00%an%x00%s").catch(() => ""),
|
|
306
|
+
]);
|
|
307
|
+
const changes = porcelain
|
|
308
|
+
.split("\n")
|
|
309
|
+
.filter((line) => line.trim())
|
|
310
|
+
// XY<space>path, where XY is the two-letter index and worktree status.
|
|
311
|
+
.map((line) => ({ status: line.slice(0, 2).trim() || "?", path: line.slice(3) }));
|
|
312
|
+
let ahead = 0;
|
|
313
|
+
let behind = 0;
|
|
314
|
+
let upstream: string | null = null;
|
|
315
|
+
try {
|
|
316
|
+
upstream = (await inRepo(agent, "rev-parse", "--abbrev-ref", "@{upstream}")).trim();
|
|
317
|
+
const [a, b] = (await inRepo(agent, "rev-list", "--left-right", "--count", "HEAD...@{upstream}"))
|
|
318
|
+
.trim()
|
|
319
|
+
.split(/\s+/)
|
|
320
|
+
.map(Number);
|
|
321
|
+
ahead = a || 0;
|
|
322
|
+
behind = b || 0;
|
|
323
|
+
} catch {
|
|
324
|
+
// No upstream. Push says so rather than guessing one.
|
|
325
|
+
}
|
|
326
|
+
const log = history
|
|
327
|
+
.split("\n")
|
|
328
|
+
.filter(Boolean)
|
|
329
|
+
.map((line) => {
|
|
330
|
+
const [sha, date, author, subject] = line.split("\0");
|
|
331
|
+
return { sha, date, author, subject };
|
|
332
|
+
});
|
|
333
|
+
return { repo: true as const, branch: branch.trim(), changes, ahead, behind, upstream, log };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export async function memoryCommit(agent: Agent, message: string, who: string) {
|
|
337
|
+
const text = message.trim();
|
|
338
|
+
if (!text) throw new BadRequest("Write a commit message first.");
|
|
339
|
+
if (!(await isRepo(agent))) throw new BadRequest("This memory is not a git repository.");
|
|
340
|
+
await inRepo(agent, "add", "-A");
|
|
341
|
+
const staged = (await inRepo(agent, "diff", "--cached", "--name-only")).trim();
|
|
342
|
+
if (!staged) throw new BadRequest("Nothing to commit.");
|
|
343
|
+
await inRepo(agent, "commit", "-m", text);
|
|
344
|
+
const sha = (await inRepo(agent, "rev-parse", "--short", "HEAD")).trim();
|
|
345
|
+
await record(agent, "commit", "/", who, { commit: sha, message: text });
|
|
346
|
+
return { commit: sha, files: staged.split("\n").length };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Push is the one thing here that sends these files off the box, so it says
|
|
351
|
+
* where they went rather than just that it worked.
|
|
352
|
+
*/
|
|
353
|
+
export async function memoryPush(agent: Agent, who: string) {
|
|
354
|
+
const status = await memoryGit(agent);
|
|
355
|
+
if (!status.repo) throw new BadRequest("This memory is not a git repository.");
|
|
356
|
+
if (!status.upstream) throw new BadRequest("No upstream branch is set for this branch.");
|
|
357
|
+
if (!status.ahead) return { pushed: 0, upstream: status.upstream };
|
|
358
|
+
const url = (await inRepo(agent, "remote", "get-url", status.upstream.split("/")[0]).catch(() => "")).trim();
|
|
359
|
+
await inRepo(agent, "push");
|
|
360
|
+
await record(agent, "push", "/", who, { upstream: status.upstream, url, commits: status.ahead });
|
|
361
|
+
return { pushed: status.ahead, upstream: status.upstream, url };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Fast-forward only: a merge or a conflict is not something a button can sensibly resolve. */
|
|
365
|
+
export async function memoryPull(agent: Agent, who: string) {
|
|
366
|
+
if (!(await isRepo(agent))) throw new BadRequest("This memory is not a git repository.");
|
|
367
|
+
const out = await inRepo(agent, "pull", "--ff-only").catch((error: { stderr?: string; message: string }) => {
|
|
368
|
+
throw new BadRequest(String(error.stderr || error.message).split("\n")[0]);
|
|
369
|
+
});
|
|
370
|
+
await record(agent, "pull", "/", who);
|
|
371
|
+
return { output: out.trim() };
|
|
372
|
+
}
|