ctxloom-pro 1.2.6 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4610,86 +4610,349 @@ var ToolRegistry = class {
4610
4610
 
4611
4611
  // packages/core/src/tools/search.ts
4612
4612
  import { z as z3 } from "zod";
4613
+
4614
+ // packages/core/src/budget/budget.ts
4615
+ var defaultTokenEstimator = (text) => Math.ceil(text.length / 4);
4616
+ function hasBudgetArgs(args) {
4617
+ if (!args || typeof args !== "object") return false;
4618
+ const a = args;
4619
+ return a.max_response_tokens !== void 0 || a.on_budget_exceeded !== void 0 || a.response_format !== void 0;
4620
+ }
4621
+ function readBudgetArgs(args) {
4622
+ if (!args || typeof args !== "object") return {};
4623
+ const a = args;
4624
+ const out = {};
4625
+ if (typeof a.max_response_tokens === "number") out.max_response_tokens = a.max_response_tokens;
4626
+ if (a.on_budget_exceeded === "skeleton" || a.on_budget_exceeded === "truncate" || a.on_budget_exceeded === "error") {
4627
+ out.on_budget_exceeded = a.on_budget_exceeded;
4628
+ }
4629
+ if (a.response_format === "full" || a.response_format === "skeleton" || a.response_format === "auto") {
4630
+ out.response_format = a.response_format;
4631
+ }
4632
+ return out;
4633
+ }
4634
+ function isBudgetDisabled() {
4635
+ return process.env.CTXLOOM_DISABLE_BUDGET === "1";
4636
+ }
4637
+ function emitTelemetry(event) {
4638
+ if (process.env.CTXLOOM_TELEMETRY_LEVEL !== "full") return;
4639
+ logger.info(event.event, event);
4640
+ }
4641
+ async function enforceBudget(opts) {
4642
+ const { full, args, toolName, defaultMaxTokens, skeletonProducer } = opts;
4643
+ const estimate = opts.estimator ?? defaultTokenEstimator;
4644
+ const originalTokens = estimate(full);
4645
+ if (isBudgetDisabled()) {
4646
+ return {
4647
+ text: full,
4648
+ meta: {
4649
+ format: "full",
4650
+ original_tokens_est: originalTokens,
4651
+ returned_tokens_est: originalTokens,
4652
+ fallback_reason: null
4653
+ }
4654
+ };
4655
+ }
4656
+ if (args.response_format === "skeleton" && skeletonProducer) {
4657
+ const skeleton2 = await safeSkeleton(skeletonProducer, toolName);
4658
+ if (skeleton2 !== null) {
4659
+ const skTokens = estimate(skeleton2);
4660
+ return {
4661
+ text: skeleton2,
4662
+ meta: {
4663
+ format: "skeleton",
4664
+ original_tokens_est: originalTokens,
4665
+ returned_tokens_est: skTokens,
4666
+ fallback_reason: null
4667
+ }
4668
+ };
4669
+ }
4670
+ return {
4671
+ text: full,
4672
+ meta: {
4673
+ format: "full",
4674
+ original_tokens_est: originalTokens,
4675
+ returned_tokens_est: originalTokens,
4676
+ fallback_reason: "skeleton_failed"
4677
+ }
4678
+ };
4679
+ }
4680
+ const budget = args.max_response_tokens ?? defaultMaxTokens;
4681
+ if (budget === void 0) {
4682
+ return {
4683
+ text: full,
4684
+ meta: {
4685
+ format: "full",
4686
+ original_tokens_est: originalTokens,
4687
+ returned_tokens_est: originalTokens,
4688
+ fallback_reason: null
4689
+ }
4690
+ };
4691
+ }
4692
+ if (originalTokens <= budget) {
4693
+ return {
4694
+ text: full,
4695
+ meta: {
4696
+ format: "full",
4697
+ original_tokens_est: originalTokens,
4698
+ returned_tokens_est: originalTokens,
4699
+ fallback_reason: null
4700
+ }
4701
+ };
4702
+ }
4703
+ emitTelemetry({
4704
+ event: "mcp.budget.exceeded",
4705
+ tool: toolName,
4706
+ original_tokens: originalTokens,
4707
+ budget,
4708
+ ratio: originalTokens / budget
4709
+ });
4710
+ const mode = args.on_budget_exceeded ?? "skeleton";
4711
+ if (mode === "error") {
4712
+ const err = new Error(
4713
+ `Response of ~${originalTokens} tokens exceeds max_response_tokens=${budget} for tool '${toolName}'. Re-ask with response_format: 'skeleton' or a larger budget.`
4714
+ );
4715
+ err.tokensOriginal = originalTokens;
4716
+ err.budget = budget;
4717
+ err.tool = toolName;
4718
+ throw err;
4719
+ }
4720
+ if (mode === "truncate") {
4721
+ const sliced2 = full.slice(0, budget * 4);
4722
+ const slicedTokens = estimate(sliced2);
4723
+ emitTelemetry({
4724
+ event: "mcp.fallback.used",
4725
+ tool: toolName,
4726
+ fallback_reason: "budget_exceeded",
4727
+ mode: "truncate"
4728
+ });
4729
+ return {
4730
+ text: sliced2,
4731
+ meta: {
4732
+ format: "truncated",
4733
+ original_tokens_est: originalTokens,
4734
+ returned_tokens_est: slicedTokens,
4735
+ fallback_reason: "budget_exceeded"
4736
+ }
4737
+ };
4738
+ }
4739
+ const skeleton = skeletonProducer ? await safeSkeleton(skeletonProducer, toolName) : null;
4740
+ if (skeleton !== null) {
4741
+ const skTokens = estimate(skeleton);
4742
+ if (skTokens <= budget) {
4743
+ emitTelemetry({
4744
+ event: "mcp.fallback.used",
4745
+ tool: toolName,
4746
+ fallback_reason: "budget_exceeded",
4747
+ mode: "skeleton"
4748
+ });
4749
+ return {
4750
+ text: skeleton,
4751
+ meta: {
4752
+ format: "skeleton",
4753
+ original_tokens_est: originalTokens,
4754
+ returned_tokens_est: skTokens,
4755
+ fallback_reason: "budget_exceeded"
4756
+ }
4757
+ };
4758
+ }
4759
+ const slicedSk = skeleton.slice(0, budget * 4);
4760
+ emitTelemetry({
4761
+ event: "mcp.fallback.used",
4762
+ tool: toolName,
4763
+ fallback_reason: "budget_exceeded",
4764
+ mode: "skeleton+truncate"
4765
+ });
4766
+ return {
4767
+ text: slicedSk,
4768
+ meta: {
4769
+ format: "truncated",
4770
+ original_tokens_est: originalTokens,
4771
+ returned_tokens_est: estimate(slicedSk),
4772
+ fallback_reason: "budget_exceeded"
4773
+ }
4774
+ };
4775
+ }
4776
+ const sliced = full.slice(0, budget * 4);
4777
+ emitTelemetry({
4778
+ event: "mcp.fallback.used",
4779
+ tool: toolName,
4780
+ fallback_reason: "skeleton_failed",
4781
+ mode: "truncate-fallback"
4782
+ });
4783
+ return {
4784
+ text: sliced,
4785
+ meta: {
4786
+ format: "truncated",
4787
+ original_tokens_est: originalTokens,
4788
+ returned_tokens_est: estimate(sliced),
4789
+ fallback_reason: "skeleton_failed"
4790
+ }
4791
+ };
4792
+ }
4793
+ async function safeSkeleton(producer, toolName) {
4794
+ try {
4795
+ return await producer();
4796
+ } catch (err) {
4797
+ logger.warn("Skeleton fallback failed", {
4798
+ tool: toolName,
4799
+ detail: err instanceof Error ? err.message : String(err)
4800
+ });
4801
+ return null;
4802
+ }
4803
+ }
4804
+ function wrapResponse(result) {
4805
+ const envelope = {
4806
+ data: result.text,
4807
+ meta: result.meta
4808
+ };
4809
+ return JSON.stringify(envelope);
4810
+ }
4811
+
4812
+ // packages/core/src/tools/search.ts
4813
+ var DEFAULT_MAX_RESPONSE_TOKENS = 4e3;
4613
4814
  var Schema = z3.object({
4614
4815
  query: z3.string().describe("Search query \u2014 natural language or code fragment"),
4615
4816
  limit: z3.number().max(100).optional().default(10).describe("Maximum results to return"),
4616
- project_root: ProjectRootField
4817
+ project_root: ProjectRootField,
4818
+ // ─── Phase B2 budget surface ──
4819
+ max_response_tokens: z3.number().int().positive().optional().describe("Soft response budget. Default: 4000 (when budget surface is opted into). Over-budget rebuilds the result list without the content snippets (paths + scores only)."),
4820
+ on_budget_exceeded: z3.enum(["skeleton", "truncate", "error"]).optional().describe("Behavior when over budget. 'skeleton' (default) drops snippets; 'truncate' slices the raw XML; 'error' throws."),
4821
+ response_format: z3.enum(["full", "skeleton", "auto"]).optional().describe("'skeleton' forces the path-and-score-only view; 'full'/'auto' lets the budget decide.")
4617
4822
  });
4618
4823
  function escapeXML3(text) {
4619
4824
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4620
4825
  }
4826
+ function renderResults(query, ranked, includeContent) {
4827
+ const lines = [`<search_results query="${escapeXML3(query)}" count="${ranked.length}">`];
4828
+ for (const result of ranked) {
4829
+ lines.push(` <result file="${escapeXML3(result.filePath)}" score="${result.score.toFixed(4)}">`);
4830
+ if (includeContent && result.content) {
4831
+ lines.push(` ${result.content.slice(0, 200).replace(/&/g, "&amp;").replace(/</g, "&lt;")}`);
4832
+ }
4833
+ lines.push(" </result>");
4834
+ }
4835
+ lines.push("</search_results>");
4836
+ return lines.join("\n");
4837
+ }
4621
4838
  function registerSearchTool(registry, ctx) {
4622
4839
  registry.register(
4623
4840
  "ctx_search",
4624
4841
  {
4625
4842
  name: "ctx_search",
4626
- description: "Hybrid semantic + graph search over the codebase. Uses vector embeddings for semantic similarity and the dependency graph for structural expansion. Returns ranked file results.",
4843
+ description: "Hybrid semantic + graph search over the codebase. Uses vector embeddings for semantic similarity and the dependency graph for structural expansion. Returns ranked file results. When callers opt into the budget surface, over-budget responses drop the content snippets and return paths + scores only.",
4627
4844
  inputSchema: {
4628
4845
  type: "object",
4629
4846
  properties: {
4630
4847
  query: { type: "string", description: "Search query \u2014 natural language or code fragment" },
4631
4848
  limit: { type: "number", description: "Maximum results to return (default: 10)" },
4632
- project_root: PROJECT_ROOT_JSON_SCHEMA
4849
+ project_root: PROJECT_ROOT_JSON_SCHEMA,
4850
+ max_response_tokens: {
4851
+ type: "number",
4852
+ description: "Soft response budget in tokens. Default: 4000 (when opted into)."
4853
+ },
4854
+ on_budget_exceeded: {
4855
+ type: "string",
4856
+ enum: ["skeleton", "truncate", "error"],
4857
+ description: "Behavior when over budget. 'skeleton' (default) drops snippets; 'truncate' slices; 'error' throws."
4858
+ },
4859
+ response_format: {
4860
+ type: "string",
4861
+ enum: ["full", "skeleton", "auto"],
4862
+ description: "'skeleton' forces path+score-only view; 'full'/'auto' lets the budget decide."
4863
+ }
4633
4864
  },
4634
4865
  required: ["query"]
4635
4866
  }
4636
4867
  },
4637
4868
  async (args) => {
4638
- const { query, limit, project_root } = Schema.parse(args);
4639
- const [store, graph] = await Promise.all([ctx.getStore(project_root), ctx.getGraph(project_root)]);
4640
- const queryEmbedding = await generateEmbedding(query);
4641
- const vectorResults = await store.search(queryEmbedding, limit);
4869
+ const parsed = Schema.parse(args);
4870
+ const [store, graph] = await Promise.all([ctx.getStore(parsed.project_root), ctx.getGraph(parsed.project_root)]);
4871
+ const queryEmbedding = await generateEmbedding(parsed.query);
4872
+ const vectorResults = await store.search(queryEmbedding, parsed.limit);
4642
4873
  const expandedResults = /* @__PURE__ */ new Map();
4643
- for (const result of vectorResults) {
4644
- const existingScore = expandedResults.get(result.filePath)?.score ?? Infinity;
4645
- if (result.score < existingScore) {
4646
- expandedResults.set(result.filePath, { score: result.score, content: result.content });
4874
+ for (const result2 of vectorResults) {
4875
+ const existingScore = expandedResults.get(result2.filePath)?.score ?? Infinity;
4876
+ if (result2.score < existingScore) {
4877
+ expandedResults.set(result2.filePath, { score: result2.score, content: result2.content });
4647
4878
  }
4648
- for (const related of [...graph.getImports(result.filePath), ...graph.getImporters(result.filePath)]) {
4879
+ for (const related of [...graph.getImports(result2.filePath), ...graph.getImporters(result2.filePath)]) {
4649
4880
  if (!expandedResults.has(related)) {
4650
- expandedResults.set(related, { score: result.score + 0.1, content: "" });
4881
+ expandedResults.set(related, { score: result2.score + 0.1, content: "" });
4651
4882
  }
4652
4883
  }
4653
4884
  }
4654
- const ranked = Array.from(expandedResults.entries()).map(([filePath, data]) => ({ filePath, score: data.score, content: data.content })).sort((a, b) => a.score - b.score).slice(0, limit);
4655
- const lines = [`<search_results query="${escapeXML3(query)}" count="${ranked.length}">`];
4656
- for (const result of ranked) {
4657
- lines.push(` <result file="${escapeXML3(result.filePath)}" score="${result.score.toFixed(4)}">`);
4658
- if (result.content) {
4659
- lines.push(` ${result.content.slice(0, 200).replace(/&/g, "&amp;").replace(/</g, "&lt;")}`);
4660
- }
4661
- lines.push(" </result>");
4662
- }
4663
- lines.push("</search_results>");
4664
- return lines.join("\n");
4885
+ const ranked = Array.from(expandedResults.entries()).map(([filePath, data]) => ({ filePath, score: data.score, content: data.content })).sort((a, b) => a.score - b.score).slice(0, parsed.limit);
4886
+ const full = renderResults(parsed.query, ranked, true);
4887
+ if (!hasBudgetArgs(args)) return full;
4888
+ const skeletonProducer = async () => renderResults(parsed.query, ranked, false);
4889
+ const result = await enforceBudget({
4890
+ full,
4891
+ args: readBudgetArgs(args),
4892
+ toolName: "ctx_search",
4893
+ defaultMaxTokens: DEFAULT_MAX_RESPONSE_TOKENS,
4894
+ skeletonProducer
4895
+ });
4896
+ return wrapResponse(result);
4665
4897
  }
4666
4898
  );
4667
4899
  }
4668
4900
 
4669
4901
  // packages/core/src/tools/file.ts
4670
4902
  import { z as z4 } from "zod";
4903
+ var DEFAULT_MAX_RESPONSE_TOKENS2 = 8e3;
4671
4904
  var Schema2 = z4.object({
4672
4905
  path: z4.string().describe("Relative path to the file"),
4673
- project_root: ProjectRootField
4906
+ project_root: ProjectRootField,
4907
+ // ─── Phase B2 budget surface (all optional; back-compat preserved) ──
4908
+ max_response_tokens: z4.number().int().positive().optional().describe("Soft response budget in tokens. Falls back to a skeleton when exceeded. Default: 8000 (when budget surface is opted into)."),
4909
+ on_budget_exceeded: z4.enum(["skeleton", "truncate", "error"]).optional().describe("Behavior when the response would exceed max_response_tokens. 'skeleton' (default) substitutes a Skeletonizer signature view; 'truncate' slices the raw text; 'error' throws a structured error with token counts so the caller can re-ask."),
4910
+ response_format: z4.enum(["full", "skeleton", "auto"]).optional().describe("'skeleton' forces a Skeletonizer view regardless of budget; 'full'/'auto' lets the budget decide.")
4674
4911
  });
4675
4912
  function registerFileTool(registry, ctx) {
4676
4913
  registry.register(
4677
4914
  "ctx_get_file",
4678
4915
  {
4679
4916
  name: "ctx_get_file",
4680
- description: "Read a file from the project. Path is validated to prevent traversal outside the project root. Returns the full file content.",
4917
+ description: "Read a file from the project. Path is validated to prevent traversal outside the project root. Returns the full file content; when callers opt into the budget surface (max_response_tokens / on_budget_exceeded / response_format), the response is wrapped in a {data, meta} envelope and oversize content is auto-substituted with a Skeletonizer signature view.",
4681
4918
  inputSchema: {
4682
4919
  type: "object",
4683
4920
  properties: {
4684
4921
  path: { type: "string", description: "Relative path to the file" },
4685
- project_root: PROJECT_ROOT_JSON_SCHEMA
4922
+ project_root: PROJECT_ROOT_JSON_SCHEMA,
4923
+ max_response_tokens: {
4924
+ type: "number",
4925
+ description: "Soft response budget in tokens. Falls back to a skeleton when exceeded. Default: 8000 (when budget surface is opted into)."
4926
+ },
4927
+ on_budget_exceeded: {
4928
+ type: "string",
4929
+ enum: ["skeleton", "truncate", "error"],
4930
+ description: "Behavior when over budget. 'skeleton' (default) substitutes a signature view; 'truncate' slices the raw text; 'error' throws."
4931
+ },
4932
+ response_format: {
4933
+ type: "string",
4934
+ enum: ["full", "skeleton", "auto"],
4935
+ description: "'skeleton' forces a Skeletonizer view regardless of budget; 'full'/'auto' lets the budget decide."
4936
+ }
4686
4937
  },
4687
4938
  required: ["path"]
4688
4939
  }
4689
4940
  },
4690
4941
  async (args) => {
4691
- const { path: filePath, project_root } = Schema2.parse(args);
4692
- return ctx.getPathValidator(project_root).readFile(filePath);
4942
+ const parsed = Schema2.parse(args);
4943
+ const validator = ctx.getPathValidator(parsed.project_root);
4944
+ const full = validator.readFile(parsed.path);
4945
+ if (!hasBudgetArgs(args)) return full;
4946
+ const absPath = validator.validate(parsed.path);
4947
+ const skeletonizer = await ctx.getSkeletonizer(parsed.project_root);
4948
+ const result = await enforceBudget({
4949
+ full,
4950
+ args: readBudgetArgs(args),
4951
+ toolName: "ctx_get_file",
4952
+ defaultMaxTokens: DEFAULT_MAX_RESPONSE_TOKENS2,
4953
+ skeletonProducer: () => skeletonizer.skeletonize(absPath)
4954
+ });
4955
+ return wrapResponse(result);
4693
4956
  }
4694
4957
  );
4695
4958
  }
@@ -4697,34 +4960,71 @@ function registerFileTool(registry, ctx) {
4697
4960
  // packages/core/src/tools/context-packet.ts
4698
4961
  import { z as z5 } from "zod";
4699
4962
  import path14 from "path";
4963
+ var DEFAULT_MAX_RESPONSE_TOKENS3 = 6e3;
4700
4964
  var Schema3 = z5.object({
4701
4965
  target_file: z5.string().describe("Relative path to the primary file"),
4702
4966
  mode: z5.enum(["edit", "read"]).optional().default("edit").describe("Context mode"),
4703
- project_root: ProjectRootField
4967
+ project_root: ProjectRootField,
4968
+ // ─── Phase B2 budget surface ──
4969
+ max_response_tokens: z5.number().int().positive().optional().describe("Soft response budget. Default: 6000 (when budget surface is opted into). Over-budget rebuilds the packet with the primary file replaced by its skeleton."),
4970
+ on_budget_exceeded: z5.enum(["skeleton", "truncate", "error"]).optional().describe("Behavior when over budget. 'skeleton' (default) re-renders the packet with the primary file skeletonized; 'truncate' slices the raw envelope; 'error' throws."),
4971
+ response_format: z5.enum(["full", "skeleton", "auto"]).optional().describe("'skeleton' forces the skeletonized-primary packet; 'full'/'auto' lets the budget decide.")
4704
4972
  });
4973
+ function renderPacket(parts) {
4974
+ return [
4975
+ `<context_packet target="${parts.target_file}" mode="${parts.mode}">`,
4976
+ ` <primary_context file="${parts.target_file}">`,
4977
+ ` ${parts.primaryContent.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")}`,
4978
+ " </primary_context>",
4979
+ ` <dependency_skeletons count="${parts.imports.length}">`,
4980
+ ...parts.skeletons.map((s) => ` ${s}`),
4981
+ " </dependency_skeletons>",
4982
+ ` <imported_by count="${parts.importers.length}">`,
4983
+ ...parts.importers.map((imp) => ` <importer file="${imp}" />`),
4984
+ " </imported_by>",
4985
+ "</context_packet>"
4986
+ ].join("\n");
4987
+ }
4705
4988
  function registerContextPacketTool(registry, ctx) {
4706
4989
  registry.register(
4707
4990
  "ctx_get_context_packet",
4708
4991
  {
4709
4992
  name: "ctx_get_context_packet",
4710
- description: "Returns a smart multi-file context packet: the full target file, skeletons of its imports, and the list of files that import it. Reduces token usage by ~80% vs. sending full dependencies.",
4993
+ description: "Returns a smart multi-file context packet: the full target file, skeletons of its imports, and the list of files that import it. Reduces token usage by ~80% vs. sending full dependencies. When callers opt into the budget surface, over-budget responses re-render the packet with the primary file ALSO replaced by its Skeletonizer view.",
4711
4994
  inputSchema: {
4712
4995
  type: "object",
4713
4996
  properties: {
4714
4997
  target_file: { type: "string", description: "Relative path to the primary file" },
4715
4998
  mode: { type: "string", enum: ["edit", "read"], description: "Context mode (default: edit)" },
4716
- project_root: PROJECT_ROOT_JSON_SCHEMA
4999
+ project_root: PROJECT_ROOT_JSON_SCHEMA,
5000
+ max_response_tokens: {
5001
+ type: "number",
5002
+ description: "Soft response budget in tokens. Default: 6000 (when opted into)."
5003
+ },
5004
+ on_budget_exceeded: {
5005
+ type: "string",
5006
+ enum: ["skeleton", "truncate", "error"],
5007
+ description: "Behavior when over budget. 'skeleton' (default) skeletonizes the primary; 'truncate' slices; 'error' throws."
5008
+ },
5009
+ response_format: {
5010
+ type: "string",
5011
+ enum: ["full", "skeleton", "auto"],
5012
+ description: "'skeleton' forces the skeletonized-primary packet; 'full'/'auto' lets the budget decide."
5013
+ }
4717
5014
  },
4718
5015
  required: ["target_file"]
4719
5016
  }
4720
5017
  },
4721
5018
  async (args) => {
4722
- const { target_file, mode, project_root } = Schema3.parse(args);
4723
- const [skeletonizer, graph] = await Promise.all([ctx.getSkeletonizer(project_root), ctx.getGraph(project_root)]);
4724
- const pathValidator = ctx.getPathValidator(project_root);
4725
- const primaryContent = pathValidator.readFile(target_file);
4726
- const imports = graph.getImports(target_file);
4727
- const importers = graph.getImporters(target_file);
5019
+ const parsed = Schema3.parse(args);
5020
+ const [skeletonizer, graph] = await Promise.all([
5021
+ ctx.getSkeletonizer(parsed.project_root),
5022
+ ctx.getGraph(parsed.project_root)
5023
+ ]);
5024
+ const pathValidator = ctx.getPathValidator(parsed.project_root);
5025
+ const primaryContent = pathValidator.readFile(parsed.target_file);
5026
+ const imports = graph.getImports(parsed.target_file);
5027
+ const importers = graph.getImporters(parsed.target_file);
4728
5028
  const skeletons = await Promise.all(
4729
5029
  imports.map(async (dep) => {
4730
5030
  try {
@@ -4738,19 +5038,29 @@ ${sk}`;
4738
5038
  }
4739
5039
  })
4740
5040
  );
4741
- return [
4742
- `<context_packet target="${target_file}" mode="${mode}">`,
4743
- ` <primary_context file="${target_file}">`,
4744
- ` ${primaryContent.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")}`,
4745
- " </primary_context>",
4746
- ` <dependency_skeletons count="${imports.length}">`,
4747
- ...skeletons.map((s) => ` ${s}`),
4748
- " </dependency_skeletons>",
4749
- ` <imported_by count="${importers.length}">`,
4750
- ...importers.map((imp) => ` <importer file="${imp}" />`),
4751
- " </imported_by>",
4752
- "</context_packet>"
4753
- ].join("\n");
5041
+ const parts = {
5042
+ target_file: parsed.target_file,
5043
+ mode: parsed.mode,
5044
+ primaryContent,
5045
+ skeletons,
5046
+ imports,
5047
+ importers
5048
+ };
5049
+ const full = renderPacket(parts);
5050
+ if (!hasBudgetArgs(args)) return full;
5051
+ const absPrimary = pathValidator.validate(parsed.target_file);
5052
+ const skeletonProducer = async () => {
5053
+ const primarySkeleton = await skeletonizer.skeletonize(absPrimary);
5054
+ return renderPacket({ ...parts, primaryContent: primarySkeleton });
5055
+ };
5056
+ const result = await enforceBudget({
5057
+ full,
5058
+ args: readBudgetArgs(args),
5059
+ toolName: "ctx_get_context_packet",
5060
+ defaultMaxTokens: DEFAULT_MAX_RESPONSE_TOKENS3,
5061
+ skeletonProducer
5062
+ });
5063
+ return wrapResponse(result);
4754
5064
  }
4755
5065
  );
4756
5066
  }
@@ -4845,9 +5155,14 @@ function registerCallGraphTool(registry, ctx) {
4845
5155
 
4846
5156
  // packages/core/src/tools/definition.ts
4847
5157
  import { z as z7 } from "zod";
5158
+ var DEFAULT_MAX_RESPONSE_TOKENS4 = 2e3;
4848
5159
  var Schema5 = z7.object({
4849
5160
  symbol: z7.string().describe("Symbol name to look up"),
4850
- project_root: ProjectRootField
5161
+ project_root: ProjectRootField,
5162
+ // ─── Phase B2 budget surface (all optional; back-compat preserved) ──
5163
+ max_response_tokens: z7.number().int().positive().optional().describe("Soft response budget in tokens. Default: 2000 (when budget surface is opted into). No skeleton fallback for this tool \u2014 the response is structural metadata; over-budget falls back to truncation."),
5164
+ on_budget_exceeded: z7.enum(["skeleton", "truncate", "error"]).optional().describe("Behavior when over budget. 'skeleton'/'truncate' both slice the XML (no file context to skeletonize from); 'error' throws."),
5165
+ response_format: z7.enum(["full", "skeleton", "auto"]).optional().describe("'full'/'auto' default. 'skeleton' is accepted for consistency but produces the same output as 'full' here \u2014 the response is already a compact symbol list.")
4851
5166
  });
4852
5167
  function escapeXML5(text) {
4853
5168
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -4857,33 +5172,57 @@ function registerDefinitionTool(registry, ctx) {
4857
5172
  "ctx_get_definition",
4858
5173
  {
4859
5174
  name: "ctx_get_definition",
4860
- description: "Look up the definition of a symbol by name. Returns file path, type, and signature for all definitions matching the symbol name.",
5175
+ description: "Look up the definition of a symbol by name. Returns file path, type, and signature for all definitions matching the symbol name. When callers opt into the budget surface (max_response_tokens / on_budget_exceeded / response_format), the response is wrapped in a {data, meta} envelope and over-budget responses are truncated (no skeleton fallback \u2014 the response is already structural).",
4861
5176
  inputSchema: {
4862
5177
  type: "object",
4863
5178
  properties: {
4864
5179
  symbol: { type: "string", description: "Symbol name to look up" },
4865
- project_root: PROJECT_ROOT_JSON_SCHEMA
5180
+ project_root: PROJECT_ROOT_JSON_SCHEMA,
5181
+ max_response_tokens: {
5182
+ type: "number",
5183
+ description: "Soft response budget in tokens. Default: 2000 (when budget surface is opted into)."
5184
+ },
5185
+ on_budget_exceeded: {
5186
+ type: "string",
5187
+ enum: ["skeleton", "truncate", "error"],
5188
+ description: "Behavior when over budget. 'skeleton'/'truncate' both slice the XML; 'error' throws."
5189
+ },
5190
+ response_format: {
5191
+ type: "string",
5192
+ enum: ["full", "skeleton", "auto"],
5193
+ description: "'full'/'auto' default; 'skeleton' produces the same output (response is already compact)."
5194
+ }
4866
5195
  },
4867
5196
  required: ["symbol"]
4868
5197
  }
4869
5198
  },
4870
5199
  async (args) => {
4871
- const { symbol, project_root } = Schema5.parse(args);
4872
- const graph = await ctx.getGraph(project_root);
4873
- const definitions = graph.lookupSymbol(symbol);
5200
+ const parsed = Schema5.parse(args);
5201
+ const graph = await ctx.getGraph(parsed.project_root);
5202
+ const definitions = graph.lookupSymbol(parsed.symbol);
5203
+ let full;
4874
5204
  if (definitions.length === 0) {
4875
- return `<definitions symbol="${escapeXML5(symbol)}" count="0">
5205
+ full = `<definitions symbol="${escapeXML5(parsed.symbol)}" count="0">
4876
5206
  <!-- Symbol not found -->
4877
5207
  </definitions>`;
4878
- }
4879
- const lines = [`<definitions symbol="${escapeXML5(symbol)}" count="${definitions.length}">`];
4880
- for (const def of definitions) {
4881
- lines.push(` <definition file="${def.filePath}" type="${def.type}">`);
4882
- lines.push(` ${def.signature.replace(/&/g, "&amp;").replace(/</g, "&lt;")}`);
4883
- lines.push(" </definition>");
4884
- }
4885
- lines.push("</definitions>");
4886
- return lines.join("\n");
5208
+ } else {
5209
+ const lines = [`<definitions symbol="${escapeXML5(parsed.symbol)}" count="${definitions.length}">`];
5210
+ for (const def of definitions) {
5211
+ lines.push(` <definition file="${def.filePath}" type="${def.type}">`);
5212
+ lines.push(` ${def.signature.replace(/&/g, "&amp;").replace(/</g, "&lt;")}`);
5213
+ lines.push(" </definition>");
5214
+ }
5215
+ lines.push("</definitions>");
5216
+ full = lines.join("\n");
5217
+ }
5218
+ if (!hasBudgetArgs(args)) return full;
5219
+ const result = await enforceBudget({
5220
+ full,
5221
+ args: readBudgetArgs(args),
5222
+ toolName: "ctx_get_definition",
5223
+ defaultMaxTokens: DEFAULT_MAX_RESPONSE_TOKENS4
5224
+ });
5225
+ return wrapResponse(result);
4887
5226
  }
4888
5227
  );
4889
5228
  }
@@ -5848,6 +6187,7 @@ function registerSurprisingConnectionsTool(registry, ctx) {
5848
6187
  // packages/core/src/tools/wiki-generate.ts
5849
6188
  import { z as z17 } from "zod";
5850
6189
  import fs14 from "fs";
6190
+ var DEFAULT_MAX_RESPONSE_TOKENS5 = 12e3;
5851
6191
  var Schema15 = z17.object({
5852
6192
  force: z17.boolean().optional().default(false).describe(
5853
6193
  "Regenerate all pages even if content unchanged (default: false)"
@@ -5855,7 +6195,11 @@ var Schema15 = z17.object({
5855
6195
  detail_level: z17.enum(["standard", "minimal"]).default("standard").describe(
5856
6196
  '"standard" (default) lists each written page with size. "minimal" returns counts only.'
5857
6197
  ),
5858
- project_root: ProjectRootField
6198
+ project_root: ProjectRootField,
6199
+ // ─── Phase B2 budget surface ──
6200
+ max_response_tokens: z17.number().int().positive().optional().describe("Soft response budget. Default: 12000 (when opted in). Over-budget re-renders at detail_level=minimal (counts only) \u2014 the wiki files themselves are unaffected."),
6201
+ on_budget_exceeded: z17.enum(["skeleton", "truncate", "error"]).optional().describe("Behavior when over budget. 'skeleton' (default) downgrades to minimal output; 'truncate' slices; 'error' throws."),
6202
+ response_format: z17.enum(["full", "skeleton", "auto"]).optional().describe("'skeleton' forces minimal output regardless of budget; 'full'/'auto' lets the budget decide.")
5859
6203
  });
5860
6204
  function escapeXML14(text) {
5861
6205
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -5885,7 +6229,10 @@ function registerWikiGenerateTool(registry, ctx) {
5885
6229
  enum: ["standard", "minimal"],
5886
6230
  description: '"standard" lists written pages with size. "minimal" returns counts only.'
5887
6231
  },
5888
- project_root: PROJECT_ROOT_JSON_SCHEMA
6232
+ project_root: PROJECT_ROOT_JSON_SCHEMA,
6233
+ max_response_tokens: { type: "number", description: "Soft response budget. Default: 12000 (when opted in)." },
6234
+ on_budget_exceeded: { type: "string", enum: ["skeleton", "truncate", "error"], description: "Behavior over budget. 'skeleton' (default) downgrades to minimal; 'truncate' slices; 'error' throws." },
6235
+ response_format: { type: "string", enum: ["full", "skeleton", "auto"], description: "'skeleton' forces minimal output; 'full'/'auto' lets the budget decide." }
5889
6236
  }
5890
6237
  }
5891
6238
  },
@@ -5894,20 +6241,30 @@ function registerWikiGenerateTool(registry, ctx) {
5894
6241
  const [graph, skeletonizer] = await Promise.all([ctx.getGraph(project_root), ctx.getSkeletonizer(project_root)]);
5895
6242
  const generator = new WikiGenerator(graph, ctx.projectRoot, skeletonizer);
5896
6243
  const result = await generator.generate(force);
5897
- if (detail_level === "minimal") {
5898
- return `<wiki_generate detail_level="minimal" wiki_dir="${escapeXML14(result.wikiDir)}" written="${result.written.length}" skipped="${result.skipped.length}" />`;
5899
- }
5900
- const lines = [
5901
- `<wiki_generate wiki_dir="${escapeXML14(result.wikiDir)}" written="${result.written.length}" skipped="${result.skipped.length}">`
5902
- ];
5903
- for (const p of result.written) {
5904
- const size = safeFileSize(p.filePath);
5905
- lines.push(
5906
- ` <page community="${escapeXML14(p.communityName)}" file="${escapeXML14(p.filePath)}" size="${size}" status="written" />`
5907
- );
5908
- }
5909
- lines.push("</wiki_generate>");
5910
- return lines.join("\n");
6244
+ const renderMinimal = () => `<wiki_generate detail_level="minimal" wiki_dir="${escapeXML14(result.wikiDir)}" written="${result.written.length}" skipped="${result.skipped.length}" />`;
6245
+ const renderStandard = () => {
6246
+ const lines = [
6247
+ `<wiki_generate wiki_dir="${escapeXML14(result.wikiDir)}" written="${result.written.length}" skipped="${result.skipped.length}">`
6248
+ ];
6249
+ for (const p of result.written) {
6250
+ const size = safeFileSize(p.filePath);
6251
+ lines.push(
6252
+ ` <page community="${escapeXML14(p.communityName)}" file="${escapeXML14(p.filePath)}" size="${size}" status="written" />`
6253
+ );
6254
+ }
6255
+ lines.push("</wiki_generate>");
6256
+ return lines.join("\n");
6257
+ };
6258
+ const full = detail_level === "minimal" ? renderMinimal() : renderStandard();
6259
+ if (!hasBudgetArgs(args)) return full;
6260
+ const budgetResult = await enforceBudget({
6261
+ full,
6262
+ args: readBudgetArgs(args),
6263
+ toolName: "ctx_wiki_generate",
6264
+ defaultMaxTokens: DEFAULT_MAX_RESPONSE_TOKENS5,
6265
+ skeletonProducer: detail_level === "standard" ? async () => renderMinimal() : void 0
6266
+ });
6267
+ return wrapResponse(budgetResult);
5911
6268
  }
5912
6269
  );
5913
6270
  }
@@ -5956,6 +6313,7 @@ function registerGraphExportTool(registry, ctx) {
5956
6313
  import { z as z19 } from "zod";
5957
6314
  import { execFile } from "child_process";
5958
6315
  import { promisify as promisify2 } from "util";
6316
+ var DEFAULT_MAX_RESPONSE_TOKENS6 = 8e3;
5959
6317
  var execFileAsync = promisify2(execFile);
5960
6318
  var Schema17 = z19.object({
5961
6319
  changed_files: z19.array(z19.string()).optional().describe(
@@ -5969,7 +6327,11 @@ var Schema17 = z19.object({
5969
6327
  max_diff_lines: z19.number().min(10).max(2e3).optional().default(300).describe(
5970
6328
  "Max diff lines to include per file (default: 300)"
5971
6329
  ),
5972
- project_root: ProjectRootField
6330
+ project_root: ProjectRootField,
6331
+ // ─── Phase B2 budget surface ──
6332
+ max_response_tokens: z19.number().int().positive().optional().describe("Soft response budget. Default: 8000 (when opted in). Over-budget re-renders without <skeleton> blocks and without the transitive_importers section \u2014 keeps diffs, direct importers, and call sites."),
6333
+ on_budget_exceeded: z19.enum(["skeleton", "truncate", "error"]).optional().describe("Behavior when over budget. 'skeleton' (default) drops skeletons + transitive importers; 'truncate' slices; 'error' throws."),
6334
+ response_format: z19.enum(["full", "skeleton", "auto"]).optional().describe("'skeleton' forces the lighter view; 'full'/'auto' lets the budget decide.")
5973
6335
  });
5974
6336
  function escapeXML16(text) {
5975
6337
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -6017,7 +6379,10 @@ function registerGitDiffReviewTool(registry, ctx) {
6017
6379
  description: "Include API skeletons for changed and importer files (default: true)"
6018
6380
  },
6019
6381
  max_diff_lines: { type: "number", description: "Max diff lines per file (default: 300)" },
6020
- project_root: PROJECT_ROOT_JSON_SCHEMA
6382
+ project_root: PROJECT_ROOT_JSON_SCHEMA,
6383
+ max_response_tokens: { type: "number", description: "Soft response budget. Default: 8000 (when opted in)." },
6384
+ on_budget_exceeded: { type: "string", enum: ["skeleton", "truncate", "error"], description: "Behavior over budget. 'skeleton' (default) drops <skeleton> blocks + transitive importers; 'truncate' slices; 'error' throws." },
6385
+ response_format: { type: "string", enum: ["full", "skeleton", "auto"], description: "'skeleton' forces lighter view; 'full'/'auto' lets the budget decide." }
6021
6386
  }
6022
6387
  }
6023
6388
  },
@@ -6037,10 +6402,21 @@ function registerGitDiffReviewTool(registry, ctx) {
6037
6402
  logger.warn("git diff failed \u2014 no changed files detected");
6038
6403
  }
6039
6404
  }
6405
+ const maybeBudget = async (full2, skeletonProducer) => {
6406
+ if (!hasBudgetArgs(args)) return full2;
6407
+ const result = await enforceBudget({
6408
+ full: full2,
6409
+ args: readBudgetArgs(args),
6410
+ toolName: "ctx_git_diff_review",
6411
+ defaultMaxTokens: DEFAULT_MAX_RESPONSE_TOKENS6,
6412
+ skeletonProducer
6413
+ });
6414
+ return wrapResponse(result);
6415
+ };
6040
6416
  if (files.length === 0) {
6041
- return `<git_diff_review changed_files="0">
6417
+ return maybeBudget(`<git_diff_review changed_files="0">
6042
6418
  <!-- No changed files detected -->
6043
- </git_diff_review>`;
6419
+ </git_diff_review>`);
6044
6420
  }
6045
6421
  const graph = await ctx.getGraph(project_root);
6046
6422
  const blast = await computeBlastRadius({
@@ -6049,62 +6425,69 @@ function registerGitDiffReviewTool(registry, ctx) {
6049
6425
  projectRoot: ctx.projectRoot,
6050
6426
  graph
6051
6427
  });
6052
- const lines = [
6053
- `<git_diff_review changed_files="${files.length}" depth="${depth}">`
6054
- ];
6055
- lines.push(` <changed_files count="${files.length}">`);
6056
- for (const file of files) {
6057
- lines.push(` <file path="${escapeXML16(file)}">`);
6428
+ const changedFileData = await Promise.all(files.map(async (file) => {
6058
6429
  const rawDiff = use_git ? await getFileDiff(ctx.projectRoot, file) : "";
6059
6430
  const diffLines = rawDiff ? rawDiff.split("\n") : [];
6060
6431
  const truncated = diffLines.length > max_diff_lines;
6061
6432
  const diffContent = truncated ? [...diffLines.slice(0, max_diff_lines), `... (${diffLines.length - max_diff_lines} more lines)`].join("\n") : rawDiff;
6062
- lines.push(` <diff lines="${diffLines.length}" truncated="${truncated}">`);
6063
- if (diffContent) {
6064
- lines.push(escapeXML16(diffContent));
6065
- }
6066
- lines.push(" </diff>");
6067
- if (include_skeletons) {
6068
- const skeleton = await trySkeletonize(ctx, file, project_root);
6069
- if (skeleton) {
6070
- lines.push(" <skeleton>");
6071
- lines.push(escapeXML16(skeleton));
6072
- lines.push(" </skeleton>");
6433
+ const skeleton = include_skeletons ? await trySkeletonize(ctx, file, project_root) : "";
6434
+ return { file, diffLines, truncated, diffContent, skeleton };
6435
+ }));
6436
+ const skeletonLimit = 5;
6437
+ const directImporterSkeletons = await Promise.all(
6438
+ blast.directImporters.map(async (file, i) => ({
6439
+ file,
6440
+ skeleton: include_skeletons && i < skeletonLimit ? await trySkeletonize(ctx, file, project_root) : ""
6441
+ }))
6442
+ );
6443
+ const render = (withSkeletons, withTransitive) => {
6444
+ const out = [`<git_diff_review changed_files="${files.length}" depth="${depth}">`];
6445
+ out.push(` <changed_files count="${files.length}">`);
6446
+ for (const cd of changedFileData) {
6447
+ out.push(` <file path="${escapeXML16(cd.file)}">`);
6448
+ out.push(` <diff lines="${cd.diffLines.length}" truncated="${cd.truncated}">`);
6449
+ if (cd.diffContent) out.push(escapeXML16(cd.diffContent));
6450
+ out.push(" </diff>");
6451
+ if (withSkeletons && cd.skeleton) {
6452
+ out.push(" <skeleton>");
6453
+ out.push(escapeXML16(cd.skeleton));
6454
+ out.push(" </skeleton>");
6073
6455
  }
6456
+ out.push(" </file>");
6457
+ }
6458
+ out.push(" </changed_files>");
6459
+ out.push(` <direct_importers count="${blast.directImporters.length}">`);
6460
+ for (const di of directImporterSkeletons) {
6461
+ out.push(` <file path="${escapeXML16(di.file)}">`);
6462
+ if (withSkeletons && di.skeleton) {
6463
+ out.push(" <skeleton>");
6464
+ out.push(escapeXML16(di.skeleton));
6465
+ out.push(" </skeleton>");
6466
+ }
6467
+ out.push(" </file>");
6074
6468
  }
6075
- lines.push(" </file>");
6076
- }
6077
- lines.push(" </changed_files>");
6078
- lines.push(` <direct_importers count="${blast.directImporters.length}">`);
6079
- const skeletonLimit = 5;
6080
- for (let i = 0; i < blast.directImporters.length; i++) {
6081
- const file = blast.directImporters[i];
6082
- lines.push(` <file path="${escapeXML16(file)}">`);
6083
- if (include_skeletons && i < skeletonLimit) {
6084
- const skeleton = await trySkeletonize(ctx, file, project_root);
6085
- if (skeleton) {
6086
- lines.push(" <skeleton>");
6087
- lines.push(escapeXML16(skeleton));
6088
- lines.push(" </skeleton>");
6469
+ out.push(" </direct_importers>");
6470
+ if (withTransitive) {
6471
+ out.push(` <transitive_importers count="${blast.transitiveImporters.length}">`);
6472
+ for (const file of blast.transitiveImporters) {
6473
+ out.push(` <file path="${escapeXML16(file)}" />`);
6089
6474
  }
6475
+ out.push(" </transitive_importers>");
6476
+ } else {
6477
+ out.push(` <transitive_importers count="${blast.transitiveImporters.length}" omitted="budget"/>`);
6090
6478
  }
6091
- lines.push(" </file>");
6092
- }
6093
- lines.push(" </direct_importers>");
6094
- lines.push(` <transitive_importers count="${blast.transitiveImporters.length}">`);
6095
- for (const file of blast.transitiveImporters) {
6096
- lines.push(` <file path="${escapeXML16(file)}" />`);
6097
- }
6098
- lines.push(" </transitive_importers>");
6099
- lines.push(` <call_sites count="${blast.callSites.length}">`);
6100
- for (const cs of blast.callSites) {
6101
- lines.push(
6102
- ` <call_site file="${escapeXML16(cs.file)}" caller="${escapeXML16(cs.callerSymbol)}" callee="${escapeXML16(cs.calleeSymbol)}" />`
6103
- );
6104
- }
6105
- lines.push(" </call_sites>");
6106
- lines.push("</git_diff_review>");
6107
- return lines.join("\n");
6479
+ out.push(` <call_sites count="${blast.callSites.length}">`);
6480
+ for (const cs of blast.callSites) {
6481
+ out.push(
6482
+ ` <call_site file="${escapeXML16(cs.file)}" caller="${escapeXML16(cs.callerSymbol)}" callee="${escapeXML16(cs.calleeSymbol)}" />`
6483
+ );
6484
+ }
6485
+ out.push(" </call_sites>");
6486
+ out.push("</git_diff_review>");
6487
+ return out.join("\n");
6488
+ };
6489
+ const full = render(include_skeletons, true);
6490
+ return maybeBudget(full, async () => render(false, false));
6108
6491
  }
6109
6492
  );
6110
6493
  }
@@ -6113,13 +6496,18 @@ function registerGitDiffReviewTool(registry, ctx) {
6113
6496
  import { z as z20 } from "zod";
6114
6497
  import fs15 from "fs";
6115
6498
  import path17 from "path";
6499
+ var DEFAULT_MAX_RESPONSE_TOKENS7 = 4e3;
6116
6500
  var Schema18 = z20.object({
6117
6501
  symbol: z20.string().min(1).describe("Symbol name to rename (exact match, case-sensitive)"),
6118
6502
  new_name: z20.string().min(1).describe("New name for the symbol"),
6119
6503
  max_files: z20.number().min(1).max(200).optional().default(50).describe(
6120
6504
  "Maximum number of files to scan for occurrences (default: 50)"
6121
6505
  ),
6122
- project_root: ProjectRootField
6506
+ project_root: ProjectRootField,
6507
+ // ─── Phase B2 budget surface ──
6508
+ max_response_tokens: z20.number().int().positive().optional().describe("Soft response budget. Default: 4000 (when opted in). Over-budget drops the per-change before/after lines; keeps the file+occurrence summary so callers can decide which files to drill into."),
6509
+ on_budget_exceeded: z20.enum(["skeleton", "truncate", "error"]).optional().describe("Behavior when over budget. 'skeleton' (default) drops change details; 'truncate' slices; 'error' throws."),
6510
+ response_format: z20.enum(["full", "skeleton", "auto"]).optional().describe("'skeleton' forces the summary-only view; 'full'/'auto' lets the budget decide.")
6123
6511
  });
6124
6512
  function escapeXML17(text) {
6125
6513
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -6167,7 +6555,10 @@ function registerRefactorPreviewTool(registry, ctx) {
6167
6555
  type: "number",
6168
6556
  description: "Maximum number of candidate files to scan (default: 50)"
6169
6557
  },
6170
- project_root: PROJECT_ROOT_JSON_SCHEMA
6558
+ project_root: PROJECT_ROOT_JSON_SCHEMA,
6559
+ max_response_tokens: { type: "number", description: "Soft response budget. Default: 4000 (when opted in)." },
6560
+ on_budget_exceeded: { type: "string", enum: ["skeleton", "truncate", "error"], description: "Behavior over budget. 'skeleton' (default) drops change details; 'truncate' slices; 'error' throws." },
6561
+ response_format: { type: "string", enum: ["full", "skeleton", "auto"], description: "'skeleton' forces summary-only view; 'full'/'auto' lets the budget decide." }
6171
6562
  },
6172
6563
  required: ["symbol", "new_name"]
6173
6564
  }
@@ -6198,36 +6589,53 @@ function registerRefactorPreviewTool(registry, ctx) {
6198
6589
  totalOccurrences += occurrences.length;
6199
6590
  }
6200
6591
  }
6201
- const xmlLines = [
6202
- `<refactor_preview symbol="${escapeXML17(symbol)}" new_name="${escapeXML17(new_name)}" total_files="${fileChanges.length}" total_occurrences="${totalOccurrences}">`
6203
- ];
6204
- xmlLines.push(` <definitions count="${definitions.length}">`);
6205
- for (const def of definitions) {
6206
- xmlLines.push(
6207
- ` <definition file="${escapeXML17(def.filePath)}" type="${escapeXML17(def.type)}" signature="${escapeXML17(def.signature)}" />`
6208
- );
6209
- }
6210
- xmlLines.push(" </definitions>");
6211
- xmlLines.push(` <changes count="${fileChanges.length}">`);
6212
- for (const fc of fileChanges) {
6213
- xmlLines.push(` <file path="${escapeXML17(fc.filePath)}" occurrences="${fc.occurrences.length}">`);
6214
- for (const occ of fc.occurrences) {
6215
- xmlLines.push(` <change line="${occ.line}">`);
6216
- xmlLines.push(` <before>${escapeXML17(occ.before)}</before>`);
6217
- xmlLines.push(` <after>${escapeXML17(occ.after)}</after>`);
6218
- xmlLines.push(" </change>");
6592
+ const render = (includeChanges) => {
6593
+ const xmlLines = [
6594
+ `<refactor_preview symbol="${escapeXML17(symbol)}" new_name="${escapeXML17(new_name)}" total_files="${fileChanges.length}" total_occurrences="${totalOccurrences}">`
6595
+ ];
6596
+ xmlLines.push(` <definitions count="${definitions.length}">`);
6597
+ for (const def of definitions) {
6598
+ xmlLines.push(
6599
+ ` <definition file="${escapeXML17(def.filePath)}" type="${escapeXML17(def.type)}" signature="${escapeXML17(def.signature)}" />`
6600
+ );
6219
6601
  }
6220
- xmlLines.push(" </file>");
6221
- }
6222
- xmlLines.push(" </changes>");
6223
- xmlLines.push("</refactor_preview>");
6224
- return xmlLines.join("\n");
6602
+ xmlLines.push(" </definitions>");
6603
+ xmlLines.push(` <changes count="${fileChanges.length}">`);
6604
+ for (const fc of fileChanges) {
6605
+ if (includeChanges) {
6606
+ xmlLines.push(` <file path="${escapeXML17(fc.filePath)}" occurrences="${fc.occurrences.length}">`);
6607
+ for (const occ of fc.occurrences) {
6608
+ xmlLines.push(` <change line="${occ.line}">`);
6609
+ xmlLines.push(` <before>${escapeXML17(occ.before)}</before>`);
6610
+ xmlLines.push(` <after>${escapeXML17(occ.after)}</after>`);
6611
+ xmlLines.push(" </change>");
6612
+ }
6613
+ xmlLines.push(" </file>");
6614
+ } else {
6615
+ xmlLines.push(` <file path="${escapeXML17(fc.filePath)}" occurrences="${fc.occurrences.length}"/>`);
6616
+ }
6617
+ }
6618
+ xmlLines.push(" </changes>");
6619
+ xmlLines.push("</refactor_preview>");
6620
+ return xmlLines.join("\n");
6621
+ };
6622
+ const full = render(true);
6623
+ if (!hasBudgetArgs(args)) return full;
6624
+ const result = await enforceBudget({
6625
+ full,
6626
+ args: readBudgetArgs(args),
6627
+ toolName: "ctx_refactor_preview",
6628
+ defaultMaxTokens: DEFAULT_MAX_RESPONSE_TOKENS7,
6629
+ skeletonProducer: async () => render(false)
6630
+ });
6631
+ return wrapResponse(result);
6225
6632
  }
6226
6633
  );
6227
6634
  }
6228
6635
 
6229
6636
  // packages/core/src/tools/execution-flow.ts
6230
6637
  import { z as z21 } from "zod";
6638
+ var DEFAULT_MAX_RESPONSE_TOKENS8 = 4e3;
6231
6639
  var Schema19 = z21.object({
6232
6640
  entry_point: z21.string().min(1).describe("Symbol name to start the execution flow from"),
6233
6641
  entry_file: z21.string().optional().describe(
@@ -6237,7 +6645,11 @@ var Schema19 = z21.object({
6237
6645
  max_nodes: z21.number().min(1).max(200).optional().default(50).describe(
6238
6646
  "Max total steps to include in output (default: 50)"
6239
6647
  ),
6240
- project_root: ProjectRootField
6648
+ project_root: ProjectRootField,
6649
+ // ─── Phase B2 budget surface ──
6650
+ max_response_tokens: z21.number().int().positive().optional().describe("Soft response budget. Default: 4000 (when opted in). No skeleton fallback \u2014 response is already a bounded step list; over-budget falls through to truncation."),
6651
+ on_budget_exceeded: z21.enum(["skeleton", "truncate", "error"]).optional().describe("Behavior when over budget. 'skeleton'/'truncate' both slice; 'error' throws."),
6652
+ response_format: z21.enum(["full", "skeleton", "auto"]).optional().describe("'full'/'auto' default; 'skeleton' same output.")
6241
6653
  });
6242
6654
  function escapeXML18(text) {
6243
6655
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -6299,7 +6711,10 @@ function registerExecutionFlowTool(registry, ctx) {
6299
6711
  type: "number",
6300
6712
  description: "Max total steps to return (default: 50)"
6301
6713
  },
6302
- project_root: PROJECT_ROOT_JSON_SCHEMA
6714
+ project_root: PROJECT_ROOT_JSON_SCHEMA,
6715
+ max_response_tokens: { type: "number", description: "Soft response budget. Default: 4000 (when opted in)." },
6716
+ on_budget_exceeded: { type: "string", enum: ["skeleton", "truncate", "error"], description: "Behavior over budget." },
6717
+ response_format: { type: "string", enum: ["full", "skeleton", "auto"], description: "'full'/'auto' default; 'skeleton' same output." }
6303
6718
  },
6304
6719
  required: ["entry_point"]
6305
6720
  }
@@ -6325,10 +6740,20 @@ function registerExecutionFlowTool(registry, ctx) {
6325
6740
  }
6326
6741
  }
6327
6742
  }
6743
+ const maybeBudget = async (full) => {
6744
+ if (!hasBudgetArgs(args)) return full;
6745
+ const result = await enforceBudget({
6746
+ full,
6747
+ args: readBudgetArgs(args),
6748
+ toolName: "ctx_execution_flow",
6749
+ defaultMaxTokens: DEFAULT_MAX_RESPONSE_TOKENS8
6750
+ });
6751
+ return wrapResponse(result);
6752
+ };
6328
6753
  if (!resolvedFile) {
6329
- return `<execution_flow entry="${escapeXML18(entry_point)}" total_steps="0" has_cycles="false">
6754
+ return maybeBudget(`<execution_flow entry="${escapeXML18(entry_point)}" total_steps="0" has_cycles="false">
6330
6755
  <!-- No call graph entries found for symbol -->
6331
- </execution_flow>`;
6756
+ </execution_flow>`);
6332
6757
  }
6333
6758
  const { steps, hasCycles } = buildFlowSteps(
6334
6759
  entry_point,
@@ -6353,7 +6778,7 @@ function registerExecutionFlowTool(registry, ctx) {
6353
6778
  }
6354
6779
  }
6355
6780
  xmlLines.push("</execution_flow>");
6356
- return xmlLines.join("\n");
6781
+ return maybeBudget(xmlLines.join("\n"));
6357
6782
  }
6358
6783
  );
6359
6784
  }
@@ -6362,6 +6787,7 @@ function registerExecutionFlowTool(registry, ctx) {
6362
6787
  import { z as z22 } from "zod";
6363
6788
  import fs16 from "fs";
6364
6789
  import path18 from "path";
6790
+ var DEFAULT_MAX_RESPONSE_TOKENS9 = 4e3;
6365
6791
  var ALIAS_REGEX = /^[a-z0-9-]{1,40}$/;
6366
6792
  var RESERVED_ALIASES = /* @__PURE__ */ new Set([
6367
6793
  "register",
@@ -6464,7 +6890,11 @@ var Schema20 = z22.object({
6464
6890
  repos: z22.array(z22.string()).optional().describe(
6465
6891
  "Specific repo root paths to search. Omit to search all registered repos."
6466
6892
  ),
6467
- project_root: ProjectRootField
6893
+ project_root: ProjectRootField,
6894
+ // ─── Phase B2 budget surface ──
6895
+ max_response_tokens: z22.number().int().positive().optional().describe("Soft response budget. Default: 4000 (when opted in). Over-budget drops content snippets (repo + path + score only)."),
6896
+ on_budget_exceeded: z22.enum(["skeleton", "truncate", "error"]).optional().describe("Behavior when over budget. 'skeleton' (default) drops content snippets; 'truncate' slices; 'error' throws."),
6897
+ response_format: z22.enum(["full", "skeleton", "auto"]).optional().describe("'skeleton' forces the path-and-score-only view; 'full'/'auto' lets the budget decide.")
6468
6898
  });
6469
6899
  function escapeXML19(text) {
6470
6900
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -6486,7 +6916,10 @@ function registerCrossRepoSearchTool(registry, _ctx, registryFilePath) {
6486
6916
  items: { type: "string" },
6487
6917
  description: "Specific repo root paths to search. Omit to search all registered repos."
6488
6918
  },
6489
- project_root: PROJECT_ROOT_JSON_SCHEMA
6919
+ project_root: PROJECT_ROOT_JSON_SCHEMA,
6920
+ max_response_tokens: { type: "number", description: "Soft response budget. Default: 4000 (when opted in)." },
6921
+ on_budget_exceeded: { type: "string", enum: ["skeleton", "truncate", "error"], description: "Behavior over budget. 'skeleton' (default) drops content snippets; 'truncate' slices; 'error' throws." },
6922
+ response_format: { type: "string", enum: ["full", "skeleton", "auto"], description: "'skeleton' forces path+score-only view; 'full'/'auto' lets the budget decide." }
6490
6923
  },
6491
6924
  required: ["query"]
6492
6925
  }
@@ -6555,19 +6988,32 @@ function registerCrossRepoSearchTool(registry, _ctx, registryFilePath) {
6555
6988
  );
6556
6989
  }
6557
6990
  xmlLines.push(" </repos>");
6558
- xmlLines.push(` <results count="${topResults.length}">`);
6559
- for (const r of topResults) {
6560
- xmlLines.push(
6561
- ` <result repo="${escapeXML19(r.repoName)}" file="${escapeXML19(r.filePath)}" score="${r.score.toFixed(4)}">`
6562
- );
6563
- if (r.content) {
6564
- xmlLines.push(` ${escapeXML19(r.content.slice(0, 200))}`);
6991
+ const render = (includeContent) => {
6992
+ const out = [...xmlLines];
6993
+ out.push(` <results count="${topResults.length}">`);
6994
+ for (const r of topResults) {
6995
+ if (includeContent && r.content) {
6996
+ out.push(` <result repo="${escapeXML19(r.repoName)}" file="${escapeXML19(r.filePath)}" score="${r.score.toFixed(4)}">`);
6997
+ out.push(` ${escapeXML19(r.content.slice(0, 200))}`);
6998
+ out.push(" </result>");
6999
+ } else {
7000
+ out.push(` <result repo="${escapeXML19(r.repoName)}" file="${escapeXML19(r.filePath)}" score="${r.score.toFixed(4)}"/>`);
7001
+ }
6565
7002
  }
6566
- xmlLines.push(" </result>");
6567
- }
6568
- xmlLines.push(" </results>");
6569
- xmlLines.push("</cross_repo_search>");
6570
- return xmlLines.join("\n");
7003
+ out.push(" </results>");
7004
+ out.push("</cross_repo_search>");
7005
+ return out.join("\n");
7006
+ };
7007
+ const full = render(true);
7008
+ if (!hasBudgetArgs(args)) return full;
7009
+ const result = await enforceBudget({
7010
+ full,
7011
+ args: readBudgetArgs(args),
7012
+ toolName: "ctx_cross_repo_search",
7013
+ defaultMaxTokens: DEFAULT_MAX_RESPONSE_TOKENS9,
7014
+ skeletonProducer: async () => render(false)
7015
+ });
7016
+ return wrapResponse(result);
6571
7017
  }
6572
7018
  );
6573
7019
  }
@@ -6576,6 +7022,7 @@ function registerCrossRepoSearchTool(registry, _ctx, registryFilePath) {
6576
7022
  import { z as z23 } from "zod";
6577
7023
  import fs17 from "fs";
6578
7024
  import path19 from "path";
7025
+ var DEFAULT_MAX_RESPONSE_TOKENS10 = 2e3;
6579
7026
  var Schema21 = z23.object({
6580
7027
  symbol: z23.string().min(1).describe("Symbol name to rename (exact, case-sensitive)"),
6581
7028
  new_name: z23.string().min(1).describe("New name for the symbol"),
@@ -6585,7 +7032,11 @@ var Schema21 = z23.object({
6585
7032
  max_files: z23.number().min(1).max(200).optional().default(50).describe(
6586
7033
  "Maximum candidate files to process (default: 50)"
6587
7034
  ),
6588
- project_root: ProjectRootField
7035
+ project_root: ProjectRootField,
7036
+ // ─── Phase B2 budget surface ──
7037
+ max_response_tokens: z23.number().int().positive().optional().describe("Soft response budget. Default: 2000 (when opted in). No skeleton fallback \u2014 response is already compact; over-budget falls through to truncation."),
7038
+ on_budget_exceeded: z23.enum(["skeleton", "truncate", "error"]).optional().describe("Behavior when over budget. 'skeleton'/'truncate' both slice the XML; 'error' throws."),
7039
+ response_format: z23.enum(["full", "skeleton", "auto"]).optional().describe("'full'/'auto' default; 'skeleton' produces the same output.")
6589
7040
  });
6590
7041
  function escapeXML20(text) {
6591
7042
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -6619,7 +7070,10 @@ function registerApplyRefactorTool(registry, ctx) {
6619
7070
  new_name: { type: "string", description: "New name" },
6620
7071
  dry_run: { type: "boolean", description: "Preview only, no writes (default: false)" },
6621
7072
  max_files: { type: "number", description: "Max candidate files (default: 50)" },
6622
- project_root: PROJECT_ROOT_JSON_SCHEMA
7073
+ project_root: PROJECT_ROOT_JSON_SCHEMA,
7074
+ max_response_tokens: { type: "number", description: "Soft response budget. Default: 2000 (when opted in)." },
7075
+ on_budget_exceeded: { type: "string", enum: ["skeleton", "truncate", "error"], description: "Behavior over budget." },
7076
+ response_format: { type: "string", enum: ["full", "skeleton", "auto"], description: "'full'/'auto' default; 'skeleton' same output." }
6623
7077
  },
6624
7078
  required: ["symbol", "new_name"]
6625
7079
  }
@@ -6659,7 +7113,15 @@ function registerApplyRefactorTool(registry, ctx) {
6659
7113
  );
6660
7114
  }
6661
7115
  xml.push("</apply_refactor>");
6662
- return xml.join("\n");
7116
+ const full = xml.join("\n");
7117
+ if (!hasBudgetArgs(args)) return full;
7118
+ const result = await enforceBudget({
7119
+ full,
7120
+ args: readBudgetArgs(args),
7121
+ toolName: "ctx_apply_refactor",
7122
+ defaultMaxTokens: DEFAULT_MAX_RESPONSE_TOKENS10
7123
+ });
7124
+ return wrapResponse(result);
6663
7125
  }
6664
7126
  );
6665
7127
  }
@@ -6780,13 +7242,18 @@ function registerDetectChangesTool(registry, ctx) {
6780
7242
  import { z as z25 } from "zod";
6781
7243
  import fs18 from "fs";
6782
7244
  import path20 from "path";
7245
+ var DEFAULT_MAX_RESPONSE_TOKENS11 = 4e3;
6783
7246
  var Schema23 = z25.object({
6784
7247
  query: z25.string().min(1).describe("Search term \u2014 literal or /regex/"),
6785
7248
  mode: z25.enum(["hybrid", "keyword", "semantic"]).optional().default("hybrid"),
6786
7249
  case_sensitive: z25.boolean().optional().default(false),
6787
7250
  limit: z25.number().min(1).max(100).optional().default(20),
6788
7251
  context_lines: z25.number().min(0).max(5).optional().default(1),
6789
- project_root: ProjectRootField
7252
+ project_root: ProjectRootField,
7253
+ // ─── Phase B2 budget surface ──
7254
+ max_response_tokens: z25.number().int().positive().optional().describe("Soft response budget. Default: 4000 (when budget surface is opted into). Over-budget rebuilds the result list without match snippets (paths + match counts only)."),
7255
+ on_budget_exceeded: z25.enum(["skeleton", "truncate", "error"]).optional().describe("Behavior when over budget. 'skeleton' (default) drops snippets; 'truncate' slices the raw XML; 'error' throws."),
7256
+ response_format: z25.enum(["full", "skeleton", "auto"]).optional().describe("'skeleton' forces the path-and-count-only view; 'full'/'auto' lets the budget decide.")
6790
7257
  });
6791
7258
  function escapeXML22(text) {
6792
7259
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -6841,27 +7308,53 @@ function registerFullTextSearchTool(registry, ctx) {
6841
7308
  case_sensitive: { type: "boolean", description: "Case-sensitive match (default: false)" },
6842
7309
  limit: { type: "number", description: "Max results (default: 20)" },
6843
7310
  context_lines: { type: "number", description: "Context lines around each match (default: 1)" },
6844
- project_root: PROJECT_ROOT_JSON_SCHEMA
7311
+ project_root: PROJECT_ROOT_JSON_SCHEMA,
7312
+ max_response_tokens: {
7313
+ type: "number",
7314
+ description: "Soft response budget in tokens. Default: 4000 (when opted into)."
7315
+ },
7316
+ on_budget_exceeded: {
7317
+ type: "string",
7318
+ enum: ["skeleton", "truncate", "error"],
7319
+ description: "Behavior when over budget. 'skeleton' (default) drops match snippets; 'truncate' slices; 'error' throws."
7320
+ },
7321
+ response_format: {
7322
+ type: "string",
7323
+ enum: ["full", "skeleton", "auto"],
7324
+ description: "'skeleton' forces path+count-only view; 'full'/'auto' lets the budget decide."
7325
+ }
6845
7326
  },
6846
7327
  required: ["query"]
6847
7328
  }
6848
7329
  },
6849
7330
  async (args) => {
6850
- const { query, mode, case_sensitive, limit, context_lines, project_root } = Schema23.parse(args);
7331
+ const parsed = Schema23.parse(args);
7332
+ const { query, mode, case_sensitive, limit, context_lines, project_root } = parsed;
7333
+ const maybeBudget = async (full, skeletonProducer) => {
7334
+ if (!hasBudgetArgs(args)) return full;
7335
+ const result = await enforceBudget({
7336
+ full,
7337
+ args: readBudgetArgs(args),
7338
+ toolName: "ctx_full_text_search",
7339
+ defaultMaxTokens: DEFAULT_MAX_RESPONSE_TOKENS11,
7340
+ skeletonProducer
7341
+ });
7342
+ return wrapResponse(result);
7343
+ };
6851
7344
  if (mode === "semantic") {
6852
7345
  try {
6853
7346
  const { generateEmbedding: generateEmbedding2 } = await import("./embedder-R4KCXSGO.js");
6854
7347
  const store = await ctx.getStore(project_root);
6855
7348
  const embedding = await generateEmbedding2(query);
6856
7349
  const results = await store.search(embedding, limit);
6857
- const xml2 = [`<full_text_search query="${escapeXML22(query)}" mode="semantic" count="${results.length}">`];
7350
+ const xml = [`<full_text_search query="${escapeXML22(query)}" mode="semantic" count="${results.length}">`];
6858
7351
  for (const r of results) {
6859
- xml2.push(` <result file="${escapeXML22(r.filePath)}" matches="0"/>`);
7352
+ xml.push(` <result file="${escapeXML22(r.filePath)}" matches="0"/>`);
6860
7353
  }
6861
- xml2.push("</full_text_search>");
6862
- return xml2.join("\n");
7354
+ xml.push("</full_text_search>");
7355
+ return maybeBudget(xml.join("\n"));
6863
7356
  } catch {
6864
- return `<full_text_search query="${escapeXML22(query)}" mode="semantic" count="0"/>`;
7357
+ return maybeBudget(`<full_text_search query="${escapeXML22(query)}" mode="semantic" count="0"/>`);
6865
7358
  }
6866
7359
  }
6867
7360
  const pattern = buildPattern(query, case_sensitive);
@@ -6903,18 +7396,28 @@ function registerFullTextSearchTool(registry, ctx) {
6903
7396
  } catch {
6904
7397
  }
6905
7398
  }
6906
- const xml = [
6907
- `<full_text_search query="${escapeXML22(query)}" mode="${mode}" case_sensitive="${case_sensitive}" count="${merged.length}">`
6908
- ];
6909
- for (const r of merged) {
6910
- xml.push(` <result file="${escapeXML22(r.filePath)}" matches="${r.matchCount}">`);
6911
- for (const snippet of r.snippets) {
6912
- xml.push(` <match><![CDATA[${snippet}]]></match>`);
7399
+ const render = (includeSnippets) => {
7400
+ const xml = [
7401
+ `<full_text_search query="${escapeXML22(query)}" mode="${mode}" case_sensitive="${case_sensitive}" count="${merged.length}">`
7402
+ ];
7403
+ for (const r of merged) {
7404
+ if (includeSnippets && r.snippets.length > 0) {
7405
+ xml.push(` <result file="${escapeXML22(r.filePath)}" matches="${r.matchCount}">`);
7406
+ for (const snippet of r.snippets) {
7407
+ xml.push(` <match><![CDATA[${snippet}]]></match>`);
7408
+ }
7409
+ xml.push(" </result>");
7410
+ } else {
7411
+ xml.push(` <result file="${escapeXML22(r.filePath)}" matches="${r.matchCount}"/>`);
7412
+ }
6913
7413
  }
6914
- xml.push(" </result>");
6915
- }
6916
- xml.push("</full_text_search>");
6917
- return xml.join("\n");
7414
+ xml.push("</full_text_search>");
7415
+ return xml.join("\n");
7416
+ };
7417
+ return maybeBudget(
7418
+ render(true),
7419
+ async () => render(false)
7420
+ );
6918
7421
  }
6919
7422
  );
6920
7423
  }
@@ -7301,6 +7804,7 @@ function registerGraphDiffTool(registry, ctx) {
7301
7804
 
7302
7805
  // packages/core/src/tools/find-large-functions.ts
7303
7806
  import { z as z30 } from "zod";
7807
+ var DEFAULT_MAX_RESPONSE_TOKENS12 = 2e3;
7304
7808
  var schema3 = z30.object({
7305
7809
  threshold: z30.number().int().min(1).default(50).describe(
7306
7810
  "Minimum line count to include (default: 50). Functions/classes shorter than this are excluded."
@@ -7311,7 +7815,11 @@ var schema3 = z30.object({
7311
7815
  limit: z30.number().int().min(1).max(200).default(30).describe(
7312
7816
  "Maximum results to return (default: 30)."
7313
7817
  ),
7314
- project_root: ProjectRootField
7818
+ project_root: ProjectRootField,
7819
+ // ─── Phase B2 budget surface ──
7820
+ max_response_tokens: z30.number().int().positive().optional().describe("Soft response budget. Default: 2000 (when opted in). No skeleton fallback \u2014 response is already structural; over-budget falls through to truncation."),
7821
+ on_budget_exceeded: z30.enum(["skeleton", "truncate", "error"]).optional().describe("Behavior when over budget. 'skeleton'/'truncate' both slice the XML; 'error' throws."),
7822
+ response_format: z30.enum(["full", "skeleton", "auto"]).optional().describe("'full'/'auto' default; 'skeleton' produces the same output (response is already compact).")
7315
7823
  });
7316
7824
  function findLargeFunctions(graph, threshold, fileFilter) {
7317
7825
  const results = [];
@@ -7361,7 +7869,10 @@ function registerFindLargeFunctionsTool(registry, ctx) {
7361
7869
  type: "number",
7362
7870
  description: "Maximum results to return (default: 30, max: 200)."
7363
7871
  },
7364
- project_root: PROJECT_ROOT_JSON_SCHEMA
7872
+ project_root: PROJECT_ROOT_JSON_SCHEMA,
7873
+ max_response_tokens: { type: "number", description: "Soft response budget. Default: 2000 (when opted in)." },
7874
+ on_budget_exceeded: { type: "string", enum: ["skeleton", "truncate", "error"], description: "Behavior over budget. 'skeleton'/'truncate' slice; 'error' throws." },
7875
+ response_format: { type: "string", enum: ["full", "skeleton", "auto"], description: "'full'/'auto' default; 'skeleton' same output (already compact)." }
7365
7876
  }
7366
7877
  }
7367
7878
  },
@@ -7369,19 +7880,29 @@ function registerFindLargeFunctionsTool(registry, ctx) {
7369
7880
  const { threshold, file_filter, limit, project_root } = schema3.parse(args);
7370
7881
  const graph = await ctx.getGraph(project_root);
7371
7882
  const results = findLargeFunctions(graph, threshold, file_filter).slice(0, limit);
7883
+ let full;
7372
7884
  if (results.length === 0) {
7373
- return `<ctx_find_large_functions threshold="${threshold}" count="0">
7885
+ full = `<ctx_find_large_functions threshold="${threshold}" count="0">
7374
7886
  <message>No functions or classes exceed ${threshold} lines.</message>
7375
7887
  </ctx_find_large_functions>`;
7376
- }
7377
- const lines = [
7378
- `<ctx_find_large_functions threshold="${threshold}" count="${results.length}">`,
7379
- ...results.map(
7380
- (r) => ` <symbol name="${escapeXML24(r.name)}" type="${r.type}" file="${escapeXML24(r.filePath)}" start="${r.startLine}" end="${r.endLine}" lines="${r.lineCount}" />`
7381
- ),
7382
- `</ctx_find_large_functions>`
7383
- ];
7384
- return lines.join("\n");
7888
+ } else {
7889
+ const lines = [
7890
+ `<ctx_find_large_functions threshold="${threshold}" count="${results.length}">`,
7891
+ ...results.map(
7892
+ (r) => ` <symbol name="${escapeXML24(r.name)}" type="${r.type}" file="${escapeXML24(r.filePath)}" start="${r.startLine}" end="${r.endLine}" lines="${r.lineCount}" />`
7893
+ ),
7894
+ `</ctx_find_large_functions>`
7895
+ ];
7896
+ full = lines.join("\n");
7897
+ }
7898
+ if (!hasBudgetArgs(args)) return full;
7899
+ const result = await enforceBudget({
7900
+ full,
7901
+ args: readBudgetArgs(args),
7902
+ toolName: "ctx_find_large_functions",
7903
+ defaultMaxTokens: DEFAULT_MAX_RESPONSE_TOKENS12
7904
+ });
7905
+ return wrapResponse(result);
7385
7906
  }
7386
7907
  );
7387
7908
  }
@@ -8821,7 +9342,7 @@ var TELEMETRY_DISABLED = TELEMETRY_LEVEL === "off";
8821
9342
  function getTelemetryLevel() {
8822
9343
  return TELEMETRY_LEVEL;
8823
9344
  }
8824
- var CTXLOOM_VERSION = "1.2.6".length > 0 ? "1.2.6" : "dev";
9345
+ var CTXLOOM_VERSION = "1.3.0".length > 0 ? "1.3.0" : "dev";
8825
9346
  var POSTHOG_HOST = "https://eu.i.posthog.com";
8826
9347
  var POSTHOG_KEY = process.env["POSTHOG_API_KEY"] ?? (true ? "phc_CiDkmFLcZ2K6uCpcoSUQLmFrnnUvsyXGhSxopX5TVKE6" : "");
8827
9348
  var SENTRY_DSN = process.env["SENTRY_DSN"] ?? (true ? "https://81c94a0f04a8e242dee493ac1e17f733@o4508531702497280.ingest.de.sentry.io/4511256875368528" : "");
@@ -9551,4 +10072,4 @@ export {
9551
10072
  FirstTouchTracker,
9552
10073
  EmittedOnceTracker
9553
10074
  };
9554
- //# sourceMappingURL=chunk-PVKQ5UQS.js.map
10075
+ //# sourceMappingURL=chunk-Q2KTZNNU.js.map