cert-coach-web 0.1.0 → 0.1.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/AGENTS.md CHANGED
@@ -54,7 +54,9 @@ Load the extension: `pi --extension ./.pi/extensions/cert-coach.ts`.
54
54
 
55
55
  ## Current certs
56
56
 
57
- - `DP600/` — Microsoft Fabric Analytics Engineer (DP-600). Blueprint verified 2026-10-21 (skills measured 2026-10-19). Bank: 117 items, 74 gradeable, 110 verified (107 confirmed, 3 corrected), 7 unverifiable, 0 pending; 12 authored gap questions are medium confidence and need a targeted docs pass. Free half: 21/43 pages. `DP600/theory.md` is a manual `DEEP_PASS`; `build_theory` refuses to overwrite it. Direct Lake on OneLake has **no** DirectQuery fallback; only the SQL analytics endpoint variant does.
58
- - `PL300/` — Microsoft Power BI Data Analyst (PL-300). Blueprint rebuilt with the current **four** domains (the old three-domain/45–50% visualize split was obsolete). Bank: 239 items, 159 gradeable, 236 verified (235 confirmed, 1 corrected), 0 pending, 3 unverifiable; 4 authored gap questions are medium confidence; free half 47/95 pages.
57
+ - `DP600/` — Microsoft Fabric Analytics Engineer (DP-600). Blueprint verified 2026-10-21 (skills measured 2026-10-19). Bank: 117 items, 74 gradeable, 110 verified (105 confirmed, 5 corrected), 7 unverifiable, 0 pending; all 16 authored gap questions human-reviewed. Free half: 21/43 pages. `DP600/theory.md` is a manual `DEEP_PASS`. Direct Lake on OneLake has **no** DirectQuery fallback; only the SQL analytics endpoint variant does.
58
+ - `PL300/` — Microsoft Power BI Data Analyst (PL-300). Current **four** domains (25–30/25–30/25–30/15–20). Bank: 234 items, 155 gradeable, 231 verified (230 confirmed, 1 corrected), 3 unverifiable, 0 pending. Free half 47/95 pages.
59
+ - `DATABRICKSDA/` — Databricks Certified Data Analyst Associate (DA-ASSOC, 45 MCQ / 90 min). Blueprint verified 2026-10-26: nine domains 11/8/5/20/15/16/12/5/8. **Dump ingested, verified, and gap-filled 2026-10-26**: bank **106 items, 91 gradeable, all answer-verified** (88 confirmed · 3 corrected · 15 unverifiable), **0 pending**, **0 unmapped**; **36-node DAG, all 36 used**, a hands-on `lab` per node, thin-bank gate `minCoverage: 0.5`. Every domain is now within ~±5 pts of blueprint share. 10 items are medium confidence — a targeted docs pass would firm them up. Note: docs have rebranded Delta Sharing → **OpenSharing** and Genie → a family (Genie One / Genie Agents / Genie Code); stems use exam-era wording, keywords carry both.
60
+ - `SAPC02/` — AWS Certified Solutions Architect – Professional (SAP-C02: 75 questions delivered = 65 scored + 10 unscored, 180 min, pass 750/1000, $300, 3-yr). Blueprint **fetched and verified 2026-10-26**: four domains 26/29/25/20. Bootstrap starter bank: 20 gradeable scenario items (all doc-verified), 31-node DAG with a hands-on AWS `lab` per node, thin-bank gate `minCoverage: 0.5`. Scenario/trade-off exam; Domain 2 is heaviest (genAI/Bedrock, event-driven, Control Tower), and scored content is 65 — not 75. ⚠️ **SAP-C02 last test day 2026-11-16; SAP-C03 registration opens 2026-10-27** (`cert_meta.lifecycle`) — confirm the booked version. Dump still to ingest.
59
61
 
60
62
  Do not trust counts written in prose; `cert_status` and `validate.mjs` are the source of truth.
package/README.md CHANGED
@@ -23,9 +23,10 @@ Counts are live in `progress.json` / `/api/status`; this is a snapshot.
23
23
  |---|---:|---:|---|---:|
24
24
  | **DP-600** | 117 | 74 | 105 confirmed · 5 corrected · 7 unverifiable · 0 pending | 22% |
25
25
  | **PL-300** | 234 | 155 | 230 confirmed · 1 corrected · 3 unverifiable · 0 pending | 23% |
26
+ | **Databricks DA** | 24 | 24 | 24 confirmed · 0 corrected · 0 unverifiable · 0 pending | bootstrap |
26
27
 
27
28
  - **Works today:** TUI (`cert_*` tools) + web console (Study, Path, Dashboard, Theory, Logs, teaching chat), Chat v2 (read + research + verify), full mock, live Mermaid, browser cert scaffold, `pi` auto-install, OpenRouter free-model config.
28
- - **Green:** 44 tests, both certs `validate` PASS, `npm pack` 61 files / no banks.
29
+ - **Green:** 67 tests, both certs `validate` PASS, `npm pack` 64 files / no banks.
29
30
  - **Next:** npm publish; generated-question review; optional extension slimming.
30
31
 
31
32
  ## Quickstart
@@ -189,7 +190,7 @@ server/ web console server (server-owned StudyLoop, SSE, auth
189
190
  public/ study SPA (Study, Path, Dashboard, Theory, Logs, chat)
190
191
  bin/ web console entry point
191
192
  tests/ deterministic core/server/web/chat/mock tests
192
- DP600/ PL300/ internal data (shareable as-is; progress.json is owner-only)
193
+ DP600/ PL300/ DATABRICKSDA/ SAPC02/ internal data (shareable as-is; progress.json is owner-only)
193
194
  ```
194
195
 
195
196
  ## Move your own workspace to another PC
@@ -200,7 +201,8 @@ For a private move (your own second machine), use the archive script:
200
201
  powershell -ExecutionPolicy Bypass -File engine/scripts/export_private.ps1
201
202
  ```
202
203
 
203
- It writes a zip to your Desktop containing the engine **and** `DP600/`, `PL300/`
204
+ It writes a zip to your Desktop containing the engine **and** `DP600/`, `PL300/`,
205
+ `DATABRICKSDA/`, `SAPC02/`
204
206
  including `raw/`, verified `bank.json`, and `vision_assets/`. Progress is reset
205
207
  in the archive and study logs are removed by default. Page state (`.cert-coach/`)
206
208
  and `node_modules/` are excluded. Use `-KeepProgress` to include attempts/history.
@@ -260,6 +262,8 @@ npm run test:e2e:real
260
262
 
261
263
  The Chrome test checks question render, no key before submit, feedback, Next, Path, and chat/tool cards. Playwright uses the installed Chrome channel. The header **+ New** button scaffolds a workspace from the browser; research and ingest still run through the agent (see `BROWSER_CERT_CREATION.md`).
262
264
 
265
+ Long chat sessions are compacted automatically so a small-context model never dies mid-study. After **12 learner turns** the server starts a fresh pi session and carries a recap of the earlier questions forward; if the model reports a context-limit error anyway, the same compaction happens on the spot and the chat keeps working. Tune the threshold with `CERT_COACH_CHAT_MAX_TURNS` (or `maxTurns` on `ChatBridge`). The chat log shows a dashed *session compacted* line when it happens.
266
+
263
267
  Mock: choose **10 / 20 / 65 questions** and **untimed / 90 / 130 minutes** before starting. The summary is isolated from `progress.json`.
264
268
 
265
269
  Mermaid fences in agent chat/theory now render as live diagrams via a local `public/vendor/mermaid.min.js`.
@@ -317,5 +321,5 @@ Pi: `cert_setup`, `cert_status`, `cert_ask`, `cert_next`, `cert_answer`, `cert_v
317
321
  - **DP-600:** 74/74 gradeable items verified; ledger 117 items (105 confirmed, 5 corrected, 7 unverifiable, 0 pending). Free half only (21/43 pages) — full coverage needs a paid account and `MAX_PAGES`.
318
322
  - **PL-300:** 155/155 gradeable items verified; ledger 234 items (230 confirmed, 1 corrected, 3 unverifiable, 0 pending). Free half (47/95 pages). `manage_secure` is ~14.5% of the bank vs the 15–20% blueprint.
319
323
  - **Image items are not lost.** 42 DP-600 / 79 PL-300 items depend on exhibits or code images. They are marked `needs_vision` with asset URLs; a vision pass can recover them.
320
- - **Data is internal-shareable, as-is.** `DP600/` and `PL300/` (banks, `raw/`, `vision_assets/`) ship in the *internal* package only, never the public npm tarball. Scraping is still subject to the source site's terms, so keep the internal export inside your org. `progress.json` is owner-only and reset on export.
324
+ - **Data is internal-shareable, as-is.** `DP600/`, `PL300/`, `DATABRICKSDA/`, and `SAPC02/` (banks, `raw/`, `vision_assets/`) ship in the *internal* package only, never the public npm tarball. Scraping is still subject to the source site's terms, so keep the internal export inside your org. `progress.json` is owner-only and reset on export.
321
325
  - **The extension is pi-only.** The portable protocol is the certification-coach skill; the deterministic state layer requires pi.
@@ -143,12 +143,17 @@ export function computeReadiness(c) {
143
143
  const gradeable = c.bank.filter((q) => q.gradeable).length || 1;
144
144
  const seen = Object.values(by).reduce((a, b) => a + b.seen, 0);
145
145
  const coverage = seen / gradeable;
146
- const label = value >= 0.85 && coverage >= 0.8 ? "ready" : value >= 0.65 ? "borderline" : "not_ready";
146
+ const gate = c.meta.readiness || {};
147
+ const minAccuracy = Number.isFinite(gate.minAccuracy) ? gate.minAccuracy : 0.85;
148
+ const minCoverage = Number.isFinite(gate.minCoverage) ? gate.minCoverage : 0.8;
149
+ const label =
150
+ value >= minAccuracy && coverage >= minCoverage ? "ready" : value >= 0.65 ? "borderline" : "not_ready";
147
151
  c.progress.readiness = {
148
152
  method: "blueprint_weighted_last_answer_accuracy",
149
153
  value: Number(value.toFixed(4)),
150
154
  label,
151
155
  coverage: Number(coverage.toFixed(4)),
156
+ gate: { minAccuracy, minCoverage },
152
157
  by_domain: Object.fromEntries(
153
158
  Object.entries(by).map(([k, v]) => [k, { ...v, accuracy: v.seen ? Number((v.lastCorrect / v.seen).toFixed(3)) : null }])
154
159
  ),
@@ -29,7 +29,7 @@
29
29
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
30
30
 
31
31
  function detectCert() {
32
- if (INJECTED_CERT && INJECTED_CERT !== "{{CERT}}") return INJECTED_CERT;
32
+ if (INJECTED_CERT && !INJECTED_CERT.includes("{")) return INJECTED_CERT;
33
33
  if (window.CERT_CODE) return window.CERT_CODE;
34
34
  const text = `${location.href} ${document.title} ${document.body?.innerText?.slice(0, 4000) || ""}`;
35
35
  const m = text.match(/\b([A-Z]{2,4}-\d{3})\b/);
@@ -17,10 +17,9 @@ if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
17
17
  New-Item -ItemType Directory -Force $stage | Out-Null
18
18
 
19
19
  $items = @(
20
- "AGENTS.md", "README.md", ".pi/skills/", ".pi/prompts/", "EERO_PORT.md",
21
- "cert-coach.ts", "extractor.js", "package.json",
22
- "core", "server", "public", "prompts", "bin", "scripts", ".agents",
23
- "DP600", "PL300"
20
+ "AGENTS.md", "README.md", "EERO_PORT.md", "BROWSER_CERT_CREATION.md", "LICENSE", "package.json",
21
+ ".pi", "engine", "web", "tests",
22
+ "DP600", "PL300", "DATABRICKSDA"
24
23
  )
25
24
  foreach ($item in $items) {
26
25
  $src = Join-Path $Repo $item
@@ -28,10 +27,13 @@ foreach ($item in $items) {
28
27
  Copy-Item $src (Join-Path $stage $item) -Recurse -Force
29
28
  }
30
29
  }
30
+ # Never ship dependencies; they are reinstalled on the other PC.
31
+ Get-ChildItem $stage -Recurse -Directory -Filter "node_modules" -ErrorAction SilentlyContinue |
32
+ Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
31
33
 
32
34
  # Reset learner state in the archive (not in the real workspace) unless asked.
33
35
  if (-not $KeepProgress) {
34
- foreach ($cert in @("DP600", "PL300")) {
36
+ foreach ($cert in @("DP600", "PL300", "DATABRICKSDA")) {
35
37
  $certDir = Join-Path $stage $cert
36
38
  if (-not (Test-Path $certDir)) { continue }
37
39
  $metaPath = Join-Path $certDir "cert_meta.json"
@@ -80,7 +82,7 @@ if (-not $KeepProgress) {
80
82
  @"
81
83
  PRIVATE CERT COACH WORKSPACE — DO NOT REDISTRIBUTE
82
84
 
83
- This archive includes the engine plus DP600/PL300 cert data (raw dumps,
85
+ This archive includes the engine plus DP600, PL300, and DATABRICKSDA cert data (raw dumps,
84
86
  verified banks, vision assets). Progress is reset; study logs are removed.
85
87
 
86
88
  On the other PC:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cert-coach-web",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Local web study console for a pi certification coach: server-owned quiz loop, images, and teaching chat.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "scripts": {
14
14
  "start": "node web/server/main.mjs",
15
- "test": "node --test tests/core.test.mjs tests/server.test.mjs tests/web.test.mjs tests/chat.test.mjs tests/mock.test.mjs",
15
+ "test": "node --test tests/core.test.mjs tests/server.test.mjs tests/web.test.mjs tests/chat.test.mjs tests/mock.test.mjs tests/spa.test.mjs tests/spa-render.test.mjs tests/ingest.test.mjs tests/validate.test.mjs tests/extractor.test.mjs",
16
16
  "pack:check": "npm pack --dry-run",
17
17
  "test:e2e:chrome": "node tests/e2e-chrome.mjs",
18
18
  "test:package": "node tests/package-smoke.mjs",
@@ -64,6 +64,7 @@
64
64
  "access": "public"
65
65
  },
66
66
  "devDependencies": {
67
- "@playwright/test": "^1.64.1"
67
+ "@playwright/test": "^1.64.1",
68
+ "jsdom": "^30.1.3"
68
69
  }
69
70
  }
package/web/public/app.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { safeHttpUrl, imageSrc } from "./spa-logic.js";
2
+ import { markdownFragment } from "./spa-render.js";
3
+
1
4
  const $ = (id) => document.getElementById(id);
2
5
 
3
6
  const el = {
@@ -165,22 +168,6 @@ function setPhase(phase) {
165
168
  el.phasePill.textContent = phase || "idle";
166
169
  }
167
170
 
168
- function safeHttpUrl(value) {
169
- try {
170
- const u = new URL(value);
171
- return u.protocol === "http:" || u.protocol === "https:" ? u.href : "";
172
- } catch {
173
- return "";
174
- }
175
- }
176
-
177
- function imageSrc(im) {
178
- const src = String(im?.src || "");
179
- if (/^https?:\/\//i.test(src)) return "/api/asset?src=" + encodeURIComponent(src);
180
- if (src.startsWith("/assets/")) return src;
181
- return "";
182
- }
183
-
184
171
  function renderImages(images) {
185
172
  el.images.replaceChildren();
186
173
  for (const im of images || []) {
@@ -456,108 +443,8 @@ async function startPractice(mode = "practice") {
456
443
  }
457
444
  }
458
445
 
459
- function inlineNodes(text) {
460
- const frag = document.createDocumentFragment();
461
- const re = /(`[^`]+`|\*\*[^*]+\*\*|\[[^\]]+\]\(https?:\/\/[^)\s]+\))/g;
462
- let last = 0;
463
- for (const m of text.matchAll(re)) {
464
- if (m.index > last) frag.append(document.createTextNode(text.slice(last, m.index)));
465
- const tok = m[0];
466
- if (tok.startsWith("`")) {
467
- const code = document.createElement("code");
468
- code.textContent = tok.slice(1, -1);
469
- frag.append(code);
470
- } else if (tok.startsWith("**")) {
471
- const b = document.createElement("strong");
472
- b.textContent = tok.slice(2, -2);
473
- frag.append(b);
474
- } else {
475
- const mm = tok.match(/^\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)$/);
476
- const a = document.createElement("a");
477
- a.textContent = mm?.[1] || tok;
478
- a.href = safeHttpUrl(mm?.[2] || "");
479
- a.target = "_blank";
480
- a.rel = "noreferrer";
481
- frag.append(a);
482
- }
483
- last = m.index + tok.length;
484
- }
485
- if (last < text.length) frag.append(document.createTextNode(text.slice(last)));
486
- return frag;
487
- }
488
-
489
- function markdownFragment(text) {
490
- const frag = document.createDocumentFragment();
491
- const lines = String(text || "").split(/\r?\n/);
492
- let i = 0;
493
- let para = [];
494
- const flush = () => {
495
- if (!para.length) return;
496
- const p = document.createElement("p");
497
- p.append(inlineNodes(para.join(" ")));
498
- frag.append(p);
499
- para = [];
500
- };
501
- while (i < lines.length) {
502
- const line = lines[i];
503
- if (/^```/.test(line)) {
504
- flush();
505
- const lang = line.slice(3).trim();
506
- i++;
507
- const code = [];
508
- while (i < lines.length && !/^```/.test(lines[i])) code.push(lines[i++]);
509
- if (i < lines.length) i++;
510
- const pre = document.createElement("pre");
511
- if (lang === "mermaid") pre.className = "diagram";
512
- const c = document.createElement("code");
513
- c.textContent = code.join("\n");
514
- pre.append(c);
515
- frag.append(pre);
516
- continue;
517
- }
518
- const h = line.match(/^(#{1,3})\s+(.*)$/);
519
- if (h) {
520
- flush();
521
- const elh = document.createElement("h" + h[1].length);
522
- elh.append(inlineNodes(h[2]));
523
- frag.append(elh);
524
- i++;
525
- continue;
526
- }
527
- if (/^\s*[-*]\s+/.test(line)) {
528
- flush();
529
- const ul = document.createElement("ul");
530
- while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
531
- const li = document.createElement("li");
532
- li.append(inlineNodes(lines[i].replace(/^\s*[-*]\s+/, "")));
533
- ul.append(li);
534
- i++;
535
- }
536
- frag.append(ul);
537
- continue;
538
- }
539
- if (/^>\s?/.test(line)) {
540
- flush();
541
- const quote = document.createElement("blockquote");
542
- quote.append(inlineNodes(line.replace(/^>\s?/, "")));
543
- frag.append(quote);
544
- i++;
545
- continue;
546
- }
547
- if (!line.trim()) {
548
- flush();
549
- i++;
550
- continue;
551
- }
552
- para.push(line);
553
- i++;
554
- }
555
- flush();
556
- return frag;
557
- }
558
-
559
446
  function renderMarkdownInto(node, text) {
560
- node.replaceChildren(markdownFragment(text));
447
+ node.replaceChildren(markdownFragment(document, text));
561
448
  void renderMermaidInto(node);
562
449
  }
563
450
 
@@ -569,7 +456,7 @@ function chatScroll(force = false) {
569
456
 
570
457
  function appendChatMessage(role, text) {
571
458
  const div = document.createElement("div");
572
- div.className = "chat-msg " + (role === "user" ? "user" : "assistant");
459
+ div.className = "chat-msg " + (role === "user" ? "user" : role === "system" ? "system" : "assistant");
573
460
  if (role === "assistant") renderMarkdownInto(div, text);
574
461
  else div.textContent = text;
575
462
  el.chatLog.append(div);
@@ -680,6 +567,16 @@ function handleChatEvent(event) {
680
567
  showNotice(event.error || "Chat failed.");
681
568
  break;
682
569
  }
570
+ case "chat.rotated": {
571
+ // The server compacted the session before it ran out of context.
572
+ appendChatMessage(
573
+ "system",
574
+ event.reason === "context-overflow"
575
+ ? "Context limit reached - earlier turns were summarised. Keep going."
576
+ : "Long session - starting a fresh context with a recap of earlier turns."
577
+ );
578
+ break;
579
+ }
683
580
  default:
684
581
  break;
685
582
  }
@@ -52,7 +52,7 @@
52
52
  <h1>Ready when you are</h1>
53
53
  <p id="idle-note" class="muted"></p>
54
54
  <div class="mock-controls">
55
- <label>Mock <select id="mock-size"><option value="10">10 questions</option><option value="20">20 questions</option><option value="65" selected>65 questions</option></select></label>
55
+ <label>Mock <select id="mock-size"><option value="10">10 questions</option><option value="20">20 questions</option><option value="45">45 questions</option><option value="65" selected>65 questions</option></select></label>
56
56
  <label>Time <select id="mock-minutes"><option value="0">untimed</option><option value="90">90 min</option><option value="130" selected>130 min</option></select></label>
57
57
  </div>
58
58
  <div class="actions">
@@ -0,0 +1,20 @@
1
+ // Pure, DOM-free SPA helpers. Kept in a module so they are unit-testable in
2
+ // node (app.js itself touches `document` at load, so it cannot be imported).
3
+
4
+ /** Allow only http(s) URLs; anything else (javascript:, data:, relative) -> "". */
5
+ export function safeHttpUrl(value) {
6
+ try {
7
+ const u = new URL(value);
8
+ return u.protocol === "http:" || u.protocol === "https:" ? u.href : "";
9
+ } catch {
10
+ return "";
11
+ }
12
+ }
13
+
14
+ /** Remote exhibits go through the guarded proxy; local /assets/ pass through. */
15
+ export function imageSrc(im) {
16
+ const src = String(im?.src || "");
17
+ if (/^https?:\/\//i.test(src)) return "/api/asset?src=" + encodeURIComponent(src);
18
+ if (src.startsWith("/assets/")) return src;
19
+ return "";
20
+ }
@@ -0,0 +1,107 @@
1
+ // DOM markdown renderer, extracted from app.js so it can be unit-tested in
2
+ // jsdom. `doc` is injected (rather than using the global) so the same code runs
3
+ // in the browser (documents) and in tests (jsdom window.document).
4
+ import { safeHttpUrl } from "./spa-logic.js";
5
+
6
+ const INLINE = /(`[^`]+`|\*\*[^*]+\*\*|\[[^\]]+\]\(https?:\/\/[^)\s]+\))/g;
7
+
8
+ /** Inline markdown (code, bold, http links) -> DocumentFragment. */
9
+ export function inlineNodes(doc, text) {
10
+ const frag = doc.createDocumentFragment();
11
+ let last = 0;
12
+ for (const m of text.matchAll(INLINE)) {
13
+ if (m.index > last) frag.append(doc.createTextNode(text.slice(last, m.index)));
14
+ const tok = m[0];
15
+ if (tok.startsWith("`")) {
16
+ const code = doc.createElement("code");
17
+ code.textContent = tok.slice(1, -1);
18
+ frag.append(code);
19
+ } else if (tok.startsWith("**")) {
20
+ const b = doc.createElement("strong");
21
+ b.textContent = tok.slice(2, -2);
22
+ frag.append(b);
23
+ } else {
24
+ const mm = tok.match(/^\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)$/);
25
+ const a = doc.createElement("a");
26
+ a.textContent = mm?.[1] || tok;
27
+ a.href = safeHttpUrl(mm?.[2] || "");
28
+ a.target = "_blank";
29
+ a.rel = "noreferrer";
30
+ frag.append(a);
31
+ }
32
+ last = m.index + tok.length;
33
+ }
34
+ if (last < text.length) frag.append(doc.createTextNode(text.slice(last)));
35
+ return frag;
36
+ }
37
+
38
+ /** Block markdown (fenced code, headings, lists, quotes, paragraphs) -> DocumentFragment. */
39
+ export function markdownFragment(doc, text) {
40
+ const frag = doc.createDocumentFragment();
41
+ const lines = String(text || "").split(/\r?\n/);
42
+ let i = 0;
43
+ let para = [];
44
+ const flush = () => {
45
+ if (!para.length) return;
46
+ const p = doc.createElement("p");
47
+ p.append(inlineNodes(doc, para.join(" ")));
48
+ frag.append(p);
49
+ para = [];
50
+ };
51
+ while (i < lines.length) {
52
+ const line = lines[i];
53
+ if (/^```/.test(line)) {
54
+ flush();
55
+ const lang = line.slice(3).trim();
56
+ i++;
57
+ const code = [];
58
+ while (i < lines.length && !/^```/.test(lines[i])) code.push(lines[i++]);
59
+ if (i < lines.length) i++;
60
+ const pre = doc.createElement("pre");
61
+ if (lang === "mermaid") pre.className = "diagram";
62
+ const c = doc.createElement("code");
63
+ c.textContent = code.join("\n");
64
+ pre.append(c);
65
+ frag.append(pre);
66
+ continue;
67
+ }
68
+ const h = line.match(/^(#{1,3})\s+(.*)$/);
69
+ if (h) {
70
+ flush();
71
+ const elh = doc.createElement("h" + h[1].length);
72
+ elh.append(inlineNodes(doc, h[2]));
73
+ frag.append(elh);
74
+ i++;
75
+ continue;
76
+ }
77
+ if (/^\s*[-*]\s+/.test(line)) {
78
+ flush();
79
+ const ul = doc.createElement("ul");
80
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
81
+ const li = doc.createElement("li");
82
+ li.append(inlineNodes(doc, lines[i].replace(/^\s*[-*]\s+/, "")));
83
+ ul.append(li);
84
+ i++;
85
+ }
86
+ frag.append(ul);
87
+ continue;
88
+ }
89
+ if (/^>\s?/.test(line)) {
90
+ flush();
91
+ const quote = doc.createElement("blockquote");
92
+ quote.append(inlineNodes(doc, line.replace(/^>\s?/, "")));
93
+ frag.append(quote);
94
+ i++;
95
+ continue;
96
+ }
97
+ if (!line.trim()) {
98
+ flush();
99
+ i++;
100
+ continue;
101
+ }
102
+ para.push(line);
103
+ i++;
104
+ }
105
+ flush();
106
+ return frag;
107
+ }
@@ -324,6 +324,7 @@ a:hover { color: #183677; }
324
324
  .chat-msg { max-width: 92%; padding: 9px 11px; border-radius: 11px; font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
325
325
  .chat-msg.user { align-self: flex-end; background: var(--cobalt-soft); border: 1px solid #cbd7f7; color: #183677; }
326
326
  .chat-msg.assistant { align-self: flex-start; background: var(--surface-2); border: 1px solid var(--rule); }
327
+ .chat-msg.system { align-self: center; max-width: 100%; background: transparent; border: 1px dashed var(--rule); color: var(--ink-3, #6b6257); font-size: 11px; font-style: italic; padding: 4px 10px; }
327
328
  .chat-msg.assistant p { margin: 0 0 7px; }
328
329
  .chat-msg.assistant p:last-child { margin-bottom: 0; }
329
330
  .chat-msg.assistant h1, .chat-msg.assistant h2, .chat-msg.assistant h3 { font-family: var(--font-display); font-size: 16px; margin: 10px 0 5px; }
@@ -21,6 +21,15 @@ function textOf(message) {
21
21
  .join("");
22
22
  }
23
23
 
24
+ // A long study session eventually overflows a small-context model. When the child
25
+ // dies for that reason we compact instead of surfacing a dead chat.
26
+ const CONTEXT_OVERFLOW = /context (?:window|length|size)|context (?:is )?too (?:big|large|long)|maximum context|token limit|too many tokens|prompt is too long/i;
27
+ const DEFAULT_MAX_TURNS = 12;
28
+
29
+ function isContextOverflow(message) {
30
+ return CONTEXT_OVERFLOW.test(String(message || ""));
31
+ }
32
+
24
33
  function contextBlock(state) {
25
34
  const lines = [];
26
35
  lines.push(`[cert] ${state?.cert || "?"} · ${state?.mode || "practice"} · phase=${state?.phase || "idle"}`);
@@ -46,7 +55,7 @@ function contextBlock(state) {
46
55
  }
47
56
 
48
57
  export class ChatBridge extends EventEmitter {
49
- constructor({ root, cert, cliPath, sessionDir = "", session = false }) {
58
+ constructor({ root, cert, cliPath, sessionDir = "", session = false, maxTurns }) {
50
59
  super();
51
60
  this.root = root;
52
61
  this.cert = cert;
@@ -56,6 +65,14 @@ export class ChatBridge extends EventEmitter {
56
65
  this.rpc = null;
57
66
  this.busy = false;
58
67
  this.history = [];
68
+ // Context hygiene: rotate the pi child before it outgrows a small-context model,
69
+ // carrying a short recap of earlier turns into the fresh session.
70
+ const configured = Number(maxTurns ?? process.env.CERT_COACH_CHAT_MAX_TURNS ?? DEFAULT_MAX_TURNS);
71
+ this.maxTurns = Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : DEFAULT_MAX_TURNS;
72
+ this.turns = 0;
73
+ this.rotations = 0;
74
+ this.recap = "";
75
+ this.lastRotateReason = "";
59
76
  this.error = cliPath ? null : "Teaching chat is unavailable: no pi CLI found. Pass --pi <pi|path> or set PI_BIN.";
60
77
  this._assistant = "";
61
78
  this._tools = new Map();
@@ -67,6 +84,10 @@ export class ChatBridge extends EventEmitter {
67
84
  running: Boolean(this.rpc?.running),
68
85
  busy: this.busy,
69
86
  error: this.error,
87
+ turns: this.turns,
88
+ maxTurns: this.maxTurns,
89
+ rotations: this.rotations,
90
+ lastRotateReason: this.lastRotateReason,
70
91
  stderr: (this.rpc?.stderr || "").slice(-1200),
71
92
  };
72
93
  }
@@ -95,8 +116,19 @@ export class ChatBridge extends EventEmitter {
95
116
  rpc.on("event", (ev) => this._onRpc(ev));
96
117
  rpc.on("exit", (e) => {
97
118
  this.busy = false;
98
- this.error = e.message;
99
119
  this.rpc = null;
120
+ if (isContextOverflow(e.message)) {
121
+ // Compact rather than dead-end: keep the learner's thread alive.
122
+ this.recap = this._buildRecap();
123
+ this.turns = 0;
124
+ this.rotations += 1;
125
+ this.lastRotateReason = "context-overflow";
126
+ this.error = null;
127
+ this._emitEvent({ type: "chat.rotated", reason: "context-overflow", rotations: this.rotations, hasRecap: Boolean(this.recap) });
128
+ this._emitEvent({ type: "chat.error", error: "Context limit reached. Earlier turns were summarised - your next message continues the conversation.", recoverable: true });
129
+ return;
130
+ }
131
+ this.error = e.message;
100
132
  this._emitEvent({ type: "chat.error", error: e.message });
101
133
  });
102
134
  await rpc.start();
@@ -161,17 +193,55 @@ export class ChatBridge extends EventEmitter {
161
193
  }
162
194
  }
163
195
 
196
+ /** Short recap of earlier turns, injected once into a fresh session. */
197
+ _buildRecap(limit = 6) {
198
+ const users = this.history.filter((m) => m.role === "user").slice(-limit);
199
+ const lastAssistant = [...this.history].reverse().find((m) => m.role === "assistant");
200
+ const lines = [];
201
+ if (users.length) {
202
+ lines.push("Earlier in this session the learner asked:");
203
+ for (const m of users) lines.push(`- ${String(m.text).replace(/\s+/g, " ").slice(0, 160)}`);
204
+ }
205
+ if (lastAssistant?.text) {
206
+ lines.push(`Where we left off: ${String(lastAssistant.text).replace(/\s+/g, " ").slice(0, 400)}`);
207
+ }
208
+ return lines.join("\n");
209
+ }
210
+
211
+ /** Stop the child so the next prompt starts a fresh pi session. */
212
+ async rotate(reason = "turn-limit") {
213
+ if (this.rpc) {
214
+ try { await this.rpc.stop(); } catch { /* ignore */ }
215
+ this.rpc = null;
216
+ }
217
+ this.recap = this._buildRecap();
218
+ this.turns = 0;
219
+ this.rotations += 1;
220
+ this.lastRotateReason = reason;
221
+ this.busy = false;
222
+ this._emitEvent({ type: "chat.rotated", reason, rotations: this.rotations, hasRecap: Boolean(this.recap) });
223
+ return this.recap;
224
+ }
225
+
164
226
  async prompt(text, state) {
165
227
  const clean = String(text || "").trim();
166
228
  if (!clean) throw new CoreError("EMPTY_PROMPT", "Message is empty.");
229
+ if (this.turns >= this.maxTurns) await this.rotate("turn-limit");
167
230
  await this.ensure();
168
231
  this.history.push({ role: "user", text: clean, at: new Date().toISOString() });
169
232
  if (this.history.length > 200) this.history = this.history.slice(-200);
170
233
  this._emitEvent({ type: "chat.user", text: clean });
171
234
  this.busy = true;
172
235
  this.error = null;
173
- const message = `${contextBlock(state)}\n\n[learner]\n${clean}`;
174
- const disposition = await this.rpc.prompt(message);
236
+ const parts = [];
237
+ if (this.recap) {
238
+ parts.push(`[session recap - earlier turns were summarised to keep this conversation responsive]\n${this.recap}`);
239
+ this.recap = "";
240
+ }
241
+ parts.push(contextBlock(state));
242
+ parts.push(`[learner]\n${clean}`);
243
+ this.turns += 1;
244
+ const disposition = await this.rpc.prompt(parts.join("\n\n"));
175
245
  return { disposition };
176
246
  }
177
247
 
@@ -386,6 +386,12 @@ export function createStudyServer({ root, loop, token = "", chat = null, runtime
386
386
  if (method === "GET" && url.pathname === "/app.js") {
387
387
  return sendFile(res, path.join(PUBLIC, "app.js"), "text/javascript; charset=utf-8");
388
388
  }
389
+ if (method === "GET" && url.pathname === "/spa-logic.js") {
390
+ return sendFile(res, path.join(PUBLIC, "spa-logic.js"), "text/javascript; charset=utf-8");
391
+ }
392
+ if (method === "GET" && url.pathname === "/spa-render.js") {
393
+ return sendFile(res, path.join(PUBLIC, "spa-render.js"), "text/javascript; charset=utf-8");
394
+ }
389
395
  if (method === "GET" && url.pathname === "/styles.css") {
390
396
  return sendFile(res, path.join(PUBLIC, "styles.css"), "text/css; charset=utf-8");
391
397
  }