@launchsecure/launch-kit 0.0.2 → 0.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/server/cli.js +191 -22
- package/dist/server/graph-mcp-entry.js +191 -22
- package/package.json +1 -1
package/dist/server/cli.js
CHANGED
|
@@ -7444,7 +7444,7 @@ var TOOLS = [
|
|
|
7444
7444
|
},
|
|
7445
7445
|
{
|
|
7446
7446
|
name: "read_graph",
|
|
7447
|
-
description: 'Query the structural project graph \u2014 a smart Glob replacement that locates files by type/module/name and returns structural metadata (imports, renders, routes, relations). \n\nUSE THIS FOR: "where is X", "what files are in module Y", "what pages exist under /admin", "what components does Z render", "what tables relate to User", "list all hooks in auth module". \n\nDO NOT USE FOR: finding text/code content (use Grep), reading actual source code (use Read), understanding behavior/logic/patterns (graph has no code semantics \u2014 only names and edges). \n\nQUERY PARAMS (at least one required for node data \u2014 unfiltered calls return summary only to stay in context):\n- search: substring match on node id, name, or route\n- type: filter by node type (ui layer: page, layout, component, ui, hook, context, config, util; api layer: endpoint; db layer: table, enum)\n- module: filter by module (ui layer only: auth, admin, project, org, settings, integrations, shared-ui, layout, root)\n- node_id: return this node + its neighborhood (incoming+outgoing edges within `hops`)\n- hops: neighborhood radius when node_id is set (default 1)\n- minimal: return only id/type/name/module/route per node (skip heavy fields like columns, exports)\n\nBATCH MODE: pass `queries` (array of query objects) to run multiple independent queries in a single call. Each query object uses the same params (layer/search/type/module/node_id/hops/minimal). Returns { batch: true, count, results: [{index, query, result}, ...] }. Use this when you need multiple graph views up-front (e.g. scoping a feature across ui+api+db layers) to save round-trips. When batch mode is used, top-level params are ignored.\n\nReturns: filtered nodes + edges between them. If no filter given, returns per-layer counts and type breakdown only.',
|
|
7447
|
+
description: 'Query the structural project graph \u2014 a smart Glob replacement that locates files by type/module/name and returns structural metadata (imports, renders, routes, relations). \n\nUSE THIS FOR: "where is X", "what files are in module Y", "what pages exist under /admin", "what components does Z render", "what tables relate to User", "list all hooks in auth module". \n\nDO NOT USE FOR: finding text/code content (use Grep), reading actual source code (use Read), understanding behavior/logic/patterns (graph has no code semantics \u2014 only names and edges). \n\nQUERY PARAMS (at least one required for node data \u2014 unfiltered calls return summary only to stay in context):\n- search: substring match on node id, name, or route\n- type: filter by node type (ui layer: page, layout, component, ui, hook, context, config, util; api layer: endpoint; db layer: table, enum)\n- module: filter by module (ui layer only: auth, admin, project, org, settings, integrations, shared-ui, layout, root)\n- node_id: return this node + its neighborhood (incoming+outgoing edges within `hops`)\n- hops: neighborhood radius when node_id is set (default 1)\n- minimal: return only id/type/name/module/route per node (skip heavy fields like columns, exports)\n- include_edges: return the actual edge list. Default: TRUE for neighborhood queries (node_id), FALSE for filter queries (search/type/module). Filter responses always include `edge_count`; only pass include_edges:true when you actually need to inspect individual edges (e.g. "which components render X"). This default cuts typical filter responses in half.\n\nBATCH MODE: pass `queries` (array of query objects) to run multiple independent queries in a single call. Each query object uses the same params (layer/search/type/module/node_id/hops/minimal). Returns { batch: true, count, results: [{index, query, result}, ...] }. Use this when you need multiple graph views up-front (e.g. scoping a feature across ui+api+db layers) to save round-trips. When batch mode is used, top-level params are ignored.\n\nReturns: filtered nodes + edges between them. If no filter given, returns per-layer counts and type breakdown only.\n\nWIRE FORMAT (compact): responses that include nodes/edges use short keys and edge-by-index refs to cut payload ~40-60%. Every such response carries a `_schema` legend. Quick reference:\n nodes[]: { i: id, t: type, n: name, m: module, r: route, mt: methods, x: exports, c: columns }\n edges[]: { s: source_node_index, d: target_node_index, t: type, l: label }\nedges.s / edges.d are 0-based indices into THIS response\'s nodes array. If a referenced node is not in the response (boundary case), s/d may instead contain the full node id string \u2014 always check the type.\n\nBUDGET GUARDS:\n- Neighborhood queries stop expanding when the projected response exceeds budget. The response then contains `budget_exceeded: true` plus `hops_traversed < hops_requested`. When this happens, drill into a specific neighbor with another node_id call rather than retrying with larger hops \u2014 it will just truncate again.\n- Batch mode caps total response size. Once the budget is hit, later queries return `{skipped: true, reason: "batch_budget_exhausted"}` and you must re-run them individually.',
|
|
7448
7448
|
inputSchema: {
|
|
7449
7449
|
type: "object",
|
|
7450
7450
|
properties: {
|
|
@@ -7475,11 +7475,15 @@ var TOOLS = [
|
|
|
7475
7475
|
},
|
|
7476
7476
|
minimal: {
|
|
7477
7477
|
type: "boolean",
|
|
7478
|
-
description: "Return minimal node fields only (id, type, name, module, route). Default false."
|
|
7478
|
+
description: "Return minimal node fields only (id, type, name, module, route). Default false, except db-layer filter queries default to true (db table nodes carry heavy `columns` arrays). Pass minimal:false on a db filter query if you want columns."
|
|
7479
|
+
},
|
|
7480
|
+
include_edges: {
|
|
7481
|
+
type: "boolean",
|
|
7482
|
+
description: "Include the edge list in the response. Default TRUE for neighborhood queries (node_id), FALSE for filter queries. Filter responses always include edge_count. Only set true on filter queries when you actually need edge data."
|
|
7479
7483
|
},
|
|
7480
7484
|
queries: {
|
|
7481
7485
|
type: "array",
|
|
7482
|
-
description: "Batch mode \u2014 array of query objects to run in a single call. Each uses the same param schema
|
|
7486
|
+
description: "Batch mode \u2014 array of query objects to run in a single call. Each uses the same param schema. When set, top-level params are ignored. Subject to an aggregate size budget \u2014 later queries may return a skipped stub.",
|
|
7483
7487
|
items: {
|
|
7484
7488
|
type: "object",
|
|
7485
7489
|
properties: {
|
|
@@ -7489,7 +7493,8 @@ var TOOLS = [
|
|
|
7489
7493
|
module: { type: "string" },
|
|
7490
7494
|
node_id: { type: "string" },
|
|
7491
7495
|
hops: { type: "number" },
|
|
7492
|
-
minimal: { type: "boolean" }
|
|
7496
|
+
minimal: { type: "boolean" },
|
|
7497
|
+
include_edges: { type: "boolean" }
|
|
7493
7498
|
}
|
|
7494
7499
|
}
|
|
7495
7500
|
}
|
|
@@ -7556,30 +7561,134 @@ function matchesSearch(node, query) {
|
|
|
7556
7561
|
function toMinimal(nodes) {
|
|
7557
7562
|
return nodes.map((n) => {
|
|
7558
7563
|
const out = { id: n.id, type: n.type, name: n.name };
|
|
7559
|
-
if (n.module) out.module = n.module;
|
|
7560
|
-
if (n.route) out.route = n.route;
|
|
7561
|
-
if (n.methods) out.methods = n.methods;
|
|
7564
|
+
if (n.module != null) out.module = n.module;
|
|
7565
|
+
if (n.route != null) out.route = n.route;
|
|
7566
|
+
if (n.methods != null) out.methods = n.methods;
|
|
7562
7567
|
return out;
|
|
7563
7568
|
});
|
|
7564
7569
|
}
|
|
7565
|
-
|
|
7570
|
+
var COMPACT_SCHEMA = {
|
|
7571
|
+
nodes: {
|
|
7572
|
+
i: "id",
|
|
7573
|
+
t: "type",
|
|
7574
|
+
n: "name",
|
|
7575
|
+
m: "module",
|
|
7576
|
+
r: "route",
|
|
7577
|
+
mt: "methods",
|
|
7578
|
+
x: "exports",
|
|
7579
|
+
c: "columns"
|
|
7580
|
+
},
|
|
7581
|
+
edges: {
|
|
7582
|
+
s: "source_node_index",
|
|
7583
|
+
d: "target_node_index",
|
|
7584
|
+
t: "type",
|
|
7585
|
+
l: "label"
|
|
7586
|
+
},
|
|
7587
|
+
note: "edges.s/d are 0-based indices into this response's nodes array. If a referenced node is outside the response (boundary case), s/d may contain the full node id string instead of an index."
|
|
7588
|
+
};
|
|
7589
|
+
var COMPACT_NODE_KNOWN_KEYS = /* @__PURE__ */ new Set([
|
|
7590
|
+
"id",
|
|
7591
|
+
"type",
|
|
7592
|
+
"name",
|
|
7593
|
+
"module",
|
|
7594
|
+
"route",
|
|
7595
|
+
"methods",
|
|
7596
|
+
"exports",
|
|
7597
|
+
"columns"
|
|
7598
|
+
]);
|
|
7599
|
+
var EST_CHARS_PER_NODE_FULL = {
|
|
7600
|
+
ui: 300,
|
|
7601
|
+
api: 300,
|
|
7602
|
+
db: 3500
|
|
7603
|
+
};
|
|
7604
|
+
var EST_CHARS_PER_NODE_MIN = {
|
|
7605
|
+
ui: 150,
|
|
7606
|
+
api: 200,
|
|
7607
|
+
db: 120
|
|
7608
|
+
};
|
|
7609
|
+
var EST_CHARS_PER_EDGE = {
|
|
7610
|
+
ui: 65,
|
|
7611
|
+
api: 65,
|
|
7612
|
+
db: 65
|
|
7613
|
+
};
|
|
7614
|
+
var NEIGHBORHOOD_BUDGET_CHARS = 55e3;
|
|
7615
|
+
var BATCH_BUDGET_CHARS = 6e4;
|
|
7616
|
+
function toCompactNode(n) {
|
|
7617
|
+
const out = { i: n.id, t: n.type, n: n.name };
|
|
7618
|
+
if (n.module != null) out.m = n.module;
|
|
7619
|
+
if (n.route != null) out.r = n.route;
|
|
7620
|
+
if (n.methods != null) out.mt = n.methods;
|
|
7621
|
+
if (n.exports != null) out.x = n.exports;
|
|
7622
|
+
if (n.columns != null) out.c = n.columns;
|
|
7623
|
+
for (const k of Object.keys(n)) {
|
|
7624
|
+
if (!COMPACT_NODE_KNOWN_KEYS.has(k) && n[k] != null) out[k] = n[k];
|
|
7625
|
+
}
|
|
7626
|
+
return out;
|
|
7627
|
+
}
|
|
7628
|
+
function toCompactEdges(edges, idx) {
|
|
7629
|
+
return edges.map((e) => {
|
|
7630
|
+
const s = idx.get(e.source);
|
|
7631
|
+
const d = idx.get(e.target);
|
|
7632
|
+
const o = {
|
|
7633
|
+
s: s ?? e.source,
|
|
7634
|
+
d: d ?? e.target,
|
|
7635
|
+
t: e.type
|
|
7636
|
+
};
|
|
7637
|
+
if (e.label != null) o.l = e.label;
|
|
7638
|
+
return o;
|
|
7639
|
+
});
|
|
7640
|
+
}
|
|
7641
|
+
function compactResult(raw) {
|
|
7642
|
+
const nodes = raw.nodes;
|
|
7643
|
+
if (!nodes) return raw;
|
|
7644
|
+
const idx = /* @__PURE__ */ new Map();
|
|
7645
|
+
nodes.forEach((n, i) => idx.set(n.id, i));
|
|
7646
|
+
const compactNodes = nodes.map(toCompactNode);
|
|
7647
|
+
const edges = raw.edges;
|
|
7648
|
+
const compactEdges = edges ? toCompactEdges(edges, idx) : void 0;
|
|
7649
|
+
const out = { _schema: COMPACT_SCHEMA };
|
|
7650
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
7651
|
+
if (k === "nodes" || k === "edges") continue;
|
|
7652
|
+
out[k] = v;
|
|
7653
|
+
}
|
|
7654
|
+
out.nodes = compactNodes;
|
|
7655
|
+
if (compactEdges !== void 0) out.edges = compactEdges;
|
|
7656
|
+
return out;
|
|
7657
|
+
}
|
|
7658
|
+
function neighborhood(graph, centerId, hops, layer, minimal) {
|
|
7566
7659
|
const center = graph.nodes.find((n) => n.id === centerId);
|
|
7567
7660
|
if (!center) return null;
|
|
7568
7661
|
const visited = /* @__PURE__ */ new Set([centerId]);
|
|
7569
7662
|
let frontier = /* @__PURE__ */ new Set([centerId]);
|
|
7663
|
+
let budgetExceeded = false;
|
|
7664
|
+
let stoppedAtHop = 0;
|
|
7570
7665
|
for (let h = 0; h < hops; h++) {
|
|
7571
7666
|
const next = /* @__PURE__ */ new Set();
|
|
7572
7667
|
for (const edge of graph.edges) {
|
|
7573
7668
|
if (frontier.has(edge.source) && !visited.has(edge.target)) next.add(edge.target);
|
|
7574
7669
|
if (frontier.has(edge.target) && !visited.has(edge.source)) next.add(edge.source);
|
|
7575
7670
|
}
|
|
7671
|
+
const projectedVisited = visited.size + next.size;
|
|
7672
|
+
let projectedEdges = 0;
|
|
7673
|
+
for (const e of graph.edges) {
|
|
7674
|
+
const srcIn = visited.has(e.source) || next.has(e.source);
|
|
7675
|
+
const dstIn = visited.has(e.target) || next.has(e.target);
|
|
7676
|
+
if (srcIn && dstIn) projectedEdges++;
|
|
7677
|
+
}
|
|
7678
|
+
const perNode = minimal ? EST_CHARS_PER_NODE_MIN[layer] : EST_CHARS_PER_NODE_FULL[layer];
|
|
7679
|
+
const projectedChars = projectedVisited * perNode + projectedEdges * EST_CHARS_PER_EDGE[layer];
|
|
7680
|
+
if (projectedChars > NEIGHBORHOOD_BUDGET_CHARS) {
|
|
7681
|
+
budgetExceeded = true;
|
|
7682
|
+
break;
|
|
7683
|
+
}
|
|
7576
7684
|
for (const id of next) visited.add(id);
|
|
7577
7685
|
frontier = next;
|
|
7686
|
+
stoppedAtHop = h + 1;
|
|
7578
7687
|
if (frontier.size === 0) break;
|
|
7579
7688
|
}
|
|
7580
7689
|
const nodes = graph.nodes.filter((n) => visited.has(n.id));
|
|
7581
7690
|
const edges = graph.edges.filter((e) => visited.has(e.source) && visited.has(e.target));
|
|
7582
|
-
return { nodes, edges };
|
|
7691
|
+
return { nodes, edges, budgetExceeded, stoppedAtHop };
|
|
7583
7692
|
}
|
|
7584
7693
|
function layerSummary(graph) {
|
|
7585
7694
|
const typeCounts = {};
|
|
@@ -7638,14 +7747,16 @@ Output: .launchsecure/graphs/
|
|
|
7638
7747
|
Use read_graph with filters (search/type/module/node_id) to query.`
|
|
7639
7748
|
);
|
|
7640
7749
|
}
|
|
7641
|
-
function
|
|
7750
|
+
function runReadGraphQueryRaw(rootDir, args) {
|
|
7642
7751
|
const layer = args.layer;
|
|
7643
7752
|
const search = args.search;
|
|
7644
7753
|
const type = args.type;
|
|
7645
7754
|
const module_ = args.module;
|
|
7646
7755
|
const nodeId = args.node_id;
|
|
7647
7756
|
const hops = args.hops ?? 1;
|
|
7648
|
-
const
|
|
7757
|
+
const layerIsDb = args.layer === "db";
|
|
7758
|
+
const minimal = args.minimal ?? layerIsDb;
|
|
7759
|
+
const includeEdges = args.include_edges;
|
|
7649
7760
|
const hasFilter = !!(search || type || module_ || nodeId);
|
|
7650
7761
|
if (layer && !["ui", "api", "db"].includes(layer)) {
|
|
7651
7762
|
return { error: `Invalid layer "${layer}". Must be one of: ui, api, db` };
|
|
@@ -7671,19 +7782,26 @@ function runReadGraphQuery(rootDir, args) {
|
|
|
7671
7782
|
return { error: `No ${layer} graph found at .launchsecure/graphs/${layer}.json. Run generate_graph first.` };
|
|
7672
7783
|
}
|
|
7673
7784
|
if (nodeId) {
|
|
7674
|
-
const nb = neighborhood(graph, nodeId, hops);
|
|
7785
|
+
const nb = neighborhood(graph, nodeId, hops, layer, minimal);
|
|
7675
7786
|
if (!nb) {
|
|
7676
7787
|
return { error: `Node "${nodeId}" not found in ${layer} graph. Try read_graph with search="${nodeId}" to find similar nodes.` };
|
|
7677
7788
|
}
|
|
7678
|
-
|
|
7789
|
+
const wantEdges2 = includeEdges ?? true;
|
|
7790
|
+
const result2 = {
|
|
7679
7791
|
layer,
|
|
7680
7792
|
center: nodeId,
|
|
7681
|
-
hops,
|
|
7793
|
+
hops_requested: hops,
|
|
7794
|
+
hops_traversed: nb.stoppedAtHop,
|
|
7682
7795
|
node_count: nb.nodes.length,
|
|
7683
7796
|
edge_count: nb.edges.length,
|
|
7684
|
-
nodes: minimal ? toMinimal(nb.nodes) : nb.nodes
|
|
7685
|
-
edges: nb.edges
|
|
7797
|
+
nodes: minimal ? toMinimal(nb.nodes) : nb.nodes
|
|
7686
7798
|
};
|
|
7799
|
+
if (wantEdges2) result2.edges = nb.edges;
|
|
7800
|
+
if (nb.budgetExceeded) {
|
|
7801
|
+
result2.budget_exceeded = true;
|
|
7802
|
+
result2.hint = `Neighborhood truncated at hop ${nb.stoppedAtHop} (projected size exceeded budget). To explore further, call read_graph with node_id set to a specific neighbor from the returned nodes, or rerun with hops=${Math.max(1, nb.stoppedAtHop)} to confirm the partial view is what you wanted.`;
|
|
7803
|
+
}
|
|
7804
|
+
return result2;
|
|
7687
7805
|
}
|
|
7688
7806
|
if (!hasFilter) {
|
|
7689
7807
|
return {
|
|
@@ -7708,14 +7826,24 @@ function runReadGraphQuery(rootDir, args) {
|
|
|
7708
7826
|
hint: "No nodes matched. Check spelling, or call read_graph without filter to see the summary and available types/modules."
|
|
7709
7827
|
};
|
|
7710
7828
|
}
|
|
7711
|
-
|
|
7829
|
+
const wantEdges = includeEdges ?? false;
|
|
7830
|
+
const result = {
|
|
7712
7831
|
layer,
|
|
7713
7832
|
filter: { search, type, module: module_ },
|
|
7714
7833
|
matched: matched.length,
|
|
7715
7834
|
edge_count: matchedEdges.length,
|
|
7716
|
-
nodes: minimal ? toMinimal(matched) : matched
|
|
7717
|
-
edges: matchedEdges
|
|
7835
|
+
nodes: minimal ? toMinimal(matched) : matched
|
|
7718
7836
|
};
|
|
7837
|
+
if (wantEdges) {
|
|
7838
|
+
result.edges = matchedEdges;
|
|
7839
|
+
} else {
|
|
7840
|
+
result.edges_hint = `${matchedEdges.length} edges between matched nodes omitted. Pass include_edges:true to retrieve them (only do this when you actually need edge data).`;
|
|
7841
|
+
}
|
|
7842
|
+
return result;
|
|
7843
|
+
}
|
|
7844
|
+
function runReadGraphQuery(rootDir, args) {
|
|
7845
|
+
const raw = runReadGraphQueryRaw(rootDir, args);
|
|
7846
|
+
return compactResult(raw);
|
|
7719
7847
|
}
|
|
7720
7848
|
function handleReadGraph(args) {
|
|
7721
7849
|
const rootDir = process.cwd();
|
|
@@ -7724,8 +7852,49 @@ function handleReadGraph(args) {
|
|
|
7724
7852
|
if (queries.length === 0) {
|
|
7725
7853
|
return err("queries array is empty. Provide at least one query object.");
|
|
7726
7854
|
}
|
|
7727
|
-
const results =
|
|
7728
|
-
|
|
7855
|
+
const results = [];
|
|
7856
|
+
let cumulativeChars = 0;
|
|
7857
|
+
let budgetHit = false;
|
|
7858
|
+
for (let i = 0; i < queries.length; i++) {
|
|
7859
|
+
const q = queries[i];
|
|
7860
|
+
if (budgetHit) {
|
|
7861
|
+
results.push({
|
|
7862
|
+
index: i,
|
|
7863
|
+
query: q,
|
|
7864
|
+
result: {
|
|
7865
|
+
skipped: true,
|
|
7866
|
+
reason: "batch_budget_exhausted",
|
|
7867
|
+
hint: "Previous sub-results filled the batch budget. Re-run this query on its own."
|
|
7868
|
+
}
|
|
7869
|
+
});
|
|
7870
|
+
continue;
|
|
7871
|
+
}
|
|
7872
|
+
const r = runReadGraphQuery(rootDir, q);
|
|
7873
|
+
const entry = { index: i, query: q, result: r };
|
|
7874
|
+
const entrySize = JSON.stringify(entry, null, 2).length;
|
|
7875
|
+
if (cumulativeChars + entrySize > BATCH_BUDGET_CHARS && results.length > 0) {
|
|
7876
|
+
budgetHit = true;
|
|
7877
|
+
results.push({
|
|
7878
|
+
index: i,
|
|
7879
|
+
query: q,
|
|
7880
|
+
result: {
|
|
7881
|
+
skipped: true,
|
|
7882
|
+
reason: "batch_budget_exhausted",
|
|
7883
|
+
hint: "This sub-query would push the batch over its size budget. Re-run it on its own."
|
|
7884
|
+
}
|
|
7885
|
+
});
|
|
7886
|
+
} else {
|
|
7887
|
+
results.push(entry);
|
|
7888
|
+
cumulativeChars += entrySize;
|
|
7889
|
+
}
|
|
7890
|
+
}
|
|
7891
|
+
return okJson({
|
|
7892
|
+
batch: true,
|
|
7893
|
+
count: results.length,
|
|
7894
|
+
cumulative_chars: cumulativeChars,
|
|
7895
|
+
budget_hit: budgetHit,
|
|
7896
|
+
results
|
|
7897
|
+
});
|
|
7729
7898
|
}
|
|
7730
7899
|
const result = runReadGraphQuery(rootDir, args);
|
|
7731
7900
|
return okJson(result);
|
|
@@ -7755,7 +7924,7 @@ function handleGrepNodes(args) {
|
|
|
7755
7924
|
} catch (e) {
|
|
7756
7925
|
return err(`Invalid regex: ${e.message}`);
|
|
7757
7926
|
}
|
|
7758
|
-
const queryResult =
|
|
7927
|
+
const queryResult = runReadGraphQueryRaw(rootDir, {
|
|
7759
7928
|
layer,
|
|
7760
7929
|
search: args.search,
|
|
7761
7930
|
type: args.type,
|
|
@@ -1069,7 +1069,7 @@ var TOOLS = [
|
|
|
1069
1069
|
},
|
|
1070
1070
|
{
|
|
1071
1071
|
name: "read_graph",
|
|
1072
|
-
description: 'Query the structural project graph \u2014 a smart Glob replacement that locates files by type/module/name and returns structural metadata (imports, renders, routes, relations). \n\nUSE THIS FOR: "where is X", "what files are in module Y", "what pages exist under /admin", "what components does Z render", "what tables relate to User", "list all hooks in auth module". \n\nDO NOT USE FOR: finding text/code content (use Grep), reading actual source code (use Read), understanding behavior/logic/patterns (graph has no code semantics \u2014 only names and edges). \n\nQUERY PARAMS (at least one required for node data \u2014 unfiltered calls return summary only to stay in context):\n- search: substring match on node id, name, or route\n- type: filter by node type (ui layer: page, layout, component, ui, hook, context, config, util; api layer: endpoint; db layer: table, enum)\n- module: filter by module (ui layer only: auth, admin, project, org, settings, integrations, shared-ui, layout, root)\n- node_id: return this node + its neighborhood (incoming+outgoing edges within `hops`)\n- hops: neighborhood radius when node_id is set (default 1)\n- minimal: return only id/type/name/module/route per node (skip heavy fields like columns, exports)\n\nBATCH MODE: pass `queries` (array of query objects) to run multiple independent queries in a single call. Each query object uses the same params (layer/search/type/module/node_id/hops/minimal). Returns { batch: true, count, results: [{index, query, result}, ...] }. Use this when you need multiple graph views up-front (e.g. scoping a feature across ui+api+db layers) to save round-trips. When batch mode is used, top-level params are ignored.\n\nReturns: filtered nodes + edges between them. If no filter given, returns per-layer counts and type breakdown only.',
|
|
1072
|
+
description: 'Query the structural project graph \u2014 a smart Glob replacement that locates files by type/module/name and returns structural metadata (imports, renders, routes, relations). \n\nUSE THIS FOR: "where is X", "what files are in module Y", "what pages exist under /admin", "what components does Z render", "what tables relate to User", "list all hooks in auth module". \n\nDO NOT USE FOR: finding text/code content (use Grep), reading actual source code (use Read), understanding behavior/logic/patterns (graph has no code semantics \u2014 only names and edges). \n\nQUERY PARAMS (at least one required for node data \u2014 unfiltered calls return summary only to stay in context):\n- search: substring match on node id, name, or route\n- type: filter by node type (ui layer: page, layout, component, ui, hook, context, config, util; api layer: endpoint; db layer: table, enum)\n- module: filter by module (ui layer only: auth, admin, project, org, settings, integrations, shared-ui, layout, root)\n- node_id: return this node + its neighborhood (incoming+outgoing edges within `hops`)\n- hops: neighborhood radius when node_id is set (default 1)\n- minimal: return only id/type/name/module/route per node (skip heavy fields like columns, exports)\n- include_edges: return the actual edge list. Default: TRUE for neighborhood queries (node_id), FALSE for filter queries (search/type/module). Filter responses always include `edge_count`; only pass include_edges:true when you actually need to inspect individual edges (e.g. "which components render X"). This default cuts typical filter responses in half.\n\nBATCH MODE: pass `queries` (array of query objects) to run multiple independent queries in a single call. Each query object uses the same params (layer/search/type/module/node_id/hops/minimal). Returns { batch: true, count, results: [{index, query, result}, ...] }. Use this when you need multiple graph views up-front (e.g. scoping a feature across ui+api+db layers) to save round-trips. When batch mode is used, top-level params are ignored.\n\nReturns: filtered nodes + edges between them. If no filter given, returns per-layer counts and type breakdown only.\n\nWIRE FORMAT (compact): responses that include nodes/edges use short keys and edge-by-index refs to cut payload ~40-60%. Every such response carries a `_schema` legend. Quick reference:\n nodes[]: { i: id, t: type, n: name, m: module, r: route, mt: methods, x: exports, c: columns }\n edges[]: { s: source_node_index, d: target_node_index, t: type, l: label }\nedges.s / edges.d are 0-based indices into THIS response\'s nodes array. If a referenced node is not in the response (boundary case), s/d may instead contain the full node id string \u2014 always check the type.\n\nBUDGET GUARDS:\n- Neighborhood queries stop expanding when the projected response exceeds budget. The response then contains `budget_exceeded: true` plus `hops_traversed < hops_requested`. When this happens, drill into a specific neighbor with another node_id call rather than retrying with larger hops \u2014 it will just truncate again.\n- Batch mode caps total response size. Once the budget is hit, later queries return `{skipped: true, reason: "batch_budget_exhausted"}` and you must re-run them individually.',
|
|
1073
1073
|
inputSchema: {
|
|
1074
1074
|
type: "object",
|
|
1075
1075
|
properties: {
|
|
@@ -1100,11 +1100,15 @@ var TOOLS = [
|
|
|
1100
1100
|
},
|
|
1101
1101
|
minimal: {
|
|
1102
1102
|
type: "boolean",
|
|
1103
|
-
description: "Return minimal node fields only (id, type, name, module, route). Default false."
|
|
1103
|
+
description: "Return minimal node fields only (id, type, name, module, route). Default false, except db-layer filter queries default to true (db table nodes carry heavy `columns` arrays). Pass minimal:false on a db filter query if you want columns."
|
|
1104
|
+
},
|
|
1105
|
+
include_edges: {
|
|
1106
|
+
type: "boolean",
|
|
1107
|
+
description: "Include the edge list in the response. Default TRUE for neighborhood queries (node_id), FALSE for filter queries. Filter responses always include edge_count. Only set true on filter queries when you actually need edge data."
|
|
1104
1108
|
},
|
|
1105
1109
|
queries: {
|
|
1106
1110
|
type: "array",
|
|
1107
|
-
description: "Batch mode \u2014 array of query objects to run in a single call. Each uses the same param schema
|
|
1111
|
+
description: "Batch mode \u2014 array of query objects to run in a single call. Each uses the same param schema. When set, top-level params are ignored. Subject to an aggregate size budget \u2014 later queries may return a skipped stub.",
|
|
1108
1112
|
items: {
|
|
1109
1113
|
type: "object",
|
|
1110
1114
|
properties: {
|
|
@@ -1114,7 +1118,8 @@ var TOOLS = [
|
|
|
1114
1118
|
module: { type: "string" },
|
|
1115
1119
|
node_id: { type: "string" },
|
|
1116
1120
|
hops: { type: "number" },
|
|
1117
|
-
minimal: { type: "boolean" }
|
|
1121
|
+
minimal: { type: "boolean" },
|
|
1122
|
+
include_edges: { type: "boolean" }
|
|
1118
1123
|
}
|
|
1119
1124
|
}
|
|
1120
1125
|
}
|
|
@@ -1181,30 +1186,134 @@ function matchesSearch(node, query) {
|
|
|
1181
1186
|
function toMinimal(nodes) {
|
|
1182
1187
|
return nodes.map((n) => {
|
|
1183
1188
|
const out = { id: n.id, type: n.type, name: n.name };
|
|
1184
|
-
if (n.module) out.module = n.module;
|
|
1185
|
-
if (n.route) out.route = n.route;
|
|
1186
|
-
if (n.methods) out.methods = n.methods;
|
|
1189
|
+
if (n.module != null) out.module = n.module;
|
|
1190
|
+
if (n.route != null) out.route = n.route;
|
|
1191
|
+
if (n.methods != null) out.methods = n.methods;
|
|
1187
1192
|
return out;
|
|
1188
1193
|
});
|
|
1189
1194
|
}
|
|
1190
|
-
|
|
1195
|
+
var COMPACT_SCHEMA = {
|
|
1196
|
+
nodes: {
|
|
1197
|
+
i: "id",
|
|
1198
|
+
t: "type",
|
|
1199
|
+
n: "name",
|
|
1200
|
+
m: "module",
|
|
1201
|
+
r: "route",
|
|
1202
|
+
mt: "methods",
|
|
1203
|
+
x: "exports",
|
|
1204
|
+
c: "columns"
|
|
1205
|
+
},
|
|
1206
|
+
edges: {
|
|
1207
|
+
s: "source_node_index",
|
|
1208
|
+
d: "target_node_index",
|
|
1209
|
+
t: "type",
|
|
1210
|
+
l: "label"
|
|
1211
|
+
},
|
|
1212
|
+
note: "edges.s/d are 0-based indices into this response's nodes array. If a referenced node is outside the response (boundary case), s/d may contain the full node id string instead of an index."
|
|
1213
|
+
};
|
|
1214
|
+
var COMPACT_NODE_KNOWN_KEYS = /* @__PURE__ */ new Set([
|
|
1215
|
+
"id",
|
|
1216
|
+
"type",
|
|
1217
|
+
"name",
|
|
1218
|
+
"module",
|
|
1219
|
+
"route",
|
|
1220
|
+
"methods",
|
|
1221
|
+
"exports",
|
|
1222
|
+
"columns"
|
|
1223
|
+
]);
|
|
1224
|
+
var EST_CHARS_PER_NODE_FULL = {
|
|
1225
|
+
ui: 300,
|
|
1226
|
+
api: 300,
|
|
1227
|
+
db: 3500
|
|
1228
|
+
};
|
|
1229
|
+
var EST_CHARS_PER_NODE_MIN = {
|
|
1230
|
+
ui: 150,
|
|
1231
|
+
api: 200,
|
|
1232
|
+
db: 120
|
|
1233
|
+
};
|
|
1234
|
+
var EST_CHARS_PER_EDGE = {
|
|
1235
|
+
ui: 65,
|
|
1236
|
+
api: 65,
|
|
1237
|
+
db: 65
|
|
1238
|
+
};
|
|
1239
|
+
var NEIGHBORHOOD_BUDGET_CHARS = 55e3;
|
|
1240
|
+
var BATCH_BUDGET_CHARS = 6e4;
|
|
1241
|
+
function toCompactNode(n) {
|
|
1242
|
+
const out = { i: n.id, t: n.type, n: n.name };
|
|
1243
|
+
if (n.module != null) out.m = n.module;
|
|
1244
|
+
if (n.route != null) out.r = n.route;
|
|
1245
|
+
if (n.methods != null) out.mt = n.methods;
|
|
1246
|
+
if (n.exports != null) out.x = n.exports;
|
|
1247
|
+
if (n.columns != null) out.c = n.columns;
|
|
1248
|
+
for (const k of Object.keys(n)) {
|
|
1249
|
+
if (!COMPACT_NODE_KNOWN_KEYS.has(k) && n[k] != null) out[k] = n[k];
|
|
1250
|
+
}
|
|
1251
|
+
return out;
|
|
1252
|
+
}
|
|
1253
|
+
function toCompactEdges(edges, idx) {
|
|
1254
|
+
return edges.map((e) => {
|
|
1255
|
+
const s = idx.get(e.source);
|
|
1256
|
+
const d = idx.get(e.target);
|
|
1257
|
+
const o = {
|
|
1258
|
+
s: s ?? e.source,
|
|
1259
|
+
d: d ?? e.target,
|
|
1260
|
+
t: e.type
|
|
1261
|
+
};
|
|
1262
|
+
if (e.label != null) o.l = e.label;
|
|
1263
|
+
return o;
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
function compactResult(raw) {
|
|
1267
|
+
const nodes = raw.nodes;
|
|
1268
|
+
if (!nodes) return raw;
|
|
1269
|
+
const idx = /* @__PURE__ */ new Map();
|
|
1270
|
+
nodes.forEach((n, i) => idx.set(n.id, i));
|
|
1271
|
+
const compactNodes = nodes.map(toCompactNode);
|
|
1272
|
+
const edges = raw.edges;
|
|
1273
|
+
const compactEdges = edges ? toCompactEdges(edges, idx) : void 0;
|
|
1274
|
+
const out = { _schema: COMPACT_SCHEMA };
|
|
1275
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
1276
|
+
if (k === "nodes" || k === "edges") continue;
|
|
1277
|
+
out[k] = v;
|
|
1278
|
+
}
|
|
1279
|
+
out.nodes = compactNodes;
|
|
1280
|
+
if (compactEdges !== void 0) out.edges = compactEdges;
|
|
1281
|
+
return out;
|
|
1282
|
+
}
|
|
1283
|
+
function neighborhood(graph, centerId, hops, layer, minimal) {
|
|
1191
1284
|
const center = graph.nodes.find((n) => n.id === centerId);
|
|
1192
1285
|
if (!center) return null;
|
|
1193
1286
|
const visited = /* @__PURE__ */ new Set([centerId]);
|
|
1194
1287
|
let frontier = /* @__PURE__ */ new Set([centerId]);
|
|
1288
|
+
let budgetExceeded = false;
|
|
1289
|
+
let stoppedAtHop = 0;
|
|
1195
1290
|
for (let h = 0; h < hops; h++) {
|
|
1196
1291
|
const next = /* @__PURE__ */ new Set();
|
|
1197
1292
|
for (const edge of graph.edges) {
|
|
1198
1293
|
if (frontier.has(edge.source) && !visited.has(edge.target)) next.add(edge.target);
|
|
1199
1294
|
if (frontier.has(edge.target) && !visited.has(edge.source)) next.add(edge.source);
|
|
1200
1295
|
}
|
|
1296
|
+
const projectedVisited = visited.size + next.size;
|
|
1297
|
+
let projectedEdges = 0;
|
|
1298
|
+
for (const e of graph.edges) {
|
|
1299
|
+
const srcIn = visited.has(e.source) || next.has(e.source);
|
|
1300
|
+
const dstIn = visited.has(e.target) || next.has(e.target);
|
|
1301
|
+
if (srcIn && dstIn) projectedEdges++;
|
|
1302
|
+
}
|
|
1303
|
+
const perNode = minimal ? EST_CHARS_PER_NODE_MIN[layer] : EST_CHARS_PER_NODE_FULL[layer];
|
|
1304
|
+
const projectedChars = projectedVisited * perNode + projectedEdges * EST_CHARS_PER_EDGE[layer];
|
|
1305
|
+
if (projectedChars > NEIGHBORHOOD_BUDGET_CHARS) {
|
|
1306
|
+
budgetExceeded = true;
|
|
1307
|
+
break;
|
|
1308
|
+
}
|
|
1201
1309
|
for (const id of next) visited.add(id);
|
|
1202
1310
|
frontier = next;
|
|
1311
|
+
stoppedAtHop = h + 1;
|
|
1203
1312
|
if (frontier.size === 0) break;
|
|
1204
1313
|
}
|
|
1205
1314
|
const nodes = graph.nodes.filter((n) => visited.has(n.id));
|
|
1206
1315
|
const edges = graph.edges.filter((e) => visited.has(e.source) && visited.has(e.target));
|
|
1207
|
-
return { nodes, edges };
|
|
1316
|
+
return { nodes, edges, budgetExceeded, stoppedAtHop };
|
|
1208
1317
|
}
|
|
1209
1318
|
function layerSummary(graph) {
|
|
1210
1319
|
const typeCounts = {};
|
|
@@ -1263,14 +1372,16 @@ Output: .launchsecure/graphs/
|
|
|
1263
1372
|
Use read_graph with filters (search/type/module/node_id) to query.`
|
|
1264
1373
|
);
|
|
1265
1374
|
}
|
|
1266
|
-
function
|
|
1375
|
+
function runReadGraphQueryRaw(rootDir, args) {
|
|
1267
1376
|
const layer = args.layer;
|
|
1268
1377
|
const search = args.search;
|
|
1269
1378
|
const type = args.type;
|
|
1270
1379
|
const module_ = args.module;
|
|
1271
1380
|
const nodeId = args.node_id;
|
|
1272
1381
|
const hops = args.hops ?? 1;
|
|
1273
|
-
const
|
|
1382
|
+
const layerIsDb = args.layer === "db";
|
|
1383
|
+
const minimal = args.minimal ?? layerIsDb;
|
|
1384
|
+
const includeEdges = args.include_edges;
|
|
1274
1385
|
const hasFilter = !!(search || type || module_ || nodeId);
|
|
1275
1386
|
if (layer && !["ui", "api", "db"].includes(layer)) {
|
|
1276
1387
|
return { error: `Invalid layer "${layer}". Must be one of: ui, api, db` };
|
|
@@ -1296,19 +1407,26 @@ function runReadGraphQuery(rootDir, args) {
|
|
|
1296
1407
|
return { error: `No ${layer} graph found at .launchsecure/graphs/${layer}.json. Run generate_graph first.` };
|
|
1297
1408
|
}
|
|
1298
1409
|
if (nodeId) {
|
|
1299
|
-
const nb = neighborhood(graph, nodeId, hops);
|
|
1410
|
+
const nb = neighborhood(graph, nodeId, hops, layer, minimal);
|
|
1300
1411
|
if (!nb) {
|
|
1301
1412
|
return { error: `Node "${nodeId}" not found in ${layer} graph. Try read_graph with search="${nodeId}" to find similar nodes.` };
|
|
1302
1413
|
}
|
|
1303
|
-
|
|
1414
|
+
const wantEdges2 = includeEdges ?? true;
|
|
1415
|
+
const result2 = {
|
|
1304
1416
|
layer,
|
|
1305
1417
|
center: nodeId,
|
|
1306
|
-
hops,
|
|
1418
|
+
hops_requested: hops,
|
|
1419
|
+
hops_traversed: nb.stoppedAtHop,
|
|
1307
1420
|
node_count: nb.nodes.length,
|
|
1308
1421
|
edge_count: nb.edges.length,
|
|
1309
|
-
nodes: minimal ? toMinimal(nb.nodes) : nb.nodes
|
|
1310
|
-
edges: nb.edges
|
|
1422
|
+
nodes: minimal ? toMinimal(nb.nodes) : nb.nodes
|
|
1311
1423
|
};
|
|
1424
|
+
if (wantEdges2) result2.edges = nb.edges;
|
|
1425
|
+
if (nb.budgetExceeded) {
|
|
1426
|
+
result2.budget_exceeded = true;
|
|
1427
|
+
result2.hint = `Neighborhood truncated at hop ${nb.stoppedAtHop} (projected size exceeded budget). To explore further, call read_graph with node_id set to a specific neighbor from the returned nodes, or rerun with hops=${Math.max(1, nb.stoppedAtHop)} to confirm the partial view is what you wanted.`;
|
|
1428
|
+
}
|
|
1429
|
+
return result2;
|
|
1312
1430
|
}
|
|
1313
1431
|
if (!hasFilter) {
|
|
1314
1432
|
return {
|
|
@@ -1333,14 +1451,24 @@ function runReadGraphQuery(rootDir, args) {
|
|
|
1333
1451
|
hint: "No nodes matched. Check spelling, or call read_graph without filter to see the summary and available types/modules."
|
|
1334
1452
|
};
|
|
1335
1453
|
}
|
|
1336
|
-
|
|
1454
|
+
const wantEdges = includeEdges ?? false;
|
|
1455
|
+
const result = {
|
|
1337
1456
|
layer,
|
|
1338
1457
|
filter: { search, type, module: module_ },
|
|
1339
1458
|
matched: matched.length,
|
|
1340
1459
|
edge_count: matchedEdges.length,
|
|
1341
|
-
nodes: minimal ? toMinimal(matched) : matched
|
|
1342
|
-
edges: matchedEdges
|
|
1460
|
+
nodes: minimal ? toMinimal(matched) : matched
|
|
1343
1461
|
};
|
|
1462
|
+
if (wantEdges) {
|
|
1463
|
+
result.edges = matchedEdges;
|
|
1464
|
+
} else {
|
|
1465
|
+
result.edges_hint = `${matchedEdges.length} edges between matched nodes omitted. Pass include_edges:true to retrieve them (only do this when you actually need edge data).`;
|
|
1466
|
+
}
|
|
1467
|
+
return result;
|
|
1468
|
+
}
|
|
1469
|
+
function runReadGraphQuery(rootDir, args) {
|
|
1470
|
+
const raw = runReadGraphQueryRaw(rootDir, args);
|
|
1471
|
+
return compactResult(raw);
|
|
1344
1472
|
}
|
|
1345
1473
|
function handleReadGraph(args) {
|
|
1346
1474
|
const rootDir = process.cwd();
|
|
@@ -1349,8 +1477,49 @@ function handleReadGraph(args) {
|
|
|
1349
1477
|
if (queries.length === 0) {
|
|
1350
1478
|
return err("queries array is empty. Provide at least one query object.");
|
|
1351
1479
|
}
|
|
1352
|
-
const results =
|
|
1353
|
-
|
|
1480
|
+
const results = [];
|
|
1481
|
+
let cumulativeChars = 0;
|
|
1482
|
+
let budgetHit = false;
|
|
1483
|
+
for (let i = 0; i < queries.length; i++) {
|
|
1484
|
+
const q = queries[i];
|
|
1485
|
+
if (budgetHit) {
|
|
1486
|
+
results.push({
|
|
1487
|
+
index: i,
|
|
1488
|
+
query: q,
|
|
1489
|
+
result: {
|
|
1490
|
+
skipped: true,
|
|
1491
|
+
reason: "batch_budget_exhausted",
|
|
1492
|
+
hint: "Previous sub-results filled the batch budget. Re-run this query on its own."
|
|
1493
|
+
}
|
|
1494
|
+
});
|
|
1495
|
+
continue;
|
|
1496
|
+
}
|
|
1497
|
+
const r = runReadGraphQuery(rootDir, q);
|
|
1498
|
+
const entry = { index: i, query: q, result: r };
|
|
1499
|
+
const entrySize = JSON.stringify(entry, null, 2).length;
|
|
1500
|
+
if (cumulativeChars + entrySize > BATCH_BUDGET_CHARS && results.length > 0) {
|
|
1501
|
+
budgetHit = true;
|
|
1502
|
+
results.push({
|
|
1503
|
+
index: i,
|
|
1504
|
+
query: q,
|
|
1505
|
+
result: {
|
|
1506
|
+
skipped: true,
|
|
1507
|
+
reason: "batch_budget_exhausted",
|
|
1508
|
+
hint: "This sub-query would push the batch over its size budget. Re-run it on its own."
|
|
1509
|
+
}
|
|
1510
|
+
});
|
|
1511
|
+
} else {
|
|
1512
|
+
results.push(entry);
|
|
1513
|
+
cumulativeChars += entrySize;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
return okJson({
|
|
1517
|
+
batch: true,
|
|
1518
|
+
count: results.length,
|
|
1519
|
+
cumulative_chars: cumulativeChars,
|
|
1520
|
+
budget_hit: budgetHit,
|
|
1521
|
+
results
|
|
1522
|
+
});
|
|
1354
1523
|
}
|
|
1355
1524
|
const result = runReadGraphQuery(rootDir, args);
|
|
1356
1525
|
return okJson(result);
|
|
@@ -1380,7 +1549,7 @@ function handleGrepNodes(args) {
|
|
|
1380
1549
|
} catch (e) {
|
|
1381
1550
|
return err(`Invalid regex: ${e.message}`);
|
|
1382
1551
|
}
|
|
1383
|
-
const queryResult =
|
|
1552
|
+
const queryResult = runReadGraphQueryRaw(rootDir, {
|
|
1384
1553
|
layer,
|
|
1385
1554
|
search: args.search,
|
|
1386
1555
|
type: args.type,
|
package/package.json
CHANGED