aegis-desktop 1.0.12 → 1.0.14
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/lib/google-connect.js +229 -0
- package/lib/local/engine.js +138 -2
- package/lib/local/entitlement.js +7 -1
- package/lib/local/prompt.js +67 -37
- package/lib/local/tools.js +49 -13
- package/main.js +446 -128
- package/package.json +2 -4
- package/preload.js +27 -5
- package/renderer/app.js +117 -7
- package/renderer/fingerprint.js +92 -2
- package/renderer/index.html +17 -0
- package/vendor/aegis.js +297 -6
- package/vendor/drive-connect.js +112 -0
- package/vendor/drive-upload.js +176 -0
- package/vendor/google-signin.js +12 -4
- package/vendor/model-discovery.js +43 -0
- package/vendor/update.js +100 -8
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
|
|
3
|
+
// AEGIS Code (aegiscode) — source: https://github.com/aegisinfo/aegiscode-plugin
|
|
4
|
+
// Conveying a modified copy, or running one as a network service, owes the
|
|
5
|
+
// corresponding source under this licence (§5/§13) — see LICENSING.md.
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* google-connect.js — the desktop's Google identity + Drive flow.
|
|
10
|
+
*
|
|
11
|
+
* Two distinct steps, run in this order because the second depends on the
|
|
12
|
+
* first having actually landed:
|
|
13
|
+
*
|
|
14
|
+
* 1. Sign in with Google (`client/google-signin.js`'s local round-trip) —
|
|
15
|
+
* replaces this machine's saved AEGIS key with the one tied to that
|
|
16
|
+
* Google identity, exactly as `aegiscode connect` already does for the
|
|
17
|
+
* CLI (cli/src/connect.js). Desktop never had this wiring before this
|
|
18
|
+
* file; the only Google sign-in that existed anywhere in this product
|
|
19
|
+
* was the CLI's.
|
|
20
|
+
* 2. Connect Drive (`client/drive-connect.js`, which opens `/connect/drive`
|
|
21
|
+
* in the system browser and polls `/api/drive/token`) — grants the
|
|
22
|
+
* `drive.file` scope to WHICHEVER Google identity that browser's
|
|
23
|
+
* consent screen uses. That is only the SAME account as step 1 if step
|
|
24
|
+
* 1 ran first on this machine: `/connect/drive`'s callback resolves an
|
|
25
|
+
* account purely by Google identity, with no notion of "the account
|
|
26
|
+
* this machine's API key already belongs to" — so connecting Drive
|
|
27
|
+
* before signing in attaches the tokens to a DIFFERENT account than the
|
|
28
|
+
* one this machine will poll, and the poll just times out.
|
|
29
|
+
*
|
|
30
|
+
* "Signed in with Google" is NOT "has an API key". Every install
|
|
31
|
+
* auto-provisions an anonymous device key, so a key always exists and an
|
|
32
|
+
* `!apiKey` guard never fires. The only honest source for the real
|
|
33
|
+
* question is the server's `google_linked` (users.google_id), carried on
|
|
34
|
+
* `/api/drive/token` and surfaced as `status().googleLinked` — so
|
|
35
|
+
* `connectDrive` refuses on that, and the renderer disables the button
|
|
36
|
+
* from the same flag.
|
|
37
|
+
*
|
|
38
|
+
* Electron-free and fully injected — same shape as `account-key.js` next to
|
|
39
|
+
* it — so the whole flow runs under a test with a stub `aegis` client, a
|
|
40
|
+
* stub `googleSignIn`/`driveConnect`/`driveUpload`, and no browser, window or
|
|
41
|
+
* network.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
function createGoogleConnect({
|
|
45
|
+
aegis,
|
|
46
|
+
credentials,
|
|
47
|
+
persistKey,
|
|
48
|
+
openUrl,
|
|
49
|
+
googleSignIn,
|
|
50
|
+
driveConnect,
|
|
51
|
+
driveUpload,
|
|
52
|
+
onAccountChange,
|
|
53
|
+
}) {
|
|
54
|
+
let signInInFlight = null;
|
|
55
|
+
let connectInFlight = null;
|
|
56
|
+
let lastSyncAt = null;
|
|
57
|
+
let lastSyncError = null;
|
|
58
|
+
// Did a Google round-trip complete in THIS process? That is first-hand
|
|
59
|
+
// evidence of a linked account and does not depend on the server reporting
|
|
60
|
+
// it — which older servers do not (see `status`).
|
|
61
|
+
let signedInViaGoogle = false;
|
|
62
|
+
|
|
63
|
+
/** Sign this machine in with Google, replacing whatever key it had. */
|
|
64
|
+
function signInWithGoogle() {
|
|
65
|
+
if (!signInInFlight) {
|
|
66
|
+
signInInFlight = (async () => {
|
|
67
|
+
try {
|
|
68
|
+
// Everything from here on (binding the local callback server,
|
|
69
|
+
// opening the browser, waiting for the round-trip) can fail, and
|
|
70
|
+
// every caller of this module — the IPC handler in main.js chief
|
|
71
|
+
// among them — is promised `{ ok, … }`/`{ ok: false, error }`,
|
|
72
|
+
// never a rejection. One try covers the whole flow instead of only
|
|
73
|
+
// the `session.wait()` half of it.
|
|
74
|
+
let session;
|
|
75
|
+
try {
|
|
76
|
+
session = await googleSignIn.startGoogleSignIn({ apiBase: aegis.apiBase });
|
|
77
|
+
} catch (e) {
|
|
78
|
+
return { ok: false, error: `could not start the local sign-in server — ${(e && e.message) || e}` };
|
|
79
|
+
}
|
|
80
|
+
const opened = Boolean(openUrl(session.url));
|
|
81
|
+
let result;
|
|
82
|
+
try {
|
|
83
|
+
result = await session.wait();
|
|
84
|
+
} catch (e) {
|
|
85
|
+
return { ok: false, opened, url: session.url, error: (e && e.message) || String(e) };
|
|
86
|
+
}
|
|
87
|
+
// Live client first: the very next call this process makes (e.g.
|
|
88
|
+
// the renderer asking for drive status right after this resolves)
|
|
89
|
+
// must see the new identity, not whatever key this process started
|
|
90
|
+
// with.
|
|
91
|
+
aegis.setApiKey(result.apiKey);
|
|
92
|
+
persistKey(result.apiKey);
|
|
93
|
+
if (result.memoryToken && credentials && typeof credentials.saveMemoryToken === 'function') {
|
|
94
|
+
try {
|
|
95
|
+
credentials.saveMemoryToken(result.memoryToken);
|
|
96
|
+
} catch {
|
|
97
|
+
/* best-effort — the account key itself is already saved */
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Before the notification, which is what makes the renderer re-read
|
|
101
|
+
// `status()` — otherwise that refresh races this assignment and
|
|
102
|
+
// reports the sign-in as not having happened.
|
|
103
|
+
signedInViaGoogle = true;
|
|
104
|
+
if (typeof onAccountChange === 'function') onAccountChange();
|
|
105
|
+
return { ok: true, opened, url: session.url, username: result.username || null };
|
|
106
|
+
} finally {
|
|
107
|
+
signInInFlight = null;
|
|
108
|
+
}
|
|
109
|
+
})();
|
|
110
|
+
}
|
|
111
|
+
return signInInFlight;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Open `/connect/drive` and wait for this account to show connected. */
|
|
115
|
+
async function connectDrive() {
|
|
116
|
+
if (!aegis.apiKey) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
error: 'sign in with Google first — this device has no AEGIS account key yet',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (!connectInFlight) {
|
|
123
|
+
connectInFlight = (async () => {
|
|
124
|
+
try {
|
|
125
|
+
// The key exists but may be an anonymous device key. Ask the server
|
|
126
|
+
// whether this account is actually tied to a Google identity before
|
|
127
|
+
// opening a consent screen that would attach the tokens elsewhere.
|
|
128
|
+
// Inside the in-flight promise so a double click cannot open two
|
|
129
|
+
// consent tabs. If the status call fails, fall through: a transport
|
|
130
|
+
// blip must not be reported as "not signed in with Google".
|
|
131
|
+
let linked = true;
|
|
132
|
+
try {
|
|
133
|
+
const st = await driveConnect.driveStatus(aegis);
|
|
134
|
+
// Refuse only on a definite "not linked". A server that predates
|
|
135
|
+
// `google_linked` answers the same shape as an unlinked account,
|
|
136
|
+
// and refusing on that would make a real Google sign-in unable to
|
|
137
|
+
// ever connect Drive — the exact dead end this guard exists to
|
|
138
|
+
// prevent, only inverted.
|
|
139
|
+
linked = !(st.googleLinkedKnown && !st.googleLinked);
|
|
140
|
+
} catch {
|
|
141
|
+
/* unknown — let the flow try rather than misreport the reason */
|
|
142
|
+
}
|
|
143
|
+
if (!linked) {
|
|
144
|
+
return {
|
|
145
|
+
ok: false,
|
|
146
|
+
error: 'sign in with Google first — this account is not linked to a Google identity yet, '
|
|
147
|
+
+ 'and connecting Drive now would attach it to a different account',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const url = driveConnect.driveConnectUrl(aegis.apiBase);
|
|
151
|
+
const opened = Boolean(openUrl(url));
|
|
152
|
+
try {
|
|
153
|
+
const status = await driveConnect.waitForDriveConnected(aegis);
|
|
154
|
+
return { ok: true, opened, url, connected: status.connected, email: status.email };
|
|
155
|
+
} catch (e) {
|
|
156
|
+
return { ok: false, opened, url, error: (e && e.message) || String(e) };
|
|
157
|
+
}
|
|
158
|
+
} finally {
|
|
159
|
+
connectInFlight = null;
|
|
160
|
+
}
|
|
161
|
+
})();
|
|
162
|
+
}
|
|
163
|
+
return connectInFlight;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A one-shot read: signed in with Google, and if so, is Drive connected.
|
|
168
|
+
*
|
|
169
|
+
* `signedIn` is the server's `google_linked`, not "has an API key" — see
|
|
170
|
+
* the header. The old `apiKey ? true` form reported every anonymous
|
|
171
|
+
* auto-provisioned device as signed in, which both mislabelled the card
|
|
172
|
+
* ("signed in · Drive not connected yet" on a machine that never touched
|
|
173
|
+
* Google) and enabled Connect Drive ahead of the sign-in it depends on.
|
|
174
|
+
*/
|
|
175
|
+
async function status() {
|
|
176
|
+
if (!aegis.apiKey) {
|
|
177
|
+
return { signedIn: false, googleLinked: false, googleLinkedKnown: true, connected: false, email: null };
|
|
178
|
+
}
|
|
179
|
+
const s = await driveConnect.driveStatus(aegis);
|
|
180
|
+
// A completed round-trip outranks a server that cannot answer: without
|
|
181
|
+
// this, signing in against a server that predates `google_linked` leaves
|
|
182
|
+
// the card reading "not signed in" forever, because the sign-in that just
|
|
183
|
+
// succeeded is the one thing the flag was supposed to prove.
|
|
184
|
+
const signedIn = s.googleLinked === true || signedInViaGoogle;
|
|
185
|
+
return {
|
|
186
|
+
signedIn,
|
|
187
|
+
...s,
|
|
188
|
+
googleLinked: s.googleLinked === true || signedIn,
|
|
189
|
+
googleLinkedKnown: s.googleLinkedKnown || signedIn,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Pull this account's AEGIS cloud memory and back it up to Drive. */
|
|
194
|
+
async function syncMemoryNow() {
|
|
195
|
+
try {
|
|
196
|
+
const tokenInfo = await aegis.driveToken();
|
|
197
|
+
if (!tokenInfo || !tokenInfo.connected || !tokenInfo.access_token) {
|
|
198
|
+
throw new Error('Google Drive is not connected yet');
|
|
199
|
+
}
|
|
200
|
+
const pulled = await aegis.memoryPull(0);
|
|
201
|
+
const entries = (pulled && pulled.entries) || [];
|
|
202
|
+
const content = JSON.stringify({ exportedAt: new Date().toISOString(), entries }, null, 2);
|
|
203
|
+
const result = await driveUpload.uploadMemoryBackup(tokenInfo.access_token, content);
|
|
204
|
+
lastSyncAt = Date.now();
|
|
205
|
+
lastSyncError = null;
|
|
206
|
+
return {
|
|
207
|
+
ok: true,
|
|
208
|
+
count: entries.length,
|
|
209
|
+
fileId: result.fileId,
|
|
210
|
+
webViewLink: result.webViewLink,
|
|
211
|
+
updated: result.updated,
|
|
212
|
+
lastSyncAt,
|
|
213
|
+
};
|
|
214
|
+
} catch (e) {
|
|
215
|
+
lastSyncError = (e && e.message) || String(e);
|
|
216
|
+
return { ok: false, error: lastSyncError, lastSyncAt };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
signInWithGoogle,
|
|
222
|
+
connectDrive,
|
|
223
|
+
status,
|
|
224
|
+
syncMemoryNow,
|
|
225
|
+
state: () => ({ lastSyncAt, lastSyncError }),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = { createGoogleConnect };
|
package/lib/local/engine.js
CHANGED
|
@@ -167,6 +167,32 @@ function normalizeToolArgs(T, name, args, ctx) {
|
|
|
167
167
|
return fn(name, args, ctx);
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Is `dir` inside a git work tree? Walks up the way git itself does, so a turn
|
|
172
|
+
* running in a subdirectory of a repo is still told it is one.
|
|
173
|
+
*
|
|
174
|
+
* Returns `'git'`, `''` (definitely not a repo), or `null` when the probe could
|
|
175
|
+
* not answer — the caller omits the line rather than asserting "not a repo",
|
|
176
|
+
* because a wrong negative is exactly the kind of fact a model will act on.
|
|
177
|
+
*/
|
|
178
|
+
function detectVcs(dir) {
|
|
179
|
+
if (!dir) return null;
|
|
180
|
+
try {
|
|
181
|
+
const fs = require('node:fs');
|
|
182
|
+
const path = require('node:path');
|
|
183
|
+
let probe = path.resolve(String(dir));
|
|
184
|
+
for (let i = 0; i < 8; i += 1) {
|
|
185
|
+
if (fs.existsSync(path.join(probe, '.git'))) return 'git';
|
|
186
|
+
const up = path.dirname(probe);
|
|
187
|
+
if (up === probe) break;
|
|
188
|
+
probe = up;
|
|
189
|
+
}
|
|
190
|
+
return '';
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
170
196
|
function hostEnv(base = {}, supplied = {}, payload = null) {
|
|
171
197
|
const b = base || {};
|
|
172
198
|
const s = supplied || {};
|
|
@@ -250,6 +276,16 @@ function hostEnv(base = {}, supplied = {}, payload = null) {
|
|
|
250
276
|
// a `{ key, value }` facet, or a bare string.
|
|
251
277
|
languages: pick('languages', b.languages),
|
|
252
278
|
cwd: pick('cwd', cwd),
|
|
279
|
+
// Whether the turn's directory is a git work tree, probed once per turn.
|
|
280
|
+
// Measured reason: the work rules order the model to check prior work with
|
|
281
|
+
// `git log` before it starts, and a benchmark workspace (a temp dir holding
|
|
282
|
+
// three files) is NOT a repo. On every task the model dutifully spent its
|
|
283
|
+
// second round on `cd … && git log`, paid a failed exec, and carried the
|
|
284
|
+
// error text into every later round's prompt — one wasted round in seven,
|
|
285
|
+
// and a bigger prefix for the rest. The host already knew; this states it
|
|
286
|
+
// so the rule can say "skip the sources the workspace does not have" and
|
|
287
|
+
// mean something the model can act on without guessing.
|
|
288
|
+
vcs: pick('vcs', b.vcs != null ? b.vcs : detectVcs(cwd)),
|
|
253
289
|
shell: pick('shell', shellName),
|
|
254
290
|
shellKind: pick('shellKind', shellKind),
|
|
255
291
|
roots: pick('roots', b.roots),
|
|
@@ -709,6 +745,7 @@ const { offerableCatalog } = requireSharedBrain();
|
|
|
709
745
|
const { withByokAdditions } = requireSharedByokCatalog();
|
|
710
746
|
const {
|
|
711
747
|
withUserModels,
|
|
748
|
+
unmatchedUserProviders,
|
|
712
749
|
load: loadUserModels,
|
|
713
750
|
addUserModel,
|
|
714
751
|
removeUserModel,
|
|
@@ -1635,6 +1672,16 @@ function createLocalEngine({
|
|
|
1635
1672
|
// it via `fee.require_balance` below.
|
|
1636
1673
|
let providers = [];
|
|
1637
1674
|
let fee = null;
|
|
1675
|
+
// Did the CATALOG itself fail to arrive, as opposed to arriving empty?
|
|
1676
|
+
// The two are different states and the picker must not confuse them: a
|
|
1677
|
+
// 5xx / offline relay used to fall into the bare `catch` below and hand
|
|
1678
|
+
// back `providers: []`, which reads on screen exactly like an account
|
|
1679
|
+
// with nothing configured — while the user's provider row and their
|
|
1680
|
+
// `models.json` declaration sat intact on disk. Reported instead, the
|
|
1681
|
+
// same way the 'local' branch reports `listed: false` for a tag list
|
|
1682
|
+
// that did not arrive.
|
|
1683
|
+
let providersFailed = false;
|
|
1684
|
+
let providersError = null;
|
|
1638
1685
|
try {
|
|
1639
1686
|
const data = await aegis.byokProviders();
|
|
1640
1687
|
// The server's catalog, plus the ids it does not name but the vendor
|
|
@@ -1652,8 +1699,16 @@ function createLocalEngine({
|
|
|
1652
1699
|
// Absent until the server publishes one, and the UI must then say
|
|
1653
1700
|
// nothing rather than show a guess.
|
|
1654
1701
|
fee = (data && data.fee) || null;
|
|
1655
|
-
} catch {
|
|
1702
|
+
} catch (err) {
|
|
1703
|
+
// Keep the empty list (nothing to offer) but SAY why, so the renderer
|
|
1704
|
+
// can distinguish "catalog unreachable" from "catalog said none". The
|
|
1705
|
+
// message is carried rather than a boolean alone because the two cases
|
|
1706
|
+
// need different copy: offline wants a retry, a server error wants the
|
|
1707
|
+
// status. `err.message` is the transport's own text, already shaped
|
|
1708
|
+
// elsewhere in this file.
|
|
1656
1709
|
providers = [];
|
|
1710
|
+
providersFailed = true;
|
|
1711
|
+
providersError = (err && err.message) || String(err);
|
|
1657
1712
|
}
|
|
1658
1713
|
const models = [];
|
|
1659
1714
|
for (const p of providers) {
|
|
@@ -1689,6 +1744,18 @@ function createLocalEngine({
|
|
|
1689
1744
|
needsProviderKey: models.length > 0 && !models.some((m) => m.configured),
|
|
1690
1745
|
needsAegisKey: !aegis.apiKey,
|
|
1691
1746
|
fee,
|
|
1747
|
+
// True only when the catalog did not arrive — never merely because it
|
|
1748
|
+
// arrived with no providers. `withUserModels` never fabricates a
|
|
1749
|
+
// provider, so this list is the same either side of the fold above.
|
|
1750
|
+
providersFailed,
|
|
1751
|
+
providersError,
|
|
1752
|
+
// Declarations in `models.json` naming a provider the catalog does not
|
|
1753
|
+
// list — a typo, or a provider renamed/dropped server-side. `withUserModels`
|
|
1754
|
+
// deliberately does NOT invent a row for them (it would 401 on its first
|
|
1755
|
+
// turn), but silence is the other wrong answer: the model stays in the
|
|
1756
|
+
// file and the user is entitled to know the picker cannot show it.
|
|
1757
|
+
// Carried as the ids alone; the renderer names them.
|
|
1758
|
+
unmatchedProviders: unmatchedUserProviders(providers),
|
|
1692
1759
|
};
|
|
1693
1760
|
}
|
|
1694
1761
|
if (cls === 'local') {
|
|
@@ -2408,6 +2475,32 @@ function createLocalEngine({
|
|
|
2408
2475
|
const refusal = localT.remoteRefusal(cfg.baseURL || localT.DEFAULT_BASE);
|
|
2409
2476
|
if (refusal) throw refusal;
|
|
2410
2477
|
}
|
|
2478
|
+
// `custom` is the mirror of `local`: both are rows whose payload is an
|
|
2479
|
+
// ADDRESS rather than a credential, and both are unusable without it.
|
|
2480
|
+
// They differ in what absence MEANS. A blank `local` row falls back to
|
|
2481
|
+
// DEFAULT_BASE (the stock Ollama port) because there is exactly one
|
|
2482
|
+
// sensible address to guess. A blank `custom` row has no default at all —
|
|
2483
|
+
// the catalog descriptor pins none, so the server refuses the call with a
|
|
2484
|
+
// bare 400 that says nothing about which half of the setup is missing.
|
|
2485
|
+
//
|
|
2486
|
+
// Caught here, before any byte goes out, so the message can name the
|
|
2487
|
+
// command that fixes it. Reachable in practice: `/byok-key custom <key>`
|
|
2488
|
+
// stores a key with no address, and the CLI explicitly trusts an
|
|
2489
|
+
// unreachable catalog rather than refusing the id — so a user who typed
|
|
2490
|
+
// the key first and the endpoint never has a row that looks configured.
|
|
2491
|
+
// Keyed on the provider, not the class: every other byok provider's host
|
|
2492
|
+
// is a server-side constant and needs no address from us.
|
|
2493
|
+
if (cls === 'byok' && byokParts.provider === 'custom' && !(cfg.baseURL || '').trim()) {
|
|
2494
|
+
const err = new Error(
|
|
2495
|
+
'No endpoint saved for the "custom" provider — its key is on this machine but the address ' +
|
|
2496
|
+
'it dials is not, and no default exists for a vendor AEGIS does not know. Fix: ' +
|
|
2497
|
+
'/model-add custom <https-base-url> <api-key> <model-id> (writes both halves), or the ' +
|
|
2498
|
+
'endpoint field in Settings › Provider (custom).'
|
|
2499
|
+
);
|
|
2500
|
+
err.status = 400;
|
|
2501
|
+
err.code = 'custom_endpoint_missing';
|
|
2502
|
+
throw err;
|
|
2503
|
+
}
|
|
2411
2504
|
const base = {
|
|
2412
2505
|
cls, model, mode: payload && payload.mode, maxTokens, statedMaxTokens, autonomous, sessionId, signal, onDelta, cfg, apiKey, toolChoice,
|
|
2413
2506
|
// The turn's working directory, computed above as `turnCwd` — `dispatch`
|
|
@@ -2563,7 +2656,34 @@ function createLocalEngine({
|
|
|
2563
2656
|
// dispatch is summed, including the truncation retry and the synthesis
|
|
2564
2657
|
// re-dispatch: both are real, separately billed provider calls.
|
|
2565
2658
|
const turnUsage = { calls: 0 };
|
|
2566
|
-
|
|
2659
|
+
// The cache fields MUST be listed here, not just summed at the top level.
|
|
2660
|
+
// DeepSeek reports its hit count as `prompt_cache_hit_tokens` and its
|
|
2661
|
+
// miss count as `prompt_cache_miss_tokens`, and neither name was in this
|
|
2662
|
+
// list — so every tool-using turn dropped them on the way out and handed
|
|
2663
|
+
// its caller a usage object with no cache information at all. The CLI's
|
|
2664
|
+
// `--json` republishes exactly this accumulator, `usageBuckets` then had
|
|
2665
|
+
// nothing to read, and the published cost of an agentic run was priced
|
|
2666
|
+
// entirely at the miss rate. Measured on the 3-task sweep: `cache_read: 0`
|
|
2667
|
+
// on all three trials (tokens_in 210,904) while the row's own baseline
|
|
2668
|
+
// showed 72,448 cached tokens. The same defect the CLI's per-turn readout
|
|
2669
|
+
// was fixed for, here one layer below it.
|
|
2670
|
+
const USAGE_FIELDS = [
|
|
2671
|
+
'input_tokens', 'output_tokens', 'prompt_tokens', 'completion_tokens', 'total_tokens',
|
|
2672
|
+
// Anthropic spelling (disjoint from its input count).
|
|
2673
|
+
'cache_read_input_tokens', 'cache_creation_input_tokens',
|
|
2674
|
+
// DeepSeek/OpenAI spelling (both SUBSETs of the prompt count, which is
|
|
2675
|
+
// what `usageBuckets` detects and subtracts rather than double-billing).
|
|
2676
|
+
'prompt_cache_hit_tokens', 'prompt_cache_miss_tokens',
|
|
2677
|
+
// Hidden deliberation, which every provider here bills as OUTPUT but
|
|
2678
|
+
// none of them split out for free. These were absent from the list
|
|
2679
|
+
// while `reasoningTokensOf` (below) already knew how to read them, so
|
|
2680
|
+
// the one place that could have separated "wrote more code" from
|
|
2681
|
+
// "thought longer" threw the split away at the accumulator and handed
|
|
2682
|
+
// its caller a single `output_tokens` with CoT folded in. A benchmark
|
|
2683
|
+
// row could therefore see that a turn cost 1.9x another's output and
|
|
2684
|
+
// still have no way to say which half moved.
|
|
2685
|
+
'reasoning_tokens', 'thinking_tokens',
|
|
2686
|
+
];
|
|
2567
2687
|
const addUsage = (res) => {
|
|
2568
2688
|
const u = res && res.usage;
|
|
2569
2689
|
if (!u || typeof u !== 'object') return;
|
|
@@ -2961,6 +3081,22 @@ function createLocalEngine({
|
|
|
2961
3081
|
});
|
|
2962
3082
|
}
|
|
2963
3083
|
}
|
|
3084
|
+
} catch (e) {
|
|
3085
|
+
// Stamp the stop at the one place it is known for certain.
|
|
3086
|
+
//
|
|
3087
|
+
// `cancel(sessionId)` aborts THIS turn's controller, and the in-flight
|
|
3088
|
+
// transport unwinds as a rejection. The host has to tell that rejection
|
|
3089
|
+
// apart from a genuine provider/transport failure before it can stop
|
|
3090
|
+
// printing Ctrl-X as an application crash — and it used to guess from the
|
|
3091
|
+
// message text, which is exactly how a real failure ("connection aborted
|
|
3092
|
+
// by peer", "upstream aborted the connection") gets relabelled as a
|
|
3093
|
+
// deliberate stop and leaves the user with a silently empty answer. The
|
|
3094
|
+
// message is not evidence. This is: the signal reads aborted only because
|
|
3095
|
+
// the user asked for it, so it cannot be confused with anything upstream
|
|
3096
|
+
// did. Attached to the error object itself so it survives any rethrow,
|
|
3097
|
+
// and read by `isAbortShapedError` in desktop/main.js.
|
|
3098
|
+
if (signal.aborted && e && typeof e === 'object') e.aegisCancelled = true;
|
|
3099
|
+
throw e;
|
|
2964
3100
|
} finally {
|
|
2965
3101
|
if (shell) shell.dispose();
|
|
2966
3102
|
controllers.delete(sessionId);
|
package/lib/local/entitlement.js
CHANGED
|
@@ -203,9 +203,15 @@ function byokFeeLine(fee) {
|
|
|
203
203
|
// default-off gate after it. Joined with ' — ' only when there IS a second
|
|
204
204
|
// clause: a sentence-ending dash with nothing after it ("…provider's bill —.")
|
|
205
205
|
// is what the first live run of this line actually printed.
|
|
206
|
+
//
|
|
207
|
+
// This rate is the deployment's published RATE, not a quote for the reader
|
|
208
|
+
// specifically — an Unlimited subscriber is waived from this fee entirely
|
|
209
|
+
// (billing_service.is_unlimited on aegis1), so the number above is what a
|
|
210
|
+
// Free-tier BYOK caller pays, not what everyone pays.
|
|
206
211
|
const price = `Bring your own key: relayed through AEGIS, handled at `
|
|
207
212
|
+ `${ratePerMillion(inRate)} in / ${ratePerMillion(outRate)} out, `
|
|
208
|
-
+ `on top of your provider's bill
|
|
213
|
+
+ `on top of your provider's bill — no fee at all on Unlimited, `
|
|
214
|
+
+ `and no fee at all during the free 1-month Unlimited trial`;
|
|
209
215
|
let clause = '';
|
|
210
216
|
if (f.require_account === true) {
|
|
211
217
|
clause = 'and needs an AEGIS account key (X-AEGIS-Key) for the fee to be billed';
|
package/lib/local/prompt.js
CHANGED
|
@@ -30,50 +30,62 @@
|
|
|
30
30
|
|
|
31
31
|
/** Identity + work rules. Ported from the upstream engine reference. */
|
|
32
32
|
const MAIN_CHAT_PROMPT =
|
|
33
|
-
`You are Aegiscodex, a terminal coding assistant
|
|
34
|
-
`
|
|
35
|
-
|
|
33
|
+
`You are Aegiscodex, a terminal coding assistant working in the user's repository: read and ` +
|
|
34
|
+
`reason about code, write and edit files, run shell commands, investigate bugs. Work rules:\n` +
|
|
35
|
+
`- Output is billed by the token and is the largest line on the bill, so spend it where it is ` +
|
|
36
|
+
` read. Before a tool call, one short sentence on what you are about to find out. Do NOT ` +
|
|
37
|
+
` restate file contents, diffs, command output or test results the user can already see. ` +
|
|
38
|
+
` Finish with the answer in a few lines — expand only when asked to explain, or when the ` +
|
|
39
|
+
` change's correctness depends on a detail the user must check.\n` +
|
|
40
|
+
`- Batch independent tool calls into ONE turn: read several files, or grep for several ` +
|
|
41
|
+
` patterns, at once, or use one exec command that does the whole job. Every round ` +
|
|
42
|
+
` re-sends the prefix and bills fresh output; one call per round is the expensive shape.\n` +
|
|
36
43
|
`- Use tools silently. A one-line reason is enough; do not narrate your plan as a story. ` +
|
|
37
44
|
`- Never claim what a tool found or what a command returned before the tool actually runs. ` +
|
|
38
45
|
` Report only the results you really received. ` +
|
|
39
|
-
`- Before
|
|
40
|
-
` it
|
|
41
|
-
`
|
|
42
|
-
`
|
|
43
|
-
`
|
|
44
|
-
`
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
`
|
|
48
|
-
`
|
|
46
|
+
`- Before starting a fix or feature, check whether they have asked for it before and whether ` +
|
|
47
|
+
` it was delivered: recalled memory, \`queue.jsonl\`, \`PLAN.md\`/notes and \`git log\` — ` +
|
|
48
|
+
` ONE batched command over sources that exist: if the Environment block says this ` +
|
|
49
|
+
` directory has no version control, skip it. ` +
|
|
50
|
+
` If it was resolved before and the problem is back, report how it was resolved and ` +
|
|
51
|
+
` why it regressed, then fix the cause — never re-fix blind as if it were new. ` +
|
|
52
|
+
` If it was never met, say so plainly before starting. ` +
|
|
53
|
+
`- Match the response to the request. A greeting, a question you already know the answer to, ` +
|
|
54
|
+
` or a request for an opinion is answered in plain words with NO tools. Reach for a tool ` +
|
|
55
|
+
` only when the answer depends on something in this repo or on this machine. ` +
|
|
56
|
+
` "hey" is not a task.\n` +
|
|
49
57
|
`- WHEN THE USER HAS ASKED FOR WORK: act, don't just inspect. After at most 2 rounds of ` +
|
|
50
58
|
` reading or exploration, start making changes with writeFile or editFile. Reconnaissance ` +
|
|
51
|
-
` is not progress — implement, then verify. This
|
|
52
|
-
`
|
|
59
|
+
` is not progress — implement, then verify. This is about HOW to carry out a task you were ` +
|
|
60
|
+
` given; it is never a reason to invent one. Never write or modify a file the user ` +
|
|
53
61
|
` did not ask you to touch.\n` +
|
|
54
|
-
`- Zero tolerance for stalling: once the
|
|
55
|
-
`
|
|
56
|
-
`
|
|
57
|
-
`
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
` user's question. Never end your turn with an intention like "Let me check…" or "I'll now…" ` +
|
|
61
|
-
` — that is not an answer. ` +
|
|
62
|
+
`- Zero tolerance for stalling: once the cause is identified and only the edit remains, make ` +
|
|
63
|
+
` it in the same turn. Never end a turn with an empty or no-op response, or a "let me ` +
|
|
64
|
+
` continue"/"I'll now…" placeholder when the user says continue — that wastes their turn. ` +
|
|
65
|
+
` This has been flagged as non-compliance before; treat it as a hard rule. ` +
|
|
66
|
+
`- When you have what you need, stop using tools and give a concise, direct answer. Never ` +
|
|
67
|
+
` end a turn with an intention like "Let me check…" — that is not an answer. ` +
|
|
62
68
|
`- When a focused multi-step sub-task can be delegated, use the task tool to spawn a ` +
|
|
63
|
-
` specialist subagent rather than doing
|
|
69
|
+
` specialist subagent rather than doing it inline. ` +
|
|
64
70
|
`- If the user references a workflow you don't recognize, inspect the repo/scripts for that ` +
|
|
65
|
-
` mechanism before acting
|
|
71
|
+
` mechanism before acting.`;
|
|
66
72
|
|
|
67
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* The tool line: what each tool is, and the two facts a schema cannot carry.
|
|
75
|
+
*
|
|
76
|
+
* It used to restate the path rule in full — absolute, `~` expansion, relative
|
|
77
|
+
* resolution — which `PATH_ARG_DESC` already says once per path argument in nine
|
|
78
|
+
* schemas. That duplication is what `d01068b` unwound from the schemas; this is
|
|
79
|
+
* the other end of it. What stays is the part no schema carries: the
|
|
80
|
+
* platform-specific example (the one form a model gets wrong) and the persistent
|
|
81
|
+
* shell, which is a property of the session rather than an argument.
|
|
82
|
+
*/
|
|
68
83
|
const TOOL_LINE =
|
|
69
|
-
'Tools: readFile, writeFile, editFile, listDir, glob, grep, exec, task. Paths are absolute
|
|
70
|
-
'
|
|
71
|
-
'
|
|
72
|
-
'
|
|
73
|
-
'
|
|
74
|
-
'exec runs in a persistent shell session on this machine — cd and exported env vars carry ' +
|
|
75
|
-
'across calls within the turn, like a real terminal. task spawns a specialist subagent ' +
|
|
76
|
-
'(its own tool loop, same model) for a focused, self-contained piece of work.';
|
|
84
|
+
'Tools: readFile, writeFile, editFile, listDir, glob, grep, exec, task. Paths are absolute ' +
|
|
85
|
+
'in the form THIS machine uses (see the Environment block): on win32 that means ' +
|
|
86
|
+
'C:\\Users\\you\\Desktop\\file.txt, not /home/you/Desktop/file.txt or /tmp/file.txt. ' +
|
|
87
|
+
'exec runs in a persistent shell session — cd and exported env vars carry across calls ' +
|
|
88
|
+
'within the turn. task spawns a specialist subagent (its own tool loop, same model).';
|
|
77
89
|
|
|
78
90
|
/**
|
|
79
91
|
* One durable-language entry as a sentence fragment, accepting the three shapes
|
|
@@ -109,6 +121,7 @@ function environmentPreamble(env = {}) {
|
|
|
109
121
|
localeName,
|
|
110
122
|
languages,
|
|
111
123
|
cwd,
|
|
124
|
+
vcs,
|
|
112
125
|
shell,
|
|
113
126
|
shellKind,
|
|
114
127
|
roots,
|
|
@@ -164,8 +177,7 @@ function environmentPreamble(env = {}) {
|
|
|
164
177
|
if (desktopdir) {
|
|
165
178
|
bits.push(
|
|
166
179
|
`desktop directory: ${desktopdir} — use this exact path when the user says "my desktop"; ` +
|
|
167
|
-
'do NOT build it yourself from the home directory,
|
|
168
|
-
'called "Desktop" there',
|
|
180
|
+
'do NOT build it yourself from the home directory',
|
|
169
181
|
);
|
|
170
182
|
}
|
|
171
183
|
// Same failure mode as the desktop line, one directory over, and reported
|
|
@@ -176,13 +188,31 @@ function environmentPreamble(env = {}) {
|
|
|
176
188
|
// missing path cannot tell a typo from an empty directory.
|
|
177
189
|
if (dataDir) {
|
|
178
190
|
bits.push(
|
|
179
|
-
`AEGIS data directory: ${dataDir} —
|
|
191
|
+
`AEGIS data directory: ${dataDir} — the store itself (config, sessions, ` +
|
|
180
192
|
'credentials, queue). Use this exact path; do NOT append it to the home directory ' +
|
|
181
193
|
'yourself, because a dropped separator ("C:\\Users\\you.aegiscode") names a path that ' +
|
|
182
194
|
'does not exist and looks like an empty directory rather than a mistake',
|
|
183
195
|
);
|
|
184
196
|
}
|
|
185
197
|
if (cwd) bits.push(`working directory: ${cwd}`);
|
|
198
|
+
// The version-control fact, which turns the work rules' "check whether this
|
|
199
|
+
// was already delivered" from a guess into a decision. Measured: in a temp
|
|
200
|
+
// workspace with no `.git`, the model still ran `git log` on its second round
|
|
201
|
+
// of all three tasks, spent a round on a guaranteed failure, and carried the
|
|
202
|
+
// error into the rest of the turn. Stated only when the host actually probed
|
|
203
|
+
// (`null`/`undefined` means "could not tell", and silence beats a wrong fact).
|
|
204
|
+
if (vcs === 'git') {
|
|
205
|
+
bits.push(
|
|
206
|
+
`version control: this directory IS inside a git work tree, so \`git log\`, ` +
|
|
207
|
+
'`git diff` and `git status` are usable for checking what was already done',
|
|
208
|
+
);
|
|
209
|
+
} else if (vcs === '') {
|
|
210
|
+
bits.push(
|
|
211
|
+
`version control: this directory is NOT a git repository and has no history to ` +
|
|
212
|
+
'consult — do NOT run `git log`, `git diff`, `git blame` or `git status`; they ' +
|
|
213
|
+
'fail here and cost a round. Read the files that exist instead',
|
|
214
|
+
);
|
|
215
|
+
}
|
|
186
216
|
if (shell) bits.push(`exec shell: ${shell}${shellKind ? ` (${shellKind})` : ''}`);
|
|
187
217
|
if (model) bits.push(`model: ${model}`);
|
|
188
218
|
if (appVersion) bits.push(`AEGIS Desktop ${appVersion}`);
|