moonmill 0.1.0 → 0.2.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/main.js +2381 -362
- package/package.json +2 -2
package/dist/main.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, posix, relative, resolve } from "node:path";
|
|
3
3
|
import { parseArgs, promisify } from "node:util";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import { chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rename, rm, rmdir, stat, symlink, utimes, writeFile } from "node:fs/promises";
|
|
5
|
+
import { appendFile, chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rename, rm, rmdir, stat, symlink, utimes, writeFile } from "node:fs/promises";
|
|
6
6
|
import { arch, homedir, hostname, release, tmpdir, userInfo, version } from "node:os";
|
|
7
7
|
import { createHash, randomUUID } from "node:crypto";
|
|
8
8
|
import { execFile, spawn } from "node:child_process";
|
|
9
9
|
import { RuntimeFailureError, SessionNotFoundError } from "@botiverse/oar/browser";
|
|
10
|
-
import { promptAndWait, toolResultText, viewOf } from "@botiverse/oar/observe";
|
|
10
|
+
import { eventsOf, promptAndWait, toolResultText, viewOf } from "@botiverse/oar/observe";
|
|
11
11
|
import { TomlError, parse } from "smol-toml";
|
|
12
12
|
import { parse as parse$1, stringify } from "yaml";
|
|
13
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
13
14
|
import { WebSocket } from "ws";
|
|
14
15
|
import { once } from "node:events";
|
|
15
|
-
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
16
16
|
//#region ../protocol/src/activity.ts
|
|
17
17
|
/** How live activity is paced and how long it lasts. */
|
|
18
18
|
const LIVE_ACTIVITY = {
|
|
@@ -110,10 +110,123 @@ function filedId(what) {
|
|
|
110
110
|
/** A git commit's id in full: 40 hexadecimal digits (SHA-1), or 64 (SHA-256). */
|
|
111
111
|
const COMMIT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
112
112
|
//#endregion
|
|
113
|
+
//#region ../protocol/src/images.ts
|
|
114
|
+
/** How many images one document may carry, and how large each may be. */
|
|
115
|
+
const IMAGE_LIMITS = {
|
|
116
|
+
/** Images in one document. */
|
|
117
|
+
images: 20,
|
|
118
|
+
/** Bytes of one image. */
|
|
119
|
+
bytes: 10485760,
|
|
120
|
+
/** Characters of what one shows (`alt`). */
|
|
121
|
+
alt: 500
|
|
122
|
+
};
|
|
123
|
+
/** The image types a document may carry. */
|
|
124
|
+
const IMAGE_TYPES = [
|
|
125
|
+
"image/png",
|
|
126
|
+
"image/jpeg",
|
|
127
|
+
"image/gif",
|
|
128
|
+
"image/webp"
|
|
129
|
+
];
|
|
130
|
+
const imageType = z.enum(IMAGE_TYPES);
|
|
131
|
+
/**
|
|
132
|
+
* An image's key in the image store, under its Owner's prefix: the SHA-256 of its bytes, hex (a
|
|
133
|
+
* Node computes it with `node:crypto`, a page with `crypto.subtle`).
|
|
134
|
+
*/
|
|
135
|
+
const imageKey = z.string().regex(/^[0-9a-f]{64}$/, "Not an image's key");
|
|
136
|
+
/** One image a document names: its key in the image store, its type and what it shows. */
|
|
137
|
+
const documentImage = z.object({
|
|
138
|
+
key: imageKey,
|
|
139
|
+
mimeType: imageType,
|
|
140
|
+
/** What it shows, for someone who can't see it. */
|
|
141
|
+
alt: z.string().max(IMAGE_LIMITS.alt).optional()
|
|
142
|
+
});
|
|
143
|
+
/** A document's images, in the order it shows them. */
|
|
144
|
+
const documentImages = z.array(documentImage).max(IMAGE_LIMITS.images);
|
|
145
|
+
/**
|
|
146
|
+
* An image as a document names it without its bytes or a key: its type and what it shows. A
|
|
147
|
+
* conversation's images, which stay on their Node (a Help Request's question and reply), are named
|
|
148
|
+
* so, and a Workspace's file so describes the image in it.
|
|
149
|
+
*/
|
|
150
|
+
const imageAbout = documentImage.pick({
|
|
151
|
+
mimeType: true,
|
|
152
|
+
alt: true
|
|
153
|
+
});
|
|
154
|
+
/**
|
|
155
|
+
* A short-lived address in the image store, signed for one image and one request: its URL, and the
|
|
156
|
+
* headers the request must carry as signed. A path (on a Platform keeping images on its own disk)
|
|
157
|
+
* is the Platform's: resolve it against the Platform's address. Send it nothing else, a Node's
|
|
158
|
+
* credential least of all.
|
|
159
|
+
*/
|
|
160
|
+
const imageAddress = z.object({
|
|
161
|
+
url: z.string().min(1),
|
|
162
|
+
headers: z.record(z.string(), z.string()).optional()
|
|
163
|
+
});
|
|
164
|
+
/** One image someone means to put into the image store: its key, its type and how large it is. */
|
|
165
|
+
const imageToUpload = z.object({
|
|
166
|
+
key: imageKey,
|
|
167
|
+
mimeType: imageType,
|
|
168
|
+
bytes: z.int().positive().max(IMAGE_LIMITS.bytes)
|
|
169
|
+
});
|
|
170
|
+
/** The images of one document to put into the image store. */
|
|
171
|
+
const imagesToUpload = z.array(imageToUpload).min(1).max(IMAGE_LIMITS.images);
|
|
172
|
+
/**
|
|
173
|
+
* Where to put one image: the address to PUT its bytes to, with the headers it names, or null when
|
|
174
|
+
* the store keeps it already.
|
|
175
|
+
*/
|
|
176
|
+
const imageUpload = z.object({
|
|
177
|
+
key: imageKey,
|
|
178
|
+
address: imageAddress.nullable()
|
|
179
|
+
});
|
|
180
|
+
/** One image sent with words, as the head names it: its type by its first bytes, and its size. */
|
|
181
|
+
const sentImage = z.object({
|
|
182
|
+
mimeType: imageType,
|
|
183
|
+
bytes: z.int().positive().max(IMAGE_LIMITS.bytes)
|
|
184
|
+
});
|
|
185
|
+
z.array(sentImage).max(IMAGE_LIMITS.images);
|
|
186
|
+
/** An image's type, from its first bytes; null for anything that isn't one of `IMAGE_TYPES`. */
|
|
187
|
+
function imageTypeOf(bytes) {
|
|
188
|
+
if (holds$1(bytes, 0, [
|
|
189
|
+
137,
|
|
190
|
+
80,
|
|
191
|
+
78,
|
|
192
|
+
71,
|
|
193
|
+
13,
|
|
194
|
+
10,
|
|
195
|
+
26,
|
|
196
|
+
10
|
|
197
|
+
])) return "image/png";
|
|
198
|
+
if (holds$1(bytes, 0, [
|
|
199
|
+
255,
|
|
200
|
+
216,
|
|
201
|
+
255
|
|
202
|
+
])) return "image/jpeg";
|
|
203
|
+
if (holds$1(bytes, 0, "GIF8")) return "image/gif";
|
|
204
|
+
if (holds$1(bytes, 0, "RIFF") && holds$1(bytes, 8, "WEBP")) return "image/webp";
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
/** Whether `bytes` hold `expected` from `at`: these bytes, or this ASCII text's. */
|
|
208
|
+
function holds$1(bytes, at, expected) {
|
|
209
|
+
for (let index = 0; index < expected.length; index++) {
|
|
210
|
+
const byte = typeof expected === "string" ? expected.charCodeAt(index) : expected[index];
|
|
211
|
+
if (bytes[at + index] !== byte) return false;
|
|
212
|
+
}
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
//#endregion
|
|
113
216
|
//#region ../protocol/src/runs.ts
|
|
114
217
|
/** A Workpiece's brief: what the Owner asks for when they dispatch it. */
|
|
115
218
|
const brief = z.string().trim().min(1).max(2e4);
|
|
116
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Where a Workstation Task is. `queued` until its Agent's Node starts it, `running` while the Agent
|
|
221
|
+
* works, `handed-off` once the Agent delivered its Hand-off, `being-checked` while the Inspector of
|
|
222
|
+
* the Belt it goes onto judges it against the Belt's criteria (ADR-0014), `accepted` once the
|
|
223
|
+
* Hand-off moved on (onto a Belt without criteria or at the end of the Pipeline at once, once it
|
|
224
|
+
* passed its check, or once the Owner accepted it anyway), `rejected` when it failed its check
|
|
225
|
+
* (until the Owner accepts it anyway, if they do). `failed` and `timed-out` end a Task that
|
|
226
|
+
* can't finish. `suspended` while its Agent waits for the Owner's reply to a Help Request, its
|
|
227
|
+
* Session kept on its Node to resume with the reply.
|
|
228
|
+
*/
|
|
229
|
+
const WORKSTATION_TASK_STATUSES = [
|
|
117
230
|
"queued",
|
|
118
231
|
"running",
|
|
119
232
|
"suspended",
|
|
@@ -123,7 +236,8 @@ z.enum([
|
|
|
123
236
|
"accepted",
|
|
124
237
|
"failed",
|
|
125
238
|
"timed-out"
|
|
126
|
-
]
|
|
239
|
+
];
|
|
240
|
+
z.enum(WORKSTATION_TASK_STATUSES);
|
|
127
241
|
/** How a Workstation Task ends without a Hand-off. */
|
|
128
242
|
const TASK_FAILURE_STATUSES = ["failed", "timed-out"];
|
|
129
243
|
/**
|
|
@@ -186,31 +300,15 @@ function shortOutcome(outcome) {
|
|
|
186
300
|
}
|
|
187
301
|
/**
|
|
188
302
|
* What keeps a Hand-off a document rather than a carrier of materials (ADR-0008): some text, a few
|
|
189
|
-
*
|
|
190
|
-
*
|
|
303
|
+
* images (as many and as large as any document's: `IMAGE_LIMITS`, kept in the image store and named
|
|
304
|
+
* by key: images.ts), and references to everything else. Together they stay well inside one
|
|
305
|
+
* message of the Node link.
|
|
191
306
|
*/
|
|
192
307
|
const HAND_OFF_LIMITS = {
|
|
193
308
|
/** Characters of Markdown. */
|
|
194
309
|
markdown: 1e5,
|
|
195
|
-
images: 4,
|
|
196
|
-
/** Bytes of one image, before base64. */
|
|
197
|
-
imageBytes: 524288,
|
|
198
310
|
references: 20
|
|
199
311
|
};
|
|
200
|
-
/** The image types a Hand-off may carry. */
|
|
201
|
-
const HAND_OFF_IMAGE_TYPES = [
|
|
202
|
-
"image/png",
|
|
203
|
-
"image/jpeg",
|
|
204
|
-
"image/gif",
|
|
205
|
-
"image/webp"
|
|
206
|
-
];
|
|
207
|
-
const handOffImage = z.object({
|
|
208
|
-
mimeType: z.enum(HAND_OFF_IMAGE_TYPES),
|
|
209
|
-
/** The image, base64. */
|
|
210
|
-
data: z.base64().max(Math.ceil(HAND_OFF_LIMITS.imageBytes / 3) * 4),
|
|
211
|
-
/** What it shows, for someone who can't see it. */
|
|
212
|
-
alt: z.string().max(500).optional()
|
|
213
|
-
});
|
|
214
312
|
/** Material the Hand-off points at, where it lives: a label, and a URL or a pinned identifier. */
|
|
215
313
|
const handOffReference = z.object({
|
|
216
314
|
label: z.string().trim().min(1).max(200),
|
|
@@ -219,25 +317,35 @@ const handOffReference = z.object({
|
|
|
219
317
|
/** The document an Agent delivers at the end of its Workstation Task (ADR-0008). */
|
|
220
318
|
const handOff = z.object({
|
|
221
319
|
markdown: z.string().trim().min(1).max(HAND_OFF_LIMITS.markdown),
|
|
222
|
-
images:
|
|
320
|
+
images: documentImages,
|
|
223
321
|
references: z.array(handOffReference).max(HAND_OFF_LIMITS.references)
|
|
224
322
|
});
|
|
225
|
-
|
|
323
|
+
/**
|
|
324
|
+
* Where a check of a Hand-off is (ADR-0014): `queued` until its Inspector's Node starts it,
|
|
325
|
+
* `running` while the Inspector judges, then ended: `passed`, `failed` (with annotations), or
|
|
326
|
+
* `no-judgement` when its Session ended without one (it crashed, timed out, lost its Node, or
|
|
327
|
+
* never called the check tool).
|
|
328
|
+
*/
|
|
329
|
+
const CHECK_STATUSES = [
|
|
226
330
|
"queued",
|
|
227
331
|
"running",
|
|
228
332
|
"passed",
|
|
229
333
|
"failed",
|
|
230
334
|
"no-judgement"
|
|
231
|
-
]
|
|
335
|
+
];
|
|
336
|
+
z.enum(CHECK_STATUSES);
|
|
232
337
|
/** An Inspector's annotations: Markdown, bounded like a Hand-off's text. */
|
|
233
338
|
const annotations = z.string().trim().min(1).max(HAND_OFF_LIMITS.markdown);
|
|
234
339
|
/**
|
|
235
340
|
* An Inspector's judgement of a Hand-off against its Belt's criteria: passed, or failed with
|
|
236
|
-
* annotations saying which criteria weren't met and why
|
|
341
|
+
* annotations saying which criteria weren't met and why, and the images that show what is wrong
|
|
342
|
+
* beside them (#184), named in the image store as a Hand-off's are (images.ts). A passing one
|
|
343
|
+
* carries neither. (A judgement from protocol 24 or before, in an outbox or the ledger, has none.)
|
|
237
344
|
*/
|
|
238
345
|
const judgement = z.discriminatedUnion("outcome", [z.object({ outcome: z.literal("passed") }), z.object({
|
|
239
346
|
outcome: z.literal("failed"),
|
|
240
|
-
annotations
|
|
347
|
+
annotations,
|
|
348
|
+
images: documentImages.default([])
|
|
241
349
|
})]);
|
|
242
350
|
/**
|
|
243
351
|
* Where a Workpiece holds on a paused line, and why (spec "Pausing a line"): each step that would
|
|
@@ -288,7 +396,17 @@ const WORKSPACE_FOLDERS = {
|
|
|
288
396
|
/** An Inspector's Workspace's Hand-off under check. */
|
|
289
397
|
underCheck: "hand-off",
|
|
290
398
|
/** A Rework's Workspace's Hand-off of the attempt it follows. */
|
|
291
|
-
previousAttempt: "previous-attempt"
|
|
399
|
+
previousAttempt: "previous-attempt",
|
|
400
|
+
/**
|
|
401
|
+
* A work Session's Help Requests (#185): the images each question asked with, copied as they were
|
|
402
|
+
* then, one folder per question.
|
|
403
|
+
*/
|
|
404
|
+
helpRequest: "help-request",
|
|
405
|
+
/**
|
|
406
|
+
* A chat's Workspace (#187): the images each of the Owner's messages came with, one folder per
|
|
407
|
+
* turn.
|
|
408
|
+
*/
|
|
409
|
+
chatTurns: "chat-turns"
|
|
292
410
|
};
|
|
293
411
|
/**
|
|
294
412
|
* Every name at a Workspace's root that is the Node's own: its folders and the instructions files
|
|
@@ -450,6 +568,8 @@ z.object({
|
|
|
450
568
|
outcome: sessionOutcome.nullable(),
|
|
451
569
|
usage: tokenUsage.nullable()
|
|
452
570
|
});
|
|
571
|
+
/** What a chat turn's prompt calls the images the Owner's message came with: "This message's…". */
|
|
572
|
+
const CHAT_MESSAGE_IMAGES = "this message";
|
|
453
573
|
/**
|
|
454
574
|
* What the Owner calls an Agent, a Factory, a Workshop, a Pipeline or a Workstation, and the name
|
|
455
575
|
* they give themselves (the Owner account's, renamed in the Account panel).
|
|
@@ -461,7 +581,7 @@ const definitionName = z.string().trim().min(1).max(100);
|
|
|
461
581
|
* at its back-left corner's from its Factory's, and a Pipeline at its Intake's from its Workshop's
|
|
462
582
|
* back-left corner (ADR-0015). Nothing stores a size: each is as big as what it holds needs.
|
|
463
583
|
*/
|
|
464
|
-
const spot = z.object({
|
|
584
|
+
const spot$1 = z.object({
|
|
465
585
|
x: z.number(),
|
|
466
586
|
z: z.number()
|
|
467
587
|
});
|
|
@@ -510,6 +630,22 @@ const attemptBudget = z.number().int().min(1).max({
|
|
|
510
630
|
human: 1
|
|
511
631
|
}.max);
|
|
512
632
|
/**
|
|
633
|
+
* How long an Agent Workstation's Task may be at work in one segment of its Session before it
|
|
634
|
+
* times out, in minutes (#174): counted from the Task's start, and afresh from each resume after
|
|
635
|
+
* the Owner's reply to a Help Request, a suspended Task never timing out. Set per Workstation by
|
|
636
|
+
* the Owner, within bounds: less than `min` would stop every Task at once, more than `max` leave a
|
|
637
|
+
* stuck one running for days unnoticed. A Human Workstation has none to set (the Owner's audit
|
|
638
|
+
* never times out): it holds the default, unused.
|
|
639
|
+
*/
|
|
640
|
+
const TIME_LIMIT = {
|
|
641
|
+
/** What a new Agent Workstation gets, and what every one had before it could be set. */
|
|
642
|
+
default: 60,
|
|
643
|
+
min: 5,
|
|
644
|
+
/** 72 hours. */
|
|
645
|
+
max: 4320
|
|
646
|
+
};
|
|
647
|
+
const timeLimitMinutes = z.number().int().min(TIME_LIMIT.min).max(TIME_LIMIT.max);
|
|
648
|
+
/**
|
|
513
649
|
* Whether an Agent can run on this Runtime: installed and signed in, as its Node last reported. A
|
|
514
650
|
* sign-in the Node couldn't confirm counts as not signed in.
|
|
515
651
|
*/
|
|
@@ -1029,6 +1165,8 @@ const RECORD_LIMITS = {
|
|
|
1029
1165
|
tool: 200,
|
|
1030
1166
|
/** Characters of why a turn failed. */
|
|
1031
1167
|
reason: 2e3,
|
|
1168
|
+
/** Images one prompt or tool call names: any past these go unnamed. */
|
|
1169
|
+
images: 50,
|
|
1032
1170
|
/** Items in one part of the answer. */
|
|
1033
1171
|
itemsPerPart: 200,
|
|
1034
1172
|
/** Items in a whole record: what follows is left out, and the record says how many. */
|
|
@@ -1044,6 +1182,20 @@ function recordText(max) {
|
|
|
1044
1182
|
omitted: z.number().int().nonnegative()
|
|
1045
1183
|
});
|
|
1046
1184
|
}
|
|
1185
|
+
/** The most images one segment of a record numbers: any past these go unnamed. */
|
|
1186
|
+
const MAX_SEGMENT_IMAGES = RECORD_LIMITS.items * RECORD_LIMITS.images;
|
|
1187
|
+
/**
|
|
1188
|
+
* An image a record names (#188): one a prompt carried, a file in the Session's Workspace, or one a
|
|
1189
|
+
* tool call returned, kept in the record itself. Named by where it is, never by a path: in segment
|
|
1190
|
+
* `segment`, the `index`th image (0, 1, 2…) of those its prompts carried and its tools returned, in
|
|
1191
|
+
* the order they came.
|
|
1192
|
+
*/
|
|
1193
|
+
const recordImage = z.object({
|
|
1194
|
+
segment: z.number().int().positive(),
|
|
1195
|
+
index: z.number().int().nonnegative().max(MAX_SEGMENT_IMAGES - 1)
|
|
1196
|
+
});
|
|
1197
|
+
/** The images a prompt or a tool call names, if any: absent when none. */
|
|
1198
|
+
const recordImages = z.array(recordImage).min(1).max(RECORD_LIMITS.images).optional();
|
|
1047
1199
|
/**
|
|
1048
1200
|
* How a tool call stands in the record: it returned (`ok`) or failed, it was still `running`
|
|
1049
1201
|
* when the record ends, or it `ended` without a result its Runtime reported (its turn or Session
|
|
@@ -1125,7 +1277,8 @@ const recordItem = z.discriminatedUnion("kind", [
|
|
|
1125
1277
|
}),
|
|
1126
1278
|
z.object({
|
|
1127
1279
|
kind: z.literal("prompt"),
|
|
1128
|
-
text: recordText(RECORD_LIMITS.prompt)
|
|
1280
|
+
text: recordText(RECORD_LIMITS.prompt),
|
|
1281
|
+
images: recordImages
|
|
1129
1282
|
}),
|
|
1130
1283
|
z.object({
|
|
1131
1284
|
kind: z.literal("text"),
|
|
@@ -1141,7 +1294,8 @@ const recordItem = z.discriminatedUnion("kind", [
|
|
|
1141
1294
|
tool: z.string().min(1).max(RECORD_LIMITS.tool),
|
|
1142
1295
|
input: recordText(RECORD_LIMITS.toolInput).nullable(),
|
|
1143
1296
|
result: z.enum(RECORD_TOOL_RESULTS),
|
|
1144
|
-
output: recordText(RECORD_LIMITS.toolOutput).nullable()
|
|
1297
|
+
output: recordText(RECORD_LIMITS.toolOutput).nullable(),
|
|
1298
|
+
images: recordImages
|
|
1145
1299
|
}),
|
|
1146
1300
|
z.object({
|
|
1147
1301
|
kind: z.literal("turn-ended"),
|
|
@@ -1175,30 +1329,49 @@ const verdictText = z.string().trim().min(1).max(2e4);
|
|
|
1175
1329
|
*/
|
|
1176
1330
|
const helpQuestion = z.string().trim().min(1).max(2e4);
|
|
1177
1331
|
/**
|
|
1332
|
+
* The images a Verdict's guidance or note carries beside its words (spec #172, stories 8 and 9),
|
|
1333
|
+
* each named by its key in the image store, where the Owner's page put it first (ADR-0025). They
|
|
1334
|
+
* travel with the Workpiece as the words do. Absent for a Verdict given without images, and for
|
|
1335
|
+
* every one given before Verdicts carried them.
|
|
1336
|
+
*/
|
|
1337
|
+
const verdictImages = documentImages.optional();
|
|
1338
|
+
/**
|
|
1178
1339
|
* The Verdicts the Owner rules with (spec stories 46, 48, 50): Approve (onward, with an optional
|
|
1179
1340
|
* note), Send back (to an earlier Agent Workstation, with guidance), Rework with guidance (a fresh
|
|
1180
1341
|
* round at the Ticket's Workstation), Accept anyway (the latest Hand-off moves on unchecked), Reply
|
|
1181
1342
|
* & resume (a Help Request's answer), and Scrap (the Workpiece ends). Which ones a Ticket takes
|
|
1182
|
-
* depends on its category (`VERDICTS`).
|
|
1343
|
+
* depends on its category (`VERDICTS`). A Send back's or a Rework's guidance, and an Approve's note,
|
|
1344
|
+
* may carry images beside their words (`imagesOf`), never instead of them: an Approve with images
|
|
1345
|
+
* has a note (the Platform's Verdict rule refuses one without).
|
|
1183
1346
|
*/
|
|
1184
1347
|
const verdict = z.discriminatedUnion("kind", [
|
|
1185
1348
|
z.object({
|
|
1186
1349
|
kind: z.literal("approve"),
|
|
1187
|
-
note: verdictText.optional()
|
|
1350
|
+
note: verdictText.optional(),
|
|
1351
|
+
images: verdictImages
|
|
1188
1352
|
}),
|
|
1189
1353
|
z.object({
|
|
1190
1354
|
kind: z.literal("send-back"),
|
|
1191
1355
|
workstationId: z.string().min(1),
|
|
1192
|
-
guidance: verdictText
|
|
1356
|
+
guidance: verdictText,
|
|
1357
|
+
images: verdictImages
|
|
1193
1358
|
}),
|
|
1194
1359
|
z.object({
|
|
1195
1360
|
kind: z.literal("rework"),
|
|
1196
|
-
guidance: verdictText
|
|
1361
|
+
guidance: verdictText,
|
|
1362
|
+
images: verdictImages
|
|
1197
1363
|
}),
|
|
1198
1364
|
z.object({ kind: z.literal("accept-anyway") }),
|
|
1199
1365
|
z.object({
|
|
1200
1366
|
kind: z.literal("reply"),
|
|
1201
|
-
reply: verdictText
|
|
1367
|
+
reply: verdictText,
|
|
1368
|
+
/**
|
|
1369
|
+
* The images the reply came with (#186), named without their bytes: they went to the Node of
|
|
1370
|
+
* the Task it resumes, into its Session's Workspace, and nowhere else (ADR-0025). The Agents
|
|
1371
|
+
* after it are told how many there were. Absent from a reply in words alone, and from every
|
|
1372
|
+
* reply given before images were.
|
|
1373
|
+
*/
|
|
1374
|
+
images: z.array(imageAbout).min(1).max(IMAGE_LIMITS.images).optional()
|
|
1202
1375
|
}),
|
|
1203
1376
|
z.object({ kind: z.literal("scrap") })
|
|
1204
1377
|
]);
|
|
@@ -1212,6 +1385,16 @@ const VERDICT_NAMES = {
|
|
|
1212
1385
|
scrap: "Scrap"
|
|
1213
1386
|
};
|
|
1214
1387
|
/**
|
|
1388
|
+
* Where a Verdict sends the Workpiece, as its stamp's ink shows it: `onward`, `back` for Rework,
|
|
1389
|
+
* or to its `end` (Scrap). The Desk's stamps, the courier robot's paper and a Showroom's moments
|
|
1390
|
+
* are inked so.
|
|
1391
|
+
*/
|
|
1392
|
+
const VERDICT_INK_KINDS = [
|
|
1393
|
+
"onward",
|
|
1394
|
+
"back",
|
|
1395
|
+
"end"
|
|
1396
|
+
];
|
|
1397
|
+
/**
|
|
1215
1398
|
* What the Owner wrote into a Verdict (spec story 54): the guidance of a Send back or a Rework with
|
|
1216
1399
|
* guidance, an Approve's note, a Help Request's reply. Null for a Verdict that carries none.
|
|
1217
1400
|
*/
|
|
@@ -1229,6 +1412,20 @@ function wordsOf(given) {
|
|
|
1229
1412
|
}
|
|
1230
1413
|
return words;
|
|
1231
1414
|
}
|
|
1415
|
+
/**
|
|
1416
|
+
* The images the Owner put beside the words of a Verdict (spec #172): its guidance's, or its note's,
|
|
1417
|
+
* in the image store. None for a Verdict that carries none, nor for a reply, whose images went to
|
|
1418
|
+
* its Node alone and are named without a key (#186).
|
|
1419
|
+
*/
|
|
1420
|
+
function imagesOf(given) {
|
|
1421
|
+
let images = [];
|
|
1422
|
+
switch (given.kind) {
|
|
1423
|
+
case "send-back":
|
|
1424
|
+
case "rework":
|
|
1425
|
+
case "approve": images = given.images ?? [];
|
|
1426
|
+
}
|
|
1427
|
+
return images;
|
|
1428
|
+
}
|
|
1232
1429
|
/** A Ticket's title: what it is about, in a line. */
|
|
1233
1430
|
const ticketTitle = z.string().min(1).max(200);
|
|
1234
1431
|
/** How the Owner sees a Ticket's number: T-1, T-2… per Owner. */
|
|
@@ -1344,15 +1541,55 @@ const NODE_TASKS_PATH = "/api/nodes/tasks";
|
|
|
1344
1541
|
function nodeHandOffPath(taskId, handOffId) {
|
|
1345
1542
|
return `${NODE_TASKS_PATH}/${encodeURIComponent(taskId)}/hand-offs/${encodeURIComponent(handOffId)}`;
|
|
1346
1543
|
}
|
|
1347
|
-
/**
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1544
|
+
/** A document's image as a Node fetches it: with an address in the image store to read it at. */
|
|
1545
|
+
const addressedImage = documentImage.extend({ address: imageAddress });
|
|
1546
|
+
/**
|
|
1547
|
+
* A Hand-off as the Node fetches it: its document, each image with an address signed for the Task
|
|
1548
|
+
* that fetched it, valid a while, to read its bytes from the image store at (never with the Node's
|
|
1549
|
+
* credential).
|
|
1550
|
+
*/
|
|
1352
1551
|
const fetchedHandOff = handOff.extend({
|
|
1353
1552
|
id: z.string().min(1),
|
|
1354
|
-
images: z.array(
|
|
1553
|
+
images: z.array(addressedImage).max(IMAGE_LIMITS.images)
|
|
1554
|
+
});
|
|
1555
|
+
/**
|
|
1556
|
+
* Where the Node fetches addresses to read the images of the brief of the Workpiece the Task
|
|
1557
|
+
* `taskId` it holds (or a check of it) works on: GET, answered `fetchedBrief`.
|
|
1558
|
+
*/
|
|
1559
|
+
function nodeBriefPath(taskId) {
|
|
1560
|
+
return `${NODE_TASKS_PATH}/${encodeURIComponent(taskId)}/brief`;
|
|
1561
|
+
}
|
|
1562
|
+
/**
|
|
1563
|
+
* A brief's images as the Node fetches them: each with an address signed for the Task that
|
|
1564
|
+
* fetched it, valid a while, to read its bytes from the image store at (never with the Node's
|
|
1565
|
+
* credential). Its words are in the message starting the Session.
|
|
1566
|
+
*/
|
|
1567
|
+
const fetchedBrief = z.object({ images: z.array(addressedImage).max(IMAGE_LIMITS.images) });
|
|
1568
|
+
/**
|
|
1569
|
+
* Where the Node fetches the annotations of check `checkId`, which failed the attempt the Task
|
|
1570
|
+
* `taskId` it holds reworks (`previousAttempt`), with the images beside them (#184): GET.
|
|
1571
|
+
*/
|
|
1572
|
+
function nodeAnnotationsPath(taskId, checkId) {
|
|
1573
|
+
return `${NODE_TASKS_PATH}/${encodeURIComponent(taskId)}/checks/${encodeURIComponent(checkId)}/annotations`;
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* A failed check's annotations as the Node fetches them: their Markdown, and each image beside them
|
|
1577
|
+
* with an address signed for the Task that fetched it, as a Hand-off's are.
|
|
1578
|
+
*/
|
|
1579
|
+
const fetchedAnnotations = z.object({
|
|
1580
|
+
annotations,
|
|
1581
|
+
images: z.array(addressedImage).max(IMAGE_LIMITS.images)
|
|
1355
1582
|
});
|
|
1583
|
+
/**
|
|
1584
|
+
* Where a Node asks where to put the images a message of its will name (a Hand-off's, a failed
|
|
1585
|
+
* judgement's), before it sends it: POST `nodeImageUploads`, answered `nodeImageUploadAnswer`, or
|
|
1586
|
+
* a `fetchRefusal`. It puts each image at its address itself, then sends the message, which the
|
|
1587
|
+
* Platform takes only once each image it names is in the image store (images.ts).
|
|
1588
|
+
*/
|
|
1589
|
+
const NODE_IMAGES_PATH = "/api/nodes/images";
|
|
1590
|
+
z.object({ images: imagesToUpload });
|
|
1591
|
+
/** Where to put each, in the order asked: null for one the image store keeps already. */
|
|
1592
|
+
const nodeImageUploadAnswer = z.object({ uploads: z.array(imageUpload) });
|
|
1356
1593
|
/** What the Platform answers a fetch it refuses. */
|
|
1357
1594
|
const fetchRefusal = z.object({ error: z.string() });
|
|
1358
1595
|
const messageId = z.string().min(1).max(128);
|
|
@@ -1378,7 +1615,7 @@ const ackMessage = z.object({
|
|
|
1378
1615
|
outcome: ackOutcome,
|
|
1379
1616
|
reason: z.string().max(500).optional()
|
|
1380
1617
|
});
|
|
1381
|
-
/** How long the Platform waits for a Node's
|
|
1618
|
+
/** How long the Platform waits for each part of a Node's answer, by default and at most. */
|
|
1382
1619
|
const NODE_REQUEST_TIMEOUT_MS = {
|
|
1383
1620
|
default: 2e4,
|
|
1384
1621
|
max: 12e4
|
|
@@ -1388,6 +1625,36 @@ const MAX_ANSWER_PARTS = RECORD_LIMITS.items;
|
|
|
1388
1625
|
/** A Task's or a check's id as a Node files its Session under it: never a path. */
|
|
1389
1626
|
const sessionFiledId = filedId("an id");
|
|
1390
1627
|
/**
|
|
1628
|
+
* The most bytes of an image one part of an answer carries: half a MiB of base64, so that a slow
|
|
1629
|
+
* link still hears from the Node well within its heartbeat timeout while an image goes.
|
|
1630
|
+
*/
|
|
1631
|
+
const IMAGE_PART_BYTES = 393216;
|
|
1632
|
+
/** One part of an image's bytes, base64: at most `IMAGE_PART_BYTES` of them. */
|
|
1633
|
+
const imagePartData = z.base64().min(1).max(IMAGE_PART_BYTES / 3 * 4);
|
|
1634
|
+
/**
|
|
1635
|
+
* What images the Platform hands a Node are for: a reply to the Help Request of Task `taskId`,
|
|
1636
|
+
* whose Session the Node keeps waiting for it (#186); or the Owner's message in turn `turn` of chat
|
|
1637
|
+
* `chatId` (#187).
|
|
1638
|
+
*/
|
|
1639
|
+
const handedTo = z.discriminatedUnion("of", [z.object({
|
|
1640
|
+
of: z.literal("reply"),
|
|
1641
|
+
taskId: sessionFiledId
|
|
1642
|
+
}), z.object({
|
|
1643
|
+
of: z.literal("chat"),
|
|
1644
|
+
chatId,
|
|
1645
|
+
turn: chatTurn
|
|
1646
|
+
})]);
|
|
1647
|
+
/** One handing-over of images to a Node, by the id the Platform gives it: never a path. */
|
|
1648
|
+
const handingId = filedId("a handing-over's id");
|
|
1649
|
+
/**
|
|
1650
|
+
* The images a message names that the Platform handed its Node before it (`handed-image`): the
|
|
1651
|
+
* handing-over's id, and each image in the order handed, without its bytes.
|
|
1652
|
+
*/
|
|
1653
|
+
const handedImages = z.object({
|
|
1654
|
+
handing: handingId,
|
|
1655
|
+
images: z.array(imageAbout).min(1).max(IMAGE_LIMITS.images)
|
|
1656
|
+
});
|
|
1657
|
+
/**
|
|
1391
1658
|
* A Session the Node ran, by the ids it files its record under (`sessions/<Task id>/`, a check's
|
|
1392
1659
|
* in `check-<check id>/` there, a chat's in `sessions/chats/<chat id>/`, a Millwright's in
|
|
1393
1660
|
* `sessions/millwright/<its id>/`), never by a path.
|
|
@@ -1411,6 +1678,37 @@ const recordedSession = z.discriminatedUnion("role", [
|
|
|
1411
1678
|
sessionId: millwrightSessionId
|
|
1412
1679
|
})
|
|
1413
1680
|
]);
|
|
1681
|
+
/** Which image of a document a Node keeps it is: 0, 1, 2… */
|
|
1682
|
+
const keptIndex = z.number().int().nonnegative().max(IMAGE_LIMITS.images - 1);
|
|
1683
|
+
/**
|
|
1684
|
+
* An image a Node keeps (ADR-0025), by the ids the Node filed it under, never a path:
|
|
1685
|
+
* - image `index` (0, 1, 2…) of a Help Request's question (#185), filed under its Task's id and its
|
|
1686
|
+
* `help-request` message's id, in its Task's Workspace;
|
|
1687
|
+
* - image `index` of a Help Request's reply (#186), filed under its Task's id and the handing-over
|
|
1688
|
+
* that brought it (`handing`), beside the question in its Task's Workspace;
|
|
1689
|
+
* - an image a Session's record names (#188), by its Session and where it is in the record
|
|
1690
|
+
* (`recordImage`): one a prompt carried, in the Session's Workspace, or one a tool returned, in
|
|
1691
|
+
* the record itself.
|
|
1692
|
+
*/
|
|
1693
|
+
const keptImage = z.discriminatedUnion("of", [
|
|
1694
|
+
z.object({
|
|
1695
|
+
of: z.literal("help-request"),
|
|
1696
|
+
taskId: sessionFiledId,
|
|
1697
|
+
question: filedId("a question's id"),
|
|
1698
|
+
index: keptIndex
|
|
1699
|
+
}),
|
|
1700
|
+
z.object({
|
|
1701
|
+
of: z.literal("help-reply"),
|
|
1702
|
+
taskId: sessionFiledId,
|
|
1703
|
+
handing: handingId,
|
|
1704
|
+
index: keptIndex
|
|
1705
|
+
}),
|
|
1706
|
+
z.object({
|
|
1707
|
+
of: z.literal("session-record"),
|
|
1708
|
+
session: recordedSession,
|
|
1709
|
+
...recordImage.shape
|
|
1710
|
+
})
|
|
1711
|
+
]);
|
|
1414
1712
|
/** What the Platform may ask a Node. */
|
|
1415
1713
|
const nodeAsk = z.discriminatedUnion("about", [
|
|
1416
1714
|
z.object({
|
|
@@ -1425,6 +1723,20 @@ const nodeAsk = z.discriminatedUnion("about", [
|
|
|
1425
1723
|
about: z.literal("own-setup"),
|
|
1426
1724
|
runtime: runtimeId,
|
|
1427
1725
|
of: z.enum(["agent", "millwright"]).default("agent")
|
|
1726
|
+
}),
|
|
1727
|
+
z.object({
|
|
1728
|
+
about: z.literal("image"),
|
|
1729
|
+
image: keptImage
|
|
1730
|
+
}),
|
|
1731
|
+
z.object({
|
|
1732
|
+
about: z.literal("handed-image"),
|
|
1733
|
+
to: handedTo,
|
|
1734
|
+
handing: handingId,
|
|
1735
|
+
index: keptIndex,
|
|
1736
|
+
at: z.number().int().nonnegative().max(IMAGE_LIMITS.bytes - 1),
|
|
1737
|
+
last: z.boolean(),
|
|
1738
|
+
mimeType: imageType,
|
|
1739
|
+
data: imagePartData
|
|
1428
1740
|
})
|
|
1429
1741
|
]);
|
|
1430
1742
|
/** One part of what a Node answers. */
|
|
@@ -1441,6 +1753,12 @@ const nodeAnswer = z.discriminatedUnion("kind", [
|
|
|
1441
1753
|
kind: z.literal("own-setup"),
|
|
1442
1754
|
setup: ownSetup
|
|
1443
1755
|
}),
|
|
1756
|
+
z.object({
|
|
1757
|
+
kind: z.literal("image"),
|
|
1758
|
+
mimeType: imageType,
|
|
1759
|
+
data: imagePartData
|
|
1760
|
+
}),
|
|
1761
|
+
z.object({ kind: z.literal("handed-image") }),
|
|
1444
1762
|
z.object({
|
|
1445
1763
|
kind: z.literal("refused"),
|
|
1446
1764
|
reason: z.string().min(1).max(500)
|
|
@@ -1448,11 +1766,15 @@ const nodeAnswer = z.discriminatedUnion("kind", [
|
|
|
1448
1766
|
z.object({
|
|
1449
1767
|
kind: z.literal("failed"),
|
|
1450
1768
|
reason: z.string().min(1).max(2e3)
|
|
1769
|
+
}),
|
|
1770
|
+
z.object({
|
|
1771
|
+
kind: z.literal("gone"),
|
|
1772
|
+
reason: z.string().min(1).max(500)
|
|
1451
1773
|
})
|
|
1452
1774
|
]);
|
|
1453
1775
|
/**
|
|
1454
|
-
* The Platform asks the Node `ask`, and waits `timeoutMs` for the
|
|
1455
|
-
* needn't answer any more. Never kept, replayed or acknowledged.
|
|
1776
|
+
* The Platform asks the Node `ask`, and waits `timeoutMs` for each part of the answer, after which
|
|
1777
|
+
* the Node needn't answer any more. Never kept, replayed or acknowledged.
|
|
1456
1778
|
*/
|
|
1457
1779
|
const requestMessage = z.object({
|
|
1458
1780
|
kind: z.literal("request"),
|
|
@@ -1462,7 +1784,7 @@ const requestMessage = z.object({
|
|
|
1462
1784
|
});
|
|
1463
1785
|
/**
|
|
1464
1786
|
* Part `part` (0, 1, 2…) of the Node's answer to the request with id `request`; `last` once it is
|
|
1465
|
-
* the whole of it. A `refused` or `
|
|
1787
|
+
* the whole of it. A `refused`, `failed` or `gone` part is always the last. Never kept, replayed or
|
|
1466
1788
|
* acknowledged.
|
|
1467
1789
|
*/
|
|
1468
1790
|
const answerMessage = z.object({
|
|
@@ -1551,7 +1873,13 @@ const helpRequestMessage = z.object({
|
|
|
1551
1873
|
kind: z.literal("help-request"),
|
|
1552
1874
|
...reliableEnvelope,
|
|
1553
1875
|
taskId: taskId$1,
|
|
1554
|
-
question: helpQuestion
|
|
1876
|
+
question: helpQuestion,
|
|
1877
|
+
/**
|
|
1878
|
+
* The images the Agent asked with (#185), in its order, named without their bytes: the Node
|
|
1879
|
+
* keeps copies of them in the Task's Workspace, filed under this message's `id`, which the
|
|
1880
|
+
* Platform asks for each by (`keptImage`).
|
|
1881
|
+
*/
|
|
1882
|
+
images: z.array(imageAbout).max(IMAGE_LIMITS.images).default([])
|
|
1555
1883
|
});
|
|
1556
1884
|
/**
|
|
1557
1885
|
* A Session of the Workstation Task ended (its work, or a check): how, and the tokens it used, or
|
|
@@ -1703,6 +2031,15 @@ const nodeMessage = z.discriminatedUnion("kind", [
|
|
|
1703
2031
|
answerMessage,
|
|
1704
2032
|
ackMessage
|
|
1705
2033
|
]);
|
|
2034
|
+
/**
|
|
2035
|
+
* The images in the image store a Node's message names (a Hand-off's, a failed judgement's), which
|
|
2036
|
+
* its Node puts there before it sends it, and which the Platform finds there before it takes it.
|
|
2037
|
+
*/
|
|
2038
|
+
function imagesNamedBy(message) {
|
|
2039
|
+
if (message.kind === "hand-off") return message.handOff.images;
|
|
2040
|
+
if (message.kind === "judgement" && message.judgement.outcome === "failed") return message.judgement.images;
|
|
2041
|
+
return [];
|
|
2042
|
+
}
|
|
1706
2043
|
/** The answer to `hello`: the Node is online. */
|
|
1707
2044
|
const welcomeMessage = z.object({
|
|
1708
2045
|
kind: z.literal("welcome"),
|
|
@@ -1749,12 +2086,16 @@ const taskStartFields = {
|
|
|
1749
2086
|
skills: sessionBindings.default([])
|
|
1750
2087
|
}),
|
|
1751
2088
|
/**
|
|
1752
|
-
* The Workpiece as it stands: its brief
|
|
1753
|
-
*
|
|
2089
|
+
* The Workpiece as it stands: its brief, with the images it carries beside its words, each named
|
|
2090
|
+
* by its key in the image store, which the Node reads through addresses it fetches for them
|
|
2091
|
+
* (`nodeBriefPath`), and the Hand-offs accepted so far, in Pipeline order, each named by its id
|
|
2092
|
+
* and where it was made; the Node fetches them (`nodeHandOffPath`). (A Session kept in an outbox
|
|
2093
|
+
* from protocol 24 or before gets no images.)
|
|
1754
2094
|
*/
|
|
1755
2095
|
workpiece: z.object({
|
|
1756
2096
|
id: z.string().min(1),
|
|
1757
2097
|
brief,
|
|
2098
|
+
images: documentImages.default([]),
|
|
1758
2099
|
handOffs: z.array(z.object({
|
|
1759
2100
|
id: z.string().min(1),
|
|
1760
2101
|
workstation: named$1,
|
|
@@ -1769,10 +2110,14 @@ const taskStartFields = {
|
|
|
1769
2110
|
*/
|
|
1770
2111
|
repositories: workspaceRepositories.default([])
|
|
1771
2112
|
};
|
|
1772
|
-
/**
|
|
2113
|
+
/**
|
|
2114
|
+
* A question an Agent asked the Owner mid-Task, and the Owner's reply, if they gave one, with how
|
|
2115
|
+
* many images it came with (#186): they went only to the Session it resumed.
|
|
2116
|
+
*/
|
|
1773
2117
|
const helpExchange = z.object({
|
|
1774
2118
|
question: helpQuestion,
|
|
1775
|
-
reply: verdictText.nullable()
|
|
2119
|
+
reply: verdictText.nullable(),
|
|
2120
|
+
replyImages: z.number().int().nonnegative().max(IMAGE_LIMITS.images).default(0)
|
|
1776
2121
|
});
|
|
1777
2122
|
/**
|
|
1778
2123
|
* The attempt a Rework follows at its Workstation (ADR-0003): who made it, how it ended, and why,
|
|
@@ -1788,8 +2133,13 @@ const previousAttempt = z.object({
|
|
|
1788
2133
|
status: z.enum([...UNSUCCESSFUL_STATUSES, "accepted"]),
|
|
1789
2134
|
/** The Hand-off it delivered, if any (fetched like the Workpiece's); it isn't on the Workpiece. */
|
|
1790
2135
|
handOff: z.object({ id: z.string().min(1) }).nullable(),
|
|
1791
|
-
/**
|
|
2136
|
+
/**
|
|
2137
|
+
* The check its Hand-off failed (`rejected`): who judged it, and the annotations; the images
|
|
2138
|
+
* beside them, the Node fetches by its id (`nodeAnnotationsPath`). (A Session kept in an outbox
|
|
2139
|
+
* from protocol 24 or before names no check, and gets none.)
|
|
2140
|
+
*/
|
|
1792
2141
|
check: z.object({
|
|
2142
|
+
id: checkId$1.nullable().default(null),
|
|
1793
2143
|
inspector: named$1,
|
|
1794
2144
|
annotations
|
|
1795
2145
|
}).nullable(),
|
|
@@ -1801,16 +2151,39 @@ const previousAttempt = z.object({
|
|
|
1801
2151
|
/**
|
|
1802
2152
|
* A Verdict the Owner gave on a Ticket of the Workpiece's Pipeline Run (spec story 37), which every
|
|
1803
2153
|
* Agent working on the Workpiece after it is told of: the Ticket (T-n, raised at a Workstation),
|
|
1804
|
-
* and the Verdict with what the Owner wrote into it
|
|
2154
|
+
* and the Verdict with what the Owner wrote into it and the images beside those words, each named by
|
|
2155
|
+
* its key in the image store, which the Node reads through addresses it fetches for them
|
|
2156
|
+
* (`nodeVerdictsPath`).
|
|
1805
2157
|
*/
|
|
1806
2158
|
const workpieceVerdict = z.object({
|
|
1807
2159
|
ticket: z.object({
|
|
1808
2160
|
number: z.number().int().positive(),
|
|
1809
2161
|
workstation: named$1
|
|
1810
2162
|
}),
|
|
1811
|
-
verdict
|
|
2163
|
+
verdict,
|
|
2164
|
+
/**
|
|
2165
|
+
* Whether it routed the Workpiece to the Task this Session works on (#183): the reworking Task
|
|
2166
|
+
* after a Send back or a Rework with guidance, the next station's after an Approve. Its images
|
|
2167
|
+
* come inside that Task's first prompt, besides as files; every Agent after it gets them as files
|
|
2168
|
+
* only. (A Session kept in an outbox from protocol 24 or before is routed by none.)
|
|
2169
|
+
*/
|
|
2170
|
+
routedHere: z.boolean().default(false)
|
|
1812
2171
|
});
|
|
1813
2172
|
/**
|
|
2173
|
+
* Where the Node fetches addresses to read the images of the Owner's Verdicts on the Workpiece the
|
|
2174
|
+
* Task `task` it holds works on (#183), which the message starting its work names by key: GET,
|
|
2175
|
+
* answered `fetchedVerdicts`.
|
|
2176
|
+
*/
|
|
2177
|
+
function nodeVerdictsPath(task) {
|
|
2178
|
+
return `${NODE_TASKS_PATH}/${encodeURIComponent(task)}/verdicts`;
|
|
2179
|
+
}
|
|
2180
|
+
/**
|
|
2181
|
+
* The images of the Owner's latest Verdicts on a Workpiece (the `MAX_WORKPIECE_VERDICTS` a Session
|
|
2182
|
+
* is told of) as the Node fetches them: each with an address signed for the Task that fetched it,
|
|
2183
|
+
* as a brief's are. Their words are in the message starting the Session.
|
|
2184
|
+
*/
|
|
2185
|
+
const fetchedVerdicts = z.object({ images: z.array(addressedImage).max(IMAGE_LIMITS.images * 20) });
|
|
2186
|
+
/**
|
|
1814
2187
|
* Start a Session for a Workstation Task, in a fresh Workspace (ADR-0002): everything the Node
|
|
1815
2188
|
* needs to run it. In its `work` role, `agent` works on the Workpiece at the Task's Workstation and
|
|
1816
2189
|
* hands off: it is told which attempt this is, what went wrong with the attempt before it if it is
|
|
@@ -1832,7 +2205,9 @@ const taskStartMessage = z.discriminatedUnion("role", [z.object({
|
|
|
1832
2205
|
previous: previousAttempt.nullable(),
|
|
1833
2206
|
/**
|
|
1834
2207
|
* The Owner's Verdicts on the Workpiece so far (the latest `MAX_WORKPIECE_VERDICTS`), in the
|
|
1835
|
-
* order given: the guidance, notes and replies they carry reach every Agent after them
|
|
2208
|
+
* order given: the guidance, notes and replies they carry reach every Agent after them, and so
|
|
2209
|
+
* do the images beside a guidance or a note, inside the first prompt of the Task one routed the
|
|
2210
|
+
* Workpiece to (`routedHere`), as files to every other.
|
|
1836
2211
|
*/
|
|
1837
2212
|
verdicts: z.array(workpieceVerdict).max(20),
|
|
1838
2213
|
/**
|
|
@@ -1879,14 +2254,17 @@ const taskCancelMessage = inRole({
|
|
|
1879
2254
|
* Agent binds as the reply was sent (#120), which the Node lays out anew for the resumed segment (a
|
|
1880
2255
|
* copy it lacks installed first). The Node reports the resumed segment's start (`task-started`), or
|
|
1881
2256
|
* the Task's end if the Session can't be resumed. (A reply kept in an outbox from protocol 21 or
|
|
1882
|
-
* before names no Skills: its segment gets those the Session had.)
|
|
2257
|
+
* before names no Skills: its segment gets those the Session had.) A reply with images (#186) names
|
|
2258
|
+
* them (`handed`): the Platform handed them to the Node before (`handed-image`), and the Node writes
|
|
2259
|
+
* them into the Session's Workspace, inside the prompt that resumes it.
|
|
1883
2260
|
*/
|
|
1884
2261
|
const taskResumeMessage = z.object({
|
|
1885
2262
|
kind: z.literal("task-resume"),
|
|
1886
2263
|
...reliableEnvelope,
|
|
1887
2264
|
taskId: taskId$1,
|
|
1888
2265
|
reply: verdictText,
|
|
1889
|
-
skills: sessionBindings.optional()
|
|
2266
|
+
skills: sessionBindings.optional(),
|
|
2267
|
+
handed: handedImages.optional()
|
|
1890
2268
|
});
|
|
1891
2269
|
/**
|
|
1892
2270
|
* Where an Agent's chat Workspace is made as for (its place, spec story 71), for its instructions:
|
|
@@ -1904,12 +2282,12 @@ const chatSayPlace = z.discriminatedUnion("kind", [z.object({
|
|
|
1904
2282
|
to: named$1
|
|
1905
2283
|
})]).nullable();
|
|
1906
2284
|
/**
|
|
1907
|
-
* The Owner said `message` in turn `turn` of a chat with `agent`, its next prompt
|
|
1908
|
-
* opens the chat's Session in a fresh Workspace
|
|
1909
|
-
* Agent's `place`), given the Agent's instructions
|
|
1910
|
-
* no Factory MCP; each later turn resumes that
|
|
1911
|
-
* it started with, whatever `agent` says now.
|
|
1912
|
-
* (`chat-turn-started`) and end (`chat-turn-ended`).
|
|
2285
|
+
* The Owner said `message` in turn `turn` of a chat with `agent`, its next prompt, with the images
|
|
2286
|
+
* it came with, if any (`handed`). The first turn opens the chat's Session in a fresh Workspace
|
|
2287
|
+
* with `repositories` checked out (made as for the Agent's `place`), given the Agent's instructions
|
|
2288
|
+
* and nothing of any Workpiece, on its model, with no Factory MCP; each later turn resumes that
|
|
2289
|
+
* Session in the same Workspace, with the instructions it started with, whatever `agent` says now.
|
|
2290
|
+
* The Node reports the turn's start (`chat-turn-started`) and end (`chat-turn-ended`).
|
|
1913
2291
|
*/
|
|
1914
2292
|
const chatSayMessage = z.object({
|
|
1915
2293
|
kind: z.literal("chat-say"),
|
|
@@ -1928,7 +2306,13 @@ const chatSayMessage = z.object({
|
|
|
1928
2306
|
}),
|
|
1929
2307
|
place: chatSayPlace,
|
|
1930
2308
|
repositories: workspaceRepositories,
|
|
1931
|
-
message: chatMessage
|
|
2309
|
+
message: chatMessage,
|
|
2310
|
+
/**
|
|
2311
|
+
* The images the message came with (#187), which the Platform handed the Node before
|
|
2312
|
+
* (`handed-image`, for this turn): the turn writes them into whatever Workspace it opens or
|
|
2313
|
+
* resumes, and its prompt carries them.
|
|
2314
|
+
*/
|
|
2315
|
+
handed: handedImages.optional()
|
|
1932
2316
|
});
|
|
1933
2317
|
/**
|
|
1934
2318
|
* The Node's Tasks and chats someone is watching now: relay their live activity (`session-events`,
|
|
@@ -2144,6 +2528,8 @@ const named = z.object({
|
|
|
2144
2528
|
/** What every Workstation is besides who staffs it (`workstationSummary`). */
|
|
2145
2529
|
const station = named.extend({
|
|
2146
2530
|
attemptBudget,
|
|
2531
|
+
/** Its time limit in minutes (#174): the default in an event recorded before it could be set. */
|
|
2532
|
+
timeLimitMinutes: timeLimitMinutes.default(TIME_LIMIT.default),
|
|
2147
2533
|
along,
|
|
2148
2534
|
/** Its Workspace policy (#90): the repositories each fresh Workspace there gets checked out. */
|
|
2149
2535
|
repositories: workspaceRepositories,
|
|
@@ -2157,8 +2543,8 @@ const emptyWorkstation = station.extend({
|
|
|
2157
2543
|
/**
|
|
2158
2544
|
* A Workstation: who staffs it, its Operator (an Agent, or the Owner at a Human Workstation, which
|
|
2159
2545
|
* has no Agent), or no one yet (an empty Workstation, which has none either); how many attempts it
|
|
2160
|
-
* gets at a Workpiece in a round (a Human one, one)
|
|
2161
|
-
* policy and its revision.
|
|
2546
|
+
* gets at a Workpiece in a round (a Human one, one) and its time limit (a Human one's unused),
|
|
2547
|
+
* where it stands along its line, its Workspace policy and its revision.
|
|
2162
2548
|
*/
|
|
2163
2549
|
const workstationSummary = z.discriminatedUnion("operator", [
|
|
2164
2550
|
station.extend({
|
|
@@ -2222,7 +2608,7 @@ const agentRestoredEvent = z.object({
|
|
|
2222
2608
|
});
|
|
2223
2609
|
/** A Factory, Workshop or Pipeline as it stands: where, in what holds it, and its revision. */
|
|
2224
2610
|
const placed = named.extend({
|
|
2225
|
-
at: spot,
|
|
2611
|
+
at: spot$1,
|
|
2226
2612
|
revision
|
|
2227
2613
|
});
|
|
2228
2614
|
/**
|
|
@@ -2262,7 +2648,7 @@ const definitionMovedEvent = z.object({
|
|
|
2262
2648
|
kind: z.literal("definition-moved"),
|
|
2263
2649
|
definition: movableDefinition,
|
|
2264
2650
|
id: z.string().min(1),
|
|
2265
|
-
at: spot,
|
|
2651
|
+
at: spot$1,
|
|
2266
2652
|
revision
|
|
2267
2653
|
});
|
|
2268
2654
|
/**
|
|
@@ -2460,7 +2846,9 @@ const checkGateRepositoriesSetEvent = z.object({
|
|
|
2460
2846
|
* The Owner staffed Workstation `workstationId` of Pipeline `pipelineId` (#63): with `agent`
|
|
2461
2847
|
* (`operator` "agent"), with themselves as its Human (`operator` "human", no Agent), or with no one
|
|
2462
2848
|
* (`operator` null, no Agent), freeing whoever staffed it before. `attemptBudget` is how many
|
|
2463
|
-
* attempts it gets now (a Human Workstation one), `
|
|
2849
|
+
* attempts it gets now (a Human Workstation one), `timeLimitMinutes` its time limit now (a Human
|
|
2850
|
+
* Workstation's the default, unused; the default in an event recorded before it could be set),
|
|
2851
|
+
* `revision` its revision now.
|
|
2464
2852
|
*/
|
|
2465
2853
|
const workstationStaffedEvent = z.object({
|
|
2466
2854
|
kind: z.literal("workstation-staffed"),
|
|
@@ -2469,6 +2857,7 @@ const workstationStaffedEvent = z.object({
|
|
|
2469
2857
|
operator: operatorKind.nullable(),
|
|
2470
2858
|
agent: named.nullable(),
|
|
2471
2859
|
attemptBudget,
|
|
2860
|
+
timeLimitMinutes: timeLimitMinutes.default(TIME_LIMIT.default),
|
|
2472
2861
|
revision
|
|
2473
2862
|
});
|
|
2474
2863
|
/**
|
|
@@ -2484,6 +2873,19 @@ const attemptBudgetChangedEvent = z.object({
|
|
|
2484
2873
|
revision
|
|
2485
2874
|
});
|
|
2486
2875
|
/**
|
|
2876
|
+
* The Owner set how long a Task at Agent Workstation `workstationId` of Pipeline `pipelineId` may
|
|
2877
|
+
* be at work in one segment of its Session (#174), in minutes, from its next segment on: a segment
|
|
2878
|
+
* timed already keeps the limit it started with.
|
|
2879
|
+
*/
|
|
2880
|
+
const timeLimitChangedEvent = z.object({
|
|
2881
|
+
kind: z.literal("time-limit-changed"),
|
|
2882
|
+
pipelineId: z.string().min(1),
|
|
2883
|
+
workstationId: z.string().min(1),
|
|
2884
|
+
timeLimitMinutes,
|
|
2885
|
+
/** The Workstation's revision now. */
|
|
2886
|
+
revision
|
|
2887
|
+
});
|
|
2888
|
+
/**
|
|
2487
2889
|
* The Owner set the Workspace policy of Workstation `workstationId` of Pipeline `pipelineId`
|
|
2488
2890
|
* (#90): each fresh Workspace there gets `repositories` checked out, in this order, each in its
|
|
2489
2891
|
* folder, from its next Task on.
|
|
@@ -2508,9 +2910,15 @@ const runDispatchedEvent = z.object({
|
|
|
2508
2910
|
runId,
|
|
2509
2911
|
number: z.number().int().positive(),
|
|
2510
2912
|
pipeline: named,
|
|
2913
|
+
/**
|
|
2914
|
+
* Its Workpiece: the brief, and the images it carries beside its words, each by its key in the
|
|
2915
|
+
* image store, never an address to read it at (they expire; the ledger is kept). (Dispatched with
|
|
2916
|
+
* protocol 24 or before, it has none.)
|
|
2917
|
+
*/
|
|
2511
2918
|
workpiece: z.object({
|
|
2512
2919
|
id: z.string().min(1),
|
|
2513
|
-
brief
|
|
2920
|
+
brief,
|
|
2921
|
+
images: documentImages.default([])
|
|
2514
2922
|
}),
|
|
2515
2923
|
scheduleId: z.string().min(1).nullable().default(null)
|
|
2516
2924
|
});
|
|
@@ -2610,7 +3018,10 @@ const checkStartedEvent = z.object({
|
|
|
2610
3018
|
taskId,
|
|
2611
3019
|
checkId
|
|
2612
3020
|
});
|
|
2613
|
-
/**
|
|
3021
|
+
/**
|
|
3022
|
+
* The Inspector judged the Hand-off: passed, or failed with annotations and the images beside them,
|
|
3023
|
+
* each by its key in the image store (none in an event from before #184).
|
|
3024
|
+
*/
|
|
2614
3025
|
const checkJudgedEvent = z.object({
|
|
2615
3026
|
kind: z.literal("check-judged"),
|
|
2616
3027
|
runId,
|
|
@@ -2994,6 +3405,7 @@ const ledgerEvent = z.discriminatedUnion("kind", [
|
|
|
2994
3405
|
checkGateRemovedEvent,
|
|
2995
3406
|
checkGateRepositoriesSetEvent,
|
|
2996
3407
|
attemptBudgetChangedEvent,
|
|
3408
|
+
timeLimitChangedEvent,
|
|
2997
3409
|
workstationRepositoriesSetEvent,
|
|
2998
3410
|
scheduleAddedEvent,
|
|
2999
3411
|
scheduleEditedEvent,
|
|
@@ -3063,6 +3475,373 @@ const PASSWORD_LIMITS = {
|
|
|
3063
3475
|
max: 128
|
|
3064
3476
|
};
|
|
3065
3477
|
`${PASSWORD_LIMITS.long}${PASSWORD_LIMITS.short}`;
|
|
3478
|
+
/**
|
|
3479
|
+
* A Showroom's title as the Owner writes it (spec story 31): words of their own, trimmed, never
|
|
3480
|
+
* filled in from their name (ADR-0021 rule 3). Empty until they write one; opening needs one.
|
|
3481
|
+
*/
|
|
3482
|
+
const showroomTitle = z.string().trim().max({
|
|
3483
|
+
/** Characters of its title. */
|
|
3484
|
+
title: 80,
|
|
3485
|
+
/**
|
|
3486
|
+
* How many Visitors watch it at once (spec story 53), so that one link going viral can't slow
|
|
3487
|
+
* every Owner's factory: the next is told it's full (`showroomMessage`). A browser signed in as
|
|
3488
|
+
* its own Owner isn't counted, the hidden frame that takes its card's picture among them.
|
|
3489
|
+
*/
|
|
3490
|
+
visitors: 200,
|
|
3491
|
+
/**
|
|
3492
|
+
* The least time between two views a Showroom's feed builds and sends (spec story 54): at most
|
|
3493
|
+
* twice a second, however busy the factory.
|
|
3494
|
+
*/
|
|
3495
|
+
viewIntervalMs: 500,
|
|
3496
|
+
/**
|
|
3497
|
+
* How long after its Run ended a Workpiece is still in the view, done or scrapped: long enough
|
|
3498
|
+
* for the World to play it leaving the line, after the courier robot carried back the Verdict
|
|
3499
|
+
* that ended it (spec story 5).
|
|
3500
|
+
*/
|
|
3501
|
+
justEndedMs: 6e4
|
|
3502
|
+
}.title);
|
|
3503
|
+
/**
|
|
3504
|
+
* The operating systems a Visitor may read on a Node's tower (spec story 49), by family only: the
|
|
3505
|
+
* name a Node gives for its system, its release and its machine's architecture stay with the Owner
|
|
3506
|
+
* (ADR-0021 rule 3).
|
|
3507
|
+
*/
|
|
3508
|
+
const SHOWROOM_SYSTEMS = [
|
|
3509
|
+
"macos",
|
|
3510
|
+
"linux",
|
|
3511
|
+
"windows",
|
|
3512
|
+
"other"
|
|
3513
|
+
];
|
|
3514
|
+
/** A place on the island or in what holds it, as the World draws it (ADR-0015). */
|
|
3515
|
+
const spot = z.strictObject({
|
|
3516
|
+
x: z.number(),
|
|
3517
|
+
z: z.number()
|
|
3518
|
+
});
|
|
3519
|
+
/** A thing by its id alone. */
|
|
3520
|
+
const ref = z.strictObject({ id: z.string() });
|
|
3521
|
+
/**
|
|
3522
|
+
* A Workstation on a chosen line.
|
|
3523
|
+
* - What: its id, its name, how far along its line it stands, and who staffs it: an Agent (by id),
|
|
3524
|
+
* the Owner (a Human), or no one yet.
|
|
3525
|
+
* - From: `workstation` (id, name, along, operator, agent_id).
|
|
3526
|
+
* - Why: the World draws it, labelled with its name, and its Operator's figure (rule 1). Never its
|
|
3527
|
+
* repositories (rule 2) nor its attempt budget, which nothing draws.
|
|
3528
|
+
*/
|
|
3529
|
+
const showroomWorkstation = z.union([
|
|
3530
|
+
z.strictObject({
|
|
3531
|
+
id: z.string(),
|
|
3532
|
+
name: z.string(),
|
|
3533
|
+
along: z.number(),
|
|
3534
|
+
operator: z.literal("agent"),
|
|
3535
|
+
agent: ref
|
|
3536
|
+
}),
|
|
3537
|
+
z.strictObject({
|
|
3538
|
+
id: z.string(),
|
|
3539
|
+
name: z.string(),
|
|
3540
|
+
along: z.number(),
|
|
3541
|
+
operator: z.literal("human"),
|
|
3542
|
+
agent: z.null()
|
|
3543
|
+
}),
|
|
3544
|
+
z.strictObject({
|
|
3545
|
+
id: z.string(),
|
|
3546
|
+
name: z.string(),
|
|
3547
|
+
along: z.number(),
|
|
3548
|
+
operator: z.null(),
|
|
3549
|
+
agent: z.null()
|
|
3550
|
+
})
|
|
3551
|
+
]);
|
|
3552
|
+
/**
|
|
3553
|
+
* A Belt on a chosen line.
|
|
3554
|
+
* - What: its id, the Workstations it joins, whether a Check gate stands on it, and its
|
|
3555
|
+
* Inspector's id.
|
|
3556
|
+
* - From: `belt` (id, from_workstation_id, to_workstation_id, gated, inspector_id).
|
|
3557
|
+
* - Why: the World draws the Belt, its gate and the Inspector's figure there (rule 1). Never its
|
|
3558
|
+
* criteria or repositories (rule 2): whether a gate stands is `gated`, never read off criteria.
|
|
3559
|
+
*/
|
|
3560
|
+
const showroomBelt = z.strictObject({
|
|
3561
|
+
id: z.string(),
|
|
3562
|
+
fromWorkstationId: z.string(),
|
|
3563
|
+
toWorkstationId: z.string(),
|
|
3564
|
+
gated: z.boolean(),
|
|
3565
|
+
inspector: ref.nullable()
|
|
3566
|
+
});
|
|
3567
|
+
/**
|
|
3568
|
+
* One of a Pipeline's Intakes, in the order they were made: its Dispatch, or a Schedule standing
|
|
3569
|
+
* there, paused or not.
|
|
3570
|
+
* - What: its kind, and a Schedule's whether it is paused.
|
|
3571
|
+
* - From: `intake` (kind) and `schedule` (paused), those not removed (`retirement_id`).
|
|
3572
|
+
* - Why: a scheduled Intake's hopper is an hourglass, its sand running while a Schedule on a line
|
|
3573
|
+
* not paused runs (rule 1). Never a Schedule's cadence, timezone, brief or overlap (rule 2), nor
|
|
3574
|
+
* an Intake's id, a handle to release anything with (rule 4).
|
|
3575
|
+
*/
|
|
3576
|
+
const showroomIntake = z.discriminatedUnion("kind", [z.strictObject({ kind: z.literal("dispatch") }), z.strictObject({
|
|
3577
|
+
kind: z.literal("schedule"),
|
|
3578
|
+
paused: z.boolean()
|
|
3579
|
+
})]);
|
|
3580
|
+
/**
|
|
3581
|
+
* A Pipeline in a chosen Workshop.
|
|
3582
|
+
* - What: its id, its name, where its Intake stands, whether the Owner paused it, its Intakes,
|
|
3583
|
+
* its Workstations and Belts.
|
|
3584
|
+
* - From: `pipeline` (id, name, at_x, at_z, paused).
|
|
3585
|
+
* - Why: the World draws the line where it stands, flagged while paused, and the breadcrumb names
|
|
3586
|
+
* it (rule 1).
|
|
3587
|
+
*/
|
|
3588
|
+
const showroomPipeline = z.strictObject({
|
|
3589
|
+
id: z.string(),
|
|
3590
|
+
name: z.string(),
|
|
3591
|
+
at: spot,
|
|
3592
|
+
paused: z.boolean(),
|
|
3593
|
+
intakes: z.array(showroomIntake),
|
|
3594
|
+
workstations: z.array(showroomWorkstation),
|
|
3595
|
+
belts: z.array(showroomBelt)
|
|
3596
|
+
});
|
|
3597
|
+
/**
|
|
3598
|
+
* A Workshop in a chosen Factory.
|
|
3599
|
+
* - What: its id, its name, where it stands in its Factory, its Pipelines.
|
|
3600
|
+
* - From: `workshop` (id, name, at_x, at_z).
|
|
3601
|
+
* - Why: the World draws it with its plate (rule 1).
|
|
3602
|
+
*/
|
|
3603
|
+
const showroomWorkshop = z.strictObject({
|
|
3604
|
+
id: z.string(),
|
|
3605
|
+
name: z.string(),
|
|
3606
|
+
at: spot,
|
|
3607
|
+
pipelines: z.array(showroomPipeline)
|
|
3608
|
+
});
|
|
3609
|
+
/**
|
|
3610
|
+
* A chosen Factory, oldest first.
|
|
3611
|
+
* - What: its id, its name, where it stands on the island, its Workshops.
|
|
3612
|
+
* - From: `factory` (id, name, at_x, at_z), only those the Owner chose (`showroom.factory_ids`)
|
|
3613
|
+
* and still standing.
|
|
3614
|
+
* - Why: the World draws it with its sign (rule 1); no other Factory exists for a Visitor (rule 5).
|
|
3615
|
+
*/
|
|
3616
|
+
const showroomFactory = z.strictObject({
|
|
3617
|
+
id: z.string(),
|
|
3618
|
+
name: z.string(),
|
|
3619
|
+
at: spot,
|
|
3620
|
+
workshops: z.array(showroomWorkshop)
|
|
3621
|
+
});
|
|
3622
|
+
/**
|
|
3623
|
+
* An Agent with a place in a chosen Factory, in the order they were hired.
|
|
3624
|
+
* - What: its id, its name, its Runtime and its Node's id.
|
|
3625
|
+
* - From: `agent` (id, name, runtime, node_id), only the Agents staffing a Workstation or
|
|
3626
|
+
* inspecting a Belt on a chosen line.
|
|
3627
|
+
* - Why: the World draws its figure in its Runtime's silhouette, and hover words name it (rule 1);
|
|
3628
|
+
* its Node's id says which tower's status puts it to sleep. Never its instructions, model or
|
|
3629
|
+
* Skills (rule 2); no Agent without a place (rule 5).
|
|
3630
|
+
*/
|
|
3631
|
+
const showroomAgent = z.strictObject({
|
|
3632
|
+
id: z.string(),
|
|
3633
|
+
name: z.string(),
|
|
3634
|
+
runtime: z.enum(RUNTIME_IDS),
|
|
3635
|
+
node: ref
|
|
3636
|
+
});
|
|
3637
|
+
/**
|
|
3638
|
+
* One of the Owner's Nodes that isn't revoked, in the order they joined.
|
|
3639
|
+
* - What: its id, whether it is online, and its system's family (`SHOWROOM_SYSTEMS`).
|
|
3640
|
+
* - From: `node` (id, status, os's platform), those with no revoked_at.
|
|
3641
|
+
* - Why: the World draws a tower for it in the home corner, its beacon lit while it is online, and
|
|
3642
|
+
* an Agent of an offline Node asleep (rule 1); its board shows its system in place of its name.
|
|
3643
|
+
* Never its name, its system's release or architecture, nor its Runtimes or their versions
|
|
3644
|
+
* (rule 3).
|
|
3645
|
+
*/
|
|
3646
|
+
const showroomNode = z.strictObject({
|
|
3647
|
+
id: z.string(),
|
|
3648
|
+
status: z.enum(["online", "offline"]),
|
|
3649
|
+
system: z.enum(SHOWROOM_SYSTEMS)
|
|
3650
|
+
});
|
|
3651
|
+
/**
|
|
3652
|
+
* The island's home corner.
|
|
3653
|
+
* - What: where the Desk and the Node towers beside it stand.
|
|
3654
|
+
* - From: `island` (home_x, home_z), or where every new island has it.
|
|
3655
|
+
* - Why: the World draws the Desk and the towers there (rule 1); it is structure, read by no one
|
|
3656
|
+
* opening anything.
|
|
3657
|
+
*/
|
|
3658
|
+
const showroomIsland = z.strictObject({ home: spot });
|
|
3659
|
+
/**
|
|
3660
|
+
* A Workstation Task of a Run in `runs`, in the order they were queued.
|
|
3661
|
+
* - What: its id, its status, when it was queued, its Agent's id (none at a Human Workstation),
|
|
3662
|
+
* its Workstation's id, and its Hand-off's checks' statuses in order (a check's status holds its
|
|
3663
|
+
* outcome: passed, failed, or no judgement).
|
|
3664
|
+
* - From: `workstation_task` (id, status, queued_at, agent_id, workstation_id) and `check`
|
|
3665
|
+
* (status).
|
|
3666
|
+
* - Why: where its Workpiece is, how it looks (warm once a Task was accepted, the "ok" mark once
|
|
3667
|
+
* a check passed or an Audit approved it) and what lights a Workstation or a Check gate are
|
|
3668
|
+
* drawn from them, and when it was queued lines Workpieces up at a place (rule 1). Never its
|
|
3669
|
+
* brief, Hand-off, failure, attempt or cause, nor a check's criteria, annotations or reason
|
|
3670
|
+
* (rule 2).
|
|
3671
|
+
*/
|
|
3672
|
+
const showroomTask = z.strictObject({
|
|
3673
|
+
id: z.string(),
|
|
3674
|
+
status: z.enum(WORKSTATION_TASK_STATUSES),
|
|
3675
|
+
queuedAt: z.string(),
|
|
3676
|
+
agent: ref.nullable(),
|
|
3677
|
+
workstation: ref,
|
|
3678
|
+
checks: z.array(z.strictObject({ status: z.enum(CHECK_STATUSES) }))
|
|
3679
|
+
});
|
|
3680
|
+
/**
|
|
3681
|
+
* Where a Workpiece holds on its paused line (the protocol's `workpieceHold`), drawn there: at its
|
|
3682
|
+
* Intake; after Task `taskId`'s Hand-off, its check passed or its attempt; or, with a Verdict to
|
|
3683
|
+
* apply, at the Workstation of the fresh round it sends the Workpiece to, or past Task `taskId`'s.
|
|
3684
|
+
* Never the Ticket whose Verdict it is.
|
|
3685
|
+
*/
|
|
3686
|
+
const showroomHold = z.union([
|
|
3687
|
+
z.strictObject({ at: z.literal("intake") }),
|
|
3688
|
+
z.strictObject({
|
|
3689
|
+
at: z.enum([
|
|
3690
|
+
"hand-off",
|
|
3691
|
+
"check-passed",
|
|
3692
|
+
"rework"
|
|
3693
|
+
]),
|
|
3694
|
+
taskId: z.string()
|
|
3695
|
+
}),
|
|
3696
|
+
z.strictObject({
|
|
3697
|
+
at: z.literal("verdict"),
|
|
3698
|
+
taskId: z.string(),
|
|
3699
|
+
workstationId: z.string().nullable()
|
|
3700
|
+
})
|
|
3701
|
+
]);
|
|
3702
|
+
/**
|
|
3703
|
+
* A Pipeline Run on a chosen line, in progress or just ended (done or scrapped within
|
|
3704
|
+
* `SHOWROOM_LIMITS.justEndedMs`), in the order they were dispatched: Workpieces that came at the
|
|
3705
|
+
* same moment line up in that order.
|
|
3706
|
+
* - What: its id, its status, its Pipeline's id, when it was dispatched, where its Workpiece holds
|
|
3707
|
+
* on its paused line (null while it doesn't, `showroomHold`), and its Tasks (`showroomTask`).
|
|
3708
|
+
* - From: `pipeline_run` (id, status, pipeline_id, dispatched_at, held; completed_at and
|
|
3709
|
+
* scrapped_at, for how long ago it ended).
|
|
3710
|
+
* - Why: its Workpiece rides the line, waits at Check gates, goes back for Rework and leaves it,
|
|
3711
|
+
* done into the bin or as Scrap (rule 1). Never its Workpiece's brief, its failure or its
|
|
3712
|
+
* number (rule 2); a Run that failed leaves the floor at once, as on the Owner's.
|
|
3713
|
+
*/
|
|
3714
|
+
const showroomRun = z.strictObject({
|
|
3715
|
+
id: z.string(),
|
|
3716
|
+
status: z.enum([
|
|
3717
|
+
"in-progress",
|
|
3718
|
+
"completed",
|
|
3719
|
+
"scrapped"
|
|
3720
|
+
]),
|
|
3721
|
+
pipeline: ref,
|
|
3722
|
+
dispatchedAt: z.string(),
|
|
3723
|
+
held: showroomHold.nullable(),
|
|
3724
|
+
tasks: z.array(showroomTask)
|
|
3725
|
+
});
|
|
3726
|
+
/**
|
|
3727
|
+
* An open Ticket raised in a chosen Factory, oldest first.
|
|
3728
|
+
* - What: its id, whether the courier robot carries it to the Desk (an Audit), its Task's and Run's
|
|
3729
|
+
* ids, and where it was raised: its Pipeline and Workstation.
|
|
3730
|
+
* - From: `ticket` (id, category, task_id, run_id, workstation_id; those open) and its Run's
|
|
3731
|
+
* `pipeline_id`.
|
|
3732
|
+
* - Why: it flies to the Desk from where it was raised, or the courier robot fetches it, and stacks
|
|
3733
|
+
* there, while its Workpiece waits for the Owner (rule 1). Never its number, title, reason,
|
|
3734
|
+
* question or Verdict, nor which kind it is beyond whether it is carried (rule 2); none raised
|
|
3735
|
+
* in a Factory not chosen (rule 5).
|
|
3736
|
+
*/
|
|
3737
|
+
const showroomTicket = z.strictObject({
|
|
3738
|
+
id: z.string(),
|
|
3739
|
+
carried: z.boolean(),
|
|
3740
|
+
taskId: z.string(),
|
|
3741
|
+
run: ref,
|
|
3742
|
+
from: z.strictObject({
|
|
3743
|
+
pipeline: ref,
|
|
3744
|
+
workstation: ref
|
|
3745
|
+
})
|
|
3746
|
+
});
|
|
3747
|
+
/**
|
|
3748
|
+
* A moment the World marks once, as it arrives live: a Task just accepted (it plays just finished
|
|
3749
|
+
* at its Workstation), a check just judged (its gate plays the stamp of its outcome), or a Verdict
|
|
3750
|
+
* just given on an Audit the courier robot carried (the robot carries the paper back, inked with
|
|
3751
|
+
* where it sends the Workpiece: on, back for Rework, or to its end).
|
|
3752
|
+
* - What: its kind, and the Task's or the Ticket's id; a check's outcome; a Verdict's ink
|
|
3753
|
+
* (`VERDICT_INKS`).
|
|
3754
|
+
* - From: the Owner's ledger since the view before (`task-accepted`, `check-judged`,
|
|
3755
|
+
* `ticket-ruled`: their kind, Task, Ticket, outcome and Verdict's kind alone), of the Tasks of
|
|
3756
|
+
* `runs` and the carried Audits on their lines.
|
|
3757
|
+
* - Why: each plays once on the floor (rule 1). Never a check's annotations, nor what a Verdict
|
|
3758
|
+
* says, its kind beyond where it sends the Workpiece, or who gave it (rules 2 and 3).
|
|
3759
|
+
*/
|
|
3760
|
+
const showroomMoment = z.discriminatedUnion("kind", [
|
|
3761
|
+
z.strictObject({
|
|
3762
|
+
kind: z.literal("task-accepted"),
|
|
3763
|
+
taskId: z.string()
|
|
3764
|
+
}),
|
|
3765
|
+
z.strictObject({
|
|
3766
|
+
kind: z.literal("check-judged"),
|
|
3767
|
+
taskId: z.string(),
|
|
3768
|
+
outcome: z.enum(["passed", "failed"])
|
|
3769
|
+
}),
|
|
3770
|
+
z.strictObject({
|
|
3771
|
+
kind: z.literal("verdict"),
|
|
3772
|
+
ticketId: z.string(),
|
|
3773
|
+
ink: z.enum(VERDICT_INK_KINDS)
|
|
3774
|
+
})
|
|
3775
|
+
]);
|
|
3776
|
+
/**
|
|
3777
|
+
* What an Agent at work on a Task under way is doing, by the Task's id (a check's Inspector's,
|
|
3778
|
+
* while its Hand-off is checked).
|
|
3779
|
+
* - What: thinking or working; an idle one isn't listed.
|
|
3780
|
+
* - From: the pulses Nodes send the Platform (live activity, in memory), for the Tasks in `runs`.
|
|
3781
|
+
* - Why: the light of thinking and working is drawn (rule 1). Never the tool it uses, nor what it
|
|
3782
|
+
* says (rule 2); a chat's pulse never, since a chat is no work of the line (ADR-0021).
|
|
3783
|
+
*/
|
|
3784
|
+
const showroomActivity = z.strictObject({ state: z.enum(["thinking", "working"]) });
|
|
3785
|
+
/**
|
|
3786
|
+
* The caption's figures (spec #146, "The caption's figures"), counting only the chosen Factories'
|
|
3787
|
+
* in-service lines, as the Owner signed the caption off in the gallery (#159).
|
|
3788
|
+
* Whether the factory runs now, and how many Workstations and Check gates are at work, are not
|
|
3789
|
+
* among them: the page reads both off what its World lights from the view (the World's
|
|
3790
|
+
* `atWork`), the one rule for the caption, the World and the card.
|
|
3791
|
+
* - What: how many Workpieces finished, and how many Workstation Tasks were queued as Rework, in
|
|
3792
|
+
* the last day (`SHOWROOM_FIGURES_SPAN_MS`); and when the World last lit work, as the records
|
|
3793
|
+
* show it: at a time, or never.
|
|
3794
|
+
* - From: `workstation_task` (cause, queued_at, started_at, usage_recorded_at), `check`
|
|
3795
|
+
* (started_at, ended_at), `hand_off` (created_at), `ticket` (raised_at, ruled_at,
|
|
3796
|
+
* withdrawn_at) and `pipeline_run` (status, completed_at), counted, never listed
|
|
3797
|
+
* (`packages/platform/src/showroom/figures.ts`).
|
|
3798
|
+
* - Why: they tell how much the factory drawn on the floor works, so that a dark island reads as
|
|
3799
|
+
* resting, not broken (rule 1: the lights of work, counted). Never which Workpiece, what it was
|
|
3800
|
+
* about or why a Rework was needed (rule 2); nothing of an unchosen Factory (rule 5).
|
|
3801
|
+
*/
|
|
3802
|
+
const showroomFigures = z.strictObject({
|
|
3803
|
+
finished: z.number().int().nonnegative(),
|
|
3804
|
+
rework: z.number().int().nonnegative(),
|
|
3805
|
+
lastAtWork: z.discriminatedUnion("kind", [z.strictObject({
|
|
3806
|
+
kind: z.literal("ago"),
|
|
3807
|
+
at: z.iso.datetime()
|
|
3808
|
+
}), z.strictObject({ kind: z.literal("never") })])
|
|
3809
|
+
});
|
|
3810
|
+
/**
|
|
3811
|
+
* What a Visitor receives of a Showroom (ADR-0021): the whitelist. Each item says what it is,
|
|
3812
|
+
* where the Platform takes it from and why the rules allow it.
|
|
3813
|
+
*/
|
|
3814
|
+
const showroomView = z.strictObject({
|
|
3815
|
+
/**
|
|
3816
|
+
* The Showroom's title. From `showroom.title`. Why: the Owner wrote it for Visitors to read, in
|
|
3817
|
+
* words of their own (rule 3: never filled in from their name).
|
|
3818
|
+
*/
|
|
3819
|
+
title: showroomTitle,
|
|
3820
|
+
/** The caption's figures (`showroomFigures`). */
|
|
3821
|
+
figures: showroomFigures,
|
|
3822
|
+
factories: z.array(showroomFactory),
|
|
3823
|
+
agents: z.array(showroomAgent),
|
|
3824
|
+
nodes: z.array(showroomNode),
|
|
3825
|
+
island: showroomIsland,
|
|
3826
|
+
runs: z.array(showroomRun),
|
|
3827
|
+
tickets: z.array(showroomTicket),
|
|
3828
|
+
activity: z.record(z.string(), showroomActivity),
|
|
3829
|
+
/** The moments since the view before, oldest first (`showroomMoment`). */
|
|
3830
|
+
moments: z.array(showroomMoment)
|
|
3831
|
+
});
|
|
3832
|
+
z.discriminatedUnion("kind", [
|
|
3833
|
+
z.strictObject({
|
|
3834
|
+
kind: z.literal("view"),
|
|
3835
|
+
live: z.boolean(),
|
|
3836
|
+
view: showroomView
|
|
3837
|
+
}),
|
|
3838
|
+
z.strictObject({ kind: z.literal("closed") }),
|
|
3839
|
+
z.strictObject({
|
|
3840
|
+
kind: z.literal("full"),
|
|
3841
|
+
title: z.string(),
|
|
3842
|
+
figures: showroomFigures.nullable()
|
|
3843
|
+
})
|
|
3844
|
+
]);
|
|
3066
3845
|
[
|
|
3067
3846
|
"password",
|
|
3068
3847
|
"github",
|
|
@@ -3086,18 +3865,18 @@ function jsonIn(text) {
|
|
|
3086
3865
|
}
|
|
3087
3866
|
}
|
|
3088
3867
|
/** A file being written, under the name it gets once it is whole. */
|
|
3089
|
-
const PARTIAL = ".tmp";
|
|
3868
|
+
const PARTIAL$1 = ".tmp";
|
|
3090
3869
|
/**
|
|
3091
|
-
* Writes `text` to `path`, readable by this user only, in one step: aside first, then
|
|
3092
|
-
* it, so a crash leaves the whole file (old or new) and never part of one.
|
|
3870
|
+
* Writes `text` (or bytes) to `path`, readable by this user only, in one step: aside first, then
|
|
3871
|
+
* renamed over it, so a crash leaves the whole file (old or new) and never part of one.
|
|
3093
3872
|
*/
|
|
3094
3873
|
async function writeAtomically(path, text) {
|
|
3095
|
-
await writeFile(`${path}${PARTIAL}`, text, { mode: 384 });
|
|
3096
|
-
await rename(`${path}${PARTIAL}`, path);
|
|
3874
|
+
await writeFile(`${path}${PARTIAL$1}`, text, { mode: 384 });
|
|
3875
|
+
await rename(`${path}${PARTIAL$1}`, path);
|
|
3097
3876
|
}
|
|
3098
3877
|
/** Whether `file` was left half-written by a crash during `writeAtomically`. */
|
|
3099
3878
|
function isPartial(file) {
|
|
3100
|
-
return file.endsWith(PARTIAL);
|
|
3879
|
+
return file.endsWith(PARTIAL$1);
|
|
3101
3880
|
}
|
|
3102
3881
|
/** Items can hold anything; their files are named by a hash. */
|
|
3103
3882
|
function fileOf(item) {
|
|
@@ -3130,10 +3909,12 @@ async function openFileSet(dir) {
|
|
|
3130
3909
|
/**
|
|
3131
3910
|
* Where a Node keeps its own files: its credential (`node.json`) and its outbox. The
|
|
3132
3911
|
* `MOONMILL_NODE_HOME` environment variable moves it, for example to run two Nodes on one
|
|
3133
|
-
* machine
|
|
3912
|
+
* machine; a relative one is taken from the folder the daemon starts in, since the paths an
|
|
3913
|
+
* Agent's instructions give under it must be absolute.
|
|
3134
3914
|
*/
|
|
3135
3915
|
function defaultStateDir(env = process.env) {
|
|
3136
|
-
|
|
3916
|
+
const home = env["MOONMILL_NODE_HOME"];
|
|
3917
|
+
return home === void 0 ? join(homedir(), ".moonmill", "node") : resolve(home);
|
|
3137
3918
|
}
|
|
3138
3919
|
/** What a Node got when it joined: who it is, where its Platform is, and how to prove it. */
|
|
3139
3920
|
const nodeCredential = z.object({
|
|
@@ -3587,6 +4368,8 @@ var UnreadableMessageError = class extends Error {
|
|
|
3587
4368
|
name = "UnreadableMessageError";
|
|
3588
4369
|
};
|
|
3589
4370
|
const SUFFIX = ".json";
|
|
4371
|
+
/** The folder of the outbox the images its messages name are kept in. */
|
|
4372
|
+
const IMAGES = "images";
|
|
3590
4373
|
/** The file the message at `place` in line is kept in: their names keep them in line. */
|
|
3591
4374
|
function fileInLine(place) {
|
|
3592
4375
|
return `${String(place).padStart(12, "0")}${SUFFIX}`;
|
|
@@ -3598,6 +4381,7 @@ function placeOf(file) {
|
|
|
3598
4381
|
/** The kind a message this daemon can't read says it is, if it says. */
|
|
3599
4382
|
const sayingItsKind = z.object({ kind: z.string() });
|
|
3600
4383
|
async function openOutbox(dir) {
|
|
4384
|
+
const imagesDir = join(dir, IMAGES);
|
|
3601
4385
|
await mkdir(dir, {
|
|
3602
4386
|
recursive: true,
|
|
3603
4387
|
mode: 448
|
|
@@ -3606,6 +4390,7 @@ async function openOutbox(dir) {
|
|
|
3606
4390
|
const discarded = [];
|
|
3607
4391
|
let next = 0;
|
|
3608
4392
|
for (const file of (await readdir(dir)).toSorted()) {
|
|
4393
|
+
if (file === IMAGES) continue;
|
|
3609
4394
|
if (!file.endsWith(SUFFIX)) {
|
|
3610
4395
|
await rm(join(dir, file), { force: true });
|
|
3611
4396
|
continue;
|
|
@@ -3629,26 +4414,56 @@ async function openOutbox(dir) {
|
|
|
3629
4414
|
});
|
|
3630
4415
|
next = Math.max(next, placeOf(file) + 1);
|
|
3631
4416
|
}
|
|
4417
|
+
/** How many messages on their way in name each image, by its key. */
|
|
4418
|
+
const adding = /* @__PURE__ */ new Map();
|
|
4419
|
+
/** The keys of the images a kept message names, or one on its way in. */
|
|
4420
|
+
const named = () => /* @__PURE__ */ new Set([...adding.keys(), ...[...kept.values()].flatMap(({ message }) => imagesNamedBy(message).map(({ key }) => key))]);
|
|
4421
|
+
/** Deletes every image no kept message names: one acknowledged, discarded or half-written. */
|
|
4422
|
+
const sweepImages = async () => {
|
|
4423
|
+
const needed = named();
|
|
4424
|
+
for (const file of await readdir(imagesDir).catch(() => [])) if (!needed.has(file)) await rm(join(imagesDir, file), { force: true });
|
|
4425
|
+
};
|
|
4426
|
+
await sweepImages();
|
|
3632
4427
|
return {
|
|
3633
4428
|
discarded,
|
|
3634
|
-
async add(message) {
|
|
4429
|
+
async add(message, images = []) {
|
|
3635
4430
|
const readable = nodeMessage.safeParse(message);
|
|
3636
4431
|
if (!readable.success) throw new UnreadableMessageError(`Not a message the Platform can read: ${z.prettifyError(readable.error)}`);
|
|
3637
|
-
const
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
4432
|
+
const given = new Map(images.map((image) => [image.key, image.bytes]));
|
|
4433
|
+
const keys = imagesNamedBy(message).map(({ key }) => key);
|
|
4434
|
+
for (const key of keys) if (!given.has(key)) throw new UnreadableMessageError(`The image ${key} it names isn't given with it`);
|
|
4435
|
+
for (const key of keys) adding.set(key, (adding.get(key) ?? 0) + 1);
|
|
4436
|
+
try {
|
|
4437
|
+
if (keys.length > 0) await mkdir(imagesDir, {
|
|
4438
|
+
recursive: true,
|
|
4439
|
+
mode: 448
|
|
4440
|
+
});
|
|
4441
|
+
for (const key of keys) await writeAtomically(join(imagesDir, imageKey.parse(key)), given.get(key) ?? "");
|
|
4442
|
+
const file = fileInLine(next++);
|
|
4443
|
+
await writeAtomically(join(dir, file), JSON.stringify(message));
|
|
4444
|
+
kept.set(message.id, {
|
|
4445
|
+
file,
|
|
4446
|
+
message
|
|
4447
|
+
});
|
|
4448
|
+
} finally {
|
|
4449
|
+
for (const key of keys) {
|
|
4450
|
+
const count = (adding.get(key) ?? 1) - 1;
|
|
4451
|
+
if (count > 0) adding.set(key, count);
|
|
4452
|
+
else adding.delete(key);
|
|
4453
|
+
}
|
|
4454
|
+
}
|
|
3643
4455
|
},
|
|
3644
4456
|
async remove(id) {
|
|
3645
4457
|
const entry = kept.get(id);
|
|
3646
4458
|
if (!entry) return null;
|
|
3647
4459
|
kept.delete(id);
|
|
3648
4460
|
await rm(join(dir, entry.file), { force: true });
|
|
4461
|
+
const needed = named();
|
|
4462
|
+
for (const { key } of imagesNamedBy(entry.message)) if (!needed.has(key)) await rm(join(imagesDir, key), { force: true });
|
|
3649
4463
|
return entry.message;
|
|
3650
4464
|
},
|
|
3651
|
-
pending: () => [...kept.values()].map((entry) => entry.message)
|
|
4465
|
+
pending: () => [...kept.values()].map((entry) => entry.message),
|
|
4466
|
+
image: async (key) => new Uint8Array(await readFile(join(imagesDir, imageKey.parse(key))))
|
|
3652
4467
|
};
|
|
3653
4468
|
}
|
|
3654
4469
|
//#endregion
|
|
@@ -3659,7 +4474,7 @@ async function openOutbox(dir) {
|
|
|
3659
4474
|
* copy included): `from` is gone either way.
|
|
3660
4475
|
*/
|
|
3661
4476
|
async function fileCopy(from, folder) {
|
|
3662
|
-
if (!await isThere$
|
|
4477
|
+
if (!await isThere$2(folder)) {
|
|
3663
4478
|
await mkdir(dirname(folder), {
|
|
3664
4479
|
recursive: true,
|
|
3665
4480
|
mode: 448
|
|
@@ -3669,7 +4484,7 @@ async function fileCopy(from, folder) {
|
|
|
3669
4484
|
await rename(from, folder);
|
|
3670
4485
|
filed = true;
|
|
3671
4486
|
} catch (error) {
|
|
3672
|
-
if (!(error instanceof Error && "code" in error && await isThere$
|
|
4487
|
+
if (!(error instanceof Error && "code" in error && await isThere$2(folder))) throw error;
|
|
3673
4488
|
}
|
|
3674
4489
|
if (filed) {
|
|
3675
4490
|
await sealCopy(folder);
|
|
@@ -3683,7 +4498,7 @@ async function fileCopy(from, folder) {
|
|
|
3683
4498
|
* there to take, since a check's copy may go (dropped, or taken over just now) at any time.
|
|
3684
4499
|
*/
|
|
3685
4500
|
async function takeCopyOver(from, folder) {
|
|
3686
|
-
if (!await isThere$
|
|
4501
|
+
if (!await isThere$2(from)) return false;
|
|
3687
4502
|
await mkdir(dirname(folder), {
|
|
3688
4503
|
recursive: true,
|
|
3689
4504
|
mode: 448
|
|
@@ -3723,7 +4538,7 @@ async function unseal(folder) {
|
|
|
3723
4538
|
for (const name of await readdir(folder)) await unseal(join(folder, name));
|
|
3724
4539
|
}
|
|
3725
4540
|
/** Whether a copy stands at `folder`: a folder of its own, not a link to one. */
|
|
3726
|
-
async function isThere$
|
|
4541
|
+
async function isThere$2(folder) {
|
|
3727
4542
|
return (await lstat(folder).catch(() => null))?.isDirectory() ?? false;
|
|
3728
4543
|
}
|
|
3729
4544
|
/** The names in folder `dir`, sorted, none when it isn't there. */
|
|
@@ -4082,22 +4897,242 @@ async function osName(platform) {
|
|
|
4082
4897
|
return platform === "win32" ? version() : null;
|
|
4083
4898
|
}
|
|
4084
4899
|
//#endregion
|
|
4085
|
-
//#region src/
|
|
4086
|
-
/** The file
|
|
4087
|
-
const
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4900
|
+
//#region src/workspace-images.ts
|
|
4901
|
+
/** The file extension of each image type. */
|
|
4902
|
+
const EXTENSIONS = {
|
|
4903
|
+
"image/png": "png",
|
|
4904
|
+
"image/jpeg": "jpg",
|
|
4905
|
+
"image/gif": "gif",
|
|
4906
|
+
"image/webp": "webp"
|
|
4092
4907
|
};
|
|
4093
4908
|
/**
|
|
4094
|
-
*
|
|
4095
|
-
*
|
|
4909
|
+
* Writes a document's `images` into `workspace` beside it, in order, reading image `index`'s bytes
|
|
4910
|
+
* with `bytesOf(index)` as it is written. Returns them as written, for what the Agent is told.
|
|
4096
4911
|
*/
|
|
4097
|
-
async function
|
|
4098
|
-
await
|
|
4912
|
+
async function writeImages(workspace, { folder, stem }, images, bytesOf) {
|
|
4913
|
+
if (images.length > 0) await mkdir(join(workspace, folder), {
|
|
4099
4914
|
recursive: true,
|
|
4100
|
-
|
|
4915
|
+
mode: 448
|
|
4916
|
+
});
|
|
4917
|
+
const written = [];
|
|
4918
|
+
for (const [index, image] of images.entries()) {
|
|
4919
|
+
const file = `${stem}-${index + 1}.${EXTENSIONS[image.mimeType]}`;
|
|
4920
|
+
await writeFile(join(workspace, folder, file), await bytesOf(index), { mode: 384 });
|
|
4921
|
+
written.push({
|
|
4922
|
+
path: posix.join(folder, file),
|
|
4923
|
+
mimeType: image.mimeType,
|
|
4924
|
+
alt: image.alt
|
|
4925
|
+
});
|
|
4926
|
+
}
|
|
4927
|
+
return written;
|
|
4928
|
+
}
|
|
4929
|
+
/**
|
|
4930
|
+
* A document's written images as Markdown, one image link a line, each linked from `folder` of the
|
|
4931
|
+
* Workspace, where they are named: `` from `workpiece`, and
|
|
4932
|
+
* `` from the root (`""`).
|
|
4933
|
+
*/
|
|
4934
|
+
function imageLinks(images, folder) {
|
|
4935
|
+
return images.map((image, index) => imageLink(image, index, folder)).join("\n");
|
|
4936
|
+
}
|
|
4937
|
+
/**
|
|
4938
|
+
* One of a document's images as a Markdown image link from `folder` of the Workspace, image `index`
|
|
4939
|
+
* of the document: `` from the root (`""`).
|
|
4940
|
+
*/
|
|
4941
|
+
function imageLink({ path, alt }, index, folder) {
|
|
4942
|
+
return `})`;
|
|
4943
|
+
}
|
|
4944
|
+
/**
|
|
4945
|
+
* What lies beside a document with `count` images, as the instructions' line on it ends: ", with
|
|
4946
|
+
* its image beside it", ", with its 3 images beside it", or nothing.
|
|
4947
|
+
*/
|
|
4948
|
+
function besideIt(count) {
|
|
4949
|
+
if (count === 0) return "";
|
|
4950
|
+
return `, with its ${count === 1 ? "image" : `${count} images`} beside it`;
|
|
4951
|
+
}
|
|
4952
|
+
/**
|
|
4953
|
+
* The image at `path` in `workspace` (from its root, or absolute), as it is now, read whole only
|
|
4954
|
+
* when it is within `IMAGE_LIMITS.bytes`: its size is what was read, never what a look before said,
|
|
4955
|
+
* so a file growing meanwhile is refused all the same.
|
|
4956
|
+
*/
|
|
4957
|
+
async function readWorkspaceImage(workspace, path) {
|
|
4958
|
+
/** Whether the Workspace went, or only the file: what a missing file means. */
|
|
4959
|
+
const missing = async () => await isThere$1(workspace) ? { kind: "absent" } : { kind: "gone" };
|
|
4960
|
+
let file;
|
|
4961
|
+
try {
|
|
4962
|
+
file = await realpath(resolve(workspace, path));
|
|
4963
|
+
} catch (error) {
|
|
4964
|
+
if (!isMissing$1(error)) throw error;
|
|
4965
|
+
return missing();
|
|
4966
|
+
}
|
|
4967
|
+
const fromRoot = relative(await realpath(workspace), file);
|
|
4968
|
+
if (fromRoot.startsWith("..") || isAbsolute(fromRoot)) return { kind: "outside" };
|
|
4969
|
+
const bytes = await readAtMost(file, IMAGE_LIMITS.bytes + 1).catch((error) => {
|
|
4970
|
+
if (isMissing$1(error)) return null;
|
|
4971
|
+
throw error;
|
|
4972
|
+
});
|
|
4973
|
+
if (bytes === null) return missing();
|
|
4974
|
+
if (bytes.length > IMAGE_LIMITS.bytes) return { kind: "too-large" };
|
|
4975
|
+
const mimeType = imageTypeOf(bytes);
|
|
4976
|
+
return mimeType === null ? { kind: "not-an-image" } : {
|
|
4977
|
+
kind: "image",
|
|
4978
|
+
mimeType,
|
|
4979
|
+
bytes
|
|
4980
|
+
};
|
|
4981
|
+
}
|
|
4982
|
+
/** The first `most` bytes of file `path`, or all of it when it holds fewer. */
|
|
4983
|
+
async function readAtMost(path, most) {
|
|
4984
|
+
const handle = await open(path, "r");
|
|
4985
|
+
try {
|
|
4986
|
+
const bytes = Buffer.alloc(most);
|
|
4987
|
+
let read = 0;
|
|
4988
|
+
while (read < most) {
|
|
4989
|
+
const { bytesRead } = await handle.read(bytes, read, most - read, read);
|
|
4990
|
+
if (bytesRead === 0) break;
|
|
4991
|
+
read += bytesRead;
|
|
4992
|
+
}
|
|
4993
|
+
return bytes.subarray(0, read);
|
|
4994
|
+
} finally {
|
|
4995
|
+
await handle.close();
|
|
4996
|
+
}
|
|
4997
|
+
}
|
|
4998
|
+
/**
|
|
4999
|
+
* The image at `path` in `workspace`, for the Owner, as it is now (`readWorkspaceImage`); `gone`
|
|
5000
|
+
* once the Workspace is.
|
|
5001
|
+
*/
|
|
5002
|
+
async function imageInWorkspace(workspace, path) {
|
|
5003
|
+
const read = await readWorkspaceImage(workspace, path);
|
|
5004
|
+
if (read.kind === "image" || read.kind === "gone") return read;
|
|
5005
|
+
return {
|
|
5006
|
+
kind: "missing",
|
|
5007
|
+
why: read.kind === "absent" ? "the image was deleted from its Workspace" : read.kind === "outside" ? "the image was moved out of its Workspace" : "the image in its Workspace was changed since"
|
|
5008
|
+
};
|
|
5009
|
+
}
|
|
5010
|
+
/** Whether `folder` is there. */
|
|
5011
|
+
async function isThere$1(folder) {
|
|
5012
|
+
try {
|
|
5013
|
+
return (await stat(folder)).isDirectory();
|
|
5014
|
+
} catch (error) {
|
|
5015
|
+
if (isMissing$1(error)) return false;
|
|
5016
|
+
throw error;
|
|
5017
|
+
}
|
|
5018
|
+
}
|
|
5019
|
+
/** Whether `error` says a file isn't there. */
|
|
5020
|
+
function isMissing$1(error) {
|
|
5021
|
+
return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
|
|
5022
|
+
}
|
|
5023
|
+
//#endregion
|
|
5024
|
+
//#region src/prompt-images.ts
|
|
5025
|
+
const MiB = 1048576;
|
|
5026
|
+
/**
|
|
5027
|
+
* How much of the Owner's images each Runtime takes inside one prompt. `largest` is measured on
|
|
5028
|
+
* the real CLIs (`pnpm probe:runtimes --spend-tokens`, 2026-11-04, #182): Claude Code 2.1.310 (on
|
|
5029
|
+
* haiku), Codex 0.164.0 and Kimi Code 2.3.0 (on their own default models) each saw every size the
|
|
5030
|
+
* probe tries, up to 10 MiB, the most an image may be (`IMAGE_LIMITS.bytes`), Claude Code's past
|
|
5031
|
+
* the 5 MB of base64 per image Anthropic's API takes (docs/research/real-cli-probe.md says what that
|
|
5032
|
+
* shows). `count` and `together` are fixed bounds, not measured: 20 images, past which
|
|
5033
|
+
* Anthropic's API lowers the pixels it takes of each, and 15 MiB, so that a prompt's images stay
|
|
5034
|
+
* well within a request's 32 MB there.
|
|
5035
|
+
*/
|
|
5036
|
+
const PROMPT_IMAGE_LIMITS = {
|
|
5037
|
+
"claude-code": {
|
|
5038
|
+
largest: 10 * MiB,
|
|
5039
|
+
count: 20,
|
|
5040
|
+
together: 15 * MiB
|
|
5041
|
+
},
|
|
5042
|
+
codex: {
|
|
5043
|
+
largest: 10 * MiB,
|
|
5044
|
+
count: 20,
|
|
5045
|
+
together: 15 * MiB
|
|
5046
|
+
},
|
|
5047
|
+
"kimi-code": {
|
|
5048
|
+
largest: 10 * MiB,
|
|
5049
|
+
count: 20,
|
|
5050
|
+
together: 15 * MiB
|
|
5051
|
+
}
|
|
5052
|
+
};
|
|
5053
|
+
/**
|
|
5054
|
+
* What `prompt` gives a Session of `runtime` in Workspace `cwd`: its words, then a paragraph per
|
|
5055
|
+
* group of images naming each, and the images that go inside it, those its Runtime takes
|
|
5056
|
+
* (`takesImages`) within `limits`, in order; none when the Runtime `refused` them in this prompt
|
|
5057
|
+
* already. Words alone go as they are.
|
|
5058
|
+
*/
|
|
5059
|
+
async function deliveredPrompt(prompt, how) {
|
|
5060
|
+
if (typeof prompt === "string") return {
|
|
5061
|
+
input: prompt,
|
|
5062
|
+
images: []
|
|
5063
|
+
};
|
|
5064
|
+
const name = RUNTIME_NAMES[how.runtime];
|
|
5065
|
+
const { largest, count, together } = how.limits;
|
|
5066
|
+
const inside = [];
|
|
5067
|
+
let carried = 0;
|
|
5068
|
+
/** Why `image` can't go inside the prompt (`""` for a file it can't read), or null when it can. */
|
|
5069
|
+
const heldBack = async ({ path }) => {
|
|
5070
|
+
if (how.refused) return `as ${name} refused it inside the prompt`;
|
|
5071
|
+
if (!how.takesImages) return `as ${name} takes no images inside a prompt`;
|
|
5072
|
+
const bytes = await sizeOf$1(join(how.cwd, path));
|
|
5073
|
+
if (bytes === null) return "";
|
|
5074
|
+
if (bytes > largest) return `larger than ${name} takes inside a prompt (${sizeWords(largest)})`;
|
|
5075
|
+
if (inside.length >= count) return `past the ${count} images ${name} takes inside one prompt`;
|
|
5076
|
+
if (carried + bytes > together) return `past the ${sizeWords(together)} of images ${name} takes inside one prompt`;
|
|
5077
|
+
carried += bytes;
|
|
5078
|
+
return null;
|
|
5079
|
+
};
|
|
5080
|
+
const paragraphs = [prompt.text];
|
|
5081
|
+
for (const { of, images } of prompt.images) {
|
|
5082
|
+
if (images.length === 0) continue;
|
|
5083
|
+
const lines = [];
|
|
5084
|
+
for (const [index, image] of images.entries()) {
|
|
5085
|
+
const why = await heldBack(image);
|
|
5086
|
+
const link = imageLink(image, index, "");
|
|
5087
|
+
if (why === null) {
|
|
5088
|
+
inside.push({
|
|
5089
|
+
path: join(how.cwd, image.path),
|
|
5090
|
+
mediaType: image.mimeType
|
|
5091
|
+
});
|
|
5092
|
+
lines.push(`- ${link}: with this prompt`);
|
|
5093
|
+
} else lines.push(`- ${link}: a file only${why ? `, ${why}` : ""}: open it to see it`);
|
|
5094
|
+
}
|
|
5095
|
+
const whose = `${of.charAt(0).toUpperCase()}${of.slice(1)}'s`;
|
|
5096
|
+
const which = images.length === 1 ? "image, a file" : `${images.length} images, each a file`;
|
|
5097
|
+
paragraphs.push(`${whose} ${which} in this Workspace:\n\n${lines.join("\n")}`);
|
|
5098
|
+
}
|
|
5099
|
+
return {
|
|
5100
|
+
input: paragraphs.join("\n\n"),
|
|
5101
|
+
images: inside
|
|
5102
|
+
};
|
|
5103
|
+
}
|
|
5104
|
+
/** How many bytes the file at `path` holds, or null when it can't be read. */
|
|
5105
|
+
async function sizeOf$1(path) {
|
|
5106
|
+
try {
|
|
5107
|
+
const found = await stat(path);
|
|
5108
|
+
return found.isFile() ? found.size : null;
|
|
5109
|
+
} catch {
|
|
5110
|
+
return null;
|
|
5111
|
+
}
|
|
5112
|
+
}
|
|
5113
|
+
/** A size as the prompt says it: "3 MiB", "1.5 MiB", "200 KiB", "900 bytes". */
|
|
5114
|
+
function sizeWords(bytes) {
|
|
5115
|
+
if (bytes >= MiB) return `${Math.round(bytes / MiB * 100) / 100} MiB`;
|
|
5116
|
+
if (bytes >= 1024) return `${Math.round(bytes / 1024 * 100) / 100} KiB`;
|
|
5117
|
+
return `${bytes} bytes`;
|
|
5118
|
+
}
|
|
5119
|
+
//#endregion
|
|
5120
|
+
//#region src/skill-acceptance.ts
|
|
5121
|
+
/** The file at the top of a Skill's folder that names and describes it. */
|
|
5122
|
+
const SKILL_FILE = "SKILL.md";
|
|
5123
|
+
/** How big a Skill's folder may be: its files' bytes added up, and how many files it holds. */
|
|
5124
|
+
const SKILL_FOLDER_LIMITS = {
|
|
5125
|
+
bytes: 52428800,
|
|
5126
|
+
files: 5e3
|
|
5127
|
+
};
|
|
5128
|
+
/**
|
|
5129
|
+
* Accepts `folder` as a Skill, or says why not, in the Owner's words. A `.git` at its top (a
|
|
5130
|
+
* clone's history, which differs on every machine) is deleted first: it isn't the Skill's.
|
|
5131
|
+
*/
|
|
5132
|
+
async function acceptSkill(folder) {
|
|
5133
|
+
await rm(join(folder, ".git"), {
|
|
5134
|
+
recursive: true,
|
|
5135
|
+
force: true
|
|
4101
5136
|
});
|
|
4102
5137
|
const walked = await entriesOf(folder);
|
|
4103
5138
|
if (typeof walked === "string") return {
|
|
@@ -4218,8 +5253,15 @@ async function digestOf(entries) {
|
|
|
4218
5253
|
//#region src/routing-skills.ts
|
|
4219
5254
|
/** Where a repository's own Skills are looked for, in this order, each Skill a folder in them. */
|
|
4220
5255
|
const REPOSITORY_SKILL_FOLDERS = [".claude/skills", ".agents/skills"];
|
|
5256
|
+
/** A repository's own instructions for agents, looked for at its root only, in this order. */
|
|
5257
|
+
const REPOSITORY_INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md"];
|
|
4221
5258
|
/** Where a Session's routing Skills are written in its Workspace, a folder each, before each launch. */
|
|
4222
5259
|
const ROUTING_SKILLS_FOLDER = join(".moonmill", "routing");
|
|
5260
|
+
/**
|
|
5261
|
+
* The routing AGENTS.md, from the Workspace's root: in the routing folder beside the routing
|
|
5262
|
+
* Skills, listing the repositories' own instructions, written with them before each launch.
|
|
5263
|
+
*/
|
|
5264
|
+
const ROUTING_AGENTS_FILE = join(ROUTING_SKILLS_FOLDER, "AGENTS.md");
|
|
4223
5265
|
/** How much of a `SKILL.md` is read for its frontmatter, which comes at its top. */
|
|
4224
5266
|
const FRONTMATTER_BYTES = 65536;
|
|
4225
5267
|
/**
|
|
@@ -4235,8 +5277,10 @@ function routingSkillName(folder) {
|
|
|
4235
5277
|
* its own, written anew into the Workspace's `ROUTING_SKILLS_FOLDER`. A routing Skill whose name
|
|
4236
5278
|
* one of `skills` has already (as names compare, `skillNameKey`), or an earlier repository's, is
|
|
4237
5279
|
* left out, each Skill it would have listed in `leftOut`, saying which has its name: the Agent's
|
|
4238
|
-
* own Skill wins it.
|
|
4239
|
-
*
|
|
5280
|
+
* own Skill wins it. The routing AGENTS.md is written there too (`ROUTING_AGENTS_FILE`), when any
|
|
5281
|
+
* of `repositories` carries instructions of its own. Undefined `repositories`: nothing is looked
|
|
5282
|
+
* for, and `skills` are given as they are; otherwise what was laid out before is replaced, `skills`
|
|
5283
|
+
* or not.
|
|
4240
5284
|
*/
|
|
4241
5285
|
async function withRoutingSkills(workspace, repositories, skills) {
|
|
4242
5286
|
if (repositories === void 0) return {
|
|
@@ -4248,6 +5292,7 @@ async function withRoutingSkills(workspace, repositories, skills) {
|
|
|
4248
5292
|
recursive: true,
|
|
4249
5293
|
force: true
|
|
4250
5294
|
});
|
|
5295
|
+
await writeRoutingAgents(workspace, repositories);
|
|
4251
5296
|
const given = [...skills ?? []];
|
|
4252
5297
|
const taken = new Map(given.map(({ name }) => [skillNameKey(name), null]));
|
|
4253
5298
|
const leftOut = [];
|
|
@@ -4300,6 +5345,68 @@ async function withRoutingSkills(workspace, repositories, skills) {
|
|
|
4300
5345
|
};
|
|
4301
5346
|
}
|
|
4302
5347
|
/**
|
|
5348
|
+
* Writes the routing AGENTS.md into `workspace` (`ROUTING_AGENTS_FILE`), listing each of
|
|
5349
|
+
* `repositories` (the folders of `workspace` they were checked out into) that carries instructions
|
|
5350
|
+
* of its own, in that order; nothing when none does.
|
|
5351
|
+
*/
|
|
5352
|
+
async function writeRoutingAgents(workspace, repositories) {
|
|
5353
|
+
const routed = [];
|
|
5354
|
+
for (const folder of repositories) {
|
|
5355
|
+
const files = await instructionFilesOf(join(workspace, folder));
|
|
5356
|
+
if (files.length > 0) routed.push({
|
|
5357
|
+
folder,
|
|
5358
|
+
files
|
|
5359
|
+
});
|
|
5360
|
+
}
|
|
5361
|
+
if (routed.length === 0) return;
|
|
5362
|
+
await mkdir(join(workspace, ROUTING_SKILLS_FOLDER), {
|
|
5363
|
+
recursive: true,
|
|
5364
|
+
mode: 448
|
|
5365
|
+
});
|
|
5366
|
+
await writeFile(join(workspace, ROUTING_AGENTS_FILE), routingAgentsFile(routed), { mode: 384 });
|
|
5367
|
+
}
|
|
5368
|
+
/**
|
|
5369
|
+
* The instruction files at the root of the repository at `repository`, each by its path there
|
|
5370
|
+
* (`<repository>/AGENTS.md`): each of `REPOSITORY_INSTRUCTION_FILES` that is a file, through any
|
|
5371
|
+
* links, wherever they lead. One that is a link to another listed already (a `CLAUDE.md` leading
|
|
5372
|
+
* to the `AGENTS.md` beside it, say) is listed once. None for a folder that isn't there.
|
|
5373
|
+
*/
|
|
5374
|
+
async function instructionFilesOf(repository) {
|
|
5375
|
+
if (!await isFolder(repository)) return [];
|
|
5376
|
+
const files = [];
|
|
5377
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5378
|
+
for (const name of REPOSITORY_INSTRUCTION_FILES) {
|
|
5379
|
+
const file = join(repository, name);
|
|
5380
|
+
if (!await isFile(file)) continue;
|
|
5381
|
+
const real = await realpath(file);
|
|
5382
|
+
if (seen.has(real)) continue;
|
|
5383
|
+
seen.add(real);
|
|
5384
|
+
files.push(file);
|
|
5385
|
+
}
|
|
5386
|
+
return files;
|
|
5387
|
+
}
|
|
5388
|
+
/**
|
|
5389
|
+
* The routing AGENTS.md listing `routed`: each repository's folder, the absolute path of each of
|
|
5390
|
+
* its instruction files, which the Agent reads where it is so that what it refers to is found from
|
|
5391
|
+
* there, and to read them before working in that folder and follow them while working there.
|
|
5392
|
+
*/
|
|
5393
|
+
function routingAgentsFile(routed) {
|
|
5394
|
+
return [
|
|
5395
|
+
"# The repositories' own instructions",
|
|
5396
|
+
"",
|
|
5397
|
+
"These repositories in your Workspace come with instructions of their own for agents working in them. Read each file where it is, at the path given: what it refers to is found from there.",
|
|
5398
|
+
"",
|
|
5399
|
+
...routed.flatMap(({ folder, files }) => [
|
|
5400
|
+
`## \`${folder}/\``,
|
|
5401
|
+
"",
|
|
5402
|
+
`Before you work in \`${folder}/\`, read ${files.length === 1 ? "this file" : "these files"}, and follow ${files.length === 1 ? "it" : "them"} while you work there:`,
|
|
5403
|
+
"",
|
|
5404
|
+
...files.map((file) => `- ${file}`),
|
|
5405
|
+
""
|
|
5406
|
+
])
|
|
5407
|
+
].join("\n");
|
|
5408
|
+
}
|
|
5409
|
+
/**
|
|
4303
5410
|
* The Skills the repository at `repository` carries: each folder of its `REPOSITORY_SKILL_FOLDERS`
|
|
4304
5411
|
* holding a `SKILL.md` (a hidden one apart), in that order, by name within each. One reached twice
|
|
4305
5412
|
* (`.agents/skills` a link to `.claude/skills`, say) is listed once; one the Runtimes wouldn't take,
|
|
@@ -4477,6 +5584,58 @@ function recordedSkillsFile(record) {
|
|
|
4477
5584
|
* when the folder holds none (the Session was never recorded here).
|
|
4478
5585
|
*/
|
|
4479
5586
|
async function readSessionRecord(folder) {
|
|
5587
|
+
const files = await segmentFiles(folder);
|
|
5588
|
+
if (files === null) return null;
|
|
5589
|
+
const items = [];
|
|
5590
|
+
for (const [index, file] of files.entries()) {
|
|
5591
|
+
const path = join(folder, file);
|
|
5592
|
+
const { items: segment } = segmentOf(index + 1, file, await readFile(path, "utf8"), await skillsNotedFor(path));
|
|
5593
|
+
items.push(...segment);
|
|
5594
|
+
}
|
|
5595
|
+
return withinLimit(items);
|
|
5596
|
+
}
|
|
5597
|
+
/**
|
|
5598
|
+
* One image the record of the Session filed in `folder` names (`image`): a prompt's from the
|
|
5599
|
+
* Session's Workspace, `gone` once the Workspace was swept, or a tool's from the record. Either
|
|
5600
|
+
* must be a png, jpeg, gif or webp within `IMAGE_LIMITS.bytes`.
|
|
5601
|
+
*/
|
|
5602
|
+
async function recordImageOf(folder, image) {
|
|
5603
|
+
const none = {
|
|
5604
|
+
kind: "missing",
|
|
5605
|
+
why: "its record names no such image"
|
|
5606
|
+
};
|
|
5607
|
+
const file = (await segmentFiles(folder))?.[image.segment - 1];
|
|
5608
|
+
if (file === void 0) return none;
|
|
5609
|
+
const text = await readFile(join(folder, file), "utf8").catch((error) => {
|
|
5610
|
+
if (isMissing$1(error)) return null;
|
|
5611
|
+
throw error;
|
|
5612
|
+
});
|
|
5613
|
+
if (text === null) return none;
|
|
5614
|
+
const { images, workspace } = segmentOf(image.segment, file, text, NOTHING_NOTED);
|
|
5615
|
+
const source = images[image.index];
|
|
5616
|
+
if (source === void 0) return none;
|
|
5617
|
+
if (source.kind === "file") return workspace === null ? {
|
|
5618
|
+
kind: "missing",
|
|
5619
|
+
why: "its record doesn't say which Workspace the image was in"
|
|
5620
|
+
} : imageInWorkspace(workspace, source.path);
|
|
5621
|
+
const bytes = Buffer.from(source.data, "base64");
|
|
5622
|
+
if (bytes.length > IMAGE_LIMITS.bytes) return {
|
|
5623
|
+
kind: "missing",
|
|
5624
|
+
why: "the image is larger than the Owner is shown"
|
|
5625
|
+
};
|
|
5626
|
+
const mimeType = imageTypeOf(bytes);
|
|
5627
|
+
if (mimeType === null) return {
|
|
5628
|
+
kind: "missing",
|
|
5629
|
+
why: "the image isn't a PNG, JPEG, GIF or WebP"
|
|
5630
|
+
};
|
|
5631
|
+
return {
|
|
5632
|
+
kind: "image",
|
|
5633
|
+
mimeType,
|
|
5634
|
+
bytes
|
|
5635
|
+
};
|
|
5636
|
+
}
|
|
5637
|
+
/** The files of the Session's segments in `folder`, in the order they opened; null for none. */
|
|
5638
|
+
async function segmentFiles(folder) {
|
|
4480
5639
|
let files;
|
|
4481
5640
|
try {
|
|
4482
5641
|
files = (await readdir(folder, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")).map((entry) => entry.name).toSorted(byOpening);
|
|
@@ -4484,13 +5643,7 @@ async function readSessionRecord(folder) {
|
|
|
4484
5643
|
if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
|
|
4485
5644
|
throw error;
|
|
4486
5645
|
}
|
|
4487
|
-
|
|
4488
|
-
const items = [];
|
|
4489
|
-
for (const [index, file] of files.entries()) {
|
|
4490
|
-
const path = join(folder, file);
|
|
4491
|
-
items.push(...segmentOf(index + 1, file, await readFile(path, "utf8"), await skillsNotedFor(path)));
|
|
4492
|
-
}
|
|
4493
|
-
return withinLimit(items);
|
|
5646
|
+
return files.length === 0 ? null : files;
|
|
4494
5647
|
}
|
|
4495
5648
|
/** Files in the order their segments opened: by the time their name starts with, then by name. */
|
|
4496
5649
|
function byOpening(a, b) {
|
|
@@ -4512,6 +5665,11 @@ const skillsNoted = z.union([z.object({
|
|
|
4512
5665
|
skills,
|
|
4513
5666
|
leftOut: []
|
|
4514
5667
|
}))]);
|
|
5668
|
+
/** Nothing noted beside a segment's log. */
|
|
5669
|
+
const NOTHING_NOTED = {
|
|
5670
|
+
skills: [],
|
|
5671
|
+
leftOut: []
|
|
5672
|
+
};
|
|
4515
5673
|
/** What is noted beside segment `record`'s log: nothing where nothing is, or it can't be read. */
|
|
4516
5674
|
async function skillsNotedFor(record) {
|
|
4517
5675
|
const text = await readFile(recordedSkillsFile(record), "utf8").catch(() => null);
|
|
@@ -4542,6 +5700,20 @@ function segmentOf(number, file, text, noted) {
|
|
|
4542
5700
|
const sessionId = typeof header?.sessionId === "string" ? header.sessionId : file.replace(/^\d+-|\.jsonl$/g, "");
|
|
4543
5701
|
const startedAt = typeof header?.startedAt === "number" ? header.startedAt : openedAtOf(file);
|
|
4544
5702
|
const view = viewOf(records, file);
|
|
5703
|
+
const endedAt = /* @__PURE__ */ new Map();
|
|
5704
|
+
for (const record of records) {
|
|
5705
|
+
if (record.agentPath.length > 0) continue;
|
|
5706
|
+
for (const event of eventsOf(record)) if (event.kind === "tool_call_ended") endedAt.set(event.callId, record.seq);
|
|
5707
|
+
}
|
|
5708
|
+
/** The items that name images, each with them and where in the log they came. */
|
|
5709
|
+
const arriving = [];
|
|
5710
|
+
const name = (item, sources, at) => {
|
|
5711
|
+
if (sources.length > 0) arriving.push({
|
|
5712
|
+
item,
|
|
5713
|
+
sources,
|
|
5714
|
+
at: at ?? Number.MAX_SAFE_INTEGER
|
|
5715
|
+
});
|
|
5716
|
+
};
|
|
4545
5717
|
const items = [{
|
|
4546
5718
|
kind: "segment",
|
|
4547
5719
|
number,
|
|
@@ -4552,19 +5724,55 @@ function segmentOf(number, file, text, noted) {
|
|
|
4552
5724
|
skills: noted.skills,
|
|
4553
5725
|
skillsLeftOut: noted.leftOut
|
|
4554
5726
|
}];
|
|
4555
|
-
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
|
|
4559
|
-
|
|
4560
|
-
|
|
5727
|
+
const workspace = typeof header?.cwd === "string" && header.cwd !== "" ? header.cwd : null;
|
|
5728
|
+
for (const message of view.messages) if (message.kind === "input") {
|
|
5729
|
+
const prompt = {
|
|
5730
|
+
kind: "prompt",
|
|
5731
|
+
text: cut(message.input.input, RECORD_LIMITS.prompt)
|
|
5732
|
+
};
|
|
5733
|
+
items.push(prompt);
|
|
5734
|
+
const carried = (message.input.images ?? []).map(({ path }) => path);
|
|
5735
|
+
const sent = workspace === null ? [] : chatImagesOf(message.input.input, workspace);
|
|
5736
|
+
name(prompt, (sent.length > 0 ? sent : carried).map((path) => ({
|
|
5737
|
+
kind: "file",
|
|
5738
|
+
path
|
|
5739
|
+
})), message.input.attempts.at(-1)?.request.seq);
|
|
5740
|
+
} else if (message.kind === "turn") {
|
|
5741
|
+
for (const section of message.sections) if (section.agentPath.length === 0) for (const part of section.parts) items.push(...itemsOf(part, (item, sources) => name(item, sources, part.kind === "tool" ? endedAt.get(part.callId) : void 0)));
|
|
4561
5742
|
if (message.outcome) items.push({
|
|
4562
5743
|
kind: "turn-ended",
|
|
4563
5744
|
outcome: message.outcome.kind,
|
|
4564
5745
|
reason: message.outcome.kind === "failed" ? cut(message.outcome.reason, RECORD_LIMITS.reason).text : null
|
|
4565
5746
|
});
|
|
4566
5747
|
}
|
|
4567
|
-
|
|
5748
|
+
const images = [];
|
|
5749
|
+
for (const { item, sources } of arriving.toSorted((a, b) => a.at - b.at)) {
|
|
5750
|
+
const named = [];
|
|
5751
|
+
for (const source of sources) {
|
|
5752
|
+
const image = {
|
|
5753
|
+
segment: number,
|
|
5754
|
+
index: images.length
|
|
5755
|
+
};
|
|
5756
|
+
images.push(source);
|
|
5757
|
+
if (named.length < RECORD_LIMITS.images && recordImage.safeParse(image).success) named.push(image);
|
|
5758
|
+
}
|
|
5759
|
+
if (named.length > 0) item.images = named;
|
|
5760
|
+
}
|
|
5761
|
+
return {
|
|
5762
|
+
items,
|
|
5763
|
+
images,
|
|
5764
|
+
workspace
|
|
5765
|
+
};
|
|
5766
|
+
}
|
|
5767
|
+
/** A link a chat turn's prompt makes to an image of its message, in the turn's folder. */
|
|
5768
|
+
const CHAT_IMAGE_LINK = new RegExp(String.raw`\]\((${WORKSPACE_FOLDERS.chatTurns}/\d+/[^)\s/]+)\)`, "g");
|
|
5769
|
+
/**
|
|
5770
|
+
* The files of the images a chat turn's prompt `input` says its message came with, in order, each
|
|
5771
|
+
* where it is in `workspace`: every one, inside the prompt or a file only. None for another prompt.
|
|
5772
|
+
*/
|
|
5773
|
+
function chatImagesOf(input, workspace) {
|
|
5774
|
+
const linked = [...input.matchAll(CHAT_IMAGE_LINK)].flatMap((match) => match[1] ?? []);
|
|
5775
|
+
return [...new Set(linked)].map((path) => join(workspace, path));
|
|
4568
5776
|
}
|
|
4569
5777
|
/** A line of a log, or null where it isn't one (the half-written last line of a daemon that died). */
|
|
4570
5778
|
function lineOf(line) {
|
|
@@ -4576,8 +5784,11 @@ function lineOf(line) {
|
|
|
4576
5784
|
return null;
|
|
4577
5785
|
}
|
|
4578
5786
|
}
|
|
4579
|
-
/**
|
|
4580
|
-
|
|
5787
|
+
/**
|
|
5788
|
+
* The record items one part of a turn stands for, each with the images it returned handed to
|
|
5789
|
+
* `name`, which numbers them among its segment's.
|
|
5790
|
+
*/
|
|
5791
|
+
function itemsOf(part, name) {
|
|
4581
5792
|
switch (part.kind) {
|
|
4582
5793
|
case "text": return [{
|
|
4583
5794
|
kind: "text",
|
|
@@ -4589,14 +5800,19 @@ function itemsOf(part) {
|
|
|
4589
5800
|
}];
|
|
4590
5801
|
case "tool": {
|
|
4591
5802
|
const output = toolResultText(part.content) ?? part.output;
|
|
4592
|
-
|
|
5803
|
+
const tool = {
|
|
4593
5804
|
kind: "tool",
|
|
4594
5805
|
callId: cut(part.callId, 200).text || "call",
|
|
4595
5806
|
tool: cut(part.tool, RECORD_LIMITS.tool).text || "tool",
|
|
4596
5807
|
input: part.input === void 0 ? null : cut(part.input, RECORD_LIMITS.toolInput),
|
|
4597
5808
|
result: part.result,
|
|
4598
5809
|
output: output === void 0 ? null : cut(output, RECORD_LIMITS.toolOutput)
|
|
4599
|
-
}
|
|
5810
|
+
};
|
|
5811
|
+
name(tool, (part.content ?? []).flatMap((each) => each.type === "image" ? [{
|
|
5812
|
+
kind: "bytes",
|
|
5813
|
+
data: each.data
|
|
5814
|
+
}] : []));
|
|
5815
|
+
return [tool];
|
|
4600
5816
|
}
|
|
4601
5817
|
}
|
|
4602
5818
|
return [];
|
|
@@ -4634,14 +5850,17 @@ const TEXT_HOLD_MS = 100;
|
|
|
4634
5850
|
* The Runtime driver over oar, for the real agent CLIs this machine has (ADR-0009), with the
|
|
4635
5851
|
* Owner's own setup in this machine's folders (`thisMachine`) unless told otherwise.
|
|
4636
5852
|
*/
|
|
4637
|
-
function oarRuntimeDriver({ machine = thisMachine() } = {}) {
|
|
5853
|
+
function oarRuntimeDriver({ machine = thisMachine(), ...options } = {}) {
|
|
4638
5854
|
return runtimeDriver(async (runtime) => {
|
|
4639
5855
|
const { defaultRuntimes } = await import("@botiverse/oar");
|
|
4640
5856
|
return defaultRuntimes.require(OAR_RUNTIME_IDS[runtime]);
|
|
4641
|
-
}, {
|
|
5857
|
+
}, {
|
|
5858
|
+
machine,
|
|
5859
|
+
...options
|
|
5860
|
+
});
|
|
4642
5861
|
}
|
|
4643
5862
|
/** The Runtime driver over the oar Runtimes `lookup` finds, on `machine`. */
|
|
4644
|
-
function runtimeDriver(lookup, { machine }) {
|
|
5863
|
+
function runtimeDriver(lookup, { machine, promptImageLimits = {}, retriesRefusedImages = true }) {
|
|
4645
5864
|
return { async open({ runtime: id, cwd, instructions, mcpServers, skills: own, repositories, model, resume, recordIn, onProblem = () => {} }) {
|
|
4646
5865
|
const runtime = await lookup(id);
|
|
4647
5866
|
const installation = await runtime.installation?.();
|
|
@@ -4672,7 +5891,12 @@ function runtimeDriver(lookup, { machine }) {
|
|
|
4672
5891
|
skills: recordedSkillsOf(skills),
|
|
4673
5892
|
leftOut,
|
|
4674
5893
|
onProblem
|
|
4675
|
-
}), onProblem
|
|
5894
|
+
}), onProblem, {
|
|
5895
|
+
runtime: id,
|
|
5896
|
+
cwd,
|
|
5897
|
+
limits: promptImageLimits[id] ?? PROMPT_IMAGE_LIMITS[id],
|
|
5898
|
+
retriesRefusedImages
|
|
5899
|
+
});
|
|
4676
5900
|
} };
|
|
4677
5901
|
}
|
|
4678
5902
|
/**
|
|
@@ -4786,12 +6010,37 @@ async function newRecordFile(dir, sessionId) {
|
|
|
4786
6010
|
}
|
|
4787
6011
|
}
|
|
4788
6012
|
}
|
|
4789
|
-
|
|
6013
|
+
/**
|
|
6014
|
+
* Whether a prompt that carried images was refused in a way they may have caused: oar refused it
|
|
6015
|
+
* (an image it couldn't deliver, or the Runtime's own no), or the provider failed the turn as too
|
|
6016
|
+
* large or an invalid request. A prompt refused for being busy, or a Session that ended, isn't.
|
|
6017
|
+
*/
|
|
6018
|
+
function refusedItsImages(run) {
|
|
6019
|
+
if (run.kind === "rejected") return run.code === "unsupported" || run.code === "runtime_refused" || run.code === "error";
|
|
6020
|
+
return run.kind === "ended" && run.outcome.kind === "failed" && (run.outcome.failure === "input_too_large" || run.outcome.failure === "invalid_request");
|
|
6021
|
+
}
|
|
6022
|
+
function driverSession(session, recording, onProblem, prompted) {
|
|
4790
6023
|
return {
|
|
4791
6024
|
id: session.id,
|
|
4792
6025
|
recording: recording?.path ?? null,
|
|
4793
|
-
async run(
|
|
4794
|
-
const
|
|
6026
|
+
async run(prompt, signal) {
|
|
6027
|
+
const { retriesRefusedImages, ...how } = prompted;
|
|
6028
|
+
const deliver = async (refused) => {
|
|
6029
|
+
const { input, images } = await deliveredPrompt(prompt, {
|
|
6030
|
+
...how,
|
|
6031
|
+
takesImages: session.capabilities.images,
|
|
6032
|
+
refused
|
|
6033
|
+
});
|
|
6034
|
+
return {
|
|
6035
|
+
run: await promptAndWait(session, input, {
|
|
6036
|
+
...signal ? { signal } : {},
|
|
6037
|
+
...images.length > 0 ? { images } : {}
|
|
6038
|
+
}),
|
|
6039
|
+
carried: images.length > 0
|
|
6040
|
+
};
|
|
6041
|
+
};
|
|
6042
|
+
const first = await deliver(false);
|
|
6043
|
+
const { run } = retriesRefusedImages && first.carried && refusedItsImages(first.run) ? await deliver(true) : first;
|
|
4795
6044
|
if (run.kind === "rejected") return {
|
|
4796
6045
|
kind: "failed",
|
|
4797
6046
|
failure: run.code,
|
|
@@ -4845,7 +6094,7 @@ function driverSession(session, recording, onProblem) {
|
|
|
4845
6094
|
//#endregion
|
|
4846
6095
|
//#region src/official-skills.ts
|
|
4847
6096
|
/** The Version an official Skill is recorded at: this daemon's, by its protocol. */
|
|
4848
|
-
const OFFICIAL_SKILLS_VERSION = `protocol
|
|
6097
|
+
const OFFICIAL_SKILLS_VERSION = `protocol 25`;
|
|
4849
6098
|
/** What every official Skill's instructions end with: no credential, and how to say it failed. */
|
|
4850
6099
|
const NEVER = `Never ask for a password or token, and never write one into a link, a command or a
|
|
4851
6100
|
file. If the source can't be reached, the Version isn't there, or there's no \`SKILL.md\` where the
|
|
@@ -5198,7 +6447,7 @@ async function openMillwright(options) {
|
|
|
5198
6447
|
const skills = await writeOfficialSkills(folders.skills);
|
|
5199
6448
|
for (const binding of setup?.skills ?? []) {
|
|
5200
6449
|
const folder = ownCopyFolder(stateDir, binding.skillId, binding.version.digest);
|
|
5201
|
-
if (nameTakenBy(binding.name, skills.map(({ name }) => name)) !== null || !await isThere$
|
|
6450
|
+
if (nameTakenBy(binding.name, skills.map(({ name }) => name)) !== null || !await isThere$2(folder)) continue;
|
|
5202
6451
|
const { version, digest } = binding.version;
|
|
5203
6452
|
skills.push({
|
|
5204
6453
|
name: binding.name,
|
|
@@ -5468,12 +6717,12 @@ async function openMillwright(options) {
|
|
|
5468
6717
|
for (const copy of filed) if (now.skills.some((each) => copy === ownCopyFolder(stateDir, each.skillId, each.version.digest))) filed.delete(copy);
|
|
5469
6718
|
for (const binding of now.skills) {
|
|
5470
6719
|
const folder = ownCopyFolder(stateDir, binding.skillId, binding.version.digest);
|
|
5471
|
-
if (owning.has(folder) || await isThere$
|
|
6720
|
+
if (owning.has(folder) || await isThere$2(folder)) continue;
|
|
5472
6721
|
if (binding.fromCheck !== null && await adopt(binding, binding.fromCheck, folder)) continue;
|
|
5473
6722
|
owning.add(folder);
|
|
5474
6723
|
enqueue("own", async () => {
|
|
5475
6724
|
try {
|
|
5476
|
-
if (stopping || killed || !stillNamed(binding) || await isThere$
|
|
6725
|
+
if (stopping || killed || !stillNamed(binding) || await isThere$2(folder)) return;
|
|
5477
6726
|
const { version, digest } = binding.version;
|
|
5478
6727
|
const installed = await installInto({
|
|
5479
6728
|
label: binding.skillId,
|
|
@@ -5913,9 +7162,9 @@ function createSkillCopies(options) {
|
|
|
5913
7162
|
* the Millwright installs. Why it couldn't, in the Owner's words, or null once it is there.
|
|
5914
7163
|
*/
|
|
5915
7164
|
const makeCopy = async (agentId, binding, folder, signal) => {
|
|
5916
|
-
if (await isThere$
|
|
7165
|
+
if (await isThere$2(folder)) return null;
|
|
5917
7166
|
if (binding.fromCheck !== null && await adopt(agentId, binding.skillId, binding.fromCheck, folder)) return null;
|
|
5918
|
-
if (await isThere$
|
|
7167
|
+
if (await isThere$2(folder)) return null;
|
|
5919
7168
|
const { version, digest } = binding.version;
|
|
5920
7169
|
const installed = await millwright.install({
|
|
5921
7170
|
label: binding.skillId,
|
|
@@ -6129,7 +7378,7 @@ function createSkillCopies(options) {
|
|
|
6129
7378
|
for (const { agentId, skills } of agents) for (const binding of skills) {
|
|
6130
7379
|
if (binding.fromCheck === null) continue;
|
|
6131
7380
|
const folder = agentCopyFolder(stateDir, agentId, binding.skillId, binding.version.digest);
|
|
6132
|
-
if (taking.has(folder) || installing.has(folder) || await isThere$
|
|
7381
|
+
if (taking.has(folder) || installing.has(folder) || await isThere$2(folder)) continue;
|
|
6133
7382
|
hold(folder);
|
|
6134
7383
|
try {
|
|
6135
7384
|
await adopt(agentId, binding.skillId, binding.fromCheck, folder);
|
|
@@ -6300,7 +7549,7 @@ const HAND_OFF_GUIDANCE = [
|
|
|
6300
7549
|
`When the work is done, call the \`${HAND_OFF_TOOL}\` tool of the \`${FACTORY_MCP_NAME}\` MCP server exactly once, as your last action, then stop. Its arguments:`,
|
|
6301
7550
|
"",
|
|
6302
7551
|
"- `markdown`: your Hand-off, a document in Markdown: what you did, what you found, and what whoever reads it next needs to know.",
|
|
6303
|
-
`- \`images\` (optional): up to ${
|
|
7552
|
+
`- \`images\` (optional): up to ${IMAGE_LIMITS.images} image files in this Workspace (${IMAGE_TYPES.map((type) => type.replace("image/", "").toUpperCase()).join(", ")}; at most ${IMAGE_LIMITS.bytes / 1024 / 1024} MiB each), as \`{ "path": "…", "alt": "…" }\`.`,
|
|
6304
7553
|
`- \`references\` (optional): up to ${HAND_OFF_LIMITS.references} materials you produced, each \`{ "label": "…", "target": "…" }\` with a URL or a pinned identifier as the target, such as a pushed commit.`,
|
|
6305
7554
|
"",
|
|
6306
7555
|
"A Hand-off is a document, not a delivery of files: put materials (code, data, archives) where they belong, such as a pushed branch, and reference them. Nothing in this Workspace is kept for whoever comes next."
|
|
@@ -6310,7 +7559,7 @@ const HAND_OFF_GUIDANCE = [
|
|
|
6310
7559
|
* the tool returns at once, and the reply continues the Session (ADR-0009).
|
|
6311
7560
|
*/
|
|
6312
7561
|
const HELP_GUIDANCE = [
|
|
6313
|
-
`If you can't go on without the Owner (something only they know or can decide), call the \`${HELP_TOOL}\` tool of the \`${FACTORY_MCP_NAME}\` MCP server with your \`question\`, in Markdown: what you need to know, and what you found that makes you ask.`,
|
|
7562
|
+
`If you can't go on without the Owner (something only they know or can decide), call the \`${HELP_TOOL}\` tool of the \`${FACTORY_MCP_NAME}\` MCP server with your \`question\`, in Markdown: what you need to know, and what you found that makes you ask. To show the Owner what you see, add \`images\`: up to ${IMAGE_LIMITS.images} image files in this Workspace, as \`{ "path": "…", "alt": "…" }\`; the Owner sees them as they are when you ask.`,
|
|
6314
7563
|
"",
|
|
6315
7564
|
"It returns at once. Then end your turn without handing off: the Owner's reply continues this Session where you left off, as your next prompt. Ask one question at a time, and only when you're stuck."
|
|
6316
7565
|
].join("\n");
|
|
@@ -6322,6 +7571,7 @@ const JUDGE_GUIDANCE = [
|
|
|
6322
7571
|
"",
|
|
6323
7572
|
"- `outcome`: `\"passed\"` when the Hand-off meets every criterion, `\"failed\"` when it misses any.",
|
|
6324
7573
|
`- \`annotations\` (when it failed): Markdown saying which criteria the Hand-off doesn't meet and why, for whoever works on it next and for the Owner; at most ${HAND_OFF_LIMITS.markdown.toLocaleString("en")} characters.`,
|
|
7574
|
+
`- \`images\` (optional, when it failed): up to ${IMAGE_LIMITS.images} image files in this Workspace that show what is wrong, such as a screenshot (${IMAGE_TYPES.map((type) => type.replace("image/", "").toUpperCase()).join(", ")}; at most ${IMAGE_LIMITS.bytes / 1024 / 1024} MiB each), as \`{ "path": "…", "alt": "…" }\`; they go beside the annotations.`,
|
|
6325
7575
|
"",
|
|
6326
7576
|
"Judge the Hand-off as it is: don't redo the work or fix it, and change nothing it references. Reach the materials it references the way this machine can."
|
|
6327
7577
|
].join("\n");
|
|
@@ -6342,40 +7592,76 @@ const WORKPIECE_DIR = WORKSPACE_FOLDERS.workpiece;
|
|
|
6342
7592
|
const UNDER_CHECK_DIR = WORKSPACE_FOLDERS.underCheck;
|
|
6343
7593
|
/** The folder of a Rework's Workspace holding the Hand-off of the attempt it follows. */
|
|
6344
7594
|
const PREVIOUS_ATTEMPT_DIR = WORKSPACE_FOLDERS.previousAttempt;
|
|
6345
|
-
/**
|
|
6346
|
-
const
|
|
6347
|
-
|
|
6348
|
-
|
|
6349
|
-
"image/gif": "gif",
|
|
6350
|
-
"image/webp": "webp"
|
|
7595
|
+
/** A Workpiece written as its brief alone, with no images: nothing in the Workspace. */
|
|
7596
|
+
const NOTHING_WRITTEN = {
|
|
7597
|
+
brief: null,
|
|
7598
|
+
handOffs: []
|
|
6351
7599
|
};
|
|
6352
7600
|
/**
|
|
6353
|
-
* Writes the Workpiece as it stands into a Session's Workspace (spec stories 24 and 35
|
|
6354
|
-
* earlier stations' Hand-offs were accepted, `workpiece/`
|
|
6355
|
-
*
|
|
6356
|
-
*
|
|
6357
|
-
*
|
|
7601
|
+
* Writes the Workpiece as it stands into a Session's Workspace (spec stories 24 and 35, and #172's
|
|
7602
|
+
* 28-30): when its brief carries images or earlier stations' Hand-offs were accepted, `workpiece/`
|
|
7603
|
+
* holds its brief as a Markdown file with the brief's images beside it (`brief-1.png`…), read from
|
|
7604
|
+
* the image store, and each of those Hand-offs as a Markdown file, with the Hand-off's images beside
|
|
7605
|
+
* it, all fetched from the Platform. Nothing at the first station for a brief without images: its
|
|
7606
|
+
* Workpiece is its brief alone, in the instructions.
|
|
6358
7607
|
*/
|
|
6359
7608
|
async function writeWorkpiece(workspace, task, documents) {
|
|
6360
|
-
const { handOffs } = task.workpiece;
|
|
6361
|
-
if (handOffs.length === 0) return
|
|
7609
|
+
const { handOffs, images } = task.workpiece;
|
|
7610
|
+
if (handOffs.length === 0 && images.length === 0) return NOTHING_WRITTEN;
|
|
6362
7611
|
const dir = join(workspace, WORKPIECE_DIR);
|
|
6363
7612
|
await mkdir(dir, { mode: 448 });
|
|
6364
|
-
await
|
|
6365
|
-
const
|
|
7613
|
+
const briefImages = await writeBriefImages(workspace, task, documents);
|
|
7614
|
+
const text = [task.workpiece.brief, ...briefImages.length > 0 ? ["## Images", imageLinks(briefImages, WORKPIECE_DIR)] : []].join("\n\n");
|
|
7615
|
+
await writeFile(join(dir, "brief.md"), `${text}\n`, { mode: 384 });
|
|
7616
|
+
const brief = {
|
|
6366
7617
|
path: `${WORKPIECE_DIR}/brief.md`,
|
|
6367
|
-
description:
|
|
6368
|
-
|
|
7618
|
+
description: `the brief${besideIt(briefImages.length)}`,
|
|
7619
|
+
images: briefImages
|
|
7620
|
+
};
|
|
7621
|
+
const files = [];
|
|
6369
7622
|
for (const [index, made] of handOffs.entries()) {
|
|
6370
7623
|
const document = await documents.handOff(task.task.id, made.id);
|
|
6371
|
-
const
|
|
6372
|
-
|
|
7624
|
+
const beside = {
|
|
7625
|
+
folder: WORKPIECE_DIR,
|
|
7626
|
+
stem: `${index + 1}-${slugOf(made.workstation.name)}`
|
|
7627
|
+
};
|
|
7628
|
+
const written = await writeHandOff(workspace, beside, document, documents);
|
|
6373
7629
|
files.push({
|
|
6374
|
-
path: `${WORKPIECE_DIR}/${stem}.md`,
|
|
6375
|
-
description: `the Hand-off of ${made.agent.name} at the Workstation "${made.workstation.name}"${
|
|
7630
|
+
path: `${WORKPIECE_DIR}/${beside.stem}.md`,
|
|
7631
|
+
description: `the Hand-off of ${made.agent.name} at the Workstation "${made.workstation.name}"${besideIt(written)}`
|
|
6376
7632
|
});
|
|
6377
7633
|
}
|
|
6378
|
-
return
|
|
7634
|
+
return {
|
|
7635
|
+
brief,
|
|
7636
|
+
handOffs: files
|
|
7637
|
+
};
|
|
7638
|
+
}
|
|
7639
|
+
/**
|
|
7640
|
+
* Writes the images of the brief into `workpiece/` beside `brief.md`, as the message starting the
|
|
7641
|
+
* Session names them, each read from the image store at an address fetched for the Task.
|
|
7642
|
+
*/
|
|
7643
|
+
async function writeBriefImages(workspace, task, documents) {
|
|
7644
|
+
const { images } = task.workpiece;
|
|
7645
|
+
if (images.length === 0) return [];
|
|
7646
|
+
const fetched = await documents.brief(task.task.id);
|
|
7647
|
+
return writeImages(workspace, {
|
|
7648
|
+
folder: WORKPIECE_DIR,
|
|
7649
|
+
stem: "brief"
|
|
7650
|
+
}, images, fromStore(documents, images, fetched.images, "the brief's image"));
|
|
7651
|
+
}
|
|
7652
|
+
/**
|
|
7653
|
+
* How `writeImages` reads `named`'s bytes from the image store: each at the address `addressed`
|
|
7654
|
+
* gives the same key (a document fetched for its addresses), or each at its own (`addressed`
|
|
7655
|
+
* omitted: `named` carries them). Rejects naming `what` ("the brief's image") for one it has no
|
|
7656
|
+
* address for.
|
|
7657
|
+
*/
|
|
7658
|
+
function fromStore(documents, named, addressed, what) {
|
|
7659
|
+
return (index) => {
|
|
7660
|
+
const image = named[index];
|
|
7661
|
+
const address = addressed === void 0 ? image?.address : addressed.find(({ key }) => key === image?.key)?.address;
|
|
7662
|
+
if (!address) return Promise.reject(/* @__PURE__ */ new Error(`${what} ${index + 1} isn't on the Platform`));
|
|
7663
|
+
return documents.image(address);
|
|
7664
|
+
};
|
|
6379
7665
|
}
|
|
6380
7666
|
/**
|
|
6381
7667
|
* Writes the Hand-off under check into an Inspector's Workspace (spec story 24): `hand-off/` holds
|
|
@@ -6384,71 +7670,109 @@ async function writeWorkpiece(workspace, task, documents) {
|
|
|
6384
7670
|
*/
|
|
6385
7671
|
async function writeHandOffUnderCheck(workspace, task, documents) {
|
|
6386
7672
|
const { handOff } = task.check;
|
|
6387
|
-
|
|
6388
|
-
await
|
|
6389
|
-
const images = await writeHandOff(
|
|
7673
|
+
await mkdir(join(workspace, UNDER_CHECK_DIR), { mode: 448 });
|
|
7674
|
+
const document = await documents.handOff(task.task.id, handOff.id);
|
|
7675
|
+
const images = await writeHandOff(workspace, {
|
|
7676
|
+
folder: UNDER_CHECK_DIR,
|
|
7677
|
+
stem: "hand-off"
|
|
7678
|
+
}, document, documents);
|
|
6390
7679
|
return {
|
|
6391
7680
|
path: `${UNDER_CHECK_DIR}/hand-off.md`,
|
|
6392
|
-
description: `the Hand-off of ${handOff.agent.name} at the Workstation "${task.workstation.name}"${
|
|
7681
|
+
description: `the Hand-off of ${handOff.agent.name} at the Workstation "${task.workstation.name}"${besideIt(images)}`
|
|
6393
7682
|
};
|
|
6394
7683
|
}
|
|
6395
7684
|
/**
|
|
6396
7685
|
* Writes the Hand-off of the attempt a Rework follows into its Workspace (spec story 33), if that
|
|
6397
7686
|
* attempt handed off: `previous-attempt/` holds it as a Markdown file, its references listed at its
|
|
6398
|
-
* end, with its images beside it,
|
|
6399
|
-
*
|
|
7687
|
+
* end, with its images beside it, and the images beside the annotations of the check it failed
|
|
7688
|
+
* (`annotations-1.png`…, #184), all fetched from the Platform. Returns them, for the instructions
|
|
7689
|
+
* to point at, or null.
|
|
6400
7690
|
*/
|
|
6401
7691
|
async function writePreviousAttempt(workspace, task, documents) {
|
|
6402
7692
|
const made = task.previous?.handOff;
|
|
6403
7693
|
if (!made) return null;
|
|
6404
|
-
|
|
6405
|
-
await
|
|
6406
|
-
const images = await writeHandOff(
|
|
7694
|
+
await mkdir(join(workspace, PREVIOUS_ATTEMPT_DIR), { mode: 448 });
|
|
7695
|
+
const document = await documents.handOff(task.task.id, made.id);
|
|
7696
|
+
const images = await writeHandOff(workspace, {
|
|
7697
|
+
folder: PREVIOUS_ATTEMPT_DIR,
|
|
7698
|
+
stem: "hand-off"
|
|
7699
|
+
}, document, documents);
|
|
7700
|
+
const failed = task.previous?.check?.id;
|
|
6407
7701
|
return {
|
|
6408
7702
|
path: `${PREVIOUS_ATTEMPT_DIR}/hand-off.md`,
|
|
6409
|
-
description:
|
|
7703
|
+
description: besideIt(images),
|
|
7704
|
+
annotationImages: failed ? await writeAnnotationImages(workspace, task, failed, documents) : []
|
|
6410
7705
|
};
|
|
6411
7706
|
}
|
|
6412
7707
|
/**
|
|
6413
|
-
* Writes
|
|
6414
|
-
* `
|
|
7708
|
+
* Writes the images beside the annotations of check `checkId`, which failed the attempt a Rework
|
|
7709
|
+
* follows, into `previous-attempt/` beside its Hand-off, as `annotations-1.png`…, fetched from the
|
|
7710
|
+
* Platform and read from the image store. Returns them as written.
|
|
6415
7711
|
*/
|
|
6416
|
-
async function
|
|
6417
|
-
const images =
|
|
6418
|
-
|
|
6419
|
-
|
|
6420
|
-
|
|
6421
|
-
|
|
7712
|
+
async function writeAnnotationImages(workspace, task, checkId, documents) {
|
|
7713
|
+
const { images } = await documents.annotations(task.task.id, checkId);
|
|
7714
|
+
return writeImages(workspace, {
|
|
7715
|
+
folder: PREVIOUS_ATTEMPT_DIR,
|
|
7716
|
+
stem: "annotations"
|
|
7717
|
+
}, images, fromStore(documents, images, void 0, "the annotations' image"));
|
|
7718
|
+
}
|
|
7719
|
+
/**
|
|
7720
|
+
* Writes the images of the Owner's Verdicts on the Workpiece (#183: a Send back's or a Rework's
|
|
7721
|
+
* guidance's, an Approve's note's) into `workpiece/`, each Verdict's named after its Ticket
|
|
7722
|
+
* (`T-3-verdict-1.png`…), read from the image store at addresses fetched for the Task. Returns them
|
|
7723
|
+
* as written, for the instructions to name under what the Owner decided and the first prompt to
|
|
7724
|
+
* carry those of the Verdict that routed the Workpiece to the Task.
|
|
7725
|
+
*/
|
|
7726
|
+
async function writeVerdictImages(workspace, task, documents) {
|
|
7727
|
+
const named = task.verdicts.map(({ verdict }) => imagesOf(verdict));
|
|
7728
|
+
if (named.every((images) => images.length === 0)) return [];
|
|
7729
|
+
const fetched = await documents.verdicts(task.task.id);
|
|
7730
|
+
const written = [];
|
|
7731
|
+
for (const [index, { ticket }] of task.verdicts.entries()) {
|
|
7732
|
+
const images = named[index] ?? [];
|
|
7733
|
+
const label = ticketLabel(ticket);
|
|
7734
|
+
const beside = {
|
|
7735
|
+
folder: WORKPIECE_DIR,
|
|
7736
|
+
stem: `${label}-verdict`
|
|
7737
|
+
};
|
|
7738
|
+
written.push(await writeImages(workspace, beside, images, fromStore(documents, images, fetched.images, `the Verdict on ${label}'s image`)));
|
|
6422
7739
|
}
|
|
7740
|
+
return written;
|
|
7741
|
+
}
|
|
7742
|
+
/**
|
|
7743
|
+
* Writes a Hand-off into `workspace` as the document `beside` names, `<stem>.md` in its folder, its
|
|
7744
|
+
* images beside it (`writeImages`, their bytes read from the image store at the addresses the
|
|
7745
|
+
* document names) and linked under its text, and its references listed at its end. Returns how
|
|
7746
|
+
* many images it has.
|
|
7747
|
+
*/
|
|
7748
|
+
async function writeHandOff(workspace, beside, document, documents) {
|
|
7749
|
+
const images = await writeImages(workspace, beside, document.images, fromStore(documents, document.images, void 0, "the Hand-off's image"));
|
|
6423
7750
|
const references = document.references.map(({ label, target }) => `- ${label}: ${target}`);
|
|
6424
7751
|
const text = [
|
|
6425
7752
|
document.markdown,
|
|
6426
|
-
...images.length > 0 ? ["## Images", images.
|
|
7753
|
+
...images.length > 0 ? ["## Images", imageLinks(images, beside.folder)] : [],
|
|
6427
7754
|
...references.length > 0 ? ["## References", references.join("\n")] : []
|
|
6428
7755
|
].join("\n\n");
|
|
6429
|
-
await writeFile(join(
|
|
7756
|
+
await writeFile(join(workspace, beside.folder, `${beside.stem}.md`), `${text}\n`, { mode: 384 });
|
|
6430
7757
|
return images.length;
|
|
6431
7758
|
}
|
|
6432
|
-
/** ", with its image beside it", for a Hand-off file with `images` images. */
|
|
6433
|
-
function beside(images) {
|
|
6434
|
-
if (images === 0) return "";
|
|
6435
|
-
return `, with its ${images === 1 ? "image" : `${images} images`} beside it`;
|
|
6436
|
-
}
|
|
6437
7759
|
/** A Workstation's name as part of a file name: "Draft PR" → "draft-pr". */
|
|
6438
7760
|
function slugOf(name) {
|
|
6439
7761
|
return name.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "") || "hand-off";
|
|
6440
7762
|
}
|
|
6441
7763
|
/**
|
|
6442
|
-
* The Agent's instructions for a Session
|
|
6443
|
-
* (`launch.ts`), the same on each launch of the Session. For a Workstation
|
|
6444
|
-
* own instructions, the brief
|
|
6445
|
-
*
|
|
6446
|
-
*
|
|
6447
|
-
* the
|
|
6448
|
-
*
|
|
6449
|
-
*
|
|
7764
|
+
* The Agent's instructions for a Session in `workspace`, which its Runtime gets as the Runtime
|
|
7765
|
+
* driver launches it (`launch.ts`), the same on each launch of the Session. For a Workstation
|
|
7766
|
+
* Task's work: the Agent's own instructions, the brief (naming each of its images where it lies in
|
|
7767
|
+
* the Workspace), the repositories checked out, if any, the `workpiece` files so far, if any, what
|
|
7768
|
+
* went wrong with the attempt before it if it is a Rework or a round a Verdict started (pointing at
|
|
7769
|
+
* its Hand-off, `previous`), what the Owner decided about the Workpiece so far, how its Hand-off is
|
|
7770
|
+
* checked on the Belt out of its Workstation if it is, and how to hand off. For a check: the
|
|
7771
|
+
* Inspector's instructions, the Belt's criteria, the Hand-off under check (`underCheck`), the brief
|
|
7772
|
+
* with its images, the repositories checked out, if any, and the Workpiece so far, and how to
|
|
7773
|
+
* judge.
|
|
6450
7774
|
*/
|
|
6451
|
-
function instructionsFor(task, { workpiece =
|
|
7775
|
+
function instructionsFor(task, workspace, { workpiece = NOTHING_WRITTEN, underCheck = null, previous = null, verdicts = [] } = {}) {
|
|
6452
7776
|
const { agent, pipeline, workstation } = task;
|
|
6453
7777
|
const instructions = agent.instructions.trim() || "You have none beyond the brief: work from the brief alone.";
|
|
6454
7778
|
if (task.role === "check") {
|
|
@@ -6470,11 +7794,8 @@ function instructionsFor(task, { workpiece = [], underCheck = null, previous = n
|
|
|
6470
7794
|
"",
|
|
6471
7795
|
underCheck ? `It is \`${underCheck.path}\`: ${underCheck.description}. Its references, if any, are listed at its end.` : `It is ${check.handOff.agent.name}'s Hand-off.`,
|
|
6472
7796
|
"",
|
|
6473
|
-
|
|
6474
|
-
|
|
6475
|
-
task.workpiece.brief,
|
|
6476
|
-
"",
|
|
6477
|
-
...checkedOut(task),
|
|
7797
|
+
...theBrief(task, workpiece),
|
|
7798
|
+
...checkedOut(task, workspace),
|
|
6478
7799
|
...soFar(workpiece, "the Hand-off under check builds on theirs"),
|
|
6479
7800
|
"## How to judge",
|
|
6480
7801
|
"",
|
|
@@ -6482,7 +7803,7 @@ function instructionsFor(task, { workpiece = [], underCheck = null, previous = n
|
|
|
6482
7803
|
""
|
|
6483
7804
|
].join("\n");
|
|
6484
7805
|
}
|
|
6485
|
-
const empty = workpiece.
|
|
7806
|
+
const empty = workpiece.brief === null && !previous && task.repositories.length === 0 && verdicts.every((images) => images.length === 0);
|
|
6486
7807
|
return [
|
|
6487
7808
|
`# ${agent.name}: Workstation Task`,
|
|
6488
7809
|
"",
|
|
@@ -6492,14 +7813,11 @@ function instructionsFor(task, { workpiece = [], underCheck = null, previous = n
|
|
|
6492
7813
|
"",
|
|
6493
7814
|
instructions,
|
|
6494
7815
|
"",
|
|
6495
|
-
|
|
6496
|
-
|
|
6497
|
-
task.workpiece.brief,
|
|
6498
|
-
"",
|
|
6499
|
-
...checkedOut(task),
|
|
7816
|
+
...theBrief(task, workpiece),
|
|
7817
|
+
...checkedOut(task, workspace),
|
|
6500
7818
|
...soFar(workpiece, "your work carries theirs on"),
|
|
6501
7819
|
...lastTime(task, previous),
|
|
6502
|
-
...ownersVerdicts(task),
|
|
7820
|
+
...ownersVerdicts(task, verdicts),
|
|
6503
7821
|
...checkedOnward(task),
|
|
6504
7822
|
"## How to hand off",
|
|
6505
7823
|
"",
|
|
@@ -6513,11 +7831,11 @@ function instructionsFor(task, { workpiece = [], underCheck = null, previous = n
|
|
|
6513
7831
|
}
|
|
6514
7832
|
/**
|
|
6515
7833
|
* The instructions' section on what went wrong with the attempt this one follows (spec story 33):
|
|
6516
|
-
* which attempt this is, why the last one didn't get through (the check's annotations,
|
|
6517
|
-
* failed), and where its Hand-off is, if it made one. A round
|
|
6518
|
-
* Workpiece's latest attempt at the Workstation, and says so; when the
|
|
6519
|
-
* back after that attempt got through (`accepted`), it says that instead.
|
|
6520
|
-
* arrival started.
|
|
7834
|
+
* which attempt this is, why the last one didn't get through (the check's annotations, with where
|
|
7835
|
+
* the images beside them are, or why it failed), and where its Hand-off is, if it made one. A round
|
|
7836
|
+
* a Verdict started follows the Workpiece's latest attempt at the Workstation, and says so; when the
|
|
7837
|
+
* Owner sent the Workpiece back after that attempt got through (`accepted`), it says that instead.
|
|
7838
|
+
* None for a round its arrival started.
|
|
6521
7839
|
*/
|
|
6522
7840
|
function lastTime(task, handOff) {
|
|
6523
7841
|
const { attempt, previous } = task;
|
|
@@ -6542,7 +7860,8 @@ function lastTime(task, handOff) {
|
|
|
6542
7860
|
`${previous.check.inspector.name}'s annotations:`,
|
|
6543
7861
|
"",
|
|
6544
7862
|
previous.check.annotations,
|
|
6545
|
-
""
|
|
7863
|
+
"",
|
|
7864
|
+
...annotatedWith(previous.check.inspector.name, handOff?.annotationImages ?? [])
|
|
6546
7865
|
] : [
|
|
6547
7866
|
`Why ${what}:`,
|
|
6548
7867
|
"",
|
|
@@ -6552,9 +7871,25 @@ function lastTime(task, handOff) {
|
|
|
6552
7871
|
...askedTheOwner(previous)
|
|
6553
7872
|
];
|
|
6554
7873
|
}
|
|
6555
|
-
/**
|
|
7874
|
+
/**
|
|
7875
|
+
* Where the images an Inspector put beside its annotations are (#184), named from the Workspace's
|
|
7876
|
+
* root under the annotations; nothing when there are none.
|
|
7877
|
+
*/
|
|
7878
|
+
function annotatedWith(inspector, images) {
|
|
7879
|
+
if (images.length === 0) return [];
|
|
7880
|
+
return [
|
|
7881
|
+
`${inspector}'s ${images.length === 1 ? "image" : `${images.length} images`} with the annotations, beside that Hand-off:`,
|
|
7882
|
+
"",
|
|
7883
|
+
imageLinks(images, ""),
|
|
7884
|
+
""
|
|
7885
|
+
];
|
|
7886
|
+
}
|
|
7887
|
+
/**
|
|
7888
|
+
* The questions the previous attempt asked the Owner, with their replies (spec story 61), and how
|
|
7889
|
+
* many images each reply came with (#186).
|
|
7890
|
+
*/
|
|
6556
7891
|
function askedTheOwner(previous) {
|
|
6557
|
-
return previous.help.flatMap(({ question, reply }, index) => [
|
|
7892
|
+
return previous.help.flatMap(({ question, reply, replyImages }, index) => [
|
|
6558
7893
|
`### Attempt ${previous.attempt} asked the Owner${previous.help.length > 1 ? ` (question ${index + 1})` : ""}`,
|
|
6559
7894
|
"",
|
|
6560
7895
|
question,
|
|
@@ -6563,30 +7898,57 @@ function askedTheOwner(previous) {
|
|
|
6563
7898
|
"The Owner replied:",
|
|
6564
7899
|
"",
|
|
6565
7900
|
reply,
|
|
6566
|
-
""
|
|
7901
|
+
"",
|
|
7902
|
+
...replyImagesWent(replyImages)
|
|
6567
7903
|
]
|
|
6568
7904
|
]);
|
|
6569
7905
|
}
|
|
6570
7906
|
/**
|
|
7907
|
+
* What an Agent after a reply is told of the images it came with (#186, ADR-0025): how many, and
|
|
7908
|
+
* that they went only to the Session it resumed, never down the line. Nothing for a reply in words
|
|
7909
|
+
* alone.
|
|
7910
|
+
*/
|
|
7911
|
+
function replyImagesWent(count) {
|
|
7912
|
+
if (count === 0) return [];
|
|
7913
|
+
return [`The reply came with ${count === 1 ? "an image" : `${count} images`}, which went only to the Session it resumed.`, ""];
|
|
7914
|
+
}
|
|
7915
|
+
/**
|
|
6571
7916
|
* The instructions' section on what the Owner decided about the Workpiece so far (spec story 37):
|
|
6572
7917
|
* each of their Verdicts on its Tickets, in the order given, with the guidance, note or reply it
|
|
6573
|
-
* carries
|
|
7918
|
+
* carries, and the images beside those words, each named where it is in the Workspace (`written`,
|
|
7919
|
+
* #183), or, for a reply, how many it came with, which went to the Session it resumed alone (#186).
|
|
7920
|
+
* None before the Owner gave one.
|
|
6574
7921
|
*/
|
|
6575
|
-
function ownersVerdicts(task) {
|
|
7922
|
+
function ownersVerdicts(task, written) {
|
|
6576
7923
|
if (task.verdicts.length === 0) return [];
|
|
6577
7924
|
return [
|
|
6578
7925
|
"## What the Owner decided",
|
|
6579
7926
|
"",
|
|
6580
7927
|
"The Owner ruled on this Workpiece's Tickets as it came down the line. Follow what they decided, in the order they decided it:",
|
|
6581
7928
|
"",
|
|
6582
|
-
...task.verdicts.flatMap(({ ticket, verdict }) => [
|
|
7929
|
+
...task.verdicts.flatMap(({ ticket, verdict }, index) => [
|
|
6583
7930
|
`### ${ticketLabel(ticket)} at "${ticket.workstation.name}": ${VERDICT_NAMES[verdict.kind]}`,
|
|
6584
7931
|
"",
|
|
6585
7932
|
wordsOf(verdict) ?? withoutWords(verdict, ticket.workstation.name),
|
|
6586
|
-
""
|
|
7933
|
+
"",
|
|
7934
|
+
...shownWith(written[index] ?? []),
|
|
7935
|
+
...verdict.kind === "reply" ? replyImagesWent(verdict.images?.length ?? 0) : []
|
|
6587
7936
|
])
|
|
6588
7937
|
];
|
|
6589
7938
|
}
|
|
7939
|
+
/**
|
|
7940
|
+
* Where the images beside a Verdict's words are in the Workspace, named from its root under the
|
|
7941
|
+
* words; nothing when there are none.
|
|
7942
|
+
*/
|
|
7943
|
+
function shownWith(images) {
|
|
7944
|
+
if (images.length === 0) return [];
|
|
7945
|
+
return [
|
|
7946
|
+
images.length === 1 ? "Its image is a file in this Workspace:" : `Its ${images.length} images are files in this Workspace:`,
|
|
7947
|
+
"",
|
|
7948
|
+
imageLinks(images, ""),
|
|
7949
|
+
""
|
|
7950
|
+
];
|
|
7951
|
+
}
|
|
6590
7952
|
/** What a Verdict that carries no words of the Owner's decided, said for the Agent. */
|
|
6591
7953
|
function withoutWords(verdict, workstation) {
|
|
6592
7954
|
let said = "";
|
|
@@ -6619,10 +7981,13 @@ function checkedOnward(task) {
|
|
|
6619
7981
|
];
|
|
6620
7982
|
}
|
|
6621
7983
|
/**
|
|
6622
|
-
* The instructions' section on the repositories checked out into
|
|
6623
|
-
* folder, in the order its policy names them
|
|
7984
|
+
* The instructions' section on the repositories checked out into `workspace` (#90), each in its
|
|
7985
|
+
* folder, in the order its policy names them, and where the routing AGENTS.md lists their own
|
|
7986
|
+
* instructions if any of them carries some (#173, `routing-skills.ts`): it is written anew on each
|
|
7987
|
+
* launch, and the instructions are fixed when the Session starts, so they name it whenever there
|
|
7988
|
+
* are repositories, "if it is there". None when its policy names none.
|
|
6624
7989
|
*/
|
|
6625
|
-
function checkedOut({ repositories }) {
|
|
7990
|
+
function checkedOut({ repositories }, workspace) {
|
|
6626
7991
|
if (repositories.length === 0) return [];
|
|
6627
7992
|
return [
|
|
6628
7993
|
"## The repositories in your Workspace",
|
|
@@ -6630,15 +7995,17 @@ function checkedOut({ repositories }) {
|
|
|
6630
7995
|
...repositories.map(({ url, ref, folder }) => `- \`${folder}/\`: ${url} at ${refWords(ref)}`),
|
|
6631
7996
|
"",
|
|
6632
7997
|
"Each is a fresh checkout in a folder of its own, made with this machine's own git access, which you fetch and push with too. This folder itself is no repository.",
|
|
7998
|
+
"",
|
|
7999
|
+
`If \`${join(workspace, ROUTING_AGENTS_FILE)}\` is there, it lists these repositories' own instructions for agents: read it before you work in one of them.`,
|
|
6633
8000
|
""
|
|
6634
8001
|
];
|
|
6635
8002
|
}
|
|
6636
8003
|
/**
|
|
6637
|
-
* A chat's instructions
|
|
6638
|
-
* repositories checked out into the Workspace, and that the Owner is talking
|
|
6639
|
-
* Nothing of any Workpiece.
|
|
8004
|
+
* A chat's instructions, in its Workspace `workspace`: who the Agent is and where it has its place,
|
|
8005
|
+
* its instructions, the repositories checked out into the Workspace, and that the Owner is talking
|
|
8006
|
+
* with it directly. Nothing of any Workpiece.
|
|
6640
8007
|
*/
|
|
6641
|
-
function chatInstructionsFor(say) {
|
|
8008
|
+
function chatInstructionsFor(say, workspace) {
|
|
6642
8009
|
const { agent, place } = say;
|
|
6643
8010
|
const where = place === null ? "You have no place on a line yet." : place.kind === "workstation" ? `You staff the Workstation "${place.workstation.name}" of the Pipeline "${place.pipeline.name}".` : `You inspect the Hand-offs on the Belt from the Workstation "${place.from.name}" to "${place.to.name}" of the Pipeline "${place.pipeline.name}".`;
|
|
6644
8011
|
const empty = say.repositories.length === 0;
|
|
@@ -6651,42 +8018,126 @@ function chatInstructionsFor(say) {
|
|
|
6651
8018
|
"",
|
|
6652
8019
|
agent.instructions.trim() || "You have none: answer as yourself.",
|
|
6653
8020
|
"",
|
|
6654
|
-
...checkedOut(say),
|
|
8021
|
+
...checkedOut(say, workspace),
|
|
6655
8022
|
"## This chat",
|
|
6656
8023
|
"",
|
|
6657
8024
|
"No Workpiece comes with it, and nothing you do here moves on along a line. Answer the Owner, look at what they ask about with this machine's own access, and say what you find.",
|
|
6658
8025
|
""
|
|
6659
8026
|
].join("\n");
|
|
6660
8027
|
}
|
|
6661
|
-
/**
|
|
6662
|
-
|
|
6663
|
-
|
|
6664
|
-
|
|
6665
|
-
|
|
6666
|
-
|
|
6667
|
-
|
|
6668
|
-
|
|
6669
|
-
|
|
6670
|
-
|
|
6671
|
-
|
|
6672
|
-
|
|
8028
|
+
/**
|
|
8029
|
+
* What the Agent is told in a turn of a chat: the Owner's message, as they wrote it; and the images
|
|
8030
|
+
* it came with (#187), inside this prompt where its Runtime takes them (`prompt-images.ts`), each a
|
|
8031
|
+
* file in the chat's Workspace beside the turn (`chat-turns/<turn>/message-1.png`…), or, when they
|
|
8032
|
+
* couldn't be kept, how many there were. What follows the Owner's words, the protocol's `chatSaid`
|
|
8033
|
+
* leaves out again, for the Owner reading the chat back.
|
|
8034
|
+
*/
|
|
8035
|
+
function chatPromptFor(message, { written, lost } = {
|
|
8036
|
+
written: [],
|
|
8037
|
+
lost: 0
|
|
8038
|
+
}) {
|
|
8039
|
+
const which = lost === 1 ? "an image" : `${lost} images`;
|
|
8040
|
+
return {
|
|
8041
|
+
text: lost > 0 ? `${message}\n\nThis message came with ${which}, which couldn't be kept on this machine for you.` : message,
|
|
8042
|
+
images: [{
|
|
8043
|
+
of: CHAT_MESSAGE_IMAGES,
|
|
8044
|
+
images: written
|
|
8045
|
+
}]
|
|
8046
|
+
};
|
|
6673
8047
|
}
|
|
6674
8048
|
/**
|
|
6675
|
-
*
|
|
6676
|
-
*
|
|
8049
|
+
* The instructions' section on the brief: its words, and its images, each named where it is in the
|
|
8050
|
+
* Workspace, beside a copy of the brief.
|
|
6677
8051
|
*/
|
|
6678
|
-
function
|
|
6679
|
-
const
|
|
8052
|
+
function theBrief(task, { brief }) {
|
|
8053
|
+
const images = brief?.images ?? [];
|
|
6680
8054
|
return [
|
|
8055
|
+
"## The brief",
|
|
8056
|
+
"",
|
|
8057
|
+
task.workpiece.brief,
|
|
8058
|
+
"",
|
|
8059
|
+
...brief && images.length > 0 ? [
|
|
8060
|
+
`${images.length === 1 ? "Its image is a file" : `Its ${images.length} images are files`} in this Workspace, beside a copy of the brief, \`${brief.path}\`:`,
|
|
8061
|
+
"",
|
|
8062
|
+
imageLinks(images, ""),
|
|
8063
|
+
""
|
|
8064
|
+
] : []
|
|
8065
|
+
];
|
|
8066
|
+
}
|
|
8067
|
+
/**
|
|
8068
|
+
* The instructions' section on the Workpiece so far, pointing at its files: once earlier stations'
|
|
8069
|
+
* Hand-offs were accepted.
|
|
8070
|
+
*/
|
|
8071
|
+
function soFar({ brief, handOffs }, because) {
|
|
8072
|
+
const files = brief ? [brief, ...handOffs] : handOffs;
|
|
8073
|
+
return handOffs.length === 0 ? [] : [
|
|
8074
|
+
"## The Workpiece so far",
|
|
8075
|
+
"",
|
|
8076
|
+
`Earlier Workstations of this Pipeline worked on this Workpiece before you. Its brief and every Hand-off accepted so far are files in \`${WORKPIECE_DIR}/\`, in Pipeline order:`,
|
|
8077
|
+
"",
|
|
8078
|
+
...files.map((file, index) => `${index + 1}. \`${file.path}\`: ${file.description}.`),
|
|
8079
|
+
"",
|
|
8080
|
+
`Read them before you start: ${because}.`,
|
|
8081
|
+
""
|
|
8082
|
+
];
|
|
8083
|
+
}
|
|
8084
|
+
/**
|
|
8085
|
+
* What the Agent is told when its Session resumes with the Owner's reply to its question: the
|
|
8086
|
+
* reply, and to carry on; and the images the reply came with (#186), inside this prompt where its
|
|
8087
|
+
* Runtime takes them (`prompt-images.ts`), each a file beside the question in its Workspace.
|
|
8088
|
+
*/
|
|
8089
|
+
function replyPromptFor(task, reply, { written, lost } = {
|
|
8090
|
+
written: [],
|
|
8091
|
+
lost: 0
|
|
8092
|
+
}) {
|
|
8093
|
+
const { name: file } = INSTRUCTIONS_ARE[task.agent.runtime];
|
|
8094
|
+
const lines = [
|
|
6681
8095
|
"The Owner replied to your question:",
|
|
6682
8096
|
"",
|
|
6683
8097
|
reply,
|
|
6684
8098
|
"",
|
|
6685
8099
|
`Carry on with the work where you left off, then hand off with the ${HAND_OFF_TOOL} tool of the ${FACTORY_MCP_NAME} MCP server, as ${file} explains.`
|
|
6686
|
-
]
|
|
8100
|
+
];
|
|
8101
|
+
if (lost > 0) {
|
|
8102
|
+
const which = lost === 1 ? "an image" : `${lost} images`;
|
|
8103
|
+
lines.push("", `The reply came with ${which}, which couldn't be kept on this machine for you.`);
|
|
8104
|
+
}
|
|
8105
|
+
return {
|
|
8106
|
+
text: lines.join("\n"),
|
|
8107
|
+
images: [{
|
|
8108
|
+
of: "the reply",
|
|
8109
|
+
images: written
|
|
8110
|
+
}]
|
|
8111
|
+
};
|
|
8112
|
+
}
|
|
8113
|
+
/**
|
|
8114
|
+
* What the Agent is told when its Session starts, a Task's or a Check's (the rest is in its
|
|
8115
|
+
* instructions), and the images that come with it (spec #172, stories 29 and 32), as `files` wrote
|
|
8116
|
+
* them into its Workspace, inside this first prompt where its Runtime takes them
|
|
8117
|
+
* (`prompt-images.ts`), and in no later one: for the Task a Verdict routed the Workpiece to
|
|
8118
|
+
* (`routedHere`), that Verdict's guidance's or note's (#183), then the brief's, so that a brief
|
|
8119
|
+
* with as many images as a prompt takes can't push the Verdict's out of it. An Agent after it has
|
|
8120
|
+
* that Verdict's images as files only.
|
|
8121
|
+
*/
|
|
8122
|
+
function promptFor(task, { workpiece = NOTHING_WRITTEN, verdicts = [] } = {}) {
|
|
8123
|
+
const images = [];
|
|
8124
|
+
if (task.role === "work") {
|
|
8125
|
+
for (const [index, { verdict, routedHere }] of task.verdicts.entries()) if (routedHere) images.push({
|
|
8126
|
+
of: verdict.kind === "approve" ? "the Owner's note" : "the Owner's guidance",
|
|
8127
|
+
images: verdicts[index] ?? []
|
|
8128
|
+
});
|
|
8129
|
+
}
|
|
8130
|
+
images.push({
|
|
8131
|
+
of: "the brief",
|
|
8132
|
+
images: workpiece.brief?.images ?? []
|
|
8133
|
+
});
|
|
8134
|
+
return {
|
|
8135
|
+
text: firstWords(task),
|
|
8136
|
+
images
|
|
8137
|
+
};
|
|
6687
8138
|
}
|
|
6688
|
-
/**
|
|
6689
|
-
function
|
|
8139
|
+
/** The words of a Session's first prompt. */
|
|
8140
|
+
function firstWords(task) {
|
|
6690
8141
|
const { where, name: file } = INSTRUCTIONS_ARE[task.agent.runtime];
|
|
6691
8142
|
if (task.role === "check") return `Your instructions, the Belt's criteria and the Hand-off to check are ${where}. Judge the Hand-off against the criteria, then record your judgement with the ${JUDGE_TOOL} tool of the ${FACTORY_MCP_NAME} MCP server, as ${file} explains.`;
|
|
6692
8143
|
return `Your instructions and this Workpiece's brief are ${where}${task.workpiece.handOffs.length === 0 ? "" : `, and it points you to what the Workstations before yours handed off`}.${!task.previous ? "" : task.previous.status === "accepted" ? ` This is attempt ${task.attempt.number} of ${task.attempt.of} of a fresh round: the Owner sent the Workpiece back here, as ${file} says.` : ` This is attempt ${task.attempt.number} of ${task.attempt.of}${task.cause === "verdict" ? " of a fresh round the Owner started" : ""}: ${file} says what went wrong with the last one.`}${task.verdicts.length === 0 ? "" : ` Follow what the Owner decided about it, as ${file} says.`} Do the work the brief asks for, then hand off with the ${HAND_OFF_TOOL} tool of the ${FACTORY_MCP_NAME} MCP server, as ${file} explains.`;
|
|
@@ -6773,6 +8224,14 @@ async function isThere(workspace) {
|
|
|
6773
8224
|
return false;
|
|
6774
8225
|
}
|
|
6775
8226
|
}
|
|
8227
|
+
/** What the images `say`'s message came with were handed for: its turn. */
|
|
8228
|
+
function handedFor(say) {
|
|
8229
|
+
return {
|
|
8230
|
+
of: "chat",
|
|
8231
|
+
chatId: say.chatId,
|
|
8232
|
+
turn: say.turn
|
|
8233
|
+
};
|
|
8234
|
+
}
|
|
6776
8235
|
/** What a turn whose Session never opened used. */
|
|
6777
8236
|
const NONE = {
|
|
6778
8237
|
input: 0,
|
|
@@ -6853,7 +8312,7 @@ function createChatRunner(options) {
|
|
|
6853
8312
|
if (stop.aborted) return { failed: { kind: "aborted" } };
|
|
6854
8313
|
const { workspace } = chat;
|
|
6855
8314
|
const resume = chat.sessionId ?? void 0;
|
|
6856
|
-
const instructions = (resume === void 0 ? null : chat.instructions) ?? chatInstructionsFor(say);
|
|
8315
|
+
const instructions = (resume === void 0 ? null : chat.instructions) ?? chatInstructionsFor(say, workspace);
|
|
6857
8316
|
try {
|
|
6858
8317
|
return {
|
|
6859
8318
|
session: await options.driver.open({
|
|
@@ -6987,6 +8446,7 @@ function createChatRunner(options) {
|
|
|
6987
8446
|
instructions
|
|
6988
8447
|
});
|
|
6989
8448
|
}
|
|
8449
|
+
const images = await imagesOf(say, workspace);
|
|
6990
8450
|
await reportStarted(say, fresh);
|
|
6991
8451
|
onEvent({
|
|
6992
8452
|
type: "chat-turn-started",
|
|
@@ -6998,7 +8458,7 @@ function createChatRunner(options) {
|
|
|
6998
8458
|
recording: opening.recording
|
|
6999
8459
|
});
|
|
7000
8460
|
return {
|
|
7001
|
-
outcome: await opening.run(say.message, stop),
|
|
8461
|
+
outcome: await opening.run(chatPromptFor(say.message, images), stop),
|
|
7002
8462
|
usage: opening.usage()
|
|
7003
8463
|
};
|
|
7004
8464
|
} finally {
|
|
@@ -7007,6 +8467,46 @@ function createChatRunner(options) {
|
|
|
7007
8467
|
await opening.close();
|
|
7008
8468
|
}
|
|
7009
8469
|
};
|
|
8470
|
+
/**
|
|
8471
|
+
* The images `say`'s message came with (#187), written into the turn's Workspace beside the turn
|
|
8472
|
+
* (`chat-turns/<turn>/message-1.png`…), from where the Platform handed them; or how many of them
|
|
8473
|
+
* there were, when they couldn't be.
|
|
8474
|
+
*/
|
|
8475
|
+
const imagesOf = async (say, workspace) => {
|
|
8476
|
+
if (say.handed === void 0) return {
|
|
8477
|
+
written: [],
|
|
8478
|
+
lost: 0
|
|
8479
|
+
};
|
|
8480
|
+
try {
|
|
8481
|
+
return {
|
|
8482
|
+
written: await options.handed.claim(handedFor(say), say.handed, workspace, {
|
|
8483
|
+
folder: posix.join(WORKSPACE_FOLDERS.chatTurns, String(say.turn)),
|
|
8484
|
+
stem: "message"
|
|
8485
|
+
}),
|
|
8486
|
+
lost: 0
|
|
8487
|
+
};
|
|
8488
|
+
} catch (error) {
|
|
8489
|
+
onEvent({
|
|
8490
|
+
type: "problem",
|
|
8491
|
+
message: `Couldn't write the images of turn ${say.turn} of chat ${say.chatId} into its Workspace: ${messageOf(error)}`
|
|
8492
|
+
});
|
|
8493
|
+
return {
|
|
8494
|
+
written: [],
|
|
8495
|
+
lost: say.handed.images.length
|
|
8496
|
+
};
|
|
8497
|
+
}
|
|
8498
|
+
};
|
|
8499
|
+
/** Forgets what was handed for `say`'s turn and those before, as the turn ends. */
|
|
8500
|
+
const forgetHanded = async (say) => {
|
|
8501
|
+
try {
|
|
8502
|
+
await options.handed.forget(handedFor(say));
|
|
8503
|
+
} catch (error) {
|
|
8504
|
+
onEvent({
|
|
8505
|
+
type: "problem",
|
|
8506
|
+
message: `Couldn't let go of the images handed for chat ${say.chatId}: ${messageOf(error)}`
|
|
8507
|
+
});
|
|
8508
|
+
}
|
|
8509
|
+
};
|
|
7010
8510
|
/** Runs one turn of `chat`, from its start to the report of its end. */
|
|
7011
8511
|
const turn = async (chat, say) => {
|
|
7012
8512
|
const underWay = {
|
|
@@ -7055,6 +8555,7 @@ function createChatRunner(options) {
|
|
|
7055
8555
|
usage: null
|
|
7056
8556
|
};
|
|
7057
8557
|
}
|
|
8558
|
+
if (!killed) await forgetHanded(say);
|
|
7058
8559
|
if (!killed && !leaving) await reportEnded(underWay, answered.outcome, answered.usage);
|
|
7059
8560
|
} catch (error) {
|
|
7060
8561
|
onEvent({
|
|
@@ -7079,7 +8580,8 @@ function createChatRunner(options) {
|
|
|
7079
8580
|
sessionId: null,
|
|
7080
8581
|
instructions: null,
|
|
7081
8582
|
opened: false,
|
|
7082
|
-
turns: Promise.resolve()
|
|
8583
|
+
turns: Promise.resolve(),
|
|
8584
|
+
said: 0
|
|
7083
8585
|
};
|
|
7084
8586
|
recalled.turns = recall(recalled, chatId);
|
|
7085
8587
|
chats.set(chatId, recalled);
|
|
@@ -7112,8 +8614,12 @@ function createChatRunner(options) {
|
|
|
7112
8614
|
return {
|
|
7113
8615
|
say(say) {
|
|
7114
8616
|
const chat = known(say.chatId);
|
|
8617
|
+
chat.said = Math.max(chat.said, say.turn);
|
|
7115
8618
|
chat.turns = chat.turns.then(() => turn(chat, say));
|
|
7116
8619
|
},
|
|
8620
|
+
whyNoImages(chatId, number) {
|
|
8621
|
+
return number <= (chats.get(chatId)?.said ?? 0) ? `it took turn ${number} of this chat on already` : null;
|
|
8622
|
+
},
|
|
7117
8623
|
async swept(workspace) {
|
|
7118
8624
|
const chatId = chatOfWorkspace(workspace);
|
|
7119
8625
|
if (chatId === null || killed) return;
|
|
@@ -7150,6 +8656,66 @@ function createChatRunner(options) {
|
|
|
7150
8656
|
};
|
|
7151
8657
|
}
|
|
7152
8658
|
//#endregion
|
|
8659
|
+
//#region src/image-uploads.ts
|
|
8660
|
+
/**
|
|
8661
|
+
* The Platform, or the image store, refused to take the images, which asking again won't change:
|
|
8662
|
+
* the message naming them goes as it is, and the Platform refuses it, saying why.
|
|
8663
|
+
*/
|
|
8664
|
+
var ImagesRefusedError = class extends Error {
|
|
8665
|
+
name = "ImagesRefusedError";
|
|
8666
|
+
};
|
|
8667
|
+
/** How long one request may take before it's given up: a 10 MiB image on a slow line. */
|
|
8668
|
+
const REQUEST_TIMEOUT_MS = 3e5;
|
|
8669
|
+
/** Puts images into the image store of the Platform the Node joined with `credential`. */
|
|
8670
|
+
function platformImageUploads(credential) {
|
|
8671
|
+
return async (images, signal) => {
|
|
8672
|
+
for (const { image, bytes: read } of images) {
|
|
8673
|
+
const bytes = await read();
|
|
8674
|
+
const asked = { images: [{
|
|
8675
|
+
key: image.key,
|
|
8676
|
+
mimeType: image.mimeType,
|
|
8677
|
+
bytes: bytes.length
|
|
8678
|
+
}] };
|
|
8679
|
+
const answered = await send(new URL(NODE_IMAGES_PATH, credential.platformUrl), signal, {
|
|
8680
|
+
method: "POST",
|
|
8681
|
+
headers: {
|
|
8682
|
+
authorization: `Bearer ${credential.credential}`,
|
|
8683
|
+
"content-type": "application/json"
|
|
8684
|
+
},
|
|
8685
|
+
body: JSON.stringify(asked)
|
|
8686
|
+
});
|
|
8687
|
+
const [upload] = nodeImageUploadAnswer.parse(await answered.json()).uploads;
|
|
8688
|
+
if (upload?.key !== image.key) throw new ImagesRefusedError(`The Platform named no place for image ${image.key}`);
|
|
8689
|
+
if (upload.address) await send(new URL(upload.address.url, credential.platformUrl), signal, {
|
|
8690
|
+
method: "PUT",
|
|
8691
|
+
headers: { ...upload.address.headers },
|
|
8692
|
+
body: bytes
|
|
8693
|
+
});
|
|
8694
|
+
}
|
|
8695
|
+
};
|
|
8696
|
+
}
|
|
8697
|
+
/**
|
|
8698
|
+
* Sends one request, resolving with its answer once it succeeded. Rejects with an
|
|
8699
|
+
* `ImagesRefusedError` for a refusal (a 4xx), and an error of its own for anything that may pass
|
|
8700
|
+
* (no answer, a 5xx).
|
|
8701
|
+
*/
|
|
8702
|
+
async function send(url, signal, init) {
|
|
8703
|
+
let response;
|
|
8704
|
+
try {
|
|
8705
|
+
response = await fetch(url, {
|
|
8706
|
+
...init,
|
|
8707
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)])
|
|
8708
|
+
});
|
|
8709
|
+
} catch (error) {
|
|
8710
|
+
throw new Error(`Couldn't reach ${url.origin}: ${messageOf(error)}`, { cause: error });
|
|
8711
|
+
}
|
|
8712
|
+
if (response.ok) return response;
|
|
8713
|
+
const refusal = fetchRefusal.safeParse(await response.json().catch(() => null));
|
|
8714
|
+
const reason = refusal.success ? refusal.data.error : `${url.origin} answered ${response.status}`;
|
|
8715
|
+
if (response.status >= 400 && response.status < 500 && ![408, 429].includes(response.status)) throw new ImagesRefusedError(reason);
|
|
8716
|
+
throw new Error(reason);
|
|
8717
|
+
}
|
|
8718
|
+
//#endregion
|
|
7153
8719
|
//#region src/connector.ts
|
|
7154
8720
|
function write(ws, message) {
|
|
7155
8721
|
ws.send(JSON.stringify(message));
|
|
@@ -7166,7 +8732,9 @@ function backoff(attempt) {
|
|
|
7166
8732
|
/**
|
|
7167
8733
|
* Dials out to the Platform (ADR-0006) and keeps the connection up: says hello, sends heartbeats,
|
|
7168
8734
|
* replays the outbox after each welcome, and reconnects with backoff whenever the connection drops
|
|
7169
|
-
* or goes quiet for longer than the heartbeat timeout.
|
|
8735
|
+
* or goes quiet for longer than the heartbeat timeout. Its messages go one at a time, in the
|
|
8736
|
+
* outbox's order: one naming images waits until they are in the image store, put there from the
|
|
8737
|
+
* outbox (trying again with backoff while that fails), and every message after it waits behind it.
|
|
7170
8738
|
*/
|
|
7171
8739
|
function connectToPlatform(options) {
|
|
7172
8740
|
const { credential, outbox, inbox, onEvent } = options;
|
|
@@ -7182,6 +8750,9 @@ function connectToPlatform(options) {
|
|
|
7182
8750
|
let heartbeatTimer;
|
|
7183
8751
|
let livenessTimer;
|
|
7184
8752
|
let sending = Promise.resolve();
|
|
8753
|
+
let written = /* @__PURE__ */ new Set();
|
|
8754
|
+
let delivering = Promise.resolve();
|
|
8755
|
+
const ending = /* @__PURE__ */ new WeakMap();
|
|
7185
8756
|
let taking = Promise.resolve();
|
|
7186
8757
|
/** Who waits to hear what the Platform made of each message, by message id. */
|
|
7187
8758
|
const waitingForAck = /* @__PURE__ */ new Map();
|
|
@@ -7199,6 +8770,7 @@ function connectToPlatform(options) {
|
|
|
7199
8770
|
const open = () => {
|
|
7200
8771
|
const ws = new WebSocket(url, { headers: { authorization: `Bearer ${credential.credential}` } });
|
|
7201
8772
|
socket = ws;
|
|
8773
|
+
ending.set(ws, new AbortController());
|
|
7202
8774
|
let refused = false;
|
|
7203
8775
|
expectWithin(ws, options.connectTimeoutMs);
|
|
7204
8776
|
ws.on("unexpected-response", (_request, response) => {
|
|
@@ -7209,7 +8781,7 @@ function connectToPlatform(options) {
|
|
|
7209
8781
|
write(ws, {
|
|
7210
8782
|
kind: "hello",
|
|
7211
8783
|
id: randomUUID(),
|
|
7212
|
-
protocolVersion:
|
|
8784
|
+
protocolVersion: 25,
|
|
7213
8785
|
os: options.os
|
|
7214
8786
|
});
|
|
7215
8787
|
});
|
|
@@ -7217,6 +8789,7 @@ function connectToPlatform(options) {
|
|
|
7217
8789
|
if (ws === socket) receive(ws, textOf$1(data));
|
|
7218
8790
|
});
|
|
7219
8791
|
ws.on("close", (code, reason) => {
|
|
8792
|
+
ending.get(ws)?.abort();
|
|
7220
8793
|
if (ws === socket) closed(refused ? NODE_CLOSE_CODES.refused : code, reason.toString());
|
|
7221
8794
|
});
|
|
7222
8795
|
ws.on("error", () => {});
|
|
@@ -7244,7 +8817,8 @@ function connectToPlatform(options) {
|
|
|
7244
8817
|
id: randomUUID()
|
|
7245
8818
|
}), message.heartbeatIntervalMs);
|
|
7246
8819
|
onEvent({ type: "connected" });
|
|
7247
|
-
|
|
8820
|
+
written = /* @__PURE__ */ new Set();
|
|
8821
|
+
deliver();
|
|
7248
8822
|
return;
|
|
7249
8823
|
case "ack":
|
|
7250
8824
|
acknowledged(message);
|
|
@@ -7368,15 +8942,72 @@ function connectToPlatform(options) {
|
|
|
7368
8942
|
});
|
|
7369
8943
|
reconnectTimer = setTimeout(open, options.reconnectDelayMs(attempt++));
|
|
7370
8944
|
};
|
|
7371
|
-
|
|
8945
|
+
/** Whether `ws` is the connection, welcomed and open, that the outbox's messages go on now. */
|
|
8946
|
+
const isUp = (ws) => ws !== null && ws === socket && welcomed && !stopping && ws.readyState === WebSocket.OPEN;
|
|
8947
|
+
/** Writes what the outbox holds that the connection open now hasn't been sent, in order. */
|
|
8948
|
+
const deliver = () => {
|
|
8949
|
+
const ws = socket;
|
|
8950
|
+
delivering = delivering.then(() => deliverOn(ws)).catch((error) => {
|
|
8951
|
+
onEvent({
|
|
8952
|
+
type: "problem",
|
|
8953
|
+
message: `Couldn't send the outbox: ${messageOf(error)}`
|
|
8954
|
+
});
|
|
8955
|
+
});
|
|
8956
|
+
};
|
|
8957
|
+
const deliverOn = async (ws) => {
|
|
8958
|
+
for (const message of outbox.pending()) {
|
|
8959
|
+
if (!isUp(ws)) return;
|
|
8960
|
+
if (written.has(message.id)) continue;
|
|
8961
|
+
if (imagesNamedBy(message).length > 0 && !await imagesPutFor(ws, message)) return;
|
|
8962
|
+
if (!isUp(ws)) return;
|
|
8963
|
+
written.add(message.id);
|
|
8964
|
+
write(ws, message);
|
|
8965
|
+
}
|
|
8966
|
+
};
|
|
8967
|
+
/**
|
|
8968
|
+
* Puts the images `message` names into the image store, trying again with backoff while that
|
|
8969
|
+
* fails and `ws` is up. Resolves with whether to send it now: false once `ws` went down, which
|
|
8970
|
+
* stops it. Images the image store refuses outright, or no longer kept here, can't be put there by
|
|
8971
|
+
* trying again: the message goes as it is, for the Platform to refuse with its reason.
|
|
8972
|
+
*/
|
|
8973
|
+
const imagesPutFor = async (ws, message) => {
|
|
8974
|
+
const images = imagesNamedBy(message).map((image) => ({
|
|
8975
|
+
image,
|
|
8976
|
+
bytes: () => outbox.image(image.key)
|
|
8977
|
+
}));
|
|
8978
|
+
const signal = ending.get(ws)?.signal ?? AbortSignal.abort();
|
|
8979
|
+
for (let tries = 0;; tries++) {
|
|
8980
|
+
try {
|
|
8981
|
+
await options.uploadImages(images, signal);
|
|
8982
|
+
return true;
|
|
8983
|
+
} catch (error) {
|
|
8984
|
+
if (!isUp(ws)) return false;
|
|
8985
|
+
const lost = error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
8986
|
+
if (error instanceof ImagesRefusedError || lost) {
|
|
8987
|
+
onEvent({
|
|
8988
|
+
type: "problem",
|
|
8989
|
+
message: `The images of a ${message.kind} couldn't be put into the image store: ${messageOf(error)}`
|
|
8990
|
+
});
|
|
8991
|
+
return true;
|
|
8992
|
+
}
|
|
8993
|
+
onEvent({
|
|
8994
|
+
type: "problem",
|
|
8995
|
+
message: `Couldn't put the images of a ${message.kind} into the image store yet: ${messageOf(error)}`
|
|
8996
|
+
});
|
|
8997
|
+
}
|
|
8998
|
+
await setTimeout$1(options.reconnectDelayMs(tries), null, { signal }).catch(() => null);
|
|
8999
|
+
if (!isUp(ws)) return false;
|
|
9000
|
+
}
|
|
9001
|
+
};
|
|
9002
|
+
const keepAndSend = async (message, images) => {
|
|
7372
9003
|
if (killed) return;
|
|
7373
|
-
await outbox.add(message);
|
|
7374
|
-
if (
|
|
9004
|
+
await outbox.add(message, images);
|
|
9005
|
+
if (isUp(socket)) deliver();
|
|
7375
9006
|
};
|
|
7376
9007
|
open();
|
|
7377
9008
|
return {
|
|
7378
|
-
send(message) {
|
|
7379
|
-
const sent = sending.then(() => keepAndSend(message));
|
|
9009
|
+
send(message, images) {
|
|
9010
|
+
const sent = sending.then(() => keepAndSend(message, images));
|
|
7380
9011
|
sending = sent.catch(() => {});
|
|
7381
9012
|
return sent;
|
|
7382
9013
|
},
|
|
@@ -7426,11 +9057,11 @@ function connectToPlatform(options) {
|
|
|
7426
9057
|
const FETCH_TIMEOUT_MS = 6e4;
|
|
7427
9058
|
/** The Platform's documents, as the Node that joined with `credential` may read them. */
|
|
7428
9059
|
function platformDocuments(credential) {
|
|
7429
|
-
const get = async (path) => {
|
|
9060
|
+
const get = async (path, headers = { authorization: `Bearer ${credential.credential}` }) => {
|
|
7430
9061
|
let response;
|
|
7431
9062
|
try {
|
|
7432
9063
|
response = await fetch(new URL(path, credential.platformUrl), {
|
|
7433
|
-
headers
|
|
9064
|
+
headers,
|
|
7434
9065
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
7435
9066
|
});
|
|
7436
9067
|
} catch (error) {
|
|
@@ -7444,7 +9075,10 @@ function platformDocuments(credential) {
|
|
|
7444
9075
|
};
|
|
7445
9076
|
return {
|
|
7446
9077
|
handOff: async (taskId, handOffId) => fetchedHandOff.parse(await (await get(nodeHandOffPath(taskId, handOffId))).json()),
|
|
7447
|
-
|
|
9078
|
+
brief: async (taskId) => fetchedBrief.parse(await (await get(nodeBriefPath(taskId))).json()),
|
|
9079
|
+
verdicts: async (taskId) => fetchedVerdicts.parse(await (await get(nodeVerdictsPath(taskId))).json()),
|
|
9080
|
+
annotations: async (taskId, checkId) => fetchedAnnotations.parse(await (await get(nodeAnnotationsPath(taskId, checkId))).json()),
|
|
9081
|
+
image: async (address) => new Uint8Array(await (await get(address.url, { ...address.headers })).arrayBuffer())
|
|
7448
9082
|
};
|
|
7449
9083
|
}
|
|
7450
9084
|
//#endregion
|
|
@@ -7480,11 +9114,11 @@ async function thisInstallation() {
|
|
|
7480
9114
|
* `reason`. Asks npm for its latest when that may help.
|
|
7481
9115
|
*/
|
|
7482
9116
|
async function protocolMove(installed, platformVersion, reason) {
|
|
7483
|
-
if (platformVersion === null || platformVersion ===
|
|
9117
|
+
if (platformVersion === null || platformVersion === 25) return {
|
|
7484
9118
|
kind: "unreadable",
|
|
7485
9119
|
reason
|
|
7486
9120
|
};
|
|
7487
|
-
if (platformVersion <
|
|
9121
|
+
if (platformVersion < 25) return {
|
|
7488
9122
|
kind: "wait-for-platform",
|
|
7489
9123
|
platformVersion
|
|
7490
9124
|
};
|
|
@@ -7589,6 +9223,119 @@ function quietNpm(env) {
|
|
|
7589
9223
|
};
|
|
7590
9224
|
}
|
|
7591
9225
|
//#endregion
|
|
9226
|
+
//#region src/handed-images.ts
|
|
9227
|
+
/** An image being handed is written aside under this name until its last part came. */
|
|
9228
|
+
const PARTIAL = ".partial";
|
|
9229
|
+
/** The folder of what is handed for `to`, in the Node's own: by what it is for, never a path. */
|
|
9230
|
+
function folderFor(dir, to) {
|
|
9231
|
+
return to.of === "reply" ? join(dir, `reply-${to.taskId}`) : join(chatFolder(dir, to.chatId), String(to.turn));
|
|
9232
|
+
}
|
|
9233
|
+
/** The folder of what is handed for chat `chatId`'s turns, a folder per turn in it. */
|
|
9234
|
+
function chatFolder(dir, chatId) {
|
|
9235
|
+
return join(dir, `chat-${chatId}`);
|
|
9236
|
+
}
|
|
9237
|
+
/**
|
|
9238
|
+
* Opens the images handed to this Node, kept in `dir`: what was handed for replies to the daemon
|
|
9239
|
+
* before goes first.
|
|
9240
|
+
*/
|
|
9241
|
+
async function openHandedImages(options) {
|
|
9242
|
+
const { dir } = options;
|
|
9243
|
+
await mkdir(dir, {
|
|
9244
|
+
recursive: true,
|
|
9245
|
+
mode: 448
|
|
9246
|
+
});
|
|
9247
|
+
for (const folder of await readdir(dir)) if (folder.startsWith("reply-")) await rm(join(dir, folder), {
|
|
9248
|
+
recursive: true,
|
|
9249
|
+
force: true
|
|
9250
|
+
});
|
|
9251
|
+
return {
|
|
9252
|
+
async take(part) {
|
|
9253
|
+
const why = options.accepts(part.to);
|
|
9254
|
+
if (why !== null) return {
|
|
9255
|
+
kind: "refused",
|
|
9256
|
+
why
|
|
9257
|
+
};
|
|
9258
|
+
const bytes = Buffer.from(part.data, "base64");
|
|
9259
|
+
if (part.at + bytes.length > IMAGE_LIMITS.bytes) return {
|
|
9260
|
+
kind: "refused",
|
|
9261
|
+
why: `image ${part.index + 1} is larger than ${IMAGE_LIMITS.bytes / 1024 / 1024} MiB`
|
|
9262
|
+
};
|
|
9263
|
+
const folder = join(folderFor(dir, part.to), part.handing);
|
|
9264
|
+
const file = join(folder, `${part.index}${PARTIAL}`);
|
|
9265
|
+
if (part.at === 0) {
|
|
9266
|
+
if (imageTypeOf(bytes) !== part.mimeType) return {
|
|
9267
|
+
kind: "refused",
|
|
9268
|
+
why: `image ${part.index + 1} isn't the ${part.mimeType} it says`
|
|
9269
|
+
};
|
|
9270
|
+
await mkdir(folder, {
|
|
9271
|
+
recursive: true,
|
|
9272
|
+
mode: 448
|
|
9273
|
+
});
|
|
9274
|
+
await writeFile(file, bytes, { mode: 384 });
|
|
9275
|
+
} else {
|
|
9276
|
+
if (await sizeOf(file) !== part.at) return {
|
|
9277
|
+
kind: "refused",
|
|
9278
|
+
why: `a part of image ${part.index + 1} came out of place`
|
|
9279
|
+
};
|
|
9280
|
+
await appendFile(file, bytes);
|
|
9281
|
+
}
|
|
9282
|
+
if (part.last) await rename(file, join(folder, String(part.index)));
|
|
9283
|
+
return { kind: "taken" };
|
|
9284
|
+
},
|
|
9285
|
+
async claim(to, { handing, images }, workspace, beside) {
|
|
9286
|
+
const folder = join(folderFor(dir, to), handing);
|
|
9287
|
+
for (const index of images.keys()) if (await sizeOf(join(folder, String(index))) < 0) throw new Error(`Image ${index + 1} wasn't handed to this Node whole`);
|
|
9288
|
+
const written = await writeImages(workspace, beside, images, (index) => readFile(join(folder, String(index))));
|
|
9289
|
+
await forget(dir, to);
|
|
9290
|
+
return written;
|
|
9291
|
+
},
|
|
9292
|
+
forget: (to) => forget(dir, to),
|
|
9293
|
+
async sweep(before) {
|
|
9294
|
+
for (const chat of await readdir(dir)) {
|
|
9295
|
+
if (!chat.startsWith("chat-")) continue;
|
|
9296
|
+
const folder = join(dir, chat);
|
|
9297
|
+
for (const turn of await readdir(folder)) if ((await stat(join(folder, turn))).mtimeMs < before) await rm(join(folder, turn), {
|
|
9298
|
+
recursive: true,
|
|
9299
|
+
force: true
|
|
9300
|
+
});
|
|
9301
|
+
if ((await readdir(folder)).length === 0 && (await stat(folder)).mtimeMs < before) await rm(folder, {
|
|
9302
|
+
recursive: true,
|
|
9303
|
+
force: true
|
|
9304
|
+
});
|
|
9305
|
+
}
|
|
9306
|
+
}
|
|
9307
|
+
};
|
|
9308
|
+
}
|
|
9309
|
+
/** Forgets what is handed in `dir` for `to`: a chat's for its turns up to `to`'s. */
|
|
9310
|
+
async function forget(dir, to) {
|
|
9311
|
+
if (to.of === "reply") {
|
|
9312
|
+
await rm(folderFor(dir, to), {
|
|
9313
|
+
recursive: true,
|
|
9314
|
+
force: true
|
|
9315
|
+
});
|
|
9316
|
+
return;
|
|
9317
|
+
}
|
|
9318
|
+
const folder = chatFolder(dir, to.chatId);
|
|
9319
|
+
let turns;
|
|
9320
|
+
try {
|
|
9321
|
+
turns = await readdir(folder);
|
|
9322
|
+
} catch {
|
|
9323
|
+
return;
|
|
9324
|
+
}
|
|
9325
|
+
for (const turn of turns) if (Number(turn) <= to.turn) await rm(join(folder, turn), {
|
|
9326
|
+
recursive: true,
|
|
9327
|
+
force: true
|
|
9328
|
+
});
|
|
9329
|
+
}
|
|
9330
|
+
/** How many bytes the file at `path` holds, or -1 when there is none. */
|
|
9331
|
+
async function sizeOf(path) {
|
|
9332
|
+
try {
|
|
9333
|
+
return (await stat(path)).size;
|
|
9334
|
+
} catch {
|
|
9335
|
+
return -1;
|
|
9336
|
+
}
|
|
9337
|
+
}
|
|
9338
|
+
//#endregion
|
|
7592
9339
|
//#region src/own-setup.ts
|
|
7593
9340
|
/** What else `runtime` reads on `machine`, for a Session in a Workspace in `workspacesDir`. */
|
|
7594
9341
|
async function ownSetupOf(runtime, machine, workspacesDir) {
|
|
@@ -8019,6 +9766,86 @@ function answerOf(found) {
|
|
|
8019
9766
|
};
|
|
8020
9767
|
}
|
|
8021
9768
|
//#endregion
|
|
9769
|
+
//#region src/question-images.ts
|
|
9770
|
+
/** Where a question's or a reply's images were written, as the Node files it. */
|
|
9771
|
+
const filedImages = z.object({
|
|
9772
|
+
/** The Workspace they are in. */
|
|
9773
|
+
workspace: z.string().min(1),
|
|
9774
|
+
/** Each image, in the question's order: its file from the Workspace's root, and its type. */
|
|
9775
|
+
images: z.array(z.object({
|
|
9776
|
+
path: z.string().min(1),
|
|
9777
|
+
mimeType: imageType
|
|
9778
|
+
}))
|
|
9779
|
+
});
|
|
9780
|
+
/** Where the Node files question `question` of Task `taskId` among its Sessions. */
|
|
9781
|
+
function filedAt(sessionsDir, taskId, question) {
|
|
9782
|
+
return join(sessionsDir, taskId, "help-requests", `${question}.json`);
|
|
9783
|
+
}
|
|
9784
|
+
/** Where the Node files the images of the reply to Task `taskId` that `handing` brought. */
|
|
9785
|
+
function replyFiledAt(sessionsDir, taskId, handing) {
|
|
9786
|
+
return join(sessionsDir, taskId, "help-replies", `${handing}.json`);
|
|
9787
|
+
}
|
|
9788
|
+
/** Files `images`, written into `workspace`, at `filed`. */
|
|
9789
|
+
async function fileImages(filed, workspace, images) {
|
|
9790
|
+
await mkdir(join(filed, ".."), {
|
|
9791
|
+
recursive: true,
|
|
9792
|
+
mode: 448
|
|
9793
|
+
});
|
|
9794
|
+
await writeAtomically(filed, JSON.stringify({
|
|
9795
|
+
workspace,
|
|
9796
|
+
images: images.map(({ path, mimeType }) => ({
|
|
9797
|
+
path,
|
|
9798
|
+
mimeType
|
|
9799
|
+
}))
|
|
9800
|
+
}));
|
|
9801
|
+
}
|
|
9802
|
+
/** Image `index` of those filed at `filed`, from their Workspace. */
|
|
9803
|
+
async function filedImage(filed, index) {
|
|
9804
|
+
const text = await readFile(filed, "utf8").catch((error) => {
|
|
9805
|
+
if (isMissing$1(error)) return null;
|
|
9806
|
+
throw error;
|
|
9807
|
+
});
|
|
9808
|
+
const parsed = text === null ? null : filedImages.safeParse(jsonIn(text));
|
|
9809
|
+
const named = parsed?.success ? parsed.data.images[index] : void 0;
|
|
9810
|
+
if (!parsed?.success || named === void 0) return {
|
|
9811
|
+
kind: "missing",
|
|
9812
|
+
why: "it keeps no such image"
|
|
9813
|
+
};
|
|
9814
|
+
return imageInWorkspace(parsed.data.workspace, named.path);
|
|
9815
|
+
}
|
|
9816
|
+
/**
|
|
9817
|
+
* Copies the images an Agent asked with into its Workspace, as they are now, and files where they
|
|
9818
|
+
* are under `asking`'s ids. Returns them as the `help-request` message names them: without their
|
|
9819
|
+
* bytes.
|
|
9820
|
+
*/
|
|
9821
|
+
async function keepQuestionImages(asking, images) {
|
|
9822
|
+
const about = images.map(({ mimeType, alt }) => ({
|
|
9823
|
+
mimeType,
|
|
9824
|
+
...alt === void 0 ? {} : { alt }
|
|
9825
|
+
}));
|
|
9826
|
+
if (images.length === 0) return about;
|
|
9827
|
+
const written = await writeImages(asking.workspace, {
|
|
9828
|
+
folder: posix.join(WORKSPACE_FOLDERS.helpRequest, String(asking.number)),
|
|
9829
|
+
stem: "question"
|
|
9830
|
+
}, about, async (index) => images[index]?.bytes ?? Buffer.alloc(0));
|
|
9831
|
+
await fileImages(filedAt(asking.sessionsDir, asking.taskId, asking.question), asking.workspace, about.map(({ mimeType }, index) => ({
|
|
9832
|
+
path: written[index]?.path ?? "",
|
|
9833
|
+
mimeType
|
|
9834
|
+
})));
|
|
9835
|
+
return about;
|
|
9836
|
+
}
|
|
9837
|
+
/** Files where the images a reply came with were written in its Task's Workspace (`written`). */
|
|
9838
|
+
async function fileReplyImages(replying, written) {
|
|
9839
|
+
await fileImages(replyFiledAt(replying.sessionsDir, replying.taskId, replying.handing), replying.workspace, written);
|
|
9840
|
+
}
|
|
9841
|
+
/**
|
|
9842
|
+
* One image of a Help Request's question or reply, from its Task's Workspace, by the ids it is
|
|
9843
|
+
* filed under.
|
|
9844
|
+
*/
|
|
9845
|
+
async function helpRequestImage(sessionsDir, image) {
|
|
9846
|
+
return filedImage(image.of === "help-request" ? filedAt(sessionsDir, image.taskId, image.question) : replyFiledAt(sessionsDir, image.taskId, image.handing), image.index);
|
|
9847
|
+
}
|
|
9848
|
+
//#endregion
|
|
8022
9849
|
//#region src/requests.ts
|
|
8023
9850
|
/** The most bytes of JSON one part of an answer holds: well inside one message of the Node link. */
|
|
8024
9851
|
const PART_BYTES = 1048576;
|
|
@@ -8027,10 +9854,11 @@ const PROBE_MARGIN_MS = 2e3;
|
|
|
8027
9854
|
/** Answers each request the Platform sends, in the background. */
|
|
8028
9855
|
function answerRequests(options) {
|
|
8029
9856
|
const answer = async (request) => {
|
|
8030
|
-
|
|
9857
|
+
let giveUpAt = Date.now() + request.timeoutMs;
|
|
8031
9858
|
const parts = await answerTo(request);
|
|
8032
9859
|
for (const [part, piece] of parts.entries()) {
|
|
8033
9860
|
if (Date.now() > giveUpAt) return;
|
|
9861
|
+
giveUpAt = Date.now() + request.timeoutMs;
|
|
8034
9862
|
if (!options.send({
|
|
8035
9863
|
kind: "answer",
|
|
8036
9864
|
id: randomUUID(),
|
|
@@ -8046,6 +9874,8 @@ function answerRequests(options) {
|
|
|
8046
9874
|
const { ask } = request;
|
|
8047
9875
|
if (ask.about === "session-record") return recordOf(ask.session);
|
|
8048
9876
|
if (ask.about === "repository-probe") return probeOf(ask.repository, request.timeoutMs, options.signal);
|
|
9877
|
+
if (ask.about === "image") return imageOf(ask.image);
|
|
9878
|
+
if (ask.about === "handed-image") return handedPart(ask);
|
|
8049
9879
|
return setupOf(ask.runtime, ask.of === "millwright" ? options.millwrightDir : options.workspacesDir);
|
|
8050
9880
|
};
|
|
8051
9881
|
/**
|
|
@@ -8065,6 +9895,53 @@ function answerRequests(options) {
|
|
|
8065
9895
|
}];
|
|
8066
9896
|
}
|
|
8067
9897
|
};
|
|
9898
|
+
/** An image it keeps: its bytes, in parts, or why it can't send them. */
|
|
9899
|
+
const imageOf = async (image) => {
|
|
9900
|
+
let kept;
|
|
9901
|
+
try {
|
|
9902
|
+
kept = image.of === "session-record" ? await recordImageOf(recordFolder(options.sessionsDir, image.session), image) : await helpRequestImage(options.sessionsDir, image);
|
|
9903
|
+
} catch (error) {
|
|
9904
|
+
return [{
|
|
9905
|
+
kind: "failed",
|
|
9906
|
+
reason: `${options.name} couldn't read the image: ${messageOf(error)}`.slice(0, 2e3)
|
|
9907
|
+
}];
|
|
9908
|
+
}
|
|
9909
|
+
if (kept.kind === "gone") return [{
|
|
9910
|
+
kind: "gone",
|
|
9911
|
+
reason: `${options.name} cleaned up the Workspace it kept the image in, and the image went with it`.slice(0, 500)
|
|
9912
|
+
}];
|
|
9913
|
+
if (kept.kind === "missing") return [{
|
|
9914
|
+
kind: "refused",
|
|
9915
|
+
reason: `${options.name} can't send the image: ${kept.why}`.slice(0, 500)
|
|
9916
|
+
}];
|
|
9917
|
+
const { mimeType, bytes } = kept;
|
|
9918
|
+
const parts = [];
|
|
9919
|
+
for (let at = 0; at < bytes.length; at += IMAGE_PART_BYTES) {
|
|
9920
|
+
const data = bytes.subarray(at, at + IMAGE_PART_BYTES).toString("base64");
|
|
9921
|
+
parts.push({
|
|
9922
|
+
kind: "image",
|
|
9923
|
+
mimeType,
|
|
9924
|
+
data
|
|
9925
|
+
});
|
|
9926
|
+
}
|
|
9927
|
+
return parts;
|
|
9928
|
+
};
|
|
9929
|
+
/** A part of an image the Platform hands it: on disk once answered, or why it isn't. */
|
|
9930
|
+
const handedPart = async (part) => {
|
|
9931
|
+
try {
|
|
9932
|
+
const taken = await options.handed.take(part);
|
|
9933
|
+
if (taken.kind === "refused") return [{
|
|
9934
|
+
kind: "refused",
|
|
9935
|
+
reason: `${options.name} can't take the images: ${taken.why}`.slice(0, 500)
|
|
9936
|
+
}];
|
|
9937
|
+
return [{ kind: "handed-image" }];
|
|
9938
|
+
} catch (error) {
|
|
9939
|
+
return [{
|
|
9940
|
+
kind: "failed",
|
|
9941
|
+
reason: `${options.name} couldn't keep the images: ${messageOf(error)}`.slice(0, 2e3)
|
|
9942
|
+
}];
|
|
9943
|
+
}
|
|
9944
|
+
};
|
|
8068
9945
|
/** `session`'s record: its items, in parts. */
|
|
8069
9946
|
const recordOf = async (session) => {
|
|
8070
9947
|
const folder = recordFolder(options.sessionsDir, session);
|
|
@@ -8131,24 +10008,32 @@ function partsOf(items) {
|
|
|
8131
10008
|
}
|
|
8132
10009
|
//#endregion
|
|
8133
10010
|
//#region src/factory-mcp.ts
|
|
10011
|
+
/** The images an Agent names in a tool call, by their paths in its Workspace, `described` so. */
|
|
10012
|
+
function imagesInput(described) {
|
|
10013
|
+
return z.array(z.object({
|
|
10014
|
+
path: z.string().min(1).describe("An image file in your Workspace."),
|
|
10015
|
+
alt: z.string().max(IMAGE_LIMITS.alt).optional().describe("What the image shows.")
|
|
10016
|
+
})).max(IMAGE_LIMITS.images).optional().describe(described);
|
|
10017
|
+
}
|
|
8134
10018
|
/** What the Agent passes to the hand-off tool. */
|
|
8135
10019
|
const handOffInput = z.object({
|
|
8136
10020
|
markdown: z.string().describe("Your Hand-off, a document in Markdown: what you did, what you found, and what whoever reads it next needs to know."),
|
|
8137
|
-
images:
|
|
8138
|
-
path: z.string().min(1).describe("An image file in your Workspace."),
|
|
8139
|
-
alt: z.string().max(500).optional().describe("What the image shows.")
|
|
8140
|
-
})).max(HAND_OFF_LIMITS.images).optional().describe("Small images that belong in the document, such as a screenshot."),
|
|
10021
|
+
images: imagesInput("Images that belong in the document, such as a screenshot."),
|
|
8141
10022
|
references: z.array(handOffReference).max(HAND_OFF_LIMITS.references).optional().describe("Materials you produced, where they live: a label, and a URL or a pinned identifier (a pushed commit, say).")
|
|
8142
10023
|
});
|
|
8143
10024
|
/** What the Agent passes to the help tool. */
|
|
8144
|
-
const helpInput = z.object({
|
|
10025
|
+
const helpInput = z.object({
|
|
10026
|
+
question: z.string().describe("Your question for the Owner, in Markdown: what you need to know, and what you found that makes you ask."),
|
|
10027
|
+
images: imagesInput("Images that show the Owner what you see, such as a screenshot: they see them as they are when you ask.")
|
|
10028
|
+
});
|
|
8145
10029
|
/**
|
|
8146
10030
|
* What the Inspector passes to the check tool: one flat object, as some CLIs want a tool's input
|
|
8147
10031
|
* to be, which `judgementFrom` makes a judgement of.
|
|
8148
10032
|
*/
|
|
8149
10033
|
const judgeInput = z.object({
|
|
8150
10034
|
outcome: z.enum(["passed", "failed"]).describe("Whether the Hand-off meets every one of the Belt's criteria."),
|
|
8151
|
-
annotations: z.string().optional().describe("When it failed: which criteria the Hand-off doesn't meet and why, in Markdown. Required then.")
|
|
10035
|
+
annotations: z.string().optional().describe("When it failed: which criteria the Hand-off doesn't meet and why, in Markdown. Required then."),
|
|
10036
|
+
images: imagesInput("When it failed: images that show what is wrong, such as a screenshot, beside the annotations.")
|
|
8152
10037
|
});
|
|
8153
10038
|
/** A tool's input schema, as JSON Schema. */
|
|
8154
10039
|
function jsonSchemaOf(schema) {
|
|
@@ -8166,7 +10051,10 @@ async function serveFactoryMcp(options) {
|
|
|
8166
10051
|
name: JUDGE_TOOL,
|
|
8167
10052
|
description: `Records your judgement of the Hand-off under check. ${JUDGE_GUIDANCE}`,
|
|
8168
10053
|
inputSchema: jsonSchemaOf(judgeInput),
|
|
8169
|
-
handler: async (input) =>
|
|
10054
|
+
handler: async (input) => {
|
|
10055
|
+
const { judgement: made, images } = await judgementFrom(options.workspace, input);
|
|
10056
|
+
return options.judge(made, images);
|
|
10057
|
+
}
|
|
8170
10058
|
}]
|
|
8171
10059
|
});
|
|
8172
10060
|
return serveMcpTools({
|
|
@@ -8176,89 +10064,126 @@ async function serveFactoryMcp(options) {
|
|
|
8176
10064
|
name: HAND_OFF_TOOL,
|
|
8177
10065
|
description: `Delivers your finished work as a Hand-off. ${HAND_OFF_GUIDANCE}`,
|
|
8178
10066
|
inputSchema: jsonSchemaOf(handOffInput),
|
|
8179
|
-
handler: async (input) =>
|
|
10067
|
+
handler: async (input) => {
|
|
10068
|
+
const { document, images } = await handOffFrom(options.workspace, input);
|
|
10069
|
+
return options.handOff(document, images);
|
|
10070
|
+
}
|
|
8180
10071
|
}, {
|
|
8181
10072
|
name: HELP_TOOL,
|
|
8182
10073
|
description: `Asks the Owner a question when you're stuck. ${HELP_GUIDANCE}`,
|
|
8183
10074
|
inputSchema: jsonSchemaOf(helpInput),
|
|
8184
|
-
handler: async (input) =>
|
|
10075
|
+
handler: async (input) => {
|
|
10076
|
+
const { question, images } = questionFrom(input);
|
|
10077
|
+
return options.askForHelp(question, await imagesIn(options.workspace, images, "question"));
|
|
10078
|
+
}
|
|
8185
10079
|
}]
|
|
8186
10080
|
});
|
|
8187
10081
|
}
|
|
8188
|
-
/**
|
|
10082
|
+
/**
|
|
10083
|
+
* The question the Agent asked, and the images it named with it, or an error saying what's wrong
|
|
10084
|
+
* with it, for the Agent.
|
|
10085
|
+
*/
|
|
8189
10086
|
function questionFrom(input) {
|
|
8190
10087
|
const asked = helpInput.safeParse(input);
|
|
8191
10088
|
if (!asked.success) throw new Error(`That isn't a question for the Owner: ${z.prettifyError(asked.error)}`);
|
|
8192
10089
|
const question = helpQuestion.safeParse(asked.data.question);
|
|
8193
10090
|
if (!question.success) throw new Error(`That isn't a question for the Owner: ${z.prettifyError(question.error)}`);
|
|
8194
|
-
return
|
|
10091
|
+
return {
|
|
10092
|
+
question: question.data,
|
|
10093
|
+
images: asked.data.images ?? []
|
|
10094
|
+
};
|
|
8195
10095
|
}
|
|
8196
|
-
/**
|
|
8197
|
-
|
|
10096
|
+
/**
|
|
10097
|
+
* The judgement the Inspector asked for, with the images a failed one names read from its
|
|
10098
|
+
* Workspace, or an error saying what's wrong with it, for it. A passing judgement carries no
|
|
10099
|
+
* annotations or images: whatever it names is never read.
|
|
10100
|
+
*/
|
|
10101
|
+
async function judgementFrom(workspace, input) {
|
|
8198
10102
|
const asked = judgeInput.safeParse(input);
|
|
8199
10103
|
if (!asked.success) throw new Error(`That isn't a judgement: ${z.prettifyError(asked.error)}`);
|
|
8200
10104
|
const { outcome, annotations } = asked.data;
|
|
8201
10105
|
if (outcome === "failed" && !annotations?.trim()) throw new Error("A Hand-off that failed needs annotations: say which criteria it doesn't meet and why.");
|
|
10106
|
+
const images = outcome === "failed" ? await imagesIn(workspace, asked.data.images ?? [], "annotations") : [];
|
|
8202
10107
|
const made = judgement.safeParse(outcome === "failed" ? {
|
|
8203
10108
|
outcome,
|
|
8204
|
-
annotations
|
|
10109
|
+
annotations,
|
|
10110
|
+
images: images.map(documentImageOf)
|
|
8205
10111
|
} : { outcome: "passed" });
|
|
8206
10112
|
if (!made.success) throw new Error(`That isn't a judgement: ${z.prettifyError(made.error)}`);
|
|
8207
|
-
return
|
|
10113
|
+
return {
|
|
10114
|
+
judgement: made.data,
|
|
10115
|
+
images
|
|
10116
|
+
};
|
|
8208
10117
|
}
|
|
8209
|
-
/**
|
|
10118
|
+
/**
|
|
10119
|
+
* The Hand-off the Agent asked for, with the images it names read from its Workspace, or an error
|
|
10120
|
+
* saying what's wrong with it, for the Agent.
|
|
10121
|
+
*/
|
|
8210
10122
|
async function handOffFrom(workspace, input) {
|
|
8211
10123
|
const asked = handOffInput.safeParse(input);
|
|
8212
10124
|
if (!asked.success) throw new Error(`That isn't a Hand-off: ${z.prettifyError(asked.error)}`);
|
|
8213
|
-
const images = await
|
|
10125
|
+
const images = await imagesIn(workspace, asked.data.images ?? [], "hand-off");
|
|
8214
10126
|
const made = handOff.safeParse({
|
|
8215
10127
|
markdown: asked.data.markdown,
|
|
8216
|
-
images,
|
|
10128
|
+
images: images.map(documentImageOf),
|
|
8217
10129
|
references: asked.data.references ?? []
|
|
8218
10130
|
});
|
|
8219
10131
|
if (!made.success) throw new Error(`That isn't a Hand-off: ${z.prettifyError(made.error)}`);
|
|
8220
|
-
return
|
|
10132
|
+
return {
|
|
10133
|
+
document: made.data,
|
|
10134
|
+
images
|
|
10135
|
+
};
|
|
8221
10136
|
}
|
|
8222
|
-
/**
|
|
8223
|
-
|
|
8224
|
-
let file;
|
|
8225
|
-
try {
|
|
8226
|
-
file = await realpath(resolve(workspace, path));
|
|
8227
|
-
} catch {
|
|
8228
|
-
throw new Error(`There's no image at ${path} in your Workspace.`);
|
|
8229
|
-
}
|
|
8230
|
-
const fromRoot = relative(await realpath(workspace), file);
|
|
8231
|
-
if (fromRoot.startsWith("..") || isAbsolute(fromRoot)) throw new Error(`${path} isn't in your Workspace: a Hand-off's images must be.`);
|
|
8232
|
-
if ((await stat(file)).size > HAND_OFF_LIMITS.imageBytes) throw new Error(`${path} is larger than ${HAND_OFF_LIMITS.imageBytes / 1024} KiB. A Hand-off is a document: make the image smaller, or put it somewhere reachable and reference it.`);
|
|
8233
|
-
const bytes = await readFile(file);
|
|
8234
|
-
const mimeType = imageTypeOf(bytes);
|
|
8235
|
-
if (!mimeType) throw new Error(`${path} isn't a PNG, JPEG, GIF or WebP image.`);
|
|
10137
|
+
/** How a document names an image the Agent named: its key in the image store, type and alt. */
|
|
10138
|
+
function documentImageOf({ key, mimeType, alt }) {
|
|
8236
10139
|
return {
|
|
10140
|
+
key,
|
|
8237
10141
|
mimeType,
|
|
8238
|
-
data: bytes.toString("base64"),
|
|
8239
10142
|
...alt === void 0 ? {} : { alt }
|
|
8240
10143
|
};
|
|
8241
10144
|
}
|
|
8242
|
-
/**
|
|
8243
|
-
|
|
8244
|
-
|
|
8245
|
-
|
|
8246
|
-
|
|
8247
|
-
|
|
8248
|
-
|
|
8249
|
-
|
|
8250
|
-
|
|
8251
|
-
|
|
8252
|
-
|
|
8253
|
-
|
|
8254
|
-
|
|
8255
|
-
|
|
8256
|
-
|
|
8257
|
-
|
|
8258
|
-
|
|
8259
|
-
|
|
8260
|
-
|
|
8261
|
-
|
|
10145
|
+
/** What a tool's images are for, in the words that refuse one. */
|
|
10146
|
+
const IMAGES_FOR = {
|
|
10147
|
+
"hand-off": {
|
|
10148
|
+
whose: "a Hand-off's",
|
|
10149
|
+
tooLarge: "A Hand-off is a document: make the image smaller, or put it somewhere reachable and reference it."
|
|
10150
|
+
},
|
|
10151
|
+
question: {
|
|
10152
|
+
whose: "a question's",
|
|
10153
|
+
tooLarge: "Make the image smaller: the Owner sees it as it is."
|
|
10154
|
+
},
|
|
10155
|
+
annotations: {
|
|
10156
|
+
whose: "annotations'",
|
|
10157
|
+
tooLarge: "Make the image smaller: whoever reworks the Hand-off sees it as it is."
|
|
10158
|
+
}
|
|
10159
|
+
};
|
|
10160
|
+
/**
|
|
10161
|
+
* Reads the images an Agent `named` in a tool call (its Hand-off, its question, or its annotations)
|
|
10162
|
+
* from its `workspace`, in order; rejects with why one can't be, in words for the Agent.
|
|
10163
|
+
*/
|
|
10164
|
+
async function imagesIn(workspace, named, forWhat) {
|
|
10165
|
+
return Promise.all(named.map(({ path, alt }) => imageIn(workspace, path, alt, forWhat)));
|
|
10166
|
+
}
|
|
10167
|
+
/**
|
|
10168
|
+
* Reads one image the Agent named from its Workspace (`readWorkspaceImage`, the one way images are
|
|
10169
|
+
* read there), refusing one it can't take in words for the Agent.
|
|
10170
|
+
*/
|
|
10171
|
+
async function imageIn(workspace, path, alt, forWhat) {
|
|
10172
|
+
const words = IMAGES_FOR[forWhat];
|
|
10173
|
+
const read = await readWorkspaceImage(workspace, path);
|
|
10174
|
+
if (read.kind === "image") {
|
|
10175
|
+
const { mimeType, bytes } = read;
|
|
10176
|
+
return {
|
|
10177
|
+
key: createHash("sha256").update(bytes).digest("hex"),
|
|
10178
|
+
mimeType,
|
|
10179
|
+
bytes,
|
|
10180
|
+
alt
|
|
10181
|
+
};
|
|
10182
|
+
}
|
|
10183
|
+
if (read.kind === "outside") throw new Error(`${path} isn't in your Workspace: ${words.whose} images must be.`);
|
|
10184
|
+
if (read.kind === "too-large") throw new Error(`${path} is larger than ${IMAGE_LIMITS.bytes / 1024 / 1024} MiB. ${words.tooLarge}`);
|
|
10185
|
+
if (read.kind === "not-an-image") throw new Error(`${path} isn't a PNG, JPEG, GIF or WebP image.`);
|
|
10186
|
+
throw new Error(`There's no image at ${path} in your Workspace.`);
|
|
8262
10187
|
}
|
|
8263
10188
|
//#endregion
|
|
8264
10189
|
//#region src/tasks.ts
|
|
@@ -8359,7 +10284,9 @@ function added(a, b) {
|
|
|
8359
10284
|
* through the Runtime driver. A Workstation Task's work hands off, or asks the Owner for help: its
|
|
8360
10285
|
* turn ends, the Task is kept suspended with its Workspace and Session id, and the Owner's reply
|
|
8361
10286
|
* resumes that Session there, in a new segment with the same instructions, a Factory MCP of its own
|
|
8362
|
-
* and its Agent's Skills as the reply names them (#120), readied anew
|
|
10287
|
+
* and its Agent's Skills as the reply names them (#120), readied anew; the images the reply came
|
|
10288
|
+
* with (#186), which the Platform handed the Node before (handed-images.ts), are written into the
|
|
10289
|
+
* Workspace beside the question and come inside the prompt that resumes it. A check of its Hand-off
|
|
8363
10290
|
* records the Inspector's judgement (ADR-0014). The Platform hears
|
|
8364
10291
|
* through the connector's outbox that the Session started (each segment), the Hand-off, the
|
|
8365
10292
|
* question or the judgement, and how the Session ended with the tokens it used, its segments'
|
|
@@ -8447,9 +10374,12 @@ function createTaskRunner(options) {
|
|
|
8447
10374
|
}, options.handOffGraceMs);
|
|
8448
10375
|
now.done.finally(() => clearTimeout(grace));
|
|
8449
10376
|
};
|
|
8450
|
-
/**
|
|
8451
|
-
|
|
8452
|
-
|
|
10377
|
+
/**
|
|
10378
|
+
* Puts `message` in the outbox, with the bytes of the images it names, keeping the call until it
|
|
10379
|
+
* is there.
|
|
10380
|
+
*/
|
|
10381
|
+
const keep = async (message, images = []) => {
|
|
10382
|
+
const kept = connector.send(message, images);
|
|
8453
10383
|
keeping.add(kept);
|
|
8454
10384
|
try {
|
|
8455
10385
|
await kept;
|
|
@@ -8458,13 +10388,16 @@ function createTaskRunner(options) {
|
|
|
8458
10388
|
}
|
|
8459
10389
|
};
|
|
8460
10390
|
/**
|
|
8461
|
-
* Relays what the Agent's tool call made (a Hand-off, or a judgement)
|
|
8462
|
-
* became of it.
|
|
10391
|
+
* Relays what the Agent's tool call made (a Hand-off, or a judgement), with the images it names,
|
|
10392
|
+
* and tells the Agent what became of it.
|
|
8463
10393
|
*/
|
|
8464
|
-
const relay = async (session, agent, message) => {
|
|
10394
|
+
const relay = async (session, agent, message, images = []) => {
|
|
8465
10395
|
const acknowledged = connector.acknowledgement(message.id);
|
|
8466
10396
|
stopAfterGrace(session, agent, message.kind === "hand-off" ? "handing off" : "judging", acknowledged);
|
|
8467
|
-
await keep(message)
|
|
10397
|
+
await keep(message, images.map(({ key, bytes }) => ({
|
|
10398
|
+
key,
|
|
10399
|
+
bytes
|
|
10400
|
+
})));
|
|
8468
10401
|
const waited = new AbortController();
|
|
8469
10402
|
const ack = await Promise.race([acknowledged, setTimeout$1(options.handOffWaitMs, null, { signal: waited.signal }).catch(() => null)]);
|
|
8470
10403
|
waited.abort();
|
|
@@ -8474,8 +10407,8 @@ function createTaskRunner(options) {
|
|
|
8474
10407
|
if (ack.outcome === "refused") throw new Error(`The Platform didn't take this ${what}: ${ack.reason ?? "no reason given"}`);
|
|
8475
10408
|
return `Your ${what} reached the Platform. Your ${done} is done: stop here.`;
|
|
8476
10409
|
};
|
|
8477
|
-
/** Relays the Agent's Hand-off. */
|
|
8478
|
-
const handOff = (session, agent, document) => {
|
|
10410
|
+
/** Relays the Agent's Hand-off, with the images it names. */
|
|
10411
|
+
const handOff = (session, agent, document, images) => {
|
|
8479
10412
|
const id = randomUUID();
|
|
8480
10413
|
return relay(session, agent, {
|
|
8481
10414
|
kind: "hand-off",
|
|
@@ -8483,10 +10416,10 @@ function createTaskRunner(options) {
|
|
|
8483
10416
|
idempotencyKey: `hand-off:${session.taskId}:${id}`,
|
|
8484
10417
|
taskId: session.taskId,
|
|
8485
10418
|
handOff: document
|
|
8486
|
-
});
|
|
10419
|
+
}, images);
|
|
8487
10420
|
};
|
|
8488
|
-
/** Relays the Inspector's judgement. */
|
|
8489
|
-
const judge = (session, inspector, judgement) => {
|
|
10421
|
+
/** Relays the Inspector's judgement, with the images a failed one names. */
|
|
10422
|
+
const judge = (session, inspector, judgement, images) => {
|
|
8490
10423
|
const id = randomUUID();
|
|
8491
10424
|
return relay(session, inspector, {
|
|
8492
10425
|
kind: "judgement",
|
|
@@ -8495,17 +10428,32 @@ function createTaskRunner(options) {
|
|
|
8495
10428
|
taskId: session.taskId,
|
|
8496
10429
|
checkId: session.checkId,
|
|
8497
10430
|
judgement
|
|
8498
|
-
});
|
|
10431
|
+
}, images);
|
|
8499
10432
|
};
|
|
8500
10433
|
/**
|
|
8501
10434
|
* Sends the Owner the Agent's question (spec stories 56 and 57): once it is in the outbox, the
|
|
8502
10435
|
* tool returns at once, telling the Agent to end its turn (ADR-0009). One question per segment:
|
|
8503
|
-
* the reply to it starts the next.
|
|
10436
|
+
* the reply to it starts the next. The images it asks with are copied into its Workspace first,
|
|
10437
|
+
* as they are now (#185, question-images.ts), and its message names them without their bytes.
|
|
8504
10438
|
*/
|
|
8505
|
-
const askForHelp = async (work, asking, question) => {
|
|
10439
|
+
const askForHelp = async (work, asking, question, images) => {
|
|
8506
10440
|
if (asking.asked) throw new Error("You asked the Owner already: end your turn now. Their reply will continue this Session.");
|
|
8507
10441
|
asking.asked = true;
|
|
8508
10442
|
const id = randomUUID();
|
|
10443
|
+
const { taskId } = work.session;
|
|
10444
|
+
let kept;
|
|
10445
|
+
try {
|
|
10446
|
+
kept = await keepQuestionImages({
|
|
10447
|
+
sessionsDir: options.sessionsDir,
|
|
10448
|
+
taskId,
|
|
10449
|
+
question: id,
|
|
10450
|
+
number: work.segments,
|
|
10451
|
+
workspace: work.workspace
|
|
10452
|
+
}, images);
|
|
10453
|
+
} catch (error) {
|
|
10454
|
+
asking.asked = false;
|
|
10455
|
+
throw new Error(`Couldn't keep your images on this machine: ${messageOf(error)}`, { cause: error });
|
|
10456
|
+
}
|
|
8509
10457
|
const acknowledged = connector.acknowledgement(id);
|
|
8510
10458
|
stopAfterGrace(work.session, work.task.agent.name, "asking for help", acknowledged, () => {
|
|
8511
10459
|
asking.stoppedAfter = true;
|
|
@@ -8514,9 +10462,10 @@ function createTaskRunner(options) {
|
|
|
8514
10462
|
await keep({
|
|
8515
10463
|
kind: "help-request",
|
|
8516
10464
|
id,
|
|
8517
|
-
idempotencyKey: `help-request:${
|
|
8518
|
-
taskId
|
|
8519
|
-
question
|
|
10465
|
+
idempotencyKey: `help-request:${taskId}:${id}`,
|
|
10466
|
+
taskId,
|
|
10467
|
+
question,
|
|
10468
|
+
images: kept
|
|
8520
10469
|
});
|
|
8521
10470
|
} catch (error) {
|
|
8522
10471
|
asking.asked = false;
|
|
@@ -8549,6 +10498,10 @@ function createTaskRunner(options) {
|
|
|
8549
10498
|
const skills = skillsHeldBy.get(keyOf(session));
|
|
8550
10499
|
skillsHeldBy.delete(keyOf(session));
|
|
8551
10500
|
if (skills !== void 0 && !killed) await skills();
|
|
10501
|
+
if (session.role === "work" && !killed) await options.handed.forget({
|
|
10502
|
+
of: "reply",
|
|
10503
|
+
taskId: session.taskId
|
|
10504
|
+
});
|
|
8552
10505
|
};
|
|
8553
10506
|
/**
|
|
8554
10507
|
* Opens a Session for `session` in `workspace` (or resumes the one `resume` names there) with the
|
|
@@ -8631,17 +10584,18 @@ function createTaskRunner(options) {
|
|
|
8631
10584
|
const agent = task.agent.name;
|
|
8632
10585
|
const factory = await serveFactoryMcp({
|
|
8633
10586
|
role: "check",
|
|
8634
|
-
|
|
10587
|
+
workspace,
|
|
10588
|
+
judge: (judgement, images) => judge(session, agent, judgement, images)
|
|
8635
10589
|
});
|
|
8636
10590
|
let segment;
|
|
8637
10591
|
try {
|
|
8638
10592
|
segment = await runSession(session, {
|
|
8639
10593
|
task,
|
|
8640
10594
|
workspace,
|
|
8641
|
-
instructions: instructionsFor(task, files),
|
|
10595
|
+
instructions: instructionsFor(task, workspace, files),
|
|
8642
10596
|
skills,
|
|
8643
10597
|
factory,
|
|
8644
|
-
prompt: promptFor(task),
|
|
10598
|
+
prompt: promptFor(task, files),
|
|
8645
10599
|
startedKey: keyFor("task-started", session)
|
|
8646
10600
|
}, stop);
|
|
8647
10601
|
} finally {
|
|
@@ -8650,8 +10604,8 @@ function createTaskRunner(options) {
|
|
|
8650
10604
|
await reportEnd(session, segment.outcome, segment.usage ?? null);
|
|
8651
10605
|
};
|
|
8652
10606
|
/**
|
|
8653
|
-
* Runs the next segment of a Task's work: its first, with the Task's prompt
|
|
8654
|
-
* Session with the Owner's reply. Then, if its Agent asked the Owner for help and ended its turn,
|
|
10607
|
+
* Runs the next segment of a Task's work: its first, with the Task's prompt (the brief's images
|
|
10608
|
+
* inside it, #181), or one resuming its Session with the Owner's reply. Then, if its Agent asked the Owner for help and ended its turn,
|
|
8655
10609
|
* the Task is suspended (or goes straight on, if the reply came meanwhile); otherwise its end is
|
|
8656
10610
|
* reported, with the tokens of all its segments.
|
|
8657
10611
|
*/
|
|
@@ -8669,8 +10623,8 @@ function createTaskRunner(options) {
|
|
|
8669
10623
|
const factory = await serveFactoryMcp({
|
|
8670
10624
|
role: "work",
|
|
8671
10625
|
workspace,
|
|
8672
|
-
handOff: (document) => handOff(session, agent, document),
|
|
8673
|
-
askForHelp: (question) => askForHelp(work, asking, question)
|
|
10626
|
+
handOff: (document, images) => handOff(session, agent, document, images),
|
|
10627
|
+
askForHelp: (question, images) => askForHelp(work, asking, question, images)
|
|
8674
10628
|
});
|
|
8675
10629
|
let segment;
|
|
8676
10630
|
try {
|
|
@@ -8763,6 +10717,46 @@ function createTaskRunner(options) {
|
|
|
8763
10717
|
if (before !== void 0 && !killed) await before();
|
|
8764
10718
|
return true;
|
|
8765
10719
|
};
|
|
10720
|
+
/**
|
|
10721
|
+
* The images `reply` came with (#186), written into its Task's Workspace beside the question it
|
|
10722
|
+
* answers (`help-request/<n>/reply-1.png`…, n the asking segment's number, as the question's own),
|
|
10723
|
+
* from where the Platform handed them; or how many of them there were, when they couldn't be.
|
|
10724
|
+
*/
|
|
10725
|
+
const replyImagesOf = async (work, { handed }) => {
|
|
10726
|
+
if (handed === void 0) return {
|
|
10727
|
+
written: [],
|
|
10728
|
+
lost: 0
|
|
10729
|
+
};
|
|
10730
|
+
const { taskId } = work.session;
|
|
10731
|
+
try {
|
|
10732
|
+
const written = await options.handed.claim({
|
|
10733
|
+
of: "reply",
|
|
10734
|
+
taskId
|
|
10735
|
+
}, handed, work.workspace, {
|
|
10736
|
+
folder: posix.join(WORKSPACE_FOLDERS.helpRequest, String(work.segments)),
|
|
10737
|
+
stem: "reply"
|
|
10738
|
+
});
|
|
10739
|
+
await fileReplyImages({
|
|
10740
|
+
sessionsDir: options.sessionsDir,
|
|
10741
|
+
taskId,
|
|
10742
|
+
handing: handed.handing,
|
|
10743
|
+
workspace: work.workspace
|
|
10744
|
+
}, written);
|
|
10745
|
+
return {
|
|
10746
|
+
written,
|
|
10747
|
+
lost: 0
|
|
10748
|
+
};
|
|
10749
|
+
} catch (error) {
|
|
10750
|
+
onEvent({
|
|
10751
|
+
type: "problem",
|
|
10752
|
+
message: `Couldn't write the images of the reply to Task ${taskId} into its Workspace: ${messageOf(error)}`
|
|
10753
|
+
});
|
|
10754
|
+
return {
|
|
10755
|
+
written: [],
|
|
10756
|
+
lost: handed.images.length
|
|
10757
|
+
};
|
|
10758
|
+
}
|
|
10759
|
+
};
|
|
8766
10760
|
/** Resumes a Task's work with the Owner's reply, in a new segment, in the background. */
|
|
8767
10761
|
const goOn = (work, reply) => {
|
|
8768
10762
|
if (stopping || killed) {
|
|
@@ -8784,7 +10778,10 @@ function createTaskRunner(options) {
|
|
|
8784
10778
|
await options.underWay.remove(kept);
|
|
8785
10779
|
}
|
|
8786
10780
|
}
|
|
8787
|
-
if (await readyForReply(work, reply, stop))
|
|
10781
|
+
if (await readyForReply(work, reply, stop)) {
|
|
10782
|
+
const images = await replyImagesOf(work, reply);
|
|
10783
|
+
await workSegment(work, replyPromptFor(work.task, reply.reply, images), stop);
|
|
10784
|
+
}
|
|
8788
10785
|
}, () => work.usage ?? null);
|
|
8789
10786
|
};
|
|
8790
10787
|
/** Runs a Session the Platform sent, from its new Workspace on. */
|
|
@@ -8825,7 +10822,10 @@ function createTaskRunner(options) {
|
|
|
8825
10822
|
try {
|
|
8826
10823
|
files = {
|
|
8827
10824
|
workpiece: await writeWorkpiece(workspace, task, options.documents),
|
|
8828
|
-
...task.role === "check" ? { underCheck: await writeHandOffUnderCheck(workspace, task, options.documents) } : {
|
|
10825
|
+
...task.role === "check" ? { underCheck: await writeHandOffUnderCheck(workspace, task, options.documents) } : {
|
|
10826
|
+
previous: await writePreviousAttempt(workspace, task, options.documents),
|
|
10827
|
+
verdicts: await writeVerdictImages(workspace, task, options.documents)
|
|
10828
|
+
}
|
|
8829
10829
|
};
|
|
8830
10830
|
} catch (error) {
|
|
8831
10831
|
await reportEnd(session, {
|
|
@@ -8851,7 +10851,7 @@ function createTaskRunner(options) {
|
|
|
8851
10851
|
taskId
|
|
8852
10852
|
},
|
|
8853
10853
|
workspace,
|
|
8854
|
-
instructions: instructionsFor(task, files),
|
|
10854
|
+
instructions: instructionsFor(task, workspace, files),
|
|
8855
10855
|
skills: readied.skills,
|
|
8856
10856
|
state: "running",
|
|
8857
10857
|
sessionId: null,
|
|
@@ -8863,7 +10863,7 @@ function createTaskRunner(options) {
|
|
|
8863
10863
|
saved: Promise.resolve()
|
|
8864
10864
|
};
|
|
8865
10865
|
works.set(taskId, work);
|
|
8866
|
-
await workSegment(work, promptFor(task), stop);
|
|
10866
|
+
await workSegment(work, promptFor(task, files), stop);
|
|
8867
10867
|
};
|
|
8868
10868
|
/** Ends every Session still running, and waits for what is on its way into the outbox. */
|
|
8869
10869
|
const stopAll = async () => {
|
|
@@ -8879,19 +10879,21 @@ function createTaskRunner(options) {
|
|
|
8879
10879
|
if (running.has(keyOf(session)) || session.role === "work" && works.has(session.taskId)) return;
|
|
8880
10880
|
inBackground(session, (stop) => run(task, stop), () => works.get(session.taskId)?.usage ?? null);
|
|
8881
10881
|
},
|
|
8882
|
-
resume({ taskId, reply, skills }) {
|
|
10882
|
+
resume({ taskId, reply, skills, handed }) {
|
|
8883
10883
|
const work = works.get(taskId);
|
|
8884
10884
|
if (work?.state === "suspended") {
|
|
8885
10885
|
goOn(work, {
|
|
8886
10886
|
reply,
|
|
8887
|
-
skills
|
|
10887
|
+
skills,
|
|
10888
|
+
handed
|
|
8888
10889
|
});
|
|
8889
10890
|
return;
|
|
8890
10891
|
}
|
|
8891
10892
|
if (work) {
|
|
8892
10893
|
work.reply = {
|
|
8893
10894
|
reply,
|
|
8894
|
-
skills
|
|
10895
|
+
skills,
|
|
10896
|
+
handed
|
|
8895
10897
|
};
|
|
8896
10898
|
return;
|
|
8897
10899
|
}
|
|
@@ -8990,6 +10992,11 @@ function createTaskRunner(options) {
|
|
|
8990
10992
|
ending(reportEnd(ref, { kind: "aborted" }, left.suspended.usage, left.item));
|
|
8991
10993
|
}
|
|
8992
10994
|
},
|
|
10995
|
+
whyNoReply(taskId) {
|
|
10996
|
+
if (works.has(taskId)) return null;
|
|
10997
|
+
const left = leftSuspended.get(taskId);
|
|
10998
|
+
return left ? `the node daemon restarted while ${left.suspended.agent} waited for your reply, and doesn't keep a waiting Session across a restart` : "it isn't keeping that Task's Session";
|
|
10999
|
+
},
|
|
8993
11000
|
stop: stopAll,
|
|
8994
11001
|
async leave() {
|
|
8995
11002
|
leaving = true;
|
|
@@ -9200,6 +11207,10 @@ async function startNode(options) {
|
|
|
9200
11207
|
message: `Discarded a message in the outbox that this daemon can't read (${kind === null ? file : `${file}, a ${kind}`}): the Platform never hears it.`
|
|
9201
11208
|
});
|
|
9202
11209
|
const inbox = await openInbox(join(options.stateDir, "inbox"));
|
|
11210
|
+
const handed = await openHandedImages({
|
|
11211
|
+
dir: join(options.stateDir, "handed-images"),
|
|
11212
|
+
accepts: (to) => to.of === "reply" ? runner.whyNoReply(to.taskId) : chats.whyNoImages(to.chatId, to.turn)
|
|
11213
|
+
});
|
|
9203
11214
|
const underWay = await openTasksUnderWay(join(options.stateDir, "under-way"), (message) => onEvent({
|
|
9204
11215
|
type: "problem",
|
|
9205
11216
|
message
|
|
@@ -9222,6 +11233,7 @@ async function startNode(options) {
|
|
|
9222
11233
|
credential,
|
|
9223
11234
|
os: options.os ?? await describeMachine(),
|
|
9224
11235
|
outbox,
|
|
11236
|
+
uploadImages: platformImageUploads(credential),
|
|
9225
11237
|
inbox,
|
|
9226
11238
|
onMessage: (message) => {
|
|
9227
11239
|
switch (message.kind) {
|
|
@@ -9296,7 +11308,8 @@ async function startNode(options) {
|
|
|
9296
11308
|
signal: stopRequests.signal,
|
|
9297
11309
|
machine: options.machine,
|
|
9298
11310
|
workspacesDir,
|
|
9299
|
-
millwrightDir: millwrightAtWorkFolder(options.stateDir)
|
|
11311
|
+
millwrightDir: millwrightAtWorkFolder(options.stateDir),
|
|
11312
|
+
handed
|
|
9300
11313
|
});
|
|
9301
11314
|
const skills = createSkillCopies({
|
|
9302
11315
|
stateDir: options.stateDir,
|
|
@@ -9310,7 +11323,10 @@ async function startNode(options) {
|
|
|
9310
11323
|
...options.clock === void 0 ? {} : { clock: options.clock },
|
|
9311
11324
|
intervalMs: options.sweepIntervalMs ?? 864e5,
|
|
9312
11325
|
onSwept: (workspace) => chats.swept(workspace),
|
|
9313
|
-
sweepAlso: (before) =>
|
|
11326
|
+
sweepAlso: async (before) => {
|
|
11327
|
+
await skills.sweep(before);
|
|
11328
|
+
await handed.sweep(before);
|
|
11329
|
+
},
|
|
9314
11330
|
onEvent
|
|
9315
11331
|
});
|
|
9316
11332
|
const runner = createTaskRunner({
|
|
@@ -9326,7 +11342,8 @@ async function startNode(options) {
|
|
|
9326
11342
|
underWay,
|
|
9327
11343
|
workspaces: sweep,
|
|
9328
11344
|
skills,
|
|
9329
|
-
checkoutTimeoutMs: options.checkoutTimeoutMs ?? 6e5
|
|
11345
|
+
checkoutTimeoutMs: options.checkoutTimeoutMs ?? 6e5,
|
|
11346
|
+
handed
|
|
9330
11347
|
});
|
|
9331
11348
|
const chats = createChatRunner({
|
|
9332
11349
|
connector,
|
|
@@ -9339,6 +11356,7 @@ async function startNode(options) {
|
|
|
9339
11356
|
workspaces: sweep,
|
|
9340
11357
|
skills,
|
|
9341
11358
|
checkoutTimeoutMs: options.checkoutTimeoutMs ?? 6e5,
|
|
11359
|
+
handed,
|
|
9342
11360
|
onEvent
|
|
9343
11361
|
});
|
|
9344
11362
|
await runner.reportLeftUnderWay();
|
|
@@ -9749,7 +11767,7 @@ async function runCli(args, io, context = {}) {
|
|
|
9749
11767
|
const stateDir = defaultStateDir(context.env);
|
|
9750
11768
|
if (values.version && positionals.length === 0) {
|
|
9751
11769
|
const version = installed.from === "npm" ? ` ${installed.version}` : "";
|
|
9752
|
-
io.log(`${PROGRAM}${version} (protocol
|
|
11770
|
+
io.log(`${PROGRAM}${version} (protocol 25)`);
|
|
9753
11771
|
return 0;
|
|
9754
11772
|
}
|
|
9755
11773
|
if (verb === "join" && rest.length === 2) {
|
|
@@ -9850,7 +11868,8 @@ async function startCommand(io, started, context) {
|
|
|
9850
11868
|
...context.protocolRetryMs === void 0 ? {} : { protocolRetryMs: context.protocolRetryMs }
|
|
9851
11869
|
});
|
|
9852
11870
|
node.resolve(running);
|
|
9853
|
-
context.signal?.
|
|
11871
|
+
if (context.signal?.aborted) stop();
|
|
11872
|
+
else context.signal?.addEventListener("abort", () => void stop(), { once: true });
|
|
9854
11873
|
await running.stopped;
|
|
9855
11874
|
await stopping;
|
|
9856
11875
|
if (successor && !context.signal?.aborted) return successor.npm.run(successor.version, started.args, {
|
|
@@ -9862,12 +11881,12 @@ async function startCommand(io, started, context) {
|
|
|
9862
11881
|
/** What the CLI prints of a move the daemon makes once the Platform refused its protocol. */
|
|
9863
11882
|
function describeMove(move) {
|
|
9864
11883
|
if (move.kind === "unreadable") return {
|
|
9865
|
-
text: `The Platform refused this daemon's protocol (
|
|
11884
|
+
text: `The Platform refused this daemon's protocol (25) without saying which it speaks: ${move.reason}`,
|
|
9866
11885
|
problem: true
|
|
9867
11886
|
};
|
|
9868
11887
|
const { platformVersion } = move;
|
|
9869
|
-
if (move.kind === "wait-for-platform") return { text: `The Platform speaks protocol ${platformVersion}, older than this daemon's
|
|
9870
|
-
const newer = `The Platform speaks protocol ${platformVersion}, newer than this daemon's
|
|
11888
|
+
if (move.kind === "wait-for-platform") return { text: `The Platform speaks protocol ${platformVersion}, older than this daemon's 25. Waiting for the Platform to be updated; trying again every few minutes.` };
|
|
11889
|
+
const newer = `The Platform speaks protocol ${platformVersion}, newer than this daemon's 25`;
|
|
9871
11890
|
if (move.kind === "update-checkout") return {
|
|
9872
11891
|
text: `${newer}: update the checkout, then start the Node again.`,
|
|
9873
11892
|
problem: true
|