opencode-claw 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +316 -0
- package/dist/channels/router.d.ts +18 -0
- package/dist/channels/router.js +188 -0
- package/dist/channels/slack.d.ts +4 -0
- package/dist/channels/slack.js +87 -0
- package/dist/channels/telegram.d.ts +4 -0
- package/dist/channels/telegram.js +79 -0
- package/dist/channels/types.d.ts +27 -0
- package/dist/channels/types.js +1 -0
- package/dist/channels/whatsapp.d.ts +4 -0
- package/dist/channels/whatsapp.js +136 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +6 -0
- package/dist/compat.d.ts +12 -0
- package/dist/compat.js +63 -0
- package/dist/config/loader.d.ts +2 -0
- package/dist/config/loader.js +57 -0
- package/dist/config/schema.d.ts +482 -0
- package/dist/config/schema.js +108 -0
- package/dist/config/types.d.ts +14 -0
- package/dist/config/types.js +1 -0
- package/dist/cron/scheduler.d.ts +16 -0
- package/dist/cron/scheduler.js +113 -0
- package/dist/exports.d.ts +9 -0
- package/dist/exports.js +4 -0
- package/dist/health/server.d.ts +17 -0
- package/dist/health/server.js +68 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +127 -0
- package/dist/memory/factory.d.ts +3 -0
- package/dist/memory/factory.js +14 -0
- package/dist/memory/openviking.d.ts +5 -0
- package/dist/memory/openviking.js +138 -0
- package/dist/memory/plugin-entry.d.ts +3 -0
- package/dist/memory/plugin-entry.js +11 -0
- package/dist/memory/plugin.d.ts +5 -0
- package/dist/memory/plugin.js +63 -0
- package/dist/memory/txt.d.ts +2 -0
- package/dist/memory/txt.js +137 -0
- package/dist/memory/types.d.ts +36 -0
- package/dist/memory/types.js +1 -0
- package/dist/outbox/drainer.d.ts +8 -0
- package/dist/outbox/drainer.js +140 -0
- package/dist/outbox/writer.d.ts +15 -0
- package/dist/outbox/writer.js +29 -0
- package/dist/sessions/manager.d.ts +20 -0
- package/dist/sessions/manager.js +68 -0
- package/dist/sessions/persistence.d.ts +4 -0
- package/dist/sessions/persistence.js +24 -0
- package/dist/utils/logger.d.ts +8 -0
- package/dist/utils/logger.js +33 -0
- package/dist/utils/reconnect.d.ts +16 -0
- package/dist/utils/reconnect.js +54 -0
- package/dist/utils/shutdown.d.ts +5 -0
- package/dist/utils/shutdown.js +27 -0
- package/opencode-claw.example.json +71 -0
- package/package.json +77 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 opencode-claw contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# opencode-claw
|
|
2
|
+
|
|
3
|
+
Transform [OpenCode](https://opencode.ai) into a personal AI assistant accessible via messaging platforms, with persistent memory and automated task processing.
|
|
4
|
+
|
|
5
|
+
## What is this?
|
|
6
|
+
|
|
7
|
+
opencode-claw wraps the `@opencode-ai/sdk` to add three capabilities that OpenCode doesn't have out of the box:
|
|
8
|
+
|
|
9
|
+
1. **Pluggable Memory** -- Persistent knowledge across projects and sessions. Ships with a simple text-file backend (Markdown files, zero dependencies) and an optional [OpenViking](https://github.com/volcengine/OpenViking) backend for semantic search.
|
|
10
|
+
|
|
11
|
+
2. **Paired Channels** -- Chat with your AI assistant from Slack, Telegram, or WhatsApp. Each conversation maps to an OpenCode session. Users can create new sessions, switch between them, and fork existing ones -- all from within the chat interface.
|
|
12
|
+
|
|
13
|
+
3. **Cron Jobs** -- Schedule periodic tasks that create OpenCode sessions and run prompts automatically. Pull Linear issues every morning, generate standup summaries from Jira, or run any recurring workflow. Results are delivered to your preferred channel.
|
|
14
|
+
|
|
15
|
+
## Architecture
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
[Slack] [Telegram] [WhatsApp]
|
|
19
|
+
\ | /
|
|
20
|
+
Channel Adapters
|
|
21
|
+
|
|
|
22
|
+
Message Router
|
|
23
|
+
/ | \
|
|
24
|
+
Session Memory Cron
|
|
25
|
+
Manager System Scheduler
|
|
26
|
+
\ | /
|
|
27
|
+
OpenCode SDK
|
|
28
|
+
(agent runtime)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
OpenCode handles the hard parts: LLM routing, tool execution, session state, file editing, MCP servers. opencode-claw adds the "glue" layer for channels, memory, and scheduling.
|
|
32
|
+
|
|
33
|
+
## Prerequisites
|
|
34
|
+
|
|
35
|
+
- [Node.js](https://nodejs.org) >= 20
|
|
36
|
+
- [OpenCode](https://opencode.ai) installed and configured (`opencode` binary in `PATH`)
|
|
37
|
+
- At least one channel configured (Telegram bot token, Slack app token, or WhatsApp)
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Run directly (no install)
|
|
43
|
+
npx opencode-claw
|
|
44
|
+
|
|
45
|
+
# Or install globally
|
|
46
|
+
npm install -g opencode-claw
|
|
47
|
+
opencode-claw
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quick Start
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# 1. Create a config file in your project directory
|
|
54
|
+
curl -O https://raw.githubusercontent.com/jinkoso/opencode-claw/main/opencode-claw.example.json
|
|
55
|
+
mv opencode-claw.example.json opencode-claw.json
|
|
56
|
+
|
|
57
|
+
# 2. Edit opencode-claw.json with your tokens and preferences
|
|
58
|
+
# (see Configuration section below)
|
|
59
|
+
|
|
60
|
+
# 3. Run
|
|
61
|
+
npx opencode-claw
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The service starts an OpenCode server, connects your configured channels, initializes the memory system, and begins listening for messages.
|
|
65
|
+
|
|
66
|
+
## Configuration
|
|
67
|
+
|
|
68
|
+
All configuration lives in `opencode-claw.json` in the current working directory. Environment variables can be referenced with `${VAR_NAME}` syntax and will be expanded at load time.
|
|
69
|
+
|
|
70
|
+
### OpenCode
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"opencode": {
|
|
75
|
+
"port": 0,
|
|
76
|
+
"directory": "/path/to/your/project"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
| Field | Type | Default | Description |
|
|
82
|
+
|-------|------|---------|-------------|
|
|
83
|
+
| `port` | number | `0` (random) | Port for the OpenCode server |
|
|
84
|
+
| `directory` | string | cwd | Working directory for OpenCode |
|
|
85
|
+
| `configPath` | string | — | Path to a custom OpenCode config file |
|
|
86
|
+
|
|
87
|
+
### Memory
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"memory": {
|
|
92
|
+
"backend": "txt",
|
|
93
|
+
"txt": {
|
|
94
|
+
"directory": "./data/memory"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Text backend** (`txt`): Stores knowledge in Markdown files organized by category (`general.md`, `project.md`, `experience.md`, `architecture.md`). Supports keyword-based search. Zero external dependencies.
|
|
101
|
+
|
|
102
|
+
**OpenViking backend** (`openviking`): Connects to a running OpenViking instance for semantic vector search. Falls back to txt backend if OpenViking is unreachable (when `fallback: true`).
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"memory": {
|
|
107
|
+
"backend": "openviking",
|
|
108
|
+
"openviking": {
|
|
109
|
+
"url": "http://localhost:8100",
|
|
110
|
+
"fallback": true
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Memory is injected into OpenCode sessions via a plugin. The agent can call `memory_search`, `memory_store`, and `memory_delete` tools during any conversation.
|
|
117
|
+
|
|
118
|
+
### Channels
|
|
119
|
+
|
|
120
|
+
#### Telegram
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"channels": {
|
|
125
|
+
"telegram": {
|
|
126
|
+
"enabled": true,
|
|
127
|
+
"botToken": "${TELEGRAM_BOT_TOKEN}",
|
|
128
|
+
"allowlist": ["your_telegram_username"],
|
|
129
|
+
"mode": "polling",
|
|
130
|
+
"rejectionBehavior": "ignore"
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Create a bot via [@BotFather](https://t.me/BotFather) and set the token. The `allowlist` restricts access to specific Telegram usernames. `rejectionBehavior` controls what happens when an unlisted user messages the bot: `"ignore"` silently drops the message, `"reject"` sends a "This assistant is private" reply.
|
|
137
|
+
|
|
138
|
+
#### Slack
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
{
|
|
142
|
+
"channels": {
|
|
143
|
+
"slack": {
|
|
144
|
+
"enabled": true,
|
|
145
|
+
"botToken": "${SLACK_BOT_TOKEN}",
|
|
146
|
+
"appToken": "${SLACK_APP_TOKEN}",
|
|
147
|
+
"mode": "socket"
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Requires a Slack app with Socket Mode enabled. The `appToken` is an app-level token (starts with `xapp-`), and `botToken` is the bot user OAuth token (starts with `xoxb-`). Optionally set `mode: "http"` with a `signingSecret` for HTTP-based event delivery.
|
|
154
|
+
|
|
155
|
+
#### WhatsApp
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{
|
|
159
|
+
"channels": {
|
|
160
|
+
"whatsapp": {
|
|
161
|
+
"enabled": true,
|
|
162
|
+
"allowlist": ["5511999887766"],
|
|
163
|
+
"authDir": "./data/whatsapp/auth",
|
|
164
|
+
"debounceMs": 1000
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Uses the [Baileys](https://github.com/WhiskeySockets/Baileys) library for a multi-device WhatsApp Web connection. On first start, a QR code is printed to the terminal for authentication. The `allowlist` uses full phone numbers (with country code, no `+` prefix). `debounceMs` batches rapid messages into a single prompt.
|
|
171
|
+
|
|
172
|
+
### Cron Jobs
|
|
173
|
+
|
|
174
|
+
```json
|
|
175
|
+
{
|
|
176
|
+
"cron": {
|
|
177
|
+
"enabled": true,
|
|
178
|
+
"defaultTimeoutMs": 300000,
|
|
179
|
+
"jobs": [
|
|
180
|
+
{
|
|
181
|
+
"id": "daily-standup",
|
|
182
|
+
"schedule": "0 9 * * 1-5",
|
|
183
|
+
"description": "Morning standup briefing",
|
|
184
|
+
"prompt": "Check my Linear board for P1 and P2 issues assigned to me. Summarize what needs attention today.",
|
|
185
|
+
"reportTo": {
|
|
186
|
+
"channel": "telegram",
|
|
187
|
+
"peerId": "your_telegram_username"
|
|
188
|
+
},
|
|
189
|
+
"enabled": true,
|
|
190
|
+
"timeoutMs": 300000
|
|
191
|
+
}
|
|
192
|
+
]
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Jobs use standard [cron expressions](https://crontab.guru/). Each job creates a fresh OpenCode session, sends the `prompt`, waits for completion (with timeout), and optionally delivers the result to a channel via the outbox. Jobs run one at a time to avoid overwhelming the agent.
|
|
198
|
+
|
|
199
|
+
### Router
|
|
200
|
+
|
|
201
|
+
```json
|
|
202
|
+
{
|
|
203
|
+
"router": {
|
|
204
|
+
"timeoutMs": 300000
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
| Field | Type | Default | Description |
|
|
210
|
+
|-------|------|---------|-------------|
|
|
211
|
+
| `timeoutMs` | number | `300000` (5 min) | Max time to wait for an agent response before timing out |
|
|
212
|
+
|
|
213
|
+
### Health Server
|
|
214
|
+
|
|
215
|
+
```json
|
|
216
|
+
{
|
|
217
|
+
"health": {
|
|
218
|
+
"enabled": true,
|
|
219
|
+
"port": 9090
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
When enabled, exposes HTTP endpoints for monitoring:
|
|
225
|
+
|
|
226
|
+
| Endpoint | Description |
|
|
227
|
+
|----------|-------------|
|
|
228
|
+
| `GET /health` | Overall status (`up`, `degraded`, `down`) + uptime |
|
|
229
|
+
| `GET /channels` | Connection status for each channel adapter |
|
|
230
|
+
| `GET /memory` | Memory backend status and stats |
|
|
231
|
+
| `GET /outbox` | Pending and dead-letter message counts |
|
|
232
|
+
|
|
233
|
+
### Outbox
|
|
234
|
+
|
|
235
|
+
```json
|
|
236
|
+
{
|
|
237
|
+
"outbox": {
|
|
238
|
+
"directory": "./data/outbox",
|
|
239
|
+
"pollIntervalMs": 500,
|
|
240
|
+
"maxAttempts": 3
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
The outbox is a file-based async delivery queue for cron job results. Messages are written as JSON files, a drainer polls the directory and delivers via the appropriate channel adapter. Failed deliveries are retried up to `maxAttempts` times before being moved to a dead-letter directory.
|
|
246
|
+
|
|
247
|
+
## Chat Commands
|
|
248
|
+
|
|
249
|
+
These commands are available in any connected channel:
|
|
250
|
+
|
|
251
|
+
| Command | Description |
|
|
252
|
+
|---------|-------------|
|
|
253
|
+
| `/new [title]` | Create a new OpenCode session |
|
|
254
|
+
| `/switch <id>` | Switch to an existing session |
|
|
255
|
+
| `/sessions` | List all your sessions |
|
|
256
|
+
| `/current` | Show the active session ID |
|
|
257
|
+
| `/fork` | Fork the current session into a new one |
|
|
258
|
+
| `/help` | Show available commands |
|
|
259
|
+
|
|
260
|
+
Any non-command message is routed to the active OpenCode session as a prompt.
|
|
261
|
+
|
|
262
|
+
## Programmatic API
|
|
263
|
+
|
|
264
|
+
Use opencode-claw as a library in your own Node.js application:
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
import { main, createMemoryBackend } from "opencode-claw"
|
|
268
|
+
|
|
269
|
+
// Run the full service programmatically
|
|
270
|
+
await main()
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Exports
|
|
274
|
+
|
|
275
|
+
| Export | Description |
|
|
276
|
+
|--------|-------------|
|
|
277
|
+
| `main()` | Start the full opencode-claw service |
|
|
278
|
+
| `createMemoryBackend(config)` | Create a memory backend (txt or openviking) from config |
|
|
279
|
+
| `createOutboxWriter(config)` | Create an outbox writer for queuing messages |
|
|
280
|
+
| `createOutboxDrainer(config, channels)` | Create an outbox drainer for delivering queued messages |
|
|
281
|
+
|
|
282
|
+
### Types
|
|
283
|
+
|
|
284
|
+
All configuration and domain types are exported for TypeScript consumers:
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
import type {
|
|
288
|
+
Config,
|
|
289
|
+
MemoryConfig,
|
|
290
|
+
MemoryBackend,
|
|
291
|
+
MemoryEntry,
|
|
292
|
+
ChannelAdapter,
|
|
293
|
+
ChannelId,
|
|
294
|
+
InboundMessage,
|
|
295
|
+
OutboundMessage,
|
|
296
|
+
} from "opencode-claw"
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### Standalone Memory Plugin
|
|
300
|
+
|
|
301
|
+
Use the memory plugin with a vanilla OpenCode installation (without the rest of opencode-claw):
|
|
302
|
+
|
|
303
|
+
```typescript
|
|
304
|
+
import { memoryPlugin } from "opencode-claw/plugin"
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
This registers `memory_search`, `memory_store`, and `memory_delete` tools, and injects relevant memories into the system prompt via a chat transform hook. Configure it by placing an `opencode-claw.json` with a `memory` section in your OpenCode project directory.
|
|
308
|
+
|
|
309
|
+
## Inspiration
|
|
310
|
+
|
|
311
|
+
- **[OpenClaw](https://github.com/nichochar/openclaw)** -- Channel plugin architecture, session key routing, memory system design.
|
|
312
|
+
- **[MonClaw](https://cefboud.com/posts/monclaw-a-light-openclaw-with-opencode-sdk/)** -- Outbox pattern, plugin-based memory injection, wrapping OpenCode SDK.
|
|
313
|
+
|
|
314
|
+
## License
|
|
315
|
+
|
|
316
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { OpencodeClient } from "@opencode-ai/sdk";
|
|
2
|
+
import type { Config } from "../config/types.js";
|
|
3
|
+
import type { SessionManager } from "../sessions/manager.js";
|
|
4
|
+
import type { Logger } from "../utils/logger.js";
|
|
5
|
+
import type { ChannelAdapter, ChannelId, InboundMessage } from "./types.js";
|
|
6
|
+
type RouterDeps = {
|
|
7
|
+
client: OpencodeClient;
|
|
8
|
+
sessions: SessionManager;
|
|
9
|
+
adapters: Map<ChannelId, ChannelAdapter>;
|
|
10
|
+
config: Config;
|
|
11
|
+
logger: Logger;
|
|
12
|
+
timeoutMs: number;
|
|
13
|
+
};
|
|
14
|
+
export declare function createRouter(deps: RouterDeps): {
|
|
15
|
+
handler: (msg: InboundMessage) => Promise<void>;
|
|
16
|
+
};
|
|
17
|
+
export type Router = ReturnType<typeof createRouter>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { buildSessionKey } from "../sessions/manager.js";
|
|
2
|
+
function allowlist(config, channel) {
|
|
3
|
+
const ch = config.channels[channel];
|
|
4
|
+
if (!ch)
|
|
5
|
+
return undefined;
|
|
6
|
+
if ("allowlist" in ch && Array.isArray(ch.allowlist))
|
|
7
|
+
return ch.allowlist;
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
function rejection(config, channel) {
|
|
11
|
+
const ch = config.channels[channel];
|
|
12
|
+
if (!ch)
|
|
13
|
+
return "ignore";
|
|
14
|
+
if ("rejectionBehavior" in ch && ch.rejectionBehavior)
|
|
15
|
+
return ch.rejectionBehavior;
|
|
16
|
+
return "ignore";
|
|
17
|
+
}
|
|
18
|
+
function checkAllowlist(config, msg) {
|
|
19
|
+
const list = allowlist(config, msg.channel);
|
|
20
|
+
if (!list)
|
|
21
|
+
return true;
|
|
22
|
+
return list.includes(msg.peerId);
|
|
23
|
+
}
|
|
24
|
+
function extractText(parts) {
|
|
25
|
+
return parts
|
|
26
|
+
.filter((p) => p.type === "text" && typeof p.text === "string")
|
|
27
|
+
.map((p) => p.text)
|
|
28
|
+
.join("\n\n");
|
|
29
|
+
}
|
|
30
|
+
function parseCommand(text) {
|
|
31
|
+
const trimmed = text.trim();
|
|
32
|
+
if (!trimmed.startsWith("/"))
|
|
33
|
+
return undefined;
|
|
34
|
+
const space = trimmed.indexOf(" ");
|
|
35
|
+
if (space === -1)
|
|
36
|
+
return { name: trimmed.slice(1).toLowerCase(), args: "" };
|
|
37
|
+
return { name: trimmed.slice(1, space).toLowerCase(), args: trimmed.slice(space + 1).trim() };
|
|
38
|
+
}
|
|
39
|
+
const HELP_TEXT = `Available commands:
|
|
40
|
+
/new [title] — Create a new session
|
|
41
|
+
/switch <id> — Switch to an existing session
|
|
42
|
+
/sessions — List your sessions
|
|
43
|
+
/current — Show current session
|
|
44
|
+
/fork — Fork current session into a new one
|
|
45
|
+
/help — Show this help`;
|
|
46
|
+
async function handleCommand(cmd, msg, deps) {
|
|
47
|
+
const key = buildSessionKey(msg.channel, msg.peerId, msg.threadId);
|
|
48
|
+
const prefix = `${msg.channel}:${msg.peerId}`;
|
|
49
|
+
switch (cmd.name) {
|
|
50
|
+
case "new": {
|
|
51
|
+
const id = await deps.sessions.newSession(key, cmd.args || undefined);
|
|
52
|
+
return `Created new session: ${id}`;
|
|
53
|
+
}
|
|
54
|
+
case "switch": {
|
|
55
|
+
if (!cmd.args)
|
|
56
|
+
return "Usage: /switch <session-id>";
|
|
57
|
+
await deps.sessions.switchSession(key, cmd.args);
|
|
58
|
+
return `Switched to session: ${cmd.args}`;
|
|
59
|
+
}
|
|
60
|
+
case "sessions": {
|
|
61
|
+
const list = await deps.sessions.listSessions(prefix);
|
|
62
|
+
if (list.length === 0)
|
|
63
|
+
return "No sessions found.";
|
|
64
|
+
return list
|
|
65
|
+
.map((s) => {
|
|
66
|
+
const marker = s.active ? " (active)" : "";
|
|
67
|
+
return `• ${s.id} — ${s.title}${marker}`;
|
|
68
|
+
})
|
|
69
|
+
.join("\n");
|
|
70
|
+
}
|
|
71
|
+
case "current": {
|
|
72
|
+
const id = deps.sessions.currentSession(key);
|
|
73
|
+
if (!id)
|
|
74
|
+
return "No active session. Send a message to create one.";
|
|
75
|
+
return `Current session: ${id}`;
|
|
76
|
+
}
|
|
77
|
+
case "fork": {
|
|
78
|
+
const current = deps.sessions.currentSession(key);
|
|
79
|
+
if (!current)
|
|
80
|
+
return "No active session to fork.";
|
|
81
|
+
const result = await deps.client.session.fork({
|
|
82
|
+
path: { id: current },
|
|
83
|
+
body: {},
|
|
84
|
+
});
|
|
85
|
+
if (!result.data)
|
|
86
|
+
return "Fork failed: no data returned.";
|
|
87
|
+
const forked = result.data.id;
|
|
88
|
+
await deps.sessions.switchSession(key, forked);
|
|
89
|
+
return `Forked into new session: ${forked}`;
|
|
90
|
+
}
|
|
91
|
+
case "help": {
|
|
92
|
+
return HELP_TEXT;
|
|
93
|
+
}
|
|
94
|
+
default: {
|
|
95
|
+
return `Unknown command: /${cmd.name}\n\n${HELP_TEXT}`;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function routeMessage(msg, deps) {
|
|
100
|
+
const adapter = deps.adapters.get(msg.channel);
|
|
101
|
+
if (!adapter) {
|
|
102
|
+
deps.logger.warn("router: no adapter for channel", { channel: msg.channel });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// Allowlist check
|
|
106
|
+
if (!checkAllowlist(deps.config, msg)) {
|
|
107
|
+
const behavior = rejection(deps.config, msg.channel);
|
|
108
|
+
if (behavior === "reject") {
|
|
109
|
+
await adapter.send(msg.peerId, { text: "This assistant is private." });
|
|
110
|
+
}
|
|
111
|
+
deps.logger.debug("router: message dropped (not in allowlist)", {
|
|
112
|
+
channel: msg.channel,
|
|
113
|
+
peerId: msg.peerId,
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// Command interception
|
|
118
|
+
const cmd = parseCommand(msg.text);
|
|
119
|
+
if (cmd) {
|
|
120
|
+
const reply = await handleCommand(cmd, msg, deps);
|
|
121
|
+
await adapter.send(msg.peerId, { text: reply, replyToId: msg.replyToId });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// Resolve or create session
|
|
125
|
+
const key = buildSessionKey(msg.channel, msg.peerId, msg.threadId);
|
|
126
|
+
const sessionId = await deps.sessions.resolveSession(key);
|
|
127
|
+
deps.logger.debug("router: prompting session", { sessionId, channel: msg.channel });
|
|
128
|
+
const controller = new AbortController();
|
|
129
|
+
const timer = setTimeout(() => controller.abort(), deps.timeoutMs);
|
|
130
|
+
let result;
|
|
131
|
+
try {
|
|
132
|
+
result = await deps.client.session.prompt({
|
|
133
|
+
path: { id: sessionId },
|
|
134
|
+
body: { parts: [{ type: "text", text: msg.text }] },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
if (controller.signal.aborted) {
|
|
140
|
+
deps.logger.warn("router: session prompt timed out", {
|
|
141
|
+
sessionId,
|
|
142
|
+
timeoutMs: deps.timeoutMs,
|
|
143
|
+
});
|
|
144
|
+
await adapter.send(msg.peerId, {
|
|
145
|
+
text: "Request timed out. The agent took too long to respond.",
|
|
146
|
+
replyToId: msg.replyToId,
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
if (!result.data) {
|
|
154
|
+
deps.logger.error("router: prompt returned no data", { sessionId });
|
|
155
|
+
await adapter.send(msg.peerId, { text: "Error: no response from agent." });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
// Extract text parts from response
|
|
159
|
+
const reply = extractText(result.data.parts);
|
|
160
|
+
if (!reply) {
|
|
161
|
+
deps.logger.warn("router: empty response from agent", { sessionId });
|
|
162
|
+
await adapter.send(msg.peerId, { text: "(empty response)" });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
await adapter.send(msg.peerId, { text: reply, replyToId: msg.replyToId });
|
|
166
|
+
}
|
|
167
|
+
export function createRouter(deps) {
|
|
168
|
+
async function handler(msg) {
|
|
169
|
+
try {
|
|
170
|
+
await routeMessage(msg, deps);
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
deps.logger.error("router: unhandled error", {
|
|
174
|
+
channel: msg.channel,
|
|
175
|
+
peerId: msg.peerId,
|
|
176
|
+
error: err instanceof Error ? err.message : String(err),
|
|
177
|
+
});
|
|
178
|
+
// Best-effort error reply
|
|
179
|
+
const adapter = deps.adapters.get(msg.channel);
|
|
180
|
+
if (adapter) {
|
|
181
|
+
await adapter
|
|
182
|
+
.send(msg.peerId, { text: "An internal error occurred. Please try again." })
|
|
183
|
+
.catch(() => { });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { handler };
|
|
188
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { App } from "@slack/bolt";
|
|
2
|
+
import { createReconnector } from "../utils/reconnect.js";
|
|
3
|
+
export function createSlackAdapter(config, logger) {
|
|
4
|
+
const app = new App({
|
|
5
|
+
token: config.botToken,
|
|
6
|
+
appToken: config.appToken,
|
|
7
|
+
socketMode: config.mode === "socket",
|
|
8
|
+
signingSecret: config.mode === "http" ? config.signingSecret : undefined,
|
|
9
|
+
});
|
|
10
|
+
let state = "disconnected";
|
|
11
|
+
let handler;
|
|
12
|
+
const reconnector = createReconnector({
|
|
13
|
+
name: "slack",
|
|
14
|
+
logger,
|
|
15
|
+
connect: async () => {
|
|
16
|
+
state = "connecting";
|
|
17
|
+
await app.start();
|
|
18
|
+
state = "connected";
|
|
19
|
+
reconnector.reset();
|
|
20
|
+
logger.info("slack: reconnected");
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
app.message(async ({ message, say }) => {
|
|
24
|
+
if (!handler)
|
|
25
|
+
return;
|
|
26
|
+
if (!("text" in message) || !message.text)
|
|
27
|
+
return;
|
|
28
|
+
if ("bot_id" in message && message.bot_id)
|
|
29
|
+
return;
|
|
30
|
+
if (!("user" in message) || !message.user)
|
|
31
|
+
return;
|
|
32
|
+
const peerId = message.user;
|
|
33
|
+
const channel = "channel" in message ? message.channel : undefined;
|
|
34
|
+
if (config.allowlist && config.allowlist.length > 0 && !config.allowlist.includes(peerId)) {
|
|
35
|
+
if (config.rejectionBehavior === "reject") {
|
|
36
|
+
await say("This assistant is private.");
|
|
37
|
+
}
|
|
38
|
+
logger.debug("slack: message dropped (not in allowlist)", { peerId });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const threadTs = "thread_ts" in message ? message.thread_ts : undefined;
|
|
42
|
+
const msg = {
|
|
43
|
+
channel: "slack",
|
|
44
|
+
peerId,
|
|
45
|
+
groupId: channel,
|
|
46
|
+
threadId: threadTs,
|
|
47
|
+
text: message.text,
|
|
48
|
+
raw: message,
|
|
49
|
+
};
|
|
50
|
+
await handler(msg);
|
|
51
|
+
});
|
|
52
|
+
app.error(async (error) => {
|
|
53
|
+
logger.error("slack: app error", {
|
|
54
|
+
error: error.message,
|
|
55
|
+
});
|
|
56
|
+
state = "error";
|
|
57
|
+
reconnector.attempt();
|
|
58
|
+
});
|
|
59
|
+
return {
|
|
60
|
+
id: "slack",
|
|
61
|
+
name: "Slack",
|
|
62
|
+
async start(h) {
|
|
63
|
+
handler = h;
|
|
64
|
+
state = "connecting";
|
|
65
|
+
logger.info("slack: starting app", { mode: config.mode });
|
|
66
|
+
await app.start();
|
|
67
|
+
state = "connected";
|
|
68
|
+
logger.info("slack: app connected");
|
|
69
|
+
},
|
|
70
|
+
async stop() {
|
|
71
|
+
reconnector.stop();
|
|
72
|
+
state = "disconnected";
|
|
73
|
+
await app.stop();
|
|
74
|
+
logger.info("slack: app stopped");
|
|
75
|
+
},
|
|
76
|
+
async send(peerId, message) {
|
|
77
|
+
await app.client.chat.postMessage({
|
|
78
|
+
channel: peerId,
|
|
79
|
+
text: message.text,
|
|
80
|
+
thread_ts: message.threadId,
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
status() {
|
|
84
|
+
return state;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|