@dotrino/ia-agent 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/bin/cli.js CHANGED
@@ -1,43 +1,88 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * dotrino-ia-agent — CLI del agente de IA.
4
- * enroll <qr-json | -> enropa esta máquina al vault como agente 'ia-agent'
5
- * run arranca el agente (escucha chats desde la PWA)
3
+ * dotrino-ia-agent — agente de Dotrino IA.
4
+ *
5
+ * dotrino-ia-agent enlaza (si falta) y CORRE el agente
6
+ * dotrino-ia-agent enroll re-enlaza (sobrescribe) y corre el agente
7
+ * opciones: [--label <nombre>] [--proxy <wss://…>] [--dir <ruta>]
8
+ *
9
+ * El agente es un dispositivo enrolado del vault (label 'ia-agent'): puede vivir en
10
+ * cualquier máquina y aparece solo en ia.dotrino.com para chatear con tus IAs
11
+ * (Claude, OpenCode…). Con un solo comando queda enlazado y sirviendo.
6
12
  */
7
- import { enroll, parseQr, dataDir } from '@dotrino/remote-agent/link'
13
+ import readline from 'node:readline'
8
14
  import { startIaAgent } from '../index.js'
15
+ import { enroll, parseQr, loadLink, dataDir } from '@dotrino/remote-agent/link'
9
16
 
10
- const DIR = dataDir('dotrino-ia-agent')
11
- const cmd = process.argv[2]
17
+ const args = process.argv.slice(2)
18
+ const cmd = args[0] && !args[0].startsWith('-') ? args[0] : 'run'
19
+ const opt = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined }
12
20
 
13
- function readStdin () {
14
- return new Promise((resolve) => {
15
- let d = ''
16
- process.stdin.setEncoding('utf8')
17
- process.stdin.on('data', (c) => { d += c })
18
- process.stdin.on('end', () => resolve(d.trim()))
21
+ function ask (q) {
22
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
23
+ return new Promise((res) => rl.question(q, (a) => { rl.close(); res(a) }))
24
+ }
25
+
26
+ if (args.includes('-h') || args.includes('--help')) {
27
+ console.log(`uso:
28
+ dotrino-ia-agent enlaza esta máquina (si falta) y corre el agente
29
+ dotrino-ia-agent enroll re-enlaza (sobrescribe el enlace) y corre el agente
30
+ opciones: [--label <nombre>] [--proxy <wss://…>] [--dir <ruta>]
31
+
32
+ datos en ${dataDir('dotrino-ia-agent')} (override DOTRINO_REMOTE_AGENT_DIR)`)
33
+ process.exit(0)
34
+ }
35
+
36
+ async function doEnroll (dir, label) {
37
+ console.log('Enlazar esta máquina con tu vault.')
38
+ console.log('El código lo generas en el CERTIFICADOR (tu vault). Hay dos formas:')
39
+ console.log(' · Sin vault externo → abre https://ia.dotrino.com, elige')
40
+ console.log(' "Usar este dispositivo como bóveda" → "Enlazar otra máquina" y copia el código.')
41
+ console.log(' · Con vault en un PC → ahí corre `dotrino-vault pair` y copia el QR/JSON.\n')
42
+ const text = await ask('Pega el código y Enter:\n> ')
43
+ const qr = parseQr(text)
44
+ console.log('\nConectando…')
45
+ await enroll({
46
+ qr,
47
+ dir,
48
+ label,
49
+ onChallenge: ({ deviceId, code }) => {
50
+ console.log('\n Escribe ESTE código en tu bóveda para aprobar esta máquina:')
51
+ console.log(` código: ${code}`)
52
+ console.log(` máquina: ${deviceId}`)
53
+ console.log(' (en ia.dotrino.com escríbelo en el campo y pulsa "Aprobar";')
54
+ console.log(` en el PC del vault: dotrino-vault approve ${code})\n`)
55
+ console.log(' Esperando aprobación…')
56
+ }
19
57
  })
58
+ console.log('\n ✓ Máquina enlazada — levantando el agente…\n')
20
59
  }
21
60
 
22
- if (cmd === 'enroll') {
23
- let qrText = process.argv[3]
24
- if (!qrText || qrText === '-') qrText = await readStdin()
25
- if (!qrText) { console.error('Uso: dotrino-ia-agent enroll "<qr-json>" (o pega el QR por stdin con -)'); process.exit(1) }
26
- try {
27
- const link = await enroll({
28
- qr: parseQr(qrText),
29
- label: 'ia-agent',
30
- dir: DIR,
31
- onChallenge: ({ deviceId, code }) => console.log(`\nMáquina ${deviceId}\n Tipeá este código en el vault: ${code}\n`)
32
- })
33
- console.log(`\n✓ Enrolado como 'ia-agent' (vault ${link.iss.slice(0, 16)}…).\n Ahora arranca el agente: dotrino-ia-agent run\n`)
34
- } catch (e) { console.error('Error:', e.message); process.exit(1) }
35
- } else if (cmd === 'run' || !cmd) {
36
- try {
37
- await startIaAgent({ dir: DIR })
38
- console.log('(Ctrl+C para detener)')
39
- } catch (e) { console.error('Error:', e.message); process.exit(1) }
40
- } else {
41
- console.error('Comandos:\n dotrino-ia-agent enroll "<qr-json>" | -\n dotrino-ia-agent run')
61
+ try {
62
+ const dir = opt('--dir')
63
+ const label = opt('--label') || 'ia-agent'
64
+ // El comando por defecto enrola SOLO si aún no está enlazada; `enroll` fuerza
65
+ // re-enrolar (sobrescribe) aunque ya lo esté. En ambos casos, al terminar sigue
66
+ // y LEVANTA el servicio.
67
+ if (cmd === 'enroll' || !loadLink(dir)) {
68
+ if (cmd === 'enroll' && loadLink(dir)) console.log('Re-enlazando esta máquina (sobrescribe el enlace actual).\n')
69
+ await doEnroll(dir, label)
70
+ }
71
+
72
+ const agent = await startIaAgent({
73
+ dir, proxyUrl: opt('--proxy'),
74
+ onRevoked: () => { console.log(' Esta máquina fue revocada desde tu bóveda. Para reconectarla, vuelve a enrolarla.\n'); process.exit(0) }
75
+ })
76
+ console.log('\n Dotrino IA agente activo')
77
+ console.log(' máquina:', agent.machineId)
78
+ console.log(' aparece solo en ia.dotrino.com\n')
79
+ // Mantener vivo el servicio aunque stdin no sea una TTY (systemd/pm2/`nohup </dev/null`):
80
+ // los sockets del proxy están `unref`'d, así que sin este keep-alive el proceso saldría
81
+ // justo después de arrancar. Vive hasta SIGINT/SIGTERM (o auto-borrado por revocación).
82
+ const keepAlive = setInterval(() => {}, 1 << 30)
83
+ const bye = () => { clearInterval(keepAlive); agent.close(); process.exit(0) }
84
+ process.on('SIGINT', bye); process.on('SIGTERM', bye)
85
+ } catch (e) {
86
+ console.error('error:', e.message)
42
87
  process.exit(1)
43
88
  }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * drivers/claude.js — invoca la CLI `claude` (Claude Code) en modo headless.
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.
7
+ *
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.
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). 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.
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
+ /** Envía un mensaje y devuelve { text, sessionId, tokens } cuando termina. */
35
+ async send (text) {
36
+ let out
37
+ try {
38
+ out = await this._run(this._args(text, this.sessionId))
39
+ } catch (e) {
40
+ // Sesión inválida/inexistente → empezar una nueva en vez de fallar.
41
+ if (this.sessionId && /no conversation found|session/i.test(e.message || '')) {
42
+ this.sessionId = null
43
+ out = await this._run(this._args(text, null))
44
+ } else throw e
45
+ }
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
+ }
56
+
57
+ _args (text, sid) {
58
+ const args = ['-p', text, '--output-format', 'json', ...this.flags]
59
+ if (sid) args.push('--resume', sid)
60
+ return args
61
+ }
62
+
63
+ _run (args) {
64
+ return new Promise((resolve, reject) => {
65
+ // stdin cerrado para que `claude -p` no espere por él.
66
+ const p = spawn(this.bin, args, {
67
+ cwd: this.cwd,
68
+ stdio: ['ignore', 'pipe', 'pipe'],
69
+ env: { ...process.env, PATH: `${process.env.PATH || ''}:${EXTRA_PATH}` }
70
+ })
71
+ let out = '', err = ''
72
+ p.stdout.on('data', (d) => { out += d })
73
+ p.stderr.on('data', (d) => { err += d })
74
+ const to = this.timeoutMs > 0
75
+ ? setTimeout(() => { try { p.kill('SIGKILL') } catch {}; reject(new Error('timeout')) }, this.timeoutMs)
76
+ : null
77
+ p.on('error', (e) => { if (to) clearTimeout(to); reject(new Error(`no se pudo ejecutar '${this.bin}' (¿instalado y en PATH?): ${e.message}`)) })
78
+ p.on('close', (code) => {
79
+ 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}`))
82
+ })
83
+ })
84
+ }
85
+ }
86
+
87
+ /** Tamaño aproximado del contexto de la última vuelta (tokens enviados). */
88
+ function contextTokens (j) {
89
+ const u = j && j.usage
90
+ if (!u) return 0
91
+ return (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
92
+ }
93
+
94
+ export default { ClaudeDriver }
package/index.js CHANGED
@@ -4,11 +4,22 @@
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 ECHOdevuelve 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
+ * F1: driver Claude (no-streaming) `claude -p --output-format json --resume`.
8
+ * F2: migrar a SDK con streaming de tokens. F4: driver OpenCode.
9
+ *
10
+ * Configuración (env):
11
+ * IA_CWD directorio donde opera Claude (default: el cwd al lanzar).
12
+ * CLAUDE_BIN binario (default 'claude').
13
+ * CLAUDE_FLAGS flags extra, ej. '--dangerously-skip-permissions' (modo yolo).
14
+ * CLAUDE_TIMEOUT segundos, 0 = sin límite.
10
15
  */
11
16
  import { startRemoteAgent } from '@dotrino/remote-agent/agent'
17
+ import { ClaudeDriver } from './drivers/claude.js'
18
+
19
+ const CWD = process.env.IA_CWD || process.cwd()
20
+ const CLAUDE_BIN = process.env.CLAUDE_BIN || 'claude'
21
+ const CLAUDE_FLAGS = (process.env.CLAUDE_FLAGS || '').split(/\s+/).filter(Boolean)
22
+ const CLAUDE_TIMEOUT = Number(process.env.CLAUDE_TIMEOUT || 0) * 1000
12
23
 
13
24
  export async function startIaAgent (opts = {}) {
14
25
  return startRemoteAgent({
@@ -19,12 +30,15 @@ export async function startIaAgent (opts = {}) {
19
30
  onReady: opts.onReady,
20
31
  onRevoked: opts.onRevoked,
21
32
  onSession (session) {
33
+ // Un driver por sesión: cada chat abre su propio hilo de Claude con su contexto.
34
+ const driver = new ClaudeDriver({ cwd: CWD, bin: CLAUDE_BIN, flags: CLAUDE_FLAGS, timeoutMs: CLAUDE_TIMEOUT })
22
35
  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 })
36
+ if (msg?.type !== 'msg' || typeof msg.text !== 'string') return
37
+ try {
38
+ const r = await driver.send(msg.text)
39
+ await session.send({ type: 'done', text: r.text, sessionId: r.sessionId, tokens: r.tokens })
40
+ } catch (e) {
41
+ await session.send({ type: 'error', message: e.message })
28
42
  }
29
43
  })
30
44
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/ia-agent",
3
- "version": "0.1.0",
3
+ "version": "0.2.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"