@echomem/mcp 1.3.1 → 1.3.2

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.
@@ -8,11 +8,24 @@ export function renderSetupPage() {
8
8
  <style>
9
9
  :root {
10
10
  color-scheme: light;
11
- --bg: #f7f7f4;
12
- --ink: #171717;
13
- --muted: #5c655f;
14
- --line: #d8d8d2;
15
- --panel: #fff;
11
+ --echo-ink-primary: #1a3a8f;
12
+ --echo-ink-accent: #4ecdc4;
13
+ --echo-ink-seal: #c7372f;
14
+ --echo-ink-text: #2b2a26;
15
+ --echo-ink-mute: #6b6f69;
16
+ --echo-ink-faint: #9a9b94;
17
+ --echo-paper-canvas: #faf7f0;
18
+ --echo-paper-white: #ffffff;
19
+ --echo-paper-soft: #f0efe9;
20
+ --echo-line-soft: #e4e0d6;
21
+ --echo-font-brand: "Avenir Next", "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif;
22
+ --echo-font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
23
+ --echo-font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
24
+ --bg: var(--echo-paper-canvas);
25
+ --ink: var(--echo-ink-text);
26
+ --muted: var(--echo-ink-mute);
27
+ --line: var(--echo-line-soft);
28
+ --panel: var(--echo-paper-white);
16
29
  --good-bg: #eff7ec;
17
30
  --good-line: #cbd9c8;
18
31
  --warn-bg: #fff6dc;
@@ -24,13 +37,15 @@ export function renderSetupPage() {
24
37
  body {
25
38
  margin: 0;
26
39
  min-height: 100vh;
27
- background: var(--bg);
28
- color: var(--ink);
29
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
40
+ background:
41
+ linear-gradient(180deg, rgba(245, 241, 231, 0.92), rgba(255, 255, 255, 0.96)),
42
+ var(--echo-paper-canvas);
43
+ color: var(--echo-ink-text);
44
+ font-family: var(--echo-font-body);
30
45
  letter-spacing: 0;
31
46
  }
32
47
  main {
33
- width: min(1080px, 100%);
48
+ width: min(1120px, 100%);
34
49
  margin: 0 auto;
35
50
  padding: clamp(18px, 4vw, 34px);
36
51
  }
@@ -46,25 +61,33 @@ export function renderSetupPage() {
46
61
  }
47
62
  .kicker {
48
63
  margin: 0;
49
- color: var(--muted);
50
- font-size: 13px;
51
- font-weight: 800;
64
+ font-family: var(--echo-font-mono);
65
+ color: var(--echo-ink-mute);
66
+ font-size: 12px;
67
+ font-weight: 700;
68
+ letter-spacing: 0.08em;
52
69
  text-transform: uppercase;
53
70
  }
54
71
  h1 {
55
72
  margin: 4px 0 0;
56
- font-size: clamp(34px, 5vw, 62px);
73
+ font-family: var(--echo-font-brand);
74
+ font-size: clamp(30px, 5vw, 56px);
75
+ font-weight: 900;
57
76
  line-height: 1;
58
77
  letter-spacing: 0;
78
+ color: var(--echo-ink-primary);
59
79
  }
60
80
  h2 {
61
81
  margin: 0;
82
+ font-family: var(--echo-font-brand);
62
83
  font-size: 22px;
84
+ font-weight: 900;
63
85
  line-height: 1.2;
64
86
  letter-spacing: 0;
87
+ color: var(--echo-ink-primary);
65
88
  }
66
89
  .pill {
67
- min-height: 38px;
90
+ min-height: 36px;
68
91
  display: inline-flex;
69
92
  align-items: center;
70
93
  gap: 8px;
@@ -72,7 +95,11 @@ export function renderSetupPage() {
72
95
  border-radius: 999px;
73
96
  padding: 0 14px;
74
97
  background: var(--panel);
75
- font-weight: 800;
98
+ font-family: var(--echo-font-mono);
99
+ font-size: 12px;
100
+ font-weight: 700;
101
+ letter-spacing: 0.04em;
102
+ text-transform: uppercase;
76
103
  white-space: nowrap;
77
104
  }
78
105
  .state {
@@ -80,208 +107,363 @@ export function renderSetupPage() {
80
107
  display: flex;
81
108
  align-items: center;
82
109
  justify-content: center;
83
- color: #3d463f;
84
- font-size: 18px;
110
+ color: var(--echo-ink-mute);
111
+ font-size: 16px;
85
112
  text-align: center;
86
113
  }
87
- .grid {
114
+
115
+ /* ---- pure loading skeleton (no copy) ---- */
116
+ .loading {
117
+ display: grid;
118
+ gap: 22px;
119
+ padding: clamp(24px, 6vw, 64px) 0;
120
+ }
121
+ .skeleton {
88
122
  display: grid;
89
- grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
90
- gap: 12px;
91
- margin-bottom: 18px;
123
+ gap: 14px;
124
+ max-width: 720px;
92
125
  }
93
- .grid.compact {
94
- grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
126
+ .sk {
127
+ height: 16px;
128
+ border-radius: 6px;
129
+ background: linear-gradient(90deg, #ece9e0, #d7dbe6, #ece9e0);
130
+ background-size: 220% 100%;
131
+ animation: pulse 1.4s ease-in-out infinite;
95
132
  }
96
- .metric, .panel {
97
- border: 1px solid var(--line);
133
+ .sk.title { height: 46px; width: 78%; }
134
+ .sk.w70 { width: 70%; }
135
+ .sk.w52 { width: 52%; }
136
+ .sk.w38 { width: 38%; }
137
+ .sk-grid {
138
+ display: grid;
139
+ grid-template-columns: repeat(4, minmax(0, 1fr));
140
+ gap: 10px;
141
+ max-width: 720px;
142
+ }
143
+ .sk-card {
144
+ height: 92px;
98
145
  border-radius: 8px;
99
- background: var(--panel);
100
- padding: 18px;
146
+ background: linear-gradient(90deg, #ece9e0, #d7dbe6, #ece9e0);
147
+ background-size: 220% 100%;
148
+ animation: pulse 1.4s ease-in-out infinite;
101
149
  }
102
- .metric {
103
- min-height: 132px;
104
- display: flex;
105
- flex-direction: column;
106
- justify-content: space-between;
150
+ @keyframes pulse {
151
+ 0% { background-position: 0% 50%; }
152
+ 100% { background-position: 220% 50%; }
107
153
  }
108
- .label {
109
- margin: 0;
110
- color: #626960;
111
- font-size: 13px;
112
- font-weight: 800;
154
+ .loadProgress { display: grid; gap: 8px; max-width: 720px; margin-top: 4px; }
155
+ .loadBar { height: 6px; border-radius: 999px; background: var(--echo-paper-soft); overflow: hidden; }
156
+ .loadFill { height: 100%; width: 0%; background: var(--echo-ink-primary); transition: width 300ms ease; }
157
+ .loadCaption { margin: 0; font-family: var(--echo-font-mono); font-size: 12px; color: var(--echo-ink-mute); }
158
+
159
+ /* ---- forensic report (ContextDoctorTwo) ---- */
160
+ .hero { padding: 4px 0 24px; }
161
+ .privacy {
162
+ display: grid;
163
+ grid-template-columns: auto 1fr;
164
+ gap: 10px;
165
+ align-items: baseline;
166
+ margin-bottom: 24px;
167
+ padding: 10px 0;
168
+ border-bottom: 1px solid var(--echo-line-soft);
169
+ }
170
+ .privacy span,
171
+ .kicker-mono,
172
+ .sectionHead span {
173
+ font-family: var(--echo-font-mono);
174
+ font-size: 12px;
175
+ font-weight: 700;
176
+ letter-spacing: 0.08em;
113
177
  text-transform: uppercase;
178
+ color: var(--echo-ink-primary);
114
179
  }
115
- .value {
116
- display: block;
117
- font-size: 36px;
118
- line-height: 1;
119
- letter-spacing: 0;
180
+ .privacy p { margin: 0; color: var(--echo-ink-mute); font-size: 14px; line-height: 1.55; }
181
+ .heroGrid {
182
+ display: grid;
183
+ grid-template-columns: minmax(0, 1fr) 280px;
184
+ gap: clamp(20px, 3vw, 36px);
185
+ align-items: stretch;
120
186
  }
121
- .detail, p {
187
+ .heroCopy { display: flex; flex-direction: column; justify-content: center; }
188
+ .heroCopy .kicker-mono { margin: 0 0 14px; color: var(--echo-ink-primary); }
189
+ .heroCopy h2 {
190
+ max-width: 720px;
122
191
  margin: 0;
123
- color: #4e554f;
124
- font-size: 15px;
125
- line-height: 1.45;
192
+ font-size: clamp(34px, 5vw, 58px);
193
+ line-height: 1.02;
126
194
  }
127
- .extract-hero {
128
- display: grid;
129
- grid-template-columns: minmax(0, 1.4fr) minmax(220px, 0.6fr);
130
- gap: 18px;
131
- align-items: end;
132
- margin-bottom: 12px;
195
+ .heroCopy p:not(.kicker-mono) {
196
+ margin: 18px 0 0;
197
+ font-size: clamp(15px, 1.25vw, 18px);
198
+ line-height: 1.62;
199
+ color: var(--echo-ink-text);
133
200
  }
134
- .hero-value {
135
- display: block;
136
- margin-top: 10px;
137
- font-size: clamp(42px, 7vw, 84px);
138
- line-height: 0.95;
139
- letter-spacing: 0;
201
+ .scoreCard, .promiseCard, .panel {
202
+ border: 1px solid var(--echo-line-soft);
203
+ border-radius: 8px;
204
+ background: var(--echo-paper-white);
205
+ }
206
+ .scoreCard { display: grid; align-content: center; min-height: 282px; padding: 22px; }
207
+ .scoreCard span, .promiseCard span {
208
+ font-family: var(--echo-font-mono);
209
+ font-size: 12px;
210
+ font-weight: 700;
211
+ letter-spacing: 0.08em;
212
+ text-transform: uppercase;
213
+ color: var(--echo-ink-mute);
140
214
  }
141
- .hero-copy {
142
- max-width: 640px;
215
+ .scoreCard strong {
143
216
  margin-top: 12px;
217
+ font-family: var(--echo-font-brand);
218
+ font-size: clamp(72px, 8vw, 96px);
219
+ font-weight: 900;
220
+ line-height: 0.9;
221
+ color: var(--echo-ink-seal);
222
+ font-variant-numeric: tabular-nums;
223
+ }
224
+ .scoreCard em {
225
+ margin-top: 4px;
226
+ font-style: normal;
227
+ font-weight: 900;
228
+ font-size: 22px;
229
+ color: var(--echo-ink-primary);
144
230
  }
145
- .mini-stack {
231
+ .scoreCard p, .promiseCard p { margin: 14px 0 0; color: var(--echo-ink-mute); font-size: 14px; line-height: 1.55; }
232
+ .statStrip {
146
233
  display: grid;
147
- gap: 8px;
234
+ grid-template-columns: repeat(4, minmax(0, 1fr));
235
+ gap: 10px;
236
+ margin-top: 22px;
148
237
  }
149
- .mini-row {
150
- display: flex;
151
- justify-content: space-between;
152
- gap: 12px;
153
- border-bottom: 1px solid #ecece7;
154
- padding-bottom: 8px;
238
+ .stat {
239
+ min-height: 92px;
240
+ padding: 14px;
241
+ border: 1px solid var(--echo-line-soft);
242
+ border-radius: 8px;
243
+ background: var(--echo-paper-white);
155
244
  }
156
- .bucket-row {
157
- align-items: center;
158
- display: grid;
159
- grid-template-columns: minmax(90px, 1fr) minmax(100px, auto);
160
- gap: 12px;
245
+ .stat strong {
246
+ display: block;
247
+ font-family: var(--echo-font-brand);
248
+ font-size: clamp(24px, 2.8vw, 34px);
249
+ font-weight: 900;
250
+ line-height: 1;
251
+ color: var(--echo-ink-primary);
252
+ font-variant-numeric: tabular-nums;
161
253
  }
162
- .bucket-value {
163
- text-align: right;
164
- font-weight: 800;
165
- white-space: nowrap;
254
+ .stat.alert strong { color: var(--echo-ink-seal); }
255
+ .stat span {
256
+ display: block;
257
+ margin-top: 8px;
258
+ font-family: var(--echo-font-mono);
259
+ font-size: 10px;
260
+ font-weight: 700;
261
+ letter-spacing: 0.06em;
262
+ text-transform: uppercase;
263
+ color: var(--echo-ink-mute);
166
264
  }
167
- .helper {
168
- margin-top: 10px;
169
- color: var(--muted);
170
- font-size: 14px;
265
+ .sourceNote {
266
+ max-width: 980px;
267
+ margin: 16px 0 0;
268
+ font-family: var(--echo-font-mono);
269
+ font-size: 11px;
270
+ line-height: 1.55;
271
+ color: var(--echo-ink-faint);
171
272
  }
172
- .notice, .done, .warning, .error {
173
- border-radius: 8px;
174
- padding: 14px 16px;
175
- margin-bottom: 18px;
273
+ .sourceNote code {
274
+ padding: 1px 5px;
275
+ border-radius: 4px;
276
+ background: var(--echo-paper-soft);
277
+ color: var(--echo-ink-primary);
176
278
  }
177
- .notice, .done {
178
- border: 1px solid var(--good-line);
179
- background: var(--good-bg);
180
- color: #203820;
279
+ .section { padding: 30px 0 0; margin-top: 30px; border-top: 1px solid var(--echo-line-soft); }
280
+ .sectionHead { display: flex; gap: 12px; align-items: baseline; margin-bottom: 18px; }
281
+ .sectionHead h2 { margin: 0; font-size: clamp(24px, 3.4vw, 38px); line-height: 1.05; }
282
+ .twoCol {
283
+ display: grid;
284
+ grid-template-columns: minmax(0, 1fr) minmax(340px, 0.8fr);
285
+ gap: clamp(20px, 3.5vw, 36px);
286
+ align-items: start;
181
287
  }
182
- .warning {
183
- border: 1px solid var(--warn-line);
184
- background: var(--warn-bg);
185
- color: #5e4300;
288
+ .storyText p { margin: 0 0 18px; color: var(--echo-ink-text); font-size: clamp(15px, 1.25vw, 18px); line-height: 1.62; }
289
+ .storyText p:last-child { margin-bottom: 0; }
290
+ .storyText b { color: var(--echo-ink-primary); }
291
+ .panel { display: grid; gap: 12px; padding: 16px; }
292
+ .promiseCard { padding: 22px; }
293
+ .promiseCard strong {
294
+ display: block;
295
+ margin-top: 12px;
296
+ font-family: var(--echo-font-brand);
297
+ font-size: clamp(34px, 4vw, 48px);
298
+ font-weight: 900;
299
+ line-height: 1;
300
+ color: var(--echo-ink-primary);
186
301
  }
187
- .error {
188
- border: 1px solid var(--error-line);
189
- background: var(--error-bg);
190
- color: #7d2117;
302
+ .promiseActions { display: grid; gap: 10px; margin-top: 18px; }
303
+ .promiseCard button, .promiseCard .button {
304
+ width: 100%;
305
+ min-height: 48px;
306
+ border-radius: 6px;
307
+ font-family: var(--echo-font-mono);
308
+ font-size: 13px;
309
+ font-weight: 800;
310
+ letter-spacing: 0.08em;
311
+ text-transform: uppercase;
191
312
  }
192
- .details {
193
- display: grid;
194
- grid-template-columns: repeat(auto-fit, minmax(min(290px, 100%), 1fr));
195
- gap: 12px;
196
- margin-bottom: 18px;
313
+ .promiseCard .primary { border-color: var(--echo-ink-primary); background: var(--echo-ink-primary); color: #fff; }
314
+ .promiseCard .secondary { background: #fff; color: var(--echo-ink-primary); border-color: var(--echo-ink-primary); }
315
+
316
+ /* histogram */
317
+ .histo { display: flex; align-items: flex-end; gap: 3px; height: 120px; padding-top: 8px; }
318
+ .histoBar { flex: 1; min-height: 2px; border-radius: 2px 2px 0 0; background: var(--echo-ink-primary); }
319
+ .histoBar.night { background: var(--echo-ink-seal); }
320
+ .histoAxis {
321
+ display: flex; justify-content: space-between; margin-top: 8px;
322
+ font-family: var(--echo-font-mono); font-size: 10px; letter-spacing: 0.06em; color: var(--echo-ink-faint);
197
323
  }
198
- dl {
199
- margin: 14px 0 0;
200
- display: grid;
201
- gap: 10px;
324
+ .chips { display: flex; flex-wrap: wrap; gap: 8px; }
325
+ .chips span {
326
+ padding: 6px 8px; border: 1px solid var(--echo-line-soft); border-radius: 6px;
327
+ background: var(--echo-paper-soft); font-family: var(--echo-font-mono); font-size: 11px; color: var(--echo-ink-primary);
202
328
  }
203
- .row {
204
- display: flex;
205
- justify-content: space-between;
206
- gap: 12px;
207
- border-bottom: 1px solid #ecece7;
208
- padding-bottom: 8px;
209
- }
210
- dt { color: #222; }
211
- dd { margin: 0; text-align: right; color: #222; }
212
- ol {
213
- list-style: none;
214
- margin: 14px 0 0;
215
- padding: 0;
216
- display: grid;
217
- gap: 10px;
329
+
330
+ /* reread / repo rows */
331
+ .rrow2 {
332
+ display: grid; grid-template-columns: 1fr auto; gap: 12px; align-items: baseline;
333
+ padding: 11px 0; border-bottom: 1px solid var(--echo-line-soft);
218
334
  }
219
- li {
220
- display: flex;
221
- justify-content: space-between;
222
- gap: 10px;
335
+ .row2:last-child { border-bottom: 0; }
336
+ .row2 b { font-family: var(--echo-font-mono); font-size: 13px; color: var(--echo-ink-primary); }
337
+ .row2 small { display: block; margin-top: 3px; font-size: 12px; color: var(--echo-ink-mute); }
338
+ .row2 strong {
339
+ font-family: var(--echo-font-brand); font-size: 24px; font-weight: 900;
340
+ color: var(--echo-ink-seal); font-variant-numeric: tabular-nums; white-space: nowrap;
223
341
  }
224
- .actions {
225
- display: flex;
226
- gap: 10px;
227
- flex-wrap: wrap;
228
- margin-top: 18px;
342
+ .repoRow strong { color: var(--echo-ink-primary); }
343
+ .tag {
344
+ display: inline-block; margin-left: 8px; padding: 2px 7px; border-radius: 999px;
345
+ background: var(--echo-paper-soft); font-family: var(--echo-font-mono); font-size: 10px;
346
+ font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--echo-ink-seal);
229
347
  }
230
- button, .button {
231
- min-height: 44px;
232
- display: inline-flex;
233
- align-items: center;
234
- justify-content: center;
235
- gap: 8px;
236
- border-radius: 8px;
237
- border: 1px solid #171717;
238
- padding: 0 16px;
239
- font: inherit;
240
- font-weight: 800;
241
- text-decoration: none;
242
- cursor: pointer;
348
+
349
+ /* cost split + model rows */
350
+ .segBar { display: flex; width: 100%; height: 22px; overflow: hidden; border-radius: 5px; background: var(--echo-paper-soft); }
351
+ .segBar span { display: block; min-width: 2px; }
352
+ .seg-output { background: var(--echo-ink-primary); }
353
+ .seg-cold { background: var(--echo-ink-mute); }
354
+ .seg-cw { background: var(--echo-ink-accent); }
355
+ .seg-cr { background: var(--echo-ink-seal); }
356
+ .costLegend { display: grid; gap: 6px; font-family: var(--echo-font-mono); font-size: 11px; color: var(--echo-ink-mute); }
357
+ .modelRow {
358
+ display: grid; grid-template-columns: 1fr auto auto; gap: 12px; align-items: baseline;
359
+ padding: 9px 0; border-bottom: 1px solid var(--echo-line-soft);
360
+ font-family: var(--echo-font-mono); font-size: 12px; color: var(--echo-ink-mute);
243
361
  }
244
- .primary {
245
- background: #171717;
246
- color: #fff;
362
+ .modelRow:last-child { border-bottom: 0; }
363
+ .modelRow b { font-size: 13px; color: var(--echo-ink-primary); }
364
+ .modelRow strong { font-family: var(--echo-font-brand); font-size: 18px; font-weight: 900; color: var(--echo-ink-seal); }
365
+
366
+ .nightCard {
367
+ margin-top: 18px; padding: 20px 22px; border: 1px solid var(--echo-ink-seal);
368
+ border-radius: 8px; background: var(--echo-paper-white);
247
369
  }
248
- .secondary {
249
- background: #fff;
250
- color: #171717;
370
+ .nightCard span {
371
+ font-family: var(--echo-font-mono); font-size: 11px; font-weight: 700; letter-spacing: 0.08em;
372
+ text-transform: uppercase; color: var(--echo-ink-seal);
251
373
  }
252
- button:disabled {
253
- cursor: not-allowed;
254
- opacity: 0.55;
374
+ .nightCard p { margin: 10px 0 0; font-size: 17px; line-height: 1.5; color: var(--echo-ink-text); }
375
+ .nightCard b { color: var(--echo-ink-primary); }
376
+
377
+ /* ---- dashboard (post-auth extraction) ---- */
378
+ .loading-panel, .doctor-copy, .doctor-score, .metric, .extract-panel {
379
+ border: 1px solid var(--line);
380
+ border-radius: 8px;
381
+ background: var(--panel);
255
382
  }
256
- .track {
257
- height: 12px;
258
- overflow: hidden;
259
- border-radius: 999px;
260
- background: #e8e8e1;
261
- margin: 16px 0 10px;
383
+ .status-strip {
384
+ display: flex; align-items: flex-start; gap: 12px;
385
+ border: 1px solid #c7d6e5; border-radius: 8px; background: #eef5fb; color: #1e3449;
386
+ padding: 13px 15px; margin-bottom: 18px;
262
387
  }
263
- .fill {
264
- height: 100%;
265
- width: 0%;
266
- background: #171717;
267
- transition: width 180ms ease;
388
+ .status-strip.good { border-color: var(--good-line); background: var(--good-bg); color: #203820; }
389
+ .status-strip.warn { border-color: var(--warn-line); background: var(--warn-bg); color: #5e4300; }
390
+ .status-strip .dot { flex: 0 0 auto; width: 12px; height: 12px; border-radius: 999px; margin-top: 4px; background: currentColor; }
391
+ .status-strip strong { display: block; line-height: 1.25; }
392
+ .status-strip p { margin-top: 3px; color: currentColor; font-size: 14px; }
393
+ .doctor-hero {
394
+ display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(260px, 0.8fr);
395
+ gap: 14px; margin-bottom: 14px; align-items: stretch;
268
396
  }
269
- .counts {
270
- display: flex;
271
- gap: 12px;
272
- flex-wrap: wrap;
273
- color: var(--muted);
274
- font-size: 14px;
275
- margin-bottom: 14px;
397
+ .doctor-copy, .doctor-score { padding: clamp(18px, 3vw, 26px); }
398
+ .doctor-copy { display: flex; flex-direction: column; justify-content: center; min-height: 240px; }
399
+ .doctor-copy h2 { max-width: 760px; margin-top: 10px; font-size: clamp(30px, 4.4vw, 52px); line-height: 1.02; }
400
+ .doctor-copy p:not(.label):not(.helper) { max-width: 720px; margin-top: 14px; font-size: clamp(15px, 1.5vw, 18px); color: var(--echo-ink-text); }
401
+ .doctor-score { display: grid; align-content: center; gap: 12px; }
402
+ .doctor-score > span { font-family: var(--echo-font-mono); color: var(--echo-ink-mute); font-size: 12px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
403
+ .doctor-score > strong { display: block; font-family: var(--echo-font-brand); font-size: clamp(34px, 5.5vw, 60px); font-weight: 900; line-height: 0.95; color: var(--echo-ink-primary); }
404
+ .doctor-mini { display: grid; gap: 8px; margin-top: 12px; }
405
+ .mini-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--echo-line-soft); padding-bottom: 8px; }
406
+ .mini-row span { color: var(--echo-ink-text); font-size: 14px; }
407
+ .mini-row strong { font-family: var(--echo-font-brand); font-size: 16px; font-weight: 900; color: var(--echo-ink-primary); font-variant-numeric: tabular-nums; white-space: nowrap; }
408
+ .doctor-stat-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; margin-bottom: 14px; }
409
+ .doctor-actions { margin: 0 0 18px; }
410
+ .metric { min-height: 122px; padding: 18px; display: flex; flex-direction: column; justify-content: space-between; }
411
+ .label { margin: 0; font-family: var(--echo-font-mono); color: var(--echo-ink-mute); font-size: 12px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
412
+ .value { display: block; font-family: var(--echo-font-brand); font-size: 34px; font-weight: 900; line-height: 1; color: var(--echo-ink-primary); }
413
+ .detail { margin: 0; color: var(--echo-ink-mute); font-size: 14px; line-height: 1.45; }
414
+ .extract-panel { padding: 18px; }
415
+ .bucket-row { align-items: center; display: grid; grid-template-columns: minmax(90px, 1fr) minmax(100px, auto); gap: 12px; }
416
+ .bucket-value { text-align: right; font-weight: 800; white-space: nowrap; }
417
+ .helper { margin-top: 10px; color: var(--echo-ink-mute); font-size: 14px; }
418
+ .notice, .done, .warning, .error { border-radius: 8px; padding: 14px 16px; margin-bottom: 18px; }
419
+ .notice, .done { border: 1px solid var(--good-line); background: var(--good-bg); color: #203820; }
420
+ .warning { border: 1px solid var(--warn-line); background: var(--warn-bg); color: #5e4300; }
421
+ .error { border: 1px solid var(--error-line); background: var(--error-bg); color: #7d2117; }
422
+ .details { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(290px, 100%), 1fr)); gap: 12px; margin-bottom: 18px; }
423
+ dl { margin: 14px 0 0; display: grid; gap: 10px; }
424
+ .row { display: flex; justify-content: space-between; gap: 12px; border-bottom: 1px solid #ecece7; padding-bottom: 8px; }
425
+ dt { color: var(--echo-ink-text); }
426
+ dd { margin: 0; text-align: right; color: var(--echo-ink-text); }
427
+ ol { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 10px; }
428
+ li { display: flex; justify-content: space-between; gap: 10px; }
429
+ .actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 18px; }
430
+ button, .button {
431
+ min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 8px;
432
+ border-radius: 8px; border: 1px solid var(--echo-ink-primary); padding: 0 16px;
433
+ font: inherit; font-weight: 800; text-decoration: none; cursor: pointer;
276
434
  }
435
+ .primary { background: var(--echo-ink-primary); color: #fff; }
436
+ .secondary { background: #fff; color: var(--echo-ink-primary); }
437
+ button:disabled { cursor: not-allowed; opacity: 0.55; }
438
+ .track { height: 12px; overflow: hidden; border-radius: 999px; background: #e8e8e1; margin: 16px 0 10px; }
439
+ .fill { height: 100%; width: 0%; background: var(--echo-ink-primary); transition: width 180ms ease; }
440
+ /* indeterminate "we're working on it" bar for the prep phase (before job counts move) */
441
+ .track.indef .fill { width: 40%; transition: none; animation: prepSlide 1.2s ease-in-out infinite; }
442
+ @keyframes prepSlide { 0% { transform: translateX(-110%); } 100% { transform: translateX(350%); } }
443
+ .prep-steps { list-style: none; margin: 18px 0 0; padding: 0; display: grid; gap: 11px; }
444
+ .prep-steps li { display: grid; grid-template-columns: 16px 1fr; gap: 11px; align-items: center; font-size: 15px; color: var(--echo-ink-mute); }
445
+ .prep-steps .dot { width: 13px; height: 13px; border-radius: 999px; border: 2px solid #cfcfc6; box-sizing: border-box; }
446
+ .prep-steps li.done { color: var(--echo-ink-text); }
447
+ .prep-steps li.done .dot { background: var(--echo-ink-primary); border-color: var(--echo-ink-primary); }
448
+ .prep-steps li.active { color: var(--echo-ink-primary); font-weight: 700; }
449
+ .prep-steps li.active .dot { border-color: var(--echo-ink-primary); animation: prepDot 1.1s ease-in-out infinite; }
450
+ @keyframes prepDot { 0%, 100% { box-shadow: 0 0 0 0 rgba(26,58,143,0.4); } 60% { box-shadow: 0 0 0 6px rgba(26,58,143,0); } }
451
+ .counts { display: flex; gap: 14px; flex-wrap: wrap; font-family: var(--echo-font-mono); color: var(--echo-ink-mute); font-size: 13px; letter-spacing: 0.02em; margin-bottom: 6px; }
452
+ .finish { display: grid; gap: 6px; place-items: center; text-align: center; padding: clamp(28px, 6vw, 64px) 0; }
453
+ .finishMark { width: 54px; height: 54px; border-radius: 999px; display: grid; place-items: center; font-size: 26px; font-weight: 900; margin-bottom: 8px; }
454
+ .finishMark.ok { background: var(--good-bg); color: #2f7f4f; border: 1px solid var(--good-line); }
455
+ .finishMark.warn { background: var(--warn-bg); color: #5e4300; border: 1px solid var(--warn-line); }
456
+ .finishBig { font-family: var(--echo-font-brand); font-size: clamp(48px, 8vw, 84px); font-weight: 900; line-height: 0.9; color: var(--echo-ink-primary); font-variant-numeric: tabular-nums; }
457
+ .finishLabel { font-family: var(--echo-font-mono); font-size: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--echo-ink-mute); }
458
+ .finish p { max-width: 560px; margin: 12px 0 0; color: var(--echo-ink-text); font-size: 16px; line-height: 1.6; }
459
+ .finish p.helper { color: var(--echo-ink-mute); font-size: 14px; margin-top: 6px; }
460
+ code { font-family: var(--echo-font-mono); font-size: 0.9em; padding: 1px 5px; border-radius: 4px; background: var(--echo-paper-soft); color: var(--echo-ink-primary); }
277
461
  .hidden { display: none; }
278
- @media (max-width: 720px) {
279
- .extract-hero {
280
- grid-template-columns: 1fr;
281
- }
282
- .hero-value {
283
- font-size: clamp(38px, 13vw, 58px);
284
- }
462
+ @media (max-width: 820px) {
463
+ .heroGrid, .twoCol, .statStrip, .privacy, .doctor-hero, .sk-grid, .doctor-stat-strip { grid-template-columns: 1fr; }
464
+ .doctor-stat-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
465
+ .heroCopy h2 { font-size: 34px; }
466
+ .scoreCard { min-height: 220px; }
285
467
  }
286
468
  </style>
287
469
  </head>
@@ -290,11 +472,11 @@ export function renderSetupPage() {
290
472
  <header>
291
473
  <div>
292
474
  <p class="kicker">EchoMem device setup</p>
293
- <h1 id="title">Connect this device</h1>
475
+ <h1 id="title">Context Doctor</h1>
294
476
  </div>
295
- <div class="pill" id="status">Working</div>
477
+ <div class="pill" id="status">Local</div>
296
478
  </header>
297
- <section id="app" class="state">Starting local setup...</section>
479
+ <section id="app" class="state">Starting local scan...</section>
298
480
  </main>
299
481
  <script>
300
482
  (function () {
@@ -303,10 +485,17 @@ export function renderSetupPage() {
303
485
  var app = document.getElementById("app");
304
486
  var title = document.getElementById("title");
305
487
  var status = document.getElementById("status");
488
+ var report = null;
306
489
  var stats = null;
307
490
  var statsSlow = false;
308
491
  var decisionMade = false;
492
+ var connected = false;
309
493
  var authUrl = "";
494
+ var authWindow = null;
495
+ var connectionPollStarted = false;
496
+ var statsPollStarted = false;
497
+ var reportPollStarted = false;
498
+ var NIGHT_HOURS = { 22: 1, 23: 1, 0: 1, 1: 1, 2: 1, 3: 1 };
310
499
 
311
500
  function esc(value) {
312
501
  return String(value == null ? "" : value).replace(/[&<>"']/g, function (ch) {
@@ -320,23 +509,37 @@ export function renderSetupPage() {
320
509
  function number(value) {
321
510
  return typeof value === "number" && isFinite(value) ? Math.round(value).toLocaleString() : "-";
322
511
  }
512
+ function countOrChecking(value) {
513
+ return typeof value === "number" && isFinite(value) ? Math.round(value).toLocaleString() : "checking";
514
+ }
323
515
  function compact(value) {
324
516
  if (typeof value !== "number" || !isFinite(value)) return "-";
325
517
  var abs = Math.abs(value);
326
- if (abs >= 1000000000) return (value / 1000000000).toFixed(1) + "B";
327
- if (abs >= 1000000) return (value / 1000000).toFixed(1) + "M";
328
- if (abs >= 1000) return (value / 1000).toFixed(1) + "K";
518
+ if (abs >= 1e9) return (value / 1e9).toFixed(1) + "B";
519
+ if (abs >= 1e6) return (value / 1e6).toFixed(1) + "M";
520
+ if (abs >= 1e3) return (value / 1e3).toFixed(1) + "K";
329
521
  return String(Math.round(value));
330
522
  }
331
- function hourLabel(hour) {
332
- if (typeof hour !== "number" || hour < 0) return "-";
333
- var h = hour % 24;
334
- var suffix = h >= 12 ? "PM" : "AM";
335
- var display = h % 12 || 12;
336
- return display + " " + suffix;
523
+ function big(value) {
524
+ if (typeof value !== "number" || !isFinite(value)) return "-";
525
+ var n = Math.abs(value);
526
+ if (n >= 1e9) return (value / 1e9).toFixed(2) + "B";
527
+ if (n >= 1e6) return (value / 1e6).toFixed(1) + "M";
528
+ if (n >= 1e3) return Math.round(value / 1e3) + "K";
529
+ return String(Math.round(value));
530
+ }
531
+ function money(value) {
532
+ return "$" + number(value);
337
533
  }
338
- function sleep(ms) {
339
- return new Promise(function (resolve) { setTimeout(resolve, ms); });
534
+ function sleep(ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); }
535
+ async function fetchWithTimeout(url, options, timeoutMs) {
536
+ var controller = new AbortController();
537
+ var timer = setTimeout(function () { controller.abort(); }, timeoutMs);
538
+ try {
539
+ return await fetch(url, Object.assign({}, options || {}, { signal: controller.signal }));
540
+ } finally {
541
+ clearTimeout(timer);
542
+ }
340
543
  }
341
544
  async function getJson(path) {
342
545
  var sep = path.indexOf("?") >= 0 ? "&" : "?";
@@ -344,18 +547,16 @@ export function renderSetupPage() {
344
547
  if (!res.ok) throw new Error(await res.text() || ("HTTP " + res.status));
345
548
  return await res.json();
346
549
  }
347
- async function postJson(path, body) {
348
- var res = await fetch(path, {
550
+ async function postJson(path, body, timeoutMs) {
551
+ var options = {
349
552
  method: "POST",
350
553
  credentials: "omit",
351
554
  headers: { "Content-Type": "application/json" },
352
555
  body: JSON.stringify(Object.assign({ nonce: nonce }, body || {}))
353
- });
556
+ };
557
+ var res = timeoutMs ? await fetchWithTimeout(path, options, timeoutMs) : await fetch(path, options);
354
558
  var data = await res.json().catch(function () { return {}; });
355
- if (!res.ok) {
356
- var msg = data.message || data.error || ("HTTP " + res.status);
357
- throw new Error(msg);
358
- }
559
+ if (!res.ok) throw new Error(data.message || data.error || ("HTTP " + res.status));
359
560
  return data;
360
561
  }
361
562
  function metric(label, value, detail) {
@@ -366,17 +567,13 @@ export function renderSetupPage() {
366
567
  }
367
568
  function migrationBuckets(migratable) {
368
569
  var eta = migratable && migratable.eta ? migratable.eta : {};
369
- var buckets = Array.isArray(migratable && migratable.buckets)
370
- ? migratable.buckets
371
- : (Array.isArray(eta.buckets) ? eta.buckets : []);
372
- return buckets;
570
+ return Array.isArray(migratable && migratable.buckets) ? migratable.buckets : (Array.isArray(eta.buckets) ? eta.buckets : []);
373
571
  }
374
572
  function bucketCount(buckets, key) {
375
573
  var found = buckets.filter(function (b) { return b && b.key === key; })[0];
376
574
  return found && typeof found.count === "number" ? found.count : 0;
377
575
  }
378
576
  function bucketList(buckets) {
379
- if (!buckets.length) return '<p style="margin-top:10px">No pending extraction buckets.</p>';
380
577
  var visible = buckets.filter(function (b) { return b && (b.count || b.chars); });
381
578
  if (!visible.length) return '<p style="margin-top:10px">No pending extraction buckets.</p>';
382
579
  return '<ol>' + visible.map(function (b) {
@@ -391,118 +588,340 @@ export function renderSetupPage() {
391
588
  if (code === "NOT_LOGGED_IN") return "The local bridge is not logged in yet. Sign in again and retry.";
392
589
  return code || "Something went wrong. Retry in a moment.";
393
590
  }
394
- function showConnect() {
395
- setHead("Connect this device", "Local");
396
- app.className = "panel";
591
+ function closeAuthWindow() {
592
+ try { if (authWindow && !authWindow.closed) authWindow.close(); } catch (_) {}
593
+ authWindow = null;
594
+ }
595
+ function notifyOpenerConnected() {
596
+ try {
597
+ if (window.opener && window.opener !== window && !window.opener.closed) {
598
+ window.opener.postMessage({ type: "echomem:connected", nonce: nonce }, location.origin);
599
+ setTimeout(function () { window.close(); }, 250);
600
+ return true;
601
+ }
602
+ } catch (_) {}
603
+ return false;
604
+ }
605
+ function openAuthWindow(url) {
606
+ var targetUrl = url || authUrl;
607
+ if (!targetUrl) return false;
608
+ try {
609
+ authWindow = window.open(targetUrl, "echomem-signin-" + nonce, "popup=yes,width=560,height=760");
610
+ if (!authWindow) return false;
611
+ authWindow.focus();
612
+ return true;
613
+ } catch (_) { return false; }
614
+ }
615
+ function showPopupFallback(message, url) {
616
+ var hint = document.getElementById("signin-help");
617
+ if (hint) hint.textContent = message || "Your browser blocked the secure sign-in window. Use the button below, then this page will resume when sign-in returns.";
618
+ var fallback = document.getElementById("signin-fallback");
619
+ if (fallback) { fallback.setAttribute("href", url || authUrl); fallback.classList.remove("hidden"); }
620
+ }
621
+ function onConnectClick() {
622
+ if (connected) { void waitForStats(); return; }
623
+ if (!openAuthWindow(authUrl)) showPopupFallback("", authUrl);
624
+ startConnectionPoll();
625
+ }
626
+ function bindConnect() {
627
+ var signin = document.getElementById("signin");
628
+ if (signin) signin.onclick = onConnectClick;
629
+ }
630
+
631
+ /* ---------- pure loading (pic 5) ---------- */
632
+ function renderLoading(prog) {
633
+ var pct = prog && prog.total ? Math.round((prog.scanned / prog.total) * 100) : 0;
634
+ var caption = prog && prog.total
635
+ ? "Reading your local history — " + number(prog.scanned) + " / " + number(prog.total) + " sessions"
636
+ : "Reading your local history…";
637
+ // Update the bar in place on later polls so the skeleton animation doesn't restart/flicker.
638
+ var fill = document.getElementById("loadFill");
639
+ if (app.className === "loading" && fill) {
640
+ fill.style.width = pct + "%";
641
+ var cap = document.getElementById("loadCaption");
642
+ if (cap) cap.textContent = caption;
643
+ return;
644
+ }
645
+ setHead("Context Doctor", "Reading");
646
+ app.className = "loading";
397
647
  app.innerHTML =
398
- '<h2>Local setup is ready</h2>' +
399
- '<p style="margin-top:10px">This page is served from 127.0.0.1. EchoMem will only use the web to sign in and mint a device token, then it returns here.</p>' +
400
- '<div class="actions"><a class="button primary" href="' + esc(authUrl) + '">Connect EchoMem</a></div>';
648
+ '<div class="skeleton">' +
649
+ '<div class="sk title"></div><div class="sk w70"></div><div class="sk w52"></div>' +
650
+ '</div>' +
651
+ '<div class="sk-grid"><div class="sk-card"></div><div class="sk-card"></div><div class="sk-card"></div><div class="sk-card"></div></div>' +
652
+ '<div class="skeleton"><div class="sk w38"></div><div class="sk w70"></div></div>' +
653
+ '<div class="loadProgress"><div class="loadBar"><div id="loadFill" class="loadFill" style="width:' + pct + '%"></div></div><p id="loadCaption" class="loadCaption">' + esc(caption) + '</p></div>';
654
+ }
655
+
656
+ /* ---------- forensic report (pic 1/2) ---------- */
657
+ function histogram(hours) {
658
+ var list = Array.isArray(hours) ? hours : [];
659
+ var max = Math.max.apply(null, [1].concat(list));
660
+ var bars = list.map(function (v, h) {
661
+ var height = Math.max(2, Math.round((v / max) * 100));
662
+ return '<span class="histoBar' + (NIGHT_HOURS[h] ? ' night' : '') + '" style="height:' + height + '%" title="' + h + ':00 — ' + number(v) + ' prompts"></span>';
663
+ }).join("");
664
+ return '<div class="histo">' + bars + '</div><div class="histoAxis"><span>12a</span><span>6a</span><span>12p</span><span>6p</span><span>11p</span></div>';
665
+ }
666
+ function rereadRows(files) {
667
+ return (files || []).slice(0, 6).map(function (f) {
668
+ var meta = esc(f.repo) + ' · ' + number(f.sessionsTouched) + ' sessions · ' + number(f.daysTouched) + ' days' + (f.nightReads ? ' · ' + number(f.nightReads) + ' at night' : '');
669
+ var tag = f.everChanged ? '' : '<span class="tag">never changed</span>';
670
+ return '<div class="row2"><div><b>' + esc(f.file) + '</b>' + tag + '<small>' + meta + '</small></div><strong>' + number(f.totalReads) + '&times;</strong></div>';
671
+ }).join("");
401
672
  }
673
+ function repoRows(repos) {
674
+ return (repos || []).slice(0, 5).map(function (r) {
675
+ var meta = number(r.sessions) + ' sessions · ' + number(r.activeAttentionDays) + ' active days · ' + (r.dailyFocusHours || 0) + 'h/day · ' + big(r.tokens) + ' tokens';
676
+ return '<div class="row2 repoRow"><div><b style="font-family:var(--echo-font-brand);font-size:18px">' + esc(r.name) + '</b><small>' + esc(meta) + '</small></div><strong style="color:var(--echo-ink-primary)">' + (r.attentionHours || 0) + 'h</strong></div>';
677
+ }).join("");
678
+ }
679
+ function costSplit(cost) {
680
+ var total = cost.total || 1;
681
+ function seg(cls, v) { return '<span class="' + cls + '" style="width:' + Math.max(0, (v / total) * 100) + '%"></span>'; }
682
+ var byModel = cost.byModel || {};
683
+ var rows = Object.keys(byModel).map(function (k) { return [k, byModel[k]]; })
684
+ .sort(function (a, b) { return b[1].total - a[1].total; })
685
+ .map(function (e) { return '<div class="modelRow"><b>' + esc(e[0]) + '</b><span>' + big(e[1].total) + ' tok</span><strong>' + money(e[1].cost) + '</strong></div>'; })
686
+ .join("");
687
+ return '<div class="segBar">' + seg("seg-output", cost.outputCost) + seg("seg-cold", cost.coldInputCost) + seg("seg-cw", cost.cacheWriteCost) + seg("seg-cr", cost.cacheReadCost) + '</div>' +
688
+ '<div class="costLegend"><span>output ' + money(cost.outputCost) + '</span><span>cold input ' + money(cost.coldInputCost) + '</span><span>cache write ' + money(cost.cacheWriteCost) + '</span><span>cache read ' + money(cost.cacheReadCost) + '</span></div>' +
689
+ '<div style="margin-top:14px">' + rows + '</div>';
690
+ }
691
+ function renderForensicReport(message) {
692
+ var r = report;
693
+ if (!r) { showReport(); return; }
694
+ var s = r.scale || {}, c = r.cost || {}, mu = r.modelUsage || {}, uwh = r.userWorkingHours || {}, rh = r.rhythm || {}, cl = r.cleanliness || {}, rf = r.rereadForensics || {}, aw = r.avoidableWait || {}, cf = r.counterfactual || {};
695
+ var night = rf.nightExample;
696
+ setHead("Context Doctor", connected ? "Signed in" : "Local");
697
+ app.className = "";
698
+ app.innerHTML =
699
+ (message ? '<div class="notice">' + esc(message) + '</div>' : '') +
700
+ '<section class="hero">' +
701
+ '<div class="privacy"><span>Local scan</span><p>Hi, I am Echo. I read your Codex and Claude Code history from ' + esc(r.scanStartDate) + ' to ' + esc(r.scanEndDate) + ', straight off your machine. Nothing was uploaded, moved to the cloud, or shared.</p></div>' +
702
+ '<div class="heroGrid">' +
703
+ '<div class="heroCopy">' +
704
+ '<p class="kicker-mono">Workspace Physical-Exam</p>' +
705
+ '<h2>You shipped a lot. Your agent kept re-reading the same files.</h2>' +
706
+ '<p>Across ' + number(uwh.activeAttentionDays) + ' active days you put in ' + (uwh.userAttentionHours || 0) + ' hours of real focus, moved ' + big(s.totalTokens) + ' tokens through ' + number(s.repoCount) + ' repos, and ran ' + number(s.sessionCount) + ' sessions. That is a season of work.</p>' +
707
+ '</div>' +
708
+ '<aside class="scoreCard"><span>Context Cleanliness</span><strong>' + number(cl.score) + '</strong><em>/100</em><p>' + number(cl.staleReadSharePct) + '% of everything the agent read was re-reading files that had not changed. That is ' + number(cl.staleRereads) + ' stale rereads.</p></aside>' +
709
+ '</div>' +
710
+ '<div class="statStrip">' +
711
+ '<div class="stat"><strong>' + (uwh.userAttentionHours || 0) + 'h</strong><span>focused attention</span></div>' +
712
+ '<div class="stat"><strong>' + big(s.totalTokens) + '</strong><span>tokens moved</span></div>' +
713
+ '<div class="stat alert"><strong>' + money(c.total) + '</strong><span>API-equivalent worth</span></div>' +
714
+ '<div class="stat"><strong>' + number(rh.lateNightShare) + '%</strong><span>prompts after 10pm</span></div>' +
715
+ '</div>' +
716
+ '<p class="sourceNote">Source: <code>~/.codex/sessions</code> + <code>~/.claude/projects</code> · ' + number(s.sessionCount) + ' sessions · model ' + esc(mu.topModel) + ' ' + number(mu.topModelShare) + '%. Cost is an API-equivalent estimate at list prices, not your subscription bill. Codex reads are extracted best-effort from shell commands (~73% coverage); Claude Code reads are exact.</p>' +
717
+ '</section>' +
718
+
719
+ '<section class="section"><div class="sectionHead"><span>01</span><h2>This is your trail.</h2></div>' +
720
+ '<div class="twoCol">' +
721
+ '<div class="storyText">' +
722
+ '<p>On an active day you average ' + (uwh.averageDailyAttentionHours || 0) + ' hours of focus. Your longest single day was ' + (uwh.maxDailyAttentionHours || 0) + ' hours, on ' + esc(uwh.peakDay && uwh.peakDay.date) + '. You are a <b>' + esc(mu.identityLabel) + '</b> — ' + esc(mu.topModel) + ' carried ' + number(mu.topModelShare) + '% of the load.</p>' +
723
+ '<p>And I can see when you work. ' + number(rh.lateNightShare) + '% of your prompts land after 10pm — ' + number(rh.lateNightSessionCount) + ' sessions ran deep into the night. Your busiest day is ' + esc(rh.busiestWeekday) + '. The red bars are the late hours you kept going.</p>' +
724
+ '</div>' +
725
+ '<div class="panel">' + histogram(rh.hourHistogram) + '<div class="chips">' + (r.repos || []).slice(0, 6).map(function (x) { return '<span>' + esc(x.name) + ' · ' + (x.attentionHours || 0) + 'h</span>'; }).join("") + '</div></div>' +
726
+ '</div>' +
727
+ '<div class="panel" style="margin-top:18px">' + repoRows(r.repos) + '</div>' +
728
+ '</section>' +
729
+
730
+ '<section class="section"><div class="sectionHead"><span>02</span><h2>The money feeds input, not output.</h2></div>' +
731
+ '<div class="twoCol">' +
732
+ '<div class="storyText">' +
733
+ '<p>' + number(s.inputPct) + '% of your tokens are input. Output — the part you picture paying for — is ' + money(c.outputCost) + '. Cold input is only ' + money(c.coldInputCost) + '.</p>' +
734
+ '<p>Cache is cheap per token, so you stop worrying about it. But the volume is enormous: cache reads alone cost ' + money(c.cacheReadCost) + ', cache writes ' + money(c.cacheWriteCost) + '. You paid ' + money(c.total) + ' in API-equivalent worth, and almost all of it is the agent re-loading context, turn after turn.</p>' +
735
+ '</div>' +
736
+ '<div class="panel">' + costSplit(c) + '</div>' +
737
+ '</div>' +
738
+ '</section>' +
739
+
740
+ '<section class="section"><div class="sectionHead"><span>03</span><h2>Why the score is ' + number(cl.score) + ': it kept re-reading.</h2></div>' +
741
+ '<div class="twoCol">' +
742
+ '<div class="storyText">' +
743
+ (night ? '<p>Late on ' + esc(night.day) + ', around ' + esc(night.hour) + ':00, you were heads-down trying to land something. And right then your agent opened <b>' + esc(night.file) + '</b> again — the ' + number(night.priorReads) + 'th time it had pulled that file into this workspace, unchanged since the read before.</p>' : '') +
744
+ '<p>That is the pattern everywhere. ' + number(cl.staleReadSharePct) + '% of the content the agent pulled in was re-reading files that never changed since the last look. It re-learns your repo, your design rules, your structure — over and over — instead of remembering them.</p>' +
745
+ '<p>This is not your fault. The agent is stateless. Every new session is a blank page; every long session pushes earlier reads out of the window. So it reads the same files again, and you wait.</p>' +
746
+ '</div>' +
747
+ '<div class="panel">' + rereadRows(rf.topFiles) + '</div>' +
748
+ '</div>' +
749
+ (night ? '<div class="nightCard"><span>Deep-night reread</span><p><b>' + esc(night.file) + '</b> re-read at ' + esc(night.hour) + ':00 on ' + esc(night.day) + ' — the <b>' + number(night.priorReads) + 'th</b> read of that file in your workspace, unchanged since the previous one.</p></div>' : '') +
750
+ '</section>' +
751
+
752
+ '<section class="section"><div class="sectionHead"><span>04</span><h2>Echo remembers so it stops re-reading.</h2></div>' +
753
+ '<div class="twoCol">' +
754
+ '<div class="storyText">' +
755
+ '<p>I do not touch the real work — the building, the judgment, the direction. I remove the part that should never repeat: warm-up, unchanged-file rereads, re-learning your repo and your rules every session.</p>' +
756
+ '<p>Direct estimate from your own data: about ' + (aw.hours || 0) + ' hours of avoidable wait, roughly ' + (aw.days || 0) + ' focused days. Every file I hold becomes a compounding asset across every future session and every tool. You stop paying the re-reading tax twice.</p>' +
757
+ '<p id="signin-help">Connect the same EchoMem account you want memories saved to. After sign-in returns here, you choose what to extract.</p>' +
758
+ '</div>' +
759
+ '<aside class="promiseCard">' +
760
+ '<span>Next-week target</span>' +
761
+ '<strong>' + number(cl.score) + ' &rarr; ' + number(cf.targetScore) + '</strong>' +
762
+ '<p>Turn your repo map, hot files, design rules, and recent decisions into memory the agent never rebuilds.</p>' +
763
+ '<div class="promiseActions">' +
764
+ '<button id="signin" class="primary">' + (connected ? "Continue" : "Connect Echo") + '</button>' +
765
+ '<a id="signin-fallback" class="button secondary hidden" href="' + esc(authUrl) + '">Open sign-in in this tab</a>' +
766
+ '</div>' +
767
+ '</aside>' +
768
+ '</div>' +
769
+ '</section>';
770
+ bindConnect();
771
+ }
772
+
773
+ /* ---------- dashboard (post-auth extraction) ---------- */
774
+ function statusStrip(title, detail, kind) {
775
+ return '<div class="status-strip ' + esc(kind || "") + '"><span class="dot"></span><div><strong>' + esc(title) + '</strong><p>' + esc(detail) + '</p></div></div>';
776
+ }
777
+ function renderBridgeIssue(error) {
778
+ setHead("Local bridge issue", "Retrying");
779
+ app.className = "loading-panel";
780
+ app.innerHTML =
781
+ '<div style="padding:24px"><h2>Still waiting for the local bridge</h2>' +
782
+ '<p class="helper">Keep the terminal running. If it already exited or printed an error, close this tab and run echomem-mcp setup again.</p>' +
783
+ (error ? '<div class="warning" style="margin-top:16px;margin-bottom:0">' + esc(error) + '</div>' : '') + '</div>';
784
+ }
785
+ function renderLogoutPending() {
786
+ setHead("Signing out locally", "Working");
787
+ app.className = "loading-panel";
788
+ app.innerHTML = '<div style="padding:24px"><h2>Clearing this device token</h2><p class="helper">If the bridge is scanning local files, this can take a few seconds to answer.</p></div>';
789
+ }
790
+ function renderLogoutIssue(error) {
791
+ setHead("Sign out did not finish", "Needs attention");
792
+ app.className = "loading-panel";
793
+ app.innerHTML =
794
+ '<div style="padding:24px"><h2>The local bridge did not answer</h2>' +
795
+ '<p class="helper">Keep the terminal open and retry. If the command exited, close this tab and run setup again.</p>' +
796
+ (error ? '<div class="warning" style="margin-top:16px;margin-bottom:0">' + esc(error) + '</div>' : '') +
797
+ '<div class="actions"><button id="logout" class="secondary">Retry sign out</button></div></div>';
798
+ var lo = document.getElementById("logout"); if (lo) lo.onclick = localLogout;
799
+ }
800
+ async function waitForConnection() {
801
+ if (connectionPollStarted) return;
802
+ connectionPollStarted = true;
803
+ try {
804
+ for (;;) {
805
+ if (decisionMade) return;
806
+ try {
807
+ var cfg = await getJson("/config");
808
+ authUrl = cfg.authUrl || authUrl;
809
+ if (cfg.connected) { connected = true; closeAuthWindow(); await waitForStats(); return; }
810
+ } catch (_) {}
811
+ await sleep(900);
812
+ }
813
+ } finally { connectionPollStarted = false; }
814
+ }
815
+ function startConnectionPoll() { void waitForConnection(); }
402
816
  async function waitForStats() {
403
- setHead("Extract local history", "Working");
404
- app.className = "state";
405
- app.textContent = "Reading local extraction info from this device...";
406
- var started = Date.now();
407
- for (;;) {
408
- if (decisionMade) return;
409
- try {
410
- var res = await fetch("/stats?nonce=" + encodeURIComponent(nonce), { credentials: "omit" });
411
- if (res.status === 200) {
412
- stats = await res.json();
413
- if (decisionMade) return;
414
- renderDashboard();
415
- if (stats && stats.partial) {
416
- await sleep(1200);
417
- continue;
817
+ if (statsPollStarted) return;
818
+ statsPollStarted = true;
819
+ setHead("Context Doctor", "Working");
820
+ app.className = "loading";
821
+ app.innerHTML = '<div class="skeleton"><div class="sk title"></div><div class="sk w70"></div></div><div class="sk-grid"><div class="sk-card"></div><div class="sk-card"></div><div class="sk-card"></div><div class="sk-card"></div></div>';
822
+ try {
823
+ var started = Date.now();
824
+ var lastError = "";
825
+ for (;;) {
826
+ if (decisionMade) return;
827
+ try {
828
+ var res = await fetchWithTimeout("/stats?nonce=" + encodeURIComponent(nonce), { credentials: "omit" }, 3000);
829
+ if (res.status === 200) {
830
+ stats = await res.json();
831
+ if (decisionMade) return;
832
+ renderDashboard();
833
+ if (stats && stats.partial) { await sleep(1200); continue; }
834
+ return;
418
835
  }
419
- return;
836
+ if (res.status !== 202) throw new Error(await res.text() || ("HTTP " + res.status));
837
+ } catch (e) {
838
+ lastError = e && e.message ? e.message : String(e);
839
+ if (!stats && Date.now() - started > 25000) renderBridgeIssue(lastError);
840
+ if (Date.now() - started > 20000) { statsSlow = true; if (stats) renderDashboard(); }
420
841
  }
421
- if (res.status !== 202) throw new Error(await res.text() || ("HTTP " + res.status));
422
- } catch (e) {
423
- if (Date.now() - started > 20000) {
424
- statsSlow = true;
425
- renderDashboard();
426
- }
427
- }
428
- if (Date.now() - started > 20000 && !statsSlow) {
429
- statsSlow = true;
430
- renderDashboard();
842
+ await sleep(statsSlow ? 1500 : 500);
431
843
  }
432
- await sleep(statsSlow ? 1500 : 500);
433
- }
844
+ } finally { statsPollStarted = false; }
434
845
  }
435
846
  function renderDashboard(error) {
436
847
  var migratable = stats && stats.migratable ? stats.migratable : {};
437
848
  var pending = stats && stats.migratable ? stats.migratable.pending : null;
438
849
  var hasPendingCount = typeof pending === "number";
439
- var knownDone = hasPendingCount && pending === 0;
440
- setHead(knownDone ? "History is ready" : "Extract local history", "Ready");
850
+ var discovery = stats && stats.discovery ? stats.discovery : {};
851
+ var isPartial = !!(stats && stats.partial);
852
+ var localScanReady = !isPartial || discovery.phase === "exact" || discovery.phase === "full";
441
853
  var eta = migratable && migratable.eta ? migratable.eta : {};
442
854
  var buckets = migrationBuckets(migratable);
443
- var over1m = bucketCount(buckets, "huge") + bucketCount(buckets, "massive");
444
- var processed = stats && stats.migratable ? stats.migratable.alreadyMigrated : null;
445
855
  var skippedActive = typeof migratable.skippedActive === "number" ? migratable.skippedActive : 0;
446
- var etaLabel = eta.estimatedLabel || migratable.estimatedLabel || (knownDone ? "complete" : "calculating");
856
+ var knownDone = hasPendingCount && pending === 0;
857
+ var canExtract = hasPendingCount && pending > 0;
447
858
  var sessions = stats && stats.sessions ? stats.sessions : {};
448
- var notice = pending == null
449
- ? "The bridge can check for unprocessed conversations and extract them now."
450
- : (knownDone && skippedActive
451
- ? "Historical conversations are processed. " + skippedActive.toLocaleString() + " active conversation" + (skippedActive === 1 ? " is" : "s are") + " still changing and will be picked up later."
452
- : (pending.toLocaleString() + " unprocessed conversation" + (pending === 1 ? "" : "s") + " can be extracted now. Already processed conversations are skipped."));
453
- var productHint = knownDone
454
- ? "EchoMem is ready in your editor. Active sessions will be picked up after they stop changing."
455
- : "You can start using EchoMem in your editor while extraction runs. Keep this tab and the terminal open until it finishes.";
859
+ setHead(knownDone ? "History is ready" : "Context Doctor", localScanReady ? "Ready" : "Scanning");
860
+ var heroValue = knownDone ? "Complete" : (eta.estimatedLabel || migratable.estimatedLabel || "checking");
861
+ // Size profile: fold the char buckets into a simple small / medium / large summary.
862
+ var tierOf = function (keys) {
863
+ var count = 0, chars = 0;
864
+ keys.forEach(function (k) {
865
+ var b = buckets.filter(function (x) { return x && x.key === k; })[0];
866
+ if (b) { count += b.count || 0; chars += b.chars || 0; }
867
+ });
868
+ return { count: count, chars: chars };
869
+ };
870
+ var smallT = tierOf(["small"]);
871
+ var mediumT = tierOf(["routine", "large"]);
872
+ var largeT = tierOf(["veryLarge", "huge", "massive"]);
873
+ var convWord = pending === 1 ? "conversation" : "conversations";
874
+ var heroTitle = knownDone
875
+ ? "Your local AI history is already covered."
876
+ : (pending === 1 ? "Echo found one conversation ready for memory." : "Echo found local AI history it can turn into memory.");
877
+ var subtext;
878
+ if (knownDone) {
879
+ subtext = skippedActive
880
+ ? skippedActive.toLocaleString() + " active conversation" + (skippedActive === 1 ? " is" : "s are") + " still changing and will be picked up later."
881
+ : "Your historical conversations are already in this account.";
882
+ } else if (hasPendingCount) {
883
+ subtext = number(pending) + " " + convWord + " ready to extract — already-processed ones are skipped, so re-running stays incremental.";
884
+ } else {
885
+ subtext = "Checking your local history…";
886
+ }
887
+ var candidateDetail = knownDone ? "Nothing to extract right now." : number(pending) + " " + convWord + (localScanReady ? " · ready" : " · still sizing");
456
888
  app.className = "";
457
889
  app.innerHTML =
458
- (statsSlow ? '<div class="warning">Local processed/unprocessed counts are taking longer than expected. You can still ask the bridge to extract anything unprocessed.</div>' : '') +
459
- (stats && stats.partial ? '<div class="warning">Fast local counts are ready. Detailed local stats are still loading.</div>' : '') +
890
+ (statsSlow && !stats ? '<div class="warning">Local counts are taking longer than expected. You can still ask the bridge to extract anything unprocessed.</div>' : '') +
460
891
  (error ? '<div class="error">' + esc(error) + '</div>' : '') +
461
- '<section class="panel extract-hero">' +
462
- '<div>' +
463
- '<p class="label">' + esc(knownDone ? "Extraction complete" : "Estimated processing time") + '</p>' +
464
- '<strong class="hero-value">' + esc(knownDone ? "Complete" : etaLabel) + '</strong>' +
465
- '<p class="hero-copy">' + esc(notice) + '</p>' +
466
- '<p class="helper">' + esc(productHint) + '</p>' +
467
- '</div>' +
468
- '<div class="mini-stack">' +
469
- '<div class="mini-row"><span>Pending</span><strong>' + esc(number(pending)) + '</strong></div>' +
470
- '<div class="mini-row"><span>Processed</span><strong>' + esc(number(processed)) + '</strong></div>' +
471
- '<div class="mini-row"><span>Active skipped</span><strong>' + esc(number(skippedActive)) + '</strong></div>' +
472
- '<div class="mini-row"><span>1M+ sessions</span><strong>' + esc(number(over1m)) + '</strong></div>' +
473
- '</div>' +
892
+ '<section class="privacy"><span>Local scan</span><p>Echo checked your Codex and Claude Code history on this device. No transcripts leave this machine unless you start extraction.</p></section>' +
893
+ '<section class="doctor-hero">' +
894
+ '<div class="doctor-copy"><h2>' + esc(heroTitle) + '</h2><p>' + esc(subtext) + '</p></div>' +
895
+ '<aside class="doctor-score"><span>' + esc(knownDone ? "Extraction complete" : "Estimated extraction time") + '</span><strong>' + esc(heroValue) + '</strong><p>' + esc(candidateDetail) + '</p>' +
896
+ '<div class="doctor-mini">' +
897
+ '<div class="mini-row"><span>Small &le;30k</span><strong>' + esc(number(smallT.count)) + ' · ' + esc(compact(smallT.chars)) + '</strong></div>' +
898
+ '<div class="mini-row"><span>Medium 30k&ndash;350k</span><strong>' + esc(number(mediumT.count)) + ' · ' + esc(compact(mediumT.chars)) + '</strong></div>' +
899
+ '<div class="mini-row"><span>Large &gt;350k</span><strong>' + esc(number(largeT.count)) + ' · ' + esc(compact(largeT.chars)) + '</strong></div>' +
900
+ '</div>' +
901
+ '</aside>' +
474
902
  '</section>' +
475
- '<div class="grid compact">' +
903
+ '<div class="doctor-stat-strip">' +
476
904
  metric("Sessions found", number(sessions.total), number(sessions.codex) + " Codex + " + number(sessions.claudeCode) + " Claude Code") +
477
- metric("Pending chars", compact(migratable.totalChars || (eta && eta.totalChars)), "assembled text to process") +
478
- metric("Approx input tokens", compact(migratable.approxInputTokens || (eta && eta.approxInputTokens)), "local estimate") +
479
- metric("Memories captured", number(stats && stats.memoriesCaptured), "already in EchoMem") +
905
+ metric("Memories captured", countOrChecking(stats && stats.memoriesCaptured), "already in EchoMem") +
480
906
  '</div>' +
481
- '<div class="details">' +
482
- '<section class="panel"><h2>Pending Size</h2>' + bucketList(buckets) + '</section>' +
483
- '<section class="panel"><h2>Extraction Plan</h2><dl>' +
484
- row("Mode", "local bridge queue") +
485
- row("Uploads", "text turns only") +
486
- row("Already processed", "skipped") +
487
- row("Active sessions", "picked up later") +
488
- '</dl></section>' +
489
- '</div>' +
490
- '<div class="actions">' +
491
- '<button id="migrate" class="primary" ' + (knownDone ? "disabled" : "") + '>-> Extract unprocessed conversations</button>' +
907
+ '<div class="actions doctor-actions">' +
908
+ '<button id="migrate" class="primary" ' + (canExtract ? "" : "disabled") + '>Extract conversations</button>' +
492
909
  '<button id="skip" class="secondary">' + (knownDone ? "Finish setup" : "Not today") + '</button>' +
910
+ '<button id="logout" class="secondary">Sign out locally</button>' +
493
911
  '</div>';
494
912
  document.getElementById("migrate").onclick = startMigrate;
495
913
  document.getElementById("skip").onclick = skip;
914
+ document.getElementById("logout").onclick = localLogout;
496
915
  }
497
916
  async function startMigrate() {
498
917
  if (decisionMade) return;
499
918
  decisionMade = true;
500
- setHead("Extraction progress", "Starting");
501
- app.className = "panel";
502
- app.innerHTML = '<h2>Starting extraction</h2><p style="margin-top:10px">You can start using EchoMem in your editor now. Keep this tab and the terminal open while the local bridge imports unprocessed conversations.</p>';
919
+ setHead("Extracting", "Starting");
920
+ app.className = "";
921
+ app.innerHTML = '<section class="privacy"><span>Extracting</span><p>Starting your extraction. Echo sizes the remaining jobs, then imports automatically keep this terminal open.</p></section><div class="loading" style="padding:18px 0"><div class="skeleton"><div class="sk title"></div><div class="sk w70"></div></div></div>';
503
922
  try {
504
923
  var started = await postJson("/migrate", {});
505
- renderProgress(Object.assign({ status: "running", completed: 0, failed: 0, extracted: 0 }, started));
924
+ renderProgress(Object.assign({ status: "starting", completed: 0, failed: 0, extracted: 0 }, started));
506
925
  await pollProgress();
507
926
  } catch (e) {
508
927
  decisionMade = false;
@@ -518,21 +937,84 @@ export function renderSetupPage() {
518
937
  }
519
938
  }
520
939
  function renderProgress(progress) {
940
+ var status = progress.status;
941
+ var done = status === "completed";
942
+ var stopped = status === "failed";
521
943
  var total = progress.total || progress.jobCount || 0;
522
944
  var completed = progress.completed || 0;
523
945
  var failed = progress.failed || 0;
524
- var pct = total > 0 ? Math.round((completed / total) * 100) : 0;
525
- setHead("Extraction progress", progress.status === "completed" ? "Done" : "Extracting");
526
- app.className = "panel";
946
+ var running = progress.running || 0;
947
+ var queued = progress.queued || 0;
948
+ var extracted = progress.extracted || 0;
949
+ var pct = total > 0 ? Math.round((completed / total) * 100) : (done ? 100 : 0);
950
+ app.className = "";
951
+ if (done || stopped) {
952
+ setHead(done ? "Memory captured" : "Extraction stopped", done ? "Done" : "Needs attention");
953
+ app.innerHTML =
954
+ '<section class="finish">' +
955
+ '<div class="finishMark ' + (done ? "ok" : "warn") + '">' + (done ? "&#10003;" : "!") + '</div>' +
956
+ '<strong class="finishBig">' + esc(number(extracted)) + '</strong>' +
957
+ '<span class="finishLabel">' + (extracted === 1 ? "memory" : "memories") + ' captured</span>' +
958
+ '<p>' + (done
959
+ ? "from " + esc(number(completed)) + " conversation" + (completed === 1 ? "" : "s") + ". Your next coding session will recall instead of re-reading — and it keeps compounding."
960
+ : (String(progress.error) === "key-expired"
961
+ ? "Your encryption key wasn't accepted. Re-run extraction; if it keeps failing, run <code>echomem-mcp unlock</code> first. Already-done conversations are skipped."
962
+ : esc(progress.error || "Some conversations did not finish.") + " Re-run <code>echomem-mcp migrate</code> to finish the rest; already-done conversations are skipped.")) + '</p>' +
963
+ (done && failed ? '<p class="helper">' + esc(number(failed)) + ' could not be extracted.</p>' : "") +
964
+ '</section>';
965
+ return;
966
+ }
967
+ if (status === "starting" || status === "preparing") {
968
+ // Prep phase — no conversation has been sent yet. Name the specific step we're on
969
+ // (with a moving bar + stepper) so this window reads as active work, not a frozen "0 of N".
970
+ setHead("Preparing", "Preparing");
971
+ var note = progress.latest || progress.message || "";
972
+ var activeStep = /creat/i.test(note) ? 1 : 0;
973
+ var prepLabels = [
974
+ "Reading your local sessions",
975
+ "Creating your EchoMem import session",
976
+ "Queuing conversations to extract",
977
+ ];
978
+ var stepsHtml = "";
979
+ for (var i = 0; i < prepLabels.length; i++) {
980
+ var stepCls = i < activeStep ? "done" : (i === activeStep ? "active" : "");
981
+ stepsHtml += '<li class="' + stepCls + '"><span class="dot"></span><span>' + esc(prepLabels[i]) + '</span></li>';
982
+ }
983
+ app.innerHTML =
984
+ '<section class="privacy"><span>Preparing</span><p>Nothing has left your machine yet — Echo is getting your sessions ready locally. This takes a few seconds before extraction starts.</p></section>' +
985
+ '<section class="doctor-hero">' +
986
+ '<div class="doctor-copy">' +
987
+ '<p class="label">Now working on</p>' +
988
+ '<h2>' + esc(prepLabels[activeStep]) + '&hellip;</h2>' +
989
+ '<div class="track indef"><div class="fill"></div></div>' +
990
+ '<ul class="prep-steps">' + stepsHtml + '</ul>' +
991
+ '</div>' +
992
+ '<aside class="doctor-score">' +
993
+ '<span>Conversations</span>' +
994
+ '<strong>' + (total ? esc(number(total)) : "&hellip;") + '</strong>' +
995
+ '<p>found locally</p>' +
996
+ '</aside>' +
997
+ '</section>';
998
+ return;
999
+ }
1000
+ setHead("Extracting", "Extracting");
527
1001
  app.innerHTML =
528
- '<h2>Extracting local history</h2>' +
529
- '<div class="track"><div class="fill" style="width:' + pct + '%"></div></div>' +
530
- '<div class="counts"><span>' + completed + ' completed</span><span>' + (progress.running || 0) + ' running</span><span>' + (progress.queued || 0) + ' queued</span><span>' + failed + ' failed</span></div>' +
531
- '<p>You can start using EchoMem now. Keep this tab and the terminal open until extraction finishes.</p>' +
532
- (progress.latest ? '<p style="margin-top:10px">' + esc(progress.latest) + '</p>' : '') +
533
- '<div class="' + (progress.status === "failed" ? "error" : "notice") + '" style="margin-top:16px;margin-bottom:0">' +
534
- esc((progress.extracted || 0).toLocaleString()) + ' memories saved so far.' + (progress.error ? ' ' + esc(progress.error) : '') +
535
- '</div>';
1002
+ '<section class="privacy"><span>Extracting</span><p>Echo is turning your history into memory. Keep this terminal open — it advances while the bridge runs. You can start using EchoMem in your editor now.</p></section>' +
1003
+ '<section class="doctor-hero">' +
1004
+ '<div class="doctor-copy">' +
1005
+ '<p class="label">Extraction progress</p>' +
1006
+ '<h2>' + (total ? esc(number(completed)) + ' of ' + esc(number(total)) : "Starting&hellip;") + '</h2>' +
1007
+ '<div class="track"><div class="fill" style="width:' + pct + '%"></div></div>' +
1008
+ '<div class="counts"><span>' + esc(number(completed)) + ' done</span><span>' + esc(number(running)) + ' running</span><span>' + esc(number(queued)) + ' queued</span>' + (failed ? '<span>' + esc(number(failed)) + ' failed</span>' : "") + '</div>' +
1009
+ (progress.latest ? '<p class="helper" style="margin-top:10px">Now: ' + esc(progress.latest) + '</p>' : "") +
1010
+ (progress.capped ? '<p class="helper">Importing the newest ' + esc(number(progress.capped)) + '; re-run <code>echomem-mcp migrate</code> later for older sessions.</p>' : "") +
1011
+ '</div>' +
1012
+ '<aside class="doctor-score">' +
1013
+ '<span>Memories captured</span>' +
1014
+ '<strong>' + esc(number(extracted)) + '</strong>' +
1015
+ '<p>' + (extracted ? "and growing" : "starting&hellip;") + '</p>' +
1016
+ '</aside>' +
1017
+ '</section>';
536
1018
  }
537
1019
  async function skip() {
538
1020
  if (decisionMade) return;
@@ -540,27 +1022,91 @@ export function renderSetupPage() {
540
1022
  try {
541
1023
  await postJson("/skip", {});
542
1024
  setHead("Setup complete", "Done");
543
- app.className = "panel";
544
- app.innerHTML = '<h2>All set</h2><p style="margin-top:10px">You can extract unprocessed conversations later with echomem-mcp migrate.</p>';
1025
+ app.className = "notice";
1026
+ app.innerHTML = '<h2 style="color:#203820">All set</h2><p style="margin-top:10px">You can extract unprocessed conversations later with echomem-mcp migrate.</p>';
545
1027
  } catch (e) {
546
1028
  decisionMade = false;
547
1029
  renderDashboard(e && e.message);
548
1030
  }
549
1031
  }
1032
+ async function localLogout() {
1033
+ if (decisionMade) return;
1034
+ decisionMade = true;
1035
+ renderLogoutPending();
1036
+ try {
1037
+ await postJson("/logout", {}, 12000);
1038
+ stats = null;
1039
+ statsSlow = false;
1040
+ connected = false;
1041
+ decisionMade = false;
1042
+ renderForensicReport("Signed out locally. Connect EchoMem again to extract.");
1043
+ } catch (e) {
1044
+ decisionMade = false;
1045
+ renderLogoutIssue(e && e.message ? e.message : String(e));
1046
+ }
1047
+ }
1048
+
1049
+ /* ---------- scan-first poll for the forensic report ---------- */
1050
+ async function showReport() {
1051
+ if (reportPollStarted) return;
1052
+ reportPollStarted = true;
1053
+ if (!report) renderLoading();
1054
+ try {
1055
+ var fails = 0;
1056
+ for (;;) {
1057
+ if (decisionMade) return;
1058
+ try {
1059
+ var res = await fetchWithTimeout("/report?nonce=" + encodeURIComponent(nonce), { credentials: "omit" }, 12000);
1060
+ if (res.status === 200) { report = await res.json(); renderForensicReport(); return; }
1061
+ if (res.status === 202) {
1062
+ fails = 0;
1063
+ var prog = await res.json().catch(function () { return null; });
1064
+ renderLoading(prog);
1065
+ } else {
1066
+ throw new Error(await res.text() || ("HTTP " + res.status));
1067
+ }
1068
+ } catch (e) {
1069
+ // Only a RUN of consecutive failures is a real bridge problem. A long scan that keeps
1070
+ // answering 202 must never trip this (that was the old false "Local bridge issue").
1071
+ fails++;
1072
+ if (!report && fails >= 8) renderBridgeIssue(e && e.message ? e.message : String(e));
1073
+ }
1074
+ await sleep(700);
1075
+ }
1076
+ } finally { reportPollStarted = false; }
1077
+ }
1078
+
1079
+ window.addEventListener("message", function (event) {
1080
+ if (event.origin !== location.origin) return;
1081
+ var data = event.data || {};
1082
+ if (data.type === "echomem:connected" && data.nonce === nonce) {
1083
+ connected = true;
1084
+ closeAuthWindow();
1085
+ void waitForStats();
1086
+ }
1087
+ });
550
1088
  async function init() {
551
1089
  if (!nonce) throw new Error("Missing setup nonce. Re-run echomem-mcp setup.");
552
- var cfg = await getJson("/config");
553
- authUrl = cfg.authUrl || "";
554
- if (!cfg.connected) {
555
- showConnect();
556
- var key = "echomem-auth-started-" + nonce;
557
- if (!sessionStorage.getItem(key)) {
558
- sessionStorage.setItem(key, "1");
559
- setTimeout(function () { location.href = authUrl; }, 700);
1090
+ if (params.get("connected") === "1") {
1091
+ connected = true;
1092
+ if (notifyOpenerConnected()) {
1093
+ // Popup flow: hand the main tab back control, then this popup closes.
1094
+ setHead("EchoMem connected", "Done");
1095
+ app.className = "notice";
1096
+ app.innerHTML = '<h2 style="color:#203820">This device is connected</h2><p style="margin-top:10px">Returning to the local setup page...</p>';
1097
+ } else {
1098
+ // Same-tab fallback (popup was blocked): go straight to the extraction dashboard.
1099
+ void waitForStats();
560
1100
  }
561
1101
  return;
562
1102
  }
563
- await waitForStats();
1103
+ // Learn the auth URL + connection state, but don't block the local scan on it.
1104
+ getJson("/config").then(function (cfg) {
1105
+ authUrl = cfg.authUrl || authUrl;
1106
+ connected = !!cfg.connected;
1107
+ }).catch(function () {});
1108
+ // Scan-first: show the local forensic report before asking the user to connect.
1109
+ await showReport();
564
1110
  }
565
1111
  init().catch(function (e) {
566
1112
  setHead("Connection issue", "Needs attention");