aegis-desktop 0.6.1 → 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 +45 -4
- package/lib/local/engine.js +38 -0
- package/lib/local/queue.js +4 -0
- package/lib/local/session-rounds.js +19 -2
- package/lib/settings.js +43 -0
- package/lib/sync/persist-gate.js +120 -0
- package/main.js +110 -6
- package/package.json +4 -2
- package/preload.js +31 -0
- package/renderer/app.js +107 -3
- package/renderer/diff.js +356 -0
- package/renderer/diffview.js +333 -0
- package/renderer/index.html +2 -0
- package/renderer/style.css +70 -0
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) => {
|
|
@@ -2181,7 +2275,7 @@ function bootstrap() {
|
|
|
2181
2275
|
// One settings store backs both the provider settings surface and the AEGIS
|
|
2182
2276
|
// API key entry. It lives only in the main process and encrypts keys at rest
|
|
2183
2277
|
// via Electron safeStorage (best-effort base64 when unavailable).
|
|
2184
|
-
const { engine, sessionsDir, settings } = createEngine(aegis, {
|
|
2278
|
+
const { engine, sessionsDir, settingsDir, settings } = createEngine(aegis, {
|
|
2185
2279
|
app,
|
|
2186
2280
|
safeStorage,
|
|
2187
2281
|
dir: dataDir,
|
|
@@ -2233,7 +2327,15 @@ function bootstrap() {
|
|
|
2233
2327
|
(url) => shell.openExternal(url),
|
|
2234
2328
|
notifyReplyIfUnfocused
|
|
2235
2329
|
);
|
|
2236
|
-
registerModelIpc(
|
|
2330
|
+
registerModelIpc(
|
|
2331
|
+
ipcMain,
|
|
2332
|
+
engine,
|
|
2333
|
+
sessionsDir,
|
|
2334
|
+
aegis,
|
|
2335
|
+
notifyReplyIfUnfocused,
|
|
2336
|
+
undefined,
|
|
2337
|
+
settingsDir
|
|
2338
|
+
);
|
|
2237
2339
|
|
|
2238
2340
|
// Tool-call approval toggle (Settings → "Confirm before running tools"):
|
|
2239
2341
|
// aegis:getConfirmMode / aegis:setConfirmMode. Registered here because this
|
|
@@ -2611,6 +2713,8 @@ module.exports = {
|
|
|
2611
2713
|
taggedChunk,
|
|
2612
2714
|
maskKey,
|
|
2613
2715
|
createModelDispatch,
|
|
2716
|
+
readTextFileForPreview,
|
|
2717
|
+
PREVIEW_READ_MAX_BYTES,
|
|
2614
2718
|
createSyncDispatch,
|
|
2615
2719
|
registerModelIpc,
|
|
2616
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/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",
|
|
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"
|
package/preload.js
CHANGED
|
@@ -102,6 +102,26 @@ const api = {
|
|
|
102
102
|
billingCheckout: () => invoke('billingCheckout'),
|
|
103
103
|
tokenBankTopup: (amountEur) => invoke('tokenBankTopup', { amountEur }),
|
|
104
104
|
listModels: () => invoke('listModels'),
|
|
105
|
+
// Pre-edit file read for the diff preview (renderer/deriveDiff). A Write's
|
|
106
|
+
// preview needs the file's contents as they were *before* the executor
|
|
107
|
+
// overwrites them, and editPreview consumes a reader synchronously — so this
|
|
108
|
+
// one method is sentSync, not invoke. It is the narrowest possible widening
|
|
109
|
+
// of the bridge: main constrains the path to inside `cwd` and caps the read
|
|
110
|
+
// (main.js readTextFileForPreview), so the renderer can only ever reach files
|
|
111
|
+
// the session it is displaying is already allowed to edit. Returns
|
|
112
|
+
// `{ text, truncated }` or null; a bridge failure reports null rather than
|
|
113
|
+
// throwing onto the render path, which degrades the diff to a create-style
|
|
114
|
+
// "all additions" block.
|
|
115
|
+
readTextFile: (file, cwd) => {
|
|
116
|
+
try {
|
|
117
|
+
return ipcRenderer.sendSync(MODEL_PREFIX + 'readTextFile', {
|
|
118
|
+
file,
|
|
119
|
+
cwd,
|
|
120
|
+
});
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
},
|
|
105
125
|
// Streaming chat (D2.1): pass an onDelta callback to receive SSE chunks as
|
|
106
126
|
// they arrive (pushed from main over aegis:chatDelta). The invoke promise
|
|
107
127
|
// resolves once with the normalised final result. Without a callback the
|
|
@@ -149,6 +169,11 @@ const api = {
|
|
|
149
169
|
// call. Both resolve `{ enabled }`.
|
|
150
170
|
getConfirmMode: () => invoke('getConfirmMode'),
|
|
151
171
|
setConfirmMode: (enabled) => invoke('setConfirmMode', { enabled: Boolean(enabled) }),
|
|
172
|
+
// Persisting memory (the desktop counterpart of the CLI's `memoryPersist`):
|
|
173
|
+
// whether a finished turn is pushed to cloud memory automatically. Default
|
|
174
|
+
// ON; `source` distinguishes a stored preference from that default.
|
|
175
|
+
getMemoryPersist: () => invoke('memoryPersist.get'),
|
|
176
|
+
setMemoryPersist: (enabled) => invoke('memoryPersist.set', { enabled: Boolean(enabled) }),
|
|
152
177
|
|
|
153
178
|
// Auto-update (electron-updater over GitHub Releases — see main.js
|
|
154
179
|
// createUpdateManager). check/download resolve the same status shape the
|
|
@@ -276,6 +301,12 @@ const sync = {
|
|
|
276
301
|
push: () => invokeSync('push'),
|
|
277
302
|
pull: () => invokeSync('pull'),
|
|
278
303
|
status: () => invokeSync('status'),
|
|
304
|
+
// The automatic post-turn push ("persisting memory"). Gate-checked in main
|
|
305
|
+
// against the `__memoryPersist` preference, so calling this on every turn is
|
|
306
|
+
// cheap when persistence is off — it returns `{ skipped: true }` without
|
|
307
|
+
// opening a request. `memoryPersistState()` reports the gate itself.
|
|
308
|
+
auto: () => invokeSync('auto'),
|
|
309
|
+
memoryPersistState: () => invokeSync('memoryPersistState'),
|
|
279
310
|
};
|
|
280
311
|
|
|
281
312
|
// Quick launcher surface: loaded by BOTH renderer/index.html (the settings
|
package/renderer/app.js
CHANGED
|
@@ -1940,7 +1940,7 @@ async function spawnPath(card, spec) {
|
|
|
1940
1940
|
// tool executes. This line is retrospective by design (see
|
|
1941
1941
|
// toolActivityLabel), so acting on the run frame too would print every
|
|
1942
1942
|
// tool twice — once when it starts, once when it finishes.
|
|
1943
|
-
if (chunk.tool.phase === 'run') return;
|
|
1943
|
+
if (chunk.tool.phase === 'run') { captureDiffPreview(chunk.tool); return; }
|
|
1944
1944
|
appendToolActivity(card, chunk.tool, 'flow-tools', '.flow-body');
|
|
1945
1945
|
return;
|
|
1946
1946
|
}
|
|
@@ -2116,6 +2116,54 @@ function setBusy(busy, { cancellable } = {}) {
|
|
|
2116
2116
|
*/
|
|
2117
2117
|
const TOOL_LABEL_MAX = 72;
|
|
2118
2118
|
|
|
2119
|
+
/**
|
|
2120
|
+
* Host tool names whose calls render as a collapsible diff block instead of a
|
|
2121
|
+
* bare line. The engine's own spellings (`Edit`/`Write`/`MultiEdit`) are
|
|
2122
|
+
* included so a frame from a newer engine renders the same rather than falling
|
|
2123
|
+
* back to a plain row.
|
|
2124
|
+
*/
|
|
2125
|
+
const EDIT_TOOL_NAMES = new Set(['editFile', 'writeFile', 'Edit', 'Write', 'MultiEdit']);
|
|
2126
|
+
|
|
2127
|
+
/**
|
|
2128
|
+
* Diff previews captured on a tool's `phase: 'run'` frame, keyed by tool id.
|
|
2129
|
+
* The run frame fires strictly before the executor writes, which is the only
|
|
2130
|
+
* moment a `writeFile`'s pre-edit contents can still be read — so the preview
|
|
2131
|
+
* is built there and replayed when the matching `done` frame arrives.
|
|
2132
|
+
*/
|
|
2133
|
+
const toolPreviews = new Map();
|
|
2134
|
+
|
|
2135
|
+
/**
|
|
2136
|
+
* The pre-edit read a `writeFile`/`Write` preview needs. The renderer runs
|
|
2137
|
+
* sandboxed (main.js sets contextIsolation + sandbox, so there is no
|
|
2138
|
+
* `node:fs`), so the read goes over the preload bridge's synchronous
|
|
2139
|
+
* `readTextFile`. Main confines the path to the session cwd it is handed, so
|
|
2140
|
+
* `cwd` must travel with the call — without it main has nothing to contain
|
|
2141
|
+
* against and refuses, which degrades the write to a create-style diff (all
|
|
2142
|
+
* additions). `editFile` carries its own old_string and needs no read, so it
|
|
2143
|
+
* always renders a full diff regardless.
|
|
2144
|
+
*/
|
|
2145
|
+
function hostReadFile(file, cwd) {
|
|
2146
|
+
try {
|
|
2147
|
+
const bridge = typeof window !== 'undefined' ? window.aegis : null;
|
|
2148
|
+
if (bridge && typeof bridge.readTextFile === 'function') {
|
|
2149
|
+
return bridge.readTextFile(file, cwd);
|
|
2150
|
+
}
|
|
2151
|
+
} catch {
|
|
2152
|
+
// A bridge that throws must not break the render path.
|
|
2153
|
+
}
|
|
2154
|
+
return undefined;
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
/** Build and stash the diff preview for an edit tool when its run frame arrives. */
|
|
2158
|
+
function captureDiffPreview(tool) {
|
|
2159
|
+
if (!tool || !EDIT_TOOL_NAMES.has(tool.name) || tool.id == null) return;
|
|
2160
|
+
// editPreview calls readFile(file) with no cwd, so bind the frame's cwd here.
|
|
2161
|
+
const cwd = tool.cwd;
|
|
2162
|
+
const readFile = (file) => hostReadFile(file, cwd);
|
|
2163
|
+
const preview = editPreview(tool.name, tool.args, { cwd, readFile });
|
|
2164
|
+
if (preview) toolPreviews.set(tool.id, preview);
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2119
2167
|
/**
|
|
2120
2168
|
* One display line for a completed tool call (`onDelta`'s `{ tool: {name,
|
|
2121
2169
|
* args, ok} }` chunk — see desktop/lib/local/engine.js). Fires after the tool
|
|
@@ -2141,7 +2189,12 @@ function toolActivityLabel(tool) {
|
|
|
2141
2189
|
return `→ ${name}${shown ? ` ${shown}` : ''} ${mark}`;
|
|
2142
2190
|
}
|
|
2143
2191
|
|
|
2144
|
-
/**
|
|
2192
|
+
/**
|
|
2193
|
+
* Append one tool-activity line to `row`, creating the container on first use.
|
|
2194
|
+
* An edit tool whose preview was captured on its run frame renders as a
|
|
2195
|
+
* collapsible diff block in place of the bare line; every other tool keeps the
|
|
2196
|
+
* plain `→ name path ✓` row.
|
|
2197
|
+
*/
|
|
2145
2198
|
function appendToolActivity(row, tool, containerClass, beforeSelector) {
|
|
2146
2199
|
if (!row) return;
|
|
2147
2200
|
let toolsEl = row.querySelector(`.${containerClass}`);
|
|
@@ -2152,6 +2205,15 @@ function appendToolActivity(row, tool, containerClass, beforeSelector) {
|
|
|
2152
2205
|
if (before) row.insertBefore(toolsEl, before);
|
|
2153
2206
|
else row.appendChild(toolsEl);
|
|
2154
2207
|
}
|
|
2208
|
+
const preview = tool && tool.id != null ? toolPreviews.get(tool.id) : null;
|
|
2209
|
+
if (preview) {
|
|
2210
|
+
toolPreviews.delete(tool.id); // one-shot: a stale id must not replay on a retry
|
|
2211
|
+
const block = renderDiffBlock(preview, document);
|
|
2212
|
+
if (block) {
|
|
2213
|
+
toolsEl.appendChild(block);
|
|
2214
|
+
return toolsEl;
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2155
2217
|
const line = document.createElement('div');
|
|
2156
2218
|
line.textContent = toolActivityLabel(tool);
|
|
2157
2219
|
toolsEl.appendChild(line);
|
|
@@ -2695,6 +2757,41 @@ async function refreshSyncStatus() {
|
|
|
2695
2757
|
/** Explicit "Sync now" — push pending sessions, then pull remote ones.
|
|
2696
2758
|
* Offline-first: sync.push()/sync.pull() resolve `{ ok:false, reason }`
|
|
2697
2759
|
* rather than throwing, so this only hits the catch on an unexpected error. */
|
|
2760
|
+
/**
|
|
2761
|
+
* Persisting memory after a turn: ask main to push what just finished.
|
|
2762
|
+
*
|
|
2763
|
+
* Deliberately unawaited — the answer is already on screen and in the local
|
|
2764
|
+
* session store, so cloud memory is strictly extra and the composer must come
|
|
2765
|
+
* back the moment the answer lands, not after a round trip. The decision to
|
|
2766
|
+
* push at all is main's (lib/sync/persist-gate.js reads the `__memoryPersist`
|
|
2767
|
+
* preference off disk and returns `{ skipped: true }` when it is off); this is
|
|
2768
|
+
* a request, not a gate, which is why no preference is read here.
|
|
2769
|
+
*
|
|
2770
|
+
* It cannot throw — createAutoPush resolves on every failure — but the
|
|
2771
|
+
* `.catch` stays: an unhandled rejection in the renderer is how a background
|
|
2772
|
+
* convenience turns into a fatal.
|
|
2773
|
+
*/
|
|
2774
|
+
function autoPersistTurn() {
|
|
2775
|
+
if (typeof sync.auto !== 'function') return;
|
|
2776
|
+
sync
|
|
2777
|
+
.auto()
|
|
2778
|
+
.then((result) => {
|
|
2779
|
+
// Same notice as syncNow(): the cap is the one sync outcome the user
|
|
2780
|
+
// must see rather than have silently absorbed.
|
|
2781
|
+
if (result && result.upgrade) {
|
|
2782
|
+
capNotice(
|
|
2783
|
+
els.sessionsHint,
|
|
2784
|
+
result.upgrade,
|
|
2785
|
+
'queued memory is waiting on the free-plan cap',
|
|
2786
|
+
'Upgrade to sync it →'
|
|
2787
|
+
);
|
|
2788
|
+
}
|
|
2789
|
+
})
|
|
2790
|
+
.catch(() => {
|
|
2791
|
+
/* persistence is non-fatal */
|
|
2792
|
+
});
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2698
2795
|
async function syncNow() {
|
|
2699
2796
|
els.syncNow.disabled = true;
|
|
2700
2797
|
els.sessionsHint.textContent = 'syncing…';
|
|
@@ -2936,7 +3033,7 @@ async function send() {
|
|
|
2936
3033
|
// row. Ignored here, and ignored *before* `toolLog.push` — a run frame
|
|
2937
3034
|
// counted as a completed tool would inflate the turn summary's tool
|
|
2938
3035
|
// count for a call that hasn't run yet.
|
|
2939
|
-
if (chunk.tool.phase === 'run') return;
|
|
3036
|
+
if (chunk.tool.phase === 'run') { captureDiffPreview(chunk.tool); return; }
|
|
2940
3037
|
toolLog.push(chunk.tool);
|
|
2941
3038
|
const row = ensurePendingRow();
|
|
2942
3039
|
row.classList.remove('pending');
|
|
@@ -2985,6 +3082,9 @@ async function send() {
|
|
|
2985
3082
|
/* persistence is non-fatal */
|
|
2986
3083
|
}
|
|
2987
3084
|
|
|
3085
|
+
// Persisting memory: the finished turn goes to the cloud on its own now.
|
|
3086
|
+
autoPersistTurn();
|
|
3087
|
+
|
|
2988
3088
|
// The AI's second path: not awaited — the lane streams beside the thread
|
|
2989
3089
|
// while the composer goes straight back to the user (chat flow D2.2).
|
|
2990
3090
|
if (exploreEnabled() && text !== '(empty response)') {
|
|
@@ -3005,6 +3105,10 @@ async function send() {
|
|
|
3005
3105
|
} catch {
|
|
3006
3106
|
/* persistence is non-fatal */
|
|
3007
3107
|
}
|
|
3108
|
+
// A stopped turn is persisted too, and on purpose: this is the case a
|
|
3109
|
+
// round-horizon stop produces (the model was cut off mid-work), and the
|
|
3110
|
+
// partial transcript is exactly what the next turn needs restored.
|
|
3111
|
+
autoPersistTurn();
|
|
3008
3112
|
} else {
|
|
3009
3113
|
addMessage(
|
|
3010
3114
|
'assistant',
|