eklavya 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +69 -0
  2. package/dist/ask.js +58 -0
  3. package/dist/ask.js.map +1 -0
  4. package/dist/assets/dashboard.html +407 -0
  5. package/dist/assets/tokens.css +135 -0
  6. package/dist/assets/tutor-skill.md +345 -0
  7. package/dist/cli.js +223 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/concurrency.js +41 -0
  10. package/dist/concurrency.js.map +1 -0
  11. package/dist/config.js +158 -0
  12. package/dist/config.js.map +1 -0
  13. package/dist/dashboard.js +214 -0
  14. package/dist/dashboard.js.map +1 -0
  15. package/dist/db.js +26 -0
  16. package/dist/db.js.map +1 -0
  17. package/dist/hooks/checkpoint-quiz.js +152 -0
  18. package/dist/hooks/checkpoint-quiz.js.map +1 -0
  19. package/dist/hooks/lib.js +177 -0
  20. package/dist/hooks/lib.js.map +1 -0
  21. package/dist/hooks/pre-tool-gate.js +53 -0
  22. package/dist/hooks/pre-tool-gate.js.map +1 -0
  23. package/dist/hooks/session-start.js +128 -0
  24. package/dist/hooks/session-start.js.map +1 -0
  25. package/dist/hooks/stop-quiz-check.js +147 -0
  26. package/dist/hooks/stop-quiz-check.js.map +1 -0
  27. package/dist/install.js +313 -0
  28. package/dist/install.js.map +1 -0
  29. package/dist/mcq.js +34 -0
  30. package/dist/mcq.js.map +1 -0
  31. package/dist/migrate.js +45 -0
  32. package/dist/migrate.js.map +1 -0
  33. package/dist/migrations/001_init.sql +71 -0
  34. package/dist/migrations/002_stop_markers.sql +14 -0
  35. package/dist/migrations/003_gate_repo.sql +5 -0
  36. package/dist/migrations/004_attempt_outcome.sql +15 -0
  37. package/dist/migrations/005_session_concept_origin.sql +24 -0
  38. package/dist/migrations/006_attempt_format.sql +31 -0
  39. package/dist/migrations/007_checkpoints.sql +23 -0
  40. package/dist/migrations/008_difficulty_levels.sql +57 -0
  41. package/dist/paths.js +25 -0
  42. package/dist/paths.js.map +1 -0
  43. package/dist/plugin/.claude-plugin/marketplace.json +14 -0
  44. package/dist/plugin/.claude-plugin/plugin.json +17 -0
  45. package/dist/plugin/.mcp.json +11 -0
  46. package/dist/plugin/agents/tutor.md +63 -0
  47. package/dist/plugin/cli/eklavya-gate +67 -0
  48. package/dist/plugin/hooks/hooks.json +57 -0
  49. package/dist/plugin/hooks/run.mjs +169 -0
  50. package/dist/plugin/scripts/install-git-hook.sh +81 -0
  51. package/dist/plugin/skills/gate/SKILL.md +17 -0
  52. package/dist/plugin/skills/learn/SKILL.md +28 -0
  53. package/dist/plugin/skills/level/SKILL.md +53 -0
  54. package/dist/plugin/skills/mode/SKILL.md +56 -0
  55. package/dist/plugin/skills/progress/SKILL.md +72 -0
  56. package/dist/plugin/skills/quiz/SKILL.md +31 -0
  57. package/dist/plugin/skills/setup/SKILL.md +49 -0
  58. package/dist/plugin/skills/tutor/SKILL.md +345 -0
  59. package/dist/seed/git.json +49 -0
  60. package/dist/seed/node-backend.json +43 -0
  61. package/dist/seed/react.json +46 -0
  62. package/dist/seed/web-auth.json +78 -0
  63. package/dist/seed.js +112 -0
  64. package/dist/seed.js.map +1 -0
  65. package/dist/server.js +52 -0
  66. package/dist/server.js.map +1 -0
  67. package/dist/session.js +24 -0
  68. package/dist/session.js.map +1 -0
  69. package/dist/slug.js +75 -0
  70. package/dist/slug.js.map +1 -0
  71. package/dist/srs.js +206 -0
  72. package/dist/srs.js.map +1 -0
  73. package/dist/store.js +450 -0
  74. package/dist/store.js.map +1 -0
  75. package/dist/tools/config_tools.js +119 -0
  76. package/dist/tools/config_tools.js.map +1 -0
  77. package/dist/tools/get_concept_graph.js +104 -0
  78. package/dist/tools/get_concept_graph.js.map +1 -0
  79. package/dist/tools/get_gate_status.js +20 -0
  80. package/dist/tools/get_gate_status.js.map +1 -0
  81. package/dist/tools/get_learner_profile.js +142 -0
  82. package/dist/tools/get_learner_profile.js.map +1 -0
  83. package/dist/tools/get_session_quiz_plan.js +393 -0
  84. package/dist/tools/get_session_quiz_plan.js.map +1 -0
  85. package/dist/tools/index.js +45 -0
  86. package/dist/tools/index.js.map +1 -0
  87. package/dist/tools/log_session_concepts.js +103 -0
  88. package/dist/tools/log_session_concepts.js.map +1 -0
  89. package/dist/tools/record_attempt.js +158 -0
  90. package/dist/tools/record_attempt.js.map +1 -0
  91. package/dist/tools/types.js +4 -0
  92. package/dist/tools/types.js.map +1 -0
  93. package/dist/tools/upsert_concepts.js +89 -0
  94. package/dist/tools/upsert_concepts.js.map +1 -0
  95. package/package.json +66 -0
package/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # eklavya
2
+
3
+ [Eklavya](https://github.com/ProjectAJ14/eklavya) — learn while your agent works. This package is
4
+ the whole thing: the installer, the MCP server, the CLI, and the Claude Code plugin it installs.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ npx eklavya install
10
+ ```
11
+
12
+ That checks your Node version, installs the runtime and its SQLite driver, registers the plugin
13
+ with Claude Code and enables it, and creates the database. Restart Claude Code afterwards, then
14
+ run `/eklavya:setup` to pick a mode. Re-running it is how you upgrade; `npx eklavya uninstall`
15
+ removes it and keeps your history unless you pass `--purge`.
16
+
17
+ Requires **Node 22+** — below that the SQLite driver has no prebuilt binary and would need a C++
18
+ toolchain to compile.
19
+
20
+ ## As a standalone MCP server
21
+
22
+ The server runs on its own, so any MCP client can use it — this is what makes Eklavya
23
+ editor-agnostic.
24
+
25
+ ```json
26
+ {
27
+ "mcpServers": {
28
+ "eklavya": { "command": "npx", "args": ["-y", "eklavya", "serve"] }
29
+ }
30
+ }
31
+ ```
32
+
33
+ > Renamed from `eklavya-mcp` after 1.7.0, and the separate `eklavya-mcp` binary folded into
34
+ > `eklavya serve` at the same time — one package, one command. The old package still exists on
35
+ > npm so older installs keep resolving, but it is no longer updated.
36
+
37
+ State lives in `~/.eklavya/knowledge.db` (SQLite, WAL). Override with `EKLAVYA_HOME` or `EKLAVYA_DB`.
38
+
39
+ ## Tools
40
+
41
+ | Tool | Purpose |
42
+ |---|---|
43
+ | `get_learner_profile` | what the developer already knows; call before teaching |
44
+ | `log_session_concepts` | record what the current task exercises, with code context |
45
+ | `get_session_quiz_plan` | what to ask, and at which difficulty tier |
46
+ | `record_attempt` | grade 0–5, update mastery and the review schedule |
47
+ | `get_gate_status` | whether this session's quiz gate has passed |
48
+ | `upsert_concepts` | grow the graph, with slug normalization and dedupe |
49
+ | `get_concept_graph` | a domain in prerequisite order |
50
+ | `get_config` / `set_config` | global and per-repo settings |
51
+
52
+ `session_id` is optional on every tool — the server resolves the current session itself.
53
+
54
+ ## CLI
55
+
56
+ ```bash
57
+ eklavya install # install into Claude Code, runtime included
58
+ eklavya uninstall [--purge] # remove it; --purge also deletes your history
59
+ eklavya doctor # check the install
60
+ eklavya config get
61
+ eklavya config set mode enforced [--repo] # how hard it pushes
62
+ eklavya config set focus concept # what it teaches
63
+ eklavya config set focus learn --topic caching
64
+ eklavya export-rules --out rules.md # the tutor pedagogy as Markdown
65
+ eklavya dashboard [--port <n>] # your learning history, served locally
66
+ eklavya db-path
67
+ ```
68
+
69
+ MIT.
package/dist/ask.js ADDED
@@ -0,0 +1,58 @@
1
+ /** Blank line between stem and footer, so the two never read as one sentence. */
2
+ export const FOOTER_GAP = '\n\n';
3
+ /** What each tier is actually asking for, so `tier 4` is not a bare number. */
4
+ const TIER_LABEL = {
5
+ 1: 'recall',
6
+ 2: 'mechanism',
7
+ 3: 'judgement',
8
+ 4: 'failure modes',
9
+ 5: 'design',
10
+ };
11
+ /**
12
+ * `null` when nothing should be shown.
13
+ *
14
+ * `quiet` is the only suppressor: someone who turned the narration off has
15
+ * already answered this question.
16
+ *
17
+ * `cadence` is deliberately absent. The question's *arrival* -- mid-task or at
18
+ * the end -- already tells them when Eklavya asks, and a footer that repeats what
19
+ * the moment just demonstrated is noise. Everything else is here: the mode, the
20
+ * focus, the level, what the tier is asking for, and how many questions are
21
+ * coming. A learner who cannot see those is answering a question with no idea
22
+ * why it was pitched where it was, or whether four more follow.
23
+ */
24
+ export function askFooter({ config, level, pinned, tier, position }) {
25
+ if (config.quiet)
26
+ return null;
27
+ const focus = config.focus === 'learn' && config.focus_topic
28
+ ? `learn: ${config.focus_topic}`
29
+ : config.focus;
30
+ const label = TIER_LABEL[tier];
31
+ const parts = [
32
+ // `enforced` is the one value with a consequence attached, so it says so.
33
+ config.mode === 'enforced' ? 'enforced (gated)' : config.mode,
34
+ focus,
35
+ // A pinned level explains itself here or nowhere: without it, questions
36
+ // simply stop getting harder one day and nothing on screen says why.
37
+ pinned ? `${level} (pinned)` : level,
38
+ label ? `tier ${tier} ${label}` : `tier ${tier}`,
39
+ ];
40
+ if (position && position.total > 1)
41
+ parts.push(`q ${position.index}/${position.total}`);
42
+ return parts.join(' · ');
43
+ }
44
+ /**
45
+ * A trailing footer line, matched exactly as `askFooter` composes it.
46
+ *
47
+ * Strict, anchored to the end, and one line only, so it can never eat a real
48
+ * stem. It exists because the tutor will eventually record the whole block it
49
+ * displayed, and `question` is what `questionFingerprint` hashes -- a footer
50
+ * inside the stem would make the same question look brand new every time the
51
+ * level or the focus changed, which is precisely the failure migration 006 kept
52
+ * the options out of the stem to avoid.
53
+ */
54
+ const FOOTER_LINE = /\n[ \t]*(?:(?:off|ambient|enforced(?:[ \t]*\(gated\))?)[ \t]*·[ \t]*)?(?:project|concept|learn(?::[^\n·]*)?)[ \t]*·[ \t]*(?:easy|medium|hard)(?:[ \t]*\(pinned\))?[ \t]*·[ \t]*tier[ \t]*[1-5][^\n]*$/i;
55
+ export function stripAskFooter(question) {
56
+ return question.replace(FOOTER_LINE, '').trimEnd();
57
+ }
58
+ //# sourceMappingURL=ask.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ask.js","sourceRoot":"","sources":["../src/ask.ts"],"names":[],"mappings":"AA0BA,iFAAiF;AACjF,MAAM,CAAC,MAAM,UAAU,GAAG,MAAM,CAAC;AAajC,+EAA+E;AAC/E,MAAM,UAAU,GAA2B;IACzC,CAAC,EAAE,QAAQ;IACX,CAAC,EAAE,WAAW;IACd,CAAC,EAAE,WAAW;IACd,CAAC,EAAE,eAAe;IAClB,CAAC,EAAE,QAAQ;CACZ,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,SAAS,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAkB;IACjF,IAAI,MAAM,CAAC,KAAK;QAAE,OAAO,IAAI,CAAC;IAE9B,MAAM,KAAK,GACT,MAAM,CAAC,KAAK,KAAK,OAAO,IAAI,MAAM,CAAC,WAAW;QAC5C,CAAC,CAAC,UAAU,MAAM,CAAC,WAAW,EAAE;QAChC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;IAEnB,MAAM,KAAK,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC;IAC/B,MAAM,KAAK,GAAG;QACZ,0EAA0E;QAC1E,MAAM,CAAC,IAAI,KAAK,UAAU,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI;QAC7D,KAAK;QACL,wEAAwE;QACxE,qEAAqE;QACrE,MAAM,CAAC,CAAC,CAAC,GAAG,KAAK,WAAW,CAAC,CAAC,CAAC,KAAK;QACpC,KAAK,CAAC,CAAC,CAAC,QAAQ,IAAI,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,QAAQ,IAAI,EAAE;KACjD,CAAC;IACF,IAAI,QAAQ,IAAI,QAAQ,CAAC,KAAK,GAAG,CAAC;QAAE,KAAK,CAAC,IAAI,CAAC,KAAK,QAAQ,CAAC,KAAK,IAAI,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC;IAExF,OAAO,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AAC3B,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,GACf,wMAAwM,CAAC;AAE3M,MAAM,UAAU,cAAc,CAAC,QAAgB;IAC7C,OAAO,QAAQ,CAAC,OAAO,CAAC,WAAW,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC;AACrD,CAAC"}
@@ -0,0 +1,407 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Eklavya — learning progress</title>
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' rx='5' fill='%23049D66'/%3E%3Cg fill='none' stroke='%23fff' stroke-width='1.8' stroke-linecap='round'%3E%3Cpath d='M7 5a11 11 0 0 1 0 14'/%3E%3Cpath d='M6 12h12'/%3E%3Cpath d='M14 9l3 3-3 3'/%3E%3C/g%3E%3C/svg%3E">
8
+ <link rel="stylesheet" href="/tokens.css">
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
12
+ <style>
13
+ *, *::before, *::after { box-sizing: border-box; }
14
+ body { margin:0; font-family:var(--font-sans); background:var(--bg-subtle); color:var(--fg);
15
+ font-size:var(--text-base); line-height:var(--lh-base); }
16
+ .wrap { max-width:1140px; margin:0 auto; padding:0 var(--space-8); }
17
+ code, .mono { font-family:var(--font-mono); font-size:var(--text-sm); }
18
+
19
+ /* ---------- header ---------- */
20
+ header { background:var(--neutral-900); color:var(--on-dark); padding:var(--space-8) 0 var(--space-10); }
21
+ .bar { display:flex; align-items:center; gap:var(--space-4); flex-wrap:wrap; }
22
+ .mark { width:36px; height:36px; border-radius:var(--radius-md); background:var(--brand);
23
+ display:grid; place-items:center; flex:none; }
24
+ h1 { font-size:var(--text-xl); font-weight:var(--weight-bold); letter-spacing:-.02em; margin:0; }
25
+ .sub { color:var(--on-dark-muted); font-size:var(--text-sm); margin:2px 0 0; }
26
+ .spacer { flex:1 1 auto; }
27
+ select { font:inherit; font-size:var(--text-sm); background:var(--neutral-800); color:var(--on-dark);
28
+ border:1px solid var(--neutral-700); border-radius:var(--radius-md);
29
+ padding:var(--space-2) var(--space-3); }
30
+ select:focus-visible { outline:none; box-shadow:var(--ring); }
31
+
32
+ /* ---------- stat tiles ---------- */
33
+ .tiles { display:grid; grid-template-columns:repeat(4,1fr); gap:var(--space-4); margin-top:var(--space-6); }
34
+ .tile { background:var(--neutral-800); border:1px solid var(--neutral-700); border-radius:var(--radius-lg);
35
+ padding:var(--space-5); }
36
+ .tile .k { font-size:var(--text-xs); font-weight:var(--weight-bold); letter-spacing:.08em;
37
+ text-transform:uppercase; color:var(--on-dark-muted); }
38
+ .tile .v { font-size:var(--text-3xl); font-weight:var(--weight-extra,800); letter-spacing:-.025em;
39
+ line-height:1.1; margin-top:var(--space-2); }
40
+ .tile .n { font-size:var(--text-sm); color:var(--on-dark-muted); margin-top:var(--space-1); }
41
+ .good { color:var(--green-300); }
42
+
43
+ /* ---------- cards ---------- */
44
+ main { padding:var(--space-10) 0 var(--space-20); }
45
+ section { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg);
46
+ padding:var(--space-6); margin-bottom:var(--space-6); }
47
+ h2 { font-size:var(--text-md); font-weight:var(--weight-bold); letter-spacing:-.01em; margin:0; }
48
+ .hint { color:var(--fg-subtle); font-size:var(--text-sm); margin:var(--space-1) 0 var(--space-5); }
49
+ .grid2 { display:grid; grid-template-columns:1fr 1fr; gap:var(--space-6); }
50
+ .grid2 > section { margin:0; }
51
+
52
+ /* ---------- tables ---------- */
53
+ table { width:100%; border-collapse:collapse; font-size:var(--text-sm); }
54
+ th { text-align:left; font-size:var(--text-xs); font-weight:var(--weight-bold); letter-spacing:.08em;
55
+ text-transform:uppercase; color:var(--fg-subtle); padding:0 var(--space-3) var(--space-2) 0;
56
+ border-bottom:1px solid var(--border); white-space:nowrap; }
57
+ td { padding:var(--space-3) var(--space-3) var(--space-3) 0; border-bottom:1px solid var(--border);
58
+ vertical-align:top; }
59
+ tr:last-child td { border-bottom:none; }
60
+ .num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
61
+ .scroll { overflow-x:auto; }
62
+
63
+ /* ---------- meters (sequential, one hue) ---------- */
64
+ .meter { height:8px; border-radius:var(--radius-pill); background:var(--bg-sunken); overflow:hidden;
65
+ min-width:80px; }
66
+ .meter > i { display:block; height:100%; background:var(--brand); border-radius:var(--radius-pill); }
67
+ .meter.warn > i { background:var(--warning); }
68
+
69
+ /* ---------- chart ---------- */
70
+ .legend { display:flex; gap:var(--space-5); font-size:var(--text-sm); color:var(--fg-muted);
71
+ margin-bottom:var(--space-4); flex-wrap:wrap; }
72
+ .legend b { display:inline-block; width:10px; height:10px; border-radius:3px; margin-right:var(--space-2); }
73
+ .chart { position:relative; }
74
+ .chart svg { display:block; width:100%; height:200px; overflow:visible; }
75
+ .gridline { stroke:var(--border); stroke-width:1; }
76
+ .axis { font-family:var(--font-mono); font-size:10px; fill:var(--fg-faint); }
77
+ .hit { fill:transparent; cursor:pointer; }
78
+ .hit:hover + .col, .hit:focus-visible + .col { opacity:.75; }
79
+ .tip { position:absolute; pointer-events:none; opacity:0; transform:translate(-50%,-100%);
80
+ background:var(--neutral-900); color:var(--on-dark); border-radius:var(--radius-md);
81
+ padding:var(--space-3); font-size:var(--text-xs); line-height:1.6; white-space:nowrap;
82
+ box-shadow:var(--shadow-md); transition:opacity var(--duration-fast) var(--ease); z-index:2; }
83
+ .tip.on { opacity:1; }
84
+ .tip .r { display:flex; justify-content:space-between; gap:var(--space-4); }
85
+
86
+ /* ---------- pills & misc ---------- */
87
+ .pill { display:inline-block; font-family:var(--font-mono); font-size:var(--text-xs);
88
+ padding:2px 8px; border-radius:var(--radius-pill); background:var(--bg-sunken);
89
+ color:var(--fg-muted); white-space:nowrap; }
90
+ .pill.due { background:var(--warning-bg); color:#8a5600; }
91
+ .pill.ok { background:var(--brand-subtle); color:var(--green-700); }
92
+ .ctx { color:var(--fg); }
93
+ .slug { font-family:var(--font-mono); font-size:var(--text-xs); color:var(--fg-subtle); }
94
+ .empty { color:var(--fg-subtle); font-size:var(--text-sm); }
95
+ footer { color:var(--fg-subtle); font-size:var(--text-sm); text-align:center; padding-bottom:var(--space-10); }
96
+
97
+ @media (max-width:900px) {
98
+ .tiles { grid-template-columns:repeat(2,1fr); }
99
+ .grid2 { grid-template-columns:1fr; }
100
+ }
101
+ @media (max-width:560px) {
102
+ .wrap { padding:0 var(--space-4); }
103
+ .tiles { grid-template-columns:1fr 1fr; gap:var(--space-3); }
104
+ .tile .v { font-size:var(--text-2xl); }
105
+ }
106
+ @media (prefers-reduced-motion:reduce) { * { transition:none !important; animation:none !important; } }
107
+ </style>
108
+ </head>
109
+ <body>
110
+ <header>
111
+ <div class="wrap">
112
+ <div class="bar">
113
+ <div class="mark" aria-hidden="true">
114
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.8"
115
+ stroke-linecap="round" stroke-linejoin="round">
116
+ <path d="M5 3a13 13 0 0 1 0 18"/><path d="M5 3l14 9L5 21"/><path d="M3 12h18"/><path d="M15 8l4 4-4 4"/>
117
+ </svg>
118
+ </div>
119
+ <div>
120
+ <h1>Learning progress</h1>
121
+ <p class="sub" id="sub">Loading…</p>
122
+ </div>
123
+ <div class="spacer"></div>
124
+ <label class="sub" for="proj">Project</label>
125
+ <select id="proj"><option value="*">All projects</option></select>
126
+ </div>
127
+ <div class="tiles" id="tiles"></div>
128
+ </div>
129
+ </header>
130
+
131
+ <main class="wrap">
132
+ <section>
133
+ <h2>Answers over the last 30 days</h2>
134
+ <p class="hint" id="chart-hint">Right, missed and skipped, per day.</p>
135
+ <div class="legend">
136
+ <span><b style="background:var(--green-600)"></b>Right</span>
137
+ <span><b style="background:var(--warning)"></b>Missed</span>
138
+ <span><b style="background:var(--neutral-400)"></b>Skipped</span>
139
+ </div>
140
+ <div class="chart"><div class="tip" id="tip"></div><svg id="chart" role="img"
141
+ aria-label="Daily answers, split into right, missed and skipped"></svg></div>
142
+ </section>
143
+
144
+ <div class="grid2">
145
+ <section>
146
+ <h2>Projects</h2>
147
+ <p class="hint">Where the learning is happening, and how it is going there.</p>
148
+ <div class="scroll"><table id="projects"></table></div>
149
+ </section>
150
+ <section>
151
+ <h2>Domains</h2>
152
+ <p class="hint">Mastered out of what you have actually touched. The catalogue is a library, not a to-do list.</p>
153
+ <div class="scroll"><table id="domains"></table></div>
154
+ </section>
155
+ </div>
156
+
157
+ <section>
158
+ <h2>What these sessions taught you</h2>
159
+ <p class="hint">The real code each concept came from — the memory the slug hangs on.</p>
160
+ <div class="scroll"><table id="recent"></table></div>
161
+ </section>
162
+
163
+ <section>
164
+ <h2>Skipped</h2>
165
+ <p class="hint">Declined or blanked. They come back a tier lower — a backlog, not a scolding.</p>
166
+ <div class="scroll"><table id="skipped"></table></div>
167
+ </section>
168
+
169
+ <section>
170
+ <h2>Every concept you have been asked</h2>
171
+ <p class="hint">Weakest first. Score decays until you see it again.</p>
172
+ <div class="scroll"><table id="concepts"></table></div>
173
+ </section>
174
+ </main>
175
+
176
+ <footer class="wrap">
177
+ Read from <code id="dbline">~/.eklavya/knowledge.db</code> — nothing on this page leaves your machine.
178
+ </footer>
179
+
180
+ <script>
181
+ const $ = (id) => document.getElementById(id);
182
+ const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
183
+ const base = (p) => (p ? p.split('/').filter(Boolean).pop() : 'unattributed');
184
+ const pct = (n, d) => (d ? Math.round((n / d) * 100) : 0);
185
+ const day = (s) => new Date(s + 'T00:00:00').toLocaleDateString(undefined, { month:'short', day:'numeric' });
186
+ const ago = (ts) => {
187
+ const d = Math.floor((Date.now() - new Date(ts.replace(' ', 'T') + 'Z')) / 86400000);
188
+ return d <= 0 ? 'today' : d === 1 ? 'yesterday' : d + ' days ago';
189
+ };
190
+
191
+ let STATE = null;
192
+ let FILTER = '*';
193
+
194
+ const inScope = (repo) => FILTER === '*' || (repo ?? '') === (FILTER === '~' ? '' : FILTER);
195
+
196
+ function rows(t, head, body) {
197
+ t.innerHTML = body.length
198
+ ? '<thead><tr>' + head.map((h) => `<th${h.n ? ' class="num"' : ''}>${esc(h.t)}</th>`).join('') + '</tr></thead>'
199
+ + '<tbody>' + body.join('') + '</tbody>'
200
+ : '<tbody><tr><td class="empty">Nothing here yet.</td></tr></tbody>';
201
+ }
202
+
203
+ function renderTiles() {
204
+ const t = STATE.totals;
205
+ const scoped = STATE.projects.filter((p) => inScope(p.repo));
206
+ const answers = scoped.reduce((a, p) => a + p.answers, 0);
207
+ const passed = scoped.reduce((a, p) => a + p.passed, 0);
208
+ const skipped = scoped.reduce((a, p) => a + p.skipped, 0);
209
+ const due = STATE.concepts.filter((c) => c.due && inScope(c.repo)).length;
210
+ const mastered = STATE.concepts.filter((c) => c.mastered && inScope(c.repo)).length;
211
+ const tiles = [
212
+ ['Accuracy', answers ? pct(passed, answers) + '%' : '—', `${passed} right of ${answers} answered`, passed && pct(passed, answers) >= 70],
213
+ ['Mastered', mastered, `of ${STATE.concepts.filter((c) => inScope(c.repo)).length} concepts asked`, mastered > 0],
214
+ ['Due now', due, due ? 'run /eklavya:quiz' : 'nothing waiting', false],
215
+ ['Skipped', skipped, skipped ? 'waiting a tier lower' : 'none ducked', false],
216
+ ];
217
+ $('tiles').innerHTML = tiles.map(([k, v, n, good]) =>
218
+ `<div class="tile"><div class="k">${k}</div><div class="v${good ? ' good' : ''}">${esc(v)}</div><div class="n">${esc(n)}</div></div>`).join('');
219
+ }
220
+
221
+ function renderChart() {
222
+ const byDay = new Map();
223
+ for (const d of STATE.timeline) {
224
+ if (!inScope(d.repo)) continue;
225
+ const cur = byDay.get(d.day) ?? { day: d.day, passed: 0, missed: 0, skipped: 0 };
226
+ cur.passed += d.passed; cur.missed += d.missed; cur.skipped += d.skipped;
227
+ byDay.set(d.day, cur);
228
+ }
229
+ const svg = $('chart');
230
+ if (!byDay.size) {
231
+ svg.innerHTML = '<text x="0" y="24" class="axis">No answers in this window.</text>';
232
+ return;
233
+ }
234
+ // A real date axis: every day in the window gets a slot, including the empty
235
+ // ones. Plotting only the days that have data would space three sessions
236
+ // evenly across a month and hide the gaps, which are the honest part.
237
+ const days = [];
238
+ const cursor = new Date();
239
+ cursor.setUTCHours(0, 0, 0, 0);
240
+ cursor.setUTCDate(cursor.getUTCDate() - (STATE.timeline_days - 1));
241
+ for (let i = 0; i < STATE.timeline_days; i++) {
242
+ const key = cursor.toISOString().slice(0, 10);
243
+ days.push(byDay.get(key) ?? { day: key, passed: 0, missed: 0, skipped: 0 });
244
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
245
+ }
246
+ // The viewBox tracks the container's real pixel width so the SVG renders 1:1.
247
+ // A fixed 1000-wide viewBox scaled to a 520px card would shrink the 10px axis
248
+ // text to five, which is the whole reason the chart is redrawn on resize.
249
+ const W = Math.max(320, svg.parentElement.clientWidth || 1000);
250
+ const H = 200, PAD_B = 22, PAD_L = 24;
251
+ const max = Math.max(...days.map((d) => d.passed + d.missed + d.skipped), 1);
252
+ const step = (W - PAD_L) / days.length;
253
+ const bw = Math.min(28, Math.max(6, step - 6));
254
+ const y = (v) => (H - PAD_B) - (v / max) * (H - PAD_B - 8);
255
+ const GAP = 2; // surface gap between stacked segments
256
+
257
+ let out = '';
258
+ for (let i = 0; i <= 2; i++) {
259
+ const v = Math.round((max / 2) * i);
260
+ out += `<line class="gridline" x1="${PAD_L}" x2="${W}" y1="${y(v)}" y2="${y(v)}"/>`
261
+ + `<text class="axis" x="0" y="${y(v) + 3}">${v}</text>`;
262
+ }
263
+ // Stack order is fixed: right at the base, then missed, then skipped on top.
264
+ // Segment colour never depends on rank, only on which measure it is.
265
+ const SEGMENTS = [['passed', 'var(--green-600)'], ['missed', 'var(--warning)'], ['skipped', 'var(--neutral-400)']];
266
+ const labelEvery = Math.max(1, Math.ceil(46 / step));
267
+ days.forEach((d, i) => {
268
+ const x = PAD_L + i * step + (step - bw) / 2;
269
+ let acc = 0, seg = '', topColor = 'var(--green-600)';
270
+ for (const [key, color] of SEGMENTS) {
271
+ if (!d[key]) continue;
272
+ const h = (d[key] / max) * (H - PAD_B - 8);
273
+ // A 2px surface gap between stacked fills, taken off the upper segment
274
+ // so the bar's total height still reads as the day's total.
275
+ const inset = acc ? GAP : 0;
276
+ seg += `<rect x="${x}" y="${y(acc) - h + inset}" width="${bw}" height="${Math.max(1, h - inset)}" fill="${color}"/>`;
277
+ acc += d[key];
278
+ topColor = color;
279
+ }
280
+ // 4px rounded cap on the top of the stack only; the base stays square on the
281
+ // axis. A day with no answers draws nothing at all — a cap on an empty stack
282
+ // is a mark sitting on the baseline, which reads as data that is not there.
283
+ const hit = `<rect class="hit" x="${PAD_L + i * step}" y="0" width="${step}" height="${H - PAD_B}" data-i="${i}" tabindex="0"/>`;
284
+ const cap = acc ? `<rect x="${x}" y="${y(acc)}" width="${bw}" height="4" rx="2" fill="${topColor}"/>` : '';
285
+ out += hit + `<g class="col">${seg}${cap}</g>`;
286
+ // Ends always get a label; strided ones stand down near the right edge
287
+ // rather than colliding with the last date.
288
+ if (i === 0 || i === days.length - 1 || (i % labelEvery === 0 && i < days.length - labelEvery)) {
289
+ out += `<text class="axis" x="${x + bw / 2}" text-anchor="middle" y="${H - 6}">${day(d.day)}</text>`;
290
+ }
291
+ });
292
+ svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
293
+ svg.innerHTML = out;
294
+
295
+ const tip = $('tip');
296
+ svg.querySelectorAll('.hit').forEach((hit) => {
297
+ const show = () => {
298
+ const d = days[+hit.dataset.i];
299
+ tip.innerHTML = `<div class="mono">${esc(day(d.day))}</div>`
300
+ + [['Right', d.passed], ['Missed', d.missed], ['Skipped', d.skipped]]
301
+ .map(([k, v]) => `<div class="r"><span>${k}</span><span>${v}</span></div>`).join('');
302
+ const r = hit.getBoundingClientRect(), p = svg.parentElement.getBoundingClientRect();
303
+ tip.style.left = (r.left - p.left + r.width / 2) + 'px';
304
+ tip.style.top = (r.top - p.top - 8) + 'px';
305
+ tip.classList.add('on');
306
+ };
307
+ hit.addEventListener('mouseenter', show);
308
+ hit.addEventListener('focus', show);
309
+ hit.addEventListener('mouseleave', () => tip.classList.remove('on'));
310
+ hit.addEventListener('blur', () => tip.classList.remove('on'));
311
+ });
312
+ }
313
+
314
+ function renderProjects() {
315
+ const body = STATE.projects.filter((p) => inScope(p.repo)).map((p) => {
316
+ const acc = pct(p.passed, p.answers);
317
+ return `<tr><td><strong>${esc(base(p.repo))}</strong><div class="slug">${esc(p.repo ?? 'recorded before projects were tracked')}</div></td>`
318
+ + `<td><span class="pill">${esc(p.level)}</span></td>`
319
+ + `<td class="num">${p.answers}</td>`
320
+ + `<td style="min-width:120px"><div class="meter${acc < 70 ? ' warn' : ''}"><i style="width:${acc}%"></i></div>
321
+ <div class="slug">${acc}% right</div></td>`
322
+ + `<td class="num slug">${esc(ago(p.last_active))}</td></tr>`;
323
+ });
324
+ rows($('projects'), [{t:'Project'},{t:'Level'},{t:'Answers',n:1},{t:'Accuracy'},{t:'Last',n:1}], body);
325
+ }
326
+
327
+ function renderDomains() {
328
+ // Recomputed from the scoped concept list rather than read off the server's
329
+ // totals, so picking a project moves this card too. `unseen` stays
330
+ // catalogue-wide on purpose: the seed graph does not belong to a repo.
331
+ const touchedBy = new Map();
332
+ for (const c of STATE.concepts) {
333
+ if (!inScope(c.repo)) continue;
334
+ const d = touchedBy.get(c.domain) ?? { domain: c.domain, mastered: 0, learning: 0, unseen: 0 };
335
+ d[c.mastered ? 'mastered' : 'learning'] += 1;
336
+ touchedBy.set(c.domain, d);
337
+ }
338
+ for (const d of STATE.domains) {
339
+ const row = touchedBy.get(d.domain);
340
+ if (row) row.unseen = d.unseen;
341
+ }
342
+ const body = [...touchedBy.values()]
343
+ .sort((a, b) => b.mastered + b.learning - (a.mastered + a.learning))
344
+ .map((d) => {
345
+ const touched = d.mastered + d.learning;
346
+ return `<tr><td><strong>${esc(d.domain)}</strong></td>`
347
+ + `<td style="min-width:120px"><div class="meter"><i style="width:${pct(d.mastered, touched)}%"></i></div>
348
+ <div class="slug">${d.mastered} of ${touched} touched</div></td>`
349
+ + `<td class="num slug">${d.unseen} not seen</td></tr>`;
350
+ });
351
+ rows($('domains'), [{t:'Domain'},{t:'Mastered'},{t:'Catalogue',n:1}], body);
352
+ }
353
+
354
+ function renderRecent() {
355
+ const body = STATE.recent.filter((r) => inScope(r.repo)).slice(0, 20).map((r) =>
356
+ `<tr><td class="ctx">${esc(r.context)}<div class="slug">${esc(r.slug)} · ${esc(base(r.repo))}</div></td>`
357
+ + `<td class="num slug">${esc(ago(r.ts))}</td></tr>`);
358
+ rows($('recent'), [{t:'What you did'},{t:'When',n:1}], body);
359
+ }
360
+
361
+ function renderSkipped() {
362
+ const body = STATE.skipped.filter((s) => inScope(s.repo)).map((s) =>
363
+ `<tr><td><strong>${esc(s.name)}</strong><div class="slug">${esc(s.slug)}</div></td>`
364
+ + `<td><span class="pill">${s.outcome === 'declined' ? 'declined' : 'blanked'}</span></td>`
365
+ + `<td class="ctx">${esc(s.question ?? '')}</td>`
366
+ + `<td class="num slug">${esc(ago(s.ts))}</td></tr>`);
367
+ rows($('skipped'), [{t:'Concept'},{t:'How'},{t:'The question'},{t:'When',n:1}], body);
368
+ }
369
+
370
+ function renderConcepts() {
371
+ const body = STATE.concepts.filter((c) => inScope(c.repo)).map((c) =>
372
+ `<tr><td><strong>${esc(c.name)}</strong><div class="slug">${esc(c.slug)} · ${esc(c.domain)} · T${c.tier}</div></td>`
373
+ + `<td style="min-width:110px"><div class="meter${c.score < 0.5 ? ' warn' : ''}"><i style="width:${Math.round(c.score * 100)}%"></i></div>
374
+ <div class="slug">${Math.round(c.score * 100)}%</div></td>`
375
+ + `<td class="num">${c.attempts}</td>`
376
+ + `<td>${c.mastered ? '<span class="pill ok">mastered</span>' : c.due ? '<span class="pill due">due</span>' : '<span class="pill">learning</span>'}</td>`
377
+ + `<td class="ctx">${esc(c.context ?? '')}</td></tr>`);
378
+ rows($('concepts'), [{t:'Concept'},{t:'Score'},{t:'Asked',n:1},{t:'State'},{t:'From your code'}], body);
379
+ }
380
+
381
+ function renderAll() {
382
+ const scoped = STATE.concepts.filter((c) => inScope(c.repo));
383
+ const answers = STATE.projects.filter((p) => inScope(p.repo)).reduce((a, p) => a + p.answers, 0);
384
+ const where = FILTER === '*' ? 'across all projects' : 'in ' + base(FILTER === '~' ? null : FILTER);
385
+ const n = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
386
+ $('sub').textContent =
387
+ `${n(scoped.length, 'concept')} asked · ${n(answers, 'answer')} · ${scoped.filter((c) => c.mastered).length} mastered — ${where}`;
388
+ renderTiles(); renderChart(); renderProjects(); renderDomains();
389
+ renderRecent(); renderSkipped(); renderConcepts();
390
+ }
391
+
392
+ fetch('/api/state').then((r) => r.json()).then((s) => {
393
+ STATE = s;
394
+ const sel = $('proj');
395
+ for (const p of s.projects) {
396
+ const o = document.createElement('option');
397
+ o.value = p.repo ?? '~';
398
+ o.textContent = base(p.repo);
399
+ sel.appendChild(o);
400
+ }
401
+ sel.addEventListener('change', () => { FILTER = sel.value; renderAll(); });
402
+ renderAll();
403
+ addEventListener('resize', renderChart);
404
+ }).catch((e) => { $('sub').textContent = 'Could not read the database: ' + e.message; });
405
+ </script>
406
+ </body>
407
+ </html>