@ctxo/cli 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +10 -1
  2. package/dist/{chunk-ZL36YS2V.js → chunk-A4URKKPP.js} +3 -177
  3. package/dist/{chunk-SRQZDGFZ.js → chunk-BBSJB4CG.js} +2 -2
  4. package/dist/{chunk-X4TVFJ4C.js → chunk-EC6INGPV.js} +1 -1
  5. package/dist/{chunk-7HSA5JVP.js → chunk-I7RPXT6L.js} +193 -1
  6. package/dist/chunk-IT426P3F.js +1036 -0
  7. package/dist/{chunk-G4BMKCKG.js → chunk-JDL6GGTO.js} +16 -5
  8. package/dist/{cli-router-AZCDNX5R.js → cli-router-W56INOQ3.js} +10 -10
  9. package/dist/index.js +3 -3
  10. package/dist/internal.d.ts +90 -3
  11. package/dist/internal.js +4 -4
  12. package/dist/{json-index-reader-DBW32TCN.js → json-index-reader-DKEIP2QR.js} +2 -2
  13. package/dist/{mcp-server-6YG2QE6E.js → mcp-server-IYCCE74E.js} +178 -446
  14. package/dist/module-host-KTGDODES.js +13 -0
  15. package/llms-full.txt +68 -3
  16. package/llms.txt +14 -2
  17. package/package.json +4 -3
  18. package/dist/atomic-write-BAFH464Q.js.map +0 -1
  19. package/dist/chunk-2Q76LH2H.js.map +0 -1
  20. package/dist/chunk-5DSKZJNN.js.map +0 -1
  21. package/dist/chunk-72CEQEQD.js.map +0 -1
  22. package/dist/chunk-7HSA5JVP.js.map +0 -1
  23. package/dist/chunk-G4BMKCKG.js.map +0 -1
  24. package/dist/chunk-L2CAUER6.js +0 -508
  25. package/dist/chunk-L2CAUER6.js.map +0 -1
  26. package/dist/chunk-LAX6VVBU.js.map +0 -1
  27. package/dist/chunk-PSKDDLSF.js.map +0 -1
  28. package/dist/chunk-RFEXMBTG.js.map +0 -1
  29. package/dist/chunk-SRQZDGFZ.js.map +0 -1
  30. package/dist/chunk-X4TVFJ4C.js.map +0 -1
  31. package/dist/chunk-ZL36YS2V.js.map +0 -1
  32. package/dist/cli-router-AZCDNX5R.js.map +0 -1
  33. package/dist/index.js.map +0 -1
  34. package/dist/internal.js.map +0 -1
  35. package/dist/json-index-reader-DBW32TCN.js.map +0 -1
  36. package/dist/mcp-server-6YG2QE6E.js.map +0 -1
  37. package/dist/module-host-KNMKS62R.js +0 -13
  38. package/dist/module-host-KNMKS62R.js.map +0 -1
  39. package/dist/module-loader-B3LYF3W7.js.map +0 -1
  40. package/dist/plugin-discovery-XRXI3IJF.js.map +0 -1
  41. package/dist/staleness-detector-4HJYBQ7Z.js.map +0 -1
@@ -0,0 +1,1036 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ SymbolGraph,
4
+ SymbolTokenizer
5
+ } from "./chunk-I7RPXT6L.js";
6
+ import {
7
+ DetailLevelSchema,
8
+ JsonIndexReader
9
+ } from "./chunk-EC6INGPV.js";
10
+ import {
11
+ createLogger
12
+ } from "./chunk-5DSKZJNN.js";
13
+
14
+ // src/core/search/fuzzy-corrector.ts
15
+ var FuzzyCorrector = class {
16
+ /** term → frequency (for tie-breaking) */
17
+ vocabulary = /* @__PURE__ */ new Map();
18
+ buildVocabulary(termFrequencies) {
19
+ this.vocabulary = new Map(termFrequencies);
20
+ }
21
+ /**
22
+ * Attempt to correct query tokens.
23
+ * Returns null if no correction found or no improvement possible.
24
+ */
25
+ correct(queryTokens) {
26
+ if (this.vocabulary.size === 0) return null;
27
+ const corrections = [];
28
+ const correctedTokens = [];
29
+ let anyChanged = false;
30
+ for (const token of queryTokens) {
31
+ if (token.length <= 2) {
32
+ correctedTokens.push(token);
33
+ continue;
34
+ }
35
+ if (this.vocabulary.has(token)) {
36
+ correctedTokens.push(token);
37
+ continue;
38
+ }
39
+ const maxDist = token.length <= 5 ? 1 : 2;
40
+ const best = this.findClosest(token, maxDist);
41
+ if (best) {
42
+ corrections.push({
43
+ original: token,
44
+ corrected: best.term,
45
+ distance: best.distance
46
+ });
47
+ correctedTokens.push(best.term);
48
+ anyChanged = true;
49
+ } else {
50
+ correctedTokens.push(token);
51
+ }
52
+ }
53
+ if (!anyChanged) return null;
54
+ return {
55
+ originalQuery: queryTokens.join(" "),
56
+ correctedQuery: correctedTokens.join(" "),
57
+ corrections
58
+ };
59
+ }
60
+ /**
61
+ * Find the closest vocabulary term within maxDistance.
62
+ * Tie-breaks by frequency (higher = preferred).
63
+ */
64
+ findClosest(token, maxDistance) {
65
+ let bestTerm = null;
66
+ let bestDist = maxDistance + 1;
67
+ let bestFreq = -1;
68
+ for (const [vocabTerm, freq] of this.vocabulary) {
69
+ if (Math.abs(vocabTerm.length - token.length) > maxDistance) continue;
70
+ const dist = damerauLevenshtein(token, vocabTerm);
71
+ if (dist <= maxDistance) {
72
+ if (dist < bestDist || dist === bestDist && freq > bestFreq) {
73
+ bestTerm = vocabTerm;
74
+ bestDist = dist;
75
+ bestFreq = freq;
76
+ }
77
+ }
78
+ }
79
+ return bestTerm !== null ? { term: bestTerm, distance: bestDist } : null;
80
+ }
81
+ };
82
+ function damerauLevenshtein(a, b) {
83
+ const lenA = a.length;
84
+ const lenB = b.length;
85
+ if (lenA === 0) return lenB;
86
+ if (lenB === 0) return lenA;
87
+ const d = Array.from({ length: lenA + 1 }, () => new Array(lenB + 1).fill(0));
88
+ for (let i = 0; i <= lenA; i++) d[i][0] = i;
89
+ for (let j = 0; j <= lenB; j++) d[0][j] = j;
90
+ for (let i = 1; i <= lenA; i++) {
91
+ for (let j = 1; j <= lenB; j++) {
92
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
93
+ d[i][j] = Math.min(
94
+ d[i - 1][j] + 1,
95
+ // deletion
96
+ d[i][j - 1] + 1,
97
+ // insertion
98
+ d[i - 1][j - 1] + cost
99
+ // substitution
100
+ );
101
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
102
+ d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + cost);
103
+ }
104
+ }
105
+ }
106
+ return d[lenA][lenB];
107
+ }
108
+
109
+ // src/core/search/search-engine.ts
110
+ var log = createLogger("ctxo:search");
111
+ var DEFAULT_CONFIG = {
112
+ bm25: { k1: 1.2, b: 0.25 },
113
+ pageRankWeight: 0.5,
114
+ trigramPenalty: 0.8,
115
+ phase2Threshold: 3,
116
+ fuzzyThreshold: 3,
117
+ maxPerPhase: 50
118
+ };
119
+ var DOCUMENT_SEARCH_CONFIG = Object.freeze({
120
+ bm25: Object.freeze({ k1: 1.2, b: 0.75 }),
121
+ pageRankWeight: 0
122
+ });
123
+ var SearchEngine = class {
124
+ config;
125
+ tokenizer;
126
+ fuzzy;
127
+ // Primary index (exact + tokenized)
128
+ documents = [];
129
+ primaryIndex = /* @__PURE__ */ new Map();
130
+ avgDocLength = 0;
131
+ isDocumentIndex = false;
132
+ // Trigram index (character 3-grams)
133
+ trigramIndex = /* @__PURE__ */ new Map();
134
+ // PageRank scores
135
+ pageRankScores = /* @__PURE__ */ new Map();
136
+ constructor(config) {
137
+ this.config = { ...DEFAULT_CONFIG, ...config };
138
+ this.tokenizer = new SymbolTokenizer({ includeOriginal: true, includeFilePath: true });
139
+ this.fuzzy = new FuzzyCorrector();
140
+ }
141
+ getTier() {
142
+ return "in-memory";
143
+ }
144
+ buildIndex(symbols, pageRankScores) {
145
+ const start = performance.now();
146
+ this.indexEntries(
147
+ symbols.map((sym) => {
148
+ const filePath = sym.symbolId.split("::")[0] ?? "";
149
+ return {
150
+ doc: {
151
+ symbolId: sym.symbolId,
152
+ name: sym.name,
153
+ kind: sym.kind,
154
+ filePath,
155
+ tokens: this.tokenizer.tokenize(sym.name, filePath),
156
+ length: 0
157
+ },
158
+ trigramText: sym.name.toLowerCase()
159
+ };
160
+ }),
161
+ pageRankScores ?? /* @__PURE__ */ new Map(),
162
+ false
163
+ );
164
+ const elapsed = performance.now() - start;
165
+ log.info(`Index built: ${symbols.length} symbols, ${this.primaryIndex.size} terms, ${this.trigramIndex.size} trigrams (${elapsed.toFixed(0)}ms)`);
166
+ }
167
+ /**
168
+ * Index documents instead of symbols: a hit carries the document `id` in `symbolId`, the `title` in `name`,
169
+ * `kind` and the `path` in `filePath`. The tokens are the title words, the path words and the text words
170
+ * (repeats kept); trigrams come from the lowercased title; there is no PageRank. A document index is
171
+ * rebuilt as a whole: `updateFile` does not apply to it.
172
+ */
173
+ buildDocumentIndex(documents) {
174
+ const start = performance.now();
175
+ this.indexEntries(
176
+ documents.map((document) => {
177
+ const tokens = [
178
+ ...this.tokenizer.tokenizeText(document.title),
179
+ ...SymbolTokenizer.tokenizeFilePath(document.path),
180
+ ...this.tokenizer.tokenizeText(document.text)
181
+ ];
182
+ return {
183
+ doc: {
184
+ symbolId: document.id,
185
+ name: document.title,
186
+ kind: document.kind,
187
+ filePath: document.path,
188
+ tokens,
189
+ length: 0
190
+ },
191
+ trigramText: document.title.toLowerCase()
192
+ };
193
+ }),
194
+ /* @__PURE__ */ new Map(),
195
+ true
196
+ );
197
+ const elapsed = performance.now() - start;
198
+ log.info(`Document index built: ${documents.length} documents, ${this.primaryIndex.size} terms, ${this.trigramIndex.size} trigrams (${elapsed.toFixed(0)}ms)`);
199
+ }
200
+ /** The indexing both `buildIndex` and `buildDocumentIndex` share: replaces the whole index. */
201
+ indexEntries(entries, pageRankScores, isDocumentIndex) {
202
+ this.isDocumentIndex = isDocumentIndex;
203
+ this.documents = [];
204
+ this.primaryIndex = /* @__PURE__ */ new Map();
205
+ this.trigramIndex = /* @__PURE__ */ new Map();
206
+ this.pageRankScores = pageRankScores;
207
+ let totalLength = 0;
208
+ const vocabFreq = /* @__PURE__ */ new Map();
209
+ for (const { doc, trigramText } of entries) {
210
+ const tokens = doc.tokens;
211
+ doc.length = tokens.length;
212
+ const docIndex = this.documents.length;
213
+ this.documents.push(doc);
214
+ totalLength += tokens.length;
215
+ const termFreqs = /* @__PURE__ */ new Map();
216
+ for (const token of tokens) {
217
+ termFreqs.set(token, (termFreqs.get(token) ?? 0) + 1);
218
+ vocabFreq.set(token, (vocabFreq.get(token) ?? 0) + 1);
219
+ }
220
+ for (const [term, tf] of termFreqs) {
221
+ let postings = this.primaryIndex.get(term);
222
+ if (!postings) {
223
+ postings = [];
224
+ this.primaryIndex.set(term, postings);
225
+ }
226
+ postings.push({ docIndex, termFrequency: tf });
227
+ }
228
+ const trigrams = this.extractTrigrams(trigramText);
229
+ const trigramFreqs = /* @__PURE__ */ new Map();
230
+ for (const tri of trigrams) {
231
+ trigramFreqs.set(tri, (trigramFreqs.get(tri) ?? 0) + 1);
232
+ }
233
+ for (const [tri, tf] of trigramFreqs) {
234
+ let postings = this.trigramIndex.get(tri);
235
+ if (!postings) {
236
+ postings = [];
237
+ this.trigramIndex.set(tri, postings);
238
+ }
239
+ postings.push({ docIndex, termFrequency: tf });
240
+ }
241
+ }
242
+ this.avgDocLength = this.documents.length > 0 ? totalLength / this.documents.length : 1;
243
+ this.fuzzy.buildVocabulary(vocabFreq);
244
+ }
245
+ search(query, limit = 50) {
246
+ const start = performance.now();
247
+ const queryTokens = this.tokenizer.tokenizeQuery(query);
248
+ if (queryTokens.length === 0) {
249
+ return this.emptyResponse(query, start);
250
+ }
251
+ const metrics = {
252
+ porterHits: 0,
253
+ trigramHits: 0,
254
+ phase2Activated: false,
255
+ fuzzyApplied: false,
256
+ latencyMs: 0
257
+ };
258
+ const phase1Scores = this.bm25Search(queryTokens, this.primaryIndex);
259
+ metrics.porterHits = phase1Scores.size;
260
+ let allScores = phase1Scores;
261
+ if (phase1Scores.size < this.config.phase2Threshold) {
262
+ metrics.phase2Activated = true;
263
+ const queryLower = query.toLowerCase();
264
+ if (queryLower.length >= 3) {
265
+ const queryTrigrams = this.extractTrigrams(queryLower);
266
+ const phase2Scores = this.bm25Search(queryTrigrams, this.trigramIndex);
267
+ metrics.trigramHits = phase2Scores.size;
268
+ allScores = this.mergeScores(phase1Scores, phase2Scores, this.config.trigramPenalty);
269
+ }
270
+ }
271
+ let fuzzyCorrection;
272
+ if (allScores.size < this.config.fuzzyThreshold) {
273
+ const correction = this.fuzzy.correct(queryTokens);
274
+ if (correction) {
275
+ metrics.fuzzyApplied = true;
276
+ fuzzyCorrection = correction;
277
+ const correctedTokens = this.tokenizer.tokenizeQuery(correction.correctedQuery);
278
+ const fuzzyScores = this.bm25Search(correctedTokens, this.primaryIndex);
279
+ allScores = this.mergeScores(allScores, fuzzyScores, 0.9);
280
+ if (allScores.size < this.config.fuzzyThreshold && correction.correctedQuery.length >= 3) {
281
+ const correctedTrigrams = this.extractTrigrams(correction.correctedQuery.toLowerCase());
282
+ const fuzzyTrigramScores = this.bm25Search(correctedTrigrams, this.trigramIndex);
283
+ allScores = this.mergeScores(allScores, fuzzyTrigramScores, this.config.trigramPenalty * 0.9);
284
+ }
285
+ }
286
+ }
287
+ const rawRelevance = new Map(allScores);
288
+ const effectiveQueryTerms = fuzzyCorrection ? this.tokenizer.tokenizeQuery(fuzzyCorrection.correctedQuery) : queryTokens;
289
+ if (effectiveQueryTerms.length >= 2) {
290
+ for (const [docIdx, score] of allScores) {
291
+ const doc = this.documents[docIdx];
292
+ const boost = this.bigramBoost(effectiveQueryTerms, doc.tokens);
293
+ allScores.set(docIdx, score * boost);
294
+ }
295
+ }
296
+ for (const [docIdx, score] of allScores) {
297
+ const doc = this.documents[docIdx];
298
+ const pr = this.pageRankScores.get(doc.symbolId) ?? 0;
299
+ allScores.set(docIdx, score * (1 + this.config.pageRankWeight * pr));
300
+ }
301
+ metrics.latencyMs = performance.now() - start;
302
+ const results = this.buildResults(allScores, rawRelevance, limit);
303
+ const phaseInfo = metrics.phase2Activated ? "phase2=yes" : "phase2=no";
304
+ if (fuzzyCorrection) {
305
+ log.info(`search "${query}" \u2192 fuzzy "${fuzzyCorrection.correctedQuery}": ${results.length} results (${metrics.latencyMs.toFixed(0)}ms)`);
306
+ } else {
307
+ log.info(`search "${query}": ${results.length} results, porter=${metrics.porterHits} trigram=${metrics.trigramHits} ${phaseInfo} (${metrics.latencyMs.toFixed(0)}ms)`);
308
+ }
309
+ return { query, results, metrics, ...fuzzyCorrection ? { fuzzyCorrection } : {} };
310
+ }
311
+ updateFile(filePath, symbols) {
312
+ if (this.isDocumentIndex) {
313
+ log.warn("updateFile does not apply to a document index; rebuild it with buildDocumentIndex");
314
+ return;
315
+ }
316
+ const oldIndices = /* @__PURE__ */ new Set();
317
+ for (let i = 0; i < this.documents.length; i++) {
318
+ if (this.documents[i].filePath === filePath) {
319
+ oldIndices.add(i);
320
+ }
321
+ }
322
+ if (oldIndices.size === 0 && symbols.length === 0) return;
323
+ const allSymbols = [];
324
+ for (let i = 0; i < this.documents.length; i++) {
325
+ if (!oldIndices.has(i)) {
326
+ const doc = this.documents[i];
327
+ allSymbols.push({
328
+ symbolId: doc.symbolId,
329
+ name: doc.name,
330
+ kind: doc.kind,
331
+ startLine: 0,
332
+ endLine: 0
333
+ });
334
+ }
335
+ }
336
+ allSymbols.push(...symbols);
337
+ this.buildIndex(allSymbols, this.pageRankScores);
338
+ }
339
+ /**
340
+ * BM25 scoring over an inverted index.
341
+ * Returns Map<docIndex, score>
342
+ */
343
+ bm25Search(queryTerms, index) {
344
+ const scores = /* @__PURE__ */ new Map();
345
+ const N = this.documents.length;
346
+ const { k1, b } = this.config.bm25;
347
+ for (const term of queryTerms) {
348
+ const postings = index.get(term);
349
+ if (!postings) continue;
350
+ const df = postings.length;
351
+ const idf = Math.log((N - df + 0.5) / (df + 0.5) + 1);
352
+ for (const { docIndex, termFrequency } of postings) {
353
+ const doc = this.documents[docIndex];
354
+ const tf = termFrequency;
355
+ const docLen = doc.length;
356
+ const numerator = tf * (k1 + 1);
357
+ const denominator = tf + k1 * (1 - b + b * (docLen / this.avgDocLength));
358
+ const termScore = idf * (numerator / denominator);
359
+ scores.set(docIndex, (scores.get(docIndex) ?? 0) + termScore);
360
+ }
361
+ }
362
+ const queryJoined = queryTerms.join("");
363
+ for (const [docIdx, score] of scores) {
364
+ const doc = this.documents[docIdx];
365
+ const nameLower = doc.name.toLowerCase();
366
+ if (nameLower === queryJoined || nameLower === queryTerms.join(" ")) {
367
+ scores.set(docIdx, score * 5);
368
+ }
369
+ }
370
+ return scores;
371
+ }
372
+ /**
373
+ * Merge two score maps. Phase 2 scores are penalized.
374
+ */
375
+ mergeScores(primary, secondary, penalty) {
376
+ const merged = new Map(primary);
377
+ for (const [docIdx, score] of secondary) {
378
+ const existing = merged.get(docIdx);
379
+ const penalized = score * penalty;
380
+ if (existing !== void 0) {
381
+ merged.set(docIdx, Math.max(existing, penalized));
382
+ } else {
383
+ merged.set(docIdx, penalized);
384
+ }
385
+ }
386
+ return merged;
387
+ }
388
+ /**
389
+ * Bigram boost for multi-word queries.
390
+ * Each adjacent query term pair found adjacent in symbol tokens → 2x boost.
391
+ */
392
+ bigramBoost(queryTerms, symbolTokens) {
393
+ if (queryTerms.length < 2) return 1;
394
+ let adjacentPairs = 0;
395
+ for (let i = 0; i < queryTerms.length - 1; i++) {
396
+ const idxA = symbolTokens.findIndex((t) => t.includes(queryTerms[i]));
397
+ const idxB = symbolTokens.findIndex((t) => t.includes(queryTerms[i + 1]));
398
+ if (idxA >= 0 && idxB >= 0 && Math.abs(idxA - idxB) === 1) {
399
+ adjacentPairs++;
400
+ }
401
+ }
402
+ return 1 + adjacentPairs * 2;
403
+ }
404
+ /**
405
+ * Extract character trigrams from a string.
406
+ * "sqlite" → ["sql", "qli", "lit", "ite"]
407
+ */
408
+ extractTrigrams(text) {
409
+ const trigrams = [];
410
+ for (let i = 0; i <= text.length - 3; i++) {
411
+ trigrams.push(text.substring(i, i + 3));
412
+ }
413
+ return trigrams;
414
+ }
415
+ buildResults(combinedScores, rawRelevance, limit) {
416
+ const entries = [...combinedScores.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);
417
+ return entries.map(([docIdx, combined]) => {
418
+ const doc = this.documents[docIdx];
419
+ const pr = this.pageRankScores.get(doc.symbolId) ?? 0;
420
+ return {
421
+ symbolId: doc.symbolId,
422
+ name: doc.name,
423
+ kind: doc.kind,
424
+ filePath: doc.filePath,
425
+ relevanceScore: rawRelevance.get(docIdx) ?? 0,
426
+ importanceScore: pr,
427
+ combinedScore: combined
428
+ };
429
+ });
430
+ }
431
+ emptyResponse(query, start) {
432
+ return {
433
+ query,
434
+ results: [],
435
+ metrics: {
436
+ porterHits: 0,
437
+ trigramHits: 0,
438
+ phase2Activated: false,
439
+ fuzzyApplied: false,
440
+ latencyMs: performance.now() - start
441
+ }
442
+ };
443
+ }
444
+ };
445
+
446
+ // src/core/response-envelope.ts
447
+ var DEFAULT_THRESHOLD = 8192;
448
+ var workspaceMeta;
449
+ function setWorkspaceMeta(workspace) {
450
+ workspaceMeta = workspace;
451
+ }
452
+ var TRUNCATABLE_FIELDS = {
453
+ impactedSymbols: "Use search_symbols to find specific impacted symbols, or get_blast_radius with a confidence filter.",
454
+ importers: "Use find_importers with edgeKinds filter or maxDepth to narrow results.",
455
+ deadSymbols: "Use search_symbols with kind filter to find specific dead symbols.",
456
+ unusedExports: "Use find_importers to check specific symbols.",
457
+ results: "Use search_symbols or get_ranked_context with a narrower query.",
458
+ rankings: "Use get_symbol_importance with kind or filePattern filter to narrow results.",
459
+ context: "Use get_context_for_task with a smaller tokenBudget.",
460
+ hierarchies: "Use get_class_hierarchy with a specific symbolId.",
461
+ scaffolding: "Review the scaffolding markers directly in the listed files.",
462
+ deadFiles: "Use find_dead_code with includeTests to adjust scope.",
463
+ files: "Use get_changed_symbols with a narrower since ref or smaller maxFiles.",
464
+ clusters: "Use get_architectural_overlay with mode=communities and layer filter for a focused view.",
465
+ candidates: "Use get_architectural_overlay with mode=contexts and candidate: <id> for one candidate in full, or ctxo contexts --json for the full contexts view.",
466
+ clustersAffected: "Use get_blast_radius on individual changed symbols for per-cluster impact.",
467
+ // The primary arrays of the @ctxo/domain tools (get_domain_context, get_rule). The additive domain lists
468
+ // on core tools (rulesAffected, cardsToUpdate, decisions, contextsTouched, cardsChanged, rulesWithoutTests)
469
+ // are capped at their source instead: listed here, a long one would take truncation over from the tool's
470
+ // primary array and `_meta.totalItems` would count the wrong list.
471
+ rules: "Use get_rule with a ruleId for one rule's tests, code and decisions.",
472
+ contexts: "Use get_domain_context with a symbolId or path for one context's card.",
473
+ terms: `Read the card's Language section, or use get_ranked_context with scope "domain".`,
474
+ openQuestions: "Read the card's Open questions section for the full list.",
475
+ tests: "Use search_symbols with a test file path as filePattern to list one file's tests.",
476
+ symbols: "Use get_blast_radius on one linked symbol for its impact.",
477
+ provenance: "Open the linked files directly; external references are never fetched.",
478
+ unresolved: 'Run "ctxo verify-domain --json" for every unresolved card reference.'
479
+ };
480
+ function getThreshold() {
481
+ const env = process.env["CTXO_RESPONSE_LIMIT"];
482
+ if (env) {
483
+ const n = parseInt(env, 10);
484
+ if (!isNaN(n) && n > 0) return n;
485
+ }
486
+ return DEFAULT_THRESHOLD;
487
+ }
488
+ function findTruncatableArray(data) {
489
+ let best = null;
490
+ for (const [key, value] of Object.entries(data)) {
491
+ if (Array.isArray(value) && key in TRUNCATABLE_FIELDS) {
492
+ if (!best || value.length > best.arr.length) {
493
+ best = { key, arr: value };
494
+ }
495
+ }
496
+ }
497
+ return best;
498
+ }
499
+ function buildMeta(base, extras) {
500
+ const meta = workspaceMeta ? { ...base, workspace: workspaceMeta } : base;
501
+ if (extras?.snapshotStaleness) {
502
+ return { ...meta, snapshotStaleness: extras.snapshotStaleness };
503
+ }
504
+ return meta;
505
+ }
506
+ function wrapResponse(data, extras) {
507
+ const threshold = getThreshold();
508
+ const fullJson = JSON.stringify(data);
509
+ const totalBytes = Buffer.byteLength(fullJson, "utf-8");
510
+ const truncatable = findTruncatableArray(data);
511
+ const totalItems = truncatable ? truncatable.arr.length : 0;
512
+ if (totalBytes <= threshold) {
513
+ return {
514
+ ...data,
515
+ _meta: buildMeta(
516
+ {
517
+ totalItems,
518
+ returnedItems: totalItems,
519
+ truncated: false,
520
+ totalBytes
521
+ },
522
+ extras
523
+ )
524
+ };
525
+ }
526
+ if (!truncatable || truncatable.arr.length <= 1) {
527
+ return {
528
+ ...data,
529
+ _meta: buildMeta(
530
+ {
531
+ totalItems,
532
+ returnedItems: totalItems,
533
+ truncated: false,
534
+ totalBytes
535
+ },
536
+ extras
537
+ )
538
+ };
539
+ }
540
+ let lo = 1;
541
+ let hi = truncatable.arr.length;
542
+ while (lo < hi) {
543
+ const mid = Math.ceil((lo + hi) / 2);
544
+ const trial = {
545
+ ...data,
546
+ [truncatable.key]: truncatable.arr.slice(0, mid),
547
+ _meta: buildMeta(
548
+ {
549
+ totalItems,
550
+ returnedItems: mid,
551
+ truncated: true,
552
+ totalBytes,
553
+ hint: TRUNCATABLE_FIELDS[truncatable.key]
554
+ },
555
+ extras
556
+ )
557
+ };
558
+ const size = Buffer.byteLength(JSON.stringify(trial), "utf-8");
559
+ if (size <= threshold) {
560
+ lo = mid;
561
+ } else {
562
+ hi = mid - 1;
563
+ }
564
+ }
565
+ const hint = TRUNCATABLE_FIELDS[truncatable.key];
566
+ return {
567
+ ...data,
568
+ [truncatable.key]: truncatable.arr.slice(0, lo),
569
+ _meta: buildMeta(
570
+ {
571
+ totalItems,
572
+ returnedItems: lo,
573
+ truncated: true,
574
+ totalBytes,
575
+ hint
576
+ },
577
+ extras
578
+ )
579
+ };
580
+ }
581
+
582
+ // src/core/describe-error.ts
583
+ function describeError(err) {
584
+ try {
585
+ if (err instanceof Error) {
586
+ const message = err.message;
587
+ if (typeof message === "string") return message;
588
+ }
589
+ if (typeof err === "string") return err;
590
+ return String(err);
591
+ } catch {
592
+ try {
593
+ return Object.prototype.toString.call(err);
594
+ } catch {
595
+ return "[unprintable error]";
596
+ }
597
+ }
598
+ }
599
+
600
+ // src/adapters/mcp/enrich-payload.ts
601
+ var log2 = createLogger("ctxo:modules");
602
+ function applyEnricher(enrich, payload, args, tool) {
603
+ if (enrich === void 0) return payload;
604
+ try {
605
+ const enriched = enrich(structuredClone(payload), args);
606
+ if (isThenable(enriched)) {
607
+ Promise.resolve(enriched).catch(() => void 0);
608
+ log2.warn(`${tool} enricher returned a Promise; a synchronous tool cannot wait for it`);
609
+ return payload;
610
+ }
611
+ return accepted(enriched, payload, tool);
612
+ } catch (err) {
613
+ return failed(err, payload, tool);
614
+ }
615
+ }
616
+ async function applyEnricherAsync(enrich, payload, args, tool) {
617
+ if (enrich === void 0) return payload;
618
+ try {
619
+ const enriched = await enrich(structuredClone(payload), args);
620
+ return accepted(enriched, payload, tool);
621
+ } catch (err) {
622
+ return failed(err, payload, tool);
623
+ }
624
+ }
625
+ function accepted(enriched, payload, tool) {
626
+ if (!isPlainObject(enriched) || !isPlainData(enriched, /* @__PURE__ */ new Set())) {
627
+ log2.warn(
628
+ `The ${tool} enricher returned something other than a plain JSON payload object; its fields are left out of this response`
629
+ );
630
+ return payload;
631
+ }
632
+ JSON.stringify(enriched);
633
+ return enriched;
634
+ }
635
+ function failed(err, payload, tool) {
636
+ log2.warn(`The ${tool} enricher failed: ${describeError(err)}; its fields are left out of this response`);
637
+ return payload;
638
+ }
639
+ function isThenable(value) {
640
+ if (value === null || typeof value !== "object" && typeof value !== "function") return false;
641
+ return typeof value.then === "function";
642
+ }
643
+ function isPlainObject(value) {
644
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
645
+ const proto = Object.getPrototypeOf(value);
646
+ return proto === Object.prototype || proto === null;
647
+ }
648
+ function isPlainData(value, path) {
649
+ if (value === null) return true;
650
+ const type = typeof value;
651
+ if (type === "string" || type === "number" || type === "boolean") return true;
652
+ if (type !== "object") return false;
653
+ const node = value;
654
+ if (path.has(node)) return false;
655
+ if (!Array.isArray(node) && !isPlainObject(node)) return false;
656
+ path.add(node);
657
+ try {
658
+ for (const child of Array.isArray(node) ? node : Object.values(node)) {
659
+ if (child !== void 0 && !isPlainData(child, path)) return false;
660
+ }
661
+ } finally {
662
+ path.delete(node);
663
+ }
664
+ return true;
665
+ }
666
+ function collectEnrichers(modules) {
667
+ const chains = /* @__PURE__ */ new Map();
668
+ for (const module of modules) {
669
+ let entries;
670
+ try {
671
+ entries = Object.entries(module.registration.enrich ?? {});
672
+ } catch (err) {
673
+ log2.warn(`${module.name} enrichers could not be read: ${describeError(err)}`);
674
+ continue;
675
+ }
676
+ for (const [tool, enrich] of entries) {
677
+ if (typeof enrich !== "function") continue;
678
+ const chain = chains.get(tool) ?? [];
679
+ chain.push(enrich);
680
+ chains.set(tool, chain);
681
+ }
682
+ }
683
+ const enrichers = /* @__PURE__ */ new Map();
684
+ for (const [tool, chain] of chains) {
685
+ enrichers.set(tool, chain.length === 1 ? chain[0] : composeChain(chain, tool));
686
+ }
687
+ return enrichers;
688
+ }
689
+ function composeChain(chain, tool) {
690
+ return (payload, args) => {
691
+ let current = payload;
692
+ for (let i = 0; i < chain.length; i++) {
693
+ try {
694
+ const enriched = chain[i](structuredClone(current), args);
695
+ if (isThenable(enriched)) return finishAsync(chain.slice(i + 1), enriched, current, args, tool);
696
+ current = accepted(enriched, current, tool);
697
+ } catch (err) {
698
+ current = failed(err, current, tool);
699
+ }
700
+ }
701
+ return current;
702
+ };
703
+ }
704
+ async function finishAsync(rest, pending, before, args, tool) {
705
+ let current;
706
+ try {
707
+ current = accepted(await pending, before, tool);
708
+ } catch (err) {
709
+ current = failed(err, before, tool);
710
+ }
711
+ for (const enrich of rest) current = await applyEnricherAsync(enrich, current, args, tool);
712
+ return current;
713
+ }
714
+ function collectTaskContextReservation(modules) {
715
+ const reservations = [];
716
+ for (const module of modules) {
717
+ let reserve;
718
+ try {
719
+ reserve = module.registration.reserveTaskContextTokens;
720
+ } catch (err) {
721
+ log2.warn(`${module.name} token reservation could not be read: ${describeError(err)}`);
722
+ continue;
723
+ }
724
+ if (typeof reserve === "function") reservations.push({ name: module.name, reserve });
725
+ }
726
+ if (reservations.length === 0) return void 0;
727
+ if (reservations.length === 1) return reservations[0].reserve;
728
+ return (args) => {
729
+ let sum = 0;
730
+ for (const { name, reserve } of reservations) {
731
+ let value;
732
+ try {
733
+ value = reserve(args);
734
+ } catch (err) {
735
+ log2.warn(`${name} token reservation failed: ${describeError(err)}; it is skipped`);
736
+ continue;
737
+ }
738
+ if (typeof value !== "number" || !Number.isFinite(value)) {
739
+ log2.warn(`${name} token reservation is not a finite number; it is skipped`);
740
+ continue;
741
+ }
742
+ sum += Math.max(value, 0);
743
+ }
744
+ return sum;
745
+ };
746
+ }
747
+
748
+ // src/adapters/mcp/get-logic-slice.ts
749
+ import { z } from "zod";
750
+
751
+ // src/core/logic-slice/logic-slice-query.ts
752
+ var LogicSliceQuery = class {
753
+ getLogicSlice(graph, symbolId, maxDepth = Infinity) {
754
+ const root = graph.getNode(symbolId);
755
+ if (!root) return void 0;
756
+ const visited = /* @__PURE__ */ new Set([root.symbolId]);
757
+ const dependencies = [];
758
+ const collectedEdges = [];
759
+ const queue = [{ id: root.symbolId, depth: 0 }];
760
+ while (queue.length > 0) {
761
+ const current = queue.shift();
762
+ if (current.depth >= maxDepth) continue;
763
+ const edges = graph.getForwardEdges(current.id);
764
+ for (const edge of edges) {
765
+ const depNode = graph.getNode(edge.to);
766
+ if (!depNode) continue;
767
+ collectedEdges.push(edge);
768
+ if (visited.has(edge.to)) continue;
769
+ visited.add(edge.to);
770
+ dependencies.push(depNode);
771
+ queue.push({ id: edge.to, depth: current.depth + 1 });
772
+ }
773
+ }
774
+ return { root, dependencies, edges: collectedEdges };
775
+ }
776
+ };
777
+
778
+ // src/core/detail-levels/detail-formatter.ts
779
+ var L1_MAX_LINES = 150;
780
+ var L4_TOKEN_BUDGET = 8e3;
781
+ function withoutAnnotations(node) {
782
+ if (node.annotations === void 0) return node;
783
+ const { annotations: _annotations, ...rest } = node;
784
+ return rest;
785
+ }
786
+ var DetailFormatter = class {
787
+ format(slice, level) {
788
+ switch (level) {
789
+ case 1:
790
+ return this.formatL1(slice);
791
+ case 2:
792
+ return this.formatL2(slice);
793
+ case 3:
794
+ return this.formatL3(slice);
795
+ case 4:
796
+ return this.formatL4(slice);
797
+ }
798
+ }
799
+ formatL1(slice) {
800
+ const root = slice.root;
801
+ const lineCount = root.endLine - root.startLine + 1;
802
+ const clampedRoot = lineCount > L1_MAX_LINES ? { ...root, endLine: root.startLine + L1_MAX_LINES - 1 } : root;
803
+ return {
804
+ root: withoutAnnotations(clampedRoot),
805
+ dependencies: [],
806
+ edges: [],
807
+ level: 1,
808
+ levelDescription: "L1: Root symbol signature only (no dependencies)",
809
+ ...lineCount > L1_MAX_LINES ? { truncation: { truncated: true, reason: "token_budget_exceeded" } } : {}
810
+ };
811
+ }
812
+ formatL2(slice) {
813
+ const directEdges = slice.edges.filter((e) => e.from === slice.root.symbolId);
814
+ const directDepIds = new Set(directEdges.map((e) => e.to));
815
+ const directDeps = slice.dependencies.filter((d) => directDepIds.has(d.symbolId));
816
+ return {
817
+ root: withoutAnnotations(slice.root),
818
+ dependencies: directDeps.map(withoutAnnotations),
819
+ edges: directEdges,
820
+ level: 2,
821
+ levelDescription: "L2: Root + direct dependencies (depth 1)"
822
+ };
823
+ }
824
+ formatL3(slice) {
825
+ return {
826
+ root: withoutAnnotations(slice.root),
827
+ dependencies: slice.dependencies.map(withoutAnnotations),
828
+ edges: slice.edges,
829
+ level: 3,
830
+ levelDescription: "L3: Full transitive dependency closure"
831
+ };
832
+ }
833
+ formatL4(slice) {
834
+ const estimatedTokens = this.estimateTokens(slice);
835
+ if (estimatedTokens > L4_TOKEN_BUDGET) {
836
+ const truncated = this.truncateToTokenBudget(slice, L4_TOKEN_BUDGET);
837
+ return {
838
+ ...truncated,
839
+ level: 4,
840
+ levelDescription: "L4: Full closure with 8K token budget (truncated)",
841
+ truncation: {
842
+ truncated: true,
843
+ reason: "token_budget_exceeded"
844
+ }
845
+ };
846
+ }
847
+ return {
848
+ root: slice.root,
849
+ dependencies: slice.dependencies,
850
+ edges: slice.edges,
851
+ level: 4,
852
+ levelDescription: "L4: Full closure with 8K token budget"
853
+ };
854
+ }
855
+ estimateTokens(slice) {
856
+ let chars = this.symbolCharEstimate(slice.root);
857
+ for (const dep of slice.dependencies) {
858
+ chars += this.symbolCharEstimate(dep);
859
+ }
860
+ for (const edge of slice.edges) {
861
+ chars += edge.from.length + edge.to.length + edge.kind.length + 10;
862
+ }
863
+ return Math.ceil(chars / 4);
864
+ }
865
+ symbolCharEstimate(node) {
866
+ if (node.startOffset !== void 0 && node.endOffset !== void 0) {
867
+ return node.endOffset - node.startOffset;
868
+ }
869
+ return (node.endLine - node.startLine + 1) * 40;
870
+ }
871
+ truncateToTokenBudget(slice, budget) {
872
+ let currentTokens = this.symbolCharEstimate(slice.root) / 4;
873
+ const deps = [];
874
+ const includedIds = /* @__PURE__ */ new Set([slice.root.symbolId]);
875
+ for (const dep of slice.dependencies) {
876
+ const depTokens = this.symbolCharEstimate(dep) / 4;
877
+ if (currentTokens + depTokens > budget) break;
878
+ currentTokens += depTokens;
879
+ deps.push(dep);
880
+ includedIds.add(dep.symbolId);
881
+ }
882
+ const edges = slice.edges.filter(
883
+ (e) => includedIds.has(e.from) && includedIds.has(e.to)
884
+ );
885
+ return { root: slice.root, dependencies: deps, edges };
886
+ }
887
+ };
888
+
889
+ // src/core/intent-filter.ts
890
+ function extractKeywords(intent) {
891
+ return intent.toLowerCase().split(/\s+/).filter((w) => w.length >= 2);
892
+ }
893
+ function matchesAny(keywords, values) {
894
+ const lower = values.map((v) => v.toLowerCase());
895
+ return keywords.some((kw) => lower.some((v) => v.includes(kw)));
896
+ }
897
+ function collectMatchableStrings(entry) {
898
+ const strings = [];
899
+ for (const key of ["symbolId", "file", "name", "kind", "edgeKind", "reason", "confidence"]) {
900
+ const v = entry[key];
901
+ if (typeof v === "string") strings.push(v);
902
+ }
903
+ const edgeKinds = entry["edgeKinds"];
904
+ if (Array.isArray(edgeKinds)) {
905
+ for (const ek of edgeKinds) {
906
+ if (typeof ek === "string") strings.push(ek);
907
+ }
908
+ }
909
+ return strings;
910
+ }
911
+ function filterByIntent(items, intent) {
912
+ if (!intent || intent.trim().length === 0) return items;
913
+ const keywords = extractKeywords(intent);
914
+ if (keywords.length === 0) return items;
915
+ return items.filter((item) => {
916
+ const strings = collectMatchableStrings(item);
917
+ return matchesAny(keywords, strings);
918
+ });
919
+ }
920
+
921
+ // src/adapters/mcp/get-logic-slice.ts
922
+ var InputSchema = z.object({
923
+ symbolId: z.string().min(1).optional(),
924
+ symbolIds: z.array(z.string().min(1)).optional(),
925
+ level: DetailLevelSchema.optional().default(3),
926
+ intent: z.string().optional().describe('Filter dependencies by intent keywords (e.g., "core", "adapter", "storage")')
927
+ }).refine(
928
+ (data) => data.symbolId || data.symbolIds && data.symbolIds.length > 0,
929
+ { message: "Either symbolId or symbolIds must be provided" }
930
+ );
931
+ function buildGraphFromStorage(storage) {
932
+ const graph = new SymbolGraph();
933
+ for (const sym of storage.getAllSymbols()) {
934
+ graph.addNode(sym);
935
+ }
936
+ for (const edge of storage.getAllEdges()) {
937
+ graph.addEdge(edge);
938
+ }
939
+ return graph;
940
+ }
941
+ function buildGraphAndFilesFromJsonIndex(ctxoRoot) {
942
+ const reader = new JsonIndexReader(ctxoRoot);
943
+ const indices = reader.readAll();
944
+ return { graph: buildGraphFromIndices(indices), indexedFiles: indices.map((fileIndex) => fileIndex.file) };
945
+ }
946
+ function buildGraphFromIndices(indices) {
947
+ const graph = new SymbolGraph();
948
+ for (const fileIndex of indices) {
949
+ for (const sym of fileIndex.symbols) {
950
+ graph.addNode(sym);
951
+ }
952
+ }
953
+ for (const fileIndex of indices) {
954
+ for (const edge of fileIndex.edges) {
955
+ graph.addEdge(edge);
956
+ }
957
+ }
958
+ return graph;
959
+ }
960
+ function getGraphAndFiles(ctxoRoot, storage) {
961
+ const json = buildGraphAndFilesFromJsonIndex(ctxoRoot);
962
+ if (json.graph.nodeCount > 0) return json;
963
+ return { graph: buildGraphFromStorage(storage), indexedFiles: storage.listIndexedFiles() };
964
+ }
965
+ function handleGetLogicSlice(storage, masking, staleness, ctxoRoot = ".ctxo", enrich) {
966
+ const query = new LogicSliceQuery();
967
+ const formatter = new DetailFormatter();
968
+ const getGraph = () => getGraphAndFiles(ctxoRoot, storage);
969
+ const handler = (args) => {
970
+ try {
971
+ const parsed = InputSchema.safeParse(args);
972
+ if (!parsed.success) {
973
+ return {
974
+ content: [{ type: "text", text: JSON.stringify({ error: true, message: parsed.error.message }) }]
975
+ };
976
+ }
977
+ const { symbolId, symbolIds, level, intent } = parsed.data;
978
+ const ids = symbolIds ?? (symbolId ? [symbolId] : []);
979
+ const { graph, indexedFiles } = getGraph();
980
+ const results = [];
981
+ for (const id of ids) {
982
+ const slice = query.getLogicSlice(graph, id);
983
+ if (slice) {
984
+ const formatted = formatter.format(slice, level);
985
+ if (intent && Array.isArray(formatted["dependencies"])) {
986
+ formatted["dependencies"] = filterByIntent(
987
+ formatted["dependencies"],
988
+ intent
989
+ );
990
+ }
991
+ results.push(formatted);
992
+ } else {
993
+ results.push({ found: false, symbolId: id, hint: 'Symbol not found. Run "ctxo index".' });
994
+ }
995
+ }
996
+ const responseData = ids.length === 1 ? results[0] : { batch: true, results };
997
+ const body = applyEnricher(enrich, responseData, parsed.data, "get_logic_slice");
998
+ const payload = masking.mask(JSON.stringify(wrapResponse(body)));
999
+ const content = [];
1000
+ if (staleness) {
1001
+ const warning = staleness.check(indexedFiles);
1002
+ if (warning) {
1003
+ content.push({ type: "text", text: `\u26A0\uFE0F ${warning.message}` });
1004
+ }
1005
+ }
1006
+ content.push({ type: "text", text: payload });
1007
+ return { content };
1008
+ } catch (err) {
1009
+ return {
1010
+ content: [{ type: "text", text: JSON.stringify({ error: true, message: err.message }) }]
1011
+ };
1012
+ }
1013
+ };
1014
+ return handler;
1015
+ }
1016
+
1017
+ export {
1018
+ DOCUMENT_SEARCH_CONFIG,
1019
+ SearchEngine,
1020
+ LogicSliceQuery,
1021
+ withoutAnnotations,
1022
+ setWorkspaceMeta,
1023
+ getThreshold,
1024
+ wrapResponse,
1025
+ filterByIntent,
1026
+ describeError,
1027
+ applyEnricher,
1028
+ applyEnricherAsync,
1029
+ collectEnrichers,
1030
+ collectTaskContextReservation,
1031
+ buildGraphFromStorage,
1032
+ buildGraphFromIndices,
1033
+ getGraphAndFiles,
1034
+ handleGetLogicSlice
1035
+ };
1036
+ //# sourceMappingURL=chunk-IT426P3F.js.map