wolli 0.0.1 → 0.0.3
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 +182 -0
- package/README.md +136 -2
- package/built-in/plugins/discord/README.md +156 -0
- package/built-in/plugins/discord/discord-chat.ts +87 -0
- package/built-in/plugins/discord/index.ts +192 -0
- package/built-in/plugins/discord/package.json +16 -0
- package/built-in/plugins/scheduler/index.ts +244 -0
- package/built-in/plugins/scheduler/package.json +16 -0
- package/built-in/plugins/scheduler/scheduler-chat.ts +164 -0
- package/built-in/plugins/telegram/README.md +153 -0
- package/built-in/plugins/telegram/index.ts +311 -0
- package/built-in/plugins/telegram/package.json +17 -0
- package/built-in/plugins/telegram/telegram-chat.ts +169 -0
- package/built-in/skills/.gitkeep +0 -0
- package/dist/cli.js +325758 -0
- package/dist/photon_rs_bg.wasm +0 -0
- package/dist/theme/dark.json +86 -0
- package/dist/theme/light.json +85 -0
- package/dist/theme/theme-schema.json +335 -0
- package/dist/theme/theme.ts +1237 -0
- package/docs/extensions.md +2331 -0
- package/docs/index.md +36 -0
- package/docs/integrations.md +715 -0
- package/docs/plugins.md +299 -0
- package/docs/prompt-templates.md +92 -0
- package/docs/sdk.md +760 -0
- package/docs/skills.md +220 -0
- package/docs/themes.md +274 -0
- package/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
- package/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
- package/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
- package/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
- package/package.json +40 -9
- package/index.js +0 -2
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord transport — holds the bot token, emits a `message` event per inbound message;
|
|
3
|
+
* the paired `discord-chat.ts` extension maps those onto sessions. Transport is the
|
|
4
|
+
* gateway WebSocket (discord.js `Client`); snowflake ids stay `string`. MESSAGE CONTENT
|
|
5
|
+
* is a privileged intent — enable it in the Developer Portal or `m.content` is empty.
|
|
6
|
+
* See README.md for setup.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { IntegrationOnboardContext, IntegrationsAPI } from "@opsyhq/wolli";
|
|
10
|
+
import { Client, Events, GatewayIntentBits, Partials, REST, Routes } from "discord.js";
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
|
|
13
|
+
/** Discord caps a single message at 2000 UTF-16 code units. */
|
|
14
|
+
const DISCORD_MAX_LENGTH = 2000;
|
|
15
|
+
|
|
16
|
+
const ONBOARD_GUIDE = [
|
|
17
|
+
"## Connect Discord",
|
|
18
|
+
"",
|
|
19
|
+
"1. Open the [Discord Developer Portal](https://discord.com/developers/applications)",
|
|
20
|
+
" and create a **New Application**.",
|
|
21
|
+
"2. Go to **Bot**, then enable the **MESSAGE CONTENT INTENT** toggle (privileged —",
|
|
22
|
+
" without it the bot receives empty message text).",
|
|
23
|
+
"3. On the **Bot** page, **Reset Token** and copy the token.",
|
|
24
|
+
"4. Go to **OAuth2 → URL Generator**, select the `bot` scope, copy the generated URL,",
|
|
25
|
+
" open it, and invite the bot to your server.",
|
|
26
|
+
"5. Paste the token on the next screen.",
|
|
27
|
+
"",
|
|
28
|
+
"Wolli verifies the token and stores it for this agent.",
|
|
29
|
+
].join("\n");
|
|
30
|
+
|
|
31
|
+
interface DiscordAccount {
|
|
32
|
+
botToken: string;
|
|
33
|
+
allowedChannelIds?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One cached `REST` client per token for the request/response actions. */
|
|
37
|
+
const rests = new Map<string, REST>();
|
|
38
|
+
function getRest(token: string): REST {
|
|
39
|
+
let rest = rests.get(token);
|
|
40
|
+
if (!rest) {
|
|
41
|
+
rest = new REST({ version: "10" }).setToken(token);
|
|
42
|
+
rests.set(token, rest);
|
|
43
|
+
}
|
|
44
|
+
return rest;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Split into ≤2000-code-unit chunks without cutting a surrogate pair. */
|
|
48
|
+
function chunkText(text: string, max = DISCORD_MAX_LENGTH): string[] {
|
|
49
|
+
if (text.length <= max) return [text];
|
|
50
|
+
const chunks: string[] = [];
|
|
51
|
+
let i = 0;
|
|
52
|
+
while (i < text.length) {
|
|
53
|
+
let end = Math.min(i + max, text.length);
|
|
54
|
+
if (end < text.length) {
|
|
55
|
+
const code = text.charCodeAt(end - 1);
|
|
56
|
+
if (code >= 0xd800 && code <= 0xdbff) end -= 1;
|
|
57
|
+
}
|
|
58
|
+
chunks.push(text.slice(i, end));
|
|
59
|
+
i = end;
|
|
60
|
+
}
|
|
61
|
+
return chunks;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Guided setup: collect the bot token, verify it via `GET /users/@me`, return it to store. */
|
|
65
|
+
async function onboard(ctx: IntegrationOnboardContext): Promise<{ botToken: string } | undefined> {
|
|
66
|
+
ctx.ui.notify(ONBOARD_GUIDE, "info");
|
|
67
|
+
|
|
68
|
+
const entered = await ctx.ui.input("Paste the bot token from the Discord Developer Portal");
|
|
69
|
+
if (entered === undefined) return undefined; // cancelled
|
|
70
|
+
const token = entered.trim();
|
|
71
|
+
if (!token) {
|
|
72
|
+
ctx.ui.notify("No token entered.", "error");
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const me = (await new REST({ version: "10" }).setToken(token).get(Routes.user())) as { username: string };
|
|
78
|
+
ctx.ui.notify(`Verified bot ${me.username}.`, "info");
|
|
79
|
+
} catch (err) {
|
|
80
|
+
ctx.ui.notify(`Could not verify the token: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { botToken: token };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export default function (wolli: IntegrationsAPI) {
|
|
88
|
+
wolli.registerIntegration({
|
|
89
|
+
name: "discord",
|
|
90
|
+
account: Type.Object({
|
|
91
|
+
botToken: Type.String(),
|
|
92
|
+
/** Empty/absent = allow all (logged as a warning). */
|
|
93
|
+
allowedChannelIds: Type.Optional(Type.Array(Type.String())),
|
|
94
|
+
}),
|
|
95
|
+
events: {
|
|
96
|
+
message: Type.Object({
|
|
97
|
+
channelId: Type.String(),
|
|
98
|
+
messageId: Type.String(),
|
|
99
|
+
text: Type.String(),
|
|
100
|
+
author: Type.Object({
|
|
101
|
+
id: Type.String(),
|
|
102
|
+
name: Type.Optional(Type.String()),
|
|
103
|
+
}),
|
|
104
|
+
}),
|
|
105
|
+
},
|
|
106
|
+
onboard,
|
|
107
|
+
actions: {
|
|
108
|
+
sendMessage: {
|
|
109
|
+
description: "Send a text message to a channel (chunked at 2000; Discord renders markdown natively).",
|
|
110
|
+
parameters: Type.Object({
|
|
111
|
+
channelId: Type.String(),
|
|
112
|
+
text: Type.String(),
|
|
113
|
+
}),
|
|
114
|
+
execute: async (params, ctx) => {
|
|
115
|
+
const { channelId, text } = params as { channelId: string; text: string };
|
|
116
|
+
const account = ctx.account as DiscordAccount;
|
|
117
|
+
const rest = getRest(account.botToken);
|
|
118
|
+
|
|
119
|
+
const messageIds: string[] = [];
|
|
120
|
+
for (const content of chunkText(text)) {
|
|
121
|
+
const sent = (await rest.post(Routes.channelMessages(channelId), { body: { content } })) as {
|
|
122
|
+
id: string;
|
|
123
|
+
};
|
|
124
|
+
messageIds.push(sent.id);
|
|
125
|
+
}
|
|
126
|
+
return { messageIds };
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
sendTyping: {
|
|
130
|
+
description: "Show the 'typing…' indicator in a channel (lasts ~10s).",
|
|
131
|
+
parameters: Type.Object({
|
|
132
|
+
channelId: Type.String(),
|
|
133
|
+
}),
|
|
134
|
+
execute: async (params, ctx) => {
|
|
135
|
+
const { channelId } = params as { channelId: string };
|
|
136
|
+
const account = ctx.account as DiscordAccount;
|
|
137
|
+
const rest = getRest(account.botToken);
|
|
138
|
+
await rest.post(Routes.channelTyping(channelId));
|
|
139
|
+
return { ok: true };
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
run(ctx) {
|
|
144
|
+
const account = ctx.account as DiscordAccount;
|
|
145
|
+
const { botToken, allowedChannelIds } = account;
|
|
146
|
+
const allowAll = !allowedChannelIds || allowedChannelIds.length === 0;
|
|
147
|
+
if (allowAll) {
|
|
148
|
+
console.warn("[discord] no allowedChannelIds configured — accepting messages from ANY channel.");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const client = new Client({
|
|
152
|
+
intents: [
|
|
153
|
+
GatewayIntentBits.Guilds,
|
|
154
|
+
GatewayIntentBits.GuildMessages,
|
|
155
|
+
GatewayIntentBits.MessageContent,
|
|
156
|
+
GatewayIntentBits.DirectMessages,
|
|
157
|
+
],
|
|
158
|
+
partials: [Partials.Channel], // required to receive DMs
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
client.on(Events.MessageCreate, (m) => {
|
|
162
|
+
if (m.author.id === client.user?.id) return; // skip self
|
|
163
|
+
if (m.author.bot) return; // skip other bots (loop prevention)
|
|
164
|
+
if (!allowAll && !allowedChannelIds?.includes(m.channelId)) return;
|
|
165
|
+
const text = m.content;
|
|
166
|
+
if (!text) return; // empty without the MESSAGE CONTENT intent, or media-only
|
|
167
|
+
|
|
168
|
+
ctx.emit("message", {
|
|
169
|
+
channelId: m.channelId,
|
|
170
|
+
messageId: m.id,
|
|
171
|
+
text,
|
|
172
|
+
author: { id: m.author.id, name: m.author.username },
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
client.on(Events.Error, (err) => {
|
|
177
|
+
console.error("[discord] client error:", err.message);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Fire-and-forget: login never settles into a state we await.
|
|
181
|
+
if (!ctx.signal.aborted) {
|
|
182
|
+
void client.login(botToken).catch((err) => {
|
|
183
|
+
console.error("[discord] failed to log in:", err instanceof Error ? err.message : err);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const dispose = () => void client.destroy();
|
|
188
|
+
ctx.signal.addEventListener("abort", dispose);
|
|
189
|
+
return dispose;
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wolli-integration-discord",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"wolli": {
|
|
7
|
+
"integrations": ["./index.ts"],
|
|
8
|
+
"extensions": ["./discord-chat.ts"]
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"discord.js": "14.26.4"
|
|
12
|
+
},
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"@opsyhq/wolli": "*"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -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
|
+
}
|