aegiscode 5.2.32 → 6.0.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,543 @@
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 } = 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
+ function printHelp() {
293
+ emit([render.renderHeading(ctx(), 'commands', width())]);
294
+ const rows = COMMANDS.map((c) => {
295
+ const usage = `/${c.name}${c.args ? ' ' + c.args : ''}`;
296
+ return { usage, help: c.help, aliases: (c.aliases || []).map((a) => `/${a}`).join(' ') };
297
+ });
298
+ const widest = rows.reduce((m, r) => Math.max(m, r.usage.length), 0);
299
+ const t = themeOf(ctx());
300
+ for (const r of rows) {
301
+ const alias = r.aliases ? render.fg(t, t.dim) + ` (${r.aliases})` + RESET : '';
302
+ emit([` ${render.fg(t, t.plasma)}${'/' + r.usage.slice(1)}` +
303
+ ' '.repeat(Math.max(1, widest - r.usage.length + 2)) +
304
+ render.fg(t, t.text) + r.help + RESET + alias]);
305
+ }
306
+ emit(['', render.renderNotice(ctx(), 'info', `plain text is a prompt ${GLYPH.bullet} /quit exits`)]);
307
+ }
308
+
309
+ /** Handle one line of input. Returns false when the session should end. */
310
+ async function handleLine(line) {
311
+ const parsed = parseLine(line);
312
+
313
+ if (parsed.kind === 'empty') return true;
314
+ if (parsed.kind === 'prompt') {
315
+ await runPrompt(parsed.text);
316
+ return true;
317
+ }
318
+ if (parsed.kind === 'unknown') {
319
+ emit(render.renderNotice(ctx(), 'error', `unknown command: /${parsed.name} — try /help`));
320
+ return true;
321
+ }
322
+
323
+ const cmd = parsed.command;
324
+ const arg = parsed.arg;
325
+
326
+ if (cmd.local) {
327
+ switch (cmd.local) {
328
+ case 'quit':
329
+ return false;
330
+ case 'clear':
331
+ out.write(EC.clearScreen);
332
+ emit(bannerLines());
333
+ return true;
334
+ case 'help':
335
+ printHelp();
336
+ return true;
337
+ case 'model':
338
+ if (!arg) {
339
+ emit(render.renderNotice(ctx(), 'info', `model: ${opts.model || 'server default'}`));
340
+ } else if (arg === '-') {
341
+ opts.model = null;
342
+ emit(render.renderNotice(ctx(), 'ok', 'model pin cleared — the server will choose'));
343
+ } else {
344
+ opts.model = arg;
345
+ emit(render.renderNotice(ctx(), 'ok', `pinned model: ${arg}`));
346
+ }
347
+ return true;
348
+ case 'stream':
349
+ opts.stream = arg ? !/^off|false|0$/i.test(arg) : !opts.stream;
350
+ emit(render.renderNotice(ctx(), 'ok', `streaming ${opts.stream ? 'on' : 'off'}`));
351
+ return true;
352
+ case 'theme':
353
+ opts.light = arg ? /^light/i.test(arg) : !opts.light;
354
+ emit(render.renderNotice(ctx(), 'ok', `theme: ${opts.light ? 'light' : 'dark'}`));
355
+ return true;
356
+ case 'cost': {
357
+ const t = themeOf(ctx());
358
+ emit([render.renderHeading(ctx(), 'session', width())]);
359
+ emit([
360
+ ` tokens ${render.fg(t, t.text)}${fmtTokens(session.tokens)}${RESET} ` +
361
+ render.fg(t, t.muted) + `(${fmtTokens(session.inputTokens)} in / ${fmtTokens(session.outputTokens)} out)` + RESET,
362
+ ` spend ${render.fg(t, t.pulse)}${fmtEur(session.cost)}${RESET}`,
363
+ ` calls ${session.calls}`,
364
+ ` balance ${session.balance == null ? 'unknown' : fmtEur(session.balance)}`,
365
+ ` elapsed ${fmtElapsed(Date.now() - session.startedAt)}`,
366
+ ]);
367
+ return true;
368
+ }
369
+ default:
370
+ emit(render.renderNotice(ctx(), 'error', `/${cmd.name} is not implemented`));
371
+ return true;
372
+ }
373
+ }
374
+
375
+ if (cmd.generic) {
376
+ const { tool, args } = cmd.build(arg);
377
+ if (!tool) {
378
+ emit(render.renderNotice(ctx(), 'error', '/tool needs a tool name — see /help'));
379
+ return true;
380
+ }
381
+ try {
382
+ await runTool(tool, args);
383
+ } catch (e) {
384
+ emit(render.renderNotice(ctx(), 'error', e.message));
385
+ }
386
+ return true;
387
+ }
388
+
389
+ // Tool-backed command.
390
+ try {
391
+ let args = cmd.build(arg);
392
+ if (cmd.secret) {
393
+ const key = await readSecret(`provider key for ${args.provider}: `);
394
+ if (!key) {
395
+ emit(render.renderNotice(ctx(), 'warn', 'empty key — nothing sent'));
396
+ return true;
397
+ }
398
+ args = { ...args, api_key: key };
399
+ }
400
+ if (cmd.name === 'ask') return await runPrompt(args.prompt);
401
+ await runTool(cmd.tool, args);
402
+ } catch (e) {
403
+ emit(render.renderNotice(ctx(), 'error', e.message));
404
+ }
405
+ return true;
406
+ }
407
+
408
+ /** Read a secret without echoing it (raw mode; falls back to plain input). */
409
+ function readSecret(promptText) {
410
+ if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
411
+ return Promise.resolve('');
412
+ }
413
+ return new Promise((resolve) => {
414
+ const t = themeOf(ctx());
415
+ out.write(render.fg(t, t.muted) + promptText + RESET);
416
+ const stdin = process.stdin;
417
+ let buf = '';
418
+ stdin.setRawMode(true);
419
+ stdin.resume();
420
+ const onData = (chunk) => {
421
+ for (const ch of chunk.toString('utf8')) {
422
+ if (ch === '\r' || ch === '\n') {
423
+ stdin.setRawMode(false);
424
+ stdin.removeListener('data', onData);
425
+ out.write('\n');
426
+ return resolve(buf);
427
+ }
428
+ if (ch === '\x03') {
429
+ // ctrl+c
430
+ stdin.setRawMode(false);
431
+ stdin.removeListener('data', onData);
432
+ out.write('\n');
433
+ return resolve('');
434
+ }
435
+ if (ch === '\u007f') {
436
+ buf = buf.slice(0, -1);
437
+ out.write('\b \b');
438
+ continue;
439
+ }
440
+ buf += ch;
441
+ out.write('•');
442
+ }
443
+ };
444
+ stdin.on('data', onData);
445
+ });
446
+ }
447
+
448
+ // --- entry points ---------------------------------------------------------
449
+
450
+ /** Non-interactive: one prompt, plain output, exit code. */
451
+ async function runOnce(prompt, { json = false } = {}) {
452
+ if (!client.apiKey) {
453
+ err.write('aegiscode: no AEGIS_API_KEY set. Export your key first (https://aegiscloud.org).\n');
454
+ return 2;
455
+ }
456
+ const res = await ask(prompt);
457
+ await refreshSpend();
458
+ const tokens = usageTokens(res.usage);
459
+ if (json) {
460
+ out.write(
461
+ JSON.stringify(
462
+ {
463
+ text: res.text,
464
+ model: res.model,
465
+ usage: res.usage,
466
+ tokens,
467
+ ms: res.ms,
468
+ interrupted: res.interrupted,
469
+ balance_eur: session.balance,
470
+ },
471
+ null,
472
+ 2
473
+ ) + '\n'
474
+ );
475
+ } else {
476
+ out.write(res.text + '\n');
477
+ if (tokens != null) {
478
+ const t = themeOf(ctx());
479
+ out.write(
480
+ render.fg(t, t.dim) + GLYPH.spend + ' ' + render.fg(t, t.beam) + (res.model || 'aegis') + RESET +
481
+ render.fg(t, t.dim) + ' ' + GLYPH.bullet + ' ' + RESET +
482
+ render.fg(t, t.text) + `${fmtTokens(tokens)} tok` + RESET + '\n'
483
+ );
484
+ }
485
+ }
486
+ return res.interrupted ? 130 : 0;
487
+ }
488
+
489
+ /** Interactive REPL. */
490
+ async function runInteractive() {
491
+ emit(bannerLines());
492
+ await refreshSpend();
493
+ out.write('\n');
494
+
495
+ const rl = options.readline || readline.createInterface({ input: process.stdin, output: out, terminal: true });
496
+ const promptStr = render.fg(themeOf(ctx()), themeOf(ctx()).plasma) + GLYPH.prompt + ' ' + RESET;
497
+ rl.setPrompt(promptStr);
498
+ rl.prompt();
499
+
500
+ return new Promise((resolve) => {
501
+ rl.on('line', async (line) => {
502
+ rl.pause();
503
+ let keep = true;
504
+ try {
505
+ keep = await handleLine(line);
506
+ } catch (e) {
507
+ emit(render.renderNotice(ctx(), 'error', e.message));
508
+ }
509
+ if (!keep) {
510
+ closed = true;
511
+ rl.close();
512
+ return;
513
+ }
514
+ rl.resume();
515
+ rl.prompt();
516
+ });
517
+ rl.on('close', () => {
518
+ if (!closed) out.write('\n');
519
+ resolve(0);
520
+ });
521
+ });
522
+ }
523
+
524
+ return {
525
+ opts,
526
+ session,
527
+ client,
528
+ TOOLS,
529
+ toolList,
530
+ ask,
531
+ runPrompt,
532
+ handleLine,
533
+ runOnce,
534
+ runInteractive,
535
+ refreshSpend,
536
+ bannerLines,
537
+ get aborted() {
538
+ return abortController != null;
539
+ },
540
+ };
541
+ }
542
+
543
+ module.exports = { createApp, VERSION };
package/src/art.js ADDED
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * AEGIS brand art.
5
+ *
6
+ * A hexagonal shield with a signal core — the mark that replaces the mascot
7
+ * art every Claude Code derivative (including this repo's sibling project
8
+ * aegiscodex-dev) ships. Same job, different iconography on purpose:
9
+ * `test/cli-identity.test.mjs` asserts Claude Code's own art never appears here.
10
+ *
11
+ * Every row is the same display width (9 cells) so it can be centred or stacked
12
+ * without drift; `tests` assert that rather than trusting it.
13
+ *
14
+ * The wordmark spells the whole product name (`aegiscode`) with the same letter
15
+ * spacing the shorter `AEGIS` mark used, now that the host and the CLI have been
16
+ * folded onto one name. `test/cli-identity.test.mjs` strips the spacing and
17
+ * asserts the mark still names the product.
18
+ */
19
+
20
+ const SIGIL = [
21
+ ' ▄▄▄▄▄▄▄ ',
22
+ '▟███████▙',
23
+ '▜███▀███▛',
24
+ ' ▜█████▛ ',
25
+ ' ▜███▛ ',
26
+ ' ▜▛ ',
27
+ ];
28
+
29
+ /** Cell offsets (row, col) of the core, painted in the secondary colour. */
30
+ const CORE = [{ row: 2, col: 4 }];
31
+
32
+ const WORDMARK = 'A E G I S C O D E';
33
+ const TAGLINE = 'Cloud brain in your shell.';
34
+
35
+ /**
36
+ * Left-pad every row of `art` so it sits centred inside `width`.
37
+ * @returns {string[]}
38
+ */
39
+ function center(art, width) {
40
+ const w = art.reduce((m, r) => Math.max(m, [...r].length), 0);
41
+ const pad = Math.max(0, Math.floor((width - w) / 2));
42
+ return art.map((r) => ' '.repeat(pad) + r);
43
+ }
44
+
45
+ module.exports = { SIGIL, CORE, WORDMARK, TAGLINE, center, SIGIL_WIDTH: 9 };