@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/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 or the real gateway. The database is in memory and
6
- // the gateway is a server on a loopback port that answers whatever the case
7
- // says, so a model step is exercised without spending anything.
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
- // A folder of its own, so a case that writes state (an account, a note) cannot
15
- // land in the real one. Set before any import, like the database above.
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 { STATE, note } = await import("@chloejs/core");
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(STATE, "test-note"))).filter((f) => f.endsWith(".part"));
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(STATE, "test-note"), { recursive: true, force: true });
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&amp;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 &lt; 2 &amp; 3 &gt; 2");
955
+ is("nothing inside code is read as formatting", telegramHtml("`**not bold** <b>`"), "<code>**not bold** &lt;b&gt;</code>");
956
+ is("a code block keeps its lines", telegramHtml("```\na < b\n c\n```"), "<pre>a &lt; 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
- calls.push({ method, body: JSON.parse(raw || "{}"), token: request.url!.split("/")[1].slice(3) });
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("&lt;b&gt;"), 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/&#39;onmouseover=&#39;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 state folder,
1773
- // which is where the memory tool has always written.
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chloejs/core",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "A job runner where asking a model is one kind of step.",
6
6
  "exports": {