eklavya 1.28.0 → 1.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -185,7 +185,7 @@
|
|
|
185
185
|
.head h2 { font-size:var(--text-md); font-weight:var(--weight-bold); letter-spacing:-.01em; margin:0; }
|
|
186
186
|
.head p { color:var(--faint); font-size:13.5px; margin:2px 0 0; max-width:78ch; overflow-wrap:anywhere; }
|
|
187
187
|
.head--row { display:flex; align-items:flex-end; gap:var(--space-4); flex-wrap:wrap; }
|
|
188
|
-
.head--row > :first-child { flex:1 1
|
|
188
|
+
.head--row > :first-child { flex:1 1 180px; }
|
|
189
189
|
|
|
190
190
|
.g2 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--space-6); }
|
|
191
191
|
.g3 { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:var(--space-6); }
|
|
@@ -429,6 +429,64 @@
|
|
|
429
429
|
.banner p { margin:0; color:var(--dim); font-size:13.5px; }
|
|
430
430
|
.banner code { background:var(--mass); padding:2px 6px; font-size:12.5px; color:var(--ink); }
|
|
431
431
|
|
|
432
|
+
/* ---------- progressive disclosure ----------
|
|
433
|
+
The timeline's rule, applied to every page: the answer first, one line per
|
|
434
|
+
item, and detail one click away. A page opens with its title, one mono line
|
|
435
|
+
of counts, the action that is waiting (if any) and the lists; explanations
|
|
436
|
+
and secondary cards are folded and remembered open once the reader opens
|
|
437
|
+
them. Every fold is a native <details>: keyboard-operable, no JS to open. */
|
|
438
|
+
.about { margin-top:var(--space-2); }
|
|
439
|
+
.about > summary, .fold > summary { list-style:none; cursor:pointer; }
|
|
440
|
+
.about > summary::-webkit-details-marker, .fold > summary::-webkit-details-marker { display:none; }
|
|
441
|
+
.about > summary { display:inline-flex; align-items:center; gap:var(--space-2); min-height:32px;
|
|
442
|
+
font-family:var(--font-mono); font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--faint); }
|
|
443
|
+
.about > summary:hover { color:var(--spot); }
|
|
444
|
+
.about > summary::before, .fold > summary::before { content:'▸'; color:var(--faint); flex:none; }
|
|
445
|
+
.about[open] > summary::before, .fold[open] > summary::before { content:'▾'; color:var(--spot); }
|
|
446
|
+
.about > summary:focus-visible, .fold > summary:focus-visible { outline:none; box-shadow:var(--ring); }
|
|
447
|
+
.about p { margin:var(--space-2) 0 0; color:var(--dim); font-size:13.5px; max-width:72ch; }
|
|
448
|
+
.fold { padding:0; }
|
|
449
|
+
/* Consecutive folds read as one list, not a column of separate cards. */
|
|
450
|
+
.stack > .fold + .fold { margin-top:var(--space-2); }
|
|
451
|
+
.fold > summary { display:flex; align-items:center; gap:var(--space-3); min-height:56px; padding:var(--space-4) var(--space-6); }
|
|
452
|
+
.fold > summary:hover { background:var(--mass); }
|
|
453
|
+
.fold > summary h2 { margin:0; font-size:var(--text-md); font-weight:var(--weight-bold); letter-spacing:-.01em; }
|
|
454
|
+
.fold > summary .fold__peek { margin-left:auto; font-family:var(--font-mono); font-size:12px; color:var(--faint);
|
|
455
|
+
text-align:right; overflow-wrap:anywhere; }
|
|
456
|
+
.fold > summary .fold__peek.warn { color:var(--warning); }
|
|
457
|
+
.fold__body { padding:0 var(--space-6) var(--space-6); }
|
|
458
|
+
.fold__body > .hint { color:var(--faint); font-size:13.5px; margin:0 0 var(--space-4); max-width:78ch; overflow-wrap:anywhere; }
|
|
459
|
+
|
|
460
|
+
/* The one next step, when there is one. Not a banner per fact. */
|
|
461
|
+
.next { display:flex; align-items:center; gap:var(--space-4); flex-wrap:wrap; padding:var(--space-4) var(--space-5);
|
|
462
|
+
border:1px solid var(--line-2); border-left:2px solid var(--warning); background:var(--panel); }
|
|
463
|
+
.next p { margin:0; flex:1 1 280px; color:var(--dim); font-size:13.5px; }
|
|
464
|
+
.next p strong { color:var(--ink); }
|
|
465
|
+
.next code { background:var(--mass); padding:2px 6px; font-size:12.5px; color:var(--ink); }
|
|
466
|
+
.next.ok { border-left-color:var(--spot); }
|
|
467
|
+
|
|
468
|
+
/* One line per item: a name, a quiet second line, and one number on the right. */
|
|
469
|
+
.lines { list-style:none; margin:0; padding:0; }
|
|
470
|
+
.lines > li { display:flex; align-items:center; gap:var(--space-4); min-height:48px; padding:var(--space-2) var(--space-2);
|
|
471
|
+
margin:0 calc(-1 * var(--space-2)); border-bottom:1px solid var(--line); }
|
|
472
|
+
.lines > li:last-child { border-bottom:none; }
|
|
473
|
+
.lines > li[data-go] { cursor:pointer; }
|
|
474
|
+
.lines > li[data-go]:hover { background:var(--mass); }
|
|
475
|
+
.lines > li[data-go]:focus-visible { outline:none; box-shadow:var(--ring); }
|
|
476
|
+
.lines .ln__main { flex:1; min-width:0; }
|
|
477
|
+
.lines .ln__t { display:block; color:var(--ink); font-weight:var(--weight-medium); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
478
|
+
.lines .ln__s { display:block; font-family:var(--font-mono); font-size:11.5px; color:var(--faint); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
479
|
+
.lines .ln__v { flex:none; text-align:right; font-family:var(--font-mono); font-size:12px; color:var(--dim); font-variant-numeric:tabular-nums; }
|
|
480
|
+
.lines .ln__v .meter { width:88px; margin-top:4px; }
|
|
481
|
+
.more { margin:var(--space-4) 0 0; font-size:13.5px; }
|
|
482
|
+
|
|
483
|
+
/* A question answered: one line — concept, grade, the question — and it opens
|
|
484
|
+
to the options and the feedback. */
|
|
485
|
+
details.qa > summary { display:flex; align-items:center; gap:var(--space-3); flex-wrap:wrap; }
|
|
486
|
+
details.qa > summary .qa__line { flex:1 1 240px; min-width:0; font-family:var(--font-body); font-size:13.5px; color:var(--ink);
|
|
487
|
+
letter-spacing:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
488
|
+
details.qa[open] > summary .qa__line { white-space:normal; }
|
|
489
|
+
|
|
432
490
|
@media (max-width:1080px) {
|
|
433
491
|
.g3 { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
|
434
492
|
}
|
|
@@ -472,6 +530,11 @@
|
|
|
472
530
|
.stats { grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--space-3); }
|
|
473
531
|
.tile strong { font-size:var(--text-xl); }
|
|
474
532
|
.card { padding:var(--space-5) var(--space-4); }
|
|
533
|
+
.fold { padding:0; }
|
|
534
|
+
.fold > summary { padding:var(--space-3) var(--space-4); flex-wrap:wrap; }
|
|
535
|
+
.fold > summary .fold__peek { margin-left:0; flex-basis:100%; text-align:left; padding-left:var(--space-5); }
|
|
536
|
+
.fold__body { padding:0 var(--space-4) var(--space-5); }
|
|
537
|
+
.lines .ln__v .meter { width:56px; }
|
|
475
538
|
.page__title { font-size:var(--text-xl); }
|
|
476
539
|
}
|
|
477
540
|
@media (prefers-reduced-motion:reduce) {
|
|
@@ -798,6 +861,81 @@ function card(title, hint, body, opts = {}) {
|
|
|
798
861
|
return `<section class="card">${head}${body}</section>`;
|
|
799
862
|
}
|
|
800
863
|
|
|
864
|
+
/* ---------- progressive disclosure ---------- */
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Which folds the reader opened or closed, by id, so a page they come back to
|
|
868
|
+
* — or one re-rendered by a resize or a ground switch — keeps the detail they
|
|
869
|
+
* asked for. Per-viewer convenience only: storage that throws means defaults.
|
|
870
|
+
*/
|
|
871
|
+
const FOLD_KEY = 'eklavya-dash-folds';
|
|
872
|
+
let FOLD = {};
|
|
873
|
+
try {
|
|
874
|
+
const v = JSON.parse(localStorage.getItem(FOLD_KEY) || '{}');
|
|
875
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) FOLD = v;
|
|
876
|
+
} catch (e) { FOLD = {}; }
|
|
877
|
+
const isOpen = (id, dflt) => (typeof FOLD[id] === 'boolean' ? FOLD[id] : dflt);
|
|
878
|
+
const openAttr = (id, dflt) => (isOpen(id, dflt) ? ' open' : '');
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* A page's head: its title, one mono line of counts, and the explanation of how
|
|
882
|
+
* the numbers are counted folded under a "How this works" line. `title` and
|
|
883
|
+
* every count are HTML the caller escaped.
|
|
884
|
+
*/
|
|
885
|
+
function pageHead(title, counts = [], about = '', back = '') {
|
|
886
|
+
const id = `about:${CUR.wf}:${CUR.page}`;
|
|
887
|
+
return `<div>${back}<h1 class="page__title">${title}</h1>
|
|
888
|
+
${counts.length ? `<p class="counts">${counts.join(' · ')}</p>` : ''}
|
|
889
|
+
${about ? `<details class="about" data-fold="${esc(id)}"${openAttr(id, false)}><summary>How this works</summary><p>${about}</p></details>` : ''}</div>`;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* A secondary card, folded: the summary line carries the title and a peek at
|
|
894
|
+
* what is inside (a count, a verdict), so a closed fold still answers
|
|
895
|
+
* something. A chart inside a closed fold is drawn when it opens.
|
|
896
|
+
*/
|
|
897
|
+
function fold(id, title, peek, body, opts = {}) {
|
|
898
|
+
return `<details class="card fold" data-fold="${esc(id)}"${openAttr(id, !!opts.open)}>
|
|
899
|
+
<summary><h2>${esc(title)}</h2>${peek ? `<span class="fold__peek${opts.warn ? ' warn' : ''}">${peek}</span>` : ''}</summary>
|
|
900
|
+
<div class="fold__body">${opts.hint ? `<p class="hint">${esc(opts.hint)}</p>` : ''}${body}</div></details>`;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** The one thing waiting on the reader, or nothing. `html` is escaped by the caller. */
|
|
904
|
+
const nextStep = (html, ok) => `<div class="next${ok ? ' ok' : ''}"><p>${html}</p></div>`;
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* One line per item: `{ t, s, v, go, cls }` — a title, a quiet mono second
|
|
908
|
+
* line, one value on the right, and where it opens. All HTML the caller escaped.
|
|
909
|
+
*/
|
|
910
|
+
function lines(items, empty = 'Nothing here yet.') {
|
|
911
|
+
if (!items.length) return `<p class="empty">${empty}</p>`;
|
|
912
|
+
return `<ul class="lines">` + items.map((x) =>
|
|
913
|
+
`<li${x.go ? ` data-go="${esc(x.go)}" role="link"` : ''}${x.cls ? ` class="${x.cls}"` : ''}>`
|
|
914
|
+
+ `<div class="ln__main"><span class="ln__t">${x.t}</span>${x.s ? `<span class="ln__s">${x.s}</span>` : ''}</div>`
|
|
915
|
+
+ (x.v != null && x.v !== '' ? `<div class="ln__v">${x.v}</div>` : '') + `</li>`).join('') + `</ul>`;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* A question and its answer as one line that opens to the options and the
|
|
920
|
+
* feedback. `lead` is plain text (a summary holds no links); `linkConcept` puts
|
|
921
|
+
* the way to the concept's page inside the opened body instead.
|
|
922
|
+
*/
|
|
923
|
+
function qaLine(a, lead, linkConcept = false) {
|
|
924
|
+
let opts = [];
|
|
925
|
+
try { opts = a.options ? JSON.parse(a.options) : []; } catch { opts = []; }
|
|
926
|
+
return `<details class="qa">
|
|
927
|
+
<summary class="qa__meta">${lead}<span class="qa__line">${esc(a.question)}</span>${gradeBadge(a)}</summary>
|
|
928
|
+
<div class="qa__meta" style="margin-top:var(--space-3)"><span>${esc(tierLabel(a.tier))}</span>
|
|
929
|
+
${a.format ? `<span>${esc(a.format)}</span>` : ''}${a.level ? `<span>${esc(a.level)}</span>` : ''}
|
|
930
|
+
${a.repo ? `<span>${esc(pname(a.repo, a.session_id))}</span>` : ''}
|
|
931
|
+
${linkConcept ? `<a class="link sp" data-go="${esc(LH('concept', a.slug))}">${esc(a.name)} →</a>` : ''}</div>
|
|
932
|
+
${opts.length ? `<ul class="qa__opts">${opts.map((o) =>
|
|
933
|
+
`<li class="${a.answer && o === a.answer ? 'picked' : ''}">${esc(o)}</li>`).join('')}</ul>` : ''}
|
|
934
|
+
${a.answer && !opts.length ? `<p class="qa__a"><b>You said:</b> ${esc(a.answer)}</p>` : ''}
|
|
935
|
+
${a.feedback ? `<p class="qa__f">${esc(a.feedback)}</p>` : ''}
|
|
936
|
+
</details>`;
|
|
937
|
+
}
|
|
938
|
+
|
|
801
939
|
function table(cols, rows) {
|
|
802
940
|
if (!rows.length) return '<p class="empty">Nothing here yet.</p>';
|
|
803
941
|
return `<div class="scroll"><table><thead><tr>`
|
|
@@ -1008,7 +1146,19 @@ function segbar(parts) {
|
|
|
1008
1146
|
+ `</div>`;
|
|
1009
1147
|
}
|
|
1010
1148
|
|
|
1011
|
-
|
|
1149
|
+
/**
|
|
1150
|
+
* A chart measures its parent, and a closed <details> has no width — so a
|
|
1151
|
+
* chart inside a closed fold waits in `LAZY` until the fold opens.
|
|
1152
|
+
*/
|
|
1153
|
+
const LAZY = new Map();
|
|
1154
|
+
const chart = (id, fn) => {
|
|
1155
|
+
AFTER.push(() => {
|
|
1156
|
+
const el = $(id);
|
|
1157
|
+
if (!el) return;
|
|
1158
|
+
if (el.closest('details:not([open])')) LAZY.set(id, fn);
|
|
1159
|
+
else fn(el);
|
|
1160
|
+
});
|
|
1161
|
+
};
|
|
1012
1162
|
|
|
1013
1163
|
/* ============================================================
|
|
1014
1164
|
The memory half
|
|
@@ -1123,7 +1273,6 @@ function viewLearningDashboard() {
|
|
|
1123
1273
|
const last30 = fillDays(daily, 30);
|
|
1124
1274
|
|
|
1125
1275
|
const weakest = seen.filter((c) => !c.mastered).sort((a, b) => a.score - b.score || b.attempts - a.attempts).slice(0, 5);
|
|
1126
|
-
const recentSessions = sess.slice(0, 5);
|
|
1127
1276
|
|
|
1128
1277
|
// Logged is what the work touched; assessed is what a question was actually
|
|
1129
1278
|
// asked about. They are different claims and the page keeps them apart.
|
|
@@ -1136,37 +1285,39 @@ function viewLearningDashboard() {
|
|
|
1136
1285
|
chart('c-activity', (el) => chartActivity(el, last30));
|
|
1137
1286
|
chart('c-heat', (el) => chartHeatmap(el, daily));
|
|
1138
1287
|
|
|
1288
|
+
// One next step, the most useful one. Due work outranks an empty scope.
|
|
1289
|
+
const next = due.length
|
|
1290
|
+
? nextStep(`<strong>${plural(due.length, 'concept')} ${due.length === 1 ? 'is' : 'are'} due.</strong>
|
|
1291
|
+
Run <code>/eklavya:quiz</code> in Claude Code, or open the
|
|
1292
|
+
<a class="link" data-go="${esc(LH('review'))}">review queue</a>.`)
|
|
1293
|
+
: PROJECT !== null && !answered
|
|
1294
|
+
? nextStep(`<strong>No assessments yet in ${esc(scopeName())}.</strong>
|
|
1295
|
+
${logged.size ? `${plural(logged.size, 'concept')} recorded from its work will come up in a quiz.` : 'Nothing has been logged here either.'}`, true)
|
|
1296
|
+
: '';
|
|
1297
|
+
|
|
1139
1298
|
return [
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1299
|
+
pageHead('Am I actually getting better?', [
|
|
1300
|
+
`${plural(seen.length, 'concept')} asked of ${S.totals.catalogue}`,
|
|
1301
|
+
plural(answered, 'answer'),
|
|
1302
|
+
`${logged.size} recorded from your work, ${loggedAsked} assessed`,
|
|
1303
|
+
`since ${esc(fmtDate(S.totals.first_answer))}`,
|
|
1304
|
+
...(PROJECT === null && unassessed.length
|
|
1305
|
+
? [`<a class="link" data-go="${esc(LH('projects'))}">${plural(unassessed.length, 'project')} not assessed yet</a>`] : []),
|
|
1306
|
+
], `A concept is mastered at score 0.7 with two or more passes. Its score decays about 5% a week once a
|
|
1307
|
+
review is overdue, so mastery is kept, not banked. The catalogue is a library, not a to-do list:
|
|
1308
|
+
untouched concepts are not debt. Captured memory is never counted as an answer — only a question you
|
|
1309
|
+
answered moves a score.`),
|
|
1145
1310
|
|
|
1146
1311
|
tiles([
|
|
1147
1312
|
['Accuracy', answered ? pct(passed, answered) + '%' : '—', answered ? `${passed} right of ${answered}` : 'No assessments yet', answered && pct(passed, answered) >= 70 ? 'good' : '', LH('sessions')],
|
|
1148
|
-
['Recorded', logged.size, `concepts your work logged · ${loggedAsked} assessed`, '', LH('sessions')],
|
|
1149
1313
|
['Mastered', mastered.length, `of ${seen.length} asked`, mastered.length ? 'good' : '', LH('concepts', 'mastered')],
|
|
1150
1314
|
['Due now', due.length, due.length ? 'ready for review' : 'nothing waiting', due.length ? 'warn' : '', LH('review')],
|
|
1151
|
-
['Streak', st.current ? plural(st.current, 'day') : '—', st.best ? `best ${plural(st.best, 'day')}
|
|
1315
|
+
['Streak', st.current ? plural(st.current, 'day') : '—', st.best ? `best ${plural(st.best, 'day')}` : 'no answers yet'],
|
|
1152
1316
|
]),
|
|
1153
1317
|
|
|
1154
|
-
|
|
1155
|
-
? `<div class="banner"><p><strong>No assessments yet</strong> in ${esc(scopeName())}.
|
|
1156
|
-
${logged.size ? `${plural(logged.size, 'concept')} ${logged.size === 1 ? 'was' : 'were'} recorded from its work and will come up in a quiz.` : 'Nothing has been logged here either.'}
|
|
1157
|
-
Captured memory is never counted as an answer.</p></div>`
|
|
1158
|
-
: PROJECT === null && unassessed.length
|
|
1159
|
-
? `<div class="banner"><p><strong>${plural(unassessed.length, 'project')}</strong> recorded concepts but has no assessments yet:
|
|
1160
|
-
${unassessed.slice(0, 4).map((p) => `<a class="link" data-go="${esc(LH('projects', null, { project: p.id }))}">${esc(p.name)}</a>`).join(', ')}.</p></div>`
|
|
1161
|
-
: '',
|
|
1162
|
-
|
|
1163
|
-
due.length
|
|
1164
|
-
? `<div class="banner"><p><strong>${plural(due.length, 'concept')}</strong> ${due.length === 1 ? 'is' : 'are'} due.
|
|
1165
|
-
Run <code>/eklavya:quiz</code> in Claude Code, or open the
|
|
1166
|
-
<a class="link" data-go="${esc(LH('review'))}">review queue</a> to see what is waiting.</p></div>`
|
|
1167
|
-
: '',
|
|
1318
|
+
next,
|
|
1168
1319
|
|
|
1169
|
-
card('Answers
|
|
1320
|
+
card('Answers, last 30 days', null,
|
|
1170
1321
|
`<div class="legend">
|
|
1171
1322
|
<span><b style="background:var(--spot)"></b>Right</span>
|
|
1172
1323
|
<span><b style="background:var(--warning)"></b>Missed</span>
|
|
@@ -1175,42 +1326,35 @@ function viewLearningDashboard() {
|
|
|
1175
1326
|
<div class="chart"><div class="tip" id="tip"></div><svg id="c-activity" role="img"
|
|
1176
1327
|
aria-label="Daily answers, split into right, missed and skipped"></svg></div>`),
|
|
1177
1328
|
|
|
1178
|
-
card('Practice history', 'One cell per day, up to a year back. Darker means more answered that day.',
|
|
1179
|
-
`<div class="heat"><svg id="c-heat" role="img" aria-label="Calendar heatmap of daily answers"></svg></div>`),
|
|
1180
|
-
|
|
1181
1329
|
`<div class="g2">
|
|
1182
|
-
${card('
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
${card('
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
<div style="width:96px">${meter(c.score * 100, c.score < 0.5 ? 'warn' : '')}
|
|
1199
|
-
<div class="slug" style="text-align:right">${Math.round(c.score * 100)}%</div></div>
|
|
1200
|
-
</div>`).join('') + `</div>`
|
|
1201
|
-
: '<p class="empty">Nothing asked in this scope yet.</p>')}
|
|
1330
|
+
${card('Weakest five', null,
|
|
1331
|
+
lines(weakest.map((c) => ({
|
|
1332
|
+
t: esc(c.name),
|
|
1333
|
+
s: `${esc(c.domain)} · ${esc(tierLabel(c.tier))}`,
|
|
1334
|
+
v: `${Math.round(c.score * 100)}%${meter(c.score * 100, c.score < 0.5 ? 'warn' : '')}`,
|
|
1335
|
+
go: LH('concept', c.slug),
|
|
1336
|
+
})), 'Nothing asked in this scope yet.'),
|
|
1337
|
+
{ aside: `<a class="link" data-go="${esc(LH('concepts'))}">All concepts →</a>` })}
|
|
1338
|
+
${card('Recent sessions', null,
|
|
1339
|
+
lines(sess.slice(0, 5).map((s) => ({
|
|
1340
|
+
t: esc(pname(s.repo, s.id)),
|
|
1341
|
+
s: esc(sessionLabel(s)),
|
|
1342
|
+
v: `${s.asked.length ? `${s.passed}/${s.asked.length} right` : plural(s.logged.length, 'concept')}<div class="slug">${esc(ago(s.last))}</div>`,
|
|
1343
|
+
go: sessHref(s.id),
|
|
1344
|
+
})), 'No sessions in this scope yet.'),
|
|
1345
|
+
{ aside: `<a class="link" data-go="${esc(href(CUR.wf, 'sessions'))}">All ${sess.length} →</a>` })}
|
|
1202
1346
|
</div>`,
|
|
1203
1347
|
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
{
|
|
1348
|
+
fold('learn:stand', 'Where you stand', `${mastered.length} mastered · ${seen.length - mastered.length} learning`,
|
|
1349
|
+
segbar([
|
|
1350
|
+
{ label:'Mastered', n:mastered.length, color:'var(--spot)' },
|
|
1351
|
+
{ label:'Learning', n:seen.length - mastered.length, color:'color-mix(in srgb, var(--spot) 45%, var(--mass))' },
|
|
1352
|
+
{ label:'Not asked', n:Math.max(0, scoped.length - seen.length), color:'var(--mass)' },
|
|
1353
|
+
])),
|
|
1354
|
+
|
|
1355
|
+
fold('learn:heat', 'Practice history', plural(st.days, 'active day'),
|
|
1356
|
+
`<div class="heat"><svg id="c-heat" role="img" aria-label="Calendar heatmap of daily answers"></svg></div>`,
|
|
1357
|
+
{ hint: 'One cell per day, up to a year back. Darker means more answered that day.' }),
|
|
1214
1358
|
].join('');
|
|
1215
1359
|
}
|
|
1216
1360
|
|
|
@@ -1245,18 +1389,17 @@ function viewConcepts(param) {
|
|
|
1245
1389
|
};
|
|
1246
1390
|
list = [...list].sort(SORTS[t.sort] ?? SORTS.weakest);
|
|
1247
1391
|
|
|
1392
|
+
// Four columns: what it is, how strong, how often asked, and its state. The
|
|
1393
|
+
// code context and the slug live on the concept's own page.
|
|
1248
1394
|
const p = page('concepts', list.length);
|
|
1249
1395
|
const rows = slice(list, p).map((c) => `
|
|
1250
1396
|
<tr class="row" data-go="${esc(LH('concept', c.slug))}">
|
|
1251
|
-
<td style="min-width:
|
|
1252
|
-
<div class="slug">${esc(c.
|
|
1253
|
-
<td><span class="pill">${esc(c.domain)}</span></td>
|
|
1254
|
-
<td class="slug" style="white-space:nowrap">${esc(tierLabel(c.tier))}</td>
|
|
1397
|
+
<td style="min-width:220px"><span class="title">${esc(c.name)}</span>
|
|
1398
|
+
<div class="slug">${esc(c.domain)} · ${esc(tierLabel(c.tier))}</div></td>
|
|
1255
1399
|
<td style="min-width:104px">${c.seen ? meter(c.score * 100, c.score < 0.5 ? 'warn' : '') +
|
|
1256
1400
|
`<div class="slug">${Math.round(c.score * 100)}%</div>` : '<span class="slug">—</span>'}</td>
|
|
1257
1401
|
<td class="num">${c.attempts || '—'}</td>
|
|
1258
1402
|
<td>${statePill(c)}</td>
|
|
1259
|
-
<td class="ctx" style="min-width:220px">${esc(c.context ?? '')}</td>
|
|
1260
1403
|
</tr>`);
|
|
1261
1404
|
|
|
1262
1405
|
const counts = {
|
|
@@ -1269,14 +1412,12 @@ function viewConcepts(param) {
|
|
|
1269
1412
|
const chip = (k, label) => `<button class="chip" data-state="${k}" aria-pressed="${t.state === k}">${label} ${counts[k]}</button>`;
|
|
1270
1413
|
|
|
1271
1414
|
return [
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
Open one to see every question you were given on it.</p>
|
|
1276
|
-
</div>`,
|
|
1415
|
+
pageHead('Concepts', [plural(all.length, 'concept'), `${counts.all - counts.unseen} asked`, `${counts.mastered} mastered`],
|
|
1416
|
+
`Everything in your graph — what you have been asked, and what is still just catalogue. Open a concept
|
|
1417
|
+
for every question you were given on it and the code that logged it.`),
|
|
1277
1418
|
card(null, null,
|
|
1278
1419
|
`<div class="filters">
|
|
1279
|
-
<div class="grow"><input type="search" id="q" value="${esc(t.q)}" placeholder="Search
|
|
1420
|
+
<div class="grow"><input type="search" id="q" value="${esc(t.q)}" placeholder="Search concepts" aria-label="Search concepts"></div>
|
|
1280
1421
|
<select id="dom" aria-label="Domain">
|
|
1281
1422
|
<option value="all">All domains</option>
|
|
1282
1423
|
${domains.map((d) => `<option value="${esc(d)}"${t.domain === d ? ' selected' : ''}>${esc(d)}</option>`).join('')}
|
|
@@ -1290,7 +1431,7 @@ function viewConcepts(param) {
|
|
|
1290
1431
|
${chip('all','All')}${chip('due','Due')}${chip('learning','Learning')}${chip('mastered','Mastered')}${chip('unseen','Not asked')}
|
|
1291
1432
|
</div>
|
|
1292
1433
|
<div style="margin-top:var(--space-4)">
|
|
1293
|
-
${table([{t:'Concept'},{t:'
|
|
1434
|
+
${table([{t:'Concept'},{t:'Score'},{t:'Asked',n:1},{t:'State'}], rows)}
|
|
1294
1435
|
${pager('concepts', list.length, p)}
|
|
1295
1436
|
</div>`),
|
|
1296
1437
|
].join('');
|
|
@@ -1305,72 +1446,54 @@ function viewConcept(slug) {
|
|
|
1305
1446
|
// would render as a date attached to nothing.
|
|
1306
1447
|
const ctx = S.logged.filter((l) => l.slug === slug && l.context && String(l.context).trim());
|
|
1307
1448
|
const byName = new Map(S.concepts.map((x) => [x.slug, x]));
|
|
1308
|
-
const links = (slugs) => slugs.length
|
|
1449
|
+
const links = (label, slugs) => `<div style="display:grid;gap:var(--space-2)"><b class="slug">${esc(label)}</b>` + (slugs.length
|
|
1309
1450
|
? `<div class="tags">` + slugs.map((s) => `<a class="pill link" data-go="${esc(LH('concept', s))}">${esc(byName.get(s)?.name ?? s)}</a>`).join('') + `</div>`
|
|
1310
|
-
: '<
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
const
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
return `<article class="qa">
|
|
1317
|
-
<div class="qa__meta">
|
|
1318
|
-
<span>${esc(fmtDate(a.ts))}</span><span>${esc(tierLabel(a.tier))}</span>
|
|
1319
|
-
${a.format ? `<span>${esc(a.format)}</span>` : ''}${a.level ? `<span>${esc(a.level)}</span>` : ''}
|
|
1320
|
-
${a.repo ? `<span>${esc(pname(a.repo, a.session_id))}</span>` : ''}
|
|
1321
|
-
<span class="sp">${gradeBadge(a)}</span>
|
|
1322
|
-
</div>
|
|
1323
|
-
<p class="qa__q">${esc(a.question)}</p>
|
|
1324
|
-
${opts.length ? `<ul class="qa__opts">${opts.map((o) =>
|
|
1325
|
-
`<li class="${a.answer && o === a.answer ? 'picked' : ''}">${esc(o)}</li>`).join('')}</ul>` : ''}
|
|
1326
|
-
${a.answer && !opts.length ? `<p class="qa__a"><b>You said:</b> ${esc(a.answer)}</p>` : ''}
|
|
1327
|
-
${a.feedback ? `<p class="qa__f">${esc(a.feedback)}</p>` : ''}
|
|
1328
|
-
</article>`;
|
|
1329
|
-
}).join('');
|
|
1451
|
+
: '<span class="slug">None recorded.</span>') + `</div>`;
|
|
1452
|
+
|
|
1453
|
+
// Collapsed, a question is one line, so ten fit where five cards did.
|
|
1454
|
+
const PER_Q = 10;
|
|
1455
|
+
const p = page('concept', atts.length, PER_Q);
|
|
1456
|
+
const history = slice(atts, p, PER_Q).map((a) => qaLine(a, `<span>${esc(fmtDate(a.ts))}</span>`)).join('');
|
|
1330
1457
|
|
|
1331
1458
|
if (atts.length) chart('c-grades', (el) => chartGrades(el, atts));
|
|
1332
1459
|
const decayed = c.stored_score > c.score;
|
|
1460
|
+
const back = `<a class="page__back" data-go="${esc(LH('concepts'))}">← Concepts</a>`;
|
|
1333
1461
|
|
|
1334
1462
|
return [
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
${esc(c.source === 'seed' ? 'from the seed graph' : 'minted from your work')}
|
|
1340
|
-
${c.description ? '<br>' + esc(c.description) : ''}</p>
|
|
1341
|
-
</div>`,
|
|
1463
|
+
pageHead(esc(c.name), [
|
|
1464
|
+
`<code class="slug">${esc(c.slug)}</code>`, esc(c.domain), esc(tierLabel(c.tier)),
|
|
1465
|
+
esc(c.source === 'seed' ? 'from the seed graph' : 'minted from your work'),
|
|
1466
|
+
], c.description ? esc(c.description) : '', back),
|
|
1342
1467
|
|
|
1343
1468
|
tiles([
|
|
1344
|
-
['Score', c.seen ? Math.round(c.score * 100) + '%' : '—', decayed ? `decayed from ${Math.round(c.stored_score * 100)}%` : '
|
|
1469
|
+
['Score', c.seen ? Math.round(c.score * 100) + '%' : '—', decayed ? `decayed from ${Math.round(c.stored_score * 100)}%` : 'last five grades, recent first', c.mastered ? 'good' : c.score < 0.5 && c.seen ? 'warn' : ''],
|
|
1345
1470
|
['State', c.mastered ? 'mastered' : c.seen ? (c.due ? 'due' : 'learning') : 'not asked', c.seen ? `${c.passed} of ${c.attempts} right` : 'no questions yet', c.due ? 'warn' : ''],
|
|
1346
|
-
['Next review', c.seen ? whenDue(c.next_review) : '—', c.interval_d ? `
|
|
1347
|
-
['Reps', c.reps, `ease ${c.ease ? c.ease.toFixed(2) : '—'}`],
|
|
1471
|
+
['Next review', c.seen ? whenDue(c.next_review) : '—', c.interval_d ? `every ${Math.round(c.interval_d)}d · ease ${c.ease ? c.ease.toFixed(2) : '—'} · ${plural(c.reps, 'rep')}` : 'not scheduled', c.due ? 'warn' : ''],
|
|
1348
1472
|
]),
|
|
1349
1473
|
|
|
1474
|
+
card('Every question you were asked', atts.length ? `${plural(atts.length, 'attempt')}, newest first. Open one for the options and the feedback.` : '',
|
|
1475
|
+
atts.length ? history + pager('concept', atts.length, p, PER_Q)
|
|
1476
|
+
: '<p class="empty">Not asked yet. It will come up when your work touches it.</p>'),
|
|
1477
|
+
|
|
1350
1478
|
atts.length
|
|
1351
|
-
?
|
|
1352
|
-
`<div class="chart"><svg id="c-grades" role="img" aria-label="Grades over time for this concept"></svg></div
|
|
1479
|
+
? fold('concept:grades', 'How the grades went', plural(atts.length, 'grade'),
|
|
1480
|
+
`<div class="chart"><svg id="c-grades" role="img" aria-label="Grades over time for this concept"></svg></div>`,
|
|
1481
|
+
{ hint: 'Every graded answer in order. Grey points are skips and blanks.' })
|
|
1353
1482
|
: '',
|
|
1354
1483
|
|
|
1355
|
-
|
|
1484
|
+
fold('concept:ctx', 'Where it came from', ctx.length ? plural(ctx.length, 'context line') : 'seed graph',
|
|
1356
1485
|
ctx.length
|
|
1357
|
-
?
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
card(`Every question you were asked`, atts.length ? `${plural(atts.length, 'attempt')}, newest first.` : '',
|
|
1366
|
-
atts.length ? history + pager('concept', atts.length, p, 5)
|
|
1367
|
-
: '<p class="empty">Not asked yet. It will come up when your work touches it.</p>'),
|
|
1486
|
+
? lines(ctx.slice(0, 12).map((l) => ({
|
|
1487
|
+
t: esc(l.context ?? ''),
|
|
1488
|
+
s: `${esc(ago(l.ts))} · ${esc(pname(l.repo, l.session_id))} · ${esc(l.origin ?? 'work')}`,
|
|
1489
|
+
go: sessHref(l.session_id),
|
|
1490
|
+
})))
|
|
1491
|
+
: '<p class="empty">No context recorded — this one came from the seed graph.</p>',
|
|
1492
|
+
{ hint: 'What the agent said the concept was for, in the code it wrote. Open a line for that session.' }),
|
|
1368
1493
|
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
${card('Related', 'Same neighbourhood.', links(c.related))}
|
|
1373
|
-
</div>`,
|
|
1494
|
+
fold('concept:graph', 'Connected concepts',
|
|
1495
|
+
`${c.prereqs.length} prerequisites · ${c.unlocks.length} unlocks · ${c.related.length} related`,
|
|
1496
|
+
`<div style="display:grid;gap:var(--space-5)">${links('Prerequisites — asked first', c.prereqs)}${links('Unlocks — lean on this one', c.unlocks)}${links('Related', c.related)}</div>`),
|
|
1374
1497
|
].join('');
|
|
1375
1498
|
}
|
|
1376
1499
|
|
|
@@ -1400,44 +1523,41 @@ function viewReview(param) {
|
|
|
1400
1523
|
});
|
|
1401
1524
|
}
|
|
1402
1525
|
chart('c-forecast', (el) => chartForecast(el, buckets));
|
|
1526
|
+
const week = buckets.slice(1, 8).reduce((a, b) => a + b.n, 0);
|
|
1527
|
+
const longest = due.length ? Math.max(...due.map((c) => c.overdue_days ?? 0)) : 0;
|
|
1403
1528
|
|
|
1404
1529
|
const tabs = [['due', `Due now ${due.length}`], ['upcoming', `Upcoming ${upcoming.length}`], ['skipped', `Skipped ${skipped.length}`]];
|
|
1405
1530
|
const list = t.tab === 'due' ? due : t.tab === 'upcoming' ? upcoming : skipped;
|
|
1406
1531
|
const p = page('review', list.length);
|
|
1407
1532
|
const rows = slice(list, p).map((x) => t.tab === 'skipped'
|
|
1408
1533
|
? `<tr class="row" data-go="${esc(LH('concept', x.slug))}">
|
|
1409
|
-
<td><span class="title">${esc(x.name)}</span><div class="
|
|
1534
|
+
<td style="min-width:260px"><span class="title">${esc(x.name)}</span><div class="ctx">${esc(x.question)}</div></td>
|
|
1410
1535
|
<td><span class="pill">${x.outcome === 'declined' ? 'declined' : 'blanked'}</span></td>
|
|
1411
|
-
<td class="ctx" style="min-width:280px">${esc(x.question)}</td>
|
|
1412
1536
|
<td class="num slug">${esc(ago(x.ts))}</td></tr>`
|
|
1413
1537
|
: `<tr class="row" data-go="${esc(LH('concept', x.slug))}">
|
|
1414
|
-
<td><span class="title">${esc(x.name)}</span><div class="slug">${esc(x.
|
|
1538
|
+
<td style="min-width:220px"><span class="title">${esc(x.name)}</span><div class="slug">${esc(x.domain)} · ${esc(tierLabel(x.tier))}</div></td>
|
|
1415
1539
|
<td style="min-width:104px">${meter(x.score * 100, x.score < 0.5 ? 'warn' : '')}
|
|
1416
1540
|
<div class="slug">${Math.round(x.score * 100)}%</div></td>
|
|
1417
|
-
<td class="num">${x.attempts}</td>
|
|
1418
|
-
<td class="slug" style="white-space:nowrap">${esc(tierLabel(x.tier))}</td>
|
|
1419
1541
|
<td class="num slug">${esc(whenDue(x.next_review))}</td></tr>`);
|
|
1420
1542
|
|
|
1421
1543
|
const cols = t.tab === 'skipped'
|
|
1422
|
-
? [{t:'Concept'},{t:'How'},{t:'
|
|
1423
|
-
: [{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}];
|
|
1424
1546
|
|
|
1425
1547
|
return [
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1548
|
+
pageHead('Review queue', [
|
|
1549
|
+
due.length ? `<span class="warn">${due.length} due now</span>` : 'nothing due',
|
|
1550
|
+
`${week} in the next 7 days`, `${skipped.length} skipped`,
|
|
1551
|
+
...(due.length ? [`longest overdue ${plural(longest, 'day')}`] : []),
|
|
1552
|
+
], `SM-2 decides when a concept comes back: one day after a miss, then 6, then the interval times its
|
|
1553
|
+
ease. A score decays about 5% a week while a review is overdue, so this list is also the list of
|
|
1554
|
+
things quietly getting weaker. A skipped question comes back a tier lower.`),
|
|
1432
1555
|
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
['Skipped', skipped.length, 'come back a tier lower'],
|
|
1437
|
-
['Longest overdue', due.length ? plural(Math.max(...due.map((c) => c.overdue_days ?? 0)), 'day') : '—', 'since it was due'],
|
|
1438
|
-
]),
|
|
1556
|
+
due.length
|
|
1557
|
+
? nextStep(`<strong>${plural(due.length, 'concept')} waiting.</strong> Run <code>/eklavya:quiz</code> in Claude Code to work through them.`)
|
|
1558
|
+
: '',
|
|
1439
1559
|
|
|
1440
|
-
card('The next fortnight',
|
|
1560
|
+
card('The next fortnight', null,
|
|
1441
1561
|
`<div class="chart"><svg id="c-forecast" role="img" aria-label="Scheduled reviews for the next fourteen days"></svg></div>`),
|
|
1442
1562
|
|
|
1443
1563
|
card(null, null,
|
|
@@ -1461,28 +1581,21 @@ function viewSessions() {
|
|
|
1461
1581
|
<td style="min-width:260px"><span class="title">${esc(pname(s.repo, s.id))}</span>
|
|
1462
1582
|
<div class="slug">${esc(sessionLabel(s).slice(0, 120))}</div></td>
|
|
1463
1583
|
<td class="num">${s.logged.filter((l) => (l.origin ?? 'work') === 'work').length}</td>
|
|
1464
|
-
<td
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
<td class="num slug">${esc(stamp(s.last))}</td></tr>`;
|
|
1584
|
+
<td style="min-width:110px">${acc === null ? '<span class="slug">not assessed</span>'
|
|
1585
|
+
: meter(acc, acc < 70 ? 'warn' : '') + `<div class="slug">${s.passed}/${s.asked.length} right</div>`}</td>
|
|
1586
|
+
<td class="num slug">${esc(ago(s.last))}</td></tr>`;
|
|
1468
1587
|
});
|
|
1469
1588
|
|
|
1470
1589
|
const withQuestions = all.filter((s) => s.asked.length).length;
|
|
1471
1590
|
return [
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
tiles([
|
|
1479
|
-
['Sessions', all.length, 'that logged or asked'],
|
|
1480
|
-
['With questions', withQuestions, `${pct(withQuestions, all.length)}% of sessions`],
|
|
1481
|
-
['Concepts logged', all.reduce((a, s) => a + s.logged.filter((l) => (l.origin ?? 'work') === 'work').length, 0), 'context lines recorded'],
|
|
1482
|
-
['Answers', all.reduce((a, s) => a + s.asked.length, 0), 'across every session'],
|
|
1483
|
-
]),
|
|
1591
|
+
pageHead('Sessions', [
|
|
1592
|
+
plural(all.length, 'session'), `${withQuestions} with questions`,
|
|
1593
|
+
plural(all.reduce((a, s) => a + s.asked.length, 0), 'answer'),
|
|
1594
|
+
], `One row per Claude Code session that logged a concept or asked a question. Open one for the whole
|
|
1595
|
+
thread: the concepts the work touched, every question you were asked, and — joined on the same
|
|
1596
|
+
session id — what the session captured and remembered.`),
|
|
1484
1597
|
card(null, null,
|
|
1485
|
-
table([{t:'Session'},{t:'
|
|
1598
|
+
table([{t:'Session'},{t:'Concepts',n:1},{t:'Answers'},{t:'When',n:1}], rows)
|
|
1486
1599
|
+ pager('sessions', all.length, p)
|
|
1487
1600
|
+ (memOnly ? `<p class="slug" style="margin:var(--space-4) 0 0">${plural(memOnly, 'session')} captured memory and logged no concept —
|
|
1488
1601
|
<a class="link" data-go="${esc(MH('sessions'))}">see them in Memory → Sessions</a>.</p>` : '')),
|
|
@@ -1519,82 +1632,62 @@ function sessionBody(s) {
|
|
|
1519
1632
|
const work = s.logged.filter((l) => (l.origin ?? 'work') === 'work');
|
|
1520
1633
|
const review = s.logged.filter((l) => l.origin === 'review');
|
|
1521
1634
|
|
|
1522
|
-
const
|
|
1635
|
+
const conceptLine = (l) => {
|
|
1523
1636
|
const c = byName.get(l.slug);
|
|
1524
|
-
return
|
|
1525
|
-
<td style="min-width:190px"><span class="title">${esc(c?.name ?? l.slug)}</span>
|
|
1526
|
-
<div class="slug">${esc(l.slug)} · ${esc(l.domain)}</div></td>
|
|
1527
|
-
<td class="ctx" style="min-width:280px">${esc(l.context ?? '')}</td>
|
|
1528
|
-
<td>${c ? statePill(c) : ''}</td></tr>`;
|
|
1637
|
+
return { t: esc(c?.name ?? l.slug), s: esc(l.context ?? l.domain ?? ''), v: c ? statePill(c) : '', go: LH('concept', l.slug) };
|
|
1529
1638
|
};
|
|
1530
1639
|
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
return `<article class="qa">
|
|
1535
|
-
<div class="qa__meta">
|
|
1536
|
-
<a class="link" data-go="${esc(LH('concept', a.slug))}">${esc(a.name)}</a>
|
|
1537
|
-
<span>${esc(tierLabel(a.tier))}</span>${a.level ? `<span>${esc(a.level)}</span>` : ''}
|
|
1538
|
-
<span class="sp">${gradeBadge(a)}</span>
|
|
1539
|
-
</div>
|
|
1540
|
-
<p class="qa__q">${esc(a.question)}</p>
|
|
1541
|
-
${opts.length ? `<ul class="qa__opts">${opts.map((o) =>
|
|
1542
|
-
`<li class="${a.answer && o === a.answer ? 'picked' : ''}">${esc(o)}</li>`).join('')}</ul>` : ''}
|
|
1543
|
-
${a.answer && !opts.length ? `<p class="qa__a"><b>You said:</b> ${esc(a.answer)}</p>` : ''}
|
|
1544
|
-
${a.feedback ? `<p class="qa__f">${esc(a.feedback)}</p>` : ''}
|
|
1545
|
-
</article>`;
|
|
1546
|
-
}).join('');
|
|
1640
|
+
// Collapsed to one line each: which concept, the question, the grade.
|
|
1641
|
+
const qa = s.asked.slice().reverse().map((a) =>
|
|
1642
|
+
qaLine(a, `<span style="color:var(--spot)">${esc(a.name)}</span>`, true)).join('');
|
|
1547
1643
|
|
|
1548
|
-
// The memory half of this session,
|
|
1549
|
-
//
|
|
1644
|
+
// The memory half of this session, folded: what it remembered and what it
|
|
1645
|
+
// proposed are the audit trail behind the learning, not the headline.
|
|
1550
1646
|
if (s.mem) {
|
|
1551
1647
|
fill('sess-mem', '/api/memory?per=100&session=' + encodeURIComponent(id), (d) => {
|
|
1552
|
-
const entries = d.rows.map((e) =>
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
table([{t:'Candidate'},{t:'Domain'},{t:'Confidence',n:1},{t:'Status'}], cands));
|
|
1648
|
+
const entries = d.rows.map((e) => ({
|
|
1649
|
+
t: `<span${e.deleted_at || e.superseded_by ? ' class="gone-title"' : ''}>${esc(e.title)}</span>`,
|
|
1650
|
+
s: `${esc(stamp(e.occurred_at))} · ${esc(e.type ?? e.kind)} · ${plural(e.event_count, 'evidence event')}`,
|
|
1651
|
+
v: entryMark(e),
|
|
1652
|
+
go: MH('entry', e.id),
|
|
1653
|
+
}));
|
|
1654
|
+
const cands = (d.candidates ?? []).map((c) => ({
|
|
1655
|
+
t: esc(c.name ?? c.slug),
|
|
1656
|
+
s: `${esc(c.domain ?? '—')} · ${Math.round((c.confidence ?? 0) * 100)}% confidence`,
|
|
1657
|
+
v: `<span class="pill${c.status === 'accepted' ? ' ok' : ''}">${esc(c.status)}</span>`,
|
|
1658
|
+
go: c.concept_slug ? LH('concept', c.concept_slug) : null,
|
|
1659
|
+
}));
|
|
1660
|
+
return fold('sess:mem', 'What the session remembered', plural(entries.length, 'observation'), lines(entries),
|
|
1661
|
+
{ hint: 'Observations distilled from the evidence this session captured.' })
|
|
1662
|
+
+ (cands.length
|
|
1663
|
+
? fold('sess:cands', 'Concepts it proposed', plural(cands.length, 'candidate'), lines(cands),
|
|
1664
|
+
{ hint: 'Derived from evidence, and still only a proposal — nothing here moved mastery.' })
|
|
1665
|
+
: '');
|
|
1571
1666
|
});
|
|
1572
1667
|
}
|
|
1573
1668
|
|
|
1669
|
+
const back = `<a class="page__back" data-go="${esc(href(CUR.wf, 'sessions'))}">← Sessions</a>`;
|
|
1574
1670
|
return [
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
s.mem ? `<div class="stack" id="sess-mem"><p class="empty">Loading what this session remembered…</p></div>` : '',
|
|
1590
|
-
card('What the work taught', 'Each line is what the agent said the concept was for, in the code it wrote.',
|
|
1591
|
-
table([{t:'Concept'},{t:'What you did'},{t:'State now'}], work.map(conceptRow))),
|
|
1671
|
+
pageHead(esc(pname(s.repo, s.id)), [
|
|
1672
|
+
`${esc(stamp(s.first))} → ${esc(stamp(s.last))}`,
|
|
1673
|
+
`${plural(work.length, 'concept')} logged`,
|
|
1674
|
+
s.asked.length ? `${s.passed}/${s.asked.length} right (${pct(s.passed, s.asked.length)}%)` : 'no questions',
|
|
1675
|
+
...(s.mem ? [`${s.mem.events} captured${s.mem.redacted ? `, ${s.mem.redacted} redacted` : ''}`, `${s.mem.entries} remembered`] : []),
|
|
1676
|
+
], `Session <code class="slug">${esc(s.id)}</code>. The learning half and the memory half are joined on the
|
|
1677
|
+
session id the host handed both — the one association between them that is recorded rather than inferred.`, back),
|
|
1678
|
+
|
|
1679
|
+
work.length
|
|
1680
|
+
? card('What the work taught', 'Each line is what the agent said the concept was for, in the code it wrote.', lines(work.map(conceptLine)))
|
|
1681
|
+
: '',
|
|
1682
|
+
|
|
1683
|
+
s.asked.length ? card('What you were asked', `${plural(s.asked.length, 'question')}, in order. Open one for the options and the feedback.`, qa) : '',
|
|
1684
|
+
|
|
1592
1685
|
review.length
|
|
1593
|
-
?
|
|
1594
|
-
|
|
1686
|
+
? fold('sess:review', 'Pulled in for review', plural(review.length, 'concept'), lines(review.map(conceptLine)),
|
|
1687
|
+
{ hint: 'Not from this diff — the tutor widened to prerequisites and neighbours.' })
|
|
1595
1688
|
: '',
|
|
1596
|
-
|
|
1597
|
-
|
|
1689
|
+
|
|
1690
|
+
s.mem ? `<div class="stack" id="sess-mem"><p class="empty">Loading what this session remembered…</p></div>` : '',
|
|
1598
1691
|
].join('');
|
|
1599
1692
|
}
|
|
1600
1693
|
|
|
@@ -1609,24 +1702,26 @@ function viewProjects() {
|
|
|
1609
1702
|
const other = inv.filter((p) => !p.learning.answers && !p.learning.logged_concepts);
|
|
1610
1703
|
const daily = dailyInScope();
|
|
1611
1704
|
|
|
1612
|
-
|
|
1705
|
+
// One fold per project. Its summary line is the standing — level, accuracy,
|
|
1706
|
+
// answers, last active — and it opens to the runway and thirty days of work.
|
|
1707
|
+
const folds = list.map((p, i) => {
|
|
1613
1708
|
const L = p.learning;
|
|
1709
|
+
const acc = L.answers ? pct(L.passed, L.answers) + '%' : null;
|
|
1710
|
+
const st = L.answers ? S.projects.filter((x) => pid(x.repo) === p.id).sort((x, y) => y.answers - x.answers)[0] : null;
|
|
1711
|
+
const peek = [st ? st.level : 'not assessed', acc ? `${acc} of ${plural(L.answers, 'answer')}` : plural(L.logged_concepts, 'concept') + ' recorded', esc(ago(L.last))].join(' · ');
|
|
1614
1712
|
const detail = `<div class="kv" style="margin-bottom:var(--space-5)">
|
|
1615
|
-
<div><b>Answers</b><span>${L.answers}</span></div>
|
|
1616
|
-
<div><b>Accuracy</b><span>${L.answers ? pct(L.passed, L.answers) + '%' : '—'}</span></div>
|
|
1617
1713
|
<div><b>Assessed</b><span>${plural(L.assessed_concepts, 'concept')}</span></div>
|
|
1618
1714
|
<div><b>Recorded</b><span>${plural(L.logged_concepts, 'concept')}</span></div>
|
|
1619
1715
|
<div><b>Sessions</b><span>${L.sessions}</span></div>
|
|
1620
|
-
<div><b>Last active</b><span>${esc(ago(L.last))}</span></div>
|
|
1621
1716
|
</div>`;
|
|
1717
|
+
const links = `<p class="more"><a class="link" data-go="${esc(LH('dashboard', null, { project: p.id }))}">Its dashboard →</a>
|
|
1718
|
+
<a class="link" data-go="${esc(LH('sessions', null, { project: p.id }))}">Its sessions →</a></p>`;
|
|
1719
|
+
const opts = { hint: whereLine(p), open: list.length === 1 };
|
|
1622
1720
|
if (!L.answers) {
|
|
1623
|
-
return
|
|
1624
|
-
+ `<p class="slug" style="margin:0"
|
|
1625
|
-
|
|
1626
|
-
<a class="link" data-go="${esc(LH('sessions', null, { project: p.id }))}">Its sessions →</a></p>`);
|
|
1721
|
+
return fold('proj:' + p.id, p.name, peek, detail
|
|
1722
|
+
+ `<p class="slug" style="margin:0">No assessments yet. The concepts this project's work recorded come up in a
|
|
1723
|
+
quiz like any others; nothing counts toward a level until one is answered.</p>` + links, opts);
|
|
1627
1724
|
}
|
|
1628
|
-
// The level runway comes from the learning payload, keyed by the same fold.
|
|
1629
|
-
const st = S.projects.filter((x) => pid(x.repo) === p.id).sort((x, y) => y.answers - x.answers)[0];
|
|
1630
1725
|
const bar = (label, value, target, ok) => `
|
|
1631
1726
|
<div><span class="${ok ? 'mark-ok' : 'mark-no'}">${ok ? '✓' : '○'}</span>
|
|
1632
1727
|
<span style="min-width:150px">${esc(label)}</span>
|
|
@@ -1649,7 +1744,7 @@ function viewProjects() {
|
|
|
1649
1744
|
chartActivity(el, fillDays(m, 30));
|
|
1650
1745
|
});
|
|
1651
1746
|
|
|
1652
|
-
return
|
|
1747
|
+
return fold('proj:' + p.id, p.name, peek, detail
|
|
1653
1748
|
+ (st ? `<div class="ladder">${ladder}</div>
|
|
1654
1749
|
${st.pinned
|
|
1655
1750
|
? `<p class="slug" style="margin:var(--space-4) 0 0">Difficulty is pinned in config, so this project does not promote.</p>`
|
|
@@ -1662,33 +1757,28 @@ function viewProjects() {
|
|
|
1662
1757
|
</div>`
|
|
1663
1758
|
: `<p class="slug" style="margin:var(--space-4) 0 0">Top of the ladder — questions can reach tier 5 here.</p>`}` : '')
|
|
1664
1759
|
+ `<div class="chart" style="margin-top:var(--space-5)"><svg id="c-proj-${i}" role="img"
|
|
1665
|
-
aria-label="Thirty days of answers in this project"></svg></div>`);
|
|
1760
|
+
aria-label="Thirty days of answers in this project"></svg></div>` + links, opts);
|
|
1666
1761
|
});
|
|
1667
1762
|
|
|
1668
1763
|
const assessed = list.filter((p) => p.learning.answers);
|
|
1669
1764
|
return [
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
['Projects', list.length, 'with logged or assessed work'],
|
|
1679
|
-
['Assessed', assessed.length, `${list.length - assessed.length} with no assessments yet`],
|
|
1680
|
-
['Answers', list.reduce((a, p) => a + p.learning.answers, 0), 'across all of them'],
|
|
1681
|
-
['Best accuracy', assessed.length ? Math.max(...assessed.map((p) => pct(p.learning.passed, p.learning.answers))) + '%' : '—', 'of any project'],
|
|
1682
|
-
['Active days', streaks(daily).days, 'with at least one answer'],
|
|
1683
|
-
]),
|
|
1684
|
-
cards.join('') || '<p class="empty">No project has logged or answered anything yet.</p>',
|
|
1765
|
+
pageHead('Projects', [
|
|
1766
|
+
plural(list.length, 'project'), `${assessed.length} assessed`,
|
|
1767
|
+
plural(list.reduce((a, p) => a + p.learning.answers, 0), 'answer'), plural(streaks(daily).days, 'active day'),
|
|
1768
|
+
], `Difficulty is earned per codebase, not per person: the same developer is a different learner on their
|
|
1769
|
+
fourth Node service and their first Rust one. Everyone starts at easy (tiers 1–2) and climbs on
|
|
1770
|
+
evidence — a promotion needs passing answers <em>and</em> accuracy <em>and</em> distinct concepts. A
|
|
1771
|
+
project appears here as soon as its work logs a concept, before a single question is asked.`),
|
|
1772
|
+
folds.join('') || '<p class="empty">No project has logged or answered anything yet.</p>',
|
|
1685
1773
|
other.length
|
|
1686
|
-
?
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1774
|
+
? fold('proj:memory-only', 'Memory only', plural(other.length, 'project'),
|
|
1775
|
+
lines(other.map((p) => ({
|
|
1776
|
+
t: esc(p.name),
|
|
1777
|
+
s: `${esc(whereLine(p))}`,
|
|
1778
|
+
v: `${p.memory.entries} obs.<div class="slug">${esc(ago(p.last_active))}</div>`,
|
|
1779
|
+
go: MH('projects', null, { project: p.id }),
|
|
1780
|
+
}))),
|
|
1781
|
+
{ hint: 'Recorded by capture, with no learning activity yet. Captured is not assessed.' })
|
|
1692
1782
|
: '',
|
|
1693
1783
|
].join('');
|
|
1694
1784
|
}
|
|
@@ -1703,34 +1793,27 @@ function viewDomains() {
|
|
|
1703
1793
|
byDomain.set(c.domain, d);
|
|
1704
1794
|
}
|
|
1705
1795
|
const list = [...byDomain.values()].sort((a, b) => b.touched - a.touched || b.catalogue - a.catalogue);
|
|
1706
|
-
|
|
1707
1796
|
const rows = list.map((d) => `
|
|
1708
1797
|
<tr class="row" data-go="${esc(LH('domain', d.domain))}">
|
|
1709
1798
|
<td><span class="title">${esc(d.domain)}</span>
|
|
1710
|
-
<div class="slug">${
|
|
1799
|
+
<div class="slug">${d.touched} asked of ${d.catalogue} in the graph</div></td>
|
|
1711
1800
|
<td style="min-width:130px">${d.touched
|
|
1712
|
-
? meter(pct(d.mastered, d.touched)) + `<div class="slug">${d.mastered} mastered
|
|
1713
|
-
: '<span class="slug">
|
|
1714
|
-
<td class="num">${d.attempts}</td>
|
|
1801
|
+
? meter(pct(d.mastered, d.touched)) + `<div class="slug">${d.mastered} mastered</div>`
|
|
1802
|
+
: '<span class="slug">not reached yet</span>'}</td>
|
|
1715
1803
|
<td class="num">${d.due || '—'}</td>
|
|
1716
|
-
<td class="num slug">${d.catalogue - d.touched} not asked</td>
|
|
1717
1804
|
</tr>`);
|
|
1718
|
-
|
|
1719
1805
|
const tierCount = [1,2,3,4,5].map((t) => scoped.filter((c) => c.seen && c.tier === t).length);
|
|
1720
1806
|
// A sequential ramp mixed from the accent role rather than picked off the
|
|
1721
1807
|
// accent scale: a fixed --vd-200 is legible on ink and invisible on paper.
|
|
1722
1808
|
const COLORS = [100, 78, 58, 40, 24].map((p) => `color-mix(in srgb, var(--spot) ${p}%, var(--mass))`);
|
|
1723
|
-
|
|
1724
1809
|
return [
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
card('Tier spread of what you have been asked', 'Tier is how hard one question is: recall, mechanism, judgement, failure modes, design.',
|
|
1733
|
-
segbar([1,2,3,4,5].map((t, i) => ({ label: tierLabel(t), n: tierCount[i], color: COLORS[i] })))),
|
|
1810
|
+
pageHead('Domains', [plural(list.length, 'domain'), `${list.filter((d) => d.touched).length} reached by your work`],
|
|
1811
|
+
`What you are learning, grouped. The catalogue is a library, not a to-do list — a domain with untouched
|
|
1812
|
+
concepts is a domain your work has not reached yet.`),
|
|
1813
|
+
card(null, null, table([{t:'Domain'},{t:'Mastered'},{t:'Due',n:1}], rows)),
|
|
1814
|
+
fold('domains:tiers', 'Tier spread', `${tierCount.reduce((a, b) => a + b, 0)} asked`,
|
|
1815
|
+
segbar([1,2,3,4,5].map((t, i) => ({ label: tierLabel(t), n: tierCount[i], color: COLORS[i] }))),
|
|
1816
|
+
{ hint: 'Tier is how hard one question is: recall, mechanism, judgement, failure modes, design.' }),
|
|
1734
1817
|
].join('');
|
|
1735
1818
|
}
|
|
1736
1819
|
|
|
@@ -1738,35 +1821,25 @@ function viewDomain(name) {
|
|
|
1738
1821
|
const all = conceptsInScope().filter((c) => c.domain === name);
|
|
1739
1822
|
if (!all.length) return `<p class="empty">No concepts in <code>${esc(name)}</code> for this scope.
|
|
1740
1823
|
<a class="link" data-go="${esc(LH('domains'))}">Back to domains</a>.</p>`;
|
|
1741
|
-
|
|
1742
1824
|
const seen = all.filter((c) => c.seen);
|
|
1743
1825
|
const list = [...all].sort((a, b) => (a.seen === b.seen ? a.score - b.score : a.seen ? -1 : 1));
|
|
1744
1826
|
const p = page('domain', list.length);
|
|
1745
1827
|
const rows = slice(list, p).map((c) => `
|
|
1746
1828
|
<tr class="row" data-go="${esc(LH('concept', c.slug))}">
|
|
1747
|
-
<td style="min-width:220px"><span class="title">${esc(c.name)}</span><div class="slug">${esc(c.
|
|
1748
|
-
<td class="slug" style="white-space:nowrap">${esc(tierLabel(c.tier))}</td>
|
|
1829
|
+
<td style="min-width:220px"><span class="title">${esc(c.name)}</span><div class="slug">${esc(tierLabel(c.tier))}</div></td>
|
|
1749
1830
|
<td style="min-width:104px">${c.seen ? meter(c.score * 100, c.score < 0.5 ? 'warn' : '')
|
|
1750
1831
|
+ `<div class="slug">${Math.round(c.score * 100)}%</div>` : '<span class="slug">—</span>'}</td>
|
|
1751
1832
|
<td class="num">${c.attempts || '—'}</td>
|
|
1752
1833
|
<td>${statePill(c)}</td>
|
|
1753
|
-
<td class="ctx" style="min-width:200px">${esc(c.context ?? '')}</td>
|
|
1754
1834
|
</tr>`);
|
|
1755
|
-
|
|
1835
|
+
const back = `<a class="page__back" data-go="${esc(LH('domains'))}">← Domains</a>`;
|
|
1756
1836
|
return [
|
|
1757
|
-
|
|
1758
|
-
<a class="page__back" data-go="${esc(LH('domains'))}">← Domains</a>
|
|
1759
|
-
<h1 class="page__title">${esc(name)}</h1>
|
|
1760
|
-
<p class="page__sub">${plural(all.length, 'concept')} in the graph · ${seen.length} asked ·
|
|
1761
|
-
${seen.filter((c) => c.mastered).length} mastered.</p>
|
|
1762
|
-
</div>`,
|
|
1837
|
+
pageHead(esc(name), [plural(all.length, 'concept'), `${seen.length} asked`, `${seen.filter((c) => c.mastered).length} mastered`], '', back),
|
|
1763
1838
|
card(null, null, segbar([
|
|
1764
1839
|
{ label:'Mastered', n:seen.filter((c) => c.mastered).length, color:'var(--spot)' },
|
|
1765
1840
|
{ label:'Learning', n:seen.filter((c) => !c.mastered).length, color:'color-mix(in srgb, var(--spot) 45%, var(--mass))' },
|
|
1766
1841
|
{ label:'Not asked', n:all.length - seen.length, color:'var(--mass)' },
|
|
1767
|
-
])),
|
|
1768
|
-
card(null, null, table([{t:'Concept'},{t:'Tier'},{t:'Score'},{t:'Asked',n:1},{t:'State'},{t:'From your code'}], rows)
|
|
1769
|
-
+ pager('domain', list.length, p)),
|
|
1842
|
+
]) + `<div style="margin-top:var(--space-5)">${table([{t:'Concept'},{t:'Score'},{t:'Asked',n:1},{t:'State'}], rows)}${pager('domain', list.length, p)}</div>`),
|
|
1770
1843
|
].join('');
|
|
1771
1844
|
}
|
|
1772
1845
|
|
|
@@ -1881,12 +1954,10 @@ function memTiles() {
|
|
|
1881
1954
|
const { scoped, events, pending, entries, sessions } = memCounts();
|
|
1882
1955
|
const everywhere = scoped ? 'all projects · ' : '';
|
|
1883
1956
|
return tiles([
|
|
1884
|
-
['Observations', entries, scoped ?
|
|
1885
|
-
|
|
1886
|
-
['
|
|
1887
|
-
['
|
|
1888
|
-
['Reused', M.reused, `${everywhere}${M.exposed} actually delivered`, '', MH('reuse')],
|
|
1889
|
-
['Assessed', M.assessed, everywhere + 'evidence concepts you were asked about', '', LH('concepts')],
|
|
1957
|
+
['Observations', entries, scoped ? 'live in this project' : `${M.indexed} indexed`, '', MH('timeline')],
|
|
1958
|
+
['Sessions', sessions, 'captured or remembered', '', MH('sessions')],
|
|
1959
|
+
['Pending', pending, pending ? `of ${events} evidence events` : `all ${events} distilled`, pending ? 'warn' : '', MH('health')],
|
|
1960
|
+
['Reused', M.reused, `${everywhere}${M.exposed} delivered`, '', MH('reuse')],
|
|
1890
1961
|
]);
|
|
1891
1962
|
}
|
|
1892
1963
|
|
|
@@ -1920,11 +1991,11 @@ function viewMemory(param) {
|
|
|
1920
1991
|
const { entries, sessions, pending } = memCounts();
|
|
1921
1992
|
|
|
1922
1993
|
return [
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1994
|
+
pageHead('Timeline', [plural(entries, 'observation'), plural(sessions, 'session'),
|
|
1995
|
+
...(pending ? [`<a class="link warn" data-go="${esc(MH('health'))}">${pending} evidence pending</a>`] : [])],
|
|
1996
|
+
`What your sessions actually did, distilled into observations and kept with the raw evidence behind each
|
|
1997
|
+
one. Corrections supersede rather than overwrite and deletions are marked and struck through, so what
|
|
1998
|
+
you see is the audit trail and not a tidied version of it.`),
|
|
1928
1999
|
|
|
1929
2000
|
`<div>
|
|
1930
2001
|
<div class="filters">
|
|
@@ -1948,65 +2019,49 @@ function viewMemory(param) {
|
|
|
1948
2019
|
}
|
|
1949
2020
|
|
|
1950
2021
|
function viewMemoryDashboard() {
|
|
1951
|
-
const R = S.reuse, H = S.health, Q = H.queue;
|
|
2022
|
+
const R = S.reuse, H = S.health, Q = H.queue, M = S.memory;
|
|
1952
2023
|
const scope = () => memScope(new URLSearchParams());
|
|
1953
2024
|
const q6 = scope(); q6.set('per', '6');
|
|
1954
|
-
fill('mem-recent', '/api/memory?' + q6, (d) => d.rows.
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
<td class="slug">${esc(pname(e.project))}</td></tr>`))
|
|
1961
|
-
: '<p class="empty">Nothing remembered in this scope yet.</p>');
|
|
2025
|
+
fill('mem-recent', '/api/memory?' + q6, (d) => lines(d.rows.map((e) => ({
|
|
2026
|
+
t: `<span${e.deleted_at || e.superseded_by ? ' class="gone-title"' : ''}>${esc(e.title)}</span>`,
|
|
2027
|
+
s: `${esc(stamp(e.occurred_at))} · ${esc(e.type ?? e.kind)} · ${esc(pname(e.project))}`,
|
|
2028
|
+
v: entryMark(e),
|
|
2029
|
+
go: MH('entry', e.id),
|
|
2030
|
+
})), 'Nothing remembered in this scope yet.'));
|
|
1962
2031
|
const q5 = scope(); q5.set('per', '5');
|
|
1963
|
-
fill('mem-sessions', '/api/memory/sessions?' + q5, (d) => d.rows.
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
2032
|
+
fill('mem-sessions', '/api/memory/sessions?' + q5, (d) => lines(d.rows.map((m) => ({
|
|
2033
|
+
t: esc(pname(m.project, m.session_id)),
|
|
2034
|
+
s: `${m.events} captured · ${m.entries} remembered`,
|
|
2035
|
+
v: esc(ago(m.last)),
|
|
2036
|
+
go: MH('session', m.session_id),
|
|
2037
|
+
})), 'No session has captured anything in this scope.'));
|
|
2038
|
+
|
|
2039
|
+
// The pipeline in two lines: is reuse paying for itself, and is capture alive.
|
|
2040
|
+
// Each opens its page; the arithmetic lives there.
|
|
2041
|
+
const verdict = R.savings.kind === 'saving' ? `${R.savings.percent}% less context`
|
|
2042
|
+
: R.savings.kind === 'overhead' ? `+${R.savings.tokens} tokens of overhead` : 'no confirmed receipts yet';
|
|
2043
|
+
const stuck = Q.failed + Q.paused;
|
|
2044
|
+
const pipeline = lines([
|
|
2045
|
+
{ t: 'Context reuse', s: `${plural(R.receipts, 'receipt')}, ${R.confirmed} confirmed · estimated, all projects`,
|
|
2046
|
+
v: esc(verdict), go: MH('reuse') },
|
|
2047
|
+
{ t: 'Health', s: `last capture ${esc(H.capture.newest_event ? ago(H.capture.newest_event) : 'never')} · capture ${H.capture.enabled ? esc(H.capture.mode) : 'off'} · ${Q.pending} queued`,
|
|
2048
|
+
v: stuck ? `<span style="color:var(--warning)">${stuck} stalled</span>` : 'running', go: MH('health') },
|
|
2049
|
+
]);
|
|
1970
2050
|
|
|
1971
2051
|
return [
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
</div>`,
|
|
2052
|
+
pageHead('What does memory hold?', [
|
|
2053
|
+
`${M.processed} distilled`, `${M.indexed} indexed`, `${M.reused} reused`, `${M.exposed} delivered`, `${M.assessed} assessed`,
|
|
2054
|
+
], `Captured, processed, stored, retrieved, delivered, assessed — six different things that can happen to one
|
|
2055
|
+
piece of evidence, counted separately. None of them is an answer: only a question you answered moves
|
|
2056
|
+
mastery. Reuse, delivery and assessment are counted across all projects.`),
|
|
1978
2057
|
memTiles(),
|
|
2058
|
+
card('Pipeline', null, pipeline),
|
|
1979
2059
|
`<div class="g2">
|
|
1980
|
-
${card('
|
|
1981
|
-
`<
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
<div><b>Net</b><span>${R.base - R.delivered}</span></div>
|
|
1985
|
-
<div><b>Verdict</b><span>${R.savings.kind === 'saving' ? R.savings.percent + '%'
|
|
1986
|
-
: R.savings.kind === 'overhead' ? '+' + R.savings.tokens + ' tokens' : '—'}</span></div>
|
|
1987
|
-
</div>
|
|
1988
|
-
<p style="margin:var(--space-4) 0 0;color:var(--faint);font-size:13.5px">${esc(savingsText(R.savings, R.receipts))}
|
|
1989
|
-
Estimated with <code class="slug">${esc(R.estimator)}</code> on both sides of the subtraction —
|
|
1990
|
-
a reduction in context volume, not a bill.
|
|
1991
|
-
<a class="link" data-go="${esc(MH('reuse'))}">Every receipt →</a></p>`)}
|
|
1992
|
-
${card('Health', 'All projects. Where the pipeline stops, when it stops.',
|
|
1993
|
-
`<div class="kv">
|
|
1994
|
-
<div><b>Last capture</b><span>${esc(H.capture.newest_event ? ago(H.capture.newest_event) : 'never')}</span></div>
|
|
1995
|
-
<div><b>Capture</b><span>${H.capture.enabled ? esc(H.capture.mode) : 'off'}</span></div>
|
|
1996
|
-
<div><b>Queued</b><span>${Q.pending}</span></div>
|
|
1997
|
-
<div><b>Failed</b><span>${Q.failed}</span></div>
|
|
1998
|
-
</div>
|
|
1999
|
-
<p style="margin:var(--space-4) 0 0;color:var(--faint);font-size:13.5px">
|
|
2000
|
-
${S.memory.processed} distilled · ${S.memory.indexed} indexed · ${S.memory.reused} reused ·
|
|
2001
|
-
${S.memory.exposed} delivered · ${S.memory.assessed} assessed.
|
|
2002
|
-
<a class="link" data-go="${esc(MH('health'))}">Health →</a></p>`)}
|
|
2060
|
+
${card('Recent observations', null, `<div id="mem-recent"><p class="empty">Loading…</p></div>`,
|
|
2061
|
+
{ aside: `<a class="link" data-go="${esc(MH('timeline'))}">Timeline →</a>` })}
|
|
2062
|
+
${card('Recent sessions', null, `<div id="mem-sessions"><p class="empty">Loading…</p></div>`,
|
|
2063
|
+
{ aside: `<a class="link" data-go="${esc(MH('sessions'))}">All sessions →</a>` })}
|
|
2003
2064
|
</div>`,
|
|
2004
|
-
card('Recent observations', 'The newest things memory kept, in this scope.',
|
|
2005
|
-
`<div id="mem-recent"><p class="empty">Loading…</p></div>`,
|
|
2006
|
-
{ aside: `<a class="link" data-go="${esc(MH('timeline'))}">The timeline →</a>` }),
|
|
2007
|
-
card('Recent sessions', 'Sessions that captured evidence or wrote an observation.',
|
|
2008
|
-
`<div id="mem-sessions"><p class="empty">Loading…</p></div>`,
|
|
2009
|
-
{ aside: `<a class="link" data-go="${esc(MH('sessions'))}">All sessions →</a>` }),
|
|
2010
2065
|
].join('');
|
|
2011
2066
|
}
|
|
2012
2067
|
|
|
@@ -2018,28 +2073,25 @@ function viewMemorySessions() {
|
|
|
2018
2073
|
const learned = new Map(sessions().map((x) => [x.id, x]));
|
|
2019
2074
|
fill('msess-rows', '/api/memory/sessions?' + q, (d) => {
|
|
2020
2075
|
t.page = d.page;
|
|
2021
|
-
return
|
|
2076
|
+
return `<p class="counts" style="margin:0 0 var(--space-4)">${plural(d.total, 'session')} in scope</p>`
|
|
2077
|
+
+ table([{t:'Session'},{t:'Remembered',n:1},{t:'Asked',n:1},{t:'When',n:1}],
|
|
2022
2078
|
d.rows.map((m) => {
|
|
2023
2079
|
const l = learned.get(m.session_id);
|
|
2080
|
+
const sub = [`${m.events} captured`, m.pending ? `${m.pending} pending` : '', m.candidates ? `${m.candidates} proposed` : '']
|
|
2081
|
+
.filter(Boolean).join(' · ');
|
|
2024
2082
|
return `<tr class="row" data-go="${esc(MH('session', m.session_id))}">
|
|
2025
2083
|
<td style="min-width:240px"><span class="title">${esc(pname(m.project, m.session_id))}</span>
|
|
2026
|
-
<div class="slug">${
|
|
2027
|
-
<td class="num">${m.events}</td>
|
|
2028
|
-
<td class="num">${m.pending || '—'}</td>
|
|
2084
|
+
<div class="slug">${sub}</div></td>
|
|
2029
2085
|
<td class="num">${m.entries}</td>
|
|
2030
|
-
<td class="num">${m.candidates || '—'}</td>
|
|
2031
2086
|
<td class="num">${l && l.asked.length ? l.asked.length : '—'}</td>
|
|
2032
|
-
<td class="num slug">${esc(
|
|
2033
|
-
})) + pager('msessions', d.total, d.page, d.per)
|
|
2034
|
-
+ `<p class="slug" style="margin:var(--space-4) 0 0">${plural(d.total, 'session')} in scope.</p>`;
|
|
2087
|
+
<td class="num slug">${esc(ago(m.last))}</td></tr>`;
|
|
2088
|
+
})) + pager('msessions', d.total, d.page, d.per);
|
|
2035
2089
|
});
|
|
2036
2090
|
return [
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
a session can be remembered without ever being assessed.</p>
|
|
2042
|
-
</div>`,
|
|
2091
|
+
pageHead('Sessions', [],
|
|
2092
|
+
`One row per session that captured evidence or wrote an observation. Pending is evidence no observation
|
|
2093
|
+
job has processed yet. Asked is the same session's questions, joined on its session id — a session can
|
|
2094
|
+
be remembered without ever being assessed.`),
|
|
2043
2095
|
card(null, null, `<div id="msess-rows"><p class="empty">Loading sessions…</p></div>`),
|
|
2044
2096
|
].join('');
|
|
2045
2097
|
}
|
|
@@ -2048,29 +2100,25 @@ function viewMemoryProjects() {
|
|
|
2048
2100
|
const inv = projectsInScope();
|
|
2049
2101
|
const list = inv.filter((p) => p.memory.events || p.memory.entries || p.memory.receipts);
|
|
2050
2102
|
const none = inv.length - list.length;
|
|
2051
|
-
const rows = list.map((p) =>
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
<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>
|
|
2056
2108
|
<td class="num">${p.memory.entries}</td>
|
|
2057
2109
|
<td class="num">${p.memory.sessions}</td>
|
|
2058
|
-
<td class="num">${p.memory.
|
|
2059
|
-
|
|
2110
|
+
<td class="num slug">${esc(ago(p.memory.last))}</td></tr>`;
|
|
2111
|
+
});
|
|
2112
|
+
const pending = list.reduce((a, p) => a + p.memory.pending, 0);
|
|
2060
2113
|
return [
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
['Projects', list.length, 'with captured or stored memory'],
|
|
2068
|
-
['Evidence', list.reduce((a, p) => a + p.memory.events, 0), 'events captured'],
|
|
2069
|
-
['Pending', list.reduce((a, p) => a + p.memory.pending, 0), 'waiting to be processed'],
|
|
2070
|
-
['Observations', list.reduce((a, p) => a + p.memory.entries, 0), 'live entries'],
|
|
2071
|
-
]),
|
|
2114
|
+
pageHead('Projects', [
|
|
2115
|
+
plural(list.length, 'project'), plural(list.reduce((a, p) => a + p.memory.entries, 0), 'observation'),
|
|
2116
|
+
`${list.reduce((a, p) => a + p.memory.events, 0)} evidence events`,
|
|
2117
|
+
...(pending ? [`<span class="warn">${pending} pending</span>`] : []),
|
|
2118
|
+
], `Every project memory has recorded anything for — including evidence that has been captured and not yet
|
|
2119
|
+
processed into an observation. Open one to read its timeline.`),
|
|
2072
2120
|
card(null, null,
|
|
2073
|
-
table([{t:'Project'},{t:'
|
|
2121
|
+
table([{t:'Project'},{t:'Observations',n:1},{t:'Sessions',n:1},{t:'Last capture',n:1}], rows)
|
|
2074
2122
|
+ (none ? `<p class="slug" style="margin:var(--space-4) 0 0">${plural(none, 'project')} in scope ${none === 1 ? 'has' : 'have'} learning activity and no memory —
|
|
2075
2123
|
<a class="link" data-go="${esc(LH('projects'))}">see Learning → Projects</a>.</p>` : '')),
|
|
2076
2124
|
].join('');
|
|
@@ -2082,7 +2130,6 @@ function drawEntry(d) {
|
|
|
2082
2130
|
const facts = jsonList(e.facts);
|
|
2083
2131
|
const files = jsonList(e.files);
|
|
2084
2132
|
const link = (x) => `<a class="link" data-go="${esc(MH('entry', x.id))}">${esc(x.title)}</a> <span class="slug">${esc(fmtDate(x.occurred_at))}</span>`;
|
|
2085
|
-
|
|
2086
2133
|
const evidence = d.events.map((ev) => `
|
|
2087
2134
|
<details class="qa">
|
|
2088
2135
|
<summary class="qa__meta">
|
|
@@ -2094,63 +2141,53 @@ function drawEntry(d) {
|
|
|
2094
2141
|
<pre class="raw">${esc(ev.body)}${ev.body_length > String(ev.body ?? '').length ? '\n… truncated' : ''}</pre>
|
|
2095
2142
|
${jsonList(ev.files).length ? `<div class="tags" style="margin-top:var(--space-3)">${jsonList(ev.files).map((f) => `<span class="pill">${esc(f)}</span>`).join('')}</div>` : ''}
|
|
2096
2143
|
</details>`).join('');
|
|
2097
|
-
|
|
2098
2144
|
return [
|
|
2099
2145
|
`<div>
|
|
2100
2146
|
<p class="page__eyebrow">${esc(e.type ?? e.kind)}</p>
|
|
2101
2147
|
<h1 class="page__title${gone ? ' gone-title' : ''}">${esc(e.title)}</h1>
|
|
2102
|
-
<p class="
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
${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>
|
|
2106
2153
|
</div>`,
|
|
2107
|
-
|
|
2108
2154
|
e.deleted_at
|
|
2109
2155
|
? `<div class="banner"><p><strong>Deleted ${esc(ago(e.deleted_at))}.</strong> It is kept here so the record
|
|
2110
2156
|
stays checkable, and it is excluded from search and recall.</p></div>` : '',
|
|
2111
2157
|
d.replaced_by
|
|
2112
2158
|
? `<div class="banner"><p><strong>Superseded.</strong> A later observation corrected this one:
|
|
2113
2159
|
${link(d.replaced_by)}. Recall prefers the replacement; this row is the audit trail.</p></div>` : '',
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
['Confidence', e.confidence == null ? '—' : Math.round(e.confidence * 100) + '%', 'the summariser’s own, not a score'],
|
|
2117
|
-
['Evidence', d.events.length, 'raw events behind this claim'],
|
|
2118
|
-
['Candidates', d.candidates.length, 'concepts it proposed'],
|
|
2119
|
-
['Reused', d.receipts.length, d.receipts.length ? 'times it was selected for context' : 'never selected yet', '', MH('reuse')],
|
|
2120
|
-
]),
|
|
2121
|
-
|
|
2122
|
-
card('What happened', 'The observation, as written.',
|
|
2123
|
-
`<p class="qa__q">${esc(e.narrative) || '<span class="empty">No narrative.</span>'}</p>
|
|
2160
|
+
card('What happened', null,
|
|
2161
|
+
`<p class="qa__q" style="margin:0">${esc(e.narrative) || '<span class="empty">No narrative.</span>'}</p>
|
|
2124
2162
|
${facts.length ? `<ul class="facts">${facts.map((f) => `<li>${esc(f)}</li>`).join('')}</ul>` : ''}
|
|
2125
2163
|
${files.length ? `<div class="tags" style="margin-top:var(--space-4)">${files.map((f) => `<span class="pill">${esc(f)}</span>`).join('')}</div>` : ''}
|
|
2126
|
-
${d.tags.length ? `<div class="tags" style="margin-top:var(--space-3)">${tagPills(d.tags)}</div>` : ''}
|
|
2127
|
-
|
|
2128
|
-
d.candidates.length
|
|
2129
|
-
? card('Concepts it proposed', 'A candidate is a proposal, not a concept — and never a passing answer. Only a question you answered moves mastery.',
|
|
2130
|
-
table([{t:'Slug'},{t:'Domain'},{t:'Confidence',n:1},{t:'Status'}], d.candidates.map((c) => {
|
|
2131
|
-
const go = c.concept_slug ? ` class="row" data-go="${esc(LH('concept', c.concept_slug))}"` : '';
|
|
2132
|
-
return `<tr${go}><td><span class="title">${esc(c.name ?? c.slug)}</span><div class="slug">${esc(c.slug)}</div></td>
|
|
2133
|
-
<td><span class="pill">${esc(c.domain ?? '—')}</span></td>
|
|
2134
|
-
<td class="num">${Math.round((c.confidence ?? 0) * 100)}%</td>
|
|
2135
|
-
<td><span class="pill${c.status === 'accepted' ? ' ok' : ''}">${esc(c.status)}</span></td></tr>`;
|
|
2136
|
-
})))
|
|
2137
|
-
: '',
|
|
2138
|
-
|
|
2164
|
+
${d.tags.length ? `<div class="tags" style="margin-top:var(--space-3)">${tagPills(d.tags)}</div>` : ''}
|
|
2165
|
+
<p class="slug" style="margin:var(--space-4) 0 0">Written by <code>${esc(e.generator)}</code>${e.import_source ? ` · imported from ${esc(e.import_source)}` : ''}.</p>`),
|
|
2139
2166
|
d.replaces.length
|
|
2140
2167
|
? card('What this replaced', 'Earlier claims this observation corrected.',
|
|
2141
2168
|
`<div class="list">${d.replaces.map((x) => `<div>${link(x)}</div>`).join('')}</div>`)
|
|
2142
2169
|
: '',
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2170
|
+
fold('entry:evidence', 'The raw evidence', plural(d.events.length, 'event'),
|
|
2171
|
+
evidence || '<p class="empty">No evidence is linked — this entry was written by hand or imported.</p>',
|
|
2172
|
+
{ hint: 'Every event this was distilled from, exactly as captured. Open one to read it.' }),
|
|
2173
|
+
d.candidates.length
|
|
2174
|
+
? fold('entry:cands', 'Concepts it proposed', plural(d.candidates.length, 'candidate'),
|
|
2175
|
+
lines(d.candidates.map((c) => ({
|
|
2176
|
+
t: esc(c.name ?? c.slug),
|
|
2177
|
+
s: `${esc(c.slug)} · ${esc(c.domain ?? '—')} · ${Math.round((c.confidence ?? 0) * 100)}% confidence`,
|
|
2178
|
+
v: `<span class="pill${c.status === 'accepted' ? ' ok' : ''}">${esc(c.status)}</span>`,
|
|
2179
|
+
go: c.concept_slug ? LH('concept', c.concept_slug) : null,
|
|
2180
|
+
}))),
|
|
2181
|
+
{ hint: 'A candidate is a proposal, not a concept — and never a passing answer. Only a question you answered moves mastery.' })
|
|
2182
|
+
: '',
|
|
2147
2183
|
d.receipts.length
|
|
2148
|
-
?
|
|
2149
|
-
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'}],
|
|
2150
2186
|
d.receipts.map((r) => `<tr><td class="slug">${esc(stamp(r.created_at))}</td>
|
|
2151
|
-
<td>${esc(r.scope)}
|
|
2187
|
+
<td>${esc(r.scope)}<div class="slug">${esc(r.stage)}</div></td>
|
|
2152
2188
|
<td class="num">${r.source_tokens}</td><td class="num">${r.sent_tokens}</td>
|
|
2153
|
-
<td>${deliveryPill(r.delivery)}</td></tr>`))
|
|
2189
|
+
<td>${deliveryPill(r.delivery)}</td></tr>`)),
|
|
2190
|
+
{ hint: 'Each time this entry was selected for a context injection.' })
|
|
2154
2191
|
: '',
|
|
2155
2192
|
].join('');
|
|
2156
2193
|
}
|
|
@@ -2171,7 +2208,6 @@ function viewReuse() {
|
|
|
2171
2208
|
const net = R.base - R.delivered;
|
|
2172
2209
|
const verdict = R.savings.kind === 'saving' ? R.savings.percent + '%'
|
|
2173
2210
|
: R.savings.kind === 'overhead' ? '+' + R.savings.tokens : '—';
|
|
2174
|
-
|
|
2175
2211
|
const body = slice(rows, p).map((r) => {
|
|
2176
2212
|
// A saving is only a saving when the delivery was confirmed. An unknown or
|
|
2177
2213
|
// prepared receipt has a B and a D but no verdict, and printing one would
|
|
@@ -2179,42 +2215,32 @@ function viewReuse() {
|
|
|
2179
2215
|
const saved = r.delivery === 'confirmed' && r.base_tokens > 0
|
|
2180
2216
|
? Math.round((100 * (r.base_tokens - r.delivered_tokens)) / r.base_tokens) + '%'
|
|
2181
2217
|
: '—';
|
|
2218
|
+
const sub = [esc(pname(r.project)), plural(r.item_count, 'item'), r.detail_tokens ? `+${r.detail_tokens} detail` : '', esc(r.method)]
|
|
2219
|
+
.filter(Boolean).join(' · ');
|
|
2182
2220
|
return `<tr>
|
|
2183
2221
|
<td class="slug" style="white-space:nowrap">${esc(stamp(r.created_at))}</td>
|
|
2184
|
-
<td><span class="title">${esc(r.scope)}</span><div class="slug">${
|
|
2222
|
+
<td style="min-width:200px"><span class="title">${esc(r.scope)}</span><div class="slug">${sub}</div></td>
|
|
2185
2223
|
<td class="num">${r.base_tokens}</td>
|
|
2186
2224
|
<td class="num">${r.delivered_tokens}</td>
|
|
2187
2225
|
<td class="num">${saved}</td>
|
|
2188
|
-
<td class="num">${r.item_count}</td>
|
|
2189
|
-
<td class="num">${r.detail_tokens ? '+' + r.detail_tokens : '—'}</td>
|
|
2190
2226
|
<td>${deliveryPill(r.delivery)}</td>
|
|
2191
|
-
<td class="slug">${esc(r.method)}</td>
|
|
2192
2227
|
</tr>`;
|
|
2193
2228
|
});
|
|
2194
|
-
|
|
2195
2229
|
return [
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2230
|
+
pageHead('Context reuse', [
|
|
2231
|
+
`${R.confirmed} of ${plural(R.receipts, 'receipt')} confirmed`, `net ${net} tokens`, `estimated with <code class="slug">${esc(R.estimator)}</code>`,
|
|
2232
|
+
], `<strong>B</strong> is what the same material would have cost to read from source. <strong>D</strong> is
|
|
2233
|
+
what was actually delivered instead. Both are counted with the same estimator, and the percentage is an
|
|
2234
|
+
estimate of how much <em>context volume</em> was avoided — not a bill, not a price, and not a measure of
|
|
2235
|
+
model spend. Receipts whose delivery is <code>unknown</code> or <code>prepared</code> are listed in the
|
|
2236
|
+
ledger but never counted in the verdict: an injection that may not have reached the model is not a saving.`),
|
|
2204
2237
|
tiles([
|
|
2238
|
+
['Verdict', verdict, savingsText(R.savings, R.receipts), R.savings.kind === 'saving' ? 'good' : R.savings.kind === 'overhead' ? 'warn' : ''],
|
|
2205
2239
|
['B — from source', R.base, 'estimated tokens, confirmed receipts'],
|
|
2206
2240
|
['D — delivered', R.delivered, 'estimated tokens actually injected'],
|
|
2207
|
-
['Net', net, net >= 0 ? 'fewer tokens of context' : 'more tokens than reading source', net < 0 ? 'warn' : ''],
|
|
2208
|
-
['Verdict', verdict, 'estimated, all projects', R.savings.kind === 'saving' ? 'good' : R.savings.kind === 'overhead' ? 'warn' : ''],
|
|
2209
|
-
['Receipts', R.receipts, `${R.confirmed} confirmed of ${R.receipts}`],
|
|
2210
2241
|
]),
|
|
2211
|
-
|
|
2212
|
-
`<div class="banner"><p>${esc(savingsText(R.savings, R.receipts))}
|
|
2213
|
-
Receipts whose delivery is <code>unknown</code> or <code>prepared</code> are counted in the ledger below
|
|
2214
|
-
but never in the verdict — an injection that may not have reached the model is not a saving.</p></div>`,
|
|
2215
|
-
|
|
2216
2242
|
card('Every receipt', `${plural(rows.length, 'receipt')} in scope. One row per injection.`,
|
|
2217
|
-
table([{t:'When'},{t:'Scope'},{t:'B',n:1},{t:'D',n:1},{t:'Saved',n:1},{t:'
|
|
2243
|
+
table([{t:'When'},{t:'Scope'},{t:'B',n:1},{t:'D',n:1},{t:'Saved',n:1},{t:'Delivery'}], body)
|
|
2218
2244
|
+ pager('reuse', rows.length, p)
|
|
2219
2245
|
+ (R.receipts > R.receipts_shown
|
|
2220
2246
|
? `<p class="slug" style="margin:var(--space-4) 0 0">Showing the most recent ${R.receipts_shown} of ${R.receipts} receipts. The totals above cover all of them.</p>`
|
|
@@ -2235,61 +2261,63 @@ function viewHealth() {
|
|
|
2235
2261
|
['Assessed', M.assessed, 'concepts you were actually asked about'],
|
|
2236
2262
|
];
|
|
2237
2263
|
const oldest = Q.oldest ? ago(Q.oldest) : '—';
|
|
2238
|
-
|
|
2264
|
+
// What needs the reader, said once, in words: the tiles below are the numbers.
|
|
2265
|
+
const problems = [
|
|
2266
|
+
!H.capture.enabled ? 'memory is disabled' : !H.capture.newest_event ? 'nothing has been captured yet' : '',
|
|
2267
|
+
Q.failed ? `${plural(Q.failed, 'job')} failed after retries` : '',
|
|
2268
|
+
Q.paused ? `${plural(Q.paused, 'job')} paused, waiting on a decision` : '',
|
|
2269
|
+
H.spool_dropped ? `${H.spool_dropped} events dropped by the spool` : '',
|
|
2270
|
+
].filter(Boolean);
|
|
2239
2271
|
return [
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2272
|
+
pageHead('Health', [
|
|
2273
|
+
`last capture ${esc(H.capture.newest_event ? ago(H.capture.newest_event) : 'never')}`,
|
|
2274
|
+
H.capture.enabled ? `capture is ${esc(H.capture.mode)}` : 'memory off',
|
|
2275
|
+
`${Q.pending} queued${Q.pending ? `, oldest ${esc(oldest)}` : ''}`,
|
|
2276
|
+
], `Where memory stops, when it stops. Capture, processing, indexing, retrieval and delivery fail in
|
|
2277
|
+
different ways and this page keeps them apart. Error classes only — no log dumps, no keys, no raw
|
|
2278
|
+
request bodies. Transient failures retry on their own; auth, quota and missing need you.`),
|
|
2279
|
+
problems.length
|
|
2280
|
+
? nextStep(`<strong>Needs a look:</strong> ${problems.map(esc).join(' · ')}.`)
|
|
2281
|
+
: nextStep('<strong>Capture is running.</strong> No job has failed or paused.', true),
|
|
2247
2282
|
tiles([
|
|
2248
|
-
['Last capture', H.capture.newest_event ? ago(H.capture.newest_event) : 'never',
|
|
2249
|
-
H.capture.enabled ? `capture is ${H.capture.mode}` : 'memory is disabled',
|
|
2250
|
-
H.capture.enabled && H.capture.newest_event ? '' : 'warn'],
|
|
2251
2283
|
['Queued', Q.pending, Q.pending ? `oldest ${oldest}` : 'nothing waiting', Q.pending ? 'warn' : 'good'],
|
|
2252
2284
|
['Paused', Q.paused, 'waiting on a decision', Q.paused ? 'warn' : ''],
|
|
2253
2285
|
['Failed', Q.failed, 'gave up after retries', Q.failed ? 'warn' : ''],
|
|
2254
|
-
['Dropped', H.spool_dropped, 'events the spool
|
|
2286
|
+
['Dropped', H.spool_dropped, 'events the spool lost', H.spool_dropped ? 'warn' : ''],
|
|
2255
2287
|
]),
|
|
2256
|
-
|
|
2288
|
+
H.stalled.length
|
|
2289
|
+
? card('Stalled jobs', 'Grouped by why they stopped.',
|
|
2290
|
+
table([{t:'Status'},{t:'Why'},{t:'Jobs',n:1},{t:'Attempts',n:1},{t:'Last',n:1}],
|
|
2291
|
+
H.stalled.map((j) => `<tr>
|
|
2292
|
+
<td><span class="pill due">${esc(j.status)}</span></td>
|
|
2293
|
+
<td><code class="slug">${esc(j.error_class)}</code></td>
|
|
2294
|
+
<td class="num">${j.n}</td><td class="num">${j.attempts}</td>
|
|
2295
|
+
<td class="num slug">${esc(ago(j.last))}</td></tr>`)))
|
|
2296
|
+
: '',
|
|
2257
2297
|
card('Captured is not assessed', 'Each step is a place the pipeline can stop. A number that falls off between two rows is where to look.',
|
|
2258
|
-
`<div class="checks">` + ladder.map(([label, n, note]) => `
|
|
2298
|
+
`<div class="checks" style="margin-top:0">` + ladder.map(([label, n, note]) => `
|
|
2259
2299
|
<div><span class="${n ? 'mark-ok' : 'mark-no'}">${n ? '✓' : '○'}</span>
|
|
2260
2300
|
<span style="min-width:110px">${esc(label)}</span>
|
|
2261
2301
|
<span class="bar">${meter(pct(n, ladder[0][1] || 1), n ? '' : 'mute')}</span>
|
|
2262
2302
|
<span class="slug" style="white-space:nowrap">${n} — ${esc(note)}</span></div>`).join('') + `</div>`),
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
your subscription.</p>`)}
|
|
2282
|
-
</div>`,
|
|
2283
|
-
|
|
2284
|
-
card('Stalled jobs', 'Grouped by why they stopped. Transient retries on its own; auth, quota and missing need you.',
|
|
2285
|
-
H.stalled.length
|
|
2286
|
-
? table([{t:'Status'},{t:'Why'},{t:'Jobs',n:1},{t:'Attempts',n:1},{t:'Last',n:1}],
|
|
2287
|
-
H.stalled.map((j) => `<tr>
|
|
2288
|
-
<td><span class="pill due">${esc(j.status)}</span></td>
|
|
2289
|
-
<td><code class="slug">${esc(j.error_class)}</code></td>
|
|
2290
|
-
<td class="num">${j.n}</td><td class="num">${j.attempts}</td>
|
|
2291
|
-
<td class="num slug">${esc(ago(j.last))}</td></tr>`))
|
|
2292
|
-
: '<p class="empty">No job has failed or paused.</p>'),
|
|
2303
|
+
fold('health:capture', 'Capture settings', `${M.redacted} redacted · ${H.capture.retention_days ? plural(H.capture.retention_days, 'day') + ' retention' : 'kept until deleted'}`,
|
|
2304
|
+
`<div class="kv">
|
|
2305
|
+
<div><b>Memory</b><span>${H.capture.enabled ? 'on' : 'off'}</span></div>
|
|
2306
|
+
<div><b>Capture mode</b><span>${esc(H.capture.mode)}</span></div>
|
|
2307
|
+
<div><b>Newest event</b><span>${esc(H.capture.newest_event ? stamp(H.capture.newest_event) : 'never')}</span></div>
|
|
2308
|
+
<div><b>Redacted</b><span>${M.redacted}</span></div>
|
|
2309
|
+
<div><b>Retention</b><span>${H.capture.retention_days ? plural(H.capture.retention_days, 'day') : 'kept until deleted'}</span></div>
|
|
2310
|
+
<div><b>Last prune</b><span>${esc(H.capture.pruned_at ? ago(H.capture.pruned_at) : 'never')}</span></div>
|
|
2311
|
+
</div>`,
|
|
2312
|
+
{ hint: 'What the hooks are allowed to record, and how long it is kept.' }),
|
|
2313
|
+
fold('health:providers', 'Providers', esc(H.providers.retrieval_mode),
|
|
2314
|
+
`<div class="kv">
|
|
2315
|
+
<div><b>Observer</b><span>${H.providers.observer ? esc(H.providers.observer.kind + ' · ' + H.providers.observer.model) : 'local summariser'}</span></div>
|
|
2316
|
+
<div><b>Embeddings</b><span>${H.providers.embeddings ? esc(H.providers.embeddings.kind + ' · ' + H.providers.embeddings.model) : 'local embedder'}</span></div>
|
|
2317
|
+
<div><b>Retrieval</b><span>${esc(H.providers.retrieval_mode)}</span></div>
|
|
2318
|
+
</div>
|
|
2319
|
+
<p class="slug" style="margin:var(--space-4) 0 0">A configured model runs through Claude Code on your subscription.</p>`,
|
|
2320
|
+
{ hint: 'Nothing leaves this machine unless a provider is configured here.' }),
|
|
2293
2321
|
].join('');
|
|
2294
2322
|
}
|
|
2295
2323
|
|
|
@@ -2347,6 +2375,7 @@ function viewArtifacts(param) {
|
|
|
2347
2375
|
}
|
|
2348
2376
|
const counts = { all: all.length, explainer: all.filter((a) => a.kind === 'explainer').length };
|
|
2349
2377
|
counts.artifact = all.length - counts.explainer;
|
|
2378
|
+
const projects = new Set(all.map((a) => pid(a.project))).size;
|
|
2350
2379
|
const chip = (k, label) => `<button class="chip" data-go="${esc(AH('dashboard', k === 'all' ? null : k))}" aria-pressed="${kind === k}">${label} ${counts[k]}</button>`;
|
|
2351
2380
|
const cols = [{t:'Page'},{t:'Kind'},{t:'Concept'},{t:'Written',n:1}];
|
|
2352
2381
|
const body = !list.length
|
|
@@ -2355,19 +2384,14 @@ function viewArtifacts(param) {
|
|
|
2355
2384
|
`<h3 class="slug" style="margin:var(--space-5) 0 var(--space-2)">${esc(projectById(id)?.name ?? (id === '~' ? 'no project' : base(id)))}</h3>`
|
|
2356
2385
|
+ table(cols, rows.map(artifactRow))).join('') + pager('artifacts', list.length, p);
|
|
2357
2386
|
return [
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
tiles([
|
|
2364
|
-
['Artifacts', all.length, 'in scope'],
|
|
2365
|
-
['Explainers', counts.explainer, 'written after a question', null, counts.explainer ? AH('dashboard', 'explainer') : null],
|
|
2366
|
-
['Projects', new Set(all.map((a) => pid(a.project))).size, 'with at least one page', null, AH('projects')],
|
|
2367
|
-
]),
|
|
2387
|
+
pageHead('Artifacts', [
|
|
2388
|
+
plural(all.length, 'page'), plural(counts.explainer, 'explainer'),
|
|
2389
|
+
`<a class="link" data-go="${esc(AH('projects'))}">${plural(projects, 'project')}</a>`,
|
|
2390
|
+
], `Pages your agent wrote for you — explainers after a missed question, and anything you asked to have
|
|
2391
|
+
turned into a page. Each opens in a new tab, from <code>~/.eklavya/artifacts/</code>.`),
|
|
2368
2392
|
card(null, null,
|
|
2369
2393
|
`<div class="filters">
|
|
2370
|
-
<div class="grow"><input type="search" id="aq" value="${esc(t.q)}" placeholder="Search
|
|
2394
|
+
<div class="grow"><input type="search" id="aq" value="${esc(t.q)}" placeholder="Search artifacts" aria-label="Search artifacts"></div>
|
|
2371
2395
|
</div>
|
|
2372
2396
|
<div class="chips">${chip('all','All')}${chip('explainer','Explainers')}${chip('artifact','Other pages')}</div>
|
|
2373
2397
|
<div style="margin-top:var(--space-4)">${body}</div>`),
|
|
@@ -2394,11 +2418,9 @@ function viewArtifactProjects() {
|
|
|
2394
2418
|
<td class="num slug">${esc(ago(r.last))}</td></tr>`;
|
|
2395
2419
|
});
|
|
2396
2420
|
return [
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
Open one to see only its pages.</p>
|
|
2401
|
-
</div>`,
|
|
2421
|
+
pageHead('Projects', [plural(by.size, 'project')],
|
|
2422
|
+
`Artifacts are filed by the project they were written in — the main checkout, for a worktree. Open one
|
|
2423
|
+
to see only its pages.`),
|
|
2402
2424
|
card(null, null, table([{t:'Project'},{t:'Pages',n:1},{t:'Explainers',n:1},{t:'Last written',n:1}], rows)),
|
|
2403
2425
|
].join('');
|
|
2404
2426
|
}
|
|
@@ -2862,6 +2884,7 @@ function render(keepFocus) {
|
|
|
2862
2884
|
document.title = `${label} · ${W.name} — Eklavya`;
|
|
2863
2885
|
|
|
2864
2886
|
AFTER.length = 0;
|
|
2887
|
+
LAZY.clear();
|
|
2865
2888
|
$('view').innerHTML = !pg ? viewNotFound('#/' + CUR.wf + '/' + CUR.page, false)
|
|
2866
2889
|
: CUR.bad ? viewNotFound(CUR.param, true)
|
|
2867
2890
|
: pg.view(CUR.param);
|
|
@@ -2926,6 +2949,22 @@ $('view').addEventListener('keydown', (e) => {
|
|
|
2926
2949
|
onActivate(e);
|
|
2927
2950
|
});
|
|
2928
2951
|
|
|
2952
|
+
// A fold remembers being opened or closed, and draws the charts it was hiding.
|
|
2953
|
+
// `toggle` does not bubble, so this listens in the capture phase.
|
|
2954
|
+
$('view').addEventListener('toggle', (e) => {
|
|
2955
|
+
const d = e.target;
|
|
2956
|
+
if (!(d instanceof HTMLDetailsElement)) return;
|
|
2957
|
+
if (d.dataset.fold) {
|
|
2958
|
+
FOLD[d.dataset.fold] = d.open;
|
|
2959
|
+
try { localStorage.setItem(FOLD_KEY, JSON.stringify(FOLD)); } catch (err) { /* defaults next time */ }
|
|
2960
|
+
}
|
|
2961
|
+
if (!d.open) return;
|
|
2962
|
+
for (const [id, fn] of LAZY) {
|
|
2963
|
+
const el = $(id);
|
|
2964
|
+
if (el && d.contains(el) && !el.closest('details:not([open])')) { LAZY.delete(id); fn(el); }
|
|
2965
|
+
}
|
|
2966
|
+
}, true);
|
|
2967
|
+
|
|
2929
2968
|
$('view').addEventListener('input', (e) => {
|
|
2930
2969
|
if (e.target.id === 'q') {
|
|
2931
2970
|
T.concepts.q = e.target.value;
|