@lingochunk/mcp 0.3.1 → 0.8.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/dist/tools.js CHANGED
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { z } from "zod";
4
4
  import { ApiError } from "./client.js";
5
+ import { GUIDES, GUIDE_TOPICS } from "./generated/guides.js";
5
6
  /** card.v1 kinds (POST /cards with format=card.v1). Mirrors the server's
6
7
  * CardKindV1 / CARD_V1_BLUR_KINDS in lingochunk_shared.models.public_v1. */
7
8
  const CARD_V1_KINDS = new Set([
@@ -121,10 +122,12 @@ function sanitise(value) {
121
122
  function params(obj) {
122
123
  return obj;
123
124
  }
124
- const EXPORT_POLL_INTERVAL_MS = 2_000;
125
- const EXPORT_POLL_BUDGET_MS = 60_000;
125
+ // Shared cadence for the async-job pollers (deck export and language apply):
126
+ // both start a job then poll its status to completion.
127
+ const POLL_INTERVAL_MS = 2_000;
128
+ const POLL_BUDGET_MS = 60_000;
126
129
  // Fallback wait when a 429 carries no Retry-After.
127
- const EXPORT_RETRY_FALLBACK_MS = 5_000;
130
+ const POLL_RETRY_FALLBACK_MS = 5_000;
128
131
  function sleep(ms) {
129
132
  return new Promise((resolve) => setTimeout(resolve, ms));
130
133
  }
@@ -145,7 +148,7 @@ async function triggerExport(client, deckId, deadline) {
145
148
  return false;
146
149
  const wait = err.retryAfter && err.retryAfter > 0
147
150
  ? err.retryAfter * 1000
148
- : EXPORT_RETRY_FALLBACK_MS;
151
+ : POLL_RETRY_FALLBACK_MS;
149
152
  await sleep(Math.min(wait, remaining));
150
153
  }
151
154
  }
@@ -156,7 +159,7 @@ async function triggerExport(client, deckId, deadline) {
156
159
  * (not an abort). A 400 (e.g. a deck with no linked submission) or 403 bubbles
157
160
  * up as an ApiError for errorResult. */
158
161
  async function exportAndPoll(client, deckId) {
159
- const deadline = Date.now() + EXPORT_POLL_BUDGET_MS;
162
+ const deadline = Date.now() + POLL_BUDGET_MS;
160
163
  if (!(await triggerExport(client, deckId, deadline))) {
161
164
  return jsonResult({
162
165
  status: "pending",
@@ -190,10 +193,87 @@ async function exportAndPoll(client, deckId) {
190
193
  "re-trigger if a later call reports 'failed' or 'none'.",
191
194
  });
192
195
  }
193
- await sleep(EXPORT_POLL_INTERVAL_MS);
196
+ await sleep(POLL_INTERVAL_MS);
194
197
  }
195
198
  }
196
- export function registerTools(server, client, config) {
199
+ /** POST the draft commit, absorbing a 429 with a Retry-After backoff (capped by
200
+ * the remaining budget) rather than aborting. Returns the apply job id, or null
201
+ * when the budget runs out mid-backoff. A 400/403/404/409 bubbles up. */
202
+ async function commitDraft(client, submissionId, language, deadline) {
203
+ for (;;) {
204
+ try {
205
+ const { job_id } = await client.commitTranslationDraft(submissionId, language);
206
+ return job_id;
207
+ }
208
+ catch (err) {
209
+ if (!(err instanceof ApiError) || err.status !== 429)
210
+ throw err;
211
+ const remaining = deadline - Date.now();
212
+ if (remaining <= 0)
213
+ return null;
214
+ const wait = err.retryAfter && err.retryAfter > 0
215
+ ? err.retryAfter * 1000
216
+ : POLL_RETRY_FALLBACK_MS;
217
+ await sleep(Math.min(wait, remaining));
218
+ }
219
+ }
220
+ }
221
+ /** Commit a language draft, then poll the apply job for up to ~60s. On success
222
+ * resolve the new sibling's submission id via list_languages. A 409 (the draft
223
+ * is missing sentence positions) or 403 bubbles up as an ApiError. */
224
+ async function commitAndPoll(client, submissionId, language) {
225
+ const deadline = Date.now() + POLL_BUDGET_MS;
226
+ const jobId = await commitDraft(client, submissionId, language, deadline);
227
+ if (jobId === null) {
228
+ return jsonResult({
229
+ status: "processing",
230
+ language,
231
+ message: "Rate limited before the commit could start; call commit_language " +
232
+ "again shortly (a duplicate commit converges safely).",
233
+ });
234
+ }
235
+ for (;;) {
236
+ const job = await client.getJob(jobId);
237
+ if (job.status === "completed") {
238
+ let siblingId;
239
+ try {
240
+ const langs = await client.listSubmissionLanguages(submissionId);
241
+ siblingId = langs.languages.find((l) => l.language === language)?.submission_id;
242
+ }
243
+ catch {
244
+ // The apply succeeded; failing to resolve the sibling id is non-fatal.
245
+ }
246
+ return jsonResult({
247
+ status: "completed",
248
+ language,
249
+ submission_id: siblingId,
250
+ job_id: jobId,
251
+ });
252
+ }
253
+ if (job.status === "failed") {
254
+ return jsonResult({
255
+ status: "failed",
256
+ language,
257
+ job_id: jobId,
258
+ error: job.error ?? null,
259
+ message: "The apply job failed. Check the draft with list_languages, then " +
260
+ "call commit_language again to retry.",
261
+ });
262
+ }
263
+ if (Date.now() >= deadline) {
264
+ return jsonResult({
265
+ status: "processing",
266
+ language,
267
+ job_id: jobId,
268
+ message: "Still applying. Call list_languages shortly to see the new " +
269
+ "sibling's status; do not re-commit unless it never appears.",
270
+ });
271
+ }
272
+ await sleep(POLL_INTERVAL_MS);
273
+ }
274
+ }
275
+ export function registerTools(server, client, config, mode = "local") {
276
+ const local = mode === "local";
197
277
  server.registerTool("get_vocabulary", {
198
278
  title: "Get vocabulary",
199
279
  description: "List the user's vocabulary, aggregated per word with FSRS maturity " +
@@ -257,8 +337,8 @@ export function registerTools(server, client, config) {
257
337
  title: "List library",
258
338
  description: "List the user's ready-to-study episodes (their own submissions plus " +
259
339
  "collections they follow), newest first. Cursor-paginated. Use the " +
260
- "returned submission ids with get_transcript / get_audio_url / " +
261
- "get_audio_clip.",
340
+ "returned submission ids with get_transcript / get_audio_url" +
341
+ (local ? " / get_audio_clip." : "."),
262
342
  inputSchema: {
263
343
  limit: z.number().int().min(1).max(200).optional(),
264
344
  cursor: z
@@ -272,8 +352,11 @@ export function registerTools(server, client, config) {
272
352
  description: "Fetch a submission's transcript: timestamped sentences with " +
273
353
  "translations. Sliceable by sentence-position range (from_sentence/" +
274
354
  "to_sentence) or time range in seconds (from_time/to_time) so you can " +
275
- "pull an excerpt instead of a whole episode. transcript_state tells you " +
276
- "if it is ready, still processing, or unavailable.",
355
+ "pull an excerpt instead of a whole episode. Each sentence also carries " +
356
+ "a stable 'sentence_id' and its 'display' string, which the annotation " +
357
+ "tools use to anchor creator notes (offsets are Unicode code points into " +
358
+ "'display'). transcript_state tells you if it is ready, still " +
359
+ "processing, or unavailable.",
277
360
  inputSchema: {
278
361
  submission_id: z.string().min(1).describe("The submission id."),
279
362
  from_sentence: z.number().int().min(1).optional(),
@@ -285,8 +368,11 @@ export function registerTools(server, client, config) {
285
368
  server.registerTool("get_audio_url", {
286
369
  title: "Get audio URL",
287
370
  description: "Get a short-lived presigned URL to a submission's full native audio " +
288
- "(supports HTTP Range). Use for streaming; for a durable snippet to " +
289
- "embed in a lesson, use get_audio_clip instead.",
371
+ "(supports HTTP Range). " +
372
+ (local
373
+ ? "Use for streaming; for a durable snippet to embed in a lesson, " +
374
+ "use get_audio_clip instead."
375
+ : "Use it for streaming or to give the user a link they can play."),
290
376
  inputSchema: {
291
377
  submission_id: z.string().min(1).describe("The submission id."),
292
378
  },
@@ -324,39 +410,85 @@ export function registerTools(server, client, config) {
324
410
  }
325
411
  return runJson(() => client.searchExamples(params(args)));
326
412
  });
327
- server.registerTool("get_audio_clip", {
328
- title: "Get audio clip",
329
- description: "Cut a native-audio snippet [start, end] (seconds, max 60s) from a " +
330
- "submission and SAVE IT to a local file, returning the file path. Use " +
331
- "these small clips to embed audio in a self-contained HTML lesson (e.g. " +
332
- "as a data URI). Rate limited.",
413
+ server.registerTool("whats_possible", {
414
+ title: "What you can do with LingoChunk",
415
+ description: "The quick tour of this connection: the menu of what the user can " +
416
+ "ask for (talk through an episode, vocabulary checks, lessons, " +
417
+ "courses, flashcards + Anki export, creator notes, extra languages, " +
418
+ "publishing to an audience), one example prompt per area. CALL THIS " +
419
+ "when the user asks what they can do with LingoChunk, what is " +
420
+ "possible, how to get started, or for help in general - then answer " +
421
+ "SHORT (a line per area) and offer to go deeper on the area they " +
422
+ "pick (the full craft guides live behind get_authoring_guide). " +
423
+ "Read-only; needs no scope.",
424
+ inputSchema: {},
425
+ }, () => Promise.resolve({
426
+ content: [{ type: "text", text: GUIDES.overview.body }],
427
+ }));
428
+ server.registerTool("get_authoring_guide", {
429
+ title: "Get an authoring guide",
430
+ description: "Fetch the craft guide for a LingoChunk authoring task, so your output " +
431
+ "matches the app's own quality instead of rendering flat. CALL THIS " +
432
+ "FIRST - before you compose - the first time in a conversation you " +
433
+ "build any of: a lesson (topic 'lesson', before save_lesson), a " +
434
+ "multi-lesson course ('course', before create_course), flashcards " +
435
+ "('cards', before add_card), creator notes ('annotations', before " +
436
+ "create_annotation), a translation / added language ('add-language', " +
437
+ "before add_language or the draft flow), or a guided discussion " +
438
+ "('discuss'). Topic 'overview' is the what-can-I-do tour (same " +
439
+ "content as whats_possible). Returns the guide markdown: anchoring " +
440
+ "rules, the block/kind menu, and worked recipes. Read-only; needs " +
441
+ "no scope.",
333
442
  inputSchema: {
334
- submission_id: z.string().min(1).describe("The submission id."),
335
- start: z.number().min(0).describe("Clip start in seconds."),
336
- end: z.number().gt(0).describe("Clip end in seconds (start < end)."),
443
+ topic: z
444
+ .enum(GUIDE_TOPICS)
445
+ .describe("Which authoring task: 'lesson' (lesson.v1 documents), 'course' " +
446
+ "(a multi-lesson series), 'cards' (card.v1 flashcards), " +
447
+ "'annotations' (creator notes), 'add-language' (translations / " +
448
+ "leveled same-language decks), 'discuss' (guided episode " +
449
+ "discussion) or 'overview' (the what-can-I-do tour)."),
337
450
  },
338
- }, async ({ submission_id, start, end }) => runResult(async () => {
339
- // The API enforces these too; checking here gives a precise message and
340
- // avoids a wasted request.
341
- if (!(start < end)) {
342
- throw new Error("get_audio_clip needs start < end.");
343
- }
344
- if (end - start > 60) {
345
- throw new Error("get_audio_clip cannot exceed 60 seconds (end - start).");
346
- }
347
- const clip = await client.getAudioClip(submission_id, start, end);
348
- // 0o700: the clip dir holds the user's own study audio, so keep it
349
- // readable only by them (mode applies to dirs this call creates).
350
- await fs.mkdir(config.clipDir, { recursive: true, mode: 0o700 });
351
- const filename = `clip-${sanitise(submission_id)}-${start}-${end}${extensionFor(clip.contentType)}`;
352
- const filePath = path.join(config.clipDir, filename);
353
- await fs.writeFile(filePath, clip.data);
354
- return jsonResult({
355
- path: filePath,
356
- media_type: clip.contentType,
357
- size_bytes: clip.data.byteLength,
358
- });
451
+ }, ({ topic }) => Promise.resolve({
452
+ content: [{ type: "text", text: GUIDES[topic].body }],
359
453
  }));
454
+ // Remote mode never registers this tool: it saves to the *server's* disk,
455
+ // which the remote caller cannot read, and unmetered writes on a shared
456
+ // host would be an abuse vector besides.
457
+ if (local) {
458
+ server.registerTool("get_audio_clip", {
459
+ title: "Get audio clip",
460
+ description: "Cut a native-audio snippet [start, end] (seconds, max 60s) from a " +
461
+ "submission and SAVE IT to a local file, returning the file path. Use " +
462
+ "these small clips to embed audio in a self-contained HTML lesson (e.g. " +
463
+ "as a data URI). Rate limited.",
464
+ inputSchema: {
465
+ submission_id: z.string().min(1).describe("The submission id."),
466
+ start: z.number().min(0).describe("Clip start in seconds."),
467
+ end: z.number().gt(0).describe("Clip end in seconds (start < end)."),
468
+ },
469
+ }, async ({ submission_id, start, end }) => runResult(async () => {
470
+ // The API enforces these too; checking here gives a precise message and
471
+ // avoids a wasted request.
472
+ if (!(start < end)) {
473
+ throw new Error("get_audio_clip needs start < end.");
474
+ }
475
+ if (end - start > 60) {
476
+ throw new Error("get_audio_clip cannot exceed 60 seconds (end - start).");
477
+ }
478
+ const clip = await client.getAudioClip(submission_id, start, end);
479
+ // 0o700: the clip dir holds the user's own study audio, so keep it
480
+ // readable only by them (mode applies to dirs this call creates).
481
+ await fs.mkdir(config.clipDir, { recursive: true, mode: 0o700 });
482
+ const filename = `clip-${sanitise(submission_id)}-${start}-${end}${extensionFor(clip.contentType)}`;
483
+ const filePath = path.join(config.clipDir, filename);
484
+ await fs.writeFile(filePath, clip.data);
485
+ return jsonResult({
486
+ path: filePath,
487
+ media_type: clip.contentType,
488
+ size_bytes: clip.data.byteLength,
489
+ });
490
+ }));
491
+ }
360
492
  // --- Write tools (phase 3) ----------------------------------------------
361
493
  server.registerTool("list_decks", {
362
494
  title: "List decks",
@@ -385,8 +517,9 @@ export function registerTools(server, client, config) {
385
517
  "Responses carry a problems[] list of degradations (e.g. " +
386
518
  "focus_span_no_timings) - fix and resend to clear them; resending the " +
387
519
  "same headword updates the card in place (created=false), preserving " +
388
- "review history. Read the lingochunk-cards skill for per-kind guidance " +
389
- "and quality rules before batch-creating cards. LEGACY kinds still " +
520
+ "review history. Before composing cards, call get_authoring_guide with " +
521
+ "topic='cards' (once per conversation) for per-kind guidance and " +
522
+ "quality rules. LEGACY kinds still " +
390
523
  "work: kind=vocab adds a word ALREADY in the user's vocabulary by " +
391
524
  "lemma (409 code=ambiguous_lemma: pass submission_id or pos); " +
392
525
  "kind=custom is a flat front/back card (409 code=duplicate_card is " +
@@ -591,9 +724,11 @@ export function registerTools(server, client, config) {
591
724
  server.registerTool("save_lesson", {
592
725
  title: "Save a lesson",
593
726
  description: "Save a lesson to the user's private LingoChunk library (up to 100 " +
594
- "lessons, private by default). PREFERRED: pass `document`, a " +
595
- "structured lesson.v1 JSON document (see the lingochunk-lesson " +
596
- "skill) - the app renders it natively in a Lessons tab on the source " +
727
+ "lessons, private by default). Before composing a lesson, call " +
728
+ "get_authoring_guide with topic='lesson' (once per conversation) for " +
729
+ "the scaffold, anchoring rules and recipes. PREFERRED: pass `document`, " +
730
+ "a structured lesson.v1 JSON document - the app renders it natively in " +
731
+ "a Lessons tab on the source " +
597
732
  "episode, with real audio playback, live vocabulary state, links " +
598
733
  "into the Words/Listen tabs and a built-in Ask AI tutor; the " +
599
734
  "response's app_url is where it opens, and unknown_lemmas lists any " +
@@ -603,8 +738,14 @@ export function registerTools(server, client, config) {
603
738
  "sentence references (400 with a stable code). LEGACY: pass `html` " +
604
739
  "(a complete self-contained HTML file, 10 MB max, title + language " +
605
740
  "required) to store an opaque artefact opened via a short-lived " +
606
- "view URL. Exactly one of document/html. Requires the lessons:write " +
607
- "scope.",
741
+ "view URL. Exactly one of document/html. To catch every problem in one " +
742
+ "pass, call validate_lesson FIRST and fix what it reports, then " +
743
+ "save_lesson. File a lesson into a course (from create_course) with " +
744
+ "course_id + optional sequence. Creators: visibility:'public' " +
745
+ "publishes the lesson to everyone who can view the source episode " +
746
+ "(e.g. followers of a collection it belongs to); it requires owning " +
747
+ "the episode and works for documents only. Requires the " +
748
+ "lessons:write scope.",
608
749
  inputSchema: {
609
750
  title: z
610
751
  .string()
@@ -633,8 +774,87 @@ export function registerTools(server, client, config) {
633
774
  .array(z.string())
634
775
  .optional()
635
776
  .describe("Optional provenance: the episode ids the lesson was built from."),
777
+ course_id: z
778
+ .string()
779
+ .max(36)
780
+ .optional()
781
+ .describe("File this lesson under a course you own (from create_course)."),
782
+ sequence: z
783
+ .number()
784
+ .int()
785
+ .optional()
786
+ .describe("Ordering position within the course (ties break by created_at). " +
787
+ "Requires course_id."),
788
+ visibility: z
789
+ .enum(["private", "public"])
790
+ .optional()
791
+ .describe("'private' (default, owner only) or 'public' (publish: visible " +
792
+ "to everyone who can view the source episode, e.g. via a " +
793
+ "public collection). Documents only, and only on an episode " +
794
+ "you own (403 publish_not_submission_owner otherwise)."),
795
+ },
796
+ }, async (args) => {
797
+ // Mirror the server's request-shape rule client-side: sequence only means
798
+ // something inside a course, so a bare sequence is a 422 on the server.
799
+ if (args.sequence !== undefined && args.course_id === undefined) {
800
+ return errorResult(new Error("save_lesson 'sequence' requires 'course_id'."));
801
+ }
802
+ return runJson(() => client.createLesson(args));
803
+ });
804
+ server.registerTool("validate_lesson", {
805
+ title: "Validate a lesson document",
806
+ description: "Dry-run validate a lesson.v1 `document` WITHOUT saving it, and get " +
807
+ "EVERY problem back at once instead of one save -> 400 -> fix cycle at " +
808
+ "a time. Call it before save_lesson (repeatedly, as you fix) and only " +
809
+ "save once it returns valid:true. Returns {valid, errors, " +
810
+ "unknown_lemmas}: each error carries a stable `code` and a `loc` " +
811
+ "(dotted path into the document) for schema faults, or `positions` / " +
812
+ "`audio_windows` for reference faults (the same codes save_lesson " +
813
+ "raises: unknown_positions, position_outside_slice, " +
814
+ "audio_outside_episode, audio_outside_slice, dialogue_mismatch, " +
815
+ "order_mismatch). `unknown_lemmas` is advisory - glossary lemmas the " +
816
+ "episode does not know; they do NOT make the document invalid. Stores " +
817
+ "nothing and spends no lesson-cap budget. Requires the lessons:write " +
818
+ "scope.",
819
+ inputSchema: {
820
+ document: z
821
+ .record(z.unknown())
822
+ .describe("A lesson.v1 document (same shape as save_lesson's document)."),
636
823
  },
637
- }, async (args) => runJson(() => client.createLesson(args)));
824
+ }, async ({ document }) => runJson(() => client.validateLesson({ document })));
825
+ server.registerTool("list_lessons", {
826
+ title: "List lessons",
827
+ description: "List the user's saved lessons, newest first: id, title, language, " +
828
+ "format (lesson.v1 or html), source submission, created_at, and its " +
829
+ "course_id / sequence / course_title when the lesson is filed under a " +
830
+ "course (so you can group without a second call). Cursor-paginated. " +
831
+ "Use it to find a lesson id for get_lesson / delete_lesson, or to see " +
832
+ "what already exists before saving another. Requires the lessons:write " +
833
+ "scope.",
834
+ inputSchema: {
835
+ limit: z.number().int().min(1).max(200).optional(),
836
+ cursor: z
837
+ .string()
838
+ .optional()
839
+ .describe("Opaque cursor from a previous page's next_cursor."),
840
+ },
841
+ }, async (args) => runJson(() => client.listLessons(params(args))));
842
+ server.registerTool("get_lesson", {
843
+ title: "Get a lesson document",
844
+ description: "Read back a saved lesson.v1 document by id (ids from list_lessons " +
845
+ "or save_lesson's response). This closes the revision loop: lessons " +
846
+ "are immutable, so to revise one, get_lesson -> edit the document -> " +
847
+ "save_lesson (a NEW id) -> delete_lesson the superseded one. 404 for " +
848
+ "legacy HTML lessons - they have no document. Requires the " +
849
+ "lessons:write scope.",
850
+ inputSchema: {
851
+ lesson_id: z
852
+ .string()
853
+ .min(1)
854
+ .max(36)
855
+ .describe("The lesson to read (id from list_lessons/save_lesson)."),
856
+ },
857
+ }, async ({ lesson_id }) => runJson(() => client.getLessonDocument(lesson_id)));
638
858
  server.registerTool("delete_lesson", {
639
859
  title: "Delete a lesson",
640
860
  description: "Permanently delete ONE of the user's saved lessons (the stored " +
@@ -657,5 +877,364 @@ export function registerTools(server, client, config) {
657
877
  await client.deleteLesson(lesson_id);
658
878
  return { deleted: true, lesson_id };
659
879
  }));
880
+ server.registerTool("create_course", {
881
+ title: "Create a course",
882
+ description: "Create a course: a named, ordered series to file lessons under " +
883
+ "(e.g. 'lesson 3 of 8'). Returns {id, title, description, " +
884
+ "lesson_count, created_at}; pass the id as save_lesson's course_id " +
885
+ "(with an optional sequence) to place lessons in it. Courses are " +
886
+ "authored via the API only (no in-app course editor). Requires the " +
887
+ "lessons:write scope.",
888
+ inputSchema: {
889
+ title: z
890
+ .string()
891
+ .min(1)
892
+ .max(255)
893
+ .describe("Course title (1-255 characters)."),
894
+ description: z
895
+ .string()
896
+ .max(2000)
897
+ .optional()
898
+ .describe("Optional longer description of the course."),
899
+ },
900
+ }, async (args) => runJson(() => client.createCourse(args)));
901
+ server.registerTool("list_courses", {
902
+ title: "List courses",
903
+ description: "List the user's courses, newest first, each with its lesson_count. " +
904
+ "Use it to find a course_id for save_lesson (or delete_course), or to " +
905
+ "see what series already exist before creating another. Requires the " +
906
+ "lessons:write scope.",
907
+ }, async () => runJson(() => client.listCourses()));
908
+ server.registerTool("delete_course", {
909
+ title: "Delete a course",
910
+ description: "Delete ONE of the user's courses by id. This un-groups its lessons " +
911
+ "(their course_id is set NULL) but NEVER deletes them - authored " +
912
+ "lessons always survive. Destructive only to the grouping, and " +
913
+ "idempotent. 404 means the id does not exist or is not the user's. " +
914
+ "Requires the lessons:write scope.",
915
+ annotations: { destructiveHint: true, idempotentHint: true },
916
+ inputSchema: {
917
+ course_id: z
918
+ .string()
919
+ .min(1)
920
+ .max(36)
921
+ .describe("The course to delete (id from create_course/list_courses)."),
922
+ },
923
+ }, async ({ course_id }) => runJson(async () => {
924
+ await client.deleteCourse(course_id);
925
+ return { deleted: true, course_id };
926
+ }));
927
+ // --- Language / translation tools (phase 4) -----------------------------
928
+ server.registerTool("list_languages", {
929
+ title: "List submission languages",
930
+ description: "List a submission's target languages and how to add more. Returns " +
931
+ "'languages' (the fan-out group so far, each with its own " +
932
+ "submission_id, status and is_primary flag), 'available_targets' " +
933
+ "(ordinary languages you can hand to add_language for the server-side " +
934
+ "Groq fan-out), 'simplify_targets' (leveled same-language codes like " +
935
+ "'de-a2' = German audio glossed in simpler A2 German, which ONLY the " +
936
+ "draft flow accepts) and 'drafts' (in-progress agent translations with " +
937
+ "sentences_drafted / sentence_count). Use it to choose a target and to " +
938
+ "poll a target's status after add_language or commit_language. " +
939
+ "Requires the content:read scope.",
940
+ inputSchema: {
941
+ submission_id: z.string().min(1).describe("The submission id."),
942
+ },
943
+ }, async ({ submission_id }) => runJson(() => client.listSubmissionLanguages(submission_id)));
944
+ server.registerTool("add_language", {
945
+ title: "Add languages (server-side fan-out)",
946
+ description: "Fan a submission out into extra ORDINARY target languages " +
947
+ "server-side (Groq translation; spends none of your tokens). Pass " +
948
+ "1-10 language codes from list_languages' available_targets; returns a " +
949
+ "job per accepted language and a 'skipped' list (e.g. the source " +
950
+ "language, an existing sibling, or a leveled code with reason " +
951
+ "'agent_only_target'). Poll list_languages until each new sibling's " +
952
+ "status is 'ready'. Leveled same-language codes (en-a2, de-b1, ...) " +
953
+ "are NOT accepted here: translate and commit them yourself via " +
954
+ "get_translation_source + put_language_translations + commit_language. " +
955
+ "Requires the translations:write scope.",
956
+ inputSchema: {
957
+ submission_id: z.string().min(1).describe("The submission id."),
958
+ languages: z
959
+ .array(z
960
+ .string()
961
+ .min(1)
962
+ .transform((v) => v.trim().toLowerCase()))
963
+ .min(1)
964
+ .max(10)
965
+ .describe("1-10 ordinary target codes from available_targets " +
966
+ "(normalised to lowercase)."),
967
+ },
968
+ }, async ({ submission_id, languages }) => runJson(() => client.addLanguages(submission_id, languages)));
969
+ server.registerTool("get_translation_source", {
970
+ title: "Get translation source",
971
+ description: "Page through the primary submission's sentences to translate " +
972
+ "yourself. Each sentence gives the source 'text', a " +
973
+ "'pivot_translation' (the whole sentence in the pivot language) and " +
974
+ "'tokens' (surface, lemma, pos and a 'pivot_meaning' that FIXES each " +
975
+ "word's sense in context). THE CONTRACT you must honour when you " +
976
+ "translate: produce exactly one meaning per token, in the same order, " +
977
+ "same length as 'tokens'; render the sense the 'pivot_meaning' fixes " +
978
+ "(do not re-interpret the word); PUNCT and INTJ tokens map to \"\"; " +
979
+ "proper nouns stay as the name; never copy the pivot or source word " +
980
+ "verbatim as its meaning. Before drafting, call get_authoring_guide " +
981
+ "with topic='add-language' (once per conversation) for the full " +
982
+ "per-level rules (ordinary target vs leveled same-language). " +
983
+ "Page with 'from_position' (0-based) until 'next_from_position' is " +
984
+ "null. Only the READY primary of a group is a valid source. Requires " +
985
+ "the content:read scope.",
986
+ inputSchema: {
987
+ submission_id: z.string().min(1).describe("The submission id."),
988
+ from_position: z
989
+ .number()
990
+ .int()
991
+ .min(0)
992
+ .optional()
993
+ .describe("0-based sentence position to start from (default 0)."),
994
+ limit: z
995
+ .number()
996
+ .int()
997
+ .min(1)
998
+ .max(100)
999
+ .optional()
1000
+ .describe("Sentences per page (max 100)."),
1001
+ },
1002
+ }, async ({ submission_id, ...rest }) => runJson(() => client.getTranslationSource(submission_id, params(rest))));
1003
+ server.registerTool("put_language_translations", {
1004
+ title: "Put draft translations",
1005
+ description: "Upload a batch of your draft sentences for one target or leveled " +
1006
+ "language (1-100 per call; page with get_translation_source and PUT " +
1007
+ "25-50 at a time). Each sentence carries its 0-based 'position', a " +
1008
+ "whole-sentence 'translation' (or null to leave the sentence back " +
1009
+ "blank - hide-on-fail for leveled decks) and 'meanings' (one per " +
1010
+ "source token, SAME ORDER and EXACT length as that sentence's " +
1011
+ "tokens). The server validates each sentence against the real " +
1012
+ "transcript and returns a 'rejected' list (meanings_length_mismatch " +
1013
+ "with expected/got, unknown position, oversize strings) while " +
1014
+ "ACCEPTING the rest - fix the rejected ones and PUT them again. " +
1015
+ "'sentences_drafted' / 'sentence_count' track completeness. Set " +
1016
+ "'generator' to the model producing the translations, for provenance. " +
1017
+ "Keep a local tally of covered positions: commit_language needs every " +
1018
+ "one. Requires the translations:write scope.",
1019
+ inputSchema: {
1020
+ submission_id: z.string().min(1).describe("The submission id."),
1021
+ language: z
1022
+ .string()
1023
+ .min(1)
1024
+ .transform((v) => v.trim().toLowerCase())
1025
+ .describe("The target or leveled code being drafted (normalised to " +
1026
+ "lowercase)."),
1027
+ generator: z
1028
+ .string()
1029
+ .max(100)
1030
+ .optional()
1031
+ .describe("Model producing the translations, recorded for provenance."),
1032
+ sentences: z
1033
+ .array(z.object({
1034
+ position: z
1035
+ .number()
1036
+ .int()
1037
+ .min(0)
1038
+ .describe("0-based sentence position from get_translation_source."),
1039
+ translation: z
1040
+ .string()
1041
+ .nullable()
1042
+ .optional()
1043
+ .describe("Whole-sentence text, or null for no sentence back."),
1044
+ meanings: z
1045
+ .array(z.string())
1046
+ .describe("One meaning per source token, same order and exact length " +
1047
+ "as the sentence's tokens."),
1048
+ }))
1049
+ .min(1)
1050
+ .max(100)
1051
+ .describe("1-100 draft sentences."),
1052
+ },
1053
+ }, async ({ submission_id, language, generator, sentences }) => runJson(() => client.putTranslations(submission_id, language, {
1054
+ generator,
1055
+ sentences: sentences.map((s) => ({
1056
+ position: s.position,
1057
+ translation: s.translation ?? null,
1058
+ meanings: s.meanings,
1059
+ })),
1060
+ })));
1061
+ server.registerTool("commit_language", {
1062
+ title: "Commit a language draft",
1063
+ description: "Validate a complete draft for one language and apply it, minting a " +
1064
+ "new sibling submission (its own deck). The draft must cover EVERY " +
1065
+ "sentence position of the primary: a 409 lists the missing count and " +
1066
+ "first missing positions - PUT those with put_language_translations, " +
1067
+ "then commit again. This starts the apply job and polls it for up to " +
1068
+ "~60s: it returns {status:'completed', submission_id} with the new " +
1069
+ "sibling's id when ready, {status:'processing'} (call list_languages " +
1070
+ "shortly to check) if it is still applying, or {status:'failed'} to " +
1071
+ "retry. A duplicate commit while a job is in flight converges safely. " +
1072
+ "Requires the translations:write scope.",
1073
+ inputSchema: {
1074
+ submission_id: z.string().min(1).describe("The submission id."),
1075
+ language: z
1076
+ .string()
1077
+ .min(1)
1078
+ .transform((v) => v.trim().toLowerCase())
1079
+ .describe("The target or leveled code to commit (normalised to lowercase)."),
1080
+ },
1081
+ }, async ({ submission_id, language }) => runResult(() => commitAndPoll(client, submission_id, language)));
1082
+ server.registerTool("discard_language_draft", {
1083
+ title: "Discard a language draft",
1084
+ description: "Permanently delete the in-progress draft sentences for one language " +
1085
+ "on a submission (NOT any committed sibling deck - those are " +
1086
+ "untouched). Destructive: only discard a draft the user has asked to " +
1087
+ "abandon or restart. Returns {deleted_sentences}. Requires the " +
1088
+ "translations:write scope.",
1089
+ annotations: { destructiveHint: true, idempotentHint: true },
1090
+ inputSchema: {
1091
+ submission_id: z.string().min(1).describe("The submission id."),
1092
+ language: z
1093
+ .string()
1094
+ .min(1)
1095
+ .transform((v) => v.trim().toLowerCase())
1096
+ .describe("The target or leveled code whose draft to delete (normalised to " +
1097
+ "lowercase)."),
1098
+ },
1099
+ }, async ({ submission_id, language }) => runJson(() => client.deleteTranslationDraft(submission_id, language)));
1100
+ // --- Creator annotation tools (phase 5) ---------------------------------
1101
+ server.registerTool("list_annotations", {
1102
+ title: "List creator annotations",
1103
+ description: "List the creator annotations already on one of your own episodes. " +
1104
+ "Each is a markdown note anchored to a transcript sentence span (or a " +
1105
+ "whole sentence). Returns 'annotations' (ordered by sentence then " +
1106
+ "char_start; each with its id, sentence_id, char_start/char_end, the " +
1107
+ "server's 'selected_text' snapshot, the 'note' and a 'stale' flag), " +
1108
+ "plus 'count' and 'max_annotations' (the per-episode cap). BUDGET " +
1109
+ "against count vs max_annotations, and read this FIRST so you do not " +
1110
+ "re-annotate an expression that already has a note. 'stale: true' means " +
1111
+ "the sentence was edited after the note was made, so the app hides its " +
1112
+ "tint until it is re-anchored - replace or delete a stale note rather " +
1113
+ "than leaving it. Requires the content:read scope.",
1114
+ inputSchema: {
1115
+ submission_id: z.string().min(1).describe("The submission id."),
1116
+ },
1117
+ }, async ({ submission_id }) => runJson(() => client.listAnnotations(submission_id)));
1118
+ server.registerTool("create_annotation", {
1119
+ title: "Create a creator annotation",
1120
+ description: "Add ONE creator annotation to your own episode: a markdown note " +
1121
+ "tinted onto a transcript sentence span (owners see the tint + note " +
1122
+ "sheet; followers get a forward-only note card). Anchor it with " +
1123
+ "'sentence_id' (from get_transcript, stable across edits) plus " +
1124
+ "'char_start'/'char_end' - UNICODE CODE-POINT offsets into that " +
1125
+ "sentence's 'display' string (Python string semantics: count code " +
1126
+ "points, so an astral character like an emoji is 1, NOT the 2 that " +
1127
+ "JavaScript's string.length / indexOf would report). Offsets are " +
1128
+ "BOTH-OR-NEITHER: give both for a span, or omit both for a " +
1129
+ "whole-sentence note; char_start must be < char_end. The server " +
1130
+ "snapshots and returns 'selected_text' = display[char_start:char_end] - " +
1131
+ "VERIFY it equals the span you intended; if it does not, your offsets " +
1132
+ "were off (usually a UTF-16 vs code-point miscount), so delete_annotation " +
1133
+ "and create it again with corrected offsets. 'note' is markdown (1-5000 " +
1134
+ "chars), rendered natively in a bottom sheet - keep it to ~4 short " +
1135
+ "lines. Leave start_time/end_time unset - the server derives the " +
1136
+ "span's audio times from the transcript, so the note sheet's Play and " +
1137
+ "the card clip work without them. The episode has a cap (see " +
1138
+ "list_annotations' max_annotations). " +
1139
+ "Before annotating, call get_authoring_guide with topic='annotations' " +
1140
+ "(once per conversation) for what is worth a note and the note format. " +
1141
+ "Requires the annotations:write scope.",
1142
+ inputSchema: {
1143
+ submission_id: z.string().min(1).describe("The submission id."),
1144
+ sentence_id: z
1145
+ .number()
1146
+ .int()
1147
+ .describe("The sentence's stable id from get_transcript (NOT its position)."),
1148
+ char_start: z
1149
+ .number()
1150
+ .int()
1151
+ .min(0)
1152
+ .optional()
1153
+ .describe("Code-point offset into the sentence's 'display' where the span " +
1154
+ "starts (omit together with char_end for a whole-sentence note)."),
1155
+ char_end: z
1156
+ .number()
1157
+ .int()
1158
+ .min(0)
1159
+ .optional()
1160
+ .describe("Code-point offset where the span ends (exclusive); must be > " +
1161
+ "char_start."),
1162
+ note: z
1163
+ .string()
1164
+ .min(1)
1165
+ .max(5000)
1166
+ .describe("The markdown note (1-5000 chars; keep it to ~4 short lines)."),
1167
+ start_time: z
1168
+ .number()
1169
+ .min(0)
1170
+ .optional()
1171
+ .describe("Optional audio-span start (s); usually left unset."),
1172
+ end_time: z
1173
+ .number()
1174
+ .min(0)
1175
+ .optional()
1176
+ .describe("Optional audio-span end (s); usually left unset."),
1177
+ },
1178
+ }, async ({ submission_id, sentence_id, char_start, char_end, note, start_time, end_time, }) => {
1179
+ // Mirror the server's both-or-neither + ordering rules so the message is
1180
+ // precise and no request is wasted.
1181
+ if ((char_start === undefined) !== (char_end === undefined)) {
1182
+ return errorResult(new Error("create_annotation needs char_start and char_end TOGETHER (a span), " +
1183
+ "or NEITHER (a whole-sentence note)."));
1184
+ }
1185
+ if (char_start !== undefined &&
1186
+ char_end !== undefined &&
1187
+ !(char_start < char_end)) {
1188
+ return errorResult(new Error("create_annotation needs char_start < char_end."));
1189
+ }
1190
+ return runJson(() => client.createAnnotation(submission_id, {
1191
+ sentence_id,
1192
+ char_start,
1193
+ char_end,
1194
+ note,
1195
+ start_time,
1196
+ end_time,
1197
+ }));
1198
+ });
1199
+ server.registerTool("update_annotation", {
1200
+ title: "Update a creator annotation",
1201
+ description: "Replace the markdown note on one existing annotation; its anchor and " +
1202
+ "span stay put. Use it to fix or reword a note without moving it; to " +
1203
+ "re-anchor a span you must delete_annotation and create it again. " +
1204
+ "Returns the updated annotation (staleness recomputed). 404 means the " +
1205
+ "annotation does not exist or is not on this episode. Requires the " +
1206
+ "annotations:write scope.",
1207
+ inputSchema: {
1208
+ submission_id: z.string().min(1).describe("The submission id."),
1209
+ annotation_id: z
1210
+ .number()
1211
+ .int()
1212
+ .describe("The annotation to update (id from list_annotations)."),
1213
+ note: z
1214
+ .string()
1215
+ .min(1)
1216
+ .max(5000)
1217
+ .describe("The replacement markdown note (1-5000 chars)."),
1218
+ },
1219
+ }, async ({ submission_id, annotation_id, note }) => runJson(() => client.updateAnnotation(submission_id, annotation_id, note)));
1220
+ server.registerTool("delete_annotation", {
1221
+ title: "Delete a creator annotation",
1222
+ description: "Permanently delete ONE creator annotation from your episode (the tint " +
1223
+ "and its note disappear; any follower note card stops being generated). " +
1224
+ "Destructive and not undoable: delete only an annotation the user asked " +
1225
+ "to remove, a stale one you are replacing, or one whose 'selected_text' " +
1226
+ "did not match the span you intended (then create it again with " +
1227
+ "corrected offsets). Returns {deleted, annotation_id}. 404 means the id " +
1228
+ "does not exist or is not on this episode. Requires the " +
1229
+ "annotations:write scope.",
1230
+ annotations: { destructiveHint: true, idempotentHint: true },
1231
+ inputSchema: {
1232
+ submission_id: z.string().min(1).describe("The submission id."),
1233
+ annotation_id: z
1234
+ .number()
1235
+ .int()
1236
+ .describe("The annotation to delete (id from list_annotations)."),
1237
+ },
1238
+ }, async ({ submission_id, annotation_id }) => runJson(() => client.deleteAnnotation(submission_id, annotation_id)));
660
1239
  }
661
1240
  //# sourceMappingURL=tools.js.map