@lingochunk/mcp 0.3.1 → 0.9.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 CHANGED
@@ -14,13 +14,23 @@ contributions (see [CONTRIBUTING.md](CONTRIBUTING.md)).
14
14
  > Install: `/plugin marketplace add lingochunk/lingochunk-mcp` in Claude Code (server
15
15
  > plus lesson skills), or `npx -y @lingochunk/mcp` as a standalone MCP server.
16
16
 
17
+ **A creator rather than a coder?** If you make audio (a podcast, a YouTube
18
+ channel, classes) and want your episodes to become annotated transcripts,
19
+ lessons, courses and multi-language study guides your audience can use, start
20
+ with **[LingoChunk for creators](docs/creator-guide.md)** - no programming
21
+ required, everything happens by talking to an AI assistant.
22
+
17
23
  ## What it gives an agent
18
24
 
19
- Twelve tools, each wrapping one public endpoint. The first seven are read-only;
20
- the last five write to your account.
25
+ Thirty tools. Twelve read from your account; sixteen write to it; two serve
26
+ guidance: `whats_possible` answers "what can I do here?" with a short menu
27
+ and an example prompt per area, and `get_authoring_guide` serves the full
28
+ authoring craft guides so remote clients that never see the skills still
29
+ compose good lessons, cards and annotations.
21
30
 
22
31
  | Tool | Scope | What it does |
23
32
  |---|---|---|
33
+ | `whats_possible` | none | The quick tour: every area this connection covers (discuss, vocabulary, lessons, courses, cards, languages, creator notes, publishing), one example prompt each. The agent answers short and offers to go deeper per area. |
24
34
  | `get_vocabulary` | `vocab:read` | Your vocabulary, aggregated per word with FSRS maturity (known/learning/new/due). Filterable; incremental sync via `since` + `cursor` (additive-only, so full-resync periodically). |
25
35
  | `lookup_word` | `vocab:read` | One word: your own context plus a shared-lexicon gender/CEFR fallback. Grounds an LLM's guesses. |
26
36
  | `list_library` | `content:read` | Your ready-to-study episodes (own + followed collections), cursor-paginated. |
@@ -28,33 +38,75 @@ the last five write to your account.
28
38
  | `get_audio_url` | `content:read` | A short-lived presigned URL to the full native audio (Range-capable). |
29
39
  | `search_examples` | `content:read` | Example sentences across your library, by word (`lemma`) or text (`q`). A capped sample, not exhaustive. |
30
40
  | `get_audio_clip` | `content:read` | Cuts a short native-audio snippet, **saves it to a local file**, and returns `{path, media_type, size_bytes}` for embedding in lessons. |
41
+ | `get_authoring_guide` | none | Returns the full craft guide for `topic` (`overview`, `lesson`, `course`, `cards`, `annotations`, `add-language`, `discuss`) - the same content as the bundled skills, embedded in the package so remote clients get it too. Call it before composing. |
31
42
  | `list_decks` | `cards:write` or `decks:export` | Your study decks with card counts, for picking a `deck_id` to add to or export. |
32
43
  | `add_card` | `cards:write` | Adds a card to your review queue (FSRS, starts new). Preferred: the `card.v1` kinds (`word`, `phrase`, `collocation`, `idiom`, `chunk`, `grammar`, `cloze`, `contrast`, `qa`, `production`) anchored to a verbatim transcript sentence - the server derives the highlight/blur painting and native-audio clip, so the card matches the app's own. Legacy: `kind=vocab` from your vocabulary, or `kind=custom` front/back. Omit `deck_id` to use the deck for the card's own submission. |
33
44
  | `export_anki_deck` | `decks:export` | Exports a deck to Anki `.apkg` (no LLM), polling internally; returns a download URL when ready. A deck with no linked episode can't be exported. |
34
- | `save_lesson` | `lessons:write` | Saves a lesson to your private library (100 max). Preferred: a structured `lesson.v1` document the app renders natively (Lessons tab on the episode, real audio, live word state, Ask AI); returns metadata + an `app_url`. Legacy: a self-contained HTML file (10 MB cap) opened via a short-lived view URL. |
35
- | `delete_lesson` | `lessons:write` | Permanently deletes one saved lesson by id (destructive; owner-scoped server-side). Mainly for iterating: re-saving creates a new lesson, so superseded drafts count against the 100-lesson cap. |
36
-
37
- Plus three skills:
38
-
45
+ | `validate_lesson` | `lessons:write` | Dry-run validates a `lesson.v1` document WITHOUT saving it, reporting EVERY problem at once (schema faults with a `loc`, reference faults with the same codes `save_lesson` raises) so you fix a document in one pass. Stores nothing; spends no lesson-cap budget. Call it before `save_lesson`. |
46
+ | `save_lesson` | `lessons:write` | Saves a lesson to your private library (100 max). Preferred: a structured `lesson.v1` document the app renders natively (Lessons tab on the episode, real audio, live word state, Ask AI); returns metadata + an `app_url`. Legacy: a self-contained HTML file (10 MB cap) opened via a short-lived view URL. Optional `course_id` (+ `sequence`) files it under a course. Creators: `visibility:'public'` publishes the lesson to everyone who can view the source episode (documents only, own episodes only). |
47
+ | `list_lessons` | `lessons:write` | Your saved lessons, newest first (id, title, language, format, source episode, and `course_id`/`sequence`/`course_title` when filed under a course), cursor-paginated - for finding ids and seeing what already exists. |
48
+ | `get_lesson` | `lessons:write` | Reads back a saved `lesson.v1` document by id, plus its `version` token. Opens the revision loop: `get_lesson` -> edit -> `update_lesson` revises in place. |
49
+ | `update_lesson` | `lessons:write` | Revises a saved `lesson.v1` document IN PLACE (same id, same `app_url`, same visibility/course): surgical block `ops` keyed to the app's 1-based § handles, or a full replacement `document`. `base_version` guards against overwriting a concurrent edit (409 `stale_document`). |
50
+ | `delete_lesson` | `lessons:write` | Permanently deletes one saved lesson by id (destructive; owner-scoped server-side). For lessons the user is done with - revision is `update_lesson`, not delete + re-save. |
51
+ | `create_course` | `lessons:write` | Creates a course: a named, ordered series to file lessons under. Returns its id, for `save_lesson`'s `course_id`. Authored via the API only (no in-app course editor). |
52
+ | `list_courses` | `lessons:write` | Your courses, newest first, each with its lesson count - for finding a `course_id` or seeing what series exist. |
53
+ | `delete_course` | `lessons:write` | Deletes one course by id (destructive to the grouping, idempotent). Its lessons SURVIVE - their `course_id` is set null, un-grouping them; authored content is never deleted. |
54
+ | `list_languages` | `content:read` | An episode's target languages and how to add more: the fan-out group so far (each with its own submission id + status), `available_targets` (ordinary Groq targets), `simplify_targets` (leveled same-language codes like `de-a2`) and in-progress drafts. |
55
+ | `get_translation_source` | `content:read` | Pages the primary's sentences to translate yourself: source text, the pivot-language gloss per sentence and per token (which fixes each word's sense). Feeds the draft flow. |
56
+ | `add_language` | `translations:write` | Fans an episode out into 1-10 extra **ordinary** target languages server-side (Groq, no tokens of yours); returns a job per language. Leveled same-language codes are rejected here - use the draft flow. |
57
+ | `put_language_translations` | `translations:write` | Uploads a batch (1-100) of agent-written draft sentences (whole-sentence translation + one meaning per token) for a target or leveled language; returns per-sentence rejections to repair. |
58
+ | `commit_language` | `translations:write` | Validates a complete draft and applies it, minting the sibling deck; polls the job and returns the new submission id when ready. |
59
+ | `discard_language_draft` | `translations:write` | Deletes the in-progress draft rows for a language (destructive; never a committed sibling). |
60
+ | `list_annotations` | `content:read` | An episode's creator annotations (each a markdown note on a transcript sentence span), plus `count` and `max_annotations` so you can budget and avoid duplicates. |
61
+ | `create_annotation` | `annotations:write` | Attaches a markdown creator note to a sentence span (Unicode code-point offsets into the sentence's `display`, or a whole-sentence note); the response echoes `selected_text` to verify the span. |
62
+ | `update_annotation` | `annotations:write` | Replaces one annotation's note in place (the anchor stays put). |
63
+ | `delete_annotation` | `annotations:write` | Deletes one annotation (destructive); also how you fix a mis-anchored span before re-creating it. |
64
+
65
+ Plus seven skills:
66
+
67
+ - **`lingochunk-overview`** - the "what can I do?" tour: a short menu of
68
+ every area (with an example prompt each) and instructions to answer
69
+ briefly, then expand whichever area the user picks.
39
70
  - **`lingochunk-lesson`** - composes a coursebook-style `lesson.v1` document
40
71
  (listen, text, vocabulary, one grammar point, graded exercises, review)
41
72
  from the tools above, filtering out words you already know; the app
42
73
  renders it natively and can export an offline HTML worksheet.
74
+ - **`lingochunk-course`** - plans a multi-lesson series: slices an episode (or
75
+ collection) into coherent parts, creates a course, then builds N lessons via
76
+ the lesson skill with a different grammar point and ramping difficulty per
77
+ lesson, each filed under the course in order.
43
78
  - **`lingochunk-cards`** - builds native-grade flashcards with the `card.v1`
44
79
  kinds: verbatim transcript anchors, per-kind guidance (grammar =
45
80
  production cloze of the morpheme with a hint), and a quality rubric
46
81
  distilled from the known failure modes of AI-generated cards.
47
82
  - **`lingochunk-discuss`** - a lighter, conversational "talk me through this
48
83
  episode" workflow.
84
+ - **`lingochunk-add-language`** - adds another language to one of your episodes
85
+ as a new sibling deck: either the server-side Groq fan-out for an ordinary
86
+ target, or an agent-supplied translation you write sentence by sentence and
87
+ commit - the only way to build a leveled same-language deck (e.g.
88
+ "German (A2)", German audio glossed in simpler A2 German).
89
+ - **`lingochunk-annotate`** - finds the genuinely useful expressions in one of
90
+ your episodes (idioms, phrasal verbs, collocations, discourse markers,
91
+ culture-bound references) and attaches a short markdown creator note to each
92
+ exact span: an iris tint + note sheet for you, a forward-only note card for
93
+ your followers.
94
+
95
+ The Claude Code plugin installs these as skills. Every client also gets the
96
+ same guidance two other ways that need no plugin: as MCP **prompts** (one per
97
+ skill, named `lingochunk-lesson`, `lingochunk-cards`, ...) in the client's
98
+ prompt/slash picker, and via the **`get_authoring_guide`** tool the model can
99
+ call itself before composing.
49
100
 
50
101
  ## Prerequisites
51
102
 
52
103
  - Node.js >= 18.
53
104
  - A LingoChunk **personal access token**: in LingoChunk, open Settings -> API
54
105
  access, create a token, and grant the scopes you need (`vocab:read` +
55
- `content:read` cover the read tools; add `cards:write`, `decks:export` and
56
- `lessons:write` for the write tools). The token is shown once and starts with
57
- `lcp_`. The 403 errors from the tools name the exact scope you are missing.
106
+ `content:read` cover the read tools; add `cards:write`, `decks:export`,
107
+ `lessons:write`, `translations:write` and `annotations:write` for the write
108
+ tools). The token is shown once and starts with `lcp_`. The 403 errors from
109
+ the tools name the exact scope you are missing.
58
110
 
59
111
  ## Use it
60
112
 
@@ -92,6 +144,36 @@ npm install # installs deps and builds dist/ via the prepare script
92
144
  claude mcp add lingochunk --env LINGOCHUNK_TOKEN=lcp_... -- node /absolute/path/to/lingochunk-mcp/dist/index.js
93
145
  ```
94
146
 
147
+ ### Option C - hosted server (no install: claude.ai, ChatGPT, Le Chat, ...)
148
+
149
+ The same server also runs hosted at `https://lingochunk.com/mcp` as a
150
+ standard **remote MCP server** (Streamable HTTP). Nothing to install - paste
151
+ the URL into any client that supports custom remote MCP servers and **sign in
152
+ with your LingoChunk account when the client prompts you** (OAuth 2.1 with a
153
+ consent screen; the grant appears in Settings -> API tokens, revocable like
154
+ any token). Clients with a token field can instead send a personal access
155
+ token as a Bearer credential:
156
+
157
+ | Client | Where |
158
+ |---|---|
159
+ | claude.ai (web/desktop/mobile, incl. Free) | Settings -> Connectors -> *Add custom connector* -> URL `https://lingochunk.com/mcp` -> a LingoChunk sign-in/consent screen opens; approve and you are connected |
160
+ | Claude Code | `claude mcp add --transport http lingochunk https://lingochunk.com/mcp` (OAuth on first use), or pass `--header "Authorization: Bearer lcp_..."` to use a token instead |
161
+ | ChatGPT (paid plans) | Settings -> enable *Developer mode* -> Apps -> "+" -> URL (OAuth), or URL + your token |
162
+ | Mistral Le Chat | *+ Add Connector* -> Custom MCP Connector -> URL (auth auto-detected) |
163
+ | Perplexity (Pro/Max), Grok, Manus | add a custom connector/MCP server by URL, sign in or paste an API key |
164
+
165
+ Differences from the local server: `get_audio_clip` is unavailable (it writes
166
+ files, which only makes sense on your own machine - use `get_audio_url`), and
167
+ skills don't auto-load (the server exposes each skill as an MCP **prompt**,
168
+ and agents can pull the same guidance through the `get_authoring_guide` tool).
169
+
170
+ **If your client offers neither OAuth nor a token field**, embed the token in
171
+ the URL: `https://lingochunk.com/mcp/t/lcp_...`. That URL then IS a
172
+ credential - treat it like a password, prefer a token with only the scopes you
173
+ need, and revoke it in Settings if it ever leaks. OAuth sign-in or a header,
174
+ when your client supports one, is always preferred (URLs end up in access
175
+ logs).
176
+
95
177
  ## Use with other agents
96
178
 
97
179
  Nothing in the server is Claude-specific: it is a standard stdio MCP server,
@@ -201,6 +283,22 @@ when you want the update.
201
283
  The token is only ever sent as an `Authorization: Bearer` header to the
202
284
  configured origin; it is never written to disk or logged.
203
285
 
286
+ ### Hosted (remote) mode
287
+
288
+ `node dist/index.js --http` serves the same tools over Streamable HTTP for
289
+ remote MCP clients (see Option C). The process is stateless and multi-user:
290
+ `LINGOCHUNK_TOKEN` is not read - every request must carry its own
291
+ `Authorization: Bearer lcp_...` header, which is forwarded verbatim to the
292
+ API (the API stays the sole authority; the server stores nothing). A
293
+ `Dockerfile` is included.
294
+
295
+ | Variable | Required | Default | Meaning |
296
+ |---|---|---|---|
297
+ | `LINGOCHUNK_MCP_PORT` | no | `8100` | Listen port (`PORT` honoured as fallback). |
298
+ | `LINGOCHUNK_BASE_URL` | no | `https://lingochunk.com` | API origin; co-located deployments should point at the loopback origin. |
299
+
300
+ Endpoints: `POST /mcp` (the MCP wire), `GET /health` (liveness).
301
+
204
302
  ## Building a lesson
205
303
 
206
304
  Ask your agent something like "build me a lesson from yesterday's German episode"
@@ -217,10 +315,15 @@ lessons side by side under the episode's Lessons tab. See
217
315
 
218
316
  ```
219
317
  src/ the MCP server (TypeScript, stdio)
318
+ skills/lingochunk-overview/ the "what can I do?" tour skill
220
319
  skills/lingochunk-lesson/ the coursebook lesson skill
320
+ skills/lingochunk-course/ the multi-lesson course planner skill
221
321
  skills/lingochunk-cards/ the flashcard (card.v1) skill
222
322
  skills/lingochunk-discuss/ the "discuss an episode" skill
323
+ skills/lingochunk-add-language/ the add-language / draft-translation skill
324
+ skills/lingochunk-annotate/ the useful-expression annotation skill
223
325
  skills/*/examples/ example lesson.v1 documents (CI-validated)
326
+ docs/creator-guide.md the guide for content creators (start here if you make audio)
224
327
  docs/skill-authoring.md how to write a new skill
225
328
  docs/skill-template.md SKILL.md starting point
226
329
  docs/integrations/fluent.md how to plug this into the fluent tutor plugin
@@ -253,7 +356,7 @@ npm run validate:lesson -- <doc.json> # validate a lesson.v1 document (Node 22
253
356
 
254
357
  `spec/openapi-public-v1.json` is the source contract; it is exported from the
255
358
  LingoChunk repo (`make generate-openapi-public`) and refreshed here on each API
256
- release. This copy was taken from LingoChunk commit `0db02377`.
359
+ release. This copy was taken from LingoChunk commit `31c47289`.
257
360
 
258
361
  ### Live smoke test
259
362
 
package/dist/client.d.ts CHANGED
@@ -24,6 +24,139 @@ export interface DeckExportStatus {
24
24
  status: "ready" | "pending" | "failed" | "none";
25
25
  download_url?: string;
26
26
  }
27
+ /** One target language present on a submission's fan-out group. */
28
+ export interface SubmissionLanguage {
29
+ language: string;
30
+ submission_id: string;
31
+ status: string;
32
+ is_primary: boolean;
33
+ }
34
+ /** An in-progress agent-supplied translation draft for one language. */
35
+ export interface DraftSummary {
36
+ language: string;
37
+ sentences_drafted: number;
38
+ sentence_count: number;
39
+ }
40
+ /** Result of GET /submissions/{id}/languages. */
41
+ export interface SubmissionLanguages {
42
+ source_language: string;
43
+ languages: SubmissionLanguage[];
44
+ /** Ordinary Groq targets addable via add_language (source + existing removed). */
45
+ available_targets: string[];
46
+ /** Leveled same-language codes (e.g. de-a2) for the draft flow only. */
47
+ simplify_targets: string[];
48
+ drafts: DraftSummary[];
49
+ }
50
+ /** One source token as the Groq translator would see it. */
51
+ export interface SourceToken {
52
+ surface: string;
53
+ lemma: string;
54
+ pos: string;
55
+ pivot_meaning: string;
56
+ }
57
+ /** One source sentence to translate, with its pivot-language gloss. */
58
+ export interface SourceSentence {
59
+ position: number;
60
+ text: string;
61
+ pivot_translation: string;
62
+ tokens: SourceToken[];
63
+ }
64
+ /** Result of GET /submissions/{id}/translation-source (one page). */
65
+ export interface TranslationSource {
66
+ source_language: string;
67
+ pivot_language: string;
68
+ sentence_count: number;
69
+ sentences: SourceSentence[];
70
+ /** null when the page exhausts the submission. */
71
+ next_from_position: number | null;
72
+ }
73
+ /** Result of POST /submissions/{id}/languages (Groq fan-out trigger). */
74
+ export interface AddLanguagesResult {
75
+ jobs: {
76
+ language: string;
77
+ job_id: string;
78
+ }[];
79
+ skipped: {
80
+ language: string;
81
+ reason: string;
82
+ }[];
83
+ }
84
+ /** One draft sentence in a PUT batch: whole-sentence target text (null = no
85
+ * sentence back) plus one meaning per source token, in order. */
86
+ export interface DraftSentence {
87
+ position: number;
88
+ translation: string | null;
89
+ meanings: string[];
90
+ }
91
+ /** Result of PUT /submissions/{id}/translations/{language}. */
92
+ export interface PutTranslationsResult {
93
+ accepted: number;
94
+ rejected: {
95
+ position: number;
96
+ reason: string;
97
+ expected?: number;
98
+ got?: number;
99
+ }[];
100
+ sentences_drafted: number;
101
+ sentence_count: number;
102
+ }
103
+ /** Result of POST /submissions/{id}/translations/{language}/commit. */
104
+ export interface CommitDraftResult {
105
+ job_id: string;
106
+ language: string;
107
+ }
108
+ /** Result of GET /jobs/{id}: status of a fan-out or draft-apply job. */
109
+ export interface JobStatus {
110
+ status: string;
111
+ progress?: number;
112
+ step?: string | null;
113
+ error?: string | null;
114
+ }
115
+ /** Result of DELETE /submissions/{id}/translations/{language}. */
116
+ export interface DeleteDraftResult {
117
+ deleted_sentences: number;
118
+ }
119
+ /** One creator annotation: a markdown note anchored to a transcript sentence,
120
+ * tinted onto a char span or the whole sentence. */
121
+ export interface AnnotationV1 {
122
+ id: number;
123
+ /** The sentence's stable id (survives split/merge), from get_transcript. */
124
+ sentence_id: number;
125
+ /** null (with char_end) for a whole-sentence note; else a code-point offset. */
126
+ char_start: number | null;
127
+ char_end: number | null;
128
+ /** Server snapshot of display[char_start:char_end]; verify it after create. */
129
+ selected_text: string | null;
130
+ note: string;
131
+ /** The sentence was edited since; the app hides the tint until re-anchored. */
132
+ stale: boolean;
133
+ start_time: number | null;
134
+ end_time: number | null;
135
+ }
136
+ /** Result of GET /submissions/{id}/annotations. */
137
+ export interface AnnotationList {
138
+ /** Ordered by sentence, then char_start. */
139
+ annotations: AnnotationV1[];
140
+ count: number;
141
+ /** The per-submission cap, so an agent can budget how many more to add. */
142
+ max_annotations: number;
143
+ }
144
+ /** Body of POST /submissions/{id}/annotations. char_start/char_end are
145
+ * both-or-neither (neither = a whole-sentence note); offsets are Unicode
146
+ * code points into the sentence's display. */
147
+ export interface CreateAnnotationBody {
148
+ sentence_id: number;
149
+ char_start?: number | null;
150
+ char_end?: number | null;
151
+ note: string;
152
+ start_time?: number | null;
153
+ end_time?: number | null;
154
+ }
155
+ /** Result of DELETE /submissions/{id}/annotations/{annotation_id}. */
156
+ export interface DeleteAnnotationResult {
157
+ deleted: boolean;
158
+ annotation_id: number;
159
+ }
27
160
  export type QueryValue = string | number | boolean | undefined | null;
28
161
  /** How long any single request may take before we abort it. */
29
162
  export declare const REQUEST_TIMEOUT_MS = 30000;
@@ -49,10 +182,21 @@ export declare class LingoChunkClient {
49
182
  private raiseForStatus;
50
183
  /** GET a JSON endpoint. */
51
184
  private getJson;
185
+ /** GET a JSON endpoint, also capturing one response header (e.g. the
186
+ * lesson optimistic-concurrency token in X-Lesson-Version). */
187
+ private getJsonWithHeader;
188
+ /** PUT a JSON body, also capturing one response header. */
189
+ private putJsonWithHeader;
52
190
  /** DELETE an endpoint; success is 204 with no body to parse. */
53
191
  private deleteNoContent;
54
192
  /** POST a JSON body to an endpoint and parse the JSON response. */
55
193
  private postJson;
194
+ /** PUT a JSON body to an endpoint and parse the JSON response. */
195
+ private putJson;
196
+ /** PATCH a JSON body to an endpoint and parse the JSON response. */
197
+ private patchJson;
198
+ /** DELETE an endpoint that returns a JSON body (unlike deleteNoContent). */
199
+ private deleteJson;
56
200
  getVocabulary(params: Record<string, QueryValue>): Promise<unknown>;
57
201
  lookupWord(params: Record<string, QueryValue>): Promise<unknown>;
58
202
  listLibrary(params: Record<string, QueryValue>): Promise<unknown>;
@@ -62,6 +206,35 @@ export declare class LingoChunkClient {
62
206
  listDecks(): Promise<unknown>;
63
207
  addCard(body: object): Promise<unknown>;
64
208
  createLesson(body: object): Promise<unknown>;
209
+ /** Dry-run validate a lesson.v1 document without saving it: reports every
210
+ * failing category at once (schema + reference), so a document can be fixed
211
+ * in one pass instead of save -> 400 -> fix cycles. Stores nothing. */
212
+ validateLesson(body: object): Promise<unknown>;
213
+ listLessons(params: Record<string, QueryValue>): Promise<unknown>;
214
+ /** Create a course: a named, ordered series to file lessons under. */
215
+ createCourse(body: object): Promise<unknown>;
216
+ /** The caller's courses, newest first, each with its lesson_count. */
217
+ listCourses(): Promise<unknown>;
218
+ /** Delete a course (owner-scoped). Its lessons survive - the DB sets their
219
+ * course_id NULL, un-grouping them. 404 for a foreign or unknown id. */
220
+ deleteCourse(courseId: string): Promise<void>;
221
+ /** The stored lesson.v1 document plus its optimistic-concurrency token
222
+ * (the X-Lesson-Version response header; a caller echoes it verbatim as
223
+ * an update's base_version). Owner-scoped server-side; 404 for HTML
224
+ * lessons (they have no document) and for foreign/unknown ids. */
225
+ getLessonDocument(lessonId: string): Promise<{
226
+ version: string | null;
227
+ document: unknown;
228
+ }>;
229
+ /** Replace a lesson.v1 document in place (owner-scoped; same id, same
230
+ * app_url). base_version is the X-Lesson-Version token the edit is based
231
+ * on - if the lesson changed since, the server answers 409 stale_document
232
+ * instead of overwriting the other writer. Returns the refreshed lesson
233
+ * metadata plus the NEW token for chained edits. */
234
+ updateLesson(lessonId: string, document: unknown, baseVersion: string): Promise<{
235
+ version: string | null;
236
+ lesson: unknown;
237
+ }>;
65
238
  /** Owner-scoped server-side: a foreign or unknown id is a 404, never a leak. */
66
239
  deleteLesson(lessonId: string): Promise<void>;
67
240
  /** Start an Anki export (no body). 400 for a deck with no linked submission. */
@@ -69,4 +242,38 @@ export declare class LingoChunkClient {
69
242
  exportDeckStatus(deckId: number): Promise<DeckExportStatus>;
70
243
  /** GET a clip as raw audio bytes (the endpoint streams audio, not JSON). */
71
244
  getAudioClip(submissionId: string, start: number, end: number): Promise<AudioClip>;
245
+ /** (a) The submission's fan-out group languages, addable targets, leveled
246
+ * simplify targets, and in-progress drafts. */
247
+ listSubmissionLanguages(submissionId: string): Promise<SubmissionLanguages>;
248
+ /** (b) A page of the primary's sentences (source text + pivot glosses) as the
249
+ * Groq translator would see them, for the agent to translate. */
250
+ getTranslationSource(submissionId: string, params: Record<string, QueryValue>): Promise<TranslationSource>;
251
+ /** (c) Trigger the server-side Groq fan-out into extra ordinary target
252
+ * languages; returns a job per accepted language plus per-code skips. */
253
+ addLanguages(submissionId: string, languages: string[]): Promise<AddLanguagesResult>;
254
+ /** (d) Upsert a batch of agent-supplied draft sentences for one language. */
255
+ putTranslations(submissionId: string, language: string, body: {
256
+ generator?: string;
257
+ sentences: DraftSentence[];
258
+ }): Promise<PutTranslationsResult>;
259
+ /** (e) Validate a complete draft and enqueue the apply job that mints the
260
+ * sibling submission. 409 when the draft misses sentence positions. */
261
+ commitTranslationDraft(submissionId: string, language: string): Promise<CommitDraftResult>;
262
+ /** (f) Owner-scoped job status, for polling a fan-out (c) or draft-apply (e)
263
+ * job to completion. */
264
+ getJob(jobId: string): Promise<JobStatus>;
265
+ /** (g) Delete the draft rows for one language (never a committed sibling). */
266
+ deleteTranslationDraft(submissionId: string, language: string): Promise<DeleteDraftResult>;
267
+ /** List a submission's creator annotations (ordered), with the per-submission
268
+ * cap so an agent can budget how many more to add. */
269
+ listAnnotations(submissionId: string): Promise<AnnotationList>;
270
+ /** Create one annotation on a sentence span (or the whole sentence). The
271
+ * response echoes the server's selected_text snapshot so the caller can
272
+ * verify the span it anchored. */
273
+ createAnnotation(submissionId: string, body: CreateAnnotationBody): Promise<AnnotationV1>;
274
+ /** Replace one annotation's markdown note (anchor unchanged; staleness is
275
+ * recomputed against the current sentence). */
276
+ updateAnnotation(submissionId: string, annotationId: number, note: string): Promise<AnnotationV1>;
277
+ /** Delete one annotation. Returns {deleted, annotation_id}. */
278
+ deleteAnnotation(submissionId: string, annotationId: number): Promise<DeleteAnnotationResult>;
72
279
  }
package/dist/client.js CHANGED
@@ -134,6 +134,34 @@ export class LingoChunkClient {
134
134
  }
135
135
  return (await res.json());
136
136
  }
137
+ /** GET a JSON endpoint, also capturing one response header (e.g. the
138
+ * lesson optimistic-concurrency token in X-Lesson-Version). */
139
+ async getJsonWithHeader(path, header) {
140
+ const res = await fetch(this.buildUrl(path), {
141
+ method: "GET",
142
+ headers: this.authHeaders("application/json"),
143
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
144
+ });
145
+ if (!res.ok) {
146
+ await this.raiseForStatus(res);
147
+ }
148
+ return { data: (await res.json()), header: res.headers.get(header) };
149
+ }
150
+ /** PUT a JSON body, also capturing one response header. */
151
+ async putJsonWithHeader(path, body, header) {
152
+ const headers = this.authHeaders("application/json");
153
+ headers["Content-Type"] = "application/json";
154
+ const res = await fetch(this.buildUrl(path), {
155
+ method: "PUT",
156
+ headers,
157
+ body: JSON.stringify(body),
158
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
159
+ });
160
+ if (!res.ok) {
161
+ await this.raiseForStatus(res);
162
+ }
163
+ return { data: (await res.json()), header: res.headers.get(header) };
164
+ }
137
165
  /** DELETE an endpoint; success is 204 with no body to parse. */
138
166
  async deleteNoContent(path) {
139
167
  const res = await fetch(this.buildUrl(path), {
@@ -161,6 +189,48 @@ export class LingoChunkClient {
161
189
  }
162
190
  return (await res.json());
163
191
  }
192
+ /** PUT a JSON body to an endpoint and parse the JSON response. */
193
+ async putJson(path, body) {
194
+ const headers = this.authHeaders("application/json");
195
+ headers["Content-Type"] = "application/json";
196
+ const res = await fetch(this.buildUrl(path), {
197
+ method: "PUT",
198
+ headers,
199
+ body: JSON.stringify(body),
200
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
201
+ });
202
+ if (!res.ok) {
203
+ await this.raiseForStatus(res);
204
+ }
205
+ return (await res.json());
206
+ }
207
+ /** PATCH a JSON body to an endpoint and parse the JSON response. */
208
+ async patchJson(path, body) {
209
+ const headers = this.authHeaders("application/json");
210
+ headers["Content-Type"] = "application/json";
211
+ const res = await fetch(this.buildUrl(path), {
212
+ method: "PATCH",
213
+ headers,
214
+ body: JSON.stringify(body),
215
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
216
+ });
217
+ if (!res.ok) {
218
+ await this.raiseForStatus(res);
219
+ }
220
+ return (await res.json());
221
+ }
222
+ /** DELETE an endpoint that returns a JSON body (unlike deleteNoContent). */
223
+ async deleteJson(path) {
224
+ const res = await fetch(this.buildUrl(path), {
225
+ method: "DELETE",
226
+ headers: this.authHeaders("application/json"),
227
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
228
+ });
229
+ if (!res.ok) {
230
+ await this.raiseForStatus(res);
231
+ }
232
+ return (await res.json());
233
+ }
164
234
  // --- Read endpoints (thin pass-throughs; the tools shape the arguments) ---
165
235
  getVocabulary(params) {
166
236
  return this.getJson("/vocab", params);
@@ -190,6 +260,45 @@ export class LingoChunkClient {
190
260
  createLesson(body) {
191
261
  return this.postJson("/lessons", body);
192
262
  }
263
+ /** Dry-run validate a lesson.v1 document without saving it: reports every
264
+ * failing category at once (schema + reference), so a document can be fixed
265
+ * in one pass instead of save -> 400 -> fix cycles. Stores nothing. */
266
+ validateLesson(body) {
267
+ return this.postJson("/lessons/validate", body);
268
+ }
269
+ listLessons(params) {
270
+ return this.getJson("/lessons", params);
271
+ }
272
+ /** Create a course: a named, ordered series to file lessons under. */
273
+ createCourse(body) {
274
+ return this.postJson("/courses", body);
275
+ }
276
+ /** The caller's courses, newest first, each with its lesson_count. */
277
+ listCourses() {
278
+ return this.getJson("/courses");
279
+ }
280
+ /** Delete a course (owner-scoped). Its lessons survive - the DB sets their
281
+ * course_id NULL, un-grouping them. 404 for a foreign or unknown id. */
282
+ deleteCourse(courseId) {
283
+ return this.deleteNoContent(`/courses/${encodeURIComponent(courseId)}`);
284
+ }
285
+ /** The stored lesson.v1 document plus its optimistic-concurrency token
286
+ * (the X-Lesson-Version response header; a caller echoes it verbatim as
287
+ * an update's base_version). Owner-scoped server-side; 404 for HTML
288
+ * lessons (they have no document) and for foreign/unknown ids. */
289
+ async getLessonDocument(lessonId) {
290
+ const { data, header } = await this.getJsonWithHeader(`/lessons/${encodeURIComponent(lessonId)}/document`, "x-lesson-version");
291
+ return { version: header, document: data };
292
+ }
293
+ /** Replace a lesson.v1 document in place (owner-scoped; same id, same
294
+ * app_url). base_version is the X-Lesson-Version token the edit is based
295
+ * on - if the lesson changed since, the server answers 409 stale_document
296
+ * instead of overwriting the other writer. Returns the refreshed lesson
297
+ * metadata plus the NEW token for chained edits. */
298
+ async updateLesson(lessonId, document, baseVersion) {
299
+ const { data, header } = await this.putJsonWithHeader(`/lessons/${encodeURIComponent(lessonId)}/document`, { document, base_version: baseVersion }, "x-lesson-version");
300
+ return { version: header, lesson: data };
301
+ }
193
302
  /** Owner-scoped server-side: a foreign or unknown id is a 404, never a leak. */
194
303
  deleteLesson(lessonId) {
195
304
  return this.deleteNoContent(`/lessons/${encodeURIComponent(lessonId)}`);
@@ -226,5 +335,60 @@ export class LingoChunkClient {
226
335
  const contentType = res.headers.get("content-type") ?? "audio/mpeg";
227
336
  return { data, contentType };
228
337
  }
338
+ // --- Language / translation endpoints (phase 4) -------------------------
339
+ /** (a) The submission's fan-out group languages, addable targets, leveled
340
+ * simplify targets, and in-progress drafts. */
341
+ listSubmissionLanguages(submissionId) {
342
+ return this.getJson(`/submissions/${encodeURIComponent(submissionId)}/languages`);
343
+ }
344
+ /** (b) A page of the primary's sentences (source text + pivot glosses) as the
345
+ * Groq translator would see them, for the agent to translate. */
346
+ getTranslationSource(submissionId, params) {
347
+ return this.getJson(`/submissions/${encodeURIComponent(submissionId)}/translation-source`, params);
348
+ }
349
+ /** (c) Trigger the server-side Groq fan-out into extra ordinary target
350
+ * languages; returns a job per accepted language plus per-code skips. */
351
+ addLanguages(submissionId, languages) {
352
+ return this.postJson(`/submissions/${encodeURIComponent(submissionId)}/languages`, { languages });
353
+ }
354
+ /** (d) Upsert a batch of agent-supplied draft sentences for one language. */
355
+ putTranslations(submissionId, language, body) {
356
+ return this.putJson(`/submissions/${encodeURIComponent(submissionId)}/translations/${encodeURIComponent(language)}`, body);
357
+ }
358
+ /** (e) Validate a complete draft and enqueue the apply job that mints the
359
+ * sibling submission. 409 when the draft misses sentence positions. */
360
+ commitTranslationDraft(submissionId, language) {
361
+ return this.postJson(`/submissions/${encodeURIComponent(submissionId)}/translations/${encodeURIComponent(language)}/commit`);
362
+ }
363
+ /** (f) Owner-scoped job status, for polling a fan-out (c) or draft-apply (e)
364
+ * job to completion. */
365
+ getJob(jobId) {
366
+ return this.getJson(`/jobs/${encodeURIComponent(jobId)}`);
367
+ }
368
+ /** (g) Delete the draft rows for one language (never a committed sibling). */
369
+ deleteTranslationDraft(submissionId, language) {
370
+ return this.deleteJson(`/submissions/${encodeURIComponent(submissionId)}/translations/${encodeURIComponent(language)}`);
371
+ }
372
+ // --- Creator annotation endpoints (phase 5) -----------------------------
373
+ /** List a submission's creator annotations (ordered), with the per-submission
374
+ * cap so an agent can budget how many more to add. */
375
+ listAnnotations(submissionId) {
376
+ return this.getJson(`/submissions/${encodeURIComponent(submissionId)}/annotations`);
377
+ }
378
+ /** Create one annotation on a sentence span (or the whole sentence). The
379
+ * response echoes the server's selected_text snapshot so the caller can
380
+ * verify the span it anchored. */
381
+ createAnnotation(submissionId, body) {
382
+ return this.postJson(`/submissions/${encodeURIComponent(submissionId)}/annotations`, body);
383
+ }
384
+ /** Replace one annotation's markdown note (anchor unchanged; staleness is
385
+ * recomputed against the current sentence). */
386
+ updateAnnotation(submissionId, annotationId, note) {
387
+ return this.patchJson(`/submissions/${encodeURIComponent(submissionId)}/annotations/${annotationId}`, { note });
388
+ }
389
+ /** Delete one annotation. Returns {deleted, annotation_id}. */
390
+ deleteAnnotation(submissionId, annotationId) {
391
+ return this.deleteJson(`/submissions/${encodeURIComponent(submissionId)}/annotations/${annotationId}`);
392
+ }
229
393
  }
230
394
  //# sourceMappingURL=client.js.map