A code query was one literal substring, while the other search
sections split theirs on whitespace and want every word. Code search
now does the same: it lists the lines that have every word, in any
order, ignoring case, and a quoted phrase ("like this") is still one
literal string, spaces and all. Words of 3 characters or more are
looked up in the trigram index, all in one FTS5 query (each word's
NEAR group, joined with AND), and their doclist pages are prefetched
in the same round trip as one word's; shorter words only filter lines.
Words match per line, not per chunk, since a hit is a line. A chunk
the index finds can have its words on different lines, so a first look
that finds chunks but no line reads on once, up to 100 chunks, and says
how far it read. The overlay narrows older results by every word and
keeps them only while each old word is in a new one.
Measured with test/code-search-bench.ts on a forge of this repo (180
files, 4.2M code.db), one-word queries cost what they did: 3 cold round
trips, 5 to 13 requests, 240K to 432K. Two or three words take the same
3 round trips, 9 to 16 requests, 416K to 480K, and a quoted phrase what
the literal did. code-search-index and code-search-range cover several
words, order, short words, quotes and the read-on.- commit
- fcf11b25f343ae303ae9b78e2d6412c63cacf98e
- parent
- 22348072565d397e36ffa46982f27725a2b73a48
- author
- cat <admin@dev.site>
- kind
- feat risk score 2.0 of 3, Jev's estimate
| M | README.md | 28 | +++++++---- |
| M | site/ | 82 | ++++++++++++++++++++++-------- |
| M | site/ | 59 | +++++++++++++-------- |
| M | test/ | 8 | ++- |
| M | test/ | 21 | +++++++- |
| M | test/ | 16 | +++++- |
@@ -539,13 +539,19 @@ reads are HTTP range requests, so a query fetches only the pages it touches.
539539 Any static server that honors `Range` works: Caddy, `demo/serve.ts`, and the540540 pages mirror on R2 through the edge, whose fallback passes the header on.541541 542-From 3 characters (a trigram can't match less) the query, as one literal543-string ignoring case, goes to a worker, and a Code section lists each544-matching line as `path:line`, linked to that line of the file's page (the545-source page for markdown). It loads nothing until then: the first code query546-starts the worker, which loads `sqlite3.wasm` and sqlite-wasm's script, both547-content-hashed assets. Results come in path order; the first look reads 12548-chunks, and "more" reads up to 100.542+Once the query has a word of 3 characters (a trigram can't match less), it543+goes to a worker, and a Code section lists each line that has every word,544+ignoring case, as `path:line`, linked to that line of the file's page (the545+source page for markdown). Words split on whitespace, as in the other546+sections, in any order, and a quoted phrase (`"import fs from"`) is one547+literal string, spaces and all. Words of 3 characters or more are looked up548+in the index; shorter ones only filter the lines it finds. It loads nothing549+until then: the first code query starts the worker, which loads550+`sqlite3.wasm` and sqlite-wasm's script, both content-hashed assets. Results551+come in path order; the first look reads 12 chunks, and "more" reads up to552+100. The index finds chunks with every word, and their words can be on553+different lines, so a first look that finds chunks but no line reads on, once,554+up to 100.549555 550556 What goes in: every file on the default branch except binary files (a NUL in551557 the first 8000 bytes, as git decides), files over the 256K highlight cap,@@ -563,8 +569,9 @@ the file by fetching both ends at once (the schema, every table's root and
563569 the file list are at the front; FTS5's term index is at the back), looks up564570 where each term's doclist lives in that term index and fetches those pages565571 together, then fetches the pages of the matching chunks together. A query566-looks up every third trigram of the string (and the last), within `NEAR`567-distance, rather than all of them, and checks the lines for the exact string.572+looks up every third trigram of each word (and the last), within `NEAR`573+distance, rather than all of them, and checks the lines for the exact words.574+The words of a query are looked up in the same round trips as one word.568575 If the index is rebuilt while a page is open, the next query notices the new569576 ETag and reopens it.570577 @@ -573,7 +580,8 @@ built forge with `demo/serve.ts` and runs the same worker code in Node:
573580 574581 | Repo | Indexed text | `code.db` | Cold query | Next queries |575582 | --- | --- | --- | --- | --- |576-| this repo, 103 files | 640K | 2.7M | 3 round trips, 4 to 11 requests, 240K to 420K | 0 to 2 round trips, up to 96K |583+| this repo, 180 files | 1.0M | 4.2M | 3 round trips, 5 to 13 requests, 240K to 432K | 1 or 2 round trips, up to 160K |584+| the same, two or three words (`Cache-Control private`, `fs readFileSync json`) | | | 3 round trips, 9 to 16 requests, 416K to 480K | 1 or 2 round trips, up to 128K |577585 | an 11M tree (eleventy, markdown-it, highlight.js, typescript's `tsc.js` and more), 662 files | 4.4M | 16.1M | 3 round trips, 6 to 12 requests, 290K to 400K | 1 or 2 round trips, 80K to 160K |578586 579587 Of a cold query, 128K is the file's head and 64K its tail, read once per@@ -379,6 +379,9 @@ export interface Result {
379379 hits: Hit[];380380 // More chunks matched than were read.381381 more: boolean;382+ // How many chunks it would read: the limit asked for, or MORE when a383+ // first look found chunks with every word but no line with them all.384+ limit: number;382385 // What this query fetched.383386 requests: number;384387 bytes: number;@@ -420,6 +423,33 @@ export function match(q: string): string {
420423 return n <= 5 ? str(q) : `NEAR(${cover(q).map(str).join(' ')}, ${n - 4})`;421424 }422425 426+// A query's words, lower case, as the other search sections split theirs:427+// on whitespace, except that a quoted phrase ("like this") is one literal428+// string, spaces and all (an unclosed quote runs to the end). A word that429+// is part of another goes, since every line with the longer one has it.430+export function words(q: string): string[] {431+ const all = [...q.toLowerCase().replaceAll('\0', '').matchAll(/"([^"]*)"?|(\S+)/g)].map((m) => m[1] ?? m[2]).filter((w) => w.trim());432+ return [...new Set(all)].filter((w, i, ws) => !ws.some((o, j) => j !== i && o.length > w.length && o.includes(w)));433+}434+435+// The words the trigram index can look up; shorter ones only filter lines.436+export const indexed = (ws: string[]): string[] => ws.filter((w) => [...w].length >= MIN);437+438+// The FTS5 query for chunks that have every indexed word.439+export const matchAll = (ws: string[]): string => indexed(ws).map(match).join(' AND ');440+441+// Where the first word starts in a line that has every word, ignoring case, or -1.442+export function lineMatch(line: string, ws: string[]): number {443+ const low = line.toLowerCase();444+ let first = -1;445+ for (const w of ws) {446+ const at = low.indexOf(w);447+ if (at < 0) return -1;448+ if (first < 0 || at < first) first = at;449+ }450+ return first;451+}452+423453 export class CodeIndex {424454 sqlite3: Sqlite3;425455 vfs: RangeVfs;@@ -479,19 +509,20 @@ export class CodeIndex {
479509 return rows;480510 }481511 482- // Lines containing q, ignoring case, by path. If the index was rebuilt483- // meanwhile, start over on the new one, once.512+ // Lines containing every word of q (words), ignoring case, by path. A513+ // query needs a word of MIN characters for the index to look up. If the514+ // index was rebuilt meanwhile, start over on the new one, once.484515 async search(q: string, limit = FIRST): Promise<Result> {485- q = q.trim().replaceAll('\0', '');486- if ([...q].length < MIN) return { hits: [], more: false, requests: 0, bytes: 0, rounds: 0 };516+ const ws = words(q);517+ if (!indexed(ws).length) return { hits: [], more: false, limit, requests: 0, bytes: 0, rounds: 0 };487518 try {488- return await this.query(q, limit);519+ return await this.query(ws, limit);489520 } catch (e) {490521 const why = e instanceof Changed ? e : this.vfs.error();491522 if (!(why instanceof Changed)) throw why ?? e;492523 this.close();493524 this.open();494- return await this.query(q, limit);525+ return await this.query(ws, limit);495526 }496527 }497528 @@ -509,7 +540,7 @@ export class CodeIndex {
509540 if (rowids.length) await this.file.prefetch(this.pages(root, rowids).map((p) => (p - 1) * page));510541 }511542 512- private async query(q: string, limit: number): Promise<Result> {543+ private async query(ws: string[], limit: number): Promise<Result> {513544 await this.ready();514545 this.layout ??= {515546 page: this.all('pragma page_size')[0][0] as number,@@ -525,7 +556,7 @@ export class CodeIndex {
525556 // number; a doclist index's has bit 36 set too. Row 10 is the structure.526557 const enc = new TextEncoder();527558 const wanted = [1, 10];528- for (const t of cover(q.toLowerCase())) {559+ for (const t of new Set(indexed(ws).flatMap(cover))) {529560 const key = enc.encode('0' + t);530561 for (const seg of this.layout.segs) {531562 const first = (this.all('select pgno >> 1 from code_idx where segid = ? and term <= ? order by term desc limit 1', [seg, key])[0]?.[0] as number | undefined) ?? 1;@@ -537,24 +568,33 @@ export class CodeIndex {
537568 }538569 await this.prefetch(this.layout.data, wanted);539570 540- const ids = this.all('select rowid from code where code match ? limit ?', [match(q), limit + 1]).map((r) => r[0] as number);541- const more = ids.length > limit;542- ids.length = Math.min(ids.length, limit);543- await this.prefetch(this.layout.chunk, ids);544-545- const needle = q.toLowerCase();571+ // Chunks with every indexed word, then their lines with every word. The572+ // words of a chunk can be on different lines, so a first look that finds573+ // chunks but no line reads on, once, up to MORE chunks.546574 const hits: Hit[] = [];547- const rows = ids.length ? this.all(`select f.path, f.source, c.line, c.text from chunk c join file f on f.id = c.file where c.id in (${ids.map(() => '?').join(',')})`, ids) : [];548- for (const [path, source, line, text] of rows as [string, number, number, string][]) {549- text.split('\n').forEach((l, i) => {550- const at = l.toLowerCase().indexOf(needle);551- if (at >= 0) hits.push({ path, line: line + i, text: around(l, at), source: !!source });552- });575+ const fts = matchAll(ws);576+ let read = 0;577+ let more = false;578+ for (;;) {579+ const ids = this.all('select rowid from code where code match ? limit ? offset ?', [fts, limit + 1 - read, read]).map((r) => r[0] as number);580+ more = ids.length > limit - read;581+ ids.length = Math.min(ids.length, limit - read);582+ read += ids.length;583+ await this.prefetch(this.layout.chunk, ids);584+ const rows = ids.length ? this.all(`select f.path, f.source, c.line, c.text from chunk c join file f on f.id = c.file where c.id in (${ids.map(() => '?').join(',')})`, ids) : [];585+ for (const [path, source, line, text] of rows as [string, number, number, string][]) {586+ text.split('\n').forEach((l, i) => {587+ const at = lineMatch(l, ws);588+ if (at >= 0) hits.push({ path, line: line + i, text: around(l, at), source: !!source });589+ });590+ }591+ if (hits.length || !more || limit >= MORE) break;592+ limit = MORE;553593 }554594 // Rows come in index order, which an incremental build doesn't keep in path order.555595 hits.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : a.line - b.line));556596 const { requests, bytes, rounds } = this.file;557- const r = { hits, more, requests: requests - this.seen.requests, bytes: bytes - this.seen.bytes, rounds: rounds - this.seen.rounds };597+ const r = { hits, more, limit, requests: requests - this.seen.requests, bytes: bytes - this.seen.bytes, rounds: rounds - this.seen.rounds };558598 this.seen = { requests, bytes, rounds };559599 return r;560600 }@@ -4,10 +4,11 @@
44 // lib/search.ts) fetched the first time the search opens. Every term must55 // match somewhere; matches in titles rank above matches in text.66 //7-// From 3 characters it also searches file contents on the default branch:8-// the query, as one literal string, goes to a worker (codesearch.ts) that9-// queries the repo's code index with sqlite-wasm over range requests. The10-// worker and sqlite-wasm load the first time someone searches code.7+// With a word of 3 characters it also searches file contents on the default8+// branch, for lines with every word ("a phrase" in quotes is one): the9+// query goes to a worker (codesearch.ts) that queries the repo's code index10+// with sqlite-wasm over range requests. The worker and sqlite-wasm load the11+// first time someone searches code.1112 (() => {1213 const button = document.querySelector<HTMLButtonElement>('button[data-search]');1314 if (!button) return;@@ -59,28 +60,38 @@
5960 error?: string;6061 }6162 // As in codesearch.ts: trigrams need 3 characters; 12 chunks for a first62- // look, 100 when asked for more.63+ // look, 100 when asked for more. A query's words split on whitespace, but64+ // a quoted phrase is one, spaces and all; a word inside another goes.6365 const CODE_MIN = 3;6466 const CODE_FIRST = 12;6567 const CODE_MORE = 100;68+ const codeWords = (q: string): string[] => {69+ const all = [...q.toLowerCase().matchAll(/"([^"]*)"?|(\S+)/g)].map((m) => m[1] ?? m[2]).filter((w) => w.trim());70+ return [...new Set(all)].filter((w, i, ws) => !ws.some((o, j) => j !== i && o.length > w.length && o.includes(w)));71+ };72+ const codeable = (q: string): boolean => codeWords(q).some((w) => [...w].length >= CODE_MIN);73+ const hasAll = (text: string, ws: string[]): boolean => {74+ const low = text.toLowerCase();75+ return ws.every((w) => low.includes(w));76+ };6677 let codeUrl: string | null = null;6778 let blobBase = '';6879 let worker: Worker | null = null;6980 let asked = 0;70- const waiting = new Map<number, (r: Omit<CodeResult, 'q' | 'limit'>) => void>();71- function codeQuery(q: string, limit: number): Promise<Omit<CodeResult, 'q' | 'limit'>> {81+ const waiting = new Map<number, (r: Omit<CodeResult, 'q'>) => void>();82+ function codeQuery(q: string, limit: number): Promise<Omit<CodeResult, 'q'>> {7283 if (!worker) {7384 const abs = (u: string): string => new URL(u, location.href).href;7485 worker = new Worker(abs(button!.dataset.code!), { type: 'module' });7586 worker.postMessage({ sqlite: abs(button!.dataset.sqlite!), wasm: abs(button!.dataset.wasm!), url: abs(codeUrl!) });7687 worker.addEventListener('message', (e: MessageEvent) => {77- const m = e.data as { id: number; hits?: CodeHit[]; more?: boolean; error?: string };78- waiting.get(m.id)?.({ hits: m.hits ?? [], more: !!m.more, error: m.error });88+ const m = e.data as { id: number; hits?: CodeHit[]; more?: boolean; limit?: number; error?: string };89+ waiting.get(m.id)?.({ hits: m.hits ?? [], more: !!m.more, limit: m.limit ?? 0, error: m.error });7990 waiting.delete(m.id);8091 });8192 // A worker that can't start fails everything asked of it.8293 worker.addEventListener('error', () => {83- for (const done of waiting.values()) done({ hits: [], more: false, error: 'Code search could not be loaded.' });94+ for (const done of waiting.values()) done({ hits: [], more: false, limit: 0, error: 'Code search could not be loaded.' });8495 waiting.clear();8596 worker = null;8697 });@@ -243,17 +254,17 @@
243254 ol.append(li);244255 };245256 246- // Lines in files: the query as one string, ignoring case. While a longer247- // query runs, the last results narrow to the lines that still match.257+ // Lines in files with every word, ignoring case. While a longer query258+ // runs, the last results narrow to the lines that still match.248259 const drawCode = (q: string): boolean => {249- const low = q.toLowerCase();260+ const ws = codeWords(q);250261 const fresh = code?.q === q;251262 if (fresh && code!.error) {252263 heading('Code', '');253264 note(code!.error);254265 return true;255266 }256- const hits = !code ? [] : fresh ? code.hits : code.hits.filter((h) => h.text.toLowerCase().includes(low));267+ const hits = !code ? [] : fresh ? code.hits : code.hits.filter((h) => hasAll(h.text, ws));257268 if (!hits.length) {258269 if (fresh) return false;259270 heading('Code', '');@@ -264,7 +275,7 @@
264275 heading('Code', `${hits.length}${more ? '+' : ''}`);265276 const shown = expanded.has('Code') ? hits : hits.slice(0, PER_KIND);266277 for (const h of shown) {267- link(`${blobBase}${enc(h.path)}/${h.source ? 'source/' : ''}#l${h.line}`, [document.createTextNode(`${h.path}:${h.line}`)], true, marked(h.text, [low]), true);278+ link(`${blobBase}${enc(h.path)}/${h.source ? 'source/' : ''}#l${h.line}`, [document.createTextNode(`${h.path}:${h.line}`)], true, marked(h.text, ws), true);268279 }269280 const rest = hits.length - shown.length;270281 if (rest || (more && code!.limit < CODE_MORE)) {@@ -277,16 +288,18 @@
277288 };278289 const runCode = (q: string, limit: number): void => {279290 codeQuery(q, limit).then((r) => {280- // Results for an older query still help while they narrow to this one.281- if (!box.isConnected || !input.value.trim().toLowerCase().includes(q.toLowerCase())) return;282- code = { q, limit, ...r };291+ // Results for an older query still help while they narrow to this292+ // one: each of its words is in a word of this one.293+ const now = codeWords(input.value);294+ if (!box.isConnected || !codeWords(q).every((w) => now.some((n) => n.includes(w)))) return;295+ code = { q, ...r, limit: Math.max(limit, r.limit) };283296 draw();284297 });285298 };286299 const queueCode = (): void => {287300 clearTimeout(timer);288301 const q = input.value.trim();289- if (!codeUrl || [...q].length < CODE_MIN || code?.q === q) return;302+ if (!codeUrl || !codeable(q) || code?.q === q) return;290303 timer = window.setTimeout(() => runCode(q, CODE_FIRST), 120);291304 };292305 @@ -301,7 +314,7 @@
301314 }302315 if (!terms.length) {303316 hint.textContent = repos ? 'Repository names and descriptions.' : codeUrl304- ? 'Searches commit messages, reviews, wiki pages, file paths and, from 3 characters, the files themselves, on the default branch. Press t to find a file on this branch.'317+ ? 'Searches commit messages, reviews, wiki pages, file paths and, for words of 3 characters or more, the lines of the files themselves ("a phrase" in quotes), on the default branch. Press t to find a file on this branch.'305318 : 'Searches commit messages, reviews, wiki pages and file paths on the default branch. Press t to find a file on this branch.';306319 return;307320 }@@ -317,10 +330,10 @@
317330 }318331 if (shown.length < list.length) moreRow(`${list.length - shown.length} more`, () => expanded.add(kind));319332 }320- const short = !!codeUrl && [...q].length < CODE_MIN;333+ const short = !!codeUrl && !codeable(q);321334 const busy = !!codeUrl && !short && drawCode(q);322- hint.textContent = rows.length || busy ? '' : short ? 'No matches. File contents are searched from 3 characters.' : 'No matches.';323- if (rows.length && short) hint.textContent = 'File contents are searched from 3 characters.';335+ hint.textContent = rows.length || busy ? '' : short ? 'No matches. File contents are searched for a word of 3 characters or more.' : 'No matches.';336+ if (rows.length && short) hint.textContent = 'File contents are searched for a word of 3 characters or more.';324337 sel = Math.min(sel, Math.max(0, rows.length - 1));325338 rows[sel]?.li.classList.add('sel');326339 };@@ -18,7 +18,7 @@ import zlib from 'node:zlib';
1818 import { spawn, type ChildProcess } from 'node:child_process';1919 import { Worker } from 'node:worker_threads';2020 import { fileURLToPath } from 'node:url';21-import { CodeIndex, installVfs, match, MORE, type GetRange, type Sqlite3 } from '../site/assets/codesearch.ts';21+import { CodeIndex, indexed, installVfs, lineMatch, matchAll, MORE, words, type GetRange, type Sqlite3 } from '../site/assets/codesearch.ts';2222 2323 const here = path.dirname(fileURLToPath(import.meta.url));2424 const [mode, ...args] = process.argv.slice(2);@@ -115,10 +115,12 @@ function open(db: string): import('node:sqlite').DatabaseSync {
115115 }116116 117117 function sqlHits(db: string, q: string): string[] {118+ const ws = words(q);119+ if (!indexed(ws).length) return [];118120 const d = open(db);119- const rows = d.prepare('select f.path, c.line, c.text from code join chunk c on c.id = code.rowid join file f on f.id = c.file where code match ?').all(match(q)) as { path: string; line: number; text: string }[];121+ const rows = d.prepare('select f.path, c.line, c.text from code join chunk c on c.id = code.rowid join file f on f.id = c.file where code match ?').all(matchAll(ws)) as { path: string; line: number; text: string }[];120122 const out: string[] = [];121- for (const r of rows) r.text.split('\n').forEach((l, i) => l.toLowerCase().includes(q.toLowerCase()) && out.push(`${r.path}:${r.line + i}: ${l.trim()}`));123+ for (const r of rows) r.text.split('\n').forEach((l, i) => lineMatch(l, ws) >= 0 && out.push(`${r.path}:${r.line + i}: ${l.trim()}`));122124 return out.sort();123125 }124126 @@ -1,5 +1,6 @@
11 # A build writes the default branch's code index, search.json points at it,2-# and a substring query finds the file and line, ignoring case.2+# and a query finds the file and line of every line with all its words (or3+# its quoted phrase), ignoring case.34 . "$(dirname "$0")/../lib.sh"45 user dami56 repo dami/greet "say hello"@@ -26,6 +27,24 @@ ok bench sql "$db" 'GREETHELPER'
2627 eq "$(output)" 'README.md:3: See greetHelper here.'2728 ok bench sql "$db" 'nothing like this'2829 eq "$(output)" ''30+# Several words: every one on the same line, in any order, ignoring case.31+# Words under 3 characters only filter; a quoted phrase is one literal string.32+ok bench sql "$db" 'name GREET'33+eq "$(output)" 'src/main.go:21: func Greet(name string) string {'34+ok bench sql "$db" 'string func'35+eq "$(output)" 'src/main.go:21: func Greet(name string) string {'36+ok bench sql "$db" 'func hello'37+eq "$(output)" ''38+ok bench sql "$db" 'name {'39+eq "$(output)" 'src/main.go:21: func Greet(name string) string {'40+ok bench sql "$db" '"greet name"'41+eq "$(output)" ''42+ok bench sql "$db" '"Greet(name" string'43+eq "$(output)" 'src/main.go:21: func Greet(name string) string {'44+ok bench sql "$db" '"return "hello'45+eq "$(output)" 'src/main.go:22: return "Hello, " + name'46+ok bench sql "$db" 'go {'47+eq "$(output)" ''2948 # Pages name the worker and sqlite-wasm, and the assets exist.3049 has "$FORGE_WWW/dami/greet/index.html" 'data-code="/assets/codesearch\.[^"]+\.js" data-sqlite="/assets/sqlite3\.[^"]+\.js" data-wasm="/assets/sqlite3\.[^"]+\.wasm"'3150 lacks "$FORGE_WWW/index.html" 'data-code='@@ -16,6 +16,20 @@ has "$T_LOG" '^greet\.sh:31: echo "Hello, \$1"$'
1616 has "$T_LOG" '^requests [1-9][0-9]* rounds [1-9][0-9]* bytes [1-9][0-9]*$'1717 has "$T_LOG" '^range 206 bytes 0-99/[0-9]+ type application/vnd\.sqlite3 encoding none length 100$'1818 has "$T_LOG" '^wasm 200 type application/wasm cache public, max-age=31536000, immutable$'19-# Too short for trigrams: nothing, and nothing fetched.19+# Several words: a first look (12 chunks) that finds chunks with both but no20+# line with both reads on, once, and finds the line further down.21+mkdir "$T/w/dami/many"22+i=1023+while [ $i -le 40 ]; do printf 'alpha %s\nbeta %s\n' "$i" "$i" >"$T/w/dami/many/a$i.txt"; i=$((i + 1)); done24+printf 'beta and alpha\n' >"$T/w/dami/many/zz.txt"25+at "$T/w/dami" commit "many"26+push_as dami "$T/w/dami" origin main27+build28+ok node "$T_HOME/test/code-search-bench.ts" http "$FORGE_ROOT/.forge" dami/greet 'ALPHA beta' 1229+eq "$(sed -n 1p "$T_LOG")" 'many/zz.txt:1: beta and alpha'30+has "$T_LOG" '^requests [1-9][0-9]* rounds [1-9][0-9]* bytes'31+# Too short for trigrams: nothing, and nothing fetched; nor for short words only.32+ok node "$T_HOME/test/code-search-bench.ts" http "$FORGE_ROOT/.forge" dami/greet 'if he'33+has "$T_LOG" '^requests 0 '2034 ok node "$T_HOME/test/code-search-bench.ts" http "$FORGE_ROOT/.forge" dami/greet 'he'2135 has "$T_LOG" '^requests 0 '