agent-lyceum 0.22.0 → 0.24.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/dist/adapters/claude.d.ts +6 -1
- package/dist/adapters/claude.js +13 -9
- package/dist/adapters/codex.js +6 -4
- package/dist/adapters/types.d.ts +6 -0
- package/dist/call.js +0 -1
- package/dist/cli.js +7 -3
- package/dist/doctor.js +12 -0
- package/dist/fs-util.d.ts +23 -0
- package/dist/fs-util.js +78 -0
- package/dist/guard.d.ts +8 -0
- package/dist/guard.js +123 -1
- package/dist/mailbox.js +6 -0
- package/dist/memory-tidy.d.ts +14 -1
- package/dist/memory-tidy.js +184 -48
- package/dist/message-store.js +22 -1
- package/dist/paths.d.ts +0 -1
- package/dist/paths.js +0 -1
- package/dist/policy.d.ts +5 -5
- package/dist/policy.js +12 -12
- package/dist/prompt.d.ts +28 -4
- package/dist/prompt.js +71 -43
- package/dist/records.d.ts +30 -0
- package/dist/records.js +130 -0
- package/dist/redo.d.ts +57 -0
- package/dist/redo.js +281 -0
- package/dist/run-session.d.ts +5 -0
- package/dist/run-session.js +50 -8
- package/dist/scaffold.js +1 -1
- package/package.json +1 -1
package/dist/prompt.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import { readFileCapped } from "./fs-util.js";
|
|
2
|
+
import { cutUtf8, readFileCapped } from "./fs-util.js";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { REQUIRED_SECTIONS } from "./format.js";
|
|
5
|
-
import {
|
|
5
|
+
import { buildRecordsView, RECORDS_BUDGET } from "./records.js";
|
|
6
|
+
import { commonFile, memoryDirs, outboxDir, recordsKindDir, repoDirFor, sharedDir, sharedIndex } from "./policy.js";
|
|
6
7
|
function memoryLabel(agent, dir) {
|
|
7
8
|
if (dir === agent.memory.task)
|
|
8
9
|
return "task (this task only, isolated)";
|
|
@@ -11,8 +12,11 @@ function memoryLabel(agent, dir) {
|
|
|
11
12
|
export const COMMON_LIMIT = 8 * 1024;
|
|
12
13
|
export const MEMORY_INDEX_LIMIT = 4 * 1024;
|
|
13
14
|
export const HANDOFF_LIMIT = COMMON_LIMIT;
|
|
14
|
-
/** Cap of each of
|
|
15
|
-
export const CONTEXT_LIMIT =
|
|
15
|
+
/** Cap of each kind of project record (decisions, lessons) in a prompt. */
|
|
16
|
+
export const CONTEXT_LIMIT = RECORDS_BUDGET;
|
|
17
|
+
/** Largest mail body put in a prompt (the inline task limit), and the total for the mails handled in one wake-up. */
|
|
18
|
+
export const MAIL_BODY_LIMIT = 16 * 1024;
|
|
19
|
+
export const MAIL_BATCH_LIMIT = 64 * 1024;
|
|
16
20
|
export const LEAD_BATCH_LIMIT = 5;
|
|
17
21
|
const BATCHABLE = new Set(["reply", "failure"]);
|
|
18
22
|
const readCapped = readFileCapped;
|
|
@@ -134,7 +138,7 @@ export function buildSystemPrompt(project, agent) {
|
|
|
134
138
|
lines.push("- Never edit any `AGENT.md`, `COMMON.md`, or the repository's own `CLAUDE.md`/`AGENTS.md`" +
|
|
135
139
|
(isLead || agent.canEditAgentMd ? " unless you need to as lead (changes are logged)." : "."), "- Never touch another agent's memory, inbox or outbox, or the team's config files.", `- The shared team context is in \`${commonFile(project)}\`${isLead ? " (you maintain it; keep it short)" : " (read-only)"}.`, ...(isLead
|
|
136
140
|
? [
|
|
137
|
-
`- You also keep two durable records for every future run of this project: \`${
|
|
141
|
+
`- You also keep two kinds of durable records for every future run of this project, one Markdown file per entry: decisions in \`${recordsKindDir(project, "decisions")}/\` (what was decided and why) and lessons in \`${recordsKindDir(project, "lessons")}/\` (heuristics and pitfalls that stay true). The file name without \`.md\` is the entry id (lowercase letters, digits, \`-\`). Each file starts with YAML frontmatter between \`---\` lines: \`status: active|superseded\`, \`date: YYYY-MM-DD\`, \`title: <one line>\`, optionally \`run: <run id>\` and \`supersedes: [<ids of entries of the same kind>]\`; the body is the decision or lesson and its reason. Prompts show only effective-active entries (\`status: active\` and not named in another entry's \`supersedes\`), newest first, up to ${CONTEXT_LIMIT / 1024} KB per kind, and list what does not fit as one line each. When an entry becomes outdated, mark it \`status: superseded\` or name it in a new entry's \`supersedes\`. Add or update entries before you send \`done\`.`,
|
|
138
142
|
]
|
|
139
143
|
: []), PR_ATTRIBUTION_RULE, NO_DAEMONS_RULE);
|
|
140
144
|
return lines.join("\n");
|
|
@@ -168,16 +172,13 @@ export function buildUserPrompt(project, agent, unread, handleCount = 1, handoff
|
|
|
168
172
|
parts.push(`\n[COMMON.md was truncated at ${COMMON_LIMIT} bytes — the lead should condense it.]`);
|
|
169
173
|
}
|
|
170
174
|
}
|
|
171
|
-
for (const [title,
|
|
172
|
-
["Project decisions
|
|
173
|
-
["Project lessons
|
|
175
|
+
for (const [title, kind] of [
|
|
176
|
+
["Project decisions", "decisions"],
|
|
177
|
+
["Project lessons", "lessons"],
|
|
174
178
|
]) {
|
|
175
|
-
const
|
|
176
|
-
if (
|
|
177
|
-
|
|
178
|
-
parts.push("", `# ${title}`, rec.text.trim());
|
|
179
|
-
if (rec.truncated)
|
|
180
|
-
parts.push(`\n[${path.basename(file)} was truncated at ${CONTEXT_LIMIT} bytes — the lead should move the oldest entries to ${path.basename(archiveFile(file))}.]`);
|
|
179
|
+
const view = buildRecordsView(project, kind, CONTEXT_LIMIT);
|
|
180
|
+
if (view)
|
|
181
|
+
parts.push("", `# ${title}`, view); // nothing recorded yet: no heading, no tokens
|
|
181
182
|
}
|
|
182
183
|
const handoffFile = handoff ? sharedIndex(project) : undefined;
|
|
183
184
|
const notes = handoffFile ? readCapped(handoffFile, HANDOFF_LIMIT) : undefined;
|
|
@@ -205,10 +206,17 @@ export function buildUserPrompt(project, agent, unread, handleCount = 1, handoff
|
|
|
205
206
|
parts.push("", current.length > 1 ? `# Messages to handle now (${current.length}, oldest first — decide on them together)` : "# Message to handle now");
|
|
206
207
|
if (current.length === 0)
|
|
207
208
|
parts.push("(no message)");
|
|
209
|
+
let remaining = MAIL_BATCH_LIMIT;
|
|
208
210
|
current.forEach((m, i) => {
|
|
209
211
|
if (current.length > 1)
|
|
210
212
|
parts.push("", `## Message ${i + 1} of ${current.length}`);
|
|
211
|
-
|
|
213
|
+
const raw = Buffer.from(m.body, "utf8");
|
|
214
|
+
const shown = cutUtf8(raw, Math.min(MAIL_BODY_LIMIT, remaining));
|
|
215
|
+
remaining -= shown.length;
|
|
216
|
+
const body = shown.length === raw.length
|
|
217
|
+
? m.body
|
|
218
|
+
: `${shown.toString("utf8")}\n[agent-lyceum: this message was cut at ${shown.length} of ${raw.length} bytes; read the whole message in \`${m.file}\` (it moves to \`read/\` in the same folder after this wake-up)]`;
|
|
219
|
+
parts.push(`id: ${m.meta.id}`, `from: ${m.meta.from}`, `type: ${m.meta.type}`, `thread: ${m.meta.thread}`, `subject: ${m.meta.subject}`, "", body);
|
|
212
220
|
});
|
|
213
221
|
if (queued.length) {
|
|
214
222
|
parts.push("", "# Queued messages (not handled in this wake-up; each gets its own wake-up later, titles only)");
|
|
@@ -219,6 +227,8 @@ export function buildUserPrompt(project, agent, unread, handleCount = 1, handoff
|
|
|
219
227
|
}
|
|
220
228
|
/** The prompts of a memory-tidy wake-up: a maintenance job, not a task, so it has none of the mail protocol. */
|
|
221
229
|
export function buildTidyPrompts(agent, ctx) {
|
|
230
|
+
const { dir, archiveDir, records } = ctx;
|
|
231
|
+
const touch = [dir ? `your ${ctx.layer} memory (\`${dir}\`)` : "", records ? `the project records (\`${records.dir}\`)` : ""].filter(Boolean).join(" and ");
|
|
222
232
|
const systemPrompt = [
|
|
223
233
|
readPersona(agent),
|
|
224
234
|
"",
|
|
@@ -226,35 +236,46 @@ export function buildTidyPrompts(agent, ctx) {
|
|
|
226
236
|
"",
|
|
227
237
|
"# Memory tidy (injected by agent-lyceum)",
|
|
228
238
|
"",
|
|
229
|
-
`You are **${agent.name}**. This wake-up is a maintenance job, not a task: there is no mail to send or read, and nothing to do in the repository. Your only job is to tidy your own ${ctx.layer} memory so it stays small, accurate and useful.`,
|
|
239
|
+
`You are **${agent.name}**. This wake-up is a maintenance job, not a task: there is no mail to send or read, and nothing to do in the repository. Your only job is to tidy ${records ? touch : `your own ${ctx.layer} memory`} so it stays small, accurate and useful.`,
|
|
230
240
|
"",
|
|
231
241
|
"## What you may touch",
|
|
232
|
-
`Only files inside
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
242
|
+
`Only files inside ${[dir ? `\`${dir}\`` : "", records ? `\`${records.dir}\`` : ""].filter(Boolean).join(" and ")}. Everything else is read-only. Do not touch your other memory directories, \`${dir ? `${dir}/` : ""}.tidy-state.json\` or the archive folders of earlier tidies.`,
|
|
243
|
+
...(dir && archiveDir
|
|
244
|
+
? [
|
|
245
|
+
"",
|
|
246
|
+
records ? "## What to do with your memory" : "## What to do",
|
|
247
|
+
"- Merge entries that say the same thing; keep one file with the combined, current content.",
|
|
248
|
+
"- Rewrite entries that are stale (see the project changes and the missing paths below); correct them if you can tell what is true now, otherwise keep them and say so in the report.",
|
|
249
|
+
"- Keep `MEMORY.md` as the index: one line per entry, each pointing at an existing file, and nothing for files that were archived.",
|
|
250
|
+
"- **When unsure, keep it.** Prefer leaving an entry alone over losing information.",
|
|
251
|
+
"",
|
|
252
|
+
"## Never delete",
|
|
253
|
+
`To retire a file, move it into the archive folder, keeping its relative path (\`${dir}/x/y.md\` goes to \`${archiveDir}/x/y.md\`). Never delete a file. \`MEMORY.md\` itself is never archived. A file whose content you merged into another one is archived too.`,
|
|
254
|
+
"",
|
|
255
|
+
records ? "## Before you finish (memory)" : "## Before you finish",
|
|
256
|
+
`Write \`${archiveDir}/tidy-report.md\` — even when you changed nothing — with these headings: \`## Merged\` (what was combined into what), \`## Archived\` (each archived file and why), \`## Unsure\` (things you kept but doubt). Write \`None\` under a heading with nothing to say. The tidy is rolled back if the report is missing or any file went missing.`,
|
|
257
|
+
]
|
|
258
|
+
: []),
|
|
259
|
+
...(records
|
|
260
|
+
? [
|
|
261
|
+
"",
|
|
262
|
+
"## What to do with the project records",
|
|
263
|
+
`The records are the project's durable decisions and lessons, one file per entry (\`${records.dir}/decisions/<id>.md\`, \`${records.dir}/lessons/<id>.md\`). Every entry has frontmatter with \`status\` (\`active\` or \`superseded\`), \`date\` (YYYY-MM-DD) and \`title\` (one line), optionally \`run\` and \`supersedes\` (a list of ids).`,
|
|
264
|
+
"- Merge entries that say the same thing into one entry (write the merged content, then archive the entries that were merged).",
|
|
265
|
+
"- When a newer entry replaces an older one, list the old id under `supersedes` in the newer entry, or set the old entry to `status: superseded`.",
|
|
266
|
+
"- Archive entries that are obsolete.",
|
|
267
|
+
"- Keep the frontmatter valid: `status`, `date` and `title` are required; the id is the file name without `.md` and uses only lowercase letters, digits and `-`.",
|
|
268
|
+
"- **When unsure, keep it.** Never delete a file.",
|
|
269
|
+
`To archive a record, move it into \`${records.archiveDir}\`, keeping its relative path (\`${records.dir}/lessons/x.md\` goes to \`${records.archiveDir}/lessons/x.md\`).`,
|
|
270
|
+
`Write \`${records.archiveDir}/tidy-report.md\` — even when you changed nothing — with the headings \`## Merged\`, \`## Archived\`, \`## Unsure\` (\`None\` when empty). The tidy of both folders is rolled back if the report is missing or any file went missing.`,
|
|
271
|
+
]
|
|
272
|
+
: []),
|
|
245
273
|
].join("\n");
|
|
246
|
-
const parts = [
|
|
247
|
-
|
|
248
|
-
`Layer: ${ctx.layer}${ctx.layer === "global" ? " (shared by every project; do not judge entries by one project's code)" : ""}`,
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
"# Files",
|
|
252
|
-
...(ctx.files.length ? ctx.files.map((f) => `${f.rel} ${f.bytes} B modified ${f.mtime.slice(0, 10)}`) : ["(none)"]),
|
|
253
|
-
"",
|
|
254
|
-
`# MEMORY.md (${ctx.dir}/MEMORY.md)`,
|
|
255
|
-
ctx.index.trim() ? ctx.index.trim() : "(no MEMORY.md)",
|
|
256
|
-
];
|
|
257
|
-
if (ctx.layer === "project") {
|
|
274
|
+
const parts = [];
|
|
275
|
+
if (dir && archiveDir) {
|
|
276
|
+
parts.push(`Archive directory: ${archiveDir}`, `Layer: ${ctx.layer}${ctx.layer === "global" ? " (shared by every project; do not judge entries by one project's code)" : ""}`, `Memory directory: ${dir}`, "", "# Files", ...(ctx.files.length ? ctx.files.map((f) => `${f.rel} ${f.bytes} B modified ${f.mtime.slice(0, 10)}`) : ["(none)"]), "", `# MEMORY.md (${dir}/MEMORY.md)`, ctx.index.trim() ? ctx.index.trim() : "(no MEMORY.md)");
|
|
277
|
+
}
|
|
278
|
+
if (ctx.layer === "project" && dir) {
|
|
258
279
|
const c = ctx.changes;
|
|
259
280
|
parts.push("", "# Changes in the project since the last tidy");
|
|
260
281
|
if (!c)
|
|
@@ -268,7 +289,14 @@ export function buildTidyPrompts(agent, ctx) {
|
|
|
268
289
|
parts.push("", "Files changed:", ...c.stat);
|
|
269
290
|
}
|
|
270
291
|
}
|
|
271
|
-
|
|
272
|
-
|
|
292
|
+
if (dir) {
|
|
293
|
+
parts.push("", "# Paths named in the memory that no longer exist");
|
|
294
|
+
parts.push(...(ctx.missing.length ? ctx.missing.map((m) => `- \`${m.path}\` (mentioned in ${m.file})`) : ["(none found)"]));
|
|
295
|
+
}
|
|
296
|
+
if (records) {
|
|
297
|
+
parts.push(...(parts.length ? [""] : []), "# Project records", `Records archive directory: ${records.archiveDir}`, `Records directory: ${records.dir}`, "", "Entries (kind/id status date size title):", ...(records.entries.length
|
|
298
|
+
? records.entries.map((e) => `${e.rel} ${e.status} ${e.date || "no date"} ${e.bytes} B ${e.title}${e.invalid ? ` [INVALID: ${e.invalid}]` : ""}`)
|
|
299
|
+
: ["(none)"]), ...(records.otherFiles.length ? ["", "Other files (leave them alone):", ...records.otherFiles.map((f) => `${f.rel} ${f.bytes} B`)] : []));
|
|
300
|
+
}
|
|
273
301
|
return { systemPrompt, userPrompt: parts.join("\n") };
|
|
274
302
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ResolvedProject } from "./config.js";
|
|
2
|
+
export type RecordKind = "decisions" | "lessons";
|
|
3
|
+
export declare const RECORD_KINDS: readonly RecordKind[];
|
|
4
|
+
/** Cap of one kind's effective-active entries in a prompt (and, for `done`, the most they may add up to). */
|
|
5
|
+
export declare const RECORDS_BUDGET: number;
|
|
6
|
+
export interface ProjectRecord {
|
|
7
|
+
kind: RecordKind;
|
|
8
|
+
id: string;
|
|
9
|
+
file: string;
|
|
10
|
+
status: "active" | "superseded";
|
|
11
|
+
/** `YYYY-MM-DD`; empty when the frontmatter is invalid. */
|
|
12
|
+
date: string;
|
|
13
|
+
run?: string;
|
|
14
|
+
supersedes: string[];
|
|
15
|
+
title: string;
|
|
16
|
+
body: string;
|
|
17
|
+
/** Why the entry could not be read as a record; set entries still count as active so nothing silently disappears. */
|
|
18
|
+
invalid?: string;
|
|
19
|
+
}
|
|
20
|
+
/** Every `*.md` entry of one kind, sorted by id. Entries that cannot be parsed are listed with `invalid` set; a missing folder is an empty list. */
|
|
21
|
+
export declare function listRecords(project: ResolvedProject, kind: RecordKind): ProjectRecord[];
|
|
22
|
+
/** Entries shown in prompts: `status: active` and not named in another entry's `supersedes`. */
|
|
23
|
+
export declare function effectiveActive(records: ProjectRecord[]): ProjectRecord[];
|
|
24
|
+
/** Bytes the effective-active entries of a kind take in a prompt (each rendered whole, as `buildRecordsView` does). */
|
|
25
|
+
export declare function activeRecordsBytes(project: ResolvedProject, kind: RecordKind): number;
|
|
26
|
+
/**
|
|
27
|
+
* The text of one kind for a prompt: effective-active entries, newest first, whole entries only until the budget is
|
|
28
|
+
* used (never cut inside one); the rest are one line each, to read on demand. Undefined when there is nothing to show.
|
|
29
|
+
*/
|
|
30
|
+
export declare function buildRecordsView(project: ResolvedProject, kind: RecordKind, budgetBytes?: number): string | undefined;
|
package/dist/records.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import { readRegularFileNoFollow } from "./fs-util.js";
|
|
5
|
+
import { recordsDir, recordsKindDir } from "./policy.js";
|
|
6
|
+
export const RECORD_KINDS = ["decisions", "lessons"];
|
|
7
|
+
/** Cap of one kind's effective-active entries in a prompt (and, for `done`, the most they may add up to). */
|
|
8
|
+
export const RECORDS_BUDGET = 6 * 1024;
|
|
9
|
+
const ID_RE = /^[a-z0-9-]+$/;
|
|
10
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
11
|
+
function parseEntry(kind, file) {
|
|
12
|
+
const id = path.basename(file, ".md");
|
|
13
|
+
const fallback = { kind, id, file, status: "active", date: "", supersedes: [], title: path.basename(file), body: "" };
|
|
14
|
+
// Never open a symlink (it could point into .archive/ or outside the tree) or a FIFO/device (it blocks the reader).
|
|
15
|
+
const buf = readRegularFileNoFollow(file);
|
|
16
|
+
if (!buf)
|
|
17
|
+
return { ...fallback, invalid: "not a readable regular file (symlinks and special files are never read)" };
|
|
18
|
+
const text = buf.toString("utf8");
|
|
19
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n([\s\S]*))?$/.exec(text);
|
|
20
|
+
if (!m)
|
|
21
|
+
return { ...fallback, body: text.trim(), invalid: "missing frontmatter" };
|
|
22
|
+
const body = (m[2] ?? "").trim();
|
|
23
|
+
let fm;
|
|
24
|
+
try {
|
|
25
|
+
fm = parseYaml(m[1]);
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
return { ...fallback, body, invalid: `frontmatter is not valid YAML: ${e.message.split("\n")[0]}` };
|
|
29
|
+
}
|
|
30
|
+
if (!fm || typeof fm !== "object" || Array.isArray(fm))
|
|
31
|
+
return { ...fallback, body, invalid: "frontmatter is not a mapping" };
|
|
32
|
+
const f = fm;
|
|
33
|
+
const problems = [];
|
|
34
|
+
if (!ID_RE.test(id))
|
|
35
|
+
problems.push("file name must be lowercase letters, digits and -");
|
|
36
|
+
if (f.status !== "active" && f.status !== "superseded")
|
|
37
|
+
problems.push("status must be active or superseded");
|
|
38
|
+
// YAML parses an unquoted 2026-01-02 as a Date.
|
|
39
|
+
const date = f.date instanceof Date ? f.date.toISOString().slice(0, 10) : f.date;
|
|
40
|
+
if (typeof date !== "string" || !DATE_RE.test(date))
|
|
41
|
+
problems.push("date must be YYYY-MM-DD");
|
|
42
|
+
if (typeof f.title !== "string" || !f.title.trim() || /[\r\n]/.test(f.title.trim()))
|
|
43
|
+
problems.push("title must be one non-empty line");
|
|
44
|
+
let supersedes = [];
|
|
45
|
+
if (f.supersedes !== undefined) {
|
|
46
|
+
if (Array.isArray(f.supersedes) && f.supersedes.every((x) => typeof x === "string"))
|
|
47
|
+
supersedes = f.supersedes;
|
|
48
|
+
else
|
|
49
|
+
problems.push("supersedes must be a list of ids");
|
|
50
|
+
}
|
|
51
|
+
if (f.run !== undefined && typeof f.run !== "string")
|
|
52
|
+
problems.push("run must be a string");
|
|
53
|
+
if (problems.length)
|
|
54
|
+
return { ...fallback, body, invalid: problems.join("; ") };
|
|
55
|
+
return {
|
|
56
|
+
kind,
|
|
57
|
+
id,
|
|
58
|
+
file,
|
|
59
|
+
status: f.status,
|
|
60
|
+
date: date,
|
|
61
|
+
run: f.run,
|
|
62
|
+
supersedes,
|
|
63
|
+
title: f.title.trim(),
|
|
64
|
+
body,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** Every `*.md` entry of one kind, sorted by id. Entries that cannot be parsed are listed with `invalid` set; a missing folder is an empty list. */
|
|
68
|
+
export function listRecords(project, kind) {
|
|
69
|
+
const dir = recordsKindDir(project, kind);
|
|
70
|
+
let names;
|
|
71
|
+
try {
|
|
72
|
+
// A symlinked records/ or kind folder could point at archived or outside entries: list nothing, never descend.
|
|
73
|
+
for (const d of [recordsDir(project), dir])
|
|
74
|
+
if (!fs.lstatSync(d).isDirectory())
|
|
75
|
+
return [];
|
|
76
|
+
names = fs.readdirSync(dir);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
return names
|
|
82
|
+
.filter((n) => n.endsWith(".md"))
|
|
83
|
+
.sort()
|
|
84
|
+
.map((n) => parseEntry(kind, path.join(dir, n)));
|
|
85
|
+
}
|
|
86
|
+
/** Entries shown in prompts: `status: active` and not named in another entry's `supersedes`. */
|
|
87
|
+
export function effectiveActive(records) {
|
|
88
|
+
const replaced = new Set();
|
|
89
|
+
for (const r of records)
|
|
90
|
+
for (const id of r.supersedes)
|
|
91
|
+
if (id !== r.id)
|
|
92
|
+
replaced.add(id);
|
|
93
|
+
return records.filter((r) => r.status === "active" && !replaced.has(r.id));
|
|
94
|
+
}
|
|
95
|
+
function newestFirst(records) {
|
|
96
|
+
return [...records].sort((a, b) => b.date.localeCompare(a.date) || b.id.localeCompare(a.id));
|
|
97
|
+
}
|
|
98
|
+
function renderEntry(r) {
|
|
99
|
+
return `## ${r.title} [${r.id}]${r.body ? `\n${r.body}` : ""}`;
|
|
100
|
+
}
|
|
101
|
+
/** Bytes the effective-active entries of a kind take in a prompt (each rendered whole, as `buildRecordsView` does). */
|
|
102
|
+
export function activeRecordsBytes(project, kind) {
|
|
103
|
+
return Buffer.byteLength(effectiveActive(listRecords(project, kind)).map(renderEntry).join("\n\n"));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The text of one kind for a prompt: effective-active entries, newest first, whole entries only until the budget is
|
|
107
|
+
* used (never cut inside one); the rest are one line each, to read on demand. Undefined when there is nothing to show.
|
|
108
|
+
*/
|
|
109
|
+
export function buildRecordsView(project, kind, budgetBytes = RECORDS_BUDGET) {
|
|
110
|
+
const entries = newestFirst(effectiveActive(listRecords(project, kind)));
|
|
111
|
+
if (entries.length === 0)
|
|
112
|
+
return undefined;
|
|
113
|
+
const shown = [];
|
|
114
|
+
let used = 0;
|
|
115
|
+
let i = 0;
|
|
116
|
+
for (; i < entries.length; i++) {
|
|
117
|
+
const text = renderEntry(entries[i]);
|
|
118
|
+
const size = Buffer.byteLength(text) + (shown.length ? 2 : 0);
|
|
119
|
+
if (used + size > budgetBytes)
|
|
120
|
+
break;
|
|
121
|
+
shown.push(text);
|
|
122
|
+
used += size;
|
|
123
|
+
}
|
|
124
|
+
const rest = entries.slice(i);
|
|
125
|
+
const parts = [...shown];
|
|
126
|
+
if (rest.length) {
|
|
127
|
+
parts.push(`${shown.length ? "More" : "These"} active entries did not fit in ${budgetBytes} bytes; read the file on demand:`, rest.map((r) => `- [${r.id}] ${r.title} (read: ${r.file})`).join("\n"));
|
|
128
|
+
}
|
|
129
|
+
return parts.join("\n\n");
|
|
130
|
+
}
|
package/dist/redo.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** An earlier attempt of the same agent at the same message that failed: what a redo starts from. */
|
|
2
|
+
export interface PriorAttempt {
|
|
3
|
+
/** `<claim id>-a<n>` */
|
|
4
|
+
id: string;
|
|
5
|
+
claimId: string;
|
|
6
|
+
n: number;
|
|
7
|
+
/** The attempt's error; "unknown (attempt record unreadable)" when its record cannot be read. */
|
|
8
|
+
error: string;
|
|
9
|
+
/** For sorting: finished_at ?? started_at ?? the folder's mtime (epoch ms). */
|
|
10
|
+
at: number;
|
|
11
|
+
/** Files the record says were set aside; absent when the record is unreadable. */
|
|
12
|
+
parked?: string[];
|
|
13
|
+
}
|
|
14
|
+
/** Something left out; `path` is relative to the run folder. */
|
|
15
|
+
export interface Skip {
|
|
16
|
+
path: string;
|
|
17
|
+
reason: string;
|
|
18
|
+
}
|
|
19
|
+
export interface PriorResult {
|
|
20
|
+
/** Valid candidates, newest first. */
|
|
21
|
+
prior: PriorAttempt[];
|
|
22
|
+
/** Records or folders that were passed over; they never affect the other candidates. */
|
|
23
|
+
skipped: Skip[];
|
|
24
|
+
}
|
|
25
|
+
export interface DraftsResult {
|
|
26
|
+
/** The whole drafts block; "" when there is nothing usable. */
|
|
27
|
+
text: string;
|
|
28
|
+
injected: {
|
|
29
|
+
path: string;
|
|
30
|
+
bytes: number;
|
|
31
|
+
truncated: boolean;
|
|
32
|
+
}[];
|
|
33
|
+
skipped: Skip[];
|
|
34
|
+
/** Attempts whose folder and record disagree on what was set aside. */
|
|
35
|
+
reconciled: {
|
|
36
|
+
attempt: string;
|
|
37
|
+
onlyOnDisk: string[];
|
|
38
|
+
onlyInRecord: string[];
|
|
39
|
+
}[];
|
|
40
|
+
}
|
|
41
|
+
/** Attempts of `agent` that failed before this invoke, on a claim for one of `messageIds`. Never throws. */
|
|
42
|
+
export declare function priorFailedAttempts(runDir: string, agent: string, messageIds: string[], exclude: string): PriorResult;
|
|
43
|
+
/** The drafts block for the attempts in `prior` (newest first), read from their folders, at most `budget` bytes and `maxFiles` files. */
|
|
44
|
+
export declare function collectDrafts(runDir: string, prior: PriorAttempt[], budget?: number, maxFiles?: number): DraftsResult;
|
|
45
|
+
/** Prompt for a repeat attempt in the same wake-up: the earlier one failed and may have changed things already. */
|
|
46
|
+
export declare function retryPrompt(prompt: string, error: string | undefined, o: {
|
|
47
|
+
drafts: boolean;
|
|
48
|
+
taskMemory?: string;
|
|
49
|
+
}): string;
|
|
50
|
+
/** The user prompt for this invoke: `base`, a Retry or Redo notice when there is something to redo, then the drafts. No IO. */
|
|
51
|
+
export declare function redoUserPrompt(base: string, o: {
|
|
52
|
+
attempt: number;
|
|
53
|
+
error?: string;
|
|
54
|
+
prior: PriorAttempt[];
|
|
55
|
+
drafts: string;
|
|
56
|
+
taskMemory?: string;
|
|
57
|
+
}): string;
|