agent-lyceum 0.21.2 → 0.23.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/prompt.js CHANGED
@@ -2,7 +2,8 @@ import fs from "node:fs";
2
2
  import { readFileCapped } from "./fs-util.js";
3
3
  import path from "node:path";
4
4
  import { REQUIRED_SECTIONS } from "./format.js";
5
- import { commonFile, memoryDirs, outboxDir, repoDirFor, sharedDir, sharedIndex } from "./policy.js";
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,6 +12,8 @@ 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;
15
+ /** Cap of each kind of project record (decisions, lessons) in a prompt. */
16
+ export const CONTEXT_LIMIT = RECORDS_BUDGET;
14
17
  export const LEAD_BATCH_LIMIT = 5;
15
18
  const BATCHABLE = new Set(["reply", "failure"]);
16
19
  const readCapped = readFileCapped;
@@ -88,7 +91,7 @@ export function buildSystemPrompt(project, agent) {
88
91
  ? `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.`
89
92
  : `Report results to the lead by mail; the lead decides what happens next.`, isLead
90
93
  ? "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."
91
- : "Each wake-up handles one message. Do the work it asks, then send the replies that are needed, then stop.", "Mail that repeats an earlier mail of yours to the same agent (same type, same text) is not sent again; you are told, and should wait for the answer or ask for something different instead of resending.", "", "## 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
94
+ : "Each wake-up handles one message, except that consecutive `reply`/`failure` messages on the same thread may be handed to you together: handle them together. Do the work it asks, then send the replies that are needed, then stop.", "Mail that repeats an earlier mail of yours to the same agent (same type, same text) is not sent again; you are told, and should wait for the answer or ask for something different instead of resending.", "", "## 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).", "A `task` or `reply` missing a heading is sent back to you (at most twice per sender + type + subject); after that it is delivered with a warning 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
92
95
  ? "- `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. " + OPEN_PR_RULE
93
96
  : "- `done` has no required headings.", ...(agent.canAskUser
94
97
  ? [
@@ -130,18 +133,26 @@ export function buildSystemPrompt(project, agent) {
130
133
  }
131
134
  lines.push("", "## Rules");
132
135
  lines.push("- Never edit any `AGENT.md`, `COMMON.md`, or the repository's own `CLAUDE.md`/`AGENTS.md`" +
133
- (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)"}.`, PR_ATTRIBUTION_RULE, NO_DAEMONS_RULE);
136
+ (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
137
+ ? [
138
+ `- 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\`.`,
139
+ ]
140
+ : []), PR_ATTRIBUTION_RULE, NO_DAEMONS_RULE);
134
141
  return lines.join("\n");
135
142
  }
136
- /** 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. */
143
+ /** Messages one wake-up handles: the oldest one, or a run of `reply`/`failure` mail (up to LEAD_BATCH_LIMIT) so the agent decides once on all of it; the lead takes any such run, other agents only same-thread ones. */
137
144
  export function pickMessages(project, agent, queue) {
138
145
  const sorted = [...queue].sort((a, b) => path.basename(a.file).localeCompare(path.basename(b.file)));
139
- if (agent.name !== project.lead || !BATCHABLE.has(sorted[0]?.meta.type))
146
+ const first = sorted[0];
147
+ if (!first || !BATCHABLE.has(first.meta.type))
140
148
  return sorted.slice(0, 1);
149
+ const isLead = agent.name === project.lead;
141
150
  const run = [];
142
151
  for (const m of sorted) {
143
152
  if (!BATCHABLE.has(m.meta.type) || run.length >= LEAD_BATCH_LIMIT)
144
153
  break;
154
+ if (!isLead && m.meta.thread !== first.meta.thread)
155
+ break;
145
156
  run.push(m);
146
157
  }
147
158
  return run;
@@ -158,6 +169,14 @@ export function buildUserPrompt(project, agent, unread, handleCount = 1, handoff
158
169
  parts.push(`\n[COMMON.md was truncated at ${COMMON_LIMIT} bytes — the lead should condense it.]`);
159
170
  }
160
171
  }
172
+ for (const [title, kind] of [
173
+ ["Project decisions", "decisions"],
174
+ ["Project lessons", "lessons"],
175
+ ]) {
176
+ const view = buildRecordsView(project, kind, CONTEXT_LIMIT);
177
+ if (view)
178
+ parts.push("", `# ${title}`, view); // nothing recorded yet: no heading, no tokens
179
+ }
161
180
  const handoffFile = handoff ? sharedIndex(project) : undefined;
162
181
  const notes = handoffFile ? readCapped(handoffFile, HANDOFF_LIMIT) : undefined;
163
182
  if (handoffFile && notes?.text.trim()) {
@@ -198,6 +217,8 @@ export function buildUserPrompt(project, agent, unread, handleCount = 1, handoff
198
217
  }
199
218
  /** The prompts of a memory-tidy wake-up: a maintenance job, not a task, so it has none of the mail protocol. */
200
219
  export function buildTidyPrompts(agent, ctx) {
220
+ const { dir, archiveDir, records } = ctx;
221
+ const touch = [dir ? `your ${ctx.layer} memory (\`${dir}\`)` : "", records ? `the project records (\`${records.dir}\`)` : ""].filter(Boolean).join(" and ");
201
222
  const systemPrompt = [
202
223
  readPersona(agent),
203
224
  "",
@@ -205,35 +226,46 @@ export function buildTidyPrompts(agent, ctx) {
205
226
  "",
206
227
  "# Memory tidy (injected by agent-lyceum)",
207
228
  "",
208
- `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.`,
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 ${records ? touch : `your own ${ctx.layer} memory`} so it stays small, accurate and useful.`,
209
230
  "",
210
231
  "## What you may touch",
211
- `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.`,
212
- "",
213
- "## What to do",
214
- "- Merge entries that say the same thing; keep one file with the combined, current content.",
215
- "- 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.",
216
- "- Keep `MEMORY.md` as the index: one line per entry, each pointing at an existing file, and nothing for files that were archived.",
217
- "- **When unsure, keep it.** Prefer leaving an entry alone over losing information.",
218
- "",
219
- "## Never delete",
220
- `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.`,
221
- "",
222
- "## Before you finish",
223
- `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.`,
232
+ `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.`,
233
+ ...(dir && archiveDir
234
+ ? [
235
+ "",
236
+ records ? "## What to do with your memory" : "## What to do",
237
+ "- Merge entries that say the same thing; keep one file with the combined, current content.",
238
+ "- 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.",
239
+ "- Keep `MEMORY.md` as the index: one line per entry, each pointing at an existing file, and nothing for files that were archived.",
240
+ "- **When unsure, keep it.** Prefer leaving an entry alone over losing information.",
241
+ "",
242
+ "## Never delete",
243
+ `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.`,
244
+ "",
245
+ records ? "## Before you finish (memory)" : "## Before you finish",
246
+ `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.`,
247
+ ]
248
+ : []),
249
+ ...(records
250
+ ? [
251
+ "",
252
+ "## What to do with the project records",
253
+ `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).`,
254
+ "- Merge entries that say the same thing into one entry (write the merged content, then archive the entries that were merged).",
255
+ "- 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`.",
256
+ "- Archive entries that are obsolete.",
257
+ "- 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 `-`.",
258
+ "- **When unsure, keep it.** Never delete a file.",
259
+ `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\`).`,
260
+ `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.`,
261
+ ]
262
+ : []),
224
263
  ].join("\n");
225
- const parts = [
226
- `Archive directory: ${ctx.archiveDir}`,
227
- `Layer: ${ctx.layer}${ctx.layer === "global" ? " (shared by every project; do not judge entries by one project's code)" : ""}`,
228
- `Memory directory: ${ctx.dir}`,
229
- "",
230
- "# Files",
231
- ...(ctx.files.length ? ctx.files.map((f) => `${f.rel} ${f.bytes} B modified ${f.mtime.slice(0, 10)}`) : ["(none)"]),
232
- "",
233
- `# MEMORY.md (${ctx.dir}/MEMORY.md)`,
234
- ctx.index.trim() ? ctx.index.trim() : "(no MEMORY.md)",
235
- ];
236
- if (ctx.layer === "project") {
264
+ const parts = [];
265
+ if (dir && archiveDir) {
266
+ 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)");
267
+ }
268
+ if (ctx.layer === "project" && dir) {
237
269
  const c = ctx.changes;
238
270
  parts.push("", "# Changes in the project since the last tidy");
239
271
  if (!c)
@@ -247,7 +279,14 @@ export function buildTidyPrompts(agent, ctx) {
247
279
  parts.push("", "Files changed:", ...c.stat);
248
280
  }
249
281
  }
250
- parts.push("", "# Paths named in the memory that no longer exist");
251
- parts.push(...(ctx.missing.length ? ctx.missing.map((m) => `- \`${m.path}\` (mentioned in ${m.file})`) : ["(none found)"]));
282
+ if (dir) {
283
+ parts.push("", "# Paths named in the memory that no longer exist");
284
+ parts.push(...(ctx.missing.length ? ctx.missing.map((m) => `- \`${m.path}\` (mentioned in ${m.file})`) : ["(none found)"]));
285
+ }
286
+ if (records) {
287
+ 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
288
+ ? records.entries.map((e) => `${e.rel} ${e.status} ${e.date || "no date"} ${e.bytes} B ${e.title}${e.invalid ? ` [INVALID: ${e.invalid}]` : ""}`)
289
+ : ["(none)"]), ...(records.otherFiles.length ? ["", "Other files (leave them alone):", ...records.otherFiles.map((f) => `${f.rel} ${f.bytes} B`)] : []));
290
+ }
252
291
  return { systemPrompt, userPrompt: parts.join("\n") };
253
292
  }
@@ -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;
@@ -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
+ }
@@ -382,6 +382,8 @@ export class RunSession {
382
382
  state.sessions[sessionKey] = result.sessionId;
383
383
  if (result.outputTokens)
384
384
  state.output_tokens += result.outputTokens;
385
+ state.cache_read_tokens += result.cacheReadTokens ?? 0;
386
+ state.cache_write_tokens += result.cacheWriteTokens ?? 0;
385
387
  delete state.active[agent.name];
386
388
  // With a chain only: which model ran, what it replaced, why it failed.
387
389
  const which = chain ? { runtime: c.runtime, model: c.model, fallback_from: c.primary || !replaced ? undefined : modelLabel(replaced), kind: result.kind } : {};
@@ -392,6 +394,8 @@ export class RunSession {
392
394
  duration_ms: Date.now() - started,
393
395
  ok: result.ok,
394
396
  output_tokens: result.outputTokens,
397
+ cache_read_tokens: result.cacheReadTokens,
398
+ cache_write_tokens: result.cacheWriteTokens,
395
399
  handling,
396
400
  error: result.error,
397
401
  ...which,
@@ -32,6 +32,9 @@ export interface WakeRecord {
32
32
  duration_ms: number;
33
33
  ok: boolean;
34
34
  output_tokens?: number;
35
+ /** Prompt-cache input tokens read / written by this wake (claude only). */
36
+ cache_read_tokens?: number;
37
+ cache_write_tokens?: number;
35
38
  handling: WakeTopic[];
36
39
  /** Mail this wake handed to others; `to` is "(done)" for the lead's final message. */
37
40
  sent?: SentTopic[];
@@ -105,6 +108,9 @@ export interface RunState {
105
108
  pr_url?: string;
106
109
  sessions: Record<string, string>;
107
110
  output_tokens: number;
111
+ /** Run totals of the wakes' prompt-cache input tokens (0 for runs before this was recorded, and for codex). */
112
+ cache_read_tokens: number;
113
+ cache_write_tokens: number;
108
114
  /** `runtime`/`model` only for agents with a fallback chain. */
109
115
  last_wake: Record<string, {
110
116
  at: string;
package/dist/run-store.js CHANGED
@@ -46,6 +46,8 @@ const WireState = z
46
46
  ask_rejections: z.record(z.string(), nonNeg).optional(),
47
47
  sessions: z.record(z.string(), z.string()).optional(),
48
48
  output_tokens: nonNeg.optional(),
49
+ cache_read_tokens: nonNeg.optional(),
50
+ cache_write_tokens: nonNeg.optional(),
49
51
  last_wake: z.record(z.string(), z.any()).optional(),
50
52
  pid: z.number().optional(),
51
53
  task_summary: z.string().optional(),
@@ -86,6 +88,8 @@ function normalize(w) {
86
88
  max_rounds: w.max_rounds ?? 0,
87
89
  sessions: w.sessions ?? {},
88
90
  output_tokens: w.output_tokens ?? 0,
91
+ cache_read_tokens: w.cache_read_tokens ?? 0,
92
+ cache_write_tokens: w.cache_write_tokens ?? 0,
89
93
  last_wake: w.last_wake ?? {},
90
94
  pid: w.pid ?? 0,
91
95
  task_summary: w.task_summary ?? "",
package/dist/scaffold.js CHANGED
@@ -6,7 +6,8 @@ const PERSONAS = {
6
6
 
7
7
  You coordinate the team. You receive the user's task, break it into concrete pieces, and delegate each piece to the right teammate by mail.
8
8
 
9
- - Keep \`COMMON.md\` (shared team context: goal, architecture decisions, conventions) accurate and short.
9
+ - Keep \`COMMON.md\` (shared team context: goal, conventions) accurate and short.
10
+ - Before you send \`done\`, record what was decided and why as decision entries, and what the project learned as lesson entries, in the records folders named in your rules (one file per entry, with frontmatter); mark outdated entries \`superseded\`. Later runs start from the effective-active ones (about 6 KB per kind).
10
11
  - Write every task with the standard task headings, and cite the mail it derives from under Upstream so teammates can trace it.
11
12
  - When a teammate reports back, decide the next step: assign follow-up work, ask another teammate to verify, or accept the result.
12
13
  - Teammates cannot message each other by default; route everything through yourself.
@@ -1,6 +1,6 @@
1
1
  import type { ResolvedProject } from "./config.js";
2
2
  import { type StatusReport } from "./status.js";
3
- /** What `status --json --watch` prints, one JSON object per line: the project's report plus one per running task. */
3
+ /** What `status --json --watch` prints, one JSON object per line: the project's report plus one per task that is not completed. */
4
4
  export type WatchFrame = {
5
5
  schema_version: 1;
6
6
  report?: StatusReport;
@@ -8,9 +8,10 @@ export type WatchFrame = {
8
8
  error?: string;
9
9
  };
10
10
  /**
11
- * The frame for now. Every running task gets its own report; a task that was running in the previous frame
12
- * and has ended since is included once more, so the reader sees it finish (and can toast) before it is dropped.
13
- * `tracked` is that previous set; it is replaced by the ids running now.
11
+ * The frame for now. Every task that is not completed (running, interrupted, waiting for answers, or ended partial,
12
+ * blocked, failed or cancelled) gets its own report, so it stays listed until it is resumed to completion or cleared.
13
+ * A task that was running in the previous frame and has completed since is included once more, so the reader sees
14
+ * it finish (and can toast) before it is dropped. `tracked` is that previous set; it is replaced by the ids running now.
14
15
  */
15
16
  export declare function buildWatchFrame(project: ResolvedProject, tracked: Set<string>): WatchFrame;
16
17
  export interface WatchDeps {
@@ -1,16 +1,17 @@
1
1
  import fs from "node:fs";
2
- import { buildStatusReport, buildTaskListReport } from "./status.js";
2
+ import { buildStatusReport, buildTaskListReport, isCompletedRun } from "./status.js";
3
3
  /**
4
- * The frame for now. Every running task gets its own report; a task that was running in the previous frame
5
- * and has ended since is included once more, so the reader sees it finish (and can toast) before it is dropped.
6
- * `tracked` is that previous set; it is replaced by the ids running now.
4
+ * The frame for now. Every task that is not completed (running, interrupted, waiting for answers, or ended partial,
5
+ * blocked, failed or cancelled) gets its own report, so it stays listed until it is resumed to completion or cleared.
6
+ * A task that was running in the previous frame and has completed since is included once more, so the reader sees
7
+ * it finish (and can toast) before it is dropped. `tracked` is that previous set; it is replaced by the ids running now.
7
8
  */
8
9
  export function buildWatchFrame(project, tracked) {
9
- const running = buildTaskListReport(project)
10
- .runs.filter((r) => r.state === "running")
11
- .map((r) => r.run_id);
10
+ const all = buildTaskListReport(project).runs;
11
+ const running = all.filter((r) => r.state === "running").map((r) => r.run_id);
12
+ const open = all.filter((r) => !isCompletedRun(r)).map((r) => r.run_id);
12
13
  const runs = {};
13
- for (const id of new Set([...running, ...tracked]))
14
+ for (const id of new Set([...open, ...tracked]))
14
15
  runs[id] = buildStatusReport(project, id);
15
16
  const report = buildStatusReport(project);
16
17
  tracked.clear();
package/dist/status.d.ts CHANGED
@@ -43,6 +43,8 @@ export interface Paint {
43
43
  cyan: (t: string) => string;
44
44
  }
45
45
  export declare function paint(on: boolean): Paint;
46
+ /** "快取 讀 288,124 寫 21,364 命中 93%": the share of cached input that was read, not written. Empty when nothing was recorded. */
47
+ export declare function cacheText(read: number | undefined, write: number | undefined): string;
46
48
  export declare function progressBar(done: number, total: number, width?: number, c?: Paint): string;
47
49
  export interface QueueItem {
48
50
  agent: string;
@@ -74,6 +76,9 @@ export interface RunReport {
74
76
  /** Set when an agent kept failing and the run stopped: `resume` after fixing the cause. */
75
77
  failure?: RunFailure;
76
78
  output_tokens: number;
79
+ /** Prompt-cache input tokens read / written over the run (claude only; 0 when unknown). */
80
+ cache_read_tokens: number;
81
+ cache_write_tokens: number;
77
82
  task_source: "text" | "file";
78
83
  task_path?: string;
79
84
  task_summary: string;
@@ -161,6 +166,8 @@ export declare function buildRunReport(run: {
161
166
  }, project?: ResolvedProject, opts?: {
162
167
  detail?: boolean;
163
168
  }): RunReport;
169
+ /** A run that ended with a verified-or-inferred `completed` outcome: the only kind the monitor stops listing. */
170
+ export declare const isCompletedRun: (r: Pick<RunReport, "state" | "outcome">) => boolean;
164
171
  /** The status of a project: its agents and one run (the newest running one, else the newest, or the one named by `runId`). */
165
172
  export declare function buildStatusReport(base: ResolvedProject, runId?: string, now?: number): StatusReport;
166
173
  /** Every run of the project, newest first. */
package/dist/status.js CHANGED
@@ -120,6 +120,11 @@ const cols = (t) => [...t].reduce((n, ch) => n + ((ch.codePointAt(0) ?? 0) >= 0x
120
120
  const padCols = (t, n) => t + " ".repeat(Math.max(0, n - cols(t)));
121
121
  const clip = (t, n) => (t.length > n ? t.slice(0, n - 1) + "…" : t);
122
122
  const tokens = (n) => (n ? n.toLocaleString("en-US") : "0");
123
+ /** "快取 讀 288,124 寫 21,364 命中 93%": the share of cached input that was read, not written. Empty when nothing was recorded. */
124
+ export function cacheText(read, write) {
125
+ const [r, w] = [read ?? 0, write ?? 0];
126
+ return r + w > 0 ? `快取 讀 ${tokens(r)} 寫 ${tokens(w)} 命中 ${Math.round((100 * r) / (r + w))}%` : "";
127
+ }
123
128
  function since(iso, now) {
124
129
  const sec = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
125
130
  return sec < 60 ? `${sec}s` : `${Math.floor(sec / 60)}m${String(sec % 60).padStart(2, "0")}s`;
@@ -207,6 +212,8 @@ export function buildRunReport(run, project, opts = {}) {
207
212
  max_rounds: s.max_rounds,
208
213
  ...(s.failure ? { failure: s.failure } : {}),
209
214
  output_tokens: s.output_tokens,
215
+ cache_read_tokens: s.cache_read_tokens ?? 0,
216
+ cache_write_tokens: s.cache_write_tokens ?? 0,
210
217
  task_source: s.task_source,
211
218
  task_path: s.task_path,
212
219
  task_summary: s.task_summary,
@@ -226,6 +233,8 @@ export function buildRunReport(run, project, opts = {}) {
226
233
  ...(ask ? { ask } : {}),
227
234
  };
228
235
  }
236
+ /** A run that ended with a verified-or-inferred `completed` outcome: the only kind the monitor stops listing. */
237
+ export const isCompletedRun = (r) => r.state === "ended" && r.outcome === "completed";
229
238
  const projectInfo = (p) => ({ name: p.name, dir: p.dir, lead: p.lead });
230
239
  /** The fallback part of an agent's report: nothing at all for an agent without a chain. */
231
240
  function fallbackView(a, state, now) {
@@ -362,7 +371,7 @@ function summaryBlock(run, now, c) {
362
371
  /** What a run is doing / did, one line per wake. */
363
372
  function describeRun(s, now, c, limit = Infinity) {
364
373
  const lines = [
365
- `${c.bold("執行 " + runLabel(s))} [${stateColor(c, reportLabel(s))}] 輪次 ${roundsLabel(s.rounds, s.max_rounds)} 輸出 tokens ${tokens(s.output_tokens)}`,
374
+ `${c.bold("執行 " + runLabel(s))} [${stateColor(c, reportLabel(s))}] 輪次 ${roundsLabel(s.rounds, s.max_rounds)} 輸出 tokens ${tokens(s.output_tokens)}${cacheText(s.cache_read_tokens, s.cache_write_tokens) ? ` ${cacheText(s.cache_read_tokens, s.cache_write_tokens)}` : ""}`,
366
375
  ` 任務:${taskText(s)}`,
367
376
  ];
368
377
  const steps = stepsLine(s.steps, c);
@@ -501,7 +510,7 @@ export function formatStatusReport(report, now = Date.now(), color = false) {
501
510
  if (fb.length)
502
511
  lines.push("", ...fb);
503
512
  if (run)
504
- lines.push("", `輸出 tokens:${tokens(run.output_tokens)}`);
513
+ lines.push("", `輸出 tokens:${tokens(run.output_tokens)}${cacheText(run.cache_read_tokens, run.cache_write_tokens) ? ` ${cacheText(run.cache_read_tokens, run.cache_write_tokens)}` : ""}`);
505
514
  if (report.memory_advice?.length)
506
515
  lines.push("", `記憶:${report.memory_advice.join(";")}(建議執行 agent-lyceum tidy -p ${report.project.name};status 只提示,不會自動整理)`);
507
516
  return lines.join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-lyceum",
3
- "version": "0.21.2",
3
+ "version": "0.23.0",
4
4
  "description": "Config + dispatcher for teams of Claude Code / Codex agents with scoped context and file-based mailboxes",
5
5
  "keywords": [
6
6
  "ai",