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
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,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 {
|
|
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
|
|
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
|
|
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
|
|
165
|
-
* headline — `receiptTotals` sums
|
|
166
|
-
* refuses to divide
|
|
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
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
|
607
|
-
(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,
|
|
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(
|
|
742
|
-
|
|
743
|
-
|
|
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:
|
|
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
|
-
|
|
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 `
|
|
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
|
|
1184
|
-
*
|
|
1185
|
-
* routes accept. So a write also needs a real loopback
|
|
1186
|
-
* content type (which a cross-origin form cannot send
|
|
1187
|
-
* this server never grants), and the token only this
|
|
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
|
|
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) >
|
|
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 >
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
1251
|
-
//
|
|
1252
|
-
|
|
1253
|
-
if (
|
|
1254
|
-
return
|
|
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',
|
|
1257
|
-
allow:
|
|
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
|
-
|
|
1329
|
-
|
|
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') {
|