eklavya 1.27.1 → 1.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -185,7 +185,7 @@
185
185
  .head h2 { font-size:var(--text-md); font-weight:var(--weight-bold); letter-spacing:-.01em; margin:0; }
186
186
  .head p { color:var(--faint); font-size:13.5px; margin:2px 0 0; max-width:78ch; overflow-wrap:anywhere; }
187
187
  .head--row { display:flex; align-items:flex-end; gap:var(--space-4); flex-wrap:wrap; }
188
- .head--row > :first-child { flex:1 1 320px; }
188
+ .head--row > :first-child { flex:1 1 180px; }
189
189
 
190
190
  .g2 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--space-6); }
191
191
  .g3 { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:var(--space-6); }
@@ -358,6 +358,66 @@
358
358
  summary.qa__meta::before { content:'▸'; color:var(--faint); }
359
359
  details[open] > summary.qa__meta::before { content:'▾'; color:var(--spot); }
360
360
  summary.qa__meta:focus-visible { outline:none; box-shadow:var(--ring); }
361
+
362
+ /* ---------- memory timeline ----------
363
+ One line per entry on a vertical rule. A row is a <details>: the title is
364
+ the summary, the snippet and the link to the full entry open under it, so
365
+ the list stays scannable and detail costs one click. */
366
+ .counts { margin:var(--space-2) 0 0; font-family:var(--font-mono); font-size:12px; color:var(--faint); }
367
+ .counts .warn { color:var(--warning); }
368
+ .tl { list-style:none; margin:var(--space-4) 0 0; padding:0; position:relative; }
369
+ .tl::before { content:''; position:absolute; left:67px; top:8px; bottom:8px; width:1px; background:var(--line-2); }
370
+ .tl-day { display:flex; align-items:baseline; gap:var(--space-3); padding:var(--space-5) 0 var(--space-2) 88px; position:relative; }
371
+ .tl-day:first-child { padding-top:0; }
372
+ .tl-day::before { content:''; position:absolute; left:62px; top:calc(var(--space-5) + 6px); width:11px; height:11px; background:var(--line-2); }
373
+ .tl-day:first-child::before { top:6px; }
374
+ .tl-day h2 { margin:0; font-family:var(--font-disp); font-size:var(--text-lg); font-weight:var(--weight-bold); letter-spacing:-.02em; }
375
+ .tl-day span { font-family:var(--font-mono); font-size:10.5px; letter-spacing:var(--tracking-caps); text-transform:uppercase; color:var(--spot); }
376
+ .tl-gap { padding:var(--space-2) 0 var(--space-2) 88px; font-family:var(--font-mono); font-size:11.5px; color:var(--faint); }
377
+ .tl-item > summary { list-style:none; cursor:pointer; display:grid; grid-template-columns:52px minmax(0,1fr) auto;
378
+ align-items:center; column-gap:36px; min-height:44px; padding-right:var(--space-3); position:relative; }
379
+ .tl-item > summary::-webkit-details-marker { display:none; }
380
+ .tl-item > summary::before { content:''; position:absolute; left:62px; top:50%; width:11px; height:11px; margin-top:-5.5px;
381
+ box-sizing:border-box; border-radius:50%; border:2px solid var(--dim); background:var(--bg); }
382
+ .tl-item > summary:hover { background:var(--panel); }
383
+ .tl-item > summary:focus-visible { outline:none; box-shadow:var(--ring); }
384
+ .tl-item[open] > summary::before { background:var(--spot); border-color:var(--spot); }
385
+ .tl-item[open] { background:var(--panel); }
386
+ .tl-time { font-family:var(--font-mono); font-size:12px; color:var(--faint); text-align:right; font-variant-numeric:tabular-nums; }
387
+ .tl-title { font-size:15px; font-weight:var(--weight-medium); color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
388
+ .tl-item[open] > summary .tl-title { font-weight:var(--weight-semibold); }
389
+ .tl-type { font-family:var(--font-mono); font-size:10.5px; letter-spacing:.12em; text-transform:uppercase; color:var(--faint); white-space:nowrap; }
390
+ .tl-body { padding:0 var(--space-4) var(--space-4) 88px; display:grid; gap:var(--space-3); }
391
+ .tl-body p { margin:0; color:var(--dim); font-size:13.5px; max-width:72ch; overflow-wrap:anywhere; }
392
+ .tl-meta { display:flex; flex-wrap:wrap; align-items:center; gap:var(--space-2) var(--space-4);
393
+ font-family:var(--font-mono); font-size:11.5px; color:var(--faint); }
394
+ .tl-meta .link { margin-left:auto; font-family:var(--font-body); font-size:13.5px; font-weight:var(--weight-semibold); }
395
+ .tl-item.gone .tl-title { text-decoration:line-through; color:var(--faint); }
396
+ /* A session is a larger ring; its observations fold under it. */
397
+ .tl-session > summary::before { width:15px; height:15px; margin-top:-7.5px; left:60px; border-color:var(--spot); }
398
+ .tl-session[open] > summary::before { background:var(--bg); }
399
+ .tl-session[open] { background:none; }
400
+ .tl-session > summary .tl-title { font-weight:var(--weight-medium); }
401
+ .tl-session > summary .tl-type { color:var(--spot); }
402
+ .tl-kids { list-style:none; margin:0; padding:0; }
403
+ .tl-kids .tl-item > summary::before { left:64px; width:7px; height:7px; margin-top:-3.5px; border-color:var(--faint-2); }
404
+ .tl-kids .tl-item[open] > summary::before { border-color:var(--spot); }
405
+ .tl-kids .tl-title { font-size:14px; padding-left:var(--space-6); }
406
+ .tl-kids .tl-body { padding-left:112px; }
407
+ .tl-kids .tl-gap { padding-left:112px; }
408
+ #mwhen, #mtype, #mtag { max-width:100%; min-width:0; }
409
+ /* Narrow: the title gets the width and two lines; the type drops under it. */
410
+ @media (max-width:560px) {
411
+ .tl-item > summary { grid-template-columns:52px minmax(0,1fr); padding:var(--space-2) var(--space-2) var(--space-2) 0; }
412
+ .tl-item > summary::before { top:calc(var(--space-2) + 11px); margin-top:0; }
413
+ .tl-session > summary::before { top:calc(var(--space-2) + 9px); }
414
+ .tl-kids .tl-item > summary::before { top:calc(var(--space-2) + 13px); }
415
+ .tl-time { align-self:start; padding-top:3px; }
416
+ .tl-title { white-space:normal; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; }
417
+ .tl-type { grid-column:2; margin-top:2px; }
418
+ .tl-kids .tl-type { padding-left:var(--space-6); }
419
+ .tl-body, .tl-kids .tl-body, .tl-kids .tl-gap { padding-left:88px; }
420
+ }
361
421
  .raw { font-family:var(--font-mono); font-size:12px; line-height:1.55; white-space:pre-wrap;
362
422
  word-break:break-word; color:var(--dim); background:var(--mass); padding:var(--space-3);
363
423
  max-height:320px; overflow:auto; margin:var(--space-3) 0 0; }
@@ -369,6 +429,64 @@
369
429
  .banner p { margin:0; color:var(--dim); font-size:13.5px; }
370
430
  .banner code { background:var(--mass); padding:2px 6px; font-size:12.5px; color:var(--ink); }
371
431
 
432
+ /* ---------- progressive disclosure ----------
433
+ The timeline's rule, applied to every page: the answer first, one line per
434
+ item, and detail one click away. A page opens with its title, one mono line
435
+ of counts, the action that is waiting (if any) and the lists; explanations
436
+ and secondary cards are folded and remembered open once the reader opens
437
+ them. Every fold is a native <details>: keyboard-operable, no JS to open. */
438
+ .about { margin-top:var(--space-2); }
439
+ .about > summary, .fold > summary { list-style:none; cursor:pointer; }
440
+ .about > summary::-webkit-details-marker, .fold > summary::-webkit-details-marker { display:none; }
441
+ .about > summary { display:inline-flex; align-items:center; gap:var(--space-2); min-height:32px;
442
+ font-family:var(--font-mono); font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--faint); }
443
+ .about > summary:hover { color:var(--spot); }
444
+ .about > summary::before, .fold > summary::before { content:'▸'; color:var(--faint); flex:none; }
445
+ .about[open] > summary::before, .fold[open] > summary::before { content:'▾'; color:var(--spot); }
446
+ .about > summary:focus-visible, .fold > summary:focus-visible { outline:none; box-shadow:var(--ring); }
447
+ .about p { margin:var(--space-2) 0 0; color:var(--dim); font-size:13.5px; max-width:72ch; }
448
+ .fold { padding:0; }
449
+ /* Consecutive folds read as one list, not a column of separate cards. */
450
+ .stack > .fold + .fold { margin-top:var(--space-2); }
451
+ .fold > summary { display:flex; align-items:center; gap:var(--space-3); min-height:56px; padding:var(--space-4) var(--space-6); }
452
+ .fold > summary:hover { background:var(--mass); }
453
+ .fold > summary h2 { margin:0; font-size:var(--text-md); font-weight:var(--weight-bold); letter-spacing:-.01em; }
454
+ .fold > summary .fold__peek { margin-left:auto; font-family:var(--font-mono); font-size:12px; color:var(--faint);
455
+ text-align:right; overflow-wrap:anywhere; }
456
+ .fold > summary .fold__peek.warn { color:var(--warning); }
457
+ .fold__body { padding:0 var(--space-6) var(--space-6); }
458
+ .fold__body > .hint { color:var(--faint); font-size:13.5px; margin:0 0 var(--space-4); max-width:78ch; overflow-wrap:anywhere; }
459
+
460
+ /* The one next step, when there is one. Not a banner per fact. */
461
+ .next { display:flex; align-items:center; gap:var(--space-4); flex-wrap:wrap; padding:var(--space-4) var(--space-5);
462
+ border:1px solid var(--line-2); border-left:2px solid var(--warning); background:var(--panel); }
463
+ .next p { margin:0; flex:1 1 280px; color:var(--dim); font-size:13.5px; }
464
+ .next p strong { color:var(--ink); }
465
+ .next code { background:var(--mass); padding:2px 6px; font-size:12.5px; color:var(--ink); }
466
+ .next.ok { border-left-color:var(--spot); }
467
+
468
+ /* One line per item: a name, a quiet second line, and one number on the right. */
469
+ .lines { list-style:none; margin:0; padding:0; }
470
+ .lines > li { display:flex; align-items:center; gap:var(--space-4); min-height:48px; padding:var(--space-2) var(--space-2);
471
+ margin:0 calc(-1 * var(--space-2)); border-bottom:1px solid var(--line); }
472
+ .lines > li:last-child { border-bottom:none; }
473
+ .lines > li[data-go] { cursor:pointer; }
474
+ .lines > li[data-go]:hover { background:var(--mass); }
475
+ .lines > li[data-go]:focus-visible { outline:none; box-shadow:var(--ring); }
476
+ .lines .ln__main { flex:1; min-width:0; }
477
+ .lines .ln__t { display:block; color:var(--ink); font-weight:var(--weight-medium); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
478
+ .lines .ln__s { display:block; font-family:var(--font-mono); font-size:11.5px; color:var(--faint); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
479
+ .lines .ln__v { flex:none; text-align:right; font-family:var(--font-mono); font-size:12px; color:var(--dim); font-variant-numeric:tabular-nums; }
480
+ .lines .ln__v .meter { width:88px; margin-top:4px; }
481
+ .more { margin:var(--space-4) 0 0; font-size:13.5px; }
482
+
483
+ /* A question answered: one line — concept, grade, the question — and it opens
484
+ to the options and the feedback. */
485
+ details.qa > summary { display:flex; align-items:center; gap:var(--space-3); flex-wrap:wrap; }
486
+ details.qa > summary .qa__line { flex:1 1 240px; min-width:0; font-family:var(--font-body); font-size:13.5px; color:var(--ink);
487
+ letter-spacing:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
488
+ details.qa[open] > summary .qa__line { white-space:normal; }
489
+
372
490
  @media (max-width:1080px) {
373
491
  .g3 { grid-template-columns:repeat(2,minmax(0,1fr)); }
374
492
  }
@@ -412,6 +530,11 @@
412
530
  .stats { grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--space-3); }
413
531
  .tile strong { font-size:var(--text-xl); }
414
532
  .card { padding:var(--space-5) var(--space-4); }
533
+ .fold { padding:0; }
534
+ .fold > summary { padding:var(--space-3) var(--space-4); flex-wrap:wrap; }
535
+ .fold > summary .fold__peek { margin-left:0; flex-basis:100%; text-align:left; padding-left:var(--space-5); }
536
+ .fold__body { padding:0 var(--space-4) var(--space-5); }
537
+ .lines .ln__v .meter { width:56px; }
415
538
  .page__title { font-size:var(--text-xl); }
416
539
  }
417
540
  @media (prefers-reduced-motion:reduce) {
@@ -738,6 +861,81 @@ function card(title, hint, body, opts = {}) {
738
861
  return `<section class="card">${head}${body}</section>`;
739
862
  }
740
863
 
864
+ /* ---------- progressive disclosure ---------- */
865
+
866
+ /**
867
+ * Which folds the reader opened or closed, by id, so a page they come back to
868
+ * — or one re-rendered by a resize or a ground switch — keeps the detail they
869
+ * asked for. Per-viewer convenience only: storage that throws means defaults.
870
+ */
871
+ const FOLD_KEY = 'eklavya-dash-folds';
872
+ let FOLD = {};
873
+ try {
874
+ const v = JSON.parse(localStorage.getItem(FOLD_KEY) || '{}');
875
+ if (v && typeof v === 'object' && !Array.isArray(v)) FOLD = v;
876
+ } catch (e) { FOLD = {}; }
877
+ const isOpen = (id, dflt) => (typeof FOLD[id] === 'boolean' ? FOLD[id] : dflt);
878
+ const openAttr = (id, dflt) => (isOpen(id, dflt) ? ' open' : '');
879
+
880
+ /**
881
+ * A page's head: its title, one mono line of counts, and the explanation of how
882
+ * the numbers are counted folded under a "How this works" line. `title` and
883
+ * every count are HTML the caller escaped.
884
+ */
885
+ function pageHead(title, counts = [], about = '', back = '') {
886
+ const id = `about:${CUR.wf}:${CUR.page}`;
887
+ return `<div>${back}<h1 class="page__title">${title}</h1>
888
+ ${counts.length ? `<p class="counts">${counts.join(' · ')}</p>` : ''}
889
+ ${about ? `<details class="about" data-fold="${esc(id)}"${openAttr(id, false)}><summary>How this works</summary><p>${about}</p></details>` : ''}</div>`;
890
+ }
891
+
892
+ /**
893
+ * A secondary card, folded: the summary line carries the title and a peek at
894
+ * what is inside (a count, a verdict), so a closed fold still answers
895
+ * something. A chart inside a closed fold is drawn when it opens.
896
+ */
897
+ function fold(id, title, peek, body, opts = {}) {
898
+ return `<details class="card fold" data-fold="${esc(id)}"${openAttr(id, !!opts.open)}>
899
+ <summary><h2>${esc(title)}</h2>${peek ? `<span class="fold__peek${opts.warn ? ' warn' : ''}">${peek}</span>` : ''}</summary>
900
+ <div class="fold__body">${opts.hint ? `<p class="hint">${esc(opts.hint)}</p>` : ''}${body}</div></details>`;
901
+ }
902
+
903
+ /** The one thing waiting on the reader, or nothing. `html` is escaped by the caller. */
904
+ const nextStep = (html, ok) => `<div class="next${ok ? ' ok' : ''}"><p>${html}</p></div>`;
905
+
906
+ /**
907
+ * One line per item: `{ t, s, v, go, cls }` — a title, a quiet mono second
908
+ * line, one value on the right, and where it opens. All HTML the caller escaped.
909
+ */
910
+ function lines(items, empty = 'Nothing here yet.') {
911
+ if (!items.length) return `<p class="empty">${empty}</p>`;
912
+ return `<ul class="lines">` + items.map((x) =>
913
+ `<li${x.go ? ` data-go="${esc(x.go)}" role="link"` : ''}${x.cls ? ` class="${x.cls}"` : ''}>`
914
+ + `<div class="ln__main"><span class="ln__t">${x.t}</span>${x.s ? `<span class="ln__s">${x.s}</span>` : ''}</div>`
915
+ + (x.v != null && x.v !== '' ? `<div class="ln__v">${x.v}</div>` : '') + `</li>`).join('') + `</ul>`;
916
+ }
917
+
918
+ /**
919
+ * A question and its answer as one line that opens to the options and the
920
+ * feedback. `lead` is plain text (a summary holds no links); `linkConcept` puts
921
+ * the way to the concept's page inside the opened body instead.
922
+ */
923
+ function qaLine(a, lead, linkConcept = false) {
924
+ let opts = [];
925
+ try { opts = a.options ? JSON.parse(a.options) : []; } catch { opts = []; }
926
+ return `<details class="qa">
927
+ <summary class="qa__meta">${lead}<span class="qa__line">${esc(a.question)}</span>${gradeBadge(a)}</summary>
928
+ <div class="qa__meta" style="margin-top:var(--space-3)"><span>${esc(tierLabel(a.tier))}</span>
929
+ ${a.format ? `<span>${esc(a.format)}</span>` : ''}${a.level ? `<span>${esc(a.level)}</span>` : ''}
930
+ ${a.repo ? `<span>${esc(pname(a.repo, a.session_id))}</span>` : ''}
931
+ ${linkConcept ? `<a class="link sp" data-go="${esc(LH('concept', a.slug))}">${esc(a.name)} →</a>` : ''}</div>
932
+ ${opts.length ? `<ul class="qa__opts">${opts.map((o) =>
933
+ `<li class="${a.answer && o === a.answer ? 'picked' : ''}">${esc(o)}</li>`).join('')}</ul>` : ''}
934
+ ${a.answer && !opts.length ? `<p class="qa__a"><b>You said:</b> ${esc(a.answer)}</p>` : ''}
935
+ ${a.feedback ? `<p class="qa__f">${esc(a.feedback)}</p>` : ''}
936
+ </details>`;
937
+ }
938
+
741
939
  function table(cols, rows) {
742
940
  if (!rows.length) return '<p class="empty">Nothing here yet.</p>';
743
941
  return `<div class="scroll"><table><thead><tr>`
@@ -948,7 +1146,19 @@ function segbar(parts) {
948
1146
  + `</div>`;
949
1147
  }
950
1148
 
951
- const chart = (id, fn) => { AFTER.push(() => { const el = $(id); if (el) fn(el); }); };
1149
+ /**
1150
+ * A chart measures its parent, and a closed <details> has no width — so a
1151
+ * chart inside a closed fold waits in `LAZY` until the fold opens.
1152
+ */
1153
+ const LAZY = new Map();
1154
+ const chart = (id, fn) => {
1155
+ AFTER.push(() => {
1156
+ const el = $(id);
1157
+ if (!el) return;
1158
+ if (el.closest('details:not([open])')) LAZY.set(id, fn);
1159
+ else fn(el);
1160
+ });
1161
+ };
952
1162
 
953
1163
  /* ============================================================
954
1164
  The memory half
@@ -1063,7 +1273,6 @@ function viewLearningDashboard() {
1063
1273
  const last30 = fillDays(daily, 30);
1064
1274
 
1065
1275
  const weakest = seen.filter((c) => !c.mastered).sort((a, b) => a.score - b.score || b.attempts - a.attempts).slice(0, 5);
1066
- const recentSessions = sess.slice(0, 5);
1067
1276
 
1068
1277
  // Logged is what the work touched; assessed is what a question was actually
1069
1278
  // asked about. They are different claims and the page keeps them apart.
@@ -1076,37 +1285,39 @@ function viewLearningDashboard() {
1076
1285
  chart('c-activity', (el) => chartActivity(el, last30));
1077
1286
  chart('c-heat', (el) => chartHeatmap(el, daily));
1078
1287
 
1288
+ // One next step, the most useful one. Due work outranks an empty scope.
1289
+ const next = due.length
1290
+ ? nextStep(`<strong>${plural(due.length, 'concept')} ${due.length === 1 ? 'is' : 'are'} due.</strong>
1291
+ Run <code>/eklavya:quiz</code> in Claude Code, or open the
1292
+ <a class="link" data-go="${esc(LH('review'))}">review queue</a>.`)
1293
+ : PROJECT !== null && !answered
1294
+ ? nextStep(`<strong>No assessments yet in ${esc(scopeName())}.</strong>
1295
+ ${logged.size ? `${plural(logged.size, 'concept')} recorded from its work will come up in a quiz.` : 'Nothing has been logged here either.'}`, true)
1296
+ : '';
1297
+
1079
1298
  return [
1080
- `<div>
1081
- <h1 class="page__title">Am I actually getting better?</h1>
1082
- <p class="page__sub">${plural(seen.length, 'concept')} asked out of ${S.totals.catalogue} in the graph ·
1083
- ${plural(answered, 'answer')} · ${plural(sess.length, 'session')} · first answer ${esc(fmtDate(S.totals.first_answer))}.</p>
1084
- </div>`,
1299
+ pageHead('Am I actually getting better?', [
1300
+ `${plural(seen.length, 'concept')} asked of ${S.totals.catalogue}`,
1301
+ plural(answered, 'answer'),
1302
+ `${logged.size} recorded from your work, ${loggedAsked} assessed`,
1303
+ `since ${esc(fmtDate(S.totals.first_answer))}`,
1304
+ ...(PROJECT === null && unassessed.length
1305
+ ? [`<a class="link" data-go="${esc(LH('projects'))}">${plural(unassessed.length, 'project')} not assessed yet</a>`] : []),
1306
+ ], `A concept is mastered at score 0.7 with two or more passes. Its score decays about 5% a week once a
1307
+ review is overdue, so mastery is kept, not banked. The catalogue is a library, not a to-do list:
1308
+ untouched concepts are not debt. Captured memory is never counted as an answer — only a question you
1309
+ answered moves a score.`),
1085
1310
 
1086
1311
  tiles([
1087
1312
  ['Accuracy', answered ? pct(passed, answered) + '%' : '—', answered ? `${passed} right of ${answered}` : 'No assessments yet', answered && pct(passed, answered) >= 70 ? 'good' : '', LH('sessions')],
1088
- ['Recorded', logged.size, `concepts your work logged · ${loggedAsked} assessed`, '', LH('sessions')],
1089
1313
  ['Mastered', mastered.length, `of ${seen.length} asked`, mastered.length ? 'good' : '', LH('concepts', 'mastered')],
1090
1314
  ['Due now', due.length, due.length ? 'ready for review' : 'nothing waiting', due.length ? 'warn' : '', LH('review')],
1091
- ['Streak', st.current ? plural(st.current, 'day') : '—', st.best ? `best ${plural(st.best, 'day')} · ${plural(st.days, 'active day')}` : 'no answers yet'],
1315
+ ['Streak', st.current ? plural(st.current, 'day') : '—', st.best ? `best ${plural(st.best, 'day')}` : 'no answers yet'],
1092
1316
  ]),
1093
1317
 
1094
- PROJECT !== null && !answered
1095
- ? `<div class="banner"><p><strong>No assessments yet</strong> in ${esc(scopeName())}.
1096
- ${logged.size ? `${plural(logged.size, 'concept')} ${logged.size === 1 ? 'was' : 'were'} recorded from its work and will come up in a quiz.` : 'Nothing has been logged here either.'}
1097
- Captured memory is never counted as an answer.</p></div>`
1098
- : PROJECT === null && unassessed.length
1099
- ? `<div class="banner"><p><strong>${plural(unassessed.length, 'project')}</strong> recorded concepts but has no assessments yet:
1100
- ${unassessed.slice(0, 4).map((p) => `<a class="link" data-go="${esc(LH('projects', null, { project: p.id }))}">${esc(p.name)}</a>`).join(', ')}.</p></div>`
1101
- : '',
1102
-
1103
- due.length
1104
- ? `<div class="banner"><p><strong>${plural(due.length, 'concept')}</strong> ${due.length === 1 ? 'is' : 'are'} due.
1105
- Run <code>/eklavya:quiz</code> in Claude Code, or open the
1106
- <a class="link" data-go="${esc(LH('review'))}">review queue</a> to see what is waiting.</p></div>`
1107
- : '',
1318
+ next,
1108
1319
 
1109
- card('Answers over the last 30 days', 'Right, missed and skipped, per day. Empty days are shown as empty.',
1320
+ card('Answers, last 30 days', null,
1110
1321
  `<div class="legend">
1111
1322
  <span><b style="background:var(--spot)"></b>Right</span>
1112
1323
  <span><b style="background:var(--warning)"></b>Missed</span>
@@ -1115,42 +1326,35 @@ function viewLearningDashboard() {
1115
1326
  <div class="chart"><div class="tip" id="tip"></div><svg id="c-activity" role="img"
1116
1327
  aria-label="Daily answers, split into right, missed and skipped"></svg></div>`),
1117
1328
 
1118
- card('Practice history', 'One cell per day, up to a year back. Darker means more answered that day.',
1119
- `<div class="heat"><svg id="c-heat" role="img" aria-label="Calendar heatmap of daily answers"></svg></div>`),
1120
-
1121
1329
  `<div class="g2">
1122
- ${card('Where you stand', 'The catalogue is a library, not a to-do list — untouched concepts are not debt.',
1123
- segbar([
1124
- { label:'Mastered', n:mastered.length, color:'var(--spot)' },
1125
- { label:'Learning', n:seen.length - mastered.length, color:'color-mix(in srgb, var(--spot) 45%, var(--mass))' },
1126
- { label:'Not asked', n:Math.max(0, scoped.length - seen.length), color:'var(--mass)' },
1127
- ]) + `<p style="margin:var(--space-4) 0 0;color:var(--faint);font-size:13.5px">
1128
- A concept is mastered at score 0.7 with two or more passes. Score decays about
1129
- 5% a week once a review is overdue, so mastery is kept, not banked.</p>`)}
1130
- ${card('Weakest five', 'Lowest score first. These are what the next quiz will reach for.',
1131
- weakest.length
1132
- ? `<div class="list">` + weakest.map((c) => `
1133
- <div style="display:flex;gap:var(--space-4);align-items:center">
1134
- <div style="flex:1;min-width:0">
1135
- <a class="link" data-go="${esc(LH('concept', c.slug))}">${esc(c.name)}</a>
1136
- <div class="slug">${esc(c.domain)} · ${esc(tierLabel(c.tier))} · ${plural(c.attempts, 'attempt')}</div>
1137
- </div>
1138
- <div style="width:96px">${meter(c.score * 100, c.score < 0.5 ? 'warn' : '')}
1139
- <div class="slug" style="text-align:right">${Math.round(c.score * 100)}%</div></div>
1140
- </div>`).join('') + `</div>`
1141
- : '<p class="empty">Nothing asked in this scope yet.</p>')}
1330
+ ${card('Weakest five', null,
1331
+ lines(weakest.map((c) => ({
1332
+ t: esc(c.name),
1333
+ s: `${esc(c.domain)} · ${esc(tierLabel(c.tier))}`,
1334
+ v: `${Math.round(c.score * 100)}%${meter(c.score * 100, c.score < 0.5 ? 'warn' : '')}`,
1335
+ go: LH('concept', c.slug),
1336
+ })), 'Nothing asked in this scope yet.'),
1337
+ { aside: `<a class="link" data-go="${esc(LH('concepts'))}">All concepts →</a>` })}
1338
+ ${card('Recent sessions', null,
1339
+ lines(sess.slice(0, 5).map((s) => ({
1340
+ t: esc(pname(s.repo, s.id)),
1341
+ s: esc(sessionLabel(s)),
1342
+ v: `${s.asked.length ? `${s.passed}/${s.asked.length} right` : plural(s.logged.length, 'concept')}<div class="slug">${esc(ago(s.last))}</div>`,
1343
+ go: sessHref(s.id),
1344
+ })), 'No sessions in this scope yet.'),
1345
+ { aside: `<a class="link" data-go="${esc(href(CUR.wf, 'sessions'))}">All ${sess.length} →</a>` })}
1142
1346
  </div>`,
1143
1347
 
1144
- card('Recent sessions', 'Each one is a piece of work your agent did, and what it taught.',
1145
- table([{t:'Session'},{t:'Concepts',n:1},{t:'Asked',n:1},{t:'Right',n:1},{t:'When',n:1}],
1146
- recentSessions.map((s) => `<tr class="row" data-go="${esc(sessHref(s.id))}">
1147
- <td><span class="title">${esc(pname(s.repo, s.id))}</span>
1148
- <div class="slug">${esc(sessionLabel(s))}</div></td>
1149
- <td class="num">${s.logged.length}</td>
1150
- <td class="num">${s.asked.length}</td>
1151
- <td class="num">${s.passed}</td>
1152
- <td class="num slug">${esc(ago(s.last))}</td></tr>`)),
1153
- { aside: `<a class="link" data-go="${esc(href(CUR.wf, 'sessions'))}">All ${sess.length} sessions →</a>` }),
1348
+ fold('learn:stand', 'Where you stand', `${mastered.length} mastered · ${seen.length - mastered.length} learning`,
1349
+ segbar([
1350
+ { label:'Mastered', n:mastered.length, color:'var(--spot)' },
1351
+ { label:'Learning', n:seen.length - mastered.length, color:'color-mix(in srgb, var(--spot) 45%, var(--mass))' },
1352
+ { label:'Not asked', n:Math.max(0, scoped.length - seen.length), color:'var(--mass)' },
1353
+ ])),
1354
+
1355
+ fold('learn:heat', 'Practice history', plural(st.days, 'active day'),
1356
+ `<div class="heat"><svg id="c-heat" role="img" aria-label="Calendar heatmap of daily answers"></svg></div>`,
1357
+ { hint: 'One cell per day, up to a year back. Darker means more answered that day.' }),
1154
1358
  ].join('');
1155
1359
  }
1156
1360
 
@@ -1185,18 +1389,17 @@ function viewConcepts(param) {
1185
1389
  };
1186
1390
  list = [...list].sort(SORTS[t.sort] ?? SORTS.weakest);
1187
1391
 
1392
+ // Four columns: what it is, how strong, how often asked, and its state. The
1393
+ // code context and the slug live on the concept's own page.
1188
1394
  const p = page('concepts', list.length);
1189
1395
  const rows = slice(list, p).map((c) => `
1190
1396
  <tr class="row" data-go="${esc(LH('concept', c.slug))}">
1191
- <td style="min-width:210px"><span class="title">${esc(c.name)}</span>
1192
- <div class="slug">${esc(c.slug)}</div></td>
1193
- <td><span class="pill">${esc(c.domain)}</span></td>
1194
- <td class="slug" style="white-space:nowrap">${esc(tierLabel(c.tier))}</td>
1397
+ <td style="min-width:220px"><span class="title">${esc(c.name)}</span>
1398
+ <div class="slug">${esc(c.domain)} · ${esc(tierLabel(c.tier))}</div></td>
1195
1399
  <td style="min-width:104px">${c.seen ? meter(c.score * 100, c.score < 0.5 ? 'warn' : '') +
1196
1400
  `<div class="slug">${Math.round(c.score * 100)}%</div>` : '<span class="slug">—</span>'}</td>
1197
1401
  <td class="num">${c.attempts || '—'}</td>
1198
1402
  <td>${statePill(c)}</td>
1199
- <td class="ctx" style="min-width:220px">${esc(c.context ?? '')}</td>
1200
1403
  </tr>`);
1201
1404
 
1202
1405
  const counts = {
@@ -1209,14 +1412,12 @@ function viewConcepts(param) {
1209
1412
  const chip = (k, label) => `<button class="chip" data-state="${k}" aria-pressed="${t.state === k}">${label} ${counts[k]}</button>`;
1210
1413
 
1211
1414
  return [
1212
- `<div>
1213
- <h1 class="page__title">Concepts</h1>
1214
- <p class="page__sub">Everything in your graph — what you have been asked, and what is still just catalogue.
1215
- Open one to see every question you were given on it.</p>
1216
- </div>`,
1415
+ pageHead('Concepts', [plural(all.length, 'concept'), `${counts.all - counts.unseen} asked`, `${counts.mastered} mastered`],
1416
+ `Everything in your graph — what you have been asked, and what is still just catalogue. Open a concept
1417
+ for every question you were given on it and the code that logged it.`),
1217
1418
  card(null, null,
1218
1419
  `<div class="filters">
1219
- <div class="grow"><input type="search" id="q" value="${esc(t.q)}" placeholder="Search name, slug, domain or context…" aria-label="Search concepts"></div>
1420
+ <div class="grow"><input type="search" id="q" value="${esc(t.q)}" placeholder="Search concepts" aria-label="Search concepts"></div>
1220
1421
  <select id="dom" aria-label="Domain">
1221
1422
  <option value="all">All domains</option>
1222
1423
  ${domains.map((d) => `<option value="${esc(d)}"${t.domain === d ? ' selected' : ''}>${esc(d)}</option>`).join('')}
@@ -1230,7 +1431,7 @@ function viewConcepts(param) {
1230
1431
  ${chip('all','All')}${chip('due','Due')}${chip('learning','Learning')}${chip('mastered','Mastered')}${chip('unseen','Not asked')}
1231
1432
  </div>
1232
1433
  <div style="margin-top:var(--space-4)">
1233
- ${table([{t:'Concept'},{t:'Domain'},{t:'Tier'},{t:'Score'},{t:'Asked',n:1},{t:'State'},{t:'From your code'}], rows)}
1434
+ ${table([{t:'Concept'},{t:'Score'},{t:'Asked',n:1},{t:'State'}], rows)}
1234
1435
  ${pager('concepts', list.length, p)}
1235
1436
  </div>`),
1236
1437
  ].join('');
@@ -1245,72 +1446,54 @@ function viewConcept(slug) {
1245
1446
  // would render as a date attached to nothing.
1246
1447
  const ctx = S.logged.filter((l) => l.slug === slug && l.context && String(l.context).trim());
1247
1448
  const byName = new Map(S.concepts.map((x) => [x.slug, x]));
1248
- const links = (slugs) => slugs.length
1449
+ const links = (label, slugs) => `<div style="display:grid;gap:var(--space-2)"><b class="slug">${esc(label)}</b>` + (slugs.length
1249
1450
  ? `<div class="tags">` + slugs.map((s) => `<a class="pill link" data-go="${esc(LH('concept', s))}">${esc(byName.get(s)?.name ?? s)}</a>`).join('') + `</div>`
1250
- : '<p class="slug" style="margin:0">None recorded.</p>';
1251
-
1252
- const p = page('concept', atts.length, 5);
1253
- const history = slice(atts, p, 5).map((a) => {
1254
- let opts = [];
1255
- try { opts = a.options ? JSON.parse(a.options) : []; } catch { opts = []; }
1256
- return `<article class="qa">
1257
- <div class="qa__meta">
1258
- <span>${esc(fmtDate(a.ts))}</span><span>${esc(tierLabel(a.tier))}</span>
1259
- ${a.format ? `<span>${esc(a.format)}</span>` : ''}${a.level ? `<span>${esc(a.level)}</span>` : ''}
1260
- ${a.repo ? `<span>${esc(pname(a.repo, a.session_id))}</span>` : ''}
1261
- <span class="sp">${gradeBadge(a)}</span>
1262
- </div>
1263
- <p class="qa__q">${esc(a.question)}</p>
1264
- ${opts.length ? `<ul class="qa__opts">${opts.map((o) =>
1265
- `<li class="${a.answer && o === a.answer ? 'picked' : ''}">${esc(o)}</li>`).join('')}</ul>` : ''}
1266
- ${a.answer && !opts.length ? `<p class="qa__a"><b>You said:</b> ${esc(a.answer)}</p>` : ''}
1267
- ${a.feedback ? `<p class="qa__f">${esc(a.feedback)}</p>` : ''}
1268
- </article>`;
1269
- }).join('');
1451
+ : '<span class="slug">None recorded.</span>') + `</div>`;
1452
+
1453
+ // Collapsed, a question is one line, so ten fit where five cards did.
1454
+ const PER_Q = 10;
1455
+ const p = page('concept', atts.length, PER_Q);
1456
+ const history = slice(atts, p, PER_Q).map((a) => qaLine(a, `<span>${esc(fmtDate(a.ts))}</span>`)).join('');
1270
1457
 
1271
1458
  if (atts.length) chart('c-grades', (el) => chartGrades(el, atts));
1272
1459
  const decayed = c.stored_score > c.score;
1460
+ const back = `<a class="page__back" data-go="${esc(LH('concepts'))}">← Concepts</a>`;
1273
1461
 
1274
1462
  return [
1275
- `<div>
1276
- <a class="page__back" data-go="${esc(LH('concepts'))}">← Concepts</a>
1277
- <h1 class="page__title">${esc(c.name)}</h1>
1278
- <p class="page__sub"><code class="slug">${esc(c.slug)}</code> · ${esc(c.domain)} · ${esc(tierLabel(c.tier))} ·
1279
- ${esc(c.source === 'seed' ? 'from the seed graph' : 'minted from your work')}
1280
- ${c.description ? '<br>' + esc(c.description) : ''}</p>
1281
- </div>`,
1463
+ pageHead(esc(c.name), [
1464
+ `<code class="slug">${esc(c.slug)}</code>`, esc(c.domain), esc(tierLabel(c.tier)),
1465
+ esc(c.source === 'seed' ? 'from the seed graph' : 'minted from your work'),
1466
+ ], c.description ? esc(c.description) : '', back),
1282
1467
 
1283
1468
  tiles([
1284
- ['Score', c.seen ? Math.round(c.score * 100) + '%' : '—', decayed ? `decayed from ${Math.round(c.stored_score * 100)}%` : 'recency-weighted last 5 grades', c.mastered ? 'good' : c.score < 0.5 && c.seen ? 'warn' : ''],
1469
+ ['Score', c.seen ? Math.round(c.score * 100) + '%' : '—', decayed ? `decayed from ${Math.round(c.stored_score * 100)}%` : 'last five grades, recent first', c.mastered ? 'good' : c.score < 0.5 && c.seen ? 'warn' : ''],
1285
1470
  ['State', c.mastered ? 'mastered' : c.seen ? (c.due ? 'due' : 'learning') : 'not asked', c.seen ? `${c.passed} of ${c.attempts} right` : 'no questions yet', c.due ? 'warn' : ''],
1286
- ['Next review', c.seen ? whenDue(c.next_review) : '—', c.interval_d ? `interval ${Math.round(c.interval_d)}d` : 'not scheduled', c.due ? 'warn' : ''],
1287
- ['Reps', c.reps, `ease ${c.ease ? c.ease.toFixed(2) : '—'}`],
1471
+ ['Next review', c.seen ? whenDue(c.next_review) : '—', c.interval_d ? `every ${Math.round(c.interval_d)}d · ease ${c.ease ? c.ease.toFixed(2) : '—'} · ${plural(c.reps, 'rep')}` : 'not scheduled', c.due ? 'warn' : ''],
1288
1472
  ]),
1289
1473
 
1474
+ card('Every question you were asked', atts.length ? `${plural(atts.length, 'attempt')}, newest first. Open one for the options and the feedback.` : '',
1475
+ atts.length ? history + pager('concept', atts.length, p, PER_Q)
1476
+ : '<p class="empty">Not asked yet. It will come up when your work touches it.</p>'),
1477
+
1290
1478
  atts.length
1291
- ? card('How the grades went', 'Every graded answer in order. Grey points are skips and blanks.',
1292
- `<div class="chart"><svg id="c-grades" role="img" aria-label="Grades over time for this concept"></svg></div>`)
1479
+ ? fold('concept:grades', 'How the grades went', plural(atts.length, 'grade'),
1480
+ `<div class="chart"><svg id="c-grades" role="img" aria-label="Grades over time for this concept"></svg></div>`,
1481
+ { hint: 'Every graded answer in order. Grey points are skips and blanks.' })
1293
1482
  : '',
1294
1483
 
1295
- card('Where this came from', 'The code that logged it — the memory the slug hangs on.',
1484
+ fold('concept:ctx', 'Where it came from', ctx.length ? plural(ctx.length, 'context line') : 'seed graph',
1296
1485
  ctx.length
1297
- ? `<div class="list">` + ctx.slice(0, 12).map((l) => `
1298
- <div>
1299
- <div class="ctx">${esc(l.context ?? '')}</div>
1300
- <div class="slug">${esc(ago(l.ts))} · ${esc(pname(l.repo, l.session_id))} · ${esc(l.origin ?? 'work')} ·
1301
- <a class="link" data-go="${esc(sessHref(l.session_id))}">that session</a></div>
1302
- </div>`).join('') + `</div>`
1303
- : '<p class="empty">No context recorded — this one came from the seed graph.</p>'),
1304
-
1305
- card(`Every question you were asked`, atts.length ? `${plural(atts.length, 'attempt')}, newest first.` : '',
1306
- atts.length ? history + pager('concept', atts.length, p, 5)
1307
- : '<p class="empty">Not asked yet. It will come up when your work touches it.</p>'),
1486
+ ? lines(ctx.slice(0, 12).map((l) => ({
1487
+ t: esc(l.context ?? ''),
1488
+ s: `${esc(ago(l.ts))} · ${esc(pname(l.repo, l.session_id))} · ${esc(l.origin ?? 'work')}`,
1489
+ go: sessHref(l.session_id),
1490
+ })))
1491
+ : '<p class="empty">No context recorded — this one came from the seed graph.</p>',
1492
+ { hint: 'What the agent said the concept was for, in the code it wrote. Open a line for that session.' }),
1308
1493
 
1309
- `<div class="g3">
1310
- ${card('Prerequisites', 'Asked first when the graph plans a session.', links(c.prereqs))}
1311
- ${card('Unlocks', 'These lean on this one.', links(c.unlocks))}
1312
- ${card('Related', 'Same neighbourhood.', links(c.related))}
1313
- </div>`,
1494
+ fold('concept:graph', 'Connected concepts',
1495
+ `${c.prereqs.length} prerequisites · ${c.unlocks.length} unlocks · ${c.related.length} related`,
1496
+ `<div style="display:grid;gap:var(--space-5)">${links('Prerequisites — asked first', c.prereqs)}${links('Unlocks — lean on this one', c.unlocks)}${links('Related', c.related)}</div>`),
1314
1497
  ].join('');
1315
1498
  }
1316
1499
 
@@ -1340,44 +1523,41 @@ function viewReview(param) {
1340
1523
  });
1341
1524
  }
1342
1525
  chart('c-forecast', (el) => chartForecast(el, buckets));
1526
+ const week = buckets.slice(1, 8).reduce((a, b) => a + b.n, 0);
1527
+ const longest = due.length ? Math.max(...due.map((c) => c.overdue_days ?? 0)) : 0;
1343
1528
 
1344
1529
  const tabs = [['due', `Due now ${due.length}`], ['upcoming', `Upcoming ${upcoming.length}`], ['skipped', `Skipped ${skipped.length}`]];
1345
1530
  const list = t.tab === 'due' ? due : t.tab === 'upcoming' ? upcoming : skipped;
1346
1531
  const p = page('review', list.length);
1347
1532
  const rows = slice(list, p).map((x) => t.tab === 'skipped'
1348
1533
  ? `<tr class="row" data-go="${esc(LH('concept', x.slug))}">
1349
- <td><span class="title">${esc(x.name)}</span><div class="slug">${esc(x.slug)}</div></td>
1534
+ <td style="min-width:260px"><span class="title">${esc(x.name)}</span><div class="ctx">${esc(x.question)}</div></td>
1350
1535
  <td><span class="pill">${x.outcome === 'declined' ? 'declined' : 'blanked'}</span></td>
1351
- <td class="ctx" style="min-width:280px">${esc(x.question)}</td>
1352
1536
  <td class="num slug">${esc(ago(x.ts))}</td></tr>`
1353
1537
  : `<tr class="row" data-go="${esc(LH('concept', x.slug))}">
1354
- <td><span class="title">${esc(x.name)}</span><div class="slug">${esc(x.slug)} · ${esc(x.domain)}</div></td>
1538
+ <td style="min-width:220px"><span class="title">${esc(x.name)}</span><div class="slug">${esc(x.domain)} · ${esc(tierLabel(x.tier))}</div></td>
1355
1539
  <td style="min-width:104px">${meter(x.score * 100, x.score < 0.5 ? 'warn' : '')}
1356
1540
  <div class="slug">${Math.round(x.score * 100)}%</div></td>
1357
- <td class="num">${x.attempts}</td>
1358
- <td class="slug" style="white-space:nowrap">${esc(tierLabel(x.tier))}</td>
1359
1541
  <td class="num slug">${esc(whenDue(x.next_review))}</td></tr>`);
1360
1542
 
1361
1543
  const cols = t.tab === 'skipped'
1362
- ? [{t:'Concept'},{t:'How'},{t:'The question'},{t:'When',n:1}]
1363
- : [{t:'Concept'},{t:'Score'},{t:'Asked',n:1},{t:'Tier'},{t:t.tab === 'due' ? 'Overdue' : 'Due',n:1}];
1544
+ ? [{t:'Concept and question'},{t:'How'},{t:'When',n:1}]
1545
+ : [{t:'Concept'},{t:'Score'},{t:t.tab === 'due' ? 'Overdue' : 'Due',n:1}];
1364
1546
 
1365
1547
  return [
1366
- `<div>
1367
- <h1 class="page__title">Review queue</h1>
1368
- <p class="page__sub">SM-2 decides when a concept comes back: one day after a miss, then 6, then the
1369
- interval times its ease. A score decays about 5% a week while a review is overdue, so this list is
1370
- also the list of things quietly getting weaker.</p>
1371
- </div>`,
1548
+ pageHead('Review queue', [
1549
+ due.length ? `<span class="warn">${due.length} due now</span>` : 'nothing due',
1550
+ `${week} in the next 7 days`, `${skipped.length} skipped`,
1551
+ ...(due.length ? [`longest overdue ${plural(longest, 'day')}`] : []),
1552
+ ], `SM-2 decides when a concept comes back: one day after a miss, then 6, then the interval times its
1553
+ ease. A score decays about 5% a week while a review is overdue, so this list is also the list of
1554
+ things quietly getting weaker. A skipped question comes back a tier lower.`),
1372
1555
 
1373
- tiles([
1374
- ['Due now', due.length, due.length ? 'run /eklavya:quiz' : 'nothing waiting', due.length ? 'warn' : 'good'],
1375
- ['Next 7 days', buckets.slice(1, 8).reduce((a, b) => a + b.n, 0), 'scheduled reviews'],
1376
- ['Skipped', skipped.length, 'come back a tier lower'],
1377
- ['Longest overdue', due.length ? plural(Math.max(...due.map((c) => c.overdue_days ?? 0)), 'day') : '—', 'since it was due'],
1378
- ]),
1556
+ due.length
1557
+ ? nextStep(`<strong>${plural(due.length, 'concept')} waiting.</strong> Run <code>/eklavya:quiz</code> in Claude Code to work through them.`)
1558
+ : '',
1379
1559
 
1380
- card('The next fortnight', 'How much review is already scheduled, day by day.',
1560
+ card('The next fortnight', null,
1381
1561
  `<div class="chart"><svg id="c-forecast" role="img" aria-label="Scheduled reviews for the next fourteen days"></svg></div>`),
1382
1562
 
1383
1563
  card(null, null,
@@ -1401,28 +1581,21 @@ function viewSessions() {
1401
1581
  <td style="min-width:260px"><span class="title">${esc(pname(s.repo, s.id))}</span>
1402
1582
  <div class="slug">${esc(sessionLabel(s).slice(0, 120))}</div></td>
1403
1583
  <td class="num">${s.logged.filter((l) => (l.origin ?? 'work') === 'work').length}</td>
1404
- <td class="num">${s.asked.length}</td>
1405
- <td style="min-width:96px">${acc === null ? '<span class="slug">not assessed</span>'
1406
- : meter(acc, acc < 70 ? 'warn' : '') + `<div class="slug">${acc}%</div>`}</td>
1407
- <td class="num slug">${esc(stamp(s.last))}</td></tr>`;
1584
+ <td style="min-width:110px">${acc === null ? '<span class="slug">not assessed</span>'
1585
+ : meter(acc, acc < 70 ? 'warn' : '') + `<div class="slug">${s.passed}/${s.asked.length} right</div>`}</td>
1586
+ <td class="num slug">${esc(ago(s.last))}</td></tr>`;
1408
1587
  });
1409
1588
 
1410
1589
  const withQuestions = all.filter((s) => s.asked.length).length;
1411
1590
  return [
1412
- `<div>
1413
- <h1 class="page__title">Sessions</h1>
1414
- <p class="page__sub">One row per Claude Code session that logged a concept or asked a question. Open one
1415
- for the whole thread: the concepts the work touched, every question you were asked, and — joined on the
1416
- same session id — what the session captured and remembered.</p>
1417
- </div>`,
1418
- tiles([
1419
- ['Sessions', all.length, 'that logged or asked'],
1420
- ['With questions', withQuestions, `${pct(withQuestions, all.length)}% of sessions`],
1421
- ['Concepts logged', all.reduce((a, s) => a + s.logged.filter((l) => (l.origin ?? 'work') === 'work').length, 0), 'context lines recorded'],
1422
- ['Answers', all.reduce((a, s) => a + s.asked.length, 0), 'across every session'],
1423
- ]),
1591
+ pageHead('Sessions', [
1592
+ plural(all.length, 'session'), `${withQuestions} with questions`,
1593
+ plural(all.reduce((a, s) => a + s.asked.length, 0), 'answer'),
1594
+ ], `One row per Claude Code session that logged a concept or asked a question. Open one for the whole
1595
+ thread: the concepts the work touched, every question you were asked, and — joined on the same
1596
+ session id — what the session captured and remembered.`),
1424
1597
  card(null, null,
1425
- table([{t:'Session'},{t:'Logged',n:1},{t:'Asked',n:1},{t:'Accuracy'},{t:'When',n:1}], rows)
1598
+ table([{t:'Session'},{t:'Concepts',n:1},{t:'Answers'},{t:'When',n:1}], rows)
1426
1599
  + pager('sessions', all.length, p)
1427
1600
  + (memOnly ? `<p class="slug" style="margin:var(--space-4) 0 0">${plural(memOnly, 'session')} captured memory and logged no concept —
1428
1601
  <a class="link" data-go="${esc(MH('sessions'))}">see them in Memory → Sessions</a>.</p>` : '')),
@@ -1459,82 +1632,62 @@ function sessionBody(s) {
1459
1632
  const work = s.logged.filter((l) => (l.origin ?? 'work') === 'work');
1460
1633
  const review = s.logged.filter((l) => l.origin === 'review');
1461
1634
 
1462
- const conceptRow = (l) => {
1635
+ const conceptLine = (l) => {
1463
1636
  const c = byName.get(l.slug);
1464
- return `<tr class="row" data-go="${esc(LH('concept', l.slug))}">
1465
- <td style="min-width:190px"><span class="title">${esc(c?.name ?? l.slug)}</span>
1466
- <div class="slug">${esc(l.slug)} · ${esc(l.domain)}</div></td>
1467
- <td class="ctx" style="min-width:280px">${esc(l.context ?? '')}</td>
1468
- <td>${c ? statePill(c) : ''}</td></tr>`;
1637
+ return { t: esc(c?.name ?? l.slug), s: esc(l.context ?? l.domain ?? ''), v: c ? statePill(c) : '', go: LH('concept', l.slug) };
1469
1638
  };
1470
1639
 
1471
- const qa = s.asked.slice().reverse().map((a) => {
1472
- let opts = [];
1473
- try { opts = a.options ? JSON.parse(a.options) : []; } catch { opts = []; }
1474
- return `<article class="qa">
1475
- <div class="qa__meta">
1476
- <a class="link" data-go="${esc(LH('concept', a.slug))}">${esc(a.name)}</a>
1477
- <span>${esc(tierLabel(a.tier))}</span>${a.level ? `<span>${esc(a.level)}</span>` : ''}
1478
- <span class="sp">${gradeBadge(a)}</span>
1479
- </div>
1480
- <p class="qa__q">${esc(a.question)}</p>
1481
- ${opts.length ? `<ul class="qa__opts">${opts.map((o) =>
1482
- `<li class="${a.answer && o === a.answer ? 'picked' : ''}">${esc(o)}</li>`).join('')}</ul>` : ''}
1483
- ${a.answer && !opts.length ? `<p class="qa__a"><b>You said:</b> ${esc(a.answer)}</p>` : ''}
1484
- ${a.feedback ? `<p class="qa__f">${esc(a.feedback)}</p>` : ''}
1485
- </article>`;
1486
- }).join('');
1640
+ // Collapsed to one line each: which concept, the question, the grade.
1641
+ const qa = s.asked.slice().reverse().map((a) =>
1642
+ qaLine(a, `<span style="color:var(--spot)">${esc(a.name)}</span>`, true)).join('');
1487
1643
 
1488
- // The memory half of this session, in the order it happened: evidence in,
1489
- // observations out, candidates proposed — then the questions above.
1644
+ // The memory half of this session, folded: what it remembered and what it
1645
+ // proposed are the audit trail behind the learning, not the headline.
1490
1646
  if (s.mem) {
1491
1647
  fill('sess-mem', '/api/memory?per=100&session=' + encodeURIComponent(id), (d) => {
1492
- const entries = d.rows.map((e) => `
1493
- <tr class="row${e.deleted_at || e.superseded_by ? ' gone' : ''}" data-go="${esc(MH('entry', e.id))}">
1494
- <td class="slug" style="white-space:nowrap">${esc(stamp(e.occurred_at))}</td>
1495
- <td style="min-width:260px"><span class="title">${esc(e.title)}</span>
1496
- <div class="ctx">${esc(e.snippet ?? '')}</div></td>
1497
- <td><span class="pill">${esc(e.type ?? e.kind)}</span></td>
1498
- <td class="num">${e.event_count}</td>
1499
- <td>${entryMark(e)}</td></tr>`);
1500
- const cands = (d.candidates ?? []).map((c) => {
1501
- const go = c.concept_slug ? ` class="row" data-go="${esc(LH('concept', c.concept_slug))}"` : '';
1502
- return `<tr${go}><td><span class="title">${esc(c.name ?? c.slug)}</span><div class="slug">${esc(c.slug)}</div></td>
1503
- <td><span class="pill">${esc(c.domain ?? '—')}</span></td>
1504
- <td class="num">${Math.round((c.confidence ?? 0) * 100)}%</td>
1505
- <td><span class="pill${c.status === 'accepted' ? ' ok' : ''}">${esc(c.status)}</span></td></tr>`;
1506
- });
1507
- return card('What the session remembered', 'Observations distilled from the evidence this session captured.',
1508
- table([{t:'When'},{t:'Observation'},{t:'Type'},{t:'Evidence',n:1},{t:''}], entries))
1509
- + card('Concepts it proposed', 'Derived from evidence, and still only a proposal — nothing here moved mastery.',
1510
- table([{t:'Candidate'},{t:'Domain'},{t:'Confidence',n:1},{t:'Status'}], cands));
1648
+ const entries = d.rows.map((e) => ({
1649
+ t: `<span${e.deleted_at || e.superseded_by ? ' class="gone-title"' : ''}>${esc(e.title)}</span>`,
1650
+ s: `${esc(stamp(e.occurred_at))} · ${esc(e.type ?? e.kind)} · ${plural(e.event_count, 'evidence event')}`,
1651
+ v: entryMark(e),
1652
+ go: MH('entry', e.id),
1653
+ }));
1654
+ const cands = (d.candidates ?? []).map((c) => ({
1655
+ t: esc(c.name ?? c.slug),
1656
+ s: `${esc(c.domain ?? '—')} · ${Math.round((c.confidence ?? 0) * 100)}% confidence`,
1657
+ v: `<span class="pill${c.status === 'accepted' ? ' ok' : ''}">${esc(c.status)}</span>`,
1658
+ go: c.concept_slug ? LH('concept', c.concept_slug) : null,
1659
+ }));
1660
+ return fold('sess:mem', 'What the session remembered', plural(entries.length, 'observation'), lines(entries),
1661
+ { hint: 'Observations distilled from the evidence this session captured.' })
1662
+ + (cands.length
1663
+ ? fold('sess:cands', 'Concepts it proposed', plural(cands.length, 'candidate'), lines(cands),
1664
+ { hint: 'Derived from evidence, and still only a proposal — nothing here moved mastery.' })
1665
+ : '');
1511
1666
  });
1512
1667
  }
1513
1668
 
1669
+ const back = `<a class="page__back" data-go="${esc(href(CUR.wf, 'sessions'))}">← Sessions</a>`;
1514
1670
  return [
1515
- `<div>
1516
- <a class="page__back" data-go="${esc(href(CUR.wf, 'sessions'))}">← Sessions</a>
1517
- <h1 class="page__title">${esc(pname(s.repo, s.id))}</h1>
1518
- <p class="page__sub">${esc(stamp(s.first))} → ${esc(stamp(s.last))} · <code class="slug">${esc(s.id)}</code></p>
1519
- </div>`,
1520
- tiles([
1521
- ['Captured', s.mem ? s.mem.events : 0, s.mem && s.mem.redacted ? `${s.mem.redacted} redacted` : 'evidence events'],
1522
- ['Remembered', s.mem ? s.mem.entries : 0, 'observations written'],
1523
- ['Logged', s.logged.length, 'concepts the work touched'],
1524
- ['Asked', s.asked.length, s.asked.length ? `${s.passed} right` : 'no questions in this one'],
1525
- ['Accuracy', s.asked.length ? pct(s.passed, s.asked.length) + '%' : '—', 'in this session',
1526
- s.asked.length && pct(s.passed, s.asked.length) >= 70 ? 'good' : ''],
1527
- ['Review pulled in', review.length, 'from earlier work'],
1528
- ]),
1529
- s.mem ? `<div class="stack" id="sess-mem"><p class="empty">Loading what this session remembered…</p></div>` : '',
1530
- card('What the work taught', 'Each line is what the agent said the concept was for, in the code it wrote.',
1531
- table([{t:'Concept'},{t:'What you did'},{t:'State now'}], work.map(conceptRow))),
1671
+ pageHead(esc(pname(s.repo, s.id)), [
1672
+ `${esc(stamp(s.first))} → ${esc(stamp(s.last))}`,
1673
+ `${plural(work.length, 'concept')} logged`,
1674
+ s.asked.length ? `${s.passed}/${s.asked.length} right (${pct(s.passed, s.asked.length)}%)` : 'no questions',
1675
+ ...(s.mem ? [`${s.mem.events} captured${s.mem.redacted ? `, ${s.mem.redacted} redacted` : ''}`, `${s.mem.entries} remembered`] : []),
1676
+ ], `Session <code class="slug">${esc(s.id)}</code>. The learning half and the memory half are joined on the
1677
+ session id the host handed both — the one association between them that is recorded rather than inferred.`, back),
1678
+
1679
+ work.length
1680
+ ? card('What the work taught', 'Each line is what the agent said the concept was for, in the code it wrote.', lines(work.map(conceptLine)))
1681
+ : '',
1682
+
1683
+ s.asked.length ? card('What you were asked', `${plural(s.asked.length, 'question')}, in order. Open one for the options and the feedback.`, qa) : '',
1684
+
1532
1685
  review.length
1533
- ? card('Pulled in for review', 'Not from this diff — the tutor widened to prerequisites and neighbours.',
1534
- table([{t:'Concept'},{t:'Context'},{t:'State now'}], review.map(conceptRow)))
1686
+ ? fold('sess:review', 'Pulled in for review', plural(review.length, 'concept'), lines(review.map(conceptLine)),
1687
+ { hint: 'Not from this diff — the tutor widened to prerequisites and neighbours.' })
1535
1688
  : '',
1536
- card('What you were asked', s.asked.length ? `${plural(s.asked.length, 'question')}, in order.` : '',
1537
- s.asked.length ? qa : '<p class="empty">No questions were asked in this session.</p>'),
1689
+
1690
+ s.mem ? `<div class="stack" id="sess-mem"><p class="empty">Loading what this session remembered…</p></div>` : '',
1538
1691
  ].join('');
1539
1692
  }
1540
1693
 
@@ -1549,24 +1702,26 @@ function viewProjects() {
1549
1702
  const other = inv.filter((p) => !p.learning.answers && !p.learning.logged_concepts);
1550
1703
  const daily = dailyInScope();
1551
1704
 
1552
- const cards = list.map((p, i) => {
1705
+ // One fold per project. Its summary line is the standing — level, accuracy,
1706
+ // answers, last active — and it opens to the runway and thirty days of work.
1707
+ const folds = list.map((p, i) => {
1553
1708
  const L = p.learning;
1709
+ const acc = L.answers ? pct(L.passed, L.answers) + '%' : null;
1710
+ const st = L.answers ? S.projects.filter((x) => pid(x.repo) === p.id).sort((x, y) => y.answers - x.answers)[0] : null;
1711
+ const peek = [st ? st.level : 'not assessed', acc ? `${acc} of ${plural(L.answers, 'answer')}` : plural(L.logged_concepts, 'concept') + ' recorded', esc(ago(L.last))].join(' · ');
1554
1712
  const detail = `<div class="kv" style="margin-bottom:var(--space-5)">
1555
- <div><b>Answers</b><span>${L.answers}</span></div>
1556
- <div><b>Accuracy</b><span>${L.answers ? pct(L.passed, L.answers) + '%' : '—'}</span></div>
1557
1713
  <div><b>Assessed</b><span>${plural(L.assessed_concepts, 'concept')}</span></div>
1558
1714
  <div><b>Recorded</b><span>${plural(L.logged_concepts, 'concept')}</span></div>
1559
1715
  <div><b>Sessions</b><span>${L.sessions}</span></div>
1560
- <div><b>Last active</b><span>${esc(ago(L.last))}</span></div>
1561
1716
  </div>`;
1717
+ const links = `<p class="more"><a class="link" data-go="${esc(LH('dashboard', null, { project: p.id }))}">Its dashboard →</a>
1718
+ &nbsp; <a class="link" data-go="${esc(LH('sessions', null, { project: p.id }))}">Its sessions →</a></p>`;
1719
+ const opts = { hint: whereLine(p), open: list.length === 1 };
1562
1720
  if (!L.answers) {
1563
- return card(p.name, whereLine(p), detail
1564
- + `<p class="slug" style="margin:0"><strong>No assessments yet.</strong> The concepts this project's work recorded
1565
- come up in a quiz like any others; nothing counts toward a level until one is answered.
1566
- <a class="link" data-go="${esc(LH('sessions', null, { project: p.id }))}">Its sessions →</a></p>`);
1721
+ return fold('proj:' + p.id, p.name, peek, detail
1722
+ + `<p class="slug" style="margin:0">No assessments yet. The concepts this project's work recorded come up in a
1723
+ quiz like any others; nothing counts toward a level until one is answered.</p>` + links, opts);
1567
1724
  }
1568
- // The level runway comes from the learning payload, keyed by the same fold.
1569
- const st = S.projects.filter((x) => pid(x.repo) === p.id).sort((x, y) => y.answers - x.answers)[0];
1570
1725
  const bar = (label, value, target, ok) => `
1571
1726
  <div><span class="${ok ? 'mark-ok' : 'mark-no'}">${ok ? '✓' : '○'}</span>
1572
1727
  <span style="min-width:150px">${esc(label)}</span>
@@ -1589,7 +1744,7 @@ function viewProjects() {
1589
1744
  chartActivity(el, fillDays(m, 30));
1590
1745
  });
1591
1746
 
1592
- return card(p.name, whereLine(p), detail
1747
+ return fold('proj:' + p.id, p.name, peek, detail
1593
1748
  + (st ? `<div class="ladder">${ladder}</div>
1594
1749
  ${st.pinned
1595
1750
  ? `<p class="slug" style="margin:var(--space-4) 0 0">Difficulty is pinned in config, so this project does not promote.</p>`
@@ -1602,33 +1757,28 @@ function viewProjects() {
1602
1757
  </div>`
1603
1758
  : `<p class="slug" style="margin:var(--space-4) 0 0">Top of the ladder — questions can reach tier 5 here.</p>`}` : '')
1604
1759
  + `<div class="chart" style="margin-top:var(--space-5)"><svg id="c-proj-${i}" role="img"
1605
- aria-label="Thirty days of answers in this project"></svg></div>`);
1760
+ aria-label="Thirty days of answers in this project"></svg></div>` + links, opts);
1606
1761
  });
1607
1762
 
1608
1763
  const assessed = list.filter((p) => p.learning.answers);
1609
1764
  return [
1610
- `<div>
1611
- <h1 class="page__title">Projects</h1>
1612
- <p class="page__sub">Difficulty is earned per codebase, not per person: the same developer is a different
1613
- learner on their fourth Node service and their first Rust one. Everyone starts at easy (tiers 1–2)
1614
- and climbs on evidence. A project appears here as soon as its work logs a concept — before a single
1615
- question is asked.</p>
1616
- </div>`,
1617
- tiles([
1618
- ['Projects', list.length, 'with logged or assessed work'],
1619
- ['Assessed', assessed.length, `${list.length - assessed.length} with no assessments yet`],
1620
- ['Answers', list.reduce((a, p) => a + p.learning.answers, 0), 'across all of them'],
1621
- ['Best accuracy', assessed.length ? Math.max(...assessed.map((p) => pct(p.learning.passed, p.learning.answers))) + '%' : '—', 'of any project'],
1622
- ['Active days', streaks(daily).days, 'with at least one answer'],
1623
- ]),
1624
- cards.join('') || '<p class="empty">No project has logged or answered anything yet.</p>',
1765
+ pageHead('Projects', [
1766
+ plural(list.length, 'project'), `${assessed.length} assessed`,
1767
+ plural(list.reduce((a, p) => a + p.learning.answers, 0), 'answer'), plural(streaks(daily).days, 'active day'),
1768
+ ], `Difficulty is earned per codebase, not per person: the same developer is a different learner on their
1769
+ fourth Node service and their first Rust one. Everyone starts at easy (tiers 1–2) and climbs on
1770
+ evidence — a promotion needs passing answers <em>and</em> accuracy <em>and</em> distinct concepts. A
1771
+ project appears here as soon as its work logs a concept, before a single question is asked.`),
1772
+ folds.join('') || '<p class="empty">No project has logged or answered anything yet.</p>',
1625
1773
  other.length
1626
- ? card('Memory only', 'Recorded by capture, with no learning activity yet. Captured is not assessed.',
1627
- table([{t:'Project'},{t:'Evidence',n:1},{t:'Observations',n:1},{t:'Last active',n:1}], other.map((p) => `
1628
- <tr class="row" data-go="${esc(MH('projects', null, { project: p.id }))}">
1629
- <td><span class="title">${esc(p.name)}</span><div class="slug">${esc(whereLine(p))}</div></td>
1630
- <td class="num">${p.memory.events}</td><td class="num">${p.memory.entries}</td>
1631
- <td class="num slug">${esc(ago(p.last_active))}</td></tr>`)))
1774
+ ? fold('proj:memory-only', 'Memory only', plural(other.length, 'project'),
1775
+ lines(other.map((p) => ({
1776
+ t: esc(p.name),
1777
+ s: `${esc(whereLine(p))}`,
1778
+ v: `${p.memory.entries} obs.<div class="slug">${esc(ago(p.last_active))}</div>`,
1779
+ go: MH('projects', null, { project: p.id }),
1780
+ }))),
1781
+ { hint: 'Recorded by capture, with no learning activity yet. Captured is not assessed.' })
1632
1782
  : '',
1633
1783
  ].join('');
1634
1784
  }
@@ -1643,34 +1793,27 @@ function viewDomains() {
1643
1793
  byDomain.set(c.domain, d);
1644
1794
  }
1645
1795
  const list = [...byDomain.values()].sort((a, b) => b.touched - a.touched || b.catalogue - a.catalogue);
1646
-
1647
1796
  const rows = list.map((d) => `
1648
1797
  <tr class="row" data-go="${esc(LH('domain', d.domain))}">
1649
1798
  <td><span class="title">${esc(d.domain)}</span>
1650
- <div class="slug">${plural(d.catalogue, 'concept')} in the graph</div></td>
1799
+ <div class="slug">${d.touched} asked of ${d.catalogue} in the graph</div></td>
1651
1800
  <td style="min-width:130px">${d.touched
1652
- ? meter(pct(d.mastered, d.touched)) + `<div class="slug">${d.mastered} mastered of ${d.touched} asked</div>`
1653
- : '<span class="slug">your work has not reached it</span>'}</td>
1654
- <td class="num">${d.attempts}</td>
1801
+ ? meter(pct(d.mastered, d.touched)) + `<div class="slug">${d.mastered} mastered</div>`
1802
+ : '<span class="slug">not reached yet</span>'}</td>
1655
1803
  <td class="num">${d.due || '—'}</td>
1656
- <td class="num slug">${d.catalogue - d.touched} not asked</td>
1657
1804
  </tr>`);
1658
-
1659
1805
  const tierCount = [1,2,3,4,5].map((t) => scoped.filter((c) => c.seen && c.tier === t).length);
1660
1806
  // A sequential ramp mixed from the accent role rather than picked off the
1661
1807
  // accent scale: a fixed --vd-200 is legible on ink and invisible on paper.
1662
1808
  const COLORS = [100, 78, 58, 40, 24].map((p) => `color-mix(in srgb, var(--spot) ${p}%, var(--mass))`);
1663
-
1664
1809
  return [
1665
- `<div>
1666
- <h1 class="page__title">Domains</h1>
1667
- <p class="page__sub">What you are learning, grouped. The catalogue is a library, not a to-do list —
1668
- a domain with untouched concepts is a domain your work has not reached yet.</p>
1669
- </div>`,
1670
- card('Coverage', 'Open a domain to see its concepts.',
1671
- table([{t:'Domain'},{t:'Mastered'},{t:'Answers',n:1},{t:'Due',n:1},{t:'Catalogue',n:1}], rows)),
1672
- card('Tier spread of what you have been asked', 'Tier is how hard one question is: recall, mechanism, judgement, failure modes, design.',
1673
- segbar([1,2,3,4,5].map((t, i) => ({ label: tierLabel(t), n: tierCount[i], color: COLORS[i] })))),
1810
+ pageHead('Domains', [plural(list.length, 'domain'), `${list.filter((d) => d.touched).length} reached by your work`],
1811
+ `What you are learning, grouped. The catalogue is a library, not a to-do list — a domain with untouched
1812
+ concepts is a domain your work has not reached yet.`),
1813
+ card(null, null, table([{t:'Domain'},{t:'Mastered'},{t:'Due',n:1}], rows)),
1814
+ fold('domains:tiers', 'Tier spread', `${tierCount.reduce((a, b) => a + b, 0)} asked`,
1815
+ segbar([1,2,3,4,5].map((t, i) => ({ label: tierLabel(t), n: tierCount[i], color: COLORS[i] }))),
1816
+ { hint: 'Tier is how hard one question is: recall, mechanism, judgement, failure modes, design.' }),
1674
1817
  ].join('');
1675
1818
  }
1676
1819
 
@@ -1678,35 +1821,25 @@ function viewDomain(name) {
1678
1821
  const all = conceptsInScope().filter((c) => c.domain === name);
1679
1822
  if (!all.length) return `<p class="empty">No concepts in <code>${esc(name)}</code> for this scope.
1680
1823
  <a class="link" data-go="${esc(LH('domains'))}">Back to domains</a>.</p>`;
1681
-
1682
1824
  const seen = all.filter((c) => c.seen);
1683
1825
  const list = [...all].sort((a, b) => (a.seen === b.seen ? a.score - b.score : a.seen ? -1 : 1));
1684
1826
  const p = page('domain', list.length);
1685
1827
  const rows = slice(list, p).map((c) => `
1686
1828
  <tr class="row" data-go="${esc(LH('concept', c.slug))}">
1687
- <td style="min-width:220px"><span class="title">${esc(c.name)}</span><div class="slug">${esc(c.slug)}</div></td>
1688
- <td class="slug" style="white-space:nowrap">${esc(tierLabel(c.tier))}</td>
1829
+ <td style="min-width:220px"><span class="title">${esc(c.name)}</span><div class="slug">${esc(tierLabel(c.tier))}</div></td>
1689
1830
  <td style="min-width:104px">${c.seen ? meter(c.score * 100, c.score < 0.5 ? 'warn' : '')
1690
1831
  + `<div class="slug">${Math.round(c.score * 100)}%</div>` : '<span class="slug">—</span>'}</td>
1691
1832
  <td class="num">${c.attempts || '—'}</td>
1692
1833
  <td>${statePill(c)}</td>
1693
- <td class="ctx" style="min-width:200px">${esc(c.context ?? '')}</td>
1694
1834
  </tr>`);
1695
-
1835
+ const back = `<a class="page__back" data-go="${esc(LH('domains'))}">← Domains</a>`;
1696
1836
  return [
1697
- `<div>
1698
- <a class="page__back" data-go="${esc(LH('domains'))}">← Domains</a>
1699
- <h1 class="page__title">${esc(name)}</h1>
1700
- <p class="page__sub">${plural(all.length, 'concept')} in the graph · ${seen.length} asked ·
1701
- ${seen.filter((c) => c.mastered).length} mastered.</p>
1702
- </div>`,
1837
+ pageHead(esc(name), [plural(all.length, 'concept'), `${seen.length} asked`, `${seen.filter((c) => c.mastered).length} mastered`], '', back),
1703
1838
  card(null, null, segbar([
1704
1839
  { label:'Mastered', n:seen.filter((c) => c.mastered).length, color:'var(--spot)' },
1705
1840
  { label:'Learning', n:seen.filter((c) => !c.mastered).length, color:'color-mix(in srgb, var(--spot) 45%, var(--mass))' },
1706
1841
  { label:'Not asked', n:all.length - seen.length, color:'var(--mass)' },
1707
- ])),
1708
- card(null, null, table([{t:'Concept'},{t:'Tier'},{t:'Score'},{t:'Asked',n:1},{t:'State'},{t:'From your code'}], rows)
1709
- + pager('domain', list.length, p)),
1842
+ ]) + `<div style="margin-top:var(--space-5)">${table([{t:'Concept'},{t:'Score'},{t:'Asked',n:1},{t:'State'}], rows)}${pager('domain', list.length, p)}</div>`),
1710
1843
  ].join('');
1711
1844
  }
1712
1845
 
@@ -1728,24 +1861,72 @@ function memoryQuery(type) {
1728
1861
  return p.toString();
1729
1862
  }
1730
1863
 
1864
+ const dayName = (d) => d.toLocaleDateString(undefined, { weekday:'short', day:'numeric', month:'short' });
1865
+ const clock = (d) => d.toLocaleTimeString(undefined, { hour:'2-digit', minute:'2-digit' });
1866
+
1867
+ /** One entry on the timeline: a line that opens to its snippet and a link to the full entry. */
1868
+ function tlEntry(e) {
1869
+ const d = parse(e.occurred_at);
1870
+ const more = e.narrative_length > String(e.snippet ?? '').length;
1871
+ const gone = e.deleted_at || e.superseded_by;
1872
+ return `<li><details class="tl-item${gone ? ' gone' : ''}" data-entry="${esc(e.id)}">
1873
+ <summary><span class="tl-time">${d ? esc(clock(d)) : '—'}</span>
1874
+ <span class="tl-title">${esc(e.title)}</span><span class="tl-type">${esc(e.type ?? e.kind)}</span></summary>
1875
+ <div class="tl-body">
1876
+ ${e.snippet ? `<p>${esc(e.snippet)}${more ? '…' : ''}</p>` : ''}
1877
+ <div class="tl-meta"><span>${esc(pname(e.project))}</span><span>${plural(e.event_count, 'evidence event')}</span>
1878
+ ${entryMark(e)}<a class="link" data-go="${esc(MH('entry', e.id))}">Open entry →</a></div>
1879
+ </div></details></li>`;
1880
+ }
1881
+
1882
+ /**
1883
+ * The page's entries as a timeline: grouped by local day, and an entry that
1884
+ * shares a session with a session summary on this page folds under that
1885
+ * summary. Grouping is per page — a session whose summary sits on the next page
1886
+ * shows its observations as ordinary lines here — because the server pages
1887
+ * entries, not sessions.
1888
+ */
1731
1889
  function drawMemory(d) {
1732
1890
  T.memory.page = d.page; // the server clamps a page number a filter shrank
1733
- const rows = d.rows.map((e) => {
1734
- const gone = e.deleted_at || e.superseded_by;
1735
- const more = e.narrative_length > String(e.snippet ?? '').length;
1736
- return `<tr class="row${gone ? ' gone' : ''}" data-go="${esc(MH('entry', e.id))}">
1737
- <td class="slug" style="white-space:nowrap">${esc(stamp(e.occurred_at))}</td>
1738
- <td style="min-width:280px"><span class="title">${esc(e.title)}</span>
1739
- <div class="ctx">${esc(e.snippet ?? '')}${more ? '…' : ''}</div></td>
1740
- <td><span class="pill">${esc(e.type ?? e.kind)}</span></td>
1741
- <td style="min-width:120px"><div class="tags">${tagPills(e.tags)}</div></td>
1742
- <td class="slug">${esc(pname(e.project))}</td>
1743
- <td class="num">${e.event_count}</td>
1744
- <td>${entryMark(e)}</td>
1745
- </tr>`;
1746
- });
1747
- return table([{t:'When'},{t:'Observation'},{t:'Type'},{t:'Tags'},{t:'Project'},{t:'Evidence',n:1},{t:''}], rows)
1748
- + pager('memory', d.total, d.page, d.per);
1891
+ if (!d.rows.length) return '<p class="empty">Nothing in memory matches these filters.</p>';
1892
+ const summaries = new Map(d.rows.filter((e) => e.kind === 'session_summary' && e.session_id).map((e) => [e.session_id, e]));
1893
+ const kids = new Map();
1894
+ for (const e of d.rows) {
1895
+ if (e.kind === 'session_summary' || !summaries.has(e.session_id)) continue;
1896
+ if (!kids.has(e.session_id)) kids.set(e.session_id, []);
1897
+ kids.get(e.session_id).push(e);
1898
+ }
1899
+ const now = new Date();
1900
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
1901
+ const out = [];
1902
+ let last = null;
1903
+ for (const e of d.rows) {
1904
+ if (e.kind !== 'session_summary' && summaries.has(e.session_id)) continue; // drawn under its summary
1905
+ const at = parse(e.occurred_at);
1906
+ const day = at ? new Date(at.getFullYear(), at.getMonth(), at.getDate()) : null;
1907
+ if (day && (!last || day.getTime() !== last.getTime())) {
1908
+ if (last) {
1909
+ const gap = Math.round((last - day) / DAY_MS) - 1;
1910
+ const after = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1);
1911
+ const before = new Date(last.getFullYear(), last.getMonth(), last.getDate() - 1);
1912
+ if (gap === 1) out.push(`<li class="tl-gap">Nothing on ${esc(dayName(after))}</li>`);
1913
+ if (gap > 1) out.push(`<li class="tl-gap">Nothing from ${esc(dayName(after))} to ${esc(dayName(before))}</li>`);
1914
+ }
1915
+ const note = day.getTime() === today ? 'Today' : Math.round((today - day.getTime()) / DAY_MS) === 1 ? 'Yesterday' : '';
1916
+ out.push(`<li class="tl-day"><h2>${esc(dayName(day))}</h2>${note ? `<span>${note}</span>` : ''}</li>`);
1917
+ last = day;
1918
+ }
1919
+ const inner = kids.get(e.session_id);
1920
+ if (e.kind === 'session_summary' && inner) {
1921
+ out.push(`<li><details class="tl-item tl-session">
1922
+ <summary><span class="tl-time">${at ? esc(clock(at)) : '—'}</span>
1923
+ <span class="tl-title">${esc(e.title.replace(/^Session:\s*/, ''))}</span>
1924
+ <span class="tl-type">Session · ${plural(inner.length, 'observation')}</span></summary>
1925
+ <ol class="tl-kids">${inner.map(tlEntry).join('')}
1926
+ <li class="tl-gap"><a class="link" data-go="${esc(MH('entry', e.id))}">The session summary →</a></li></ol></details></li>`);
1927
+ } else out.push(tlEntry(e));
1928
+ }
1929
+ return `<ol class="tl" aria-label="Timeline">${out.join('')}</ol>` + pager('memory', d.total, d.page, d.per);
1749
1930
  }
1750
1931
 
1751
1932
  /**
@@ -1754,25 +1935,39 @@ function drawMemory(d) {
1754
1935
  * selector; indexing, reuse, delivery and assessment are only aggregated for
1755
1936
  * the whole database, and say so rather than pretend to be this project's.
1756
1937
  */
1757
- function memTiles() {
1938
+ function memCounts() {
1758
1939
  const M = S.memory;
1759
1940
  const scoped = PROJECT !== null;
1760
1941
  const inv = projectsInScope();
1761
1942
  const sum = (f) => inv.reduce((a, p) => a + f(p), 0);
1762
- const events = scoped ? sum((p) => p.memory.events) : M.captured;
1763
- const pending = scoped ? sum((p) => p.memory.pending) : M.pending;
1764
- const entries = scoped ? sum((p) => p.memory.entries) : M.entries;
1943
+ return {
1944
+ scoped,
1945
+ events: scoped ? sum((p) => p.memory.events) : M.captured,
1946
+ pending: scoped ? sum((p) => p.memory.pending) : M.pending,
1947
+ entries: scoped ? sum((p) => p.memory.entries) : M.entries,
1948
+ sessions: scoped ? sum((p) => p.memory.sessions) : INV.projects.reduce((a, p) => a + p.memory.sessions, 0),
1949
+ };
1950
+ }
1951
+
1952
+ function memTiles() {
1953
+ const M = S.memory;
1954
+ const { scoped, events, pending, entries, sessions } = memCounts();
1765
1955
  const everywhere = scoped ? 'all projects · ' : '';
1766
1956
  return tiles([
1767
- ['Observations', entries, scoped ? `live here · ${M.indexed} indexed across all projects`
1768
- : `${M.entries_total} written · ${M.superseded} superseded · ${M.deleted} deleted · ${M.indexed} indexed`, '', MH('timeline')],
1769
- ['Evidence', events, `${events - pending} distilled · ${pending} still pending`, pending ? 'warn' : '', MH('health')],
1770
- ['Sessions', scoped ? sum((p) => p.memory.sessions) : INV.projects.reduce((a, p) => a + p.memory.sessions, 0), 'that captured or remembered', '', MH('sessions')],
1771
- ['Reused', M.reused, `${everywhere}${M.exposed} actually delivered`, '', MH('reuse')],
1772
- ['Assessed', M.assessed, everywhere + 'evidence concepts you were asked about', '', LH('concepts')],
1957
+ ['Observations', entries, scoped ? 'live in this project' : `${M.indexed} indexed`, '', MH('timeline')],
1958
+ ['Sessions', sessions, 'captured or remembered', '', MH('sessions')],
1959
+ ['Pending', pending, pending ? `of ${events} evidence events` : `all ${events} distilled`, pending ? 'warn' : '', MH('health')],
1960
+ ['Reused', M.reused, `${everywhere}${M.exposed} delivered`, '', MH('reuse')],
1773
1961
  ]);
1774
1962
  }
1775
1963
 
1964
+ /** `YYYY-MM-DD` for n days before today, local — what a date input holds. */
1965
+ const daysAgo = (n) => {
1966
+ const d = new Date(); d.setDate(d.getDate() - n);
1967
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
1968
+ };
1969
+ const WHEN = { today:0, '7d':6, '30d':29 };
1970
+
1776
1971
  function viewMemory(param) {
1777
1972
  const t = T.memory;
1778
1973
  const M = S.memory;
@@ -1785,98 +1980,88 @@ function viewMemory(param) {
1785
1980
  t.until = CUR.query.get('to') || '';
1786
1981
  fill('mem-rows', '/api/memory?' + memoryQuery(type), drawMemory);
1787
1982
 
1788
- const chip = (k, label, n) =>
1789
- `<button class="chip" data-mtype="${esc(k)}" aria-pressed="${type === k}">${esc(label)}${n === undefined ? '' : ' ' + n}</button>`;
1790
- const typeChips = types.map((x) => chip(x.type, x.type, x.n)).join('')
1791
- + (type !== 'all' && !types.some((x) => x.type === type) ? chip(type, type, 0) : '');
1983
+ // A preset is only a `from` date, so the URL stays the one date format and a
1984
+ // shared link reads the same; anything else is a custom range.
1985
+ const when = !t.since && !t.until ? 'any'
1986
+ : !t.until && Object.keys(WHEN).find((k) => daysAgo(WHEN[k]) === t.since) || 'custom';
1987
+ const opt = (v, label, on) => `<option value="${esc(v)}"${on ? ' selected' : ''}>${esc(label)}</option>`;
1988
+ const typeOpts = opt('all', 'All types', type === 'all')
1989
+ + types.map((x) => opt(x.type, `${x.type} (${x.n})`, type === x.type)).join('')
1990
+ + (type !== 'all' && !types.some((x) => x.type === type) ? opt(type, `${type} (0)`, true) : '');
1991
+ const { entries, sessions, pending } = memCounts();
1792
1992
 
1793
1993
  return [
1794
- `<div>
1795
- <h1 class="page__title">Timeline</h1>
1796
- <p class="page__sub">What your sessions actually did, distilled into observations and kept with the raw
1797
- evidence behind each one. Corrections supersede rather than overwrite and deletions are marked, so what
1798
- you see is the audit trail and not a tidied version of it.</p>
1799
- </div>`,
1994
+ pageHead('Timeline', [plural(entries, 'observation'), plural(sessions, 'session'),
1995
+ ...(pending ? [`<a class="link warn" data-go="${esc(MH('health'))}">${pending} evidence pending</a>`] : [])],
1996
+ `What your sessions actually did, distilled into observations and kept with the raw evidence behind each
1997
+ one. Corrections supersede rather than overwrite and deletions are marked and struck through, so what
1998
+ you see is the audit trail and not a tidied version of it.`),
1800
1999
 
1801
- memTiles(),
1802
-
1803
- card(null, null,
1804
- `<div class="filters">
1805
- <div class="grow"><input type="search" id="mq" value="${esc(t.q)}" placeholder="Search titles, narrative, facts and files…" aria-label="Search memory"></div>
2000
+ `<div>
2001
+ <div class="filters">
2002
+ <div class="grow"><input type="search" id="mq" value="${esc(t.q)}" placeholder="Search memory" aria-label="Search memory"></div>
2003
+ <select id="mtype" aria-label="Type">${typeOpts}</select>
1806
2004
  <select id="mtag" aria-label="Tag">
1807
2005
  <option value="all">All tags</option>
1808
- ${(M.tags ?? []).map((x) => `<option value="${esc(x.tag)}"${t.tag === x.tag ? ' selected' : ''}>${esc(x.tag)} (${x.n})</option>`).join('')}
2006
+ ${(M.tags ?? []).map((x) => opt(x.tag, `${x.tag} (${x.n})`, t.tag === x.tag)).join('')}
1809
2007
  </select>
1810
- <input type="date" id="mfrom" value="${esc(t.since)}" aria-label="From date">
1811
- <input type="date" id="mto" value="${esc(t.until)}" aria-label="To date">
1812
- </div>
1813
- <div class="chips" id="mtypes">
1814
- ${chip('all', 'All')}${typeChips}
2008
+ <select id="mwhen" aria-label="When">
2009
+ ${opt('any', 'Any time', when === 'any')}${opt('today', 'Today', when === 'today')}
2010
+ ${opt('7d', 'Last 7 days', when === '7d')}${opt('30d', 'Last 30 days', when === '30d')}
2011
+ ${opt('custom', 'Custom range', when === 'custom')}
2012
+ </select>
2013
+ ${when === 'custom' ? `<input type="date" id="mfrom" value="${esc(t.since)}" aria-label="From date">
2014
+ <input type="date" id="mto" value="${esc(t.until)}" aria-label="To date">` : ''}
1815
2015
  </div>
1816
- <div style="margin-top:var(--space-4)" id="mem-rows"><p class="empty">Loading the timeline…</p></div>`),
2016
+ <div id="mem-rows"><p class="empty">Loading the timeline…</p></div>
2017
+ </div>`,
1817
2018
  ].join('');
1818
2019
  }
1819
2020
 
1820
2021
  function viewMemoryDashboard() {
1821
- const R = S.reuse, H = S.health, Q = H.queue;
2022
+ const R = S.reuse, H = S.health, Q = H.queue, M = S.memory;
1822
2023
  const scope = () => memScope(new URLSearchParams());
1823
2024
  const q6 = scope(); q6.set('per', '6');
1824
- fill('mem-recent', '/api/memory?' + q6, (d) => d.rows.length
1825
- ? table([{t:'When'},{t:'Observation'},{t:'Type'},{t:'Project'}], d.rows.map((e) => `
1826
- <tr class="row${e.deleted_at || e.superseded_by ? ' gone' : ''}" data-go="${esc(MH('entry', e.id))}">
1827
- <td class="slug" style="white-space:nowrap">${esc(stamp(e.occurred_at))}</td>
1828
- <td style="min-width:240px"><span class="title">${esc(e.title)}</span></td>
1829
- <td><span class="pill">${esc(e.type ?? e.kind)}</span></td>
1830
- <td class="slug">${esc(pname(e.project))}</td></tr>`))
1831
- : '<p class="empty">Nothing remembered in this scope yet.</p>');
2025
+ fill('mem-recent', '/api/memory?' + q6, (d) => lines(d.rows.map((e) => ({
2026
+ t: `<span${e.deleted_at || e.superseded_by ? ' class="gone-title"' : ''}>${esc(e.title)}</span>`,
2027
+ s: `${esc(stamp(e.occurred_at))} · ${esc(e.type ?? e.kind)} · ${esc(pname(e.project))}`,
2028
+ v: entryMark(e),
2029
+ go: MH('entry', e.id),
2030
+ })), 'Nothing remembered in this scope yet.'));
1832
2031
  const q5 = scope(); q5.set('per', '5');
1833
- fill('mem-sessions', '/api/memory/sessions?' + q5, (d) => d.rows.length
1834
- ? table([{t:'Session'},{t:'Captured',n:1},{t:'Remembered',n:1},{t:'When',n:1}], d.rows.map((m) => `
1835
- <tr class="row" data-go="${esc(MH('session', m.session_id))}">
1836
- <td><span class="title">${esc(pname(m.project, m.session_id))}</span><div class="slug">${esc(m.session_id)}</div></td>
1837
- <td class="num">${m.events}</td><td class="num">${m.entries}</td>
1838
- <td class="num slug">${esc(ago(m.last))}</td></tr>`))
1839
- : '<p class="empty">No session has captured anything in this scope.</p>');
2032
+ fill('mem-sessions', '/api/memory/sessions?' + q5, (d) => lines(d.rows.map((m) => ({
2033
+ t: esc(pname(m.project, m.session_id)),
2034
+ s: `${m.events} captured · ${m.entries} remembered`,
2035
+ v: esc(ago(m.last)),
2036
+ go: MH('session', m.session_id),
2037
+ })), 'No session has captured anything in this scope.'));
2038
+
2039
+ // The pipeline in two lines: is reuse paying for itself, and is capture alive.
2040
+ // Each opens its page; the arithmetic lives there.
2041
+ const verdict = R.savings.kind === 'saving' ? `${R.savings.percent}% less context`
2042
+ : R.savings.kind === 'overhead' ? `+${R.savings.tokens} tokens of overhead` : 'no confirmed receipts yet';
2043
+ const stuck = Q.failed + Q.paused;
2044
+ const pipeline = lines([
2045
+ { t: 'Context reuse', s: `${plural(R.receipts, 'receipt')}, ${R.confirmed} confirmed · estimated, all projects`,
2046
+ v: esc(verdict), go: MH('reuse') },
2047
+ { t: 'Health', s: `last capture ${esc(H.capture.newest_event ? ago(H.capture.newest_event) : 'never')} · capture ${H.capture.enabled ? esc(H.capture.mode) : 'off'} · ${Q.pending} queued`,
2048
+ v: stuck ? `<span style="color:var(--warning)">${stuck} stalled</span>` : 'running', go: MH('health') },
2049
+ ]);
1840
2050
 
1841
2051
  return [
1842
- `<div>
1843
- <h1 class="page__title">What does memory hold?</h1>
1844
- <p class="page__sub">Captured, processed, stored, retrieved, delivered, assessed — six different things that
1845
- can happen to one piece of evidence, counted separately. None of them is an answer: only a question you
1846
- answered moves mastery.</p>
1847
- </div>`,
2052
+ pageHead('What does memory hold?', [
2053
+ `${M.processed} distilled`, `${M.indexed} indexed`, `${M.reused} reused`, `${M.exposed} delivered`, `${M.assessed} assessed`,
2054
+ ], `Captured, processed, stored, retrieved, delivered, assessed — six different things that can happen to one
2055
+ piece of evidence, counted separately. None of them is an answer: only a question you answered moves
2056
+ mastery. Reuse, delivery and assessment are counted across all projects.`),
1848
2057
  memTiles(),
2058
+ card('Pipeline', null, pipeline),
1849
2059
  `<div class="g2">
1850
- ${card('Context reuse', 'All projects. B is what the same material would have cost read from source; D is what was delivered instead.',
1851
- `<div class="kv">
1852
- <div><b>B — from source</b><span>${R.base}</span></div>
1853
- <div><b>D — delivered</b><span>${R.delivered}</span></div>
1854
- <div><b>Net</b><span>${R.base - R.delivered}</span></div>
1855
- <div><b>Verdict</b><span>${R.savings.kind === 'saving' ? R.savings.percent + '%'
1856
- : R.savings.kind === 'overhead' ? '+' + R.savings.tokens + ' tokens' : '—'}</span></div>
1857
- </div>
1858
- <p style="margin:var(--space-4) 0 0;color:var(--faint);font-size:13.5px">${esc(savingsText(R.savings, R.receipts))}
1859
- Estimated with <code class="slug">${esc(R.estimator)}</code> on both sides of the subtraction —
1860
- a reduction in context volume, not a bill.
1861
- <a class="link" data-go="${esc(MH('reuse'))}">Every receipt →</a></p>`)}
1862
- ${card('Health', 'All projects. Where the pipeline stops, when it stops.',
1863
- `<div class="kv">
1864
- <div><b>Last capture</b><span>${esc(H.capture.newest_event ? ago(H.capture.newest_event) : 'never')}</span></div>
1865
- <div><b>Capture</b><span>${H.capture.enabled ? esc(H.capture.mode) : 'off'}</span></div>
1866
- <div><b>Queued</b><span>${Q.pending}</span></div>
1867
- <div><b>Failed</b><span>${Q.failed}</span></div>
1868
- </div>
1869
- <p style="margin:var(--space-4) 0 0;color:var(--faint);font-size:13.5px">
1870
- ${S.memory.processed} distilled · ${S.memory.indexed} indexed · ${S.memory.reused} reused ·
1871
- ${S.memory.exposed} delivered · ${S.memory.assessed} assessed.
1872
- <a class="link" data-go="${esc(MH('health'))}">Health →</a></p>`)}
2060
+ ${card('Recent observations', null, `<div id="mem-recent"><p class="empty">Loading…</p></div>`,
2061
+ { aside: `<a class="link" data-go="${esc(MH('timeline'))}">Timeline →</a>` })}
2062
+ ${card('Recent sessions', null, `<div id="mem-sessions"><p class="empty">Loading…</p></div>`,
2063
+ { aside: `<a class="link" data-go="${esc(MH('sessions'))}">All sessions →</a>` })}
1873
2064
  </div>`,
1874
- card('Recent observations', 'The newest things memory kept, in this scope.',
1875
- `<div id="mem-recent"><p class="empty">Loading…</p></div>`,
1876
- { aside: `<a class="link" data-go="${esc(MH('timeline'))}">The timeline →</a>` }),
1877
- card('Recent sessions', 'Sessions that captured evidence or wrote an observation.',
1878
- `<div id="mem-sessions"><p class="empty">Loading…</p></div>`,
1879
- { aside: `<a class="link" data-go="${esc(MH('sessions'))}">All sessions →</a>` }),
1880
2065
  ].join('');
1881
2066
  }
1882
2067
 
@@ -1888,28 +2073,25 @@ function viewMemorySessions() {
1888
2073
  const learned = new Map(sessions().map((x) => [x.id, x]));
1889
2074
  fill('msess-rows', '/api/memory/sessions?' + q, (d) => {
1890
2075
  t.page = d.page;
1891
- return table([{t:'Session'},{t:'Captured',n:1},{t:'Pending',n:1},{t:'Remembered',n:1},{t:'Proposed',n:1},{t:'Asked',n:1},{t:'When',n:1}],
2076
+ return `<p class="counts" style="margin:0 0 var(--space-4)">${plural(d.total, 'session')} in scope</p>`
2077
+ + table([{t:'Session'},{t:'Remembered',n:1},{t:'Asked',n:1},{t:'When',n:1}],
1892
2078
  d.rows.map((m) => {
1893
2079
  const l = learned.get(m.session_id);
2080
+ const sub = [`${m.events} captured`, m.pending ? `${m.pending} pending` : '', m.candidates ? `${m.candidates} proposed` : '']
2081
+ .filter(Boolean).join(' · ');
1894
2082
  return `<tr class="row" data-go="${esc(MH('session', m.session_id))}">
1895
2083
  <td style="min-width:240px"><span class="title">${esc(pname(m.project, m.session_id))}</span>
1896
- <div class="slug">${esc(m.session_id)}</div></td>
1897
- <td class="num">${m.events}</td>
1898
- <td class="num">${m.pending || '—'}</td>
2084
+ <div class="slug">${sub}</div></td>
1899
2085
  <td class="num">${m.entries}</td>
1900
- <td class="num">${m.candidates || '—'}</td>
1901
2086
  <td class="num">${l && l.asked.length ? l.asked.length : '—'}</td>
1902
- <td class="num slug">${esc(stamp(m.last))}</td></tr>`;
1903
- })) + pager('msessions', d.total, d.page, d.per)
1904
- + `<p class="slug" style="margin:var(--space-4) 0 0">${plural(d.total, 'session')} in scope.</p>`;
2087
+ <td class="num slug">${esc(ago(m.last))}</td></tr>`;
2088
+ })) + pager('msessions', d.total, d.page, d.per);
1905
2089
  });
1906
2090
  return [
1907
- `<div>
1908
- <h1 class="page__title">Sessions</h1>
1909
- <p class="page__sub">One row per session that captured evidence or wrote an observation. Pending is evidence
1910
- no observation job has processed yet. Asked is the same session's questions, joined on its session id —
1911
- a session can be remembered without ever being assessed.</p>
1912
- </div>`,
2091
+ pageHead('Sessions', [],
2092
+ `One row per session that captured evidence or wrote an observation. Pending is evidence no observation
2093
+ job has processed yet. Asked is the same session's questions, joined on its session id — a session can
2094
+ be remembered without ever being assessed.`),
1913
2095
  card(null, null, `<div id="msess-rows"><p class="empty">Loading sessions…</p></div>`),
1914
2096
  ].join('');
1915
2097
  }
@@ -1918,29 +2100,25 @@ function viewMemoryProjects() {
1918
2100
  const inv = projectsInScope();
1919
2101
  const list = inv.filter((p) => p.memory.events || p.memory.entries || p.memory.receipts);
1920
2102
  const none = inv.length - list.length;
1921
- const rows = list.map((p) => `
1922
- <tr class="row" data-go="${esc(MH('timeline', null, { project: p.id }))}">
1923
- <td style="min-width:220px"><span class="title">${esc(p.name)}</span><div class="slug">${esc(whereLine(p))}</div></td>
1924
- <td class="num">${p.memory.events}</td>
1925
- <td class="num">${p.memory.pending || '—'}</td>
2103
+ const rows = list.map((p) => {
2104
+ const sub = [`${p.memory.events} captured`, p.memory.pending ? `${p.memory.pending} pending` : '',
2105
+ p.memory.receipts ? `${p.memory.receipts} receipts` : ''].filter(Boolean).join(' · ');
2106
+ return `<tr class="row" data-go="${esc(MH('timeline', null, { project: p.id }))}">
2107
+ <td style="min-width:220px"><span class="title">${esc(p.name)}</span><div class="slug">${sub}</div></td>
1926
2108
  <td class="num">${p.memory.entries}</td>
1927
2109
  <td class="num">${p.memory.sessions}</td>
1928
- <td class="num">${p.memory.receipts || '—'}</td>
1929
- <td class="num slug">${esc(ago(p.memory.last))}</td></tr>`);
2110
+ <td class="num slug">${esc(ago(p.memory.last))}</td></tr>`;
2111
+ });
2112
+ const pending = list.reduce((a, p) => a + p.memory.pending, 0);
1930
2113
  return [
1931
- `<div>
1932
- <h1 class="page__title">Projects</h1>
1933
- <p class="page__sub">Every project memory has recorded anything for — including evidence that has been
1934
- captured and not yet processed into an observation. Open one to read its timeline.</p>
1935
- </div>`,
1936
- tiles([
1937
- ['Projects', list.length, 'with captured or stored memory'],
1938
- ['Evidence', list.reduce((a, p) => a + p.memory.events, 0), 'events captured'],
1939
- ['Pending', list.reduce((a, p) => a + p.memory.pending, 0), 'waiting to be processed'],
1940
- ['Observations', list.reduce((a, p) => a + p.memory.entries, 0), 'live entries'],
1941
- ]),
2114
+ pageHead('Projects', [
2115
+ plural(list.length, 'project'), plural(list.reduce((a, p) => a + p.memory.entries, 0), 'observation'),
2116
+ `${list.reduce((a, p) => a + p.memory.events, 0)} evidence events`,
2117
+ ...(pending ? [`<span class="warn">${pending} pending</span>`] : []),
2118
+ ], `Every project memory has recorded anything for — including evidence that has been captured and not yet
2119
+ processed into an observation. Open one to read its timeline.`),
1942
2120
  card(null, null,
1943
- table([{t:'Project'},{t:'Captured',n:1},{t:'Pending',n:1},{t:'Observations',n:1},{t:'Sessions',n:1},{t:'Receipts',n:1},{t:'Last capture',n:1}], rows)
2121
+ table([{t:'Project'},{t:'Observations',n:1},{t:'Sessions',n:1},{t:'Last capture',n:1}], rows)
1944
2122
  + (none ? `<p class="slug" style="margin:var(--space-4) 0 0">${plural(none, 'project')} in scope ${none === 1 ? 'has' : 'have'} learning activity and no memory —
1945
2123
  <a class="link" data-go="${esc(LH('projects'))}">see Learning → Projects</a>.</p>` : '')),
1946
2124
  ].join('');
@@ -1952,7 +2130,6 @@ function drawEntry(d) {
1952
2130
  const facts = jsonList(e.facts);
1953
2131
  const files = jsonList(e.files);
1954
2132
  const link = (x) => `<a class="link" data-go="${esc(MH('entry', x.id))}">${esc(x.title)}</a> <span class="slug">${esc(fmtDate(x.occurred_at))}</span>`;
1955
-
1956
2133
  const evidence = d.events.map((ev) => `
1957
2134
  <details class="qa">
1958
2135
  <summary class="qa__meta">
@@ -1964,63 +2141,53 @@ function drawEntry(d) {
1964
2141
  <pre class="raw">${esc(ev.body)}${ev.body_length > String(ev.body ?? '').length ? '\n… truncated' : ''}</pre>
1965
2142
  ${jsonList(ev.files).length ? `<div class="tags" style="margin-top:var(--space-3)">${jsonList(ev.files).map((f) => `<span class="pill">${esc(f)}</span>`).join('')}</div>` : ''}
1966
2143
  </details>`).join('');
1967
-
1968
2144
  return [
1969
2145
  `<div>
1970
2146
  <p class="page__eyebrow">${esc(e.type ?? e.kind)}</p>
1971
2147
  <h1 class="page__title${gone ? ' gone-title' : ''}">${esc(e.title)}</h1>
1972
- <p class="page__sub">${esc(stamp(e.occurred_at))} · ${esc(pname(e.project))} ·
1973
- written by <code class="slug">${esc(e.generator)}</code>
1974
- ${e.import_source ? ` · imported from ${esc(e.import_source)}` : ''}
1975
- ${e.session_id ? ` · <a class="link" data-go="${esc(sessHref(e.session_id))}">that session</a>` : ''}</p>
2148
+ <p class="counts">${esc(stamp(e.occurred_at))} · ${esc(pname(e.project))}
2149
+ ${e.session_id ? ` · <a class="link" data-go="${esc(sessHref(e.session_id))}">that session</a>` : ''}
2150
+ · ${plural(d.events.length, 'evidence event')}
2151
+ ${e.confidence == null ? '' : ` · ${Math.round(e.confidence * 100)}% summariser confidence`}
2152
+ · ${d.receipts.length ? `reused ${plural(d.receipts.length, 'time')}` : 'never reused'}</p>
1976
2153
  </div>`,
1977
-
1978
2154
  e.deleted_at
1979
2155
  ? `<div class="banner"><p><strong>Deleted ${esc(ago(e.deleted_at))}.</strong> It is kept here so the record
1980
2156
  stays checkable, and it is excluded from search and recall.</p></div>` : '',
1981
2157
  d.replaced_by
1982
2158
  ? `<div class="banner"><p><strong>Superseded.</strong> A later observation corrected this one:
1983
2159
  ${link(d.replaced_by)}. Recall prefers the replacement; this row is the audit trail.</p></div>` : '',
1984
-
1985
- tiles([
1986
- ['Confidence', e.confidence == null ? '—' : Math.round(e.confidence * 100) + '%', 'the summariser’s own, not a score'],
1987
- ['Evidence', d.events.length, 'raw events behind this claim'],
1988
- ['Candidates', d.candidates.length, 'concepts it proposed'],
1989
- ['Reused', d.receipts.length, d.receipts.length ? 'times it was selected for context' : 'never selected yet', '', MH('reuse')],
1990
- ]),
1991
-
1992
- card('What happened', 'The observation, as written.',
1993
- `<p class="qa__q">${esc(e.narrative) || '<span class="empty">No narrative.</span>'}</p>
2160
+ card('What happened', null,
2161
+ `<p class="qa__q" style="margin:0">${esc(e.narrative) || '<span class="empty">No narrative.</span>'}</p>
1994
2162
  ${facts.length ? `<ul class="facts">${facts.map((f) => `<li>${esc(f)}</li>`).join('')}</ul>` : ''}
1995
2163
  ${files.length ? `<div class="tags" style="margin-top:var(--space-4)">${files.map((f) => `<span class="pill">${esc(f)}</span>`).join('')}</div>` : ''}
1996
- ${d.tags.length ? `<div class="tags" style="margin-top:var(--space-3)">${tagPills(d.tags)}</div>` : ''}`),
1997
-
1998
- d.candidates.length
1999
- ? card('Concepts it proposed', 'A candidate is a proposal, not a concept — and never a passing answer. Only a question you answered moves mastery.',
2000
- table([{t:'Slug'},{t:'Domain'},{t:'Confidence',n:1},{t:'Status'}], d.candidates.map((c) => {
2001
- const go = c.concept_slug ? ` class="row" data-go="${esc(LH('concept', c.concept_slug))}"` : '';
2002
- return `<tr${go}><td><span class="title">${esc(c.name ?? c.slug)}</span><div class="slug">${esc(c.slug)}</div></td>
2003
- <td><span class="pill">${esc(c.domain ?? '—')}</span></td>
2004
- <td class="num">${Math.round((c.confidence ?? 0) * 100)}%</td>
2005
- <td><span class="pill${c.status === 'accepted' ? ' ok' : ''}">${esc(c.status)}</span></td></tr>`;
2006
- })))
2007
- : '',
2008
-
2164
+ ${d.tags.length ? `<div class="tags" style="margin-top:var(--space-3)">${tagPills(d.tags)}</div>` : ''}
2165
+ <p class="slug" style="margin:var(--space-4) 0 0">Written by <code>${esc(e.generator)}</code>${e.import_source ? ` · imported from ${esc(e.import_source)}` : ''}.</p>`),
2009
2166
  d.replaces.length
2010
2167
  ? card('What this replaced', 'Earlier claims this observation corrected.',
2011
2168
  `<div class="list">${d.replaces.map((x) => `<div>${link(x)}</div>`).join('')}</div>`)
2012
2169
  : '',
2013
-
2014
- card('The raw evidence', 'Every event this was distilled from, exactly as captured. Open one to read it.',
2015
- evidence || '<p class="empty">No evidence is linked — this entry was written by hand or imported.</p>'),
2016
-
2170
+ fold('entry:evidence', 'The raw evidence', plural(d.events.length, 'event'),
2171
+ evidence || '<p class="empty">No evidence is linked — this entry was written by hand or imported.</p>',
2172
+ { hint: 'Every event this was distilled from, exactly as captured. Open one to read it.' }),
2173
+ d.candidates.length
2174
+ ? fold('entry:cands', 'Concepts it proposed', plural(d.candidates.length, 'candidate'),
2175
+ lines(d.candidates.map((c) => ({
2176
+ t: esc(c.name ?? c.slug),
2177
+ s: `${esc(c.slug)} · ${esc(c.domain ?? '—')} · ${Math.round((c.confidence ?? 0) * 100)}% confidence`,
2178
+ v: `<span class="pill${c.status === 'accepted' ? ' ok' : ''}">${esc(c.status)}</span>`,
2179
+ go: c.concept_slug ? LH('concept', c.concept_slug) : null,
2180
+ }))),
2181
+ { hint: 'A candidate is a proposal, not a concept — and never a passing answer. Only a question you answered moves mastery.' })
2182
+ : '',
2017
2183
  d.receipts.length
2018
- ? card('Where it was reused', 'Each time this entry was selected for a context injection.',
2019
- table([{t:'When'},{t:'Scope'},{t:'Stage'},{t:'From source',n:1},{t:'Sent',n:1},{t:'Delivery'}],
2184
+ ? fold('entry:receipts', 'Where it was reused', plural(d.receipts.length, 'receipt'),
2185
+ table([{t:'When'},{t:'Scope'},{t:'From source',n:1},{t:'Sent',n:1},{t:'Delivery'}],
2020
2186
  d.receipts.map((r) => `<tr><td class="slug">${esc(stamp(r.created_at))}</td>
2021
- <td>${esc(r.scope)}</td><td class="slug">${esc(r.stage)}</td>
2187
+ <td>${esc(r.scope)}<div class="slug">${esc(r.stage)}</div></td>
2022
2188
  <td class="num">${r.source_tokens}</td><td class="num">${r.sent_tokens}</td>
2023
- <td>${deliveryPill(r.delivery)}</td></tr>`)))
2189
+ <td>${deliveryPill(r.delivery)}</td></tr>`)),
2190
+ { hint: 'Each time this entry was selected for a context injection.' })
2024
2191
  : '',
2025
2192
  ].join('');
2026
2193
  }
@@ -2041,7 +2208,6 @@ function viewReuse() {
2041
2208
  const net = R.base - R.delivered;
2042
2209
  const verdict = R.savings.kind === 'saving' ? R.savings.percent + '%'
2043
2210
  : R.savings.kind === 'overhead' ? '+' + R.savings.tokens : '—';
2044
-
2045
2211
  const body = slice(rows, p).map((r) => {
2046
2212
  // A saving is only a saving when the delivery was confirmed. An unknown or
2047
2213
  // prepared receipt has a B and a D but no verdict, and printing one would
@@ -2049,42 +2215,32 @@ function viewReuse() {
2049
2215
  const saved = r.delivery === 'confirmed' && r.base_tokens > 0
2050
2216
  ? Math.round((100 * (r.base_tokens - r.delivered_tokens)) / r.base_tokens) + '%'
2051
2217
  : '—';
2218
+ const sub = [esc(pname(r.project)), plural(r.item_count, 'item'), r.detail_tokens ? `+${r.detail_tokens} detail` : '', esc(r.method)]
2219
+ .filter(Boolean).join(' · ');
2052
2220
  return `<tr>
2053
2221
  <td class="slug" style="white-space:nowrap">${esc(stamp(r.created_at))}</td>
2054
- <td><span class="title">${esc(r.scope)}</span><div class="slug">${esc(pname(r.project))}</div></td>
2222
+ <td style="min-width:200px"><span class="title">${esc(r.scope)}</span><div class="slug">${sub}</div></td>
2055
2223
  <td class="num">${r.base_tokens}</td>
2056
2224
  <td class="num">${r.delivered_tokens}</td>
2057
2225
  <td class="num">${saved}</td>
2058
- <td class="num">${r.item_count}</td>
2059
- <td class="num">${r.detail_tokens ? '+' + r.detail_tokens : '—'}</td>
2060
2226
  <td>${deliveryPill(r.delivery)}</td>
2061
- <td class="slug">${esc(r.method)}</td>
2062
2227
  </tr>`;
2063
2228
  });
2064
-
2065
2229
  return [
2066
- `<div>
2067
- <h1 class="page__title">Context reuse</h1>
2068
- <p class="page__sub"><strong>B</strong> is what the same material would have cost to read from source.
2069
- <strong>D</strong> is what was actually delivered instead. Both are counted with the same estimator
2070
- (<code class="slug">${esc(R.estimator)}</code>), and the percentage is an estimate of how much
2071
- <em>context volume</em> was avoided — not a bill, not a price, and not a measure of model spend.</p>
2072
- </div>`,
2073
-
2230
+ pageHead('Context reuse', [
2231
+ `${R.confirmed} of ${plural(R.receipts, 'receipt')} confirmed`, `net ${net} tokens`, `estimated with <code class="slug">${esc(R.estimator)}</code>`,
2232
+ ], `<strong>B</strong> is what the same material would have cost to read from source. <strong>D</strong> is
2233
+ what was actually delivered instead. Both are counted with the same estimator, and the percentage is an
2234
+ estimate of how much <em>context volume</em> was avoided — not a bill, not a price, and not a measure of
2235
+ model spend. Receipts whose delivery is <code>unknown</code> or <code>prepared</code> are listed in the
2236
+ ledger but never counted in the verdict: an injection that may not have reached the model is not a saving.`),
2074
2237
  tiles([
2238
+ ['Verdict', verdict, savingsText(R.savings, R.receipts), R.savings.kind === 'saving' ? 'good' : R.savings.kind === 'overhead' ? 'warn' : ''],
2075
2239
  ['B — from source', R.base, 'estimated tokens, confirmed receipts'],
2076
2240
  ['D — delivered', R.delivered, 'estimated tokens actually injected'],
2077
- ['Net', net, net >= 0 ? 'fewer tokens of context' : 'more tokens than reading source', net < 0 ? 'warn' : ''],
2078
- ['Verdict', verdict, 'estimated, all projects', R.savings.kind === 'saving' ? 'good' : R.savings.kind === 'overhead' ? 'warn' : ''],
2079
- ['Receipts', R.receipts, `${R.confirmed} confirmed of ${R.receipts}`],
2080
2241
  ]),
2081
-
2082
- `<div class="banner"><p>${esc(savingsText(R.savings, R.receipts))}
2083
- Receipts whose delivery is <code>unknown</code> or <code>prepared</code> are counted in the ledger below
2084
- but never in the verdict — an injection that may not have reached the model is not a saving.</p></div>`,
2085
-
2086
2242
  card('Every receipt', `${plural(rows.length, 'receipt')} in scope. One row per injection.`,
2087
- table([{t:'When'},{t:'Scope'},{t:'B',n:1},{t:'D',n:1},{t:'Saved',n:1},{t:'Items',n:1},{t:'Detail fetch',n:1},{t:'Delivery'},{t:'Method'}], body)
2243
+ table([{t:'When'},{t:'Scope'},{t:'B',n:1},{t:'D',n:1},{t:'Saved',n:1},{t:'Delivery'}], body)
2088
2244
  + pager('reuse', rows.length, p)
2089
2245
  + (R.receipts > R.receipts_shown
2090
2246
  ? `<p class="slug" style="margin:var(--space-4) 0 0">Showing the most recent ${R.receipts_shown} of ${R.receipts} receipts. The totals above cover all of them.</p>`
@@ -2105,61 +2261,63 @@ function viewHealth() {
2105
2261
  ['Assessed', M.assessed, 'concepts you were actually asked about'],
2106
2262
  ];
2107
2263
  const oldest = Q.oldest ? ago(Q.oldest) : '—';
2108
-
2264
+ // What needs the reader, said once, in words: the tiles below are the numbers.
2265
+ const problems = [
2266
+ !H.capture.enabled ? 'memory is disabled' : !H.capture.newest_event ? 'nothing has been captured yet' : '',
2267
+ Q.failed ? `${plural(Q.failed, 'job')} failed after retries` : '',
2268
+ Q.paused ? `${plural(Q.paused, 'job')} paused, waiting on a decision` : '',
2269
+ H.spool_dropped ? `${H.spool_dropped} events dropped by the spool` : '',
2270
+ ].filter(Boolean);
2109
2271
  return [
2110
- `<div>
2111
- <h1 class="page__title">Health</h1>
2112
- <p class="page__sub">Where memory stops, when it stops. Capture, processing, indexing, retrieval and
2113
- delivery fail in different ways and this page keeps them apart. Error classes only — no log dumps,
2114
- no keys, no raw request bodies.</p>
2115
- </div>`,
2116
-
2272
+ pageHead('Health', [
2273
+ `last capture ${esc(H.capture.newest_event ? ago(H.capture.newest_event) : 'never')}`,
2274
+ H.capture.enabled ? `capture is ${esc(H.capture.mode)}` : 'memory off',
2275
+ `${Q.pending} queued${Q.pending ? `, oldest ${esc(oldest)}` : ''}`,
2276
+ ], `Where memory stops, when it stops. Capture, processing, indexing, retrieval and delivery fail in
2277
+ different ways and this page keeps them apart. Error classes only — no log dumps, no keys, no raw
2278
+ request bodies. Transient failures retry on their own; auth, quota and missing need you.`),
2279
+ problems.length
2280
+ ? nextStep(`<strong>Needs a look:</strong> ${problems.map(esc).join(' · ')}.`)
2281
+ : nextStep('<strong>Capture is running.</strong> No job has failed or paused.', true),
2117
2282
  tiles([
2118
- ['Last capture', H.capture.newest_event ? ago(H.capture.newest_event) : 'never',
2119
- H.capture.enabled ? `capture is ${H.capture.mode}` : 'memory is disabled',
2120
- H.capture.enabled && H.capture.newest_event ? '' : 'warn'],
2121
2283
  ['Queued', Q.pending, Q.pending ? `oldest ${oldest}` : 'nothing waiting', Q.pending ? 'warn' : 'good'],
2122
2284
  ['Paused', Q.paused, 'waiting on a decision', Q.paused ? 'warn' : ''],
2123
2285
  ['Failed', Q.failed, 'gave up after retries', Q.failed ? 'warn' : ''],
2124
- ['Dropped', H.spool_dropped, 'events the spool could not keep', H.spool_dropped ? 'warn' : ''],
2286
+ ['Dropped', H.spool_dropped, 'events the spool lost', H.spool_dropped ? 'warn' : ''],
2125
2287
  ]),
2126
-
2288
+ H.stalled.length
2289
+ ? card('Stalled jobs', 'Grouped by why they stopped.',
2290
+ table([{t:'Status'},{t:'Why'},{t:'Jobs',n:1},{t:'Attempts',n:1},{t:'Last',n:1}],
2291
+ H.stalled.map((j) => `<tr>
2292
+ <td><span class="pill due">${esc(j.status)}</span></td>
2293
+ <td><code class="slug">${esc(j.error_class)}</code></td>
2294
+ <td class="num">${j.n}</td><td class="num">${j.attempts}</td>
2295
+ <td class="num slug">${esc(ago(j.last))}</td></tr>`)))
2296
+ : '',
2127
2297
  card('Captured is not assessed', 'Each step is a place the pipeline can stop. A number that falls off between two rows is where to look.',
2128
- `<div class="checks">` + ladder.map(([label, n, note]) => `
2298
+ `<div class="checks" style="margin-top:0">` + ladder.map(([label, n, note]) => `
2129
2299
  <div><span class="${n ? 'mark-ok' : 'mark-no'}">${n ? '✓' : '○'}</span>
2130
2300
  <span style="min-width:110px">${esc(label)}</span>
2131
2301
  <span class="bar">${meter(pct(n, ladder[0][1] || 1), n ? '' : 'mute')}</span>
2132
2302
  <span class="slug" style="white-space:nowrap">${n} — ${esc(note)}</span></div>`).join('') + `</div>`),
2133
-
2134
- `<div class="g2">
2135
- ${card('Capture', 'What the hooks are allowed to record, and how long it is kept.',
2136
- `<div class="kv">
2137
- <div><b>Memory</b><span>${H.capture.enabled ? 'on' : 'off'}</span></div>
2138
- <div><b>Capture mode</b><span>${esc(H.capture.mode)}</span></div>
2139
- <div><b>Newest event</b><span>${esc(H.capture.newest_event ? stamp(H.capture.newest_event) : 'never')}</span></div>
2140
- <div><b>Redacted</b><span>${M.redacted}</span></div>
2141
- <div><b>Retention</b><span>${H.capture.retention_days ? plural(H.capture.retention_days, 'day') : 'kept until deleted'}</span></div>
2142
- <div><b>Last prune</b><span>${esc(H.capture.pruned_at ? ago(H.capture.pruned_at) : 'never')}</span></div>
2143
- </div>`)}
2144
- ${card('Providers', 'Nothing leaves this machine unless a provider is configured here.',
2145
- `<div class="kv">
2146
- <div><b>Observer</b><span>${H.providers.observer ? esc(H.providers.observer.kind + ' · ' + H.providers.observer.model) : 'local summariser'}</span></div>
2147
- <div><b>Embeddings</b><span>${H.providers.embeddings ? esc(H.providers.embeddings.kind + ' · ' + H.providers.embeddings.model) : 'local embedder'}</span></div>
2148
- <div><b>Retrieval</b><span>${esc(H.providers.retrieval_mode)}</span></div>
2149
- </div>
2150
- <p class="slug" style="margin:var(--space-4) 0 0">A configured model runs through Claude Code on
2151
- your subscription.</p>`)}
2152
- </div>`,
2153
-
2154
- card('Stalled jobs', 'Grouped by why they stopped. Transient retries on its own; auth, quota and missing need you.',
2155
- H.stalled.length
2156
- ? table([{t:'Status'},{t:'Why'},{t:'Jobs',n:1},{t:'Attempts',n:1},{t:'Last',n:1}],
2157
- H.stalled.map((j) => `<tr>
2158
- <td><span class="pill due">${esc(j.status)}</span></td>
2159
- <td><code class="slug">${esc(j.error_class)}</code></td>
2160
- <td class="num">${j.n}</td><td class="num">${j.attempts}</td>
2161
- <td class="num slug">${esc(ago(j.last))}</td></tr>`))
2162
- : '<p class="empty">No job has failed or paused.</p>'),
2303
+ fold('health:capture', 'Capture settings', `${M.redacted} redacted · ${H.capture.retention_days ? plural(H.capture.retention_days, 'day') + ' retention' : 'kept until deleted'}`,
2304
+ `<div class="kv">
2305
+ <div><b>Memory</b><span>${H.capture.enabled ? 'on' : 'off'}</span></div>
2306
+ <div><b>Capture mode</b><span>${esc(H.capture.mode)}</span></div>
2307
+ <div><b>Newest event</b><span>${esc(H.capture.newest_event ? stamp(H.capture.newest_event) : 'never')}</span></div>
2308
+ <div><b>Redacted</b><span>${M.redacted}</span></div>
2309
+ <div><b>Retention</b><span>${H.capture.retention_days ? plural(H.capture.retention_days, 'day') : 'kept until deleted'}</span></div>
2310
+ <div><b>Last prune</b><span>${esc(H.capture.pruned_at ? ago(H.capture.pruned_at) : 'never')}</span></div>
2311
+ </div>`,
2312
+ { hint: 'What the hooks are allowed to record, and how long it is kept.' }),
2313
+ fold('health:providers', 'Providers', esc(H.providers.retrieval_mode),
2314
+ `<div class="kv">
2315
+ <div><b>Observer</b><span>${H.providers.observer ? esc(H.providers.observer.kind + ' · ' + H.providers.observer.model) : 'local summariser'}</span></div>
2316
+ <div><b>Embeddings</b><span>${H.providers.embeddings ? esc(H.providers.embeddings.kind + ' · ' + H.providers.embeddings.model) : 'local embedder'}</span></div>
2317
+ <div><b>Retrieval</b><span>${esc(H.providers.retrieval_mode)}</span></div>
2318
+ </div>
2319
+ <p class="slug" style="margin:var(--space-4) 0 0">A configured model runs through Claude Code on your subscription.</p>`,
2320
+ { hint: 'Nothing leaves this machine unless a provider is configured here.' }),
2163
2321
  ].join('');
2164
2322
  }
2165
2323
 
@@ -2217,6 +2375,7 @@ function viewArtifacts(param) {
2217
2375
  }
2218
2376
  const counts = { all: all.length, explainer: all.filter((a) => a.kind === 'explainer').length };
2219
2377
  counts.artifact = all.length - counts.explainer;
2378
+ const projects = new Set(all.map((a) => pid(a.project))).size;
2220
2379
  const chip = (k, label) => `<button class="chip" data-go="${esc(AH('dashboard', k === 'all' ? null : k))}" aria-pressed="${kind === k}">${label} ${counts[k]}</button>`;
2221
2380
  const cols = [{t:'Page'},{t:'Kind'},{t:'Concept'},{t:'Written',n:1}];
2222
2381
  const body = !list.length
@@ -2225,19 +2384,14 @@ function viewArtifacts(param) {
2225
2384
  `<h3 class="slug" style="margin:var(--space-5) 0 var(--space-2)">${esc(projectById(id)?.name ?? (id === '~' ? 'no project' : base(id)))}</h3>`
2226
2385
  + table(cols, rows.map(artifactRow))).join('') + pager('artifacts', list.length, p);
2227
2386
  return [
2228
- `<div>
2229
- <h1 class="page__title">Artifacts</h1>
2230
- <p class="page__sub">Pages your agent wrote for you — explainers after a missed question, and anything you asked
2231
- to have turned into a page. Each opens in a new tab, from <code>~/.eklavya/artifacts/</code>.</p>
2232
- </div>`,
2233
- tiles([
2234
- ['Artifacts', all.length, 'in scope'],
2235
- ['Explainers', counts.explainer, 'written after a question', null, counts.explainer ? AH('dashboard', 'explainer') : null],
2236
- ['Projects', new Set(all.map((a) => pid(a.project))).size, 'with at least one page', null, AH('projects')],
2237
- ]),
2387
+ pageHead('Artifacts', [
2388
+ plural(all.length, 'page'), plural(counts.explainer, 'explainer'),
2389
+ `<a class="link" data-go="${esc(AH('projects'))}">${plural(projects, 'project')}</a>`,
2390
+ ], `Pages your agent wrote for you — explainers after a missed question, and anything you asked to have
2391
+ turned into a page. Each opens in a new tab, from <code>~/.eklavya/artifacts/</code>.`),
2238
2392
  card(null, null,
2239
2393
  `<div class="filters">
2240
- <div class="grow"><input type="search" id="aq" value="${esc(t.q)}" placeholder="Search title, description, concept or project…" aria-label="Search artifacts"></div>
2394
+ <div class="grow"><input type="search" id="aq" value="${esc(t.q)}" placeholder="Search artifacts" aria-label="Search artifacts"></div>
2241
2395
  </div>
2242
2396
  <div class="chips">${chip('all','All')}${chip('explainer','Explainers')}${chip('artifact','Other pages')}</div>
2243
2397
  <div style="margin-top:var(--space-4)">${body}</div>`),
@@ -2264,11 +2418,9 @@ function viewArtifactProjects() {
2264
2418
  <td class="num slug">${esc(ago(r.last))}</td></tr>`;
2265
2419
  });
2266
2420
  return [
2267
- `<div>
2268
- <h1 class="page__title">Projects</h1>
2269
- <p class="page__sub">Artifacts are filed by the project they were written in — the main checkout, for a worktree.
2270
- Open one to see only its pages.</p>
2271
- </div>`,
2421
+ pageHead('Projects', [plural(by.size, 'project')],
2422
+ `Artifacts are filed by the project they were written in — the main checkout, for a worktree. Open one
2423
+ to see only its pages.`),
2272
2424
  card(null, null, table([{t:'Project'},{t:'Pages',n:1},{t:'Explainers',n:1},{t:'Last written',n:1}], rows)),
2273
2425
  ].join('');
2274
2426
  }
@@ -2732,6 +2884,7 @@ function render(keepFocus) {
2732
2884
  document.title = `${label} · ${W.name} — Eklavya`;
2733
2885
 
2734
2886
  AFTER.length = 0;
2887
+ LAZY.clear();
2735
2888
  $('view').innerHTML = !pg ? viewNotFound('#/' + CUR.wf + '/' + CUR.page, false)
2736
2889
  : CUR.bad ? viewNotFound(CUR.param, true)
2737
2890
  : pg.view(CUR.param);
@@ -2782,13 +2935,6 @@ function onActivate(e) {
2782
2935
  }
2783
2936
  const tab = e.target.closest('[data-tab]');
2784
2937
  if (tab) { T.review.page = 1; return goTo(LH('review', tab.dataset.tab === 'due' ? null : tab.dataset.tab)); }
2785
- const mtype = e.target.closest('[data-mtype]');
2786
- if (mtype) {
2787
- T.memory.page = 1;
2788
- const q = CUR.query;
2789
- return goTo(MH('timeline', mtype.dataset.mtype === 'all' ? null : mtype.dataset.mtype,
2790
- { tag: q.get('tag'), from: q.get('from'), to: q.get('to') }));
2791
- }
2792
2938
  const go = e.target.closest('[data-go]');
2793
2939
  if (go) return goTo(go.dataset.go);
2794
2940
  }
@@ -2798,11 +2944,27 @@ $('view').addEventListener('click', onActivate);
2798
2944
  // are divs and table rows, so Enter and Space have to be wired by hand.
2799
2945
  $('view').addEventListener('keydown', (e) => {
2800
2946
  if (e.key !== 'Enter' && e.key !== ' ') return;
2801
- if (!e.target.closest('[data-go],[data-state],[data-tab],[data-page],[data-mtype]')) return;
2947
+ if (!e.target.closest('[data-go],[data-state],[data-tab],[data-page]')) return;
2802
2948
  e.preventDefault();
2803
2949
  onActivate(e);
2804
2950
  });
2805
2951
 
2952
+ // A fold remembers being opened or closed, and draws the charts it was hiding.
2953
+ // `toggle` does not bubble, so this listens in the capture phase.
2954
+ $('view').addEventListener('toggle', (e) => {
2955
+ const d = e.target;
2956
+ if (!(d instanceof HTMLDetailsElement)) return;
2957
+ if (d.dataset.fold) {
2958
+ FOLD[d.dataset.fold] = d.open;
2959
+ try { localStorage.setItem(FOLD_KEY, JSON.stringify(FOLD)); } catch (err) { /* defaults next time */ }
2960
+ }
2961
+ if (!d.open) return;
2962
+ for (const [id, fn] of LAZY) {
2963
+ const el = $(id);
2964
+ if (el && d.contains(el) && !el.closest('details:not([open])')) { LAZY.delete(id); fn(el); }
2965
+ }
2966
+ }, true);
2967
+
2806
2968
  $('view').addEventListener('input', (e) => {
2807
2969
  if (e.target.id === 'q') {
2808
2970
  T.concepts.q = e.target.value;
@@ -2825,6 +2987,19 @@ $('view').addEventListener('change', (e) => {
2825
2987
  if (e.target.id === 'dom') { T.concepts.page = 1; setQuery({ domain: e.target.value }); }
2826
2988
  if (e.target.id === 'sort') { T.concepts.sort = e.target.value; T.concepts.page = 1; render(); }
2827
2989
  if (e.target.id === 'mtag') { T.memory.page = 1; setQuery({ tag: e.target.value }); }
2990
+ if (e.target.id === 'mtype') {
2991
+ T.memory.page = 1;
2992
+ const q = CUR.query;
2993
+ goTo(MH('timeline', e.target.value === 'all' ? null : e.target.value,
2994
+ { tag: q.get('tag'), from: q.get('from'), to: q.get('to') }));
2995
+ }
2996
+ if (e.target.id === 'mwhen') {
2997
+ T.memory.page = 1;
2998
+ const v = e.target.value;
2999
+ // Custom keeps the range's start, closes it at today and reveals the date boxes.
3000
+ if (v === 'custom') setQuery({ from: CUR.query.get('from') || daysAgo(6), to: daysAgo(0) });
3001
+ else setQuery({ from: v === 'any' ? null : daysAgo(WHEN[v]), to: null });
3002
+ }
2828
3003
  if (e.target.id === 'mfrom') { T.memory.page = 1; setQuery({ from: e.target.value }); }
2829
3004
  if (e.target.id === 'mto') { T.memory.page = 1; setQuery({ to: e.target.value }); }
2830
3005
  });