@danieltmn/openbridge 0.6.2 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +10 -0
- package/package.json +1 -1
- package/src/bridge/bridge.js +81 -8
- package/src/store/index.js +24 -2
- package/src/web/routes.js +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,24 @@ Todos los cambios relevantes de OpenBridge. Formato basado en
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/) y
|
|
5
5
|
[Versionado Semantico](https://semver.org/lang/es/).
|
|
6
6
|
|
|
7
|
+
## [0.6.3] - 2026-09-15
|
|
8
|
+
|
|
9
|
+
### Agregado
|
|
10
|
+
|
|
11
|
+
- **Sincronizacion casi en tiempo real**: el puente vigila la base de opencode
|
|
12
|
+
(`opencode.db`/`-wal`) y dispara un barrido tras ~8 s de calma, en vez de
|
|
13
|
+
esperar 15 min (que queda como respaldo cada 3 min).
|
|
14
|
+
|
|
15
|
+
### Corregido
|
|
16
|
+
|
|
17
|
+
- El barrido ya **no se pierde** cuando opencode esta ocupado: se marca pendiente
|
|
18
|
+
y corre al terminar el mensaje (antes se salteaba el tick entero).
|
|
19
|
+
- **Mensajes del TUI en sesiones web**: las sesiones vinculadas a la web ya no
|
|
20
|
+
quedan solo con refresco de tokens; se importan los mensajes nuevos.
|
|
21
|
+
- **Sin duplicados**: el merge deduplica por el id de mensaje de opencode
|
|
22
|
+
(`oc_msg`) y "adopta" el mensaje optimista que publico la web. Paridad en el
|
|
23
|
+
hub Node (`store.sessionImport`) y en el PHP.
|
|
24
|
+
|
|
7
25
|
## [0.6.2] - 2026-09-14
|
|
8
26
|
|
|
9
27
|
### Corregido
|
package/README.md
CHANGED
|
@@ -145,6 +145,16 @@ Ejemplo concreto:
|
|
|
145
145
|
El puente remoto solo necesita salida a internet hacia el hub; no abre puertos ni
|
|
146
146
|
túnel propio. Para que arranque solo en cada PC: `openbridge autostart install`.
|
|
147
147
|
|
|
148
|
+
## Sincronización (opencode es la fuente)
|
|
149
|
+
|
|
150
|
+
La conversación vive en **opencode** (tu PC); la web la refleja. Los mensajes que
|
|
151
|
+
pasás por la web se guardan al instante; los que hacés en el **TUI de opencode**
|
|
152
|
+
se importan por un barrido que ahora corre **casi en tiempo real**: el puente
|
|
153
|
+
vigila la base de opencode (`opencode.db`) y sincroniza a los pocos segundos
|
|
154
|
+
(respaldo cada 3 min). Si la sesión empezó en la web y la seguís en el TUI (o al
|
|
155
|
+
revés), los mensajes nuevos se agregan sin duplicarse: el merge identifica cada
|
|
156
|
+
mensaje por su id de opencode (`oc_msg`).
|
|
157
|
+
|
|
148
158
|
## Hub en hosting PHP (URL fija, sin túnel)
|
|
149
159
|
|
|
150
160
|
Además del hub Node, OpenBridge puede correr su hub en un **hosting PHP** (cPanel),
|
package/package.json
CHANGED
package/src/bridge/bridge.js
CHANGED
|
@@ -540,6 +540,7 @@ function streamCli(args, opts, onPartial) {
|
|
|
540
540
|
let settled = false;
|
|
541
541
|
let timer = null;
|
|
542
542
|
let sessionID = null;
|
|
543
|
+
let assistantMsgID = null;
|
|
543
544
|
let errorText = '';
|
|
544
545
|
const texts = [];
|
|
545
546
|
const reasons = [];
|
|
@@ -577,6 +578,7 @@ function streamCli(args, opts, onPartial) {
|
|
|
577
578
|
killed: false,
|
|
578
579
|
canceled: false,
|
|
579
580
|
sessionID: sessionID,
|
|
581
|
+
assistantMsgID: assistantMsgID,
|
|
580
582
|
errorText: errorText,
|
|
581
583
|
}, extra || {});
|
|
582
584
|
}
|
|
@@ -636,6 +638,9 @@ function streamCli(args, opts, onPartial) {
|
|
|
636
638
|
if (ev.part && typeof ev.part.text === 'string') {
|
|
637
639
|
if (ev.type === 'text') texts.push(ev.part.text);
|
|
638
640
|
else if (ev.type === 'reasoning') reasons.push(ev.part.text);
|
|
641
|
+
// Id del mensaje de opencode (msg_...): identifica la respuesta
|
|
642
|
+
// para que el hub no la duplique al importar del TUI.
|
|
643
|
+
if (typeof ev.messageID === 'string' && ev.messageID) assistantMsgID = ev.messageID;
|
|
639
644
|
}
|
|
640
645
|
if (ev.type === 'error') {
|
|
641
646
|
errorText = errorMessage(ev);
|
|
@@ -1369,6 +1374,7 @@ async function exportAndImport(folder, sess, target) {
|
|
|
1369
1374
|
ts: ts,
|
|
1370
1375
|
agent: role === 'assistant' ? lastAgent : (info.agent || ''),
|
|
1371
1376
|
};
|
|
1377
|
+
if (info.id) out.oc_msg = String(info.id);
|
|
1372
1378
|
if (reasoning) out.reasoning = reasoning;
|
|
1373
1379
|
msgs.push(out);
|
|
1374
1380
|
}
|
|
@@ -1447,18 +1453,27 @@ async function sweepTarget(t, opts) {
|
|
|
1447
1453
|
if (!sessions.length) continue;
|
|
1448
1454
|
const fstate = target.folders[folder] || (target.folders[folder] = {});
|
|
1449
1455
|
for (const s of sessions) {
|
|
1450
|
-
|
|
1456
|
+
// Sin cambios desde el último barrido (el marcador es el "updated"
|
|
1457
|
+
// de la lista, con precision de minuto). Con `force` (watcher) se
|
|
1458
|
+
// re-exportan las sesiones tocadas en los últimos 30 min para no
|
|
1459
|
+
// perder cambios dentro del mismo minuto.
|
|
1460
|
+
const unchanged = fstate[s.id] === s.updated;
|
|
1461
|
+
const forceRecent = !!(opts && opts.force) && listUpdatedRecent(s.updated, 30 * 60 * 1000);
|
|
1462
|
+
if (unchanged && !forceRecent) { seen.add(s.id); continue; }
|
|
1451
1463
|
if (seen.has(s.id)) { fstate[s.id] = s.updated; continue; } // ya atendida en esta pasada
|
|
1452
1464
|
seen.add(s.id);
|
|
1453
1465
|
if (knownOc.includes(s.id)) {
|
|
1454
|
-
// Chat web vinculado:
|
|
1455
|
-
//
|
|
1466
|
+
// Chat web vinculado: el hub ya tiene lo que publicó la web,
|
|
1467
|
+
// pero el TUI puede haber agregado mensajes. Se importa igual
|
|
1468
|
+
// (el merge deduplica por oc_msg) y el hub lo marca `importada`,
|
|
1469
|
+
// con lo que sale de known_oc y sigue sincronizando.
|
|
1456
1470
|
try {
|
|
1457
|
-
await
|
|
1471
|
+
await exportAndImport(folder, s, t);
|
|
1458
1472
|
fstate[s.id] = s.updated;
|
|
1459
1473
|
saveSyncState();
|
|
1474
|
+
imported++;
|
|
1460
1475
|
} catch (e) {
|
|
1461
|
-
log('aviso: no pude
|
|
1476
|
+
log('aviso: no pude sincronizar ' + s.id + ': ' + e.message);
|
|
1462
1477
|
}
|
|
1463
1478
|
continue;
|
|
1464
1479
|
}
|
|
@@ -1496,6 +1511,61 @@ async function sweepTarget(t, opts) {
|
|
|
1496
1511
|
return imported;
|
|
1497
1512
|
}
|
|
1498
1513
|
|
|
1514
|
+
// ---------------------------------------------------------------------------
|
|
1515
|
+
// Sincronizacion casi en tiempo real. opencode persiste en su base SQLite
|
|
1516
|
+
// (opencode.db / -wal / -shm); un watcher liviano mira mtime+tamano y dispara
|
|
1517
|
+
// un barrido tras un periodo de calma, en vez de esperar 15 min.
|
|
1518
|
+
// ---------------------------------------------------------------------------
|
|
1519
|
+
let sweepPending = false;
|
|
1520
|
+
let lastSweepAt = 0;
|
|
1521
|
+
|
|
1522
|
+
function scheduleSweep(reason) {
|
|
1523
|
+
if (busy || sweepRunning) { sweepPending = true; return; }
|
|
1524
|
+
if (Date.now() - lastSweepAt < 30000) { sweepPending = true; return; }
|
|
1525
|
+
lastSweepAt = Date.now();
|
|
1526
|
+
syncSessions({ silent: true, force: reason === 'watcher' }).catch(handleError);
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
function opencodeDataDirs() {
|
|
1530
|
+
const dirs = [];
|
|
1531
|
+
if (process.env.XDG_DATA_HOME) dirs.push(path.join(process.env.XDG_DATA_HOME, 'opencode'));
|
|
1532
|
+
dirs.push(path.join(os.homedir(), '.local', 'share', 'opencode'));
|
|
1533
|
+
dirs.push(path.join(os.homedir(), 'AppData', 'Local', 'opencode'));
|
|
1534
|
+
return dirs;
|
|
1535
|
+
}
|
|
1536
|
+
function opencodeDbSignature() {
|
|
1537
|
+
let sig = '';
|
|
1538
|
+
for (const d of opencodeDataDirs()) {
|
|
1539
|
+
for (const f of ['opencode.db', 'opencode.db-wal', 'opencode.db-shm']) {
|
|
1540
|
+
try {
|
|
1541
|
+
const st = fs.statSync(path.join(d, f));
|
|
1542
|
+
sig += f + ':' + Math.round(st.mtimeMs) + ':' + st.size + ';';
|
|
1543
|
+
} catch (e) { /* no existe en esa ruta */ }
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
return sig;
|
|
1547
|
+
}
|
|
1548
|
+
// `updated` de `session list` es "HH:MM · D/M/YYYY" (precision de minuto).
|
|
1549
|
+
function listUpdatedRecent(updated, windowMs) {
|
|
1550
|
+
const m = String(updated || '').match(/^(\d{1,2}):(\d{2})\s*·\s*(\d{1,2})\/(\d{1,2})\/(\d{4})/);
|
|
1551
|
+
if (!m) return false;
|
|
1552
|
+
const ts = new Date(Number(m[5]), Number(m[4]) - 1, Number(m[3]), Number(m[1]), Number(m[2])).getTime();
|
|
1553
|
+
return ts > 0 && (Date.now() - ts) <= windowMs;
|
|
1554
|
+
}
|
|
1555
|
+
function startSyncWatcher() {
|
|
1556
|
+
let last = opencodeDbSignature();
|
|
1557
|
+
let timer = null;
|
|
1558
|
+
setInterval(() => {
|
|
1559
|
+
const sig = opencodeDbSignature();
|
|
1560
|
+
if (sig === '' || sig === last) return;
|
|
1561
|
+
last = sig;
|
|
1562
|
+
if (timer) clearTimeout(timer);
|
|
1563
|
+
timer = setTimeout(() => scheduleSweep('watcher'), 8000);
|
|
1564
|
+
}, 4000);
|
|
1565
|
+
// Respaldo por si el watcher no ve el cambio (otra ruta o filesystem).
|
|
1566
|
+
setInterval(() => scheduleSweep('periodico'), 3 * 60 * 1000);
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1499
1569
|
// ---------------------------------------------------------------------------
|
|
1500
1570
|
// Archivos: listado y lectura (read-only) resueltos por el puente,
|
|
1501
1571
|
// siempre dentro del workspace y sin recorrer basura (node_modules, .git...).
|
|
@@ -2321,6 +2391,7 @@ async function tick(opts) {
|
|
|
2321
2391
|
text: r.text,
|
|
2322
2392
|
reasoning: r.reasoning || '',
|
|
2323
2393
|
opencode_session: r.opencodeSession || '',
|
|
2394
|
+
oc_msg: r.assistantMsgID || '',
|
|
2324
2395
|
canceled: !!r.canceled,
|
|
2325
2396
|
};
|
|
2326
2397
|
try {
|
|
@@ -2354,6 +2425,9 @@ async function tick(opts) {
|
|
|
2354
2425
|
activeMsgTarget = null;
|
|
2355
2426
|
busy = false;
|
|
2356
2427
|
for (const t of involvedTargets) notifyBusy(t, null);
|
|
2428
|
+
// El watcher pudo pedir un barrido mientras opencode estaba ocupado:
|
|
2429
|
+
// se corre ahora que terminó (en vez de perderse).
|
|
2430
|
+
if (sweepPending) { sweepPending = false; scheduleSweep('pendiente'); }
|
|
2357
2431
|
}
|
|
2358
2432
|
return 'trabajo';
|
|
2359
2433
|
}
|
|
@@ -2416,8 +2490,7 @@ process.on('SIGINT', () => {
|
|
|
2416
2490
|
// Historial único: primer poll hecho (lastKnownOc cargado), importamos
|
|
2417
2491
|
// en segundo plano y repetimos cada 15 minutos.
|
|
2418
2492
|
syncSessions().catch(handleError);
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
}, 15 * 60 * 1000);
|
|
2493
|
+
// Watcher de la base de opencode: sincroniza en segundos, no cada 15 min.
|
|
2494
|
+
startSyncWatcher();
|
|
2422
2495
|
scheduleTick(POLL_QUICK_MS);
|
|
2423
2496
|
})();
|
package/src/store/index.js
CHANGED
|
@@ -198,9 +198,14 @@ async function sessionImport(ocSession, name, folder, model, agent, updatedTs, m
|
|
|
198
198
|
let added = 0;
|
|
199
199
|
await jsonfile.update(paths.messagesFile(sid), { messages: [], nextId: 1 }, (data) => {
|
|
200
200
|
const known = {};
|
|
201
|
-
|
|
201
|
+
const knownOc = {};
|
|
202
|
+
const byText = {};
|
|
203
|
+
data.messages.forEach((m, i) => {
|
|
202
204
|
known[String(m.role || '') + '|' + String(m.ts || '') + '|' + md5(mbSubstr(m.text || '', 0, 400))] = true;
|
|
203
|
-
|
|
205
|
+
const ocId = String(m.oc_msg || '').trim();
|
|
206
|
+
if (ocId !== '') knownOc[ocId] = true;
|
|
207
|
+
else byText[String(m.role || '') + '|' + md5(mbSubstr(String(m.text || '').trim(), 0, 400))] = i;
|
|
208
|
+
});
|
|
204
209
|
for (const m of (Array.isArray(messages) ? messages : [])) {
|
|
205
210
|
if (!m || typeof m !== 'object') continue;
|
|
206
211
|
const role = String(m.role || '');
|
|
@@ -210,12 +215,29 @@ async function sessionImport(ocSession, name, folder, model, agent, updatedTs, m
|
|
|
210
215
|
if (Array.from(text).length > 50000) text = mbSubstr(text, 0, 50000);
|
|
211
216
|
let ts = String(m.ts || '');
|
|
212
217
|
if (ts === '' || isNaN(Date.parse(ts))) ts = nowIso();
|
|
218
|
+
const ocMsg = String(m.oc_msg || '').trim();
|
|
219
|
+
// Identidad de opencode: si ya esta, es el mismo mensaje.
|
|
220
|
+
if (ocMsg !== '' && knownOc[ocMsg]) continue;
|
|
213
221
|
const key = role + '|' + ts + '|' + md5(mbSubstr(text, 0, 400));
|
|
214
222
|
if (known[key]) continue;
|
|
223
|
+
// Adopcion: llego de opencode (con oc_msg) y existe uno de la web
|
|
224
|
+
// con el mismo rol+texto y sin id. Se le asigna el id.
|
|
225
|
+
if (ocMsg !== '') {
|
|
226
|
+
const tk = role + '|' + md5(mbSubstr(text, 0, 400));
|
|
227
|
+
if (byText[tk] !== undefined) {
|
|
228
|
+
data.messages[byText[tk]].oc_msg = ocMsg;
|
|
229
|
+
knownOc[ocMsg] = true;
|
|
230
|
+
known[key] = true;
|
|
231
|
+
delete byText[tk];
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
215
235
|
known[key] = true;
|
|
236
|
+
if (ocMsg !== '') knownOc[ocMsg] = true;
|
|
216
237
|
const id = data.nextId;
|
|
217
238
|
data.nextId = id + 1;
|
|
218
239
|
const msg = { id, role, text, ts, status: 'done' };
|
|
240
|
+
if (ocMsg !== '') msg.oc_msg = ocMsg;
|
|
219
241
|
const reasoning = String(m.reasoning || '').trim();
|
|
220
242
|
if (reasoning !== '') msg.reasoning = Array.from(reasoning).length > 50000 ? mbSubstr(reasoning, 0, 50000) : reasoning;
|
|
221
243
|
if (m.agent) msg.agent = mbSubstr(String(m.agent), 0, 40);
|
package/src/web/routes.js
CHANGED
|
@@ -627,6 +627,7 @@ async function handleApi(ctx) {
|
|
|
627
627
|
const userId = parseInt(body.user_id, 10) || 0;
|
|
628
628
|
const text = String(body.text || '').trim();
|
|
629
629
|
const reasoning = String(body.reasoning || '').trim();
|
|
630
|
+
const ocMsg = String(body.oc_msg || '').trim();
|
|
630
631
|
if (sid <= 0 || userId <= 0) return ok({ ok: false, error: 'session_id y user_id son obligatorios' }, 400);
|
|
631
632
|
if (Array.from(text).length > 50000 || Array.from(reasoning).length > 50000) return ok({ ok: false, error: 'Respuesta demasiado larga' }, 400);
|
|
632
633
|
if (text === '' && reasoning === '') return ok({ ok: false, error: 'Nada para publicar' }, 400);
|
|
@@ -639,6 +640,7 @@ async function handleApi(ctx) {
|
|
|
639
640
|
if (draftIdx >= 0) {
|
|
640
641
|
data.messages[draftIdx].text = text;
|
|
641
642
|
if (reasoning !== '') data.messages[draftIdx].reasoning = reasoning;
|
|
643
|
+
if (ocMsg !== '') data.messages[draftIdx].oc_msg = ocMsg;
|
|
642
644
|
data.messages[draftIdx].ts = store.nowIso();
|
|
643
645
|
} else {
|
|
644
646
|
const aid = data.nextId;
|
|
@@ -648,6 +650,7 @@ async function handleApi(ctx) {
|
|
|
648
650
|
draft_for: userId, agent: store.messageAgentOf(data, userId),
|
|
649
651
|
};
|
|
650
652
|
if (reasoning !== '') draft.reasoning = reasoning;
|
|
653
|
+
if (ocMsg !== '') draft.oc_msg = ocMsg;
|
|
651
654
|
data.messages.push(draft);
|
|
652
655
|
}
|
|
653
656
|
});
|
|
@@ -664,6 +667,7 @@ async function handleApi(ctx) {
|
|
|
664
667
|
if (!exists) return ok({ ok: false, error: 'Sesion no encontrada' }, 404);
|
|
665
668
|
const oc = body.opencode_session ? String(body.opencode_session).trim() : '';
|
|
666
669
|
const reasoning = String(body.reasoning || '').trim();
|
|
670
|
+
const ocMsg = String(body.oc_msg || '').trim();
|
|
667
671
|
const clearSession = !!body.clear_session;
|
|
668
672
|
const canceled = !!body.canceled;
|
|
669
673
|
await store.sessionsUpdate((sd) => {
|
|
@@ -706,6 +710,7 @@ async function handleApi(ctx) {
|
|
|
706
710
|
m.answered_ts = store.nowIso();
|
|
707
711
|
m.ts = store.nowIso();
|
|
708
712
|
if (reasoning !== '') m.reasoning = reasoning; else delete m.reasoning;
|
|
713
|
+
if (ocMsg !== '') m.oc_msg = ocMsg;
|
|
709
714
|
if (canceled) m.canceled = true; else delete m.canceled;
|
|
710
715
|
if (!m.agent) m.agent = store.messageAgentOf(data, userId, (sess && sess.agent) || '');
|
|
711
716
|
if (author && !m.author) m.author = author;
|
|
@@ -715,6 +720,7 @@ async function handleApi(ctx) {
|
|
|
715
720
|
data.nextId = aid + 1;
|
|
716
721
|
const nm = { id: aid, role: 'assistant', text, ts: store.nowIso(), status: 'done', agent: store.messageAgentOf(data, userId, (sess && sess.agent) || '') };
|
|
717
722
|
if (reasoning !== '') nm.reasoning = reasoning;
|
|
723
|
+
if (ocMsg !== '') nm.oc_msg = ocMsg;
|
|
718
724
|
if (canceled) nm.canceled = true;
|
|
719
725
|
if (author) nm.author = author;
|
|
720
726
|
data.messages.push(nm);
|