@vitortrack777/djinnai 0.0.0-stage → 0.6.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/LICENSE +21 -0
- package/README.md +137 -2
- package/djinn-completion.bash +9 -0
- package/djinn.sh +246 -0
- package/index.js +88 -0
- package/package.json +65 -4
- package/server.js +350 -0
- package/web/dist/assets/index-CdLvctyU.js +112 -0
- package/web/dist/assets/index-DRQ_cItt.css +1 -0
- package/web/dist/index.html +13 -0
- package/window.cjs +20 -0
package/server.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
|
|
10
|
+
const DIST = path.join(path.dirname(fileURLToPath(import.meta.url)), 'web', 'dist');
|
|
11
|
+
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml' };
|
|
12
|
+
const OLLAMA_URL = 'http://127.0.0.1:11434';
|
|
13
|
+
const MAX_FOLDERS = 150;
|
|
14
|
+
const INIT_TIMEOUT_MS = 20000;
|
|
15
|
+
|
|
16
|
+
const APP_COMMANDS = [
|
|
17
|
+
['start', 'Sobe o stack e builda só o que mudou'],
|
|
18
|
+
['restart', 'Reinicia só os serviços alterados (ex.: /restart api)'],
|
|
19
|
+
['rebuild', 'Build sem cache e recriação forçada'],
|
|
20
|
+
['logs', 'Logs dos containers (ex.: /logs --tail=100 api)'],
|
|
21
|
+
['status', 'Containers e uso do Docker'],
|
|
22
|
+
['stop', 'Para e remove os containers'],
|
|
23
|
+
['clean', 'Remove imagens órfãs e limita o cache'],
|
|
24
|
+
['hot-reload', 'Atualiza só o frontend'],
|
|
25
|
+
['worker', 'Builda o front atualizando a pasta dist'],
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const FALLBACK_MODELS = [{ key: 'claude:default', name: 'Padrão', tag: 'Claude' }];
|
|
29
|
+
|
|
30
|
+
const LEGACY_DIR = '.detro';
|
|
31
|
+
const DATA_DIR = '.djinn';
|
|
32
|
+
|
|
33
|
+
const dataDir = (projectDir) => path.join(projectDir, existsSync(path.join(projectDir, LEGACY_DIR)) && !existsSync(path.join(projectDir, DATA_DIR)) ? LEGACY_DIR : DATA_DIR);
|
|
34
|
+
const configPath = (projectDir) => path.join(dataDir(projectDir), 'config.json');
|
|
35
|
+
|
|
36
|
+
async function loadConfig(projectDir) {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(await readFile(configPath(projectDir), 'utf8'));
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error.code === 'ENOENT') return {};
|
|
41
|
+
throw new Error(`${path.relative(projectDir, configPath(projectDir))} não é um JSON válido: ${error.message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function saveSettings(projectDir, settings) {
|
|
46
|
+
const config = { ...(await loadConfig(projectDir)), ...Object.fromEntries(Object.entries(settings).filter(([, value]) => value !== undefined)) };
|
|
47
|
+
await mkdir(path.dirname(configPath(projectDir)), { recursive: true });
|
|
48
|
+
await writeFile(configPath(projectDir), `${JSON.stringify(config, null, 2)}\n`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ollamaUrlOf = (config) => (config.ollamaUrl ?? config.ai?.ollama?.baseUrl ?? OLLAMA_URL).replace(/\/$/, '');
|
|
52
|
+
|
|
53
|
+
async function settingsOf(projectDir) {
|
|
54
|
+
const config = await loadConfig(projectDir);
|
|
55
|
+
return {
|
|
56
|
+
model: !config.model || config.model === 'claude:' ? 'claude:default' : config.model,
|
|
57
|
+
effort: config.effort ?? '',
|
|
58
|
+
mode: config.mode ?? 'acceptEdits',
|
|
59
|
+
ollamaUrl: ollamaUrlOf(config),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function ollamaModels(url) {
|
|
64
|
+
try {
|
|
65
|
+
const response = await fetch(`${url}/api/tags`, { signal: AbortSignal.timeout(2000) });
|
|
66
|
+
const { models = [] } = await response.json();
|
|
67
|
+
return models.map(({ name }) => ({ key: `ollama:${name}`, name, tag: 'Ollama' }));
|
|
68
|
+
} catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function sessionContext(projectDir) {
|
|
74
|
+
const git = (args) => promisify(execFile)('git', args, { cwd: projectDir, maxBuffer: 5e7 }).then(({ stdout }) => stdout.trim(), () => '');
|
|
75
|
+
const [files, notes] = await Promise.all([
|
|
76
|
+
git(['ls-files']),
|
|
77
|
+
readFile(path.join(dataDir(projectDir), 'contexto.md'), 'utf8').then((text) => text.trim(), () => ''),
|
|
78
|
+
]);
|
|
79
|
+
const folders = [...new Set(files.split('\n').map((file) => file.split('/').slice(0, -1).slice(0, 4).join('/')).filter(Boolean))].sort();
|
|
80
|
+
return [
|
|
81
|
+
folders.length > 0 && `Pastas do projeto (até 4 níveis):\n${folders.slice(0, MAX_FOLDERS).join('\n')}${folders.length > MAX_FOLDERS ? `\n(+${folders.length - MAX_FOLDERS} pastas)` : ''}`,
|
|
82
|
+
notes && `NOTAS DO PROJETO (${path.basename(dataDir(projectDir))}/contexto.md), siga como instrução:\n${notes}`,
|
|
83
|
+
].filter(Boolean).join('\n\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function eachLine(stream, onLine) {
|
|
87
|
+
let pending = '';
|
|
88
|
+
stream.on('data', (chunk) => {
|
|
89
|
+
const lines = (pending + chunk).split('\n');
|
|
90
|
+
pending = lines.pop();
|
|
91
|
+
for (const line of lines) if (line.trim()) onLine(line);
|
|
92
|
+
});
|
|
93
|
+
stream.on('end', () => pending.trim() && onLine(pending));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const killGroup = (child, signal = 'SIGTERM') => {
|
|
97
|
+
try {
|
|
98
|
+
process.kill(-child.pid, signal);
|
|
99
|
+
} catch {}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const readBody = async (request) => {
|
|
103
|
+
let body = '';
|
|
104
|
+
for await (const chunk of request) body += chunk;
|
|
105
|
+
return JSON.parse(body || '{}');
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export function startServer({ projectDir, appScript, version, port = 0 }) {
|
|
109
|
+
const token = randomBytes(16).toString('hex');
|
|
110
|
+
mkdirSync(dataDir(projectDir), { recursive: true });
|
|
111
|
+
const clients = new Set();
|
|
112
|
+
let history = [];
|
|
113
|
+
let session = null;
|
|
114
|
+
let sessionId = null;
|
|
115
|
+
let busy = false;
|
|
116
|
+
let app = null;
|
|
117
|
+
let record = null;
|
|
118
|
+
|
|
119
|
+
const broadcast = (event, keep = true) => {
|
|
120
|
+
if (keep) history.push(event);
|
|
121
|
+
for (const client of clients) client.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const write = (target, message) => target.child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
125
|
+
const control = (target, request) => write(target, { type: 'control_request', request_id: randomBytes(6).toString('hex'), request });
|
|
126
|
+
|
|
127
|
+
async function openSession(settings) {
|
|
128
|
+
const [agent, name] = settings.model.split(/:(.*)/s);
|
|
129
|
+
const args = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--include-partial-messages', '--permission-prompt-tool', 'stdio', '--permission-mode', settings.mode];
|
|
130
|
+
const context = await sessionContext(projectDir);
|
|
131
|
+
if (context) args.push('--append-system-prompt', context);
|
|
132
|
+
if (name !== 'default') args.push('--model', name);
|
|
133
|
+
if (settings.effort) args.push('--effort', settings.effort);
|
|
134
|
+
if (sessionId) args.push('--resume', sessionId);
|
|
135
|
+
|
|
136
|
+
const env = { ...process.env, CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: '1' };
|
|
137
|
+
delete env.ANTHROPIC_API_KEY;
|
|
138
|
+
if (agent === 'ollama') Object.assign(env, { ANTHROPIC_BASE_URL: settings.ollamaUrl, ANTHROPIC_AUTH_TOKEN: 'ollama', ANTHROPIC_API_KEY: '' });
|
|
139
|
+
|
|
140
|
+
const child = spawn('claude', args, { cwd: projectDir, env, detached: true });
|
|
141
|
+
const current = { child, agent, effort: settings.effort, model: settings.model, mode: settings.mode, closing: false };
|
|
142
|
+
let resolveInit;
|
|
143
|
+
current.init = new Promise((resolve) => { resolveInit = resolve; });
|
|
144
|
+
const initId = randomBytes(6).toString('hex');
|
|
145
|
+
let errors = '';
|
|
146
|
+
let spawnError = '';
|
|
147
|
+
|
|
148
|
+
eachLine(child.stdout, (line) => {
|
|
149
|
+
record?.write(`${line}\n`);
|
|
150
|
+
let event;
|
|
151
|
+
try {
|
|
152
|
+
event = JSON.parse(line);
|
|
153
|
+
} catch {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (event.session_id) sessionId = event.session_id;
|
|
157
|
+
if (event.type === 'control_response') {
|
|
158
|
+
if (event.response?.request_id === initId) resolveInit(event.response.response ?? null);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (event.type === 'control_request' && event.request?.subtype !== 'can_use_tool') {
|
|
162
|
+
write(current, { type: 'control_response', response: { subtype: 'error', request_id: event.request_id, error: 'O DjinnAI não atende este pedido.' } });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (event.subtype === 'session_state_changed') busy = event.state !== 'idle';
|
|
166
|
+
broadcast(event, event.type !== 'stream_event');
|
|
167
|
+
});
|
|
168
|
+
child.stderr.on('data', (chunk) => { errors = (errors + chunk).slice(-2000); });
|
|
169
|
+
child.stdin.on('error', () => {});
|
|
170
|
+
child.on('error', (error) => {
|
|
171
|
+
spawnError = `Não foi possível executar o claude (${error.message}). Confira se o Claude Code está instalado e logado.`;
|
|
172
|
+
});
|
|
173
|
+
child.on('close', (code) => {
|
|
174
|
+
resolveInit(null);
|
|
175
|
+
if (session === current) session = null;
|
|
176
|
+
if (current.closing) return;
|
|
177
|
+
busy = false;
|
|
178
|
+
broadcast({ type: 'session_end', error: spawnError || errors.trim().slice(-600) || `O agente encerrou sozinho (código ${code}). Mande a mensagem de novo para retomar a conversa.` });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
write(current, { type: 'control_request', request_id: initId, request: { subtype: 'initialize' } });
|
|
182
|
+
return current;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let opening = null;
|
|
186
|
+
|
|
187
|
+
async function ensureSession(settings) {
|
|
188
|
+
while (opening) await opening;
|
|
189
|
+
const [agent] = settings.model.split(':');
|
|
190
|
+
if (session && session.agent === agent && session.effort === settings.effort) {
|
|
191
|
+
if (session.mode !== settings.mode) control(session, { subtype: 'set_permission_mode', mode: settings.mode });
|
|
192
|
+
if (session.model !== settings.model) {
|
|
193
|
+
const name = settings.model.split(/:(.*)/s)[1];
|
|
194
|
+
control(session, { subtype: 'set_model', ...(name !== 'default' && { model: name }) });
|
|
195
|
+
}
|
|
196
|
+
Object.assign(session, { mode: settings.mode, model: settings.model });
|
|
197
|
+
return session;
|
|
198
|
+
}
|
|
199
|
+
if (session && busy) return session;
|
|
200
|
+
closeSession();
|
|
201
|
+
opening = openSession(settings);
|
|
202
|
+
session = await opening;
|
|
203
|
+
opening = null;
|
|
204
|
+
return session;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function closeSession() {
|
|
208
|
+
if (!session) return;
|
|
209
|
+
session.closing = true;
|
|
210
|
+
killGroup(session.child);
|
|
211
|
+
session = null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const routes = {
|
|
215
|
+
'GET /api/state': async () => {
|
|
216
|
+
const settings = await settingsOf(projectDir);
|
|
217
|
+
const current = await ensureSession(settings);
|
|
218
|
+
const init = await Promise.race([current.init, new Promise((resolve) => setTimeout(resolve, INIT_TIMEOUT_MS, null))]);
|
|
219
|
+
const branch = await promisify(execFile)('git', ['branch', '--show-current'], { cwd: projectDir }).then(({ stdout }) => stdout.trim(), () => '');
|
|
220
|
+
const appNames = new Set(['novo', ...APP_COMMANDS.map(([name]) => name)]);
|
|
221
|
+
const claudeModels = (init?.models ?? []).map((m) => ({ key: `claude:${m.value}`, name: m.value === 'default' ? 'Padrão' : m.displayName, tag: m.description?.split(' · ')[0] ?? 'Claude' }));
|
|
222
|
+
return {
|
|
223
|
+
project: path.basename(projectDir),
|
|
224
|
+
projectDir,
|
|
225
|
+
branch,
|
|
226
|
+
version,
|
|
227
|
+
agentError: init ? '' : 'O Claude Code não respondeu. Confira se o comando claude está instalado e logado e rode djinn de novo.',
|
|
228
|
+
models: [...(claudeModels.length ? claudeModels : FALLBACK_MODELS), ...(await ollamaModels(settings.ollamaUrl))],
|
|
229
|
+
appCommands: [...appNames],
|
|
230
|
+
commands: [
|
|
231
|
+
{ key: 'novo', name: '/novo', description: 'Começa uma conversa nova' },
|
|
232
|
+
...APP_COMMANDS.map(([key, description]) => ({ key, name: `/${key}`, description })),
|
|
233
|
+
...(init?.commands ?? []).filter((c) => !appNames.has(c.name)).map((c) => ({ key: `claude-${c.name}`, name: `/${c.name}`, description: c.description.split(/(?<=\.)\s/)[0].slice(0, 110) })),
|
|
234
|
+
],
|
|
235
|
+
settings: { model: settings.model, effort: settings.effort, mode: settings.mode },
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
'POST /api/settings': async (request) => {
|
|
239
|
+
const { model, effort, mode } = await readBody(request);
|
|
240
|
+
await saveSettings(projectDir, { model, effort, mode });
|
|
241
|
+
await ensureSession(await settingsOf(projectDir));
|
|
242
|
+
return {};
|
|
243
|
+
},
|
|
244
|
+
'POST /api/send': async (request) => {
|
|
245
|
+
const { text, images = [] } = await readBody(request);
|
|
246
|
+
const current = await ensureSession(await settingsOf(projectDir));
|
|
247
|
+
if (!busy) {
|
|
248
|
+
record?.end();
|
|
249
|
+
record = createWriteStream(path.join(dataDir(projectDir), 'last-turn.ndjson')).on('error', () => {});
|
|
250
|
+
}
|
|
251
|
+
const content = images.length
|
|
252
|
+
? [...images.map(({ mediaType, data }) => ({ type: 'image', source: { type: 'base64', media_type: mediaType, data } })), { type: 'text', text }]
|
|
253
|
+
: text;
|
|
254
|
+
write(current, { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null });
|
|
255
|
+
busy = true;
|
|
256
|
+
broadcast({ type: 'user_prompt', text, images: images.map(({ mediaType, data }) => `data:${mediaType};base64,${data}`) });
|
|
257
|
+
return {};
|
|
258
|
+
},
|
|
259
|
+
'POST /api/permission': async (request) => {
|
|
260
|
+
const { requestId, behavior, updatedInput, updatedPermissions, message } = await readBody(request);
|
|
261
|
+
if (!session) throw new Error('A conversa não está mais aberta. Mande a mensagem de novo.');
|
|
262
|
+
const response = behavior === 'allow'
|
|
263
|
+
? { behavior, updatedInput, ...(updatedPermissions && { updatedPermissions }) }
|
|
264
|
+
: { behavior, message: message || 'O usuário recusou.' };
|
|
265
|
+
write(session, { type: 'control_response', response: { subtype: 'success', request_id: requestId, response } });
|
|
266
|
+
broadcast({ type: 'permission_answer', requestId, behavior });
|
|
267
|
+
return {};
|
|
268
|
+
},
|
|
269
|
+
'POST /api/interrupt': async () => {
|
|
270
|
+
if (session && busy) {
|
|
271
|
+
control(session, { subtype: 'interrupt' });
|
|
272
|
+
broadcast({ type: 'interrupted' });
|
|
273
|
+
}
|
|
274
|
+
return {};
|
|
275
|
+
},
|
|
276
|
+
'POST /api/new': async () => {
|
|
277
|
+
closeSession();
|
|
278
|
+
sessionId = null;
|
|
279
|
+
busy = false;
|
|
280
|
+
history = [];
|
|
281
|
+
broadcast({ type: 'reset' }, false);
|
|
282
|
+
settingsOf(projectDir).then(ensureSession).catch(() => {});
|
|
283
|
+
return {};
|
|
284
|
+
},
|
|
285
|
+
'POST /api/app': async (request) => {
|
|
286
|
+
const { args } = await readBody(request);
|
|
287
|
+
if (!APP_COMMANDS.some(([name]) => name === args[0])) throw new Error(`Comando desconhecido: ${args[0]}`);
|
|
288
|
+
if (app) throw new Error('Já tem um comando do ambiente rodando. Pare ele antes de mandar outro.');
|
|
289
|
+
const child = spawn(appScript, args, { cwd: projectDir, env: { ...process.env, FORCE_COLOR: '0' }, detached: true });
|
|
290
|
+
app = { child, stopped: false, error: '' };
|
|
291
|
+
const current = app;
|
|
292
|
+
broadcast({ type: 'app_start', command: `/${args.join(' ')}` });
|
|
293
|
+
const out = (line) => broadcast({ type: 'app_out', text: `${line}\n` });
|
|
294
|
+
eachLine(child.stdout, out);
|
|
295
|
+
eachLine(child.stderr, out);
|
|
296
|
+
child.on('error', (error) => {
|
|
297
|
+
current.error = `Não foi possível executar ${appScript} (${error.message}). Confira se ele existe e é executável.`;
|
|
298
|
+
});
|
|
299
|
+
child.on('close', (code, signal) => {
|
|
300
|
+
app = null;
|
|
301
|
+
broadcast({ type: 'app_exit', code: code ?? 1, stopped: current.stopped || Boolean(signal), error: current.error });
|
|
302
|
+
});
|
|
303
|
+
return {};
|
|
304
|
+
},
|
|
305
|
+
'POST /api/stop': async () => {
|
|
306
|
+
if (app) {
|
|
307
|
+
app.stopped = true;
|
|
308
|
+
killGroup(app.child, 'SIGINT');
|
|
309
|
+
}
|
|
310
|
+
return {};
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const server = createServer(async (request, response) => {
|
|
315
|
+
const url = new URL(request.url, 'http://localhost');
|
|
316
|
+
if (url.pathname === '/api/events') {
|
|
317
|
+
if (url.searchParams.get('t') !== token) return response.writeHead(403).end();
|
|
318
|
+
response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
|
|
319
|
+
for (const event of [{ type: 'reset' }, ...history]) response.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
320
|
+
clients.add(response);
|
|
321
|
+
request.on('close', () => clients.delete(response));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const route = routes[`${request.method} ${url.pathname}`];
|
|
325
|
+
if (!route) {
|
|
326
|
+
const file = path.join(DIST, path.normalize(url.pathname === '/' ? '/index.html' : url.pathname));
|
|
327
|
+
if (!file.startsWith(DIST) || !existsSync(file)) return response.writeHead(404).end();
|
|
328
|
+
response.writeHead(200, { 'content-type': MIME[path.extname(file)] ?? 'application/octet-stream' });
|
|
329
|
+
return createReadStream(file).pipe(response);
|
|
330
|
+
}
|
|
331
|
+
if (request.headers['x-djinn-token'] !== token) return response.writeHead(403).end();
|
|
332
|
+
try {
|
|
333
|
+
response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await route(request)));
|
|
334
|
+
} catch (error) {
|
|
335
|
+
response.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: error.message }));
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
return new Promise((resolve, reject) => {
|
|
340
|
+
server.on('error', (error) => reject(new Error(error.code === 'EADDRINUSE' ? `A porta ${port} já está em uso. Feche o outro djinn ou rode sem DJINN_PORT.` : error.message)));
|
|
341
|
+
server.listen(port, '127.0.0.1', () => resolve({
|
|
342
|
+
url: `http://127.0.0.1:${server.address().port}/?t=${token}`,
|
|
343
|
+
close: () => {
|
|
344
|
+
closeSession();
|
|
345
|
+
if (app) killGroup(app.child);
|
|
346
|
+
server.close();
|
|
347
|
+
},
|
|
348
|
+
}));
|
|
349
|
+
});
|
|
350
|
+
}
|