eklavya 1.66.1 → 1.67.1

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/dist/dashboard.js CHANGED
@@ -28,9 +28,9 @@ import { fileURLToPath } from 'node:url';
28
28
  import { decayedScore, isKnown, isOwed, MS_PER_DAY } from './srs.js';
29
29
  import { correctionStates, correctionTarget, CorrectionError, GLOBAL_PROJECT, levelStanding, PASSING_GRADE, projectKey, promoteIfEarned, recordRetry, } from './store.js';
30
30
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
31
- import { loadConfig, loadGlobalConfig, loadProjectConfig, configFileProblem, isGlobalOnlyKey, mainRepoRoot, readConfigFile, normalizeLegacyKeys, } from './config.js';
31
+ import { findRepoConfig, LEGACY_REPO_CONFIG_FILE, loadConfig, loadGlobalConfig, loadProjectConfig, configFileProblem, isGlobalOnlyKey, mainRepoRoot, readConfigFile, normalizeLegacyKeys, } from './config.js';
32
32
  import { applySetting, knownKeys, SETTING_RULES, valueAt } from './config-path.js';
33
- import { dashboardPort, dbPath, DEFAULT_PORT, eklavyaHome, globalConfigPath, projectConfigPath } from './paths.js';
33
+ import { artifactsDir, dashboardPort, dbPath, DEFAULT_PORT, eklavyaHome, globalConfigPath, projectConfigPath, projectsDir, } from './paths.js';
34
34
  import { ownVersion } from './dashboard-daemon.js';
35
35
  import { countUse } from './telemetry.js';
36
36
  import { acknowledgeFeedback, deleteFeedback, feedbackSummary, getFeedback, feedbackPending, listAcknowledged } from './feedback.js';
@@ -50,11 +50,38 @@ export { DEFAULT_PORT };
50
50
  */
51
51
  const TIMELINE_DAYS = 365;
52
52
  /**
53
- * Cap on the attempt rows shipped for drill-down. Counts and per-day totals are
54
- * aggregated in SQL over the whole table, so the cap can only ever shorten a
55
- * history list, never make a number wrong.
53
+ * Cap on the attempt rows shipped for drill-down, newest first. Counts and
54
+ * per-day totals are aggregated in SQL over the whole table, so the cap can only
55
+ * ever shorten a history list, never make a number wrong: anything the page
56
+ * counts, or narrows a project to, it takes from those aggregates and not from
57
+ * these rows (`totals`, `concepts`, `daily`, and `projectInventory`'s per-project
58
+ * counts, `totals` and `concept_slugs`).
59
+ *
60
+ * One number with `LOGGED_LIMIT`, and the one that sets the size of `/api/state`:
61
+ * a row is about 0.85 KB (a question, its options, the answer and the tutor's
62
+ * feedback), so these rows are over half of the payload. The budget for the
63
+ * payload at a year of daily use is 1.5 MB (issue #171), and at 2,000 rows of
64
+ * each kind it was 2.5 MB however the rest was trimmed: the two lists alone
65
+ * were more than that. At this size it is 1.15 MB with a 25-character
66
+ * repository path and 1.44 MB with a 105-character one (every row names its
67
+ * repository, so the path's length is in the size), which is why the number is
68
+ * not larger. `scripts/dashboard-perf.mjs medium` prints it.
69
+ */
70
+ export const ATTEMPT_LIMIT = 800;
71
+ /**
72
+ * Cap on the logged context lines (`session_concepts` rows) shipped in the
73
+ * payload, newest first like `attempts` and the same size: one number for how
74
+ * much history each kind of row brings. Each line carries its context text, so
75
+ * the list grew without bound and was the largest key at scale; a learner with
76
+ * fewer lines sees no change. `logged_shown` and `logged_total` say when it is
77
+ * cut, as `attempts_shown` and `attempts_total` do, and every total stays right
78
+ * because it is aggregated in SQL: `totals.sessions`, the concept catalogue's
79
+ * last context, the project inventory (its counts, its `sessions`, its `totals`
80
+ * across projects and the `concept_slugs` the page narrows a project to). A session whose lines fall
81
+ * outside the cap is still opened through `/api/memory/sessions`
82
+ * (`sessionLearning`).
56
83
  */
57
- const ATTEMPT_LIMIT = 2000;
84
+ export const LOGGED_LIMIT = 800;
58
85
  /**
59
86
  * The memory corpus does not travel in `/api/state`.
60
87
  *
@@ -133,19 +160,30 @@ const many = (db, sql, ...args) => db.prepare(sql).all(...args);
133
160
  * is not assessment (PRD LRN-02) — `assessed` is the only one of these that
134
161
  * required the developer to answer something.
135
162
  */
136
- function memorySummary(db) {
137
- const entries = one(db, `SELECT count(*) AS total,
138
- COALESCE(SUM(deleted_at IS NULL AND superseded_by IS NULL), 0) AS live,
139
- COALESCE(SUM(superseded_by IS NOT NULL), 0) AS superseded,
140
- COALESCE(SUM(deleted_at IS NOT NULL), 0) AS deleted,
141
- COALESCE(SUM(kind = 'note'), 0) AS notes
142
- FROM memory_entries`);
143
- const events = one(db, `SELECT count(*) AS captured,
144
- COALESCE(SUM(status = 'summarized'), 0) AS processed,
145
- COALESCE(SUM(status <> 'summarized'), 0) AS pending,
146
- COALESCE(SUM(redacted), 0) AS redacted,
147
- max(occurred_at) AS newest
148
- FROM evidence_events`);
163
+ function memorySummary(db, events) {
164
+ // One pass over the entries (each carries its narrative, so a pass reads the most bytes of any
165
+ // table here) gives the totals and the types: a group per type, summed for the totals.
166
+ const entries = { total: 0, live: 0, superseded: 0, deleted: 0, notes: 0 };
167
+ const types = [];
168
+ for (const g of many(db, `SELECT type, count(*) AS n,
169
+ SUM(deleted_at IS NULL AND superseded_by IS NULL) AS live,
170
+ SUM(superseded_by IS NOT NULL) AS superseded,
171
+ SUM(deleted_at IS NOT NULL) AS deleted,
172
+ SUM(kind = 'note') AS notes
173
+ FROM memory_entries GROUP BY type ORDER BY type`)) {
174
+ entries.total += g.n;
175
+ entries.live += g.live;
176
+ entries.superseded += g.superseded;
177
+ entries.deleted += g.deleted;
178
+ entries.notes += g.notes;
179
+ // A type counts what is not deleted (superseded entries stay on the timeline, marked), so a type
180
+ // with nothing left is not listed, and an entry with no type is `untyped`.
181
+ if (g.n > g.deleted)
182
+ types.push({ type: g.type ?? 'untyped', n: g.n - g.deleted });
183
+ }
184
+ // Largest first; equal counts keep the types' order. (The statement this replaced left equal counts
185
+ // in whatever order its plan gave them, which was the opposite one until an index served it.)
186
+ types.sort((a, b) => b.n - a.n);
149
187
  const candidates = many(db, 'SELECT status, count(*) AS n FROM learning_sources GROUP BY status');
150
188
  return {
151
189
  // What the hooks accepted from the host.
@@ -173,11 +211,14 @@ function memorySummary(db) {
173
211
  JOIN attempts a ON a.concept_id = ls.concept_id
174
212
  WHERE ls.concept_id IS NOT NULL`).n,
175
213
  candidates: Object.fromEntries(candidates.map((c) => [c.status, c.n])),
176
- types: many(db, `SELECT COALESCE(type, 'untyped') AS type, count(*) AS n FROM memory_entries
177
- WHERE deleted_at IS NULL GROUP BY type ORDER BY n DESC`),
178
- tags: many(db, `SELECT t.tag, count(*) AS n FROM memory_entry_tags t
179
- JOIN memory_entries e ON e.id = t.entry_id AND e.deleted_at IS NULL
180
- GROUP BY t.tag ORDER BY n DESC, t.tag LIMIT 60`),
214
+ types,
215
+ // A tag row always names an entry (a foreign key), so while none is deleted every tag counts and
216
+ // the entries need not be read again; once one is, only the live entries' tags do.
217
+ tags: entries.deleted === 0
218
+ ? many(db, `SELECT tag, count(*) AS n FROM memory_entry_tags GROUP BY tag ORDER BY n DESC, tag LIMIT 60`)
219
+ : many(db, `SELECT t.tag, count(*) AS n FROM memory_entry_tags t
220
+ JOIN memory_entries e ON e.id = t.entry_id AND e.deleted_at IS NULL
221
+ GROUP BY t.tag ORDER BY n DESC, t.tag LIMIT 60`),
181
222
  projects: many(db, `SELECT project, count(*) AS n FROM memory_entries GROUP BY project ORDER BY n DESC`),
182
223
  per_page: MEMORY_PER,
183
224
  };
@@ -228,14 +269,13 @@ function reuseSummary(db) {
228
269
  * `last_error`, never a key, never a log dump (PRD DASH-03). A class is what
229
270
  * tells the reader whether to re-authenticate or wait.
230
271
  */
231
- function healthSummary(db, config) {
232
- const beat = one(db, 'SELECT max(occurred_at) AS newest, max(received_at) AS received FROM evidence_events');
272
+ function healthSummary(db, config, events) {
233
273
  return {
234
274
  capture: {
235
275
  enabled: config.memory.enabled,
236
276
  mode: config.memory.capture,
237
- newest_event: beat.newest,
238
- newest_received: beat.received,
277
+ newest_event: events.newest,
278
+ newest_received: events.received,
239
279
  retention_days: config.memory.retention_days,
240
280
  // Each project stamps its own sweep; the page shows the latest of them
241
281
  // (the bare key is the stamp versions before per-project retention wrote).
@@ -261,17 +301,86 @@ function healthSummary(db, config) {
261
301
  },
262
302
  };
263
303
  }
264
- /** One row per session that captured evidence, so a session page can link the two halves. */
265
- function memorySessions(db) {
266
- return many(db, `SELECT e.session_id, e.project, count(*) AS events,
267
- COALESCE(SUM(e.redacted), 0) AS redacted,
268
- min(e.occurred_at) AS first, max(e.occurred_at) AS last,
269
- (SELECT count(*) FROM memory_entries m WHERE m.session_id = e.session_id) AS entries,
270
- (SELECT count(*) FROM learning_sources ls JOIN memory_entries m ON m.id = ls.entry_id
271
- WHERE m.session_id = e.session_id) AS candidates
272
- FROM evidence_events e
273
- GROUP BY e.session_id, e.project
274
- ORDER BY last DESC LIMIT ?`, MEMORY_SESSION_LIMIT);
304
+ /**
305
+ * Every group, in `(project, session_id, checkout)` order: the order `GROUP BY`
306
+ * already yields, spelled out because the inventory's rows are created in it.
307
+ */
308
+ function evidenceGroups(db) {
309
+ // Every aggregate here is paid once per event (about 0.1 ms per thousand events
310
+ // each), so a sum that follows from the others is taken from them once per
311
+ // group: `status` is never null, so what is not processed is pending. A group
312
+ // has rows, so its sums are numbers and need no default.
313
+ return many(db, `SELECT project, session_id, checkout, count(*) AS events,
314
+ SUM(status = 'summarized') AS processed,
315
+ count(*) - SUM(status = 'summarized') AS pending,
316
+ SUM(redacted) AS redacted,
317
+ min(occurred_at) AS first, max(occurred_at) AS last, max(received_at) AS received
318
+ FROM evidence_events
319
+ GROUP BY project, session_id, checkout
320
+ ORDER BY project, session_id, checkout`);
321
+ }
322
+ /** The larger of two timestamps; `null` is no timestamp. They are ISO text, so text order is time order. */
323
+ const laterOf = (a, b) => (a === null || b > a ? b : a);
324
+ function evidenceTotals(groups) {
325
+ const t = { captured: 0, processed: 0, pending: 0, redacted: 0, newest: null, received: null };
326
+ for (const g of groups) {
327
+ t.captured += g.events;
328
+ t.processed += g.processed;
329
+ t.pending += g.pending;
330
+ t.redacted += g.redacted;
331
+ t.newest = laterOf(t.newest, g.last);
332
+ t.received = laterOf(t.received, g.received);
333
+ }
334
+ return t;
335
+ }
336
+ /**
337
+ * Text in the order SQLite sorts it (bytes of the UTF-8 form), which is not JavaScript's (UTF-16 units) for every string.
338
+ * The two agree wherever the first difference is between characters below the surrogates (which is
339
+ * every id and path people use), and the bytes are compared only where it is not.
340
+ */
341
+ const binaryOrder = (a, b) => {
342
+ if (a === b)
343
+ return 0;
344
+ const shared = Math.min(a.length, b.length);
345
+ for (let i = 0; i < shared; i++) {
346
+ const x = a.charCodeAt(i);
347
+ const y = b.charCodeAt(i);
348
+ if (x !== y)
349
+ return x < 0xd800 && y < 0xd800 ? x - y : Buffer.compare(Buffer.from(a), Buffer.from(b));
350
+ }
351
+ return a.length - b.length;
352
+ };
353
+ /**
354
+ * One row per session that captured evidence, newest first, so a session page can
355
+ * link the two halves. A session that two projects both captured is two rows.
356
+ * Equal newest events keep the order `GROUP BY session_id, project` gave them.
357
+ */
358
+ function memorySessions(db, groups) {
359
+ const merged = new Map();
360
+ for (const g of groups) {
361
+ const key = `${g.session_id}\u0000${g.project}`;
362
+ const row = merged.get(key);
363
+ if (!row) {
364
+ merged.set(key, { session_id: g.session_id, project: g.project, events: g.events, redacted: g.redacted, first: g.first, last: g.last });
365
+ }
366
+ else {
367
+ row.events += g.events;
368
+ row.redacted += g.redacted;
369
+ if (g.first < row.first)
370
+ row.first = g.first;
371
+ if (g.last > row.last)
372
+ row.last = g.last;
373
+ }
374
+ }
375
+ const newest = [...merged.values()]
376
+ .sort((a, b) => binaryOrder(b.last, a.last) || binaryOrder(a.session_id, b.session_id) || binaryOrder(a.project, b.project))
377
+ .slice(0, MEMORY_SESSION_LIMIT);
378
+ // Counted by session alone, not by project, as this list always has. One pass
379
+ // over each table rather than a lookup per row (the candidates one scans).
380
+ const entries = new Map(many(db, 'SELECT session_id, count(*) AS n FROM memory_entries WHERE session_id IS NOT NULL GROUP BY session_id').map((r) => [r.session_id, r.n]));
381
+ const candidates = new Map(many(db, `SELECT m.session_id, count(*) AS n FROM learning_sources ls JOIN memory_entries m ON m.id = ls.entry_id
382
+ WHERE m.session_id IS NOT NULL GROUP BY m.session_id`).map((r) => [r.session_id, r.n]));
383
+ return newest.map((r) => ({ ...r, entries: entries.get(r.session_id) ?? 0, candidates: candidates.get(r.session_id) ?? 0 }));
275
384
  }
276
385
  /**
277
386
  * One page of the memory timeline (PRD DASH-02).
@@ -344,8 +453,28 @@ export function memoryPage(db, q = {}) {
344
453
  * real total, so the Memory workflow's Sessions list never stops at the cap.
345
454
  * A session can exist in `memory_entries` alone — an import carries entries
346
455
  * and no evidence — so both tables contribute.
456
+ *
457
+ * Two steps, so the cost follows the page and not the database: the key set
458
+ * (which sessions there are, newest first, which is the total and the order),
459
+ * then the counts for the page's own rows. Equal newest times keep the order
460
+ * `GROUP BY session_id, project` gives them: session id, then project.
461
+ *
462
+ * Asking for one session (`session`) also answers for the learning half, which
463
+ * is in neither table above: see `sessionLearning`. That is how the page opens
464
+ * a session whose rows were cut from `logged` and `attempts`.
465
+ *
466
+ * One read transaction, so the key set and the counts under it are one snapshot
467
+ * of the database. They were one statement before they were two, and another
468
+ * process (a retention sweep, a purge) may delete rows at any moment: without
469
+ * the snapshot a session's last row could go between the two reads, leaving a
470
+ * key with nothing to count, and the page's first and last time would have no
471
+ * time to take (an HTTP 500). A deferred transaction that only reads takes no
472
+ * write lock and, in WAL, never blocks the writer it is a snapshot against.
347
473
  */
348
474
  export function memorySessionPage(db, q = {}) {
475
+ return db.transaction(() => sessionPageRows(db, q))();
476
+ }
477
+ function sessionPageRows(db, q) {
349
478
  const per = Math.min(MEMORY_PER_MAX, Math.max(1, Math.floor(q.per || MEMORY_PER)));
350
479
  const where = [];
351
480
  const args = [];
@@ -358,25 +487,54 @@ export function memorySessionPage(db, q = {}) {
358
487
  args.push(q.session);
359
488
  }
360
489
  const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
361
- const union = `
362
- SELECT session_id, project, count(*) AS events, COALESCE(SUM(redacted), 0) AS redacted,
363
- COALESCE(SUM(status <> 'summarized'), 0) AS pending, min(occurred_at) AS first, max(occurred_at) AS last
364
- FROM evidence_events GROUP BY session_id, project
365
- UNION ALL
366
- SELECT session_id, project, 0, 0, 0, min(occurred_at), max(occurred_at)
367
- FROM memory_entries WHERE session_id IS NOT NULL GROUP BY session_id, project`;
368
- const total = one(db, `SELECT count(*) AS n FROM (SELECT session_id, project FROM (${union}) ${clause} GROUP BY session_id, project)`, ...args).n;
490
+ // The entries half never names a session that is NULL; an import can leave one.
491
+ const entryClause = clause ? `${clause} AND session_id IS NOT NULL` : 'WHERE session_id IS NOT NULL';
492
+ const keys = many(db, `SELECT session_id, project FROM (
493
+ SELECT session_id, project, max(occurred_at) AS last FROM evidence_events ${clause} GROUP BY session_id, project
494
+ UNION ALL
495
+ SELECT session_id, project, max(occurred_at) FROM memory_entries ${entryClause} GROUP BY session_id, project)
496
+ GROUP BY session_id, project ORDER BY max(last) DESC, session_id, project`, ...args, ...args);
497
+ const total = keys.length;
369
498
  const pages = Math.max(1, Math.ceil(total / per));
370
499
  const p = Math.min(Math.max(1, Math.floor(q.page || 1)), pages);
371
- const rows = many(db, `SELECT s.session_id, s.project, SUM(s.events) AS events, SUM(s.redacted) AS redacted, SUM(s.pending) AS pending,
372
- min(s.first) AS first, max(s.last) AS last,
373
- (SELECT count(*) FROM memory_entries m WHERE m.session_id = s.session_id AND m.project = s.project
374
- AND m.deleted_at IS NULL AND m.superseded_by IS NULL) AS entries,
375
- (SELECT count(*) FROM learning_sources ls JOIN memory_entries m ON m.id = ls.entry_id
376
- WHERE m.session_id = s.session_id AND m.project = s.project) AS candidates
377
- FROM (${union}) s ${clause}
378
- GROUP BY s.session_id, s.project ORDER BY last DESC LIMIT ? OFFSET ?`, ...args, per, (p - 1) * per);
379
- return { total, page: p, pages, per, rows };
500
+ const evidence = db.prepare(`SELECT count(*) AS events, COALESCE(SUM(redacted), 0) AS redacted, COALESCE(SUM(status <> 'summarized'), 0) AS pending,
501
+ min(occurred_at) AS first, max(occurred_at) AS last
502
+ FROM evidence_events WHERE session_id = ? AND project = ?`);
503
+ const entries = db.prepare(`SELECT COALESCE(SUM(deleted_at IS NULL AND superseded_by IS NULL), 0) AS live, min(occurred_at) AS first, max(occurred_at) AS last
504
+ FROM memory_entries WHERE session_id = ? AND project = ?`);
505
+ const candidates = db.prepare(`SELECT count(*) AS n FROM learning_sources ls JOIN memory_entries m ON m.id = ls.entry_id
506
+ WHERE m.session_id = ? AND m.project = ?`);
507
+ const rows = keys.slice((p - 1) * per, p * per).map(({ session_id, project }) => {
508
+ const e = evidence.get(session_id, project);
509
+ const m = entries.get(session_id, project);
510
+ const c = candidates.get(session_id, project);
511
+ // The key is in at least one table, so at least one side has times.
512
+ const first = [e.first, m.first].filter((t) => t !== null).reduce((a, b) => (b < a ? b : a));
513
+ const last = [e.last, m.last].filter((t) => t !== null).reduce((a, b) => (b > a ? b : a));
514
+ return { session_id, project, events: e.events, redacted: e.redacted, pending: e.pending, first, last, entries: m.live, candidates: c.n };
515
+ });
516
+ return { total, page: p, pages, per, rows, ...(q.session ? { learning: sessionLearning(db, q.session) } : {}) };
517
+ }
518
+ /**
519
+ * Everything one session logged and was asked, however old: the rows the state
520
+ * payload cut (`LOGGED_LIMIT`, `ATTEMPT_LIMIT`) and the ones it never had to cut
521
+ * because the session has no memory to list it under. Same row shapes as the
522
+ * payload's `logged` and `attempts` (one query each, shared), so the page draws
523
+ * a looked-up session exactly as one it already holds.
524
+ *
525
+ * Whole, and not narrowed by the page's project scope: which project a row
526
+ * belongs to is the page's `pid()`, which folds worktrees and recorded
527
+ * checkouts the way `projectInventory` does, and a second reading of that here
528
+ * would be a second opinion. The session's attempts are capped at
529
+ * `ATTEMPT_LIMIT` like every attempts list, with `attempts_total` to say so;
530
+ * its logged lines are at most one per concept.
531
+ */
532
+ function sessionLearning(db, session) {
533
+ return {
534
+ logged: loggedRows(db, projectFolder(), -1, session),
535
+ attempts: attemptRows(db, ATTEMPT_LIMIT, session),
536
+ attempts_total: one(db, `SELECT count(*) AS n FROM attempts a WHERE a.session_id = ? AND a.${NOT_CORRECTION}`, session).n,
537
+ };
380
538
  }
381
539
  /** The id of a project whose rows carry no repository at all: recorded before projects were tracked. */
382
540
  export const UNATTRIBUTED = '~';
@@ -406,9 +564,12 @@ export const UNATTRIBUTED = '~';
406
564
  * stored tells the two apart.
407
565
  */
408
566
  export function projectInventory(db) {
567
+ const evidence = evidenceGroups(db);
409
568
  const recorded = new Map();
410
- for (const r of many(db, `SELECT DISTINCT checkout, project FROM evidence_events WHERE checkout IS NOT NULL AND checkout <> project`)) {
411
- recorded.set(r.checkout, (recorded.get(r.checkout) ?? new Set()).add(r.project));
569
+ for (const g of evidence) {
570
+ if (g.checkout !== null && g.checkout !== g.project) {
571
+ recorded.set(g.checkout, (recorded.get(g.checkout) ?? new Set()).add(g.project));
572
+ }
412
573
  }
413
574
  const folded = new Map();
414
575
  const canonical = (raw) => {
@@ -436,10 +597,14 @@ export function projectInventory(db) {
436
597
  p = {
437
598
  id, kind, path: kind === 'repo' ? id : null, name: '', available: kind === 'repo' ? fs.existsSync(id) : null,
438
599
  sources: [], aliases: [],
439
- learning: { answers: 0, passed: 0, skipped: 0, assessed_concepts: 0, logged_concepts: 0, sessions: 0, first: null, last: null },
600
+ learning: {
601
+ answers: 0, passed: 0, skipped: 0, assessed_concepts: 0, logged_concepts: 0, logged_assessed_concepts: 0,
602
+ sessions: 0, first: null, last: null, concept_slugs: [],
603
+ },
440
604
  memory: { events: 0, pending: 0, entries: 0, sessions: 0, receipts: 0, first: null, last: null },
441
605
  first_active: null, last_active: null,
442
- _assessed: new Set(), _logged: new Set(), _lsess: new Set(), _msess: new Set(),
606
+ _assessed: new Set(), _logged: new Set(), _touched: new Set(), _lsess: new Set(), _msess: new Set(),
607
+ _lspan: { min: null, max: null }, _mspan: { min: null, max: null },
443
608
  };
444
609
  byId.set(id, p);
445
610
  }
@@ -450,26 +615,34 @@ export function projectInventory(db) {
450
615
  p.aliases.push(v);
451
616
  return p;
452
617
  };
618
+ // Across every project: the sets whose union is not the sum of the cards.
619
+ const every = { sessions: new Set(), logged: new Set(), assessed: new Set() };
453
620
  const sessionIds = new Map();
454
621
  const note = (sid, id) => sessionIds.set(sid, (sessionIds.get(sid) ?? new Set()).add(id));
455
- // Both timestamp shapes, flattened to ISO so min/max compare as instants.
456
- const iso = (ts) => {
622
+ // Both timestamp shapes, read as instants (epoch milliseconds) so min/max compare as instants. A
623
+ // group of one row has the same time at both ends and is read once; the instant becomes ISO
624
+ // text once per project at the end, not once per row (it was most of this function's time).
625
+ const epoch = (ts) => {
457
626
  if (!ts)
458
627
  return null;
459
628
  const t = parseTs(ts);
460
- return Number.isFinite(t) ? new Date(t).toISOString() : null;
629
+ return Number.isFinite(t) ? t : null;
461
630
  };
462
631
  const span = (o, first, last) => {
463
- const f = iso(first), l = iso(last);
464
- if (f && (!o.first || f < o.first))
465
- o.first = f;
466
- if (l && (!o.last || l > o.last))
467
- o.last = l;
632
+ const f = epoch(first);
633
+ const l = last === first ? f : epoch(last);
634
+ if (f !== null && (o.min === null || f < o.min))
635
+ o.min = f;
636
+ if (l !== null && (o.max === null || l > o.max))
637
+ o.max = l;
468
638
  };
639
+ const isoOf = (t) => (t === null ? null : new Date(t).toISOString());
640
+ // Answers that name no repository: the project they count toward for the concept list is
641
+ // settled below, once the sessions that prove one are known (as the page's `pid()` does).
642
+ const blankAsked = [];
469
643
  for (const r of many(db, `SELECT repo, session_id, concept_id, count(*) AS n,
470
- -- A miss corrected from its explainer counts as right, as on the Accuracy tile.
471
- COALESCE(SUM(grade >= ${PASSING_GRADE} OR EXISTS (SELECT 1 FROM attempts fix WHERE fix.retry_of = attempts.id)), 0) AS passed,
472
- COALESCE(SUM(outcome IN ('declined','dont_know')), 0) AS skipped,
644
+ SUM(grade >= ${PASSING_GRADE}) AS passed,
645
+ SUM(outcome IN ('declined','dont_know')) AS skipped,
473
646
  min(ts) AS first, max(ts) AS last
474
647
  FROM attempts WHERE ${NOT_CORRECTION} GROUP BY repo, session_id, concept_id`)) {
475
648
  const p = at(r.repo, 'attempts');
@@ -477,22 +650,34 @@ export function projectInventory(db) {
477
650
  p.learning.passed += r.passed;
478
651
  p.learning.skipped += r.skipped;
479
652
  p._assessed.add(r.concept_id);
653
+ every.assessed.add(r.concept_id);
654
+ if (r.repo?.trim())
655
+ p._touched.add(r.concept_id);
656
+ else
657
+ blankAsked.push([r.session_id, r.concept_id]);
480
658
  if (r.session_id) {
481
659
  p._lsess.add(r.session_id);
660
+ every.sessions.add(r.session_id);
482
661
  if (r.repo?.trim())
483
662
  note(r.session_id, p.id);
484
663
  }
485
- span(p.learning, r.first, r.last);
664
+ span(p._lspan, r.first, r.last);
486
665
  }
487
- for (const r of many(db, `SELECT project, session_id, count(*) AS n, COALESCE(SUM(status <> 'summarized'), 0) AS pending,
488
- min(occurred_at) AS first, max(occurred_at) AS last
489
- FROM evidence_events GROUP BY project, session_id`)) {
490
- const p = at(r.project, 'evidence');
491
- p.memory.events += r.n;
492
- p.memory.pending += r.pending;
493
- p._msess.add(r.session_id);
494
- note(r.session_id, p.id);
495
- span(p.memory, r.first, r.last);
666
+ // A miss corrected from its explainer counts as right, as on the Accuracy tile. They are the answers
667
+ // that have a correction and did not pass (a handful), counted here by repository rather than by
668
+ // a lookup made for every answer above; each one's project is already there.
669
+ for (const r of many(db, `SELECT a.repo, count(*) AS n FROM attempts a JOIN attempts fix ON fix.retry_of = a.id
670
+ WHERE a.${NOT_CORRECTION} AND a.grade < ${PASSING_GRADE} GROUP BY a.repo`)) {
671
+ byId.get(canonical(r.repo)).learning.passed += r.n;
672
+ }
673
+ // One row per project and session, whichever checkouts the events came from.
674
+ for (const g of evidence) {
675
+ const p = at(g.project, 'evidence');
676
+ p.memory.events += g.events;
677
+ p.memory.pending += g.pending;
678
+ p._msess.add(g.session_id);
679
+ note(g.session_id, p.id);
680
+ span(p._mspan, g.first, g.last);
496
681
  }
497
682
  // A gate row with no repository names no project, but the same session's
498
683
  // own answers and evidence may. Where they agree on exactly one project, the
@@ -512,11 +697,19 @@ export function projectInventory(db) {
512
697
  // Only work the agent logged. `record_attempt` writes a `review` row for
513
698
  // every concept it grades, and counting those would call every answered
514
699
  // concept "recorded from your work".
515
- if ((r.origin ?? 'work') === 'work')
700
+ if ((r.origin ?? 'work') === 'work') {
516
701
  p._logged.add(r.concept_id);
702
+ every.logged.add(r.concept_id);
703
+ }
704
+ // Any origin: a concept a question was asked about is as much in the project as one its work touched.
705
+ p._touched.add(r.concept_id);
517
706
  p._lsess.add(r.session_id);
518
- span(p.learning, r.first, r.last);
707
+ every.sessions.add(r.session_id);
708
+ span(p._lspan, r.first, r.last);
519
709
  }
710
+ // An answer with no repository belongs where its session was proven to belong, else nowhere.
711
+ for (const [sid, concept] of blankAsked)
712
+ byId.get((sid && sessionProjects[sid]) || UNATTRIBUTED)._touched.add(concept);
520
713
  for (const r of many(db, `SELECT project, session_id, COALESCE(SUM(deleted_at IS NULL AND superseded_by IS NULL), 0) AS live,
521
714
  min(occurred_at) AS first, max(occurred_at) AS last
522
715
  FROM memory_entries GROUP BY project, session_id`)) {
@@ -524,18 +717,26 @@ export function projectInventory(db) {
524
717
  p.memory.entries += r.live;
525
718
  if (r.session_id)
526
719
  p._msess.add(r.session_id);
527
- span(p.memory, r.first, r.last);
720
+ span(p._mspan, r.first, r.last);
528
721
  }
529
722
  for (const r of many(db, 'SELECT project, count(*) AS n FROM context_receipts GROUP BY project')) {
530
723
  at(r.project, 'receipts').memory.receipts += r.n;
531
724
  }
532
725
  for (const r of many(db, 'SELECT repo FROM project_levels'))
533
726
  at(r.repo, 'levels');
534
- const projects = [...byId.values()].map(({ _assessed, _logged, _lsess, _msess, ...p }) => {
727
+ const slugs = new Map(many(db, 'SELECT id, slug FROM concepts').map((c) => [c.id, c.slug]));
728
+ const projects = [...byId.values()].map(({ _assessed, _logged, _touched, _lsess, _msess, _lspan, _mspan, ...p }) => {
535
729
  p.learning.assessed_concepts = _assessed.size;
536
730
  p.learning.logged_concepts = _logged.size;
731
+ p.learning.logged_assessed_concepts = [..._logged].filter((id) => _assessed.has(id)).length;
732
+ // Concepts exist for every row that names one (a foreign key), so every id has its slug.
733
+ p.learning.concept_slugs = [..._touched].map((id) => slugs.get(id)).sort();
537
734
  p.learning.sessions = _lsess.size;
538
735
  p.memory.sessions = _msess.size;
736
+ p.learning.first = isoOf(_lspan.min);
737
+ p.learning.last = isoOf(_lspan.max);
738
+ p.memory.first = isoOf(_mspan.min);
739
+ p.memory.last = isoOf(_mspan.max);
539
740
  const firsts = [p.learning.first, p.memory.first].filter(Boolean);
540
741
  const lasts = [p.learning.last, p.memory.last].filter(Boolean);
541
742
  p.first_active = firsts.sort()[0] ?? null;
@@ -562,11 +763,18 @@ export function projectInventory(db) {
562
763
  const aliases = {};
563
764
  for (const [raw, id] of folded)
564
765
  aliases[raw] = id;
565
- return { projects, aliases, sessions: sessionProjects };
766
+ const totals = {
767
+ sessions: every.sessions.size,
768
+ logged_concepts: every.logged.size,
769
+ logged_assessed_concepts: [...every.logged].filter((id) => every.assessed.has(id)).length,
770
+ };
771
+ return { projects, aliases, sessions: sessionProjects, totals };
566
772
  }
567
773
  /**
568
- * What an open page polls to notice that work landed while it was reading it
569
- * (PRD DASH-01), served alone at `/api/cursor` so a poll builds no payload.
774
+ * What an open page is told about, to notice that work landed while it was
775
+ * reading it (PRD DASH-01): `/api/events` streams it (`createLive`, which reads
776
+ * it for as long as a page is listening) and `/api/cursor` returns it alone,
777
+ * building no payload.
570
778
  *
571
779
  * Three parts, one per place the page's content comes from:
572
780
  * - `change_version` (migration 021), which triggers move on every write to a
@@ -577,9 +785,9 @@ export function projectInventory(db) {
577
785
  * - The artifact files' count, total size and newest mtime (`artifactsStamp`).
578
786
  *
579
787
  * Deliberately not `generated_at`, and not a hash of the payload: both change
580
- * on every call -- scores are decayed against the clock -- and a page that
581
- * announces new activity every minute is a page whose banner is ignored inside
582
- * a day. Not watched: the prune stamp and spool drop count in the health panel.
788
+ * on every call -- scores are decayed against the clock -- and a cursor that
789
+ * moves on every call would redraw the page every second. Not watched: the
790
+ * prune stamp and spool drop count in the health panel.
583
791
  */
584
792
  export function changeCursor(db, configs = readConfigs(db)) {
585
793
  const version = one(db, 'SELECT n FROM change_version WHERE id = 1').n;
@@ -589,6 +797,154 @@ export function changeCursor(db, configs = readConfigs(db)) {
589
797
  .slice(0, 12);
590
798
  return `${version}:${dials}:${artifactsStamp()}`;
591
799
  }
800
+ /*
801
+ * Serving a build once per cursor.
802
+ *
803
+ * A build is the expensive part of opening the page (the state alone is 20 ms
804
+ * to a second, by the size of the database) and almost always rebuilds what the
805
+ * last one said: the page reads the whole payload on every load and the server
806
+ * is one thread. `changeCursor` is the version of everything the page shows and
807
+ * costs a few milliseconds, so every read asks it first and rebuilds only when
808
+ * it moved.
809
+ *
810
+ * One memo per database handle, in a WeakMap, never a global: tests and the
811
+ * daemon hold several databases in one process, and each answers for its own.
812
+ * The exported `settingsState` and `configurableProjects` read the same memo
813
+ * as the server does, so a build is paid for once whoever asks first.
814
+ *
815
+ * Two inputs are not in the cursor, and both expire the build by time instead:
816
+ * the clock (scores decay at read time, so `due` and `overdue_days` move
817
+ * without a write) and the filesystem (a project's `available` flag, whether a
818
+ * checkout still has its `.git`, what `listArtifacts` reads from the files).
819
+ * The health panel's prune stamp and the spool's drop count are not in the
820
+ * cursor either (see its comment) and ride the same minute: they show on the
821
+ * next build, not the next request. One more input is left out of the cursor on
822
+ * purpose and is too visible to wait a minute: `reviewFailed`.
823
+ */
824
+ /** How long a build that reads the clock or the filesystem is served without being rebuilt. */
825
+ export const MEMO_TTL_MS = 60_000;
826
+ /** Serialised responses kept per database; the least recently used goes first. */
827
+ export const PAGE_CACHE_ENTRIES = 64;
828
+ /** A response larger than this many bytes is served but not kept, so one huge entry cannot pin megabytes. */
829
+ const PAGE_MAX_BYTES = 1024 * 1024;
830
+ const memos = new WeakMap();
831
+ /** The one route whose response a settings write changes. */
832
+ const SETTINGS_PATH = '/api/settings';
833
+ /**
834
+ * Whether the last session Eklavya looked at for feedback could not be reviewed.
835
+ *
836
+ * `feedback_reviewed` has no change triggers, deliberately (migration 026, and
837
+ * `migrate.test.ts` pins it): a session looked at and found quiet is not news
838
+ * for an open page. But the state says "couldn't review the last session" from
839
+ * it, and a page loaded a moment after that row landed has to say so, as it
840
+ * did when every load was a build. So the memo asks, alongside the cursor.
841
+ * An older schema without the table reads as not failed, as `feedbackSummary` does.
842
+ */
843
+ function reviewFailed(db) {
844
+ try {
845
+ const last = one(db, 'SELECT outcome FROM feedback_reviewed ORDER BY reviewed_at DESC, rowid DESC LIMIT 1');
846
+ return last?.outcome === 'failed';
847
+ }
848
+ catch {
849
+ return false;
850
+ }
851
+ }
852
+ /** Whether a build made at `at` may still be served at `now`. A clock that stepped back expires it too. */
853
+ const served = (at, now) => now >= at && now - at < MEMO_TTL_MS;
854
+ /**
855
+ * The memo for this database, valid for the cursor as it is right now (and for
856
+ * `reviewFailed`, the one thing beside it that the state reads and the cursor does not).
857
+ *
858
+ * The cursor is read here, before any build that is stored in the memo, and
859
+ * stays with it: a write that lands during a build makes the next read see a
860
+ * newer cursor and rebuild, where reading it after would label a build with a
861
+ * cursor newer than the rows it read. A change to the config or to an artifact
862
+ * keeps the inventory, which reads neither: only the database's counter, the
863
+ * first part of the cursor, drops it.
864
+ */
865
+ function memoFor(db) {
866
+ const cursor = changeCursor(db);
867
+ const version = cursor.slice(0, cursor.indexOf(':'));
868
+ const valid = `${cursor}|${reviewFailed(db)}`;
869
+ let memo = memos.get(db);
870
+ if (memo?.cursor !== valid) {
871
+ memo = { cursor: valid, version, state: null, inventory: memo?.version === version ? memo.inventory : null, pages: new Map() };
872
+ memos.set(db, memo);
873
+ }
874
+ return memo;
875
+ }
876
+ /** `/api/state` as the bytes it is sent as. Built at most once per cursor and minute. */
877
+ function stateBody(memo, db) {
878
+ const now = Date.now();
879
+ if (memo.state && served(memo.state.at, now))
880
+ return memo.state.body;
881
+ const body = Buffer.from(JSON.stringify(dashboardState(db)));
882
+ memo.state = { at: now, body };
883
+ return body;
884
+ }
885
+ /** The project inventory, rebuilt when the database moved, a minute passed, or the caller must not trust a memo. */
886
+ function inventoryOf(memo, db, rebuild = false) {
887
+ const now = Date.now();
888
+ if (rebuild || !memo.inventory || !served(memo.inventory.at, now)) {
889
+ memo.inventory = { at: now, value: projectInventory(db), configurable: null };
890
+ }
891
+ return memo.inventory;
892
+ }
893
+ /**
894
+ * One response by its full URL (path and query), most recently used last.
895
+ *
896
+ * `build` returns null for a response that is not a page worth keeping (an
897
+ * entry that does not exist); it is not stored. `expires` is for a response
898
+ * that reads the filesystem, and `stamp` for one whose inputs are files the
899
+ * cursor does not cover: an entry made under another stamp is not served.
900
+ */
901
+ function pageBody(memo, key, build, opts = {}) {
902
+ const now = Date.now();
903
+ const stamp = opts.stamp ? opts.stamp() : '';
904
+ const hit = memo.pages.get(key);
905
+ if (hit && hit.stamp === stamp && (!opts.expires || served(hit.at, now))) {
906
+ memo.pages.delete(key);
907
+ memo.pages.set(key, hit);
908
+ return hit.body;
909
+ }
910
+ const json = build();
911
+ // Replacing an entry must also move it: `Map.set` on a present key keeps its place.
912
+ memo.pages.delete(key);
913
+ if (json === null)
914
+ return null;
915
+ const body = Buffer.from(json);
916
+ if (body.length <= PAGE_MAX_BYTES) {
917
+ memo.pages.set(key, { at: now, stamp, body });
918
+ while (memo.pages.size > PAGE_CACHE_ENTRIES)
919
+ memo.pages.delete(memo.pages.keys().next().value);
920
+ }
921
+ return body;
922
+ }
923
+ /** The state and the inventory, built now so the first request finds them. Throws as the builders do. */
924
+ function warmMemo(db) {
925
+ const memo = memoFor(db);
926
+ stateBody(memo, db);
927
+ inventoryOf(memo, db);
928
+ }
929
+ /** What a settings write changed is on disk before any cursor or clock can say so: drop the pages that show it. */
930
+ function forgetSettings(db) {
931
+ const pages = memos.get(db)?.pages;
932
+ if (!pages)
933
+ return;
934
+ for (const key of pages.keys())
935
+ if (key.startsWith(SETTINGS_PATH))
936
+ pages.delete(key);
937
+ }
938
+ /** One file's identity and version, or `-` when there is none. Inode, size and nanosecond mtime: an atomic rewrite changes at least one. */
939
+ function fileStamp(file) {
940
+ try {
941
+ const s = fs.statSync(file, { bigint: true });
942
+ return `${s.ino}.${s.size}.${s.mtimeNs}`;
943
+ }
944
+ catch {
945
+ return '-';
946
+ }
947
+ }
592
948
  /** One entry with its tags, its raw evidence and the candidates it proposed. */
593
949
  export function memoryEntry(db, id) {
594
950
  const entry = one(db, 'SELECT * FROM memory_entries WHERE id = ?', id);
@@ -619,6 +975,187 @@ export function memoryEntry(db, id) {
619
975
  WHERE i.entry_id = ? ORDER BY r.id DESC LIMIT 20`, id),
620
976
  };
621
977
  }
978
+ /**
979
+ * `projectKey`, once per distinct path in one build. It looks at the checkout's
980
+ * `.git` on disk (a worktree folds into its main checkout), and a context line
981
+ * carries the path of every session's repository: a statement per row is
982
+ * thousands of statements for a few hundred distinct answers.
983
+ */
984
+ function projectFolder() {
985
+ const keys = new Map();
986
+ return (repo) => {
987
+ let key = keys.get(repo);
988
+ if (key === undefined) {
989
+ key = projectKey(repo);
990
+ keys.set(repo, key);
991
+ }
992
+ return key;
993
+ };
994
+ }
995
+ /**
996
+ * Graded answers, newest first, as the page reads them: the payload's capped
997
+ * history (`attempts`) and one session's own rows (`sessionLearning`) come from
998
+ * this one statement, so they cannot drift apart. A correction row is a pick on
999
+ * the explainer, not a question asked (`NOT_CORRECTION`), and rides on the row
1000
+ * it corrected.
1001
+ */
1002
+ function attemptRows(db, limit, session) {
1003
+ return many(db, `SELECT a.id, c.slug, c.name, c.domain, a.session_id, a.question, a.answer, a.feedback,
1004
+ a.grade, a.difficulty AS tier, a.outcome, a.format, a.options, a.ts,
1005
+ NULLIF(trim(COALESCE(a.repo, '')), '') AS repo, a.level,
1006
+ -- A miss corrected from its explainer: when, and on which try.
1007
+ fix.ts AS corrected_at,
1008
+ CASE WHEN fix.id IS NULL THEN NULL
1009
+ ELSE (SELECT count(*) FROM attempt_retries r WHERE r.attempt_id = a.id) END AS corrected_try
1010
+ FROM attempts a JOIN concepts c ON c.id = a.concept_id
1011
+ LEFT JOIN attempts fix ON fix.retry_of = a.id
1012
+ WHERE a.${NOT_CORRECTION}${session === undefined ? '' : ' AND a.session_id = ?'}
1013
+ ORDER BY a.id DESC
1014
+ LIMIT ?`, ...(session === undefined ? [] : [session]), limit);
1015
+ }
1016
+ /**
1017
+ * The context lines the agent logged, newest first, as the page reads them: the
1018
+ * payload's capped list and one session's lines (`sessionLearning`, a `limit`
1019
+ * of -1, which SQLite reads as none). Equal times keep the order the rows were
1020
+ * written in, which is the order they have always had.
1021
+ *
1022
+ * `gates.repo` stays the worktree path -- the POSIX gate matches it against
1023
+ * `git rev-parse --show-toplevel`. Every other repo on this page comes from
1024
+ * `attempts`, already folded, so fold this one too or a project filter drops
1025
+ * every context line a worktree session logged.
1026
+ */
1027
+ function loggedRows(db, fold, limit, session) {
1028
+ const rows = many(db, `SELECT sc.session_id, c.slug, c.name, c.domain, sc.context, sc.ts, sc.origin,
1029
+ NULLIF(trim(COALESCE(g.repo, '')), '') AS repo
1030
+ FROM session_concepts sc
1031
+ JOIN concepts c ON c.id = sc.concept_id
1032
+ LEFT JOIN gates g ON g.session_id = sc.session_id
1033
+ ${session === undefined ? '' : 'WHERE sc.session_id = ?'}
1034
+ ORDER BY sc.ts DESC, sc.rowid
1035
+ LIMIT ?`, ...(session === undefined ? [] : [session]), limit);
1036
+ for (const row of rows)
1037
+ row.repo = row.repo ? fold(row.repo) : null;
1038
+ return rows;
1039
+ }
1040
+ /**
1041
+ * Every number the page takes from `attempts` that is not a list of rows: the
1042
+ * per-day split, the per-project totals, the all-time totals and each concept's
1043
+ * counts. They were five statements, each reading the whole table and the
1044
+ * first two converting every timestamp to the learner's day (2 microseconds a
1045
+ * row, 19 ms at 10,000 answers); they are two statements now, one grouped by
1046
+ * day and repository and one by repository and concept, and a few sums in JS.
1047
+ *
1048
+ * Each figure is the one the statement it replaced gave, value for value: a
1049
+ * correction row is no answer (`NOT_CORRECTION`) but is counted as one
1050
+ * `corrected` against the miss it fixes, and an answer that was corrected is
1051
+ * neither missed nor skipped. That last exclusion is taken from the answers
1052
+ * that were corrected (a handful) instead of a join over every answer.
1053
+ */
1054
+ function answerRollups(db) {
1055
+ const dayGroups = many(db, `SELECT date(a.ts, 'localtime') AS day, NULLIF(trim(COALESCE(a.repo, '')), '') AS repo, a.ts >= date('now', ?) AS recent,
1056
+ count(*) AS answers,
1057
+ sum(CASE WHEN a.grade >= ${PASSING_GRADE} THEN 1 ELSE 0 END) AS passed,
1058
+ sum(CASE WHEN a.grade < ${PASSING_GRADE} AND (a.outcome IS NULL OR a.outcome = 'answered') THEN 1 ELSE 0 END) AS missed,
1059
+ sum(CASE WHEN a.outcome IN ('declined','dont_know') THEN 1 ELSE 0 END) AS skipped,
1060
+ min(a.ts) AS first
1061
+ FROM attempts a
1062
+ WHERE a.${NOT_CORRECTION}
1063
+ GROUP BY 1, 2, 3
1064
+ ORDER BY 1, 2, 3`, `-${TIMELINE_DAYS} days`);
1065
+ // The same groups, over the answers that were corrected (a handful): what to take back out of
1066
+ // missed and skipped.
1067
+ const groupKey = (g) => `${g.day ?? ''}\u0000${g.repo ?? ''}\u0000${g.recent}`;
1068
+ const fixed = new Map();
1069
+ for (const f of many(db, `SELECT date(a.ts, 'localtime') AS day, NULLIF(trim(COALESCE(a.repo, '')), '') AS repo, a.ts >= date('now', ?) AS recent,
1070
+ count(*) AS fixed,
1071
+ sum(CASE WHEN a.grade < ${PASSING_GRADE} AND (a.outcome IS NULL OR a.outcome = 'answered') THEN 1 ELSE 0 END) AS missed,
1072
+ sum(CASE WHEN a.outcome IN ('declined','dont_know') THEN 1 ELSE 0 END) AS skipped
1073
+ FROM attempts a JOIN attempts fix ON fix.retry_of = a.id
1074
+ WHERE a.${NOT_CORRECTION}
1075
+ GROUP BY 1, 2, 3`, `-${TIMELINE_DAYS} days`)) {
1076
+ fixed.set(groupKey(f), f);
1077
+ }
1078
+ const none = { fixed: 0, missed: 0, skipped: 0 };
1079
+ const daily = [];
1080
+ const allTime = { answers: 0, passed: 0, missed: 0, skipped: 0, corrected: 0, active_days: 0, first_answer: null };
1081
+ const activeDays = new Set();
1082
+ for (const g of dayGroups) {
1083
+ // Every corrected answer is an answer, so its group is among these.
1084
+ const f = fixed.size === 0 ? none : fixed.get(groupKey(g)) ?? none;
1085
+ const missed = g.missed - f.missed;
1086
+ const skipped = g.skipped - f.skipped;
1087
+ allTime.answers += g.answers;
1088
+ allTime.passed += g.passed;
1089
+ allTime.missed += missed;
1090
+ allTime.skipped += skipped;
1091
+ allTime.corrected += f.fixed;
1092
+ if (g.day !== null)
1093
+ activeDays.add(g.day);
1094
+ if (allTime.first_answer === null || g.first < allTime.first_answer)
1095
+ allTime.first_answer = g.first;
1096
+ // The groups come in the order `GROUP BY day, repo` gave the rows they replace.
1097
+ if (g.recent)
1098
+ daily.push({ day: g.day, repo: g.repo, passed: g.passed, missed, skipped, corrected: f.fixed });
1099
+ }
1100
+ allTime.active_days = activeDays.size;
1101
+ // One group per repository and concept over the answers, which give the project rows and the
1102
+ // catalogue's counts, and the same over the corrections (an index finds them), which the
1103
+ // catalogue counts as `corrected` and which may be the newest row naming a repository.
1104
+ const asked = new Map();
1105
+ const byRepo = new Map();
1106
+ const concept = (id) => {
1107
+ let c = asked.get(id);
1108
+ if (c === undefined) {
1109
+ c = { attempts: 0, passed: 0, skipped: 0, corrected: 0, first_asked: null, last_id: null, last_repo_id: null };
1110
+ asked.set(id, c);
1111
+ }
1112
+ return c;
1113
+ };
1114
+ // Grouped by the repo column as written, which is how the project rows have always been grouped:
1115
+ // a project row is one per spelling (an empty repo, a blank one and an unset one are three rows,
1116
+ // each reading as no repository), and a catalogue row cannot tell them apart.
1117
+ for (const g of many(db, `SELECT a.repo AS raw, NULLIF(trim(COALESCE(a.repo, '')), '') AS repo, a.concept_id,
1118
+ count(*) AS attempts,
1119
+ sum(a.grade >= ${PASSING_GRADE}) AS passed,
1120
+ sum(a.outcome IN ('declined','dont_know')) AS skipped,
1121
+ min(a.ts) AS first, max(a.ts) AS last, max(a.id) AS last_id
1122
+ FROM attempts a
1123
+ WHERE a.${NOT_CORRECTION}
1124
+ GROUP BY a.repo, a.concept_id`)) {
1125
+ const c = concept(g.concept_id);
1126
+ c.attempts += g.attempts;
1127
+ c.passed += g.passed;
1128
+ c.skipped += g.skipped;
1129
+ if (c.first_asked === null || g.first < c.first_asked)
1130
+ c.first_asked = g.first;
1131
+ if (c.last_id === null || g.last_id > c.last_id)
1132
+ c.last_id = g.last_id;
1133
+ if (g.repo !== null && (c.last_repo_id === null || g.last_id > c.last_repo_id))
1134
+ c.last_repo_id = g.last_id;
1135
+ const p = byRepo.get(g.raw) ?? { repo: g.repo, answers: 0, passed: 0, skipped: 0, concepts: 0, first_active: g.first, last_active: g.last };
1136
+ byRepo.set(g.raw, p);
1137
+ p.answers += g.attempts;
1138
+ p.passed += g.passed;
1139
+ p.skipped += g.skipped;
1140
+ p.concepts += 1;
1141
+ if (g.first < p.first_active)
1142
+ p.first_active = g.first;
1143
+ if (g.last > p.last_active)
1144
+ p.last_active = g.last;
1145
+ }
1146
+ for (const g of many(db, `SELECT NULLIF(trim(COALESCE(a.repo, '')), '') AS repo, a.concept_id, count(*) AS corrections, max(a.id) AS last_id
1147
+ FROM attempts a
1148
+ WHERE a.retry_of IS NOT NULL
1149
+ GROUP BY 1, 2`)) {
1150
+ const c = concept(g.concept_id);
1151
+ c.corrected += g.corrections;
1152
+ if (g.repo !== null && (c.last_repo_id === null || g.last_id > c.last_repo_id))
1153
+ c.last_repo_id = g.last_id;
1154
+ }
1155
+ // Repositories came out in order, so equal last-active times keep it.
1156
+ const projects = [...byRepo.values()].sort((a, b) => (a.last_active === b.last_active ? 0 : a.last_active < b.last_active ? 1 : -1));
1157
+ return { daily, projects, allTime, asked };
1158
+ }
622
1159
  export function dashboardState(db) {
623
1160
  const now = new Date();
624
1161
  const configs = readConfigs(db);
@@ -626,58 +1163,44 @@ export function dashboardState(db) {
626
1163
  // speak for every project, and the server's cwd is not any project's choice.
627
1164
  const config = configs.user;
628
1165
  // First, before any of the payload is read: a write that lands while it is
629
- // being built then shows as a notice on the next poll, never as a miss.
1166
+ // being built moves the next cursor, so the page reads again and never misses it.
630
1167
  const cursor = changeCursor(db, configs);
631
- // One row per calendar day per project, split three ways. `grade >= 3` is the
632
- // same pass line the level ladder uses, so the chart and the promotion agree.
633
- const daily = db
634
- .prepare(`SELECT date(a.ts, 'localtime') AS day,
635
- NULLIF(trim(COALESCE(a.repo, '')), '') AS repo,
636
- sum(CASE WHEN a.grade >= ? THEN 1 ELSE 0 END) AS passed,
637
- 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,
638
- sum(CASE WHEN a.outcome IN ('declined','dont_know') AND fix.id IS NULL THEN 1 ELSE 0 END) AS skipped,
639
- count(fix.id) AS corrected
640
- FROM attempts a LEFT JOIN attempts fix ON fix.retry_of = a.id
641
- WHERE a.${NOT_CORRECTION} AND a.ts >= date('now', ?)
642
- GROUP BY 1, 2
643
- ORDER BY day`)
644
- .all(PASSING_GRADE, PASSING_GRADE, `-${TIMELINE_DAYS} days`);
645
- // A null repo is the pre-migration-003 bucket. It is kept, not dropped:
1168
+ // The numbers taken from `attempts` that are not lists of rows: one row per calendar day per
1169
+ // project (split three ways: `grade >= PASSING_GRADE` is the pass line the level ladder uses, so
1170
+ // the chart and the promotion agree), the per-project totals, the all-time totals and each
1171
+ // concept's counts. A null repo is the pre-migration-003 bucket. It is kept, not dropped:
646
1172
  // hiding those rows would silently shrink every total on the page.
647
- const projects = db
648
- .prepare(`SELECT NULLIF(trim(COALESCE(a.repo, '')), '') AS repo,
649
- count(*) AS answers,
650
- sum(CASE WHEN a.grade >= ? THEN 1 ELSE 0 END) AS passed,
651
- sum(CASE WHEN a.outcome IN ('declined','dont_know') THEN 1 ELSE 0 END) AS skipped,
652
- count(DISTINCT a.concept_id) AS concepts,
653
- min(a.ts) AS first_active,
654
- max(a.ts) AS last_active
655
- FROM attempts a
656
- WHERE a.${NOT_CORRECTION}
657
- GROUP BY repo
658
- ORDER BY last_active DESC`)
659
- .all(PASSING_GRADE);
1173
+ const { daily, projects, allTime, asked } = answerRollups(db);
1174
+ // The catalogue with each concept's last context. The newest answer of a concept and its counts
1175
+ // come from the rollup above; the last context is one index lookup per concept
1176
+ // (migration 031), where this used to be eight correlated subqueries per concept over
1177
+ // `attempts` and `session_concepts`, the last of them reading every logged line.
1178
+ const lastGrade = db.prepare('SELECT grade FROM attempts WHERE id = ?').pluck();
1179
+ const lastRepo = db.prepare('SELECT repo FROM attempts WHERE id = ?').pluck();
660
1180
  const conceptRows = db
661
1181
  .prepare(`SELECT c.id, c.slug, c.name, c.domain, c.description, c.tier, c.source,
662
- m.score, m.ease, m.interval_d, m.reps, m.next_review, m.last_seen,
663
- (SELECT count(*) FROM attempts a WHERE a.concept_id = c.id AND a.${NOT_CORRECTION}) AS attempts,
664
- (SELECT count(*) FROM attempts a WHERE a.concept_id = c.id AND a.${NOT_CORRECTION} AND a.grade >= ${PASSING_GRADE}) AS passed,
665
- (SELECT count(*) FROM attempts a WHERE a.concept_id = c.id AND a.${NOT_CORRECTION}
666
- AND a.outcome IN ('declined','dont_know')) AS skipped,
667
- (SELECT count(*) FROM attempts a WHERE a.concept_id = c.id AND a.retry_of IS NOT NULL) AS corrected,
668
- (SELECT min(a.ts) FROM attempts a WHERE a.concept_id = c.id AND a.${NOT_CORRECTION}) AS first_asked,
669
- -- The backlog rule's grade (OWED_SQL in store.ts): a correction does not clear it.
670
- (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,
671
- (SELECT sc.context FROM session_concepts sc
672
- WHERE sc.concept_id = c.id AND sc.context IS NOT NULL
673
- ORDER BY sc.ts DESC LIMIT 1) AS last_context,
674
- (SELECT a.repo FROM attempts a
675
- WHERE a.concept_id = c.id AND a.repo IS NOT NULL AND trim(a.repo) <> ''
676
- ORDER BY a.id DESC LIMIT 1) AS last_repo
677
- FROM concepts c
678
- LEFT JOIN mastery m ON m.concept_id = c.id
679
- ORDER BY c.domain, c.tier, c.name`)
680
- .all();
1182
+ m.score, m.ease, m.interval_d, m.reps, m.next_review, m.last_seen,
1183
+ -- The latest context a session logged for the concept; equal times go to the row written first.
1184
+ (SELECT sc.context FROM session_concepts sc
1185
+ WHERE sc.concept_id = c.id AND sc.context IS NOT NULL
1186
+ ORDER BY sc.ts DESC, sc.rowid LIMIT 1) AS last_context
1187
+ FROM concepts c
1188
+ LEFT JOIN mastery m ON m.concept_id = c.id
1189
+ ORDER BY c.domain, c.tier, c.name, c.id`)
1190
+ .all().map((c) => {
1191
+ const a = asked.get(c.id);
1192
+ return {
1193
+ ...c,
1194
+ attempts: a?.attempts ?? 0,
1195
+ passed: a?.passed ?? 0,
1196
+ skipped: a?.skipped ?? 0,
1197
+ corrected: a?.corrected ?? 0,
1198
+ first_asked: a?.first_asked ?? null,
1199
+ // The backlog rule's grade (OWED_SQL in store.ts): a correction does not clear it.
1200
+ last_grade: a?.last_id != null ? lastGrade.get(a.last_id) : null,
1201
+ last_repo: a?.last_repo_id != null ? lastRepo.get(a.last_repo_id) : null,
1202
+ };
1203
+ });
681
1204
  const byId = new Map(conceptRows.map((r) => [r.id, r.slug]));
682
1205
  // Edges travel as slugs so the page can link one concept card to another
683
1206
  // without carrying the id space around with it.
@@ -771,51 +1294,19 @@ export function dashboardState(db) {
771
1294
  // Every graded response, newest first, with its question and the tutor's
772
1295
  // explanation. This is what makes a concept page a record of what you were
773
1296
  // actually asked rather than a score.
774
- const attempts = db
775
- .prepare(`SELECT a.id, c.slug, c.name, c.domain, a.session_id, a.question, a.answer, a.feedback,
776
- a.grade, a.difficulty AS tier, a.outcome, a.format, a.options, a.ts,
777
- NULLIF(trim(COALESCE(a.repo, '')), '') AS repo, a.level,
778
- -- A miss corrected from its explainer: when, and on which try.
779
- fix.ts AS corrected_at,
780
- CASE WHEN fix.id IS NULL THEN NULL
781
- ELSE (SELECT count(*) FROM attempt_retries r WHERE r.attempt_id = a.id) END AS corrected_try
782
- FROM attempts a JOIN concepts c ON c.id = a.concept_id
783
- LEFT JOIN attempts fix ON fix.retry_of = a.id
784
- WHERE a.${NOT_CORRECTION}
785
- ORDER BY a.id DESC
786
- LIMIT ?`)
787
- .all(ATTEMPT_LIMIT);
1297
+ const attempts = attemptRows(db, ATTEMPT_LIMIT);
788
1298
  // The context lines, which are what a concept means to *this* learner: the
789
- // code that taught it. Grouped by session on the page.
790
- const logged = db
791
- .prepare(`SELECT sc.session_id, c.slug, c.name, c.domain, sc.context, sc.ts, sc.origin,
792
- NULLIF(trim(COALESCE(g.repo, '')), '') AS repo
793
- FROM session_concepts sc
794
- JOIN concepts c ON c.id = sc.concept_id
795
- LEFT JOIN gates g ON g.session_id = sc.session_id
796
- ORDER BY sc.ts DESC`)
797
- .all()
798
- // `gates.repo` stays the worktree path -- the POSIX gate matches it against
799
- // `git rev-parse --show-toplevel`. Every other repo on this page comes from
800
- // `attempts`, already folded, so fold this one too or a project filter drops
801
- // every context line a worktree session logged.
802
- .map((r) => {
803
- const row = r;
804
- return { ...row, repo: row.repo ? projectKey(row.repo) : null };
805
- });
806
- const allTime = db
807
- .prepare(`SELECT count(*) AS answers,
808
- sum(CASE WHEN a.grade >= ? THEN 1 ELSE 0 END) AS passed,
809
- 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,
810
- sum(CASE WHEN a.outcome IN ('declined','dont_know') AND fix.id IS NULL THEN 1 ELSE 0 END) AS skipped,
811
- count(fix.id) AS corrected,
812
- count(DISTINCT date(a.ts, 'localtime')) AS active_days,
813
- min(a.ts) AS first_answer
814
- FROM attempts a LEFT JOIN attempts fix ON fix.retry_of = a.id
815
- WHERE a.${NOT_CORRECTION}`)
816
- .get(PASSING_GRADE, PASSING_GRADE);
1299
+ // code that taught it. Grouped by session on the page. Newest first and cut at
1300
+ // `LOGGED_LIMIT`, with the total beside it.
1301
+ const logged = loggedRows(db, projectFolder(), LOGGED_LIMIT);
1302
+ const loggedTotal = logged.length < LOGGED_LIMIT
1303
+ ? logged.length
1304
+ : one(db, 'SELECT count(*) AS n FROM session_concepts sc JOIN concepts c ON c.id = sc.concept_id').n;
817
1305
  const sessionCount = db.prepare('SELECT count(DISTINCT session_id) AS n FROM session_concepts').get().n;
818
- const memory = memorySummary(db);
1306
+ // The one read of evidence_events: the counts, the heartbeat and the session list all come from it.
1307
+ const evidence = evidenceGroups(db);
1308
+ const evidenceSummary = evidenceTotals(evidence);
1309
+ const memory = memorySummary(db, evidenceSummary);
819
1310
  const reuse = reuseSummary(db);
820
1311
  return {
821
1312
  generated_at: now.toISOString(),
@@ -824,6 +1315,10 @@ export function dashboardState(db) {
824
1315
  timeline_days: TIMELINE_DAYS,
825
1316
  attempts_shown: attempts.length,
826
1317
  attempts_total: allTime.answers,
1318
+ // The same disclosure for the context lines: shown is what travelled, total
1319
+ // is every row, so a cut list cannot pass for the whole one.
1320
+ logged_shown: logged.length,
1321
+ logged_total: loggedTotal,
827
1322
  // The user file's dials, before any project's overrides. Each project row
828
1323
  // below carries the level and runway its own settings resolve to.
829
1324
  config_scope: 'user',
@@ -841,9 +1336,9 @@ export function dashboardState(db) {
841
1336
  },
842
1337
  totals: {
843
1338
  answers: allTime.answers,
844
- passed: allTime.passed ?? 0,
845
- missed: allTime.missed ?? 0,
846
- skipped: allTime.skipped ?? 0,
1339
+ passed: allTime.passed,
1340
+ missed: allTime.missed,
1341
+ skipped: allTime.skipped,
847
1342
  corrected: allTime.corrected,
848
1343
  mastered: concepts.filter((c) => c.mastered).length,
849
1344
  due: concepts.filter((c) => c.due).length,
@@ -880,8 +1375,8 @@ export function dashboardState(db) {
880
1375
  // because `/api/state` is a contract an older page still reads.
881
1376
  memory,
882
1377
  reuse,
883
- health: healthSummary(db, config),
884
- memory_sessions: memorySessions(db),
1378
+ health: healthSummary(db, config, evidenceSummary),
1379
+ memory_sessions: memorySessions(db, evidence),
885
1380
  // The third workflow. Read from the files themselves on every load
886
1381
  // (`artifacts.ts`): there is no table to fall out of step with the disk.
887
1382
  artifacts: artifactRows(db),
@@ -1045,14 +1540,10 @@ export const CLI_ONLY = [
1045
1540
  { key: 'sync.target', why: 'writes memory to a shared folder' },
1046
1541
  { key: 'sync.device_id', why: 'pins this device\'s sync identity' },
1047
1542
  ];
1048
- /**
1049
- * Projects the Settings pages may write for: an inventory project whose
1050
- * checkout still exists. The POST checks against this list, so the page can
1051
- * never name an arbitrary path to write settings for.
1052
- */
1053
- export function configurableProjects(db) {
1543
+ /** The Settings pages' projects, from an inventory: a checkout that still has its `.git`. */
1544
+ function configurableFrom(inventory) {
1054
1545
  const out = new Map();
1055
- for (const p of projectInventory(db).projects) {
1546
+ for (const p of inventory.projects) {
1056
1547
  // A checkout, not just a directory: `loadConfig` finds the project file
1057
1548
  // through the repository, so a folder without `.git` would show the user
1058
1549
  // settings as if they were this project's.
@@ -1064,6 +1555,59 @@ export function configurableProjects(db) {
1064
1555
  }
1065
1556
  return [...out.values()].sort((a, b) => a.name.localeCompare(b.name));
1066
1557
  }
1558
+ /** The memoized list for a memo already in hand; derived with the inventory, so it expires with it. */
1559
+ const configurableOf = (memo, db, rebuild = false) => {
1560
+ const slot = inventoryOf(memo, db, rebuild);
1561
+ return (slot.configurable ??= configurableFrom(slot.value));
1562
+ };
1563
+ /**
1564
+ * Projects the Settings pages may write for: an inventory project whose
1565
+ * checkout still exists. The POST checks against this list, so the page can
1566
+ * never name an arbitrary path to write settings for.
1567
+ *
1568
+ * Read from the per-cursor memo: the inventory behind it is six full-table
1569
+ * scans. A caller that is about to write passes `fresh` and gets a rebuild, so
1570
+ * a write is never validated against a minute-old picture of the filesystem;
1571
+ * that rebuild becomes the memo's.
1572
+ */
1573
+ export function configurableProjects(db, opts = {}) {
1574
+ return configurableOf(memoFor(db), db, opts.fresh);
1575
+ }
1576
+ /** The project a `?project=` names, by its checkout or its inventory id. */
1577
+ const pickProject = (projects, root) => root ? projects.find((p) => p.id === root || p.inventory === root) ?? null : null;
1578
+ /**
1579
+ * What `/api/settings` reads that the cursor does not cover, as a string that
1580
+ * changes when any of it does.
1581
+ *
1582
+ * Files, by their stat and never by their content: the user file, and the named
1583
+ * project's. A project the inventory knows from memory alone has no answers, so
1584
+ * its settings file is in no cursor; a save from the terminal must still show on
1585
+ * the next read. Beside them, the legacy `<checkout>/.eklavya.json`, because
1586
+ * `loadConfig` still falls back to it for a project that has no settings file
1587
+ * outside the checkout (until a session moves it), and the page shows the
1588
+ * resolved values. It is a file a `git clone` may have brought, so it is
1589
+ * statted from the place `loadConfig` finds it and its bytes go nowhere near a
1590
+ * stamp.
1591
+ *
1592
+ * And when the project list inside the page was built. The list is the
1593
+ * inventory's, which expires on its own clock: a page built late in the
1594
+ * inventory's minute would otherwise carry that list a minute past the
1595
+ * inventory's expiry, and offer (and refuse a write to) a project the next
1596
+ * build would drop. With the build time in the stamp the page lives exactly as
1597
+ * long as the older of its two sources.
1598
+ */
1599
+ function settingsStamp(memo, db, root) {
1600
+ const projects = configurableOf(memo, db);
1601
+ // Served or rebuilt by the line above, so the slot is there.
1602
+ const stamps = [String(memo.inventory.at), fileStamp(globalConfigPath())];
1603
+ const project = pickProject(projects, root);
1604
+ if (project) {
1605
+ stamps.push(fileStamp(projectConfigPath(project.id)));
1606
+ const checkout = findRepoConfig(project.id).repoRoot;
1607
+ stamps.push(checkout ? fileStamp(path.join(checkout, LEGACY_REPO_CONFIG_FILE)) : '-');
1608
+ }
1609
+ return stamps.join('|');
1610
+ }
1067
1611
  const settingKeys = () => [...SETTINGS.map((f) => f.key), ...CLI_ONLY.map((c) => c.key)];
1068
1612
  /** What one file sets, key by key, in today's spelling: a key absent here inherits. */
1069
1613
  const setIn = (file) => {
@@ -1077,7 +1621,7 @@ const effectiveOf = (c) => Object.fromEntries(settingKeys().map((k) => [k, value
1077
1621
  /** `GET /api/settings?project=<checkout>`: both files and what they resolve to. */
1078
1622
  export function settingsState(db, root) {
1079
1623
  const projects = configurableProjects(db);
1080
- const project = root ? projects.find((p) => p.id === root || p.inventory === root) ?? null : null;
1624
+ const project = pickProject(projects, root);
1081
1625
  return {
1082
1626
  fields: SETTINGS,
1083
1627
  cli_only: CLI_ONLY,
@@ -1114,7 +1658,8 @@ export function updateSetting(db, body) {
1114
1658
  }
1115
1659
  let root = null;
1116
1660
  if (b.scope === 'project') {
1117
- const p = configurableProjects(db).find((x) => x.id === b.project);
1661
+ // A write is checked against the filesystem as it is, not as a minute ago.
1662
+ const p = configurableProjects(db, { fresh: true }).find((x) => x.id === b.project);
1118
1663
  if (!p)
1119
1664
  return bad('That project is not one this dashboard can configure.');
1120
1665
  if (isGlobalOnlyKey(f.key))
@@ -1128,6 +1673,7 @@ export function updateSetting(db, body) {
1128
1673
  // so this refuses exactly what `eklavya config set` refuses, in its words.
1129
1674
  try {
1130
1675
  const { target } = applySetting(f.key, unset ? undefined : b.value, root);
1676
+ forgetSettings(db);
1131
1677
  return { status: 200, body: { ok: true, key: f.key, target, unset } };
1132
1678
  }
1133
1679
  catch (err) {
@@ -1406,15 +1952,42 @@ const MASCOT_ASSETS = new Map([
1406
1952
  ]);
1407
1953
  /** The tips library, copied from node_modules at build by `copy-assets.mjs`. */
1408
1954
  const VENDOR = { '/vendor/driver-hints.js': 'text/javascript', '/vendor/driver-hints.css': 'text/css' };
1955
+ /** What a file that only changes with a release may be kept for: this browser, an hour. */
1956
+ const CACHE_HOUR = 'private, max-age=3600';
1957
+ /**
1958
+ * Whether an `If-None-Match` header names `etag`. A comparison for a GET is
1959
+ * weak (RFC 9110 §13.1.2), so `W/"x"` names `"x"`; the header may be a list,
1960
+ * with any spacing, or `*` for any version at all. Anything that is not a
1961
+ * quoted tag is ignored, never matched.
1962
+ */
1963
+ export function etagMatches(header, etag) {
1964
+ if (header === undefined)
1965
+ return false;
1966
+ if (header.trim() === '*')
1967
+ return true;
1968
+ return (header.match(/(?:W\/)?"[^"]*"/g) ?? []).some((tag) => tag.replace(/^W\//, '') === etag);
1969
+ }
1970
+ /** What every response carries: its type, `no-store`, `SECURITY_HEADERS`, then whatever the route overrides. The event stream, which is not sent in one piece, shares it. */
1971
+ const headersFor = (type, extra = {}) => ({
1972
+ 'content-type': type,
1973
+ 'cache-control': 'no-store',
1974
+ ...SECURITY_HEADERS,
1975
+ ...extra,
1976
+ });
1977
+ /**
1978
+ * Every response goes out here, so every one carries `SECURITY_HEADERS`.
1979
+ *
1980
+ * JSON is never cached (`no-store`, the default): a dashboard read from a stale
1981
+ * cache is one that lies about progress made ten seconds ago, which is the one
1982
+ * thing it is for, and the server's own memo is what makes a repeat cheap, not
1983
+ * the browser's. A file may be, because nothing about it moves with the
1984
+ * learner: the tokens, the mascot and the tips library stay an hour
1985
+ * (`CACHE_HOUR`); the page itself is `no-cache` with an `ETag`, so the browser
1986
+ * keeps it but asks, in one round trip, whether it is still the one this
1987
+ * server start wrote (the write token is in it). A caller passes `extra` to say so.
1988
+ */
1409
1989
  function send(res, status, type, body, extra = {}) {
1410
- res.writeHead(status, {
1411
- 'content-type': type,
1412
- // A dashboard read from a stale cache is a dashboard that lies about
1413
- // progress made ten seconds ago, which is the one thing it is for.
1414
- 'cache-control': 'no-store',
1415
- ...SECURITY_HEADERS,
1416
- ...extra,
1417
- });
1990
+ res.writeHead(status, headersFor(type, extra));
1418
1991
  res.end(body);
1419
1992
  }
1420
1993
  /**
@@ -1444,10 +2017,312 @@ export function fromLoopback(hostHeader, originHeader) {
1444
2017
  }
1445
2018
  return true;
1446
2019
  }
2020
+ /*
2021
+ * Live updates: `GET /api/events`.
2022
+ *
2023
+ * What an open page needs is to be told that something changed, so it can fetch
2024
+ * what. The stream carries one thing, the change cursor (`changeCursor`, the
2025
+ * string `/api/state` carries in its `cursor`): `event: cursor` and `data:
2026
+ * <cursor>` at once on connect, then one more each time it moves, and a
2027
+ * `: keep-alive` comment every `keepAliveMs` so nothing between here and the
2028
+ * browser closes a quiet stream. The payload stays one source of truth: the
2029
+ * stream says *that*, `/api/state` says *what*. `/api/cursor` is unchanged, for
2030
+ * older pages and for anything that polls.
2031
+ *
2032
+ * The writers are other processes (hooks, the MCP server, the CLI, the
2033
+ * observer), so the server can only learn of a change by reading the database
2034
+ * and the filesystem, which a watcher does for as long as a stream is open and
2035
+ * not a moment longer:
2036
+ *
2037
+ * - The floor: `changeCursor` every `floorMs`. It is 0.5 to 3 ms, and it is
2038
+ * what keeps the stream correct on a filesystem where `fs.watch` is silent
2039
+ * (a network mount, an editor that replaces a file instead of writing it).
2040
+ * - The early trigger: `fs.watch` on the database's `-wal` file, the user's
2041
+ * config file, the project config area and the artifacts folder, debounced by
2042
+ * `debounceMs` into one check. Every one of those is optional. A directory
2043
+ * that is not there yet, an `error` event: that watch is skipped or dropped
2044
+ * and the floor carries on. The `-wal` is watched through its directory,
2045
+ * filtered by name, because the file may not exist yet and is replaced when
2046
+ * the database is checkpointed; `-shm` and the database file are never
2047
+ * watched, because a reader touches them and the watcher's own reads must not
2048
+ * retrigger it.
2049
+ * The project config area and the artifacts folder hold their files one level
2050
+ * down (`<folder>/<file>`), and each folder is watched by itself, not through
2051
+ * one recursive watch: on Linux Node's recursive watch holds one watch per
2052
+ * file inode, so the first atomic rewrite of a file (a temporary file renamed
2053
+ * over it, which is how every config write is made, and how most editors save)
2054
+ * leaves the new file unwatched, and every later change to it went unseen. A
2055
+ * watch on the folder names each entry that is replaced in it, however often.
2056
+ * The area's own watch tells of a new or removed folder, and a watch is made
2057
+ * or dropped for it then.
2058
+ * - On a change the memo is refreshed first (the state and the inventory, so
2059
+ * the fetch the event provokes is a repeat), then every stream is written.
2060
+ * Between changes the watcher costs a cursor read a second and no read route
2061
+ * waits on it; on a change it pays, once, for the rebuild the next read would
2062
+ * have made anyway, and that rebuild is the single thread's for as long as it
2063
+ * takes (the builders' cost, unchanged).
2064
+ * - A write the dashboard itself accepted checks at once (`bump`) instead of
2065
+ * waiting for the floor, and does nothing when no stream is open.
2066
+ *
2067
+ * A stream that will not take a write (its buffer is full: the reader has not
2068
+ * read for a long time) is cut, not buffered for; the browser reconnects and
2069
+ * the connect event is the current cursor, so it loses nothing.
2070
+ */
2071
+ /** How often the watcher reads `changeCursor` while a stream is open. The floor: correct even where `fs.watch` is not. */
2072
+ export const EVENTS_FLOOR_MS = 1000;
2073
+ /** How long after the last filesystem event the early check waits, so a burst of writes (a hook logging eight concepts) is one check. */
2074
+ export const EVENTS_DEBOUNCE_MS = 150;
2075
+ /** An open stream is written to this often when nothing changed, so a proxy or a browser does not close it for silence. */
2076
+ export const EVENTS_KEEPALIVE_MS = 25_000;
2077
+ /**
2078
+ * Open streams at once; the next gets a 503. A browser allows six HTTP/1.1
2079
+ * connections to one origin and every stream holds one for as long as it is
2080
+ * open, so one tab uses one of its six and leaves the page five for its own
2081
+ * fetches; the page closes its stream while its tab is hidden, which keeps the
2082
+ * count at the tabs being read. Thirty-two is several browsers with several
2083
+ * tabs each plus the odd `curl`, with room to spare, and still bounds what a
2084
+ * client that opens streams in a loop can cost: a socket and a few bytes of
2085
+ * writes each per change.
2086
+ */
2087
+ export const EVENTS_MAX_STREAMS = 32;
2088
+ export const LIVE_DEFAULTS = {
2089
+ floorMs: EVENTS_FLOOR_MS,
2090
+ debounceMs: EVENTS_DEBOUNCE_MS,
2091
+ keepAliveMs: EVENTS_KEEPALIVE_MS,
2092
+ maxStreams: EVENTS_MAX_STREAMS,
2093
+ };
2094
+ const EVENTS_PATH = '/api/events';
2095
+ /** One watcher and its streams, for one server: nothing runs until the first stream opens and nothing outlives the last. */
2096
+ function createLive(db, opts) {
2097
+ const { floorMs, debounceMs, keepAliveMs, maxStreams } = { ...LIVE_DEFAULTS, ...opts };
2098
+ const streams = new Set();
2099
+ /** The cursor the open streams have been sent last. */
2100
+ let last = '';
2101
+ let floor;
2102
+ let keepAlive;
2103
+ let debounce;
2104
+ /** Set by `shutdown` and never cleared: the server it belongs to is closing and does not open again. */
2105
+ let closed = false;
2106
+ /** Every watch that is open, so `stop` can close each one and one that failed is not closed twice. */
2107
+ const watchers = new Set();
2108
+ const push = (res, chunk) => {
2109
+ if (!res.write(chunk))
2110
+ res.destroy();
2111
+ };
2112
+ const emit = (cursor) => {
2113
+ for (const res of streams)
2114
+ push(res, `event: cursor\ndata: ${cursor}\n\n`);
2115
+ };
2116
+ /**
2117
+ * Reads the cursor; when it moved and someone is listening, refreshes the memo
2118
+ * and tells them. Returns the cursor as read. Throws as `changeCursor` does.
2119
+ */
2120
+ const check = () => {
2121
+ const cursor = changeCursor(db);
2122
+ if (cursor === last)
2123
+ return cursor;
2124
+ last = cursor;
2125
+ if (streams.size > 0) {
2126
+ try {
2127
+ warmMemo(db);
2128
+ }
2129
+ catch {
2130
+ /* the fetch the event provokes builds it instead, and reports the error if it still fails */
2131
+ }
2132
+ emit(cursor);
2133
+ }
2134
+ return cursor;
2135
+ };
2136
+ /** A check from a timer, a file event or a write: nothing waits on it, so a failure (a busy or closed database) is only the next check's to retry. */
2137
+ const run = () => {
2138
+ try {
2139
+ check();
2140
+ }
2141
+ catch {
2142
+ /* the next check asks again */
2143
+ }
2144
+ };
2145
+ /** A file event: check once `debounceMs` after the last of a burst. */
2146
+ const early = () => {
2147
+ clearTimeout(debounce);
2148
+ debounce = setTimeout(run, debounceMs).unref();
2149
+ };
2150
+ const targets = () => {
2151
+ const out = [];
2152
+ if (!db.memory) {
2153
+ const file = path.resolve(db.name);
2154
+ out.push({ dir: path.dirname(file), match: (name) => name === `${path.basename(file)}-wal` });
2155
+ }
2156
+ const config = globalConfigPath();
2157
+ out.push({ dir: path.dirname(config), match: (name) => name === path.basename(config) });
2158
+ return out;
2159
+ };
2160
+ const trees = () => [
2161
+ { root: projectsDir(), match: (file) => file === 'config.json' },
2162
+ { root: artifactsDir(), match: () => true },
2163
+ ];
2164
+ /** One watch on one directory, not its subdirectories, or null when it cannot be made: optional, and the floor covers it. */
2165
+ const watchDir = (dir, onName) => {
2166
+ try {
2167
+ const watcher = fs.watch(dir, { persistent: false, recursive: false }, (_event, name) => onName(name));
2168
+ // A watch that fails is closed and forgotten, once; the floor carries on.
2169
+ watcher.on('error', () => release(watcher));
2170
+ watchers.add(watcher);
2171
+ return watcher;
2172
+ }
2173
+ catch {
2174
+ return null;
2175
+ }
2176
+ };
2177
+ const release = (watcher) => {
2178
+ watchers.delete(watcher);
2179
+ watcher.close();
2180
+ };
2181
+ /**
2182
+ * Which folder this is, not only what it is called: the inode and the time it was made. A watch is on a
2183
+ * folder, so one removed and made again under the same name (before the area's event is read) is a
2184
+ * folder the old watch will never hear from, and the filesystem may hand the new one the old inode: the
2185
+ * time it was made is what tells them apart. `-` when it cannot be read.
2186
+ */
2187
+ const identity = (dir) => {
2188
+ try {
2189
+ const s = fs.statSync(dir, { bigint: true });
2190
+ return `${s.ino}.${s.birthtimeNs}`;
2191
+ }
2192
+ catch {
2193
+ return '-';
2194
+ }
2195
+ };
2196
+ /**
2197
+ * A watch on `root` for its folders, and one on each folder for its files. The
2198
+ * root's own events (a folder made, renamed or removed) adopt what is there
2199
+ * now: a watch for each new folder, none for one that has gone, a new one for
2200
+ * one that was made again. The check they also ask for is what sees a file
2201
+ * already written into a folder by the time its watch was made.
2202
+ */
2203
+ const watchTree = ({ root, match }) => {
2204
+ const folders = new Map();
2205
+ const adopt = () => {
2206
+ let present;
2207
+ try {
2208
+ // The folders the cursor lists: no dot-names, no links (`artifactFiles`).
2209
+ present = fs.readdirSync(root, { withFileTypes: true })
2210
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
2211
+ .map((entry) => entry.name);
2212
+ }
2213
+ catch {
2214
+ present = [];
2215
+ }
2216
+ for (const [name, { watcher, id }] of folders) {
2217
+ // Gone, replaced by another folder of the name, or its watch failed (and was closed): forget it, and the
2218
+ // loop below makes a new one if the folder is there.
2219
+ const open = watchers.has(watcher);
2220
+ if (open && present.includes(name) && identity(path.join(root, name)) === id)
2221
+ continue;
2222
+ folders.delete(name);
2223
+ if (open)
2224
+ release(watcher);
2225
+ }
2226
+ for (const name of present) {
2227
+ if (folders.has(name))
2228
+ continue;
2229
+ const dir = path.join(root, name);
2230
+ // Read before the watch is made: a folder replaced in between is seen as changed by the next event.
2231
+ const id = identity(dir);
2232
+ const watcher = watchDir(dir, (file) => {
2233
+ if (file !== null && match(file))
2234
+ early();
2235
+ });
2236
+ if (watcher)
2237
+ folders.set(name, { watcher, id });
2238
+ }
2239
+ };
2240
+ const rootWatch = watchDir(root, () => {
2241
+ adopt();
2242
+ early();
2243
+ });
2244
+ if (rootWatch)
2245
+ adopt();
2246
+ };
2247
+ const watchFiles = () => {
2248
+ for (const { dir, match } of targets()) {
2249
+ watchDir(dir, (name) => {
2250
+ if (name !== null && match(name))
2251
+ early();
2252
+ });
2253
+ }
2254
+ for (const tree of trees())
2255
+ watchTree(tree);
2256
+ };
2257
+ const start = () => {
2258
+ floor = setInterval(run, floorMs).unref();
2259
+ keepAlive = setInterval(() => {
2260
+ for (const res of streams)
2261
+ push(res, ': keep-alive\n\n');
2262
+ }, keepAliveMs).unref();
2263
+ watchFiles();
2264
+ };
2265
+ const stop = () => {
2266
+ clearInterval(floor);
2267
+ clearInterval(keepAlive);
2268
+ clearTimeout(debounce);
2269
+ for (const watcher of watchers)
2270
+ watcher.close();
2271
+ watchers.clear();
2272
+ };
2273
+ return {
2274
+ connect(res) {
2275
+ // A request already on a connection when `close()` ran (the next one of a
2276
+ // pipelined pair, or a keep-alive request that was mid-flight) still reaches
2277
+ // the handler: it must not open what `shutdown` just ended. The connection is
2278
+ // told to close too, so this refusal is not what `server.close()` waits on.
2279
+ if (closed)
2280
+ return send(res, 503, 'text/plain', 'The dashboard is shutting down.\n', { connection: 'close' });
2281
+ if (streams.size >= maxStreams)
2282
+ return send(res, 503, 'text/plain', 'Too many open event streams.\n');
2283
+ // Before anything is written, so a database that cannot be read is the
2284
+ // route's ordinary 500. With streams already open, a change since the last
2285
+ // check is sent to them first, so no stream is ever sent the same cursor twice.
2286
+ const cursor = check();
2287
+ res.writeHead(200, headersFor('text/event-stream; charset=utf-8'));
2288
+ push(res, `event: cursor\ndata: ${cursor}\n\n`);
2289
+ streams.add(res);
2290
+ // A write to a dead socket can surface as an 'error' on the response; a
2291
+ // stream that errors is a stream that is gone.
2292
+ res.on('error', () => res.destroy());
2293
+ res.on('close', () => {
2294
+ if (streams.delete(res) && streams.size === 0)
2295
+ stop();
2296
+ });
2297
+ if (streams.size === 1)
2298
+ start();
2299
+ },
2300
+ bump() {
2301
+ if (streams.size > 0)
2302
+ run();
2303
+ },
2304
+ shutdown() {
2305
+ closed = true;
2306
+ const open = [...streams];
2307
+ streams.clear();
2308
+ stop();
2309
+ // A finished response is what lets `server.close` take its connection, kept alive or not: an
2310
+ // idle one is closed at once, and without this the stream would hold the close open for ever.
2311
+ for (const res of open)
2312
+ res.end();
2313
+ },
2314
+ };
2315
+ }
2316
+ /**
2317
+ * `opts.live` overrides the event stream's intervals and cap (`LIVE_DEFAULTS`);
2318
+ * only tests have a reason to. `close()` ends the open streams and resolves
2319
+ * once the server has closed.
2320
+ */
1447
2321
  export function startDashboard(db, opts = {}) {
1448
2322
  const host = opts.host ?? '127.0.0.1';
1449
2323
  const wanted = opts.port ?? dashboardPort();
1450
2324
  const assets = path.join(moduleDir, 'assets');
2325
+ const live = createLive(db, opts.live ?? {});
1451
2326
  // Per server start. Every write must carry it (see `acceptWrite`).
1452
2327
  const token = randomBytes(24).toString('hex');
1453
2328
  const json = (res, status, body) => send(res, status, 'application/json', JSON.stringify(body));
@@ -1503,6 +2378,9 @@ export function startDashboard(db, opts = {}) {
1503
2378
  }
1504
2379
  const out = route.handler(db, body);
1505
2380
  json(res, out.status, out.body);
2381
+ // Whatever the handler changed is told to the open streams now, not at the
2382
+ // next check; the answer above is already on its way, so the writer does not wait for it.
2383
+ live.bump();
1506
2384
  });
1507
2385
  };
1508
2386
  const server = http.createServer((req, res) => {
@@ -1535,7 +2413,8 @@ export function startDashboard(db, opts = {}) {
1535
2413
  return acceptWrite(req, res, write);
1536
2414
  if (req.method !== 'GET' && req.method !== 'HEAD') {
1537
2415
  return send(res, 405, 'text/plain', `This route is read-only. Writes: ${Object.keys(WRITES).join(', ')}.\n`, {
1538
- allow: write ? 'GET, HEAD, POST' : 'GET, HEAD',
2416
+ // A stream has no HEAD: there is nothing to answer without opening one.
2417
+ allow: write ? 'GET, HEAD, POST' : url.pathname === EVENTS_PATH ? 'GET' : 'GET, HEAD',
1539
2418
  });
1540
2419
  }
1541
2420
  try {
@@ -1544,20 +2423,36 @@ export function startDashboard(db, opts = {}) {
1544
2423
  if (url.pathname === '/api/health') {
1545
2424
  return send(res, 200, 'application/json', JSON.stringify({ app: 'eklavya', version: ownVersion(), pid: process.pid, db: dbPath() }));
1546
2425
  }
1547
- if (url.pathname === '/api/settings') {
1548
- return send(res, 200, 'application/json', JSON.stringify(settingsState(db, url.searchParams.get('project'))));
2426
+ // The cursor as a stream (`createLive`). A GET only: a HEAD has no stream to
2427
+ // open and falls through to the 404 it always had.
2428
+ if (url.pathname === EVENTS_PATH && req.method === 'GET')
2429
+ return live.connect(res);
2430
+ // The reads below are served from the per-cursor memo (`memoFor`): the cursor
2431
+ // is asked for first, and a build runs only when it moved or a minute passed.
2432
+ // Not memoized, on purpose: `/api/cursor`, which must always say what the
2433
+ // database says now; `/api/health`, which names the process; one feedback
2434
+ // item, which a POST acknowledges; and a correction, which is graded live.
2435
+ const pageKey = url.pathname + url.search;
2436
+ const reply = (body) => body === null ? send(res, 404, 'application/json', '{"error":"no such entry"}') : send(res, 200, 'application/json', body);
2437
+ if (url.pathname === SETTINGS_PATH) {
2438
+ const memo = memoFor(db);
2439
+ const project = url.searchParams.get('project');
2440
+ return reply(pageBody(memo, pageKey, () => JSON.stringify(settingsState(db, project)), {
2441
+ expires: true,
2442
+ stamp: () => settingsStamp(memo, db, project),
2443
+ }));
1549
2444
  }
1550
2445
  if (url.pathname === '/api/cursor') {
1551
2446
  return send(res, 200, 'application/json', JSON.stringify({ cursor: changeCursor(db) }));
1552
2447
  }
1553
2448
  if (url.pathname === '/api/state') {
1554
- return send(res, 200, 'application/json', JSON.stringify(dashboardState(db)));
2449
+ return reply(stateBody(memoFor(db), db));
1555
2450
  }
1556
2451
  // The memory timeline is a paged resource rather than part of the state
1557
2452
  // payload: the corpus is the one thing here that grows without bound.
1558
2453
  if (url.pathname === '/api/memory') {
1559
2454
  const g = (k) => url.searchParams.get(k);
1560
- return send(res, 200, 'application/json', JSON.stringify(memoryPage(db, {
2455
+ return reply(pageBody(memoFor(db), pageKey, () => JSON.stringify(memoryPage(db, {
1561
2456
  project: g('project'),
1562
2457
  session: g('session'),
1563
2458
  type: g('type'),
@@ -1567,19 +2462,19 @@ export function startDashboard(db, opts = {}) {
1567
2462
  until: g('until'),
1568
2463
  page: Number(g('page')) || 1,
1569
2464
  per: Number(g('per')) || MEMORY_PER,
1570
- })));
2465
+ }))));
1571
2466
  }
1572
2467
  if (url.pathname === '/api/projects') {
1573
- return send(res, 200, 'application/json', JSON.stringify(projectInventory(db)));
2468
+ return reply(Buffer.from(JSON.stringify(inventoryOf(memoFor(db), db).value)));
1574
2469
  }
1575
2470
  if (url.pathname === '/api/memory/sessions') {
1576
2471
  const g = (k) => url.searchParams.get(k);
1577
- return send(res, 200, 'application/json', JSON.stringify(memorySessionPage(db, {
2472
+ return reply(pageBody(memoFor(db), pageKey, () => JSON.stringify(memorySessionPage(db, {
1578
2473
  project: g('project'),
1579
2474
  session: g('session'),
1580
2475
  page: Number(g('page')) || 1,
1581
2476
  per: Number(g('per')) || MEMORY_PER,
1582
- })));
2477
+ }))));
1583
2478
  }
1584
2479
  if (url.pathname === '/api/feedback') {
1585
2480
  const out = feedbackItem(db, url.searchParams.get('id'));
@@ -1587,17 +2482,17 @@ export function startDashboard(db, opts = {}) {
1587
2482
  }
1588
2483
  if (url.pathname === '/api/feedback/list') {
1589
2484
  const g = (k) => url.searchParams.get(k);
1590
- return send(res, 200, 'application/json', JSON.stringify(listAcknowledged(db, { project: g('project'), page: Number(g('page')) || 1, per: Number(g('per')) || 20 })));
2485
+ return reply(pageBody(memoFor(db), pageKey, () => JSON.stringify(listAcknowledged(db, { project: g('project'), page: Number(g('page')) || 1, per: Number(g('per')) || 20 }))));
1591
2486
  }
1592
2487
  if (url.pathname === '/api/attempts/correction') {
1593
2488
  const out = correctionState(db, Number(url.searchParams.get('id')));
1594
2489
  return send(res, out.status, 'application/json', JSON.stringify(out.body));
1595
2490
  }
1596
2491
  if (url.pathname === '/api/memory/entry') {
1597
- const entry = memoryEntry(db, Number(url.searchParams.get('id')));
1598
- if (!entry)
1599
- return send(res, 404, 'application/json', '{"error":"no such entry"}');
1600
- return send(res, 200, 'application/json', JSON.stringify(entry));
2492
+ return reply(pageBody(memoFor(db), pageKey, () => {
2493
+ const entry = memoryEntry(db, Number(url.searchParams.get('id')));
2494
+ return entry ? JSON.stringify(entry) : null;
2495
+ }));
1601
2496
  }
1602
2497
  if (url.pathname.startsWith('/artifacts/')) {
1603
2498
  let id;
@@ -1618,7 +2513,7 @@ export function startDashboard(db, opts = {}) {
1618
2513
  return send(res, 400, 'text/plain', 'thumb is ink or paper');
1619
2514
  return send(res, 200, 'image/svg+xml; charset=utf-8', artifactThumb(file, thumb), {
1620
2515
  'content-security-policy': ARTIFACT_CSP,
1621
- 'cache-control': 'private, max-age=3600',
2516
+ 'cache-control': CACHE_HOUR,
1622
2517
  });
1623
2518
  }
1624
2519
  const embed = url.searchParams.has('embed');
@@ -1641,17 +2536,26 @@ export function startDashboard(db, opts = {}) {
1641
2536
  }
1642
2537
  const mascotAsset = MASCOT_ASSETS.get(url.pathname);
1643
2538
  if (mascotAsset) {
1644
- return send(res, 200, mascotAsset[1], fs.readFileSync(path.join(assets, mascotAsset[0])));
2539
+ return send(res, 200, mascotAsset[1], fs.readFileSync(path.join(assets, mascotAsset[0])), { 'cache-control': CACHE_HOUR });
1645
2540
  }
1646
2541
  if (url.pathname === '/tokens.css') {
1647
- return send(res, 200, 'text/css', localTokens(fs.readFileSync(path.join(assets, 'tokens.css'), 'utf8')));
2542
+ return send(res, 200, 'text/css', localTokens(fs.readFileSync(path.join(assets, 'tokens.css'), 'utf8')), {
2543
+ 'cache-control': CACHE_HOUR,
2544
+ });
1648
2545
  }
1649
2546
  if (url.pathname === '/' || url.pathname === '/index.html') {
1650
2547
  // The write token rides in the page itself: a hostile origin cannot read
1651
2548
  // this response, so it cannot learn the token to send back.
1652
2549
  const html = fs.readFileSync(path.join(assets, 'dashboard.html'), 'utf8')
1653
2550
  .replace('<meta name="eklavya-token" content="">', `<meta name="eklavya-token" content="${token}">`);
1654
- return send(res, 200, 'text/html; charset=utf-8', html);
2551
+ // The tag is a hash of what is sent, so a new server start (a new token)
2552
+ // changes it, and so does an edit to the file on disk, which the page's
2553
+ // own development loop relies on a reload to pick up.
2554
+ const etag = `"${createHash('sha256').update(html).digest('hex').slice(0, 32)}"`;
2555
+ const headers = { etag, 'cache-control': 'no-cache' };
2556
+ if (etagMatches(req.headers['if-none-match'], etag))
2557
+ return send(res, 304, 'text/html; charset=utf-8', '', headers);
2558
+ return send(res, 200, 'text/html; charset=utf-8', html, headers);
1655
2559
  }
1656
2560
  return send(res, 404, 'text/plain', 'not found');
1657
2561
  }
@@ -1669,7 +2573,32 @@ export function startDashboard(db, opts = {}) {
1669
2573
  const addr = server.address();
1670
2574
  /* c8 ignore next -- a TCP listener's address() is always an object; a string is a pipe */
1671
2575
  const port = typeof addr === 'object' && addr ? addr.port : wanted;
1672
- resolve({ url: `http://${host}:${port}`, close: () => server.close() });
2576
+ // The daemon starts at SessionStart, long before anyone opens the page, so
2577
+ // the first build is made now rather than by the first request. After
2578
+ // 'listening' and off the current turn, so neither the bind nor a probe of
2579
+ // `/api/health` waits on it; unref'd, so it never holds a process or a test
2580
+ // open; cancelled by `close()`; and a failure of any kind (a database that
2581
+ // is closed, busy or locked) only means the first request builds it instead.
2582
+ const warming = setImmediate(() => {
2583
+ try {
2584
+ warmMemo(db);
2585
+ }
2586
+ catch {
2587
+ /* the first request builds it, and reports the error if it still fails */
2588
+ }
2589
+ });
2590
+ warming.unref();
2591
+ resolve({
2592
+ url: `http://${host}:${port}`,
2593
+ // Ends every open stream first: `server.close` waits for every connection,
2594
+ // and an event stream never ends by itself. Resolves once the server has
2595
+ // closed; calling it again is harmless and resolves too.
2596
+ close: () => {
2597
+ clearImmediate(warming);
2598
+ live.shutdown();
2599
+ return new Promise((done) => server.close(() => done()));
2600
+ },
2601
+ });
1673
2602
  });
1674
2603
  server.listen(wanted, host);
1675
2604
  });