@danieltmn/openbridge 0.1.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 (44) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/LICENSE +21 -0
  3. package/README.md +143 -0
  4. package/bin/openbridge.js +18 -0
  5. package/docs/ARCHITECTURE.md +77 -0
  6. package/package.json +54 -0
  7. package/src/auth.js +204 -0
  8. package/src/bridge/bridge.js +2280 -0
  9. package/src/cli.js +791 -0
  10. package/src/config.js +108 -0
  11. package/src/log.js +22 -0
  12. package/src/paths.js +152 -0
  13. package/src/push.js +85 -0
  14. package/src/store/index.js +930 -0
  15. package/src/store/jsonfile.js +54 -0
  16. package/src/tunnel/index.js +141 -0
  17. package/src/web/assets/app.js +3939 -0
  18. package/src/web/assets/icons/icon-128x128.png +0 -0
  19. package/src/web/assets/icons/icon-144x144.png +0 -0
  20. package/src/web/assets/icons/icon-152x152.png +0 -0
  21. package/src/web/assets/icons/icon-192x192.png +0 -0
  22. package/src/web/assets/icons/icon-384x384.png +0 -0
  23. package/src/web/assets/icons/icon-512x512.png +0 -0
  24. package/src/web/assets/icons/icon-72x72.png +0 -0
  25. package/src/web/assets/icons/icon-96x96.png +0 -0
  26. package/src/web/assets/manifest.webmanifest +24 -0
  27. package/src/web/assets/sw.js +85 -0
  28. package/src/web/assets/themes/dark-plus.css +19 -0
  29. package/src/web/assets/themes/dark-red.css +18 -0
  30. package/src/web/assets/themes/default.css +18 -0
  31. package/src/web/assets/themes/github-light.css +19 -0
  32. package/src/web/assets/themes/high-contrast.css +18 -0
  33. package/src/web/assets/themes/index.json +15 -0
  34. package/src/web/assets/themes/light-plus.css +19 -0
  35. package/src/web/assets/themes/light.css +18 -0
  36. package/src/web/assets/themes/monokai.css +19 -0
  37. package/src/web/assets/themes/one-dark.css +19 -0
  38. package/src/web/assets/themes/solarized.css +18 -0
  39. package/src/web/assets/themes/terminal.css +28 -0
  40. package/src/web/assets/themes/themes.css +2 -0
  41. package/src/web/routes.js +871 -0
  42. package/src/web/server.js +130 -0
  43. package/src/web/templates/chat.html +1296 -0
  44. package/src/web/templates/login.html +144 -0
@@ -0,0 +1,930 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Capa de datos (port de app/lib.php): sesiones, mensajes, catalogo por puente,
5
+ * registro de puentes, workspace y busqueda. Mantiene EXACTAMENTE el mismo
6
+ * esquema JSON que la version PHP, para poder migrar datos entre ambas.
7
+ */
8
+
9
+ const fs = require('node:fs/promises');
10
+ const fssync = require('node:fs');
11
+ const path = require('node:path');
12
+ const crypto = require('node:crypto');
13
+ const paths = require('../paths');
14
+ const jsonfile = require('./jsonfile');
15
+
16
+ const STALE_PROCESSING_SECONDS = 600;
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Utilidades
20
+ // ---------------------------------------------------------------------------
21
+ function nowIso() { return new Date().toISOString(); }
22
+ function md5(s) { return crypto.createHash('md5').update(String(s)).digest('hex'); }
23
+ function mbSubstr(s, start, len) {
24
+ const arr = Array.from(String(s == null ? '' : s));
25
+ return len === undefined ? arr.slice(start).join('') : arr.slice(start, start + len).join('');
26
+ }
27
+ function clone(v) { return structuredClone(v); }
28
+ function normFolder(f) {
29
+ return String(f || '').replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase();
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Sesiones
34
+ // ---------------------------------------------------------------------------
35
+ const SESSIONS_DEFAULT = () => ({ sessions: [], nextId: 1 });
36
+
37
+ function sessionsUpdate(fn) {
38
+ return jsonfile.update(paths.sessionsFile(), SESSIONS_DEFAULT(), fn);
39
+ }
40
+ async function sessionsRead() {
41
+ return jsonfile.readJson(paths.sessionsFile(), SESSIONS_DEFAULT());
42
+ }
43
+ function findSessionRef(data, id) {
44
+ for (const sess of (data.sessions || [])) {
45
+ if (parseInt(sess.id, 10) === parseInt(id, 10)) return sess;
46
+ }
47
+ return null;
48
+ }
49
+ async function getSession(id) {
50
+ const data = await sessionsRead();
51
+ return findSessionRef(data, id);
52
+ }
53
+
54
+ function bridgeValidId(id) {
55
+ return typeof id === 'string' && id !== '' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/.test(id);
56
+ }
57
+ function sessionBridge(sess) {
58
+ const b = sess && typeof sess.bridge === 'string' ? sess.bridge : '';
59
+ return bridgeValidId(b) ? b : '';
60
+ }
61
+
62
+ async function addSession(name, folder, model, agent = 'build', opencodeSession = null, bridge = '') {
63
+ let id = null;
64
+ await sessionsUpdate((data) => {
65
+ id = data.nextId;
66
+ data.nextId = id + 1;
67
+ const sess = {
68
+ id,
69
+ name: name !== '' ? name : 'Chat ' + id,
70
+ folder: String(folder || ''),
71
+ model: String(model || ''),
72
+ agent: String(agent || 'build'),
73
+ created_ts: nowIso(),
74
+ last_ts: nowIso(),
75
+ opencode_session: opencodeSession,
76
+ };
77
+ if (bridgeValidId(bridge)) sess.bridge = bridge;
78
+ data.sessions.push(sess);
79
+ });
80
+ await touchFile(paths.messagesFile(id));
81
+ return id;
82
+ }
83
+
84
+ async function updateSession(id, folder, model, opencodeSession = null) {
85
+ let ok = false;
86
+ await sessionsUpdate((data) => {
87
+ const idx = data.sessions.findIndex((s) => parseInt(s.id, 10) === parseInt(id, 10));
88
+ if (idx < 0) return false;
89
+ if (typeof folder === 'string') data.sessions[idx].folder = folder;
90
+ if (typeof model === 'string') data.sessions[idx].model = model;
91
+ if (typeof opencodeSession === 'string') data.sessions[idx].opencode_session = opencodeSession;
92
+ data.sessions[idx].last_ts = nowIso();
93
+ ok = true;
94
+ });
95
+ return ok;
96
+ }
97
+
98
+ async function touchSession(id) {
99
+ await sessionsUpdate((data) => {
100
+ const idx = data.sessions.findIndex((s) => parseInt(s.id, 10) === parseInt(id, 10));
101
+ if (idx < 0) return false;
102
+ data.sessions[idx].last_ts = nowIso();
103
+ });
104
+ }
105
+
106
+ async function sessionRename(id, name) {
107
+ const clean = String(name || '').trim();
108
+ if (clean === '') return false;
109
+ let ok = false;
110
+ await sessionsUpdate((data) => {
111
+ const idx = data.sessions.findIndex((s) => parseInt(s.id, 10) === parseInt(id, 10));
112
+ if (idx < 0) return false;
113
+ data.sessions[idx].name = mbSubstr(clean, 0, 60);
114
+ data.sessions[idx].last_ts = nowIso();
115
+ ok = true;
116
+ });
117
+ return ok;
118
+ }
119
+
120
+ function sessionHasDefaultName(sess) {
121
+ const name = sess && typeof sess.name === 'string' ? sess.name : '';
122
+ return name === '' || /^Chat \d+$/.test(name);
123
+ }
124
+
125
+ function sessionTitleFromPrompt(text) {
126
+ let t = String(text || '').replace(/\s+/gu, ' ').trim();
127
+ if (t === '') return '';
128
+ const max = 48;
129
+ if (Array.from(t).length > max) {
130
+ let cut = mbSubstr(t, 0, max);
131
+ const sp = cut.lastIndexOf(' ');
132
+ if (sp > 20) cut = cut.slice(0, sp);
133
+ t = cut.replace(/[ \t.,:;-]+$/, '') + '\u2026';
134
+ }
135
+ return t;
136
+ }
137
+
138
+ async function deleteSession(id) {
139
+ await sessionsUpdate((data) => {
140
+ data.sessions = data.sessions.filter((s) => parseInt(s.id, 10) !== parseInt(id, 10));
141
+ });
142
+ try { await fs.unlink(paths.messagesFile(id)); } catch (e) { /* no estaba */ }
143
+ }
144
+
145
+ // Importa (merge idempotente) una sesion de opencode. Mismo esquema que PHP.
146
+ async function sessionImport(ocSession, name, folder, model, agent, updatedTs, messages, tokens = 0, cost = 0, bridge = '') {
147
+ const oc = String(ocSession || '').trim();
148
+ if (oc === '') return { ok: false, error: 'opencode_session requerido' };
149
+ const file = paths.bridgeCatalogFile(bridge);
150
+ folder = String(folder || '');
151
+ if (folder !== '' && (await folderPathInCatalog(folder, file)) === null) {
152
+ const base = path.basename(folder.replace(/\\/g, '/').replace(/\/+$/, ''));
153
+ await catalogModify(file, (cat) => {
154
+ if (!Array.isArray(cat.folders)) cat.folders = [];
155
+ if (cat.folders.some((f) => f && f.path === folder)) return;
156
+ cat.folders.push({ name: mbSubstr(base, 0, 60), path: mbSubstr(folder, 0, 500) });
157
+ });
158
+ }
159
+ const cat = await catalogRead(file);
160
+ const modelOk = model !== '' && modelInCatalogObj(cat, model);
161
+ const agentOk = agent !== '' && agentInCatalogObj(cat, agent);
162
+
163
+ let sid = null, created = false;
164
+ await sessionsUpdate((sdata) => {
165
+ let idx = sdata.sessions.findIndex((s) => s.opencode_session && String(s.opencode_session) === oc);
166
+ if (idx < 0) {
167
+ const id = sdata.nextId;
168
+ sdata.nextId = id + 1;
169
+ const ts = (updatedTs && !isNaN(Date.parse(updatedTs))) ? updatedTs : nowIso();
170
+ const sess = {
171
+ id,
172
+ name: name !== '' ? name : 'Opencode ' + oc.slice(0, 8),
173
+ folder,
174
+ model: modelOk ? model : '',
175
+ agent: agentOk ? agent : 'build',
176
+ created_ts: ts,
177
+ last_ts: ts,
178
+ opencode_session: oc,
179
+ importada: true,
180
+ };
181
+ if (bridgeValidId(bridge)) sess.bridge = bridge;
182
+ sdata.sessions.push(sess);
183
+ idx = sdata.sessions.length - 1;
184
+ created = true;
185
+ } else {
186
+ const s = sdata.sessions[idx];
187
+ if (sessionHasDefaultName(s) && name !== '') s.name = mbSubstr(name, 0, 60);
188
+ if (folder !== '' && String(s.folder || '') !== folder) s.folder = folder;
189
+ s.importada = true;
190
+ if (bridgeValidId(bridge) && sessionBridge(s) === '') s.bridge = bridge;
191
+ }
192
+ const s = sdata.sessions[idx];
193
+ if (tokens > 0) s.tokens = tokens;
194
+ if (cost > 0) s.cost = Math.round(cost * 10000) / 10000;
195
+ sid = parseInt(s.id, 10);
196
+ });
197
+
198
+ let added = 0;
199
+ await jsonfile.update(paths.messagesFile(sid), { messages: [], nextId: 1 }, (data) => {
200
+ const known = {};
201
+ for (const m of data.messages) {
202
+ known[String(m.role || '') + '|' + String(m.ts || '') + '|' + md5(mbSubstr(m.text || '', 0, 400))] = true;
203
+ }
204
+ for (const m of (Array.isArray(messages) ? messages : [])) {
205
+ if (!m || typeof m !== 'object') continue;
206
+ const role = String(m.role || '');
207
+ if (role !== 'user' && role !== 'assistant') continue;
208
+ let text = String(m.text || '').trim();
209
+ if (text === '') continue;
210
+ if (Array.from(text).length > 50000) text = mbSubstr(text, 0, 50000);
211
+ let ts = String(m.ts || '');
212
+ if (ts === '' || isNaN(Date.parse(ts))) ts = nowIso();
213
+ const key = role + '|' + ts + '|' + md5(mbSubstr(text, 0, 400));
214
+ if (known[key]) continue;
215
+ known[key] = true;
216
+ const id = data.nextId;
217
+ data.nextId = id + 1;
218
+ const msg = { id, role, text, ts, status: 'done' };
219
+ const reasoning = String(m.reasoning || '').trim();
220
+ if (reasoning !== '') msg.reasoning = Array.from(reasoning).length > 50000 ? mbSubstr(reasoning, 0, 50000) : reasoning;
221
+ if (m.agent) msg.agent = mbSubstr(String(m.agent), 0, 40);
222
+ data.messages.push(msg);
223
+ added++;
224
+ }
225
+ });
226
+ const mdata = await messagesRead(sid);
227
+ const last = mdata.messages[mdata.messages.length - 1];
228
+ if (last && last.ts) {
229
+ await sessionsUpdate((sdata) => {
230
+ const idx = sdata.sessions.findIndex((s) => s.opencode_session && String(s.opencode_session) === oc);
231
+ if (idx < 0) return false;
232
+ if (Date.parse(last.ts) > Date.parse(sdata.sessions[idx].last_ts || 0)) {
233
+ sdata.sessions[idx].last_ts = last.ts;
234
+ }
235
+ });
236
+ }
237
+ return { ok: true, session_id: sid, created, added };
238
+ }
239
+
240
+ // Refresca tokens/costo (y carpeta real) de una sesion ya vinculada.
241
+ async function sessionTokens(ocSession, tokens, cost, folder = '', bridge = '') {
242
+ const oc = String(ocSession || '').trim();
243
+ if (oc === '') return;
244
+ const file = paths.bridgeCatalogFile(bridge);
245
+ folder = String(folder || '');
246
+ if (folder !== '' && (await folderPathInCatalog(folder, file)) === null) {
247
+ const base = path.basename(folder.replace(/\\/g, '/').replace(/\/+$/, ''));
248
+ await catalogModify(file, (cat) => {
249
+ if (!Array.isArray(cat.folders)) cat.folders = [];
250
+ if (cat.folders.some((f) => f && f.path === folder)) return;
251
+ cat.folders.push({ name: mbSubstr(base, 0, 60), path: mbSubstr(folder, 0, 500) });
252
+ });
253
+ }
254
+ await sessionsUpdate((sdata) => {
255
+ const s = sdata.sessions.find((x) => x.opencode_session && String(x.opencode_session) === oc);
256
+ if (!s) return false;
257
+ if (tokens > 0) s.tokens = tokens;
258
+ if (cost > 0) s.cost = Math.round(cost * 10000) / 10000;
259
+ if (folder !== '' && String(s.folder || '') !== folder) s.folder = folder;
260
+ if (bridgeValidId(bridge) && sessionBridge(s) === '') s.bridge = bridge;
261
+ });
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // Mensajes
266
+ // ---------------------------------------------------------------------------
267
+ function messagesUpdate(sid, fn) {
268
+ return jsonfile.update(paths.messagesFile(sid), { messages: [], nextId: 1 }, fn);
269
+ }
270
+ function messagesRead(sid) {
271
+ return jsonfile.readJson(paths.messagesFile(sid), { messages: [], nextId: 1 });
272
+ }
273
+ function messagesHealStaleStreaming(data, cutoff) {
274
+ let changed = false;
275
+ if (!data || !Array.isArray(data.messages)) return false;
276
+ for (const msg of data.messages) {
277
+ if ((msg.role || '') !== 'assistant') continue;
278
+ if ((msg.status || '') !== 'streaming') continue;
279
+ if (Date.parse(msg.ts || 0) >= cutoff) continue;
280
+ msg.status = 'done';
281
+ msg.canceled = true;
282
+ changed = true;
283
+ }
284
+ return changed;
285
+ }
286
+ async function addMessage(sid, role, text, status = 'pending', extra = {}) {
287
+ let id = null;
288
+ await messagesUpdate(sid, (data) => {
289
+ id = data.nextId;
290
+ data.nextId = id + 1;
291
+ const msg = { id, role, text, ts: nowIso(), status };
292
+ for (const k of Object.keys(extra || {})) {
293
+ if (['id', 'role', 'text', 'ts', 'status', 'draft_for'].includes(k)) continue;
294
+ msg[k] = extra[k];
295
+ }
296
+ data.messages.push(msg);
297
+ });
298
+ await touchSession(sid);
299
+ return id;
300
+ }
301
+ function messageAgentOf(data, userId, fallback = '') {
302
+ for (const msg of (data.messages || [])) {
303
+ if (parseInt(msg.id, 10) === parseInt(userId, 10)) {
304
+ const a = typeof msg.agent === 'string' ? msg.agent : '';
305
+ return a !== '' ? a : fallback;
306
+ }
307
+ }
308
+ return fallback;
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // Catalogo (por puente)
313
+ // ---------------------------------------------------------------------------
314
+ function catalogDefault() {
315
+ return {
316
+ folders: [],
317
+ models: [],
318
+ models_full: {},
319
+ models_ctx: {},
320
+ vision: [],
321
+ workspace: '',
322
+ allow_create_folders: false,
323
+ agents: ['build', 'plan'],
324
+ synced_ts: null,
325
+ last_online_ts: null,
326
+ busy_session: null,
327
+ busy_since: null,
328
+ requests: [],
329
+ nextRequestId: 1,
330
+ };
331
+ }
332
+ async function catalogRead(file) {
333
+ const data = await jsonfile.readJson(file || paths.catalogFile(), {});
334
+ const def = catalogDefault();
335
+ for (const k of Object.keys(def)) if (data[k] === undefined) data[k] = def[k];
336
+ return data;
337
+ }
338
+ function catalogModify(file, fn) {
339
+ return jsonfile.update(file || paths.catalogFile(), catalogDefault(), async (data) => {
340
+ const def = catalogDefault();
341
+ for (const k of Object.keys(def)) if (data[k] === undefined) data[k] = def[k];
342
+ return fn(data);
343
+ });
344
+ }
345
+ function catalogVersion(cat) {
346
+ const sig = { ...cat };
347
+ for (const k of ['last_online_ts', 'busy_session', 'busy_since', 'requests', 'commands', 'nextRequestId', 'nextCommandId']) {
348
+ delete sig[k];
349
+ }
350
+ return md5(JSON.stringify(sig));
351
+ }
352
+ async function syncCatalog(folders, models, workspace, allowCreateFolder, agents, modelsFull, vision, modelsCtx, file) {
353
+ const fresh = catalogDefault();
354
+ fresh.folders = Array.isArray(folders) ? folders : [];
355
+ fresh.models = Array.isArray(models) ? models : [];
356
+ fresh.models_full = (modelsFull && typeof modelsFull === 'object') ? modelsFull : {};
357
+ fresh.models_ctx = (modelsCtx && typeof modelsCtx === 'object') ? modelsCtx : {};
358
+ fresh.vision = Array.isArray(vision) ? vision : [];
359
+ fresh.workspace = String(workspace || '');
360
+ fresh.allow_create_folders = !!allowCreateFolder;
361
+ fresh.agents = (Array.isArray(agents) ? agents : []).filter((a) => typeof a === 'string');
362
+ fresh.synced_ts = nowIso();
363
+ await catalogModify(file, (cat) => {
364
+ const keep = {};
365
+ for (const k of ['commands', 'requests', 'nextCommandId', 'nextRequestId']) {
366
+ if (cat[k] !== undefined) keep[k] = cat[k];
367
+ }
368
+ for (const k of Object.keys(cat)) delete cat[k];
369
+ Object.assign(cat, fresh, keep);
370
+ });
371
+ return true;
372
+ }
373
+ function validFolderName(name) {
374
+ return typeof name === 'string' && /^[A-Za-z0-9][A-Za-z0-9 _\-.()]{1,49}$/.test(name);
375
+ }
376
+ async function catalogAddRequest(name, file) {
377
+ let id = 0;
378
+ await catalogModify(file, (cat) => {
379
+ id = cat.nextRequestId;
380
+ cat.nextRequestId = id + 1;
381
+ if (!Array.isArray(cat.requests)) cat.requests = [];
382
+ cat.requests.push({ id, name: mbSubstr(String(name).trim(), 0, 50), status: 'pending', error: '', ts: nowIso() });
383
+ });
384
+ return id;
385
+ }
386
+ async function catalogClaimRequests(file) {
387
+ const out = [];
388
+ await catalogModify(file, (cat) => {
389
+ if (!cat.allow_create_folders || !Array.isArray(cat.requests)) return;
390
+ for (const r of cat.requests) {
391
+ if (r.status === 'pending') {
392
+ r.status = 'processing';
393
+ out.push({ id: parseInt(r.id, 10), name: r.name });
394
+ }
395
+ }
396
+ });
397
+ return out;
398
+ }
399
+ async function catalogFinishRequest(id, ok, folder, error, file) {
400
+ await catalogModify(file, (cat) => {
401
+ if (!Array.isArray(cat.requests)) return;
402
+ for (const r of cat.requests) {
403
+ if (parseInt(r.id, 10) !== parseInt(id, 10)) continue;
404
+ r.status = ok ? 'done' : 'error';
405
+ r.error = String(error || '');
406
+ r.ts = nowIso();
407
+ if (ok && folder && folder.name && folder.path) {
408
+ if (!Array.isArray(cat.folders)) cat.folders = [];
409
+ if (!cat.folders.some((f) => f && f.path === folder.path)) {
410
+ cat.folders.push({ name: folder.name, path: folder.path });
411
+ }
412
+ }
413
+ }
414
+ });
415
+ }
416
+ async function folderPathInCatalog(folder, file) {
417
+ const cat = await catalogRead(file);
418
+ for (const f of (cat.folders || [])) {
419
+ if (f && typeof f === 'object' && f.path === folder) return f.path;
420
+ if (typeof f === 'string' && f === folder) return f;
421
+ }
422
+ return null;
423
+ }
424
+ function modelInCatalogObj(cat, model) {
425
+ if ((cat.models || []).includes(model)) return true;
426
+ for (const prov of Object.keys(cat.models_full || {})) {
427
+ const list = cat.models_full[prov];
428
+ if (Array.isArray(list) && list.includes(model)) return true;
429
+ }
430
+ return false;
431
+ }
432
+ async function modelInCatalog(model, file) {
433
+ return modelInCatalogObj(await catalogRead(file), model);
434
+ }
435
+ // Ventana de contexto del modelo (0 si no se conoce).
436
+ function modelContextObj(cat, model) {
437
+ const v = (cat.models_ctx || {})[model];
438
+ return (typeof v === 'number' && v > 0) ? v : 0;
439
+ }
440
+ async function modelContext(model, file) {
441
+ return modelContextObj(await catalogRead(file), model);
442
+ }
443
+ function agentInCatalogObj(cat, agent) {
444
+ return (cat.agents || []).includes(agent);
445
+ }
446
+ async function agentInCatalog(agent, file) {
447
+ return agentInCatalogObj(await catalogRead(file), agent);
448
+ }
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // Registro de puentes
452
+ // ---------------------------------------------------------------------------
453
+ const BRIDGES_DEFAULT = () => ({ version: 1, bridges: {} });
454
+ function bridgesUpdate(fn) {
455
+ return jsonfile.update(paths.bridgesFile(), BRIDGES_DEFAULT(), (reg) => {
456
+ if (!reg.bridges || typeof reg.bridges !== 'object') reg.bridges = {};
457
+ return fn(reg);
458
+ });
459
+ }
460
+ async function bridgesMap() {
461
+ const data = await jsonfile.readJson(paths.bridgesFile(), BRIDGES_DEFAULT());
462
+ return (data && data.bridges && typeof data.bridges === 'object') ? data.bridges : {};
463
+ }
464
+ async function bridgeRegistryUpsert(id, name, busy = null, busySession = 0) {
465
+ if (typeof id !== 'string' || (id !== '' && !bridgeValidId(id))) return false;
466
+ if (id === '') {
467
+ const map = await bridgesMap();
468
+ const hasReal = Object.keys(map).some((k) => k !== '');
469
+ if (hasReal) return false;
470
+ if (name === '') name = 'Puente';
471
+ }
472
+ await bridgesUpdate((reg) => {
473
+ if (!reg.bridges[id] || typeof reg.bridges[id] !== 'object') reg.bridges[id] = { id };
474
+ const e = reg.bridges[id];
475
+ if (name !== '') e.name = name;
476
+ e.last_online_ts = nowIso();
477
+ if (busy !== null) {
478
+ const bs = parseInt(busySession, 10) || 0;
479
+ if (busy && bs > 0) { e.busy_session = bs; e.busy_since = nowIso(); }
480
+ else { e.busy_session = null; e.busy_since = null; }
481
+ }
482
+ });
483
+ return true;
484
+ }
485
+ async function bridgeRegistryGet(id) {
486
+ const map = await bridgesMap();
487
+ const e = map[id];
488
+ return (e && typeof e === 'object') ? e : null;
489
+ }
490
+ async function bridgeOnlineLive(id) {
491
+ const e = await bridgeRegistryGet(id);
492
+ if (!e) return false;
493
+ const ts = e.last_online_ts || '';
494
+ if (ts === '') return false;
495
+ return (Date.now() - Date.parse(ts)) <= 120000;
496
+ }
497
+ async function bridgeBusySession(id) {
498
+ const e = await bridgeRegistryGet(id);
499
+ if (!e) return null;
500
+ const sid = parseInt(e.busy_session, 10) || 0;
501
+ const ts = e.busy_since || '';
502
+ if (sid <= 0 || ts === '') return null;
503
+ if ((Date.now() - Date.parse(ts)) > 120000) return null;
504
+ return sid;
505
+ }
506
+ async function bridgesSummary() {
507
+ const map = await bridgesMap();
508
+ const out = [];
509
+ for (const id of Object.keys(map)) {
510
+ const e = map[id] || {};
511
+ out.push({
512
+ id,
513
+ name: (e.name && e.name !== '') ? e.name : (id === '' ? 'Puente' : id),
514
+ online: await bridgeOnlineLive(id),
515
+ busy_session: await bridgeBusySession(id),
516
+ });
517
+ }
518
+ out.sort((a, b) => a.id.localeCompare(b.id));
519
+ return out;
520
+ }
521
+ async function soleBridgeId() {
522
+ const map = await bridgesMap();
523
+ const keys = Object.keys(map);
524
+ return keys.length === 1 ? keys[0] : '';
525
+ }
526
+ async function bridgeLiveOverlay(cat, id) {
527
+ const e = await bridgeRegistryGet(id);
528
+ if (!e) return cat;
529
+ const out = { ...cat };
530
+ out.last_online_ts = e.last_online_ts || '';
531
+ out.busy_session = e.busy_session ? parseInt(e.busy_session, 10) : null;
532
+ out.busy_since = e.busy_since || null;
533
+ return out;
534
+ }
535
+ async function adoptSessionsToBridge(id) {
536
+ if (!bridgeValidId(id)) return;
537
+ await sessionsUpdate((data) => {
538
+ let changed = false;
539
+ for (const s of data.sessions) {
540
+ if (sessionBridge(s) === '') { s.bridge = id; changed = true; }
541
+ }
542
+ if (!changed) return false;
543
+ });
544
+ }
545
+ async function bridgeRegisterFirst(id, name) {
546
+ if (!bridgeValidId(id)) return false;
547
+ const map = await bridgesMap();
548
+ if (map[id]) return false;
549
+ const keys = Object.keys(map);
550
+ const onlyLegacy = keys.length === 1 && Object.prototype.hasOwnProperty.call(map, '');
551
+ if (keys.length > 0 && !onlyLegacy) return false;
552
+ const file = paths.bridgeCatalogFile(id);
553
+ if (!fssync.existsSync(file) && fssync.existsSync(paths.catalogFile())) {
554
+ try { fssync.copyFileSync(paths.catalogFile(), file); } catch (e) { /* nada */ }
555
+ }
556
+ await adoptSessionsToBridge(id);
557
+ await bridgesUpdate((reg) => {
558
+ if (Object.prototype.hasOwnProperty.call(reg.bridges, '')) delete reg.bridges[''];
559
+ if (!reg.bridges[id] || typeof reg.bridges[id] !== 'object') reg.bridges[id] = { id };
560
+ reg.bridges[id].name = name;
561
+ reg.bridges[id].last_online_ts = nowIso();
562
+ });
563
+ return true;
564
+ }
565
+ async function bridgeCanClaimSession(id, sess) {
566
+ const owner = sessionBridge(sess);
567
+ if (owner !== '') return owner === id;
568
+ if (!bridgeValidId(id)) return true;
569
+ const folder = sess.folder || '';
570
+ if (folder === '') return true;
571
+ const cat = await catalogRead(paths.bridgeCatalogFile(id));
572
+ for (const f of (cat.folders || [])) {
573
+ if (f && typeof f === 'object' && f.path === folder) return true;
574
+ if (typeof f === 'string' && f === folder) return true;
575
+ }
576
+ return false;
577
+ }
578
+
579
+ // ---------------------------------------------------------------------------
580
+ // Listado de sesiones con estado (working/waiting)
581
+ // ---------------------------------------------------------------------------
582
+ async function sessionsListFull() {
583
+ const data = await sessionsRead();
584
+ const cutoff = Date.now() - STALE_PROCESSING_SECONDS * 1000;
585
+ const out = [];
586
+ for (const sess of data.sessions) {
587
+ const owner = sessionBridge(sess);
588
+ const busyId = await bridgeBusySession(owner);
589
+ const live = await bridgeOnlineLive(owner);
590
+ let prev = '', prevRole = '';
591
+ let prevTs = sess.last_ts || sess.created_ts;
592
+ let hasPending = false, hasProcessing = false, hasStreaming = false;
593
+ const mdata = await messagesRead(sess.id);
594
+ let changed = false;
595
+ for (const msg of (mdata.messages || [])) {
596
+ const role = msg.role || '';
597
+ const status = msg.status || '';
598
+ if (role === 'assistant') {
599
+ if (status === 'streaming') {
600
+ if (Date.parse(msg.ts || 0) < cutoff) {
601
+ msg.status = 'done';
602
+ msg.canceled = true;
603
+ changed = true;
604
+ } else {
605
+ hasStreaming = true;
606
+ }
607
+ }
608
+ } else if (role === 'user') {
609
+ if (status === 'processing') {
610
+ if (Date.parse(msg.ts || 0) >= cutoff) hasProcessing = true;
611
+ } else if (status === 'pending') {
612
+ hasPending = true;
613
+ }
614
+ }
615
+ prev = msg.text || '';
616
+ prevRole = role;
617
+ prevTs = msg.ts || prevTs;
618
+ }
619
+ if (changed) await jsonfile.writeAtomic(paths.messagesFile(sess.id), mdata);
620
+ let state;
621
+ if (busyId !== null) {
622
+ state = parseInt(sess.id, 10) === busyId ? 'working' : ((hasPending || hasProcessing) ? 'waiting' : '');
623
+ } else {
624
+ state = (live && (hasStreaming || hasProcessing)) ? 'working' : (hasPending ? 'waiting' : '');
625
+ }
626
+ out.push({
627
+ ...sess,
628
+ state,
629
+ preview: mbSubstr(prev, 0, 120),
630
+ preview_role: prevRole,
631
+ last_ts: prevTs,
632
+ });
633
+ }
634
+ out.sort((a, b) => String(b.last_ts || '').localeCompare(String(a.last_ts || '')));
635
+ return out;
636
+ }
637
+
638
+ // ---------------------------------------------------------------------------
639
+ // Comandos (cola por puente)
640
+ // ---------------------------------------------------------------------------
641
+ function pruneCommands(cat, keep = 60) {
642
+ if (!Array.isArray(cat.commands)) return cat;
643
+ const finished = [];
644
+ cat.commands.forEach((c, i) => { if (c.status === 'done' || c.status === 'error') finished.push(i); });
645
+ const excess = finished.length - keep;
646
+ if (excess <= 0) return cat;
647
+ const drop = new Set(finished.slice(0, excess));
648
+ cat.commands = cat.commands.filter((c, i) => !drop.has(i));
649
+ return cat;
650
+ }
651
+ async function enqueueCommand(name, args, file) {
652
+ let id = 0;
653
+ await catalogModify(file, (cat) => {
654
+ id = parseInt(cat.nextCommandId, 10) || 1;
655
+ cat.nextCommandId = id + 1;
656
+ if (!Array.isArray(cat.commands)) cat.commands = [];
657
+ cat.commands.push({ id, name: String(name), args: (args || []).map(String), status: 'pending', result: null, error: '', ts: nowIso() });
658
+ pruneCommands(cat);
659
+ });
660
+ return id;
661
+ }
662
+ async function claimCommands(file) {
663
+ const out = [];
664
+ await catalogModify(file, (cat) => {
665
+ if (!Array.isArray(cat.commands)) return;
666
+ for (const c of cat.commands) {
667
+ if (c.status === 'pending') {
668
+ c.status = 'processing';
669
+ out.push({ id: parseInt(c.id, 10), name: c.name, args: c.args || [] });
670
+ }
671
+ }
672
+ pruneCommands(cat);
673
+ });
674
+ return out;
675
+ }
676
+ async function finishCommand(id, ok, text, error, file) {
677
+ let found = false;
678
+ await catalogModify(file, (cat) => {
679
+ if (!Array.isArray(cat.commands)) return;
680
+ for (const c of cat.commands) {
681
+ if (parseInt(c.id, 10) === parseInt(id, 10)) {
682
+ c.status = ok ? 'done' : 'error';
683
+ c.result = String(text);
684
+ c.error = String(error || '');
685
+ c.finished_ts = nowIso();
686
+ found = true;
687
+ break;
688
+ }
689
+ }
690
+ if (found) pruneCommands(cat);
691
+ });
692
+ return found;
693
+ }
694
+ async function pollPeekWork(cutoff, bridge) {
695
+ const sdata = await sessionsRead();
696
+ for (const sess of sdata.sessions) {
697
+ if (!(await bridgeCanClaimSession(bridge, sess))) continue;
698
+ const data = await messagesRead(sess.id);
699
+ for (const msg of (data.messages || [])) {
700
+ if (msg.role !== 'user') continue;
701
+ if (msg.status === 'pending' || (msg.status === 'processing' && Date.parse(msg.ts || 0) < cutoff)) {
702
+ return true;
703
+ }
704
+ }
705
+ }
706
+ const cat = await catalogRead(paths.bridgeCatalogFile(bridge));
707
+ if (cat.allow_create_folders && (cat.requests || []).some((r) => r.status === 'pending')) return true;
708
+ if ((cat.commands || []).some((c) => c.status === 'pending')) return true;
709
+ return false;
710
+ }
711
+
712
+ // ---------------------------------------------------------------------------
713
+ // Workspace (explorador de archivos)
714
+ // ---------------------------------------------------------------------------
715
+ async function catalogWorkspaceRoot(file) {
716
+ const cat = await catalogRead(file);
717
+ return cat.workspace ? String(cat.workspace) : '';
718
+ }
719
+ async function safeJoinWorkspace(rel, maxDepth = 4, file) {
720
+ const root = await catalogWorkspaceRoot(file);
721
+ if (root === '') return null;
722
+ let rootReal;
723
+ try { rootReal = await fs.realpath(root); } catch (e) { return null; }
724
+ let stat;
725
+ try { stat = await fs.stat(rootReal); } catch (e) { return null; }
726
+ if (!stat.isDirectory()) return null;
727
+ let r = String(rel || '').replace(/\\/g, '/').replace(/^\/+/, '');
728
+ if (r === '' || r === '.') return rootReal;
729
+ const parts = r.split('/').filter((x) => x !== '' && x !== '.' && x !== '..');
730
+ if (parts.length > maxDepth) return null;
731
+ const candidate = path.join(rootReal, ...parts);
732
+ let real;
733
+ try { real = await fs.realpath(candidate); } catch (e) { return null; }
734
+ const rootNorm = rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
735
+ if (real !== rootReal && !real.startsWith(rootNorm)) return null;
736
+ return real;
737
+ }
738
+ function fileTooBig(size, max = 524288) { return size > max; }
739
+ async function readTextFile(abs, max = 524288) {
740
+ let st;
741
+ try { st = await fs.stat(abs); } catch (e) { return null; }
742
+ if (!st.isFile() || st.size > max) return null;
743
+ const buf = await fs.readFile(abs);
744
+ if (buf.length === 0) return '';
745
+ const sample = buf.subarray(0, Math.min(4096, buf.length));
746
+ let nonPrintable = 0;
747
+ for (const b of sample) {
748
+ if (b === 0 || (b < 32 && b !== 9 && b !== 10 && b !== 13)) nonPrintable++;
749
+ }
750
+ if (nonPrintable / Math.max(1, sample.length) > 0.3) return null;
751
+ return buf.toString('utf8');
752
+ }
753
+ function fmtSize(n) {
754
+ if (n < 1024) return n + ' B';
755
+ if (n < 1024 * 1024) return (Math.round(n / 1024 * 10) / 10) + ' KB';
756
+ if (n < 1024 * 1024 * 1024) return (Math.round(n / (1024 * 1024) * 10) / 10) + ' MB';
757
+ return (Math.round(n / (1024 * 1024 * 1024) * 10) / 10) + ' GB';
758
+ }
759
+ function workspaceSkipName(name) {
760
+ if (!name || name[0] === '.') return true;
761
+ return ['node_modules', 'dist', 'build', '.next', '.cache', '.venv', '__pycache__', 'vendor', 'target', 'Pods', '.gradle', '.idea', '.vscode'].includes(name);
762
+ }
763
+ async function workspaceListEntries(base, absRoot) {
764
+ const rootNorm = absRoot.endsWith(path.sep) ? absRoot : absRoot + path.sep;
765
+ let items;
766
+ try { items = await fs.readdir(base, { withFileTypes: true }); } catch (e) { return []; }
767
+ const dirs = [], files = [];
768
+ for (const it of items) {
769
+ if (workspaceSkipName(it.name)) continue;
770
+ const full = path.join(base, it.name);
771
+ let st;
772
+ try { st = await fs.lstat(full); } catch (e) { continue; }
773
+ if (st.isSymbolicLink()) {
774
+ let real;
775
+ try { real = await fs.realpath(full); } catch (e) { continue; }
776
+ if (!real.startsWith(rootNorm) && real !== absRoot) continue;
777
+ let rst;
778
+ try { rst = await fs.stat(real); } catch (e) { continue; }
779
+ if (rst.isDirectory()) dirs.push({ name: it.name, type: 'dir', size: null, mtime: null });
780
+ continue;
781
+ }
782
+ if (st.isDirectory()) {
783
+ dirs.push({ name: it.name, type: 'dir', size: null, mtime: null });
784
+ } else if (st.isFile()) {
785
+ files.push({ name: it.name, type: 'file', size: st.size, mtime: Math.floor(st.mtimeMs / 1000) });
786
+ }
787
+ }
788
+ const cmp = (a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase());
789
+ dirs.sort(cmp);
790
+ files.sort(cmp);
791
+ return dirs.concat(files);
792
+ }
793
+ async function workspaceList(rel, file) {
794
+ const root = await catalogWorkspaceRoot(file);
795
+ let absRoot;
796
+ try { absRoot = await fs.realpath(root); } catch (e) { return null; }
797
+ let base;
798
+ if (rel === '' || rel === '.') base = absRoot;
799
+ else base = await safeJoinWorkspace(rel, 8, file);
800
+ if (!base) return null;
801
+ let st;
802
+ try { st = await fs.stat(base); } catch (e) { return null; }
803
+ if (!st.isDirectory()) return null;
804
+ const entries = await workspaceListEntries(base, absRoot);
805
+ const relOut = base === absRoot ? '' : base.slice(absRoot.length).replace(/\\/g, '/').replace(/^\/+/, '');
806
+ return { path: relOut, entries };
807
+ }
808
+
809
+ // ---------------------------------------------------------------------------
810
+ // Busqueda
811
+ // ---------------------------------------------------------------------------
812
+ async function searchIndexBuild(query, maxSnippets = 3) {
813
+ const q = String(query || '').trim();
814
+ if (q === '' || Array.from(q).length < 2) return [];
815
+ const needle = q.toLowerCase();
816
+ const out = [];
817
+ const sdata = await sessionsRead();
818
+ const sessions = {};
819
+ for (const s of sdata.sessions) sessions[parseInt(s.id, 10)] = s;
820
+ let entries;
821
+ try { entries = await fs.readdir(paths.dataDir()); } catch (e) { return []; }
822
+ for (const f of entries) {
823
+ const m = /^messages-(\d+)\.json$/.exec(f);
824
+ if (!m) continue;
825
+ const sid = parseInt(m[1], 10);
826
+ const data = await jsonfile.readJson(path.join(paths.dataDir(), f), { messages: [] });
827
+ const matches = [];
828
+ let count = 0;
829
+ for (const msg of (data.messages || [])) {
830
+ const text = String(msg.text || '');
831
+ if (text === '') continue;
832
+ const hay = text.toLowerCase();
833
+ let pos = 0;
834
+ while ((pos = hay.indexOf(needle, pos)) >= 0) {
835
+ count++;
836
+ if (matches.length < maxSnippets) {
837
+ const start = Math.max(0, pos - 40);
838
+ let snippet = mbSubstr(text, start, 140);
839
+ if (start > 0) snippet = '\u2026' + snippet;
840
+ matches.push({ mid: parseInt(msg.id, 10) || 0, role: msg.role || '', snippet });
841
+ }
842
+ pos += needle.length;
843
+ }
844
+ }
845
+ if (count > 0) {
846
+ out.push({ session_id: sid, name: sessions[sid] ? sessions[sid].name : ('Chat ' + sid), count, matches });
847
+ }
848
+ }
849
+ return out;
850
+ }
851
+
852
+ // ---------------------------------------------------------------------------
853
+ // Temas
854
+ // ---------------------------------------------------------------------------
855
+ function themesIndex() {
856
+ const file = path.join(__dirname, '..', 'web', 'assets', 'themes', 'index.json');
857
+ const fallback = [
858
+ { slug: 'terminal', label: 'Terminal', sw: ['#0a0d12', '#3fb950'] },
859
+ { slug: 'default', label: 'Oscuro', sw: ['#0f172a', '#0ea5e9'] },
860
+ ];
861
+ try {
862
+ const data = JSON.parse(fssync.readFileSync(file, 'utf8'));
863
+ if (!data || !Array.isArray(data.themes)) return fallback;
864
+ const list = [];
865
+ for (const t of data.themes) {
866
+ if (!t || typeof t.slug !== 'string' || !/^[a-z\-]{1,40}$/.test(t.slug)) continue;
867
+ const entry = { slug: t.slug, label: typeof t.label === 'string' ? t.label : t.slug };
868
+ if (Array.isArray(t.sw) && t.sw.length === 2) entry.sw = [t.sw[0], t.sw[1]];
869
+ list.push(entry);
870
+ }
871
+ return list.length ? list : fallback;
872
+ } catch (e) {
873
+ return fallback;
874
+ }
875
+ }
876
+ function themesKnown() {
877
+ return themesIndex().map((t) => t.slug);
878
+ }
879
+
880
+ // ---------------------------------------------------------------------------
881
+ // Lista de sesiones opencode (chats vinculados)
882
+ // ---------------------------------------------------------------------------
883
+ async function ocSessionsList() {
884
+ const data = await sessionsRead();
885
+ const found = [];
886
+ for (const sess of data.sessions) {
887
+ const oc = sess.opencode_session || null;
888
+ found.push({
889
+ session_id: parseInt(sess.id, 10),
890
+ name: sess.name || ('Chat ' + sess.id),
891
+ folder: sess.folder || '',
892
+ model: sess.model || '',
893
+ agent: sess.agent || 'build',
894
+ opencode_session: oc,
895
+ has_opencode: !!oc,
896
+ });
897
+ }
898
+ return found;
899
+ }
900
+
901
+ // ---------------------------------------------------------------------------
902
+ async function touchFile(file) {
903
+ try { await fs.writeFile(file, '', { flag: 'a' }); } catch (e) { /* nada */ }
904
+ }
905
+
906
+ module.exports = {
907
+ STALE_PROCESSING_SECONDS,
908
+ nowIso, md5, mbSubstr, normFolder,
909
+ // sesiones
910
+ sessionsRead, sessionsUpdate, findSessionRef, getSession, addSession, updateSession,
911
+ touchSession, sessionRename, sessionHasDefaultName, sessionTitleFromPrompt, deleteSession,
912
+ sessionImport, sessionTokens, sessionsListFull, sessionBridge, bridgeValidId,
913
+ // mensajes
914
+ messagesRead, messagesUpdate, messagesHealStaleStreaming, addMessage, messageAgentOf,
915
+ // catalogo
916
+ catalogDefault, catalogRead, catalogModify, catalogVersion, syncCatalog,
917
+ validFolderName, catalogAddRequest, catalogClaimRequests, catalogFinishRequest,
918
+ folderPathInCatalog, modelInCatalog, agentInCatalog, catalogWorkspaceRoot,
919
+ modelContext, modelContextObj,
920
+ // puentes
921
+ bridgesMap, bridgeRegistryUpsert, bridgeRegistryGet, bridgeOnlineLive, bridgeBusySession,
922
+ bridgesSummary, soleBridgeId, bridgeLiveOverlay, adoptSessionsToBridge, bridgeRegisterFirst,
923
+ bridgeCanClaimSession,
924
+ // comandos
925
+ enqueueCommand, claimCommands, finishCommand, pruneCommands, pollPeekWork,
926
+ // workspace
927
+ safeJoinWorkspace, readTextFile, fileTooBig, fmtSize, workspaceList, workspaceListEntries,
928
+ // busqueda / temas / opencode
929
+ searchIndexBuild, themesIndex, themesKnown, ocSessionsList,
930
+ };