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/adapters/claude.d.ts +8 -1
- package/dist/adapters/claude.js +15 -9
- package/dist/adapters/codex.js +6 -4
- package/dist/adapters/types.d.ts +11 -0
- package/dist/cli.js +7 -3
- package/dist/doctor.js +12 -0
- package/dist/format-rejections.d.ts +6 -0
- package/dist/format-rejections.js +29 -0
- package/dist/fs-util.d.ts +5 -0
- package/dist/fs-util.js +24 -0
- package/dist/guard.d.ts +8 -0
- package/dist/guard.js +125 -2
- package/dist/mailbox.js +16 -1
- package/dist/memory-tidy.d.ts +14 -1
- package/dist/memory-tidy.js +184 -48
- package/dist/policy.d.ts +7 -0
- package/dist/policy.js +20 -3
- package/dist/prompt.d.ts +27 -4
- package/dist/prompt.js +72 -33
- package/dist/records.d.ts +30 -0
- package/dist/records.js +130 -0
- package/dist/run-session.js +4 -0
- package/dist/run-store.d.ts +6 -0
- package/dist/run-store.js +4 -0
- package/dist/scaffold.js +2 -1
- package/dist/status-watch.d.ts +5 -4
- package/dist/status-watch.js +9 -8
- package/dist/status.d.ts +7 -0
- package/dist/status.js +11 -2
- package/package.json +1 -1
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 {
|
|
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).", "
|
|
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)"}.`,
|
|
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
|
|
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
|
-
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
227
|
-
`Layer: ${ctx.layer}${ctx.layer === "global" ? " (shared by every project; do not judge entries by one project's code)" : ""}`,
|
|
228
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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;
|
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/run-session.js
CHANGED
|
@@ -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,
|
package/dist/run-store.d.ts
CHANGED
|
@@ -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,
|
|
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.
|
package/dist/status-watch.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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 {
|
package/dist/status-watch.js
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
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([...
|
|
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");
|