wolli 0.0.2 → 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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Wolli
4
4
 
5
- **Build persistent, purposeful AI agents.**
5
+ **Create purposeful, self-extending AI agents.**
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)
@@ -31,14 +31,25 @@ wolli
31
31
  The first run sets up your provider and creates your first agent. A new agent
32
32
  starts in a **forming** state: it interviews you to work out its purpose and
33
33
  records what it learns, and does not act unattended until you deploy it. Agents and
34
- state live under `~/.wolli`.
34
+ state live under `~/.wolli`. `●` deployed and `○` forming.
35
+
36
+ ```
37
+ Agents
38
+
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.
43
+
44
+ ↑/↓ browse · enter chat · tab details · type to search commands · ctrl+c quit
45
+ ```
35
46
 
36
47
  ## How it works
37
48
 
38
49
  - **Purpose-built.** You state the agent's purpose at birth. It decides what the
39
50
  agent stores, when it speaks up, and what it does unattended.
40
51
  - **Self-extending.** The agent builds itself out for its purpose. It curates its
41
- own memory and authors and installs its own skills, tools, and extensions; they
52
+ own memory and authors and installs its own skills, tools, extensions and integrations; they
42
53
  live in its home and load on reload. The agent grows more capable at its job
43
54
  instead of staying a fixed tool.
44
55
  - **Persistent.** Sessions are an append-only JSONL tree, the agent's lifetime
@@ -54,7 +65,7 @@ state live under `~/.wolli`.
54
65
  | `USER.md` | Facts about its human. |
55
66
 
56
67
  - **Always on, locally.** A per-agent daemon supervised by launchd (macOS) or
57
- systemd (linux) runs the agent on schedules and events while your machine is on.
68
+ systemd (Linux) runs the agent on schedules and events while your machine is on.
58
69
  - **Sandboxed.** The agent runs in a sandbox by default: `srt` (Apple Seatbelt /
59
70
  bubblewrap), or optional Docker. Reaching your real machine is an explicit,
60
71
  approval-gated escalation.
@@ -76,6 +87,7 @@ removes them with it:
76
87
 
77
88
  | Type | What it adds |
78
89
  | --- | --- |
90
+ | **Integrations** | TypeScript modules: tools, commands, events, UI. |
79
91
  | **Extensions** | TypeScript modules: tools, commands, events, UI. |
80
92
  | **Skills** | The Agent Skills standard. |
81
93
  | **Prompt templates** | `/name` slash commands. |
@@ -101,7 +113,6 @@ Two integrations ship bundled: **Telegram** (bidirectional chat) and a
101
113
 
102
114
  Full documentation is in [`packages/wolli/docs`](packages/wolli/docs/index.md):
103
115
  extensions, skills, prompt templates, themes, integrations, plugins, and the SDK.
104
- The integration/extension split is covered in [INTEGRATION.md](INTEGRATION.md).
105
116
 
106
117
  ## Roadmap
107
118
 
@@ -0,0 +1,156 @@
1
+ # Discord
2
+
3
+ Connect a wolli agent to Discord. The bot runs over Discord's gateway WebSocket,
4
+ gives each channel and DM its own wolli session, and replies in place. Setup is a
5
+ few clicks in the Discord Developer Portal plus a single token prompt.
6
+
7
+ ## How the bot behaves
8
+
9
+ Before setup, here's the part most people want to know: this bot answers
10
+ **everything it can read**. There is no `@mention` gate.
11
+
12
+ | Context | Behavior |
13
+ |---------|----------|
14
+ | **DMs** | Responds to every non-empty message. |
15
+ | **Server channels** | Responds to every non-empty message in any channel it can read. No `@mention` required — unlike some Discord agents, the bot replies to plain messages. Scope where it listens with `allowedChannelIds`. |
16
+ | **Other bots / itself** | Ignored. The bot skips its own messages and messages from any other bot, so it can't loop. |
17
+ | **Empty / media-only messages** | Skipped (there is no inbound media handling). |
18
+
19
+ While a turn runs, the bot shows Discord's **typing…** indicator in the channel and
20
+ keeps it alive until the reply lands. Replies are chunked at **2000 characters**
21
+ (Discord's per-message limit); Discord renders markdown natively, so formatting in
22
+ the agent's output comes through as-is.
23
+
24
+ If a new message arrives while the agent is mid-reply, it is **queued as a
25
+ follow-up** and answered after the current turn finishes — it does not interrupt the
26
+ in-flight response.
27
+
28
+ ## Session model
29
+
30
+ Each channel and DM gets its **own wolli session**, bound by a `discord:channel`
31
+ tag:
32
+
33
+ - **Inbound** — an incoming message is routed to the session tagged for its channel.
34
+ If none exists yet, one is created and tagged on the spot. Histories never bleed
35
+ across channels, and two channels can run in parallel.
36
+ - **Outbound** — the reply rides the producing session's tag, so the answer always
37
+ returns to the channel that started the turn — not to whoever messaged most
38
+ recently.
39
+
40
+ ## Setup
41
+
42
+ ### 1. Create a Discord application
43
+
44
+ Open the [Discord Developer Portal](https://discord.com/developers/applications) and
45
+ click **New Application**. Name it (e.g. "Wolli") and accept the terms.
46
+
47
+ ### 2. Enable the Message Content Intent
48
+
49
+ This is the critical step. Open **Bot** in the sidebar, scroll to **Privileged
50
+ Gateway Intents**, toggle **Message Content Intent** to **ON**, and **Save Changes**.
51
+
52
+ Without it, the bot still receives message events but the message text arrives
53
+ **empty**, so it can never reply. This is the single privileged intent the bot
54
+ needs — you do **not** need Server Members or Presence.
55
+
56
+ ### 3. Reset and copy the bot token
57
+
58
+ On the same **Bot** page, click **Reset Token**, complete 2FA if prompted, and copy
59
+ the token. It is shown only once — if you lose it, reset and generate a new one. Keep
60
+ it secret.
61
+
62
+ ### 4. Invite the bot
63
+
64
+ Open **OAuth2 → URL Generator** and select:
65
+
66
+ - **Scopes:** `bot`
67
+ - **Bot Permissions:** **View Channels**, **Send Messages**, **Read Message History**
68
+
69
+ Copy the generated URL at the bottom, open it, pick a server you administer, and
70
+ authorize. Only the `bot` scope is required — the `applications.commands` scope is
71
+ not needed.
72
+
73
+ ### 5. Install and onboard in wolli
74
+
75
+ ```bash
76
+ wolli <agent> plugins install ./built-in/plugins/discord
77
+ ```
78
+
79
+ In an interactive terminal this runs onboarding immediately: it prints the connect
80
+ guide, then prompts you to **paste the bot token**. Paste the token from step 3.
81
+ Wolli verifies it with a live `GET /users/@me` call and stores it. That single token
82
+ is the only value you enter.
83
+
84
+ If you installed non-interactively, onboard later with:
85
+
86
+ ```bash
87
+ wolli <agent> plugins configure discord
88
+ ```
89
+
90
+ ### 6. Restart the agent
91
+
92
+ ```bash
93
+ wolli restart <agent>
94
+ ```
95
+
96
+ This starts the gateway producer that connects to Discord. After onboarding a fresh
97
+ integration, restart the agent once so the bot comes online and begins responding.
98
+
99
+ ## Configuration reference
100
+
101
+ Configuration lives per agent in `~/.wolli/agents/<name>/integrations.json` under
102
+ `discord.default`:
103
+
104
+ ```json
105
+ {
106
+ "discord": {
107
+ "default": {
108
+ "botToken": "...",
109
+ "allowedChannelIds": ["123456789012345678"]
110
+ }
111
+ }
112
+ }
113
+ ```
114
+
115
+ | Field | Required | Default | Purpose |
116
+ |-------|----------|---------|---------|
117
+ | `botToken` | Yes | — | The bot token. Onboarding stores it raw; a `$ENV` / `!cmd` reference placed here by hand also resolves on read. |
118
+ | `allowedChannelIds` | No | *(any channel)* | Allowlist of channel IDs the bot will respond in. Empty or absent accepts **any** channel the bot can read (logged as a warning at startup). |
119
+
120
+ Discord IDs are snowflakes — keep them as **quoted strings** in JSON, never numbers,
121
+ since a 64-bit snowflake exceeds JavaScript's safe-integer range. To grab a channel
122
+ ID, enable **Developer Mode** in Discord (Settings → Advanced), then right-click a
123
+ channel and **Copy Channel ID**.
124
+
125
+ `allowedChannelIds` is not asked during onboarding — edit `integrations.json` to set
126
+ it.
127
+
128
+ ## Not supported
129
+
130
+ The bot is deliberately focused on text chat. It does not provide:
131
+
132
+ - Mention-gating — it answers every readable message, not only `@mentions`.
133
+ - User, role, or guild allowlists — channel-level `allowedChannelIds` is the only
134
+ scope control.
135
+ - Threads or auto-threading.
136
+ - Reactions.
137
+ - Native Discord slash commands.
138
+ - Inbound or outbound media, attachments, or voice.
139
+
140
+ ## Troubleshooting
141
+
142
+ | Symptom | Fix |
143
+ |---------|-----|
144
+ | Bot is online but never replies | Enable the **Message Content Intent** (step 2). Without it the message text arrives empty and there is nothing to answer. |
145
+ | `Disallowed intents` on connect | Same cause: the Message Content Intent is not enabled in the Developer Portal. |
146
+ | No reply right after the first install/onboard | Restart the agent (`wolli restart <agent>`) so the gateway producer starts. |
147
+ | Silence in one specific channel | The bot is missing **View Channels** / **Send Messages** there, or the channel isn't in `allowedChannelIds`. Try a DM to isolate. |
148
+
149
+ ## Security
150
+
151
+ - `allowedChannelIds` is the only access gate today. With it empty, **any** channel
152
+ the bot can read is accepted — set it to lock the bot to known channels.
153
+ - There is no per-user or per-role gate. Anyone who can post in an allowed channel
154
+ can drive the agent, so keep the bot in trusted channels.
155
+ - The bot token grants full control of the bot account — never commit it or share
156
+ it. Wolli writes `integrations.json` with mode `0600`.
@@ -0,0 +1,87 @@
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`.
6
+ */
7
+
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
+ }
@@ -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,153 @@
1
+ # Telegram
2
+
3
+ Connect a wolli agent to Telegram. The bot runs over Telegram's long-polling API,
4
+ gives each chat its own wolli session, and replies in place. Setup is one message to
5
+ [@BotFather](https://t.me/BotFather) plus a single token prompt — no public URL or
6
+ TLS is required.
7
+
8
+ ## How the bot behaves
9
+
10
+ Before setup, here's the part most people want to know: this bot answers **every
11
+ text message it receives**. There is no `@mention` gate.
12
+
13
+ | Context | Behavior |
14
+ |---------|----------|
15
+ | **Private chats** | Responds to every text message. |
16
+ | **Group chats** | Responds to every text message it can see in any chat. No `@mention` required. Scope where it listens with `allowedChatIds`. |
17
+ | **Its own messages** | Ignored. The bot skips messages it sent itself, so it can't loop. |
18
+ | **Non-text messages** | Skipped. Photos, stickers, voice, and other media are not handled — only text messages reach the agent. |
19
+ | **Slash commands** | `/new`, `/status`, `/help` are handled locally and never sent to the model (see [Commands](#commands)). |
20
+
21
+ While a turn runs, the bot shows Telegram's **typing…** indicator and re-sends it
22
+ every ~4 seconds (Telegram clears the state on its own) until the reply lands.
23
+
24
+ Replies are chunked at **4096 characters** (Telegram's per-message limit) and each
25
+ chunk is sent with the configured **parse mode** (`MarkdownV2` by default). Telegram,
26
+ unlike Discord, needs a parse mode to render formatting — and if it rejects a chunk's
27
+ formatting, that chunk is **re-sent as plain text**, so a stray markdown character
28
+ never silently drops the reply.
29
+
30
+ If a new message arrives while the agent is mid-reply, it is **queued as a follow-up**
31
+ and answered after the current turn finishes — it does not interrupt the in-flight
32
+ response.
33
+
34
+ ## Commands
35
+
36
+ These commands are registered as the bot's command menu via BotFather on startup and
37
+ are handled by the integration directly, not forwarded to the model:
38
+
39
+ | Command | Action |
40
+ |---------|--------|
41
+ | `/new` | Start a fresh session for this chat. The new session becomes the active one; the previous session stays addressable but new messages route to the new one. |
42
+ | `/status` | Show the current session name and the model used in the last reply. |
43
+ | `/help` | List the available commands. |
44
+
45
+ Any other `/command` returns `Unknown command: /<name>. Try /help.`
46
+
47
+ ## Session model
48
+
49
+ Each chat gets its **own wolli session**, bound by a `telegram:chat` tag:
50
+
51
+ - **Inbound** — an incoming message is routed to the session tagged for its chat. If
52
+ none exists yet, one is created and tagged on the spot. Histories never bleed across
53
+ chats, and two chats can run in parallel.
54
+ - **Outbound** — the reply rides the producing session's tag, so the answer always
55
+ returns to the chat that started the turn — not to whoever messaged most recently.
56
+
57
+ `/new` creates a fresh session tagged for the chat; because it becomes the newest
58
+ match for that tag, subsequent messages route to it.
59
+
60
+ ## Setup
61
+
62
+ ### 1. Create a bot with BotFather
63
+
64
+ Open [@BotFather](https://t.me/BotFather) in Telegram and send `/newbot`. Pick a name
65
+ and a username; BotFather replies with a **bot token** like `123456:ABC-DEF...`. Copy
66
+ it — you'll paste it in the next step. Keep it secret.
67
+
68
+ ### 2. Install and onboard in wolli
69
+
70
+ ```bash
71
+ wolli <agent> plugins install ./built-in/plugins/telegram
72
+ ```
73
+
74
+ In an interactive terminal this runs onboarding immediately: it prints the BotFather
75
+ walkthrough, then prompts you to **paste the bot token**. Paste the token from step 1.
76
+ Wolli verifies it with a live `getMe()` call and stores it. That single token is the
77
+ only value you enter.
78
+
79
+ If you installed non-interactively, onboard later with:
80
+
81
+ ```bash
82
+ wolli <agent> plugins configure telegram
83
+ ```
84
+
85
+ ### 3. Restart the agent
86
+
87
+ ```bash
88
+ wolli restart <agent>
89
+ ```
90
+
91
+ This starts the long-poll producer that connects to Telegram. After onboarding a
92
+ fresh integration its producer starts only on the next daemon start, so restart the
93
+ agent once for the bot to come online and begin responding.
94
+
95
+ ## Configuration reference
96
+
97
+ Configuration lives per agent in `~/.wolli/agents/<name>/integrations.json` under
98
+ `telegram.default`:
99
+
100
+ ```json
101
+ {
102
+ "telegram": {
103
+ "default": {
104
+ "botToken": "123456:ABC-DEF...",
105
+ "allowedChatIds": [123456789],
106
+ "parseMode": "MarkdownV2"
107
+ }
108
+ }
109
+ }
110
+ ```
111
+
112
+ | Field | Required | Default | Purpose |
113
+ |-------|----------|---------|---------|
114
+ | `botToken` | Yes | — | The BotFather token. Onboarding stores it raw; a `$ENV` / `!cmd` reference placed here by hand also resolves on read. |
115
+ | `allowedChatIds` | No | *(any chat)* | Allowlist of chat IDs the bot will respond in. Empty or absent accepts **any** chat (logged as a warning at startup). |
116
+ | `parseMode` | No | `MarkdownV2` | Outbound formatting: `"MarkdownV2"`, `"HTML"`, or `"plain"` (disables parse mode). |
117
+
118
+ Telegram chat IDs are **numbers** — keep them unquoted in JSON, unlike Discord's
119
+ quoted snowflakes.
120
+
121
+ `allowedChatIds` and `parseMode` are not asked during onboarding — edit
122
+ `integrations.json` to set them.
123
+
124
+ ## Not supported
125
+
126
+ The bot is deliberately focused on text chat. It does not provide:
127
+
128
+ - A durable cursor — on start it calls `deleteWebhook({ drop_pending_updates: true })`,
129
+ so a restart never replays a backlog but also drops messages sent while the bot was
130
+ offline.
131
+ - Inbound media or images — only text messages are handled.
132
+ - Webhook mode — the transport is long polling only.
133
+ - Callback queries or inline keyboards.
134
+ - Outbound rate-limit throttling.
135
+ - Mention-gating, or per-user / per-role allowlists — chat-level `allowedChatIds` is
136
+ the only scope control.
137
+
138
+ ## Troubleshooting
139
+
140
+ | Symptom | Fix |
141
+ |---------|-----|
142
+ | No reply right after the first install/onboard | Restart the agent (`wolli restart <agent>`) so the long-poll producer starts. |
143
+ | Bot ignores messages sent while it was offline | Expected — `deleteWebhook({ drop_pending_updates: true })` clears the backlog on start. The bot only sees messages that arrive while it is running. |
144
+ | Formatting looks broken, or a message seems to drop | A chunk that fails parse-mode parsing is re-sent as plain text. Set `parseMode: "plain"` to disable formatting entirely. |
145
+
146
+ ## Security
147
+
148
+ - `allowedChatIds` is the only access gate. With it empty, **any** chat is accepted —
149
+ set it to lock the bot to known chats.
150
+ - There is no per-user gate beyond that. Anyone who can post in an allowed chat can
151
+ drive the agent, so keep the bot in trusted chats.
152
+ - The bot token grants full control of the bot account — never commit it or share it.
153
+ Wolli writes `integrations.json` with mode `0600`.
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * ## Install + configure
14
14
  *
15
- * wolli <agent> plugins install ./plugins/telegram
15
+ * wolli <agent> plugins install ./built-in/plugins/telegram
16
16
  * # then paste the BotFather token into the guided prompt — that's it.
17
17
  *
18
18
  * Onboarding asks for the BotFather token directly, verifies it with a live `getMe()`,
File without changes
package/dist/cli.js CHANGED
@@ -257407,6 +257407,8 @@ __export(src_exports4, {
257407
257407
  getAuthPath: () => getAuthPath,
257408
257408
  getAvailableThemesWithPaths: () => getAvailableThemesWithPaths,
257409
257409
  getBinDir: () => getBinDir,
257410
+ getBuiltInDir: () => getBuiltInDir,
257411
+ getBuiltInSkillsDir: () => getBuiltInSkillsDir,
257410
257412
  getCustomThemesDir: () => getCustomThemesDir,
257411
257413
  getDaemonHost: () => getDaemonHost,
257412
257414
  getDaemonRuntimeDir: () => getDaemonRuntimeDir,
@@ -274245,8 +274247,14 @@ function getReadmePath() {
274245
274247
  function getDocsPath() {
274246
274248
  return resolve(join(getPackageDir(), "docs"));
274247
274249
  }
274250
+ function getBuiltInDir() {
274251
+ return resolve(join(getPackageDir(), "built-in"));
274252
+ }
274248
274253
  function getPluginsDir() {
274249
- return resolve(join(getPackageDir(), "plugins"));
274254
+ return join(getBuiltInDir(), "plugins");
274255
+ }
274256
+ function getBuiltInSkillsDir() {
274257
+ return join(getBuiltInDir(), "skills");
274250
274258
  }
274251
274259
  function getHomeDir() {
274252
274260
  const envHome = process.env[ENV_HOME];
@@ -278576,12 +278584,13 @@ var Agent2 = class {
278576
278584
  const token = getDaemonToken() || persisted;
278577
278585
  const [latest] = this.getAgentState().sessions;
278578
278586
  if (!latest) throw new Error(`No session for agent "${this.name}".`);
278587
+ const birthStartedAt = (await getDaemonInfo(base))?.startedAt ?? null;
278579
278588
  const snap = await this.send(latest.sessionId, { type: "deploy" });
278580
278589
  if (getServiceManager().kind === "none") return snap;
278581
278590
  await requestDaemonShutdown(base, token);
278582
278591
  await waitForShutdown(base);
278583
278592
  getServiceManager().start(this.name);
278584
- await waitForHealth(base);
278593
+ await waitForRestart(base, birthStartedAt);
278585
278594
  this.controlStream?.close();
278586
278595
  for (const handle of this.sessions.values()) handle.close();
278587
278596
  this.sessions.clear();
@@ -278636,6 +278645,7 @@ var Agent2 = class {
278636
278645
  const base = `http://${getDaemonHost()}:${port}`;
278637
278646
  const token = getDaemonToken() || persisted;
278638
278647
  const service = getServiceManager();
278648
+ const previousStartedAt = (await getDaemonInfo(base))?.startedAt ?? null;
278639
278649
  if (service.kind !== "none" && await service.isRunning(this.name)) {
278640
278650
  service.stop(this.name);
278641
278651
  await waitForShutdown(base);
@@ -278646,7 +278656,7 @@ var Agent2 = class {
278646
278656
  const [command, ...commandArgs] = daemonLaunchCommand(this.name);
278647
278657
  spawn2(command, commandArgs, { detached: true, stdio: "ignore" }).unref();
278648
278658
  }
278649
- await waitForHealth(base);
278659
+ await waitForRestart(base, previousStartedAt);
278650
278660
  }
278651
278661
  };
278652
278662
  var SessionHandle = class _SessionHandle {
@@ -278917,6 +278927,16 @@ var SessionHandle = class _SessionHandle {
278917
278927
  var HEALTH_TIMEOUT_MS = 15e3;
278918
278928
  var HEALTH_POLL_MS = 150;
278919
278929
  var sleep5 = (ms2) => new Promise((resolve13) => setTimeout(resolve13, ms2));
278930
+ async function getDaemonInfo(base) {
278931
+ try {
278932
+ const response = await fetch(`${base}/health`, { signal: AbortSignal.timeout(1e3) });
278933
+ if (!response.ok) return null;
278934
+ const { status, pid, startedAt } = await response.json();
278935
+ return status === "ok" && pid !== void 0 && startedAt !== void 0 ? { pid, startedAt } : null;
278936
+ } catch {
278937
+ return null;
278938
+ }
278939
+ }
278920
278940
  async function isHealthy(base) {
278921
278941
  try {
278922
278942
  const response = await fetch(`${base}/health`, { signal: AbortSignal.timeout(1e3) });
@@ -278942,6 +278962,15 @@ async function waitForShutdown(base) {
278942
278962
  }
278943
278963
  throw new Error(`Daemon at ${base} did not shut down within ${HEALTH_TIMEOUT_MS / 1e3}s.`);
278944
278964
  }
278965
+ async function waitForRestart(base, since) {
278966
+ const deadline = Date.now() + HEALTH_TIMEOUT_MS;
278967
+ while (Date.now() < deadline) {
278968
+ const info = await getDaemonInfo(base);
278969
+ if (info && info.startedAt !== since) return;
278970
+ await sleep5(HEALTH_POLL_MS);
278971
+ }
278972
+ throw new Error(`Daemon at ${base} did not restart within ${HEALTH_TIMEOUT_MS / 1e3}s.`);
278973
+ }
278945
278974
  async function requestDaemonShutdown(base, token) {
278946
278975
  try {
278947
278976
  const list2 = await fetch(`${base}/sessions`, {
@@ -310137,8 +310166,9 @@ var BIRTH_INSTRUCTION = [
310137
310166
  "",
310138
310167
  "You cannot act unattended yet. This session exists to make you real: open the conversation yourself,",
310139
310168
  "then interview your human hard \u2014 one sharp question at a time \u2014 until you understand what you are for",
310140
- "and who they are. Record as you go with the memory tool: USER = durable facts about your human;",
310141
- "MEMORY = your own durable notes. Their answers are raw material, not gospel \u2014 distill what you're",
310169
+ "and who they are. Record with the memory tool only what will still matter next session: USER = stable",
310170
+ "facts about your human (name, role, timezone, how they work, standing constraints); MEMORY = your own",
310171
+ "durable notes. Their answers are raw material, not gospel \u2014 distill what you're",
310142
310172
  "really for, push back, and ask the follow-up. Do not hand-write SOUL.md and do not start doing the",
310143
310173
  "job yet; first become yourself.",
310144
310174
  "",
@@ -310162,7 +310192,9 @@ var EXTENDING_YOURSELF = [
310162
310192
  "commands, events, UI), connect external services and message channels via integrations, and add",
310163
310193
  "skills, prompt templates, and themes \u2014 bundle and install them as plugins. Before building from",
310164
310194
  "scratch, check the bundled plugins folder (path below) for a ready-made plugin: if one fits the need",
310165
- "(e.g. Telegram for chat), have your human install and onboard it instead of writing your own. Read",
310195
+ "(e.g. Telegram for chat), have your human install and onboard it instead of writing your own. Skills",
310196
+ "are lighter \u2014 the built-in skills folder (path below) holds ready-made ones you can install yourself by",
310197
+ "copying into your own skills/ folder; prefer a fitting built-in skill over authoring a new one. Read",
310166
310198
  "the relevant docs below and follow their cross-references before building anything."
310167
310199
  ].join("\n");
310168
310200
  function section(title, content) {
@@ -310186,8 +310218,9 @@ function buildSystemPrompt(options2) {
310186
310218
  "immediately but become effective next session). Each is modifiable on its own:",
310187
310219
  "- SOUL.md \u2014 who you are, what you're for, how you operate. Authored when you deploy; once deployed you",
310188
310220
  " rewrite it yourself with the bash tool.",
310189
- "- MEMORY.md \u2014 your own durable notebook. Edit it with the memory tool.",
310190
- "- USER.md \u2014 durable facts about your human. Edit it with the memory tool.",
310221
+ "- MEMORY.md \u2014 your own durable notebook: knowledge, decisions, and learnings you accumulate. Edit it with the memory tool.",
310222
+ "- USER.md \u2014 durable facts about your human: name, role, timezone, how they like to work, lasting constraints. Edit it with the memory tool.",
310223
+ "- Save to either only what will still matter in a future session.",
310191
310224
  "",
310192
310225
  section("SOUL.md", soul),
310193
310226
  "",
@@ -310199,6 +310232,7 @@ function buildSystemPrompt(options2) {
310199
310232
  const readmePath = getReadmePath();
310200
310233
  const docsPath = getDocsPath();
310201
310234
  const pluginsDir = getPluginsDir();
310235
+ const builtInSkillsDir = getBuiltInSkillsDir();
310202
310236
  parts.push(
310203
310237
  "",
310204
310238
  `## ${APP_NAME} documentation (read when the user asks about ${APP_NAME} itself \u2014 its extensions, integrations, skills, prompt templates, themes, plugins, or SDK \u2014 or when you extend or modify yourself)`,
@@ -310206,6 +310240,7 @@ function buildSystemPrompt(options2) {
310206
310240
  `- Additional docs: ${docsPath} (resolve docs/... under here, not the current working directory)`,
310207
310241
  `- Topics: extensions (docs/extensions.md), integrations (docs/integrations.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), themes (docs/themes.md), plugins (docs/plugins.md), sdk (docs/sdk.md)`,
310208
310242
  `- Bundled plugins ready to install: ${pluginsDir} (e.g. telegram, scheduler) \u2014 prefer installing a fitting one over building from scratch; have your human run \`${APP_NAME} <name> plugins install <path>\` then onboard it`,
310243
+ `- Built-in skills ready to install: ${builtInSkillsDir} \u2014 browse them, then install one yourself by copying its folder into your own skills/ dir (\`mkdir -p skills && cp -r ${builtInSkillsDir}/<name> skills/\`); it loads next session`,
310209
310244
  `- When working on ${APP_NAME} topics, read the docs and follow .md cross-references before implementing`,
310210
310245
  `- Always read ${APP_NAME} .md files completely and follow links to related docs`
310211
310246
  );
@@ -312727,7 +312762,7 @@ function createMemoryTool(name) {
312727
312762
  return {
312728
312763
  name: "memory",
312729
312764
  label: "Memory",
312730
- description: "Edit your curated memory. MEMORY.md is your own durable notebook; USER.md holds facts about your human. (Your SOUL.md \u2014 who you are \u2014 is a free-form file you rewrite with the bash tool instead.) Edits are saved immediately but only appear in your prompt on your next session. Keep entries concise.",
312765
+ description: "Edit your curated memory. MEMORY.md is your durable notebook (knowledge, decisions, learnings); USER.md holds durable facts about your human (role, timezone, working style, standing constraints). Save only what will still matter next session. (Your SOUL.md \u2014 who you are \u2014 is a free-form file you rewrite with the bash tool instead.) Edits are saved immediately but only appear in your prompt on your next session. Keep entries concise.",
312731
312766
  parameters: memorySchema,
312732
312767
  executionMode: "sequential",
312733
312768
  execute: async (_toolCallId, params) => {
@@ -318685,8 +318720,9 @@ async function handleCommand(runtime, session, cmd, requestShutdown) {
318685
318720
  await runtime.reload();
318686
318721
  return ok2(id, "update_plugins");
318687
318722
  }
318688
- // Onboarding writes through the runtime's live account store, so no second reload is needed; the
318689
- // source scopes onboarding to the just-installed plugin's integrations that declare `onboard`.
318723
+ // Onboarding writes the account to the live store, but its producer (e.g. a Discord/Telegram
318724
+ // gateway) starts only on the next daemon start, so the CLI tells the user to restart the agent.
318725
+ // The `source` scopes onboarding to the just-installed plugin's integrations that declare `onboard`.
318690
318726
  case "onboard_plugin":
318691
318727
  return ok2(id, "onboard_plugin", {
318692
318728
  results: await runDaemonOnboarding(runtime, session.ui, ({ integrations, pluginManager }) => {
@@ -325419,7 +325455,7 @@ function printOnboardResults(agent, results, emptyMessage) {
325419
325455
  switch (status) {
325420
325456
  case "connected":
325421
325457
  console.log(source_default.green(`${service} connected.`));
325422
- console.log(source_default.dim(`Run "${APP_NAME} ${agent}" to use it.`));
325458
+ console.log(source_default.dim(`Restart the agent for it to take effect: ${APP_NAME} restart ${agent}`));
325423
325459
  break;
325424
325460
  case "cancelled":
325425
325461
  console.log(source_default.dim(`${service}: onboarding cancelled.`));
package/docs/skills.md CHANGED
@@ -7,6 +7,7 @@ Wolli implements the [Agent Skills standard](https://agentskills.io/specificatio
7
7
  ## Table of Contents
8
8
 
9
9
  - [Locations](#locations)
10
+ - [Built-in Skills](#built-in-skills)
10
11
  - [How Skills Work](#how-skills-work)
11
12
  - [Skill Commands](#skill-commands)
12
13
  - [Skill Structure](#skill-structure)
@@ -45,6 +46,19 @@ To use skills from Claude Code or OpenAI Codex, add their directories to setting
45
46
  }
46
47
  ```
47
48
 
49
+ ## Built-in Skills
50
+
51
+ Wolli ships a catalog of ready-made skills in the package's `built-in/skills/` directory. These are **not loaded automatically** — they are a library to install from, the same way bundled plugins are. The agent browses them and installs the ones it wants.
52
+
53
+ Installing a built-in skill is just a copy into the agent's own `skills/` folder (the agent's working directory is its home, so `skills/` is relative):
54
+
55
+ ```bash
56
+ mkdir -p skills
57
+ cp -r <built-in-skills-dir>/<name> skills/
58
+ ```
59
+
60
+ The agent is told the absolute `<built-in-skills-dir>` path in its system prompt. After the copy the skill loads on the next session (or after `/reload`), at which point it appears in the available-skills list and as `/skill:<name>`. The copy lives in the agent's home, so the agent owns it and can edit it freely without affecting the built-in original.
61
+
48
62
  ## How Skills Work
49
63
 
50
64
  1. At startup, Wolli scans skill locations and extracts names and descriptions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wolli",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Persistent, purposeful agent CLI with memory and identity",
5
5
  "type": "module",
6
6
  "piConfig": {
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "files": [
14
14
  "dist",
15
- "plugins",
15
+ "built-in",
16
16
  "docs",
17
17
  "native"
18
18
  ],