@bytespell/shella 0.1.3 → 0.1.4

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 (89) hide show
  1. package/README.md +1 -1
  2. package/bin/cli.js +153 -82
  3. package/dev/cli.tsx +26 -0
  4. package/dist/config/openai-codex-models.json +205 -0
  5. package/dist/server/index.d.ts +4 -2
  6. package/dist/server/index.js +66 -8
  7. package/dist/server/lib/opencode-client.d.ts +14 -0
  8. package/dist/server/lib/opencode-client.js +17 -0
  9. package/dist/server/lib/opencode-config.d.ts +14 -0
  10. package/dist/server/lib/opencode-config.js +25 -0
  11. package/dist/server/routes/config.d.ts +12 -0
  12. package/dist/server/routes/config.js +207 -0
  13. package/dist/server/routes/init.d.ts +4 -3
  14. package/dist/server/routes/init.js +23 -9
  15. package/dist/server/routes/local-llm.d.ts +8 -0
  16. package/dist/server/routes/local-llm.js +255 -0
  17. package/dist/server/routes/logs.js +35 -11
  18. package/dist/server/routes/prompt.d.ts +16 -0
  19. package/dist/server/routes/prompt.js +173 -0
  20. package/dist/server/routes/session.d.ts +8 -0
  21. package/dist/server/routes/session.js +63 -0
  22. package/dist/server/routes/status.d.ts +9 -0
  23. package/dist/server/routes/status.js +54 -0
  24. package/dist/server/routes/usage.d.ts +12 -0
  25. package/dist/server/routes/usage.js +60 -0
  26. package/dist/server/routes/windows.js +4 -4
  27. package/dist/server/schema.d.ts +47 -16
  28. package/dist/server/schema.js +8 -1
  29. package/dist/server/services/database.d.ts +10 -1
  30. package/dist/server/services/database.js +19 -6
  31. package/dist/web/assets/{_baseUniq-BXqY9Mam.js → _baseUniq-6T01QAux.js} +1 -1
  32. package/dist/web/assets/{arc-Bn6tUpO_.js → arc-BkH3TPJb.js} +1 -1
  33. package/dist/web/assets/{architectureDiagram-VXUJARFQ-C7FAApUY.js → architectureDiagram-VXUJARFQ-BSi6BLCC.js} +1 -1
  34. package/dist/web/assets/{blockDiagram-VD42YOAC-C2fdaEWa.js → blockDiagram-VD42YOAC-QSPUbinO.js} +1 -1
  35. package/dist/web/assets/{c4Diagram-YG6GDRKO-FEVzhARQ.js → c4Diagram-YG6GDRKO-Cya_BihR.js} +1 -1
  36. package/dist/web/assets/channel-DGAtS-pa.js +1 -0
  37. package/dist/web/assets/{chunk-4BX2VUAB-DLekcSAU.js → chunk-4BX2VUAB-DIL6eizv.js} +1 -1
  38. package/dist/web/assets/{chunk-55IACEB6-8hFRjyTP.js → chunk-55IACEB6-CgwejoZz.js} +1 -1
  39. package/dist/web/assets/{chunk-B4BG7PRW-DULC9-MQ.js → chunk-B4BG7PRW-9mIPqoGe.js} +1 -1
  40. package/dist/web/assets/{chunk-DI55MBZ5-DuOE5RH1.js → chunk-DI55MBZ5-BRbyRfgT.js} +1 -1
  41. package/dist/web/assets/{chunk-FMBD7UC4-DaDNiCk7.js → chunk-FMBD7UC4-CVBT25Fj.js} +1 -1
  42. package/dist/web/assets/{chunk-QN33PNHL-CKshfIHj.js → chunk-QN33PNHL-rTj-WT2G.js} +1 -1
  43. package/dist/web/assets/{chunk-QZHKN3VN-D2Qy0tdi.js → chunk-QZHKN3VN-BaUBiHya.js} +1 -1
  44. package/dist/web/assets/{chunk-TZMSLE5B-SPxkj-lp.js → chunk-TZMSLE5B-C4_O5TI-.js} +1 -1
  45. package/dist/web/assets/classDiagram-2ON5EDUG-DLvlUUJq.js +1 -0
  46. package/dist/web/assets/classDiagram-v2-WZHVMYZB-DLvlUUJq.js +1 -0
  47. package/dist/web/assets/clone-BZW2JABw.js +1 -0
  48. package/dist/web/assets/{code-block-QI2IAROF-BZdAQmZ2.js → code-block-QI2IAROF-Bj_2OIYt.js} +1 -1
  49. package/dist/web/assets/{cose-bilkent-S5V4N54A-DbasixUk.js → cose-bilkent-S5V4N54A-T7a1luWi.js} +1 -1
  50. package/dist/web/assets/{dagre-6UL2VRFP-CStyjTc9.js → dagre-6UL2VRFP-CeH5ZsdW.js} +1 -1
  51. package/dist/web/assets/{diagram-PSM6KHXK-Crk93U8d.js → diagram-PSM6KHXK-Cdod2Lna.js} +1 -1
  52. package/dist/web/assets/{diagram-QEK2KX5R-DiW6RNbg.js → diagram-QEK2KX5R-CYks2r54.js} +1 -1
  53. package/dist/web/assets/{diagram-S2PKOQOG-CKksz_qL.js → diagram-S2PKOQOG-DCmy0g7p.js} +1 -1
  54. package/dist/web/assets/{erDiagram-Q2GNP2WA-CisACqqq.js → erDiagram-Q2GNP2WA-Dlz1bNvI.js} +1 -1
  55. package/dist/web/assets/{flowDiagram-NV44I4VS-BBp_5zAe.js → flowDiagram-NV44I4VS-Di5Iit1B.js} +1 -1
  56. package/dist/web/assets/{ganttDiagram-JELNMOA3-BKZ30gLA.js → ganttDiagram-JELNMOA3-9i1dugg-.js} +1 -1
  57. package/dist/web/assets/{gitGraphDiagram-NY62KEGX-ClizxUXq.js → gitGraphDiagram-NY62KEGX-BORbMVri.js} +1 -1
  58. package/dist/web/assets/{graph-DqhaNOTU.js → graph-C0SCKxbQ.js} +1 -1
  59. package/dist/web/assets/index-CYVJT8rN.js +1 -0
  60. package/dist/web/assets/index-CcAJUkQw.css +1 -0
  61. package/dist/web/assets/index-CcDdxbB-.js +1719 -0
  62. package/dist/web/assets/{infoDiagram-WHAUD3N6-BQwNR0md.js → infoDiagram-WHAUD3N6-7ohMQFLY.js} +1 -1
  63. package/dist/web/assets/{journeyDiagram-XKPGCS4Q-YOqPPID4.js → journeyDiagram-XKPGCS4Q-DZp7Z7wE.js} +1 -1
  64. package/dist/web/assets/{kanban-definition-3W4ZIXB7-Dtu8bvBx.js → kanban-definition-3W4ZIXB7-BCNLCm54.js} +1 -1
  65. package/dist/web/assets/{layout-Cc1ESzTe.js → layout-AUnZuY21.js} +1 -1
  66. package/dist/web/assets/{linear-BwI2ANFG.js → linear-B0bfAqGt.js} +1 -1
  67. package/dist/web/assets/{mermaid.core-npIGP8NS.js → mermaid.core-D5fXNCxA.js} +5 -5
  68. package/dist/web/assets/{min--MKscDc6.js → min-BZUFOEEw.js} +1 -1
  69. package/dist/web/assets/{mindmap-definition-VGOIOE7T-Cr39Vhym.js → mindmap-definition-VGOIOE7T-hEGJLJ8N.js} +1 -1
  70. package/dist/web/assets/{pieDiagram-ADFJNKIX-Cv8ke00t.js → pieDiagram-ADFJNKIX-BRpCTJIO.js} +1 -1
  71. package/dist/web/assets/{quadrantDiagram-AYHSOK5B-BPhHaTg8.js → quadrantDiagram-AYHSOK5B-m7jaiHQb.js} +1 -1
  72. package/dist/web/assets/{requirementDiagram-UZGBJVZJ-Cc42SoK0.js → requirementDiagram-UZGBJVZJ-Coh9g9Sp.js} +1 -1
  73. package/dist/web/assets/{sankeyDiagram-TZEHDZUN-CtgBuq8T.js → sankeyDiagram-TZEHDZUN-CrD_kUGR.js} +1 -1
  74. package/dist/web/assets/{sequenceDiagram-WL72ISMW-B9lNGN6V.js → sequenceDiagram-WL72ISMW-C04yD1EI.js} +1 -1
  75. package/dist/web/assets/{stateDiagram-FKZM4ZOC-C3dRTOMb.js → stateDiagram-FKZM4ZOC-DhP-DMZW.js} +1 -1
  76. package/dist/web/assets/stateDiagram-v2-4FDKWEC3-DWi5vrD6.js +1 -0
  77. package/dist/web/assets/{timeline-definition-IT6M3QCI-CXhSuTlt.js → timeline-definition-IT6M3QCI-40iW2p_5.js} +1 -1
  78. package/dist/web/assets/{treemap-KMMF4GRG-Csy25Uov.js → treemap-KMMF4GRG-BnxWQbzt.js} +1 -1
  79. package/dist/web/assets/welcome-screen-test-CLeWuIqq.js +1 -0
  80. package/dist/web/assets/{xychartDiagram-PRI3JC2R-CxEERqse.js → xychartDiagram-PRI3JC2R-D6lcJDCc.js} +1 -1
  81. package/dist/web/index.html +3 -3
  82. package/package.json +14 -5
  83. package/dist/web/assets/channel-CxjnQtV7.js +0 -1
  84. package/dist/web/assets/classDiagram-2ON5EDUG-CVG91-fs.js +0 -1
  85. package/dist/web/assets/classDiagram-v2-WZHVMYZB-CVG91-fs.js +0 -1
  86. package/dist/web/assets/clone-C7jxvixc.js +0 -1
  87. package/dist/web/assets/index-B0jWvqrS.css +0 -1
  88. package/dist/web/assets/index-Dnmavb3d.js +0 -1716
  89. package/dist/web/assets/stateDiagram-v2-4FDKWEC3-oHTO1yj_.js +0 -1
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Local LLM Routes
3
+ *
4
+ * Endpoints for validating and adding OpenAI-compatible local LLM providers
5
+ * (LM Studio, Ollama, llama.cpp, etc.) to the global OpenCode config.
6
+ */
7
+ import { Router } from 'express';
8
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { homedir } from 'os';
11
+ import { opencodeClient } from '../lib/opencode-client.js';
12
+ const router = Router();
13
+ /** Global OpenCode config path */
14
+ const OPENCODE_CONFIG_DIR = join(homedir(), '.config', 'opencode');
15
+ const OPENCODE_CONFIG_PATH = join(OPENCODE_CONFIG_DIR, 'opencode.json');
16
+ /** Timeout for validation requests (5 seconds) */
17
+ const VALIDATION_TIMEOUT_MS = 5000;
18
+ /**
19
+ * Sanitize provider name to create a valid provider ID.
20
+ * "LM Studio" -> "lm-studio"
21
+ * "My Custom Provider!" -> "my-custom-provider"
22
+ */
23
+ function sanitizeProviderId(name) {
24
+ return name
25
+ .toLowerCase()
26
+ .trim()
27
+ .replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens
28
+ .replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
29
+ .substring(0, 50); // Limit length
30
+ }
31
+ /**
32
+ * Read the global OpenCode config file.
33
+ * Returns empty config structure if file doesn't exist.
34
+ */
35
+ function readOpencodeConfig() {
36
+ try {
37
+ if (existsSync(OPENCODE_CONFIG_PATH)) {
38
+ const content = readFileSync(OPENCODE_CONFIG_PATH, 'utf-8');
39
+ return JSON.parse(content);
40
+ }
41
+ }
42
+ catch (err) {
43
+ console.error('[local-llm] Failed to read opencode.json:', err);
44
+ }
45
+ // Return default config structure
46
+ return {
47
+ $schema: 'https://opencode.ai/config.json',
48
+ provider: {},
49
+ };
50
+ }
51
+ /**
52
+ * Write the global OpenCode config file.
53
+ */
54
+ function writeOpencodeConfig(config) {
55
+ // Ensure config directory exists
56
+ if (!existsSync(OPENCODE_CONFIG_DIR)) {
57
+ mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true });
58
+ }
59
+ const content = JSON.stringify(config, null, 2);
60
+ writeFileSync(OPENCODE_CONFIG_PATH, content, 'utf-8');
61
+ console.log(`[local-llm] Wrote config to ${OPENCODE_CONFIG_PATH}`);
62
+ }
63
+ /**
64
+ * POST /validate
65
+ *
66
+ * Validates a local LLM server by fetching its /models endpoint.
67
+ * Returns the list of available models if successful.
68
+ *
69
+ * Request body: { baseUrl: string }
70
+ * Response: { success: true, models: [...] } or { success: false, error: "..." }
71
+ */
72
+ router.post('/validate', async (req, res) => {
73
+ const { baseUrl } = req.body;
74
+ if (!baseUrl || typeof baseUrl !== 'string') {
75
+ res.status(400).json({ success: false, error: 'baseUrl is required' });
76
+ return;
77
+ }
78
+ // Normalize URL - remove trailing slash
79
+ const normalizedUrl = baseUrl.replace(/\/+$/, '');
80
+ // Construct models endpoint
81
+ const modelsUrl = normalizedUrl.endsWith('/v1')
82
+ ? `${normalizedUrl}/models`
83
+ : `${normalizedUrl}/v1/models`;
84
+ console.log(`[local-llm] Validating: ${modelsUrl}`);
85
+ try {
86
+ const controller = new AbortController();
87
+ const timeoutId = setTimeout(() => controller.abort(), VALIDATION_TIMEOUT_MS);
88
+ const response = await fetch(modelsUrl, {
89
+ method: 'GET',
90
+ headers: {
91
+ Accept: 'application/json',
92
+ },
93
+ signal: controller.signal,
94
+ });
95
+ clearTimeout(timeoutId);
96
+ if (!response.ok) {
97
+ const text = await response.text().catch(() => '');
98
+ res.json({
99
+ success: false,
100
+ error: `Server returned ${response.status}: ${text || response.statusText}`,
101
+ });
102
+ return;
103
+ }
104
+ const data = (await response.json());
105
+ if (!data.data || !Array.isArray(data.data)) {
106
+ res.json({
107
+ success: false,
108
+ error: 'Invalid response format: expected { data: [...] }',
109
+ });
110
+ return;
111
+ }
112
+ // Extract model info
113
+ const models = data.data.map((model) => ({
114
+ id: model.id,
115
+ name: model.id, // Use ID as name by default
116
+ owned_by: model.owned_by,
117
+ }));
118
+ console.log(`[local-llm] Found ${models.length} model(s)`);
119
+ res.json({ success: true, models });
120
+ }
121
+ catch (err) {
122
+ const message = err instanceof Error ? err.message : String(err);
123
+ const errorCode = err?.code;
124
+ console.log(`[local-llm] Validation failed: ${message} (code: ${errorCode})`);
125
+ // Provide user-friendly error messages
126
+ let userError = message;
127
+ if (errorCode === 'ECONNREFUSED' || message.includes('ECONNREFUSED')) {
128
+ userError = 'Connection refused. Is the server running?';
129
+ }
130
+ else if (errorCode === 'ENOTFOUND' || message.includes('ENOTFOUND')) {
131
+ userError = 'Host not found. Check the URL.';
132
+ }
133
+ else if (errorCode === 'ETIMEDOUT' ||
134
+ message.includes('abort') ||
135
+ message.includes('timeout')) {
136
+ userError = 'Connection timed out after 5 seconds';
137
+ }
138
+ else if (message === 'fetch failed' || message.includes('fetch')) {
139
+ userError = 'Connection failed. Is the server running at this address?';
140
+ }
141
+ res.json({ success: false, error: userError });
142
+ }
143
+ });
144
+ /**
145
+ * POST /add
146
+ *
147
+ * Adds a local LLM provider to the global OpenCode config.
148
+ *
149
+ * Request body: { name: string, baseUrl: string, models: { id: string, name: string }[] }
150
+ * Response: { success: true, providerId: "..." } or { success: false, error: "..." }
151
+ */
152
+ router.post('/add', async (req, res) => {
153
+ const { name, baseUrl, models } = req.body;
154
+ if (!name || typeof name !== 'string') {
155
+ res.status(400).json({ success: false, error: 'name is required' });
156
+ return;
157
+ }
158
+ if (!baseUrl || typeof baseUrl !== 'string') {
159
+ res.status(400).json({ success: false, error: 'baseUrl is required' });
160
+ return;
161
+ }
162
+ if (!models || !Array.isArray(models) || models.length === 0) {
163
+ res.status(400).json({ success: false, error: 'At least one model is required' });
164
+ return;
165
+ }
166
+ const providerId = sanitizeProviderId(name);
167
+ if (!providerId) {
168
+ res.status(400).json({ success: false, error: 'Invalid provider name' });
169
+ return;
170
+ }
171
+ // Read existing config
172
+ const config = readOpencodeConfig();
173
+ // Check if provider already exists
174
+ if (config.provider?.[providerId]) {
175
+ res.status(409).json({
176
+ success: false,
177
+ error: `Provider "${providerId}" already exists`,
178
+ });
179
+ return;
180
+ }
181
+ // Normalize URL - ensure it ends with /v1
182
+ let normalizedUrl = baseUrl.replace(/\/+$/, '');
183
+ if (!normalizedUrl.endsWith('/v1')) {
184
+ normalizedUrl = `${normalizedUrl}/v1`;
185
+ }
186
+ // Build models config
187
+ const modelsConfig = {};
188
+ for (const model of models) {
189
+ modelsConfig[model.id] = { name: model.name };
190
+ }
191
+ // Create provider config
192
+ const providerConfig = {
193
+ npm: '@ai-sdk/openai-compatible',
194
+ name: name.trim(),
195
+ options: {
196
+ baseURL: normalizedUrl,
197
+ },
198
+ models: modelsConfig,
199
+ };
200
+ // Merge into config
201
+ if (!config.provider) {
202
+ config.provider = {};
203
+ }
204
+ config.provider[providerId] = providerConfig;
205
+ try {
206
+ // Write config
207
+ writeOpencodeConfig(config);
208
+ // Force OpenCode to reload by disposing the current instance
209
+ // This will cause the next request to pick up the new config
210
+ try {
211
+ await opencodeClient.global.dispose();
212
+ console.log('[local-llm] Disposed OpenCode instance to reload config');
213
+ }
214
+ catch {
215
+ // Non-fatal - OpenCode may not be running yet
216
+ console.log('[local-llm] Could not dispose OpenCode instance (may not be running)');
217
+ }
218
+ res.json({ success: true, providerId });
219
+ }
220
+ catch (err) {
221
+ const message = err instanceof Error ? err.message : String(err);
222
+ console.error('[local-llm] Failed to write config:', message);
223
+ res.status(500).json({ success: false, error: 'Failed to save configuration' });
224
+ }
225
+ });
226
+ /**
227
+ * GET /list
228
+ *
229
+ * Lists all local LLM providers from the global OpenCode config.
230
+ *
231
+ * Response: { providers: [{ id: string, name: string, baseUrl: string, models: [...] }] }
232
+ */
233
+ router.get('/list', (_req, res) => {
234
+ const config = readOpencodeConfig();
235
+ const providers = [];
236
+ if (config.provider) {
237
+ for (const [id, providerConfig] of Object.entries(config.provider)) {
238
+ // Only include local LLM providers (those using @ai-sdk/openai-compatible)
239
+ if (providerConfig.npm === '@ai-sdk/openai-compatible') {
240
+ const models = Object.entries(providerConfig.models || {}).map(([modelId, modelInfo]) => ({
241
+ id: modelId,
242
+ name: modelInfo.name,
243
+ }));
244
+ providers.push({
245
+ id,
246
+ name: providerConfig.name,
247
+ baseUrl: providerConfig.options?.baseURL || '',
248
+ models,
249
+ });
250
+ }
251
+ }
252
+ }
253
+ res.json({ providers });
254
+ });
255
+ export default router;
@@ -3,26 +3,50 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
  const LOG_FILE = path.resolve(process.cwd(), 'dev.log');
5
5
  const router = Router();
6
- // POST /__logs - receive browser console logs
6
+ // Pino log levels
7
+ const LEVEL_MAP = {
8
+ error: 50,
9
+ warn: 40,
10
+ info: 30,
11
+ log: 30,
12
+ debug: 20,
13
+ };
14
+ // POST /__logs - receive browser console logs (JSON format for pino compatibility)
7
15
  router.post('/', (req, res) => {
8
16
  try {
9
17
  const { level, args } = req.body;
10
- const prefix = level === 'error' ? '🔴' : level === 'warn' ? '🟡' : '🌐';
11
- const timestamp = new Date().toISOString().slice(11, 23); // HH:MM:SS.mmm
18
+ const pinoLevel = LEVEL_MAP[level] ?? 30;
12
19
  const message = args
13
20
  .map((a) => (typeof a === 'object' ? JSON.stringify(a) : String(a)))
14
21
  .join(' ');
15
- const line = `${timestamp} ${prefix} ${message}`;
16
- // Log to terminal
17
- console.log(line);
18
- // Append to file
19
- fs.appendFileSync(LOG_FILE, line + '\n');
22
+ // JSON log entry compatible with pino format
23
+ const entry = {
24
+ level: pinoLevel,
25
+ time: Date.now(),
26
+ source: 'browser',
27
+ msg: message,
28
+ };
29
+ // Pretty print to terminal
30
+ const prefix = level === 'error'
31
+ ? '\x1b[31m●\x1b[0m'
32
+ : level === 'warn'
33
+ ? '\x1b[33m●\x1b[0m'
34
+ : '\x1b[36m●\x1b[0m';
35
+ const timestamp = new Date().toISOString().slice(11, 23);
36
+ console.log(`${timestamp} ${prefix} [browser] ${message}`);
37
+ // Append JSON to file
38
+ fs.appendFileSync(LOG_FILE, JSON.stringify(entry) + '\n');
20
39
  res.sendStatus(200);
21
40
  }
22
41
  catch {
23
- const fallback = `${new Date().toISOString().slice(11, 23)} 🌐 [parse error] ${JSON.stringify(req.body)}`;
24
- console.log(fallback);
25
- fs.appendFileSync(LOG_FILE, fallback + '\n');
42
+ const entry = {
43
+ level: 40,
44
+ time: Date.now(),
45
+ source: 'browser',
46
+ msg: `[parse error] ${JSON.stringify(req.body)}`,
47
+ };
48
+ console.log(`\x1b[33m●\x1b[0m [browser] ${entry.msg}`);
49
+ fs.appendFileSync(LOG_FILE, JSON.stringify(entry) + '\n');
26
50
  res.sendStatus(200);
27
51
  }
28
52
  });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Shella Prompt Routes
3
+ *
4
+ * Handles prompt, command, and shell execution with integrated auto-routing.
5
+ * All __auto__ model resolution happens server-side - no extra HTTP calls needed.
6
+ *
7
+ * Endpoints:
8
+ * POST /prompt - Submit a prompt (message)
9
+ * POST /command - Execute a slash command
10
+ * POST /shell - Execute a shell command
11
+ *
12
+ * All endpoints return 202 immediately with routing info.
13
+ * The actual OpenCode SDK call is fire-and-forget.
14
+ */
15
+ declare const router: import("express-serve-static-core").Router;
16
+ export default router;
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Shella Prompt Routes
3
+ *
4
+ * Handles prompt, command, and shell execution with integrated auto-routing.
5
+ * All __auto__ model resolution happens server-side - no extra HTTP calls needed.
6
+ *
7
+ * Endpoints:
8
+ * POST /prompt - Submit a prompt (message)
9
+ * POST /command - Execute a slash command
10
+ * POST /shell - Execute a shell command
11
+ *
12
+ * All endpoints return 202 immediately with routing info.
13
+ * The actual OpenCode SDK call is fire-and-forget.
14
+ */
15
+ import { Router } from 'express';
16
+ import { pickBestProvider, getUsage, } from '@bytespell/model-provider-usage-limits';
17
+ import { opencodeClient } from '../lib/opencode-client.js';
18
+ const router = Router();
19
+ // =============================================================================
20
+ // Auto-Route Resolution
21
+ // =============================================================================
22
+ /**
23
+ * Infer default provider from model name pattern.
24
+ * Used as fallback when routing fails.
25
+ */
26
+ function inferDefaultProvider(modelID) {
27
+ // GPT models, o-series (o1, o3, etc.), and codex -> openai
28
+ if (modelID.startsWith('gpt') || /^o\d/.test(modelID) || modelID.startsWith('codex')) {
29
+ return 'openai';
30
+ }
31
+ // Everything else (claude, etc.) -> anthropic
32
+ return 'anthropic';
33
+ }
34
+ /**
35
+ * Server-side auto-route resolution.
36
+ * No HTTP call needed - direct function call to usage-limits library.
37
+ */
38
+ async function resolveModel(model) {
39
+ // Not an auto model - return as-is
40
+ if (model.providerID !== '__auto__') {
41
+ return { resolved: model, routed: false };
42
+ }
43
+ const defaultProvider = inferDefaultProvider(model.modelID);
44
+ try {
45
+ const usageData = await getUsage({ autoDetectAuthTokens: true });
46
+ const result = pickBestProvider({ providerID: defaultProvider, modelID: model.modelID }, usageData);
47
+ if (result) {
48
+ return {
49
+ resolved: { providerID: result.providerID, modelID: result.modelID },
50
+ routed: true,
51
+ reason: result.reason,
52
+ };
53
+ }
54
+ }
55
+ catch (err) {
56
+ const message = err instanceof Error ? err.message : String(err);
57
+ console.error('[prompt] Routing error:', message);
58
+ }
59
+ // Fallback to default provider
60
+ return {
61
+ resolved: { providerID: defaultProvider, modelID: model.modelID },
62
+ routed: true,
63
+ reason: 'Fallback to default provider',
64
+ fallback: true,
65
+ };
66
+ }
67
+ // =============================================================================
68
+ // Routes
69
+ // =============================================================================
70
+ /**
71
+ * POST /prompt
72
+ *
73
+ * Submit a prompt with auto-routing support.
74
+ * Returns 202 immediately with routing info, fires request to OpenCode async.
75
+ */
76
+ router.post('/prompt', async (req, res) => {
77
+ const { sessionID, directory, messageID, parts, model, agent } = req.body;
78
+ if (!sessionID || !directory || !model) {
79
+ res.status(400).json({ error: 'Missing required fields: sessionID, directory, model' });
80
+ return;
81
+ }
82
+ // Resolve model (handles __auto__)
83
+ const routeResult = await resolveModel(model);
84
+ console.log(`[prompt] ${model.providerID}/${model.modelID} → ${routeResult.resolved.providerID}/${routeResult.resolved.modelID}`);
85
+ // Fire-and-forget to OpenCode
86
+ opencodeClient.session
87
+ .promptAsync({
88
+ sessionID,
89
+ directory,
90
+ messageID,
91
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
92
+ parts: parts, // Parts are validated by the SDK
93
+ model: routeResult.resolved,
94
+ agent,
95
+ })
96
+ .catch((err) => {
97
+ console.error('[prompt] SDK error:', err);
98
+ });
99
+ // Return immediately with routing info
100
+ res.status(202).json({
101
+ accepted: true,
102
+ ...routeResult,
103
+ });
104
+ });
105
+ /**
106
+ * POST /command
107
+ *
108
+ * Execute a slash command with auto-routing support.
109
+ */
110
+ router.post('/command', async (req, res) => {
111
+ const { sessionID, directory, command, args, model, agent } = req.body;
112
+ if (!sessionID || !directory || !command || !model) {
113
+ res
114
+ .status(400)
115
+ .json({ error: 'Missing required fields: sessionID, directory, command, model' });
116
+ return;
117
+ }
118
+ const routeResult = await resolveModel(model);
119
+ console.log(`[command] /${command} → ${routeResult.resolved.providerID}/${routeResult.resolved.modelID}`);
120
+ // Fire-and-forget to OpenCode
121
+ // Note: SDK command() expects model as string "{providerID}/{modelID}"
122
+ const modelString = `${routeResult.resolved.providerID}/${routeResult.resolved.modelID}`;
123
+ opencodeClient.session
124
+ .command({
125
+ sessionID,
126
+ directory,
127
+ command,
128
+ arguments: args,
129
+ model: modelString,
130
+ agent,
131
+ })
132
+ .catch((err) => {
133
+ console.error('[command] SDK error:', err);
134
+ });
135
+ res.status(202).json({
136
+ accepted: true,
137
+ ...routeResult,
138
+ });
139
+ });
140
+ /**
141
+ * POST /shell
142
+ *
143
+ * Execute a shell command with auto-routing support.
144
+ */
145
+ router.post('/shell', async (req, res) => {
146
+ const { sessionID, directory, command, model, agent } = req.body;
147
+ if (!sessionID || !directory || !command || !model) {
148
+ res
149
+ .status(400)
150
+ .json({ error: 'Missing required fields: sessionID, directory, command, model' });
151
+ return;
152
+ }
153
+ const routeResult = await resolveModel(model);
154
+ const truncatedCmd = command.length > 30 ? `${command.slice(0, 30)}...` : command;
155
+ console.log(`[shell] !${truncatedCmd} → ${routeResult.resolved.providerID}/${routeResult.resolved.modelID}`);
156
+ // Fire-and-forget to OpenCode
157
+ opencodeClient.session
158
+ .shell({
159
+ sessionID,
160
+ directory,
161
+ command,
162
+ model: routeResult.resolved,
163
+ agent,
164
+ })
165
+ .catch((err) => {
166
+ console.error('[shell] SDK error:', err);
167
+ });
168
+ res.status(202).json({
169
+ accepted: true,
170
+ ...routeResult,
171
+ });
172
+ });
173
+ export default router;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Session Routes
3
+ *
4
+ * Intercepts OpenCode session endpoints to apply shella-specific transformations.
5
+ * Currently handles message chunking on compaction boundaries.
6
+ */
7
+ declare const router: import("express-serve-static-core").Router;
8
+ export default router;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Session Routes
3
+ *
4
+ * Intercepts OpenCode session endpoints to apply shella-specific transformations.
5
+ * Currently handles message chunking on compaction boundaries.
6
+ */
7
+ import { Router } from 'express';
8
+ import { opencodeClient } from '../lib/opencode-client.js';
9
+ const router = Router();
10
+ /**
11
+ * GET /session/:sessionId/message
12
+ *
13
+ * Fetches messages for a session and applies compaction chunking.
14
+ * Only returns messages from the last compaction boundary onwards,
15
+ * reducing payload size and frontend memory for long-running sessions.
16
+ */
17
+ router.get('/:sessionId/message', async (req, res) => {
18
+ const { sessionId } = req.params;
19
+ const directory = req.query.directory;
20
+ if (!directory) {
21
+ res.status(400).json({ error: 'directory query parameter is required' });
22
+ return;
23
+ }
24
+ try {
25
+ const response = await opencodeClient.session.messages({
26
+ sessionID: sessionId,
27
+ directory,
28
+ });
29
+ const allMessages = (response.data ?? []);
30
+ // Find the last message with a compaction part
31
+ let lastCompactionIndex = -1;
32
+ for (let i = allMessages.length - 1; i >= 0; i--) {
33
+ const msg = allMessages[i];
34
+ if (msg.parts?.some((p) => p.type === 'compaction')) {
35
+ lastCompactionIndex = i;
36
+ break;
37
+ }
38
+ }
39
+ // If compaction found, return only messages from compaction onwards
40
+ if (lastCompactionIndex >= 0) {
41
+ const chunkedResponse = {
42
+ data: allMessages.slice(lastCompactionIndex),
43
+ hasEarlierChunks: lastCompactionIndex > 0,
44
+ chunkBoundaryMessageId: allMessages[lastCompactionIndex].info.id,
45
+ };
46
+ console.log(`[session] GET /${sessionId}/message - chunked: ${allMessages.length} -> ${chunkedResponse.data.length} messages`);
47
+ res.json(chunkedResponse);
48
+ return;
49
+ }
50
+ // No compaction - return all messages
51
+ const fullResponse = {
52
+ data: allMessages,
53
+ hasEarlierChunks: false,
54
+ };
55
+ console.log(`[session] GET /${sessionId}/message - no compaction: ${allMessages.length} messages`);
56
+ res.json(fullResponse);
57
+ }
58
+ catch (error) {
59
+ console.error(`[session] GET /${sessionId}/message error:`, error);
60
+ res.status(502).json({ error: 'Failed to fetch messages from OpenCode' });
61
+ }
62
+ });
63
+ export default router;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Status endpoint - checks if the server has been configured (onboarding complete).
3
+ *
4
+ * This is used by the frontend to determine whether to show the welcome screen.
5
+ * Unlike localStorage-based checks, this is server-side so it works correctly
6
+ * when the same browser connects to different shella servers.
7
+ */
8
+ declare const router: import("express-serve-static-core").Router;
9
+ export default router;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Status endpoint - checks if the server has been configured (onboarding complete).
3
+ *
4
+ * This is used by the frontend to determine whether to show the welcome screen.
5
+ * Unlike localStorage-based checks, this is server-side so it works correctly
6
+ * when the same browser connects to different shella servers.
7
+ */
8
+ import { Router } from 'express';
9
+ import { eq } from 'drizzle-orm';
10
+ import { getDatabase, saveDatabase } from '../services/database.js';
11
+ import { settings } from '../schema.js';
12
+ const router = Router();
13
+ const CONFIGURED_KEY = 'configured';
14
+ /**
15
+ * GET /__shella/status
16
+ * Returns the server configuration status.
17
+ */
18
+ router.get('/', async (_req, res) => {
19
+ try {
20
+ const db = await getDatabase();
21
+ const result = await db.select().from(settings).where(eq(settings.key, CONFIGURED_KEY));
22
+ const configured = result.length > 0 && result[0].value === 'true';
23
+ res.json({ configured });
24
+ }
25
+ catch (err) {
26
+ console.error('[status] Failed to get status:', err);
27
+ res.status(500).json({ error: 'Failed to get status' });
28
+ }
29
+ });
30
+ /**
31
+ * POST /__shella/status/configured
32
+ * Marks the server as configured (onboarding complete).
33
+ */
34
+ router.post('/configured', async (_req, res) => {
35
+ try {
36
+ const db = await getDatabase();
37
+ // Upsert the configured setting
38
+ await db
39
+ .insert(settings)
40
+ .values({ key: CONFIGURED_KEY, value: 'true' })
41
+ .onConflictDoUpdate({
42
+ target: settings.key,
43
+ set: { value: 'true' },
44
+ });
45
+ // Persist immediately
46
+ saveDatabase();
47
+ res.json({ success: true });
48
+ }
49
+ catch (err) {
50
+ console.error('[status] Failed to set configured:', err);
51
+ res.status(500).json({ error: 'Failed to set configured' });
52
+ }
53
+ });
54
+ export default router;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Usage Limits API Routes
3
+ *
4
+ * Fetches AI provider usage data.
5
+ * Uses @bytespell/model-provider-usage-limits for the heavy lifting.
6
+ *
7
+ * Note: Routing is now handled server-side in prompt.ts.
8
+ * Usage data is also included in the config response.
9
+ * This endpoint is kept for debugging/manual refresh.
10
+ */
11
+ declare const router: import("express-serve-static-core").Router;
12
+ export default router;