@thallylabs/mcp 0.10.21 → 0.10.23

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
@@ -324,24 +324,106 @@ async function handleUpdatePage(input) {
324
324
  ].join("\n");
325
325
  }
326
326
 
327
- // src/tools/migrate-docs.ts
327
+ // src/tools/replace-page-text.ts
328
+ import { createHash } from "crypto";
329
+ import {
330
+ existsSync as existsSync3,
331
+ lstatSync,
332
+ readFileSync as readFileSync3,
333
+ realpathSync,
334
+ writeFileSync as writeFileSync4
335
+ } from "fs";
336
+ import { isAbsolute, join as join4, relative, sep } from "path";
328
337
  import { z as z6 } from "zod";
338
+ var MAX_REPLACEMENT_BYTES = 64 * 1024;
339
+ var replacePageTextSchema = z6.object({
340
+ projectDir: z6.string().describe("Path to the Thally project root"),
341
+ pageId: z6.string().describe(
342
+ 'Page identifier (for example "guides/auth"). No .mdx extension.'
343
+ ),
344
+ oldText: z6.string().min(1).max(MAX_REPLACEMENT_BYTES).describe("Exact existing text to replace; it must occur exactly once"),
345
+ newText: z6.string().min(1).max(MAX_REPLACEMENT_BYTES).describe(
346
+ "Complete replacement prose for oldText; never include a Track evidence marker"
347
+ ),
348
+ evidenceReferenceId: z6.string().min(1).max(128).optional().describe(
349
+ "Exact evidence reference ID supplied by Track. When present, the tool appends its deterministic citation marker."
350
+ )
351
+ });
352
+ function isInside(root, candidate) {
353
+ const fromRoot = relative(root, candidate);
354
+ return Boolean(fromRoot) && fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot);
355
+ }
356
+ function pageFile(projectDir, pageId) {
357
+ if (!/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/u.test(pageId) || pageId.split("/").some((part) => part.toLowerCase() === ".git")) {
358
+ throw new Error("Invalid pageId.");
359
+ }
360
+ const contentRoot = realpathSync(join4(projectDir, "src", "content"));
361
+ const candidates = [
362
+ join4(projectDir, "src", "content", `${pageId}.mdx`),
363
+ join4(projectDir, "src", "content", pageId, "index.mdx")
364
+ ];
365
+ const existing = candidates.filter((candidate2) => existsSync3(candidate2));
366
+ if (existing.length !== 1) throw new Error("Page not found or ambiguous.");
367
+ const candidate = existing[0];
368
+ const metadata = lstatSync(candidate);
369
+ if (!metadata.isFile() || metadata.isSymbolicLink() || !isInside(contentRoot, realpathSync(candidate))) {
370
+ throw new Error("Page must be a regular file inside src/content.");
371
+ }
372
+ return candidate;
373
+ }
374
+ function consumedTrailingLineSeparator(value) {
375
+ return value.match(/(\r?\n)[^\S\r\n]*$/u)?.[1] ?? "";
376
+ }
377
+ async function handleReplacePageText(input) {
378
+ if (input.oldText.includes("\0") || input.newText.includes("\0") || input.oldText === input.newText || input.newText.trim().length === 0) {
379
+ throw new Error("Replacement text is invalid.");
380
+ }
381
+ const filePath = pageFile(input.projectDir, input.pageId);
382
+ const source = readFileSync3(filePath, "utf8");
383
+ const first = source.indexOf(input.oldText);
384
+ if (first < 0 || source.indexOf(input.oldText, first + input.oldText.length) >= 0) {
385
+ throw new Error("oldText must match exactly one span.");
386
+ }
387
+ const marker = input.evidenceReferenceId ? `<!-- thally-cite:v1:${createHash("sha256").update(`evidence\0${input.evidenceReferenceId}`, "utf8").digest("hex")} -->` : null;
388
+ if (marker && input.newText.includes(marker)) {
389
+ throw new Error("Replacement text must not include its citation marker.");
390
+ }
391
+ const suffixSeparator = marker ? consumedTrailingLineSeparator(input.oldText) : "";
392
+ const citedReplacement = marker ? `${input.newText.replace(/\s*$/u, "")}
393
+ ${marker}` : input.newText;
394
+ const replacement = `${citedReplacement}${suffixSeparator}`;
395
+ writeFileSync4(
396
+ filePath,
397
+ `${source.slice(0, first)}${replacement}${source.slice(first + input.oldText.length)}`,
398
+ "utf8"
399
+ );
400
+ const startLine = source.slice(0, first).split("\n").length;
401
+ const endLine = startLine + citedReplacement.split("\n").length - 1;
402
+ return [
403
+ `\u2705 Page text replaced: ${input.pageId}`,
404
+ `Final replacement lines: ${startLine}-${endLine}.`,
405
+ "Use this exact final line range for the corresponding factual claim."
406
+ ].join("\n");
407
+ }
408
+
409
+ // src/tools/migrate-docs.ts
410
+ import { z as z7 } from "zod";
329
411
  import { migrateDocs } from "create-thally-docs/migrate";
330
412
  var migrationSourceShape = {
331
- sourceUrl: z6.string().describe("GitHub repository URL or public documentation URL to migrate"),
332
- branch: z6.string().optional().describe("Git branch (default: auto-detect)"),
333
- docsDir: z6.string().optional().describe("Docs subdirectory in repo (default: auto-detect)"),
334
- apiKey: z6.string().optional().describe("Anthropic API key for non-Markdown file conversion"),
335
- maxPages: z6.number().int().min(1).max(1e3).optional().describe("Maximum public URL pages to import"),
336
- platform: z6.enum(["mintlify", "docusaurus"]).optional().describe("Source platform (default: auto-detect)")
413
+ sourceUrl: z7.string().describe("GitHub repository URL or public documentation URL to migrate"),
414
+ branch: z7.string().optional().describe("Git branch (default: auto-detect)"),
415
+ docsDir: z7.string().optional().describe("Docs subdirectory in repo (default: auto-detect)"),
416
+ apiKey: z7.string().optional().describe("Anthropic API key for non-Markdown file conversion"),
417
+ maxPages: z7.number().int().min(1).max(1e3).optional().describe("Maximum public URL pages to import"),
418
+ platform: z7.enum(["mintlify", "docusaurus"]).optional().describe("Source platform (default: auto-detect)")
337
419
  };
338
- var migrateDocsSchema = z6.object({
420
+ var migrateDocsSchema = z7.object({
339
421
  ...migrationSourceShape,
340
- projectDir: z6.string().describe("Path for the new canonical Thally project; the directory must be absent or empty")
422
+ projectDir: z7.string().describe("Path for the new canonical Thally project; the directory must be absent or empty")
341
423
  });
342
- var importDocsSchema = z6.object({
424
+ var importDocsSchema = z7.object({
343
425
  ...migrationSourceShape,
344
- projectDir: z6.string().describe("Path to an existing Thally project whose runtime should be preserved")
426
+ projectDir: z7.string().describe("Path to an existing Thally project whose runtime should be preserved")
345
427
  });
346
428
  async function runMigration(input, isInPlaceImport) {
347
429
  const apiKey = input.apiKey ?? process.env.ANTHROPIC_API_KEY;
@@ -369,13 +451,13 @@ async function handleImportDocs(input) {
369
451
  }
370
452
 
371
453
  // src/tools/search-docs.ts
372
- import { z as z7 } from "zod";
373
- import { readdirSync, statSync, readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
374
- import { join as join4, relative, extname } from "path";
375
- var searchDocsSchema = z7.object({
376
- projectDir: z7.string().describe("Path to the Thally project root"),
377
- query: z7.string().describe("Search query"),
378
- limit: z7.number().optional().default(5).describe("Max results to return (default 5)")
454
+ import { z as z8 } from "zod";
455
+ import { readdirSync, statSync, readFileSync as readFileSync4, existsSync as existsSync4 } from "fs";
456
+ import { join as join5, relative as relative2, extname } from "path";
457
+ var searchDocsSchema = z8.object({
458
+ projectDir: z8.string().describe("Path to the Thally project root"),
459
+ query: z8.string().describe("Search query"),
460
+ limit: z8.number().optional().default(5).describe("Max results to return (default 5)")
379
461
  });
380
462
  function scanMdxFiles(dir, results) {
381
463
  let entries;
@@ -385,7 +467,7 @@ function scanMdxFiles(dir, results) {
385
467
  return;
386
468
  }
387
469
  for (const entry of entries) {
388
- const fullPath = join4(dir, entry);
470
+ const fullPath = join5(dir, entry);
389
471
  try {
390
472
  const stat = statSync(fullPath);
391
473
  if (stat.isDirectory()) {
@@ -403,7 +485,7 @@ function scoreFiles(files, contentDir, query) {
403
485
  for (const filePath of files) {
404
486
  let raw;
405
487
  try {
406
- raw = readFileSync3(filePath, "utf8");
488
+ raw = readFileSync4(filePath, "utf8");
407
489
  } catch {
408
490
  continue;
409
491
  }
@@ -411,7 +493,7 @@ function scoreFiles(files, contentDir, query) {
411
493
  const title = data.title ?? "";
412
494
  const description = data.description ?? "";
413
495
  const keywords = data.keywords ?? [];
414
- const pageId = relative(contentDir, filePath).replace(/\.mdx$/, "").replace(/\\/g, "/");
496
+ const pageId = relative2(contentDir, filePath).replace(/\.mdx$/, "").replace(/\\/g, "/");
415
497
  let score = 0;
416
498
  for (const term of terms) {
417
499
  if (title.toLowerCase().includes(term)) score += 3;
@@ -428,8 +510,8 @@ function scoreFiles(files, contentDir, query) {
428
510
  }
429
511
  async function handleSearchDocs(input) {
430
512
  const { projectDir, query, limit = 5 } = input;
431
- const contentDir = join4(projectDir, "src", "content");
432
- if (!existsSync3(contentDir)) {
513
+ const contentDir = join5(projectDir, "src", "content");
514
+ if (!existsSync4(contentDir)) {
433
515
  throw new Error(`Content directory not found: ${contentDir}`);
434
516
  }
435
517
  const files = [];
@@ -449,12 +531,12 @@ async function handleSearchDocs(input) {
449
531
  }
450
532
 
451
533
  // src/tools/semantic-search.ts
452
- import { z as z8 } from "zod";
453
- var semanticSearchSchema = z8.object({
454
- siteUrl: z8.string().describe("Base URL of the deployed Thally site (e.g. https://docs.example.com)"),
455
- query: z8.string().describe("Natural-language search query"),
456
- limit: z8.number().optional().default(8).describe("Max results to return (default 8)"),
457
- mode: z8.enum(["hybrid", "fulltext"]).optional().default("hybrid").describe("Search mode: hybrid (full-text + vector) or fulltext")
534
+ import { z as z9 } from "zod";
535
+ var semanticSearchSchema = z9.object({
536
+ siteUrl: z9.string().describe("Base URL of the deployed Thally site (e.g. https://docs.example.com)"),
537
+ query: z9.string().describe("Natural-language search query"),
538
+ limit: z9.number().optional().default(8).describe("Max results to return (default 8)"),
539
+ mode: z9.enum(["hybrid", "fulltext"]).optional().default("hybrid").describe("Search mode: hybrid (full-text + vector) or fulltext")
458
540
  });
459
541
  async function handleSemanticSearch(input) {
460
542
  const { siteUrl, query, limit = 8, mode = "hybrid" } = input;
@@ -485,10 +567,10 @@ async function handleSemanticSearch(input) {
485
567
  }
486
568
 
487
569
  // src/tools/agent-readiness.ts
488
- import { z as z9 } from "zod";
489
- var agentReadinessSchema = z9.object({
490
- siteUrl: z9.string().describe("Base URL of the deployed Thally site (e.g. https://docs.example.com)"),
491
- minScore: z9.number().optional().describe("Optional threshold (0-100). If set, the summary flags whether the site passes.")
570
+ import { z as z10 } from "zod";
571
+ var agentReadinessSchema = z10.object({
572
+ siteUrl: z10.string().describe("Base URL of the deployed Thally site (e.g. https://docs.example.com)"),
573
+ minScore: z10.number().optional().describe("Optional threshold (0-100). If set, the summary flags whether the site passes.")
492
574
  });
493
575
  async function handleAgentReadiness(input) {
494
576
  const { siteUrl, minScore } = input;
@@ -523,53 +605,211 @@ async function handleAgentReadiness(input) {
523
605
  }
524
606
 
525
607
  // src/tools/read-page.ts
526
- import { z as z10 } from "zod";
527
- import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
528
- import { join as join5 } from "path";
529
- var readPageSchema = z10.object({
530
- projectDir: z10.string().describe("Path to the Thally project root"),
531
- pageId: z10.string().describe('Page ID, e.g. "guides/authentication"')
608
+ import { z as z11 } from "zod";
609
+ import { existsSync as existsSync5, lstatSync as lstatSync2, realpathSync as realpathSync2 } from "fs";
610
+ import { isAbsolute as isAbsolute2, join as join6, relative as relative3, sep as sep2 } from "path";
611
+
612
+ // src/lib/text-window.ts
613
+ import { createHash as createHash2 } from "crypto";
614
+ import {
615
+ closeSync,
616
+ constants,
617
+ fstatSync,
618
+ openSync,
619
+ readFileSync as readFileSync5
620
+ } from "fs";
621
+ var MODEL_READ_WINDOW_DEFAULT_BYTES = 48 * 1024;
622
+ var MODEL_READ_WINDOW_MAX_BYTES = 180 * 1024;
623
+ var MODEL_READ_SOURCE_MAX_BYTES = 8 * 1024 * 1024;
624
+ function readModelTextFile(path) {
625
+ let descriptor;
626
+ try {
627
+ descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
628
+ const metadata = fstatSync(descriptor);
629
+ if (!metadata.isFile() || metadata.size > MODEL_READ_SOURCE_MAX_BYTES) {
630
+ throw new Error("text_source_invalid");
631
+ }
632
+ const bytes = readFileSync5(descriptor);
633
+ if (bytes.byteLength !== metadata.size)
634
+ throw new Error("text_source_invalid");
635
+ return bytes;
636
+ } catch (error) {
637
+ if (error instanceof Error && error.message === "text_source_invalid")
638
+ throw error;
639
+ throw new Error("text_source_invalid");
640
+ } finally {
641
+ if (descriptor !== void 0) closeSync(descriptor);
642
+ }
643
+ }
644
+ function textWindowMetadata(window) {
645
+ const metadata = [
646
+ `content-sha256: ${window.sha256}`,
647
+ window.contentBytes === 0 ? `window-bytes: empty at ${window.startByte} of ${window.totalBytes}` : `window-bytes: ${window.startByte}-${window.startByte + window.contentBytes - 1} of ${window.totalBytes}`,
648
+ `window-lines: ${window.startLine}-${window.endLine} of ${window.totalLines}`,
649
+ `complete: ${window.isComplete}`
650
+ ];
651
+ if (!window.isComplete) {
652
+ metadata.push(
653
+ `next-start-byte: ${window.nextStartByte}`,
654
+ `next-start-line: ${window.nextStartLine}`
655
+ );
656
+ }
657
+ return metadata;
658
+ }
659
+ function countLinesBefore(bytes, end) {
660
+ let lines = 1;
661
+ for (let index = 0; index < end; index += 1) {
662
+ if (bytes[index] === 10) lines += 1;
663
+ }
664
+ return lines;
665
+ }
666
+ function byteOffsetForLine(bytes, requestedLine) {
667
+ if (requestedLine === 1) return 0;
668
+ let line = 1;
669
+ for (let index = 0; index < bytes.byteLength; index += 1) {
670
+ if (bytes[index] !== 10) continue;
671
+ line += 1;
672
+ if (line === requestedLine) return index + 1;
673
+ }
674
+ throw new Error("text_window_start_out_of_range");
675
+ }
676
+ function decodeUtf8(bytes) {
677
+ try {
678
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
679
+ } catch {
680
+ throw new Error("text_window_invalid_utf8");
681
+ }
682
+ }
683
+ function isUtf8Boundary(bytes, offset) {
684
+ return offset === 0 || offset === bytes.byteLength || (bytes[offset] & 192) !== 128;
685
+ }
686
+ function createTextWindow(bytes, request = {}) {
687
+ decodeUtf8(bytes);
688
+ const maxBytes = request.maxBytes ?? MODEL_READ_WINDOW_DEFAULT_BYTES;
689
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MODEL_READ_WINDOW_MAX_BYTES) {
690
+ throw new Error("text_window_size_invalid");
691
+ }
692
+ if (request.startByte !== void 0 && request.startLine !== void 0) {
693
+ throw new Error("text_window_start_ambiguous");
694
+ }
695
+ let startByte;
696
+ if (request.startLine !== void 0) {
697
+ if (!Number.isSafeInteger(request.startLine) || request.startLine < 1) {
698
+ throw new Error("text_window_start_invalid");
699
+ }
700
+ startByte = byteOffsetForLine(bytes, request.startLine);
701
+ } else {
702
+ startByte = request.startByte ?? 0;
703
+ if (!Number.isSafeInteger(startByte) || startByte < 0 || startByte > bytes.byteLength || !isUtf8Boundary(bytes, startByte)) {
704
+ throw new Error("text_window_start_invalid");
705
+ }
706
+ }
707
+ let endByte = Math.min(bytes.byteLength, startByte + maxBytes);
708
+ while (endByte > startByte && !isUtf8Boundary(bytes, endByte)) endByte -= 1;
709
+ if (endByte === startByte && startByte < bytes.byteLength) {
710
+ throw new Error("text_window_size_invalid");
711
+ }
712
+ const contentBytes = endByte - startByte;
713
+ const content = decodeUtf8(bytes.subarray(startByte, endByte));
714
+ const startLine = countLinesBefore(bytes, startByte);
715
+ const endLine = startLine + (content.match(/\n/g)?.length ?? 0);
716
+ const isComplete = endByte === bytes.byteLength;
717
+ const totalLines = countLinesBefore(bytes, bytes.byteLength);
718
+ return {
719
+ content,
720
+ contentBytes,
721
+ endLine,
722
+ isComplete,
723
+ ...isComplete ? {} : { nextStartByte: endByte, nextStartLine: endLine },
724
+ sha256: createHash2("sha256").update(bytes).digest("hex"),
725
+ startByte,
726
+ startLine,
727
+ totalBytes: bytes.byteLength,
728
+ totalLines
729
+ };
730
+ }
731
+ function renderTextWindow(window) {
732
+ return [...textWindowMetadata(window), "", window.content].join("\n");
733
+ }
734
+
735
+ // src/tools/read-page.ts
736
+ var readPageSchema = z11.object({
737
+ projectDir: z11.string().describe("Path to the Thally project root"),
738
+ pageId: z11.string().describe('Page ID, e.g. "guides/authentication"'),
739
+ startByte: z11.number().int().min(0).optional().describe("UTF-8 byte continuation from a previous partial result"),
740
+ startLine: z11.number().int().min(1).optional().describe("1-based body line to start at; do not combine with startByte"),
741
+ maxBytes: z11.number().int().min(1).max(MODEL_READ_WINDOW_MAX_BYTES).optional().describe(
742
+ `Maximum body bytes to return (default 49152, maximum ${MODEL_READ_WINDOW_MAX_BYTES})`
743
+ )
532
744
  });
745
+ function isInside2(root, candidate) {
746
+ const fromRoot = relative3(root, candidate);
747
+ return Boolean(fromRoot) && fromRoot !== ".." && !fromRoot.startsWith(`..${sep2}`) && !isAbsolute2(fromRoot);
748
+ }
533
749
  async function handleReadPage(input) {
534
750
  const { projectDir, pageId } = input;
535
- const contentDir = join5(projectDir, "src", "content");
751
+ const contentDir = join6(projectDir, "src", "content");
752
+ if (!/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/u.test(pageId) || pageId.split("/").some((part) => part.toLowerCase() === ".git")) {
753
+ throw new Error("Page ID must be a safe content-relative identifier");
754
+ }
755
+ const contentRoot = realpathSync2(contentDir);
536
756
  const candidates = [
537
- join5(contentDir, `${pageId}.mdx`),
538
- join5(contentDir, `${pageId}/index.mdx`)
757
+ join6(contentDir, `${pageId}.mdx`),
758
+ join6(contentDir, `${pageId}/index.mdx`)
539
759
  ];
540
760
  let filePath = null;
541
761
  for (const c of candidates) {
542
- if (existsSync4(c)) {
762
+ if (existsSync5(c)) {
763
+ const metadata = lstatSync2(c);
764
+ if (!metadata.isFile() || metadata.isSymbolicLink() || !isInside2(contentRoot, realpathSync2(c))) {
765
+ throw new Error("Page must be a regular file inside src/content");
766
+ }
543
767
  filePath = c;
544
768
  break;
545
769
  }
546
770
  }
547
771
  if (!filePath) {
548
- throw new Error(`Page not found: "${pageId}". No file at src/content/${pageId}.mdx`);
772
+ throw new Error(
773
+ `Page not found: "${pageId}". No file at src/content/${pageId}.mdx`
774
+ );
775
+ }
776
+ const rawBytes = readModelTextFile(filePath);
777
+ let raw;
778
+ try {
779
+ raw = new TextDecoder("utf-8", { fatal: true }).decode(rawBytes);
780
+ } catch {
781
+ throw new Error("Page is not valid UTF-8");
549
782
  }
550
- const raw = readFileSync4(filePath, "utf8");
551
783
  const { data, content } = parseFrontmatter(raw);
552
784
  const title = data.title ?? pageId;
553
785
  const description = data.description ?? "";
554
786
  const lines = [`id: ${pageId}`, `title: ${title}`];
555
787
  if (description) lines.push(`description: ${description}`);
556
- lines.push("", readPageBodyDelimiter(), "", content.trim());
788
+ const window = createTextWindow(Buffer.from(content, "utf8"), input);
789
+ lines.push(
790
+ "",
791
+ ...textWindowMetadata(window),
792
+ "",
793
+ readPageBodyDelimiter(),
794
+ "",
795
+ window.content
796
+ );
557
797
  return lines.join("\n");
558
798
  }
559
799
 
560
800
  // src/tools/get-context.ts
561
- import { z as z11 } from "zod";
562
- import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
563
- import { join as join6 } from "path";
564
- var getContextSchema = z11.object({
565
- projectDir: z11.string().describe("Path to the Thally project root"),
566
- topic: z11.string().describe("Topic or question to find relevant docs for"),
567
- maxTokens: z11.number().optional().default(4e3).describe("Approximate token budget for returned context (default 4000)")
801
+ import { z as z12 } from "zod";
802
+ import { existsSync as existsSync6, readFileSync as readFileSync6 } from "fs";
803
+ import { join as join7 } from "path";
804
+ var getContextSchema = z12.object({
805
+ projectDir: z12.string().describe("Path to the Thally project root"),
806
+ topic: z12.string().describe("Topic or question to find relevant docs for"),
807
+ maxTokens: z12.number().optional().default(4e3).describe("Approximate token budget for returned context (default 4000)")
568
808
  });
569
809
  async function handleGetContext(input) {
570
810
  const { projectDir, topic, maxTokens = 4e3 } = input;
571
- const contentDir = join6(projectDir, "src", "content");
572
- if (!existsSync5(contentDir)) {
811
+ const contentDir = join7(projectDir, "src", "content");
812
+ if (!existsSync6(contentDir)) {
573
813
  throw new Error(`Content directory not found: ${contentDir}`);
574
814
  }
575
815
  const files = [];
@@ -583,13 +823,13 @@ async function handleGetContext(input) {
583
823
  const sections = [];
584
824
  for (const result of scored) {
585
825
  const candidates = [
586
- join6(contentDir, `${result.pageId}.mdx`),
587
- join6(contentDir, `${result.pageId}/index.mdx`)
826
+ join7(contentDir, `${result.pageId}.mdx`),
827
+ join7(contentDir, `${result.pageId}/index.mdx`)
588
828
  ];
589
829
  let content = "";
590
830
  for (const c of candidates) {
591
- if (existsSync5(c)) {
592
- const raw = readFileSync5(c, "utf8");
831
+ if (existsSync6(c)) {
832
+ const raw = readFileSync6(c, "utf8");
593
833
  const { content: body } = parseFrontmatter(raw);
594
834
  content = body.trim();
595
835
  break;
@@ -613,12 +853,12 @@ async function handleGetContext(input) {
613
853
  }
614
854
 
615
855
  // src/tools/lint-project.ts
616
- import { z as z12 } from "zod";
617
- import { existsSync as existsSync6, readFileSync as readFileSync6 } from "fs";
618
- import { join as join7 } from "path";
619
- var lintProjectSchema = z12.object({
620
- projectDir: z12.string().describe("Path to the Thally project root"),
621
- fix: z12.boolean().optional().default(false).describe("Auto-fix issues where possible (adds orphan pages to nav)")
856
+ import { z as z13 } from "zod";
857
+ import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
858
+ import { join as join8 } from "path";
859
+ var lintProjectSchema = z13.object({
860
+ projectDir: z13.string().describe("Path to the Thally project root"),
861
+ fix: z13.boolean().optional().default(false).describe("Auto-fix issues where possible (adds orphan pages to nav)")
622
862
  });
623
863
  function collectNavPageIds(groups, seen, duplicates) {
624
864
  for (const page of groups) {
@@ -646,9 +886,9 @@ function addOrphanToNav(projectDir, pageId) {
646
886
  }
647
887
  async function handleLintProject(input) {
648
888
  const { projectDir, fix = false } = input;
649
- const contentDir = join7(projectDir, "src", "content");
889
+ const contentDir = join8(projectDir, "src", "content");
650
890
  const issues = [];
651
- if (!existsSync6(join7(projectDir, "docs.json"))) {
891
+ if (!existsSync7(join8(projectDir, "docs.json"))) {
652
892
  throw new Error(`Not a Thally project: docs.json not found in ${projectDir}`);
653
893
  }
654
894
  const config = readDocsJson(projectDir);
@@ -667,10 +907,10 @@ async function handleLintProject(input) {
667
907
  }
668
908
  for (const pageId of navPageIds) {
669
909
  const candidates = [
670
- join7(contentDir, `${pageId}.mdx`),
671
- join7(contentDir, `${pageId}/index.mdx`)
910
+ join8(contentDir, `${pageId}.mdx`),
911
+ join8(contentDir, `${pageId}/index.mdx`)
672
912
  ];
673
- if (!candidates.some((c) => existsSync6(c))) {
913
+ if (!candidates.some((c) => existsSync7(c))) {
674
914
  issues.push({
675
915
  severity: "error",
676
916
  message: `"${pageId}" is in docs.json but has no MDX file`,
@@ -679,7 +919,7 @@ async function handleLintProject(input) {
679
919
  }
680
920
  }
681
921
  const allFiles = [];
682
- if (existsSync6(contentDir)) {
922
+ if (existsSync7(contentDir)) {
683
923
  scanMdxFiles(contentDir, allFiles);
684
924
  }
685
925
  const fixedOrphans = [];
@@ -697,7 +937,7 @@ async function handleLintProject(input) {
697
937
  let data = {};
698
938
  let content = "";
699
939
  try {
700
- const raw = readFileSync6(filePath, "utf8");
940
+ const raw = readFileSync7(filePath, "utf8");
701
941
  const parsed = parseFrontmatter(raw);
702
942
  data = parsed.data;
703
943
  content = parsed.content;
@@ -753,22 +993,22 @@ async function handleLintProject(input) {
753
993
  }
754
994
 
755
995
  // src/tools/translate-docs.ts
756
- import { z as z13 } from "zod";
757
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync5, existsSync as existsSync7, mkdirSync as mkdirSync2 } from "fs";
758
- import { join as join8, dirname as dirname2 } from "path";
996
+ import { z as z14 } from "zod";
997
+ import { readFileSync as readFileSync8, writeFileSync as writeFileSync6, existsSync as existsSync8, mkdirSync as mkdirSync2 } from "fs";
998
+ import { join as join9, dirname as dirname2 } from "path";
759
999
  import Anthropic from "@anthropic-ai/sdk";
760
1000
  import pLimit from "p-limit";
761
- var translateDocsSchema = z13.object({
762
- projectDir: z13.string().describe("Path to the Thally project directory"),
763
- locale: z13.string().describe('Target locale code, e.g. "es", "fr"'),
764
- pages: z13.array(z13.string()).optional().describe("Page IDs to translate (omit for all pages)"),
765
- force: z13.boolean().optional().default(false).describe("Overwrite existing translation files"),
766
- apiKey: z13.string().optional().describe("Anthropic API key (falls back to ANTHROPIC_API_KEY env var)"),
767
- model: z13.string().optional().default("claude-sonnet-4-6").describe("Claude model to use for translation")
1001
+ var translateDocsSchema = z14.object({
1002
+ projectDir: z14.string().describe("Path to the Thally project directory"),
1003
+ locale: z14.string().describe('Target locale code, e.g. "es", "fr"'),
1004
+ pages: z14.array(z14.string()).optional().describe("Page IDs to translate (omit for all pages)"),
1005
+ force: z14.boolean().optional().default(false).describe("Overwrite existing translation files"),
1006
+ apiKey: z14.string().optional().describe("Anthropic API key (falls back to ANTHROPIC_API_KEY env var)"),
1007
+ model: z14.string().optional().default("claude-sonnet-4-6").describe("Claude model to use for translation")
768
1008
  });
769
1009
  function readDocsJson2(projectDir) {
770
- const docsPath = join8(projectDir, "docs.json");
771
- const raw = readFileSync7(docsPath, "utf8");
1010
+ const docsPath = join9(projectDir, "docs.json");
1011
+ const raw = readFileSync8(docsPath, "utf8");
772
1012
  return JSON.parse(raw);
773
1013
  }
774
1014
  function collectPageIds(pages) {
@@ -810,12 +1050,12 @@ function getAllPageIds(config) {
810
1050
  return { ids, hrefOnlyPages };
811
1051
  }
812
1052
  function findSourceFile(projectDir, pageId) {
813
- const contentRoot = join8(projectDir, "src", "content");
1053
+ const contentRoot = join9(projectDir, "src", "content");
814
1054
  const candidates = [
815
- join8(contentRoot, `${pageId}.mdx`),
816
- join8(contentRoot, `${pageId}/index.mdx`)
1055
+ join9(contentRoot, `${pageId}.mdx`),
1056
+ join9(contentRoot, `${pageId}/index.mdx`)
817
1057
  ];
818
- return candidates.find((p) => existsSync7(p)) ?? null;
1058
+ return candidates.find((p) => existsSync8(p)) ?? null;
819
1059
  }
820
1060
  var TRANSLATION_SYSTEM_PROMPT = `You are a professional documentation translator. You will receive an MDX documentation file and translate it into the target language.
821
1061
 
@@ -867,7 +1107,7 @@ async function handleTranslateDocs(input) {
867
1107
  }
868
1108
  const { ids: allPageIds, hrefOnlyPages } = getAllPageIds(config);
869
1109
  const targetPageIds = pages ?? allPageIds;
870
- const contentRoot = join8(projectDir, "src", "content");
1110
+ const contentRoot = join9(projectDir, "src", "content");
871
1111
  const toTranslate = [];
872
1112
  const skipped = [];
873
1113
  for (const pageId of targetPageIds) {
@@ -877,8 +1117,8 @@ async function handleTranslateDocs(input) {
877
1117
  continue;
878
1118
  }
879
1119
  const relativeFromContent = sourceFile.slice(contentRoot.length + 1);
880
- const targetFile = join8(contentRoot, locale, relativeFromContent);
881
- if (existsSync7(targetFile) && !force) {
1120
+ const targetFile = join9(contentRoot, locale, relativeFromContent);
1121
+ if (existsSync8(targetFile) && !force) {
882
1122
  skipped.push(`${pageId} (already translated)`);
883
1123
  continue;
884
1124
  }
@@ -894,14 +1134,14 @@ async function handleTranslateDocs(input) {
894
1134
  toTranslate.map(
895
1135
  ({ pageId, sourceFile, targetFile }) => limit(async () => {
896
1136
  try {
897
- const sourceContent = readFileSync7(sourceFile, "utf8");
1137
+ const sourceContent = readFileSync8(sourceFile, "utf8");
898
1138
  const parsed = parseFrontmatter(sourceContent);
899
1139
  if (!parsed.data.title) {
900
1140
  console.warn(`[translate] ${pageId}: missing title in frontmatter`);
901
1141
  }
902
1142
  const translated = await translatePage(sourceContent, targetLocale.label, locale, model, client);
903
1143
  mkdirSync2(dirname2(targetFile), { recursive: true });
904
- writeFileSync5(targetFile, translated + "\n", "utf8");
1144
+ writeFileSync6(targetFile, translated + "\n", "utf8");
905
1145
  results.push({ pageId, success: true });
906
1146
  } catch (err) {
907
1147
  const msg = err instanceof Error ? err.message : String(err);
@@ -941,19 +1181,30 @@ async function handleTranslateDocs(input) {
941
1181
  }
942
1182
 
943
1183
  // src/tools/sync-from-repo.ts
944
- import { z as z14 } from "zod";
1184
+ import { z as z15 } from "zod";
945
1185
 
946
1186
  // src/lib/track.ts
947
- import { createSign, createHash } from "crypto";
1187
+ import { createSign, createHash as createHash3 } from "crypto";
948
1188
  function parseOwnerRepo(spec) {
949
1189
  const trimmed = spec.trim();
950
1190
  const url = trimmed.match(
951
1191
  /^https?:\/\/github\.com\/([^/\s]+)\/([^/\s#?]+?)(?:\.git)?(?:\/pull\/(\d+))?(?:[/#?].*)?$/i
952
1192
  );
953
- if (url) return { owner: url[1], repo: url[2], ...url[3] ? { pr: Number(url[3]) } : {} };
954
- const plain = trimmed.match(/^([A-Za-z0-9-_.]+)\/([A-Za-z0-9-_.]+?)(?:#(\d+))?$/);
1193
+ if (url)
1194
+ return {
1195
+ owner: url[1],
1196
+ repo: url[2],
1197
+ ...url[3] ? { pr: Number(url[3]) } : {}
1198
+ };
1199
+ const plain = trimmed.match(
1200
+ /^([A-Za-z0-9-_.]+)\/([A-Za-z0-9-_.]+?)(?:#(\d+))?$/
1201
+ );
955
1202
  if (!plain) return null;
956
- return { owner: plain[1], repo: plain[2], ...plain[3] ? { pr: Number(plain[3]) } : {} };
1203
+ return {
1204
+ owner: plain[1],
1205
+ repo: plain[2],
1206
+ ...plain[3] ? { pr: Number(plain[3]) } : {}
1207
+ };
957
1208
  }
958
1209
  function base64url(input) {
959
1210
  return Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
@@ -962,22 +1213,34 @@ var installationTokenCache = /* @__PURE__ */ new Map();
962
1213
  function createAppJwt(appId, privateKey) {
963
1214
  const now = Math.floor(Date.now() / 1e3);
964
1215
  const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
965
- const payload = base64url(JSON.stringify({ iat: now - 60, exp: now + 9 * 60, iss: String(appId) }));
966
- const signature = base64url(createSign("RSA-SHA256").update(`${header}.${payload}`).sign(privateKey));
1216
+ const payload = base64url(
1217
+ JSON.stringify({ iat: now - 60, exp: now + 9 * 60, iss: String(appId) })
1218
+ );
1219
+ const signature = base64url(
1220
+ createSign("RSA-SHA256").update(`${header}.${payload}`).sign(privateKey)
1221
+ );
967
1222
  return `${header}.${payload}.${signature}`;
968
1223
  }
969
1224
  async function mintInstallationToken(creds, fetchImpl = fetch) {
970
- const keyFp = createHash("sha256").update(creds.privateKey).digest("hex").slice(0, 12);
1225
+ const keyFp = createHash3("sha256").update(creds.privateKey).digest("hex").slice(0, 12);
971
1226
  const cacheKey = `${creds.appId}:${creds.installationId}:${keyFp}`;
972
1227
  const cached = installationTokenCache.get(cacheKey);
973
1228
  if (cached && cached.expiresAtMs - 6e4 > Date.now()) return cached.token;
974
1229
  const jwt = createAppJwt(creds.appId, creds.privateKey);
975
- const res = await fetchImpl(`https://api.github.com/app/installations/${creds.installationId}/access_tokens`, {
976
- method: "POST",
977
- headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${jwt}` }
978
- });
1230
+ const res = await fetchImpl(
1231
+ `https://api.github.com/app/installations/${creds.installationId}/access_tokens`,
1232
+ {
1233
+ method: "POST",
1234
+ headers: {
1235
+ Accept: "application/vnd.github+json",
1236
+ Authorization: `Bearer ${jwt}`
1237
+ }
1238
+ }
1239
+ );
979
1240
  if (!res.ok) {
980
- throw new Error(`GitHub App token exchange failed (${res.status}) \u2014 check the app id, installation id, and private key.`);
1241
+ throw new Error(
1242
+ `GitHub App token exchange failed (${res.status}) \u2014 check the app id, installation id, and private key.`
1243
+ );
981
1244
  }
982
1245
  const body = await res.json();
983
1246
  const parsed = Date.parse(body.expires_at);
@@ -989,7 +1252,8 @@ function envAppCreds() {
989
1252
  const appId = (process.env.THALLY_GITHUB_APP_ID ?? process.env.DOX_GITHUB_APP_ID)?.trim();
990
1253
  const installationId = (process.env.THALLY_GITHUB_APP_INSTALLATION_ID ?? process.env.DOX_GITHUB_APP_INSTALLATION_ID)?.trim();
991
1254
  const privateKey = process.env.THALLY_GITHUB_APP_PRIVATE_KEY ?? process.env.DOX_GITHUB_APP_PRIVATE_KEY;
992
- if (appId && installationId && privateKey) return { appId, installationId, privateKey };
1255
+ if (appId && installationId && privateKey)
1256
+ return { appId, installationId, privateKey };
993
1257
  return void 0;
994
1258
  }
995
1259
  async function resolveGithubToken(options) {
@@ -1000,7 +1264,9 @@ async function resolveGithubToken(options) {
1000
1264
  try {
1001
1265
  return await mintInstallationToken(appCreds, options?.fetchImpl ?? fetch);
1002
1266
  } catch (err) {
1003
- console.warn(`[thally-track] GitHub App token mint failed, falling back to PAT: ${err instanceof Error ? err.message : String(err)}`);
1267
+ console.warn(
1268
+ `[thally-track] GitHub App token mint failed, falling back to PAT: ${err instanceof Error ? err.message : String(err)}`
1269
+ );
1004
1270
  return pat;
1005
1271
  }
1006
1272
  }
@@ -1009,9 +1275,13 @@ async function resolveGithubToken(options) {
1009
1275
  async function githubJson(path, options) {
1010
1276
  const fetchImpl = options?.fetchImpl ?? fetch;
1011
1277
  const token = await resolveGithubToken(options);
1012
- const headers = { Accept: "application/vnd.github+json" };
1278
+ const headers = {
1279
+ Accept: "application/vnd.github+json"
1280
+ };
1013
1281
  if (token) headers.Authorization = `Bearer ${token}`;
1014
- const response = await fetchImpl(`https://api.github.com${path}`, { headers });
1282
+ const response = await fetchImpl(`https://api.github.com${path}`, {
1283
+ headers
1284
+ });
1015
1285
  if (!response.ok) {
1016
1286
  const hint = response.status === 404 || response.status === 403 ? " (private repo or rate limit? set THALLY_GITHUB_TOKEN or connect a GitHub App)" : "";
1017
1287
  throw new Error(`GitHub API ${response.status} for ${path}${hint}`);
@@ -1030,7 +1300,10 @@ function toPullRequestInfo(raw) {
1030
1300
  };
1031
1301
  }
1032
1302
  async function fetchPullRequest(owner, repo, number, options) {
1033
- const raw = await githubJson(`/repos/${owner}/${repo}/pulls/${number}`, options);
1303
+ const raw = await githubJson(
1304
+ `/repos/${owner}/${repo}/pulls/${number}`,
1305
+ options
1306
+ );
1034
1307
  return toPullRequestInfo(raw);
1035
1308
  }
1036
1309
  async function fetchPullRequestFiles(owner, repo, number, options) {
@@ -1038,7 +1311,10 @@ async function fetchPullRequestFiles(owner, repo, number, options) {
1038
1311
  const maxPages = 30;
1039
1312
  const raw = [];
1040
1313
  for (let page = 1; page <= maxPages; page++) {
1041
- const chunk = await githubJson(`/repos/${owner}/${repo}/pulls/${number}/files?per_page=${perPage}&page=${page}`, options);
1314
+ const chunk = await githubJson(
1315
+ `/repos/${owner}/${repo}/pulls/${number}/files?per_page=${perPage}&page=${page}`,
1316
+ options
1317
+ );
1042
1318
  raw.push(...chunk);
1043
1319
  if (chunk.length < perPage) break;
1044
1320
  }
@@ -1055,7 +1331,9 @@ async function fetchLatestMergedPr(owner, repo, base, options) {
1055
1331
  `/repos/${owner}/${repo}/pulls?state=closed&base=${encodeURIComponent(base)}&sort=updated&direction=desc&per_page=30`,
1056
1332
  options
1057
1333
  );
1058
- const merged = raw.filter((pr) => Boolean(pr.merged_at)).sort((a, b) => Date.parse(b.merged_at ?? "") - Date.parse(a.merged_at ?? ""))[0];
1334
+ const merged = raw.filter((pr) => Boolean(pr.merged_at)).sort(
1335
+ (a, b) => Date.parse(b.merged_at ?? "") - Date.parse(a.merged_at ?? "")
1336
+ )[0];
1059
1337
  return merged ? toPullRequestInfo(merged) : null;
1060
1338
  }
1061
1339
  function compileGlob(pattern) {
@@ -1128,16 +1406,19 @@ _(diff truncated \u2014 ${files.length} file(s) total)_
1128
1406
  return context.slice(0, TRACK_CONTEXT_CHAR_CAP);
1129
1407
  }
1130
1408
  function buildTrackTask(repo, pr, files) {
1131
- return { instruction: buildTrackInstruction(repo, pr), context: buildTrackContext(repo, pr, files) };
1409
+ return {
1410
+ instruction: buildTrackInstruction(repo, pr),
1411
+ context: buildTrackContext(repo, pr, files)
1412
+ };
1132
1413
  }
1133
1414
 
1134
1415
  // src/tools/sync-from-repo.ts
1135
- var syncFromRepoSchema = z14.object({
1136
- projectDir: z14.string().describe("Path to the Thally project (reads the tracking config from docs.json)"),
1137
- repo: z14.string().optional().describe("Tracked repo to sync as owner/repo (defaults to the single tracked repo when only one is configured)"),
1138
- pr: z14.number().optional().describe("Pull request number to analyze (defaults to the latest PR merged into the tracked base branch)"),
1139
- dryRun: z14.boolean().optional().default(true).describe("When true (default), preview the distilled docs task without dispatching anything"),
1140
- docsRepo: z14.string().optional().describe("owner/repo of the docs repository to dispatch the task to (required when dryRun is false)")
1416
+ var syncFromRepoSchema = z15.object({
1417
+ projectDir: z15.string().describe("Path to the Thally project (reads the tracking config from docs.json)"),
1418
+ repo: z15.string().optional().describe("Tracked repo to sync as owner/repo (defaults to the single tracked repo when only one is configured)"),
1419
+ pr: z15.number().optional().describe("Pull request number to analyze (defaults to the latest PR merged into the tracked base branch)"),
1420
+ dryRun: z15.boolean().optional().default(true).describe("When true (default), preview the distilled docs task without dispatching anything"),
1421
+ docsRepo: z15.string().optional().describe("owner/repo of the docs repository to dispatch the task to (required when dryRun is false)")
1141
1422
  });
1142
1423
  async function handleSyncFromRepo(input) {
1143
1424
  const config = readDocsJson(input.projectDir);
@@ -1209,12 +1490,11 @@ async function handleSyncFromRepo(input) {
1209
1490
  }
1210
1491
 
1211
1492
  // src/tools/read-api-spec.ts
1212
- import { readFileSync as readFileSync8 } from "fs";
1213
- import { z as z15 } from "zod";
1493
+ import { z as z16 } from "zod";
1214
1494
 
1215
1495
  // src/lib/api-spec.ts
1216
- import { existsSync as existsSync8, lstatSync, realpathSync } from "fs";
1217
- import { isAbsolute, join as join9, posix, relative as relative2, resolve, sep } from "path";
1496
+ import { existsSync as existsSync9, lstatSync as lstatSync3, realpathSync as realpathSync3 } from "fs";
1497
+ import { isAbsolute as isAbsolute3, join as join10, posix, relative as relative4, resolve, sep as sep3 } from "path";
1218
1498
  var CONVENTIONAL_API_SOURCES = ["openapi.yaml", "openapi.yml", "openapi.json"];
1219
1499
  function normalizeApiSource(source) {
1220
1500
  const normalized = posix.normalize(source.trim().replace(/^\/+/, ""));
@@ -1226,7 +1506,7 @@ function normalizeApiSource(source) {
1226
1506
  function configuredApiSources(projectDir) {
1227
1507
  const configured = readDocsJson(projectDir).tabs.map((tab) => tab.api?.source).filter((source) => typeof source === "string").map(normalizeApiSource).filter((source) => source !== null);
1228
1508
  const conventional = CONVENTIONAL_API_SOURCES.filter(
1229
- (source) => existsSync8(join9(projectDir, source))
1509
+ (source) => existsSync9(join10(projectDir, source))
1230
1510
  );
1231
1511
  return [.../* @__PURE__ */ new Set([...configured, ...conventional])];
1232
1512
  }
@@ -1250,12 +1530,12 @@ function resolveApiSource(projectDir, requested) {
1250
1530
  }
1251
1531
  function resolveApiSourcePath(projectDir, requested) {
1252
1532
  const source = resolveApiSource(projectDir, requested);
1253
- const projectRoot = realpathSync(projectDir);
1533
+ const projectRoot = realpathSync3(projectDir);
1254
1534
  const candidate = resolve(projectRoot, source);
1255
1535
  try {
1256
- const resolved = realpathSync(candidate);
1257
- const fromRoot = relative2(projectRoot, resolved);
1258
- if (!fromRoot || fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot) || !lstatSync(candidate).isFile() || lstatSync(candidate).isSymbolicLink()) {
1536
+ const resolved = realpathSync3(candidate);
1537
+ const fromRoot = relative4(projectRoot, resolved);
1538
+ if (!fromRoot || fromRoot === ".." || fromRoot.startsWith(`..${sep3}`) || isAbsolute3(fromRoot) || !lstatSync3(candidate).isFile() || lstatSync3(candidate).isSymbolicLink()) {
1259
1539
  throw new Error("unsafe");
1260
1540
  }
1261
1541
  return { source, path: resolved };
@@ -1265,24 +1545,33 @@ function resolveApiSourcePath(projectDir, requested) {
1265
1545
  }
1266
1546
 
1267
1547
  // src/tools/read-api-spec.ts
1268
- var readApiSpecSchema = z15.object({
1269
- projectDir: z15.string().describe("Path to the Thally project root"),
1270
- source: z15.string().optional().describe("Configured OpenAPI source; omit when the project has one")
1548
+ var readApiSpecSchema = z16.object({
1549
+ projectDir: z16.string().describe("Path to the Thally project root"),
1550
+ source: z16.string().optional().describe("Configured OpenAPI source; omit when the project has one"),
1551
+ startByte: z16.number().int().min(0).optional().describe("UTF-8 byte continuation from a previous partial result"),
1552
+ startLine: z16.number().int().min(1).optional().describe("1-based source line to start at; do not combine with startByte"),
1553
+ maxBytes: z16.number().int().min(1).max(MODEL_READ_WINDOW_MAX_BYTES).optional().describe(
1554
+ `Maximum source bytes to return (default 49152, maximum ${MODEL_READ_WINDOW_MAX_BYTES})`
1555
+ )
1271
1556
  });
1272
1557
  async function handleReadApiSpec(input) {
1273
1558
  const source = resolveApiSourcePath(input.projectDir, input.source);
1274
- return [`API source: ${source.source}`, "", readFileSync8(source.path, "utf8")].join("\n");
1559
+ const window = createTextWindow(readModelTextFile(source.path), input);
1560
+ return [
1561
+ `API source: ${source.source}`,
1562
+ ...renderTextWindow(window).split("\n")
1563
+ ].join("\n");
1275
1564
  }
1276
1565
 
1277
1566
  // src/tools/update-api-spec.ts
1278
- import { writeFileSync as writeFileSync6 } from "fs";
1567
+ import { writeFileSync as writeFileSync7 } from "fs";
1279
1568
  import { extname as extname2 } from "path";
1280
- import { z as z16 } from "zod";
1569
+ import { z as z17 } from "zod";
1281
1570
  import { parse as parseYaml } from "yaml";
1282
- var updateApiSpecSchema = z16.object({
1283
- projectDir: z16.string().describe("Path to the Thally project root"),
1284
- source: z16.string().optional().describe("Configured OpenAPI source; omit when the project has one"),
1285
- content: z16.string().min(2).max(512e3).describe("Complete replacement OpenAPI JSON or YAML document")
1571
+ var updateApiSpecSchema = z17.object({
1572
+ projectDir: z17.string().describe("Path to the Thally project root"),
1573
+ source: z17.string().optional().describe("Configured OpenAPI source; omit when the project has one"),
1574
+ content: z17.string().min(2).max(512e3).describe("Complete replacement OpenAPI JSON or YAML document")
1286
1575
  });
1287
1576
  function parseDocument(source, content) {
1288
1577
  try {
@@ -1301,7 +1590,7 @@ async function handleUpdateApiSpec(input) {
1301
1590
  }
1302
1591
  const content = input.content.endsWith("\n") ? input.content : `${input.content}
1303
1592
  `;
1304
- writeFileSync6(source.path, content, "utf8");
1593
+ writeFileSync7(source.path, content, "utf8");
1305
1594
  return `\u2705 API source updated: ${source.source}`;
1306
1595
  }
1307
1596
 
@@ -1345,9 +1634,16 @@ var tools = [
1345
1634
  schema: updatePageSchema,
1346
1635
  handler: handleUpdatePage
1347
1636
  }),
1637
+ defineTool({
1638
+ name: "replace_page_text",
1639
+ description: "Replace one exact unique span in an existing MDX page; prefer this for small edits so the full page never travels through the model response",
1640
+ scope: "project",
1641
+ schema: replacePageTextSchema,
1642
+ handler: handleReplacePageText
1643
+ }),
1348
1644
  defineTool({
1349
1645
  name: "read_api_spec",
1350
- description: "Read an OpenAPI JSON or YAML source explicitly configured by this Thally project",
1646
+ description: "Read a bounded UTF-8 window from an OpenAPI JSON or YAML source configured by this project; follow next-start-byte until complete",
1351
1647
  scope: "project",
1352
1648
  schema: readApiSpecSchema,
1353
1649
  handler: handleReadApiSpec
@@ -1396,7 +1692,7 @@ var tools = [
1396
1692
  }),
1397
1693
  defineTool({
1398
1694
  name: "read_page",
1399
- description: "Read the full content of a documentation page by its page ID",
1695
+ description: "Read a bounded UTF-8 body window from a documentation page; follow next-start-byte until complete before replacing a page",
1400
1696
  scope: "project",
1401
1697
  schema: readPageSchema,
1402
1698
  handler: handleReadPage