@dotrino/ia-agent 0.1.1 → 0.3.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.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * drivers/claude.js — invoca la CLI `claude` (Claude Code) con STREAMING real.
3
+ *
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).
8
+ *
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
+ */
12
+ import { spawn } from 'node:child_process'
13
+ import { dirname } from 'node:path'
14
+
15
+ // PATH robusto para systemd/PM2 (PATH mínimo): node + ~/.local/bin (donde vive `claude`).
16
+ const EXTRA_PATH = [dirname(process.execPath), `${process.env.HOME || ''}/.local/bin`].filter(Boolean).join(':')
17
+
18
+ export class ClaudeDriver {
19
+ /**
20
+ * @param {object} opts
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
+ */
26
+ constructor ({ cwd, bin = 'claude', flags = [], timeoutMs = 0 } = {}) {
27
+ this.cwd = cwd || process.cwd()
28
+ this.bin = bin
29
+ this.flags = flags
30
+ this.timeoutMs = timeoutMs
31
+ this.sessionId = null
32
+ }
33
+
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 } = {}) {
39
+ try {
40
+ return await this._send(text, this.sessionId, onToken)
41
+ } catch (e) {
42
+ // Sesión inválida/inexistente → empezar una nueva en vez de fallar.
43
+ if (this.sessionId && /no conversation found|session/i.test(e.message || '')) {
44
+ this.sessionId = null
45
+ return this._send(text, null, onToken)
46
+ }
47
+ throw e
48
+ }
49
+ }
50
+
51
+ async _send (text, sid, onToken) {
52
+ const args = ['-p', text, '--output-format', 'stream-json', '--verbose', ...this.flags]
53
+ if (sid) args.push('--resume', sid)
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 }
91
+ }
92
+
93
+ _runStream (args, { onLine, onStderr }) {
94
+ return new Promise((resolve, reject) => {
95
+ const p = spawn(this.bin, args, {
96
+ cwd: this.cwd,
97
+ stdio: ['ignore', 'pipe', 'pipe'],
98
+ env: { ...process.env, PATH: `${process.env.PATH || ''}:${EXTRA_PATH}` }
99
+ })
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) })
110
+ const to = this.timeoutMs > 0
111
+ ? setTimeout(() => { try { p.kill('SIGKILL') } catch {}; reject(new Error('timeout')) }, this.timeoutMs)
112
+ : null
113
+ p.on('error', (e) => { if (to) clearTimeout(to); reject(new Error(`no se pudo ejecutar '${this.bin}' (¿instalado y en PATH?): ${e.message}`)) })
114
+ p.on('close', (code) => {
115
+ if (to) clearTimeout(to)
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}`))
119
+ })
120
+ })
121
+ }
122
+ }
123
+
124
+ function contextTokens (ev) {
125
+ const u = ev && ev.usage
126
+ if (!u) return 0
127
+ return (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
128
+ }
129
+
130
+ export default { ClaudeDriver }
package/index.js CHANGED
@@ -4,11 +4,23 @@
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
- * F0: driver ECHO devuelve el texto recibido. Sirve para validar el flujo extremo
8
- * a extremo (enrolamiento descubrimiento handshake chat) antes de meter el
9
- * driver de Claude (F1). Reemplazar el cuerpo de `onSession` por el driver.
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.
10
+ *
11
+ * Configuración (env):
12
+ * IA_CWD directorio donde opera Claude (default: el cwd al lanzar).
13
+ * CLAUDE_BIN binario (default 'claude').
14
+ * CLAUDE_FLAGS flags extra, ej. '--dangerously-skip-permissions' (modo yolo).
15
+ * CLAUDE_TIMEOUT segundos, 0 = sin límite.
10
16
  */
11
17
  import { startRemoteAgent } from '@dotrino/remote-agent/agent'
18
+ import { ClaudeDriver } from './drivers/claude.js'
19
+
20
+ const CWD = process.env.IA_CWD || process.cwd()
21
+ const CLAUDE_BIN = process.env.CLAUDE_BIN || 'claude'
22
+ const CLAUDE_FLAGS = (process.env.CLAUDE_FLAGS || '').split(/\s+/).filter(Boolean)
23
+ const CLAUDE_TIMEOUT = Number(process.env.CLAUDE_TIMEOUT || 0) * 1000
12
24
 
13
25
  export async function startIaAgent (opts = {}) {
14
26
  return startRemoteAgent({
@@ -19,12 +31,30 @@ export async function startIaAgent (opts = {}) {
19
31
  onReady: opts.onReady,
20
32
  onRevoked: opts.onRevoked,
21
33
  onSession (session) {
34
+ // Un driver por sesión: cada chat abre su propio hilo de Claude con su contexto.
35
+ const driver = new ClaudeDriver({ cwd: CWD, bin: CLAUDE_BIN, flags: CLAUDE_FLAGS, timeoutMs: CLAUDE_TIMEOUT })
22
36
  session.on('message', async (msg) => {
23
- if (msg?.type === 'msg' && typeof msg.text === 'string') {
24
- // F0 — ECHO. F1+: session es un canal cifrado; pasalo al driver:
25
- // const reply = await claudeDriver.send(msg.text, { sessionId, onToken })
26
- // session.send({ type: 'done', text: reply })
27
- await session.send({ type: 'done', text: 'echo: ' + msg.text })
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
+
45
+ try {
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 })
55
+ } catch (e) {
56
+ if (timer) clearTimeout(timer)
57
+ await session.send({ type: 'error', message: e.message }).catch(() => {})
28
58
  }
29
59
  })
30
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/ia-agent",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
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": {
@@ -11,7 +11,8 @@
11
11
  },
12
12
  "files": [
13
13
  "index.js",
14
- "bin"
14
+ "bin",
15
+ "drivers"
15
16
  ],
16
17
  "scripts": {
17
18
  "start": "node bin/cli.js run"