@inceptionstack/roundhouse 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 +164 -0
- package/architecture.md +214 -0
- package/bin/roundhouse.mjs +5 -0
- package/package.json +48 -0
- package/src/agents/pi.ts +154 -0
- package/src/agents/registry.ts +25 -0
- package/src/cli/cli.ts +425 -0
- package/src/gateway.ts +129 -0
- package/src/index.ts +121 -0
- package/src/router.ts +24 -0
- package/src/types.ts +56 -0
- package/src/util.ts +87 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 InceptionStack
|
|
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,164 @@
|
|
|
1
|
+
# roundhouse
|
|
2
|
+
|
|
3
|
+
A multi-platform chat gateway that routes messages through a single configured AI agent.
|
|
4
|
+
|
|
5
|
+
One gateway instance = one agent target (pi, Kiro, etc.), configured at install time.
|
|
6
|
+
Multiple chat inputs (Telegram, Slack, Discord via [Vercel Chat SDK](https://chat-sdk.dev)) all feed into that same agent.
|
|
7
|
+
|
|
8
|
+
## Architecture
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
┌─────────────────────────────────────────────────┐
|
|
12
|
+
│ Vercel Chat SDK │
|
|
13
|
+
│ ┌───────────┐ ┌───────────┐ ┌──────────────┐ │
|
|
14
|
+
│ │ Telegram │ │ Slack │ │ Discord │ │
|
|
15
|
+
│ └─────┬─────┘ └─────┬─────┘ └──────┬───────┘ │
|
|
16
|
+
│ └──────────────┼──────────────┘ │
|
|
17
|
+
└───────────────────────┬──────────────────────────┘
|
|
18
|
+
│
|
|
19
|
+
┌─────────┴──────────┐
|
|
20
|
+
│ Gateway │
|
|
21
|
+
│ │
|
|
22
|
+
│ • user allowlist │
|
|
23
|
+
│ • message split │
|
|
24
|
+
│ • typing indicator│
|
|
25
|
+
└─────────┬──────────┘
|
|
26
|
+
│
|
|
27
|
+
┌─────────┴──────────┐
|
|
28
|
+
│ AgentRouter │
|
|
29
|
+
│ │
|
|
30
|
+
│ today: single │
|
|
31
|
+
│ agent pass-through│
|
|
32
|
+
│ │
|
|
33
|
+
│ future: per-thread│
|
|
34
|
+
│ multi-agent, etc. │
|
|
35
|
+
└─────────┬──────────┘
|
|
36
|
+
│
|
|
37
|
+
┌─────────┴──────────┐
|
|
38
|
+
│ AgentAdapter │ ← ONE, configured at install time
|
|
39
|
+
│ │
|
|
40
|
+
│ e.g. Pi agent on │
|
|
41
|
+
│ THIS machine with │
|
|
42
|
+
│ persistent │
|
|
43
|
+
│ sessions on disk │
|
|
44
|
+
└────────────────────┘
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Design decisions
|
|
48
|
+
|
|
49
|
+
- **One gateway = one agent target.** The `agent` block in config picks the type and its settings. All chat inputs route to this single agent instance.
|
|
50
|
+
- **Multiple chat inputs into the same agent.** Telegram and Slack messages go to the same agent, each on their own session thread (`telegram:<id>`, `slack:<id>`).
|
|
51
|
+
- **AgentRouter is a seam.** Today it's `SingleAgentRouter` (hardcoded pass-through). The interface exists so we can later swap in per-thread routing, multi-agent, or load-balanced strategies without changing the gateway or adapters.
|
|
52
|
+
- **AgentAdapter is the only abstraction we own.** The chat side is Vercel Chat SDK — we don't wrap it. The agent side is our `AgentAdapter` interface: `prompt(threadId, text) → AgentResponse`.
|
|
53
|
+
- **Config-driven.** `gateway.config.json` (or `--config` flag, or env vars) determines everything at startup. No runtime reconfiguration.
|
|
54
|
+
- **Persistent sessions.** Each thread gets its own session file on disk. Gateway restarts resume from the same file. Pi CLI can join the same session.
|
|
55
|
+
|
|
56
|
+
## Quick start
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npm install
|
|
60
|
+
export TELEGRAM_BOT_TOKEN="your-token"
|
|
61
|
+
export ALLOWED_USERS="your_telegram_username"
|
|
62
|
+
npm start
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Config
|
|
66
|
+
|
|
67
|
+
Place `gateway.config.json` in the project root, or use `--config path`:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"agent": {
|
|
72
|
+
"type": "pi",
|
|
73
|
+
"cwd": "/home/you/project"
|
|
74
|
+
},
|
|
75
|
+
"chat": {
|
|
76
|
+
"botUsername": "my_bot",
|
|
77
|
+
"allowedUsers": ["your_username"],
|
|
78
|
+
"adapters": {
|
|
79
|
+
"telegram": { "mode": "polling" }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Without a config file, defaults are used with env vars (`TELEGRAM_BOT_TOKEN`, `BOT_USERNAME`, `ALLOWED_USERS`).
|
|
86
|
+
|
|
87
|
+
### Config reference
|
|
88
|
+
|
|
89
|
+
| Field | Description |
|
|
90
|
+
|-------|-------------|
|
|
91
|
+
| `agent.type` | Agent backend: `"pi"` (more coming) |
|
|
92
|
+
| `agent.cwd` | Working directory for the agent |
|
|
93
|
+
| `agent.sessionDir` | Override session storage path |
|
|
94
|
+
| `chat.botUsername` | Bot display name for Chat SDK |
|
|
95
|
+
| `chat.allowedUsers` | Telegram usernames / user IDs allowed (empty = allow all) |
|
|
96
|
+
| `chat.adapters.telegram` | `{ "mode": "polling" \| "webhook" \| "auto" }` |
|
|
97
|
+
|
|
98
|
+
Secrets stay in env vars: `TELEGRAM_BOT_TOKEN`, `ANTHROPIC_API_KEY`, etc.
|
|
99
|
+
|
|
100
|
+
## Joining a session from pi CLI
|
|
101
|
+
|
|
102
|
+
Sessions are stored at `~/.pi/agent/gateway-sessions/<thread>/`. Resume from CLI:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
pi --resume ~/.pi/agent/gateway-sessions/<thread_dir>/<session>.jsonl
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Messages from Telegram/Slack and from the CLI share the same context.
|
|
109
|
+
|
|
110
|
+
## Adding a new agent backend
|
|
111
|
+
|
|
112
|
+
1. Create `src/agents/kiro.ts` implementing `AgentAdapter`
|
|
113
|
+
2. Register in `src/agents/registry.ts`: `registry.set("kiro", createKiroAgentAdapter)`
|
|
114
|
+
3. Set `"agent": { "type": "kiro" }` in config
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
// src/agents/kiro.ts
|
|
118
|
+
import type { AgentAdapter, AgentAdapterFactory } from "../types";
|
|
119
|
+
|
|
120
|
+
export const createKiroAgentAdapter: AgentAdapterFactory = (config) => {
|
|
121
|
+
return {
|
|
122
|
+
name: "kiro",
|
|
123
|
+
async prompt(threadId, text) {
|
|
124
|
+
// your implementation
|
|
125
|
+
return { text: "response" };
|
|
126
|
+
},
|
|
127
|
+
async dispose() {},
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Adding a new chat platform
|
|
133
|
+
|
|
134
|
+
Add the Chat SDK adapter package and wire it in `gateway.ts`:
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
// In buildChatAdapters():
|
|
138
|
+
if (config.slack) {
|
|
139
|
+
const { createSlackAdapter } = await import("@chat-adapter/slack");
|
|
140
|
+
adapters.slack = createSlackAdapter();
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
No other changes needed — the gateway's unified handler covers all platforms.
|
|
145
|
+
|
|
146
|
+
## Files
|
|
147
|
+
|
|
148
|
+
| File | Purpose |
|
|
149
|
+
|------|---------|
|
|
150
|
+
| `src/index.ts` | Entry point, config loading, startup |
|
|
151
|
+
| `src/gateway.ts` | Owns Chat SDK, wires events → router → agent |
|
|
152
|
+
| `src/router.ts` | `AgentRouter` interface + `SingleAgentRouter` |
|
|
153
|
+
| `src/types.ts` | Core interfaces: `AgentAdapter`, `AgentRouter`, `GatewayConfig` |
|
|
154
|
+
| `src/util.ts` | Pure utilities: `splitMessage`, `isAllowed`, `threadIdToDir` |
|
|
155
|
+
| `src/agents/pi.ts` | Pi agent adapter (persistent sessions via pi SDK) |
|
|
156
|
+
| `src/agents/registry.ts` | Agent type → factory registry |
|
|
157
|
+
| `test/` | Unit tests (vitest, 32 passing) |
|
|
158
|
+
|
|
159
|
+
## Testing
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
npm test # run once
|
|
163
|
+
npm run test:watch # watch mode
|
|
164
|
+
```
|
package/architecture.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Roundhouse is a gateway that sits between **chat platforms** (Telegram, Slack, Discord) and a **single AI agent backend** (pi, Kiro, etc.).
|
|
6
|
+
|
|
7
|
+
One gateway instance is configured for exactly one agent target at install time. Multiple chat platforms can feed into that same agent simultaneously.
|
|
8
|
+
|
|
9
|
+
## System diagram
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
┌─────────────────────────────────────────────────────┐
|
|
13
|
+
│ Vercel Chat SDK │
|
|
14
|
+
│ │
|
|
15
|
+
│ ┌────────────┐ ┌────────────┐ ┌─────────────┐ │
|
|
16
|
+
Telegram users ──▶│ │ Telegram │ │ Slack │ │ Discord │ │◀── Discord users
|
|
17
|
+
│ │ Adapter │ │ Adapter │ │ Adapter │ │
|
|
18
|
+
Slack users ─────▶│ └─────┬──────┘ └─────┬──────┘ └──────┬──────┘ │
|
|
19
|
+
│ │ │ │ │
|
|
20
|
+
│ └───────────────┼────────────────┘ │
|
|
21
|
+
│ │ │
|
|
22
|
+
│ onDirectMessage / onNewMention / │
|
|
23
|
+
│ onSubscribedMessage │
|
|
24
|
+
└─────────────────────────┬──────────────────────────┘
|
|
25
|
+
│
|
|
26
|
+
▼
|
|
27
|
+
┌───────────────────────┐
|
|
28
|
+
│ Gateway │
|
|
29
|
+
│ │
|
|
30
|
+
│ • User allowlist │
|
|
31
|
+
│ • Message splitting │
|
|
32
|
+
│ • Typing indicators │
|
|
33
|
+
│ • Error handling │
|
|
34
|
+
└───────────┬────────────┘
|
|
35
|
+
│
|
|
36
|
+
▼
|
|
37
|
+
┌───────────────────────┐
|
|
38
|
+
│ AgentRouter │
|
|
39
|
+
│ │
|
|
40
|
+
│ SingleAgentRouter │
|
|
41
|
+
│ (pass-through today) │
|
|
42
|
+
│ │
|
|
43
|
+
│ Future: │
|
|
44
|
+
│ • MultiAgentRouter │
|
|
45
|
+
│ • UserChoiceRouter │
|
|
46
|
+
│ • FallbackRouter │
|
|
47
|
+
└───────────┬────────────┘
|
|
48
|
+
│
|
|
49
|
+
▼
|
|
50
|
+
┌───────────────────────┐
|
|
51
|
+
│ AgentAdapter │
|
|
52
|
+
│ │
|
|
53
|
+
│ Configured at install │
|
|
54
|
+
│ time via config file │
|
|
55
|
+
│ │
|
|
56
|
+
│ ┌─────────────────┐ │
|
|
57
|
+
│ │ Pi Agent │ │
|
|
58
|
+
│ │ │ │
|
|
59
|
+
│ │ • pi SDK │ │
|
|
60
|
+
│ │ • persistent │ │
|
|
61
|
+
│ │ .jsonl │ │
|
|
62
|
+
│ │ sessions │ │
|
|
63
|
+
│ │ • per-thread │ │
|
|
64
|
+
│ │ isolation │ │
|
|
65
|
+
│ └─────────────────┘ │
|
|
66
|
+
│ │
|
|
67
|
+
│ (or Kiro, Raw LLM, │
|
|
68
|
+
│ custom agent, etc.) │
|
|
69
|
+
└────────────────────────┘
|
|
70
|
+
│
|
|
71
|
+
▼
|
|
72
|
+
┌────────────────────────┐
|
|
73
|
+
│ Session storage │
|
|
74
|
+
│ │
|
|
75
|
+
│ ~/.pi/agent/ │
|
|
76
|
+
│ gateway-sessions/ │
|
|
77
|
+
│ telegram_c<id>/ │
|
|
78
|
+
│ <session>.jsonl │
|
|
79
|
+
│ slack_c<id>/ │
|
|
80
|
+
│ <session>.jsonl │
|
|
81
|
+
└────────────────────────┘
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Data flow
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
User sends "list files" on Telegram
|
|
88
|
+
│
|
|
89
|
+
▼
|
|
90
|
+
┌─ Vercel Chat SDK ────────────────────────────────────────────┐
|
|
91
|
+
│ Telegram adapter receives update via polling │
|
|
92
|
+
│ Normalizes to: { thread.id, message.text, message.author } │
|
|
93
|
+
│ Fires onDirectMessage(thread, message) │
|
|
94
|
+
└──────────────────────────────┬───────────────────────────────┘
|
|
95
|
+
│
|
|
96
|
+
▼
|
|
97
|
+
┌─ Gateway ────────────────────────────────────────────────────┐
|
|
98
|
+
│ 1. Check isAllowed(message.author, allowedUsers) │
|
|
99
|
+
│ 2. Resolve agent via router.resolve(thread.id) │
|
|
100
|
+
│ 3. thread.startTyping() │
|
|
101
|
+
│ 4. agent.prompt(thread.id, "list files") │
|
|
102
|
+
│ └─▶ Pi SDK creates/resumes session │
|
|
103
|
+
│ └─▶ LLM processes, tools execute │
|
|
104
|
+
│ └─▶ Returns AgentResponse { text: "..." } │
|
|
105
|
+
│ 5. splitMessage(response.text, 4000) │
|
|
106
|
+
│ 6. thread.post(chunk) for each chunk │
|
|
107
|
+
└──────────────────────────────────────────────────────────────┘
|
|
108
|
+
│
|
|
109
|
+
▼
|
|
110
|
+
User receives reply on Telegram
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Key interfaces
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
interface AgentAdapter {
|
|
117
|
+
name: string;
|
|
118
|
+
prompt(threadId: string, text: string): Promise<AgentResponse>;
|
|
119
|
+
dispose(): Promise<void>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface AgentResponse {
|
|
123
|
+
text: string;
|
|
124
|
+
metadata?: Record<string, unknown>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface AgentRouter {
|
|
128
|
+
resolve(threadId: string): AgentAdapter;
|
|
129
|
+
dispose(): Promise<void>;
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Config model
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
gateway.config.json
|
|
137
|
+
├── agent # Exactly ONE agent target
|
|
138
|
+
│ ├── type: "pi" # Selects factory from registry
|
|
139
|
+
│ ├── cwd: "/home/user" # Agent working directory
|
|
140
|
+
│ └── sessionDir: "..." # Override session storage
|
|
141
|
+
│
|
|
142
|
+
└── chat # Multiple chat inputs
|
|
143
|
+
├── botUsername: "my_bot"
|
|
144
|
+
├── allowedUsers: [...] # Auth filter (userName or userId)
|
|
145
|
+
└── adapters
|
|
146
|
+
├── telegram: { mode: "polling" }
|
|
147
|
+
├── slack: { ... } # (future)
|
|
148
|
+
└── discord: { ... } # (future)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Secrets (`TELEGRAM_BOT_TOKEN`, `ANTHROPIC_API_KEY`) are always env vars, never in config.
|
|
152
|
+
|
|
153
|
+
## Startup sequence
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
1. Load config (--config flag → gateway.config.json → env var defaults)
|
|
157
|
+
2. Look up agent.type in registry → get factory function
|
|
158
|
+
3. factory(agentConfig) → AgentAdapter instance
|
|
159
|
+
4. Wrap in SingleAgentRouter
|
|
160
|
+
5. Create Gateway(router, config)
|
|
161
|
+
6. gateway.start():
|
|
162
|
+
a. Build Chat SDK adapters from config (lazy import)
|
|
163
|
+
b. Create Chat instance with all adapters
|
|
164
|
+
c. Wire onDirectMessage / onNewMention / onSubscribedMessage → handle()
|
|
165
|
+
d. chat.initialize() — starts polling / webhooks
|
|
166
|
+
7. Running. Ctrl+C → gateway.stop() → router.dispose() → agent.dispose()
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Session threading
|
|
170
|
+
|
|
171
|
+
Each chat platform thread gets its own agent session:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
Telegram DM with Alice → threadId = "telegram:123456789" → session A
|
|
175
|
+
Slack DM with Alice → threadId = "slack:U12345" → session B
|
|
176
|
+
Telegram group mention → threadId = "telegram:-100123456" → session C
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
These are **separate sessions** by design. Cross-platform session unification (mapping multiple platform identities to one session) is a future capability.
|
|
180
|
+
|
|
181
|
+
Sessions persist as `.jsonl` files. The gateway resumes them on restart. Pi CLI can join any session with:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
pi --resume ~/.pi/agent/gateway-sessions/<thread_dir>/<session>.jsonl
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Router extensibility
|
|
188
|
+
|
|
189
|
+
The `AgentRouter` interface is a seam for future multi-agent routing:
|
|
190
|
+
|
|
191
|
+
| Router | Behavior |
|
|
192
|
+
|--------|----------|
|
|
193
|
+
| `SingleAgentRouter` | All threads → one agent (current) |
|
|
194
|
+
| `MultiAgentRouter` | Map thread prefixes to different agents |
|
|
195
|
+
| `UserChoiceRouter` | User sends `/agent kiro` to switch |
|
|
196
|
+
| `FallbackRouter` | Try primary agent, fall back to secondary |
|
|
197
|
+
| `RoundRobinRouter` | Load balance across agent instances |
|
|
198
|
+
|
|
199
|
+
The gateway and agent adapters don't change — only the router.
|
|
200
|
+
|
|
201
|
+
## Module dependency graph
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
index.ts
|
|
205
|
+
├── agents/registry.ts
|
|
206
|
+
│ └── agents/pi.ts
|
|
207
|
+
│ └── util.ts (threadIdToDir)
|
|
208
|
+
├── router.ts
|
|
209
|
+
├── gateway.ts
|
|
210
|
+
│ └── util.ts (splitMessage, isAllowed)
|
|
211
|
+
└── types.ts (shared interfaces)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
No circular dependencies. `util.ts` and `types.ts` are leaf modules.
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@inceptionstack/roundhouse",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Multi-platform chat gateway that routes messages through a configured AI agent",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/inceptionstack/roundhouse.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"chat",
|
|
13
|
+
"gateway",
|
|
14
|
+
"telegram",
|
|
15
|
+
"slack",
|
|
16
|
+
"discord",
|
|
17
|
+
"ai",
|
|
18
|
+
"agent",
|
|
19
|
+
"pi",
|
|
20
|
+
"bot"
|
|
21
|
+
],
|
|
22
|
+
"bin": {
|
|
23
|
+
"roundhouse": "bin/roundhouse.mjs"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"start": "tsx src/index.ts",
|
|
27
|
+
"dev": "tsx watch src/index.ts",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"src/",
|
|
33
|
+
"bin/",
|
|
34
|
+
"LICENSE",
|
|
35
|
+
"README.md",
|
|
36
|
+
"architecture.md"
|
|
37
|
+
],
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@chat-adapter/state-memory": "latest",
|
|
40
|
+
"@chat-adapter/telegram": "latest",
|
|
41
|
+
"@mariozechner/pi-coding-agent": "latest",
|
|
42
|
+
"chat": "latest",
|
|
43
|
+
"tsx": "^4.0.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"vitest": "^4.1.5"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/agents/pi.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agents/pi.ts — Pi agent adapter
|
|
3
|
+
*
|
|
4
|
+
* Wraps pi's SDK (createAgentSession) as an AgentAdapter.
|
|
5
|
+
* One persistent session per thread, stored at:
|
|
6
|
+
* ~/.pi/agent/gateway-sessions/<thread_id>/<session>.jsonl
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdir } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
AuthStorage,
|
|
15
|
+
createAgentSession,
|
|
16
|
+
ModelRegistry,
|
|
17
|
+
SessionManager,
|
|
18
|
+
type AgentSession,
|
|
19
|
+
type AgentSessionEvent,
|
|
20
|
+
} from "@mariozechner/pi-coding-agent";
|
|
21
|
+
|
|
22
|
+
import type { AgentAdapter, AgentAdapterFactory, AgentResponse } from "../types";
|
|
23
|
+
import { threadIdToDir } from "../util";
|
|
24
|
+
|
|
25
|
+
interface SessionEntry {
|
|
26
|
+
session: AgentSession;
|
|
27
|
+
lastUsed: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_SESSIONS_DIR = join(
|
|
31
|
+
homedir(),
|
|
32
|
+
".pi",
|
|
33
|
+
"agent",
|
|
34
|
+
"gateway-sessions"
|
|
35
|
+
);
|
|
36
|
+
const DEFAULT_MAX_IDLE_MS = 30 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
39
|
+
const cwd = (config.cwd as string) ?? process.cwd();
|
|
40
|
+
const sessionsDir =
|
|
41
|
+
(config.sessionDir as string) ?? DEFAULT_SESSIONS_DIR;
|
|
42
|
+
const maxIdleMs =
|
|
43
|
+
(config.maxIdleMs as number) ?? DEFAULT_MAX_IDLE_MS;
|
|
44
|
+
|
|
45
|
+
const authStorage = AuthStorage.create();
|
|
46
|
+
const modelRegistry = ModelRegistry.create(authStorage);
|
|
47
|
+
const sessions = new Map<string, SessionEntry>();
|
|
48
|
+
// Track in-flight session creation to prevent races
|
|
49
|
+
const creating = new Map<string, Promise<SessionEntry>>();
|
|
50
|
+
let reapInterval: ReturnType<typeof setInterval> | undefined;
|
|
51
|
+
|
|
52
|
+
async function createSession(threadId: string): Promise<SessionEntry> {
|
|
53
|
+
const threadDir = join(sessionsDir, threadIdToDir(threadId));
|
|
54
|
+
await mkdir(threadDir, { recursive: true });
|
|
55
|
+
|
|
56
|
+
let sessionManager: InstanceType<typeof SessionManager>;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
sessionManager = SessionManager.continueRecent(cwd, threadDir);
|
|
60
|
+
console.log(
|
|
61
|
+
`[pi-agent] resuming session for ${threadId}: ${sessionManager.getSessionFile()}`
|
|
62
|
+
);
|
|
63
|
+
} catch {
|
|
64
|
+
sessionManager = SessionManager.create(cwd, threadDir);
|
|
65
|
+
console.log(
|
|
66
|
+
`[pi-agent] new session for ${threadId}: ${sessionManager.getSessionFile()}`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const result = await createAgentSession({
|
|
71
|
+
cwd,
|
|
72
|
+
sessionManager,
|
|
73
|
+
authStorage,
|
|
74
|
+
modelRegistry,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (result.modelFallbackMessage) {
|
|
78
|
+
console.log(`[pi-agent] model fallback: ${result.modelFallbackMessage}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const entry: SessionEntry = { session: result.session, lastUsed: Date.now() };
|
|
82
|
+
sessions.set(threadId, entry);
|
|
83
|
+
return entry;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function getOrCreate(threadId: string): Promise<SessionEntry> {
|
|
87
|
+
// Fast path: already created
|
|
88
|
+
const existing = sessions.get(threadId);
|
|
89
|
+
if (existing) return existing;
|
|
90
|
+
|
|
91
|
+
// Prevent concurrent creation for the same threadId
|
|
92
|
+
let pending = creating.get(threadId);
|
|
93
|
+
if (pending) return pending;
|
|
94
|
+
|
|
95
|
+
pending = createSession(threadId).finally(() => {
|
|
96
|
+
creating.delete(threadId);
|
|
97
|
+
});
|
|
98
|
+
creating.set(threadId, pending);
|
|
99
|
+
return pending;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function reap() {
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
for (const [id, entry] of sessions) {
|
|
105
|
+
if (now - entry.lastUsed > maxIdleMs) {
|
|
106
|
+
entry.session.dispose();
|
|
107
|
+
sessions.delete(id);
|
|
108
|
+
console.log(`[pi-agent] reaped idle handle for ${id}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Start reaper (unref so it doesn't prevent Node from exiting)
|
|
114
|
+
reapInterval = setInterval(reap, 60_000);
|
|
115
|
+
reapInterval.unref();
|
|
116
|
+
|
|
117
|
+
const adapter: AgentAdapter = {
|
|
118
|
+
name: "pi",
|
|
119
|
+
|
|
120
|
+
async prompt(threadId: string, text: string): Promise<AgentResponse> {
|
|
121
|
+
const entry = await getOrCreate(threadId);
|
|
122
|
+
entry.lastUsed = Date.now();
|
|
123
|
+
|
|
124
|
+
let fullText = "";
|
|
125
|
+
const unsub = entry.session.subscribe((event: AgentSessionEvent) => {
|
|
126
|
+
if (
|
|
127
|
+
event.type === "message_update" &&
|
|
128
|
+
event.assistantMessageEvent.type === "text_delta"
|
|
129
|
+
) {
|
|
130
|
+
fullText += event.assistantMessageEvent.delta;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
await entry.session.prompt(text);
|
|
136
|
+
} finally {
|
|
137
|
+
unsub();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { text: fullText };
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
async dispose(): Promise<void> {
|
|
144
|
+
if (reapInterval) clearInterval(reapInterval);
|
|
145
|
+
for (const [, entry] of sessions) {
|
|
146
|
+
entry.session.dispose();
|
|
147
|
+
}
|
|
148
|
+
sessions.clear();
|
|
149
|
+
creating.clear();
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return adapter;
|
|
154
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agents/registry.ts — Agent adapter registry
|
|
3
|
+
*
|
|
4
|
+
* Maps agent type names to their factory functions.
|
|
5
|
+
* Add new agents here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AgentAdapterFactory } from "../types";
|
|
9
|
+
import { createPiAgentAdapter } from "./pi";
|
|
10
|
+
|
|
11
|
+
const registry = new Map<string, AgentAdapterFactory>();
|
|
12
|
+
|
|
13
|
+
registry.set("pi", createPiAgentAdapter);
|
|
14
|
+
// registry.set("kiro", createKiroAgentAdapter);
|
|
15
|
+
|
|
16
|
+
export function getAgentFactory(type: string): AgentAdapterFactory {
|
|
17
|
+
const factory = registry.get(type);
|
|
18
|
+
if (!factory) {
|
|
19
|
+
const available = [...registry.keys()].join(", ");
|
|
20
|
+
throw new Error(
|
|
21
|
+
`Unknown agent type "${type}". Available: ${available}`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return factory;
|
|
25
|
+
}
|