agent-lyceum 0.25.1 → 0.27.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 +6 -2
- package/README.zh-TW.md +6 -2
- package/dist/call.js +2 -0
- package/dist/config.d.ts +29 -1
- package/dist/config.js +34 -3
- package/dist/doctor-instances.d.ts +4 -0
- package/dist/doctor-instances.js +63 -0
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +17 -1
- package/dist/duplicate-notices.d.ts +9 -0
- package/dist/duplicate-notices.js +34 -0
- package/dist/fallback.d.ts +4 -2
- package/dist/fallback.js +11 -4
- package/dist/guard.d.ts +17 -1
- package/dist/guard.js +37 -6
- package/dist/instance-containment.d.ts +15 -0
- package/dist/instance-containment.js +36 -0
- package/dist/instances.d.ts +183 -0
- package/dist/instances.js +268 -0
- package/dist/mailbox.d.ts +46 -2
- package/dist/mailbox.js +121 -36
- package/dist/message-store.d.ts +47 -1
- package/dist/message-store.js +75 -7
- package/dist/paths.d.ts +7 -0
- package/dist/paths.js +27 -2
- package/dist/policy.d.ts +1 -0
- package/dist/policy.js +2 -2
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +66 -10
- package/dist/run-session.d.ts +35 -2
- package/dist/run-session.js +337 -20
- package/dist/run-store.d.ts +17 -3
- package/dist/run-store.js +17 -7
- package/dist/scaffold.d.ts +3 -0
- package/dist/scaffold.js +10 -1
- package/dist/schema.d.ts +19 -0
- package/dist/schema.js +12 -0
- package/dist/status.d.ts +7 -0
- package/dist/status.js +74 -15
- package/dist/tiers.d.ts +27 -0
- package/dist/tiers.js +49 -0
- package/dist/validate.js +28 -1
- package/dist/worktree.d.ts +6 -0
- package/dist/worktree.js +16 -2
- package/package.json +1 -1
package/dist/mailbox.js
CHANGED
|
@@ -2,9 +2,12 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import YAML from "yaml";
|
|
5
|
+
import { duplicateNoticeKey, shouldNotifyDuplicate } from "./duplicate-notices.js";
|
|
5
6
|
import { formatWarning, missingSections, parseSteps } from "./format.js";
|
|
6
|
-
import { atomicWrite } from "./fs-util.js";
|
|
7
|
+
import { atomicWrite, readRegularFileNoFollow } from "./fs-util.js";
|
|
7
8
|
import { countFormatRejection, formatRejectionKey, formatRejections, MAX_FORMAT_REJECTIONS } from "./format-rejections.js";
|
|
9
|
+
import { parallelFlag, roleOfName } from "./instances.js";
|
|
10
|
+
import { effectiveTier, tierFlag } from "./tiers.js";
|
|
8
11
|
import { deterministicId, fault, RouteJournal, sourceIdOf } from "./message-store.js";
|
|
9
12
|
import { inboxDir, outboxDir, sharedDir } from "./policy.js";
|
|
10
13
|
import { activeRecordsBytes, RECORD_KINDS, RECORDS_BUDGET } from "./records.js";
|
|
@@ -38,14 +41,17 @@ export function ensureProjectDirs(project) {
|
|
|
38
41
|
const handoff = sharedDir(project);
|
|
39
42
|
if (handoff)
|
|
40
43
|
fs.mkdirSync(handoff, { recursive: true });
|
|
41
|
-
for (const name of Object.keys(project.agents))
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
44
|
+
for (const name of Object.keys(project.agents))
|
|
45
|
+
ensureAgentDirs(project, name);
|
|
46
|
+
}
|
|
47
|
+
/** The mailbox and memory directories of one agent (a newly opened instance gets just its own). */
|
|
48
|
+
export function ensureAgentDirs(project, name) {
|
|
49
|
+
fs.mkdirSync(path.join(inboxDir(project, name), "read"), { recursive: true });
|
|
50
|
+
fs.mkdirSync(path.join(outboxDir(project, name), "rejected"), { recursive: true });
|
|
51
|
+
const a = must(project.agents[name], `agent ${name}`);
|
|
52
|
+
for (const m of [a.memory.global, a.memory.project, a.memory.task])
|
|
53
|
+
if (m)
|
|
54
|
+
fs.mkdirSync(m, { recursive: true });
|
|
49
55
|
}
|
|
50
56
|
/** Deliver a message into `to`'s inbox (dispatcher-side; senders other than agents allowed). */
|
|
51
57
|
export function deliver(project, input) {
|
|
@@ -60,6 +66,7 @@ export function deliver(project, input) {
|
|
|
60
66
|
subject: input.subject,
|
|
61
67
|
created: input.created ?? new Date().toISOString(),
|
|
62
68
|
...(project.run?.layout === "run" ? { run_id: project.run.id } : {}),
|
|
69
|
+
...(input.tier ? { tier: input.tier } : {}),
|
|
63
70
|
};
|
|
64
71
|
const file = path.join(inboxDir(project, input.to), input.fileName ?? `${stamp()}-${input.from}-${id}.md`);
|
|
65
72
|
atomicWrite(file, serialize(meta, input.body));
|
|
@@ -70,7 +77,7 @@ export function deliver(project, input) {
|
|
|
70
77
|
* the plan (id, file name, timestamp) is journaled before anything is written, a retry writes the identical file,
|
|
71
78
|
* and mail the recipient already read is never written again. Without a run (no journal) it just delivers.
|
|
72
79
|
*/
|
|
73
|
-
export function deliverOnce(project, journal, sourceKey, input) {
|
|
80
|
+
export function deliverOnce(project, journal, sourceKey, input, extra = {}) {
|
|
74
81
|
if (!journal)
|
|
75
82
|
return { msg: deliver(project, input), fresh: true };
|
|
76
83
|
const prior = journal.get(sourceKey, input.to);
|
|
@@ -81,14 +88,23 @@ export function deliverOnce(project, journal, sourceKey, input) {
|
|
|
81
88
|
msg_id: deterministicId(sourceKey, input.to),
|
|
82
89
|
file: `${stamp()}-${input.from}-${deterministicId(sourceKey, input.to)}.md`,
|
|
83
90
|
created: new Date().toISOString(),
|
|
91
|
+
...(extra.held ? { held: true } : {}),
|
|
92
|
+
...(extra.parallel ? { parallel: true } : {}),
|
|
93
|
+
...(extra.from ? { from: extra.from } : {}),
|
|
94
|
+
...(extra.about ? { about: extra.about } : {}),
|
|
84
95
|
});
|
|
85
96
|
fault("route:intent");
|
|
86
|
-
|
|
97
|
+
// A replay goes where the first attempt was planned to go: the journal's `held`, not the current state of the run.
|
|
98
|
+
const held = planned.held === true;
|
|
99
|
+
const inbox = inboxDir(project, held ? roleOfName(project.agents, input.to) : input.to);
|
|
100
|
+
const dest = path.join(held ? path.join(inbox, PENDING_DIR) : inbox, planned.file);
|
|
87
101
|
const meta = deliveredMeta(project, input, planned);
|
|
88
102
|
const want = serialize(meta, input.body);
|
|
89
103
|
const alreadyRead = fs.existsSync(path.join(path.dirname(dest), "read", planned.file));
|
|
104
|
+
// A held mail that was delivered (or assigned) may have been moved out of `pending/` already: it is never written again.
|
|
105
|
+
const settled = held && (planned.delivered || journal.assigned(planned.msg_id) !== undefined);
|
|
90
106
|
// Write the planned file; if one is already there, verify it instead of trusting it. Mail already read is never written again.
|
|
91
|
-
if (!alreadyRead && (!fs.existsSync(dest) || fs.readFileSync(dest, "utf8") !== want))
|
|
107
|
+
if (!settled && !alreadyRead && (!fs.existsSync(dest) || fs.readFileSync(dest, "utf8") !== want))
|
|
92
108
|
atomicWrite(dest, want);
|
|
93
109
|
fault("route:written");
|
|
94
110
|
const fresh = !planned.delivered;
|
|
@@ -97,6 +113,8 @@ export function deliverOnce(project, journal, sourceKey, input) {
|
|
|
97
113
|
fault("route:delivered");
|
|
98
114
|
return { msg: { meta, body: input.body, file: dest }, fresh };
|
|
99
115
|
}
|
|
116
|
+
/** Mail for a role that has instances waits here, in the role's inbox, until the assigner places it. */
|
|
117
|
+
export const PENDING_DIR = "pending";
|
|
100
118
|
function deliveredMeta(project, input, planned) {
|
|
101
119
|
return {
|
|
102
120
|
id: planned.msg_id,
|
|
@@ -108,6 +126,7 @@ function deliveredMeta(project, input, planned) {
|
|
|
108
126
|
subject: input.subject,
|
|
109
127
|
created: planned.created,
|
|
110
128
|
...(project.run?.layout === "run" ? { run_id: project.run.id } : {}),
|
|
129
|
+
...(input.tier ? { tier: input.tier } : {}),
|
|
111
130
|
};
|
|
112
131
|
}
|
|
113
132
|
export function readMessage(file) {
|
|
@@ -115,6 +134,14 @@ export function readMessage(file) {
|
|
|
115
134
|
const meta = data;
|
|
116
135
|
return { meta, body, file };
|
|
117
136
|
}
|
|
137
|
+
/** `readMessage` for files that may be anything (held mail): a symlink, FIFO or other non-regular entry throws instead of being followed or blocking. */
|
|
138
|
+
export function readMessageNoFollow(file) {
|
|
139
|
+
const buf = readRegularFileNoFollow(file);
|
|
140
|
+
if (!buf)
|
|
141
|
+
throw new Error(`${file} is not a readable regular file`);
|
|
142
|
+
const { data, body } = parseRaw(buf.toString("utf8"));
|
|
143
|
+
return { meta: data, body, file };
|
|
144
|
+
}
|
|
118
145
|
/** Paths of the unread messages in an agent's inbox, oldest name first. Does not open them. */
|
|
119
146
|
export function unreadFiles(project, agent) {
|
|
120
147
|
const dir = inboxDir(project, agent);
|
|
@@ -176,12 +203,15 @@ function sameText(body) {
|
|
|
176
203
|
* An earlier mail from `from` to `to` with the same type, the same reply target and the same text (unread or already read):
|
|
177
204
|
* sending it again changes nothing and is how two agents end up in a loop. Texts too short to mean anything are never duplicates.
|
|
178
205
|
*/
|
|
179
|
-
function earlierDuplicate(project, from, to, type, replyTo, body) {
|
|
206
|
+
function earlierDuplicate(project, from, to, type, replyTo, body, held = false, tier = "high") {
|
|
180
207
|
const text = sameText(body);
|
|
181
208
|
if (text.length < 20)
|
|
182
209
|
return undefined;
|
|
183
210
|
const dir = inboxDir(project, to);
|
|
184
|
-
|
|
211
|
+
const role = roleOfName(project.agents, to);
|
|
212
|
+
const slots = held ? Object.values(project.agents).filter((a) => roleOfName(project.agents, a.name) === role).map((a) => inboxDir(project, a.name)) : [dir];
|
|
213
|
+
const dirs = held ? [...slots.flatMap((d) => [d, path.join(d, "read")]), path.join(inboxDir(project, role), PENDING_DIR)] : [dir, path.join(dir, "read")];
|
|
214
|
+
for (const d of dirs) {
|
|
185
215
|
if (!fs.existsSync(d))
|
|
186
216
|
continue;
|
|
187
217
|
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
@@ -189,7 +219,7 @@ function earlierDuplicate(project, from, to, type, replyTo, body) {
|
|
|
189
219
|
continue;
|
|
190
220
|
try {
|
|
191
221
|
const m = readMessage(path.join(d, e.name));
|
|
192
|
-
if (m.meta.from === from && m.meta.type === type && (m.meta.reply_to ?? "") === (replyTo ?? "") && sameText(m.body) === text)
|
|
222
|
+
if (m.meta.from === from && m.meta.type === type && (m.meta.reply_to ?? "") === (replyTo ?? "") && sameText(m.body) === text && effectiveTier(m.meta) === tier)
|
|
193
223
|
return m;
|
|
194
224
|
}
|
|
195
225
|
catch {
|
|
@@ -200,8 +230,8 @@ function earlierDuplicate(project, from, to, type, replyTo, body) {
|
|
|
200
230
|
return undefined;
|
|
201
231
|
}
|
|
202
232
|
/** Validate each agent's outbox and move accepted mail into recipients' inboxes. Safe to run again after a crash. */
|
|
203
|
-
export function routeOutboxes(project) {
|
|
204
|
-
const res = { delivered: [], rejected: [], warnings: [], asks: [] };
|
|
233
|
+
export function routeOutboxes(project, routing) {
|
|
234
|
+
const res = { delivered: [], rejected: [], warnings: [], asks: [], tierIgnored: [] };
|
|
205
235
|
const journal = project.run ? new RouteJournal(project.run.dir) : undefined;
|
|
206
236
|
for (const sender of Object.values(project.agents)) {
|
|
207
237
|
const dir = outboxDir(project, sender.name);
|
|
@@ -214,14 +244,15 @@ export function routeOutboxes(project) {
|
|
|
214
244
|
.sort((a, b) => fs.statSync(a).mtimeMs - fs.statSync(b).mtimeMs || a.localeCompare(b));
|
|
215
245
|
for (const file of files) {
|
|
216
246
|
const source = sourceIdOf(sender.name, file);
|
|
217
|
-
const reject = (reason) => {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
247
|
+
const reject = (reason, notify = true) => {
|
|
248
|
+
if (notify)
|
|
249
|
+
deliverOnce(project, journal, `reject:${source}`, {
|
|
250
|
+
from: "dispatcher",
|
|
251
|
+
to: sender.name,
|
|
252
|
+
type: "failure",
|
|
253
|
+
subject: `Message ${path.basename(file)} was rejected`,
|
|
254
|
+
body: `Your outbox message \`${path.basename(file)}\` was not delivered: ${reason}\n\nIt was moved to \`rejected/\`.`,
|
|
255
|
+
});
|
|
225
256
|
const dest = path.join(dir, "rejected", path.basename(file));
|
|
226
257
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
227
258
|
fs.renameSync(file, dest);
|
|
@@ -276,7 +307,15 @@ export function routeOutboxes(project) {
|
|
|
276
307
|
reject(`missing "to".`);
|
|
277
308
|
continue;
|
|
278
309
|
}
|
|
279
|
-
const
|
|
310
|
+
const roleOf = (n) => roleOfName(project.agents, n);
|
|
311
|
+
if (sender.instance) {
|
|
312
|
+
const same = targets.find((t) => t !== sender.name && roleOf(t) === roleOf(sender.name));
|
|
313
|
+
if (same !== undefined) {
|
|
314
|
+
reject(`cannot message another instance of your own role ("${same}" is an instance of "${roleOf(sender.name)}"). Put what it needs in your reply to the lead.`);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const bad = targets.find((t) => !project.agents[t] || t === sender.name || (sender.canMessage !== "all" && !sender.canMessage.includes(roleOf(t))));
|
|
280
319
|
if (bad !== undefined) {
|
|
281
320
|
const why = !project.agents[bad]
|
|
282
321
|
? `no such agent "${bad}"`
|
|
@@ -286,6 +325,30 @@ export function routeOutboxes(project) {
|
|
|
286
325
|
reject(why + ".");
|
|
287
326
|
continue;
|
|
288
327
|
}
|
|
328
|
+
// `parallel` is read here from the outbox file, but only a verdict on it is kept: it goes to the journal (never back into the mail).
|
|
329
|
+
const flags = new Map();
|
|
330
|
+
if (routing && d.parallel !== undefined) {
|
|
331
|
+
for (const t of targets) {
|
|
332
|
+
const role = roleOf(t);
|
|
333
|
+
flags.set(t, parallelFlag({ value: d.parallel, senderIsLead: sender.name === project.lead, type, replyTo: d.reply_to, thread: d.thread, cap: routing.cap(role) }));
|
|
334
|
+
}
|
|
335
|
+
if ([...flags.values()].includes("invalid")) {
|
|
336
|
+
reject(`parallel must be true or false (YAML boolean), not ${JSON.stringify(d.parallel)}. Write \`parallel: true\` on a new task to run it in a parallel instance, or leave the line out.`);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// `tier` is judged per recipient like `parallel`, before the duplicate check, which needs the effective tier (spec 24.5.2, 24.5.4).
|
|
341
|
+
const tiers = new Map();
|
|
342
|
+
for (const t of targets)
|
|
343
|
+
tiers.set(t, tierFlag({ value: d.tier, senderIsLead: sender.name === project.lead, type, recipientTiers: project.agents[t]?.tiers }));
|
|
344
|
+
if ([...tiers.values()].includes("invalid")) {
|
|
345
|
+
reject(`tier must be low, mid or high, not ${JSON.stringify(d.tier)}. Write \`tier: low\` (or mid/high) on a task to run it on a cheaper model, or leave the line out to use the member's own model.`);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const tierOf = (to) => {
|
|
349
|
+
const f = tiers.get(to);
|
|
350
|
+
return f === "low" || f === "mid" || f === "high" ? f : undefined;
|
|
351
|
+
};
|
|
289
352
|
const missing = missingSections(type, parsed.body);
|
|
290
353
|
if (missing.length && project.run?.layout === "run") {
|
|
291
354
|
const key = formatRejectionKey(sender.name, type, subject);
|
|
@@ -302,7 +365,7 @@ export function routeOutboxes(project) {
|
|
|
302
365
|
for (const to of targets) {
|
|
303
366
|
if (journal?.get(source, to))
|
|
304
367
|
continue;
|
|
305
|
-
const earlier = earlierDuplicate(project, sender.name, to, type, replyTo, parsed.body);
|
|
368
|
+
const earlier = earlierDuplicate(project, sender.name, to, type, replyTo, parsed.body, !!routing?.held(roleOf(to)), tierOf(to) ?? "high");
|
|
306
369
|
if (earlier)
|
|
307
370
|
repeats.set(to, earlier);
|
|
308
371
|
}
|
|
@@ -310,18 +373,25 @@ export function routeOutboxes(project) {
|
|
|
310
373
|
.map(([to, m]) => `to "${to}" it repeats mail ${m.meta.id} ("${m.meta.subject}", ${m.meta.created}) with the same content`)
|
|
311
374
|
.join("; ");
|
|
312
375
|
const REPEAT_HELP = "Do not send it again: if that mail has not been answered yet, wait for the reply; if you need something different, change what you ask for; if the work is finished, the lead sends `done`.";
|
|
376
|
+
// A failure mail wakes the sender; an agent that answers with the same text again would be woken forever, so after
|
|
377
|
+
// MAX_DUPLICATE_NOTICES notices for the same repeated mail the repeat is still set aside and logged, but not announced.
|
|
378
|
+
// Bound to a run directory (either layout); a project-only mailbox has no place to count and stays unbounded.
|
|
379
|
+
const notifyRepeat = !repeats.size ||
|
|
380
|
+
!project.run ||
|
|
381
|
+
shouldNotifyDuplicate(project.run.dir, duplicateNoticeKey(sender.name, [...repeats].map(([to, m]) => [to, m.meta.id])), source);
|
|
313
382
|
if (repeats.size === targets.length) {
|
|
314
|
-
reject(`it is a duplicate: ${repeatNote()}, so it was not sent again. ${REPEAT_HELP}
|
|
383
|
+
reject(`it is a duplicate: ${repeatNote()}, so it was not sent again. ${REPEAT_HELP}`, notifyRepeat);
|
|
315
384
|
continue;
|
|
316
385
|
}
|
|
317
386
|
if (repeats.size) {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
387
|
+
if (notifyRepeat)
|
|
388
|
+
deliverOnce(project, journal, `dup:${source}`, {
|
|
389
|
+
from: "dispatcher",
|
|
390
|
+
to: sender.name,
|
|
391
|
+
type: "failure",
|
|
392
|
+
subject: `Part of ${path.basename(file)} was not sent`,
|
|
393
|
+
body: `Your outbox message \`${path.basename(file)}\` was delivered, except that ${repeatNote()}. ${REPEAT_HELP}`,
|
|
394
|
+
});
|
|
325
395
|
res.rejected.push({ from: sender.name, file, reason: `duplicate: ${repeatNote()}` });
|
|
326
396
|
}
|
|
327
397
|
const body = missing.length ? `${formatWarning(type, missing)}\n\n${parsed.body}` : parsed.body;
|
|
@@ -333,7 +403,22 @@ export function routeOutboxes(project) {
|
|
|
333
403
|
for (const to of targets) {
|
|
334
404
|
if (repeats.has(to))
|
|
335
405
|
continue;
|
|
336
|
-
const
|
|
406
|
+
const flag = flags.get(to);
|
|
407
|
+
const role = roleOf(to);
|
|
408
|
+
const tf = tiers.get(to);
|
|
409
|
+
if (typeof tf === "object" && !journal?.get(source, to))
|
|
410
|
+
res.tierIgnored.push({ from: sender.name, id: deterministicId(source, to), to, subject, type, reason: tf.ignored });
|
|
411
|
+
if (typeof flag === "object" && !journal?.get(source, to))
|
|
412
|
+
routing?.ignored(flag.ignored, { from: sender.name, id: deterministicId(source, to), to, subject });
|
|
413
|
+
// The first valid parallel task makes the role's instances durable before a single file of this mail exists.
|
|
414
|
+
if (routing && flag === "on" && !routing.held(role))
|
|
415
|
+
routing.enable(role);
|
|
416
|
+
const held = !!routing?.held(role);
|
|
417
|
+
const { msg, fresh } = deliverOnce(project, journal, source, { from: sender.name, to, type, subject, body, thread, reply_to: replyTo, ...(tierOf(to) ? { tier: tierOf(to) } : {}) }, {
|
|
418
|
+
...(held ? { held: true } : {}),
|
|
419
|
+
...(held && flag === "on" ? { parallel: true } : {}),
|
|
420
|
+
...(routing?.held(roleOf(sender.name)) ? { from: sender.name } : {}),
|
|
421
|
+
});
|
|
337
422
|
if (!fresh)
|
|
338
423
|
continue;
|
|
339
424
|
res.delivered.push({ from: sender.name, to, id: msg.meta.id, file: msg.file, type, subject });
|
package/dist/message-store.d.ts
CHANGED
|
@@ -9,15 +9,61 @@ export interface JournalEntry {
|
|
|
9
9
|
file: string;
|
|
10
10
|
created: string;
|
|
11
11
|
delivered: boolean;
|
|
12
|
+
/** Same-role instances (spec 23): the mail waits in `inbox/<role>/pending/` for the assigner. Only written for an activated role. */
|
|
13
|
+
held?: true;
|
|
14
|
+
/** The lead marked it `parallel: true` (read only from here, never from the mail file). */
|
|
15
|
+
parallel?: true;
|
|
16
|
+
/** The instance of an activated role that sent it. */
|
|
17
|
+
from?: string;
|
|
18
|
+
/** The instance a dispatcher notice is about. */
|
|
19
|
+
about?: string;
|
|
20
|
+
/** Position of the intent among the journal's records (a line cut short by a crash does not count). */
|
|
21
|
+
seq?: number;
|
|
22
|
+
}
|
|
23
|
+
/** What the journal knows about one message id: where the assigner put it, who sent it or what it is about, and its delivery line. */
|
|
24
|
+
export interface Ownership {
|
|
25
|
+
assign?: string;
|
|
26
|
+
from?: string;
|
|
27
|
+
about?: string;
|
|
28
|
+
intent?: {
|
|
29
|
+
seq: number;
|
|
30
|
+
to: string;
|
|
31
|
+
file: string;
|
|
32
|
+
source: string;
|
|
33
|
+
msg_id: string;
|
|
34
|
+
};
|
|
12
35
|
}
|
|
13
36
|
/** Append-only log of deliveries. A delivery is identified by `(source, recipient)`, never by a random id. */
|
|
14
37
|
export declare class RouteJournal {
|
|
15
38
|
private readonly file;
|
|
16
39
|
private readonly entries;
|
|
40
|
+
private readonly byMsg;
|
|
41
|
+
private readonly byFileName;
|
|
42
|
+
private readonly assigns;
|
|
43
|
+
private readonly firstHeldSeq;
|
|
44
|
+
private seqNext;
|
|
17
45
|
constructor(runDir: string);
|
|
46
|
+
/** Enter an intent in every index; `seq` is its position among the records read or appended. */
|
|
47
|
+
private index;
|
|
18
48
|
get(source: string, to: string): JournalEntry | undefined;
|
|
19
49
|
private append;
|
|
20
|
-
intent(e: Omit<JournalEntry, "delivered">): JournalEntry;
|
|
50
|
+
intent(e: Omit<JournalEntry, "delivered" | "seq">): JournalEntry;
|
|
51
|
+
/** The assigner's decision for a held mail, made durable before the file moves (spec 23.7.2). */
|
|
52
|
+
assign(a: {
|
|
53
|
+
msg_id: string;
|
|
54
|
+
file: string;
|
|
55
|
+
role: string;
|
|
56
|
+
slot: string;
|
|
57
|
+
}): void;
|
|
58
|
+
assigned(msgId: string): {
|
|
59
|
+
slot: string;
|
|
60
|
+
file: string;
|
|
61
|
+
role: string;
|
|
62
|
+
} | undefined;
|
|
63
|
+
byFile(file: string): JournalEntry | undefined;
|
|
64
|
+
/** Position of the first held intent addressed to `name`; undefined when there is none. */
|
|
65
|
+
firstHeld(name: string): number | undefined;
|
|
66
|
+
ownership(msgId: string): Ownership | undefined;
|
|
21
67
|
delivered(source: string, to: string): void;
|
|
22
68
|
}
|
|
23
69
|
/** Stable id of one outbox file instance: who wrote it, its name, when, and what it said. */
|
package/dist/message-store.js
CHANGED
|
@@ -15,6 +15,11 @@ export const mailDir = (runDir) => path.join(runDir, "mail");
|
|
|
15
15
|
export class RouteJournal {
|
|
16
16
|
file;
|
|
17
17
|
entries = new Map();
|
|
18
|
+
byMsg = new Map();
|
|
19
|
+
byFileName = new Map();
|
|
20
|
+
assigns = new Map();
|
|
21
|
+
firstHeldSeq = new Map();
|
|
22
|
+
seqNext = 0;
|
|
18
23
|
constructor(runDir) {
|
|
19
24
|
this.file = path.join(mailDir(runDir), "journal.jsonl");
|
|
20
25
|
let text = "";
|
|
@@ -30,27 +35,65 @@ export class RouteJournal {
|
|
|
30
35
|
try {
|
|
31
36
|
const j = JSON.parse(line);
|
|
32
37
|
const key = `${j.source}\u0000${j.to}`;
|
|
33
|
-
if (j.t === "intent")
|
|
34
|
-
this.
|
|
38
|
+
if (j.t === "intent") {
|
|
39
|
+
this.index({
|
|
40
|
+
source: j.source,
|
|
41
|
+
to: j.to,
|
|
42
|
+
msg_id: j.msg_id,
|
|
43
|
+
file: j.file,
|
|
44
|
+
created: j.created,
|
|
45
|
+
delivered: false,
|
|
46
|
+
...(j.held === true ? { held: true } : {}),
|
|
47
|
+
...(j.parallel === true ? { parallel: true } : {}),
|
|
48
|
+
...(typeof j.from === "string" ? { from: j.from } : {}),
|
|
49
|
+
...(typeof j.about === "string" ? { about: j.about } : {}),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
35
52
|
else if (j.t === "delivered") {
|
|
36
53
|
const e = this.entries.get(key);
|
|
37
54
|
if (e)
|
|
38
55
|
e.delivered = true;
|
|
39
56
|
}
|
|
57
|
+
else if (j.t === "assign" && typeof j.msg_id === "string" && typeof j.slot === "string") {
|
|
58
|
+
this.assigns.set(j.msg_id, { slot: j.slot, file: String(j.file), role: String(j.role) });
|
|
59
|
+
}
|
|
40
60
|
}
|
|
41
61
|
catch {
|
|
42
62
|
/* a line cut short by a crash: ignore it */
|
|
43
63
|
}
|
|
44
64
|
}
|
|
45
65
|
}
|
|
66
|
+
/** Enter an intent in every index; `seq` is its position among the records read or appended. */
|
|
67
|
+
index(e) {
|
|
68
|
+
e.seq = this.seqNext++;
|
|
69
|
+
this.entries.set(`${e.source}\u0000${e.to}`, e);
|
|
70
|
+
this.byMsg.set(e.msg_id, e);
|
|
71
|
+
this.byFileName.set(e.file, e);
|
|
72
|
+
if (e.held && !this.firstHeldSeq.has(e.to))
|
|
73
|
+
this.firstHeldSeq.set(e.to, e.seq);
|
|
74
|
+
return e;
|
|
75
|
+
}
|
|
46
76
|
get(source, to) {
|
|
47
77
|
return this.entries.get(`${source}\u0000${to}`);
|
|
48
78
|
}
|
|
49
79
|
append(obj) {
|
|
50
80
|
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
51
|
-
const fd = fs.openSync(this.file, "a");
|
|
81
|
+
const fd = fs.openSync(this.file, "a+");
|
|
52
82
|
try {
|
|
53
|
-
|
|
83
|
+
let text = `${JSON.stringify(obj)}\n`;
|
|
84
|
+
// A crash or a short write can leave the last line without its newline; appending straight after it would glue the two into one
|
|
85
|
+
// unreadable line. The real tail is read on every append (one byte), so no cached check can go stale, even with another writer.
|
|
86
|
+
const size = fs.fstatSync(fd).size;
|
|
87
|
+
if (size > 0) {
|
|
88
|
+
const last = Buffer.alloc(1);
|
|
89
|
+
fs.readSync(fd, last, 0, 1, size - 1);
|
|
90
|
+
if (last[0] !== 0x0a)
|
|
91
|
+
text = `\n${text}`;
|
|
92
|
+
}
|
|
93
|
+
const bytes = Buffer.from(text);
|
|
94
|
+
const written = fs.writeSync(fd, bytes);
|
|
95
|
+
if (written < bytes.length)
|
|
96
|
+
throw new Error(`Short write to ${this.file}: ${written} of ${bytes.length} bytes (disk full?). Resume the run after freeing space.`);
|
|
54
97
|
fs.fsyncSync(fd);
|
|
55
98
|
}
|
|
56
99
|
finally {
|
|
@@ -59,9 +102,34 @@ export class RouteJournal {
|
|
|
59
102
|
}
|
|
60
103
|
intent(e) {
|
|
61
104
|
this.append({ t: "intent", ...e });
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
105
|
+
return this.index({ ...e, delivered: false });
|
|
106
|
+
}
|
|
107
|
+
/** The assigner's decision for a held mail, made durable before the file moves (spec 23.7.2). */
|
|
108
|
+
assign(a) {
|
|
109
|
+
this.append({ t: "assign", ...a });
|
|
110
|
+
this.assigns.set(a.msg_id, { slot: a.slot, file: a.file, role: a.role });
|
|
111
|
+
}
|
|
112
|
+
assigned(msgId) {
|
|
113
|
+
return this.assigns.get(msgId);
|
|
114
|
+
}
|
|
115
|
+
byFile(file) {
|
|
116
|
+
return this.byFileName.get(file);
|
|
117
|
+
}
|
|
118
|
+
/** Position of the first held intent addressed to `name`; undefined when there is none. */
|
|
119
|
+
firstHeld(name) {
|
|
120
|
+
return this.firstHeldSeq.get(name);
|
|
121
|
+
}
|
|
122
|
+
ownership(msgId) {
|
|
123
|
+
const intent = this.byMsg.get(msgId);
|
|
124
|
+
const assign = this.assigns.get(msgId)?.slot;
|
|
125
|
+
if (!intent && assign === undefined)
|
|
126
|
+
return undefined;
|
|
127
|
+
return {
|
|
128
|
+
...(assign !== undefined ? { assign } : {}),
|
|
129
|
+
...(intent?.from ? { from: intent.from } : {}),
|
|
130
|
+
...(intent?.about ? { about: intent.about } : {}),
|
|
131
|
+
...(intent ? { intent: { seq: intent.seq, to: intent.to, file: intent.file, source: intent.source, msg_id: intent.msg_id } } : {}),
|
|
132
|
+
};
|
|
65
133
|
}
|
|
66
134
|
delivered(source, to) {
|
|
67
135
|
this.append({ t: "delivered", source, to });
|
package/dist/paths.d.ts
CHANGED
|
@@ -23,5 +23,12 @@ export interface ProjectPaths {
|
|
|
23
23
|
taskMemory: string;
|
|
24
24
|
}
|
|
25
25
|
export declare function projectPaths(home: string, project: string): ProjectPaths;
|
|
26
|
+
/** True when a relative path leaves its base: it is `..`, starts with a `..` segment, or is absolute. A name like `..x` is not an escape. */
|
|
27
|
+
export declare function escapesParent(rel: string, sep?: string): boolean;
|
|
26
28
|
/** True when `child` is `parent` or inside it. */
|
|
27
29
|
export declare function isInside(parent: string, child: string): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* `p` with symlinks resolved as far as it exists: the real path of its deepest existing ancestor plus the rest. A path that does not exist
|
|
32
|
+
* yet (a directory about to be made) still compares equal to the same place reached through a symlink.
|
|
33
|
+
*/
|
|
34
|
+
export declare function canonicalPath(p: string): string;
|
package/dist/paths.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
export const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
@@ -43,8 +44,32 @@ export function projectPaths(home, project) {
|
|
|
43
44
|
taskMemory: path.join(root, "task-memory"),
|
|
44
45
|
};
|
|
45
46
|
}
|
|
47
|
+
/** True when a relative path leaves its base: it is `..`, starts with a `..` segment, or is absolute. A name like `..x` is not an escape. */
|
|
48
|
+
export function escapesParent(rel, sep = path.sep) {
|
|
49
|
+
return rel === ".." || rel.startsWith(`..${sep}`) || path.isAbsolute(rel);
|
|
50
|
+
}
|
|
46
51
|
/** True when `child` is `parent` or inside it. */
|
|
47
52
|
export function isInside(parent, child) {
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
return !escapesParent(path.relative(parent, child));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* `p` with symlinks resolved as far as it exists: the real path of its deepest existing ancestor plus the rest. A path that does not exist
|
|
57
|
+
* yet (a directory about to be made) still compares equal to the same place reached through a symlink.
|
|
58
|
+
*/
|
|
59
|
+
export function canonicalPath(p) {
|
|
60
|
+
const abs = path.resolve(p);
|
|
61
|
+
const rest = [];
|
|
62
|
+
let head = abs;
|
|
63
|
+
for (;;) {
|
|
64
|
+
try {
|
|
65
|
+
return path.join(fs.realpathSync.native(head), ...rest);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
const up = path.dirname(head);
|
|
69
|
+
if (up === head)
|
|
70
|
+
return abs;
|
|
71
|
+
rest.unshift(path.basename(head));
|
|
72
|
+
head = up;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
50
75
|
}
|
package/dist/policy.d.ts
CHANGED
|
@@ -44,6 +44,7 @@ export declare const scratchCwd: (workDir: string) => string;
|
|
|
44
44
|
/** The repo directory this agent works in: its own worktree, or the main repo. */
|
|
45
45
|
export declare function repoDirFor(project: ResolvedProject, agent: string): string;
|
|
46
46
|
export declare function writePolicy(project: ResolvedProject, agent: ResolvedAgent): WritePolicy;
|
|
47
|
+
export declare function osSandboxAvailable(): boolean;
|
|
47
48
|
/** Test seam: pretend the OS sandbox is (un)available; `undefined` re-probes the host. */
|
|
48
49
|
export declare function setOsSandboxForTest(available: boolean | undefined): void;
|
|
49
50
|
export declare function enforcementFor(project: ResolvedProject, agent: ResolvedAgent): AgentEnforcement;
|
package/dist/policy.js
CHANGED
|
@@ -136,13 +136,13 @@ export function writePolicy(project, agent) {
|
|
|
136
136
|
deny.push(path.join(ws.root, ".git"), ...["commondir", "config.worktree", "gitdir"].map((f) => path.join(ws.gitDir, f)));
|
|
137
137
|
return {
|
|
138
138
|
allowDirs,
|
|
139
|
-
readDirs: [...(shared && !mayWriteShared ? [shared] : []), ...(isLead ? [] : [recordsDir(project)])],
|
|
139
|
+
readDirs: [...(shared && !mayWriteShared ? [shared] : []), ...(isLead ? [] : [recordsDir(project)]), ...Object.values(agent.sharedMemory ?? {})],
|
|
140
140
|
allowFiles,
|
|
141
141
|
deny: [...new Set(deny)],
|
|
142
142
|
};
|
|
143
143
|
}
|
|
144
144
|
let sandboxProbe;
|
|
145
|
-
function osSandboxAvailable() {
|
|
145
|
+
export function osSandboxAvailable() {
|
|
146
146
|
if (sandboxProbe !== undefined)
|
|
147
147
|
return sandboxProbe;
|
|
148
148
|
if (process.platform === "darwin")
|
package/dist/prompt.d.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import type { ResolvedAgent, ResolvedProject } from "./config.js";
|
|
2
2
|
import type { Message } from "./mailbox.js";
|
|
3
3
|
import type { Layer, MemoryFile, MissingPath, ProjectChanges } from "./memory-tidy.js";
|
|
4
|
+
/** Lead-only rules that keep the number of wake-ups down; placed after the progress-tracking block. */
|
|
5
|
+
export declare const WAKE_ECONOMY_SECTION: string[];
|
|
6
|
+
/** The lead's system prompt gets this at its very end when same-role instances are possible in the run (spec 23.12). No trailing newline. */
|
|
7
|
+
export declare const PARALLEL_SECTION: (n: number) => string;
|
|
8
|
+
/** The lead's system prompt ends with this when a member can run a task on a cheaper model (spec 24.10); `members` = [name, model of low, mid, high]. No trailing newline. */
|
|
9
|
+
export declare const TIER_SECTION: (members: {
|
|
10
|
+
name: string;
|
|
11
|
+
low: string;
|
|
12
|
+
mid: string;
|
|
13
|
+
high: string;
|
|
14
|
+
}[]) => string;
|
|
4
15
|
export declare const COMMON_LIMIT: number;
|
|
5
16
|
export declare const MEMORY_INDEX_LIMIT: number;
|
|
6
17
|
export declare const HANDOFF_LIMIT: number;
|