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.
- package/README.md +69 -0
- package/dist/ask.js +58 -0
- package/dist/ask.js.map +1 -0
- package/dist/assets/dashboard.html +407 -0
- package/dist/assets/tokens.css +135 -0
- package/dist/assets/tutor-skill.md +345 -0
- package/dist/cli.js +223 -0
- package/dist/cli.js.map +1 -0
- package/dist/concurrency.js +41 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/config.js +158 -0
- package/dist/config.js.map +1 -0
- package/dist/dashboard.js +214 -0
- package/dist/dashboard.js.map +1 -0
- package/dist/db.js +26 -0
- package/dist/db.js.map +1 -0
- package/dist/hooks/checkpoint-quiz.js +152 -0
- package/dist/hooks/checkpoint-quiz.js.map +1 -0
- package/dist/hooks/lib.js +177 -0
- package/dist/hooks/lib.js.map +1 -0
- package/dist/hooks/pre-tool-gate.js +53 -0
- package/dist/hooks/pre-tool-gate.js.map +1 -0
- package/dist/hooks/session-start.js +128 -0
- package/dist/hooks/session-start.js.map +1 -0
- package/dist/hooks/stop-quiz-check.js +147 -0
- package/dist/hooks/stop-quiz-check.js.map +1 -0
- package/dist/install.js +313 -0
- package/dist/install.js.map +1 -0
- package/dist/mcq.js +34 -0
- package/dist/mcq.js.map +1 -0
- package/dist/migrate.js +45 -0
- package/dist/migrate.js.map +1 -0
- package/dist/migrations/001_init.sql +71 -0
- package/dist/migrations/002_stop_markers.sql +14 -0
- package/dist/migrations/003_gate_repo.sql +5 -0
- package/dist/migrations/004_attempt_outcome.sql +15 -0
- package/dist/migrations/005_session_concept_origin.sql +24 -0
- package/dist/migrations/006_attempt_format.sql +31 -0
- package/dist/migrations/007_checkpoints.sql +23 -0
- package/dist/migrations/008_difficulty_levels.sql +57 -0
- package/dist/paths.js +25 -0
- package/dist/paths.js.map +1 -0
- package/dist/plugin/.claude-plugin/marketplace.json +14 -0
- package/dist/plugin/.claude-plugin/plugin.json +17 -0
- package/dist/plugin/.mcp.json +11 -0
- package/dist/plugin/agents/tutor.md +63 -0
- package/dist/plugin/cli/eklavya-gate +67 -0
- package/dist/plugin/hooks/hooks.json +57 -0
- package/dist/plugin/hooks/run.mjs +169 -0
- package/dist/plugin/scripts/install-git-hook.sh +81 -0
- package/dist/plugin/skills/gate/SKILL.md +17 -0
- package/dist/plugin/skills/learn/SKILL.md +28 -0
- package/dist/plugin/skills/level/SKILL.md +53 -0
- package/dist/plugin/skills/mode/SKILL.md +56 -0
- package/dist/plugin/skills/progress/SKILL.md +72 -0
- package/dist/plugin/skills/quiz/SKILL.md +31 -0
- package/dist/plugin/skills/setup/SKILL.md +49 -0
- package/dist/plugin/skills/tutor/SKILL.md +345 -0
- package/dist/seed/git.json +49 -0
- package/dist/seed/node-backend.json +43 -0
- package/dist/seed/react.json +46 -0
- package/dist/seed/web-auth.json +78 -0
- package/dist/seed.js +112 -0
- package/dist/seed.js.map +1 -0
- package/dist/server.js +52 -0
- package/dist/server.js.map +1 -0
- package/dist/session.js +24 -0
- package/dist/session.js.map +1 -0
- package/dist/slug.js +75 -0
- package/dist/slug.js.map +1 -0
- package/dist/srs.js +206 -0
- package/dist/srs.js.map +1 -0
- package/dist/store.js +450 -0
- package/dist/store.js.map +1 -0
- package/dist/tools/config_tools.js +119 -0
- package/dist/tools/config_tools.js.map +1 -0
- package/dist/tools/get_concept_graph.js +104 -0
- package/dist/tools/get_concept_graph.js.map +1 -0
- package/dist/tools/get_gate_status.js +20 -0
- package/dist/tools/get_gate_status.js.map +1 -0
- package/dist/tools/get_learner_profile.js +142 -0
- package/dist/tools/get_learner_profile.js.map +1 -0
- package/dist/tools/get_session_quiz_plan.js +393 -0
- package/dist/tools/get_session_quiz_plan.js.map +1 -0
- package/dist/tools/index.js +45 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/log_session_concepts.js +103 -0
- package/dist/tools/log_session_concepts.js.map +1 -0
- package/dist/tools/record_attempt.js +158 -0
- package/dist/tools/record_attempt.js.map +1 -0
- package/dist/tools/types.js +4 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/tools/upsert_concepts.js +89 -0
- package/dist/tools/upsert_concepts.js.map +1 -0
- 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
|
package/dist/ask.js.map
ADDED
|
@@ -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) => ({'&':'&','<':'<','>':'>','"':'"'}[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>
|