eklavya 1.28.0 → 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); }
@@ -429,6 +429,64 @@
429
429
  .banner p { margin:0; color:var(--dim); font-size:13.5px; }
430
430
  .banner code { background:var(--mass); padding:2px 6px; font-size:12.5px; color:var(--ink); }
431
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
+
432
490
  @media (max-width:1080px) {
433
491
  .g3 { grid-template-columns:repeat(2,minmax(0,1fr)); }
434
492
  }
@@ -472,6 +530,11 @@
472
530
  .stats { grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--space-3); }
473
531
  .tile strong { font-size:var(--text-xl); }
474
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; }
475
538
  .page__title { font-size:var(--text-xl); }
476
539
  }
477
540
  @media (prefers-reduced-motion:reduce) {
@@ -798,6 +861,81 @@ function card(title, hint, body, opts = {}) {
798
861
  return `<section class="card">${head}${body}</section>`;
799
862
  }
800
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
+
801
939
  function table(cols, rows) {
802
940
  if (!rows.length) return '<p class="empty">Nothing here yet.</p>';
803
941
  return `<div class="scroll"><table><thead><tr>`
@@ -1008,7 +1146,19 @@ function segbar(parts) {
1008
1146
  + `</div>`;
1009
1147
  }
1010
1148
 
1011
- 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
+ };
1012
1162
 
1013
1163
  /* ============================================================
1014
1164
  The memory half
@@ -1123,7 +1273,6 @@ function viewLearningDashboard() {
1123
1273
  const last30 = fillDays(daily, 30);
1124
1274
 
1125
1275
  const weakest = seen.filter((c) => !c.mastered).sort((a, b) => a.score - b.score || b.attempts - a.attempts).slice(0, 5);
1126
- const recentSessions = sess.slice(0, 5);
1127
1276
 
1128
1277
  // Logged is what the work touched; assessed is what a question was actually
1129
1278
  // asked about. They are different claims and the page keeps them apart.
@@ -1136,37 +1285,39 @@ function viewLearningDashboard() {
1136
1285
  chart('c-activity', (el) => chartActivity(el, last30));
1137
1286
  chart('c-heat', (el) => chartHeatmap(el, daily));
1138
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
+
1139
1298
  return [
1140
- `<div>
1141
- <h1 class="page__title">Am I actually getting better?</h1>
1142
- <p class="page__sub">${plural(seen.length, 'concept')} asked out of ${S.totals.catalogue} in the graph ·
1143
- ${plural(answered, 'answer')} · ${plural(sess.length, 'session')} · first answer ${esc(fmtDate(S.totals.first_answer))}.</p>
1144
- </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.`),
1145
1310
 
1146
1311
  tiles([
1147
1312
  ['Accuracy', answered ? pct(passed, answered) + '%' : '—', answered ? `${passed} right of ${answered}` : 'No assessments yet', answered && pct(passed, answered) >= 70 ? 'good' : '', LH('sessions')],
1148
- ['Recorded', logged.size, `concepts your work logged · ${loggedAsked} assessed`, '', LH('sessions')],
1149
1313
  ['Mastered', mastered.length, `of ${seen.length} asked`, mastered.length ? 'good' : '', LH('concepts', 'mastered')],
1150
1314
  ['Due now', due.length, due.length ? 'ready for review' : 'nothing waiting', due.length ? 'warn' : '', LH('review')],
1151
- ['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'],
1152
1316
  ]),
1153
1317
 
1154
- PROJECT !== null && !answered
1155
- ? `<div class="banner"><p><strong>No assessments yet</strong> in ${esc(scopeName())}.
1156
- ${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.'}
1157
- Captured memory is never counted as an answer.</p></div>`
1158
- : PROJECT === null && unassessed.length
1159
- ? `<div class="banner"><p><strong>${plural(unassessed.length, 'project')}</strong> recorded concepts but has no assessments yet:
1160
- ${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>`
1161
- : '',
1162
-
1163
- due.length
1164
- ? `<div class="banner"><p><strong>${plural(due.length, 'concept')}</strong> ${due.length === 1 ? 'is' : 'are'} due.
1165
- Run <code>/eklavya:quiz</code> in Claude Code, or open the
1166
- <a class="link" data-go="${esc(LH('review'))}">review queue</a> to see what is waiting.</p></div>`
1167
- : '',
1318
+ next,
1168
1319
 
1169
- 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,
1170
1321
  `<div class="legend">
1171
1322
  <span><b style="background:var(--spot)"></b>Right</span>
1172
1323
  <span><b style="background:var(--warning)"></b>Missed</span>
@@ -1175,42 +1326,35 @@ function viewLearningDashboard() {
1175
1326
  <div class="chart"><div class="tip" id="tip"></div><svg id="c-activity" role="img"
1176
1327
  aria-label="Daily answers, split into right, missed and skipped"></svg></div>`),
1177
1328
 
1178
- card('Practice history', 'One cell per day, up to a year back. Darker means more answered that day.',
1179
- `<div class="heat"><svg id="c-heat" role="img" aria-label="Calendar heatmap of daily answers"></svg></div>`),
1180
-
1181
1329
  `<div class="g2">
1182
- ${card('Where you stand', 'The catalogue is a library, not a to-do list — untouched concepts are not debt.',
1183
- segbar([
1184
- { label:'Mastered', n:mastered.length, color:'var(--spot)' },
1185
- { label:'Learning', n:seen.length - mastered.length, color:'color-mix(in srgb, var(--spot) 45%, var(--mass))' },
1186
- { label:'Not asked', n:Math.max(0, scoped.length - seen.length), color:'var(--mass)' },
1187
- ]) + `<p style="margin:var(--space-4) 0 0;color:var(--faint);font-size:13.5px">
1188
- A concept is mastered at score 0.7 with two or more passes. Score decays about
1189
- 5% a week once a review is overdue, so mastery is kept, not banked.</p>`)}
1190
- ${card('Weakest five', 'Lowest score first. These are what the next quiz will reach for.',
1191
- weakest.length
1192
- ? `<div class="list">` + weakest.map((c) => `
1193
- <div style="display:flex;gap:var(--space-4);align-items:center">
1194
- <div style="flex:1;min-width:0">
1195
- <a class="link" data-go="${esc(LH('concept', c.slug))}">${esc(c.name)}</a>
1196
- <div class="slug">${esc(c.domain)} · ${esc(tierLabel(c.tier))} · ${plural(c.attempts, 'attempt')}</div>
1197
- </div>
1198
- <div style="width:96px">${meter(c.score * 100, c.score < 0.5 ? 'warn' : '')}
1199
- <div class="slug" style="text-align:right">${Math.round(c.score * 100)}%</div></div>
1200
- </div>`).join('') + `</div>`
1201
- : '<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>` })}
1202
1346
  </div>`,
1203
1347
 
1204
- card('Recent sessions', 'Each one is a piece of work your agent did, and what it taught.',
1205
- table([{t:'Session'},{t:'Concepts',n:1},{t:'Asked',n:1},{t:'Right',n:1},{t:'When',n:1}],
1206
- recentSessions.map((s) => `<tr class="row" data-go="${esc(sessHref(s.id))}">
1207
- <td><span class="title">${esc(pname(s.repo, s.id))}</span>
1208
- <div class="slug">${esc(sessionLabel(s))}</div></td>
1209
- <td class="num">${s.logged.length}</td>
1210
- <td class="num">${s.asked.length}</td>
1211
- <td class="num">${s.passed}</td>
1212
- <td class="num slug">${esc(ago(s.last))}</td></tr>`)),
1213
- { 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.' }),
1214
1358
  ].join('');
1215
1359
  }
1216
1360
 
@@ -1245,18 +1389,17 @@ function viewConcepts(param) {
1245
1389
  };
1246
1390
  list = [...list].sort(SORTS[t.sort] ?? SORTS.weakest);
1247
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.
1248
1394
  const p = page('concepts', list.length);
1249
1395
  const rows = slice(list, p).map((c) => `
1250
1396
  <tr class="row" data-go="${esc(LH('concept', c.slug))}">
1251
- <td style="min-width:210px"><span class="title">${esc(c.name)}</span>
1252
- <div class="slug">${esc(c.slug)}</div></td>
1253
- <td><span class="pill">${esc(c.domain)}</span></td>
1254
- <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>
1255
1399
  <td style="min-width:104px">${c.seen ? meter(c.score * 100, c.score < 0.5 ? 'warn' : '') +
1256
1400
  `<div class="slug">${Math.round(c.score * 100)}%</div>` : '<span class="slug">—</span>'}</td>
1257
1401
  <td class="num">${c.attempts || '—'}</td>
1258
1402
  <td>${statePill(c)}</td>
1259
- <td class="ctx" style="min-width:220px">${esc(c.context ?? '')}</td>
1260
1403
  </tr>`);
1261
1404
 
1262
1405
  const counts = {
@@ -1269,14 +1412,12 @@ function viewConcepts(param) {
1269
1412
  const chip = (k, label) => `<button class="chip" data-state="${k}" aria-pressed="${t.state === k}">${label} ${counts[k]}</button>`;
1270
1413
 
1271
1414
  return [
1272
- `<div>
1273
- <h1 class="page__title">Concepts</h1>
1274
- <p class="page__sub">Everything in your graph — what you have been asked, and what is still just catalogue.
1275
- Open one to see every question you were given on it.</p>
1276
- </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.`),
1277
1418
  card(null, null,
1278
1419
  `<div class="filters">
1279
- <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>
1280
1421
  <select id="dom" aria-label="Domain">
1281
1422
  <option value="all">All domains</option>
1282
1423
  ${domains.map((d) => `<option value="${esc(d)}"${t.domain === d ? ' selected' : ''}>${esc(d)}</option>`).join('')}
@@ -1290,7 +1431,7 @@ function viewConcepts(param) {
1290
1431
  ${chip('all','All')}${chip('due','Due')}${chip('learning','Learning')}${chip('mastered','Mastered')}${chip('unseen','Not asked')}
1291
1432
  </div>
1292
1433
  <div style="margin-top:var(--space-4)">
1293
- ${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)}
1294
1435
  ${pager('concepts', list.length, p)}
1295
1436
  </div>`),
1296
1437
  ].join('');
@@ -1305,72 +1446,54 @@ function viewConcept(slug) {
1305
1446
  // would render as a date attached to nothing.
1306
1447
  const ctx = S.logged.filter((l) => l.slug === slug && l.context && String(l.context).trim());
1307
1448
  const byName = new Map(S.concepts.map((x) => [x.slug, x]));
1308
- 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
1309
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>`
1310
- : '<p class="slug" style="margin:0">None recorded.</p>';
1311
-
1312
- const p = page('concept', atts.length, 5);
1313
- const history = slice(atts, p, 5).map((a) => {
1314
- let opts = [];
1315
- try { opts = a.options ? JSON.parse(a.options) : []; } catch { opts = []; }
1316
- return `<article class="qa">
1317
- <div class="qa__meta">
1318
- <span>${esc(fmtDate(a.ts))}</span><span>${esc(tierLabel(a.tier))}</span>
1319
- ${a.format ? `<span>${esc(a.format)}</span>` : ''}${a.level ? `<span>${esc(a.level)}</span>` : ''}
1320
- ${a.repo ? `<span>${esc(pname(a.repo, a.session_id))}</span>` : ''}
1321
- <span class="sp">${gradeBadge(a)}</span>
1322
- </div>
1323
- <p class="qa__q">${esc(a.question)}</p>
1324
- ${opts.length ? `<ul class="qa__opts">${opts.map((o) =>
1325
- `<li class="${a.answer && o === a.answer ? 'picked' : ''}">${esc(o)}</li>`).join('')}</ul>` : ''}
1326
- ${a.answer && !opts.length ? `<p class="qa__a"><b>You said:</b> ${esc(a.answer)}</p>` : ''}
1327
- ${a.feedback ? `<p class="qa__f">${esc(a.feedback)}</p>` : ''}
1328
- </article>`;
1329
- }).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('');
1330
1457
 
1331
1458
  if (atts.length) chart('c-grades', (el) => chartGrades(el, atts));
1332
1459
  const decayed = c.stored_score > c.score;
1460
+ const back = `<a class="page__back" data-go="${esc(LH('concepts'))}">← Concepts</a>`;
1333
1461
 
1334
1462
  return [
1335
- `<div>
1336
- <a class="page__back" data-go="${esc(LH('concepts'))}">← Concepts</a>
1337
- <h1 class="page__title">${esc(c.name)}</h1>
1338
- <p class="page__sub"><code class="slug">${esc(c.slug)}</code> · ${esc(c.domain)} · ${esc(tierLabel(c.tier))} ·
1339
- ${esc(c.source === 'seed' ? 'from the seed graph' : 'minted from your work')}
1340
- ${c.description ? '<br>' + esc(c.description) : ''}</p>
1341
- </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),
1342
1467
 
1343
1468
  tiles([
1344
- ['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' : ''],
1345
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' : ''],
1346
- ['Next review', c.seen ? whenDue(c.next_review) : '—', c.interval_d ? `interval ${Math.round(c.interval_d)}d` : 'not scheduled', c.due ? 'warn' : ''],
1347
- ['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' : ''],
1348
1472
  ]),
1349
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
+
1350
1478
  atts.length
1351
- ? card('How the grades went', 'Every graded answer in order. Grey points are skips and blanks.',
1352
- `<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.' })
1353
1482
  : '',
1354
1483
 
1355
- 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',
1356
1485
  ctx.length
1357
- ? `<div class="list">` + ctx.slice(0, 12).map((l) => `
1358
- <div>
1359
- <div class="ctx">${esc(l.context ?? '')}</div>
1360
- <div class="slug">${esc(ago(l.ts))} · ${esc(pname(l.repo, l.session_id))} · ${esc(l.origin ?? 'work')} ·
1361
- <a class="link" data-go="${esc(sessHref(l.session_id))}">that session</a></div>
1362
- </div>`).join('') + `</div>`
1363
- : '<p class="empty">No context recorded — this one came from the seed graph.</p>'),
1364
-
1365
- card(`Every question you were asked`, atts.length ? `${plural(atts.length, 'attempt')}, newest first.` : '',
1366
- atts.length ? history + pager('concept', atts.length, p, 5)
1367
- : '<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.' }),
1368
1493
 
1369
- `<div class="g3">
1370
- ${card('Prerequisites', 'Asked first when the graph plans a session.', links(c.prereqs))}
1371
- ${card('Unlocks', 'These lean on this one.', links(c.unlocks))}
1372
- ${card('Related', 'Same neighbourhood.', links(c.related))}
1373
- </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>`),
1374
1497
  ].join('');
1375
1498
  }
1376
1499
 
@@ -1400,44 +1523,41 @@ function viewReview(param) {
1400
1523
  });
1401
1524
  }
1402
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;
1403
1528
 
1404
1529
  const tabs = [['due', `Due now ${due.length}`], ['upcoming', `Upcoming ${upcoming.length}`], ['skipped', `Skipped ${skipped.length}`]];
1405
1530
  const list = t.tab === 'due' ? due : t.tab === 'upcoming' ? upcoming : skipped;
1406
1531
  const p = page('review', list.length);
1407
1532
  const rows = slice(list, p).map((x) => t.tab === 'skipped'
1408
1533
  ? `<tr class="row" data-go="${esc(LH('concept', x.slug))}">
1409
- <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>
1410
1535
  <td><span class="pill">${x.outcome === 'declined' ? 'declined' : 'blanked'}</span></td>
1411
- <td class="ctx" style="min-width:280px">${esc(x.question)}</td>
1412
1536
  <td class="num slug">${esc(ago(x.ts))}</td></tr>`
1413
1537
  : `<tr class="row" data-go="${esc(LH('concept', x.slug))}">
1414
- <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>
1415
1539
  <td style="min-width:104px">${meter(x.score * 100, x.score < 0.5 ? 'warn' : '')}
1416
1540
  <div class="slug">${Math.round(x.score * 100)}%</div></td>
1417
- <td class="num">${x.attempts}</td>
1418
- <td class="slug" style="white-space:nowrap">${esc(tierLabel(x.tier))}</td>
1419
1541
  <td class="num slug">${esc(whenDue(x.next_review))}</td></tr>`);
1420
1542
 
1421
1543
  const cols = t.tab === 'skipped'
1422
- ? [{t:'Concept'},{t:'How'},{t:'The question'},{t:'When',n:1}]
1423
- : [{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}];
1424
1546
 
1425
1547
  return [
1426
- `<div>
1427
- <h1 class="page__title">Review queue</h1>
1428
- <p class="page__sub">SM-2 decides when a concept comes back: one day after a miss, then 6, then the
1429
- interval times its ease. A score decays about 5% a week while a review is overdue, so this list is
1430
- also the list of things quietly getting weaker.</p>
1431
- </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.`),
1432
1555
 
1433
- tiles([
1434
- ['Due now', due.length, due.length ? 'run /eklavya:quiz' : 'nothing waiting', due.length ? 'warn' : 'good'],
1435
- ['Next 7 days', buckets.slice(1, 8).reduce((a, b) => a + b.n, 0), 'scheduled reviews'],
1436
- ['Skipped', skipped.length, 'come back a tier lower'],
1437
- ['Longest overdue', due.length ? plural(Math.max(...due.map((c) => c.overdue_days ?? 0)), 'day') : '—', 'since it was due'],
1438
- ]),
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
+ : '',
1439
1559
 
1440
- card('The next fortnight', 'How much review is already scheduled, day by day.',
1560
+ card('The next fortnight', null,
1441
1561
  `<div class="chart"><svg id="c-forecast" role="img" aria-label="Scheduled reviews for the next fourteen days"></svg></div>`),
1442
1562
 
1443
1563
  card(null, null,
@@ -1461,28 +1581,21 @@ function viewSessions() {
1461
1581
  <td style="min-width:260px"><span class="title">${esc(pname(s.repo, s.id))}</span>
1462
1582
  <div class="slug">${esc(sessionLabel(s).slice(0, 120))}</div></td>
1463
1583
  <td class="num">${s.logged.filter((l) => (l.origin ?? 'work') === 'work').length}</td>
1464
- <td class="num">${s.asked.length}</td>
1465
- <td style="min-width:96px">${acc === null ? '<span class="slug">not assessed</span>'
1466
- : meter(acc, acc < 70 ? 'warn' : '') + `<div class="slug">${acc}%</div>`}</td>
1467
- <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>`;
1468
1587
  });
1469
1588
 
1470
1589
  const withQuestions = all.filter((s) => s.asked.length).length;
1471
1590
  return [
1472
- `<div>
1473
- <h1 class="page__title">Sessions</h1>
1474
- <p class="page__sub">One row per Claude Code session that logged a concept or asked a question. Open one
1475
- for the whole thread: the concepts the work touched, every question you were asked, and — joined on the
1476
- same session id — what the session captured and remembered.</p>
1477
- </div>`,
1478
- tiles([
1479
- ['Sessions', all.length, 'that logged or asked'],
1480
- ['With questions', withQuestions, `${pct(withQuestions, all.length)}% of sessions`],
1481
- ['Concepts logged', all.reduce((a, s) => a + s.logged.filter((l) => (l.origin ?? 'work') === 'work').length, 0), 'context lines recorded'],
1482
- ['Answers', all.reduce((a, s) => a + s.asked.length, 0), 'across every session'],
1483
- ]),
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.`),
1484
1597
  card(null, null,
1485
- 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)
1486
1599
  + pager('sessions', all.length, p)
1487
1600
  + (memOnly ? `<p class="slug" style="margin:var(--space-4) 0 0">${plural(memOnly, 'session')} captured memory and logged no concept —
1488
1601
  <a class="link" data-go="${esc(MH('sessions'))}">see them in Memory → Sessions</a>.</p>` : '')),
@@ -1519,82 +1632,62 @@ function sessionBody(s) {
1519
1632
  const work = s.logged.filter((l) => (l.origin ?? 'work') === 'work');
1520
1633
  const review = s.logged.filter((l) => l.origin === 'review');
1521
1634
 
1522
- const conceptRow = (l) => {
1635
+ const conceptLine = (l) => {
1523
1636
  const c = byName.get(l.slug);
1524
- return `<tr class="row" data-go="${esc(LH('concept', l.slug))}">
1525
- <td style="min-width:190px"><span class="title">${esc(c?.name ?? l.slug)}</span>
1526
- <div class="slug">${esc(l.slug)} · ${esc(l.domain)}</div></td>
1527
- <td class="ctx" style="min-width:280px">${esc(l.context ?? '')}</td>
1528
- <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) };
1529
1638
  };
1530
1639
 
1531
- const qa = s.asked.slice().reverse().map((a) => {
1532
- let opts = [];
1533
- try { opts = a.options ? JSON.parse(a.options) : []; } catch { opts = []; }
1534
- return `<article class="qa">
1535
- <div class="qa__meta">
1536
- <a class="link" data-go="${esc(LH('concept', a.slug))}">${esc(a.name)}</a>
1537
- <span>${esc(tierLabel(a.tier))}</span>${a.level ? `<span>${esc(a.level)}</span>` : ''}
1538
- <span class="sp">${gradeBadge(a)}</span>
1539
- </div>
1540
- <p class="qa__q">${esc(a.question)}</p>
1541
- ${opts.length ? `<ul class="qa__opts">${opts.map((o) =>
1542
- `<li class="${a.answer && o === a.answer ? 'picked' : ''}">${esc(o)}</li>`).join('')}</ul>` : ''}
1543
- ${a.answer && !opts.length ? `<p class="qa__a"><b>You said:</b> ${esc(a.answer)}</p>` : ''}
1544
- ${a.feedback ? `<p class="qa__f">${esc(a.feedback)}</p>` : ''}
1545
- </article>`;
1546
- }).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('');
1547
1643
 
1548
- // The memory half of this session, in the order it happened: evidence in,
1549
- // 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.
1550
1646
  if (s.mem) {
1551
1647
  fill('sess-mem', '/api/memory?per=100&session=' + encodeURIComponent(id), (d) => {
1552
- const entries = d.rows.map((e) => `
1553
- <tr class="row${e.deleted_at || e.superseded_by ? ' gone' : ''}" data-go="${esc(MH('entry', e.id))}">
1554
- <td class="slug" style="white-space:nowrap">${esc(stamp(e.occurred_at))}</td>
1555
- <td style="min-width:260px"><span class="title">${esc(e.title)}</span>
1556
- <div class="ctx">${esc(e.snippet ?? '')}</div></td>
1557
- <td><span class="pill">${esc(e.type ?? e.kind)}</span></td>
1558
- <td class="num">${e.event_count}</td>
1559
- <td>${entryMark(e)}</td></tr>`);
1560
- const cands = (d.candidates ?? []).map((c) => {
1561
- const go = c.concept_slug ? ` class="row" data-go="${esc(LH('concept', c.concept_slug))}"` : '';
1562
- return `<tr${go}><td><span class="title">${esc(c.name ?? c.slug)}</span><div class="slug">${esc(c.slug)}</div></td>
1563
- <td><span class="pill">${esc(c.domain ?? '—')}</span></td>
1564
- <td class="num">${Math.round((c.confidence ?? 0) * 100)}%</td>
1565
- <td><span class="pill${c.status === 'accepted' ? ' ok' : ''}">${esc(c.status)}</span></td></tr>`;
1566
- });
1567
- return card('What the session remembered', 'Observations distilled from the evidence this session captured.',
1568
- table([{t:'When'},{t:'Observation'},{t:'Type'},{t:'Evidence',n:1},{t:''}], entries))
1569
- + card('Concepts it proposed', 'Derived from evidence, and still only a proposal — nothing here moved mastery.',
1570
- 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
+ : '');
1571
1666
  });
1572
1667
  }
1573
1668
 
1669
+ const back = `<a class="page__back" data-go="${esc(href(CUR.wf, 'sessions'))}">← Sessions</a>`;
1574
1670
  return [
1575
- `<div>
1576
- <a class="page__back" data-go="${esc(href(CUR.wf, 'sessions'))}">← Sessions</a>
1577
- <h1 class="page__title">${esc(pname(s.repo, s.id))}</h1>
1578
- <p class="page__sub">${esc(stamp(s.first))} → ${esc(stamp(s.last))} · <code class="slug">${esc(s.id)}</code></p>
1579
- </div>`,
1580
- tiles([
1581
- ['Captured', s.mem ? s.mem.events : 0, s.mem && s.mem.redacted ? `${s.mem.redacted} redacted` : 'evidence events'],
1582
- ['Remembered', s.mem ? s.mem.entries : 0, 'observations written'],
1583
- ['Logged', s.logged.length, 'concepts the work touched'],
1584
- ['Asked', s.asked.length, s.asked.length ? `${s.passed} right` : 'no questions in this one'],
1585
- ['Accuracy', s.asked.length ? pct(s.passed, s.asked.length) + '%' : '—', 'in this session',
1586
- s.asked.length && pct(s.passed, s.asked.length) >= 70 ? 'good' : ''],
1587
- ['Review pulled in', review.length, 'from earlier work'],
1588
- ]),
1589
- s.mem ? `<div class="stack" id="sess-mem"><p class="empty">Loading what this session remembered…</p></div>` : '',
1590
- card('What the work taught', 'Each line is what the agent said the concept was for, in the code it wrote.',
1591
- 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
+
1592
1685
  review.length
1593
- ? card('Pulled in for review', 'Not from this diff — the tutor widened to prerequisites and neighbours.',
1594
- 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.' })
1595
1688
  : '',
1596
- card('What you were asked', s.asked.length ? `${plural(s.asked.length, 'question')}, in order.` : '',
1597
- 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>` : '',
1598
1691
  ].join('');
1599
1692
  }
1600
1693
 
@@ -1609,24 +1702,26 @@ function viewProjects() {
1609
1702
  const other = inv.filter((p) => !p.learning.answers && !p.learning.logged_concepts);
1610
1703
  const daily = dailyInScope();
1611
1704
 
1612
- 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) => {
1613
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(' · ');
1614
1712
  const detail = `<div class="kv" style="margin-bottom:var(--space-5)">
1615
- <div><b>Answers</b><span>${L.answers}</span></div>
1616
- <div><b>Accuracy</b><span>${L.answers ? pct(L.passed, L.answers) + '%' : '—'}</span></div>
1617
1713
  <div><b>Assessed</b><span>${plural(L.assessed_concepts, 'concept')}</span></div>
1618
1714
  <div><b>Recorded</b><span>${plural(L.logged_concepts, 'concept')}</span></div>
1619
1715
  <div><b>Sessions</b><span>${L.sessions}</span></div>
1620
- <div><b>Last active</b><span>${esc(ago(L.last))}</span></div>
1621
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 };
1622
1720
  if (!L.answers) {
1623
- return card(p.name, whereLine(p), detail
1624
- + `<p class="slug" style="margin:0"><strong>No assessments yet.</strong> The concepts this project's work recorded
1625
- come up in a quiz like any others; nothing counts toward a level until one is answered.
1626
- <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);
1627
1724
  }
1628
- // The level runway comes from the learning payload, keyed by the same fold.
1629
- const st = S.projects.filter((x) => pid(x.repo) === p.id).sort((x, y) => y.answers - x.answers)[0];
1630
1725
  const bar = (label, value, target, ok) => `
1631
1726
  <div><span class="${ok ? 'mark-ok' : 'mark-no'}">${ok ? '✓' : '○'}</span>
1632
1727
  <span style="min-width:150px">${esc(label)}</span>
@@ -1649,7 +1744,7 @@ function viewProjects() {
1649
1744
  chartActivity(el, fillDays(m, 30));
1650
1745
  });
1651
1746
 
1652
- return card(p.name, whereLine(p), detail
1747
+ return fold('proj:' + p.id, p.name, peek, detail
1653
1748
  + (st ? `<div class="ladder">${ladder}</div>
1654
1749
  ${st.pinned
1655
1750
  ? `<p class="slug" style="margin:var(--space-4) 0 0">Difficulty is pinned in config, so this project does not promote.</p>`
@@ -1662,33 +1757,28 @@ function viewProjects() {
1662
1757
  </div>`
1663
1758
  : `<p class="slug" style="margin:var(--space-4) 0 0">Top of the ladder — questions can reach tier 5 here.</p>`}` : '')
1664
1759
  + `<div class="chart" style="margin-top:var(--space-5)"><svg id="c-proj-${i}" role="img"
1665
- 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);
1666
1761
  });
1667
1762
 
1668
1763
  const assessed = list.filter((p) => p.learning.answers);
1669
1764
  return [
1670
- `<div>
1671
- <h1 class="page__title">Projects</h1>
1672
- <p class="page__sub">Difficulty is earned per codebase, not per person: the same developer is a different
1673
- learner on their fourth Node service and their first Rust one. Everyone starts at easy (tiers 1–2)
1674
- and climbs on evidence. A project appears here as soon as its work logs a concept — before a single
1675
- question is asked.</p>
1676
- </div>`,
1677
- tiles([
1678
- ['Projects', list.length, 'with logged or assessed work'],
1679
- ['Assessed', assessed.length, `${list.length - assessed.length} with no assessments yet`],
1680
- ['Answers', list.reduce((a, p) => a + p.learning.answers, 0), 'across all of them'],
1681
- ['Best accuracy', assessed.length ? Math.max(...assessed.map((p) => pct(p.learning.passed, p.learning.answers))) + '%' : '—', 'of any project'],
1682
- ['Active days', streaks(daily).days, 'with at least one answer'],
1683
- ]),
1684
- 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>',
1685
1773
  other.length
1686
- ? card('Memory only', 'Recorded by capture, with no learning activity yet. Captured is not assessed.',
1687
- table([{t:'Project'},{t:'Evidence',n:1},{t:'Observations',n:1},{t:'Last active',n:1}], other.map((p) => `
1688
- <tr class="row" data-go="${esc(MH('projects', null, { project: p.id }))}">
1689
- <td><span class="title">${esc(p.name)}</span><div class="slug">${esc(whereLine(p))}</div></td>
1690
- <td class="num">${p.memory.events}</td><td class="num">${p.memory.entries}</td>
1691
- <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.' })
1692
1782
  : '',
1693
1783
  ].join('');
1694
1784
  }
@@ -1703,34 +1793,27 @@ function viewDomains() {
1703
1793
  byDomain.set(c.domain, d);
1704
1794
  }
1705
1795
  const list = [...byDomain.values()].sort((a, b) => b.touched - a.touched || b.catalogue - a.catalogue);
1706
-
1707
1796
  const rows = list.map((d) => `
1708
1797
  <tr class="row" data-go="${esc(LH('domain', d.domain))}">
1709
1798
  <td><span class="title">${esc(d.domain)}</span>
1710
- <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>
1711
1800
  <td style="min-width:130px">${d.touched
1712
- ? meter(pct(d.mastered, d.touched)) + `<div class="slug">${d.mastered} mastered of ${d.touched} asked</div>`
1713
- : '<span class="slug">your work has not reached it</span>'}</td>
1714
- <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>
1715
1803
  <td class="num">${d.due || '—'}</td>
1716
- <td class="num slug">${d.catalogue - d.touched} not asked</td>
1717
1804
  </tr>`);
1718
-
1719
1805
  const tierCount = [1,2,3,4,5].map((t) => scoped.filter((c) => c.seen && c.tier === t).length);
1720
1806
  // A sequential ramp mixed from the accent role rather than picked off the
1721
1807
  // accent scale: a fixed --vd-200 is legible on ink and invisible on paper.
1722
1808
  const COLORS = [100, 78, 58, 40, 24].map((p) => `color-mix(in srgb, var(--spot) ${p}%, var(--mass))`);
1723
-
1724
1809
  return [
1725
- `<div>
1726
- <h1 class="page__title">Domains</h1>
1727
- <p class="page__sub">What you are learning, grouped. The catalogue is a library, not a to-do list —
1728
- a domain with untouched concepts is a domain your work has not reached yet.</p>
1729
- </div>`,
1730
- card('Coverage', 'Open a domain to see its concepts.',
1731
- table([{t:'Domain'},{t:'Mastered'},{t:'Answers',n:1},{t:'Due',n:1},{t:'Catalogue',n:1}], rows)),
1732
- card('Tier spread of what you have been asked', 'Tier is how hard one question is: recall, mechanism, judgement, failure modes, design.',
1733
- 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.' }),
1734
1817
  ].join('');
1735
1818
  }
1736
1819
 
@@ -1738,35 +1821,25 @@ function viewDomain(name) {
1738
1821
  const all = conceptsInScope().filter((c) => c.domain === name);
1739
1822
  if (!all.length) return `<p class="empty">No concepts in <code>${esc(name)}</code> for this scope.
1740
1823
  <a class="link" data-go="${esc(LH('domains'))}">Back to domains</a>.</p>`;
1741
-
1742
1824
  const seen = all.filter((c) => c.seen);
1743
1825
  const list = [...all].sort((a, b) => (a.seen === b.seen ? a.score - b.score : a.seen ? -1 : 1));
1744
1826
  const p = page('domain', list.length);
1745
1827
  const rows = slice(list, p).map((c) => `
1746
1828
  <tr class="row" data-go="${esc(LH('concept', c.slug))}">
1747
- <td style="min-width:220px"><span class="title">${esc(c.name)}</span><div class="slug">${esc(c.slug)}</div></td>
1748
- <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>
1749
1830
  <td style="min-width:104px">${c.seen ? meter(c.score * 100, c.score < 0.5 ? 'warn' : '')
1750
1831
  + `<div class="slug">${Math.round(c.score * 100)}%</div>` : '<span class="slug">—</span>'}</td>
1751
1832
  <td class="num">${c.attempts || '—'}</td>
1752
1833
  <td>${statePill(c)}</td>
1753
- <td class="ctx" style="min-width:200px">${esc(c.context ?? '')}</td>
1754
1834
  </tr>`);
1755
-
1835
+ const back = `<a class="page__back" data-go="${esc(LH('domains'))}">← Domains</a>`;
1756
1836
  return [
1757
- `<div>
1758
- <a class="page__back" data-go="${esc(LH('domains'))}">← Domains</a>
1759
- <h1 class="page__title">${esc(name)}</h1>
1760
- <p class="page__sub">${plural(all.length, 'concept')} in the graph · ${seen.length} asked ·
1761
- ${seen.filter((c) => c.mastered).length} mastered.</p>
1762
- </div>`,
1837
+ pageHead(esc(name), [plural(all.length, 'concept'), `${seen.length} asked`, `${seen.filter((c) => c.mastered).length} mastered`], '', back),
1763
1838
  card(null, null, segbar([
1764
1839
  { label:'Mastered', n:seen.filter((c) => c.mastered).length, color:'var(--spot)' },
1765
1840
  { label:'Learning', n:seen.filter((c) => !c.mastered).length, color:'color-mix(in srgb, var(--spot) 45%, var(--mass))' },
1766
1841
  { label:'Not asked', n:all.length - seen.length, color:'var(--mass)' },
1767
- ])),
1768
- card(null, null, table([{t:'Concept'},{t:'Tier'},{t:'Score'},{t:'Asked',n:1},{t:'State'},{t:'From your code'}], rows)
1769
- + 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>`),
1770
1843
  ].join('');
1771
1844
  }
1772
1845
 
@@ -1881,12 +1954,10 @@ function memTiles() {
1881
1954
  const { scoped, events, pending, entries, sessions } = memCounts();
1882
1955
  const everywhere = scoped ? 'all projects · ' : '';
1883
1956
  return tiles([
1884
- ['Observations', entries, scoped ? `live here · ${M.indexed} indexed across all projects`
1885
- : `${M.entries_total} written · ${M.superseded} superseded · ${M.deleted} deleted · ${M.indexed} indexed`, '', MH('timeline')],
1886
- ['Evidence', events, `${events - pending} distilled · ${pending} still pending`, pending ? 'warn' : '', MH('health')],
1887
- ['Sessions', sessions, 'that captured or remembered', '', MH('sessions')],
1888
- ['Reused', M.reused, `${everywhere}${M.exposed} actually delivered`, '', MH('reuse')],
1889
- ['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')],
1890
1961
  ]);
1891
1962
  }
1892
1963
 
@@ -1920,11 +1991,11 @@ function viewMemory(param) {
1920
1991
  const { entries, sessions, pending } = memCounts();
1921
1992
 
1922
1993
  return [
1923
- `<div>
1924
- <h1 class="page__title">Timeline</h1>
1925
- <p class="counts">${plural(entries, 'observation')} · ${plural(sessions, 'session')}${pending
1926
- ? ` · <a class="link warn" data-go="${esc(MH('health'))}">${pending} evidence pending</a>` : ''}</p>
1927
- </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.`),
1928
1999
 
1929
2000
  `<div>
1930
2001
  <div class="filters">
@@ -1948,65 +2019,49 @@ function viewMemory(param) {
1948
2019
  }
1949
2020
 
1950
2021
  function viewMemoryDashboard() {
1951
- const R = S.reuse, H = S.health, Q = H.queue;
2022
+ const R = S.reuse, H = S.health, Q = H.queue, M = S.memory;
1952
2023
  const scope = () => memScope(new URLSearchParams());
1953
2024
  const q6 = scope(); q6.set('per', '6');
1954
- fill('mem-recent', '/api/memory?' + q6, (d) => d.rows.length
1955
- ? table([{t:'When'},{t:'Observation'},{t:'Type'},{t:'Project'}], d.rows.map((e) => `
1956
- <tr class="row${e.deleted_at || e.superseded_by ? ' gone' : ''}" data-go="${esc(MH('entry', e.id))}">
1957
- <td class="slug" style="white-space:nowrap">${esc(stamp(e.occurred_at))}</td>
1958
- <td style="min-width:240px"><span class="title">${esc(e.title)}</span></td>
1959
- <td><span class="pill">${esc(e.type ?? e.kind)}</span></td>
1960
- <td class="slug">${esc(pname(e.project))}</td></tr>`))
1961
- : '<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.'));
1962
2031
  const q5 = scope(); q5.set('per', '5');
1963
- fill('mem-sessions', '/api/memory/sessions?' + q5, (d) => d.rows.length
1964
- ? table([{t:'Session'},{t:'Captured',n:1},{t:'Remembered',n:1},{t:'When',n:1}], d.rows.map((m) => `
1965
- <tr class="row" data-go="${esc(MH('session', m.session_id))}">
1966
- <td><span class="title">${esc(pname(m.project, m.session_id))}</span><div class="slug">${esc(m.session_id)}</div></td>
1967
- <td class="num">${m.events}</td><td class="num">${m.entries}</td>
1968
- <td class="num slug">${esc(ago(m.last))}</td></tr>`))
1969
- : '<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
+ ]);
1970
2050
 
1971
2051
  return [
1972
- `<div>
1973
- <h1 class="page__title">What does memory hold?</h1>
1974
- <p class="page__sub">Captured, processed, stored, retrieved, delivered, assessed — six different things that
1975
- can happen to one piece of evidence, counted separately. None of them is an answer: only a question you
1976
- answered moves mastery.</p>
1977
- </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.`),
1978
2057
  memTiles(),
2058
+ card('Pipeline', null, pipeline),
1979
2059
  `<div class="g2">
1980
- ${card('Context reuse', 'All projects. B is what the same material would have cost read from source; D is what was delivered instead.',
1981
- `<div class="kv">
1982
- <div><b>B — from source</b><span>${R.base}</span></div>
1983
- <div><b>D — delivered</b><span>${R.delivered}</span></div>
1984
- <div><b>Net</b><span>${R.base - R.delivered}</span></div>
1985
- <div><b>Verdict</b><span>${R.savings.kind === 'saving' ? R.savings.percent + '%'
1986
- : R.savings.kind === 'overhead' ? '+' + R.savings.tokens + ' tokens' : '—'}</span></div>
1987
- </div>
1988
- <p style="margin:var(--space-4) 0 0;color:var(--faint);font-size:13.5px">${esc(savingsText(R.savings, R.receipts))}
1989
- Estimated with <code class="slug">${esc(R.estimator)}</code> on both sides of the subtraction —
1990
- a reduction in context volume, not a bill.
1991
- <a class="link" data-go="${esc(MH('reuse'))}">Every receipt →</a></p>`)}
1992
- ${card('Health', 'All projects. Where the pipeline stops, when it stops.',
1993
- `<div class="kv">
1994
- <div><b>Last capture</b><span>${esc(H.capture.newest_event ? ago(H.capture.newest_event) : 'never')}</span></div>
1995
- <div><b>Capture</b><span>${H.capture.enabled ? esc(H.capture.mode) : 'off'}</span></div>
1996
- <div><b>Queued</b><span>${Q.pending}</span></div>
1997
- <div><b>Failed</b><span>${Q.failed}</span></div>
1998
- </div>
1999
- <p style="margin:var(--space-4) 0 0;color:var(--faint);font-size:13.5px">
2000
- ${S.memory.processed} distilled · ${S.memory.indexed} indexed · ${S.memory.reused} reused ·
2001
- ${S.memory.exposed} delivered · ${S.memory.assessed} assessed.
2002
- <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>` })}
2003
2064
  </div>`,
2004
- card('Recent observations', 'The newest things memory kept, in this scope.',
2005
- `<div id="mem-recent"><p class="empty">Loading…</p></div>`,
2006
- { aside: `<a class="link" data-go="${esc(MH('timeline'))}">The timeline →</a>` }),
2007
- card('Recent sessions', 'Sessions that captured evidence or wrote an observation.',
2008
- `<div id="mem-sessions"><p class="empty">Loading…</p></div>`,
2009
- { aside: `<a class="link" data-go="${esc(MH('sessions'))}">All sessions →</a>` }),
2010
2065
  ].join('');
2011
2066
  }
2012
2067
 
@@ -2018,28 +2073,25 @@ function viewMemorySessions() {
2018
2073
  const learned = new Map(sessions().map((x) => [x.id, x]));
2019
2074
  fill('msess-rows', '/api/memory/sessions?' + q, (d) => {
2020
2075
  t.page = d.page;
2021
- 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}],
2022
2078
  d.rows.map((m) => {
2023
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(' · ');
2024
2082
  return `<tr class="row" data-go="${esc(MH('session', m.session_id))}">
2025
2083
  <td style="min-width:240px"><span class="title">${esc(pname(m.project, m.session_id))}</span>
2026
- <div class="slug">${esc(m.session_id)}</div></td>
2027
- <td class="num">${m.events}</td>
2028
- <td class="num">${m.pending || '—'}</td>
2084
+ <div class="slug">${sub}</div></td>
2029
2085
  <td class="num">${m.entries}</td>
2030
- <td class="num">${m.candidates || '—'}</td>
2031
2086
  <td class="num">${l && l.asked.length ? l.asked.length : '—'}</td>
2032
- <td class="num slug">${esc(stamp(m.last))}</td></tr>`;
2033
- })) + pager('msessions', d.total, d.page, d.per)
2034
- + `<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);
2035
2089
  });
2036
2090
  return [
2037
- `<div>
2038
- <h1 class="page__title">Sessions</h1>
2039
- <p class="page__sub">One row per session that captured evidence or wrote an observation. Pending is evidence
2040
- no observation job has processed yet. Asked is the same session's questions, joined on its session id —
2041
- a session can be remembered without ever being assessed.</p>
2042
- </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.`),
2043
2095
  card(null, null, `<div id="msess-rows"><p class="empty">Loading sessions…</p></div>`),
2044
2096
  ].join('');
2045
2097
  }
@@ -2048,29 +2100,25 @@ function viewMemoryProjects() {
2048
2100
  const inv = projectsInScope();
2049
2101
  const list = inv.filter((p) => p.memory.events || p.memory.entries || p.memory.receipts);
2050
2102
  const none = inv.length - list.length;
2051
- const rows = list.map((p) => `
2052
- <tr class="row" data-go="${esc(MH('timeline', null, { project: p.id }))}">
2053
- <td style="min-width:220px"><span class="title">${esc(p.name)}</span><div class="slug">${esc(whereLine(p))}</div></td>
2054
- <td class="num">${p.memory.events}</td>
2055
- <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>
2056
2108
  <td class="num">${p.memory.entries}</td>
2057
2109
  <td class="num">${p.memory.sessions}</td>
2058
- <td class="num">${p.memory.receipts || '—'}</td>
2059
- <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);
2060
2113
  return [
2061
- `<div>
2062
- <h1 class="page__title">Projects</h1>
2063
- <p class="page__sub">Every project memory has recorded anything for — including evidence that has been
2064
- captured and not yet processed into an observation. Open one to read its timeline.</p>
2065
- </div>`,
2066
- tiles([
2067
- ['Projects', list.length, 'with captured or stored memory'],
2068
- ['Evidence', list.reduce((a, p) => a + p.memory.events, 0), 'events captured'],
2069
- ['Pending', list.reduce((a, p) => a + p.memory.pending, 0), 'waiting to be processed'],
2070
- ['Observations', list.reduce((a, p) => a + p.memory.entries, 0), 'live entries'],
2071
- ]),
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.`),
2072
2120
  card(null, null,
2073
- 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)
2074
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 —
2075
2123
  <a class="link" data-go="${esc(LH('projects'))}">see Learning → Projects</a>.</p>` : '')),
2076
2124
  ].join('');
@@ -2082,7 +2130,6 @@ function drawEntry(d) {
2082
2130
  const facts = jsonList(e.facts);
2083
2131
  const files = jsonList(e.files);
2084
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>`;
2085
-
2086
2133
  const evidence = d.events.map((ev) => `
2087
2134
  <details class="qa">
2088
2135
  <summary class="qa__meta">
@@ -2094,63 +2141,53 @@ function drawEntry(d) {
2094
2141
  <pre class="raw">${esc(ev.body)}${ev.body_length > String(ev.body ?? '').length ? '\n… truncated' : ''}</pre>
2095
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>` : ''}
2096
2143
  </details>`).join('');
2097
-
2098
2144
  return [
2099
2145
  `<div>
2100
2146
  <p class="page__eyebrow">${esc(e.type ?? e.kind)}</p>
2101
2147
  <h1 class="page__title${gone ? ' gone-title' : ''}">${esc(e.title)}</h1>
2102
- <p class="page__sub">${esc(stamp(e.occurred_at))} · ${esc(pname(e.project))} ·
2103
- written by <code class="slug">${esc(e.generator)}</code>
2104
- ${e.import_source ? ` · imported from ${esc(e.import_source)}` : ''}
2105
- ${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>
2106
2153
  </div>`,
2107
-
2108
2154
  e.deleted_at
2109
2155
  ? `<div class="banner"><p><strong>Deleted ${esc(ago(e.deleted_at))}.</strong> It is kept here so the record
2110
2156
  stays checkable, and it is excluded from search and recall.</p></div>` : '',
2111
2157
  d.replaced_by
2112
2158
  ? `<div class="banner"><p><strong>Superseded.</strong> A later observation corrected this one:
2113
2159
  ${link(d.replaced_by)}. Recall prefers the replacement; this row is the audit trail.</p></div>` : '',
2114
-
2115
- tiles([
2116
- ['Confidence', e.confidence == null ? '—' : Math.round(e.confidence * 100) + '%', 'the summariser’s own, not a score'],
2117
- ['Evidence', d.events.length, 'raw events behind this claim'],
2118
- ['Candidates', d.candidates.length, 'concepts it proposed'],
2119
- ['Reused', d.receipts.length, d.receipts.length ? 'times it was selected for context' : 'never selected yet', '', MH('reuse')],
2120
- ]),
2121
-
2122
- card('What happened', 'The observation, as written.',
2123
- `<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>
2124
2162
  ${facts.length ? `<ul class="facts">${facts.map((f) => `<li>${esc(f)}</li>`).join('')}</ul>` : ''}
2125
2163
  ${files.length ? `<div class="tags" style="margin-top:var(--space-4)">${files.map((f) => `<span class="pill">${esc(f)}</span>`).join('')}</div>` : ''}
2126
- ${d.tags.length ? `<div class="tags" style="margin-top:var(--space-3)">${tagPills(d.tags)}</div>` : ''}`),
2127
-
2128
- d.candidates.length
2129
- ? card('Concepts it proposed', 'A candidate is a proposal, not a concept — and never a passing answer. Only a question you answered moves mastery.',
2130
- table([{t:'Slug'},{t:'Domain'},{t:'Confidence',n:1},{t:'Status'}], d.candidates.map((c) => {
2131
- const go = c.concept_slug ? ` class="row" data-go="${esc(LH('concept', c.concept_slug))}"` : '';
2132
- return `<tr${go}><td><span class="title">${esc(c.name ?? c.slug)}</span><div class="slug">${esc(c.slug)}</div></td>
2133
- <td><span class="pill">${esc(c.domain ?? '—')}</span></td>
2134
- <td class="num">${Math.round((c.confidence ?? 0) * 100)}%</td>
2135
- <td><span class="pill${c.status === 'accepted' ? ' ok' : ''}">${esc(c.status)}</span></td></tr>`;
2136
- })))
2137
- : '',
2138
-
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>`),
2139
2166
  d.replaces.length
2140
2167
  ? card('What this replaced', 'Earlier claims this observation corrected.',
2141
2168
  `<div class="list">${d.replaces.map((x) => `<div>${link(x)}</div>`).join('')}</div>`)
2142
2169
  : '',
2143
-
2144
- card('The raw evidence', 'Every event this was distilled from, exactly as captured. Open one to read it.',
2145
- evidence || '<p class="empty">No evidence is linked — this entry was written by hand or imported.</p>'),
2146
-
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
+ : '',
2147
2183
  d.receipts.length
2148
- ? card('Where it was reused', 'Each time this entry was selected for a context injection.',
2149
- 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'}],
2150
2186
  d.receipts.map((r) => `<tr><td class="slug">${esc(stamp(r.created_at))}</td>
2151
- <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>
2152
2188
  <td class="num">${r.source_tokens}</td><td class="num">${r.sent_tokens}</td>
2153
- <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.' })
2154
2191
  : '',
2155
2192
  ].join('');
2156
2193
  }
@@ -2171,7 +2208,6 @@ function viewReuse() {
2171
2208
  const net = R.base - R.delivered;
2172
2209
  const verdict = R.savings.kind === 'saving' ? R.savings.percent + '%'
2173
2210
  : R.savings.kind === 'overhead' ? '+' + R.savings.tokens : '—';
2174
-
2175
2211
  const body = slice(rows, p).map((r) => {
2176
2212
  // A saving is only a saving when the delivery was confirmed. An unknown or
2177
2213
  // prepared receipt has a B and a D but no verdict, and printing one would
@@ -2179,42 +2215,32 @@ function viewReuse() {
2179
2215
  const saved = r.delivery === 'confirmed' && r.base_tokens > 0
2180
2216
  ? Math.round((100 * (r.base_tokens - r.delivered_tokens)) / r.base_tokens) + '%'
2181
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(' · ');
2182
2220
  return `<tr>
2183
2221
  <td class="slug" style="white-space:nowrap">${esc(stamp(r.created_at))}</td>
2184
- <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>
2185
2223
  <td class="num">${r.base_tokens}</td>
2186
2224
  <td class="num">${r.delivered_tokens}</td>
2187
2225
  <td class="num">${saved}</td>
2188
- <td class="num">${r.item_count}</td>
2189
- <td class="num">${r.detail_tokens ? '+' + r.detail_tokens : '—'}</td>
2190
2226
  <td>${deliveryPill(r.delivery)}</td>
2191
- <td class="slug">${esc(r.method)}</td>
2192
2227
  </tr>`;
2193
2228
  });
2194
-
2195
2229
  return [
2196
- `<div>
2197
- <h1 class="page__title">Context reuse</h1>
2198
- <p class="page__sub"><strong>B</strong> is what the same material would have cost to read from source.
2199
- <strong>D</strong> is what was actually delivered instead. Both are counted with the same estimator
2200
- (<code class="slug">${esc(R.estimator)}</code>), and the percentage is an estimate of how much
2201
- <em>context volume</em> was avoided — not a bill, not a price, and not a measure of model spend.</p>
2202
- </div>`,
2203
-
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.`),
2204
2237
  tiles([
2238
+ ['Verdict', verdict, savingsText(R.savings, R.receipts), R.savings.kind === 'saving' ? 'good' : R.savings.kind === 'overhead' ? 'warn' : ''],
2205
2239
  ['B — from source', R.base, 'estimated tokens, confirmed receipts'],
2206
2240
  ['D — delivered', R.delivered, 'estimated tokens actually injected'],
2207
- ['Net', net, net >= 0 ? 'fewer tokens of context' : 'more tokens than reading source', net < 0 ? 'warn' : ''],
2208
- ['Verdict', verdict, 'estimated, all projects', R.savings.kind === 'saving' ? 'good' : R.savings.kind === 'overhead' ? 'warn' : ''],
2209
- ['Receipts', R.receipts, `${R.confirmed} confirmed of ${R.receipts}`],
2210
2241
  ]),
2211
-
2212
- `<div class="banner"><p>${esc(savingsText(R.savings, R.receipts))}
2213
- Receipts whose delivery is <code>unknown</code> or <code>prepared</code> are counted in the ledger below
2214
- but never in the verdict — an injection that may not have reached the model is not a saving.</p></div>`,
2215
-
2216
2242
  card('Every receipt', `${plural(rows.length, 'receipt')} in scope. One row per injection.`,
2217
- 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)
2218
2244
  + pager('reuse', rows.length, p)
2219
2245
  + (R.receipts > R.receipts_shown
2220
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>`
@@ -2235,61 +2261,63 @@ function viewHealth() {
2235
2261
  ['Assessed', M.assessed, 'concepts you were actually asked about'],
2236
2262
  ];
2237
2263
  const oldest = Q.oldest ? ago(Q.oldest) : '—';
2238
-
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);
2239
2271
  return [
2240
- `<div>
2241
- <h1 class="page__title">Health</h1>
2242
- <p class="page__sub">Where memory stops, when it stops. Capture, processing, indexing, retrieval and
2243
- delivery fail in different ways and this page keeps them apart. Error classes only — no log dumps,
2244
- no keys, no raw request bodies.</p>
2245
- </div>`,
2246
-
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),
2247
2282
  tiles([
2248
- ['Last capture', H.capture.newest_event ? ago(H.capture.newest_event) : 'never',
2249
- H.capture.enabled ? `capture is ${H.capture.mode}` : 'memory is disabled',
2250
- H.capture.enabled && H.capture.newest_event ? '' : 'warn'],
2251
2283
  ['Queued', Q.pending, Q.pending ? `oldest ${oldest}` : 'nothing waiting', Q.pending ? 'warn' : 'good'],
2252
2284
  ['Paused', Q.paused, 'waiting on a decision', Q.paused ? 'warn' : ''],
2253
2285
  ['Failed', Q.failed, 'gave up after retries', Q.failed ? 'warn' : ''],
2254
- ['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' : ''],
2255
2287
  ]),
2256
-
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
+ : '',
2257
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.',
2258
- `<div class="checks">` + ladder.map(([label, n, note]) => `
2298
+ `<div class="checks" style="margin-top:0">` + ladder.map(([label, n, note]) => `
2259
2299
  <div><span class="${n ? 'mark-ok' : 'mark-no'}">${n ? '✓' : '○'}</span>
2260
2300
  <span style="min-width:110px">${esc(label)}</span>
2261
2301
  <span class="bar">${meter(pct(n, ladder[0][1] || 1), n ? '' : 'mute')}</span>
2262
2302
  <span class="slug" style="white-space:nowrap">${n} — ${esc(note)}</span></div>`).join('') + `</div>`),
2263
-
2264
- `<div class="g2">
2265
- ${card('Capture', 'What the hooks are allowed to record, and how long it is kept.',
2266
- `<div class="kv">
2267
- <div><b>Memory</b><span>${H.capture.enabled ? 'on' : 'off'}</span></div>
2268
- <div><b>Capture mode</b><span>${esc(H.capture.mode)}</span></div>
2269
- <div><b>Newest event</b><span>${esc(H.capture.newest_event ? stamp(H.capture.newest_event) : 'never')}</span></div>
2270
- <div><b>Redacted</b><span>${M.redacted}</span></div>
2271
- <div><b>Retention</b><span>${H.capture.retention_days ? plural(H.capture.retention_days, 'day') : 'kept until deleted'}</span></div>
2272
- <div><b>Last prune</b><span>${esc(H.capture.pruned_at ? ago(H.capture.pruned_at) : 'never')}</span></div>
2273
- </div>`)}
2274
- ${card('Providers', 'Nothing leaves this machine unless a provider is configured here.',
2275
- `<div class="kv">
2276
- <div><b>Observer</b><span>${H.providers.observer ? esc(H.providers.observer.kind + ' · ' + H.providers.observer.model) : 'local summariser'}</span></div>
2277
- <div><b>Embeddings</b><span>${H.providers.embeddings ? esc(H.providers.embeddings.kind + ' · ' + H.providers.embeddings.model) : 'local embedder'}</span></div>
2278
- <div><b>Retrieval</b><span>${esc(H.providers.retrieval_mode)}</span></div>
2279
- </div>
2280
- <p class="slug" style="margin:var(--space-4) 0 0">A configured model runs through Claude Code on
2281
- your subscription.</p>`)}
2282
- </div>`,
2283
-
2284
- card('Stalled jobs', 'Grouped by why they stopped. Transient retries on its own; auth, quota and missing need you.',
2285
- H.stalled.length
2286
- ? table([{t:'Status'},{t:'Why'},{t:'Jobs',n:1},{t:'Attempts',n:1},{t:'Last',n:1}],
2287
- H.stalled.map((j) => `<tr>
2288
- <td><span class="pill due">${esc(j.status)}</span></td>
2289
- <td><code class="slug">${esc(j.error_class)}</code></td>
2290
- <td class="num">${j.n}</td><td class="num">${j.attempts}</td>
2291
- <td class="num slug">${esc(ago(j.last))}</td></tr>`))
2292
- : '<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.' }),
2293
2321
  ].join('');
2294
2322
  }
2295
2323
 
@@ -2347,6 +2375,7 @@ function viewArtifacts(param) {
2347
2375
  }
2348
2376
  const counts = { all: all.length, explainer: all.filter((a) => a.kind === 'explainer').length };
2349
2377
  counts.artifact = all.length - counts.explainer;
2378
+ const projects = new Set(all.map((a) => pid(a.project))).size;
2350
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>`;
2351
2380
  const cols = [{t:'Page'},{t:'Kind'},{t:'Concept'},{t:'Written',n:1}];
2352
2381
  const body = !list.length
@@ -2355,19 +2384,14 @@ function viewArtifacts(param) {
2355
2384
  `<h3 class="slug" style="margin:var(--space-5) 0 var(--space-2)">${esc(projectById(id)?.name ?? (id === '~' ? 'no project' : base(id)))}</h3>`
2356
2385
  + table(cols, rows.map(artifactRow))).join('') + pager('artifacts', list.length, p);
2357
2386
  return [
2358
- `<div>
2359
- <h1 class="page__title">Artifacts</h1>
2360
- <p class="page__sub">Pages your agent wrote for you — explainers after a missed question, and anything you asked
2361
- to have turned into a page. Each opens in a new tab, from <code>~/.eklavya/artifacts/</code>.</p>
2362
- </div>`,
2363
- tiles([
2364
- ['Artifacts', all.length, 'in scope'],
2365
- ['Explainers', counts.explainer, 'written after a question', null, counts.explainer ? AH('dashboard', 'explainer') : null],
2366
- ['Projects', new Set(all.map((a) => pid(a.project))).size, 'with at least one page', null, AH('projects')],
2367
- ]),
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>.`),
2368
2392
  card(null, null,
2369
2393
  `<div class="filters">
2370
- <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>
2371
2395
  </div>
2372
2396
  <div class="chips">${chip('all','All')}${chip('explainer','Explainers')}${chip('artifact','Other pages')}</div>
2373
2397
  <div style="margin-top:var(--space-4)">${body}</div>`),
@@ -2394,11 +2418,9 @@ function viewArtifactProjects() {
2394
2418
  <td class="num slug">${esc(ago(r.last))}</td></tr>`;
2395
2419
  });
2396
2420
  return [
2397
- `<div>
2398
- <h1 class="page__title">Projects</h1>
2399
- <p class="page__sub">Artifacts are filed by the project they were written in — the main checkout, for a worktree.
2400
- Open one to see only its pages.</p>
2401
- </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.`),
2402
2424
  card(null, null, table([{t:'Project'},{t:'Pages',n:1},{t:'Explainers',n:1},{t:'Last written',n:1}], rows)),
2403
2425
  ].join('');
2404
2426
  }
@@ -2862,6 +2884,7 @@ function render(keepFocus) {
2862
2884
  document.title = `${label} · ${W.name} — Eklavya`;
2863
2885
 
2864
2886
  AFTER.length = 0;
2887
+ LAZY.clear();
2865
2888
  $('view').innerHTML = !pg ? viewNotFound('#/' + CUR.wf + '/' + CUR.page, false)
2866
2889
  : CUR.bad ? viewNotFound(CUR.param, true)
2867
2890
  : pg.view(CUR.param);
@@ -2926,6 +2949,22 @@ $('view').addEventListener('keydown', (e) => {
2926
2949
  onActivate(e);
2927
2950
  });
2928
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
+
2929
2968
  $('view').addEventListener('input', (e) => {
2930
2969
  if (e.target.id === 'q') {
2931
2970
  T.concepts.q = e.target.value;