@chloejs/core 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/channels/shared.ts +20 -2
- package/channels/slack.ts +9 -6
- package/channels/telegram.ts +129 -14
- package/core/clock.ts +24 -7
- package/core/current.ts +19 -0
- package/core/db.ts +20 -0
- package/core/notes.ts +4 -4
- package/core/paths.ts +36 -5
- package/core/root.ts +1 -1
- package/core/settings.ts +46 -5
- package/core/steps.ts +16 -1
- package/core/turn.ts +49 -18
- package/index.ts +3 -3
- package/load/load.ts +172 -38
- package/model/tools/files.ts +18 -4
- package/model/tools/index.ts +2 -2
- package/model/tools/memory.ts +19 -14
- package/model/tools/own_files.ts +46 -0
- package/model/tools/send_email.ts +36 -9
- package/ops/test.ts +448 -15
- package/package.json +1 -1
- package/serve/changes.ts +61 -0
- package/serve/files.ts +26 -4
- package/serve/http.ts +77 -5
- package/serve/login.ts +46 -3
- package/serve/memory.ts +43 -34
- package/serve/site.ts +6 -1
- package/server.ts +39 -20
- package/services/emailService.ts +144 -8
- package/services/filesService.ts +28 -17
- package/services/historyService.ts +372 -0
- package/services/index.ts +1 -1
- package/services/ownFilesService.ts +141 -0
- package/services/scriptsService.ts +9 -4
- package/model/tools/write_skill.ts +0 -31
package/services/emailService.ts
CHANGED
|
@@ -20,14 +20,25 @@ export interface EmailSender {
|
|
|
20
20
|
to: string[];
|
|
21
21
|
/** Prefix put in front of every subject, so an inbox can be filtered. */
|
|
22
22
|
tag?: string;
|
|
23
|
+
/** Where a reply goes, when not to `from`: a sending address that cannot receive mail needs this. */
|
|
24
|
+
replyTo?: string[];
|
|
25
|
+
/**
|
|
26
|
+
* The body is Markdown: it is sent as HTML with headings, lists, tables and
|
|
27
|
+
* links, plus a plain text copy without the symbols. Off, it is sent as
|
|
28
|
+
* written, as plain text only.
|
|
29
|
+
*/
|
|
30
|
+
markdown?: boolean;
|
|
23
31
|
}
|
|
24
32
|
|
|
25
33
|
/** One message, ready to go: the tag is already in the subject. */
|
|
26
34
|
export interface Email {
|
|
27
35
|
from: string;
|
|
28
36
|
to: string[];
|
|
37
|
+
replyTo?: string[];
|
|
29
38
|
subject: string;
|
|
39
|
+
/** Plain text. */
|
|
30
40
|
body: string;
|
|
41
|
+
html?: string;
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
/** Something that can carry an email. Returns the provider's id for it, if it gives one. */
|
|
@@ -36,13 +47,13 @@ export interface EmailProvider {
|
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
const resend: EmailProvider = {
|
|
39
|
-
async send({ from, to, subject, body }) {
|
|
50
|
+
async send({ from, to, replyTo, subject, body, html }) {
|
|
40
51
|
const key = setting(settings.resend.api_key, "RESEND_API_KEY");
|
|
41
52
|
if (!key) throw new Error("No Resend key. Put it in settings.local.json as resend.api_key.");
|
|
42
53
|
const response = await fetch("https://api.resend.com/emails", {
|
|
43
54
|
method: "POST",
|
|
44
55
|
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
|
45
|
-
body: JSON.stringify({ from, to, subject, text: body }),
|
|
56
|
+
body: JSON.stringify({ from, to, reply_to: replyTo, subject, text: body, html }),
|
|
46
57
|
signal: AbortSignal.timeout(30_000),
|
|
47
58
|
});
|
|
48
59
|
if (!response.ok) {
|
|
@@ -52,22 +63,147 @@ const resend: EmailProvider = {
|
|
|
52
63
|
},
|
|
53
64
|
};
|
|
54
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Carries nothing: the subject and who it was for go to the log and the
|
|
68
|
+
* message is dropped. What a test run and a box with no mail account use, so
|
|
69
|
+
* that code which mails can be exercised without anything leaving the box.
|
|
70
|
+
*/
|
|
71
|
+
const none: EmailProvider = {
|
|
72
|
+
async send({ to, subject }) {
|
|
73
|
+
console.log(`email (not sent, provider is none): "${subject}" to ${to.join(", ")}`);
|
|
74
|
+
return {};
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
55
78
|
/** Every provider email.provider can name. Adding one is an entry here and in the settings schema. */
|
|
56
|
-
const providers: Record<typeof settings.email.provider, EmailProvider> = { resend };
|
|
79
|
+
const providers: Record<typeof settings.email.provider, EmailProvider> = { resend, none };
|
|
57
80
|
|
|
58
81
|
/** Sends one email through the configured provider and returns its id. The tag is put in front of the subject. */
|
|
59
82
|
export async function sendEmail(
|
|
60
|
-
{ from, to, tag }: EmailSender,
|
|
83
|
+
{ from, to, tag, replyTo, markdown }: EmailSender,
|
|
61
84
|
subject: string,
|
|
62
85
|
body: string,
|
|
63
86
|
): Promise<{ sent: true; id?: string; subject: string }> {
|
|
64
87
|
// Refuse rather than send nowhere.
|
|
65
88
|
if (to.length === 0) throw new Error("Nobody to send to. Give the sender at least one address in to.");
|
|
66
|
-
const
|
|
89
|
+
const tagged = tag && !subject.startsWith(`[${tag}]`) ? `[${tag}] ${subject}` : subject;
|
|
90
|
+
const chosen = setting(settings.email.provider, "EMAIL_PROVIDER") as typeof settings.email.provider;
|
|
91
|
+
const provider = providers[chosen];
|
|
92
|
+
if (!provider) throw new Error(`No email provider called "${chosen}". It is one of: ${Object.keys(providers).join(", ")}.`);
|
|
93
|
+
const { id } = await provider.send({
|
|
67
94
|
from,
|
|
68
95
|
to,
|
|
69
|
-
|
|
70
|
-
|
|
96
|
+
replyTo,
|
|
97
|
+
subject: tagged,
|
|
98
|
+
body: markdown ? markdownToText(body) : body,
|
|
99
|
+
html: markdown ? markdownToHtml(body) : undefined,
|
|
71
100
|
});
|
|
72
|
-
return { sent: true, id, subject };
|
|
101
|
+
return { sent: true, id, subject: tagged };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const escape = (s: string) =>
|
|
105
|
+
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
106
|
+
|
|
107
|
+
const LINK = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
108
|
+
|
|
109
|
+
function inline(s: string): string {
|
|
110
|
+
return escape(s)
|
|
111
|
+
.replace(/`([^`]+)`/g, "<code style='background:#f1f1f1;padding:1px 4px;border-radius:3px'>$1</code>")
|
|
112
|
+
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
113
|
+
// Only a web or mail address becomes a link, never javascript: or data:.
|
|
114
|
+
.replace(LINK, (_, words, to) => (/^(https?:|mailto:)/i.test(to) ? `<a href='${to}'>${words}</a>` : words))
|
|
115
|
+
.replace(/(?<!["'>=])(https?:\/\/[^\s<)]+)/g, "<a href='$1'>$1</a>");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const cells = (row: string) => row.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
|
|
119
|
+
const isRule = (row: string[]) => row.every((c) => /^[-: ]*$/.test(c));
|
|
120
|
+
const BULLET = /^\s*[-*] /;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Markdown as email HTML: `##` headings, `-` lists, `|` tables, `>` quotes,
|
|
124
|
+
* fenced code, **bold**, `code` and links. Lines next to each other are one
|
|
125
|
+
* paragraph, so a body wrapped at any width reads as prose rather than as a
|
|
126
|
+
* ragged line per paragraph. Only a blank line starts a new one.
|
|
127
|
+
*/
|
|
128
|
+
export function markdownToHtml(markdown: string): string {
|
|
129
|
+
const out: string[] = [];
|
|
130
|
+
const lines = markdown.split("\n");
|
|
131
|
+
let para: string[] = [];
|
|
132
|
+
let table: string[][] = [];
|
|
133
|
+
const endPara = () => {
|
|
134
|
+
if (para.length) out.push(`<p style='margin:0 0 14px'>${inline(para.join(" "))}</p>`);
|
|
135
|
+
para = [];
|
|
136
|
+
};
|
|
137
|
+
const endTable = () => {
|
|
138
|
+
const rows = table.filter((r) => !isRule(r));
|
|
139
|
+
table = [];
|
|
140
|
+
if (!rows.length) return;
|
|
141
|
+
const th = "padding:5px 10px;border-bottom:2px solid #d9d9d9;text-align:left";
|
|
142
|
+
const td = "padding:5px 10px;border-bottom:1px solid #eee";
|
|
143
|
+
const head = rows[0].map((c) => `<th style='${th}'>${inline(c)}</th>`).join("");
|
|
144
|
+
const body = rows.slice(1).map((r) => `<tr>${r.map((c) => `<td style='${td}'>${inline(c)}</td>`).join("")}</tr>`);
|
|
145
|
+
out.push(`<table style='border-collapse:collapse;margin:0 0 16px'><tr>${head}</tr>${body.join("")}</table>`);
|
|
146
|
+
};
|
|
147
|
+
for (let i = 0; i < lines.length; ) {
|
|
148
|
+
const line = lines[i];
|
|
149
|
+
if (line.trim().startsWith("```")) {
|
|
150
|
+
endPara();
|
|
151
|
+
endTable();
|
|
152
|
+
const code: string[] = [];
|
|
153
|
+
for (i++; i < lines.length && !lines[i].trim().startsWith("```"); i++) code.push(escape(lines[i]));
|
|
154
|
+
out.push(`<pre style='background:#f6f6f6;padding:10px;overflow:auto'>${code.join("\n")}</pre>`);
|
|
155
|
+
i++;
|
|
156
|
+
} else if (line.trimStart().startsWith("|") && line.split("|").length > 2) {
|
|
157
|
+
endPara();
|
|
158
|
+
table.push(cells(line));
|
|
159
|
+
i++;
|
|
160
|
+
} else if (!line.trim()) {
|
|
161
|
+
endPara();
|
|
162
|
+
endTable();
|
|
163
|
+
i++;
|
|
164
|
+
} else if (line.startsWith("#")) {
|
|
165
|
+
endPara();
|
|
166
|
+
endTable();
|
|
167
|
+
const size = line.startsWith("###") ? 15 : 17;
|
|
168
|
+
out.push(`<div style='font-size:${size}px;font-weight:700;margin:22px 0 10px'>${inline(line.replace(/^#+/, "").trim())}</div>`);
|
|
169
|
+
i++;
|
|
170
|
+
} else if (BULLET.test(line)) {
|
|
171
|
+
endPara();
|
|
172
|
+
endTable();
|
|
173
|
+
const items: string[] = [];
|
|
174
|
+
while (i < lines.length && BULLET.test(lines[i])) {
|
|
175
|
+
let item = lines[i++].replace(BULLET, "");
|
|
176
|
+
while (i < lines.length && lines[i].trim() && !/^\s*[-*] |^#|^\s*\||^> /.test(lines[i])) item += ` ${lines[i++].trim()}`;
|
|
177
|
+
items.push(`<li style='margin:0 0 6px'>${inline(item)}</li>`);
|
|
178
|
+
}
|
|
179
|
+
out.push(`<ul style='margin:0 0 14px;padding-left:22px'>${items.join("")}</ul>`);
|
|
180
|
+
} else if (line.startsWith("> ")) {
|
|
181
|
+
endPara();
|
|
182
|
+
endTable();
|
|
183
|
+
const quote: string[] = [];
|
|
184
|
+
while (i < lines.length && lines[i].startsWith("> ")) quote.push(lines[i++].slice(2).trim());
|
|
185
|
+
out.push(`<blockquote style='margin:0 0 14px;padding-left:14px;border-left:3px solid #ccc'>${inline(quote.join(" "))}</blockquote>`);
|
|
186
|
+
} else {
|
|
187
|
+
endTable();
|
|
188
|
+
para.push(line.trim());
|
|
189
|
+
i++;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
endPara();
|
|
193
|
+
endTable();
|
|
194
|
+
return `<div style="font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:15px;line-height:1.55;max-width:640px">${out.join("\n")}</div>`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The same Markdown as plain text: no `#`, `**` or backticks, a table row as `a: b`, a link as `words (address)`. */
|
|
198
|
+
export function markdownToText(markdown: string): string {
|
|
199
|
+
return markdown
|
|
200
|
+
.split("\n")
|
|
201
|
+
.flatMap((line) => {
|
|
202
|
+
if (line.trimStart().startsWith("|")) {
|
|
203
|
+
const row = cells(line);
|
|
204
|
+
return isRule(row) ? [] : [` ${row.filter(Boolean).join(": ")}`];
|
|
205
|
+
}
|
|
206
|
+
return [line.replace(/^#+\s*/, "").replace(LINK, "$1 ($2)").replace(/\*\*/g, "").replace(/`/g, "")];
|
|
207
|
+
})
|
|
208
|
+
.join("\n");
|
|
73
209
|
}
|
package/services/filesService.ts
CHANGED
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
// skill, which is text you can edit, not code that needs a restart.
|
|
11
11
|
//
|
|
12
12
|
// What they do enforce is the edge of the folder, through confine().
|
|
13
|
-
import {
|
|
14
|
-
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
13
|
+
import { appendFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
15
14
|
import { dirname } from "node:path";
|
|
16
15
|
|
|
17
16
|
import { confine } from "#chloe/core/confine.ts";
|
|
17
|
+
import { commitPaths, noteCommit, type Place } from "./historyService.ts";
|
|
18
18
|
import { run } from "./runService.ts";
|
|
19
19
|
|
|
20
20
|
/** List a folder. `path` is relative to `root`, and omitting it means the top. */
|
|
@@ -65,32 +65,43 @@ export async function searchFiles(root: string, query: string, folder?: string)
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
|
-
* Write one file, replacing it
|
|
69
|
-
*
|
|
68
|
+
* Write one file, replacing it, or with `append` adding to the end of it on a
|
|
69
|
+
* line of its own, so a long file that only grows is never written out whole.
|
|
70
|
+
* `commit` makes the write a git commit of that file alone, for a folder in a
|
|
71
|
+
* repo, and then `message` is required. `author` is the agent it is written
|
|
72
|
+
* under, and `in` which of its places this is, so the run writing it lists
|
|
73
|
+
* the commit. Without an author it is this box's own git name.
|
|
70
74
|
*/
|
|
71
75
|
export async function writeFiles(
|
|
72
76
|
root: string,
|
|
73
77
|
path: string,
|
|
74
78
|
content: string,
|
|
75
|
-
{
|
|
79
|
+
{
|
|
80
|
+
commit = false,
|
|
81
|
+
message,
|
|
82
|
+
append = false,
|
|
83
|
+
author,
|
|
84
|
+
in: place,
|
|
85
|
+
}: { commit?: boolean; message?: string; append?: boolean; author?: string; in?: Place } = {},
|
|
76
86
|
) {
|
|
77
87
|
if (commit && (message ?? "").length < 10) {
|
|
78
88
|
throw new Error("This folder is a repo, so every write needs a commit message.");
|
|
79
89
|
}
|
|
80
90
|
const resolved = confine(root, path);
|
|
81
91
|
await mkdir(dirname(resolved), { recursive: true });
|
|
82
|
-
|
|
92
|
+
if (append) {
|
|
93
|
+
const before = await readFile(resolved, "utf8").catch(() => "");
|
|
94
|
+
await appendFile(resolved, before && !before.endsWith("\n") ? `\n${content}` : content, "utf8");
|
|
95
|
+
} else {
|
|
96
|
+
await writeFile(resolved, content, "utf8");
|
|
97
|
+
}
|
|
83
98
|
if (!commit) return { path: resolved, bytes: content.length };
|
|
84
|
-
const committed = await
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
done(error ? `not committed: ${err || out}` : out.trim()),
|
|
92
|
-
),
|
|
93
|
-
);
|
|
94
|
-
});
|
|
99
|
+
const committed = await commitPaths(root, [resolved], { message: message!, author }).then(
|
|
100
|
+
(id) => {
|
|
101
|
+
if (place) noteCommit(place, id, message!);
|
|
102
|
+
return id ? id.slice(0, 12) : "nothing changed, so nothing was committed";
|
|
103
|
+
},
|
|
104
|
+
(error: unknown) => `not committed: ${error instanceof Error ? error.message : String(error)}`,
|
|
105
|
+
);
|
|
95
106
|
return { path: resolved, bytes: content.length, commit: committed };
|
|
96
107
|
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// What changed in an agent's folders, as git history.
|
|
2
|
+
//
|
|
3
|
+
// Two places can hold it: the agent's memory, a folder in the repository every
|
|
4
|
+
// memory shares, and the agent's own folder (instructions, skills, jobs),
|
|
5
|
+
// usually inside the repository the agents are written in. So a place is a
|
|
6
|
+
// folder inside a repository and never a repository, and everything here is
|
|
7
|
+
// scoped to that folder: what is listed, what is shown, and above all what is
|
|
8
|
+
// committed, because the folder beside it belongs to another agent. A commit made
|
|
9
|
+
// for an agent is written under the agent's name with an empty email, and
|
|
10
|
+
// ends with the run that made it, `Run: <id>`, so a file's history leads back
|
|
11
|
+
// to the run and the run's record lists its commits.
|
|
12
|
+
//
|
|
13
|
+
// Every call is git with an argument list, never a shell string.
|
|
14
|
+
import { execFile } from "node:child_process";
|
|
15
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
16
|
+
import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
17
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
18
|
+
|
|
19
|
+
import { currentRun } from "#chloe/core/current.ts";
|
|
20
|
+
import { addCommit, db, type RunCommit } from "#chloe/core/db.ts";
|
|
21
|
+
import type { Agent } from "#chloe/load/load.ts";
|
|
22
|
+
|
|
23
|
+
/** Which of an agent's two places a commit is in. */
|
|
24
|
+
export type Place = RunCommit["in"];
|
|
25
|
+
|
|
26
|
+
interface Git {
|
|
27
|
+
code: number;
|
|
28
|
+
out: string;
|
|
29
|
+
err: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One git command in `cwd`. `author` makes the commit that agent's, author and
|
|
34
|
+
* committer both, so it works on a box whose git has no name set. It goes in
|
|
35
|
+
* git's own environment variables, because those beat any setting. Never throws.
|
|
36
|
+
*/
|
|
37
|
+
async function git(cwd: string, args: string[], author?: string): Promise<Git> {
|
|
38
|
+
const env = author
|
|
39
|
+
? { ...process.env, GIT_AUTHOR_NAME: author, GIT_AUTHOR_EMAIL: "", GIT_COMMITTER_NAME: author, GIT_COMMITTER_EMAIL: "" }
|
|
40
|
+
: process.env;
|
|
41
|
+
const unsigned = author ? ["-c", "commit.gpgsign=false"] : [];
|
|
42
|
+
for (let attempt = 0; ; attempt++) {
|
|
43
|
+
const result = await new Promise<Git>((done) =>
|
|
44
|
+
execFile(
|
|
45
|
+
"git",
|
|
46
|
+
[...unsigned, ...args],
|
|
47
|
+
{ cwd, env, encoding: "utf8", maxBuffer: 32 << 20, timeout: 60_000 },
|
|
48
|
+
(error, out, err) => done({ code: error ? Number((error as { code?: unknown }).code) || 1 : 0, out: out ?? "", err: err ?? "" }),
|
|
49
|
+
),
|
|
50
|
+
);
|
|
51
|
+
// Two writes to one repo at the same moment: the second finds the first's lock.
|
|
52
|
+
if (result.code === 0 || !result.err.includes("index.lock") || attempt === 5) return result;
|
|
53
|
+
await new Promise((wait) => setTimeout(wait, 200));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function gitOrThrow(cwd: string, args: string[], author?: string): Promise<string> {
|
|
58
|
+
const result = await git(cwd, args, author);
|
|
59
|
+
if (result.code !== 0) throw new Error((result.err || result.out).trim().split("\n")[0] || `git ${args[0]} failed`);
|
|
60
|
+
return result.out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The top of the repository a folder is in, or undefined when it is in none. */
|
|
64
|
+
export async function repoOf(folder: string): Promise<string | undefined> {
|
|
65
|
+
if (!existsSync(folder)) return undefined;
|
|
66
|
+
const found = await git(folder, ["rev-parse", "--show-toplevel"]);
|
|
67
|
+
return found.code === 0 ? realpathSync(found.out.trim()) : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether a folder is the top of a repository of its own. Being inside one is
|
|
72
|
+
* not enough: asked from there, git answers for the repository around it.
|
|
73
|
+
*/
|
|
74
|
+
export async function isRepoTop(folder: string): Promise<boolean> {
|
|
75
|
+
const top = await repoOf(folder);
|
|
76
|
+
return top !== undefined && top === realpathSync(folder);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function withRun(message: string, runId: string | undefined): string {
|
|
80
|
+
return runId ? `${message.trim()}\n\nRun: ${runId}` : message.trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Commits these paths and nothing else: whatever else is changed or staged in
|
|
85
|
+
* the repository stays as it was, so somebody's work in progress stays theirs.
|
|
86
|
+
* Answers the new commit's id, or undefined when those paths had not changed.
|
|
87
|
+
*/
|
|
88
|
+
export async function commitPaths(
|
|
89
|
+
folder: string,
|
|
90
|
+
paths: string[],
|
|
91
|
+
{ message, author, runId = currentRun() }: { message: string; author?: string; runId?: string },
|
|
92
|
+
): Promise<string | undefined> {
|
|
93
|
+
const repo = await repoOf(folder);
|
|
94
|
+
if (!repo) throw new Error(`${folder} is not in a git repository.`);
|
|
95
|
+
if (paths.length === 0) return undefined;
|
|
96
|
+
const inRepo = paths.map((one) => relative(repo, resolve(realpathSync(folder), one)));
|
|
97
|
+
await gitOrThrow(repo, ["add", "-A", "--", ...inRepo]);
|
|
98
|
+
if ((await git(repo, ["diff", "--cached", "--quiet", "--", ...inRepo])).code === 0) return undefined;
|
|
99
|
+
await gitOrThrow(repo, ["commit", "-q", "-m", withRun(message, runId), "--", ...inRepo], author);
|
|
100
|
+
return (await gitOrThrow(repo, ["rev-parse", "HEAD"])).trim();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Commits everything that changed in a repository. Undefined when nothing had. */
|
|
104
|
+
export async function commitAll(
|
|
105
|
+
repo: string,
|
|
106
|
+
{ message, author, runId }: { message: string; author?: string; runId?: string },
|
|
107
|
+
): Promise<string | undefined> {
|
|
108
|
+
await gitOrThrow(repo, ["add", "-A"]);
|
|
109
|
+
if ((await git(repo, ["diff", "--cached", "--quiet"])).code === 0) return undefined;
|
|
110
|
+
await gitOrThrow(repo, ["commit", "-q", "-m", withRun(message, runId)], author);
|
|
111
|
+
return (await gitOrThrow(repo, ["rev-parse", "HEAD"])).trim();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Whether a file has changes nobody has committed, including being new and never added. */
|
|
115
|
+
export async function uncommitted(file: string): Promise<boolean> {
|
|
116
|
+
const repo = await repoOf(dirname(file));
|
|
117
|
+
if (!repo) return false;
|
|
118
|
+
const status = await git(repo, ["status", "--porcelain", "--", relative(repo, file)]);
|
|
119
|
+
return status.out.trim() !== "";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Makes a folder a repository of its own and commits what is already in it,
|
|
124
|
+
* under `author`. A repository the folder sits inside is told to leave it
|
|
125
|
+
* alone, in its own `info/exclude`, so a "commit all" there never takes it.
|
|
126
|
+
*/
|
|
127
|
+
export async function makeRepo(folder: string, author: string): Promise<void> {
|
|
128
|
+
await mkdir(folder, { recursive: true });
|
|
129
|
+
if (await isRepoTop(folder)) return;
|
|
130
|
+
const outer = await repoOf(folder);
|
|
131
|
+
await gitOrThrow(folder, ["init", "-q", "-b", "main"]);
|
|
132
|
+
if (outer) {
|
|
133
|
+
const exclude = resolve(outer, (await gitOrThrow(outer, ["rev-parse", "--git-path", "info/exclude"])).trim());
|
|
134
|
+
const line = `/${relative(outer, realpathSync(folder))}/`;
|
|
135
|
+
const already = await readFile(exclude, "utf8").catch(() => "");
|
|
136
|
+
if (!already.split("\n").includes(line)) {
|
|
137
|
+
await mkdir(dirname(exclude), { recursive: true });
|
|
138
|
+
await appendFile(exclude, `${already && !already.endsWith("\n") ? "\n" : ""}${line}\n`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
await commitAll(folder, { message: "What was here when this folder started keeping its history", author });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The repository a memory's history is in: the one the memory folder is the top
|
|
146
|
+
* of, or the one its parent is the top of, which is the repository every agent's
|
|
147
|
+
* memory shares. Anywhere deeper inside a repository has no history here, on
|
|
148
|
+
* purpose. Being inside a repository is not enough: asked from there, git
|
|
149
|
+
* answers for whatever repository is around it, so a memory somebody keeps in
|
|
150
|
+
* their source tree would show their work in progress as its own changes and
|
|
151
|
+
* push their branch off the box.
|
|
152
|
+
*/
|
|
153
|
+
export async function memoryRepo(agent: Agent): Promise<string | undefined> {
|
|
154
|
+
const top = await repoOf(agent.memory.folder);
|
|
155
|
+
if (!top) return undefined;
|
|
156
|
+
const own = realpathSync(agent.memory.folder);
|
|
157
|
+
return top === own || top === dirname(own) ? top : undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Whether this agent's memory is committed once a run ends. See Memory in load/load.ts. */
|
|
161
|
+
function eachRun(agent: Agent): boolean {
|
|
162
|
+
return agent.memory.commit === "each run";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Before a run: whatever changed in the memory since the last run ended was
|
|
167
|
+
* done by somebody else, so it is committed under this box's own git name and
|
|
168
|
+
* the run's commit holds only the run's work. Skipped while another run of the
|
|
169
|
+
* same agent is going, because then what changed is that run's.
|
|
170
|
+
*/
|
|
171
|
+
export async function beforeRun(agent: Agent, runId: string): Promise<void> {
|
|
172
|
+
if (!eachRun(agent) || !(await memoryRepo(agent))) return;
|
|
173
|
+
const busy = db
|
|
174
|
+
.prepare("select 1 from runs where agent = ? and finished is null and parked is null and id != ? limit 1")
|
|
175
|
+
.get(agent.name, runId);
|
|
176
|
+
if (busy) return;
|
|
177
|
+
await commitPaths(agent.memory.folder, ["."], { message: "Changed outside a run", runId: undefined }).catch((error: unknown) =>
|
|
178
|
+
console.error(`${agent.name}: committing what changed outside a run failed:`, error instanceof Error ? error.message : error),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** A commit's first line: what the run was, and what it said or why it stopped. */
|
|
183
|
+
function subjectFor({ job, source, summary, error }: RunEnd): string {
|
|
184
|
+
const said = error ? `stopped: ${error.split("\n")[0]}` : summary?.trim() || "finished";
|
|
185
|
+
const line = `${job || source}: ${said}`;
|
|
186
|
+
return line.length > 100 ? `${line.slice(0, 99).trimEnd()}…` : line;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** How a run ended, for its commit message. */
|
|
190
|
+
export interface RunEnd {
|
|
191
|
+
job?: string | null;
|
|
192
|
+
source: string;
|
|
193
|
+
summary?: string | null;
|
|
194
|
+
error?: string | null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* After a run, or when it stops to wait for somebody: everything that changed
|
|
199
|
+
* in the memory is committed under the agent's name, and the run keeps the
|
|
200
|
+
* commit. Never throws, because the run's work is already done: a commit that
|
|
201
|
+
* failed is said in the service's log.
|
|
202
|
+
*/
|
|
203
|
+
export async function afterRun(agent: Agent, runId: string, end: RunEnd): Promise<void> {
|
|
204
|
+
try {
|
|
205
|
+
if (!eachRun(agent) || !(await memoryRepo(agent))) return;
|
|
206
|
+
const subject = subjectFor(end);
|
|
207
|
+
const id = await commitPaths(agent.memory.folder, ["."], { message: subject, author: agent.name, runId });
|
|
208
|
+
if (id) addCommit(runId, { in: "memory", id, subject });
|
|
209
|
+
} catch (error) {
|
|
210
|
+
console.error(`${agent.name}: committing run ${runId} failed:`, error instanceof Error ? error.message : error);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Records a commit made during a run, on that run. */
|
|
215
|
+
export function noteCommit(place: Place, id: string | undefined, subject: string): void {
|
|
216
|
+
const runId = currentRun();
|
|
217
|
+
if (runId && id) addCommit(runId, { in: place, id, subject: subject.split("\n")[0] });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** One commit, as the site lists it. Paths are inside the place it is in. */
|
|
221
|
+
export interface Change {
|
|
222
|
+
in: Place;
|
|
223
|
+
id: string;
|
|
224
|
+
at: string;
|
|
225
|
+
by: string;
|
|
226
|
+
subject: string;
|
|
227
|
+
/** The run that made it, when a run did. */
|
|
228
|
+
run?: string;
|
|
229
|
+
files: { path: string; status: string }[];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Where a place's history is: the repository, and the agent's part of it. */
|
|
233
|
+
async function whereIs(agent: Agent, place: Place): Promise<{ repo: string; under: string } | undefined> {
|
|
234
|
+
const folder = place === "memory" ? agent.memory.folder : agent.folder;
|
|
235
|
+
const repo = place === "memory" ? await memoryRepo(agent) : await repoOf(agent.folder);
|
|
236
|
+
return repo && existsSync(folder) ? { repo, under: relative(repo, realpathSync(folder)) } : undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function inside(under: string, path: string): string {
|
|
240
|
+
return under ? join(under, path) : path;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function outOf(under: string, path: string): string {
|
|
244
|
+
return under && path.startsWith(`${under}/`) ? path.slice(under.length + 1) : path;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const FORMAT = "--format=%x1e%H%x1f%aI%x1f%an%x1f%s%x1f%b%x1f";
|
|
248
|
+
|
|
249
|
+
function parseLog(text: string, place: Place, under: string): Change[] {
|
|
250
|
+
return text
|
|
251
|
+
.split("\x1e")
|
|
252
|
+
.filter((chunk) => chunk.trim())
|
|
253
|
+
.map((chunk) => {
|
|
254
|
+
const [id, at, by, subject, body, names = ""] = chunk.split("\x1f");
|
|
255
|
+
return {
|
|
256
|
+
in: place,
|
|
257
|
+
id,
|
|
258
|
+
at: new Date(at).toISOString(),
|
|
259
|
+
by,
|
|
260
|
+
subject,
|
|
261
|
+
run: body.match(/^Run: (\S+)$/m)?.[1],
|
|
262
|
+
files: names
|
|
263
|
+
.split("\n")
|
|
264
|
+
.filter((line) => line.includes("\t"))
|
|
265
|
+
.map((line) => {
|
|
266
|
+
const [status, path] = line.split("\t");
|
|
267
|
+
return { path: outOf(under, path), status: status.trim() };
|
|
268
|
+
}),
|
|
269
|
+
};
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The commits in one or both places, newest first: every commit to the memory,
|
|
275
|
+
* and every commit to the repository that touched the agent's own folder.
|
|
276
|
+
* `path` narrows it to one file, inside that place.
|
|
277
|
+
*/
|
|
278
|
+
export async function changes(
|
|
279
|
+
agent: Agent,
|
|
280
|
+
{ place, path, limit = 50 }: { place?: Place; path?: string; limit?: number } = {},
|
|
281
|
+
): Promise<Change[]> {
|
|
282
|
+
const places: Place[] = place ? [place] : ["memory", "folder"];
|
|
283
|
+
const found: Change[] = [];
|
|
284
|
+
for (const one of places) {
|
|
285
|
+
const where = await whereIs(agent, one);
|
|
286
|
+
if (!where) continue;
|
|
287
|
+
const scope = path ? inside(where.under, path) : where.under;
|
|
288
|
+
const log = await git(where.repo, [
|
|
289
|
+
"log",
|
|
290
|
+
`-${limit}`,
|
|
291
|
+
"--no-renames",
|
|
292
|
+
"--name-status",
|
|
293
|
+
FORMAT,
|
|
294
|
+
...(scope ? ["--", scope] : []),
|
|
295
|
+
]);
|
|
296
|
+
if (log.code === 0) found.push(...parseLog(log.out, one, where.under));
|
|
297
|
+
}
|
|
298
|
+
return found.sort((a, b) => b.at.localeCompare(a.at)).slice(0, limit);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** One commit, with its diff, cut to the agent's own part of the repository. */
|
|
302
|
+
export async function change(agent: Agent, place: Place, id: string): Promise<(Change & { diff: string }) | undefined> {
|
|
303
|
+
const where = await whereIs(agent, place);
|
|
304
|
+
if (!where || !/^[0-9a-f]{4,40}$/.test(id)) return undefined;
|
|
305
|
+
const scope = where.under ? ["--", where.under] : [];
|
|
306
|
+
const about = await git(where.repo, ["show", "--no-renames", "--name-status", FORMAT, id, ...scope]);
|
|
307
|
+
if (about.code !== 0) return undefined;
|
|
308
|
+
const [one] = parseLog(about.out, place, where.under);
|
|
309
|
+
if (!one) return undefined;
|
|
310
|
+
const shown = await git(where.repo, ["show", "--format=", "--no-renames", "--patch", id, ...scope]);
|
|
311
|
+
const diff = shown.out.length > 400_000 ? `${shown.out.slice(0, 400_000)}\n...[cut here]` : shown.out;
|
|
312
|
+
return { ...one, diff };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Undoes one commit: every file it touched goes back to how it was before it,
|
|
317
|
+
* and that is committed under this box's git name. Refused when a file has
|
|
318
|
+
* changed since, because putting it back would lose that later change too.
|
|
319
|
+
*/
|
|
320
|
+
export async function undo(agent: Agent, place: Place, id: string): Promise<{ id: string | undefined; files: string[] }> {
|
|
321
|
+
const where = await whereIs(agent, place);
|
|
322
|
+
const found = where && (await change(agent, place, id));
|
|
323
|
+
if (!where || !found) throw new Error("There is no such change.");
|
|
324
|
+
const parents = (await gitOrThrow(where.repo, ["rev-list", "--parents", "-n", "1", found.id])).trim().split(" ").slice(1);
|
|
325
|
+
if (parents.length === 0) throw new Error("This is the first commit there is, so there is nothing before it to go back to.");
|
|
326
|
+
if (parents.length > 1) throw new Error("This commit joins two histories, so it cannot be undone from here.");
|
|
327
|
+
|
|
328
|
+
const touched = found.files.map((file) => ({ ...file, inRepo: inside(where.under, file.path) }));
|
|
329
|
+
for (const file of touched) {
|
|
330
|
+
const now = await readFile(join(where.repo, file.inRepo)).catch(() => undefined);
|
|
331
|
+
const then = file.status === "D" ? undefined : await showFile(where.repo, found.id, file.inRepo);
|
|
332
|
+
if ((now === undefined) !== (then === undefined) || (now && then && !now.equals(then))) {
|
|
333
|
+
throw new Error(`${file.path} has changed since, so undoing this would lose that change. Undo the later change first.`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
for (const file of touched) {
|
|
337
|
+
const before = file.status === "A" ? undefined : await showFile(where.repo, parents[0], file.inRepo);
|
|
338
|
+
const at = join(where.repo, file.inRepo);
|
|
339
|
+
if (before === undefined) await rm(at, { force: true });
|
|
340
|
+
else {
|
|
341
|
+
await mkdir(dirname(at), { recursive: true });
|
|
342
|
+
await writeFile(at, before);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const undone = await commitPaths(
|
|
346
|
+
where.repo,
|
|
347
|
+
touched.map((file) => file.inRepo),
|
|
348
|
+
{ message: `Undo "${found.subject}"\n\nUndoes: ${found.id}`, runId: undefined },
|
|
349
|
+
);
|
|
350
|
+
return { id: undone, files: touched.map((file) => file.path) };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** A file's bytes at a commit, or undefined when it was not there. */
|
|
354
|
+
async function showFile(repo: string, id: string, path: string): Promise<Buffer | undefined> {
|
|
355
|
+
return new Promise((done) =>
|
|
356
|
+
execFile("git", ["show", `${id}:${path}`], { cwd: repo, encoding: "buffer", maxBuffer: 64 << 20 }, (error, out) =>
|
|
357
|
+
done(error ? undefined : (out as Buffer)),
|
|
358
|
+
),
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** When somebody last looked at this agent's changes, or undefined when nobody has. */
|
|
363
|
+
export function seenAt(agent: string): string | undefined {
|
|
364
|
+
return (db.prepare("select at from seen where agent = ?").get(agent) as { at?: string } | undefined)?.at;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Everything this agent has changed up to now has been looked at. */
|
|
368
|
+
export function markSeen(agent: string): string {
|
|
369
|
+
const at = new Date().toISOString();
|
|
370
|
+
db.prepare("insert into seen (agent, at) values (?, ?) on conflict (agent) do update set at = excluded.at").run(agent, at);
|
|
371
|
+
return at;
|
|
372
|
+
}
|
package/services/index.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// name here is publishing it.
|
|
10
10
|
|
|
11
11
|
export { run, type Result } from "./runService.ts";
|
|
12
|
-
export { sendEmail, type EmailSender } from "./emailService.ts";
|
|
12
|
+
export { markdownToHtml, markdownToText, sendEmail, type EmailSender } from "./emailService.ts";
|
|
13
13
|
export { readEmailMessages, readOneEmailMessage, type Message as Mail } from "./gmailService.ts";
|
|
14
14
|
export { listFiles, readFiles, searchFiles, writeFiles } from "./filesService.ts";
|
|
15
15
|
export { listScripts, runScripts } from "./scriptsService.ts";
|