@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.
package/core/steps.ts CHANGED
@@ -19,7 +19,9 @@ import { randomUUID } from "node:crypto";
19
19
  import { z } from "zod";
20
20
 
21
21
  import { deliver, owner as whoOwns } from "#chloe/model/ask.ts";
22
+ import { duringRun } from "#chloe/core/current.ts";
22
23
  import { db } from "#chloe/core/db.ts";
24
+ import { afterRun, beforeRun } from "#chloe/services/historyService.ts";
23
25
  import { oneLineSummary } from "#chloe/core/markdown.ts";
24
26
  import type { Agent, Job } from "#chloe/load/load.ts";
25
27
  import { ask as askModel, type Message } from "#chloe/model/model.ts";
@@ -336,21 +338,29 @@ async function drive(ctx: Ctx): Promise<Result> {
336
338
  signal: ctx.signal,
337
339
  };
338
340
 
341
+ // Whatever the memory holds when the run stops, finished or waiting, is what it did.
342
+ const committed = (end: { summary?: string | null; error?: string }) =>
343
+ afterRun(ctx.agent, ctx.runId, { job: ctx.job.id, source: sourceOf(ctx.runId), ...end });
344
+ await beforeRun(ctx.agent, ctx.runId);
345
+
339
346
  try {
340
- const value = await ctx.job.run!(api);
347
+ const value = await duringRun(ctx.runId, () => ctx.job.run!(api));
341
348
  ctx.parked = undefined;
342
349
  const reply = typeof value === "string" ? value : JSON.stringify(value ?? { ok: true }, null, 2);
343
350
  const summary = summarise(ctx.job, value);
344
351
  finish(ctx, reply, summary);
352
+ await committed({ summary });
345
353
  return { runId: ctx.runId, text: reply, summary, reply: chatReply(ctx.job, value) ?? summary ?? undefined, steps: ctx.lines.length, cost: ctx.cost, parked: false };
346
354
  } catch (error) {
347
355
  if (error instanceof Waiting) {
348
356
  save(ctx);
357
+ await committed({ summary: `waiting on ${ctx.parked?.who ?? "an answer"}` });
349
358
  return { runId: ctx.runId, text: error.message, steps: ctx.lines.length, cost: ctx.cost, parked: true };
350
359
  }
351
360
  ctx.parked = undefined;
352
361
  const why = error instanceof Error ? error.message : String(error);
353
362
  fail(ctx, why);
363
+ await committed({ error: why });
354
364
  if (error instanceof Unanswered || error instanceof Changed) {
355
365
  return { runId: ctx.runId, text: why, steps: ctx.lines.length, cost: ctx.cost, parked: false };
356
366
  }
@@ -358,6 +368,11 @@ async function drive(ctx: Ctx): Promise<Result> {
358
368
  }
359
369
  }
360
370
 
371
+ /** The channel a run came in on, as its row says. */
372
+ function sourceOf(runId: string): string {
373
+ return (db.prepare("select source from runs where id = ?").get(runId) as { source?: string } | undefined)?.source ?? "unknown";
374
+ }
375
+
361
376
  /**
362
377
  * The replay. A step that is already in the record hands back what it returned
363
378
  * and does not run. The name is checked as well as the place, because a job
package/core/turn.ts CHANGED
@@ -5,12 +5,14 @@ import { randomUUID } from "node:crypto";
5
5
 
6
6
  import { z } from "zod";
7
7
 
8
+ import { duringRun } from "#chloe/core/current.ts";
8
9
  import { db } from "#chloe/core/db.ts";
9
10
  import { oneLineSummary } from "#chloe/core/markdown.ts";
10
11
  import type { Agent, ChatHistory, Skill } from "#chloe/load/load.ts";
11
12
  import { ask, type Attachment, type Message, type ToolCall } from "#chloe/model/model.ts";
12
13
  import { recall, remember } from "#chloe/model/memory.ts";
13
14
  import { describe, type Approve, type Call, type Tool, type Tools } from "#chloe/model/tool.ts";
15
+ import { afterRun, beforeRun } from "#chloe/services/historyService.ts";
14
16
 
15
17
  export interface Ask {
16
18
  agent: Agent;
@@ -35,6 +37,11 @@ export interface Ask {
35
37
  * written. Never the final answer, which is what turn() returns.
36
38
  */
37
39
  said?: (text: string) => void;
40
+ /**
41
+ * The person on the other end of a channel, by name. The model is told it is
42
+ * talking to them, so it writes to them as "you" rather than about them.
43
+ */
44
+ talkingTo?: string;
38
45
  /** Who this run is for, as an address. One column, and the team version reads it. */
39
46
  owner?: string;
40
47
  /** Answer tools from here instead of running them. For evals. */
@@ -66,7 +73,7 @@ const MAX_STEPS = 40;
66
73
  * Runs a prompt: ask a model, run the tools it asked for, put the answers
67
74
  * back, ask again, until it stops asking.
68
75
  */
69
- export async function turn({ agent, prompt, attachments, model, thread, source, job, history, said, owner, instead, signal }: Ask): Promise<Result> {
76
+ export async function turn({ agent, prompt, attachments, model, thread, source, job, history, said, talkingTo, owner, instead, signal }: Ask): Promise<Result> {
70
77
  const runId = randomUUID();
71
78
  const using = model ?? agent.model;
72
79
  const tools = { ...(agent.tools ?? {}), skill: skillTool(agent.skills) };
@@ -77,7 +84,7 @@ export async function turn({ agent, prompt, attachments, model, thread, source,
77
84
  ).run(runId, agent.name, started, source, job ?? null, using, prompt, owner ?? null);
78
85
 
79
86
  const messages: Message[] = [
80
- { role: "system", content: systemPrompt(agent) },
87
+ { role: "system", content: systemPrompt(agent, talkingTo && { name: talkingTo, source, asYouGo: Boolean(said) }) },
81
88
  ...(thread ? recall(thread, { ...shown(history), tools: true }) : []),
82
89
  { role: "user", content: prompt, attachments },
83
90
  ];
@@ -87,33 +94,43 @@ export async function turn({ agent, prompt, attachments, model, thread, source,
87
94
  const calls: Result["calls"] = [];
88
95
  let cost = 0;
89
96
  let steps = 0;
97
+ // An eval answers every tool itself, so nothing it does is written anywhere.
98
+ const committed = (end: { summary?: string; error?: string }) =>
99
+ instead ? Promise.resolve() : afterRun(agent, runId, { job, source, ...end });
100
+ if (!instead) await beforeRun(agent, runId);
90
101
 
91
102
  try {
92
- const done = await loop({
93
- model: using,
94
- messages,
95
- tools,
96
- maxSteps: agent.maxSteps ?? MAX_STEPS,
97
- signal,
98
- instead,
99
- onStep: (line) => {
100
- trace.push(line);
101
- if (said && line.say?.trim() && line.wants?.length) said(line.say);
102
- if (line.tool) calls.push({ tool: line.tool, args: line.args, result: line.result });
103
- save(runId, trace.filter((one) => (one as { say?: string }).say !== undefined).length, costOf(trace), trace);
104
- },
105
- });
103
+ const done = await duringRun(runId, () =>
104
+ loop({
105
+ model: using,
106
+ messages,
107
+ tools,
108
+ maxSteps: agent.maxSteps ?? MAX_STEPS,
109
+ signal,
110
+ instead,
111
+ onStep: (line) => {
112
+ trace.push(line);
113
+ if (said && line.say?.trim() && line.wants?.length) said(line.say);
114
+ if (line.tool) calls.push({ tool: line.tool, args: line.args, result: line.result });
115
+ save(runId, trace.filter((one) => (one as { say?: string }).say !== undefined).length, costOf(trace), trace);
116
+ },
117
+ }),
118
+ );
106
119
  cost = done.cost;
107
120
  steps = done.steps;
108
121
  if (done.stopped) {
109
122
  fail(runId, done.text, steps, cost, trace);
123
+ await committed({ error: done.text });
110
124
  return { runId, text: done.text, steps, cost, calls };
111
125
  }
112
126
  finish(runId, done.text, steps, cost, trace);
127
+ await committed({ summary: oneLineSummary(done.text) });
113
128
  if (thread) remember(thread, "assistant", done.text, calls);
114
129
  return { runId, text: done.text, steps, cost, calls };
115
130
  } catch (error) {
116
- fail(runId, String(error instanceof Error ? error.message : error), steps, cost, trace);
131
+ const why = String(error instanceof Error ? error.message : error);
132
+ fail(runId, why, steps, cost, trace);
133
+ await committed({ error: why });
117
134
  throw error;
118
135
  }
119
136
  }
@@ -276,8 +293,22 @@ function skillTool(skills: Skill[]): Tool {
276
293
  };
277
294
  }
278
295
 
279
- function systemPrompt(agent: Agent): string {
296
+ /**
297
+ * The agent's own instructions talk about people in the third person, because
298
+ * they describe them. This is what stops that carrying over into a
299
+ * conversation with one of them.
300
+ */
301
+ function talkingWith({ name, source, asYouGo }: { name: string; source: string; asYouGo: boolean }): string {
302
+ return (
303
+ `## Who you are talking to\n\n` +
304
+ `You are talking with ${name} on ${source}, directly. Write to them as "you", never by name or in the third person.` +
305
+ (asYouGo ? " What you write before calling a tool is sent to them straight away, so write that to them too." : "")
306
+ );
307
+ }
308
+
309
+ function systemPrompt(agent: Agent, person?: { name: string; source: string; asYouGo: boolean } | "" | undefined): string {
280
310
  const parts = [agent.instructions];
311
+ if (person) parts.push(talkingWith(person));
281
312
  if (agent.skills.length > 0) {
282
313
  parts.push(
283
314
  "## Your skills\n\n" +
package/index.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  // The server itself is `server.ts`, and it is run rather than imported.
16
16
 
17
17
  // An agent, and the jobs it runs.
18
- export { defineAgent, defineConfig, markdownJob, load, loadAll, names, type Agent, type Channel, type ChannelRoute, type Running, type Job, type Skill, type Binding, type Home, type Features, type ChatHistory } from "./load/load.ts";
18
+ export { defineAgent, defineConfig, markdownJob, load, loadAll, names, type Agent, type Channel, type ChannelRoute, type Running, type Job, type Skill, type Binding, type Home, type Features, type SelfImprovement, type Memory, type ChatHistory } from "./load/load.ts";
19
19
  export { defineJob } from "./load/job.ts";
20
20
  export { prompt, isPrompt, oneLineSummary, type Prompt } from "./core/markdown.ts";
21
21
 
@@ -35,8 +35,8 @@ export { canReach, deliver, owner, reachBy, split, type Send } from "./model/ask
35
35
 
36
36
  // The floor: where things are, what the box was told, staying inside a
37
37
  // folder, a small file an agent keeps, the history.
38
- export { agentDir, ROOT, STATE } from "./core/paths.ts";
39
- export { readSettings, setting, settings, type Settings } from "./core/settings.ts";
38
+ export { agentDir, MEMORIES, ROOT, STATE } from "./core/paths.ts";
39
+ export { readSettings, reloadSettings, setting, settings, unclaimed, type Settings } from "./core/settings.ts";
40
40
  export { confine } from "./core/confine.ts";
41
41
  export { note, type Note } from "./core/notes.ts";
42
42
  export { copyDatabase, DATABASE, db, trim } from "./core/db.ts";
package/load/load.ts CHANGED
@@ -1,24 +1,27 @@
1
1
  // An agent is declared, not found. Each one is a defineAgent(...) with a name,
2
- // and chloe.config.ts at the top of the repo lists them. Nothing is found by
3
- // looking in a folder except an agent's skills/.
4
- import { readFile, readdir } from "node:fs/promises";
2
+ // and chloe.config.ts at the top of the repo lists them. Every job, tool and
3
+ // channel is named in the definition. skills/ is the one folder read by
4
+ // looking, because a skill is loaded only when the model asks for it.
5
+ import { mkdir, readFile, readdir } from "node:fs/promises";
5
6
  import { existsSync } from "node:fs";
6
7
  import { registerHooks } from "node:module";
7
8
  import type { IncomingMessage, ServerResponse } from "node:http";
8
- import { dirname, relative } from "node:path";
9
+ import { dirname, join, relative } from "node:path";
9
10
  import { fileURLToPath, pathToFileURL } from "node:url";
10
11
  import { getCallSites } from "node:util";
11
12
 
12
13
  import type { z } from "zod";
13
14
 
14
- import { ROOT, STATE, setAgentDirs } from "#chloe/core/paths.ts";
15
- import { readPrompt, settingsAndBody, type Prompt } from "#chloe/core/markdown.ts";
15
+ import { MEMORIES, ROOT, setAgentDirs } from "#chloe/core/paths.ts";
16
+ import { isPrompt, readPrompt, settingsAndBody, type Prompt } from "#chloe/core/markdown.ts";
16
17
  import { parse } from "#chloe/timer/cron.ts";
17
18
  import type { Definition as JobFile } from "./job.ts";
18
19
  import type { Tool, Tools } from "#chloe/model/tool.ts";
19
20
  import { memoryTools } from "#chloe/model/tools/memory.ts";
21
+ import { ownFiles } from "#chloe/model/tools/own_files.ts";
20
22
  import { runScripts } from "#chloe/model/tools/run_script.ts";
21
- import { selfImprovement } from "#chloe/model/tools/write_skill.ts";
23
+ import { makeRepo } from "#chloe/services/historyService.ts";
24
+ import { shareLogin } from "#chloe/serve/login.ts";
22
25
  import type { Work } from "#chloe/core/steps.ts";
23
26
 
24
27
  export const CONFIG = `${ROOT}/chloe.config.ts`;
@@ -52,7 +55,7 @@ export type Binding = (agent: Home) => Tools;
52
55
 
53
56
  /** What defineAgent is given. */
54
57
  export interface Definition {
55
- /** What the run history, its data folder and its pages are filed under. Do not change it once it has run. */
58
+ /** What the run history, its memory and its pages are filed under. Do not change it once it has run. */
56
59
  name: string;
57
60
  /** What the page calls it, when that is not its name: "C.C.". Free to change. */
58
61
  label?: string;
@@ -70,11 +73,11 @@ export interface Definition {
70
73
  * runs, browsable and editable from the site.
71
74
  *
72
75
  * Every agent has one, and always has list_notes, read_notes, search_notes
73
- * and write_notes on it. Left unsaid it is that agent's own folder under the
74
- * state directory, so this is only worth writing down when the agent shares
75
- * a folder with a person. Every file served out of it is written to that
76
- * agent's own audit log first. See serve/memory.ts for why that log is not
77
- * optional.
76
+ * and write_notes on it. Left unsaid it is its own folder inside `memory/`
77
+ * beside the agents, which is a git repository, so this is only worth writing
78
+ * down when the agent shares a folder with a person. Every file served out of it
79
+ * is written to that agent's own audit log first. See serve/memory.ts for
80
+ * why that log is not optional.
78
81
  */
79
82
  memory?: Memory;
80
83
  /** The tools the runtime can give any agent, each switched on or off here. */
@@ -86,7 +89,7 @@ export interface Definition {
86
89
  * its id. What `features` turns on is added to these and not listed here.
87
90
  */
88
91
  tools?: (Tool | Tools | Binding)[];
89
- /** Each job, imported, or markdownJob("jobs/name.md") for one that is only words. */
92
+ /** Each job: one imported, or markdownJob("jobs/<id>.md") for one that is only a prompt. */
90
93
  jobs?: (JobFile<any, any> | MarkdownJob)[];
91
94
  /** Each way in: `[telegramChannel({ ... }), apiChannel()]`. Each one carries its own name. */
92
95
  channels?: Channel[];
@@ -113,6 +116,14 @@ export function defineAgent(definition: Definition): Defined {
113
116
  /** What chloe.config.ts exports: every agent this box runs. */
114
117
  export interface Config {
115
118
  agents: Defined[];
119
+ login?: {
120
+ /**
121
+ * A name like "example.com" makes the one login cover every site under
122
+ * it, so another app can ask GET /api/check whether its visitor is signed
123
+ * in. Leave it out and the login is for this site's own name only.
124
+ */
125
+ domain?: string;
126
+ };
116
127
  }
117
128
 
118
129
  /** The default export of chloe.config.ts: every agent to run. */
@@ -122,21 +133,51 @@ export function defineConfig(config: Config): Config {
122
133
 
123
134
  /**
124
135
  * A job that is only a prompt, kept whole in one markdown file with its
125
- * settings (`cron`, `description`, `timezone`, `model`) at the top. The path
126
- * is inside the agent's folder, and the file's name is the job's id.
136
+ * settings (`cron`, `description`, `timezone`, `model`) at the top. The path is
137
+ * inside the agent's folder, and the file's name is the job's id.
127
138
  */
128
139
  export interface MarkdownJob {
129
140
  markdownJob: string;
130
141
  }
131
142
 
132
143
  /**
133
- * A job that is words and nothing else, named in `agent.ts` as
134
- * `markdownJob("jobs/<id>.md")`. The file name is the job's id.
144
+ * A job that is words and nothing else: `markdownJob("jobs/<id>.md")` in
145
+ * `agent.ts`. The file name is the job's id.
135
146
  */
136
147
  export function markdownJob(file: string): MarkdownJob {
137
148
  return { markdownJob: file };
138
149
  }
139
150
 
151
+ /** The settings a markdown job may have at its top, and no others. */
152
+ export const MARKDOWN_JOB_SETTINGS = ["cron", "description", "timezone", "model"];
153
+
154
+ /**
155
+ * What is wrong with a markdown job, in one sentence, or undefined when it
156
+ * would load. Stricter than the loader: a setting it does not know is refused
157
+ * here rather than ignored.
158
+ */
159
+ export function markdownJobProblem(text: string): string | undefined {
160
+ const { settings, body } = settingsAndBody(text);
161
+ if (!body.trim()) return "it has no prompt under its settings";
162
+ const unknown = Object.keys(settings).filter((key) => !MARKDOWN_JOB_SETTINGS.includes(key));
163
+ if (unknown.length) return `it has ${unknown.join(", ")} at the top, and a job only reads ${MARKDOWN_JOB_SETTINGS.join(", ")}`;
164
+ if (settings.cron) {
165
+ try {
166
+ parse(settings.cron);
167
+ } catch (error) {
168
+ return `its cron line does not read: ${error instanceof Error ? error.message : String(error)}`;
169
+ }
170
+ }
171
+ if (settings.timezone) {
172
+ try {
173
+ new Intl.DateTimeFormat("en-US", { timeZone: settings.timezone });
174
+ } catch {
175
+ return `${settings.timezone} is not a timezone, like America/New_York`;
176
+ }
177
+ }
178
+ return undefined;
179
+ }
180
+
140
181
  /** One markdown file out of an agent's `skills/` folder. */
141
182
  export interface Skill {
142
183
  name: string;
@@ -184,8 +225,13 @@ export interface Job {
184
225
  export interface Features {
185
226
  /** list_notes, read_notes, search_notes and write_notes on its memory. On unless this says false. */
186
227
  memory?: boolean;
187
- /** write_skill, to rewrite its own skills. Every write is a commit. Off unless this says true. */
188
- selfImprovement?: boolean;
228
+ /**
229
+ * list_own_files, read_own_file and write_own_file, to change the plain text
230
+ * in its own folder: its instructions, its skills, its markdown jobs. Off
231
+ * unless this says. `true` is every ending in PLAIN_TEXT; an object narrows
232
+ * that or keeps a path back. Every write is a git commit under its name.
233
+ */
234
+ selfImprovement?: boolean | SelfImprovement;
189
235
  /**
190
236
  * run_script, to run a file in its own scripts/ folder. Off unless this says
191
237
  * true, and refused as it loads when that folder has no scripts.
@@ -193,17 +239,47 @@ export interface Features {
193
239
  runScripts?: boolean;
194
240
  }
195
241
 
242
+ /** `selfImprovement` with its file endings worked out, which is what the tools are given. */
243
+ export interface OwnFileRules extends SelfImprovement {
244
+ files: string[];
245
+ }
246
+
247
+ /** The plain text an agent may change when `selfImprovement` is `true`, without the dots. */
248
+ export const PLAIN_TEXT = ["md", "txt", "html", "json", "yml", "yaml", "csv"];
249
+
250
+ /**
251
+ * Which of its own files an agent may change: `{ except: ["PERMISSIONS.md"] }`,
252
+ * or `{ files: ["md"] }` for less than the plain text it would get from `true`.
253
+ *
254
+ * Code never, whatever `files` says: nothing in tools/, services/, channels/ or
255
+ * scripts/, and nothing ending in .ts or .js. Nor its evals/, which say what a
256
+ * good run of it looks like, nor its memory, which is write_notes.
257
+ */
258
+ export interface SelfImprovement {
259
+ /** File endings it may write, without the dot. PLAIN_TEXT when it says none. */
260
+ files?: string[];
261
+ /** Paths inside its folder it may read and never write, like a file of permissions it obeys. */
262
+ except?: string[];
263
+ }
264
+
196
265
  /** Where an agent remembers things, shown on the site beside its own pages. */
197
266
  export interface Memory {
198
267
  /**
199
- * An absolute path. Unset, it is this agent's own folder under the state
200
- * directory.
268
+ * An absolute path. Unset, it is the agent's own folder inside `memory/`
269
+ * beside the agents, which the runtime makes one git repository.
201
270
  */
202
271
  folder?: string;
203
272
  /** What the site calls it. "Memory" when nothing is said. */
204
273
  label?: string;
205
- /** Make every write a git commit, from the site and from write_notes. For a folder that is a repo. */
206
- commit?: boolean;
274
+ /**
275
+ * When a change becomes a git commit. `"each run"`: whatever a run changed
276
+ * is committed when it ends, under the agent's name, and the folder is made
277
+ * a repository of its own if it is not one. `true`: every write from the
278
+ * site and from write_notes is its own commit, with a message, for a folder
279
+ * shared with a person. `false`: never. Unsaid, it is "each run" for the
280
+ * folder the runtime keeps and false for one named here.
281
+ */
282
+ commit?: boolean | "each run";
207
283
  }
208
284
 
209
285
  /**
@@ -228,6 +304,12 @@ export interface Channel {
228
304
  name: string;
229
305
  /** How much of a conversation on this channel a turn is shown. */
230
306
  chatHistory?: ChatHistory;
307
+ /**
308
+ * The options it was made with, written out. A reload restarts a running
309
+ * channel when this changes, since what `start` was given is fixed for as
310
+ * long as it runs.
311
+ */
312
+ madeWith?: string;
231
313
  /** Starts listening. `agent` is read again for every message, so an edit is live. */
232
314
  start(agent: () => Agent | undefined): Running;
233
315
  }
@@ -262,12 +344,27 @@ export interface Agent extends Omit<Definition, "instructions" | "tools" | "jobs
262
344
  }
263
345
 
264
346
  /**
265
- * Where an agent remembers things: what it said, or its own folder under the
266
- * state directory. That default is where the memory tool has always written, so
267
- * an agent that never mentions memory still has one and it is not empty.
347
+ * Where the memories are kept: `memory/` beside the agents, which the runtime
348
+ * makes one git repository with a folder per agent inside it, so one history
349
+ * covers every agent and the agents' own folders stay source and nothing else.
350
+ * A memory that names its own folder keeps its history in that folder instead.
351
+ */
352
+ export function memoryRoot(memory?: Memory): string {
353
+ return memory?.folder || MEMORIES;
354
+ }
355
+
356
+ /**
357
+ * Where one agent remembers things: what it said, or its own folder inside
358
+ * `memory/`. Named after the agent rather than its folder, so a folder that is
359
+ * renamed still finds the same memory.
268
360
  */
269
361
  export function memoryFolder(name: string, memory?: Memory): string {
270
- return memory?.folder || `${STATE}/${name}`;
362
+ return memory?.folder || join(MEMORIES, name);
363
+ }
364
+
365
+ /** When a change to this memory becomes a commit. See Memory. */
366
+ function commitsWhen(memory?: Memory): Memory["commit"] {
367
+ return memory?.commit ?? (memory?.folder ? false : "each run");
271
368
  }
272
369
 
273
370
  /** An agent's folder as the repo sees it, for saying where something is wrong. */
@@ -287,15 +384,18 @@ export async function loadAll(): Promise<Map<string, Agent>> {
287
384
  })) as { default?: Config };
288
385
  const listed = module.default?.agents;
289
386
  if (!Array.isArray(listed)) throw new Error("chloe.config.ts does not export defineConfig({ agents: [...] }) as its default.");
387
+ shareLogin(module.default?.login?.domain);
290
388
 
291
389
  const folders = new Map<string, string>();
390
+ const memories = new Map<string, string>();
292
391
  for (const one of listed) {
293
392
  if (!one?.name) throw new Error("chloe.config.ts lists an agent with no name.");
294
393
  if (folders.has(one.name)) throw new Error(`chloe.config.ts lists two agents called ${one.name}.`);
295
394
  folders.set(one.name, one.folder);
395
+ memories.set(one.name, memoryFolder(one.name, one.memory));
296
396
  }
297
397
  // Before anything is bound, so a tool that asks where its agent lives is told.
298
- setAgentDirs(folders);
398
+ setAgentDirs(folders, memories);
299
399
 
300
400
  const all = new Map<string, Agent>();
301
401
  for (const one of listed) all.set(one.name, await resolveAgent(one));
@@ -324,12 +424,26 @@ async function resolveAgent(definition: Defined): Promise<Agent> {
324
424
  const { tools, jobs, channels, ...rest } = definition;
325
425
  // Worked out once, here, so the site, the memory tool and a job's
326
426
  // work.memory all mean the same folder without any of them saying it again.
327
- const memory = { ...definition.memory, folder: memoryFolder(name, definition.memory) };
427
+ const memory = {
428
+ ...definition.memory,
429
+ folder: memoryFolder(name, definition.memory),
430
+ commit: commitsWhen(definition.memory),
431
+ };
432
+ // Made now, so committing what a run changed has a folder to name.
433
+ await mkdir(memory.folder, { recursive: true }).catch(() => undefined);
434
+ if (memory.commit === "each run") {
435
+ // Without git, or on a folder it cannot write, the agent still runs: it only
436
+ // has no history. The repository is the memory root, shared by every agent.
437
+ await makeRepo(memoryRoot(definition.memory), name).catch((error: unknown) =>
438
+ console.error(`${where}: its memory could not be made a git repository:`, error instanceof Error ? error.message : error),
439
+ );
440
+ }
441
+ const home = { name, folder, memory };
328
442
  return {
329
443
  ...rest,
330
444
  memory,
331
445
  instructions: await readPrompt(definition.instructions, { dir: folder, where }),
332
- tools: toolsOf([...featureTools(definition.features, memory), ...(tools ?? [])], { name, folder, memory }, where),
446
+ tools: toolsOf([...featureTools(definition.features, where), ...(tools ?? [])], home, where),
333
447
  skills: await skillsIn(`${folder}/skills`),
334
448
  jobs: await jobsOf(name, folder, jobs ?? []),
335
449
  channels: channelsOf(channels ?? [], where),
@@ -357,10 +471,20 @@ export function hasChannel(agent: Pick<Agent, "channels">, name: string): boolea
357
471
  }
358
472
 
359
473
  /** What an agent's `features` turn on, as tools. The memory tools unless it says memory: false. */
360
- function featureTools(features: Features = {}, memory: Memory & { folder: string }): (Tools | Binding)[] {
474
+ /** What `selfImprovement` comes to: `true` is the plain text, an object is itself. */
475
+ export function ownFileRules(self: true | SelfImprovement): OwnFileRules {
476
+ const said = self === true ? {} : self;
477
+ return { ...said, files: said.files ?? PLAIN_TEXT };
478
+ }
479
+
480
+ function featureTools(features: Features = {}, where: string): (Tools | Binding)[] {
481
+ const self = features.selfImprovement;
482
+ if (typeof self === "object" && self.files && self.files.length === 0) {
483
+ throw new Error(`${where}: selfImprovement is true, or says which files it may change, like { files: ["md"] }.`);
484
+ }
361
485
  return [
362
- ...(features.memory === false ? [] : [memoryTools(memory)]),
363
- ...(features.selfImprovement ? [selfImprovement()] : []),
486
+ ...(features.memory === false ? [] : [memoryTools()]),
487
+ ...(self ? [ownFiles(ownFileRules(self))] : []),
364
488
  ...(features.runScripts ? [runScripts()] : []),
365
489
  ];
366
490
  }
@@ -393,13 +517,18 @@ async function skillsIn(dir: string): Promise<Skill[]> {
393
517
  return skills;
394
518
  }
395
519
 
396
- /** The jobs an agent names, in the order it names them. */
520
+ /**
521
+ * The jobs an agent names, in the order it names them, then every markdown
522
+ * file in its jobs/ that is not already one of those or the words of one.
523
+ */
397
524
  export async function jobsOf(agent: string, dir: string, list: (JobFile<any, any> | MarkdownJob)[]): Promise<Job[]> {
398
525
  const jobs: Job[] = [];
399
- for (const one of list) {
400
- const job = "markdownJob" in one ? await fromMarkdown(agent, dir, one.markdownJob) : await fromCode(agent, dir, one);
526
+ const add = (job: Job) => {
401
527
  if (jobs.some((other) => other.id === job.id)) throw new Error(`${agent}: two jobs are called ${job.id}.`);
402
528
  jobs.push(job);
529
+ };
530
+ for (const one of list) {
531
+ add("markdownJob" in one ? await fromMarkdown(agent, dir, one.markdownJob) : await fromCode(agent, dir, one));
403
532
  }
404
533
  return jobs;
405
534
  }
@@ -457,7 +586,12 @@ async function fromCode(agent: string, dir: string, definition: JobFile<any, any
457
586
  cron: checked(definition.cron, where),
458
587
  timezone: definition.timezone ?? "UTC",
459
588
  model: definition.model,
460
- files: [`jobs/${id}.md`, `jobs/${id}.ts`].filter((file) => existsSync(`${dir}/${file}`)),
589
+ files: [
590
+ ...new Set([
591
+ ...(isPrompt(definition.markdown) ? [relative(dir, join(dir, definition.markdown.file))] : []),
592
+ ...[`jobs/${id}.md`, `jobs/${id}.ts`].filter((file) => existsSync(`${dir}/${file}`)),
593
+ ]),
594
+ ],
461
595
  };
462
596
 
463
597
  // A job has no prompt, and the empty string is what says so everywhere else.
@@ -53,19 +53,33 @@ export function search_in({ root, what, id = "search_notes" }: Folder) {
53
53
  });
54
54
  }
55
55
 
56
- /** `commit` makes every write a git commit, for a folder that is a repo. */
57
- export function write_in({ root, what, id = "write_notes", commit = false }: Folder & { commit?: boolean }) {
56
+ /**
57
+ * `commit` makes every write a git commit, for a folder that is a repo, under
58
+ * `author` when there is one. `memory` says this folder is the agent's memory,
59
+ * so the run writing it lists the commit.
60
+ */
61
+ export function write_in({
62
+ root,
63
+ what,
64
+ id = "write_notes",
65
+ commit = false,
66
+ author,
67
+ memory = false,
68
+ }: Folder & { commit?: boolean; author?: string; memory?: boolean }) {
58
69
  return tool({
59
70
  id,
60
71
  description:
61
72
  `Write one file in ${what}. This replaces the file, so include everything you want kept: ` +
62
- `read it first unless it is new.` +
73
+ `read it first unless it is new. To add to the end of a file, such as a log or a list that only ` +
74
+ `grows, set append instead of writing it out again.` +
63
75
  (commit ? " Committed as it is written, so `message` is required." : ""),
64
76
  inputSchema: z.object({
65
77
  path: z.string(),
66
78
  content: z.string().min(1),
79
+ append: z.boolean().optional().describe("Add content to the end of the file instead of replacing it."),
67
80
  message: z.string().optional().describe("Commit message saying what changed. Required here."),
68
81
  }),
69
- execute: ({ path, content, message }) => writeFiles(root, path, content, { commit, message }),
82
+ execute: ({ path, content, append, message }) =>
83
+ writeFiles(root, path, content, { commit, message, append, author, in: memory ? "memory" : undefined }),
70
84
  });
71
85
  }
@@ -4,7 +4,7 @@
4
4
  // write. All the model chooses is how far back and how many.
5
5
  import { z } from "zod";
6
6
 
7
- import { messages, oneMessage } from "#chloe/services/gmailService.ts";
7
+ import { readEmailMessages, readOneEmailMessage } from "#chloe/services/gmailService.ts";
8
8
  import { tool } from "#chloe/model/tool.ts";
9
9
 
10
10
  interface Options {
@@ -46,7 +46,7 @@ export function read_mail({
46
46
  }),
47
47
  execute: ({ days: back, limit, messageId }) =>
48
48
  messageId
49
- ? oneMessage({ search, what, days: back ?? days, limit: limit ?? 10, messageId })
50
- : messages({ search, days: back ?? days, limit: limit ?? 10 }),
49
+ ? readOneEmailMessage({ search, what, days: back ?? days, limit: limit ?? 10, messageId })
50
+ : readEmailMessages({ search, days: back ?? days, limit: limit ?? 10 }),
51
51
  });
52
52
  }
@@ -7,8 +7,8 @@
7
7
  //
8
8
  // import { read_mail, read_web } from "@chloejs/core/tools";
9
9
  //
10
- // The notes tools, write_skill and run_script are not here: an agent turns
11
- // them on with `features` in its definition.
10
+ // The notes tools, the own-file tools and run_script are not
11
+ // here: an agent turns them on with `features` in its definition.
12
12
  //
13
13
  // The work itself is in services/, published as "@chloejs/core/services", and a job calls it
14
14
  // from a step rather than coming through here. If a job imports this file,