@fungi.computer/shiitake-extensions 0.0.2 → 0.1.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 (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 +206 -124
  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 +129 -84
  19. package/dist/loop/contract.js +79 -9
  20. package/dist/loop/host.d.ts +1 -0
  21. package/dist/loop/host.js +89 -48
  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 +161 -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
- import { Mycelium } from "@fungi.computer/mycelium";
1
+ import { CapabilityFailed, 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
@@ -182,17 +245,24 @@ function settle(ctx, run) {
182
245
  yield* ctx.observe(run.sessionId);
183
246
  });
184
247
  }
185
- /** Run one tool transaction and surface its failure to the model as an Error. */
186
- function toolCall(ctx, body) {
248
+ /** Run one tool transaction and preserve its owner-admitted diagnostic. */
249
+ function toolCall(ctx, operation, body) {
187
250
  return Effect.runPromiseExit(ctx.transact(body)).then((exit) => {
188
251
  if (Exit.isSuccess(exit))
189
252
  return exit.value;
190
253
  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"));
254
+ return Promise.reject(new CapabilityFailed({
255
+ namespace: "goal",
256
+ operation,
257
+ cause: {
258
+ kind: "OperationFailed",
259
+ message: failure instanceof ExtensionStop
260
+ ? failure.reason
261
+ : failure instanceof Error
262
+ ? failure.message
263
+ : "Goal tool failed",
264
+ },
265
+ }));
196
266
  });
197
267
  }
198
268
  // Mycelium's maintained operation interface takes Zod for model-tool I/O.
@@ -213,24 +283,21 @@ function goalModule(ctx, sessionId, workId) {
213
283
  objective: toolObjective,
214
284
  state: z.literal("active"),
215
285
  }),
216
- execute: ({ objective }) => toolCall(ctx, Effect.gen(function* () {
286
+ execute: ({ objective }) => toolCall(ctx, "create_goal", Effect.gen(function* () {
217
287
  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"));
288
+ if (Option.isSome(yield* ctx.state.goals.get(sessionId)))
289
+ return yield* Effect.fail(conflict("A Session goal is already active or paused; only a person replaces it"));
223
290
  yield* commitGoal(ctx, {
224
291
  sessionId,
225
292
  objective: decoded,
226
293
  state: "active",
227
- pendingState: null,
228
- pendingReason: null,
294
+ startedAt: yield* Clock.currentTimeMillis,
295
+ pending: null,
229
296
  stalledTurns: 0,
230
297
  revision: `run:${workId}`,
231
298
  revisionRequestId: `run:${workId}`,
232
299
  trackedWorkId: workId,
233
- stopReason: null,
300
+ pauseCause: null,
234
301
  });
235
302
  yield* ctx.observe(sessionId);
236
303
  return { objective: decoded, state: "active" };
@@ -244,16 +311,18 @@ function goalModule(ctx, sessionId, workId) {
244
311
  z.strictObject({
245
312
  objective: toolObjective,
246
313
  state: toolState,
247
- stopReason: z.string().nullable(),
314
+ reason: z.string().nullable(),
248
315
  }),
249
316
  z.null(),
250
317
  ]),
251
- execute: () => toolCall(ctx, ctx.state.goals.get(sessionId).pipe(Effect.map((found) => Option.isNone(found)
318
+ execute: () => toolCall(ctx, "get_goal", ctx.state.goals.get(sessionId).pipe(Effect.map((found) => Option.isNone(found)
252
319
  ? null
253
320
  : {
254
321
  objective: found.value.objective,
255
- state: found.value.pendingState ?? found.value.state,
256
- stopReason: found.value.pendingReason ?? found.value.stopReason,
322
+ state: found.value.pending?.state ?? found.value.state,
323
+ reason: found.value.pending?.reason ??
324
+ found.value.pauseCause?.detail ??
325
+ null,
257
326
  }))),
258
327
  }),
259
328
  update_goal: Mycelium.operation({
@@ -268,7 +337,7 @@ function goalModule(ctx, sessionId, workId) {
268
337
  state: z.enum(["complete", "blocked"]),
269
338
  reason: z.string(),
270
339
  }),
271
- execute: ({ status, reason }) => toolCall(ctx, Effect.gen(function* () {
340
+ execute: ({ status, reason }) => toolCall(ctx, "update_goal", Effect.gen(function* () {
272
341
  const decoded = yield* Schema.decodeUnknownEffect(GoalReason)(reason).pipe(Effect.mapError(() => conflict("Goal reason is invalid")));
273
342
  const found = yield* ctx.state.goals.get(sessionId);
274
343
  if (Option.isNone(found) ||
@@ -277,8 +346,7 @@ function goalModule(ctx, sessionId, workId) {
277
346
  return yield* Effect.fail(conflict("Session goal work guard is stale"));
278
347
  yield* commitGoal(ctx, {
279
348
  ...found.value,
280
- pendingState: status,
281
- pendingReason: decoded,
349
+ pending: { state: status, reason: decoded },
282
350
  });
283
351
  return {
284
352
  objective: found.value.objective,
@@ -332,17 +400,20 @@ function setGoal(ctx, sessionId, input) {
332
400
  return yield* Effect.fail(conflict("Session goal changed after this request was accepted"));
333
401
  return { goal: committed(current), accepted };
334
402
  }
403
+ // A replaced goal ends as cleared: history, never current.
404
+ if (current !== undefined && !retried)
405
+ yield* finishGoal(ctx, current, "cleared", null);
335
406
  const row = {
336
407
  sessionId,
337
408
  objective: input.objective,
338
409
  state: "active",
339
- pendingState: null,
340
- pendingReason: null,
410
+ startedAt: yield* Clock.currentTimeMillis,
411
+ pending: null,
341
412
  stalledTurns: 0,
342
413
  revision: `set:${input.requestId}`,
343
414
  revisionRequestId: input.requestId,
344
415
  trackedWorkId: admitted.commandId,
345
- stopReason: null,
416
+ pauseCause: null,
346
417
  };
347
418
  yield* commitGoal(ctx, row);
348
419
  yield* ctx.observe(sessionId);
@@ -350,20 +421,27 @@ function setGoal(ctx, sessionId, input) {
350
421
  });
351
422
  }
352
423
  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})`;
424
+ if (goal.state === "active")
425
+ return `Pursuing goal: ${goal.objective}`;
426
+ return goal.pauseCause?.detail == null
427
+ ? `Goal paused: ${goal.objective}`
428
+ : `Goal paused: ${goal.objective} (${goal.pauseCause.detail})`;
357
429
  }
358
430
  const objectivePrompt = {
359
431
  type: "text",
360
432
  message: "What should this Session work toward?",
361
433
  placeholder: "Describe the finished result",
362
434
  };
435
+ /** Clear the current goal into history; returns whether one was current. */
363
436
  function clearGoal(ctx, sessionId) {
364
- return ctx.state.goals
365
- .remove(sessionId)
366
- .pipe(Effect.andThen(ctx.observe(sessionId)), Effect.as({}));
437
+ return Effect.gen(function* () {
438
+ const current = yield* ctx.state.goals.get(sessionId);
439
+ if (Option.isNone(current))
440
+ return false;
441
+ yield* finishGoal(ctx, current.value, "cleared", null);
442
+ yield* ctx.observe(sessionId);
443
+ return true;
444
+ });
367
445
  }
368
446
  /**
369
447
  * The human `/goal` conversation. Every step is decided here from the
@@ -376,30 +454,24 @@ function humanGoal(ctx, sessionId, input) {
376
454
  if (current === undefined)
377
455
  return ask(objectivePrompt, { action: "set", requestId: yield* freshRequestId }, "objective");
378
456
  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
457
  return ask({
385
458
  type: "choice",
386
459
  message: goalLine(goal),
387
460
  options: [
388
- ...options,
461
+ goal.state === "active"
462
+ ? { value: "pause", label: "Pause goal" }
463
+ : { value: "resume", label: "Resume goal" },
389
464
  { 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
- },
465
+ { value: "replace", label: "Replace goal" },
396
466
  ],
397
467
  }, {}, "action");
398
468
  }
399
469
  switch (input.action) {
400
470
  case "clear":
401
- yield* clearGoal(ctx, sessionId);
402
- return done({ type: "success", message: "Goal cleared." });
471
+ // The chat's ending line confirms a clear; only an empty one says so.
472
+ return (yield* clearGoal(ctx, sessionId))
473
+ ? { type: "done" }
474
+ : done({ type: "info", message: "No goal to clear." });
403
475
  case "pause":
404
476
  yield* pauseGoal(ctx, sessionId);
405
477
  return { type: "done" };
@@ -415,13 +487,14 @@ function humanGoal(ctx, sessionId, input) {
415
487
  }, "objective");
416
488
  case "set": {
417
489
  const retried = current?.revisionRequestId === input.requestId;
418
- const replacing = current !== undefined && current.state !== "complete" && !retried;
490
+ // Only a live or paused goal is current, so only it asks first.
491
+ const replacing = current !== undefined && !retried;
419
492
  if (replacing && input.confirmed === false)
420
493
  return declined({ type: "info", message: "Kept the current goal." });
421
494
  if (replacing && input.confirmed !== true)
422
495
  return ask({
423
496
  type: "confirmation",
424
- message: `Replace the current goal?\nCurrent: ${current.objective}\nNew: ${input.objective}`,
497
+ message: `Replace the ${current.state === "active" ? "live" : "paused"} goal?\nCurrent: ${current.objective}\nNew: ${input.objective}`,
425
498
  confirmLabel: "Replace and start",
426
499
  cancelLabel: "Keep current goal",
427
500
  }, {
@@ -486,18 +559,25 @@ export const goalCatalog = catalog([
486
559
  /** Botanical's Session goal, driven only by settled runs. */
487
560
  export const goalExtension = defineExtension({
488
561
  id: goalContract.id,
489
- version: "4.1.0",
562
+ version: "7.0.0",
490
563
  })
491
564
  .config(config)
492
- .state({ version: 4, migrations: {}, collections })
565
+ // A command anchor cannot be converted into a historical Session position.
566
+ // Old preview state must be republished separately, never guessed here.
567
+ .state({ version: 7, migrations: {}, collections })
493
568
  .define({
569
+ attention: true,
494
570
  prompt: (ctx, { sessionId, origin }) => Effect.gen(function* () {
495
571
  const goal = Option.getOrNull(yield* ctx.state.goals.get(sessionId));
496
- const current = goal === null ? "No current goal." : goalLine(committed(goal));
572
+ const text = goal === null
573
+ ? ctx.config.idle
574
+ : (goal.state === "active" ? ctx.config.guide : ctx.config.idle) +
575
+ "\n" +
576
+ goalLine(committed(goal));
497
577
  return [
498
578
  {
499
579
  name: "goal",
500
- text: boundedPrompt(ctx.config.guide + "\n" + current, ctx.config.promptLimit),
580
+ text: boundedPrompt(text, ctx.config.promptLimit),
501
581
  },
502
582
  ...(goal?.state === "active" &&
503
583
  origin?.extensionId === goalContract.id &&
@@ -516,13 +596,15 @@ export const goalExtension = defineExtension({
516
596
  schema: goalContract.projection.schema,
517
597
  read: (ctx, sessionId) => {
518
598
  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
- });
599
+ const endings = Option.getOrUndefined(ctx.state.endings.peek(sessionId));
600
+ if (goal === undefined && endings === undefined)
601
+ return Option.none();
602
+ const ended = endings?.ended ?? [];
603
+ return Option.some({
604
+ goal: goal === undefined ? null : committed(goal),
605
+ ended,
606
+ omitted: (endings?.total ?? 0) - ended.length,
607
+ });
526
608
  },
527
609
  },
528
610
  hooks: {
@@ -535,7 +617,7 @@ export const goalExtension = defineExtension({
535
617
  goal: ({ sessionId, input }) => humanGoal(ctx, sessionId, input),
536
618
  pause: ({ sessionId }) => pauseGoal(ctx, sessionId),
537
619
  resume: ({ sessionId }) => resumeGoal(ctx, sessionId),
538
- clear: ({ sessionId }) => clearGoal(ctx, sessionId),
620
+ clear: ({ sessionId }) => clearGoal(ctx, sessionId).pipe(Effect.as({})),
539
621
  set: ({ sessionId, input }) => setGoal(ctx, sessionId, input),
540
622
  }),
541
623
  },
@@ -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
  };