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:
@@ -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 2–3)', () => {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
+67
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user