@tspappsen/elamax 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +308 -0
- package/dist/api/server.js +297 -0
- package/dist/cli.js +105 -0
- package/dist/config.js +96 -0
- package/dist/copilot/classifier.js +72 -0
- package/dist/copilot/client.js +30 -0
- package/dist/copilot/mcp-config.js +22 -0
- package/dist/copilot/orchestrator.js +459 -0
- package/dist/copilot/router.js +147 -0
- package/dist/copilot/skills.js +125 -0
- package/dist/copilot/system-message.js +185 -0
- package/dist/copilot/tools.js +486 -0
- package/dist/copilot/watchdog-tools.js +312 -0
- package/dist/copilot/workspace-instructions.js +100 -0
- package/dist/daemon.js +237 -0
- package/dist/diagnosis.js +79 -0
- package/dist/discord/bot.js +505 -0
- package/dist/discord/formatter.js +29 -0
- package/dist/paths.js +37 -0
- package/dist/setup.js +476 -0
- package/dist/store/db.js +173 -0
- package/dist/telegram/bot.js +344 -0
- package/dist/telegram/formatter.js +96 -0
- package/dist/tui/index.js +1026 -0
- package/dist/update.js +72 -0
- package/dist/utils/parseJSON.js +71 -0
- package/package.json +61 -0
- package/skills/.gitkeep +0 -0
- package/skills/find-skills/SKILL.md +161 -0
- package/skills/find-skills/_meta.json +4 -0
- package/templates/instructions/AGENTS.md +18 -0
- package/templates/instructions/TOOLS.md +12 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ELA
|
|
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,308 @@
|
|
|
1
|
+
# Max
|
|
2
|
+
|
|
3
|
+
AI orchestrator powered by [Copilot SDK](https://github.com/github/copilot-sdk) — control multiple Copilot CLI sessions from Telegram or a local terminal.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
curl -fsSL https://raw.githubusercontent.com/burkeholland/max/main/install.sh | bash
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or install directly with npm:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g elamax
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
### 1. Run setup
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
max setup
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
This creates `~/.max/` and walks you through configuration (Telegram bot token, etc.). Telegram is optional — you can use Max with just the terminal UI.
|
|
26
|
+
|
|
27
|
+
If you're running Max from a local clone instead of a global install, use:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm run build
|
|
31
|
+
node dist/cli.js setup
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
You can also run setup directly from source without building first:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npx tsx src/setup.ts
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### 2. Make sure Copilot CLI is authenticated
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
copilot login
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### 3. Start Max
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
max start
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
From a local clone, use:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
node dist/cli.js start
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 4. Connect via terminal
|
|
59
|
+
|
|
60
|
+
In a separate terminal:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
max tui
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
From a local clone, use:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm run tui
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### 5. Talk to Max
|
|
73
|
+
|
|
74
|
+
From Telegram or the TUI, just send natural language:
|
|
75
|
+
|
|
76
|
+
- "Start working on the auth bug in ~/dev/myapp"
|
|
77
|
+
- "What sessions are running?"
|
|
78
|
+
- "Check on the api-tests session"
|
|
79
|
+
- "Kill the auth-fix session"
|
|
80
|
+
- "Restart yourself"
|
|
81
|
+
- "What's the capital of France?"
|
|
82
|
+
|
|
83
|
+
## Commands
|
|
84
|
+
|
|
85
|
+
| Command | Description |
|
|
86
|
+
|---------|-------------|
|
|
87
|
+
| `max start` | Start the Max daemon |
|
|
88
|
+
| `max tui` | Connect to the daemon via terminal |
|
|
89
|
+
| `max setup` | Interactive first-run configuration |
|
|
90
|
+
| `max update` | Check for and install updates |
|
|
91
|
+
| `max help` | Show available commands |
|
|
92
|
+
|
|
93
|
+
### Flags
|
|
94
|
+
|
|
95
|
+
| Flag | Description |
|
|
96
|
+
|------|-------------|
|
|
97
|
+
| `--profile <name>` | Run as a named profile (e.g. `--profile watchdog`) |
|
|
98
|
+
| `--self-edit` | Allow Max to modify his own source code (use with `max start`) |
|
|
99
|
+
|
|
100
|
+
### TUI commands
|
|
101
|
+
|
|
102
|
+
| Command | Description |
|
|
103
|
+
|---------|-------------|
|
|
104
|
+
| `/model [name]` | Show or switch the current model |
|
|
105
|
+
| `/memory` | Show stored memories |
|
|
106
|
+
| `/skills` | List installed skills |
|
|
107
|
+
| `/workers` | List active worker sessions |
|
|
108
|
+
| `/copy` | Copy last response to clipboard |
|
|
109
|
+
| `/status` | Daemon health check |
|
|
110
|
+
| `/restart` | Restart the daemon (under pm2, exit and let pm2 restart it) |
|
|
111
|
+
| `/cancel` | Cancel the current in-flight message |
|
|
112
|
+
| `/clear` | Clear the screen |
|
|
113
|
+
| `/help` | Show help |
|
|
114
|
+
| `/quit` | Exit the TUI |
|
|
115
|
+
| `Escape` | Cancel a running response |
|
|
116
|
+
|
|
117
|
+
### Restarting Max
|
|
118
|
+
|
|
119
|
+
You can restart Max in any of these ways:
|
|
120
|
+
|
|
121
|
+
- **TUI**: run `/restart`
|
|
122
|
+
- **Telegram**: send `/restart`
|
|
123
|
+
- **Discord**: use `/restart`
|
|
124
|
+
- **Natural language**: ask Max something like "restart yourself" or "restart and come back online"
|
|
125
|
+
|
|
126
|
+
If Max is running under [pm2](https://pm2.keymetrics.io/), a restart request does **not** spawn a replacement process directly.
|
|
127
|
+
Instead, Max detects pm2 by checking `PM2_HOME` or `pm_id`, logs a message, and exits cleanly.
|
|
128
|
+
pm2 then starts the daemon again.
|
|
129
|
+
|
|
130
|
+
So the behavior is:
|
|
131
|
+
|
|
132
|
+
- **pm2-managed** → exit only, let pm2 restart
|
|
133
|
+
- **non-pm2** → spawn a detached replacement process, then exit
|
|
134
|
+
|
|
135
|
+
## How it Works
|
|
136
|
+
|
|
137
|
+
Max runs a persistent **orchestrator Copilot session** — an always-on AI brain that receives your messages and decides how to handle them. For coding tasks, it spawns **worker Copilot sessions** in specific directories. For simple questions, it answers directly.
|
|
138
|
+
|
|
139
|
+
You can talk to Max from:
|
|
140
|
+
- **Telegram** — remote access from your phone (authenticated by user ID)
|
|
141
|
+
- **TUI** — local terminal client (no auth needed)
|
|
142
|
+
|
|
143
|
+
## Architecture
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
Telegram ──→ Max Daemon ←── TUI
|
|
147
|
+
│
|
|
148
|
+
Orchestrator Session (Copilot SDK)
|
|
149
|
+
│
|
|
150
|
+
┌─────────┼─────────┐
|
|
151
|
+
Worker 1 Worker 2 Worker N
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
- **Daemon** (`max start`) — persistent service running Copilot SDK + Telegram bot + HTTP API
|
|
155
|
+
- **TUI** (`max tui`) — lightweight terminal client connecting to the daemon
|
|
156
|
+
- **Orchestrator** — long-running Copilot session with custom tools for session management
|
|
157
|
+
- **Workers** — child Copilot sessions for specific coding tasks
|
|
158
|
+
|
|
159
|
+
## Development
|
|
160
|
+
|
|
161
|
+
When developing locally, `npm run dev` starts the daemon in watch mode, but it does **not** register the `max` command on your system. Use the built CLI directly:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
# One-time install
|
|
165
|
+
git clone https://github.com/burkeholland/max.git
|
|
166
|
+
cd max
|
|
167
|
+
npm install
|
|
168
|
+
|
|
169
|
+
# Run setup locally
|
|
170
|
+
npm run build
|
|
171
|
+
node dist/cli.js setup
|
|
172
|
+
|
|
173
|
+
# Start the daemon in watch mode
|
|
174
|
+
npm run dev
|
|
175
|
+
|
|
176
|
+
# In a second terminal, connect the TUI
|
|
177
|
+
npm run tui
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
If you want the `max` command while developing locally, link the package after building:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
npm run build
|
|
184
|
+
npm link
|
|
185
|
+
max setup
|
|
186
|
+
max start
|
|
187
|
+
max tui
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
On Windows, prefer `node dist/cli.js setup` for local development rather than the shell installer.
|
|
191
|
+
|
|
192
|
+
### Running as a background service with pm2
|
|
193
|
+
|
|
194
|
+
To keep Max running even after you close the terminal, use [pm2](https://pm2.keymetrics.io/):
|
|
195
|
+
|
|
196
|
+
When Max restarts under pm2, it no longer spawns a second process itself.
|
|
197
|
+
It checks for `PM2_HOME` or `pm_id`, exits cleanly, and lets pm2 bring it back.
|
|
198
|
+
This avoids duplicate daemon instances during restart.
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
# Install pm2 globally
|
|
202
|
+
npm install -g pm2
|
|
203
|
+
|
|
204
|
+
# Start Max with pm2
|
|
205
|
+
pm2 start "npm run dev" --name max
|
|
206
|
+
|
|
207
|
+
# Check status
|
|
208
|
+
pm2 list
|
|
209
|
+
|
|
210
|
+
# Stop it
|
|
211
|
+
pm2 stop max
|
|
212
|
+
|
|
213
|
+
# Restart it manually
|
|
214
|
+
pm2 restart max
|
|
215
|
+
|
|
216
|
+
# Auto-start on login (macOS uses launchd)
|
|
217
|
+
pm2 startup # follow the printed instructions
|
|
218
|
+
pm2 save
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
pm2 works on macOS, Linux, and Windows.
|
|
222
|
+
|
|
223
|
+
## Watchdog
|
|
224
|
+
|
|
225
|
+
Max-Watchdog is a second, ops-only Max instance that monitors and repairs the main Max daemon. When main Max goes down, you message the watchdog over Telegram (or Discord) to diagnose, read logs, and restart — no SSH required.
|
|
226
|
+
|
|
227
|
+
### How it works
|
|
228
|
+
|
|
229
|
+
```
|
|
230
|
+
┌────────────────────────┐ ┌─────────────────────────┐
|
|
231
|
+
│ Main Max (~/.max) │ │ Watchdog Max │
|
|
232
|
+
│ Port 7777 │ │ (~/.max-watchdog) │
|
|
233
|
+
│ @MaxBot on Telegram │ │ Port 7778 │
|
|
234
|
+
│ General-purpose AI │ │ @WatchdogBot │
|
|
235
|
+
│ Skills, workers, etc. │ │ Ops-only: health, │
|
|
236
|
+
└────────────────────────┘ │ restart, logs, shell │
|
|
237
|
+
└─────────────────────────┘
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Two fully isolated instances: separate home directories, separate SQLite databases, separate bot tokens, separate ports, separate pm2 processes. Zero shared state.
|
|
241
|
+
|
|
242
|
+
### Watchdog setup
|
|
243
|
+
|
|
244
|
+
1. Create a **second Telegram bot** via [@BotFather](https://t.me/BotFather) (e.g. "Max Watchdog 🔧").
|
|
245
|
+
|
|
246
|
+
2. Run the watchdog setup wizard:
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
max setup --profile watchdog
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
This creates `~/.max-watchdog/` and prompts for the watchdog bot token, your user ID, API port (default 7778), and the main Max pm2 process name.
|
|
253
|
+
|
|
254
|
+
3. Start the watchdog:
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
max start --profile watchdog
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Watchdog tools
|
|
261
|
+
|
|
262
|
+
The watchdog has its own Copilot-powered AI session with these ops tools:
|
|
263
|
+
|
|
264
|
+
| Tool | Description |
|
|
265
|
+
|------|-------------|
|
|
266
|
+
| `check_main_max` | Check if main Max is running (pm2 status + HTTP health) |
|
|
267
|
+
| `restart_main_max` | Restart the main Max pm2 process |
|
|
268
|
+
| `read_main_logs` | Read the last N lines of main Max's daemon log |
|
|
269
|
+
| `server_health` | Report hostname, uptime, memory, disk, load average |
|
|
270
|
+
| `run_shell` | Run a shell command (30s timeout, output capped) |
|
|
271
|
+
| `update_main_max` | Stop main Max, update via npm, restart |
|
|
272
|
+
|
|
273
|
+
The watchdog does **not** have workers, skills, or long-term memory — it's purpose-built for ops.
|
|
274
|
+
|
|
275
|
+
### Dual-instance pm2 deployment
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
# Main Max
|
|
279
|
+
pm2 start "max start" --name max
|
|
280
|
+
|
|
281
|
+
# Watchdog
|
|
282
|
+
pm2 start "max start --profile watchdog" --name max-watchdog
|
|
283
|
+
|
|
284
|
+
pm2 save
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### Recovery example
|
|
288
|
+
|
|
289
|
+
```
|
|
290
|
+
You → Watchdog: "is Max alive?"
|
|
291
|
+
Watchdog: "Main Max is down. Last log: [ERROR] Copilot SDK auth expired."
|
|
292
|
+
|
|
293
|
+
You → Watchdog: "show me the last 50 log lines"
|
|
294
|
+
Watchdog: (tails ~/.max/daemon.log)
|
|
295
|
+
|
|
296
|
+
You → Watchdog: "restart main Max"
|
|
297
|
+
Watchdog: "Main Max is back online (pid 4521)."
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### Configuration
|
|
301
|
+
|
|
302
|
+
| Env var | Default | Description |
|
|
303
|
+
|---------|---------|-------------|
|
|
304
|
+
| `MAX_PROFILE` | _(unset)_ | Profile name; `watchdog` for the ops instance |
|
|
305
|
+
| `MAX_HOME` | `~/.max` or `~/.max-<profile>` | Override the home directory |
|
|
306
|
+
| `MAIN_MAX_PM2_NAME` | `max` | pm2 process name of the main Max instance |
|
|
307
|
+
| `MAIN_MAX_HOME` | `~/.max` | Home directory of the main Max instance |
|
|
308
|
+
| `MAIN_MAX_API_PORT` | `7777` | HTTP API port of the main Max instance |
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
3
|
+
import { randomBytes } from "crypto";
|
|
4
|
+
import { sendToOrchestrator, getWorkers, cancelCurrentMessage, getLastRouteResult, invalidateSession } from "../copilot/orchestrator.js";
|
|
5
|
+
import { sendPhoto, sendProactiveMessage } from "../telegram/bot.js";
|
|
6
|
+
import { sendDiscordProactiveMessage, startDiscordThread } from "../discord/bot.js";
|
|
7
|
+
import { config, persistModel } from "../config.js";
|
|
8
|
+
import { getRouterConfig, updateRouterConfig } from "../copilot/router.js";
|
|
9
|
+
import { searchMemories } from "../store/db.js";
|
|
10
|
+
import { listSkills, removeSkill } from "../copilot/skills.js";
|
|
11
|
+
import { restartDaemon } from "../daemon.js";
|
|
12
|
+
import { API_TOKEN_PATH, ensureMaxHome } from "../paths.js";
|
|
13
|
+
// Ensure token file exists (generate on first run)
|
|
14
|
+
let apiToken = null;
|
|
15
|
+
try {
|
|
16
|
+
if (existsSync(API_TOKEN_PATH)) {
|
|
17
|
+
apiToken = readFileSync(API_TOKEN_PATH, "utf-8").trim();
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
ensureMaxHome();
|
|
21
|
+
apiToken = randomBytes(32).toString("hex");
|
|
22
|
+
writeFileSync(API_TOKEN_PATH, apiToken, { mode: 0o600 });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
console.error(`[auth] Failed to load/generate API token: ${err}`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
const app = express();
|
|
30
|
+
app.use(express.json());
|
|
31
|
+
// Handle malformed JSON bodies
|
|
32
|
+
app.use((err, _req, res, next) => {
|
|
33
|
+
if (err.type === "entity.parse.failed") {
|
|
34
|
+
console.error("[max] API received malformed JSON body");
|
|
35
|
+
res.status(400).json({ error: "Invalid JSON" });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
next(err);
|
|
39
|
+
});
|
|
40
|
+
// Bearer token authentication middleware (skip /status health check)
|
|
41
|
+
app.use((req, res, next) => {
|
|
42
|
+
if (!apiToken || req.path === "/status" || req.path === "/send-photo")
|
|
43
|
+
return next();
|
|
44
|
+
const auth = req.headers.authorization;
|
|
45
|
+
if (!auth || auth !== `Bearer ${apiToken}`) {
|
|
46
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
next();
|
|
50
|
+
});
|
|
51
|
+
// Active SSE connections
|
|
52
|
+
const sseClients = new Map();
|
|
53
|
+
let connectionCounter = 0;
|
|
54
|
+
// Health check
|
|
55
|
+
app.get("/status", (_req, res) => {
|
|
56
|
+
res.json({
|
|
57
|
+
status: "ok",
|
|
58
|
+
workers: Array.from(getWorkers().values()).map((w) => ({
|
|
59
|
+
name: w.name,
|
|
60
|
+
workingDir: w.workingDir,
|
|
61
|
+
status: w.status,
|
|
62
|
+
})),
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
// List worker sessions
|
|
66
|
+
app.get("/sessions", (_req, res) => {
|
|
67
|
+
const workers = Array.from(getWorkers().values()).map((w) => ({
|
|
68
|
+
name: w.name,
|
|
69
|
+
workingDir: w.workingDir,
|
|
70
|
+
status: w.status,
|
|
71
|
+
lastOutput: w.lastOutput?.slice(0, 500),
|
|
72
|
+
}));
|
|
73
|
+
res.json(workers);
|
|
74
|
+
});
|
|
75
|
+
// SSE stream for real-time responses
|
|
76
|
+
app.get("/stream", (req, res) => {
|
|
77
|
+
const connectionId = `tui-${++connectionCounter}`;
|
|
78
|
+
res.writeHead(200, {
|
|
79
|
+
"Content-Type": "text/event-stream",
|
|
80
|
+
"Cache-Control": "no-cache",
|
|
81
|
+
Connection: "keep-alive",
|
|
82
|
+
});
|
|
83
|
+
res.write(`data: ${JSON.stringify({ type: "connected", connectionId })}\n\n`);
|
|
84
|
+
sseClients.set(connectionId, res);
|
|
85
|
+
// Heartbeat to keep connection alive
|
|
86
|
+
const heartbeat = setInterval(() => {
|
|
87
|
+
res.write(`:ping\n\n`);
|
|
88
|
+
}, 20_000);
|
|
89
|
+
req.on("close", () => {
|
|
90
|
+
clearInterval(heartbeat);
|
|
91
|
+
sseClients.delete(connectionId);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
// Send a message to the orchestrator
|
|
95
|
+
app.post("/message", (req, res) => {
|
|
96
|
+
const { prompt, connectionId } = req.body;
|
|
97
|
+
if (!prompt || typeof prompt !== "string") {
|
|
98
|
+
res.status(400).json({ error: "Missing 'prompt' in request body" });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!connectionId || !sseClients.has(connectionId)) {
|
|
102
|
+
res.status(400).json({ error: "Missing or invalid 'connectionId'. Connect to /stream first." });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
sendToOrchestrator(prompt, { type: "tui", connectionId }, (text, done) => {
|
|
106
|
+
const sseRes = sseClients.get(connectionId);
|
|
107
|
+
if (sseRes) {
|
|
108
|
+
const event = {
|
|
109
|
+
type: done ? "message" : "delta",
|
|
110
|
+
content: text,
|
|
111
|
+
};
|
|
112
|
+
if (done) {
|
|
113
|
+
const routeResult = getLastRouteResult();
|
|
114
|
+
if (routeResult) {
|
|
115
|
+
event.route = {
|
|
116
|
+
model: routeResult.model,
|
|
117
|
+
routerMode: routeResult.routerMode,
|
|
118
|
+
tier: routeResult.tier,
|
|
119
|
+
...(routeResult.overrideName ? { overrideName: routeResult.overrideName } : {}),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
sseRes.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
res.json({ status: "queued" });
|
|
127
|
+
});
|
|
128
|
+
// Cancel the current in-flight message
|
|
129
|
+
app.post("/cancel", async (_req, res) => {
|
|
130
|
+
const cancelled = await cancelCurrentMessage();
|
|
131
|
+
// Notify all SSE clients that the message was cancelled
|
|
132
|
+
for (const [, sseRes] of sseClients) {
|
|
133
|
+
sseRes.write(`data: ${JSON.stringify({ type: "cancelled" })}\n\n`);
|
|
134
|
+
}
|
|
135
|
+
res.json({ status: "ok", cancelled });
|
|
136
|
+
});
|
|
137
|
+
// Get or switch model
|
|
138
|
+
app.get("/model", (_req, res) => {
|
|
139
|
+
res.json({ model: config.copilotModel });
|
|
140
|
+
});
|
|
141
|
+
app.post("/model", async (req, res) => {
|
|
142
|
+
const { model } = req.body;
|
|
143
|
+
if (!model || typeof model !== "string") {
|
|
144
|
+
res.status(400).json({ error: "Missing 'model' in request body" });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// Validate against available models before persisting
|
|
148
|
+
try {
|
|
149
|
+
const { getClient } = await import("../copilot/client.js");
|
|
150
|
+
const client = await getClient();
|
|
151
|
+
const models = await client.listModels();
|
|
152
|
+
const match = models.find((m) => m.id === model);
|
|
153
|
+
if (!match) {
|
|
154
|
+
const suggestions = models
|
|
155
|
+
.filter((m) => m.id.includes(model) || m.id.toLowerCase().includes(model.toLowerCase()))
|
|
156
|
+
.map((m) => m.id);
|
|
157
|
+
const hint = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}?` : "";
|
|
158
|
+
res.status(400).json({ error: `Model '${model}' not found.${hint}` });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// If we can't validate (client not ready), allow the switch — it'll fail on next message if wrong
|
|
164
|
+
}
|
|
165
|
+
const previous = config.copilotModel;
|
|
166
|
+
config.copilotModel = model;
|
|
167
|
+
persistModel(model);
|
|
168
|
+
invalidateSession();
|
|
169
|
+
res.json({ previous, current: model });
|
|
170
|
+
});
|
|
171
|
+
// Get auto-routing config
|
|
172
|
+
app.get("/auto", (_req, res) => {
|
|
173
|
+
const routerConfig = getRouterConfig();
|
|
174
|
+
const lastRoute = getLastRouteResult();
|
|
175
|
+
res.json({
|
|
176
|
+
...routerConfig,
|
|
177
|
+
currentModel: config.copilotModel,
|
|
178
|
+
lastRoute: lastRoute || null,
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
// Update auto-routing config
|
|
182
|
+
app.post("/auto", (req, res) => {
|
|
183
|
+
const body = req.body;
|
|
184
|
+
const updated = updateRouterConfig(body);
|
|
185
|
+
console.log(`[max] Auto-routing ${updated.enabled ? "enabled" : "disabled"}`);
|
|
186
|
+
res.json(updated);
|
|
187
|
+
});
|
|
188
|
+
// List memories
|
|
189
|
+
app.get("/memory", (_req, res) => {
|
|
190
|
+
const memories = searchMemories(undefined, undefined, 100);
|
|
191
|
+
res.json(memories);
|
|
192
|
+
});
|
|
193
|
+
// List skills
|
|
194
|
+
app.get("/skills", (_req, res) => {
|
|
195
|
+
const skills = listSkills();
|
|
196
|
+
res.json(skills);
|
|
197
|
+
});
|
|
198
|
+
// Remove a local skill
|
|
199
|
+
app.delete("/skills/:slug", (req, res) => {
|
|
200
|
+
const slug = Array.isArray(req.params.slug) ? req.params.slug[0] : req.params.slug;
|
|
201
|
+
const result = removeSkill(slug);
|
|
202
|
+
if (!result.ok) {
|
|
203
|
+
res.status(400).json({ error: result.message });
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
res.json({ ok: true, message: result.message });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
// Restart daemon
|
|
210
|
+
app.post("/restart", (_req, res) => {
|
|
211
|
+
res.json({ status: "restarting" });
|
|
212
|
+
setTimeout(() => {
|
|
213
|
+
restartDaemon().catch((err) => {
|
|
214
|
+
console.error("[max] Restart failed:", err);
|
|
215
|
+
});
|
|
216
|
+
}, 500);
|
|
217
|
+
});
|
|
218
|
+
// Send a photo to Telegram (protected by bearer token auth middleware)
|
|
219
|
+
app.post("/send-photo", async (req, res) => {
|
|
220
|
+
const { photo, caption } = req.body;
|
|
221
|
+
if (!photo || typeof photo !== "string") {
|
|
222
|
+
res.status(400).json({ error: "Missing 'photo' (file path or URL) in request body" });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
await sendPhoto(photo, caption);
|
|
227
|
+
res.json({ status: "sent" });
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
231
|
+
res.status(500).json({ error: msg });
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
// Send a message to a channel (telegram or discord)
|
|
235
|
+
app.post("/send-message", async (req, res) => {
|
|
236
|
+
const { channel, target, message } = req.body;
|
|
237
|
+
if (!message || typeof message !== "string") {
|
|
238
|
+
res.status(400).json({ error: "Missing 'message' in request body" });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
if (channel === "discord") {
|
|
243
|
+
await sendDiscordProactiveMessage(message, target);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
await sendProactiveMessage(message);
|
|
247
|
+
}
|
|
248
|
+
res.json({ status: "sent" });
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
252
|
+
res.status(500).json({ error: msg });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
// Start a Discord thread on a specific message
|
|
256
|
+
app.post("/discord/start-thread", async (req, res) => {
|
|
257
|
+
const { channelId, messageId, name } = req.body;
|
|
258
|
+
if (!channelId || typeof channelId !== "string") {
|
|
259
|
+
res.status(400).json({ error: "Missing 'channelId' in request body" });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (!messageId || typeof messageId !== "string") {
|
|
263
|
+
res.status(400).json({ error: "Missing 'messageId' in request body" });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
const threadId = await startDiscordThread(channelId, messageId, name);
|
|
268
|
+
res.json({ threadId });
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
272
|
+
res.status(500).json({ error: msg });
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
export function startApiServer() {
|
|
276
|
+
return new Promise((resolve, reject) => {
|
|
277
|
+
const server = app.listen(config.apiPort, "127.0.0.1", () => {
|
|
278
|
+
console.log(`[max] HTTP API listening on http://127.0.0.1:${config.apiPort}`);
|
|
279
|
+
resolve();
|
|
280
|
+
});
|
|
281
|
+
server.on("error", (err) => {
|
|
282
|
+
if (err.code === "EADDRINUSE") {
|
|
283
|
+
reject(new Error(`Port ${config.apiPort} is already in use. Is another Max instance running?`));
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
reject(err);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
/** Broadcast a proactive message to all connected SSE clients (for background task completions). */
|
|
292
|
+
export function broadcastToSSE(text) {
|
|
293
|
+
for (const [, res] of sseClients) {
|
|
294
|
+
res.write(`data: ${JSON.stringify({ type: "message", content: text })}\n\n`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
//# sourceMappingURL=server.js.map
|