aegis-desktop 1.0.10 → 1.0.13

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/renderer/app.js CHANGED
@@ -96,6 +96,14 @@ const ELEMENT_IDS = {
96
96
  apiKeyHint: 'api-key-hint',
97
97
  classSelect: 'class-select',
98
98
  modelSelect: 'model-select',
99
+ ptSearch: 'pt-search',
100
+ ptRefresh: 'pt-refresh',
101
+ ptList: 'pt-list',
102
+ ptCount: 'pt-count',
103
+ ptHint: 'pt-hint',
104
+ companionCard: 'companion-card',
105
+ companionText: 'companion-text',
106
+ companionDismiss: 'companion-dismiss',
99
107
  modelFree: 'model-input',
100
108
  budgetHint: 'budget-hint',
101
109
  autonomousToggle: 'autonomous-toggle',
@@ -106,6 +114,15 @@ const ELEMENT_IDS = {
106
114
  modelHint: 'model-hint',
107
115
  settingsList: 'settings-list',
108
116
  settingsHint: 'settings-hint',
117
+ // The custom-provider card (index.html #custom-provider-card): an address, a
118
+ // key and a model id, saved as `byok:custom`. Separate from settingsList
119
+ // because its row carries a base URL the catalog rows do not have.
120
+ customBaseUrl: 'custom-base-url',
121
+ customKey: 'custom-key',
122
+ customModel: 'custom-model',
123
+ customSave: 'custom-save',
124
+ customRemove: 'custom-remove',
125
+ customStatus: 'custom-status',
109
126
  quickLauncherEnabled: 'quick-launcher-enabled',
110
127
  quickLauncherShortcut: 'quick-launcher-shortcut',
111
128
  quickLauncherSave: 'quick-launcher-save',
@@ -239,6 +256,20 @@ const RESERVED_PROVIDERS = new Set(['__aegis', 'aegis']);
239
256
  // (desktop/lib/settings.js) and the engine re-checks before every call, so the
240
257
  // value typed here can only ever address this machine or this LAN.
241
258
  const LOCAL_PROVIDER = 'local';
259
+ // The one catalog provider whose configuration is not a key alone.
260
+ //
261
+ // aegis1 lists `custom` in GET /api/v1/byok/providers (services/byok_service.py
262
+ // `byok_provider_catalog`) even though its descriptor pins no credential env and
263
+ // no base URL, because the address travels per request instead. That makes it
264
+ // the only row where "paste your key" is not enough: aegis1's
265
+ // `_byok_custom_base_url` refuses a custom call that carries no `base_url`, so a
266
+ // key saved with no address is a credential nothing can spend.
267
+ //
268
+ // So the catalog loop below SKIPS this id and the card under #custom-provider-card
269
+ // owns it end to end (address + key + model id, one Save). Kept as one constant
270
+ // because two surfaces must agree on the spelling — the row that is skipped here
271
+ // and the row that is written there.
272
+ const CUSTOM_PROVIDER = 'custom';
242
273
  // Ollama's stock port: the address a freshly installed daemon actually answers
243
274
  // on, so the field's placeholder is also the value a blank save falls back to.
244
275
  const LOCAL_DEFAULT_BASE = 'http://localhost:11434';
@@ -1102,6 +1133,12 @@ function renderStatus(s) {
1102
1133
  // from the CLI) retires the connect block; with no key it stays. No-ops when
1103
1134
  // the panel is not mounted.
1104
1135
  applyWelcomeConnect();
1136
+ // The personalized line reads the same facts (a key that just landed, an
1137
+ // update that just downloaded), and the panel is painted BEFORE this read
1138
+ // returns on a cold start — so without this repaint the line would keep the
1139
+ // tip it chose while the key state was still unknown. No-ops when the panel
1140
+ // is not mounted.
1141
+ applyWelcomePersonal();
1105
1142
  // And the two-key pop-up's gate: a status read is the only thing that knows
1106
1143
  // either fact, so this is where the sheet is opened (a key still missing) or
1107
1144
  // retired (both present). No-ops while the markup is absent.
@@ -1123,6 +1160,10 @@ let updateDismissedFor = null;
1123
1160
  function renderUpdateBanner(state) {
1124
1161
  if (!els.updateBanner) return;
1125
1162
  lastUpdateState = state;
1163
+ // A downloaded update is the single most actionable fact the daily line can
1164
+ // report, and it arrives AFTER boot. Re-rendering here is what keeps the two
1165
+ // from disagreeing — the dismissal rule inside renderCompanion still wins.
1166
+ try { renderCompanion(); } catch { /* the banner must not fail on the card */ }
1126
1167
  const status = state && state.status;
1127
1168
  // 'unavailable' is the npm channel's silent verdict after a background check
1128
1169
  // that failed for a transient reason (no network yet at login, a VPN coming
@@ -1142,12 +1183,15 @@ function renderUpdateBanner(state) {
1142
1183
  let showRetry = false;
1143
1184
  const version = state.version ? `v${state.version} ` : '';
1144
1185
  if (status === 'available') {
1145
- // The npm channel cannot install itself, so its banner names the command
1146
- // instead of offering a Download button that would do nothing.
1186
+ // A channel the main process can install for (npm, pip) gets a real
1187
+ // Download button — `canDownload` is true only when it has an argv to run.
1188
+ // The rest (npx, a source checkout) have nothing to install, so the banner
1189
+ // names the command instead of offering a button that would do nothing.
1147
1190
  text = state.command
1148
1191
  ? `Update ${version}available — run: ${state.command}`
1149
1192
  : `Update ${version}available.`;
1150
1193
  showDownload = !!state.canDownload;
1194
+ if (showDownload) text = `Update ${version}available.`;
1151
1195
  } else if (status === 'downloading') {
1152
1196
  const pct = typeof state.progress === 'number' ? ` (${Math.round(state.progress)}%)` : '';
1153
1197
  text = `Downloading update${pct}…`;
@@ -2045,7 +2089,7 @@ function handleQuickLauncherPush(payload) {
2045
2089
  //
2046
2090
  // Field set per entry (aegis1/app.py:9349 `_memory_row_to_dict`): id,
2047
2091
  // timestamp, createdAt (epoch ms), source, role, tags[], content, session,
2048
- // importance, summary(bool), topics[], entities[], sentiment, tokenCount,
2092
+ // importance, summary(bool), entities[], sentiment, tokenCount,
2049
2093
  // embedding[]. There is no `tier` — L0–L3 is the local CLI engine's concept
2050
2094
  // (the upstream engine's memory module) and does not exist on the cloud rows, so
2051
2095
  // source/role are the real provenance axes here.
@@ -2125,6 +2169,166 @@ function keysOverlayOpen() {
2125
2169
  * above can ask it whether it is open without reaching into its internals. */
2126
2170
  let fpPanel = null;
2127
2171
 
2172
+ // ------------------------------------------------- providers & models toolbar
2173
+ //
2174
+ // The list is built from the SAME calls the Model card uses (models.listClasses
2175
+ // then models.listModels per class), so a provider that appears in one place
2176
+ // cannot be missing from the other. It is deliberately lazy: the per-class
2177
+ // catalog calls cost a network round trip each, and this card is a reference,
2178
+ // not the control the user is working in — so it loads on first sight (the
2179
+ // Refresh button, or the moment the card is actually opened) rather than
2180
+ // delaying boot behind four catalog fetches.
2181
+ let ptToolbar = null;
2182
+ let ptLoaded = false;
2183
+
2184
+ /** Collect every class's models payload, in class order. Never throws. */
2185
+ async function collectProviderRows() {
2186
+ let classes = [];
2187
+ try {
2188
+ classes = (await models.listClasses()) || [];
2189
+ } catch (err) {
2190
+ els.ptHint.textContent =
2191
+ `listClasses failed: ${err && err.message ? err.message : err}`;
2192
+ return [];
2193
+ }
2194
+ const rows = [];
2195
+ for (const c of classes) {
2196
+ if (!c || !c.class) continue;
2197
+ let list = null;
2198
+ try {
2199
+ list = await models.listModels(c.class);
2200
+ } catch (err) {
2201
+ // A class that failed is reported AS a class that failed, not dropped:
2202
+ // an absent row would read as "this build cannot address it", which is a
2203
+ // different and much worse claim than "its catalog call errored".
2204
+ list = { models: [], error: (err && err.message) || String(err) };
2205
+ }
2206
+ rows.push({ class: c.class, classLabel: c.label, classConfigured: Boolean(c.configured), list });
2207
+ }
2208
+ return rows;
2209
+ }
2210
+
2211
+ async function loadProviderToolbar(force) {
2212
+ if (!ptToolbar) return;
2213
+ if (ptLoaded && !force) return;
2214
+ if (els.ptHint) els.ptHint.textContent = 'listing providers…';
2215
+ const rows = await collectProviderRows();
2216
+ ptLoaded = true;
2217
+ ptToolbar.set(rows);
2218
+ }
2219
+
2220
+ /** Apply a toolbar pick to the Model card's own controls. */
2221
+ function applyProviderPick(sel) {
2222
+ if (!sel || !sel.class) return;
2223
+ if (!Array.from(els.classSelect.options).some((o) => o.value === sel.class)) return;
2224
+ els.classSelect.value = sel.class;
2225
+ localStorage.setItem(CLASS_KEY, sel.class);
2226
+ updateBudgetControls(sel.class);
2227
+ loadModels(sel.class).then(() => {
2228
+ // The model list is async, so the model can only be applied once the
2229
+ // options exist. Setting a value the picker does not offer would leave the
2230
+ // select on its first entry while the toolbar claims a different row.
2231
+ if (sel.model && Array.from(els.modelSelect.options).some((o) => o.value === sel.model)) {
2232
+ els.modelSelect.value = sel.model;
2233
+ els.modelSelect.dispatchEvent(new Event('change'));
2234
+ }
2235
+ });
2236
+ }
2237
+
2238
+ function mountProviderToolbar() {
2239
+ if (!window.aegisProviderToolbar || !els.ptList) return;
2240
+ ptToolbar = window.aegisProviderToolbar.mount({
2241
+ list: els.ptList,
2242
+ count: els.ptCount,
2243
+ search: els.ptSearch,
2244
+ hint: els.ptHint,
2245
+ onSelect: applyProviderPick,
2246
+ });
2247
+ if (els.ptRefresh) {
2248
+ els.ptRefresh.addEventListener('click', () => { loadProviderToolbar(true); });
2249
+ }
2250
+ // Lazy first load: fires once, when the card is first scrolled into view.
2251
+ if (typeof IntersectionObserver === 'function' && els.ptList.closest('section')) {
2252
+ const io = new IntersectionObserver((hits) => {
2253
+ if (hits.some((h) => h.isIntersecting)) {
2254
+ io.disconnect();
2255
+ loadProviderToolbar(false);
2256
+ }
2257
+ });
2258
+ io.observe(els.ptList.closest('section'));
2259
+ } else {
2260
+ loadProviderToolbar(false);
2261
+ }
2262
+ }
2263
+
2264
+ // ------------------------------------------------------- the daily line
2265
+ //
2266
+ // One message per local day, from facts this process already holds (see
2267
+ // companion.js for why every branch is fact-gated). Stored as `{day, id}` so a
2268
+ // dismissal survives a restart and a NEW day re-opens the card.
2269
+ const COMPANION_KEY = 'aegis.companion.dismissed';
2270
+
2271
+ function companionFacts() {
2272
+ const facts = { keyConfigured: keyConfigured === null ? undefined : keyConfigured };
2273
+ // Only counts the renderer can actually see are passed; everything else is
2274
+ // left undefined so companion.js drops that branch instead of guessing.
2275
+ //
2276
+ // The memory count comes from `memoryView.entries` — the inspector's own
2277
+ // fetched page — NOT from `fpPanel.entries()`, which this used to call and
2278
+ // which does not exist: renderer/fingerprint.js's mount() returns open/close/
2279
+ // isOpen/refresh/loadAutoMint/state/actions and nothing else, so that branch
2280
+ // was dead code and `memoryCount` was never populated, which in turn made the
2281
+ // memory branch in companion.js unreachable in the app. `memoryView.entries`
2282
+ // is empty until the inspector has been opened once (loadMemoryOverlay is
2283
+ // what fills it), so this reports a count only when the process has really
2284
+ // seen one — the same "no guess in the blank" rule as every other fact here.
2285
+ if (Array.isArray(memoryView.entries) && memoryView.entries.length) {
2286
+ facts.memoryCount = memoryView.entries.length;
2287
+ }
2288
+ if (lastUpdateState && lastUpdateState.status === 'downloaded' && lastUpdateState.version) {
2289
+ facts.updatePending = String(lastUpdateState.version);
2290
+ }
2291
+ if (classOptions.length && els.modelSelect && els.modelSelect.options.length > 1) {
2292
+ facts.modelCount = els.modelSelect.options.length;
2293
+ }
2294
+ return facts;
2295
+ }
2296
+
2297
+ function renderCompanion() {
2298
+ if (!window.aegisCompanion || !els.companionCard || !els.companionText) return;
2299
+ const now = new Date();
2300
+ let dismissed = null;
2301
+ try {
2302
+ dismissed = JSON.parse(localStorage.getItem(COMPANION_KEY) || 'null');
2303
+ } catch {
2304
+ dismissed = null;
2305
+ }
2306
+ if (window.aegisCompanion.dismissedToday(dismissed, now)) {
2307
+ els.companionCard.hidden = true;
2308
+ return;
2309
+ }
2310
+ const msg = window.aegisCompanion.pickMessage(companionFacts(), now);
2311
+ if (!msg || !msg.text) {
2312
+ els.companionCard.hidden = true;
2313
+ return;
2314
+ }
2315
+ els.companionText.textContent = msg.text;
2316
+ els.companionCard.dataset.kind = msg.kind;
2317
+ els.companionCard.dataset.messageId = msg.id;
2318
+ els.companionCard.hidden = false;
2319
+ }
2320
+
2321
+ function wireCompanion() {
2322
+ if (els.companionDismiss) {
2323
+ els.companionDismiss.addEventListener('click', () => {
2324
+ localStorage.setItem(COMPANION_KEY, JSON.stringify({ day: window.aegisCompanion
2325
+ ? window.aegisCompanion.dayKey(new Date()) : '', id: els.companionCard.dataset.messageId || '' }));
2326
+ els.companionCard.hidden = true;
2327
+ });
2328
+ }
2329
+ renderCompanion();
2330
+ }
2331
+
2128
2332
  /** "3m ago" / "2d ago" from epoch ms. Empty string when unknown. */
2129
2333
  function relTime(ms) {
2130
2334
  if (!Number.isFinite(ms)) return '';
@@ -2156,7 +2360,6 @@ function normalizeMemoryEntry(raw) {
2156
2360
  source: asString(r.source) || '(unknown source)',
2157
2361
  role: asString(r.role),
2158
2362
  tags: asArray(r.tags),
2159
- topics: asArray(r.topics),
2160
2363
  entities: asArray(r.entities),
2161
2364
  session: asString(r.session),
2162
2365
  sentiment: asString(r.sentiment),
@@ -2307,7 +2510,7 @@ async function searchMemory(query) {
2307
2510
 
2308
2511
  // ------------------------------------------------- memory inspector overlay
2309
2512
 
2310
- /** Small coloured label used for source / role / tag / topic / entity. */
2513
+ /** Small coloured label used for source / role / tag / entity. */
2311
2514
  function memoryChip(text, cls) {
2312
2515
  const span = document.createElement('span');
2313
2516
  span.className = `mem-chip${cls ? ` ${cls}` : ''}`;
@@ -2345,7 +2548,7 @@ function memoryCard(e) {
2345
2548
  body.textContent = e.content || '(empty)';
2346
2549
  li.appendChild(body);
2347
2550
 
2348
- const pills = [...e.tags, ...e.topics, ...e.entities];
2551
+ const pills = [...e.tags, ...e.entities];
2349
2552
  if (pills.length) {
2350
2553
  const row = document.createElement('div');
2351
2554
  row.className = 'mem-card-pills';
@@ -2381,7 +2584,6 @@ function memoryMatches(e, q) {
2381
2584
  e.session,
2382
2585
  e.sentiment,
2383
2586
  ...e.tags,
2384
- ...e.topics,
2385
2587
  ...e.entities,
2386
2588
  ].join('\n').toLowerCase();
2387
2589
  return hay.includes(String(q == null ? '' : q).toLowerCase());
@@ -2644,16 +2846,262 @@ function applyGreeting() {
2644
2846
  : 'Working late?';
2645
2847
  }
2646
2848
 
2647
- /** Prefill the composer with a starter question, caret at the end. */
2648
- function quickAction(text) {
2649
- const inp = els.prompt;
2650
- if (!inp) return;
2651
- inp.value = text;
2652
- inp.focus();
2849
+ /** Pack the facts above into the shape renderer/companion.js consumes.
2850
+ *
2851
+ * The welcome line and the sidebar's daily message answer the same question —
2852
+ * "what does this install actually know that is worth saying?" — so they read
2853
+ * the SAME fact bag rather than growing a second, drifting opinion of what is
2854
+ * true. Everything absent is left undefined on purpose: companion.js drops a
2855
+ * branch whose facts are missing instead of filling the gap with a guess.
2856
+ */
2857
+ function personalizeFacts() {
2858
+ const facts = companionFacts();
2859
+ // companionFacts() only returns `keyConfigured` when the status read has
2860
+ // landed (null means "not read yet"). The welcome panel is painted before
2861
+ // that read on a cold start, and the panel is the one place where "paste your
2862
+ // key" is the useful thing to say — so an unread status, not a known keyless
2863
+ // machine, must NOT be reported as keyConfigured === false.
2864
+ if (facts.keyConfigured === undefined) delete facts.keyConfigured;
2865
+ /*
2866
+ * The provider-class-with-models-but-no-key case is deliberately NOT passed
2867
+ * here. It is a sidebar diagnosis (it names the Provider settings card the
2868
+ * user was already looking at); in the welcome panel the byok route below is
2869
+ * the same information written as an invitation, and showing both in one
2870
+ * column reads as two warnings about one thing.
2871
+ */
2872
+ return facts;
2873
+ }
2874
+
2875
+ /**
2876
+ * The one personalized line in the welcome panel.
2877
+ *
2878
+ * This replaced five generic topic pills ("Write code", "Explain a concept", …)
2879
+ * that every install showed identically. A topic menu asks the user to invent
2880
+ * an intent that fits a canned frame; a line built from a fact about THEIR
2881
+ * install either saves them a step (a downloaded update, a key they have not
2882
+ * pasted yet) or tells them something true that they already own (memory
2883
+ * entries, sessions). Where no such fact exists it rotates one true tip — and
2884
+ * where even that is unavailable, the node stays hidden rather than printing an
2885
+ * empty box or a guess.
2886
+ *
2887
+ * Returns `{ text, kind }` or null. `kind` is 'action' (a concrete step) or
2888
+ * 'tip' (a true statement about the build); it is written to the dataset so CSS
2889
+ * can style an action line a little louder than a rotation.
2890
+ */
2891
+ function welcomePersonalize(now) {
2892
+ const panel = window.aegisCompanion;
2893
+ if (!panel || typeof panel.pickMessage !== 'function') return null;
2894
+ const msg = panel.pickMessage(personalizeFacts(), now || new Date());
2895
+ if (!msg || !msg.text) return null;
2896
+ return { text: msg.text, kind: msg.kind || 'tip' };
2897
+ }
2898
+
2899
+ /** Paint the personalized line into the cloned panel. */
2900
+ function applyWelcomePersonal() {
2901
+ const el = document.getElementById('welcome-personal');
2902
+ if (!el) return;
2903
+ const line = welcomePersonalize();
2904
+ if (!line) {
2905
+ // Nothing true to say: hidden, and empty, so a stale line can never be left
2906
+ // behind by a second render in the same session.
2907
+ el.textContent = '';
2908
+ el.hidden = true;
2909
+ return;
2910
+ }
2911
+ el.textContent = line.text;
2912
+ el.dataset.kind = line.kind;
2913
+ el.hidden = false;
2914
+ }
2915
+
2916
+ // ------------------------------------------------ welcome: the AI's own line
2917
+ //
2918
+ // The line above is written by this app, from facts it holds. It is always true
2919
+ // and it is always the same sentence, which is exactly why it stops being read
2920
+ // after the third launch: the same install gets the same words every day.
2921
+ //
2922
+ // So the panel asks the model instead — one short turn, once per local day,
2923
+ // given the facts (the local time of day, how many memory entries there are,
2924
+ // which model is selected, whether a key is present) and told to write ONE
2925
+ // sentence for this user. It is cached per day, so a New chat re-render costs
2926
+ // nothing, and the app's own line is what paints first: the panel never waits
2927
+ // on a network round trip to have something to say.
2928
+ //
2929
+ // WHAT THIS DELIBERATELY IS NOT. It is not a free-form agent turn. It carries
2930
+ // no tools, no history and no user text; `singlePass` keeps a pooled model id
2931
+ // from silently fanning out to (workers + 1) calls, and the budget is the small
2932
+ // fixed one below. A greeting that bills a multi-worker dispatch on every cold
2933
+ // start would be the most expensive sentence in the app.
2934
+ //
2935
+ // AND IT IS GATED, not assumed: a turn is only sent when something can answer
2936
+ // it — an AEGIS key for the pooled class, or a local model list with a model in
2937
+ // it. With neither, no request is made at all and the fact line stands alone.
2938
+ // Nothing is invented when the model cannot be reached; a failed call leaves the
2939
+ // fact line exactly as it was.
2940
+
2941
+ const AI_GREETING_KEY = 'aegis.welcomeAiLine';
2942
+ /** One sentence. Long enough for a model to finish a thought, short enough that
2943
+ * a truncated answer cannot fill the panel — the renderer trims to the last
2944
+ * sentence boundary below rather than showing half a clause. */
2945
+ const AI_GREETING_MAX_TOKENS = 120;
2946
+ /** Hard cap on what is put on screen, characters. A model that ignores the
2947
+ * length instruction must not be able to push the composer off the panel. */
2948
+ const AI_GREETING_MAX_CHARS = 200;
2949
+
2950
+ /** Local calendar day, YYYY-MM-DD. Local, for the same reason companion.js is:
2951
+ * "today" means the user's today, not UTC's. */
2952
+ function aiGreetingDay(now) {
2953
+ const d = now instanceof Date ? now : new Date(now == null ? Date.now() : now);
2954
+ if (Number.isNaN(d.getTime())) return '';
2955
+ const p = (n) => String(n).padStart(2, '0');
2956
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
2957
+ }
2958
+
2959
+ /** Can a turn actually be sent right now? Read from what the process knows. */
2960
+ function aiGreetingReachable() {
2961
+ if (keyConfigured === true) return true;
2962
+ // Local needs no account. A listed model means the daemon answered, which is
2963
+ // the only evidence this renderer has that anything is listening.
2964
+ return !!(els.modelSelect && els.modelSelect.options && els.modelSelect.options.length > 1);
2965
+ }
2966
+
2967
+ /** The cached line for today, or null. Cache is `{ day, text }`. */
2968
+ function aiGreetingCached(now) {
2969
+ let rec = null;
2653
2970
  try {
2654
- inp.setSelectionRange(text.length, text.length);
2655
- } catch (err) {
2656
- /* not supported on every input type; focus alone is enough */
2971
+ rec = JSON.parse(localStorage.getItem(AI_GREETING_KEY) || 'null');
2972
+ } catch {
2973
+ rec = null;
2974
+ }
2975
+ if (!rec || typeof rec.text !== 'string' || !rec.text) return null;
2976
+ if (rec.day !== aiGreetingDay(now)) return null;
2977
+ return rec.text;
2978
+ }
2979
+
2980
+ /** Trim a model's answer to one presentable sentence.
2981
+ *
2982
+ * Models wrap greetings in quotes, prefix them with "Sure!" or emit three
2983
+ * sentences when asked for one. Rather than trusting the instruction, this
2984
+ * takes the first sentence and drops the scaffolding — and returns '' when
2985
+ * what is left is too short to be a greeting, so the caller keeps the fact
2986
+ * line instead of printing a fragment. */
2987
+ function trimAiGreeting(raw) {
2988
+ let t = String(raw == null ? '' : raw).trim();
2989
+ if (!t) return '';
2990
+ // Reasoning models sometimes lead with a labelled preamble line.
2991
+ t = t.replace(/^(assistant|greeting|response)\s*:\s*/i, '');
2992
+ // ...and chat models lead with agreement ("Sure!", "Of course.") or an
2993
+ // announcement of what follows ("Here's your greeting:"). Neither is the
2994
+ // sentence that was asked for, and both read as a machine clearing its
2995
+ // throat at the top of the panel. Stripped repeatedly, so "Sure! Here you
2996
+ // go:" composed of two such segments goes too.
2997
+ let prev;
2998
+ do {
2999
+ prev = t;
3000
+ t = t.replace(
3001
+ /^(?:sure|certainly|of course|absolutely|okay|ok|got it|no problem|here(?:'s| is)\b[^.!?:]*)\s*[.!?:,—–-]\s*/i,
3002
+ '',
3003
+ );
3004
+ } while (t !== prev && t);
3005
+ // A quoted one-liner: keep what is inside the outermost quotes.
3006
+ const q = t.match(/^["'“”'']([\s\S]+)["'“”'']$/);
3007
+ if (q) t = q[1].trim();
3008
+ t = t.replace(/\s+/g, ' ');
3009
+ // First sentence, on a boundary that is followed by a space or end of string,
3010
+ // so "llama3.2:3b" and "a.m." are not cut in half.
3011
+ const m = t.match(/^[\s\S]*?[.!?](?=\s|$)/);
3012
+ if (m) t = m[0].trim();
3013
+ if (t.length > AI_GREETING_MAX_CHARS) {
3014
+ const cut = t.slice(0, AI_GREETING_MAX_CHARS);
3015
+ const at = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? '));
3016
+ t = at > 40 ? cut.slice(0, at + 1) : `${cut.replace(/[,;:\s]+$/, '')}…`;
3017
+ }
3018
+ return t.length >= 12 ? t : '';
3019
+ }
3020
+
3021
+ /** The one sentence the model is asked for. Facts only — no user text, no
3022
+ * history, nothing that could carry a conversation in. */
3023
+ function aiGreetingPrompt(facts) {
3024
+ const h = new Date().getHours();
3025
+ const when =
3026
+ h >= 5 && h < 12 ? 'morning'
3027
+ : h >= 12 && h < 17 ? 'afternoon'
3028
+ : h >= 17 && h < 22 ? 'evening'
3029
+ : 'late night';
3030
+ const known = [];
3031
+ if (typeof facts.memoryCount === 'number') {
3032
+ known.push(`${facts.memoryCount} entries saved in the user's memory`);
3033
+ }
3034
+ if (typeof facts.modelCount === 'number') known.push(`${facts.modelCount} models available`);
3035
+ if (typeof facts.updatePending === 'string') {
3036
+ known.push(`update ${facts.updatePending} is downloaded and installs on quit`);
3037
+ }
3038
+ if (facts.keyConfigured === true) known.push('the account key is connected');
3039
+ if (facts.keyConfigured === false) known.push('no account key is connected yet');
3040
+ return [
3041
+ 'Write the one sentence shown at the top of a coding assistant\'s empty',
3042
+ `chat panel. It is ${when} for the person reading it.`,
3043
+ known.length ? `What you know about their setup: ${known.join('; ')}.` : '',
3044
+ 'Rules: one sentence, at most 25 words, second person, no greeting cliché',
3045
+ 'like "Welcome back", no exclamation marks, no quotes, no preamble, no',
3046
+ 'markdown. Say something specific and useful about their work right now, or',
3047
+ 'ask one short question that helps them start. Reply with the sentence only.',
3048
+ ].filter(Boolean).join(' ');
3049
+ }
3050
+
3051
+ /** Ask the model for the line and paint it. Never throws, never blocks paint. */
3052
+ async function applyWelcomeAiLine() {
3053
+ const el = document.getElementById('welcome-personal');
3054
+ if (!el) return;
3055
+ if (typeof models === 'undefined' || !models || typeof models.chat !== 'function') return;
3056
+ if (!aiGreetingReachable()) return;
3057
+
3058
+ const now = new Date();
3059
+ const cached = aiGreetingCached(now);
3060
+ if (cached) {
3061
+ el.textContent = cached;
3062
+ el.dataset.kind = 'ai';
3063
+ el.hidden = false;
3064
+ return;
3065
+ }
3066
+
3067
+ const facts = personalizeFacts();
3068
+ const classId = els.classSelect && els.classSelect.value ? els.classSelect.value : 'aegis';
3069
+ const modelId = els.modelSelect && els.modelSelect.value ? els.modelSelect.value : '';
3070
+ const payload = {
3071
+ class: classId,
3072
+ prompt: aiGreetingPrompt(facts),
3073
+ maxTokens: AI_GREETING_MAX_TOKENS,
3074
+ // One cheap pass: no tools, no history, no fan-out. See the block comment.
3075
+ tools: false,
3076
+ singlePass: true,
3077
+ effort: 'low',
3078
+ };
3079
+ if (modelId) payload.model = modelId;
3080
+
3081
+ let data = null;
3082
+ try {
3083
+ data = await models.chat(payload);
3084
+ } catch {
3085
+ // Unreachable, refused, out of balance: the fact line already on screen
3086
+ // stands. Saying "could not write your greeting" would be noise about a
3087
+ // sentence the user never asked for.
3088
+ return;
3089
+ }
3090
+ const choice = (data && data.choices && data.choices[0]) || {};
3091
+ const text = trimAiGreeting((choice.message && choice.message.content) || '');
3092
+ if (!text) return;
3093
+
3094
+ // The panel may have been replaced (New chat, another session opened) while
3095
+ // the turn was in flight. Only paint into a node still in the document.
3096
+ const live = document.getElementById('welcome-personal');
3097
+ if (!live) return;
3098
+ live.textContent = text;
3099
+ live.dataset.kind = 'ai';
3100
+ live.hidden = false;
3101
+ try {
3102
+ localStorage.setItem(AI_GREETING_KEY, JSON.stringify({ day: aiGreetingDay(now), text }));
3103
+ } catch {
3104
+ /* storage disabled — the line is regenerated next launch, nothing breaks */
2657
3105
  }
2658
3106
  }
2659
3107
 
@@ -2664,9 +3112,11 @@ function renderWelcome() {
2664
3112
  els.messages.innerHTML = '';
2665
3113
  els.messages.appendChild(tpl.content.cloneNode(true));
2666
3114
  applyGreeting();
2667
- for (const btn of els.messages.querySelectorAll('.chat-quick-pill')) {
2668
- btn.addEventListener('click', () => quickAction(btn.dataset.quick || ''));
2669
- }
3115
+ applyWelcomePersonal();
3116
+ // Then, if a model can be reached, let it write the line (cached per day).
3117
+ // Deliberately not awaited: the panel is already complete without it, and a
3118
+ // slow or dead backend must not hold up the first paint.
3119
+ applyWelcomeAiLine();
2670
3120
  // The connect block's three controls. Bound here (not inline, not at boot)
2671
3121
  // because they only exist in the cloned panel; CSP `script-src 'self'` would
2672
3122
  // drop an inline handler anyway.
@@ -4304,7 +4754,14 @@ async function loadSettings() {
4304
4754
  const byokProviders = Array.isArray(byokData && byokData.providers) ? byokData.providers : [];
4305
4755
  for (const p of byokProviders) {
4306
4756
  if (!p || !p.id) continue;
4757
+ // Offered to the "your own models" datalist either way: `custom` is a
4758
+ // provider id the relay takes a key for, and a hand-declared model under
4759
+ // it is legitimate.
4307
4760
  byokProviderIds.push({ id: p.id, label: p.label || p.id });
4761
+ // ...but not rendered as a key row here. Its own card (below) collects the
4762
+ // base URL that makes the key spendable; a row here would save a key with
4763
+ // nowhere to send it, which is exactly the state this split removes.
4764
+ if (p.id === CUSTOM_PROVIDER) continue;
4308
4765
  const provider = `byok:${p.id}`;
4309
4766
  const local = settings.find((s) => s.provider === provider) || {
4310
4767
  provider, baseURL: '', configured: false, keyMask: null,
@@ -4355,6 +4812,19 @@ async function loadSettings() {
4355
4812
  els.settingsHint.textContent =
4356
4813
  `your own models: ${err && err.message ? err.message : err}`;
4357
4814
  }
4815
+
4816
+ // The custom endpoint's own card (#custom-provider-card) is painted from the
4817
+ // same two stores this function just read — the settings row it owns and the
4818
+ // declarations the block above lists — so the two surfaces cannot disagree
4819
+ // about whether `custom` is configured.
4820
+ try {
4821
+ await loadCustomProvider();
4822
+ } catch (err) {
4823
+ if (els.customStatus) {
4824
+ els.customStatus.textContent =
4825
+ `custom provider: ${err && err.message ? err.message : err}`;
4826
+ }
4827
+ }
4358
4828
  }
4359
4829
 
4360
4830
  /**
@@ -4638,6 +5108,166 @@ async function renderUserModelRows(byokProviderIds) {
4638
5108
  els.settingsList.appendChild(block);
4639
5109
  }
4640
5110
 
5111
+ /**
5112
+ * The custom-provider card: address + key + model id, one Save.
5113
+ *
5114
+ * WHY A CARD OF ITS OWN. Every other BYOK provider is reachable by key alone —
5115
+ * its upstream host is a constant aegis1 already knows (services/nexus_provider/
5116
+ * catalog.py), so the client's only input is the credential. `custom` is the
5117
+ * inverse: the HOST is the user's to name, and the server validates it per call
5118
+ * (`_byok_custom_base_url`: https, and no loopback/private/link-local target,
5119
+ * because that route is otherwise an SSRF primitive). So the row has a field no
5120
+ * other provider has, and it is not the same decision as "paste a key" — hence
5121
+ * its own card instead of a special case inside the catalog list.
5122
+ *
5123
+ * The model id is part of the same save because aegis1's `custom` descriptor
5124
+ * pins no `default_model`, so the relay catalog contributes no custom model row:
5125
+ * the picker can only offer what the user-models file declares. Saving the key
5126
+ * without declaring a model would leave a configured provider with nothing
5127
+ * selectable — half a setup that looks finished.
5128
+ *
5129
+ * Order of writes, and why: the settings row first (that is what holds the
5130
+ * address), the model declaration second, the re-render last. A failure between
5131
+ * them leaves a saved endpoint and no model, which the status line says outright
5132
+ * rather than reporting a clean save.
5133
+ */
5134
+ function paintCustomRow(cfg, declared) {
5135
+ if (!els.customBaseUrl) return;
5136
+ const saved = cfg || {};
5137
+ const declaredModels = Array.isArray(declared) ? declared : [];
5138
+ if (!els.customBaseUrl.value && saved.baseURL) els.customBaseUrl.value = saved.baseURL;
5139
+ if (els.customKey) {
5140
+ els.customKey.placeholder = saved.configured
5141
+ ? `key ${saved.keyMask || '••••'} (blank = keep)`
5142
+ : 'sk-…';
5143
+ els.customKey.value = '';
5144
+ }
5145
+ if (els.customRemove) els.customRemove.disabled = !saved.configured && !saved.baseURL;
5146
+ if (!els.customStatus) return;
5147
+ const parts = [];
5148
+ if (saved.baseURL) parts.push(`endpoint ${saved.baseURL}`);
5149
+ else parts.push('no endpoint saved');
5150
+ parts.push(saved.configured ? `key ${saved.keyMask || 'saved'}` : 'no key');
5151
+ parts.push(declaredModels.length
5152
+ ? `model${declaredModels.length === 1 ? '' : 's'} ${declaredModels.join(', ')}`
5153
+ : 'no model declared');
5154
+ els.customStatus.textContent = parts.join(' · ');
5155
+ }
5156
+
5157
+ /** Read the custom provider's saved state back (settings row + declarations). */
5158
+ async function loadCustomProvider() {
5159
+ if (!els.customBaseUrl) return;
5160
+ let cfg = null;
5161
+ let declared = [];
5162
+ try {
5163
+ const rows = (await models.settings.get()) || [];
5164
+ const found = (Array.isArray(rows) ? rows : []).find(
5165
+ (s) => s && s.provider === `byok:${CUSTOM_PROVIDER}`
5166
+ );
5167
+ if (found) {
5168
+ // `baseURL` IS part of the row `settings.get()` returns (settings.js
5169
+ // `get()`: provider, baseURL, configured, keyMask) — only the key itself
5170
+ // is masked. So the address is read, never re-written to be read back.
5171
+ cfg = {
5172
+ baseURL: found.baseURL || '',
5173
+ configured: Boolean(found.configured),
5174
+ keyMask: found.keyMask,
5175
+ };
5176
+ }
5177
+ } catch {
5178
+ /* store unreadable: the status line below says what is missing */
5179
+ }
5180
+ try {
5181
+ const report = await models.userModels.get();
5182
+ const map = (report && report.map) || {};
5183
+ declared = Array.isArray(map[CUSTOM_PROVIDER]) ? map[CUSTOM_PROVIDER] : [];
5184
+ } catch {
5185
+ /* no declarations readable: treat as none */
5186
+ }
5187
+ paintCustomRow(cfg, declared);
5188
+ }
5189
+
5190
+ /** Save the custom endpoint: settings row (address + key), then the model id. */
5191
+ async function saveCustomProvider() {
5192
+ if (!els.customBaseUrl) return;
5193
+ const baseURL = String(els.customBaseUrl.value || '').trim();
5194
+ const key = els.customKey ? String(els.customKey.value || '').trim() : '';
5195
+ const model = els.customModel ? String(els.customModel.value || '').trim() : '';
5196
+ const say = (t) => { if (els.customStatus) els.customStatus.textContent = t; };
5197
+
5198
+ if (!baseURL) {
5199
+ say('an address is required — aegis1 dials the endpoint you name, and refuses a custom call that names none.');
5200
+ return;
5201
+ }
5202
+ // Checked here as well as server-side so the refusal is a sentence next to the
5203
+ // field instead of a 400 that arrives after the model was declared. aegis1
5204
+ // refuses http:// and every non-public target for this provider; saying so up
5205
+ // front is the difference between a fixable typo and "custom is broken".
5206
+ if (!/^https:\/\//i.test(baseURL)) {
5207
+ say('refused: only an https:// endpoint is dialed — aegis1 rejects a plain-http or scheme-less base URL for this provider.');
5208
+ return;
5209
+ }
5210
+ if (!model) {
5211
+ say('a model id is required — the `custom` provider pins no default, so nothing would be selectable in the picker.');
5212
+ return;
5213
+ }
5214
+
5215
+ say('saving…');
5216
+ const provider = `byok:${CUSTOM_PROVIDER}`;
5217
+ try {
5218
+ const cfg = {};
5219
+ cfg.baseURL = baseURL;
5220
+ if (key) cfg.key = key;
5221
+ await models.settings.set(provider, cfg);
5222
+ } catch (err) {
5223
+ say(`endpoint not saved: ${err && err.message ? err.message : err}`);
5224
+ return;
5225
+ }
5226
+ try {
5227
+ await models.userModels.add(CUSTOM_PROVIDER, model);
5228
+ } catch (err) {
5229
+ say(`endpoint saved, model NOT declared: ${err && err.message ? err.message : err}`);
5230
+ await loadCustomProvider();
5231
+ await loadModels(els.classSelect.value).catch(() => {});
5232
+ return;
5233
+ }
5234
+ if (els.customModel) els.customModel.value = '';
5235
+ say(`saved ${baseURL} with model ${model} — pick it under provider "byok", then the model.`);
5236
+ await loadCustomProvider();
5237
+ await loadSettings();
5238
+ await loadModels(els.classSelect.value).catch(() => {});
5239
+ }
5240
+
5241
+ /** Forget the custom endpoint: the key row, then every declared custom model. */
5242
+ async function removeCustomProvider() {
5243
+ if (!els.customBaseUrl) return;
5244
+ const say = (t) => { if (els.customStatus) els.customStatus.textContent = t; };
5245
+ say('removing…');
5246
+ let removedModels = 0;
5247
+ try {
5248
+ const report = await models.userModels.get();
5249
+ const map = (report && report.map) || {};
5250
+ const declared = Array.isArray(map[CUSTOM_PROVIDER]) ? map[CUSTOM_PROVIDER] : [];
5251
+ for (const model of declared) {
5252
+ await models.userModels.remove(CUSTOM_PROVIDER, model);
5253
+ removedModels += 1;
5254
+ }
5255
+ } catch {
5256
+ /* declarations already gone, or unreadable — the key removal still runs */
5257
+ }
5258
+ try {
5259
+ await models.settings.remove(`byok:${CUSTOM_PROVIDER}`);
5260
+ } catch (err) {
5261
+ say(`remove failed: ${err && err.message ? err.message : err}`);
5262
+ return;
5263
+ }
5264
+ if (els.customBaseUrl) els.customBaseUrl.value = '';
5265
+ say(`removed the custom endpoint${removedModels ? ` and ${removedModels} declared model(s)` : ''}.`);
5266
+ await loadCustomProvider();
5267
+ await loadSettings();
5268
+ await loadModels(els.classSelect.value).catch(() => {});
5269
+ }
5270
+
4641
5271
  async function saveSetting(provider, key) {
4642
5272
  els.settingsHint.textContent = 'saving…';
4643
5273
  try {
@@ -5540,6 +6170,14 @@ async function init() {
5540
6170
  // File > Save as… / Export Session… — same "menu pings, renderer acts"
5541
6171
  // pattern as New Chat/Search above; both call exportSession() with the
5542
6172
  // format the menu label promised (see main.js buildAppMenu).
6173
+ // View > Authorship Panel… — the sidebar card that used to open the φ(α)
6174
+ // sheet is gone (sealing runs in the background), so the menu is the entry
6175
+ // point. `fpPanel` is mount()'s handle, assigned below in init().
6176
+ if (aegis.onMenuFingerprint) {
6177
+ aegis.onMenuFingerprint(() => {
6178
+ if (fpPanel && typeof fpPanel.open === 'function') fpPanel.open();
6179
+ });
6180
+ }
5543
6181
  if (aegis.onMenuExportMarkdown) aegis.onMenuExportMarkdown(() => exportSession('markdown'));
5544
6182
  if (aegis.onMenuExportJson) aegis.onMenuExportJson(() => exportSession('json'));
5545
6183
  // aegis:// deep link (main.js sendDeepLinkToWindow) — delivered once the
@@ -5562,6 +6200,20 @@ async function init() {
5562
6200
  }
5563
6201
  });
5564
6202
 
6203
+ // The custom-provider card (#custom-provider-card). Its three fields are one
6204
+ // decision, so Enter in any of them runs the same Save the button does.
6205
+ if (els.customSave) els.customSave.addEventListener('click', saveCustomProvider);
6206
+ if (els.customRemove) els.customRemove.addEventListener('click', removeCustomProvider);
6207
+ for (const box of [els.customBaseUrl, els.customKey, els.customModel]) {
6208
+ if (!box) continue;
6209
+ box.addEventListener('keydown', (e) => {
6210
+ if (e.key === 'Enter') {
6211
+ e.preventDefault();
6212
+ saveCustomProvider();
6213
+ }
6214
+ });
6215
+ }
6216
+
5565
6217
  // Billing actions in the Status card — see startBilling()/renderBillingError().
5566
6218
  els.upgradePlan.addEventListener('click', () => startBilling('upgrade'));
5567
6219
  els.topupBtn.addEventListener('click', () => startBilling('topup'));
@@ -5818,9 +6470,16 @@ async function init() {
5818
6470
 
5819
6471
  els.updateDownloadBtn.addEventListener('click', () => {
5820
6472
  els.updateDownloadBtn.disabled = true;
5821
- aegis.downloadUpdate().finally(() => {
5822
- els.updateDownloadBtn.disabled = false;
5823
- });
6473
+ // The resolved value is the post-install state: render it as well as
6474
+ // waiting for the push, so a channel whose installer finishes inside the
6475
+ // invoke still repaints (and so a failure surfaces even if the push
6476
+ // channel is not wired yet).
6477
+ Promise.resolve(aegis.downloadUpdate())
6478
+ .then((state) => { if (state && state.status) renderUpdateBanner(state); })
6479
+ .catch(() => {})
6480
+ .finally(() => {
6481
+ els.updateDownloadBtn.disabled = false;
6482
+ });
5824
6483
  });
5825
6484
 
5826
6485
  // The only place quitAndInstallUpdate is ever called — an explicit user
@@ -5869,6 +6528,16 @@ async function init() {
5869
6528
  // (a host that predates the IPC handlers) leaves the card on "checking…"
5870
6529
  // instead of crashing boot. Not awaited: the panel's first status read is a
5871
6530
  // filesystem stat, and the welcome panel must not wait on it.
6531
+ // The provider reference card and the daily line. Both are `?`-guarded by
6532
+ // their own module presence, so a window whose build predates these scripts
6533
+ // boots normally instead of throwing on a missing global.
6534
+ try {
6535
+ mountProviderToolbar();
6536
+ } catch (e) {
6537
+ if (els.ptHint) els.ptHint.textContent = `provider list unavailable: ${(e && e.message) || e}`;
6538
+ }
6539
+ wireCompanion();
6540
+
5872
6541
  if (window.aegisFingerprint) {
5873
6542
  try {
5874
6543
  fpPanel = window.aegisFingerprint.mount({ api: aegis });