@thallylabs/mcp 0.10.20 → 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/index.js CHANGED
@@ -333,24 +333,106 @@ async function handleUpdatePage(input) {
333
333
  ].join("\n");
334
334
  }
335
335
 
336
- // src/tools/migrate-docs.ts
336
+ // src/tools/replace-page-text.ts
337
+ import { createHash } from "crypto";
338
+ import {
339
+ existsSync as existsSync3,
340
+ lstatSync,
341
+ readFileSync as readFileSync3,
342
+ realpathSync,
343
+ writeFileSync as writeFileSync4
344
+ } from "fs";
345
+ import { isAbsolute, join as join4, relative, sep } from "path";
337
346
  import { z as z6 } from "zod";
347
+ var MAX_REPLACEMENT_BYTES = 64 * 1024;
348
+ var replacePageTextSchema = z6.object({
349
+ projectDir: z6.string().describe("Path to the Thally project root"),
350
+ pageId: z6.string().describe(
351
+ 'Page identifier (for example "guides/auth"). No .mdx extension.'
352
+ ),
353
+ oldText: z6.string().min(1).max(MAX_REPLACEMENT_BYTES).describe("Exact existing text to replace; it must occur exactly once"),
354
+ newText: z6.string().min(1).max(MAX_REPLACEMENT_BYTES).describe(
355
+ "Complete replacement prose for oldText; never include a Track evidence marker"
356
+ ),
357
+ evidenceReferenceId: z6.string().min(1).max(128).optional().describe(
358
+ "Exact evidence reference ID supplied by Track. When present, the tool appends its deterministic citation marker."
359
+ )
360
+ });
361
+ function isInside(root, candidate) {
362
+ const fromRoot = relative(root, candidate);
363
+ return Boolean(fromRoot) && fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot);
364
+ }
365
+ function pageFile(projectDir, pageId) {
366
+ if (!/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/u.test(pageId) || pageId.split("/").some((part) => part.toLowerCase() === ".git")) {
367
+ throw new Error("Invalid pageId.");
368
+ }
369
+ const contentRoot = realpathSync(join4(projectDir, "src", "content"));
370
+ const candidates = [
371
+ join4(projectDir, "src", "content", `${pageId}.mdx`),
372
+ join4(projectDir, "src", "content", pageId, "index.mdx")
373
+ ];
374
+ const existing = candidates.filter((candidate2) => existsSync3(candidate2));
375
+ if (existing.length !== 1) throw new Error("Page not found or ambiguous.");
376
+ const candidate = existing[0];
377
+ const metadata = lstatSync(candidate);
378
+ if (!metadata.isFile() || metadata.isSymbolicLink() || !isInside(contentRoot, realpathSync(candidate))) {
379
+ throw new Error("Page must be a regular file inside src/content.");
380
+ }
381
+ return candidate;
382
+ }
383
+ function consumedTrailingLineSeparator(value) {
384
+ return value.match(/(\r?\n)[^\S\r\n]*$/u)?.[1] ?? "";
385
+ }
386
+ async function handleReplacePageText(input) {
387
+ if (input.oldText.includes("\0") || input.newText.includes("\0") || input.oldText === input.newText || input.newText.trim().length === 0) {
388
+ throw new Error("Replacement text is invalid.");
389
+ }
390
+ const filePath = pageFile(input.projectDir, input.pageId);
391
+ const source = readFileSync3(filePath, "utf8");
392
+ const first = source.indexOf(input.oldText);
393
+ if (first < 0 || source.indexOf(input.oldText, first + input.oldText.length) >= 0) {
394
+ throw new Error("oldText must match exactly one span.");
395
+ }
396
+ const marker = input.evidenceReferenceId ? `<!-- thally-cite:v1:${createHash("sha256").update(`evidence\0${input.evidenceReferenceId}`, "utf8").digest("hex")} -->` : null;
397
+ if (marker && input.newText.includes(marker)) {
398
+ throw new Error("Replacement text must not include its citation marker.");
399
+ }
400
+ const suffixSeparator = marker ? consumedTrailingLineSeparator(input.oldText) : "";
401
+ const citedReplacement = marker ? `${input.newText.replace(/\s*$/u, "")}
402
+ ${marker}` : input.newText;
403
+ const replacement = `${citedReplacement}${suffixSeparator}`;
404
+ writeFileSync4(
405
+ filePath,
406
+ `${source.slice(0, first)}${replacement}${source.slice(first + input.oldText.length)}`,
407
+ "utf8"
408
+ );
409
+ const startLine = source.slice(0, first).split("\n").length;
410
+ const endLine = startLine + citedReplacement.split("\n").length - 1;
411
+ return [
412
+ `\u2705 Page text replaced: ${input.pageId}`,
413
+ `Final replacement lines: ${startLine}-${endLine}.`,
414
+ "Use this exact final line range for the corresponding factual claim."
415
+ ].join("\n");
416
+ }
417
+
418
+ // src/tools/migrate-docs.ts
419
+ import { z as z7 } from "zod";
338
420
  import { migrateDocs } from "create-thally-docs/migrate";
339
421
  var migrationSourceShape = {
340
- sourceUrl: z6.string().describe("GitHub repository URL or public documentation URL to migrate"),
341
- branch: z6.string().optional().describe("Git branch (default: auto-detect)"),
342
- docsDir: z6.string().optional().describe("Docs subdirectory in repo (default: auto-detect)"),
343
- apiKey: z6.string().optional().describe("Anthropic API key for non-Markdown file conversion"),
344
- maxPages: z6.number().int().min(1).max(1e3).optional().describe("Maximum public URL pages to import"),
345
- platform: z6.enum(["mintlify", "docusaurus"]).optional().describe("Source platform (default: auto-detect)")
422
+ sourceUrl: z7.string().describe("GitHub repository URL or public documentation URL to migrate"),
423
+ branch: z7.string().optional().describe("Git branch (default: auto-detect)"),
424
+ docsDir: z7.string().optional().describe("Docs subdirectory in repo (default: auto-detect)"),
425
+ apiKey: z7.string().optional().describe("Anthropic API key for non-Markdown file conversion"),
426
+ maxPages: z7.number().int().min(1).max(1e3).optional().describe("Maximum public URL pages to import"),
427
+ platform: z7.enum(["mintlify", "docusaurus"]).optional().describe("Source platform (default: auto-detect)")
346
428
  };
347
- var migrateDocsSchema = z6.object({
429
+ var migrateDocsSchema = z7.object({
348
430
  ...migrationSourceShape,
349
- projectDir: z6.string().describe("Path for the new canonical Thally project; the directory must be absent or empty")
431
+ projectDir: z7.string().describe("Path for the new canonical Thally project; the directory must be absent or empty")
350
432
  });
351
- var importDocsSchema = z6.object({
433
+ var importDocsSchema = z7.object({
352
434
  ...migrationSourceShape,
353
- projectDir: z6.string().describe("Path to an existing Thally project whose runtime should be preserved")
435
+ projectDir: z7.string().describe("Path to an existing Thally project whose runtime should be preserved")
354
436
  });
355
437
  async function runMigration(input, isInPlaceImport) {
356
438
  const apiKey = input.apiKey ?? process.env.ANTHROPIC_API_KEY;
@@ -378,13 +460,13 @@ async function handleImportDocs(input) {
378
460
  }
379
461
 
380
462
  // src/tools/search-docs.ts
381
- import { z as z7 } from "zod";
382
- import { readdirSync, statSync, readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
383
- import { join as join4, relative, extname } from "path";
384
- var searchDocsSchema = z7.object({
385
- projectDir: z7.string().describe("Path to the Thally project root"),
386
- query: z7.string().describe("Search query"),
387
- limit: z7.number().optional().default(5).describe("Max results to return (default 5)")
463
+ import { z as z8 } from "zod";
464
+ import { readdirSync, statSync, readFileSync as readFileSync4, existsSync as existsSync4 } from "fs";
465
+ import { join as join5, relative as relative2, extname } from "path";
466
+ var searchDocsSchema = z8.object({
467
+ projectDir: z8.string().describe("Path to the Thally project root"),
468
+ query: z8.string().describe("Search query"),
469
+ limit: z8.number().optional().default(5).describe("Max results to return (default 5)")
388
470
  });
389
471
  function scanMdxFiles(dir, results) {
390
472
  let entries;
@@ -394,7 +476,7 @@ function scanMdxFiles(dir, results) {
394
476
  return;
395
477
  }
396
478
  for (const entry of entries) {
397
- const fullPath = join4(dir, entry);
479
+ const fullPath = join5(dir, entry);
398
480
  try {
399
481
  const stat = statSync(fullPath);
400
482
  if (stat.isDirectory()) {
@@ -412,7 +494,7 @@ function scoreFiles(files, contentDir, query) {
412
494
  for (const filePath of files) {
413
495
  let raw;
414
496
  try {
415
- raw = readFileSync3(filePath, "utf8");
497
+ raw = readFileSync4(filePath, "utf8");
416
498
  } catch {
417
499
  continue;
418
500
  }
@@ -420,7 +502,7 @@ function scoreFiles(files, contentDir, query) {
420
502
  const title = data.title ?? "";
421
503
  const description = data.description ?? "";
422
504
  const keywords = data.keywords ?? [];
423
- const pageId = relative(contentDir, filePath).replace(/\.mdx$/, "").replace(/\\/g, "/");
505
+ const pageId = relative2(contentDir, filePath).replace(/\.mdx$/, "").replace(/\\/g, "/");
424
506
  let score = 0;
425
507
  for (const term of terms) {
426
508
  if (title.toLowerCase().includes(term)) score += 3;
@@ -437,8 +519,8 @@ function scoreFiles(files, contentDir, query) {
437
519
  }
438
520
  async function handleSearchDocs(input) {
439
521
  const { projectDir, query, limit = 5 } = input;
440
- const contentDir = join4(projectDir, "src", "content");
441
- if (!existsSync3(contentDir)) {
522
+ const contentDir = join5(projectDir, "src", "content");
523
+ if (!existsSync4(contentDir)) {
442
524
  throw new Error(`Content directory not found: ${contentDir}`);
443
525
  }
444
526
  const files = [];
@@ -458,12 +540,12 @@ async function handleSearchDocs(input) {
458
540
  }
459
541
 
460
542
  // src/tools/semantic-search.ts
461
- import { z as z8 } from "zod";
462
- var semanticSearchSchema = z8.object({
463
- siteUrl: z8.string().describe("Base URL of the deployed Thally site (e.g. https://docs.example.com)"),
464
- query: z8.string().describe("Natural-language search query"),
465
- limit: z8.number().optional().default(8).describe("Max results to return (default 8)"),
466
- mode: z8.enum(["hybrid", "fulltext"]).optional().default("hybrid").describe("Search mode: hybrid (full-text + vector) or fulltext")
543
+ import { z as z9 } from "zod";
544
+ var semanticSearchSchema = z9.object({
545
+ siteUrl: z9.string().describe("Base URL of the deployed Thally site (e.g. https://docs.example.com)"),
546
+ query: z9.string().describe("Natural-language search query"),
547
+ limit: z9.number().optional().default(8).describe("Max results to return (default 8)"),
548
+ mode: z9.enum(["hybrid", "fulltext"]).optional().default("hybrid").describe("Search mode: hybrid (full-text + vector) or fulltext")
467
549
  });
468
550
  async function handleSemanticSearch(input) {
469
551
  const { siteUrl, query, limit = 8, mode = "hybrid" } = input;
@@ -494,10 +576,10 @@ async function handleSemanticSearch(input) {
494
576
  }
495
577
 
496
578
  // src/tools/agent-readiness.ts
497
- import { z as z9 } from "zod";
498
- var agentReadinessSchema = z9.object({
499
- siteUrl: z9.string().describe("Base URL of the deployed Thally site (e.g. https://docs.example.com)"),
500
- minScore: z9.number().optional().describe("Optional threshold (0-100). If set, the summary flags whether the site passes.")
579
+ import { z as z10 } from "zod";
580
+ var agentReadinessSchema = z10.object({
581
+ siteUrl: z10.string().describe("Base URL of the deployed Thally site (e.g. https://docs.example.com)"),
582
+ minScore: z10.number().optional().describe("Optional threshold (0-100). If set, the summary flags whether the site passes.")
501
583
  });
502
584
  async function handleAgentReadiness(input) {
503
585
  const { siteUrl, minScore } = input;
@@ -532,53 +614,211 @@ async function handleAgentReadiness(input) {
532
614
  }
533
615
 
534
616
  // src/tools/read-page.ts
535
- import { z as z10 } from "zod";
536
- import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
537
- import { join as join5 } from "path";
538
- var readPageSchema = z10.object({
539
- projectDir: z10.string().describe("Path to the Thally project root"),
540
- pageId: z10.string().describe('Page ID, e.g. "guides/authentication"')
617
+ import { z as z11 } from "zod";
618
+ import { existsSync as existsSync5, lstatSync as lstatSync2, realpathSync as realpathSync2 } from "fs";
619
+ import { isAbsolute as isAbsolute2, join as join6, relative as relative3, sep as sep2 } from "path";
620
+
621
+ // src/lib/text-window.ts
622
+ import { createHash as createHash2 } from "crypto";
623
+ import {
624
+ closeSync,
625
+ constants,
626
+ fstatSync,
627
+ openSync,
628
+ readFileSync as readFileSync5
629
+ } from "fs";
630
+ var MODEL_READ_WINDOW_DEFAULT_BYTES = 48 * 1024;
631
+ var MODEL_READ_WINDOW_MAX_BYTES = 180 * 1024;
632
+ var MODEL_READ_SOURCE_MAX_BYTES = 8 * 1024 * 1024;
633
+ function readModelTextFile(path) {
634
+ let descriptor;
635
+ try {
636
+ descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
637
+ const metadata = fstatSync(descriptor);
638
+ if (!metadata.isFile() || metadata.size > MODEL_READ_SOURCE_MAX_BYTES) {
639
+ throw new Error("text_source_invalid");
640
+ }
641
+ const bytes = readFileSync5(descriptor);
642
+ if (bytes.byteLength !== metadata.size)
643
+ throw new Error("text_source_invalid");
644
+ return bytes;
645
+ } catch (error) {
646
+ if (error instanceof Error && error.message === "text_source_invalid")
647
+ throw error;
648
+ throw new Error("text_source_invalid");
649
+ } finally {
650
+ if (descriptor !== void 0) closeSync(descriptor);
651
+ }
652
+ }
653
+ function textWindowMetadata(window) {
654
+ const metadata = [
655
+ `content-sha256: ${window.sha256}`,
656
+ window.contentBytes === 0 ? `window-bytes: empty at ${window.startByte} of ${window.totalBytes}` : `window-bytes: ${window.startByte}-${window.startByte + window.contentBytes - 1} of ${window.totalBytes}`,
657
+ `window-lines: ${window.startLine}-${window.endLine} of ${window.totalLines}`,
658
+ `complete: ${window.isComplete}`
659
+ ];
660
+ if (!window.isComplete) {
661
+ metadata.push(
662
+ `next-start-byte: ${window.nextStartByte}`,
663
+ `next-start-line: ${window.nextStartLine}`
664
+ );
665
+ }
666
+ return metadata;
667
+ }
668
+ function countLinesBefore(bytes, end) {
669
+ let lines = 1;
670
+ for (let index = 0; index < end; index += 1) {
671
+ if (bytes[index] === 10) lines += 1;
672
+ }
673
+ return lines;
674
+ }
675
+ function byteOffsetForLine(bytes, requestedLine) {
676
+ if (requestedLine === 1) return 0;
677
+ let line = 1;
678
+ for (let index = 0; index < bytes.byteLength; index += 1) {
679
+ if (bytes[index] !== 10) continue;
680
+ line += 1;
681
+ if (line === requestedLine) return index + 1;
682
+ }
683
+ throw new Error("text_window_start_out_of_range");
684
+ }
685
+ function decodeUtf8(bytes) {
686
+ try {
687
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
688
+ } catch {
689
+ throw new Error("text_window_invalid_utf8");
690
+ }
691
+ }
692
+ function isUtf8Boundary(bytes, offset) {
693
+ return offset === 0 || offset === bytes.byteLength || (bytes[offset] & 192) !== 128;
694
+ }
695
+ function createTextWindow(bytes, request = {}) {
696
+ decodeUtf8(bytes);
697
+ const maxBytes = request.maxBytes ?? MODEL_READ_WINDOW_DEFAULT_BYTES;
698
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MODEL_READ_WINDOW_MAX_BYTES) {
699
+ throw new Error("text_window_size_invalid");
700
+ }
701
+ if (request.startByte !== void 0 && request.startLine !== void 0) {
702
+ throw new Error("text_window_start_ambiguous");
703
+ }
704
+ let startByte;
705
+ if (request.startLine !== void 0) {
706
+ if (!Number.isSafeInteger(request.startLine) || request.startLine < 1) {
707
+ throw new Error("text_window_start_invalid");
708
+ }
709
+ startByte = byteOffsetForLine(bytes, request.startLine);
710
+ } else {
711
+ startByte = request.startByte ?? 0;
712
+ if (!Number.isSafeInteger(startByte) || startByte < 0 || startByte > bytes.byteLength || !isUtf8Boundary(bytes, startByte)) {
713
+ throw new Error("text_window_start_invalid");
714
+ }
715
+ }
716
+ let endByte = Math.min(bytes.byteLength, startByte + maxBytes);
717
+ while (endByte > startByte && !isUtf8Boundary(bytes, endByte)) endByte -= 1;
718
+ if (endByte === startByte && startByte < bytes.byteLength) {
719
+ throw new Error("text_window_size_invalid");
720
+ }
721
+ const contentBytes = endByte - startByte;
722
+ const content = decodeUtf8(bytes.subarray(startByte, endByte));
723
+ const startLine = countLinesBefore(bytes, startByte);
724
+ const endLine = startLine + (content.match(/\n/g)?.length ?? 0);
725
+ const isComplete = endByte === bytes.byteLength;
726
+ const totalLines = countLinesBefore(bytes, bytes.byteLength);
727
+ return {
728
+ content,
729
+ contentBytes,
730
+ endLine,
731
+ isComplete,
732
+ ...isComplete ? {} : { nextStartByte: endByte, nextStartLine: endLine },
733
+ sha256: createHash2("sha256").update(bytes).digest("hex"),
734
+ startByte,
735
+ startLine,
736
+ totalBytes: bytes.byteLength,
737
+ totalLines
738
+ };
739
+ }
740
+ function renderTextWindow(window) {
741
+ return [...textWindowMetadata(window), "", window.content].join("\n");
742
+ }
743
+
744
+ // src/tools/read-page.ts
745
+ var readPageSchema = z11.object({
746
+ projectDir: z11.string().describe("Path to the Thally project root"),
747
+ pageId: z11.string().describe('Page ID, e.g. "guides/authentication"'),
748
+ startByte: z11.number().int().min(0).optional().describe("UTF-8 byte continuation from a previous partial result"),
749
+ startLine: z11.number().int().min(1).optional().describe("1-based body line to start at; do not combine with startByte"),
750
+ maxBytes: z11.number().int().min(1).max(MODEL_READ_WINDOW_MAX_BYTES).optional().describe(
751
+ `Maximum body bytes to return (default 49152, maximum ${MODEL_READ_WINDOW_MAX_BYTES})`
752
+ )
541
753
  });
754
+ function isInside2(root, candidate) {
755
+ const fromRoot = relative3(root, candidate);
756
+ return Boolean(fromRoot) && fromRoot !== ".." && !fromRoot.startsWith(`..${sep2}`) && !isAbsolute2(fromRoot);
757
+ }
542
758
  async function handleReadPage(input) {
543
759
  const { projectDir, pageId } = input;
544
- const contentDir = join5(projectDir, "src", "content");
760
+ const contentDir = join6(projectDir, "src", "content");
761
+ if (!/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/u.test(pageId) || pageId.split("/").some((part) => part.toLowerCase() === ".git")) {
762
+ throw new Error("Page ID must be a safe content-relative identifier");
763
+ }
764
+ const contentRoot = realpathSync2(contentDir);
545
765
  const candidates = [
546
- join5(contentDir, `${pageId}.mdx`),
547
- join5(contentDir, `${pageId}/index.mdx`)
766
+ join6(contentDir, `${pageId}.mdx`),
767
+ join6(contentDir, `${pageId}/index.mdx`)
548
768
  ];
549
769
  let filePath = null;
550
770
  for (const c of candidates) {
551
- if (existsSync4(c)) {
771
+ if (existsSync5(c)) {
772
+ const metadata = lstatSync2(c);
773
+ if (!metadata.isFile() || metadata.isSymbolicLink() || !isInside2(contentRoot, realpathSync2(c))) {
774
+ throw new Error("Page must be a regular file inside src/content");
775
+ }
552
776
  filePath = c;
553
777
  break;
554
778
  }
555
779
  }
556
780
  if (!filePath) {
557
- throw new Error(`Page not found: "${pageId}". No file at src/content/${pageId}.mdx`);
781
+ throw new Error(
782
+ `Page not found: "${pageId}". No file at src/content/${pageId}.mdx`
783
+ );
784
+ }
785
+ const rawBytes = readModelTextFile(filePath);
786
+ let raw;
787
+ try {
788
+ raw = new TextDecoder("utf-8", { fatal: true }).decode(rawBytes);
789
+ } catch {
790
+ throw new Error("Page is not valid UTF-8");
558
791
  }
559
- const raw = readFileSync4(filePath, "utf8");
560
792
  const { data, content } = parseFrontmatter(raw);
561
793
  const title = data.title ?? pageId;
562
794
  const description = data.description ?? "";
563
795
  const lines = [`id: ${pageId}`, `title: ${title}`];
564
796
  if (description) lines.push(`description: ${description}`);
565
- lines.push("", readPageBodyDelimiter(), "", content.trim());
797
+ const window = createTextWindow(Buffer.from(content, "utf8"), input);
798
+ lines.push(
799
+ "",
800
+ ...textWindowMetadata(window),
801
+ "",
802
+ readPageBodyDelimiter(),
803
+ "",
804
+ window.content
805
+ );
566
806
  return lines.join("\n");
567
807
  }
568
808
 
569
809
  // src/tools/get-context.ts
570
- import { z as z11 } from "zod";
571
- import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
572
- import { join as join6 } from "path";
573
- var getContextSchema = z11.object({
574
- projectDir: z11.string().describe("Path to the Thally project root"),
575
- topic: z11.string().describe("Topic or question to find relevant docs for"),
576
- maxTokens: z11.number().optional().default(4e3).describe("Approximate token budget for returned context (default 4000)")
810
+ import { z as z12 } from "zod";
811
+ import { existsSync as existsSync6, readFileSync as readFileSync6 } from "fs";
812
+ import { join as join7 } from "path";
813
+ var getContextSchema = z12.object({
814
+ projectDir: z12.string().describe("Path to the Thally project root"),
815
+ topic: z12.string().describe("Topic or question to find relevant docs for"),
816
+ maxTokens: z12.number().optional().default(4e3).describe("Approximate token budget for returned context (default 4000)")
577
817
  });
578
818
  async function handleGetContext(input) {
579
819
  const { projectDir, topic, maxTokens = 4e3 } = input;
580
- const contentDir = join6(projectDir, "src", "content");
581
- if (!existsSync5(contentDir)) {
820
+ const contentDir = join7(projectDir, "src", "content");
821
+ if (!existsSync6(contentDir)) {
582
822
  throw new Error(`Content directory not found: ${contentDir}`);
583
823
  }
584
824
  const files = [];
@@ -592,13 +832,13 @@ async function handleGetContext(input) {
592
832
  const sections = [];
593
833
  for (const result of scored) {
594
834
  const candidates = [
595
- join6(contentDir, `${result.pageId}.mdx`),
596
- join6(contentDir, `${result.pageId}/index.mdx`)
835
+ join7(contentDir, `${result.pageId}.mdx`),
836
+ join7(contentDir, `${result.pageId}/index.mdx`)
597
837
  ];
598
838
  let content = "";
599
839
  for (const c of candidates) {
600
- if (existsSync5(c)) {
601
- const raw = readFileSync5(c, "utf8");
840
+ if (existsSync6(c)) {
841
+ const raw = readFileSync6(c, "utf8");
602
842
  const { content: body } = parseFrontmatter(raw);
603
843
  content = body.trim();
604
844
  break;
@@ -622,12 +862,12 @@ async function handleGetContext(input) {
622
862
  }
623
863
 
624
864
  // src/tools/lint-project.ts
625
- import { z as z12 } from "zod";
626
- import { existsSync as existsSync6, readFileSync as readFileSync6 } from "fs";
627
- import { join as join7 } from "path";
628
- var lintProjectSchema = z12.object({
629
- projectDir: z12.string().describe("Path to the Thally project root"),
630
- fix: z12.boolean().optional().default(false).describe("Auto-fix issues where possible (adds orphan pages to nav)")
865
+ import { z as z13 } from "zod";
866
+ import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
867
+ import { join as join8 } from "path";
868
+ var lintProjectSchema = z13.object({
869
+ projectDir: z13.string().describe("Path to the Thally project root"),
870
+ fix: z13.boolean().optional().default(false).describe("Auto-fix issues where possible (adds orphan pages to nav)")
631
871
  });
632
872
  function collectNavPageIds(groups, seen, duplicates) {
633
873
  for (const page of groups) {
@@ -655,9 +895,9 @@ function addOrphanToNav(projectDir, pageId) {
655
895
  }
656
896
  async function handleLintProject(input) {
657
897
  const { projectDir, fix = false } = input;
658
- const contentDir = join7(projectDir, "src", "content");
898
+ const contentDir = join8(projectDir, "src", "content");
659
899
  const issues = [];
660
- if (!existsSync6(join7(projectDir, "docs.json"))) {
900
+ if (!existsSync7(join8(projectDir, "docs.json"))) {
661
901
  throw new Error(`Not a Thally project: docs.json not found in ${projectDir}`);
662
902
  }
663
903
  const config = readDocsJson(projectDir);
@@ -676,10 +916,10 @@ async function handleLintProject(input) {
676
916
  }
677
917
  for (const pageId of navPageIds) {
678
918
  const candidates = [
679
- join7(contentDir, `${pageId}.mdx`),
680
- join7(contentDir, `${pageId}/index.mdx`)
919
+ join8(contentDir, `${pageId}.mdx`),
920
+ join8(contentDir, `${pageId}/index.mdx`)
681
921
  ];
682
- if (!candidates.some((c) => existsSync6(c))) {
922
+ if (!candidates.some((c) => existsSync7(c))) {
683
923
  issues.push({
684
924
  severity: "error",
685
925
  message: `"${pageId}" is in docs.json but has no MDX file`,
@@ -688,7 +928,7 @@ async function handleLintProject(input) {
688
928
  }
689
929
  }
690
930
  const allFiles = [];
691
- if (existsSync6(contentDir)) {
931
+ if (existsSync7(contentDir)) {
692
932
  scanMdxFiles(contentDir, allFiles);
693
933
  }
694
934
  const fixedOrphans = [];
@@ -706,7 +946,7 @@ async function handleLintProject(input) {
706
946
  let data = {};
707
947
  let content = "";
708
948
  try {
709
- const raw = readFileSync6(filePath, "utf8");
949
+ const raw = readFileSync7(filePath, "utf8");
710
950
  const parsed = parseFrontmatter(raw);
711
951
  data = parsed.data;
712
952
  content = parsed.content;
@@ -762,22 +1002,22 @@ async function handleLintProject(input) {
762
1002
  }
763
1003
 
764
1004
  // src/tools/translate-docs.ts
765
- import { z as z13 } from "zod";
766
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync5, existsSync as existsSync7, mkdirSync as mkdirSync2 } from "fs";
767
- import { join as join8, dirname as dirname2 } from "path";
1005
+ import { z as z14 } from "zod";
1006
+ import { readFileSync as readFileSync8, writeFileSync as writeFileSync6, existsSync as existsSync8, mkdirSync as mkdirSync2 } from "fs";
1007
+ import { join as join9, dirname as dirname2 } from "path";
768
1008
  import Anthropic from "@anthropic-ai/sdk";
769
1009
  import pLimit from "p-limit";
770
- var translateDocsSchema = z13.object({
771
- projectDir: z13.string().describe("Path to the Thally project directory"),
772
- locale: z13.string().describe('Target locale code, e.g. "es", "fr"'),
773
- pages: z13.array(z13.string()).optional().describe("Page IDs to translate (omit for all pages)"),
774
- force: z13.boolean().optional().default(false).describe("Overwrite existing translation files"),
775
- apiKey: z13.string().optional().describe("Anthropic API key (falls back to ANTHROPIC_API_KEY env var)"),
776
- model: z13.string().optional().default("claude-sonnet-4-6").describe("Claude model to use for translation")
1010
+ var translateDocsSchema = z14.object({
1011
+ projectDir: z14.string().describe("Path to the Thally project directory"),
1012
+ locale: z14.string().describe('Target locale code, e.g. "es", "fr"'),
1013
+ pages: z14.array(z14.string()).optional().describe("Page IDs to translate (omit for all pages)"),
1014
+ force: z14.boolean().optional().default(false).describe("Overwrite existing translation files"),
1015
+ apiKey: z14.string().optional().describe("Anthropic API key (falls back to ANTHROPIC_API_KEY env var)"),
1016
+ model: z14.string().optional().default("claude-sonnet-4-6").describe("Claude model to use for translation")
777
1017
  });
778
1018
  function readDocsJson2(projectDir) {
779
- const docsPath = join8(projectDir, "docs.json");
780
- const raw = readFileSync7(docsPath, "utf8");
1019
+ const docsPath = join9(projectDir, "docs.json");
1020
+ const raw = readFileSync8(docsPath, "utf8");
781
1021
  return JSON.parse(raw);
782
1022
  }
783
1023
  function collectPageIds(pages) {
@@ -819,12 +1059,12 @@ function getAllPageIds(config) {
819
1059
  return { ids, hrefOnlyPages };
820
1060
  }
821
1061
  function findSourceFile(projectDir, pageId) {
822
- const contentRoot = join8(projectDir, "src", "content");
1062
+ const contentRoot = join9(projectDir, "src", "content");
823
1063
  const candidates = [
824
- join8(contentRoot, `${pageId}.mdx`),
825
- join8(contentRoot, `${pageId}/index.mdx`)
1064
+ join9(contentRoot, `${pageId}.mdx`),
1065
+ join9(contentRoot, `${pageId}/index.mdx`)
826
1066
  ];
827
- return candidates.find((p) => existsSync7(p)) ?? null;
1067
+ return candidates.find((p) => existsSync8(p)) ?? null;
828
1068
  }
829
1069
  var TRANSLATION_SYSTEM_PROMPT = `You are a professional documentation translator. You will receive an MDX documentation file and translate it into the target language.
830
1070
 
@@ -876,7 +1116,7 @@ async function handleTranslateDocs(input) {
876
1116
  }
877
1117
  const { ids: allPageIds, hrefOnlyPages } = getAllPageIds(config);
878
1118
  const targetPageIds = pages ?? allPageIds;
879
- const contentRoot = join8(projectDir, "src", "content");
1119
+ const contentRoot = join9(projectDir, "src", "content");
880
1120
  const toTranslate = [];
881
1121
  const skipped = [];
882
1122
  for (const pageId of targetPageIds) {
@@ -886,8 +1126,8 @@ async function handleTranslateDocs(input) {
886
1126
  continue;
887
1127
  }
888
1128
  const relativeFromContent = sourceFile.slice(contentRoot.length + 1);
889
- const targetFile = join8(contentRoot, locale, relativeFromContent);
890
- if (existsSync7(targetFile) && !force) {
1129
+ const targetFile = join9(contentRoot, locale, relativeFromContent);
1130
+ if (existsSync8(targetFile) && !force) {
891
1131
  skipped.push(`${pageId} (already translated)`);
892
1132
  continue;
893
1133
  }
@@ -903,14 +1143,14 @@ async function handleTranslateDocs(input) {
903
1143
  toTranslate.map(
904
1144
  ({ pageId, sourceFile, targetFile }) => limit(async () => {
905
1145
  try {
906
- const sourceContent = readFileSync7(sourceFile, "utf8");
1146
+ const sourceContent = readFileSync8(sourceFile, "utf8");
907
1147
  const parsed = parseFrontmatter(sourceContent);
908
1148
  if (!parsed.data.title) {
909
1149
  console.warn(`[translate] ${pageId}: missing title in frontmatter`);
910
1150
  }
911
1151
  const translated = await translatePage(sourceContent, targetLocale.label, locale, model, client);
912
1152
  mkdirSync2(dirname2(targetFile), { recursive: true });
913
- writeFileSync5(targetFile, translated + "\n", "utf8");
1153
+ writeFileSync6(targetFile, translated + "\n", "utf8");
914
1154
  results.push({ pageId, success: true });
915
1155
  } catch (err) {
916
1156
  const msg = err instanceof Error ? err.message : String(err);
@@ -950,19 +1190,30 @@ async function handleTranslateDocs(input) {
950
1190
  }
951
1191
 
952
1192
  // src/tools/sync-from-repo.ts
953
- import { z as z14 } from "zod";
1193
+ import { z as z15 } from "zod";
954
1194
 
955
1195
  // src/lib/track.ts
956
- import { createSign, createHash } from "crypto";
1196
+ import { createSign, createHash as createHash3 } from "crypto";
957
1197
  function parseOwnerRepo(spec) {
958
1198
  const trimmed = spec.trim();
959
1199
  const url = trimmed.match(
960
1200
  /^https?:\/\/github\.com\/([^/\s]+)\/([^/\s#?]+?)(?:\.git)?(?:\/pull\/(\d+))?(?:[/#?].*)?$/i
961
1201
  );
962
- if (url) return { owner: url[1], repo: url[2], ...url[3] ? { pr: Number(url[3]) } : {} };
963
- const plain = trimmed.match(/^([A-Za-z0-9-_.]+)\/([A-Za-z0-9-_.]+?)(?:#(\d+))?$/);
1202
+ if (url)
1203
+ return {
1204
+ owner: url[1],
1205
+ repo: url[2],
1206
+ ...url[3] ? { pr: Number(url[3]) } : {}
1207
+ };
1208
+ const plain = trimmed.match(
1209
+ /^([A-Za-z0-9-_.]+)\/([A-Za-z0-9-_.]+?)(?:#(\d+))?$/
1210
+ );
964
1211
  if (!plain) return null;
965
- return { owner: plain[1], repo: plain[2], ...plain[3] ? { pr: Number(plain[3]) } : {} };
1212
+ return {
1213
+ owner: plain[1],
1214
+ repo: plain[2],
1215
+ ...plain[3] ? { pr: Number(plain[3]) } : {}
1216
+ };
966
1217
  }
967
1218
  function base64url(input) {
968
1219
  return Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
@@ -971,22 +1222,34 @@ var installationTokenCache = /* @__PURE__ */ new Map();
971
1222
  function createAppJwt(appId, privateKey) {
972
1223
  const now = Math.floor(Date.now() / 1e3);
973
1224
  const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
974
- const payload = base64url(JSON.stringify({ iat: now - 60, exp: now + 9 * 60, iss: String(appId) }));
975
- const signature = base64url(createSign("RSA-SHA256").update(`${header}.${payload}`).sign(privateKey));
1225
+ const payload = base64url(
1226
+ JSON.stringify({ iat: now - 60, exp: now + 9 * 60, iss: String(appId) })
1227
+ );
1228
+ const signature = base64url(
1229
+ createSign("RSA-SHA256").update(`${header}.${payload}`).sign(privateKey)
1230
+ );
976
1231
  return `${header}.${payload}.${signature}`;
977
1232
  }
978
1233
  async function mintInstallationToken(creds, fetchImpl = fetch) {
979
- const keyFp = createHash("sha256").update(creds.privateKey).digest("hex").slice(0, 12);
1234
+ const keyFp = createHash3("sha256").update(creds.privateKey).digest("hex").slice(0, 12);
980
1235
  const cacheKey = `${creds.appId}:${creds.installationId}:${keyFp}`;
981
1236
  const cached = installationTokenCache.get(cacheKey);
982
1237
  if (cached && cached.expiresAtMs - 6e4 > Date.now()) return cached.token;
983
1238
  const jwt = createAppJwt(creds.appId, creds.privateKey);
984
- const res = await fetchImpl(`https://api.github.com/app/installations/${creds.installationId}/access_tokens`, {
985
- method: "POST",
986
- headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${jwt}` }
987
- });
1239
+ const res = await fetchImpl(
1240
+ `https://api.github.com/app/installations/${creds.installationId}/access_tokens`,
1241
+ {
1242
+ method: "POST",
1243
+ headers: {
1244
+ Accept: "application/vnd.github+json",
1245
+ Authorization: `Bearer ${jwt}`
1246
+ }
1247
+ }
1248
+ );
988
1249
  if (!res.ok) {
989
- throw new Error(`GitHub App token exchange failed (${res.status}) \u2014 check the app id, installation id, and private key.`);
1250
+ throw new Error(
1251
+ `GitHub App token exchange failed (${res.status}) \u2014 check the app id, installation id, and private key.`
1252
+ );
990
1253
  }
991
1254
  const body = await res.json();
992
1255
  const parsed = Date.parse(body.expires_at);
@@ -998,7 +1261,8 @@ function envAppCreds() {
998
1261
  const appId = (process.env.THALLY_GITHUB_APP_ID ?? process.env.DOX_GITHUB_APP_ID)?.trim();
999
1262
  const installationId = (process.env.THALLY_GITHUB_APP_INSTALLATION_ID ?? process.env.DOX_GITHUB_APP_INSTALLATION_ID)?.trim();
1000
1263
  const privateKey = process.env.THALLY_GITHUB_APP_PRIVATE_KEY ?? process.env.DOX_GITHUB_APP_PRIVATE_KEY;
1001
- if (appId && installationId && privateKey) return { appId, installationId, privateKey };
1264
+ if (appId && installationId && privateKey)
1265
+ return { appId, installationId, privateKey };
1002
1266
  return void 0;
1003
1267
  }
1004
1268
  async function resolveGithubToken(options) {
@@ -1009,7 +1273,9 @@ async function resolveGithubToken(options) {
1009
1273
  try {
1010
1274
  return await mintInstallationToken(appCreds, options?.fetchImpl ?? fetch);
1011
1275
  } catch (err) {
1012
- console.warn(`[thally-track] GitHub App token mint failed, falling back to PAT: ${err instanceof Error ? err.message : String(err)}`);
1276
+ console.warn(
1277
+ `[thally-track] GitHub App token mint failed, falling back to PAT: ${err instanceof Error ? err.message : String(err)}`
1278
+ );
1013
1279
  return pat;
1014
1280
  }
1015
1281
  }
@@ -1018,9 +1284,13 @@ async function resolveGithubToken(options) {
1018
1284
  async function githubJson(path, options) {
1019
1285
  const fetchImpl = options?.fetchImpl ?? fetch;
1020
1286
  const token = await resolveGithubToken(options);
1021
- const headers = { Accept: "application/vnd.github+json" };
1287
+ const headers = {
1288
+ Accept: "application/vnd.github+json"
1289
+ };
1022
1290
  if (token) headers.Authorization = `Bearer ${token}`;
1023
- const response = await fetchImpl(`https://api.github.com${path}`, { headers });
1291
+ const response = await fetchImpl(`https://api.github.com${path}`, {
1292
+ headers
1293
+ });
1024
1294
  if (!response.ok) {
1025
1295
  const hint = response.status === 404 || response.status === 403 ? " (private repo or rate limit? set THALLY_GITHUB_TOKEN or connect a GitHub App)" : "";
1026
1296
  throw new Error(`GitHub API ${response.status} for ${path}${hint}`);
@@ -1039,7 +1309,10 @@ function toPullRequestInfo(raw) {
1039
1309
  };
1040
1310
  }
1041
1311
  async function fetchPullRequest(owner, repo, number, options) {
1042
- const raw = await githubJson(`/repos/${owner}/${repo}/pulls/${number}`, options);
1312
+ const raw = await githubJson(
1313
+ `/repos/${owner}/${repo}/pulls/${number}`,
1314
+ options
1315
+ );
1043
1316
  return toPullRequestInfo(raw);
1044
1317
  }
1045
1318
  async function fetchPullRequestFiles(owner, repo, number, options) {
@@ -1047,7 +1320,10 @@ async function fetchPullRequestFiles(owner, repo, number, options) {
1047
1320
  const maxPages = 30;
1048
1321
  const raw = [];
1049
1322
  for (let page = 1; page <= maxPages; page++) {
1050
- const chunk = await githubJson(`/repos/${owner}/${repo}/pulls/${number}/files?per_page=${perPage}&page=${page}`, options);
1323
+ const chunk = await githubJson(
1324
+ `/repos/${owner}/${repo}/pulls/${number}/files?per_page=${perPage}&page=${page}`,
1325
+ options
1326
+ );
1051
1327
  raw.push(...chunk);
1052
1328
  if (chunk.length < perPage) break;
1053
1329
  }
@@ -1064,7 +1340,9 @@ async function fetchLatestMergedPr(owner, repo, base, options) {
1064
1340
  `/repos/${owner}/${repo}/pulls?state=closed&base=${encodeURIComponent(base)}&sort=updated&direction=desc&per_page=30`,
1065
1341
  options
1066
1342
  );
1067
- const merged = raw.filter((pr) => Boolean(pr.merged_at)).sort((a, b) => Date.parse(b.merged_at ?? "") - Date.parse(a.merged_at ?? ""))[0];
1343
+ const merged = raw.filter((pr) => Boolean(pr.merged_at)).sort(
1344
+ (a, b) => Date.parse(b.merged_at ?? "") - Date.parse(a.merged_at ?? "")
1345
+ )[0];
1068
1346
  return merged ? toPullRequestInfo(merged) : null;
1069
1347
  }
1070
1348
  function compileGlob(pattern) {
@@ -1137,16 +1415,19 @@ _(diff truncated \u2014 ${files.length} file(s) total)_
1137
1415
  return context.slice(0, TRACK_CONTEXT_CHAR_CAP);
1138
1416
  }
1139
1417
  function buildTrackTask(repo, pr, files) {
1140
- return { instruction: buildTrackInstruction(repo, pr), context: buildTrackContext(repo, pr, files) };
1418
+ return {
1419
+ instruction: buildTrackInstruction(repo, pr),
1420
+ context: buildTrackContext(repo, pr, files)
1421
+ };
1141
1422
  }
1142
1423
 
1143
1424
  // src/tools/sync-from-repo.ts
1144
- var syncFromRepoSchema = z14.object({
1145
- projectDir: z14.string().describe("Path to the Thally project (reads the tracking config from docs.json)"),
1146
- repo: z14.string().optional().describe("Tracked repo to sync as owner/repo (defaults to the single tracked repo when only one is configured)"),
1147
- pr: z14.number().optional().describe("Pull request number to analyze (defaults to the latest PR merged into the tracked base branch)"),
1148
- dryRun: z14.boolean().optional().default(true).describe("When true (default), preview the distilled docs task without dispatching anything"),
1149
- docsRepo: z14.string().optional().describe("owner/repo of the docs repository to dispatch the task to (required when dryRun is false)")
1425
+ var syncFromRepoSchema = z15.object({
1426
+ projectDir: z15.string().describe("Path to the Thally project (reads the tracking config from docs.json)"),
1427
+ repo: z15.string().optional().describe("Tracked repo to sync as owner/repo (defaults to the single tracked repo when only one is configured)"),
1428
+ pr: z15.number().optional().describe("Pull request number to analyze (defaults to the latest PR merged into the tracked base branch)"),
1429
+ dryRun: z15.boolean().optional().default(true).describe("When true (default), preview the distilled docs task without dispatching anything"),
1430
+ docsRepo: z15.string().optional().describe("owner/repo of the docs repository to dispatch the task to (required when dryRun is false)")
1150
1431
  });
1151
1432
  async function handleSyncFromRepo(input) {
1152
1433
  const config = readDocsJson(input.projectDir);
@@ -1218,12 +1499,11 @@ async function handleSyncFromRepo(input) {
1218
1499
  }
1219
1500
 
1220
1501
  // src/tools/read-api-spec.ts
1221
- import { readFileSync as readFileSync8 } from "fs";
1222
- import { z as z15 } from "zod";
1502
+ import { z as z16 } from "zod";
1223
1503
 
1224
1504
  // src/lib/api-spec.ts
1225
- import { existsSync as existsSync8, lstatSync, realpathSync } from "fs";
1226
- import { isAbsolute, join as join9, posix, relative as relative2, resolve, sep } from "path";
1505
+ import { existsSync as existsSync9, lstatSync as lstatSync3, realpathSync as realpathSync3 } from "fs";
1506
+ import { isAbsolute as isAbsolute3, join as join10, posix, relative as relative4, resolve, sep as sep3 } from "path";
1227
1507
  var CONVENTIONAL_API_SOURCES = ["openapi.yaml", "openapi.yml", "openapi.json"];
1228
1508
  function normalizeApiSource(source) {
1229
1509
  const normalized = posix.normalize(source.trim().replace(/^\/+/, ""));
@@ -1235,7 +1515,7 @@ function normalizeApiSource(source) {
1235
1515
  function configuredApiSources(projectDir) {
1236
1516
  const configured = readDocsJson(projectDir).tabs.map((tab) => tab.api?.source).filter((source) => typeof source === "string").map(normalizeApiSource).filter((source) => source !== null);
1237
1517
  const conventional = CONVENTIONAL_API_SOURCES.filter(
1238
- (source) => existsSync8(join9(projectDir, source))
1518
+ (source) => existsSync9(join10(projectDir, source))
1239
1519
  );
1240
1520
  return [.../* @__PURE__ */ new Set([...configured, ...conventional])];
1241
1521
  }
@@ -1259,12 +1539,12 @@ function resolveApiSource(projectDir, requested) {
1259
1539
  }
1260
1540
  function resolveApiSourcePath(projectDir, requested) {
1261
1541
  const source = resolveApiSource(projectDir, requested);
1262
- const projectRoot = realpathSync(projectDir);
1542
+ const projectRoot = realpathSync3(projectDir);
1263
1543
  const candidate = resolve(projectRoot, source);
1264
1544
  try {
1265
- const resolved = realpathSync(candidate);
1266
- const fromRoot = relative2(projectRoot, resolved);
1267
- if (!fromRoot || fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot) || !lstatSync(candidate).isFile() || lstatSync(candidate).isSymbolicLink()) {
1545
+ const resolved = realpathSync3(candidate);
1546
+ const fromRoot = relative4(projectRoot, resolved);
1547
+ if (!fromRoot || fromRoot === ".." || fromRoot.startsWith(`..${sep3}`) || isAbsolute3(fromRoot) || !lstatSync3(candidate).isFile() || lstatSync3(candidate).isSymbolicLink()) {
1268
1548
  throw new Error("unsafe");
1269
1549
  }
1270
1550
  return { source, path: resolved };
@@ -1274,24 +1554,33 @@ function resolveApiSourcePath(projectDir, requested) {
1274
1554
  }
1275
1555
 
1276
1556
  // src/tools/read-api-spec.ts
1277
- var readApiSpecSchema = z15.object({
1278
- projectDir: z15.string().describe("Path to the Thally project root"),
1279
- source: z15.string().optional().describe("Configured OpenAPI source; omit when the project has one")
1557
+ var readApiSpecSchema = z16.object({
1558
+ projectDir: z16.string().describe("Path to the Thally project root"),
1559
+ source: z16.string().optional().describe("Configured OpenAPI source; omit when the project has one"),
1560
+ startByte: z16.number().int().min(0).optional().describe("UTF-8 byte continuation from a previous partial result"),
1561
+ startLine: z16.number().int().min(1).optional().describe("1-based source line to start at; do not combine with startByte"),
1562
+ maxBytes: z16.number().int().min(1).max(MODEL_READ_WINDOW_MAX_BYTES).optional().describe(
1563
+ `Maximum source bytes to return (default 49152, maximum ${MODEL_READ_WINDOW_MAX_BYTES})`
1564
+ )
1280
1565
  });
1281
1566
  async function handleReadApiSpec(input) {
1282
1567
  const source = resolveApiSourcePath(input.projectDir, input.source);
1283
- return [`API source: ${source.source}`, "", readFileSync8(source.path, "utf8")].join("\n");
1568
+ const window = createTextWindow(readModelTextFile(source.path), input);
1569
+ return [
1570
+ `API source: ${source.source}`,
1571
+ ...renderTextWindow(window).split("\n")
1572
+ ].join("\n");
1284
1573
  }
1285
1574
 
1286
1575
  // src/tools/update-api-spec.ts
1287
- import { writeFileSync as writeFileSync6 } from "fs";
1576
+ import { writeFileSync as writeFileSync7 } from "fs";
1288
1577
  import { extname as extname2 } from "path";
1289
- import { z as z16 } from "zod";
1578
+ import { z as z17 } from "zod";
1290
1579
  import { parse as parseYaml } from "yaml";
1291
- var updateApiSpecSchema = z16.object({
1292
- projectDir: z16.string().describe("Path to the Thally project root"),
1293
- source: z16.string().optional().describe("Configured OpenAPI source; omit when the project has one"),
1294
- content: z16.string().min(2).max(512e3).describe("Complete replacement OpenAPI JSON or YAML document")
1580
+ var updateApiSpecSchema = z17.object({
1581
+ projectDir: z17.string().describe("Path to the Thally project root"),
1582
+ source: z17.string().optional().describe("Configured OpenAPI source; omit when the project has one"),
1583
+ content: z17.string().min(2).max(512e3).describe("Complete replacement OpenAPI JSON or YAML document")
1295
1584
  });
1296
1585
  function parseDocument(source, content) {
1297
1586
  try {
@@ -1310,7 +1599,7 @@ async function handleUpdateApiSpec(input) {
1310
1599
  }
1311
1600
  const content = input.content.endsWith("\n") ? input.content : `${input.content}
1312
1601
  `;
1313
- writeFileSync6(source.path, content, "utf8");
1602
+ writeFileSync7(source.path, content, "utf8");
1314
1603
  return `\u2705 API source updated: ${source.source}`;
1315
1604
  }
1316
1605
 
@@ -1354,9 +1643,16 @@ var tools = [
1354
1643
  schema: updatePageSchema,
1355
1644
  handler: handleUpdatePage
1356
1645
  }),
1646
+ defineTool({
1647
+ name: "replace_page_text",
1648
+ 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",
1649
+ scope: "project",
1650
+ schema: replacePageTextSchema,
1651
+ handler: handleReplacePageText
1652
+ }),
1357
1653
  defineTool({
1358
1654
  name: "read_api_spec",
1359
- description: "Read an OpenAPI JSON or YAML source explicitly configured by this Thally project",
1655
+ description: "Read a bounded UTF-8 window from an OpenAPI JSON or YAML source configured by this project; follow next-start-byte until complete",
1360
1656
  scope: "project",
1361
1657
  schema: readApiSpecSchema,
1362
1658
  handler: handleReadApiSpec
@@ -1405,7 +1701,7 @@ var tools = [
1405
1701
  }),
1406
1702
  defineTool({
1407
1703
  name: "read_page",
1408
- description: "Read the full content of a documentation page by its page ID",
1704
+ description: "Read a bounded UTF-8 body window from a documentation page; follow next-start-byte until complete before replacing a page",
1409
1705
  scope: "project",
1410
1706
  schema: readPageSchema,
1411
1707
  handler: handleReadPage