@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 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