|
@@ -632,28 +632,17 @@ export function getStaticTools(): ToolDefinition[] {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * The MCP tools served by DEFAULT (short names). The other defined tools
|
|
|
|
|
- * (callees, impact, files, status) remain fully functional — handlers stay,
|
|
|
|
|
- * the library API and CLI are untouched, and `CODEGRAPH_MCP_TOOLS` re-enables
|
|
|
|
|
- * any of them — they just aren't LISTED to agents anymore.
|
|
|
|
|
|
|
+ * The MCP tools served by DEFAULT (short names). Pared to ONLY `codegraph_explore`
|
|
|
|
|
+ * — the single tool that reliably earns its place: one capped call returns the
|
|
|
|
|
+ * verbatim source of the relevant symbols grouped by file. Every other tool is a
|
|
|
|
|
+ * narrower slice of what explore already does, and presence itself steers
|
|
|
|
|
+ * mis-picks, so they are no longer LISTED to agents.
|
|
|
*
|
|
*
|
|
|
- * Evidence for the cut (the "adapt the tool to the agent" principle —
|
|
|
|
|
- * fewer tools = fewer mis-picks, and presence itself steers):
|
|
|
|
|
- * - `codegraph_impact` appears in ZERO recorded eval runs ever — its
|
|
|
|
|
- * blast-radius info already arrives inline on explore (the "Blast radius"
|
|
|
|
|
- * section) and node (the dependents note), so agents never need the
|
|
|
|
|
- * standalone tool.
|
|
|
|
|
- * - `codegraph_callees` is redundant by construction: a symbol's body (which
|
|
|
|
|
- * node returns) IS its callee list, plus the caller/callee trail.
|
|
|
|
|
- * - `codegraph_files` / `codegraph_status`: the tiny-repo audit (see
|
|
|
|
|
- * getTools) found they "reduce to one grep"; staleness banners already
|
|
|
|
|
- * inline the pending-sync info on every read tool, and the CLI covers
|
|
|
|
|
- * diagnostics.
|
|
|
|
|
- * - `codegraph_callers` stays: exhaustive call-site enumeration (every
|
|
|
|
|
- * caller with file:line, callback registrations labeled, one section per
|
|
|
|
|
- * same-named definition) is the one job explore/node don't replicate.
|
|
|
|
|
|
|
+ * The other defined tools (`node`, `search`, `callers`, plus callees/impact/files/
|
|
|
|
|
+ * status) remain fully functional — handlers stay, the library API and CLI are
|
|
|
|
|
+ * untouched, and `CODEGRAPH_MCP_TOOLS=explore,node,...` re-enables any of them.
|
|
|
*/
|
|
*/
|
|
|
-const DEFAULT_MCP_TOOLS = new Set(['explore', 'node', 'search', 'callers']);
|
|
|
|
|
|
|
+const DEFAULT_MCP_TOOLS = new Set(['explore']);
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* Tool handler that executes tools against a CodeGraph instance
|
|
* Tool handler that executes tools against a CodeGraph instance
|
|
@@ -1539,6 +1528,13 @@ export class ToolHandler {
|
|
|
registeredAt,
|
|
registeredAt,
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
+ // Generic fallback for any other synthesizer (redux-thunk, gin-middleware-chain,
|
|
|
|
|
+ // flutter-build, …): a synthesized hop must never read as a bare static `calls`.
|
|
|
|
|
+ // It's a dynamic-dispatch bridge — label it as one and keep its wiring site.
|
|
|
|
|
+ if (typeof m?.synthesizedBy === 'string') {
|
|
|
|
|
+ const kind = m.synthesizedBy.replace(/-/g, ' ');
|
|
|
|
|
+ return { label: `${kind} (dynamic dispatch)`, compact: `dynamic: ${kind}${at}`, registeredAt };
|
|
|
|
|
+ }
|
|
|
return null;
|
|
return null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1556,8 +1552,11 @@ export class ToolHandler {
|
|
|
* whose qualifiedName contains another named token (`PmsProductServiceImpl::list`),
|
|
* whose qualifiedName contains another named token (`PmsProductServiceImpl::list`),
|
|
|
* dropping unrelated `OmsOrderService::list`.
|
|
* dropping unrelated `OmsOrderService::list`.
|
|
|
*/
|
|
*/
|
|
|
- private buildFlowFromNamedSymbols(cg: CodeGraph, query: string): { text: string; pathNodeIds: Set<string>; namedNodeIds: Set<string>; uniqueNamedNodeIds: Set<string> } {
|
|
|
|
|
- const EMPTY = { text: '', pathNodeIds: new Set<string>(), namedNodeIds: new Set<string>(), uniqueNamedNodeIds: new Set<string>() };
|
|
|
|
|
|
|
+ private buildFlowFromNamedSymbols(cg: CodeGraph, query: string): { text: string; pathNodeIds: Set<string>; namedNodeIds: Set<string>; uniqueNamedNodeIds: Set<string>; spineCallSites: Map<string, number> } {
|
|
|
|
|
+ // spineCallSites: for each spine node, the line where it CALLS the next hop —
|
|
|
|
|
+ // lets the source assembler window an oversize spine method (e.g. n8n's 962-line
|
|
|
|
|
+ // processRunExecutionData) to the call site instead of dumping the whole body.
|
|
|
|
|
+ const EMPTY = { text: '', pathNodeIds: new Set<string>(), namedNodeIds: new Set<string>(), uniqueNamedNodeIds: new Set<string>(), spineCallSites: new Map<string, number>() };
|
|
|
try {
|
|
try {
|
|
|
const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
|
|
const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
|
|
|
// Strip only a REAL file extension (Create.cs → Create); KEEP qualified
|
|
// Strip only a REAL file extension (Create.cs → Create); KEEP qualified
|
|
@@ -1587,8 +1586,25 @@ export class ToolHandler {
|
|
|
// the dynamic-boundary scan (a token is covered when ANY of its nodes
|
|
// the dynamic-boundary scan (a token is covered when ANY of its nodes
|
|
|
// lands on the main chain — overloads off the chain don't count against).
|
|
// lands on the main chain — overloads off the chain don't count against).
|
|
|
const tokenNodes = new Map<string, string[]>();
|
|
const tokenNodes = new Map<string, string[]>();
|
|
|
|
|
+ // token → its full same-name callable family (before the container filter).
|
|
|
|
|
+ // A LARGE family that fails to connect on the chain is a polymorphic
|
|
|
|
|
+ // interface/registry dispatch — surfaced by buildPolymorphicBoundaries below.
|
|
|
|
|
+ const tokenFamily = new Map<string, Node[]>();
|
|
|
|
|
+ // Non-callable endpoints (CONSTANT/VARIABLE/FIELD) connected by a SYNTHESIZED
|
|
|
|
|
+ // edge. RTK thunks are `const X = createAsyncThunk(...)`, so a thunk→thunk hop
|
|
|
|
|
+ // is constant→constant — the CALLABLE-only `named` set can't hold it, and
|
|
|
|
|
+ // without this the hop is invisible to the Flow path at every tier (the
|
|
|
|
|
+ // Relationships section catches it only on repos ≥500 files). Kept SEPARATE
|
|
|
|
|
+ // from `named` (which drives the call-chain + source sizing, callable-only);
|
|
|
|
|
+ // fed only to the dynamic-dispatch-links scan below.
|
|
|
|
|
+ const dynNamed = new Map<string, Node>();
|
|
|
|
|
+ const DYN_KINDS = new Set(['constant', 'variable', 'field', 'property']);
|
|
|
|
|
+ const hasHeuristicEdge = (id: string): boolean =>
|
|
|
|
|
+ [...cg.getCallers(id), ...cg.getCallees(id)].some(({ edge }) => edge.provenance === 'heuristic');
|
|
|
for (const t of tokens) {
|
|
for (const t of tokens) {
|
|
|
- const cands = this.findAllSymbols(cg, t).nodes.filter((n) => CALLABLE.has(n.kind));
|
|
|
|
|
|
|
+ const hits = this.findAllSymbols(cg, t).nodes;
|
|
|
|
|
+ const cands = hits.filter((n) => CALLABLE.has(n.kind));
|
|
|
|
|
+ tokenFamily.set(t, cands);
|
|
|
// A qualified or otherwise-specific name (<=3 hits) keeps all; an
|
|
// A qualified or otherwise-specific name (<=3 hits) keeps all; an
|
|
|
// ambiguous simple name keeps only candidates whose container is named.
|
|
// ambiguous simple name keeps only candidates whose container is named.
|
|
|
const specific = cands.length <= 3;
|
|
const specific = cands.length <= 3;
|
|
@@ -1605,18 +1621,58 @@ export class ToolHandler {
|
|
|
named.set(n.id, n);
|
|
named.set(n.id, n);
|
|
|
if (specific) uniqueNamedNodeIds.add(n.id);
|
|
if (specific) uniqueNamedNodeIds.add(n.id);
|
|
|
}
|
|
}
|
|
|
|
|
+ // Same token, non-callable synth endpoints (capped, precision-gated on an
|
|
|
|
|
+ // actual heuristic edge so plain config constants never qualify).
|
|
|
|
|
+ if (dynNamed.size < 12) {
|
|
|
|
|
+ for (const n of hits) {
|
|
|
|
|
+ if (CALLABLE.has(n.kind) || !DYN_KINDS.has(n.kind) || dynNamed.has(n.id)) continue;
|
|
|
|
|
+ if (hasHeuristicEdge(n.id)) dynNamed.set(n.id, n);
|
|
|
|
|
+ if (dynNamed.size >= 12) break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
if (named.size > 40) break;
|
|
if (named.size > 40) break;
|
|
|
}
|
|
}
|
|
|
|
|
+ // Surface synthesized (heuristic) edges incident to a named symbol — INCLUDING
|
|
|
|
|
+ // the non-callable CONSTANT endpoints in `dynNamed`. `skipInChain` drops a hop
|
|
|
|
|
+ // already shown in the rendered main chain (a 2-node chain renders nothing, so a
|
|
|
|
|
+ // direct named→named synth hop still surfaces — #687).
|
|
|
|
|
+ const collectSynthLinks = (skipInChain: ((e: Edge) => boolean) | null): string[] => {
|
|
|
|
|
+ const synthLines: string[] = [];
|
|
|
|
|
+ const synthSeen = new Set<string>();
|
|
|
|
|
+ for (const n of [...named.values(), ...dynNamed.values()]) {
|
|
|
|
|
+ if (synthLines.length >= 6) break;
|
|
|
|
|
+ for (const { node: other, edge } of [...cg.getCallers(n.id), ...cg.getCallees(n.id)]) {
|
|
|
|
|
+ if (synthLines.length >= 6) break;
|
|
|
|
|
+ if (edge.provenance !== 'heuristic' || other.id === n.id) continue;
|
|
|
|
|
+ if (skipInChain && skipInChain(edge)) continue;
|
|
|
|
|
+ const src = edge.source === n.id ? n : other;
|
|
|
|
|
+ const tgt = edge.source === n.id ? other : n;
|
|
|
|
|
+ const key = `${src.name}>${tgt.name}`;
|
|
|
|
|
+ if (synthSeen.has(key)) continue;
|
|
|
|
|
+ synthSeen.add(key);
|
|
|
|
|
+ const note = this.synthEdgeNote(edge);
|
|
|
|
|
+ synthLines.push(`- ${src.name} → ${tgt.name} [${note ? note.compact : edge.kind}]`);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return synthLines;
|
|
|
|
|
+ };
|
|
|
if (named.size < 2) {
|
|
if (named.size < 2) {
|
|
|
- // The agent named a flow but only one side resolved (the other end is
|
|
|
|
|
- // anonymous / runtime-registered / not extracted). The resolved side's
|
|
|
|
|
- // body may still hold the dynamic-dispatch site that EXPLAINS the gap —
|
|
|
|
|
- // surface that instead of silently returning nothing.
|
|
|
|
|
- if (named.size === 0) return EMPTY;
|
|
|
|
|
- const boundaries = this.buildDynamicBoundaries(cg, [...named.values()], named);
|
|
|
|
|
- if (!boundaries) return EMPTY;
|
|
|
|
|
- const text = boundaries + '> Full source for these symbols is below.\n';
|
|
|
|
|
- return { text, pathNodeIds: new Set(), namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds };
|
|
|
|
|
|
|
+ // <2 CALLABLES resolved. Two recoveries before giving up: (1) synthesized
|
|
|
|
|
+ // edges among named CONSTANT/VARIABLE endpoints — RTK thunk→thunk is
|
|
|
|
|
+ // constant→constant, so `named` can be empty while `dynNamed` holds the
|
|
|
|
|
+ // whole chain; (2) the one resolved callable's body may hold the
|
|
|
|
|
+ // dynamic-dispatch site that EXPLAINS a half-connected flow.
|
|
|
|
|
+ const synthLines = collectSynthLinks(null);
|
|
|
|
|
+ const boundaries = named.size === 0 ? '' : (this.buildDynamicBoundaries(cg, [...named.values()], named) || '');
|
|
|
|
|
+ if (synthLines.length === 0 && !boundaries) return EMPTY;
|
|
|
|
|
+ const out: string[] = [];
|
|
|
|
|
+ if (synthLines.length) out.push(
|
|
|
|
|
+ '## Dynamic-dispatch links among your symbols',
|
|
|
|
|
+ '(synthesized — the indirect hops grep/Read would reconstruct; the `@file:line` is the wiring site)',
|
|
|
|
|
+ '', ...synthLines, '');
|
|
|
|
|
+ if (boundaries) out.push(boundaries);
|
|
|
|
|
+ out.push('> Full source for these symbols is below.\n');
|
|
|
|
|
+ return { text: out.join('\n'), pathNodeIds: new Set(), namedNodeIds: new Set<string>([...named.keys(), ...dynNamed.keys()]), uniqueNamedNodeIds, spineCallSites: new Map<string, number>() };
|
|
|
}
|
|
}
|
|
|
const MAX_HOPS = 7;
|
|
const MAX_HOPS = 7;
|
|
|
let best: Array<{ node: Node; edge: Edge | null }> | null = null;
|
|
let best: Array<{ node: Node; edge: Edge | null }> | null = null;
|
|
@@ -1651,6 +1707,14 @@ export class ToolHandler {
|
|
|
}
|
|
}
|
|
|
const hasMain = !!best && best.length >= 3;
|
|
const hasMain = !!best && best.length >= 3;
|
|
|
const pathIds = new Set((best ?? []).map((s) => s.node.id));
|
|
const pathIds = new Set((best ?? []).map((s) => s.node.id));
|
|
|
|
|
+ // Where each spine node calls the NEXT hop (best[i+1].edge is the edge from
|
|
|
|
|
+ // best[i] → best[i+1]; its line is the call site inside best[i]'s body). Lets
|
|
|
|
|
+ // the assembler window an oversize spine method to the call instead of dumping it.
|
|
|
|
|
+ const spineCallSites = new Map<string, number>();
|
|
|
|
|
+ if (best) for (let i = 0; i < best.length - 1; i++) {
|
|
|
|
|
+ const ln = best[i + 1]?.edge?.line;
|
|
|
|
|
+ if (ln && ln > 0 && !spineCallSites.has(best[i]!.node.id)) spineCallSites.set(best[i]!.node.id, ln);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
// Dynamic-boundary scan (#687) — fires ONLY when the flow the agent
|
|
// Dynamic-boundary scan (#687) — fires ONLY when the flow the agent
|
|
|
// asked about did not fully connect: some token resolved to nodes but
|
|
// asked about did not fully connect: some token resolved to nodes but
|
|
@@ -1682,38 +1746,40 @@ export class ToolHandler {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Supplementary: dynamic-dispatch (synthesized) edges incident to a NAMED
|
|
|
|
|
- // symbol — the indirect hops an agent would otherwise grep/Read to
|
|
|
|
|
- // reconstruct ("where do the appended `validators` actually run?"). The
|
|
|
|
|
- // synth edge IS that answer, so surface it even when the OTHER end wasn't
|
|
|
|
|
- // named (e.g. the agent names `validate` but not the `didCompleteTask`
|
|
|
|
|
- // that drains the collection). On-topic by construction: only heuristic
|
|
|
|
|
- // edges touching a symbol the agent named; skipped when the hop already
|
|
|
|
|
- // shows in the main chain.
|
|
|
|
|
- const synthLines: string[] = [];
|
|
|
|
|
- const synthSeen = new Set<string>();
|
|
|
|
|
- for (const n of named.values()) {
|
|
|
|
|
- if (synthLines.length >= 6) break;
|
|
|
|
|
- for (const { node: other, edge } of [...cg.getCallers(n.id), ...cg.getCallees(n.id)]) {
|
|
|
|
|
- if (synthLines.length >= 6) break;
|
|
|
|
|
- if (edge.provenance !== 'heuristic' || other.id === n.id) continue;
|
|
|
|
|
- // "Already in the main chain" only applies when a chain RENDERS
|
|
|
|
|
- // (hasMain). A 2-node chain populates pathIds but renders nothing,
|
|
|
|
|
- // so a direct synthesized hop between two named symbols (custom
|
|
|
|
|
- // EventBus emit→handler, #687) was invisible — too short for Flow,
|
|
|
|
|
- // skipped here as in-chain. Surface it.
|
|
|
|
|
- if (hasMain && pathIds.has(edge.source) && pathIds.has(edge.target)) continue;
|
|
|
|
|
- const src = edge.source === n.id ? n : other;
|
|
|
|
|
- const tgt = edge.source === n.id ? other : n;
|
|
|
|
|
- const key = `${src.name}>${tgt.name}`;
|
|
|
|
|
- if (synthSeen.has(key)) continue;
|
|
|
|
|
- synthSeen.add(key);
|
|
|
|
|
- const note = this.synthEdgeNote(edge);
|
|
|
|
|
- synthLines.push(`- ${src.name} → ${tgt.name} [${note ? note.compact : edge.kind}]`);
|
|
|
|
|
|
|
+ // Interface/registry-dispatch announcement (extends #687 to GRAPH-visible
|
|
|
|
|
+ // polymorphism). A method the agent NAMED that resolves to a large same-name
|
|
|
|
|
+ // family AND did not land on the main chain is almost always a runtime
|
|
|
|
|
+ // dispatch (plugin/strategy/handler interface): the concrete target is chosen
|
|
|
|
|
+ // at runtime from N implementations, so no single static edge is the answer.
|
|
|
|
|
+ // The body-scan above can't see this — `nodeType.execute()` is textually an
|
|
|
|
|
+ // ordinary call; the polymorphism lives in the graph (implements edges), so
|
|
|
|
|
+ // detect it there. Fires ONLY for an uncovered named token; a connected flow
|
|
|
|
|
+ // stays silent.
|
|
|
|
|
+ let polyText = '';
|
|
|
|
|
+ {
|
|
|
|
|
+ const POLY_MIN_FAMILY = 8; // smaller families are overload sets, not dispatch
|
|
|
|
|
+ const polyCands: Array<{ token: string; family: Node[] }> = [];
|
|
|
|
|
+ for (const [t, fam] of tokenFamily) {
|
|
|
|
|
+ if (fam.length < POLY_MIN_FAMILY) continue;
|
|
|
|
|
+ const ids = tokenNodes.get(t) || [];
|
|
|
|
|
+ if (ids.some((id) => pathIds.has(id))) continue; // covered by the flow — silent
|
|
|
|
|
+ polyCands.push({ token: t, family: fam });
|
|
|
}
|
|
}
|
|
|
|
|
+ if (polyCands.length) polyText = this.buildPolymorphicBoundaries(cg, polyCands, named);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if (!hasMain && synthLines.length === 0 && !boundaryText) return EMPTY;
|
|
|
|
|
|
|
+ // Supplementary: dynamic-dispatch (synthesized) edges incident to a named
|
|
|
|
|
+ // symbol (incl. the non-callable CONSTANT endpoints in `dynNamed`) — the
|
|
|
|
|
+ // indirect hops an agent would otherwise grep/Read to reconstruct ("where do
|
|
|
|
|
+ // the appended `validators` actually run?"). Surfaced even when the OTHER end
|
|
|
|
|
+ // wasn't named. The skip drops a hop already in the rendered main chain; a
|
|
|
|
|
+ // 2-node chain renders nothing (hasMain false) so a direct named→named synth
|
|
|
|
|
+ // hop still surfaces — too short for Flow, but #687-visible here.
|
|
|
|
|
+ const synthLines = collectSynthLinks(
|
|
|
|
|
+ hasMain ? (e: Edge) => pathIds.has(e.source) && pathIds.has(e.target) : null
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ if (!hasMain && synthLines.length === 0 && !boundaryText && !polyText) return EMPTY;
|
|
|
const out: string[] = [];
|
|
const out: string[] = [];
|
|
|
if (hasMain) {
|
|
if (hasMain) {
|
|
|
out.push('## Flow (call path among the symbols you queried)', '');
|
|
out.push('## Flow (call path among the symbols you queried)', '');
|
|
@@ -1734,13 +1800,14 @@ export class ToolHandler {
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
if (boundaryText) out.push(boundaryText);
|
|
if (boundaryText) out.push(boundaryText);
|
|
|
|
|
+ if (polyText) out.push(polyText);
|
|
|
out.push('> Full source for these symbols is below — the call flow among them, followed by their bodies.', '');
|
|
out.push('> Full source for these symbols is below — the call flow among them, followed by their bodies.', '');
|
|
|
// namedNodeIds = every callable the agent explicitly named (a superset of
|
|
// namedNodeIds = every callable the agent explicitly named (a superset of
|
|
|
// the spine). A file holding one is something the agent asked to SEE, so it
|
|
// the spine). A file holding one is something the agent asked to SEE, so it
|
|
|
// must keep full source even if it's an off-spine polymorphic sibling — the
|
|
// must keep full source even if it's an off-spine polymorphic sibling — the
|
|
|
// agent named `getResponseWithInterceptorChain` / `SQLCompiler.execute_sql`
|
|
// agent named `getResponseWithInterceptorChain` / `SQLCompiler.execute_sql`
|
|
|
// as the mechanism, not as an interchangeable leaf. See the skeleton gate.
|
|
// as the mechanism, not as an interchangeable leaf. See the skeleton gate.
|
|
|
- return { text: out.join('\n'), pathNodeIds: pathIds, namedNodeIds: new Set(named.keys()), uniqueNamedNodeIds };
|
|
|
|
|
|
|
+ return { text: out.join('\n'), pathNodeIds: pathIds, namedNodeIds: new Set<string>([...named.keys(), ...dynNamed.keys()]), uniqueNamedNodeIds, spineCallSites };
|
|
|
} catch {
|
|
} catch {
|
|
|
return EMPTY;
|
|
return EMPTY;
|
|
|
}
|
|
}
|
|
@@ -1802,6 +1869,93 @@ export class ToolHandler {
|
|
|
].join('\n');
|
|
].join('\n');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Interface/registry-dispatch announcement — #687 extended to GRAPH-visible
|
|
|
|
|
+ * polymorphism (the body-scan can't see it: `nodeType.execute()` is textually
|
|
|
|
|
+ * an ordinary call; the polymorphism lives in the `implements`/`extends` edges).
|
|
|
|
|
+ *
|
|
|
|
|
+ * A method the agent named that resolves to a large same-name family whose
|
|
|
|
|
+ * definers overwhelmingly implement/extend ONE supertype is a runtime dispatch:
|
|
|
|
|
+ * the concrete target is chosen at runtime from N implementations, so no single
|
|
|
|
|
+ * static edge is "the answer" — the implementations ARE the continuations. We
|
|
|
|
|
+ * announce the supertype, its TRUE implementer count, and a few concrete targets,
|
|
|
|
|
+ * then steer to codegraph_explore. Graph-only, query-time, zero mutation; the
|
|
|
|
|
+ * caller fires it ONLY for an UNCOVERED named token, so a connected flow is silent.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Robust to FTS sampling bias: the same-name family is a capped FTS sample that
|
|
|
|
|
+ * over-represents whatever FTS ranks first (n8n: DB `TableOperation.execute`
|
|
|
|
|
+ * outnumbered `INodeType.execute` in the sample 7:6 even though INodeType has
|
|
|
|
|
+ * 611 implementers vs a handful). So candidate supertypes are ranked by their
|
|
|
|
|
+ * TRUE graph-wide implementer count, NOT their frequency in the sample.
|
|
|
|
|
+ */
|
|
|
|
|
+ private buildPolymorphicBoundaries(cg: CodeGraph, candidates: Array<{ token: string; family: Node[] }>, named: Map<string, Node>): string {
|
|
|
|
|
+ const CLASSY = new Set(['class', 'struct', 'interface', 'trait', 'protocol', 'abstract']);
|
|
|
|
|
+ const MIN_IMPL = 8; // a supertype needs >= this many implementers to count as "polymorphic"
|
|
|
|
|
+ const MIN_SUPPORT = 2; // >= this many sampled definers must share the supertype (ties it to the token)
|
|
|
|
|
+ const SAMPLE = 40; // family members inspected per token
|
|
|
|
|
+ const MAX_NOTES = 3;
|
|
|
|
|
+ const rel = (p: string) => p.replace(/\\/g, '/');
|
|
|
|
|
+ const containerOf = (m: Node): Node | null => {
|
|
|
|
|
+ try { const ce = cg.getIncomingEdges(m.id).find((e) => e.kind === 'contains'); return ce ? cg.getNode(ce.source) : null; }
|
|
|
|
|
+ catch { return null; }
|
|
|
|
|
+ };
|
|
|
|
|
+ const notes: string[] = [];
|
|
|
|
|
+ const seenSuper = new Set<string>();
|
|
|
|
|
+ for (const { token, family } of candidates) {
|
|
|
|
|
+ if (notes.length >= MAX_NOTES) break;
|
|
|
|
|
+ // supertype id → how many sampled definers share it + a few example definers
|
|
|
|
|
+ const supers = new Map<string, { node: Node; count: number; targets: Node[] }>();
|
|
|
|
|
+ for (const m of family.slice(0, SAMPLE)) {
|
|
|
|
|
+ const container = containerOf(m);
|
|
|
|
|
+ if (!container || !CLASSY.has(container.kind)) continue;
|
|
|
|
|
+ let sups: Node[] = [];
|
|
|
|
|
+ try {
|
|
|
|
|
+ sups = cg.getOutgoingEdges(container.id)
|
|
|
|
|
+ .filter((e) => e.kind === 'implements' || e.kind === 'extends')
|
|
|
|
|
+ .map((e) => { try { return cg.getNode(e.target); } catch { return null; } })
|
|
|
|
|
+ .filter((n): n is Node => !!n && CLASSY.has(n.kind) && (n.name?.length || 0) >= 3);
|
|
|
|
|
+ } catch { /* no supertypes — free function or unresolved */ }
|
|
|
|
|
+ for (const s of sups) {
|
|
|
|
|
+ const e = supers.get(s.id) || { node: s, count: 0, targets: [] };
|
|
|
|
|
+ e.count++;
|
|
|
|
|
+ if (e.targets.length < 6) e.targets.push(m);
|
|
|
|
|
+ supers.set(s.id, e);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // Pick the supertype with the most TRUE implementers (graph-wide), among
|
|
|
|
|
+ // those genuinely shared by the token's definers.
|
|
|
|
|
+ let best: { node: Node; impl: number; targets: Node[] } | null = null;
|
|
|
|
|
+ for (const { node, count, targets } of supers.values()) {
|
|
|
|
|
+ if (count < MIN_SUPPORT) continue;
|
|
|
|
|
+ let impl = 0;
|
|
|
|
|
+ try { impl = cg.getIncomingEdges(node.id).filter((e) => e.kind === 'implements' || e.kind === 'extends').length; }
|
|
|
|
|
+ catch { /* leave 0 — gated out below */ }
|
|
|
|
|
+ if (impl < MIN_IMPL) continue;
|
|
|
|
|
+ if (!best || impl > best.impl) best = { node, impl, targets };
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!best || seenSuper.has(best.node.id)) continue;
|
|
|
|
|
+ seenSuper.add(best.node.id);
|
|
|
|
|
+ const namedNames = new Set([...named.values()].map((n) => n.name));
|
|
|
|
|
+ const eg = best.targets.slice(0, 4).map((m) => {
|
|
|
|
|
+ const cont = containerOf(m);
|
|
|
|
|
+ const disp = cont ? `${cont.name}.${m.name}` : (m.qualifiedName || m.name);
|
|
|
|
|
+ const mark = cont && namedNames.has(cont.name) ? ' ← you named this' : '';
|
|
|
|
|
+ return `\`${disp}\` (${rel(m.filePath)}:${m.startLine})${mark}`;
|
|
|
|
|
+ });
|
|
|
|
|
+ const more = best.impl > eg.length ? ` +${best.impl - eg.length} more` : '';
|
|
|
|
|
+ notes.push(`- \`${token}\` → runtime dispatch to **${best.impl}** types implementing \`${best.node.name}\` — the static path ends here, the target is chosen at runtime. e.g. ${eg.join(', ')}${more}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (notes.length === 0) return '';
|
|
|
|
|
+ return [
|
|
|
|
|
+ '## Interface dispatch (a named method has many implementations)',
|
|
|
|
|
+ '',
|
|
|
|
|
+ ...notes,
|
|
|
|
|
+ '',
|
|
|
|
|
+ '> The method above is dispatched at runtime to one of the listed implementations (a registry / plugin / strategy interface) — there is no single static caller→callee edge; the implementations ARE the continuations. To follow one, run codegraph_explore on a listed target.',
|
|
|
|
|
+ '',
|
|
|
|
|
+ ].join('\n');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* Shortlist candidate runtime targets for a dispatch key surfaced by
|
|
* Shortlist candidate runtime targets for a dispatch key surfaced by
|
|
|
* {@link buildDynamicBoundaries}. Exact conventional names first (`save` →
|
|
* {@link buildDynamicBoundaries}. Exact conventional names first (`save` →
|
|
@@ -2327,6 +2481,26 @@ export class ToolHandler {
|
|
|
if (n) namedSeedFiles.add(n.filePath);
|
|
if (n) namedSeedFiles.add(n.filePath);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // Multi-term corroboration tier: a file that is BOTH (a) an entry/central file
|
|
|
|
|
+ // (a search root, named seed, or graph-central hub — i.e. structurally part of
|
|
|
|
|
+ // the answer) AND (b) matched by ≥2 DISTINCT query terms must not be buried by
|
|
|
|
|
+ // graph-centrality mass that accrued to a denser-but-off-topic cluster. In a
|
|
|
|
|
+ // cross-layer monorepo (an API server alongside a much larger, internally dense
|
|
|
|
|
+ // frontend that mirrors the same domain words) the Random-Walk-with-Restart mass
|
|
|
|
|
+ // — seeded from text matches that skew to the bigger layer — floats hits=0
|
|
|
|
|
+ // frontend files above the hits=2/3 backend service that IS the answer (its many
|
|
|
|
|
+ // callers don't help: it's call-isolated from the frontend seed cluster). The
|
|
|
|
|
+ // entry/central GUARD keeps this safe: an INCIDENTAL multi-term file that is
|
|
|
|
|
+ // neither entry nor central (a type/util file that matches "element"+x but isn't
|
|
|
|
|
+ // the flow) is NOT promoted, so it can't displace the graph-central answer file
|
|
|
|
|
+ // (hits=1) the way a blunt hits-only tier would. Single-layer repos with one
|
|
|
|
|
+ // cluster are unaffected (no competing mass). Set CODEGRAPH_RANK_NO_MULTITERM=1
|
|
|
|
|
+ // to disable.
|
|
|
|
|
+ const MULTITERM_OFF = process.env.CODEGRAPH_RANK_NO_MULTITERM === '1';
|
|
|
|
|
+ const isCorroborated = (fp: string) =>
|
|
|
|
|
+ !MULTITERM_OFF &&
|
|
|
|
|
+ (fileTermHits.get(fp) ?? 0) >= 2 &&
|
|
|
|
|
+ (entryFiles.has(fp) || centralFiles.has(fp));
|
|
|
const sortedFiles = relevantFiles.sort((a, b) => {
|
|
const sortedFiles = relevantFiles.sort((a, b) => {
|
|
|
const aPath = a[0].toLowerCase();
|
|
const aPath = a[0].toLowerCase();
|
|
|
const bPath = b[0].toLowerCase();
|
|
const bPath = b[0].toLowerCase();
|
|
@@ -2336,6 +2510,11 @@ export class ToolHandler {
|
|
|
const bNamed = namedSeedFiles.has(b[0]) ? 1 : 0;
|
|
const bNamed = namedSeedFiles.has(b[0]) ? 1 : 0;
|
|
|
if (aNamed !== bNamed) return bNamed - aNamed;
|
|
if (aNamed !== bNamed) return bNamed - aNamed;
|
|
|
|
|
|
|
|
|
|
+ // Corroborated (entry/central + ≥2 terms) tier, above the graph signal.
|
|
|
|
|
+ const aCorr = isCorroborated(a[0]) ? 1 : 0;
|
|
|
|
|
+ const bCorr = isCorroborated(b[0]) ? 1 : 0;
|
|
|
|
|
+ if (aCorr !== bCorr) return bCorr - aCorr;
|
|
|
|
|
+
|
|
|
// Graph connectivity is the next key (small epsilon so near-ties fall
|
|
// Graph connectivity is the next key (small epsilon so near-ties fall
|
|
|
// through to the text signal rather than coin-flipping on float noise).
|
|
// through to the text signal rather than coin-flipping on float noise).
|
|
|
const aG = fileGraphScore.get(a[0]) ?? 0;
|
|
const aG = fileGraphScore.get(a[0]) ?? 0;
|
|
@@ -2705,7 +2884,7 @@ export class ToolHandler {
|
|
|
const n = cg.getNode(id);
|
|
const n = cg.getNode(id);
|
|
|
if (n && n.filePath === filePath && n.startLine > 0 && n.endLine > 0) rangeNodes.set(id, n);
|
|
if (n && n.filePath === filePath && n.startLine > 0 && n.endLine > 0) rangeNodes.set(id, n);
|
|
|
}
|
|
}
|
|
|
- const ranges: Array<{ start: number; end: number; name: string; kind: string; importance: number }> = [...rangeNodes.values()]
|
|
|
|
|
|
|
+ const ranges: Array<{ start: number; end: number; name: string; kind: string; importance: number; spine: boolean; spineCallLine?: number }> = [...rangeNodes.values()]
|
|
|
// Drop whole-file envelope nodes (containers covering >50% of the file).
|
|
// Drop whole-file envelope nodes (containers covering >50% of the file).
|
|
|
.filter(n => !(ENVELOPE_KINDS.has(n.kind) && (n.endLine - n.startLine + 1) > fileLines.length * 0.5))
|
|
.filter(n => !(ENVELOPE_KINDS.has(n.kind) && (n.endLine - n.startLine + 1) > fileLines.length * 0.5))
|
|
|
.map(n => {
|
|
.map(n => {
|
|
@@ -2714,7 +2893,12 @@ export class ToolHandler {
|
|
|
else if (flow.namedNodeIds.has(n.id)) importance = 9; // agent named it → keep its cluster
|
|
else if (flow.namedNodeIds.has(n.id)) importance = 9; // agent named it → keep its cluster
|
|
|
else if (glueNodeIds.has(n.id)) importance = 6; // bridging caller/callee of an entry
|
|
else if (glueNodeIds.has(n.id)) importance = 6; // bridging caller/callee of an entry
|
|
|
else if (connectedToEntry.has(n.id)) importance = 3;
|
|
else if (connectedToEntry.has(n.id)) importance = 3;
|
|
|
- return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance };
|
|
|
|
|
|
|
+ // On the rendered call-path spine? That IS the flow answer — its cluster
|
|
|
|
|
+ // must never be dropped by the per-file budget (n8n's huge workflow-execute.ts:
|
|
|
|
|
+ // processRunExecutionData, the named flow ENTRY at L1562, is a large
|
|
|
|
|
+ // low-density method that lost the budget to denser blocks and got cut, so
|
|
|
|
|
+ // the agent Read it back — the very thing explore exists to prevent).
|
|
|
|
|
+ return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance, spine: flow.pathNodeIds.has(n.id), spineCallLine: flow.spineCallSites.get(n.id) };
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
// Add edge source locations in this file — captures template references
|
|
// Add edge source locations in this file — captures template references
|
|
@@ -2732,7 +2916,7 @@ export class ToolHandler {
|
|
|
// Look up target name from subgraph first, fall back to edge kind
|
|
// Look up target name from subgraph first, fall back to edge kind
|
|
|
const targetNode = subgraph.nodes.get(edge.target);
|
|
const targetNode = subgraph.nodes.get(edge.target);
|
|
|
const targetName = targetNode?.name ?? edge.kind;
|
|
const targetName = targetNode?.name ?? edge.kind;
|
|
|
- ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2 });
|
|
|
|
|
|
|
+ ranges.push({ start: edge.line, end: edge.line, name: targetName, kind: edge.kind, importance: 2, spine: false });
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -2741,13 +2925,15 @@ export class ToolHandler {
|
|
|
if (ranges.length === 0) continue;
|
|
if (ranges.length === 0) continue;
|
|
|
|
|
|
|
|
const gapThreshold = budget.gapThreshold;
|
|
const gapThreshold = budget.gapThreshold;
|
|
|
- const clusters: Array<{ start: number; end: number; symbols: string[]; score: number; maxImportance: number }> = [];
|
|
|
|
|
|
|
+ const clusters: Array<{ start: number; end: number; symbols: string[]; score: number; maxImportance: number; hasSpine: boolean; spineCallLine?: number }> = [];
|
|
|
let current = {
|
|
let current = {
|
|
|
start: ranges[0]!.start,
|
|
start: ranges[0]!.start,
|
|
|
end: ranges[0]!.end,
|
|
end: ranges[0]!.end,
|
|
|
symbols: [`${ranges[0]!.name}(${ranges[0]!.kind})`],
|
|
symbols: [`${ranges[0]!.name}(${ranges[0]!.kind})`],
|
|
|
score: ranges[0]!.importance,
|
|
score: ranges[0]!.importance,
|
|
|
maxImportance: ranges[0]!.importance,
|
|
maxImportance: ranges[0]!.importance,
|
|
|
|
|
+ hasSpine: ranges[0]!.spine,
|
|
|
|
|
+ spineCallLine: ranges[0]!.spineCallLine,
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
for (let i = 1; i < ranges.length; i++) {
|
|
for (let i = 1; i < ranges.length; i++) {
|
|
@@ -2757,6 +2943,8 @@ export class ToolHandler {
|
|
|
current.symbols.push(`${r.name}(${r.kind})`);
|
|
current.symbols.push(`${r.name}(${r.kind})`);
|
|
|
current.score += r.importance;
|
|
current.score += r.importance;
|
|
|
current.maxImportance = Math.max(current.maxImportance, r.importance);
|
|
current.maxImportance = Math.max(current.maxImportance, r.importance);
|
|
|
|
|
+ current.hasSpine = current.hasSpine || r.spine;
|
|
|
|
|
+ current.spineCallLine = current.spineCallLine ?? r.spineCallLine;
|
|
|
} else {
|
|
} else {
|
|
|
clusters.push(current);
|
|
clusters.push(current);
|
|
|
current = {
|
|
current = {
|
|
@@ -2765,6 +2953,8 @@ export class ToolHandler {
|
|
|
symbols: [`${r.name}(${r.kind})`],
|
|
symbols: [`${r.name}(${r.kind})`],
|
|
|
score: r.importance,
|
|
score: r.importance,
|
|
|
maxImportance: r.importance,
|
|
maxImportance: r.importance,
|
|
|
|
|
+ hasSpine: r.spine,
|
|
|
|
|
+ spineCallLine: r.spineCallLine,
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -2779,16 +2969,40 @@ export class ToolHandler {
|
|
|
// get tail-trimmed with a marker.
|
|
// get tail-trimmed with a marker.
|
|
|
const contextPadding = 3;
|
|
const contextPadding = 3;
|
|
|
const withLineNumbers = exploreLineNumbersEnabled();
|
|
const withLineNumbers = exploreLineNumbersEnabled();
|
|
|
- const buildSection = (c: { start: number; end: number }): string => {
|
|
|
|
|
|
|
+ // Language-neutral separator (no `//` — not a comment in Python, Ruby,
|
|
|
|
|
+ // etc.). With line numbers on, the line-number jump also signals the gap.
|
|
|
|
|
+ const GAP_MARKER = '\n\n... (gap) ...\n\n';
|
|
|
|
|
+ // An oversize spine method (the call path runs THROUGH a god-method — n8n's
|
|
|
|
|
+ // processRunExecutionData is 962 lines) is windowed to its next-hop CALL site
|
|
|
|
|
+ // plus the signature head, NOT dumped whole. Without this the cluster is too big
|
|
|
|
|
+ // for any per-file cap and gets dropped, so the agent Reads the method back —
|
|
|
|
|
+ // the exact gap this closes. Bounded, so a god-method can't blow the budget yet
|
|
|
|
|
+ // the spine's call still appears in context.
|
|
|
|
|
+ const OVERSIZE_SPINE_LINES = 200;
|
|
|
|
|
+ const SPINE_WINDOW = 28; // lines each side of the next-hop call site
|
|
|
|
|
+ const buildSection = (c: { start: number; end: number; hasSpine?: boolean; spineCallLine?: number }): string => {
|
|
|
|
|
+ if (c.hasSpine && c.spineCallLine && (c.end - c.start + 1) > OVERSIZE_SPINE_LINES) {
|
|
|
|
|
+ const call = c.spineCallLine;
|
|
|
|
|
+ const winStart = Math.max(c.start, call - SPINE_WINDOW);
|
|
|
|
|
+ const winEnd = Math.min(c.end, call + SPINE_WINDOW);
|
|
|
|
|
+ const parts: string[] = [];
|
|
|
|
|
+ // Signature head, only when it sits clearly above the window (else the
|
|
|
|
|
+ // window already covers the method opening).
|
|
|
|
|
+ const headEnd = Math.min(c.start + 4, winStart - 2);
|
|
|
|
|
+ if (headEnd >= c.start) {
|
|
|
|
|
+ const head = fileLines.slice(c.start - 1, headEnd).join('\n');
|
|
|
|
|
+ parts.push(withLineNumbers ? numberSourceLines(head, c.start) : head);
|
|
|
|
|
+ }
|
|
|
|
|
+ const win = fileLines.slice(winStart - 1, winEnd).join('\n');
|
|
|
|
|
+ parts.push(withLineNumbers ? numberSourceLines(win, winStart) : win);
|
|
|
|
|
+ return parts.join(GAP_MARKER);
|
|
|
|
|
+ }
|
|
|
const startIdx = Math.max(0, c.start - 1 - contextPadding);
|
|
const startIdx = Math.max(0, c.start - 1 - contextPadding);
|
|
|
const endIdx = Math.min(fileLines.length, c.end + contextPadding);
|
|
const endIdx = Math.min(fileLines.length, c.end + contextPadding);
|
|
|
const slice = fileLines.slice(startIdx, endIdx).join('\n');
|
|
const slice = fileLines.slice(startIdx, endIdx).join('\n');
|
|
|
// startIdx is 0-based, so the slice's first line is line startIdx + 1.
|
|
// startIdx is 0-based, so the slice's first line is line startIdx + 1.
|
|
|
return withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice;
|
|
return withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice;
|
|
|
};
|
|
};
|
|
|
- // Language-neutral separator (no `//` — not a comment in Python, Ruby,
|
|
|
|
|
- // etc.). With line numbers on, the line-number jump also signals the gap.
|
|
|
|
|
- const GAP_MARKER = '\n\n... (gap) ...\n\n';
|
|
|
|
|
|
|
|
|
|
// Rank clusters for inclusion under the per-file cap. Entry-point
|
|
// Rank clusters for inclusion under the per-file cap. Entry-point
|
|
|
// clusters come first: a cluster containing a query entry point
|
|
// clusters come first: a cluster containing a query entry point
|
|
@@ -2803,6 +3017,11 @@ export class ToolHandler {
|
|
|
const rankedClusters = clusters
|
|
const rankedClusters = clusters
|
|
|
.map((c, i) => ({ idx: i, span: c.end - c.start + 1, c }))
|
|
.map((c, i) => ({ idx: i, span: c.end - c.start + 1, c }))
|
|
|
.sort((a, b) => {
|
|
.sort((a, b) => {
|
|
|
|
|
+ // Spine clusters first — the rendered call path IS the flow answer, so it
|
|
|
|
|
+ // outranks any denser block of peripheral declarations (a low-density entry
|
|
|
|
|
+ // method must not lose the budget to them). Within spine / within non-spine,
|
|
|
|
|
+ // the existing importance → density → score → span order holds.
|
|
|
|
|
+ if (a.c.hasSpine !== b.c.hasSpine) return (b.c.hasSpine ? 1 : 0) - (a.c.hasSpine ? 1 : 0);
|
|
|
if (b.c.maxImportance !== a.c.maxImportance) return b.c.maxImportance - a.c.maxImportance;
|
|
if (b.c.maxImportance !== a.c.maxImportance) return b.c.maxImportance - a.c.maxImportance;
|
|
|
const densityA = a.c.score / a.span;
|
|
const densityA = a.c.score / a.span;
|
|
|
const densityB = b.c.score / b.span;
|
|
const densityB = b.c.score / b.span;
|
|
@@ -2818,6 +3037,11 @@ export class ToolHandler {
|
|
|
// That source-order slice is what cut Django's `_fetch_all` (L2237, importance
|
|
// That source-order slice is what cut Django's `_fetch_all` (L2237, importance
|
|
|
// 9 — agent-named) when query.py was the last of four big files to be emitted.
|
|
// 9 — agent-named) when query.py was the last of four big files to be emitted.
|
|
|
const fileBudget = Math.min(budget.maxCharsPerFile, Math.max(0, budget.maxOutputChars - totalChars - 200));
|
|
const fileBudget = Math.min(budget.maxCharsPerFile, Math.max(0, budget.maxOutputChars - totalChars - 200));
|
|
|
|
|
+ // Spine ceiling: a flow-path cluster may exceed the per-file cap (the call
|
|
|
|
|
+ // path is the answer), but bounded — at most ~2.5× the per-file cap and never
|
|
|
|
|
+ // past what's left of the total output cap — so a pathological long in-file
|
|
|
|
|
+ // spine can't run away or starve co-flow files entirely.
|
|
|
|
|
+ const SPINE_CEILING = Math.min(budget.maxCharsPerFile * 2.5, Math.max(0, budget.maxOutputChars - totalChars - 200));
|
|
|
const chosenIndices = new Set<number>();
|
|
const chosenIndices = new Set<number>();
|
|
|
let projectedChars = 0;
|
|
let projectedChars = 0;
|
|
|
for (const rc of rankedClusters) {
|
|
for (const rc of rankedClusters) {
|
|
@@ -2830,7 +3054,12 @@ export class ToolHandler {
|
|
|
projectedChars += sectionLen;
|
|
projectedChars += sectionLen;
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
- if (projectedChars + sectionLen > fileBudget) continue;
|
|
|
|
|
|
|
+ // A spine cluster (the rendered call path) is the flow answer — include it
|
|
|
|
|
+ // past the per-file budget up to the spine ceiling; non-spine clusters obey
|
|
|
|
|
+ // the normal per-file budget.
|
|
|
|
|
+ const fits = projectedChars + sectionLen <= fileBudget;
|
|
|
|
|
+ const spineFits = rc.c.hasSpine && projectedChars + sectionLen <= SPINE_CEILING;
|
|
|
|
|
+ if (!fits && !spineFits) continue;
|
|
|
chosenIndices.add(rc.idx);
|
|
chosenIndices.add(rc.idx);
|
|
|
projectedChars += sectionLen;
|
|
projectedChars += sectionLen;
|
|
|
}
|
|
}
|
|
@@ -2960,6 +3189,7 @@ export class ToolHandler {
|
|
|
// necessary overflow above the 24K budget, but hard-stop at 25K — never into
|
|
// necessary overflow above the 24K budget, but hard-stop at 25K — never into
|
|
|
// externalize territory.
|
|
// externalize territory.
|
|
|
const output = flow.text + lines.join('\n');
|
|
const output = flow.text + lines.join('\n');
|
|
|
|
|
+
|
|
|
const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000);
|
|
const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000);
|
|
|
if (output.length > hardCeiling) {
|
|
if (output.length > hardCeiling) {
|
|
|
// Cut at a FILE-SECTION boundary (the last `#### ` header before the
|
|
// Cut at a FILE-SECTION boundary (the last `#### ` header before the
|