wolli 0.0.3 → 0.0.4

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Wolli
4
4
 
5
- **Create purposeful, self-extending AI agents.**
5
+ **Create agents that grow around a purpose.**
6
6
 
7
7
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
8
8
  [![npm](https://img.shields.io/npm/v/wolli.svg)](https://www.npmjs.com/package/wolli)
@@ -16,10 +16,7 @@
16
16
 
17
17
  > **What is my purpose?**
18
18
 
19
- Wolli lets you create purposeful agents. That purpose becomes the organizing
20
- principle of an agent's life: it remembers across sessions, runs on a schedule,
21
- acts on events, and extends itself to do its job better over time, writing its
22
- own skills, integrations, and extensions as the work demands.
19
+ Wolli lets you create agents that grow around a purpose. Each agent remembers across sessions, runs on schedules, reacts to events, and extends itself over time by writing the skills, integrations, and workflows it needs to do its job better.
23
20
 
24
21
  ## Install
25
22
 
@@ -29,27 +26,27 @@ wolli
29
26
  ```
30
27
 
31
28
  The first run sets up your provider and creates your first agent. A new agent
32
- starts in a **forming** state: it interviews you to work out its purpose and
33
- records what it learns, and does not act unattended until you deploy it. Agents and
34
- state live under `~/.wolli`. `●` deployed and `○` forming.
29
+ opens by interviewing you to work out its purpose, then writes its own
30
+ `SOUL.md` its first line becomes the agent's description everywhere. Agents
31
+ and state live under `~/.wolli`.
35
32
 
36
33
  ```
37
34
  Agents
38
35
 
39
- inbox Triage my email each morning, draft replies to the routine ones, flag what needs me.
40
- scout Watch the repos and deps we ship; when a release or CVE needs action, open an issue and ping me.
41
- ledger Track project spend across providers, reconcile invoices weekly, warn me before a budget tips over.
42
- ○ sprout Still working out my purpose.
36
+ → inbox Triage my email each morning, draft replies to the routine ones, flag what needs me.
37
+ scout Watch the repos and deps we ship; when a release or CVE needs action, open an issue and ping me.
38
+ ledger Track project spend across providers, reconcile invoices weekly, warn me before a budget tips over.
43
39
 
44
40
  ↑/↓ browse · enter chat · tab details · type to search commands · ctrl+c quit
45
41
  ```
46
42
 
47
43
  ## How it works
48
44
 
49
- - **Purpose-built.** You state the agent's purpose at birth. It decides what the
50
- agent stores, when it speaks up, and what it does unattended.
45
+ - **Purpose-built.** The agent works its purpose out with you in its first
46
+ conversation and records it as the first line of its `SOUL.md`. It decides what
47
+ the agent stores, when it speaks up, and what it does unattended.
51
48
  - **Self-extending.** The agent builds itself out for its purpose. It curates its
52
- own memory and authors and installs its own skills, tools, extensions and integrations; they
49
+ own memory and authors and installs its own skills, tools, workflows, and integrations; they
53
50
  live in its home and load on reload. The agent grows more capable at its job
54
51
  instead of staying a fixed tool.
55
52
  - **Persistent.** Sessions are an append-only JSONL tree, the agent's lifetime
@@ -60,7 +57,7 @@ state live under `~/.wolli`. `●` deployed and `○` forming.
60
57
 
61
58
  | File | Holds |
62
59
  | --- | --- |
63
- | `SOUL.md` | Identity, authored at deploy. |
60
+ | `SOUL.md` | Identity, authored by the agent; the first line is its purpose. |
64
61
  | `MEMORY.md` | Durable notes the agent keeps. |
65
62
  | `USER.md` | Facts about its human. |
66
63
 
@@ -73,10 +70,10 @@ state live under `~/.wolli`. `●` deployed and `○` forming.
73
70
 
74
71
  ## Lifecycle
75
72
 
76
- | State | What happens |
77
- | --- | --- |
78
- | **forming** | Interviews its human and records memory. Does not act unattended. |
79
- | **deployed** | The agent has authored its purpose and `SOUL.md`, and you confirmed `/deploy`, the single human-held latch. It now runs on schedules and events. |
73
+ An agent is live from the moment it is created: its daemon is installed as an
74
+ OS service at creation and runs on schedules and events from birth. While its
75
+ `SOUL.md` is empty it interviews you to work out its purpose, then authors
76
+ `SOUL.md` itself.
80
77
 
81
78
  `wolli delete <name>` removes an agent and its state.
82
79
 
@@ -87,8 +84,10 @@ removes them with it:
87
84
 
88
85
  | Type | What it adds |
89
86
  | --- | --- |
90
- | **Integrations** | TypeScript modules: tools, commands, events, UI. |
91
- | **Extensions** | TypeScript modules: tools, commands, events, UI. |
87
+ | **Integrations** | Transports that connect external services and message channels. |
88
+ | **Workflows** | Route events into sessions and automate the agent. |
89
+ | **Tools** | Typed actions the model calls during a turn. |
90
+ | **Providers** | Model providers beyond the built-in catalog. |
92
91
  | **Skills** | The Agent Skills standard. |
93
92
  | **Prompt templates** | `/name` slash commands. |
94
93
  | **Themes** | TUI appearance. |
@@ -101,7 +100,7 @@ Two integrations ship bundled: **Telegram** (bidirectional chat) and a
101
100
  | Command | Action |
102
101
  | --- | --- |
103
102
  | `wolli` | Set up on first run; otherwise pick an agent and open it |
104
- | `wolli new <name>` | Create and birth a new agent |
103
+ | `wolli new <name>` | Create a new agent and start chatting with it |
105
104
  | `wolli <agent>` | Open a specific agent interactively |
106
105
  | `wolli <agent> "msg" --print` | One-shot, non-interactive reply |
107
106
  | `wolli list` | List agents |
@@ -112,7 +111,7 @@ Two integrations ship bundled: **Telegram** (bidirectional chat) and a
112
111
  ## Documentation
113
112
 
114
113
  Full documentation is in [`packages/wolli/docs`](packages/wolli/docs/index.md):
115
- extensions, skills, prompt templates, themes, integrations, plugins, and the SDK.
114
+ workflows, integrations, tools, providers, hooks, skills, prompt templates, themes, plugins, and the SDK.
116
115
 
117
116
  ## Roadmap
118
117
 
@@ -1,87 +1,50 @@
1
1
  /**
2
- * Discord chat extension maps the transport's `message` events onto wolli sessions:
3
- * each channel/DM gets its own session (bound by a `discord:channel` tag), inbound text
4
- * is queued as a followUp, and the final assistant text is sent back on `agent_end`.
5
- * Paired with `index.ts`.
2
+ * Discord chat routing, as one workflows file:
3
+ * - `inbound` binds each channel/DM to its own session by a `discord:channel` tag and
4
+ * delivers inbound text as a followUp. Discord has no channel commands, so every message
5
+ * routes into a session.
6
+ * - `typing` starts the "typing…" indicator on `agent_start`; the integration owns the
7
+ * keep-alive timer.
8
+ * - `reply` ships the turn's final assistant text back on `agent_end`, riding the
9
+ * producing session's `discord:channel` tag; typing stops first so a pure tool-call turn
10
+ * still clears the indicator.
6
11
  */
7
12
 
8
- import type { AgentMessage } from "@opsyhq/agent";
9
- import type { ExtensionAPI } from "@opsyhq/wolli";
10
-
11
- // Discord's typing state lasts ~10s; refresh a little ahead of that.
12
- const TYPING_INTERVAL_MS = 8000;
13
-
14
- interface DiscordMessage {
15
- channelId: string;
16
- messageId: string;
17
- text: string;
18
- author: { id: string; name?: string };
19
- }
20
-
21
- /** Text of the last assistant message; "" for a pure tool-call turn. */
22
- function finalAssistantText(messages: AgentMessage[]): string {
23
- for (let i = messages.length - 1; i >= 0; i--) {
24
- const m = messages[i];
25
- if (m.role !== "assistant") continue;
26
- return m.content
27
- .filter((c): c is { type: "text"; text: string } => c.type === "text")
28
- .map((c) => c.text)
29
- .join("")
30
- .trim();
31
- }
32
- return "";
33
- }
34
-
35
- export default function (wolli: ExtensionAPI) {
36
- const discord = wolli.getIntegration("discord", "default");
37
-
38
- let typingTimer: ReturnType<typeof setInterval> | undefined;
39
-
40
- discord.on("message", async (data) => {
41
- const m = data as DiscordMessage;
42
-
43
- // Reuse this channel's session, or create + tag a fresh one.
44
- const channelTag = { "discord:channel": m.channelId };
45
- const [match] = await wolli.findSessions(channelTag);
46
- const session = match
47
- ? await wolli.openSession(match.id)
48
- : await wolli.createSession({
49
- setup: async (sessionManager) => {
50
- await sessionManager.appendTags(channelTag);
51
- },
52
- });
53
-
54
- // followUp: a message arriving mid-turn queues instead of interrupting.
55
- void session.sendUserMessage(m.text, { deliverAs: "followUp" });
56
- });
57
-
58
- const stopTyping = (): void => {
59
- if (typingTimer) {
60
- clearInterval(typingTimer);
61
- typingTimer = undefined;
62
- }
63
- };
64
-
65
- wolli.on("agent_start", async (_event, ctx) => {
66
- const channelId = ctx.session.getTags()["discord:channel"];
67
- if (!channelId) return; // not a discord-bound session
68
- const sendTyping = () => {
69
- void discord.call("sendTyping", { channelId }).catch(() => {});
70
- };
71
- sendTyping();
72
- stopTyping();
73
- typingTimer = setInterval(sendTyping, TYPING_INTERVAL_MS);
74
- });
75
-
76
- wolli.on("agent_end", async ({ messages }, ctx) => {
77
- stopTyping();
78
-
79
- // Reply goes to the channel that started this turn (the producing session's tag).
80
- const channelId = ctx.session.getTags()["discord:channel"];
81
- if (!channelId) return; // not a discord-bound session
82
-
83
- const text = finalAssistantText(messages as AgentMessage[]);
84
- if (!text) return; // pure tool-call turn — nothing to send
85
- await discord.call("sendMessage", { channelId, text });
86
- });
87
- }
13
+ import { wolli } from "wolli";
14
+ import discord from "./index.ts";
15
+
16
+ // m is typed from the event schema
17
+ export const inbound = discord.on("message", async (m, ctx) => {
18
+ const channelTag = { "discord:channel": m.channelId };
19
+ const [match] = await ctx.agent.findSessions(channelTag);
20
+ const session = match
21
+ ? await ctx.agent.openSession(match.id)
22
+ : await ctx.agent.createSession({
23
+ setup: (s) => s.appendTags(channelTag),
24
+ });
25
+ // followUp queues behind a running turn instead of interrupting it.
26
+ await session.sendUserMessage(m.text, { deliverAs: "followUp" });
27
+ });
28
+
29
+ export const typing = wolli.on("agent_start", async (_evt, ctx) => {
30
+ const channelId = ctx.session.getTags()["discord:channel"];
31
+ if (!channelId) return; // not a discord-bound session
32
+ await ctx.integration(discord).startTyping({ channelId });
33
+ });
34
+
35
+ export const reply = wolli.on("agent_end", async (evt, ctx) => {
36
+ const channelId = ctx.session.getTags()["discord:channel"];
37
+ if (!channelId) return; // not a discord-bound session
38
+ // Stop the typing indicator before the empty-text early return, so a pure tool-call
39
+ // turn still clears it.
40
+ await ctx.integration(discord).stopTyping({ channelId });
41
+ const text = evt.messages
42
+ .filter((m) => m.role === "assistant")
43
+ .at(-1)
44
+ ?.content.filter((c) => c.type === "text")
45
+ .map((c) => c.text)
46
+ .join("")
47
+ .trim();
48
+ if (!text) return; // a pure tool-call turn sends nothing
49
+ await ctx.integration(discord).sendMessage({ channelId, text });
50
+ });
@@ -1,18 +1,43 @@
1
1
  /**
2
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.
3
+ * the routing workflows (`discord-chat.ts`, declared under `wolli.workflows`) map those
4
+ * onto sessions. Transport is the gateway
5
+ * WebSocket (discord.js `Client`); snowflake ids stay `string`. MESSAGE CONTENT is a
6
+ * privileged intent — enable it in the Developer Portal or `m.content` is empty. The
7
+ * "typing…" keep-alive timers live here, since workflow files hold no module state.
6
8
  * See README.md for setup.
7
9
  */
8
10
 
9
- import type { IntegrationOnboardContext, IntegrationsAPI } from "@opsyhq/wolli";
11
+ import { defineIntegration, type IntegrationOnboardContext } from "wolli";
10
12
  import { Client, Events, GatewayIntentBits, Partials, REST, Routes } from "discord.js";
11
13
  import { Type } from "typebox";
12
14
 
13
15
  /** Discord caps a single message at 2000 UTF-16 code units. */
14
16
  const DISCORD_MAX_LENGTH = 2000;
15
17
 
18
+ /** Typing indicator refresh interval — Discord's typing state lasts ~10s, so refresh a little ahead. */
19
+ const TYPING_INTERVAL_MS = 8000;
20
+
21
+ /**
22
+ * Live "typing…" keep-alive timers, keyed by channelId. Per-channel so parallel channels
23
+ * never stomp each other; cleared by `stopTyping` and by the producer's disposer on reload,
24
+ * so a reload can never orphan an interval.
25
+ */
26
+ const typingTimers = new Map<string, ReturnType<typeof setInterval>>();
27
+
28
+ function stopTypingTimer(channelId: string): void {
29
+ const timer = typingTimers.get(channelId);
30
+ if (timer) {
31
+ clearInterval(timer);
32
+ typingTimers.delete(channelId);
33
+ }
34
+ }
35
+
36
+ function clearAllTypingTimers(): void {
37
+ for (const timer of typingTimers.values()) clearInterval(timer);
38
+ typingTimers.clear();
39
+ }
40
+
16
41
  const ONBOARD_GUIDE = [
17
42
  "## Connect Discord",
18
43
  "",
@@ -84,109 +109,126 @@ async function onboard(ctx: IntegrationOnboardContext): Promise<{ botToken: stri
84
109
  return { botToken: token };
85
110
  }
86
111
 
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())),
112
+ export default defineIntegration({
113
+ account: Type.Object({
114
+ botToken: Type.String(),
115
+ /** Empty/absent = allow all (logged as a warning). */
116
+ allowedChannelIds: Type.Optional(Type.Array(Type.String())),
117
+ }),
118
+ events: {
119
+ message: Type.Object({
120
+ channelId: Type.String(),
121
+ messageId: Type.String(),
122
+ text: Type.String(),
123
+ author: Type.Object({
124
+ id: Type.String(),
125
+ name: Type.Optional(Type.String()),
126
+ }),
94
127
  }),
95
- events: {
96
- message: Type.Object({
128
+ },
129
+ onboard,
130
+ actions: {
131
+ sendMessage: {
132
+ description: "Send a text message to a channel (chunked at 2000; Discord renders markdown natively).",
133
+ parameters: Type.Object({
97
134
  channelId: Type.String(),
98
- messageId: Type.String(),
99
135
  text: Type.String(),
100
- author: Type.Object({
101
- id: Type.String(),
102
- name: Type.Optional(Type.String()),
103
- }),
104
136
  }),
137
+ execute: async (params, ctx) => {
138
+ const { channelId, text } = params as { channelId: string; text: string };
139
+ const account = ctx.account as DiscordAccount;
140
+ const rest = getRest(account.botToken);
141
+
142
+ const messageIds: string[] = [];
143
+ for (const content of chunkText(text)) {
144
+ const sent = (await rest.post(Routes.channelMessages(channelId), { body: { content } })) as {
145
+ id: string;
146
+ };
147
+ messageIds.push(sent.id);
148
+ }
149
+ return { messageIds };
150
+ },
105
151
  },
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
- },
152
+ startTyping: {
153
+ description: "Show the 'typing…' indicator in a channel and keep it alive on a timer until stopTyping.",
154
+ parameters: Type.Object({
155
+ channelId: Type.String(),
156
+ }),
157
+ execute: async (params, ctx) => {
158
+ const { channelId } = params as { channelId: string };
159
+ const account = ctx.account as DiscordAccount;
160
+ const rest = getRest(account.botToken);
161
+ const send = () => {
162
+ void rest.post(Routes.channelTyping(channelId)).catch(() => {});
163
+ };
164
+ send(); // immediate first tick; the indicator would otherwise lag by the interval
165
+ stopTypingTimer(channelId); // replace any existing timer for this channel
166
+ typingTimers.set(channelId, setInterval(send, TYPING_INTERVAL_MS));
167
+ return { ok: true };
128
168
  },
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
- },
169
+ },
170
+ stopTyping: {
171
+ description: "Stop the 'typing…' indicator in a channel.",
172
+ parameters: Type.Object({
173
+ channelId: Type.String(),
174
+ }),
175
+ execute: async (params) => {
176
+ const { channelId } = params as { channelId: string };
177
+ stopTypingTimer(channelId);
178
+ return { ok: true };
141
179
  },
142
180
  },
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
- });
181
+ },
182
+ run(ctx) {
183
+ const account = ctx.account as DiscordAccount;
184
+ const { botToken, allowedChannelIds } = account;
185
+ const allowAll = !allowedChannelIds || allowedChannelIds.length === 0;
186
+ if (allowAll) {
187
+ console.warn("[discord] no allowedChannelIds configured — accepting messages from ANY channel.");
188
+ }
160
189
 
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
- });
190
+ const client = new Client({
191
+ intents: [
192
+ GatewayIntentBits.Guilds,
193
+ GatewayIntentBits.GuildMessages,
194
+ GatewayIntentBits.MessageContent,
195
+ GatewayIntentBits.DirectMessages,
196
+ ],
197
+ partials: [Partials.Channel], // required to receive DMs
198
+ });
199
+
200
+ client.on(Events.MessageCreate, (m) => {
201
+ if (m.author.id === client.user?.id) return; // skip self
202
+ if (m.author.bot) return; // skip other bots (loop prevention)
203
+ if (!allowAll && !allowedChannelIds?.includes(m.channelId)) return;
204
+ const text = m.content;
205
+ if (!text) return; // empty without the MESSAGE CONTENT intent, or media-only
206
+
207
+ ctx.emit("message", {
208
+ channelId: m.channelId,
209
+ messageId: m.id,
210
+ text,
211
+ author: { id: m.author.id, name: m.author.username },
174
212
  });
213
+ });
175
214
 
176
- client.on(Events.Error, (err) => {
177
- console.error("[discord] client error:", err.message);
178
- });
215
+ client.on(Events.Error, (err) => {
216
+ console.error("[discord] client error:", err.message);
217
+ });
179
218
 
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
- }
219
+ // Fire-and-forget: login never settles into a state we await.
220
+ if (!ctx.signal.aborted) {
221
+ void client.login(botToken).catch((err) => {
222
+ console.error("[discord] failed to log in:", err instanceof Error ? err.message : err);
223
+ });
224
+ }
186
225
 
187
- const dispose = () => void client.destroy();
188
- ctx.signal.addEventListener("abort", dispose);
189
- return dispose;
190
- },
191
- });
192
- }
226
+ const dispose = () => {
227
+ // Clear typing timers here so a reload can never orphan an interval.
228
+ clearAllTypingTimers();
229
+ void client.destroy();
230
+ };
231
+ ctx.signal.addEventListener("abort", dispose);
232
+ return dispose;
233
+ },
234
+ });
@@ -5,12 +5,12 @@
5
5
  "type": "module",
6
6
  "wolli": {
7
7
  "integrations": ["./index.ts"],
8
- "extensions": ["./discord-chat.ts"]
8
+ "workflows": ["./discord-chat.ts"]
9
9
  },
10
10
  "dependencies": {
11
11
  "discord.js": "14.26.4"
12
12
  },
13
13
  "peerDependencies": {
14
- "@opsyhq/wolli": "*"
14
+ "wolli": "*"
15
15
  }
16
16
  }