aegis-desktop 0.3.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.
@@ -0,0 +1,1990 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * AEGIS Desktop renderer — thin UI only.
5
+ *
6
+ * Talks exclusively to the surfaces exposed by preload.js (contextBridge):
7
+ * - window.aegis.* cloud status / memory / account (back-compat surface)
8
+ * - window.models.* 3-class provider layer (Aegis Cloud, Ollama,
9
+ * custom OpenAI-/Anthropic-compatible) — the chat path
10
+ * - window.sync.* local session persistence + cloud push/pull (P3 §7)
11
+ *
12
+ * No engine/routing logic lives here; the model class is the user's explicit
13
+ * selection, routed in the main process. If this file grows engine logic it is
14
+ * wrong.
15
+ *
16
+ * `maxTokensCeiling`/`FLAT_CEILING` come from max-tokens.js, a sibling
17
+ * classic script loaded before this one (see index.html) so the per-model
18
+ * ceiling math stays unit-testable without window.aegis/window.models.
19
+ */
20
+
21
+ // Everything below runs inside an IIFE. preload.js's contextBridge.exposeInMainWorld
22
+ // calls define window.aegis/models/sync as non-configurable globals; a
23
+ // top-level `const aegis = …` in a classic script binds into that *same*
24
+ // global lexical environment, and V8 refuses to shadow a non-configurable
25
+ // global property that way ("Identifier 'aegis' has already been declared").
26
+ // That SyntaxError kills the whole script before a single line runs — the UI
27
+ // is left stuck on "connecting…" with every button unbound. A function scope
28
+ // sidesteps the global environment entirely, so the same names are fine here.
29
+ (function () {
30
+
31
+ const aegis = window.aegis;
32
+ const models = window.models;
33
+ const sync = window.sync;
34
+
35
+ if (!aegis || !models) {
36
+ document.body.textContent =
37
+ 'This page must run inside the AEGIS Electron host (window.aegis/window.models missing).';
38
+ throw new Error('window.aegis/window.models unavailable — not running under Electron');
39
+ }
40
+
41
+ // Element handles are resolved lazily instead of eagerly at script parse time.
42
+ // If this script is loaded in <head> before the body exists, an eager
43
+ // document.getElementById() would capture null for every control and leave the
44
+ // whole UI dead (Save/Verify never bound, class/model dropdowns empty). Lazy
45
+ // getters re-read the live DOM on each access, so boot order can never cause
46
+ // that failure mode.
47
+ const ELEMENT_IDS = {
48
+ connDot: 'conn-dot',
49
+ connText: 'conn-text',
50
+ app: 'st-app',
51
+ client: 'st-client',
52
+ base: 'st-base',
53
+ key: 'st-key',
54
+ plan: 'st-plan',
55
+ balance: 'st-balance',
56
+ apiKeyInput: 'api-key-input',
57
+ apiKeySave: 'api-key-save',
58
+ apiKeyVerify: 'api-key-verify',
59
+ apiKeyHint: 'api-key-hint',
60
+ classSelect: 'class-select',
61
+ modelSelect: 'model-select',
62
+ modelPreset: 'model-preset',
63
+ modelInput: 'model-input',
64
+ maxTokens: 'max-tokens',
65
+ maxTokensAdaptive: 'max-tokens-adaptive',
66
+ autonomousToggle: 'autonomous-toggle',
67
+ autonomousToggleWrap: 'autonomous-toggle-wrap',
68
+ autonomousControls: 'autonomous-controls',
69
+ autonomousEffort: 'autonomous-effort',
70
+ autonomousWorkers: 'autonomous-workers',
71
+ modelHint: 'model-hint',
72
+ settingsList: 'settings-list',
73
+ settingsHint: 'settings-hint',
74
+ sessionsRefresh: 'sessions-refresh',
75
+ sessionsList: 'sessions-list',
76
+ sessionsHint: 'sessions-hint',
77
+ syncNow: 'sync-now',
78
+ syncStatus: 'sync-status',
79
+ newChat: 'new-chat',
80
+ memorySearchForm: 'memory-search-form',
81
+ memoryQuery: 'memory-query',
82
+ memoryResults: 'memory-results',
83
+ memoryEntry: 'memory-entry',
84
+ memorySaveBtn: 'memory-save-btn',
85
+ memoryImportBtn: 'memory-import-btn',
86
+ memoryHint: 'memory-hint',
87
+ memoryOpen: 'memory-open',
88
+ memoryOverlay: 'memory-overlay',
89
+ memoryBackdrop: 'memory-backdrop',
90
+ memoryClose: 'memory-close',
91
+ memoryFilters: 'memory-filters',
92
+ memoryOverlayQuery: 'memory-overlay-query',
93
+ memoryFiltersClear: 'memory-filters-clear',
94
+ memoryChips: 'memory-chips',
95
+ memoryList: 'memory-list',
96
+ memoryCount: 'memory-count',
97
+ memoryOverlayEntry: 'memory-overlay-entry',
98
+ memoryOverlaySave: 'memory-overlay-save',
99
+ messages: 'messages',
100
+ composer: 'composer',
101
+ prompt: 'prompt',
102
+ send: 'send',
103
+ exploreToggle: 'explore-toggle',
104
+ };
105
+
106
+ const els = {};
107
+ for (const [prop, id] of Object.entries(ELEMENT_IDS)) {
108
+ Object.defineProperty(els, prop, {
109
+ get: () => document.getElementById(id),
110
+ enumerable: true,
111
+ configurable: true,
112
+ });
113
+ }
114
+
115
+ const CLASS_KEY = 'aegis.class';
116
+ const MAX_TOKENS_KEY = 'aegis.maxTokens';
117
+ const MAX_TOKENS_ADAPTIVE_KEY = 'aegis.maxTokensAdaptive';
118
+ const AUTONOMOUS_KEY = 'aegis.autonomous';
119
+ const AUTONOMOUS_EFFORT_KEY = 'aegis.autonomousEffort';
120
+ const AUTONOMOUS_WORKERS_KEY = 'aegis.autonomousWorkers';
121
+ const EXPLORE_KEY = 'aegis.explore';
122
+ // "Work autonomously" (pool_brain worker fan-out, aegis1 services/pool_brain.py)
123
+ // is only billable/routable through the pooled AEGIS Cloud class.
124
+ const AUTONOMOUS_CLASS = 'aegis';
125
+ const CUSTOM_CLASSES = new Set(['openai-compat', 'anthropic']);
126
+ // Placeholders for the typed model-id field: custom endpoints enumerate
127
+ // nothing, so the field has to say what a valid id looks like.
128
+ const MODEL_ID_PLACEHOLDER = {
129
+ 'openai-compat': 'type a model id — e.g. gpt-4o-mini',
130
+ anthropic: 'type a model id — e.g. claude-sonnet-4-5',
131
+ };
132
+ // Quick-fill presets for the two custom-endpoint classes — model id + the
133
+ // base URL it actually lives at, since typing the right model string is only
134
+ // half the problem (the wrong base URL 400s just as hard). Base URLs and
135
+ // default model ids match aegis1 services/nexus_provider/catalog.py exactly:
136
+ // DeepSeek is served via Anthropic-Messages transport, Gemini via
137
+ // OpenAI-chat transport — that is why each shows up under the *other*
138
+ // custom class from what its own name suggests.
139
+ const CUSTOM_MODEL_PRESETS = {
140
+ 'openai-compat': [
141
+ { label: 'OpenAI — gpt-4o-mini', baseURL: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
142
+ { label: 'OpenAI — gpt-4o', baseURL: 'https://api.openai.com/v1', model: 'gpt-4o' },
143
+ {
144
+ label: 'Gemini — 3.5 Flash',
145
+ baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai/',
146
+ model: 'gemini-3.5-flash',
147
+ },
148
+ ],
149
+ anthropic: [
150
+ { label: 'Anthropic — Claude Sonnet 5', baseURL: 'https://api.anthropic.com/v1', model: 'claude-sonnet-5' },
151
+ { label: 'Anthropic — Claude Haiku 4.5', baseURL: 'https://api.anthropic.com/v1', model: 'claude-haiku-4-5' },
152
+ { label: 'DeepSeek — v4 Flash', baseURL: 'https://api.deepseek.com/anthropic', model: 'deepseek-v4-flash' },
153
+ { label: 'DeepSeek — v4 Pro', baseURL: 'https://api.deepseek.com/anthropic', model: 'deepseek-v4-pro' },
154
+ ],
155
+ };
156
+ // The in-app AEGIS key is stored in a reserved namespace the main process
157
+ // already filters out of settings.list(); never render it as a provider row
158
+ // even if a stale store still surfaces it (defect #1).
159
+ const RESERVED_PROVIDERS = new Set(['__aegis', 'aegis']);
160
+
161
+ let pendingEl = null;
162
+ let pendingSessionId = null;
163
+ let classOptions = [];
164
+ let modelMeta = new Map(); // model id -> raw model object from listModels() (P2 §6.3 ceiling)
165
+
166
+ // ---------------------------------------------------------- discovery lane
167
+ //
168
+ // The chat flow reads vertically: one prompt, one answer, forever. That makes
169
+ // the AI's *alternatives* a cost the user has to pay for manually ("ask again,
170
+ // differently"). The discovery lane turns that into a first-class part of the
171
+ // flow: after every answer the AI is sent down extra paths in parallel, and
172
+ // each path streams into its own card on a horizontal track beside the thread
173
+ // — the 2nd path, the new genre, the unexpected discovery, read left→right.
174
+ //
175
+ // Concurrency is what makes this non-trivial: several `models.chat` calls are
176
+ // live at once, so every stream owns a derived sessionId and the preload
177
+ // listener only accepts chunks tagged with it (main.js `taggedChunk`). Without
178
+ // that tag the replies would interleave into a single bubble.
179
+ const FLOW_SYSTEM =
180
+ 'You are the exploratory half of a chat assistant. The user is reading the ' +
181
+ 'main answer elsewhere, so never restate it. Be concrete and brief: one ' +
182
+ 'lead line naming the path, then 3-5 tight bullets. Never pad.';
183
+
184
+ /** The paths the lane offers. `hint` is the whole instruction for that card. */
185
+ const FLOW_PATHS = [
186
+ {
187
+ key: 'alternate',
188
+ badge: 'A',
189
+ title: 'Alternative angle',
190
+ hint:
191
+ 'Answer the request from a genuinely different angle: another method, ' +
192
+ 'school of thought or genre. Name the angle in one line, then 3-5 ' +
193
+ 'bullets of how it actually plays out. Do not restate the main answer.',
194
+ },
195
+ {
196
+ key: 'discovery',
197
+ badge: 'B',
198
+ title: 'Unexpected discovery',
199
+ hint:
200
+ 'Act as a scout, not an assistant. Surface ONE non-obvious connection, ' +
201
+ 'adjacent field or surprise finding the user did not ask for but which ' +
202
+ 'reframes the request. One line naming the discovery, then 2-4 bullets ' +
203
+ 'on why it matters and how to test it. Flag uncertainty honestly.',
204
+ },
205
+ {
206
+ key: 'genre',
207
+ badge: 'C',
208
+ title: 'New genre',
209
+ hint:
210
+ 'Recast the request in an unfamiliar genre or discipline — pick one that ' +
211
+ 'fits oddly well (e.g. field biology, contract law, ecology, jazz, ' +
212
+ 'logistics, restoration). Name the genre in one line, then 3-5 bullets ' +
213
+ 'of what that discipline would do first.',
214
+ },
215
+ ];
216
+
217
+ let flowCount = 0;
218
+ const activeBranches = new Set();
219
+
220
+ /** Every in-flight path for the current thread, so New chat can stop them. */
221
+ function abortBranches() {
222
+ for (const id of activeBranches) {
223
+ try {
224
+ models.cancel(id);
225
+ } catch {
226
+ /* a dead controller is not an error */
227
+ }
228
+ }
229
+ activeBranches.clear();
230
+ }
231
+
232
+ function exploreEnabled() {
233
+ const box = els.exploreToggle;
234
+ return Boolean(box && box.checked);
235
+ }
236
+
237
+ function maxTokensAdaptive() {
238
+ const box = els.maxTokensAdaptive;
239
+ return Boolean(box && box.checked);
240
+ }
241
+
242
+ function autonomousEnabled() {
243
+ const box = els.autonomousToggle;
244
+ return Boolean(box && box.checked);
245
+ }
246
+
247
+ /** Effort/workers only matter (and only show) once autonomous mode is on for
248
+ * the pooled class — same gating as the toggle itself, plus its checked state. */
249
+ function updateAutonomousControlsVisibility() {
250
+ if (!els.autonomousControls) return;
251
+ const wrapVisible = els.autonomousToggleWrap && !els.autonomousToggleWrap.hidden;
252
+ els.autonomousControls.hidden = !(wrapVisible && autonomousEnabled());
253
+ }
254
+
255
+ // ---------------------------------------------------------------- UI helpers
256
+
257
+ function setConn(ok, text) {
258
+ els.connDot.classList.toggle('ok', Boolean(ok));
259
+ els.connText.textContent = text;
260
+ }
261
+
262
+ function renderStatus(s) {
263
+ if (!s) {
264
+ setConn(false, 'IPC unavailable');
265
+ return;
266
+ }
267
+ els.app.textContent = s.appVersion ? `v${s.appVersion}` : '–';
268
+ els.client.textContent = s.clientVersion || '–';
269
+ els.base.textContent = s.apiBase || '–';
270
+ els.key.textContent = s.keyConfigured ? s.keyMask : 'not set';
271
+ setConn(s.keyConfigured, s.keyConfigured ? 'key configured' : 'no API key');
272
+ }
273
+
274
+ async function loadAccountInfo() {
275
+ try {
276
+ const verify = await aegis.verifyApiKey();
277
+ els.plan.textContent = verify && verify.valid
278
+ ? (verify.plan || 'active')
279
+ : 'invalid key';
280
+ } catch {
281
+ els.plan.textContent = 'unavailable';
282
+ }
283
+
284
+ try {
285
+ const bank = await aegis.tokenBankBalance();
286
+ els.balance.textContent = bank && bank.balance_eur != null
287
+ ? `€${bank.balance_eur}`
288
+ : '–';
289
+ } catch {
290
+ els.balance.textContent = 'unavailable';
291
+ }
292
+ }
293
+
294
+ // Refresh every key-dependent surface after the in-app key changes. The main
295
+ // process already replaced the live client key; this re-reads status (masked
296
+ // preview) and repopulates class/model dropdowns + account plan/balance.
297
+ async function refreshAfterKeyChange() {
298
+ try {
299
+ renderStatus(await aegis.status());
300
+ } catch {
301
+ renderStatus(null);
302
+ }
303
+ // loadClasses() also re-runs loadModels() for the currently selected class.
304
+ await loadClasses();
305
+ await loadAccountInfo();
306
+ }
307
+
308
+ async function saveApiKey() {
309
+ const key = els.apiKeyInput.value.trim();
310
+ els.apiKeySave.disabled = true;
311
+ els.apiKeyVerify.disabled = true;
312
+ els.apiKeyHint.textContent = 'saving…';
313
+ try {
314
+ const res = await aegis.setApiKey(key);
315
+ // Never retain the raw key in the DOM once saved — show only the masked
316
+ // preview the main process returned.
317
+ els.apiKeyInput.value = '';
318
+ els.apiKeyHint.textContent = key
319
+ ? `saved (${res && res.keyMask ? res.keyMask : 'configured'})`
320
+ : 'key cleared';
321
+ await refreshAfterKeyChange();
322
+ } catch (err) {
323
+ els.apiKeyHint.textContent =
324
+ `save failed: ${err && err.message ? err.message : err}`;
325
+ } finally {
326
+ els.apiKeySave.disabled = false;
327
+ els.apiKeyVerify.disabled = false;
328
+ }
329
+ }
330
+
331
+ async function verifyAegisKey() {
332
+ els.apiKeyVerify.disabled = true;
333
+ els.apiKeyHint.textContent = 'verifying…';
334
+ try {
335
+ const verify = await aegis.verifyApiKey();
336
+ els.apiKeyHint.textContent = verify && verify.valid
337
+ ? `valid (${verify.plan || 'active'})`
338
+ : 'invalid key';
339
+ } catch (err) {
340
+ els.apiKeyHint.textContent =
341
+ `verify failed: ${err && err.message ? err.message : err}`;
342
+ } finally {
343
+ els.apiKeyVerify.disabled = false;
344
+ }
345
+ }
346
+
347
+ // ----------------------------------------------------------------- memory
348
+ //
349
+ // Two surfaces read the same endpoint:
350
+ // - the sidebar card (last 10, one line each) — the quick peek;
351
+ // - the full inspector overlay (up to 50, every field) — "all memories and
352
+ // their sources" behind a translucent backdrop.
353
+ //
354
+ // Backend contract (aegis1/app.py:9214 `memory_search`): the response key is
355
+ // `entries`, NOT `results` — reading `results` is what kept this card empty
356
+ // for every account. `limit` is clamped server-side to 50 with no offset
357
+ // (app.py:9243), so the inspector is honestly "the 50 most recent", not an
358
+ // unbounded list. The endpoint also runs `_memory_sync_access` and can answer
359
+ // HTTP 402 `free_session_limit_reached`, which must render as an upgrade
360
+ // prompt — never as an empty list.
361
+ //
362
+ // Field set per entry (aegis1/app.py:9349 `_memory_row_to_dict`): id,
363
+ // timestamp, createdAt (epoch ms), source, role, tags[], content, session,
364
+ // importance, summary(bool), topics[], entities[], sentiment, tokenCount,
365
+ // embedding[]. There is no `tier` — L0–L3 is the local CLI engine's concept
366
+ // (aegiscodex-dev/src/memory.js) and does not exist on the cloud rows, so
367
+ // source/role are the real provenance axes here.
368
+ //
369
+ // `embedding` is dropped on ingest: it is a raw float vector, sometimes
370
+ // thousands of numbers, and nothing in this view renders or needs it.
371
+
372
+ const MEMORY_SIDEBAR_LIMIT = 10;
373
+ const MEMORY_INSPECTOR_LIMIT = 50; // server clamp — app.py:9243
374
+
375
+ /** Inspector state. `entries` is the fetched page; chips filter it in place. */
376
+ const memoryView = {
377
+ entries: [],
378
+ query: '',
379
+ source: '',
380
+ role: '',
381
+ error: '',
382
+ upgrade: null,
383
+ loading: false,
384
+ };
385
+
386
+ function overlayOpen() {
387
+ return !!els.memoryOverlay && !els.memoryOverlay.hidden;
388
+ }
389
+
390
+ /** "3m ago" / "2d ago" from epoch ms. Empty string when unknown. */
391
+ function relTime(ms) {
392
+ if (!Number.isFinite(ms)) return '';
393
+ const secs = Math.round((Date.now() - ms) / 1000);
394
+ if (secs < 45) return 'just now';
395
+ const mins = Math.round(secs / 60);
396
+ if (mins < 60) return `${mins}m ago`;
397
+ const hrs = Math.round(mins / 60);
398
+ if (hrs < 24) return `${hrs}h ago`;
399
+ const days = Math.round(hrs / 24);
400
+ if (days < 30) return `${days}d ago`;
401
+ const mos = Math.round(days / 30);
402
+ if (mos < 12) return `${mos}mo ago`;
403
+ return `${Math.round(mos / 12)}y ago`;
404
+ }
405
+
406
+ /** Coerce one raw row into the shape the renderers expect. Never trusts the
407
+ * payload: every field is type-checked so a malformed row degrades to a
408
+ * readable card instead of throwing mid-render. */
409
+ function normalizeMemoryEntry(raw) {
410
+ const r = raw && typeof raw === 'object' ? raw : {};
411
+ const asArray = (v) => (Array.isArray(v) ? v.filter((x) => x != null && x !== '') : []);
412
+ const asString = (v) => (typeof v === 'string' ? v : '');
413
+ const asNumber = (v) => (Number.isFinite(Number(v)) ? Number(v) : null);
414
+ const content = asString(r.content) || asString(r.text) || asString(r.entry);
415
+ return {
416
+ id: r.id != null ? String(r.id) : '',
417
+ content,
418
+ source: asString(r.source) || '(unknown source)',
419
+ role: asString(r.role),
420
+ tags: asArray(r.tags),
421
+ topics: asArray(r.topics),
422
+ entities: asArray(r.entities),
423
+ session: asString(r.session),
424
+ sentiment: asString(r.sentiment),
425
+ importance: asNumber(r.importance),
426
+ tokenCount: asNumber(r.tokenCount),
427
+ summary: r.summary === true,
428
+ createdAt: asNumber(r.createdAt),
429
+ timestamp: asString(r.timestamp),
430
+ };
431
+ }
432
+
433
+ /** Fetch one page of memory. Returns a discriminated result rather than
434
+ * throwing, because "needs upgrade" and "failed" render very differently
435
+ * from "empty" and conflating them is the bug this view exists to fix. */
436
+ async function fetchMemory(query, limit) {
437
+ try {
438
+ const data = query
439
+ ? await aegis.memorySearch(query, limit)
440
+ : await aegis.memoryList(limit);
441
+ // The free-plan cap does NOT arrive as a rejection on this path: main.js
442
+ // detects it where `err.status`/`err.data` still exist (ipcRenderer.invoke
443
+ // carries only the message string across the process boundary) and resolves
444
+ // `{ entries: [], upgrade }` instead. Checking the resolved payload first
445
+ // is what makes the upgrade UI reachable at all — the catch below only
446
+ // covers a non-IPC caller that still throws the raw client error.
447
+ if (data && data.upgrade) {
448
+ return { entries: [], error: '', upgrade: data.upgrade };
449
+ }
450
+ // `entries` is the real key. Keep the `results` fallback only so an older
451
+ // backend that still sends it degrades to a working list, not an empty one.
452
+ const raw = data && (data.entries || data.results);
453
+ const entries = Array.isArray(raw) ? raw.map(normalizeMemoryEntry) : [];
454
+ return { entries, error: '', upgrade: null };
455
+ } catch (err) {
456
+ const status = err && err.status;
457
+ const code = err && err.data && err.data.error;
458
+ if (status === 402 || code === 'free_session_limit_reached') {
459
+ return {
460
+ entries: [],
461
+ error: '',
462
+ upgrade: {
463
+ url: (err.data && err.data.upgradeUrl) || 'https://aegiscloud.org/subscribe',
464
+ used: err.data && err.data.sessionsUsed,
465
+ limit: err.data && err.data.freeSessionLimit,
466
+ },
467
+ };
468
+ }
469
+ return {
470
+ entries: [],
471
+ error: `search failed: ${err && err.message ? err.message : err}`,
472
+ upgrade: null,
473
+ };
474
+ }
475
+ }
476
+
477
+ /** Shared free-plan-cap notice. Every surface that can hit the 402 funnels
478
+ * through here so the wording and the target stay identical to the
479
+ * inspector's block. The hint elements are bare <p>s, so the subscribe link
480
+ * has to be a real child node — a plain text assignment would wipe it, and an
481
+ * href left in text is not clickable. */
482
+ function capNotice(el, upgrade, prefix, cta) {
483
+ if (!el) return;
484
+ const used = upgrade.used != null ? upgrade.used : '?';
485
+ const cap = upgrade.limit != null ? upgrade.limit : '?';
486
+ el.textContent =
487
+ `${prefix || 'free plan limit reached'} — ${used} of ${cap} sync sessions used. ` +
488
+ 'Nothing was lost. ';
489
+ const a = document.createElement('a');
490
+ a.href = upgrade.url || 'https://aegiscloud.org/subscribe';
491
+ a.target = '_blank';
492
+ a.rel = 'noreferrer noopener';
493
+ a.textContent = cta || 'Upgrade to keep saving →';
494
+ el.appendChild(a);
495
+ }
496
+
497
+ /** The cap notice in the memory section's hint line. */
498
+ function renderCapHint(upgrade, prefix) {
499
+ capNotice(els.memoryHint, upgrade, prefix);
500
+ }
501
+
502
+ /** Compact sidebar row: content only, with the source as a quiet prefix. */
503
+ function renderMemoryResults(entries, error, upgrade) {
504
+ els.memoryResults.innerHTML = '';
505
+ if (upgrade) {
506
+ const li = document.createElement('li');
507
+ li.className = 'empty mem-side-upgrade';
508
+ const a = document.createElement('a');
509
+ a.href = upgrade.url || 'https://aegiscloud.org/subscribe';
510
+ a.target = '_blank';
511
+ a.rel = 'noreferrer noopener';
512
+ a.textContent = 'Free plan limit reached — upgrade to read memory →';
513
+ li.appendChild(a);
514
+ els.memoryResults.appendChild(li);
515
+ return;
516
+ }
517
+ if (error) {
518
+ const li = document.createElement('li');
519
+ li.className = 'empty';
520
+ li.textContent = error;
521
+ els.memoryResults.appendChild(li);
522
+ return;
523
+ }
524
+ const list = Array.isArray(entries) ? entries : [];
525
+ if (!list.length) {
526
+ const li = document.createElement('li');
527
+ li.className = 'empty';
528
+ li.textContent = 'no memory entries';
529
+ els.memoryResults.appendChild(li);
530
+ return;
531
+ }
532
+ for (const e of list) {
533
+ const li = document.createElement('li');
534
+ const src = document.createElement('span');
535
+ src.className = 'mem-side-src';
536
+ src.textContent = e.source;
537
+ const body = document.createElement('span');
538
+ body.className = 'mem-side-body';
539
+ body.textContent = e.content || '(empty)';
540
+ li.appendChild(src);
541
+ li.appendChild(body);
542
+ els.memoryResults.appendChild(li);
543
+ }
544
+ }
545
+
546
+ async function searchMemory(query) {
547
+ els.memoryHint.textContent = 'searching…';
548
+ const res = await fetchMemory(query, MEMORY_SIDEBAR_LIMIT);
549
+ renderMemoryResults(res.entries, res.error, res.upgrade);
550
+ if (res.upgrade) renderCapHint(res.upgrade, 'cloud memory needs an active plan');
551
+ else els.memoryHint.textContent = res.error || '';
552
+ }
553
+
554
+ // ------------------------------------------------- memory inspector overlay
555
+
556
+ /** Small coloured label used for source / role / tag / topic / entity. */
557
+ function memoryChip(text, cls) {
558
+ const span = document.createElement('span');
559
+ span.className = `mem-chip${cls ? ` ${cls}` : ''}`;
560
+ span.textContent = text;
561
+ return span;
562
+ }
563
+
564
+ /** One expandable card: content plus every provenance field the row carries. */
565
+ function memoryCard(e) {
566
+ const li = document.createElement('li');
567
+ li.className = 'mem-card';
568
+
569
+ const head = document.createElement('div');
570
+ head.className = 'mem-card-head';
571
+ head.appendChild(memoryChip(e.source, 'src'));
572
+ if (e.role) head.appendChild(memoryChip(e.role, 'role'));
573
+ if (e.importance != null) head.appendChild(memoryChip(`imp ${e.importance}`, 'imp'));
574
+ if (e.sentiment) head.appendChild(memoryChip(e.sentiment, 'sent'));
575
+ if (e.summary) head.appendChild(memoryChip('summary', 'flag'));
576
+
577
+ const meta = document.createElement('span');
578
+ meta.className = 'mem-card-meta';
579
+ const bits = [];
580
+ const rel = relTime(e.createdAt);
581
+ if (rel) bits.push(rel);
582
+ else if (e.timestamp) bits.push(e.timestamp);
583
+ if (e.session) bits.push(`session ${e.session}`);
584
+ if (e.tokenCount != null) bits.push(`${e.tokenCount} tok`);
585
+ meta.textContent = bits.join(' · ');
586
+ head.appendChild(meta);
587
+ li.appendChild(head);
588
+
589
+ const body = document.createElement('p');
590
+ body.className = 'mem-card-body';
591
+ body.textContent = e.content || '(empty)';
592
+ li.appendChild(body);
593
+
594
+ const pills = [...e.tags, ...e.topics, ...e.entities];
595
+ if (pills.length) {
596
+ const row = document.createElement('div');
597
+ row.className = 'mem-card-pills';
598
+ for (const p of pills) row.appendChild(memoryChip(p, 'pill'));
599
+ li.appendChild(row);
600
+ }
601
+
602
+ // Expand on click, but never while the user is selecting text to copy.
603
+ body.addEventListener('click', () => {
604
+ if (window.getSelection && String(window.getSelection())) return;
605
+ li.classList.toggle('expanded');
606
+ });
607
+ return li;
608
+ }
609
+
610
+ /** Distinct values of `key` across the fetched page, with counts. */
611
+ function memoryFacets(key) {
612
+ const counts = new Map();
613
+ for (const e of memoryView.entries) {
614
+ const v = e[key];
615
+ if (!v) continue;
616
+ counts.set(v, (counts.get(v) || 0) + 1);
617
+ }
618
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]);
619
+ }
620
+
621
+ /** Case-insensitive match over content plus every metadata field. */
622
+ function memoryMatches(e, q) {
623
+ const hay = [
624
+ e.content,
625
+ e.source,
626
+ e.role,
627
+ e.session,
628
+ e.sentiment,
629
+ ...e.tags,
630
+ ...e.topics,
631
+ ...e.entities,
632
+ ].join('\n').toLowerCase();
633
+ return hay.includes(String(q == null ? '' : q).toLowerCase());
634
+ }
635
+
636
+ /** Chip filters (source / role) — click a chip to narrow, again to clear. */
637
+ function renderMemoryChips() {
638
+ els.memoryChips.innerHTML = '';
639
+ if (memoryView.upgrade || memoryView.error) return;
640
+ const groups = [
641
+ ['source', 'source', memoryFacets('source')],
642
+ ['role', 'role', memoryFacets('role')],
643
+ ];
644
+ for (const [key, label, facets] of groups) {
645
+ if (facets.length < 2) continue; // a single value is not a useful filter
646
+ const group = document.createElement('div');
647
+ group.className = 'mem-chip-group';
648
+ const lab = document.createElement('span');
649
+ lab.className = 'mem-chip-label';
650
+ lab.textContent = label;
651
+ group.appendChild(lab);
652
+ for (const [value, count] of facets) {
653
+ const b = document.createElement('button');
654
+ b.type = 'button';
655
+ b.className = `mem-chip btn${memoryView[key] === value ? ' on' : ''}`;
656
+ b.textContent = `${value} ${count}`;
657
+ b.addEventListener('click', () => {
658
+ memoryView[key] = memoryView[key] === value ? '' : value;
659
+ renderMemoryOverlay();
660
+ });
661
+ group.appendChild(b);
662
+ }
663
+ els.memoryChips.appendChild(group);
664
+ }
665
+ }
666
+
667
+ /** Repaint the inspector from `memoryView`. Pure — makes no network calls. */
668
+ function renderMemoryOverlay() {
669
+ if (!overlayOpen()) return;
670
+ const total = memoryView.entries.length;
671
+ els.memoryList.innerHTML = '';
672
+ renderMemoryChips();
673
+
674
+ if (memoryView.loading) {
675
+ els.memoryCount.textContent = 'loading…';
676
+ return;
677
+ }
678
+
679
+ if (memoryView.upgrade) {
680
+ els.memoryCount.textContent = '';
681
+ const li = document.createElement('li');
682
+ li.className = 'mem-empty mem-upgrade';
683
+ const h = document.createElement('strong');
684
+ h.textContent = 'Cloud memory is paused on the free plan';
685
+ const p = document.createElement('span');
686
+ const used = memoryView.upgrade.used != null ? memoryView.upgrade.used : '?';
687
+ const cap = memoryView.upgrade.limit != null ? memoryView.upgrade.limit : '?';
688
+ p.textContent =
689
+ `This account has used ${used} of ${cap} sync sessions. Saved memory is ` +
690
+ 'not lost — it becomes readable again once the plan is active.';
691
+ const a = document.createElement('a');
692
+ a.href = memoryView.upgrade.url;
693
+ a.target = '_blank';
694
+ a.rel = 'noreferrer noopener';
695
+ a.textContent = 'Open subscribe page ↗';
696
+ li.appendChild(h);
697
+ li.appendChild(p);
698
+ li.appendChild(a);
699
+ els.memoryList.appendChild(li);
700
+ return;
701
+ }
702
+
703
+ if (memoryView.error) {
704
+ els.memoryCount.textContent = '';
705
+ const li = document.createElement('li');
706
+ li.className = 'mem-empty';
707
+ li.textContent = memoryView.error;
708
+ els.memoryList.appendChild(li);
709
+ return;
710
+ }
711
+
712
+ if (!total) {
713
+ els.memoryCount.textContent = '0 entries';
714
+ const li = document.createElement('li');
715
+ li.className = 'mem-empty';
716
+ li.textContent = 'No memory entries yet — save a note below, or import from other AI tools.';
717
+ els.memoryList.appendChild(li);
718
+ return;
719
+ }
720
+
721
+ const q = memoryView.query.trim().toLowerCase();
722
+ const shown = memoryView.entries.filter(
723
+ (e) =>
724
+ (!memoryView.source || e.source === memoryView.source) &&
725
+ (!memoryView.role || e.role === memoryView.role) &&
726
+ (!q || memoryMatches(e, q))
727
+ );
728
+
729
+ const sources = new Set(memoryView.entries.map((e) => e.source)).size;
730
+ els.memoryCount.textContent =
731
+ `${shown.length} of ${total} shown · ${sources} source${sources === 1 ? '' : 's'}` +
732
+ (total >= MEMORY_INSPECTOR_LIMIT ? ` · newest ${MEMORY_INSPECTOR_LIMIT}` : '');
733
+
734
+ if (!shown.length) {
735
+ const li = document.createElement('li');
736
+ li.className = 'mem-empty';
737
+ li.textContent = 'Nothing matches those filters.';
738
+ els.memoryList.appendChild(li);
739
+ return;
740
+ }
741
+ for (const e of shown) els.memoryList.appendChild(memoryCard(e));
742
+ }
743
+
744
+ /** Fetch a fresh page into the inspector and repaint. Preserves the filter
745
+ * text so typing a query does not reset the chips you just clicked. */
746
+ async function loadMemoryOverlay(query) {
747
+ memoryView.loading = true;
748
+ memoryView.error = '';
749
+ memoryView.upgrade = null;
750
+ renderMemoryOverlay();
751
+ const res = await fetchMemory(query, MEMORY_INSPECTOR_LIMIT);
752
+ memoryView.entries = res.entries;
753
+ memoryView.error = res.error;
754
+ memoryView.upgrade = res.upgrade;
755
+ memoryView.loading = false;
756
+ renderMemoryOverlay();
757
+ }
758
+
759
+ function openMemoryOverlay() {
760
+ if (!els.memoryOverlay) return;
761
+ els.memoryOverlay.hidden = false;
762
+ document.body.classList.add('memory-open');
763
+ // Start from whatever the sidebar is showing so the two never disagree.
764
+ els.memoryOverlayQuery.value = els.memoryQuery ? els.memoryQuery.value.trim() : '';
765
+ memoryView.query = els.memoryOverlayQuery.value;
766
+ memoryView.source = '';
767
+ memoryView.role = '';
768
+ renderMemoryOverlay();
769
+ loadMemoryOverlay(memoryView.query);
770
+ if (els.memoryOverlayQuery) els.memoryOverlayQuery.focus();
771
+ }
772
+
773
+ function closeMemoryOverlay() {
774
+ if (!els.memoryOverlay) return;
775
+ els.memoryOverlay.hidden = true;
776
+ document.body.classList.remove('memory-open');
777
+ if (els.memoryOpen) els.memoryOpen.focus();
778
+ }
779
+
780
+ /** Textarea the save button should read from — the inspector's when it is up,
781
+ * otherwise the sidebar's. Keeps both save paths on one code path. */
782
+ function activeMemoryEntry() {
783
+ return overlayOpen() && els.memoryOverlayEntry ? els.memoryOverlayEntry : els.memoryEntry;
784
+ }
785
+
786
+ async function importMemory() {
787
+ if (!aegis.memoryImport) return;
788
+ els.memoryImportBtn.disabled = true;
789
+ els.memoryHint.textContent = 'scanning for other AI tool memory…';
790
+ try {
791
+ // Phase 1 — dry run. The scan is read-only against the foreign stores
792
+ // (client/foreign-memory.js never writes to them) and the user already
793
+ // asked for the import by clicking, so this runs unattended: no confirm
794
+ // prompt. The preview text left in the hint is the audit trail of what
795
+ // was found and from where, which is what the dialog used to be for.
796
+ const preview = await aegis.memoryImport({ confirm: false });
797
+ if (!preview || !preview.totals || !preview.totals.entries) {
798
+ els.memoryHint.textContent = preview && preview.summary ? preview.summary : 'nothing found.';
799
+ return;
800
+ }
801
+
802
+ const detail = (preview.sources || [])
803
+ .filter((s) => s.present && s.count > 0)
804
+ .map((s) => `${s.label}: ${s.count}`)
805
+ .join(' · ');
806
+
807
+ // Phase 2 — the confirmed write, immediately.
808
+ els.memoryHint.textContent = `importing ${preview.totals.entries} entries — ${detail}`;
809
+ const result = await aegis.memoryImport({ confirm: true, limit: 1000 });
810
+ if (result && result.upgrade) {
811
+ // The cap stopped the import part-way. Entries already in the cloud stay
812
+ // there and the rest are still in the foreign stores, so a later scan
813
+ // re-finds them — nothing to retry by hand, just show the plan page.
814
+ renderCapHint(result.upgrade, `import stopped after ${result.saved || 0} entries`);
815
+ await refreshMemoryViews();
816
+ return;
817
+ }
818
+ const queued = result && result.queued ? ` (${result.queued} queued offline)` : '';
819
+ els.memoryHint.textContent = result && result.ok
820
+ ? `imported ${result.saved} entries${queued}.`
821
+ : `import stopped: ${(result && result.reason) || 'unknown error'}`;
822
+ await refreshMemoryViews();
823
+ } catch (err) {
824
+ els.memoryHint.textContent = `import failed: ${err && err.message ? err.message : err}`;
825
+ } finally {
826
+ els.memoryImportBtn.disabled = false;
827
+ }
828
+ }
829
+
830
+ async function saveMemory() {
831
+ const box = activeMemoryEntry();
832
+ if (!box) return;
833
+ const text = box.value.trim();
834
+ if (!text) return;
835
+ els.memorySaveBtn.disabled = true;
836
+ if (els.memoryOverlaySave) els.memoryOverlaySave.disabled = true;
837
+ els.memoryHint.textContent = 'saving…';
838
+ try {
839
+ const result = await aegis.memorySave({ text, source: 'aegis-desktop' });
840
+ if (result && result.upgrade) {
841
+ // Nothing was stored, and it is not queued either (main.js deliberately
842
+ // does not queue a cap — it would flush-fail forever). So keep the text
843
+ // in the box: clearing it here would destroy the note the user just
844
+ // tried to remember, while the hint claimed it was saved.
845
+ renderCapHint(result.upgrade, 'free plan limit reached');
846
+ await refreshMemoryViews();
847
+ return;
848
+ }
849
+ box.value = '';
850
+ els.memoryHint.textContent =
851
+ result && result.queued ? 'saved offline — syncs when AEGIS is reachable.' : 'saved.';
852
+ await refreshMemoryViews();
853
+ } catch (err) {
854
+ els.memoryHint.textContent = `save failed: ${err && err.message ? err.message : err}`;
855
+ } finally {
856
+ els.memorySaveBtn.disabled = false;
857
+ if (els.memoryOverlaySave) els.memoryOverlaySave.disabled = false;
858
+ }
859
+ }
860
+
861
+ /** Re-read both surfaces after a write. The inspector only refetches when it
862
+ * is actually on screen — a hidden overlay should not spend a request. */
863
+ async function refreshMemoryViews() {
864
+ const q = els.memoryQuery ? els.memoryQuery.value.trim() : '';
865
+ await searchMemory(q);
866
+ if (overlayOpen()) await loadMemoryOverlay(memoryView.query);
867
+ }
868
+
869
+ // ------------------------------------------------------------ chat welcome
870
+ // The welcome panel is cloned from #welcome-template in index.html rather than
871
+ // built with createElement, so its markup lives in exactly one place and
872
+ // survives the innerHTML clears in newChat()/openSession().
873
+ //
874
+ // Pills PREFILL the composer (matching ae-guix native-chat); they never
875
+ // auto-send, so a mis-click costs nothing. Wiring is addEventListener rather
876
+ // than inline onclick because this renderer runs under CSP `script-src 'self'`,
877
+ // which would silently drop inline handlers.
878
+
879
+ /** Time-aware greeting, recomputed each time the panel is rendered. */
880
+ function applyGreeting() {
881
+ const el = document.getElementById('chat-greeting');
882
+ if (!el) return;
883
+ const h = new Date().getHours();
884
+ el.textContent =
885
+ h >= 5 && h < 12
886
+ ? 'Good morning'
887
+ : h >= 12 && h < 17
888
+ ? 'Good afternoon'
889
+ : h >= 17 && h < 22
890
+ ? 'Good evening'
891
+ : 'Working late?';
892
+ }
893
+
894
+ /** Prefill the composer with a starter question, caret at the end. */
895
+ function quickAction(text) {
896
+ const inp = els.prompt;
897
+ if (!inp) return;
898
+ inp.value = text;
899
+ inp.focus();
900
+ try {
901
+ inp.setSelectionRange(text.length, text.length);
902
+ } catch (err) {
903
+ /* not supported on every input type; focus alone is enough */
904
+ }
905
+ }
906
+
907
+ /** Clear the transcript and render the welcome panel. */
908
+ function renderWelcome() {
909
+ const tpl = document.getElementById('welcome-template');
910
+ if (!tpl) return;
911
+ els.messages.innerHTML = '';
912
+ els.messages.appendChild(tpl.content.cloneNode(true));
913
+ applyGreeting();
914
+ for (const btn of els.messages.querySelectorAll('.chat-quick-pill')) {
915
+ btn.addEventListener('click', () => quickAction(btn.dataset.quick || ''));
916
+ }
917
+ }
918
+
919
+ /** Drop the welcome panel once real transcript content exists. */
920
+ function hideWelcome() {
921
+ const w = document.getElementById('chat-welcome');
922
+ if (w) w.remove();
923
+ }
924
+
925
+ // `sessionId`, when given for an assistant message, renders a "copy" button
926
+ // that puts the message text on the clipboard. `toolLog` (assistant only) is
927
+ // the turn's collected `{name, args, ok}` tool calls, rendered above the
928
+ // answer text so the reply the user reads is followed by, not replaced by,
929
+ // what the model actually did to produce it.
930
+ function addMessage(role, text, meta, sessionId, toolLog) {
931
+ hideWelcome();
932
+ const row = document.createElement('div');
933
+ row.className = `msg ${role}`;
934
+
935
+ const who = document.createElement('div');
936
+ who.className = 'who';
937
+ who.textContent =
938
+ role === 'user' ? 'You' : role === 'assistant' ? 'AEGIS' : 'System';
939
+
940
+ const body = document.createElement('div');
941
+ body.className = 'body';
942
+ body.textContent = text;
943
+
944
+ row.appendChild(who);
945
+ if (Array.isArray(toolLog) && toolLog.length) {
946
+ const toolsEl = document.createElement('div');
947
+ toolsEl.className = 'tool-activity';
948
+ for (const t of toolLog) {
949
+ const line = document.createElement('div');
950
+ line.textContent = toolActivityLabel(t);
951
+ toolsEl.appendChild(line);
952
+ }
953
+ row.appendChild(toolsEl);
954
+ }
955
+ row.appendChild(body);
956
+
957
+ if (meta) {
958
+ const m = document.createElement('div');
959
+ m.className = 'meta';
960
+ m.textContent = meta;
961
+ row.appendChild(m);
962
+ }
963
+
964
+ if (role === 'assistant' && sessionId) {
965
+ const copyBtn = document.createElement('button');
966
+ copyBtn.type = 'button';
967
+ copyBtn.className = 'copy-btn';
968
+ copyBtn.textContent = 'copy';
969
+ copyBtn.addEventListener('click', () => copyMessage(text, copyBtn));
970
+ row.appendChild(copyBtn);
971
+ }
972
+
973
+ els.messages.appendChild(row);
974
+ els.messages.scrollTop = els.messages.scrollHeight;
975
+ return row;
976
+ }
977
+
978
+ /** Copy one message's text to the clipboard, with brief button feedback. */
979
+ async function copyMessage(text, btn) {
980
+ try {
981
+ await navigator.clipboard.writeText(text);
982
+ if (btn) btn.textContent = 'copied!';
983
+ } catch (err) {
984
+ if (btn) btn.textContent = 'copy failed';
985
+ } finally {
986
+ if (btn) setTimeout(() => { btn.textContent = 'copy'; }, 1500);
987
+ }
988
+ }
989
+
990
+ /**
991
+ * Build one card of the lane. Each card is a self-contained stream slot: its
992
+ * own `.flow-body`, its own state chip and its own cancel button. The card is
993
+ * inserted into the track *before* the trailing "+ another path" button so the
994
+ * track always ends with the affordance that extends it.
995
+ */
996
+ function flowCard(path, track, spec) {
997
+ const card = document.createElement('article');
998
+ card.className = 'flow-card pending';
999
+ card.dataset.path = path.key;
1000
+
1001
+ const head = document.createElement('div');
1002
+ head.className = 'flow-head';
1003
+
1004
+ const badge = document.createElement('span');
1005
+ badge.className = 'flow-badge';
1006
+ badge.textContent = path.badge;
1007
+
1008
+ const title = document.createElement('span');
1009
+ title.className = 'flow-title';
1010
+ title.textContent = path.title;
1011
+
1012
+ const state = document.createElement('span');
1013
+ state.className = 'flow-state';
1014
+ state.textContent = 'queued';
1015
+
1016
+ head.appendChild(badge);
1017
+ head.appendChild(title);
1018
+ head.appendChild(state);
1019
+
1020
+ const body = document.createElement('div');
1021
+ body.className = 'flow-body';
1022
+ body.textContent = '…';
1023
+
1024
+ const meta = document.createElement('div');
1025
+ meta.className = 'flow-meta';
1026
+
1027
+ const abort = document.createElement('button');
1028
+ abort.type = 'button';
1029
+ abort.className = 'ghost-btn flow-abort';
1030
+ abort.textContent = 'stop';
1031
+ abort.addEventListener('click', () => {
1032
+ if (card.dataset.session) models.cancel(card.dataset.session);
1033
+ state.textContent = 'stopped';
1034
+ card.classList.remove('pending');
1035
+ card.classList.add('stopped');
1036
+ abort.remove();
1037
+ });
1038
+
1039
+ card.appendChild(head);
1040
+ card.appendChild(body);
1041
+ card.appendChild(meta);
1042
+ card.appendChild(abort);
1043
+
1044
+ const addBtn = track.querySelector('.flow-add');
1045
+ if (addBtn) track.insertBefore(card, addBtn);
1046
+ else track.appendChild(card);
1047
+
1048
+ spawnPath(card, { ...spec, path });
1049
+ return card;
1050
+ }
1051
+
1052
+ /**
1053
+ * Run one path: a streaming `models.chat` on a derived sessionId, rendered
1054
+ * into the card it owns. Fire-and-forget — the caller never awaits, so N paths
1055
+ * stream simultaneously while the vertical thread stays responsive.
1056
+ */
1057
+ async function spawnPath(card, spec) {
1058
+ const id = `${spec.parentSessionId}::flow${++flowCount}`;
1059
+ card.dataset.session = id;
1060
+ activeBranches.add(id);
1061
+
1062
+ const body = card.querySelector('.flow-body');
1063
+ const state = card.querySelector('.flow-state');
1064
+ const meta = card.querySelector('.flow-meta');
1065
+
1066
+ let streamed = '';
1067
+ const onDelta = (chunk) => {
1068
+ if (chunk && chunk.tool) {
1069
+ if (card.classList.contains('pending')) {
1070
+ card.classList.remove('pending');
1071
+ state.textContent = 'streaming…';
1072
+ }
1073
+ appendToolActivity(card, chunk.tool, 'flow-tools', '.flow-body');
1074
+ return;
1075
+ }
1076
+ const delta =
1077
+ chunk && (typeof chunk.delta === 'string' ? chunk.delta : chunk.content);
1078
+ if (!delta) return;
1079
+ if (card.classList.contains('pending')) {
1080
+ card.classList.remove('pending');
1081
+ state.textContent = 'streaming…';
1082
+ }
1083
+ streamed += delta;
1084
+ body.textContent = streamed;
1085
+ // Follow the newest text sideways only while this card is the one being
1086
+ // read — horizontal auto-scroll that fights the user is worse than none.
1087
+ if (trackOf(card) && isTrailing(card)) {
1088
+ card.scrollIntoView({ block: 'nearest', inline: 'end' });
1089
+ }
1090
+ };
1091
+
1092
+ try {
1093
+ const data = await models.chat(
1094
+ {
1095
+ class: spec.cls,
1096
+ // The path instruction rides in the system prompt so the transcript
1097
+ // stays the user's own words; the echoed request follows it because
1098
+ // not every provider honours `system` (Ollama, some compat gateways).
1099
+ system: `${FLOW_SYSTEM}\n\n${spec.path.hint}`,
1100
+ prompt: `Original request:\n${spec.prompt}\n\n${spec.path.hint}`,
1101
+ model: spec.model,
1102
+ maxTokens: Math.min(spec.maxTokens || 1024, 1024),
1103
+ sessionId: id,
1104
+ },
1105
+ onDelta
1106
+ );
1107
+
1108
+ const choice = (data && data.choices && data.choices[0]) || {};
1109
+ const text =
1110
+ (choice.message && choice.message.content) || streamed || '(no path found)';
1111
+ body.textContent = text;
1112
+ card.classList.remove('pending');
1113
+ card.classList.add('done');
1114
+ state.textContent = 'done';
1115
+
1116
+ const bits = [spec.path.title];
1117
+ if (data && data.model) bits.push(data.model);
1118
+ else if (spec.model) bits.push(spec.model);
1119
+ if (data && data.usage && data.usage.total_tokens != null) {
1120
+ bits.push(`${data.usage.total_tokens} tokens`);
1121
+ }
1122
+ meta.textContent = bits.join(' · ');
1123
+ } catch (err) {
1124
+ const message = err && err.message ? err.message : String(err);
1125
+ card.classList.remove('pending');
1126
+ card.classList.add('stopped');
1127
+ state.textContent = 'stopped';
1128
+ body.textContent = streamed || `Path unavailable: ${message}`;
1129
+ meta.textContent = spec.path.title;
1130
+ } finally {
1131
+ activeBranches.delete(id);
1132
+ const abort = card.querySelector('.flow-abort');
1133
+ if (abort) abort.remove();
1134
+ }
1135
+ }
1136
+
1137
+ function trackOf(card) {
1138
+ return card.parentElement;
1139
+ }
1140
+
1141
+ /** True when nothing but the "+ another path" button follows this card. */
1142
+ function isTrailing(card) {
1143
+ const next = card.nextElementSibling;
1144
+ return !next || next.classList.contains('flow-add');
1145
+ }
1146
+
1147
+ /**
1148
+ * The lane itself: a horizontal track appended under the vertical thread.
1149
+ * `spec` carries the class/model/budget the user already chose, so a path is
1150
+ * generated by the same provider as the answer it sits beside.
1151
+ */
1152
+ function addFlowLane(spec) {
1153
+ const lane = document.createElement('div');
1154
+ lane.className = 'chatflow';
1155
+
1156
+ const rail = document.createElement('div');
1157
+ rail.className = 'flow-rail';
1158
+ const label = document.createElement('span');
1159
+ label.className = 'flow-rail-label';
1160
+ label.textContent = 'discovery lane';
1161
+ const hint = document.createElement('span');
1162
+ hint.className = 'flow-rail-hint';
1163
+ hint.textContent = 'alternatives from the AI — scroll →';
1164
+ rail.appendChild(label);
1165
+ rail.appendChild(hint);
1166
+
1167
+ const track = document.createElement('div');
1168
+ track.className = 'flow-track';
1169
+
1170
+ const addBtn = document.createElement('button');
1171
+ addBtn.type = 'button';
1172
+ addBtn.className = 'ghost-btn flow-add';
1173
+ addBtn.textContent = '+ another path';
1174
+ addBtn.addEventListener('click', () => {
1175
+ // Cycle the presets so repeated clicks keep discovering new genres.
1176
+ const path = FLOW_PATHS[flowCount % FLOW_PATHS.length];
1177
+ flowCard(path, track, spec);
1178
+ });
1179
+ track.appendChild(addBtn);
1180
+
1181
+ lane.appendChild(rail);
1182
+ lane.appendChild(track);
1183
+ els.messages.appendChild(lane);
1184
+
1185
+ for (const path of FLOW_PATHS.slice(0, 2)) flowCard(path, track, spec);
1186
+ return lane;
1187
+ }
1188
+
1189
+ function setBusy(busy, { cancellable } = {}) {
1190
+ els.send.disabled = busy;
1191
+ els.prompt.disabled = busy;
1192
+ if (busy) {
1193
+ pendingEl = addMessage('assistant', '');
1194
+ pendingEl.classList.add('pending');
1195
+ const dots = document.createElement('span');
1196
+ dots.className = 'typing-dots';
1197
+ for (let i = 0; i < 3; i++) dots.appendChild(document.createElement('span'));
1198
+ pendingEl.querySelector('.body').appendChild(dots);
1199
+ if (cancellable) {
1200
+ const cancelBtn = document.createElement('button');
1201
+ cancelBtn.type = 'button';
1202
+ cancelBtn.className = 'cancel-btn';
1203
+ cancelBtn.textContent = 'cancel';
1204
+ cancelBtn.addEventListener('click', () => {
1205
+ if (pendingSessionId) models.cancel(pendingSessionId);
1206
+ });
1207
+ pendingEl.appendChild(cancelBtn);
1208
+ }
1209
+ } else if (pendingEl) {
1210
+ pendingEl.remove();
1211
+ pendingEl = null;
1212
+ }
1213
+ }
1214
+
1215
+ /**
1216
+ * One display line for a completed tool call (`onDelta`'s `{ tool: {name,
1217
+ * args, ok} }` chunk — see desktop/lib/local/engine.js). Fires after the tool
1218
+ * already ran, so this is a retrospective log line, not a live spinner.
1219
+ */
1220
+ function toolActivityLabel(tool) {
1221
+ const { name, args, ok } = tool || {};
1222
+ const mark = ok === false ? '✗' : '✓';
1223
+ const a = args || {};
1224
+ if (name === 'task') {
1225
+ const kind = a.subagent_type && a.subagent_type !== 'general' ? a.subagent_type : 'general';
1226
+ return `${mark} task → ${a.description || 'subagent'} (${kind})`;
1227
+ }
1228
+ const detail = a.file_path || a.path || a.pattern || (a.command ? a.command.slice(0, 60) : '') || '';
1229
+ return `${mark} ${name}${detail ? `(${detail})` : ''}`;
1230
+ }
1231
+
1232
+ /** Append one tool-activity line to `row`, creating the container on first use. */
1233
+ function appendToolActivity(row, tool, containerClass, beforeSelector) {
1234
+ if (!row) return;
1235
+ let toolsEl = row.querySelector(`.${containerClass}`);
1236
+ if (!toolsEl) {
1237
+ toolsEl = document.createElement('div');
1238
+ toolsEl.className = containerClass;
1239
+ const before = beforeSelector ? row.querySelector(beforeSelector) : null;
1240
+ if (before) row.insertBefore(toolsEl, before);
1241
+ else row.appendChild(toolsEl);
1242
+ }
1243
+ const line = document.createElement('div');
1244
+ line.textContent = toolActivityLabel(tool);
1245
+ toolsEl.appendChild(line);
1246
+ return toolsEl;
1247
+ }
1248
+
1249
+ function classLabel(cls) {
1250
+ const found = classOptions.find((c) => c.class === cls);
1251
+ return found ? found.label : cls;
1252
+ }
1253
+
1254
+ function newSessionId() {
1255
+ try {
1256
+ if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') {
1257
+ return globalThis.crypto.randomUUID();
1258
+ }
1259
+ } catch {
1260
+ /* fall through */
1261
+ }
1262
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
1263
+ const r = (Math.random() * 16) | 0;
1264
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
1265
+ return v.toString(16);
1266
+ });
1267
+ }
1268
+
1269
+ // ------------------------------------------------------------- model classes
1270
+
1271
+ async function loadClasses() {
1272
+ try {
1273
+ classOptions = (await models.listClasses()) || [];
1274
+ } catch (err) {
1275
+ classOptions = [];
1276
+ els.modelHint.textContent =
1277
+ `listClasses failed: ${err && err.message ? err.message : err}`;
1278
+ }
1279
+
1280
+ els.classSelect.innerHTML = '';
1281
+ for (const c of classOptions) {
1282
+ const opt = document.createElement('option');
1283
+ opt.value = c.class;
1284
+ opt.textContent = c.configured
1285
+ ? c.label
1286
+ : `${c.label} (not configured)`;
1287
+ els.classSelect.appendChild(opt);
1288
+ }
1289
+
1290
+ const saved = localStorage.getItem(CLASS_KEY);
1291
+ if (saved && classOptions.some((c) => c.class === saved)) {
1292
+ els.classSelect.value = saved;
1293
+ }
1294
+ await loadModels(els.classSelect.value);
1295
+ }
1296
+
1297
+ // Disable max-tokens options above the selected model's ceiling and clamp the
1298
+ // current selection down if it no longer fits; falls back to the flat 300k
1299
+ // ceiling (all options enabled) when no per-model metadata is known. When
1300
+ // "adaptive" is on, the manual select is irrelevant — the effective value
1301
+ // (returned here, read by send()) is always the model's own ceiling — so the
1302
+ // select is disabled rather than clamped.
1303
+ function applyMaxTokensClamp(modelId) {
1304
+ const meta = modelId ? modelMeta.get(modelId) : null;
1305
+ const ceiling = maxTokensCeiling(meta);
1306
+ const adaptive = maxTokensAdaptive();
1307
+ els.maxTokens.disabled = adaptive;
1308
+ for (const opt of els.maxTokens.options) {
1309
+ opt.disabled = Number(opt.value) > ceiling;
1310
+ }
1311
+ if (!adaptive && Number(els.maxTokens.value) > ceiling) {
1312
+ const enabled = Array.from(els.maxTokens.options).filter((o) => !o.disabled);
1313
+ const fallback = enabled[enabled.length - 1];
1314
+ if (fallback) {
1315
+ els.maxTokens.value = fallback.value;
1316
+ localStorage.setItem(MAX_TOKENS_KEY, els.maxTokens.value);
1317
+ }
1318
+ }
1319
+ return ceiling;
1320
+ }
1321
+
1322
+ async function loadModels(cls) {
1323
+ // "Work autonomously" only makes sense for the pooled AEGIS Cloud class —
1324
+ // hide it for Ollama/custom endpoints rather than showing a checkbox that
1325
+ // would silently do nothing.
1326
+ if (els.autonomousToggleWrap) {
1327
+ els.autonomousToggleWrap.hidden = cls !== AUTONOMOUS_CLASS;
1328
+ }
1329
+ updateAutonomousControlsVisibility();
1330
+
1331
+ const custom = CUSTOM_CLASSES.has(cls);
1332
+ els.modelSelect.hidden = custom;
1333
+ els.modelSelect.disabled = custom;
1334
+ els.modelInput.hidden = !custom;
1335
+ els.modelInput.disabled = !custom;
1336
+ els.modelPreset.hidden = true;
1337
+ els.modelHint.textContent = '';
1338
+
1339
+ if (custom) {
1340
+ modelMeta = new Map();
1341
+ applyMaxTokensClamp(null);
1342
+ let cfg = { baseURL: '', configured: false, keyMask: null };
1343
+ try {
1344
+ const settings = (await models.settings.get()) || [];
1345
+ cfg = (Array.isArray(settings) && settings.find((s) => s.provider === cls)) || cfg;
1346
+ } catch {
1347
+ /* settings unavailable — leave hint below */
1348
+ }
1349
+ // The engine lists no models for a custom endpoint: the only usable id is
1350
+ // one the user types (it used to offer the base URL as an id, which POSTed
1351
+ // `model: "<url>"` and 400'd upstream). `needsModelId` is what turns this
1352
+ // into an explicit "type a model id" prompt rather than an empty picker.
1353
+ let needsModelId = true;
1354
+ try {
1355
+ const data = await models.listModels(cls);
1356
+ if (data && typeof data.needsModelId === 'boolean') needsModelId = data.needsModelId;
1357
+ if (data && typeof data.baseURL === 'string' && data.baseURL) {
1358
+ cfg = { ...cfg, baseURL: data.baseURL };
1359
+ }
1360
+ const list = Array.isArray(data && data.models) ? data.models : [];
1361
+ if (list.length) {
1362
+ // A custom class that does enumerate models (a future provider) still
1363
+ // gets a picker; the typed input is only for the unlistable case.
1364
+ needsModelId = false;
1365
+ els.modelSelect.hidden = false;
1366
+ els.modelSelect.disabled = false;
1367
+ els.modelInput.hidden = true;
1368
+ els.modelInput.disabled = true;
1369
+ els.modelSelect.innerHTML = '';
1370
+ for (const m of list) {
1371
+ modelMeta.set(m.id, m);
1372
+ const opt = document.createElement('option');
1373
+ opt.value = m.id;
1374
+ opt.textContent = m.label || m.id;
1375
+ els.modelSelect.appendChild(opt);
1376
+ }
1377
+ }
1378
+ } catch {
1379
+ /* engine unavailable — fall back to settings + the typed input */
1380
+ }
1381
+ // Quick-fill presets only make sense while the id is still hand-typed —
1382
+ // a class that starts enumerating real models (needsModelId false) gets
1383
+ // a proper picker above instead, so the preset list would be redundant.
1384
+ const presets = needsModelId ? CUSTOM_MODEL_PRESETS[cls] || [] : [];
1385
+ els.modelPreset.hidden = presets.length === 0;
1386
+ if (presets.length) {
1387
+ els.modelPreset.innerHTML = '';
1388
+ const placeholder = document.createElement('option');
1389
+ placeholder.value = '';
1390
+ placeholder.textContent = 'quick pick…';
1391
+ placeholder.disabled = true;
1392
+ placeholder.selected = true;
1393
+ els.modelPreset.appendChild(placeholder);
1394
+ for (const preset of presets) {
1395
+ const opt = document.createElement('option');
1396
+ opt.value = preset.model;
1397
+ opt.textContent = preset.label;
1398
+ opt.title = preset.baseURL;
1399
+ els.modelPreset.appendChild(opt);
1400
+ }
1401
+ }
1402
+ els.modelInput.placeholder = needsModelId
1403
+ ? MODEL_ID_PLACEHOLDER[cls] || 'type a model id'
1404
+ : 'model id';
1405
+ els.modelHint.textContent = cfg.baseURL
1406
+ ? `endpoint: ${cfg.baseURL} · key: ${cfg.configured ? cfg.keyMask : 'not set'}` +
1407
+ (needsModelId ? ' · type a model id above' : '')
1408
+ : 'Set base URL + key in Provider settings, then type a model id.';
1409
+ return;
1410
+ }
1411
+
1412
+ els.modelSelect.innerHTML = '';
1413
+ modelMeta = new Map();
1414
+ if (cls === 'aegis') {
1415
+ const auto = document.createElement('option');
1416
+ auto.value = '';
1417
+ auto.textContent = 'server default (auto)';
1418
+ els.modelSelect.appendChild(auto);
1419
+ }
1420
+
1421
+ try {
1422
+ const data = await models.listModels(cls);
1423
+ const list = Array.isArray(data && data.models) ? data.models : [];
1424
+ for (const m of list) {
1425
+ modelMeta.set(m.id, m);
1426
+ const opt = document.createElement('option');
1427
+ opt.value = m.id;
1428
+ opt.textContent = m.label || m.id;
1429
+ els.modelSelect.appendChild(opt);
1430
+ }
1431
+ let hint;
1432
+ if (!list.length) {
1433
+ hint = cls === 'ollama' ? 'Ollama not running or no models pulled.' : 'No models listed.';
1434
+ } else {
1435
+ hint = `${list.length} model${list.length === 1 ? '' : 's'} available.`;
1436
+ }
1437
+ const ceiling = applyMaxTokensClamp(els.modelSelect.value);
1438
+ els.modelHint.textContent =
1439
+ ceiling < FLAT_CEILING ? `${hint} · max output: ${ceiling.toLocaleString()}` : hint;
1440
+ } catch (err) {
1441
+ modelMeta = new Map();
1442
+ applyMaxTokensClamp(null);
1443
+ els.modelHint.textContent =
1444
+ `listModels failed: ${err && err.message ? err.message : err}`;
1445
+ }
1446
+ }
1447
+
1448
+ /**
1449
+ * Quick-fill a Model-card preset: sets the typed model id, and fills the
1450
+ * matching Provider-settings base URL field IF it's currently empty. An
1451
+ * already-configured base URL is left alone (never silently overwritten) —
1452
+ * if it doesn't match what the preset expects, the hint says so instead, so
1453
+ * the user's own custom endpoint can't be clobbered by a stray click.
1454
+ */
1455
+ function applyCustomPreset(cls, modelId) {
1456
+ const preset = (CUSTOM_MODEL_PRESETS[cls] || []).find((p) => p.model === modelId);
1457
+ if (!preset) return;
1458
+ els.modelInput.value = preset.model;
1459
+
1460
+ const row = els.settingsList.querySelector(`.setting-row[data-provider="${cls}"]`);
1461
+ const baseInput = row && row.querySelector('.setting-base');
1462
+ if (!baseInput) return;
1463
+ const current = baseInput.value.trim();
1464
+ if (!current) {
1465
+ baseInput.value = preset.baseURL;
1466
+ els.modelHint.textContent = `filled in — click Save in Provider settings below to store the ${preset.label} endpoint.`;
1467
+ } else if (current !== preset.baseURL) {
1468
+ els.modelHint.textContent =
1469
+ `${preset.label} needs base URL ${preset.baseURL} — Provider settings below has ${current}. Update it there too.`;
1470
+ }
1471
+ }
1472
+
1473
+ // -------------------------------------------------------------- settings pane
1474
+
1475
+ async function loadSettings() {
1476
+ els.settingsList.innerHTML = '';
1477
+ let settings = [];
1478
+ try {
1479
+ settings = (await models.settings.get()) || [];
1480
+ } catch {
1481
+ /* leave empty */
1482
+ }
1483
+ if (Array.isArray(settings)) {
1484
+ settings = settings.filter((s) => s && !RESERVED_PROVIDERS.has(s.provider));
1485
+ }
1486
+
1487
+ const providers = [
1488
+ { provider: 'openai-compat', name: 'OpenAI-compatible' },
1489
+ { provider: 'anthropic', name: 'Anthropic-compatible' },
1490
+ ];
1491
+
1492
+ for (const { provider, name } of providers) {
1493
+ const cfg = settings.find((s) => s.provider === provider) || {
1494
+ provider,
1495
+ baseURL: '',
1496
+ configured: false,
1497
+ keyMask: null,
1498
+ };
1499
+
1500
+ const row = document.createElement('div');
1501
+ row.className = 'setting-row';
1502
+ // Targeted by applyCustomPreset() so picking a Model-card preset can
1503
+ // quick-fill the matching base URL here without a full loadSettings()
1504
+ // round trip.
1505
+ row.dataset.provider = provider;
1506
+
1507
+ const label = document.createElement('div');
1508
+ label.className = 'setting-name';
1509
+ label.textContent = name;
1510
+ row.appendChild(label);
1511
+
1512
+ const baseInput = document.createElement('input');
1513
+ baseInput.type = 'text';
1514
+ baseInput.className = 'setting-input setting-base';
1515
+ baseInput.placeholder = 'base URL';
1516
+ baseInput.value = cfg.baseURL || '';
1517
+ row.appendChild(baseInput);
1518
+
1519
+ const keyInput = document.createElement('input');
1520
+ keyInput.type = 'password';
1521
+ keyInput.className = 'setting-input';
1522
+ keyInput.placeholder = cfg.configured
1523
+ ? `key ${cfg.keyMask} (blank = keep)`
1524
+ : 'API key';
1525
+ row.appendChild(keyInput);
1526
+
1527
+ const status = document.createElement('div');
1528
+ status.className = 'setting-status';
1529
+ status.textContent = cfg.configured ? `configured (${cfg.keyMask})` : 'no key';
1530
+ row.appendChild(status);
1531
+
1532
+ const actions = document.createElement('div');
1533
+ actions.className = 'setting-actions';
1534
+
1535
+ const saveBtn = document.createElement('button');
1536
+ saveBtn.type = 'button';
1537
+ saveBtn.className = 'ghost-btn';
1538
+ saveBtn.textContent = 'Save';
1539
+ saveBtn.addEventListener('click', () =>
1540
+ saveSetting(provider, baseInput.value.trim(), keyInput.value)
1541
+ );
1542
+ actions.appendChild(saveBtn);
1543
+
1544
+ const removeBtn = document.createElement('button');
1545
+ removeBtn.type = 'button';
1546
+ removeBtn.className = 'ghost-btn danger';
1547
+ removeBtn.textContent = 'Remove';
1548
+ removeBtn.disabled = !cfg.configured;
1549
+ removeBtn.addEventListener('click', () => removeSetting(provider));
1550
+ actions.appendChild(removeBtn);
1551
+
1552
+ row.appendChild(actions);
1553
+ els.settingsList.appendChild(row);
1554
+ }
1555
+ }
1556
+
1557
+ async function saveSetting(provider, baseURL, key) {
1558
+ els.settingsHint.textContent = 'saving…';
1559
+ try {
1560
+ const cfg = { baseURL };
1561
+ if (key) cfg.key = key;
1562
+ await models.settings.set(provider, cfg);
1563
+ els.settingsHint.textContent = 'saved.';
1564
+ await loadSettings();
1565
+ await loadModels(els.classSelect.value);
1566
+ } catch (err) {
1567
+ els.settingsHint.textContent =
1568
+ `save failed: ${err && err.message ? err.message : err}`;
1569
+ }
1570
+ }
1571
+
1572
+ async function removeSetting(provider) {
1573
+ els.settingsHint.textContent = 'removing…';
1574
+ try {
1575
+ await models.settings.remove(provider);
1576
+ els.settingsHint.textContent = 'removed.';
1577
+ await loadSettings();
1578
+ await loadModels(els.classSelect.value);
1579
+ } catch (err) {
1580
+ els.settingsHint.textContent =
1581
+ `remove failed: ${err && err.message ? err.message : err}`;
1582
+ }
1583
+ }
1584
+
1585
+ // -------------------------------------------------------------- sessions pane
1586
+
1587
+ async function loadSessions() {
1588
+ els.sessionsList.innerHTML = '';
1589
+ els.sessionsHint.textContent = '';
1590
+ let sessions = [];
1591
+ try {
1592
+ const data = await sync.listSessions();
1593
+ sessions = (data && data.sessions) || [];
1594
+ } catch (err) {
1595
+ els.sessionsHint.textContent =
1596
+ `list failed: ${err && err.message ? err.message : err}`;
1597
+ return;
1598
+ }
1599
+
1600
+ if (!sessions.length) {
1601
+ const li = document.createElement('li');
1602
+ li.className = 'empty';
1603
+ li.textContent = 'no sessions yet';
1604
+ els.sessionsList.appendChild(li);
1605
+ return;
1606
+ }
1607
+
1608
+ for (const s of sessions) {
1609
+ const li = document.createElement('li');
1610
+ li.className = 'session-row';
1611
+ li.tabIndex = 0;
1612
+
1613
+ const title = document.createElement('span');
1614
+ title.className = 'session-title';
1615
+ title.textContent = (s && s.title) || (s && s.id) || 'untitled';
1616
+ li.appendChild(title);
1617
+
1618
+ const meta = document.createElement('span');
1619
+ meta.className = 'session-meta';
1620
+ const count = (s && Array.isArray(s.messages) && s.messages.length) || 0;
1621
+ const when = (s && s.updatedAt)
1622
+ ? new Date(s.updatedAt).toLocaleTimeString()
1623
+ : '';
1624
+ meta.textContent =
1625
+ `${count} msg${count === 1 ? '' : 's'}${when ? ' · ' + when : ''}`;
1626
+ li.appendChild(meta);
1627
+
1628
+ li.addEventListener('click', () => openSession(s.id));
1629
+ els.sessionsList.appendChild(li);
1630
+ }
1631
+ }
1632
+
1633
+ function renderSyncStatus(status) {
1634
+ if (!els.syncStatus) return;
1635
+ if (!status) {
1636
+ els.syncStatus.textContent = '';
1637
+ return;
1638
+ }
1639
+ const bits = [status.cloud ? 'cloud sync on' : 'cloud sync off (no key)', `${status.pending} pending`];
1640
+ if (status.lastSyncAt) {
1641
+ bits.push(`last synced ${new Date(status.lastSyncAt).toLocaleTimeString()}`);
1642
+ }
1643
+ els.syncStatus.textContent = bits.join(' · ');
1644
+ }
1645
+
1646
+ async function refreshSyncStatus() {
1647
+ try {
1648
+ renderSyncStatus(await sync.status());
1649
+ } catch {
1650
+ renderSyncStatus(null);
1651
+ }
1652
+ }
1653
+
1654
+ /** Explicit "Sync now" — push pending sessions, then pull remote ones.
1655
+ * Offline-first: sync.push()/sync.pull() resolve `{ ok:false, reason }`
1656
+ * rather than throwing, so this only hits the catch on an unexpected error. */
1657
+ async function syncNow() {
1658
+ els.syncNow.disabled = true;
1659
+ els.sessionsHint.textContent = 'syncing…';
1660
+ try {
1661
+ const pushResult = await sync.push();
1662
+ const pullResult = await sync.pull();
1663
+ if (pushResult.upgrade || pullResult.upgrade) {
1664
+ // The queued memory-save flush hit the free-plan cap. main.js stops the
1665
+ // flush at that point rather than reporting a clean "synced" while every
1666
+ // entry silently stays queued — point at the plan page instead.
1667
+ capNotice(
1668
+ els.sessionsHint,
1669
+ pushResult.upgrade || pullResult.upgrade,
1670
+ 'queued memory is waiting on the free-plan cap',
1671
+ 'Upgrade to sync it →'
1672
+ );
1673
+ } else if (!pushResult.ok && !pullResult.ok) {
1674
+ els.sessionsHint.textContent =
1675
+ `sync failed: ${pushResult.reason || pullResult.reason || 'unknown error'}`;
1676
+ } else {
1677
+ els.sessionsHint.textContent =
1678
+ `synced (pushed ${pushResult.pushed || 0}, pulled ${pullResult.merged || 0})`;
1679
+ }
1680
+ } catch (err) {
1681
+ els.sessionsHint.textContent = `sync failed: ${err && err.message ? err.message : err}`;
1682
+ } finally {
1683
+ els.syncNow.disabled = false;
1684
+ await refreshSyncStatus();
1685
+ await loadSessions();
1686
+ }
1687
+ }
1688
+
1689
+ function openSession(id) {
1690
+ sync.open(id)
1691
+ .then((s) => {
1692
+ if (!s) {
1693
+ els.sessionsHint.textContent = 'session not found';
1694
+ return;
1695
+ }
1696
+ els.messages.innerHTML = '';
1697
+ const msgs = Array.isArray(s.messages) ? s.messages : [];
1698
+ if (!msgs.length) renderWelcome();
1699
+ for (const m of msgs) {
1700
+ const role =
1701
+ m.role === 'assistant' ? 'assistant'
1702
+ : m.role === 'system' ? 'system'
1703
+ : 'user';
1704
+ addMessage(role, m.content || m.text || '', undefined, role === 'assistant' ? s.id : undefined);
1705
+ }
1706
+ els.sessionsHint.textContent = `opened ${s.id.slice(0, 8)}…`;
1707
+ })
1708
+ .catch((err) => {
1709
+ els.sessionsHint.textContent =
1710
+ `open failed: ${err && err.message ? err.message : err}`;
1711
+ });
1712
+ }
1713
+
1714
+ function newChat() {
1715
+ abortBranches();
1716
+ renderWelcome();
1717
+ els.sessionsHint.textContent = '';
1718
+ pendingEl = null;
1719
+ pendingSessionId = null;
1720
+ flowCount = 0;
1721
+ }
1722
+
1723
+ // ------------------------------------------------------------------ actions
1724
+
1725
+ async function send() {
1726
+ const prompt = els.prompt.value.trim();
1727
+ if (!prompt || els.send.disabled) return;
1728
+
1729
+ const cls = els.classSelect.value;
1730
+ const model = CUSTOM_CLASSES.has(cls)
1731
+ ? els.modelInput.value.trim()
1732
+ : els.modelSelect.value;
1733
+ // A custom endpoint has no default model, and a blank id reaches the
1734
+ // provider as `model: undefined` (defect B). Ask for it instead of sending.
1735
+ if (CUSTOM_CLASSES.has(cls) && !model) {
1736
+ els.modelHint.textContent =
1737
+ MODEL_ID_PLACEHOLDER[cls] || 'type a model id before sending.';
1738
+ els.modelInput.focus();
1739
+ return;
1740
+ }
1741
+
1742
+ els.prompt.value = '';
1743
+ addMessage('user', prompt);
1744
+
1745
+ const ceiling = applyMaxTokensClamp(model);
1746
+ const maxTokens = maxTokensAdaptive() ? ceiling : parseInt(els.maxTokens.value, 10) || 4096;
1747
+ const autonomous = cls === AUTONOMOUS_CLASS && autonomousEnabled();
1748
+ // Only meaningful (and only sent) alongside `autonomous` — see
1749
+ // aegis1 services/pool_brain.py parse_brain_request for the effort/workers
1750
+ // clamping this feeds.
1751
+ const effort = autonomous ? els.autonomousEffort.value : undefined;
1752
+ const workers = autonomous ? parseInt(els.autonomousWorkers.value, 10) || undefined : undefined;
1753
+ const sessionId = newSessionId();
1754
+ pendingSessionId = sessionId;
1755
+
1756
+ setBusy(true, { cancellable: true });
1757
+
1758
+ // Persist the user turn locally (best-effort — never blocks chat).
1759
+ try {
1760
+ await sync.append(sessionId, { role: 'user', content: prompt });
1761
+ } catch {
1762
+ /* persistence is non-fatal */
1763
+ }
1764
+
1765
+ let streamedText = '';
1766
+ const toolLog = [];
1767
+ const onDelta = (chunk) => {
1768
+ if (chunk && chunk.tool) {
1769
+ toolLog.push(chunk.tool);
1770
+ if (pendingEl) {
1771
+ pendingEl.classList.remove('pending');
1772
+ appendToolActivity(pendingEl, chunk.tool, 'tool-activity', '.body');
1773
+ els.messages.scrollTop = els.messages.scrollHeight;
1774
+ }
1775
+ return;
1776
+ }
1777
+ const delta =
1778
+ chunk && (typeof chunk.delta === 'string' ? chunk.delta : chunk.content);
1779
+ if (!delta) return;
1780
+ streamedText += delta;
1781
+ if (!pendingEl) return;
1782
+ pendingEl.classList.remove('pending');
1783
+ const bodyEl = pendingEl.querySelector('.body');
1784
+ if (bodyEl) bodyEl.textContent = streamedText;
1785
+ els.messages.scrollTop = els.messages.scrollHeight;
1786
+ };
1787
+
1788
+ try {
1789
+ // All four classes route through the model: surface (the main process
1790
+ // decides transport — cloud client, ollama, or a direct provider).
1791
+ const data = await models.chat(
1792
+ { class: cls, prompt, model, maxTokens, sessionId, autonomous, effort, workers },
1793
+ onDelta
1794
+ );
1795
+
1796
+ const choice = (data && data.choices && data.choices[0]) || {};
1797
+ const text =
1798
+ (choice.message && choice.message.content) ||
1799
+ streamedText ||
1800
+ '(empty response)';
1801
+ const bits = [];
1802
+ if (data && data.model) bits.push(`model: ${data.model}`);
1803
+ else if (model) bits.push(`model: ${model}`);
1804
+ bits.push(classLabel(cls));
1805
+ if (autonomous) bits.push(`autonomous (${effort}, ${workers || 3}w)`);
1806
+ if (data && data.usage && data.usage.total_tokens != null) {
1807
+ bits.push(`tokens: ${data.usage.total_tokens}`);
1808
+ }
1809
+ addMessage('assistant', text, bits.join(' · ') || undefined, sessionId, toolLog);
1810
+
1811
+ try {
1812
+ await sync.append(sessionId, { role: 'assistant', content: text });
1813
+ await sync.save({ id: sessionId, title: prompt.slice(0, 60) });
1814
+ } catch {
1815
+ /* persistence is non-fatal */
1816
+ }
1817
+
1818
+ // The AI's second path: not awaited — the lane streams beside the thread
1819
+ // while the composer goes straight back to the user (chat flow D2.2).
1820
+ if (exploreEnabled() && text !== '(empty response)') {
1821
+ addFlowLane({ prompt, cls, model, maxTokens, parentSessionId: sessionId });
1822
+ }
1823
+ } catch (err) {
1824
+ addMessage(
1825
+ 'assistant',
1826
+ `Error: ${err && err.message ? err.message : err}`,
1827
+ 'request failed'
1828
+ );
1829
+ } finally {
1830
+ setBusy(false);
1831
+ pendingSessionId = null;
1832
+ loadSessions();
1833
+ }
1834
+ }
1835
+
1836
+ // --------------------------------------------------------------------- boot
1837
+
1838
+ async function init() {
1839
+ try {
1840
+ renderStatus(await aegis.status());
1841
+ } catch {
1842
+ renderStatus(null);
1843
+ }
1844
+
1845
+ const savedMax = localStorage.getItem(MAX_TOKENS_KEY);
1846
+ if (savedMax) els.maxTokens.value = savedMax;
1847
+
1848
+ els.maxTokens.addEventListener('change', () => {
1849
+ localStorage.setItem(MAX_TOKENS_KEY, els.maxTokens.value);
1850
+ });
1851
+
1852
+ // Adaptive max tokens is opt-in per machine, remembered across restarts.
1853
+ const savedAdaptive = localStorage.getItem(MAX_TOKENS_ADAPTIVE_KEY);
1854
+ if (savedAdaptive === 'on') els.maxTokensAdaptive.checked = true;
1855
+ els.maxTokensAdaptive.addEventListener('change', () => {
1856
+ localStorage.setItem(MAX_TOKENS_ADAPTIVE_KEY, els.maxTokensAdaptive.checked ? 'on' : 'off');
1857
+ const cls = els.classSelect.value;
1858
+ const modelId = CUSTOM_CLASSES.has(cls) ? els.modelInput.value.trim() : els.modelSelect.value;
1859
+ applyMaxTokensClamp(modelId);
1860
+ });
1861
+
1862
+ // Work-autonomously is opt-in per machine, remembered across restarts;
1863
+ // only ever sent when the active class is AEGIS Cloud (see AUTONOMOUS_CLASS).
1864
+ const savedAutonomous = localStorage.getItem(AUTONOMOUS_KEY);
1865
+ if (savedAutonomous === 'on') els.autonomousToggle.checked = true;
1866
+ els.autonomousToggle.addEventListener('change', () => {
1867
+ localStorage.setItem(AUTONOMOUS_KEY, els.autonomousToggle.checked ? 'on' : 'off');
1868
+ updateAutonomousControlsVisibility();
1869
+ });
1870
+
1871
+ // Effort/worker count for the pool_brain fan-out (aegis1
1872
+ // services/pool_brain.py parse_brain_request reads `effort`/`workers` off
1873
+ // the request body) — opt-in per machine, remembered across restarts.
1874
+ const savedEffort = localStorage.getItem(AUTONOMOUS_EFFORT_KEY);
1875
+ if (savedEffort) els.autonomousEffort.value = savedEffort;
1876
+ els.autonomousEffort.addEventListener('change', () => {
1877
+ localStorage.setItem(AUTONOMOUS_EFFORT_KEY, els.autonomousEffort.value);
1878
+ });
1879
+ const savedWorkers = localStorage.getItem(AUTONOMOUS_WORKERS_KEY);
1880
+ if (savedWorkers) els.autonomousWorkers.value = savedWorkers;
1881
+ els.autonomousWorkers.addEventListener('change', () => {
1882
+ localStorage.setItem(AUTONOMOUS_WORKERS_KEY, els.autonomousWorkers.value);
1883
+ });
1884
+ updateAutonomousControlsVisibility();
1885
+
1886
+ // The discovery lane is opt-in per machine, remembered across restarts.
1887
+ const savedExplore = localStorage.getItem(EXPLORE_KEY);
1888
+ if (savedExplore === 'off') els.exploreToggle.checked = false;
1889
+ els.exploreToggle.addEventListener('change', () => {
1890
+ localStorage.setItem(EXPLORE_KEY, els.exploreToggle.checked ? 'on' : 'off');
1891
+ if (!els.exploreToggle.checked) abortBranches();
1892
+ });
1893
+
1894
+ els.classSelect.addEventListener('change', () => {
1895
+ localStorage.setItem(CLASS_KEY, els.classSelect.value);
1896
+ loadModels(els.classSelect.value);
1897
+ });
1898
+
1899
+ els.modelSelect.addEventListener('change', () => {
1900
+ const ceiling = applyMaxTokensClamp(els.modelSelect.value);
1901
+ const base = els.modelHint.textContent.replace(/ · max output: [\d,]+$/, '');
1902
+ els.modelHint.textContent =
1903
+ ceiling < FLAT_CEILING ? `${base} · max output: ${ceiling.toLocaleString()}` : base;
1904
+ });
1905
+
1906
+ els.modelPreset.addEventListener('change', () => {
1907
+ applyCustomPreset(els.classSelect.value, els.modelPreset.value);
1908
+ });
1909
+
1910
+ els.newChat.addEventListener('click', newChat);
1911
+ els.sessionsRefresh.addEventListener('click', loadSessions);
1912
+ els.syncNow.addEventListener('click', syncNow);
1913
+
1914
+ els.apiKeySave.addEventListener('click', saveApiKey);
1915
+ els.apiKeyVerify.addEventListener('click', verifyAegisKey);
1916
+ els.apiKeyInput.addEventListener('keydown', (e) => {
1917
+ if (e.key === 'Enter') {
1918
+ e.preventDefault();
1919
+ saveApiKey();
1920
+ }
1921
+ });
1922
+
1923
+ els.composer.addEventListener('submit', (e) => {
1924
+ e.preventDefault();
1925
+ send();
1926
+ });
1927
+
1928
+ els.prompt.addEventListener('keydown', (e) => {
1929
+ if (e.key === 'Enter' && !e.shiftKey) {
1930
+ e.preventDefault();
1931
+ els.composer.requestSubmit();
1932
+ }
1933
+ });
1934
+
1935
+ els.memorySearchForm.addEventListener('submit', (e) => {
1936
+ e.preventDefault();
1937
+ searchMemory(els.memoryQuery.value.trim());
1938
+ });
1939
+
1940
+ els.memorySaveBtn.addEventListener('click', saveMemory);
1941
+ els.memoryImportBtn.addEventListener('click', importMemory);
1942
+
1943
+ // Memory inspector. `?`-guarded: the overlay markup is optional, and a
1944
+ // missing node must degrade to the plain sidebar card, not a boot crash.
1945
+ if (els.memoryOpen) els.memoryOpen.addEventListener('click', openMemoryOverlay);
1946
+ if (els.memoryClose) els.memoryClose.addEventListener('click', closeMemoryOverlay);
1947
+ if (els.memoryBackdrop) els.memoryBackdrop.addEventListener('click', closeMemoryOverlay);
1948
+ if (els.memoryOverlaySave) els.memoryOverlaySave.addEventListener('click', saveMemory);
1949
+ if (els.memoryFilters) {
1950
+ els.memoryFilters.addEventListener('submit', (e) => {
1951
+ e.preventDefault();
1952
+ memoryView.query = els.memoryOverlayQuery.value.trim();
1953
+ loadMemoryOverlay(memoryView.query);
1954
+ });
1955
+ }
1956
+ if (els.memoryFiltersClear) {
1957
+ els.memoryFiltersClear.addEventListener('click', () => {
1958
+ els.memoryOverlayQuery.value = '';
1959
+ memoryView.query = '';
1960
+ memoryView.source = '';
1961
+ memoryView.role = '';
1962
+ renderMemoryOverlay();
1963
+ });
1964
+ }
1965
+ document.addEventListener('keydown', (e) => {
1966
+ if (e.key === 'Escape' && overlayOpen()) closeMemoryOverlay();
1967
+ });
1968
+
1969
+ // First paint: show the welcome panel unless a session already rendered rows.
1970
+ if (!els.messages.querySelector('.msg, .chatflow')) renderWelcome();
1971
+
1972
+ await loadClasses();
1973
+ await loadSettings();
1974
+ await loadSessions();
1975
+ await refreshSyncStatus();
1976
+ loadAccountInfo();
1977
+ searchMemory('');
1978
+ }
1979
+
1980
+ // Boot only after the DOM is parsed so every control this script queries
1981
+ // actually exists. If the script is already running post-DOM this is a no-op;
1982
+ // if it was loaded early (e.g. <head> without defer) this prevents the
1983
+ // null-element boot crash that left Save/Verify unbound and dropdowns empty.
1984
+ if (document.readyState === 'loading') {
1985
+ document.addEventListener('DOMContentLoaded', init);
1986
+ } else {
1987
+ init();
1988
+ }
1989
+
1990
+ })();