gnosys 5.16.0 → 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.
package/README.md CHANGED
@@ -51,7 +51,7 @@ That's the 60-second tour. **Everything else lives on [gnosys.ai](https://gnosys
51
51
  - **Central brain** — one `~/.gnosys/gnosys.db` unifies every project (project / user / global scopes). Sub-10ms reads, SQLite as the sole source of truth.
52
52
  - **Federated search** — tier-boosted hybrid (FTS5 keyword + semantic) search across scopes, with recency and reinforcement.
53
53
  - **MCP server** — `gnosys serve` exposes 50+ memory tools to any MCP client. Sandbox-first runtime keeps context cost near zero.
54
- - **Web Knowledge Base** — `gnosys web build` turns any site into a searchable index for serverless chatbots. Zero runtime deps.
54
+ - **Web Knowledge Base** — `gnosys web build` turns any site into a searchable index for serverless chatbots, with optional build-time vectors for serverless-safe hybrid semantic search. Zero runtime deps.
55
55
  - **Dream Mode** — idle-time consolidation: confidence decay, summaries, relationship discovery. Never deletes — only suggests.
56
56
  - **Multi-machine sync** — share your brain across machines; conflict detection with skip-and-flag resolution.
57
57
  - **Obsidian export** — `gnosys export` regenerates a full vault with frontmatter, `[[wikilinks]]`, and graph data.
package/dist/cli.js CHANGED
@@ -1788,6 +1788,9 @@ webCmd
1788
1788
  .option("--input <dir>", "Override knowledge directory")
1789
1789
  .option("--output <path>", "Override output file path")
1790
1790
  .option("--no-stop-words", "Disable stop-word filtering")
1791
+ .option("--embeddings <provider>", "Also build gnosys-vectors.json (openai|voyage|local)")
1792
+ .option("--embed-model <id>", "Override the embedding model")
1793
+ .option("--no-expansions", "Skip LLM concept-expansion generation")
1791
1794
  .option("--json", "Output index stats as JSON")
1792
1795
  .action(async (opts) => {
1793
1796
  const { runWebBuildIndexCommand } = await import("./lib/webBuildIndexCommand.js");
@@ -1801,6 +1804,9 @@ webCmd
1801
1804
  .option("--no-llm", "Force structured mode (no LLM)")
1802
1805
  .option("--concurrency <n>", "Parallel processing limit", "3")
1803
1806
  .option("--dry-run", "Show what would change without writing files")
1807
+ .option("--embeddings <provider>", "Also build gnosys-vectors.json (openai|voyage|local)")
1808
+ .option("--embed-model <id>", "Override the embedding model")
1809
+ .option("--no-expansions", "Skip LLM concept-expansion generation")
1804
1810
  .option("--json", "Output results as JSON")
1805
1811
  .action(async (opts) => {
1806
1812
  const { runWebBuildCommand } = await import("./lib/webBuildCommand.js");
@@ -13,6 +13,17 @@ export interface GnosysWebIndex {
13
13
  documentCount: number;
14
14
  documents: DocumentManifest[];
15
15
  invertedIndex: Record<string, IndexEntry[]>;
16
+ expansions?: Record<string, string[]>;
17
+ }
18
+ export interface GnosysWebVectors {
19
+ version: number;
20
+ model: string;
21
+ dims: number;
22
+ quantization: "int8";
23
+ generated: string;
24
+ scale: number;
25
+ offset: number;
26
+ vectors: Record<string, number[]>;
16
27
  }
17
28
  export interface DocumentManifest {
18
29
  id: string;
@@ -36,21 +47,40 @@ export interface SearchOptions {
36
47
  category?: string;
37
48
  tags?: string[];
38
49
  boostRecent?: boolean;
50
+ queryVector?: number[];
51
+ vectors?: GnosysWebVectors;
52
+ expectedModel?: string;
53
+ expandQuery?: boolean;
39
54
  }
40
55
  export interface SearchResult {
41
56
  document: DocumentManifest;
42
57
  score: number;
43
58
  matchedTokens: string[];
59
+ semanticScore?: number;
44
60
  }
45
61
  /**
46
62
  * Load a pre-computed Gnosys web index from a file path or raw JSON string.
47
63
  * Caches the result for repeated calls with the same source.
48
64
  */
49
65
  export declare function loadIndex(pathOrJson: string): GnosysWebIndex;
66
+ /**
67
+ * Load pre-computed Gnosys web vectors from a file path or raw JSON string.
68
+ * Caches the result for repeated calls with the same source.
69
+ */
70
+ export declare function loadVectors(pathOrJson: string): GnosysWebVectors;
50
71
  /**
51
72
  * Clear the cached index (useful for testing).
52
73
  */
53
74
  export declare function clearIndexCache(): void;
75
+ /**
76
+ * Clear the cached vectors file (useful for testing).
77
+ */
78
+ export declare function clearVectorsCache(): void;
79
+ /**
80
+ * Cosine similarity for equal-dimension vectors. Returns 0 for zero-magnitude
81
+ * vectors or dimension mismatches.
82
+ */
83
+ export declare function cosineSimilarity(a: number[], b: number[]): number;
54
84
  /**
55
85
  * Search the pre-computed index and return ranked results.
56
86
  */
@@ -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
  */
@@ -24,6 +24,8 @@ const STOP_WORDS = new Set([
24
24
  "re", "same", "some", "such", "up", "us", "we", "what", "when",
25
25
  "where", "which", "who", "whom", "why", "you", "your",
26
26
  ]);
27
+ const DEFAULT_EXPANSION_CANDIDATES = 100;
28
+ const MIN_EXPANSION_TOKEN_LENGTH = 3;
27
29
  // ─── Tokenization ────────────────────────────────────────────────────────
28
30
  function tokenize(text, minLength) {
29
31
  return text
@@ -182,13 +184,16 @@ export function buildIndexSync(knowledgeDir, options = {}) {
182
184
  for (const token of Object.keys(invertedIndex).sort()) {
183
185
  sortedIndex[token] = invertedIndex[token];
184
186
  }
185
- return {
187
+ const index = {
186
188
  version: 1,
187
189
  generated: new Date().toISOString(),
188
190
  documentCount: documents.length,
189
191
  documents,
190
192
  invertedIndex: sortedIndex,
191
193
  };
194
+ // Default builds intentionally remain v1; only indexes with a non-empty
195
+ // expansion map become v2 so older gnosys/web runtimes can still load them.
196
+ return attachExpansions(index, options.expansions);
192
197
  }
193
198
  /**
194
199
  * Build a search index (async wrapper).
@@ -196,6 +201,127 @@ export function buildIndexSync(knowledgeDir, options = {}) {
196
201
  export async function buildIndex(knowledgeDir, options) {
197
202
  return buildIndexSync(knowledgeDir, options);
198
203
  }
204
+ /**
205
+ * Generate a token expansion map from an existing index using an injected LLM.
206
+ * The provider is resolved by callers so this build module remains easy to test
207
+ * and does not depend on runtime config or provider wiring.
208
+ */
209
+ export async function generateExpansions(index, llmProvider, options = {}) {
210
+ const maxTokens = options.maxTokens ?? DEFAULT_EXPANSION_CANDIDATES;
211
+ if (maxTokens <= 0)
212
+ return {};
213
+ const candidates = selectExpansionCandidates(index, maxTokens);
214
+ if (candidates.length === 0)
215
+ return {};
216
+ const prompt = [
217
+ "Return strict JSON only: an object mapping each provided token to an array of related search tokens.",
218
+ "Use lowercase single-word domain concepts. Do not include explanations, markdown, or tokens not in the input list.",
219
+ "Input tokens:",
220
+ candidates.join(", "),
221
+ ].join("\n");
222
+ let raw;
223
+ try {
224
+ raw = await llmProvider.generate(prompt);
225
+ }
226
+ catch (error) {
227
+ console.warn(`[gnosys/web] concept expansion generation failed: ${formatErrorMessage(error)}`);
228
+ return {};
229
+ }
230
+ let parsed;
231
+ try {
232
+ parsed = JSON.parse(raw.trim());
233
+ }
234
+ catch {
235
+ console.warn("[gnosys/web] concept expansion provider returned invalid JSON; skipping expansions");
236
+ return {};
237
+ }
238
+ if (!isRecord(parsed))
239
+ return {};
240
+ const candidateSet = new Set(candidates);
241
+ const expansions = {};
242
+ for (const [rawKey, rawRelated] of Object.entries(parsed)) {
243
+ const key = tokenize(rawKey, MIN_EXPANSION_TOKEN_LENGTH)[0];
244
+ if (!key || !candidateSet.has(key) || !Array.isArray(rawRelated))
245
+ continue;
246
+ const relatedTokens = normalizeExpansionTokens(rawRelated, key);
247
+ if (relatedTokens.length > 0) {
248
+ expansions[key] = relatedTokens;
249
+ }
250
+ }
251
+ return sortExpansionMap(expansions);
252
+ }
253
+ /**
254
+ * Attach a non-empty concept expansion map to an index and bump it to v2.
255
+ * Empty maps are ignored so default/no-enrichment builds keep emitting v1.
256
+ */
257
+ export function attachExpansions(index, expansions) {
258
+ const normalized = normalizeExpansionMap(expansions);
259
+ if (Object.keys(normalized).length === 0) {
260
+ const { expansions: _ignored, ...withoutExpansions } = index;
261
+ return { ...withoutExpansions, version: 1 };
262
+ }
263
+ return {
264
+ ...index,
265
+ version: 2,
266
+ expansions: normalized,
267
+ };
268
+ }
269
+ function selectExpansionCandidates(index, maxTokens) {
270
+ return Object.entries(index.invertedIndex)
271
+ .filter(([token, entries]) => token.length >= MIN_EXPANSION_TOKEN_LENGTH &&
272
+ !STOP_WORDS.has(token) &&
273
+ Array.isArray(entries) &&
274
+ entries.length > 0)
275
+ .sort(([tokenA, entriesA], [tokenB, entriesB]) => {
276
+ const frequencyDelta = entriesB.length - entriesA.length;
277
+ return frequencyDelta || tokenA.localeCompare(tokenB);
278
+ })
279
+ .slice(0, maxTokens)
280
+ .map(([token]) => token);
281
+ }
282
+ function normalizeExpansionMap(expansions) {
283
+ if (!expansions)
284
+ return {};
285
+ const normalized = {};
286
+ for (const [rawKey, rawRelated] of Object.entries(expansions)) {
287
+ const key = tokenize(rawKey, MIN_EXPANSION_TOKEN_LENGTH)[0];
288
+ if (!key || STOP_WORDS.has(key) || !Array.isArray(rawRelated))
289
+ continue;
290
+ const relatedTokens = normalizeExpansionTokens(rawRelated, key);
291
+ if (relatedTokens.length > 0) {
292
+ normalized[key] = relatedTokens;
293
+ }
294
+ }
295
+ return sortExpansionMap(normalized);
296
+ }
297
+ function normalizeExpansionTokens(values, key) {
298
+ const tokens = [];
299
+ for (const value of values) {
300
+ if (typeof value !== "string")
301
+ continue;
302
+ let relatedTokens = tokenize(value, MIN_EXPANSION_TOKEN_LENGTH);
303
+ relatedTokens = filterStopWords(relatedTokens);
304
+ for (const token of relatedTokens) {
305
+ if (token === key || tokens.includes(token))
306
+ continue;
307
+ tokens.push(token);
308
+ }
309
+ }
310
+ return tokens.sort();
311
+ }
312
+ function sortExpansionMap(expansions) {
313
+ const sorted = {};
314
+ for (const key of Object.keys(expansions).sort()) {
315
+ sorted[key] = expansions[key];
316
+ }
317
+ return sorted;
318
+ }
319
+ function isRecord(value) {
320
+ return typeof value === "object" && value !== null && !Array.isArray(value);
321
+ }
322
+ function formatErrorMessage(error) {
323
+ return error instanceof Error ? error.message : String(error);
324
+ }
199
325
  /**
200
326
  * Write an index to a JSON file.
201
327
  */
@@ -153,6 +153,7 @@ export async function runWebInitCommand(getWebStorePath, opts) {
153
153
  console.log(` ${!sitemapUrl && envVarName ? "4" : envVarName || !sitemapUrl ? "3" : "2"}. Add to package.json: ${CYAN}"postbuild": "npx gnosys web build"${RESET}`);
154
154
  console.log();
155
155
  console.log(`${DIM}Every deploy will re-crawl and rebuild the search index automatically.${RESET}`);
156
+ console.log(`${DIM}Tip: add semantic search with ${CYAN}gnosys web build --embeddings openai${RESET}${DIM} (see docs/web-semantic-search.md).${RESET}`);
156
157
  }
157
158
  }
158
159
  catch (err) {
@@ -52,6 +52,28 @@ export async function runWebStatusCommand(getWebStorePath, opts) {
52
52
  indexInfo = { exists: true, size: stat.size };
53
53
  }
54
54
  }
55
+ const vectorsPath = path.join(resolvedDir, "gnosys-vectors.json");
56
+ let vectorsInfo = { exists: false };
57
+ if (existsSync(vectorsPath)) {
58
+ const vectorsStat = statSync(vectorsPath);
59
+ try {
60
+ const vectorsData = JSON.parse(readFileSync(vectorsPath, "utf-8"));
61
+ const vectorMap = typeof vectorsData.vectors === "object" && vectorsData.vectors !== null
62
+ ? vectorsData.vectors
63
+ : {};
64
+ vectorsInfo = {
65
+ exists: true,
66
+ model: typeof vectorsData.model === "string" ? vectorsData.model : undefined,
67
+ dims: typeof vectorsData.dims === "number" ? vectorsData.dims : undefined,
68
+ count: Object.keys(vectorMap).length,
69
+ size: vectorsStat.size,
70
+ generated: typeof vectorsData.generated === "string" ? vectorsData.generated : undefined,
71
+ };
72
+ }
73
+ catch {
74
+ vectorsInfo = { exists: true, size: vectorsStat.size };
75
+ }
76
+ }
55
77
  if (opts.json) {
56
78
  console.log(JSON.stringify({
57
79
  ok: true,
@@ -59,6 +81,7 @@ export async function runWebStatusCommand(getWebStorePath, opts) {
59
81
  totalFiles,
60
82
  categoryCounts,
61
83
  index: indexInfo,
84
+ vectors: vectorsInfo,
62
85
  }, null, 2));
63
86
  }
64
87
  else {
@@ -80,6 +103,17 @@ export async function runWebStatusCommand(getWebStorePath, opts) {
80
103
  else {
81
104
  console.log(` Index: not built (run 'gnosys web build-index')`);
82
105
  }
106
+ if (vectorsInfo.exists) {
107
+ if (vectorsInfo.model && typeof vectorsInfo.dims === "number" && typeof vectorsInfo.count === "number") {
108
+ console.log(` Vectors: ${vectorsInfo.count} docs, ${vectorsInfo.model} (${vectorsInfo.dims}d), ${((vectorsInfo.size || 0) / 1024).toFixed(1)}KB`);
109
+ }
110
+ else {
111
+ console.log(` Vectors: present, ${((vectorsInfo.size || 0) / 1024).toFixed(1)}KB (unreadable)`);
112
+ }
113
+ }
114
+ else {
115
+ console.log(` Vectors: not built (run 'gnosys web build-index --embeddings <provider>')`);
116
+ }
83
117
  }
84
118
  }
85
119
  catch (err) {
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Gnosys Web Vectors - build-time semantic vector generator.
3
+ *
4
+ * This module is intentionally build-time only. The zero-dependency
5
+ * `gnosys/web` runtime must not import it because the local provider reaches
6
+ * into the optional embeddings stack.
7
+ */
8
+ export type VectorProvider = "openai" | "voyage" | "local";
9
+ export interface WebVectorsFile {
10
+ version: 1;
11
+ model: string;
12
+ dims: number;
13
+ quantization: "int8";
14
+ generated: string;
15
+ scale: number;
16
+ offset: number;
17
+ vectors: Record<string, number[]>;
18
+ }
19
+ export interface BuildVectorsOptions {
20
+ provider: VectorProvider;
21
+ model?: string;
22
+ apiKey?: string;
23
+ storePath?: string;
24
+ }
25
+ /**
26
+ * Symmetric global int8 quantization. One scale/offset pair is recorded for
27
+ * the whole file so the runtime can dequantize without per-vector metadata.
28
+ */
29
+ export declare function quantizeVector(vec: number[], scale: number): number[];
30
+ export declare function dequantizeVector(qvec: number[], scale: number): number[];
31
+ export declare function buildVectors(knowledgeDir: string, options: BuildVectorsOptions): Promise<WebVectorsFile>;
32
+ export declare function writeVectorsFile(knowledgeDir: string, vectorsFile: WebVectorsFile): Promise<string>;
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Gnosys Web Vectors - build-time semantic vector generator.
3
+ *
4
+ * This module is intentionally build-time only. The zero-dependency
5
+ * `gnosys/web` runtime must not import it because the local provider reaches
6
+ * into the optional embeddings stack.
7
+ */
8
+ import fs from "fs/promises";
9
+ import path from "path";
10
+ import matter from "gray-matter";
11
+ import { getOpenAIApiKey, getOpenAIBaseUrl, loadConfig } from "./config.js";
12
+ import { GnosysEmbeddings } from "./embeddings.js";
13
+ const VECTOR_FILE_NAME = "gnosys-vectors.json";
14
+ const MAX_BODY_TOKENS = 512;
15
+ const API_BATCH_SIZE = 96;
16
+ const DEFAULT_OPENAI_MODEL = "text-embedding-3-small";
17
+ const DEFAULT_VOYAGE_MODEL = "voyage-3-lite";
18
+ const DEFAULT_LOCAL_MODEL = "Xenova/all-MiniLM-L6-v2";
19
+ /**
20
+ * Symmetric global int8 quantization. One scale/offset pair is recorded for
21
+ * the whole file so the runtime can dequantize without per-vector metadata.
22
+ */
23
+ export function quantizeVector(vec, scale) {
24
+ if (!Number.isFinite(scale) || scale <= 0) {
25
+ throw new Error("Cannot quantize vector with a non-positive scale");
26
+ }
27
+ return vec.map((value) => clampInt8(Math.round(value / scale)));
28
+ }
29
+ export function dequantizeVector(qvec, scale) {
30
+ if (!Number.isFinite(scale) || scale <= 0) {
31
+ throw new Error("Cannot dequantize vector with a non-positive scale");
32
+ }
33
+ return qvec.map((value) => value * scale);
34
+ }
35
+ export async function buildVectors(knowledgeDir, options) {
36
+ const docs = await readKnowledgeDocs(knowledgeDir);
37
+ const model = modelForProvider(options);
38
+ if (docs.length === 0) {
39
+ return {
40
+ version: 1,
41
+ model,
42
+ dims: 0,
43
+ quantization: "int8",
44
+ generated: new Date().toISOString(),
45
+ scale: 1,
46
+ offset: 0,
47
+ vectors: {},
48
+ };
49
+ }
50
+ const texts = docs.map((doc) => embeddingText(doc));
51
+ const embeddings = await embedTexts(texts, options, model);
52
+ const dims = validateEmbeddings(embeddings, docs.length);
53
+ const scale = computeGlobalScale(embeddings);
54
+ const vectors = {};
55
+ for (let i = 0; i < docs.length; i++) {
56
+ const doc = docs[i];
57
+ if (vectors[doc.id]) {
58
+ throw new Error(`Duplicate web knowledge document id: ${doc.id}`);
59
+ }
60
+ vectors[doc.id] = quantizeVector(embeddings[i], scale);
61
+ }
62
+ return {
63
+ version: 1,
64
+ model,
65
+ dims,
66
+ quantization: "int8",
67
+ generated: new Date().toISOString(),
68
+ scale,
69
+ offset: 0,
70
+ vectors,
71
+ };
72
+ }
73
+ export async function writeVectorsFile(knowledgeDir, vectorsFile) {
74
+ const outputPath = path.join(knowledgeDir, VECTOR_FILE_NAME);
75
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
76
+ await fs.writeFile(outputPath, JSON.stringify(vectorsFile, null, 2), "utf-8");
77
+ return outputPath;
78
+ }
79
+ async function readKnowledgeDocs(knowledgeDir) {
80
+ const resolvedDir = path.resolve(knowledgeDir);
81
+ const files = await findMarkdownFiles(resolvedDir);
82
+ const docs = [];
83
+ for (const filePath of files) {
84
+ let raw;
85
+ try {
86
+ raw = await fs.readFile(filePath, "utf-8");
87
+ }
88
+ catch {
89
+ continue;
90
+ }
91
+ let parsed;
92
+ try {
93
+ parsed = matter(raw);
94
+ }
95
+ catch {
96
+ continue;
97
+ }
98
+ const fm = parsed.data;
99
+ const status = typeof fm.status === "string" ? fm.status : "active";
100
+ if (status === "archived")
101
+ continue;
102
+ docs.push({
103
+ id: typeof fm.id === "string" ? fm.id : path.basename(filePath, ".md"),
104
+ title: typeof fm.title === "string" ? fm.title : path.basename(filePath, ".md"),
105
+ relevance: typeof fm.relevance === "string" ? fm.relevance : "",
106
+ body: parsed.content.trim(),
107
+ });
108
+ }
109
+ return docs;
110
+ }
111
+ async function findMarkdownFiles(dir) {
112
+ const results = [];
113
+ async function walk(current) {
114
+ let entries;
115
+ try {
116
+ entries = await fs.readdir(current, { withFileTypes: true });
117
+ }
118
+ catch {
119
+ return;
120
+ }
121
+ for (const entry of entries) {
122
+ const fullPath = path.join(current, entry.name);
123
+ if (entry.isDirectory()) {
124
+ await walk(fullPath);
125
+ }
126
+ else if (entry.isFile() && entry.name.endsWith(".md")) {
127
+ results.push(fullPath);
128
+ }
129
+ }
130
+ }
131
+ await walk(dir);
132
+ return results.sort();
133
+ }
134
+ function embeddingText(doc) {
135
+ const cappedBody = doc.body.split(/\s+/).filter(Boolean).slice(0, MAX_BODY_TOKENS).join(" ");
136
+ return `${doc.title}\n${doc.relevance}\n${cappedBody}`;
137
+ }
138
+ function modelForProvider(options) {
139
+ if (options.model)
140
+ return options.model;
141
+ switch (options.provider) {
142
+ case "openai":
143
+ return DEFAULT_OPENAI_MODEL;
144
+ case "voyage":
145
+ return DEFAULT_VOYAGE_MODEL;
146
+ case "local":
147
+ return DEFAULT_LOCAL_MODEL;
148
+ }
149
+ }
150
+ async function embedTexts(texts, options, model) {
151
+ switch (options.provider) {
152
+ case "openai":
153
+ return embedOpenAI(texts, options, model);
154
+ case "voyage":
155
+ return embedVoyage(texts, options, model);
156
+ case "local":
157
+ return embedLocal(texts, options);
158
+ }
159
+ }
160
+ async function embedOpenAI(texts, options, model) {
161
+ const config = await loadConfig(options.storePath ?? process.cwd());
162
+ const apiKey = options.apiKey || getOpenAIApiKey(config) || process.env.OPENAI_API_KEY;
163
+ if (!apiKey) {
164
+ throw new Error("OpenAI embeddings require an API key. Set OPENAI_API_KEY or configure an OpenAI key in Gnosys.");
165
+ }
166
+ const endpoint = `${getOpenAIBaseUrl(config).replace(/\/+$/, "")}/embeddings`;
167
+ return embedApiBatches("OpenAI", endpoint, apiKey, texts, model);
168
+ }
169
+ async function embedVoyage(texts, options, model) {
170
+ // Voyage is not part of the Gnosys config schema yet; for now it is env-only.
171
+ const apiKey = options.apiKey || process.env.VOYAGE_API_KEY;
172
+ if (!apiKey) {
173
+ throw new Error("Voyage embeddings require an API key. Set VOYAGE_API_KEY.");
174
+ }
175
+ return embedApiBatches("Voyage", "https://api.voyageai.com/v1/embeddings", apiKey, texts, model);
176
+ }
177
+ async function embedApiBatches(providerName, endpoint, apiKey, texts, model) {
178
+ const embeddings = [];
179
+ for (let i = 0; i < texts.length; i += API_BATCH_SIZE) {
180
+ const input = texts.slice(i, i + API_BATCH_SIZE);
181
+ const response = await fetch(endpoint, {
182
+ method: "POST",
183
+ headers: {
184
+ Authorization: `Bearer ${apiKey}`,
185
+ "Content-Type": "application/json",
186
+ },
187
+ body: JSON.stringify({ model, input }),
188
+ });
189
+ const bodyText = await response.text();
190
+ if (!response.ok) {
191
+ const detail = bodyText ? `: ${bodyText.slice(0, 500)}` : "";
192
+ throw new Error(`${providerName} embedding request failed (${response.status} ${response.statusText})${detail}`);
193
+ }
194
+ embeddings.push(...parseEmbeddingResponse(providerName, bodyText));
195
+ }
196
+ return embeddings;
197
+ }
198
+ async function embedLocal(texts, options) {
199
+ const embeddings = new GnosysEmbeddings(options.storePath ?? path.resolve(process.cwd(), ".gnosys"));
200
+ const vectors = await embeddings.embedBatch(texts);
201
+ return vectors.map((vec) => Array.from(vec));
202
+ }
203
+ function parseEmbeddingResponse(providerName, bodyText) {
204
+ let payload;
205
+ try {
206
+ payload = JSON.parse(bodyText);
207
+ }
208
+ catch {
209
+ throw new Error(`${providerName} embedding response was not valid JSON`);
210
+ }
211
+ if (!isEmbeddingPayload(payload)) {
212
+ throw new Error(`${providerName} embedding response did not include data[].embedding arrays`);
213
+ }
214
+ return payload.data.map((entry) => entry.embedding);
215
+ }
216
+ function isEmbeddingPayload(payload) {
217
+ if (typeof payload !== "object" || payload === null)
218
+ return false;
219
+ const data = payload.data;
220
+ if (!Array.isArray(data))
221
+ return false;
222
+ return data.every((entry) => {
223
+ if (typeof entry !== "object" || entry === null)
224
+ return false;
225
+ const embedding = entry.embedding;
226
+ return Array.isArray(embedding) && embedding.every((value) => typeof value === "number" && Number.isFinite(value));
227
+ });
228
+ }
229
+ function validateEmbeddings(embeddings, expectedCount) {
230
+ if (embeddings.length !== expectedCount) {
231
+ throw new Error(`Embedding provider returned ${embeddings.length} vectors for ${expectedCount} documents`);
232
+ }
233
+ const dims = embeddings[0]?.length ?? 0;
234
+ if (dims === 0) {
235
+ throw new Error("Embedding provider returned empty vectors");
236
+ }
237
+ for (const vec of embeddings) {
238
+ if (vec.length !== dims) {
239
+ throw new Error("Embedding provider returned vectors with inconsistent dimensions");
240
+ }
241
+ }
242
+ return dims;
243
+ }
244
+ function computeGlobalScale(embeddings) {
245
+ let absMax = 0;
246
+ for (const vec of embeddings) {
247
+ for (const value of vec) {
248
+ absMax = Math.max(absMax, Math.abs(value));
249
+ }
250
+ }
251
+ return absMax > 0 ? absMax / 127 : 1;
252
+ }
253
+ function clampInt8(value) {
254
+ return Math.max(-128, Math.min(127, value));
255
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gnosys",
3
- "version": "5.16.0",
3
+ "version": "5.17.0",
4
4
  "description": "Gnosys — Persistent Memory for AI Agents. Sandbox-first runtime, central SQLite brain, federated search, Dream Mode, Web Knowledge Base, Obsidian export.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",