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