@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 +75 -39
- package/index.js +21 -5
- package/package.json +2 -2
package/drivers/claude.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* drivers/claude.js — invoca la CLI `claude` (Claude Code)
|
|
2
|
+
* drivers/claude.js — invoca la CLI `claude` (Claude Code) con STREAMING real.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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:
|
|
9
|
-
*
|
|
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]
|
|
22
|
-
* @param {string} [opts.bin='claude']
|
|
23
|
-
* @param {string[]} [opts.flags=[]]
|
|
24
|
-
* @param {number} [opts.timeoutMs=0]
|
|
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
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
72
|
-
p.stdout.
|
|
73
|
-
p.
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
21
|
+
"@dotrino/remote-agent": "^0.2.0"
|
|
22
22
|
},
|
|
23
23
|
"engines": {
|
|
24
24
|
"node": ">=18"
|