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.
- package/README.md +15 -2
- package/dist/artifacts.js +26 -0
- package/dist/artifacts.js.map +1 -1
- package/dist/assets/artifact-template.html +21 -0
- package/dist/assets/dashboard.html +268 -41
- package/dist/assets/tutor/SKILL.md +2 -1
- package/dist/assets/tutor/references/writing-mcq.md +11 -1
- package/dist/cli-memory.js +8 -3
- package/dist/cli-memory.js.map +1 -1
- package/dist/cli.js +44 -12
- package/dist/cli.js.map +1 -1
- package/dist/concurrency.js +1 -1
- package/dist/concurrency.js.map +1 -1
- package/dist/dashboard.js +169 -55
- package/dist/dashboard.js.map +1 -1
- package/dist/hooks/checkpoint-quiz.js +3 -2
- package/dist/hooks/checkpoint-quiz.js.map +1 -1
- package/dist/hooks/file-context.js +60 -26
- package/dist/hooks/file-context.js.map +1 -1
- package/dist/hooks/memory-lib.js +3 -5
- package/dist/hooks/memory-lib.js.map +1 -1
- package/dist/hooks/prompt-submit-nudge.js +19 -14
- package/dist/hooks/prompt-submit-nudge.js.map +1 -1
- package/dist/hooks/session-start.js +21 -7
- package/dist/hooks/session-start.js.map +1 -1
- package/dist/hooks/stop-quiz-check.js +3 -2
- package/dist/hooks/stop-quiz-check.js.map +1 -1
- package/dist/hooks/subagent-start.js +39 -8
- package/dist/hooks/subagent-start.js.map +1 -1
- package/dist/memory/recall.js +71 -22
- package/dist/memory/recall.js.map +1 -1
- package/dist/memory/store.js +40 -6
- package/dist/memory/store.js.map +1 -1
- package/dist/memory/tokens.js +16 -1
- package/dist/memory/tokens.js.map +1 -1
- package/dist/memory/worker.js +3 -1
- package/dist/memory/worker.js.map +1 -1
- package/dist/migrations/018_memory_reads.sql +26 -0
- package/dist/migrations/019_attempt_corrections.sql +41 -0
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/agents/explainer.md +9 -5
- package/dist/plugin/hooks/CLAUDE.md +2 -2
- package/dist/plugin/skills/memory/SKILL.md +5 -3
- package/dist/plugin/skills/tutor/SKILL.md +2 -1
- package/dist/plugin/skills/tutor/references/writing-mcq.md +11 -1
- package/dist/relocate.js +1 -0
- package/dist/relocate.js.map +1 -1
- package/dist/store.js +142 -14
- package/dist/store.js.map +1 -1
- package/dist/telemetry-send.js +17 -13
- package/dist/telemetry-send.js.map +1 -1
- package/dist/tools/get_learner_profile.js +5 -3
- package/dist/tools/get_learner_profile.js.map +1 -1
- package/dist/tools/get_session_quiz_plan.js +1 -1
- package/dist/tools/get_session_quiz_plan.js.map +1 -1
- package/dist/tools/memory_read_tools.js +71 -15
- package/dist/tools/memory_read_tools.js.map +1 -1
- package/dist/tools/record_attempt.js +48 -8
- package/dist/tools/record_attempt.js.map +1 -1
- package/dist/user-skill/eklavya/SKILL.md +7 -3
- package/dist/user-skill/eklavya-artifacts/SKILL.md +10 -3
- 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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
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 '
|
|
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
|
|
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` : '
|
|
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}
|
|
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
|
|
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
|
|
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')}
|
|
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.
|
|
2354
|
-
|
|
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,
|
|
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
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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
|
package/dist/cli-memory.js
CHANGED
|
@@ -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
|
|
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}
|
|
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`);
|