@chloejs/core 0.2.3 → 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.
@@ -0,0 +1,61 @@
1
+ // An agent's changes, for the site: what changed in its memory and in its own
2
+ // folder, one change in full, undoing one, and when somebody last looked.
3
+ //
4
+ // A change in a memory shows what that memory held, so it is written to the
5
+ // agent's audit log before it is served, like any other read of a memory, and
6
+ // these routes never take a token.
7
+ import type { Agent } from "#chloe/load/load.ts";
8
+ import { change, changes, markSeen, seenAt, undo, type Change, type Place } from "#chloe/services/historyService.ts";
9
+ import { BadRequest, NotFound } from "./errors.ts";
10
+ import { record } from "./memory.ts";
11
+
12
+ /** A place as the page names it, or a 400. */
13
+ export function placeOf(value: string | null | undefined): Place | undefined {
14
+ if (value === null || value === undefined || value === "") return undefined;
15
+ if (value === "memory" || value === "folder") return value;
16
+ throw new BadRequest(`in is "memory" or "folder", not ${JSON.stringify(value)}.`);
17
+ }
18
+
19
+ /**
20
+ * The agent's changes, newest first, with the ones it made since somebody last
21
+ * looked marked `new`. `path` narrows it to one file's history.
22
+ */
23
+ export async function agentChanges(
24
+ agent: Agent,
25
+ { place, path, limit }: { place?: Place; path?: string; limit?: number },
26
+ from: string,
27
+ ) {
28
+ const found = await changes(agent, { place, path, limit });
29
+ if (found.some((one) => one.in === "memory")) await record(agent, "history", path ?? "/", from);
30
+ const seen = seenAt(agent.name);
31
+ const marked = found.map((one) => ({ ...one, new: isNew(agent, one, seen) }));
32
+ return { seen: seen ?? null, unseen: marked.filter((one) => one.new).length, changes: marked };
33
+ }
34
+
35
+ function isNew(agent: Agent, one: Change, seen: string | undefined): boolean {
36
+ return one.by === agent.name && (!seen || one.at > seen);
37
+ }
38
+
39
+ /** One change with its diff. */
40
+ export async function agentChange(agent: Agent, place: Place, id: string, from: string) {
41
+ const found = await change(agent, place, id);
42
+ if (!found) throw new NotFound("There is no such change.");
43
+ if (place === "memory") await record(agent, "read", `change ${found.id}`, from, { bytes: found.diff.length });
44
+ return { ...found, new: isNew(agent, found, seenAt(agent.name)) };
45
+ }
46
+
47
+ /** Puts back what one change changed, and commits that. */
48
+ export async function agentUndo(agent: Agent, place: Place, id: string, from: string) {
49
+ try {
50
+ const undone = await undo(agent, place, id);
51
+ if (place === "memory") await record(agent, "undo", undone.files.join(", "), from, { change: id, commit: undone.id });
52
+ return undone;
53
+ } catch (error) {
54
+ throw new BadRequest(error instanceof Error ? error.message : String(error));
55
+ }
56
+ }
57
+
58
+ /** Everything up to now has been looked at. */
59
+ export function agentSeen(agent: Agent) {
60
+ return { seen: markSeen(agent.name) };
61
+ }
package/serve/files.ts CHANGED
@@ -5,12 +5,17 @@
5
5
  // a browser would be live just as fast with nothing type checking it, so code
6
6
  // is edited where `npm run check` runs.
7
7
  //
8
+ // The agent's memory is never reached from here, even when it is a folder
9
+ // inside this one: a token may read these routes, and every read of a memory
10
+ // is recorded first, by serve/memory.ts and nothing else.
11
+ //
8
12
  // The edge of the folder is confine()'s job, inside the calls below: a path
9
13
  // from the page is as untrusted as a path from a model.
10
- import { existsSync, statSync } from "node:fs";
14
+ import { existsSync, realpathSync, statSync } from "node:fs";
15
+ import { join, sep } from "node:path";
11
16
 
12
17
  import { confine } from "#chloe/core/confine.ts";
13
- import { agentDir } from "#chloe/core/paths.ts";
18
+ import { agentDir, memoryDir } from "#chloe/core/paths.ts";
14
19
  import { listFiles, readFiles, writeFiles } from "#chloe/services/filesService.ts";
15
20
 
16
21
  export interface Entry {
@@ -26,6 +31,20 @@ const DEPTH = 6;
26
31
  /** Made by a program, not by anybody, so it is not part of what an agent is. */
27
32
  const JUNK = ["__pycache__", "node_modules"];
28
33
 
34
+ /** Whether a path is the agent's memory or inside it, however either is reached. */
35
+ function inMemory(agent: string, path: string): boolean {
36
+ const memory = memoryDir(agent);
37
+ if (!memory || !existsSync(memory)) return false;
38
+ const real = realpathSync(memory);
39
+ let resolved = path;
40
+ try {
41
+ resolved = realpathSync(path);
42
+ } catch {
43
+ // Not there yet, or a broken link: compared as written.
44
+ }
45
+ return resolved === real || resolved.startsWith(real + sep);
46
+ }
47
+
29
48
  /** Everything in one agent's folder, folders first, as a tree. */
30
49
  export async function tree(agent: string, path = "", depth = 0): Promise<Entry[]> {
31
50
  const { entries } = await listFiles(agentDir(agent), path || undefined);
@@ -35,6 +54,7 @@ export async function tree(agent: string, path = "", depth = 0): Promise<Entry[]
35
54
  const name = dir ? entry.slice(0, -1) : entry;
36
55
  if (JUNK.includes(name)) continue;
37
56
  const at = path ? `${path}/${name}` : name;
57
+ if (inMemory(agent, join(agentDir(agent), at))) continue;
38
58
  out.push({
39
59
  name,
40
60
  path: at,
@@ -51,11 +71,12 @@ export const editable = (path: string): boolean => path.endsWith(".md");
51
71
  /**
52
72
  * One thing in the folder, whichever kind it is. Every path in the tree is an
53
73
  * address, so a folder answers with what is in it rather than with an error.
54
- * Nothing there is `null`, which the route turns into a 404.
74
+ * Nothing there is `null`, which the route turns into a 404, and so is the
75
+ * agent's memory.
55
76
  */
56
77
  export async function open(agent: string, path: string) {
57
78
  const resolved = confine(agentDir(agent), path);
58
- if (!existsSync(resolved)) return null;
79
+ if (!existsSync(resolved) || inMemory(agent, resolved)) return null;
59
80
  if (statSync(resolved).isDirectory()) {
60
81
  return { path, dir: true as const, entries: await tree(agent, path) };
61
82
  }
@@ -65,6 +86,7 @@ export async function open(agent: string, path: string) {
65
86
 
66
87
  export async function save(agent: string, path: string, content: string) {
67
88
  if (!editable(path)) throw new Error(`${path} is not markdown.`);
89
+ if (inMemory(agent, confine(agentDir(agent), path))) throw new Error(`${path} is in the agent's memory.`);
68
90
  const written = await writeFiles(agentDir(agent), path, content);
69
91
  return { path, bytes: written.bytes };
70
92
  }
package/serve/http.ts CHANGED
@@ -13,6 +13,7 @@ import { db } from "#chloe/core/db.ts";
13
13
  import { hasChannel, type Agent, type ChannelRoute, type Job } from "#chloe/load/load.ts";
14
14
  import type { Clock } from "#chloe/core/clock.ts";
15
15
  import { forget, recall } from "#chloe/model/memory.ts";
16
+ import { agentChange, agentChanges, agentSeen, agentUndo, placeOf } from "./changes.ts";
16
17
  import { editable, open, save, tree } from "./files.ts";
17
18
  import {
18
19
  memoryCommit,
@@ -32,7 +33,7 @@ import { checkPass, makePass } from "./pass.ts";
32
33
  import { BadRequest, NotFound } from "./errors.ts";
33
34
  import { recentWork } from "./recentWork.ts";
34
35
  import { describe } from "#chloe/timer/every.ts";
35
- import { type Caller, caller, createAccount, from, hasAccount, overHttps, setCookie, signIn } from "./login.ts";
36
+ import { type Caller, caller, covers, createAccount, from, hasAccount, overHttps, renew, setCookie, signIn } from "./login.ts";
36
37
  import { makeToken, revokeToken, tokens } from "./tokens.ts";
37
38
  import { signedInFrom } from "./alerts.ts";
38
39
  import { docsPage, type RouteDoc, sitePage } from "./site.ts";
@@ -148,7 +149,7 @@ export const routes: Route[] = [
148
149
  },
149
150
  },
150
151
 
151
- // The ways in. These four are the whole of what is answered without a
152
+ // The ways in. These five are the whole of what is answered without a
152
153
  // session, and each says as little as it can.
153
154
  {
154
155
  method: "GET",
@@ -183,6 +184,30 @@ export const routes: Route[] = [
183
184
  json(response, { ok: true });
184
185
  },
185
186
  },
187
+ {
188
+ method: "GET",
189
+ path: "/api/check",
190
+ does: "204 when the account is signed in, 401 when not. For a proxy in front of another site under the same login.",
191
+ handle: ({ response }) => {
192
+ response.writeHead(204).end();
193
+ },
194
+ },
195
+ {
196
+ method: "GET",
197
+ path: "/api/back",
198
+ does: "Sends a signed-in browser back to ?to=, an https address under login.domain in chloe.config.ts. Anybody else goes to the sign-in page first.",
199
+ takes: "?to=https://...",
200
+ open: true,
201
+ handle: ({ request, response, url }) => {
202
+ const to = url.searchParams.get("to") ?? "";
203
+ if (!covers(to)) return void response.writeHead(302, { location: "/" }).end();
204
+ if (caller(request)?.kind !== "account") {
205
+ return void response.writeHead(302, { location: `/login?back=${encodeURIComponent(to)}` }).end();
206
+ }
207
+ response.setHeader("set-cookie", setCookie(renew(), overHttps(request)));
208
+ response.writeHead(302, { location: to }).end();
209
+ },
210
+ },
186
211
 
187
212
  // Reading. A token may do all of this.
188
213
  {
@@ -275,12 +300,12 @@ export const routes: Route[] = [
275
300
  {
276
301
  method: "GET",
277
302
  path: "/api/runs/:id",
278
- does: "One run in full, with every step it took.",
303
+ does: "One run in full, with every step it took and the commits it made.",
279
304
  token: true,
280
305
  handle: ({ response, params }) => {
281
- const row = db.prepare("select * from runs where id = ?").get(params.id) as { trace: string } | undefined;
306
+ const row = db.prepare("select * from runs where id = ?").get(params.id) as { trace: string; commits: string | null } | undefined;
282
307
  if (!row) throw new NotFound("No run with that id.");
283
- json(response, { ...row, trace: JSON.parse(row.trace) });
308
+ json(response, { ...row, trace: JSON.parse(row.trace), commits: row.commits ? JSON.parse(row.commits) : [] });
284
309
  },
285
310
  },
286
311
  {
@@ -543,6 +568,53 @@ export const routes: Route[] = [
543
568
  handle: async ({ request, response, context, params }) =>
544
569
  json(response, await memoryPull(context.agent(params.name), from(request))),
545
570
  },
571
+
572
+ // What an agent changed: the commits in its memory and in its own folder.
573
+ // A memory's are recorded like any read of it, so none of these takes a token.
574
+ {
575
+ method: "GET",
576
+ path: "/api/agents/:name/changes",
577
+ does: "Commits to that agent's memory and own folder, newest first, the ones it made since somebody last looked marked new. Takes ?in=memory|folder, ?path= for one file's history, and ?limit=, at most 200.",
578
+ handle: async ({ request, response, context, params, url }) =>
579
+ json(
580
+ response,
581
+ await agentChanges(
582
+ context.agent(params.name),
583
+ {
584
+ place: placeOf(url.searchParams.get("in")),
585
+ path: url.searchParams.get("path") || undefined,
586
+ limit: Math.min(Number(url.searchParams.get("limit") ?? 50) || 50, 200),
587
+ },
588
+ from(request),
589
+ ),
590
+ ),
591
+ },
592
+ {
593
+ method: "GET",
594
+ path: "/api/agents/:name/changes/:id",
595
+ does: "One commit and its diff, cut to that agent's part of the repository. Takes ?in=memory|folder.",
596
+ handle: async ({ request, response, context, params, url }) => {
597
+ const place = placeOf(url.searchParams.get("in"));
598
+ if (!place) return json(response, { error: "Say which: ?in=memory or ?in=folder." }, 400);
599
+ json(response, await agentChange(context.agent(params.name), place, params.id, from(request)));
600
+ },
601
+ },
602
+ {
603
+ method: "POST",
604
+ path: "/api/agents/:name/changes/seen",
605
+ does: "Everything that agent has changed up to now has been looked at.",
606
+ handle: ({ response, context, params }) => json(response, agentSeen(context.agent(params.name))),
607
+ },
608
+ {
609
+ method: "POST",
610
+ path: "/api/agents/:name/changes/:id/undo",
611
+ does: "Put every file one commit changed back how it was, and commit that. Refused when a file has changed since.",
612
+ takes: '{"in": "memory"}',
613
+ handle: async ({ request, response, context, params }) => {
614
+ const { in: place } = await body(request, z.object({ in: z.enum(["memory", "folder"]) }));
615
+ json(response, await agentUndo(context.agent(params.name), place, params.id, from(request)));
616
+ },
617
+ },
546
618
  ];
547
619
 
548
620
  /** Signing in, and making the account the first time. One body, two doors. */
package/serve/login.ts CHANGED
@@ -145,10 +145,42 @@ function holds(value: string, held: Account): boolean {
145
145
  }
146
146
  }
147
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 {
148
+ /** The name the login covers along with every site under it, from chloe.config.ts. */
149
+ let domain: string | undefined;
150
+
151
+ export function shareLogin(name: string | undefined): void {
152
+ const clean = name?.trim().replace(/^\./, "").toLowerCase();
153
+ if (clean && !/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(clean)) throw new Error(`login.domain "${name}" is not a name like example.com.`);
154
+ domain = clean || undefined;
155
+ }
156
+
157
+ /**
158
+ * Whether an address is one this login covers, so it is safe to send somebody
159
+ * back there after they sign in. Anything else would let a link send them off
160
+ * to a stranger's page straight from this one.
161
+ */
162
+ export function covers(address: string): boolean {
163
+ if (!domain) return false;
164
+ try {
165
+ const url = new URL(address);
166
+ return url.protocol === "https:" && (url.hostname === domain || url.hostname.endsWith(`.${domain}`));
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * The Set-Cookie headers for a session, or for ending one when the value is
174
+ * empty. With a domain set, ending one also ends the cookie made before there
175
+ * was one, which only this site's own name carried.
176
+ */
177
+ export function setCookie(value: string, secure: boolean): string[] {
150
178
  const rest = `Path=/; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`;
151
- return value ? `${COOKIE}=${value}; Max-Age=${LASTS}; ${rest}` : `${COOKIE}=; Max-Age=0; ${rest}`;
179
+ const shared = domain ? `; Domain=${domain}` : "";
180
+ if (value) return [`${COOKIE}=${value}; Max-Age=${LASTS}; ${rest}${shared}`];
181
+ const ended = [`${COOKIE}=; Max-Age=0; ${rest}${shared}`];
182
+ if (domain) ended.push(`${COOKIE}=; Max-Age=0; ${rest}`);
183
+ return ended;
152
184
  }
153
185
 
154
186
  /**
@@ -162,6 +194,17 @@ export function ownCookie(): string {
162
194
  return `${COOKIE}=${sign(held)}`;
163
195
  }
164
196
 
197
+ /**
198
+ * A fresh session for somebody already signed in. Sending them back to another
199
+ * site gives them one, because a session made before login.domain was set only
200
+ * reached this site, and the other one would send them straight back here.
201
+ */
202
+ export function renew(): string {
203
+ const held = read();
204
+ if (!held) throw new Error("There is no account yet.");
205
+ return sign(held);
206
+ }
207
+
165
208
  /**
166
209
  * Something signed with the account's secret, for a purpose other than a
167
210
  * session. The purpose is part of what is signed, so a value made for one
package/serve/memory.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  // Not to be confused with model/memory.ts, which is the last few messages of a
4
4
  // conversation. This is a folder: the one an agent reads and writes between
5
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.
6
+ // memory/ inside that agent's own folder. An agent that shares a folder with a
7
+ // person, like one that keeps somebody's notes, says where.
8
8
  //
9
9
  // Every file served is written down first, and that is the point rather than a
10
10
  // detail. Reading these files from a shell is not recorded, because a shell on
@@ -15,13 +15,14 @@
15
15
  // refused instead.
16
16
  import { execFile } from "node:child_process";
17
17
  import { appendFile, mkdir, readFile, rename as move, rm, stat } from "node:fs/promises";
18
- import { existsSync, realpathSync, statSync } from "node:fs";
18
+ import { existsSync, statSync } from "node:fs";
19
19
  import { dirname, extname } from "node:path";
20
20
  import { promisify } from "node:util";
21
21
 
22
22
  import { confine, unreachable } from "#chloe/core/confine.ts";
23
23
  import type { Agent } from "#chloe/load/load.ts";
24
24
  import { listFiles, readFiles, writeFiles } from "#chloe/services/filesService.ts";
25
+ import { commitPaths, memoryRepo } from "#chloe/services/historyService.ts";
25
26
  import { STATE } from "#chloe/core/paths.ts";
26
27
  import { BadRequest } from "./errors.ts";
27
28
  import { noteHead } from "./page.ts";
@@ -50,9 +51,9 @@ export function memoryLabel(agent: Agent): string {
50
51
  }
51
52
 
52
53
  /**
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.
54
+ * In the state folder rather than inside the memory it records: a log inside
55
+ * the folder it logs would show up in its own tree, change it every time it
56
+ * was read, and go wherever the memory is pushed.
56
57
  */
57
58
  function logFor(agent: Agent): string {
58
59
  return `${STATE}/memory-audit/${agent.name}.jsonl`;
@@ -68,7 +69,7 @@ function folder(agent: Agent): string {
68
69
  */
69
70
  export async function record(
70
71
  agent: Agent,
71
- what: "read" | "write" | "list" | "serve" | "rename" | "delete" | "commit" | "push" | "pull",
72
+ what: "read" | "write" | "list" | "serve" | "rename" | "delete" | "commit" | "push" | "pull" | "history" | "undo",
72
73
  path: string,
73
74
  from: string,
74
75
  extra: Record<string, unknown> = {},
@@ -221,6 +222,16 @@ export async function memorySave(agent: Agent, path: string, content: string, fr
221
222
  return { path, bytes: written.bytes };
222
223
  }
223
224
 
225
+ /**
226
+ * A memory committed at the end of each run gets what is done to it from the
227
+ * site committed at once, under this box's own git name, so the next run's
228
+ * commit does not take it in under the agent's.
229
+ */
230
+ async function committedNow(agent: Agent, paths: string[], message: string): Promise<void> {
231
+ if (agent.memory.commit !== "each run" || !(await isRepo(agent))) return;
232
+ await commitPaths(folder(agent), paths, { message });
233
+ }
234
+
224
235
  /** Moves a file or a folder. Both ends have to be inside, and the new one must not exist. */
225
236
  export async function memoryRename(agent: Agent, from: string, to: string, who: string) {
226
237
  const here = inside(agent, from, who);
@@ -230,6 +241,7 @@ export async function memoryRename(agent: Agent, from: string, to: string, who:
230
241
  await mkdir(dirname(there), { recursive: true });
231
242
  await move(here, there);
232
243
  await record(agent, "rename", from, who, { to });
244
+ await committedNow(agent, [here, there], `memory: ${from} moved to ${to} from the site`);
233
245
  return { from, to };
234
246
  }
235
247
 
@@ -244,6 +256,7 @@ export async function memoryDelete(agent: Agent, path: string, who: string) {
244
256
  }
245
257
  await rm(here, { recursive: true });
246
258
  await record(agent, "delete", path, who);
259
+ await committedNow(agent, [here], `memory: ${path} deleted from the site`);
247
260
  return { deleted: path };
248
261
  }
249
262
 
@@ -264,11 +277,17 @@ export async function memoryLog(agent: Agent, limit = 200): Promise<unknown[]> {
264
277
  });
265
278
  }
266
279
 
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.
280
+ // Source control on the memory. Enough to mirror the panel an editor puts
281
+ // beside its file tree: what changed, commit it, push, pull, and the recent
282
+ // history. Every call is `git` with an argument array and never a shell string,
283
+ // and nothing here takes a path from the browser: a commit is this memory's
284
+ // whole folder, which is the only shape of commit this offers.
285
+ //
286
+ // One repository holds every agent's memory, a folder each, so what is listed
287
+ // and what is committed is scoped to this memory's folder with `-- .` and cwd
288
+ // set to it. Without that, cc's panel would show tempo's changes and one click
289
+ // would commit them under a message written about something else. Push and pull
290
+ // move the whole repository, because a branch is not per folder.
272
291
 
273
292
  async function inRepo(agent: Agent, ...args: string[]): Promise<string> {
274
293
  const { stdout } = await git("git", args, { cwd: folder(agent), maxBuffer: 8 << 20, timeout: 60_000 });
@@ -276,33 +295,23 @@ async function inRepo(agent: Agent, ...args: string[]): Promise<string> {
276
295
  }
277
296
 
278
297
  /**
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.
298
+ * Whether this memory has a history to show: its folder is in a repository that
299
+ * is not the one the agents themselves are written in. That last part is the
300
+ * whole of this function. Being inside any repository is not enough: a memory
301
+ * somebody put inside their source would answer for that repository, and the
302
+ * panel would show their work in progress as the memory's own changes, commit
303
+ * it under a message written about something else, and push it off the box.
289
304
  */
290
305
  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
- }
306
+ return (await memoryRepo(agent)) !== undefined;
298
307
  }
299
308
 
300
309
  export async function memoryGit(agent: Agent) {
301
310
  if (!(await isRepo(agent))) return { repo: false as const };
302
311
  const [porcelain, branch, history] = await Promise.all([
303
- inRepo(agent, "status", "--porcelain=v1"),
312
+ inRepo(agent, "status", "--porcelain=v1", "--", "."),
304
313
  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(() => ""),
314
+ inRepo(agent, "log", "-20", "--date=short", "--format=%h%x00%ad%x00%an%x00%s", "--", ".").catch(() => ""),
306
315
  ]);
307
316
  const changes = porcelain
308
317
  .split("\n")
@@ -337,10 +346,10 @@ export async function memoryCommit(agent: Agent, message: string, who: string) {
337
346
  const text = message.trim();
338
347
  if (!text) throw new BadRequest("Write a commit message first.");
339
348
  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();
349
+ await inRepo(agent, "add", "-A", "--", ".");
350
+ const staged = (await inRepo(agent, "diff", "--cached", "--name-only", "--", ".")).trim();
342
351
  if (!staged) throw new BadRequest("Nothing to commit.");
343
- await inRepo(agent, "commit", "-m", text);
352
+ await inRepo(agent, "commit", "-m", text, "--", ".");
344
353
  const sha = (await inRepo(agent, "rev-parse", "--short", "HEAD")).trim();
345
354
  await record(agent, "commit", "/", who, { commit: sha, message: text });
346
355
  return { commit: sha, files: staged.split("\n").length };
package/serve/site.ts CHANGED
@@ -173,10 +173,15 @@ function go(form) {
173
173
  body: JSON.stringify({ username: form.username.value, password: form.password.value }),
174
174
  })
175
175
  .then((r) => r.json())
176
- .then((a) => { if (a.ok) location = '/'; else document.getElementById('trouble').textContent = a.error; })
176
+ .then((a) => { if (a.ok) location = back(); else document.getElementById('trouble').textContent = a.error; })
177
177
  .catch((e) => { document.getElementById('trouble').textContent = String(e); });
178
178
  return false;
179
179
  }
180
+ // Where a sign-in started, when another site under the same login sent it here.
181
+ function back() {
182
+ const to = new URLSearchParams(location.search).get('back');
183
+ return to ? '/api/back?to=' + encodeURIComponent(to) : '/';
184
+ }
180
185
  </script>`,
181
186
  false,
182
187
  );
package/server.ts CHANGED
@@ -6,20 +6,25 @@
6
6
  // lists them, so adding one is adding it to that list.
7
7
  //
8
8
  // If this process is not running, nothing fires.
9
- import { existsSync, readdirSync, watch, type FSWatcher } from "node:fs";
9
+ import { readdirSync, watch, type FSWatcher } from "node:fs";
10
10
 
11
11
  import { ROOT } from "#chloe/core/paths.ts";
12
+ import { reloadSettings, unclaimed } from "#chloe/core/settings.ts";
12
13
  import { closeCutOff, trim } from "#chloe/core/db.ts";
13
14
  import { loadAll, type Agent, type Running } from "#chloe/load/load.ts";
14
15
  import { via } from "#chloe/model/model.ts";
15
16
  import { HOST, PORT, serve } from "#chloe/serve/http.ts";
16
17
  import { startClock } from "#chloe/core/clock.ts";
17
18
 
18
- // Credentials a channel reads from the environment can be kept in .env beside
19
- // the repo, which is not in source control.
20
- if (existsSync(`${ROOT}/.env`)) process.loadEnvFile(`${ROOT}/.env`);
21
-
22
19
  let agents: Map<string, Agent> = await loadAll();
20
+
21
+ /** An entry under `agents` in settings that no agent claims is usually one that was renamed. */
22
+ function sayUnclaimed(): void {
23
+ for (const name of unclaimed([...agents.keys()])) {
24
+ console.error(`settings: "agents" has an entry for ${name}, and no agent is called that. If it was renamed, rename the entry too.`);
25
+ }
26
+ }
27
+ sayUnclaimed();
23
28
  trim();
24
29
  const cutOff = closeCutOff();
25
30
  if (cutOff) console.log(`closed ${cutOff} run${cutOff === 1 ? "" : "s"} the last stop cut off`);
@@ -27,10 +32,10 @@ if (cutOff) console.log(`closed ${cutOff} run${cutOff === 1 ? "" : "s"} the last
27
32
  const clock = startClock(() => agents);
28
33
 
29
34
  // Every way in that is not the API: the channels each agent names. A channel
30
- // keeps running across a reload unless a file in that agent's channels/
31
- // folder changed, because restarting one drops whatever it was halfway
32
- // through reading.
33
- const running = new Map<string, Running>();
35
+ // keeps running across a reload unless the options it was made with changed,
36
+ // wherever they are written, or the settings did, because restarting one drops
37
+ // whatever it was halfway through reading.
38
+ const running = new Map<string, Running & { madeWith?: string }>();
34
39
 
35
40
  function startChannels(changed: Set<string> = new Set()): void {
36
41
  const wanted = new Set<string>();
@@ -38,9 +43,11 @@ function startChannels(changed: Set<string> = new Set()): void {
38
43
  for (const one of agent.channels) {
39
44
  const key = `${agent.name}/${one.name}`;
40
45
  wanted.add(key);
41
- if (running.has(key) && !changed.has(agent.name)) continue;
42
- running.get(key)?.stop();
43
- running.set(key, one.start(() => agents.get(agent.name)));
46
+ const now = running.get(key);
47
+ if (now && !changed.has(agent.name) && now.madeWith === one.madeWith) continue;
48
+ if (now) console.log(`${key}: restarted, ${changed.has(agent.name) ? "the settings" : "its options"} changed`);
49
+ now?.stop();
50
+ running.set(key, { ...one.start(() => agents.get(agent.name)), madeWith: one.madeWith });
44
51
  }
45
52
  }
46
53
  for (const [key, one] of running) {
@@ -73,11 +80,11 @@ for (const agent of agents.values()) {
73
80
 
74
81
  let pending: NodeJS.Timeout | undefined;
75
82
  const changedChannels = new Set<string>();
83
+ const SETTINGS = ["settings.json", "settings.local.json"];
84
+ let settingsChanged = false;
76
85
 
77
86
  function changed(path: string): void {
78
- for (const agent of agents.values()) {
79
- if (path.startsWith(`${agent.folder}/channels/`)) changedChannels.add(agent.name);
80
- }
87
+ if (SETTINGS.some((file) => path === `${ROOT}/${file}`)) settingsChanged = true;
81
88
  clearTimeout(pending);
82
89
  pending = setTimeout(reload, 500);
83
90
  }
@@ -96,7 +103,14 @@ async function reload(): Promise<void> {
96
103
  do {
97
104
  again = false;
98
105
  try {
106
+ // A channel reads its token as it starts, so new settings restart them all.
107
+ if (settingsChanged) {
108
+ settingsChanged = false;
109
+ reloadSettings();
110
+ for (const name of agents.keys()) changedChannels.add(name);
111
+ }
99
112
  agents = await loadAll();
113
+ sayUnclaimed();
100
114
  watchFolders();
101
115
  startChannels(changedChannels);
102
116
  changedChannels.clear();
@@ -114,7 +128,8 @@ async function reload(): Promise<void> {
114
128
  }
115
129
 
116
130
  /**
117
- * Reload when chloe.config.ts or anything in an agent's folder changes.
131
+ * Reload when chloe.config.ts, either settings file, or anything in an
132
+ * agent's folder changes.
118
133
  *
119
134
  * Every folder is watched on its own, not recursively. Node's recursive watch
120
135
  * on Linux keeps a watch per file, and a file replaced rather than edited in
@@ -125,20 +140,24 @@ async function reload(): Promise<void> {
125
140
  * reload halfway through someone writing a file would load a broken one. A
126
141
  * reload that throws keeps the agents that were already working, so a typo in
127
142
  * one agent does not take the others down.
143
+ *
144
+ * An agent's memory is left out even when it is inside the agent's folder:
145
+ * it changes on every run and is never loaded.
128
146
  */
129
147
  const watching = new Map<string, FSWatcher>();
130
148
  const SKIP = new Set(["node_modules", ".git", "__pycache__"]);
131
149
 
132
- function foldersIn(folder: string): string[] {
150
+ function foldersIn(folder: string, memory: string): string[] {
151
+ if (folder === memory) return [];
133
152
  const found = [folder];
134
153
  for (const entry of readdirSync(folder, { withFileTypes: true })) {
135
- if (entry.isDirectory() && !SKIP.has(entry.name)) found.push(...foldersIn(`${folder}/${entry.name}`));
154
+ if (entry.isDirectory() && !SKIP.has(entry.name)) found.push(...foldersIn(`${folder}/${entry.name}`, memory));
136
155
  }
137
156
  return found;
138
157
  }
139
158
 
140
159
  function watchFolders(): void {
141
- const wanted = new Set([ROOT, ...[...agents.values()].flatMap((one) => foldersIn(one.folder))]);
160
+ const wanted = new Set([ROOT, ...[...agents.values()].flatMap((one) => foldersIn(one.folder, one.memory.folder))]);
142
161
  for (const [folder, watcher] of watching) {
143
162
  if (!wanted.has(folder)) {
144
163
  watcher.close();
@@ -149,7 +168,7 @@ function watchFolders(): void {
149
168
  if (watching.has(folder)) continue;
150
169
  const top = folder === ROOT;
151
170
  const watcher = watch(folder, (_event, file) => {
152
- if (file && (!top || file === "chloe.config.ts")) changed(`${folder}/${file}`);
171
+ if (file && (!top || file === "chloe.config.ts" || SETTINGS.includes(file))) changed(`${folder}/${file}`);
153
172
  });
154
173
  // A folder that is deleted ends its watch with an error, which would otherwise stop the service.
155
174
  watcher.on("error", () => {