@echomem/mcp 1.4.9 → 1.4.12

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 (45) hide show
  1. package/assets/hud/github.svg +1 -0
  2. package/dist/city/README.md +9 -0
  3. package/dist/city/echo-ai-city-only.html +54 -93
  4. package/dist/context-analysis/claude-native-canonical.js +35 -12
  5. package/dist/context-metrics/calculate.js +2 -15
  6. package/dist/context-metrics/estimator.js +45 -0
  7. package/dist/context-metrics/ledger.js +507 -0
  8. package/dist/context-metrics/parse-claude.js +227 -0
  9. package/dist/context-metrics/parse-codex.js +276 -0
  10. package/dist/hud/adapters.js +69 -198
  11. package/dist/hud/electron-main.js +3 -2
  12. package/dist/hud/fs.js +14 -0
  13. package/dist/hud/metric.js +4 -98
  14. package/dist/hud/monitor.js +1 -12
  15. package/dist/hud/render.js +4 -3
  16. package/dist/hud/server.js +30 -0
  17. package/dist/hud/web.js +409 -186
  18. package/dist/index.js +1 -0
  19. package/dist/setup-page/client-core.js +475 -0
  20. package/dist/setup-page/client-extraction.js +550 -0
  21. package/dist/setup-page/client-lifecycle.js +116 -0
  22. package/dist/setup-page/client-report-audit.js +818 -0
  23. package/dist/setup-page/client-report-city.js +204 -0
  24. package/dist/setup-page/client-report.js +6 -0
  25. package/dist/setup-page/client.js +15 -0
  26. package/dist/setup-page/document.js +37 -0
  27. package/dist/setup-page/styles-city-report.js +880 -0
  28. package/dist/setup-page/styles-context-audit.js +470 -0
  29. package/dist/setup-page/styles-extraction.js +821 -0
  30. package/dist/setup-page/styles-foundation.js +231 -0
  31. package/dist/setup-page/styles.js +11 -0
  32. package/dist/setup-page.js +6 -5623
  33. package/dist/setup.js +24 -10
  34. package/package.json +4 -4
  35. package/dist/city/10-problems-report.html +0 -649
  36. package/dist/city/_live.html +0 -37
  37. package/dist/city/_serve.mjs +0 -45
  38. package/dist/city/card-data.json +0 -15
  39. package/dist/city/chaos-to-clarity-pencil.html +0 -582
  40. package/dist/city/city-data.json +0 -248
  41. package/dist/city/echo-ai-city-only.template.html +0 -2271
  42. package/dist/city/generate-echo-city-only.mjs +0 -112
  43. package/dist/city/pencil-pie-generator.html +0 -883
  44. package/dist/city/pencil-webgl-landscape.html +0 -1239
  45. package/dist/city/spatial-fan-story.html +0 -479
@@ -0,0 +1,550 @@
1
+ // Generated by splitting the previous inline setup page. Keep this source readable: it is emitted into one browser IIFE.
2
+ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dashboard (post-auth extraction) ---------- */
3
+ function acceptReportScanId(envelope) {
4
+ var scanId = envelope && typeof envelope.scanId === "string" ? envelope.scanId : "";
5
+ if (!scanId) throw new Error("Report response is missing its scan identity.");
6
+ if (activeScanId && activeScanId !== scanId) throw new Error("The local scan identity changed before the report completed.");
7
+ activeScanId = scanId;
8
+ }
9
+ function validateProductionReportEnvelope(envelope) {
10
+ if (!envelope || typeof envelope !== "object" || envelope.schemaVersion !== 1 || envelope.mode !== "production") {
11
+ throw new Error("Report response has an unsupported production schema.");
12
+ }
13
+ acceptReportScanId(envelope);
14
+ if (["scanning", "ready", "empty", "failed"].indexOf(envelope.kind) === -1) {
15
+ throw new Error("Report response has an unknown state.");
16
+ }
17
+ if (envelope.kind === "scanning") return envelope;
18
+ if (envelope.kind === "failed") {
19
+ if (!envelope.error || typeof envelope.error.code !== "string" || typeof envelope.error.message !== "string") {
20
+ throw new Error("Report failure response is malformed.");
21
+ }
22
+ return envelope;
23
+ }
24
+ var r = envelope.report;
25
+ if (!r || typeof r !== "object" || r.schemaVersion !== 1 || r.dataOrigin !== "local-workspace-scan") {
26
+ throw new Error("Report did not prove that it came from this local workspace scan.");
27
+ }
28
+ var scale = r.scale || {};
29
+ var sessions = Number(scale.sessionCount);
30
+ var gs = gsClean(r.canonicalGoldenStandard);
31
+ var summary = (gs && gs.summary) || {};
32
+ var canonicalSessions = Number(summary.sessionsAnalyzed || 0);
33
+ var canonicalInput = Number(summary.officialInputTokens || 0);
34
+ var skipped = Number((gs && gs.diagnostics && gs.diagnostics.skippedSessions) || 0);
35
+ if (envelope.kind === "empty") {
36
+ if (sessions !== 0 || canonicalSessions !== 0 || canonicalInput !== 0 || Number(scale.totalInputTokens || 0) !== 0) {
37
+ throw new Error("An empty report contained non-empty usage data.");
38
+ }
39
+ return envelope;
40
+ }
41
+ if (!gs || !Number.isFinite(sessions) || sessions <= 0 || canonicalSessions !== sessions || canonicalInput <= 0 || skipped !== 0) {
42
+ throw new Error("Canonical analysis is incomplete or does not match the provider session cohort.");
43
+ }
44
+ if (!Array.isArray(r.repos) || r.repos.length === 0) {
45
+ throw new Error("The local report has no workspace rows to render.");
46
+ }
47
+ gsBillingProjection(r, gs);
48
+ return envelope;
49
+ }
50
+ function renderReportIssue(code, message) {
51
+ report = null;
52
+ reportEnvelope = null;
53
+ resetReportSurface();
54
+ setHead("Your local report could not be shown", "Needs attention");
55
+ app.className = "loading-panel";
56
+ app.innerHTML =
57
+ '<div style="padding:24px" data-report-state="failed">' +
58
+ '<h2>No backup or sample data was shown</h2>' +
59
+ '<p class="helper" style="margin-top:10px">' + esc(message || "The local scan did not produce a report that passed validation.") + '</p>' +
60
+ '<div class="warning" style="margin-top:16px;margin-bottom:0"><strong>Error ' + esc(code || "REPORT_FAILED") + '</strong><br />Close this tab, check the terminal, and run <code>echomem-mcp init</code> again.</div>' +
61
+ '</div>';
62
+ }
63
+ function renderEmptyReport() {
64
+ report = null;
65
+ reportEnvelope = null;
66
+ resetReportSurface();
67
+ setHead("No local coding history found", "Local scan complete");
68
+ app.className = "loading-panel";
69
+ app.innerHTML =
70
+ '<div style="padding:24px" data-report-state="empty">' +
71
+ '<h2>There is no local Codex or Claude Code history to analyze yet</h2>' +
72
+ '<p class="helper" style="margin-top:10px">Echo checked this machine and found no supported sessions with token usage. Start a coding session, then run <code>echomem-mcp init</code> again.</p>' +
73
+ '<p class="helper" style="margin-top:10px"><strong>No demo data, percentages, savings, persona, or city was substituted.</strong></p>' +
74
+ '</div>';
75
+ }
76
+ function renderBridgeIssue() {
77
+ renderReportIssue("BRIDGE_UNREACHABLE", "The local bridge stopped answering before your report was ready. Your terminal may have closed or the process may have stopped.");
78
+ }
79
+ function renderLogoutPending() {
80
+ setCityMode(false);
81
+ setHead("Signing out locally", "Working");
82
+ app.className = "loading-panel";
83
+ app.innerHTML = '<div style="padding:24px"><h2>Signing this device out</h2><p class="helper">If Echo is scanning local files, this can take a few seconds to answer.</p></div>';
84
+ }
85
+ function renderLogoutIssue(error) {
86
+ setCityMode(false);
87
+ setHead("Sign out did not finish", "Needs attention");
88
+ app.className = "loading-panel";
89
+ app.innerHTML =
90
+ '<div style="padding:24px"><h2>Echo on this machine did not answer</h2>' +
91
+ '<p class="helper">Keep the terminal open and retry. If the command exited, close this tab and run echomem-mcp init again.</p>' +
92
+ (error ? '<div class="warning" style="margin-top:16px;margin-bottom:0">' + esc(error) + '</div>' : '') +
93
+ '<div class="actions"><button id="logout" class="secondary">Retry sign out</button></div></div>';
94
+ var lo = document.getElementById("logout"); if (lo) lo.onclick = localLogout;
95
+ }
96
+ async function waitForConnection() {
97
+ if (connectionPollStarted) return;
98
+ connectionPollStarted = true;
99
+ try {
100
+ for (;;) {
101
+ if (decisionMade) return;
102
+ try {
103
+ var cfg = await getJson("/config");
104
+ authUrl = cfg.authUrl || authUrl;
105
+ if (cfg.connected) { connected = true; closeAuthWindow(); await waitForStats(); return; }
106
+ } catch (_) {}
107
+ await sleep(900);
108
+ }
109
+ } finally { connectionPollStarted = false; }
110
+ }
111
+ function startConnectionPoll() { void waitForConnection(); }
112
+ async function waitForStats() {
113
+ if (statsPollStarted) return;
114
+ statsPollStarted = true;
115
+ // Use the same one-reading-line stage as the partial scan state. This avoids a jarring
116
+ // old setup shell between device approval and the ready decision.
117
+ renderDashboard();
118
+ try {
119
+ var started = Date.now();
120
+ var lastError = "";
121
+ for (;;) {
122
+ if (decisionMade) return;
123
+ try {
124
+ var res = await fetchWithTimeout("/stats?nonce=" + encodeURIComponent(nonce), { credentials: "omit" }, 3000);
125
+ if (res.status === 200) {
126
+ stats = await res.json();
127
+ if (decisionMade) return;
128
+ renderDashboard();
129
+ if (stats && stats.partial) { await sleep(1200); continue; }
130
+ return;
131
+ }
132
+ if (res.status !== 202) throw new Error(await res.text() || ("HTTP " + res.status));
133
+ } catch (e) {
134
+ lastError = e && e.message ? e.message : String(e);
135
+ if (!stats && Date.now() - started > 25000) renderBridgeIssue(lastError);
136
+ if (Date.now() - started > 20000) { statsSlow = true; if (stats) renderDashboard(); }
137
+ }
138
+ await sleep(statsSlow ? 1500 : 500);
139
+ }
140
+ } finally { statsPollStarted = false; }
141
+ }
142
+ // The plate slot: ONLY the WebGL clay city (echo-extraction-plate.html). It self-fetches /report
143
+ // (your repos) and polls /progress (extraction state), growing as memories are created. The old SVG
144
+ // fallback board was removed — it was bleeding through behind the transparent canvas (the double board).
145
+ function plateBlock() {
146
+ return '<div class="exPlate"><div class="plateStack">' +
147
+ '<iframe class="plateGl" title="Extraction plate" src="/city/echo-extraction-plate.html?nonce=' + encodeURIComponent(nonce) + '"></iframe>' +
148
+ '</div></div>';
149
+ }
150
+ function renderDashboard(error) {
151
+ setCityMode(false);
152
+ setExtractMode(false);
153
+ setReadyMode(true);
154
+ var migratable = stats && stats.migratable ? stats.migratable : {};
155
+ var pending = stats && stats.migratable ? stats.migratable.pending : null;
156
+ var hasPendingCount = typeof pending === "number";
157
+ var discovery = stats && stats.discovery ? stats.discovery : {};
158
+ var isPartial = !!(stats && stats.partial);
159
+ var localScanReady = !isPartial || discovery.phase === "exact" || discovery.phase === "full";
160
+ var eta = migratable && migratable.eta ? migratable.eta : {};
161
+ var skippedActive = typeof migratable.skippedActive === "number" ? migratable.skippedActive : 0;
162
+ var sessions = stats && stats.sessions ? stats.sessions : {};
163
+ // The "quick" phase counts only this device's local ledger; it does not know what the account
164
+ // already imported elsewhere, so it over-counts (e.g. 10). The count is only trustworthy after the
165
+ // account check (phase "account"/"exact"/"full"), which corrects it (e.g. 3). Until then we show a
166
+ // "counting" state with no number, so the user never sees the count jump down.
167
+ var pendingTrusted = hasPendingCount && !!discovery.phase && discovery.phase !== "quick";
168
+ var knownDone = pendingTrusted && pending === 0;
169
+ var canExtract = pendingTrusted && pending > 0;
170
+ var pendN = hasPendingCount ? pending : 0;
171
+ var etaLabel = eta.estimatedLabel || migratable.estimatedLabel || "a couple of minutes";
172
+ var pendingCodex = typeof migratable.pendingCodex === "number" ? migratable.pendingCodex : null;
173
+ var pendingClaudeCode = typeof migratable.pendingClaudeCode === "number" ? migratable.pendingClaudeCode : null;
174
+ setHead("Turn coding history into memory", pendingTrusted ? (knownDone ? "Done" : "Ready") : (localScanReady ? "Ready" : "Scanning"));
175
+ var headline = !pendingTrusted
176
+ ? "Counting your new conversations…"
177
+ : (knownDone ? "You are all caught up." : "Echo found " + number(pendN) + " new coding session" + (pendN === 1 ? "" : "s") + ".");
178
+ var sub = !pendingTrusted
179
+ ? "Echo is matching your local history against what is already in memory."
180
+ : (knownDone
181
+ ? (skippedActive ? number(skippedActive) + " active conversation" + (skippedActive === 1 ? " is" : "s are") + " still changing, so Echo will pick them up later." : "Your history is already in EchoMem. Nothing new to extract.")
182
+ : "Turn them into memory your agents can recall: decisions, fixes, and where you left off. No more re-explaining your project every session.");
183
+ var extractLabel = !pendingTrusted ? "Counting…" : (canExtract ? "Start extraction" : "All caught up");
184
+ // Mount the dashboard shell ONCE so the WebGL plate iframe is never re-created across /stats polls.
185
+ // (Rebuilding app.innerHTML on every partial-scan poll reloaded the iframe → the city flickered.)
186
+ // Later polls only patch the text/counts in place; the iframe stays mounted.
187
+ var live = dashMounted && document.getElementById("exHeadline");
188
+ if (!live) {
189
+ extractMounted = false;
190
+ app.className = "";
191
+ // One reading line, top to bottom: the find (headline) → the value (sub) → quiet proof
192
+ // (source strip) → the single action with its reassurances attached → optional detail.
193
+ // The 19/9 split is EVIDENCE ("Echo scanned MY machine"), not data to study — so it is a
194
+ // one-line strip, not stat cards; the ETA is a commitment-moment note under the button,
195
+ // not a display-size headline.
196
+ app.innerHTML =
197
+ '<div id="exBanner"></div>' +
198
+ '<section class="readyDecision">' +
199
+ '<div class="readyHero">' +
200
+ '<img class="readyMascot" src="/hud-assets/echo-face-cutout.png" alt="" />' +
201
+ '<div class="exCopy">' +
202
+ '<h2 id="exHeadline"></h2>' +
203
+ '<p class="exSub" id="exSub"></p>' +
204
+ '</div>' +
205
+ '</div>' +
206
+ '<div class="proofStrip" id="exSources"></div>' +
207
+ '<div class="readyGo">' +
208
+ '<div class="actions">' +
209
+ '<button id="migrate" class="primary"></button>' +
210
+ '</div>' +
211
+ '<p class="ctaMeta" id="exEta"></p>' +
212
+ '</div>' +
213
+ '<div class="readyFoot">' +
214
+ '<details class="readyExplain" id="readyExplain"></details>' +
215
+ '</div>' +
216
+ '</section>';
217
+ document.getElementById("migrate").onclick = startMigrate;
218
+ dashMounted = true;
219
+ }
220
+ document.getElementById("exBanner").innerHTML =
221
+ (statsSlow && !stats ? '<div class="warning">Local counts are taking longer than expected. You can still ask Echo to extract anything unprocessed.</div>' : '') +
222
+ (error ? '<div class="error">' + esc(error) + '</div>' : '');
223
+ document.getElementById("exHeadline").textContent = headline;
224
+ document.getElementById("exSub").textContent = sub;
225
+ var codexN = pendingTrusted && pendingCodex !== null ? pendingCodex : (sessions.codex || 0);
226
+ var claudeN = pendingTrusted && pendingClaudeCode !== null ? pendingClaudeCode : (sessions.claudeCode || 0);
227
+ var alreadyN = Math.max(0, (sessions.total || 0) - pendN);
228
+ // Proof strip: one quiet line of evidence that the scan is real. Hidden when there is nothing to show.
229
+ var srcIcon = function (id, fallback) {
230
+ return '<span class="pfIcon"><img src="/agent-icons/' + id + '.png" alt="" onerror="this.style.display=&quot;none&quot;;this.nextElementSibling.style.display=&quot;grid&quot;;" /><span class="pfFallback">' + fallback + '</span></span>';
231
+ };
232
+ document.getElementById("exSources").innerHTML = !pendingTrusted
233
+ ? '<span class="pfNote">Scanning local history&hellip;</span>'
234
+ : (canExtract
235
+ ? '<span class="pf">' + srcIcon("codex", "CX") + '<strong>' + esc(number(codexN)) + '</strong> Codex</span>' +
236
+ '<span class="pfDot">&middot;</span>' +
237
+ '<span class="pf">' + srcIcon("claude-desktop", "CL") + '<strong>' + esc(number(claudeN)) + '</strong> Claude Code</span>' +
238
+ '<span class="pfNote">found on this Mac</span>'
239
+ : "");
240
+ // Reassurances live at the moment of commitment — right under the button.
241
+ document.getElementById("exEta").innerHTML = pendingTrusted
242
+ ? (canExtract
243
+ ? '<span class="cm">&asymp; ' + esc(etaLabel) + '</span><span class="cmDot">&middot;</span><span class="cm">runs locally</span>' + (alreadyN ? '<span class="cmDot">&middot;</span><span class="cm">' + esc(number(alreadyN)) + ' already-saved skipped</span>' : '')
244
+ : 'Nothing new to extract right now.')
245
+ : 'Echo is checking local history against saved memory&hellip;';
246
+ document.getElementById("readyExplain").innerHTML =
247
+ '<summary>What exactly happens?</summary>' +
248
+ '<p>A coding session is a local Codex or Claude Code transcript from this Mac. Echo extracts compact memories from it, not a full chat replay.</p>' +
249
+ '<div class="readySteps">' +
250
+ '<div class="readyStep"><strong>Read locally</strong><span>Echo scans session logs on this device.</span></div>' +
251
+ '<div class="readyStep"><strong>Skip repeats</strong><span>Anything already in memory is ignored.</span></div>' +
252
+ '<div class="readyStep"><strong>Save memory</strong><span>Decisions, fixes, context, and next steps become searchable.</span></div>' +
253
+ '</div>';
254
+ // While still counting, hide the primary button entirely; the headline + source split
255
+ // already say it is working, and the ready state should present one clear action.
256
+ var migrateBtn = document.getElementById("migrate");
257
+ if (pendingTrusted) {
258
+ migrateBtn.style.display = "";
259
+ migrateBtn.disabled = false;
260
+ migrateBtn.textContent = canExtract ? extractLabel : "Finish setup";
261
+ migrateBtn.onclick = canExtract ? startMigrate : skip;
262
+ } else {
263
+ migrateBtn.style.display = "none";
264
+ }
265
+ }
266
+ async function startMigrate() {
267
+ if (decisionMade) return;
268
+ decisionMade = true;
269
+ extractionEnded = false;
270
+ setCityMode(false);
271
+ setHead("Extracting", "Starting");
272
+ app.className = "";
273
+ app.innerHTML = '<div class="miniLoad"><h2>Starting extraction</h2><p>Echo is sizing the remaining jobs, then imports automatically. Finished work is saved as it goes.</p><div class="miniBar"><i></i></div></div>';
274
+ try {
275
+ var started = await postJson("/migrate", {});
276
+ extractMounted = false;
277
+ renderProgress(Object.assign({ status: "starting", completed: 0, failed: 0, extracted: 0 }, started));
278
+ await pollProgress();
279
+ } catch (e) {
280
+ decisionMade = false;
281
+ renderDashboard(routeError(e && e.message));
282
+ }
283
+ }
284
+ async function pollProgress() {
285
+ for (;;) {
286
+ if (extractionEnded) return;
287
+ var progress = await getJson("/progress");
288
+ if (extractionEnded) return;
289
+ renderProgress(progress);
290
+ if (progress.status === "completed" || progress.status === "failed") return;
291
+ await sleep(1200);
292
+ }
293
+ }
294
+ function renderProgress(progress) {
295
+ if (extractionEnded) return;
296
+ setCityMode(false);
297
+ setExtractMode(true);
298
+ var status = progress.status;
299
+ var done = status === "completed";
300
+ var stopped = status === "failed";
301
+ var total = progress.total || progress.jobCount || 0;
302
+ var completed = progress.completed || 0;
303
+ var failed = progress.failed || 0;
304
+ var extracted = progress.extracted || 0;
305
+ var pct = total > 0 ? Math.round((completed / total) * 100) : (done ? 100 : 0);
306
+ var preparing = (status === "starting" || status === "preparing");
307
+ app.className = "";
308
+ // Mount the shell ONCE so the WebGL plate iframe survives every poll (it self-polls /progress);
309
+ // each poll only rebuilds the text column.
310
+ if (!extractMounted) {
311
+ app.innerHTML =
312
+ '<section class="exHero"><div class="exCopy" id="exText"></div>' +
313
+ plateBlock() +
314
+ '</section>';
315
+ extractMounted = true;
316
+ }
317
+ var shell = app.querySelector(".exHero");
318
+ if (shell) {
319
+ shell.classList.toggle("exHeroResult", done || stopped);
320
+ shell.classList.toggle("exHeroActive", !done && !stopped);
321
+ }
322
+ var t = document.getElementById("exText");
323
+ if (!t) return;
324
+ if (done || stopped) {
325
+ setHead(done ? "Memory captured" : "Extraction stopped", done ? "Done" : "Needs attention");
326
+ var memoryNoun = extracted === 1 ? "new memory" : "new memories";
327
+ var savedMemoryLabel = extracted === 1 ? "memory saved" : "memories saved";
328
+ var conversationNoun = completed === 1 ? "conversation" : "conversations";
329
+ var timelineUrl = "https://yeahecho.com/memories/timeline";
330
+ var stopDetail = String(progress.error) === "key-expired"
331
+ ? "Your encryption key was not accepted. Re-run extraction; if it keeps failing, run <code>echomem-mcp unlock</code> first. Already-done conversations are skipped."
332
+ : esc(progress.error || "Re-run extraction to finish the rest. Already-done conversations are skipped.");
333
+ t.innerHTML =
334
+ '<div class="resultStage ' + (done ? "ok" : "warn") + '">' +
335
+ (done ? "" : '<p class="kicker-mono">Extraction stopped</p>') +
336
+ '<div class="resultTop">' +
337
+ '<div>' +
338
+ (done
339
+ ? '<h2 class="savedHeading"><span class="savedMain"><strong>' + esc(number(extracted)) + '</strong> ' + esc(savedMemoryLabel) + '</span><a class="viewEchoLink" href="' + timelineUrl + '" target="_blank" rel="noopener noreferrer">(view in yeahecho)</a></h2>'
340
+ : '<h2>Some conversations need another pass.</h2>' +
341
+ '<p class="exSub">' + stopDetail + '</p>' +
342
+ '<div class="resultNumber"><strong>' + esc(number(extracted)) + '</strong><span>' + esc(memoryNoun) + '</span></div>') +
343
+ '</div>' +
344
+ '</div>' +
345
+ (done ? tryItCard() : "") +
346
+ (done ? "" :
347
+ '<div class="memoryReceipt">' +
348
+ '<p class="receiptK">What just happened</p>' +
349
+ '<div class="finishStats">' +
350
+ '<div class="finishStat"><strong>' + esc(number(completed)) + '</strong><span>' + esc(conversationNoun) + ' extracted</span></div>' +
351
+ '<div class="finishStat"><strong>' + esc(number(extracted)) + '</strong><span>memories ready</span></div>' +
352
+ '</div>' +
353
+ '</div>') +
354
+ '</div>' +
355
+ (done ? hudTip() + doneCloseNote() : "");
356
+ wireTryButtons();
357
+ return;
358
+ }
359
+ setHead(preparing ? "Preparing" : "Extracting", preparing ? "Preparing" : "Extracting");
360
+ var checkingEdgeCases = failed > 0 && !preparing;
361
+ var statusLine = checkingEdgeCases
362
+ ? "Finishing a few checks without stopping the import."
363
+ : (progress.latest ? (progress.latest + (progress.latestRepo ? " · " + progress.latestRepo : "")) : (preparing ? "Getting your sessions ready locally…" : "Importing the next batch…"));
364
+ // Honest countdown: first ETA from batch size (throughput is rate-capped ≈ 26/min), then
365
+ // refined from the observed rate. Blowing past the FIRST estimate flips on the reassurance banner.
366
+ var countdownHtml = "";
367
+ var overdueHtml = "";
368
+ if (!preparing && total > 0) {
369
+ var now = Date.now();
370
+ if (!exStartAt) { exStartAt = now; exDeadline = now + Math.max(60, total * 2.6) * 1000; }
371
+ var elapsed = (now - exStartAt) / 1000;
372
+ var remainSec;
373
+ if (completed >= 5 && elapsed > 20) remainSec = (total - completed) / (completed / elapsed);
374
+ else remainSec = Math.max(0, (exDeadline - now) / 1000);
375
+ if (now > exDeadline) {
376
+ overdueHtml = '<div class="exOverdue"><strong>Taking longer than expected. Still working.</strong>' +
377
+ 'Everything already processed is saved. Ending now is safe; the next run resumes from the remaining conversations.</div>';
378
+ } else {
379
+ countdownHtml = '<p class="extractFacts"><strong>' + esc(fmtCountdown(remainSec)) + '</strong> left · saved as it goes · safe to end and resume</p>';
380
+ }
381
+ }
382
+ t.innerHTML =
383
+ '<div class="workbench">' +
384
+ '<h2>' + (preparing ? "Setting up the import." : "Building your memory.") + '</h2>' +
385
+ '<div class="exMeter"><strong>' + (preparing ? "Local" : esc(number(pct)) + "%") + '</strong><span>' + (preparing ? "sizing sessions" : (total ? esc(number(completed)) + " of " + esc(number(total)) + " conversations" : "starting")) + '</span></div>' +
386
+ '<div class="track' + (preparing ? " indef" : "") + '"><div class="fill"' + (preparing ? "" : ' style="width:' + pct + '%"') + '></div></div>' +
387
+ '<p class="exSub">' + esc(number(extracted)) + ' ' + (extracted === 1 ? "memory" : "memories") + ' captured' + (checkingEdgeCases ? ' · finishing checks' : "") + '.</p>' +
388
+ '<div class="exCurrent"><i></i><div><span>Now handling</span><strong>' + esc(statusLine) + '</strong></div></div>' +
389
+ countdownHtml + overdueHtml +
390
+ '<div class="extractEndRow"><button type="button" class="secondary" id="endExtraction">End for now</button><span>Ends cleanly; next time continues from saved work.</span></div>' +
391
+ '<p class="exHint">You can step away from this page. Echo saves each finished conversation as it goes.</p>' +
392
+ '</div>';
393
+ var endBtn = document.getElementById("endExtraction");
394
+ if (endBtn) endBtn.onclick = endExtraction;
395
+ }
396
+ function fmtCountdown(sec) {
397
+ sec = Math.max(0, Math.round(sec));
398
+ var m = Math.floor(sec / 60), s = sec % 60;
399
+ if (m >= 60) { var h = Math.floor(m / 60); return h + "h " + (m % 60) + "m"; }
400
+ return m > 0 ? m + "m " + s + "s" : s + "s";
401
+ }
402
+ function recallPrompt() {
403
+ return "Warm up from EchoMem: call search_memories for what I have been working on recently, then give me a 5-line recap of my current projects and where each one stands, so we can pick up right where I left off. If this is Codex or Claude Code, open the EchoMem context HUD by running echomem-hud app. Use the HUD's context-health score to tell me when to renew into a fresh session and carry a checkpoint forward. As we work, when we make a decision, finish a fix, or hit a milestone, save it with save_conversation. Use this prompt in Codex, Claude Code, or Claude Desktop; if you are in another chat surface, tell me EchoMem tools may not be available here.";
404
+ }
405
+ function setLaunchStatus(message) {
406
+ var node = document.getElementById("launchStatus");
407
+ if (node) node.textContent = message || "";
408
+ }
409
+ async function copyRecallPrompt() {
410
+ var prompt = recallPrompt();
411
+ try {
412
+ await navigator.clipboard.writeText(prompt);
413
+ return true;
414
+ } catch (_) {
415
+ return false;
416
+ }
417
+ }
418
+ async function copyPromptOnly() {
419
+ var copied = await copyRecallPrompt();
420
+ var copyBtn = document.getElementById("tryCopy");
421
+ if (copyBtn && copied) {
422
+ copyBtn.textContent = "Copied";
423
+ window.setTimeout(function () { copyBtn.textContent = "Copy prompt"; }, 1600);
424
+ }
425
+ setLaunchStatus(copied ? "Prompt copied." : "Could not copy automatically. Select the prompt and copy it.");
426
+ }
427
+ function codexDeepLink(prompt) {
428
+ var q = "prompt=" + encodeURIComponent(prompt);
429
+ if (workspacePath) q += "&path=" + encodeURIComponent(workspacePath);
430
+ return "codex://new?" + q;
431
+ }
432
+ async function tryInCodex() {
433
+ var prompt = recallPrompt();
434
+ var copied = await copyRecallPrompt();
435
+ setLaunchStatus(copied ? "Copied prompt. Opening Codex..." : "Opening Codex. If the prompt is empty, copy it manually.");
436
+ window.location.href = codexDeepLink(prompt);
437
+ }
438
+ async function openClaudeDesktop() {
439
+ var copied = await copyRecallPrompt();
440
+ setLaunchStatus(copied ? "Copied prompt. Opening Claude Desktop..." : "Opening Claude Desktop. If the prompt is empty, copy it manually.");
441
+ try {
442
+ var res = await postJson("/launch-agent", { agent: "claude-desktop" }, 8000);
443
+ setLaunchStatus(res && res.message ? res.message : "Prompt copied. Paste it into Claude Desktop.");
444
+ } catch (error) {
445
+ var msg = error && error.message ? error.message : "Could not auto-open Claude.";
446
+ if (msg.indexOf("prompt") === -1 && msg.indexOf("Prompt") === -1) msg += " The prompt is copied - open Claude Desktop and paste it.";
447
+ setLaunchStatus(msg);
448
+ }
449
+ }
450
+ function tryItCard() {
451
+ var prompt = recallPrompt();
452
+ return '<div class="tryCard">' +
453
+ '<h3 class="tryTitle">Echo MCP now available to use</h3>' +
454
+ '<div class="promptShell">' +
455
+ '<div class="promptControls">' +
456
+ '<button type="button" id="tryCopy" class="promptCopyLabel">Copy prompt</button>' +
457
+ '</div>' +
458
+ '<pre id="tryPrompt">' + esc(prompt) + '</pre>' +
459
+ '</div>' +
460
+ '<div class="agentLaunch" aria-label="Open in a coding agent">' +
461
+ '<button type="button" id="tryCodex"><span class="agentIcon"><img src="/agent-icons/codex.png" alt="" onerror="this.style.display=&quot;none&quot;;this.nextElementSibling.style.display=&quot;grid&quot;;" /><span class="agentFallback">CX</span></span><span class="agentText"><strong>Try in Codex</strong><small>copy prompt + open session</small></span></button>' +
462
+ '<button type="button" id="tryClaudeDesktop"><span class="agentIcon"><img src="/agent-icons/claude-desktop.png" alt="" onerror="this.style.display=&quot;none&quot;;this.nextElementSibling.style.display=&quot;grid&quot;;" /><span class="agentFallback">CL</span></span><span class="agentText"><strong>Open Claude Desktop</strong><small>copy prompt + open app</small></span></button>' +
463
+ '</div>' +
464
+ '<p class="launchStatus" id="launchStatus"></p>' +
465
+ '</div>';
466
+ }
467
+ function wireTryButtons() {
468
+ var copyBtn = document.getElementById("tryCopy");
469
+ if (copyBtn) copyBtn.addEventListener("click", copyPromptOnly);
470
+ var codexBtn = document.getElementById("tryCodex");
471
+ if (codexBtn) codexBtn.addEventListener("click", tryInCodex);
472
+ var claudeBtn = document.getElementById("tryClaudeDesktop");
473
+ if (claudeBtn) claudeBtn.addEventListener("click", openClaudeDesktop);
474
+ }
475
+ function hudTip() {
476
+ return '<p class="hudTip"><img src="/hud-assets/echo-face-cutout.png" alt="" /><span><strong>Live HUD:</strong> Echo watches the context window while you work. When the score drifts low, start a fresh session and carry the checkpoint forward. In Codex or Claude Code, ask &ldquo;<strong>open the EchoMem HUD</strong>&rdquo; or run <code>echomem-hud app</code>.</span></p>';
477
+ }
478
+ function doneCloseNote() {
479
+ return '<p class="doneCloseNote">You can safely close this page. EchoMem is set up, and your memories are saved.</p>';
480
+ }
481
+ function renderEnding() {
482
+ setCityMode(false);
483
+ setExtractMode(true);
484
+ setHead("Ending extraction", "Ending");
485
+ app.className = "";
486
+ app.innerHTML =
487
+ '<div class="miniLoad" style="margin:0 auto;text-align:center;place-items:center">' +
488
+ '<h2>Ending cleanly</h2>' +
489
+ '<p>Give Echo a moment to mark this setup as finished for now. Everything already captured stays saved.</p>' +
490
+ '<div class="miniBar"><i></i></div>' +
491
+ '</div>';
492
+ }
493
+ function renderSkipped() {
494
+ setCityMode(false);
495
+ setExtractMode(true);
496
+ setHead("Setup paused", "Skipped");
497
+ app.className = "";
498
+ app.innerHTML =
499
+ '<div class="skipStage">' +
500
+ '<span class="seal">Skipped for now</span>' +
501
+ '<h2>You can come back anytime.</h2>' +
502
+ '<p>Echo saved what already finished. When you are ready, run <code>echomem-mcp init</code> again — it picks up right where it left off.</p>' +
503
+ tryItCard() +
504
+ hudTip() +
505
+ '</div>';
506
+ wireTryButtons();
507
+ }
508
+ async function endExtraction() {
509
+ if (extractionEnded) return;
510
+ var ok = window.confirm("End extraction for now? You can come back anytime. Everything already captured stays saved, and the next run continues from what remains.");
511
+ if (!ok) return;
512
+ extractionEnded = true;
513
+ renderEnding();
514
+ try {
515
+ await postJson("/skip", {});
516
+ } catch (_) {
517
+ /* The bridge may close while ending; the local page can still show the paused state. */
518
+ }
519
+ await sleep(650);
520
+ renderSkipped();
521
+ }
522
+ async function skip() {
523
+ if (decisionMade) return;
524
+ decisionMade = true;
525
+ try {
526
+ await postJson("/skip", {});
527
+ renderSkipped();
528
+ } catch (e) {
529
+ decisionMade = false;
530
+ renderDashboard(e && e.message);
531
+ }
532
+ }
533
+ async function localLogout() {
534
+ if (decisionMade) return;
535
+ decisionMade = true;
536
+ renderLogoutPending();
537
+ try {
538
+ await postJson("/logout", {}, 12000);
539
+ stats = null;
540
+ statsSlow = false;
541
+ connected = false;
542
+ decisionMade = false;
543
+ renderForensicReport("Signed out locally. Connect EchoMem again to extract.");
544
+ } catch (e) {
545
+ decisionMade = false;
546
+ renderLogoutIssue(e && e.message ? e.message : String(e));
547
+ }
548
+ }
549
+
550
+ `;