gnosys 5.15.3 → 5.17.0

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.
@@ -11,6 +11,11 @@ import { readFileSync, existsSync } from "fs";
11
11
  // ─── Module-level cache ──────────────────────────────────────────────────
12
12
  let cachedIndex = null;
13
13
  let cachedSource = null;
14
+ let cachedVectors = null;
15
+ let cachedVectorsSource = null;
16
+ const RRF_K = 60;
17
+ /** Expanded (concept-related) tokens contribute at half weight — plan §3. */
18
+ const EXPANSION_DISCOUNT = 0.5;
14
19
  // ─── Stop words (same list used by webIndex.ts at build time) ────────────
15
20
  const STOP_WORDS = new Set([
16
21
  "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for",
@@ -63,7 +68,7 @@ export function loadIndex(pathOrJson) {
63
68
  if (!index.version || typeof index.version !== "number") {
64
69
  throw new Error("Invalid Gnosys web index: missing or invalid version field");
65
70
  }
66
- if (index.version > 1) {
71
+ if (index.version > 2) {
67
72
  throw new Error(`Gnosys web index version ${index.version} is not supported by this version of gnosys/web. ` +
68
73
  `Please update the gnosys package.`);
69
74
  }
@@ -71,6 +76,36 @@ export function loadIndex(pathOrJson) {
71
76
  cachedSource = pathOrJson;
72
77
  return index;
73
78
  }
79
+ /**
80
+ * Load pre-computed Gnosys web vectors from a file path or raw JSON string.
81
+ * Caches the result for repeated calls with the same source.
82
+ */
83
+ export function loadVectors(pathOrJson) {
84
+ if (cachedVectors && cachedVectorsSource === pathOrJson) {
85
+ return cachedVectors;
86
+ }
87
+ let raw;
88
+ if (pathOrJson.trimStart().startsWith("{")) {
89
+ raw = pathOrJson;
90
+ }
91
+ else {
92
+ if (!existsSync(pathOrJson)) {
93
+ throw new Error(`Gnosys web vectors not found: ${pathOrJson}`);
94
+ }
95
+ raw = readFileSync(pathOrJson, "utf-8");
96
+ }
97
+ let parsed;
98
+ try {
99
+ parsed = JSON.parse(raw);
100
+ }
101
+ catch {
102
+ throw new Error("Invalid JSON in Gnosys web vectors");
103
+ }
104
+ const vectors = validateVectorsFile(parsed);
105
+ cachedVectors = vectors;
106
+ cachedVectorsSource = pathOrJson;
107
+ return vectors;
108
+ }
74
109
  /**
75
110
  * Clear the cached index (useful for testing).
76
111
  */
@@ -78,31 +113,86 @@ export function clearIndexCache() {
78
113
  cachedIndex = null;
79
114
  cachedSource = null;
80
115
  }
116
+ /**
117
+ * Clear the cached vectors file (useful for testing).
118
+ */
119
+ export function clearVectorsCache() {
120
+ cachedVectors = null;
121
+ cachedVectorsSource = null;
122
+ }
123
+ /**
124
+ * Cosine similarity for equal-dimension vectors. Returns 0 for zero-magnitude
125
+ * vectors or dimension mismatches.
126
+ */
127
+ export function cosineSimilarity(a, b) {
128
+ if (a.length !== b.length)
129
+ return 0;
130
+ let dot = 0;
131
+ let magA = 0;
132
+ let magB = 0;
133
+ for (let i = 0; i < a.length; i++) {
134
+ dot += a[i] * b[i];
135
+ magA += a[i] * a[i];
136
+ magB += b[i] * b[i];
137
+ }
138
+ if (magA === 0 || magB === 0)
139
+ return 0;
140
+ return dot / (Math.sqrt(magA) * Math.sqrt(magB));
141
+ }
81
142
  /**
82
143
  * Search the pre-computed index and return ranked results.
83
144
  */
84
145
  export function search(index, query, options = {}) {
85
- const { limit = 6, minScore = 0.1, category, tags, boostRecent = false } = options;
146
+ const { limit = 6, minScore = 0.1, category, tags, boostRecent = false, queryVector, vectors, expectedModel, expandQuery = true, } = options;
86
147
  const queryTokens = tokenize(query);
87
148
  if (queryTokens.length === 0)
88
149
  return [];
150
+ const lexicalOptions = { limit, minScore, category, tags, boostRecent, expandQuery };
151
+ if (!queryVector || !vectors) {
152
+ return stripDocIndexes(buildLexicalResults(index, queryTokens, lexicalOptions));
153
+ }
154
+ if (expectedModel && expectedModel !== vectors.model) {
155
+ console.warn(`[gnosys/web] embedding model mismatch: index built with ${vectors.model}; ` +
156
+ `query uses ${expectedModel} - falling back to lexical search`);
157
+ return stripDocIndexes(buildLexicalResults(index, queryTokens, lexicalOptions));
158
+ }
159
+ if (queryVector.length !== vectors.dims) {
160
+ console.warn(`[gnosys/web] embedding dimension mismatch: index built with ${vectors.dims} dimensions; ` +
161
+ `query has ${queryVector.length} - falling back to lexical search`);
162
+ return stripDocIndexes(buildLexicalResults(index, queryTokens, lexicalOptions));
163
+ }
164
+ // For hybrid fusion, minScore is intentionally not applied: RRF scores are
165
+ // rank-based and much smaller than lexical TF-IDF scores.
166
+ const lexicalRanking = buildLexicalResults(index, queryTokens, {
167
+ limit: Number.POSITIVE_INFINITY,
168
+ minScore: Number.NEGATIVE_INFINITY,
169
+ category,
170
+ tags,
171
+ boostRecent,
172
+ expandQuery,
173
+ });
174
+ const semanticRanking = buildSemanticRanking(index, queryVector, vectors, category, tags);
175
+ return rrfFusion(lexicalRanking, semanticRanking).slice(0, limit);
176
+ }
177
+ function buildLexicalResults(index, queryTokens, options) {
178
+ const { limit, minScore, category, tags, boostRecent, expandQuery } = options;
89
179
  // Accumulate scores per document
90
180
  const docScores = new Map();
91
- for (const token of queryTokens) {
181
+ for (const { token, weight } of buildWeightedQueryTokens(index, queryTokens, expandQuery)) {
92
182
  const entries = index.invertedIndex[token];
93
183
  if (!entries)
94
184
  continue;
95
185
  for (const entry of entries) {
96
186
  const existing = docScores.get(entry.docIndex);
97
187
  if (existing) {
98
- existing.score += entry.score;
188
+ existing.score += entry.score * weight;
99
189
  if (!existing.matchedTokens.includes(token)) {
100
190
  existing.matchedTokens.push(token);
101
191
  }
102
192
  }
103
193
  else {
104
194
  docScores.set(entry.docIndex, {
105
- score: entry.score,
195
+ score: entry.score * weight,
106
196
  matchedTokens: [token],
107
197
  });
108
198
  }
@@ -131,12 +221,151 @@ export function search(index, query, options = {}) {
131
221
  }
132
222
  if (finalScore < minScore)
133
223
  continue;
134
- results.push({ document: doc, score: finalScore, matchedTokens });
224
+ results.push({ document: doc, score: finalScore, matchedTokens, docIndex });
135
225
  }
136
226
  // Sort by score descending
137
227
  results.sort((a, b) => b.score - a.score);
138
228
  return results.slice(0, limit);
139
229
  }
230
+ function buildWeightedQueryTokens(index, queryTokens, expandQuery) {
231
+ if (!expandQuery || !index.expansions) {
232
+ return queryTokens.map((token) => ({ token, weight: 1 }));
233
+ }
234
+ const directTokens = new Set(queryTokens);
235
+ const expandedTokens = new Set();
236
+ const weightedTokens = queryTokens.map((token) => ({ token, weight: 1 }));
237
+ for (const token of queryTokens) {
238
+ const related = index.expansions[token];
239
+ if (!Array.isArray(related))
240
+ continue;
241
+ for (const relatedToken of related) {
242
+ if (typeof relatedToken !== "string" ||
243
+ directTokens.has(relatedToken) ||
244
+ expandedTokens.has(relatedToken)) {
245
+ continue;
246
+ }
247
+ expandedTokens.add(relatedToken);
248
+ weightedTokens.push({ token: relatedToken, weight: EXPANSION_DISCOUNT });
249
+ }
250
+ }
251
+ return weightedTokens;
252
+ }
253
+ function buildSemanticRanking(index, queryVector, vectors, category, tags) {
254
+ const docIndexById = new Map();
255
+ for (let i = 0; i < index.documents.length; i++) {
256
+ docIndexById.set(index.documents[i].id, i);
257
+ }
258
+ const results = [];
259
+ for (const [docId, quantizedVector] of Object.entries(vectors.vectors)) {
260
+ const docIndex = docIndexById.get(docId);
261
+ if (docIndex === undefined)
262
+ continue;
263
+ if (!Array.isArray(quantizedVector) || quantizedVector.length !== vectors.dims)
264
+ continue;
265
+ const doc = index.documents[docIndex];
266
+ if (!doc)
267
+ continue;
268
+ if (category && doc.category !== category)
269
+ continue;
270
+ if (tags && tags.length > 0 && !tags.some((t) => doc.tags.includes(t)))
271
+ continue;
272
+ const docVector = quantizedVector.map((value) => value * vectors.scale + vectors.offset);
273
+ const semanticScore = cosineSimilarity(queryVector, docVector);
274
+ if (!Number.isFinite(semanticScore))
275
+ continue;
276
+ results.push({ docIndex, document: doc, semanticScore });
277
+ }
278
+ results.sort((a, b) => b.semanticScore - a.semanticScore);
279
+ return results;
280
+ }
281
+ function rrfFusion(lexicalResults, semanticResults) {
282
+ const fused = new Map();
283
+ for (let i = 0; i < lexicalResults.length; i++) {
284
+ const result = lexicalResults[i];
285
+ fused.set(result.docIndex, {
286
+ score: 1 / (RRF_K + i + 1),
287
+ document: result.document,
288
+ matchedTokens: result.matchedTokens,
289
+ });
290
+ }
291
+ for (let i = 0; i < semanticResults.length; i++) {
292
+ const result = semanticResults[i];
293
+ const rrfScore = 1 / (RRF_K + i + 1);
294
+ const existing = fused.get(result.docIndex);
295
+ if (existing) {
296
+ existing.score += rrfScore;
297
+ existing.semanticScore = result.semanticScore;
298
+ }
299
+ else {
300
+ fused.set(result.docIndex, {
301
+ score: rrfScore,
302
+ document: result.document,
303
+ matchedTokens: [],
304
+ semanticScore: result.semanticScore,
305
+ });
306
+ }
307
+ }
308
+ return Array.from(fused.values()).sort((a, b) => b.score - a.score);
309
+ }
310
+ function stripDocIndexes(results) {
311
+ return results.map((result) => ({
312
+ document: result.document,
313
+ score: result.score,
314
+ matchedTokens: result.matchedTokens,
315
+ }));
316
+ }
317
+ function validateVectorsFile(parsed) {
318
+ if (!isRecord(parsed)) {
319
+ throw new Error("Invalid Gnosys web vectors: expected an object");
320
+ }
321
+ const version = parsed.version;
322
+ if (typeof version !== "number") {
323
+ throw new Error("Invalid Gnosys web vectors: missing or invalid version field");
324
+ }
325
+ if (version !== 1) {
326
+ throw new Error(`Gnosys web vectors version ${version} is not supported by this version of gnosys/web. ` +
327
+ `Please update the gnosys package.`);
328
+ }
329
+ if (typeof parsed.model !== "string") {
330
+ throw new Error("Invalid Gnosys web vectors: missing or invalid model field");
331
+ }
332
+ if (typeof parsed.dims !== "number" || !Number.isFinite(parsed.dims) || parsed.dims < 0) {
333
+ throw new Error("Invalid Gnosys web vectors: missing or invalid dims field");
334
+ }
335
+ if (parsed.quantization !== "int8") {
336
+ throw new Error("Invalid Gnosys web vectors: quantization must be int8");
337
+ }
338
+ if (typeof parsed.generated !== "string") {
339
+ throw new Error("Invalid Gnosys web vectors: missing or invalid generated field");
340
+ }
341
+ if (typeof parsed.scale !== "number" || !Number.isFinite(parsed.scale) || parsed.scale <= 0) {
342
+ throw new Error("Invalid Gnosys web vectors: missing or invalid scale field");
343
+ }
344
+ if (typeof parsed.offset !== "number" || !Number.isFinite(parsed.offset)) {
345
+ throw new Error("Invalid Gnosys web vectors: missing or invalid offset field");
346
+ }
347
+ if (!isRecord(parsed.vectors)) {
348
+ throw new Error("Invalid Gnosys web vectors: missing or invalid vectors field");
349
+ }
350
+ for (const [docId, vector] of Object.entries(parsed.vectors)) {
351
+ if (!Array.isArray(vector) || !vector.every((value) => typeof value === "number")) {
352
+ throw new Error(`Invalid Gnosys web vectors: vector for ${docId} must be a number array`);
353
+ }
354
+ }
355
+ return {
356
+ version,
357
+ model: parsed.model,
358
+ dims: parsed.dims,
359
+ quantization: parsed.quantization,
360
+ generated: parsed.generated,
361
+ scale: parsed.scale,
362
+ offset: parsed.offset,
363
+ vectors: parsed.vectors,
364
+ };
365
+ }
366
+ function isRecord(value) {
367
+ return typeof value === "object" && value !== null && !Array.isArray(value);
368
+ }
140
369
  /**
141
370
  * Get a specific document's metadata by ID or path.
142
371
  */
@@ -5,6 +5,9 @@ export type WebBuildCommandOptions = {
5
5
  llm: boolean;
6
6
  concurrency: string;
7
7
  dryRun?: boolean;
8
+ embeddings?: string;
9
+ embedModel?: string;
10
+ expansions?: boolean;
8
11
  json?: boolean;
9
12
  };
10
13
  export declare function runWebBuildCommand(getWebStorePath: GetWebStorePath, opts: WebBuildCommandOptions): Promise<void>;
@@ -1,14 +1,18 @@
1
1
  import path from "path";
2
+ const VECTOR_PROVIDERS = new Set(["openai", "voyage", "local"]);
2
3
  export async function runWebBuildCommand(getWebStorePath, opts) {
3
4
  try {
4
5
  const { loadConfig } = await import("./config.js");
5
6
  const { ingestSite } = await import("./webIngest.js");
6
- const { buildIndex, writeIndex } = await import("./webIndex.js");
7
- const gnosysConfig = await loadConfig(await getWebStorePath());
7
+ const { attachExpansions, buildIndex, generateExpansions, writeIndex } = await import("./webIndex.js");
8
+ const storePath = await getWebStorePath();
9
+ const gnosysConfig = await loadConfig(storePath);
8
10
  const webConfig = gnosysConfig.web;
9
11
  if (!webConfig) {
10
12
  throw new Error("No web configuration found in gnosys.json. Run 'gnosys web init' first.");
11
13
  }
14
+ const embeddingProvider = parseVectorProvider(opts.embeddings);
15
+ const llmEnrich = opts.llm ? webConfig.llmEnrich : false;
12
16
  // Step 1: Ingest
13
17
  const ingestResult = await ingestSite({
14
18
  source: webConfig.source,
@@ -18,7 +22,7 @@ export async function runWebBuildCommand(getWebStorePath, opts) {
18
22
  outputDir: webConfig.outputDir,
19
23
  exclude: webConfig.exclude,
20
24
  categories: webConfig.categories,
21
- llmEnrich: opts.llm ? webConfig.llmEnrich : false,
25
+ llmEnrich,
22
26
  prune: opts.prune || webConfig.prune,
23
27
  concurrency: parseInt(opts.concurrency, 10) || webConfig.concurrency,
24
28
  crawlDelayMs: webConfig.crawlDelayMs,
@@ -26,17 +30,32 @@ export async function runWebBuildCommand(getWebStorePath, opts) {
26
30
  }, gnosysConfig);
27
31
  // Step 2: Build index (skip if dry run)
28
32
  let indexStats = { documentCount: 0, tokenCount: 0 };
33
+ let vectorsStats;
29
34
  if (!opts.dryRun) {
30
- const index = await buildIndex(webConfig.outputDir);
35
+ let index = await buildIndex(webConfig.outputDir);
36
+ if (opts.expansions !== false && llmEnrich !== false) {
37
+ const llmProvider = await resolveExpansionProvider(gnosysConfig);
38
+ if (llmProvider) {
39
+ const expansions = await generateExpansions(index, llmProvider);
40
+ index = attachExpansions(index, expansions);
41
+ }
42
+ }
31
43
  const indexPath = path.join(webConfig.outputDir, "gnosys-index.json");
32
44
  await writeIndex(index, indexPath);
33
45
  indexStats = {
34
46
  documentCount: index.documentCount,
35
47
  tokenCount: Object.keys(index.invertedIndex).length,
36
48
  };
49
+ if (embeddingProvider) {
50
+ vectorsStats = await buildCommandVectors(webConfig.outputDir, storePath, embeddingProvider, opts.embedModel);
51
+ }
37
52
  }
38
53
  if (opts.json) {
39
- console.log(JSON.stringify({ ...ingestResult, index: indexStats }));
54
+ console.log(JSON.stringify({
55
+ ...ingestResult,
56
+ index: indexStats,
57
+ ...(vectorsStats ? { vectors: vectorsStats } : {}),
58
+ }));
40
59
  }
41
60
  else {
42
61
  console.log(`Web build complete (${ingestResult.duration}ms):`);
@@ -45,6 +64,10 @@ export async function runWebBuildCommand(getWebStorePath, opts) {
45
64
  console.log(` Unchanged: ${ingestResult.unchanged.length}`);
46
65
  console.log(` Removed: ${ingestResult.removed.length}`);
47
66
  console.log(` Index: ${indexStats.documentCount} docs, ${indexStats.tokenCount} tokens`);
67
+ if (vectorsStats) {
68
+ console.log(` Vectors: ${vectorsStats.count} docs, ${vectorsStats.model} (${vectorsStats.dims}d)`);
69
+ console.log(` Vector output: ${vectorsStats.outputPath}`);
70
+ }
48
71
  if (ingestResult.errors.length > 0) {
49
72
  console.log(` Errors: ${ingestResult.errors.length}`);
50
73
  for (const e of ingestResult.errors) {
@@ -63,3 +86,36 @@ export async function runWebBuildCommand(getWebStorePath, opts) {
63
86
  process.exit(1);
64
87
  }
65
88
  }
89
+ function parseVectorProvider(provider) {
90
+ if (!provider)
91
+ return undefined;
92
+ const normalized = provider.trim().toLowerCase();
93
+ if (!VECTOR_PROVIDERS.has(normalized)) {
94
+ throw new Error(`Invalid embeddings provider "${provider}". Valid providers: openai, voyage, local.`);
95
+ }
96
+ return normalized;
97
+ }
98
+ async function resolveExpansionProvider(gnosysConfig) {
99
+ try {
100
+ const { getLLMProvider } = await import("./llm.js");
101
+ return getLLMProvider(gnosysConfig, "structuring");
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ async function buildCommandVectors(knowledgeDir, storePath, provider, model) {
108
+ const { buildVectors, writeVectorsFile } = await import("./webVectors.js");
109
+ const vectorsFile = await buildVectors(knowledgeDir, {
110
+ provider,
111
+ model,
112
+ storePath,
113
+ });
114
+ const outputPath = await writeVectorsFile(knowledgeDir, vectorsFile);
115
+ return {
116
+ model: vectorsFile.model,
117
+ dims: vectorsFile.dims,
118
+ count: Object.keys(vectorsFile.vectors).length,
119
+ outputPath,
120
+ };
121
+ }
@@ -3,6 +3,9 @@ export type WebBuildIndexCommandOptions = {
3
3
  input?: string;
4
4
  output?: string;
5
5
  stopWords: boolean;
6
+ embeddings?: string;
7
+ embedModel?: string;
8
+ expansions?: boolean;
6
9
  json?: boolean;
7
10
  };
8
11
  export declare function runWebBuildIndexCommand(getWebStorePath: GetWebStorePath, opts: WebBuildIndexCommandOptions): Promise<void>;
@@ -1,21 +1,35 @@
1
1
  import path from "path";
2
+ const VECTOR_PROVIDERS = new Set(["openai", "voyage", "local"]);
2
3
  export async function runWebBuildIndexCommand(getWebStorePath, opts) {
3
4
  try {
4
5
  const { loadConfig } = await import("./config.js");
5
- const { buildIndex, writeIndex } = await import("./webIndex.js");
6
- const gnosysConfig = await loadConfig(await getWebStorePath());
6
+ const { attachExpansions, buildIndex, generateExpansions, writeIndex } = await import("./webIndex.js");
7
+ const storePath = await getWebStorePath();
8
+ const gnosysConfig = await loadConfig(storePath);
7
9
  const knowledgeDir = opts.input || gnosysConfig.web?.outputDir || "./knowledge";
8
10
  const outputPath = opts.output || path.join(knowledgeDir, "gnosys-index.json");
9
- const index = await buildIndex(knowledgeDir, {
11
+ const embeddingProvider = parseVectorProvider(opts.embeddings);
12
+ let index = await buildIndex(knowledgeDir, {
10
13
  stopWords: opts.stopWords,
11
14
  });
15
+ if (opts.expansions !== false) {
16
+ const llmProvider = await resolveExpansionProvider(gnosysConfig);
17
+ if (llmProvider) {
18
+ const expansions = await generateExpansions(index, llmProvider);
19
+ index = attachExpansions(index, expansions);
20
+ }
21
+ }
12
22
  await writeIndex(index, outputPath);
23
+ const vectors = embeddingProvider
24
+ ? await buildCommandVectors(knowledgeDir, storePath, embeddingProvider, opts.embedModel)
25
+ : undefined;
13
26
  if (opts.json) {
14
27
  console.log(JSON.stringify({
15
28
  ok: true,
16
29
  documentCount: index.documentCount,
17
30
  tokenCount: Object.keys(index.invertedIndex).length,
18
31
  outputPath,
32
+ ...(vectors ? { vectors } : {}),
19
33
  }));
20
34
  }
21
35
  else {
@@ -23,6 +37,10 @@ export async function runWebBuildIndexCommand(getWebStorePath, opts) {
23
37
  console.log(` Documents: ${index.documentCount}`);
24
38
  console.log(` Tokens: ${Object.keys(index.invertedIndex).length}`);
25
39
  console.log(` Output: ${outputPath}`);
40
+ if (vectors) {
41
+ console.log(` Vectors: ${vectors.count} docs, ${vectors.model} (${vectors.dims}d)`);
42
+ console.log(` Vector output: ${vectors.outputPath}`);
43
+ }
26
44
  }
27
45
  }
28
46
  catch (err) {
@@ -35,3 +53,36 @@ export async function runWebBuildIndexCommand(getWebStorePath, opts) {
35
53
  process.exit(1);
36
54
  }
37
55
  }
56
+ function parseVectorProvider(provider) {
57
+ if (!provider)
58
+ return undefined;
59
+ const normalized = provider.trim().toLowerCase();
60
+ if (!VECTOR_PROVIDERS.has(normalized)) {
61
+ throw new Error(`Invalid embeddings provider "${provider}". Valid providers: openai, voyage, local.`);
62
+ }
63
+ return normalized;
64
+ }
65
+ async function resolveExpansionProvider(gnosysConfig) {
66
+ try {
67
+ const { getLLMProvider } = await import("./llm.js");
68
+ return getLLMProvider(gnosysConfig, "structuring");
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ async function buildCommandVectors(knowledgeDir, storePath, provider, model) {
75
+ const { buildVectors, writeVectorsFile } = await import("./webVectors.js");
76
+ const vectorsFile = await buildVectors(knowledgeDir, {
77
+ provider,
78
+ model,
79
+ storePath,
80
+ });
81
+ const outputPath = await writeVectorsFile(knowledgeDir, vectorsFile);
82
+ return {
83
+ model: vectorsFile.model,
84
+ dims: vectorsFile.dims,
85
+ count: Object.keys(vectorsFile.vectors).length,
86
+ outputPath,
87
+ };
88
+ }
@@ -11,6 +11,10 @@ export interface BuildIndexOptions {
11
11
  minTokenLength?: number;
12
12
  maxTokensPerDoc?: number;
13
13
  includeArchived?: boolean;
14
+ expansions?: Record<string, string[]>;
15
+ }
16
+ export interface ExpansionProvider {
17
+ generate(prompt: string, options?: unknown): Promise<string>;
14
18
  }
15
19
  /**
16
20
  * Build a search index from a directory of Gnosys markdown files.
@@ -20,6 +24,19 @@ export declare function buildIndexSync(knowledgeDir: string, options?: BuildInde
20
24
  * Build a search index (async wrapper).
21
25
  */
22
26
  export declare function buildIndex(knowledgeDir: string, options?: BuildIndexOptions): Promise<GnosysWebIndex>;
27
+ /**
28
+ * Generate a token expansion map from an existing index using an injected LLM.
29
+ * The provider is resolved by callers so this build module remains easy to test
30
+ * and does not depend on runtime config or provider wiring.
31
+ */
32
+ export declare function generateExpansions(index: GnosysWebIndex, llmProvider: ExpansionProvider, options?: {
33
+ maxTokens?: number;
34
+ }): Promise<Record<string, string[]>>;
35
+ /**
36
+ * Attach a non-empty concept expansion map to an index and bump it to v2.
37
+ * Empty maps are ignored so default/no-enrichment builds keep emitting v1.
38
+ */
39
+ export declare function attachExpansions(index: GnosysWebIndex, expansions?: Record<string, string[]>): GnosysWebIndex;
23
40
  /**
24
41
  * Write an index to a JSON file.
25
42
  */