wolli 0.0.1 → 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.
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Scheduler integration — the timer half (self-contained package).
3
+ *
4
+ * This integration owns the jobs and the wake loop: it persists jobs in `ctx.store`
5
+ * (one file at `~/.wolli/agents/<name>/store/scheduler.json`), ticks a coarse timer,
6
+ * and emits a `due` event when a job's time arrives. It does not touch sessions or the
7
+ * agent; the paired extension (`scheduler-chat.ts`) registers the agent-facing `cron`
8
+ * tool and, on `due`, wakes a session. See `INTEGRATION.md` for the producer-vs-mapping
9
+ * split.
10
+ *
11
+ * Jobs are scheduled by the agent through the `cron` tool, which calls the CRUD actions
12
+ * below. The scheduler has no secret — onboarding just writes an empty `scheduler.default`
13
+ * account so `run()` starts.
14
+ *
15
+ * ## Guarantees
16
+ * - At-most-once: a tick advances a job's `nextRunAt` (or disables a one-shot) and
17
+ * persists that BEFORE emitting `due`, so a crash/reload right after an emit never
18
+ * double-fires.
19
+ * - Missed runs while down: the catch-up tick on start fires each overdue job once
20
+ * (recompute-from-now), not one replay per missed interval.
21
+ */
22
+
23
+ import { randomUUID } from "node:crypto";
24
+ import type { IntegrationOnboardContext, IntegrationsAPI, KeyValueStore } from "@opsyhq/wolli";
25
+ import { Cron } from "croner";
26
+ import { type Static, Type } from "typebox";
27
+
28
+ /** Default wake interval — coarse by design (a fixed tick is trivially idempotent across reloads). */
29
+ const DEFAULT_TICK_MS = 60_000;
30
+
31
+ const Schedule = Type.Union([
32
+ /** One-shot at an absolute epoch-ms instant. */
33
+ Type.Object({ kind: Type.Literal("at"), at: Type.Number() }),
34
+ /** Fixed interval; the first run is one interval after creation. */
35
+ Type.Object({ kind: Type.Literal("every"), everyMs: Type.Number() }),
36
+ /** Cron expression; `tz` omitted = host local time. */
37
+ Type.Object({ kind: Type.Literal("cron"), expr: Type.String(), tz: Type.Optional(Type.String()) }),
38
+ ]);
39
+ type Schedule = Static<typeof Schedule>;
40
+
41
+ interface Job {
42
+ id: string;
43
+ name?: string;
44
+ prompt: string;
45
+ schedule: Schedule;
46
+ enabled: boolean;
47
+ /** Tags of the session that scheduled the job; the fired result is delivered to the newest session matching these. */
48
+ originTags?: Record<string, string>;
49
+ /** Epoch ms; advanced before firing. */
50
+ nextRunAt: number;
51
+ lastRunAt?: number;
52
+ }
53
+
54
+ interface SchedulerAccount {
55
+ tickMs?: number;
56
+ }
57
+
58
+ /** Jobs live under the single store key `"jobs"`, keyed by id. */
59
+ function loadJobs(store: KeyValueStore): Record<string, Job> {
60
+ return (store.get("jobs") as Record<string, Job> | undefined) ?? {};
61
+ }
62
+ function saveJobs(store: KeyValueStore, jobs: Record<string, Job>): void {
63
+ store.set("jobs", jobs);
64
+ }
65
+
66
+ /** Next run for a schedule relative to `fromMs`; null when there is no future run. */
67
+ function computeNextRunAt(schedule: Schedule, fromMs: number): number | null {
68
+ switch (schedule.kind) {
69
+ case "at":
70
+ return schedule.at;
71
+ case "every":
72
+ return fromMs + schedule.everyMs;
73
+ case "cron":
74
+ return new Cron(schedule.expr, { timezone: schedule.tz }).nextRun(new Date(fromMs))?.getTime() ?? null;
75
+ }
76
+ }
77
+
78
+ async function onboard(ctx: IntegrationOnboardContext): Promise<Record<string, unknown>> {
79
+ ctx.ui.notify("Scheduler enabled.", "info");
80
+ return {};
81
+ }
82
+
83
+ export default function (wolli: IntegrationsAPI) {
84
+ wolli.registerIntegration({
85
+ name: "scheduler",
86
+ account: Type.Object({
87
+ /** Wake interval in ms; defaults to 60s. */
88
+ tickMs: Type.Optional(Type.Number()),
89
+ }),
90
+ events: {
91
+ due: Type.Object({
92
+ id: Type.String(),
93
+ prompt: Type.String(),
94
+ originTags: Type.Optional(Type.Record(Type.String(), Type.String())),
95
+ name: Type.Optional(Type.String()),
96
+ }),
97
+ },
98
+ onboard,
99
+ actions: {
100
+ addJob: {
101
+ description: "Schedule a new job from a prompt and a schedule (at / every / cron).",
102
+ parameters: Type.Object({
103
+ prompt: Type.String(),
104
+ name: Type.Optional(Type.String()),
105
+ schedule: Schedule,
106
+ originTags: Type.Optional(Type.Record(Type.String(), Type.String())),
107
+ }),
108
+ execute: async (params, ctx) => {
109
+ const p = params as {
110
+ prompt: string;
111
+ name?: string;
112
+ schedule: Schedule;
113
+ originTags?: Record<string, string>;
114
+ };
115
+ const now = Date.now();
116
+ const seeded = computeNextRunAt(p.schedule, now);
117
+ const job: Job = {
118
+ id: randomUUID(),
119
+ name: p.name,
120
+ prompt: p.prompt,
121
+ schedule: p.schedule,
122
+ enabled: seeded !== null,
123
+ originTags: p.originTags,
124
+ nextRunAt: seeded ?? 0,
125
+ };
126
+ const jobs = loadJobs(ctx.store);
127
+ jobs[job.id] = job;
128
+ saveJobs(ctx.store, jobs);
129
+ return { id: job.id, nextRunAt: job.nextRunAt };
130
+ },
131
+ },
132
+ listJobs: {
133
+ description: "List all scheduled jobs.",
134
+ parameters: Type.Object({}),
135
+ execute: async (_params, ctx) => {
136
+ return { jobs: Object.values(loadJobs(ctx.store)) };
137
+ },
138
+ },
139
+ updateJob: {
140
+ description: "Update a job by id; recomputes the next run when the schedule changes.",
141
+ parameters: Type.Object({
142
+ id: Type.String(),
143
+ prompt: Type.Optional(Type.String()),
144
+ name: Type.Optional(Type.String()),
145
+ schedule: Type.Optional(Schedule),
146
+ enabled: Type.Optional(Type.Boolean()),
147
+ }),
148
+ execute: async (params, ctx) => {
149
+ const p = params as {
150
+ id: string;
151
+ prompt?: string;
152
+ name?: string;
153
+ schedule?: Schedule;
154
+ enabled?: boolean;
155
+ };
156
+ const jobs = loadJobs(ctx.store);
157
+ const job = jobs[p.id];
158
+ if (!job) throw new Error(`unknown job '${p.id}'`);
159
+
160
+ if (p.prompt !== undefined) job.prompt = p.prompt;
161
+ if (p.name !== undefined) job.name = p.name;
162
+ if (p.enabled !== undefined) job.enabled = p.enabled;
163
+ if (p.schedule !== undefined) {
164
+ job.schedule = p.schedule;
165
+ const next = computeNextRunAt(p.schedule, Date.now());
166
+ job.nextRunAt = next ?? 0;
167
+ if (next === null) job.enabled = false;
168
+ }
169
+
170
+ saveJobs(ctx.store, jobs);
171
+ return { job };
172
+ },
173
+ },
174
+ removeJob: {
175
+ description: "Delete a job by id.",
176
+ parameters: Type.Object({ id: Type.String() }),
177
+ execute: async (params, ctx) => {
178
+ const { id } = params as { id: string };
179
+ const jobs = loadJobs(ctx.store);
180
+ const removed = id in jobs;
181
+ delete jobs[id];
182
+ saveJobs(ctx.store, jobs);
183
+ return { removed };
184
+ },
185
+ },
186
+ runJob: {
187
+ description: "Run a job on the next tick (sets it due immediately).",
188
+ parameters: Type.Object({ id: Type.String() }),
189
+ execute: async (params, ctx) => {
190
+ const { id } = params as { id: string };
191
+ const jobs = loadJobs(ctx.store);
192
+ const job = jobs[id];
193
+ if (!job) throw new Error(`unknown job '${id}'`);
194
+ job.enabled = true;
195
+ job.nextRunAt = 0;
196
+ saveJobs(ctx.store, jobs);
197
+ return { id, nextRunAt: job.nextRunAt };
198
+ },
199
+ },
200
+ },
201
+ run(ctx) {
202
+ const account = ctx.account as SchedulerAccount;
203
+ const tickMs = account.tickMs ?? DEFAULT_TICK_MS;
204
+
205
+ const tick = (): void => {
206
+ const now = Date.now();
207
+ const jobs = loadJobs(ctx.store);
208
+ const due: Job[] = [];
209
+ for (const job of Object.values(jobs)) {
210
+ if (!job.enabled || job.nextRunAt > now) continue;
211
+ job.lastRunAt = now;
212
+ if (job.schedule.kind === "at") {
213
+ job.enabled = false; // one-shot
214
+ } else {
215
+ const next = computeNextRunAt(job.schedule, now);
216
+ if (next === null) job.enabled = false;
217
+ else job.nextRunAt = next;
218
+ }
219
+ due.push(job);
220
+ }
221
+ if (due.length === 0) return;
222
+
223
+ // Persist the advanced state before emitting so a crash right after an emit never re-fires.
224
+ saveJobs(ctx.store, jobs);
225
+ for (const job of due) {
226
+ ctx.emit("due", {
227
+ id: job.id,
228
+ prompt: job.prompt,
229
+ originTags: job.originTags,
230
+ name: job.name,
231
+ });
232
+ }
233
+ };
234
+
235
+ // One catch-up tick on start: each overdue job fires once (recompute-from-now), not N replays.
236
+ tick();
237
+ const timer = setInterval(tick, tickMs);
238
+
239
+ const dispose = () => clearInterval(timer);
240
+ ctx.signal.addEventListener("abort", dispose);
241
+ return dispose;
242
+ },
243
+ });
244
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "wolli-integration-scheduler",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "wolli": {
7
+ "integrations": ["./index.ts"],
8
+ "extensions": ["./scheduler-chat.ts"]
9
+ },
10
+ "dependencies": {
11
+ "croner": "10.0.1"
12
+ },
13
+ "peerDependencies": {
14
+ "@opsyhq/wolli": "*"
15
+ }
16
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Scheduler chat extension — the mapping half (paired with `index.ts`).
3
+ *
4
+ * The integration (`index.ts`) is the producer (jobs, wake timer, `due` events); this
5
+ * extension maps that onto the agent:
6
+ *
7
+ * - tool: registers the `cron` tool so the agent schedules its own jobs
8
+ * (add / list / update / remove / run) via the integration's CRUD actions.
9
+ * - inbound: `scheduler.on("due")` runs the job's prompt in the session it was scheduled from.
10
+ *
11
+ * Delivery via tags: `add` snapshots the scheduling session's tags onto the job (`originTags`).
12
+ * When the job fires, the prompt runs as a turn in the newest session matching those tags, so
13
+ * whatever extension owns that surface delivers the answer onward with no scheduler-side
14
+ * special-casing — a telegram-tagged origin means telegram's own `agent_end` ships the reply
15
+ * back to that chat. An untagged origin falls back to the newest session.
16
+ *
17
+ * This file is declared under the package's `wolli.extensions` and is resolved in place by the
18
+ * package manager when the integration is onboarded.
19
+ */
20
+
21
+ import type { ExtensionAPI } from "@opsyhq/wolli";
22
+ import { Type } from "typebox";
23
+
24
+ const CronParams = Type.Object({
25
+ action: Type.Union([
26
+ Type.Literal("add"),
27
+ Type.Literal("list"),
28
+ Type.Literal("update"),
29
+ Type.Literal("remove"),
30
+ Type.Literal("run"),
31
+ ]),
32
+ prompt: Type.Optional(Type.String({ description: "What to run (the woken session's first message)." })),
33
+ name: Type.Optional(Type.String({ description: "Human label for the job." })),
34
+ at: Type.Optional(Type.Number({ description: "One-shot run time, epoch ms." })),
35
+ everyMs: Type.Optional(Type.Number({ description: "Fixed interval in ms." })),
36
+ cron: Type.Optional(Type.String({ description: "Cron expression (5/6-field)." })),
37
+ tz: Type.Optional(Type.String({ description: "Timezone for the cron expression (host local if omitted)." })),
38
+ id: Type.Optional(Type.String({ description: "Job id, for update / remove / run." })),
39
+ enabled: Type.Optional(Type.Boolean({ description: "Enable or disable the job (update)." })),
40
+ });
41
+
42
+ /** The fields of a job this extension reads back from `listJobs` (the integration owns the full shape). */
43
+ interface Job {
44
+ id: string;
45
+ name?: string;
46
+ schedule: { kind: "at"; at: number } | { kind: "every"; everyMs: number } | { kind: "cron"; expr: string; tz?: string };
47
+ enabled: boolean;
48
+ nextRunAt: number;
49
+ }
50
+
51
+ /** Map the tool's flat `at`/`everyMs`/`cron` fields onto the integration's `Schedule` union. */
52
+ function buildSchedule(p: { at?: number; everyMs?: number; cron?: string; tz?: string }): Job["schedule"] | undefined {
53
+ if (p.at !== undefined) return { kind: "at", at: p.at };
54
+ if (p.everyMs !== undefined) return { kind: "every", everyMs: p.everyMs };
55
+ if (p.cron !== undefined) return { kind: "cron", expr: p.cron, tz: p.tz };
56
+ return undefined;
57
+ }
58
+
59
+ function describeSchedule(schedule: Job["schedule"]): string {
60
+ switch (schedule.kind) {
61
+ case "at":
62
+ return `at ${new Date(schedule.at).toISOString()}`;
63
+ case "every":
64
+ return `every ${schedule.everyMs}ms`;
65
+ case "cron":
66
+ return `cron "${schedule.expr}"${schedule.tz ? ` (${schedule.tz})` : ""}`;
67
+ }
68
+ }
69
+
70
+ function text(message: string, details: unknown) {
71
+ return { content: [{ type: "text" as const, text: message }], details };
72
+ }
73
+
74
+ export default function (wolli: ExtensionAPI) {
75
+ const sched = wolli.getIntegration("scheduler", "default");
76
+
77
+ wolli.registerTool({
78
+ name: "cron",
79
+ label: "Cron",
80
+ description:
81
+ "Schedule prompts to run later. Actions: add (prompt + at/everyMs/cron), list, update (id), remove (id), run (id).",
82
+ parameters: CronParams,
83
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
84
+ try {
85
+ switch (params.action) {
86
+ case "add": {
87
+ if (!params.prompt) return text("Error: prompt is required to add a job.", { error: "prompt required" });
88
+ const schedule = buildSchedule(params);
89
+ if (!schedule) {
90
+ return text("Error: provide one of at, everyMs, or cron.", { error: "schedule required" });
91
+ }
92
+ // Snapshot the scheduling session's tags so the fired result returns to this surface.
93
+ const result = (await sched.call("addJob", {
94
+ prompt: params.prompt,
95
+ name: params.name,
96
+ schedule,
97
+ originTags: ctx.session.getTags(),
98
+ })) as { id: string; nextRunAt: number };
99
+ return text(
100
+ `Scheduled job ${result.id} — ${describeSchedule(schedule)} — next ${new Date(result.nextRunAt).toISOString()}.`,
101
+ result,
102
+ );
103
+ }
104
+ case "list": {
105
+ const result = (await sched.call("listJobs")) as { jobs: Job[] };
106
+ const body = result.jobs.length
107
+ ? result.jobs
108
+ .map((j) => {
109
+ const label = j.name ? `${j.name} ` : "";
110
+ const state = j.enabled ? `next ${new Date(j.nextRunAt).toISOString()}` : "disabled";
111
+ return `${j.id} ${label}— ${describeSchedule(j.schedule)} — ${state}`;
112
+ })
113
+ .join("\n")
114
+ : "No scheduled jobs.";
115
+ return text(body, result);
116
+ }
117
+ case "update": {
118
+ if (!params.id) return text("Error: id is required to update a job.", { error: "id required" });
119
+ const result = await sched.call("updateJob", {
120
+ id: params.id,
121
+ prompt: params.prompt,
122
+ name: params.name,
123
+ schedule: buildSchedule(params),
124
+ enabled: params.enabled,
125
+ });
126
+ return text(`Updated job ${params.id}.`, result);
127
+ }
128
+ case "remove": {
129
+ if (!params.id) return text("Error: id is required to remove a job.", { error: "id required" });
130
+ const result = (await sched.call("removeJob", { id: params.id })) as { removed: boolean };
131
+ return text(result.removed ? `Removed job ${params.id}.` : `No job ${params.id}.`, result);
132
+ }
133
+ case "run": {
134
+ if (!params.id) return text("Error: id is required to run a job.", { error: "id required" });
135
+ const result = await sched.call("runJob", { id: params.id });
136
+ return text(`Job ${params.id} will run on the next tick.`, result);
137
+ }
138
+ }
139
+ } catch (err) {
140
+ const message = err instanceof Error ? err.message : String(err);
141
+ return text(`Error: ${message}`, { error: message });
142
+ }
143
+ },
144
+ });
145
+
146
+ sched.on("due", async (data) => {
147
+ const job = data as { id: string; prompt: string; originTags?: Record<string, string> };
148
+
149
+ // Run the prompt as a turn in the session the job was scheduled from (newest match for its
150
+ // origin tags). A telegram-tagged origin → telegram's own agent_end ships the reply to that
151
+ // chat; no scheduler-side channel handling. followUp queues cleanly if a turn is in flight.
152
+ // If no session matches (e.g. the origin was pruned), create one carrying the SAME origin tags
153
+ // so it stays bound to that surface — never an untagged session, which would deliver nowhere.
154
+ const [match] = await wolli.findSessions(job.originTags ?? {});
155
+ const session = match
156
+ ? await wolli.openSession(match.id)
157
+ : await wolli.createSession({
158
+ setup: async (sessionManager) => {
159
+ await sessionManager.appendTags(job.originTags ?? {});
160
+ },
161
+ });
162
+ await session.sendUserMessage(job.prompt, { deliverAs: "followUp" });
163
+ });
164
+ }