primocode 9.8.0-beta.0 → 9.8.0-beta.2
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/README.md +5 -4
- package/bin/primocode.js +73 -0
- package/lib/api.js +21 -0
- package/lib/claude-engine.js +630 -0
- package/lib/codex-engine.js +207 -0
- package/lib/local/cerebro.json +2116 -0
- package/lib/local/entrada.js +332 -0
- package/lib/local/motor-cli.js +90 -0
- package/lib/local/motor.js +264 -0
- package/lib/local/provedor.js +169 -0
- package/package.json +2 -2
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claude-engine.js — motor alternativo do agente: Claude Code local em vez
|
|
3
|
+
* de OpenRouter/Groq via primocode-server.
|
|
4
|
+
*
|
|
5
|
+
* Implementa a MESMA forma que lib/api.js (requestAct(server, payload,
|
|
6
|
+
* onEvent)) — é o que permite lib/act.js trocar de motor sem saber disso.
|
|
7
|
+
* Ver docs/superpowers/specs/2026-08-15-claude-engine-design.md.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const { execSync, execFileSync } = require('child_process');
|
|
16
|
+
|
|
17
|
+
/* ── O MOTOR EXTERNO NÃO APARECE ────────────────────────────────────────
|
|
18
|
+
*
|
|
19
|
+
* "O Primo Code abre esses processos ocultamente (em segundo plano) e usa
|
|
20
|
+
* apenas o modelo dessas ferramentas. Toda a interface, comandos, agentes e
|
|
21
|
+
* experiência continuam sendo 100% do Primo Code."
|
|
22
|
+
*
|
|
23
|
+
* `execFileSync` sem `stdio` HERDA o stderr do pai — é o padrão do Node, e não
|
|
24
|
+
* parece nada até o dia em que o motor imprime um aviso de versão, de
|
|
25
|
+
* atualização ou de configuração. Aquilo cai no meio da tela do PrimoCode, com
|
|
26
|
+
* a cara e o vocabulário de outro produto, no meio de uma tarefa.
|
|
27
|
+
*
|
|
28
|
+
* Com o stderr CAPTURADO ele some da tela e ganha um segundo uso: `e.stderr` só
|
|
29
|
+
* existe quando é canalizado, e o tratamento de erro daqui (e o do
|
|
30
|
+
* codex-engine) já lia esse campo — estava lendo `null` desde sempre, e a
|
|
31
|
+
* mensagem de falha do motor saía sem o motivo que ele mesmo tinha escrito.
|
|
32
|
+
*
|
|
33
|
+
* `windowsHide` é a outra metade do "ocultamente": sem ele, cada chamada
|
|
34
|
+
* pisca um console preto na frente de quem usa Windows. */
|
|
35
|
+
const SEM_APARECER = { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true };
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
const MARCA_INICIO = '<<<PRIMOCODE>>>';
|
|
39
|
+
const MARCA_FIM = '<<<FIM>>>';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* O modelo que o /claude pede.
|
|
43
|
+
*
|
|
44
|
+
* "Ele usa o modelo Opus 5 [no Claude]."
|
|
45
|
+
*
|
|
46
|
+
* Vai o APELIDO (`opus`), não o identificador completo: o apelido é o que o
|
|
47
|
+
* Claude Code resolve para o Opus do dia, e um identificador fixo aqui
|
|
48
|
+
* envelheceria dentro de um pacote publicado no npm — a pessoa atualizaria o
|
|
49
|
+
* Claude Code, o modelo sairia de catálogo, e o PrimoCode pediria um modelo
|
|
50
|
+
* que não existe mais.
|
|
51
|
+
*
|
|
52
|
+
* Quem quiser outro põe PRIMOCODE_CLAUDE_MODELO. Vazio manda sem `--model`, e
|
|
53
|
+
* aí vale o padrão da conta da pessoa.
|
|
54
|
+
*/
|
|
55
|
+
const MODELO_CLAUDE = process.env.PRIMOCODE_CLAUDE_MODELO !== undefined
|
|
56
|
+
? process.env.PRIMOCODE_CLAUDE_MODELO
|
|
57
|
+
: 'opus';
|
|
58
|
+
|
|
59
|
+
/** Os argumentos do modelo, ou nenhum quando a escolha é do dono da conta. */
|
|
60
|
+
function argsDoModelo() {
|
|
61
|
+
return MODELO_CLAUDE ? ['--model', MODELO_CLAUDE] : [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Acha o ÚLTIMO bloco marcado no texto (pode vir ruído de terminal — prompt
|
|
66
|
+
* ANSI, texto solto — antes e depois) e devolve o JSON de dentro dele.
|
|
67
|
+
* null quando não achou bloco válido: quem chama decide o que fazer.
|
|
68
|
+
*/
|
|
69
|
+
function extrairDecisao(texto) {
|
|
70
|
+
const t = String(texto || '');
|
|
71
|
+
|
|
72
|
+
// Varre TODOS os blocos completos e fica com o último que dá para ler.
|
|
73
|
+
//
|
|
74
|
+
// A versão anterior fazia lastIndexOf(INICIO) e depois procurava o FIM
|
|
75
|
+
// para frente. Bastava o modelo escrever a marca de abertura mais uma vez
|
|
76
|
+
// — explicando o formato, ou começando um bloco e desistindo — para o FIM
|
|
77
|
+
// não existir depois dela: a extração devolvia null e o protocolo CRU ia
|
|
78
|
+
// parar na tela do usuário, com tool_call e tudo. Foi o que aconteceu num
|
|
79
|
+
// pedido real de vídeo.
|
|
80
|
+
const blocos = [];
|
|
81
|
+
let i = 0;
|
|
82
|
+
while (true) {
|
|
83
|
+
const inicio = t.indexOf(MARCA_INICIO, i);
|
|
84
|
+
if (inicio === -1) break;
|
|
85
|
+
const fim = t.indexOf(MARCA_FIM, inicio + MARCA_INICIO.length);
|
|
86
|
+
if (fim === -1) break; // bloco aberto e não fechado: ignora
|
|
87
|
+
blocos.push(t.slice(inicio + MARCA_INICIO.length, fim));
|
|
88
|
+
i = fim + MARCA_FIM.length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (let k = blocos.length - 1; k >= 0; k--) {
|
|
92
|
+
const lido = lerJSON(blocos[k]);
|
|
93
|
+
if (lido) return lido;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** JSON com quebra de linha CRUA dentro das aspas — o jeito mais comum de o
|
|
99
|
+
* motor falhar. O Claude escreve o content de um write_file com linhas de
|
|
100
|
+
* verdade em vez de \n, o JSON.parse recusa, e o turno inteiro morria com
|
|
101
|
+
* "decisão que não consegui ler". O conserto é mecânico: dentro de string,
|
|
102
|
+
* controle vira escape. */
|
|
103
|
+
function consertarControles(s) {
|
|
104
|
+
let saida = '';
|
|
105
|
+
let dentro = false, escapa = false;
|
|
106
|
+
for (const c of String(s || '')) {
|
|
107
|
+
if (dentro && !escapa && (c === '\n' || c === '\r' || c === '\t')) {
|
|
108
|
+
saida += c === '\n' ? '\\n' : c === '\r' ? '\\r' : '\\t';
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (escapa) { escapa = false; saida += c; continue; }
|
|
112
|
+
if (c === '\\') { escapa = true; saida += c; continue; }
|
|
113
|
+
if (c === '"') dentro = !dentro;
|
|
114
|
+
saida += c;
|
|
115
|
+
}
|
|
116
|
+
return saida;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** O JSON do bloco, tolerando o que o modelo costuma acrescentar sem querer. */
|
|
120
|
+
function lerJSON(bruto) {
|
|
121
|
+
let s = String(bruto || '').trim();
|
|
122
|
+
// Cerca de markdown em volta do JSON — o modelo põe por hábito.
|
|
123
|
+
s = s.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim();
|
|
124
|
+
try { return JSON.parse(s); } catch {}
|
|
125
|
+
try { return JSON.parse(consertarControles(s)); } catch {}
|
|
126
|
+
s = consertarControles(s);
|
|
127
|
+
// Sobrou texto depois do objeto? Corta no fecha-chaves que casa com o primeiro.
|
|
128
|
+
const abre = s.indexOf('{');
|
|
129
|
+
if (abre === -1) return null;
|
|
130
|
+
let nivel = 0, dentro = false, escapa = false;
|
|
131
|
+
for (let k = abre; k < s.length; k++) {
|
|
132
|
+
const c = s[k];
|
|
133
|
+
if (escapa) { escapa = false; continue; }
|
|
134
|
+
if (c === '\\') { escapa = true; continue; }
|
|
135
|
+
if (c === '"') { dentro = !dentro; continue; }
|
|
136
|
+
if (dentro) continue;
|
|
137
|
+
if (c === '{') nivel++;
|
|
138
|
+
else if (c === '}' && --nivel === 0) {
|
|
139
|
+
const trecho = s.slice(abre, k + 1);
|
|
140
|
+
try { return JSON.parse(trecho); } catch {}
|
|
141
|
+
try { return JSON.parse(consertarControles(trecho)); } catch { return null; }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const catalogo = require('./catalogo');
|
|
148
|
+
|
|
149
|
+
/** Serializa lib/catalogo.js em texto — única fonte de verdade, sem
|
|
150
|
+
* duplicar a lista de ferramentas num arquivo separado. */
|
|
151
|
+
function catalogoParaTexto() {
|
|
152
|
+
const linhas = [];
|
|
153
|
+
for (const grupo of catalogo.GRUPOS) {
|
|
154
|
+
linhas.push(`## ${grupo.nome} — ${grupo.resumo}`);
|
|
155
|
+
for (const item of grupo.itens) {
|
|
156
|
+
const args = item.args ? ` | args: ${item.args}` : ' | sem args';
|
|
157
|
+
linhas.push(`- ${item.tool}: ${item.faz}${args}`);
|
|
158
|
+
}
|
|
159
|
+
linhas.push('');
|
|
160
|
+
}
|
|
161
|
+
return linhas.join('\n').trim();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Texto completo que ensina o Claude Code a se comportar como motor do
|
|
165
|
+
* PrimoCode. Vira o conteúdo de ~/.claude/skills/primocode-engine/SKILL.md
|
|
166
|
+
* (Task 4) e é referenciado na primeira mensagem de cada sessão. */
|
|
167
|
+
function textoBootstrap() {
|
|
168
|
+
return `---
|
|
169
|
+
name: primocode-engine
|
|
170
|
+
description: Skill instalada pelo PrimoCode para o modo /claude — motor de decisão do agente, não um agente autônomo.
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
# PrimoCode — modo motor
|
|
174
|
+
|
|
175
|
+
Você está sendo usado como o "cérebro" do PrimoCode, um CLI que executa
|
|
176
|
+
tarefas de engenharia. Não use suas próprias ferramentas nativas (Read,
|
|
177
|
+
Write, Edit, Bash, Glob, Grep, WebFetch, etc.) — quem executa é o
|
|
178
|
+
PrimoCode, não você. Sua única saída é dizer QUAL ferramenta do PrimoCode
|
|
179
|
+
chamar, com quais argumentos, ou a resposta final.
|
|
180
|
+
|
|
181
|
+
## Ferramentas disponíveis
|
|
182
|
+
|
|
183
|
+
${catalogoParaTexto()}
|
|
184
|
+
|
|
185
|
+
## Formato de resposta — obrigatório
|
|
186
|
+
|
|
187
|
+
Termine toda resposta com exatamente um bloco, sem nada depois dele.
|
|
188
|
+
|
|
189
|
+
Para chamar uma ferramenta:
|
|
190
|
+
|
|
191
|
+
${MARCA_INICIO}
|
|
192
|
+
{"tool_call": {"name": "write_file", "arguments": {"path": "index.html", "content": "..."}}}
|
|
193
|
+
${MARCA_FIM}
|
|
194
|
+
|
|
195
|
+
Para terminar a tarefa (sem mais ferramenta pra chamar):
|
|
196
|
+
|
|
197
|
+
${MARCA_INICIO}
|
|
198
|
+
{"assistant_message": "frase curta do que foi feito"}
|
|
199
|
+
${MARCA_FIM}
|
|
200
|
+
|
|
201
|
+
Nunca omita o bloco. Nunca escreva mais de um bloco por resposta.`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const SKILL_DIR = () => path.join(os.homedir(), '.claude', 'skills', 'primocode-engine');
|
|
205
|
+
const SKILL_PATH = () => path.join(SKILL_DIR(), 'SKILL.md');
|
|
206
|
+
|
|
207
|
+
/** Grava (ou regrava) a skill em ~/.claude/skills/primocode-engine/SKILL.md.
|
|
208
|
+
* Sempre reescreve com o texto atual — barato, e garante que uma versão
|
|
209
|
+
* nova do PrimoCode (com ferramenta nova em catalogo.js) atualiza a skill
|
|
210
|
+
* sozinha na próxima vez que /claude ligar. */
|
|
211
|
+
function garantirSkill() {
|
|
212
|
+
const dir = SKILL_DIR();
|
|
213
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
214
|
+
const caminho = SKILL_PATH();
|
|
215
|
+
fs.writeFileSync(caminho, textoBootstrap(), 'utf-8');
|
|
216
|
+
return { ok: true, caminho };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** executor(cmd) roda um comando e devolve a saída, ou lança. Default:
|
|
220
|
+
* execSync de verdade. Testes injetam uma função fake — sem mock framework. */
|
|
221
|
+
function temClaudeCode(executor = (cmd) => execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] })) {
|
|
222
|
+
try { executor('claude --version'); return true; }
|
|
223
|
+
catch { return false; }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function temOrca(executor = (cmd) => execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] })) {
|
|
227
|
+
try { executor('orca --version'); return true; }
|
|
228
|
+
catch { return false; }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** executor aqui devolve TEXTO (o JSON cru de `orca status --json`), não
|
|
232
|
+
* só "roda ou lança" — por isso um default diferente do de cima. */
|
|
233
|
+
function orcaAlcancavel(executor = (cmd) => execSync(cmd, { encoding: 'utf-8' })) {
|
|
234
|
+
try {
|
|
235
|
+
const saida = executor('orca status --json');
|
|
236
|
+
const j = JSON.parse(saida);
|
|
237
|
+
return !!(j && j.result && j.result.runtime && j.result.runtime.reachable);
|
|
238
|
+
} catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Função pura — nenhum I/O aqui, só decide. */
|
|
244
|
+
function escolherCaminho({ temClaude, temOrcaCli, orcaOk }) {
|
|
245
|
+
if (!temClaude) return 'sem-claude';
|
|
246
|
+
if (temOrcaCli && orcaOk) return 'A';
|
|
247
|
+
return 'B';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Handle do terminal ORCA desta execução do processo — sessão contínua
|
|
251
|
+
// enquanto o CLI ficar de pé. Cursor de leitura por handle, pra "terminal
|
|
252
|
+
// read --cursor" trazer só o que é novo desde o turno anterior.
|
|
253
|
+
let sessao = { handle: null, cursor: '0', bootstrapEnviado: false };
|
|
254
|
+
|
|
255
|
+
function rodarOrca(args, opts = {}) {
|
|
256
|
+
return execFileSync('orca', args,
|
|
257
|
+
{ encoding: 'utf-8', timeout: opts.timeout || 15000, ...SEM_APARECER });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let contadorId = 0;
|
|
261
|
+
/** Id sintético pro tool_call — o bloco marcado do Claude Code não traz um
|
|
262
|
+
* (só existe UM tool_call por resposta, pela própria regra do bootstrap),
|
|
263
|
+
* mas lib/act.js precisa de tc.id pra parear com a mensagem role:'tool' que
|
|
264
|
+
* vem depois. Único dentro do processo já basta. */
|
|
265
|
+
function proximoId() {
|
|
266
|
+
contadorId += 1;
|
|
267
|
+
return `claude-engine-${contadorId}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Caminho A (sessão contínua via ORCA) tem um problema arquitetural real,
|
|
271
|
+
// achado rodando de verdade na verificação manual (Task 9 do plano): o
|
|
272
|
+
// Claude Code interativo desenha a TUI em tela alternada, e
|
|
273
|
+
// `orca terminal read` (scrollback linha a linha) nunca enxerga esse
|
|
274
|
+
// conteúdo — o tail fica preso no eco do comando de lançamento. `orca
|
|
275
|
+
// terminal show`'s preview mostra a tela renderizada de verdade, mas é só
|
|
276
|
+
// um recorte das últimas linhas visíveis, sem garantia de conter o bloco
|
|
277
|
+
// marcado inteiro numa resposta mais longa que o painel. Até esse
|
|
278
|
+
// mecanismo de leitura ser resolvido, o Caminho B (testado de ponta a
|
|
279
|
+
// ponta, funciona) é o único confiável — força ele sempre, mesmo com ORCA
|
|
280
|
+
// disponível. O código do Caminho A fica pronto pra quando isso for
|
|
281
|
+
// resolvido, só não é escolhido.
|
|
282
|
+
const CAMINHO_A_HABILITADO = false;
|
|
283
|
+
|
|
284
|
+
async function garantirEngine() {
|
|
285
|
+
// execFileSync('claude', …) no caminho headless não resolve o shim
|
|
286
|
+
// .cmd/.bat do Windows sem shell:true — quebraria em EVERY turno, mesmo
|
|
287
|
+
// com a detecção acima (que usa execSync com shell) passando. Recusa
|
|
288
|
+
// antes de instalar a skill: honesto e seguro, em vez de "sucesso" que
|
|
289
|
+
// quebra na primeira mensagem real.
|
|
290
|
+
if (process.platform === 'win32') {
|
|
291
|
+
return {
|
|
292
|
+
ok: false,
|
|
293
|
+
error: 'O motor Claude Code ainda não funciona no Windows.',
|
|
294
|
+
comoResolver: 'Aguarde uma próxima versão, ou escolha outra opção no /api.',
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
if (!temClaudeCode()) {
|
|
298
|
+
return {
|
|
299
|
+
ok: false,
|
|
300
|
+
error: 'Claude Code não está instalado nesta máquina.',
|
|
301
|
+
comoResolver: 'Instale o Claude Code (veja https://docs.claude.com/claude-code) e escolha Claude Code no /api de novo.',
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
garantirSkill();
|
|
305
|
+
const orcaCli = CAMINHO_A_HABILITADO && temOrca();
|
|
306
|
+
const caminho = escolherCaminho({
|
|
307
|
+
temClaude: true,
|
|
308
|
+
temOrcaCli: orcaCli,
|
|
309
|
+
orcaOk: orcaCli ? orcaAlcancavel() : false,
|
|
310
|
+
});
|
|
311
|
+
return { ok: true, caminho };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function abrirTerminalOrca() {
|
|
315
|
+
// --dangerously-skip-permissions aqui não dá poder novo ao Claude Code:
|
|
316
|
+
// a skill (textoBootstrap) já instrui ele a nunca usar suas próprias
|
|
317
|
+
// ferramentas nativas. A flag só pula os PRÓPRIOS prompts de permissão
|
|
318
|
+
// dele, que travariam a sessão sem ninguém pra responder.
|
|
319
|
+
const saida = rodarOrca(['terminal', 'create', '--command', 'claude --dangerously-skip-permissions', '--title', 'PrimoCode', '--json']);
|
|
320
|
+
const r = JSON.parse(saida);
|
|
321
|
+
// `orca terminal create --json` aninha o handle em result.terminal.handle,
|
|
322
|
+
// não em result.handle — confirmado rodando o comando de verdade (Task 9).
|
|
323
|
+
return r.result.terminal.handle;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function esperarIdleOrca(handle, timeoutMs) {
|
|
327
|
+
rodarOrca(['terminal', 'wait', '--terminal', handle, '--for', 'tui-idle', '--timeout-ms', String(timeoutMs)], { timeout: timeoutMs + 5000 });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function ultimaMensagem(messages) {
|
|
331
|
+
const m = (messages || [])[messages.length - 1];
|
|
332
|
+
return m && m.content ? String(m.content) : '';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Devolve a decisão pro onEvent — tool_call, ou assistant_message (com
|
|
336
|
+
* degradação pra texto puro quando o bloco marcado não veio, em vez de
|
|
337
|
+
* travar o turno em silêncio). */
|
|
338
|
+
/* ─────────────────────────────────────────────── o que não deu para ler ──
|
|
339
|
+
O motor às vezes devolve algo que este lado não sabe processar: bloco com
|
|
340
|
+
sintaxe torta, ou uma ferramenta que não existe. O usuário via isso na
|
|
341
|
+
tela — "O motor Claude Code devolveu uma decisão que não consegui ler" —
|
|
342
|
+
e a tarefa parava.
|
|
343
|
+
|
|
344
|
+
Erro de forma não é assunto do usuário. Quem errou a forma foi o motor, e
|
|
345
|
+
quem sabe consertar é ele. Então o problema volta PARA ELE, dizendo
|
|
346
|
+
exatamente o que estava errado, e ele reescreve a ação. Só depois de
|
|
347
|
+
algumas tentativas seguidas é que alguém precisa saber.
|
|
348
|
+
|
|
349
|
+
`pedirCorrecao` é preenchido por quem chamou (o caminho headless ou o
|
|
350
|
+
ORCA), porque só eles sabem como falar com o motor de novo. */
|
|
351
|
+
function diagnosticar(cru, nomesValidos) {
|
|
352
|
+
if (cru.includes(MARCA_INICIO) && !cru.includes(MARCA_FIM)) {
|
|
353
|
+
return 'você abriu ' + MARCA_INICIO + ' e nunca fechou com ' + MARCA_FIM + '.';
|
|
354
|
+
}
|
|
355
|
+
if (!cru.includes(MARCA_INICIO) && cru.includes(MARCA_FIM)) {
|
|
356
|
+
return 'apareceu ' + MARCA_FIM + ' sem o ' + MARCA_INICIO + ' antes.';
|
|
357
|
+
}
|
|
358
|
+
const dentro = /<<<PRIMOCODE>>>([\s\S]*?)<<<FIM>>>/.exec(cru);
|
|
359
|
+
if (dentro) {
|
|
360
|
+
try {
|
|
361
|
+
JSON.parse(dentro[1].trim());
|
|
362
|
+
return 'o JSON até é válido, mas não tem "tool_call" nem "assistant_message" na raiz.';
|
|
363
|
+
} catch (e) {
|
|
364
|
+
return 'o conteúdo entre as marcas não é JSON válido: ' + e.message
|
|
365
|
+
+ '. Lembre de escapar quebra de linha como \\n dentro das strings.';
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (nomesValidos) return 'a ferramenta pedida não existe. As que existem: ' + nomesValidos + '.';
|
|
369
|
+
return 'não achei um bloco marcado na sua resposta.';
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function textoDeCorrecao(cru, motivo) {
|
|
373
|
+
return 'A sua última resposta não pôde ser executada: ' + motivo
|
|
374
|
+
+ '\n\nO que você mandou foi:\n---\n' + String(cru).slice(0, 1200) + '\n---\n\n'
|
|
375
|
+
+ 'Reescreva AGORA a mesma intenção numa ação que o sistema reconheça. '
|
|
376
|
+
+ 'Responda com exatamente UM bloco:\n'
|
|
377
|
+
+ MARCA_INICIO + '\n{"tool_call":{"name":"<uma ferramenta do catálogo>","arguments":{...}}}\n' + MARCA_FIM
|
|
378
|
+
+ '\nou, se era só texto para a pessoa, ' + MARCA_INICIO
|
|
379
|
+
+ '\n{"assistant_message":"..."}\n' + MARCA_FIM
|
|
380
|
+
+ '\nNada fora do bloco.';
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const MAX_CORRECOES = 3;
|
|
384
|
+
|
|
385
|
+
async function emitirDecisao(textoLido, onEvent, pedirCorrecao, tentativa, validos) {
|
|
386
|
+
tentativa = tentativa || 0;
|
|
387
|
+
const decisao = extrairDecisao(textoLido);
|
|
388
|
+
if (!decisao) {
|
|
389
|
+
const cru = String(textoLido || '').trim();
|
|
390
|
+
// Traz a marca do protocolo mas o bloco não dá para ler? O motor
|
|
391
|
+
// TENTOU chamar ferramenta e errou a sintaxe. Em vez de mostrar isso
|
|
392
|
+
// ao usuário, devolvemos o problema ao motor com o diagnóstico e
|
|
393
|
+
// pedimos a mesma intenção escrita direito.
|
|
394
|
+
if (cru.includes(MARCA_INICIO) || cru.includes(MARCA_FIM)) {
|
|
395
|
+
if (pedirCorrecao && tentativa < MAX_CORRECOES) {
|
|
396
|
+
const motivo = diagnosticar(cru);
|
|
397
|
+
try {
|
|
398
|
+
const novo = await pedirCorrecao(textoDeCorrecao(cru, motivo));
|
|
399
|
+
return await emitirDecisao(novo, onEvent, pedirCorrecao, tentativa + 1, validos);
|
|
400
|
+
} catch (e) {
|
|
401
|
+
// A correção em si falhou (motor fora do ar, tempo
|
|
402
|
+
// esgotado). Cai no caminho antigo, que o loop repete.
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
onEvent({ erro: 'Failed to call a function: o motor Claude devolveu um bloco ilegível — repetindo a rodada.' });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
// Sem marca nenhuma: é resposta em texto, e texto é resposta válida.
|
|
409
|
+
onEvent({ assistant_message: { content: cru } });
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (decisao.tool_call) {
|
|
413
|
+
// Ferramenta que não existe é o outro jeito de o motor devolver algo
|
|
414
|
+
// que este lado não sabe processar. Antes viraria uma ação falhada
|
|
415
|
+
// com "ferramenta desconhecida" — ruído para o usuário por um erro
|
|
416
|
+
// que é do motor. Volta para ele, com a lista do que existe.
|
|
417
|
+
const nome = decisao.tool_call.name;
|
|
418
|
+
if (validos && validos.length && validos.indexOf(nome) === -1) {
|
|
419
|
+
if (pedirCorrecao && tentativa < MAX_CORRECOES) {
|
|
420
|
+
try {
|
|
421
|
+
const novo = await pedirCorrecao(textoDeCorrecao(
|
|
422
|
+
JSON.stringify(decisao),
|
|
423
|
+
'a ferramenta "' + nome + '" não existe. As que existem: ' + validos.join(', ') + '.'));
|
|
424
|
+
return await emitirDecisao(novo, onEvent, pedirCorrecao, tentativa + 1, validos);
|
|
425
|
+
} catch (e) { /* motor mudo: segue e deixa o loop tratar */ }
|
|
426
|
+
}
|
|
427
|
+
onEvent({ erro: 'Failed to call a function: ferramenta "' + nome + '" não existe — repetindo a rodada.' });
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
// lib/act.js usa tc.id pra parear a mensagem role:'tool' com este
|
|
431
|
+
// tool_call (messages.push({ tool_call_id: tc.id, ... })) — sem id
|
|
432
|
+
// aqui, esse pareamento quebra.
|
|
433
|
+
onEvent({ tool_call: { id: proximoId(), name: nome, arguments: decisao.tool_call.arguments || {} } });
|
|
434
|
+
}
|
|
435
|
+
else if (decisao.assistant_message) {
|
|
436
|
+
// O modelo pode confundir a forma com a do tool_call (que É um
|
|
437
|
+
// objeto) e emitir {"assistant_message": {"content": "..."}} em vez
|
|
438
|
+
// de string pura. Sem essa coerção, `content` vira um objeto
|
|
439
|
+
// aninhado e lib/act.js quebra com TypeError no `.trim()` — a
|
|
440
|
+
// travessia direta que este arquivo inteiro existe pra evitar.
|
|
441
|
+
const am = decisao.assistant_message;
|
|
442
|
+
const conteudo = typeof am === 'string'
|
|
443
|
+
? am
|
|
444
|
+
: (am && typeof am.content === 'string' ? am.content : JSON.stringify(am));
|
|
445
|
+
onEvent({ assistant_message: { content: conteudo } });
|
|
446
|
+
}
|
|
447
|
+
else onEvent({ assistant_message: { content: JSON.stringify(decisao) } });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function requestActViaOrca(payload, onEvent) {
|
|
451
|
+
if (!sessao.handle) {
|
|
452
|
+
try {
|
|
453
|
+
sessao.handle = abrirTerminalOrca();
|
|
454
|
+
sessao.cursor = '0';
|
|
455
|
+
sessao.bootstrapEnviado = false;
|
|
456
|
+
esperarIdleOrca(sessao.handle, 30000);
|
|
457
|
+
} catch (e) {
|
|
458
|
+
// Mesmo tratamento do try/catch abaixo: se o terminal não
|
|
459
|
+
// chegou a ficar pronto (timeout do tui-idle, por exemplo), o
|
|
460
|
+
// handle nunca foi confirmado usável — zera pra próxima
|
|
461
|
+
// chamada abrir um terminal novo do zero, em vez de deixar
|
|
462
|
+
// sessao.handle setado com algo que nunca funcionou.
|
|
463
|
+
sessao.handle = null;
|
|
464
|
+
throw e;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
let texto = ultimaMensagem(payload.messages);
|
|
468
|
+
if (!sessao.bootstrapEnviado) {
|
|
469
|
+
texto = 'Use a skill primocode-engine para esta sessão inteira — ela está em ~/.claude/skills/primocode-engine/SKILL.md.\n\n' + texto;
|
|
470
|
+
sessao.bootstrapEnviado = true;
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
rodarOrca(['terminal', 'send', '--terminal', sessao.handle, '--text', texto, '--enter']);
|
|
474
|
+
esperarIdleOrca(sessao.handle, 120000);
|
|
475
|
+
const lida = rodarOrca(['terminal', 'read', '--terminal', sessao.handle, '--cursor', sessao.cursor, '--json']);
|
|
476
|
+
const leitura = JSON.parse(lida);
|
|
477
|
+
// Mesma correção do handle: `terminal read --json` também aninha em
|
|
478
|
+
// result.terminal — e o texto vem como ARRAY de linhas (`tail`), não
|
|
479
|
+
// uma string única (`text`). Confirmado rodando de verdade (Task 9).
|
|
480
|
+
const t = leitura.result.terminal || {};
|
|
481
|
+
sessao.cursor = t.nextCursor != null ? String(t.nextCursor) : sessao.cursor;
|
|
482
|
+
// No terminal a correção é só mais uma mensagem na MESMA sessão: o
|
|
483
|
+
// motor já tem o protocolo e a conversa toda em contexto, então
|
|
484
|
+
// basta dizer o que saiu errado.
|
|
485
|
+
const pedirCorrecao = (aviso) => {
|
|
486
|
+
rodarOrca(['terminal', 'send', '--terminal', sessao.handle, '--text', aviso, '--enter']);
|
|
487
|
+
esperarIdleOrca(sessao.handle, 120000);
|
|
488
|
+
const outra = JSON.parse(rodarOrca(['terminal', 'read', '--terminal', sessao.handle,
|
|
489
|
+
'--cursor', sessao.cursor, '--json']));
|
|
490
|
+
const t2 = outra.result.terminal || {};
|
|
491
|
+
sessao.cursor = t2.nextCursor != null ? String(t2.nextCursor) : sessao.cursor;
|
|
492
|
+
return (t2.tail || []).join('\n');
|
|
493
|
+
};
|
|
494
|
+
await emitirDecisao((t.tail || []).join('\n'), onEvent, pedirCorrecao, 0, nomesDasTools(payload));
|
|
495
|
+
} catch (e) {
|
|
496
|
+
// Handle obsoleto (terminal fechado, ORCA reiniciou) — documentado
|
|
497
|
+
// pelo próprio `orca agent-context` como terminal_handle_stale. Zera
|
|
498
|
+
// a sessão: a PRÓXIMA chamada abre um terminal novo do zero. Esta
|
|
499
|
+
// chamada ainda falha (o usuário vê o erro e pode só pedir de novo).
|
|
500
|
+
sessao.handle = null;
|
|
501
|
+
throw e;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Sem sessão viva (caminho B), o histórico inteiro vira texto a cada
|
|
506
|
+
* chamada — mas `content` sozinho perde a mensagem quando é um turno de
|
|
507
|
+
* tool_calls (content vem null ali, o que importa está em tool_calls) ou
|
|
508
|
+
* uma resposta de ferramenta (role:'tool'). Reconstrói os dois casos em
|
|
509
|
+
* vez de deixar "assistant: null" no texto. */
|
|
510
|
+
function linhaDoHistorico(m) {
|
|
511
|
+
if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
|
|
512
|
+
const chamadas = m.tool_calls
|
|
513
|
+
.map((tc) => `chamou ${tc.function.name} com ${tc.function.arguments}`)
|
|
514
|
+
.join('; ');
|
|
515
|
+
return `assistant: ${chamadas}`;
|
|
516
|
+
}
|
|
517
|
+
if (m.role === 'tool') return `resultado da ferramenta (${m.tool_call_id}): ${m.content}`;
|
|
518
|
+
return `${m.role}: ${m.content}`;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* O protocolo INTEIRO, mais a conversa, num prompt só.
|
|
523
|
+
*
|
|
524
|
+
* A versão anterior dizia "use a skill primocode-engine" e confiava que o
|
|
525
|
+
* headless a carregaria — quando não carregava, o Claude respondia sem bloco
|
|
526
|
+
* nenhum e o turno morria. Prompt maior, mas o motor tem contexto de sobra e
|
|
527
|
+
* o formato SEMPRE chega.
|
|
528
|
+
*
|
|
529
|
+
* SEM o cabeçalho YAML da skill: ele começa com "---", e um prompt que começa
|
|
530
|
+
* com traço o parser de argumentos lê como OPÇÃO — "error: unknown option
|
|
531
|
+
* '---'" na cara do usuário, com o protocolo inteiro despejado no terminal.
|
|
532
|
+
* Foi exatamente o que aconteceu.
|
|
533
|
+
*
|
|
534
|
+
* Isto é o que o /claude e o /codex têm em comum, e é de propósito que more
|
|
535
|
+
* num lugar só: são dois motores, e tem de ser UM protocolo.
|
|
536
|
+
*/
|
|
537
|
+
function corpoDoProtocolo() {
|
|
538
|
+
return textoBootstrap().replace(/^---[\s\S]*?---\s*/, '');
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** O pedido de conserto: o protocolo de novo, mais o que saiu errado. Sem a
|
|
542
|
+
* conversa — o assunto aqui é a FORMA, e repetir o histórico inteiro só
|
|
543
|
+
* gastaria contexto no que não é o problema. */
|
|
544
|
+
function promptDeCorrecao(aviso) {
|
|
545
|
+
return corpoDoProtocolo() + '\n\n' + aviso;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function promptDoProtocolo(payload) {
|
|
549
|
+
const historico = (payload.messages || []).map(linhaDoHistorico).join('\n\n');
|
|
550
|
+
return corpoDoProtocolo()
|
|
551
|
+
+ '\n\nIMPORTANTE: no JSON do bloco, escreva quebras de linha como \\n (escapadas), nunca linha de verdade.'
|
|
552
|
+
+ '\n\n# A conversa até aqui\n\n' + historico
|
|
553
|
+
+ '\n\nResponda com exatamente UM bloco marcado.';
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function requestActViaHeadless(payload, onEvent) {
|
|
557
|
+
const corpoBootstrap = corpoDoProtocolo();
|
|
558
|
+
const prompt = promptDoProtocolo(payload);
|
|
559
|
+
// --dangerously-skip-permissions: mesma razão do caminho ORCA acima —
|
|
560
|
+
// a skill já proíbe o Claude Code de usar suas próprias ferramentas
|
|
561
|
+
// nativas; a flag só evita que ele pare pedindo permissão pra si mesmo
|
|
562
|
+
// numa chamada headless sem ninguém pra responder.
|
|
563
|
+
// O prompt vai por STDIN, nunca por argumento: argumento tem limite de
|
|
564
|
+
// tamanho (a conversa cresce) e qualquer conteúdo começando com traço
|
|
565
|
+
// seria lido como opção. stdin não tem nenhum dos dois problemas.
|
|
566
|
+
const saida = execFileSync('claude', [
|
|
567
|
+
'-p',
|
|
568
|
+
'--output-format', 'json',
|
|
569
|
+
'--dangerously-skip-permissions',
|
|
570
|
+
...argsDoModelo(),
|
|
571
|
+
], { encoding: 'utf-8', timeout: 180000, input: prompt, maxBuffer: 32 * 1024 * 1024,
|
|
572
|
+
env: process.env, ...SEM_APARECER });
|
|
573
|
+
const r = JSON.parse(saida);
|
|
574
|
+
// A correção é uma segunda ida ao motor, com o mesmo protocolo e o
|
|
575
|
+
// diagnóstico do que saiu errado. Barata: o prompt é curto, e evita que
|
|
576
|
+
// um erro de sintaxe do motor vire erro na cara do usuário.
|
|
577
|
+
const pedirCorrecao = (aviso) => {
|
|
578
|
+
const s = execFileSync('claude', [
|
|
579
|
+
'-p', '--output-format', 'json', '--dangerously-skip-permissions',
|
|
580
|
+
...argsDoModelo(),
|
|
581
|
+
], {
|
|
582
|
+
encoding: 'utf-8', timeout: 120000, maxBuffer: 32 * 1024 * 1024,
|
|
583
|
+
env: process.env,
|
|
584
|
+
input: promptDeCorrecao(aviso),
|
|
585
|
+
...SEM_APARECER,
|
|
586
|
+
});
|
|
587
|
+
return JSON.parse(s).result || '';
|
|
588
|
+
};
|
|
589
|
+
await emitirDecisao(r.result || '', onEvent, pedirCorrecao, 0, nomesDasTools(payload));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Os nomes que o sistema realmente sabe executar, tirados do payload. */
|
|
593
|
+
function nomesDasTools(payload) {
|
|
594
|
+
return ((payload && payload.tools) || [])
|
|
595
|
+
.map((t) => (t && t.function && t.function.name) || t.name)
|
|
596
|
+
.filter(Boolean);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Mesma forma de api.requestAct(server, payload, onEvent) — `server` não
|
|
600
|
+
* é usado aqui (não existe servidor neste motor), fica só pra manter a
|
|
601
|
+
* assinatura uniforme com o outro lado da escolha em lib/act.js. */
|
|
602
|
+
async function requestAct(server, payload, onEvent) {
|
|
603
|
+
try {
|
|
604
|
+
const engineInfo = await garantirEngine();
|
|
605
|
+
if (!engineInfo.ok) {
|
|
606
|
+
onEvent({ erro: `${engineInfo.error} ${engineInfo.comoResolver || ''}`.trim() });
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (engineInfo.caminho === 'A') await requestActViaOrca(payload, onEvent);
|
|
610
|
+
else await requestActViaHeadless(payload, onEvent);
|
|
611
|
+
} catch (e) {
|
|
612
|
+
onEvent({ erro: e.message });
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
module.exports = {
|
|
617
|
+
MARCA_INICIO, MARCA_FIM, extrairDecisao,
|
|
618
|
+
catalogoParaTexto, textoBootstrap,
|
|
619
|
+
// O protocolo sai daqui para o lib/codex-engine.js usar o MESMO. Ele é o
|
|
620
|
+
// que faz um motor de fora virar cérebro do PrimoCode: o texto que
|
|
621
|
+
// ensina o formato, a leitura do bloco marcado e a volta para consertar
|
|
622
|
+
// o que veio torto. Reimplementar aquilo no outro motor seria manter dois
|
|
623
|
+
// protocolos que divergem na primeira correção de um deles.
|
|
624
|
+
linhaDoHistorico, nomesDasTools, promptDoProtocolo, promptDeCorrecao,
|
|
625
|
+
MODELO_CLAUDE, argsDoModelo,
|
|
626
|
+
SKILL_DIR, SKILL_PATH, garantirSkill,
|
|
627
|
+
temClaudeCode, temOrca, orcaAlcancavel, escolherCaminho,
|
|
628
|
+
garantirEngine, requestAct, emitirDecisao,
|
|
629
|
+
_emitirDecisao: emitirDecisao,
|
|
630
|
+
};
|