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.
- package/README.md +1 -1
- package/dist/cli.js +6 -0
- package/dist/index.js +26 -3
- package/dist/lib/cleanup.js +2 -17
- package/dist/lib/paths.d.ts +1 -0
- package/dist/lib/paths.js +29 -0
- package/dist/lib/resolver.js +11 -1
- package/dist/lib/rulesGen.js +2 -2
- package/dist/lib/setupSyncProjectsCommand.js +36 -12
- package/dist/lib/staticSearch.d.ts +30 -0
- package/dist/lib/staticSearch.js +235 -6
- package/dist/lib/webBuildCommand.d.ts +3 -0
- package/dist/lib/webBuildCommand.js +61 -5
- package/dist/lib/webBuildIndexCommand.d.ts +3 -0
- package/dist/lib/webBuildIndexCommand.js +54 -3
- package/dist/lib/webIndex.d.ts +17 -0
- package/dist/lib/webIndex.js +127 -1
- package/dist/lib/webInitCommand.js +1 -0
- package/dist/lib/webStatusCommand.js +34 -0
- package/dist/lib/webVectors.d.ts +32 -0
- package/dist/lib/webVectors.js +255 -0
- package/package.json +1 -1
package/dist/lib/webIndex.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|