@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.
- package/LICENSE +21 -0
- package/README.md +262 -2
- package/dist/almanac/contract.d.ts +213 -0
- package/dist/almanac/contract.js +77 -0
- package/dist/almanac/host.d.ts +3 -0
- package/dist/almanac/host.js +171 -0
- package/dist/goal/client.d.ts +10 -0
- package/dist/goal/client.js +88 -0
- package/dist/goal/contract.d.ts +172 -0
- package/dist/goal/contract.js +76 -0
- package/dist/goal/host.d.ts +5 -0
- package/dist/goal/host.js +542 -0
- package/dist/home/contract.d.ts +33 -0
- package/dist/home/contract.js +25 -0
- package/dist/home/host.d.ts +18 -0
- package/dist/home/host.js +136 -0
- package/dist/home/routing.d.ts +3 -0
- package/dist/home/routing.js +80 -0
- package/dist/internal/catalog.d.ts +20 -0
- package/dist/internal/catalog.js +33 -0
- package/dist/internal/cron.d.ts +5 -0
- package/dist/internal/cron.js +14 -0
- package/dist/internal/history.d.ts +29 -0
- package/dist/internal/history.js +41 -0
- package/dist/loop/client.d.ts +10 -0
- package/dist/loop/client.js +89 -0
- package/dist/loop/contract.d.ts +342 -0
- package/dist/loop/contract.js +94 -0
- package/dist/loop/host.d.ts +4 -0
- package/dist/loop/host.js +637 -0
- package/dist/plan/client.d.ts +13 -0
- package/dist/plan/client.js +51 -0
- package/dist/plan/contract.d.ts +105 -0
- package/dist/plan/contract.js +38 -0
- package/dist/plan/host.d.ts +3 -0
- package/dist/plan/host.js +154 -0
- package/dist/question/client.d.ts +10 -0
- package/dist/question/client.js +28 -0
- package/dist/question/contract.d.ts +251 -0
- package/dist/question/contract.js +114 -0
- package/dist/question/host.d.ts +3 -0
- package/dist/question/host.js +997 -0
- package/dist/roster.d.ts +9 -0
- package/dist/roster.js +80 -0
- package/dist/skills/host.d.ts +7 -0
- package/dist/skills/host.js +223 -0
- package/dist/skills/source.d.ts +28 -0
- package/dist/skills/source.js +1 -0
- package/dist/subagents/client.d.ts +10 -0
- package/dist/subagents/client.js +53 -0
- package/dist/subagents/contract.d.ts +54 -0
- package/dist/subagents/contract.js +38 -0
- package/dist/subagents/host.d.ts +50 -0
- package/dist/subagents/host.js +176 -0
- package/package.json +117 -3
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
import { appendHistory, emptyHistory, historySchema, } from "../internal/history.js";
|
|
2
|
+
import { Mycelium } from "@fungi.computer/mycelium";
|
|
3
|
+
import { defineCollection, defineExtension, ExtensionCommandError, ExtensionStop, } from "@fungi.computer/shiitake/extensions";
|
|
4
|
+
import * as Cause from "effect/Cause";
|
|
5
|
+
import * as Clock from "effect/Clock";
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
import * as Result from "effect/Result";
|
|
8
|
+
import * as Exit from "effect/Exit";
|
|
9
|
+
import * as Option from "effect/Option";
|
|
10
|
+
import * as Schema from "effect/Schema";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { ask, catalog, done, freshRequestId } from "../internal/catalog.js";
|
|
13
|
+
import { nextOccurrence } from "../internal/cron.js";
|
|
14
|
+
import { Loop, LoopCommandInput, loopContract } from "./contract.js";
|
|
15
|
+
/** Hashed prose and policy: every constant loop reads comes from here. */
|
|
16
|
+
const config = {
|
|
17
|
+
minDelaySeconds: 60,
|
|
18
|
+
maxDelaySeconds: 3_600,
|
|
19
|
+
fixedLifetimeMs: 7 * 24 * 60 * 60 * 1_000,
|
|
20
|
+
promptLimit: 1_024,
|
|
21
|
+
guide: "Session loop tools in execute: loop.get({}), loop.start({prompt,cadence}), loop.schedule_next({delaySeconds,reason}), loop.stop({reason}). You may start loops yourself; one live loop per Session. Find them with forage.search({query:'loop'}) or 'schedule' and forage.describe. schedule_next is allowed only in the active self-paced loop's own run, clamped to 60–3600 seconds. Omitting it ends that loop. stop works in any Session run. Human pause/resume/stop and Esc remain user controls.",
|
|
22
|
+
moduleDescription: "Session loop control and schedule tools. Start, get or stop a loop from any Session run; schedule_next requires the self-paced loop's current run.",
|
|
23
|
+
scheduleDescription: "Schedule the original loop prompt after this run settles successfully. delaySeconds is clamped between 60 and 3600. The reason is stored as why now and does not change the loop prompt. Do not call this when the loop should end.",
|
|
24
|
+
};
|
|
25
|
+
/** One Session's durable loop row. */
|
|
26
|
+
const LoopRow = Schema.Struct({
|
|
27
|
+
...Loop.fields,
|
|
28
|
+
sessionId: Schema.String,
|
|
29
|
+
/** The start request; its retry replays the same loop. */
|
|
30
|
+
startRequestId: Schema.String,
|
|
31
|
+
/** The model's request, made during `workId`, for the next occurrence. */
|
|
32
|
+
requested: Schema.NullOr(Schema.Struct({
|
|
33
|
+
workId: Schema.String,
|
|
34
|
+
delaySeconds: Schema.Number,
|
|
35
|
+
reason: Schema.String,
|
|
36
|
+
})),
|
|
37
|
+
});
|
|
38
|
+
const collections = {
|
|
39
|
+
history: defineCollection(Schema.Struct({ sessionId: Schema.String, history: historySchema(Loop) }), { key: (row) => row.sessionId }),
|
|
40
|
+
loops: defineCollection(LoopRow, {
|
|
41
|
+
key: (loop) => loop.sessionId,
|
|
42
|
+
index: (loop) => loop.state,
|
|
43
|
+
}),
|
|
44
|
+
};
|
|
45
|
+
function conflict(message) {
|
|
46
|
+
return new ExtensionCommandError({ reason: "conflict", message });
|
|
47
|
+
}
|
|
48
|
+
function projected(row) {
|
|
49
|
+
return {
|
|
50
|
+
loopId: row.loopId,
|
|
51
|
+
origin: row.origin,
|
|
52
|
+
expiresAt: row.expiresAt,
|
|
53
|
+
prompt: row.prompt,
|
|
54
|
+
mode: row.mode,
|
|
55
|
+
cron: row.cron,
|
|
56
|
+
state: row.state,
|
|
57
|
+
commandId: row.commandId,
|
|
58
|
+
reason: row.reason,
|
|
59
|
+
nextDueAt: row.nextDueAt,
|
|
60
|
+
lastError: row.lastError,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function commitLoop(ctx, row) {
|
|
64
|
+
return Effect.gen(function* () {
|
|
65
|
+
const previous = Option.getOrUndefined(yield* ctx.state.loops.get(row.sessionId));
|
|
66
|
+
if (previous?.state !== row.state &&
|
|
67
|
+
(row.state === "active" ||
|
|
68
|
+
row.state === "finished" ||
|
|
69
|
+
row.state === "cancelled" ||
|
|
70
|
+
row.state === "failed")) {
|
|
71
|
+
const history = Option.getOrUndefined(yield* ctx.state.history.get(row.sessionId))
|
|
72
|
+
?.history ?? emptyHistory();
|
|
73
|
+
yield* ctx.state.history.put({
|
|
74
|
+
sessionId: row.sessionId,
|
|
75
|
+
history: appendHistory(history, projected(row), row.commandId || null, yield* Clock.currentTimeMillis),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
yield* ctx.state.loops.put(row);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/** Arm the next occurrence, keeping its instant on the row for a resume. */
|
|
82
|
+
function arm(ctx, row, dueAt) {
|
|
83
|
+
const armed = { ...row, nextDueAt: dueAt };
|
|
84
|
+
return commitLoop(ctx, armed).pipe(Effect.andThen(ctx.timers.set(row.sessionId, dueAt)), Effect.as(armed));
|
|
85
|
+
}
|
|
86
|
+
function admitStart(ctx, sessionId, loopId, input) {
|
|
87
|
+
return ctx.send({
|
|
88
|
+
sessionId,
|
|
89
|
+
when: "now",
|
|
90
|
+
correlation: loopId,
|
|
91
|
+
prompt: {
|
|
92
|
+
text: input.prompt,
|
|
93
|
+
requestId: input.requestId,
|
|
94
|
+
delivery: "followUp",
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Start one loop and send its first prompt in the same transaction. A retry
|
|
100
|
+
* of the same start replays through the door instead of conflicting.
|
|
101
|
+
*/
|
|
102
|
+
function start(ctx, sessionId, input, cron, origin = "human") {
|
|
103
|
+
return Effect.gen(function* () {
|
|
104
|
+
const current = Option.getOrUndefined(yield* ctx.state.loops.get(sessionId));
|
|
105
|
+
if (current?.startRequestId === input.requestId) {
|
|
106
|
+
const replayed = yield* admitStart(ctx, sessionId, current.loopId, input);
|
|
107
|
+
if (replayed._tag !== "Accepted")
|
|
108
|
+
return yield* Effect.fail(conflict("Loop start was not admitted"));
|
|
109
|
+
return {
|
|
110
|
+
loop: projected(current),
|
|
111
|
+
accepted: {
|
|
112
|
+
status: "accepted",
|
|
113
|
+
commandId: replayed.commandId,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (current?.state === "active" || current?.state === "paused")
|
|
118
|
+
return yield* Effect.fail(conflict("A Session loop is already active"));
|
|
119
|
+
const loopId = `loop:${input.requestId}`;
|
|
120
|
+
const admitted = yield* admitStart(ctx, sessionId, loopId, input);
|
|
121
|
+
if (admitted._tag !== "Accepted")
|
|
122
|
+
return yield* Effect.fail(conflict("Loop start was not admitted"));
|
|
123
|
+
const row = {
|
|
124
|
+
sessionId,
|
|
125
|
+
loopId,
|
|
126
|
+
origin,
|
|
127
|
+
expiresAt: cron === null
|
|
128
|
+
? null
|
|
129
|
+
: (yield* Clock.currentTimeMillis) + ctx.config.fixedLifetimeMs,
|
|
130
|
+
prompt: input.prompt,
|
|
131
|
+
mode: cron === null ? "self_paced" : "fixed_interval",
|
|
132
|
+
cron,
|
|
133
|
+
state: "active",
|
|
134
|
+
commandId: admitted.commandId,
|
|
135
|
+
reason: null,
|
|
136
|
+
nextDueAt: null,
|
|
137
|
+
lastError: null,
|
|
138
|
+
startRequestId: input.requestId,
|
|
139
|
+
requested: null,
|
|
140
|
+
};
|
|
141
|
+
const stored = cron === null
|
|
142
|
+
? row
|
|
143
|
+
: yield* arm(ctx, row, yield* nextOccurrence(cron, yield* Clock.currentTimeMillis));
|
|
144
|
+
yield* commitLoop(ctx, stored);
|
|
145
|
+
yield* ctx.observe(sessionId);
|
|
146
|
+
return {
|
|
147
|
+
loop: projected(stored),
|
|
148
|
+
accepted: { status: "accepted", commandId: admitted.commandId },
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
function requireLoop(ctx, sessionId) {
|
|
153
|
+
return ctx.state.loops.get(sessionId).pipe(Effect.flatMap(Option.match({
|
|
154
|
+
onNone: () => Effect.fail(new ExtensionCommandError({
|
|
155
|
+
reason: "not_found",
|
|
156
|
+
message: "Session loop does not exist",
|
|
157
|
+
})),
|
|
158
|
+
onSome: Effect.succeed,
|
|
159
|
+
})));
|
|
160
|
+
}
|
|
161
|
+
/** Where a resumed loop picks up: its cadence, or its recorded next instant. */
|
|
162
|
+
function resumeAt(loop, now) {
|
|
163
|
+
return loop.cron === null
|
|
164
|
+
? Effect.succeed(loop.nextDueAt)
|
|
165
|
+
: nextOccurrence(loop.cron, now);
|
|
166
|
+
}
|
|
167
|
+
/** Apply one settled run to its Session's loop, if the run was the loop's. */
|
|
168
|
+
function settle(ctx, run) {
|
|
169
|
+
return Effect.gen(function* () {
|
|
170
|
+
const found = yield* ctx.state.loops.get(run.sessionId);
|
|
171
|
+
if (Option.isNone(found))
|
|
172
|
+
return;
|
|
173
|
+
const loop = found.value;
|
|
174
|
+
if (loop.state !== "active" && loop.state !== "paused")
|
|
175
|
+
return;
|
|
176
|
+
if (run.workId !== loop.commandId)
|
|
177
|
+
return;
|
|
178
|
+
if (run.outcome !== "completed" || run.terminal !== "completed") {
|
|
179
|
+
yield* ctx.timers.cancel(loop.sessionId);
|
|
180
|
+
yield* commitLoop(ctx, {
|
|
181
|
+
...loop,
|
|
182
|
+
state: run.outcome === "cancelled" ? "cancelled" : "failed",
|
|
183
|
+
requested: null,
|
|
184
|
+
reason: null,
|
|
185
|
+
nextDueAt: null,
|
|
186
|
+
lastError: run.outcome === "completed"
|
|
187
|
+
? "Completed loop work has no exact terminal."
|
|
188
|
+
: `Loop work ended with outcome ${run.outcome}.`,
|
|
189
|
+
});
|
|
190
|
+
return yield* ctx.observe(loop.sessionId);
|
|
191
|
+
}
|
|
192
|
+
// A fixed interval loop's cadence is armed independently of its runs.
|
|
193
|
+
if (loop.mode === "fixed_interval")
|
|
194
|
+
return;
|
|
195
|
+
// Omitting schedule_next during the run ends a self-paced loop.
|
|
196
|
+
if (loop.requested?.workId !== run.workId) {
|
|
197
|
+
yield* commitLoop(ctx, {
|
|
198
|
+
...loop,
|
|
199
|
+
state: "finished",
|
|
200
|
+
reason: "no next wake requested",
|
|
201
|
+
nextDueAt: null,
|
|
202
|
+
});
|
|
203
|
+
return yield* ctx.observe(loop.sessionId);
|
|
204
|
+
}
|
|
205
|
+
const now = yield* Clock.currentTimeMillis;
|
|
206
|
+
const scheduled = {
|
|
207
|
+
...loop,
|
|
208
|
+
reason: loop.requested.reason,
|
|
209
|
+
requested: null,
|
|
210
|
+
nextDueAt: now + loop.requested.delaySeconds * 1_000,
|
|
211
|
+
};
|
|
212
|
+
// A paused loop keeps the instant; resume arms it.
|
|
213
|
+
if (loop.state === "paused")
|
|
214
|
+
yield* commitLoop(ctx, scheduled);
|
|
215
|
+
else
|
|
216
|
+
yield* arm(ctx, scheduled, scheduled.nextDueAt ?? now);
|
|
217
|
+
yield* ctx.observe(loop.sessionId);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Send one due occurrence. Its request id is `loopId:dueAt`, so a retried
|
|
222
|
+
* delivery replays at the door instead of admitting twice.
|
|
223
|
+
*/
|
|
224
|
+
function fire(ctx, sessionId, dueAt, now) {
|
|
225
|
+
return Effect.gen(function* () {
|
|
226
|
+
const found = yield* ctx.state.loops.get(sessionId);
|
|
227
|
+
if (Option.isNone(found) || found.value.state !== "active")
|
|
228
|
+
return;
|
|
229
|
+
const loop = found.value;
|
|
230
|
+
if (loop.nextDueAt !== dueAt)
|
|
231
|
+
return;
|
|
232
|
+
if (loop.expiresAt !== null && now >= loop.expiresAt) {
|
|
233
|
+
yield* commitLoop(ctx, {
|
|
234
|
+
...loop,
|
|
235
|
+
state: "finished",
|
|
236
|
+
nextDueAt: null,
|
|
237
|
+
reason: "fixed interval expired after 7 days",
|
|
238
|
+
requested: null,
|
|
239
|
+
});
|
|
240
|
+
return yield* ctx.observe(sessionId);
|
|
241
|
+
}
|
|
242
|
+
const admitted = yield* Effect.result(ctx.send({
|
|
243
|
+
sessionId,
|
|
244
|
+
requireExistingSession: true,
|
|
245
|
+
when: "now",
|
|
246
|
+
correlation: loop.loopId,
|
|
247
|
+
prompt: {
|
|
248
|
+
text: loop.prompt,
|
|
249
|
+
requestId: `${loop.loopId}:${String(dueAt)}`,
|
|
250
|
+
delivery: "followUp",
|
|
251
|
+
},
|
|
252
|
+
}));
|
|
253
|
+
if (Result.isFailure(admitted) || admitted.success._tag === "NotReady") {
|
|
254
|
+
yield* commitLoop(ctx, {
|
|
255
|
+
...loop,
|
|
256
|
+
state: "failed",
|
|
257
|
+
nextDueAt: null,
|
|
258
|
+
lastError: Result.isFailure(admitted)
|
|
259
|
+
? admitted.failure.message
|
|
260
|
+
: "Session did not accept the loop occurrence",
|
|
261
|
+
});
|
|
262
|
+
return yield* ctx.observe(sessionId);
|
|
263
|
+
}
|
|
264
|
+
const next = {
|
|
265
|
+
...loop,
|
|
266
|
+
commandId: admitted.success.commandId,
|
|
267
|
+
nextDueAt: null,
|
|
268
|
+
lastError: null,
|
|
269
|
+
};
|
|
270
|
+
if (loop.cron === null)
|
|
271
|
+
yield* commitLoop(ctx, next);
|
|
272
|
+
else {
|
|
273
|
+
const upcoming = yield* Effect.result(nextOccurrence(loop.cron, now));
|
|
274
|
+
if (Result.isFailure(upcoming))
|
|
275
|
+
yield* commitLoop(ctx, {
|
|
276
|
+
...next,
|
|
277
|
+
state: "failed",
|
|
278
|
+
lastError: upcoming.failure.message,
|
|
279
|
+
});
|
|
280
|
+
else
|
|
281
|
+
yield* arm(ctx, next, upcoming.success);
|
|
282
|
+
}
|
|
283
|
+
yield* ctx.observe(sessionId);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
/** Run one tool transaction and surface its failure to the model as an Error. */
|
|
287
|
+
function toolCall(ctx, body) {
|
|
288
|
+
return Effect.runPromiseExit(ctx.transact(body)).then((exit) => {
|
|
289
|
+
if (Exit.isSuccess(exit))
|
|
290
|
+
return exit.value;
|
|
291
|
+
const failure = Cause.squash(exit.cause);
|
|
292
|
+
return Promise.reject(new Error(failure instanceof ExtensionStop
|
|
293
|
+
? failure.reason
|
|
294
|
+
: failure instanceof Error
|
|
295
|
+
? failure.message
|
|
296
|
+
: "Loop tool failed"));
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
// Mycelium's maintained operation interface takes Zod for model-tool I/O.
|
|
300
|
+
const scheduleNextInput = z.strictObject({
|
|
301
|
+
delaySeconds: z.number(),
|
|
302
|
+
reason: z.string().trim().min(1).max(4_000),
|
|
303
|
+
});
|
|
304
|
+
const toolLoop = z.strictObject({
|
|
305
|
+
loopId: z.string(),
|
|
306
|
+
origin: z.enum(["human", "model"]),
|
|
307
|
+
expiresAt: z.number().nullable(),
|
|
308
|
+
prompt: z.string(),
|
|
309
|
+
mode: z.enum(["self_paced", "fixed_interval"]),
|
|
310
|
+
cron: z
|
|
311
|
+
.strictObject({ expression: z.string(), timeZone: z.string() })
|
|
312
|
+
.nullable(),
|
|
313
|
+
state: z.enum(["active", "paused", "finished", "failed", "cancelled"]),
|
|
314
|
+
commandId: z.string(),
|
|
315
|
+
reason: z.string().nullable(),
|
|
316
|
+
nextDueAt: z.number().nullable(),
|
|
317
|
+
lastError: z.string().nullable(),
|
|
318
|
+
});
|
|
319
|
+
/** The self-paced `schedule_next` tool for exactly the loop's current run. */
|
|
320
|
+
function loopModule(ctx, sessionId, workId) {
|
|
321
|
+
return Mycelium.module({
|
|
322
|
+
id: "botanical.session-loop",
|
|
323
|
+
name: "loop",
|
|
324
|
+
description: ctx.config.moduleDescription,
|
|
325
|
+
operations: {
|
|
326
|
+
get: Mycelium.operation({
|
|
327
|
+
replay: "safe",
|
|
328
|
+
description: "Get the current Session loop and its next scheduled wake.",
|
|
329
|
+
input: z.strictObject({}),
|
|
330
|
+
output: toolLoop.nullable(),
|
|
331
|
+
execute: () => toolCall(ctx, ctx.state.loops
|
|
332
|
+
.get(sessionId)
|
|
333
|
+
.pipe(Effect.map((found) => Option.isSome(found) ? projected(found.value) : null))),
|
|
334
|
+
}),
|
|
335
|
+
start: Mycelium.operation({
|
|
336
|
+
replay: "unsafe",
|
|
337
|
+
description: "Start a Session loop, including autonomously. One live loop per Session; the first prompt runs after the current run.",
|
|
338
|
+
input: z.strictObject({
|
|
339
|
+
prompt: z
|
|
340
|
+
.string()
|
|
341
|
+
.min(1)
|
|
342
|
+
.max(256 * 1024),
|
|
343
|
+
cadence: z.union([
|
|
344
|
+
z.strictObject({ kind: z.literal("self_paced") }),
|
|
345
|
+
z.strictObject({
|
|
346
|
+
kind: z.literal("every"),
|
|
347
|
+
minutes: z.number().int().min(1).max(59),
|
|
348
|
+
}),
|
|
349
|
+
z.strictObject({
|
|
350
|
+
kind: z.literal("every"),
|
|
351
|
+
hours: z.number().int().min(1).max(23),
|
|
352
|
+
}),
|
|
353
|
+
]),
|
|
354
|
+
}),
|
|
355
|
+
output: toolLoop,
|
|
356
|
+
execute: ({ prompt, cadence }) => toolCall(ctx, Effect.gen(function* () {
|
|
357
|
+
const requestId = yield* freshRequestId;
|
|
358
|
+
const cron = cadence.kind === "self_paced"
|
|
359
|
+
? null
|
|
360
|
+
: {
|
|
361
|
+
expression: "minutes" in cadence
|
|
362
|
+
? "0 */" + String(cadence.minutes) + " * * * *"
|
|
363
|
+
: "0 0 */" + String(cadence.hours) + " * * *",
|
|
364
|
+
timeZone: "UTC",
|
|
365
|
+
};
|
|
366
|
+
const result = yield* start(ctx, sessionId, { prompt, requestId }, cron, "model");
|
|
367
|
+
return result.loop;
|
|
368
|
+
})),
|
|
369
|
+
}),
|
|
370
|
+
stop: Mycelium.operation({
|
|
371
|
+
replay: "unsafe",
|
|
372
|
+
description: "Stop this Session's loop from any run, with a visible reason. Cancel its next scheduled wake.",
|
|
373
|
+
input: z.strictObject({ reason: z.string().trim().min(1).max(1_000) }),
|
|
374
|
+
output: toolLoop,
|
|
375
|
+
execute: ({ reason }) => toolCall(ctx, stopLoop(ctx, sessionId, reason)),
|
|
376
|
+
}),
|
|
377
|
+
schedule_next: Mycelium.operation({
|
|
378
|
+
replay: "unsafe",
|
|
379
|
+
description: ctx.config.scheduleDescription,
|
|
380
|
+
input: scheduleNextInput,
|
|
381
|
+
output: z.strictObject({
|
|
382
|
+
delaySeconds: z.number(),
|
|
383
|
+
dueAt: z.number(),
|
|
384
|
+
reason: z.string().min(1).max(4_000),
|
|
385
|
+
}),
|
|
386
|
+
execute: ({ delaySeconds, reason }) => toolCall(ctx, Effect.gen(function* () {
|
|
387
|
+
const found = yield* ctx.state.loops.get(sessionId);
|
|
388
|
+
if (Option.isNone(found) ||
|
|
389
|
+
found.value.state !== "active" ||
|
|
390
|
+
found.value.mode !== "self_paced" ||
|
|
391
|
+
found.value.commandId !== workId)
|
|
392
|
+
return yield* Effect.fail(conflict("Loop work guard is stale"));
|
|
393
|
+
const clamped = Math.max(ctx.config.minDelaySeconds, Math.min(ctx.config.maxDelaySeconds, delaySeconds));
|
|
394
|
+
yield* commitLoop(ctx, {
|
|
395
|
+
...found.value,
|
|
396
|
+
requested: { workId, delaySeconds: clamped, reason },
|
|
397
|
+
});
|
|
398
|
+
return {
|
|
399
|
+
delaySeconds: clamped,
|
|
400
|
+
dueAt: (yield* Clock.currentTimeMillis) + clamped * 1_000,
|
|
401
|
+
reason,
|
|
402
|
+
};
|
|
403
|
+
})),
|
|
404
|
+
}),
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
/** Botanical's Session loop on core timers and `effect/Cron`. */
|
|
409
|
+
function pauseLoop(ctx, sessionId) {
|
|
410
|
+
return Effect.gen(function* () {
|
|
411
|
+
const loop = yield* requireLoop(ctx, sessionId);
|
|
412
|
+
if (loop.state !== "active")
|
|
413
|
+
return yield* Effect.fail(conflict("Session loop is not active"));
|
|
414
|
+
const paused = { ...loop, state: "paused" };
|
|
415
|
+
yield* commitLoop(ctx, paused);
|
|
416
|
+
yield* ctx.timers.cancel(sessionId);
|
|
417
|
+
yield* ctx.observe(sessionId);
|
|
418
|
+
return projected(paused);
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
function resumeLoop(ctx, sessionId) {
|
|
422
|
+
return Effect.gen(function* () {
|
|
423
|
+
const loop = yield* requireLoop(ctx, sessionId);
|
|
424
|
+
if (loop.state !== "paused")
|
|
425
|
+
return yield* Effect.fail(conflict("Session loop is not paused"));
|
|
426
|
+
const now = yield* Clock.currentTimeMillis;
|
|
427
|
+
const due = yield* resumeAt(loop, now);
|
|
428
|
+
const active = { ...loop, state: "active" };
|
|
429
|
+
const resumed = due === null ? active : yield* arm(ctx, active, due);
|
|
430
|
+
yield* commitLoop(ctx, resumed);
|
|
431
|
+
yield* ctx.observe(sessionId);
|
|
432
|
+
return projected(resumed);
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
function stopLoop(ctx, sessionId, reason) {
|
|
436
|
+
return Effect.gen(function* () {
|
|
437
|
+
const loop = yield* requireLoop(ctx, sessionId);
|
|
438
|
+
if (loop.state !== "active" && loop.state !== "paused")
|
|
439
|
+
return yield* Effect.fail(conflict("Session loop is not active"));
|
|
440
|
+
const stopped = {
|
|
441
|
+
...loop,
|
|
442
|
+
state: reason === undefined ? "cancelled" : "finished",
|
|
443
|
+
requested: null,
|
|
444
|
+
reason: reason ?? "cancelled by user",
|
|
445
|
+
lastError: null,
|
|
446
|
+
nextDueAt: null,
|
|
447
|
+
};
|
|
448
|
+
yield* commitLoop(ctx, stopped);
|
|
449
|
+
yield* ctx.timers.cancel(sessionId);
|
|
450
|
+
yield* ctx.observe(sessionId);
|
|
451
|
+
return projected(stopped);
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
function cronForInterval(value, unit) {
|
|
455
|
+
if (unit === "m" || unit === "min")
|
|
456
|
+
return value > 59 ? undefined : `0 */${String(value)} * * * *`;
|
|
457
|
+
return value > 23 ? undefined : `0 0 */${String(value)} * * *`;
|
|
458
|
+
}
|
|
459
|
+
function invalid(message) {
|
|
460
|
+
return new ExtensionCommandError({ reason: "invalid", message });
|
|
461
|
+
}
|
|
462
|
+
/** `<prompt>` paces itself; `<N>m|h <prompt>` runs on a fixed UTC interval. */
|
|
463
|
+
function startFromText(ctx, sessionId, input) {
|
|
464
|
+
return Effect.gen(function* () {
|
|
465
|
+
const interval = /^([1-9]\d*)(m|min|h|hr)\s+(.+)$/su.exec(input.text);
|
|
466
|
+
if (interval === null) {
|
|
467
|
+
if (/^\d+[a-z]+\s/u.test(input.text))
|
|
468
|
+
return yield* Effect.fail(invalid("Use /loop <N>m or /loop <N>h followed by a prompt."));
|
|
469
|
+
return yield* start(ctx, sessionId, { prompt: input.text, requestId: input.requestId }, null);
|
|
470
|
+
}
|
|
471
|
+
const expression = cronForInterval(Number(interval[1]), interval[2] ?? "m");
|
|
472
|
+
if (expression === undefined)
|
|
473
|
+
return yield* Effect.fail(invalid("Cron loop intervals support 1 to 59 minutes or 1 to 23 hours."));
|
|
474
|
+
return yield* start(ctx, sessionId, { prompt: interval[3] ?? input.text, requestId: input.requestId }, { expression, timeZone: "UTC" });
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
function loopLine(loop) {
|
|
478
|
+
const mode = loop.mode === "self_paced" ? "self-paced" : "fixed interval";
|
|
479
|
+
return `Loop ${loop.state} (${mode}): ${loop.prompt}`;
|
|
480
|
+
}
|
|
481
|
+
const loopPrompt = {
|
|
482
|
+
type: "text",
|
|
483
|
+
message: "What should the loop repeat? Start with 5m or 1h for a fixed interval.",
|
|
484
|
+
placeholder: "Check the build and fix what broke",
|
|
485
|
+
};
|
|
486
|
+
/** The human `/loop` conversation, decided from the committed loop. */
|
|
487
|
+
function humanLoop(ctx, sessionId, input) {
|
|
488
|
+
return Effect.gen(function* () {
|
|
489
|
+
const found = Option.getOrUndefined(yield* ctx.state.loops.get(sessionId));
|
|
490
|
+
const current = found === undefined ? undefined : projected(found);
|
|
491
|
+
const live = current?.state === "active" || current?.state === "paused";
|
|
492
|
+
if (!("action" in input)) {
|
|
493
|
+
if (current === undefined || !live)
|
|
494
|
+
return ask(loopPrompt, { action: "start", requestId: yield* freshRequestId }, "text");
|
|
495
|
+
return ask({
|
|
496
|
+
type: "choice",
|
|
497
|
+
message: loopLine(current),
|
|
498
|
+
options: [
|
|
499
|
+
current.state === "active"
|
|
500
|
+
? { value: "pause", label: "Pause loop" }
|
|
501
|
+
: { value: "resume", label: "Resume loop" },
|
|
502
|
+
{ value: "stop", label: "Stop loop" },
|
|
503
|
+
],
|
|
504
|
+
}, {}, "action");
|
|
505
|
+
}
|
|
506
|
+
switch (input.action) {
|
|
507
|
+
case "status":
|
|
508
|
+
return done({
|
|
509
|
+
type: "info",
|
|
510
|
+
message: current === undefined
|
|
511
|
+
? "This Session has no loop."
|
|
512
|
+
: loopLine(current),
|
|
513
|
+
});
|
|
514
|
+
case "new":
|
|
515
|
+
return ask(loopPrompt, { action: "start", requestId: yield* freshRequestId }, "text");
|
|
516
|
+
case "pause":
|
|
517
|
+
return done({
|
|
518
|
+
type: "success",
|
|
519
|
+
message: loopLine(yield* pauseLoop(ctx, sessionId)),
|
|
520
|
+
});
|
|
521
|
+
case "resume":
|
|
522
|
+
return done({
|
|
523
|
+
type: "success",
|
|
524
|
+
message: loopLine(yield* resumeLoop(ctx, sessionId)),
|
|
525
|
+
});
|
|
526
|
+
case "stop":
|
|
527
|
+
return done({
|
|
528
|
+
type: "success",
|
|
529
|
+
message: loopLine(yield* stopLoop(ctx, sessionId)),
|
|
530
|
+
});
|
|
531
|
+
case "start": {
|
|
532
|
+
const started = yield* startFromText(ctx, sessionId, input);
|
|
533
|
+
return done({ type: "success", message: loopLine(started.loop) });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
/** The commands this extension advertises to every client. */
|
|
539
|
+
export const loopCatalog = catalog([
|
|
540
|
+
{
|
|
541
|
+
id: "loop",
|
|
542
|
+
title: "Loop",
|
|
543
|
+
category: "Conversation",
|
|
544
|
+
description: "Repeat a prompt",
|
|
545
|
+
projections: {
|
|
546
|
+
help: { order: 3 },
|
|
547
|
+
palette: { order: 3 },
|
|
548
|
+
slash: {
|
|
549
|
+
aliases: ["loop"],
|
|
550
|
+
order: 3,
|
|
551
|
+
arguments: {
|
|
552
|
+
hint: "[<N>m|<N>h] <prompt>",
|
|
553
|
+
words: [
|
|
554
|
+
{
|
|
555
|
+
word: "status",
|
|
556
|
+
description: "Show the loop",
|
|
557
|
+
arguments: { action: "status" },
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
word: "pause",
|
|
561
|
+
description: "Pause the loop",
|
|
562
|
+
arguments: { action: "pause" },
|
|
563
|
+
},
|
|
564
|
+
{
|
|
565
|
+
word: "resume",
|
|
566
|
+
description: "Resume the loop",
|
|
567
|
+
arguments: { action: "resume" },
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
word: "stop",
|
|
571
|
+
description: "Stop the loop",
|
|
572
|
+
arguments: { action: "stop" },
|
|
573
|
+
},
|
|
574
|
+
],
|
|
575
|
+
text: {
|
|
576
|
+
field: "text",
|
|
577
|
+
arguments: { action: "start" },
|
|
578
|
+
requestIdField: "requestId",
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
]);
|
|
585
|
+
export const loopExtension = defineExtension({
|
|
586
|
+
id: loopContract.id,
|
|
587
|
+
version: "3.0.0",
|
|
588
|
+
})
|
|
589
|
+
.config(config)
|
|
590
|
+
.state({ version: 3, migrations: {}, collections })
|
|
591
|
+
.define({
|
|
592
|
+
prompt: (ctx, { sessionId }) => ctx.state.loops.get(sessionId).pipe(Effect.map((found) => [
|
|
593
|
+
{
|
|
594
|
+
name: "loop",
|
|
595
|
+
text: (ctx.config.guide +
|
|
596
|
+
"\n" +
|
|
597
|
+
(Option.isSome(found)
|
|
598
|
+
? loopLine(projected(found.value)) +
|
|
599
|
+
"; nextDueAt=" +
|
|
600
|
+
String(found.value.nextDueAt)
|
|
601
|
+
: "No current loop.")).slice(0, ctx.config.promptLimit),
|
|
602
|
+
},
|
|
603
|
+
])),
|
|
604
|
+
catalog: loopCatalog,
|
|
605
|
+
projection: {
|
|
606
|
+
schema: loopContract.projection.schema,
|
|
607
|
+
read: (ctx, sessionId) => {
|
|
608
|
+
const loop = Option.getOrUndefined(ctx.state.loops.peek(sessionId));
|
|
609
|
+
const history = Option.getOrUndefined(ctx.state.history.peek(sessionId))?.history;
|
|
610
|
+
return loop === undefined && history === undefined
|
|
611
|
+
? Option.none()
|
|
612
|
+
: Option.some({
|
|
613
|
+
loop: loop === undefined ? null : projected(loop),
|
|
614
|
+
history: history ?? emptyHistory(),
|
|
615
|
+
});
|
|
616
|
+
},
|
|
617
|
+
},
|
|
618
|
+
hooks: {
|
|
619
|
+
onSettled: (ctx, page) => Effect.forEach(page, (run) => settle(ctx, run), { discard: true }),
|
|
620
|
+
onTimer: (ctx, timer) => fire(ctx, timer.key, timer.dueAt, timer.firedAt),
|
|
621
|
+
},
|
|
622
|
+
tools: (ctx, { sessionId, workId }) => Effect.succeed(Option.some(loopModule(ctx, sessionId, workId))),
|
|
623
|
+
commands: {
|
|
624
|
+
specs: loopContract.commands,
|
|
625
|
+
handlers: (ctx) => ({
|
|
626
|
+
startSelfPaced: ({ sessionId, input }) => start(ctx, sessionId, input, null),
|
|
627
|
+
startFixedInterval: ({ sessionId, input }) => start(ctx, sessionId, input, {
|
|
628
|
+
expression: input.expression,
|
|
629
|
+
timeZone: input.timeZone,
|
|
630
|
+
}),
|
|
631
|
+
pause: ({ sessionId }) => pauseLoop(ctx, sessionId),
|
|
632
|
+
resume: ({ sessionId }) => resumeLoop(ctx, sessionId),
|
|
633
|
+
stop: ({ sessionId }) => stopLoop(ctx, sessionId),
|
|
634
|
+
loop: ({ sessionId, input }) => humanLoop(ctx, sessionId, input),
|
|
635
|
+
}),
|
|
636
|
+
},
|
|
637
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ClientContributionV1 } from "@fungi.computer/shiitake/client-entry";
|
|
2
|
+
/**
|
|
3
|
+
* Only an open plan occupies the footer, where it is inspectable and clearable;
|
|
4
|
+
* a finished or cleared plan hides while its committed history stays in state.
|
|
5
|
+
*/
|
|
6
|
+
declare const _default: Readonly<{
|
|
7
|
+
protocol: "shiitake.client/v1";
|
|
8
|
+
id: import("@fungi.computer/shiitake/extensions").ExtensionId;
|
|
9
|
+
correlatedPrompts?: "note";
|
|
10
|
+
attach?: (context: import("@fungi.computer/shiitake/client-entry").ClientExtensionContextV1) => import("effect/Effect").Effect<void, Error, import("effect/Scope").Scope>;
|
|
11
|
+
contribute?: (slot: import("@fungi.computer/shiitake").ExtensionSlotV0 | undefined) => ClientContributionV1;
|
|
12
|
+
}>;
|
|
13
|
+
export default _default;
|