ctxloom-pro 1.2.5 → 1.2.7

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.
@@ -4,7 +4,7 @@ import {
4
4
  import {
5
5
  collectFiles,
6
6
  generateEmbedding
7
- } from "./chunk-NMXQC5CG.js";
7
+ } from "./chunk-UVR65QBJ.js";
8
8
  import {
9
9
  logger
10
10
  } from "./chunk-TYDMSHV7.js";
@@ -165,7 +165,7 @@ var GrammarLoader = class {
165
165
  const url = entry.downloadUrl?.trim() ? entry.downloadUrl : `${this.cdn}/${entry.npmPackage}@${entry.version}/${entry.wasmFile}`;
166
166
  const dest = path.join(this.cacheDir, entry.wasmFile);
167
167
  logger.info("Downloading grammar", { language, url, source: entry.downloadUrl?.trim() ? "custom" : "cdn" });
168
- fs.mkdirSync(this.cacheDir, { recursive: true });
168
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
169
169
  await this.download(url, dest);
170
170
  if (entry.sha256 && !this.skipVerify) {
171
171
  await this.verifyHash(dest, entry.sha256, language);
@@ -179,6 +179,12 @@ var GrammarLoader = class {
179
179
  return new Promise((resolve, reject) => {
180
180
  const tmp = dest + ".tmp";
181
181
  const file = fs.createWriteStream(tmp);
182
+ const onFileError = (err) => {
183
+ file.destroy();
184
+ fs.rmSync(tmp, { force: true });
185
+ reject(err);
186
+ };
187
+ file.on("error", onFileError);
182
188
  const request = https.get(url, (response) => {
183
189
  if (response.statusCode === 301 || response.statusCode === 302) {
184
190
  const location = response.headers.location;
@@ -208,11 +214,6 @@ var GrammarLoader = class {
208
214
  fs.rmSync(tmp, { force: true });
209
215
  reject(err);
210
216
  });
211
- file.on("error", (err) => {
212
- file.destroy();
213
- fs.rmSync(tmp, { force: true });
214
- reject(err);
215
- });
216
217
  file.on("finish", () => {
217
218
  fs.renameSync(tmp, dest);
218
219
  resolve();
@@ -843,15 +844,14 @@ var ASTParser = class {
843
844
  return;
844
845
  }
845
846
  case "import_declaration": {
847
+ const specs = [];
846
848
  const walkImport = (n) => {
847
849
  if (n.type === "import_spec") {
848
850
  const pathNode = n.childForFieldName?.("path");
849
851
  if (pathNode) {
850
852
  const spec = pathNode.text.replace(/^"|"$/g, "");
851
- nodes.push({
852
- type: "import",
853
+ specs.push({
853
854
  name: spec,
854
- source: spec,
855
855
  startLine: n.startPosition.row + 1,
856
856
  endLine: n.endPosition.row + 1
857
857
  });
@@ -862,6 +862,25 @@ var ASTParser = class {
862
862
  }
863
863
  };
864
864
  walkImport(node);
865
+ if (specs.length > 0) {
866
+ const firstSpec = specs[0];
867
+ nodes.push({
868
+ type: "import",
869
+ name: firstSpec.name,
870
+ source: firstSpec.name,
871
+ startLine: node.startPosition.row + 1,
872
+ endLine: node.endPosition.row + 1
873
+ });
874
+ }
875
+ for (const spec of specs) {
876
+ nodes.push({
877
+ type: "import",
878
+ name: spec.name,
879
+ source: spec.name,
880
+ startLine: spec.startLine,
881
+ endLine: spec.endLine
882
+ });
883
+ }
865
884
  return;
866
885
  }
867
886
  }
@@ -1183,6 +1202,24 @@ var ASTParser = class {
1183
1202
  const lines = source.split("\n");
1184
1203
  const walk = (node) => {
1185
1204
  switch (node.type) {
1205
+ case "call": {
1206
+ const methodNode = node.childForFieldName?.("method") ?? node.children.find((c) => c?.type === "identifier");
1207
+ const name = methodNode?.text ?? "";
1208
+ if (name === "require" || name === "require_relative" || name === "load" || name === "autoload") {
1209
+ const argsNode = node.childForFieldName?.("arguments") ?? node.children.find((c) => c?.type === "argument_list");
1210
+ const firstStringArg = argsNode?.children.find((c) => c?.type === "string" || c?.type === "simple_symbol");
1211
+ const spec = firstStringArg?.text.replace(/^['":]+|['"]+$/g, "") ?? "";
1212
+ nodes.push({
1213
+ type: "import",
1214
+ name: spec,
1215
+ source: spec,
1216
+ startLine: node.startPosition.row + 1,
1217
+ endLine: node.endPosition.row + 1
1218
+ });
1219
+ return;
1220
+ }
1221
+ break;
1222
+ }
1186
1223
  case "method":
1187
1224
  case "singleton_method": {
1188
1225
  const nameNode = node.childForFieldName?.("name") ?? node.children.find((c) => c?.type === "identifier");
@@ -1470,9 +1507,18 @@ var ASTParser = class {
1470
1507
  const walk = (node) => {
1471
1508
  switch (node.type) {
1472
1509
  case "import_or_export": {
1473
- const uriNode = node.children.find((c) => c?.type === "uri");
1510
+ const findUri = (n) => {
1511
+ if (n.type === "uri") return n;
1512
+ for (const c of n.children) {
1513
+ if (!c) continue;
1514
+ const hit = findUri(c);
1515
+ if (hit) return hit;
1516
+ }
1517
+ return void 0;
1518
+ };
1519
+ const uriNode = findUri(node);
1474
1520
  const uri = uriNode?.text?.replace(/['"]/g, "") ?? "";
1475
- if (uri.startsWith(".")) {
1521
+ if (uri) {
1476
1522
  nodes.push({
1477
1523
  type: "import",
1478
1524
  name: uri,
@@ -3411,8 +3457,8 @@ var CoChangeIndex = class _CoChangeIndex {
3411
3457
  if (event.isBulk || event.isMerge) return;
3412
3458
  const paths = event.files.map((f) => f.path);
3413
3459
  if (paths.length === 0) return;
3414
- for (const path35 of paths) {
3415
- this.nodeCounts.set(path35, (this.nodeCounts.get(path35) ?? 0) + 1);
3460
+ for (const path36 of paths) {
3461
+ this.nodeCounts.set(path36, (this.nodeCounts.get(path36) ?? 0) + 1);
3416
3462
  }
3417
3463
  for (let i = 0; i < paths.length; i++) {
3418
3464
  for (let j = i + 1; j < paths.length; j++) {
@@ -3559,8 +3605,8 @@ var ChurnIndex = class _ChurnIndex {
3559
3605
  */
3560
3606
  snapshot() {
3561
3607
  const nodes = {};
3562
- for (const [path35, raw] of this.nodes) {
3563
- nodes[path35] = {
3608
+ for (const [path36, raw] of this.nodes) {
3609
+ nodes[path36] = {
3564
3610
  commits: raw.commits,
3565
3611
  churnLines: raw.churnLines,
3566
3612
  bugCommits: raw.bugCommits,
@@ -3575,8 +3621,8 @@ var ChurnIndex = class _ChurnIndex {
3575
3621
  */
3576
3622
  static load(s) {
3577
3623
  const idx = new _ChurnIndex();
3578
- for (const [path35, raw] of Object.entries(s.nodes)) {
3579
- idx.nodes.set(path35, {
3624
+ for (const [path36, raw] of Object.entries(s.nodes)) {
3625
+ idx.nodes.set(path36, {
3580
3626
  commits: raw.commits,
3581
3627
  churnLines: raw.churnLines,
3582
3628
  bugCommits: raw.bugCommits,
@@ -3589,8 +3635,8 @@ var ChurnIndex = class _ChurnIndex {
3589
3635
  // -------------------------------------------------------------------------
3590
3636
  // Private helpers
3591
3637
  // -------------------------------------------------------------------------
3592
- getOrCreate(path35) {
3593
- const existing = this.nodes.get(path35);
3638
+ getOrCreate(path36) {
3639
+ const existing = this.nodes.get(path36);
3594
3640
  if (existing !== void 0) return existing;
3595
3641
  const fresh = {
3596
3642
  commits: 0,
@@ -3599,7 +3645,7 @@ var ChurnIndex = class _ChurnIndex {
3599
3645
  authorCounts: {},
3600
3646
  lastTouch: 0
3601
3647
  };
3602
- this.nodes.set(path35, fresh);
3648
+ this.nodes.set(path36, fresh);
3603
3649
  return fresh;
3604
3650
  }
3605
3651
  };
@@ -3680,12 +3726,12 @@ var OwnershipIndex = class _OwnershipIndex {
3680
3726
  */
3681
3727
  snapshot() {
3682
3728
  const nodes = {};
3683
- for (const [path35, raw] of this.nodes) {
3729
+ for (const [path36, raw] of this.nodes) {
3684
3730
  const authorWeights = {};
3685
3731
  for (const [email, entry] of Object.entries(raw.authorWeights)) {
3686
3732
  authorWeights[email] = { ...entry };
3687
3733
  }
3688
- nodes[path35] = { authorWeights, lastTouch: raw.lastTouch };
3734
+ nodes[path36] = { authorWeights, lastTouch: raw.lastTouch };
3689
3735
  }
3690
3736
  return { version: 1, nodes };
3691
3737
  }
@@ -3694,23 +3740,23 @@ var OwnershipIndex = class _OwnershipIndex {
3694
3740
  */
3695
3741
  static load(s) {
3696
3742
  const idx = new _OwnershipIndex();
3697
- for (const [path35, raw] of Object.entries(s.nodes)) {
3743
+ for (const [path36, raw] of Object.entries(s.nodes)) {
3698
3744
  const authorWeights = {};
3699
3745
  for (const [email, entry] of Object.entries(raw.authorWeights)) {
3700
3746
  authorWeights[email] = { ...entry };
3701
3747
  }
3702
- idx.nodes.set(path35, { authorWeights, lastTouch: raw.lastTouch });
3748
+ idx.nodes.set(path36, { authorWeights, lastTouch: raw.lastTouch });
3703
3749
  }
3704
3750
  return idx;
3705
3751
  }
3706
3752
  // -------------------------------------------------------------------------
3707
3753
  // Private helpers
3708
3754
  // -------------------------------------------------------------------------
3709
- getOrCreate(path35) {
3710
- const existing = this.nodes.get(path35);
3755
+ getOrCreate(path36) {
3756
+ const existing = this.nodes.get(path36);
3711
3757
  if (existing !== void 0) return existing;
3712
3758
  const fresh = { authorWeights: {}, lastTouch: 0 };
3713
- this.nodes.set(path35, fresh);
3759
+ this.nodes.set(path36, fresh);
3714
3760
  return fresh;
3715
3761
  }
3716
3762
  };
@@ -4564,86 +4610,349 @@ var ToolRegistry = class {
4564
4610
 
4565
4611
  // packages/core/src/tools/search.ts
4566
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;
4567
4814
  var Schema = z3.object({
4568
4815
  query: z3.string().describe("Search query \u2014 natural language or code fragment"),
4569
4816
  limit: z3.number().max(100).optional().default(10).describe("Maximum results to return"),
4570
- 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.")
4571
4822
  });
4572
4823
  function escapeXML3(text) {
4573
4824
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4574
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
+ }
4575
4838
  function registerSearchTool(registry, ctx) {
4576
4839
  registry.register(
4577
4840
  "ctx_search",
4578
4841
  {
4579
4842
  name: "ctx_search",
4580
- 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.",
4581
4844
  inputSchema: {
4582
4845
  type: "object",
4583
4846
  properties: {
4584
4847
  query: { type: "string", description: "Search query \u2014 natural language or code fragment" },
4585
4848
  limit: { type: "number", description: "Maximum results to return (default: 10)" },
4586
- 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
+ }
4587
4864
  },
4588
4865
  required: ["query"]
4589
4866
  }
4590
4867
  },
4591
4868
  async (args) => {
4592
- const { query, limit, project_root } = Schema.parse(args);
4593
- const [store, graph] = await Promise.all([ctx.getStore(project_root), ctx.getGraph(project_root)]);
4594
- const queryEmbedding = await generateEmbedding(query);
4595
- 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);
4596
4873
  const expandedResults = /* @__PURE__ */ new Map();
4597
- for (const result of vectorResults) {
4598
- const existingScore = expandedResults.get(result.filePath)?.score ?? Infinity;
4599
- if (result.score < existingScore) {
4600
- 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 });
4601
4878
  }
4602
- 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)]) {
4603
4880
  if (!expandedResults.has(related)) {
4604
- expandedResults.set(related, { score: result.score + 0.1, content: "" });
4881
+ expandedResults.set(related, { score: result2.score + 0.1, content: "" });
4605
4882
  }
4606
4883
  }
4607
4884
  }
4608
- 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);
4609
- const lines = [`<search_results query="${escapeXML3(query)}" count="${ranked.length}">`];
4610
- for (const result of ranked) {
4611
- lines.push(` <result file="${escapeXML3(result.filePath)}" score="${result.score.toFixed(4)}">`);
4612
- if (result.content) {
4613
- lines.push(` ${result.content.slice(0, 200).replace(/&/g, "&amp;").replace(/</g, "&lt;")}`);
4614
- }
4615
- lines.push(" </result>");
4616
- }
4617
- lines.push("</search_results>");
4618
- 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);
4619
4897
  }
4620
4898
  );
4621
4899
  }
4622
4900
 
4623
4901
  // packages/core/src/tools/file.ts
4624
4902
  import { z as z4 } from "zod";
4903
+ var DEFAULT_MAX_RESPONSE_TOKENS2 = 8e3;
4625
4904
  var Schema2 = z4.object({
4626
4905
  path: z4.string().describe("Relative path to the file"),
4627
- 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.")
4628
4911
  });
4629
4912
  function registerFileTool(registry, ctx) {
4630
4913
  registry.register(
4631
4914
  "ctx_get_file",
4632
4915
  {
4633
4916
  name: "ctx_get_file",
4634
- 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.",
4635
4918
  inputSchema: {
4636
4919
  type: "object",
4637
4920
  properties: {
4638
4921
  path: { type: "string", description: "Relative path to the file" },
4639
- 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
+ }
4640
4937
  },
4641
4938
  required: ["path"]
4642
4939
  }
4643
4940
  },
4644
4941
  async (args) => {
4645
- const { path: filePath, project_root } = Schema2.parse(args);
4646
- 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);
4647
4956
  }
4648
4957
  );
4649
4958
  }
@@ -4651,34 +4960,71 @@ function registerFileTool(registry, ctx) {
4651
4960
  // packages/core/src/tools/context-packet.ts
4652
4961
  import { z as z5 } from "zod";
4653
4962
  import path14 from "path";
4963
+ var DEFAULT_MAX_RESPONSE_TOKENS3 = 6e3;
4654
4964
  var Schema3 = z5.object({
4655
4965
  target_file: z5.string().describe("Relative path to the primary file"),
4656
4966
  mode: z5.enum(["edit", "read"]).optional().default("edit").describe("Context mode"),
4657
- 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.")
4658
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
+ }
4659
4988
  function registerContextPacketTool(registry, ctx) {
4660
4989
  registry.register(
4661
4990
  "ctx_get_context_packet",
4662
4991
  {
4663
4992
  name: "ctx_get_context_packet",
4664
- 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.",
4665
4994
  inputSchema: {
4666
4995
  type: "object",
4667
4996
  properties: {
4668
4997
  target_file: { type: "string", description: "Relative path to the primary file" },
4669
4998
  mode: { type: "string", enum: ["edit", "read"], description: "Context mode (default: edit)" },
4670
- 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
+ }
4671
5014
  },
4672
5015
  required: ["target_file"]
4673
5016
  }
4674
5017
  },
4675
5018
  async (args) => {
4676
- const { target_file, mode, project_root } = Schema3.parse(args);
4677
- const [skeletonizer, graph] = await Promise.all([ctx.getSkeletonizer(project_root), ctx.getGraph(project_root)]);
4678
- const pathValidator = ctx.getPathValidator(project_root);
4679
- const primaryContent = pathValidator.readFile(target_file);
4680
- const imports = graph.getImports(target_file);
4681
- 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);
4682
5028
  const skeletons = await Promise.all(
4683
5029
  imports.map(async (dep) => {
4684
5030
  try {
@@ -4692,19 +5038,29 @@ ${sk}`;
4692
5038
  }
4693
5039
  })
4694
5040
  );
4695
- return [
4696
- `<context_packet target="${target_file}" mode="${mode}">`,
4697
- ` <primary_context file="${target_file}">`,
4698
- ` ${primaryContent.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")}`,
4699
- " </primary_context>",
4700
- ` <dependency_skeletons count="${imports.length}">`,
4701
- ...skeletons.map((s) => ` ${s}`),
4702
- " </dependency_skeletons>",
4703
- ` <imported_by count="${importers.length}">`,
4704
- ...importers.map((imp) => ` <importer file="${imp}" />`),
4705
- " </imported_by>",
4706
- "</context_packet>"
4707
- ].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);
4708
5064
  }
4709
5065
  );
4710
5066
  }
@@ -4799,9 +5155,14 @@ function registerCallGraphTool(registry, ctx) {
4799
5155
 
4800
5156
  // packages/core/src/tools/definition.ts
4801
5157
  import { z as z7 } from "zod";
5158
+ var DEFAULT_MAX_RESPONSE_TOKENS4 = 2e3;
4802
5159
  var Schema5 = z7.object({
4803
5160
  symbol: z7.string().describe("Symbol name to look up"),
4804
- 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.")
4805
5166
  });
4806
5167
  function escapeXML5(text) {
4807
5168
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -4811,33 +5172,57 @@ function registerDefinitionTool(registry, ctx) {
4811
5172
  "ctx_get_definition",
4812
5173
  {
4813
5174
  name: "ctx_get_definition",
4814
- 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).",
4815
5176
  inputSchema: {
4816
5177
  type: "object",
4817
5178
  properties: {
4818
5179
  symbol: { type: "string", description: "Symbol name to look up" },
4819
- 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
+ }
4820
5195
  },
4821
5196
  required: ["symbol"]
4822
5197
  }
4823
5198
  },
4824
5199
  async (args) => {
4825
- const { symbol, project_root } = Schema5.parse(args);
4826
- const graph = await ctx.getGraph(project_root);
4827
- 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;
4828
5204
  if (definitions.length === 0) {
4829
- return `<definitions symbol="${escapeXML5(symbol)}" count="0">
5205
+ full = `<definitions symbol="${escapeXML5(parsed.symbol)}" count="0">
4830
5206
  <!-- Symbol not found -->
4831
5207
  </definitions>`;
4832
- }
4833
- const lines = [`<definitions symbol="${escapeXML5(symbol)}" count="${definitions.length}">`];
4834
- for (const def of definitions) {
4835
- lines.push(` <definition file="${def.filePath}" type="${def.type}">`);
4836
- lines.push(` ${def.signature.replace(/&/g, "&amp;").replace(/</g, "&lt;")}`);
4837
- lines.push(" </definition>");
4838
- }
4839
- lines.push("</definitions>");
4840
- 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);
4841
5226
  }
4842
5227
  );
4843
5228
  }
@@ -5802,6 +6187,7 @@ function registerSurprisingConnectionsTool(registry, ctx) {
5802
6187
  // packages/core/src/tools/wiki-generate.ts
5803
6188
  import { z as z17 } from "zod";
5804
6189
  import fs14 from "fs";
6190
+ var DEFAULT_MAX_RESPONSE_TOKENS5 = 12e3;
5805
6191
  var Schema15 = z17.object({
5806
6192
  force: z17.boolean().optional().default(false).describe(
5807
6193
  "Regenerate all pages even if content unchanged (default: false)"
@@ -5809,7 +6195,11 @@ var Schema15 = z17.object({
5809
6195
  detail_level: z17.enum(["standard", "minimal"]).default("standard").describe(
5810
6196
  '"standard" (default) lists each written page with size. "minimal" returns counts only.'
5811
6197
  ),
5812
- 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.")
5813
6203
  });
5814
6204
  function escapeXML14(text) {
5815
6205
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -5839,7 +6229,10 @@ function registerWikiGenerateTool(registry, ctx) {
5839
6229
  enum: ["standard", "minimal"],
5840
6230
  description: '"standard" lists written pages with size. "minimal" returns counts only.'
5841
6231
  },
5842
- 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." }
5843
6236
  }
5844
6237
  }
5845
6238
  },
@@ -5848,20 +6241,30 @@ function registerWikiGenerateTool(registry, ctx) {
5848
6241
  const [graph, skeletonizer] = await Promise.all([ctx.getGraph(project_root), ctx.getSkeletonizer(project_root)]);
5849
6242
  const generator = new WikiGenerator(graph, ctx.projectRoot, skeletonizer);
5850
6243
  const result = await generator.generate(force);
5851
- if (detail_level === "minimal") {
5852
- return `<wiki_generate detail_level="minimal" wiki_dir="${escapeXML14(result.wikiDir)}" written="${result.written.length}" skipped="${result.skipped.length}" />`;
5853
- }
5854
- const lines = [
5855
- `<wiki_generate wiki_dir="${escapeXML14(result.wikiDir)}" written="${result.written.length}" skipped="${result.skipped.length}">`
5856
- ];
5857
- for (const p of result.written) {
5858
- const size = safeFileSize(p.filePath);
5859
- lines.push(
5860
- ` <page community="${escapeXML14(p.communityName)}" file="${escapeXML14(p.filePath)}" size="${size}" status="written" />`
5861
- );
5862
- }
5863
- lines.push("</wiki_generate>");
5864
- 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);
5865
6268
  }
5866
6269
  );
5867
6270
  }
@@ -5910,6 +6313,7 @@ function registerGraphExportTool(registry, ctx) {
5910
6313
  import { z as z19 } from "zod";
5911
6314
  import { execFile } from "child_process";
5912
6315
  import { promisify as promisify2 } from "util";
6316
+ var DEFAULT_MAX_RESPONSE_TOKENS6 = 8e3;
5913
6317
  var execFileAsync = promisify2(execFile);
5914
6318
  var Schema17 = z19.object({
5915
6319
  changed_files: z19.array(z19.string()).optional().describe(
@@ -5923,7 +6327,11 @@ var Schema17 = z19.object({
5923
6327
  max_diff_lines: z19.number().min(10).max(2e3).optional().default(300).describe(
5924
6328
  "Max diff lines to include per file (default: 300)"
5925
6329
  ),
5926
- 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.")
5927
6335
  });
5928
6336
  function escapeXML16(text) {
5929
6337
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -5971,7 +6379,10 @@ function registerGitDiffReviewTool(registry, ctx) {
5971
6379
  description: "Include API skeletons for changed and importer files (default: true)"
5972
6380
  },
5973
6381
  max_diff_lines: { type: "number", description: "Max diff lines per file (default: 300)" },
5974
- 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." }
5975
6386
  }
5976
6387
  }
5977
6388
  },
@@ -5991,10 +6402,21 @@ function registerGitDiffReviewTool(registry, ctx) {
5991
6402
  logger.warn("git diff failed \u2014 no changed files detected");
5992
6403
  }
5993
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
+ };
5994
6416
  if (files.length === 0) {
5995
- return `<git_diff_review changed_files="0">
6417
+ return maybeBudget(`<git_diff_review changed_files="0">
5996
6418
  <!-- No changed files detected -->
5997
- </git_diff_review>`;
6419
+ </git_diff_review>`);
5998
6420
  }
5999
6421
  const graph = await ctx.getGraph(project_root);
6000
6422
  const blast = await computeBlastRadius({
@@ -6003,62 +6425,69 @@ function registerGitDiffReviewTool(registry, ctx) {
6003
6425
  projectRoot: ctx.projectRoot,
6004
6426
  graph
6005
6427
  });
6006
- const lines = [
6007
- `<git_diff_review changed_files="${files.length}" depth="${depth}">`
6008
- ];
6009
- lines.push(` <changed_files count="${files.length}">`);
6010
- for (const file of files) {
6011
- lines.push(` <file path="${escapeXML16(file)}">`);
6428
+ const changedFileData = await Promise.all(files.map(async (file) => {
6012
6429
  const rawDiff = use_git ? await getFileDiff(ctx.projectRoot, file) : "";
6013
6430
  const diffLines = rawDiff ? rawDiff.split("\n") : [];
6014
6431
  const truncated = diffLines.length > max_diff_lines;
6015
6432
  const diffContent = truncated ? [...diffLines.slice(0, max_diff_lines), `... (${diffLines.length - max_diff_lines} more lines)`].join("\n") : rawDiff;
6016
- lines.push(` <diff lines="${diffLines.length}" truncated="${truncated}">`);
6017
- if (diffContent) {
6018
- lines.push(escapeXML16(diffContent));
6019
- }
6020
- lines.push(" </diff>");
6021
- if (include_skeletons) {
6022
- const skeleton = await trySkeletonize(ctx, file, project_root);
6023
- if (skeleton) {
6024
- lines.push(" <skeleton>");
6025
- lines.push(escapeXML16(skeleton));
6026
- 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>");
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>");
6027
6466
  }
6467
+ out.push(" </file>");
6028
6468
  }
6029
- lines.push(" </file>");
6030
- }
6031
- lines.push(" </changed_files>");
6032
- lines.push(` <direct_importers count="${blast.directImporters.length}">`);
6033
- const skeletonLimit = 5;
6034
- for (let i = 0; i < blast.directImporters.length; i++) {
6035
- const file = blast.directImporters[i];
6036
- lines.push(` <file path="${escapeXML16(file)}">`);
6037
- if (include_skeletons && i < skeletonLimit) {
6038
- const skeleton = await trySkeletonize(ctx, file, project_root);
6039
- if (skeleton) {
6040
- lines.push(" <skeleton>");
6041
- lines.push(escapeXML16(skeleton));
6042
- 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)}" />`);
6043
6474
  }
6475
+ out.push(" </transitive_importers>");
6476
+ } else {
6477
+ out.push(` <transitive_importers count="${blast.transitiveImporters.length}" omitted="budget"/>`);
6044
6478
  }
6045
- lines.push(" </file>");
6046
- }
6047
- lines.push(" </direct_importers>");
6048
- lines.push(` <transitive_importers count="${blast.transitiveImporters.length}">`);
6049
- for (const file of blast.transitiveImporters) {
6050
- lines.push(` <file path="${escapeXML16(file)}" />`);
6051
- }
6052
- lines.push(" </transitive_importers>");
6053
- lines.push(` <call_sites count="${blast.callSites.length}">`);
6054
- for (const cs of blast.callSites) {
6055
- lines.push(
6056
- ` <call_site file="${escapeXML16(cs.file)}" caller="${escapeXML16(cs.callerSymbol)}" callee="${escapeXML16(cs.calleeSymbol)}" />`
6057
- );
6058
- }
6059
- lines.push(" </call_sites>");
6060
- lines.push("</git_diff_review>");
6061
- 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));
6062
6491
  }
6063
6492
  );
6064
6493
  }
@@ -6067,13 +6496,18 @@ function registerGitDiffReviewTool(registry, ctx) {
6067
6496
  import { z as z20 } from "zod";
6068
6497
  import fs15 from "fs";
6069
6498
  import path17 from "path";
6499
+ var DEFAULT_MAX_RESPONSE_TOKENS7 = 4e3;
6070
6500
  var Schema18 = z20.object({
6071
6501
  symbol: z20.string().min(1).describe("Symbol name to rename (exact match, case-sensitive)"),
6072
6502
  new_name: z20.string().min(1).describe("New name for the symbol"),
6073
6503
  max_files: z20.number().min(1).max(200).optional().default(50).describe(
6074
6504
  "Maximum number of files to scan for occurrences (default: 50)"
6075
6505
  ),
6076
- 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.")
6077
6511
  });
6078
6512
  function escapeXML17(text) {
6079
6513
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -6121,7 +6555,10 @@ function registerRefactorPreviewTool(registry, ctx) {
6121
6555
  type: "number",
6122
6556
  description: "Maximum number of candidate files to scan (default: 50)"
6123
6557
  },
6124
- 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." }
6125
6562
  },
6126
6563
  required: ["symbol", "new_name"]
6127
6564
  }
@@ -6152,36 +6589,53 @@ function registerRefactorPreviewTool(registry, ctx) {
6152
6589
  totalOccurrences += occurrences.length;
6153
6590
  }
6154
6591
  }
6155
- const xmlLines = [
6156
- `<refactor_preview symbol="${escapeXML17(symbol)}" new_name="${escapeXML17(new_name)}" total_files="${fileChanges.length}" total_occurrences="${totalOccurrences}">`
6157
- ];
6158
- xmlLines.push(` <definitions count="${definitions.length}">`);
6159
- for (const def of definitions) {
6160
- xmlLines.push(
6161
- ` <definition file="${escapeXML17(def.filePath)}" type="${escapeXML17(def.type)}" signature="${escapeXML17(def.signature)}" />`
6162
- );
6163
- }
6164
- xmlLines.push(" </definitions>");
6165
- xmlLines.push(` <changes count="${fileChanges.length}">`);
6166
- for (const fc of fileChanges) {
6167
- xmlLines.push(` <file path="${escapeXML17(fc.filePath)}" occurrences="${fc.occurrences.length}">`);
6168
- for (const occ of fc.occurrences) {
6169
- xmlLines.push(` <change line="${occ.line}">`);
6170
- xmlLines.push(` <before>${escapeXML17(occ.before)}</before>`);
6171
- xmlLines.push(` <after>${escapeXML17(occ.after)}</after>`);
6172
- 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
+ );
6173
6601
  }
6174
- xmlLines.push(" </file>");
6175
- }
6176
- xmlLines.push(" </changes>");
6177
- xmlLines.push("</refactor_preview>");
6178
- 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);
6179
6632
  }
6180
6633
  );
6181
6634
  }
6182
6635
 
6183
6636
  // packages/core/src/tools/execution-flow.ts
6184
6637
  import { z as z21 } from "zod";
6638
+ var DEFAULT_MAX_RESPONSE_TOKENS8 = 4e3;
6185
6639
  var Schema19 = z21.object({
6186
6640
  entry_point: z21.string().min(1).describe("Symbol name to start the execution flow from"),
6187
6641
  entry_file: z21.string().optional().describe(
@@ -6191,7 +6645,11 @@ var Schema19 = z21.object({
6191
6645
  max_nodes: z21.number().min(1).max(200).optional().default(50).describe(
6192
6646
  "Max total steps to include in output (default: 50)"
6193
6647
  ),
6194
- 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.")
6195
6653
  });
6196
6654
  function escapeXML18(text) {
6197
6655
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -6253,7 +6711,10 @@ function registerExecutionFlowTool(registry, ctx) {
6253
6711
  type: "number",
6254
6712
  description: "Max total steps to return (default: 50)"
6255
6713
  },
6256
- 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." }
6257
6718
  },
6258
6719
  required: ["entry_point"]
6259
6720
  }
@@ -6279,10 +6740,20 @@ function registerExecutionFlowTool(registry, ctx) {
6279
6740
  }
6280
6741
  }
6281
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
+ };
6282
6753
  if (!resolvedFile) {
6283
- 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">
6284
6755
  <!-- No call graph entries found for symbol -->
6285
- </execution_flow>`;
6756
+ </execution_flow>`);
6286
6757
  }
6287
6758
  const { steps, hasCycles } = buildFlowSteps(
6288
6759
  entry_point,
@@ -6307,7 +6778,7 @@ function registerExecutionFlowTool(registry, ctx) {
6307
6778
  }
6308
6779
  }
6309
6780
  xmlLines.push("</execution_flow>");
6310
- return xmlLines.join("\n");
6781
+ return maybeBudget(xmlLines.join("\n"));
6311
6782
  }
6312
6783
  );
6313
6784
  }
@@ -6316,6 +6787,7 @@ function registerExecutionFlowTool(registry, ctx) {
6316
6787
  import { z as z22 } from "zod";
6317
6788
  import fs16 from "fs";
6318
6789
  import path18 from "path";
6790
+ var DEFAULT_MAX_RESPONSE_TOKENS9 = 4e3;
6319
6791
  var ALIAS_REGEX = /^[a-z0-9-]{1,40}$/;
6320
6792
  var RESERVED_ALIASES = /* @__PURE__ */ new Set([
6321
6793
  "register",
@@ -6418,7 +6890,11 @@ var Schema20 = z22.object({
6418
6890
  repos: z22.array(z22.string()).optional().describe(
6419
6891
  "Specific repo root paths to search. Omit to search all registered repos."
6420
6892
  ),
6421
- 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.")
6422
6898
  });
6423
6899
  function escapeXML19(text) {
6424
6900
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -6440,7 +6916,10 @@ function registerCrossRepoSearchTool(registry, _ctx, registryFilePath) {
6440
6916
  items: { type: "string" },
6441
6917
  description: "Specific repo root paths to search. Omit to search all registered repos."
6442
6918
  },
6443
- 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." }
6444
6923
  },
6445
6924
  required: ["query"]
6446
6925
  }
@@ -6509,19 +6988,32 @@ function registerCrossRepoSearchTool(registry, _ctx, registryFilePath) {
6509
6988
  );
6510
6989
  }
6511
6990
  xmlLines.push(" </repos>");
6512
- xmlLines.push(` <results count="${topResults.length}">`);
6513
- for (const r of topResults) {
6514
- xmlLines.push(
6515
- ` <result repo="${escapeXML19(r.repoName)}" file="${escapeXML19(r.filePath)}" score="${r.score.toFixed(4)}">`
6516
- );
6517
- if (r.content) {
6518
- 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
+ }
6519
7002
  }
6520
- xmlLines.push(" </result>");
6521
- }
6522
- xmlLines.push(" </results>");
6523
- xmlLines.push("</cross_repo_search>");
6524
- 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);
6525
7017
  }
6526
7018
  );
6527
7019
  }
@@ -6530,6 +7022,7 @@ function registerCrossRepoSearchTool(registry, _ctx, registryFilePath) {
6530
7022
  import { z as z23 } from "zod";
6531
7023
  import fs17 from "fs";
6532
7024
  import path19 from "path";
7025
+ var DEFAULT_MAX_RESPONSE_TOKENS10 = 2e3;
6533
7026
  var Schema21 = z23.object({
6534
7027
  symbol: z23.string().min(1).describe("Symbol name to rename (exact, case-sensitive)"),
6535
7028
  new_name: z23.string().min(1).describe("New name for the symbol"),
@@ -6539,7 +7032,11 @@ var Schema21 = z23.object({
6539
7032
  max_files: z23.number().min(1).max(200).optional().default(50).describe(
6540
7033
  "Maximum candidate files to process (default: 50)"
6541
7034
  ),
6542
- 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.")
6543
7040
  });
6544
7041
  function escapeXML20(text) {
6545
7042
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -6573,7 +7070,10 @@ function registerApplyRefactorTool(registry, ctx) {
6573
7070
  new_name: { type: "string", description: "New name" },
6574
7071
  dry_run: { type: "boolean", description: "Preview only, no writes (default: false)" },
6575
7072
  max_files: { type: "number", description: "Max candidate files (default: 50)" },
6576
- 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." }
6577
7077
  },
6578
7078
  required: ["symbol", "new_name"]
6579
7079
  }
@@ -6613,7 +7113,15 @@ function registerApplyRefactorTool(registry, ctx) {
6613
7113
  );
6614
7114
  }
6615
7115
  xml.push("</apply_refactor>");
6616
- 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);
6617
7125
  }
6618
7126
  );
6619
7127
  }
@@ -6734,13 +7242,18 @@ function registerDetectChangesTool(registry, ctx) {
6734
7242
  import { z as z25 } from "zod";
6735
7243
  import fs18 from "fs";
6736
7244
  import path20 from "path";
7245
+ var DEFAULT_MAX_RESPONSE_TOKENS11 = 4e3;
6737
7246
  var Schema23 = z25.object({
6738
7247
  query: z25.string().min(1).describe("Search term \u2014 literal or /regex/"),
6739
7248
  mode: z25.enum(["hybrid", "keyword", "semantic"]).optional().default("hybrid"),
6740
7249
  case_sensitive: z25.boolean().optional().default(false),
6741
7250
  limit: z25.number().min(1).max(100).optional().default(20),
6742
7251
  context_lines: z25.number().min(0).max(5).optional().default(1),
6743
- 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.")
6744
7257
  });
6745
7258
  function escapeXML22(text) {
6746
7259
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -6795,27 +7308,53 @@ function registerFullTextSearchTool(registry, ctx) {
6795
7308
  case_sensitive: { type: "boolean", description: "Case-sensitive match (default: false)" },
6796
7309
  limit: { type: "number", description: "Max results (default: 20)" },
6797
7310
  context_lines: { type: "number", description: "Context lines around each match (default: 1)" },
6798
- 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
+ }
6799
7326
  },
6800
7327
  required: ["query"]
6801
7328
  }
6802
7329
  },
6803
7330
  async (args) => {
6804
- 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
+ };
6805
7344
  if (mode === "semantic") {
6806
7345
  try {
6807
- const { generateEmbedding: generateEmbedding2 } = await import("./embedder-5LMEYY4M.js");
7346
+ const { generateEmbedding: generateEmbedding2 } = await import("./embedder-R4KCXSGO.js");
6808
7347
  const store = await ctx.getStore(project_root);
6809
7348
  const embedding = await generateEmbedding2(query);
6810
7349
  const results = await store.search(embedding, limit);
6811
- 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}">`];
6812
7351
  for (const r of results) {
6813
- xml2.push(` <result file="${escapeXML22(r.filePath)}" matches="0"/>`);
7352
+ xml.push(` <result file="${escapeXML22(r.filePath)}" matches="0"/>`);
6814
7353
  }
6815
- xml2.push("</full_text_search>");
6816
- return xml2.join("\n");
7354
+ xml.push("</full_text_search>");
7355
+ return maybeBudget(xml.join("\n"));
6817
7356
  } catch {
6818
- return `<full_text_search query="${escapeXML22(query)}" mode="semantic" count="0"/>`;
7357
+ return maybeBudget(`<full_text_search query="${escapeXML22(query)}" mode="semantic" count="0"/>`);
6819
7358
  }
6820
7359
  }
6821
7360
  const pattern = buildPattern(query, case_sensitive);
@@ -6841,7 +7380,7 @@ function registerFullTextSearchTool(registry, ctx) {
6841
7380
  let merged = keywordResults.slice(0, limit);
6842
7381
  if (mode === "hybrid") {
6843
7382
  try {
6844
- const { generateEmbedding: generateEmbedding2 } = await import("./embedder-5LMEYY4M.js");
7383
+ const { generateEmbedding: generateEmbedding2 } = await import("./embedder-R4KCXSGO.js");
6845
7384
  const store = await ctx.getStore(project_root);
6846
7385
  const embedding = await generateEmbedding2(query);
6847
7386
  const vectorResults = await store.search(embedding, Math.ceil(limit / 2));
@@ -6857,18 +7396,28 @@ function registerFullTextSearchTool(registry, ctx) {
6857
7396
  } catch {
6858
7397
  }
6859
7398
  }
6860
- const xml = [
6861
- `<full_text_search query="${escapeXML22(query)}" mode="${mode}" case_sensitive="${case_sensitive}" count="${merged.length}">`
6862
- ];
6863
- for (const r of merged) {
6864
- xml.push(` <result file="${escapeXML22(r.filePath)}" matches="${r.matchCount}">`);
6865
- for (const snippet of r.snippets) {
6866
- 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
+ }
6867
7413
  }
6868
- xml.push(" </result>");
6869
- }
6870
- xml.push("</full_text_search>");
6871
- 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
+ );
6872
7421
  }
6873
7422
  );
6874
7423
  }
@@ -7255,6 +7804,7 @@ function registerGraphDiffTool(registry, ctx) {
7255
7804
 
7256
7805
  // packages/core/src/tools/find-large-functions.ts
7257
7806
  import { z as z30 } from "zod";
7807
+ var DEFAULT_MAX_RESPONSE_TOKENS12 = 2e3;
7258
7808
  var schema3 = z30.object({
7259
7809
  threshold: z30.number().int().min(1).default(50).describe(
7260
7810
  "Minimum line count to include (default: 50). Functions/classes shorter than this are excluded."
@@ -7265,7 +7815,11 @@ var schema3 = z30.object({
7265
7815
  limit: z30.number().int().min(1).max(200).default(30).describe(
7266
7816
  "Maximum results to return (default: 30)."
7267
7817
  ),
7268
- 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).")
7269
7823
  });
7270
7824
  function findLargeFunctions(graph, threshold, fileFilter) {
7271
7825
  const results = [];
@@ -7315,7 +7869,10 @@ function registerFindLargeFunctionsTool(registry, ctx) {
7315
7869
  type: "number",
7316
7870
  description: "Maximum results to return (default: 30, max: 200)."
7317
7871
  },
7318
- 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)." }
7319
7876
  }
7320
7877
  }
7321
7878
  },
@@ -7323,19 +7880,29 @@ function registerFindLargeFunctionsTool(registry, ctx) {
7323
7880
  const { threshold, file_filter, limit, project_root } = schema3.parse(args);
7324
7881
  const graph = await ctx.getGraph(project_root);
7325
7882
  const results = findLargeFunctions(graph, threshold, file_filter).slice(0, limit);
7883
+ let full;
7326
7884
  if (results.length === 0) {
7327
- return `<ctx_find_large_functions threshold="${threshold}" count="0">
7885
+ full = `<ctx_find_large_functions threshold="${threshold}" count="0">
7328
7886
  <message>No functions or classes exceed ${threshold} lines.</message>
7329
7887
  </ctx_find_large_functions>`;
7330
- }
7331
- const lines = [
7332
- `<ctx_find_large_functions threshold="${threshold}" count="${results.length}">`,
7333
- ...results.map(
7334
- (r) => ` <symbol name="${escapeXML24(r.name)}" type="${r.type}" file="${escapeXML24(r.filePath)}" start="${r.startLine}" end="${r.endLine}" lines="${r.lineCount}" />`
7335
- ),
7336
- `</ctx_find_large_functions>`
7337
- ];
7338
- 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);
7339
7906
  }
7340
7907
  );
7341
7908
  }
@@ -8717,113 +9284,15 @@ function maybePrintExpiryWarning(expiresAt) {
8717
9284
  }
8718
9285
 
8719
9286
  // packages/core/src/license/index.ts
8720
- import os3 from "os";
8721
- var REVALIDATION_DAYS = 7;
8722
- var GRACE_HOURS = 72;
8723
- function defaultHome() {
8724
- return os3.homedir();
8725
- }
8726
- async function isActive(opts = {}) {
8727
- const home = opts.home ?? defaultHome();
8728
- const store = new LicenseStore(home);
8729
- const license = await store.read();
8730
- if (!license) return false;
8731
- if (new Date(license.expiresAt).getTime() <= Date.now()) return false;
8732
- const lastValidated = new Date(license.lastValidatedAt).getTime();
8733
- const msSinceValidation = Date.now() - lastValidated;
8734
- const revalidationMs = REVALIDATION_DAYS * 24 * 60 * 60 * 1e3;
8735
- if (msSinceValidation <= revalidationMs) {
8736
- maybePrintExpiryWarning(license.expiresAt);
8737
- return true;
8738
- }
8739
- const client = new ApiClient(opts.apiBase);
8740
- try {
8741
- const result = await client.validate(license.key, license.instanceId);
8742
- if (result.status === "revoked" || result.status === "expired") return false;
8743
- const refreshed = {
8744
- ...license,
8745
- lastValidatedAt: (/* @__PURE__ */ new Date()).toISOString(),
8746
- expiresAt: result.expiresAt || license.expiresAt
8747
- };
8748
- await store.write(refreshed);
8749
- maybePrintExpiryWarning(refreshed.expiresAt);
8750
- return true;
8751
- } catch (err) {
8752
- if (err instanceof LicenseRevokedError) return false;
8753
- if (err instanceof NetworkError || err instanceof TypeError) {
8754
- const graceMs = GRACE_HOURS * 60 * 60 * 1e3;
8755
- if (msSinceValidation <= revalidationMs + graceMs) {
8756
- process.stderr.write(
8757
- `\u26A0 ctxloom is running offline. License will be reverified when network is available.
8758
-
8759
- `
8760
- );
8761
- maybePrintExpiryWarning(license.expiresAt);
8762
- return true;
8763
- }
8764
- return false;
8765
- }
8766
- return false;
8767
- }
8768
- }
8769
- async function requireActive(opts = {}) {
8770
- const active = await isActive(opts);
8771
- if (!active) throw new LicenseRequiredError();
8772
- }
8773
- async function getLicenseInfo(opts = {}) {
8774
- const home = opts.home ?? defaultHome();
8775
- const store = new LicenseStore(home);
8776
- return store.read();
8777
- }
8778
- async function activateLicense(key, opts = {}) {
8779
- const home = opts.home ?? defaultHome();
8780
- const fingerprint = await Fingerprint.compute();
8781
- const hostname = os3.hostname();
8782
- const platform = `${os3.platform()}-${os3.arch()}`;
8783
- const client = new ApiClient(opts.apiBase);
8784
- const result = await client.activate(key, fingerprint, hostname, platform);
8785
- const license = {
8786
- schemaVersion: 1,
8787
- key,
8788
- tier: result.tier,
8789
- status: "active",
8790
- fingerprint,
8791
- seats: result.seatsTotal,
8792
- issuedAt: (/* @__PURE__ */ new Date()).toISOString(),
8793
- expiresAt: result.expiresAt,
8794
- lastValidatedAt: (/* @__PURE__ */ new Date()).toISOString(),
8795
- licenseId: result.licenseId,
8796
- instanceId: result.instanceId
8797
- };
8798
- const store = new LicenseStore(home);
8799
- await store.write(license);
8800
- return license;
8801
- }
8802
- async function deactivateLicense(opts = {}) {
8803
- const home = opts.home ?? defaultHome();
8804
- const store = new LicenseStore(home);
8805
- const license = await store.read();
8806
- if (!license) return;
8807
- const client = new ApiClient(opts.apiBase);
8808
- await client.deactivate(license.key, license.instanceId);
8809
- await store.clear();
8810
- }
8811
- async function startTrial(email, opts = {}) {
8812
- const home = opts.home ?? defaultHome();
8813
- const fingerprint = await Fingerprint.compute();
8814
- const client = new ApiClient(opts.apiBase);
8815
- const result = await client.startTrial(email, fingerprint);
8816
- void home;
8817
- return result;
8818
- }
9287
+ import os5 from "os";
8819
9288
 
8820
9289
  // packages/core/src/license/DistinctIdStore.ts
8821
9290
  import { readFileSync as readFileSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, existsSync as existsSync2 } from "fs";
8822
9291
  import path30 from "path";
8823
- import os4 from "os";
9292
+ import os3 from "os";
8824
9293
  var UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
8825
9294
  function distinctIdPath(home) {
8826
- return path30.join(home ?? os4.homedir(), ".ctxloom", "distinct_id");
9295
+ return path30.join(home ?? os3.homedir(), ".ctxloom", "distinct_id");
8827
9296
  }
8828
9297
  function isValidV4(id) {
8829
9298
  return typeof id === "string" && UUID_V4_REGEX.test(id);
@@ -8842,7 +9311,7 @@ function getOrCreateDistinctId(home) {
8842
9311
  }
8843
9312
  const record = {
8844
9313
  id: crypto.randomUUID(),
8845
- alias_pending: os4.hostname()
9314
+ alias_pending: os3.hostname()
8846
9315
  };
8847
9316
  mkdirSync2(path30.dirname(filePath), { recursive: true });
8848
9317
  writeFileSync2(filePath, JSON.stringify(record), { mode: 384 });
@@ -8873,7 +9342,7 @@ var TELEMETRY_DISABLED = TELEMETRY_LEVEL === "off";
8873
9342
  function getTelemetryLevel() {
8874
9343
  return TELEMETRY_LEVEL;
8875
9344
  }
8876
- var CTXLOOM_VERSION = "1.2.5".length > 0 ? "1.2.5" : "dev";
9345
+ var CTXLOOM_VERSION = "1.2.7".length > 0 ? "1.2.7" : "dev";
8877
9346
  var POSTHOG_HOST = "https://eu.i.posthog.com";
8878
9347
  var POSTHOG_KEY = process.env["POSTHOG_API_KEY"] ?? (true ? "phc_CiDkmFLcZ2K6uCpcoSUQLmFrnnUvsyXGhSxopX5TVKE6" : "");
8879
9348
  var SENTRY_DSN = process.env["SENTRY_DSN"] ?? (true ? "https://81c94a0f04a8e242dee493ac1e17f733@o4508531702497280.ingest.de.sentry.io/4511256875368528" : "");
@@ -9004,32 +9473,169 @@ function parseStack(stack) {
9004
9473
  }).filter((f) => f !== null).slice(0, 20);
9005
9474
  }
9006
9475
 
9007
- // packages/core/src/license/TelemetryNotice.ts
9476
+ // packages/core/src/license/FunnelMilestones.ts
9008
9477
  import { existsSync as existsSync3, mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
9009
9478
  import path31 from "path";
9010
- import os5 from "os";
9479
+ import os4 from "os";
9480
+ var INSTALL_MARKER = "installed_at";
9481
+ var FIRST_REVIEW_MARKER = "first_review_at";
9482
+ function writeMarker(filePath) {
9483
+ mkdirSync3(path31.dirname(filePath), { recursive: true });
9484
+ writeFileSync3(filePath, (/* @__PURE__ */ new Date()).toISOString(), { mode: 384 });
9485
+ }
9486
+ function shouldEmitInstallCompleted(home) {
9487
+ const root = home ?? os4.homedir();
9488
+ const marker = path31.join(root, ".ctxloom", INSTALL_MARKER);
9489
+ if (existsSync3(marker)) return false;
9490
+ try {
9491
+ writeMarker(marker);
9492
+ } catch {
9493
+ }
9494
+ return true;
9495
+ }
9496
+ function shouldEmitFirstReviewRun(projectRoot) {
9497
+ const marker = path31.join(projectRoot, ".ctxloom", FIRST_REVIEW_MARKER);
9498
+ if (existsSync3(marker)) return false;
9499
+ try {
9500
+ writeMarker(marker);
9501
+ } catch {
9502
+ }
9503
+ return true;
9504
+ }
9505
+
9506
+ // packages/core/src/license/index.ts
9507
+ var REVALIDATION_DAYS = 7;
9508
+ var GRACE_HOURS = 72;
9509
+ function defaultHome() {
9510
+ return os5.homedir();
9511
+ }
9512
+ async function isActive(opts = {}) {
9513
+ const home = opts.home ?? defaultHome();
9514
+ const store = new LicenseStore(home);
9515
+ const license = await store.read();
9516
+ if (!license) return false;
9517
+ if (new Date(license.expiresAt).getTime() <= Date.now()) return false;
9518
+ const lastValidated = new Date(license.lastValidatedAt).getTime();
9519
+ const msSinceValidation = Date.now() - lastValidated;
9520
+ const revalidationMs = REVALIDATION_DAYS * 24 * 60 * 60 * 1e3;
9521
+ if (msSinceValidation <= revalidationMs) {
9522
+ maybePrintExpiryWarning(license.expiresAt);
9523
+ return true;
9524
+ }
9525
+ const client = new ApiClient(opts.apiBase);
9526
+ try {
9527
+ const result = await client.validate(license.key, license.instanceId);
9528
+ if (result.status === "revoked" || result.status === "expired") return false;
9529
+ const refreshed = {
9530
+ ...license,
9531
+ lastValidatedAt: (/* @__PURE__ */ new Date()).toISOString(),
9532
+ expiresAt: result.expiresAt || license.expiresAt
9533
+ };
9534
+ await store.write(refreshed);
9535
+ if (result.expiresAt && result.expiresAt !== license.expiresAt) {
9536
+ track("renewal", {
9537
+ tier: license.tier,
9538
+ previousExpiresAt: license.expiresAt,
9539
+ newExpiresAt: result.expiresAt
9540
+ });
9541
+ }
9542
+ maybePrintExpiryWarning(refreshed.expiresAt);
9543
+ return true;
9544
+ } catch (err) {
9545
+ if (err instanceof LicenseRevokedError) return false;
9546
+ if (err instanceof NetworkError || err instanceof TypeError) {
9547
+ const graceMs = GRACE_HOURS * 60 * 60 * 1e3;
9548
+ if (msSinceValidation <= revalidationMs + graceMs) {
9549
+ process.stderr.write(
9550
+ `\u26A0 ctxloom is running offline. License will be reverified when network is available.
9551
+
9552
+ `
9553
+ );
9554
+ maybePrintExpiryWarning(license.expiresAt);
9555
+ return true;
9556
+ }
9557
+ return false;
9558
+ }
9559
+ return false;
9560
+ }
9561
+ }
9562
+ async function requireActive(opts = {}) {
9563
+ const active = await isActive(opts);
9564
+ if (!active) throw new LicenseRequiredError();
9565
+ }
9566
+ async function getLicenseInfo(opts = {}) {
9567
+ const home = opts.home ?? defaultHome();
9568
+ const store = new LicenseStore(home);
9569
+ return store.read();
9570
+ }
9571
+ async function activateLicense(key, opts = {}) {
9572
+ const home = opts.home ?? defaultHome();
9573
+ const fingerprint = await Fingerprint.compute();
9574
+ const hostname = os5.hostname();
9575
+ const platform = `${os5.platform()}-${os5.arch()}`;
9576
+ const client = new ApiClient(opts.apiBase);
9577
+ const result = await client.activate(key, fingerprint, hostname, platform);
9578
+ const license = {
9579
+ schemaVersion: 1,
9580
+ key,
9581
+ tier: result.tier,
9582
+ status: "active",
9583
+ fingerprint,
9584
+ seats: result.seatsTotal,
9585
+ issuedAt: (/* @__PURE__ */ new Date()).toISOString(),
9586
+ expiresAt: result.expiresAt,
9587
+ lastValidatedAt: (/* @__PURE__ */ new Date()).toISOString(),
9588
+ licenseId: result.licenseId,
9589
+ instanceId: result.instanceId
9590
+ };
9591
+ const store = new LicenseStore(home);
9592
+ await store.write(license);
9593
+ return license;
9594
+ }
9595
+ async function deactivateLicense(opts = {}) {
9596
+ const home = opts.home ?? defaultHome();
9597
+ const store = new LicenseStore(home);
9598
+ const license = await store.read();
9599
+ if (!license) return;
9600
+ const client = new ApiClient(opts.apiBase);
9601
+ await client.deactivate(license.key, license.instanceId);
9602
+ await store.clear();
9603
+ }
9604
+ async function startTrial(email, opts = {}) {
9605
+ const home = opts.home ?? defaultHome();
9606
+ const fingerprint = await Fingerprint.compute();
9607
+ const client = new ApiClient(opts.apiBase);
9608
+ const result = await client.startTrial(email, fingerprint);
9609
+ void home;
9610
+ return result;
9611
+ }
9612
+
9613
+ // packages/core/src/license/TelemetryNotice.ts
9614
+ import { existsSync as existsSync4, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
9615
+ import path32 from "path";
9616
+ import os6 from "os";
9011
9617
  function noticePath(home) {
9012
- return path31.join(home ?? os5.homedir(), ".ctxloom", "telemetry_notice_shown");
9618
+ return path32.join(home ?? os6.homedir(), ".ctxloom", "telemetry_notice_shown");
9013
9619
  }
9014
9620
  function shouldShowTelemetryNotice(home) {
9015
9621
  const filePath = noticePath(home);
9016
- if (existsSync3(filePath)) return false;
9622
+ if (existsSync4(filePath)) return false;
9017
9623
  try {
9018
- mkdirSync3(path31.dirname(filePath), { recursive: true });
9019
- writeFileSync3(filePath, (/* @__PURE__ */ new Date()).toISOString(), { mode: 384 });
9624
+ mkdirSync4(path32.dirname(filePath), { recursive: true });
9625
+ writeFileSync4(filePath, (/* @__PURE__ */ new Date()).toISOString(), { mode: 384 });
9020
9626
  } catch {
9021
9627
  }
9022
9628
  return true;
9023
9629
  }
9024
9630
 
9025
9631
  // packages/core/src/server/ProjectState.ts
9026
- import path33 from "path";
9632
+ import path34 from "path";
9027
9633
 
9028
9634
  // packages/core/src/server/projectId.ts
9029
9635
  import crypto5 from "crypto";
9030
- import path32 from "path";
9636
+ import path33 from "path";
9031
9637
  function hashProjectRoot(absPath) {
9032
- const canonical = path32.resolve(absPath);
9638
+ const canonical = path33.resolve(absPath);
9033
9639
  return crypto5.createHash("sha256").update(canonical).digest("hex").slice(0, 16);
9034
9640
  }
9035
9641
 
@@ -9037,7 +9643,7 @@ function hashProjectRoot(absPath) {
9037
9643
  function createProjectState(projectRoot, opts = {}) {
9038
9644
  return {
9039
9645
  projectRoot,
9040
- dbPath: path33.join(projectRoot, ".ctxloom", "vectors.lancedb"),
9646
+ dbPath: path34.join(projectRoot, ".ctxloom", "vectors.lancedb"),
9041
9647
  pinned: opts.pinned ?? false,
9042
9648
  lastTouchedAt: Date.now(),
9043
9649
  vectorsInitialized: false,
@@ -9190,7 +9796,7 @@ var ProjectStateManager = class {
9190
9796
 
9191
9797
  // packages/core/src/server/resolveProjectRoot.ts
9192
9798
  import fs27 from "fs";
9193
- import path34 from "path";
9799
+ import path35 from "path";
9194
9800
  var PATH_SEPARATOR_PATTERN = /[/\\~]|^[A-Za-z]:/;
9195
9801
  function looksLikePath(value) {
9196
9802
  return PATH_SEPARATOR_PATTERN.test(value);
@@ -9215,9 +9821,9 @@ function resolvePathSafely(p, cwd) {
9215
9821
  let expanded = p;
9216
9822
  if (p === "~" || p.startsWith("~/")) {
9217
9823
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
9218
- expanded = p === "~" ? home : path34.join(home, p.slice(2));
9824
+ expanded = p === "~" ? home : path35.join(home, p.slice(2));
9219
9825
  }
9220
- return path34.isAbsolute(expanded) ? path34.resolve(expanded) : path34.resolve(cwd, expanded);
9826
+ return path35.isAbsolute(expanded) ? path35.resolve(expanded) : path35.resolve(cwd, expanded);
9221
9827
  }
9222
9828
  function realpathOrSame(p) {
9223
9829
  try {
@@ -9289,7 +9895,7 @@ function validateDefaultRoot(candidate) {
9289
9895
  } catch {
9290
9896
  return false;
9291
9897
  }
9292
- return PROJECT_MARKERS.some((m) => fs27.existsSync(path34.join(candidate, m)));
9898
+ return PROJECT_MARKERS.some((m) => fs27.existsSync(path35.join(candidate, m)));
9293
9899
  }
9294
9900
 
9295
9901
  // packages/core/src/server/structuredErrors.ts
@@ -9436,17 +10042,19 @@ export {
9436
10042
  ApiClient,
9437
10043
  Fingerprint,
9438
10044
  maybePrintExpiryWarning,
10045
+ getOrCreateDistinctId,
10046
+ markAliasSent,
10047
+ getTelemetryLevel,
10048
+ track,
10049
+ captureError,
10050
+ shouldEmitInstallCompleted,
10051
+ shouldEmitFirstReviewRun,
9439
10052
  isActive,
9440
10053
  requireActive,
9441
10054
  getLicenseInfo,
9442
10055
  activateLicense,
9443
10056
  deactivateLicense,
9444
10057
  startTrial,
9445
- getOrCreateDistinctId,
9446
- markAliasSent,
9447
- getTelemetryLevel,
9448
- track,
9449
- captureError,
9450
10058
  shouldShowTelemetryNotice,
9451
10059
  hashProjectRoot,
9452
10060
  createProjectState,
@@ -9464,4 +10072,4 @@ export {
9464
10072
  FirstTouchTracker,
9465
10073
  EmittedOnceTracker
9466
10074
  };
9467
- //# sourceMappingURL=chunk-6S3ZF2YS.js.map
10075
+ //# sourceMappingURL=chunk-RY3JAC2Q.js.map