@nibgat/aide 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/repl.mjs ADDED
@@ -0,0 +1,501 @@
1
+ /**
2
+ * Etkileşimli oturum (argümansız `aide`) ve ilk kurulum sihirbazı.
3
+ * - Yanıt akışla gelir, bitince Markdown olarak yeniden çizilir; adımlar kalıcı soluk satırlar, son adımda canlı gösterge.
4
+ * - Ctrl+C: süren turu iptal eder (sunucudaki iş de durur); boş satırda iki kez → çıkış. Ctrl+D → çıkış.
5
+ * - Yapıştırılan çok satırlı metin tek mesajdır; satır sonu `\` ile alt satıra devam. Tur sürerken yazılanlar sıraya girer.
6
+ * - `/komut` (Tab ile tamamlanır), `!komut` (yerel terminal), `/aide …` (CLI'nin her komutu oturum içinden).
7
+ */
8
+ import { createInterface, clearLine, cursorTo } from 'node:readline';
9
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
10
+ import { spawn } from 'node:child_process';
11
+ import path from 'node:path';
12
+ import os from 'node:os';
13
+ import { normalizeServer, updateConfig, HISTORY_FILE, readConfig, streamTurn, AideError, settings, probe, login, call, ask } from './client.mjs';
14
+ import { runGroupCommand, routesTable, parseArgs, splitLine, runApi, GROUP_TITLES } from './commands.mjs';
15
+ import { renderMarkdown, createSpinner, visibleLength, columns, box, c } from './ui.mjs';
16
+
17
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
18
+
19
+ // ─────────────────────────────── ilk kurulum ───────────────────────────────
20
+
21
+ /** İlk çalıştırma: Ai-de sunucusunun adresi sorulur, denenir, giriş yapılır ve kaydedilir. */
22
+ const onboard = async (flags = {}) => {
23
+ const config = readConfig();
24
+
25
+ console.log(`\n${box([`${c.accent('✻')} ${c.bold('Ai-de')} ${c.dim(`CLI v${VERSION}`)}`, 'Şirketinizin Ai-de sistemini bu terminalden yöneteceksiniz.', c.dim('Önce hangi Ai-de sunucusuna bağlanacağımızı ayarlayalım.')])}\n`);
26
+
27
+ let server = normalizeServer(flags.server || '');
28
+
29
+ for(let attempt = 0; attempt < 5; attempt += 1) {
30
+ if(!server) {
31
+ server = normalizeServer(await ask(`${c.accent('?')} Ai-de sunucusunun adresi ${c.dim(`(ör. https://aide.sirketiniz.com) [${config.server || 'http://localhost:3000'}]`)}: `, { fallback: config.server || 'http://localhost:3000' }));
32
+ }
33
+
34
+ try {
35
+ await probe(server);
36
+ console.log(`${c.green('✓')} ${server} bir Ai-de sunucusu, bağlanılabiliyor.`);
37
+ break;
38
+ } catch (error) {
39
+ console.log(c.red(`✗ ${error.cause?.code || error.message}`));
40
+ server = '';
41
+
42
+ if(attempt === 4) {
43
+ throw new AideError('Sunucuya bağlanılamadı. Adresi kontrol edip tekrar deneyin: aide login --server <adres>');
44
+ }
45
+ }
46
+ }
47
+
48
+ for(let attempt = 0; attempt < 3; attempt += 1) {
49
+ const email = String(flags.email || await ask(`${c.accent('?')} E-posta${config.email ? c.dim(` [${config.email}]`) : ''}: `, { fallback: config.email || '' })).trim();
50
+ const password = String(flags.password || process.env.AIDE_PASSWORD || await ask(`${c.accent('?')} Şifre: `, { hidden: true }));
51
+
52
+ try {
53
+ const token = await login(server, email, password);
54
+
55
+ updateConfig({ server, email, token, sessionId: null });
56
+ console.log(`${c.green('✓')} Giriş yapıldı: ${email}\n`);
57
+
58
+ return { server, token };
59
+ } catch (error) {
60
+ console.log(c.red(`✗ ${error.message}`));
61
+
62
+ if(flags.password || process.env.AIDE_PASSWORD) {
63
+ break;
64
+ }
65
+ }
66
+ }
67
+
68
+ throw new AideError('Giriş yapılamadı.');
69
+ };
70
+
71
+ // ─────────────────────────────── oturum yardımcıları ───────────────────────────────
72
+
73
+ const SLASH = [
74
+ ['/yardim', 'komutları göster'],
75
+ ['/yeni', 'yeni sohbet'],
76
+ ['/oturumlar', 'sohbetleri listele'],
77
+ ['/oturum', '<no|id> sohbete geç'],
78
+ ['/ad', '<başlık> bu sohbetin adını değiştir'],
79
+ ['/sil', 'bu sohbeti sil'],
80
+ ['/ekip', 'personel listesi'],
81
+ ['/durum', 'motor, model ve kuyruk durumu'],
82
+ ['/bildirimler', 'son bildirimler'],
83
+ ['/hatirlat', '<ne zaman + ne> hatırlatma kur'],
84
+ ['/aide', '<komut> CLI komutu çalıştır (ör. /aide data list)'],
85
+ ['/sunucu', 'bağlı sunucu ve kullanıcı'],
86
+ ['/temizle', 'ekranı temizle'],
87
+ ['/cikis', 'çık']
88
+ ];
89
+ const ALIASES = { '/remind': '/hatirlat', '/hatırlat': '/hatirlat', '/help': '/yardim', '/new': '/yeni', '/sessions': '/oturumlar', '/session': '/oturum', '/rename': '/ad', '/delete': '/sil', '/team': '/ekip', '/status': '/durum', '/notifications': '/bildirimler', '/server': '/sunucu', '/clear': '/temizle', '/exit': '/cikis', '/quit': '/cikis', '/çıkış': '/cikis', '/yardım': '/yardim' };
90
+
91
+ const loadHistory = () => {
92
+ try {
93
+ return readFileSync(HISTORY_FILE, 'utf8').split('\n').filter(Boolean).slice(0, 500);
94
+ } catch {
95
+ return [];
96
+ }
97
+ };
98
+
99
+ const saveHistory = (history) => {
100
+ try {
101
+ mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
102
+ writeFileSync(HISTORY_FILE, history.slice(0, 500).join('\n'), { mode: 0o600 });
103
+ } catch {
104
+ // geçmiş yazılamazsa sohbet etkilenmez
105
+ }
106
+ };
107
+
108
+ /** Akışla basılmış ham metni silmek için kaç ekran satırı kapladığı (satır sarmaları dahil). */
109
+ const screenRows = (text) => String(text).split('\n').reduce((sum, line) => sum + Math.max(1, Math.ceil(visibleLength(line) / columns())), 0);
110
+
111
+ const ensureSession = async (flags, fresh = false) => {
112
+ const config = readConfig();
113
+
114
+ if(!fresh && config.sessionId) {
115
+ return config.sessionId;
116
+ }
117
+
118
+ const json = await call(flags, 'POST', '/api/chat/sessions', { title: 'Terminal' });
119
+ const id = (json.data || json.session || {}).id;
120
+
121
+ updateConfig({ sessionId: id });
122
+
123
+ return id;
124
+ };
125
+
126
+ /** Gürültü adımları (yönlendirme, tamamlandı) yalnızca göstergede kalır; anlamlı adımlar kalıcı satır olur. */
127
+ const isNoise = (message) => /^(Tamamlandı|🧭 İstek yönlendiriliyor)/.test(message);
128
+
129
+ // ─────────────────────────────── etkileşimli oturum ───────────────────────────────
130
+
131
+ const startRepl = async (flags = {}) => {
132
+ let { server, token } = settings(flags);
133
+
134
+ if(!server || !token) {
135
+ ({ server, token } = await onboard(flags));
136
+ }
137
+
138
+ const [me, profile, status] = await Promise.all([
139
+ call(flags, 'GET', '/api/auth/me').catch((error) => (error.status === 401 ? 'unauthorized' : null)),
140
+ call(flags, 'GET', '/api/me/profile').catch(() => null),
141
+ call(flags, 'GET', '/api/engine/status').catch(() => null)
142
+ ]);
143
+
144
+ if(me === 'unauthorized') {
145
+ console.log(c.yellow('Oturumun süresi dolmuş, yeniden giriş yapalım.'));
146
+ ({ server, token } = await onboard({ ...flags, server }));
147
+ }
148
+
149
+ let sessionId = await ensureSession(flags);
150
+ const user = me?.data || me?.user || me || {};
151
+ const organization = profile?.profile?.organization || profile?.data?.organization || {};
152
+ const work = status?.models?.work || status?.data?.models?.work;
153
+ const loaded = status?.scheduler?.loaded || status?.data?.scheduler?.loaded || [];
154
+
155
+ console.log(`\n${box([
156
+ `${c.accent('✻')} ${c.bold('Ai-de')} ${c.dim(`v${VERSION}`)}`,
157
+ `${user.name || user.email || ''}${organization.name ? c.dim(` · ${organization.name}`) : ''}`,
158
+ c.dim(`${server}${work ? ` · ${String(work).split('/').pop()}${loaded.length ? ` (${loaded.length} model yüklü)` : ''}` : ''}`)
159
+ ])}`);
160
+ console.log(c.dim(' /yardim komutlar · !komut yerel terminal · Ctrl+C iptal · Ctrl+D çıkış\n'));
161
+
162
+ const completer = (line) => {
163
+ if(!line.startsWith('/')) {
164
+ return [[], line];
165
+ }
166
+
167
+ if(line.startsWith('/aide ')) {
168
+ const groups = Object.keys(GROUP_TITLES).map((group) => `/aide ${group}`);
169
+ const hits = groups.filter((item) => item.startsWith(line));
170
+
171
+ return [hits.length ? hits : groups, line];
172
+ }
173
+
174
+ const all = [...SLASH.map(([name]) => name), ...Object.keys(ALIASES)];
175
+ const hits = all.filter((name) => name.startsWith(line));
176
+
177
+ return [hits.length ? hits : SLASH.map(([name]) => name), line];
178
+ };
179
+ const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: `${c.accent('❯')} `, completer, history: loadHistory(), historySize: 500, removeHistoryDuplicates: true });
180
+ const state = { controller: null, lastSigint: 0, sessions: [], pending: [], queue: [], timer: null, busy: false };
181
+ const replFlags = { ...flags };
182
+
183
+ const replAsk = (question) => new Promise((resolve) => rl.question(question, (answer) => resolve(answer.trim())));
184
+
185
+ // ── tur ──
186
+ const turn = async (text) => {
187
+ const spinner = createSpinner('Düşünüyor');
188
+ const started = Date.now();
189
+ let streamed = '';
190
+
191
+ state.controller = new AbortController();
192
+ spinner.start();
193
+
194
+ try {
195
+ const final = await streamTurn(replFlags, {
196
+ signal: state.controller.signal,
197
+ prompt: text,
198
+ sessionId,
199
+ onStep: (message) => {
200
+ const clean = message.replace(/\.\.\.$/, '…');
201
+
202
+ if(isNoise(message)) {
203
+ spinner.update(clean);
204
+ return;
205
+ }
206
+
207
+ spinner.stop(`⎿ ${clean}`);
208
+ spinner.update(clean);
209
+ spinner.start();
210
+ },
211
+ onToken: (token) => {
212
+ if(!streamed) {
213
+ spinner.stop();
214
+ }
215
+
216
+ streamed += token;
217
+ process.stdout.write(token);
218
+ }
219
+ });
220
+
221
+ spinner.stop();
222
+
223
+ // Akışla basılan ham metin silinir, yanıt Markdown olarak çizilir
224
+ if(streamed && process.stdout.isTTY) {
225
+ const rows = screenRows(streamed);
226
+
227
+ process.stdout.write(rows > 1 ? `\x1b[${rows - 1}F\x1b[J` : '\r\x1b[2K');
228
+ } else if(streamed) {
229
+ process.stdout.write('\n');
230
+ }
231
+
232
+ const parts = Array.isArray(final.messages) && final.messages.length > 1 ? final.messages : [final.finalTurkishResponse || streamed];
233
+ // Terminal değilse akışla basılan metin silinemez: tek parçalı yanıt yeniden basılmaz
234
+ const shown = streamed && !process.stdout.isTTY && parts.length === 1 ? [] : parts;
235
+
236
+ shown.forEach((part, index) => {
237
+ if(index > 0) {
238
+ console.log(c.gray('─'.repeat(Math.min(48, columns()))));
239
+ }
240
+
241
+ console.log(renderMarkdown(part));
242
+ });
243
+
244
+ const seconds = ((final.metrics?.totalMs || Date.now() - started) / 1000).toFixed(1);
245
+ const analysis = final.analysis ? ` · ${final.analysis.status}${final.analysis.attempts?.length > 1 ? ` (${final.analysis.attempts.length} analiz)` : ''}` : '';
246
+
247
+ console.log(c.dim(`\n ${seconds} sn · ${final.route || '—'}${analysis}\n`));
248
+ } catch (error) {
249
+ spinner.stop();
250
+
251
+ if(error.name === 'AbortError') {
252
+ console.log(c.yellow('\n⏹ İptal edildi.\n'));
253
+ await call(replFlags, 'POST', '/api/chat/cancel', { sessionId }).catch(() => null);
254
+ } else {
255
+ console.log(c.red(`\n✗ ${error.message}\n`));
256
+ }
257
+ } finally {
258
+ state.controller = null;
259
+ }
260
+ };
261
+
262
+ // ── yerel terminal ──
263
+ const shell = (command) => new Promise((resolve) => {
264
+ rl.pause();
265
+ process.stdin.setRawMode?.(false);
266
+
267
+ const child = spawn(command, { shell: process.platform === 'win32' ? 'powershell.exe' : (process.env.SHELL || '/bin/sh'), stdio: 'inherit' });
268
+
269
+ child.on('exit', (code) => {
270
+ if(code) {
271
+ console.log(c.red(`(çıkış kodu ${code})`));
272
+ }
273
+
274
+ process.stdin.setRawMode?.(true);
275
+ rl.resume();
276
+ resolve();
277
+ });
278
+ });
279
+
280
+ // ── eğik çizgi komutları ──
281
+ const slash = async (text) => {
282
+ const [rawName, ...rest] = text.split(/\s+/);
283
+ const name = ALIASES[rawName.toLocaleLowerCase('tr-TR')] || rawName.toLocaleLowerCase('tr-TR');
284
+ const argument = rest.join(' ').trim();
285
+
286
+ switch(name) {
287
+ case '/yardim':
288
+ SLASH.forEach(([command, summary]) => console.log(` ${c.accent(command.padEnd(14))} ${c.dim(summary)}`));
289
+ console.log(` ${c.accent('!komut'.padEnd(14))} ${c.dim('yerel terminalde çalıştır')}`);
290
+ break;
291
+ case '/yeni':
292
+ sessionId = await ensureSession(replFlags, true);
293
+ console.log(c.dim(`Yeni sohbet: ${sessionId}`));
294
+ break;
295
+ case '/oturumlar': {
296
+ const json = await call(replFlags, 'GET', '/api/chat/sessions');
297
+
298
+ state.sessions = json.data || json.sessions || [];
299
+ state.sessions.slice(0, 20).forEach((item, index) => console.log(` ${c.accent(String(index + 1).padStart(2))} ${item.id === sessionId ? c.bold(item.title) : item.title} ${c.dim(`· ${item.messageCount ?? 0} mesaj`)}`));
300
+ console.log(c.dim(' /oturum <no> ile geç'));
301
+ break;
302
+ }
303
+ case '/oturum': {
304
+ const byNumber = state.sessions[Number(argument) - 1];
305
+
306
+ sessionId = byNumber?.id || argument || sessionId;
307
+ updateConfig({ sessionId });
308
+ console.log(c.dim(`Sohbet: ${byNumber?.title || sessionId}`));
309
+ break;
310
+ }
311
+ case '/ad':
312
+ if(!argument) {
313
+ console.log(c.dim('Kullanım: /ad <yeni başlık>'));
314
+ break;
315
+ }
316
+
317
+ await call(replFlags, 'PATCH', `/api/chat/sessions/${encodeURIComponent(sessionId)}`, { title: argument });
318
+ console.log(c.green('✓ Başlık değişti.'));
319
+ break;
320
+ case '/sil':
321
+ if(/^(e|evet|y|yes)$/i.test(await replAsk(`Bu sohbet silinsin mi? (e/H) `))) {
322
+ await call(replFlags, 'DELETE', `/api/chat/sessions/${encodeURIComponent(sessionId)}`);
323
+ sessionId = await ensureSession(replFlags, true);
324
+ console.log(c.green('✓ Silindi; yeni sohbet açıldı.'));
325
+ }
326
+ break;
327
+ case '/ekip':
328
+ await runGroupCommand('team', parseArgs(['list']));
329
+ break;
330
+ case '/durum': {
331
+ const json = await call(replFlags, 'GET', '/api/engine/status');
332
+ const data = json.data || json;
333
+
334
+ console.log(` ${c.bold('modeller')} ${Object.entries(data.models || {}).map(([role, model]) => `${role}: ${String(model).split('/').pop()}`).join(' · ')}`);
335
+ console.log(` ${c.bold('yüklü')} ${(data.scheduler?.loaded || []).map((model) => String(model).split('/').pop()).join(', ') || '—'} ${c.dim(`· kuyruk ${data.scheduler?.queued ?? 0}${data.scheduler?.busy ? ' · meşgul' : ''}`)}`);
336
+
337
+ if(data.slo) {
338
+ console.log(` ${c.bold('hız')} p50 ${data.slo.p50Seconds ?? '—'} sn · p95 ${data.slo.p95Seconds ?? '—'} sn ${c.dim(`(${data.slo.turns ?? 0} tur)`)}`);
339
+ }
340
+ break;
341
+ }
342
+ case '/bildirimler':
343
+ await runGroupCommand('notify', parseArgs(['list']));
344
+ break;
345
+ case '/hatirlat':
346
+ if(!argument) {
347
+ await runGroupCommand('reminders', parseArgs(['list']));
348
+ break;
349
+ }
350
+
351
+ await runGroupCommand('reminders', { ...parseArgs(['add']), positional: ['add', argument] });
352
+ break;
353
+ case '/aide': {
354
+ const args = parseArgs(splitLine(argument));
355
+ const [group, ...positional] = args.positional;
356
+
357
+ if(group === 'api') {
358
+ await runApi({ ...args, flags: { ...replFlags, ...args.flags }, positional });
359
+ } else if(group === 'routes') {
360
+ routesTable(args.flags);
361
+ } else if(!(await runGroupCommand(group, { ...args, flags: { ...replFlags, ...args.flags }, positional }))) {
362
+ console.log(c.dim(`Gruplar: ${Object.keys(GROUP_TITLES).join(', ')} · api · routes`));
363
+ }
364
+ break;
365
+ }
366
+ case '/sunucu':
367
+ console.log(` ${server} ${c.dim(`· ${readConfig().email || ''} · sohbet ${sessionId}`)}`);
368
+ break;
369
+ case '/temizle':
370
+ console.clear();
371
+ break;
372
+ case '/cikis':
373
+ rl.close();
374
+ break;
375
+ default:
376
+ console.log(c.dim(`Bilinmeyen komut: ${rawName} (/yardim)`));
377
+ }
378
+ };
379
+
380
+ // ── girdi ──
381
+ const run = async (text) => {
382
+ state.busy = true;
383
+
384
+ try {
385
+ if(text.startsWith('/')) {
386
+ await slash(text);
387
+ } else if(text.startsWith('!')) {
388
+ await shell(text.slice(1).trim());
389
+ } else {
390
+ await turn(text);
391
+ }
392
+ } catch (error) {
393
+ console.log(c.red(`✗ ${error.message}`));
394
+ } finally {
395
+ state.busy = false;
396
+ }
397
+
398
+ if(state.queue.length > 0) {
399
+ const next = state.queue.shift();
400
+
401
+ console.log(`${c.accent('❯')} ${next}`);
402
+ await run(next);
403
+ return;
404
+ }
405
+
406
+ rl.prompt();
407
+ };
408
+
409
+ const submit = (text) => {
410
+ if(!text) {
411
+ rl.prompt();
412
+ return;
413
+ }
414
+
415
+ if(state.busy) {
416
+ state.queue.push(text);
417
+ console.log(c.dim(` (sıraya alındı: ${text.slice(0, 60)})`));
418
+ return;
419
+ }
420
+
421
+ void run(text);
422
+ };
423
+
424
+ // Yapıştırma: art arda gelen satırlar tek mesajdır; satır sonu "\" ile devam edilir
425
+ rl.on('line', (line) => {
426
+ const continued = /\\$/.test(line);
427
+
428
+ state.pending.push(continued ? line.slice(0, -1) : line);
429
+ clearTimeout(state.timer);
430
+
431
+ if(continued) {
432
+ process.stdout.write(c.dim('… '));
433
+ return;
434
+ }
435
+
436
+ state.timer = setTimeout(() => {
437
+ const text = state.pending.join('\n').trim();
438
+
439
+ state.pending = [];
440
+ submit(text);
441
+ }, 30);
442
+ });
443
+
444
+ rl.on('SIGINT', () => {
445
+ if(state.controller) {
446
+ state.controller.abort();
447
+ return;
448
+ }
449
+
450
+ if(rl.line) {
451
+ rl.write(null, { ctrl: true, name: 'u' });
452
+ return;
453
+ }
454
+
455
+ if(Date.now() - state.lastSigint < 2000) {
456
+ rl.close();
457
+ return;
458
+ }
459
+
460
+ state.lastSigint = Date.now();
461
+ console.log(c.dim('\n(çıkmak için tekrar Ctrl+C ya da Ctrl+D)'));
462
+ rl.prompt();
463
+ });
464
+
465
+ // Boştayken yeni bildirimler istemin üstüne düşer
466
+ let since = new Date().toISOString();
467
+ const poll = setInterval(async () => {
468
+ if(state.busy) {
469
+ return;
470
+ }
471
+
472
+ const json = await call(replFlags, 'GET', `/api/engine/notifications?since=${encodeURIComponent(since)}`).catch(() => null);
473
+ const items = json?.data || json?.notifications || [];
474
+
475
+ if(Array.isArray(items) && items.length > 0 && !state.busy) {
476
+ clearLine(process.stdout, 0);
477
+ cursorTo(process.stdout, 0);
478
+ items.forEach((item) => {
479
+ console.log(`${c.accent('●')} ${c.bold(item.title || 'Bildirim')} ${c.dim(item.body || item.message || '')}`);
480
+ since = item.at || item.createdAt || since;
481
+ });
482
+ rl.prompt(true);
483
+ }
484
+ }, 30000);
485
+
486
+ rl.on('close', () => {
487
+ clearInterval(poll);
488
+ saveHistory(rl.history || []);
489
+ console.log(c.dim('\nGörüşmek üzere.'));
490
+ process.exit(0);
491
+ });
492
+
493
+ rl.prompt();
494
+ };
495
+
496
+ export {
497
+ ensureSession,
498
+ startRepl,
499
+ onboard,
500
+ VERSION
501
+ };