aegis-desktop 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -0
- package/lib/local/autonomous.js +210 -24
- package/lib/local/engine.js +100 -7
- package/lib/local/queue.js +20 -1
- package/lib/local/session-rounds.js +205 -0
- package/lib/settings.js +43 -0
- package/lib/sync/persist-gate.js +120 -0
- package/main.js +128 -6
- package/package.json +4 -2
- package/preload.js +31 -0
- package/renderer/app.js +117 -3
- package/renderer/diff.js +356 -0
- package/renderer/diffview.js +333 -0
- package/renderer/index.html +12 -3
- package/renderer/style.css +70 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Session-scoped tool-round ledger.
|
|
5
|
+
*
|
|
6
|
+
* The engine's tool-round cap used to be a wall. A turn that reached it ended
|
|
7
|
+
* mid-investigation with `[stopped at 24 tool rounds…]`, and because the
|
|
8
|
+
* accounting lived only for the duration of that call, the next turn in the
|
|
9
|
+
* same conversation started from zero: it knew nothing about the horizon that
|
|
10
|
+
* had just cut it off, so a long job could be truncated by the cap forever
|
|
11
|
+
* without ever being finished.
|
|
12
|
+
*
|
|
13
|
+
* This makes the cap *session* state instead of turn state:
|
|
14
|
+
*
|
|
15
|
+
* - a turn that reaches its horizon records the unfinished work against the
|
|
16
|
+
* session key (rounds spent, tokens, the note the user saw, and the
|
|
17
|
+
* tool-call transcript itself — see `record`'s `messages`);
|
|
18
|
+
* - the next turn in that session takes the record — consuming it, so one
|
|
19
|
+
* resume per interruption and a chain of interruptions is a chain of
|
|
20
|
+
* deliberate asks, never an automatic loop — and gets a continuation
|
|
21
|
+
* preamble plus a small round bonus, so re-orientation does not eat the
|
|
22
|
+
* new horizon before any work happens;
|
|
23
|
+
* - an entry nobody comes back for expires after TTL_MS.
|
|
24
|
+
*
|
|
25
|
+
* State is module-level, so it outlives a turn, a `send()` and whichever
|
|
26
|
+
* engine instance the desktop happens to be using. It never leaves the
|
|
27
|
+
* process and is never written to disk.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** How long an unclaimed interruption stays resumable (30 min). */
|
|
31
|
+
const TTL_MS = 30 * 60 * 1000;
|
|
32
|
+
/** Bound on live entries: a long-lived app must not grow without limit. */
|
|
33
|
+
const MAX_ENTRIES = 64;
|
|
34
|
+
|
|
35
|
+
const entries = new Map(); // key -> { rounds, tokens, note, at, interruptions }
|
|
36
|
+
const historyKeys = new WeakMap(); // history[] -> key
|
|
37
|
+
let historyKeySeq = 0;
|
|
38
|
+
|
|
39
|
+
/** Drop expired entries, then the oldest ones past the bound. */
|
|
40
|
+
function prune(now = Date.now()) {
|
|
41
|
+
for (const [key, entry] of entries) {
|
|
42
|
+
if (now - entry.at > TTL_MS) entries.delete(key);
|
|
43
|
+
}
|
|
44
|
+
while (entries.size > MAX_ENTRIES) {
|
|
45
|
+
// Map iteration is insertion-ordered and `record` re-inserts on write, so
|
|
46
|
+
// the first key is the oldest.
|
|
47
|
+
const oldest = entries.keys().next();
|
|
48
|
+
if (oldest.done) break;
|
|
49
|
+
entries.delete(oldest.value);
|
|
50
|
+
}
|
|
51
|
+
return entries.size;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The key an interruption is filed under. A caller that states its session
|
|
56
|
+
* (desktop payloads carry an id) gets an exact match; otherwise the working
|
|
57
|
+
* directory, then the identity of the history array, then a single shared
|
|
58
|
+
* bucket. `adopt` covers the remaining case — a caller that mints a fresh key
|
|
59
|
+
* every turn.
|
|
60
|
+
*/
|
|
61
|
+
function keyFor(payload, history) {
|
|
62
|
+
const p = payload && typeof payload === 'object' ? payload : {};
|
|
63
|
+
const stated = p.sessionKey || p.sessionId || p.chatId || p.threadId || p.conversationId;
|
|
64
|
+
if (typeof stated === 'string' && stated.trim()) return `s:${stated.trim()}`;
|
|
65
|
+
if (typeof p.cwd === 'string' && p.cwd.trim()) return `cwd:${p.cwd.trim()}`;
|
|
66
|
+
if (history && typeof history === 'object') {
|
|
67
|
+
let key = historyKeys.get(history);
|
|
68
|
+
if (!key) {
|
|
69
|
+
historyKeySeq += 1;
|
|
70
|
+
key = `hist-${historyKeySeq}`;
|
|
71
|
+
historyKeys.set(history, key);
|
|
72
|
+
}
|
|
73
|
+
return key;
|
|
74
|
+
}
|
|
75
|
+
return 'default';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Extra rounds granted to a resuming turn. Deliberately small and bounded: the
|
|
80
|
+
* point of resuming is to *continue* work that already exists, not to hand out
|
|
81
|
+
* a second full horizon (every round re-sends the whole conversation, so this
|
|
82
|
+
* is the cheapest way to make the cap non-fatal).
|
|
83
|
+
*/
|
|
84
|
+
function bonus(base) {
|
|
85
|
+
const n = Number.isFinite(base) && base > 0 ? base : 0;
|
|
86
|
+
return Math.max(4, Math.ceil(n / 4));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Peek without consuming — the entry stays resumable. */
|
|
90
|
+
function peek(key, now = Date.now()) {
|
|
91
|
+
prune(now);
|
|
92
|
+
return entries.get(key) || null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Claim the interruption for `key`, if any. Consuming: a second call gets null. */
|
|
96
|
+
function take(key, now = Date.now()) {
|
|
97
|
+
prune(now);
|
|
98
|
+
const entry = entries.get(key);
|
|
99
|
+
if (!entry) return null;
|
|
100
|
+
entries.delete(key);
|
|
101
|
+
return entry;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Claim the most recent interruption filed under ANY key. This is the bridge
|
|
106
|
+
* for a caller whose key does not survive between turns: the next turn on this
|
|
107
|
+
* process adopts the held work rather than losing it. Bounded by `maxAgeMs` so
|
|
108
|
+
* an unrelated new conversation is not handed yesterday's job.
|
|
109
|
+
*/
|
|
110
|
+
function adopt({ now = Date.now(), maxAgeMs = 10 * 60 * 1000 } = {}) {
|
|
111
|
+
prune(now);
|
|
112
|
+
let bestKey = null;
|
|
113
|
+
let best = null;
|
|
114
|
+
for (const [key, entry] of entries) {
|
|
115
|
+
if (now - entry.at > maxAgeMs) continue;
|
|
116
|
+
if (!best || entry.at > best.at) {
|
|
117
|
+
best = entry;
|
|
118
|
+
bestKey = key;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!best) return null;
|
|
122
|
+
entries.delete(bestKey);
|
|
123
|
+
return { key: bestKey, entry: best };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* File an interruption. `chain` carries the count forward from the entry this
|
|
128
|
+
* turn resumed, so `interruptions` reports how many times one job has been cut
|
|
129
|
+
* off — visible in the note and useful when deciding to raise the horizon.
|
|
130
|
+
*
|
|
131
|
+
* `messages` is the interrupted turn's own tool-call transcript (its live
|
|
132
|
+
* `history` array at the moment it hit the horizon) — real memory of what was
|
|
133
|
+
* already done, not just a rounds/tokens count of it. Without it a caller
|
|
134
|
+
* that supplies no conversation of its own (a queue task's fresh dispatch)
|
|
135
|
+
* resumes with an empty history: the model is told "continue, don't restart"
|
|
136
|
+
* with nothing to continue FROM, which is a cold start wearing a note.
|
|
137
|
+
* Stored as a defensive copy — the caller's `history` array keeps being
|
|
138
|
+
* mutated by the turn that is returning it.
|
|
139
|
+
*/
|
|
140
|
+
function record(key, { rounds, tokens, note, chain, messages, added } = {}, now = Date.now()) {
|
|
141
|
+
prune(now);
|
|
142
|
+
const prior = entries.get(key);
|
|
143
|
+
const entry = {
|
|
144
|
+
rounds: Number.isFinite(rounds) ? rounds : 0,
|
|
145
|
+
tokens: Number.isFinite(tokens) ? tokens : 0,
|
|
146
|
+
note: typeof note === 'string' ? note : '',
|
|
147
|
+
at: now,
|
|
148
|
+
interruptions: (Number.isFinite(chain) ? chain : prior && prior.interruptions) || 0,
|
|
149
|
+
messages: Array.isArray(messages) ? messages.slice() : [],
|
|
150
|
+
// And what the interrupted turn added on TOP of what its caller already
|
|
151
|
+
// had. A resuming caller who brings its own conversation (an interactive
|
|
152
|
+
// chat) must get this half, not `messages` — pushing the full transcript
|
|
153
|
+
// into a history that already holds its first half duplicates the user's
|
|
154
|
+
// own turns back to the model.
|
|
155
|
+
added: Array.isArray(added) ? added.slice() : [],
|
|
156
|
+
};
|
|
157
|
+
entry.interruptions += 1;
|
|
158
|
+
entries.delete(key);
|
|
159
|
+
entries.set(key, entry); // re-insert so Map order stays age order
|
|
160
|
+
prune(now);
|
|
161
|
+
return entry;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The instruction that turns a cold start into a continuation. */
|
|
165
|
+
function resumePreamble(entry) {
|
|
166
|
+
const rounds = entry && entry.rounds ? entry.rounds : 'the previous turn\'s';
|
|
167
|
+
const tokens = entry && entry.tokens ? ` (${entry.tokens.toLocaleString()} tokens)` : '';
|
|
168
|
+
return (
|
|
169
|
+
`[session resume] The previous turn in this conversation was cut off at ${rounds} tool rounds${tokens} ` +
|
|
170
|
+
'because it reached the round horizon, so the work it started is unfinished. Continue that work now: ' +
|
|
171
|
+
'do not restart, do not re-do steps that already completed, and do not treat the interruption as a ' +
|
|
172
|
+
'failure to report. Pick up the next unfinished step and finish with a written answer.'
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Read-only snapshot for the UI (and for tests). */
|
|
177
|
+
function state(now = Date.now()) {
|
|
178
|
+
prune(now);
|
|
179
|
+
return {
|
|
180
|
+
size: entries.size,
|
|
181
|
+
ttlMs: TTL_MS,
|
|
182
|
+
maxEntries: MAX_ENTRIES,
|
|
183
|
+
entries: [...entries].map(([key, entry]) => ({ key, ...entry })),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Test hook: forget everything. */
|
|
188
|
+
function reset() {
|
|
189
|
+
entries.clear();
|
|
190
|
+
return entries.size;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = {
|
|
194
|
+
TTL_MS,
|
|
195
|
+
MAX_ENTRIES,
|
|
196
|
+
keyFor,
|
|
197
|
+
bonus,
|
|
198
|
+
peek,
|
|
199
|
+
take,
|
|
200
|
+
adopt,
|
|
201
|
+
record,
|
|
202
|
+
resumePreamble,
|
|
203
|
+
state,
|
|
204
|
+
reset,
|
|
205
|
+
};
|
package/lib/settings.js
CHANGED
|
@@ -54,12 +54,21 @@ const DEFAULT_QUICK_LAUNCHER_SHORTCUT = 'CmdOrCtrl+Shift+Space';
|
|
|
54
54
|
* top-level namespace — never inside a provider's `cfg[provider]` object. */
|
|
55
55
|
const CONFIRM_MODE_NAMESPACE = '__confirmMode';
|
|
56
56
|
|
|
57
|
+
/** Reserved namespace for the persisting-memory preference: `{ enabled }` —
|
|
58
|
+
* app-level, not a provider. This is the desktop's counterpart to the CLI's
|
|
59
|
+
* `memoryPersist` config key, and the gate `lib/sync/persist-gate.js` reads
|
|
60
|
+
* before any automatic cloud push. Default is ON (an absent namespace means
|
|
61
|
+
* persistence is on, which is the shipped decision); an explicit `false` is
|
|
62
|
+
* what turns it off. Nothing secret lives here, so no encryption. */
|
|
63
|
+
const MEMORY_PERSIST_NAMESPACE = '__memoryPersist';
|
|
64
|
+
|
|
57
65
|
/** Namespaces the provider-config surface must never see or mutate. */
|
|
58
66
|
const RESERVED_NAMESPACES = Object.freeze([
|
|
59
67
|
AEGIS_KEY_NAMESPACE,
|
|
60
68
|
LEGACY_AEGIS_NAMESPACE,
|
|
61
69
|
QUICK_LAUNCHER_NAMESPACE,
|
|
62
70
|
CONFIRM_MODE_NAMESPACE,
|
|
71
|
+
MEMORY_PERSIST_NAMESPACE,
|
|
63
72
|
]);
|
|
64
73
|
|
|
65
74
|
/** True for the AEGIS-key namespace(s) — provider CRUD must refuse these. */
|
|
@@ -282,6 +291,36 @@ function createSettingsStore({ dir, safeStorage } = {}) {
|
|
|
282
291
|
return getConfirmMode();
|
|
283
292
|
}
|
|
284
293
|
|
|
294
|
+
// --- Persisting memory: reserved namespace, plain preference -------------
|
|
295
|
+
// The desktop's half of "persisting memory for every account": whether a
|
|
296
|
+
// finished turn is pushed to cloud memory automatically. ON when unset —
|
|
297
|
+
// an install that never touched the toggle behaves like cli/, whose
|
|
298
|
+
// `memoryPersist` also defaults to on. `memoryPersistState()` reports the
|
|
299
|
+
// SOURCE as well as the value so "off" and "never set" stay distinguishable;
|
|
300
|
+
// lib/sync/persist-gate.js reads the same namespace straight off disk, and
|
|
301
|
+
// the two must agree on the default.
|
|
302
|
+
|
|
303
|
+
function getMemoryPersist() {
|
|
304
|
+
const cfg = load()[MEMORY_PERSIST_NAMESPACE] || {};
|
|
305
|
+
return cfg.enabled === undefined ? true : Boolean(cfg.enabled);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** `{ enabled, source }` — mirrors cli/src/cloudsync.js memoryPersistState(). */
|
|
309
|
+
function memoryPersistState() {
|
|
310
|
+
const cfg = load()[MEMORY_PERSIST_NAMESPACE] || {};
|
|
311
|
+
return {
|
|
312
|
+
enabled: getMemoryPersist(),
|
|
313
|
+
source: cfg.enabled === undefined ? 'default' : 'config',
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function setMemoryPersist(enabled) {
|
|
318
|
+
const data = load();
|
|
319
|
+
data[MEMORY_PERSIST_NAMESPACE] = { enabled: Boolean(enabled) };
|
|
320
|
+
save(data);
|
|
321
|
+
return memoryPersistState();
|
|
322
|
+
}
|
|
323
|
+
|
|
285
324
|
return {
|
|
286
325
|
file,
|
|
287
326
|
get,
|
|
@@ -299,6 +338,9 @@ function createSettingsStore({ dir, safeStorage } = {}) {
|
|
|
299
338
|
setQuickLauncherConfig,
|
|
300
339
|
getConfirmMode,
|
|
301
340
|
setConfirmMode,
|
|
341
|
+
getMemoryPersist,
|
|
342
|
+
memoryPersistState,
|
|
343
|
+
setMemoryPersist,
|
|
302
344
|
};
|
|
303
345
|
}
|
|
304
346
|
|
|
@@ -308,6 +350,7 @@ module.exports = {
|
|
|
308
350
|
LEGACY_AEGIS_NAMESPACE,
|
|
309
351
|
QUICK_LAUNCHER_NAMESPACE,
|
|
310
352
|
CONFIRM_MODE_NAMESPACE,
|
|
353
|
+
MEMORY_PERSIST_NAMESPACE,
|
|
311
354
|
DEFAULT_QUICK_LAUNCHER_SHORTCUT,
|
|
312
355
|
RESERVED_NAMESPACES,
|
|
313
356
|
isReservedNamespace,
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* persist-gate.js — the desktop half of "persisting memory for every account".
|
|
5
|
+
*
|
|
6
|
+
* `cli/` flips this default in config.json (`memoryPersist`, migrated off the
|
|
7
|
+
* legacy `cloudSync` key). The desktop has no config.json, so its preference
|
|
8
|
+
* lives in the same settings.json the Settings pane already writes, under the
|
|
9
|
+
* reserved `__memoryPersist` namespace — and it is read HERE, from disk, by the
|
|
10
|
+
* main process that performs the push.
|
|
11
|
+
*
|
|
12
|
+
* Two reasons the gate is not taken from the renderer:
|
|
13
|
+
*
|
|
14
|
+
* 1. The decision has to be enforceable in the main process whatever the
|
|
15
|
+
* renderer believes. A renderer-held flag is a suggestion; the process
|
|
16
|
+
* that opens the socket decides.
|
|
17
|
+
* 2. `createSyncDispatch()` / createAutoPush() are unit-tested in plain Node,
|
|
18
|
+
* where no renderer and no Electron exist.
|
|
19
|
+
*
|
|
20
|
+
* Default is ON, matching cli/. `source` keeps "the user turned this off" and
|
|
21
|
+
* "nobody has been asked yet" tellable apart — the whole point of shipping a
|
|
22
|
+
* default is that the absence of a stored key is not the same as a stored no.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
|
|
28
|
+
const { MEMORY_PERSIST_NAMESPACE, SETTINGS_FILE } = require('../settings.js');
|
|
29
|
+
|
|
30
|
+
/** The settings file the store writes (`createSettingsStore` uses the same
|
|
31
|
+
* `path.join(dir, SETTINGS_FILE)`, so both agree on one source of truth).
|
|
32
|
+
* A caller that already holds the file path may pass it directly. */
|
|
33
|
+
function settingsFile(dir) {
|
|
34
|
+
const p = dir || '';
|
|
35
|
+
return p.endsWith(SETTINGS_FILE) ? p : path.join(p, SETTINGS_FILE);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readSettings(dir) {
|
|
39
|
+
try {
|
|
40
|
+
const data = JSON.parse(fs.readFileSync(settingsFile(dir), 'utf8'));
|
|
41
|
+
return data && typeof data === 'object' ? data : {};
|
|
42
|
+
} catch {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `{ enabled, source }`. `source` is 'config' only when the file actually
|
|
49
|
+
* carries the key — an explicit `false` survives, an absent namespace is ON.
|
|
50
|
+
*/
|
|
51
|
+
function gateState(dir) {
|
|
52
|
+
const cfg = readSettings(dir)[MEMORY_PERSIST_NAMESPACE];
|
|
53
|
+
if (!cfg || cfg.enabled === undefined) return { enabled: true, source: 'default' };
|
|
54
|
+
return { enabled: Boolean(cfg.enabled), source: 'config' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The post-turn push, gated.
|
|
59
|
+
*
|
|
60
|
+
* `push` is `createSyncDispatch`'s own drain, so this inherits the queue
|
|
61
|
+
* fallback for free: a push flushes `memory-queue.json` before it touches the
|
|
62
|
+
* session list, which is exactly the "offline now, sync later" behaviour the
|
|
63
|
+
* manual button had and the automatic path must not lose.
|
|
64
|
+
*
|
|
65
|
+
* Coalesced: a turn that ends while a drain is still in flight does not open a
|
|
66
|
+
* second one. `push()` snapshots `sessions.listPending(dir)`, so a concurrent
|
|
67
|
+
* call would race the same rows; the newest session instead goes out on the
|
|
68
|
+
* next trigger (the following turn's auto push, or "Sync now"). One turn of
|
|
69
|
+
* delay is the correct trade against two writers marking the same session
|
|
70
|
+
* synced.
|
|
71
|
+
*/
|
|
72
|
+
function createAutoPush({ push, dir, gate } = {}) {
|
|
73
|
+
if (typeof push !== 'function') throw new Error('createAutoPush requires a push()');
|
|
74
|
+
const readGate = typeof gate === 'function' ? gate : () => gateState(dir);
|
|
75
|
+
let inFlight = null;
|
|
76
|
+
|
|
77
|
+
async function run() {
|
|
78
|
+
const state = readGate();
|
|
79
|
+
if (!state.enabled) {
|
|
80
|
+
return {
|
|
81
|
+
ok: true,
|
|
82
|
+
skipped: true,
|
|
83
|
+
gate: state,
|
|
84
|
+
reason: 'persisting memory is off',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const result = await push();
|
|
89
|
+
return Object.assign({}, result, { skipped: false, gate: state });
|
|
90
|
+
} catch (err) {
|
|
91
|
+
// A chat turn must never fail because a background push did. `push()`
|
|
92
|
+
// already resolves `{ ok:false, … }` for its expected failures; this
|
|
93
|
+
// catches the unexpected so the caller's fire-and-forget cannot become
|
|
94
|
+
// an unhandled rejection.
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
skipped: false,
|
|
98
|
+
gate: state,
|
|
99
|
+
reason: (err && err.message) || String(err),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function auto() {
|
|
105
|
+
if (inFlight) return inFlight;
|
|
106
|
+
inFlight = run().finally(() => {
|
|
107
|
+
inFlight = null;
|
|
108
|
+
});
|
|
109
|
+
return inFlight;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { auto, gate: () => readGate(), state: () => readGate() };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
settingsFile,
|
|
117
|
+
readSettings,
|
|
118
|
+
gateState,
|
|
119
|
+
createAutoPush,
|
|
120
|
+
};
|
package/main.js
CHANGED
|
@@ -66,6 +66,7 @@ const ollama = require('./lib/local/ollama.js');
|
|
|
66
66
|
const providers = require('./lib/local/providers.js');
|
|
67
67
|
const sessionStore = require('./lib/sync/sessions.js');
|
|
68
68
|
const memoryQueue = require('./lib/sync/memory-queue.js');
|
|
69
|
+
const persistGate = require('./lib/sync/persist-gate.js');
|
|
69
70
|
const windowState = require('./lib/window-state.js');
|
|
70
71
|
const deepLink = require('./lib/deep-link.js');
|
|
71
72
|
const quickLauncherLib = require('./lib/quick-launcher.js');
|
|
@@ -829,6 +830,62 @@ function createEngine(aegis, { app, safeStorage, dir: dirOverride, sessionsDir:
|
|
|
829
830
|
return { engine, sessionsDir, settingsDir, settings };
|
|
830
831
|
}
|
|
831
832
|
|
|
833
|
+
/** Largest pre-edit read the diff bridge will hand the renderer (bytes). */
|
|
834
|
+
const PREVIEW_READ_MAX_BYTES = 512 * 1024;
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* The contained, capped read behind the renderer's diff preview — the only
|
|
838
|
+
* filesystem capability the renderer has, and the narrowest form of it.
|
|
839
|
+
*
|
|
840
|
+
* The renderer supplies a file plus the cwd of the session it is displaying.
|
|
841
|
+
* Every path is resolved with `realpath` *before* the containment test, so a
|
|
842
|
+
* symlink planted inside the workspace cannot point the read outside it, and
|
|
843
|
+
* containment is judged with `path.relative` rather than a `startsWith` prefix
|
|
844
|
+
* (which would let `/home/neo-evil` pass a naive `/home/neo` check). Anything
|
|
845
|
+
* refused — relative cwd, escape, missing file, directory, non-regular file —
|
|
846
|
+
* returns null, which degrades the preview to a create-style diff instead of
|
|
847
|
+
* throwing onto the render path.
|
|
848
|
+
*
|
|
849
|
+
* @returns {{text: string, truncated: boolean}|null}
|
|
850
|
+
*/
|
|
851
|
+
function readTextFileForPreview(payload) {
|
|
852
|
+
try {
|
|
853
|
+
const file = payload && typeof payload.file === 'string' ? payload.file : '';
|
|
854
|
+
const cwd = payload && typeof payload.cwd === 'string' ? payload.cwd : '';
|
|
855
|
+
if (!file || !cwd || !path.isAbsolute(cwd)) return null;
|
|
856
|
+
|
|
857
|
+
// realpath both sides: resolving the root too keeps this correct on hosts
|
|
858
|
+
// where the workspace itself is reached through a symlink (/tmp, /var).
|
|
859
|
+
const root = fs.realpathSync(cwd);
|
|
860
|
+
const abs = fs.realpathSync(path.resolve(root, file));
|
|
861
|
+
const rel = path.relative(root, abs);
|
|
862
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
863
|
+
|
|
864
|
+
const st = fs.statSync(abs);
|
|
865
|
+
if (!st.isFile()) return null;
|
|
866
|
+
|
|
867
|
+
if (st.size <= PREVIEW_READ_MAX_BYTES) {
|
|
868
|
+
return { text: fs.readFileSync(abs, 'utf8'), truncated: false };
|
|
869
|
+
}
|
|
870
|
+
// Oversized: read only the head of the file and cut back to the last
|
|
871
|
+
// newline, so a byte-boundary truncation cannot leave a half-decoded
|
|
872
|
+
// multi-byte sequence at the end of the preview.
|
|
873
|
+
const fd = fs.openSync(abs, 'r');
|
|
874
|
+
try {
|
|
875
|
+
const buf = Buffer.alloc(PREVIEW_READ_MAX_BYTES);
|
|
876
|
+
const n = fs.readSync(fd, buf, 0, PREVIEW_READ_MAX_BYTES, 0);
|
|
877
|
+
let text = buf.toString('utf8', 0, n);
|
|
878
|
+
const nl = text.lastIndexOf('\n');
|
|
879
|
+
if (nl > 0) text = text.slice(0, nl + 1);
|
|
880
|
+
return { text, truncated: true };
|
|
881
|
+
} finally {
|
|
882
|
+
fs.closeSync(fd);
|
|
883
|
+
}
|
|
884
|
+
} catch {
|
|
885
|
+
return null;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
832
889
|
/**
|
|
833
890
|
* Pure mapping: model:<name> -> LocalEngine call (unit-testable in Node).
|
|
834
891
|
*/
|
|
@@ -849,6 +906,14 @@ function createModelDispatch(engine) {
|
|
|
849
906
|
}),
|
|
850
907
|
'settings.remove': (payload) =>
|
|
851
908
|
engine.settings.remove(payload && payload.provider),
|
|
909
|
+
// Persisting memory ("sync finished turns to cloud memory automatically").
|
|
910
|
+
// Reserved namespace, so it is invisible to the provider CRUD above — but
|
|
911
|
+
// unlike a provider it IS reachable deliberately, through these two. The
|
|
912
|
+
// gate that enforces it lives in lib/sync/persist-gate.js and reads the
|
|
913
|
+
// same file, so a `false` written here stops the next automatic push.
|
|
914
|
+
'memoryPersist.get': () => engine.settings.memoryPersistState(),
|
|
915
|
+
'memoryPersist.set': (payload) =>
|
|
916
|
+
engine.settings.setMemoryPersist(Boolean(payload && payload.enabled)),
|
|
852
917
|
cancel: (payload) => engine.cancel(payload && payload.sessionId),
|
|
853
918
|
// Tool-call approval gate (desktop/lib/local/engine.js gatedExecuteTool):
|
|
854
919
|
// the renderer's approval card answers a pending exec/writeFile/editFile
|
|
@@ -869,7 +934,7 @@ function createModelDispatch(engine) {
|
|
|
869
934
|
* resolve `{ ok: false, reason }` without ever throwing, and every local
|
|
870
935
|
* flow (save/append/list) keeps working untouched.
|
|
871
936
|
*/
|
|
872
|
-
function createSyncDispatch(sessions, dir, aegis) {
|
|
937
|
+
function createSyncDispatch(sessions, dir, aegis, settingsDir) {
|
|
873
938
|
let lastSyncAt = null;
|
|
874
939
|
|
|
875
940
|
function hasCloud() {
|
|
@@ -1013,7 +1078,20 @@ function createSyncDispatch(sessions, dir, aegis) {
|
|
|
1013
1078
|
}
|
|
1014
1079
|
}
|
|
1015
1080
|
|
|
1081
|
+
// The automatic post-turn push (persisting memory for every account). Gated
|
|
1082
|
+
// by the `__memoryPersist` preference in settings.json, which the Settings
|
|
1083
|
+
// pane writes through the memoryPersist:* IPC below; the gate is read from
|
|
1084
|
+
// disk in the main process so the renderer cannot push past it. This must
|
|
1085
|
+
// read `settingsDir` (Electron's userData path), NOT `dir` — `dir` here is
|
|
1086
|
+
// the shared aegisHome() sessions/credentials directory, a different path
|
|
1087
|
+
// from userData in a real install, and the settings store is created with
|
|
1088
|
+
// `settingsDir`. Falling back to `dir` only covers callers (tests) that
|
|
1089
|
+
// never pass `settingsDir` and use one directory for everything.
|
|
1090
|
+
const autoPush = persistGate.createAutoPush({ push, dir: settingsDir || dir });
|
|
1091
|
+
|
|
1016
1092
|
return {
|
|
1093
|
+
auto: () => autoPush.auto(),
|
|
1094
|
+
memoryPersistState: () => autoPush.gate(),
|
|
1017
1095
|
listSessions: () => ({ sessions: sessions.listSessions(dir) }),
|
|
1018
1096
|
open: (payload) => sessions.getSession(dir, payload && payload.sessionId),
|
|
1019
1097
|
save: (payload) => sessions.upsertSession(dir, payload || {}),
|
|
@@ -1135,11 +1213,16 @@ function createHeartbeatRetry(push, opts = {}) {
|
|
|
1135
1213
|
* `onReplyFinished`, like the identical param on registerIpc, is
|
|
1136
1214
|
* bootstrap()-only — it fires the native "reply ready" notification when the
|
|
1137
1215
|
* window is unfocused/hidden and is never set in the headless test path.
|
|
1138
|
-
* `heartbeatOpts` ({ now, baseMs, maxMs }) is for tests only.
|
|
1216
|
+
* `heartbeatOpts` ({ now, baseMs, maxMs }) is for tests only. `settingsDir`
|
|
1217
|
+
* is Electron's userData path — where settings.json (and so the
|
|
1218
|
+
* `__memoryPersist` preference the auto-push gate reads) actually lives;
|
|
1219
|
+
* `sessionsDir` is the separate shared aegisHome() directory and must not be
|
|
1220
|
+
* used for that gate. Falls back to `sessionsDir` for callers that don't pass
|
|
1221
|
+
* it, i.e. tests that use one directory for everything.
|
|
1139
1222
|
*/
|
|
1140
|
-
function registerModelIpc(ipcMain, engine, sessionsDir, aegis, onReplyFinished, heartbeatOpts) {
|
|
1223
|
+
function registerModelIpc(ipcMain, engine, sessionsDir, aegis, onReplyFinished, heartbeatOpts, settingsDir) {
|
|
1141
1224
|
const modelDispatch = createModelDispatch(engine);
|
|
1142
|
-
const syncDispatch = createSyncDispatch(sessionStore, sessionsDir, aegis);
|
|
1225
|
+
const syncDispatch = createSyncDispatch(sessionStore, sessionsDir, aegis, settingsDir);
|
|
1143
1226
|
|
|
1144
1227
|
const heartbeat = createHeartbeatRetry(() => syncDispatch.push(), heartbeatOpts);
|
|
1145
1228
|
|
|
@@ -1187,6 +1270,17 @@ function registerModelIpc(ipcMain, engine, sessionsDir, aegis, onReplyFinished,
|
|
|
1187
1270
|
ipcMain.handle(`${MODEL_PREFIX}${name}`, (_event, payload) => handler(payload));
|
|
1188
1271
|
}
|
|
1189
1272
|
|
|
1273
|
+
// Diff-preview pre-edit read (preload.js `readTextFile`). Registered with
|
|
1274
|
+
// `on` + `event.returnValue`, NOT `handle`: the bridge is `sendSync` because
|
|
1275
|
+
// editPreview consumes its reader synchronously and cannot await an invoke
|
|
1276
|
+
// promise, and `sendSync` is answered only by an `ipcMain.on` listener.
|
|
1277
|
+
// Putting it in modelDispatch above would register a `handle` that sendSync
|
|
1278
|
+
// never reaches. Fires on every edit tool's run frame, so the refusal path
|
|
1279
|
+
// is the common case (a `writeFile` creating a new file) and must stay cheap.
|
|
1280
|
+
ipcMain.on(`${MODEL_PREFIX}readTextFile`, (event, payload) => {
|
|
1281
|
+
event.returnValue = readTextFileForPreview(payload);
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1190
1284
|
for (const [name, handler] of Object.entries(syncDispatch)) {
|
|
1191
1285
|
if (name === 'status') {
|
|
1192
1286
|
ipcMain.handle(`${SYNC_PREFIX}${name}`, (_event, payload) => {
|
|
@@ -1357,6 +1451,11 @@ function createQueueDispatch({
|
|
|
1357
1451
|
draining,
|
|
1358
1452
|
stopping: stopRequested,
|
|
1359
1453
|
defaultCwd: process.cwd(),
|
|
1454
|
+
// What a task with no model of its own actually runs on. The queue is
|
|
1455
|
+
// Aegis Cloud only, so this is a pooled tier id — and the card prints it,
|
|
1456
|
+
// because "which model is this drain spending on" was previously
|
|
1457
|
+
// unanswerable from the UI.
|
|
1458
|
+
defaultModel: autonomous.resolveModel({ env }),
|
|
1360
1459
|
queueFile: queue.queuePath(env),
|
|
1361
1460
|
runs: queue.readRuns(env, { limit: 20 }),
|
|
1362
1461
|
};
|
|
@@ -1385,6 +1484,13 @@ function createQueueDispatch({
|
|
|
1385
1484
|
if (!isExistingDir(cwd)) return { error: `not a directory: ${cwd}` };
|
|
1386
1485
|
|
|
1387
1486
|
const model = String(p.model == null ? '' : p.model).trim().slice(0, 120) || null;
|
|
1487
|
+
// Aegis Cloud only (autonomous.js modelRefusal): the worker's requests go
|
|
1488
|
+
// out as `class: 'aegis'`, so a model the pool does not serve is a task that
|
|
1489
|
+
// fails inside a drain. Refused HERE so the card gets the reason at the
|
|
1490
|
+
// moment of queueing instead of a red row later. queue.addTask enforces the
|
|
1491
|
+
// same rule for the CLI and for hand-written queue files.
|
|
1492
|
+
const modelRefusal = model ? autonomous.modelRefusal(model) : '';
|
|
1493
|
+
if (modelRefusal) return { error: modelRefusal };
|
|
1388
1494
|
const effortRaw = String(p.effort == null ? '' : p.effort).trim().toLowerCase();
|
|
1389
1495
|
return {
|
|
1390
1496
|
opts: {
|
|
@@ -1394,6 +1500,12 @@ function createQueueDispatch({
|
|
|
1394
1500
|
effort: QUEUE_EFFORTS.has(effortRaw) ? effortRaw : null,
|
|
1395
1501
|
workers: clampInt(p.workers, 1, QUEUE_MAX_WORKERS),
|
|
1396
1502
|
maxRounds: clampInt(p.maxRounds, 1, QUEUE_MAX_ROUNDS),
|
|
1503
|
+
// The pooled-brain fan-out is opt-in (autonomous.js resolveFanout):
|
|
1504
|
+
// it costs about workers+1 full reasoning calls on one task, so the
|
|
1505
|
+
// card has to ask for it per task. queue.addTask stores the inverse
|
|
1506
|
+
// (`singlePass`), which is also the field a hand-written queue file
|
|
1507
|
+
// or the CLI uses.
|
|
1508
|
+
singlePass: !(p.fanout === true || p.autonomous === true),
|
|
1397
1509
|
commit: Boolean(p.commit),
|
|
1398
1510
|
source: 'desktop',
|
|
1399
1511
|
},
|
|
@@ -2163,7 +2275,7 @@ function bootstrap() {
|
|
|
2163
2275
|
// One settings store backs both the provider settings surface and the AEGIS
|
|
2164
2276
|
// API key entry. It lives only in the main process and encrypts keys at rest
|
|
2165
2277
|
// via Electron safeStorage (best-effort base64 when unavailable).
|
|
2166
|
-
const { engine, sessionsDir, settings } = createEngine(aegis, {
|
|
2278
|
+
const { engine, sessionsDir, settingsDir, settings } = createEngine(aegis, {
|
|
2167
2279
|
app,
|
|
2168
2280
|
safeStorage,
|
|
2169
2281
|
dir: dataDir,
|
|
@@ -2215,7 +2327,15 @@ function bootstrap() {
|
|
|
2215
2327
|
(url) => shell.openExternal(url),
|
|
2216
2328
|
notifyReplyIfUnfocused
|
|
2217
2329
|
);
|
|
2218
|
-
registerModelIpc(
|
|
2330
|
+
registerModelIpc(
|
|
2331
|
+
ipcMain,
|
|
2332
|
+
engine,
|
|
2333
|
+
sessionsDir,
|
|
2334
|
+
aegis,
|
|
2335
|
+
notifyReplyIfUnfocused,
|
|
2336
|
+
undefined,
|
|
2337
|
+
settingsDir
|
|
2338
|
+
);
|
|
2219
2339
|
|
|
2220
2340
|
// Tool-call approval toggle (Settings → "Confirm before running tools"):
|
|
2221
2341
|
// aegis:getConfirmMode / aegis:setConfirmMode. Registered here because this
|
|
@@ -2593,6 +2713,8 @@ module.exports = {
|
|
|
2593
2713
|
taggedChunk,
|
|
2594
2714
|
maskKey,
|
|
2595
2715
|
createModelDispatch,
|
|
2716
|
+
readTextFileForPreview,
|
|
2717
|
+
PREVIEW_READ_MAX_BYTES,
|
|
2596
2718
|
createSyncDispatch,
|
|
2597
2719
|
registerModelIpc,
|
|
2598
2720
|
createHeartbeatRetry,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegis-desktop",
|
|
3
3
|
"productName": "AEGIS Desktop",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.0",
|
|
5
5
|
"description": "Thin Electron host for AEGIS — a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "AEGIS Code",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"icon": "node scripts/generate-icon.mjs",
|
|
30
30
|
"dist": "npm run predist && electron-builder",
|
|
31
31
|
"dist:dir": "npm run predist && electron-builder --dir",
|
|
32
|
-
"check": "node --check main.js && node --check preload.js && node --check renderer/app.js && node --check renderer/quick.js && node --check renderer/budget.js && node --check renderer/usage.js && node --check renderer/stream-policy.js && node --check renderer/transcript-view.js && node --check renderer/markdown.js && node --check renderer/vendor/aegis-highlight.js && node --check scripts/predist.mjs && node --check scripts/generate-icon.mjs && node --check lib/local/context.js && node --check lib/local/providers.js && node --check lib/local/ollama.js && node --check lib/local/engine.js && node --check lib/local/tools.js && node --check lib/local/prompt.js && node --check lib/local/shell.js && node --check lib/local/agents.js && node --check lib/local/queue.js && node --check lib/local/autonomous.js && node --check lib/settings.js && node --check lib/sync/sessions.js && node --check lib/sync/memory-queue.js && node --check lib/window-state.js && node --check lib/deep-link.js && node --check lib/quick-launcher.js && node --check bin/aegis.js",
|
|
32
|
+
"check": "node --check main.js && node --check preload.js && node --check renderer/app.js && node --check renderer/quick.js && node --check renderer/budget.js && node --check renderer/usage.js && node --check renderer/stream-policy.js && node --check renderer/transcript-view.js && node --check renderer/diff.js && node --check renderer/diffview.js && node --check renderer/markdown.js && node --check renderer/vendor/aegis-highlight.js && node --check scripts/predist.mjs && node --check scripts/generate-icon.mjs && node --check lib/local/context.js && node --check lib/local/providers.js && node --check lib/local/ollama.js && node --check lib/local/engine.js && node --check lib/local/session-rounds.js && node --check lib/local/tools.js && node --check lib/local/prompt.js && node --check lib/local/shell.js && node --check lib/local/agents.js && node --check lib/local/queue.js && node --check lib/local/autonomous.js && node --check lib/settings.js && node --check lib/sync/sessions.js && node --check lib/sync/memory-queue.js && node --check lib/window-state.js && node --check lib/deep-link.js && node --check lib/quick-launcher.js && node --check bin/aegis.js",
|
|
33
33
|
"test:shell": "node ../test/desktop-shell.mjs",
|
|
34
34
|
"test:model": "node ../test/model-dispatch.mjs",
|
|
35
35
|
"test:budget": "node ../test/budget.test.mjs",
|
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
"test:highlight": "node ../test/aegis-highlight.test.mjs",
|
|
40
40
|
"test:markdown": "node ../test/markdown.test.mjs",
|
|
41
41
|
"test:deep-link": "node test/deep-link.test.mjs",
|
|
42
|
+
"test:renderer-diff": "node test/renderer-diff.test.mjs",
|
|
43
|
+
"test:preview-read": "node ../test/desktop-preview-read.test.mjs",
|
|
42
44
|
"test:stream-policy": "node ../test/stream-policy.test.mjs",
|
|
43
45
|
"test:renderer-dom": "node ../test/renderer-dom.test.mjs",
|
|
44
46
|
"test:renderer-wiring": "node ../test/renderer-wiring.test.mjs"
|