@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.
- package/README.md +1 -1
- package/bin/cli.js +153 -82
- package/dev/cli.tsx +26 -0
- package/dist/config/openai-codex-models.json +205 -0
- package/dist/server/index.d.ts +4 -2
- package/dist/server/index.js +66 -8
- package/dist/server/lib/opencode-client.d.ts +14 -0
- package/dist/server/lib/opencode-client.js +17 -0
- package/dist/server/lib/opencode-config.d.ts +14 -0
- package/dist/server/lib/opencode-config.js +25 -0
- package/dist/server/routes/config.d.ts +12 -0
- package/dist/server/routes/config.js +207 -0
- package/dist/server/routes/init.d.ts +4 -3
- package/dist/server/routes/init.js +23 -9
- package/dist/server/routes/local-llm.d.ts +8 -0
- package/dist/server/routes/local-llm.js +255 -0
- package/dist/server/routes/logs.js +35 -11
- package/dist/server/routes/prompt.d.ts +16 -0
- package/dist/server/routes/prompt.js +173 -0
- package/dist/server/routes/session.d.ts +8 -0
- package/dist/server/routes/session.js +63 -0
- package/dist/server/routes/status.d.ts +9 -0
- package/dist/server/routes/status.js +54 -0
- package/dist/server/routes/usage.d.ts +12 -0
- package/dist/server/routes/usage.js +60 -0
- package/dist/server/routes/windows.js +4 -4
- package/dist/server/schema.d.ts +47 -16
- package/dist/server/schema.js +8 -1
- package/dist/server/services/database.d.ts +10 -1
- package/dist/server/services/database.js +19 -6
- package/dist/web/assets/{_baseUniq-BXqY9Mam.js → _baseUniq-6T01QAux.js} +1 -1
- package/dist/web/assets/{arc-Bn6tUpO_.js → arc-BkH3TPJb.js} +1 -1
- package/dist/web/assets/{architectureDiagram-VXUJARFQ-C7FAApUY.js → architectureDiagram-VXUJARFQ-BSi6BLCC.js} +1 -1
- package/dist/web/assets/{blockDiagram-VD42YOAC-C2fdaEWa.js → blockDiagram-VD42YOAC-QSPUbinO.js} +1 -1
- package/dist/web/assets/{c4Diagram-YG6GDRKO-FEVzhARQ.js → c4Diagram-YG6GDRKO-Cya_BihR.js} +1 -1
- package/dist/web/assets/channel-DGAtS-pa.js +1 -0
- package/dist/web/assets/{chunk-4BX2VUAB-DLekcSAU.js → chunk-4BX2VUAB-DIL6eizv.js} +1 -1
- package/dist/web/assets/{chunk-55IACEB6-8hFRjyTP.js → chunk-55IACEB6-CgwejoZz.js} +1 -1
- package/dist/web/assets/{chunk-B4BG7PRW-DULC9-MQ.js → chunk-B4BG7PRW-9mIPqoGe.js} +1 -1
- package/dist/web/assets/{chunk-DI55MBZ5-DuOE5RH1.js → chunk-DI55MBZ5-BRbyRfgT.js} +1 -1
- package/dist/web/assets/{chunk-FMBD7UC4-DaDNiCk7.js → chunk-FMBD7UC4-CVBT25Fj.js} +1 -1
- package/dist/web/assets/{chunk-QN33PNHL-CKshfIHj.js → chunk-QN33PNHL-rTj-WT2G.js} +1 -1
- package/dist/web/assets/{chunk-QZHKN3VN-D2Qy0tdi.js → chunk-QZHKN3VN-BaUBiHya.js} +1 -1
- package/dist/web/assets/{chunk-TZMSLE5B-SPxkj-lp.js → chunk-TZMSLE5B-C4_O5TI-.js} +1 -1
- package/dist/web/assets/classDiagram-2ON5EDUG-DLvlUUJq.js +1 -0
- package/dist/web/assets/classDiagram-v2-WZHVMYZB-DLvlUUJq.js +1 -0
- package/dist/web/assets/clone-BZW2JABw.js +1 -0
- package/dist/web/assets/{code-block-QI2IAROF-BZdAQmZ2.js → code-block-QI2IAROF-Bj_2OIYt.js} +1 -1
- package/dist/web/assets/{cose-bilkent-S5V4N54A-DbasixUk.js → cose-bilkent-S5V4N54A-T7a1luWi.js} +1 -1
- package/dist/web/assets/{dagre-6UL2VRFP-CStyjTc9.js → dagre-6UL2VRFP-CeH5ZsdW.js} +1 -1
- package/dist/web/assets/{diagram-PSM6KHXK-Crk93U8d.js → diagram-PSM6KHXK-Cdod2Lna.js} +1 -1
- package/dist/web/assets/{diagram-QEK2KX5R-DiW6RNbg.js → diagram-QEK2KX5R-CYks2r54.js} +1 -1
- package/dist/web/assets/{diagram-S2PKOQOG-CKksz_qL.js → diagram-S2PKOQOG-DCmy0g7p.js} +1 -1
- package/dist/web/assets/{erDiagram-Q2GNP2WA-CisACqqq.js → erDiagram-Q2GNP2WA-Dlz1bNvI.js} +1 -1
- package/dist/web/assets/{flowDiagram-NV44I4VS-BBp_5zAe.js → flowDiagram-NV44I4VS-Di5Iit1B.js} +1 -1
- package/dist/web/assets/{ganttDiagram-JELNMOA3-BKZ30gLA.js → ganttDiagram-JELNMOA3-9i1dugg-.js} +1 -1
- package/dist/web/assets/{gitGraphDiagram-NY62KEGX-ClizxUXq.js → gitGraphDiagram-NY62KEGX-BORbMVri.js} +1 -1
- package/dist/web/assets/{graph-DqhaNOTU.js → graph-C0SCKxbQ.js} +1 -1
- package/dist/web/assets/index-CYVJT8rN.js +1 -0
- package/dist/web/assets/index-CcAJUkQw.css +1 -0
- package/dist/web/assets/index-CcDdxbB-.js +1719 -0
- package/dist/web/assets/{infoDiagram-WHAUD3N6-BQwNR0md.js → infoDiagram-WHAUD3N6-7ohMQFLY.js} +1 -1
- package/dist/web/assets/{journeyDiagram-XKPGCS4Q-YOqPPID4.js → journeyDiagram-XKPGCS4Q-DZp7Z7wE.js} +1 -1
- package/dist/web/assets/{kanban-definition-3W4ZIXB7-Dtu8bvBx.js → kanban-definition-3W4ZIXB7-BCNLCm54.js} +1 -1
- package/dist/web/assets/{layout-Cc1ESzTe.js → layout-AUnZuY21.js} +1 -1
- package/dist/web/assets/{linear-BwI2ANFG.js → linear-B0bfAqGt.js} +1 -1
- package/dist/web/assets/{mermaid.core-npIGP8NS.js → mermaid.core-D5fXNCxA.js} +5 -5
- package/dist/web/assets/{min--MKscDc6.js → min-BZUFOEEw.js} +1 -1
- package/dist/web/assets/{mindmap-definition-VGOIOE7T-Cr39Vhym.js → mindmap-definition-VGOIOE7T-hEGJLJ8N.js} +1 -1
- package/dist/web/assets/{pieDiagram-ADFJNKIX-Cv8ke00t.js → pieDiagram-ADFJNKIX-BRpCTJIO.js} +1 -1
- package/dist/web/assets/{quadrantDiagram-AYHSOK5B-BPhHaTg8.js → quadrantDiagram-AYHSOK5B-m7jaiHQb.js} +1 -1
- package/dist/web/assets/{requirementDiagram-UZGBJVZJ-Cc42SoK0.js → requirementDiagram-UZGBJVZJ-Coh9g9Sp.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-TZEHDZUN-CtgBuq8T.js → sankeyDiagram-TZEHDZUN-CrD_kUGR.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-WL72ISMW-B9lNGN6V.js → sequenceDiagram-WL72ISMW-C04yD1EI.js} +1 -1
- package/dist/web/assets/{stateDiagram-FKZM4ZOC-C3dRTOMb.js → stateDiagram-FKZM4ZOC-DhP-DMZW.js} +1 -1
- package/dist/web/assets/stateDiagram-v2-4FDKWEC3-DWi5vrD6.js +1 -0
- package/dist/web/assets/{timeline-definition-IT6M3QCI-CXhSuTlt.js → timeline-definition-IT6M3QCI-40iW2p_5.js} +1 -1
- package/dist/web/assets/{treemap-KMMF4GRG-Csy25Uov.js → treemap-KMMF4GRG-BnxWQbzt.js} +1 -1
- package/dist/web/assets/welcome-screen-test-CLeWuIqq.js +1 -0
- package/dist/web/assets/{xychartDiagram-PRI3JC2R-CxEERqse.js → xychartDiagram-PRI3JC2R-D6lcJDCc.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +14 -5
- package/dist/web/assets/channel-CxjnQtV7.js +0 -1
- package/dist/web/assets/classDiagram-2ON5EDUG-CVG91-fs.js +0 -1
- package/dist/web/assets/classDiagram-v2-WZHVMYZB-CVG91-fs.js +0 -1
- package/dist/web/assets/clone-C7jxvixc.js +0 -1
- package/dist/web/assets/index-B0jWvqrS.css +0 -1
- package/dist/web/assets/index-Dnmavb3d.js +0 -1716
- 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
|
-
//
|
|
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
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
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;
|