@ajdev0/token-shrink 2.0.2 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -189,20 +189,37 @@ __export(index_exports, {
189
189
  CLAUDE_RULE_SENTINEL: () => CLAUDE_RULE_SENTINEL,
190
190
  CLINE_RULE_PATH: () => CLINE_RULE_PATH,
191
191
  CLINE_RULE_SENTINEL: () => CLINE_RULE_SENTINEL,
192
+ CONFIG_FILE_NAME: () => CONFIG_FILE_NAME,
192
193
  CURSOR_RULE_PATH: () => CURSOR_RULE_PATH,
193
194
  DEFAULT_IGNORED: () => DEFAULT_IGNORED,
195
+ EMPTY_CONFIG: () => EMPTY_CONFIG,
196
+ PROJECT_MANIFEST_FILES: () => PROJECT_MANIFEST_FILES,
194
197
  RULE_TARGET_PATH: () => RULE_TARGET_PATH,
198
+ SymbolSearch: () => SymbolSearch,
199
+ VCS_MARKER_DIRS: () => VCS_MARKER_DIRS,
195
200
  allExtensions: () => allExtensions,
201
+ analyze: () => analyze,
196
202
  approximateTokens: () => approximateTokens,
197
203
  assemble: () => assemble,
204
+ assembleMany: () => assembleMany,
205
+ collectSymbols: () => collectSymbols,
206
+ configPathFor: () => configPathFor,
198
207
  createAutoRule: () => createAutoRule,
199
208
  createCursorRule: () => createCursorRule,
200
209
  createWatcher: () => createWatcher,
210
+ detectProjectRoot: () => detectProjectRoot,
211
+ ensureParserInit: () => ensureParserInit,
201
212
  extractImports: () => extractImports,
202
213
  extractSpecifiers: () => extractSpecifiers,
203
214
  getGrammar: () => getGrammar,
215
+ globToRegExp: () => globToRegExp,
204
216
  hashOf: () => hashOf,
205
217
  languageForFile: () => languageForFile,
218
+ loadConfig: () => loadConfig,
219
+ loadLanguage: () => loadLanguage,
220
+ matchSymbols: () => matchSymbols,
221
+ matchesAny: () => matchesAny,
222
+ matchesGlob: () => matchesGlob,
206
223
  prune: () => prune,
207
224
  registry: () => registry,
208
225
  resolveImport: () => resolveImport,
@@ -215,7 +232,7 @@ __export(index_exports, {
215
232
  });
216
233
  module.exports = __toCommonJS(index_exports);
217
234
 
218
- // src/parser/pruner.ts
235
+ // src/parser/analyze.ts
219
236
  var import_web_tree_sitter = __toESM(require("web-tree-sitter"), 1);
220
237
  var import_node_module = require("module");
221
238
  var import_node_path3 = __toESM(require("path"), 1);
@@ -300,7 +317,97 @@ async function warmGrammars(specs = []) {
300
317
  return results.filter((r) => r.status === "fulfilled").length;
301
318
  }
302
319
 
303
- // src/parser/pruner.ts
320
+ // src/parser/symbols.ts
321
+ var KIND_BY_TYPE = {
322
+ // TypeScript / JavaScript / TSX / JSX
323
+ function_declaration: "function",
324
+ generator_function_declaration: "function",
325
+ function_expression: "function",
326
+ function_signature_item: "function",
327
+ method_definition: "method",
328
+ method_signature: "method",
329
+ class_declaration: "class",
330
+ abstract_class_declaration: "class",
331
+ interface_declaration: "interface",
332
+ enum_declaration: "enum",
333
+ type_alias_declaration: "type",
334
+ // Python
335
+ function_definition: "function",
336
+ class_definition: "class",
337
+ // Go
338
+ method_declaration: "method",
339
+ type_spec: "type",
340
+ // Rust
341
+ function_item: "function",
342
+ struct_item: "class",
343
+ enum_item: "enum",
344
+ trait_item: "interface",
345
+ type_item: "type",
346
+ // Java / Kotlin / PHP / Dart / Swift (best-effort generic names)
347
+ constructor_declaration: "method",
348
+ module_declaration: "class",
349
+ protocol_declaration: "interface"
350
+ };
351
+ var SIGNATURE_MAX = 200;
352
+ function collectSymbols(root, source) {
353
+ const out = [];
354
+ const walk2 = (node) => {
355
+ const kind = KIND_BY_TYPE[node.type];
356
+ if (kind) {
357
+ const nameNode = node.childForFieldName("name");
358
+ if (nameNode && nameNode.text.trim()) {
359
+ out.push(symbolFrom(nameNode.text.trim(), kind, node.startIndex, node.endIndex, source));
360
+ }
361
+ } else if (node.type === "variable_declarator") {
362
+ const value = node.childForFieldName("value");
363
+ const valueType = value?.type;
364
+ if (valueType === "arrow_function" || valueType === "function_expression") {
365
+ const nameNode = node.childForFieldName("name");
366
+ if (nameNode && nameNode.text.trim()) {
367
+ out.push(
368
+ symbolFrom(nameNode.text.trim(), "arrow", node.startIndex, node.endIndex, source)
369
+ );
370
+ }
371
+ }
372
+ }
373
+ for (let i = 0; i < node.childCount; i++) {
374
+ const child = node.child(i);
375
+ if (child) walk2(child);
376
+ }
377
+ };
378
+ walk2(root);
379
+ return out;
380
+ }
381
+ function matchSymbols(symbols, query, kind) {
382
+ const q = query.trim();
383
+ if (!q) return [];
384
+ const pool = kind ? symbols.filter((s) => s.kind === kind) : symbols;
385
+ const byName = pool.filter((s) => s.name === q);
386
+ if (byName.length > 0) return byName;
387
+ const byNameCi = pool.filter((s) => s.name.toLowerCase() === q.toLowerCase());
388
+ if (byNameCi.length > 0) return byNameCi;
389
+ const lower = q.toLowerCase();
390
+ const bySubstring = pool.filter((s) => s.name.toLowerCase().includes(lower));
391
+ return bySubstring.length > 0 ? bySubstring : pool.filter((s) => (s.signature ?? "").toLowerCase().includes(lower));
392
+ }
393
+ function symbolFrom(name, kind, start, end, source) {
394
+ return {
395
+ name,
396
+ kind,
397
+ line: source.slice(0, start).split("\n").length,
398
+ start,
399
+ end,
400
+ signature: signaturePreview(source, start, end)
401
+ };
402
+ }
403
+ function signaturePreview(source, start, end) {
404
+ const nl = source.indexOf("\n", start);
405
+ const endOfLine = nl === -1 ? end : nl;
406
+ const first = source.slice(start, Math.min(endOfLine, end)).trim();
407
+ return first.length > SIGNATURE_MAX ? `${first.slice(0, SIGNATURE_MAX)}\u2026` : first;
408
+ }
409
+
410
+ // src/parser/analyze.ts
304
411
  var languageCache = /* @__PURE__ */ new Map();
305
412
  var initPromise = null;
306
413
  function ensureParserInit() {
@@ -333,35 +440,6 @@ function loadLanguage(spec, force = false) {
333
440
  return language;
334
441
  })();
335
442
  }
336
- async function prune(filePath, source, opts = {}) {
337
- const spec = languageForFile(filePath);
338
- if (!spec || spec.rules.length === 0) {
339
- return { code: source, language: null, removed: 0 };
340
- }
341
- const language = await loadLanguage(spec, opts.forceDownload);
342
- const parser = new import_web_tree_sitter.default();
343
- parser.setLanguage(language);
344
- const tree = parser.parse(source);
345
- const ranges = [];
346
- for (const rule of spec.rules) {
347
- const query = language.query(rule.query);
348
- const captures = query.captures(tree.rootNode);
349
- for (const cap of captures) {
350
- if (cap.node.startIndex === cap.node.endIndex) continue;
351
- if (rule.replacement.keepIf?.test(cap.node.text)) continue;
352
- ranges.push({
353
- start: cap.node.startIndex,
354
- end: cap.node.endIndex,
355
- token: rule.replacement.token
356
- });
357
- }
358
- query.delete();
359
- }
360
- tree.delete();
361
- parser.delete();
362
- const { code, removed } = spliceRanges(source, ranges);
363
- return { code, language: spec.name, removed };
364
- }
365
443
  function spliceRanges(source, ranges) {
366
444
  let removed = 0;
367
445
  const sorted = [...ranges].sort((a, b) => b.start - a.start);
@@ -373,13 +451,354 @@ function spliceRanges(source, ranges) {
373
451
  }
374
452
  return { code: out, removed };
375
453
  }
454
+ async function analyze(filePath, source, opts = {}) {
455
+ const spec = languageForFile(filePath);
456
+ if (!spec || spec.rules.length === 0) {
457
+ return { code: source, language: null, removed: 0, symbols: [] };
458
+ }
459
+ let language;
460
+ try {
461
+ language = await loadLanguage(spec, opts.forceDownload);
462
+ } catch {
463
+ return { code: source, language: null, removed: 0, symbols: [] };
464
+ }
465
+ const parser = new import_web_tree_sitter.default();
466
+ parser.setLanguage(language);
467
+ const tree = parser.parse(source);
468
+ try {
469
+ const symbols = collectSymbols(tree.rootNode, source);
470
+ if (opts.skipPrune) {
471
+ return { code: source, language: spec.name, removed: 0, symbols };
472
+ }
473
+ const keepRanges = [
474
+ ...opts.keepBlocksInside ?? [],
475
+ ...opts.preserveAnnotations?.length ? annotatedKeepRanges(symbols, source, opts.preserveAnnotations) : []
476
+ ];
477
+ const isProtected = (start, end) => keepRanges.some((k) => k.start <= start && end <= k.end);
478
+ const ranges = [];
479
+ for (const rule of spec.rules) {
480
+ const query = language.query(rule.query);
481
+ const captures = query.captures(tree.rootNode);
482
+ for (const cap of captures) {
483
+ if (cap.node.startIndex === cap.node.endIndex) continue;
484
+ if (rule.replacement.keepIf?.test(cap.node.text)) continue;
485
+ if (isProtected(cap.node.startIndex, cap.node.endIndex)) continue;
486
+ ranges.push({
487
+ start: cap.node.startIndex,
488
+ end: cap.node.endIndex,
489
+ token: rule.replacement.token
490
+ });
491
+ }
492
+ query.delete();
493
+ }
494
+ const { code, removed } = spliceRanges(source, ranges);
495
+ return { code, language: spec.name, removed, symbols };
496
+ } finally {
497
+ tree.delete();
498
+ parser.delete();
499
+ }
500
+ }
501
+ async function prune(filePath, source, opts = {}) {
502
+ const res = await analyze(filePath, source, opts);
503
+ return { code: res.code, language: res.language, removed: res.removed };
504
+ }
505
+ var ANNOTATION_WINDOW = 240;
506
+ function annotatedKeepRanges(symbols, source, annotations) {
507
+ if (annotations.length === 0) return [];
508
+ const markers = annotations.filter(Boolean).map(escapeRegExp);
509
+ if (markers.length === 0) return [];
510
+ const re = new RegExp(`@(${markers.join("|")})\\b`, "i");
511
+ return symbols.filter((s) => {
512
+ const windowStart = Math.max(0, s.start - ANNOTATION_WINDOW);
513
+ const tail = source.slice(windowStart, s.start);
514
+ const cut = Math.max(tail.lastIndexOf("}\n"), tail.lastIndexOf(";\n"));
515
+ const relevant = cut === -1 ? tail : tail.slice(cut + 1);
516
+ return re.test(relevant);
517
+ }).map((s) => ({ start: s.start, end: s.end }));
518
+ }
519
+ function escapeRegExp(text) {
520
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
521
+ }
376
522
 
377
523
  // src/index.ts
378
524
  init_registry();
379
525
 
380
- // src/watcher/sync.ts
526
+ // src/detect.ts
381
527
  var import_node_fs2 = __toESM(require("fs"), 1);
382
528
  var import_node_path4 = __toESM(require("path"), 1);
529
+ var VCS_MARKER_DIRS = [".git", ".hg", ".svn"];
530
+ var PROJECT_MANIFEST_FILES = [
531
+ // JavaScript / TypeScript
532
+ "package.json",
533
+ "tsconfig.json",
534
+ // Python
535
+ "pyproject.toml",
536
+ "setup.py",
537
+ "setup.cfg",
538
+ "requirements.txt",
539
+ // Go / Rust / Dart / Swift
540
+ "go.mod",
541
+ "Cargo.toml",
542
+ "pubspec.yaml",
543
+ "Package.swift",
544
+ // Java / Kotlin
545
+ "pom.xml",
546
+ "build.gradle",
547
+ "build.gradle.kts",
548
+ "settings.gradle",
549
+ "settings.gradle.kts",
550
+ // PHP / Ruby / Elixir
551
+ "composer.json",
552
+ "Gemfile",
553
+ "mix.exs"
554
+ ];
555
+ var MANIFEST_FILE_SET = new Set(PROJECT_MANIFEST_FILES);
556
+ var MAX_WALK_DEPTH = 64;
557
+ function detectProjectRoot(fromPath) {
558
+ const resolved = import_node_path4.default.resolve(fromPath);
559
+ let dir = isDirectory(resolved) ? resolved : import_node_path4.default.dirname(resolved);
560
+ let nearestManifest = null;
561
+ for (let depth = 0; depth < MAX_WALK_DEPTH; depth++) {
562
+ const names = readDirNames(dir);
563
+ if (names) {
564
+ if (VCS_MARKER_DIRS.some((vcs) => names.has(vcs))) return dir;
565
+ if (nearestManifest === null && hasManifest(names)) nearestManifest = dir;
566
+ }
567
+ const parent = import_node_path4.default.dirname(dir);
568
+ if (parent === dir) break;
569
+ dir = parent;
570
+ }
571
+ return nearestManifest;
572
+ }
573
+ function isDirectory(p) {
574
+ try {
575
+ return import_node_fs2.default.statSync(p).isDirectory();
576
+ } catch {
577
+ return false;
578
+ }
579
+ }
580
+ function readDirNames(dir) {
581
+ try {
582
+ return new Set(import_node_fs2.default.readdirSync(dir));
583
+ } catch {
584
+ return null;
585
+ }
586
+ }
587
+ function hasManifest(names) {
588
+ for (const name of names) {
589
+ if (MANIFEST_FILE_SET.has(name)) return true;
590
+ }
591
+ return false;
592
+ }
593
+
594
+ // src/search/index.ts
595
+ function words(text) {
596
+ const out = /* @__PURE__ */ new Set();
597
+ for (const part of text.split(/[^A-Za-z0-9_$]+/)) {
598
+ if (!part) continue;
599
+ for (const seg of part.split(/(?<=[a-z0-9])(?=[A-Z])/)) {
600
+ const w = seg.toLowerCase();
601
+ if (w) out.add(w);
602
+ }
603
+ for (const seg of part.split("_")) {
604
+ const w = seg.toLowerCase();
605
+ if (w) out.add(w);
606
+ }
607
+ }
608
+ return [...out];
609
+ }
610
+ var SymbolSearch = class {
611
+ docs = [];
612
+ byFile = /* @__PURE__ */ new Map();
613
+ inverted = /* @__PURE__ */ new Map();
614
+ get size() {
615
+ return this.docs.length;
616
+ }
617
+ /** Replace the docs for one file (or remove when `symbols` is empty). */
618
+ setFile(filePath, symbols) {
619
+ this.removeFile(filePath);
620
+ if (symbols.length === 0) return;
621
+ const docs = symbols.map((s) => ({ ...s, filePath }));
622
+ const ids = docs.map((d) => {
623
+ const id = this.docs.length;
624
+ this.docs.push(d);
625
+ for (const w of words(`${d.name}`)) {
626
+ const list = this.inverted.get(w) ?? [];
627
+ list.push(id);
628
+ this.inverted.set(w, list);
629
+ }
630
+ return id;
631
+ });
632
+ this.byFile.set(filePath, docs);
633
+ void ids;
634
+ }
635
+ removeFile(filePath) {
636
+ const docs = this.byFile.get(filePath);
637
+ if (!docs) return;
638
+ const removed = new Set(docs);
639
+ this.docs = this.docs.filter((d) => !removed.has(d));
640
+ this.byFile.delete(filePath);
641
+ this.rebuildInverted();
642
+ }
643
+ /** Bulk-load a whole cache (used at attach time and cold lazy builds). */
644
+ loadCache(entries) {
645
+ this.docs = [];
646
+ this.byFile.clear();
647
+ for (const [abs, entry] of entries) {
648
+ if (!entry.symbols || entry.symbols.length === 0) continue;
649
+ const docs = entry.symbols.map((s) => ({ ...s, filePath: abs }));
650
+ this.byFile.set(abs, docs);
651
+ this.docs.push(...docs);
652
+ }
653
+ this.rebuildInverted();
654
+ }
655
+ /** Ranked symbol hits for `query`. */
656
+ search(query, opts = {}) {
657
+ const maxResults = opts.maxResults ?? 10;
658
+ const q = query.trim().toLowerCase();
659
+ if (!q) return [];
660
+ const queryWords = words(q);
661
+ let candidates;
662
+ if (queryWords.length > 0) {
663
+ const ids = /* @__PURE__ */ new Set();
664
+ for (const w of queryWords) {
665
+ const post = this.inverted.get(w);
666
+ if (post) for (const id of post) ids.add(id);
667
+ }
668
+ candidates = [...ids].map((id) => this.docs[id]);
669
+ } else {
670
+ candidates = this.docs;
671
+ }
672
+ if (opts.kind) candidates = candidates.filter((d) => d.kind === opts.kind);
673
+ const scored = candidates.map((d) => this.score(d, q, queryWords)).filter((s) => s !== null).sort((a, b) => b.score - a.score || a.line - b.line).slice(0, maxResults);
674
+ return scored.map((s) => ({ ...s, label: this.label(s) }));
675
+ }
676
+ score(d, q, queryWords) {
677
+ const name = d.name.toLowerCase();
678
+ let score = 0;
679
+ if (name === q) score += 100;
680
+ if (name.startsWith(q)) score += 60;
681
+ if (name.includes(q)) score += 30;
682
+ if ((d.signature ?? "").toLowerCase().includes(q)) score += 8;
683
+ for (const w of queryWords) {
684
+ if (name.includes(w)) score += 4;
685
+ if ((d.signature ?? "").toLowerCase().includes(w)) score += 1;
686
+ }
687
+ if (score <= 0) return null;
688
+ return { ...d, score };
689
+ }
690
+ label(d) {
691
+ const sig = d.signature && d.signature.length > 0 ? ` \u2014 ${d.signature}` : "";
692
+ return `${d.filePath}:${d.line}${sig}`;
693
+ }
694
+ rebuildInverted() {
695
+ this.inverted.clear();
696
+ this.docs.forEach((d, id) => {
697
+ for (const w of words(d.name)) {
698
+ const list = this.inverted.get(w) ?? [];
699
+ list.push(id);
700
+ this.inverted.set(w, list);
701
+ }
702
+ });
703
+ }
704
+ };
705
+
706
+ // src/config.ts
707
+ var import_node_fs3 = __toESM(require("fs"), 1);
708
+ var import_node_path5 = __toESM(require("path"), 1);
709
+ var CONFIG_FILE_NAME = ".tokenshrinkrc.json";
710
+ var EMPTY_CONFIG = {
711
+ ignorePatterns: [],
712
+ keepUnpruned: [],
713
+ preserveAnnotations: []
714
+ };
715
+ function configPathFor(root) {
716
+ return import_node_path5.default.join(root, CONFIG_FILE_NAME);
717
+ }
718
+ function loadConfig(root, warn) {
719
+ const file = configPathFor(root);
720
+ let raw;
721
+ try {
722
+ if (!import_node_fs3.default.existsSync(file)) return { ...EMPTY_CONFIG };
723
+ raw = JSON.parse(import_node_fs3.default.readFileSync(file, "utf8"));
724
+ } catch (err) {
725
+ warn?.(`Invalid ${CONFIG_FILE_NAME} at ${file}: ${err.message}`);
726
+ return { ...EMPTY_CONFIG };
727
+ }
728
+ return normalizeConfig(raw, warn, file);
729
+ }
730
+ function normalizeConfig(raw, warn, file) {
731
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
732
+ warn?.(`Invalid ${CONFIG_FILE_NAME}${file ? ` at ${file}` : ""}: expected a JSON object.`);
733
+ return { ...EMPTY_CONFIG };
734
+ }
735
+ const obj = raw;
736
+ const asStrings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
737
+ return {
738
+ ignorePatterns: asStrings(obj.ignorePatterns),
739
+ keepUnpruned: asStrings(obj.keepUnpruned),
740
+ preserveAnnotations: asStrings(obj.preserveAnnotations),
741
+ ...typeof obj.autoWorkflow === "boolean" ? { autoWorkflow: obj.autoWorkflow } : {}
742
+ };
743
+ }
744
+ function matchesAny(file, patterns) {
745
+ const rel2 = normalizeSlashes(file);
746
+ for (const raw of patterns) {
747
+ if (!raw) continue;
748
+ const pattern = normalizeSlashes(raw).replace(/^\.\//, "");
749
+ if (matchesGlob(rel2, pattern)) return true;
750
+ }
751
+ return false;
752
+ }
753
+ function matchesGlob(file, pattern) {
754
+ if (file === pattern) return true;
755
+ if (!/[?*[]/.test(pattern)) {
756
+ return file.endsWith(`/${pattern}`);
757
+ }
758
+ const re = globToRegExp(pattern);
759
+ return re.test(file);
760
+ }
761
+ function globToRegExp(glob) {
762
+ let re = "^";
763
+ for (let i = 0; i < glob.length; i++) {
764
+ const c = glob[i];
765
+ if (c === "*") {
766
+ if (glob[i + 1] === "*") {
767
+ i++;
768
+ if (glob[i + 1] === "/") {
769
+ i++;
770
+ re += "(?:.*/)?";
771
+ } else {
772
+ re += ".*";
773
+ }
774
+ } else {
775
+ re += "[^/]*";
776
+ }
777
+ } else if (c === "?") {
778
+ re += "[^/]";
779
+ } else if (c === "[") {
780
+ const close = glob.indexOf("]", i);
781
+ if (close === -1) {
782
+ re += "\\[";
783
+ } else {
784
+ const inner = glob.slice(i + 1, close).replace(/\\/g, "\\\\");
785
+ re += `[${inner}]`;
786
+ i = close;
787
+ }
788
+ } else {
789
+ re += c.replace(/[.+^${}()|\\]/g, "\\$&");
790
+ }
791
+ }
792
+ re += "$";
793
+ return new RegExp(re);
794
+ }
795
+ function normalizeSlashes(p) {
796
+ return p.split(import_node_path5.default.sep).join("/").replace(/^\.\//, "");
797
+ }
798
+
799
+ // src/watcher/sync.ts
800
+ var import_node_fs4 = __toESM(require("fs"), 1);
801
+ var import_node_path6 = __toESM(require("path"), 1);
383
802
  var import_node_crypto = require("crypto");
384
803
  var import_chokidar = __toESM(require("chokidar"), 1);
385
804
  var DEFAULT_IGNORED = [
@@ -399,7 +818,7 @@ var ContextCache = class {
399
818
  }
400
819
  /** Returns the cached skeleton for a file, if present. */
401
820
  getSkeleton(filePath) {
402
- return this.entries.get(import_node_path4.default.resolve(filePath)) ?? null;
821
+ return this.entries.get(import_node_path6.default.resolve(filePath)) ?? null;
403
822
  }
404
823
  get imports() {
405
824
  return this.entries;
@@ -421,18 +840,18 @@ function extractImports(_filePath, source) {
421
840
  function resolveImport(importer, specifier, root) {
422
841
  if (!/^[.~@]/.test(specifier)) {
423
842
  if (specifier.startsWith("@/")) {
424
- return resolveCandidate(import_node_path4.default.join(root, specifier.slice(2)));
843
+ return resolveCandidate(import_node_path6.default.join(root, specifier.slice(2)));
425
844
  }
426
845
  if (specifier.startsWith("@")) {
427
846
  return null;
428
847
  }
429
848
  if (specifier.startsWith("~")) {
430
- return resolveCandidate(import_node_path4.default.join(root, specifier.slice(1)));
849
+ return resolveCandidate(import_node_path6.default.join(root, specifier.slice(1)));
431
850
  }
432
851
  return null;
433
852
  }
434
- const base = import_node_path4.default.dirname(import_node_path4.default.resolve(importer));
435
- return resolveCandidate(import_node_path4.default.resolve(base, specifier));
853
+ const base = import_node_path6.default.dirname(import_node_path6.default.resolve(importer));
854
+ return resolveCandidate(import_node_path6.default.resolve(base, specifier));
436
855
  }
437
856
  function resolveCandidate(p) {
438
857
  const candidates = [
@@ -455,29 +874,34 @@ function resolveCandidate(p) {
455
874
  `${p}.h`,
456
875
  `${p}.hpp`,
457
876
  `${p}.php`,
458
- import_node_path4.default.join(p, "index.ts"),
459
- import_node_path4.default.join(p, "index.js"),
460
- import_node_path4.default.join(p, "index.tsx"),
461
- import_node_path4.default.join(p, "index.jsx"),
462
- import_node_path4.default.join(p, "index.py")
877
+ import_node_path6.default.join(p, "index.ts"),
878
+ import_node_path6.default.join(p, "index.js"),
879
+ import_node_path6.default.join(p, "index.tsx"),
880
+ import_node_path6.default.join(p, "index.jsx"),
881
+ import_node_path6.default.join(p, "index.py")
463
882
  ];
464
883
  for (const c of candidates) {
465
- if (import_node_fs2.default.existsSync(c) && import_node_fs2.default.statSync(c).isFile()) return import_node_path4.default.resolve(c);
884
+ if (import_node_fs4.default.existsSync(c) && import_node_fs4.default.statSync(c).isFile()) return import_node_path6.default.resolve(c);
466
885
  }
467
886
  return null;
468
887
  }
469
888
  function createWatcher(opts) {
470
889
  const cache = new ContextCache();
471
- const { root, ignored = DEFAULT_IGNORED, onIndexed } = opts;
890
+ const { root, ignored = DEFAULT_IGNORED, config, onIndexed, onRemoved } = opts;
891
+ const rel2 = (abs) => import_node_path6.default.relative(root, abs);
892
+ const effectiveIgnored = [
893
+ ...ignored,
894
+ ...config?.ignorePatterns?.length ? [(_abs) => matchesAny(rel2(String(_abs)), config.ignorePatterns)] : []
895
+ ];
472
896
  const pending = /* @__PURE__ */ new Map();
473
897
  const DEBOUNCE_MS = 100;
474
898
  async function handle(filePath) {
475
- const abs = import_node_path4.default.resolve(filePath);
899
+ const abs = import_node_path6.default.resolve(filePath);
476
900
  let source;
477
901
  try {
478
- const st = import_node_fs2.default.statSync(abs);
902
+ const st = import_node_fs4.default.statSync(abs);
479
903
  if (!st.isFile()) return;
480
- source = import_node_fs2.default.readFileSync(abs, "utf8");
904
+ source = import_node_fs4.default.readFileSync(abs, "utf8");
481
905
  } catch {
482
906
  return;
483
907
  }
@@ -485,13 +909,18 @@ function createWatcher(opts) {
485
909
  const cached = cache.entries.get(abs);
486
910
  if (cached && cached.hash === hash) return;
487
911
  await cache.ensureInit();
488
- const { code, language } = await prune(abs, source);
489
- const imports = await extractImports(abs, source);
912
+ const keepFull = config?.keepUnpruned?.length ? matchesAny(import_node_path6.default.relative(root, abs), config.keepUnpruned) : false;
913
+ const { code, language, symbols } = await analyze(abs, source, {
914
+ skipPrune: keepFull,
915
+ preserveAnnotations: config?.preserveAnnotations
916
+ });
917
+ const imports = extractImports(abs, source);
490
918
  const entry = {
491
919
  hash,
492
920
  skeleton: code,
493
921
  language,
494
- imports: imports.map((spec) => resolveImport(abs, spec, root)).filter((p) => p !== null)
922
+ imports: imports.map((spec) => resolveImport(abs, spec, root)).filter((p) => p !== null),
923
+ ...symbols.length > 0 ? { symbols } : {}
495
924
  };
496
925
  cache.entries.set(abs, entry);
497
926
  onIndexed?.(abs, entry);
@@ -501,7 +930,7 @@ function createWatcher(opts) {
501
930
  // sockets) are never opened with fs.watch (which raises UVException).
502
931
  ignored(_p, stats) {
503
932
  if (stats && !stats.isFile() && !stats.isDirectory()) return true;
504
- return isIgnored(import_node_path4.default.resolve(String(_p)), ignored);
933
+ return isIgnored(import_node_path6.default.resolve(String(_p)), effectiveIgnored);
505
934
  },
506
935
  alwaysStat: true,
507
936
  ignoreInitial: true,
@@ -509,7 +938,7 @@ function createWatcher(opts) {
509
938
  awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 10 }
510
939
  });
511
940
  function debounce(filePath) {
512
- const abs = import_node_path4.default.resolve(filePath);
941
+ const abs = import_node_path6.default.resolve(filePath);
513
942
  const existing = pending.get(abs);
514
943
  if (existing) clearTimeout(existing);
515
944
  if (!existing) void handle(abs);
@@ -521,8 +950,9 @@ function createWatcher(opts) {
521
950
  watcher.on("add", debounce);
522
951
  watcher.on("change", debounce);
523
952
  watcher.on("unlink", (filePath) => {
524
- const abs = import_node_path4.default.resolve(filePath);
953
+ const abs = import_node_path6.default.resolve(filePath);
525
954
  cache.entries.delete(abs);
955
+ onRemoved?.(abs);
526
956
  });
527
957
  return {
528
958
  cache,
@@ -535,7 +965,7 @@ function createWatcher(opts) {
535
965
  */
536
966
  async indexAll() {
537
967
  const files = [];
538
- await walk(root, (f) => files.push(f), ignored);
968
+ await walk(root, (f) => files.push(f), effectiveIgnored);
539
969
  let ok = 0;
540
970
  for (const f of files) {
541
971
  try {
@@ -550,9 +980,9 @@ function createWatcher(opts) {
550
980
  };
551
981
  }
552
982
  async function walk(root, push, ignored) {
553
- const entries = await import_node_fs2.default.promises.readdir(root, { withFileTypes: true });
983
+ const entries = await import_node_fs4.default.promises.readdir(root, { withFileTypes: true });
554
984
  for (const e of entries) {
555
- const abs = import_node_path4.default.join(root, e.name);
985
+ const abs = import_node_path6.default.join(root, e.name);
556
986
  if (isIgnored(abs, ignored)) continue;
557
987
  if (e.isDirectory()) {
558
988
  await walk(abs, push, ignored);
@@ -563,6 +993,10 @@ async function walk(root, push, ignored) {
563
993
  }
564
994
  function isIgnored(abs, ignored) {
565
995
  for (const m of ignored) {
996
+ if (typeof m === "function") {
997
+ if (m(abs)) return true;
998
+ continue;
999
+ }
566
1000
  if (typeof m === "string" && abs.includes(m)) return true;
567
1001
  if (m instanceof RegExp && m.test(abs)) return true;
568
1002
  }
@@ -570,100 +1004,179 @@ function isIgnored(abs, ignored) {
570
1004
  }
571
1005
 
572
1006
  // src/server/assembler.ts
573
- var import_node_fs3 = __toESM(require("fs"), 1);
574
- var import_node_path5 = __toESM(require("path"), 1);
575
- function assemble(activeFilePath, cache, opts = {}) {
576
- const { includeStats = false, maxSkeletons = 50, pruneActiveFile = false } = opts;
577
- const abs = import_node_path5.default.resolve(activeFilePath);
578
- const activeSource = import_node_fs3.default.existsSync(abs) ? import_node_fs3.default.readFileSync(abs, "utf8") : "";
579
- const activeEntry = cache.get(abs);
1007
+ var import_node_fs5 = __toESM(require("fs"), 1);
1008
+ var import_node_path7 = __toESM(require("path"), 1);
1009
+ var MARKDOWN_OVERHEAD_TOKENS = 40;
1010
+ function assembleMany(activeFiles, cache, opts = {}) {
1011
+ const {
1012
+ includeStats = false,
1013
+ maxSkeletons = 50,
1014
+ maxTokens,
1015
+ pruneActiveFile = false
1016
+ } = opts;
1017
+ const absList = activeFiles.map((p) => import_node_path7.default.resolve(p));
1018
+ const sources = absList.map((abs) => {
1019
+ try {
1020
+ return import_node_fs5.default.readFileSync(abs, "utf8");
1021
+ } catch {
1022
+ return "";
1023
+ }
1024
+ });
1025
+ const activeSet = new Set(absList);
1026
+ const ring0Texts = absList.map((abs, i) => {
1027
+ const entry = cache.get(abs);
1028
+ return pruneActiveFile && entry ? entry.skeleton : sources[i];
1029
+ });
580
1030
  const included = [];
581
1031
  const unresolved = [];
582
1032
  const seen = /* @__PURE__ */ new Set();
583
- if (activeEntry) {
584
- for (const imp of activeEntry.imports) {
585
- const entry = cache.get(imp);
586
- if (!entry) {
587
- unresolved.push(imp);
588
- continue;
589
- }
590
- if (seen.has(imp)) continue;
591
- seen.add(imp);
592
- included.push({ filePath: imp, language: entry.language });
593
- if (included.length >= maxSkeletons) break;
594
- }
595
- }
596
- if (!activeEntry && activeSource) {
597
- const specifiers = extractSpecifiers(activeSource);
598
- for (const spec of specifiers) {
599
- const resolved = resolveLocal(abs, spec);
600
- if (!resolved) {
601
- unresolved.push(spec);
602
- continue;
603
- }
604
- if (seen.has(resolved)) continue;
605
- seen.add(resolved);
606
- const cached = cache.get(resolved);
607
- included.push({ filePath: resolved, language: cached?.language ?? null });
608
- if (included.length >= maxSkeletons) break;
1033
+ const addUnresolved = (p) => {
1034
+ if (!unresolved.includes(p)) unresolved.push(p);
1035
+ };
1036
+ const addIncluded = (p, allowUncached) => {
1037
+ if (seen.has(p)) return;
1038
+ const entry = cache.get(p);
1039
+ if (!entry && !allowUncached) {
1040
+ addUnresolved(p);
1041
+ return;
1042
+ }
1043
+ seen.add(p);
1044
+ included.push({ filePath: p, language: entry?.language ?? null });
1045
+ };
1046
+ for (let i = 0; i < absList.length; i++) {
1047
+ const abs = absList[i];
1048
+ const entry = cache.get(abs);
1049
+ if (entry) {
1050
+ for (const imp of entry.imports) {
1051
+ if (activeSet.has(imp)) continue;
1052
+ addIncluded(imp, false);
1053
+ }
1054
+ } else if (sources[i]) {
1055
+ for (const spec of extractSpecifiers(sources[i])) {
1056
+ const resolved = resolveLocal(abs, spec);
1057
+ if (!resolved) {
1058
+ addUnresolved(spec);
1059
+ continue;
1060
+ }
1061
+ if (activeSet.has(resolved)) continue;
1062
+ addIncluded(resolved, true);
1063
+ }
1064
+ }
1065
+ }
1066
+ const ordered = maxTokens === void 0 ? included : rankByRelevance(included, cache);
1067
+ const budget = maxTokens === void 0 ? void 0 : Math.max(0, maxTokens - MARKDOWN_OVERHEAD_TOKENS);
1068
+ const selected = [];
1069
+ let trimmed = 0;
1070
+ let dependencyTokens = 0;
1071
+ for (const cand of ordered) {
1072
+ if (selected.length >= maxSkeletons) break;
1073
+ const entry = cache.get(cand.filePath);
1074
+ const cost = entry ? approximateTokens(entry.skeleton) : 8;
1075
+ if (budget !== void 0 && dependencyTokens + cost > budget) {
1076
+ trimmed++;
1077
+ continue;
609
1078
  }
1079
+ selected.push(cand);
1080
+ dependencyTokens += cost;
610
1081
  }
1082
+ const activeTokens = ring0Texts.reduce(
1083
+ (acc, t) => acc + approximateTokens(t),
1084
+ 0
1085
+ );
611
1086
  const lines = [];
612
1087
  lines.push("# Compressed Code Context", "");
613
- lines.push(`Active file: \`${rel(abs)}\``, "");
614
- lines.push("## Ring 0 \u2014 Active file (full text)", "");
615
- const ring0 = pruneActiveFile && activeEntry ? activeEntry.skeleton : activeSource;
616
- lines.push(`\`\`\`${ext(activeFilePath)}`);
617
- lines.push(ring0.trim() || "(empty or unreadable file)");
618
- lines.push("```", "");
1088
+ if (absList.length === 1) {
1089
+ lines.push(`Active file: \`${rel(absList[0])}\``, "");
1090
+ } else {
1091
+ lines.push("Active files:", "");
1092
+ for (const p of absList) lines.push(`- \`${rel(p)}\``);
1093
+ lines.push("");
1094
+ }
1095
+ lines.push(
1096
+ absList.length === 1 ? "## Ring 0 \u2014 Active file (full text)" : "## Ring 0 \u2014 Active files (full text)",
1097
+ ""
1098
+ );
1099
+ for (let i = 0; i < absList.length; i++) {
1100
+ if (absList.length > 1) lines.push(`### \`${rel(absList[i])}\``, "");
1101
+ const body = ring0Texts[i].trim() || "(empty or unreadable file)";
1102
+ lines.push(`\`\`\`${ext(absList[i])}`, body, "```", "");
1103
+ }
619
1104
  lines.push(
620
- `## Ring 1 \u2014 Pruned dependencies (${included.length})`,
1105
+ `## Ring 1 \u2014 Pruned dependencies (${selected.length})`,
621
1106
  "",
622
1107
  "Implementation bodies removed; type signatures, interfaces and exports retained.",
623
1108
  ""
624
1109
  );
625
- if (included.length === 0) {
1110
+ if (selected.length === 0) {
626
1111
  lines.push("_No local dependency skeletons available._", "");
627
1112
  }
628
- for (const inc of included) {
1113
+ for (const inc of selected) {
629
1114
  const entry = cache.get(inc.filePath);
630
1115
  const label = rel(inc.filePath);
631
1116
  lines.push(`### \`${label}\``, "");
632
1117
  if (entry) {
633
- lines.push(`\`\`\`${ext(inc.filePath)}`);
634
- lines.push(entry.skeleton.trim());
635
- lines.push("```", "");
1118
+ lines.push(`\`\`\`${ext(inc.filePath)}`, entry.skeleton.trim(), "```", "");
636
1119
  } else {
637
1120
  lines.push("_Unindexed file._", "");
638
1121
  }
639
1122
  }
1123
+ if (maxTokens !== void 0 && trimmed > 0) {
1124
+ lines.push(
1125
+ `_Note: token budget of ${maxTokens} excluded ${trimmed} lower-priority dependenc${trimmed === 1 ? "y" : "ies"}._`,
1126
+ ""
1127
+ );
1128
+ }
640
1129
  if (unresolved.length > 0) {
641
1130
  lines.push("## Unresolved imports", "");
642
1131
  for (const u of unresolved) lines.push(`- \`${u}\``);
643
1132
  lines.push("");
644
1133
  }
645
1134
  if (includeStats) {
646
- const depTokens = included.reduce(
647
- (acc, inc) => {
648
- const e = cache.get(inc.filePath);
649
- return e ? acc + approximateTokens(e.skeleton) : acc;
650
- },
651
- 0
652
- );
653
- lines.push("---", "");
1135
+ const budgetNote = maxTokens !== void 0 ? ` \xB7 budget ${maxTokens} (${trimmed} deps trimmed)` : "";
654
1136
  lines.push(
655
- `_Token estimate \u2014 active: ${approximateTokens(activeSource)} \xB7 pruned deps: ${depTokens}._`,
1137
+ "---",
1138
+ "",
1139
+ `_Token estimate \u2014 active: ${activeTokens} \xB7 pruned deps: ${dependencyTokens}${budgetNote}._`,
656
1140
  ""
657
1141
  );
658
1142
  }
659
1143
  return {
660
1144
  markdown: lines.join("\n"),
661
- activeFilePath: abs,
662
- activeSource,
663
- included,
664
- unresolved
1145
+ activeFilePath: absList[0] ?? "",
1146
+ activeFilePaths: absList,
1147
+ activeSource: sources.join("\n"),
1148
+ included: selected,
1149
+ unresolved,
1150
+ tokenStats: {
1151
+ activeTokens,
1152
+ dependencyTokens,
1153
+ totalTokens: activeTokens + dependencyTokens,
1154
+ ...maxTokens !== void 0 ? { budget: maxTokens } : {},
1155
+ trimmed
1156
+ }
665
1157
  };
666
1158
  }
1159
+ function assemble(activeFilePath, cache, opts = {}) {
1160
+ return assembleMany([activeFilePath], cache, opts);
1161
+ }
1162
+ function rankByRelevance(candidates, cache) {
1163
+ const fanIn = /* @__PURE__ */ new Map();
1164
+ for (const entry of cache.values()) {
1165
+ for (const imp of entry.imports) fanIn.set(imp, (fanIn.get(imp) ?? 0) + 1);
1166
+ }
1167
+ return candidates.map((cand, idx) => ({ cand, idx })).sort((a, b) => {
1168
+ const fa = fanIn.get(a.cand.filePath) ?? 0;
1169
+ const fb = fanIn.get(b.cand.filePath) ?? 0;
1170
+ if (fa !== fb) return fb - fa;
1171
+ const da = pathDepth(a.cand.filePath);
1172
+ const db = pathDepth(b.cand.filePath);
1173
+ if (da !== db) return da - db;
1174
+ return a.idx - b.idx;
1175
+ }).map((x) => x.cand);
1176
+ }
1177
+ function pathDepth(p) {
1178
+ return p.split(/[/\\]+/).filter(Boolean).length;
1179
+ }
667
1180
  function extractSpecifiers(source) {
668
1181
  const found = [];
669
1182
  const re = /(?:from\s+['"`]|import\s+['"`]|require\s*\(\s*['"`])([^'"`]+)['"`]/g;
@@ -677,8 +1190,8 @@ function resolveLocal(importer, specifier) {
677
1190
  if (!/^[.~@]/.test(specifier)) return null;
678
1191
  if (specifier.startsWith("@/")) specifier = specifier.slice(2);
679
1192
  else if (specifier.startsWith("~")) specifier = specifier.slice(1);
680
- const base = import_node_path5.default.dirname(import_node_path5.default.resolve(importer));
681
- const p = import_node_path5.default.resolve(base, specifier);
1193
+ const base = import_node_path7.default.dirname(import_node_path7.default.resolve(importer));
1194
+ const p = import_node_path7.default.resolve(base, specifier);
682
1195
  const candidates = [
683
1196
  p,
684
1197
  `${p}.ts`,
@@ -694,11 +1207,11 @@ function resolveLocal(importer, specifier) {
694
1207
  `${p}.kt`,
695
1208
  `${p}.c`,
696
1209
  `${p}.cpp`,
697
- import_node_path5.default.join(p, "index.ts"),
698
- import_node_path5.default.join(p, "index.js")
1210
+ import_node_path7.default.join(p, "index.ts"),
1211
+ import_node_path7.default.join(p, "index.js")
699
1212
  ];
700
1213
  for (const c of candidates) {
701
- if (import_node_fs3.default.existsSync(c) && import_node_fs3.default.statSync(c).isFile()) return import_node_path5.default.resolve(c);
1214
+ if (import_node_fs5.default.existsSync(c) && import_node_fs5.default.statSync(c).isFile()) return import_node_path7.default.resolve(c);
702
1215
  }
703
1216
  return null;
704
1217
  }
@@ -711,7 +1224,7 @@ function rel(p) {
711
1224
  return p;
712
1225
  }
713
1226
  function ext(p) {
714
- return import_node_path5.default.extname(p).replace(/^\./, "") || "text";
1227
+ return import_node_path7.default.extname(p).replace(/^\./, "") || "text";
715
1228
  }
716
1229
  function approximateTokens(text) {
717
1230
  if (!text) return 0;
@@ -722,8 +1235,8 @@ function approximateTokens(text) {
722
1235
  // src/cli.ts
723
1236
  var import_fastify = __toESM(require("fastify"), 1);
724
1237
  var import_picocolors = __toESM(require("picocolors"), 1);
725
- var import_node_fs4 = __toESM(require("fs"), 1);
726
- var import_node_path6 = __toESM(require("path"), 1);
1238
+ var import_node_fs6 = __toESM(require("fs"), 1);
1239
+ var import_node_path8 = __toESM(require("path"), 1);
727
1240
  function parseArgv(argv = process.argv) {
728
1241
  const out = {};
729
1242
  for (let i = 0; i < argv.length; i++) {
@@ -745,8 +1258,9 @@ async function startServer(opts = {}) {
745
1258
  const args = parseArgv();
746
1259
  const port = opts.port ?? Number(args.port ?? process.env.PORT ?? 3e3);
747
1260
  const host = opts.host ?? args.host ?? "0.0.0.0";
748
- const root = import_node_path6.default.resolve(opts.root ?? args.root ?? process.cwd());
1261
+ const root = import_node_path8.default.resolve(opts.root ?? args.root ?? process.cwd());
749
1262
  const silent = opts.silent ?? args.silent === "true";
1263
+ const maxTokensDefault = args["max-tokens"] ? Number(args["max-tokens"]) : void 0;
750
1264
  const log = (msg) => {
751
1265
  if (!silent) console.log(import_picocolors.default.dim(msg));
752
1266
  };
@@ -765,18 +1279,27 @@ async function startServer(opts = {}) {
765
1279
  }));
766
1280
  app.post("/v1/context", async (req, reply) => {
767
1281
  const body = req.body ?? {};
768
- if (!body.activeFilePath) {
769
- return reply.status(400).send({ error: "`activeFilePath` is required" });
1282
+ const hasMany = Array.isArray(body.activeFiles) && body.activeFiles.length > 0;
1283
+ if (hasMany && body.activeFilePath) {
1284
+ return reply.status(400).send({ error: "Pass either `activeFilePath` or `activeFiles`, not both." });
770
1285
  }
771
- const result = assemble(body.activeFilePath, watcher.cache.entries, {
1286
+ const paths = hasMany ? body.activeFiles : body.activeFilePath ? [body.activeFilePath] : [];
1287
+ if (paths.length === 0) {
1288
+ return reply.status(400).send({ error: "`activeFilePath` or `activeFiles` is required" });
1289
+ }
1290
+ const maxTokens = body.maxTokens !== void 0 ? body.maxTokens : maxTokensDefault;
1291
+ const result = assembleMany(paths, watcher.cache.entries, {
772
1292
  maxSkeletons: body.maxSkeletons,
1293
+ maxTokens: Number.isFinite(maxTokens) ? maxTokens : void 0,
773
1294
  includeStats: body.includeStats
774
1295
  });
775
1296
  return {
776
1297
  markdown: result.markdown,
777
1298
  activeFilePath: result.activeFilePath,
1299
+ activeFilePaths: result.activeFilePaths,
778
1300
  dependencies: result.included.map((i) => i.filePath),
779
- unresolved: result.unresolved
1301
+ unresolved: result.unresolved,
1302
+ tokenStats: result.tokenStats
780
1303
  };
781
1304
  });
782
1305
  app.setNotFoundHandler(async (req, reply) => {
@@ -787,10 +1310,10 @@ async function startServer(opts = {}) {
787
1310
  log(`Listening on http://${host}:${port}`);
788
1311
  return { app, watcher };
789
1312
  }
790
- var argv1 = process.argv[1] ? import_node_path6.default.basename(process.argv[1]) : "";
1313
+ var argv1 = process.argv[1] ? import_node_path8.default.basename(process.argv[1]) : "";
791
1314
  var argv1Real = "";
792
1315
  try {
793
- argv1Real = process.argv[1] ? import_node_path6.default.basename(import_node_fs4.default.realpathSync(process.argv[1])) : "";
1316
+ argv1Real = process.argv[1] ? import_node_path8.default.basename(import_node_fs6.default.realpathSync(process.argv[1])) : "";
794
1317
  } catch {
795
1318
  }
796
1319
  if (argv1 === "cli.js" || argv1 === "cli.mjs" || argv1 === "cli.cjs" || argv1 === "cli.ts" || argv1Real === "cli.js" || argv1Real === "cli.mjs" || argv1Real === "cli.cjs" || argv1Real === "cli.ts") {
@@ -804,13 +1327,109 @@ if (argv1 === "cli.js" || argv1 === "cli.mjs" || argv1 === "cli.cjs" || argv1 ==
804
1327
  var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
805
1328
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
806
1329
  var import_zod = require("zod");
807
- var import_node_fs5 = __toESM(require("fs"), 1);
808
- var import_node_path7 = __toESM(require("path"), 1);
1330
+ var import_node_fs8 = __toESM(require("fs"), 1);
1331
+ var import_node_path10 = __toESM(require("path"), 1);
1332
+ init_registry();
1333
+
1334
+ // src/git.ts
1335
+ var import_node_child_process = require("child_process");
1336
+ var import_node_path9 = __toESM(require("path"), 1);
1337
+ var import_node_fs7 = __toESM(require("fs"), 1);
1338
+ function runGit(root, args) {
1339
+ return new Promise((resolve) => {
1340
+ const child = (0, import_node_child_process.spawn)("git", ["--no-pager", ...args], {
1341
+ cwd: root,
1342
+ env: { ...process.env, LC_ALL: "C" },
1343
+ stdio: ["ignore", "pipe", "pipe"]
1344
+ });
1345
+ let out = "";
1346
+ let err = "";
1347
+ child.stdout.on("data", (d) => {
1348
+ out += d;
1349
+ });
1350
+ child.stderr.on("data", (d) => {
1351
+ err += d;
1352
+ });
1353
+ child.on("error", (e) => resolve({ ok: false, out: "", err: e.message }));
1354
+ child.on("close", (code) => resolve({ ok: code === 0, out, err }));
1355
+ });
1356
+ }
1357
+ function parseNames(out) {
1358
+ return out.split("\n").map((l) => l.trim()).filter(Boolean);
1359
+ }
1360
+ function isFatal(err) {
1361
+ if (!err) return void 0;
1362
+ const e = err.trim();
1363
+ if (/not a git repository|fatal:/i.test(e)) return e;
1364
+ return void 0;
1365
+ }
1366
+ async function gitChangedFiles(root, opts) {
1367
+ const scope = opts.scope ?? "worktree";
1368
+ const filter = "--diff-filter=ACMRT";
1369
+ let relative = [];
1370
+ if (scope === "staged") {
1371
+ const r = await runGit(root, ["diff", "--cached", "--name-only", filter]);
1372
+ const fatal = isFatal(r.err);
1373
+ if (!r.ok) return { files: [], ...fatal ? { error: fatal } : {} };
1374
+ relative = parseNames(r.out);
1375
+ } else if (scope === "branch") {
1376
+ const base = opts.base ?? "HEAD~1";
1377
+ const head = opts.head ?? "HEAD";
1378
+ const r = await runGit(root, ["diff", "--name-only", `${base}...${head}`, filter]);
1379
+ const fatal = isFatal(r.err);
1380
+ if (!r.ok) return { files: [], ...fatal ? { error: fatal } : {} };
1381
+ relative = parseNames(r.out);
1382
+ } else {
1383
+ const [unstaged, staged] = await Promise.all([
1384
+ runGit(root, ["diff", "--name-only", filter]),
1385
+ runGit(root, ["diff", "--cached", "--name-only", filter])
1386
+ ]);
1387
+ const fatal = isFatal(unstaged.err) ?? isFatal(staged.err);
1388
+ if (!unstaged.ok && !staged.ok) {
1389
+ return { files: [], ...fatal ? { error: fatal } : {} };
1390
+ }
1391
+ const merged = /* @__PURE__ */ new Set([...parseNames(unstaged.out), ...parseNames(staged.out)]);
1392
+ if (opts.includeUntracked) {
1393
+ const ut = await runGit(root, ["ls-files", "--others", "--exclude-standard"]);
1394
+ for (const f of parseNames(ut.out)) merged.add(f);
1395
+ }
1396
+ relative = [...merged];
1397
+ }
1398
+ const files = relative.map((rel2) => import_node_path9.default.resolve(root, rel2)).filter((abs) => {
1399
+ try {
1400
+ return import_node_fs7.default.statSync(abs).isFile();
1401
+ } catch {
1402
+ return false;
1403
+ }
1404
+ });
1405
+ return { files };
1406
+ }
1407
+
1408
+ // src/mcp.ts
809
1409
  var RULE_TARGET_PATH = {
810
1410
  cursor: ".cursor/rules/token-shrink.mdc",
811
1411
  claude: ".claude/rules/token-shrink.md",
812
1412
  cline: ".clinerules/token-shrink.md"
813
1413
  };
1414
+ var RULE_VERSION = 3;
1415
+ var RULE_VERSION_MARKER = `# token-shrink rule v${RULE_VERSION}`;
1416
+ var WORKFLOW_GUIDANCE = [
1417
+ "When a pruned skeleton is not enough to write or change code correctly, expand the exact",
1418
+ "definition with expand_symbol.",
1419
+ "",
1420
+ "Call search_symbol_signatures automatically for any identifier you reference that the",
1421
+ "context does not already define, so you always work from exact signatures.",
1422
+ "",
1423
+ "Before multi-file edits, reviews, or work on code with uncommitted or staged changes, call",
1424
+ "git_diff_context and use its impact payload (changed files and their callers) as context.",
1425
+ "",
1426
+ "Pass activeFiles to get_compressed_code_context when a task spans several files, and",
1427
+ "maxTokens whenever the payload must fit a token budget."
1428
+ ].join("\n");
1429
+ var LIGHT_GUIDANCE = [
1430
+ "Use expand_symbol when a pruned body is not enough, git_diff_context for changed or",
1431
+ "multi-file work, and search_symbol_signatures to locate definitions repo-wide."
1432
+ ].join("\n");
814
1433
  var AUTO_RULE_SENTINEL = "# auto-generated by token-shrink";
815
1434
  var CURSOR_RULE_PATH = RULE_TARGET_PATH.cursor;
816
1435
  var AUTO_RULE_PATH = CURSOR_RULE_PATH;
@@ -831,6 +1450,9 @@ Before working on a file in this repo, call the \`get_compressed_code_context\`
831
1450
  that file's path, and use the returned Ring 0 + Ring 1 payload as the context for that file
832
1451
  and its direct imports.
833
1452
 
1453
+ ${WORKFLOW_GUIDANCE}
1454
+
1455
+ ${RULE_VERSION_MARKER}
834
1456
  ${AUTO_RULE_SENTINEL}
835
1457
  `
836
1458
  },
@@ -845,6 +1467,9 @@ Before working on a file in this repo, call the \`get_compressed_code_context\`
845
1467
  that file's path, and use the returned Ring 0 + Ring 1 payload as the context for that file
846
1468
  and its direct imports.
847
1469
 
1470
+ ${WORKFLOW_GUIDANCE}
1471
+
1472
+ ${RULE_VERSION_MARKER}
848
1473
  ${CLAUDE_RULE_SENTINEL}
849
1474
  `
850
1475
  },
@@ -859,23 +1484,32 @@ Before working on a file in this repo, call the \`get_compressed_code_context\`
859
1484
  that file's path, and use the returned Ring 0 + Ring 1 payload as the context for that file
860
1485
  and its direct imports.
861
1486
 
1487
+ ${WORKFLOW_GUIDANCE}
1488
+
1489
+ ${RULE_VERSION_MARKER}
862
1490
  ${CLINE_RULE_SENTINEL}
863
1491
  `
864
1492
  }
865
1493
  };
866
1494
  function createAutoRule(root, target) {
867
1495
  const spec = ruleTargets[target];
868
- const rulePath = import_node_path7.default.join(root, spec.relPath);
1496
+ const rulePath = import_node_path10.default.join(root, spec.relPath);
1497
+ const autoWorkflow = loadConfig(root).autoWorkflow !== false;
1498
+ const body = autoWorkflow ? spec.body : spec.body.replace(WORKFLOW_GUIDANCE, LIGHT_GUIDANCE);
869
1499
  try {
870
- if (import_node_fs5.default.existsSync(rulePath)) {
871
- const existing = import_node_fs5.default.readFileSync(rulePath, "utf8");
1500
+ if (import_node_fs8.default.existsSync(rulePath)) {
1501
+ const existing = import_node_fs8.default.readFileSync(rulePath, "utf8");
872
1502
  if (existing.includes(spec.sentinel)) {
873
- return { created: false, skipped: "exists", filePath: rulePath };
1503
+ if (existing.includes(RULE_VERSION_MARKER)) {
1504
+ return { created: false, skipped: "exists", filePath: rulePath };
1505
+ }
1506
+ import_node_fs8.default.writeFileSync(rulePath, body, "utf8");
1507
+ return { created: true, skipped: "none", filePath: rulePath };
874
1508
  }
875
1509
  return { created: false, skipped: "user", filePath: rulePath };
876
1510
  }
877
- import_node_fs5.default.mkdirSync(import_node_path7.default.dirname(rulePath), { recursive: true });
878
- import_node_fs5.default.writeFileSync(rulePath, spec.body, "utf8");
1511
+ import_node_fs8.default.mkdirSync(import_node_path10.default.dirname(rulePath), { recursive: true });
1512
+ import_node_fs8.default.writeFileSync(rulePath, body, "utf8");
879
1513
  return { created: true, skipped: "none", filePath: rulePath };
880
1514
  } catch (err) {
881
1515
  process.stderr.write(
@@ -894,27 +1528,154 @@ function resolveTargets(ruleTarget) {
894
1528
  if (list.includes("all")) return ["cursor", "claude", "cline"];
895
1529
  return list;
896
1530
  }
1531
+ function isWithin(parent, child) {
1532
+ const rel2 = import_node_path10.default.relative(import_node_path10.default.resolve(parent), import_node_path10.default.resolve(child));
1533
+ return rel2 === "" || !rel2.startsWith("..") && !import_node_path10.default.isAbsolute(rel2);
1534
+ }
1535
+ function resolveActiveFiles(single, many) {
1536
+ if (many && many.length > 0) {
1537
+ if (single) {
1538
+ throw new Error("Pass either `activeFilePath` or `activeFiles`, not both.");
1539
+ }
1540
+ return many;
1541
+ }
1542
+ if (single) return [single];
1543
+ throw new Error("`activeFilePath` or `activeFiles` is required");
1544
+ }
897
1545
  async function startMcpServer(opts = {}) {
898
- const root = import_node_path7.default.resolve(opts.root ?? process.env.ROOT ?? process.cwd());
899
1546
  const log = (msg) => {
900
1547
  if (!opts.silent) process.stderr.write(`[token-shrink] ${msg}
901
1548
  `);
902
1549
  };
903
- const watcher = createWatcher({ root, ignored: opts.ignored });
904
- if (!opts.silent) {
905
- log(`Indexing ${root} in the background\u2026`);
906
- }
907
- if (opts.createRule !== false) {
1550
+ const textReply = (text) => ({
1551
+ content: [{ type: "text", text }]
1552
+ });
1553
+ const explicitRoot = opts.root?.trim() || process.env.ROOT?.trim() || "";
1554
+ const autoMode = explicitRoot === "";
1555
+ let root = "";
1556
+ let watcher = null;
1557
+ let searchIndex = null;
1558
+ let lifecycle = Promise.resolve();
1559
+ let config = { ...EMPTY_CONFIG };
1560
+ let configFsw = null;
1561
+ let configReloadTimer = null;
1562
+ const stopConfigWatch = () => {
1563
+ if (configReloadTimer) {
1564
+ clearTimeout(configReloadTimer);
1565
+ configReloadTimer = null;
1566
+ }
1567
+ if (configFsw) {
1568
+ configFsw.close();
1569
+ configFsw = null;
1570
+ }
1571
+ };
1572
+ const reloadForRoot = async (r) => {
1573
+ const next = loadConfig(r, (m) => log(m));
1574
+ const changed = JSON.stringify(next) !== JSON.stringify(config);
1575
+ config = next;
1576
+ if (!changed || !watcher || root !== r) return;
1577
+ log(".tokenshrinkrc.json changed \u2014 re-indexing with the new rules.");
1578
+ stopConfigWatch();
1579
+ const old = watcher;
1580
+ watcher = null;
1581
+ root = "";
1582
+ await old.close().catch(() => {
1583
+ });
1584
+ await attachWatcher(r, false);
1585
+ };
1586
+ const startConfigWatch = (r) => {
1587
+ stopConfigWatch();
1588
+ const cfgPath = configPathFor(r);
1589
+ if (!import_node_fs8.default.existsSync(cfgPath)) return;
1590
+ try {
1591
+ configFsw = import_node_fs8.default.watch(cfgPath, () => {
1592
+ if (configReloadTimer) clearTimeout(configReloadTimer);
1593
+ configReloadTimer = setTimeout(() => {
1594
+ void enqueue(() => reloadForRoot(r));
1595
+ }, 200);
1596
+ });
1597
+ } catch {
1598
+ }
1599
+ };
1600
+ const writeRules = (r) => {
1601
+ if (opts.createRule === false) return;
908
1602
  for (const target of resolveTargets(opts.ruleTarget)) {
909
- const res = createAutoRule(root, target);
1603
+ const res = createAutoRule(r, target);
910
1604
  if (res.created) {
911
1605
  log(`Wrote ${target} rule to ${res.filePath}`);
912
1606
  } else if (res.skipped === "user") {
913
1607
  log(`${target} rule exists (user-authored); leaving it untouched.`);
914
1608
  }
915
1609
  }
1610
+ };
1611
+ const attachWatcher = (r, waitForIndex) => {
1612
+ stopConfigWatch();
1613
+ config = loadConfig(r, (m) => log(m));
1614
+ const search = new SymbolSearch();
1615
+ const w = createWatcher({
1616
+ root: r,
1617
+ ignored: opts.ignored,
1618
+ config,
1619
+ onIndexed: (abs, entry) => {
1620
+ if (entry.symbols && entry.symbols.length > 0) search.setFile(abs, entry.symbols);
1621
+ else search.removeFile(abs);
1622
+ },
1623
+ onRemoved: (abs) => search.removeFile(abs)
1624
+ });
1625
+ searchIndex = search;
1626
+ root = r;
1627
+ watcher = w;
1628
+ writeRules(r);
1629
+ log(`Indexing ${r} in the background\u2026`);
1630
+ const indexed = w.indexAll().then((n) => log(`Indexed ${n} files.`)).catch((err) => log(`Indexing ${r} failed: ${err?.message ?? err}`));
1631
+ startConfigWatch(r);
1632
+ return waitForIndex ? indexed : Promise.resolve();
1633
+ };
1634
+ const enqueue = (fn) => {
1635
+ const run = lifecycle.then(fn);
1636
+ lifecycle = run.then(
1637
+ () => {
1638
+ },
1639
+ () => {
1640
+ }
1641
+ );
1642
+ return run;
1643
+ };
1644
+ if (explicitRoot) {
1645
+ void enqueue(() => attachWatcher(import_node_path10.default.resolve(explicitRoot), false));
1646
+ } else {
1647
+ const fromCwd = detectProjectRoot(process.cwd());
1648
+ if (fromCwd) {
1649
+ log(`Auto-detected project root ${fromCwd} (from cwd). Pass --root to pin it.`);
1650
+ void enqueue(() => attachWatcher(fromCwd, false));
1651
+ } else {
1652
+ log(
1653
+ "No --root and no project markers around the current directory \u2014 will auto-detect the project from the first tool call."
1654
+ );
1655
+ }
916
1656
  }
917
- void watcher.indexAll().then((n) => log(`Indexed ${n} files.`));
1657
+ const ensureReadyFor = (activeFilePath) => enqueue(async () => {
1658
+ const abs = import_node_path10.default.resolve(activeFilePath);
1659
+ if (watcher && root) {
1660
+ if (!autoMode || isWithin(root, abs)) return;
1661
+ const next = detectProjectRoot(abs);
1662
+ if (!next || next === root) return;
1663
+ log(`Active file is in a different project (${next}); re-indexing (was ${root}).`);
1664
+ await watcher.close().catch(() => {
1665
+ });
1666
+ watcher = null;
1667
+ root = "";
1668
+ }
1669
+ if (root) return;
1670
+ const detected = detectProjectRoot(abs);
1671
+ const target = detected ?? process.cwd();
1672
+ if (detected) {
1673
+ log(`Auto-detected project root ${target} from ${abs}.`);
1674
+ } else {
1675
+ log(`No project markers around ${abs}; falling back to ${target}.`);
1676
+ }
1677
+ await attachWatcher(target, true);
1678
+ });
918
1679
  const server = new import_mcp.McpServer(
919
1680
  { name: "token-shrink", version: "2.0.0" },
920
1681
  { capabilities: { tools: {} } }
@@ -923,18 +1684,28 @@ async function startMcpServer(opts = {}) {
923
1684
  "get_compressed_code_context",
924
1685
  {
925
1686
  title: "Get Compressed Code Context",
926
- description: "Returns a compressed, framework-aware AST context payload for a file: the active file\u2019s full source (Ring 0) plus pruned skeletons of its direct imports (Ring 1). Implementation bodies are removed but type signatures, interfaces, and module exports are preserved for ~80-90% token reduction.",
1687
+ description: "Returns a compressed, framework-aware AST context payload for one or more files: each active file\u2019s full source (Ring 0) plus pruned skeletons of their direct imports (Ring 1). Implementation bodies are removed but type signatures, interfaces, and module exports are preserved for ~80-90% token reduction. Use `activeFiles` to pin multiple files as Ring 0 and `maxTokens` to cap the payload with a relevance-ranked Ring 1.",
927
1688
  inputSchema: {
928
- activeFilePath: import_zod.z.string().describe("Path to the file the agent is working on"),
1689
+ activeFilePath: import_zod.z.string().optional().describe("Path to the file the agent is working on (or use `activeFiles`)"),
1690
+ activeFiles: import_zod.z.array(import_zod.z.string()).optional().describe("Multiple files to keep as Ring 0 (full text); mutually exclusive with `activeFilePath`"),
929
1691
  maxSkeletons: import_zod.z.number().int().min(1).max(200).optional().describe("Cap on number of dependency skeletons to include"),
1692
+ maxTokens: import_zod.z.number().int().positive().optional().describe("Hard token budget for the whole payload; Ring 1 is relevance-ranked and packed to fit"),
930
1693
  includeStats: import_zod.z.boolean().optional().describe("Append approximate token-count stats")
931
1694
  }
932
1695
  },
933
- async ({ activeFilePath, maxSkeletons, includeStats }) => {
934
- const result = assemble(activeFilePath, watcher.cache.entries, {
1696
+ async ({ activeFilePath, activeFiles, maxSkeletons, maxTokens, includeStats }) => {
1697
+ const paths = resolveActiveFiles(activeFilePath, activeFiles);
1698
+ for (const p of paths) await ensureReadyFor(p);
1699
+ if (!watcher) {
1700
+ throw new Error("token-shrink watcher failed to start");
1701
+ }
1702
+ const result = assembleMany(paths, watcher.cache.entries, {
935
1703
  maxSkeletons,
1704
+ maxTokens,
936
1705
  includeStats
937
1706
  });
1707
+ const ts = result.tokenStats;
1708
+ const budgetNote = ts.budget !== void 0 ? ` budget=${ts.budget} trimmed=${ts.trimmed}` : "";
938
1709
  return {
939
1710
  content: [
940
1711
  {
@@ -943,21 +1714,270 @@ async function startMcpServer(opts = {}) {
943
1714
  },
944
1715
  {
945
1716
  type: "text",
946
- text: `[stats] active=${result.activeFilePath} dependencies=${result.included.length} unresolved=${result.unresolved.length}`
1717
+ text: `[stats] files=${result.activeFilePaths.length} dependencies=${result.included.length} unresolved=${result.unresolved.length} tokens=${ts.totalTokens}${budgetNote}`
947
1718
  }
948
1719
  ]
949
1720
  };
950
1721
  }
951
1722
  );
1723
+ server.registerTool(
1724
+ "expand_symbol",
1725
+ {
1726
+ title: "Expand Symbol",
1727
+ description: "Returns the full, un-pruned source of a named definition (function, method, class, interface, enum, arrow/const function, \u2026) from one file. Use it when a Ring-1 skeleton is not enough \u2014 e.g. you need the exact algorithm inside a pruned body before writing or changing code.",
1728
+ inputSchema: {
1729
+ filePath: import_zod.z.string().describe("Path to the file containing the symbol"),
1730
+ symbolName: import_zod.z.string().describe("Name of the definition to expand (exact name preferred; falls back to case-insensitive/substring)"),
1731
+ maxMatches: import_zod.z.number().int().min(1).max(20).optional().describe("Maximum matching definitions to return (default 5)")
1732
+ }
1733
+ },
1734
+ async ({ filePath, symbolName, maxMatches }) => {
1735
+ await ensureReadyFor(filePath);
1736
+ const abs = import_node_path10.default.resolve(filePath);
1737
+ let source;
1738
+ try {
1739
+ source = import_node_fs8.default.readFileSync(abs, "utf8");
1740
+ } catch {
1741
+ return textReply(`File not found or unreadable: ${filePath}`);
1742
+ }
1743
+ const spec = languageForFile(abs);
1744
+ if (!spec) {
1745
+ return textReply(`No token-shrink grammar for file type: ${filePath}`);
1746
+ }
1747
+ const { symbols } = await analyze(abs, source, { skipPrune: true });
1748
+ if (symbols.length === 0) {
1749
+ return textReply(
1750
+ `No parseable definitions found in ${filePath} \u2014 the grammar may be unavailable (first run offline) or the language exposes no name-carrying definitions yet.`
1751
+ );
1752
+ }
1753
+ const matches = matchSymbols(symbols, symbolName).slice(0, maxMatches ?? 5);
1754
+ if (matches.length === 0) {
1755
+ const names = [...new Set(symbols.map((s) => s.name))].slice(0, 12).join(", ");
1756
+ return textReply(
1757
+ `No symbol named "${symbolName}" in ${filePath}.` + (names ? ` Other definitions there: ${names}.` : "")
1758
+ );
1759
+ }
1760
+ const fence = import_node_path10.default.extname(abs).replace(/^\./, "") || "text";
1761
+ const parts = matches.map((m) => {
1762
+ const body = source.slice(m.start, m.end).trim();
1763
+ return `### ${m.name} (${m.kind}) \u2014 ${abs}:${m.line}
1764
+
1765
+ \`\`\`` + fence + "\n" + body + "\n```\n";
1766
+ });
1767
+ const disambig = matches.length > 1 ? `
1768
+ _Multiple definitions matched (${matches.length}); each is shown above._` : "";
1769
+ return textReply(parts.join("\n") + disambig);
1770
+ }
1771
+ );
1772
+ server.registerTool(
1773
+ "git_diff_context",
1774
+ {
1775
+ title: "Git Diff Context",
1776
+ description: 'Builds an impact-analysis payload from git changes: every changed file is kept as Ring 0 (full code), their imports AND the files that import them (file-level callers) are attached as pruned Ring-1 skeletons. Use for PR reviews, regression fixes and multi-file tasks where no single "active file" exists.',
1777
+ inputSchema: {
1778
+ scope: import_zod.z.enum(["worktree", "staged", "branch"]).optional().describe("Diff scope: 'worktree' (default, staged+unstaged), 'staged', or 'branch' (base...head)"),
1779
+ base: import_zod.z.string().optional().describe("Base ref for scope=branch (default HEAD~1)"),
1780
+ head: import_zod.z.string().optional().describe("Head ref for scope=branch (default HEAD)"),
1781
+ includeUntracked: import_zod.z.boolean().optional().describe("Include untracked files (worktree scope only)"),
1782
+ maxFiles: import_zod.z.number().int().min(1).max(200).optional().describe("Maximum changed files to include (default 30)"),
1783
+ maxImporters: import_zod.z.number().int().min(0).max(100).optional().describe("Maximum importer skeletons to append (default 20)"),
1784
+ maxSkeletons: import_zod.z.number().int().min(1).max(200).optional().describe("Cap on dependency skeletons per payload"),
1785
+ maxTokens: import_zod.z.number().int().positive().optional().describe("Hard token budget for the whole payload"),
1786
+ includeStats: import_zod.z.boolean().optional().describe("Append approximate token-count stats")
1787
+ }
1788
+ },
1789
+ async ({
1790
+ scope,
1791
+ base,
1792
+ head,
1793
+ includeUntracked,
1794
+ maxFiles,
1795
+ maxImporters,
1796
+ maxSkeletons,
1797
+ maxTokens,
1798
+ includeStats
1799
+ }) => {
1800
+ await ensureReadyFor(process.cwd());
1801
+ if (!watcher || !root) {
1802
+ throw new Error("token-shrink watcher failed to start");
1803
+ }
1804
+ const repoRoot = root;
1805
+ const changedResult = await gitChangedFiles(repoRoot, {
1806
+ scope: scope ?? "worktree",
1807
+ base,
1808
+ head,
1809
+ includeUntracked
1810
+ });
1811
+ if (changedResult.error) {
1812
+ return textReply(`git error: ${changedResult.error}`);
1813
+ }
1814
+ if (changedResult.files.length === 0) {
1815
+ return textReply("No changed files (clean worktree, or empty diff for the requested scope).");
1816
+ }
1817
+ const fileCap = maxFiles ?? 30;
1818
+ const changed = changedResult.files.slice(0, fileCap);
1819
+ const filesTruncated = changedResult.files.length > changed.length;
1820
+ for (const abs of changed) {
1821
+ if (!watcher.cache.entries.has(abs)) {
1822
+ try {
1823
+ await watcher.index(abs);
1824
+ } catch {
1825
+ }
1826
+ }
1827
+ }
1828
+ const assembled = assembleMany(changed, watcher.cache.entries, {
1829
+ maxSkeletons,
1830
+ maxTokens,
1831
+ includeStats
1832
+ });
1833
+ const changedSet = new Set(changed);
1834
+ const importerOf = /* @__PURE__ */ new Map();
1835
+ for (const [fileAbs, entry] of watcher.cache.entries) {
1836
+ for (const imp of entry.imports) {
1837
+ if (!changedSet.has(imp)) continue;
1838
+ const list = importerOf.get(imp) ?? [];
1839
+ list.push(fileAbs);
1840
+ importerOf.set(imp, list);
1841
+ }
1842
+ }
1843
+ const importerList = [...new Set([...importerOf.values()].flat())].filter(
1844
+ (f) => !changedSet.has(f)
1845
+ );
1846
+ const importerCap = maxImporters ?? 20;
1847
+ const importers = importerList.slice(0, importerCap);
1848
+ const importersTruncated = importerList.length > importers.length;
1849
+ const relLabel = (abs) => {
1850
+ const rel2 = import_node_path10.default.relative(repoRoot, abs);
1851
+ return rel2 && !rel2.startsWith("..") ? rel2 : abs;
1852
+ };
1853
+ const fence = (abs) => import_node_path10.default.extname(abs).replace(/^\./, "") || "text";
1854
+ const parts = [];
1855
+ parts.push(
1856
+ `# Git Impact Context (${changed.length} changed file${changed.length === 1 ? "" : "s"})`,
1857
+ ""
1858
+ );
1859
+ parts.push(`- changed files: ${changed.map(relLabel).join(", ")}`);
1860
+ if (importers.length > 0) {
1861
+ parts.push(`- importing files (callers): ${importers.map(relLabel).join(", ")}`);
1862
+ }
1863
+ parts.push("", "---", "");
1864
+ parts.push("## Changed files \u2014 full code", "");
1865
+ for (const abs of changed) {
1866
+ parts.push(`### \`${relLabel(abs)}\``, "");
1867
+ let source = "";
1868
+ try {
1869
+ source = import_node_fs8.default.readFileSync(abs, "utf8");
1870
+ } catch {
1871
+ }
1872
+ parts.push(`\`\`\`${fence(abs)}`, source.trim() || "(unreadable file)", "```", "");
1873
+ }
1874
+ parts.push(`## Ring 1 \u2014 Pruned dependencies (${assembled.included.length})`, "");
1875
+ parts.push("", "Implementation bodies removed; type signatures, interfaces and exports retained.", "");
1876
+ if (assembled.included.length === 0) {
1877
+ parts.push("_No local dependency skeletons available._", "");
1878
+ }
1879
+ for (const inc of assembled.included) {
1880
+ const entry = watcher.cache.entries.get(inc.filePath);
1881
+ const label = relLabel(inc.filePath);
1882
+ parts.push(`### \`${label}\``, "");
1883
+ if (entry) {
1884
+ parts.push(`\`\`\`${fence(inc.filePath)}`, entry.skeleton.trim(), "```", "");
1885
+ } else {
1886
+ parts.push("_Unindexed file._", "");
1887
+ }
1888
+ parts.push("");
1889
+ }
1890
+ if (assembled.tokenStats.budget !== void 0 && assembled.tokenStats.trimmed > 0) {
1891
+ parts.push(
1892
+ `_Note: token budget of ${assembled.tokenStats.budget} excluded ${assembled.tokenStats.trimmed} lower-priority dependencies._`,
1893
+ ""
1894
+ );
1895
+ }
1896
+ if (importers.length > 0) {
1897
+ parts.push(`## Ring 2 \u2014 Files importing the diff (${importers.length})`, "");
1898
+ parts.push("", "Pruned skeletons of modules that call into the changed files.", "");
1899
+ for (const abs of importers) {
1900
+ const entry = watcher.cache.entries.get(abs);
1901
+ parts.push(`### \`${relLabel(abs)}\``, "");
1902
+ if (entry) {
1903
+ parts.push(`\`\`\`${fence(abs)}`, entry.skeleton.trim(), "```", "");
1904
+ } else {
1905
+ parts.push("_Unindexed file._", "");
1906
+ }
1907
+ parts.push("");
1908
+ }
1909
+ if (importersTruncated) {
1910
+ parts.push(
1911
+ `_\u2026and ${importerList.length - importers.length} more importing files (raise maxImporters)._`,
1912
+ ""
1913
+ );
1914
+ }
1915
+ }
1916
+ if (filesTruncated) {
1917
+ parts.push(
1918
+ `_Note: capped to ${fileCap} changed files (${changedResult.files.length} total); raise maxFiles to include more._`,
1919
+ ""
1920
+ );
1921
+ }
1922
+ if (assembled.unresolved.length > 0) {
1923
+ parts.push("## Unresolved imports", "");
1924
+ for (const u of assembled.unresolved) parts.push(`- \`${u}\``);
1925
+ parts.push("");
1926
+ }
1927
+ const ts = assembled.tokenStats;
1928
+ const budgetNote = ts.budget !== void 0 ? ` budget=${ts.budget} trimmed=${ts.trimmed}` : "";
1929
+ const stats = `[git-stats] files=${changed.length} ring1=${assembled.included.length} importers=${importers.length} unresolved=${assembled.unresolved.length} tokens=${ts.totalTokens}${budgetNote}`;
1930
+ return {
1931
+ content: [
1932
+ { type: "text", text: parts.join("\n") },
1933
+ { type: "text", text: stats }
1934
+ ]
1935
+ };
1936
+ }
1937
+ );
1938
+ server.registerTool(
1939
+ "search_symbol_signatures",
1940
+ {
1941
+ title: "Search Symbol Signatures",
1942
+ description: "Fast, repo-wide lookup of named definitions (functions, methods, classes, interfaces, enums, \u2026). Returns compact `file:line \u2014 signature` lines instead of raw search hits. Use it to discover where a symbol lives and what its exact signature is before reading or editing code.",
1943
+ inputSchema: {
1944
+ query: import_zod.z.string().describe("Search text (matched against symbol names; falls back to signatures)"),
1945
+ maxResults: import_zod.z.number().int().min(1).max(100).optional().describe("Maximum results to return (default 10)"),
1946
+ kind: import_zod.z.enum(["function", "method", "arrow", "class", "interface", "enum", "type", "other"]).optional().describe("Only return definitions of this kind")
1947
+ }
1948
+ },
1949
+ async ({ query, maxResults, kind }) => {
1950
+ await ensureReadyFor(process.cwd());
1951
+ if (!searchIndex) {
1952
+ throw new Error("token-shrink symbol index failed to start");
1953
+ }
1954
+ if (searchIndex.size === 0 && watcher && watcher.cache.entries.size > 0) {
1955
+ searchIndex.loadCache(watcher.cache.entries);
1956
+ }
1957
+ const hits = searchIndex.search(query, {
1958
+ maxResults: maxResults ?? 10,
1959
+ kind
1960
+ });
1961
+ if (hits.length === 0) {
1962
+ return textReply(`No symbols match "${query}". Try a different name or kind.`);
1963
+ }
1964
+ const lines = hits.map((h) => `- \`${h.label}\``);
1965
+ return textReply(
1966
+ `${lines.join("\n")}
1967
+
1968
+ _Found ${hits.length} symbol${hits.length === 1 ? "" : "s"} matching "${query}"._`
1969
+ );
1970
+ }
1971
+ );
952
1972
  const transport = new import_stdio.StdioServerTransport();
953
1973
  await server.connect(transport);
954
1974
  log("MCP server connected.");
955
1975
  return server;
956
1976
  }
957
- var argv12 = process.argv[1] ? import_node_path7.default.basename(process.argv[1]) : "";
1977
+ var argv12 = process.argv[1] ? import_node_path10.default.basename(process.argv[1]) : "";
958
1978
  var argv1Real2 = "";
959
1979
  try {
960
- argv1Real2 = process.argv[1] ? import_node_path7.default.basename(import_node_fs5.default.realpathSync(process.argv[1])) : "";
1980
+ argv1Real2 = process.argv[1] ? import_node_path10.default.basename(import_node_fs8.default.realpathSync(process.argv[1])) : "";
961
1981
  } catch {
962
1982
  }
963
1983
  var invokedAsMcp = argv12 === "mcp.js" || argv12 === "mcp.mjs" || argv12 === "mcp.cjs" || argv12 === "mcp.ts" || argv1Real2 === "mcp.js" || argv1Real2 === "mcp.mjs" || argv1Real2 === "mcp.cjs" || argv1Real2 === "mcp.ts";
@@ -994,20 +2014,37 @@ var version = "2.0.0";
994
2014
  CLAUDE_RULE_SENTINEL,
995
2015
  CLINE_RULE_PATH,
996
2016
  CLINE_RULE_SENTINEL,
2017
+ CONFIG_FILE_NAME,
997
2018
  CURSOR_RULE_PATH,
998
2019
  DEFAULT_IGNORED,
2020
+ EMPTY_CONFIG,
2021
+ PROJECT_MANIFEST_FILES,
999
2022
  RULE_TARGET_PATH,
2023
+ SymbolSearch,
2024
+ VCS_MARKER_DIRS,
1000
2025
  allExtensions,
2026
+ analyze,
1001
2027
  approximateTokens,
1002
2028
  assemble,
2029
+ assembleMany,
2030
+ collectSymbols,
2031
+ configPathFor,
1003
2032
  createAutoRule,
1004
2033
  createCursorRule,
1005
2034
  createWatcher,
2035
+ detectProjectRoot,
2036
+ ensureParserInit,
1006
2037
  extractImports,
1007
2038
  extractSpecifiers,
1008
2039
  getGrammar,
2040
+ globToRegExp,
1009
2041
  hashOf,
1010
2042
  languageForFile,
2043
+ loadConfig,
2044
+ loadLanguage,
2045
+ matchSymbols,
2046
+ matchesAny,
2047
+ matchesGlob,
1011
2048
  prune,
1012
2049
  registry,
1013
2050
  resolveImport,