agent-lyceum 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -4
- package/README.zh-TW.md +20 -4
- package/dist/adapters/claude.js +2 -0
- package/dist/ask-answer.d.ts +10 -0
- package/dist/ask-answer.js +21 -0
- package/dist/ask-reply.d.ts +65 -0
- package/dist/ask-reply.js +165 -0
- package/dist/call.d.ts +35 -0
- package/dist/call.js +162 -0
- package/dist/cli.js +249 -20
- package/dist/config.d.ts +8 -0
- package/dist/config.js +9 -3
- package/dist/doctor.js +3 -0
- package/dist/mailbox.d.ts +9 -1
- package/dist/mailbox.js +11 -3
- package/dist/memory-tidy.d.ts +115 -0
- package/dist/memory-tidy.js +351 -0
- package/dist/policy.js +3 -2
- package/dist/prompt.d.ts +24 -0
- package/dist/prompt.js +113 -2
- package/dist/run-outcome.js +2 -0
- package/dist/run-session.d.ts +12 -0
- package/dist/run-session.js +104 -3
- package/dist/run-store.d.ts +5 -1
- package/dist/run-store.js +5 -3
- package/dist/schema.d.ts +5 -2
- package/dist/schema.js +4 -1
- package/dist/solo.d.ts +12 -0
- package/dist/solo.js +28 -0
- package/dist/status.d.ts +9 -1
- package/dist/status.js +32 -2
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,9 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import readline from "node:readline/promises";
|
|
5
5
|
import { Command } from "commander";
|
|
6
|
-
import { ConfigError, findProjectForCwd, flattenResolved, listProjects, resolveProject, resolveProjectWithSources } from "./config.js";
|
|
6
|
+
import { ConfigError, findProjectForCwd, flattenResolved, listProjects, loadGlobal, resolveProject, resolveProjectWithSources } from "./config.js";
|
|
7
|
+
import { openInEditor, prepareAnswers } from "./ask-answer.js";
|
|
8
|
+
import { restoreMemory, tidyMemory, tidyStamp } from "./memory-tidy.js";
|
|
7
9
|
import { RESULT_FILE, runTeam } from "./dispatcher.js";
|
|
8
10
|
import { executeRunCleanup, planRunCleanup } from "./run-cleanup.js";
|
|
9
11
|
import { diagnoseProject, formatDoctor, preflightRuntimes } from "./doctor.js";
|
|
@@ -14,8 +16,10 @@ import { absPath, assertName, projectPaths, resolveHome } from "./paths.js";
|
|
|
14
16
|
import { addProject, initHome, removeProject } from "./scaffold.js";
|
|
15
17
|
import { buildStatusReport, buildTaskListReport, formatMonitor, formatRunDetail, formatStatusWithLog, formatTaskList, latestUnfinishedRun, runIsAlive } from "./status.js";
|
|
16
18
|
import { prepareTask, readTaskFile } from "./task.js";
|
|
19
|
+
import { callAgent, callProject, callsDir, resolveCallAgent } from "./call.js";
|
|
17
20
|
import { formatEnforcement, validateProject } from "./validate.js";
|
|
18
21
|
import { must } from "./assert.js";
|
|
22
|
+
import { onlyAgent, soloProject } from "./solo.js";
|
|
19
23
|
const program = new Command();
|
|
20
24
|
program
|
|
21
25
|
.name("agent-lyceum")
|
|
@@ -177,7 +181,7 @@ function cancelOnSignals() {
|
|
|
177
181
|
},
|
|
178
182
|
};
|
|
179
183
|
}
|
|
180
|
-
function
|
|
184
|
+
function printRun(summary, runDir) {
|
|
181
185
|
console.log(`\nRun ${summary.runId} ended: ${summary.endReason}, outcome: ${summary.outcome}, after ${summary.rounds} round(s). Logs: ${path.join(runDir, "log.jsonl")}`);
|
|
182
186
|
if (summary.outcomeNote)
|
|
183
187
|
console.log(`Note: ${summary.outcomeNote}`);
|
|
@@ -187,13 +191,73 @@ function reportRun(summary, runDir) {
|
|
|
187
191
|
console.log(`\nLead's final message — ${summary.doneMessage.subject}\n\n${summary.doneMessage.body}`);
|
|
188
192
|
console.log(`\nResult saved to: ${path.join(runDir, RESULT_FILE)}`);
|
|
189
193
|
}
|
|
194
|
+
}
|
|
195
|
+
function reportRun(summary, runDir) {
|
|
196
|
+
printRun(summary, runDir);
|
|
190
197
|
process.exit(exitCodeForOutcome(summary.outcome));
|
|
191
198
|
}
|
|
199
|
+
/** Take the lock and continue a run; the lock is released when it stops (also when it stops because it waits for answers). */
|
|
200
|
+
async function continueRun(pr, dir, runId) {
|
|
201
|
+
const lease = takeLock(pr, runId);
|
|
202
|
+
const cancel = cancelOnSignals();
|
|
203
|
+
try {
|
|
204
|
+
console.log(`Project ${pr.name} — repo ${pr.dir}`);
|
|
205
|
+
const state = loadRunState(dir);
|
|
206
|
+
console.log(`Resuming run ${state.run_id} (${state.end_reason ?? "interrupted"}) at round ${state.rounds}/${pr.dispatcher.max_rounds}: ${state.task_summary}`);
|
|
207
|
+
// Read the state again under the lock: another process may have changed it while we were checking.
|
|
208
|
+
return await runTeam({ project: pr, resume: state, runDir: dir, signal: cancel.signal });
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
cancel.dispose();
|
|
212
|
+
lease.release();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const interactive = () => !!process.stdin.isTTY && !!process.stdout.isTTY;
|
|
216
|
+
function printWaiting(pr, runId, file, problems) {
|
|
217
|
+
console.log(`\nRun ${runId} is waiting for your answers.\n File: ${file}`);
|
|
218
|
+
for (const p of problems)
|
|
219
|
+
console.log(` - ${p}`);
|
|
220
|
+
console.log(`Answer with: agent-lyceum answer ${runId} -p ${pr.name} (or edit the file, then: agent-lyceum resume ${runId} -p ${pr.name})`);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Keep going while the run stops to ask and the questions can be settled right now: by --assume-defaults, or (in a terminal)
|
|
224
|
+
* by opening the editor. Otherwise print where to answer and exit 3.
|
|
225
|
+
*/
|
|
226
|
+
async function driveRun(pr, runDir, first, assumeDefaults) {
|
|
227
|
+
let summary = first;
|
|
228
|
+
while (summary.outcome === "waiting") {
|
|
229
|
+
let prep = prepareAnswers(runDir, { assumeDefaults });
|
|
230
|
+
while (!prep.complete && interactive()) {
|
|
231
|
+
console.log(`\nRun ${summary.runId} needs your answers (${prep.path}).`);
|
|
232
|
+
openInEditor(prep.path);
|
|
233
|
+
prep = prepareAnswers(runDir, {});
|
|
234
|
+
if (prep.complete)
|
|
235
|
+
break;
|
|
236
|
+
console.log("Some answers are missing or invalid:");
|
|
237
|
+
for (const p of prep.problems)
|
|
238
|
+
console.log(` - ${p}`);
|
|
239
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
240
|
+
const again = (await rl.question("Edit again? [Y/n] ")).trim().toLowerCase();
|
|
241
|
+
rl.close();
|
|
242
|
+
if (again === "n" || again === "no")
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
if (!prep.complete) {
|
|
246
|
+
printRun(summary, runDir);
|
|
247
|
+
printWaiting(pr, summary.runId, prep.path, prep.problems);
|
|
248
|
+
process.exit(3);
|
|
249
|
+
}
|
|
250
|
+
summary = await continueRun(pr, runDir, summary.runId);
|
|
251
|
+
}
|
|
252
|
+
return reportRun(summary, runDir);
|
|
253
|
+
}
|
|
192
254
|
program
|
|
193
255
|
.command("run [task]")
|
|
194
|
-
.description('Start the dispatcher: give the task to the lead. Use "<task>" or --task-file <path>.')
|
|
256
|
+
.description('Start the dispatcher: give the task to the lead. Use "<task>" or --task-file <path>. With --agent, give it to that one member alone.')
|
|
195
257
|
.option("-p, --project <name>")
|
|
196
258
|
.option("--task-file <path>", "read the task from a file")
|
|
259
|
+
.option("--agent <name>", "run only this member of the project, alone: it cannot mail teammates or ask you, and its own `done` ends the run")
|
|
260
|
+
.option("--assume-defaults", "when the run stops to ask, use the suggested answer for every question that has one")
|
|
197
261
|
.action(async (task, opts) => {
|
|
198
262
|
try {
|
|
199
263
|
const pr = loadProject(opts.project);
|
|
@@ -202,7 +266,16 @@ program
|
|
|
202
266
|
console.error(`${i.level === "error" ? "ERROR" : "warn "} ${i.message}`);
|
|
203
267
|
if (!res.ok)
|
|
204
268
|
fail("Configuration is invalid; fix the errors above (see `agent-lyceum validate`).");
|
|
205
|
-
|
|
269
|
+
let checked = pr;
|
|
270
|
+
if (opts.agent) {
|
|
271
|
+
assertName("agent", opts.agent);
|
|
272
|
+
if (opts.assumeDefaults)
|
|
273
|
+
console.error("warn --assume-defaults is ignored with --agent: a solo agent cannot ask questions.");
|
|
274
|
+
// Fails with the member list (and a hint for a global-only agent) before anything is written.
|
|
275
|
+
const sp = soloProject(pr, opts.agent, { globalAgents: Object.keys(loadGlobal(home()).agents) });
|
|
276
|
+
checked = onlyAgent(sp, opts.agent);
|
|
277
|
+
}
|
|
278
|
+
await preflight(checked);
|
|
206
279
|
const runId = newRunId();
|
|
207
280
|
const runDir = path.join(pr.paths.runs, runId);
|
|
208
281
|
const lease = takeLock(pr, runId);
|
|
@@ -210,15 +283,15 @@ program
|
|
|
210
283
|
const cancel = cancelOnSignals();
|
|
211
284
|
try {
|
|
212
285
|
const prepared = prepareTask({ text: task, file: opts.taskFile, cwd: process.cwd(), runDir });
|
|
213
|
-
console.log(`Project ${pr.name} — repo ${pr.dir}`);
|
|
286
|
+
console.log(`Project ${pr.name} — repo ${pr.dir}${opts.agent ? ` — solo: ${opts.agent}` : ""}`);
|
|
214
287
|
console.log(`Task: ${prepared.source === "file" ? `file ${prepared.sourcePath}` : "text"} (${prepared.bytes} bytes${prepared.inline ? "" : ", passed by reference"})`);
|
|
215
|
-
summary = await runTeam({ project: pr, task: prepared, runDir, signal: cancel.signal });
|
|
288
|
+
summary = await runTeam({ project: pr, task: prepared, runDir, soloAgent: opts.agent, signal: cancel.signal });
|
|
216
289
|
}
|
|
217
290
|
finally {
|
|
218
291
|
cancel.dispose();
|
|
219
292
|
lease.release();
|
|
220
293
|
}
|
|
221
|
-
|
|
294
|
+
await driveRun(pr, runDir, summary, !!opts.assumeDefaults);
|
|
222
295
|
}
|
|
223
296
|
catch (e) {
|
|
224
297
|
fail(e instanceof ConfigError ? e.message : e.message);
|
|
@@ -228,6 +301,7 @@ program
|
|
|
228
301
|
.command("resume [run-id]")
|
|
229
302
|
.description("Continue an interrupted (or failed) run: same run dir, sessions and round count; the task is not re-sent. Without an id, picks the newest run that is not done or running; with an id, continues that run.")
|
|
230
303
|
.option("-p, --project <name>")
|
|
304
|
+
.option("--assume-defaults", "if the run waits for answers, use the suggested answer for every unanswered question that has one")
|
|
231
305
|
.action(async (runId, opts) => {
|
|
232
306
|
try {
|
|
233
307
|
const pr = loadProject(opts.project);
|
|
@@ -236,7 +310,6 @@ program
|
|
|
236
310
|
console.error(`${i.level === "error" ? "ERROR" : "warn "} ${i.message}`);
|
|
237
311
|
if (!res.ok)
|
|
238
312
|
fail("Configuration is invalid; fix the errors above (see `agent-lyceum validate`).");
|
|
239
|
-
await preflight(pr);
|
|
240
313
|
let found;
|
|
241
314
|
if (runId) {
|
|
242
315
|
assertName("run", runId);
|
|
@@ -251,6 +324,8 @@ program
|
|
|
251
324
|
fail("No unfinished run to resume. Start one with: agent-lyceum run \"<task>\"");
|
|
252
325
|
}
|
|
253
326
|
const { dir, state } = found;
|
|
327
|
+
// A solo run needs only its own agent's runtime, as when it started.
|
|
328
|
+
await preflight(state.solo_agent ? onlyAgent(soloProject(pr, state.solo_agent), state.solo_agent) : pr);
|
|
254
329
|
if (!state.end_reason && runIsAlive(state, pr))
|
|
255
330
|
fail(`Run ${state.run_id} is still running (pid ${state.pid}).`);
|
|
256
331
|
if (state.end_reason === "done") {
|
|
@@ -263,20 +338,51 @@ program
|
|
|
263
338
|
}
|
|
264
339
|
if (state.rounds >= pr.dispatcher.max_rounds)
|
|
265
340
|
fail(`Run ${state.run_id} used ${state.rounds}/${pr.dispatcher.max_rounds} rounds; raise dispatcher.max_rounds in project.yaml first.`);
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
// Read the state again under the lock: another process may have changed it while we were checking.
|
|
273
|
-
summary = await runTeam({ project: pr, resume: loadRunState(dir), runDir: dir, signal: cancel.signal });
|
|
341
|
+
if (state.end_reason === "waiting") {
|
|
342
|
+
const prep = prepareAnswers(dir, { assumeDefaults: !!opts.assumeDefaults });
|
|
343
|
+
if (!prep.complete) {
|
|
344
|
+
printWaiting(pr, state.run_id, prep.path, prep.problems);
|
|
345
|
+
process.exit(3);
|
|
346
|
+
}
|
|
274
347
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
348
|
+
const summary = await continueRun(pr, dir, state.run_id);
|
|
349
|
+
await driveRun(pr, dir, summary, !!opts.assumeDefaults);
|
|
350
|
+
}
|
|
351
|
+
catch (e) {
|
|
352
|
+
fail(e instanceof ConfigError ? e.message : e.message);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
program
|
|
356
|
+
.command("answer <run-id>")
|
|
357
|
+
.description("Answer the questions a waiting run asked: opens ask-reply.md in $EDITOR, checks the answers, then continues the run. With --no-edit it only checks a file you already edited.")
|
|
358
|
+
.option("-p, --project <name>")
|
|
359
|
+
.option("--no-edit", "do not open the editor; check the file as it is")
|
|
360
|
+
.option("--assume-defaults", "use the suggested answer for every unanswered question that has one")
|
|
361
|
+
.action(async (runId, opts) => {
|
|
362
|
+
try {
|
|
363
|
+
assertName("run", runId);
|
|
364
|
+
const pr = loadProject(opts.project);
|
|
365
|
+
const dir = path.join(pr.paths.runs, runId);
|
|
366
|
+
if (!fs.existsSync(path.join(dir, "state.json")))
|
|
367
|
+
fail(`Run "${runId}" not found in ${pr.paths.runs}.`);
|
|
368
|
+
const state = loadRunState(dir);
|
|
369
|
+
if (state.end_reason !== "waiting")
|
|
370
|
+
fail(`Run ${runId} is not waiting for answers (${state.end_reason ?? "not finished"}).`);
|
|
371
|
+
if (opts.edit)
|
|
372
|
+
openInEditor(prepareAnswers(dir, {}).path);
|
|
373
|
+
const prep = prepareAnswers(dir, { assumeDefaults: !!opts.assumeDefaults });
|
|
374
|
+
if (!prep.complete) {
|
|
375
|
+
printWaiting(pr, runId, prep.path, prep.problems);
|
|
376
|
+
process.exit(3);
|
|
278
377
|
}
|
|
279
|
-
|
|
378
|
+
const res = validateProject(pr);
|
|
379
|
+
for (const i of res.issues)
|
|
380
|
+
console.error(`${i.level === "error" ? "ERROR" : "warn "} ${i.message}`);
|
|
381
|
+
if (!res.ok)
|
|
382
|
+
fail("Configuration is invalid; fix the errors above (see `agent-lyceum validate`).");
|
|
383
|
+
await preflight(pr);
|
|
384
|
+
const summary = await continueRun(pr, dir, runId);
|
|
385
|
+
await driveRun(pr, dir, summary, !!opts.assumeDefaults);
|
|
280
386
|
}
|
|
281
387
|
catch (e) {
|
|
282
388
|
fail(e instanceof ConfigError ? e.message : e.message);
|
|
@@ -407,6 +513,129 @@ program
|
|
|
407
513
|
fail(e.message);
|
|
408
514
|
}
|
|
409
515
|
});
|
|
516
|
+
program
|
|
517
|
+
.command("call <agent> [task]")
|
|
518
|
+
.description("Call one agent of the global library on its own, outside any project: it works in --dir (default: the current directory) and its final answer is printed. It cannot mail anyone or ask you.")
|
|
519
|
+
.option("--task-file <path>", "read the task from a file")
|
|
520
|
+
.option("--dir <path>", "the directory the agent works in (default: the current directory)")
|
|
521
|
+
.action(async (agentName, task, opts) => {
|
|
522
|
+
try {
|
|
523
|
+
if (task !== undefined && opts.taskFile)
|
|
524
|
+
fail("Give the task either as text or with --task-file, not both.");
|
|
525
|
+
const text = opts.taskFile ? readTaskFile(absPath(opts.taskFile, process.cwd())) : task;
|
|
526
|
+
if (!text?.trim())
|
|
527
|
+
fail("Give the task as text or with --task-file.");
|
|
528
|
+
const h = home();
|
|
529
|
+
const dir = absPath(opts.dir ?? ".", process.cwd());
|
|
530
|
+
const agent = resolveCallAgent(h, agentName);
|
|
531
|
+
await preflight(callProject(h, agent, dir, path.join(callsDir(h), "preflight")));
|
|
532
|
+
const cancel = cancelOnSignals();
|
|
533
|
+
let r;
|
|
534
|
+
try {
|
|
535
|
+
r = await callAgent({ home: h, agent: agentName, task: text, dir, signal: cancel.signal });
|
|
536
|
+
}
|
|
537
|
+
finally {
|
|
538
|
+
cancel.dispose();
|
|
539
|
+
}
|
|
540
|
+
console.error(`Call ${r.callId}: ${r.callDir}`);
|
|
541
|
+
if (r.cancelled)
|
|
542
|
+
fail("Cancelled.", 130);
|
|
543
|
+
if (!r.ok)
|
|
544
|
+
fail(`Call failed: ${r.error}\nLog: ${path.join(r.callDir, "log")}`);
|
|
545
|
+
console.log(r.text.trimEnd());
|
|
546
|
+
}
|
|
547
|
+
catch (e) {
|
|
548
|
+
fail(e instanceof ConfigError ? e.message : e.message);
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
const memoryCmd = program.command("memory").description("Look after the agents' long-term memory");
|
|
552
|
+
function layerOpt(v) {
|
|
553
|
+
if (v === undefined || v === "project")
|
|
554
|
+
return "project";
|
|
555
|
+
if (v === "global")
|
|
556
|
+
return "global";
|
|
557
|
+
return fail(`--layer must be "project" or "global" (task memory is not tidied: \`clear\` deletes it).`);
|
|
558
|
+
}
|
|
559
|
+
memoryCmd
|
|
560
|
+
.command("tidy")
|
|
561
|
+
.description("Have agents tidy their own memory: merge duplicates, fix stale entries, archive what is obsolete (never deletes). Manual only; nothing triggers it automatically.")
|
|
562
|
+
.option("-p, --project <name>")
|
|
563
|
+
.option("--agent <name>", "tidy only this agent (default: every agent)")
|
|
564
|
+
.option("--layer <layer>", "project (default) or global; global memory is shared by every project, so it is never tidied unless you ask")
|
|
565
|
+
.option("--dry-run", "only show the memory sizes and what changed in the project; wake nobody, change nothing")
|
|
566
|
+
.action(async (opts) => {
|
|
567
|
+
try {
|
|
568
|
+
const layer = layerOpt(opts.layer);
|
|
569
|
+
const pr = loadProject(opts.project);
|
|
570
|
+
if (opts.dryRun) {
|
|
571
|
+
const dry = await tidyMemory({ project: pr, layer, agent: opts.agent, dryRun: true });
|
|
572
|
+
if (dry.length === 0)
|
|
573
|
+
console.log(`No agent has ${layer} memory configured. ${layer === "project" ? "The template gives agents global memory only: set `memory.project` for an agent in project.yaml (or use --layer global)." : "Set `memory.global` for an agent."}`);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const res = validateProject(pr);
|
|
577
|
+
for (const i of res.issues)
|
|
578
|
+
console.error(`${i.level === "error" ? "ERROR" : "warn "} ${i.message}`);
|
|
579
|
+
if (!res.ok)
|
|
580
|
+
fail("Configuration is invalid; fix the errors above (see `agent-lyceum validate`).");
|
|
581
|
+
await preflight(pr);
|
|
582
|
+
const lease = takeLock(pr, `tidy-${tidyStamp()}`);
|
|
583
|
+
const cancel = cancelOnSignals();
|
|
584
|
+
let results;
|
|
585
|
+
try {
|
|
586
|
+
results = await tidyMemory({ project: pr, layer, agent: opts.agent, signal: cancel.signal });
|
|
587
|
+
}
|
|
588
|
+
finally {
|
|
589
|
+
cancel.dispose();
|
|
590
|
+
lease.release();
|
|
591
|
+
}
|
|
592
|
+
if (results.length === 0)
|
|
593
|
+
console.log(`No agent has ${layer} memory configured. ${layer === "project" ? "The template gives agents global memory only: set `memory.project` for an agent in project.yaml (or tidy global memory with --layer global)." : "Set `memory.global` for an agent."}`);
|
|
594
|
+
for (const r of results) {
|
|
595
|
+
if (r.status === "tidied")
|
|
596
|
+
console.log(` ${r.agent} (${r.layer}): tidied. Archive: ${path.join(r.dir, ".archive", r.archive ?? "")} — undo with: agent-lyceum memory restore ${r.archive} -p ${pr.name} --agent ${r.agent}${r.layer === "global" ? " --layer global" : ""}`);
|
|
597
|
+
else if (r.status === "skipped")
|
|
598
|
+
console.log(` ${r.agent} (${r.layer}): skipped — ${r.problems.join("; ")}`);
|
|
599
|
+
else if (r.status === "failed") {
|
|
600
|
+
console.error(` ${r.agent} (${r.layer}): FAILED, memory restored to how it was`);
|
|
601
|
+
for (const p of r.problems)
|
|
602
|
+
console.error(` - ${p}`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
process.exit(results.some((r) => r.status === "failed") ? 1 : 0);
|
|
606
|
+
}
|
|
607
|
+
catch (e) {
|
|
608
|
+
fail(e instanceof ConfigError ? e.message : e.message);
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
memoryCmd
|
|
612
|
+
.command("restore <stamp>")
|
|
613
|
+
.description("Move the files of one tidy's archive back (nothing that exists now is overwritten) and re-add them to MEMORY.md")
|
|
614
|
+
.option("-p, --project <name>")
|
|
615
|
+
.requiredOption("--agent <name>")
|
|
616
|
+
.option("--layer <layer>", "project (default) or global")
|
|
617
|
+
.action((stamp, opts) => {
|
|
618
|
+
try {
|
|
619
|
+
const layer = layerOpt(opts.layer);
|
|
620
|
+
const pr = loadProject(opts.project);
|
|
621
|
+
const lease = takeLock(pr, `restore-${tidyStamp()}`);
|
|
622
|
+
try {
|
|
623
|
+
const r = restoreMemory({ project: pr, agent: opts.agent, layer, stamp });
|
|
624
|
+
for (const f of r.restored)
|
|
625
|
+
console.log(` restored ${f}`);
|
|
626
|
+
for (const s of r.skipped)
|
|
627
|
+
console.log(` skipped ${s.rel}: ${s.reason}`);
|
|
628
|
+
if (!r.restored.length && !r.skipped.length)
|
|
629
|
+
console.log(`Archive ${stamp} holds no files to restore.`);
|
|
630
|
+
}
|
|
631
|
+
finally {
|
|
632
|
+
lease.release();
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
catch (e) {
|
|
636
|
+
fail(e.message);
|
|
637
|
+
}
|
|
638
|
+
});
|
|
410
639
|
function useColor() {
|
|
411
640
|
if (process.env.NO_COLOR)
|
|
412
641
|
return false;
|
package/dist/config.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface ResolvedAgent {
|
|
|
18
18
|
resume: boolean;
|
|
19
19
|
canMessage: "all" | string[];
|
|
20
20
|
canEditAgentMd: boolean;
|
|
21
|
+
/** May send `type: ask` mail to the user (the run then waits for the answer). */
|
|
22
|
+
canAskUser: boolean;
|
|
21
23
|
owns: string[];
|
|
22
24
|
/** Where each field's value came from (for `validate` output). */
|
|
23
25
|
sources: Record<string, Source>;
|
|
@@ -39,6 +41,8 @@ export interface ResolvedProject {
|
|
|
39
41
|
};
|
|
40
42
|
/** Agents working in their own git worktree instead of `dir` (set by the dispatcher for the wake-ups in progress). */
|
|
41
43
|
workspaces?: Record<string, AgentWorkspace>;
|
|
44
|
+
/** Set for a single agent working alone: "run" = inside a run of this project (`run --agent`), "call" = outside any project (`call`). */
|
|
45
|
+
solo?: "run" | "call";
|
|
42
46
|
}
|
|
43
47
|
export declare function loadGlobal(home: string): {
|
|
44
48
|
agents: Record<string, {
|
|
@@ -53,6 +57,7 @@ export declare function loadGlobal(home: string): {
|
|
|
53
57
|
resume?: boolean | undefined;
|
|
54
58
|
can_message?: "all" | string[] | undefined;
|
|
55
59
|
can_edit_agent_md?: boolean | undefined;
|
|
60
|
+
can_ask_user?: boolean | undefined;
|
|
56
61
|
owns?: string[] | undefined;
|
|
57
62
|
}>;
|
|
58
63
|
};
|
|
@@ -82,10 +87,13 @@ export declare function loadProjectRaw(home: string, name: string): {
|
|
|
82
87
|
resume?: boolean | undefined;
|
|
83
88
|
can_message?: "all" | string[] | undefined;
|
|
84
89
|
can_edit_agent_md?: boolean | undefined;
|
|
90
|
+
can_ask_user?: boolean | undefined;
|
|
85
91
|
owns?: string[] | undefined;
|
|
86
92
|
}>;
|
|
87
93
|
};
|
|
88
94
|
export declare function resolveProject(home: string, name: string): ResolvedProject;
|
|
95
|
+
/** The merge `resolveProject` does, from configs already read: `call` uses it with a made-up project that lists one global agent. */
|
|
96
|
+
export declare function resolveProjectFrom(home: string, name: string, g: ReturnType<typeof loadGlobal>, p: ReturnType<typeof loadProjectRaw>): ResolvedProject;
|
|
89
97
|
/** Registered projects: name -> repo dir (best effort; skips broken configs). */
|
|
90
98
|
export declare function listProjects(home: string): {
|
|
91
99
|
name: string;
|
package/dist/config.js
CHANGED
|
@@ -51,8 +51,10 @@ function resolveLayer(p, base) {
|
|
|
51
51
|
return out;
|
|
52
52
|
}
|
|
53
53
|
export function resolveProject(home, name) {
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
return resolveProjectFrom(home, name, loadGlobal(home), loadProjectRaw(home, name));
|
|
55
|
+
}
|
|
56
|
+
/** The merge `resolveProject` does, from configs already read: `call` uses it with a made-up project that lists one global agent. */
|
|
57
|
+
export function resolveProjectFrom(home, name, g, p) {
|
|
56
58
|
const paths = projectPaths(home, name);
|
|
57
59
|
const hp = homePaths(home);
|
|
58
60
|
const lead = p.team.lead;
|
|
@@ -130,11 +132,13 @@ export function resolveProject(home, name) {
|
|
|
130
132
|
const resume = pick("resume");
|
|
131
133
|
const canMessage = pick("can_message");
|
|
132
134
|
const canEdit = pick("can_edit_agent_md");
|
|
135
|
+
const canAsk = pick("can_ask_user");
|
|
133
136
|
const owns = pick("owns");
|
|
134
137
|
for (const [k, v] of [
|
|
135
138
|
["resume", resume],
|
|
136
139
|
["can_message", canMessage],
|
|
137
140
|
["can_edit_agent_md", canEdit],
|
|
141
|
+
["can_ask_user", canAsk],
|
|
138
142
|
["owns", owns],
|
|
139
143
|
]) {
|
|
140
144
|
if (v === undefined)
|
|
@@ -150,6 +154,7 @@ export function resolveProject(home, name) {
|
|
|
150
154
|
resume: resume ?? isLead,
|
|
151
155
|
canMessage: canMessage ?? (isLead ? "all" : [lead]),
|
|
152
156
|
canEditAgentMd: canEdit ?? isLead,
|
|
157
|
+
canAskUser: canAsk ?? isLead,
|
|
153
158
|
owns: owns ?? [],
|
|
154
159
|
sources,
|
|
155
160
|
};
|
|
@@ -198,7 +203,7 @@ export function findProjectForCwd(home, cwd) {
|
|
|
198
203
|
}
|
|
199
204
|
return best?.name;
|
|
200
205
|
}
|
|
201
|
-
const AGENT_FIELDS = ["runtime", "model", "effort", "agent_md", "memory.global", "memory.project", "resume", "can_message", "can_edit_agent_md", "owns"];
|
|
206
|
+
const AGENT_FIELDS = ["runtime", "model", "effort", "agent_md", "memory.global", "memory.project", "resume", "can_message", "can_edit_agent_md", "can_ask_user", "owns"];
|
|
202
207
|
/** The resolved project plus, for every setting, the file and key it was taken from (or "default"). */
|
|
203
208
|
export function resolveProjectWithSources(home, name) {
|
|
204
209
|
const project = resolveProject(home, name);
|
|
@@ -255,6 +260,7 @@ export function flattenResolved(project) {
|
|
|
255
260
|
v[`${p}.resume`] = a.resume;
|
|
256
261
|
v[`${p}.can_message`] = a.canMessage;
|
|
257
262
|
v[`${p}.can_edit_agent_md`] = a.canEditAgentMd;
|
|
263
|
+
v[`${p}.can_ask_user`] = a.canAskUser;
|
|
258
264
|
v[`${p}.owns`] = a.owns;
|
|
259
265
|
}
|
|
260
266
|
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[];
|