grepmax 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -53,11 +53,14 @@ exports.mcp = void 0;
53
53
  exports.toStringArray = toStringArray;
54
54
  exports.ok = ok;
55
55
  exports.err = err;
56
+ exports.isExplicitCrossProjectSearch = isExplicitCrossProjectSearch;
56
57
  exports.searchResultPath = searchResultPath;
57
58
  exports.searchResultStartLine = searchResultStartLine;
58
59
  exports.searchResultEndLine = searchResultEndLine;
59
60
  exports.filterMcpSearchResults = filterMcpSearchResults;
60
61
  exports.formatMcpPointerSearchResults = formatMcpPointerSearchResults;
62
+ exports.formatMcpSurprisingConnections = formatMcpSurprisingConnections;
63
+ exports.mcpLogQuery = mcpLogQuery;
61
64
  const fs = __importStar(require("node:fs"));
62
65
  const path = __importStar(require("node:path"));
63
66
  const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
@@ -65,6 +68,7 @@ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
65
68
  const commander_1 = require("commander");
66
69
  const zod_1 = require("zod");
67
70
  const config_1 = require("../config");
71
+ const surprising_connections_1 = require("../lib/analysis/surprising-connections");
68
72
  const languages_1 = require("../lib/core/languages");
69
73
  const graph_builder_1 = require("../lib/graph/graph-builder");
70
74
  const index_config_1 = require("../lib/index/index-config");
@@ -109,6 +113,13 @@ function ok(text) {
109
113
  function err(text) {
110
114
  return { content: [{ type: "text", text }], isError: true };
111
115
  }
116
+ function isExplicitCrossProjectSearch(args, isSearchAll = false) {
117
+ return (isSearchAll ||
118
+ args.scope === "all" ||
119
+ (typeof args.projects === "string" && args.projects.trim() !== "") ||
120
+ (typeof args.exclude_projects === "string" &&
121
+ args.exclude_projects.trim() !== ""));
122
+ }
112
123
  function chunkAbsPath(chunk) {
113
124
  const metadata = chunk.metadata;
114
125
  return String(chunk.path || (metadata === null || metadata === void 0 ? void 0 : metadata.path) || "");
@@ -171,6 +182,59 @@ function formatMcpPointerSearchResults(data, displayRoot, options = {}) {
171
182
  query: options.query,
172
183
  });
173
184
  }
185
+ function formatMcpSurprisingConnections(result, top = 10) {
186
+ const { summary, findings } = result;
187
+ const lines = [
188
+ [
189
+ "summary",
190
+ `sampled=${summary.sampledAnchors}`,
191
+ `code=${summary.codeRows}`,
192
+ `pairs=${summary.acceptedPairs}`,
193
+ `file_pairs=${summary.acceptedFilePairs}`,
194
+ `score_p90=${summary.actionabilityScore.p90}`,
195
+ ].join("\t"),
196
+ ];
197
+ for (const finding of findings.slice(0, top)) {
198
+ const pair = finding.representative;
199
+ lines.push([
200
+ "surprise",
201
+ finding.score.toFixed(3),
202
+ finding.maxSimilarity.toFixed(3),
203
+ String(finding.pairCount),
204
+ finding.fileA,
205
+ finding.fileB,
206
+ (0, surprising_connections_1.lineLabel)(pair.source),
207
+ (0, surprising_connections_1.lineLabel)(pair.target),
208
+ finding.reasons.join(","),
209
+ `buckets=${(0, surprising_connections_1.findingBucketLabel)(finding, summary.options.dirDepth)}`,
210
+ `top_sims=${finding.topSimilarities.join(",")}`,
211
+ `penalties=${(0, surprising_connections_1.formatPenaltySummary)(pair.scoreParts)}`,
212
+ `next=${(0, surprising_connections_1.skeletonHint)(finding)}`,
213
+ ].join("\t"));
214
+ }
215
+ if (findings.length === 0)
216
+ lines.push("none");
217
+ return lines.join("\n");
218
+ }
219
+ function mcpLogQuery(name, args) {
220
+ var _a, _b;
221
+ const direct = (_b = (_a = args.query) !== null && _a !== void 0 ? _a : args.symbol) !== null && _b !== void 0 ? _b : args.target;
222
+ if (direct !== undefined && direct !== null && String(direct) !== "") {
223
+ return String(direct);
224
+ }
225
+ if (name === "surprising_connections") {
226
+ const parts = [
227
+ "surprising_connections",
228
+ typeof args.root === "string" && args.root ? `root=${args.root}` : "",
229
+ typeof args.in === "string" && args.in ? `in=${args.in}` : "",
230
+ typeof args.exclude === "string" && args.exclude
231
+ ? `exclude=${args.exclude}`
232
+ : "",
233
+ ].filter(Boolean);
234
+ return parts.join(" ");
235
+ }
236
+ return "";
237
+ }
174
238
  // ---------------------------------------------------------------------------
175
239
  // Command
176
240
  // ---------------------------------------------------------------------------
@@ -264,38 +328,56 @@ exports.mcp = new commander_1.Command("mcp")
264
328
  }
265
329
  });
266
330
  }
331
+ // Resolve the registered project this server scopes to. The server pins to
332
+ // its launch cwd (findProjectRoot, .git-only), but a registered project may
333
+ // be an umbrella with no .git of its own — so a session launched in a
334
+ // subdirectory resolves to a path that isn't the registered root. Prefer an
335
+ // exact registry match; otherwise walk the registry for an ancestor that
336
+ // covers the cwd, and scope to it instead of silently widening to the whole
337
+ // index. `root` is the registered project root to scope to.
338
+ function resolveRegisteredProject() {
339
+ const exact = (0, project_registry_1.getProject)(projectRoot);
340
+ if (exact)
341
+ return { proj: exact, root: projectRoot };
342
+ const parent = (0, project_registry_1.getParentProject)(projectRoot);
343
+ if (parent)
344
+ return { proj: parent, root: parent.root };
345
+ return { proj: undefined, root: projectRoot };
346
+ }
267
347
  // --- Tool handlers ---
268
348
  function handleSemanticSearch(args_1) {
269
349
  return __awaiter(this, arguments, void 0, function* (args, isSearchAll = false) {
270
350
  const query = String(args.query || "");
271
351
  if (!query)
272
352
  return err("Missing required parameter: query");
273
- let searchAll = isSearchAll || args.scope === "all";
353
+ const searchAll = isExplicitCrossProjectSearch(args, isSearchAll);
274
354
  const limit = Math.min(Math.max(Number(args.limit) || 3, 1), 50);
275
355
  ensureWatcher();
276
356
  // Project resolution. The server is pinned to whatever cwd it launched in
277
- // (resolved once at startup). When that cwd ISN'T an indexed project and
278
- // the caller hasn't pinned an explicit --root, fall back to cross-project
279
- // search instead of dead-ending this is what makes the server usable
280
- // from a session that isn't sitting inside a registered repo.
281
- let scopeNote = "";
282
- const proj = (0, project_registry_1.getProject)(projectRoot);
283
- if (!proj && typeof args.root !== "string") {
284
- const indexed = (0, project_registry_1.listProjects)().filter((p) => { var _a; return p.status !== "pending" && ((_a = p.chunkCount) !== null && _a !== void 0 ? _a : 0) > 0; });
285
- if (indexed.length === 0) {
286
- return err("No indexed projects yet. cd into a repo and run `gmax add` to index it first.");
287
- }
288
- searchAll = true;
289
- scopeNote =
290
- `Note: ${path.basename(projectRoot)}/ isn't an indexed gmax project ` +
291
- `searching all ${indexed.length} indexed project(s). ` +
292
- `Call list_projects to see them, then pass projects:"name" to narrow.`;
293
- }
294
- else if (!proj) {
295
- return err("Project not added to gmax yet. Run `gmax add` to index it first.");
296
- }
297
- else if (proj.status === "pending" || proj.chunkCount === 0) {
298
- return err("Project not indexed yet. Run `gmax add` to index it first.");
357
+ // (resolved once at startup). Resolve that cwd to its registered project
358
+ // exact match, or a registered ancestor when the cwd is a subdirectory of
359
+ // an umbrella project. Searches scope to `resolvedRoot`.
360
+ const { proj, root: resolvedRoot } = resolveRegisteredProject();
361
+ // Cross-project search (the whole index) is opt-in: it happens only when
362
+ // the caller passes scope:"all" or projects:"…". Otherwise we require a
363
+ // resolved project and error loudly when there isn't one — never silently
364
+ // widen a scoped query to every indexed project.
365
+ if (!searchAll) {
366
+ if (!proj) {
367
+ if (typeof args.root === "string") {
368
+ return err("Project not added to gmax yet. Run `gmax add` to index it first.");
369
+ }
370
+ const indexed = (0, project_registry_1.listProjects)().filter((p) => { var _a; return p.status !== "pending" && ((_a = p.chunkCount) !== null && _a !== void 0 ? _a : 0) > 0; });
371
+ if (indexed.length === 0) {
372
+ return err("No indexed projects yet. cd into a repo and run `gmax add` to index it first.");
373
+ }
374
+ return err(`${path.basename(projectRoot)}/ isn't an indexed gmax project. ` +
375
+ `Pass scope:"all" to search all ${indexed.length} indexed project(s), ` +
376
+ `projects:"name" to target specific ones, or cd into an indexed project.`);
377
+ }
378
+ if (proj.status === "pending" || proj.chunkCount === 0) {
379
+ return err("Project not indexed yet. Run `gmax add` to index it first.");
380
+ }
299
381
  }
300
382
  try {
301
383
  const searcher = getSearcher();
@@ -305,7 +387,7 @@ exports.mcp = new commander_1.Command("mcp")
305
387
  if (!searchAll) {
306
388
  const searchRoot = typeof args.root === "string"
307
389
  ? path.resolve(args.root)
308
- : path.resolve(projectRoot);
390
+ : path.resolve(resolvedRoot);
309
391
  if (typeof args.root === "string" && !fs.existsSync(searchRoot)) {
310
392
  return err(`Directory not found: ${args.root}`);
311
393
  }
@@ -372,11 +454,10 @@ exports.mcp = new commander_1.Command("mcp")
372
454
  ? { files: seedFiles, symbols: seedSymbols }
373
455
  : undefined;
374
456
  const result = yield searcher.search(query, limit, { rerank: process.env.GMAX_RERANK === "1", seeds }, Object.keys(filters).length > 0 ? filters : undefined, pathPrefix);
375
- // Prepend the scope-fallback note (if any) + searcher warnings to
376
- // whatever body we return, so the agent learns it searched all projects.
457
+ // Prepend any searcher warnings to whatever body we return.
377
458
  const prefixNotes = (body) => {
378
459
  var _a;
379
- const notes = [scopeNote, ...((_a = result.warnings) !== null && _a !== void 0 ? _a : [])].filter(Boolean);
460
+ const notes = ((_a = result.warnings) !== null && _a !== void 0 ? _a : []).filter(Boolean);
380
461
  return notes.length ? `${notes.join("\n")}\n\n${body}` : body;
381
462
  };
382
463
  if (!result.data || result.data.length === 0) {
@@ -676,7 +757,7 @@ exports.mcp = new commander_1.Command("mcp")
676
757
  const symbol = String(args.symbol || "");
677
758
  if (!symbol)
678
759
  return err("Missing required parameter: symbol");
679
- const proj = (0, project_registry_1.getProject)(projectRoot);
760
+ const { proj } = resolveRegisteredProject();
680
761
  if (!proj) {
681
762
  return err("Project not added to gmax yet. Run `gmax add` to index it first.");
682
763
  }
@@ -1042,6 +1123,7 @@ exports.mcp = new commander_1.Command("mcp")
1042
1123
  "start_line",
1043
1124
  "defined_symbols",
1044
1125
  "referenced_symbols",
1126
+ "type_referenced_symbols",
1045
1127
  "is_exported",
1046
1128
  ])
1047
1129
  .where((0, filter_builder_1.pathStartsWith)(prefix))
@@ -1055,7 +1137,12 @@ exports.mcp = new commander_1.Command("mcp")
1055
1137
  start_line: Number(r.start_line || 0),
1056
1138
  is_exported: Boolean(r.is_exported),
1057
1139
  defined_symbols: toStringArray(r.defined_symbols),
1058
- referenced_symbols: toStringArray(r.referenced_symbols),
1140
+ referenced_symbols: [
1141
+ ...new Set([
1142
+ ...toStringArray(r.referenced_symbols),
1143
+ ...toStringArray(r.type_referenced_symbols),
1144
+ ]),
1145
+ ],
1059
1146
  })), prefix, top);
1060
1147
  const lines = [];
1061
1148
  lines.push(`Audit — ${audit.scannedChunks} chunks across ${audit.scannedFiles} files`);
@@ -1074,6 +1161,13 @@ exports.mcp = new commander_1.Command("mcp")
1074
1161
  if (audit.hubFiles.length === 0)
1075
1162
  lines.push(" none");
1076
1163
  lines.push("");
1164
+ lines.push("File dependency cycles (symbol-derived):");
1165
+ for (const c of audit.fileCycles) {
1166
+ lines.push(` ${c.files.join(", ")} - ${c.files.length} files, ${c.edgeCount} internal edges`);
1167
+ }
1168
+ if (audit.fileCycles.length === 0)
1169
+ lines.push(" none");
1170
+ lines.push("");
1077
1171
  lines.push(`Dead-code candidates (${audit.deadTotal} non-exported symbols with zero inbound refs):`);
1078
1172
  for (const d of audit.deadCandidates) {
1079
1173
  lines.push(` ${d.symbol} (${d.file}:${d.line + 1})`);
@@ -1093,6 +1187,43 @@ exports.mcp = new commander_1.Command("mcp")
1093
1187
  }
1094
1188
  });
1095
1189
  }
1190
+ function handleSurprisingConnections(args) {
1191
+ return __awaiter(this, void 0, void 0, function* () {
1192
+ ensureWatcher();
1193
+ if (args.experimental !== true) {
1194
+ return err("surprising_connections is experimental; pass experimental:true.");
1195
+ }
1196
+ const root = typeof args.root === "string" && args.root
1197
+ ? path.resolve(args.root)
1198
+ : projectRoot;
1199
+ const top = Math.min(Math.max(Number(args.top) || 10, 1), 100);
1200
+ const sample = Math.min(Math.max(Number(args.sample) || surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.sample, 1), 10000);
1201
+ const neighbors = Math.min(Math.max(Number(args.neighbors) || surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.neighbors, 1), 200);
1202
+ const dirDepth = Math.min(Math.max(Number(args.dir_depth) || surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.dirDepth, 1), 20);
1203
+ const minSimilarity = Math.max(Number(args.min_similarity) || surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.minSimilarity, 0);
1204
+ const maxRows = Math.min(Math.max(Number(args.max_rows) || surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.maxRows, 1), surprising_connections_1.MAX_SURPRISE_ROWS);
1205
+ try {
1206
+ const db = getVectorDb();
1207
+ const table = yield db.ensureTable();
1208
+ const result = yield (0, surprising_connections_1.analyzeSurprisingConnections)(table, root, {
1209
+ sample,
1210
+ neighbors,
1211
+ dirDepth,
1212
+ minSimilarity,
1213
+ maxRows,
1214
+ includeTests: Boolean(args.include_tests),
1215
+ includeEval: Boolean(args.include_eval),
1216
+ in: typeof args.in === "string" ? args.in : undefined,
1217
+ exclude: typeof args.exclude === "string" ? args.exclude : undefined,
1218
+ });
1219
+ return ok(formatMcpSurprisingConnections(result, top));
1220
+ }
1221
+ catch (e) {
1222
+ const msg = e instanceof Error ? e.message : String(e);
1223
+ return err(`Surprising connections failed: ${msg}`);
1224
+ }
1225
+ });
1226
+ }
1096
1227
  function handleGetNeighbors(args) {
1097
1228
  return __awaiter(this, void 0, void 0, function* () {
1098
1229
  ensureWatcher();
@@ -1205,7 +1336,7 @@ exports.mcp = new commander_1.Command("mcp")
1205
1336
  const pattern = typeof args.pattern === "string" ? args.pattern : undefined;
1206
1337
  const limit = Math.min(Math.max(Number(args.limit) || 20, 1), 100);
1207
1338
  const pathPrefix = typeof args.path === "string" ? args.path : undefined;
1208
- const proj = (0, project_registry_1.getProject)(projectRoot);
1339
+ const { proj } = resolveRegisteredProject();
1209
1340
  if (!proj) {
1210
1341
  return err("Project not added to gmax yet. Run `gmax add` to index it first.");
1211
1342
  }
@@ -1746,8 +1877,11 @@ exports.mcp = new commander_1.Command("mcp")
1746
1877
  if (!target)
1747
1878
  return err("Missing required parameter: target");
1748
1879
  const depth = Math.min(Math.max(Number(args.depth) || 1, 1), 3);
1880
+ const rollup = args.rollup === true;
1881
+ const top = Math.min(Math.max(Number(args.top) || 10, 1), 100);
1749
1882
  try {
1750
- const { resolveTargetSymbols, findTests, findDependents, isTestPath } = yield Promise.resolve().then(() => __importStar(require("../lib/graph/impact")));
1883
+ const { resolveTargetSymbols, findTests, findDependents, findDependentsDetailed, isTestPath, } = yield Promise.resolve().then(() => __importStar(require("../lib/graph/impact")));
1884
+ const { buildImpactRollup, formatImpactRollupAgent } = yield Promise.resolve().then(() => __importStar(require("../lib/graph/impact-rollup")));
1751
1885
  const db = getVectorDb();
1752
1886
  const { symbols, resolvedAsFile, symbolFamilies } = yield resolveTargetSymbols(target, db, projectRoot);
1753
1887
  if (symbols.length === 0)
@@ -1756,10 +1890,28 @@ exports.mcp = new commander_1.Command("mcp")
1756
1890
  ? path.resolve(projectRoot, target)
1757
1891
  : undefined;
1758
1892
  const excludePaths = targetPath ? new Set([targetPath]) : undefined;
1893
+ const rollupLimit = Math.min(Math.max(top * 10, 100), 500);
1759
1894
  const [dependents, tests] = yield Promise.all([
1760
- findDependents(symbols, db, projectRoot, excludePaths, undefined, undefined, symbolFamilies),
1895
+ rollup
1896
+ ? findDependentsDetailed(symbols, db, projectRoot, excludePaths, rollupLimit, undefined, symbolFamilies)
1897
+ : findDependents(symbols, db, projectRoot, excludePaths, undefined, undefined, symbolFamilies),
1761
1898
  findTests(symbols, db, projectRoot, depth, undefined, symbolFamilies),
1762
1899
  ]);
1900
+ if (rollup) {
1901
+ const detailedDependents = dependents;
1902
+ const impactRollup = buildImpactRollup({
1903
+ targetSymbols: symbols,
1904
+ dependents: detailedDependents,
1905
+ tests,
1906
+ projectRoot,
1907
+ top,
1908
+ });
1909
+ return ok(formatImpactRollupAgent(impactRollup, {
1910
+ target,
1911
+ projectRoot,
1912
+ includeTests: true,
1913
+ }));
1914
+ }
1763
1915
  const nonTestDeps = dependents.filter((d) => !isTestPath(d.file));
1764
1916
  const rel = (p) => p.startsWith(`${projectRoot}/`) ? p.slice(projectRoot.length + 1) : p;
1765
1917
  const sections = [`Impact analysis for ${target}:\n`];
@@ -2032,15 +2184,16 @@ exports.mcp = new commander_1.Command("mcp")
2032
2184
  "If you are NOT inside a specific repo, or want a different one: call " +
2033
2185
  '`list_projects` to see what\'s indexed, then pass `projects:"name"` (or ' +
2034
2186
  '`scope:"all"`) to `semantic_search`. When the working directory isn\'t an ' +
2035
- "indexed project, `semantic_search` automatically searches all of them and " +
2036
- "says so. Prefer `semantic_search` (detail:'pointer') for discovery, then " +
2187
+ "indexed project, `semantic_search` errors instead of searching everything " +
2188
+ "implicitly. Prefer `semantic_search` (detail:'pointer') for discovery, then " +
2037
2189
  "`extract_symbol`/`peek_symbol`/`code_skeleton` to read, and " +
2038
- "`trace_calls`/`impact_analysis`/`find_tests` for call-graph questions.",
2190
+ "`trace_calls`/`impact_analysis`/`find_tests` for call-graph questions. " +
2191
+ "Use `audit` and experimental `surprising_connections` for project orientation.",
2039
2192
  });
2040
2193
  // Best-effort query logging, applied uniformly to every tool exactly as the
2041
2194
  // old single CallToolRequestSchema dispatch did before each return.
2042
2195
  const logToolCall = (name, toolArgs, startMs, result) => __awaiter(void 0, void 0, void 0, function* () {
2043
- var _a, _b, _c, _d, _e, _f;
2196
+ var _a, _b, _c;
2044
2197
  try {
2045
2198
  const { logQuery } = yield Promise.resolve().then(() => __importStar(require("../lib/utils/query-log")));
2046
2199
  const text = (_c = (_b = (_a = result.content) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.text) !== null && _c !== void 0 ? _c : "";
@@ -2049,14 +2202,14 @@ exports.mcp = new commander_1.Command("mcp")
2049
2202
  ts: new Date().toISOString(),
2050
2203
  source: "mcp",
2051
2204
  tool: name,
2052
- query: String((_f = (_e = (_d = toolArgs.query) !== null && _d !== void 0 ? _d : toolArgs.symbol) !== null && _e !== void 0 ? _e : toolArgs.target) !== null && _f !== void 0 ? _f : ""),
2205
+ query: mcpLogQuery(name, toolArgs),
2053
2206
  project: projectRoot,
2054
2207
  results: resultLines,
2055
2208
  ms: Date.now() - startMs,
2056
2209
  error: result.isError ? text.slice(0, 200) : undefined,
2057
2210
  });
2058
2211
  }
2059
- catch (_g) { }
2212
+ catch (_d) { }
2060
2213
  });
2061
2214
  // Register a tool, wrapping its handler with timing + query logging. Zod raw
2062
2215
  // shapes give us free input validation (the SDK rejects calls that violate
@@ -2204,7 +2357,7 @@ exports.mcp = new commander_1.Command("mcp")
2204
2357
  },
2205
2358
  }, handleDead);
2206
2359
  tool("audit", {
2207
- description: "Graph-summary of the indexed project in one call: god nodes (most depended-upon symbols), hub files (most depended-upon files), and dead-code candidates (non-exported symbols with zero inbound references). Built from the static call graph dynamic dispatch, reflection, eval, and type-position-only references are invisible, so dead candidates are hypotheses; verify with the `dead` tool before acting.",
2360
+ description: "Graph-summary of the indexed project in one call: god nodes (most depended-upon symbols), hub files (most depended-upon files), symbol-derived file dependency cycles, and dead-code candidates (non-exported symbols with zero inbound references). Built from the static reference graph; dynamic dispatch, reflection, and eval are invisible, so dead candidates are hypotheses; verify with the `dead` tool before acting.",
2208
2361
  inputSchema: {
2209
2362
  root: zod_1.z
2210
2363
  .string()
@@ -2216,6 +2369,55 @@ exports.mcp = new commander_1.Command("mcp")
2216
2369
  .optional(),
2217
2370
  },
2218
2371
  }, handleAudit);
2372
+ tool("surprising_connections", {
2373
+ description: "Experimental orientation signal: embedding-similar cross-directory file pairs that are not directly connected by the static symbol graph. Useful for spotting duplicate or parallel logic. Requires experimental:true.",
2374
+ inputSchema: {
2375
+ experimental: zod_1.z
2376
+ .boolean()
2377
+ .describe("Must be true to acknowledge experimental signal quality"),
2378
+ root: zod_1.z
2379
+ .string()
2380
+ .describe("Project root (default: current)")
2381
+ .optional(),
2382
+ sample: zod_1.z
2383
+ .number()
2384
+ .describe("Indexed code chunks to sample (default 160, max 10000)")
2385
+ .optional(),
2386
+ neighbors: zod_1.z
2387
+ .number()
2388
+ .describe("Nearest neighbors per sampled chunk (default 20, max 200)")
2389
+ .optional(),
2390
+ top: zod_1.z
2391
+ .number()
2392
+ .describe("Grouped findings to return (default 10, max 100)")
2393
+ .optional(),
2394
+ dir_depth: zod_1.z
2395
+ .number()
2396
+ .describe("Directory bucket depth considered unsurprising")
2397
+ .optional(),
2398
+ min_similarity: zod_1.z
2399
+ .number()
2400
+ .describe("Minimum similarity 0-1 (default 0)")
2401
+ .optional(),
2402
+ max_rows: zod_1.z
2403
+ .number()
2404
+ .describe(`Maximum indexed rows to scan (default 50000, max ${surprising_connections_1.MAX_SURPRISE_ROWS})`)
2405
+ .optional(),
2406
+ in: zod_1.z
2407
+ .string()
2408
+ .describe("Restrict to a sub-path of the project")
2409
+ .optional(),
2410
+ exclude: zod_1.z
2411
+ .string()
2412
+ .describe("Exclude a sub-path of the project")
2413
+ .optional(),
2414
+ include_tests: zod_1.z.boolean().describe("Include test files").optional(),
2415
+ include_eval: zod_1.z
2416
+ .boolean()
2417
+ .describe("Include eval/experiment/script files")
2418
+ .optional(),
2419
+ },
2420
+ }, handleSurprisingConnections);
2219
2421
  tool("get_neighbors", {
2220
2422
  description: "Graph primitive: symbols reachable from a node along call edges within N hops. direction 'callees' = what it calls (outbound), 'callers' = what calls it (inbound). Each result carries its hop distance and definition location. Static call graph — same caveats as `dead`/`audit`.",
2221
2423
  inputSchema: {
@@ -2358,6 +2560,14 @@ exports.mcp = new commander_1.Command("mcp")
2358
2560
  .number()
2359
2561
  .describe("Caller traversal depth 1-3 (default 1)")
2360
2562
  .optional(),
2563
+ rollup: zod_1.z
2564
+ .boolean()
2565
+ .describe("Return export/package rollup TSV rows")
2566
+ .optional(),
2567
+ top: zod_1.z
2568
+ .number()
2569
+ .describe("Max rows per rollup section (default 10)")
2570
+ .optional(),
2361
2571
  },
2362
2572
  }, handleImpactAnalysis);
2363
2573
  tool("find_similar", {
@@ -0,0 +1,150 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.surprises = void 0;
13
+ const commander_1 = require("commander");
14
+ const surprising_connections_1 = require("../lib/analysis/surprising-connections");
15
+ const vector_db_1 = require("../lib/store/vector-db");
16
+ const exit_1 = require("../lib/utils/exit");
17
+ const project_registry_1 = require("../lib/utils/project-registry");
18
+ const project_root_1 = require("../lib/utils/project-root");
19
+ const stale_hint_1 = require("../lib/utils/stale-hint");
20
+ const useColors = process.stdout.isTTY && !process.env.NO_COLOR;
21
+ const style = {
22
+ bold: (s) => (useColors ? `\x1b[1m${s}\x1b[22m` : s),
23
+ dim: (s) => (useColors ? `\x1b[2m${s}\x1b[39m` : s),
24
+ cyan: (s) => (useColors ? `\x1b[36m${s}\x1b[39m` : s),
25
+ };
26
+ function parseIntOption(value, fallback, max = 10000) {
27
+ const parsed = Number.parseInt(String(value !== null && value !== void 0 ? value : ""), 10);
28
+ if (!Number.isFinite(parsed) || parsed <= 0)
29
+ return fallback;
30
+ return Math.min(parsed, max);
31
+ }
32
+ function parseFloatOption(value, fallback) {
33
+ const parsed = Number.parseFloat(String(value !== null && value !== void 0 ? value : ""));
34
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
35
+ }
36
+ function formatAgent(result, top) {
37
+ const lines = [];
38
+ const { summary, findings } = result;
39
+ lines.push(`summary\t${summary.sampledAnchors}\t${summary.codeRows}\t${summary.acceptedPairs}\t${summary.acceptedFilePairs}`);
40
+ for (const finding of findings.slice(0, top)) {
41
+ const pair = finding.representative;
42
+ lines.push([
43
+ "surprise",
44
+ finding.score.toFixed(3),
45
+ finding.maxSimilarity.toFixed(3),
46
+ String(finding.pairCount),
47
+ finding.fileA,
48
+ finding.fileB,
49
+ (0, surprising_connections_1.lineLabel)(pair.source),
50
+ (0, surprising_connections_1.lineLabel)(pair.target),
51
+ finding.reasons.join(","),
52
+ `buckets=${(0, surprising_connections_1.findingBucketLabel)(finding, summary.options.dirDepth)}`,
53
+ `top_sims=${finding.topSimilarities.join(",")}`,
54
+ `penalties=${(0, surprising_connections_1.formatPenaltySummary)(pair.scoreParts)}`,
55
+ `next=${(0, surprising_connections_1.skeletonHint)(finding)}`,
56
+ ].join("\t"));
57
+ }
58
+ return lines.join("\n");
59
+ }
60
+ function formatHuman(result, top) {
61
+ const { summary, findings } = result;
62
+ const out = [];
63
+ out.push(`${style.bold("Surprising connections")} ${style.dim("(experimental, embedding-similar but graph-disconnected file pairs)")}`);
64
+ out.push(style.dim(` sampled ${summary.sampledAnchors}/${summary.codeRows} code chunks; accepted ${summary.acceptedPairs} chunk pairs across ${summary.acceptedFilePairs} file pairs`));
65
+ out.push(style.dim(` score p50/p90/max ${summary.actionabilityScore.p50}/${summary.actionabilityScore.p90}/${summary.actionabilityScore.max}; graph file edges filtered ${summary.filters.graphEdge}`));
66
+ out.push("");
67
+ if (findings.length === 0) {
68
+ out.push(style.dim(" none"));
69
+ return out.join("\n");
70
+ }
71
+ for (const finding of findings.slice(0, top)) {
72
+ const pair = finding.representative;
73
+ out.push(` ${style.cyan(`score=${finding.score.toFixed(3)}`)} sim=${finding.maxSimilarity.toFixed(3)} pairs=${finding.pairCount} ${finding.fileA}`);
74
+ out.push(` <-> ${finding.fileB}`);
75
+ out.push(` best: ${(0, surprising_connections_1.lineLabel)(pair.source)} <-> ${(0, surprising_connections_1.lineLabel)(pair.target)}`);
76
+ out.push(` detail: no static file edge; buckets=${(0, surprising_connections_1.findingBucketLabel)(finding, summary.options.dirDepth)}; top_sims=${finding.topSimilarities.join(",")}`);
77
+ out.push(` reasons: ${finding.reasons.join(", ") || "none"}; penalties=${(0, surprising_connections_1.formatPenaltySummary)(pair.scoreParts)}`);
78
+ const examples = (0, surprising_connections_1.findingExamples)(finding, 2);
79
+ if (examples.length > 1) {
80
+ out.push(` examples: ${examples
81
+ .map((example) => `${(0, surprising_connections_1.lineLabel)(example.source)} <-> ${(0, surprising_connections_1.lineLabel)(example.target)}`)
82
+ .join("; ")}`);
83
+ }
84
+ out.push(` next: ${(0, surprising_connections_1.skeletonHint)(finding)}`);
85
+ }
86
+ return out.join("\n");
87
+ }
88
+ exports.surprises = new commander_1.Command("surprises")
89
+ .description("Experimental: find embedding-similar cross-directory file pairs not already connected by the static graph")
90
+ .option("--experimental", "Required acknowledgement for this experimental signal", false)
91
+ .option("--root <dir>", "Project root directory")
92
+ .option("--sample <n>", "How many indexed code chunks to sample", String(surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.sample))
93
+ .option("--neighbors <n>", "Nearest neighbors to inspect per sampled chunk", String(surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.neighbors))
94
+ .option("--top <n>", "How many grouped findings to show", "20")
95
+ .option("--dir-depth <n>", "Directory bucket depth considered unsurprising", String(surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.dirDepth))
96
+ .option("--min-sim <n>", "Minimum similarity 0-1", String(surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.minSimilarity))
97
+ .option("--max-rows <n>", `Maximum indexed rows to scan (capped at ${surprising_connections_1.MAX_SURPRISE_ROWS})`, String(surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.maxRows))
98
+ .option("--include-tests", "Include test files", false)
99
+ .option("--include-eval", "Include eval/experiment/script files", false)
100
+ .option("--in <subpath>", "Restrict to a sub-path of the project (repeatable)", (value, prev) => prev ? [...prev, value] : [value])
101
+ .option("--exclude <subpath>", "Exclude a sub-path of the project (repeatable)", (value, prev) => prev ? [...prev, value] : [value])
102
+ .option("--agent", "Compact TSV output for AI agents", false)
103
+ .action((opts) => __awaiter(void 0, void 0, void 0, function* () {
104
+ var _a;
105
+ if (!opts.experimental) {
106
+ const msg = "`gmax surprises` is experimental; rerun with --experimental.";
107
+ console.error(opts.agent ? `error\texperimental_required\t${msg}` : msg);
108
+ process.exitCode = 1;
109
+ return;
110
+ }
111
+ let vectorDb = null;
112
+ try {
113
+ const root = (0, project_registry_1.resolveRootOrExit)(opts.root);
114
+ if (root === null)
115
+ return;
116
+ const projectRoot = (_a = (0, project_root_1.findProjectRoot)(root)) !== null && _a !== void 0 ? _a : root;
117
+ (0, stale_hint_1.maybeWarnStaleChunker)(projectRoot, { agent: opts.agent });
118
+ (0, stale_hint_1.maybeWarnStaleEmbedding)(projectRoot, { agent: opts.agent });
119
+ const paths = (0, project_root_1.ensureProjectPaths)(projectRoot);
120
+ vectorDb = new vector_db_1.VectorDB(paths.lancedbDir);
121
+ const table = yield vectorDb.ensureTable();
122
+ const top = parseIntOption(opts.top, 20, 100);
123
+ const result = yield (0, surprising_connections_1.analyzeSurprisingConnections)(table, projectRoot, {
124
+ sample: parseIntOption(opts.sample, surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.sample),
125
+ neighbors: parseIntOption(opts.neighbors, surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.neighbors, 200),
126
+ dirDepth: parseIntOption(opts.dirDepth, surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.dirDepth, 20),
127
+ minSimilarity: parseFloatOption(opts.minSim, surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.minSimilarity),
128
+ maxRows: parseIntOption(opts.maxRows, surprising_connections_1.DEFAULT_SURPRISE_OPTIONS.maxRows, surprising_connections_1.MAX_SURPRISE_ROWS),
129
+ includeTests: Boolean(opts.includeTests),
130
+ includeEval: Boolean(opts.includeEval),
131
+ in: opts.in,
132
+ exclude: opts.exclude,
133
+ });
134
+ console.log(opts.agent ? formatAgent(result, top) : formatHuman(result, top));
135
+ }
136
+ catch (error) {
137
+ const msg = error instanceof Error ? error.message : "Unknown error";
138
+ console.error("Surprises failed:", msg);
139
+ process.exitCode = 1;
140
+ }
141
+ finally {
142
+ if (vectorDb) {
143
+ try {
144
+ yield vectorDb.close();
145
+ }
146
+ catch (_b) { }
147
+ }
148
+ yield (0, exit_1.gracefulExit)();
149
+ }
150
+ }));