aegiscode 6.5.4 → 6.5.6

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,7 +1,7 @@
1
1
  {
2
2
  "name": "aegiscode",
3
3
  "productName": "AEGIS Code",
4
- "version": "6.5.4",
4
+ "version": "6.5.6",
5
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.",
6
6
  "author": {
7
7
  "name": "AEGIS Code",
@@ -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
@@ -65,6 +65,26 @@ function defaultSystemPrompt() {
65
65
  return _defaultSystem || undefined;
66
66
  }
67
67
 
68
+ // The engine accepts exactly 'once' | 'session' and coerces anything else to
69
+ // 'deny' (vendor/desktop/lib/local/engine.js → respondApproval). Bridging the
70
+ // vocabularies here, rather than trusting every prompter to speak the engine's
71
+ // dialect, is what stops an approval from silently becoming a denial. The
72
+ // failure mode is invisible from the outside: the tool is refused, the model
73
+ // is told the user declined, and it retries against a gate that can only ever
74
+ // say no — the turn burns its whole budget.
75
+ //
76
+ // Module scope on purpose: this must exist before createApp() runs, because it
77
+ // is re-exported for the conformance tests. A nested copy here previously left
78
+ // the export binding undefined and threw on require.
79
+ function normalizeDecision(decision) {
80
+ const v = typeof decision === 'string' ? decision.trim().toLowerCase() : '';
81
+ if (v === 'session') return 'session';
82
+ if (v === 'once' || v === 'allow' || v === 'yes' || v === 'approve' || v === 'approved') {
83
+ return 'once';
84
+ }
85
+ return 'deny';
86
+ }
87
+
68
88
  function createApp(options = {}) {
69
89
  const opts = {
70
90
  model: null,
@@ -125,6 +145,15 @@ function createApp(options = {}) {
125
145
  // pinning one would make every turn cost what that rung grants.
126
146
  effort: null,
127
147
  thinking: false,
148
+ // Deep recall (`aegis_recall_deep`) — the metered tier of the read: brain
149
+ // corrections plus the semantic answer cache, and the answer cache costs
150
+ // one provider embedding per turn (aegis1 app.py:8367 →
151
+ // services/brain_memory.py find_cached_answer). This host recalls on every
152
+ // turn already and pays per turn, so the deep tier is NEVER inferred: it is
153
+ // false here, `/memory-deep on` is the only thing that flips it, and that
154
+ // choice is deliberately NOT persisted — a flag that meters every turn must
155
+ // not survive into a session where nobody asked for it.
156
+ recallDeep: false,
128
157
  themeIndex: opts.light ? 2 : 1,
129
158
  vim: false,
130
159
  stream: opts.stream,
@@ -444,6 +473,8 @@ function createApp(options = {}) {
444
473
  return Promise.resolve('deny');
445
474
  };
446
475
 
476
+ // Vocabulary bridging lives at module scope (see normalizeDecision) so the
477
+ // same function the tests import is the one the live path calls.
447
478
  const onDelta = (chunk) => {
448
479
  if (!chunk) return;
449
480
  if (chunk.reasoning) {
@@ -472,7 +503,7 @@ function createApp(options = {}) {
472
503
  const decide = p.approval || approvalPrompter || denyNoPrompter;
473
504
  Promise.resolve(decide(info))
474
505
  .catch(() => 'deny')
475
- .then((decision) => engine.respondApproval(info.id, decision));
506
+ .then((decision) => engine.respondApproval(info.id, normalizeDecision(decision)));
476
507
  }
477
508
  };
478
509
 
@@ -497,6 +528,11 @@ function createApp(options = {}) {
497
528
  // about what the turn cost. `null` (auto) is omitted so the server
498
529
  // infers the rung from the ask.
499
530
  effort: commandCtx.effort || undefined,
531
+ // The deep-recall opt-in travels only when this session turned it on
532
+ // (`/memory-deep on`). Sent as a literal `true` or not at all: the
533
+ // engine treats anything but `true` as off, and an omitted field is
534
+ // what keeps a stray value from ever buying a per-turn embedding.
535
+ recallDeep: commandCtx.recallDeep === true ? true : undefined,
500
536
  // false asks the engine for the buffered (non-stream) wire form, so
501
537
  // `--no-stream` and piped runs get a single body rather than SSE.
502
538
  stream: commandCtx.stream !== false,
@@ -1352,4 +1388,4 @@ function createApp(options = {}) {
1352
1388
  };
1353
1389
  }
1354
1390
 
1355
- module.exports = { createApp, VERSION };
1391
+ module.exports = { createApp, VERSION, normalizeDecision };
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
@@ -254,6 +298,27 @@ function shortcutsGrid(cols, ctx) {
254
298
  ];
255
299
  }
256
300
 
301
+ /**
302
+ * The engine's approval vocabulary — and the ONLY tokens that survive
303
+ * `respondApproval` (see cli/vendor/desktop/lib/local/engine.js). It accepts
304
+ * 'once' and 'session' and coerces EVERYTHING ELSE to 'deny'.
305
+ *
306
+ * This overlay used to answer 'allow': the desktop renderer's word for "yes",
307
+ * not the engine's. So a user who pressed "1. Yes" was denied anyway — silently,
308
+ * on every mutating tool, every time. The model then read "the user denied the
309
+ * request" and retried with a different command, which was denied too, and the
310
+ * turn burned several rounds of tokens to accomplish nothing. Emitting the
311
+ * engine's own words is the whole fix; `test/cli-approval.test.mjs` pins it so
312
+ * the two vocabularies cannot drift apart again.
313
+ */
314
+ const APPROVE_ONCE = 'once';
315
+ const APPROVE_SESSION = 'session';
316
+ const DENY_TOKEN = 'deny';
317
+
318
+ /** Choice labels, in overlay order. Index maps 1:1 onto DECISIONS. */
319
+ const APPROVAL_CHOICES = ['Yes', 'Yes, allow for this session', 'No'];
320
+ const APPROVAL_DECISIONS = [APPROVE_ONCE, APPROVE_SESSION, DENY_TOKEN];
321
+
257
322
  /** The tool-approval dialog body. */
258
323
  function confirmLines(overlay, cols, ctx) {
259
324
  const t = themeOf(ctx);
@@ -278,8 +343,13 @@ function confirmLines(overlay, cols, ctx) {
278
343
  const left = active ? span(t.lavender, GLYPH.cursor) : span('', ' ');
279
344
  return [left, span(t.gray, ` ${i + 1}. `), span(active ? t.lavender : t.white, label)];
280
345
  };
281
- lines.push(opt(0, 'Yes'));
282
- lines.push(opt(1, 'No'));
346
+ const toolName = overlay.name || 'this tool';
347
+ APPROVAL_CHOICES.forEach((label, i) => {
348
+ // The session option names the tool it will stop asking about, so the
349
+ // blanket allow is never something the user has to infer.
350
+ const text = i === 1 ? `Yes, and allow ${toolName} for this session` : label;
351
+ lines.push(opt(i, text));
352
+ });
283
353
  lines.push([span('', '')]);
284
354
  lines.push([span(t.gray, 'Esc to cancel')]);
285
355
  return lines;
@@ -358,6 +428,36 @@ function rowLines(msg, cols, ctx, now = Date.now()) {
358
428
  out.push(line);
359
429
  return out;
360
430
  }
431
+ if (msg.role === 'summary') {
432
+ // The end-of-turn delta line. Rendered as the LAST row of the turn so the
433
+ // bottom of the screen answers "what did that actually do?" without the
434
+ // reader scrolling back up through the tool rows that produced it.
435
+ const s = msg.counts || {};
436
+ if (!s.tools) return out;
437
+ const line = [span(t.dim, GLYPH.hook), span('', ' ')];
438
+ const bits = [
439
+ span(t.white, String(s.tools)),
440
+ span(t.gray, ` ${s.tools === 1 ? 'command' : 'commands'}`),
441
+ ];
442
+ if (s.files && s.files.length) {
443
+ bits.push(span(t.gray, ` ${GLYPH.bullet} `));
444
+ bits.push(
445
+ span(t.white, String(s.files.length)),
446
+ span(t.gray, ` ${s.files.length === 1 ? 'file changed' : 'files changed'}`)
447
+ );
448
+ }
449
+ if (s.failed) {
450
+ bits.push(span(t.gray, ` ${GLYPH.bullet} `));
451
+ bits.push(span(t.coral, `${s.failed} failed`));
452
+ }
453
+ if (s.names && s.names.length) {
454
+ const more = s.more ? ` +${s.more}` : '';
455
+ bits.push(span(t.dim, ` ${s.names.join(', ')}${more}`));
456
+ }
457
+ line.push(...bits);
458
+ out.push(line);
459
+ return out;
460
+ }
361
461
  if (msg.role === 'meta') {
362
462
  // Deliberate divergence from the reference: this client's reason to exist
363
463
  // is showing what a turn consumed, so the accounting line is a transcript
@@ -591,6 +691,10 @@ async function runSession(host) {
591
691
  let verb = VERBS[0];
592
692
  let turnCount = 0;
593
693
  let toolSeq = 0;
694
+ // This turn's raw tool events, kept so the end-of-turn summary can report the
695
+ // deltas (commands run, files touched, failures) without re-reading the
696
+ // transcript — the rows are display state, the events are the record.
697
+ let turnTools = [];
594
698
  let suggestionIdx = 0;
595
699
  let scroll = 0;
596
700
  let anchorEnd = null;
@@ -730,7 +834,6 @@ async function runSession(host) {
730
834
 
731
835
  let streamedChars = 0;
732
836
  const streamedCells = () => streamedChars;
733
-
734
837
  // ── the turn ──
735
838
 
736
839
  const confirmTool = (info) =>
@@ -738,34 +841,28 @@ async function runSession(host) {
738
841
  overlay = { type: 'confirm', name: info.tool || info.name || 'tool', args: info.args || {}, sel: 0, info };
739
842
  render();
740
843
  (async () => {
844
+ const choose = (decision) => {
845
+ overlay = null;
846
+ render();
847
+ resolve(decision);
848
+ };
741
849
  for (;;) {
742
850
  const key = await nextKey();
743
851
  if (key.name === KEY.UP || key.name === KEY.DOWN || key.name === KEY.TAB) {
744
- overlay.sel = 1 - overlay.sel;
852
+ const step = key.name === KEY.UP ? -1 : 1;
853
+ overlay.sel = (overlay.sel + step + APPROVAL_CHOICES.length) % APPROVAL_CHOICES.length;
745
854
  render();
746
855
  } else if (key.name === KEY.ENTER) {
747
- const yes = overlay.sel === 0;
748
- overlay = null;
749
- render();
750
- resolve(yes ? 'allow' : 'deny');
856
+ choose(APPROVAL_DECISIONS[overlay.sel] || DENY_TOKEN);
751
857
  return;
752
858
  } else if (key.name === KEY.ESC || key.name === KEY.CTRL_C) {
753
- overlay = null;
754
- render();
755
- resolve('deny');
859
+ choose(DENY_TOKEN);
756
860
  return;
757
861
  } else if (key.name === 'char') {
758
862
  const ch = String(key.ch).trim();
759
- if (ch === '1') {
760
- overlay = null;
761
- render();
762
- resolve('allow');
763
- return;
764
- }
765
- if (ch === '2') {
766
- overlay = null;
767
- render();
768
- resolve('deny');
863
+ const idx = Number(ch) - 1;
864
+ if (Number.isInteger(idx) && idx >= 0 && idx < APPROVAL_DECISIONS.length) {
865
+ choose(APPROVAL_DECISIONS[idx]);
769
866
  return;
770
867
  }
771
868
  }
@@ -779,6 +876,7 @@ async function runSession(host) {
779
876
  startedAt = Date.now();
780
877
  streamedChars = 0;
781
878
  toolSeq = 0;
879
+ turnTools = [];
782
880
  verb = VERBS[turnCount % VERBS.length];
783
881
  abort = new AbortController();
784
882
  spinnerTimer = setInterval(() => {
@@ -803,6 +901,7 @@ async function runSession(host) {
803
901
  // from one that produced nothing at all.
804
902
  reasoning: () => { sawReasoning = true; },
805
903
  tool: (tool) => {
904
+ turnTools.push(tool);
806
905
  if (tool.phase === 'run') {
807
906
  const n = ++toolSeq;
808
907
  const prefix = tool.agent ? `${tool.agent} ▸ ` : '';
@@ -819,8 +918,27 @@ async function runSession(host) {
819
918
  },
820
919
  { follow: false }
821
920
  );
822
- } else {
823
- resolveToolDone(transcript, tool);
921
+ } else if (!resolveToolDone(transcript, tool)) {
922
+ // A host that reports a tool once, after it ran, never opens a
923
+ // `phase: 'run'` row for resolveToolDone to close — the vendored
924
+ // local engine does exactly this (one `tool: {name,args,ok}` frame
925
+ // post-execution). Falling through left the row unwritten, so the
926
+ // turn silently showed no tool activity at all. Record the finished
927
+ // tool directly instead.
928
+ const n = ++toolSeq;
929
+ push(
930
+ {
931
+ role: 'tool',
932
+ phase: 'done',
933
+ name: tool.name,
934
+ args: tool.args,
935
+ agent: tool.agent,
936
+ id: tool.id,
937
+ ok: tool.ok,
938
+ label: `Ran ${n} ${tool.agent ? `${tool.agent} ▸ ` : ''}${toolLabel(tool.name, n)}`,
939
+ },
940
+ { follow: false }
941
+ );
824
942
  }
825
943
  scheduleRender();
826
944
  },
@@ -909,6 +1027,17 @@ async function runSession(host) {
909
1027
  },
910
1028
  { follow: false }
911
1029
  );
1030
+ // The turn's last row: a one-line delta of what the tools actually did.
1031
+ // Without it the tail of a tool-heavy turn was the accounting row, and
1032
+ // the answer to "what did that change?" was only reachable by scrolling
1033
+ // back up through the tool rows. Follows the viewport EXCEPT when the
1034
+ // reader has scrolled up themselves — scroll is non-zero exactly when
1035
+ // they are mid-read, and yanking them to the bottom then would undo the
1036
+ // scroll they asked for.
1037
+ const counts = summarizeTurnTools(turnTools);
1038
+ if (counts.tools > 0) {
1039
+ push({ role: 'summary', counts }, { follow: scroll === 0 });
1040
+ }
912
1041
  render();
913
1042
  }
914
1043
  };
@@ -1645,6 +1774,7 @@ module.exports = {
1645
1774
  FRAME_MS,
1646
1775
  toolLabel,
1647
1776
  resolveToolDone,
1777
+ summarizeTurnTools,
1648
1778
  finalizeTurnText,
1649
1779
  historyPairs,
1650
1780
  paletteQuery,
@@ -1655,6 +1785,10 @@ module.exports = {
1655
1785
  statusLine,
1656
1786
  shortcutsGrid,
1657
1787
  confirmLines,
1788
+ // The approval vocabulary, exported so a test can assert the CLI only ever
1789
+ // answers with tokens the engine actually honours (see APPROVAL_CHOICES).
1790
+ APPROVAL_CHOICES,
1791
+ APPROVAL_DECISIONS,
1658
1792
  rowLines,
1659
1793
  transcriptLines,
1660
1794
  inputPreviewText,
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 };
@@ -346,6 +346,15 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
346
346
  // In-memory only, on purpose — never persisted, so a restart (or
347
347
  // newChat()'s clearSessionApprovals) always starts from a clean gate.
348
348
  const sessionAllowlists = new Map(); // rootSessionId -> Set<toolName>
349
+ // Denials are remembered for the same reason allows are, mirroring it for
350
+ // the other answer. Without this, a "Deny" was forgotten the instant it was
351
+ // given: the model read `… the user denied the request`, re-planned, called
352
+ // the SAME tool again, and the gate raised a SECOND card — so one "no" cost
353
+ // the user a prompt per round for up to maxRounds (24 chat / 40 autonomous)
354
+ // rounds, each round re-sending the whole conversation to the provider. A
355
+ // gate that only remembers "yes" turns a single click into a retry storm;
356
+ // remembering "no" makes the first answer stick.
357
+ const sessionDenials = new Map(); // rootSessionId -> Set<toolName>
349
358
  const pendingApprovals = new Map(); // approvalId -> { resolve }
350
359
 
351
360
  function sessionAllows(rootId, name) {
@@ -358,10 +367,44 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
358
367
  sessionAllowlists.get(rootId).add(name);
359
368
  }
360
369
 
370
+ /** Has this conversation already refused this tool? Checked before the card
371
+ * is raised, so a repeat call is refused outright instead of re-prompting. */
372
+ function sessionDenies(rootId, name) {
373
+ const set = sessionDenials.get(rootId);
374
+ return Boolean(set && set.has(name));
375
+ }
376
+
377
+ function denyForSession(rootId, name) {
378
+ if (!sessionDenials.has(rootId)) sessionDenials.set(rootId, new Set());
379
+ sessionDenials.get(rootId).add(name);
380
+ }
381
+
382
+ /**
383
+ * The text a refused tool hands back to the model. The old one-line
384
+ * `"<tool> was not executed — the user denied the request."` read to a
385
+ * capable agent as a transient failure to route around: it would apologise,
386
+ * pick a different command that does the same thing, and call the gate
387
+ * again. This says the durable part out loud (the refusal covers the rest of
388
+ * the conversation, not just that call) and asks for the one response that
389
+ * actually helps — say what you need and stop, so the user can re-enable it.
390
+ */
391
+ function denialText(name) {
392
+ return (
393
+ `${name} was not executed — the user denied this tool for this conversation. ` +
394
+ `Do NOT retry it and do NOT attempt the same effect by another route ` +
395
+ `(another command, a writeFile instead of an edit, a subagent). ` +
396
+ `Stop calling tools and reply in plain text: say what you were trying to do, ` +
397
+ `what you need, and that the user can re-enable ${name} to let it proceed.`
398
+ );
399
+ }
400
+
361
401
  /** newChat() in the renderer calls this so a fresh conversation never
362
- * inherits a prior thread's blanket allows. */
402
+ * inherits a prior thread's blanket allows — or its refusals. A new chat is
403
+ * a new gate in both directions: leaving denials behind would silently
404
+ * refuse a tool in a thread where the user never said no. */
363
405
  function clearSessionApprovals(rootSessionId) {
364
406
  sessionAllowlists.delete(rootSessionId);
407
+ sessionDenials.delete(rootSessionId);
365
408
  return { ok: true };
366
409
  }
367
410
 
@@ -379,22 +422,28 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
379
422
 
380
423
  /**
381
424
  * Ask the renderer to approve one mutating tool call. Resolves 'once',
382
- * 'session' or 'deny'. Sent over `rootOnDelta` (see chat()) as an
383
- * `{ approval }` chunk so it rides the exact same streaming channel as
384
- * tool-activity chunks no new IPC surface needed on the push side, only
385
- * on the reply side (respondApproval). Fails safe: no listener able to
386
- * ever answer (no onDelta, or the turn was aborted) resolves 'deny'
387
- * instead of hanging the tool round forever.
425
+ * 'session' or 'deny' (an explicit choice by the user) or 'cancel' when
426
+ * nobody ever answered: no listener able to reply, or the turn was aborted.
427
+ * 'cancel' is kept apart from 'deny' on purpose. Both refuse the call, but
428
+ * only 'deny' is a decision the user made, so only 'deny' may be remembered
429
+ * as "this conversation said no" (see sessionDenials). Folding the two
430
+ * together which is what the old fail-safe did, resolving 'deny' for an
431
+ * abort — would let a cancelled turn permanently refuse a tool the user
432
+ * never ruled on. Sent over `rootOnDelta` (see chat()) as an `{ approval }`
433
+ * chunk so it rides the exact same streaming channel as tool-activity
434
+ * chunks — no new IPC surface needed on the push side, only on the reply
435
+ * side (respondApproval). Fails safe: an unanswered request refuses the
436
+ * call instead of hanging the tool round forever.
388
437
  */
389
438
  function requestApproval(rootSessionId, rootOnDelta, signal, info) {
390
439
  return new Promise((resolve) => {
391
440
  if (signal && signal.aborted) {
392
- resolve('deny');
441
+ resolve('cancel');
393
442
  return;
394
443
  }
395
444
  const id = randomUUID();
396
445
  let settled = false;
397
- const onAbort = () => finish('deny');
446
+ const onAbort = () => finish('cancel');
398
447
  const finish = (decision) => {
399
448
  if (settled) return;
400
449
  settled = true;
@@ -405,7 +454,7 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
405
454
  if (signal) signal.addEventListener('abort', onAbort, { once: true });
406
455
  pendingApprovals.set(id, { resolve: finish });
407
456
  if (typeof rootOnDelta !== 'function') {
408
- finish('deny');
457
+ finish('cancel');
409
458
  return;
410
459
  }
411
460
  rootOnDelta({
@@ -440,6 +489,13 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
440
489
  if (!T.MUTATING_TOOLS.has(name)) return T.executeTool(name, args, toolCtx);
441
490
  if (!confirmModeEnabled()) return T.executeTool(name, args, toolCtx);
442
491
  if (sessionAllows(rootSessionId, name)) return T.executeTool(name, args, toolCtx);
492
+ // Already refused in this conversation: refuse again WITHOUT raising a
493
+ // second card. Before this, the model's retry after a denial re-prompted
494
+ // the user for the same tool — one "no" produced a card per round for up
495
+ // to maxRounds rounds, each one a billed provider call re-sending the
496
+ // whole conversation. Checked after the confirm-mode short-circuit so
497
+ // turning the gate off still overrides an earlier refusal.
498
+ if (sessionDenies(rootSessionId, name)) return { ok: false, error: denialText(name) };
443
499
 
444
500
  let preview = null;
445
501
  if (name === 'writeFile' || name === 'editFile') {
@@ -453,8 +509,15 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
453
509
  diff: preview && preview.diff,
454
510
  });
455
511
 
512
+ // Only a click is remembered. 'cancel' (aborted turn / nobody able to
513
+ // answer) refuses this call but must not write a durable "no" the user
514
+ // never gave.
456
515
  if (decision === 'deny') {
457
- return { ok: false, error: `${name} was not executed — the user denied the request.` };
516
+ denyForSession(rootSessionId, name);
517
+ return { ok: false, error: denialText(name) };
518
+ }
519
+ if (decision !== 'session' && decision !== 'once') {
520
+ return { ok: false, error: `${name} was not executed — the request was cancelled.` };
458
521
  }
459
522
  if (decision === 'session') allowForSession(rootSessionId, name);
460
523
 
@@ -598,12 +661,53 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
598
661
  // AUTONOMOUS_IDLE_TIMEOUT_MS). Undefined elsewhere -> 60s default.
599
662
  idleTimeoutMs: opts.idleTimeoutMs,
600
663
  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).
664
+ // aegis_recall the read half of aegis_memory, and the only half a
665
+ // client on this engine should send.
666
+ //
667
+ // aegis_memory is both halves: it injects the account's synced memory
668
+ // into context AND persists this turn back into it, charging sync
669
+ // quota for the write. That pairing is right for aegis-online, whose
670
+ // chat has nowhere else to live. It is wrong here, because this engine
671
+ // is shared by the GUI and the CLI (cli/src/deps.js loads this file):
672
+ // either would be billed for every "hey" and would fill the user's
673
+ // memory with greetings. Verified against aegis1 app.py: every
674
+ // write-back site (the note/upsert calls) is gated on aegis_memory,
675
+ // and aegis_memory implies aegis_recall there — so dropping the write
676
+ // half leaves /online unchanged and costs nothing on the read side.
677
+ //
678
+ // Writing is still available, explicitly: the aegis_memory_save tool
679
+ // (mcp/tools.js; the CLI exposes it as /memory). Recall on every turn,
680
+ // store only what the user asks for — which is what aegiscodex-dev's
681
+ // own cross-session memory does, and what this comment claimed to
682
+ // match while sending both halves.
683
+ //
684
+ // Recall was previously unreachable for a client that would not pay
685
+ // for it: services/tiered_recall.py only ran behind a flag that also
686
+ // bought a write, so the tiered path existed with no caller able to
687
+ // afford it. The split is what makes it reachable. The DEEP tier of the
688
+ // same read is a third flag with its own price — see `opts.recallDeep`
689
+ // below, which is off unless the session opted in.
605
690
  extra: {
606
- aegis_memory: true,
691
+ aegis_recall: true,
692
+ // The DEEP tier of that read — brain corrections plus the semantic
693
+ // answer cache — is not the same price, so it does not ride along.
694
+ // aegis1 app.py:8367 reads `aegis_recall_deep` (or the
695
+ // X-AEGIS-Recall-Deep header) and services/brain_memory.py
696
+ // find_cached_answer embeds the query: one provider embedding per
697
+ // turn, metered. The server deliberately implies it from
698
+ // `aegis_memory` and NOT from `aegis_recall`, so that a terminal
699
+ // client can buy the cheap read without the embedding.
700
+ //
701
+ // This client is that terminal client (the CLI loads this file via
702
+ // cli/src/deps.js), so it must not opt itself in: the flag is sent
703
+ // only when the SESSION asked for it — CLI `/memory-deep on`, a
704
+ // desktop payload with `recallDeep: true` — and it defaults false
705
+ // everywhere. It also travels only on the user's own turn
706
+ // (`opts.recallDeep` is cleared for every other dispatch below): a
707
+ // tool round, the doubled-budget retry and the write-up re-dispatch
708
+ // all re-send a context whose embedding the first round already
709
+ // bought, which would turn one embedding per turn into one per round.
710
+ ...(opts.recallDeep ? { aegis_recall_deep: true } : {}),
607
711
  session: opts.sessionId,
608
712
  // The fan-out is opt-in per dispatch. `brain` is sent EXPLICITLY
609
713
  // whenever this dispatch is not the autonomous one, because the
@@ -759,6 +863,14 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
759
863
  cls, model, mode: payload && payload.mode, maxTokens, statedMaxTokens, autonomous, sessionId, signal, onDelta, cfg, apiKey, toolChoice,
760
864
  effort: payload && payload.effort,
761
865
  workers: payload && payload.workers,
866
+ // Deep recall (`aegis_recall_deep`) is an explicit per-session opt-in
867
+ // and never a default: it costs one provider embedding per turn
868
+ // server-side (aegis1 services/brain_memory.py find_cached_answer), so
869
+ // a client that pays per turn must not turn it on for itself. Only a
870
+ // literal `true` from the caller counts — an absent or `undefined`
871
+ // field is off, which is what keeps every existing caller (the
872
+ // renderer's IPC payloads included) on the cheap read.
873
+ recallDeep: payload && payload.recallDeep === true,
762
874
  onReasoning,
763
875
  idleTimeoutMs: autonomous ? AUTONOMOUS_IDLE_TIMEOUT_MS : undefined,
764
876
  // A caller with no live streaming surface (a `--no-stream` CLI flag, a
@@ -873,7 +985,12 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
873
985
  });
874
986
  }
875
987
  round += 1;
876
- const opts = { ...base, system, messages: history, prompt, tools: toolSchemas };
988
+ // `round === 1` is the user's own ask, and the ONLY dispatch allowed to
989
+ // carry the deep-recall opt-in: the deep tier embeds the query once per
990
+ // dispatch, so leaving it on for an agentic turn would charge one
991
+ // embedding per tool round instead of one per turn (the retries below
992
+ // clear it explicitly, being re-dispatches inside round 1).
993
+ const opts = { ...base, system, messages: history, prompt, tools: toolSchemas, recallDeep: base.recallDeep && round === 1 };
877
994
  let res;
878
995
  try {
879
996
  res = await dispatch(cls, opts);
@@ -917,6 +1034,10 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
917
1034
  res = await dispatch(cls, {
918
1035
  ...opts,
919
1036
  singlePass: true,
1037
+ // A re-dispatch, not a new ask: the deep tier's embedding was
1038
+ // bought by round 1, and buying it again here would charge a
1039
+ // second one for the same context.
1040
+ recallDeep: false,
920
1041
  maxTokens: doubledBudget(opts.maxTokens),
921
1042
  // Doubling applies to the pooled path only when the caller stated a
922
1043
  // number. With none stated, the server's effort ladder IS the
@@ -948,6 +1069,9 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
948
1069
  res = await dispatch(cls, {
949
1070
  ...opts,
950
1071
  singlePass: true,
1072
+ // Same as the truncation retry above: this pass writes up findings
1073
+ // already in `history`, and a fresh embedding buys it nothing.
1074
+ recallDeep: false,
951
1075
  messages: history,
952
1076
  prompt: '',
953
1077
  tools: [],