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
package/dist/dashboard.js CHANGED
@@ -9,8 +9,9 @@
9
9
  * Deliberately a static page plus JSON endpoints: no framework, no build
10
10
  * step, same rule the landing page follows. It binds to loopback only — the
11
11
  * data never leaves the machine, and that is a promise the landing page makes
12
- * on Eklavya's behalf. The one write, a setting, also needs the page's token
13
- * (see `postSettings`).
12
+ * on Eklavya's behalf. It is read-only except the routes in `WRITES`, and every
13
+ * one of those goes through `acceptWrite`: loopback Origin, JSON, the page's
14
+ * per-start token and a size cap.
14
15
  *
15
16
  * The endpoint ships mostly *flat rows* — every attempt, every logged context,
16
17
  * one row per day — and lets the page derive the views. One aggregate query per
@@ -25,14 +26,15 @@ import { spawn } from 'node:child_process';
25
26
  import path from 'node:path';
26
27
  import { fileURLToPath } from 'node:url';
27
28
  import { decayedScore, isKnown, isOwed, MS_PER_DAY } from './srs.js';
28
- import { GLOBAL_PROJECT, levelStanding, PASSING_GRADE, projectKey } from './store.js';
29
+ import { correctionStates, correctionTarget, CorrectionError, GLOBAL_PROJECT, levelStanding, PASSING_GRADE, projectKey, recordRetry, } from './store.js';
29
30
  import { randomBytes, timingSafeEqual } from 'node:crypto';
30
31
  import { loadConfig, loadGlobalConfig, DEFAULT_CONFIG, configFileProblem, isGlobalOnlyKey, mainRepoRoot, readConfigFile, normalizeLegacyKeys, } from './config.js';
31
32
  import { applySetting, knownKeys, SETTING_RULES, valueAt } from './config-path.js';
32
33
  import { dashboardPort, dbPath, DEFAULT_PORT, eklavyaHome, globalConfigPath, projectConfigPath } from './paths.js';
33
34
  import { ownVersion } from './dashboard-daemon.js';
34
35
  import { artifactThumb, listArtifacts, resolveArtifact } from './artifacts.js';
35
- import { NOT_HELPER_RECEIPT, receiptTotals } from './memory/store.js';
36
+ import { LIMITS } from './tools/types.js';
37
+ import { DELIVERED, NOT_HELPER_RECEIPT, readTotals, receiptTotals, totalsDelivery } from './memory/store.js';
36
38
  import { ESTIMATOR, savingsFrom, savingsLine } from './memory/tokens.js';
37
39
  import { queueDepth } from './memory/worker.js';
38
40
  import { droppedCount } from './memory/spool.js';
@@ -74,6 +76,13 @@ const RECEIPT_LIMIT = 200;
74
76
  const MEMORY_SESSION_LIMIT = 500;
75
77
  /** Longest narrative excerpt a timeline row carries; the detail page has it all. */
76
78
  const SNIPPET = 150;
79
+ /**
80
+ * A correction row (`retry_of`, migration 019) is a pick made on this page
81
+ * after the explainer showed the answer, not a question asked: no count here
82
+ * includes it. The miss it corrects is counted once, as `corrected` instead of
83
+ * missed or skipped, by joining that row as `fix`.
84
+ */
85
+ const NOT_CORRECTION = 'retry_of IS NULL';
77
86
  /**
78
87
  * Two timestamp shapes live in this database and both have to parse.
79
88
  * `attempts.ts` defaults to SQLite's `2026-09-05 21:04:00` — UTC, no zone
@@ -140,9 +149,9 @@ function memorySummary(db) {
140
149
  JOIN memory_entries e ON e.id = v.entry_id WHERE e.deleted_at IS NULL`).n,
141
150
  // Entries a receipt ever selected …
142
151
  reused: one(db, 'SELECT count(DISTINCT entry_id) AS n FROM context_receipt_items').n,
143
- // … and the subset that a *confirmed* delivery actually put in front of the agent.
152
+ // … and the subset a hook actually wrote out, rather than only prepared.
144
153
  exposed: one(db, `SELECT count(DISTINCT i.entry_id) AS n FROM context_receipt_items i
145
- JOIN context_receipts r ON r.id = i.receipt_id WHERE r.delivery = 'confirmed'`).n,
154
+ JOIN context_receipts r ON r.id = i.receipt_id WHERE r.${DELIVERED}`).n,
146
155
  // Concepts that came out of evidence and were then actually answered on.
147
156
  assessed: one(db, `SELECT count(DISTINCT ls.concept_id) AS n FROM learning_sources ls
148
157
  JOIN attempts a ON a.concept_id = ls.concept_id
@@ -161,20 +170,22 @@ function memorySummary(db) {
161
170
  * The savings ledger, receipt by receipt.
162
171
  *
163
172
  * `B` is what the same material would have cost read from source; `D` is what
164
- * was actually delivered. Only a *confirmed* delivery contributes to the
165
- * headline — `receiptTotals` sums the confirmed rows alone, and `savingsFrom`
166
- * refuses to divide at all unless the delivery was confirmed, so an optimistic
167
- * "prepared" receipt can never be shown as a saving.
173
+ * was actually delivered. Only context a hook wrote out (`emitted`, or a legacy
174
+ * `confirmed`) contributes to the headline — `receiptTotals` sums those rows
175
+ * alone, and `savingsFrom` refuses to divide for anything else, so a
176
+ * "prepared" receipt can never be shown as a saving. `reads` counts explicit
177
+ * memory reads, receipt or not, so a fetch without one is not lost.
168
178
  */
169
179
  function reuseSummary(db) {
170
180
  const totals = receiptTotals(db);
171
181
  const savings = savingsFrom({
172
182
  baseTokens: totals.base,
173
183
  deliveredTokens: totals.delivered,
174
- delivery: totals.confirmed > 0 ? 'confirmed' : 'unknown',
184
+ delivery: totalsDelivery(totals),
175
185
  });
176
186
  return {
177
187
  ...totals,
188
+ reads: readTotals(db),
178
189
  savings,
179
190
  line: savingsLine(savings),
180
191
  estimator: ESTIMATOR,
@@ -443,7 +454,7 @@ export function projectInventory(db) {
443
454
  COALESCE(SUM(grade >= ${PASSING_GRADE}), 0) AS passed,
444
455
  COALESCE(SUM(outcome IN ('declined','dont_know')), 0) AS skipped,
445
456
  min(ts) AS first, max(ts) AS last
446
- FROM attempts GROUP BY repo, session_id, concept_id`)) {
457
+ FROM attempts WHERE ${NOT_CORRECTION} GROUP BY repo, session_id, concept_id`)) {
447
458
  const p = at(r.repo, 'attempts');
448
459
  p.learning.answers += r.n;
449
460
  p.learning.passed += r.passed;
@@ -572,14 +583,15 @@ export function dashboardState(db) {
572
583
  // One row per calendar day per project, split three ways. `grade >= 3` is the
573
584
  // same pass line the level ladder uses, so the chart and the promotion agree.
574
585
  const daily = db
575
- .prepare(`SELECT date(ts) AS day,
576
- NULLIF(trim(COALESCE(repo, '')), '') AS repo,
577
- sum(CASE WHEN grade >= ? THEN 1 ELSE 0 END) AS passed,
578
- sum(CASE WHEN grade < ? AND (outcome IS NULL OR outcome = 'answered') THEN 1 ELSE 0 END) AS missed,
579
- sum(CASE WHEN outcome IN ('declined','dont_know') THEN 1 ELSE 0 END) AS skipped
580
- FROM attempts
581
- WHERE ts >= date('now', ?)
582
- GROUP BY day, repo
586
+ .prepare(`SELECT date(a.ts) AS day,
587
+ NULLIF(trim(COALESCE(a.repo, '')), '') AS repo,
588
+ sum(CASE WHEN a.grade >= ? THEN 1 ELSE 0 END) AS passed,
589
+ sum(CASE WHEN a.grade < ? AND (a.outcome IS NULL OR a.outcome = 'answered') AND fix.id IS NULL THEN 1 ELSE 0 END) AS missed,
590
+ sum(CASE WHEN a.outcome IN ('declined','dont_know') AND fix.id IS NULL THEN 1 ELSE 0 END) AS skipped,
591
+ count(fix.id) AS corrected
592
+ FROM attempts a LEFT JOIN attempts fix ON fix.retry_of = a.id
593
+ WHERE a.${NOT_CORRECTION} AND a.ts >= date('now', ?)
594
+ GROUP BY 1, 2
583
595
  ORDER BY day`)
584
596
  .all(PASSING_GRADE, PASSING_GRADE, `-${TIMELINE_DAYS} days`);
585
597
  // A null repo is the pre-migration-003 bucket. It is kept, not dropped:
@@ -593,18 +605,21 @@ export function dashboardState(db) {
593
605
  min(a.ts) AS first_active,
594
606
  max(a.ts) AS last_active
595
607
  FROM attempts a
608
+ WHERE a.${NOT_CORRECTION}
596
609
  GROUP BY repo
597
610
  ORDER BY last_active DESC`)
598
611
  .all(PASSING_GRADE);
599
612
  const conceptRows = db
600
613
  .prepare(`SELECT c.id, c.slug, c.name, c.domain, c.description, c.tier, c.source,
601
614
  m.score, m.ease, m.interval_d, m.reps, m.next_review, m.last_seen,
602
- (SELECT count(*) FROM attempts a WHERE a.concept_id = c.id) AS attempts,
603
- (SELECT count(*) FROM attempts a WHERE a.concept_id = c.id AND a.grade >= ${PASSING_GRADE}) AS passed,
604
- (SELECT count(*) FROM attempts a WHERE a.concept_id = c.id
615
+ (SELECT count(*) FROM attempts a WHERE a.concept_id = c.id AND a.${NOT_CORRECTION}) AS attempts,
616
+ (SELECT count(*) FROM attempts a WHERE a.concept_id = c.id AND a.${NOT_CORRECTION} AND a.grade >= ${PASSING_GRADE}) AS passed,
617
+ (SELECT count(*) FROM attempts a WHERE a.concept_id = c.id AND a.${NOT_CORRECTION}
605
618
  AND a.outcome IN ('declined','dont_know')) AS skipped,
606
- (SELECT min(a.ts) FROM attempts a WHERE a.concept_id = c.id) AS first_asked,
607
- (SELECT a.grade FROM attempts a WHERE a.concept_id = c.id ORDER BY a.id DESC LIMIT 1) AS last_grade,
619
+ (SELECT count(*) FROM attempts a WHERE a.concept_id = c.id AND a.retry_of IS NOT NULL) AS corrected,
620
+ (SELECT min(a.ts) FROM attempts a WHERE a.concept_id = c.id AND a.${NOT_CORRECTION}) AS first_asked,
621
+ -- The backlog rule's grade (OWED_SQL in store.ts): a correction does not clear it.
622
+ (SELECT a.grade FROM attempts a WHERE a.concept_id = c.id AND a.${NOT_CORRECTION} ORDER BY a.id DESC LIMIT 1) AS last_grade,
608
623
  (SELECT sc.context FROM session_concepts sc
609
624
  WHERE sc.concept_id = c.id AND sc.context IS NOT NULL
610
625
  ORDER BY sc.ts DESC LIMIT 1) AS last_context,
@@ -688,6 +703,7 @@ export function dashboardState(db) {
688
703
  attempts: row.attempts,
689
704
  passed: row.passed,
690
705
  skipped: row.skipped,
706
+ corrected: row.corrected,
691
707
  last_grade: row.last_grade,
692
708
  mastered,
693
709
  owed,
@@ -710,8 +726,14 @@ export function dashboardState(db) {
710
726
  const attempts = db
711
727
  .prepare(`SELECT a.id, c.slug, c.name, c.domain, a.session_id, a.question, a.answer, a.feedback,
712
728
  a.grade, a.difficulty AS tier, a.outcome, a.format, a.options, a.ts,
713
- NULLIF(trim(COALESCE(a.repo, '')), '') AS repo, a.level
729
+ NULLIF(trim(COALESCE(a.repo, '')), '') AS repo, a.level,
730
+ -- A miss corrected from its explainer: when, and on which try.
731
+ fix.ts AS corrected_at,
732
+ CASE WHEN fix.id IS NULL THEN NULL
733
+ ELSE (SELECT count(*) FROM attempt_retries r WHERE r.attempt_id = a.id) END AS corrected_try
714
734
  FROM attempts a JOIN concepts c ON c.id = a.concept_id
735
+ LEFT JOIN attempts fix ON fix.retry_of = a.id
736
+ WHERE a.${NOT_CORRECTION}
715
737
  ORDER BY a.id DESC
716
738
  LIMIT ?`)
717
739
  .all(ATTEMPT_LIMIT);
@@ -735,12 +757,14 @@ export function dashboardState(db) {
735
757
  });
736
758
  const allTime = db
737
759
  .prepare(`SELECT count(*) AS answers,
738
- sum(CASE WHEN grade >= ? THEN 1 ELSE 0 END) AS passed,
739
- sum(CASE WHEN grade < ? AND (outcome IS NULL OR outcome = 'answered') THEN 1 ELSE 0 END) AS missed,
740
- sum(CASE WHEN outcome IN ('declined','dont_know') THEN 1 ELSE 0 END) AS skipped,
741
- count(DISTINCT date(ts)) AS active_days,
742
- min(ts) AS first_answer
743
- FROM attempts`)
760
+ sum(CASE WHEN a.grade >= ? THEN 1 ELSE 0 END) AS passed,
761
+ sum(CASE WHEN a.grade < ? AND (a.outcome IS NULL OR a.outcome = 'answered') AND fix.id IS NULL THEN 1 ELSE 0 END) AS missed,
762
+ sum(CASE WHEN a.outcome IN ('declined','dont_know') AND fix.id IS NULL THEN 1 ELSE 0 END) AS skipped,
763
+ count(fix.id) AS corrected,
764
+ count(DISTINCT date(a.ts)) AS active_days,
765
+ min(a.ts) AS first_answer
766
+ FROM attempts a LEFT JOIN attempts fix ON fix.retry_of = a.id
767
+ WHERE a.${NOT_CORRECTION}`)
744
768
  .get(PASSING_GRADE, PASSING_GRADE);
745
769
  const sessionCount = db.prepare('SELECT count(DISTINCT session_id) AS n FROM session_concepts').get().n;
746
770
  const memory = memorySummary(db);
@@ -765,6 +789,7 @@ export function dashboardState(db) {
765
789
  */
766
790
  cursor: [
767
791
  allTime.answers,
792
+ allTime.corrected,
768
793
  logged.length,
769
794
  sessionCount,
770
795
  memory.captured,
@@ -794,6 +819,7 @@ export function dashboardState(db) {
794
819
  passed: allTime.passed ?? 0,
795
820
  missed: allTime.missed ?? 0,
796
821
  skipped: allTime.skipped ?? 0,
822
+ corrected: allTime.corrected,
797
823
  mastered: concepts.filter((c) => c.mastered).length,
798
824
  due: concepts.filter((c) => c.due).length,
799
825
  touched: concepts.filter((c) => c.seen).length,
@@ -832,9 +858,20 @@ export function dashboardState(db) {
832
858
  memory_sessions: memorySessions(db),
833
859
  // The third workflow. Read from the files themselves on every load
834
860
  // (`artifacts.ts`): there is no table to fall out of step with the disk.
835
- artifacts: listArtifacts(),
861
+ artifacts: artifactRows(db),
836
862
  };
837
863
  }
864
+ /**
865
+ * `listArtifacts`, plus whether each explainer can still be corrected:
866
+ * `open`, `done`, or null for a page with no attempt, a plain artifact, or an
867
+ * attempt recorded without its right answer. One query for all of them.
868
+ */
869
+ function artifactRows(db) {
870
+ const rows = listArtifacts();
871
+ const ids = rows.filter((r) => r.kind === 'explainer' && r.attempt !== null).map((r) => r.attempt);
872
+ const states = correctionStates(db, ids);
873
+ return rows.map((r) => ({ ...r, correction: (r.kind === 'explainer' && states.get(r.attempt)) || null }));
874
+ }
838
875
  /**
839
876
  * The platform's URL opener, as a command and its arguments.
840
877
  *
@@ -1068,6 +1105,61 @@ export function updateSetting(db, body) {
1068
1105
  // Room for the largest value `SETTING_RULES` accepts — 100 list lines of 500
1069
1106
  // characters, JSON-escaped — so the page refuses nothing the CLI would take.
1070
1107
  const MAX_SETTINGS_BODY = 256 * 1024;
1108
+ /** Each refusal a correction can meet: its status and the words the modal shows. */
1109
+ const CORRECTION_ERRORS = {
1110
+ not_found: { status: 404, error: 'No such question.' },
1111
+ not_correctable: { status: 409, error: 'This answer cannot be corrected: it was recorded without its right answer, or it was not a miss.' },
1112
+ already_corrected: { status: 409, error: 'Already corrected.' },
1113
+ not_an_option: { status: 400, error: 'That is not one of the options.' },
1114
+ };
1115
+ function correctionError(code) {
1116
+ const e = CORRECTION_ERRORS[code];
1117
+ return { status: e.status, body: { error: e.error, code } };
1118
+ }
1119
+ /**
1120
+ * `GET /api/attempts/correction?id=`: the missed question the correction modal
1121
+ * shows. Never the right answer -- the server grades a pick, the page does not.
1122
+ */
1123
+ export function correctionState(db, id) {
1124
+ const t = correctionTarget(db, id);
1125
+ if (!t)
1126
+ return correctionError('not_found');
1127
+ if (!t.correctable)
1128
+ return correctionError('not_correctable');
1129
+ const { correct: _answer, correctable: _c, ...shown } = t;
1130
+ return { status: 200, body: shown };
1131
+ }
1132
+ /** `POST /api/attempts/retry`: `{ attempt_id, picked }`, one pick on the correction modal. */
1133
+ export function retryAttempt(db, body) {
1134
+ const bad = (error) => ({ status: 400, body: { error } });
1135
+ if (!body || typeof body !== 'object' || Array.isArray(body))
1136
+ return bad('Expected a JSON object.');
1137
+ const { attempt_id: id, picked } = body;
1138
+ if (typeof id !== 'number' || !Number.isSafeInteger(id) || id < 1)
1139
+ return bad('attempt_id is a positive integer.');
1140
+ if (typeof picked !== 'string' || picked.length > LIMITS.option)
1141
+ return bad('picked is the option label, as text.');
1142
+ try {
1143
+ return { status: 200, body: recordRetry(db, id, picked, new Date()) };
1144
+ }
1145
+ catch (err) {
1146
+ if (err instanceof CorrectionError)
1147
+ return correctionError(err.code);
1148
+ throw err;
1149
+ }
1150
+ }
1151
+ /**
1152
+ * Every route that writes, and nothing else does. Exact paths, no parameters:
1153
+ * a write carries its target in its JSON body. A new write is one row here and
1154
+ * one handler; `acceptWrite` gives it the same guard as the rest, and the
1155
+ * table-driven test in `dashboard.test.ts` runs every guard against it.
1156
+ */
1157
+ export const WRITES = {
1158
+ '/api/settings': { handler: updateSetting, maxBytes: MAX_SETTINGS_BODY },
1159
+ // An option label is at most LIMITS.option characters; 16 KiB holds one
1160
+ // JSON-escaped with room to spare.
1161
+ '/api/attempts/retry': { handler: retryAttempt, maxBytes: 16 * 1024 },
1162
+ };
1071
1163
  /**
1072
1164
  * What a browser may do with anything this server sends. The page is one file
1073
1165
  * with an inline script and inline styles, a `data:` favicon, and same-origin
@@ -1106,10 +1198,25 @@ const ARTIFACT_CSP = [
1106
1198
  "style-src 'unsafe-inline' https://fonts.googleapis.com",
1107
1199
  'font-src https://fonts.gstatic.com',
1108
1200
  'img-src data: blob:',
1109
- "frame-ancestors 'none'",
1201
+ // The dashboard's explainer viewer frames it (`#/artifacts/view/`); the
1202
+ // sandbox above still keeps the framed page off the API.
1203
+ "frame-ancestors 'self'",
1110
1204
  "base-uri 'none'",
1111
1205
  "form-action 'none'",
1112
1206
  ].join('; ');
1207
+ /**
1208
+ * The same page framed in the dashboard's viewer (`?embed`). The dashboard
1209
+ * makes no request to any other host, and a framed page's requests are the
1210
+ * dashboard's, so the web fonts go: the template's links are cut out
1211
+ * (`withoutWebFonts`), and this CSP refuses any that an agent added.
1212
+ */
1213
+ const ARTIFACT_EMBED_CSP = ARTIFACT_CSP
1214
+ .replace(" https://fonts.googleapis.com", '')
1215
+ .replace('font-src https://fonts.gstatic.com', "font-src 'none'");
1216
+ /** An artifact without its Google Fonts links; it falls back to the system faces in the token stacks. */
1217
+ export function withoutWebFonts(html) {
1218
+ return html.replace(/<link\b[^>]*\bhref="https:\/\/fonts\.(?:googleapis|gstatic)\.com[^>]*>\s*/gi, '');
1219
+ }
1113
1220
  /**
1114
1221
  * What makes the page installable as an app (Chrome and Edge "Install",
1115
1222
  * Safari "Add to Dock"), so it opens from Spotlight or the Dock in its own
@@ -1176,17 +1283,19 @@ export function startDashboard(db, opts = {}) {
1176
1283
  const host = opts.host ?? '127.0.0.1';
1177
1284
  const wanted = opts.port ?? dashboardPort();
1178
1285
  const assets = path.join(moduleDir, 'assets');
1179
- // Per server start. Every write must carry it (see `postSettings`).
1286
+ // Per server start. Every write must carry it (see `acceptWrite`).
1180
1287
  const token = randomBytes(24).toString('hex');
1181
1288
  const json = (res, status, body) => send(res, status, 'application/json', JSON.stringify(body));
1182
1289
  /**
1183
- * The one mutating route, and why the loopback check alone is not enough for
1184
- * it: a sandboxed frame on any page sends `Origin: null`, which the read
1185
- * routes accept. So a write also needs a real loopback `Origin`, a JSON
1186
- * content type (which a cross-origin form cannot send without a preflight
1187
- * this server never grants), and the token only this page was served.
1290
+ * The guard every write goes through, and why the loopback check alone is
1291
+ * not enough for one: a sandboxed frame on any page sends `Origin: null`,
1292
+ * which the read routes accept. So a write also needs a real loopback
1293
+ * `Origin`, a JSON content type (which a cross-origin form cannot send
1294
+ * without a preflight this server never grants), and the token only this
1295
+ * page was served. Only then is the body read, capped, parsed and handed to
1296
+ * the route's handler.
1188
1297
  */
1189
- const postSettings = (req, res) => {
1298
+ const acceptWrite = (req, res, route) => {
1190
1299
  const origin = req.headers.origin;
1191
1300
  if (!origin || origin === 'null')
1192
1301
  return json(res, 403, { error: 'A write needs a loopback Origin.' });
@@ -1198,14 +1307,14 @@ export function startDashboard(db, opts = {}) {
1198
1307
  if (sent.length !== want.length || !timingSafeEqual(sent, want)) {
1199
1308
  return json(res, 403, { error: 'Stale or missing dashboard token. Reload the page.' });
1200
1309
  }
1201
- if (Number(req.headers['content-length'] ?? 0) > MAX_SETTINGS_BODY) {
1310
+ if (Number(req.headers['content-length'] ?? 0) > route.maxBytes) {
1202
1311
  return json(res, 413, { error: 'Request too large.' });
1203
1312
  }
1204
1313
  const chunks = [];
1205
1314
  let size = 0;
1206
1315
  req.on('data', (c) => {
1207
1316
  size += c.length;
1208
- if (size > MAX_SETTINGS_BODY) {
1317
+ if (size > route.maxBytes) {
1209
1318
  // Answer first and drop the connection only once the answer is out,
1210
1319
  // so the browser reads the 413 instead of a reset.
1211
1320
  res.on('finish', () => req.destroy());
@@ -1227,7 +1336,7 @@ export function startDashboard(db, opts = {}) {
1227
1336
  catch {
1228
1337
  return json(res, 400, { error: 'Not valid JSON.' });
1229
1338
  }
1230
- const out = updateSetting(db, body);
1339
+ const out = route.handler(db, body);
1231
1340
  json(res, out.status, out.body);
1232
1341
  });
1233
1342
  };
@@ -1236,8 +1345,7 @@ export function startDashboard(db, opts = {}) {
1236
1345
  // A page the developer happens to have open can point a hostname it
1237
1346
  // controls at 127.0.0.1 and fetch from here -- DNS rebinding -- and the
1238
1347
  // same-origin policy does not help, because the page's origin *is* that
1239
- // hostname. The risk is a write to settings (`postSettings` adds a token
1240
- // for that) and that this payload contains the developer's prompts, code and project
1348
+ // hostname. The risk is a write (`acceptWrite` adds a token for those) and that this payload contains the developer's prompts, code and project
1241
1349
  // history, and a hostile page would be reading all of it.
1242
1350
  //
1243
1351
  // The check is the standard one: the request has to have been addressed to
@@ -1247,14 +1355,14 @@ export function startDashboard(db, opts = {}) {
1247
1355
  }
1248
1356
  /* c8 ignore next -- a server-side request always carries its url; the fallback is for the type */
1249
1357
  const url = new URL(req.url ?? '/', `http://${host}`);
1250
- // One route writes: a setting, through the same `applySetting` the CLI
1251
- // uses. Everything else reads, and any other method is refused rather
1252
- // than answered as a GET.
1253
- if (url.pathname === '/api/settings' && req.method === 'POST')
1254
- return postSettings(req, res);
1358
+ // The routes in `WRITES` take a POST, through `acceptWrite`. Everything
1359
+ // else reads, and any other method is refused rather than answered as a GET.
1360
+ const write = WRITES[url.pathname];
1361
+ if (write && req.method === 'POST')
1362
+ return acceptWrite(req, res, write);
1255
1363
  if (req.method !== 'GET' && req.method !== 'HEAD') {
1256
- return send(res, 405, 'text/plain', 'Only /api/settings accepts a write; everything else here is read-only.\n', {
1257
- allow: url.pathname === '/api/settings' ? 'GET, HEAD, POST' : 'GET, HEAD',
1364
+ return send(res, 405, 'text/plain', `This route is read-only. Writes: ${Object.keys(WRITES).join(', ')}.\n`, {
1365
+ allow: write ? 'GET, HEAD, POST' : 'GET, HEAD',
1258
1366
  });
1259
1367
  }
1260
1368
  try {
@@ -1297,6 +1405,10 @@ export function startDashboard(db, opts = {}) {
1297
1405
  per: Number(g('per')) || MEMORY_PER,
1298
1406
  })));
1299
1407
  }
1408
+ if (url.pathname === '/api/attempts/correction') {
1409
+ const out = correctionState(db, Number(url.searchParams.get('id')));
1410
+ return send(res, out.status, 'application/json', JSON.stringify(out.body));
1411
+ }
1300
1412
  if (url.pathname === '/api/memory/entry') {
1301
1413
  const entry = memoryEntry(db, Number(url.searchParams.get('id')));
1302
1414
  if (!entry)
@@ -1325,8 +1437,10 @@ export function startDashboard(db, opts = {}) {
1325
1437
  'cache-control': 'private, max-age=3600',
1326
1438
  });
1327
1439
  }
1328
- return send(res, 200, 'text/html; charset=utf-8', fs.readFileSync(file), {
1329
- 'content-security-policy': ARTIFACT_CSP,
1440
+ const embed = url.searchParams.has('embed');
1441
+ return send(res, 200, 'text/html; charset=utf-8', embed ? withoutWebFonts(fs.readFileSync(file, 'utf8')) : fs.readFileSync(file), {
1442
+ 'content-security-policy': embed ? ARTIFACT_EMBED_CSP : ARTIFACT_CSP,
1443
+ 'x-frame-options': 'SAMEORIGIN',
1330
1444
  });
1331
1445
  }
1332
1446
  if (url.pathname === '/manifest.webmanifest') {