@chloejs/core 0.2.4 → 0.3.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/README.md +2 -1
- package/channels/shared.ts +20 -2
- package/channels/slack.ts +9 -6
- package/channels/telegram.ts +129 -14
- package/core/clock.ts +24 -7
- package/core/current.ts +19 -0
- package/core/db.ts +20 -0
- package/core/notes.ts +4 -4
- package/core/paths.ts +36 -5
- package/core/root.ts +1 -1
- package/core/settings.ts +46 -5
- package/core/steps.ts +16 -1
- package/core/turn.ts +49 -18
- package/index.ts +3 -3
- package/load/load.ts +172 -38
- package/model/tools/files.ts +18 -4
- package/model/tools/index.ts +2 -2
- package/model/tools/memory.ts +19 -14
- package/model/tools/own_files.ts +46 -0
- package/model/tools/send_email.ts +36 -9
- package/ops/test.ts +448 -15
- package/package.json +1 -1
- package/serve/changes.ts +61 -0
- package/serve/files.ts +26 -4
- package/serve/http.ts +77 -5
- package/serve/login.ts +46 -3
- package/serve/memory.ts +43 -34
- package/serve/site.ts +6 -1
- package/server.ts +39 -20
- package/services/emailService.ts +144 -8
- package/services/filesService.ts +28 -17
- package/services/historyService.ts +372 -0
- package/services/index.ts +1 -1
- package/services/ownFilesService.ts +141 -0
- package/services/scriptsService.ts +9 -4
- package/model/tools/write_skill.ts +0 -31
package/ops/test.ts
CHANGED
|
@@ -2,21 +2,27 @@
|
|
|
2
2
|
//
|
|
3
3
|
// A job is code, so it is tested rather than scored: `npm run evals` is for
|
|
4
4
|
// the prompts, and this is for the machinery underneath them. Nothing here
|
|
5
|
-
// touches the real database
|
|
6
|
-
// the gateway is a server on a loopback port that
|
|
7
|
-
// says, so a model step is exercised without
|
|
5
|
+
// touches the real database, the real gateway or a real mail account. The
|
|
6
|
+
// database is in memory, the gateway is a server on a loopback port that
|
|
7
|
+
// answers whatever the case says, so a model step is exercised without
|
|
8
|
+
// spending anything, and mail goes to the log.
|
|
8
9
|
//
|
|
9
10
|
// These are set rather than left to settings.json, because a setting in a file
|
|
10
11
|
// applies here too: a box with model.via "claude" would otherwise run every
|
|
11
12
|
// case against a real subscription, slowly, and score differently from the
|
|
12
13
|
// next box.
|
|
13
14
|
process.env.AGENTS_DB = ":memory:";
|
|
14
|
-
//
|
|
15
|
-
// land in the real
|
|
15
|
+
// Folders of their own, so a case that writes state (an account) or a note
|
|
16
|
+
// cannot land in the real ones. Set before any import, like the database above.
|
|
16
17
|
process.env.AGENTS_STATE = (await import("node:fs")).mkdtempSync(`${(await import("node:os")).tmpdir()}/chloe-test-`);
|
|
18
|
+
process.env.AGENTS_MEMORY = `${process.env.AGENTS_STATE}/memory`;
|
|
17
19
|
process.env.OWNER = "test:somebody";
|
|
18
20
|
process.env.AI_GATEWAY_API_KEY = "test";
|
|
19
21
|
process.env.MODEL_VIA = "gateway";
|
|
22
|
+
// Signing in and getting locked out both mail, and the addresses used here are
|
|
23
|
+
// made up. Without this the suite sends two real emails on a box that has a
|
|
24
|
+
// mail key, because the alert settings are read from the same file.
|
|
25
|
+
process.env.EMAIL_PROVIDER = "none";
|
|
20
26
|
|
|
21
27
|
import { existsSync } from "node:fs";
|
|
22
28
|
import { createServer } from "node:http";
|
|
@@ -559,6 +565,23 @@ about("a model step that never fits");
|
|
|
559
565
|
"a",
|
|
560
566
|
);
|
|
561
567
|
is("a setting nobody set is empty rather than missing", readSettings({}, {}).node, "");
|
|
568
|
+
is("each agent's own settings are under its name", readSettings({}, { agents: { tempo: { telegram: "t" } } }).agents.tempo.telegram, "t");
|
|
569
|
+
is("and what it does not say is empty", readSettings({}, { agents: { tempo: { telegram: "t" } } }).agents.tempo.slack.app_token, "");
|
|
570
|
+
let misspelt = "";
|
|
571
|
+
try {
|
|
572
|
+
readSettings({}, { agents: { tempo: { telegarm: "t" } } });
|
|
573
|
+
} catch (error) {
|
|
574
|
+
misspelt = error instanceof Error ? error.message : "";
|
|
575
|
+
}
|
|
576
|
+
is("a misspelt key under an agent is refused rather than ignored", misspelt.includes("telegarm"), true);
|
|
577
|
+
{
|
|
578
|
+
const { settings, unclaimed } = await import("@chloejs/core");
|
|
579
|
+
const before = settings.agents;
|
|
580
|
+
settings.agents = { tempo: { telegram: "t", slack: { bot_token: "", app_token: "" } } };
|
|
581
|
+
is("an entry for an agent that exists is claimed", unclaimed(["tempo"]), []);
|
|
582
|
+
is("one left behind by a rename is not", unclaimed(["growth"]), ["tempo"]);
|
|
583
|
+
settings.agents = before;
|
|
584
|
+
}
|
|
562
585
|
is("an environment variable beats the files", setting("fromfile", "TEST_SETTING_WINS"), "fromfile");
|
|
563
586
|
process.env.TEST_SETTING_WINS = "fromenv";
|
|
564
587
|
is("once there is one", setting("fromfile", "TEST_SETTING_WINS"), "fromenv");
|
|
@@ -800,7 +823,7 @@ about("a model step that never fits");
|
|
|
800
823
|
{
|
|
801
824
|
about("a note two jobs want at the same moment");
|
|
802
825
|
|
|
803
|
-
const {
|
|
826
|
+
const { MEMORIES, note } = await import("@chloejs/core");
|
|
804
827
|
const shape = z.object({ sites: z.record(z.string(), z.string()) }).catch({ sites: {} });
|
|
805
828
|
const kept = note("test-note", "sites", shape);
|
|
806
829
|
|
|
@@ -823,9 +846,9 @@ about("a model step that never fits");
|
|
|
823
846
|
const counts = (await Promise.all(reads)).map((one) => Object.keys(one.sites).length);
|
|
824
847
|
is("nobody reads a half written note", counts.filter((n) => n !== 1 && n !== 20_000), []);
|
|
825
848
|
is("and the note itself is whole afterwards", Object.keys((await kept.read()).sites).length, 20_000);
|
|
826
|
-
const left = (await readdir(join(
|
|
849
|
+
const left = (await readdir(join(MEMORIES, "test-note"))).filter((f) => f.endsWith(".part"));
|
|
827
850
|
is("the temporary file is renamed, not left behind", left, []);
|
|
828
|
-
await rm(join(
|
|
851
|
+
await rm(join(MEMORIES, "test-note"), { recursive: true, force: true });
|
|
829
852
|
}
|
|
830
853
|
|
|
831
854
|
{
|
|
@@ -920,7 +943,49 @@ for (const agent of (await (await import("@chloejs/core")).loadAll()).values())
|
|
|
920
943
|
|
|
921
944
|
{
|
|
922
945
|
about("telegram");
|
|
923
|
-
const { listen } = await import("#chloe/channels/telegram.ts");
|
|
946
|
+
const { listen, telegramChannel, telegramHtml, inPieces } = await import("#chloe/channels/telegram.ts");
|
|
947
|
+
|
|
948
|
+
is(
|
|
949
|
+
"markdown arrives as telegram's own formatting",
|
|
950
|
+
telegramHtml("**Blocked on you:**\n1. `main` needs a fast-forward, see [the log](https://example.com/a?b=1&c=2).\n- *one* item"),
|
|
951
|
+
"<b>Blocked on you:</b>\n1. <code>main</code> needs a fast-forward, see <a href=\"https://example.com/a?b=1&c=2\">the log</a>.\n• <i>one</i> item",
|
|
952
|
+
);
|
|
953
|
+
is("a heading is a bold line", telegramHtml("## Numbers"), "<b>Numbers</b>");
|
|
954
|
+
is("what telegram would read as a tag is escaped", telegramHtml("1 < 2 & 3 > 2"), "1 < 2 & 3 > 2");
|
|
955
|
+
is("nothing inside code is read as formatting", telegramHtml("`**not bold** <b>`"), "<code>**not bold** <b></code>");
|
|
956
|
+
is("a code block keeps its lines", telegramHtml("```\na < b\n c\n```"), "<pre>a < b\n c</pre>");
|
|
957
|
+
is("a quote is a quote", telegramHtml("> said\n> twice"), "<blockquote>said\ntwice</blockquote>");
|
|
958
|
+
is("a link that is not a web or mail address stays as written", telegramHtml("[go](javascript:alert(1))"), "[go](javascript:alert(1))");
|
|
959
|
+
is("underscores in a name are left alone", telegramHtml("run_script and ship_code_change"), "run_script and ship_code_change");
|
|
960
|
+
is("a sum is not italics", telegramHtml("2 * 3 * 4"), "2 * 3 * 4");
|
|
961
|
+
is("a long reply is cut at a line break", inPieces("aaaa\nbbbb\ncc", 10), ["aaaa\nbbbb", "cc"]);
|
|
962
|
+
is("and one with none is cut where it has to be", inPieces("abcdefghij", 4), ["abcd", "efgh", "ij"]);
|
|
963
|
+
|
|
964
|
+
{
|
|
965
|
+
// Two agents, one with a bot in settings and one without. Each reads its
|
|
966
|
+
// own entry, by the name it has when the channel starts.
|
|
967
|
+
const { settings } = await import("@chloejs/core");
|
|
968
|
+
const before = settings.agents;
|
|
969
|
+
settings.agents = { first: { telegram: "first-bot", slack: { bot_token: "", app_token: "" } } };
|
|
970
|
+
const said: string[] = [];
|
|
971
|
+
const log = console.error;
|
|
972
|
+
console.error = (line: string) => void said.push(line);
|
|
973
|
+
const channel = telegramChannel({ api: "http://127.0.0.1:9" });
|
|
974
|
+
channel.start(() => ({ name: "second" }) as any).stop();
|
|
975
|
+
channel.start(() => ({ name: "first" }) as any).stop();
|
|
976
|
+
console.error = log;
|
|
977
|
+
settings.agents = before;
|
|
978
|
+
is("an agent with no entry has no bot, and is not handed another's", said.some((l) => l.includes("second has a Telegram channel but no bot")), true);
|
|
979
|
+
is("an agent with one uses its own", said.some((l) => l.includes("first has a Telegram channel but no bot")), false);
|
|
980
|
+
}
|
|
981
|
+
is(
|
|
982
|
+
"a channel says what it was made with, so a reload can tell an edit from none",
|
|
983
|
+
[
|
|
984
|
+
telegramChannel({ allowFrom: [1] }).madeWith === telegramChannel({ allowFrom: [1] }).madeWith,
|
|
985
|
+
telegramChannel({ allowFrom: [1] }).madeWith === telegramChannel({ allowFrom: [1], sendWhileWorking: true }).madeWith,
|
|
986
|
+
],
|
|
987
|
+
[true, false],
|
|
988
|
+
);
|
|
924
989
|
|
|
925
990
|
// A stand-in Telegram: each update is handed out once, a file is always the
|
|
926
991
|
// same four bytes, and everything the bot sends is written down.
|
|
@@ -932,7 +997,14 @@ for (const agent of (await (await import("@chloejs/core")).loadAll()).values())
|
|
|
932
997
|
request.on("end", () => {
|
|
933
998
|
if (request.url!.startsWith("/file/")) return void response.end("PNG!");
|
|
934
999
|
const method = request.url!.split("/").pop()!;
|
|
935
|
-
|
|
1000
|
+
const body = JSON.parse(raw || "{}");
|
|
1001
|
+
const token = request.url!.split("/")[1].slice(3);
|
|
1002
|
+
// The bot called "picky" refuses every formatted message, as Telegram
|
|
1003
|
+
// does one whose tags it cannot read.
|
|
1004
|
+
if (token === "picky" && body.parse_mode) {
|
|
1005
|
+
return void response.end(JSON.stringify({ ok: false, description: "Bad Request: can't parse entities" }));
|
|
1006
|
+
}
|
|
1007
|
+
calls.push({ method, body, token });
|
|
936
1008
|
const result =
|
|
937
1009
|
method === "getUpdates" ? inbox.splice(0)
|
|
938
1010
|
: method === "getMe" ? { id: 999, is_bot: true, username: "testbot" }
|
|
@@ -972,6 +1044,15 @@ for (const agent of (await (await import("@chloejs/core")).loadAll()).values())
|
|
|
972
1044
|
// messages out once and for all, unlike Telegram. Let it land first.
|
|
973
1045
|
await pause(100);
|
|
974
1046
|
is("it clears a webhook first, or Telegram refuses to hand out messages", calls.some((c) => c.method === "deleteWebhook"), true);
|
|
1047
|
+
is("a reply goes as telegram's formatting", calls.find((c) => c.method === "sendMessage")?.body.parse_mode, "HTML");
|
|
1048
|
+
|
|
1049
|
+
const picky = listen({ name: "test", token: "picky", api, agent: () => agent });
|
|
1050
|
+
inbox.push(privately(2, stranger, "hi"));
|
|
1051
|
+
for (let i = 0; i < 100 && !calls.some((c) => c.token === "picky" && c.method === "sendMessage"); i++) await pause(20);
|
|
1052
|
+
picky.stop();
|
|
1053
|
+
await pause(100);
|
|
1054
|
+
const plain = calls.find((c) => c.token === "picky" && c.method === "sendMessage");
|
|
1055
|
+
is("a reply telegram refuses to format is sent again as plain words", [plain?.body.parse_mode, typeof plain?.body.text], [undefined, "string"]);
|
|
975
1056
|
is("with nobody allowed yet, a private message is told its user id", said()[0]?.startsWith("9: Your Telegram user id is 9."), true);
|
|
976
1057
|
|
|
977
1058
|
calls.length = 0;
|
|
@@ -1087,6 +1168,42 @@ for (const agent of (await (await import("@chloejs/core")).loadAll()).values())
|
|
|
1087
1168
|
const { recall: recalled } = await import("#chloe/model/memory.ts");
|
|
1088
1169
|
is("and the exchange is kept in that chat's conversation", recalled("test/telegram--100").map((m) => m.content).slice(-2), ["\u201cA line from a book.\u201d \u2014 A Book", whole]);
|
|
1089
1170
|
is("the / menu is the agent's jobs", calls.find((c) => c.method === "setMyCommands")?.body.commands, [{ command: "highlights", description: "highlights" }]);
|
|
1171
|
+
|
|
1172
|
+
// Two messages a second apart are one message, and the answer goes under the
|
|
1173
|
+
// first of them. A share that arrives as a quote and then a comment is why.
|
|
1174
|
+
calls.length = 0;
|
|
1175
|
+
handed.length = 0;
|
|
1176
|
+
const sixth = listen({ name: "test", token: "j", api, allowFrom: [7], inGroups: "always", stackWithin: 1, agent: () => reader });
|
|
1177
|
+
inbox.push(inGroup(41, me, "\u201cA line.\u201d \u2014 A Book"));
|
|
1178
|
+
await pause(200);
|
|
1179
|
+
inbox.push(inGroup(42, me, "and what I thought of it"));
|
|
1180
|
+
await settle(1);
|
|
1181
|
+
sixth.stop();
|
|
1182
|
+
await pause(100);
|
|
1183
|
+
is("messages sent close together are handled as one", handed, ["\u201cA line.\u201d \u2014 A Book\n\nand what I thought of it"]);
|
|
1184
|
+
is("and the answer replies to the first of them", calls.find((c) => c.method === "sendMessage")?.body.reply_parameters, { message_id: 41 });
|
|
1185
|
+
|
|
1186
|
+
// A run that failed says so. Saying it is already running would send
|
|
1187
|
+
// somebody looking for a run that is not there.
|
|
1188
|
+
calls.length = 0;
|
|
1189
|
+
const breaks = {
|
|
1190
|
+
...codeJob("highlights", async () => {
|
|
1191
|
+
throw new Error("the page would not write");
|
|
1192
|
+
}),
|
|
1193
|
+
input: z.object({ text: z.string() }),
|
|
1194
|
+
answers: (text: string) => text.startsWith("\u201c"),
|
|
1195
|
+
} as Job;
|
|
1196
|
+
delete breaks.cron;
|
|
1197
|
+
const broken = agentFor(breaks);
|
|
1198
|
+
const failing = startClock(() => new Map([["test", broken]]));
|
|
1199
|
+
const seventh = listen({ name: "test", token: "j", api, allowFrom: [7], inGroups: "always", agent: () => broken });
|
|
1200
|
+
inbox.push(inGroup(43, me, "\u201cAnother line.\u201d \u2014 A Book"));
|
|
1201
|
+
await settle(1);
|
|
1202
|
+
seventh.stop();
|
|
1203
|
+
failing.stop();
|
|
1204
|
+
await pause(100);
|
|
1205
|
+
is("a failed job says what failed, not that it is already running", said(), ["-100: highlights failed. the page would not write"]);
|
|
1206
|
+
|
|
1090
1207
|
telegram.close();
|
|
1091
1208
|
|
|
1092
1209
|
}
|
|
@@ -1373,6 +1490,9 @@ for (const agent of (await (await import("@chloejs/core")).loadAll()).values())
|
|
|
1373
1490
|
await receive(brief, { channel: "test", chat: "c", thread: "test/old", from: { id: "1", name: "Me" }, text: "and today?", private: true }, { chatHistory: { messages: 1 } });
|
|
1374
1491
|
const shownTo = lastAsked.filter((m) => m.role !== "system").map((m) => m.content);
|
|
1375
1492
|
is("a channel's chatHistory is what a turn on it is shown", shownTo, ["just now", "and today?"]);
|
|
1493
|
+
const system = lastAsked.find((m) => m.role === "system")?.content ?? "";
|
|
1494
|
+
is("a turn on a channel is told who it is talking to, and to say you", system.includes('You are talking with Me on test, directly. Write to them as "you"'), true);
|
|
1495
|
+
is("and not that its lines on the way are sent, when they are not", system.includes("sent to them straight away"), false);
|
|
1376
1496
|
|
|
1377
1497
|
// What the model writes on its way to an answer is sent as it goes only when
|
|
1378
1498
|
// the channel asks for it, and always before the answer.
|
|
@@ -1382,6 +1502,10 @@ for (const agent of (await (await import("@chloejs/core")).loadAll()).values())
|
|
|
1382
1502
|
receive(brief, { channel: "test", chat: "w", thread: "test/while", from: { id: "1", name: "Me" }, text: "how is it?", private: true },
|
|
1383
1503
|
{ sendWhileWorking }, { send: async (text) => void onTheWay.push(text) });
|
|
1384
1504
|
answers.push({ content: "Let me check.", tool_calls: [look] }, "All fine.");
|
|
1505
|
+
await talk(true);
|
|
1506
|
+
is("with sendWhileWorking on, it is told its lines on the way reach them", lastAsked.find((m) => m.role === "system")?.content.includes("sent to them straight away"), true);
|
|
1507
|
+
onTheWay.length = 0;
|
|
1508
|
+
answers.push({ content: "Let me check.", tool_calls: [look] }, "All fine.");
|
|
1385
1509
|
const quiet = await talk(false);
|
|
1386
1510
|
is("off, only the answer comes back", [onTheWay, quiet?.text], [[], "All fine."]);
|
|
1387
1511
|
answers.push({ content: "Let me check.", tool_calls: [look] }, "All fine.");
|
|
@@ -1390,6 +1514,299 @@ for (const agent of (await (await import("@chloejs/core")).loadAll()).values())
|
|
|
1390
1514
|
is("and kept in the conversation", recall("test/while").map((m) => m.content).slice(-3), ["how is it?", "Let me check.", "All fine."]);
|
|
1391
1515
|
}
|
|
1392
1516
|
|
|
1517
|
+
{
|
|
1518
|
+
about("adding to the end of a note");
|
|
1519
|
+
|
|
1520
|
+
const { writeFiles } = await import("@chloejs/core/services");
|
|
1521
|
+
const { mkdtemp, readFile } = await import("node:fs/promises");
|
|
1522
|
+
const folder = await mkdtemp(`${(await import("node:os")).tmpdir()}/chloe-append-`);
|
|
1523
|
+
await writeFiles(folder, "LESSONS.md", "# Lessons\n\n- one");
|
|
1524
|
+
await writeFiles(folder, "LESSONS.md", "- two\n", { append: true });
|
|
1525
|
+
is("what was there stays, and the new part starts on a line of its own", await readFile(`${folder}/LESSONS.md`, "utf8"), "# Lessons\n\n- one\n- two\n");
|
|
1526
|
+
await writeFiles(folder, "new/list.md", "- first\n", { append: true });
|
|
1527
|
+
is("adding to a file that is not there yet makes it", await readFile(`${folder}/new/list.md`, "utf8"), "- first\n");
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
{
|
|
1531
|
+
about("an agent's history, in git");
|
|
1532
|
+
|
|
1533
|
+
const { execFileSync } = await import("node:child_process");
|
|
1534
|
+
const { chmod, mkdtemp, mkdir: makeDir, readFile: get, writeFile: put } = await import("node:fs/promises");
|
|
1535
|
+
const { realpathSync } = await import("node:fs");
|
|
1536
|
+
const { jobsOf, loadAll, markdownJob } = await import("#chloe/load/load.ts");
|
|
1537
|
+
const { setAgentDirs } = await import("#chloe/core/paths.ts");
|
|
1538
|
+
const { change, makeRepo, markSeen, undo } = await import("#chloe/services/historyService.ts");
|
|
1539
|
+
const { whyNot, writeOwn } = await import("#chloe/services/ownFilesService.ts");
|
|
1540
|
+
const { runScripts } = await import("#chloe/services/scriptsService.ts");
|
|
1541
|
+
const { agentChanges } = await import("#chloe/serve/changes.ts");
|
|
1542
|
+
const { open, tree } = await import("#chloe/serve/files.ts");
|
|
1543
|
+
|
|
1544
|
+
// What this box's git calls a person, for the commits a person makes.
|
|
1545
|
+
const identity = ["GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL"];
|
|
1546
|
+
const before = identity.map((key) => process.env[key]);
|
|
1547
|
+
process.env.GIT_AUTHOR_NAME = process.env.GIT_COMMITTER_NAME = "a person";
|
|
1548
|
+
process.env.GIT_AUTHOR_EMAIL = process.env.GIT_COMMITTER_EMAIL = "person@example.com";
|
|
1549
|
+
const git = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
|
1550
|
+
const failed = (error: Error) => error.message;
|
|
1551
|
+
|
|
1552
|
+
// The repo the agents are written in, with somebody's work in progress in it.
|
|
1553
|
+
const repo = realpathSync(await mkdtemp(join(tmpdir(), "chloe-history-")));
|
|
1554
|
+
const folder = join(repo, "agents", "keeper");
|
|
1555
|
+
await makeDir(join(folder, "jobs"), { recursive: true });
|
|
1556
|
+
await makeDir(join(folder, "skills"), { recursive: true });
|
|
1557
|
+
await put(join(folder, "instructions.md"), "Be brief.");
|
|
1558
|
+
await put(join(folder, "PERMISSIONS.md"), "| deploy | no |");
|
|
1559
|
+
await put(join(folder, "skills", "deploys.md"), "---\nname: deploys\ndescription: how\n---\nDo it.");
|
|
1560
|
+
await put(join(folder, "jobs", "build.ts"), "// a job made of code");
|
|
1561
|
+
await put(join(folder, "jobs", "build.md"), "The words of that job.");
|
|
1562
|
+
await put(join(folder, "jobs", "weekly.md"), "---\ncron: 0 8 * * 1\n---\nLook back.");
|
|
1563
|
+
git(repo, "init", "-q", "-b", "main");
|
|
1564
|
+
git(repo, "add", "-A");
|
|
1565
|
+
git(repo, "commit", "-q", "-m", "the agent as a person wrote it");
|
|
1566
|
+
await put(join(repo, "half-done.ts"), "work in progress");
|
|
1567
|
+
|
|
1568
|
+
// The memories start keeping history as the agents load: one repository,
|
|
1569
|
+
// beside the agents rather than inside any of their folders, a folder each.
|
|
1570
|
+
const memories = join(repo, "memory");
|
|
1571
|
+
const memory = join(memories, "keeper");
|
|
1572
|
+
const theirs = join(memories, "other");
|
|
1573
|
+
await makeDir(memory, { recursive: true });
|
|
1574
|
+
await makeDir(theirs, { recursive: true });
|
|
1575
|
+
await put(join(memory, "STATUS.md"), "old news");
|
|
1576
|
+
await makeRepo(memories, "keeper");
|
|
1577
|
+
is("the memories are one repository of their own", git(memory, "rev-parse", "--show-toplevel"), memories);
|
|
1578
|
+
is(
|
|
1579
|
+
"holding what was already there, under the agent's name",
|
|
1580
|
+
git(memories, "log", "--format=%an: %s"),
|
|
1581
|
+
"keeper: What was here when this folder started keeping its history",
|
|
1582
|
+
);
|
|
1583
|
+
is("and the repository around them leaves them alone", git(repo, "status", "--porcelain"), "?? half-done.ts");
|
|
1584
|
+
|
|
1585
|
+
// Somebody changes it by hand, then a run changes it.
|
|
1586
|
+
await put(join(memory, "notes.md"), "written by hand");
|
|
1587
|
+
const status = codeJob(
|
|
1588
|
+
"status",
|
|
1589
|
+
async ({ step, memory: at }) => step("write", () => put(join(at, "STATUS.md"), "new news").then(() => "done")),
|
|
1590
|
+
undefined,
|
|
1591
|
+
() => "wrote the status",
|
|
1592
|
+
);
|
|
1593
|
+
const keeper: Agent = { ...agentFor(status), name: "keeper", folder, memory: { folder: memory, commit: "each run" } };
|
|
1594
|
+
const ran = await work({ agent: keeper, job: status });
|
|
1595
|
+
is(
|
|
1596
|
+
"a change made outside a run is committed before it, under this box's git name",
|
|
1597
|
+
git(memory, "log", "-1", "--skip=1", "--format=%an: %s"),
|
|
1598
|
+
"a person: Changed outside a run",
|
|
1599
|
+
);
|
|
1600
|
+
is(
|
|
1601
|
+
"and what the run changed is committed after it, under the agent's name",
|
|
1602
|
+
git(memory, "log", "-1", "--format=%an <%ae>: %s"),
|
|
1603
|
+
"keeper <>: status: wrote the status",
|
|
1604
|
+
);
|
|
1605
|
+
is("ending with the run it came from", git(memory, "log", "-1", "--format=%b"), `Run: ${ran.runId}`);
|
|
1606
|
+
is(
|
|
1607
|
+
"which lists it",
|
|
1608
|
+
JSON.parse(row(ran.runId).commits).map((one: { in: string; subject: string }) => [one.in, one.subject]),
|
|
1609
|
+
[["memory", "status: wrote the status"]],
|
|
1610
|
+
);
|
|
1611
|
+
const quiet = await work({ agent: keeper, job: codeJob("nothing", async () => "ok") });
|
|
1612
|
+
is("a run that changed nothing makes no commit", row(quiet.runId).commits, null);
|
|
1613
|
+
|
|
1614
|
+
// The folder beside it is another agent's memory, in the same repository.
|
|
1615
|
+
await put(join(theirs, "journal.md"), "nobody has committed this");
|
|
1616
|
+
await work({ agent: keeper, job: status });
|
|
1617
|
+
is(
|
|
1618
|
+
"a run commits its own memory and leaves the one beside it alone",
|
|
1619
|
+
git(memories, "status", "--porcelain", "-uall"),
|
|
1620
|
+
"?? other/journal.md",
|
|
1621
|
+
);
|
|
1622
|
+
|
|
1623
|
+
// What it may change of its own folder.
|
|
1624
|
+
const home = { name: "keeper", folder, memory: { folder: memory } };
|
|
1625
|
+
const rules = { files: ["md", "txt", "json"], except: ["PERMISSIONS.md"] };
|
|
1626
|
+
const may = (path: string) => whyNot(home, rules, path) ?? "yes";
|
|
1627
|
+
is("it may change its instructions", may("instructions.md"), "yes");
|
|
1628
|
+
is("and a skill", may("skills/deploys.md"), "yes");
|
|
1629
|
+
is("but not what is kept back for a person", may("PERMISSIONS.md"), "it is kept back for a person to change");
|
|
1630
|
+
is("nor anything in a folder of code", may("tools/check.md"), "tools/ is code");
|
|
1631
|
+
is("nor a file of code", may("jobs/build.ts"), "it is code");
|
|
1632
|
+
is("nor an ending it was not given", may("notes.html"), "only files ending in .md, .txt, .json can be written");
|
|
1633
|
+
is(
|
|
1634
|
+
"nor its memory, which has tools of its own, when somebody keeps that inside its folder",
|
|
1635
|
+
whyNot({ ...home, memory: { folder: join(folder, "memory") } }, rules, "memory/STATUS.md") ?? "yes",
|
|
1636
|
+
"that is your memory, which you write with write_notes",
|
|
1637
|
+
);
|
|
1638
|
+
is("nor what marks its runs", may("evals/status.json"), "evals/ is how your runs are marked");
|
|
1639
|
+
is("nor a file no loader would read", may("skills/deploys/SKILL.md"), "a file in a folder inside skills/ is never read");
|
|
1640
|
+
is(
|
|
1641
|
+
"nor a skill that is not markdown",
|
|
1642
|
+
may("skills/deploys.txt"),
|
|
1643
|
+
"a skill is one markdown file, and anything else in skills/ is never read",
|
|
1644
|
+
);
|
|
1645
|
+
is("nor anything outside its folder", await Promise.resolve().then(() => may("../other/x.md")).catch(failed), `Path is outside ${folder}: ../other/x.md`);
|
|
1646
|
+
|
|
1647
|
+
const wrote = (path: string, content: string, message = "a change worth making") =>
|
|
1648
|
+
writeOwn(home, rules, path, content, message).then((done) => done.commit ?? "not committed", failed);
|
|
1649
|
+
is(
|
|
1650
|
+
"a job that would not load is refused",
|
|
1651
|
+
await wrote("jobs/weekly.md", "---\ncron: every monday\n---\nLook back."),
|
|
1652
|
+
'jobs/weekly.md would not load as a job: its cron line does not read: A cron line needs five fields, got 2: "every monday".',
|
|
1653
|
+
);
|
|
1654
|
+
is(
|
|
1655
|
+
"so is a setting no job reads",
|
|
1656
|
+
await wrote("jobs/weekly.md", "---\nretries: 3\n---\nLook back."),
|
|
1657
|
+
"jobs/weekly.md would not load as a job: it has retries at the top, and a job only reads cron, description, timezone, model.",
|
|
1658
|
+
);
|
|
1659
|
+
is(
|
|
1660
|
+
"and a job made to run more than once an hour",
|
|
1661
|
+
await wrote("jobs/weekly.md", "---\ncron: */5 * * * *\n---\nLook back."),
|
|
1662
|
+
'A job you write runs at most once an hour: give its cron line one minute, like "0 7 * * *".',
|
|
1663
|
+
);
|
|
1664
|
+
is("and JSON that does not parse", (await wrote("targets.json", "{ nope")).startsWith("targets.json is not valid JSON"), true);
|
|
1665
|
+
is("and a file with nothing in it", await wrote("instructions.md", " \n"), "instructions.md would be empty. Write what it should say.");
|
|
1666
|
+
const made = await wrote("jobs/weekly.md", "---\ncron: 0 9 * * 1\ntimezone: America/New_York\n---\nLook back at the week.");
|
|
1667
|
+
is("a job that loads is written and committed", /^[0-9a-f]{12}$/.test(made), true);
|
|
1668
|
+
is("under the agent's name, with its message", git(repo, "log", "-1", "--format=%an: %s"), "keeper: a change worth making");
|
|
1669
|
+
is("and nothing of anybody else's went with it", git(repo, "status", "--porcelain"), "?? half-done.ts");
|
|
1670
|
+
is(
|
|
1671
|
+
"writing it does not make it a job, because jobs are named in agent.ts",
|
|
1672
|
+
(await jobsOf("keeper", folder, [])).map((one) => one.id),
|
|
1673
|
+
[],
|
|
1674
|
+
);
|
|
1675
|
+
is(
|
|
1676
|
+
"naming it makes it one, and the words of a job made of code are not a job",
|
|
1677
|
+
(await jobsOf("keeper", folder, [markdownJob("jobs/weekly.md")])).map((one) => [one.id, one.cron]),
|
|
1678
|
+
[["weekly", "0 9 * * 1"]],
|
|
1679
|
+
);
|
|
1680
|
+
is(
|
|
1681
|
+
"and a job that is not there yet is refused, because only a person can name it",
|
|
1682
|
+
await wrote("jobs/monthly.md", "---\ncron: 0 9 1 * *\n---\nLook back further."),
|
|
1683
|
+
"jobs/monthly.md would be a new job, and a job only runs once it is named in agent.ts, which only a person can change. " +
|
|
1684
|
+
"Ask for it, and change a job that is already there meanwhile.",
|
|
1685
|
+
);
|
|
1686
|
+
await put(join(folder, "instructions.md"), "Be brief, and say so.");
|
|
1687
|
+
is(
|
|
1688
|
+
"a file somebody is in the middle of changing is left alone",
|
|
1689
|
+
await wrote("instructions.md", "Be long."),
|
|
1690
|
+
"instructions.md has changes nobody has committed yet, and writing it would put them under your name. " +
|
|
1691
|
+
"Leave it for now, and say that you could not change it and why.",
|
|
1692
|
+
);
|
|
1693
|
+
git(repo, "checkout", "-q", "--", "agents/keeper/instructions.md");
|
|
1694
|
+
|
|
1695
|
+
// A change made during a run belongs to that run.
|
|
1696
|
+
const improve = codeJob("improve", async ({ step }) =>
|
|
1697
|
+
step("rewrite the skill", () =>
|
|
1698
|
+
writeOwn(home, rules, "skills/deploys.md", "---\nname: deploys\ndescription: how\n---\nShip it small.", "the skill says how to ship"),
|
|
1699
|
+
).then(() => "ok"),
|
|
1700
|
+
);
|
|
1701
|
+
const improved = await work({ agent: keeper, job: improve });
|
|
1702
|
+
is(
|
|
1703
|
+
"a change made during a run is listed on that run",
|
|
1704
|
+
JSON.parse(row(improved.runId).commits).map((one: { in: string; subject: string }) => [one.in, one.subject]),
|
|
1705
|
+
[["folder", "the skill says how to ship"]],
|
|
1706
|
+
);
|
|
1707
|
+
is("and says which run it came from", git(repo, "log", "-1", "--format=%b"), `Run: ${improved.runId}`);
|
|
1708
|
+
|
|
1709
|
+
// The site's view of it.
|
|
1710
|
+
const all = await agentChanges(keeper, {}, "test");
|
|
1711
|
+
is(
|
|
1712
|
+
"both places are listed",
|
|
1713
|
+
all.changes.map((one) => `${one.in} ${one.by}: ${one.subject}`).sort(),
|
|
1714
|
+
[
|
|
1715
|
+
"folder a person: the agent as a person wrote it",
|
|
1716
|
+
"folder keeper: a change worth making",
|
|
1717
|
+
"folder keeper: the skill says how to ship",
|
|
1718
|
+
"memory a person: Changed outside a run",
|
|
1719
|
+
"memory keeper: What was here when this folder started keeping its history",
|
|
1720
|
+
"memory keeper: status: wrote the status",
|
|
1721
|
+
],
|
|
1722
|
+
);
|
|
1723
|
+
is("what the agent did is new until somebody looks", all.unseen, 4);
|
|
1724
|
+
markSeen("keeper");
|
|
1725
|
+
is("and then it is not", (await agentChanges(keeper, {}, "test")).unseen, 0);
|
|
1726
|
+
const history = (await agentChanges(keeper, { place: "memory", path: "STATUS.md" }, "test")).changes;
|
|
1727
|
+
is(
|
|
1728
|
+
"one file's history is the commits that touched it, newest first",
|
|
1729
|
+
history.map((one) => one.subject),
|
|
1730
|
+
["status: wrote the status", "What was here when this folder started keeping its history"],
|
|
1731
|
+
);
|
|
1732
|
+
const skill = all.changes.find((one) => one.subject === "the skill says how to ship")!;
|
|
1733
|
+
const shown = await change(keeper, "folder", skill.id);
|
|
1734
|
+
is(
|
|
1735
|
+
"one change comes with its diff, and its paths as the agent's folder sees them",
|
|
1736
|
+
[shown?.files, shown?.diff.includes("+Ship it small.")],
|
|
1737
|
+
[[{ path: "skills/deploys.md", status: "M" }], true],
|
|
1738
|
+
);
|
|
1739
|
+
const undone = await undo(keeper, "folder", skill.id);
|
|
1740
|
+
is("undoing it puts the file back", await get(join(folder, "skills", "deploys.md"), "utf8"), "---\nname: deploys\ndescription: how\n---\nDo it.");
|
|
1741
|
+
is("and commits that under this box's git name", git(repo, "log", "-1", "--format=%an: %s"), 'a person: Undo "the skill says how to ship"');
|
|
1742
|
+
is("saying which files", undone.files, ["skills/deploys.md"]);
|
|
1743
|
+
await work({
|
|
1744
|
+
agent: keeper,
|
|
1745
|
+
job: codeJob("again", async ({ step, memory: at }) => step("write", () => put(join(at, "STATUS.md"), "newer news").then(() => "done"))),
|
|
1746
|
+
});
|
|
1747
|
+
is(
|
|
1748
|
+
"a change to a file that has changed since cannot be undone",
|
|
1749
|
+
await undo(keeper, "memory", history[0].id).then(() => "undone", failed),
|
|
1750
|
+
"STATUS.md has changed since, so undoing this would lose that change. Undo the later change first.",
|
|
1751
|
+
);
|
|
1752
|
+
is(
|
|
1753
|
+
"nor can the first commit",
|
|
1754
|
+
await undo(keeper, "memory", history[1].id).then(() => "undone", failed),
|
|
1755
|
+
"This is the first commit there is, so there is nothing before it to go back to.",
|
|
1756
|
+
);
|
|
1757
|
+
|
|
1758
|
+
// Seen from the site and from its scripts.
|
|
1759
|
+
await makeDir(join(folder, "scripts"), { recursive: true });
|
|
1760
|
+
await put(join(folder, "scripts", "where.sh"), '#!/bin/sh\nprintf %s "$MEMORY_FOLDER"\n');
|
|
1761
|
+
await chmod(join(folder, "scripts", "where.sh"), 0o755);
|
|
1762
|
+
setAgentDirs(new Map([["keeper", folder]]), new Map([["keeper", memory]]));
|
|
1763
|
+
is(
|
|
1764
|
+
"the site's view of an agent's folder leaves its memory out",
|
|
1765
|
+
(await tree("keeper")).map((one) => one.name),
|
|
1766
|
+
["jobs", "scripts", "skills", "instructions.md", "PERMISSIONS.md"],
|
|
1767
|
+
);
|
|
1768
|
+
is("and will not open a file in it", await open("keeper", "memory/STATUS.md"), null);
|
|
1769
|
+
is("a script is told where its agent's memory is", (await runScripts("keeper", "where.sh")).stdout, memory);
|
|
1770
|
+
await loadAll();
|
|
1771
|
+
|
|
1772
|
+
identity.forEach((key, i) => {
|
|
1773
|
+
if (before[i] === undefined) delete process.env[key];
|
|
1774
|
+
else process.env[key] = before[i];
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
{
|
|
1779
|
+
about("an email written in Markdown");
|
|
1780
|
+
|
|
1781
|
+
const { markdownToHtml, markdownToText } = await import("@chloejs/core/services");
|
|
1782
|
+
const body = "## Today\n\nOne line\nwrapped here.\n\n- a **bold** item\n- [a link](https://example.com)\n\n| day | visits |\n|---|---|\n| Mon | 3 |";
|
|
1783
|
+
const html = markdownToHtml(body);
|
|
1784
|
+
is("lines next to each other are one paragraph", html.includes("<p style='margin:0 0 14px'>One line wrapped here.</p>"), true);
|
|
1785
|
+
is("a heading is a heading", html.includes(">Today</div>"), true);
|
|
1786
|
+
is("a list is a list", (html.match(/<li /g) ?? []).length, 2);
|
|
1787
|
+
is("a table keeps its rows and drops the rule", (html.match(/<tr>/g) ?? []).length, 2);
|
|
1788
|
+
is("what a person typed is escaped", markdownToHtml("<b>hi</b>").includes("<b>"), true);
|
|
1789
|
+
is("a link that is not a web or mail address is only words", markdownToHtml("[go](javascript:alert(1))").includes("<a"), false);
|
|
1790
|
+
is("a quote cannot end the address early", markdownToHtml("[go](https://x.com/'onmouseover='y)").includes("href='https://x.com/'onmouseover='y'"), true);
|
|
1791
|
+
is(
|
|
1792
|
+
"the plain copy has no symbols in it",
|
|
1793
|
+
markdownToText(body),
|
|
1794
|
+
"Today\n\nOne line\nwrapped here.\n\n- a bold item\n- a link (https://example.com)\n\n day: visits\n Mon: 3",
|
|
1795
|
+
);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
{
|
|
1799
|
+
about("a provider that carries nothing");
|
|
1800
|
+
|
|
1801
|
+
const { sendEmail } = await import("@chloejs/core/services");
|
|
1802
|
+
const sent = await sendEmail({ from: "a@example.com", to: ["b@example.com"], tag: "test" }, "Hello", "A body.");
|
|
1803
|
+
is("it says it was sent, with the tag in front", [sent.sent, sent.subject], [true, "[test] Hello"]);
|
|
1804
|
+
is("and it has no id, because nothing carried it", sent.id, undefined);
|
|
1805
|
+
|
|
1806
|
+
const nobody = await sendEmail({ from: "a@example.com", to: [] }, "Hello", "A body.").catch((error: Error) => error.message);
|
|
1807
|
+
is("nobody to send to is refused rather than dropped", nobody, "Nobody to send to. Give the sender at least one address in to.");
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1393
1810
|
{
|
|
1394
1811
|
about("reading a web page");
|
|
1395
1812
|
|
|
@@ -1440,7 +1857,7 @@ for (const agent of (await (await import("@chloejs/core")).loadAll()).values())
|
|
|
1440
1857
|
{
|
|
1441
1858
|
about("the login in front of the page");
|
|
1442
1859
|
|
|
1443
|
-
const { createAccount, hasAccount, setCookie, signIn, signedIn } = await import("#chloe/serve/login.ts");
|
|
1860
|
+
const { covers, createAccount, hasAccount, setCookie, shareLogin, signIn, signedIn } = await import("#chloe/serve/login.ts");
|
|
1444
1861
|
const carrying = (cookie: string) => ({ headers: { cookie } }) as import("node:http").IncomingMessage;
|
|
1445
1862
|
|
|
1446
1863
|
is("a fresh copy has no account", hasAccount(), false);
|
|
@@ -1461,7 +1878,22 @@ for (const agent of (await (await import("@chloejs/core")).loadAll()).values())
|
|
|
1461
1878
|
is("the right password signs in", signedIn(carrying(`chloe_session=${session}`)), true);
|
|
1462
1879
|
is("a cookie somebody edited does not", signedIn(carrying(`chloe_session=${session.slice(0, -1)}x`)), false);
|
|
1463
1880
|
is("no cookie does not", signedIn(carrying("")), false);
|
|
1464
|
-
is("signing out clears it", setCookie("", true).includes("Max-Age=0"), true);
|
|
1881
|
+
is("signing out clears it", setCookie("", true)[0].includes("Max-Age=0"), true);
|
|
1882
|
+
shareLogin(undefined);
|
|
1883
|
+
is("with no domain the cookie is this site's alone", setCookie(session, true)[0].includes("Domain="), false);
|
|
1884
|
+
is("and nowhere else is somewhere to send somebody back to", covers("https://example.com/"), false);
|
|
1885
|
+
|
|
1886
|
+
shareLogin("example.com");
|
|
1887
|
+
is("with one, the cookie covers every site under it", setCookie(session, true)[0].includes("Domain=example.com"), true);
|
|
1888
|
+
is("and signing out ends the old one too", setCookie("", true).length, 2);
|
|
1889
|
+
is("a site under it is somewhere to go back to", covers("https://money.example.com/x?y=1"), true);
|
|
1890
|
+
is("and so is the name itself", covers("https://example.com/"), true);
|
|
1891
|
+
is("but not over plain http", covers("http://money.example.com/"), false);
|
|
1892
|
+
is("nor a name that only ends the same way", covers("https://badexample.com/"), false);
|
|
1893
|
+
is("nor one that only starts the same way", covers("https://example.com.evil.net/"), false);
|
|
1894
|
+
is("nor something that is not an address", covers("/elsewhere"), false);
|
|
1895
|
+
is("a name that is not one is refused", (() => { try { shareLogin("not a name"); return "taken"; } catch { return "refused"; } })(), "refused");
|
|
1896
|
+
shareLogin(undefined);
|
|
1465
1897
|
|
|
1466
1898
|
// The same value said the other way, for a caller that is not a browser.
|
|
1467
1899
|
const bearing = (authorization: string) => ({ headers: { authorization } }) as import("node:http").IncomingMessage;
|
|
@@ -1753,6 +2185,7 @@ for (const agent of (await (await import("@chloejs/core")).loadAll()).values())
|
|
|
1753
2185
|
const { serve } = await import("#chloe/serve/http.ts");
|
|
1754
2186
|
const { makeToken, forgetTokens } = await import("#chloe/serve/tokens.ts");
|
|
1755
2187
|
const { memoryFolder } = await import("#chloe/load/load.ts");
|
|
2188
|
+
const { MEMORIES } = await import("#chloe/core/paths.ts");
|
|
1756
2189
|
|
|
1757
2190
|
const folder = `${process.env.AGENTS_STATE}/memory-under-test`;
|
|
1758
2191
|
await makeDir(`${folder}/01_projects`, { recursive: true });
|
|
@@ -1769,9 +2202,8 @@ for (const agent of (await (await import("@chloejs/core")).loadAll()).values())
|
|
|
1769
2202
|
await put(`${folder}/secrets/key.txt`, "never shown");
|
|
1770
2203
|
process.env.CHLOE_PAGE = "builtin";
|
|
1771
2204
|
|
|
1772
|
-
// Every agent has a memory. Unsaid, it is the agent's own
|
|
1773
|
-
|
|
1774
|
-
is("unsaid, an agent's memory is its own state folder", memoryFolder("tempo"), `${process.env.AGENTS_STATE}/tempo`);
|
|
2205
|
+
// Every agent has a memory. Unsaid, it is memory/ in the agent's own folder.
|
|
2206
|
+
is("unsaid, an agent's memory is its own folder in memory/", memoryFolder("tempo"), `${MEMORIES}/tempo`);
|
|
1775
2207
|
is("said, it is wherever the agent says", memoryFolder("chloe", { folder: "/somewhere" }), "/somewhere");
|
|
1776
2208
|
|
|
1777
2209
|
const keeper: Agent = { ...agentFor(codeJob("unused", async () => ({}))), memory: { folder, label: "Private" } };
|
|
@@ -1822,6 +2254,7 @@ for (const agent of (await (await import("@chloejs/core")).loadAll()).values())
|
|
|
1822
2254
|
const { secret } = makeToken("for a test");
|
|
1823
2255
|
is("a token cannot read a memory at all", (await fetch(`${at}/api/agents/test/memory`, { headers: { authorization: `Bearer ${secret}` } })).status, 403);
|
|
1824
2256
|
is("nor get a pass to one", (await fetch(`${at}/api/agents/test/memory/pass`, { headers: { authorization: `Bearer ${secret}` } })).status, 403);
|
|
2257
|
+
is("nor read what changed in it", (await fetch(`${at}/api/agents/test/changes`, { headers: { authorization: `Bearer ${secret}` } })).status, 403);
|
|
1825
2258
|
|
|
1826
2259
|
// The frame. This is the part the whole viewer's safety rests on.
|
|
1827
2260
|
const { at: under } = (await (await fetch(`${at}/api/agents/test/memory/pass`, { headers: as })).json()) as { at: string };
|