eklavya-mcp 1.0.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 +42 -0
- package/bin/eklavya-mcp.sh +25 -0
- package/dist/assets/tutor-skill.md +106 -0
- package/dist/cli.js +145 -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 +116 -0
- package/dist/config.js.map +1 -0
- package/dist/db.js +26 -0
- package/dist/db.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/paths.js +25 -0
- package/dist/paths.js.map +1 -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 +39 -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 +130 -0
- package/dist/srs.js.map +1 -0
- package/dist/store.js +122 -0
- package/dist/store.js.map +1 -0
- package/dist/tools/config_tools.js +73 -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 +78 -0
- package/dist/tools/get_learner_profile.js.map +1 -0
- package/dist/tools/get_session_quiz_plan.js +107 -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 +83 -0
- package/dist/tools/log_session_concepts.js.map +1 -0
- package/dist/tools/record_attempt.js +69 -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 +68 -0
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# eklavya-mcp
|
|
2
|
+
|
|
3
|
+
The MCP server behind [Eklavya](../README.md): a local knowledge graph, SM-2 spaced repetition, and commit-gate state for agent-assisted learning.
|
|
4
|
+
|
|
5
|
+
Runs standalone, so any MCP client can use it — this is what makes Eklavya editor-agnostic.
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{
|
|
9
|
+
"mcpServers": {
|
|
10
|
+
"eklavya": { "command": "npx", "args": ["-y", "eklavya-mcp"] }
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
State lives in `~/.eklavya/knowledge.db` (SQLite, WAL). Override with `EKLAVYA_HOME` or `EKLAVYA_DB`.
|
|
16
|
+
|
|
17
|
+
## Tools
|
|
18
|
+
|
|
19
|
+
| Tool | Purpose |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `get_learner_profile` | what the developer already knows; call before teaching |
|
|
22
|
+
| `log_session_concepts` | record what the current task exercises, with code context |
|
|
23
|
+
| `get_session_quiz_plan` | what to ask, and at which difficulty tier |
|
|
24
|
+
| `record_attempt` | grade 0–5, update mastery and the review schedule |
|
|
25
|
+
| `get_gate_status` | whether this session's quiz gate has passed |
|
|
26
|
+
| `upsert_concepts` | grow the graph, with slug normalization and dedupe |
|
|
27
|
+
| `get_concept_graph` | a domain in prerequisite order |
|
|
28
|
+
| `get_config` / `set_config` | global and per-repo settings |
|
|
29
|
+
|
|
30
|
+
`session_id` is optional on every tool — the server resolves the current session itself.
|
|
31
|
+
|
|
32
|
+
## CLI
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
eklavya doctor # check the install
|
|
36
|
+
eklavya config get
|
|
37
|
+
eklavya config set mode enforced [--repo]
|
|
38
|
+
eklavya export-rules --out .cursor/rules/eklavya.md
|
|
39
|
+
eklavya db-path
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
MIT.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Launches the Eklavya MCP server, from wherever it actually exists.
|
|
3
|
+
#
|
|
4
|
+
# Two very different situations have to work:
|
|
5
|
+
#
|
|
6
|
+
# 1. Development from a checkout — use the local build, so edits take effect
|
|
7
|
+
# without republishing anything.
|
|
8
|
+
# 2. Installed as a plugin from git — there is no local build, because dist/
|
|
9
|
+
# and node_modules/ are not tracked. Fall back to the published package,
|
|
10
|
+
# which npx fetches and whose native better-sqlite3 binding npm builds.
|
|
11
|
+
#
|
|
12
|
+
# Keep PINNED_VERSION in step with .claude-plugin/plugin.json and
|
|
13
|
+
# mcp/package.json. A test asserts all three match.
|
|
14
|
+
|
|
15
|
+
PINNED_VERSION="1.0.0"
|
|
16
|
+
|
|
17
|
+
MCP_DIR=$(cd "$(dirname "$0")/.." && pwd)
|
|
18
|
+
LOCAL_SERVER="$MCP_DIR/dist/server.js"
|
|
19
|
+
|
|
20
|
+
if [ -f "$LOCAL_SERVER" ] && [ -d "$MCP_DIR/node_modules/better-sqlite3" ]; then
|
|
21
|
+
exec node "$LOCAL_SERVER" "$@"
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
# stdout is the MCP transport, so npx's own chatter must not land there.
|
|
25
|
+
exec npx --yes "eklavya-mcp@$PINNED_VERSION" "$@" 2>/dev/null
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tutor
|
|
3
|
+
description: Teach the developer the concepts behind the code being written, using the Eklavya knowledge graph. Use while implementing any non-trivial task (to log the concepts it touches), and whenever quizzing, grading, or explaining a concept the developer is learning.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Eklavya tutor
|
|
7
|
+
|
|
8
|
+
You are teaching a real person, not generating a lesson. They are watching you build something; your job is to make sure they could have built it themselves next time.
|
|
9
|
+
|
|
10
|
+
Everything you learn about them persists in the Eklavya MCP server. Use it — the whole point is never asking the same question twice.
|
|
11
|
+
|
|
12
|
+
## Session id
|
|
13
|
+
|
|
14
|
+
Every tool takes an optional `session_id`. **Omit it.** The server resolves the current session on its own. Only pass one if the user or a hook explicitly gave you an id to use.
|
|
15
|
+
|
|
16
|
+
## While you work
|
|
17
|
+
|
|
18
|
+
Call `log_session_concepts` as you implement, batched, once or twice per task. Log **3–8 concepts the work genuinely exercises** — not everything that appears in the file.
|
|
19
|
+
|
|
20
|
+
Each concept needs a `context`: one line naming the actual decision, in the actual file.
|
|
21
|
+
|
|
22
|
+
- Good: `"set httpOnly on the refresh cookie in auth.ts:42"`
|
|
23
|
+
- Useless: `"used cookies"`
|
|
24
|
+
|
|
25
|
+
If a concept has no slug yet, just log it — unknown slugs are created. Use kebab-case and check the response: `matched` tells you the concept already existed under a canonical slug, and you should use that slug from then on.
|
|
26
|
+
|
|
27
|
+
Never interrupt the implementation to teach. Logging is silent. Teaching happens after the work is done, or when the developer asks.
|
|
28
|
+
|
|
29
|
+
## When Eklavya asks you to quiz
|
|
30
|
+
|
|
31
|
+
At the end of a task you may be told, mid-turn, to quiz the developer before
|
|
32
|
+
finishing — with a list of concepts and the code context behind them. That is
|
|
33
|
+
Eklavya's Stop hook, not the user. Treat it as a prompt to teach, not as an
|
|
34
|
+
error, and do not mention hooks or exit codes. Just run the quiz below, then
|
|
35
|
+
finish your turn normally.
|
|
36
|
+
|
|
37
|
+
It fires at most once per batch of work, so this is your one chance to teach
|
|
38
|
+
what the task covered. If the developer declines, record the skip and let it go.
|
|
39
|
+
|
|
40
|
+
## Before you teach or quiz
|
|
41
|
+
|
|
42
|
+
Call `get_learner_profile` first. Always. It tells you:
|
|
43
|
+
|
|
44
|
+
- `mode` — how hard to push (see below)
|
|
45
|
+
- what they already know, so you don't insult them by asking
|
|
46
|
+
- `weak` — where they are actually struggling
|
|
47
|
+
- `due_for_review` — what spaced repetition says is ready to resurface
|
|
48
|
+
- `suggested_tier` — roughly where to pitch
|
|
49
|
+
|
|
50
|
+
Then call `get_session_quiz_plan`. It returns the concepts worth asking about *and the tier to ask each one at*. Respect `tier_to_ask` — that number is how a concept gets harder as they get better.
|
|
51
|
+
|
|
52
|
+
If `questions_needed` is 0, say nothing and move on. `reason: "cooldown"` means they were quizzed recently; `reason: "mode_off"` means Eklavya is dormant.
|
|
53
|
+
|
|
54
|
+
## Asking
|
|
55
|
+
|
|
56
|
+
**One question at a time.** Ask, wait for the answer, grade it, give a tight explanation, then the next. Never post a numbered list of five questions — that is a test, not teaching.
|
|
57
|
+
|
|
58
|
+
**Ground every question in the diff you just wrote.** Reference the file, the line, the decision. The concept is abstract; the code in front of them is not.
|
|
59
|
+
|
|
60
|
+
- Grounded: *"I set `httpOnly: true` on the refresh cookie in `auth.ts` but left the access token in memory. What attack is that split defending against, and what does it cost us?"*
|
|
61
|
+
- Textbook, avoid: *"What is an httpOnly cookie?"*
|
|
62
|
+
|
|
63
|
+
**Match the tier.** This is the difference between a quiz that teaches and one that annoys:
|
|
64
|
+
|
|
65
|
+
| Tier | Asks for | Shape |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| 1 | recall | "What does this flag do?" |
|
|
68
|
+
| 2 | mechanism | "Walk me through what the browser does with this." |
|
|
69
|
+
| 3 | judgement | "Why this choice here rather than the obvious alternative?" |
|
|
70
|
+
| 4 | failure modes | "What breaks this, and how would you notice in production?" |
|
|
71
|
+
| 5 | design | "When is this the wrong architecture entirely, and what replaces it?" |
|
|
72
|
+
|
|
73
|
+
Definitions are tier 1 **only**. If you find yourself asking "what is X" at tier 3, you have written a bad question.
|
|
74
|
+
|
|
75
|
+
## Grading
|
|
76
|
+
|
|
77
|
+
Call `record_attempt` for **every** answer, including skips. Grade honestly on SM-2's 0–5:
|
|
78
|
+
|
|
79
|
+
| Grade | Means |
|
|
80
|
+
|---|---|
|
|
81
|
+
| 0 | skipped, or no attempt |
|
|
82
|
+
| 1 | wrong, and the misconception is load-bearing |
|
|
83
|
+
| 2 | wrong, but the shape of the idea is there |
|
|
84
|
+
| 3 | correct, but hesitant or incomplete — got there slowly |
|
|
85
|
+
| 4 | correct and clean |
|
|
86
|
+
| 5 | correct, and explained *why*, or caught a nuance you didn't ask for |
|
|
87
|
+
|
|
88
|
+
Before you pick a number, state to yourself what in their answer justifies it. A gate built on inflated grades teaches nothing and the developer knows it. Being generous here is not kindness — it is the one failure mode that makes this whole tool pointless.
|
|
89
|
+
|
|
90
|
+
Then give feedback in **four sentences or fewer**. Correct the specific thing they got wrong; don't re-teach the whole topic.
|
|
91
|
+
|
|
92
|
+
If they get it wrong, do not immediately give the answer. Ask one narrower question that isolates the gap. If they miss that too, then explain.
|
|
93
|
+
|
|
94
|
+
## Growing the graph
|
|
95
|
+
|
|
96
|
+
When work touches a concept with no slug and no near match, call `upsert_concepts` with a sensible `domain`, a `tier`, and at least one `prerequisite_of` edge to something that already exists. An isolated node teaches nothing about what to learn next.
|
|
97
|
+
|
|
98
|
+
## Mode
|
|
99
|
+
|
|
100
|
+
- **ambient** — offer. If they say skip, record it (grade 0, feedback `"skipped"`) and drop it immediately. Do not ask twice. Do not guilt them.
|
|
101
|
+
- **enforced** — the quiz is required before committing. Say so plainly and once: the gate exists, here is what it needs, let's get through it. Supportive, not punitive. Never imply they are being punished.
|
|
102
|
+
- **off** — do nothing at all.
|
|
103
|
+
|
|
104
|
+
## The bar
|
|
105
|
+
|
|
106
|
+
The developer should finish a quiz thinking *"I understand what we just built"* — not *"I passed."* If a question only proves they read the diff, it was the wrong question.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The `eklavya` CLI: the parts of Eklavya that make sense outside a Claude Code
|
|
4
|
+
* session. The commit gate has its own POSIX script (`cli/eklavya-gate`) because
|
|
5
|
+
* a git hook must not pay Node's startup cost.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { openDb } from './db.js';
|
|
11
|
+
import { dbPath, eklavyaHome } from './paths.js';
|
|
12
|
+
import { loadConfig, writeConfigFile, REPO_CONFIG_FILE, DEFAULT_CONFIG } from './config.js';
|
|
13
|
+
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const USAGE = `eklavya — local learning state for agent-assisted development
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
eklavya export-rules [--out <file>] Write the tutor pedagogy as a Cursor rules file
|
|
18
|
+
eklavya config get Show the effective configuration
|
|
19
|
+
eklavya config set <key> <value> Change a setting (add --repo to scope it to this repo)
|
|
20
|
+
eklavya doctor Check that everything is wired up
|
|
21
|
+
eklavya db-path Print the database location
|
|
22
|
+
|
|
23
|
+
Config keys: mode, pass_threshold, max_questions_per_task,
|
|
24
|
+
min_minutes_between_quizzes, max_new_concepts_per_session, quiet
|
|
25
|
+
`;
|
|
26
|
+
function fail(message) {
|
|
27
|
+
process.stderr.write(`${message}\n`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
function exportRules(args) {
|
|
31
|
+
const skillPath = path.join(moduleDir, 'assets', 'tutor-skill.md');
|
|
32
|
+
if (!fs.existsSync(skillPath)) {
|
|
33
|
+
fail('The bundled tutor skill is missing. Run `npm run build` in the mcp/ directory.');
|
|
34
|
+
}
|
|
35
|
+
const skill = fs.readFileSync(skillPath, 'utf8').replace(/^---\n[\s\S]*?\n---\n/, '');
|
|
36
|
+
const rules = `---
|
|
37
|
+
description: Eklavya tutor — teach the concepts behind the code being written
|
|
38
|
+
alwaysApply: true
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
<!-- Generated by \`eklavya export-rules\`. Do not edit by hand; edit the plugin's
|
|
42
|
+
skills/tutor/SKILL.md and regenerate, so Claude Code and Cursor never drift. -->
|
|
43
|
+
|
|
44
|
+
The Eklavya MCP server is available in this editor. Its tools are the source of
|
|
45
|
+
truth for what this developer already knows.
|
|
46
|
+
|
|
47
|
+
${skill.trim()}
|
|
48
|
+
`;
|
|
49
|
+
const outIndex = args.indexOf('--out');
|
|
50
|
+
if (outIndex >= 0) {
|
|
51
|
+
const out = args[outIndex + 1];
|
|
52
|
+
if (!out)
|
|
53
|
+
fail('--out needs a file path.');
|
|
54
|
+
fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
|
|
55
|
+
fs.writeFileSync(out, rules, 'utf8');
|
|
56
|
+
process.stdout.write(`Wrote ${out}\n`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
process.stdout.write(rules);
|
|
60
|
+
}
|
|
61
|
+
function configCommand(args) {
|
|
62
|
+
const [action, key, value] = args;
|
|
63
|
+
const scopeRepo = args.includes('--repo');
|
|
64
|
+
const resolved = loadConfig();
|
|
65
|
+
if (!action || action === 'get') {
|
|
66
|
+
process.stdout.write(`${JSON.stringify(resolved.config, null, 2)}\n`);
|
|
67
|
+
process.stdout.write(`\nglobal: ${resolved.globalPath}\n`);
|
|
68
|
+
process.stdout.write(`repo: ${resolved.repoPath ?? '(none)'}\n`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (action !== 'set')
|
|
72
|
+
fail(`Unknown config action "${action}".`);
|
|
73
|
+
if (!key || value === undefined)
|
|
74
|
+
fail('Usage: eklavya config set <key> <value>');
|
|
75
|
+
if (!(key in DEFAULT_CONFIG)) {
|
|
76
|
+
fail(`Unknown setting "${key}". Known: ${Object.keys(DEFAULT_CONFIG).join(', ')}`);
|
|
77
|
+
}
|
|
78
|
+
let parsed = value;
|
|
79
|
+
if (value === 'true' || value === 'false')
|
|
80
|
+
parsed = value === 'true';
|
|
81
|
+
else if (value !== '' && !Number.isNaN(Number(value)))
|
|
82
|
+
parsed = Number(value);
|
|
83
|
+
let target;
|
|
84
|
+
if (scopeRepo) {
|
|
85
|
+
if (!resolved.repoRoot)
|
|
86
|
+
fail('Not inside a git repository, so there is nowhere to write .eklavya.json.');
|
|
87
|
+
target = resolved.repoPath ?? path.join(resolved.repoRoot, REPO_CONFIG_FILE);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
target = resolved.globalPath;
|
|
91
|
+
}
|
|
92
|
+
writeConfigFile(target, { [key]: parsed });
|
|
93
|
+
process.stdout.write(`${key} = ${JSON.stringify(parsed)} -> ${target}\n`);
|
|
94
|
+
}
|
|
95
|
+
function doctor() {
|
|
96
|
+
const file = dbPath();
|
|
97
|
+
const lines = [];
|
|
98
|
+
let ok = true;
|
|
99
|
+
lines.push(`home: ${eklavyaHome()}`);
|
|
100
|
+
lines.push(`database: ${file}${fs.existsSync(file) ? '' : ' (not created yet)'}`);
|
|
101
|
+
try {
|
|
102
|
+
const db = openDb(file);
|
|
103
|
+
const concepts = db.prepare('SELECT count(*) n FROM concepts').get().n;
|
|
104
|
+
const attempts = db.prepare('SELECT count(*) n FROM attempts').get().n;
|
|
105
|
+
const known = db.prepare('SELECT count(*) n FROM mastery WHERE score >= 0.7 AND reps >= 2').get().n;
|
|
106
|
+
lines.push(`concepts: ${concepts}`);
|
|
107
|
+
lines.push(`attempts: ${attempts}`);
|
|
108
|
+
lines.push(`mastered: ${known}`);
|
|
109
|
+
lines.push(`journal: ${String(db.pragma('journal_mode', { simple: true }))}`);
|
|
110
|
+
db.close();
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
ok = false;
|
|
114
|
+
lines.push(`database error: ${err instanceof Error ? err.message : String(err)}`);
|
|
115
|
+
}
|
|
116
|
+
const resolved = loadConfig();
|
|
117
|
+
lines.push(`mode: ${resolved.config.mode}${resolved.repoPath ? ' (from this repo)' : ''}`);
|
|
118
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
119
|
+
if (!ok)
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
function main() {
|
|
123
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
124
|
+
switch (command) {
|
|
125
|
+
case 'export-rules':
|
|
126
|
+
return exportRules(rest);
|
|
127
|
+
case 'config':
|
|
128
|
+
return configCommand(rest);
|
|
129
|
+
case 'doctor':
|
|
130
|
+
return doctor();
|
|
131
|
+
case 'db-path':
|
|
132
|
+
process.stdout.write(`${dbPath()}\n`);
|
|
133
|
+
return;
|
|
134
|
+
case undefined:
|
|
135
|
+
case '-h':
|
|
136
|
+
case '--help':
|
|
137
|
+
process.stdout.write(USAGE);
|
|
138
|
+
return;
|
|
139
|
+
default:
|
|
140
|
+
process.stderr.write(USAGE);
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
main();
|
|
145
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Several Claude Code sessions and the git pre-commit hook can hit the same
|
|
3
|
+
* SQLite file at once (PRD §8 "Concurrency"). WAL plus `busy_timeout` handles
|
|
4
|
+
* almost all of it; what remains is the case where two writers upgrade to a
|
|
5
|
+
* write transaction at the same moment and SQLite returns SQLITE_BUSY
|
|
6
|
+
* immediately rather than waiting.
|
|
7
|
+
*
|
|
8
|
+
* A busy transaction has already been rolled back, so retrying it is safe —
|
|
9
|
+
* that is what makes this wrapper correct for writes and not just for reads.
|
|
10
|
+
*/
|
|
11
|
+
const BUSY_CODES = new Set(['SQLITE_BUSY', 'SQLITE_BUSY_SNAPSHOT', 'SQLITE_LOCKED']);
|
|
12
|
+
export const MAX_BUSY_ATTEMPTS = 5;
|
|
13
|
+
function isBusy(err) {
|
|
14
|
+
if (typeof err !== 'object' || err === null)
|
|
15
|
+
return false;
|
|
16
|
+
const code = err.code;
|
|
17
|
+
return typeof code === 'string' && BUSY_CODES.has(code);
|
|
18
|
+
}
|
|
19
|
+
/** Blocking backoff — better-sqlite3 is synchronous, so there is nothing to await. */
|
|
20
|
+
function sleepSync(ms) {
|
|
21
|
+
const until = Date.now() + ms;
|
|
22
|
+
while (Date.now() < until) {
|
|
23
|
+
// busy-wait; only ever a few milliseconds, and only under real contention
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function retryOnBusy(fn, attempts = MAX_BUSY_ATTEMPTS) {
|
|
27
|
+
let lastError;
|
|
28
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
29
|
+
try {
|
|
30
|
+
return fn();
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
if (!isBusy(err))
|
|
34
|
+
throw err;
|
|
35
|
+
lastError = err;
|
|
36
|
+
sleepSync(10 * (attempt + 1));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
throw lastError;
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=concurrency.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"concurrency.js","sourceRoot":"","sources":["../src/concurrency.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC,CAAC,aAAa,EAAE,sBAAsB,EAAE,eAAe,CAAC,CAAC,CAAC;AAErF,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,CAAC;AAEnC,SAAS,MAAM,CAAC,GAAY;IAC1B,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IAC1D,MAAM,IAAI,GAAI,GAAyB,CAAC,IAAI,CAAC;IAC7C,OAAO,OAAO,IAAI,KAAK,QAAQ,IAAI,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;AAC1D,CAAC;AAED,sFAAsF;AACtF,SAAS,SAAS,CAAC,EAAU;IAC3B,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC;IAC9B,OAAO,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK,EAAE,CAAC;QAC1B,0EAA0E;IAC5E,CAAC;AACH,CAAC;AAED,MAAM,UAAU,WAAW,CAAI,EAAW,EAAE,QAAQ,GAAG,iBAAiB;IACtE,IAAI,SAAkB,CAAC;IAEvB,KAAK,IAAI,OAAO,GAAG,CAAC,EAAE,OAAO,GAAG,QAAQ,EAAE,OAAO,IAAI,CAAC,EAAE,CAAC;QACvD,IAAI,CAAC;YACH,OAAO,EAAE,EAAE,CAAC;QACd,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC;gBAAE,MAAM,GAAG,CAAC;YAC5B,SAAS,GAAG,GAAG,CAAC;YAChB,SAAS,CAAC,EAAE,GAAG,CAAC,OAAO,GAAG,CAAC,CAAC,CAAC,CAAC;QAChC,CAAC;IACH,CAAC;IAED,MAAM,SAAS,CAAC;AAClB,CAAC"}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { globalConfigPath } from './paths.js';
|
|
5
|
+
export const DEFAULT_CONFIG = {
|
|
6
|
+
mode: 'ambient',
|
|
7
|
+
pass_threshold: 0.7,
|
|
8
|
+
max_questions_per_task: 4,
|
|
9
|
+
min_minutes_between_quizzes: 20,
|
|
10
|
+
domains_enabled: ['*'],
|
|
11
|
+
quiet: false,
|
|
12
|
+
max_new_concepts_per_session: 8,
|
|
13
|
+
};
|
|
14
|
+
export const REPO_CONFIG_FILE = '.eklavya.json';
|
|
15
|
+
function realPath(p) {
|
|
16
|
+
try {
|
|
17
|
+
return fs.realpathSync(p);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return p;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function readJson(file) {
|
|
24
|
+
try {
|
|
25
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
26
|
+
const parsed = JSON.parse(text);
|
|
27
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
28
|
+
? parsed
|
|
29
|
+
: null;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// A missing or malformed config must never take a session down; defaults win.
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Walks up from `cwd` looking for a repo-level config, stopping at the git root
|
|
38
|
+
* or the filesystem root. Returns the directory holding it, plus the git root if
|
|
39
|
+
* one was passed on the way (Phase 3 stamps that onto gate rows).
|
|
40
|
+
*/
|
|
41
|
+
export function findRepoConfig(cwd = process.cwd()) {
|
|
42
|
+
// Resolve symlinks: `git rev-parse --show-toplevel` reports the real path, and
|
|
43
|
+
// the git pre-commit hook matches gate rows on it. On macOS /tmp is a symlink
|
|
44
|
+
// to /private/tmp, so without this the two sides silently never match.
|
|
45
|
+
let dir = realPath(path.resolve(cwd));
|
|
46
|
+
let repoPath = null;
|
|
47
|
+
let repoRoot = null;
|
|
48
|
+
for (;;) {
|
|
49
|
+
if (!repoPath && fs.existsSync(path.join(dir, REPO_CONFIG_FILE))) {
|
|
50
|
+
repoPath = path.join(dir, REPO_CONFIG_FILE);
|
|
51
|
+
}
|
|
52
|
+
if (!repoRoot && fs.existsSync(path.join(dir, '.git'))) {
|
|
53
|
+
repoRoot = dir;
|
|
54
|
+
// The git root is the boundary: a config above it belongs to another project.
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
const parent = path.dirname(dir);
|
|
58
|
+
if (parent === dir || dir === os.homedir())
|
|
59
|
+
break;
|
|
60
|
+
dir = parent;
|
|
61
|
+
}
|
|
62
|
+
return { repoPath, repoRoot };
|
|
63
|
+
}
|
|
64
|
+
function coerce(raw, base) {
|
|
65
|
+
const out = { ...base };
|
|
66
|
+
if (raw.mode === 'ambient' || raw.mode === 'enforced' || raw.mode === 'off')
|
|
67
|
+
out.mode = raw.mode;
|
|
68
|
+
if (typeof raw.pass_threshold === 'number' && raw.pass_threshold >= 0 && raw.pass_threshold <= 1) {
|
|
69
|
+
out.pass_threshold = raw.pass_threshold;
|
|
70
|
+
}
|
|
71
|
+
if (typeof raw.max_questions_per_task === 'number' && raw.max_questions_per_task > 0) {
|
|
72
|
+
out.max_questions_per_task = Math.floor(raw.max_questions_per_task);
|
|
73
|
+
}
|
|
74
|
+
if (typeof raw.min_minutes_between_quizzes === 'number' && raw.min_minutes_between_quizzes >= 0) {
|
|
75
|
+
out.min_minutes_between_quizzes = Math.floor(raw.min_minutes_between_quizzes);
|
|
76
|
+
}
|
|
77
|
+
if (Array.isArray(raw.domains_enabled) && raw.domains_enabled.every((d) => typeof d === 'string')) {
|
|
78
|
+
out.domains_enabled = raw.domains_enabled;
|
|
79
|
+
}
|
|
80
|
+
if (typeof raw.quiet === 'boolean')
|
|
81
|
+
out.quiet = raw.quiet;
|
|
82
|
+
if (typeof raw.max_new_concepts_per_session === 'number' &&
|
|
83
|
+
raw.max_new_concepts_per_session >= 0) {
|
|
84
|
+
out.max_new_concepts_per_session = Math.floor(raw.max_new_concepts_per_session);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/** Global config merged with the repo's, repo winning (PRD §11). */
|
|
89
|
+
export function loadConfig(cwd = process.cwd()) {
|
|
90
|
+
const globalPath = globalConfigPath();
|
|
91
|
+
const { repoPath, repoRoot } = findRepoConfig(cwd);
|
|
92
|
+
const globalRaw = readJson(globalPath) ?? {};
|
|
93
|
+
const repoRaw = repoPath ? (readJson(repoPath) ?? {}) : {};
|
|
94
|
+
const raw = { ...globalRaw, ...repoRaw };
|
|
95
|
+
return {
|
|
96
|
+
config: coerce(raw, DEFAULT_CONFIG),
|
|
97
|
+
raw,
|
|
98
|
+
globalPath,
|
|
99
|
+
repoPath,
|
|
100
|
+
repoRoot,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** Writes via temp file + rename: the git hook may be reading mid-write. */
|
|
104
|
+
export function writeConfigFile(file, patch) {
|
|
105
|
+
const existing = readJson(file) ?? {};
|
|
106
|
+
const merged = { ...existing, ...patch };
|
|
107
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
108
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
109
|
+
fs.writeFileSync(tmp, `${JSON.stringify(merged, null, 2)}\n`, 'utf8');
|
|
110
|
+
fs.renameSync(tmp, file);
|
|
111
|
+
return merged;
|
|
112
|
+
}
|
|
113
|
+
export function isDomainEnabled(config, domain) {
|
|
114
|
+
return config.domains_enabled.includes('*') || config.domains_enabled.includes(domain);
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
|
package/dist/db.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import Database from 'better-sqlite3';
|
|
4
|
+
import { dbPath } from './paths.js';
|
|
5
|
+
import { runMigrations } from './migrate.js';
|
|
6
|
+
import { seedIfNeeded } from './seed.js';
|
|
7
|
+
/**
|
|
8
|
+
* Opens (creating if needed) the knowledge DB with the pragmas the design
|
|
9
|
+
* depends on: WAL so several Claude Code sessions and the git hook can share
|
|
10
|
+
* the file (PRD §8 "Concurrency"), and foreign keys so the graph stays honest.
|
|
11
|
+
*/
|
|
12
|
+
export function openDb(file = dbPath()) {
|
|
13
|
+
if (file !== ':memory:') {
|
|
14
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
const db = new Database(file);
|
|
17
|
+
db.pragma('journal_mode = WAL');
|
|
18
|
+
db.pragma('foreign_keys = ON');
|
|
19
|
+
// Concurrent writers (two sessions, or a session plus the pre-commit hook)
|
|
20
|
+
// should wait briefly rather than fail with SQLITE_BUSY.
|
|
21
|
+
db.pragma('busy_timeout = 5000');
|
|
22
|
+
runMigrations(db);
|
|
23
|
+
seedIfNeeded(db);
|
|
24
|
+
return db;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=db.js.map
|
package/dist/db.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"db.js","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AACtC,OAAO,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AACpC,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAIzC;;;;GAIG;AACH,MAAM,UAAU,MAAM,CAAC,OAAe,MAAM,EAAE;IAC5C,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;QACxB,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACxD,CAAC;IAED,MAAM,EAAE,GAAG,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC;IAC9B,EAAE,CAAC,MAAM,CAAC,oBAAoB,CAAC,CAAC;IAChC,EAAE,CAAC,MAAM,CAAC,mBAAmB,CAAC,CAAC;IAC/B,2EAA2E;IAC3E,yDAAyD;IACzD,EAAE,CAAC,MAAM,CAAC,qBAAqB,CAAC,CAAC;IAEjC,aAAa,CAAC,EAAE,CAAC,CAAC;IAClB,YAAY,CAAC,EAAE,CAAC,CAAC;IAEjB,OAAO,EAAE,CAAC;AACZ,CAAC"}
|
package/dist/migrate.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { migrationsDir } from './paths.js';
|
|
4
|
+
const VERSION_KEY = 'schema_version';
|
|
5
|
+
function readVersion(db) {
|
|
6
|
+
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get(VERSION_KEY);
|
|
7
|
+
return row ? Number(row.value) : 0;
|
|
8
|
+
}
|
|
9
|
+
function writeVersion(db, version) {
|
|
10
|
+
db.prepare(`INSERT INTO meta (key, value) VALUES (?, ?)
|
|
11
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(VERSION_KEY, String(version));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Applies numbered SQL migrations in order, recording progress in `meta`.
|
|
15
|
+
* Idempotent: re-running applies nothing. Returns the filenames applied.
|
|
16
|
+
*/
|
|
17
|
+
export function runMigrations(db, dir = migrationsDir()) {
|
|
18
|
+
// Bootstrap `meta` itself so the version read below has somewhere to look.
|
|
19
|
+
db.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
|
|
20
|
+
const current = readVersion(db);
|
|
21
|
+
const files = fs
|
|
22
|
+
.readdirSync(dir)
|
|
23
|
+
.filter((f) => f.endsWith('.sql'))
|
|
24
|
+
.sort();
|
|
25
|
+
const applied = [];
|
|
26
|
+
for (const file of files) {
|
|
27
|
+
const n = Number(file.slice(0, 3));
|
|
28
|
+
if (!Number.isFinite(n) || n === 0) {
|
|
29
|
+
throw new Error(`Migration filename must start with a number: ${file}`);
|
|
30
|
+
}
|
|
31
|
+
if (n <= current)
|
|
32
|
+
continue;
|
|
33
|
+
const sql = fs.readFileSync(path.join(dir, file), 'utf8');
|
|
34
|
+
db.transaction(() => {
|
|
35
|
+
db.exec(sql);
|
|
36
|
+
writeVersion(db, n);
|
|
37
|
+
})();
|
|
38
|
+
applied.push(file);
|
|
39
|
+
}
|
|
40
|
+
return applied;
|
|
41
|
+
}
|
|
42
|
+
export function schemaVersion(db) {
|
|
43
|
+
return readVersion(db);
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=migrate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"migrate.js","sourceRoot":"","sources":["../src/migrate.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE3C,MAAM,WAAW,GAAG,gBAAgB,CAAC;AAErC,SAAS,WAAW,CAAC,EAAY;IAC/B,MAAM,GAAG,GAAG,EAAE,CAAC,OAAO,CAAC,sCAAsC,CAAC,CAAC,GAAG,CAAC,WAAW,CAEjE,CAAC;IACd,OAAO,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACrC,CAAC;AAED,SAAS,YAAY,CAAC,EAAY,EAAE,OAAe;IACjD,EAAE,CAAC,OAAO,CACR;2DACuD,CACxD,CAAC,GAAG,CAAC,WAAW,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC;AACtC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,aAAa,CAAC,EAAY,EAAE,GAAG,GAAG,aAAa,EAAE;IAC/D,2EAA2E;IAC3E,EAAE,CAAC,IAAI,CAAC,6EAA6E,CAAC,CAAC;IAEvF,MAAM,OAAO,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC;IAChC,MAAM,KAAK,GAAG,EAAE;SACb,WAAW,CAAC,GAAG,CAAC;SAChB,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;SACjC,IAAI,EAAE,CAAC;IAEV,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;QACnC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;YACnC,MAAM,IAAI,KAAK,CAAC,gDAAgD,IAAI,EAAE,CAAC,CAAC;QAC1E,CAAC;QACD,IAAI,CAAC,IAAI,OAAO;YAAE,SAAS;QAE3B,MAAM,GAAG,GAAG,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,CAAC;QAC1D,EAAE,CAAC,WAAW,CAAC,GAAG,EAAE;YAClB,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YACb,YAAY,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;QACtB,CAAC,CAAC,EAAE,CAAC;QACL,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACrB,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,EAAY;IACxC,OAAO,WAAW,CAAC,EAAE,CAAC,CAAC;AACzB,CAAC"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
-- Eklavya schema v1 (PRD §7).
|
|
2
|
+
-- Every statement is IF NOT EXISTS so the runner is safe to re-apply.
|
|
3
|
+
|
|
4
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
5
|
+
key TEXT PRIMARY KEY,
|
|
6
|
+
value TEXT NOT NULL
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
CREATE TABLE IF NOT EXISTS concepts (
|
|
10
|
+
id INTEGER PRIMARY KEY,
|
|
11
|
+
slug TEXT NOT NULL UNIQUE, -- 'jwt-structure', 'httponly-cookies'
|
|
12
|
+
name TEXT NOT NULL,
|
|
13
|
+
domain TEXT NOT NULL, -- 'web-auth', 'react', 'node-backend', ...
|
|
14
|
+
description TEXT,
|
|
15
|
+
tier INTEGER NOT NULL DEFAULT 1, -- 1 fundamentals … 5 expert
|
|
16
|
+
source TEXT NOT NULL DEFAULT 'seed' -- 'seed' | 'llm'
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
20
|
+
from_concept INTEGER NOT NULL REFERENCES concepts(id),
|
|
21
|
+
to_concept INTEGER NOT NULL REFERENCES concepts(id),
|
|
22
|
+
relation TEXT NOT NULL CHECK (relation IN ('prerequisite_of','related_to','part_of')),
|
|
23
|
+
PRIMARY KEY (from_concept, to_concept, relation)
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS mastery (
|
|
27
|
+
concept_id INTEGER PRIMARY KEY REFERENCES concepts(id),
|
|
28
|
+
score REAL NOT NULL DEFAULT 0, -- 0..1
|
|
29
|
+
ease REAL NOT NULL DEFAULT 2.5, -- SM-2 ease factor
|
|
30
|
+
interval_d REAL NOT NULL DEFAULT 0, -- current review interval, days
|
|
31
|
+
reps INTEGER NOT NULL DEFAULT 0,
|
|
32
|
+
last_seen TEXT, -- ISO timestamp
|
|
33
|
+
next_review TEXT -- ISO timestamp
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
CREATE TABLE IF NOT EXISTS attempts (
|
|
37
|
+
id INTEGER PRIMARY KEY,
|
|
38
|
+
concept_id INTEGER NOT NULL REFERENCES concepts(id),
|
|
39
|
+
session_id TEXT,
|
|
40
|
+
question TEXT NOT NULL,
|
|
41
|
+
answer TEXT,
|
|
42
|
+
grade INTEGER NOT NULL, -- 0..5 (SM-2 quality)
|
|
43
|
+
difficulty INTEGER NOT NULL, -- tier the question was asked at
|
|
44
|
+
feedback TEXT, -- tutor's explanation
|
|
45
|
+
ts TEXT NOT NULL DEFAULT (datetime('now'))
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE TABLE IF NOT EXISTS session_concepts (
|
|
49
|
+
session_id TEXT NOT NULL,
|
|
50
|
+
concept_id INTEGER NOT NULL REFERENCES concepts(id),
|
|
51
|
+
context TEXT, -- e.g. 'set httpOnly on refresh cookie in auth.ts'
|
|
52
|
+
ts TEXT NOT NULL DEFAULT (datetime('now')),
|
|
53
|
+
PRIMARY KEY (session_id, concept_id)
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
CREATE TABLE IF NOT EXISTS gates (
|
|
57
|
+
session_id TEXT PRIMARY KEY,
|
|
58
|
+
mode TEXT NOT NULL, -- 'ambient' | 'enforced'
|
|
59
|
+
required INTEGER NOT NULL DEFAULT 0,
|
|
60
|
+
answered INTEGER NOT NULL DEFAULT 0,
|
|
61
|
+
passed INTEGER NOT NULL DEFAULT 0, -- boolean
|
|
62
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
-- Read paths that must stay fast: the SessionStart hook and the PreToolUse gate
|
|
66
|
+
-- both run on the critical path of a user action (PRD §9).
|
|
67
|
+
CREATE INDEX IF NOT EXISTS idx_concepts_domain ON concepts(domain);
|
|
68
|
+
CREATE INDEX IF NOT EXISTS idx_session_concepts_sid ON session_concepts(session_id);
|
|
69
|
+
CREATE INDEX IF NOT EXISTS idx_attempts_concept_ts ON attempts(concept_id, ts DESC);
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_attempts_session ON attempts(session_id);
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_mastery_next_review ON mastery(next_review);
|