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 +1 -1
- package/scripts/predist.mjs +1 -0
- package/src/app.js +38 -2
- package/src/art.js +63 -14
- package/src/chatflow.js +157 -23
- package/src/commands.js +19 -0
- package/src/deps.js +7 -1
- package/src/render.js +11 -3
- package/src/theme.js +21 -7
- package/src/update.js +8 -132
- package/vendor/client/aegis.js +12 -1
- package/vendor/client/update.js +140 -0
- package/vendor/desktop/lib/local/engine.js +141 -17
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegiscode",
|
|
3
3
|
"productName": "AEGIS Code",
|
|
4
|
-
"version": "6.5.
|
|
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",
|
package/scripts/predist.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
const
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
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 (
|
|
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
|
|
4
|
+
* The CLI's view of the shared npm update checker (client/update.js).
|
|
100
5
|
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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
|
-
|
|
12
|
+
const { resolveShared } = require('./deps.js');
|
|
13
|
+
module.exports = require(resolveShared('client/update.js'));
|
package/vendor/client/aegis.js
CHANGED
|
@@ -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'
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
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('
|
|
441
|
+
resolve('cancel');
|
|
393
442
|
return;
|
|
394
443
|
}
|
|
395
444
|
const id = randomUUID();
|
|
396
445
|
let settled = false;
|
|
397
|
-
const onAbort = () => finish('
|
|
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('
|
|
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
|
-
|
|
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
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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: [],
|