primocode 7.0.2 → 7.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/lib/act.js CHANGED
@@ -1,104 +1,157 @@
1
- // Loop do agente autônomo — streaming via /api/act/stream
1
+ // Loop do agente autônomo — streaming via /api/act/stream.
2
+ //
3
+ // O contrato com o servidor é uma conversa completa (`messages`, estilo OpenAI).
4
+ // Cada volta do loop: o servidor devolve tool_calls -> executamos AQUI, com await,
5
+ // e devolvemos o resultado na próxima volta. Sem isso o modelo fica cego e repete
6
+ // a mesma tool até estourar o limite.
2
7
 
3
8
  'use strict';
4
9
 
5
10
  const { c, actionHeader, Spinner } = require('./ui');
6
11
  const tools = require('./tools');
7
12
  const api = require('./api');
13
+ const cfg = require('./config');
8
14
 
9
15
  const MAX_ITERATIONS = 15;
10
16
 
11
- function makeLogger() {
12
- return {
13
- event(kind, arg, result) {
14
- if (kind === 'run_shell') return;
15
- if (kind === 'run_shell_done') {
16
- if (result) { const s = result.ok ? c.ok(' exit 0') : c.err(` exit ${result.code}${result.killed?' timeout':''}`); process.stdout.write('\n'+s+'\n'); }
17
- return;
18
- }
19
- const prefix = c.muted(' ⎿ ');
20
- if (kind === 'write_file' && result && result.ok) { console.log(prefix + c.muted(`${result.created?'created':'updated'} · ${tools.fmtBytes(result.bytes)}`)); return; }
21
- if (kind === 'read_file' && result && result.ok) { console.log(prefix + c.muted(`${tools.fmtBytes(result.bytes||0)}${result.truncated?' (truncated)':''}`)); return; }
22
- if (kind === 'list_dir' && result && result.ok) { const lines = result.tree.split('\n').slice(0,8); for (const l of lines) console.log(prefix + c.muted(l)); const total = result.tree.split('\n').length; if (total>8) console.log(prefix + c.muted(`… +${total-8}`)); return; }
23
- if (kind === 'open_in_browser' && result && result.ok) { console.log(prefix + c.muted(`${result.opener||'browser'}`)); return; }
24
- if (kind === 'run_in_new_terminal' && result && result.ok) { console.log(prefix + c.muted(`${result.opener}`)); return; }
25
- if (kind === 'finish') { console.log(prefix + c.muted(arg || 'ok')); return; }
26
- if (result && result.ok === false) console.log(prefix + c.err(result.error || 'failed'));
27
- },
28
- };
17
+ function logResult(kind, result) {
18
+ const prefix = c.muted(' ⎿ ');
19
+ if (!result) return;
20
+ if (result.ok === false) { console.log(prefix + c.err(result.error || `exit ${result.code ?? 'erro'}`)); return; }
21
+
22
+ switch (kind) {
23
+ case 'write_file':
24
+ console.log(prefix + c.muted(`${result.created ? 'created' : 'updated'} · ${tools.fmtBytes(result.bytes)}`));
25
+ return;
26
+ case 'read_file':
27
+ console.log(prefix + c.muted(`${tools.fmtBytes(result.bytes || 0)}${result.truncated ? ' (truncated)' : ''}`));
28
+ return;
29
+ case 'list_dir': {
30
+ const lines = (result.tree || '').split('\n').filter(Boolean);
31
+ for (const l of lines.slice(0, 8)) console.log(prefix + c.muted(l));
32
+ if (lines.length > 8) console.log(prefix + c.muted(`… +${lines.length - 8}`));
33
+ return;
34
+ }
35
+ case 'run_shell':
36
+ console.log(prefix + c.ok('exit 0'));
37
+ return;
38
+ case 'open_in_browser':
39
+ case 'run_in_new_terminal':
40
+ console.log(prefix + c.muted(result.opener || 'ok'));
41
+ return;
42
+ }
43
+ }
44
+
45
+ // O que mandamos de volta ao modelo. Cortamos saídas gigantes para não
46
+ // estourar a janela de contexto na iteração seguinte.
47
+ function summarizeForModel(result) {
48
+ const json = JSON.stringify(result);
49
+ return json.length > 4000 ? json.slice(0, 4000) + '…(truncado)' : json;
50
+ }
51
+
52
+ function buildMemoryContext(projectName) {
53
+ const parts = [];
54
+ const geral = cfg.readGeneralMemory();
55
+ if (geral.trim()) parts.push(`[memory-geral.md]\n${geral}`);
56
+ const projeto = cfg.readProjectMemory(projectName);
57
+ if (projeto.trim()) parts.push(`[${projectName}/memory.md]\n${projeto}`);
58
+ return parts.join('\n\n');
29
59
  }
30
60
 
31
61
  async function runAct(userPrompt, opts) {
32
- const { server, model, history, projectDir, projectName } = opts || {};
33
- const logger = makeLogger();
34
- const messages = [
35
- ...(Array.isArray(history) ? history.filter(m => m && m.role && m.content).slice(-20) : []),
36
- { role: 'user', content: userPrompt },
37
- ];
38
- const toolResults = [];
39
- let iteration = 0;
40
- let plan = '';
41
- let assistantContent = '';
42
-
43
- while (iteration < MAX_ITERATIONS) {
44
- iteration++;
62
+ const { server, model, token, history, projectDir, projectName } = opts || {};
63
+
64
+ // Conversa completa desta tarefa: histórico recente + memória + pedido.
65
+ const messages = [];
66
+ const memory = buildMemoryContext(projectName);
67
+ if (memory) messages.push({ role: 'system', content: `Memória persistente:\n\n${memory}` });
68
+ for (const m of (history || []).slice(-20)) {
69
+ if (m && m.role && m.content) messages.push({ role: m.role, content: m.content });
70
+ }
71
+ messages.push({ role: 'user', content: userPrompt });
72
+
73
+ let finalText = '';
74
+
75
+ for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
45
76
  const sp = new Spinner('Primo Code pensando').start();
46
77
 
78
+ const toolCalls = [];
79
+ let assistantText = '';
80
+ let streamError = null;
81
+ let streaming = false;
82
+
47
83
  try {
48
- await api.requestAct(server, messages, model, { projectDir, projectName, plan_cache: plan }, (evt) => {
49
- if (evt.plan) {
50
- plan = evt.plan;
51
- }
52
- if (evt.tool_call) {
53
- sp.stop();
54
- const tc = evt.tool_call;
55
- console.log(actionHeader(tc.name, tc.arguments));
56
- logger.event(tc.name, tc.arguments);
57
-
58
- // Executar tool localmente
59
- tools.execute(tc.name, tc.arguments).then(result => {
60
- logger.event(tc.name + '_done', null, result);
61
- toolResults.push({ tool_call_id: tc.id || 'tc_' + iteration, result });
62
- });
63
- sp.start();
64
- }
65
- if (evt.assistant_message) {
66
- assistantContent = evt.assistant_message.content || '';
67
- }
84
+ await api.requestAct(server, { messages, model, token, projectDir, projectName }, (evt) => {
85
+ if (evt.erro) { streamError = evt.erro; return; }
86
+ if (evt.tool_call) { toolCalls.push(evt.tool_call); return; }
68
87
  if (evt.delta) {
69
- sp.stop();
70
- process.stdout.write(evt.delta);
71
- sp.start();
88
+ if (!streaming) { sp.stop(); streaming = true; }
89
+ assistantText += evt.delta;
90
+ process.stdout.write(c.white(evt.delta));
72
91
  }
92
+ if (evt.assistant_message) assistantText = evt.assistant_message.content || assistantText;
73
93
  });
94
+ } catch (e) {
74
95
  sp.stop();
96
+ console.log(c.err(`\nErro: ${e.message || e}`));
97
+ throw e;
98
+ }
99
+ sp.stop();
75
100
 
76
- // Se não teve tool_calls, mostrar conteúdo
77
- if (toolResults.length === 0 && assistantContent) {
78
- console.log('\n' + c.white(assistantContent));
79
- return { userMessage: { role: 'user', content: userPrompt }, assistantMessage: { role: 'assistant', content: assistantContent } };
80
- }
101
+ if (streamError) {
102
+ console.log(c.err(`\nErro: ${streamError}`));
103
+ return { assistantMessage: { role: 'assistant', content: `Erro: ${streamError}` } };
104
+ }
81
105
 
82
- // Se teve tool_results, enviar de volta para próximo turno
83
- if (toolResults.length > 0) {
84
- messages.push({ role: 'assistant', content: assistantContent, tool_calls: [] });
85
- for (const tr of toolResults) {
86
- messages.push({ role: 'tool', tool_call_id: tr.tool_call_id, content: JSON.stringify(tr.result) });
87
- }
88
- toolResults.length = 0;
89
- assistantContent = '';
106
+ // Sem tool_calls: o modelo respondeu e acabou.
107
+ if (!toolCalls.length) {
108
+ finalText = assistantText.trim();
109
+ if (finalText && !streaming) console.log('\n' + c.white(finalText));
110
+ else if (streaming) process.stdout.write('\n');
111
+ break;
112
+ }
113
+
114
+ if (streaming) process.stdout.write('\n');
115
+
116
+ // Registra na conversa o turno do assistente COM os tool_calls.
117
+ // A API exige que cada mensagem role:'tool' venha depois deste turno.
118
+ messages.push({
119
+ role: 'assistant',
120
+ content: assistantText || null,
121
+ tool_calls: toolCalls.map((tc) => ({
122
+ id: tc.id,
123
+ type: 'function',
124
+ function: { name: tc.name, arguments: JSON.stringify(tc.arguments || {}) },
125
+ })),
126
+ });
127
+
128
+ let finished = null;
129
+ for (const tc of toolCalls) {
130
+ if (tc.name === 'finish') {
131
+ finished = (tc.arguments && tc.arguments.summary) || 'Concluído.';
132
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: 'ok' });
90
133
  continue;
91
134
  }
92
135
 
93
- break;
94
- } catch (e) {
95
- sp.stop();
96
- console.log(c.err(`\nErro: ${e.message||e}`));
97
- throw e;
136
+ console.log(actionHeader(tc.name, tc.arguments));
137
+ const result = await tools.execute(tc.name, tc.arguments, { projectDir });
138
+ logResult(tc.name, result);
139
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: summarizeForModel(result) });
98
140
  }
141
+
142
+ if (finished) { finalText = finished; console.log('\n' + c.white(finished)); break; }
99
143
  }
100
144
 
101
- return { userMessage: { role: 'user', content: userPrompt }, assistantMessage: { role: 'assistant', content: assistantContent || 'Concluído.' } };
145
+ if (!finalText) finalText = 'Cheguei ao limite de passos sem concluir. Refaça o pedido em partes menores.';
146
+
147
+ if (projectName) {
148
+ cfg.appendProjectMemory(projectName, `**Pedido:** ${userPrompt.slice(0, 300)}\n**Resultado:** ${finalText.slice(0, 500)}`);
149
+ }
150
+
151
+ return {
152
+ userMessage: { role: 'user', content: userPrompt },
153
+ assistantMessage: { role: 'assistant', content: finalText },
154
+ };
102
155
  }
103
156
 
104
157
  module.exports = { runAct };
package/lib/api.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * API client — comunicação com primocode-server
2
+ * Cliente HTTP do primocode-server.
3
3
  */
4
4
 
5
5
  'use strict';
@@ -8,35 +8,128 @@ const https = require('https');
8
8
  const http = require('http');
9
9
  const { URL } = require('url');
10
10
 
11
- function request(serverUrl, endpoint, payload) {
11
+ // Erro cru do servidor não diz o que fazer. Estes dois dizem.
12
+ function explainHttpError(status, msg) {
13
+ if (status === 401) {
14
+ return `${msg}\nEste servidor pede um token. Configure com:\n export PRIMOCODE_TOKEN="seu-token"\nou adicione "token" em ~/.primocode/config.json`;
15
+ }
16
+ if (status === 429) {
17
+ return `${msg}\nSe você é o dono do servidor, configure PRIMOCODE_TOKEN para não ter limite.`;
18
+ }
19
+ return msg;
20
+ }
21
+
22
+ function buildOptions(url, method, extraHeaders, bodyLength, token) {
23
+ const headers = { ...extraHeaders };
24
+ if (bodyLength != null) {
25
+ headers['Content-Type'] = 'application/json';
26
+ headers['Content-Length'] = bodyLength;
27
+ }
28
+ if (token) headers['x-primocode-token'] = token;
29
+ return {
30
+ hostname: url.hostname,
31
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
32
+ path: url.pathname + url.search,
33
+ method,
34
+ headers,
35
+ };
36
+ }
37
+
38
+ function request(serverUrl, endpoint, payload, token) {
12
39
  return new Promise((resolve, reject) => {
13
40
  const url = new URL(endpoint, serverUrl);
14
41
  const data = JSON.stringify(payload);
15
- const options = { hostname: url.hostname, port: url.port || (url.protocol==='https:'?443:80), path: url.pathname+url.search, method: 'POST', headers: { 'Content-Type':'application/json', 'Content-Length':Buffer.byteLength(data) } };
16
- const client = url.protocol==='https:' ? https : http;
17
- const req = client.request(options, (res) => { let body=''; res.on('data', c=>body+=c); res.on('end', () => { try { const json=JSON.parse(body); if (res.statusCode>=400) reject(new Error(json.erro||json.error||`HTTP ${res.statusCode}`)); else resolve(json); } catch { reject(new Error(`Resposta inválida: ${body.slice(0,200)}`)); } }); });
18
- req.on('error', reject); req.write(data); req.end();
42
+ const client = url.protocol === 'https:' ? https : http;
43
+ const req = client.request(buildOptions(url, 'POST', {}, Buffer.byteLength(data), token), (res) => {
44
+ let body = '';
45
+ res.on('data', (chunk) => { body += chunk; });
46
+ res.on('end', () => {
47
+ let json;
48
+ try { json = JSON.parse(body); }
49
+ catch { return reject(new Error(`Resposta inválida (HTTP ${res.statusCode}): ${body.slice(0, 200)}`)); }
50
+ if (res.statusCode >= 400) reject(new Error(json.erro || json.error || `HTTP ${res.statusCode}`));
51
+ else resolve(json);
52
+ });
53
+ });
54
+ req.on('error', reject);
55
+ req.write(data);
56
+ req.end();
19
57
  });
20
58
  }
21
59
 
22
- function requestStream(serverUrl, endpoint, payload, onEvent) {
60
+ function requestStream(serverUrl, endpoint, payload, onEvent, token) {
23
61
  return new Promise((resolve, reject) => {
24
62
  const url = new URL(endpoint, serverUrl);
25
63
  const data = JSON.stringify(payload);
26
- const options = { hostname: url.hostname, port: url.port || (url.protocol==='https:'?443:80), path: url.pathname+url.search, method: 'POST', headers: { 'Content-Type':'application/json', 'Content-Length':Buffer.byteLength(data), 'Accept':'text/event-stream' } };
27
- const client = url.protocol==='https:' ? https : http;
28
- const req = client.request(options, (res) => { let buffer=''; res.on('data', chunk => { buffer+=chunk; const lines=buffer.split('\n'); buffer=lines.pop(); for (const line of lines) if (line.startsWith('data: ')) { try { const evt=JSON.parse(line.slice(6)); onEvent(evt); if (evt.done) resolve(); } catch {} } }); res.on('end', () => resolve()); });
29
- req.on('error', reject); req.write(data); req.end();
64
+ const client = url.protocol === 'https:' ? https : http;
65
+ const options = buildOptions(url, 'POST', { Accept: 'text/event-stream' }, Buffer.byteLength(data), token);
66
+
67
+ const req = client.request(options, (res) => {
68
+ // Erro HTTP não vem como SSE — o corpo é um JSON de erro.
69
+ if (res.statusCode >= 400) {
70
+ let body = '';
71
+ res.on('data', (chunk) => { body += chunk; });
72
+ res.on('end', () => {
73
+ let msg = `HTTP ${res.statusCode}`;
74
+ try { const j = JSON.parse(body); msg = j.erro || j.error || msg; } catch {}
75
+ reject(new Error(explainHttpError(res.statusCode, msg)));
76
+ });
77
+ return;
78
+ }
79
+
80
+ let settled = false;
81
+ const finish = () => { if (!settled) { settled = true; resolve(); } };
82
+
83
+ let buffer = '';
84
+ res.setEncoding('utf8');
85
+ res.on('data', (chunk) => {
86
+ buffer += chunk;
87
+ const lines = buffer.split('\n');
88
+ buffer = lines.pop();
89
+ for (const line of lines) {
90
+ if (!line.startsWith('data: ')) continue;
91
+ let evt;
92
+ try { evt = JSON.parse(line.slice(6)); } catch { continue; }
93
+ onEvent(evt);
94
+ if (evt.done) finish();
95
+ }
96
+ });
97
+ res.on('end', finish);
98
+ res.on('error', reject);
99
+ });
100
+
101
+ req.on('error', reject);
102
+ req.write(data);
103
+ req.end();
30
104
  });
31
105
  }
32
106
 
33
- function requestAct(serverUrl, messages, model, extras, onEvent) {
34
- // Usa streaming para /api/act/stream
35
- return requestStream(serverUrl, '/api/act/stream', { messages, model, ...(extras || {}) }, onEvent);
107
+ // Agente autônomo. `payload` carrega a conversa completa (messages).
108
+ function requestAct(serverUrl, payload, onEvent) {
109
+ const { token, ...body } = payload;
110
+ return requestStream(serverUrl, '/api/act/stream', body, onEvent, token);
111
+ }
112
+
113
+ // Chat puro.
114
+ function requestChat(serverUrl, payload, onEvent) {
115
+ const { token, ...body } = payload;
116
+ return requestStream(serverUrl, '/api/chat/stream', body, onEvent, token);
36
117
  }
37
118
 
38
- function getHealth(serverUrl) {
39
- return new Promise((resolve, reject) => { const url=new URL('/health',serverUrl); const client=url.protocol==='https:'?https:http; const req=client.get(url, (res)=>{ let body=''; res.on('data',c=>body+=c); res.on('end',()=>{ try { resolve(JSON.parse(body)); } catch { reject(new Error('Resposta inválida do health check')); } }); }); req.on('error',reject); });
119
+ function getHealth(serverUrl, token) {
120
+ return new Promise((resolve, reject) => {
121
+ const url = new URL('/health', serverUrl);
122
+ const client = url.protocol === 'https:' ? https : http;
123
+ const req = client.get(buildOptions(url, 'GET', {}, null, token), (res) => {
124
+ let body = '';
125
+ res.on('data', (chunk) => { body += chunk; });
126
+ res.on('end', () => {
127
+ try { resolve(JSON.parse(body)); }
128
+ catch { reject(new Error(`Resposta inválida do health check (HTTP ${res.statusCode})`)); }
129
+ });
130
+ });
131
+ req.on('error', reject);
132
+ });
40
133
  }
41
134
 
42
- module.exports = { request, requestStream, requestAct, getHealth };
135
+ module.exports = { request, requestStream, requestAct, requestChat, getHealth };