langcoach 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +92 -2
- package/dist/index.js +869 -0
- package/package.json +50 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Oktay Bilge
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,93 @@
|
|
|
1
|
-
#
|
|
1
|
+
# LangCoach
|
|
2
|
+
|
|
3
|
+
**English** | [Türkçe](https://github.com/oktaybilge1/langcoach/blob/main/README.tr.md)
|
|
4
|
+
|
|
5
|
+
[](https://github.com/oktaybilge1/langcoach/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/langcoach)
|
|
7
|
+
[](https://github.com/oktaybilge1/langcoach/blob/main/LICENSE)
|
|
8
|
+
|
|
9
|
+
**Your language coach inside Claude Code: weekly plan, daily tasks, spaced-repetition reviews, streak and XP, for any language pair.**
|
|
10
|
+
|
|
11
|
+
<!-- TODO: hero GIF of a real session goes here: docs/media/demo.gif (not recorded yet) -->
|
|
12
|
+
|
|
13
|
+
AI language lessons are great, but most forget you the moment the chat ends. LangCoach is an MCP server that gives Claude a memory for your learning: Claude writes the lessons in your target language, LangCoach stores your plan, reviews and progress on your machine.
|
|
14
|
+
|
|
15
|
+
- No API key, no account, no server.
|
|
16
|
+
- Works with any native + target language.
|
|
17
|
+
- Your data stays on your machine.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
2
20
|
|
|
3
|
-
|
|
21
|
+
Requires [Node.js](https://nodejs.org) 20+ and [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (signed in).
|
|
22
|
+
|
|
23
|
+
macOS / Linux / WSL:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
claude mcp add --scope user langcoach -- npx -y langcoach
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Windows (native PowerShell/CMD, not WSL):
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
claude mcp add --scope user langcoach -- cmd /c npx -y langcoach
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**No API key needed.** Your own Claude subscription writes the lessons.
|
|
36
|
+
|
|
37
|
+
Or paste this repo link into Claude Code and say: *Install LangCoach for me*
|
|
38
|
+
|
|
39
|
+
Details, verification, update, uninstall and troubleshooting: [docs/INSTALL.md](https://github.com/oktaybilge1/langcoach/blob/main/docs/INSTALL.md). The `langcoach` package is not on npm yet; until it is published, see "Run from source" in that guide.
|
|
40
|
+
|
|
41
|
+
## Use it
|
|
42
|
+
|
|
43
|
+
In Claude Code, run the `tutor` prompt. MCP prompts show up as a slash command, like `/mcp__langcoach__tutor`. Or just ask Claude:
|
|
44
|
+
|
|
45
|
+
> Let's start my language lesson with langcoach
|
|
46
|
+
|
|
47
|
+
The first time, Claude interviews you (native language, target language, CEFR level, goal, minutes per day, days per week) and saves your profile. After that it builds this week's plan, tells you what is on for today, teaches the lesson, quizzes you on due cards and records everything.
|
|
48
|
+
|
|
49
|
+
## Features
|
|
50
|
+
|
|
51
|
+
- **Personal profile:** native language, target language, level (A1-C2), goal, daily minutes and study days per week.
|
|
52
|
+
- **Weekly plan:** study days spread across the ISO week, sized to your daily minutes, with a theme picked from your goal.
|
|
53
|
+
- **Daily tasks:** Tiny Lesson-style situational lessons, Slang Hang-style informal lessons, reviews, and a quiz on the last study day.
|
|
54
|
+
- **Spaced repetition:** every lesson item becomes a review card scheduled with [FSRS](https://github.com/open-spaced-repetition/ts-fsrs); duplicates are skipped.
|
|
55
|
+
- **Progress:** streak, total XP and weekly goal, always read from stored data, never invented.
|
|
56
|
+
- **Safe data:** atomic writes, a backup before every import, and corrupt files are preserved instead of overwritten.
|
|
57
|
+
- **No network calls:** LangCoach makes no network calls; your data stays on your machine.
|
|
58
|
+
|
|
59
|
+
### Tools
|
|
60
|
+
|
|
61
|
+
Claude uses these tools for you: `set_profile`, `get_weekly_plan`, `get_today`, `save_lesson`, `complete_task`, `get_due_reviews`, `submit_review`, `get_progress`, `export_data`, `import_data`.
|
|
62
|
+
|
|
63
|
+
## Your data
|
|
64
|
+
|
|
65
|
+
Everything lives in one JSON file: `~/.langcoach/data.json`. Set the `LANGCOACH_HOME` environment variable to use a different folder.
|
|
66
|
+
|
|
67
|
+
- **Export / import:** ask Claude to run `export_data` for a backup or to move to another machine, and `import_data` to restore it.
|
|
68
|
+
- Writes are atomic, so a crash cannot leave a half-written file.
|
|
69
|
+
- Parallel tool calls and several Claude Code sessions take turns through a short-lived `data.json.lock` file, so no update is lost.
|
|
70
|
+
- If the data file is corrupt, it is preserved as `data.corrupt-<timestamp>.json` and you get a clear error.
|
|
71
|
+
- Before an import replaces your data, the current file is saved as `data.backup-<timestamp>.json`.
|
|
72
|
+
|
|
73
|
+
## Roadmap
|
|
74
|
+
|
|
75
|
+
Planned, not built yet:
|
|
76
|
+
|
|
77
|
+
- [ ] Web app (PWA) that runs in the browser with your own Gemini API key, plus a live demo on GitHub Pages
|
|
78
|
+
- [ ] Word Cam-style camera vocabulary (web app)
|
|
79
|
+
- [ ] More UI languages via community PRs
|
|
80
|
+
|
|
81
|
+
## Contributing
|
|
82
|
+
|
|
83
|
+
Contributions are welcome. See [CONTRIBUTING.md](https://github.com/oktaybilge1/langcoach/blob/main/CONTRIBUTING.md).
|
|
84
|
+
|
|
85
|
+
## Credits and disclaimer
|
|
86
|
+
|
|
87
|
+
LangCoach is inspired by Google Labs' [Little Language Lessons](https://labs.google/lll/en) (Tiny Lesson, Slang Hang, Word Cam) and adds what one-off exercises lack: a weekly plan and progress tracking.
|
|
88
|
+
|
|
89
|
+
LangCoach is an independent project and is not affiliated with, endorsed by, or sponsored by Google.
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
[MIT](https://github.com/oktaybilge1/langcoach/blob/main/LICENSE) (c) 2026 Oktay Bilge
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
|
|
6
|
+
// ../core/src/types.ts
|
|
7
|
+
function emptyState() {
|
|
8
|
+
return { version: 1, profile: null, plans: {}, lessons: [], cards: [], log: [] };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ../core/src/dates.ts
|
|
12
|
+
var pad = (n, w = 2) => String(n).padStart(w, "0");
|
|
13
|
+
function toLocalDate(d) {
|
|
14
|
+
return `${pad(d.getFullYear(), 4)}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
15
|
+
}
|
|
16
|
+
function isoWeek(d) {
|
|
17
|
+
const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
|
18
|
+
const dayNum = t.getUTCDay() || 7;
|
|
19
|
+
t.setUTCDate(t.getUTCDate() + 4 - dayNum);
|
|
20
|
+
const year = t.getUTCFullYear();
|
|
21
|
+
const yearStart = Date.UTC(year, 0, 1);
|
|
22
|
+
const week = Math.ceil(((t.getTime() - yearStart) / 864e5 + 1) / 7);
|
|
23
|
+
return `${pad(year, 4)}-W${pad(week)}`;
|
|
24
|
+
}
|
|
25
|
+
function weekDates(week) {
|
|
26
|
+
const m = /^(\d{4})-W(\d{2})$/.exec(week);
|
|
27
|
+
if (!m) throw new Error(`Invalid ISO week: ${week}`);
|
|
28
|
+
const year = Number(m[1]);
|
|
29
|
+
const w = Number(m[2]);
|
|
30
|
+
const jan4 = new Date(Date.UTC(year, 0, 4));
|
|
31
|
+
const jan4Day = jan4.getUTCDay() || 7;
|
|
32
|
+
const mondayMs = jan4.getTime() - (jan4Day - 1) * 864e5 + (w - 1) * 7 * 864e5;
|
|
33
|
+
return Array.from({ length: 7 }, (_, i) => {
|
|
34
|
+
const x = new Date(mondayMs + i * 864e5);
|
|
35
|
+
return `${pad(x.getUTCFullYear(), 4)}-${pad(x.getUTCMonth() + 1)}-${pad(x.getUTCDate())}`;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ../core/src/errors.ts
|
|
40
|
+
var InvalidDataError = class extends Error {
|
|
41
|
+
constructor(message) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = "InvalidDataError";
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
var NoProfileError = class extends Error {
|
|
47
|
+
constructor() {
|
|
48
|
+
super("No learner profile yet. Call set_profile first.");
|
|
49
|
+
this.name = "NoProfileError";
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
var ThemeRequiredError = class extends Error {
|
|
53
|
+
constructor() {
|
|
54
|
+
super("A theme is needed to create this week's plan. Provide a theme and try again.");
|
|
55
|
+
this.name = "ThemeRequiredError";
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
var NotFoundError = class extends Error {
|
|
59
|
+
constructor(what, id) {
|
|
60
|
+
super(`${what} not found: ${id}`);
|
|
61
|
+
this.name = "NotFoundError";
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
var CorruptDataError = class extends Error {
|
|
65
|
+
preservedAt;
|
|
66
|
+
constructor(message, preservedAt) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.name = "CorruptDataError";
|
|
69
|
+
this.preservedAt = preservedAt;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// ../core/src/schema.ts
|
|
74
|
+
import { z } from "zod";
|
|
75
|
+
var cefr = z.enum(["A1", "A2", "B1", "B2", "C1", "C2"]);
|
|
76
|
+
var taskType = z.enum(["tiny_lesson", "slang_hang", "review", "quiz"]);
|
|
77
|
+
var obj = z.looseObject;
|
|
78
|
+
var profile = obj({
|
|
79
|
+
nativeLanguage: z.string(),
|
|
80
|
+
targetLanguage: z.string(),
|
|
81
|
+
level: cefr,
|
|
82
|
+
goal: z.string(),
|
|
83
|
+
minutesPerDay: z.number().int().min(5).max(180),
|
|
84
|
+
daysPerWeek: z.number().int().min(1).max(7),
|
|
85
|
+
createdAt: z.string()
|
|
86
|
+
});
|
|
87
|
+
var lessonItem = obj({
|
|
88
|
+
term: z.string(),
|
|
89
|
+
translation: z.string(),
|
|
90
|
+
example: z.string(),
|
|
91
|
+
note: z.string().optional()
|
|
92
|
+
});
|
|
93
|
+
var lesson = obj({
|
|
94
|
+
id: z.string(),
|
|
95
|
+
kind: z.enum(["tiny_lesson", "slang_hang"]),
|
|
96
|
+
situation: z.string(),
|
|
97
|
+
items: z.array(lessonItem),
|
|
98
|
+
createdAt: z.string()
|
|
99
|
+
});
|
|
100
|
+
var srs = obj({
|
|
101
|
+
due: z.iso.datetime({ offset: true }),
|
|
102
|
+
state: z.number(),
|
|
103
|
+
reps: z.number(),
|
|
104
|
+
lapses: z.number()
|
|
105
|
+
});
|
|
106
|
+
var reviewCard = obj({
|
|
107
|
+
id: z.string(),
|
|
108
|
+
lessonId: z.string(),
|
|
109
|
+
term: z.string(),
|
|
110
|
+
translation: z.string(),
|
|
111
|
+
example: z.string(),
|
|
112
|
+
srs
|
|
113
|
+
});
|
|
114
|
+
var planTask = obj({
|
|
115
|
+
id: z.string(),
|
|
116
|
+
date: z.string(),
|
|
117
|
+
type: taskType,
|
|
118
|
+
minutes: z.number().int().min(1),
|
|
119
|
+
status: z.enum(["todo", "done"]),
|
|
120
|
+
completedAt: z.string().optional(),
|
|
121
|
+
lessonId: z.string().optional()
|
|
122
|
+
});
|
|
123
|
+
var weekPlan = obj({ week: z.string(), theme: z.string(), tasks: z.array(planTask) });
|
|
124
|
+
var logEntry = obj({
|
|
125
|
+
at: z.string(),
|
|
126
|
+
date: z.string(),
|
|
127
|
+
taskId: z.string(),
|
|
128
|
+
type: taskType,
|
|
129
|
+
xp: z.number().min(0)
|
|
130
|
+
});
|
|
131
|
+
var stateSchema = obj({
|
|
132
|
+
version: z.literal(1),
|
|
133
|
+
profile: profile.nullable(),
|
|
134
|
+
plans: z.record(z.string(), weekPlan),
|
|
135
|
+
lessons: z.array(lesson),
|
|
136
|
+
cards: z.array(reviewCard),
|
|
137
|
+
log: z.array(logEntry)
|
|
138
|
+
});
|
|
139
|
+
function parseState(raw) {
|
|
140
|
+
const result = stateSchema.safeParse(raw);
|
|
141
|
+
if (!result.success) {
|
|
142
|
+
const detail = result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
|
|
143
|
+
throw new InvalidDataError(`Invalid LangCoach data: ${detail}`);
|
|
144
|
+
}
|
|
145
|
+
return result.data;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ../core/src/store.ts
|
|
149
|
+
import { randomBytes } from "crypto";
|
|
150
|
+
import { copyFile, mkdir, open, readFile, readdir, rename, stat, unlink } from "fs/promises";
|
|
151
|
+
import { homedir } from "os";
|
|
152
|
+
import { basename, dirname, join, resolve } from "path";
|
|
153
|
+
var stamp = () => `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${randomBytes(3).toString("hex")}`;
|
|
154
|
+
var code = (e) => e?.code;
|
|
155
|
+
var isNotFound = (e) => code(e) === "ENOENT";
|
|
156
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
157
|
+
var Mutex = class {
|
|
158
|
+
tail = Promise.resolve();
|
|
159
|
+
run(task) {
|
|
160
|
+
const next = this.tail.then(task, task);
|
|
161
|
+
this.tail = next.catch(() => void 0);
|
|
162
|
+
return next;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
var fileMutexes = /* @__PURE__ */ new Map();
|
|
166
|
+
function mutexFor(path) {
|
|
167
|
+
const key = process.platform === "win32" || process.platform === "darwin" ? path.toLowerCase() : path;
|
|
168
|
+
let m = fileMutexes.get(key);
|
|
169
|
+
if (!m) {
|
|
170
|
+
m = new Mutex();
|
|
171
|
+
fileMutexes.set(key, m);
|
|
172
|
+
}
|
|
173
|
+
return m;
|
|
174
|
+
}
|
|
175
|
+
var JsonFileStore = class {
|
|
176
|
+
filePath;
|
|
177
|
+
lockPath;
|
|
178
|
+
lockTimeoutMs;
|
|
179
|
+
staleLockMs;
|
|
180
|
+
constructor(filePath, opts = {}) {
|
|
181
|
+
this.filePath = resolve(filePath);
|
|
182
|
+
this.lockPath = `${this.filePath}.lock`;
|
|
183
|
+
this.lockTimeoutMs = opts.lockTimeoutMs ?? 5e3;
|
|
184
|
+
this.staleLockMs = opts.staleLockMs ?? 1e4;
|
|
185
|
+
}
|
|
186
|
+
async load() {
|
|
187
|
+
let bytes;
|
|
188
|
+
try {
|
|
189
|
+
bytes = await readFile(this.filePath);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
if (isNotFound(e)) return emptyState();
|
|
192
|
+
throw e;
|
|
193
|
+
}
|
|
194
|
+
let raw;
|
|
195
|
+
try {
|
|
196
|
+
raw = JSON.parse(bytes.toString("utf8"));
|
|
197
|
+
} catch {
|
|
198
|
+
const preservedAt = await this.preserveCorrupt(bytes);
|
|
199
|
+
throw new CorruptDataError(
|
|
200
|
+
`Data file is not valid JSON. Original preserved at ${preservedAt}. Restore with import_data from a data.backup-*.json, or move data.json aside to start fresh.`,
|
|
201
|
+
preservedAt
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return parseState(raw);
|
|
205
|
+
}
|
|
206
|
+
async save(s) {
|
|
207
|
+
const dir = dirname(this.filePath);
|
|
208
|
+
await mkdir(dir, { recursive: true });
|
|
209
|
+
const tmp = join(dir, `${basename(this.filePath)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
210
|
+
try {
|
|
211
|
+
const fh = await open(tmp, "wx");
|
|
212
|
+
try {
|
|
213
|
+
await fh.writeFile(JSON.stringify(s, null, 2), "utf8");
|
|
214
|
+
await fh.sync();
|
|
215
|
+
} finally {
|
|
216
|
+
await fh.close();
|
|
217
|
+
}
|
|
218
|
+
await renameWithRetry(tmp, this.filePath);
|
|
219
|
+
} catch (e) {
|
|
220
|
+
await unlink(tmp).catch(() => void 0);
|
|
221
|
+
throw e;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async backup() {
|
|
225
|
+
const dest = join(dirname(this.filePath), `data.backup-${stamp()}.json`);
|
|
226
|
+
try {
|
|
227
|
+
await copyFile(this.filePath, dest);
|
|
228
|
+
} catch (e) {
|
|
229
|
+
if (isNotFound(e)) return null;
|
|
230
|
+
throw e;
|
|
231
|
+
}
|
|
232
|
+
return dest;
|
|
233
|
+
}
|
|
234
|
+
transaction(fn) {
|
|
235
|
+
return mutexFor(this.filePath).run(async () => {
|
|
236
|
+
const token = await this.acquireLock();
|
|
237
|
+
try {
|
|
238
|
+
const out = await fn(await this.load());
|
|
239
|
+
if (out.state !== void 0) await this.save(out.state);
|
|
240
|
+
return out.result;
|
|
241
|
+
} finally {
|
|
242
|
+
await this.releaseLock(token);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
replace(next) {
|
|
247
|
+
return mutexFor(this.filePath).run(async () => {
|
|
248
|
+
const token = await this.acquireLock();
|
|
249
|
+
try {
|
|
250
|
+
const backupPath = await this.backup();
|
|
251
|
+
await this.save(next);
|
|
252
|
+
return backupPath;
|
|
253
|
+
} finally {
|
|
254
|
+
await this.releaseLock(token);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
/** Copy corrupt bytes aside once: reuse an existing data.corrupt-*.json with identical bytes. */
|
|
259
|
+
async preserveCorrupt(bytes) {
|
|
260
|
+
const dir = dirname(this.filePath);
|
|
261
|
+
const names = await readdir(dir).catch(() => []);
|
|
262
|
+
for (const name of names) {
|
|
263
|
+
if (!/^data\.corrupt-.*\.json$/.test(name)) continue;
|
|
264
|
+
const p = join(dir, name);
|
|
265
|
+
const other = await readFile(p).catch(() => null);
|
|
266
|
+
if (other && other.equals(bytes)) return p;
|
|
267
|
+
}
|
|
268
|
+
const preservedAt = join(dir, `data.corrupt-${stamp()}.json`);
|
|
269
|
+
await copyFile(this.filePath, preservedAt);
|
|
270
|
+
return preservedAt;
|
|
271
|
+
}
|
|
272
|
+
async acquireLock() {
|
|
273
|
+
const dir = dirname(this.filePath);
|
|
274
|
+
await mkdir(dir, { recursive: true });
|
|
275
|
+
const token = randomBytes(8).toString("hex");
|
|
276
|
+
const deadline = Date.now() + this.lockTimeoutMs;
|
|
277
|
+
let delay = 20;
|
|
278
|
+
for (; ; ) {
|
|
279
|
+
let openCode;
|
|
280
|
+
try {
|
|
281
|
+
const fh = await open(this.lockPath, "wx");
|
|
282
|
+
try {
|
|
283
|
+
await fh.writeFile(JSON.stringify({ pid: process.pid, at: Date.now(), token }), "utf8");
|
|
284
|
+
} catch (e) {
|
|
285
|
+
await fh.close().catch(() => void 0);
|
|
286
|
+
await unlink(this.lockPath).catch(() => void 0);
|
|
287
|
+
throw e;
|
|
288
|
+
}
|
|
289
|
+
await fh.close();
|
|
290
|
+
return token;
|
|
291
|
+
} catch (e) {
|
|
292
|
+
openCode = code(e);
|
|
293
|
+
if (openCode !== "EEXIST" && openCode !== "EPERM" && openCode !== "EACCES") throw e;
|
|
294
|
+
}
|
|
295
|
+
const lock = await this.inspectLock();
|
|
296
|
+
if (lock === "removed" || lock === "missing" && openCode === "EEXIST") continue;
|
|
297
|
+
if (Date.now() >= deadline) {
|
|
298
|
+
if (lock === "missing") {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`LangCoach cannot create files in its data folder (${dir}): the folder is not writable. Check its permissions or set LANGCOACH_HOME to a writable folder.`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
if (lock === "stuck") {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`LangCoach could not remove an old lock file: ${this.lockPath}. Make sure no other LangCoach is running, delete that file and try again.`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
throw new Error("LangCoach data file is busy, try again.");
|
|
309
|
+
}
|
|
310
|
+
await sleep(Math.min(delay, Math.max(1, deadline - Date.now())));
|
|
311
|
+
delay = Math.min(delay * 2, 200);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Look at the existing lock file: 'missing' (no lock file), 'held' (fresh, or unreadable),
|
|
316
|
+
* 'removed' (it was stale and we deleted it) or 'stuck' (stale but could not be deleted).
|
|
317
|
+
*/
|
|
318
|
+
async inspectLock() {
|
|
319
|
+
let mtime;
|
|
320
|
+
try {
|
|
321
|
+
mtime = (await stat(this.lockPath)).mtimeMs;
|
|
322
|
+
} catch (e) {
|
|
323
|
+
return isNotFound(e) ? "missing" : "held";
|
|
324
|
+
}
|
|
325
|
+
let at = mtime;
|
|
326
|
+
try {
|
|
327
|
+
const parsed = JSON.parse(await readFile(this.lockPath, "utf8"));
|
|
328
|
+
if (typeof parsed.at === "number") at = Math.max(parsed.at, mtime);
|
|
329
|
+
} catch {
|
|
330
|
+
}
|
|
331
|
+
if (Date.now() - at <= this.staleLockMs) return "held";
|
|
332
|
+
try {
|
|
333
|
+
await unlink(this.lockPath);
|
|
334
|
+
return "removed";
|
|
335
|
+
} catch (e) {
|
|
336
|
+
return isNotFound(e) ? "removed" : "stuck";
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async releaseLock(token) {
|
|
340
|
+
try {
|
|
341
|
+
const parsed = JSON.parse(await readFile(this.lockPath, "utf8"));
|
|
342
|
+
if (parsed.token !== token) return;
|
|
343
|
+
} catch (e) {
|
|
344
|
+
if (isNotFound(e)) return;
|
|
345
|
+
}
|
|
346
|
+
await unlink(this.lockPath).catch(() => void 0);
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
async function renameWithRetry(from, to) {
|
|
350
|
+
const deadline = Date.now() + 1e3;
|
|
351
|
+
let delay = 10;
|
|
352
|
+
for (; ; ) {
|
|
353
|
+
try {
|
|
354
|
+
await rename(from, to);
|
|
355
|
+
return;
|
|
356
|
+
} catch (e) {
|
|
357
|
+
const c = code(e);
|
|
358
|
+
if (c !== "EPERM" && c !== "EACCES" && c !== "EBUSY" || Date.now() >= deadline) throw e;
|
|
359
|
+
await sleep(delay);
|
|
360
|
+
delay = Math.min(delay * 2, 100);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function defaultDataPath(env = process.env) {
|
|
365
|
+
const home = env.LANGCOACH_HOME;
|
|
366
|
+
return home ? join(home, "data.json") : join(homedir(), ".langcoach", "data.json");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ../core/src/srs.ts
|
|
370
|
+
import { createEmptyCard, fsrs } from "ts-fsrs";
|
|
371
|
+
var scheduler = fsrs();
|
|
372
|
+
function toCard(s) {
|
|
373
|
+
return {
|
|
374
|
+
...s,
|
|
375
|
+
due: new Date(s.due),
|
|
376
|
+
last_review: s.last_review === void 0 ? void 0 : new Date(s.last_review)
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
function fromCard(c) {
|
|
380
|
+
const { due, last_review, ...rest } = c;
|
|
381
|
+
const out = { ...rest, due: due.toISOString() };
|
|
382
|
+
if (last_review) out.last_review = last_review.toISOString();
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
function newSrs(now) {
|
|
386
|
+
return fromCard(createEmptyCard(now));
|
|
387
|
+
}
|
|
388
|
+
function reviewSrs(s, rating, now) {
|
|
389
|
+
if (!Number.isInteger(rating) || rating < 1 || rating > 4) {
|
|
390
|
+
throw new Error(`Invalid rating ${String(rating)}: must be an integer from 1 (Again) to 4 (Easy)`);
|
|
391
|
+
}
|
|
392
|
+
return fromCard(scheduler.next(toCard(s), now, rating).card);
|
|
393
|
+
}
|
|
394
|
+
function isDue(s, now) {
|
|
395
|
+
return new Date(s.due).getTime() <= now.getTime();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ../core/src/planner.ts
|
|
399
|
+
function buildWeekPlan(profile2, week, theme, newId, fromDate) {
|
|
400
|
+
const m = profile2.minutesPerDay;
|
|
401
|
+
const allDays = weekDates(week);
|
|
402
|
+
const start = fromDate === void 0 ? 0 : Math.max(0, allDays.indexOf(fromDate));
|
|
403
|
+
const days = allDays.slice(start);
|
|
404
|
+
const r = days.length;
|
|
405
|
+
const n = Math.min(profile2.daysPerWeek, r);
|
|
406
|
+
const tasks = [];
|
|
407
|
+
for (let k = 0; k < n; k++) {
|
|
408
|
+
const date = days[Math.floor(k * r / n)];
|
|
409
|
+
const main2 = k === n - 1 && n >= 2 ? "quiz" : k % 2 === 0 ? "tiny_lesson" : "slang_hang";
|
|
410
|
+
let mainMinutes = m;
|
|
411
|
+
if (k > 0) {
|
|
412
|
+
const reviewMinutes = Math.max(5, Math.round(m * 0.3));
|
|
413
|
+
mainMinutes = Math.max(5, m - reviewMinutes);
|
|
414
|
+
tasks.push({ id: newId(), date, type: "review", minutes: reviewMinutes, status: "todo" });
|
|
415
|
+
}
|
|
416
|
+
tasks.push({ id: newId(), date, type: main2, minutes: mainMinutes, status: "todo" });
|
|
417
|
+
}
|
|
418
|
+
return { week, theme, tasks };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ../core/src/progress.ts
|
|
422
|
+
var BASE_XP = {
|
|
423
|
+
tiny_lesson: 20,
|
|
424
|
+
slang_hang: 20,
|
|
425
|
+
quiz: 30,
|
|
426
|
+
review: 10
|
|
427
|
+
};
|
|
428
|
+
function xpFor(type, cardsReviewed = 0) {
|
|
429
|
+
return BASE_XP[type] + (type === "review" ? cardsReviewed : 0);
|
|
430
|
+
}
|
|
431
|
+
function prevDay(date) {
|
|
432
|
+
const [y, m, d] = date.split("-").map(Number);
|
|
433
|
+
const t = new Date(Date.UTC(y, m - 1, d - 1));
|
|
434
|
+
const p = (n, w = 2) => String(n).padStart(w, "0");
|
|
435
|
+
return `${p(t.getUTCFullYear(), 4)}-${p(t.getUTCMonth() + 1)}-${p(t.getUTCDate())}`;
|
|
436
|
+
}
|
|
437
|
+
function streak(log, today) {
|
|
438
|
+
const days = new Set(log.map((e) => e.date));
|
|
439
|
+
let cursor = days.has(today) ? today : prevDay(today);
|
|
440
|
+
let count = 0;
|
|
441
|
+
while (days.has(cursor)) {
|
|
442
|
+
count++;
|
|
443
|
+
cursor = prevDay(cursor);
|
|
444
|
+
}
|
|
445
|
+
return count;
|
|
446
|
+
}
|
|
447
|
+
function weeklyGoal(plan) {
|
|
448
|
+
if (!plan) return { done: 0, planned: 0 };
|
|
449
|
+
const byDate = /* @__PURE__ */ new Map();
|
|
450
|
+
for (const t of plan.tasks) {
|
|
451
|
+
byDate.set(t.date, (byDate.get(t.date) ?? true) && t.status === "done");
|
|
452
|
+
}
|
|
453
|
+
return { done: [...byDate.values()].filter(Boolean).length, planned: byDate.size };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ../core/src/coach.ts
|
|
457
|
+
import { randomUUID } from "crypto";
|
|
458
|
+
var LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"];
|
|
459
|
+
var KINDS = ["tiny_lesson", "slang_hang"];
|
|
460
|
+
var blank = (v) => typeof v !== "string" || v.trim() === "";
|
|
461
|
+
var dupKey = (term) => term.trim().toLocaleLowerCase();
|
|
462
|
+
function requireProfile(s) {
|
|
463
|
+
if (!s.profile) throw new NoProfileError();
|
|
464
|
+
return s.profile;
|
|
465
|
+
}
|
|
466
|
+
function intIn(v, min, max) {
|
|
467
|
+
return typeof v === "number" && Number.isInteger(v) && v >= min && v <= max;
|
|
468
|
+
}
|
|
469
|
+
var Coach = class {
|
|
470
|
+
constructor(store, clock = () => /* @__PURE__ */ new Date(), newId = () => randomUUID()) {
|
|
471
|
+
this.store = store;
|
|
472
|
+
this.clock = clock;
|
|
473
|
+
this.newId = newId;
|
|
474
|
+
}
|
|
475
|
+
store;
|
|
476
|
+
clock;
|
|
477
|
+
newId;
|
|
478
|
+
async setProfile(input) {
|
|
479
|
+
if (blank(input.nativeLanguage)) throw new InvalidDataError("nativeLanguage must be a non-empty string");
|
|
480
|
+
if (blank(input.targetLanguage)) throw new InvalidDataError("targetLanguage must be a non-empty string");
|
|
481
|
+
if (!LEVELS.includes(input.level)) {
|
|
482
|
+
throw new InvalidDataError(`level must be one of ${LEVELS.join(", ")}`);
|
|
483
|
+
}
|
|
484
|
+
if (typeof input.goal !== "string") throw new InvalidDataError("goal must be a string");
|
|
485
|
+
if (!intIn(input.minutesPerDay, 5, 180)) {
|
|
486
|
+
throw new InvalidDataError("minutesPerDay must be an integer from 5 to 180");
|
|
487
|
+
}
|
|
488
|
+
if (!intIn(input.daysPerWeek, 1, 7)) {
|
|
489
|
+
throw new InvalidDataError("daysPerWeek must be an integer from 1 to 7");
|
|
490
|
+
}
|
|
491
|
+
return this.store.transaction((s) => {
|
|
492
|
+
const profile2 = {
|
|
493
|
+
nativeLanguage: input.nativeLanguage,
|
|
494
|
+
targetLanguage: input.targetLanguage,
|
|
495
|
+
level: input.level,
|
|
496
|
+
goal: input.goal,
|
|
497
|
+
minutesPerDay: input.minutesPerDay,
|
|
498
|
+
daysPerWeek: input.daysPerWeek,
|
|
499
|
+
createdAt: s.profile?.createdAt ?? this.clock().toISOString()
|
|
500
|
+
};
|
|
501
|
+
s.profile = profile2;
|
|
502
|
+
return { state: s, result: profile2 };
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
async weekPlan(theme) {
|
|
506
|
+
if (theme !== void 0 && typeof theme !== "string") throw new InvalidDataError("theme must be a string");
|
|
507
|
+
return this.store.transaction((s) => {
|
|
508
|
+
const profile2 = requireProfile(s);
|
|
509
|
+
const now = this.clock();
|
|
510
|
+
const week = isoWeek(now);
|
|
511
|
+
const existing = s.plans[week];
|
|
512
|
+
if (existing) return { result: existing };
|
|
513
|
+
if (theme === void 0 || theme.trim() === "") throw new ThemeRequiredError();
|
|
514
|
+
const plan = buildWeekPlan(profile2, week, theme, this.newId, toLocalDate(now));
|
|
515
|
+
s.plans[week] = plan;
|
|
516
|
+
return { state: s, result: plan };
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
async today() {
|
|
520
|
+
const s = await this.store.load();
|
|
521
|
+
requireProfile(s);
|
|
522
|
+
const now = this.clock();
|
|
523
|
+
const date = toLocalDate(now);
|
|
524
|
+
const plan = s.plans[isoWeek(now)];
|
|
525
|
+
if (!plan) return { date, tasks: [], planExists: false };
|
|
526
|
+
return { date, tasks: plan.tasks.filter((t) => t.date === date), planExists: true };
|
|
527
|
+
}
|
|
528
|
+
async saveLesson(input) {
|
|
529
|
+
if (!KINDS.includes(input.kind)) throw new InvalidDataError(`kind must be one of ${KINDS.join(", ")}`);
|
|
530
|
+
if (blank(input.situation)) throw new InvalidDataError("situation must be a non-empty string");
|
|
531
|
+
if (!Array.isArray(input.items) || input.items.length < 1 || input.items.length > 50) {
|
|
532
|
+
throw new InvalidDataError("items must contain between 1 and 50 entries");
|
|
533
|
+
}
|
|
534
|
+
input.items.forEach((it, i) => {
|
|
535
|
+
if (!it || blank(it.term)) throw new InvalidDataError(`items[${i}].term must be a non-empty string`);
|
|
536
|
+
if (blank(it.translation)) throw new InvalidDataError(`items[${i}].translation must be a non-empty string`);
|
|
537
|
+
if (typeof it.example !== "string") throw new InvalidDataError(`items[${i}].example must be a string`);
|
|
538
|
+
if (it.note !== void 0 && typeof it.note !== "string") {
|
|
539
|
+
throw new InvalidDataError(`items[${i}].note must be a string`);
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
return this.store.transaction((s) => {
|
|
543
|
+
requireProfile(s);
|
|
544
|
+
const now = this.clock();
|
|
545
|
+
const lesson2 = {
|
|
546
|
+
id: this.newId(),
|
|
547
|
+
kind: input.kind,
|
|
548
|
+
situation: input.situation,
|
|
549
|
+
items: input.items.map((it) => {
|
|
550
|
+
const item = { term: it.term, translation: it.translation, example: it.example };
|
|
551
|
+
if (it.note !== void 0) item.note = it.note;
|
|
552
|
+
return item;
|
|
553
|
+
}),
|
|
554
|
+
createdAt: now.toISOString()
|
|
555
|
+
};
|
|
556
|
+
const seen = new Set(s.cards.map((c) => dupKey(c.term)));
|
|
557
|
+
const duplicates = [];
|
|
558
|
+
let newCards = 0;
|
|
559
|
+
for (const it of lesson2.items) {
|
|
560
|
+
const key = dupKey(it.term);
|
|
561
|
+
if (seen.has(key)) {
|
|
562
|
+
duplicates.push(it.term);
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
seen.add(key);
|
|
566
|
+
s.cards.push({
|
|
567
|
+
id: this.newId(),
|
|
568
|
+
lessonId: lesson2.id,
|
|
569
|
+
term: it.term,
|
|
570
|
+
translation: it.translation,
|
|
571
|
+
example: it.example,
|
|
572
|
+
srs: newSrs(now)
|
|
573
|
+
});
|
|
574
|
+
newCards++;
|
|
575
|
+
}
|
|
576
|
+
s.lessons.push(lesson2);
|
|
577
|
+
return { state: s, result: { lesson: lesson2, newCards, duplicates } };
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
async completeTask(taskId, opts = {}) {
|
|
581
|
+
const cardsReviewed = opts.cardsReviewed;
|
|
582
|
+
if (cardsReviewed !== void 0 && !intIn(cardsReviewed, 0, Number.MAX_SAFE_INTEGER)) {
|
|
583
|
+
throw new InvalidDataError("cardsReviewed must be an integer >= 0");
|
|
584
|
+
}
|
|
585
|
+
if (opts.lessonId !== void 0 && typeof opts.lessonId !== "string") {
|
|
586
|
+
throw new InvalidDataError("lessonId must be a string");
|
|
587
|
+
}
|
|
588
|
+
return this.store.transaction((s) => {
|
|
589
|
+
requireProfile(s);
|
|
590
|
+
let task;
|
|
591
|
+
for (const plan of Object.values(s.plans)) {
|
|
592
|
+
task = plan.tasks.find((t) => t.id === taskId);
|
|
593
|
+
if (task) break;
|
|
594
|
+
}
|
|
595
|
+
if (!task) throw new NotFoundError("Task", taskId);
|
|
596
|
+
if (task.status === "done") return { result: { task, xpAwarded: 0, alreadyDone: true } };
|
|
597
|
+
const now = this.clock();
|
|
598
|
+
const xp = xpFor(task.type, cardsReviewed);
|
|
599
|
+
task.status = "done";
|
|
600
|
+
task.completedAt = now.toISOString();
|
|
601
|
+
if (opts.lessonId !== void 0) task.lessonId = opts.lessonId;
|
|
602
|
+
s.log.push({ at: now.toISOString(), date: toLocalDate(now), taskId, type: task.type, xp });
|
|
603
|
+
return { state: s, result: { task, xpAwarded: xp, alreadyDone: false } };
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
async dueReviews(limit = 20) {
|
|
607
|
+
if (!intIn(limit, 1, Number.MAX_SAFE_INTEGER)) throw new InvalidDataError("limit must be a positive integer");
|
|
608
|
+
const s = await this.store.load();
|
|
609
|
+
requireProfile(s);
|
|
610
|
+
const now = this.clock();
|
|
611
|
+
return s.cards.filter((c) => isDue(c.srs, now)).sort((a, b) => new Date(a.srs.due).getTime() - new Date(b.srs.due).getTime()).slice(0, limit);
|
|
612
|
+
}
|
|
613
|
+
async submitReview(cardId, rating) {
|
|
614
|
+
return this.store.transaction((s) => {
|
|
615
|
+
requireProfile(s);
|
|
616
|
+
const card = s.cards.find((c) => c.id === cardId);
|
|
617
|
+
if (!card) throw new NotFoundError("Card", cardId);
|
|
618
|
+
card.srs = reviewSrs(card.srs, rating, this.clock());
|
|
619
|
+
return { state: s, result: card };
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
async progress() {
|
|
623
|
+
const s = await this.store.load();
|
|
624
|
+
const now = this.clock();
|
|
625
|
+
return {
|
|
626
|
+
profile: s.profile,
|
|
627
|
+
streak: streak(s.log, toLocalDate(now)),
|
|
628
|
+
totalXp: s.log.reduce((sum, e) => sum + e.xp, 0),
|
|
629
|
+
weekly: weeklyGoal(s.plans[isoWeek(now)]),
|
|
630
|
+
dueCount: s.cards.filter((c) => isDue(c.srs, now)).length
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
async exportData() {
|
|
634
|
+
return this.store.load();
|
|
635
|
+
}
|
|
636
|
+
async importData(raw) {
|
|
637
|
+
const parsed = parseState(raw);
|
|
638
|
+
await this.store.replace(parsed);
|
|
639
|
+
return { lessons: parsed.lessons.length, cards: parsed.cards.length };
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
// src/server.ts
|
|
644
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
645
|
+
import { z as z2 } from "zod";
|
|
646
|
+
|
|
647
|
+
// package.json
|
|
648
|
+
var package_default = {
|
|
649
|
+
name: "langcoach",
|
|
650
|
+
version: "0.1.0",
|
|
651
|
+
description: "Language-learning coach MCP server for Claude Code: Claude writes the lessons, LangCoach keeps your weekly plan, streak, XP and spaced-repetition (FSRS) reviews on your machine.",
|
|
652
|
+
keywords: [
|
|
653
|
+
"language-learning",
|
|
654
|
+
"spaced-repetition",
|
|
655
|
+
"fsrs",
|
|
656
|
+
"mcp",
|
|
657
|
+
"mcp-server",
|
|
658
|
+
"claude",
|
|
659
|
+
"claude-code",
|
|
660
|
+
"tutor",
|
|
661
|
+
"flashcards",
|
|
662
|
+
"vocabulary"
|
|
663
|
+
],
|
|
664
|
+
homepage: "https://github.com/oktaybilge1/langcoach#readme",
|
|
665
|
+
bugs: {
|
|
666
|
+
url: "https://github.com/oktaybilge1/langcoach/issues"
|
|
667
|
+
},
|
|
668
|
+
repository: {
|
|
669
|
+
type: "git",
|
|
670
|
+
url: "git+https://github.com/oktaybilge1/langcoach.git",
|
|
671
|
+
directory: "packages/mcp"
|
|
672
|
+
},
|
|
673
|
+
license: "MIT",
|
|
674
|
+
type: "module",
|
|
675
|
+
bin: {
|
|
676
|
+
langcoach: "dist/index.js"
|
|
677
|
+
},
|
|
678
|
+
files: [
|
|
679
|
+
"dist",
|
|
680
|
+
"README.md",
|
|
681
|
+
"LICENSE"
|
|
682
|
+
],
|
|
683
|
+
engines: {
|
|
684
|
+
node: ">=20"
|
|
685
|
+
},
|
|
686
|
+
scripts: {
|
|
687
|
+
build: "tsup",
|
|
688
|
+
prepack: "npm run build && node scripts/copy-docs.mjs"
|
|
689
|
+
},
|
|
690
|
+
dependencies: {
|
|
691
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
692
|
+
"ts-fsrs": "^5.4.2",
|
|
693
|
+
zod: "^4.6.7"
|
|
694
|
+
},
|
|
695
|
+
devDependencies: {
|
|
696
|
+
"@langcoach/core": "*",
|
|
697
|
+
tsup: "^9.0.0"
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
// src/prompt.ts
|
|
702
|
+
var TUTOR_PROMPT = `You are LangCoach, a friendly language tutor. You write the lessons yourself; the langcoach tools only store the learner's state.
|
|
703
|
+
|
|
704
|
+
Start every session by calling get_progress and get_today.
|
|
705
|
+
|
|
706
|
+
- No profile yet: interview the learner (native language, target language, CEFR level, goal, minutes per day, days per week), then call set_profile.
|
|
707
|
+
- Profile but no plan for this week: propose a theme based on the learner's goal and call get_weekly_plan with it.
|
|
708
|
+
- For each task, write the lesson in the target language with translations in the learner's native language. Save it with save_lesson BEFORE you teach it, so its items become review cards.
|
|
709
|
+
- For review tasks, call get_due_reviews and quiz the cards one at a time. Record each answer right away with submit_review (1 again, 2 hard, 3 good, 4 easy).
|
|
710
|
+
- Call complete_task only after the learner has actually finished the task. Pass the lessonId or cardsReviewed when relevant.
|
|
711
|
+
- Never invent progress, streaks or XP. Report only what the tools return.
|
|
712
|
+
- Keep it encouraging and short: small chunks, one question at a time.
|
|
713
|
+
`;
|
|
714
|
+
|
|
715
|
+
// src/server.ts
|
|
716
|
+
var ok = (value) => ({
|
|
717
|
+
content: [{ type: "text", text: JSON.stringify(value, null, 2) }]
|
|
718
|
+
});
|
|
719
|
+
var fail = (err) => ({
|
|
720
|
+
isError: true,
|
|
721
|
+
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }]
|
|
722
|
+
});
|
|
723
|
+
async function run(fn) {
|
|
724
|
+
try {
|
|
725
|
+
return ok(await fn());
|
|
726
|
+
} catch (err) {
|
|
727
|
+
return fail(err);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
var nonEmpty = z2.string().trim().min(1);
|
|
731
|
+
function createServer(coach) {
|
|
732
|
+
const server = new McpServer({ name: "langcoach", version: package_default.version });
|
|
733
|
+
server.registerTool(
|
|
734
|
+
"set_profile",
|
|
735
|
+
{
|
|
736
|
+
description: "Save or update the learner profile. Call this once at the very start (after interviewing the learner), or when their level, goal or schedule changes. Required before the other tools, except export_data, import_data and get_progress, which work without a profile. A changed schedule (minutes per day, days per week) applies from next week's plan; this week's existing plan is kept.",
|
|
737
|
+
inputSchema: {
|
|
738
|
+
nativeLanguage: nonEmpty,
|
|
739
|
+
targetLanguage: nonEmpty,
|
|
740
|
+
level: z2.enum(["A1", "A2", "B1", "B2", "C1", "C2"]),
|
|
741
|
+
goal: z2.string(),
|
|
742
|
+
minutesPerDay: z2.number().int().min(5).max(180),
|
|
743
|
+
daysPerWeek: z2.number().int().min(1).max(7)
|
|
744
|
+
}
|
|
745
|
+
},
|
|
746
|
+
(args) => run(() => coach.setProfile(args))
|
|
747
|
+
);
|
|
748
|
+
server.registerTool(
|
|
749
|
+
"get_weekly_plan",
|
|
750
|
+
{
|
|
751
|
+
description: "Get this week's study plan. If none exists yet, pass a theme (picked from the learner's goal) to create it; without a theme and without an existing plan it returns an error asking for one.",
|
|
752
|
+
inputSchema: { theme: z2.string().optional() }
|
|
753
|
+
},
|
|
754
|
+
({ theme }) => run(() => coach.weekPlan(theme))
|
|
755
|
+
);
|
|
756
|
+
server.registerTool(
|
|
757
|
+
"get_today",
|
|
758
|
+
{
|
|
759
|
+
description: "Call at the start of a study session: returns today's date and the tasks planned for today (with task ids and status)."
|
|
760
|
+
},
|
|
761
|
+
() => run(() => coach.today())
|
|
762
|
+
);
|
|
763
|
+
server.registerTool(
|
|
764
|
+
"save_lesson",
|
|
765
|
+
{
|
|
766
|
+
description: "Save a lesson you have written, BEFORE teaching it. Each item becomes a spaced-repetition review card (duplicates are skipped). Returns the lesson id to pass to complete_task.",
|
|
767
|
+
inputSchema: {
|
|
768
|
+
kind: z2.enum(["tiny_lesson", "slang_hang"]),
|
|
769
|
+
situation: nonEmpty,
|
|
770
|
+
items: z2.array(
|
|
771
|
+
z2.object({
|
|
772
|
+
term: nonEmpty,
|
|
773
|
+
translation: nonEmpty,
|
|
774
|
+
example: z2.string(),
|
|
775
|
+
note: z2.string().optional()
|
|
776
|
+
})
|
|
777
|
+
).min(1).max(50)
|
|
778
|
+
}
|
|
779
|
+
},
|
|
780
|
+
(args) => run(() => coach.saveLesson(args))
|
|
781
|
+
);
|
|
782
|
+
server.registerTool(
|
|
783
|
+
"complete_task",
|
|
784
|
+
{
|
|
785
|
+
description: "Mark a planned task as done and award XP. Call only after the learner actually finished the task. Pass lessonId for lesson tasks and cardsReviewed for review tasks.",
|
|
786
|
+
inputSchema: {
|
|
787
|
+
taskId: nonEmpty,
|
|
788
|
+
lessonId: z2.string().optional(),
|
|
789
|
+
cardsReviewed: z2.number().int().min(0).optional()
|
|
790
|
+
}
|
|
791
|
+
},
|
|
792
|
+
({ taskId, lessonId, cardsReviewed }) => run(() => {
|
|
793
|
+
const opts = {};
|
|
794
|
+
if (lessonId !== void 0) opts.lessonId = lessonId;
|
|
795
|
+
if (cardsReviewed !== void 0) opts.cardsReviewed = cardsReviewed;
|
|
796
|
+
return coach.completeTask(taskId, opts);
|
|
797
|
+
})
|
|
798
|
+
);
|
|
799
|
+
server.registerTool(
|
|
800
|
+
"get_due_reviews",
|
|
801
|
+
{
|
|
802
|
+
description: "Get review cards that are due now (oldest first). Use for review tasks, then quiz the learner one card at a time and record each answer with submit_review.",
|
|
803
|
+
inputSchema: { limit: z2.number().int().min(1).optional() }
|
|
804
|
+
},
|
|
805
|
+
({ limit }) => run(() => coach.dueReviews(limit))
|
|
806
|
+
);
|
|
807
|
+
server.registerTool(
|
|
808
|
+
"submit_review",
|
|
809
|
+
{
|
|
810
|
+
description: "Record the learner's answer for one review card right after quizzing it. Rating: 1 = again (forgot), 2 = hard, 3 = good, 4 = easy.",
|
|
811
|
+
inputSchema: {
|
|
812
|
+
cardId: nonEmpty,
|
|
813
|
+
rating: z2.number().int().min(1).max(4)
|
|
814
|
+
}
|
|
815
|
+
},
|
|
816
|
+
({ cardId, rating }) => run(() => coach.submitReview(cardId, rating))
|
|
817
|
+
);
|
|
818
|
+
server.registerTool(
|
|
819
|
+
"get_progress",
|
|
820
|
+
{
|
|
821
|
+
description: "Get profile, streak, total XP, weekly goal progress and number of due reviews. Call at the start of a session and when the learner asks how they are doing; never invent progress."
|
|
822
|
+
},
|
|
823
|
+
() => run(() => coach.progress())
|
|
824
|
+
);
|
|
825
|
+
server.registerTool(
|
|
826
|
+
"export_data",
|
|
827
|
+
{
|
|
828
|
+
description: "Export all stored learner data as JSON (backup, or to move to another machine)."
|
|
829
|
+
},
|
|
830
|
+
() => run(() => coach.exportData())
|
|
831
|
+
);
|
|
832
|
+
server.registerTool(
|
|
833
|
+
"import_data",
|
|
834
|
+
{
|
|
835
|
+
description: "Replace all stored data with a previously exported JSON document (passed as a string). The current data is backed up first. Only use when the learner explicitly asks to restore data.",
|
|
836
|
+
inputSchema: { json: z2.string() }
|
|
837
|
+
},
|
|
838
|
+
({ json }) => run(async () => {
|
|
839
|
+
let raw;
|
|
840
|
+
try {
|
|
841
|
+
raw = JSON.parse(json);
|
|
842
|
+
} catch {
|
|
843
|
+
throw new Error("Invalid JSON: the provided text could not be parsed. Nothing was changed.");
|
|
844
|
+
}
|
|
845
|
+
return coach.importData(raw);
|
|
846
|
+
})
|
|
847
|
+
);
|
|
848
|
+
server.registerPrompt(
|
|
849
|
+
"tutor",
|
|
850
|
+
{
|
|
851
|
+
description: "Start a LangCoach tutoring session."
|
|
852
|
+
},
|
|
853
|
+
() => ({
|
|
854
|
+
messages: [{ role: "user", content: { type: "text", text: TUTOR_PROMPT } }]
|
|
855
|
+
})
|
|
856
|
+
);
|
|
857
|
+
return server;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// src/index.ts
|
|
861
|
+
async function main() {
|
|
862
|
+
const coach = new Coach(new JsonFileStore(defaultDataPath()));
|
|
863
|
+
const server = createServer(coach);
|
|
864
|
+
await server.connect(new StdioServerTransport());
|
|
865
|
+
}
|
|
866
|
+
main().catch((err) => {
|
|
867
|
+
console.error("langcoach failed to start:", err instanceof Error ? err.message : err);
|
|
868
|
+
process.exit(1);
|
|
869
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "langcoach",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Language-learning coach MCP server for Claude Code: Claude writes the lessons, LangCoach keeps your weekly plan, streak, XP and spaced-repetition (FSRS) reviews on your machine.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"language-learning",
|
|
7
|
+
"spaced-repetition",
|
|
8
|
+
"fsrs",
|
|
9
|
+
"mcp",
|
|
10
|
+
"mcp-server",
|
|
11
|
+
"claude",
|
|
12
|
+
"claude-code",
|
|
13
|
+
"tutor",
|
|
14
|
+
"flashcards",
|
|
15
|
+
"vocabulary"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://github.com/oktaybilge1/langcoach#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/oktaybilge1/langcoach/issues"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/oktaybilge1/langcoach.git",
|
|
24
|
+
"directory": "packages/mcp"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"type": "module",
|
|
28
|
+
"bin": {
|
|
29
|
+
"langcoach": "dist/index.js"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsup",
|
|
41
|
+
"prepack": "npm run build && node scripts/copy-docs.mjs"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
45
|
+
"ts-fsrs": "^5.4.2",
|
|
46
|
+
"zod": "^4.6.7"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@langcoach/core": "*",
|
|
50
|
+
"tsup": "^9.0.0"
|
|
51
|
+
}
|
|
52
|
+
}
|