@dotrino/ia-agent 0.2.0 → 0.3.1

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/drivers/claude.js CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
- * drivers/claude.js — invoca la CLI `claude` (Claude Code) en modo headless.
2
+ * drivers/claude.js — invoca la CLI `claude` (Claude Code) con STREAMING real.
3
3
  *
4
- * F1: NO streaming. Lanza `claude -p "<texto>" --output-format json [--resume <sid>]`
5
- * y devuelve el resultado completo cuando termina (igual que dotrino-telegram-claude-bot).
6
- * F2 migrará esto al SDK (@anthropic-ai/claude-code) con onToken para streaming real.
4
+ * F2: usa `--output-format stream-json --verbose` y parsea los eventos NDJSON que
5
+ * Claude re-emite del streaming de Anthropic. Los `content_block_delta` /
6
+ * `text_delta` son los tokens los pasamos a `onToken` conforme se generan.
7
+ * El evento `result` final da `session_id`, `usage` y el texto completo (fallback).
7
8
  *
8
- * Memoria de sesión: guarda el `session_id` que devuelve Claude y lo pasa como
9
- * `--resume` en la siguiente vuelta (hilo continuo). Si la sesión se invalida,
10
- * arranca una nueva.
9
+ * Memoria de sesión: `--resume <sid>` con el `session_id` del `result`. Si la sesión
10
+ * se invalida, arranca una nueva. Mismo patrón que dotrino-telegram-claude-bot.
11
11
  */
12
12
  import { spawn } from 'node:child_process'
13
13
  import { dirname } from 'node:path'
@@ -18,10 +18,10 @@ const EXTRA_PATH = [dirname(process.execPath), `${process.env.HOME || ''}/.local
18
18
  export class ClaudeDriver {
19
19
  /**
20
20
  * @param {object} opts
21
- * @param {string} [opts.cwd] directorio donde opera Claude (el proyecto). Default process.cwd().
22
- * @param {string} [opts.bin='claude'] binario a invocar.
23
- * @param {string[]} [opts.flags=[]] flags extra (p. ej. ['--dangerously-skip-permissions'] para yolo).
24
- * @param {number} [opts.timeoutMs=0] 0 = sin límite.
21
+ * @param {string} [opts.cwd] directorio donde opera Claude (el proyecto).
22
+ * @param {string} [opts.bin='claude']
23
+ * @param {string[]} [opts.flags=[]] flags extra (p. ej. '--dangerously-skip-permissions').
24
+ * @param {number} [opts.timeoutMs=0] 0 = sin límite.
25
25
  */
26
26
  constructor ({ cwd, bin = 'claude', flags = [], timeoutMs = 0 } = {}) {
27
27
  this.cwd = cwd || process.cwd()
@@ -31,62 +31,98 @@ export class ClaudeDriver {
31
31
  this.sessionId = null
32
32
  }
33
33
 
34
- /** Envía un mensaje y devuelve { text, sessionId, tokens } cuando termina. */
35
- async send (text) {
36
- let out
34
+ /**
35
+ * Envía un mensaje. `onToken(text)` recibe los tokens conforme se generan.
36
+ * Devuelve { text, sessionId, tokens } al terminar.
37
+ */
38
+ async send (text, { onToken } = {}) {
37
39
  try {
38
- out = await this._run(this._args(text, this.sessionId))
40
+ return await this._send(text, this.sessionId, onToken)
39
41
  } catch (e) {
40
42
  // Sesión inválida/inexistente → empezar una nueva en vez de fallar.
41
43
  if (this.sessionId && /no conversation found|session/i.test(e.message || '')) {
42
44
  this.sessionId = null
43
- out = await this._run(this._args(text, null))
44
- } else throw e
45
+ return this._send(text, null, onToken)
46
+ }
47
+ throw e
45
48
  }
46
- let result = out, sid = this.sessionId, tokens = 0
47
- try {
48
- const j = JSON.parse(out)
49
- result = (j.result ?? out)
50
- sid = j.session_id || sid
51
- tokens = contextTokens(j)
52
- } catch {}
53
- if (sid) this.sessionId = sid
54
- return { text: String(result || '(sin respuesta)'), sessionId: sid, tokens }
55
49
  }
56
50
 
57
- _args (text, sid) {
58
- const args = ['-p', text, '--output-format', 'json', ...this.flags]
51
+ async _send (text, sid, onToken) {
52
+ const args = ['-p', text, '--output-format', 'stream-json', '--verbose', ...this.flags]
59
53
  if (sid) args.push('--resume', sid)
60
- return args
54
+
55
+ let streamed = ''
56
+ let finalEv = null
57
+ let stderrText = ''
58
+
59
+ await this._runStream(args, {
60
+ onLine (line) {
61
+ let ev
62
+ try { ev = JSON.parse(line) } catch { return }
63
+ // Streaming real: deltas de texto del modelo (Anthropic content_block_delta).
64
+ const se = ev.event
65
+ if (ev.type === 'stream_event' && se?.type === 'content_block_delta' && se.delta?.type === 'text_delta' && se.delta.text) {
66
+ streamed += se.delta.text
67
+ onToken?.(se.delta.text)
68
+ return
69
+ }
70
+ // Resultado final: session_id + usage (+ result como fallback de texto).
71
+ if (ev.type === 'result') finalEv = ev
72
+ },
73
+ onStderr (d) { stderrText += d }
74
+ })
75
+
76
+ let out = streamed
77
+ let nextSid = sid
78
+ let tokens = 0
79
+ if (finalEv) {
80
+ if (!out && finalEv.result) out = String(finalEv.result) // fallback: respuesta completa
81
+ if (finalEv.session_id) nextSid = finalEv.session_id
82
+ tokens = contextTokens(finalEv)
83
+ }
84
+ if (!out) {
85
+ if (finalEv?.is_error) throw new Error(String(finalEv.result || 'claude reportó un error'))
86
+ if (stderrText) throw new Error(stderrText.slice(0, 500))
87
+ throw new Error('claude no devolvió respuesta')
88
+ }
89
+ if (nextSid) this.sessionId = nextSid
90
+ return { text: out, sessionId: nextSid, tokens }
61
91
  }
62
92
 
63
- _run (args) {
93
+ _runStream (args, { onLine, onStderr }) {
64
94
  return new Promise((resolve, reject) => {
65
- // stdin cerrado para que `claude -p` no espere por él.
66
95
  const p = spawn(this.bin, args, {
67
96
  cwd: this.cwd,
68
97
  stdio: ['ignore', 'pipe', 'pipe'],
69
98
  env: { ...process.env, PATH: `${process.env.PATH || ''}:${EXTRA_PATH}` }
70
99
  })
71
- let out = '', err = ''
72
- p.stdout.on('data', (d) => { out += d })
73
- p.stderr.on('data', (d) => { err += d })
100
+ let buf = ''
101
+ p.stdout.setEncoding('utf8')
102
+ p.stdout.on('data', (d) => {
103
+ buf += d
104
+ const lines = buf.split('\n')
105
+ buf = lines.pop() // último fragmento (posiblemente incompleto)
106
+ for (const ln of lines) if (ln.trim()) onLine(ln)
107
+ })
108
+ let errBuf = ''
109
+ p.stderr.on('data', (d) => { errBuf += d; onStderr?.(d) })
74
110
  const to = this.timeoutMs > 0
75
111
  ? setTimeout(() => { try { p.kill('SIGKILL') } catch {}; reject(new Error('timeout')) }, this.timeoutMs)
76
112
  : null
77
113
  p.on('error', (e) => { if (to) clearTimeout(to); reject(new Error(`no se pudo ejecutar '${this.bin}' (¿instalado y en PATH?): ${e.message}`)) })
78
114
  p.on('close', (code) => {
79
115
  if (to) clearTimeout(to)
80
- if (code === 0) resolve(out)
81
- else reject(new Error(err.slice(0, 500) || `claude salió con código ${code}`))
116
+ // stream-json puede escribir su 'result' final y salir 0; errores no cero ⇒ rechazar.
117
+ if (code === 0 || code === null) resolve()
118
+ else reject(new Error(errBuf.slice(0, 500) || `claude salió con código ${code}`))
82
119
  })
83
120
  })
84
121
  }
85
122
  }
86
123
 
87
- /** Tamaño aproximado del contexto de la última vuelta (tokens enviados). */
88
- function contextTokens (j) {
89
- const u = j && j.usage
124
+ function contextTokens (ev) {
125
+ const u = ev && ev.usage
90
126
  if (!u) return 0
91
127
  return (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
92
128
  }
package/index.js CHANGED
@@ -4,8 +4,9 @@
4
4
  * Expone tus CLIs de IA (Claude, OpenCode…) al chat remoto, sobre
5
5
  * `@dotrino/remote-agent` (handshake E2E, emparejamiento con vault, revocación).
6
6
  *
7
- * F1: driver Claude (no-streaming) `claude -p --output-format json --resume`.
8
- * F2: migrar a SDK con streaming de tokens. F4: driver OpenCode.
7
+ * F2: driver Claude con STREAMING real (stream-json). Los tokens del modelo se
8
+ * reenvían al cliente como mensajes `ia.tok` (batcheados ~60 ms para no saturar el
9
+ * proxy) y el cierre como `ia.done`. F4: driver OpenCode.
9
10
  *
10
11
  * Configuración (env):
11
12
  * IA_CWD directorio donde opera Claude (default: el cwd al lanzar).
@@ -34,11 +35,26 @@ export async function startIaAgent (opts = {}) {
34
35
  const driver = new ClaudeDriver({ cwd: CWD, bin: CLAUDE_BIN, flags: CLAUDE_FLAGS, timeoutMs: CLAUDE_TIMEOUT })
35
36
  session.on('message', async (msg) => {
36
37
  if (msg?.type !== 'msg' || typeof msg.text !== 'string') return
38
+
39
+ // Batch de tokens (~60 ms): el rate-limit del proxy es discreto; sin batch,
40
+ // una ráfaga de deltas saturaría. Flush al final siempre.
41
+ let buf = ''
42
+ let timer = null
43
+ const flush = () => { timer = null; if (buf) { const t = buf; buf = ''; session.send({ type: 'tok', text: t }).catch(() => {}) } }
44
+
37
45
  try {
38
- const r = await driver.send(msg.text)
39
- await session.send({ type: 'done', text: r.text, sessionId: r.sessionId, tokens: r.tokens })
46
+ const r = await driver.send(msg.text, {
47
+ onToken: (tok) => {
48
+ buf += tok
49
+ if (!timer) timer = setTimeout(flush, 60)
50
+ }
51
+ })
52
+ if (timer) { clearTimeout(timer); timer = null }
53
+ if (buf) { const t = buf; buf = ''; await session.send({ type: 'tok', text: t }) }
54
+ await session.send({ type: 'done', sessionId: r.sessionId, tokens: r.tokens })
40
55
  } catch (e) {
41
- await session.send({ type: 'error', message: e.message })
56
+ if (timer) clearTimeout(timer)
57
+ await session.send({ type: 'error', message: e.message }).catch(() => {})
42
58
  }
43
59
  })
44
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/ia-agent",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Agente de Dotrino IA: expone tus CLIs de IA (Claude, OpenCode) al chat remoto de ia.dotrino.com, vía @dotrino/remote-agent.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,7 +18,7 @@
18
18
  "start": "node bin/cli.js run"
19
19
  },
20
20
  "dependencies": {
21
- "@dotrino/remote-agent": "^0.1.0"
21
+ "@dotrino/remote-agent": "^0.2.0"
22
22
  },
23
23
  "engines": {
24
24
  "node": ">=18"