feat(app): search history by workspace, agent, and branch (#2995)
History was a date-grouped list you scrolled. Finding a session from last week meant eyeballing every row. This adds a search field to the existing filter rail, next to the host picker. Search runs on the daemon, not the client. The daemon already holds every persisted agent in memory, so it ranks the whole history and the client never has to warn that it only looked at the pages it had loaded. A searched response is one ranked page with no cursor. The global top K is always contained in the union of each host's own top K, so merging per-host pages and slicing back to the limit is the exact global answer — no k-way merge, and no offset into a ranking that is recomputed per request, which would duplicate and skip rows as history changes underneath it. Past result 200 of a relevance order the answer is a narrower query, so the footer says that instead of offering a "Load more" that cannot deliver. Matching is the app's existing picker matcher, moved to the protocol package so app and daemon share one implementation instead of drifting into two "why didn't this show up?" rule sets. Typo tolerance is opt-in, so the comboboxes keep exact narrowing; the budget is per token, and four-character tokens get transpositions only, because at that length a free substitution turns "main" into "mail", "maid" and "rain". Ranked rows carry the spans they matched on, so results can mark them. The subsequence and typo tiers match characters the eye would never find unaided, and an unexplained row reads as a bug. The ranker computes the spans anyway; sending them keeps the client from deriving a second opinion that could disagree with the ranking it is explaining. Hosts that fail or are offline are now named above the list. Without that, "No sessions match" is a claim the app has no basis for. Gated on server_info.features.agentHistorySearch, checked once against the connected hosts. Reachability is reported by the banner instead, so one offline host in the registry no longer hides the field.
This commit is contained in:
@@ -1138,6 +1138,10 @@ export const FetchAgentHistoryRequestMessageSchema = z.object({
|
||||
type: z.literal("fetch_agent_history_request"),
|
||||
requestId: z.string(),
|
||||
filter: AgentDirectoryFilterSchema.optional(),
|
||||
// A ranked free-text query over agent title, workspace name, branch, and
|
||||
// project name. Present only on history: agent subscriptions filter on
|
||||
// structure, not on relevance. Ranking replaces `sort` when it is set.
|
||||
search: z.string().optional(),
|
||||
sort: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -2880,6 +2884,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
rewind: z.boolean().optional(),
|
||||
// COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X.
|
||||
agentTimelinePromptIndex: z.boolean().optional(),
|
||||
// COMPAT(agentHistorySearch): added in v0.3.0, remove gate after 2027-02-07.
|
||||
agentHistorySearch: z.boolean().optional(),
|
||||
// COMPAT(checkoutRefresh): added in v0.1.86, remove gate after 2026-11-29.
|
||||
checkoutRefresh: z.boolean().optional(),
|
||||
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97
|
||||
@@ -3307,9 +3313,31 @@ export const AgentListMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const AgentSearchMatchFieldSchema = z.enum(["workspace", "title", "branch", "project"]);
|
||||
|
||||
export const AgentSearchMatchSchema = z.object({
|
||||
field: AgentSearchMatchFieldSchema,
|
||||
ranges: z.array(
|
||||
z.object({
|
||||
start: z.number().int().nonnegative(),
|
||||
length: z.number().int().positive(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type AgentSearchMatch = z.infer<typeof AgentSearchMatchSchema>;
|
||||
|
||||
const AgentDirectoryResponseEntrySchema = z.object({
|
||||
agent: AgentSnapshotPayloadSchema,
|
||||
project: ProjectPlacementPayloadSchema,
|
||||
// Relevance of this entry to the request's `search`, lower being better.
|
||||
// Set only when the request carried a query; a client merging results from
|
||||
// several hosts needs it to interleave their separately ranked pages.
|
||||
searchScore: z.number().optional(),
|
||||
// Where the query matched, so the row can mark it. The ranker computes this
|
||||
// anyway; sending it keeps the client from re-deriving a second opinion that
|
||||
// could disagree with the ranking it is explaining.
|
||||
searchMatches: z.array(AgentSearchMatchSchema).optional(),
|
||||
});
|
||||
|
||||
const AgentDirectoryPageInfoSchema = z.object({
|
||||
@@ -3334,6 +3362,10 @@ export const FetchAgentHistoryResponseMessageSchema = z.object({
|
||||
requestId: z.string(),
|
||||
entries: z.array(AgentDirectoryResponseEntrySchema),
|
||||
pageInfo: AgentDirectoryPageInfoSchema,
|
||||
// More sessions matched the request's `search` than the page could hold.
|
||||
// Distinct from `pageInfo.hasMore`, which promises a fetchable next page —
|
||||
// a ranked result set has none, and the way on is a narrower query.
|
||||
searchTruncated: z.boolean().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
compareMatchScores,
|
||||
fuzzyPolicyForToken,
|
||||
matchRanges,
|
||||
scoreMatch,
|
||||
scoreTextFields,
|
||||
} from "./text-match.js";
|
||||
|
||||
function markUp(query: string, text: string, options?: Parameters<typeof scoreMatch>[2]): string {
|
||||
const score = scoreMatch(query, text, options);
|
||||
if (!score) return text;
|
||||
let marked = "";
|
||||
let cursor = 0;
|
||||
for (const range of matchRanges(query, text, score)) {
|
||||
marked += text.slice(cursor, range.start);
|
||||
marked += `[${text.slice(range.start, range.start + range.length)}]`;
|
||||
cursor = range.start + range.length;
|
||||
}
|
||||
return marked + text.slice(cursor);
|
||||
}
|
||||
|
||||
describe("scoreMatch", () => {
|
||||
it("returns tier 0 for empty query", () => {
|
||||
expect(scoreMatch("", "anything")).toEqual({ tier: 0, offset: 0 });
|
||||
});
|
||||
|
||||
it("returns tier 0 for exact match ignoring case", () => {
|
||||
expect(scoreMatch("pi", "pi")).toEqual({ tier: 0, offset: 0 });
|
||||
expect(scoreMatch("PI", "pi")).toEqual({ tier: 0, offset: 0 });
|
||||
expect(scoreMatch("Pi", "pI")).toEqual({ tier: 0, offset: 0 });
|
||||
});
|
||||
|
||||
it("returns tier 1 for whole-word match at start", () => {
|
||||
expect(scoreMatch("feat", "feat/pi-direct-sdk")).toEqual({ tier: 1, offset: 0 });
|
||||
});
|
||||
|
||||
it("returns tier 1 for whole-word match in middle (word boundaries on both sides)", () => {
|
||||
expect(scoreMatch("pi", "feat/pi-direct-sdk")).toEqual({ tier: 1, offset: 5 });
|
||||
expect(scoreMatch("pi", "a b pi c d")).toEqual({ tier: 1, offset: 4 });
|
||||
});
|
||||
|
||||
it("returns tier 2 for string prefix that does not complete a word", () => {
|
||||
expect(scoreMatch("par", "party")).toEqual({ tier: 2, offset: 0 });
|
||||
});
|
||||
|
||||
it("returns tier 3 for word-boundary start that does not complete a word", () => {
|
||||
expect(scoreMatch("par", "a/party")).toEqual({ tier: 3, offset: 2 });
|
||||
});
|
||||
|
||||
it("returns tier 4 for substring inside a word", () => {
|
||||
expect(scoreMatch("art", "party")).toEqual({ tier: 4, offset: 1 });
|
||||
});
|
||||
|
||||
it("returns null when query is not found", () => {
|
||||
expect(scoreMatch("xyz", "feat/pi-direct-sdk")).toBeNull();
|
||||
});
|
||||
|
||||
it("picks the best tier across multiple occurrences", () => {
|
||||
expect(scoreMatch("ab", "xabxab-yy")).toEqual({ tier: 4, offset: 1 });
|
||||
expect(scoreMatch("ab", "xxab x-ab-y")).toEqual({ tier: 1, offset: 7 });
|
||||
});
|
||||
|
||||
it("prefers earlier offset when tiers are equal", () => {
|
||||
expect(scoreMatch("pi", "pi-a pi-b")).toEqual({ tier: 1, offset: 0 });
|
||||
});
|
||||
|
||||
it("treats common separators as word boundaries", () => {
|
||||
expect(scoreMatch("pi", "x/pi")).toEqual({ tier: 1, offset: 2 });
|
||||
expect(scoreMatch("pi", "x-pi")).toEqual({ tier: 1, offset: 2 });
|
||||
expect(scoreMatch("pi", "x_pi")).toEqual({ tier: 1, offset: 2 });
|
||||
expect(scoreMatch("pi", "x pi")).toEqual({ tier: 1, offset: 2 });
|
||||
expect(scoreMatch("pi", "x.pi")).toEqual({ tier: 1, offset: 2 });
|
||||
expect(scoreMatch("pi", "x:pi")).toEqual({ tier: 1, offset: 2 });
|
||||
expect(scoreMatch("202", "#202 feat")).toEqual({ tier: 1, offset: 1 });
|
||||
});
|
||||
|
||||
it("scores PR-title-shaped text realistically", () => {
|
||||
const pr202 = "#202 feat(server): replace Pi ACP with direct SDK provider";
|
||||
expect(scoreMatch("pi", pr202)?.tier).toBe(1);
|
||||
expect(scoreMatch("202", pr202)).toEqual({ tier: 1, offset: 1 });
|
||||
expect(scoreMatch("replace", pr202)?.tier).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scoreMatch typo tolerance", () => {
|
||||
const typoTolerant = { fuzzy: { maxEdits: 2, transpositionsOnly: false } };
|
||||
|
||||
it("is off by default so exact pickers keep exact behavior", () => {
|
||||
expect(scoreMatch("bulling", "add stripe billing")).toBeNull();
|
||||
});
|
||||
|
||||
it("matches a transposition", () => {
|
||||
expect(
|
||||
scoreMatch("brnach", "brnach is not branch", {
|
||||
fuzzy: { maxEdits: 1, transpositionsOnly: false },
|
||||
})?.tier,
|
||||
).toBe(1);
|
||||
expect(
|
||||
scoreMatch("brnach", "switch the branch", {
|
||||
fuzzy: { maxEdits: 1, transpositionsOnly: false },
|
||||
})?.tier,
|
||||
).toBe(6);
|
||||
});
|
||||
|
||||
it("matches a typo the subsequence tier cannot reach", () => {
|
||||
// Dropped characters already land on the subsequence tier; the fuzzy tier
|
||||
// exists for the ones that reorder or replace.
|
||||
expect(scoreMatch("confg", "revamp configuration editor")?.tier).toBe(5);
|
||||
expect(scoreMatch("confug", "revamp configuration editor")).toBeNull();
|
||||
expect(scoreMatch("confug", "revamp configuration editor", typoTolerant)?.tier).toBe(6);
|
||||
});
|
||||
|
||||
it("reports the offset and edit distance of the word it matched", () => {
|
||||
expect(scoreMatch("bulling", "add stripe billing", typoTolerant)).toEqual({
|
||||
tier: 6,
|
||||
offset: 11,
|
||||
spread: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("ranks every exact tier ahead of a fuzzy hit", () => {
|
||||
const fuzzy = scoreMatch("bilingg", "billing", typoTolerant);
|
||||
const worstExact = scoreMatch("iln", "billing");
|
||||
if (!fuzzy || !worstExact) throw new Error("expected both scores");
|
||||
expect(compareMatchScores(worstExact, fuzzy)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("refuses to fuzzy-match beyond the budget", () => {
|
||||
// "stpie" is two edits from "stripe": insert the r, then swap p and i.
|
||||
expect(
|
||||
scoreMatch("stpie", "add stripe billing", {
|
||||
fuzzy: { maxEdits: 2, transpositionsOnly: false },
|
||||
})?.tier,
|
||||
).toBe(6);
|
||||
expect(
|
||||
scoreMatch("stpie", "add stripe billing", {
|
||||
fuzzy: { maxEdits: 1, transpositionsOnly: false },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
scoreMatch("zzzzzz", "add stripe billing", {
|
||||
fuzzy: { maxEdits: 2, transpositionsOnly: false },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("forgives only transpositions in short tokens, where a substitution matches too much", () => {
|
||||
expect(fuzzyPolicyForToken("pi")).toBeNull();
|
||||
expect(fuzzyPolicyForToken("main")).toEqual({ maxEdits: 1, transpositionsOnly: true });
|
||||
expect(fuzzyPolicyForToken("stipe")).toEqual({ maxEdits: 1, transpositionsOnly: false });
|
||||
expect(fuzzyPolicyForToken("configration")).toEqual({ maxEdits: 2, transpositionsOnly: false });
|
||||
|
||||
// The branch typo that has to work.
|
||||
expect(scoreMatch("mian", "switch to main", { fuzzy: fuzzyPolicyForToken("mian") })?.tier).toBe(
|
||||
6,
|
||||
);
|
||||
// Words one substitution from "main" stay out.
|
||||
for (const nearMiss of ["mial", "rain", "maid"]) {
|
||||
expect(
|
||||
scoreMatch(nearMiss, "switch to main", { fuzzy: fuzzyPolicyForToken(nearMiss) }),
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("finds a transposed short token at the head of a longer word", () => {
|
||||
expect(
|
||||
scoreMatch("mian", "feat/mainline-fix", { fuzzy: fuzzyPolicyForToken("mian") })?.tier,
|
||||
).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scoreTextFields", () => {
|
||||
it("requires every token to match some field", () => {
|
||||
expect(scoreTextFields("stripe billing", ["add stripe billing system"])).not.toBeNull();
|
||||
expect(scoreTextFields("stripe rosetta", ["add stripe billing system"])).toBeNull();
|
||||
});
|
||||
|
||||
it("lets tokens match across different fields", () => {
|
||||
expect(scoreTextFields("stripe main", ["add stripe billing", "main"])).not.toBeNull();
|
||||
});
|
||||
|
||||
it("carries typo tolerance down to each token", () => {
|
||||
expect(
|
||||
scoreTextFields("stipe bulling", ["add stripe billing"], { typoTolerant: true }),
|
||||
).not.toBeNull();
|
||||
expect(scoreTextFields("stipe bulling", ["add stripe billing"])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareMatchScores", () => {
|
||||
it("sorts by tier ascending", () => {
|
||||
expect(compareMatchScores({ tier: 1, offset: 10 }, { tier: 2, offset: 0 })).toBeLessThan(0);
|
||||
expect(compareMatchScores({ tier: 3, offset: 0 }, { tier: 1, offset: 99 })).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("tie-breaks by offset ascending at the same tier", () => {
|
||||
expect(compareMatchScores({ tier: 1, offset: 0 }, { tier: 1, offset: 5 })).toBeLessThan(0);
|
||||
expect(compareMatchScores({ tier: 1, offset: 5 }, { tier: 1, offset: 5 })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchRanges", () => {
|
||||
it("marks a whole-word hit", () => {
|
||||
expect(markUp("stripe", "add stripe billing")).toBe("add [stripe] billing");
|
||||
});
|
||||
|
||||
it("marks a hit buried inside a word", () => {
|
||||
expect(markUp("bill", "unbilled usage")).toBe("un[bill]ed usage");
|
||||
});
|
||||
|
||||
it("marks the exact text when the whole field is the query", () => {
|
||||
expect(markUp("main", "main")).toBe("[main]");
|
||||
});
|
||||
|
||||
it("marks the scattered characters a subsequence walked", () => {
|
||||
expect(markUp("confg", "configuration")).toBe("[conf]i[g]uration");
|
||||
});
|
||||
|
||||
it("marks the whole word a typo resolved to", () => {
|
||||
// The characters the user got wrong are not in the text to point at, so
|
||||
// the word is the smallest honest thing to mark.
|
||||
expect(markUp("bulling", "add stripe billing", { fuzzy: fuzzyPolicyForToken("bulling") })).toBe(
|
||||
"add stripe [billing]",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks a transposed short token", () => {
|
||||
expect(markUp("mian", "switch to main", { fuzzy: fuzzyPolicyForToken("mian") })).toBe(
|
||||
"switch to [main]",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns nothing for an empty query", () => {
|
||||
expect(matchRanges("", "anything", { tier: 0, offset: 0 })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Ranked text matching shared by the app's pickers and the daemon's history
|
||||
* search. A match is a tier plus the offset it was found at; lower is better on
|
||||
* both, so callers sort ascending and never have to invent a scale.
|
||||
*
|
||||
* Typo tolerance is opt-in via `fuzzy`. The pickers leave it off — a combobox
|
||||
* over a known list wants exact narrowing — while history search turns it on
|
||||
* because the user is recalling a title from memory.
|
||||
*/
|
||||
|
||||
export interface MatchScore {
|
||||
tier: number;
|
||||
offset: number;
|
||||
spread?: number;
|
||||
}
|
||||
|
||||
export interface MatchOptions {
|
||||
/** Omit or pass null to match exactly. `fuzzyPolicyForToken` picks a policy. */
|
||||
fuzzy?: FuzzyPolicy | null;
|
||||
}
|
||||
|
||||
/** Exact tiers, best to worst. The fuzzy tier always sorts after all of them. */
|
||||
const TIER_EXACT = 0;
|
||||
const TIER_WHOLE_WORD = 1;
|
||||
const TIER_PREFIX = 2;
|
||||
const TIER_WORD_START = 3;
|
||||
const TIER_SUBSTRING = 4;
|
||||
const TIER_SUBSEQUENCE = 5;
|
||||
const TIER_FUZZY = 6;
|
||||
|
||||
function isWordBoundaryChar(ch: string | undefined): boolean {
|
||||
if (ch === undefined) return true;
|
||||
return !/[a-z0-9]/.test(ch);
|
||||
}
|
||||
|
||||
function scoreSubstringMatch(query: string, text: string): MatchScore | null {
|
||||
let best: MatchScore | null = null;
|
||||
let pos = 0;
|
||||
while (pos <= text.length - query.length) {
|
||||
const found = text.indexOf(query, pos);
|
||||
if (found === -1) break;
|
||||
const before = found > 0 ? text[found - 1] : undefined;
|
||||
const after = text[found + query.length];
|
||||
const startsAtBoundary = found === 0 || isWordBoundaryChar(before);
|
||||
const endsAtBoundary = after === undefined || isWordBoundaryChar(after);
|
||||
let tier: number;
|
||||
if (startsAtBoundary && endsAtBoundary) {
|
||||
tier = TIER_WHOLE_WORD;
|
||||
} else if (found === 0) {
|
||||
tier = TIER_PREFIX;
|
||||
} else if (startsAtBoundary) {
|
||||
tier = TIER_WORD_START;
|
||||
} else {
|
||||
tier = TIER_SUBSTRING;
|
||||
}
|
||||
if (!best || tier < best.tier || (tier === best.tier && found < best.offset)) {
|
||||
best = { tier, offset: found };
|
||||
}
|
||||
pos = found + 1;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function scoreSubsequenceMatch(query: string, text: string): MatchScore | null {
|
||||
let queryIndex = 0;
|
||||
let firstIndex = -1;
|
||||
let lastIndex = -1;
|
||||
for (let textIndex = 0; textIndex < text.length && queryIndex < query.length; textIndex += 1) {
|
||||
if (text[textIndex] !== query[queryIndex]) continue;
|
||||
if (firstIndex === -1) firstIndex = textIndex;
|
||||
lastIndex = textIndex;
|
||||
queryIndex += 1;
|
||||
}
|
||||
|
||||
if (queryIndex !== query.length || firstIndex === -1) return null;
|
||||
return { tier: TIER_SUBSEQUENCE, offset: firstIndex, spread: lastIndex - firstIndex + 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Damerau-Levenshtein distance, abandoned as soon as every cell in a row is
|
||||
* over budget. Bounding it is what keeps the fuzzy tier affordable to run
|
||||
* against every word of every candidate.
|
||||
*/
|
||||
function boundedEditDistance(query: string, word: string, budget: number): number | null {
|
||||
if (Math.abs(query.length - word.length) > budget) return null;
|
||||
|
||||
let twoRowsBack: number[] = [];
|
||||
let previousRow: number[] = Array.from({ length: word.length + 1 }, (_, index) => index);
|
||||
|
||||
for (let queryIndex = 1; queryIndex <= query.length; queryIndex += 1) {
|
||||
const currentRow = [queryIndex];
|
||||
let rowBest = queryIndex;
|
||||
for (let wordIndex = 1; wordIndex <= word.length; wordIndex += 1) {
|
||||
const substitutionCost = query[queryIndex - 1] === word[wordIndex - 1] ? 0 : 1;
|
||||
let cost = Math.min(
|
||||
currentRow[wordIndex - 1] + 1,
|
||||
previousRow[wordIndex] + 1,
|
||||
previousRow[wordIndex - 1] + substitutionCost,
|
||||
);
|
||||
const isTransposition =
|
||||
queryIndex > 1 &&
|
||||
wordIndex > 1 &&
|
||||
query[queryIndex - 1] === word[wordIndex - 2] &&
|
||||
query[queryIndex - 2] === word[wordIndex - 1];
|
||||
if (isTransposition) {
|
||||
cost = Math.min(cost, twoRowsBack[wordIndex - 2] + 1);
|
||||
}
|
||||
currentRow.push(cost);
|
||||
rowBest = Math.min(rowBest, cost);
|
||||
}
|
||||
if (rowBest > budget) return null;
|
||||
twoRowsBack = previousRow;
|
||||
previousRow = currentRow;
|
||||
}
|
||||
|
||||
const distance = previousRow[word.length];
|
||||
return distance <= budget ? distance : null;
|
||||
}
|
||||
|
||||
function transpositionDistance(query: string, word: string): number | null {
|
||||
return isAdjacentTransposition(query, word) ? 1 : null;
|
||||
}
|
||||
|
||||
/** True when the two differ only by one swap of neighbouring characters. */
|
||||
function isAdjacentTransposition(query: string, word: string): boolean {
|
||||
if (query.length !== word.length) return false;
|
||||
let index = 0;
|
||||
while (index < query.length && query[index] === word[index]) index += 1;
|
||||
if (index >= query.length - 1) return false;
|
||||
if (query[index] !== word[index + 1] || query[index + 1] !== word[index]) return false;
|
||||
return query.slice(index + 2) === word.slice(index + 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* How much a typo in one query token is forgiven. Short tokens get
|
||||
* transpositions and nothing else: at four characters a free substitution turns
|
||||
* "main" into "mail", "maid", and "rain", while a swap can only ever reach the
|
||||
* word the user meant. Null means the token is matched exactly.
|
||||
*/
|
||||
export interface FuzzyPolicy {
|
||||
maxEdits: number;
|
||||
transpositionsOnly: boolean;
|
||||
}
|
||||
|
||||
export function fuzzyPolicyForToken(token: string): FuzzyPolicy | null {
|
||||
if (token.length <= 3) return null;
|
||||
if (token.length === 4) return { maxEdits: 1, transpositionsOnly: true };
|
||||
if (token.length <= 7) return { maxEdits: 1, transpositionsOnly: false };
|
||||
return { maxEdits: 2, transpositionsOnly: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Words are what people mistype, so the fuzzy tier compares the query against
|
||||
* each word rather than against the whole string — otherwise a long title's
|
||||
* length difference alone would blow the budget.
|
||||
*/
|
||||
function scoreFuzzyMatch(query: string, text: string, policy: FuzzyPolicy): MatchScore | null {
|
||||
if (policy.maxEdits <= 0 || query.length <= policy.maxEdits) return null;
|
||||
|
||||
let best: MatchScore | null = null;
|
||||
const wordPattern = /[a-z0-9]+/g;
|
||||
let word = wordPattern.exec(text);
|
||||
while (word !== null) {
|
||||
// Compare against the whole word and against its leading slices, so a typo
|
||||
// in a prefix ("confug" for "configuration") still lands — the length gap
|
||||
// to the full word would otherwise blow the budget on its own.
|
||||
const candidates = new Set([
|
||||
word[0],
|
||||
word[0].slice(0, query.length),
|
||||
word[0].slice(0, query.length + policy.maxEdits),
|
||||
]);
|
||||
for (const candidate of candidates) {
|
||||
const distance = policy.transpositionsOnly
|
||||
? transpositionDistance(query, candidate)
|
||||
: boundedEditDistance(query, candidate, policy.maxEdits);
|
||||
if (distance === null) continue;
|
||||
const score: MatchScore = { tier: TIER_FUZZY, offset: word.index, spread: distance };
|
||||
if (!best || compareMatchScores(score, best) < 0) {
|
||||
best = score;
|
||||
}
|
||||
}
|
||||
word = wordPattern.exec(text);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function scoreMatch(
|
||||
query: string,
|
||||
text: string,
|
||||
options: MatchOptions = {},
|
||||
): MatchScore | null {
|
||||
if (!query) return { tier: TIER_EXACT, offset: 0 };
|
||||
const q = query.toLowerCase();
|
||||
const t = text.toLowerCase();
|
||||
if (t === q) return { tier: TIER_EXACT, offset: 0 };
|
||||
|
||||
const exact = scoreSubstringMatch(q, t) ?? scoreSubsequenceMatch(q, t);
|
||||
if (exact) return exact;
|
||||
|
||||
const fuzzy = options.fuzzy;
|
||||
return fuzzy ? scoreFuzzyMatch(q, t, fuzzy) : null;
|
||||
}
|
||||
|
||||
export interface MatchRange {
|
||||
start: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
function mergeAdjacentRanges(indices: readonly number[]): MatchRange[] {
|
||||
const ranges: MatchRange[] = [];
|
||||
for (const index of indices) {
|
||||
const last = ranges.at(-1);
|
||||
if (last && last.start + last.length === index) {
|
||||
last.length += 1;
|
||||
continue;
|
||||
}
|
||||
ranges.push({ start: index, length: 1 });
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function wordRangeAt(text: string, offset: number): MatchRange {
|
||||
let end = offset;
|
||||
while (end < text.length && /[a-z0-9]/.test(text[end])) end += 1;
|
||||
return { start: offset, length: Math.max(end - offset, 1) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a score's match actually landed, so a caller can mark it. Derived from
|
||||
* a score rather than produced alongside one: ranking touches every candidate
|
||||
* and needs no ranges, while only the handful of rows that get rendered do.
|
||||
*
|
||||
* The tier decides the shape. A substring hit is one span; a subsequence hit is
|
||||
* the scattered characters it walked; a typo hit marks the whole word, because
|
||||
* the characters the user got wrong are not in the text to point at.
|
||||
*/
|
||||
export function matchRanges(query: string, text: string, score: MatchScore): MatchRange[] {
|
||||
if (!query) return [];
|
||||
const q = query.toLowerCase();
|
||||
const t = text.toLowerCase();
|
||||
|
||||
if (score.tier === TIER_EXACT) return [{ start: 0, length: text.length }];
|
||||
if (score.tier === TIER_FUZZY) return [wordRangeAt(t, score.offset)];
|
||||
if (score.tier === TIER_SUBSEQUENCE) {
|
||||
const indices: number[] = [];
|
||||
let queryIndex = 0;
|
||||
for (let textIndex = 0; textIndex < t.length && queryIndex < q.length; textIndex += 1) {
|
||||
if (t[textIndex] !== q[queryIndex]) continue;
|
||||
indices.push(textIndex);
|
||||
queryIndex += 1;
|
||||
}
|
||||
return mergeAdjacentRanges(indices);
|
||||
}
|
||||
return [{ start: score.offset, length: q.length }];
|
||||
}
|
||||
|
||||
export function compareMatchScores(a: MatchScore, b: MatchScore): number {
|
||||
if (a.tier !== b.tier) return a.tier - b.tier;
|
||||
if (a.offset !== b.offset) return a.offset - b.offset;
|
||||
return (a.spread ?? 0) - (b.spread ?? 0);
|
||||
}
|
||||
|
||||
export function tokenizeQuery(query: string): string[] {
|
||||
return query
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((token) => token.length > 0);
|
||||
}
|
||||
|
||||
export interface TextFieldsOptions {
|
||||
/**
|
||||
* Forgive typos. The budget is per token rather than per query, because a
|
||||
* query mixes long words that can absorb an edit with short ones that cannot.
|
||||
*/
|
||||
typoTolerant?: boolean;
|
||||
}
|
||||
|
||||
export function scoreTextFields(
|
||||
query: string,
|
||||
fields: string[],
|
||||
options: TextFieldsOptions = {},
|
||||
): MatchScore | null {
|
||||
const tokens = tokenizeQuery(query);
|
||||
if (tokens.length === 0) return { tier: TIER_EXACT, offset: 0, spread: 0 };
|
||||
|
||||
const aggregate: MatchScore = { tier: TIER_EXACT, offset: 0, spread: 0 };
|
||||
for (const token of tokens) {
|
||||
const fuzzy = options.typoTolerant ? fuzzyPolicyForToken(token) : null;
|
||||
let best: MatchScore | null = null;
|
||||
for (const field of fields) {
|
||||
const score = scoreMatch(token, field, { fuzzy });
|
||||
if (score && (!best || compareMatchScores(score, best) < 0)) {
|
||||
best = score;
|
||||
}
|
||||
}
|
||||
if (!best) return null;
|
||||
aggregate.tier += best.tier;
|
||||
aggregate.offset += best.offset;
|
||||
aggregate.spread = (aggregate.spread ?? 0) + (best.spread ?? token.length);
|
||||
}
|
||||
return aggregate;
|
||||
}
|
||||
Reference in New Issue
Block a user