agent-lyceum 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,351 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { realInvoker } from "./adapters/index.js";
6
+ import { atomicWrite } from "./fs-util.js";
7
+ import { ensureProjectDirs } from "./mailbox.js";
8
+ import { memoryDirs } from "./policy.js";
9
+ import { buildTidyPrompts, MEMORY_INDEX_LIMIT } from "./prompt.js";
10
+ import { git, isGitRepo } from "./worktree.js";
11
+ export const ARCHIVE_DIR = ".archive";
12
+ export const TIDY_STATE_FILE = ".tidy-state.json";
13
+ export const TIDY_REPORT_FILE = "tidy-report.md";
14
+ export const STAMP_RE = /^\d{8}T\d{6}Z$/;
15
+ /** `20261008T031500Z`: UTC, sortable, safe in a file name. */
16
+ export const tidyStamp = (d = new Date()) => d.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
17
+ /** The memory directories to tidy. Without `agent`, every agent that has a directory for the layer; with it, that agent or an error. */
18
+ export function targetsFor(project, opts) {
19
+ const pick = (name) => {
20
+ const dir = project.agents[name]?.memory[opts.layer];
21
+ return dir ? { agent: name, layer: opts.layer, dir } : undefined;
22
+ };
23
+ if (opts.agent) {
24
+ if (!project.agents[opts.agent])
25
+ throw new Error(`No agent "${opts.agent}" in project ${project.name}. Agents: ${Object.keys(project.agents).join(", ")}.`);
26
+ const t = pick(opts.agent);
27
+ if (!t)
28
+ throw new Error(`Agent "${opts.agent}" has no ${opts.layer} memory directory.`);
29
+ return [t];
30
+ }
31
+ return Object.keys(project.agents).flatMap((n) => pick(n) ?? []);
32
+ }
33
+ function walk(dir, base, out) {
34
+ let entries;
35
+ try {
36
+ entries = fs.readdirSync(dir, { withFileTypes: true });
37
+ }
38
+ catch {
39
+ return;
40
+ }
41
+ for (const e of entries) {
42
+ const full = path.join(dir, e.name);
43
+ if (e.isDirectory())
44
+ walk(full, base, out);
45
+ else if (e.isFile()) {
46
+ const rel = path.relative(base, full).split(path.sep).join("/");
47
+ if (rel === TIDY_STATE_FILE)
48
+ continue;
49
+ const st = fs.statSync(full);
50
+ out.push({ rel, bytes: st.size, mtime: st.mtime.toISOString() });
51
+ }
52
+ }
53
+ }
54
+ /** Every file under `dir` (the archive included, the state file not), `/`-separated and sorted. */
55
+ export function listMemoryFiles(dir) {
56
+ const out = [];
57
+ walk(dir, dir, out);
58
+ return out.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
59
+ }
60
+ /** Size and age of the live files (the archive is not counted). */
61
+ export function memoryStats(dir) {
62
+ const live = listMemoryFiles(dir).filter((f) => !f.rel.startsWith(`${ARCHIVE_DIR}/`));
63
+ const times = live.map((f) => f.mtime).sort();
64
+ return {
65
+ files: live.length,
66
+ bytes: live.reduce((n, f) => n + f.bytes, 0),
67
+ indexBytes: live.find((f) => f.rel === "MEMORY.md")?.bytes ?? 0,
68
+ oldest: times[0],
69
+ newest: times.at(-1),
70
+ };
71
+ }
72
+ /** Content hash of every file (the archive included), to tell whether a directory changed. */
73
+ export function fingerprint(dir) {
74
+ const out = {};
75
+ for (const f of listMemoryFiles(dir))
76
+ out[f.rel] = crypto.createHash("sha256").update(fs.readFileSync(path.join(dir, f.rel))).digest("hex");
77
+ return out;
78
+ }
79
+ const slot = (into, i) => path.join(into, String(i));
80
+ /** Copy each directory aside (a directory that does not exist is remembered as absent). */
81
+ export function snapshotDirs(dirs, into) {
82
+ fs.rmSync(into, { recursive: true, force: true });
83
+ fs.mkdirSync(into, { recursive: true });
84
+ dirs.forEach((d, i) => {
85
+ if (fs.existsSync(d))
86
+ fs.cpSync(d, slot(into, i), { recursive: true });
87
+ });
88
+ }
89
+ /** Put each directory back as `snapshotDirs` saw it, removing whatever was added since. */
90
+ export function restoreDirs(dirs, from) {
91
+ dirs.forEach((d, i) => {
92
+ fs.rmSync(d, { recursive: true, force: true });
93
+ if (fs.existsSync(slot(from, i)))
94
+ fs.cpSync(slot(from, i), d, { recursive: true });
95
+ });
96
+ }
97
+ /**
98
+ * Nothing may disappear: every file that existed before is still in place, or archived under the same relative
99
+ * path in this tidy's archive folder. Earlier archives must stay as they were; MEMORY.md is never archived.
100
+ */
101
+ export function verifyConservation(before, dir, stamp) {
102
+ const problems = [];
103
+ for (const rel of before) {
104
+ const here = fs.existsSync(path.join(dir, rel));
105
+ if (here)
106
+ continue;
107
+ const inArchive = !rel.startsWith(`${ARCHIVE_DIR}/`) && rel !== "MEMORY.md" && fs.existsSync(path.join(dir, ARCHIVE_DIR, stamp, rel));
108
+ if (!inArchive)
109
+ problems.push(rel === "MEMORY.md" ? `MEMORY.md is missing (the index must stay in place)` : `${rel} is gone: it is neither in place nor under ${ARCHIVE_DIR}/${stamp}/`);
110
+ }
111
+ return problems;
112
+ }
113
+ export function readTidyState(dir) {
114
+ try {
115
+ const j = JSON.parse(fs.readFileSync(path.join(dir, TIDY_STATE_FILE), "utf8"));
116
+ return j && typeof j === "object" && typeof j.at === "string" ? j : undefined;
117
+ }
118
+ catch {
119
+ return undefined;
120
+ }
121
+ }
122
+ export function writeTidyState(dir, st) {
123
+ atomicWrite(path.join(dir, TIDY_STATE_FILE), JSON.stringify(st, null, 2));
124
+ }
125
+ const LOG_LINES = 50;
126
+ const STAT_LINES = 40;
127
+ function tryGit(cwd, args) {
128
+ try {
129
+ return git(cwd, args);
130
+ }
131
+ catch {
132
+ return undefined;
133
+ }
134
+ }
135
+ const lines = (s) => (s ? s.split("\n").filter(Boolean) : []);
136
+ export function projectChanges(repoDir, since) {
137
+ if (!fs.existsSync(repoDir) || !isGitRepo(repoDir))
138
+ return { log: [], stat: [], note: "the project directory is not a git repository" };
139
+ const head = tryGit(repoDir, ["rev-parse", "HEAD"]);
140
+ if (!head)
141
+ return { log: [], stat: [], note: "the repository has no commits yet" };
142
+ if (!since)
143
+ return { head, log: [], stat: [], note: "first tidy: there is no earlier baseline, only the path check applies" };
144
+ if (since === head)
145
+ return { head, since, log: [], stat: [], note: "no commits since the last tidy" };
146
+ if (tryGit(repoDir, ["cat-file", "-t", `${since}^{commit}`]) !== "commit")
147
+ return { head, since, log: [], stat: [], note: "the baseline commit of the last tidy no longer exists in this repository (history was rewritten?)" };
148
+ const log = lines(tryGit(repoDir, ["log", "--oneline", "--no-decorate", `${since}..${head}`]));
149
+ const stat = lines(tryGit(repoDir, ["diff", "--stat", since, head]));
150
+ return {
151
+ head,
152
+ since,
153
+ log: log.length > LOG_LINES ? [...log.slice(0, LOG_LINES), `… and ${log.length - LOG_LINES} more commits`] : log,
154
+ stat: stat.length > STAT_LINES ? [...stat.slice(0, STAT_LINES - 1), stat.at(-1) ?? ""] : stat,
155
+ };
156
+ }
157
+ const PATH_TOKEN = /`([^`\n]+)`/g;
158
+ function looksLikePath(t) {
159
+ if (!t.includes("/") || /\s/.test(t) || t.includes("://"))
160
+ return false;
161
+ if (/^-|[*?{}<>$|;&=]/.test(t))
162
+ return false;
163
+ return true;
164
+ }
165
+ /** Backticked paths in the live Markdown files of `dir` that do not exist. Relative ones are looked up in `repoDir` (skipped without it). */
166
+ export function findMissingPaths(dir, repoDir) {
167
+ const seen = new Set();
168
+ const out = [];
169
+ for (const f of listMemoryFiles(dir)) {
170
+ if (f.rel.startsWith(`${ARCHIVE_DIR}/`) || !f.rel.endsWith(".md"))
171
+ continue;
172
+ const text = fs.readFileSync(path.join(dir, f.rel), "utf8");
173
+ for (const m of text.matchAll(PATH_TOKEN)) {
174
+ const tok = (m[1] ?? "").trim();
175
+ if (!looksLikePath(tok))
176
+ continue;
177
+ const abs = tok.startsWith("~/") ? path.join(os.homedir(), tok.slice(2)) : path.isAbsolute(tok) ? tok : repoDir ? path.join(repoDir, tok) : undefined;
178
+ if (!abs || fs.existsSync(abs))
179
+ continue;
180
+ const key = `${f.rel}\0${tok}`;
181
+ if (seen.has(key))
182
+ continue;
183
+ seen.add(key);
184
+ out.push({ file: f.rel, path: tok });
185
+ }
186
+ }
187
+ return out;
188
+ }
189
+ /** Scratch space of one tidy (snapshot and the invocation's logs); outside every memory dir and every run. */
190
+ export const tidyWorkDir = (project, stamp, agent) => path.join(project.paths.root, "tidy-work", stamp, agent);
191
+ const kb = (n) => (n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KiB`);
192
+ function describeTarget(t, project, say) {
193
+ const s = memoryStats(t.dir);
194
+ say(`${t.agent} (${t.layer}): ${s.files} file(s), ${kb(s.bytes)}, MEMORY.md ${kb(s.indexBytes)}${s.oldest ? `, modified ${s.oldest.slice(0, 10)} … ${s.newest?.slice(0, 10)}` : ""}`);
195
+ if (t.layer === "project") {
196
+ const c = projectChanges(project.dir, readTidyState(t.dir)?.head);
197
+ say(` project changes: ${c.note ?? `${c.log.length} commit(s) since ${c.since?.slice(0, 8)}`}`);
198
+ }
199
+ for (const m of findMissingPaths(t.dir, t.layer === "project" ? project.dir : undefined))
200
+ say(` missing path: ${m.path} (in ${m.file})`);
201
+ }
202
+ async function tidyOne(o, t, stamp) {
203
+ const { project } = o;
204
+ const say = o.say ?? ((s) => console.log(s));
205
+ const base = { agent: t.agent, layer: t.layer, dir: t.dir };
206
+ const agent = project.agents[t.agent];
207
+ const live = listMemoryFiles(t.dir).filter((f) => !f.rel.startsWith(`${ARCHIVE_DIR}/`));
208
+ if (live.length === 0)
209
+ return { ...base, status: "skipped", problems: ["no memory files: nothing to tidy"] };
210
+ const dirs = memoryDirs(agent);
211
+ const others = dirs.filter((d) => d !== t.dir);
212
+ const before = listMemoryFiles(t.dir).map((f) => f.rel);
213
+ const othersBefore = others.map((d) => fingerprint(d));
214
+ const archiveDir = path.join(t.dir, ARCHIVE_DIR, stamp);
215
+ const workDir = tidyWorkDir(project, stamp, t.agent);
216
+ const snapshot = path.join(workDir, "snapshot");
217
+ const state = readTidyState(t.dir);
218
+ const changes = t.layer === "project" ? projectChanges(project.dir, state?.head) : undefined;
219
+ const missing = findMissingPaths(t.dir, t.layer === "project" ? project.dir : undefined);
220
+ const index = fs.existsSync(path.join(t.dir, "MEMORY.md")) ? fs.readFileSync(path.join(t.dir, "MEMORY.md"), "utf8") : "";
221
+ const prompts = buildTidyPrompts(agent, { layer: t.layer, dir: t.dir, archiveDir, files: live, index, changes, missing });
222
+ ensureProjectDirs(project);
223
+ snapshotDirs(dirs, snapshot);
224
+ const fail = (problems) => {
225
+ restoreDirs(dirs, snapshot);
226
+ fs.rmSync(snapshot, { recursive: true, force: true });
227
+ return { ...base, status: "failed", problems };
228
+ };
229
+ say(`tidying ${t.layer} memory of ${t.agent} (${live.length} file(s)) …`);
230
+ const invoke = o.invoker ?? realInvoker;
231
+ const result = await invoke({
232
+ project,
233
+ agent,
234
+ workDir,
235
+ systemPrompt: prompts.systemPrompt,
236
+ userPrompt: prompts.userPrompt,
237
+ timeoutSec: project.dispatcher.wake_timeout_sec,
238
+ signal: o.signal,
239
+ logDir: path.join(workDir, "log"),
240
+ }).catch((e) => ({ ok: false, text: "", exitCode: null, timedOut: false, error: e.message }));
241
+ if (!result.ok)
242
+ return fail([`the wake-up failed: ${result.error ?? "unknown error"}`]);
243
+ const problems = [];
244
+ others.forEach((d, i) => {
245
+ if (JSON.stringify(fingerprint(d)) !== JSON.stringify(othersBefore[i]))
246
+ problems.push(`the agent changed ${d}, which is not the ${t.layer} memory being tidied`);
247
+ });
248
+ problems.push(...verifyConservation(before, t.dir, stamp));
249
+ const reportFile = path.join(archiveDir, TIDY_REPORT_FILE);
250
+ if (!fs.existsSync(reportFile) || fs.readFileSync(reportFile, "utf8").trim() === "")
251
+ problems.push(`${ARCHIVE_DIR}/${stamp}/${TIDY_REPORT_FILE} is missing or empty`);
252
+ if (problems.length)
253
+ return fail(problems);
254
+ fs.rmSync(snapshot, { recursive: true, force: true });
255
+ writeTidyState(t.dir, { ...(changes?.head ? { head: changes.head } : {}), at: (o.now?.() ?? new Date()).toISOString(), archive: stamp });
256
+ return { ...base, status: "tidied", archive: stamp, problems: [] };
257
+ }
258
+ /** Tidy the memory of the chosen agents, one after another. Only ever called by a person (`agent-lyceum memory tidy`). */
259
+ export async function tidyMemory(o) {
260
+ const say = o.say ?? ((s) => console.log(s));
261
+ const targets = targetsFor(o.project, { layer: o.layer, agent: o.agent });
262
+ const results = [];
263
+ for (const t of targets) {
264
+ if (o.dryRun) {
265
+ describeTarget(t, o.project, say);
266
+ results.push({ agent: t.agent, layer: t.layer, dir: t.dir, status: "dry-run", problems: [] });
267
+ continue;
268
+ }
269
+ const r = await tidyOne(o, t, tidyStamp(o.now?.()));
270
+ results.push(r);
271
+ if (o.signal?.aborted)
272
+ break;
273
+ }
274
+ return results;
275
+ }
276
+ function titleOf(file, rel) {
277
+ const text = fs.readFileSync(file, "utf8");
278
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
279
+ const name = fm ? /^name:\s*(.+)$/m.exec(fm[1] ?? "")?.[1]?.trim() : undefined;
280
+ if (name)
281
+ return name;
282
+ const h = /^#\s+(.+)$/m.exec(text)?.[1]?.trim();
283
+ return h || path.basename(rel, path.extname(rel));
284
+ }
285
+ function pruneEmpty(dir, stop) {
286
+ for (let d = dir; d !== stop && d.startsWith(stop + path.sep); d = path.dirname(d)) {
287
+ if (fs.readdirSync(d).length)
288
+ break;
289
+ fs.rmdirSync(d);
290
+ }
291
+ }
292
+ /** Move the files of one tidy's archive back to where they were. Nothing that exists now is overwritten. */
293
+ export function restoreMemory(o) {
294
+ if (!STAMP_RE.test(o.stamp))
295
+ throw new Error(`"${o.stamp}" is not an archive stamp (expected something like 20261008T031500Z).`);
296
+ const [t] = targetsFor(o.project, { layer: o.layer, agent: o.agent });
297
+ const archive = path.join(t.dir, ARCHIVE_DIR, o.stamp);
298
+ if (!fs.existsSync(archive))
299
+ throw new Error(`No archive ${o.stamp} for ${o.agent} (${o.layer}). Archives: ${listArchives(t.dir).join(", ") || "(none)"}.`);
300
+ const restored = [];
301
+ const skipped = [];
302
+ const inArchive = listMemoryFiles(archive).filter((f) => f.rel !== TIDY_REPORT_FILE);
303
+ for (const f of inArchive) {
304
+ const from = path.join(archive, f.rel);
305
+ const to = path.join(t.dir, f.rel);
306
+ if (fs.existsSync(to)) {
307
+ skipped.push({ rel: f.rel, reason: "a file with this name exists now; it was not overwritten" });
308
+ continue;
309
+ }
310
+ fs.mkdirSync(path.dirname(to), { recursive: true });
311
+ fs.renameSync(from, to);
312
+ pruneEmpty(path.dirname(from), archive);
313
+ restored.push(f.rel);
314
+ }
315
+ const indexFile = path.join(t.dir, "MEMORY.md");
316
+ const index = fs.existsSync(indexFile) ? fs.readFileSync(indexFile, "utf8") : "";
317
+ const add = restored.filter((rel) => rel.endsWith(".md") && !index.includes(rel)).map((rel) => `- [${titleOf(path.join(t.dir, rel), rel)}](${rel}) — restored from tidy ${o.stamp}`);
318
+ if (add.length)
319
+ atomicWrite(indexFile, `${index.replace(/\n*$/, index ? "\n" : "")}${add.join("\n")}\n`);
320
+ return { restored, skipped };
321
+ }
322
+ /** The stamps of the archives in a memory directory, oldest first. */
323
+ export function listArchives(dir) {
324
+ try {
325
+ return fs.readdirSync(path.join(dir, ARCHIVE_DIR)).filter((n) => STAMP_RE.test(n)).sort();
326
+ }
327
+ catch {
328
+ return [];
329
+ }
330
+ }
331
+ export const ADVICE_COMMITS = 50;
332
+ /** Hints only: project memory whose index is larger than what an agent is shown, or whose baseline is far behind. Never acts. */
333
+ export function tidyAdvice(project) {
334
+ const out = [];
335
+ for (const t of targetsFor(project, { layer: "project" })) {
336
+ if (!fs.existsSync(t.dir))
337
+ continue;
338
+ const s = memoryStats(t.dir);
339
+ if (s.indexBytes > MEMORY_INDEX_LIMIT) {
340
+ out.push({ agent: t.agent, reason: `MEMORY.md is ${s.indexBytes} bytes, more than the ${MEMORY_INDEX_LIMIT} an agent is shown` });
341
+ continue;
342
+ }
343
+ const since = readTidyState(t.dir)?.head;
344
+ if (!since || !fs.existsSync(project.dir) || !isGitRepo(project.dir))
345
+ continue;
346
+ const n = Number(tryGit(project.dir, ["rev-list", "--count", `${since}..HEAD`]));
347
+ if (Number.isFinite(n) && n >= ADVICE_COMMITS)
348
+ out.push({ agent: t.agent, reason: `${n} commits since the last tidy` });
349
+ }
350
+ return out;
351
+ }
package/dist/policy.js CHANGED
@@ -49,9 +49,10 @@ export function repoDirFor(project, agent) {
49
49
  return project.workspaces?.[agent]?.dir ?? project.dir;
50
50
  }
51
51
  export function writePolicy(project, agent) {
52
- const isLead = agent.name === project.lead;
52
+ const isLead = agent.name === project.lead && !project.solo;
53
53
  const ws = project.workspaces?.[agent.name];
54
- const allowDirs = [...memoryDirs(agent), outboxDir(project, agent.name), ...(ws ? [ws.gitDir] : [])];
54
+ // A `call` has no mailboxes at all; a solo member of a run keeps its outbox to send `done`.
55
+ const allowDirs = [...memoryDirs(agent), ...(project.solo === "call" ? [] : [outboxDir(project, agent.name)]), ...(ws ? [ws.gitDir] : [])];
55
56
  const allowFiles = [];
56
57
  if (isLead)
57
58
  allowFiles.push(commonFile(project));
package/dist/prompt.d.ts CHANGED
@@ -1,11 +1,35 @@
1
1
  import type { ResolvedAgent, ResolvedProject } from "./config.js";
2
2
  import type { Message } from "./mailbox.js";
3
+ import type { Layer, MemoryFile, MissingPath, ProjectChanges } from "./memory-tidy.js";
3
4
  export declare const COMMON_LIMIT: number;
4
5
  export declare const MEMORY_INDEX_LIMIT: number;
5
6
  export declare const LEAD_BATCH_LIMIT = 5;
6
7
  export declare function readPersona(agent: ResolvedAgent): string;
8
+ /** The protocol of an agent working alone: no teammates, no ask. `run --agent` still ends with a `done` mail; `call` just answers. */
9
+ export declare function buildSoloSystemPrompt(project: ResolvedProject, agent: ResolvedAgent): string;
10
+ /** The prompt of a `call`: the memory indexes the agent is used to, then the task. */
11
+ export declare function buildCallUserPrompt(agent: ResolvedAgent, task: string): string;
7
12
  /** Everything that goes to the runtime's system prompt: the persona plus the team protocol. */
8
13
  export declare function buildSystemPrompt(project: ResolvedProject, agent: ResolvedAgent): string;
9
14
  /** Messages one wake-up handles: the oldest one, or for the lead a run of `reply`/`failure` mail (up to LEAD_BATCH_LIMIT) so it decides once on all of it. */
10
15
  export declare function pickMessages(project: ResolvedProject, agent: ResolvedAgent, queue: Message[]): Message[];
11
16
  export declare function buildUserPrompt(project: ResolvedProject, agent: ResolvedAgent, unread: Message[], handleCount?: number): string;
17
+ export interface TidyContext {
18
+ layer: Layer;
19
+ /** The memory directory being tidied. */
20
+ dir: string;
21
+ /** Absolute path of this tidy's archive folder (the agent creates it). */
22
+ archiveDir: string;
23
+ /** Live files only (the archive is not listed). */
24
+ files: MemoryFile[];
25
+ /** MEMORY.md text ("" when there is none). */
26
+ index: string;
27
+ /** What changed in the project since the last tidy (project layer only). */
28
+ changes?: ProjectChanges;
29
+ missing: MissingPath[];
30
+ }
31
+ /** The prompts of a memory-tidy wake-up: a maintenance job, not a task, so it has none of the mail protocol. */
32
+ export declare function buildTidyPrompts(agent: ResolvedAgent, ctx: TidyContext): {
33
+ systemPrompt: string;
34
+ userPrompt: string;
35
+ };
package/dist/prompt.js CHANGED
@@ -22,8 +22,46 @@ function readCapped(file, limit) {
22
22
  export function readPersona(agent) {
23
23
  return fs.readFileSync(agent.agentMd, "utf8").trim();
24
24
  }
25
+ /** The protocol of an agent working alone: no teammates, no ask. `run --agent` still ends with a `done` mail; `call` just answers. */
26
+ export function buildSoloSystemPrompt(project, agent) {
27
+ const isCall = project.solo === "call";
28
+ const lines = [readPersona(agent), "", "---", "", "# Solo protocol (injected by agent-lyceum)", ""];
29
+ lines.push(`You are **${agent.name}**, working alone: there is no team, no lead and nobody to mail, and you cannot ask the user questions. You work in \`${repoDirFor(project, agent.name)}\`.`, isCall
30
+ ? "Do the task you are given. Your final reply text is printed to the user as your answer, so end with the answer or a short report of what you did; nothing else is delivered."
31
+ : "");
32
+ if (!isCall) {
33
+ lines.push("## Finishing", `When the job is finished, create a Markdown file (any name ending in \`.md\`) directly inside \`${outboxDir(project, agent.name)}\` with this exact shape:`, "", "```", "---", "type: done", "subject: <short subject>", "outcome: completed|partial|blocked|failed", "---", "", "## Result", "## Files", "## Verification", "## Not done", "```", "", "That ends the run. `## Result` is the actual deliverable or conclusion (not just \"done\"), `## Files` the paths you created or changed, `## Verification` what you ran or checked and what it showed, `## Not done` anything skipped or unverified (or `None`). Only `completed` means success and it needs all four headings; if work remains or is blocked say `partial`/`blocked`/`failed` (those need only `## Result` and `## Not done`). A summary in your reply text is never delivered: without a `done` mail the run ends as idle with no report.");
34
+ }
35
+ lines.push("", "## Memory");
36
+ const mems = memoryDirs(agent);
37
+ if (mems.length === 0)
38
+ lines.push("You have no long-term memory directory.");
39
+ for (const m of mems)
40
+ lines.push(`- ${memoryLabel(agent, m)}: \`${m}\``);
41
+ if (mems.length) {
42
+ lines.push("Keep a `MEMORY.md` index in each memory directory (one line per entry, pointing at a file). Its contents are shown in your prompt on every wake-up; you can write only inside these directories.", "Use memory only for durable lessons that stay true across tasks; never record task-specific details there.");
43
+ }
44
+ lines.push("", "## Rules", "- Never edit any `AGENT.md`, `COMMON.md`, or the repository's own `CLAUDE.md`/`AGENTS.md`.", "- Never touch another agent's memory, inbox or outbox, or the configuration files of agent-lyceum.");
45
+ return lines.join("\n");
46
+ }
47
+ /** The prompt of a `call`: the memory indexes the agent is used to, then the task. */
48
+ export function buildCallUserPrompt(agent, task) {
49
+ const parts = [];
50
+ for (const dir of memoryDirs(agent)) {
51
+ const idx = readCapped(path.join(dir, "MEMORY.md"), MEMORY_INDEX_LIMIT);
52
+ parts.push(`# Your ${memoryLabel(agent, dir).split(" ")[0]} memory index (${dir}/MEMORY.md)`);
53
+ if (!idx)
54
+ parts.push("(no MEMORY.md yet — create one when you have something worth keeping)", "");
55
+ else
56
+ parts.push(idx.text.trim(), ...(idx.truncated ? [`[index truncated at ${MEMORY_INDEX_LIMIT} bytes — trim it]`] : []), "");
57
+ }
58
+ parts.push("# Task", task.trim());
59
+ return parts.join("\n");
60
+ }
25
61
  /** Everything that goes to the runtime's system prompt: the persona plus the team protocol. */
26
62
  export function buildSystemPrompt(project, agent) {
63
+ if (project.solo)
64
+ return buildSoloSystemPrompt(project, agent);
27
65
  const outbox = outboxDir(project, agent.name);
28
66
  const mates = Object.keys(project.agents).filter((n) => n !== agent.name);
29
67
  const recipients = agent.canMessage === "all" ? mates : agent.canMessage.filter((n) => project.agents[n] && n !== agent.name);
@@ -34,13 +72,31 @@ export function buildSystemPrompt(project, agent) {
34
72
  ? [
35
73
  `That directory is your own isolated git worktree (branch \`${project.workspaces?.[agent.name]?.branch}\`): teammates cannot see your changes while you work, and you cannot see theirs. Do not commit, push, switch branches or touch any other checkout; the dispatcher collects your changes from this directory after you finish.`,
36
74
  ]
37
- : []), `The team lead is **${project.lead}**${isLead ? " (that is you)" : ""}. Teammates: ${mates.join(", ") || "(none)"}.`, "", "## Sending mail", "You cannot talk to teammates directly. Mail you receive is shown in your prompt. To send mail, create a Markdown file", `(any name ending in \`.md\`) directly inside \`${outbox}\` with this exact shape:`, "", "```", "---", "to: <agent-name>", "type: reply # task | reply" + (isLead ? " | done" : ""), "subject: <short subject>", "reply_to: <message id> # optional", "---", "", "<body>", "```", "", `You may send to: ${recipients.join(", ") || "(nobody)"}.`, isLead
75
+ : []), `The team lead is **${project.lead}**${isLead ? " (that is you)" : ""}. Teammates: ${mates.join(", ") || "(none)"}.`, "", "## Sending mail", "You cannot talk to teammates directly. Mail you receive is shown in your prompt. To send mail, create a Markdown file", `(any name ending in \`.md\`) directly inside \`${outbox}\` with this exact shape:`, "", "```", "---", "to: <agent-name>", `type: reply # task | reply${agent.canAskUser ? " | ask" : ""}${isLead ? " | done" : ""}`, "subject: <short subject>", "reply_to: <message id> # optional", "---", "", "<body>", "```", "", `You may send to: ${recipients.join(", ") || "(nobody)"}.`, isLead
38
76
  ? `When the whole job is finished, send a message with \`type: done\` (no \`to\` needed). That ends the run. A summary in your reply text is never delivered and does not end the run: after the last teammate reply is verified you MUST send \`done\` (or a \`task\` for the remaining work), or the run stops as "idle" with no final report.`
39
77
  : `Report results to the lead by mail; the lead decides what happens next.`, isLead
40
78
  ? "Each wake-up handles one message, except that several `reply`/`failure` messages waiting together are handed to you in one wake-up: weigh them together, then decide the next step once."
41
79
  : "Each wake-up handles one message. Do the work it asks, then send the replies that are needed, then stop.", "", "## Message format", "The body of every `task` and `reply` must contain these `##` headings, exactly as written (write `None` under a heading if there is nothing to say).", "Mail missing a heading is still delivered, but is flagged to the recipient.", "", `- \`task\`: ${REQUIRED_SECTIONS.task.map((h) => `\`## ${h}\``).join(", ")} (Upstream = id of the mail this task derives from, or \`None\`).`, `- \`reply\`: ${REQUIRED_SECTIONS.reply.map((h) => `\`## ${h}\``).join(", ")}.`, isLead
42
80
  ? "- `done`: your final report to the user (shown when the run ends, saved as `result.md`). Its frontmatter must contain `outcome: completed|partial|blocked|failed`, and the body the headings `## Result` (the actual deliverable or conclusion, not just \"done\"), `## Files` (paths created or changed), `## Verification` (what you ran or checked, and what it showed) and `## Not done` (anything skipped or unverified, or `None`). Only `completed` means success, and it needs all four headings and every checklist step ticked; if work remains or is blocked, say `partial`/`blocked`/`failed` (those need only `## Result` and `## Not done`). A `done` that breaks this is sent back to you and does not end the run."
43
- : "- `done` has no required headings.", ...(isLead
81
+ : "- `done` has no required headings.", ...(agent.canAskUser
82
+ ? [
83
+ "",
84
+ "## Asking the user",
85
+ "You may ask the human user with `type: ask` (no `to` needed). The run then pauses until they answer, so ask only when the answer changes what you do next and you cannot work it out from the code, the memory or the task. Ask everything you need in ONE batch (at most 10 questions); prefer questions with `<options>` and a `<suggested>` value (with a `reason`) so the user can answer quickly. The body is exactly this:",
86
+ "",
87
+ "```",
88
+ "<ask>",
89
+ ' <question id="q1">',
90
+ " <text>Session or JWT for login?</text>",
91
+ " <options><option>session</option><option>jwt</option></options>",
92
+ ' <suggested reason="existing code already uses cookies">session</suggested>',
93
+ " </question>",
94
+ "</ask>",
95
+ "```",
96
+ "",
97
+ "The answers come back to you as a `reply` mail containing an `<answers>` block. A user answer that starts with `other:` is free text. Do not ask what you could decide yourself, and do not ask again for something already answered.",
98
+ ]
99
+ : []), ...(isLead
44
100
  ? [
45
101
  "",
46
102
  "## Progress tracking (optional, shown in `agent-lyceum status`)",
@@ -123,3 +179,58 @@ export function buildUserPrompt(project, agent, unread, handleCount = 1) {
123
179
  }
124
180
  return parts.join("\n");
125
181
  }
182
+ /** The prompts of a memory-tidy wake-up: a maintenance job, not a task, so it has none of the mail protocol. */
183
+ export function buildTidyPrompts(agent, ctx) {
184
+ const systemPrompt = [
185
+ readPersona(agent),
186
+ "",
187
+ "---",
188
+ "",
189
+ "# Memory tidy (injected by agent-lyceum)",
190
+ "",
191
+ `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.`,
192
+ "",
193
+ "## What you may touch",
194
+ `Only files inside \`${ctx.dir}\`. Everything else is read-only. Do not touch your other memory directories, \`${ctx.dir}/.tidy-state.json\` or the archive folders of earlier tidies.`,
195
+ "",
196
+ "## What to do",
197
+ "- Merge entries that say the same thing; keep one file with the combined, current content.",
198
+ "- 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.",
199
+ "- Keep `MEMORY.md` as the index: one line per entry, each pointing at an existing file, and nothing for files that were archived.",
200
+ "- **When unsure, keep it.** Prefer leaving an entry alone over losing information.",
201
+ "",
202
+ "## Never delete",
203
+ `To retire a file, move it into the archive folder, keeping its relative path (\`${ctx.dir}/x/y.md\` goes to \`${ctx.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.`,
204
+ "",
205
+ "## Before you finish",
206
+ `Write \`${ctx.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.`,
207
+ ].join("\n");
208
+ const parts = [
209
+ `Archive directory: ${ctx.archiveDir}`,
210
+ `Layer: ${ctx.layer}${ctx.layer === "global" ? " (shared by every project; do not judge entries by one project's code)" : ""}`,
211
+ `Memory directory: ${ctx.dir}`,
212
+ "",
213
+ "# Files",
214
+ ...(ctx.files.length ? ctx.files.map((f) => `${f.rel} ${f.bytes} B modified ${f.mtime.slice(0, 10)}`) : ["(none)"]),
215
+ "",
216
+ `# MEMORY.md (${ctx.dir}/MEMORY.md)`,
217
+ ctx.index.trim() ? ctx.index.trim() : "(no MEMORY.md)",
218
+ ];
219
+ if (ctx.layer === "project") {
220
+ const c = ctx.changes;
221
+ parts.push("", "# Changes in the project since the last tidy");
222
+ if (!c)
223
+ parts.push("(not available)");
224
+ else {
225
+ if (c.note)
226
+ parts.push(c.note);
227
+ if (c.log.length)
228
+ parts.push("", "Commits:", ...c.log);
229
+ if (c.stat.length)
230
+ parts.push("", "Files changed:", ...c.stat);
231
+ }
232
+ }
233
+ parts.push("", "# Paths named in the memory that no longer exist");
234
+ parts.push(...(ctx.missing.length ? ctx.missing.map((m) => `- \`${m.path}\` (mentioned in ${m.file})`) : ["(none found)"]));
235
+ return { systemPrompt, userPrompt: parts.join("\n") };
236
+ }
@@ -16,6 +16,8 @@ export function endOutcome(endReason, done) {
16
16
  return { outcome: done?.outcome ?? "partial", note: done?.note };
17
17
  case "lead_failed":
18
18
  return { outcome: "failed", note: "The lead's wake-up failed." };
19
+ case "waiting":
20
+ return { outcome: "waiting", note: "Waiting for your answers. Fill in ask-reply.md or run `agent-lyceum answer <run-id>`, then continue with `agent-lyceum resume`." };
19
21
  case "cancelled":
20
22
  return { outcome: "cancelled", note: "Cancelled before the lead finished. Unread mail was kept; continue with `agent-lyceum resume`." };
21
23
  case "idle":
@@ -8,6 +8,8 @@ export type { ActiveWake, EndReason, RunState, SentTopic, WakeRecord, WakeTopic
8
8
  export declare const RESULT_FILE = "result.md";
9
9
  /** How many times a non-conforming `done` goes back to the lead before it is accepted as `partial`. */
10
10
  export declare const MAX_DONE_REJECTIONS = 2;
11
+ /** How many times a malformed `ask` goes back to its sender before the lead is told instead. */
12
+ export declare const MAX_ASK_REJECTIONS = 2;
11
13
  export interface RunSummary {
12
14
  runId: string;
13
15
  runDir: string;
@@ -21,9 +23,13 @@ export interface RunSummary {
21
23
  subject: string;
22
24
  body: string;
23
25
  };
26
+ /** Set when the run waits for the user: the file with the questions. */
27
+ askReplyPath?: string;
24
28
  }
25
29
  export interface RunOptions {
26
30
  project: ResolvedProject;
31
+ /** Run only this member of the project, alone (`run --agent`). A resumed run uses the one recorded in its state. */
32
+ soloAgent?: string;
27
33
  /** The task for a new run; omit when resuming. */
28
34
  task?: PreparedTask;
29
35
  /** Continue this earlier run (same run dir, sessions and counters) instead of starting a new one. */
@@ -63,6 +69,8 @@ export declare class RunSession {
63
69
  /** The attempt that produced each batch member's output; committed together with its claim. */
64
70
  private readonly attempts;
65
71
  constructor(opts: RunOptions);
72
+ /** A waiting run only continues once every unanswered question has a valid answer. */
73
+ private static assertAnswered;
66
74
  private saveState;
67
75
  private journal;
68
76
  private note;
@@ -76,6 +84,10 @@ export declare class RunSession {
76
84
  private integrateMember;
77
85
  /** After a batch (or, on resume, before the first one): revert guarded edits, route mail, detect done / lead failure. */
78
86
  private settle;
87
+ /** The user answered: send each asker its answers as a `reply`, then mark them delivered. Safe to repeat after a crash. */
88
+ private deliverAnswers;
89
+ /** Store the questions of this pass's valid `ask` mails; malformed ones go back to their sender. Returns true when the run should now wait. */
90
+ private handleAsks;
79
91
  /** On resume: sort out what the interrupted process left behind before anything is woken. */
80
92
  private recover;
81
93
  run(): Promise<RunSummary>;