@chloejs/core 0.2.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/LICENSE +21 -0
- package/README.md +221 -0
- package/channels/api.ts +41 -0
- package/channels/shared.ts +250 -0
- package/channels/slack.ts +390 -0
- package/channels/telegram.ts +396 -0
- package/core/clock.ts +126 -0
- package/core/confine.ts +45 -0
- package/core/db.ts +117 -0
- package/core/markdown.ts +95 -0
- package/core/notes.ts +44 -0
- package/core/paths.ts +29 -0
- package/core/root.ts +26 -0
- package/core/settings.ts +124 -0
- package/core/steps.ts +896 -0
- package/core/turn.ts +314 -0
- package/do/email.ts +45 -0
- package/do/files.ts +96 -0
- package/do/mail.ts +155 -0
- package/do/run.ts +56 -0
- package/do/scripts.ts +49 -0
- package/do/web.ts +192 -0
- package/index.ts +52 -0
- package/load/job.ts +84 -0
- package/load/load.ts +478 -0
- package/model/ask.ts +84 -0
- package/model/claude.ts +261 -0
- package/model/memory.ts +68 -0
- package/model/model.ts +185 -0
- package/model/tool.ts +53 -0
- package/model/tools/files.ts +71 -0
- package/model/tools/gmail.ts +43 -0
- package/model/tools/index.ts +28 -0
- package/model/tools/memory.ts +23 -0
- package/model/tools/run_script.ts +44 -0
- package/model/tools/send_email.ts +29 -0
- package/model/tools/web.ts +23 -0
- package/model/tools/write_skill.ts +31 -0
- package/ops/account.ts +109 -0
- package/ops/agent.ts +290 -0
- package/ops/check.ts +37 -0
- package/ops/evals.ts +206 -0
- package/ops/install.sh +101 -0
- package/ops/test.ts +1976 -0
- package/package.json +65 -0
- package/scorers/calls.ts +50 -0
- package/scorers/expectations.ts +118 -0
- package/scorers/index.ts +5 -0
- package/serve/alerts.ts +79 -0
- package/serve/errors.ts +10 -0
- package/serve/files.ts +70 -0
- package/serve/http.ts +767 -0
- package/serve/login.ts +299 -0
- package/serve/memory.ts +372 -0
- package/serve/page.ts +142 -0
- package/serve/pass.ts +45 -0
- package/serve/recentWork.ts +69 -0
- package/serve/site.ts +409 -0
- package/serve/tokens.ts +132 -0
- package/server.ts +170 -0
- package/timer/cron.ts +92 -0
- package/timer/every.ts +153 -0
- package/timer/index.ts +4 -0
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chloejs/core",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "A job runner where asking a model is one kind of step.",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.ts",
|
|
8
|
+
"./channels/*": "./channels/*.ts",
|
|
9
|
+
"./scorers": "./scorers/index.ts",
|
|
10
|
+
"./timer": "./timer/index.ts",
|
|
11
|
+
"./tools": "./model/tools/index.ts",
|
|
12
|
+
"./test": "./ops/check.ts"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"chloe": "./server.ts"
|
|
16
|
+
},
|
|
17
|
+
"imports": {
|
|
18
|
+
"#chloe/*": "./*"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"channels",
|
|
22
|
+
"core",
|
|
23
|
+
"do",
|
|
24
|
+
"index.ts",
|
|
25
|
+
"load",
|
|
26
|
+
"model",
|
|
27
|
+
"ops",
|
|
28
|
+
"scorers",
|
|
29
|
+
"serve",
|
|
30
|
+
"server.ts",
|
|
31
|
+
"timer"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22.18.0"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"zod": "^4.4.3"
|
|
38
|
+
},
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"author": "Carlos Martinez",
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/carlosmartinezt/chloejs.git"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://chloejs.org",
|
|
46
|
+
"keywords": [
|
|
47
|
+
"agents",
|
|
48
|
+
"jobs",
|
|
49
|
+
"cron",
|
|
50
|
+
"llm",
|
|
51
|
+
"typescript"
|
|
52
|
+
],
|
|
53
|
+
"scripts": {
|
|
54
|
+
"test": "node ops/test.ts",
|
|
55
|
+
"typecheck": "tsc --noEmit -p .",
|
|
56
|
+
"check": "tsc --noEmit -p . && node ops/test.ts"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/node": "latest",
|
|
60
|
+
"typescript": "^6.0.3"
|
|
61
|
+
},
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public"
|
|
64
|
+
}
|
|
65
|
+
}
|
package/scorers/calls.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Which tools a run had to use, and which it must never touch.
|
|
2
|
+
//
|
|
3
|
+
// A judge reads what an agent said. This reads what it did, and it does not
|
|
4
|
+
// ask a model: emailing on a quiet morning, or restarting the same service
|
|
5
|
+
// twice, are facts about the run, and a fact is worth checking exactly rather
|
|
6
|
+
// than asking a second model whether it happened.
|
|
7
|
+
//
|
|
8
|
+
// What to expect arrives with the run, from the eval case, so this file names
|
|
9
|
+
// no agent and no tool.
|
|
10
|
+
import type { Result } from "#chloe/core/turn.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Which tools a run should have used, must not have used, and may use only
|
|
14
|
+
* once.
|
|
15
|
+
*/
|
|
16
|
+
export interface Expected {
|
|
17
|
+
mustCall?: string[];
|
|
18
|
+
mustNotCall?: string[];
|
|
19
|
+
/** Tools it may use, but only once in a run. Restarting twice is why this exists. */
|
|
20
|
+
atMostOnce?: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A score between zero and one, and why it came out that way. */
|
|
24
|
+
export interface Mark {
|
|
25
|
+
score: number;
|
|
26
|
+
reason: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Marks a run on the tools it used, which is arithmetic and asks nobody. */
|
|
30
|
+
export function calls(result: Result, expected: Expected): Mark {
|
|
31
|
+
const used = result.calls.map((c) => c.tool);
|
|
32
|
+
const times = (tool: string) => used.filter((name) => name === tool).length;
|
|
33
|
+
|
|
34
|
+
const missing = (expected.mustCall ?? []).filter((tool) => !used.includes(tool));
|
|
35
|
+
const forbidden = (expected.mustNotCall ?? []).filter((tool) => used.includes(tool));
|
|
36
|
+
const repeated = (expected.atMostOnce ?? []).filter((tool) => times(tool) > 1);
|
|
37
|
+
|
|
38
|
+
const parts: string[] = [];
|
|
39
|
+
if (missing.length) parts.push(`never called ${missing.join(", ")}`);
|
|
40
|
+
if (forbidden.length) parts.push(`called ${forbidden.join(", ")}, which it must not`);
|
|
41
|
+
if (repeated.length) parts.push(`called ${repeated.join(", ")} more than once in one run`);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
score: parts.length === 0 ? 1 : 0,
|
|
45
|
+
reason:
|
|
46
|
+
parts.length === 0
|
|
47
|
+
? `Used: ${used.join(", ") || "nothing"}.`
|
|
48
|
+
: `${parts.join("; ")}. Used: ${used.join(", ") || "nothing"}.`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Judging one run against what a person said a good run looks like.
|
|
2
|
+
//
|
|
3
|
+
// The expectations are not in this file. They arrive with the run, from the
|
|
4
|
+
// eval case, so one judge covers every agent and every job: the file that says
|
|
5
|
+
// what good means is the eval file, which is JSON a person can read and change
|
|
6
|
+
// without touching code.
|
|
7
|
+
import { ask } from "#chloe/model/model.ts";
|
|
8
|
+
import type { Result } from "#chloe/core/turn.ts";
|
|
9
|
+
import type { Mark } from "./calls.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The situation a case sets up, and what the agent should and should not have
|
|
13
|
+
* done about it.
|
|
14
|
+
*/
|
|
15
|
+
export interface Expected {
|
|
16
|
+
situation?: string;
|
|
17
|
+
should?: string[];
|
|
18
|
+
shouldNot?: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const JUDGE = `You grade one run of an agent against expectations written by the person who owns it.
|
|
22
|
+
Grade only what is written. Do not invent standards, do not reward extra work nobody asked for, and
|
|
23
|
+
do not excuse a miss because the run was otherwise good. An expectation about what the agent said is
|
|
24
|
+
met only if it actually said it. An expectation about what it did is met only if the tool calls show
|
|
25
|
+
it. When you are not sure, it is not met, and say what you would have needed to see.
|
|
26
|
+
|
|
27
|
+
Answer with JSON and nothing else, in this shape:
|
|
28
|
+
{"checks":[{"expectation":"copied back","met":true,"why":"one sentence, quoting the run where you can"}]}
|
|
29
|
+
One check per expectation, in the order they are given.`;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Marks what a run said against what it should have said, by asking a model to
|
|
33
|
+
* judge it.
|
|
34
|
+
*/
|
|
35
|
+
export async function expectations(
|
|
36
|
+
prompt: string,
|
|
37
|
+
result: Result,
|
|
38
|
+
expected: Expected,
|
|
39
|
+
model: string,
|
|
40
|
+
): Promise<Mark> {
|
|
41
|
+
const lines = [
|
|
42
|
+
...(expected.should ?? []).map((s) => `It should: ${s}`),
|
|
43
|
+
...(expected.shouldNot ?? []).map((s) => `It should not: ${s}`),
|
|
44
|
+
];
|
|
45
|
+
if (lines.length === 0) return { score: 1, reason: "Nothing was expected." };
|
|
46
|
+
|
|
47
|
+
const did = result.calls.map((c) => ({
|
|
48
|
+
tool: c.tool,
|
|
49
|
+
args: c.args,
|
|
50
|
+
// A tool result is often the whole site list or the whole disk table. The
|
|
51
|
+
// judge needs to know what came back, not to re-read all of it.
|
|
52
|
+
result: JSON.stringify(c.result ?? "").slice(0, 2000),
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
const answer = await ask({
|
|
56
|
+
model,
|
|
57
|
+
messages: [
|
|
58
|
+
{ role: "system", content: JUDGE },
|
|
59
|
+
{
|
|
60
|
+
role: "user",
|
|
61
|
+
content: `An agent was asked to do a job. Here is what it was told, what was true at the
|
|
62
|
+
time, what it said, and what it did. Grade it against the expectations below.
|
|
63
|
+
|
|
64
|
+
WHAT IT WAS ASKED
|
|
65
|
+
${prompt}
|
|
66
|
+
|
|
67
|
+
WHAT WAS TRUE AT THE TIME
|
|
68
|
+
${expected.situation ?? "Not written down."}
|
|
69
|
+
|
|
70
|
+
WHAT IT SAID
|
|
71
|
+
${result.text || "(it said nothing)"}
|
|
72
|
+
|
|
73
|
+
WHAT IT DID
|
|
74
|
+
${JSON.stringify(did, null, 2)}
|
|
75
|
+
|
|
76
|
+
EXPECTATIONS, one check each, in this order
|
|
77
|
+
${lines.map((line, i) => `${i + 1}. ${line}`).join("\n")}`,
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const checks = parse(answer.text);
|
|
83
|
+
if (!checks) return { score: 0, reason: `The judge did not answer with JSON: ${answer.text.slice(0, 200)}` };
|
|
84
|
+
|
|
85
|
+
const met = checks.filter((c) => c.met).length;
|
|
86
|
+
const score = checks.length === 0 ? 1 : met / checks.length;
|
|
87
|
+
const missed = checks.filter((c) => !c.met);
|
|
88
|
+
return {
|
|
89
|
+
score,
|
|
90
|
+
reason:
|
|
91
|
+
missed.length === 0
|
|
92
|
+
? "Met every expectation."
|
|
93
|
+
: `${score.toFixed(2)}. Missed: ${missed.map((c) => `${c.expectation} (${c.why})`).join(" ")}`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface Check {
|
|
98
|
+
expectation: string;
|
|
99
|
+
met: boolean;
|
|
100
|
+
why: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A model asked for JSON usually returns JSON, and sometimes returns JSON
|
|
105
|
+
* inside a code fence or with a sentence in front of it. Take the first
|
|
106
|
+
* object in the text rather than failing the case over punctuation.
|
|
107
|
+
*/
|
|
108
|
+
function parse(text: string): Check[] | null {
|
|
109
|
+
const start = text.indexOf("{");
|
|
110
|
+
const end = text.lastIndexOf("}");
|
|
111
|
+
if (start === -1 || end <= start) return null;
|
|
112
|
+
try {
|
|
113
|
+
const parsed = JSON.parse(text.slice(start, end + 1)) as { checks?: Check[] };
|
|
114
|
+
return Array.isArray(parsed.checks) ? parsed.checks : null;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
package/scorers/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// How a run is marked. Imported as "@chloejs/core/scorers", by whatever runs the
|
|
2
|
+
// evals: the tool calls a turn made, and what somebody said should have
|
|
3
|
+
// happened. Both name a type called Expected, so each keeps its own name.
|
|
4
|
+
export { calls, type Expected as ExpectedCalls, type Mark } from "./calls.ts";
|
|
5
|
+
export { expectations, type Expected as ExpectedOutcome } from "./expectations.ts";
|
package/serve/alerts.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Mail when somebody signs in from an address this copy has not seen before,
|
|
2
|
+
// and when one gets locked out for guessing.
|
|
3
|
+
//
|
|
4
|
+
// The addresses that have been seen are kept beside the run history, so the
|
|
5
|
+
// first sign-in after a fresh install is always a new one. That first mail is
|
|
6
|
+
// not noise: it is the only one that proves the alert works.
|
|
7
|
+
//
|
|
8
|
+
// Sending is best effort and never blocks a sign-in. A person who cannot get
|
|
9
|
+
// in because a mail server is down would be a worse failure than a sign-in
|
|
10
|
+
// nobody was told about, and the sign-in is recorded either way.
|
|
11
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
|
|
13
|
+
import { send } from "#chloe/do/email.ts";
|
|
14
|
+
import { STATE } from "#chloe/core/paths.ts";
|
|
15
|
+
import { settings } from "#chloe/core/settings.ts";
|
|
16
|
+
|
|
17
|
+
const FILE = `${STATE}/seen-addresses.json`;
|
|
18
|
+
|
|
19
|
+
let seen: Record<string, string> | undefined;
|
|
20
|
+
|
|
21
|
+
function read(): Record<string, string> {
|
|
22
|
+
if (seen) return seen;
|
|
23
|
+
try {
|
|
24
|
+
seen = JSON.parse(readFileSync(FILE, "utf8")) as Record<string, string>;
|
|
25
|
+
} catch {
|
|
26
|
+
seen = {};
|
|
27
|
+
}
|
|
28
|
+
return seen;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function write(all: Record<string, string>): void {
|
|
32
|
+
mkdirSync(STATE, { recursive: true });
|
|
33
|
+
writeFileSync(FILE, JSON.stringify(all, null, 2), { mode: 0o600 });
|
|
34
|
+
chmodSync(FILE, 0o600);
|
|
35
|
+
seen = all;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function where(): { to: string[]; from: string } | null {
|
|
39
|
+
const to = settings.alerts.email_to.split(",").map((one) => one.trim()).filter(Boolean);
|
|
40
|
+
if (!to.length || !settings.alerts.email_from) return null;
|
|
41
|
+
return { to, from: settings.alerts.email_from };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Sends, and says so in the log if it could not. Never throws at the caller. */
|
|
45
|
+
function mail(subject: string, body: string): void {
|
|
46
|
+
const address = where();
|
|
47
|
+
if (!address) return;
|
|
48
|
+
void send({ from: address.from, to: address.to, tag: "chloe" }, subject, body).catch((error: unknown) => {
|
|
49
|
+
console.error("could not send the alert:", error instanceof Error ? error.message : error);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Records a successful sign-in, and mails when the address is a new one. */
|
|
54
|
+
export function signedInFrom(username: string, address: string): void {
|
|
55
|
+
const all = read();
|
|
56
|
+
const first = !all[address];
|
|
57
|
+
all[address] = new Date().toISOString();
|
|
58
|
+
write(all);
|
|
59
|
+
if (!first) return;
|
|
60
|
+
mail(
|
|
61
|
+
`New sign-in from ${address}`,
|
|
62
|
+
`${username} signed in from ${address}, which this copy had not seen before.\n\n` +
|
|
63
|
+
`If that was not you, change the password: delete data/login.json on the box and run \`npm run account\`.\n` +
|
|
64
|
+
`Revoke every token at the same time, from the site's tokens page.\n`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Mails when one address has been locked out for guessing. */
|
|
69
|
+
export function lockedOut(address: string): void {
|
|
70
|
+
mail(
|
|
71
|
+
`Locked out ${address}`,
|
|
72
|
+
`Too many wrong passwords from ${address}, so it is locked out for fifteen minutes.\n`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Forgets what was read, so a test can write the file and be believed. */
|
|
77
|
+
export function forgetAddresses(): void {
|
|
78
|
+
seen = undefined;
|
|
79
|
+
}
|
package/serve/errors.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The two refusals every route can make, in a file of their own so that the
|
|
2
|
+
// files doing the work can throw them without importing the router that
|
|
3
|
+
// catches them. http.ts re-exports both, because that is where a route author
|
|
4
|
+
// is already looking.
|
|
5
|
+
|
|
6
|
+
/** Nothing at that address, or nothing this caller is allowed to know is there. */
|
|
7
|
+
export class NotFound extends Error {}
|
|
8
|
+
|
|
9
|
+
/** The caller sent something this route cannot take. Answered 400 with the reason. */
|
|
10
|
+
export class BadRequest extends Error {}
|
package/serve/files.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// An agent's own folder, read from the page and written back to.
|
|
2
|
+
//
|
|
3
|
+
// Markdown is writable and nothing else is. Words are what the page is for,
|
|
4
|
+
// and the watcher makes a saved skill live in under a second. A .ts saved from
|
|
5
|
+
// a browser would be live just as fast with nothing type checking it, so code
|
|
6
|
+
// is edited where `npm run check` runs.
|
|
7
|
+
//
|
|
8
|
+
// The edge of the folder is confine()'s job, inside the calls below: a path
|
|
9
|
+
// from the page is as untrusted as a path from a model.
|
|
10
|
+
import { existsSync, statSync } from "node:fs";
|
|
11
|
+
|
|
12
|
+
import { confine } from "#chloe/core/confine.ts";
|
|
13
|
+
import { agentDir } from "#chloe/core/paths.ts";
|
|
14
|
+
import { list, read, write } from "#chloe/do/files.ts";
|
|
15
|
+
|
|
16
|
+
export interface Entry {
|
|
17
|
+
name: string;
|
|
18
|
+
path: string;
|
|
19
|
+
dir: boolean;
|
|
20
|
+
children?: Entry[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Deep enough for any agent folder, and an end to it if something links in a circle. */
|
|
24
|
+
const DEPTH = 6;
|
|
25
|
+
|
|
26
|
+
/** Made by a program, not by anybody, so it is not part of what an agent is. */
|
|
27
|
+
const JUNK = ["__pycache__", "node_modules"];
|
|
28
|
+
|
|
29
|
+
/** Everything in one agent's folder, folders first, as a tree. */
|
|
30
|
+
export async function tree(agent: string, path = "", depth = 0): Promise<Entry[]> {
|
|
31
|
+
const { entries } = await list(agentDir(agent), path || undefined);
|
|
32
|
+
const out: Entry[] = [];
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
const dir = entry.endsWith("/");
|
|
35
|
+
const name = dir ? entry.slice(0, -1) : entry;
|
|
36
|
+
if (JUNK.includes(name)) continue;
|
|
37
|
+
const at = path ? `${path}/${name}` : name;
|
|
38
|
+
out.push({
|
|
39
|
+
name,
|
|
40
|
+
path: at,
|
|
41
|
+
dir,
|
|
42
|
+
children: dir && depth < DEPTH ? await tree(agent, at, depth + 1) : undefined,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return out.sort((a, b) => Number(b.dir) - Number(a.dir) || a.name.localeCompare(b.name));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What the page may write back. */
|
|
49
|
+
export const editable = (path: string): boolean => path.endsWith(".md");
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One thing in the folder, whichever kind it is. Every path in the tree is an
|
|
53
|
+
* 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.
|
|
55
|
+
*/
|
|
56
|
+
export async function open(agent: string, path: string) {
|
|
57
|
+
const resolved = confine(agentDir(agent), path);
|
|
58
|
+
if (!existsSync(resolved)) return null;
|
|
59
|
+
if (statSync(resolved).isDirectory()) {
|
|
60
|
+
return { path, dir: true as const, entries: await tree(agent, path) };
|
|
61
|
+
}
|
|
62
|
+
const file = await read(agentDir(agent), path);
|
|
63
|
+
return { path, dir: false as const, content: file.content, editable: editable(path) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function save(agent: string, path: string, content: string) {
|
|
67
|
+
if (!editable(path)) throw new Error(`${path} is not markdown.`);
|
|
68
|
+
const written = await write(agentDir(agent), path, content);
|
|
69
|
+
return { path, bytes: written.bytes };
|
|
70
|
+
}
|