@mybase-dev/sdk 1.0.0 → 1.0.1

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/src/vector.ts ADDED
@@ -0,0 +1,356 @@
1
+ // Vector and full-text search through mybase: named indexes of documents, searched by
2
+ // meaning (pgvector), by keywords (Postgres full-text search), or both at once (hybrid).
3
+ // Documents live in your project's own database (schema `mybase_vector`), so SQL can join them.
4
+ //
5
+ // const vector = createVectorClient({ apiUrl, project: "shop", apiKey: process.env.MYBASE_API_KEY! });
6
+ // const docs = vector.index<{ lang: string }>("docs");
7
+ // await docs.upsert([{ id: "faq-1", title: "Reset your password", text: "…", metadata: { lang: "en" } }]);
8
+ // const { matches } = await docs.query("forgot my password", { topK: 5, filter: { lang: "en" } });
9
+ //
10
+ // Send your own vectors, or set an embedding provider for the project (any OpenAI-compatible
11
+ // `/embeddings` endpoint, e.g. OpenAI or Ollama) and mybase embeds `text` for you.
12
+ import { MybaseApiError } from "./cron";
13
+ import type { ToolInputSchema } from "./search";
14
+
15
+ export { MybaseApiError };
16
+ export type { ToolInputSchema };
17
+
18
+ /** Index names: they become table names, so lowercase letters, digits, and `_`. */
19
+ export const VECTOR_INDEX_PATTERN = /^[a-z][a-z0-9_]{0,47}$/;
20
+ export const VECTOR_INDEX_RULES = "Use 1–48 lowercase letters, digits, or `_`, starting with a letter.";
21
+
22
+ /** How vectors are compared. `cosine` suits most embedding models. */
23
+ export const VECTOR_METRICS = ["cosine", "l2", "ip"] as const;
24
+ export type VectorMetric = (typeof VECTOR_METRICS)[number];
25
+
26
+ /** Postgres text search configurations: stemming and stop words. `simple` does neither. */
27
+ export const VECTOR_LANGUAGES = [
28
+ "simple", "arabic", "armenian", "basque", "catalan", "danish", "dutch", "english", "finnish", "french",
29
+ "german", "greek", "hindi", "hungarian", "indonesian", "irish", "italian", "lithuanian", "nepali",
30
+ "norwegian", "portuguese", "romanian", "russian", "serbian", "spanish", "swedish", "tamil", "turkish", "yiddish",
31
+ ] as const;
32
+ export type VectorLanguage = (typeof VECTOR_LANGUAGES)[number];
33
+
34
+ export const VECTOR_QUERY_MODES = ["text", "vector", "hybrid"] as const;
35
+ export type VectorQueryMode = (typeof VECTOR_QUERY_MODES)[number];
36
+
37
+ export const VECTOR_LIMITS = {
38
+ /** Documents per upsert, get, or delete. */
39
+ batch: 100,
40
+ /** One upsert's JSON body. */
41
+ batchBytes: 8 * 1024 * 1024,
42
+ idLength: 256,
43
+ titleLength: 1000,
44
+ textLength: 100_000,
45
+ /** A document's metadata, as JSON. */
46
+ metadataBytes: 16 * 1024,
47
+ /** Vector dimensions (pgvector's HNSW index takes up to 2000). */
48
+ dimensions: { min: 1, max: 2000 },
49
+ topK: { min: 1, max: 100, default: 10 },
50
+ /** Query text. */
51
+ queryLength: 2000,
52
+ indexesPerProject: 50,
53
+ } as const;
54
+
55
+ /** Where an index is defined: dashboard/CLI, mybase.config.ts (`mybase vector sync`), or app code. */
56
+ export type VectorIndexSource = "dashboard" | "code" | "api";
57
+
58
+ export type VectorMetadata = Record<string, unknown>;
59
+
60
+ /** An index's settings. Dimensions, metric, and language are fixed at creation. */
61
+ export interface VectorIndexSettings {
62
+ /** Vector length; null = a text-only index (no vectors). */
63
+ dimensions: number | null;
64
+ metric: VectorMetric;
65
+ language: VectorLanguage;
66
+ /** mybase embeds `text` with the project's embedding provider when a document or query has no vector. */
67
+ embed: boolean;
68
+ }
69
+
70
+ export interface VectorIndexInput {
71
+ /** Omit with `embed: true` to use the provider's dimensions. */
72
+ dimensions?: number | null;
73
+ /** Default `cosine`. */
74
+ metric?: VectorMetric;
75
+ /** Default `english`. */
76
+ language?: VectorLanguage;
77
+ /** Default false. Needs an embedding provider in the project's vector settings. */
78
+ embed?: boolean;
79
+ }
80
+
81
+ export interface VectorIndex extends VectorIndexSettings {
82
+ name: string;
83
+ source: VectorIndexSource;
84
+ /** Approximate document count (Postgres statistics, exact for small tables). */
85
+ documents: number;
86
+ createdAt: string;
87
+ }
88
+
89
+ /** A document to upsert. Documents with the same id are replaced. */
90
+ export interface VectorDocument<M extends VectorMetadata = VectorMetadata> {
91
+ id: string;
92
+ /** Searched by keyword, weighted above `text`. */
93
+ title?: string | null;
94
+ /** Searched by keyword, and embedded when the index has `embed` on and no `vector` is given. */
95
+ text?: string;
96
+ /** Free-form JSON, matched by `filter`. */
97
+ metadata?: M;
98
+ vector?: number[];
99
+ }
100
+
101
+ export interface StoredDocument<M extends VectorMetadata = VectorMetadata> {
102
+ id: string;
103
+ title: string | null;
104
+ text: string;
105
+ metadata: M;
106
+ /** Only when asked for (`includeVectors`). */
107
+ vector?: number[] | null;
108
+ updatedAt: string;
109
+ }
110
+
111
+ export interface UpsertDocumentsRequest {
112
+ documents: VectorDocument[];
113
+ }
114
+
115
+ export interface UpsertDocumentsResponse {
116
+ upserted: number;
117
+ /** How many of them mybase embedded. */
118
+ embedded: number;
119
+ }
120
+
121
+ export interface DeleteDocumentsRequest {
122
+ ids?: string[];
123
+ /** Deletes every document whose metadata contains this object. */
124
+ filter?: VectorMetadata;
125
+ }
126
+
127
+ export interface DeleteDocumentsResponse {
128
+ deleted: number;
129
+ }
130
+
131
+ export interface ListDocumentsResponse<M extends VectorMetadata = VectorMetadata> {
132
+ documents: StoredDocument<M>[];
133
+ /** Pass as `after` for the next page; null at the end. */
134
+ next: string | null;
135
+ }
136
+
137
+ export interface VectorQueryRequest {
138
+ /** Keywords for text search, and embedded for vector search when no `vector` is given. */
139
+ text?: string;
140
+ vector?: number[];
141
+ /** Default: `hybrid` when both a vector (given or embedded) and text are available, else whichever is. */
142
+ mode?: VectorQueryMode;
143
+ /** 1–100, default 10. */
144
+ topK?: number;
145
+ /** Only documents whose metadata contains this object, e.g. `{ lang: "en" }`. */
146
+ filter?: VectorMetadata;
147
+ /** Hybrid only: weight of the vector ranking, 0–1 (text gets the rest). Default 0.5. */
148
+ alpha?: number;
149
+ /** Return each match's text. Default true. */
150
+ includeText?: boolean;
151
+ includeVectors?: boolean;
152
+ /** Text and hybrid: a snippet with matched words wrapped in `<b>…</b>`. */
153
+ highlight?: boolean;
154
+ }
155
+
156
+ export interface VectorMatch<M extends VectorMetadata = VectorMetadata> {
157
+ id: string;
158
+ /**
159
+ * Higher is better. text: ts_rank_cd. vector: cosine similarity (cosine), negative distance (l2),
160
+ * or inner product (ip). hybrid: reciprocal rank fusion.
161
+ */
162
+ score: number;
163
+ title: string | null;
164
+ text?: string;
165
+ metadata: M;
166
+ vector?: number[] | null;
167
+ highlight?: string;
168
+ }
169
+
170
+ export interface VectorQueryResponse<M extends VectorMetadata = VectorMetadata> {
171
+ mode: VectorQueryMode;
172
+ matches: VectorMatch<M>[];
173
+ }
174
+
175
+ /** The project's embedding provider: any OpenAI-compatible `POST <url>/embeddings`. */
176
+ export interface EmbeddingSettings {
177
+ /** e.g. `https://api.openai.com/v1` or `http://ollama.lan:11434/v1`. */
178
+ url: string;
179
+ model: string;
180
+ /** Vector length the model returns. */
181
+ dimensions: number;
182
+ /** Whether an API key is stored (it's never returned). */
183
+ hasKey: boolean;
184
+ }
185
+
186
+ export interface VectorServiceSettings {
187
+ /** The API's public URL, for `createVectorClient({ apiUrl })`. */
188
+ apiUrl: string;
189
+ embedding: EmbeddingSettings | null;
190
+ enabledAt: string;
191
+ }
192
+
193
+ // ---- client -----------------------------------------------------------------
194
+
195
+ export interface VectorClientOptions {
196
+ /** Where the mybase API is reachable from your app, e.g. `https://mybase.example.com`. */
197
+ apiUrl: string;
198
+ project: string;
199
+ /** A mybase API key (`mb_…`) with `vector:write` for this project (`vector:read` is enough to query). Server-side only. */
200
+ apiKey: string;
201
+ /** How long to wait for mybase, in ms. Default 30000 (embedding can take a while). */
202
+ timeoutMs?: number;
203
+ /** Default `globalThis.fetch`. */
204
+ fetch?: typeof fetch;
205
+ }
206
+
207
+ export type QueryOptions = Omit<VectorQueryRequest, "text">;
208
+
209
+ export interface VectorIndexClient<M extends VectorMetadata = VectorMetadata> {
210
+ readonly name: string;
211
+ /** Inserts or replaces documents by id, up to 100 per call. */
212
+ upsert(documents: VectorDocument<M> | VectorDocument<M>[]): Promise<UpsertDocumentsResponse>;
213
+ /** Documents by id; missing ones are left out. */
214
+ get(ids: string | string[], options?: { includeVectors?: boolean }): Promise<StoredDocument<M>[]>;
215
+ /** By ids, or by `{ filter }`. */
216
+ delete(target: string | string[] | { filter: Partial<M> & VectorMetadata }): Promise<DeleteDocumentsResponse>;
217
+ /** Search by text (`query("…")`), or pass a full request (`query({ vector, filter })`). */
218
+ query(query: string | VectorQueryRequest, options?: QueryOptions): Promise<VectorQueryResponse<M>>;
219
+ /** Pages through documents in id order. */
220
+ list(options?: { limit?: number; after?: string }): Promise<ListDocumentsResponse<M>>;
221
+ info(): Promise<VectorIndex>;
222
+ }
223
+
224
+ export interface VectorClient {
225
+ index<M extends VectorMetadata = VectorMetadata>(name: string): VectorIndexClient<M>;
226
+ listIndexes(): Promise<VectorIndex[]>;
227
+ getIndex(name: string): Promise<VectorIndex>;
228
+ /** Idempotent: an existing index with the same settings is returned as is. */
229
+ createIndex(name: string, settings?: VectorIndexInput): Promise<VectorIndex>;
230
+ /** Only `embed` can change. */
231
+ updateIndex(name: string, settings: { embed: boolean }): Promise<VectorIndex>;
232
+ /** Deletes the index and all its documents. */
233
+ deleteIndex(name: string): Promise<void>;
234
+ }
235
+
236
+ export function createVectorClient(options: VectorClientOptions): VectorClient {
237
+ const doFetch = options.fetch ?? globalThis.fetch;
238
+ const base = `${options.apiUrl.replace(/\/+$/, "")}/vector/${encodeURIComponent(options.project)}/indexes`;
239
+ const timeoutMs = options.timeoutMs ?? 30_000;
240
+ const path = (name: string, rest = "") => `${base}/${encodeURIComponent(name)}${rest}`;
241
+
242
+ async function call<T>(method: string, url: string, body?: unknown): Promise<T> {
243
+ const res = await doFetch(url, {
244
+ method,
245
+ body: body === undefined ? undefined : JSON.stringify(body),
246
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${options.apiKey}` },
247
+ signal: AbortSignal.timeout(timeoutMs),
248
+ });
249
+ if (res.status === 204) return undefined as T;
250
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
251
+ if (!res.ok) throw new MybaseApiError(data.error ?? `mybase API answered ${res.status}`, res.status);
252
+ return data as T;
253
+ }
254
+
255
+ function index<M extends VectorMetadata>(name: string): VectorIndexClient<M> {
256
+ return {
257
+ name,
258
+ upsert: (documents) =>
259
+ call("POST", path(name, "/documents"), { documents: Array.isArray(documents) ? documents : [documents] } satisfies UpsertDocumentsRequest),
260
+ async get(ids, opts = {}) {
261
+ const params = new URLSearchParams();
262
+ for (const id of Array.isArray(ids) ? ids : [ids]) params.append("id", id);
263
+ if (opts.includeVectors) params.set("includeVectors", "true");
264
+ const { documents } = await call<ListDocumentsResponse<M>>("GET", path(name, `/documents?${params}`));
265
+ return documents;
266
+ },
267
+ delete(target) {
268
+ const body: DeleteDocumentsRequest = typeof target === "string" ? { ids: [target] } : Array.isArray(target) ? { ids: target } : { filter: target.filter };
269
+ return call("POST", path(name, "/documents/delete"), body);
270
+ },
271
+ query(query, opts = {}) {
272
+ const body: VectorQueryRequest = typeof query === "string" ? { ...opts, text: query } : { ...opts, ...query };
273
+ return call("POST", path(name, "/query"), body);
274
+ },
275
+ list({ limit, after } = {}) {
276
+ const params = new URLSearchParams();
277
+ if (limit !== undefined) params.set("limit", String(limit));
278
+ if (after !== undefined) params.set("after", after);
279
+ return call("GET", path(name, `/documents?${params}`));
280
+ },
281
+ info: () => call("GET", path(name)),
282
+ };
283
+ }
284
+
285
+ return {
286
+ index,
287
+ listIndexes: () => call("GET", base),
288
+ getIndex: (name) => call("GET", path(name)),
289
+ createIndex: (name, settings = {}) => call("POST", base, { name, ...settings }),
290
+ updateIndex: (name, settings) => call("PATCH", path(name), settings),
291
+ deleteIndex: (name) => call("DELETE", path(name)),
292
+ };
293
+ }
294
+
295
+ // ---- agent tool -------------------------------------------------------------
296
+
297
+ /**
298
+ * A tool for an LLM agent, independent of the provider. Map it to yours:
299
+ * Anthropic `{ name, description, input_schema: inputSchema }`,
300
+ * OpenAI `{ type: "function", function: { name, description, parameters: inputSchema } }`.
301
+ */
302
+ export interface RetrievalTool {
303
+ name: string;
304
+ description: string;
305
+ inputSchema: ToolInputSchema;
306
+ /** Runs the search and returns text for the tool result. Fixable problems come back as `Error: …`. */
307
+ run(input: unknown): Promise<string>;
308
+ }
309
+
310
+ export interface RetrievalToolOptions {
311
+ /** Default `search_documents`. */
312
+ name?: string;
313
+ /** What the index holds, for the model, e.g. "the product documentation". */
314
+ about?: string;
315
+ /** Matches returned. Default 5. */
316
+ topK?: number;
317
+ /** Applied to every search (e.g. the signed-in user's tenant). */
318
+ filter?: VectorMetadata;
319
+ mode?: VectorQueryMode;
320
+ /** Most characters of each match's text. Default 2000. */
321
+ maxChars?: number;
322
+ }
323
+
324
+ /** A retrieval tool over one index, ready for an LLM tool-use loop (RAG). */
325
+ export function retrievalTool(index: VectorIndexClient, options: RetrievalToolOptions = {}): RetrievalTool {
326
+ const maxChars = options.maxChars ?? 2000;
327
+ return {
328
+ name: options.name ?? "search_documents",
329
+ description:
330
+ `Search ${options.about ?? "the knowledge base"} and return the most relevant passages with their ids. ` +
331
+ "Use specific words from the topic you need.",
332
+ inputSchema: {
333
+ type: "object",
334
+ properties: { query: { type: "string", description: "What to look for." } },
335
+ required: ["query"],
336
+ additionalProperties: false,
337
+ },
338
+ async run(input) {
339
+ const query = typeof input === "object" && input !== null ? (input as { query?: unknown }).query : undefined;
340
+ if (typeof query !== "string" || !query.trim()) return "Error: query is required.";
341
+ try {
342
+ const { matches } = await index.query(query, { topK: options.topK ?? 5, filter: options.filter, mode: options.mode });
343
+ if (matches.length === 0) return `Nothing found for "${query}".`;
344
+ return matches
345
+ .map((m, i) => {
346
+ const text = (m.text ?? "").length > maxChars ? `${m.text!.slice(0, maxChars)}…` : (m.text ?? "");
347
+ return `${i + 1}. [${m.id}]${m.title ? ` ${m.title}` : ""}\n${text}`;
348
+ })
349
+ .join("\n\n");
350
+ } catch (error) {
351
+ if (error instanceof MybaseApiError && ![401, 403, 404].includes(error.status)) return `Error: ${error.message}`;
352
+ throw error;
353
+ }
354
+ },
355
+ };
356
+ }