@ajdev0/token-shrink 2.0.1 → 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/README.md +52 -14
- package/dist/chunk-FYCLLG7F.js +847 -0
- package/dist/chunk-FYCLLG7F.js.map +1 -0
- package/dist/{chunk-7H6PGILN.js → chunk-RI64PBQ5.js} +24 -8
- package/dist/chunk-RI64PBQ5.js.map +1 -0
- package/dist/chunk-RLHEIKPR.js +894 -0
- package/dist/chunk-RLHEIKPR.js.map +1 -0
- package/dist/cli-DzY7l7Rr.d.cts +182 -0
- package/dist/cli-DzY7l7Rr.d.ts +182 -0
- package/dist/cli.cjs +396 -118
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.d.cts +2 -1
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +2 -2
- package/dist/index.cjs +1200 -151
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +160 -20
- package/dist/index.d.ts +160 -20
- package/dist/index.js +37 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp.cjs +1132 -136
- package/dist/mcp.cjs.map +1 -1
- package/dist/mcp.d.cts +21 -6
- package/dist/mcp.d.ts +21 -6
- package/dist/mcp.js +6 -2
- package/hooks/claude/README.md +29 -0
- package/hooks/claude/git-impact.mjs +47 -0
- package/hooks/claude/settings.example.json +24 -0
- package/package.json +3 -1
- package/dist/chunk-7H6PGILN.js.map +0 -1
- package/dist/chunk-ERJ3LBJ4.js +0 -198
- package/dist/chunk-ERJ3LBJ4.js.map +0 -1
- package/dist/chunk-HRF3BIOV.js +0 -518
- package/dist/chunk-HRF3BIOV.js.map +0 -1
- package/dist/cli-EpVqinpB.d.cts +0 -96
- package/dist/cli-EpVqinpB.d.ts +0 -96
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
EMPTY_CONFIG,
|
|
4
|
+
analyze,
|
|
5
|
+
assembleMany,
|
|
6
|
+
configPathFor,
|
|
7
|
+
createWatcher,
|
|
8
|
+
loadConfig,
|
|
9
|
+
matchSymbols
|
|
10
|
+
} from "./chunk-FYCLLG7F.js";
|
|
11
|
+
import {
|
|
12
|
+
languageForFile
|
|
13
|
+
} from "./chunk-7SQ6HMWM.js";
|
|
14
|
+
|
|
15
|
+
// src/mcp.ts
|
|
16
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
17
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import fs3 from "fs";
|
|
20
|
+
import path3 from "path";
|
|
21
|
+
|
|
22
|
+
// src/detect.ts
|
|
23
|
+
import fs from "fs";
|
|
24
|
+
import path from "path";
|
|
25
|
+
var VCS_MARKER_DIRS = [".git", ".hg", ".svn"];
|
|
26
|
+
var PROJECT_MANIFEST_FILES = [
|
|
27
|
+
// JavaScript / TypeScript
|
|
28
|
+
"package.json",
|
|
29
|
+
"tsconfig.json",
|
|
30
|
+
// Python
|
|
31
|
+
"pyproject.toml",
|
|
32
|
+
"setup.py",
|
|
33
|
+
"setup.cfg",
|
|
34
|
+
"requirements.txt",
|
|
35
|
+
// Go / Rust / Dart / Swift
|
|
36
|
+
"go.mod",
|
|
37
|
+
"Cargo.toml",
|
|
38
|
+
"pubspec.yaml",
|
|
39
|
+
"Package.swift",
|
|
40
|
+
// Java / Kotlin
|
|
41
|
+
"pom.xml",
|
|
42
|
+
"build.gradle",
|
|
43
|
+
"build.gradle.kts",
|
|
44
|
+
"settings.gradle",
|
|
45
|
+
"settings.gradle.kts",
|
|
46
|
+
// PHP / Ruby / Elixir
|
|
47
|
+
"composer.json",
|
|
48
|
+
"Gemfile",
|
|
49
|
+
"mix.exs"
|
|
50
|
+
];
|
|
51
|
+
var MANIFEST_FILE_SET = new Set(PROJECT_MANIFEST_FILES);
|
|
52
|
+
var MAX_WALK_DEPTH = 64;
|
|
53
|
+
function detectProjectRoot(fromPath) {
|
|
54
|
+
const resolved = path.resolve(fromPath);
|
|
55
|
+
let dir = isDirectory(resolved) ? resolved : path.dirname(resolved);
|
|
56
|
+
let nearestManifest = null;
|
|
57
|
+
for (let depth = 0; depth < MAX_WALK_DEPTH; depth++) {
|
|
58
|
+
const names = readDirNames(dir);
|
|
59
|
+
if (names) {
|
|
60
|
+
if (VCS_MARKER_DIRS.some((vcs) => names.has(vcs))) return dir;
|
|
61
|
+
if (nearestManifest === null && hasManifest(names)) nearestManifest = dir;
|
|
62
|
+
}
|
|
63
|
+
const parent = path.dirname(dir);
|
|
64
|
+
if (parent === dir) break;
|
|
65
|
+
dir = parent;
|
|
66
|
+
}
|
|
67
|
+
return nearestManifest;
|
|
68
|
+
}
|
|
69
|
+
function isDirectory(p) {
|
|
70
|
+
try {
|
|
71
|
+
return fs.statSync(p).isDirectory();
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function readDirNames(dir) {
|
|
77
|
+
try {
|
|
78
|
+
return new Set(fs.readdirSync(dir));
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function hasManifest(names) {
|
|
84
|
+
for (const name of names) {
|
|
85
|
+
if (MANIFEST_FILE_SET.has(name)) return true;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/search/index.ts
|
|
91
|
+
function words(text) {
|
|
92
|
+
const out = /* @__PURE__ */ new Set();
|
|
93
|
+
for (const part of text.split(/[^A-Za-z0-9_$]+/)) {
|
|
94
|
+
if (!part) continue;
|
|
95
|
+
for (const seg of part.split(/(?<=[a-z0-9])(?=[A-Z])/)) {
|
|
96
|
+
const w = seg.toLowerCase();
|
|
97
|
+
if (w) out.add(w);
|
|
98
|
+
}
|
|
99
|
+
for (const seg of part.split("_")) {
|
|
100
|
+
const w = seg.toLowerCase();
|
|
101
|
+
if (w) out.add(w);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return [...out];
|
|
105
|
+
}
|
|
106
|
+
var SymbolSearch = class {
|
|
107
|
+
docs = [];
|
|
108
|
+
byFile = /* @__PURE__ */ new Map();
|
|
109
|
+
inverted = /* @__PURE__ */ new Map();
|
|
110
|
+
get size() {
|
|
111
|
+
return this.docs.length;
|
|
112
|
+
}
|
|
113
|
+
/** Replace the docs for one file (or remove when `symbols` is empty). */
|
|
114
|
+
setFile(filePath, symbols) {
|
|
115
|
+
this.removeFile(filePath);
|
|
116
|
+
if (symbols.length === 0) return;
|
|
117
|
+
const docs = symbols.map((s) => ({ ...s, filePath }));
|
|
118
|
+
const ids = docs.map((d) => {
|
|
119
|
+
const id = this.docs.length;
|
|
120
|
+
this.docs.push(d);
|
|
121
|
+
for (const w of words(`${d.name}`)) {
|
|
122
|
+
const list = this.inverted.get(w) ?? [];
|
|
123
|
+
list.push(id);
|
|
124
|
+
this.inverted.set(w, list);
|
|
125
|
+
}
|
|
126
|
+
return id;
|
|
127
|
+
});
|
|
128
|
+
this.byFile.set(filePath, docs);
|
|
129
|
+
void ids;
|
|
130
|
+
}
|
|
131
|
+
removeFile(filePath) {
|
|
132
|
+
const docs = this.byFile.get(filePath);
|
|
133
|
+
if (!docs) return;
|
|
134
|
+
const removed = new Set(docs);
|
|
135
|
+
this.docs = this.docs.filter((d) => !removed.has(d));
|
|
136
|
+
this.byFile.delete(filePath);
|
|
137
|
+
this.rebuildInverted();
|
|
138
|
+
}
|
|
139
|
+
/** Bulk-load a whole cache (used at attach time and cold lazy builds). */
|
|
140
|
+
loadCache(entries) {
|
|
141
|
+
this.docs = [];
|
|
142
|
+
this.byFile.clear();
|
|
143
|
+
for (const [abs, entry] of entries) {
|
|
144
|
+
if (!entry.symbols || entry.symbols.length === 0) continue;
|
|
145
|
+
const docs = entry.symbols.map((s) => ({ ...s, filePath: abs }));
|
|
146
|
+
this.byFile.set(abs, docs);
|
|
147
|
+
this.docs.push(...docs);
|
|
148
|
+
}
|
|
149
|
+
this.rebuildInverted();
|
|
150
|
+
}
|
|
151
|
+
/** Ranked symbol hits for `query`. */
|
|
152
|
+
search(query, opts = {}) {
|
|
153
|
+
const maxResults = opts.maxResults ?? 10;
|
|
154
|
+
const q = query.trim().toLowerCase();
|
|
155
|
+
if (!q) return [];
|
|
156
|
+
const queryWords = words(q);
|
|
157
|
+
let candidates;
|
|
158
|
+
if (queryWords.length > 0) {
|
|
159
|
+
const ids = /* @__PURE__ */ new Set();
|
|
160
|
+
for (const w of queryWords) {
|
|
161
|
+
const post = this.inverted.get(w);
|
|
162
|
+
if (post) for (const id of post) ids.add(id);
|
|
163
|
+
}
|
|
164
|
+
candidates = [...ids].map((id) => this.docs[id]);
|
|
165
|
+
} else {
|
|
166
|
+
candidates = this.docs;
|
|
167
|
+
}
|
|
168
|
+
if (opts.kind) candidates = candidates.filter((d) => d.kind === opts.kind);
|
|
169
|
+
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);
|
|
170
|
+
return scored.map((s) => ({ ...s, label: this.label(s) }));
|
|
171
|
+
}
|
|
172
|
+
score(d, q, queryWords) {
|
|
173
|
+
const name = d.name.toLowerCase();
|
|
174
|
+
let score = 0;
|
|
175
|
+
if (name === q) score += 100;
|
|
176
|
+
if (name.startsWith(q)) score += 60;
|
|
177
|
+
if (name.includes(q)) score += 30;
|
|
178
|
+
if ((d.signature ?? "").toLowerCase().includes(q)) score += 8;
|
|
179
|
+
for (const w of queryWords) {
|
|
180
|
+
if (name.includes(w)) score += 4;
|
|
181
|
+
if ((d.signature ?? "").toLowerCase().includes(w)) score += 1;
|
|
182
|
+
}
|
|
183
|
+
if (score <= 0) return null;
|
|
184
|
+
return { ...d, score };
|
|
185
|
+
}
|
|
186
|
+
label(d) {
|
|
187
|
+
const sig = d.signature && d.signature.length > 0 ? ` \u2014 ${d.signature}` : "";
|
|
188
|
+
return `${d.filePath}:${d.line}${sig}`;
|
|
189
|
+
}
|
|
190
|
+
rebuildInverted() {
|
|
191
|
+
this.inverted.clear();
|
|
192
|
+
this.docs.forEach((d, id) => {
|
|
193
|
+
for (const w of words(d.name)) {
|
|
194
|
+
const list = this.inverted.get(w) ?? [];
|
|
195
|
+
list.push(id);
|
|
196
|
+
this.inverted.set(w, list);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// src/git.ts
|
|
203
|
+
import { spawn } from "child_process";
|
|
204
|
+
import path2 from "path";
|
|
205
|
+
import fs2 from "fs";
|
|
206
|
+
function runGit(root, args) {
|
|
207
|
+
return new Promise((resolve) => {
|
|
208
|
+
const child = spawn("git", ["--no-pager", ...args], {
|
|
209
|
+
cwd: root,
|
|
210
|
+
env: { ...process.env, LC_ALL: "C" },
|
|
211
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
212
|
+
});
|
|
213
|
+
let out = "";
|
|
214
|
+
let err = "";
|
|
215
|
+
child.stdout.on("data", (d) => {
|
|
216
|
+
out += d;
|
|
217
|
+
});
|
|
218
|
+
child.stderr.on("data", (d) => {
|
|
219
|
+
err += d;
|
|
220
|
+
});
|
|
221
|
+
child.on("error", (e) => resolve({ ok: false, out: "", err: e.message }));
|
|
222
|
+
child.on("close", (code) => resolve({ ok: code === 0, out, err }));
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
function parseNames(out) {
|
|
226
|
+
return out.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
227
|
+
}
|
|
228
|
+
function isFatal(err) {
|
|
229
|
+
if (!err) return void 0;
|
|
230
|
+
const e = err.trim();
|
|
231
|
+
if (/not a git repository|fatal:/i.test(e)) return e;
|
|
232
|
+
return void 0;
|
|
233
|
+
}
|
|
234
|
+
async function gitChangedFiles(root, opts) {
|
|
235
|
+
const scope = opts.scope ?? "worktree";
|
|
236
|
+
const filter = "--diff-filter=ACMRT";
|
|
237
|
+
let relative = [];
|
|
238
|
+
if (scope === "staged") {
|
|
239
|
+
const r = await runGit(root, ["diff", "--cached", "--name-only", filter]);
|
|
240
|
+
const fatal = isFatal(r.err);
|
|
241
|
+
if (!r.ok) return { files: [], ...fatal ? { error: fatal } : {} };
|
|
242
|
+
relative = parseNames(r.out);
|
|
243
|
+
} else if (scope === "branch") {
|
|
244
|
+
const base = opts.base ?? "HEAD~1";
|
|
245
|
+
const head = opts.head ?? "HEAD";
|
|
246
|
+
const r = await runGit(root, ["diff", "--name-only", `${base}...${head}`, filter]);
|
|
247
|
+
const fatal = isFatal(r.err);
|
|
248
|
+
if (!r.ok) return { files: [], ...fatal ? { error: fatal } : {} };
|
|
249
|
+
relative = parseNames(r.out);
|
|
250
|
+
} else {
|
|
251
|
+
const [unstaged, staged] = await Promise.all([
|
|
252
|
+
runGit(root, ["diff", "--name-only", filter]),
|
|
253
|
+
runGit(root, ["diff", "--cached", "--name-only", filter])
|
|
254
|
+
]);
|
|
255
|
+
const fatal = isFatal(unstaged.err) ?? isFatal(staged.err);
|
|
256
|
+
if (!unstaged.ok && !staged.ok) {
|
|
257
|
+
return { files: [], ...fatal ? { error: fatal } : {} };
|
|
258
|
+
}
|
|
259
|
+
const merged = /* @__PURE__ */ new Set([...parseNames(unstaged.out), ...parseNames(staged.out)]);
|
|
260
|
+
if (opts.includeUntracked) {
|
|
261
|
+
const ut = await runGit(root, ["ls-files", "--others", "--exclude-standard"]);
|
|
262
|
+
for (const f of parseNames(ut.out)) merged.add(f);
|
|
263
|
+
}
|
|
264
|
+
relative = [...merged];
|
|
265
|
+
}
|
|
266
|
+
const files = relative.map((rel) => path2.resolve(root, rel)).filter((abs) => {
|
|
267
|
+
try {
|
|
268
|
+
return fs2.statSync(abs).isFile();
|
|
269
|
+
} catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
return { files };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/mcp.ts
|
|
277
|
+
var RULE_TARGET_PATH = {
|
|
278
|
+
cursor: ".cursor/rules/token-shrink.mdc",
|
|
279
|
+
claude: ".claude/rules/token-shrink.md",
|
|
280
|
+
cline: ".clinerules/token-shrink.md"
|
|
281
|
+
};
|
|
282
|
+
var RULE_VERSION = 3;
|
|
283
|
+
var RULE_VERSION_MARKER = `# token-shrink rule v${RULE_VERSION}`;
|
|
284
|
+
var WORKFLOW_GUIDANCE = [
|
|
285
|
+
"When a pruned skeleton is not enough to write or change code correctly, expand the exact",
|
|
286
|
+
"definition with expand_symbol.",
|
|
287
|
+
"",
|
|
288
|
+
"Call search_symbol_signatures automatically for any identifier you reference that the",
|
|
289
|
+
"context does not already define, so you always work from exact signatures.",
|
|
290
|
+
"",
|
|
291
|
+
"Before multi-file edits, reviews, or work on code with uncommitted or staged changes, call",
|
|
292
|
+
"git_diff_context and use its impact payload (changed files and their callers) as context.",
|
|
293
|
+
"",
|
|
294
|
+
"Pass activeFiles to get_compressed_code_context when a task spans several files, and",
|
|
295
|
+
"maxTokens whenever the payload must fit a token budget."
|
|
296
|
+
].join("\n");
|
|
297
|
+
var LIGHT_GUIDANCE = [
|
|
298
|
+
"Use expand_symbol when a pruned body is not enough, git_diff_context for changed or",
|
|
299
|
+
"multi-file work, and search_symbol_signatures to locate definitions repo-wide."
|
|
300
|
+
].join("\n");
|
|
301
|
+
var AUTO_RULE_SENTINEL = "# auto-generated by token-shrink";
|
|
302
|
+
var CURSOR_RULE_PATH = RULE_TARGET_PATH.cursor;
|
|
303
|
+
var AUTO_RULE_PATH = CURSOR_RULE_PATH;
|
|
304
|
+
var CLAUDE_RULE_PATH = RULE_TARGET_PATH.claude;
|
|
305
|
+
var CLAUDE_RULE_SENTINEL = "# auto-generated by token-shrink (claude)";
|
|
306
|
+
var CLINE_RULE_PATH = RULE_TARGET_PATH.cline;
|
|
307
|
+
var CLINE_RULE_SENTINEL = "# auto-generated by token-shrink (cline)";
|
|
308
|
+
var ruleTargets = {
|
|
309
|
+
cursor: {
|
|
310
|
+
relPath: CURSOR_RULE_PATH,
|
|
311
|
+
sentinel: AUTO_RULE_SENTINEL,
|
|
312
|
+
body: `---
|
|
313
|
+
description: Compress dependency context with token-shrink on every task
|
|
314
|
+
globs: **/*.{ts,tsx,js,jsx,py,go,rs,dart,swift,java,kt,c,cpp,h,hpp,php}
|
|
315
|
+
alwaysApply: true
|
|
316
|
+
---
|
|
317
|
+
Before working on a file in this repo, call the \`get_compressed_code_context\` MCP tool with
|
|
318
|
+
that file's path, and use the returned Ring 0 + Ring 1 payload as the context for that file
|
|
319
|
+
and its direct imports.
|
|
320
|
+
|
|
321
|
+
${WORKFLOW_GUIDANCE}
|
|
322
|
+
|
|
323
|
+
${RULE_VERSION_MARKER}
|
|
324
|
+
${AUTO_RULE_SENTINEL}
|
|
325
|
+
`
|
|
326
|
+
},
|
|
327
|
+
claude: {
|
|
328
|
+
relPath: CLAUDE_RULE_PATH,
|
|
329
|
+
sentinel: CLAUDE_RULE_SENTINEL,
|
|
330
|
+
body: `---
|
|
331
|
+
description: Compress dependency context with token-shrink on every task
|
|
332
|
+
paths: ["**/*.{ts,tsx,js,jsx,py,go,rs,dart,swift,java,kt,c,cpp,h,hpp,php}"]
|
|
333
|
+
---
|
|
334
|
+
Before working on a file in this repo, call the \`get_compressed_code_context\` MCP tool with
|
|
335
|
+
that file's path, and use the returned Ring 0 + Ring 1 payload as the context for that file
|
|
336
|
+
and its direct imports.
|
|
337
|
+
|
|
338
|
+
${WORKFLOW_GUIDANCE}
|
|
339
|
+
|
|
340
|
+
${RULE_VERSION_MARKER}
|
|
341
|
+
${CLAUDE_RULE_SENTINEL}
|
|
342
|
+
`
|
|
343
|
+
},
|
|
344
|
+
cline: {
|
|
345
|
+
relPath: CLINE_RULE_PATH,
|
|
346
|
+
sentinel: CLINE_RULE_SENTINEL,
|
|
347
|
+
// No frontmatter -> always-on rule (Cline treats unconditional .clinerules/*.md
|
|
348
|
+
// files as universal rules, applied to every task regardless of active file).
|
|
349
|
+
body: `# token-shrink
|
|
350
|
+
|
|
351
|
+
Before working on a file in this repo, call the \`get_compressed_code_context\` MCP tool with
|
|
352
|
+
that file's path, and use the returned Ring 0 + Ring 1 payload as the context for that file
|
|
353
|
+
and its direct imports.
|
|
354
|
+
|
|
355
|
+
${WORKFLOW_GUIDANCE}
|
|
356
|
+
|
|
357
|
+
${RULE_VERSION_MARKER}
|
|
358
|
+
${CLINE_RULE_SENTINEL}
|
|
359
|
+
`
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
function createAutoRule(root, target) {
|
|
363
|
+
const spec = ruleTargets[target];
|
|
364
|
+
const rulePath = path3.join(root, spec.relPath);
|
|
365
|
+
const autoWorkflow = loadConfig(root).autoWorkflow !== false;
|
|
366
|
+
const body = autoWorkflow ? spec.body : spec.body.replace(WORKFLOW_GUIDANCE, LIGHT_GUIDANCE);
|
|
367
|
+
try {
|
|
368
|
+
if (fs3.existsSync(rulePath)) {
|
|
369
|
+
const existing = fs3.readFileSync(rulePath, "utf8");
|
|
370
|
+
if (existing.includes(spec.sentinel)) {
|
|
371
|
+
if (existing.includes(RULE_VERSION_MARKER)) {
|
|
372
|
+
return { created: false, skipped: "exists", filePath: rulePath };
|
|
373
|
+
}
|
|
374
|
+
fs3.writeFileSync(rulePath, body, "utf8");
|
|
375
|
+
return { created: true, skipped: "none", filePath: rulePath };
|
|
376
|
+
}
|
|
377
|
+
return { created: false, skipped: "user", filePath: rulePath };
|
|
378
|
+
}
|
|
379
|
+
fs3.mkdirSync(path3.dirname(rulePath), { recursive: true });
|
|
380
|
+
fs3.writeFileSync(rulePath, body, "utf8");
|
|
381
|
+
return { created: true, skipped: "none", filePath: rulePath };
|
|
382
|
+
} catch (err) {
|
|
383
|
+
process.stderr.write(
|
|
384
|
+
`[token-shrink] Failed to write rule for "${target}": ${err.message}
|
|
385
|
+
`
|
|
386
|
+
);
|
|
387
|
+
return { created: false, skipped: "none", filePath: rulePath };
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function createCursorRule(root) {
|
|
391
|
+
return createAutoRule(root, "cursor");
|
|
392
|
+
}
|
|
393
|
+
function resolveTargets(ruleTarget) {
|
|
394
|
+
if (!ruleTarget) return ["cursor", "claude", "cline"];
|
|
395
|
+
const list = Array.isArray(ruleTarget) ? ruleTarget : [ruleTarget];
|
|
396
|
+
if (list.includes("all")) return ["cursor", "claude", "cline"];
|
|
397
|
+
return list;
|
|
398
|
+
}
|
|
399
|
+
function isWithin(parent, child) {
|
|
400
|
+
const rel = path3.relative(path3.resolve(parent), path3.resolve(child));
|
|
401
|
+
return rel === "" || !rel.startsWith("..") && !path3.isAbsolute(rel);
|
|
402
|
+
}
|
|
403
|
+
function resolveActiveFiles(single, many) {
|
|
404
|
+
if (many && many.length > 0) {
|
|
405
|
+
if (single) {
|
|
406
|
+
throw new Error("Pass either `activeFilePath` or `activeFiles`, not both.");
|
|
407
|
+
}
|
|
408
|
+
return many;
|
|
409
|
+
}
|
|
410
|
+
if (single) return [single];
|
|
411
|
+
throw new Error("`activeFilePath` or `activeFiles` is required");
|
|
412
|
+
}
|
|
413
|
+
async function startMcpServer(opts = {}) {
|
|
414
|
+
const log = (msg) => {
|
|
415
|
+
if (!opts.silent) process.stderr.write(`[token-shrink] ${msg}
|
|
416
|
+
`);
|
|
417
|
+
};
|
|
418
|
+
const textReply = (text) => ({
|
|
419
|
+
content: [{ type: "text", text }]
|
|
420
|
+
});
|
|
421
|
+
const explicitRoot = opts.root?.trim() || process.env.ROOT?.trim() || "";
|
|
422
|
+
const autoMode = explicitRoot === "";
|
|
423
|
+
let root = "";
|
|
424
|
+
let watcher = null;
|
|
425
|
+
let searchIndex = null;
|
|
426
|
+
let lifecycle = Promise.resolve();
|
|
427
|
+
let config = { ...EMPTY_CONFIG };
|
|
428
|
+
let configFsw = null;
|
|
429
|
+
let configReloadTimer = null;
|
|
430
|
+
const stopConfigWatch = () => {
|
|
431
|
+
if (configReloadTimer) {
|
|
432
|
+
clearTimeout(configReloadTimer);
|
|
433
|
+
configReloadTimer = null;
|
|
434
|
+
}
|
|
435
|
+
if (configFsw) {
|
|
436
|
+
configFsw.close();
|
|
437
|
+
configFsw = null;
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
const reloadForRoot = async (r) => {
|
|
441
|
+
const next = loadConfig(r, (m) => log(m));
|
|
442
|
+
const changed = JSON.stringify(next) !== JSON.stringify(config);
|
|
443
|
+
config = next;
|
|
444
|
+
if (!changed || !watcher || root !== r) return;
|
|
445
|
+
log(".tokenshrinkrc.json changed \u2014 re-indexing with the new rules.");
|
|
446
|
+
stopConfigWatch();
|
|
447
|
+
const old = watcher;
|
|
448
|
+
watcher = null;
|
|
449
|
+
root = "";
|
|
450
|
+
await old.close().catch(() => {
|
|
451
|
+
});
|
|
452
|
+
await attachWatcher(r, false);
|
|
453
|
+
};
|
|
454
|
+
const startConfigWatch = (r) => {
|
|
455
|
+
stopConfigWatch();
|
|
456
|
+
const cfgPath = configPathFor(r);
|
|
457
|
+
if (!fs3.existsSync(cfgPath)) return;
|
|
458
|
+
try {
|
|
459
|
+
configFsw = fs3.watch(cfgPath, () => {
|
|
460
|
+
if (configReloadTimer) clearTimeout(configReloadTimer);
|
|
461
|
+
configReloadTimer = setTimeout(() => {
|
|
462
|
+
void enqueue(() => reloadForRoot(r));
|
|
463
|
+
}, 200);
|
|
464
|
+
});
|
|
465
|
+
} catch {
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
const writeRules = (r) => {
|
|
469
|
+
if (opts.createRule === false) return;
|
|
470
|
+
for (const target of resolveTargets(opts.ruleTarget)) {
|
|
471
|
+
const res = createAutoRule(r, target);
|
|
472
|
+
if (res.created) {
|
|
473
|
+
log(`Wrote ${target} rule to ${res.filePath}`);
|
|
474
|
+
} else if (res.skipped === "user") {
|
|
475
|
+
log(`${target} rule exists (user-authored); leaving it untouched.`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
const attachWatcher = (r, waitForIndex) => {
|
|
480
|
+
stopConfigWatch();
|
|
481
|
+
config = loadConfig(r, (m) => log(m));
|
|
482
|
+
const search = new SymbolSearch();
|
|
483
|
+
const w = createWatcher({
|
|
484
|
+
root: r,
|
|
485
|
+
ignored: opts.ignored,
|
|
486
|
+
config,
|
|
487
|
+
onIndexed: (abs, entry) => {
|
|
488
|
+
if (entry.symbols && entry.symbols.length > 0) search.setFile(abs, entry.symbols);
|
|
489
|
+
else search.removeFile(abs);
|
|
490
|
+
},
|
|
491
|
+
onRemoved: (abs) => search.removeFile(abs)
|
|
492
|
+
});
|
|
493
|
+
searchIndex = search;
|
|
494
|
+
root = r;
|
|
495
|
+
watcher = w;
|
|
496
|
+
writeRules(r);
|
|
497
|
+
log(`Indexing ${r} in the background\u2026`);
|
|
498
|
+
const indexed = w.indexAll().then((n) => log(`Indexed ${n} files.`)).catch((err) => log(`Indexing ${r} failed: ${err?.message ?? err}`));
|
|
499
|
+
startConfigWatch(r);
|
|
500
|
+
return waitForIndex ? indexed : Promise.resolve();
|
|
501
|
+
};
|
|
502
|
+
const enqueue = (fn) => {
|
|
503
|
+
const run = lifecycle.then(fn);
|
|
504
|
+
lifecycle = run.then(
|
|
505
|
+
() => {
|
|
506
|
+
},
|
|
507
|
+
() => {
|
|
508
|
+
}
|
|
509
|
+
);
|
|
510
|
+
return run;
|
|
511
|
+
};
|
|
512
|
+
if (explicitRoot) {
|
|
513
|
+
void enqueue(() => attachWatcher(path3.resolve(explicitRoot), false));
|
|
514
|
+
} else {
|
|
515
|
+
const fromCwd = detectProjectRoot(process.cwd());
|
|
516
|
+
if (fromCwd) {
|
|
517
|
+
log(`Auto-detected project root ${fromCwd} (from cwd). Pass --root to pin it.`);
|
|
518
|
+
void enqueue(() => attachWatcher(fromCwd, false));
|
|
519
|
+
} else {
|
|
520
|
+
log(
|
|
521
|
+
"No --root and no project markers around the current directory \u2014 will auto-detect the project from the first tool call."
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
const ensureReadyFor = (activeFilePath) => enqueue(async () => {
|
|
526
|
+
const abs = path3.resolve(activeFilePath);
|
|
527
|
+
if (watcher && root) {
|
|
528
|
+
if (!autoMode || isWithin(root, abs)) return;
|
|
529
|
+
const next = detectProjectRoot(abs);
|
|
530
|
+
if (!next || next === root) return;
|
|
531
|
+
log(`Active file is in a different project (${next}); re-indexing (was ${root}).`);
|
|
532
|
+
await watcher.close().catch(() => {
|
|
533
|
+
});
|
|
534
|
+
watcher = null;
|
|
535
|
+
root = "";
|
|
536
|
+
}
|
|
537
|
+
if (root) return;
|
|
538
|
+
const detected = detectProjectRoot(abs);
|
|
539
|
+
const target = detected ?? process.cwd();
|
|
540
|
+
if (detected) {
|
|
541
|
+
log(`Auto-detected project root ${target} from ${abs}.`);
|
|
542
|
+
} else {
|
|
543
|
+
log(`No project markers around ${abs}; falling back to ${target}.`);
|
|
544
|
+
}
|
|
545
|
+
await attachWatcher(target, true);
|
|
546
|
+
});
|
|
547
|
+
const server = new McpServer(
|
|
548
|
+
{ name: "token-shrink", version: "2.0.0" },
|
|
549
|
+
{ capabilities: { tools: {} } }
|
|
550
|
+
);
|
|
551
|
+
server.registerTool(
|
|
552
|
+
"get_compressed_code_context",
|
|
553
|
+
{
|
|
554
|
+
title: "Get Compressed Code Context",
|
|
555
|
+
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.",
|
|
556
|
+
inputSchema: {
|
|
557
|
+
activeFilePath: z.string().optional().describe("Path to the file the agent is working on (or use `activeFiles`)"),
|
|
558
|
+
activeFiles: z.array(z.string()).optional().describe("Multiple files to keep as Ring 0 (full text); mutually exclusive with `activeFilePath`"),
|
|
559
|
+
maxSkeletons: z.number().int().min(1).max(200).optional().describe("Cap on number of dependency skeletons to include"),
|
|
560
|
+
maxTokens: z.number().int().positive().optional().describe("Hard token budget for the whole payload; Ring 1 is relevance-ranked and packed to fit"),
|
|
561
|
+
includeStats: z.boolean().optional().describe("Append approximate token-count stats")
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
async ({ activeFilePath, activeFiles, maxSkeletons, maxTokens, includeStats }) => {
|
|
565
|
+
const paths = resolveActiveFiles(activeFilePath, activeFiles);
|
|
566
|
+
for (const p of paths) await ensureReadyFor(p);
|
|
567
|
+
if (!watcher) {
|
|
568
|
+
throw new Error("token-shrink watcher failed to start");
|
|
569
|
+
}
|
|
570
|
+
const result = assembleMany(paths, watcher.cache.entries, {
|
|
571
|
+
maxSkeletons,
|
|
572
|
+
maxTokens,
|
|
573
|
+
includeStats
|
|
574
|
+
});
|
|
575
|
+
const ts = result.tokenStats;
|
|
576
|
+
const budgetNote = ts.budget !== void 0 ? ` budget=${ts.budget} trimmed=${ts.trimmed}` : "";
|
|
577
|
+
return {
|
|
578
|
+
content: [
|
|
579
|
+
{
|
|
580
|
+
type: "text",
|
|
581
|
+
text: result.markdown
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
type: "text",
|
|
585
|
+
text: `[stats] files=${result.activeFilePaths.length} dependencies=${result.included.length} unresolved=${result.unresolved.length} tokens=${ts.totalTokens}${budgetNote}`
|
|
586
|
+
}
|
|
587
|
+
]
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
);
|
|
591
|
+
server.registerTool(
|
|
592
|
+
"expand_symbol",
|
|
593
|
+
{
|
|
594
|
+
title: "Expand Symbol",
|
|
595
|
+
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.",
|
|
596
|
+
inputSchema: {
|
|
597
|
+
filePath: z.string().describe("Path to the file containing the symbol"),
|
|
598
|
+
symbolName: z.string().describe("Name of the definition to expand (exact name preferred; falls back to case-insensitive/substring)"),
|
|
599
|
+
maxMatches: z.number().int().min(1).max(20).optional().describe("Maximum matching definitions to return (default 5)")
|
|
600
|
+
}
|
|
601
|
+
},
|
|
602
|
+
async ({ filePath, symbolName, maxMatches }) => {
|
|
603
|
+
await ensureReadyFor(filePath);
|
|
604
|
+
const abs = path3.resolve(filePath);
|
|
605
|
+
let source;
|
|
606
|
+
try {
|
|
607
|
+
source = fs3.readFileSync(abs, "utf8");
|
|
608
|
+
} catch {
|
|
609
|
+
return textReply(`File not found or unreadable: ${filePath}`);
|
|
610
|
+
}
|
|
611
|
+
const spec = languageForFile(abs);
|
|
612
|
+
if (!spec) {
|
|
613
|
+
return textReply(`No token-shrink grammar for file type: ${filePath}`);
|
|
614
|
+
}
|
|
615
|
+
const { symbols } = await analyze(abs, source, { skipPrune: true });
|
|
616
|
+
if (symbols.length === 0) {
|
|
617
|
+
return textReply(
|
|
618
|
+
`No parseable definitions found in ${filePath} \u2014 the grammar may be unavailable (first run offline) or the language exposes no name-carrying definitions yet.`
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
const matches = matchSymbols(symbols, symbolName).slice(0, maxMatches ?? 5);
|
|
622
|
+
if (matches.length === 0) {
|
|
623
|
+
const names = [...new Set(symbols.map((s) => s.name))].slice(0, 12).join(", ");
|
|
624
|
+
return textReply(
|
|
625
|
+
`No symbol named "${symbolName}" in ${filePath}.` + (names ? ` Other definitions there: ${names}.` : "")
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
const fence = path3.extname(abs).replace(/^\./, "") || "text";
|
|
629
|
+
const parts = matches.map((m) => {
|
|
630
|
+
const body = source.slice(m.start, m.end).trim();
|
|
631
|
+
return `### ${m.name} (${m.kind}) \u2014 ${abs}:${m.line}
|
|
632
|
+
|
|
633
|
+
\`\`\`` + fence + "\n" + body + "\n```\n";
|
|
634
|
+
});
|
|
635
|
+
const disambig = matches.length > 1 ? `
|
|
636
|
+
_Multiple definitions matched (${matches.length}); each is shown above._` : "";
|
|
637
|
+
return textReply(parts.join("\n") + disambig);
|
|
638
|
+
}
|
|
639
|
+
);
|
|
640
|
+
server.registerTool(
|
|
641
|
+
"git_diff_context",
|
|
642
|
+
{
|
|
643
|
+
title: "Git Diff Context",
|
|
644
|
+
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.',
|
|
645
|
+
inputSchema: {
|
|
646
|
+
scope: z.enum(["worktree", "staged", "branch"]).optional().describe("Diff scope: 'worktree' (default, staged+unstaged), 'staged', or 'branch' (base...head)"),
|
|
647
|
+
base: z.string().optional().describe("Base ref for scope=branch (default HEAD~1)"),
|
|
648
|
+
head: z.string().optional().describe("Head ref for scope=branch (default HEAD)"),
|
|
649
|
+
includeUntracked: z.boolean().optional().describe("Include untracked files (worktree scope only)"),
|
|
650
|
+
maxFiles: z.number().int().min(1).max(200).optional().describe("Maximum changed files to include (default 30)"),
|
|
651
|
+
maxImporters: z.number().int().min(0).max(100).optional().describe("Maximum importer skeletons to append (default 20)"),
|
|
652
|
+
maxSkeletons: z.number().int().min(1).max(200).optional().describe("Cap on dependency skeletons per payload"),
|
|
653
|
+
maxTokens: z.number().int().positive().optional().describe("Hard token budget for the whole payload"),
|
|
654
|
+
includeStats: z.boolean().optional().describe("Append approximate token-count stats")
|
|
655
|
+
}
|
|
656
|
+
},
|
|
657
|
+
async ({
|
|
658
|
+
scope,
|
|
659
|
+
base,
|
|
660
|
+
head,
|
|
661
|
+
includeUntracked,
|
|
662
|
+
maxFiles,
|
|
663
|
+
maxImporters,
|
|
664
|
+
maxSkeletons,
|
|
665
|
+
maxTokens,
|
|
666
|
+
includeStats
|
|
667
|
+
}) => {
|
|
668
|
+
await ensureReadyFor(process.cwd());
|
|
669
|
+
if (!watcher || !root) {
|
|
670
|
+
throw new Error("token-shrink watcher failed to start");
|
|
671
|
+
}
|
|
672
|
+
const repoRoot = root;
|
|
673
|
+
const changedResult = await gitChangedFiles(repoRoot, {
|
|
674
|
+
scope: scope ?? "worktree",
|
|
675
|
+
base,
|
|
676
|
+
head,
|
|
677
|
+
includeUntracked
|
|
678
|
+
});
|
|
679
|
+
if (changedResult.error) {
|
|
680
|
+
return textReply(`git error: ${changedResult.error}`);
|
|
681
|
+
}
|
|
682
|
+
if (changedResult.files.length === 0) {
|
|
683
|
+
return textReply("No changed files (clean worktree, or empty diff for the requested scope).");
|
|
684
|
+
}
|
|
685
|
+
const fileCap = maxFiles ?? 30;
|
|
686
|
+
const changed = changedResult.files.slice(0, fileCap);
|
|
687
|
+
const filesTruncated = changedResult.files.length > changed.length;
|
|
688
|
+
for (const abs of changed) {
|
|
689
|
+
if (!watcher.cache.entries.has(abs)) {
|
|
690
|
+
try {
|
|
691
|
+
await watcher.index(abs);
|
|
692
|
+
} catch {
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
const assembled = assembleMany(changed, watcher.cache.entries, {
|
|
697
|
+
maxSkeletons,
|
|
698
|
+
maxTokens,
|
|
699
|
+
includeStats
|
|
700
|
+
});
|
|
701
|
+
const changedSet = new Set(changed);
|
|
702
|
+
const importerOf = /* @__PURE__ */ new Map();
|
|
703
|
+
for (const [fileAbs, entry] of watcher.cache.entries) {
|
|
704
|
+
for (const imp of entry.imports) {
|
|
705
|
+
if (!changedSet.has(imp)) continue;
|
|
706
|
+
const list = importerOf.get(imp) ?? [];
|
|
707
|
+
list.push(fileAbs);
|
|
708
|
+
importerOf.set(imp, list);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
const importerList = [...new Set([...importerOf.values()].flat())].filter(
|
|
712
|
+
(f) => !changedSet.has(f)
|
|
713
|
+
);
|
|
714
|
+
const importerCap = maxImporters ?? 20;
|
|
715
|
+
const importers = importerList.slice(0, importerCap);
|
|
716
|
+
const importersTruncated = importerList.length > importers.length;
|
|
717
|
+
const relLabel = (abs) => {
|
|
718
|
+
const rel = path3.relative(repoRoot, abs);
|
|
719
|
+
return rel && !rel.startsWith("..") ? rel : abs;
|
|
720
|
+
};
|
|
721
|
+
const fence = (abs) => path3.extname(abs).replace(/^\./, "") || "text";
|
|
722
|
+
const parts = [];
|
|
723
|
+
parts.push(
|
|
724
|
+
`# Git Impact Context (${changed.length} changed file${changed.length === 1 ? "" : "s"})`,
|
|
725
|
+
""
|
|
726
|
+
);
|
|
727
|
+
parts.push(`- changed files: ${changed.map(relLabel).join(", ")}`);
|
|
728
|
+
if (importers.length > 0) {
|
|
729
|
+
parts.push(`- importing files (callers): ${importers.map(relLabel).join(", ")}`);
|
|
730
|
+
}
|
|
731
|
+
parts.push("", "---", "");
|
|
732
|
+
parts.push("## Changed files \u2014 full code", "");
|
|
733
|
+
for (const abs of changed) {
|
|
734
|
+
parts.push(`### \`${relLabel(abs)}\``, "");
|
|
735
|
+
let source = "";
|
|
736
|
+
try {
|
|
737
|
+
source = fs3.readFileSync(abs, "utf8");
|
|
738
|
+
} catch {
|
|
739
|
+
}
|
|
740
|
+
parts.push(`\`\`\`${fence(abs)}`, source.trim() || "(unreadable file)", "```", "");
|
|
741
|
+
}
|
|
742
|
+
parts.push(`## Ring 1 \u2014 Pruned dependencies (${assembled.included.length})`, "");
|
|
743
|
+
parts.push("", "Implementation bodies removed; type signatures, interfaces and exports retained.", "");
|
|
744
|
+
if (assembled.included.length === 0) {
|
|
745
|
+
parts.push("_No local dependency skeletons available._", "");
|
|
746
|
+
}
|
|
747
|
+
for (const inc of assembled.included) {
|
|
748
|
+
const entry = watcher.cache.entries.get(inc.filePath);
|
|
749
|
+
const label = relLabel(inc.filePath);
|
|
750
|
+
parts.push(`### \`${label}\``, "");
|
|
751
|
+
if (entry) {
|
|
752
|
+
parts.push(`\`\`\`${fence(inc.filePath)}`, entry.skeleton.trim(), "```", "");
|
|
753
|
+
} else {
|
|
754
|
+
parts.push("_Unindexed file._", "");
|
|
755
|
+
}
|
|
756
|
+
parts.push("");
|
|
757
|
+
}
|
|
758
|
+
if (assembled.tokenStats.budget !== void 0 && assembled.tokenStats.trimmed > 0) {
|
|
759
|
+
parts.push(
|
|
760
|
+
`_Note: token budget of ${assembled.tokenStats.budget} excluded ${assembled.tokenStats.trimmed} lower-priority dependencies._`,
|
|
761
|
+
""
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
if (importers.length > 0) {
|
|
765
|
+
parts.push(`## Ring 2 \u2014 Files importing the diff (${importers.length})`, "");
|
|
766
|
+
parts.push("", "Pruned skeletons of modules that call into the changed files.", "");
|
|
767
|
+
for (const abs of importers) {
|
|
768
|
+
const entry = watcher.cache.entries.get(abs);
|
|
769
|
+
parts.push(`### \`${relLabel(abs)}\``, "");
|
|
770
|
+
if (entry) {
|
|
771
|
+
parts.push(`\`\`\`${fence(abs)}`, entry.skeleton.trim(), "```", "");
|
|
772
|
+
} else {
|
|
773
|
+
parts.push("_Unindexed file._", "");
|
|
774
|
+
}
|
|
775
|
+
parts.push("");
|
|
776
|
+
}
|
|
777
|
+
if (importersTruncated) {
|
|
778
|
+
parts.push(
|
|
779
|
+
`_\u2026and ${importerList.length - importers.length} more importing files (raise maxImporters)._`,
|
|
780
|
+
""
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (filesTruncated) {
|
|
785
|
+
parts.push(
|
|
786
|
+
`_Note: capped to ${fileCap} changed files (${changedResult.files.length} total); raise maxFiles to include more._`,
|
|
787
|
+
""
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
if (assembled.unresolved.length > 0) {
|
|
791
|
+
parts.push("## Unresolved imports", "");
|
|
792
|
+
for (const u of assembled.unresolved) parts.push(`- \`${u}\``);
|
|
793
|
+
parts.push("");
|
|
794
|
+
}
|
|
795
|
+
const ts = assembled.tokenStats;
|
|
796
|
+
const budgetNote = ts.budget !== void 0 ? ` budget=${ts.budget} trimmed=${ts.trimmed}` : "";
|
|
797
|
+
const stats = `[git-stats] files=${changed.length} ring1=${assembled.included.length} importers=${importers.length} unresolved=${assembled.unresolved.length} tokens=${ts.totalTokens}${budgetNote}`;
|
|
798
|
+
return {
|
|
799
|
+
content: [
|
|
800
|
+
{ type: "text", text: parts.join("\n") },
|
|
801
|
+
{ type: "text", text: stats }
|
|
802
|
+
]
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
);
|
|
806
|
+
server.registerTool(
|
|
807
|
+
"search_symbol_signatures",
|
|
808
|
+
{
|
|
809
|
+
title: "Search Symbol Signatures",
|
|
810
|
+
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.",
|
|
811
|
+
inputSchema: {
|
|
812
|
+
query: z.string().describe("Search text (matched against symbol names; falls back to signatures)"),
|
|
813
|
+
maxResults: z.number().int().min(1).max(100).optional().describe("Maximum results to return (default 10)"),
|
|
814
|
+
kind: z.enum(["function", "method", "arrow", "class", "interface", "enum", "type", "other"]).optional().describe("Only return definitions of this kind")
|
|
815
|
+
}
|
|
816
|
+
},
|
|
817
|
+
async ({ query, maxResults, kind }) => {
|
|
818
|
+
await ensureReadyFor(process.cwd());
|
|
819
|
+
if (!searchIndex) {
|
|
820
|
+
throw new Error("token-shrink symbol index failed to start");
|
|
821
|
+
}
|
|
822
|
+
if (searchIndex.size === 0 && watcher && watcher.cache.entries.size > 0) {
|
|
823
|
+
searchIndex.loadCache(watcher.cache.entries);
|
|
824
|
+
}
|
|
825
|
+
const hits = searchIndex.search(query, {
|
|
826
|
+
maxResults: maxResults ?? 10,
|
|
827
|
+
kind
|
|
828
|
+
});
|
|
829
|
+
if (hits.length === 0) {
|
|
830
|
+
return textReply(`No symbols match "${query}". Try a different name or kind.`);
|
|
831
|
+
}
|
|
832
|
+
const lines = hits.map((h) => `- \`${h.label}\``);
|
|
833
|
+
return textReply(
|
|
834
|
+
`${lines.join("\n")}
|
|
835
|
+
|
|
836
|
+
_Found ${hits.length} symbol${hits.length === 1 ? "" : "s"} matching "${query}"._`
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
);
|
|
840
|
+
const transport = new StdioServerTransport();
|
|
841
|
+
await server.connect(transport);
|
|
842
|
+
log("MCP server connected.");
|
|
843
|
+
return server;
|
|
844
|
+
}
|
|
845
|
+
var argv1 = process.argv[1] ? path3.basename(process.argv[1]) : "";
|
|
846
|
+
var argv1Real = "";
|
|
847
|
+
try {
|
|
848
|
+
argv1Real = process.argv[1] ? path3.basename(fs3.realpathSync(process.argv[1])) : "";
|
|
849
|
+
} catch {
|
|
850
|
+
}
|
|
851
|
+
var invokedAsMcp = argv1 === "mcp.js" || argv1 === "mcp.mjs" || argv1 === "mcp.cjs" || argv1 === "mcp.ts" || argv1Real === "mcp.js" || argv1Real === "mcp.mjs" || argv1Real === "mcp.cjs" || argv1Real === "mcp.ts";
|
|
852
|
+
if (invokedAsMcp) {
|
|
853
|
+
const rootArg = (() => {
|
|
854
|
+
const i = process.argv.indexOf("--root");
|
|
855
|
+
if (i !== -1 && process.argv[i + 1]) return process.argv[i + 1];
|
|
856
|
+
const eq = process.argv.find((a) => a.startsWith("--root="));
|
|
857
|
+
if (eq) return eq.slice("--root=".length);
|
|
858
|
+
return void 0;
|
|
859
|
+
})();
|
|
860
|
+
const createRule = !(process.env.TOKEN_SHRINK_CREATE_RULE === "0" || process.env.TOKEN_SHRINK_CREATE_RULE === "false" || process.env.CONTEXT_SHRINK_CREATE_RULE === "0" || process.env.CONTEXT_SHRINK_CREATE_RULE === "false" || process.argv.includes("--no-create-rule") || (() => {
|
|
861
|
+
const f = process.argv.find((a) => a.startsWith("--create-rule="));
|
|
862
|
+
return f ? f.slice("--create-rule=".length) === "false" : false;
|
|
863
|
+
})());
|
|
864
|
+
const rawTargets = process.argv.filter((a) => a.startsWith("--rule-target=")).flatMap((a) => a.slice("--rule-target=".length).split(","));
|
|
865
|
+
const ruleTarget = rawTargets.length > 0 && !rawTargets.includes("all") ? [...new Set(rawTargets)].filter(
|
|
866
|
+
(v) => v === "cursor" || v === "claude" || v === "cline"
|
|
867
|
+
) : void 0;
|
|
868
|
+
void startMcpServer({ root: rootArg, createRule, ruleTarget }).catch((err) => {
|
|
869
|
+
process.stderr.write(`[token-shrink] MCP server error: ${err.message}
|
|
870
|
+
`);
|
|
871
|
+
process.exitCode = 1;
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
export {
|
|
876
|
+
VCS_MARKER_DIRS,
|
|
877
|
+
PROJECT_MANIFEST_FILES,
|
|
878
|
+
detectProjectRoot,
|
|
879
|
+
SymbolSearch,
|
|
880
|
+
RULE_TARGET_PATH,
|
|
881
|
+
RULE_VERSION,
|
|
882
|
+
RULE_VERSION_MARKER,
|
|
883
|
+
AUTO_RULE_SENTINEL,
|
|
884
|
+
CURSOR_RULE_PATH,
|
|
885
|
+
AUTO_RULE_PATH,
|
|
886
|
+
CLAUDE_RULE_PATH,
|
|
887
|
+
CLAUDE_RULE_SENTINEL,
|
|
888
|
+
CLINE_RULE_PATH,
|
|
889
|
+
CLINE_RULE_SENTINEL,
|
|
890
|
+
createAutoRule,
|
|
891
|
+
createCursorRule,
|
|
892
|
+
startMcpServer
|
|
893
|
+
};
|
|
894
|
+
//# sourceMappingURL=chunk-RLHEIKPR.js.map
|