@danieltmn/openbridge 0.1.0 → 0.2.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@ 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.2.0] - 2026-09-13
8
+
9
+ ### Cambios
10
+
11
+ - web: muestra el costo acumulado por sesion y total
12
+ - cli: QR de la URL (init/status/server y comando qr)
13
+ - web: dictado por voz en el compositor (Web Speech API)
14
+ - release: exporta logica de version/changelog y agrega tests
15
+ - release: proceso privado con guard de dueno, semver y dist-tags
16
+ - UI: porcentaje de contexto, boton ver archivos y badge plan; refresco de tokens al responder
17
+
7
18
  ## [0.1.0] - 2026-09-11
8
19
 
9
20
  Primera version publicada. Paquete npm **`@danieltmn/openbridge`** y repo
package/README.md CHANGED
@@ -71,6 +71,7 @@ primer plano usá `openbridge server --stream`; para detenerlo, `openbridge stop
71
71
  | `openbridge server` | Arranca app + puente + túnel en **segundo plano** y muestra el estado (`--stream` = primer plano) |
72
72
  | `openbridge stop` | Detiene el server y su árbol de procesos |
73
73
  | `openbridge status` | Estado, URL, puente en línea y chats |
74
+ | `openbridge qr` | Muestra la URL (pública o local) como QR para escanear desde el celular |
74
75
  | `openbridge logs` | Logs (`--follow`, `--server`, `--bridge`) |
75
76
  | `openbridge bridge` | Corre **solo** el puente (`--api --token --id --name`) |
76
77
  | `openbridge import` | Trae `data/` de OpenConex (`<data-dir> [--force]`) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/openbridge",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Tu opencode en el celular, sin hosting: corre la app, el puente y un tunel publico desde tu PC.",
5
5
  "type": "commonjs",
6
6
  "author": "tamnora",
@@ -43,8 +43,16 @@
43
43
  "scripts": {
44
44
  "start": "node bin/openbridge.js server",
45
45
  "test": "node --test",
46
+ "release": "node scripts/release.mjs",
47
+ "release:dry": "node scripts/release.mjs --dry-run",
46
48
  "prepublishOnly": "npm test"
47
49
  },
50
+ "release": {
51
+ "npmOwner": "danieltmn",
52
+ "gitRemote": "tamnora/openbridge",
53
+ "gitOwner": "tamnora",
54
+ "branch": "master"
55
+ },
48
56
  "publishConfig": {
49
57
  "access": "public"
50
58
  },
@@ -2194,6 +2194,14 @@ async function tick(opts) {
2194
2194
  } catch (e) {
2195
2195
  log('error al publicar la respuesta #' + m.id + ': ' + e.message);
2196
2196
  }
2197
+ // Refresca tokens/costo enseguida: el barrido solo corre cada 15 min.
2198
+ if (r.opencodeSession && m._t) {
2199
+ try {
2200
+ await refreshTokens(path.resolve(m.session.folder || ''), { id: r.opencodeSession }, m._t);
2201
+ } catch (e) {
2202
+ log('aviso: no pude refrescar tokens de ' + r.opencodeSession + ': ' + e.message);
2203
+ }
2204
+ }
2197
2205
  }
2198
2206
  for (const f of folders) {
2199
2207
  await handleFolderRequest(f);
package/src/cli.js CHANGED
@@ -10,6 +10,7 @@ const config = require('./config');
10
10
  const store = require('./store');
11
11
  const auth = require('./auth');
12
12
  const push = require('./push');
13
+ const qr = require('./qr');
13
14
  const web = require('./web/server');
14
15
  const tunnel = require('./tunnel');
15
16
  const log = require('./log');
@@ -117,6 +118,15 @@ function clearRuntime() {
117
118
  }
118
119
  }
119
120
 
121
+ // Muestra la URL como QR en la consola (para abrirla en el celular).
122
+ // Devuelve false si la URL es demasiado larga para el QR.
123
+ function printQr(url) {
124
+ const art = qr.qrTerminal(url, 2);
125
+ if (!art) return false;
126
+ console.log(art);
127
+ return true;
128
+ }
129
+
120
130
  // Detiene el server en ejecucion (si lo hay) matando server + hijos. Devuelve
121
131
  // true si habia uno. Se usa antes de reconfigurar para no dejar un proceso con
122
132
  // la config vieja en memoria (p.ej. la contrasena).
@@ -255,6 +265,9 @@ async function cmdInit(argv) {
255
265
  console.log(' carpetas : ' + folders.length + ' en folders.json');
256
266
  if (generated) console.log(' -> guarda esta contrasena: no se vuelve a mostrar.');
257
267
  console.log('');
268
+ console.log(' QR local (misma PC):');
269
+ printQr('http://127.0.0.1:' + port + '/chat.php');
270
+ console.log('');
258
271
  console.log('Siguiente paso: openbridge server');
259
272
  return 0;
260
273
  }
@@ -439,6 +452,12 @@ async function printStatus() {
439
452
  const sessions = await store.sessionsListFull();
440
453
  console.log(' chats : ' + sessions.length);
441
454
  } catch (e) { /* sin datos todavia */ }
455
+ const target = rt.publicUrl || rt.localUrl;
456
+ if (target) {
457
+ console.log('');
458
+ console.log(rt.publicUrl ? ' escanea para abrir en el celular:' : ' QR local (misma PC):');
459
+ if (!printQr(target)) console.log(' (URL demasiado larga para el QR; usa el link de arriba)');
460
+ }
442
461
  return true;
443
462
  }
444
463
 
@@ -447,6 +466,28 @@ async function cmdStatus() {
447
466
  return 0;
448
467
  }
449
468
 
469
+ // Muestra la URL como QR para escanear desde el celular.
470
+ async function cmdQr() {
471
+ const rt = readRuntime();
472
+ if (rt && pidAlive(rt.pid) && (rt.publicUrl || rt.localUrl)) {
473
+ const target = rt.publicUrl || rt.localUrl;
474
+ console.log((rt.publicUrl ? 'URL publica' : 'URL local') + ': ' + target);
475
+ console.log('');
476
+ if (!printQr(target)) { console.log('URL demasiado larga para el QR.'); return 1; }
477
+ return 0;
478
+ }
479
+ if (!paths.exists()) {
480
+ console.error('No hay configuracion. Corre primero: openbridge init');
481
+ return 1;
482
+ }
483
+ const app = config.readApp();
484
+ const url = 'http://' + (app.host || '127.0.0.1') + ':' + (app.port || 8799) + '/chat.php';
485
+ console.log('URL local (el server no esta corriendo): ' + url);
486
+ console.log('');
487
+ if (!printQr(url)) { console.log('URL demasiado larga para el QR.'); return 1; }
488
+ return 0;
489
+ }
490
+
450
491
  async function cmdLogs(argv) {
451
492
  const { flags } = parseArgs(argv);
452
493
  const n = parseInt(flags.n || '40', 10) || 40;
@@ -743,6 +784,7 @@ function usage() {
743
784
  console.log(' passwd Cambia la contrasena de acceso (--password <clave>)');
744
785
  console.log(' stop Detiene el server en segundo plano');
745
786
  console.log(' status Estado del server, puente y chats');
787
+ console.log(' qr Muestra la URL (publica o local) como QR para el celular');
746
788
  console.log(' logs Ultimas lineas de los logs (--follow --server --bridge)');
747
789
  console.log(' bridge Corre solo el puente (--api --token --id --name)');
748
790
  console.log(' import Trae data/ de OpenConex (<data-dir> [--force])');
@@ -773,6 +815,7 @@ async function main(argv) {
773
815
  case 'server': case 'start': return cmdServer(args.slice(1));
774
816
  case 'stop': return cmdStop();
775
817
  case 'status': return cmdStatus();
818
+ case 'qr': return cmdQr();
776
819
  case 'logs': return cmdLogs(args.slice(1));
777
820
  case 'bridge': return cmdBridge(args.slice(1));
778
821
  case 'import': return cmdImport(args.slice(1));
package/src/qr.js ADDED
@@ -0,0 +1,224 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Generador de QR minimo (ISO/IEC 18004, Model 2) sin dependencias.
5
+ *
6
+ * Soporta modo byte (UTF-8) con correccion de errores nivel L, versiones 1 a 5
7
+ * (hasta 106 bytes, de sobra para una URL de tunel) y un solo bloque RS. La
8
+ * mascara es fija (0): cualquier mascara es valida y la info de formato la
9
+ * declara, asi que el resultado es escaneable.
10
+ */
11
+
12
+ // dataCodewords, eccCodewords y centros de patrones de alineacion por version.
13
+ const VERSIONS = {
14
+ 1: { data: 19, ecc: 7, align: [] },
15
+ 2: { data: 34, ecc: 10, align: [6, 18] },
16
+ 3: { data: 55, ecc: 15, align: [6, 22] },
17
+ 4: { data: 80, ecc: 20, align: [6, 26] },
18
+ 5: { data: 108, ecc: 26, align: [6, 30] },
19
+ };
20
+
21
+ const MASK = 0;
22
+ const ECC_FORMAT_BITS = 1; // nivel L -> 01
23
+
24
+ function gfMul(x, y) {
25
+ let z = 0;
26
+ for (let i = 7; i >= 0; i--) {
27
+ z = (z << 1) ^ ((z >>> 7) * 0x11d);
28
+ z ^= ((y >>> i) & 1) * x;
29
+ }
30
+ return z & 0xff;
31
+ }
32
+
33
+ function rsDivisor(degree) {
34
+ const result = new Array(degree).fill(0);
35
+ result[degree - 1] = 1;
36
+ let root = 1;
37
+ for (let i = 0; i < degree; i++) {
38
+ for (let j = 0; j < result.length; j++) {
39
+ result[j] = gfMul(result[j], root);
40
+ if (j + 1 < result.length) result[j] ^= result[j + 1];
41
+ }
42
+ root = gfMul(root, 0x02);
43
+ }
44
+ return result;
45
+ }
46
+
47
+ function rsRemainder(data, divisor) {
48
+ const result = divisor.map(() => 0);
49
+ for (const b of data) {
50
+ const factor = b ^ result.shift();
51
+ result.push(0);
52
+ for (let i = 0; i < divisor.length; i++) result[i] ^= gfMul(divisor[i], factor);
53
+ }
54
+ return result;
55
+ }
56
+
57
+ function getBit(x, i) {
58
+ return ((x >>> i) & 1) !== 0;
59
+ }
60
+
61
+ function chooseVersion(byteLen) {
62
+ for (let v = 1; v <= 5; v++) {
63
+ if (byteLen <= VERSIONS[v].data - 2) return v;
64
+ }
65
+ return 0;
66
+ }
67
+
68
+ function dataCodewords(bytes, version) {
69
+ const cap = VERSIONS[version].data;
70
+ const bits = [];
71
+ const push = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >>> i) & 1); };
72
+ push(0b0100, 4); // modo byte
73
+ push(bytes.length, 8); // conteo (v1-9: 8 bits)
74
+ for (const b of bytes) push(b, 8);
75
+ for (let i = 0; i < 4 && bits.length < cap * 8; i++) bits.push(0);
76
+ while (bits.length % 8 !== 0) bits.push(0);
77
+ const out = [];
78
+ for (let i = 0; i < bits.length; i += 8) {
79
+ let b = 0;
80
+ for (let j = 0; j < 8; j++) b = (b << 1) | bits[i + j];
81
+ out.push(b);
82
+ }
83
+ const pad = [0xec, 0x11];
84
+ let k = 0;
85
+ while (out.length < cap) out.push(pad[k++ % 2]);
86
+ return out;
87
+ }
88
+
89
+ function buildMatrix(version, codewords) {
90
+ const size = version * 4 + 17;
91
+ const modules = Array.from({ length: size }, () => new Array(size).fill(false));
92
+ const isFn = Array.from({ length: size }, () => new Array(size).fill(false));
93
+ const set = (x, y, dark) => { modules[y][x] = dark; isFn[y][x] = true; };
94
+
95
+ // Patrones de sincronizacion.
96
+ for (let i = 0; i < size; i++) {
97
+ set(6, i, i % 2 === 0);
98
+ set(i, 6, i % 2 === 0);
99
+ }
100
+
101
+ // Localizadores (finder) + separadores.
102
+ const drawFinder = (cx, cy) => {
103
+ for (let dy = -4; dy <= 4; dy++) {
104
+ for (let dx = -4; dx <= 4; dx++) {
105
+ const x = cx + dx, y = cy + dy;
106
+ if (x < 0 || x >= size || y < 0 || y >= size) continue;
107
+ const dist = Math.max(Math.abs(dx), Math.abs(dy));
108
+ set(x, y, dist !== 2 && dist !== 4);
109
+ }
110
+ }
111
+ };
112
+ drawFinder(3, 3);
113
+ drawFinder(size - 4, 3);
114
+ drawFinder(3, size - 4);
115
+
116
+ // Patrones de alineacion.
117
+ const pos = VERSIONS[version].align;
118
+ for (const cy of pos) {
119
+ for (const cx of pos) {
120
+ if ((cx === 6 && cy === 6) || (cx === 6 && cy === size - 7) || (cx === size - 7 && cy === 6)) continue;
121
+ for (let dy = -2; dy <= 2; dy++) {
122
+ for (let dx = -2; dx <= 2; dx++) {
123
+ set(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ // Reserva la info de formato (se reescribe abajo) y el modulo oscuro.
130
+ drawFormatBits(size, modules, isFn, 0);
131
+
132
+ // Colocacion de datos en zigzag.
133
+ let i = 0;
134
+ for (let right = size - 1; right >= 1; right -= 2) {
135
+ if (right === 6) right = 5;
136
+ for (let vert = 0; vert < size; vert++) {
137
+ for (let j = 0; j < 2; j++) {
138
+ const x = right - j;
139
+ const upward = ((right + 1) & 2) === 0;
140
+ const y = upward ? size - 1 - vert : vert;
141
+ if (isFn[y][x] || i >= codewords.length * 8) continue;
142
+ modules[y][x] = getBit(codewords[i >>> 3], 7 - (i & 7));
143
+ i++;
144
+ }
145
+ }
146
+ }
147
+
148
+ // Mascara fija.
149
+ for (let y = 0; y < size; y++) {
150
+ for (let x = 0; x < size; x++) {
151
+ if (!isFn[y][x] && (x + y) % 2 === 0) modules[y][x] = !modules[y][x];
152
+ }
153
+ }
154
+
155
+ drawFormatBits(size, modules, isFn, MASK);
156
+ return { size, modules };
157
+ }
158
+
159
+ function drawFormatBits(size, modules, isFn, mask) {
160
+ const data = (ECC_FORMAT_BITS << 3) | mask;
161
+ let rem = data;
162
+ for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
163
+ const bits = ((data << 10) | rem) ^ 0x5412;
164
+ const put = (x, y, dark) => { modules[y][x] = dark; isFn[y][x] = true; };
165
+
166
+ for (let i = 0; i <= 5; i++) put(8, i, getBit(bits, i));
167
+ put(8, 7, getBit(bits, 6));
168
+ put(8, 8, getBit(bits, 7));
169
+ put(7, 8, getBit(bits, 8));
170
+ for (let i = 9; i < 15; i++) put(14 - i, 8, getBit(bits, i));
171
+
172
+ for (let i = 0; i < 8; i++) put(size - 1 - i, 8, getBit(bits, i));
173
+ for (let i = 8; i < 15; i++) put(8, size - 15 + i, getBit(bits, i));
174
+ put(8, size - 8, true);
175
+ }
176
+
177
+ // Devuelve { size, modules } o null si el texto no entra (URL muy larga).
178
+ function qrMatrix(text) {
179
+ const bytes = Buffer.from(String(text), 'utf8');
180
+ const version = chooseVersion(bytes.length);
181
+ if (!version) return null;
182
+ const data = dataCodewords(bytes, version);
183
+ const ecc = rsRemainder(data, rsDivisor(VERSIONS[version].ecc));
184
+ return buildMatrix(version, data.concat(ecc));
185
+ }
186
+
187
+ // QR para terminal con medios bloques (cuadrado en consolas 1:2).
188
+ function qrTerminal(text, quiet) {
189
+ const qr = qrMatrix(text);
190
+ if (!qr) return '';
191
+ const q = quiet === undefined ? 2 : quiet;
192
+ const size = qr.size;
193
+ const at = (x, y) => (x >= 0 && x < size && y >= 0 && y < size) && qr.modules[y][x];
194
+ const lines = [];
195
+ for (let y = -q; y < size + q; y += 2) {
196
+ let line = '';
197
+ for (let x = -q; x < size + q; x++) {
198
+ const top = at(x, y), bot = at(x, y + 1);
199
+ line += top && bot ? '\u2588' : top ? '\u2580' : bot ? '\u2584' : ' ';
200
+ }
201
+ lines.push(line);
202
+ }
203
+ return lines.join('\n');
204
+ }
205
+
206
+ // QR como SVG (para la web o para guardar).
207
+ function qrSvg(text, quiet) {
208
+ const qr = qrMatrix(text);
209
+ if (!qr) return '';
210
+ const q = quiet === undefined ? 4 : quiet;
211
+ const size = qr.size;
212
+ let path = '';
213
+ for (let y = 0; y < size; y++) {
214
+ for (let x = 0; x < size; x++) {
215
+ if (qr.modules[y][x]) path += 'M' + (x + q) + ' ' + (y + q) + 'h1v1h-1z';
216
+ }
217
+ }
218
+ const total = size + q * 2;
219
+ return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + total + ' ' + total
220
+ + '" shape-rendering="crispEdges"><rect width="' + total + '" height="' + total
221
+ + '" fill="#fff"/><path d="' + path + '" fill="#000"/></svg>';
222
+ }
223
+
224
+ module.exports = { qrMatrix, qrTerminal, qrSvg };
@@ -27,6 +27,7 @@ var els = {
27
27
  input: document.getElementById('input'),
28
28
  sendBtn: document.getElementById('sendBtn'),
29
29
  btnImg: document.getElementById('btnImg'),
30
+ btnMic: document.getElementById('btnMic'),
30
31
  imgInput: document.getElementById('imgInput'),
31
32
  imgPreview: document.getElementById('imgPreview'),
32
33
  imgThumb: document.getElementById('imgThumb'),
@@ -220,8 +221,17 @@ function updateStatusbar() {
220
221
  if (state.currentId !== null && state.currentSession) {
221
222
  extra = '· ' + extra;
222
223
  var tokTxt = sessionTokensLabel(state.currentSession);
223
- if (tokTxt) extra += ' · ' + tokTxt + ' tok';
224
+ if (tokTxt) {
225
+ var pct = sessionTokensPct(state.currentSession);
226
+ extra += ' · ' + tokTxt + ' tokens' + (pct ? ' (' + pct + ')' : '');
227
+ }
228
+ var cost = fmtCost(state.currentSession.cost);
229
+ if (cost) extra += ' · ' + cost;
224
230
  }
231
+ var totalCost = 0;
232
+ for (var ci = 0; ci < state.sessions.length; ci++) totalCost += Number(state.sessions[ci].cost) || 0;
233
+ var totalTxt = fmtCost(totalCost);
234
+ if (totalTxt) extra += ' · ' + totalTxt + ' total';
225
235
  els.sbExtra.textContent = extra;
226
236
  }
227
237
 
@@ -761,6 +771,14 @@ function fmtTokens(n) {
761
771
  return String(n);
762
772
  }
763
773
 
774
+ // Costo acumulado (USD) con precision segun magnitud ('' si no hay dato).
775
+ function fmtCost(n) {
776
+ var v = Number(n) || 0;
777
+ if (v <= 0) return '';
778
+ var s = v >= 1 ? v.toFixed(2) : (v >= 0.01 ? v.toFixed(3) : v.toFixed(4));
779
+ return '$' + s.replace('.', ',');
780
+ }
781
+
764
782
  // Ventana de contexto del modelo segun el catalogo (0 si no se conoce).
765
783
  function modelContext(model) {
766
784
  var c = state.catalog || {};
@@ -779,7 +797,16 @@ function sessionTokensLabel(s) {
779
797
  function sessionTokensTitle(s) {
780
798
  var tok = Math.round(Number(s.tokens) || 0);
781
799
  var ctx = modelContext(s.model);
782
- return tok + ' tokens' + (ctx ? ' de ' + ctx + ' de contexto' : '');
800
+ var cost = fmtCost(s.cost);
801
+ return tok + ' tokens' + (ctx ? ' de ' + ctx + ' de contexto' : '') + (cost ? ' · ' + cost + ' acumulado' : '');
802
+ }
803
+
804
+ // Porcentaje de contexto consumido ('' si no hay datos).
805
+ function sessionTokensPct(s) {
806
+ var tok = Math.round(Number(s.tokens) || 0);
807
+ var ctx = modelContext(s.model);
808
+ if (!tok || !ctx) return '';
809
+ return Math.min(100, Math.round((tok / ctx) * 100)) + '%';
783
810
  }
784
811
 
785
812
  // Etiqueta "consumidos / capacidad" de la sesion.
@@ -789,11 +816,23 @@ function sessionTokensHtml(s) {
789
816
  return '<span class="stok" title="' + sessionTokensTitle(s) + '">' + label + '</span>';
790
817
  }
791
818
 
792
- // Subtitulo del encabezado: proyecto · modelo · agente · tokens/contexto.
819
+ // Costo acumulado de la sesion ('' si no hay dato).
820
+ function sessionCostHtml(s) {
821
+ var cost = fmtCost(s.cost);
822
+ if (!cost) return '';
823
+ return '<span class="scost" title="' + sessionTokensTitle(s) + '">' + cost + '</span>';
824
+ }
825
+
826
+ // Subtitulo del encabezado: proyecto · modelo · agente · tokens/contexto · costo.
793
827
  function sessionSubtitle(s) {
794
828
  var txt = projectLabel(s.folder) + ' · ' + (s.model || 'sin modelo') + ' · ' + (s.agent || 'build');
795
829
  var label = sessionTokensLabel(s);
796
- if (label) txt += ' · ' + label + ' tok';
830
+ if (label) {
831
+ var pct = sessionTokensPct(s);
832
+ txt += ' · ' + label + ' tokens' + (pct ? ' (' + pct + ')' : '');
833
+ }
834
+ var cost = fmtCost(s.cost);
835
+ if (cost) txt += ' · ' + cost;
797
836
  return txt;
798
837
  }
799
838
 
@@ -818,6 +857,7 @@ function sessionItemHtml(s, withFolder) {
818
857
  + (withFolder ? '<span class="sfolder">' + esc(projectLabel(s.folder)) + '</span>' : '')
819
858
  + pen
820
859
  + sessionTokensHtml(s)
860
+ + sessionCostHtml(s)
821
861
  + '<span class="stime">' + esc(timeShort(s.last_ts)) + '</span>'
822
862
  + '</button>';
823
863
  }
@@ -1057,6 +1097,7 @@ function composeOpenSheet() {
1057
1097
  }
1058
1098
  function composeCloseSheet() {
1059
1099
  if (!els.sendForm) return;
1100
+ voiceStop();
1060
1101
  els.sendForm.classList.remove('open');
1061
1102
  document.body.classList.remove('compose-open');
1062
1103
  if (els.imgPreview && els.imgPreview.parentNode === els.sendForm && els.composeOpen) {
@@ -1216,9 +1257,12 @@ function renderHome(sessions) {
1216
1257
  + '</div>';
1217
1258
  }
1218
1259
  html += '</div>';
1219
- html += '<button type="button" class="histbtn" data-folder="' + esc(key) + '">'
1260
+ html += '<div class="histrow">'
1261
+ + '<button type="button" class="histbtn" data-folder="' + esc(key) + '">'
1220
1262
  + (hidden > 0 ? 'ver historial · ' + hidden + ' más' : 'ver historial')
1221
- + '</button>';
1263
+ + '</button>'
1264
+ + '<button type="button" class="histbtn filesbtn" data-folder="' + esc(key) + '" title="Archivos del proyecto">ver archivos</button>'
1265
+ + '</div>';
1222
1266
  html += '</div></div>';
1223
1267
  }
1224
1268
  }
@@ -1594,7 +1638,8 @@ function msgSig(m) {
1594
1638
  function msgNodeHtml(m, chatQuery) {
1595
1639
  var cls = m.role === 'user' ? 'mine' : 'theirs';
1596
1640
  if (m.status === 'canceled') cls += ' canceled';
1597
- var who = m.role === 'user' ? '❯ vos' : '● agent';
1641
+ var who = m.role === 'user' ? '❯ vos' : '● Agente';
1642
+ if (m.agent === 'plan') cls += ' plan';
1598
1643
  var agentBadge = m.agent ? ' <span class="abadge ' + esc(m.agent) + '">' + esc(m.agent) + '</span>' : '';
1599
1644
  var stopBadge = m.canceled ? ' <span class="stopbadge">⏹ detenido</span>' : '';
1600
1645
  var streaming = m.role === 'assistant' && m.status === 'streaming';
@@ -1855,6 +1900,19 @@ function focusProject(folder) {
1855
1900
  }
1856
1901
  }
1857
1902
 
1903
+ // Convierte la carpeta absoluta de un proyecto en ruta relativa al workspace
1904
+ // (para la vista de archivos). '' si no se puede derivar (usa la raiz).
1905
+ function folderToFilesPath(folder) {
1906
+ var ws = state.catalog && state.catalog.workspace ? state.catalog.workspace : '';
1907
+ if (!folder || !ws) return '';
1908
+ var norm = function (p) { return String(p).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); };
1909
+ var nws = norm(ws), nf = norm(folder);
1910
+ if (nf === nws || nf.indexOf(nws + '/') !== 0) return '';
1911
+ var raw = String(folder).replace(/\\/g, '/').replace(/\/+$/, '');
1912
+ var rawWs = String(ws).replace(/\\/g, '/').replace(/\/+$/, '');
1913
+ return raw.slice(rawWs.length + 1);
1914
+ }
1915
+
1858
1916
  // Historial de un proyecto: vista aparte con buscador dentro del proyecto.
1859
1917
  function openHistory(folder) {
1860
1918
  state.histFolder = folder;
@@ -2364,6 +2422,69 @@ if (els.btnImg) {
2364
2422
  els.imgRemove.addEventListener('click', function () { setPendingImage(null); });
2365
2423
  }
2366
2424
 
2425
+ // ---------------------------------------------------------------------------
2426
+ // Dictado por voz (Web Speech API nativa del navegador; sin dependencias).
2427
+ // ---------------------------------------------------------------------------
2428
+ var SpeechRec = window.SpeechRecognition || window.webkitSpeechRecognition;
2429
+ var voice = { rec: null, listening: false, base: '' };
2430
+
2431
+ function voiceSetListening(on) {
2432
+ voice.listening = on;
2433
+ if (!els.btnMic) return;
2434
+ els.btnMic.classList.toggle('listening', on);
2435
+ els.btnMic.title = on ? 'Detener dictado' : 'Dictar por voz';
2436
+ els.btnMic.setAttribute('aria-label', on ? 'Detener dictado' : 'Dictar por voz');
2437
+ }
2438
+
2439
+ function voiceStop() {
2440
+ if (voice.rec && voice.listening) {
2441
+ try { voice.rec.stop(); } catch (e) {}
2442
+ }
2443
+ voiceSetListening(false);
2444
+ }
2445
+
2446
+ function voiceStart() {
2447
+ if (!voice.rec || state.currentId === null) return;
2448
+ voice.base = els.input.value ? els.input.value.replace(/\s+$/, ' ') : '';
2449
+ try { voice.rec.start(); } catch (e) { /* ya estaba iniciado */ }
2450
+ voiceSetListening(true);
2451
+ toast('dictado activo · hablá ahora', 'ok');
2452
+ }
2453
+
2454
+ if (els.btnMic) {
2455
+ if (!SpeechRec) {
2456
+ els.btnMic.style.display = 'none';
2457
+ } else {
2458
+ voice.rec = new SpeechRec();
2459
+ voice.rec.lang = navigator.language || 'es-AR';
2460
+ voice.rec.continuous = true;
2461
+ voice.rec.interimResults = true;
2462
+ voice.rec.onresult = function (ev) {
2463
+ var fin = '', interim = '';
2464
+ for (var i = ev.resultIndex; i < ev.results.length; i++) {
2465
+ var r = ev.results[i];
2466
+ if (r.isFinal) fin += r[0].transcript;
2467
+ else interim += r[0].transcript;
2468
+ }
2469
+ if (fin) voice.base = (voice.base + fin).replace(/\s+/g, ' ');
2470
+ var tail = interim ? (voice.base ? ' ' : '') + interim : '';
2471
+ els.input.value = (voice.base + tail).replace(/^\s+/, '');
2472
+ autoGrow();
2473
+ };
2474
+ voice.rec.onerror = function (ev) {
2475
+ voiceSetListening(false);
2476
+ var m = (ev && ev.error) || '';
2477
+ if (m === 'not-allowed' || m === 'service-not-allowed') toast('permiso de micrófono denegado', 'error');
2478
+ else if (m !== 'aborted' && m !== 'no-speech') toast('dictado: ' + m, 'error');
2479
+ };
2480
+ voice.rec.onend = function () { voiceSetListening(false); };
2481
+ els.btnMic.addEventListener('click', function () {
2482
+ if (voice.listening) voiceStop();
2483
+ else voiceStart();
2484
+ });
2485
+ }
2486
+ }
2487
+
2367
2488
  // ---------------------------------------------------------------------------
2368
2489
  // Composer: textarea multilínea (Enter envía, Shift+Enter salta de línea)
2369
2490
  // y menú de comandos "/" con filtro, flechas y Enter.
@@ -2472,6 +2593,7 @@ els.sendForm.addEventListener('submit', async function (ev) {
2472
2593
  var text = els.input.value.trim();
2473
2594
  if ((!text && !state.pendingImage) || state.isSending) return;
2474
2595
  state.isSending = true;
2596
+ voiceStop();
2475
2597
  try {
2476
2598
  var body = { session: state.currentId, text: text };
2477
2599
  if (state.pendingImage) body.image = state.pendingImage;
@@ -2644,9 +2766,17 @@ els.home.addEventListener('click', function (e) {
2644
2766
  return;
2645
2767
  }
2646
2768
  var hist = e.target.closest('.histbtn');
2647
- if (hist) {
2769
+ if (hist && !e.target.closest('.filesbtn')) {
2648
2770
  e.stopPropagation();
2649
2771
  openHistory(hist.getAttribute('data-folder'));
2772
+ return;
2773
+ }
2774
+ var fbtn = e.target.closest('.filesbtn');
2775
+ if (fbtn) {
2776
+ e.stopPropagation();
2777
+ state.filesPath = folderToFilesPath(fbtn.getAttribute('data-folder'));
2778
+ delete state.filesCache[state.filesPath];
2779
+ showView('files');
2650
2780
  }
2651
2781
  });
2652
2782
 
@@ -69,6 +69,7 @@
69
69
  z-index: 30;
70
70
  }
71
71
  #sidebar .brand {
72
+ min-height: 54px;
72
73
  padding: 12px 14px 10px;
73
74
  display: flex;
74
75
  align-items: center;
@@ -233,6 +234,7 @@
233
234
  .sitem .sname { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: .78; }
234
235
  .sitem.active .sname { opacity: 1; }
235
236
  .sitem .stok { font-size: 10.5px; color: var(--muted); flex-shrink: 0; }
237
+ .sitem .scost { font-size: 10.5px; color: var(--warn-text, #d29922); flex-shrink: 0; }
236
238
  .sitem .stime { font-size: 11px; color: var(--muted); flex-shrink: 0; }
237
239
  .sitem .spen { width: 6px; height: 6px; border-radius: 50%; background: var(--mine); flex-shrink: 0; }
238
240
  .sdot {
@@ -495,8 +497,9 @@
495
497
  .projsec .pshead .pscount { font-size: 11px; color: var(--muted); flex-shrink: 0; }
496
498
  .projsec .pbody { display: none; }
497
499
  .projsec.open .pbody { display: block; }
500
+ .histrow { display: flex; gap: 6px; margin-top: 6px; }
498
501
  .histbtn {
499
- width: 100%; margin-top: 6px; padding: 8px; border: 1px dashed var(--border); border-radius: 8px;
502
+ flex: 1; min-width: 0; padding: 8px; border: 1px dashed var(--border); border-radius: 8px;
500
503
  background: transparent; color: var(--muted); font: inherit; font-size: 12px; cursor: pointer;
501
504
  }
502
505
  .histbtn:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
@@ -598,6 +601,8 @@
598
601
  .msg.mine .who { color: var(--mine); }
599
602
  .msg.theirs { border-left-color: var(--accent); }
600
603
  .msg.theirs .who { color: var(--accent); }
604
+ .msg.theirs.plan { border-left-color: var(--warn-text, #d29922); }
605
+ .msg.theirs.plan .who { color: var(--warn-text, #d29922); }
601
606
  .msg.error { border-left-color: var(--danger); }
602
607
  /* Aire extra en el cambio de turno: lo tuyo ↔ lo que responde el agente */
603
608
  .msg.mine + .msg.theirs,
@@ -778,6 +783,14 @@
778
783
  }
779
784
  form.sendbar .imgbtn:hover { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
780
785
  form.sendbar .imgbtn svg { flex-shrink: 0; display: block; }
786
+ form.sendbar .imgbtn.listening {
787
+ color: var(--danger); border-color: var(--danger); background: transparent;
788
+ animation: micpulse 1.1s ease-in-out infinite;
789
+ }
790
+ @keyframes micpulse {
791
+ 0%, 100% { box-shadow: 0 0 0 0 transparent; }
792
+ 50% { box-shadow: 0 0 0 4px var(--accent-soft); }
793
+ }
781
794
  /* ----- Botón que abre la hoja de escritura (móvil) ----- */
782
795
  .composeopen {
783
796
  display: none; align-items: center; gap: 10px; width: 100%;
@@ -1209,6 +1222,7 @@
1209
1222
  <span class="prompt">❯</span>
1210
1223
  <textarea id="input" rows="1" placeholder="escribí un mensaje…" autocomplete="off"></textarea>
1211
1224
  <button type="button" class="imgbtn" id="btnImg" title="Adjuntar imagen (modelos con visión)" aria-label="Adjuntar imagen"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"/></svg></button>
1225
+ <button type="button" class="imgbtn" id="btnMic" title="Dictar por voz" aria-label="Dictar por voz"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z"/></svg></button>
1212
1226
  <input type="file" id="imgInput" accept="image/png,image/jpeg,image/webp,image/gif" style="display:none">
1213
1227
  <button type="submit" id="sendBtn">enviar</button>
1214
1228
  <div id="slashPanel" class="slash-panel"></div>