eklavya 1.44.1 → 1.46.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.
Files changed (62) hide show
  1. package/README.md +15 -2
  2. package/dist/artifacts.js +26 -0
  3. package/dist/artifacts.js.map +1 -1
  4. package/dist/assets/artifact-template.html +21 -0
  5. package/dist/assets/dashboard.html +268 -41
  6. package/dist/assets/tutor/SKILL.md +2 -1
  7. package/dist/assets/tutor/references/writing-mcq.md +11 -1
  8. package/dist/cli-memory.js +8 -3
  9. package/dist/cli-memory.js.map +1 -1
  10. package/dist/cli.js +44 -12
  11. package/dist/cli.js.map +1 -1
  12. package/dist/concurrency.js +1 -1
  13. package/dist/concurrency.js.map +1 -1
  14. package/dist/dashboard.js +169 -55
  15. package/dist/dashboard.js.map +1 -1
  16. package/dist/hooks/checkpoint-quiz.js +3 -2
  17. package/dist/hooks/checkpoint-quiz.js.map +1 -1
  18. package/dist/hooks/file-context.js +60 -26
  19. package/dist/hooks/file-context.js.map +1 -1
  20. package/dist/hooks/memory-lib.js +3 -5
  21. package/dist/hooks/memory-lib.js.map +1 -1
  22. package/dist/hooks/prompt-submit-nudge.js +19 -14
  23. package/dist/hooks/prompt-submit-nudge.js.map +1 -1
  24. package/dist/hooks/session-start.js +21 -7
  25. package/dist/hooks/session-start.js.map +1 -1
  26. package/dist/hooks/stop-quiz-check.js +3 -2
  27. package/dist/hooks/stop-quiz-check.js.map +1 -1
  28. package/dist/hooks/subagent-start.js +39 -8
  29. package/dist/hooks/subagent-start.js.map +1 -1
  30. package/dist/memory/recall.js +71 -22
  31. package/dist/memory/recall.js.map +1 -1
  32. package/dist/memory/store.js +40 -6
  33. package/dist/memory/store.js.map +1 -1
  34. package/dist/memory/tokens.js +16 -1
  35. package/dist/memory/tokens.js.map +1 -1
  36. package/dist/memory/worker.js +3 -1
  37. package/dist/memory/worker.js.map +1 -1
  38. package/dist/migrations/018_memory_reads.sql +26 -0
  39. package/dist/migrations/019_attempt_corrections.sql +41 -0
  40. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  41. package/dist/plugin/agents/explainer.md +9 -5
  42. package/dist/plugin/hooks/CLAUDE.md +2 -2
  43. package/dist/plugin/skills/memory/SKILL.md +5 -3
  44. package/dist/plugin/skills/tutor/SKILL.md +2 -1
  45. package/dist/plugin/skills/tutor/references/writing-mcq.md +11 -1
  46. package/dist/relocate.js +1 -0
  47. package/dist/relocate.js.map +1 -1
  48. package/dist/store.js +142 -14
  49. package/dist/store.js.map +1 -1
  50. package/dist/telemetry-send.js +17 -13
  51. package/dist/telemetry-send.js.map +1 -1
  52. package/dist/tools/get_learner_profile.js +5 -3
  53. package/dist/tools/get_learner_profile.js.map +1 -1
  54. package/dist/tools/get_session_quiz_plan.js +1 -1
  55. package/dist/tools/get_session_quiz_plan.js.map +1 -1
  56. package/dist/tools/memory_read_tools.js +71 -15
  57. package/dist/tools/memory_read_tools.js.map +1 -1
  58. package/dist/tools/record_attempt.js +48 -8
  59. package/dist/tools/record_attempt.js.map +1 -1
  60. package/dist/user-skill/eklavya/SKILL.md +7 -3
  61. package/dist/user-skill/eklavya-artifacts/SKILL.md +10 -3
  62. package/package.json +1 -1
@@ -262,6 +262,53 @@
262
262
  .art__thumb { display:block; aspect-ratio:16/10; background:var(--panel); border:1px solid var(--line-2); padding:var(--space-3); }
263
263
  .art__open:hover .art__thumb { border-color:var(--spot); }
264
264
  .art__thumb img { display:block; width:100%; height:100%; object-fit:contain; }
265
+ .tag { font-family:var(--font-mono); font-size:11px; letter-spacing:var(--tracking-caps); text-transform:uppercase; }
266
+ .tag.warn { color:var(--warning); }
267
+ .tag.ok { color:var(--spot); }
268
+
269
+ /* The explainer viewer: the agent's page, framed sandboxed, then this page's
270
+ own correction bar. The frame takes the page's reported height, so the two
271
+ scroll as one document; with no report it stays 80vh and scrolls inside. */
272
+ .viewer { display:block; width:100%; height:80vh; border:0; background:var(--bg); }
273
+ .fixbar { display:flex; flex-direction:column; align-items:flex-start; gap:var(--space-2);
274
+ padding:var(--space-5); border:1px solid var(--line-2); background:var(--panel); }
275
+ .fixbar p { margin:0; }
276
+ .fixbar__note { color:var(--dim); font-size:13px; }
277
+ .fixbar__done { display:flex; align-items:center; gap:var(--space-2); color:var(--ink); }
278
+ .fixbar__done svg { width:16px; height:16px; color:var(--spot); flex:none; }
279
+ .btn { font-family:var(--font-mono); font-size:12px; letter-spacing:.06em; text-transform:uppercase;
280
+ background:var(--spot); color:var(--spot-ink); border:1px solid var(--spot); padding:10px 16px; cursor:pointer;
281
+ transition:background var(--duration-fast) var(--ease), color var(--duration-fast) var(--ease); }
282
+ .btn:hover { background:var(--mass); color:var(--spot); }
283
+ .btn:focus-visible, .fix__opt:focus-visible, .set__btn:focus-visible { outline:none; box-shadow:var(--ring); }
284
+
285
+ /* The correction modal: a native <dialog>, so focus, Escape and the top
286
+ layer come with it. Square, hairline, no shadow; the backdrop blurs. */
287
+ dialog.fix { width:min(560px, calc(100vw - 32px)); max-height:calc(100vh - 32px); overflow:auto; margin:auto;
288
+ padding:var(--space-6); border:1px solid var(--line-2); border-radius:0; background:var(--panel); color:var(--ink); }
289
+ dialog.fix::backdrop { background:color-mix(in srgb, var(--ink) 40%, transparent); backdrop-filter:blur(6px); }
290
+ dialog.fix[open] { animation:fix-in 180ms var(--ease); }
291
+ @keyframes fix-in { from { opacity:0; transform:translateY(8px); } }
292
+ .fix__eyebrow { margin:0 0 var(--space-3); font:400 11px/1 var(--font-mono); letter-spacing:var(--tracking-caps-wide);
293
+ text-transform:uppercase; color:var(--spot); }
294
+ .fix__q { margin:0 0 var(--space-5); font:800 clamp(20px,3vw,26px)/1.15 var(--font-disp); letter-spacing:var(--tracking-tight); }
295
+ .fix__opts { display:flex; flex-direction:column; gap:var(--space-2); }
296
+ .fix__opt { display:flex; flex-direction:column; gap:2px; text-align:left; width:100%; padding:10px 14px;
297
+ border:1px solid var(--line-2); background:var(--bg); color:var(--ink); font:inherit; cursor:pointer; }
298
+ .fix__opt:hover:not(:disabled) { border-color:var(--spot); }
299
+ .fix__opt:disabled { cursor:default; }
300
+ .fix__n { color:var(--dim); font-size:13px; }
301
+ .fix__tag { font:400 11px var(--font-mono); letter-spacing:var(--tracking-caps); text-transform:uppercase; }
302
+ .fix__tag:empty { display:none; }
303
+ .fix__opt.is-wrong { border-color:var(--error); }
304
+ .fix__opt.is-wrong .fix__tag { color:var(--error); }
305
+ .fix__opt.is-right { border-color:var(--spot); background:var(--spot-soft); }
306
+ .fix__opt.is-right .fix__tag { color:var(--spot); }
307
+ .fix__foot { display:flex; align-items:center; gap:var(--space-4); margin-top:var(--space-5); }
308
+ .fix__msg { flex:1; margin:0; font-size:13.5px; }
309
+ .fix__msg.wrong { color:var(--error); }
310
+ .fix__msg.warn { color:var(--warning); }
311
+ .fix__msg.ok { color:var(--spot); }
265
312
  .g2 > *, .g3 > * { margin-top:0 !important; }
266
313
 
267
314
  /* ---------- stat tiles ---------- */
@@ -720,6 +767,16 @@
720
767
  </main>
721
768
  </div>
722
769
 
770
+ <dialog class="fix" id="fix" aria-labelledby="fix-q">
771
+ <p class="fix__eyebrow">Correct your answer</p>
772
+ <h2 class="fix__q" id="fix-q"></h2>
773
+ <div class="fix__opts" id="fix-opts" role="group" aria-labelledby="fix-q"></div>
774
+ <div class="fix__foot">
775
+ <p class="fix__msg" id="fix-msg" role="status"></p>
776
+ <button type="button" class="set__btn" id="fix-close">Close</button>
777
+ </div>
778
+ </dialog>
779
+
723
780
  <div class="picker" id="wf-menu" role="menu" aria-label="Workflows" hidden></div>
724
781
  <div class="picker" id="proj-menu" role="listbox" aria-labelledby="proj-label" hidden></div>
725
782
  <div class="picker" id="sel-menu" role="listbox" hidden></div>
@@ -832,14 +889,14 @@ const scopeName = () => (PROJECT === null ? 'all projects' : projectById(PROJECT
832
889
  /** Inventory rows in scope — one, or all of them. */
833
890
  const projectsInScope = () => INV.projects.filter((p) => PROJECT === null || p.id === PROJECT);
834
891
 
835
- /** One row per calendar day in scope: { day, passed, missed, skipped, total }. */
892
+ /** One row per calendar day in scope: { day, passed, corrected, missed, skipped, total }. */
836
893
  function dailyInScope() {
837
894
  const m = new Map();
838
895
  for (const d of S.daily) {
839
896
  if (!inScope(d.repo)) continue;
840
- const cur = m.get(d.day) ?? { day:d.day, passed:0, missed:0, skipped:0, total:0 };
841
- cur.passed += d.passed; cur.missed += d.missed; cur.skipped += d.skipped;
842
- cur.total = cur.passed + cur.missed + cur.skipped;
897
+ const cur = m.get(d.day) ?? { day:d.day, passed:0, corrected:0, missed:0, skipped:0, total:0 };
898
+ cur.passed += d.passed; cur.corrected += d.corrected ?? 0; cur.missed += d.missed; cur.skipped += d.skipped;
899
+ cur.total = cur.passed + cur.corrected + cur.missed + cur.skipped;
843
900
  m.set(d.day, cur);
844
901
  }
845
902
  return m;
@@ -853,7 +910,7 @@ function fillDays(map, n, endKey) {
853
910
  const cur = new Date(end.getTime() - (n - 1) * DAY_MS);
854
911
  for (let i = 0; i < n; i++) {
855
912
  const k = dayKey(cur);
856
- out.push(map.get(k) ?? { day:k, passed:0, missed:0, skipped:0, total:0 });
913
+ out.push(map.get(k) ?? { day:k, passed:0, corrected:0, missed:0, skipped:0, total:0 });
857
914
  cur.setUTCDate(cur.getUTCDate() + 1);
858
915
  }
859
916
  return out;
@@ -935,6 +992,8 @@ function sessionLabel(s) {
935
992
  ============================================================ */
936
993
  const meter = (p, kind) => `<div class="meter${kind ? ' ' + kind : ''}"><i style="width:${Math.max(0, Math.min(100, p))}%"></i></div>`;
937
994
  const gradeBadge = (a) => {
995
+ // A miss corrected from its explainer: the row stays, labelled for what it became.
996
+ if (a.corrected_at) return `<span class="grade pass">corrected${a.corrected_try ? ` · try ${a.corrected_try}` : ''}</span>`;
938
997
  if (a.outcome === 'declined') return '<span class="grade">skipped</span>';
939
998
  if (a.outcome === 'dont_know') return '<span class="grade">blanked</span>';
940
999
  return `<span class="grade ${a.grade >= 3 ? 'pass' : 'miss'}">${a.grade}/5</span>`;
@@ -1084,7 +1143,11 @@ const slice = (arr, p, per = PER) => arr.slice((p - 1) * per, p * per);
1084
1143
  Charts — hand-rolled SVG. No library, no network, both grounds.
1085
1144
  ============================================================ */
1086
1145
 
1087
- /** Stacked daily bars: right at the base, then missed, then skipped. */
1146
+ /**
1147
+ * Stacked daily bars: right at the base, then corrected (a miss answered right
1148
+ * later from its explainer, counted once, on the day of the miss), then
1149
+ * missed, then skipped.
1150
+ */
1088
1151
  function chartActivity(el, days) {
1089
1152
  const tip = el.parentElement.querySelector('.tip');
1090
1153
  const W = Math.max(320, el.parentElement.clientWidth || 900);
@@ -1093,7 +1156,7 @@ function chartActivity(el, days) {
1093
1156
  const step = (W - PAD_L) / days.length;
1094
1157
  const bw = Math.min(26, Math.max(4, step - 5));
1095
1158
  const y = (v) => (H - PAD_B) - (v / max) * (H - PAD_B - 10);
1096
- const SEGMENTS = [['passed', 'var(--spot)'], ['missed', 'var(--warning)'], ['skipped', 'var(--faint-2)']];
1159
+ const SEGMENTS = [['passed', 'var(--spot)'], ['corrected', 'var(--spot-soft)'], ['missed', 'var(--warning)'], ['skipped', 'var(--faint-2)']];
1097
1160
 
1098
1161
  let out = '';
1099
1162
  for (let i = 0; i <= 2; i++) {
@@ -1109,7 +1172,9 @@ function chartActivity(el, days) {
1109
1172
  if (!d[key]) continue;
1110
1173
  const h = (d[key] / max) * (H - PAD_B - 10);
1111
1174
  const inset = acc ? 2 : 0; // a 2px surface gap between stacked fills
1112
- seg += `<rect x="${x}" y="${y(acc) - h + inset}" width="${bw}" height="${Math.max(1, h - inset)}" fill="${color}"/>`;
1175
+ // The soft fill gets a hairline in the accent, or it vanishes against the panel.
1176
+ const edge = key === 'corrected' ? ' stroke="var(--spot)" stroke-width="1"' : '';
1177
+ seg += `<rect x="${x}" y="${y(acc) - h + inset}" width="${bw}" height="${Math.max(1, h - inset)}" fill="${color}"${edge}/>`;
1113
1178
  acc += d[key]; top = color;
1114
1179
  }
1115
1180
  out += `<rect class="hit" x="${PAD_L + i * step}" y="0" width="${step}" height="${H - PAD_B}" data-i="${i}" tabindex="0"/>`
@@ -1127,7 +1192,7 @@ function chartActivity(el, days) {
1127
1192
  const show = () => {
1128
1193
  const d = days[+hit.dataset.i];
1129
1194
  tip.innerHTML = `<div class="mono">${esc(fmtDay(d.day))}</div>`
1130
- + [['Right', d.passed], ['Missed', d.missed], ['Skipped', d.skipped]]
1195
+ + [['Right', d.passed], ['Corrected', d.corrected], ['Missed', d.missed], ['Skipped', d.skipped]]
1131
1196
  .map(([k, v]) => `<div class="r"><span>${k}</span><span>${v}</span></div>`).join('');
1132
1197
  const r = hit.getBoundingClientRect(), p = el.parentElement.getBoundingClientRect();
1133
1198
  tip.style.left = (r.left - p.left + r.width / 2) + 'px';
@@ -1351,24 +1416,26 @@ function memScope(params) {
1351
1416
  return params;
1352
1417
  }
1353
1418
 
1419
+ // `confirmed` is what earlier releases wrote for the same fact as `emitted`.
1420
+ const delivered = (d) => d === 'emitted' || d === 'confirmed';
1354
1421
  const deliveryPill = (d) =>
1355
- d === 'confirmed' ? '<span class="pill ok">confirmed</span>'
1422
+ delivered(d) ? `<span class="pill ok">${esc(d)}</span>`
1356
1423
  : d === 'prepared' ? '<span class="pill due">prepared</span>'
1357
1424
  : '<span class="pill">unknown</span>';
1358
1425
 
1359
1426
  /**
1360
1427
  * The one sentence that says what a savings verdict means, in words, not a bare
1361
1428
  * percentage. `none` has two readings and they are not the same story: nothing
1362
- * was ever reused, or plenty was and none of it was ever confirmed delivered —
1429
+ * was ever reused, or plenty was and none of it ever left the hook —
1363
1430
  * which is why the receipt count is part of the sentence.
1364
1431
  */
1365
1432
  function savingsText(s, receipts) {
1366
1433
  if (!s) return 'No receipts yet.';
1367
1434
  if (s.kind === 'saving') return `${s.percent}% less context than reading the same material from source (estimated).`;
1368
1435
  if (s.kind === 'overhead') return `${s.tokens} tokens more than reading from source — reuse cost more than it saved.`;
1369
- if (s.kind === 'unavailable') return 'No confirmed delivery yet, so there is no saving to report.';
1436
+ if (s.kind === 'unavailable') return 'Nothing emitted yet, so there is no saving to report.';
1370
1437
  return receipts
1371
- ? `${plural(receipts, 'receipt')}, none of them a confirmed delivery — so there is no saving to report.`
1438
+ ? `${plural(receipts, 'receipt')}, none of them emitted — so there is no saving to report.`
1372
1439
  : 'No context has been reused yet.';
1373
1440
  }
1374
1441
 
@@ -1438,11 +1505,12 @@ function viewLearningDashboard() {
1438
1505
  card('Answers, last 30 days', null,
1439
1506
  `<div class="legend">
1440
1507
  <span><b style="background:var(--spot)"></b>Right</span>
1508
+ <span><b style="background:var(--spot-soft);box-shadow:inset 0 0 0 1px var(--spot)"></b>Corrected</span>
1441
1509
  <span><b style="background:var(--warning)"></b>Missed</span>
1442
1510
  <span><b style="background:var(--faint-2)"></b>Skipped</span>
1443
1511
  </div>
1444
1512
  <div class="chart"><div class="tip" id="tip"></div><svg id="c-activity" role="img"
1445
- aria-label="Daily answers, split into right, missed and skipped"></svg></div>`),
1513
+ aria-label="Daily answers, split into right, corrected, missed and skipped"></svg></div>`),
1446
1514
 
1447
1515
  `<div class="g2">
1448
1516
  ${card('Weakest five', null,
@@ -1585,7 +1653,7 @@ function viewConcept(slug) {
1585
1653
 
1586
1654
  tiles([
1587
1655
  ['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' : ''],
1588
- ['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' : ''],
1656
+ ['State', c.mastered ? 'mastered' : c.seen ? (c.due ? 'due' : 'learning') : 'not asked', c.seen ? `${c.passed} of ${c.attempts} right${c.corrected ? ` · ${c.corrected} corrected` : ''}` : 'no questions yet', c.due ? 'warn' : ''],
1589
1657
  ['Next review', c.owed ? 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' : ''],
1590
1658
  ]),
1591
1659
 
@@ -1854,9 +1922,9 @@ function viewProjects() {
1854
1922
  const m = new Map();
1855
1923
  for (const d of S.daily) {
1856
1924
  if (pid(d.repo) !== p.id) continue;
1857
- const cur = m.get(d.day) ?? { day:d.day, passed:0, missed:0, skipped:0, total:0 };
1858
- cur.passed += d.passed; cur.missed += d.missed; cur.skipped += d.skipped;
1859
- cur.total = cur.passed + cur.missed + cur.skipped;
1925
+ const cur = m.get(d.day) ?? { day:d.day, passed:0, corrected:0, missed:0, skipped:0, total:0 };
1926
+ cur.passed += d.passed; cur.corrected += d.corrected ?? 0; cur.missed += d.missed; cur.skipped += d.skipped;
1927
+ cur.total = cur.passed + cur.corrected + cur.missed + cur.skipped;
1860
1928
  m.set(d.day, cur);
1861
1929
  }
1862
1930
  chartActivity(el, fillDays(m, 30));
@@ -2157,10 +2225,10 @@ function viewMemoryDashboard() {
2157
2225
  // The pipeline in two lines: is reuse paying for itself, and is capture alive.
2158
2226
  // Each opens its page; the arithmetic lives there.
2159
2227
  const verdict = R.savings.kind === 'saving' ? `${R.savings.percent}% less context`
2160
- : R.savings.kind === 'overhead' ? `+${R.savings.tokens} tokens of overhead` : 'no confirmed receipts yet';
2228
+ : R.savings.kind === 'overhead' ? `+${R.savings.tokens} tokens of overhead` : 'nothing emitted yet';
2161
2229
  const stuck = Q.failed + Q.paused;
2162
2230
  const pipeline = lines([
2163
- { t: 'Context reuse', s: `${plural(R.receipts, 'receipt')}, ${R.confirmed} confirmed · estimated, all projects`,
2231
+ { t: 'Context reuse', s: `${plural(R.receipts, 'receipt')}, ${R.emitted + R.confirmed} emitted · ${plural(R.reads.reads, 'read')} · estimated, all projects`,
2164
2232
  v: esc(verdict), go: MH('reuse') },
2165
2233
  { 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`,
2166
2234
  v: stuck ? `<span style="color:var(--warning)">${stuck} stalled</span>` : 'running', go: MH('health') },
@@ -2327,10 +2395,10 @@ function viewReuse() {
2327
2395
  const verdict = R.savings.kind === 'saving' ? R.savings.percent + '%'
2328
2396
  : R.savings.kind === 'overhead' ? '+' + R.savings.tokens : '—';
2329
2397
  const body = slice(rows, p).map((r) => {
2330
- // A saving is only a saving when the delivery was confirmed. An unknown or
2398
+ // A saving is only a saving when the context left the hook. An unknown or
2331
2399
  // prepared receipt has a B and a D but no verdict, and printing one would
2332
2400
  // be counting context that may never have reached the model.
2333
- const saved = r.delivery === 'confirmed' && r.base_tokens > 0
2401
+ const saved = delivered(r.delivery) && r.base_tokens > 0
2334
2402
  ? Math.round((100 * (r.base_tokens - r.delivered_tokens)) / r.base_tokens) + '%'
2335
2403
  : '—';
2336
2404
  const sub = [esc(pname(r.project)), plural(r.item_count, 'item'), r.detail_tokens ? `+${r.detail_tokens} detail` : '', esc(r.method)]
@@ -2346,15 +2414,20 @@ function viewReuse() {
2346
2414
  });
2347
2415
  return [
2348
2416
  pageHead('Context reuse', [
2349
- `${R.confirmed} of ${plural(R.receipts, 'receipt')} confirmed`, `net ${net} tokens`, `estimated with <code class="slug">${esc(R.estimator)}</code>`,
2417
+ `${R.emitted + R.confirmed} of ${plural(R.receipts, 'receipt')} emitted`, `net ${net} tokens`,
2418
+ `${plural(R.reads.reads, 'memory read')}, ${R.reads.unlinked} without a receipt`, `estimated with <code class="slug">${esc(R.estimator)}</code>`,
2350
2419
  ], `<strong>B</strong> is what the same material would have cost to read from source. <strong>D</strong> is
2351
2420
  what was actually delivered instead. Both are counted with the same estimator, and the percentage is an
2352
2421
  estimate of how much <em>context volume</em> was avoided — not a bill, not a price, and not a measure of
2353
- model spend. Receipts whose delivery is <code>unknown</code> or <code>prepared</code> are listed in the
2354
- ledger but never counted in the verdict: an injection that may not have reached the model is not a saving.`),
2422
+ model spend. <code>emitted</code> means the hook wrote the context to Claude Code, which does not
2423
+ acknowledge it; older receipts say <code>confirmed</code> for the same thing. Receipts whose delivery is
2424
+ <code>unknown</code> or <code>prepared</code> are listed in the ledger but never counted in the verdict:
2425
+ an injection that may not have reached the model is not a saving. Memory reads count every
2426
+ <code>memory_search</code>, <code>memory_get</code>, <code>memory_timeline</code> and
2427
+ <code>memory_file_history</code> call, with or without a receipt.`),
2355
2428
  tiles([
2356
2429
  ['Verdict', verdict, savingsText(R.savings, R.receipts), R.savings.kind === 'saving' ? 'good' : R.savings.kind === 'overhead' ? 'warn' : ''],
2357
- ['B — from source', R.base, 'estimated tokens, confirmed receipts'],
2430
+ ['B — from source', R.base, 'estimated tokens, emitted receipts'],
2358
2431
  ['D — delivered', R.delivered, 'estimated tokens actually injected'],
2359
2432
  ]),
2360
2433
  card('Every receipt', `${plural(rows.length, 'receipt')} in scope. One row per injection.`,
@@ -2375,7 +2448,7 @@ function viewHealth() {
2375
2448
  ['Processed', M.processed, 'distilled into an observation'],
2376
2449
  ['Indexed', M.indexed, 'reachable by semantic search'],
2377
2450
  ['Reused', M.reused, 'selected for a context injection'],
2378
- ['Exposed', M.exposed, 'in a confirmed delivery'],
2451
+ ['Exposed', M.exposed, 'in context a hook emitted'],
2379
2452
  ['Assessed', M.assessed, 'concepts you were actually asked about'],
2380
2453
  ];
2381
2454
  const oldest = Q.oldest ? ago(Q.oldest) : '—';
@@ -2455,8 +2528,10 @@ function viewHealth() {
2455
2528
  Artifacts — the pages an agent wrote under ~/.eklavya/artifacts/.
2456
2529
  `S.artifacts` is read from the files on every load; opening one is a plain
2457
2530
  link to `/artifacts/<id>`, which the server serves sandboxed, in a new tab.
2531
+ An explainer for a missed question that can still be corrected opens in
2532
+ the viewer instead (`#/artifacts/view/<id>`), with the correction bar.
2458
2533
  ============================================================ */
2459
- const ARTIFACT_KINDS = ['all', 'explainer', 'artifact'];
2534
+ const ARTIFACT_KINDS = ['all', 'explainer', 'artifact', 'to-correct'];
2460
2535
  const artifactsInScope = () => (S.artifacts ?? []).filter((a) => inScope(a.project));
2461
2536
  const artifactUrl = (a) => '/artifacts/' + a.id.split('/').map(encodeURIComponent).join('/');
2462
2537
 
@@ -2468,12 +2543,17 @@ function artifactCard(a) {
2468
2543
  const concept = !a.concept ? ''
2469
2544
  : known ? ` · <a class="link" data-go="${esc(LH('concept', a.concept))}">${esc(a.concept)}</a>`
2470
2545
  : ` · ${esc(a.concept)}`;
2546
+ const tag = a.correction === 'open' ? ' · <span class="tag warn">To correct</span>'
2547
+ : a.correction === 'done' ? ' · <span class="tag ok">Corrected</span>' : '';
2548
+ // A correctable explainer opens in the viewer, where the correction lives;
2549
+ // every other page is a plain link to the sandboxed file, in a new tab.
2550
+ const open = a.correction ? `href="${esc(AH('view', a.id))}"` : `href="${esc(artifactUrl(a))}" target="_blank" rel="noopener"`;
2471
2551
  return `<article class="art">
2472
- <a class="art__open" href="${esc(artifactUrl(a))}" target="_blank" rel="noopener">
2552
+ <a class="art__open" ${open}>
2473
2553
  <span class="art__thumb"><img src="${esc(thumbUrl(a, window.eklavyaGround.read()))}" data-thumb="${esc(thumbUrl(a, '@'))}" alt="" loading="lazy" decoding="async"></span>
2474
2554
  <span class="title">${esc(a.title)}</span></a>
2475
2555
  ${a.description ? `<p class="ctx">${esc(a.description)}</p>` : ''}
2476
- <p class="slug">${esc(a.kind)} · ${esc(stamp(a.created))}${concept}</p>
2556
+ <p class="slug">${esc(a.kind)} · ${esc(stamp(a.created))}${concept}${tag}</p>
2477
2557
  </article>`;
2478
2558
  }
2479
2559
 
@@ -2483,7 +2563,7 @@ function viewArtifacts(param) {
2483
2563
  const all = artifactsInScope();
2484
2564
  const q = t.q.trim().toLowerCase();
2485
2565
  const list = all.filter((a) => {
2486
- if (kind !== 'all' && a.kind !== kind) return false;
2566
+ if (kind === 'to-correct' ? a.correction !== 'open' : kind !== 'all' && a.kind !== kind) return false;
2487
2567
  if (q && ![a.title, a.description, a.concept ?? '', pname(a.project), a.id].join(' ').toLowerCase().includes(q)) return false;
2488
2568
  return true;
2489
2569
  });
@@ -2497,6 +2577,7 @@ function viewArtifacts(param) {
2497
2577
  }
2498
2578
  const counts = { all: all.length, explainer: all.filter((a) => a.kind === 'explainer').length };
2499
2579
  counts.artifact = all.length - counts.explainer;
2580
+ counts['to-correct'] = all.filter((a) => a.correction === 'open').length;
2500
2581
  const projects = new Set(all.map((a) => pid(a.project))).size;
2501
2582
  const chip = (k, label) => `<button class="chip" data-go="${esc(AH('dashboard', k === 'all' ? null : k))}" aria-pressed="${kind === k}">${label} ${counts[k]}</button>`;
2502
2583
  const body = !list.length
@@ -2509,16 +2590,150 @@ function viewArtifacts(param) {
2509
2590
  plural(all.length, 'page'), plural(counts.explainer, 'explainer'),
2510
2591
  `<a class="link" data-go="${esc(AH('projects'))}">${plural(projects, 'project')}</a>`,
2511
2592
  ], `Pages your agent wrote for you — explainers after a missed question, and anything you asked to have
2512
- turned into a page. Each opens in a new tab, from <code>~/.eklavya/artifacts/</code>.`),
2593
+ turned into a page. Each opens in a new tab, from <code>~/.eklavya/artifacts/</code>. An explainer for a
2594
+ missed question opens here instead, with a <b>Correct your answer</b> button under it: pick the right
2595
+ option and the miss shows as corrected. That counts as a pass for the concept's score, but not toward a
2596
+ commit gate or a level, and the concept still comes back for review.`),
2513
2597
  card(null, null,
2514
2598
  `<div class="filters">
2515
2599
  <div class="grow"><input type="search" id="aq" value="${esc(t.q)}" placeholder="Search artifacts" aria-label="Search artifacts"></div>
2516
2600
  </div>
2517
- <div class="chips">${chip('all','All')}${chip('explainer','Explainers')}${chip('artifact','Other pages')}</div>
2601
+ <div class="chips">${chip('all','All')}${chip('explainer','Explainers')}${chip('to-correct','To correct')}${chip('artifact','Other pages')}</div>
2518
2602
  <div style="margin-top:var(--space-4)">${body}</div>`),
2519
2603
  ].join('');
2520
2604
  }
2521
2605
 
2606
+ /* ---------- the explainer viewer and its correction ----------
2607
+ The agent's page is framed sandboxed without same-origin, so it can never
2608
+ read or write the API; the bar and the modal around it are this page's own
2609
+ code, and the server grades every pick. */
2610
+ const FRAME_SANDBOX = 'allow-scripts allow-modals allow-downloads allow-popups allow-popups-to-escape-sandbox';
2611
+ /** The correction on screen: `{ attempt, artifact, q }`, `q` being /api/attempts/correction's answer. */
2612
+ let FIX = null;
2613
+
2614
+ function viewArtifact(id) {
2615
+ const a = (S.artifacts ?? []).find((x) => x.id === id);
2616
+ if (!a) return viewNotFound(id, false);
2617
+ if (a.correction) fill('fixbar', `/api/attempts/correction?id=${a.attempt}`, (d) => fixBar(a, d));
2618
+ // The page's own ground follows this one; a page made before the viewer ignores it.
2619
+ AFTER.push(() => {
2620
+ const f = $('art-frame');
2621
+ f?.addEventListener('load', () => f.contentWindow?.postMessage({ type:'eklavya:mode', mode:window.eklavyaGround.read() }, '*'));
2622
+ });
2623
+ return `<a class="page__back" data-go="${esc(AH('dashboard'))}">← Artifacts</a>
2624
+ <iframe class="viewer" id="art-frame" src="${esc(artifactUrl(a))}?embed" title="${esc(a.title)}" sandbox="${FRAME_SANDBOX}"></iframe>
2625
+ ${a.correction ? `<div class="fixbar" id="fixbar">${loader('Loading the question…')}</div>` : ''}`;
2626
+ }
2627
+
2628
+ function fixBar(a, d) {
2629
+ FIX = { attempt: a.attempt, artifact: a.id, q: d };
2630
+ const done = d.corrected_at !== null;
2631
+ armLeave(!done);
2632
+ return done
2633
+ ? `<p class="fixbar__done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6L9 17l-5-5"/></svg>Corrected on try ${d.tries} · ${esc(stamp(d.corrected_at))}</p>`
2634
+ : `<button type="button" class="btn" id="fix-open">Correct your answer</button>
2635
+ <p class="fixbar__note">Not now? Correct it later from Dashboard → Artifacts.</p>`;
2636
+ }
2637
+
2638
+ /** The options in a new order each time: the page above shows the right one at its letter. */
2639
+ function shuffled(n) {
2640
+ const order = [...Array(n).keys()];
2641
+ for (let tries = 0; tries < 5; tries++) {
2642
+ for (let i = n - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [order[i], order[j]] = [order[j], order[i]]; }
2643
+ if (order.some((v, i) => v !== i)) break;
2644
+ }
2645
+ return order;
2646
+ }
2647
+
2648
+ function fixSay(text, kind) {
2649
+ const m = $('fix-msg');
2650
+ m.textContent = text;
2651
+ m.className = 'fix__msg' + (kind ? ' ' + kind : '');
2652
+ }
2653
+
2654
+ function openFix() {
2655
+ const d = FIX.q;
2656
+ $('fix-q').textContent = d.question;
2657
+ $('fix-opts').innerHTML = shuffled(d.options.length).map((i) =>
2658
+ `<button type="button" class="fix__opt" data-pick="${esc(d.options[i])}"><span>${esc(d.options[i])}</span>`
2659
+ + `${d.option_notes?.[i] ? `<span class="fix__n">${esc(d.option_notes[i])}</span>` : ''}<span class="fix__tag"></span></button>`).join('');
2660
+ fixSay('', '');
2661
+ $('fix').showModal();
2662
+ $('fix-opts').querySelector('button').focus();
2663
+ }
2664
+
2665
+ function pickFix(btn) {
2666
+ const all = [...$('fix-opts').querySelectorAll('button')];
2667
+ const reopen = () => all.forEach((b) => { b.disabled = !!b.dataset.wrong; });
2668
+ all.forEach((b) => { b.disabled = true; });
2669
+ postJson('/api/attempts/retry', { attempt_id: FIX.attempt, picked: btn.dataset.pick })
2670
+ .then((r) => {
2671
+ if (r.corrected) {
2672
+ btn.classList.add('is-right');
2673
+ btn.querySelector('.fix__tag').textContent = 'Right answer';
2674
+ fixSay('Corrected. Your stats now count this as answered.', 'ok');
2675
+ FIX.q = { ...FIX.q, tries: r.tries, corrected_at: new Date().toISOString() };
2676
+ const row = S.artifacts.find((x) => x.id === FIX.artifact);
2677
+ if (row) row.correction = 'done';
2678
+ // Every other count on the page was drawn from the payload it booted with:
2679
+ // say so now rather than waiting for the poll.
2680
+ $('stale').hidden = false;
2681
+ armLeave(false);
2682
+ $('fix-close').focus();
2683
+ return;
2684
+ }
2685
+ btn.classList.add('is-wrong');
2686
+ btn.dataset.wrong = '1';
2687
+ btn.querySelector('.fix__tag').textContent = 'Not this one';
2688
+ reopen();
2689
+ fixSay('Still incorrect. Please correct your answer.', 'wrong');
2690
+ all.find((b) => !b.disabled)?.focus();
2691
+ })
2692
+ .catch((e) => { reopen(); fixSay(e.message, 'warn'); });
2693
+ }
2694
+
2695
+ $('fix-opts').addEventListener('click', (e) => {
2696
+ const b = e.target.closest('.fix__opt');
2697
+ if (b && !b.disabled) pickFix(b);
2698
+ });
2699
+ // Arrow keys walk the options that can still be picked; Enter and Space pick (native buttons).
2700
+ $('fix-opts').addEventListener('keydown', (e) => {
2701
+ const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[e.key];
2702
+ if (!step) return;
2703
+ e.preventDefault();
2704
+ const live = [...$('fix-opts').querySelectorAll('button:not(:disabled)')];
2705
+ const i = live.indexOf(document.activeElement);
2706
+ live[(i + step + live.length) % live.length]?.focus();
2707
+ });
2708
+ $('fix-close').addEventListener('click', () => $('fix').close());
2709
+ // However it closes -- the button, Escape -- the bar shows where things stand.
2710
+ $('fix').addEventListener('close', () => {
2711
+ const bar = $('fixbar');
2712
+ const a = FIX && S.artifacts.find((x) => x.id === FIX.artifact);
2713
+ if (bar && a) { bar.innerHTML = fixBar(a, FIX.q); $('fix-open')?.focus(); }
2714
+ });
2715
+ $('view').addEventListener('click', (e) => { if (e.target.closest('#fix-open')) openFix(); });
2716
+
2717
+ /**
2718
+ * Closing the tab with a correction still open asks first. Browsers show their
2719
+ * own generic prompt -- a page cannot set its text -- so the bar carries ours.
2720
+ */
2721
+ const holdLeave = (e) => { e.preventDefault(); e.returnValue = ''; };
2722
+ let LEAVE = false;
2723
+ function armLeave(on) {
2724
+ if (on === LEAVE) return;
2725
+ LEAVE = on;
2726
+ window[on ? 'addEventListener' : 'removeEventListener']('beforeunload', holdLeave);
2727
+ }
2728
+
2729
+ // The framed page reports its height; only that frame's message counts.
2730
+ addEventListener('message', (e) => {
2731
+ const f = $('art-frame');
2732
+ if (!f || e.source !== f.contentWindow || e.data?.type !== 'eklavya:height') return;
2733
+ const h = Number(e.data.h);
2734
+ if (Number.isFinite(h)) f.style.height = Math.min(20000, Math.max(200, h)) + 'px';
2735
+ });
2736
+
2522
2737
  function viewArtifactProjects() {
2523
2738
  const by = new Map();
2524
2739
  for (const a of artifactsInScope()) {
@@ -2723,6 +2938,20 @@ function controlValue(f, el) {
2723
2938
  return el.value;
2724
2939
  }
2725
2940
 
2941
+ /**
2942
+ * Every write the page makes. The server's `acceptWrite` wants the per-start
2943
+ * token from the page's own meta tag and JSON; this sends both. Resolves with
2944
+ * the parsed reply, rejects with the server's own error text.
2945
+ */
2946
+ function postJson(path, body) {
2947
+ const token = document.querySelector('meta[name="eklavya-token"]')?.content ?? '';
2948
+ return fetch(path, {
2949
+ method: 'POST',
2950
+ headers: { 'content-type': 'application/json', 'x-eklavya-token': token },
2951
+ body: JSON.stringify(body),
2952
+ }).then((r) => r.json().then((d) => { if (!r.ok) throw new Error(d.error || `${r.status}`); return d; }));
2953
+ }
2954
+
2726
2955
  /**
2727
2956
  * Saves one setting and redraws the settings in place — not through `render()`,
2728
2957
  * which would scroll to the top and lose the control the reader just used.
@@ -2733,14 +2962,8 @@ function saveSetting(key, change) {
2733
2962
  const say = (text, warn) => { if (msg) { msg.textContent = text; msg.className = warn ? 'warn' : 'here'; } };
2734
2963
  fieldError(key, null);
2735
2964
  say('saving…');
2736
- const token = document.querySelector('meta[name="eklavya-token"]')?.content ?? '';
2737
2965
  const gen = GEN;
2738
- fetch('/api/settings', {
2739
- method: 'POST',
2740
- headers: { 'content-type': 'application/json', 'x-eklavya-token': token },
2741
- body: JSON.stringify({ scope, project: SETS?.project?.id, key, ...change }),
2742
- })
2743
- .then((r) => r.json().then((d) => { if (!r.ok) throw new Error(d.error || `${r.status}`); }))
2966
+ postJson('/api/settings', { scope, project: SETS?.project?.id, key, ...change })
2744
2967
  .then(() => fetch(settingsUrl()).then((r) => r.json()))
2745
2968
  .then((d) => {
2746
2969
  const el = $('settings');
@@ -2841,6 +3064,7 @@ const WORKFLOWS = {
2841
3064
  ],
2842
3065
  pages: {
2843
3066
  dashboard: { label: 'Dashboard', view: (p) => viewArtifacts(p) },
3067
+ view: { parent: 'dashboard', view: (p) => viewArtifact(p) },
2844
3068
  projects: { label: 'Projects', view: () => viewArtifactProjects() },
2845
3069
  },
2846
3070
  },
@@ -3463,6 +3687,8 @@ function render(keepFocus) {
3463
3687
 
3464
3688
  AFTER.length = 0;
3465
3689
  LAZY.clear();
3690
+ // A screen that still has a correction open re-arms this when it draws its bar.
3691
+ armLeave(false);
3466
3692
  $('view').innerHTML = !pg ? viewNotFound('#/' + CUR.wf + '/' + CUR.page, false)
3467
3693
  : CUR.bad ? viewNotFound(CUR.param, true)
3468
3694
  : pg.view(CUR.param);
@@ -3603,7 +3829,8 @@ addEventListener('hashchange', () => { if (INV) { closePicker(false); syncFromUr
3603
3829
  let resizeTimer = 0;
3604
3830
  addEventListener('resize', () => {
3605
3831
  clearTimeout(resizeTimer);
3606
- resizeTimer = setTimeout(() => { if (S) render({ id:'q', pos:null }); }, 120);
3832
+ // Not the viewer: a re-render would reload the framed page and lose its scroll.
3833
+ resizeTimer = setTimeout(() => { if (S && CUR.page !== 'view') render({ id:'q', pos:null }); }, 120);
3607
3834
  });
3608
3835
 
3609
3836
  document.querySelectorAll('[data-ground]').forEach((b) => {
@@ -183,7 +183,8 @@ scales, how long feedback may be, the sequence a blank earns, and what
183
183
  `already_taught` changes.
184
184
 
185
185
  **A missed answer can come back with `explain`.** That is `explain_on_wrong` at
186
- work: follow its `instruction` exactly. The page is written in the background
186
+ work: follow its `instruction` exactly. Hand the explainer its `attempt_id` and
187
+ `option_notes` too: the page shows the notes and can be corrected. The page is written in the background
187
188
  and opens by itself, so give the verdict, say the page is on its way, and go
188
189
  back to the task — no longer explanation, no waiting. When the developer asks
189
190
  for something to be explained as a page ("explain this to me", "make me a page
@@ -140,7 +140,17 @@ concept has unmet prerequisites:
140
140
  ## Recording it
141
141
 
142
142
  `record_attempt` with `format: "mcq"`, `options` as the labels you offered,
143
- `answer` as the one they picked, and `question` as the **stem only**.
143
+ `answer` as the one they picked, `correct` as the right option's label
144
+ verbatim, `option_notes` as the `description` you wrote under each option (same
145
+ order as `options`), and `question` as the **stem only**.
146
+
147
+ `correct` and `option_notes` are what let the learner correct a missed answer
148
+ from its explainer page: the dashboard grades the new pick against `correct`
149
+ and shows each note under its option. A `correct` that is not one of the
150
+ options, or a notes list of the wrong length, is stored as nothing and the
151
+ response says so (`correct_mismatch`, `option_notes_mismatch`) — the answer
152
+ itself is still recorded. The response's `attempt_id` names the row; on a miss
153
+ the `explain` block carries it for the explainer.
144
154
 
145
155
  Options belong in `options`. The stem is what gets fingerprinted, so options
146
156
  baked into it would make every reshuffle look like a brand-new question and
@@ -20,7 +20,7 @@ import { spin } from './theme.js';
20
20
  import { importOffThread } from './memory/import-worker.js';
21
21
  import { isInternalObserver, releaseWorker, renewWorker, reserveWorker, stopWorker, workerStatus, } from './memory/reservation.js';
22
22
  import { identityFor } from './memory/identity.js';
23
- import { backlogSummary, countEntries, discardBacklog, entryById, entryEvents, entryTags, pendingEventCount, quarantineBacklog, receiptTotals, restoreBacklog, resumePaused, timeline, } from './memory/store.js';
23
+ import { backlogSummary, countEntries, discardBacklog, entryById, entryEvents, entryTags, pendingEventCount, quarantineBacklog, readTotals, receiptTotals, totalsDelivery, restoreBacklog, resumePaused, timeline, } from './memory/store.js';
24
24
  import { search } from './memory/search.js';
25
25
  import { pull, push, syncStatus } from './memory/sync.js';
26
26
  import { pruneEvidence, queueDepth, resumeIfRepaired, summarizerFor, superviseWorker } from './memory/worker.js';
@@ -112,6 +112,10 @@ export function workerLine(db) {
112
112
  .filter(Boolean)
113
113
  .join(' · ');
114
114
  }
115
+ /** Explicit memory reads in one line: linked to a recall or not, every one counted. */
116
+ function readLine(r) {
117
+ return `${r.reads} (${r.linked} with a receipt, ${r.unlinked} without) · ${r.empty} empty · ${r.errors} failed · ~${r.tokens} tokens returned`;
118
+ }
115
119
  /**
116
120
  * Why the queue is paused, by class — never `last_error`, which is the
117
121
  * provider's own prose and has carried a URL with a token in it.
@@ -142,7 +146,7 @@ function memoryStatus() {
142
146
  const savings = savingsFrom({
143
147
  baseTokens: totals.base,
144
148
  deliveredTokens: totals.delivered,
145
- delivery: totals.confirmed > 0 ? 'confirmed' : 'unknown',
149
+ delivery: totalsDelivery(totals),
146
150
  });
147
151
  const lines = [
148
152
  `project: ${project}`,
@@ -162,7 +166,8 @@ function memoryStatus() {
162
166
  : 'none — nothing leaves this machine'}`,
163
167
  `summarizer: ${summarizerFor(config).id}`,
164
168
  `spool drops: ${droppedCount()}`,
165
- `receipts: ${totals.receipts} (${totals.confirmed} confirmed) · base ${totals.base} → delivered ${totals.delivered} tokens`,
169
+ `receipts: ${totals.receipts} (${totals.emitted + totals.confirmed} emitted to Claude Code; it does not acknowledge them) · base ${totals.base} → delivered ${totals.delivered} tokens`,
170
+ `reads: ${readLine(readTotals(db))}`,
166
171
  savingsLine(savings),
167
172
  ];
168
173
  process.stdout.write(`${lines.join('\n')}\n`);