arsenal-agent 0.1.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/COMANDOS.md +34 -0
- package/README.md +36 -0
- package/app.py +308 -0
- package/dist/agents.d.ts +8 -0
- package/dist/agents.js +37 -0
- package/dist/app.d.ts +1 -0
- package/dist/app.js +121 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +57 -0
- package/dist/profiles.d.ts +14 -0
- package/dist/profiles.js +171 -0
- package/dist/router.d.ts +4 -0
- package/dist/router.js +42 -0
- package/dist/types.d.ts +20 -0
- package/dist/types.js +2 -0
- package/package.json +40 -0
- package/run.sh +19 -0
- package/src/agents.ts +54 -0
- package/src/app.tsx +236 -0
- package/src/index.tsx +64 -0
- package/src/profiles.ts +217 -0
- package/src/router.ts +47 -0
- package/src/types.ts +23 -0
- package/tsconfig.json +18 -0
package/COMANDOS.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# arsenal-agent — Comandos
|
|
2
|
+
|
|
3
|
+
## Trocar de conta
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
aa profile use pessoal # dbezerra106@gmail.com
|
|
7
|
+
aa profile use trabalho # claude-datascience@zerum.com
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Ver contas cadastradas
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
aa profile list
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Abrir o TUI (dual agent)
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
aa # usa o perfil ativo
|
|
20
|
+
aa --profile trabalho # força um perfil específico
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Adicionar nova conta
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
aa profile add <nome>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Onde ficam os tokens
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
~/.arsenal-agent/credentials/pessoal.json
|
|
33
|
+
~/.arsenal-agent/credentials/trabalho.json
|
|
34
|
+
```
|
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# arsenal-agent
|
|
2
|
+
|
|
3
|
+
Dual AI agent CLI — roteamento automático entre Claude Sonnet e Nemotron (free).
|
|
4
|
+
|
|
5
|
+
Tasks simples vão pro Nemotron via OpenCode (grátis). Tasks complexas vão pro Claude Sonnet.
|
|
6
|
+
|
|
7
|
+
## Instalação
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g arsenal-agent
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Uso
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
arsenal-agent
|
|
17
|
+
# ou o alias curto:
|
|
18
|
+
aa
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Requisitos
|
|
22
|
+
|
|
23
|
+
- `opencode` instalado: `npm install -g opencode-ai`
|
|
24
|
+
- `ANTHROPIC_API_KEY` no ambiente
|
|
25
|
+
- `OPENROUTER_API_KEY` no ambiente
|
|
26
|
+
|
|
27
|
+
## Como funciona
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
Você digita uma task
|
|
31
|
+
└── ROUTER classifica
|
|
32
|
+
├── task simples → Nemotron 120B (OpenRouter, free)
|
|
33
|
+
└── task complexa → Claude Sonnet 4.6 (Anthropic)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Teclado: `ctrl+c` sair · `ctrl+l` limpar
|
package/app.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Dual Agent CLI — Claude Code + OpenCode side-by-side
|
|
4
|
+
Roteador automatico: tasks simples vao pro Nemotron (free), complexas pro Claude Sonnet.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from textual.app import App, ComposeResult
|
|
14
|
+
from textual.containers import Horizontal, Vertical
|
|
15
|
+
from textual.widgets import (
|
|
16
|
+
Header, Footer, Input, RichLog, Static, Label
|
|
17
|
+
)
|
|
18
|
+
from textual.reactive import reactive
|
|
19
|
+
from textual import work
|
|
20
|
+
from rich.text import Text
|
|
21
|
+
from rich.panel import Panel
|
|
22
|
+
|
|
23
|
+
# ── Custo aproximado por 1k tokens ──────────────────────────────────────────
|
|
24
|
+
COST_PER_1K = {
|
|
25
|
+
"nemotron-120b": 0.0,
|
|
26
|
+
"nemotron-nano": 0.0,
|
|
27
|
+
"sonnet": 0.003,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
MODELS = {
|
|
31
|
+
"nemotron-120b": "openrouter/nvidia/nemotron-3-super-120b-a12b:free",
|
|
32
|
+
"nemotron-nano": "openrouter/nvidia/nemotron-nano-12b-v2-vl:free",
|
|
33
|
+
"sonnet": "anthropic/claude-sonnet-4-6",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# Palavras-chave que indicam task COMPLEXA → Claude Sonnet
|
|
37
|
+
COMPLEX_KEYWORDS = [
|
|
38
|
+
"arquitetura", "architecture", "seguranca", "security", "vulnerabilidade",
|
|
39
|
+
"refatora", "refactor", "por que", "why", "debug", "erro complexo",
|
|
40
|
+
"analisa", "analyze", "projeta", "design", "mcp", "agente", "agent",
|
|
41
|
+
"pipeline", "infraestrutura", "infrastructure", "revisao", "review",
|
|
42
|
+
"estrategia", "strategy", "implemente", "implement", "crie um sistema",
|
|
43
|
+
"como funciona", "how does", "explique a arquitetura",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def classify_task(task: str) -> tuple[str, str]:
|
|
48
|
+
"""Retorna (model_alias, razao) para a task."""
|
|
49
|
+
low = task.lower()
|
|
50
|
+
for kw in COMPLEX_KEYWORDS:
|
|
51
|
+
if kw in low:
|
|
52
|
+
return "sonnet", f"keyword: '{kw}'"
|
|
53
|
+
if len(task) > 400:
|
|
54
|
+
return "sonnet", "prompt longo (>400 chars)"
|
|
55
|
+
return "nemotron-120b", "task simples → free"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run_opencode(task: str, model_alias: str) -> tuple[str, float]:
|
|
59
|
+
"""Executa opencode run e retorna (output, tempo_segundos)."""
|
|
60
|
+
model_id = MODELS[model_alias]
|
|
61
|
+
t0 = time.time()
|
|
62
|
+
result = subprocess.run(
|
|
63
|
+
["opencode", "run", task, "--model", model_id],
|
|
64
|
+
capture_output=True, text=True, timeout=120,
|
|
65
|
+
)
|
|
66
|
+
elapsed = time.time() - t0
|
|
67
|
+
output = result.stdout.strip() or result.stderr.strip() or "Sem resposta."
|
|
68
|
+
# Remove escape sequences de cor do opencode
|
|
69
|
+
output = re.sub(r'\x1b\[[0-9;]*m', '', output)
|
|
70
|
+
output = re.sub(r'\[[\d]+m', '', output)
|
|
71
|
+
return output.strip(), elapsed
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def run_claude(task: str) -> tuple[str, float]:
|
|
75
|
+
"""Executa Claude via claude CLI e retorna (output, tempo_segundos)."""
|
|
76
|
+
t0 = time.time()
|
|
77
|
+
result = subprocess.run(
|
|
78
|
+
["claude", "--print", task],
|
|
79
|
+
capture_output=True, text=True, timeout=180,
|
|
80
|
+
env={**os.environ},
|
|
81
|
+
)
|
|
82
|
+
elapsed = time.time() - t0
|
|
83
|
+
output = result.stdout.strip() or result.stderr.strip() or "Sem resposta do Claude."
|
|
84
|
+
return output, elapsed
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── Widgets ─────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
class AgentPanel(Vertical):
|
|
90
|
+
"""Painel de um agente com log de mensagens."""
|
|
91
|
+
|
|
92
|
+
DEFAULT_CSS = """
|
|
93
|
+
AgentPanel {
|
|
94
|
+
border: solid $primary;
|
|
95
|
+
padding: 0 1;
|
|
96
|
+
height: 100%;
|
|
97
|
+
}
|
|
98
|
+
AgentPanel .panel-title {
|
|
99
|
+
text-align: center;
|
|
100
|
+
text-style: bold;
|
|
101
|
+
color: $accent;
|
|
102
|
+
padding: 0 0 1 0;
|
|
103
|
+
}
|
|
104
|
+
AgentPanel RichLog {
|
|
105
|
+
height: 1fr;
|
|
106
|
+
}
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(self, title: str, id: str):
|
|
110
|
+
super().__init__(id=id)
|
|
111
|
+
self._title = title
|
|
112
|
+
|
|
113
|
+
def compose(self) -> ComposeResult:
|
|
114
|
+
yield Label(self._title, classes="panel-title")
|
|
115
|
+
yield RichLog(highlight=True, markup=True, wrap=True, id=f"{self.id}-log")
|
|
116
|
+
|
|
117
|
+
def log(self, text: str, style: str = ""):
|
|
118
|
+
log = self.query_one(f"#{self.id}-log", RichLog)
|
|
119
|
+
if style:
|
|
120
|
+
log.write(Text(text, style=style))
|
|
121
|
+
else:
|
|
122
|
+
log.write(text)
|
|
123
|
+
|
|
124
|
+
def clear(self):
|
|
125
|
+
self.query_one(f"#{self.id}-log", RichLog).clear()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class StatsBar(Static):
|
|
129
|
+
"""Barra de estatísticas de tokens/custo."""
|
|
130
|
+
|
|
131
|
+
tasks_routed: reactive[int] = reactive(0)
|
|
132
|
+
tokens_saved: reactive[int] = reactive(0)
|
|
133
|
+
cost_paid: reactive[float] = reactive(0.0)
|
|
134
|
+
cost_saved: reactive[float] = reactive(0.0)
|
|
135
|
+
|
|
136
|
+
DEFAULT_CSS = """
|
|
137
|
+
StatsBar {
|
|
138
|
+
background: $surface;
|
|
139
|
+
padding: 0 2;
|
|
140
|
+
height: 3;
|
|
141
|
+
border-top: solid $primary-darken-2;
|
|
142
|
+
color: $text-muted;
|
|
143
|
+
}
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def render(self) -> str:
|
|
147
|
+
saved_pct = 0
|
|
148
|
+
total = self.cost_paid + self.cost_saved
|
|
149
|
+
if total > 0:
|
|
150
|
+
saved_pct = int((self.cost_saved / total) * 100)
|
|
151
|
+
return (
|
|
152
|
+
f" Tasks: [bold]{self.tasks_routed}[/bold] | "
|
|
153
|
+
f"Economizado: [green]{saved_pct}%[/green] (~${self.cost_saved:.4f}) | "
|
|
154
|
+
f"Gasto real: [yellow]${self.cost_paid:.4f}[/yellow] | "
|
|
155
|
+
f"[dim]Nemotron = free • Sonnet = $3/MTok[/dim]"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ── App principal ────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
class DualAgentApp(App):
|
|
162
|
+
"""Dual Agent CLI: Claude Code + OpenCode side-by-side."""
|
|
163
|
+
|
|
164
|
+
CSS = """
|
|
165
|
+
Screen {
|
|
166
|
+
background: #0a0a0a;
|
|
167
|
+
}
|
|
168
|
+
Header {
|
|
169
|
+
background: #111111;
|
|
170
|
+
}
|
|
171
|
+
Footer {
|
|
172
|
+
background: #111111;
|
|
173
|
+
}
|
|
174
|
+
#main {
|
|
175
|
+
height: 1fr;
|
|
176
|
+
}
|
|
177
|
+
#left-panel {
|
|
178
|
+
width: 1fr;
|
|
179
|
+
border: solid #00aaff;
|
|
180
|
+
}
|
|
181
|
+
#router-panel {
|
|
182
|
+
width: 28;
|
|
183
|
+
border: solid #444444;
|
|
184
|
+
padding: 0 1;
|
|
185
|
+
height: 100%;
|
|
186
|
+
}
|
|
187
|
+
#router-panel Label {
|
|
188
|
+
text-align: center;
|
|
189
|
+
color: #888888;
|
|
190
|
+
text-style: bold;
|
|
191
|
+
padding: 0 0 1 0;
|
|
192
|
+
}
|
|
193
|
+
#router-log {
|
|
194
|
+
height: 1fr;
|
|
195
|
+
color: #666666;
|
|
196
|
+
}
|
|
197
|
+
#right-panel {
|
|
198
|
+
width: 1fr;
|
|
199
|
+
border: solid #ff6600;
|
|
200
|
+
}
|
|
201
|
+
#input-bar {
|
|
202
|
+
height: 3;
|
|
203
|
+
padding: 0 1;
|
|
204
|
+
border-top: solid #222222;
|
|
205
|
+
}
|
|
206
|
+
#task-input {
|
|
207
|
+
width: 1fr;
|
|
208
|
+
}
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
TITLE = "Dual Agent CLI"
|
|
212
|
+
SUB_TITLE = "Claude Sonnet + Nemotron (free)"
|
|
213
|
+
BINDINGS = [
|
|
214
|
+
("ctrl+c", "quit", "Sair"),
|
|
215
|
+
("ctrl+l", "clear_all", "Limpar"),
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
def __init__(self):
|
|
219
|
+
super().__init__()
|
|
220
|
+
self._stats = None
|
|
221
|
+
|
|
222
|
+
def compose(self) -> ComposeResult:
|
|
223
|
+
yield Header()
|
|
224
|
+
with Horizontal(id="main"):
|
|
225
|
+
yield AgentPanel("Claude Sonnet 4.6 [dim](tasks complexas)[/dim]", id="left-panel")
|
|
226
|
+
with Vertical(id="router-panel"):
|
|
227
|
+
yield Label("ROUTER")
|
|
228
|
+
yield RichLog(highlight=False, markup=True, wrap=True, id="router-log")
|
|
229
|
+
yield AgentPanel("Nemotron 120B [dim](free • tasks simples)[/dim]", id="right-panel")
|
|
230
|
+
with Horizontal(id="input-bar"):
|
|
231
|
+
yield Input(placeholder="Digite sua task e pressione Enter...", id="task-input")
|
|
232
|
+
yield StatsBar(id="stats")
|
|
233
|
+
yield Footer()
|
|
234
|
+
|
|
235
|
+
def on_mount(self) -> None:
|
|
236
|
+
self._stats = self.query_one("#stats", StatsBar)
|
|
237
|
+
self.query_one("#task-input", Input).focus()
|
|
238
|
+
self._router_log("Sistema iniciado")
|
|
239
|
+
self._router_log(f"Claude: claude CLI")
|
|
240
|
+
self._router_log(f"OpenCode: {MODELS['nemotron-120b'][:30]}...")
|
|
241
|
+
|
|
242
|
+
def _router_log(self, msg: str) -> None:
|
|
243
|
+
ts = datetime.now().strftime("%H:%M:%S")
|
|
244
|
+
log = self.query_one("#router-log", RichLog)
|
|
245
|
+
log.write(f"[dim]{ts}[/dim] {msg}")
|
|
246
|
+
|
|
247
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
248
|
+
task = event.value.strip()
|
|
249
|
+
if not task:
|
|
250
|
+
return
|
|
251
|
+
event.input.value = ""
|
|
252
|
+
self._dispatch_task(task)
|
|
253
|
+
|
|
254
|
+
@work(thread=True)
|
|
255
|
+
def _dispatch_task(self, task: str) -> None:
|
|
256
|
+
model_alias, reason = classify_task(task)
|
|
257
|
+
ts = datetime.now().strftime("%H:%M:%S")
|
|
258
|
+
|
|
259
|
+
self.call_from_thread(self._router_log, f"[cyan]→[/cyan] {task[:40]}...")
|
|
260
|
+
self.call_from_thread(self._router_log, f" [yellow]{model_alias}[/yellow] ({reason})")
|
|
261
|
+
|
|
262
|
+
if model_alias == "sonnet":
|
|
263
|
+
panel = self.query_one("#left-panel", AgentPanel)
|
|
264
|
+
self.call_from_thread(panel.log, f"\n[bold cyan][{ts}] Você:[/bold cyan] {task}")
|
|
265
|
+
self.call_from_thread(panel.log, "[dim]aguardando Claude...[/dim]", "dim")
|
|
266
|
+
|
|
267
|
+
output, elapsed = run_claude(task)
|
|
268
|
+
|
|
269
|
+
self.call_from_thread(panel.log, f"[bold green]Claude:[/bold green]")
|
|
270
|
+
self.call_from_thread(panel.log, output)
|
|
271
|
+
self.call_from_thread(panel.log, f"[dim]({elapsed:.1f}s)[/dim]")
|
|
272
|
+
|
|
273
|
+
# Estima custo: ~300 tokens de output
|
|
274
|
+
cost = (300 / 1000) * COST_PER_1K["sonnet"]
|
|
275
|
+
cost_saved = (300 / 1000) * COST_PER_1K["nemotron-120b"] # seria 0
|
|
276
|
+
self.call_from_thread(self._update_stats, cost, 0.0)
|
|
277
|
+
|
|
278
|
+
else:
|
|
279
|
+
panel = self.query_one("#right-panel", AgentPanel)
|
|
280
|
+
self.call_from_thread(panel.log, f"\n[bold orange1][{ts}] Você:[/bold orange1] {task}")
|
|
281
|
+
self.call_from_thread(panel.log, "[dim]aguardando Nemotron...[/dim]", "dim")
|
|
282
|
+
|
|
283
|
+
output, elapsed = run_opencode(task, model_alias)
|
|
284
|
+
|
|
285
|
+
self.call_from_thread(panel.log, f"[bold green]Nemotron:[/bold green]")
|
|
286
|
+
self.call_from_thread(panel.log, output)
|
|
287
|
+
self.call_from_thread(panel.log, f"[dim]({elapsed:.1f}s • $0.00)[/dim]")
|
|
288
|
+
|
|
289
|
+
# Quanto teria custado no Sonnet
|
|
290
|
+
cost_saved = (300 / 1000) * COST_PER_1K["sonnet"]
|
|
291
|
+
self.call_from_thread(self._update_stats, 0.0, cost_saved)
|
|
292
|
+
|
|
293
|
+
def _update_stats(self, cost_paid: float, cost_saved: float) -> None:
|
|
294
|
+
stats = self._stats
|
|
295
|
+
stats.tasks_routed += 1
|
|
296
|
+
stats.cost_paid += cost_paid
|
|
297
|
+
stats.cost_saved += cost_saved
|
|
298
|
+
|
|
299
|
+
def action_clear_all(self) -> None:
|
|
300
|
+
self.query_one("#left-panel", AgentPanel).clear()
|
|
301
|
+
self.query_one("#right-panel", AgentPanel).clear()
|
|
302
|
+
self.query_one("#router-log", RichLog).clear()
|
|
303
|
+
self._router_log("Logs limpos")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
if __name__ == "__main__":
|
|
307
|
+
app = DualAgentApp()
|
|
308
|
+
app.run()
|
package/dist/agents.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ModelAlias } from "./types.js";
|
|
2
|
+
export interface AgentResult {
|
|
3
|
+
content: string;
|
|
4
|
+
elapsed: number;
|
|
5
|
+
model: ModelAlias;
|
|
6
|
+
}
|
|
7
|
+
export declare function runClaude(task: string): Promise<AgentResult>;
|
|
8
|
+
export declare function runOpencode(task: string, alias: ModelAlias): Promise<AgentResult>;
|
package/dist/agents.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
4
|
+
import { getModelId } from "./router.js";
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
const anthropic = new Anthropic({
|
|
7
|
+
apiKey: process.env.ANTHROPIC_API_KEY,
|
|
8
|
+
});
|
|
9
|
+
export async function runClaude(task) {
|
|
10
|
+
const t0 = Date.now();
|
|
11
|
+
const message = await anthropic.messages.create({
|
|
12
|
+
model: "claude-sonnet-4-6-20251101",
|
|
13
|
+
max_tokens: 2048,
|
|
14
|
+
messages: [{ role: "user", content: task }],
|
|
15
|
+
});
|
|
16
|
+
const content = message.content
|
|
17
|
+
.filter((b) => b.type === "text")
|
|
18
|
+
.map((b) => b.text)
|
|
19
|
+
.join("\n");
|
|
20
|
+
return { content, elapsed: (Date.now() - t0) / 1000, model: "sonnet" };
|
|
21
|
+
}
|
|
22
|
+
export async function runOpencode(task, alias) {
|
|
23
|
+
const t0 = Date.now();
|
|
24
|
+
const modelId = getModelId(alias);
|
|
25
|
+
try {
|
|
26
|
+
const { stdout, stderr } = await execFileAsync("opencode", ["run", task, "--model", modelId], { timeout: 120_000, env: process.env });
|
|
27
|
+
const raw = stdout || stderr || "Sem resposta.";
|
|
28
|
+
// Remove ANSI escape codes
|
|
29
|
+
const content = raw.replace(/\x1b\[[0-9;]*m/g, "").replace(/\[[\d]+m/g, "").trim();
|
|
30
|
+
return { content, elapsed: (Date.now() - t0) / 1000, model: alias };
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
34
|
+
return { content: `Erro: ${msg}`, elapsed: (Date.now() - t0) / 1000, model: alias };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=agents.js.map
|
package/dist/app.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function App(): import("react/jsx-runtime").JSX.Element;
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useCallback } from "react";
|
|
3
|
+
import { Box, Text, useInput, useApp } from "ink";
|
|
4
|
+
import { classifyTask, COST_PER_1K } from "./router.js";
|
|
5
|
+
import { runClaude, runOpencode } from "./agents.js";
|
|
6
|
+
// ── Componentes ──────────────────────────────────────────────────────────────
|
|
7
|
+
function MessageList({ messages, model }) {
|
|
8
|
+
const filtered = messages.filter((m) => model === "sonnet" ? m.model === "sonnet" : m.model !== "sonnet");
|
|
9
|
+
return (_jsx(Box, { flexDirection: "column", gap: 0, children: filtered.map((msg) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { color: msg.role === "user" ? "cyan" : "green", bold: true, children: [msg.role === "user" ? "Você" : model === "sonnet" ? "Claude" : "Nemotron", msg.elapsed ? ` (${msg.elapsed.toFixed(1)}s)` : ""] }), _jsx(Text, { wrap: "wrap", children: msg.content })] }, msg.id))) }));
|
|
10
|
+
}
|
|
11
|
+
function Panel({ title, color, messages, model, loading, width, }) {
|
|
12
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: color, width: width, height: 30, paddingX: 1, children: [_jsx(Text, { bold: true, color: color, children: title }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: [_jsx(MessageList, { messages: messages, model: model }), loading && (_jsx(Text, { color: "gray", dimColor: true, children: "aguardando..." }))] })] }));
|
|
13
|
+
}
|
|
14
|
+
function RouterLog({ entries }) {
|
|
15
|
+
const last = entries.slice(-12);
|
|
16
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", width: 26, height: 30, paddingX: 1, children: [_jsx(Text, { bold: true, color: "gray", children: "ROUTER" }), last.map((e, i) => (_jsx(Text, { color: "gray", dimColor: true, wrap: "wrap", children: e }, i)))] }));
|
|
17
|
+
}
|
|
18
|
+
function StatsBar({ stats }) {
|
|
19
|
+
const pct = stats.costSaved + stats.costPaid > 0
|
|
20
|
+
? Math.round((stats.costSaved / (stats.costSaved + stats.costPaid)) * 100)
|
|
21
|
+
: 0;
|
|
22
|
+
return (_jsx(Box, { borderStyle: "single", borderColor: "gray", paddingX: 2, marginTop: 0, children: _jsxs(Text, { children: ["Tasks: ", _jsx(Text, { bold: true, children: stats.tasksTotal }), " ", "Free: ", _jsx(Text, { color: "green", bold: true, children: stats.tasksFree }), " ", "Pago: ", _jsx(Text, { color: "yellow", bold: true, children: stats.tasksPaid }), " ", "Economizado: ", _jsxs(Text, { color: "green", bold: true, children: [pct, "%"] }), " (~$", stats.costSaved.toFixed(4), ")", " ", "Gasto: ", _jsxs(Text, { color: "yellow", children: ["$", stats.costPaid.toFixed(4)] }), " ", _jsx(Text, { dimColor: true, children: "[ctrl+c sair \u2022 ctrl+l limpar]" })] }) }));
|
|
23
|
+
}
|
|
24
|
+
// ── App principal ─────────────────────────────────────────────────────────────
|
|
25
|
+
export function App() {
|
|
26
|
+
const { exit } = useApp();
|
|
27
|
+
const [messages, setMessages] = useState([]);
|
|
28
|
+
const [routerLog, setRouterLog] = useState(["iniciado", "claude: sonnet-4-6", "free: nemotron-120b"]);
|
|
29
|
+
const [input, setInput] = useState("");
|
|
30
|
+
const [loadingLeft, setLoadingLeft] = useState(false);
|
|
31
|
+
const [loadingRight, setLoadingRight] = useState(false);
|
|
32
|
+
const [stats, setStats] = useState({
|
|
33
|
+
tasksTotal: 0, tasksFree: 0, tasksPaid: 0, costPaid: 0, costSaved: 0,
|
|
34
|
+
});
|
|
35
|
+
const addRouter = useCallback((line) => {
|
|
36
|
+
setRouterLog((prev) => [...prev, line]);
|
|
37
|
+
}, []);
|
|
38
|
+
const dispatch = useCallback(async (task) => {
|
|
39
|
+
const decision = classifyTask(task);
|
|
40
|
+
const ts = new Date().toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
41
|
+
addRouter(`${ts} → ${task.slice(0, 22)}...`);
|
|
42
|
+
addRouter(` ${decision.model} (${decision.reason})`);
|
|
43
|
+
const userMsg = {
|
|
44
|
+
id: `u-${Date.now()}`,
|
|
45
|
+
role: "user",
|
|
46
|
+
content: task,
|
|
47
|
+
model: decision.model,
|
|
48
|
+
timestamp: new Date(),
|
|
49
|
+
};
|
|
50
|
+
setMessages((prev) => [...prev, userMsg]);
|
|
51
|
+
if (decision.model === "sonnet") {
|
|
52
|
+
setLoadingLeft(true);
|
|
53
|
+
try {
|
|
54
|
+
const result = await runClaude(task);
|
|
55
|
+
const assistantMsg = {
|
|
56
|
+
id: `a-${Date.now()}`,
|
|
57
|
+
role: "assistant",
|
|
58
|
+
content: result.content,
|
|
59
|
+
model: "sonnet",
|
|
60
|
+
elapsed: result.elapsed,
|
|
61
|
+
timestamp: new Date(),
|
|
62
|
+
};
|
|
63
|
+
setMessages((prev) => [...prev, assistantMsg]);
|
|
64
|
+
const cost = (300 / 1000) * COST_PER_1K["sonnet"];
|
|
65
|
+
const saved = (300 / 1000) * COST_PER_1K["nemotron-120b"];
|
|
66
|
+
setStats((s) => ({ ...s, tasksTotal: s.tasksTotal + 1, tasksPaid: s.tasksPaid + 1, costPaid: s.costPaid + cost, costSaved: s.costSaved + saved }));
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
setLoadingLeft(false);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
setLoadingRight(true);
|
|
74
|
+
try {
|
|
75
|
+
const result = await runOpencode(task, decision.model);
|
|
76
|
+
const assistantMsg = {
|
|
77
|
+
id: `a-${Date.now()}`,
|
|
78
|
+
role: "assistant",
|
|
79
|
+
content: result.content,
|
|
80
|
+
model: decision.model,
|
|
81
|
+
elapsed: result.elapsed,
|
|
82
|
+
timestamp: new Date(),
|
|
83
|
+
};
|
|
84
|
+
setMessages((prev) => [...prev, assistantMsg]);
|
|
85
|
+
const saved = (300 / 1000) * COST_PER_1K["sonnet"];
|
|
86
|
+
setStats((s) => ({ ...s, tasksTotal: s.tasksTotal + 1, tasksFree: s.tasksFree + 1, costSaved: s.costSaved + saved }));
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
setLoadingRight(false);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}, [addRouter]);
|
|
93
|
+
useInput((char, key) => {
|
|
94
|
+
if (key.ctrl && char === "c") {
|
|
95
|
+
exit();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (key.ctrl && char === "l") {
|
|
99
|
+
setMessages([]);
|
|
100
|
+
setRouterLog(["limpo"]);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (key.return) {
|
|
104
|
+
const task = input.trim();
|
|
105
|
+
if (task) {
|
|
106
|
+
setInput("");
|
|
107
|
+
dispatch(task);
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (key.backspace || key.delete) {
|
|
112
|
+
setInput((prev) => prev.slice(0, -1));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (char && !key.ctrl && !key.meta) {
|
|
116
|
+
setInput((prev) => prev + char);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "blue", children: " arsenal-agent " }), _jsx(Text, { dimColor: true, children: "Claude Sonnet + Nemotron free \u2014 roteamento automatico" })] }), _jsxs(Box, { flexDirection: "row", gap: 0, children: [_jsx(Panel, { title: "Claude Sonnet 4.6 (tasks complexas)", color: "cyan", messages: messages, model: "sonnet", loading: loadingLeft, width: 50 }), _jsx(RouterLog, { entries: routerLog }), _jsx(Panel, { title: "Nemotron 120B (free \u00B7 tasks simples)", color: "yellow", messages: messages, model: "nemotron", loading: loadingRight, width: 50 })] }), _jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { color: "gray", children: [">", " "] }), _jsx(Text, { children: input }), _jsx(Text, { color: "gray", children: "\u2588" })] }), _jsx(StatsBar, { stats: stats })] }));
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=app.js.map
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { render } from "ink";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { App } from "./app.js";
|
|
6
|
+
import { addProfile, listProfiles, useProfile, loadProfile, applyProfile } from "./profiles.js";
|
|
7
|
+
const program = new Command();
|
|
8
|
+
program
|
|
9
|
+
.name("arsenal-agent")
|
|
10
|
+
.description("Dual AI agent CLI — Claude Sonnet + Nemotron free, roteamento automatico")
|
|
11
|
+
.version("0.1.0")
|
|
12
|
+
.option("-p, --profile <name>", "perfil a usar (substitui env vars)");
|
|
13
|
+
// ── Subcomando: profile ────────────────────────────────────────────────────
|
|
14
|
+
const profileCmd = program
|
|
15
|
+
.command("profile")
|
|
16
|
+
.description("gerenciar perfis de API keys");
|
|
17
|
+
profileCmd
|
|
18
|
+
.command("add [name]")
|
|
19
|
+
.description("adicionar ou atualizar um perfil")
|
|
20
|
+
.action(async (name) => {
|
|
21
|
+
await addProfile(name);
|
|
22
|
+
process.exit(0);
|
|
23
|
+
});
|
|
24
|
+
profileCmd
|
|
25
|
+
.command("list")
|
|
26
|
+
.alias("ls")
|
|
27
|
+
.description("listar perfis cadastrados")
|
|
28
|
+
.action(() => {
|
|
29
|
+
listProfiles();
|
|
30
|
+
process.exit(0);
|
|
31
|
+
});
|
|
32
|
+
profileCmd
|
|
33
|
+
.command("use <name>")
|
|
34
|
+
.description("definir perfil ativo padrão")
|
|
35
|
+
.action((name) => {
|
|
36
|
+
useProfile(name);
|
|
37
|
+
process.exit(0);
|
|
38
|
+
});
|
|
39
|
+
// ── Ação principal: lança TUI ──────────────────────────────────────────────
|
|
40
|
+
program.action((opts) => {
|
|
41
|
+
// Aplica perfil se informado ou se há um perfil ativo salvo
|
|
42
|
+
const profile = loadProfile(opts.profile);
|
|
43
|
+
if (profile) {
|
|
44
|
+
applyProfile(profile);
|
|
45
|
+
process.stderr.write(`[arsenal-agent] perfil: ${profile.label ?? profile.name}\n`);
|
|
46
|
+
}
|
|
47
|
+
else if (!process.env.ANTHROPIC_API_KEY) {
|
|
48
|
+
console.error("Nenhuma ANTHROPIC_API_KEY encontrada.");
|
|
49
|
+
console.error("Adicione um perfil: aa profile add");
|
|
50
|
+
console.error("Ou exporte a variável: export ANTHROPIC_API_KEY=sk-ant-...");
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
const { waitUntilExit } = render(_jsx(App, {}));
|
|
54
|
+
waitUntilExit().then(() => process.exit(0));
|
|
55
|
+
});
|
|
56
|
+
program.parse();
|
|
57
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type AuthMethod = "api_key" | "browser";
|
|
2
|
+
export interface Profile {
|
|
3
|
+
name: string;
|
|
4
|
+
label?: string;
|
|
5
|
+
auth_method: AuthMethod;
|
|
6
|
+
anthropic_api_key?: string;
|
|
7
|
+
openrouter_api_key?: string;
|
|
8
|
+
email?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function addProfile(name?: string): Promise<void>;
|
|
11
|
+
export declare function listProfiles(): void;
|
|
12
|
+
export declare function useProfile(name: string): void;
|
|
13
|
+
export declare function loadProfile(name?: string): Profile | null;
|
|
14
|
+
export declare function applyProfile(profile: Profile): void;
|