aegiscode 6.5.4 → 6.5.5

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/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "aegiscode",
3
3
  "productName": "AEGIS Code",
4
- "version": "6.5.4",
5
- "description": "aegiscode \u2014 the command-line version of AEGIS Desktop. The shared tool surface in your shell, over the same thin transport and tool registry as the MCP plugin and the desktop app. Ships transport + UI only; no brain.",
4
+ "version": "6.5.5",
5
+ "description": "aegiscode the command-line version of AEGIS Desktop. The shared tool surface in your shell, over the same thin transport and tool registry as the MCP plugin and the desktop app. Ships transport + UI only; no brain.",
6
6
  "author": {
7
7
  "name": "AEGIS Code",
8
8
  "email": "nborneklint@gmail.com"
@@ -37,6 +37,7 @@ const VENDOR = path.join(CLI_DIR, 'vendor');
37
37
  /** repo-relative -> staged location (same relative shape, under vendor/) */
38
38
  const FILES = [
39
39
  'client/aegis.js',
40
+ 'client/update.js',
40
41
  'client/foreign-memory.js',
41
42
  // The account credential store and the unified session store. Shared with the
42
43
  // desktop app and the MCP plugin so all three see one key and one session
package/src/app.js CHANGED
@@ -125,6 +125,15 @@ function createApp(options = {}) {
125
125
  // pinning one would make every turn cost what that rung grants.
126
126
  effort: null,
127
127
  thinking: false,
128
+ // Deep recall (`aegis_recall_deep`) — the metered tier of the read: brain
129
+ // corrections plus the semantic answer cache, and the answer cache costs
130
+ // one provider embedding per turn (aegis1 app.py:8367 →
131
+ // services/brain_memory.py find_cached_answer). This host recalls on every
132
+ // turn already and pays per turn, so the deep tier is NEVER inferred: it is
133
+ // false here, `/memory-deep on` is the only thing that flips it, and that
134
+ // choice is deliberately NOT persisted — a flag that meters every turn must
135
+ // not survive into a session where nobody asked for it.
136
+ recallDeep: false,
128
137
  themeIndex: opts.light ? 2 : 1,
129
138
  vim: false,
130
139
  stream: opts.stream,
@@ -497,6 +506,11 @@ function createApp(options = {}) {
497
506
  // about what the turn cost. `null` (auto) is omitted so the server
498
507
  // infers the rung from the ask.
499
508
  effort: commandCtx.effort || undefined,
509
+ // The deep-recall opt-in travels only when this session turned it on
510
+ // (`/memory-deep on`). Sent as a literal `true` or not at all: the
511
+ // engine treats anything but `true` as off, and an omitted field is
512
+ // what keeps a stray value from ever buying a per-turn embedding.
513
+ recallDeep: commandCtx.recallDeep === true ? true : undefined,
500
514
  // false asks the engine for the buffered (non-stream) wire form, so
501
515
  // `--no-stream` and piped runs get a single body rather than SSE.
502
516
  stream: commandCtx.stream !== false,
package/src/art.js CHANGED
@@ -89,6 +89,15 @@ const TAGLINE = 'Cloud brain in your shell.';
89
89
  // mangle █▓▒░ entirely; on macOS ✦ renders as a double-width emoji. Each pass
90
90
  // swaps offending code points 1:1 (same character count per row) so the width
91
91
  // maths stays exact and the mark keeps its shape.
92
+ //
93
+ // This table is the ONE stencil source of truth for the product. `theme.js`
94
+ // resolves `GLYPH.star` through `stencilGlyph(STAR, platform)` rather than
95
+ // carrying its own `'✦' → '*'` rule, and `render.js` builds the mark's tint map
96
+ // by stencilling its runes — both had a private copy of the same decision, which
97
+ // is how a stencil edit here could leave the star wide on macOS or the whale
98
+ // untinted on Windows. `test/cli-art-platform.test.mjs` pins all of it.
99
+ const STAR = '✦';
100
+
92
101
  const ASCII_STENCIL = new Map([
93
102
  ['█', '#'],
94
103
  ['▓', '@'],
@@ -100,7 +109,7 @@ const ASCII_STENCIL = new Map([
100
109
  ['▌', '#'],
101
110
  ['▝', '#'],
102
111
  ['▘', '#'],
103
- ['✦', '*'],
112
+ [STAR, '*'],
104
113
  ['·', '.'],
105
114
  ]);
106
115
 
@@ -111,18 +120,45 @@ const DARWIN_STENCIL = new Map([
111
120
  ['▌', '#'],
112
121
  ['▝', '#'],
113
122
  ['▘', '#'],
114
- ['✦', '*'],
123
+ [STAR, '*'],
115
124
  ]);
116
125
 
117
- /** One platform pass over an art block. */
126
+ /** The platforms this mark has a pass for. Linux needs none (see `stencilFor`). */
127
+ const PLATFORMS = ['linux', 'darwin', 'win32'];
128
+
129
+ /** Every pass, keyed by `process.platform`; a platform with no entry is native. */
130
+ const STENCILS = { darwin: DARWIN_STENCIL, win32: ASCII_STENCIL };
131
+
132
+ /**
133
+ * The stencil for a platform, or `null` when its runes need no pass.
134
+ * @param {string} platform a `process.platform` string
135
+ */
136
+ function stencilFor(platform) {
137
+ return STENCILS[platform] || null;
138
+ }
139
+
140
+ /**
141
+ * One rune as `platform` renders it — the identity when that platform is native.
142
+ * Exported so `theme.js`'s glyph table and `render.js`'s tint map are derived
143
+ * from the stencil instead of restating it.
144
+ * @param {string} ch
145
+ * @param {string} [platform]
146
+ */
147
+ function stencilGlyph(ch, platform = process.platform) {
148
+ const s = stencilFor(platform);
149
+ return (s && s.get(ch)) || ch;
150
+ }
151
+
152
+ /** One platform pass over an art block, for an explicit platform. */
153
+ function portabilityFor(rows, platform) {
154
+ const s = stencilFor(platform);
155
+ if (!s) return rows;
156
+ return rows.map((r) => [...r].map((ch) => s.get(ch) ?? ch).join(''));
157
+ }
158
+
159
+ /** The mark as the terminal running this process will render it. */
118
160
  function portability(rows) {
119
- if (process.platform === 'win32') {
120
- return rows.map((r) => [...r].map((ch) => ASCII_STENCIL.get(ch) ?? ch).join(''));
121
- }
122
- if (process.platform === 'darwin') {
123
- return rows.map((r) => [...r].map((ch) => DARWIN_STENCIL.get(ch) ?? ch).join(''));
124
- }
125
- return rows;
161
+ return portabilityFor(rows, process.platform);
126
162
  }
127
163
 
128
164
  /** The mark that fits the current terminal: side-by-side when there's room. */
@@ -151,8 +187,9 @@ function welcomeArtParts(cols) {
151
187
  const n = Math.max(mascot.length, moonWhale.length);
152
188
  const rows = [];
153
189
  for (let i = 0; i < n; i++) {
154
- const li = i + (n - mascot.length);
155
- const ri = i + (n - moonWhale.length);
190
+ // Same bottom-align rule as joinSideBySide subtract the offset.
191
+ const li = i - (n - mascot.length);
192
+ const ri = i - (n - moonWhale.length);
156
193
  rows.push([(mascot[li] ?? '').padEnd(L), (moonWhale[ri] ?? '').padEnd(R)]);
157
194
  }
158
195
  return { rows, width: L + gutter + R, gutter };
@@ -188,8 +225,14 @@ function joinSideBySide(left, right, gutter = 2, align = 'bottom') {
188
225
  const R = Math.max(...right.map((r) => [...r].length));
189
226
  const n = Math.max(left.length, right.length);
190
227
  return Array.from({ length: n }, (_, i) => {
191
- const li = align === 'bottom' ? i + (n - left.length) : i;
192
- const ri = align === 'bottom' ? i + (n - right.length) : i;
228
+ // Bottom-align puts each block's LAST row on the output's last row, so a
229
+ // shorter block starts lower and its index runs *behind* the output index.
230
+ // Adding the offset instead of subtracting it pushed the shorter block off
231
+ // the bottom edge and silently dropped its top rows: the moon+whale half,
232
+ // two rows shorter than the mascot, lost its spout and its top star and
233
+ // hung two rows below the baseline.
234
+ const li = align === 'bottom' ? i - (n - left.length) : i;
235
+ const ri = align === 'bottom' ? i - (n - right.length) : i;
193
236
  const l = (left[li] ?? '').padEnd(L);
194
237
  const r = (right[ri] ?? '').padEnd(R);
195
238
  return l + ' '.repeat(gutter) + r;
@@ -225,7 +268,13 @@ module.exports = {
225
268
  WELCOME_TITLE,
226
269
  WELCOME_BACK,
227
270
  TAGLINE,
271
+ STAR,
272
+ PLATFORMS,
273
+ STENCILS,
274
+ stencilFor,
275
+ stencilGlyph,
228
276
  portability,
277
+ portabilityFor,
229
278
  welcomeArtFor,
230
279
  welcomeArtParts,
231
280
  stackVertical,
package/src/chatflow.js CHANGED
@@ -131,6 +131,50 @@ function resolveToolDone(transcript, t) {
131
131
  return m;
132
132
  }
133
133
 
134
+ /**
135
+ * Fold one turn's tool events into the counts the end-of-turn summary prints.
136
+ *
137
+ * Deterministic and purely local, on purpose. The tail of every turn is the
138
+ * wrong place to spend a model call: it would bill the reader for a restatement
139
+ * of work they have already paid to have done, and it would arrive after a
140
+ * network round-trip instead of at the instant the turn ends. The data needed
141
+ * is already in the tool events — the engine hands us `ok` on every call and
142
+ * the args name every file and command — so the summary is arithmetic, not
143
+ * inference.
144
+ *
145
+ * @param {Array<{ok?:boolean,args?:object}>} tools This turn's tool events, in order.
146
+ * @returns {{tools:number,failed:number,files:string[],names:string[],more:number,commands:string[]}}
147
+ */
148
+ function summarizeTurnTools(tools, { maxNames = 3 } = {}) {
149
+ const files = [];
150
+ const commands = [];
151
+ let failed = 0;
152
+ for (const tool of tools || []) {
153
+ if (!tool) continue;
154
+ if (tool.ok === false) failed += 1;
155
+ const args = tool.args;
156
+ if (!args || typeof args !== 'object') continue;
157
+ const file = args.file_path || args.path || args.notebook_path;
158
+ if (typeof file === 'string' && file && !files.includes(file)) files.push(file);
159
+ const cmd = args.command;
160
+ if (typeof cmd === 'string' && cmd.trim()) {
161
+ // First word only — the binary or builtin that ran, not its whole argv.
162
+ // "node /a/b/c.mjs --flag" reports as "node", which is what a one-line
163
+ // summary has room for.
164
+ const head = cmd.trim().split(/\s+/)[0].split('/').pop();
165
+ if (head && !commands.includes(head)) commands.push(head);
166
+ }
167
+ }
168
+ return {
169
+ tools: (tools || []).length,
170
+ failed,
171
+ files,
172
+ names: files.slice(0, maxNames).map((f) => f.split('/').pop()),
173
+ more: Math.max(0, files.length - maxNames),
174
+ commands,
175
+ };
176
+ }
177
+
134
178
  /**
135
179
  * Finalize an assistant message's text: append a "(backend error: …)" marker
136
180
  * when the turn failed mid-stream, a single "(stopped)" marker on abort, and
@@ -358,6 +402,36 @@ function rowLines(msg, cols, ctx, now = Date.now()) {
358
402
  out.push(line);
359
403
  return out;
360
404
  }
405
+ if (msg.role === 'summary') {
406
+ // The end-of-turn delta line. Rendered as the LAST row of the turn so the
407
+ // bottom of the screen answers "what did that actually do?" without the
408
+ // reader scrolling back up through the tool rows that produced it.
409
+ const s = msg.counts || {};
410
+ if (!s.tools) return out;
411
+ const line = [span(t.dim, GLYPH.hook), span('', ' ')];
412
+ const bits = [
413
+ span(t.white, String(s.tools)),
414
+ span(t.gray, ` ${s.tools === 1 ? 'command' : 'commands'}`),
415
+ ];
416
+ if (s.files && s.files.length) {
417
+ bits.push(span(t.gray, ` ${GLYPH.bullet} `));
418
+ bits.push(
419
+ span(t.white, String(s.files.length)),
420
+ span(t.gray, ` ${s.files.length === 1 ? 'file changed' : 'files changed'}`)
421
+ );
422
+ }
423
+ if (s.failed) {
424
+ bits.push(span(t.gray, ` ${GLYPH.bullet} `));
425
+ bits.push(span(t.coral, `${s.failed} failed`));
426
+ }
427
+ if (s.names && s.names.length) {
428
+ const more = s.more ? ` +${s.more}` : '';
429
+ bits.push(span(t.dim, ` ${s.names.join(', ')}${more}`));
430
+ }
431
+ line.push(...bits);
432
+ out.push(line);
433
+ return out;
434
+ }
361
435
  if (msg.role === 'meta') {
362
436
  // Deliberate divergence from the reference: this client's reason to exist
363
437
  // is showing what a turn consumed, so the accounting line is a transcript
@@ -591,6 +665,10 @@ async function runSession(host) {
591
665
  let verb = VERBS[0];
592
666
  let turnCount = 0;
593
667
  let toolSeq = 0;
668
+ // This turn's raw tool events, kept so the end-of-turn summary can report the
669
+ // deltas (commands run, files touched, failures) without re-reading the
670
+ // transcript — the rows are display state, the events are the record.
671
+ let turnTools = [];
594
672
  let suggestionIdx = 0;
595
673
  let scroll = 0;
596
674
  let anchorEnd = null;
@@ -730,7 +808,6 @@ async function runSession(host) {
730
808
 
731
809
  let streamedChars = 0;
732
810
  const streamedCells = () => streamedChars;
733
-
734
811
  // ── the turn ──
735
812
 
736
813
  const confirmTool = (info) =>
@@ -779,6 +856,7 @@ async function runSession(host) {
779
856
  startedAt = Date.now();
780
857
  streamedChars = 0;
781
858
  toolSeq = 0;
859
+ turnTools = [];
782
860
  verb = VERBS[turnCount % VERBS.length];
783
861
  abort = new AbortController();
784
862
  spinnerTimer = setInterval(() => {
@@ -803,6 +881,7 @@ async function runSession(host) {
803
881
  // from one that produced nothing at all.
804
882
  reasoning: () => { sawReasoning = true; },
805
883
  tool: (tool) => {
884
+ turnTools.push(tool);
806
885
  if (tool.phase === 'run') {
807
886
  const n = ++toolSeq;
808
887
  const prefix = tool.agent ? `${tool.agent} ▸ ` : '';
@@ -819,8 +898,27 @@ async function runSession(host) {
819
898
  },
820
899
  { follow: false }
821
900
  );
822
- } else {
823
- resolveToolDone(transcript, tool);
901
+ } else if (!resolveToolDone(transcript, tool)) {
902
+ // A host that reports a tool once, after it ran, never opens a
903
+ // `phase: 'run'` row for resolveToolDone to close — the vendored
904
+ // local engine does exactly this (one `tool: {name,args,ok}` frame
905
+ // post-execution). Falling through left the row unwritten, so the
906
+ // turn silently showed no tool activity at all. Record the finished
907
+ // tool directly instead.
908
+ const n = ++toolSeq;
909
+ push(
910
+ {
911
+ role: 'tool',
912
+ phase: 'done',
913
+ name: tool.name,
914
+ args: tool.args,
915
+ agent: tool.agent,
916
+ id: tool.id,
917
+ ok: tool.ok,
918
+ label: `Ran ${n} ${tool.agent ? `${tool.agent} ▸ ` : ''}${toolLabel(tool.name, n)}`,
919
+ },
920
+ { follow: false }
921
+ );
824
922
  }
825
923
  scheduleRender();
826
924
  },
@@ -909,6 +1007,17 @@ async function runSession(host) {
909
1007
  },
910
1008
  { follow: false }
911
1009
  );
1010
+ // The turn's last row: a one-line delta of what the tools actually did.
1011
+ // Without it the tail of a tool-heavy turn was the accounting row, and
1012
+ // the answer to "what did that change?" was only reachable by scrolling
1013
+ // back up through the tool rows. Follows the viewport EXCEPT when the
1014
+ // reader has scrolled up themselves — scroll is non-zero exactly when
1015
+ // they are mid-read, and yanking them to the bottom then would undo the
1016
+ // scroll they asked for.
1017
+ const counts = summarizeTurnTools(turnTools);
1018
+ if (counts.tools > 0) {
1019
+ push({ role: 'summary', counts }, { follow: scroll === 0 });
1020
+ }
912
1021
  render();
913
1022
  }
914
1023
  };
@@ -1645,6 +1754,7 @@ module.exports = {
1645
1754
  FRAME_MS,
1646
1755
  toolLabel,
1647
1756
  resolveToolDone,
1757
+ summarizeTurnTools,
1648
1758
  finalizeTurnText,
1649
1759
  historyPairs,
1650
1760
  paletteQuery,
package/src/commands.js CHANGED
@@ -1211,6 +1211,25 @@ const COMMANDS = [
1211
1211
  tool: 'aegis_memory_list',
1212
1212
  build: () => ({}),
1213
1213
  },
1214
+ {
1215
+ name: 'memory-deep', aliases: ['recall-deep'], args: ['mode'], hint: '[on|off]', category: 'model',
1216
+ desc: 'Toggle DEEP recall for this session — brain corrections + cached answers (metered: one embedding per turn)',
1217
+ handler: async (c, args) => {
1218
+ const mode = (args.mode || '').toLowerCase();
1219
+ if (mode && !['on', 'off'].includes(mode)) { note(c, 'Usage: /memory-deep [on|off]'); c.render(); return true; }
1220
+ const want = mode === 'on' ? true : mode === 'off' ? false : !(c.ctx.recallDeep === true);
1221
+ c.ctx.recallDeep = want;
1222
+ // Deliberately session-scoped: nothing is written to config.json, because
1223
+ // this flag meters every turn (aegis1 services/brain_memory.py embeds the
1224
+ // query once per turn) and a silent restore on a later launch would bill
1225
+ // for a decision made in a session that has ended. Contrast /thinking,
1226
+ // which persists — it costs nothing. The cheap read half (`aegis_recall`)
1227
+ // is always on and has no toggle.
1228
+ note(c, `Deep recall: ${want ? 'on — brain corrections + cached answers, one embedding per turn' : 'off (only the cheap recall read)'}${want ? ' · this session only' : ''}`);
1229
+ c.render();
1230
+ return true;
1231
+ },
1232
+ },
1214
1233
  {
1215
1234
  name: 'confirm', aliases: ['confirmations'], args: ['mode'], hint: '[on|off]', category: 'model',
1216
1235
  desc: 'Toggle tool-call confirmation prompts',
package/src/deps.js CHANGED
@@ -65,6 +65,9 @@ const agentsPath = resolveShared(path.join('desktop', 'lib', 'local', 'agents.js
65
65
  // running shell commands. Same file as the GUI, for the same reason engine.js
66
66
  // is: two personas would drift, and only one of them would get fixed.
67
67
  const promptPath = resolveShared(path.join('desktop', 'lib', 'local', 'prompt.js'));
68
+ // The npm update checker, shared with the desktop host: two package names, one
69
+ // implementation, so a fix to the check reaches both.
70
+ const updatePath = resolveShared(path.join('client', 'update.js'));
68
71
 
69
72
  const { createClient } = require(clientPath);
70
73
  const { createTools } = require(toolsPath);
@@ -72,8 +75,10 @@ const { usageTokens } = require(usagePath);
72
75
  const { createLocalEngine } = require(enginePath);
73
76
  const { agentRoles, agentRoleLabel } = require(agentsPath);
74
77
  const { buildSystemPrompt } = require(promptPath);
78
+ const updater = require(updatePath);
75
79
 
76
80
  module.exports = {
81
+ resolveShared,
77
82
  createClient,
78
83
  createTools,
79
84
  usageTokens,
@@ -81,6 +86,7 @@ module.exports = {
81
86
  agentRoles,
82
87
  agentRoleLabel,
83
88
  buildSystemPrompt,
84
- paths: { client: clientPath, tools: toolsPath, usage: usagePath, engine: enginePath, agents: agentsPath, prompt: promptPath },
89
+ updater,
90
+ paths: { client: clientPath, tools: toolsPath, usage: usagePath, engine: enginePath, agents: agentsPath, prompt: promptPath, update: updatePath },
85
91
  roots,
86
92
  };
package/src/render.js CHANGED
@@ -31,7 +31,7 @@ const {
31
31
  DONE_VERBS,
32
32
  themeOf,
33
33
  } = require('./theme.js');
34
- const { WELCOME_TITLE, WELCOME_BACK, TAGLINE, welcomeArtParts } = require('./art.js');
34
+ const { WELCOME_TITLE, WELCOME_BACK, TAGLINE, STAR, stencilGlyph, welcomeArtParts } = require('./art.js');
35
35
  const { w, pad, padStart, wrapBlock, clip, span } = require('./screen.js');
36
36
  const { fmtTokens, fmtEur, fmtElapsed } = require('./format.js');
37
37
 
@@ -63,9 +63,17 @@ function centerStyled(text, width, style) {
63
63
  }
64
64
 
65
65
  /** The right (moon + whale) half of the mark: ▓ blue, ▒ lavender, ░ dim,
66
- * eye white, stars gold. */
66
+ * eye white, stars gold.
67
+ *
68
+ * The keys are the runes as `art.js` drew them, then run through the stencil:
69
+ * `artRow` is handed rows that have *already* been stencilled for this
70
+ * platform, so a raw-rune table silently matched nothing on win32/darwin and
71
+ * the whole moon/whale half rendered uncoloured there — the mark looked
72
+ * different on two of the three platforms it ships to. */
67
73
  function tintWhale(str, t) {
68
- const map = { '▓': t.blue, '▒': t.lavender, '░': t.dim, '█': t.white, '✦': t.gold, '·': t.dim };
74
+ const byRune = { '▓': t.blue, '▒': t.lavender, '░': t.dim, '█': t.white, [STAR]: t.gold, '·': t.dim };
75
+ const map = {};
76
+ for (const rune of Object.keys(byRune)) map[stencilGlyph(rune)] = byRune[rune];
69
77
  const spans = [];
70
78
  let cur = null;
71
79
  let style = '';
package/src/theme.js CHANGED
@@ -16,6 +16,11 @@
16
16
  * Zero dependencies: SGR escapes are hand-built, like the rest of this repo.
17
17
  */
18
18
 
19
+ // The stencil is the mark's single source of truth for its own runes — this
20
+ // file resolves `star` through it rather than restating the platform rule.
21
+ // `art.js` requires nothing, so this import cannot cycle.
22
+ const { STAR, stencilGlyph } = require('./art.js');
23
+
19
24
  const RESET = '\x1b[0m';
20
25
  const BOLD = '\x1b[1m';
21
26
  const DIM = '\x1b[2m';
@@ -66,8 +71,16 @@ const LIGHT = {
66
71
  * or render as double-width emoji (✦ U+2726, ✻/✢/✽/✶), which breaks row
67
72
  * alignment exactly like the welcome art — those platforms get single-width
68
73
  * stand-ins instead.
74
+ *
75
+ * `star` is NOT restated here: it is resolved through `art.js`'s stencil, the
76
+ * single source of truth for the mark's runes. This file used to carry its own
77
+ * `'✦' → '*'` rule on win32/darwin, which is precisely how the star could stay
78
+ * wide on one platform while the art got stencilled on another — the mark and
79
+ * the glyph table would then disagree about the same character. Every other
80
+ * entry below is a *text* glyph (cursor, hook, spinner frames) that no art row
81
+ * contains, so it keeps its own definition.
69
82
  */
70
- const GLYPH = (() => {
83
+ function glyphsFor(platform = process.platform) {
71
84
  const base = {
72
85
  cursor: '❯', // menu selection marker, prompt prefix
73
86
  check: '✔', // selected / completed
@@ -75,7 +88,7 @@ const GLYPH = (() => {
75
88
  bullet: '·', // inline separators
76
89
  hint: '❯', // "Try ..." suggestion marker
77
90
  pause: '⏸', // bottom status line (manual mode)
78
- star: '✦', // own-session marker, decorations
91
+ star: stencilGlyph(STAR, platform), // own-session marker, decorations
79
92
  leftarrow: '←', // hints on the status line
80
93
  pointer: '▸', // tips list bullets
81
94
  hook: '⎿', // inline command / tip rows (tool commands, usage hints)
@@ -84,26 +97,26 @@ const GLYPH = (() => {
84
97
  spin: ['✢', '·', '✻', '*', '✽', '✶'], // working spinner
85
98
  ellipse: '…',
86
99
  };
87
- if (process.platform === 'win32') {
100
+ if (platform === 'win32') {
88
101
  return {
89
102
  ...base,
90
103
  pause: '❚❚', // ⏸ U+23F8 missing from many Windows fonts
91
104
  hook: '_|', // ⎿ U+23BF almost never present in Windows fonts
92
- star: '*', // ✦ U+2726 renders wide/emoji
93
105
  bloom: '*', // ✻ U+273B
94
106
  spin: ['|', '/', '-', '\\', '*', '-'],
95
107
  };
96
108
  }
97
- if (process.platform === 'darwin') {
109
+ if (platform === 'darwin') {
98
110
  return {
99
111
  ...base,
100
112
  pause: '❚❚', // ⏸ has an emoji presentation on Apple fonts
101
- star: '*', // ✦ renders as a double-width emoji in Terminal.app
102
113
  hook: '_|', // ⎿ U+23BF not in Apple monospace fonts
103
114
  };
104
115
  }
105
116
  return base;
106
- })();
117
+ }
118
+
119
+ const GLYPH = glyphsFor(process.platform);
107
120
 
108
121
  /** Working-line verbs, verbatim from the capture-backed table. */
109
122
  const VERBS = [
@@ -216,6 +229,7 @@ module.exports = {
216
229
  THEME_TABLE,
217
230
  themeForIndex,
218
231
  GLYPH,
232
+ glyphsFor,
219
233
  VERBS,
220
234
  DONE_VERBS,
221
235
  themeOf,
package/src/update.js CHANGED
@@ -1,137 +1,13 @@
1
- /**
2
- * "There is a newer version" — the notice this CLI never had.
3
- *
4
- * Checked on 2026-09-15: there was no update check anywhere in the client, so
5
- * a user learned about a release only by guessing to run
6
- * `npm i -g aegiscode@latest`. Publishing therefore reached almost nobody, and
7
- * the installed base drifted months behind the registry.
8
- *
9
- * Three rules, because an update check is a background nicety that must never
10
- * become a liability:
11
- * · it never blocks — the caller gets a cached answer immediately and the
12
- * network call settles whenever it settles;
13
- * · it never throws — offline, proxied, rate-limited or garbage JSON all
14
- * mean "no notice", not a broken CLI;
15
- * · it never nags — one check a day, cached, and nothing printed when the
16
- * user is already current.
17
- */
18
-
19
- const https = require('node:https');
20
-
21
- const REGISTRY = 'https://registry.npmjs.org';
22
- const PKG = 'aegiscode';
23
- /** One check a day. A CLI that pings the registry every launch is spyware. */
24
- const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
25
- /** The registry is not on the critical path; give up quickly and silently. */
26
- const TIMEOUT_MS = 2500;
27
-
28
- /** Compare two semver-ish strings. Returns true when `latest` is newer. */
29
- function isNewer(latest, current) {
30
- const parse = (v) =>
31
- String(v || '')
32
- .trim()
33
- .replace(/^v/, '')
34
- .split('-')[0] // a prerelease never counts as newer than its release
35
- .split('.')
36
- .map((n) => Number.parseInt(n, 10) || 0);
37
- const a = parse(latest);
38
- const b = parse(current);
39
- for (let i = 0; i < Math.max(a.length, b.length); i++) {
40
- const x = a[i] || 0;
41
- const y = b[i] || 0;
42
- if (x !== y) return x > y;
43
- }
44
- return false;
45
- }
46
-
47
- /** Fetch the registry's `latest` dist-tag. Resolves null on any failure. */
48
- function fetchLatest({ pkg = PKG, timeoutMs = TIMEOUT_MS } = {}) {
49
- return new Promise((resolve) => {
50
- let settled = false;
51
- const done = (v) => {
52
- if (!settled) {
53
- settled = true;
54
- resolve(v);
55
- }
56
- };
57
- try {
58
- // The abbreviated metadata document: a few KB instead of the full
59
- // packument, which for a package with this many releases is megabytes.
60
- const req = https.get(
61
- `${REGISTRY}/${pkg}`,
62
- { headers: { accept: 'application/vnd.npm.install-v1+json' }, timeout: timeoutMs },
63
- (res) => {
64
- if (res.statusCode !== 200) {
65
- res.resume();
66
- return done(null);
67
- }
68
- let body = '';
69
- res.setEncoding('utf8');
70
- res.on('data', (c) => {
71
- body += c;
72
- if (body.length > 2_000_000) {
73
- req.destroy();
74
- done(null);
75
- }
76
- });
77
- res.on('end', () => {
78
- try {
79
- const tags = JSON.parse(body)['dist-tags'];
80
- done((tags && tags.latest) || null);
81
- } catch {
82
- done(null);
83
- }
84
- });
85
- }
86
- );
87
- req.on('timeout', () => {
88
- req.destroy();
89
- done(null);
90
- });
91
- req.on('error', () => done(null));
92
- } catch {
93
- done(null);
94
- }
95
- });
96
- }
1
+ 'use strict';
97
2
 
98
3
  /**
99
- * The notice to show, from cache. Never waits on the network.
4
+ * The CLI's view of the shared npm update checker (client/update.js).
100
5
  *
101
- * `cache` is the persisted `{ checkedAt, latest }` blob; `save` persists a new
102
- * one. Returns `{ latest, behind }`, and kicks off a refresh in the background
103
- * when the cache is stale.
6
+ * It lives at the repo root rather than here because the desktop host needs
7
+ * the same thing for `aegis-desktop`: two hosts, two package names, one
8
+ * checker. Resolved through deps.js so the in-repo and published-vendor
9
+ * layouts both work.
104
10
  */
105
- function updateNotice({
106
- current,
107
- cache,
108
- save,
109
- now = Date.now(),
110
- fetchImpl = fetchLatest,
111
- intervalMs = CHECK_INTERVAL_MS,
112
- } = {}) {
113
- const c = cache || {};
114
- const fresh = typeof c.checkedAt === 'number' && now - c.checkedAt < intervalMs;
115
-
116
- if (!fresh) {
117
- // Fire and forget: this run reports on what was already known, and the
118
- // answer lands for the next one. A first run therefore never shows a
119
- // notice, which is correct — it has nothing to compare against yet.
120
- Promise.resolve(fetchImpl())
121
- .then((latest) => {
122
- if (latest && typeof save === 'function') save({ checkedAt: now, latest });
123
- })
124
- .catch(() => {});
125
- }
126
-
127
- const latest = c.latest || null;
128
- return { latest, behind: !!(latest && isNewer(latest, current)) };
129
- }
130
-
131
- /** One line for the welcome box, or null when there is nothing to say. */
132
- function updateLine({ current, latest, behind }) {
133
- if (!behind) return null;
134
- return `Update available: ${current} → ${latest} run: npm i -g ${PKG}@latest`;
135
- }
136
11
 
137
- module.exports = { isNewer, fetchLatest, updateNotice, updateLine, PKG, CHECK_INTERVAL_MS };
12
+ const { resolveShared } = require('./deps.js');
13
+ module.exports = require(resolveShared('client/update.js'));
@@ -664,11 +664,21 @@ function createClient(opts = {}) {
664
664
  return apiGet('/api/token-bank/balance');
665
665
  }
666
666
 
667
- /** Start a token-bank top-up; resolves to { url } for the payment page. */
667
+ /** Start a token-bank top-up; resolves to { url } for the payment page.
668
+ * aegis1 requires a signed-in account (403/401 without a key) and rejects
669
+ * an amount outside 2..1000 EUR with 400. */
668
670
  async function tokenBankTopup(amountEur) {
669
671
  return apiPost('/api/token-bank/topup', { amount_eur: amountEur });
670
672
  }
671
673
 
674
+ /** Start a plan checkout; resolves to { url } for Stripe's hosted page.
675
+ * aegis1 `/api/billing/checkout` is deliberately public (the Stripe page
676
+ * collects the email), so this works with no key configured — but answers
677
+ * 503 `setup_required` while the price id is unset on the server. */
678
+ async function billingCheckout() {
679
+ return apiPost('/api/billing/checkout', {});
680
+ }
681
+
672
682
  async function byokStatus() {
673
683
  return apiGet('/api/user/api-keys');
674
684
  }
@@ -797,6 +807,7 @@ function createClient(opts = {}) {
797
807
  listModels,
798
808
  tokenBankBalance,
799
809
  tokenBankTopup,
810
+ billingCheckout,
800
811
  byokStatus,
801
812
  byokSet,
802
813
  getMemoryToken,
@@ -0,0 +1,140 @@
1
+ /**
2
+ * "There is a newer version" — the notice this CLI never had.
3
+ *
4
+ * Checked on 2026-09-15: there was no update check anywhere in the client, so
5
+ * a user learned about a release only by guessing to run
6
+ * `npm i -g aegiscode@latest`. Publishing therefore reached almost nobody, and
7
+ * the installed base drifted months behind the registry.
8
+ *
9
+ * Three rules, because an update check is a background nicety that must never
10
+ * become a liability:
11
+ * · it never blocks — the caller gets a cached answer immediately and the
12
+ * network call settles whenever it settles;
13
+ * · it never throws — offline, proxied, rate-limited or garbage JSON all
14
+ * mean "no notice", not a broken CLI;
15
+ * · it never nags — one check a day, cached, and nothing printed when the
16
+ * user is already current.
17
+ */
18
+
19
+ const https = require('node:https');
20
+
21
+ const REGISTRY = 'https://registry.npmjs.org';
22
+ //: Default package. Both hosts ship from npm — the CLI as `aegiscode`, the
23
+ //: desktop as `aegis-desktop` — so one checker serves both; the caller names
24
+ //: which one it is.
25
+ const PKG = 'aegiscode';
26
+ /** One check a day. A CLI that pings the registry every launch is spyware. */
27
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
28
+ /** The registry is not on the critical path; give up quickly and silently. */
29
+ const TIMEOUT_MS = 2500;
30
+
31
+ /** Compare two semver-ish strings. Returns true when `latest` is newer. */
32
+ function isNewer(latest, current) {
33
+ const parse = (v) =>
34
+ String(v || '')
35
+ .trim()
36
+ .replace(/^v/, '')
37
+ .split('-')[0] // a prerelease never counts as newer than its release
38
+ .split('.')
39
+ .map((n) => Number.parseInt(n, 10) || 0);
40
+ const a = parse(latest);
41
+ const b = parse(current);
42
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
43
+ const x = a[i] || 0;
44
+ const y = b[i] || 0;
45
+ if (x !== y) return x > y;
46
+ }
47
+ return false;
48
+ }
49
+
50
+ /** Fetch the registry's `latest` dist-tag. Resolves null on any failure. */
51
+ function fetchLatest({ pkg = PKG, timeoutMs = TIMEOUT_MS } = {}) {
52
+ return new Promise((resolve) => {
53
+ let settled = false;
54
+ const done = (v) => {
55
+ if (!settled) {
56
+ settled = true;
57
+ resolve(v);
58
+ }
59
+ };
60
+ try {
61
+ // The abbreviated metadata document: a few KB instead of the full
62
+ // packument, which for a package with this many releases is megabytes.
63
+ const req = https.get(
64
+ `${REGISTRY}/${pkg}`,
65
+ { headers: { accept: 'application/vnd.npm.install-v1+json' }, timeout: timeoutMs },
66
+ (res) => {
67
+ if (res.statusCode !== 200) {
68
+ res.resume();
69
+ return done(null);
70
+ }
71
+ let body = '';
72
+ res.setEncoding('utf8');
73
+ res.on('data', (c) => {
74
+ body += c;
75
+ if (body.length > 2_000_000) {
76
+ req.destroy();
77
+ done(null);
78
+ }
79
+ });
80
+ res.on('end', () => {
81
+ try {
82
+ const tags = JSON.parse(body)['dist-tags'];
83
+ done((tags && tags.latest) || null);
84
+ } catch {
85
+ done(null);
86
+ }
87
+ });
88
+ }
89
+ );
90
+ req.on('timeout', () => {
91
+ req.destroy();
92
+ done(null);
93
+ });
94
+ req.on('error', () => done(null));
95
+ } catch {
96
+ done(null);
97
+ }
98
+ });
99
+ }
100
+
101
+ /**
102
+ * The notice to show, from cache. Never waits on the network.
103
+ *
104
+ * `cache` is the persisted `{ checkedAt, latest }` blob; `save` persists a new
105
+ * one. Returns `{ latest, behind }`, and kicks off a refresh in the background
106
+ * when the cache is stale.
107
+ */
108
+ function updateNotice({
109
+ current,
110
+ cache,
111
+ save,
112
+ now = Date.now(),
113
+ fetchImpl = fetchLatest,
114
+ intervalMs = CHECK_INTERVAL_MS,
115
+ } = {}) {
116
+ const c = cache || {};
117
+ const fresh = typeof c.checkedAt === 'number' && now - c.checkedAt < intervalMs;
118
+
119
+ if (!fresh) {
120
+ // Fire and forget: this run reports on what was already known, and the
121
+ // answer lands for the next one. A first run therefore never shows a
122
+ // notice, which is correct — it has nothing to compare against yet.
123
+ Promise.resolve(fetchImpl())
124
+ .then((latest) => {
125
+ if (latest && typeof save === 'function') save({ checkedAt: now, latest });
126
+ })
127
+ .catch(() => {});
128
+ }
129
+
130
+ const latest = c.latest || null;
131
+ return { latest, behind: !!(latest && isNewer(latest, current)) };
132
+ }
133
+
134
+ /** One line for the welcome box, or null when there is nothing to say. */
135
+ function updateLine({ current, latest, behind, pkg = PKG }) {
136
+ if (!behind) return null;
137
+ return `Update available: ${current} → ${latest} run: npm i -g ${pkg}@latest`;
138
+ }
139
+
140
+ module.exports = { isNewer, fetchLatest, updateNotice, updateLine, PKG, CHECK_INTERVAL_MS };
@@ -598,12 +598,53 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
598
598
  // AUTONOMOUS_IDLE_TIMEOUT_MS). Undefined elsewhere -> 60s default.
599
599
  idleTimeoutMs: opts.idleTimeoutMs,
600
600
  signal: opts.signal,
601
- // aegis_memory: automatic, no button the server both reads prior
602
- // synced memory into context AND writes this turn back to it, the
603
- // same flag aegis-online sets. Matches aegiscodex-dev's own
604
- // cross-session memory (auto-indexed, no manual tagging).
601
+ // aegis_recall the read half of aegis_memory, and the only half a
602
+ // client on this engine should send.
603
+ //
604
+ // aegis_memory is both halves: it injects the account's synced memory
605
+ // into context AND persists this turn back into it, charging sync
606
+ // quota for the write. That pairing is right for aegis-online, whose
607
+ // chat has nowhere else to live. It is wrong here, because this engine
608
+ // is shared by the GUI and the CLI (cli/src/deps.js loads this file):
609
+ // either would be billed for every "hey" and would fill the user's
610
+ // memory with greetings. Verified against aegis1 app.py: every
611
+ // write-back site (the note/upsert calls) is gated on aegis_memory,
612
+ // and aegis_memory implies aegis_recall there — so dropping the write
613
+ // half leaves /online unchanged and costs nothing on the read side.
614
+ //
615
+ // Writing is still available, explicitly: the aegis_memory_save tool
616
+ // (mcp/tools.js; the CLI exposes it as /memory). Recall on every turn,
617
+ // store only what the user asks for — which is what aegiscodex-dev's
618
+ // own cross-session memory does, and what this comment claimed to
619
+ // match while sending both halves.
620
+ //
621
+ // Recall was previously unreachable for a client that would not pay
622
+ // for it: services/tiered_recall.py only ran behind a flag that also
623
+ // bought a write, so the tiered path existed with no caller able to
624
+ // afford it. The split is what makes it reachable. The DEEP tier of the
625
+ // same read is a third flag with its own price — see `opts.recallDeep`
626
+ // below, which is off unless the session opted in.
605
627
  extra: {
606
- aegis_memory: true,
628
+ aegis_recall: true,
629
+ // The DEEP tier of that read — brain corrections plus the semantic
630
+ // answer cache — is not the same price, so it does not ride along.
631
+ // aegis1 app.py:8367 reads `aegis_recall_deep` (or the
632
+ // X-AEGIS-Recall-Deep header) and services/brain_memory.py
633
+ // find_cached_answer embeds the query: one provider embedding per
634
+ // turn, metered. The server deliberately implies it from
635
+ // `aegis_memory` and NOT from `aegis_recall`, so that a terminal
636
+ // client can buy the cheap read without the embedding.
637
+ //
638
+ // This client is that terminal client (the CLI loads this file via
639
+ // cli/src/deps.js), so it must not opt itself in: the flag is sent
640
+ // only when the SESSION asked for it — CLI `/memory-deep on`, a
641
+ // desktop payload with `recallDeep: true` — and it defaults false
642
+ // everywhere. It also travels only on the user's own turn
643
+ // (`opts.recallDeep` is cleared for every other dispatch below): a
644
+ // tool round, the doubled-budget retry and the write-up re-dispatch
645
+ // all re-send a context whose embedding the first round already
646
+ // bought, which would turn one embedding per turn into one per round.
647
+ ...(opts.recallDeep ? { aegis_recall_deep: true } : {}),
607
648
  session: opts.sessionId,
608
649
  // The fan-out is opt-in per dispatch. `brain` is sent EXPLICITLY
609
650
  // whenever this dispatch is not the autonomous one, because the
@@ -759,6 +800,14 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
759
800
  cls, model, mode: payload && payload.mode, maxTokens, statedMaxTokens, autonomous, sessionId, signal, onDelta, cfg, apiKey, toolChoice,
760
801
  effort: payload && payload.effort,
761
802
  workers: payload && payload.workers,
803
+ // Deep recall (`aegis_recall_deep`) is an explicit per-session opt-in
804
+ // and never a default: it costs one provider embedding per turn
805
+ // server-side (aegis1 services/brain_memory.py find_cached_answer), so
806
+ // a client that pays per turn must not turn it on for itself. Only a
807
+ // literal `true` from the caller counts — an absent or `undefined`
808
+ // field is off, which is what keeps every existing caller (the
809
+ // renderer's IPC payloads included) on the cheap read.
810
+ recallDeep: payload && payload.recallDeep === true,
762
811
  onReasoning,
763
812
  idleTimeoutMs: autonomous ? AUTONOMOUS_IDLE_TIMEOUT_MS : undefined,
764
813
  // A caller with no live streaming surface (a `--no-stream` CLI flag, a
@@ -873,7 +922,12 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
873
922
  });
874
923
  }
875
924
  round += 1;
876
- const opts = { ...base, system, messages: history, prompt, tools: toolSchemas };
925
+ // `round === 1` is the user's own ask, and the ONLY dispatch allowed to
926
+ // carry the deep-recall opt-in: the deep tier embeds the query once per
927
+ // dispatch, so leaving it on for an agentic turn would charge one
928
+ // embedding per tool round instead of one per turn (the retries below
929
+ // clear it explicitly, being re-dispatches inside round 1).
930
+ const opts = { ...base, system, messages: history, prompt, tools: toolSchemas, recallDeep: base.recallDeep && round === 1 };
877
931
  let res;
878
932
  try {
879
933
  res = await dispatch(cls, opts);
@@ -917,6 +971,10 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
917
971
  res = await dispatch(cls, {
918
972
  ...opts,
919
973
  singlePass: true,
974
+ // A re-dispatch, not a new ask: the deep tier's embedding was
975
+ // bought by round 1, and buying it again here would charge a
976
+ // second one for the same context.
977
+ recallDeep: false,
920
978
  maxTokens: doubledBudget(opts.maxTokens),
921
979
  // Doubling applies to the pooled path only when the caller stated a
922
980
  // number. With none stated, the server's effort ladder IS the
@@ -948,6 +1006,9 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
948
1006
  res = await dispatch(cls, {
949
1007
  ...opts,
950
1008
  singlePass: true,
1009
+ // Same as the truncation retry above: this pass writes up findings
1010
+ // already in `history`, and a fresh embedding buys it nothing.
1011
+ recallDeep: false,
951
1012
  messages: history,
952
1013
  prompt: '',
953
1014
  tools: [],