aegis-desktop 1.0.13 → 1.0.15

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.
Files changed (96) hide show
  1. package/SOURCE-OFFER.txt +9 -0
  2. package/bin/aegis.js +1 -32
  3. package/lib/account-key.js +1 -56
  4. package/lib/auto.js +1 -430
  5. package/lib/avatar/companion.js +1 -698
  6. package/lib/avatar/cosmetics.js +1 -532
  7. package/lib/avatar/events.js +1 -344
  8. package/lib/avatar/holder-turn.js +1 -350
  9. package/lib/avatar/holders.js +1 -612
  10. package/lib/avatar/identity.js +1 -641
  11. package/lib/avatar/level.js +1 -368
  12. package/lib/avatar/mirror.js +1 -524
  13. package/lib/avatar/persona.js +1 -388
  14. package/lib/avatar/profile.js +1 -977
  15. package/lib/avatar/register.js +1 -307
  16. package/lib/avatar/store.js +1 -472
  17. package/lib/avatar/tiers.js +1 -38
  18. package/lib/avatar/transfer.js +1 -415
  19. package/lib/avatar/turn.js +1 -216
  20. package/lib/avatar/voice.js +1 -328
  21. package/lib/avatar/xp.js +1 -404
  22. package/lib/deep-link.js +1 -120
  23. package/lib/fingerprint.js +2 -347
  24. package/lib/google-connect.js +1 -169
  25. package/lib/local/agents.js +1 -105
  26. package/lib/local/autonomous.js +1 -1045
  27. package/lib/local/claude-pro.js +1 -993
  28. package/lib/local/context.js +1 -95
  29. package/lib/local/engine.js +1 -3228
  30. package/lib/local/entitlement.js +1 -654
  31. package/lib/local/git-scope.js +1 -228
  32. package/lib/local/local.js +1 -2135
  33. package/lib/local/prompt.js +1 -275
  34. package/lib/local/queue.js +1 -560
  35. package/lib/local/session-rounds.js +1 -208
  36. package/lib/local/shell.js +1 -680
  37. package/lib/local/telemetry.js +1 -470
  38. package/lib/local/tools.js +1 -1238
  39. package/lib/local/turn-guard.js +1 -300
  40. package/lib/local/worktree-lock.js +1 -235
  41. package/lib/provider-key-mirror.js +1 -252
  42. package/lib/quick-launcher.js +1 -84
  43. package/lib/settings.js +1 -890
  44. package/lib/sync/memory-queue.js +1 -60
  45. package/lib/sync/persist-gate.js +1 -148
  46. package/lib/sync/sessions.js +1 -106
  47. package/lib/window-state.js +1 -66
  48. package/main.js +3 -4390
  49. package/package.json +3 -5
  50. package/preload.js +3 -628
  51. package/renderer/app.js +1 -6560
  52. package/renderer/avatar/assemble.js +1 -335
  53. package/renderer/avatar/avatar.js +1 -380
  54. package/renderer/avatar/cards.js +1 -141
  55. package/renderer/avatar/hud.js +1 -206
  56. package/renderer/avatar/machine.js +1 -213
  57. package/renderer/avatar/pane.js +1 -439
  58. package/renderer/avatar/parts.js +1 -437
  59. package/renderer/budget.js +1 -168
  60. package/renderer/companion.js +1 -223
  61. package/renderer/diff.js +1 -359
  62. package/renderer/diffview.js +1 -336
  63. package/renderer/fingerprint.js +3 -720
  64. package/renderer/markdown.js +1 -168
  65. package/renderer/provider-toolbar.js +1 -134
  66. package/renderer/providers.js +1 -259
  67. package/renderer/quick.js +1 -191
  68. package/renderer/stream-policy.js +1 -306
  69. package/renderer/transcript-view.js +1 -323
  70. package/renderer/usage.js +1 -1239
  71. package/vendor/aegis.js +1 -2054
  72. package/vendor/attest.js +1 -442
  73. package/vendor/authorship-key.js +1 -410
  74. package/vendor/auto-provision.js +1 -134
  75. package/vendor/brain-catalog.js +1 -268
  76. package/vendor/byok-catalog.js +1 -230
  77. package/vendor/credentials.js +1 -494
  78. package/vendor/device-fingerprint.js +1 -141
  79. package/vendor/drive-connect.js +1 -85
  80. package/vendor/drive-upload.js +1 -174
  81. package/vendor/env-file.js +1 -1273
  82. package/vendor/fingerprint.js +1 -1064
  83. package/vendor/foreign-memory.js +1 -669
  84. package/vendor/google-signin.js +1 -125
  85. package/vendor/hooks.js +1 -430
  86. package/vendor/model-discovery.js +1 -330
  87. package/vendor/platform.js +1 -937
  88. package/vendor/prefs.js +1 -351
  89. package/vendor/presence-constants.js +1 -107
  90. package/vendor/presence.js +1 -438
  91. package/vendor/record-vault.js +3 -1055
  92. package/vendor/session-store.js +1 -548
  93. package/vendor/topup.js +1 -297
  94. package/vendor/touch.js +1 -458
  95. package/vendor/update.js +1 -704
  96. package/vendor/user-models.js +1 -381
package/renderer/quick.js CHANGED
@@ -1,193 +1,3 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
2
  // Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
3
- // AEGIS Code (aegiscode) — source: https://github.com/aegisinfo/aegiscode-plugin
4
- // Conveying a modified copy, or running one as a network service, owes the
5
- // corresponding source under this licence (§5/§13) — see LICENSING.md.
6
- 'use strict';
7
-
8
- /**
9
- * AEGIS Quick Launcher renderer — a one-shot prompt over window.models.chat
10
- * (the same LocalEngine transport the main window's chat uses), with the
11
- * agent loop's tool calling turned off (`tools: false`): a small always-
12
- * on-top popup is the wrong place for a tool-approval card to pop up in.
13
- *
14
- * Same contextIsolation/sandbox posture as renderer/app.js — talks only to
15
- * window.models / window.quickLauncher, both whitelisted by preload.js.
16
- */
17
-
18
- (function () {
19
- if (!window.models || !window.quickLauncher) {
20
- document.body.textContent =
21
- 'This page must run inside the AEGIS Electron host (window.models/window.quickLauncher missing).';
22
- throw new Error('window.models/window.quickLauncher unavailable — not running under Electron');
23
- }
24
-
25
- const models = window.models;
26
- const quickLauncher = window.quickLauncher;
27
-
28
- const CLASS = 'aegis';
29
- const MODEL_KEY = 'quick-launcher-model';
30
- const IDLE_HINT = 'Enter to ask · Esc to close';
31
-
32
- const els = {
33
- form: document.getElementById('ql-form'),
34
- prompt: document.getElementById('ql-prompt'),
35
- response: document.getElementById('ql-response'),
36
- modelLabel: document.getElementById('ql-model-label'),
37
- modelSelect: document.getElementById('ql-model-select'),
38
- stopBtn: document.getElementById('ql-stop'),
39
- pushBtn: document.getElementById('ql-push'),
40
- status: document.getElementById('ql-status'),
41
- };
42
-
43
- let busy = false;
44
- let sessionId = null;
45
- let lastPrompt = '';
46
- let lastResponse = '';
47
- let hasResult = false;
48
-
49
- function setStatus(text) {
50
- els.status.textContent = text;
51
- }
52
-
53
- function setBusy(next) {
54
- busy = next;
55
- els.stopBtn.hidden = !next;
56
- els.prompt.disabled = next;
57
- }
58
-
59
- /** Populate the model picker for the fixed 'aegis' class. Degrades to a
60
- * plain label (no picker) when there is no key configured or the catalog
61
- * call fails — the launcher still opens, it just can't send anything. */
62
- async function loadModel() {
63
- try {
64
- const classes = (await models.listClasses()) || [];
65
- const aegisClass = classes.find((c) => c.class === CLASS);
66
- if (!aegisClass || !aegisClass.configured) {
67
- els.modelLabel.textContent = 'Aegis Cloud — no key configured';
68
- els.modelSelect.hidden = true;
69
- return;
70
- }
71
- const data = await models.listModels(CLASS);
72
- const list = (data && data.models) || [];
73
- els.modelSelect.innerHTML = '';
74
- for (const m of list) {
75
- const opt = document.createElement('option');
76
- opt.value = m.id;
77
- opt.textContent = m.label || m.id;
78
- els.modelSelect.appendChild(opt);
79
- }
80
- const saved = localStorage.getItem(MODEL_KEY);
81
- if (saved && list.some((m) => m.id === saved)) {
82
- els.modelSelect.value = saved;
83
- }
84
- els.modelSelect.hidden = list.length <= 1;
85
- els.modelLabel.textContent = 'Aegis Cloud';
86
- } catch {
87
- els.modelLabel.textContent = 'Aegis Cloud — unavailable';
88
- els.modelSelect.hidden = true;
89
- }
90
- }
91
-
92
- els.modelSelect.addEventListener('change', () => {
93
- localStorage.setItem(MODEL_KEY, els.modelSelect.value);
94
- });
95
-
96
- async function ask(prompt) {
97
- if (!prompt || busy) return;
98
- hasResult = false;
99
- els.pushBtn.hidden = true;
100
- els.response.textContent = '';
101
- setBusy(true);
102
- setStatus('thinking…');
103
- sessionId = `quick-${Date.now()}-${Math.random().toString(36).slice(2)}`;
104
- let text = '';
105
- try {
106
- const res = await models.chat(
107
- {
108
- class: CLASS,
109
- model: els.modelSelect.value || undefined,
110
- prompt,
111
- tools: false,
112
- sessionId,
113
- },
114
- (chunk) => {
115
- if (chunk && typeof chunk.delta === 'string' && chunk.delta) {
116
- text += chunk.delta;
117
- els.response.textContent = text;
118
- els.response.scrollTop = els.response.scrollHeight;
119
- }
120
- }
121
- );
122
- const msg = res && res.choices && res.choices[0] && res.choices[0].message;
123
- const finalText = text || (msg && msg.content) || '';
124
- els.response.textContent = finalText;
125
- lastPrompt = prompt;
126
- lastResponse = finalText;
127
- hasResult = Boolean(finalText);
128
- els.pushBtn.hidden = !hasResult;
129
- setStatus(hasResult ? '⌘/Ctrl+⏎ to add this to the main chat' : 'no answer');
130
- } catch (err) {
131
- els.response.textContent = `Error: ${err && err.message ? err.message : err}`;
132
- setStatus('failed');
133
- } finally {
134
- setBusy(false);
135
- sessionId = null;
136
- }
137
- }
138
-
139
- function stop() {
140
- if (sessionId) models.cancel(sessionId).catch(() => {});
141
- }
142
-
143
- function pushToMain() {
144
- if (!hasResult) return;
145
- quickLauncher
146
- .pushToMain({
147
- prompt: lastPrompt,
148
- response: lastResponse,
149
- model: els.modelSelect.value || null,
150
- })
151
- .catch(() => {});
152
- }
153
-
154
- els.form.addEventListener('submit', (e) => {
155
- e.preventDefault();
156
- ask(els.prompt.value.trim());
157
- });
158
-
159
- els.prompt.addEventListener('keydown', (e) => {
160
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
161
- e.preventDefault();
162
- pushToMain();
163
- return;
164
- }
165
- if (e.key === 'Enter' && !e.shiftKey) {
166
- e.preventDefault();
167
- ask(els.prompt.value.trim());
168
- }
169
- });
170
-
171
- els.stopBtn.addEventListener('click', stop);
172
- els.pushBtn.addEventListener('click', pushToMain);
173
-
174
- // The window is shown/hidden, not reloaded, on every shortcut press (see
175
- // main.js toggleQuickLauncher) — so each time it regains focus is a fresh
176
- // "session" from the user's point of view and should start clean, unless a
177
- // stream from the previous session is still running in the background.
178
- window.addEventListener('focus', () => {
179
- if (busy) {
180
- els.prompt.focus();
181
- return;
182
- }
183
- els.prompt.value = '';
184
- els.response.textContent = '';
185
- els.pushBtn.hidden = true;
186
- hasResult = false;
187
- setStatus(IDLE_HINT);
188
- els.prompt.focus();
189
- });
190
-
191
- loadModel();
192
- els.prompt.focus();
193
- })();
3
+ "use strict";!function(){if(!window.models||!window.quickLauncher)throw document.body.textContent="This page must run inside the AEGIS Electron host (window.models/window.quickLauncher missing).",new Error("window.models/window.quickLauncher unavailable — not running under Electron");const e=window.models,t=window.quickLauncher,n="aegis",o="quick-launcher-model",l={form:document.getElementById("ql-form"),prompt:document.getElementById("ql-prompt"),response:document.getElementById("ql-response"),modelLabel:document.getElementById("ql-model-label"),modelSelect:document.getElementById("ql-model-select"),stopBtn:document.getElementById("ql-stop"),pushBtn:document.getElementById("ql-push"),status:document.getElementById("ql-status")};let s=!1,d=null,c="",i="",a=!1;function r(e){l.status.textContent=e}function m(e){s=e,l.stopBtn.hidden=!e,l.prompt.disabled=e}async function u(t){if(!t||s)return;a=!1,l.pushBtn.hidden=!0,l.response.textContent="",m(!0),r("thinking…"),d=`quick-${Date.now()}-${Math.random().toString(36).slice(2)}`;let o="";try{const s=await e.chat({class:n,model:l.modelSelect.value||void 0,prompt:t,tools:!1,sessionId:d},(e=>{e&&"string"==typeof e.delta&&e.delta&&(o+=e.delta,l.response.textContent=o,l.response.scrollTop=l.response.scrollHeight)})),m=s&&s.choices&&s.choices[0]&&s.choices[0].message,u=o||m&&m.content||"";l.response.textContent=u,c=t,i=u,a=Boolean(u),l.pushBtn.hidden=!a,r(a?"⌘/Ctrl+⏎ to add this to the main chat":"no answer")}catch(e){l.response.textContent=`Error: ${e&&e.message?e.message:e}`,r("failed")}finally{m(!1),d=null}}function p(){a&&t.pushToMain({prompt:c,response:i,model:l.modelSelect.value||null}).catch((()=>{}))}l.modelSelect.addEventListener("change",(()=>{localStorage.setItem(o,l.modelSelect.value)})),l.form.addEventListener("submit",(e=>{e.preventDefault(),u(l.prompt.value.trim())})),l.prompt.addEventListener("keydown",(e=>{if("Enter"===e.key&&(e.metaKey||e.ctrlKey))return e.preventDefault(),void p();"Enter"!==e.key||e.shiftKey||(e.preventDefault(),u(l.prompt.value.trim()))})),l.stopBtn.addEventListener("click",(function(){d&&e.cancel(d).catch((()=>{}))})),l.pushBtn.addEventListener("click",p),window.addEventListener("focus",(()=>{s||(l.prompt.value="",l.response.textContent="",l.pushBtn.hidden=!0,a=!1,r("Enter to ask · Esc to close")),l.prompt.focus()})),async function(){try{const t=(await e.listClasses()||[]).find((e=>e.class===n));if(!t||!t.configured)return l.modelLabel.textContent="Aegis Cloud — no key configured",void(l.modelSelect.hidden=!0);const s=await e.listModels(n),d=s&&s.models||[];l.modelSelect.innerHTML="";for(const e of d){const t=document.createElement("option");t.value=e.id,t.textContent=e.label||e.id,l.modelSelect.appendChild(t)}const c=localStorage.getItem(o);c&&d.some((e=>e.id===c))&&(l.modelSelect.value=c),l.modelSelect.hidden=d.length<=1,l.modelLabel.textContent="Aegis Cloud"}catch{l.modelLabel.textContent="Aegis Cloud — unavailable",l.modelSelect.hidden=!0}}(),l.prompt.focus()}();
@@ -1,308 +1,3 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
2
  // Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
3
- // AEGIS Code (aegiscode) — source: https://github.com/aegisinfo/aegiscode-plugin
4
- // Conveying a modified copy, or running one as a network service, owes the
5
- // corresponding source under this licence (§5/§13) — see LICENSING.md.
6
- 'use strict';
7
-
8
- /**
9
- * Pure decisions behind a live streaming turn: whether the transcript is
10
- * allowed to follow the stream, and whether a rejection means the user asked
11
- * to stop. Kept out of app.js — like budget.js — so both rules are
12
- * unit-testable from plain Node without a DOM or window.aegis. app.js only
13
- * calls into this.
14
- *
15
- * Both rules exist because a running turn used to own the transcript: it
16
- * re-pinned the view on every chunk (so earlier turns could not be read) and
17
- * it reported a deliberate stop as a failure (so interrupting lost the answer
18
- * already on screen).
19
- */
20
-
21
- /** Distance from the bottom still counted as "following the stream". */
22
- const STICK_SLOP_PX = 48;
23
-
24
- /**
25
- * Is the view at (or effectively at) the tail? A few pixels of slack absorbs
26
- * sub-pixel layout and a scrollbar that appears mid-stream; without it the
27
- * follow silently stops a fraction short.
28
- */
29
- function nearBottom(metrics, slop) {
30
- const m = metrics || {};
31
- const budget = Number.isFinite(slop) ? slop : STICK_SLOP_PX;
32
- const scrollHeight = Number(m.scrollHeight) || 0;
33
- const scrollTop = Number(m.scrollTop) || 0;
34
- const clientHeight = Number(m.clientHeight) || 0;
35
- return scrollHeight - scrollTop - clientHeight <= budget;
36
- }
37
-
38
- /**
39
- * May the transcript be scrolled to the bottom right now? `force` is for the
40
- * cases where the view genuinely must move (the user just sent, or a card is
41
- * blocking the turn); otherwise the reader's explicit scroll-away wins.
42
- *
43
- * The flag is checked before the measurement on purpose: a mid-stream reflow
44
- * can momentarily measure as "at the bottom" while the reader is nowhere near
45
- * it, which is exactly the case where re-pinning feels like a hijack.
46
- */
47
- function shouldFollow(metrics, opts) {
48
- const o = opts || {};
49
- if (o.force) return true;
50
- if (o.userScrolledUp) return false;
51
- return nearBottom(metrics, o.slop);
52
- }
53
-
54
- /**
55
- * Signatures of a real abort, as each layer spells it: Chromium's fetch
56
- * ("The user aborted a request."), undici/Node ("This operation was aborted"),
57
- * and an AbortSignal's own reason ("signal is aborted without reason").
58
- *
59
- * Deliberately NOT a bare /abort/i. A dropped socket surfaces as
60
- * "ECONNABORTED: connection aborted by peer" or "socket hang up", which
61
- * contains the same word but is a genuine transport failure — matching it
62
- * would relabel a real error as a deliberate stop and strand the user with a
63
- * silently truncated answer.
64
- */
65
- const ABORT_SIGNATURES = [
66
- /AbortError/,
67
- /\boperation was aborted\b/i,
68
- /\buser aborted\b/i,
69
- /\brequest\s+aborted\b/i,
70
- /\bsignal is aborted\b/i,
71
- /\baborted without reason\b/i,
72
- ];
73
-
74
- /**
75
- * Did the user ask for this turn to stop? The abort travels back over IPC,
76
- * which rebuilds the Error object and drops `name`/`code` — the renderer only
77
- * sees a wrapped message. So the caller's own flag is the primary signal, and
78
- * the signatures above are a backstop for an abort this renderer did not
79
- * initiate.
80
- */
81
- function isCancellation(err, opts) {
82
- const o = opts || {};
83
- if (o.userStopped) return true;
84
- if (!err) return false;
85
- if (err.name === 'AbortError' || err.code === 'ABORT_ERR') return true;
86
- const msg = typeof err === 'string' ? err : err.message;
87
- if (typeof msg !== 'string') return false;
88
- return ABORT_SIGNATURES.some((re) => re.test(msg));
89
- }
90
-
91
- /**
92
- * Should a stop request reach the turn running right now?
93
- *
94
- * `runningTurn` is the token of the turn in progress (null when idle) and
95
- * `stoppedTurn` is the token already asked to stop. The abort is not
96
- * instantaneous — the transport has to unwind before `send()`'s catch runs — so
97
- * a second press lands on a turn that is still nominally running. That second
98
- * press is the *same* intent and must not re-enter.
99
- *
100
- * The two writers keep the pair consistent: app.js only ever assigns
101
- * `stoppedTurn = runningTurn`, and claiming a new turn sets `stoppedTurn = null`
102
- * alongside `runningTurn = myTurn`. So `stoppedTurn` is never some *other*
103
- * turn's number, and `runningTurn !== stoppedTurn` means exactly "not already
104
- * stopping". What keeps a finished turn's stop off its successor is that
105
- * invariant plus the catch's `stoppedTurn === myTurn` — not a refusal here. A
106
- * press on a live successor is legitimate and does reach it.
107
- */
108
- function stopAppliesTo(runningTurn, stoppedTurn) {
109
- if (runningTurn == null) return false;
110
- return runningTurn !== stoppedTurn;
111
- }
112
-
113
- /**
114
- * What a cancelled turn leaves on screen, and what it honestly is.
115
- *
116
- * `streamedText || reasoningText` collapsed two different outcomes into one
117
- * string: a turn cut off after real answer text, and a turn cut off with
118
- * deliberation but no answer. The second is not a failure and the user should
119
- * keep it, but folding it in as if it were the answer loses the distinction —
120
- * so the case is named here and the caller labels it. With neither, the
121
- * placeholder is still returned rather than an empty bubble.
122
- */
123
- function salvageTurn(input) {
124
- const i = input || {};
125
- const streamed = typeof i.streamedText === 'string' ? i.streamedText : '';
126
- const reasoning = typeof i.reasoningText === 'string' ? i.reasoningText : '';
127
- if (streamed) return { text: streamed, reasoning: reasoning, kind: 'answer' };
128
- if (reasoning) return { text: reasoning, reasoning: '', kind: 'reasoning-only' };
129
- return { text: '(stopped before any output)', reasoning: '', kind: 'empty' };
130
- }
131
-
132
- /**
133
- * What a salvaged turn may contribute to the history that gets replayed —
134
- * as opposed to what it shows on screen.
135
- *
136
- * These were the same string, and on a cancelled turn that was a loop.
137
- * `salvageTurn` promotes the deliberation to `text` so the bubble is not
138
- * empty, which is right for the screen; but that same string was pushed into
139
- * `threadMessages` and written through `sync.append`, so the next turn fed the
140
- * model its own truncated chain-of-thought back as its prior reply. A model
141
- * handed an unfinished sentence finishes it — the same words every time,
142
- * because the same incomplete prefix went back every time. It survived a
143
- * reopen, and `openSession` rebuilds `threadMessages` from the stored rows, so
144
- * the loop outlived the session that started it.
145
- *
146
- * So the screen keeps the deliberation (labelled `reasoning only`) and the
147
- * history gets an annotation instead. The annotation is deliberate rather than
148
- * a blank: a stopped turn is a real exchange and the row still carries the
149
- * ledger fields the rolling meter folds — only the prose is replaced, so
150
- * billing and the token total are unchanged.
151
- */
152
- function replayableContent(kind, text) {
153
- if (kind === 'reasoning-only') return '(stopped: reasoning only)';
154
- if (kind === 'empty') return '(stopped before any output)';
155
- return text;
156
- }
157
-
158
- /**
159
- * The history a turn is actually replayed with: the same rows, minus anything
160
- * that is not the model's own prose, with runs of one role folded together.
161
- *
162
- * Both halves are needed at once, and each one alone breaks the other.
163
- * Keeping the annotations replayed a label as if the model had said it — the
164
- * loop `replayableContent` exists to stop. Dropping them left two `user` rows
165
- * adjacent whenever the turn between them contributed nothing (a cancel, an
166
- * empty response), and strict providers reject a non-alternating thread
167
- * outright: the fix for the loop would have turned the next turn into a 400 on
168
- * exactly the messages that needed it most. So the run is folded instead —
169
- * nothing that is not prose reaches the wire, and the roles still alternate.
170
- *
171
- * A runaway of empty rows disappears for the same reason: a message with no
172
- * content is not a message, and the provider would reject the empty block.
173
- * A leading assistant row goes too — there is no user request for it to
174
- * answer, so replaying it as the start of the conversation is a thread that
175
- * begins with the model talking to itself. The current prompt travels
176
- * separately (`prompt`, appended after `messages`), so nothing of the user's
177
- * is lost by either drop.
178
- *
179
- * Display and the ledger are untouched by design: openSession draws the
180
- * bubbles from `msgs` and folds the meter with `rollMessages(msgs)`, both
181
- * before this is called, so only what goes back to the model changes.
182
- */
183
- function replayHistory(messages) {
184
- const out = [];
185
- for (const m of Array.isArray(messages) ? messages : []) {
186
- if (!m) continue;
187
- // Same rows openSession always replayed: user and assistant. A `system`
188
- // row was never part of the renderer's thread and is not promoted to one
189
- // here.
190
- if (m.role !== 'user' && m.role !== 'assistant') continue;
191
- const content = typeof m.content === 'string' ? m.content
192
- : typeof m.text === 'string' ? m.text : '';
193
- if (!content.trim()) continue;
194
- if (m.role === 'assistant' && isPlaceholderContent(content)) continue;
195
- const last = out[out.length - 1];
196
- if (last && last.role === m.role) last.content = `${last.content}\n\n${content}`;
197
- else out.push({ role: m.role, content });
198
- }
199
- while (out.length && out[0].role === 'assistant') out.shift();
200
- return out;
201
- }
202
-
203
- /** True for the annotations above — rows that must never be replayed as prose. */
204
- function isPlaceholderContent(content) {
205
- const t = typeof content === 'string' ? content.trim() : '';
206
- if (!t) return false;
207
- return (
208
- t === '(stopped: reasoning only)' ||
209
- t === '(stopped before any output)' ||
210
- t === '(empty response)' ||
211
- t === '(no response)'
212
- );
213
- }
214
-
215
- /** The smallest stream growth, in characters, that earns a live-meter recompute.
216
- *
217
- * It was 256, and that number was the start-of-turn lag this rule exists to
218
- * avoid: the counter's first paint required a quarter kilobyte of text — ~64
219
- * tokens, several seconds into a slow turn — so the bubble opened with no
220
- * figure at all and then jumped to it, while the CLI's own tail
221
- * (`cli/src/chatflow.js`'s spinner, `↓${fmtTokens(state.streamed)}`) and the
222
- * reference engine both have a count climbing from the first frame. 32 chars
223
- * (~8 tokens) is a step the eye reads as continuous at the start of a reply,
224
- * which is the whole point of a live count.
225
- *
226
- * The cost bound is NOT this number — it is the proportional term below. At
227
- * 100k chars the step is 1/32 of the stream (3125), so lowering this floor
228
- * leaves the long-reply recompute count unchanged; it only buys paints in the
229
- * first ~1k characters, where each estimate is over a string too short to
230
- * matter. */
231
- const LIVE_METER_MIN_GROWTH = 32;
232
-
233
- /** …and the fraction of the whole stream that must have arrived since the last
234
- * one: a recompute comes due once the stream has grown by 1/this. */
235
- const LIVE_METER_GROWTH_FRACTION = 32;
236
-
237
- /**
238
- * Whether the live token meter is due to be recomputed, given how much of the
239
- * stream it last measured and how much of the stream has arrived now.
240
- *
241
- * The meter is paint-driven — `renderRollMeter` is called from inside the rAF
242
- * painter — and each recompute estimates the WHOLE accumulated reply plus
243
- * reasoning trace. `estimateTokens` counts code points by spreading the string
244
- * into an array (`[...String(text)].length`), so one estimate is linear in the
245
- * stream AND allocates a character-per-entry array. Per frame that is linear
246
- * per frame, i.e. quadratic over the turn, and it was measured on the thread
247
- * the user is trying to scroll: 0.37ms a frame on a 20k-char reply (2.2% of a
248
- * 60fps budget), 1.5ms at 100k (9%), 7.7ms at 400k (46%).
249
- *
250
- * The step is proportional rather than a fixed interval so the lag is bounded
251
- * at both ends. A short reply still updates every `LIVE_METER_MIN_GROWTH`
252
- * characters, so the number visibly moves; a long one updates once per 1/32 of
253
- * itself, which at the CLI's 4-chars-per-token rate means the figure can sit at
254
- * most ~3% below the truth before its next step — invisible on a token counter.
255
- *
256
- * Nothing about the settled number depends on this cadence: the turn's
257
- * committed value comes from `foldRoll`'s single estimate over the finished
258
- * text, which every exit path writes (success, stop, and error), and a reopened
259
- * thread is rebuilt by `rollMessages`. This only decides how often the preview
260
- * is allowed to cost anything.
261
- */
262
- function liveMeterDue(measured, streamChars) {
263
- const now = Number(streamChars) || 0;
264
- // Nothing has been painted yet this turn. The cursor is per-turn and starts
265
- // UNSET (see app.js `let meterChars = null`), and an unset cursor must not be
266
- // read as "measured an empty stream" — that made the first paint wait out the
267
- // whole step below, which is the start-of-turn dead zone. The first text to
268
- // arrive paints, so the figure is on screen from the first chunk rather than
269
- // from the first 32nd of the reply.
270
- if (measured === null || measured === undefined) return now > 0;
271
- const grown = now - (Number(measured) || 0);
272
- if (grown <= 0) return false;
273
- const step = Math.max(LIVE_METER_MIN_GROWTH, Math.floor(now / LIVE_METER_GROWTH_FRACTION));
274
- return grown >= step;
275
- }
276
-
277
- /**
278
- * The status glyph for one tool-activity line.
279
- *
280
- * Only an explicit `true` is a ✓. A call with no `ok` at all is a call whose
281
- * result never arrived — the tool-call-mid-abort case — and drawing it as ✓
282
- * claimed success for work the turn never finished. It gets its own mark
283
- * instead of ✗, because interrupted is not the same as failed.
284
- */
285
- function toolMark(tool) {
286
- const t = tool || {};
287
- if (t.ok === true) return '✓';
288
- if (t.ok === false) return '✗';
289
- return '⊘';
290
- }
291
-
292
- if (typeof module !== 'undefined' && module.exports) {
293
- module.exports = {
294
- nearBottom,
295
- shouldFollow,
296
- isCancellation,
297
- stopAppliesTo,
298
- salvageTurn,
299
- replayableContent,
300
- isPlaceholderContent,
301
- replayHistory,
302
- liveMeterDue,
303
- toolMark,
304
- STICK_SLOP_PX,
305
- LIVE_METER_MIN_GROWTH,
306
- LIVE_METER_GROWTH_FRACTION,
307
- };
308
- }
3
+ "use strict";const STICK_SLOP_PX=48;function nearBottom(e,t){const o=e||{},n=Number.isFinite(t)?t:STICK_SLOP_PX;return(Number(o.scrollHeight)||0)-(Number(o.scrollTop)||0)-(Number(o.clientHeight)||0)<=n}function shouldFollow(e,t){const o=t||{};return!!o.force||!o.userScrolledUp&&nearBottom(e,o.slop)}const ABORT_SIGNATURES=[/AbortError/,/\boperation was aborted\b/i,/\buser aborted\b/i,/\brequest\s+aborted\b/i,/\bsignal is aborted\b/i,/\baborted without reason\b/i];function isCancellation(e,t){if((t||{}).userStopped)return!0;if(!e)return!1;if("AbortError"===e.name||"ABORT_ERR"===e.code)return!0;const o="string"==typeof e?e:e.message;return"string"==typeof o&&ABORT_SIGNATURES.some((e=>e.test(o)))}function stopAppliesTo(e,t){return null!=e&&e!==t}function salvageTurn(e){const t=e||{},o="string"==typeof t.streamedText?t.streamedText:"",n="string"==typeof t.reasoningText?t.reasoningText:"";return o?{text:o,reasoning:n,kind:"answer"}:n?{text:n,reasoning:"",kind:"reasoning-only"}:{text:"(stopped before any output)",reasoning:"",kind:"empty"}}function replayableContent(e,t){return"reasoning-only"===e?"(stopped: reasoning only)":"empty"===e?"(stopped before any output)":t}function replayHistory(e){const t=[];for(const o of Array.isArray(e)?e:[]){if(!o)continue;if("user"!==o.role&&"assistant"!==o.role)continue;const e="string"==typeof o.content?o.content:"string"==typeof o.text?o.text:"";if(!e.trim())continue;if("assistant"===o.role&&isPlaceholderContent(e))continue;const n=t[t.length-1];n&&n.role===o.role?n.content=`${n.content}\n\n${e}`:t.push({role:o.role,content:e})}for(;t.length&&"assistant"===t[0].role;)t.shift();return t}function isPlaceholderContent(e){const t="string"==typeof e?e.trim():"";return!!t&&("(stopped: reasoning only)"===t||"(stopped before any output)"===t||"(empty response)"===t||"(no response)"===t)}const LIVE_METER_MIN_GROWTH=32,LIVE_METER_GROWTH_FRACTION=32;function liveMeterDue(e,t){const o=Number(t)||0;if(null==e)return o>0;const n=o-(Number(e)||0);return!(n<=0)&&n>=Math.max(32,Math.floor(o/32))}function toolMark(e){const t=e||{};return!0===t.ok?"✓":!1===t.ok?"✗":"⊘"}"undefined"!=typeof module&&module.exports&&(module.exports={nearBottom:nearBottom,shouldFollow:shouldFollow,isCancellation:isCancellation,stopAppliesTo:stopAppliesTo,salvageTurn:salvageTurn,replayableContent:replayableContent,isPlaceholderContent:isPlaceholderContent,replayHistory:replayHistory,liveMeterDue:liveMeterDue,toolMark:toolMark,STICK_SLOP_PX:STICK_SLOP_PX,LIVE_METER_MIN_GROWTH:32,LIVE_METER_GROWTH_FRACTION:32});