@fungi.computer/shiitake-extensions 0.0.0-stage → 0.0.1

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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +262 -2
  3. package/dist/almanac/contract.d.ts +213 -0
  4. package/dist/almanac/contract.js +77 -0
  5. package/dist/almanac/host.d.ts +3 -0
  6. package/dist/almanac/host.js +171 -0
  7. package/dist/goal/client.d.ts +10 -0
  8. package/dist/goal/client.js +88 -0
  9. package/dist/goal/contract.d.ts +172 -0
  10. package/dist/goal/contract.js +76 -0
  11. package/dist/goal/host.d.ts +5 -0
  12. package/dist/goal/host.js +542 -0
  13. package/dist/home/contract.d.ts +33 -0
  14. package/dist/home/contract.js +25 -0
  15. package/dist/home/host.d.ts +18 -0
  16. package/dist/home/host.js +136 -0
  17. package/dist/home/routing.d.ts +3 -0
  18. package/dist/home/routing.js +80 -0
  19. package/dist/internal/catalog.d.ts +20 -0
  20. package/dist/internal/catalog.js +33 -0
  21. package/dist/internal/cron.d.ts +5 -0
  22. package/dist/internal/cron.js +14 -0
  23. package/dist/internal/history.d.ts +29 -0
  24. package/dist/internal/history.js +41 -0
  25. package/dist/loop/client.d.ts +10 -0
  26. package/dist/loop/client.js +89 -0
  27. package/dist/loop/contract.d.ts +342 -0
  28. package/dist/loop/contract.js +94 -0
  29. package/dist/loop/host.d.ts +4 -0
  30. package/dist/loop/host.js +637 -0
  31. package/dist/plan/client.d.ts +13 -0
  32. package/dist/plan/client.js +51 -0
  33. package/dist/plan/contract.d.ts +105 -0
  34. package/dist/plan/contract.js +38 -0
  35. package/dist/plan/host.d.ts +3 -0
  36. package/dist/plan/host.js +154 -0
  37. package/dist/question/client.d.ts +10 -0
  38. package/dist/question/client.js +28 -0
  39. package/dist/question/contract.d.ts +251 -0
  40. package/dist/question/contract.js +114 -0
  41. package/dist/question/host.d.ts +3 -0
  42. package/dist/question/host.js +997 -0
  43. package/dist/roster.d.ts +9 -0
  44. package/dist/roster.js +80 -0
  45. package/dist/skills/host.d.ts +7 -0
  46. package/dist/skills/host.js +223 -0
  47. package/dist/skills/source.d.ts +28 -0
  48. package/dist/skills/source.js +1 -0
  49. package/dist/subagents/client.d.ts +10 -0
  50. package/dist/subagents/client.js +53 -0
  51. package/dist/subagents/contract.d.ts +54 -0
  52. package/dist/subagents/contract.js +38 -0
  53. package/dist/subagents/host.d.ts +50 -0
  54. package/dist/subagents/host.js +176 -0
  55. package/package.json +117 -3
@@ -0,0 +1,997 @@
1
+ import { nanoid } from "@fungi.computer/cairn";
2
+ import { Mycelium } from "@fungi.computer/mycelium";
3
+ import { defineCollection, defineExtension, ExtensionCommandError, } from "@fungi.computer/shiitake/extensions";
4
+ import * as Clock from "effect/Clock";
5
+ import * as Effect from "effect/Effect";
6
+ import * as Option from "effect/Option";
7
+ import * as Schema from "effect/Schema";
8
+ import { createHash } from "node:crypto";
9
+ import { z } from "zod";
10
+ import Papa from "papaparse";
11
+ import { ask, catalog, done, freshRequestId } from "../internal/catalog.js";
12
+ import { questionContract, OpenAsk, Questions, Answers, HumanResult, DeclineInput, ClosedStatus, AnswerFlowInput, } from "./contract.js";
13
+ const config = {
14
+ maximumOpen: 4,
15
+ liveWindowMs: 600_000,
16
+ guide: "Use question.ask for decisions that need a person's answer. Put the recommended option first; do not add Other, since free text is always available. Use credential tools for secrets. While a question is parked, finish independent work and end the turn.",
17
+ };
18
+ const Response = Schema.Union([
19
+ Schema.Struct({ status: Schema.Literal("answered"), answers: Answers }),
20
+ Schema.Struct({
21
+ status: Schema.Literal("declined"),
22
+ note: DeclineInput.fields.note,
23
+ }),
24
+ Schema.Struct({
25
+ status: Schema.Literal("expired"),
26
+ action: Schema.Literal("stop"),
27
+ }),
28
+ Schema.Struct({
29
+ status: Schema.Literal("expired"),
30
+ action: Schema.Literal("proceed_with_recommended"),
31
+ answers: Answers,
32
+ }),
33
+ ]);
34
+ const AskRow = Schema.Struct({
35
+ askId: OpenAsk.fields.askId,
36
+ questions: Questions,
37
+ status: OpenAsk.fields.status,
38
+ createdAt: OpenAsk.fields.createdAt,
39
+ expiry: Schema.NullOr(Schema.Struct({
40
+ at: Schema.Number,
41
+ action: Schema.Literals(["stop", "proceed_with_recommended"]),
42
+ })),
43
+ sessionId: Schema.String,
44
+ workId: Schema.String,
45
+ digest: Schema.String,
46
+ runAttemptId: Schema.NullOr(Schema.String),
47
+ pending: Schema.NullOr(Response),
48
+ });
49
+ const Outcome = Schema.Union([
50
+ Response,
51
+ Schema.Struct({ status: Schema.Literal("withdrawn") }),
52
+ Schema.Struct({ status: Schema.Literal("cancelled") }),
53
+ Schema.Struct({
54
+ status: Schema.Literal("undeliverable"),
55
+ reason: Schema.String,
56
+ }),
57
+ ]);
58
+ const ClosedAsk = Schema.Struct({
59
+ ...AskRow.fields,
60
+ status: Schema.Literal("settled"),
61
+ outcome: Outcome,
62
+ replay: Schema.Literals(["parked", "outcome", "none"]),
63
+ delivery: Schema.Union([
64
+ Schema.Struct({ via: Schema.Literal("none") }),
65
+ Schema.Struct({
66
+ via: Schema.Literal("in_turn"),
67
+ status: Schema.Literals(["pending", "settled"]),
68
+ }),
69
+ Schema.Struct({
70
+ via: Schema.Literal("follow_up"),
71
+ workId: Schema.String,
72
+ status: Schema.Literals(["pending", "settled", "withdrawn"]),
73
+ }),
74
+ ]),
75
+ });
76
+ const Receipt = Schema.Struct({
77
+ id: Schema.String,
78
+ digest: Schema.String,
79
+ sessionId: Schema.String,
80
+ requestId: Schema.String,
81
+ payload: Schema.String,
82
+ result: HumanResult,
83
+ });
84
+ const collections = {
85
+ deliveries: defineCollection(Schema.Struct({
86
+ id: Schema.String,
87
+ workId: Schema.String,
88
+ askId: Schema.String,
89
+ toolCallId: Schema.String,
90
+ }), {
91
+ key: (row) => row.id,
92
+ index: (row) => row.workId,
93
+ }),
94
+ closed: defineCollection(ClosedAsk, {
95
+ key: (row) => row.askId,
96
+ index: (row) => (row.replay === "none" ? null : row.digest),
97
+ }),
98
+ receipts: defineCollection(Receipt, {
99
+ key: (row) => row.id,
100
+ index: (row) => row.digest,
101
+ }),
102
+ asks: defineCollection(AskRow, {
103
+ key: (row) => row.askId,
104
+ index: (row) => row.sessionId,
105
+ }),
106
+ };
107
+ /** Property order and an omitted false flag do not make a different question. */
108
+ function questionDigest(sessionId, workId, questions) {
109
+ return createHash("sha256")
110
+ .update(JSON.stringify([
111
+ sessionId,
112
+ workId,
113
+ questions.map((question) => [
114
+ question.id,
115
+ question.header,
116
+ question.question,
117
+ question.options.map((option) => [option.label, option.description]),
118
+ question.multiple ?? false,
119
+ ]),
120
+ ]))
121
+ .digest("hex");
122
+ }
123
+ function parkAsk(ctx, row) {
124
+ return Effect.gen(function* () {
125
+ const parked = { ...row, status: "parked", runAttemptId: null };
126
+ yield* ctx.state.asks.put(parked);
127
+ yield* ctx.observe(row.sessionId);
128
+ return parked;
129
+ });
130
+ }
131
+ function closedReplay(outcome, followUpWorkId) {
132
+ if (outcome.status === "cancelled")
133
+ return "none";
134
+ if (followUpWorkId !== null)
135
+ return "parked";
136
+ return "outcome";
137
+ }
138
+ /** Every terminal transition commits outcome, delivery and visibility together. */
139
+ function closeAsk(ctx, row, outcome, followUpWorkId = null, toolCallId) {
140
+ return Effect.gen(function* () {
141
+ yield* ctx.state.closed.put({
142
+ ...row,
143
+ pending: null,
144
+ runAttemptId: null,
145
+ status: "settled",
146
+ outcome,
147
+ replay: closedReplay(outcome, followUpWorkId),
148
+ delivery: followUpWorkId !== null
149
+ ? { via: "follow_up", workId: followUpWorkId, status: "pending" }
150
+ : outcome.status === "answered" ||
151
+ outcome.status === "declined" ||
152
+ outcome.status === "expired"
153
+ ? {
154
+ via: "in_turn",
155
+ status: toolCallId === undefined ? "settled" : "pending",
156
+ }
157
+ : { via: "none" },
158
+ });
159
+ if (toolCallId !== undefined)
160
+ yield* recordDelivery(ctx, row, toolCallId);
161
+ yield* ctx.state.asks.remove(row.askId);
162
+ if (row.expiry !== null)
163
+ yield* ctx.timers.cancel(`ask:${row.askId}`);
164
+ yield* ctx.attention.clear(row.sessionId, row.askId);
165
+ yield* ctx.observe(row.sessionId);
166
+ });
167
+ }
168
+ function recordDelivery(ctx, row, toolCallId) {
169
+ return ctx.state.deliveries.put({
170
+ id: JSON.stringify([row.askId, toolCallId]),
171
+ askId: row.askId,
172
+ workId: row.workId,
173
+ toolCallId,
174
+ });
175
+ }
176
+ function acknowledgeDelivery(ctx, run, receipt) {
177
+ return Effect.gen(function* () {
178
+ const closed = yield* ctx.state.closed.get(receipt.askId);
179
+ if (Option.isSome(closed) &&
180
+ closed.value.delivery.via === "in_turn" &&
181
+ closed.value.delivery.status === "pending" &&
182
+ (yield* ctx.hasSuccessfulToolResult(run, receipt.toolCallId)))
183
+ yield* ctx.state.closed.put({
184
+ ...closed.value,
185
+ delivery: { via: "in_turn", status: "settled" },
186
+ });
187
+ });
188
+ }
189
+ function recoverDelivery(ctx, run, askId) {
190
+ return Effect.gen(function* () {
191
+ const closed = yield* ctx.state.closed.get(askId);
192
+ if (Option.isSome(closed) &&
193
+ closed.value.delivery.via === "in_turn" &&
194
+ closed.value.delivery.status === "pending") {
195
+ const row = closed.value;
196
+ if (run.outcome === "cancelled")
197
+ yield* closeAsk(ctx, { ...row, status: "parked" }, { status: "cancelled" });
198
+ else if (row.outcome.status === "answered" ||
199
+ row.outcome.status === "declined" ||
200
+ row.outcome.status === "expired")
201
+ yield* deliverResponse(ctx, { ...row, status: "parked" }, row.outcome);
202
+ }
203
+ });
204
+ }
205
+ /** A returned value is acknowledged only after Mule has durably saved its result. */
206
+ function reconcileDeliveries(ctx, run) {
207
+ return Effect.gen(function* () {
208
+ // First acknowledge every successful consumer. A failed parallel consumer
209
+ // must not resend an answer another consumer durably received.
210
+ let after;
211
+ for (;;) {
212
+ const page = yield* ctx.state.deliveries.page({
213
+ index: run.workId,
214
+ after,
215
+ limit: 64,
216
+ });
217
+ if (page.length === 0)
218
+ break;
219
+ for (const receipt of page) {
220
+ yield* acknowledgeDelivery(ctx, run, receipt);
221
+ after = receipt.id;
222
+ }
223
+ }
224
+ for (;;) {
225
+ const page = yield* ctx.state.deliveries.page({
226
+ index: run.workId,
227
+ limit: 64,
228
+ });
229
+ if (page.length === 0)
230
+ break;
231
+ for (const receipt of page) {
232
+ yield* recoverDelivery(ctx, run, receipt.askId);
233
+ yield* ctx.state.deliveries.remove(receipt.id);
234
+ }
235
+ }
236
+ });
237
+ }
238
+ function settleFollowUp(ctx, run) {
239
+ return Effect.gen(function* () {
240
+ if (run.origin?.extensionId === questionContract.id &&
241
+ run.origin.correlation !== null) {
242
+ const closed = yield* ctx.state.closed.get(run.origin.correlation);
243
+ if (Option.isSome(closed) && closed.value.sessionId === run.sessionId) {
244
+ const row = closed.value;
245
+ if (row.delivery.via === "follow_up" &&
246
+ row.delivery.workId === run.workId &&
247
+ row.delivery.status === "pending")
248
+ yield* ctx.state.closed.put({
249
+ ...row,
250
+ delivery: {
251
+ ...row.delivery,
252
+ status: run.outcome === "cancelled" ? "withdrawn" : "settled",
253
+ },
254
+ });
255
+ }
256
+ }
257
+ });
258
+ }
259
+ function settle(ctx, run) {
260
+ return Effect.gen(function* () {
261
+ yield* reconcileDeliveries(ctx, run);
262
+ yield* settleFollowUp(ctx, run);
263
+ const open = yield* ctx.state.asks.page({
264
+ index: run.sessionId,
265
+ limit: ctx.config.maximumOpen,
266
+ });
267
+ for (const row of open) {
268
+ if (row.workId !== run.workId)
269
+ continue;
270
+ if (run.outcome === "cancelled")
271
+ yield* closeAsk(ctx, row, { status: "cancelled" });
272
+ else if (row.pending !== null)
273
+ yield* deliverResponse(ctx, row, row.pending);
274
+ else if (row.status === "live")
275
+ yield* parkAsk(ctx, row);
276
+ }
277
+ });
278
+ }
279
+ function project(row) {
280
+ return {
281
+ askId: row.askId,
282
+ questions: row.questions,
283
+ status: row.status,
284
+ createdAt: row.createdAt,
285
+ expiresAt: row.expiry?.at ?? null,
286
+ };
287
+ }
288
+ const encoder = new TextEncoder();
289
+ /** Keep every question and selection represented within the follow-up byte budget. */
290
+ function clip(text, budget) {
291
+ const bytes = encoder.encode(text);
292
+ if (bytes.length <= budget)
293
+ return text;
294
+ const suffix = "… [truncated]";
295
+ const room = budget - encoder.encode(suffix).length;
296
+ if (room <= 0)
297
+ return "";
298
+ return (new TextDecoder().decode(bytes.subarray(0, room), { stream: true }) + suffix);
299
+ }
300
+ function responsePrompt(row, response) {
301
+ const prefix = response.status === "answered"
302
+ ? `Answer to ask ${row.askId}:\n`
303
+ : response.status === "declined"
304
+ ? `Declined ask ${row.askId}:\n${response.note === undefined ? "" : `Note: ${clip(response.note, 2_048)}\n`}`
305
+ : `Ask ${row.askId} expired; ${response.action === "stop" ? "do not proceed with this decision" : "recommended default"}:\n`;
306
+ const perQuestion = Math.floor((8_192 - encoder.encode(prefix).length) / row.questions.length) -
307
+ 1;
308
+ const byId = response.status === "answered" ||
309
+ (response.status === "expired" &&
310
+ response.action === "proceed_with_recommended")
311
+ ? new Map(Object.entries(response.answers))
312
+ : undefined;
313
+ return (prefix +
314
+ row.questions
315
+ .map((question) => {
316
+ const questionText = `${question.header} (${question.id}): ${clip(question.question, Math.floor(perQuestion / 3))}`;
317
+ if (byId === undefined)
318
+ return questionText;
319
+ const answer = byId.get(question.id);
320
+ if (answer === undefined)
321
+ throw new Error("Validated answer is missing");
322
+ const selection = answer.selected.length === 0
323
+ ? "(free text)"
324
+ : answer.selected.join(", ");
325
+ const base = `${questionText}\nSelected: ${selection}`;
326
+ if (answer.text === undefined || answer.text.length === 0)
327
+ return base;
328
+ const heading = base + "\nText: ";
329
+ return (heading +
330
+ clip(answer.text, perQuestion - encoder.encode(heading).length));
331
+ })
332
+ .join("\n"));
333
+ }
334
+ /** Commands and settlement use the same atomic follow-up admission and closure. */
335
+ function deliverResponse(ctx, row, response) {
336
+ return Effect.gen(function* () {
337
+ const delivered = yield* ctx
338
+ .send({
339
+ sessionId: row.sessionId,
340
+ requireExistingSession: true,
341
+ when: "now",
342
+ correlation: row.askId,
343
+ prompt: {
344
+ text: responsePrompt(row, response),
345
+ requestId: `ask:${row.askId}`,
346
+ delivery: "followUp",
347
+ },
348
+ })
349
+ .pipe(Effect.map((sent) => sent._tag === "Accepted"
350
+ ? { status: "sent", workId: sent.commandId }
351
+ : { status: "failed", reason: "not_ready" }), Effect.catchTag("AdmissionError", (error) => Effect.succeed({ status: "failed", reason: error.reason })));
352
+ const outcome = delivered.status === "sent"
353
+ ? response
354
+ : { status: "undeliverable", reason: delivered.reason };
355
+ yield* closeAsk(ctx, row, outcome, delivered.status === "sent" ? delivered.workId : null);
356
+ return delivered.status === "sent"
357
+ ? { result: "accepted", via: "follow_up" }
358
+ : { result: "closed", status: "undeliverable" };
359
+ });
360
+ }
361
+ /** Live responses await the tool; parked responses cross the send door now. */
362
+ function offerResponse(ctx, row, response) {
363
+ return Effect.gen(function* () {
364
+ if (row.status === "parked")
365
+ return yield* deliverResponse(ctx, row, response);
366
+ yield* ctx.state.asks.put({ ...row, pending: response });
367
+ yield* ctx.attention.clear(row.sessionId, row.askId);
368
+ yield* ctx.observe(row.sessionId);
369
+ return { result: "accepted", via: "in_turn" };
370
+ });
371
+ }
372
+ /** One expiry transition, shared by durable timers and a live waiter. */
373
+ function expireAsk(ctx, row, now) {
374
+ return Effect.gen(function* () {
375
+ if (row.expiry === null || now < row.expiry.at || row.pending !== null)
376
+ return undefined;
377
+ const response = row.expiry.action === "stop"
378
+ ? { status: "expired", action: "stop" }
379
+ : {
380
+ status: "expired",
381
+ action: "proceed_with_recommended",
382
+ answers: Object.fromEntries(row.questions.map((question) => [
383
+ question.id,
384
+ {
385
+ selected: question.options
386
+ .slice(0, 1)
387
+ .map((option) => option.label),
388
+ },
389
+ ])),
390
+ };
391
+ yield* offerResponse(ctx, row, response);
392
+ return response;
393
+ });
394
+ }
395
+ function validAnswers(questions, values) {
396
+ const answers = new Map(Object.entries(values));
397
+ const valid = answers.size === questions.length &&
398
+ questions.every((question) => {
399
+ const answer = answers.get(question.id);
400
+ return (answer !== undefined &&
401
+ (question.multiple === true || answer.selected.length <= 1) &&
402
+ new Set(answer.selected).size === answer.selected.length &&
403
+ answer.selected.every((label) => question.options.some((option) => option.label === label)) &&
404
+ (answer.selected.length > 0 || (answer.text?.trim().length ?? 0) > 0));
405
+ });
406
+ return valid;
407
+ }
408
+ function responseIdentity(input) {
409
+ const response = input.kind === "answer"
410
+ ? { status: "answered", answers: input.answers }
411
+ : input.note === undefined
412
+ ? { status: "declined" }
413
+ : { status: "declined", note: input.note };
414
+ const payload = JSON.stringify([
415
+ input.kind,
416
+ input.askId,
417
+ input.kind === "answer"
418
+ ? Object.entries(input.answers)
419
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
420
+ .map(([id, answer]) => [
421
+ id,
422
+ [...answer.selected].sort(),
423
+ answer.text ?? null,
424
+ ])
425
+ : (input.note ?? null),
426
+ ]);
427
+ return { response, payload };
428
+ }
429
+ function respond(ctx, sessionId, input) {
430
+ return Effect.gen(function* () {
431
+ const { response, payload } = responseIdentity(input);
432
+ const receiptDigest = createHash("sha256")
433
+ .update(JSON.stringify([sessionId, input.requestId]))
434
+ .digest("hex");
435
+ const prior = (yield* ctx.state.receipts.page({
436
+ index: receiptDigest,
437
+ limit: 1,
438
+ }))[0];
439
+ if (prior !== undefined) {
440
+ if (prior.payload !== payload)
441
+ return yield* Effect.fail(new ExtensionCommandError({
442
+ reason: "conflict",
443
+ message: "This request id was used for a different response",
444
+ }));
445
+ return prior.result;
446
+ }
447
+ const record = (result) => ctx.state.receipts
448
+ .put({
449
+ id: nanoid(),
450
+ digest: receiptDigest,
451
+ sessionId,
452
+ requestId: input.requestId,
453
+ payload,
454
+ result,
455
+ })
456
+ .pipe(Effect.as(result));
457
+ const found = yield* ctx.state.asks.get(input.askId);
458
+ if (Option.isNone(found) || found.value.sessionId !== sessionId) {
459
+ const closed = yield* ctx.state.closed.get(input.askId);
460
+ return yield* record(Option.isSome(closed) && closed.value.sessionId === sessionId
461
+ ? { result: "closed", status: closed.value.outcome.status }
462
+ : { result: "not_found" });
463
+ }
464
+ const row = found.value;
465
+ if (row.pending !== null)
466
+ return yield* record({ result: "closed", status: row.pending.status });
467
+ if (input.kind === "answer") {
468
+ const valid = validAnswers(row.questions, input.answers);
469
+ if (!valid)
470
+ return yield* Effect.fail(new ExtensionCommandError({
471
+ reason: "invalid",
472
+ message: "Answer every question using its offered labels or nonempty text",
473
+ }));
474
+ }
475
+ return yield* record(yield* offerResponse(ctx, row, response));
476
+ });
477
+ }
478
+ // Mycelium's maintained operation interface uses Zod. The owner additionally
479
+ // decodes Questions for shared domain refinements and durable state.
480
+ const baseInput = z.strictObject({
481
+ questions: z
482
+ .array(z.strictObject({
483
+ id: z.string().min(1).max(32),
484
+ header: z.string().min(1).max(12),
485
+ question: z.string().min(1).max(1_000),
486
+ options: z
487
+ .array(z.strictObject({
488
+ label: z.string().min(1).max(60),
489
+ description: z.string().min(1).max(300),
490
+ }))
491
+ .min(2)
492
+ .max(4),
493
+ multiple: z.boolean().default(false),
494
+ }))
495
+ .min(1)
496
+ .max(4),
497
+ mode: z.enum(["wait", "continue"]).default("wait"),
498
+ });
499
+ const input = z.union([
500
+ baseInput.extend({
501
+ onExpire: z.strictObject({
502
+ afterMinutes: z
503
+ .number()
504
+ .int()
505
+ .min(5)
506
+ .max(7 * 24 * 60),
507
+ action: z.enum(["stop", "proceed_with_recommended"]),
508
+ }),
509
+ }),
510
+ baseInput,
511
+ ]);
512
+ const deadlineReserveMs = 60_000;
513
+ const pollIntervalMs = 1_000;
514
+ /** Wake on abort as well as the polling edge; durable state owns the response. */
515
+ function pause(ms, signal) {
516
+ return new Promise((resolve) => {
517
+ const finish = () => {
518
+ clearTimeout(timer);
519
+ signal.removeEventListener("abort", finish);
520
+ resolve();
521
+ };
522
+ const timer = setTimeout(finish, ms);
523
+ signal.addEventListener("abort", finish, { once: true });
524
+ if (signal.aborted)
525
+ finish();
526
+ });
527
+ }
528
+ async function waitForResponse(ctx, askId, until, invocation) {
529
+ for (;;) {
530
+ const next = await Effect.runPromise(ctx.transact(Effect.gen(function* () {
531
+ const found = yield* ctx.state.asks.get(askId);
532
+ if (invocation.signal.aborted) {
533
+ if (Option.isSome(found))
534
+ yield* parkAsk(ctx, found.value);
535
+ return { status: "aborted" };
536
+ }
537
+ if (Option.isNone(found)) {
538
+ const closed = yield* ctx.state.closed.get(askId);
539
+ if (Option.isSome(closed) &&
540
+ closed.value.outcome.status === "withdrawn")
541
+ return closed.value.outcome;
542
+ throw new Error("Waiting question disappeared");
543
+ }
544
+ const row = found.value;
545
+ if (row.pending !== null) {
546
+ yield* closeAsk(ctx, row, row.pending, null, invocation.toolCallId);
547
+ return row.pending;
548
+ }
549
+ const now = yield* Clock.currentTimeMillis;
550
+ const expired = yield* expireAsk(ctx, row, now);
551
+ if (expired !== undefined) {
552
+ yield* closeAsk(ctx, row, expired, null, invocation.toolCallId);
553
+ return expired;
554
+ }
555
+ const pendingSteer = yield* ctx.hasPendingSteer({
556
+ sessionId: row.sessionId,
557
+ workId: row.workId,
558
+ });
559
+ if (now >= until || pendingSteer) {
560
+ yield* parkAsk(ctx, row);
561
+ return { status: "parked", askId };
562
+ }
563
+ return {
564
+ status: "waiting",
565
+ delay: Math.min(pollIntervalMs, until - now),
566
+ };
567
+ })));
568
+ if (next.status === "aborted") {
569
+ invocation.signal.throwIfAborted();
570
+ throw new Error("Aborted invocation has no abort reason");
571
+ }
572
+ if (next.status !== "waiting")
573
+ return next;
574
+ await pause(next.delay, invocation.signal);
575
+ }
576
+ }
577
+ const toolAnswers = z.record(z.string(), z.union([
578
+ z.strictObject({ selected: z.array(z.string()), text: z.string() }),
579
+ z.strictObject({ selected: z.array(z.string()) }),
580
+ ]));
581
+ const ToolResult = z.union([
582
+ z.strictObject({ status: z.literal("withdrawn") }),
583
+ z.strictObject({ status: z.literal("undeliverable"), reason: z.string() }),
584
+ z.strictObject({
585
+ status: z.literal("parked"),
586
+ askId: z.string(),
587
+ }),
588
+ z.strictObject({
589
+ status: z.literal("answered"),
590
+ answers: toolAnswers,
591
+ }),
592
+ z.strictObject({ status: z.literal("declined"), note: z.string() }),
593
+ z.strictObject({ status: z.literal("declined") }),
594
+ z.strictObject({ status: z.literal("expired"), action: z.literal("stop") }),
595
+ z.strictObject({
596
+ status: z.literal("expired"),
597
+ action: z.literal("proceed_with_recommended"),
598
+ answers: toolAnswers,
599
+ }),
600
+ z.strictObject({
601
+ status: z.literal("refused"),
602
+ reason: z.enum(["session_limit", "live_limit"]),
603
+ askIds: z.array(z.string()),
604
+ }),
605
+ ]);
606
+ const WithdrawResult = z.union([
607
+ z.strictObject({
608
+ result: z.literal("closed"),
609
+ status: z.enum(ClosedStatus.literals),
610
+ }),
611
+ z.strictObject({ result: z.literal("not_found") }),
612
+ ]);
613
+ function withdraw(ctx, sessionId, askId) {
614
+ return Effect.gen(function* () {
615
+ const found = yield* ctx.state.asks.get(askId);
616
+ if (Option.isSome(found) && found.value.sessionId === sessionId) {
617
+ const row = found.value;
618
+ if (row.pending !== null)
619
+ return { result: "closed", status: row.pending.status };
620
+ yield* closeAsk(ctx, row, { status: "withdrawn" });
621
+ return { result: "closed", status: "withdrawn" };
622
+ }
623
+ const closed = yield* ctx.state.closed.get(askId);
624
+ return Option.isSome(closed) && closed.value.sessionId === sessionId
625
+ ? { result: "closed", status: closed.value.outcome.status }
626
+ : { result: "not_found" };
627
+ });
628
+ }
629
+ function answerNotice(result) {
630
+ if (result.result === "accepted")
631
+ return done({ type: "success", message: "Answer sent." });
632
+ return done({
633
+ type: "info",
634
+ message: result.result === "closed"
635
+ ? `Question closed: ${result.status}.`
636
+ : "That question is no longer available.",
637
+ });
638
+ }
639
+ function chooseQuestion(ctx, sessionId, askId) {
640
+ return Effect.gen(function* () {
641
+ if (askId === undefined) {
642
+ const rows = (yield* ctx.state.asks.page({
643
+ index: sessionId,
644
+ limit: ctx.config.maximumOpen,
645
+ })).filter((row) => row.pending === null);
646
+ if (rows.length === 0)
647
+ return done({ type: "info", message: "No questions are waiting." });
648
+ if (rows.length > 1)
649
+ return ask({
650
+ type: "choice",
651
+ message: "Which question would you like to answer?",
652
+ options: rows.map((row) => ({
653
+ value: row.askId,
654
+ label: row.questions
655
+ .map((question) => question.header)
656
+ .join(" · "),
657
+ })),
658
+ }, {}, "askId");
659
+ const first = rows[0];
660
+ if (first === undefined)
661
+ throw new Error("Expected the waiting question");
662
+ return { type: "selected", askId: first.askId };
663
+ }
664
+ return { type: "selected", askId };
665
+ });
666
+ }
667
+ function multipleSelection(question, selection) {
668
+ const parsed = Papa.parse(selection, {
669
+ delimiter: ",",
670
+ skipEmptyLines: true,
671
+ });
672
+ const selected = parsed.data[0] ?? [];
673
+ const offered = new Set(question.options.map((option) => option.label));
674
+ if (question.multiple !== true ||
675
+ parsed.errors.length > 0 ||
676
+ parsed.data.length > 1 ||
677
+ new Set(selected).size !== selected.length ||
678
+ selected.some((label) => !offered.has(label)))
679
+ return Effect.fail(new ExtensionCommandError({
680
+ reason: "invalid",
681
+ message: "Enter offered labels once each as a comma list; quote labels containing commas",
682
+ }));
683
+ return Effect.succeed(selected);
684
+ }
685
+ function singleSelection(question, selection) {
686
+ if (selection === "free_text")
687
+ return Effect.succeed([]);
688
+ const option = question.options.find((option) => `option:${option.label}` === selection);
689
+ return option === undefined
690
+ ? Effect.fail(new ExtensionCommandError({
691
+ reason: "invalid",
692
+ message: "Choose an offered option or free text",
693
+ }))
694
+ : Effect.succeed([option.label]);
695
+ }
696
+ function promptSelection(question, next, input) {
697
+ const selected = input.stage === "multiple"
698
+ ? multipleSelection(question, input.selection)
699
+ : singleSelection(question, input.selection);
700
+ return selected.pipe(Effect.map((labels) => ask({
701
+ type: "text",
702
+ message: labels.length === 0
703
+ ? `Your answer for ${question.header}:`
704
+ : `Additional context for ${question.header} (leave blank to skip):`,
705
+ }, { stage: "text", ...next, selected: labels }, "text")));
706
+ }
707
+ function promptQuestion(nextQuestion, next) {
708
+ if (nextQuestion.multiple === true)
709
+ return ask({
710
+ type: "text",
711
+ message: `${nextQuestion.question}\n${nextQuestion.options.map((option) => `${Papa.unparse([[option.label]])}: ${option.description}`).join("\n")}\nEnter selected labels as a comma list (quote labels containing commas), or leave blank for free text.`,
712
+ }, { stage: "multiple", ...next }, "selection");
713
+ return ask({
714
+ type: "choice",
715
+ message: nextQuestion.question,
716
+ options: [
717
+ ...nextQuestion.options.map((option) => ({
718
+ value: `option:${option.label}`,
719
+ label: option.label,
720
+ description: option.description,
721
+ })),
722
+ { value: "free_text", label: "Write an answer" },
723
+ ],
724
+ }, { stage: "selection", ...next }, "selection");
725
+ }
726
+ function answerFlow(ctx, sessionId, input) {
727
+ return Effect.gen(function* () {
728
+ const selection = yield* chooseQuestion(ctx, sessionId, "askId" in input ? input.askId : undefined);
729
+ if (selection.type !== "selected")
730
+ return selection;
731
+ const { askId } = selection;
732
+ const open = yield* ctx.state.asks.get(askId);
733
+ const row = Option.getOrNull(open) ??
734
+ Option.getOrNull(yield* ctx.state.closed.get(askId));
735
+ if (row?.sessionId !== sessionId)
736
+ return done({
737
+ type: "info",
738
+ message: "That question is no longer available.",
739
+ });
740
+ return yield* answerQuestion(ctx, sessionId, row, input);
741
+ });
742
+ }
743
+ /** Progress the selected Ask's draft; request replay still belongs to respond. */
744
+ function answerQuestion(ctx, sessionId, row, input) {
745
+ return Effect.gen(function* () {
746
+ const draft = "stage" in input
747
+ ? input
748
+ : {
749
+ stage: "start",
750
+ index: 0,
751
+ requestId: yield* freshRequestId,
752
+ answers: {},
753
+ };
754
+ let { index, answers } = draft;
755
+ const { requestId } = draft;
756
+ const question = row.questions[index];
757
+ if (question === undefined)
758
+ return yield* Effect.fail(new ExtensionCommandError({
759
+ reason: "invalid",
760
+ message: "Unknown question step",
761
+ }));
762
+ if (draft.stage === "text") {
763
+ const answer = draft.text.length === 0
764
+ ? { selected: draft.selected }
765
+ : { selected: draft.selected, text: draft.text };
766
+ answers = { ...answers, [question.id]: answer };
767
+ index += 1;
768
+ if (index === row.questions.length) {
769
+ const result = yield* respond(ctx, sessionId, {
770
+ kind: "answer",
771
+ askId: row.askId,
772
+ requestId,
773
+ answers,
774
+ });
775
+ return answerNotice(result);
776
+ }
777
+ }
778
+ if (row.status === "settled" || row.pending !== null)
779
+ return done({
780
+ type: "info",
781
+ message: "That question is already closed.",
782
+ });
783
+ const next = { askId: row.askId, requestId, index, answers };
784
+ if (draft.stage === "selection" || draft.stage === "multiple")
785
+ return yield* promptSelection(question, next, draft);
786
+ const nextQuestion = row.questions[index];
787
+ if (nextQuestion === undefined)
788
+ throw new Error("Expected the next question");
789
+ return promptQuestion(nextQuestion, next);
790
+ });
791
+ }
792
+ function replayQuestion(ctx, open, workId, digest, toolCallId) {
793
+ return Effect.gen(function* () {
794
+ const prior = open.find((row) => row.workId === workId && row.digest === digest);
795
+ if (prior !== undefined) {
796
+ if (prior.status === "parked" && prior.pending !== null) {
797
+ yield* closeAsk(ctx, prior, prior.pending, null, toolCallId);
798
+ return prior.pending;
799
+ }
800
+ return { status: "parked", askId: prior.askId };
801
+ }
802
+ const consumed = (yield* ctx.state.closed.page({
803
+ index: digest,
804
+ limit: 1,
805
+ }))[0];
806
+ if (consumed?.replay === "parked")
807
+ return {
808
+ status: "parked",
809
+ askId: consumed.askId,
810
+ };
811
+ if (consumed?.outcome.status === "answered" ||
812
+ consumed?.outcome.status === "declined" ||
813
+ consumed?.outcome.status === "expired" ||
814
+ consumed?.outcome.status === "withdrawn" ||
815
+ consumed?.outcome.status === "undeliverable") {
816
+ if (consumed.delivery.via === "in_turn" &&
817
+ consumed.delivery.status === "pending" &&
818
+ toolCallId !== undefined)
819
+ yield* recordDelivery(ctx, consumed, toolCallId);
820
+ return consumed.outcome;
821
+ }
822
+ return undefined;
823
+ });
824
+ }
825
+ function answerCatalog(available) {
826
+ return catalog([
827
+ {
828
+ id: "answerFlow",
829
+ title: "Answer a question",
830
+ category: "Conversation",
831
+ description: "Answer a waiting question",
832
+ availability: available
833
+ ? { status: "available" }
834
+ : {
835
+ status: "unavailable",
836
+ reason: "There is no waiting question to answer.",
837
+ },
838
+ projections: {
839
+ slash: { aliases: ["answer"], order: 5 },
840
+ palette: { order: 5 },
841
+ help: { order: 5 },
842
+ },
843
+ },
844
+ ]);
845
+ }
846
+ /** One durable question owner over the ordinary extension doors. */
847
+ export const questionExtension = defineExtension({
848
+ id: questionContract.id,
849
+ version: "1.0.0",
850
+ })
851
+ .config(config)
852
+ .state({ version: 1, migrations: {}, collections })
853
+ .define({
854
+ catalog: answerCatalog(false),
855
+ attention: true,
856
+ timers: true,
857
+ hooks: {
858
+ onSettled: (ctx, page) => Effect.forEach(page, (run) => settle(ctx, run), { discard: true }),
859
+ onTimer: (ctx, timer) => Effect.gen(function* () {
860
+ if (!timer.key.startsWith("ask:"))
861
+ return;
862
+ const row = yield* ctx.state.asks.get(timer.key.slice(4));
863
+ if (Option.isSome(row))
864
+ yield* expireAsk(ctx, row.value, timer.firedAt);
865
+ }),
866
+ },
867
+ prompt: (ctx) => [{ name: "question", text: ctx.config.guide }],
868
+ projection: {
869
+ schema: questionContract.projection.schema,
870
+ catalog: (value) => answerCatalog(value.asks.length > 0),
871
+ read: (ctx, sessionId) => Option.some({
872
+ asks: ctx.state.asks
873
+ .peekPage({ index: sessionId, limit: ctx.config.maximumOpen })
874
+ .filter((row) => row.pending === null)
875
+ .map(project),
876
+ }),
877
+ },
878
+ commands: {
879
+ specs: questionContract.commands,
880
+ handlers: (ctx) => ({
881
+ answerFlow: ({ sessionId, input }) => answerFlow(ctx, sessionId, input),
882
+ answer: ({ sessionId, input }) => respond(ctx, sessionId, { kind: "answer", ...input }),
883
+ decline: ({ sessionId, input }) => respond(ctx, sessionId, { kind: "decline", ...input }),
884
+ list: ({ sessionId }) => ctx.state.asks
885
+ .page({ index: sessionId, limit: ctx.config.maximumOpen })
886
+ .pipe(Effect.map((rows) => ({
887
+ asks: rows.filter((row) => row.pending === null).map(project),
888
+ }))),
889
+ }),
890
+ },
891
+ tools: (ctx, { sessionId, workId }) => {
892
+ const runAttemptId = nanoid();
893
+ return Mycelium.module({
894
+ id: "botanical.session-question",
895
+ name: "question",
896
+ description: ctx.config.guide,
897
+ operations: {
898
+ withdraw: Mycelium.operation({
899
+ replay: "unsafe",
900
+ description: "Withdraw a question that no longer needs a human answer. Repeating withdrawal preserves its closed outcome.",
901
+ input: z.strictObject({ askId: z.string().min(1).max(256) }),
902
+ output: WithdrawResult,
903
+ execute: async ({ askId }) => WithdrawResult.parse(await Effect.runPromise(ctx.transact(withdraw(ctx, sessionId, askId)))),
904
+ }),
905
+ ask: Mycelium.operation({
906
+ replay: "unsafe",
907
+ description: ctx.config.guide,
908
+ input,
909
+ output: ToolResult,
910
+ execute: async (value, invocation) => {
911
+ const created = await Effect.runPromise(ctx.transact(Effect.gen(function* () {
912
+ const questions = yield* Schema.decodeUnknownEffect(Questions)(value.questions).pipe(Effect.mapError(() => new ExtensionCommandError({
913
+ reason: "invalid",
914
+ message: "Invalid question identifiers or option labels",
915
+ })));
916
+ const digest = questionDigest(sessionId, workId, questions);
917
+ const rows = yield* ctx.state.asks.page({
918
+ index: sessionId,
919
+ limit: ctx.config.maximumOpen,
920
+ });
921
+ const open = yield* Effect.forEach(rows, (row) => row.workId === workId &&
922
+ row.status === "live" &&
923
+ row.runAttemptId !== runAttemptId
924
+ ? parkAsk(ctx, row)
925
+ : Effect.succeed(row));
926
+ const replay = yield* replayQuestion(ctx, open, workId, digest, invocation.toolCallId);
927
+ if (replay !== undefined)
928
+ return replay;
929
+ if (open.length === ctx.config.maximumOpen)
930
+ return {
931
+ status: "refused",
932
+ reason: "session_limit",
933
+ askIds: open.map((row) => row.askId),
934
+ };
935
+ const now = yield* Clock.currentTimeMillis;
936
+ const window = invocation.deadlineAt === null
937
+ ? 0
938
+ : Math.max(0, Math.min(ctx.config.liveWindowMs, invocation.deadlineAt - now - deadlineReserveMs));
939
+ const live = value.mode === "wait" && window > 0;
940
+ const existingLive = open.filter((row) => row.workId === workId && row.status === "live");
941
+ if (live && existingLive.length > 0)
942
+ return {
943
+ status: "refused",
944
+ reason: "live_limit",
945
+ askIds: existingLive.map((row) => row.askId),
946
+ };
947
+ const row = {
948
+ askId: nanoid(),
949
+ sessionId,
950
+ workId,
951
+ digest,
952
+ questions,
953
+ status: live ? "live" : "parked",
954
+ runAttemptId: live ? runAttemptId : null,
955
+ pending: null,
956
+ createdAt: now,
957
+ expiry: "onExpire" in value
958
+ ? {
959
+ at: now + value.onExpire.afterMinutes * 60_000,
960
+ action: value.onExpire.action,
961
+ }
962
+ : null,
963
+ };
964
+ yield* ctx.state.asks.put(row);
965
+ if (row.expiry !== null)
966
+ yield* ctx.timers.set(`ask:${row.askId}`, row.expiry.at);
967
+ const attention = {
968
+ id: row.askId,
969
+ kind: "question",
970
+ summary: questions
971
+ .map((question) => question.header)
972
+ .join(", "),
973
+ since: now,
974
+ };
975
+ yield* ctx.attention.set(sessionId, row.expiry === null
976
+ ? attention
977
+ : { ...attention, expiresAt: row.expiry.at });
978
+ yield* ctx.observe(sessionId);
979
+ return live
980
+ ? {
981
+ status: "waiting",
982
+ askId: row.askId,
983
+ until: now + window,
984
+ }
985
+ : { status: "parked", askId: row.askId };
986
+ })));
987
+ const result = created.status === "waiting"
988
+ ? await waitForResponse(ctx, created.askId, created.until, invocation)
989
+ : created;
990
+ // Zod owns the JSON output representation, including mutable arrays.
991
+ return ToolResult.parse(result);
992
+ },
993
+ }),
994
+ },
995
+ });
996
+ },
997
+ });