@shoalrun/cli 0.1.0-alpha.15 → 0.1.0-alpha.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-alpha.16 — 2026-11-04
4
+
5
+ - Set `outputs.html.search.provider: semantic` to connect the search box to
6
+ Shoalrun's semantic endpoint without writing a custom browser module. The
7
+ included keyword index remains available when the endpoint is absent or
8
+ unavailable. Configure an embedding provider, build the passage index, and
9
+ run or deploy the search endpoint; selecting the browser provider alone does
10
+ not provision a backend.
11
+
12
+ - Qualify stored passage IDs by publication so repeated page and section IDs
13
+ across releases cannot alias embedding rows or citation digests. Rebuild
14
+ passage indexes with `shoalrun index`; when using semantic retrieval, rerun
15
+ indexing with `--embed` and the same embedding configuration to reuse cached
16
+ inputs. Regenerate answer cards after rebuilding their cited passage IDs.
17
+ Ambiguous legacy indexes now fail with rebuild instructions.
18
+ - Include publication membership, routes, reader marks, and prepared search
19
+ evidence in passage-index generation hashes without changing embedding inputs.
20
+ - Read publication-specific passage counts and `sourceDigest` from the index
21
+ command's JSON summary to check which retained releases were indexed.
22
+ - Stream hosted search scores into page selection, retaining at most three
23
+ candidates per page and a result-sized final ranking. Large galleries no
24
+ longer require an unbounded sorted passage array. Exact identifiers reserve
25
+ a slot among displayable pages and keep literal table/code landing evidence.
26
+ File-backed retrieval still scans the selected publication; external vector
27
+ storage and publication-sharded browser downloads are not included yet.
28
+
3
29
  ## 0.1.0-alpha.15 — 2026-11-03
4
30
 
5
31
  - Opt into `profiles.requireClassification` to reject unclassified public pages
package/README.md CHANGED
@@ -102,6 +102,44 @@ for main, developer, and regional sites built from one source directory.
102
102
  For deployment-owned SKU selections, direct or grandfathered feature grants, and
103
103
  branding supplied outside the shared repository, see [deployment profiles](DEPLOYMENT-PROFILES.md).
104
104
 
105
+ ## Enable semantic search
106
+
107
+ The default search runs in the browser with no server or API key. To use
108
+ Shoalrun's semantic endpoint, select the included provider:
109
+
110
+ ```yaml
111
+ outputs:
112
+ html:
113
+ search:
114
+ provider: semantic
115
+ retrieval:
116
+ embeddings:
117
+ provider: voyage
118
+ model: voyage-4-large
119
+ queryModel: voyage-4-lite
120
+ dimension: 1024
121
+ ```
122
+
123
+ Set `VOYAGE_API_KEY` in the build and server environment, then run:
124
+
125
+ ```sh
126
+ shoalrun build docs.config.yaml site-dist
127
+ shoalrun index site-dist --embed --config docs.config.yaml
128
+ shoalrun serve site-dist --search --config docs.config.yaml
129
+ ```
130
+
131
+ No custom browser adapter or chat model is needed. The browser sends the selected
132
+ publication and reader view to the same-origin endpoint and uses keyword search
133
+ if it is missing or unavailable. Each semantic query calls the embedding
134
+ provider; keys stay server-side. Reindex changed content and restart the preview
135
+ after rebuilding indexes. Existing matching embedding inputs are reused.
136
+
137
+ `provider: semantic` configures the browser connection; production hosting still
138
+ needs a search endpoint. The current Vercel integration supplies search through
139
+ its chat-function deployment, which does not yet support deployment profiles.
140
+ A static deployment remains usable through keyword fallback. External search
141
+ services can continue to use `provider: custom`.
142
+
105
143
  ## Export content into an application
106
144
 
107
145
  Export pages from the same source into a portable directory for an application's
@@ -0,0 +1,34 @@
1
+ // Built-in semantic connection. Credentials and query embeddings stay server-side.
2
+ import * as lexical from './search-lexical.js';
3
+ const endpoint = new URL('../__shoalrun/search', import.meta.url).pathname;
4
+ let endpointMissing = false;
5
+ export const prepare = lexical.prepare;
6
+
7
+ export async function search(query, context) {
8
+ const fallback = () => lexical.search(query, context);
9
+ context.signal?.throwIfAborted();
10
+ if (endpointMissing || query.trim().length < 3) return fallback();
11
+ // Debounce requests before spending query-embedding credits. Every listener
12
+ // is removed on completion, including when a keystroke cancels this request.
13
+ await new Promise((resolve, reject) => {
14
+ const abort = () => { clearTimeout(timer); context.signal?.removeEventListener('abort', abort); reject(context.signal.reason); };
15
+ const timer = setTimeout(() => { context.signal?.removeEventListener('abort', abort); resolve(); }, 200);
16
+ context.signal?.addEventListener('abort', abort, { once: true });
17
+ });
18
+ context.signal?.throwIfAborted();
19
+ try {
20
+ const signal = AbortSignal.any([AbortSignal.timeout(4000), ...(context.signal ? [context.signal] : [])]);
21
+ const response = await fetch(endpoint, {
22
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
23
+ body: JSON.stringify({ query, variant: context.variant, limit: context.limit, readerSelection: context.readerSelection }), signal
24
+ });
25
+ if (response.status === 404) endpointMissing = true;
26
+ if (!response.ok) return fallback();
27
+ const body = await response.json();
28
+ context.signal?.throwIfAborted();
29
+ return Array.isArray(body.results) ? body.results : fallback();
30
+ } catch {
31
+ context.signal?.throwIfAborted();
32
+ return fallback();
33
+ }
34
+ }
@@ -32,7 +32,7 @@ export type ArtifactAssemblyInput = {
32
32
  generatedPaletteCss?: string;
33
33
  generatedBadgeCss?: string;
34
34
  search: {
35
- provider: 'lexical' | 'custom' | 'none';
35
+ provider: 'lexical' | 'semantic' | 'custom' | 'none';
36
36
  index: boolean;
37
37
  module?: string;
38
38
  };
@@ -122,7 +122,7 @@ export async function assembleArtifacts(input) {
122
122
  const compact = { schemaVersion: 1, documents: index.documents.map(({ id, demo, url, markdownUrl, title, summary, type, variant, headings, sections, navigationTab }) => ({ id, demo, url, markdownUrl, title, summary, type, variant, headings, ...(navigationTab ? { navigationTab } : {}), ...(sections ? { sections } : {}), ...sizes.get(markdownUrl) })) };
123
123
  addMetadataArtifact('/search-index.min.json', JSON.stringify(compact) + '\n');
124
124
  }
125
- if (search.provider === 'custom' && search.index)
125
+ if ((search.provider === 'custom' || search.provider === 'semantic') && search.index)
126
126
  addPublicArtifact('/assets/search-lexical.js', await readFile(builtInSearchProvider, 'utf8'));
127
127
  if (search.module)
128
128
  addPublicArtifact('/assets/search-provider.js', await readFile(search.module, 'utf8'));
package/dist/cli.js CHANGED
@@ -238,7 +238,7 @@ try {
238
238
  embedQuery = async (query, signal) => (await provider.embed([query], 'query', signal))[0];
239
239
  }
240
240
  extras = { search: createSearchHandler({ retriever, embedQuery, hosts, readerFilters: validateReaderFilters(settings.readerFilters), pageSummaries }) };
241
- banner = `Search is on: ${embeddings ? 'hybrid retrieval' : 'lexical retrieval; run shoalrun index --embed for semantic results'}.\n`;
241
+ banner = `Search is on: ${embeddings ? 'semantic retrieval' : 'lexical retrieval; run shoalrun index --embed for semantic results'}.\n`;
242
242
  }
243
243
  if (ask) {
244
244
  // The preview spends your API credits on every question, so it answers only on this machine.
@@ -425,7 +425,11 @@ try {
425
425
  const provider = createProvider(embeddingProviderConfig(retrieval, { provider: values['--provider'], model: values['--model'], dimension: values['--dimension'] === undefined ? undefined : Number(values['--dimension']), baseUrl: values['--base-url'], keyEnv: values['--key-env'] }));
426
426
  embeddings = await embedChunkIndex(subcommand, result.index, provider, embeddingInput);
427
427
  }
428
- process.stdout.write(JSON.stringify({ status: 'ok', documents: result.documents, chunks: result.index.chunks.length, output: result.output, ...(embeddings ? { embeddings } : {}) }, null, 2) + '\n');
428
+ const counts = new Map();
429
+ for (const chunk of result.index.chunks)
430
+ counts.set(chunk.variant, (counts.get(chunk.variant) ?? 0) + 1);
431
+ const publications = [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([variant, passages]) => ({ variant, passages }));
432
+ process.stdout.write(JSON.stringify({ status: 'ok', documents: result.documents, chunks: result.index.chunks.length, sourceDigest: result.index.sourceDigest, publications, output: result.output, ...(embeddings ? { embeddings } : {}) }, null, 2) + '\n');
429
433
  }
430
434
  else if (command === 'ask') {
431
435
  const usage = 'Usage: shoalrun ask <site-directory> "<question>" [--config FILE] [--model MODEL] [--passages N] [--reader-view FACET=CHOICE[,CHOICE]]... [--channel C --version V --language L] [--no-rerank] [--no-direct] [--no-escalate] [--no-cards] [--json]';
package/dist/compiler.js CHANGED
@@ -66,6 +66,8 @@ function validateSearch(value, declaringFile) {
66
66
  return { provider, index: false };
67
67
  if (provider === 'lexical')
68
68
  return { provider, module: builtInSearchProvider, index: true };
69
+ if (provider === 'semantic')
70
+ return { provider, module: path.resolve(path.dirname(builtInSearchProvider), 'search-semantic.js'), index: true };
69
71
  if (provider !== 'custom')
70
72
  throw new Error(`Unknown search provider ${provider}`);
71
73
  if (typeof config.module !== 'string' || !/\.m?js$/.test(config.module))
@@ -164,7 +164,9 @@ export function chunkMarkdown(markdown, document, anchors = [], options = {}) {
164
164
  return chunks;
165
165
  }
166
166
  function makeChunk(document, headingPath, anchor, text, used, part = 0) {
167
- const base = `${document.id}#${slug(headingPath.join(' ')) || 'intro'}${part ? `~${part + 1}` : ''}`;
167
+ // Logical page IDs repeat across releases. Stored passage IDs must not: vector
168
+ // rows and answer citations are keyed globally within this site's index.
169
+ const base = `${encodeURIComponent(document.variant)}:${encodeURIComponent(document.id)}#${slug(headingPath.join(' ')) || 'intro'}${part ? `~${part + 1}` : ''}`;
168
170
  const seen = used.get(base) ?? 0;
169
171
  used.set(base, seen + 1);
170
172
  const chunk = { id: seen ? `${base}-${seen + 1}` : base, docId: document.id, title: document.title, type: document.type, variant: document.variant, headingPath, url: anchor ? `${document.url}#${anchor}` : document.url, markdownUrl: document.markdownUrl, text };
@@ -219,8 +221,10 @@ export async function buildChunkIndex(directory, options = {}) {
219
221
  const { channel, version, language } = document.variant;
220
222
  chunks.push(...chunkMarkdown(markdown, { ...document, variant: `${channel}|${version}|${language}` }, anchors.get(document.url), options));
221
223
  }
222
- const source = manifest.documents.map(document => `${document.id}:${document.digest}`).sort().join('\n');
223
- const index = { schemaVersion: 1, sourceDigest: sha(source + (synonyms.length ? `\n${JSON.stringify(synonyms)}` : '')), chunks, ...(synonyms.length ? { synonyms } : {}) };
224
+ // Generation identity includes publication membership, routes, reader marks,
225
+ // and prepared search evidence. Embedding digests remain content-only.
226
+ const source = chunks.map(({ id, variant, digest, url, markdownUrl, readerFilters, search }) => ({ id, variant, digest, url, markdownUrl, readerFilters: Object.fromEntries(Object.entries(readerFilters ?? {}).sort(([a], [b]) => a.localeCompare(b)).map(([facet, values]) => [facet, [...values].sort()])), search })).sort((a, b) => a.id.localeCompare(b.id));
227
+ const index = { schemaVersion: 1, sourceDigest: sha(JSON.stringify({ identityVersion: 2, source, synonyms })), chunks, ...(synonyms.length ? { synonyms } : {}) };
224
228
  const output = path.join(await aiDirectory(root), 'chunks.json');
225
229
  await writeFile(output, JSON.stringify(index) + '\n');
226
230
  return { index, output, documents: manifest.documents.length };
@@ -52,7 +52,7 @@ export declare function renderPublicationPages({ rendered, docs, relationships,
52
52
  header: HeaderConfig;
53
53
  footer?: FooterConfig;
54
54
  search: {
55
- provider: 'lexical' | 'custom' | 'none';
55
+ provider: 'lexical' | 'semantic' | 'custom' | 'none';
56
56
  };
57
57
  readerFilters?: ReaderFiltersConfig;
58
58
  variants: Variant[];
@@ -21,6 +21,7 @@ export declare class LexicalIndex {
21
21
  private readonly average;
22
22
  constructor(chunks: Chunk[], synonyms?: SynonymGroups);
23
23
  search(query: string, limit?: number, keep?: Keep): Hit[];
24
+ scan(query: string, keep?: Keep): IterableIterator<Hit>;
24
25
  }
25
26
  type VectorRow = {
26
27
  chunk: Chunk;
@@ -30,7 +31,9 @@ export declare class VectorIndex {
30
31
  private readonly rows;
31
32
  constructor(rows: VectorRow[]);
32
33
  search(query: Float32Array, limit?: number, keep?: Keep): Hit[];
34
+ scan(query: Float32Array, keep?: Keep): IterableIterator<Hit>;
33
35
  }
36
+ export declare const compareHits: (left: Hit, right: Hit) => number;
34
37
  export declare function fuse(lists: Hit[][], limit?: number, k?: number): Hit[];
35
38
  export type Coverage = {
36
39
  total: number;
@@ -53,6 +56,7 @@ export declare class Retriever {
53
56
  private variant;
54
57
  pageIntroduction(docId: string, options?: LexicalOptions): Chunk | undefined;
55
58
  lexical(query: string, options?: LexicalOptions): Hit[];
59
+ searchPassages(query: string, queryVector: Float32Array | undefined, options?: LexicalOptions): Iterable<Hit>;
56
60
  vector(queryVector: Float32Array, options?: LexicalOptions): Hit[];
57
61
  hasVectors(options?: LexicalOptions): boolean;
58
62
  hybrid(query: string, queryVector: Float32Array | undefined, options?: LexicalOptions): Hit[];
package/dist/retrieval.js CHANGED
@@ -67,10 +67,12 @@ export class LexicalIndex {
67
67
  this.average = this.entries.reduce((sum, entry) => sum + entry.length, 0) / (this.entries.length || 1);
68
68
  }
69
69
  search(query, limit = 8, keep) {
70
+ return [...this.scan(query, keep)].sort(compareHits).slice(0, limit);
71
+ }
72
+ *scan(query, keep) {
70
73
  const terms = [...new Set(tokenize(query))];
71
74
  const units = queryUnits(query, this.synonyms).filter(unit => unit.alternatives.length);
72
75
  const total = this.entries.length;
73
- const hits = [];
74
76
  for (const entry of this.entries) {
75
77
  if (keep && !keep(entry.chunk))
76
78
  continue;
@@ -93,9 +95,8 @@ export class LexicalIndex {
93
95
  }
94
96
  }
95
97
  if (score > 0)
96
- hits.push({ chunk: entry.chunk, score });
98
+ yield { chunk: entry.chunk, score };
97
99
  }
98
- return hits.sort((left, right) => right.score - left.score || left.chunk.id.localeCompare(right.chunk.id)).slice(0, limit);
99
100
  }
100
101
  }
101
102
  function unit(vector) {
@@ -113,8 +114,10 @@ export class VectorIndex {
113
114
  this.rows = rows;
114
115
  }
115
116
  search(query, limit = 8, keep) {
117
+ return [...this.scan(query, keep)].sort(compareHits).slice(0, limit);
118
+ }
119
+ *scan(query, keep) {
116
120
  const q = unit(query);
117
- const hits = [];
118
121
  for (const row of this.rows) {
119
122
  if (keep && !keep(row.chunk))
120
123
  continue;
@@ -123,11 +126,11 @@ export class VectorIndex {
123
126
  let score = 0;
124
127
  for (let at = 0; at < q.length; at++)
125
128
  score += q[at] * row.vector[at];
126
- hits.push({ chunk: row.chunk, score });
129
+ yield { chunk: row.chunk, score };
127
130
  }
128
- return hits.sort((left, right) => right.score - left.score || left.chunk.id.localeCompare(right.chunk.id)).slice(0, limit);
129
131
  }
130
132
  }
133
+ export const compareHits = (left, right) => right.score - left.score || left.chunk.id.localeCompare(right.chunk.id);
131
134
  // Reciprocal rank fusion. Lexical and vector scores are not comparable, so only ranks are combined.
132
135
  export function fuse(lists, limit = 8, k = 60) {
133
136
  const scores = new Map();
@@ -149,10 +152,16 @@ export class Retriever {
149
152
  constructor(index, embeddings) {
150
153
  if (index?.schemaVersion !== 1 || !Array.isArray(index.chunks))
151
154
  throw new Error('Unsupported or invalid chunks.json');
155
+ if (new Set(index.chunks.map(chunk => chunk.id)).size !== index.chunks.length)
156
+ throw new Error('Ambiguous passage IDs in chunks.json; run shoalrun index <site-directory> to rebuild publication-qualified IDs. Existing passage embeddings can be reused.');
152
157
  const synonyms = validateSearchSynonyms(index.synonyms);
153
158
  const groups = new Map();
154
- for (const chunk of index.chunks)
155
- groups.set(chunk.variant, [...(groups.get(chunk.variant) ?? []), chunk]);
159
+ for (const chunk of index.chunks) {
160
+ let passages = groups.get(chunk.variant);
161
+ if (!passages)
162
+ groups.set(chunk.variant, passages = []);
163
+ passages.push(chunk);
164
+ }
156
165
  this.counts = { total: index.chunks.length, embedded: 0, stale: 0, ...(embeddings ? { model: embeddings.meta.model, dimension: embeddings.meta.dimension } : {}) };
157
166
  const rowOf = new Map((embeddings?.meta.ids ?? []).map((id, row) => [id, row]));
158
167
  for (const chunk of index.chunks)
@@ -209,6 +218,17 @@ export class Retriever {
209
218
  lexical(query, options = {}) {
210
219
  return this.byVariant.get(this.variant(options)).search(query, options.limit, keeping(options));
211
220
  }
221
+ // File-backed search still scans its selected publication, but streams scores
222
+ // into page selection instead of sorting/materializing every passage first.
223
+ searchPassages(query, queryVector, options = {}) {
224
+ const variant = this.variant(options);
225
+ if (!queryVector)
226
+ return this.byVariant.get(variant).scan(query, keeping(options));
227
+ const index = this.vectors.get(variant);
228
+ if (!index)
229
+ throw new Error('No current embeddings for this publication; run shoalrun index --embed');
230
+ return index.scan(queryVector, keeping(options));
231
+ }
212
232
  vector(queryVector, options = {}) {
213
233
  const index = this.vectors.get(this.variant(options));
214
234
  if (!index)
@@ -1,6 +1,7 @@
1
+ import { compareHits } from './retrieval.js';
1
2
  import { validateReaderSelection } from './reader-filters.js';
2
3
  import { searchSnippetFromParagraphs } from './search-snippet.js';
3
- import { identifierRescue, searchIdentifiers } from './search-identifier.js';
4
+ import { identifierMatch } from './search-identifier.js';
4
5
  import { createSearchResultSelector } from './search-results.js';
5
6
  export function createSearchHandler(options) {
6
7
  const selectResults = createSearchResultSelector(options.retriever);
@@ -91,17 +92,16 @@ export function createSearchHandler(options) {
91
92
  }
92
93
  if (!vector && fallbackReason)
93
94
  options.onFallback?.(fallbackReason);
94
- // Rank pages after inspecting their passages. A large gallery can otherwise
95
- // consume the entire candidate window before page deduplication happens.
96
- const retrievalOptions = { ...searchOptions, limit: Number.MAX_SAFE_INTEGER };
97
- let hits = vector
98
- ? options.retriever.vector(vector, retrievalOptions)
99
- : options.retriever.lexical(query.trim(), retrievalOptions);
100
- if (vector && limit >= 3 && searchIdentifiers(query).length) {
101
- const lexical = options.retriever.lexical(query.trim(), retrievalOptions);
102
- hits = identifierRescue(query.trim(), hits, lexical);
95
+ let candidate;
96
+ const matchesIdentifier = identifierMatch(query);
97
+ if (vector && limit >= 3 && matchesIdentifier) {
98
+ for (const hit of options.retriever.searchPassages(query.trim(), undefined, searchOptions)) {
99
+ if (matchesIdentifier(hit) && (!candidate || compareHits(hit, candidate) < 0))
100
+ candidate = hit;
101
+ }
103
102
  }
104
- const results = selectResults(hits, query.trim(), searchOptions).map(({ chunk, literalSummary }) => ({ url: chunk.url, title: chunk.title, section: chunk.headingPath.at(-1), summary: literalSummary ?? searchSnippetFromParagraphs(chunk.search.paragraphs, query.trim(), options.pageSummaries?.get(`${chunk.variant}:${chunk.docId}`)) }));
103
+ const hits = selectResults(options.retriever.searchPassages(query.trim(), vector, searchOptions), query.trim(), searchOptions, candidate);
104
+ const results = hits.map(({ chunk, literalSummary }) => ({ url: chunk.url, title: chunk.title, section: chunk.headingPath.at(-1), summary: literalSummary ?? searchSnippetFromParagraphs(chunk.search.paragraphs, query.trim(), options.pageSummaries?.get(`${chunk.variant}:${chunk.docId}`)) }));
105
105
  return json(200, { results, mode: vector ? 'vector' : 'lexical', ...(!vector ? { fallbackReason } : {}) });
106
106
  }
107
107
  catch {
@@ -1,3 +1,4 @@
1
1
  import type { Hit } from './retrieval.js';
2
2
  export declare function searchIdentifiers(query: string): string[];
3
- export declare function identifierRescue(query: string, semantic: Hit[], lexical: Hit[]): Hit[];
3
+ export declare function identifierRescue<T extends Hit>(query: string, semantic: T[], lexical: T[]): T[];
4
+ export declare function identifierMatch(query: string): ((hit: Hit) => boolean) | undefined;
@@ -7,11 +7,10 @@ export function searchIdentifiers(query) {
7
7
  return [...new Set(identifiers.filter(id => !/\.(?:txt|json|ya?ml|mdx?|[cm]?js|tsx?|html?|css|svg|png|jpe?g|com|org|net)$/i.test(id)))];
8
8
  }
9
9
  export function identifierRescue(query, semantic, lexical) {
10
- const identifiers = searchIdentifiers(query);
11
- if (!identifiers.length)
10
+ const matches = identifierMatch(query);
11
+ if (!matches)
12
12
  return semantic;
13
- const matches = identifiers.map(id => new RegExp(`(?<![\\w$.-])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w$-]|\\.[\\w$])`, 'i'));
14
- const candidate = lexical.find(hit => (hit.chunk.search?.paragraphs ?? searchParagraphs(hit.chunk.text)).some(p => matches.every(re => re.test(p))));
13
+ const candidate = lexical.find(matches);
15
14
  if (!candidate)
16
15
  return semantic;
17
16
  const pages = [...new Set(semantic.map(h => h.chunk.docId))];
@@ -23,3 +22,10 @@ export function identifierRescue(query, semantic, lexical) {
23
22
  const position = at < 0 ? remaining.length : at;
24
23
  return [...remaining.slice(0, position), candidate, ...remaining.slice(position)];
25
24
  }
25
+ export function identifierMatch(query) {
26
+ const identifiers = searchIdentifiers(query);
27
+ if (!identifiers.length)
28
+ return undefined;
29
+ const matches = identifiers.map(id => new RegExp(`(?<![\\w$.-])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w$-]|\\.[\\w$])`, 'i'));
30
+ return hit => (hit.chunk.search?.paragraphs ?? searchParagraphs(hit.chunk.text)).some(p => matches.every(re => re.test(p)));
31
+ }
@@ -2,5 +2,5 @@ import type { Hit, LexicalOptions, Retriever } from './retrieval.js';
2
2
  type SearchHit = Hit & {
3
3
  literalSummary?: string;
4
4
  };
5
- export declare function createSearchResultSelector(retriever: Retriever): (hits: Hit[], query: string, options: LexicalOptions) => SearchHit[];
5
+ export declare function createSearchResultSelector(retriever: Retriever): (hits: Iterable<Hit>, query: string, options: LexicalOptions, rescue?: Hit) => SearchHit[];
6
6
  export {};
@@ -1,45 +1,59 @@
1
- import { tokenize } from './retrieval.js';
1
+ import { compareHits, tokenize } from './retrieval.js';
2
2
  import { literalSearch } from './search-literal.js';
3
+ import { identifierRescue } from './search-identifier.js';
3
4
  // Select landing passages for the search box, without changing passage retrieval
4
5
  // used by Ask. A gallery's repeated examples must not crowd other pages out.
5
6
  export function createSearchResultSelector(retriever) {
6
7
  retriever.requirePreparedSearch();
7
8
  const normalized = (text) => tokenize(text).join(' ');
8
- return (hits, query, options) => {
9
+ return (hits, query, options, rescue) => {
9
10
  const pages = new Map();
11
+ const normalizedQuery = normalized(query);
10
12
  const literal = literalSearch(query);
11
- const literalPages = new Map();
12
- if (literal) {
13
- for (const hit of hits) {
14
- if (literalPages.has(hit.chunk.docId))
15
- continue;
16
- const literalSummary = literal(hit.chunk.search.literalLines);
17
- if (literalSummary)
18
- literalPages.set(hit.chunk.docId, { ...hit, literalSummary });
19
- }
20
- }
13
+ let exact;
14
+ // Retain at most three candidates per page, independent of its passage
15
+ // count. Input scores need not be sorted or collected into an array.
21
16
  for (const hit of hits) {
22
- if (pages.has(hit.chunk.docId))
17
+ const page = pages.get(hit.chunk.docId) ?? { exactTitle: normalized(hit.chunk.title) === normalizedQuery };
18
+ if (!page.first || compareHits(hit, page.first) < 0)
19
+ page.first = hit;
20
+ if (hit.chunk.search.paragraphs.length && (!page.prose || compareHits(hit, page.prose) < 0))
21
+ page.prose = hit;
22
+ const literalSummary = literal?.(hit.chunk.search.literalLines);
23
+ if (literalSummary && (!page.literal || compareHits(hit, page.literal) < 0))
24
+ page.literal = { ...hit, literalSummary };
25
+ pages.set(hit.chunk.docId, page);
26
+ if (page.exactTitle && (!exact || compareHits(hit, exact) < 0))
27
+ exact = hit;
28
+ }
29
+ const limit = options.limit ?? 8;
30
+ const candidates = [];
31
+ for (const page of pages.values()) {
32
+ const candidate = page.literal ? { rank: page.first, hit: { ...page.literal, score: page.first.score } } : page.prose ? { rank: page.prose, hit: page.prose } : undefined;
33
+ if (!candidate)
23
34
  continue;
24
- // Keep the page's retrieved position, but land on the passage that actually
25
- // contains the literal. A nearby token table must not hide the correct row.
26
- const exactPassage = literalPages.get(hit.chunk.docId);
27
- if (exactPassage)
28
- pages.set(hit.chunk.docId, { ...exactPassage, score: hit.score });
29
- else if (hit.chunk.search.paragraphs.length)
30
- pages.set(hit.chunk.docId, hit);
35
+ const at = candidates.findIndex(other => compareHits(candidate.rank, other.rank) < 0);
36
+ candidates.splice(at < 0 ? candidates.length : at, 0, candidate);
37
+ if (candidates.length > limit)
38
+ candidates.pop();
39
+ }
40
+ let selected = candidates.map(item => item.hit);
41
+ if (rescue) {
42
+ selected = identifierRescue(query, selected, [rescue]);
43
+ const landing = pages.get(rescue.chunk.docId)?.literal;
44
+ if (landing)
45
+ selected = selected.map(hit => hit.chunk.docId === rescue.chunk.docId ? { ...landing, score: hit.score } : hit);
31
46
  }
32
47
  // An exact page-title query is a request for that topic's entry point.
33
48
  // Ordinary semantic queries retain their ranking; incidental words do not qualify.
34
- const exact = hits.find(hit => normalized(hit.chunk.title) === normalized(query));
35
49
  if (exact) {
36
50
  const introduction = retriever.pageIntroduction(exact.chunk.docId, options);
37
- const landing = introduction ? { chunk: introduction, score: exact.score } : pages.get(exact.chunk.docId);
51
+ const page = pages.get(exact.chunk.docId);
52
+ const landing = introduction ? { chunk: introduction, score: exact.score } : page?.literal ?? page?.prose;
38
53
  if (landing) {
39
- pages.delete(exact.chunk.docId);
40
- return [landing, ...pages.values()].slice(0, options.limit ?? 8);
54
+ return [landing, ...selected.filter(hit => hit.chunk.docId !== exact.chunk.docId)].slice(0, options.limit ?? 8);
41
55
  }
42
56
  }
43
- return [...pages.values()].slice(0, options.limit ?? 8);
57
+ return selected.slice(0, limit);
44
58
  };
45
59
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shoalrun/cli",
3
- "version": "0.1.0-alpha.15",
3
+ "version": "0.1.0-alpha.16",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "access": "public"
@@ -33,4 +33,6 @@ For a new OpenAPI source, read `guide.api-references` and complete its API-sourc
33
33
 
34
34
  Check the generated HTML, Markdown twins, and `documents.json`. Confirm navigation and API links resolve, and inspect assets inside the components that contain them. Run `shoalrun audit docs.config.yaml` when project checks or API audits are configured; documentation behavior tests can use Doc Detective through `audit.tests` or another external CLI.
35
35
 
36
+ For optional semantic search, use `outputs.html.search.provider: semantic` (available since `0.1.0-alpha.16`) with `retrieval.embeddings`; do not generate a custom browser adapter for Shoalrun's own endpoint. Build the site, run `shoalrun index <output> --embed --config docs.config.yaml`, then `shoalrun serve <output> --search --config docs.config.yaml` with the provider key only in the environment. Confirm the endpoint returns `mode: vector`; a visible result or keyword fallback does not prove semantic retrieval. Selecting the browser provider does not provision production hosting, and profiled Vercel chat-function builds remain unsupported.
37
+
36
38
  Report the files created, commands run, and anything still needing an owner decision. Do not deploy, publish a release, or claim factual accuracy from a successful build alone.