agent-lyceum 0.5.0 → 0.6.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/config.js CHANGED
@@ -130,11 +130,13 @@ export function resolveProject(home, name) {
130
130
  const resume = pick("resume");
131
131
  const canMessage = pick("can_message");
132
132
  const canEdit = pick("can_edit_agent_md");
133
+ const canAsk = pick("can_ask_user");
133
134
  const owns = pick("owns");
134
135
  for (const [k, v] of [
135
136
  ["resume", resume],
136
137
  ["can_message", canMessage],
137
138
  ["can_edit_agent_md", canEdit],
139
+ ["can_ask_user", canAsk],
138
140
  ["owns", owns],
139
141
  ]) {
140
142
  if (v === undefined)
@@ -150,6 +152,7 @@ export function resolveProject(home, name) {
150
152
  resume: resume ?? isLead,
151
153
  canMessage: canMessage ?? (isLead ? "all" : [lead]),
152
154
  canEditAgentMd: canEdit ?? isLead,
155
+ canAskUser: canAsk ?? isLead,
153
156
  owns: owns ?? [],
154
157
  sources,
155
158
  };
@@ -198,7 +201,7 @@ export function findProjectForCwd(home, cwd) {
198
201
  }
199
202
  return best?.name;
200
203
  }
201
- const AGENT_FIELDS = ["runtime", "model", "effort", "agent_md", "memory.global", "memory.project", "resume", "can_message", "can_edit_agent_md", "owns"];
204
+ const AGENT_FIELDS = ["runtime", "model", "effort", "agent_md", "memory.global", "memory.project", "resume", "can_message", "can_edit_agent_md", "can_ask_user", "owns"];
202
205
  /** The resolved project plus, for every setting, the file and key it was taken from (or "default"). */
203
206
  export function resolveProjectWithSources(home, name) {
204
207
  const project = resolveProject(home, name);
@@ -255,6 +258,7 @@ export function flattenResolved(project) {
255
258
  v[`${p}.resume`] = a.resume;
256
259
  v[`${p}.can_message`] = a.canMessage;
257
260
  v[`${p}.can_edit_agent_md`] = a.canEditAgentMd;
261
+ v[`${p}.can_ask_user`] = a.canAskUser;
258
262
  v[`${p}.owns`] = a.owns;
259
263
  }
260
264
  return v;
package/dist/doctor.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { tidyAdvice } from "./memory-tidy.js";
4
5
  import { inspectProjectLock, lockHolderAlive } from "./project-lock.js";
5
6
  import { runInvocation } from "./process-runner.js";
6
7
  import { validateProject } from "./validate.js";
@@ -132,6 +133,8 @@ export async function diagnoseProject(project, opts = {}) {
132
133
  message: alive ? `A run is active: ${lock.run_id} (pid ${lock.pid}).` : `A stale run lock is left by ${lock.run_id} (pid ${lock.pid} is gone); \`agent-lyceum unlock --force\` clears it.`,
133
134
  });
134
135
  }
136
+ for (const a of tidyAdvice(project))
137
+ checks.push({ level: "info", subject: "memory", message: `${a.agent}: ${a.reason}. Consider: agent-lyceum memory tidy -p ${project.name} --agent ${a.agent} (doctor only suggests; it never tidies).` });
135
138
  const runtimes = await probeProjectRuntimes(project, opts);
136
139
  checks.push(...judgeRuntimes(project, runtimes));
137
140
  checks.push({ level: "info", subject: "login", message: "Login status is unknown: agent-lyceum does not read credentials, and the CLIs offer no read-only login check it can rely on." });
package/dist/mailbox.d.ts CHANGED
@@ -3,7 +3,7 @@ import { type Step } from "./format.js";
3
3
  import { atomicWrite } from "./fs-util.js";
4
4
  import { RouteJournal } from "./message-store.js";
5
5
  export { atomicWrite };
6
- export declare const MESSAGE_TYPES: readonly ["task", "reply", "failure", "done"];
6
+ export declare const MESSAGE_TYPES: readonly ["task", "reply", "failure", "done", "ask"];
7
7
  export type MessageType = (typeof MESSAGE_TYPES)[number];
8
8
  export interface MessageMeta {
9
9
  id: string;
@@ -88,6 +88,14 @@ export interface RouteResult {
88
88
  file: string;
89
89
  meta: Record<string, unknown>;
90
90
  };
91
+ /** Questions for the user. Like `done`, the file stays in the outbox until the caller has stored the questions. */
92
+ asks: {
93
+ from: string;
94
+ subject: string;
95
+ body: string;
96
+ file: string;
97
+ source: string;
98
+ }[];
91
99
  /** Latest `## Steps` checklist the lead sent, if any. */
92
100
  steps?: Step[];
93
101
  }
package/dist/mailbox.js CHANGED
@@ -8,7 +8,7 @@ import { deterministicId, fault, RouteJournal, sourceIdOf } from "./message-stor
8
8
  import { inboxDir, outboxDir } from "./policy.js";
9
9
  import { must } from "./assert.js";
10
10
  export { atomicWrite };
11
- export const MESSAGE_TYPES = ["task", "reply", "failure", "done"];
11
+ export const MESSAGE_TYPES = ["task", "reply", "failure", "done", "ask"];
12
12
  export function newId() {
13
13
  return crypto.randomBytes(3).toString("hex");
14
14
  }
@@ -165,7 +165,7 @@ export function rejectDone(file) {
165
165
  }
166
166
  /** Validate each agent's outbox and move accepted mail into recipients' inboxes. Safe to run again after a crash. */
167
167
  export function routeOutboxes(project) {
168
- const res = { delivered: [], rejected: [], warnings: [] };
168
+ const res = { delivered: [], rejected: [], warnings: [], asks: [] };
169
169
  const journal = project.run ? new RouteJournal(project.run.dir) : undefined;
170
170
  for (const sender of Object.values(project.agents)) {
171
171
  const dir = outboxDir(project, sender.name);
@@ -207,10 +207,18 @@ export function routeOutboxes(project) {
207
207
  }
208
208
  const type = (d.type ?? "reply");
209
209
  if (!MESSAGE_TYPES.includes(type) || type === "failure") {
210
- reject(`unknown or not allowed message type "${String(d.type)}" (use task, reply or done).`);
210
+ reject(`unknown or not allowed message type "${String(d.type)}" (use task, reply, ask or done).`);
211
211
  continue;
212
212
  }
213
213
  const subject = String(d.subject ?? "(no subject)");
214
+ if (type === "ask") {
215
+ if (!sender.canAskUser) {
216
+ reject(`you may not ask the user directly (can_ask_user is off for "${sender.name}"). Put the question in a reply to the lead ("${project.lead}"), who can ask.`);
217
+ continue;
218
+ }
219
+ res.asks.push({ from: sender.name, subject, body: parsed.body, file, source });
220
+ continue;
221
+ }
214
222
  if (type === "done") {
215
223
  if (sender.name !== project.lead) {
216
224
  reject(`only the lead ("${project.lead}") may send a "done" message.`);
@@ -0,0 +1,115 @@
1
+ import { type Invoker } from "./adapters/index.js";
2
+ import type { ResolvedProject } from "./config.js";
3
+ export type Layer = "project" | "global";
4
+ export declare const ARCHIVE_DIR = ".archive";
5
+ export declare const TIDY_STATE_FILE = ".tidy-state.json";
6
+ export declare const TIDY_REPORT_FILE = "tidy-report.md";
7
+ export declare const STAMP_RE: RegExp;
8
+ /** `20261008T031500Z`: UTC, sortable, safe in a file name. */
9
+ export declare const tidyStamp: (d?: Date) => string;
10
+ export interface MemoryTarget {
11
+ agent: string;
12
+ layer: Layer;
13
+ dir: string;
14
+ }
15
+ /** The memory directories to tidy. Without `agent`, every agent that has a directory for the layer; with it, that agent or an error. */
16
+ export declare function targetsFor(project: ResolvedProject, opts: {
17
+ layer: Layer;
18
+ agent?: string;
19
+ }): MemoryTarget[];
20
+ export interface MemoryFile {
21
+ rel: string;
22
+ bytes: number;
23
+ mtime: string;
24
+ }
25
+ /** Every file under `dir` (the archive included, the state file not), `/`-separated and sorted. */
26
+ export declare function listMemoryFiles(dir: string): MemoryFile[];
27
+ export interface MemoryStats {
28
+ files: number;
29
+ bytes: number;
30
+ indexBytes: number;
31
+ oldest?: string;
32
+ newest?: string;
33
+ }
34
+ /** Size and age of the live files (the archive is not counted). */
35
+ export declare function memoryStats(dir: string): MemoryStats;
36
+ /** Content hash of every file (the archive included), to tell whether a directory changed. */
37
+ export declare function fingerprint(dir: string): Record<string, string>;
38
+ /** Copy each directory aside (a directory that does not exist is remembered as absent). */
39
+ export declare function snapshotDirs(dirs: string[], into: string): void;
40
+ /** Put each directory back as `snapshotDirs` saw it, removing whatever was added since. */
41
+ export declare function restoreDirs(dirs: string[], from: string): void;
42
+ /**
43
+ * Nothing may disappear: every file that existed before is still in place, or archived under the same relative
44
+ * path in this tidy's archive folder. Earlier archives must stay as they were; MEMORY.md is never archived.
45
+ */
46
+ export declare function verifyConservation(before: string[], dir: string, stamp: string): string[];
47
+ export interface TidyState {
48
+ /** HEAD of the project repo when the last tidy finished. */
49
+ head?: string;
50
+ at: string;
51
+ /** The archive folder of that tidy. */
52
+ archive?: string;
53
+ }
54
+ export declare function readTidyState(dir: string): TidyState | undefined;
55
+ export declare function writeTidyState(dir: string, st: TidyState): void;
56
+ export interface ProjectChanges {
57
+ head?: string;
58
+ since?: string;
59
+ log: string[];
60
+ stat: string[];
61
+ note?: string;
62
+ }
63
+ export declare function projectChanges(repoDir: string, since?: string): ProjectChanges;
64
+ export interface MissingPath {
65
+ file: string;
66
+ path: string;
67
+ }
68
+ /** Backticked paths in the live Markdown files of `dir` that do not exist. Relative ones are looked up in `repoDir` (skipped without it). */
69
+ export declare function findMissingPaths(dir: string, repoDir: string | undefined): MissingPath[];
70
+ export interface TidyOptions {
71
+ project: ResolvedProject;
72
+ layer: Layer;
73
+ agent?: string;
74
+ dryRun?: boolean;
75
+ invoker?: Invoker;
76
+ signal?: AbortSignal;
77
+ say?: (line: string) => void;
78
+ now?: () => Date;
79
+ }
80
+ export interface TidyResult {
81
+ agent: string;
82
+ layer: Layer;
83
+ dir: string;
84
+ status: "tidied" | "skipped" | "failed" | "dry-run";
85
+ /** The archive stamp of a finished tidy. */
86
+ archive?: string;
87
+ problems: string[];
88
+ }
89
+ /** Scratch space of one tidy (snapshot and the invocation's logs); outside every memory dir and every run. */
90
+ export declare const tidyWorkDir: (project: ResolvedProject, stamp: string, agent: string) => string;
91
+ /** Tidy the memory of the chosen agents, one after another. Only ever called by a person (`agent-lyceum memory tidy`). */
92
+ export declare function tidyMemory(o: TidyOptions): Promise<TidyResult[]>;
93
+ export interface RestoreResult {
94
+ restored: string[];
95
+ skipped: {
96
+ rel: string;
97
+ reason: string;
98
+ }[];
99
+ }
100
+ /** Move the files of one tidy's archive back to where they were. Nothing that exists now is overwritten. */
101
+ export declare function restoreMemory(o: {
102
+ project: ResolvedProject;
103
+ agent: string;
104
+ layer: Layer;
105
+ stamp: string;
106
+ }): RestoreResult;
107
+ /** The stamps of the archives in a memory directory, oldest first. */
108
+ export declare function listArchives(dir: string): string[];
109
+ export declare const ADVICE_COMMITS = 50;
110
+ export interface TidyAdvice {
111
+ agent: string;
112
+ reason: string;
113
+ }
114
+ /** Hints only: project memory whose index is larger than what an agent is shown, or whose baseline is far behind. Never acts. */
115
+ export declare function tidyAdvice(project: ResolvedProject): TidyAdvice[];
@@ -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/prompt.d.ts CHANGED
@@ -1,5 +1,6 @@
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;
@@ -9,3 +10,22 @@ export declare function buildSystemPrompt(project: ResolvedProject, agent: Resol
9
10
  /** 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
11
  export declare function pickMessages(project: ResolvedProject, agent: ResolvedAgent, queue: Message[]): Message[];
11
12
  export declare function buildUserPrompt(project: ResolvedProject, agent: ResolvedAgent, unread: Message[], handleCount?: number): string;
13
+ export interface TidyContext {
14
+ layer: Layer;
15
+ /** The memory directory being tidied. */
16
+ dir: string;
17
+ /** Absolute path of this tidy's archive folder (the agent creates it). */
18
+ archiveDir: string;
19
+ /** Live files only (the archive is not listed). */
20
+ files: MemoryFile[];
21
+ /** MEMORY.md text ("" when there is none). */
22
+ index: string;
23
+ /** What changed in the project since the last tidy (project layer only). */
24
+ changes?: ProjectChanges;
25
+ missing: MissingPath[];
26
+ }
27
+ /** The prompts of a memory-tidy wake-up: a maintenance job, not a task, so it has none of the mail protocol. */
28
+ export declare function buildTidyPrompts(agent: ResolvedAgent, ctx: TidyContext): {
29
+ systemPrompt: string;
30
+ userPrompt: string;
31
+ };