fix(explore): accurately resolve query file paths and find camelCase symbols

Previously, `codegraph_explore` queries explicitly naming files by path (e.g., `src/routes/m/projects/[id]/runs/[runId]/+page.svelte`) were shredded. Bracketed path segments exploded into "named symbol" seeds, and FTS on fragments like `page` or `runs` admitted every sibling file, starving the user's intended target.

This change introduces:
- **Query path pinning:** File paths named in a query are now resolved against the index, "pinned," and stripped from the query. Pinned files are guaranteed inclusion, top ranking, and fair allocation. Unresolvable path-like spans are reported.
- **Segment vocabulary supplement:** Natural language query terms (e.g., "auto-scroll to bottom") can now reach camelCase identifiers (e.g., `pinFeedIfNearBottom`, `feedAtBottom`) by matching against their constituent segments.
- **Variable seeding:** `variable` and `constant` node kinds are now included in identifier seeding, improving recall for `$state`-style variables common in frameworks like Svelte.
This commit is contained in:
Colby McHenry
2026-08-20 09:10:07 -07:00
parent c6aaa20358
commit 238dbc5cec
15 changed files with 905 additions and 31 deletions
+105
View File
@@ -0,0 +1,105 @@
/**
* End-to-end gate for query-path pinning + the segment-vocab supplement +
* variable seeding, on the bug that motivated all three: an agent named a
* SvelteKit route file by exact path plus behavior words ("scrollToBottom,
* onscroll, atBottom tracking") and got back neither the file's scroll code
* nor the file itself at full weight — the bracketed path was tokenizer
* shrapnel (`runId` seeded as a named symbol, every sibling `+page` admitted)
* and the camelCase scroll symbols were FTS-opaque.
*
* The fixture mirrors that shape in plain TS (bracket/paren directories are
* the crux, not the language): a target file under
* `src/routes/m/projects/[id]/runs/[runId]/` holding `feedAtBottom` /
* `handleFeedScroll` / `pinFeedIfNearBottom`, a decoy chat-window page under
* a `(protected)` route group, and a runs-store decoy defining `runId` and
* `Scope` — the two symbols that headlined the original junk blast radius.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import CodeGraph from '../src/index';
import { ToolHandler } from '../src/mcp/tools';
const FIXTURE = 'explore-path-pinning';
const TARGET = 'src/routes/m/projects/[id]/runs/[runId]/+page.ts';
const DECOY_CHAT = 'src/routes/(protected)/chat-window/+page.ts';
let dir: string;
let cg: CodeGraph;
async function explore(query: string): Promise<string> {
const res = await new ToolHandler(cg).execute('codegraph_explore', { query });
return res.content?.[0]?.text ?? '';
}
/** The response renders a source section for `file`. */
const hasSection = (response: string, file: string): boolean =>
response.includes('**`' + file + '`');
beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-path-pin-'));
fs.cpSync(path.join(__dirname, 'fixtures', FIXTURE), dir, { recursive: true });
fs.rmSync(path.join(dir, '.codegraph'), { recursive: true, force: true });
cg = CodeGraph.initSync(dir);
await cg.indexAll();
}, 180_000);
afterAll(() => {
cg?.destroy();
if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
});
describe('fixture shape — if this rots, the gates below mean nothing', () => {
it('indexes the bracketed-path target with its scroll symbols', () => {
const names = cg.getNodesInFile(TARGET).map((n) => n.name);
expect(names).toContain('feedAtBottom');
expect(names).toContain('handleFeedScroll');
expect(names).toContain('pinFeedIfNearBottom');
});
});
describe('path pinning (fix 1)', () => {
it('a pure-path query renders the named file and says it was pinned', async () => {
const out = await explore(TARGET);
expect(hasSection(out, TARGET)).toBe(true);
expect(out).toContain('pinned from the query');
});
it('the original bug-shaped query renders the pinned file, not path shrapnel', async () => {
const out = await explore(
`run page auto-scroll to bottom logic in ${TARGET} — scrollToBottom, onscroll, atBottom tracking`,
);
expect(hasSection(out, TARGET)).toBe(true);
// The path fragments must not seed: `runId` (runs-store decoy) and the
// bracketed segment's namesakes headlined the original junk blast radius.
const blast = out.split('**Relationships**')[0]!;
expect(blast).not.toMatch(/`runId` \(src\/lib\/runs-store\.ts/);
// The chat decoy MAY render — it genuinely holds scroll-pinning code the
// segment supplement now finds — but the pinned file must rank first.
// (Pre-fix, `+page`/`runs` shrapnel admitted the siblings ABOVE the named
// file and the envelope truncated it.)
const decoyAt = out.indexOf('**`' + DECOY_CHAT + '`');
const targetAt = out.indexOf('**`' + TARGET + '`');
expect(targetAt).toBeGreaterThan(-1);
if (decoyAt !== -1) expect(targetAt).toBeLessThan(decoyAt);
});
it('an unresolvable path is reported, not silently dropped', async () => {
const out = await explore('crash in src/routes/gone/missing-page.ts on load');
expect(out).toContain('No indexed file uniquely matches');
expect(out).toContain('src/routes/gone/missing-page.ts');
});
});
describe('segment supplement + variable seeding (fixes 23)', () => {
it('word-level scroll terms reach the camelCase scroll code without a path', async () => {
const out = await explore('feed auto-scroll to bottom pinning behavior');
expect(hasSection(out, TARGET)).toBe(true);
});
it('a camel infix naming only $state-style variables still finds their file', async () => {
const out = await explore('where does the atBottom flag get reset');
expect(hasSection(out, TARGET)).toBe(true);
});
});
@@ -0,0 +1,83 @@
/**
* Pinned files in `allocateExploreBudget` (see query-paths.ts): a file the
* query named by PATH must survive every allocation guard. Its score is
* whatever the path-stripped query happened to match — for a pure-path query,
* nearly nothing — so without the pinned floor the proportional split would
* fund the one file the agent explicitly asked for worst of all, and the
* cliff would zero it outright.
*/
import { describe, it, expect } from 'vitest';
import { allocateExploreBudget, getExploreOutputBudget, EXPLORE_ALLOCATION } from '../src/mcp/tools';
import type { ExploreAllocationCandidate } from '../src/mcp/tools';
const cand = (
path: string,
score: number,
extra: Partial<ExploreAllocationCandidate> = {},
): ExploreAllocationCandidate => ({ path, score, worth: 1, spine: false, ...extra });
const budget = getExploreOutputBudget(1000);
describe('allocateExploreBudget — pinned files', () => {
it('never cliffs a pinned file, however low it scores', () => {
const { allowances, cliffed } = allocateExploreBudget(
[
cand('pinned.svelte', 0.1, { pinned: true }),
cand('hub.ts', 200),
cand('noise.ts', 0.1),
],
budget,
8,
);
expect(cliffed).toContain('noise.ts');
expect(cliffed).not.toContain('pinned.svelte');
expect(allowances.has('pinned.svelte')).toBe(true);
});
it('funds a pinned file at least as well as the strongest candidate', () => {
const { allowances } = allocateExploreBudget(
[
cand('pinned.svelte', 0.5, { pinned: true }),
cand('hub.ts', 300),
cand('helper.ts', 40),
],
budget,
8,
);
expect(allowances.get('pinned.svelte')!).toBeGreaterThanOrEqual(allowances.get('hub.ts')!);
expect(allowances.get('pinned.svelte')!).toBeGreaterThan(allowances.get('helper.ts')!);
});
it('keeps pinned files through the affordability trim', () => {
// Smallest tier: affordable = floor(13000 / (MIN_CHARS + FILE_OVERHEAD)) = 14
// slots. 18 equal-weight candidates admitted → the trim must cut 4. The
// pinned file sits last with a TIED weight (the pinned floor lifts it to
// the top weight), so the stable by-weight sort would slice it off — only
// the explicit spine/pinned keep saves it.
const tiny = getExploreOutputBudget(10);
const fleet = Array.from({ length: 17 }, (_, i) => cand(`f${i}.ts`, 100));
fleet.push(cand('pinned.svelte', 0.1, { pinned: true }));
const { allowances, cliffed } = allocateExploreBudget(fleet, tiny, 18);
expect(allowances.has('pinned.svelte')).toBe(true);
expect(cliffed).not.toContain('pinned.svelte');
expect(allowances.size).toBeLessThan(18);
});
it('an all-pinned zero-score call still allocates (pure-path query)', () => {
const { allowances, pool } = allocateExploreBudget(
[cand('a.svelte', 0, { pinned: true }), cand('b.svelte', 0, { pinned: true })],
budget,
8,
);
expect(pool).toBeGreaterThan(0);
expect(allowances.get('a.svelte')!).toBeGreaterThanOrEqual(EXPLORE_ALLOCATION.MIN_CHARS);
expect(allowances.get('b.svelte')!).toBeGreaterThanOrEqual(EXPLORE_ALLOCATION.MIN_CHARS);
});
it('unpinned behavior is unchanged when no candidate is pinned', () => {
const before = allocateExploreBudget(
[cand('a.ts', 40), cand('b.ts', 10)], budget, 8,
);
expect(before.allowances.get('a.ts')!).toBeGreaterThan(before.allowances.get('b.ts')!);
});
});
@@ -0,0 +1,5 @@
{
"name": "explore-path-pinning-fixture",
"version": "1.0.0",
"private": true
}
@@ -0,0 +1,31 @@
/** In-memory registry of task runs, keyed by run id. */
export interface Scope {
projectId: string;
label: string;
}
export const runId = 'run-000';
const runs = new Map<string, { id: string; scope: Scope; status: string }>();
export function registerRun(id: string, scope: Scope): void {
runs.set(id, { id, scope, status: 'queued' });
}
export function getRun(id: string): { id: string; scope: Scope; status: string } | null {
return runs.get(id) ?? null;
}
export function listRuns(scope: Scope): string[] {
return [...runs.values()]
.filter((r) => r.scope.projectId === scope.projectId)
.map((r) => r.id);
}
export function stopRun(id: string): boolean {
const run = runs.get(id);
if (!run) return false;
run.status = 'cancelled';
return true;
}
@@ -0,0 +1,28 @@
/** Detached chat window page — session presence + streaming state. */
let chatAtBottom = true;
let isStreaming = false;
let messages: string[] = [];
export function handleMessagesScroll(distance: number): void {
chatAtBottom = distance < 50;
}
export function sendMessage(text: string): void {
messages = [...messages, text];
isStreaming = true;
}
export function stopResponse(): void {
isStreaming = false;
}
export function redock(): void {
messages = [];
isStreaming = false;
chatAtBottom = true;
}
export function chatSnapshot(): { messages: string[]; streaming: boolean } {
return { messages: [...messages], streaming: isStreaming };
}
@@ -0,0 +1,67 @@
/** Mobile run feed — event stream + scroll pinning for the run page. */
export interface FeedEvent {
id: string;
kind: 'output' | 'tool' | 'error';
content: string;
}
const EVENT_CAP = 300;
let events: FeedEvent[] = [];
let workingLine: string | null = null;
/** Whether the reader is at the tail of the feed (within 50px). */
let feedAtBottom = true;
interface FeedElement {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}
let feedEl: FeedElement | null = null;
export function bindFeedElement(el: FeedElement | null): void {
feedEl = el;
}
/** Track the reader's position; called from the feed's scroll listener. */
export function handleFeedScroll(): void {
const el = feedEl;
if (!el) return;
feedAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
}
/** Re-pin after async content growth (image loads), only when at the tail. */
export function pinFeedIfNearBottom(): void {
const el = feedEl;
if (!el) return;
if (feedAtBottom) {
el.scrollTop = el.scrollHeight;
}
}
export function appendEvent(event: FeedEvent): void {
events = events.length >= EVENT_CAP
? [...events.slice(-(EVENT_CAP - 1)), event]
: [...events, event];
if (feedAtBottom) {
pinFeedIfNearBottom();
}
}
export function setWorkingLine(line: string | null): void {
workingLine = line;
pinFeedIfNearBottom();
}
export function resetFeed(): void {
events = [];
workingLine = null;
feedAtBottom = true;
}
export function feedSnapshot(): { events: FeedEvent[]; workingLine: string | null } {
return { events: [...events], workingLine };
}
+30
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import { import {
splitIdentifierSegments, splitIdentifierSegments,
extractProseCandidates, extractProseCandidates,
extractSegmentSearchWords,
normalizeProseWord, normalizeProseWord,
segmentLookupVariants, segmentLookupVariants,
} from '../src/search/identifier-segments'; } from '../src/search/identifier-segments';
@@ -101,3 +102,32 @@ describe('segmentLookupVariants — light plural folding', () => {
expect(segmentLookupVariants('boxes')).toEqual(['boxes']); // -es strip would go sub-minimum expect(segmentLookupVariants('boxes')).toEqual(['boxes']); // -es strip would go sub-minimum
}); });
}); });
describe('extractSegmentSearchWords — query words for the search-side vocab supplement', () => {
it('keeps prose words and adds camel-token segments', () => {
const words = extractSegmentSearchWords('auto-scroll to bottom — atBottom tracking');
// Prose candidates survive as before…
expect(words).toContain('scroll');
expect(words).toContain('bottom');
expect(words).toContain('tracking');
// …and the camel token contributed its ≥4-char segments ("at" is under
// the prose minimum; "bottom" arrives from the split even when the prose
// pass missed it).
expect(extractSegmentSearchWords('where is atBottom set')).toContain('bottom');
});
it('splits multi-hump tokens into every usable segment', () => {
const words = extractSegmentSearchWords('trace pinFeedIfNearBottom please');
expect(words).toEqual(expect.arrayContaining(['feed', 'near', 'bottom']));
});
it('does not invent segments for plain prose', () => {
const words = extractSegmentSearchWords('how does checkout work');
expect(words).toContain('checkout');
expect(words).not.toContain('check');
});
it('returns nothing for an empty query', () => {
expect(extractSegmentSearchWords('')).toEqual([]);
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* File-path recognition in explore queries (src/search/query-paths.ts).
*
* The originating bug: an agent named two SvelteKit route files by exact path
* (`src/routes/m/projects/[id]/runs/[runId]/+page.svelte`) and the explore
* pipeline shredded them — the seeding tokenizer splits on brackets, so the
* fragments `runId`/`scope` seeded as "named symbols" and headlined the blast
* radius, while FTS admitted every sibling `+page.svelte` off the `page`/`runs`
* fragments. These tests pin the module that stops that: path spans resolve
* against the indexed file list, matching files pin, and the spans leave the
* query. Resolution IS the detector — slash-bearing non-paths stay untouched.
*/
import { describe, it, expect } from 'vitest';
import { extractQueryPaths, queryMightContainPaths } from '../src/search/query-paths';
const INDEX = [
'src/routes/m/projects/[id]/runs/[runId]/+page.svelte',
'src/routes/m/projects/[id]/chat/[scope]/+page.svelte',
'src/routes/m/projects/[id]/+page.svelte',
'src/routes/(protected)/chat-window/+page.svelte',
'src/lib/chat-manager.ts',
'src/lib/task-runner-manager.ts',
'src/lib/stores/sqlite-store.ts',
'src/lib/stores/postgresql-store.ts',
];
describe('queryMightContainPaths — the cheap pre-gate', () => {
it('fires on slashes and dotted basenames', () => {
expect(queryMightContainPaths('look at src/lib/chat-manager.ts')).toBe(true);
expect(queryMightContainPaths('look at chat-manager.ts please')).toBe(true);
});
it('stays quiet on plain prose and Class.method spans', () => {
expect(queryMightContainPaths('how does the scroll pinning work')).toBe(false);
// `.isPackaged` is 10 chars — past the 8-char extension cap.
expect(queryMightContainPaths('what reads app.isPackaged here')).toBe(false);
});
});
describe('extractQueryPaths — resolution and stripping', () => {
it('resolves a bracketed SvelteKit path and strips it from the query', () => {
const q = 'auto-scroll logic in src/routes/m/projects/[id]/runs/[runId]/+page.svelte — atBottom tracking';
const out = extractQueryPaths(q, INDEX);
expect(out.pinnedFiles).toEqual(['src/routes/m/projects/[id]/runs/[runId]/+page.svelte']);
expect(out.strippedQuery).not.toContain('+page.svelte');
expect(out.strippedQuery).not.toContain('runId');
expect(out.strippedQuery).toContain('atBottom tracking');
expect(out.unresolvedPathSpans).toEqual([]);
});
it('pins multiple named files in appearance order', () => {
const q = 'compare src/routes/m/projects/[id]/chat/[scope]/+page.svelte and src/routes/m/projects/[id]/runs/[runId]/+page.svelte';
const out = extractQueryPaths(q, INDEX);
expect(out.pinnedFiles).toEqual([
'src/routes/m/projects/[id]/chat/[scope]/+page.svelte',
'src/routes/m/projects/[id]/runs/[runId]/+page.svelte',
]);
});
it('resolves a (protected) route-group path — parens are path characters', () => {
const out = extractQueryPaths('read src/routes/(protected)/chat-window/+page.svelte', INDEX);
expect(out.pinnedFiles).toEqual(['src/routes/(protected)/chat-window/+page.svelte']);
});
it('resolves an absolute path by walking suffixes to the indexed relative path', () => {
const q = 'fix /Users/colby/dev/beads-live-dashboard/src/lib/chat-manager.ts';
const out = extractQueryPaths(q, INDEX);
expect(out.pinnedFiles).toEqual(['src/lib/chat-manager.ts']);
});
it('resolves a unique basename and a partial path', () => {
expect(extractQueryPaths('see chat-manager.ts', INDEX).pinnedFiles)
.toEqual(['src/lib/chat-manager.ts']);
expect(extractQueryPaths('see stores/sqlite-store.ts', INDEX).pinnedFiles)
.toEqual(['src/lib/stores/sqlite-store.ts']);
});
it('strips wrapping punctuation and line references', () => {
const out = extractQueryPaths('the bug (see `src/lib/chat-manager.ts:243`).', INDEX);
expect(out.pinnedFiles).toEqual(['src/lib/chat-manager.ts']);
const hash = extractQueryPaths('regression at src/lib/task-runner-manager.ts#L88-L120', INDEX);
expect(hash.pinnedFiles).toEqual(['src/lib/task-runner-manager.ts']);
});
it('treats an over-ambiguous basename as unresolved — stripped and reported', () => {
const out = extractQueryPaths('why do all +page.svelte files flash', INDEX);
expect(out.pinnedFiles).toEqual([]);
expect(out.unresolvedPathSpans).toEqual(['+page.svelte']);
expect(out.strippedQuery).toBe('why do all files flash');
});
it('strips and reports a clearly-path-shaped span that matches nothing', () => {
const out = extractQueryPaths('crash in src/routes/gone/missing-page.svelte on load', INDEX);
expect(out.pinnedFiles).toEqual([]);
expect(out.unresolvedPathSpans).toEqual(['src/routes/gone/missing-page.svelte']);
expect(out.strippedQuery).toBe('crash in on load');
});
it('leaves slash-bearing non-paths alone', () => {
const q = 'does gen_server:call/2 block and/or timeout';
const out = extractQueryPaths(q, INDEX);
expect(out.pinnedFiles).toEqual([]);
expect(out.unresolvedPathSpans).toEqual([]);
expect(out.strippedQuery).toBe(q);
});
it('dedupes a path named twice and honors maxPins', () => {
const twice = extractQueryPaths(
'src/lib/chat-manager.ts wraps src/lib/chat-manager.ts', INDEX,
);
expect(twice.pinnedFiles).toEqual(['src/lib/chat-manager.ts']);
const capped = extractQueryPaths(
'src/lib/chat-manager.ts src/lib/task-runner-manager.ts', INDEX, { maxPins: 1 },
);
expect(capped.pinnedFiles).toEqual(['src/lib/chat-manager.ts']);
});
it('matches case-insensitively but returns the indexed spelling', () => {
const out = extractQueryPaths('SRC/LIB/CHAT-MANAGER.TS', INDEX);
expect(out.pinnedFiles).toEqual(['src/lib/chat-manager.ts']);
});
it('passes through untouched when nothing resolves', () => {
const q = 'plain prose question about scrolling';
const out = extractQueryPaths(q, INDEX);
expect(out).toEqual({ strippedQuery: q, pinnedFiles: [], unresolvedPathSpans: [] });
});
});
+31 -6
View File
@@ -171,6 +171,7 @@ const DEFAULT_FIND_OPTIONS: Required<FindRelevantContextOptions> = {
minScore: 0.3, minScore: 0.3,
edgeKinds: [], edgeKinds: [],
nodeKinds: HIGH_VALUE_NODE_KINDS, // Filter out imports/exports by default nodeKinds: HIGH_VALUE_NODE_KINDS, // Filter out imports/exports by default
seedNames: [], // Segment-vocab supplement — filled by the facade
}; };
// Re-export the low-confidence sentinel (defined in a dependency-free leaf so // Re-export the low-confidence sentinel (defined in a dependency-free leaf so
@@ -460,13 +461,37 @@ export class ContextBuilder {
// Step 2: Look up exact matches for extracted symbols // Step 2: Look up exact matches for extracted symbols
let exactMatches: SearchResult[] = []; let exactMatches: SearchResult[] = [];
if (symbolsFromQuery.length > 0) { if (symbolsFromQuery.length > 0 || opts.seedNames.length > 0) {
try { try {
// Get more results so we can apply co-location boosting before trimming if (symbolsFromQuery.length > 0) {
exactMatches = this.queries.findNodesByExactName(symbolsFromQuery, { // Get more results so we can apply co-location boosting before trimming
limit: Math.ceil(opts.searchLimit * 5), exactMatches = this.queries.findNodesByExactName(symbolsFromQuery, {
kinds: opts.nodeKinds && opts.nodeKinds.length > 0 ? opts.nodeKinds : undefined, limit: Math.ceil(opts.searchLimit * 5),
}); kinds: opts.nodeKinds && opts.nodeKinds.length > 0 ? opts.nodeKinds : undefined,
});
}
// Step 2a: segment-vocabulary seeds. Word-level query terms cannot
// reach camelCase names through FTS (one token per name), so the
// caller resolves query words → names via the segment vocab and hands
// them in as seedNames. Merged at a dampened score — a symbol the
// query names outright must outrank a segment-derived one — but
// BEFORE the co-location boost below, because several seeds landing
// in one file (pinFeedIfNearBottom + feedAtBottom + handleFeedScroll)
// is exactly the evidence that file is the answer.
if (opts.seedNames.length > 0) {
const seedResults = this.queries.findNodesByExactName(opts.seedNames, {
limit: Math.ceil(opts.searchLimit * 3),
kinds: opts.nodeKinds && opts.nodeKinds.length > 0 ? opts.nodeKinds : undefined,
});
const known = new Set(exactMatches.map((r) => r.node.id));
for (const r of seedResults) {
if (known.has(r.node.id)) continue;
known.add(r.node.id);
exactMatches.push({ ...r, score: r.score * 0.6 });
}
logDebug('Segment seed matches', { seedNames: opts.seedNames, added: known.size });
}
// Co-location boost: when multiple extracted symbols appear in the same file, // Co-location boost: when multiple extracted symbols appear in the same file,
// those results are much more likely to be what the user is looking for. // those results are much more likely to be what the user is looking for.
+18 -2
View File
@@ -54,7 +54,7 @@ import { EXTRACTION_VERSION } from './extraction/extraction-version';
import { getCodeGraphDir } from './directory'; import { getCodeGraphDir } from './directory';
import { deriveProjectNameTokens } from './search/query-utils'; import { deriveProjectNameTokens } from './search/query-utils';
import { CodeGraphPackageVersion } from './mcp/version'; import { CodeGraphPackageVersion } from './mcp/version';
import { segmentLookupVariants, splitIdentifierSegments } from './search/identifier-segments'; import { extractSegmentSearchWords, segmentLookupVariants, splitIdentifierSegments } from './search/identifier-segments';
import { createYielder } from './resolution/cooperative-yield'; import { createYielder } from './resolution/cooperative-yield';
import { minRefsForPool } from './resolution/resolver-pool'; import { minRefsForPool } from './resolution/resolver-pool';
@@ -1823,7 +1823,23 @@ export class CodeGraph {
query: string, query: string,
options?: FindRelevantContextOptions options?: FindRelevantContextOptions
): Promise<Subgraph> { ): Promise<Subgraph> {
return this.contextBuilder.findRelevantContext(query, options); // Segment-vocab supplement: FTS keeps camelCase names as single tokens,
// so a word-level query ("auto-scroll to bottom") can never reach
// `pinFeedIfNearBottom` through search alone. Resolve the query's words
// against name_segment_vocab (same precision rules as the prompt hook:
// co-occurrence, else rare singles, verified against live nodes) and hand
// the names down as dampened exact-name seeds. Callers that pass their
// own seedNames keep them; failures degrade to no supplement.
let seedNames = options?.seedNames;
if (seedNames === undefined) {
try {
seedNames = this.getSegmentMatches(extractSegmentSearchWords(query), 8)
.map((m) => m.name);
} catch {
seedNames = [];
}
}
return this.contextBuilder.findRelevantContext(query, { ...options, seedNames });
} }
/** /**
+4
View File
@@ -60,6 +60,8 @@ export interface ExploreCandidateMeta {
graphScore: number; graphScore: number;
termHits: number; termHits: number;
nodes: number; nodes: number;
/** The query named this file by PATH — pinned rank/allocation treatment. */
pinned?: boolean;
named: boolean; named: boolean;
central: boolean; central: boolean;
entry: boolean; entry: boolean;
@@ -579,6 +581,7 @@ export class ExploreDiagnostics {
graphScore: round6(r.graphScore), graphScore: round6(r.graphScore),
termHits: r.termHits, termHits: r.termHits,
nodes: r.nodes, nodes: r.nodes,
pinned: r.pinned ?? false,
named: r.named, named: r.named,
central: r.central, central: r.central,
entry: r.entry, entry: r.entry,
@@ -797,6 +800,7 @@ export function renderTable(report: ExploreDiagnosticReport): string {
function flagString(f: ExploreDiagnosticFile): string { function flagString(f: ExploreDiagnosticFile): string {
const flags: string[] = []; const flags: string[] = [];
if (f.pinned) flags.push('pinned');
if (f.named) flags.push('named'); if (f.named) flags.push('named');
if (f.entry) flags.push('entry'); if (f.entry) flags.push('entry');
if (f.central) flags.push('central'); if (f.central) flags.push('central');
+125 -23
View File
@@ -32,6 +32,7 @@ import {
import type { PendingFile } from '../sync'; import type { PendingFile } from '../sync';
import type { Node, Edge, SearchResult, Subgraph, NodeKind } from '../types'; import type { Node, Edge, SearchResult, Subgraph, NodeKind } from '../types';
import { isTestFile, normalizeNameToken } from '../search/query-utils'; import { isTestFile, normalizeNameToken } from '../search/query-utils';
import { extractQueryPaths, queryMightContainPaths } from '../search/query-paths';
import { import {
existsSync, existsSync,
readFileSync, readFileSync,
@@ -631,6 +632,13 @@ export interface ExploreAllocationCandidate {
worth: number; worth: number;
/** Carries a symbol on the rendered flow spine. */ /** Carries a symbol on the rendered flow spine. */
spine: boolean; spine: boolean;
/**
* The query named this file by PATH (see query-paths.ts). Pinned files are
* never cliffed or trimmed, and weigh at least as much as the strongest
* candidate the agent asked for the file itself, so starving it on text/
* graph scores (which a pure-path query doesn't produce) defeats the ask.
*/
pinned?: boolean;
} }
export interface ExploreAllocation { export interface ExploreAllocation {
@@ -676,7 +684,16 @@ export function allocateExploreBudget(
return Number.isFinite(w) ? w : 0; return Number.isFinite(w) ? w : 0;
}; };
const weights = new Map(candidates.map((c) => [c.path, weightOf(c)])); // Pinned files weigh at least as much as the strongest raw candidate: their
// score is whatever the stripped query happened to match (for a pure-path
// query, nearly nothing), and a proportional split on that would fund the
// named file worst of all. Floor of 1 covers the all-pinned/zero-score case.
const rawWeights = new Map(candidates.map((c) => [c.path, weightOf(c)]));
const topRaw = Math.max(...rawWeights.values());
const weights = new Map(candidates.map((c) => [
c.path,
c.pinned ? Math.max(rawWeights.get(c.path) ?? 0, topRaw, 1) : (rawWeights.get(c.path) ?? 0),
]));
const topWeight = Math.max(...weights.values()); const topWeight = Math.max(...weights.values());
if (!(topWeight > 0)) return empty; if (!(topWeight > 0)) return empty;
@@ -686,7 +703,7 @@ export function allocateExploreBudget(
const cliffed: string[] = []; const cliffed: string[] = [];
let admitted: ExploreAllocationCandidate[] = []; let admitted: ExploreAllocationCandidate[] = [];
for (const c of candidates) { for (const c of candidates) {
if (!c.spine && (weights.get(c.path) ?? 0) < cliffAt) cliffed.push(c.path); if (!c.spine && !c.pinned && (weights.get(c.path) ?? 0) < cliffAt) cliffed.push(c.path);
else admitted.push(c); else admitted.push(c);
} }
// Never cliff every candidate: an empty response costs a whole round-trip. // Never cliff every candidate: an empty response costs a whole round-trip.
@@ -705,7 +722,7 @@ export function allocateExploreBudget(
if (admitted.length > affordable) { if (admitted.length > affordable) {
const byWeight = [...admitted].sort((a, b) => (weights.get(b.path) ?? 0) - (weights.get(a.path) ?? 0)); const byWeight = [...admitted].sort((a, b) => (weights.get(b.path) ?? 0) - (weights.get(a.path) ?? 0));
const keep = new Set(byWeight.slice(0, affordable).map((c) => c.path)); const keep = new Set(byWeight.slice(0, affordable).map((c) => c.path));
for (const c of admitted) if (c.spine) keep.add(c.path); for (const c of admitted) if (c.spine || c.pinned) keep.add(c.path);
for (const c of admitted) if (!keep.has(c.path)) cliffed.push(c.path); for (const c of admitted) if (!keep.has(c.path)) cliffed.push(c.path);
admitted = admitted.filter((c) => keep.has(c.path)); admitted = admitted.filter((c) => keep.has(c.path));
} }
@@ -3223,6 +3240,34 @@ export class ToolHandler {
} }
const maxFiles = clamp((args.maxFiles as number) || budget.defaultMaxFiles, 1, 20); const maxFiles = clamp((args.maxFiles as number) || budget.defaultMaxFiles, 1, 20);
// File paths named in the query become PINNED files: guaranteed admission,
// top of the rank order, funded first — and their span is REMOVED from the
// matching query. Runs on the RAW query (normalizeQuerySpelling strips
// `/digits` tails, which would mangle numeric path segments). Without this,
// a SvelteKit path like `runs/[runId]/+page.svelte` was shredded by the
// seeding tokenizer (splits on brackets → `runId` seeded as a "named
// symbol") and by FTS (`page`/`runs` fragments admitted every sibling
// `+page.svelte`), starving the very files the agent asked for.
let pinnedFiles: string[] = [];
let unresolvedPathSpans: string[] = [];
let matchQuery = query;
if (queryMightContainPaths(rawQuery)) {
try {
const extraction = extractQueryPaths(
rawQuery,
cg.getFiles().map((f) => f.path),
{ maxPins: maxFiles },
);
if (extraction.pinnedFiles.length > 0 || extraction.unresolvedPathSpans.length > 0) {
pinnedFiles = extraction.pinnedFiles;
unresolvedPathSpans = extraction.unresolvedPathSpans;
matchQuery = normalizeQuerySpelling(extraction.strippedQuery);
}
} catch { /* path pinning must never fail an explore call */ }
}
const pinnedSet = new Set(pinnedFiles);
const pinnedOrder = new Map(pinnedFiles.map((p, i) => [p, i]));
// Per-file allocation diagnostic (CG-4). `null` unless CODEGRAPH_EXPLORE_DEBUG // Per-file allocation diagnostic (CG-4). `null` unless CODEGRAPH_EXPLORE_DEBUG
// is set — every `diag?.` below is then a no-op and the response is // is set — every `diag?.` below is then a no-op and the response is
// byte-identical. It only OBSERVES: it must never feed back into rendering. // byte-identical. It only OBSERVES: it must never feed back into rendering.
@@ -3279,16 +3324,34 @@ export class ToolHandler {
// Use a large maxNodes budget — explore has its own 35k char output limit // Use a large maxNodes budget — explore has its own 35k char output limit
// that prevents context bloat, so more nodes just means better coverage // that prevents context bloat, so more nodes just means better coverage
// across entry points (especially for large files like Svelte components). // across entry points (especially for large files like Svelte components).
const subgraph = await cg.findRelevantContext(query, { // Matching runs on the path-stripped query; `query` stays for display.
const subgraph = await cg.findRelevantContext(matchQuery, {
searchLimit: 8, searchLimit: 8,
traversalDepth: 3, traversalDepth: 3,
maxNodes: 200, maxNodes: 200,
minScore: 0.2, minScore: 0.2,
}); });
// Pinned files' symbols enter the gather unconditionally — the agent named
// the file itself, so its contents ARE the answer regardless of what the
// stripped query text matched (which, for a pure-path query, is nothing).
const PINNED_FILE_NODE_CAP = 300;
for (const fp of pinnedFiles) {
let fileNodes: Node[] = [];
try { fileNodes = cg.getNodesInFile(fp); } catch { continue; }
fileNodes
.filter((n) => n.kind !== 'file' && n.kind !== 'import' && n.kind !== 'export')
.sort((a, b) => a.startLine - b.startLine)
.slice(0, PINNED_FILE_NODE_CAP)
.forEach((n) => { if (!subgraph.nodes.has(n.id)) subgraph.nodes.set(n.id, n); });
}
if (subgraph.nodes.size === 0) { if (subgraph.nodes.size === 0) {
diag?.finishEmpty('no relevant code found — empty subgraph'); diag?.finishEmpty('no relevant code found — empty subgraph');
const empty = `No relevant code found for "${query}"`; const missNote = unresolvedPathSpans.length > 0
? ` (no indexed file uniquely matches ${unresolvedPathSpans.map((s) => `\`${s}\``).join(', ')})`
: '';
const empty = `No relevant code found for "${query}"${missNote}`;
// Still an explore call, so it is still recorded: an empty answer spends a // Still an explore call, so it is still recorded: an empty answer spends a
// call against the tier budget even though it emits no source. // call against the tier budget even though it emits no source.
return this.exploreResult(empty, { return this.exploreResult(empty, {
@@ -3351,11 +3414,18 @@ export class ToolHandler {
{ {
const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl)$/i; const FILE_EXT = /\.(?:java|kt|kts|ts|tsx|js|jsx|mjs|cjs|cs|py|go|rb|php|swift|rs|cpp|cc|cxx|c|h|hpp|scala|lua|dart|vue|svelte|astro|erl|hrl)$/i;
const CALLABLE = new Set(['method', 'function', 'component', 'constructor']); const CALLABLE = new Set(['method', 'function', 'component', 'constructor']);
// Variables/constants seed too: in Svelte/React a `$state` variable
// (`chatAtBottom`, `feedAtBottom`) is exactly the kind of symbol an agent
// names in a query, and the exact-name search channel already returns
// them — only this seeding tier was callable-only. The NL-stopword guard
// below applies unchanged, so bare English words still can't seed a
// same-named local. Callables keep priority via the body-size sort.
const SEEDABLE = new Set([...CALLABLE, 'variable', 'constant']);
const isTestPath = (p: string) => /(^|\/)(tests?|specs?|__tests__|testdata|mocks?|fixtures?)\//i.test(p) || /\.(test|spec)\.[a-z]+$/i.test(p); const isTestPath = (p: string) => /(^|\/)(tests?|specs?|__tests__|testdata|mocks?|fixtures?)\//i.test(p) || /\.(test|spec)\.[a-z]+$/i.test(p);
const bodyLines = (n: Node) => Math.max(0, (n.endLine ?? n.startLine) - n.startLine); const bodyLines = (n: Node) => Math.max(0, (n.endLine ?? n.startLine) - n.startLine);
const callerCount = (n: Node) => { try { return cg.getCallers(n.id).length; } catch { return 0; } }; const callerCount = (n: Node) => { try { return cg.getCallers(n.id).length; } catch { return 0; } };
const tokens = [...new Set( const tokens = [...new Set(
query.split(/[\s,()[\]]+/) matchQuery.split(/[\s,()[\]]+/)
.map((t) => t.replace(FILE_EXT, '').trim()) .map((t) => t.replace(FILE_EXT, '').trim())
.filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t)) .filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t))
)].slice(0, 16); )].slice(0, 16);
@@ -3430,24 +3500,26 @@ export class ToolHandler {
} }
} }
let cands = raw let cands = raw
.filter((n) => CALLABLE.has(n.kind) && !isTestPath(n.filePath)) .filter((n) => SEEDABLE.has(n.kind) && !isTestPath(n.filePath))
.sort((a, b) => (bodyLines(b) > 1 ? 1 : 0) - (bodyLines(a) > 1 ? 1 : 0) || bodyLines(b) - bodyLines(a)); .sort((a, b) => (bodyLines(b) > 1 ? 1 : 0) - (bodyLines(a) > 1 ? 1 : 0) || bodyLines(b) - bodyLines(a));
// Field-name seeding fallback (#1196): a camelCase token that names NO // Field-name seeding fallback (#1196): a camelCase token that names NO
// definition of its own is usually an object-literal key / API field // definition of its own is usually an object-literal key / API field
// (`profileInfo`) — no node exists, so it contributed zero seeds and // (`profileInfo`) — no node exists, so it contributed zero seeds and
// the files that DEFINE it (`getProfileInfoV2` in profileController) // the files that DEFINE it (`getProfileInfoV2` in profileController)
// never surfaced. Seed its camel-infix definers instead: callables // never surfaced. Seed its camel-infix definers instead: seedable
// whose name contains the token at a hump boundary or as a prefix. // symbols (callables + variables — `atBottom` must reach the `$state`
// variables `feedAtBottom`/`chatAtBottom`) whose name contains the
// token at a hump boundary or as a prefix.
// Exact-empty + camel-shaped only (bare words keep the NL-stopword // Exact-empty + camel-shaped only (bare words keep the NL-stopword
// guard below), shortest-first, capped so a hot infix can't flood. // guard below), shortest-first, capped so a hot infix can't flood.
if (cands.length === 0 && !isQual && /[a-z][A-Z]/.test(t)) { if (cands.length === 0 && !isQual && /[a-z][A-Z]/.test(t)) {
const lcToken = t.toLowerCase(); const lcToken = t.toLowerCase();
cands = cg cands = cg
.getNodesByNameSubstring(t, { .getNodesByNameSubstring(t, {
kinds: ['function', 'method', 'component'], kinds: ['function', 'method', 'component', 'variable', 'constant'],
limit: 60, limit: 60,
}) })
.filter((n) => CALLABLE.has(n.kind) && !isTestPath(n.filePath)) .filter((n) => SEEDABLE.has(n.kind) && !isTestPath(n.filePath))
.filter((n) => { .filter((n) => {
const idx = n.name.toLowerCase().indexOf(lcToken); const idx = n.name.toLowerCase().indexOf(lcToken);
if (idx < 0) return false; if (idx < 0) return false;
@@ -3624,8 +3696,9 @@ export class ToolHandler {
fileGroups.set(node.filePath, group); fileGroups.set(node.filePath, group);
} }
// Extract query terms for relevance checking // Extract query terms for relevance checking (path-stripped: a pinned
const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3); // file's own path fragments must not count as "term hits" everywhere)
const queryTerms = matchQuery.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
// Test/spec/icon/i18n file detector — used by the pre-floor hard filter, the // Test/spec/icon/i18n file detector — used by the pre-floor hard filter, the
// rank penalty, and the comparator deprioritization. // rank penalty, and the comparator deprioritization.
@@ -3715,9 +3788,10 @@ export class ToolHandler {
// keep-minimum then pulled two test files back in as the "spread". // keep-minimum then pulled two test files back in as the "spread".
let candidateFiles = [...fileGroups.entries()]; let candidateFiles = [...fileGroups.entries()];
{ {
const queryMentionsTests = /\b(test|tests|testing|spec|verify|verifies)\b/i.test(query); const queryMentionsTests = /\b(test|tests|testing|spec|verify|verifies)\b/i.test(matchQuery);
if (!queryMentionsTests) { if (!queryMentionsTests) {
const nonLow = candidateFiles.filter(([p]) => !isLowValue(p)); // A pinned file is exempt: naming a test file by path IS asking for it.
const nonLow = candidateFiles.filter(([p]) => !isLowValue(p) || pinnedSet.has(p));
if (nonLow.length >= 2) { if (nonLow.length >= 2) {
candidateFiles = nonLow; candidateFiles = nonLow;
} }
@@ -3732,7 +3806,9 @@ export class ToolHandler {
SCORE_FLOOR_ABSOLUTE, SCORE_FLOOR_ABSOLUTE,
Math.min(SCORE_FLOOR_MAX, topScore * SCORE_FLOOR_FRACTION_OF_TOP), Math.min(SCORE_FLOOR_MAX, topScore * SCORE_FLOOR_FRACTION_OF_TOP),
); );
let relevantFiles = candidateFiles.filter(([, group]) => group.score >= scoreFloor); let relevantFiles = candidateFiles.filter(
([fp, group]) => group.score >= scoreFloor || pinnedSet.has(fp),
);
if (relevantFiles.length < SCORE_FLOOR_KEEP_MIN) { if (relevantFiles.length < SCORE_FLOOR_KEEP_MIN) {
// Backfill from what the RELATIVE floor cut, best first, at two strengths: // Backfill from what the RELATIVE floor cut, best first, at two strengths:
// //
@@ -3747,8 +3823,11 @@ export class ToolHandler {
// worst outcome on the board — the agent falls straight back to grep. // worst outcome on the board — the agent falls straight back to grep.
const minEvidence = relevantFiles.length === 0 ? Number.EPSILON : SCORE_FLOOR_ABSOLUTE; const minEvidence = relevantFiles.length === 0 ? Number.EPSILON : SCORE_FLOOR_ABSOLUTE;
relevantFiles = candidateFiles relevantFiles = candidateFiles
.filter(([, group]) => group.score >= minEvidence) .filter(([fp, group]) => group.score >= minEvidence || pinnedSet.has(fp))
.sort((a, b) => b[1].score - a[1].score || b[1].nodes.length - a[1].nodes.length) .sort((a, b) =>
(pinnedSet.has(b[0]) ? 1 : 0) - (pinnedSet.has(a[0]) ? 1 : 0)
|| b[1].score - a[1].score
|| b[1].nodes.length - a[1].nodes.length)
.slice(0, Math.max(SCORE_FLOOR_KEEP_MIN, relevantFiles.length)); .slice(0, Math.max(SCORE_FLOOR_KEEP_MIN, relevantFiles.length));
} }
diag?.setScoreFloor(scoreFloor, relevantFiles.length); diag?.setScoreFloor(scoreFloor, relevantFiles.length);
@@ -3852,7 +3931,8 @@ export class ToolHandler {
// never prunes below 2. // never prunes below 2.
if (maxGraph > 0) { if (maxGraph > 0) {
const gated = relevantFiles.filter(([fp]) => const gated = relevantFiles.filter(([fp]) =>
(fileGraphScore.get(fp) ?? 0) >= maxGraph * 0.06 pinnedSet.has(fp)
|| (fileGraphScore.get(fp) ?? 0) >= maxGraph * 0.06
|| centralFiles.has(fp) || centralFiles.has(fp)
|| entryFiles.has(fp) || entryFiles.has(fp)
|| changeSurfaceFiles.has(fp) || changeSurfaceFiles.has(fp)
@@ -3908,7 +3988,15 @@ export class ToolHandler {
const aPath = a[0].toLowerCase(); const aPath = a[0].toLowerCase();
const bPath = b[0].toLowerCase(); const bPath = b[0].toLowerCase();
// Agent-named files first (it asked for a symbol defined here by name). // Pinned files first of all — the agent named the FILE by path, which is
// even more explicit than naming a symbol in it. Among pins, keep the
// order they appeared in the query.
const aPin = pinnedSet.has(a[0]) ? 1 : 0;
const bPin = pinnedSet.has(b[0]) ? 1 : 0;
if (aPin !== bPin) return bPin - aPin;
if (aPin && bPin) return (pinnedOrder.get(a[0]) ?? 0) - (pinnedOrder.get(b[0]) ?? 0);
// Agent-named files next (it asked for a symbol defined here by name).
const aNamed = namedSeedFiles.has(a[0]) ? 1 : 0; const aNamed = namedSeedFiles.has(a[0]) ? 1 : 0;
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;
@@ -4010,7 +4098,7 @@ export class ToolHandler {
// Compute the flow spine once — used both to prepend the Flow section (below) // Compute the flow spine once — used both to prepend the Flow section (below)
// and to gate adaptive source sizing: files on the spine get full source, // and to gate adaptive source sizing: files on the spine get full source,
// off-spine peers skeletonize. // off-spine peers skeletonize.
const flow = this.buildFlowFromNamedSymbols(cg, query); const flow = this.buildFlowFromNamedSymbols(cg, matchQuery);
// Snapshot every ranked candidate's scoring inputs, in final sort order, so // Snapshot every ranked candidate's scoring inputs, in final sort order, so
// the diagnostic can show what each file's share of the envelope was BOUGHT // the diagnostic can show what each file's share of the envelope was BOUGHT
@@ -4031,6 +4119,7 @@ export class ToolHandler {
graphScore: fileGraphScore.get(fp) ?? 0, graphScore: fileGraphScore.get(fp) ?? 0,
termHits: fileTermHits.get(fp) ?? 0, termHits: fileTermHits.get(fp) ?? 0,
nodes: group.nodes.length, nodes: group.nodes.length,
pinned: pinnedSet.has(fp),
named: namedSeedFiles.has(fp), named: namedSeedFiles.has(fp),
central: centralFiles.has(fp), central: centralFiles.has(fp),
entry: entryFiles.has(fp), entry: entryFiles.has(fp),
@@ -4051,8 +4140,11 @@ export class ToolHandler {
sortedFiles.map(([fp, group]) => ({ sortedFiles.map(([fp, group]) => ({
path: fp, path: fp,
score: group.score, score: group.score,
worth: rankPenalty(fp), // A pinned file's bytes are worth full price by definition — the agent
// asked for the file itself, generated/test or not.
worth: pinnedSet.has(fp) ? 1 : rankPenalty(fp),
spine: group.nodes.some((n) => flow.pathNodeIds.has(n.id)), spine: group.nodes.some((n) => flow.pathNodeIds.has(n.id)),
pinned: pinnedSet.has(fp),
})), })),
budget, budget,
maxFiles, maxFiles,
@@ -5804,9 +5896,19 @@ export class ToolHandler {
g.nodes.filter((n) => n.kind !== 'import' && n.kind !== 'export').map((n) => n.id), g.nodes.filter((n) => n.kind !== 'import' && n.kind !== 'export').map((n) => n.id),
).size; ).size;
}, 0); }, 0);
const summaryLine = survivors.length > 0 let summaryLine = survivors.length > 0
? `Found ${shownSymbols} symbol${shownSymbols === 1 ? '' : 's'} across ${survivors.length} file${survivors.length === 1 ? '' : 's'}.` ? `Found ${shownSymbols} symbol${shownSymbols === 1 ? '' : 's'} across ${survivors.length} file${survivors.length === 1 ? '' : 's'}.`
: `Found ${subgraph.nodes.size} symbol${subgraph.nodes.size === 1 ? '' : 's'} across ${fileGroups.size} file${fileGroups.size === 1 ? '' : 's'}.`; : `Found ${subgraph.nodes.size} symbol${subgraph.nodes.size === 1 ? '' : 's'} across ${fileGroups.size} file${fileGroups.size === 1 ? '' : 's'}.`;
// Path pinning is visible, not silent: say which query-named files were
// honored, and which path spans matched nothing so the agent can correct
// them instead of trusting a response that quietly ignored the path.
const pinnedShown = pinnedFiles.filter((fp) => survivors.includes(fp)).length;
if (pinnedShown > 0) {
summaryLine += ` ${pinnedShown} file${pinnedShown === 1 ? '' : 's'} pinned from the query.`;
}
if (unresolvedPathSpans.length > 0) {
summaryLine += ` No indexed file uniquely matches ${unresolvedPathSpans.map((s) => `\`${s}\``).join(', ')}.`;
}
finalText = finalText.replace(SUMMARY_SENTINEL, summaryLine); finalText = finalText.replace(SUMMARY_SENTINEL, summaryLine);
// Emit the allocation diagnostic from the FINAL text, so per-file bytes and // Emit the allocation diagnostic from the FINAL text, so per-file bytes and
+27
View File
@@ -126,6 +126,33 @@ export function extractProseCandidates(prompt: string): string[] {
return [...seen]; return [...seen];
} }
/**
* Words to look up in the segment vocabulary for a SEARCH query (as opposed
* to a prompt-hook gate): the query's prose candidates PLUS the segments of
* its identifier-shaped tokens. An agent's query names concepts both ways —
* "auto-scroll to bottom" (prose) and "atBottom tracking" (camel) — and the
* camel token must still reach the segment "bottom" even though the whole
* token matches no name. Same stopword/length rules as the hook path, since
* both feeds run through {@link extractProseCandidates}.
*/
export function extractSegmentSearchWords(query: string): string[] {
if (!query) return [];
const words = new Set(extractProseCandidates(query));
const segments: string[] = [];
for (const run of query.match(/[\p{L}\p{N}]+/gu) ?? []) {
// Only camel-humped tokens contribute segments — a plain word's
// "segments" are itself (already covered above), and snake_case arrives
// as separate runs because `_` is not a letter.
if (/[\p{Ll}\p{N}]\p{Lu}/u.test(run)) {
segments.push(...splitIdentifierSegments(run));
}
}
if (segments.length > 0) {
for (const w of extractProseCandidates(segments.join(' '))) words.add(w);
}
return [...words];
}
/** /**
* Lookup variants for a prose word: the word itself plus light plural folding * Lookup variants for a prose word: the word itself plus light plural folding
* ("services" → service, "dependencies" → dependencie/dependency is NOT * ("services" → service, "dependencies" → dependencie/dependency is NOT
+213
View File
@@ -0,0 +1,213 @@
/**
* File-path recognition for explore queries.
*
* Agents routinely name files by path in a `codegraph_explore` query —
* "the scroll logic in src/routes/m/projects/[id]/runs/[runId]/+page.svelte" —
* and until this module existed those spans were SHREDDED by the downstream
* tokenizers instead of being read as file references:
*
* - the named-symbol seeder splits on `[\s,()[\]]+`, so SvelteKit/Next
* bracketed segments (`[id]`, `[runId]`) and route groups (`(protected)`)
* exploded the path into fragments; the identifier-shaped survivors
* (`runId`, `scope`) then seeded as "symbols the agent named" and
* headlined the blast radius;
* - FTS saw the fragments (`page`, `chat`, `runs`) and admitted every
* sibling `+page.svelte` in the repo, which ate the output envelope and
* truncated the files the agent actually asked for.
*
* `extractQueryPaths` finds path-like spans, resolves them against the
* INDEXED file list (resolution IS the detector — `and/or`, `gen_server:call/2`
* and other slash-bearing non-paths match nothing and are left alone), and
* returns the matches as pinned files plus the query with those spans removed.
* Callers treat pinned files as first-class: guaranteed admission, top rank,
* funded first. Pure string work — no DB, no fs — so it is trivially testable
* and safe inside the query-pool workers.
*/
export interface QueryPathExtraction {
/** The query with resolved/clearly-path spans removed, whitespace-joined. */
strippedQuery: string;
/** Indexed file paths the query named, appearance-ordered, deduped. */
pinnedFiles: string[];
/**
* Spans that are unambiguously path-shaped but resolved to nothing (stale
* path, unindexed file) or to too many files (bare `+page.svelte`). Stripped
* from the query — their fragments could only mint junk matches — and
* surfaced to the agent so the miss is visible instead of silent.
*/
unresolvedPathSpans: string[];
}
/**
* Cheap pre-gate so callers only fetch the indexed file list when the query
* could possibly contain a path: a slash, or a dot-extension-shaped tail
* (`chat-manager.ts`). Extensions cap at 8 chars, which keeps `Class.method`
* spans (`app.isPackaged`) from qualifying.
*/
export function queryMightContainPaths(query: string): boolean {
return /[/\\]/.test(query) || /\.[A-Za-z][A-Za-z0-9]{0,7}(?=[\s,;:)\]'"`]|$)/.test(query);
}
/**
* Longest span→suffix walk tried per span. 8 covers an absolute macOS path
* (`/Users/<user>/dev/<repo>/…`) over a deeply nested repo-relative file;
* deeper prefixes buy nothing.
*/
const MAX_SUFFIX_TRIES = 8;
/** Spans examined per query — a prose sentence is not 50 paths. */
const MAX_CANDIDATE_SPANS = 8;
/** `name.ext` shape with a plausible source extension (no slash required). */
const DOTTED_BASENAME = /^[^\s/\\]+\.[A-Za-z][A-Za-z0-9]{0,7}$/;
/**
* Strip prose punctuation wrapped around a token without eating punctuation
* that is PART of the path: quotes/backticks always strip; a trailing `)`/`]`
* strips only when the token has no matching opener (so `(protected)` and
* `[id]` segments survive, while "…(see src/foo.ts)" loses its parenthesis);
* a leading `(`/`[` mirrors that. Trailing sentence punctuation strips last,
* so "src/foo.ts." resolves.
*/
function stripWrapping(token: string): string {
let s = token;
for (;;) {
const first = s[0];
if (!first) break;
if ('\'"`<'.includes(first)) { s = s.slice(1); continue; }
if (first === '(' && !s.includes(')')) { s = s.slice(1); continue; }
if (first === '[' && !s.includes(']')) { s = s.slice(1); continue; }
if (first === '{' && !s.includes('}')) { s = s.slice(1); continue; }
break;
}
for (;;) {
const last = s[s.length - 1];
if (!last) break;
if ('\'"`>.,;!?'.includes(last)) { s = s.slice(0, -1); continue; }
if (last === ')' && !s.includes('(')) { s = s.slice(0, -1); continue; }
if (last === ']' && !s.includes('[')) { s = s.slice(0, -1); continue; }
if (last === '}' && !s.includes('{')) { s = s.slice(0, -1); continue; }
break;
}
// Line references ride along in agent-written paths: `foo.ts:123`,
// `foo.ts:12-40`, `foo.ts#L88`. The file is what gets pinned.
s = s.replace(/(?::\d+(?:-\d+)?|#L\d+(?:-L?\d+)?)$/, '');
return s;
}
/** Normalize a span into the repo-relative shape the files table stores. */
function normalizeSpan(span: string): string {
return span
.replace(/\\/g, '/')
.replace(/^(?:\.\/)+/, '')
.replace(/\/{2,}/g, '/')
.replace(/\/+$/, '');
}
/** Path-shaped beyond doubt: ≥2 segments and a dot-extension on the last. */
function isClearlyPathShaped(normalized: string): boolean {
const slash = normalized.lastIndexOf('/');
if (slash <= 0) return false;
return DOTTED_BASENAME.test(normalized.slice(slash + 1));
}
/**
* Resolve one normalized span against the indexed paths: exact match first,
* then segment-aligned suffix matches, dropping leading segments one at a
* time (so an absolute path, or one prefixed with the repo directory name,
* still lands on the indexed repo-relative file). Suffixes only get shorter —
* and therefore only match MORE — so the walk stops at the first suffix that
* matches anything: within budget it resolves, over budget it is ambiguous.
*/
function resolveSpan(
normalizedLower: string,
lowerToOriginal: ReadonlyMap<string, string>,
maxMatches: number,
): { matches: string[]; ambiguous: boolean } {
const exact = lowerToOriginal.get(normalizedLower);
if (exact) return { matches: [exact], ambiguous: false };
const segments = normalizedLower.split('/').filter(Boolean);
const tries = Math.min(segments.length, MAX_SUFFIX_TRIES);
for (let drop = 0; drop < tries; drop++) {
const suffix = segments.slice(drop).join('/');
if (!suffix) break;
const withSlash = '/' + suffix;
const matches: string[] = [];
for (const [lower, original] of lowerToOriginal) {
if (lower === suffix || lower.endsWith(withSlash)) {
matches.push(original);
if (matches.length > maxMatches) return { matches: [], ambiguous: true };
}
}
if (matches.length > 0) return { matches, ambiguous: false };
}
return { matches: [], ambiguous: false };
}
export function extractQueryPaths(
query: string,
indexedPaths: readonly string[],
opts: { maxPins?: number; maxMatchesPerSpan?: number } = {},
): QueryPathExtraction {
const maxPins = Math.max(1, opts.maxPins ?? 8);
const maxMatchesPerSpan = Math.max(1, opts.maxMatchesPerSpan ?? 3);
const passthrough: QueryPathExtraction = {
strippedQuery: query,
pinnedFiles: [],
unresolvedPathSpans: [],
};
if (!query.trim() || indexedPaths.length === 0) return passthrough;
// Lowercase view of the index, built once per call. Last writer wins on a
// case-colliding pair, which is the existing file-view behavior too.
const lowerToOriginal = new Map<string, string>();
for (const p of indexedPaths) lowerToOriginal.set(p.toLowerCase(), p);
const tokens = query.split(/\s+/).filter(Boolean);
const consumed = new Set<number>();
const pinned: string[] = [];
const pinnedSeen = new Set<string>();
const unresolved: string[] = [];
let candidatesExamined = 0;
for (let i = 0; i < tokens.length; i++) {
if (pinned.length >= maxPins) break;
if (candidatesExamined >= MAX_CANDIDATE_SPANS) break;
const stripped = stripWrapping(tokens[i]!);
if (stripped.length < 4) continue;
const hasSlash = /[/\\]/.test(stripped);
if (!hasSlash && !DOTTED_BASENAME.test(stripped)) continue;
const normalized = normalizeSpan(stripped);
if (!normalized) continue;
candidatesExamined++;
const { matches, ambiguous } = resolveSpan(
normalized.toLowerCase(), lowerToOriginal, maxMatchesPerSpan,
);
if (matches.length > 0) {
consumed.add(i);
for (const m of matches) {
if (pinnedSeen.has(m) || pinned.length >= maxPins) continue;
pinnedSeen.add(m);
pinned.push(m);
}
} else if (ambiguous || isClearlyPathShaped(normalized)) {
// A real path that didn't resolve to a usable set. Keeping it in the
// query is strictly worse — its fragments are what minted the junk
// matches this module exists to stop — so strip it and say so.
consumed.add(i);
if (unresolved.length < 4) unresolved.push(normalized);
}
// Anything else (`and/or`, `call/2`, `foo.Bar`) is not a path reference:
// leave the token for the normal matching pipeline.
}
if (consumed.size === 0) return passthrough;
return {
strippedQuery: tokens.filter((_, i) => !consumed.has(i)).join(' '),
pinnedFiles: pinned,
unresolvedPathSpans: unresolved,
};
}
+9
View File
@@ -688,4 +688,13 @@ export interface FindRelevantContextOptions {
/** Node types to include */ /** Node types to include */
nodeKinds?: NodeKind[]; nodeKinds?: NodeKind[];
/**
* Extra symbol names to merge in as exact-name search candidates, at a
* dampened score. Fed by the segment-vocabulary supplement (CodeGraph.
* findRelevantContext): word-level query terms can't reach camelCase names
* through FTS — `pinFeedIfNearBottom` is one FTS token — so names whose
* SEGMENTS the query's words name are seeded here instead.
*/
seedNames?: string[];
} }