@mooncompany/uplink-chat 0.5.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.
Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/bin/uplink.js +279 -0
  4. package/middleware/error-handler.js +69 -0
  5. package/package.json +93 -0
  6. package/public/css/agents.36b98c0f.css +1469 -0
  7. package/public/css/agents.css +1469 -0
  8. package/public/css/app.a6a7f8f5.css +2731 -0
  9. package/public/css/app.css +2731 -0
  10. package/public/css/artifacts.css +444 -0
  11. package/public/css/commands.css +55 -0
  12. package/public/css/connection.css +131 -0
  13. package/public/css/dashboard.css +233 -0
  14. package/public/css/developer.css +328 -0
  15. package/public/css/files.css +123 -0
  16. package/public/css/markdown.css +156 -0
  17. package/public/css/message-actions.css +278 -0
  18. package/public/css/mobile.css +614 -0
  19. package/public/css/panels-unified.css +483 -0
  20. package/public/css/premium.css +415 -0
  21. package/public/css/realtime.css +189 -0
  22. package/public/css/satellites.css +401 -0
  23. package/public/css/shortcuts.css +185 -0
  24. package/public/css/split-view.4def0262.css +673 -0
  25. package/public/css/split-view.css +673 -0
  26. package/public/css/theme-generator.css +391 -0
  27. package/public/css/themes.css +387 -0
  28. package/public/css/timestamps.css +54 -0
  29. package/public/css/variables.css +78 -0
  30. package/public/dist/bundle.b55050c4.js +15757 -0
  31. package/public/favicon.svg +24 -0
  32. package/public/img/agents/ada.png +0 -0
  33. package/public/img/agents/clarice.png +0 -0
  34. package/public/img/agents/dennis-nedry.png +0 -0
  35. package/public/img/agents/elliot-alderson.png +0 -0
  36. package/public/img/agents/main.png +0 -0
  37. package/public/img/agents/scotty.png +0 -0
  38. package/public/img/agents/top-flight-security.png +0 -0
  39. package/public/index.html +1083 -0
  40. package/public/js/agents-data.js +234 -0
  41. package/public/js/agents-ui.js +72 -0
  42. package/public/js/agents.js +1525 -0
  43. package/public/js/app.js +79 -0
  44. package/public/js/appearance-settings.js +111 -0
  45. package/public/js/artifacts.js +432 -0
  46. package/public/js/audio-queue.js +168 -0
  47. package/public/js/bootstrap.js +54 -0
  48. package/public/js/chat.js +1211 -0
  49. package/public/js/commands.js +581 -0
  50. package/public/js/connection-api.js +121 -0
  51. package/public/js/connection.js +1231 -0
  52. package/public/js/context-tracker.js +271 -0
  53. package/public/js/core.js +172 -0
  54. package/public/js/dashboard.js +452 -0
  55. package/public/js/developer.js +432 -0
  56. package/public/js/encryption.js +124 -0
  57. package/public/js/errors.js +122 -0
  58. package/public/js/event-bus.js +77 -0
  59. package/public/js/fetch-utils.js +171 -0
  60. package/public/js/file-handler.js +229 -0
  61. package/public/js/files.js +352 -0
  62. package/public/js/gateway-chat.js +538 -0
  63. package/public/js/logger.js +112 -0
  64. package/public/js/markdown.js +190 -0
  65. package/public/js/message-actions.js +431 -0
  66. package/public/js/message-renderer.js +288 -0
  67. package/public/js/missed-messages.js +235 -0
  68. package/public/js/mobile-debug.js +95 -0
  69. package/public/js/notifications.js +367 -0
  70. package/public/js/offline-queue.js +178 -0
  71. package/public/js/onboarding.js +543 -0
  72. package/public/js/panels.js +156 -0
  73. package/public/js/premium.js +412 -0
  74. package/public/js/realtime-voice.js +844 -0
  75. package/public/js/satellite-sync.js +256 -0
  76. package/public/js/satellite-ui.js +175 -0
  77. package/public/js/satellites.js +1516 -0
  78. package/public/js/settings.js +1087 -0
  79. package/public/js/shortcuts.js +381 -0
  80. package/public/js/split-chat.js +1234 -0
  81. package/public/js/split-resize.js +211 -0
  82. package/public/js/splitview.js +340 -0
  83. package/public/js/storage.js +408 -0
  84. package/public/js/streaming-handler.js +324 -0
  85. package/public/js/stt-settings.js +316 -0
  86. package/public/js/theme-generator.js +661 -0
  87. package/public/js/themes.js +164 -0
  88. package/public/js/timestamps.js +198 -0
  89. package/public/js/tts-settings.js +575 -0
  90. package/public/js/ui.js +267 -0
  91. package/public/js/update-notifier.js +143 -0
  92. package/public/js/utils/constants.js +165 -0
  93. package/public/js/utils/sanitize.js +93 -0
  94. package/public/js/utils/sse-parser.js +195 -0
  95. package/public/js/voice.js +883 -0
  96. package/public/manifest.json +58 -0
  97. package/public/moon_texture.jpg +0 -0
  98. package/public/sw.js +221 -0
  99. package/public/three.min.js +6 -0
  100. package/server/channel.js +529 -0
  101. package/server/chat.js +270 -0
  102. package/server/config-store.js +362 -0
  103. package/server/config.js +159 -0
  104. package/server/context.js +131 -0
  105. package/server/gateway-commands.js +211 -0
  106. package/server/gateway-proxy.js +318 -0
  107. package/server/index.js +22 -0
  108. package/server/logger.js +89 -0
  109. package/server/middleware/auth.js +188 -0
  110. package/server/middleware.js +218 -0
  111. package/server/openclaw-discover.js +308 -0
  112. package/server/premium/index.js +156 -0
  113. package/server/premium/license.js +140 -0
  114. package/server/realtime/bridge.js +837 -0
  115. package/server/realtime/index.js +349 -0
  116. package/server/realtime/tts-stream.js +446 -0
  117. package/server/routes/agents.js +564 -0
  118. package/server/routes/artifacts.js +174 -0
  119. package/server/routes/chat.js +311 -0
  120. package/server/routes/config-settings.js +345 -0
  121. package/server/routes/config.js +603 -0
  122. package/server/routes/files.js +307 -0
  123. package/server/routes/index.js +18 -0
  124. package/server/routes/media.js +451 -0
  125. package/server/routes/missed-messages.js +107 -0
  126. package/server/routes/premium.js +75 -0
  127. package/server/routes/push.js +156 -0
  128. package/server/routes/satellite.js +406 -0
  129. package/server/routes/status.js +251 -0
  130. package/server/routes/stt.js +35 -0
  131. package/server/routes/voice.js +260 -0
  132. package/server/routes/webhooks.js +203 -0
  133. package/server/routes.js +206 -0
  134. package/server/runtime-config.js +336 -0
  135. package/server/share.js +305 -0
  136. package/server/stt/faster-whisper.js +72 -0
  137. package/server/stt/groq.js +51 -0
  138. package/server/stt/index.js +196 -0
  139. package/server/stt/openai.js +49 -0
  140. package/server/sync.js +244 -0
  141. package/server/tailscale-https.js +175 -0
  142. package/server/tts.js +646 -0
  143. package/server/update-checker.js +172 -0
  144. package/server/utils/filename.js +129 -0
  145. package/server/utils.js +147 -0
  146. package/server/watchdog.js +318 -0
  147. package/server/websocket/broadcast.js +359 -0
  148. package/server/websocket/connections.js +339 -0
  149. package/server/websocket/index.js +215 -0
  150. package/server/websocket/routing.js +277 -0
  151. package/server/websocket/sync.js +102 -0
  152. package/server.js +404 -0
  153. package/utils/detect-tool-usage.js +93 -0
  154. package/utils/errors.js +158 -0
  155. package/utils/html-escape.js +84 -0
  156. package/utils/id-sanitize.js +94 -0
  157. package/utils/response.js +130 -0
  158. package/utils/with-retry.js +105 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Moon Company LLC
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,185 @@
1
+ # ⬡ Uplink
2
+
3
+ [![npm version](https://img.shields.io/npm/v/uplink-chat)](https://www.npmjs.com/package/uplink-chat)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org)
6
+
7
+ **Local-first AI chat with voice** — talk to any LLM from your own machine.
8
+
9
+ <!-- TODO: Add hero screenshot showing Uplink chat interface -->
10
+
11
+ Uplink is a self-hosted chat interface that runs as a lightweight Node.js server on your computer. Connect it to OpenAI, Anthropic, local models, or any OpenAI-compatible API. Add voice with free TTS/STT engines. Access from any device on your network.
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ npx uplink-chat
17
+ ```
18
+
19
+ That's it. Open `http://localhost:3456` in your browser.
20
+
21
+ ## Install Globally
22
+
23
+ ```bash
24
+ npm install -g uplink-chat
25
+ uplink-chat start
26
+ ```
27
+
28
+ ## Features
29
+
30
+ - **Any LLM** — OpenAI, Anthropic, Ollama, LM Studio, or any OpenAI-compatible endpoint
31
+ - **Voice Chat** — Text-to-speech and speech-to-text with multiple engine options
32
+ - **Agent Management** — Create, edit, delete, and route between OpenClaw agents directly from Uplink
33
+ - **Local-First** — Runs on your machine. Your data stays on your machine.
34
+ - **PWA** — Install as an app on desktop or mobile from your browser
35
+ - **Themes** — 6 built-in themes (dark, light, OLED, and more)
36
+ - **Mobile Ready** — Responsive UI, works great on phones via local network
37
+ - **Satellites** — Connect multiple AI providers and switch between them
38
+ - **File Uploads** — Attach images (JPEG, PNG, WebP, GIF), audio, video, and documents (PDF, DOCX, Excel) to chat messages
39
+ - **Dashboard** — At-a-glance view of your conversations, agents, and provider status
40
+ - **Split View** — View multiple conversations or panels side by side
41
+ - **Keyboard Shortcuts** — Navigate and control the app without touching the mouse
42
+ - **Encrypted Storage** — Optional password protection for your chat history
43
+ - **Structured Logging** — LOG_LEVEL environment variable support (debug, info, warn, error, silent)
44
+
45
+ ## Voice Engines
46
+
47
+ ### Text-to-Speech (TTS)
48
+
49
+ | Engine | Cost | Setup |
50
+ |--------|------|-------|
51
+ | Edge TTS | Free | None — works out of the box |
52
+ | OpenAI TTS | Paid | API key required |
53
+ | Coqui XTTS | Free | Local GPU server |
54
+ | ElevenLabs | Paid (~$5/mo) | API key required |
55
+
56
+ ### Speech-to-Text (STT)
57
+
58
+ | Engine | Cost | Setup |
59
+ |--------|------|-------|
60
+ | Browser STT | Free | None — built into browser |
61
+ | Faster-Whisper | Free | Local server (pip or Docker) |
62
+ | Groq Whisper | Free tier | API key required |
63
+ | OpenAI Whisper | Paid (~$0.006/min) | API key required |
64
+
65
+ ## Logging
66
+
67
+ Control verbosity with the `LOG_LEVEL` environment variable:
68
+
69
+ ```bash
70
+ LOG_LEVEL=debug uplink-chat # Verbose debugging output
71
+ LOG_LEVEL=info uplink-chat # Default (application events)
72
+ LOG_LEVEL=warn uplink-chat # Warnings and errors only
73
+ LOG_LEVEL=error uplink-chat # Errors only
74
+ LOG_LEVEL=silent uplink-chat # Suppress all logs
75
+ ```
76
+
77
+ Default is `info`. Useful for troubleshooting or reducing console noise in production.
78
+
79
+ ## CLI
80
+
81
+ ```bash
82
+ uplink-chat # Start server (foreground)
83
+ uplink-chat start # Start server (foreground)
84
+ uplink-chat start -d # Start in background
85
+ uplink-chat stop # Stop background server
86
+ uplink-chat status # Check if running
87
+ uplink-chat --port 8080 # Custom port
88
+ uplink-chat --version # Show version
89
+ uplink-chat --help # Show help
90
+ ```
91
+
92
+ ## Remote Access
93
+
94
+ Uplink runs on `localhost` by default. To access from your phone or other devices:
95
+
96
+ - **Same network** — Use your computer's local IP (e.g., `http://192.168.1.100:3456`)
97
+ - **Anywhere** — Use [Tailscale](https://tailscale.com) for a private, encrypted connection (recommended)
98
+ - **Advanced** — Cloudflare Tunnel or your own reverse proxy
99
+
100
+ > **Note:** Microphone access (STT) requires HTTPS or localhost. Tailscale provides this automatically.
101
+
102
+ ## Configuration
103
+
104
+ All settings are configured through the web UI — no config files to edit.
105
+
106
+ On first launch, you'll be guided through:
107
+ 1. Naming your assistant
108
+ 2. Connecting your AI provider (API key + endpoint)
109
+ 3. Optional: voice setup, theme selection, password protection
110
+
111
+ ## Requirements
112
+
113
+ - **Node.js 18+**
114
+ - A browser (Chrome/Edge recommended for best voice support)
115
+ - An AI provider API key (OpenAI, Anthropic, etc.) or a local model server
116
+
117
+ ## Connecting to OpenClaw
118
+
119
+ Uplink can connect to [OpenClaw](https://openclaw.ai) as a gateway for AI providers. To use this:
120
+
121
+ 1. Enter your OpenClaw gateway URL and token in Uplink's settings (or during onboarding)
122
+ 2. **Enable the chat completions endpoint** in your OpenClaw config:
123
+
124
+ ```json
125
+ {
126
+ "gateway": {
127
+ "http": {
128
+ "endpoints": {
129
+ "chatCompletions": {
130
+ "enabled": true
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ ```
137
+
138
+ > **⚠️ Important:** The `chatCompletions` endpoint is **disabled by default** in OpenClaw. Uplink sends all AI requests through `/v1/chat/completions` — if this endpoint isn't enabled, chat will silently fail or return errors. This is the #1 setup issue when connecting Uplink to OpenClaw.
139
+
140
+ You can enable it via the OpenClaw CLI:
141
+ ```bash
142
+ openclaw config set gateway.http.endpoints.chatCompletions.enabled true
143
+ ```
144
+
145
+ ## Troubleshooting
146
+
147
+ ### sharp (Image Processing)
148
+
149
+ `sharp` is a native dependency used for image processing. On some platforms it may require build tools (Python, C++ compiler) to install. If `npm install` fails due to sharp:
150
+
151
+ ```bash
152
+ npm install --ignore-scripts
153
+ npm rebuild sharp
154
+ ```
155
+
156
+ On most systems, prebuilt binaries are downloaded automatically and no extra steps are needed.
157
+
158
+ | Problem | Cause | Fix |
159
+ |---------|-------|-----|
160
+ | Chat sends but no AI response | `chatCompletions` endpoint disabled in OpenClaw | Enable it (see above) |
161
+ | 401 errors in console | Auth token mismatch or missing | Check gateway token matches in both Uplink settings and OpenClaw config |
162
+ | WebSocket 1006 error via Tailscale | Uplink/OpenClaw running in WSL while Tailscale runs on Windows | See WSL + Tailscale section below |
163
+ | Mic not working on mobile | Not using HTTPS | Access via Tailscale or localhost (browsers require secure context for mic) |
164
+ | Voice not working | TTS/STT engine not configured | Check voice settings — Edge TTS works out of the box with no setup |
165
+
166
+ ### WSL + Tailscale (Windows)
167
+
168
+ If you run Uplink or OpenClaw inside **WSL** (Windows Subsystem for Linux) but Tailscale is installed on **Windows**, WebSocket connections over Tailscale will fail with a **1006 error**. This happens because WSL and Windows have separate network stacks — Tailscale traffic arrives at Windows, but can't reach the WSL instance.
169
+
170
+ **Solutions (pick one):**
171
+
172
+ 1. **Install Tailscale inside WSL** (recommended) — Run `tailscale up` inside your Ubuntu/WSL instance so it gets its own Tailscale IP. Connect to that hostname instead.
173
+
174
+ 2. **Run Uplink on Windows directly** — Node.js works natively on Windows. This puts Uplink and Tailscale on the same network stack.
175
+
176
+ 3. **Port forward from Windows to WSL** — Forward traffic from your Windows Tailscale IP into WSL:
177
+ ```powershell
178
+ # Get your WSL IP (run inside WSL: hostname -I)
179
+ netsh interface portproxy add v4tov4 listenport=3456 listenaddress=0.0.0.0 connectport=3456 connectaddress=<WSL_IP>
180
+ ```
181
+ > ⚠️ WSL's IP can change on reboot, so you may need to update this.
182
+
183
+ ## License
184
+
185
+ MIT — [Moon Company](https://moonco.pro)
package/bin/uplink.js ADDED
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Uplink CLI
5
+ * Usage:
6
+ * uplink-chat → starts the server (default)
7
+ * uplink-chat start → starts the server
8
+ * uplink-chat stop → stops any running Uplink server
9
+ * uplink-chat --port N → use custom port (default: 3456)
10
+ * uplink-chat --help → show help
11
+ */
12
+
13
+ import { spawn, execSync } from 'child_process';
14
+ import { fileURLToPath } from 'url';
15
+ import { dirname, join } from 'path';
16
+ import { existsSync, writeFileSync, readFileSync, unlinkSync } from 'fs';
17
+ import { createRequire } from 'module';
18
+
19
+ const __filename = fileURLToPath(import.meta.url);
20
+ const __dirname = dirname(__filename);
21
+ const ROOT = join(__dirname, '..');
22
+ const PID_FILE = join(ROOT, '.uplink.pid');
23
+ const WATCHDOG_PID_FILE = join(ROOT, '.uplink-watchdog.pid');
24
+ const WATCHDOG_STATE_FILE = join(ROOT, '.uplink-watchdog.json');
25
+
26
+ const args = process.argv.slice(2);
27
+ const command = args[0] || 'start';
28
+
29
+ // Parse flags
30
+ const flags = {};
31
+ for (let i = 0; i < args.length; i++) {
32
+ if (args[i] === '--port' && args[i + 1]) {
33
+ flags.port = parseInt(args[i + 1], 10);
34
+ i++;
35
+ }
36
+ if (args[i] === '--host') {
37
+ flags.host = args[i + 1] || '0.0.0.0';
38
+ i++;
39
+ }
40
+ if (args[i] === '--help' || args[i] === '-h') {
41
+ flags.help = true;
42
+ }
43
+ if (args[i] === '--version' || args[i] === '-v') {
44
+ flags.version = true;
45
+ }
46
+ if (args[i] === '--detach' || args[i] === '-d') {
47
+ flags.detach = true;
48
+ }
49
+ if (args[i] === '--no-watchdog') {
50
+ flags.noWatchdog = true;
51
+ }
52
+ }
53
+
54
+ // Version
55
+ if (flags.version) {
56
+ const require = createRequire(import.meta.url);
57
+ const pkg = require(join(ROOT, 'package.json'));
58
+ console.log(`uplink-chat v${pkg.version}`);
59
+ process.exit(0);
60
+ }
61
+
62
+ // Help
63
+ if (flags.help) {
64
+ console.log(`
65
+ ⬡ Uplink — Local AI Chat
66
+
67
+ Usage:
68
+ uplink-chat [command] [options]
69
+
70
+ Commands:
71
+ start Start the Uplink server (default)
72
+ stop Stop a running Uplink server
73
+ status Check if Uplink is running
74
+
75
+ Options:
76
+ --port N Port to listen on (default: 3456)
77
+ --host H Host to bind to (default: 0.0.0.0)
78
+ --detach, -d Run in background (daemon mode)
79
+ --no-watchdog Run without auto-restart watchdog
80
+ -v Show version
81
+ -h Show this help
82
+
83
+ Examples:
84
+ npx uplink-chat # Start on default port
85
+ npx uplink-chat --port 8080 # Start on port 8080
86
+ npx uplink-chat stop # Stop the server
87
+ `);
88
+ process.exit(0);
89
+ }
90
+
91
+ function isRunning(pid) {
92
+ try {
93
+ process.kill(pid, 0);
94
+ return true;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ function getPid() {
101
+ if (!existsSync(PID_FILE)) return null;
102
+ const pid = parseInt(readFileSync(PID_FILE, 'utf8').trim(), 10);
103
+ if (isNaN(pid) || !isRunning(pid)) {
104
+ // Stale pid file
105
+ try { unlinkSync(PID_FILE); } catch {}
106
+ return null;
107
+ }
108
+ return pid;
109
+ }
110
+
111
+ function getWatchdogPid() {
112
+ if (!existsSync(WATCHDOG_PID_FILE)) return null;
113
+ const pid = parseInt(readFileSync(WATCHDOG_PID_FILE, 'utf8').trim(), 10);
114
+ if (isNaN(pid) || !isRunning(pid)) {
115
+ try { unlinkSync(WATCHDOG_PID_FILE); } catch {}
116
+ return null;
117
+ }
118
+ return pid;
119
+ }
120
+
121
+ function getWatchdogState() {
122
+ if (!existsSync(WATCHDOG_STATE_FILE)) return null;
123
+ try {
124
+ return JSON.parse(readFileSync(WATCHDOG_STATE_FILE, 'utf8'));
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ function formatUptime(ms) {
131
+ const seconds = Math.floor(ms / 1000);
132
+ const minutes = Math.floor(seconds / 60);
133
+ const hours = Math.floor(minutes / 60);
134
+ const days = Math.floor(hours / 24);
135
+ if (days > 0) return `${days}d ${hours % 24}h ${minutes % 60}m`;
136
+ if (hours > 0) return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
137
+ if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
138
+ return `${seconds}s`;
139
+ }
140
+
141
+ // ── STOP ──
142
+ if (command === 'stop') {
143
+ // Try watchdog first (daemon mode), then direct PID
144
+ const watchdogPid = getWatchdogPid();
145
+ if (watchdogPid) {
146
+ try {
147
+ process.kill(watchdogPid, 'SIGTERM');
148
+ console.log(`⬡ Uplink watchdog stopped (PID ${watchdogPid}).`);
149
+ try { unlinkSync(WATCHDOG_PID_FILE); } catch {}
150
+ try { unlinkSync(PID_FILE); } catch {}
151
+ } catch (e) {
152
+ console.error(`⬡ Failed to stop Uplink watchdog (PID ${watchdogPid}):`, e.message);
153
+ process.exit(1);
154
+ }
155
+ process.exit(0);
156
+ }
157
+
158
+ const pid = getPid();
159
+ if (!pid) {
160
+ console.log('⬡ Uplink is not running.');
161
+ process.exit(0);
162
+ }
163
+ try {
164
+ process.kill(pid, 'SIGTERM');
165
+ console.log(`⬡ Uplink stopped (PID ${pid}).`);
166
+ try { unlinkSync(PID_FILE); } catch {}
167
+ } catch (e) {
168
+ console.error(`⬡ Failed to stop Uplink (PID ${pid}):`, e.message);
169
+ process.exit(1);
170
+ }
171
+ process.exit(0);
172
+ }
173
+
174
+ // ── STATUS ──
175
+ if (command === 'status') {
176
+ const watchdogPid = getWatchdogPid();
177
+ const state = getWatchdogState();
178
+
179
+ if (watchdogPid && state) {
180
+ const uptime = formatUptime(Date.now() - state.startedAt);
181
+ console.log(`⬡ Uplink is running (watchdog mode)`);
182
+ console.log(` Watchdog PID: ${watchdogPid}`);
183
+ if (state.serverPid) console.log(` Server PID: ${state.serverPid}`);
184
+ console.log(` Uptime: ${uptime}`);
185
+ console.log(` Restarts: ${state.restartCount || 0}`);
186
+ if (state.backoffMs > 1000) console.log(` Backoff: ${state.backoffMs}ms`);
187
+ } else {
188
+ const pid = getPid();
189
+ if (pid) {
190
+ console.log(`⬡ Uplink is running (PID ${pid}).`);
191
+ } else {
192
+ console.log('⬡ Uplink is not running.');
193
+ }
194
+ }
195
+ process.exit(0);
196
+ }
197
+
198
+ // ── START ──
199
+ if (command === 'start' || !['stop', 'status'].includes(command)) {
200
+ // Check both watchdog and direct PID
201
+ const existingWatchdog = getWatchdogPid();
202
+ const existingPid = getPid();
203
+ if (existingWatchdog || existingPid) {
204
+ const pid = existingWatchdog || existingPid;
205
+ const mode = existingWatchdog ? 'watchdog' : 'direct';
206
+ console.log(`⬡ Uplink is already running (${mode}, PID ${pid}).`);
207
+ console.log(` Stop it first: uplink-chat stop`);
208
+ process.exit(1);
209
+ }
210
+
211
+ const env = { ...process.env };
212
+ if (flags.port) env.PORT = String(flags.port);
213
+ if (flags.host) env.UPLINK_HOST = flags.host;
214
+
215
+ const serverPath = join(ROOT, 'server.js');
216
+
217
+ if (flags.detach) {
218
+ // Daemon mode — spawn watchdog (which spawns and monitors server)
219
+ const watchdogPath = join(ROOT, 'server', 'watchdog.js');
220
+
221
+ // Pass extra env vars as JSON arg to watchdog
222
+ const extraEnv = {};
223
+ if (flags.port) extraEnv.PORT = String(flags.port);
224
+ if (flags.host) extraEnv.UPLINK_HOST = flags.host;
225
+
226
+ const child = spawn(process.execPath, [watchdogPath, JSON.stringify(extraEnv)], {
227
+ cwd: ROOT,
228
+ env,
229
+ detached: true,
230
+ stdio: 'ignore',
231
+ });
232
+ child.unref();
233
+
234
+ const port = flags.port || env.PORT || 3456;
235
+ console.log(`⬡ Uplink started in background with watchdog (PID ${child.pid}).`);
236
+ console.log(` → http://localhost:${port}`);
237
+ console.log(` Auto-restarts on crash. Stop with: uplink-chat stop`);
238
+ process.exit(0);
239
+ } else if (flags.noWatchdog) {
240
+ // Foreground mode without watchdog — raw server, no auto-restart
241
+ const child = spawn(process.execPath, [serverPath], {
242
+ cwd: ROOT,
243
+ env,
244
+ stdio: 'inherit',
245
+ });
246
+
247
+ writeFileSync(PID_FILE, String(child.pid));
248
+
249
+ // Clean up pid file on exit
250
+ const cleanup = () => {
251
+ try { unlinkSync(PID_FILE); } catch {}
252
+ };
253
+ process.on('SIGINT', () => { cleanup(); process.exit(0); });
254
+ process.on('SIGTERM', () => { cleanup(); process.exit(0); });
255
+ child.on('exit', (code) => { cleanup(); process.exit(code || 0); });
256
+ } else {
257
+ // Foreground mode with watchdog (default) — auto-restarts on crash
258
+ const watchdogPath = join(ROOT, 'server', 'watchdog.js');
259
+
260
+ const extraEnv = {};
261
+ if (flags.port) extraEnv.PORT = String(flags.port);
262
+ if (flags.host) extraEnv.UPLINK_HOST = flags.host;
263
+
264
+ const child = spawn(process.execPath, [watchdogPath, JSON.stringify(extraEnv)], {
265
+ cwd: ROOT,
266
+ env,
267
+ stdio: 'inherit',
268
+ });
269
+
270
+ const port = flags.port || env.PORT || 3456;
271
+ console.log(`⬡ Uplink starting with watchdog (auto-restart enabled)`);
272
+ console.log(` → http://localhost:${port}`);
273
+ console.log(` Use --no-watchdog for raw server mode`);
274
+
275
+ process.on('SIGINT', () => { child.kill('SIGTERM'); });
276
+ process.on('SIGTERM', () => { child.kill('SIGTERM'); });
277
+ child.on('exit', (code) => { process.exit(code || 0); });
278
+ }
279
+ }
@@ -0,0 +1,69 @@
1
+ // Centralized error handler middleware
2
+
3
+ /**
4
+ * Structured error logging with timestamp, error type, and message
5
+ * @param {Error} err - The error object
6
+ * @param {string} requestId - Request ID for tracing
7
+ * @param {Object} req - Express request object for additional context
8
+ */
9
+ function logError(err, requestId, req) {
10
+ const timestamp = new Date().toISOString();
11
+ const errorType = err.constructor?.name || err.name || 'Error';
12
+
13
+ const logEntry = {
14
+ timestamp,
15
+ requestId,
16
+ errorType,
17
+ message: err.message,
18
+ code: err.code || 'UNKNOWN',
19
+ statusCode: err.statusCode || 500,
20
+ isOperational: err.isOperational || false,
21
+ stack: err.stack,
22
+ path: req?.path,
23
+ method: req?.method,
24
+ userAgent: req?.headers?.['user-agent'],
25
+ ip: req?.ip || req?.socket?.remoteAddress
26
+ };
27
+
28
+ // Output as JSON for structured logging (can be ingested by log aggregators)
29
+ console.error(JSON.stringify(logEntry));
30
+ }
31
+
32
+ /**
33
+ * Get safe error response for client (never includes stack traces in production)
34
+ * @param {Error} err - The error object
35
+ * @returns {Object} Safe error response object
36
+ */
37
+ function getSafeErrorResponse(err) {
38
+ const isProduction = process.env.NODE_ENV === 'production';
39
+
40
+ if (err.isOperational) {
41
+ return {
42
+ error: true,
43
+ message: err.message,
44
+ code: err.code
45
+ };
46
+ }
47
+
48
+ // Never expose stack traces or internal details to client in production
49
+ return {
50
+ error: true,
51
+ message: isProduction ? 'Internal server error' : err.message,
52
+ code: 'INTERNAL_ERROR',
53
+ ...(isProduction ? {} : { stack: err.stack })
54
+ };
55
+ }
56
+
57
+ function errorHandler(err, req, res, next) {
58
+ const requestId = req.headers['x-request-id'] || 'unknown';
59
+
60
+ // Structured logging (server-side only, never sent to client)
61
+ logError(err, requestId, req);
62
+
63
+ const statusCode = err.statusCode || 500;
64
+ const response = getSafeErrorResponse(err);
65
+
66
+ return res.status(statusCode).json(response);
67
+ }
68
+
69
+ module.exports = errorHandler;
package/package.json ADDED
@@ -0,0 +1,93 @@
1
+ {
2
+ "name": "@mooncompany/uplink-chat",
3
+ "version": "0.5.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Local-first AI chat with voice — talk to any LLM from your own machine",
8
+ "type": "module",
9
+ "main": "server.js",
10
+ "bin": {
11
+ "uplink-chat": "bin/uplink.js"
12
+ },
13
+ "files": [
14
+ "bin/",
15
+ "middleware/",
16
+ "public/css/",
17
+ "public/dist/",
18
+ "public/img/",
19
+ "public/js/",
20
+ "public/index.html",
21
+ "public/manifest.json",
22
+ "public/sw.js",
23
+ "public/favicon.svg",
24
+ "public/moon_texture.jpg",
25
+ "public/three.min.js",
26
+ "server/",
27
+ "server.js",
28
+ "utils/",
29
+ "LICENSE",
30
+ "README.md"
31
+ ],
32
+ "scripts": {
33
+ "start": "node bin/uplink.js start",
34
+ "build": "node scripts/build.js",
35
+ "dev": "esbuild public/js/app.js --bundle --outfile=public/dist/bundle.js --format=iife --platform=browser --watch",
36
+ "test": "vitest run",
37
+ "test:watch": "vitest",
38
+ "lint": "eslint public/js/**/*.js",
39
+ "lint:fix": "eslint public/js/**/*.js --fix",
40
+ "format": "prettier --write public/js/**/*.js",
41
+ "format:check": "prettier --check public/js/**/*.js",
42
+ "prepublishOnly": "node scripts/build.js"
43
+ },
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/mooncompany/uplink.git"
47
+ },
48
+ "keywords": [
49
+ "ai",
50
+ "chat",
51
+ "voice",
52
+ "tts",
53
+ "stt",
54
+ "local-first",
55
+ "assistant",
56
+ "openai",
57
+ "llm",
58
+ "openclaw"
59
+ ],
60
+ "author": "Moon Company <hello@moonco.pro>",
61
+ "license": "MIT",
62
+ "bugs": {
63
+ "url": "https://github.com/mooncompany/uplink/issues"
64
+ },
65
+ "homepage": "https://uplinkchat.app",
66
+ "dependencies": {
67
+ "cors": "^2.8.6",
68
+ "dotenv": "^17.2.3",
69
+ "exceljs": "^4.4.0",
70
+ "express": "^4.18.2",
71
+ "express-rate-limit": "^8.2.1",
72
+ "file-type": "^21.3.0",
73
+ "mammoth": "^1.11.0",
74
+ "multer": "^2.0.2",
75
+ "pdf-parse": "^2.4.5",
76
+ "proper-lockfile": "^4.1.2",
77
+ "web-push": "^3.6.7",
78
+ "ws": "^8.19.0"
79
+ },
80
+ "optionalDependencies": {
81
+ "canvas": "^3.2.1",
82
+ "sharp": "^0.34.5"
83
+ },
84
+ "devDependencies": {
85
+ "esbuild": "^0.27.3",
86
+ "eslint": "^9.39.2",
87
+ "prettier": "^3.3.0",
88
+ "vitest": "^4.0.18"
89
+ },
90
+ "engines": {
91
+ "node": ">=18.0.0"
92
+ }
93
+ }