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

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,542 @@
1
+ import * as Clock from "effect/Clock";
2
+ import { appendHistory, emptyHistory, historySchema, } from "../internal/history.js";
3
+ import { Mycelium } from "@fungi.computer/mycelium";
4
+ import { defineCollection, defineExtension, ExtensionCommandError, ExtensionStop, } from "@fungi.computer/shiitake/extensions";
5
+ import * as Cause from "effect/Cause";
6
+ import * as Effect from "effect/Effect";
7
+ import * as Exit from "effect/Exit";
8
+ import * as Option from "effect/Option";
9
+ import * as Schema from "effect/Schema";
10
+ import { z } from "zod";
11
+ import { ask, catalog, declined, done, freshRequestId, } from "../internal/catalog.js";
12
+ import { Goal, GoalCommandInput, goalContract, GoalReason, Objective, } from "./contract.js";
13
+ /** Hashed prose and policy: every constant goal reads comes from here. */
14
+ const config = {
15
+ continuation: "Continue working toward the current goal described in the goal section. Find goal.get_goal and goal.update_goal with forage.search({query:'goal'}) and forage.describe. Audit every requirement against evidence before goal.update_goal({status:'complete', reason}). If the same external blocker persists for three goal turns, report blocked with a reason. Check for progress rather than repeating yourself.",
16
+ moduleDescription: "Session goal coordination state. You may create goals autonomously; complete only after actual achievement; block only after the same external impasse for three consecutive goal turns.",
17
+ createDescription: "Create a Session goal, including autonomously. A replacement is allowed only after the current goal is complete.",
18
+ getDescription: "Read the current goal. A requested terminal state remains provisional until successful settlement.",
19
+ updateDescription: "Request complete only after actual achievement, or blocked only after the same external impasse for three consecutive goal turns; settlement commits the request.",
20
+ promptLimit: 1_024,
21
+ stallTurns: 3,
22
+ stallReason: "Stalled: no progress for 3 turns. /goal resume to retry",
23
+ guide: "Session goal tools in execute: goal.create_goal({objective}), goal.get_goal({}), goal.update_goal({status:'complete'|'blocked', reason}). Find them with forage.search({query:'goal'}) and forage.describe. You may start a goal yourself. Before marking complete audit every requirement against evidence. Block only after the same external impasse for three consecutive goal turns. Human pause/resume/clear remain human controls. Use plan.update_plan for multi-step work when available.",
24
+ };
25
+ const promptEncoder = new TextEncoder();
26
+ const promptDecoder = new TextDecoder();
27
+ /** Encode only whole code points that fit the section's UTF-8 byte budget. */
28
+ function boundedPrompt(text, byteLimit) {
29
+ const bytes = new Uint8Array(byteLimit);
30
+ const { written } = promptEncoder.encodeInto(text, bytes);
31
+ return promptDecoder.decode(bytes.subarray(0, written));
32
+ }
33
+ /** One Session's durable goal row. */
34
+ const GoalRow = Schema.Struct({
35
+ ...Goal.fields,
36
+ sessionId: Schema.String,
37
+ /** A model-requested terminal, committed only by its run's success. */
38
+ pendingState: Schema.NullOr(Schema.Literals(["complete", "blocked"])),
39
+ pendingReason: Schema.NullOr(GoalReason),
40
+ stalledTurns: Schema.Int,
41
+ /** Request that set this revision; its retry replays. */
42
+ revisionRequestId: Schema.String,
43
+ /** Latest work whose settlement advances this goal. */
44
+ trackedWorkId: Schema.String,
45
+ });
46
+ const collections = {
47
+ history: defineCollection(Schema.Struct({ sessionId: Schema.String, history: historySchema(Goal) }), { key: (row) => row.sessionId }),
48
+ goals: defineCollection(GoalRow, {
49
+ key: (goal) => goal.sessionId,
50
+ index: (goal) => goal.state,
51
+ }),
52
+ };
53
+ function committed(row) {
54
+ return {
55
+ revision: row.revision,
56
+ objective: row.objective,
57
+ state: row.state,
58
+ stopReason: row.stopReason,
59
+ };
60
+ }
61
+ function commitGoal(ctx, row) {
62
+ return Effect.gen(function* () {
63
+ const previous = Option.getOrUndefined(yield* ctx.state.goals.get(row.sessionId));
64
+ if ((row.state === "complete" || row.state === "blocked") &&
65
+ previous?.state !== row.state) {
66
+ const history = Option.getOrUndefined(yield* ctx.state.history.get(row.sessionId))
67
+ ?.history ?? emptyHistory();
68
+ yield* ctx.state.history.put({
69
+ sessionId: row.sessionId,
70
+ history: appendHistory(history, committed(row), row.trackedWorkId || null, yield* Clock.currentTimeMillis),
71
+ });
72
+ }
73
+ yield* ctx.state.goals.put(row);
74
+ });
75
+ }
76
+ function conflict(message) {
77
+ return new ExtensionCommandError({ reason: "conflict", message });
78
+ }
79
+ function requireGoal(ctx, sessionId) {
80
+ return ctx.state.goals.get(sessionId).pipe(Effect.flatMap(Option.match({
81
+ onNone: () => Effect.fail(conflict("Session goal does not exist")),
82
+ onSome: Effect.succeed,
83
+ })));
84
+ }
85
+ /** Pause or reactivate a nonterminal goal without touching admitted work. */
86
+ function changeActivity(ctx, sessionId, target) {
87
+ return Effect.gen(function* () {
88
+ const goal = yield* requireGoal(ctx, sessionId);
89
+ if (goal.state === "complete" || goal.state === "blocked")
90
+ return yield* Effect.fail(conflict("A terminal Session goal cannot change activity"));
91
+ const changed = target === "active"
92
+ ? { ...goal, state: "active", stopReason: null, stalledTurns: 0 }
93
+ : { ...goal, state: "paused" };
94
+ yield* commitGoal(ctx, changed);
95
+ yield* ctx.observe(sessionId);
96
+ return changed;
97
+ });
98
+ }
99
+ /** Send one continuation turn if the Session is idle; returns the new row. */
100
+ function continueIfIdle(ctx, goal, requestId) {
101
+ return ctx
102
+ .send({
103
+ sessionId: goal.sessionId,
104
+ requireExistingSession: true,
105
+ when: "idle",
106
+ correlation: goal.revision,
107
+ prompt: {
108
+ text: `Goal turn · ${goal.objective}`,
109
+ requestId,
110
+ delivery: "followUp",
111
+ },
112
+ })
113
+ .pipe(Effect.map((admitted) => admitted._tag === "Accepted"
114
+ ? { ...goal, trackedWorkId: admitted.commandId }
115
+ : goal), Effect.catchTag("AdmissionError", (error) => Effect.succeed({
116
+ ...goal,
117
+ state: "paused",
118
+ stopReason: error.message,
119
+ })));
120
+ }
121
+ /**
122
+ * Advance one Session's goal from one settled run. Any successful run in
123
+ * the Session becomes the tracked progress; failed tracked work is a visible
124
+ * item-level stop that a human resume retries.
125
+ */
126
+ function settle(ctx, run) {
127
+ return Effect.gen(function* () {
128
+ const found = yield* ctx.state.goals.get(run.sessionId);
129
+ if (Option.isNone(found))
130
+ return;
131
+ const goal = found.value;
132
+ if (goal.state !== "active" && goal.state !== "paused")
133
+ return;
134
+ if (run.outcome !== "completed") {
135
+ if (run.workId !== goal.trackedWorkId)
136
+ return;
137
+ yield* commitGoal(ctx, {
138
+ ...goal,
139
+ pendingState: null,
140
+ pendingReason: null,
141
+ stopReason: `Goal work ended ${run.outcome}`,
142
+ });
143
+ return yield* ctx.observe(run.sessionId);
144
+ }
145
+ if (run.terminal !== "completed") {
146
+ yield* commitGoal(ctx, {
147
+ ...goal,
148
+ stopReason: "Completed goal work has no exact terminal",
149
+ });
150
+ return yield* ctx.observe(run.sessionId);
151
+ }
152
+ const tracked = {
153
+ ...goal,
154
+ trackedWorkId: run.workId,
155
+ stopReason: null,
156
+ stalledTurns: run.origin?.extensionId === goalContract.id &&
157
+ run.origin.correlation === goal.revision
158
+ ? run.toolCalls === 0
159
+ ? goal.stalledTurns + 1
160
+ : 0
161
+ : goal.stalledTurns,
162
+ };
163
+ if (goal.pendingState !== null && goal.trackedWorkId === run.workId)
164
+ yield* commitGoal(ctx, {
165
+ ...tracked,
166
+ state: goal.pendingState,
167
+ stopReason: goal.pendingReason,
168
+ pendingState: null,
169
+ pendingReason: null,
170
+ });
171
+ else if (goal.state === "active" &&
172
+ tracked.stalledTurns >= ctx.config.stallTurns)
173
+ yield* commitGoal(ctx, {
174
+ ...tracked,
175
+ state: "paused",
176
+ stopReason: ctx.config.stallReason,
177
+ });
178
+ else if (goal.state === "active")
179
+ yield* commitGoal(ctx, yield* continueIfIdle(ctx, tracked, `goal-continue:${run.workId}`));
180
+ else
181
+ yield* commitGoal(ctx, tracked);
182
+ yield* ctx.observe(run.sessionId);
183
+ });
184
+ }
185
+ /** Run one tool transaction and surface its failure to the model as an Error. */
186
+ function toolCall(ctx, body) {
187
+ return Effect.runPromiseExit(ctx.transact(body)).then((exit) => {
188
+ if (Exit.isSuccess(exit))
189
+ return exit.value;
190
+ const failure = Cause.squash(exit.cause);
191
+ return Promise.reject(new Error(failure instanceof ExtensionStop
192
+ ? failure.reason
193
+ : failure instanceof Error
194
+ ? failure.message
195
+ : "Goal tool failed"));
196
+ });
197
+ }
198
+ // Mycelium's maintained operation interface takes Zod for model-tool I/O.
199
+ const toolObjective = z.string().min(1).max(4_000);
200
+ const toolState = z.enum(["active", "paused", "complete", "blocked"]);
201
+ /** The model-facing goal tools for one run, guarded by that run's work id. */
202
+ function goalModule(ctx, sessionId, workId) {
203
+ return Mycelium.module({
204
+ id: "botanical.session-goal",
205
+ name: "goal",
206
+ description: ctx.config.moduleDescription,
207
+ operations: {
208
+ create_goal: Mycelium.operation({
209
+ replay: "unsafe",
210
+ description: ctx.config.createDescription,
211
+ input: z.strictObject({ objective: toolObjective }),
212
+ output: z.strictObject({
213
+ objective: toolObjective,
214
+ state: z.literal("active"),
215
+ }),
216
+ execute: ({ objective }) => toolCall(ctx, Effect.gen(function* () {
217
+ const decoded = yield* Schema.decodeUnknownEffect(Objective)(objective).pipe(Effect.mapError(() => conflict("Goal objective is invalid")));
218
+ const current = yield* ctx.state.goals.get(sessionId);
219
+ if (Option.isSome(current) && current.value.state !== "complete")
220
+ return yield* Effect.fail(conflict(current.value.state === "blocked"
221
+ ? "A blocked Session goal cannot be replaced"
222
+ : "A nonterminal Session goal already exists"));
223
+ yield* commitGoal(ctx, {
224
+ sessionId,
225
+ objective: decoded,
226
+ state: "active",
227
+ pendingState: null,
228
+ pendingReason: null,
229
+ stalledTurns: 0,
230
+ revision: `run:${workId}`,
231
+ revisionRequestId: `run:${workId}`,
232
+ trackedWorkId: workId,
233
+ stopReason: null,
234
+ });
235
+ yield* ctx.observe(sessionId);
236
+ return { objective: decoded, state: "active" };
237
+ })),
238
+ }),
239
+ get_goal: Mycelium.operation({
240
+ replay: "safe",
241
+ description: ctx.config.getDescription,
242
+ input: z.strictObject({}),
243
+ output: z.union([
244
+ z.strictObject({
245
+ objective: toolObjective,
246
+ state: toolState,
247
+ stopReason: z.string().nullable(),
248
+ }),
249
+ z.null(),
250
+ ]),
251
+ execute: () => toolCall(ctx, ctx.state.goals.get(sessionId).pipe(Effect.map((found) => Option.isNone(found)
252
+ ? null
253
+ : {
254
+ objective: found.value.objective,
255
+ state: found.value.pendingState ?? found.value.state,
256
+ stopReason: found.value.pendingReason ?? found.value.stopReason,
257
+ }))),
258
+ }),
259
+ update_goal: Mycelium.operation({
260
+ replay: "unsafe",
261
+ description: ctx.config.updateDescription,
262
+ input: z.strictObject({
263
+ status: z.enum(["complete", "blocked"]),
264
+ reason: z.string().min(1).max(1_000),
265
+ }),
266
+ output: z.strictObject({
267
+ objective: toolObjective,
268
+ state: z.enum(["complete", "blocked"]),
269
+ reason: z.string(),
270
+ }),
271
+ execute: ({ status, reason }) => toolCall(ctx, Effect.gen(function* () {
272
+ const decoded = yield* Schema.decodeUnknownEffect(GoalReason)(reason).pipe(Effect.mapError(() => conflict("Goal reason is invalid")));
273
+ const found = yield* ctx.state.goals.get(sessionId);
274
+ if (Option.isNone(found) ||
275
+ found.value.trackedWorkId !== workId ||
276
+ found.value.state !== "active")
277
+ return yield* Effect.fail(conflict("Session goal work guard is stale"));
278
+ yield* commitGoal(ctx, {
279
+ ...found.value,
280
+ pendingState: status,
281
+ pendingReason: decoded,
282
+ });
283
+ return {
284
+ objective: found.value.objective,
285
+ state: status,
286
+ reason: decoded,
287
+ };
288
+ })),
289
+ }),
290
+ },
291
+ });
292
+ }
293
+ function pauseGoal(ctx, sessionId) {
294
+ return changeActivity(ctx, sessionId, "paused").pipe(Effect.map(committed));
295
+ }
296
+ function resumeGoal(ctx, sessionId) {
297
+ return Effect.gen(function* () {
298
+ // A human resume is the retry: it clears the stop and admits one
299
+ // continuation if the Session is idle.
300
+ const resumed = yield* changeActivity(ctx, sessionId, "active");
301
+ const continued = yield* continueIfIdle(ctx, resumed, `goal-resume:${resumed.revision}:${resumed.trackedWorkId}`);
302
+ yield* commitGoal(ctx, continued);
303
+ return committed(continued);
304
+ });
305
+ }
306
+ function setGoal(ctx, sessionId, input) {
307
+ return Effect.gen(function* () {
308
+ const current = Option.getOrUndefined(yield* ctx.state.goals.get(sessionId));
309
+ const retried = current?.revisionRequestId === input.requestId;
310
+ if (!retried && (current?.revision ?? null) !== input.expectedRevision)
311
+ return yield* Effect.fail(conflict("Session goal changed; read it before replacing it"));
312
+ const admitted = yield* ctx.send({
313
+ sessionId,
314
+ when: "now",
315
+ prompt: {
316
+ text: input.objective,
317
+ requestId: input.requestId,
318
+ delivery: "followUp",
319
+ },
320
+ });
321
+ if (admitted._tag !== "Accepted")
322
+ return yield* Effect.fail(conflict("Session goal was not admitted"));
323
+ const accepted = {
324
+ status: "accepted",
325
+ commandId: admitted.commandId,
326
+ };
327
+ if (admitted.delivery === "replay") {
328
+ // The door proved the request identical; the goal must still be it.
329
+ if (current === undefined ||
330
+ !retried ||
331
+ current.objective !== input.objective)
332
+ return yield* Effect.fail(conflict("Session goal changed after this request was accepted"));
333
+ return { goal: committed(current), accepted };
334
+ }
335
+ const row = {
336
+ sessionId,
337
+ objective: input.objective,
338
+ state: "active",
339
+ pendingState: null,
340
+ pendingReason: null,
341
+ stalledTurns: 0,
342
+ revision: `set:${input.requestId}`,
343
+ revisionRequestId: input.requestId,
344
+ trackedWorkId: admitted.commandId,
345
+ stopReason: null,
346
+ };
347
+ yield* commitGoal(ctx, row);
348
+ yield* ctx.observe(sessionId);
349
+ return { goal: committed(row), accepted };
350
+ });
351
+ }
352
+ function goalLine(goal) {
353
+ const state = goal.state.charAt(0).toUpperCase() + goal.state.slice(1);
354
+ return goal.stopReason === null
355
+ ? `${state}: ${goal.objective}`
356
+ : `${state}: ${goal.objective} (${goal.stopReason})`;
357
+ }
358
+ const objectivePrompt = {
359
+ type: "text",
360
+ message: "What should this Session work toward?",
361
+ placeholder: "Describe the finished result",
362
+ };
363
+ function clearGoal(ctx, sessionId) {
364
+ return ctx.state.goals
365
+ .remove(sessionId)
366
+ .pipe(Effect.andThen(ctx.observe(sessionId)), Effect.as({}));
367
+ }
368
+ /**
369
+ * The human `/goal` conversation. Every step is decided here from the
370
+ * committed goal, so every client shows the same choices.
371
+ */
372
+ function humanGoal(ctx, sessionId, input) {
373
+ return Effect.gen(function* () {
374
+ const current = Option.getOrUndefined(yield* ctx.state.goals.get(sessionId));
375
+ if (!("action" in input)) {
376
+ if (current === undefined)
377
+ return ask(objectivePrompt, { action: "set", requestId: yield* freshRequestId }, "objective");
378
+ const goal = committed(current);
379
+ const options = goal.state === "active"
380
+ ? [{ value: "pause", label: "Pause goal" }]
381
+ : goal.state === "paused"
382
+ ? [{ value: "resume", label: "Resume goal" }]
383
+ : [];
384
+ return ask({
385
+ type: "choice",
386
+ message: goalLine(goal),
387
+ options: [
388
+ ...options,
389
+ { value: "clear", label: "Clear goal" },
390
+ {
391
+ value: "replace",
392
+ label: goal.state === "complete" || goal.state === "blocked"
393
+ ? "Set a new goal"
394
+ : "Replace goal",
395
+ },
396
+ ],
397
+ }, {}, "action");
398
+ }
399
+ switch (input.action) {
400
+ case "clear":
401
+ yield* clearGoal(ctx, sessionId);
402
+ return done({ type: "success", message: "Goal cleared." });
403
+ case "pause":
404
+ yield* pauseGoal(ctx, sessionId);
405
+ return { type: "done" };
406
+ case "resume":
407
+ yield* resumeGoal(ctx, sessionId);
408
+ return { type: "done" };
409
+ case "replace":
410
+ return ask(objectivePrompt, {
411
+ action: "set",
412
+ requestId: yield* freshRequestId,
413
+ expectedRevision: current?.revision ?? null,
414
+ confirmed: true,
415
+ }, "objective");
416
+ case "set": {
417
+ const retried = current?.revisionRequestId === input.requestId;
418
+ const replacing = current !== undefined && current.state !== "complete" && !retried;
419
+ if (replacing && input.confirmed === false)
420
+ return declined({ type: "info", message: "Kept the current goal." });
421
+ if (replacing && input.confirmed !== true)
422
+ return ask({
423
+ type: "confirmation",
424
+ message: `Replace the current goal?\nCurrent: ${current.objective}\nNew: ${input.objective}`,
425
+ confirmLabel: "Replace and start",
426
+ cancelLabel: "Keep current goal",
427
+ }, {
428
+ action: "set",
429
+ objective: input.objective,
430
+ requestId: input.requestId,
431
+ expectedRevision: current.revision,
432
+ }, "confirmed");
433
+ yield* setGoal(ctx, sessionId, {
434
+ objective: input.objective,
435
+ requestId: input.requestId,
436
+ expectedRevision: input.expectedRevision ?? current?.revision ?? null,
437
+ });
438
+ // The committed Goal row already shows the state and objective.
439
+ return { type: "done" };
440
+ }
441
+ }
442
+ });
443
+ }
444
+ /** The commands this extension advertises to every client. */
445
+ export const goalCatalog = catalog([
446
+ {
447
+ id: "goal",
448
+ title: "Goal",
449
+ category: "Conversation",
450
+ description: "Set what to work toward",
451
+ projections: {
452
+ help: { order: 2 },
453
+ palette: { order: 2 },
454
+ slash: {
455
+ aliases: ["goal"],
456
+ order: 2,
457
+ arguments: {
458
+ hint: "<objective>",
459
+ words: [
460
+ {
461
+ word: "pause",
462
+ description: "Pause the goal",
463
+ arguments: { action: "pause" },
464
+ },
465
+ {
466
+ word: "resume",
467
+ description: "Resume the goal",
468
+ arguments: { action: "resume" },
469
+ },
470
+ {
471
+ word: "clear",
472
+ description: "Clear the current goal",
473
+ arguments: { action: "clear" },
474
+ },
475
+ ],
476
+ text: {
477
+ field: "objective",
478
+ arguments: { action: "set" },
479
+ requestIdField: "requestId",
480
+ },
481
+ },
482
+ },
483
+ },
484
+ },
485
+ ]);
486
+ /** Botanical's Session goal, driven only by settled runs. */
487
+ export const goalExtension = defineExtension({
488
+ id: goalContract.id,
489
+ version: "4.1.0",
490
+ })
491
+ .config(config)
492
+ .state({ version: 4, migrations: {}, collections })
493
+ .define({
494
+ prompt: (ctx, { sessionId, origin }) => Effect.gen(function* () {
495
+ const goal = Option.getOrNull(yield* ctx.state.goals.get(sessionId));
496
+ const current = goal === null ? "No current goal." : goalLine(committed(goal));
497
+ return [
498
+ {
499
+ name: "goal",
500
+ text: boundedPrompt(ctx.config.guide + "\n" + current, ctx.config.promptLimit),
501
+ },
502
+ ...(goal?.state === "active" &&
503
+ origin?.extensionId === goalContract.id &&
504
+ origin.correlation !== null
505
+ ? [
506
+ {
507
+ name: "goal-continuation",
508
+ text: boundedPrompt(ctx.config.continuation, ctx.config.promptLimit),
509
+ },
510
+ ]
511
+ : []),
512
+ ];
513
+ }),
514
+ catalog: goalCatalog,
515
+ projection: {
516
+ schema: goalContract.projection.schema,
517
+ read: (ctx, sessionId) => {
518
+ const goal = Option.getOrUndefined(ctx.state.goals.peek(sessionId));
519
+ const history = Option.getOrUndefined(ctx.state.history.peek(sessionId))?.history;
520
+ return goal === undefined && history === undefined
521
+ ? Option.none()
522
+ : Option.some({
523
+ goal: goal === undefined ? null : committed(goal),
524
+ history: history ?? emptyHistory(),
525
+ });
526
+ },
527
+ },
528
+ hooks: {
529
+ onSettled: (ctx, page) => Effect.forEach(page, (run) => settle(ctx, run), { discard: true }),
530
+ },
531
+ tools: (ctx, { sessionId, workId }) => Effect.succeed(Option.some(goalModule(ctx, sessionId, workId))),
532
+ commands: {
533
+ specs: goalContract.commands,
534
+ handlers: (ctx) => ({
535
+ goal: ({ sessionId, input }) => humanGoal(ctx, sessionId, input),
536
+ pause: ({ sessionId }) => pauseGoal(ctx, sessionId),
537
+ resume: ({ sessionId }) => resumeGoal(ctx, sessionId),
538
+ clear: ({ sessionId }) => clearGoal(ctx, sessionId),
539
+ set: ({ sessionId, input }) => setGoal(ctx, sessionId, input),
540
+ }),
541
+ },
542
+ });
@@ -0,0 +1,33 @@
1
+ import * as Schema from "effect/Schema";
2
+ /** The Agent's recorded choice survives the chosen attachment being detached. */
3
+ export declare const Home: Schema.Struct<{
4
+ readonly attachmentId: Schema.NullOr<Schema.String>;
5
+ }>;
6
+ export type Home = Schema.Schema.Type<typeof Home>;
7
+ /** Human selection changes Agent state; no model operation exposes this command. */
8
+ export declare const homeContract: {
9
+ id: string & import("effect/Brand").Brand<"ExtensionId">;
10
+ commands: {
11
+ home: {
12
+ scope: "session";
13
+ input: Schema.Struct<{
14
+ readonly name: Schema.optionalKey<Schema.String>;
15
+ }>;
16
+ output: Schema.declare<import("@fungi.computer/whistle/catalog").WhistleCatalogStepV1, import("@fungi.computer/whistle/catalog").WhistleCatalogStepV1>;
17
+ };
18
+ seed: {
19
+ scope: "agent";
20
+ input: Schema.Struct<{
21
+ readonly attachmentId: Schema.NullOr<Schema.String>;
22
+ }>;
23
+ output: Schema.Struct<{
24
+ readonly seeded: Schema.Boolean;
25
+ }>;
26
+ };
27
+ };
28
+ projection: {
29
+ schema: Schema.NullOr<Schema.Struct<{
30
+ readonly attachmentId: Schema.NullOr<Schema.String>;
31
+ }>>;
32
+ };
33
+ };
@@ -0,0 +1,25 @@
1
+ import { ExtensionId, } from "@fungi.computer/shiitake/extensions";
2
+ import * as Schema from "effect/Schema";
3
+ import { CatalogStep } from "../internal/catalog.js";
4
+ const attachmentId = Schema.String.pipe(Schema.check(Schema.isMinLength(1)));
5
+ /** The Agent's recorded choice survives the chosen attachment being detached. */
6
+ export const Home = Schema.Struct({
7
+ attachmentId: Schema.NullOr(attachmentId),
8
+ });
9
+ /** Human selection changes Agent state; no model operation exposes this command. */
10
+ export const homeContract = {
11
+ id: ExtensionId.make("botanical-home"),
12
+ commands: {
13
+ home: {
14
+ scope: "session",
15
+ input: Schema.Struct({ name: Schema.optionalKey(Schema.String) }),
16
+ output: CatalogStep,
17
+ },
18
+ seed: {
19
+ scope: "agent",
20
+ input: Home,
21
+ output: Schema.Struct({ seeded: Schema.Boolean }),
22
+ },
23
+ },
24
+ projection: { schema: Schema.NullOr(Home) },
25
+ };
@@ -0,0 +1,18 @@
1
+ import { type FilesPort } from "@fungi.computer/knapsack";
2
+ import { type ExtensionGrantV0, type RosteredExtensionV0 } from "@fungi.computer/shiitake/extensions";
3
+ /** Already-scoped authority supplied by the host, never constructed by Home. */
4
+ export type HomeComputer = Readonly<{
5
+ name: string;
6
+ attachmentId: string;
7
+ port: FilesPort;
8
+ }>;
9
+ /** Hosts admit Home explicitly; no other extension receives the Files slot. */
10
+ export declare const homeGrant: ExtensionGrantV0;
11
+ /** Agent-scope choice with per-run tools and prompt over one captured table. */
12
+ export declare function homeExtension(options: {
13
+ computers: {
14
+ read: (context: Readonly<{
15
+ sessionId: string;
16
+ }>) => readonly HomeComputer[];
17
+ };
18
+ }): RosteredExtensionV0;