eklavya 1.45.0 → 1.47.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 +11 -1
- package/dist/artifacts.js +17 -0
- package/dist/artifacts.js.map +1 -1
- package/dist/assets/artifact-template.html +3 -0
- package/dist/assets/dashboard.html +476 -30
- package/dist/assets/tutor/SKILL.md +2 -1
- package/dist/assets/tutor/references/writing-mcq.md +11 -1
- package/dist/cli.js +44 -12
- package/dist/cli.js.map +1 -1
- package/dist/dashboard.js +201 -47
- 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/memory-lib.js +1 -1
- package/dist/hooks/memory-lib.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/memory/recall.js +1 -1
- package/dist/migrations/019_attempt_corrections.sql +41 -0
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/agents/explainer.md +11 -6
- package/dist/plugin/skills/tutor/SKILL.md +2 -1
- package/dist/plugin/skills/tutor/references/writing-mcq.md +11 -1
- package/dist/store.js +142 -14
- package/dist/store.js.map +1 -1
- package/dist/telemetry-send.js +9 -7
- 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/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 +12 -4
- 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.
|
|
13
|
-
*
|
|
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,13 +26,14 @@ 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';
|
|
36
|
+
import { LIMITS } from './tools/types.js';
|
|
35
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';
|
|
@@ -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
|
|
@@ -445,7 +454,7 @@ export function projectInventory(db) {
|
|
|
445
454
|
COALESCE(SUM(grade >= ${PASSING_GRADE}), 0) AS passed,
|
|
446
455
|
COALESCE(SUM(outcome IN ('declined','dont_know')), 0) AS skipped,
|
|
447
456
|
min(ts) AS first, max(ts) AS last
|
|
448
|
-
FROM attempts GROUP BY repo, session_id, concept_id`)) {
|
|
457
|
+
FROM attempts WHERE ${NOT_CORRECTION} GROUP BY repo, session_id, concept_id`)) {
|
|
449
458
|
const p = at(r.repo, 'attempts');
|
|
450
459
|
p.learning.answers += r.n;
|
|
451
460
|
p.learning.passed += r.passed;
|
|
@@ -574,14 +583,15 @@ export function dashboardState(db) {
|
|
|
574
583
|
// One row per calendar day per project, split three ways. `grade >= 3` is the
|
|
575
584
|
// same pass line the level ladder uses, so the chart and the promotion agree.
|
|
576
585
|
const daily = db
|
|
577
|
-
.prepare(`SELECT date(ts) AS day,
|
|
578
|
-
NULLIF(trim(COALESCE(repo, '')), '') AS repo,
|
|
579
|
-
sum(CASE WHEN grade >= ? THEN 1 ELSE 0 END) AS passed,
|
|
580
|
-
sum(CASE WHEN grade < ? AND (outcome IS NULL OR outcome = 'answered') THEN 1 ELSE 0 END) AS missed,
|
|
581
|
-
sum(CASE WHEN outcome IN ('declined','dont_know') THEN 1 ELSE 0 END) AS skipped
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
|
585
595
|
ORDER BY day`)
|
|
586
596
|
.all(PASSING_GRADE, PASSING_GRADE, `-${TIMELINE_DAYS} days`);
|
|
587
597
|
// A null repo is the pre-migration-003 bucket. It is kept, not dropped:
|
|
@@ -595,18 +605,21 @@ export function dashboardState(db) {
|
|
|
595
605
|
min(a.ts) AS first_active,
|
|
596
606
|
max(a.ts) AS last_active
|
|
597
607
|
FROM attempts a
|
|
608
|
+
WHERE a.${NOT_CORRECTION}
|
|
598
609
|
GROUP BY repo
|
|
599
610
|
ORDER BY last_active DESC`)
|
|
600
611
|
.all(PASSING_GRADE);
|
|
601
612
|
const conceptRows = db
|
|
602
613
|
.prepare(`SELECT c.id, c.slug, c.name, c.domain, c.description, c.tier, c.source,
|
|
603
614
|
m.score, m.ease, m.interval_d, m.reps, m.next_review, m.last_seen,
|
|
604
|
-
(SELECT count(*) FROM attempts a WHERE a.concept_id = c.id) AS attempts,
|
|
605
|
-
(SELECT count(*) FROM attempts a WHERE a.concept_id = c.id AND a.grade >= ${PASSING_GRADE}) AS passed,
|
|
606
|
-
(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}
|
|
607
618
|
AND a.outcome IN ('declined','dont_know')) AS skipped,
|
|
608
|
-
(SELECT
|
|
609
|
-
(SELECT a.
|
|
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,
|
|
610
623
|
(SELECT sc.context FROM session_concepts sc
|
|
611
624
|
WHERE sc.concept_id = c.id AND sc.context IS NOT NULL
|
|
612
625
|
ORDER BY sc.ts DESC LIMIT 1) AS last_context,
|
|
@@ -690,6 +703,7 @@ export function dashboardState(db) {
|
|
|
690
703
|
attempts: row.attempts,
|
|
691
704
|
passed: row.passed,
|
|
692
705
|
skipped: row.skipped,
|
|
706
|
+
corrected: row.corrected,
|
|
693
707
|
last_grade: row.last_grade,
|
|
694
708
|
mastered,
|
|
695
709
|
owed,
|
|
@@ -712,8 +726,14 @@ export function dashboardState(db) {
|
|
|
712
726
|
const attempts = db
|
|
713
727
|
.prepare(`SELECT a.id, c.slug, c.name, c.domain, a.session_id, a.question, a.answer, a.feedback,
|
|
714
728
|
a.grade, a.difficulty AS tier, a.outcome, a.format, a.options, a.ts,
|
|
715
|
-
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
|
|
716
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}
|
|
717
737
|
ORDER BY a.id DESC
|
|
718
738
|
LIMIT ?`)
|
|
719
739
|
.all(ATTEMPT_LIMIT);
|
|
@@ -737,12 +757,14 @@ export function dashboardState(db) {
|
|
|
737
757
|
});
|
|
738
758
|
const allTime = db
|
|
739
759
|
.prepare(`SELECT count(*) AS answers,
|
|
740
|
-
sum(CASE WHEN grade >= ? THEN 1 ELSE 0 END) AS passed,
|
|
741
|
-
sum(CASE WHEN grade < ? AND (outcome IS NULL OR outcome = 'answered') THEN 1 ELSE 0 END) AS missed,
|
|
742
|
-
sum(CASE WHEN outcome IN ('declined','dont_know') THEN 1 ELSE 0 END) AS skipped,
|
|
743
|
-
count(
|
|
744
|
-
|
|
745
|
-
|
|
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}`)
|
|
746
768
|
.get(PASSING_GRADE, PASSING_GRADE);
|
|
747
769
|
const sessionCount = db.prepare('SELECT count(DISTINCT session_id) AS n FROM session_concepts').get().n;
|
|
748
770
|
const memory = memorySummary(db);
|
|
@@ -767,6 +789,7 @@ export function dashboardState(db) {
|
|
|
767
789
|
*/
|
|
768
790
|
cursor: [
|
|
769
791
|
allTime.answers,
|
|
792
|
+
allTime.corrected,
|
|
770
793
|
logged.length,
|
|
771
794
|
sessionCount,
|
|
772
795
|
memory.captured,
|
|
@@ -796,6 +819,7 @@ export function dashboardState(db) {
|
|
|
796
819
|
passed: allTime.passed ?? 0,
|
|
797
820
|
missed: allTime.missed ?? 0,
|
|
798
821
|
skipped: allTime.skipped ?? 0,
|
|
822
|
+
corrected: allTime.corrected,
|
|
799
823
|
mastered: concepts.filter((c) => c.mastered).length,
|
|
800
824
|
due: concepts.filter((c) => c.due).length,
|
|
801
825
|
touched: concepts.filter((c) => c.seen).length,
|
|
@@ -834,9 +858,20 @@ export function dashboardState(db) {
|
|
|
834
858
|
memory_sessions: memorySessions(db),
|
|
835
859
|
// The third workflow. Read from the files themselves on every load
|
|
836
860
|
// (`artifacts.ts`): there is no table to fall out of step with the disk.
|
|
837
|
-
artifacts:
|
|
861
|
+
artifacts: artifactRows(db),
|
|
838
862
|
};
|
|
839
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
|
+
}
|
|
840
875
|
/**
|
|
841
876
|
* The platform's URL opener, as a command and its arguments.
|
|
842
877
|
*
|
|
@@ -1070,6 +1105,61 @@ export function updateSetting(db, body) {
|
|
|
1070
1105
|
// Room for the largest value `SETTING_RULES` accepts — 100 list lines of 500
|
|
1071
1106
|
// characters, JSON-escaped — so the page refuses nothing the CLI would take.
|
|
1072
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
|
+
};
|
|
1073
1163
|
/**
|
|
1074
1164
|
* What a browser may do with anything this server sends. The page is one file
|
|
1075
1165
|
* with an inline script and inline styles, a `data:` favicon, and same-origin
|
|
@@ -1108,10 +1198,67 @@ const ARTIFACT_CSP = [
|
|
|
1108
1198
|
"style-src 'unsafe-inline' https://fonts.googleapis.com",
|
|
1109
1199
|
'font-src https://fonts.gstatic.com',
|
|
1110
1200
|
'img-src data: blob:',
|
|
1111
|
-
|
|
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'",
|
|
1112
1204
|
"base-uri 'none'",
|
|
1113
1205
|
"form-action 'none'",
|
|
1114
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
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Added to every page the viewer frames, so the window is the one scrollbar:
|
|
1222
|
+
* the page stops scrolling itself and reports its height, which the viewer
|
|
1223
|
+
* gives the frame. The height is the body's bottom edge, which shrinks when the
|
|
1224
|
+
* window widens; `scrollHeight` cannot drop below the frame's own height. Pages
|
|
1225
|
+
* on disk carry `main{min-height:100vh}`, and 100vh is the frame, so that floor
|
|
1226
|
+
* goes too. Past the viewer's 20000px cap the page scrolls itself again.
|
|
1227
|
+
*/
|
|
1228
|
+
export const EMBED_SCRIPT = `<script>
|
|
1229
|
+
(function () {
|
|
1230
|
+
if (parent === window || window.__eklavyaEmbed) return; window.__eklavyaEmbed = 1;
|
|
1231
|
+
var root = document.documentElement, body = document.body, last = 0, css = document.createElement('style');
|
|
1232
|
+
root.classList.add('framed');
|
|
1233
|
+
css.textContent = 'html,body{overflow-y:hidden !important}.framed main{min-height:0 !important}';
|
|
1234
|
+
(document.head || root).appendChild(css);
|
|
1235
|
+
function post() {
|
|
1236
|
+
var h = Math.ceil(body.getBoundingClientRect().bottom + scrollY + parseFloat(getComputedStyle(body).marginBottom));
|
|
1237
|
+
css.disabled = h > 20000;
|
|
1238
|
+
if (h !== last) { last = h; parent.postMessage({ type: 'eklavya:height', h: h }, '*'); }
|
|
1239
|
+
}
|
|
1240
|
+
post();
|
|
1241
|
+
addEventListener('DOMContentLoaded', post);
|
|
1242
|
+
addEventListener('load', post);
|
|
1243
|
+
if (window.ResizeObserver) new ResizeObserver(post).observe(body);
|
|
1244
|
+
if (document.fonts) document.fonts.ready.then(post);
|
|
1245
|
+
addEventListener('message', function (e) {
|
|
1246
|
+
var d = e.data;
|
|
1247
|
+
if (e.source === parent && d && d.type === 'eklavya:mode' && (d.mode === 'ink' || d.mode === 'paper')) root.setAttribute('data-mode', d.mode);
|
|
1248
|
+
});
|
|
1249
|
+
})();
|
|
1250
|
+
</script>`;
|
|
1251
|
+
/**
|
|
1252
|
+
* The page as the viewer frames it: no web fonts, and `EMBED_SCRIPT` just
|
|
1253
|
+
* before the last `</body>` (or at the end). A 1.46.0 page carries its own
|
|
1254
|
+
* height report, inside the script that also holds its HTML button; renaming
|
|
1255
|
+
* its message keeps that button and takes the stale height out of the race.
|
|
1256
|
+
*/
|
|
1257
|
+
export function embedHtml(html) {
|
|
1258
|
+
const page = withoutWebFonts(html).replace(/type:'eklavya:height'/g, "type:'eklavya:height-1.46'");
|
|
1259
|
+
const at = page.toLowerCase().lastIndexOf('</body>');
|
|
1260
|
+
return at < 0 ? page + EMBED_SCRIPT : page.slice(0, at) + EMBED_SCRIPT + page.slice(at);
|
|
1261
|
+
}
|
|
1115
1262
|
/**
|
|
1116
1263
|
* What makes the page installable as an app (Chrome and Edge "Install",
|
|
1117
1264
|
* Safari "Add to Dock"), so it opens from Spotlight or the Dock in its own
|
|
@@ -1178,17 +1325,19 @@ export function startDashboard(db, opts = {}) {
|
|
|
1178
1325
|
const host = opts.host ?? '127.0.0.1';
|
|
1179
1326
|
const wanted = opts.port ?? dashboardPort();
|
|
1180
1327
|
const assets = path.join(moduleDir, 'assets');
|
|
1181
|
-
// Per server start. Every write must carry it (see `
|
|
1328
|
+
// Per server start. Every write must carry it (see `acceptWrite`).
|
|
1182
1329
|
const token = randomBytes(24).toString('hex');
|
|
1183
1330
|
const json = (res, status, body) => send(res, status, 'application/json', JSON.stringify(body));
|
|
1184
1331
|
/**
|
|
1185
|
-
* The
|
|
1186
|
-
*
|
|
1187
|
-
* routes accept. So a write also needs a real loopback
|
|
1188
|
-
* content type (which a cross-origin form cannot send
|
|
1189
|
-
* this server never grants), and the token only this
|
|
1332
|
+
* The guard every write goes through, and why the loopback check alone is
|
|
1333
|
+
* not enough for one: a sandboxed frame on any page sends `Origin: null`,
|
|
1334
|
+
* which the read routes accept. So a write also needs a real loopback
|
|
1335
|
+
* `Origin`, a JSON content type (which a cross-origin form cannot send
|
|
1336
|
+
* without a preflight this server never grants), and the token only this
|
|
1337
|
+
* page was served. Only then is the body read, capped, parsed and handed to
|
|
1338
|
+
* the route's handler.
|
|
1190
1339
|
*/
|
|
1191
|
-
const
|
|
1340
|
+
const acceptWrite = (req, res, route) => {
|
|
1192
1341
|
const origin = req.headers.origin;
|
|
1193
1342
|
if (!origin || origin === 'null')
|
|
1194
1343
|
return json(res, 403, { error: 'A write needs a loopback Origin.' });
|
|
@@ -1200,14 +1349,14 @@ export function startDashboard(db, opts = {}) {
|
|
|
1200
1349
|
if (sent.length !== want.length || !timingSafeEqual(sent, want)) {
|
|
1201
1350
|
return json(res, 403, { error: 'Stale or missing dashboard token. Reload the page.' });
|
|
1202
1351
|
}
|
|
1203
|
-
if (Number(req.headers['content-length'] ?? 0) >
|
|
1352
|
+
if (Number(req.headers['content-length'] ?? 0) > route.maxBytes) {
|
|
1204
1353
|
return json(res, 413, { error: 'Request too large.' });
|
|
1205
1354
|
}
|
|
1206
1355
|
const chunks = [];
|
|
1207
1356
|
let size = 0;
|
|
1208
1357
|
req.on('data', (c) => {
|
|
1209
1358
|
size += c.length;
|
|
1210
|
-
if (size >
|
|
1359
|
+
if (size > route.maxBytes) {
|
|
1211
1360
|
// Answer first and drop the connection only once the answer is out,
|
|
1212
1361
|
// so the browser reads the 413 instead of a reset.
|
|
1213
1362
|
res.on('finish', () => req.destroy());
|
|
@@ -1229,7 +1378,7 @@ export function startDashboard(db, opts = {}) {
|
|
|
1229
1378
|
catch {
|
|
1230
1379
|
return json(res, 400, { error: 'Not valid JSON.' });
|
|
1231
1380
|
}
|
|
1232
|
-
const out =
|
|
1381
|
+
const out = route.handler(db, body);
|
|
1233
1382
|
json(res, out.status, out.body);
|
|
1234
1383
|
});
|
|
1235
1384
|
};
|
|
@@ -1238,8 +1387,7 @@ export function startDashboard(db, opts = {}) {
|
|
|
1238
1387
|
// A page the developer happens to have open can point a hostname it
|
|
1239
1388
|
// controls at 127.0.0.1 and fetch from here -- DNS rebinding -- and the
|
|
1240
1389
|
// same-origin policy does not help, because the page's origin *is* that
|
|
1241
|
-
// hostname. The risk is a write
|
|
1242
|
-
// for that) and that this payload contains the developer's prompts, code and project
|
|
1390
|
+
// hostname. The risk is a write (`acceptWrite` adds a token for those) and that this payload contains the developer's prompts, code and project
|
|
1243
1391
|
// history, and a hostile page would be reading all of it.
|
|
1244
1392
|
//
|
|
1245
1393
|
// The check is the standard one: the request has to have been addressed to
|
|
@@ -1249,14 +1397,14 @@ export function startDashboard(db, opts = {}) {
|
|
|
1249
1397
|
}
|
|
1250
1398
|
/* c8 ignore next -- a server-side request always carries its url; the fallback is for the type */
|
|
1251
1399
|
const url = new URL(req.url ?? '/', `http://${host}`);
|
|
1252
|
-
//
|
|
1253
|
-
//
|
|
1254
|
-
|
|
1255
|
-
if (
|
|
1256
|
-
return
|
|
1400
|
+
// The routes in `WRITES` take a POST, through `acceptWrite`. Everything
|
|
1401
|
+
// else reads, and any other method is refused rather than answered as a GET.
|
|
1402
|
+
const write = WRITES[url.pathname];
|
|
1403
|
+
if (write && req.method === 'POST')
|
|
1404
|
+
return acceptWrite(req, res, write);
|
|
1257
1405
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
1258
|
-
return send(res, 405, 'text/plain',
|
|
1259
|
-
allow:
|
|
1406
|
+
return send(res, 405, 'text/plain', `This route is read-only. Writes: ${Object.keys(WRITES).join(', ')}.\n`, {
|
|
1407
|
+
allow: write ? 'GET, HEAD, POST' : 'GET, HEAD',
|
|
1260
1408
|
});
|
|
1261
1409
|
}
|
|
1262
1410
|
try {
|
|
@@ -1299,6 +1447,10 @@ export function startDashboard(db, opts = {}) {
|
|
|
1299
1447
|
per: Number(g('per')) || MEMORY_PER,
|
|
1300
1448
|
})));
|
|
1301
1449
|
}
|
|
1450
|
+
if (url.pathname === '/api/attempts/correction') {
|
|
1451
|
+
const out = correctionState(db, Number(url.searchParams.get('id')));
|
|
1452
|
+
return send(res, out.status, 'application/json', JSON.stringify(out.body));
|
|
1453
|
+
}
|
|
1302
1454
|
if (url.pathname === '/api/memory/entry') {
|
|
1303
1455
|
const entry = memoryEntry(db, Number(url.searchParams.get('id')));
|
|
1304
1456
|
if (!entry)
|
|
@@ -1327,8 +1479,10 @@ export function startDashboard(db, opts = {}) {
|
|
|
1327
1479
|
'cache-control': 'private, max-age=3600',
|
|
1328
1480
|
});
|
|
1329
1481
|
}
|
|
1330
|
-
|
|
1331
|
-
|
|
1482
|
+
const embed = url.searchParams.has('embed');
|
|
1483
|
+
return send(res, 200, 'text/html; charset=utf-8', embed ? embedHtml(fs.readFileSync(file, 'utf8')) : fs.readFileSync(file), {
|
|
1484
|
+
'content-security-policy': embed ? ARTIFACT_EMBED_CSP : ARTIFACT_CSP,
|
|
1485
|
+
'x-frame-options': 'SAMEORIGIN',
|
|
1332
1486
|
});
|
|
1333
1487
|
}
|
|
1334
1488
|
if (url.pathname === '/manifest.webmanifest') {
|