@gmickel/gno 0.22.5 → 0.24.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.
@@ -0,0 +1,584 @@
1
+ /**
2
+ * GNO SDK client.
3
+ *
4
+ * @module src/sdk/client
5
+ */
6
+
7
+ import { mkdir } from "node:fs/promises";
8
+ import { dirname } from "node:path";
9
+
10
+ import type { Config } from "../config/types";
11
+ import type { DownloadPolicy } from "../llm/policy";
12
+ import type { EmbeddingPort, GenerationPort, RerankPort } from "../llm/types";
13
+ import type { AskResult, SearchResults } from "../pipeline/types";
14
+ import type { IndexStatus, StoreResult } from "../store/types";
15
+ import type { VectorIndexPort } from "../store/vector";
16
+ import type {
17
+ GnoAskOptions,
18
+ GnoClient,
19
+ GnoClientInitOptions,
20
+ GnoEmbedOptions,
21
+ GnoEmbedResult,
22
+ GnoGetOptions,
23
+ GnoIndexOptions,
24
+ GnoIndexResult,
25
+ GnoListOptions,
26
+ GnoMultiGetOptions,
27
+ GnoQueryOptions,
28
+ GnoUpdateOptions,
29
+ GnoVectorSearchOptions,
30
+ } from "./types";
31
+
32
+ import { getIndexDbPath } from "../app/constants";
33
+ import { ConfigSchema, loadConfig } from "../config";
34
+ import { normalizeStructuredQueryInput } from "../core/structured-query";
35
+ import { defaultSyncService, type SyncResult } from "../ingestion";
36
+ import { LlmAdapter } from "../llm/nodeLlamaCpp/adapter";
37
+ import { resolveDownloadPolicy } from "../llm/policy";
38
+ import {
39
+ generateGroundedAnswer,
40
+ processAnswerResult,
41
+ } from "../pipeline/answer";
42
+ import { formatQueryForEmbedding } from "../pipeline/contextual";
43
+ import { searchHybrid } from "../pipeline/hybrid";
44
+ import { searchBm25 } from "../pipeline/search";
45
+ import { searchVectorWithEmbedding } from "../pipeline/vsearch";
46
+ import { SqliteAdapter } from "../store/sqlite/adapter";
47
+ import { createVectorIndexPort } from "../store/vector";
48
+ import {
49
+ getDocumentByRef,
50
+ listDocuments,
51
+ multiGetDocuments,
52
+ } from "./documents";
53
+ import { runEmbed } from "./embed";
54
+ import { sdkError } from "./errors";
55
+
56
+ interface OpenedClientState {
57
+ config: Config;
58
+ configPath: string | null;
59
+ configSource: "file" | "inline";
60
+ dbPath: string;
61
+ store: SqliteAdapter;
62
+ llm: LlmAdapter;
63
+ downloadPolicy: DownloadPolicy;
64
+ }
65
+
66
+ interface RuntimePorts {
67
+ embedPort: EmbeddingPort | null;
68
+ genPort: GenerationPort | null;
69
+ rerankPort: RerankPort | null;
70
+ vectorIndex: VectorIndexPort | null;
71
+ }
72
+
73
+ function unwrapStore<T>(
74
+ result: StoreResult<T>,
75
+ code: "STORE" | "RUNTIME" = "STORE"
76
+ ): T {
77
+ if (!result.ok) {
78
+ throw sdkError(code, result.error.message, { cause: result.error.cause });
79
+ }
80
+ return result.value;
81
+ }
82
+
83
+ async function resolveClientState(
84
+ options: GnoClientInitOptions = {}
85
+ ): Promise<OpenedClientState> {
86
+ if (options.config && options.configPath) {
87
+ throw sdkError("VALIDATION", "Pass either config or configPath, not both");
88
+ }
89
+
90
+ let config: Config;
91
+ let configPath: string | null;
92
+ let configSource: "file" | "inline";
93
+
94
+ if (options.config) {
95
+ const parsed = ConfigSchema.safeParse(options.config);
96
+ if (!parsed.success) {
97
+ throw sdkError(
98
+ "CONFIG",
99
+ parsed.error.issues[0]?.message ?? "Invalid config"
100
+ );
101
+ }
102
+ config = parsed.data;
103
+ configPath = null;
104
+ configSource = "inline";
105
+ } else {
106
+ const loaded = await loadConfig(options.configPath);
107
+ if (!loaded.ok) {
108
+ throw sdkError("CONFIG", loaded.error.message);
109
+ }
110
+ config = loaded.value;
111
+ configPath = options.configPath ?? null;
112
+ configSource = "file";
113
+ }
114
+
115
+ const dbPath = options.dbPath ?? getIndexDbPath(options.indexName);
116
+ await mkdir(dirname(dbPath), { recursive: true });
117
+
118
+ const store = new SqliteAdapter();
119
+ store.setConfigPath(configPath ?? "<inline-config>");
120
+ unwrapStore(await store.open(dbPath, config.ftsTokenizer));
121
+ unwrapStore(await store.syncCollections(config.collections));
122
+ unwrapStore(await store.syncContexts(config.contexts ?? []));
123
+
124
+ return {
125
+ config,
126
+ configPath,
127
+ configSource,
128
+ dbPath,
129
+ store,
130
+ llm: new LlmAdapter(config, options.cacheDir),
131
+ downloadPolicy:
132
+ options.downloadPolicy ?? resolveDownloadPolicy(process.env, {}),
133
+ };
134
+ }
135
+
136
+ class GnoClientImpl implements GnoClient {
137
+ readonly config: Config;
138
+ readonly dbPath: string;
139
+ readonly configPath: string | null;
140
+ readonly configSource: "file" | "inline";
141
+
142
+ private readonly store: SqliteAdapter;
143
+ private readonly llm: LlmAdapter;
144
+ private readonly downloadPolicy: DownloadPolicy;
145
+ private closed = false;
146
+
147
+ constructor(state: OpenedClientState) {
148
+ this.config = state.config;
149
+ this.dbPath = state.dbPath;
150
+ this.configPath = state.configPath;
151
+ this.configSource = state.configSource;
152
+ this.store = state.store;
153
+ this.llm = state.llm;
154
+ this.downloadPolicy = state.downloadPolicy;
155
+ }
156
+
157
+ isOpen(): boolean {
158
+ return !this.closed && this.store.isOpen();
159
+ }
160
+
161
+ private assertOpen(): void {
162
+ if (!this.isOpen()) {
163
+ throw sdkError("RUNTIME", "GNO client is closed");
164
+ }
165
+ }
166
+
167
+ private getCollections(collection?: string) {
168
+ if (!collection) {
169
+ return this.config.collections;
170
+ }
171
+ const filtered = this.config.collections.filter(
172
+ (c) => c.name === collection
173
+ );
174
+ if (filtered.length === 0) {
175
+ throw sdkError("VALIDATION", `Collection not found: ${collection}`);
176
+ }
177
+ return filtered;
178
+ }
179
+
180
+ private async createRuntimePorts(options: {
181
+ embed?: boolean;
182
+ gen?: boolean;
183
+ rerank?: boolean;
184
+ requiredEmbed?: boolean;
185
+ requiredGen?: boolean;
186
+ requiredRerank?: boolean;
187
+ embedModel?: string;
188
+ genModel?: string;
189
+ rerankModel?: string;
190
+ }): Promise<RuntimePorts> {
191
+ this.assertOpen();
192
+
193
+ let embedPort: EmbeddingPort | null = null;
194
+ let genPort: GenerationPort | null = null;
195
+ let rerankPort: RerankPort | null = null;
196
+ let vectorIndex: VectorIndexPort | null = null;
197
+
198
+ if (options.embed) {
199
+ const embedResult = await this.llm.createEmbeddingPort(
200
+ options.embedModel,
201
+ {
202
+ policy: this.downloadPolicy,
203
+ }
204
+ );
205
+ if (embedResult.ok) {
206
+ embedPort = embedResult.value;
207
+ const initResult = await embedPort.init();
208
+ if (initResult.ok) {
209
+ const vectorResult = await createVectorIndexPort(
210
+ this.store.getRawDb(),
211
+ {
212
+ model: embedPort.modelUri,
213
+ dimensions: embedPort.dimensions(),
214
+ }
215
+ );
216
+ if (vectorResult.ok) {
217
+ vectorIndex = vectorResult.value;
218
+ } else if (options.requiredEmbed) {
219
+ await embedPort.dispose();
220
+ throw sdkError("STORE", vectorResult.error.message, {
221
+ cause: vectorResult.error.cause,
222
+ });
223
+ }
224
+ } else if (options.requiredEmbed) {
225
+ await embedPort.dispose();
226
+ throw sdkError("MODEL", initResult.error.message, {
227
+ cause: initResult.error.cause,
228
+ });
229
+ }
230
+ } else if (options.requiredEmbed) {
231
+ throw sdkError("MODEL", embedResult.error.message, {
232
+ cause: embedResult.error.cause,
233
+ });
234
+ }
235
+ }
236
+
237
+ if (options.gen) {
238
+ const genResult = await this.llm.createGenerationPort(options.genModel, {
239
+ policy: this.downloadPolicy,
240
+ });
241
+ if (genResult.ok) {
242
+ genPort = genResult.value;
243
+ } else if (options.requiredGen) {
244
+ if (embedPort) {
245
+ await embedPort.dispose();
246
+ }
247
+ throw sdkError("MODEL", genResult.error.message, {
248
+ cause: genResult.error.cause,
249
+ });
250
+ }
251
+ }
252
+
253
+ if (options.rerank) {
254
+ const rerankResult = await this.llm.createRerankPort(
255
+ options.rerankModel,
256
+ {
257
+ policy: this.downloadPolicy,
258
+ }
259
+ );
260
+ if (rerankResult.ok) {
261
+ rerankPort = rerankResult.value;
262
+ } else if (options.requiredRerank) {
263
+ if (embedPort) {
264
+ await embedPort.dispose();
265
+ }
266
+ if (genPort) {
267
+ await genPort.dispose();
268
+ }
269
+ throw sdkError("MODEL", rerankResult.error.message, {
270
+ cause: rerankResult.error.cause,
271
+ });
272
+ }
273
+ }
274
+
275
+ return { embedPort, genPort, rerankPort, vectorIndex };
276
+ }
277
+
278
+ private async disposeRuntimePorts(ports: RuntimePorts): Promise<void> {
279
+ if (ports.embedPort) {
280
+ await ports.embedPort.dispose();
281
+ }
282
+ if (ports.genPort) {
283
+ await ports.genPort.dispose();
284
+ }
285
+ if (ports.rerankPort) {
286
+ await ports.rerankPort.dispose();
287
+ }
288
+ }
289
+
290
+ async search(
291
+ query: string,
292
+ options: import("../pipeline/types").SearchOptions = {}
293
+ ): Promise<SearchResults> {
294
+ this.assertOpen();
295
+ return unwrapStore(await searchBm25(this.store, query, options));
296
+ }
297
+
298
+ async vsearch(
299
+ query: string,
300
+ options: GnoVectorSearchOptions = {}
301
+ ): Promise<SearchResults> {
302
+ this.assertOpen();
303
+
304
+ const ports = await this.createRuntimePorts({
305
+ embed: true,
306
+ requiredEmbed: true,
307
+ embedModel: options.model,
308
+ });
309
+
310
+ try {
311
+ if (!ports.embedPort || !ports.vectorIndex) {
312
+ throw sdkError(
313
+ "MODEL",
314
+ "Vector search requires an embedding model and vector index"
315
+ );
316
+ }
317
+
318
+ const queryEmbedResult = await ports.embedPort.embed(
319
+ formatQueryForEmbedding(query)
320
+ );
321
+ if (!queryEmbedResult.ok) {
322
+ throw sdkError("MODEL", queryEmbedResult.error.message, {
323
+ cause: queryEmbedResult.error.cause,
324
+ });
325
+ }
326
+
327
+ return unwrapStore(
328
+ await searchVectorWithEmbedding(
329
+ {
330
+ store: this.store,
331
+ vectorIndex: ports.vectorIndex,
332
+ embedPort: ports.embedPort,
333
+ config: this.config,
334
+ },
335
+ query,
336
+ new Float32Array(queryEmbedResult.value),
337
+ options
338
+ )
339
+ );
340
+ } finally {
341
+ await this.disposeRuntimePorts(ports);
342
+ }
343
+ }
344
+
345
+ async query(
346
+ query: string,
347
+ options: GnoQueryOptions = {}
348
+ ): Promise<SearchResults> {
349
+ this.assertOpen();
350
+
351
+ const normalizedInput = normalizeStructuredQueryInput(
352
+ query,
353
+ options.queryModes ?? []
354
+ );
355
+ if (!normalizedInput.ok) {
356
+ throw sdkError("VALIDATION", normalizedInput.error.message);
357
+ }
358
+ query = normalizedInput.value.query;
359
+ options = {
360
+ ...options,
361
+ queryModes:
362
+ normalizedInput.value.queryModes.length > 0
363
+ ? normalizedInput.value.queryModes
364
+ : undefined,
365
+ };
366
+
367
+ const ports = await this.createRuntimePorts({
368
+ embed: true,
369
+ gen: !options.noExpand && !options.queryModes?.length,
370
+ rerank: !options.noRerank,
371
+ embedModel: options.embedModel,
372
+ genModel: options.genModel,
373
+ rerankModel: options.rerankModel,
374
+ });
375
+
376
+ try {
377
+ return unwrapStore(
378
+ await searchHybrid(
379
+ {
380
+ store: this.store,
381
+ config: this.config,
382
+ vectorIndex: ports.vectorIndex,
383
+ embedPort: ports.embedPort,
384
+ genPort: ports.genPort,
385
+ rerankPort: ports.rerankPort,
386
+ },
387
+ query,
388
+ options
389
+ )
390
+ );
391
+ } finally {
392
+ await this.disposeRuntimePorts(ports);
393
+ }
394
+ }
395
+
396
+ async ask(query: string, options: GnoAskOptions = {}): Promise<AskResult> {
397
+ this.assertOpen();
398
+
399
+ const normalizedInput = normalizeStructuredQueryInput(
400
+ query,
401
+ options.queryModes ?? []
402
+ );
403
+ if (!normalizedInput.ok) {
404
+ throw sdkError("VALIDATION", normalizedInput.error.message);
405
+ }
406
+ query = normalizedInput.value.query;
407
+ options = {
408
+ ...options,
409
+ queryModes:
410
+ normalizedInput.value.queryModes.length > 0
411
+ ? normalizedInput.value.queryModes
412
+ : undefined,
413
+ };
414
+
415
+ const answerRequested = Boolean(options.answer && !options.noAnswer);
416
+ const needsExpansionGen = !options.noExpand && !options.queryModes?.length;
417
+ const ports = await this.createRuntimePorts({
418
+ embed: true,
419
+ gen: needsExpansionGen || answerRequested,
420
+ rerank: !options.noRerank,
421
+ genModel: options.genModel,
422
+ embedModel: options.embedModel,
423
+ rerankModel: options.rerankModel,
424
+ });
425
+
426
+ try {
427
+ if (answerRequested && !ports.genPort) {
428
+ throw sdkError(
429
+ "MODEL",
430
+ "Answer generation requested but no generation model is available"
431
+ );
432
+ }
433
+
434
+ const searchResult = unwrapStore(
435
+ await searchHybrid(
436
+ {
437
+ store: this.store,
438
+ config: this.config,
439
+ vectorIndex: ports.vectorIndex,
440
+ embedPort: ports.embedPort,
441
+ genPort: ports.genPort,
442
+ rerankPort: ports.rerankPort,
443
+ },
444
+ query,
445
+ {
446
+ limit: options.limit,
447
+ collection: options.collection,
448
+ lang: options.lang,
449
+ intent: options.intent,
450
+ since: options.since,
451
+ until: options.until,
452
+ categories: options.categories,
453
+ author: options.author,
454
+ tagsAll: options.tagsAll,
455
+ tagsAny: options.tagsAny,
456
+ exclude: options.exclude,
457
+ queryModes: options.queryModes,
458
+ noExpand: options.noExpand,
459
+ noRerank: options.noRerank,
460
+ candidateLimit: options.candidateLimit,
461
+ queryLanguageHint: options.queryLanguageHint,
462
+ }
463
+ )
464
+ );
465
+
466
+ let answer: string | undefined;
467
+ let citations: AskResult["citations"];
468
+ let answerContext: AskResult["meta"]["answerContext"];
469
+ let answerGenerated = false;
470
+
471
+ if (answerRequested && ports.genPort && searchResult.results.length > 0) {
472
+ const rawAnswer = await generateGroundedAnswer(
473
+ { genPort: ports.genPort, store: this.store },
474
+ query,
475
+ searchResult.results,
476
+ options.maxAnswerTokens ?? 512
477
+ );
478
+ if (!rawAnswer) {
479
+ throw sdkError("MODEL", "Answer generation failed");
480
+ }
481
+ const processed = processAnswerResult(rawAnswer);
482
+ answer = processed.answer;
483
+ citations = processed.citations;
484
+ answerContext = processed.answerContext;
485
+ answerGenerated = true;
486
+ }
487
+
488
+ return {
489
+ query,
490
+ mode: searchResult.meta.vectorsUsed ? "hybrid" : "bm25_only",
491
+ queryLanguage: searchResult.meta.queryLanguage ?? "und",
492
+ answer,
493
+ citations,
494
+ results: searchResult.results,
495
+ meta: {
496
+ expanded: searchResult.meta.expanded ?? false,
497
+ reranked: searchResult.meta.reranked ?? false,
498
+ vectorsUsed: searchResult.meta.vectorsUsed ?? false,
499
+ intent: searchResult.meta.intent,
500
+ candidateLimit: searchResult.meta.candidateLimit,
501
+ exclude: searchResult.meta.exclude,
502
+ queryModes: searchResult.meta.queryModes,
503
+ answerGenerated,
504
+ totalResults: searchResult.results.length,
505
+ answerContext,
506
+ },
507
+ };
508
+ } finally {
509
+ await this.disposeRuntimePorts(ports);
510
+ }
511
+ }
512
+
513
+ async get(ref: string, options: GnoGetOptions = {}) {
514
+ this.assertOpen();
515
+ return getDocumentByRef(this.store, this.config, ref, options);
516
+ }
517
+
518
+ async multiGet(refs: string[], options: GnoMultiGetOptions = {}) {
519
+ this.assertOpen();
520
+ return multiGetDocuments(this.store, this.config, refs, options);
521
+ }
522
+
523
+ async list(options: GnoListOptions = {}) {
524
+ this.assertOpen();
525
+ return listDocuments(this.store, options);
526
+ }
527
+
528
+ async status(): Promise<IndexStatus> {
529
+ this.assertOpen();
530
+ return unwrapStore(await this.store.getStatus());
531
+ }
532
+
533
+ async update(options: GnoUpdateOptions = {}): Promise<SyncResult> {
534
+ this.assertOpen();
535
+ const collections = this.getCollections(options.collection);
536
+ return defaultSyncService.syncAll(collections, this.store, {
537
+ gitPull: options.gitPull,
538
+ runUpdateCmd: true,
539
+ });
540
+ }
541
+
542
+ async embed(options: GnoEmbedOptions = {}): Promise<GnoEmbedResult> {
543
+ this.assertOpen();
544
+ return runEmbed(
545
+ {
546
+ config: this.config,
547
+ store: this.store,
548
+ llm: this.llm,
549
+ downloadPolicy: this.downloadPolicy,
550
+ },
551
+ options
552
+ );
553
+ }
554
+
555
+ async index(options: GnoIndexOptions = {}): Promise<GnoIndexResult> {
556
+ const syncResult = await this.update(options);
557
+ if (options.noEmbed) {
558
+ return { syncResult, embedSkipped: true };
559
+ }
560
+
561
+ const embedResult = await this.embed(options);
562
+ return {
563
+ syncResult,
564
+ embedSkipped: false,
565
+ embedResult,
566
+ };
567
+ }
568
+
569
+ async close(): Promise<void> {
570
+ if (this.closed) {
571
+ return;
572
+ }
573
+ this.closed = true;
574
+ await this.store.close();
575
+ await this.llm.dispose();
576
+ }
577
+ }
578
+
579
+ export async function createGnoClient(
580
+ options: GnoClientInitOptions = {}
581
+ ): Promise<GnoClient> {
582
+ const state = await resolveClientState(options);
583
+ return new GnoClientImpl(state);
584
+ }