@fungi.computer/shiitake-extensions 0.0.1 → 0.1.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.
Files changed (66) hide show
  1. package/README.md +43 -13
  2. package/dist/almanac/contract.d.ts +94 -0
  3. package/dist/almanac/contract.js +71 -5
  4. package/dist/goal/client.d.ts +5 -2
  5. package/dist/goal/client.js +66 -72
  6. package/dist/goal/contract.d.ts +181 -64
  7. package/dist/goal/contract.js +102 -12
  8. package/dist/goal/host.js +188 -113
  9. package/dist/home/contract.d.ts +18 -0
  10. package/dist/home/contract.js +24 -3
  11. package/dist/home/host.d.ts +3 -0
  12. package/dist/home/host.js +1 -2
  13. package/dist/home/routing.js +13 -24
  14. package/dist/internal/history.d.ts +16 -0
  15. package/dist/internal/history.js +8 -0
  16. package/dist/loop/client.d.ts +1 -1
  17. package/dist/loop/client.js +28 -34
  18. package/dist/loop/contract.d.ts +113 -84
  19. package/dist/loop/contract.js +77 -9
  20. package/dist/loop/host.d.ts +1 -0
  21. package/dist/loop/host.js +17 -30
  22. package/dist/plan/client.d.ts +1 -1
  23. package/dist/plan/contract.d.ts +33 -0
  24. package/dist/plan/contract.js +28 -3
  25. package/dist/plan/host.js +12 -7
  26. package/dist/question/client.d.ts +1 -1
  27. package/dist/question/contract.d.ts +158 -0
  28. package/dist/question/contract.js +92 -7
  29. package/dist/question/host.js +34 -20
  30. package/dist/roster/conversation-agents.d.ts +31 -0
  31. package/dist/roster/conversation-agents.js +114 -0
  32. package/dist/roster.js +1 -0
  33. package/dist/skills/host.d.ts +1 -0
  34. package/dist/skills/host.js +22 -17
  35. package/dist/skills/source.d.ts +34 -0
  36. package/dist/subagents/client.d.ts +1 -1
  37. package/dist/subagents/client.js +43 -12
  38. package/dist/subagents/contract.d.ts +46 -8
  39. package/dist/subagents/contract.js +38 -8
  40. package/dist/subagents/host.d.ts +20 -8
  41. package/dist/subagents/host.js +58 -12
  42. package/docs/api/README.md +35 -0
  43. package/docs/api/almanac/contract.md +90 -0
  44. package/docs/api/almanac/host.md +19 -0
  45. package/docs/api/goal/client.md +26 -0
  46. package/docs/api/goal/contract.md +247 -0
  47. package/docs/api/goal/host.md +29 -0
  48. package/docs/api/home/contract.md +58 -0
  49. package/docs/api/home/host.md +55 -0
  50. package/docs/api/loop/client.md +25 -0
  51. package/docs/api/loop/contract.md +159 -0
  52. package/docs/api/loop/host.md +30 -0
  53. package/docs/api/plan/client.md +26 -0
  54. package/docs/api/plan/contract.md +163 -0
  55. package/docs/api/plan/host.md +19 -0
  56. package/docs/api/question/client.md +25 -0
  57. package/docs/api/question/contract.md +377 -0
  58. package/docs/api/question/host.md +19 -0
  59. package/docs/api/roster/conversation-agents.md +80 -0
  60. package/docs/api/roster.md +40 -0
  61. package/docs/api/skills/host.md +39 -0
  62. package/docs/api/skills/source.md +104 -0
  63. package/docs/api/subagents/client.md +26 -0
  64. package/docs/api/subagents/contract.md +131 -0
  65. package/docs/api/subagents/host.md +115 -0
  66. package/package.json +14 -8
package/dist/goal/host.js CHANGED
@@ -1,26 +1,44 @@
1
- import * as Clock from "effect/Clock";
2
- import { appendHistory, emptyHistory, historySchema, } from "../internal/history.js";
3
1
  import { Mycelium } from "@fungi.computer/mycelium";
4
2
  import { defineCollection, defineExtension, ExtensionCommandError, ExtensionStop, } from "@fungi.computer/shiitake/extensions";
5
3
  import * as Cause from "effect/Cause";
4
+ import * as Clock from "effect/Clock";
6
5
  import * as Effect from "effect/Effect";
7
6
  import * as Exit from "effect/Exit";
8
7
  import * as Option from "effect/Option";
9
8
  import * as Schema from "effect/Schema";
10
9
  import { z } from "zod";
11
10
  import { ask, catalog, declined, done, freshRequestId, } from "../internal/catalog.js";
12
- import { Goal, GoalCommandInput, goalContract, GoalReason, Objective, } from "./contract.js";
11
+ import { EndedGoal, endedGoalLimit, Goal, GoalCommandInput, goalContract, GoalReason, Objective, } from "./contract.js";
12
+ /** The rules the model follows; the describe page and an active goal's prompt share them. */
13
+ const rules = "You may start a goal yourself when none is current. Before marking complete audit every requirement against evidence; complete only after actual achievement. Block only after the same external impasse for three consecutive goal turns; a blocked goal pauses until a person resumes it. Human pause/resume/clear remain human controls. Use plan.update_plan for multi-step work when available.";
13
14
  /** Hashed prose and policy: every constant goal reads comes from here. */
14
15
  const config = {
15
16
  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.",
17
+ moduleDescription: "Session goal tools: create_goal({objective}), get_goal({}), update_goal({status:'complete'|'blocked', reason}). " +
18
+ rules,
19
+ createDescription: "Create a Session goal, including autonomously. Fails while a goal is active or paused; a finished goal is history.",
18
20
  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.",
21
+ updateDescription: "Request complete only after actual achievement, or blocked only after the same external impasse for three consecutive goal turns; settlement commits the request. Complete ends the goal; blocked pauses it for a person.",
20
22
  promptLimit: 1_024,
21
23
  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
+ stallReason: "Stalled: no progress for 3 turns",
25
+ /** Why a goal paused when its tracked work did not complete. */
26
+ stoppedReasons: {
27
+ cancelled: "Stopped",
28
+ failed: "Goal work failed",
29
+ interrupted: "Goal work was interrupted",
30
+ outcome_unknown: "Goal work ended without a known outcome",
31
+ },
32
+ /** Why a live goal paused when another turn of its Session did not complete. */
33
+ otherStoppedReasons: {
34
+ failed: "The last turn failed",
35
+ interrupted: "The last turn was interrupted",
36
+ outcome_unknown: "The last turn ended without a known outcome",
37
+ },
38
+ noTerminalReason: "Goal work ended without a final reply",
39
+ idle: "Long-running objective? forage.describe({path:'goal'}) first.",
40
+ guide: "Session goal tools: goal.create_goal, goal.get_goal, goal.update_goal. " +
41
+ rules,
24
42
  };
25
43
  const promptEncoder = new TextEncoder();
26
44
  const promptDecoder = new TextDecoder();
@@ -30,47 +48,63 @@ function boundedPrompt(text, byteLimit) {
30
48
  const { written } = promptEncoder.encodeInto(text, bytes);
31
49
  return promptDecoder.decode(bytes.subarray(0, written));
32
50
  }
33
- /** One Session's durable goal row. */
51
+ /** One Session's current goal row; a finished goal leaves this collection. */
34
52
  const GoalRow = Schema.Struct({
35
53
  ...Goal.fields,
36
54
  sessionId: Schema.String,
37
55
  /** A model-requested terminal, committed only by its run's success. */
38
- pendingState: Schema.NullOr(Schema.Literals(["complete", "blocked"])),
39
- pendingReason: Schema.NullOr(GoalReason),
56
+ pending: Schema.NullOr(Schema.Struct({
57
+ state: Schema.Literals(["complete", "blocked"]),
58
+ reason: GoalReason,
59
+ })),
40
60
  stalledTurns: Schema.Int,
41
61
  /** Request that set this revision; its retry replays. */
42
62
  revisionRequestId: Schema.String,
43
63
  /** Latest work whose settlement advances this goal. */
44
64
  trackedWorkId: Schema.String,
45
65
  });
66
+ /** One Session's finished goals, newest last, and how many were dropped. */
67
+ const EndingsRow = Schema.Struct({
68
+ sessionId: Schema.String,
69
+ ended: Schema.Array(EndedGoal).check(Schema.isMaxLength(endedGoalLimit)),
70
+ total: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
71
+ });
46
72
  const collections = {
47
- history: defineCollection(Schema.Struct({ sessionId: Schema.String, history: historySchema(Goal) }), { key: (row) => row.sessionId }),
48
73
  goals: defineCollection(GoalRow, {
49
74
  key: (goal) => goal.sessionId,
50
75
  index: (goal) => goal.state,
51
76
  }),
77
+ endings: defineCollection(EndingsRow, {
78
+ key: (endings) => endings.sessionId,
79
+ }),
52
80
  };
53
81
  function committed(row) {
54
82
  return {
55
83
  revision: row.revision,
56
84
  objective: row.objective,
57
85
  state: row.state,
58
- stopReason: row.stopReason,
86
+ startedAt: row.startedAt,
87
+ pauseCause: row.pauseCause,
59
88
  };
60
89
  }
61
90
  function commitGoal(ctx, row) {
62
91
  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
92
  yield* ctx.state.goals.put(row);
93
+ if (row.state === "paused" &&
94
+ (row.pauseCause?.kind === "blocked" || row.pauseCause?.kind === "stalled"))
95
+ yield* ctx.attention
96
+ .set(row.sessionId, {
97
+ id: "paused-goal",
98
+ kind: `goal-${row.pauseCause.kind}`,
99
+ inbox: false,
100
+ summary: goalLine(committed(row)).slice(0, 200),
101
+ since: yield* Clock.currentTimeMillis,
102
+ })
103
+ .pipe(Effect.catchTag("ExtensionStateError", (error) => error.reason === "session_absent"
104
+ ? Effect.void
105
+ : Effect.fail(error)));
106
+ else
107
+ yield* ctx.attention.clear(row.sessionId, "paused-goal");
74
108
  });
75
109
  }
76
110
  function conflict(message) {
@@ -82,15 +116,54 @@ function requireGoal(ctx, sessionId) {
82
116
  onSome: Effect.succeed,
83
117
  })));
84
118
  }
85
- /** Pause or reactivate a nonterminal goal without touching admitted work. */
119
+ /** Pause a goal with the reason a person will see; provisional terminals drop. */
120
+ function paused(goal, kind, detail) {
121
+ return {
122
+ ...goal,
123
+ state: "paused",
124
+ pauseCause: { kind, detail },
125
+ pending: null,
126
+ };
127
+ }
128
+ /**
129
+ * Finish the current goal: it leaves the current slot and joins the
130
+ * Session's bounded history, which the chat shows as one line.
131
+ */
132
+ function finishGoal(ctx, goal, outcome, reason) {
133
+ return Effect.gen(function* () {
134
+ const endedAt = yield* Clock.currentTimeMillis;
135
+ const afterSequence = yield* ctx.sessionPosition(goal.sessionId);
136
+ const previous = Option.getOrUndefined(yield* ctx.state.endings.get(goal.sessionId));
137
+ const total = (previous?.total ?? 0) + 1;
138
+ const ending = {
139
+ sequence: total,
140
+ objective: goal.objective,
141
+ outcome,
142
+ reason,
143
+ endedAt,
144
+ afterSequence,
145
+ };
146
+ yield* ctx.state.endings.put({
147
+ sessionId: goal.sessionId,
148
+ ended: [...(previous?.ended ?? []), ending].slice(-endedGoalLimit),
149
+ total,
150
+ });
151
+ yield* ctx.state.goals.remove(goal.sessionId);
152
+ yield* ctx.attention.clear(goal.sessionId, "paused-goal");
153
+ });
154
+ }
155
+ /** Pause or reactivate the current goal without touching admitted work. */
86
156
  function changeActivity(ctx, sessionId, target) {
87
157
  return Effect.gen(function* () {
88
158
  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
159
  const changed = target === "active"
92
- ? { ...goal, state: "active", stopReason: null, stalledTurns: 0 }
93
- : { ...goal, state: "paused" };
160
+ ? {
161
+ ...goal,
162
+ state: "active",
163
+ pauseCause: null,
164
+ stalledTurns: 0,
165
+ }
166
+ : paused(goal, "person", null);
94
167
  yield* commitGoal(ctx, changed);
95
168
  yield* ctx.observe(sessionId);
96
169
  return changed;
@@ -112,16 +185,17 @@ function continueIfIdle(ctx, goal, requestId) {
112
185
  })
113
186
  .pipe(Effect.map((admitted) => admitted._tag === "Accepted"
114
187
  ? { ...goal, trackedWorkId: admitted.commandId }
115
- : goal), Effect.catchTag("AdmissionError", (error) => Effect.succeed({
116
- ...goal,
117
- state: "paused",
118
- stopReason: error.message,
119
- })));
188
+ : goal), Effect.catchTag("AdmissionError", (error) => Effect.succeed(paused(goal, "failed", error.message))));
120
189
  }
121
190
  /**
122
191
  * 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.
192
+ * the Session becomes the tracked progress. Tracked work that did not
193
+ * complete (Esc, failure, or a restart whose resumed turn could not finish),
194
+ * or any other turn of the Session that failed or was stopped while the goal
195
+ * was live,
196
+ * pauses the goal until a person resumes it; a turn a restart resumed
197
+ * settles here like any other, so the goal keeps going. An achieved goal
198
+ * finishes and leaves the current slot.
125
199
  */
126
200
  function settle(ctx, run) {
127
201
  return Effect.gen(function* () {
@@ -129,30 +203,26 @@ function settle(ctx, run) {
129
203
  if (Option.isNone(found))
130
204
  return;
131
205
  const goal = found.value;
132
- if (goal.state !== "active" && goal.state !== "paused")
133
- return;
134
206
  if (run.outcome !== "completed") {
135
- if (run.workId !== goal.trackedWorkId)
207
+ const tracked = run.workId === goal.trackedWorkId;
208
+ if (!tracked && goal.state !== "active")
136
209
  return;
137
- yield* commitGoal(ctx, {
138
- ...goal,
139
- pendingState: null,
140
- pendingReason: null,
141
- stopReason: `Goal work ended ${run.outcome}`,
142
- });
210
+ const cancelled = run.outcome === "cancelled";
211
+ // Esc stops a live goal even on another turn. Restart-resumed work never
212
+ // settles here; other failures pause the goal with their own display line.
213
+ const detail = tracked || cancelled
214
+ ? ctx.config.stoppedReasons[run.outcome]
215
+ : ctx.config.otherStoppedReasons[run.outcome];
216
+ yield* commitGoal(ctx, paused(goal, cancelled ? "cancelled" : "failed", detail));
143
217
  return yield* ctx.observe(run.sessionId);
144
218
  }
145
219
  if (run.terminal !== "completed") {
146
- yield* commitGoal(ctx, {
147
- ...goal,
148
- stopReason: "Completed goal work has no exact terminal",
149
- });
220
+ yield* commitGoal(ctx, paused(goal, "no-terminal", ctx.config.noTerminalReason));
150
221
  return yield* ctx.observe(run.sessionId);
151
222
  }
152
223
  const tracked = {
153
224
  ...goal,
154
225
  trackedWorkId: run.workId,
155
- stopReason: null,
156
226
  stalledTurns: run.origin?.extensionId === goalContract.id &&
157
227
  run.origin.correlation === goal.revision
158
228
  ? run.toolCalls === 0
@@ -160,21 +230,14 @@ function settle(ctx, run) {
160
230
  : 0
161
231
  : goal.stalledTurns,
162
232
  };
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
- });
233
+ const pending = goal.trackedWorkId === run.workId ? goal.pending : null;
234
+ if (pending?.state === "complete")
235
+ yield* finishGoal(ctx, tracked, "achieved", pending.reason);
236
+ else if (pending?.state === "blocked")
237
+ yield* commitGoal(ctx, paused(tracked, "blocked", pending.reason));
171
238
  else if (goal.state === "active" &&
172
239
  tracked.stalledTurns >= ctx.config.stallTurns)
173
- yield* commitGoal(ctx, {
174
- ...tracked,
175
- state: "paused",
176
- stopReason: ctx.config.stallReason,
177
- });
240
+ yield* commitGoal(ctx, paused(tracked, "stalled", ctx.config.stallReason));
178
241
  else if (goal.state === "active")
179
242
  yield* commitGoal(ctx, yield* continueIfIdle(ctx, tracked, `goal-continue:${run.workId}`));
180
243
  else
@@ -215,22 +278,19 @@ function goalModule(ctx, sessionId, workId) {
215
278
  }),
216
279
  execute: ({ objective }) => toolCall(ctx, Effect.gen(function* () {
217
280
  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"));
281
+ if (Option.isSome(yield* ctx.state.goals.get(sessionId)))
282
+ return yield* Effect.fail(conflict("A Session goal is already active or paused; only a person replaces it"));
223
283
  yield* commitGoal(ctx, {
224
284
  sessionId,
225
285
  objective: decoded,
226
286
  state: "active",
227
- pendingState: null,
228
- pendingReason: null,
287
+ startedAt: yield* Clock.currentTimeMillis,
288
+ pending: null,
229
289
  stalledTurns: 0,
230
290
  revision: `run:${workId}`,
231
291
  revisionRequestId: `run:${workId}`,
232
292
  trackedWorkId: workId,
233
- stopReason: null,
293
+ pauseCause: null,
234
294
  });
235
295
  yield* ctx.observe(sessionId);
236
296
  return { objective: decoded, state: "active" };
@@ -244,7 +304,7 @@ function goalModule(ctx, sessionId, workId) {
244
304
  z.strictObject({
245
305
  objective: toolObjective,
246
306
  state: toolState,
247
- stopReason: z.string().nullable(),
307
+ reason: z.string().nullable(),
248
308
  }),
249
309
  z.null(),
250
310
  ]),
@@ -252,8 +312,10 @@ function goalModule(ctx, sessionId, workId) {
252
312
  ? null
253
313
  : {
254
314
  objective: found.value.objective,
255
- state: found.value.pendingState ?? found.value.state,
256
- stopReason: found.value.pendingReason ?? found.value.stopReason,
315
+ state: found.value.pending?.state ?? found.value.state,
316
+ reason: found.value.pending?.reason ??
317
+ found.value.pauseCause?.detail ??
318
+ null,
257
319
  }))),
258
320
  }),
259
321
  update_goal: Mycelium.operation({
@@ -277,8 +339,7 @@ function goalModule(ctx, sessionId, workId) {
277
339
  return yield* Effect.fail(conflict("Session goal work guard is stale"));
278
340
  yield* commitGoal(ctx, {
279
341
  ...found.value,
280
- pendingState: status,
281
- pendingReason: decoded,
342
+ pending: { state: status, reason: decoded },
282
343
  });
283
344
  return {
284
345
  objective: found.value.objective,
@@ -332,17 +393,20 @@ function setGoal(ctx, sessionId, input) {
332
393
  return yield* Effect.fail(conflict("Session goal changed after this request was accepted"));
333
394
  return { goal: committed(current), accepted };
334
395
  }
396
+ // A replaced goal ends as cleared: history, never current.
397
+ if (current !== undefined && !retried)
398
+ yield* finishGoal(ctx, current, "cleared", null);
335
399
  const row = {
336
400
  sessionId,
337
401
  objective: input.objective,
338
402
  state: "active",
339
- pendingState: null,
340
- pendingReason: null,
403
+ startedAt: yield* Clock.currentTimeMillis,
404
+ pending: null,
341
405
  stalledTurns: 0,
342
406
  revision: `set:${input.requestId}`,
343
407
  revisionRequestId: input.requestId,
344
408
  trackedWorkId: admitted.commandId,
345
- stopReason: null,
409
+ pauseCause: null,
346
410
  };
347
411
  yield* commitGoal(ctx, row);
348
412
  yield* ctx.observe(sessionId);
@@ -350,20 +414,27 @@ function setGoal(ctx, sessionId, input) {
350
414
  });
351
415
  }
352
416
  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})`;
417
+ if (goal.state === "active")
418
+ return `Pursuing goal: ${goal.objective}`;
419
+ return goal.pauseCause?.detail == null
420
+ ? `Goal paused: ${goal.objective}`
421
+ : `Goal paused: ${goal.objective} (${goal.pauseCause.detail})`;
357
422
  }
358
423
  const objectivePrompt = {
359
424
  type: "text",
360
425
  message: "What should this Session work toward?",
361
426
  placeholder: "Describe the finished result",
362
427
  };
428
+ /** Clear the current goal into history; returns whether one was current. */
363
429
  function clearGoal(ctx, sessionId) {
364
- return ctx.state.goals
365
- .remove(sessionId)
366
- .pipe(Effect.andThen(ctx.observe(sessionId)), Effect.as({}));
430
+ return Effect.gen(function* () {
431
+ const current = yield* ctx.state.goals.get(sessionId);
432
+ if (Option.isNone(current))
433
+ return false;
434
+ yield* finishGoal(ctx, current.value, "cleared", null);
435
+ yield* ctx.observe(sessionId);
436
+ return true;
437
+ });
367
438
  }
368
439
  /**
369
440
  * The human `/goal` conversation. Every step is decided here from the
@@ -376,30 +447,24 @@ function humanGoal(ctx, sessionId, input) {
376
447
  if (current === undefined)
377
448
  return ask(objectivePrompt, { action: "set", requestId: yield* freshRequestId }, "objective");
378
449
  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
450
  return ask({
385
451
  type: "choice",
386
452
  message: goalLine(goal),
387
453
  options: [
388
- ...options,
454
+ goal.state === "active"
455
+ ? { value: "pause", label: "Pause goal" }
456
+ : { value: "resume", label: "Resume goal" },
389
457
  { 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
- },
458
+ { value: "replace", label: "Replace goal" },
396
459
  ],
397
460
  }, {}, "action");
398
461
  }
399
462
  switch (input.action) {
400
463
  case "clear":
401
- yield* clearGoal(ctx, sessionId);
402
- return done({ type: "success", message: "Goal cleared." });
464
+ // The chat's ending line confirms a clear; only an empty one says so.
465
+ return (yield* clearGoal(ctx, sessionId))
466
+ ? { type: "done" }
467
+ : done({ type: "info", message: "No goal to clear." });
403
468
  case "pause":
404
469
  yield* pauseGoal(ctx, sessionId);
405
470
  return { type: "done" };
@@ -415,13 +480,14 @@ function humanGoal(ctx, sessionId, input) {
415
480
  }, "objective");
416
481
  case "set": {
417
482
  const retried = current?.revisionRequestId === input.requestId;
418
- const replacing = current !== undefined && current.state !== "complete" && !retried;
483
+ // Only a live or paused goal is current, so only it asks first.
484
+ const replacing = current !== undefined && !retried;
419
485
  if (replacing && input.confirmed === false)
420
486
  return declined({ type: "info", message: "Kept the current goal." });
421
487
  if (replacing && input.confirmed !== true)
422
488
  return ask({
423
489
  type: "confirmation",
424
- message: `Replace the current goal?\nCurrent: ${current.objective}\nNew: ${input.objective}`,
490
+ message: `Replace the ${current.state === "active" ? "live" : "paused"} goal?\nCurrent: ${current.objective}\nNew: ${input.objective}`,
425
491
  confirmLabel: "Replace and start",
426
492
  cancelLabel: "Keep current goal",
427
493
  }, {
@@ -486,18 +552,25 @@ export const goalCatalog = catalog([
486
552
  /** Botanical's Session goal, driven only by settled runs. */
487
553
  export const goalExtension = defineExtension({
488
554
  id: goalContract.id,
489
- version: "4.1.0",
555
+ version: "7.0.0",
490
556
  })
491
557
  .config(config)
492
- .state({ version: 4, migrations: {}, collections })
558
+ // A command anchor cannot be converted into a historical Session position.
559
+ // Old preview state must be republished separately, never guessed here.
560
+ .state({ version: 7, migrations: {}, collections })
493
561
  .define({
562
+ attention: true,
494
563
  prompt: (ctx, { sessionId, origin }) => Effect.gen(function* () {
495
564
  const goal = Option.getOrNull(yield* ctx.state.goals.get(sessionId));
496
- const current = goal === null ? "No current goal." : goalLine(committed(goal));
565
+ const text = goal === null
566
+ ? ctx.config.idle
567
+ : (goal.state === "active" ? ctx.config.guide : ctx.config.idle) +
568
+ "\n" +
569
+ goalLine(committed(goal));
497
570
  return [
498
571
  {
499
572
  name: "goal",
500
- text: boundedPrompt(ctx.config.guide + "\n" + current, ctx.config.promptLimit),
573
+ text: boundedPrompt(text, ctx.config.promptLimit),
501
574
  },
502
575
  ...(goal?.state === "active" &&
503
576
  origin?.extensionId === goalContract.id &&
@@ -516,13 +589,15 @@ export const goalExtension = defineExtension({
516
589
  schema: goalContract.projection.schema,
517
590
  read: (ctx, sessionId) => {
518
591
  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
- });
592
+ const endings = Option.getOrUndefined(ctx.state.endings.peek(sessionId));
593
+ if (goal === undefined && endings === undefined)
594
+ return Option.none();
595
+ const ended = endings?.ended ?? [];
596
+ return Option.some({
597
+ goal: goal === undefined ? null : committed(goal),
598
+ ended,
599
+ omitted: (endings?.total ?? 0) - ended.length,
600
+ });
526
601
  },
527
602
  },
528
603
  hooks: {
@@ -535,7 +610,7 @@ export const goalExtension = defineExtension({
535
610
  goal: ({ sessionId, input }) => humanGoal(ctx, sessionId, input),
536
611
  pause: ({ sessionId }) => pauseGoal(ctx, sessionId),
537
612
  resume: ({ sessionId }) => resumeGoal(ctx, sessionId),
538
- clear: ({ sessionId }) => clearGoal(ctx, sessionId),
613
+ clear: ({ sessionId }) => clearGoal(ctx, sessionId).pipe(Effect.as({})),
539
614
  set: ({ sessionId, input }) => setGoal(ctx, sessionId, input),
540
615
  }),
541
616
  },
@@ -1,32 +1,50 @@
1
1
  import * as Schema from "effect/Schema";
2
2
  /** The Agent's recorded choice survives the chosen attachment being detached. */
3
3
  export declare const Home: Schema.Struct<{
4
+ /** Chosen Computer attachment, or null when no Home is selected. */
4
5
  readonly attachmentId: Schema.NullOr<Schema.String>;
5
6
  }>;
7
+ /** Persisted Home selection for one Agent. */
6
8
  export type Home = Schema.Schema.Type<typeof Home>;
7
9
  /** Human selection changes Agent state; no model operation exposes this command. */
8
10
  export declare const homeContract: {
11
+ /** Stable extension identity. */
9
12
  id: string & import("effect/Brand").Brand<"ExtensionId">;
13
+ /** Human selection and initial seeding operations. */
10
14
  commands: {
15
+ /** Open Home controls or select an attachment by name. */
11
16
  home: {
17
+ /** Human controls are opened from a Session. */
12
18
  scope: "session";
19
+ /** Decode an optional attachment name. */
13
20
  input: Schema.Struct<{
21
+ /** Attachment name selected by the person. */
14
22
  readonly name: Schema.optionalKey<Schema.String>;
15
23
  }>;
24
+ /** Return the server-owned conversation step. */
16
25
  output: Schema.declare<import("@fungi.computer/whistle/catalog").WhistleCatalogStepV1, import("@fungi.computer/whistle/catalog").WhistleCatalogStepV1>;
17
26
  };
27
+ /** Seed the Agent's initial recorded Home choice. */
18
28
  seed: {
29
+ /** Home selection belongs to the Agent. */
19
30
  scope: "agent";
31
+ /** Decode the initial selection. */
20
32
  input: Schema.Struct<{
33
+ /** Chosen Computer attachment, or null when no Home is selected. */
21
34
  readonly attachmentId: Schema.NullOr<Schema.String>;
22
35
  }>;
36
+ /** Report whether seeding changed the recorded choice. */
23
37
  output: Schema.Struct<{
38
+ /** Whether the initial choice was seeded. */
24
39
  readonly seeded: Schema.Boolean;
25
40
  }>;
26
41
  };
27
42
  };
43
+ /** Recorded selection visible to clients, or no seeded state. */
28
44
  projection: {
45
+ /** Decoder for the optional Home state. */
29
46
  schema: Schema.NullOr<Schema.Struct<{
47
+ /** Chosen Computer attachment, or null when no Home is selected. */
30
48
  readonly attachmentId: Schema.NullOr<Schema.String>;
31
49
  }>>;
32
50
  };
@@ -4,22 +4,43 @@ import { CatalogStep } from "../internal/catalog.js";
4
4
  const attachmentId = Schema.String.pipe(Schema.check(Schema.isMinLength(1)));
5
5
  /** The Agent's recorded choice survives the chosen attachment being detached. */
6
6
  export const Home = Schema.Struct({
7
+ /** Chosen Computer attachment, or null when no Home is selected. */
7
8
  attachmentId: Schema.NullOr(attachmentId),
8
9
  });
9
10
  /** Human selection changes Agent state; no model operation exposes this command. */
10
11
  export const homeContract = {
12
+ /** Stable extension identity. */
11
13
  id: ExtensionId.make("botanical-home"),
14
+ /** Human selection and initial seeding operations. */
12
15
  commands: {
16
+ /** Open Home controls or select an attachment by name. */
13
17
  home: {
18
+ /** Human controls are opened from a Session. */
14
19
  scope: "session",
15
- input: Schema.Struct({ name: Schema.optionalKey(Schema.String) }),
20
+ /** Decode an optional attachment name. */
21
+ input: Schema.Struct({
22
+ /** Attachment name selected by the person. */
23
+ name: Schema.optionalKey(Schema.String),
24
+ }),
25
+ /** Return the server-owned conversation step. */
16
26
  output: CatalogStep,
17
27
  },
28
+ /** Seed the Agent's initial recorded Home choice. */
18
29
  seed: {
30
+ /** Home selection belongs to the Agent. */
19
31
  scope: "agent",
32
+ /** Decode the initial selection. */
20
33
  input: Home,
21
- output: Schema.Struct({ seeded: Schema.Boolean }),
34
+ /** Report whether seeding changed the recorded choice. */
35
+ output: Schema.Struct({
36
+ /** Whether the initial choice was seeded. */
37
+ seeded: Schema.Boolean,
38
+ }),
22
39
  },
23
40
  },
24
- projection: { schema: Schema.NullOr(Home) },
41
+ /** Recorded selection visible to clients, or no seeded state. */
42
+ projection: {
43
+ /** Decoder for the optional Home state. */
44
+ schema: Schema.NullOr(Home),
45
+ },
25
46
  };
@@ -2,8 +2,11 @@ import { type FilesPort } from "@fungi.computer/knapsack";
2
2
  import { type ExtensionGrantV0, type RosteredExtensionV0 } from "@fungi.computer/shiitake/extensions";
3
3
  /** Already-scoped authority supplied by the host, never constructed by Home. */
4
4
  export type HomeComputer = Readonly<{
5
+ /** Attachment name offered to the person choosing Home. */
5
6
  name: string;
7
+ /** Stable identity stored by the Home selection. */
6
8
  attachmentId: string;
9
+ /** Already-scoped Files authority captured for this attachment. */
7
10
  port: FilesPort;
8
11
  }>;
9
12
  /** Hosts admit Home explicitly; no other extension receives the Files slot. */
package/dist/home/host.js CHANGED
@@ -13,7 +13,7 @@ export const homeGrant = {
13
13
  };
14
14
  const config = {
15
15
  promptLimit: 4_000,
16
- guide: "Home supplies read, list, glob and grep over your default Computer. Bare paths and computer://home/ use Home; computer://<name>/ uses that captured attachment. apply_patch, when offered, uses Home's native patch engine and path jail. Only a person changes Home with /home <name>; ask them when no Home is available.",
16
+ guide: "Home supplies read, list, glob and grep over your default Computer. Bare paths and computer://home/ use Home; computer://<name>/ uses that captured attachment. apply_patch, when offered, also works on Home. Only a person changes Home with /home <name>; ask them when no Home is available.",
17
17
  };
18
18
  const collections = {
19
19
  choice: defineCollection(Home, { key: () => "default" }),
@@ -79,7 +79,6 @@ export function homeExtension(options) {
79
79
  return null;
80
80
  return Knapsack.files({
81
81
  port: homeFilesRouting(new Map(computers.map(({ name, port }) => [name, port])), selected.port),
82
- pathInstructions: "Bare paths and computer://home/ select Home. computer://<name>/ selects another captured attachment. apply_patch uses Home only.",
83
82
  }).tools;
84
83
  },
85
84
  prompt: (_ctx, run) => [