aegiscode 5.2.33 → 6.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/src/app.js ADDED
@@ -0,0 +1,619 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The CLI application: session state, the ask/stream path, and the command
5
+ * dispatcher.
6
+ *
7
+ * Split from `bin/aegiscode.js` so the whole app is constructible with injected
8
+ * IO (`out`, `err`, `readline`) and a stub client — the tests drive real turns,
9
+ * real streaming and real command dispatch without a TTY and without a child
10
+ * process. Bin entry = argument parsing and process lifecycle; everything else
11
+ * lives here.
12
+ */
13
+
14
+ const readline = require('node:readline');
15
+ const { createTools, createClient, usageTokens } = require('./deps.js');
16
+ const { GLYPH, VERBS, themeOf, RESET, BOLD } = require('./theme.js');
17
+ const { LiveRegion, termWidth, EC, w } = require('./screen.js');
18
+ const { parseLine, findCommand, COMMANDS } = require('./commands.js');
19
+ const render = require('./render.js');
20
+ const { fmtTokens, fmtEur, maskKey, fmtElapsed } = require('./format.js');
21
+
22
+ const VERSION = require('../package.json').version;
23
+
24
+ function createApp(options = {}) {
25
+ const opts = {
26
+ model: null,
27
+ stream: true,
28
+ light: false,
29
+ maxTokens: undefined,
30
+ system: undefined,
31
+ interactive: true,
32
+ ...options,
33
+ };
34
+ const out = options.out || process.stdout;
35
+ const err = options.err || process.stderr;
36
+ const client = options.client || createClient();
37
+ const { TOOLS, toolList } = options.tools || createTools(client);
38
+
39
+ const ctx = () => ({ light: opts.light });
40
+ const width = () => (options.width ? options.width() : termWidth());
41
+
42
+ const session = {
43
+ turns: 0,
44
+ calls: 0,
45
+ tokens: 0,
46
+ inputTokens: 0,
47
+ outputTokens: 0,
48
+ cost: 0, // € spent, from ledger rows observed this session
49
+ balance: null,
50
+ lastLedgerAt: null,
51
+ startedAt: Date.now(),
52
+ };
53
+
54
+ let abortController = null;
55
+ let closed = false;
56
+
57
+ // --- helpers --------------------------------------------------------------
58
+
59
+ /** Refresh the balance and fold any *new* usage row into the session tally.
60
+ * Best-effort: an accounting refresh must never break a turn.
61
+ * @returns {Promise<{balance:number, lastCost:number|null}|null>} */
62
+ async function refreshSpend() {
63
+ try {
64
+ const data = await client.tokenBankBalance();
65
+ session.balance = Number(data.balance_eur || 0);
66
+ let lastCost = null;
67
+ const row = (data.ledger || [])[0];
68
+ if (row && row.kind === 'usage' && row.created_at && row.created_at !== session.lastLedgerAt) {
69
+ session.lastLedgerAt = row.created_at;
70
+ // amount_eur is signed from the user's side (negative = spent); the
71
+ // ledger fallback inverts the raw micros column, which has the
72
+ // opposite sign.
73
+ const eur = Math.abs(
74
+ Number(row.amount_eur != null ? row.amount_eur : -Number(row.charged_micros || 0) / 1e6)
75
+ );
76
+ session.cost += eur;
77
+ lastCost = eur;
78
+ }
79
+ return { balance: session.balance, lastCost };
80
+ } catch {
81
+ return null; // no balance rights / offline: keep showing tokens
82
+ }
83
+ }
84
+
85
+ function bannerLines() {
86
+ return render.renderBanner(ctx(), {
87
+ width: width(),
88
+ version: VERSION,
89
+ model: opts.model || 'server default',
90
+ base: client.apiBase,
91
+ key: maskKey(client.apiKey),
92
+ stream: opts.stream,
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Write lines out. Accepts a single line as well as a block — a bare string
98
+ * would otherwise be iterated as characters, which renders a notice one
99
+ * character per line.
100
+ */
101
+ function emit(lines) {
102
+ const block = typeof lines === 'string' ? [lines] : lines;
103
+ for (const l of block) out.write(l + '\n');
104
+ }
105
+
106
+ // --- the ask path ---------------------------------------------------------
107
+
108
+ /**
109
+ * One pooled call, streamed into the live region.
110
+ * @returns {Promise<{text:string, usage:object|null, model:string|null, ms:number, interrupted:boolean}>}
111
+ */
112
+ async function ask(prompt) {
113
+ const started = Date.now();
114
+ const live = opts.interactive && opts.stream && out.isTTY ? new LiveRegion(out) : null;
115
+ let tick = 0;
116
+ let chars = 0;
117
+ let partial = '';
118
+ let reasoning = 0;
119
+ let verb = VERBS[Math.floor(Math.random() * VERBS.length)];
120
+ let sawReasoning = false;
121
+
122
+ const paint = () => {
123
+ if (!live) return;
124
+ live.update([
125
+ render.renderWorking(ctx(), {
126
+ tick: tick++,
127
+ verb,
128
+ elapsedMs: Date.now() - started,
129
+ streamed: chars,
130
+ }),
131
+ ]);
132
+ };
133
+
134
+ const timer = live ? setInterval(paint, 90) : null;
135
+ if (timer && timer.unref) timer.unref();
136
+ paint();
137
+
138
+ abortController = new AbortController();
139
+ let interrupted = false;
140
+ const onSigint = () => {
141
+ interrupted = true;
142
+ if (abortController) abortController.abort();
143
+ };
144
+ process.once('SIGINT', onSigint);
145
+
146
+ try {
147
+ const res = await client.chatCompletion({
148
+ prompt,
149
+ model: opts.model || undefined,
150
+ system: opts.system,
151
+ maxTokens: opts.maxTokens,
152
+ stream: Boolean(opts.stream),
153
+ // Ask the server for the token count on the streaming path: an
154
+ // OpenAI-compatible SSE reply carries no usage unless asked, and this
155
+ // is the same wire form the desktop sends.
156
+ includeUsage: Boolean(opts.stream),
157
+ signal: abortController.signal,
158
+ onReasoning: (t) => {
159
+ reasoning += w(t);
160
+ // The pool's worker findings arrive on the reasoning channel before
161
+ // the answer; say so rather than looking stalled.
162
+ if (!sawReasoning) {
163
+ sawReasoning = true;
164
+ verb = 'Reasoning';
165
+ }
166
+ },
167
+ onStream: ({ delta, reasoning: r }) => {
168
+ if (r) reasoning += w(r);
169
+ if (delta) {
170
+ chars += w(delta);
171
+ partial += delta;
172
+ // Re-paint on every delta but coalesce to ~30fps through the frame
173
+ // counter — a fast provider otherwise spends the CPU on escapes.
174
+ if (live && tick % 2 === 0) paint();
175
+ }
176
+ },
177
+ });
178
+
179
+ const choice = (res.choices && res.choices[0]) || {};
180
+ const text = (choice.message && choice.message.content) || partial;
181
+ return {
182
+ text,
183
+ usage: res.usage || null,
184
+ model: res.model || opts.model || null,
185
+ ms: Date.now() - started,
186
+ interrupted,
187
+ reasoningChars: reasoning,
188
+ };
189
+ } catch (e) {
190
+ // A user interrupt is not a failure: keep what already streamed.
191
+ if (interrupted && partial) {
192
+ return {
193
+ text: partial,
194
+ usage: null,
195
+ model: opts.model || null,
196
+ ms: Date.now() - started,
197
+ interrupted: true,
198
+ reasoningChars: reasoning,
199
+ };
200
+ }
201
+ throw e;
202
+ } finally {
203
+ if (timer) clearInterval(timer);
204
+ process.removeListener('SIGINT', onSigint);
205
+ if (live) live.clear();
206
+ abortController = null;
207
+ }
208
+ }
209
+
210
+ /** Print a completed turn: role block, then the accounting line. */
211
+ function printTurn(turn) {
212
+ emit(render.renderTurn(ctx(), turn, width()));
213
+ }
214
+
215
+ /** Ask, then account for it. Shared by plain prompts and `/ask`. */
216
+ async function runPrompt(prompt, { label = 'you' } = {}) {
217
+ if (!prompt) {
218
+ emit(render.renderNotice(ctx(), 'warn', 'nothing to ask — give /ask a prompt'));
219
+ return;
220
+ }
221
+ if (!client.apiKey) {
222
+ emit(
223
+ render.renderNotice(
224
+ ctx(),
225
+ 'error',
226
+ 'no AEGIS_API_KEY set — export one (https://aegiscloud.org) or run /byok-set'
227
+ )
228
+ );
229
+ return;
230
+ }
231
+
232
+ printTurn({ role: 'user', text: prompt, label });
233
+
234
+ let res;
235
+ try {
236
+ res = await ask(prompt);
237
+ } catch (e) {
238
+ emit(render.renderTurn(ctx(), { role: 'error', text: e.message }, width()));
239
+ return;
240
+ }
241
+
242
+ session.turns++;
243
+ session.calls++;
244
+
245
+ const tokens = usageTokens(res.usage);
246
+ if (tokens != null) {
247
+ session.tokens += tokens;
248
+ session.inputTokens += Number(res.usage.input_tokens ?? res.usage.prompt_tokens ?? 0) || 0;
249
+ session.outputTokens += Number(res.usage.output_tokens ?? res.usage.completion_tokens ?? 0) || 0;
250
+ }
251
+
252
+ const spend = await refreshSpend();
253
+
254
+ printTurn({
255
+ role: 'assistant',
256
+ text: res.text || '(the pool returned no text — see /balance for what it charged)',
257
+ meta: {
258
+ model: res.model,
259
+ tokens,
260
+ usage:
261
+ res.usage == null
262
+ ? null
263
+ : {
264
+ input: res.usage.input_tokens ?? res.usage.prompt_tokens,
265
+ output: res.usage.output_tokens ?? res.usage.completion_tokens,
266
+ },
267
+ // What this call settled at, from the ledger row it produced — the
268
+ // number that has to agree with the token count beside it.
269
+ eur: spend && spend.lastCost != null ? spend.lastCost : null,
270
+ ms: res.ms,
271
+ calls: 1,
272
+ },
273
+ });
274
+
275
+ if (res.interrupted) {
276
+ emit(render.renderNotice(ctx(), 'warn', 'interrupted — the pool may still be running and billing this call'));
277
+ }
278
+ }
279
+
280
+ // --- command dispatch -----------------------------------------------------
281
+
282
+ async function runTool(name, args) {
283
+ const tool = TOOLS[name];
284
+ if (!tool) throw new Error(`unknown tool: ${name}`);
285
+ if (!client.apiKey) {
286
+ throw new Error('no AEGIS_API_KEY set — export one (https://aegiscloud.org)');
287
+ }
288
+ const text = await tool.run(args || {});
289
+ emit(render.renderToolResult(ctx(), name, text, width()));
290
+ }
291
+
292
+ // Category order and labels, matching aegiscodex-dev's palette.
293
+ const CATEGORY_ORDER = ['aegis', 'model', 'session', 'data', 'auth', 'support', 'workspace'];
294
+ const CATEGORY_LABEL = {
295
+ aegis: 'Aegis plugin',
296
+ model: 'Model & behavior',
297
+ session: 'Session & context',
298
+ data: 'Data',
299
+ auth: 'Auth',
300
+ support: 'Support',
301
+ workspace: 'Workspace',
302
+ };
303
+
304
+ /**
305
+ * The nearest routable name to a mistyped one: a prefix of it, or a name it
306
+ * is a prefix of. Deliberately simple — `cli/src/fuzzy.js` is a separate
307
+ * workstream and may not exist, so this must not depend on it.
308
+ */
309
+ function nearest(name) {
310
+ const n = String(name || '').toLowerCase();
311
+ if (!n) return null;
312
+ let best = null;
313
+ for (const c of COMMANDS) {
314
+ for (const cand of [c.name, ...(c.aliases || [])]) {
315
+ if (cand === n) continue;
316
+ if (cand.startsWith(n) || n.startsWith(cand)) {
317
+ if (best == null || Math.abs(cand.length - n.length) < Math.abs(best.length - n.length)) {
318
+ best = cand;
319
+ }
320
+ }
321
+ }
322
+ }
323
+ return best;
324
+ }
325
+
326
+ function printHelp() {
327
+ const t = themeOf(ctx());
328
+ emit([render.renderHeading(ctx(), 'commands', width())]);
329
+ const rows = COMMANDS.filter((c) => !c.unavailable).map((c) => ({
330
+ cat: c.category || 'other',
331
+ usage: `/${c.name}${c.args ? ' ' + c.args : ''}`,
332
+ desc: c.desc,
333
+ aliases: (c.aliases || []).map((a) => `/${a}`).join(' '),
334
+ }));
335
+ const widest = rows.reduce((m, r) => Math.max(m, r.usage.length), 0);
336
+ for (const cat of CATEGORY_ORDER) {
337
+ const group = rows.filter((r) => r.cat === cat);
338
+ if (!group.length) continue;
339
+ emit(['', `${t.dim}${BOLD}${CATEGORY_LABEL[cat] || cat}${RESET}`]);
340
+ for (const r of group) {
341
+ const alias = r.aliases ? `${t.dim} (${r.aliases})${RESET}` : '';
342
+ emit([
343
+ ` ${t.gold}${r.usage}${RESET}${' '.repeat(Math.max(1, widest - r.usage.length + 2))}` +
344
+ `${t.white}${r.desc}${RESET}${alias}`,
345
+ ]);
346
+ }
347
+ }
348
+ const unavail = COMMANDS.filter((c) => c.unavailable).map((c) => `/${c.name}`);
349
+ if (unavail.length) {
350
+ emit(['', `${t.dim}not available in this client: ${unavail.join(' ')}${RESET}`]);
351
+ }
352
+ emit(['', render.renderNotice(ctx(), 'info', `plain text is a prompt ${GLYPH.bullet} /exit exits`)]);
353
+ }
354
+
355
+ /** Handle one line of input. Returns false when the session should end. */
356
+ async function handleLine(line) {
357
+ const parsed = parseLine(line);
358
+
359
+ if (parsed.kind === 'empty') return true;
360
+ if (parsed.kind === 'prompt') {
361
+ await runPrompt(parsed.text);
362
+ return true;
363
+ }
364
+ if (parsed.kind === 'unknown') {
365
+ const alt = nearest(parsed.name);
366
+ emit(
367
+ render.renderNotice(
368
+ ctx(),
369
+ 'error',
370
+ `unknown command: /${parsed.name}${alt ? ` — did you mean /${alt}?` : ` — try /help`}`
371
+ )
372
+ );
373
+ return true;
374
+ }
375
+ if (parsed.kind === 'unavailable') {
376
+ const c = parsed.command;
377
+ emit(render.renderNotice(ctx(), 'warn', `/${c.name} is not available in aegiscode — ${c.why}`));
378
+ if (c.alt) emit(render.renderNotice(ctx(), 'info', `try ${c.alt} instead`));
379
+ else {
380
+ const alt = nearest(c.name);
381
+ if (alt) emit(render.renderNotice(ctx(), 'info', `try /${alt} instead`));
382
+ }
383
+ return true;
384
+ }
385
+
386
+ const cmd = parsed.command;
387
+ const arg = parsed.arg;
388
+
389
+ if (cmd.local) {
390
+ switch (cmd.local) {
391
+ case 'exit':
392
+ return false;
393
+ case 'clear':
394
+ out.write(EC.clearScreen);
395
+ // aegiscodex-dev's /clear starts a new session with empty context, so
396
+ // the session tallies reset too (the transcript is not persisted here).
397
+ session.turns = 0;
398
+ session.calls = 0;
399
+ session.tokens = 0;
400
+ session.inputTokens = 0;
401
+ session.outputTokens = 0;
402
+ session.cost = 0;
403
+ session.startedAt = Date.now();
404
+ emit(bannerLines());
405
+ return true;
406
+ case 'help':
407
+ printHelp();
408
+ return true;
409
+ case 'version':
410
+ emit(render.renderNotice(ctx(), 'info', `aegiscode v${VERSION}`));
411
+ return true;
412
+ case 'model':
413
+ if (!arg) {
414
+ emit(render.renderNotice(ctx(), 'info', `model: ${opts.model || 'server default'}`));
415
+ } else if (arg === '-') {
416
+ opts.model = null;
417
+ emit(render.renderNotice(ctx(), 'ok', 'model pin cleared — the server will choose'));
418
+ } else {
419
+ opts.model = arg;
420
+ emit(render.renderNotice(ctx(), 'ok', `pinned model: ${arg}`));
421
+ }
422
+ return true;
423
+ case 'stream':
424
+ opts.stream = arg ? !/^off|false|0$/i.test(arg) : !opts.stream;
425
+ emit(render.renderNotice(ctx(), 'ok', `streaming ${opts.stream ? 'on' : 'off'}`));
426
+ return true;
427
+ case 'theme':
428
+ opts.light = arg ? /^light/i.test(arg) : !opts.light;
429
+ emit(render.renderNotice(ctx(), 'ok', `theme: ${opts.light ? 'light' : 'dark'}`));
430
+ return true;
431
+ case 'cost':
432
+ case 'tokens': {
433
+ const t = themeOf(ctx());
434
+ emit([render.renderHeading(ctx(), 'session', width())]);
435
+ emit([
436
+ ` tokens ${t.white}${fmtTokens(session.tokens)}${RESET} ` +
437
+ t.gray + `(${fmtTokens(session.inputTokens)} in / ${fmtTokens(session.outputTokens)} out)` + RESET,
438
+ ` spend ${t.green}${fmtEur(session.cost)}${RESET}`,
439
+ ` calls ${session.calls}`,
440
+ ` balance ${session.balance == null ? 'unknown' : fmtEur(session.balance)}`,
441
+ ` elapsed ${fmtElapsed(Date.now() - session.startedAt)}`,
442
+ ]);
443
+ return true;
444
+ }
445
+ default:
446
+ emit(render.renderNotice(ctx(), 'error', `/${cmd.name} is not implemented`));
447
+ return true;
448
+ }
449
+ }
450
+
451
+ if (cmd.generic) {
452
+ const { tool, args } = cmd.build(arg);
453
+ if (!tool) {
454
+ emit(render.renderNotice(ctx(), 'error', '/tool needs a tool name — see /help'));
455
+ return true;
456
+ }
457
+ try {
458
+ await runTool(tool, args);
459
+ } catch (e) {
460
+ emit(render.renderNotice(ctx(), 'error', e.message));
461
+ }
462
+ return true;
463
+ }
464
+
465
+ // Tool-backed command.
466
+ try {
467
+ let args = cmd.build(arg);
468
+ if (cmd.secret) {
469
+ const key = await readSecret(`provider key for ${args.provider}: `);
470
+ if (!key) {
471
+ emit(render.renderNotice(ctx(), 'warn', 'empty key — nothing sent'));
472
+ return true;
473
+ }
474
+ args = { ...args, api_key: key };
475
+ }
476
+ if (cmd.tool === 'aegis_ask') return await runPrompt(args.prompt);
477
+ await runTool(cmd.tool, args);
478
+ } catch (e) {
479
+ emit(render.renderNotice(ctx(), 'error', e.message));
480
+ }
481
+ return true;
482
+ }
483
+
484
+ /** Read a secret without echoing it (raw mode; falls back to plain input). */
485
+ function readSecret(promptText) {
486
+ if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
487
+ return Promise.resolve('');
488
+ }
489
+ return new Promise((resolve) => {
490
+ const t = themeOf(ctx());
491
+ out.write(t.gray + promptText + RESET);
492
+ const stdin = process.stdin;
493
+ let buf = '';
494
+ stdin.setRawMode(true);
495
+ stdin.resume();
496
+ const onData = (chunk) => {
497
+ for (const ch of chunk.toString('utf8')) {
498
+ if (ch === '\r' || ch === '\n') {
499
+ stdin.setRawMode(false);
500
+ stdin.removeListener('data', onData);
501
+ out.write('\n');
502
+ return resolve(buf);
503
+ }
504
+ if (ch === '\x03') {
505
+ // ctrl+c
506
+ stdin.setRawMode(false);
507
+ stdin.removeListener('data', onData);
508
+ out.write('\n');
509
+ return resolve('');
510
+ }
511
+ if (ch === '\u007f') {
512
+ buf = buf.slice(0, -1);
513
+ out.write('\b \b');
514
+ continue;
515
+ }
516
+ buf += ch;
517
+ out.write('•');
518
+ }
519
+ };
520
+ stdin.on('data', onData);
521
+ });
522
+ }
523
+
524
+ // --- entry points ---------------------------------------------------------
525
+
526
+ /** Non-interactive: one prompt, plain output, exit code. */
527
+ async function runOnce(prompt, { json = false } = {}) {
528
+ if (!client.apiKey) {
529
+ err.write('aegiscode: no AEGIS_API_KEY set. Export your key first (https://aegiscloud.org).\n');
530
+ return 2;
531
+ }
532
+ const res = await ask(prompt);
533
+ await refreshSpend();
534
+ const tokens = usageTokens(res.usage);
535
+ if (json) {
536
+ out.write(
537
+ JSON.stringify(
538
+ {
539
+ text: res.text,
540
+ model: res.model,
541
+ usage: res.usage,
542
+ tokens,
543
+ ms: res.ms,
544
+ interrupted: res.interrupted,
545
+ balance_eur: session.balance,
546
+ },
547
+ null,
548
+ 2
549
+ ) + '\n'
550
+ );
551
+ } else {
552
+ out.write(res.text + '\n');
553
+ if (tokens != null) {
554
+ const t = themeOf(ctx());
555
+ out.write(
556
+ t.dim + GLYPH.spend + ' ' + t.blue + (res.model || 'aegis') + RESET +
557
+ t.dim + ' ' + GLYPH.bullet + ' ' + RESET +
558
+ t.white + `${fmtTokens(tokens)} tok` + RESET + '\n'
559
+ );
560
+ }
561
+ }
562
+ return res.interrupted ? 130 : 0;
563
+ }
564
+
565
+ /** Interactive REPL. */
566
+ async function runInteractive() {
567
+ emit(bannerLines());
568
+ await refreshSpend();
569
+ out.write('\n');
570
+
571
+ const rl = options.readline || readline.createInterface({ input: process.stdin, output: out, terminal: true });
572
+ const promptStr = themeOf(ctx()).gold + GLYPH.prompt + ' ' + RESET;
573
+ rl.setPrompt(promptStr);
574
+ rl.prompt();
575
+
576
+ return new Promise((resolve) => {
577
+ rl.on('line', async (line) => {
578
+ rl.pause();
579
+ let keep = true;
580
+ try {
581
+ keep = await handleLine(line);
582
+ } catch (e) {
583
+ emit(render.renderNotice(ctx(), 'error', e.message));
584
+ }
585
+ if (!keep) {
586
+ closed = true;
587
+ rl.close();
588
+ return;
589
+ }
590
+ rl.resume();
591
+ rl.prompt();
592
+ });
593
+ rl.on('close', () => {
594
+ if (!closed) out.write('\n');
595
+ resolve(0);
596
+ });
597
+ });
598
+ }
599
+
600
+ return {
601
+ opts,
602
+ session,
603
+ client,
604
+ TOOLS,
605
+ toolList,
606
+ ask,
607
+ runPrompt,
608
+ handleLine,
609
+ runOnce,
610
+ runInteractive,
611
+ refreshSpend,
612
+ bannerLines,
613
+ get aborted() {
614
+ return abortController != null;
615
+ },
616
+ };
617
+ }
618
+
619
+ module.exports = { createApp, VERSION };