@unson/brainbase-mcp 0.4.0 → 0.5.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,391 @@
1
+ import { createHash } from 'node:crypto';
2
+ export const EMBEDDING_MAX_INPUTS = 64;
3
+ /** Maximum number of texts accepted by one embed() call across all batches. */
4
+ export const EMBEDDING_MAX_TOTAL_INPUTS = 2_048;
5
+ export const EMBEDDING_MAX_TEXT_LENGTH = 16_000;
6
+ export const EMBEDDING_MAX_BATCH_BYTES = 1_000_000;
7
+ export const EMBEDDING_MAX_RESPONSE_BYTES = 4_000_000;
8
+ export const EMBEDDING_DEFAULT_TIMEOUT_MS = 10_000;
9
+ export const EMBEDDING_DEFAULT_CACHE_SIZE = EMBEDDING_MAX_TOTAL_INPUTS;
10
+ const MAX_URL_LENGTH = 2_048;
11
+ const MAX_MODEL_LENGTH = 256;
12
+ const MAX_API_KEY_LENGTH = 4_096;
13
+ const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/u;
14
+ export class EmbeddingProviderConfigError extends Error {
15
+ code = 'embedding_config_invalid';
16
+ constructor(message) {
17
+ super(`${'embedding_config_invalid'}: ${message}`);
18
+ this.name = 'EmbeddingProviderConfigError';
19
+ }
20
+ }
21
+ export class EmbeddingProviderError extends Error {
22
+ code;
23
+ constructor(code, message) {
24
+ super(`${code}: ${message}`);
25
+ this.name = 'EmbeddingProviderError';
26
+ this.code = code;
27
+ }
28
+ }
29
+ /**
30
+ * Build an opt-in provider from environment variables.
31
+ *
32
+ * With no embedding variables configured this returns undefined and performs
33
+ * no network setup. A partial or invalid configuration throws instead of
34
+ * silently falling back to lexical search or another provider.
35
+ */
36
+ export function createEmbeddingProviderFromEnv(env = process.env) {
37
+ const rawUrl = env.BRAINBASE_EMBEDDING_URL;
38
+ const rawModel = env.BRAINBASE_EMBEDDING_MODEL;
39
+ const rawApiKey = env.BRAINBASE_EMBEDDING_API_KEY;
40
+ const configured = rawUrl !== undefined || rawModel !== undefined || rawApiKey !== undefined;
41
+ if (!configured)
42
+ return undefined;
43
+ const url = requireString(rawUrl, 'BRAINBASE_EMBEDDING_URL');
44
+ const model = requireString(rawModel, 'BRAINBASE_EMBEDDING_MODEL');
45
+ const apiKey = rawApiKey === undefined || (typeof rawApiKey === 'string' && rawApiKey.trim() === '')
46
+ ? undefined
47
+ : validateApiKey(rawApiKey);
48
+ return createHttpEmbeddingProvider({ url, model, ...(apiKey === undefined ? {} : { apiKey }) });
49
+ }
50
+ /** Create the HTTP adapter. No request is made until embed() is called. */
51
+ export function createHttpEmbeddingProvider(config) {
52
+ const endpoint = validateEndpoint(config.url);
53
+ const model = validateModel(config.model);
54
+ const apiKey = validateApiKey(config.apiKey);
55
+ const fetchImpl = config.fetch ?? config.fetchImpl ?? defaultFetch;
56
+ const timeoutMs = validatePositiveInteger(config.timeoutMs ?? EMBEDDING_DEFAULT_TIMEOUT_MS, 'timeoutMs', 60_000);
57
+ const cacheSize = validatePositiveInteger(config.cacheSize ?? EMBEDDING_DEFAULT_CACHE_SIZE, 'cacheSize', EMBEDDING_MAX_TOTAL_INPUTS);
58
+ const configFingerprint = sha256(`${endpoint.href}\u0000${model}`);
59
+ const cache = new LruEmbeddingCache(cacheSize);
60
+ return {
61
+ id: `http-embedding:${configFingerprint.slice(0, 16)}`,
62
+ async embed(texts) {
63
+ validateInputs(texts, model);
64
+ if (texts.length === 0)
65
+ return [];
66
+ const results = new Array(texts.length);
67
+ const misses = new Map();
68
+ for (const [index, text] of texts.entries()) {
69
+ const key = cacheKey(configFingerprint, text);
70
+ const cached = cache.get(key);
71
+ if (cached !== undefined) {
72
+ results[index] = cached;
73
+ continue;
74
+ }
75
+ const existing = misses.get(key);
76
+ if (existing)
77
+ existing.indexes.push(index);
78
+ else
79
+ misses.set(key, { text, indexes: [index] });
80
+ }
81
+ if (misses.size > 0) {
82
+ const missing = [...misses.values()];
83
+ const deadline = createDeadline(timeoutMs);
84
+ try {
85
+ let offset = 0;
86
+ for (const batch of splitBatches(missing.map((entry) => entry.text), model)) {
87
+ const vectors = await requestEmbeddings({ endpoint, model, apiKey, fetchImpl }, batch, deadline);
88
+ for (const [batchIndex, vector] of vectors.entries()) {
89
+ const entry = missing[offset + batchIndex];
90
+ const key = cacheKey(configFingerprint, entry.text);
91
+ cache.set(key, vector);
92
+ for (const index of entry.indexes)
93
+ results[index] = [...vector];
94
+ }
95
+ offset += batch.length;
96
+ }
97
+ }
98
+ finally {
99
+ deadline.controller.abort();
100
+ }
101
+ }
102
+ return results.map((vector, index) => {
103
+ if (vector === undefined)
104
+ throw new EmbeddingProviderError('embedding_response_invalid', `missing vector at input index ${index}`);
105
+ return [...vector];
106
+ });
107
+ }
108
+ };
109
+ }
110
+ function requireString(value, name) {
111
+ if (typeof value !== 'string' || value.trim() === '') {
112
+ throw new EmbeddingProviderConfigError(`${name} must be set when embedding is configured`);
113
+ }
114
+ return value.trim();
115
+ }
116
+ function validateEndpoint(raw) {
117
+ if (typeof raw !== 'string' || raw.trim() === '' || raw.length > MAX_URL_LENGTH) {
118
+ throw new EmbeddingProviderConfigError('BRAINBASE_EMBEDDING_URL must be a valid full embeddings endpoint');
119
+ }
120
+ let endpoint;
121
+ try {
122
+ endpoint = new URL(raw.trim());
123
+ }
124
+ catch {
125
+ throw new EmbeddingProviderConfigError('BRAINBASE_EMBEDDING_URL must be a valid full embeddings endpoint');
126
+ }
127
+ if (endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
128
+ throw new EmbeddingProviderConfigError('BRAINBASE_EMBEDDING_URL must not contain credentials, query parameters, or a fragment');
129
+ }
130
+ const host = endpoint.hostname.toLowerCase().replace(/^\[|\]$/g, '');
131
+ const loopback = host === 'localhost' || host === '127.0.0.1' || host === '::1';
132
+ if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && loopback)) {
133
+ throw new EmbeddingProviderConfigError('BRAINBASE_EMBEDDING_URL must use HTTPS; HTTP is allowed only for loopback services');
134
+ }
135
+ if (!/(?:^|\/)embeddings\/?$/u.test(endpoint.pathname)) {
136
+ throw new EmbeddingProviderConfigError('BRAINBASE_EMBEDDING_URL must point to an /embeddings endpoint');
137
+ }
138
+ return endpoint;
139
+ }
140
+ function validateModel(raw) {
141
+ if (typeof raw !== 'string' || raw.trim() === '' || raw.length > MAX_MODEL_LENGTH || CONTROL_CHARACTER.test(raw)) {
142
+ throw new EmbeddingProviderConfigError('BRAINBASE_EMBEDDING_MODEL must be a valid non-empty model name');
143
+ }
144
+ return raw.trim();
145
+ }
146
+ function validateApiKey(raw) {
147
+ if (raw === undefined)
148
+ return undefined;
149
+ if (typeof raw !== 'string' || raw.length > MAX_API_KEY_LENGTH || CONTROL_CHARACTER.test(raw)) {
150
+ throw new EmbeddingProviderConfigError('BRAINBASE_EMBEDDING_API_KEY is invalid');
151
+ }
152
+ if (raw.trim() === '')
153
+ return undefined;
154
+ return raw;
155
+ }
156
+ function validatePositiveInteger(value, name, max) {
157
+ if (!Number.isInteger(value) || value < 1 || value > max) {
158
+ throw new EmbeddingProviderConfigError(`${name} must be an integer between 1 and ${max}`);
159
+ }
160
+ return value;
161
+ }
162
+ function validateInputs(texts, model) {
163
+ if (!Array.isArray(texts))
164
+ throw new EmbeddingProviderError('embedding_input_invalid', 'texts must be an array');
165
+ if (texts.length > EMBEDDING_MAX_TOTAL_INPUTS) {
166
+ throw new EmbeddingProviderError('embedding_input_invalid', `at most ${EMBEDDING_MAX_TOTAL_INPUTS} texts may be embedded per request`);
167
+ }
168
+ for (const text of texts) {
169
+ if (typeof text !== 'string')
170
+ throw new EmbeddingProviderError('embedding_input_invalid', 'each text must be a string');
171
+ if (text.length > EMBEDDING_MAX_TEXT_LENGTH) {
172
+ throw new EmbeddingProviderError('embedding_input_invalid', `each text may contain at most ${EMBEDDING_MAX_TEXT_LENGTH} characters`);
173
+ }
174
+ }
175
+ if (Buffer.byteLength(JSON.stringify({ input: ['x'.repeat(0)], model }), 'utf8') > EMBEDDING_MAX_BATCH_BYTES) {
176
+ throw new EmbeddingProviderError('embedding_input_invalid', 'embedding model metadata exceeds the batch byte limit');
177
+ }
178
+ }
179
+ async function requestEmbeddings(config, texts, deadline) {
180
+ const headers = { 'content-type': 'application/json', accept: 'application/json' };
181
+ if (config.apiKey !== undefined)
182
+ headers.authorization = `Bearer ${config.apiKey}`;
183
+ const body = JSON.stringify({ input: texts, model: config.model });
184
+ let response;
185
+ try {
186
+ response = await withDeadline(config.fetchImpl(config.endpoint.href, {
187
+ method: 'POST', headers, body, redirect: 'error', signal: deadline.controller.signal
188
+ }), deadline);
189
+ }
190
+ catch (error) {
191
+ if (error instanceof EmbeddingProviderError)
192
+ throw error;
193
+ throw new EmbeddingProviderError('embedding_request_failed', 'embedding service request failed');
194
+ }
195
+ if (!Number.isInteger(response.status) || response.status < 200 || response.status >= 300) {
196
+ throw new EmbeddingProviderError('embedding_request_failed', `embedding service returned HTTP ${Number.isInteger(response.status) ? response.status : 'an invalid status'}`);
197
+ }
198
+ let raw;
199
+ try {
200
+ raw = await withDeadline(readResponseText(response), deadline);
201
+ }
202
+ catch (error) {
203
+ if (error instanceof EmbeddingProviderError)
204
+ throw error;
205
+ throw new EmbeddingProviderError('embedding_response_invalid', 'embedding service response could not be read');
206
+ }
207
+ let parsed;
208
+ try {
209
+ parsed = JSON.parse(raw);
210
+ }
211
+ catch {
212
+ throw new EmbeddingProviderError('embedding_response_invalid', 'embedding service returned invalid JSON');
213
+ }
214
+ return validateEmbeddingResponse(parsed, texts.length);
215
+ }
216
+ function createDeadline(timeoutMs) {
217
+ return { controller: new AbortController(), expiresAt: Date.now() + timeoutMs, timeoutMs };
218
+ }
219
+ async function withDeadline(operation, deadline) {
220
+ const remaining = deadline.expiresAt - Date.now();
221
+ if (remaining <= 0) {
222
+ deadline.controller.abort();
223
+ throw new EmbeddingProviderError('embedding_request_timeout', `embedding service timed out after ${deadline.timeoutMs}ms`);
224
+ }
225
+ let timer;
226
+ const timeout = new Promise((_, reject) => {
227
+ timer = setTimeout(() => {
228
+ deadline.controller.abort();
229
+ reject(new EmbeddingProviderError('embedding_request_timeout', `embedding service timed out after ${deadline.timeoutMs}ms`));
230
+ }, remaining);
231
+ if (typeof timer === 'object' && timer !== null && 'unref' in timer && typeof timer.unref === 'function')
232
+ timer.unref();
233
+ });
234
+ try {
235
+ return await Promise.race([operation, timeout]);
236
+ }
237
+ finally {
238
+ if (timer !== undefined)
239
+ clearTimeout(timer);
240
+ }
241
+ }
242
+ function splitBatches(texts, model) {
243
+ const batches = [];
244
+ let current = [];
245
+ for (const text of texts) {
246
+ const candidate = [...current, text];
247
+ const bytes = Buffer.byteLength(JSON.stringify({ input: candidate, model }), 'utf8');
248
+ if (current.length > 0 && (candidate.length > EMBEDDING_MAX_INPUTS || bytes > EMBEDDING_MAX_BATCH_BYTES)) {
249
+ batches.push(current);
250
+ current = [text];
251
+ if (Buffer.byteLength(JSON.stringify({ input: current, model }), 'utf8') > EMBEDDING_MAX_BATCH_BYTES) {
252
+ throw new EmbeddingProviderError('embedding_input_invalid', `embedding batch exceeds ${EMBEDDING_MAX_BATCH_BYTES} bytes`);
253
+ }
254
+ }
255
+ else if (candidate.length > EMBEDDING_MAX_INPUTS || bytes > EMBEDDING_MAX_BATCH_BYTES) {
256
+ throw new EmbeddingProviderError('embedding_input_invalid', `embedding batch exceeds ${EMBEDDING_MAX_BATCH_BYTES} bytes`);
257
+ }
258
+ else {
259
+ current = candidate;
260
+ }
261
+ }
262
+ if (current.length > 0)
263
+ batches.push(current);
264
+ return batches;
265
+ }
266
+ async function readResponseText(response) {
267
+ const contentLength = headerValue(response.headers, 'content-length');
268
+ if (contentLength !== undefined) {
269
+ const length = Number(contentLength);
270
+ if (Number.isFinite(length) && length > EMBEDDING_MAX_RESPONSE_BYTES) {
271
+ throw new EmbeddingProviderError('embedding_response_too_large', `embedding service response exceeds ${EMBEDDING_MAX_RESPONSE_BYTES} bytes`);
272
+ }
273
+ }
274
+ const reader = response.body?.getReader?.();
275
+ if (!reader) {
276
+ let text;
277
+ if (response.text)
278
+ text = await response.text();
279
+ else if (response.json)
280
+ text = JSON.stringify(await response.json());
281
+ else
282
+ throw new EmbeddingProviderError('embedding_response_invalid', 'embedding service response could not be read');
283
+ if (Buffer.byteLength(text, 'utf8') > EMBEDDING_MAX_RESPONSE_BYTES) {
284
+ throw new EmbeddingProviderError('embedding_response_too_large', `embedding service response exceeds ${EMBEDDING_MAX_RESPONSE_BYTES} bytes`);
285
+ }
286
+ return text;
287
+ }
288
+ const chunks = [];
289
+ let total = 0;
290
+ try {
291
+ while (true) {
292
+ const chunk = await reader.read();
293
+ if (chunk.done)
294
+ break;
295
+ const value = chunk.value;
296
+ if (!value)
297
+ continue;
298
+ total += value.byteLength;
299
+ if (total > EMBEDDING_MAX_RESPONSE_BYTES) {
300
+ await reader.cancel?.('response too large');
301
+ throw new EmbeddingProviderError('embedding_response_too_large', `embedding service response exceeds ${EMBEDDING_MAX_RESPONSE_BYTES} bytes`);
302
+ }
303
+ chunks.push(value);
304
+ }
305
+ }
306
+ catch (error) {
307
+ if (error instanceof EmbeddingProviderError)
308
+ throw error;
309
+ throw new EmbeddingProviderError('embedding_response_invalid', 'embedding service response could not be read');
310
+ }
311
+ const bytes = new Uint8Array(total);
312
+ let offset = 0;
313
+ for (const chunk of chunks) {
314
+ bytes.set(chunk, offset);
315
+ offset += chunk.byteLength;
316
+ }
317
+ return new TextDecoder().decode(bytes);
318
+ }
319
+ function headerValue(headers, name) {
320
+ if (!headers)
321
+ return undefined;
322
+ if (typeof headers.get === 'function')
323
+ return headers.get(name) ?? undefined;
324
+ if (headers instanceof Map)
325
+ return headers.get(name) ?? headers.get(name.toLowerCase());
326
+ const record = headers;
327
+ return record[name] ?? record[name.toLowerCase()];
328
+ }
329
+ function validateEmbeddingResponse(value, count) {
330
+ if (!isRecord(value) || !Array.isArray(value.data) || value.data.length !== count) {
331
+ throw new EmbeddingProviderError('embedding_response_invalid', 'embedding service returned the wrong number of vectors');
332
+ }
333
+ const ordered = new Array(count);
334
+ for (const item of value.data) {
335
+ if (!isRecord(item) || !Number.isInteger(item.index) || item.index < 0 || item.index >= count || ordered[item.index] !== undefined) {
336
+ throw new EmbeddingProviderError('embedding_response_invalid', 'embedding service returned invalid vector indexes');
337
+ }
338
+ if (!Array.isArray(item.embedding) || item.embedding.length === 0 || item.embedding.some((entry) => typeof entry !== 'number' || !Number.isFinite(entry))) {
339
+ throw new EmbeddingProviderError('embedding_response_invalid', 'embedding service returned an invalid vector');
340
+ }
341
+ if (item.embedding.every((entry) => entry === 0)) {
342
+ throw new EmbeddingProviderError('embedding_response_invalid', 'embedding service returned a zero vector');
343
+ }
344
+ ordered[item.index] = [...item.embedding];
345
+ }
346
+ if (ordered.some((vector) => vector === undefined)) {
347
+ throw new EmbeddingProviderError('embedding_response_invalid', 'embedding service omitted a vector index');
348
+ }
349
+ const dimension = ordered[0].length;
350
+ if (ordered.some((vector) => vector.length !== dimension)) {
351
+ throw new EmbeddingProviderError('embedding_response_invalid', 'embedding service returned vectors with different dimensions');
352
+ }
353
+ return ordered;
354
+ }
355
+ function isRecord(value) {
356
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
357
+ }
358
+ function sha256(value) {
359
+ return createHash('sha256').update(value, 'utf8').digest('hex');
360
+ }
361
+ function cacheKey(configFingerprint, text) {
362
+ return sha256(`${configFingerprint}\u0000${text}`);
363
+ }
364
+ class LruEmbeddingCache {
365
+ maxEntries;
366
+ values = new Map();
367
+ constructor(maxEntries) {
368
+ this.maxEntries = maxEntries;
369
+ }
370
+ get(key) {
371
+ const value = this.values.get(key);
372
+ if (value === undefined)
373
+ return undefined;
374
+ this.values.delete(key);
375
+ this.values.set(key, value);
376
+ return [...value];
377
+ }
378
+ set(key, vector) {
379
+ this.values.delete(key);
380
+ this.values.set(key, [...vector]);
381
+ while (this.values.size > this.maxEntries) {
382
+ const oldest = this.values.keys().next().value;
383
+ if (oldest === undefined)
384
+ break;
385
+ this.values.delete(oldest);
386
+ }
387
+ }
388
+ }
389
+ async function defaultFetch(input, init) {
390
+ return fetch(input, init);
391
+ }
@@ -0,0 +1,94 @@
1
+ import type { EmbeddingProvider } from './embedding-provider.js';
2
+ import type { CanonicalEntityKind, CoreRelation, PersonalOs, SearchResult } from './types.js';
3
+ /** The deliberately small bounds keep a model-supplied relation plan finite. */
4
+ export declare const GRAPH_RETRIEVAL_MAX_SEEDS = 10;
5
+ export declare const GRAPH_RETRIEVAL_MAX_STEPS = 3;
6
+ export declare const GRAPH_RETRIEVAL_MAX_LIMIT = 50;
7
+ export type RetrievalDiscovery = 'semantic' | 'lexical' | 'seed' | 'traversal';
8
+ export interface GraphRetrievalStep {
9
+ relation: CoreRelation;
10
+ direction: 'incoming' | 'outgoing';
11
+ targetType?: CanonicalEntityKind;
12
+ }
13
+ export interface GraphRetrievalInput {
14
+ query: string;
15
+ limit?: number;
16
+ project?: string;
17
+ asOf?: string;
18
+ seedIds?: string[];
19
+ steps?: GraphRetrievalStep[];
20
+ }
21
+ export interface GraphEvidence {
22
+ source: 'legacy_decisions';
23
+ sourceId: string;
24
+ canonicalEntityId: string;
25
+ statement: string;
26
+ }
27
+ export interface DecisionRationale {
28
+ decisionId: string;
29
+ title: string;
30
+ decision: string;
31
+ rationale?: string;
32
+ }
33
+ export interface GraphRetrievalCandidate extends SearchResult {
34
+ discovery: RetrievalDiscovery;
35
+ relationPath: string[];
36
+ evidence: GraphEvidence[];
37
+ decisionRationale?: DecisionRationale;
38
+ }
39
+ export interface ObservedRelation {
40
+ relation: CoreRelation;
41
+ meaning: string;
42
+ direction: 'incoming' | 'outgoing';
43
+ targetType: CanonicalEntityKind;
44
+ edgeIds: string[];
45
+ targetIds: string[];
46
+ }
47
+ export interface GraphRetrievalResponse {
48
+ graphVersion: 1 | 2;
49
+ schemaVersion: 1 | 2;
50
+ status: 'ok' | 'migration_required';
51
+ migrationRequired: boolean;
52
+ authority: 'local_graph' | 'organization_graph';
53
+ query: string;
54
+ asOf: string;
55
+ project?: {
56
+ id: string;
57
+ name: string;
58
+ };
59
+ method: 'semantic' | 'lexical' | 'seed';
60
+ semantic: {
61
+ available: boolean;
62
+ method: 'semantic' | 'lexical' | 'seed';
63
+ providerId?: string;
64
+ };
65
+ candidates: GraphRetrievalCandidate[];
66
+ results: GraphRetrievalCandidate[];
67
+ observedRelations: ObservedRelation[];
68
+ /** Alias kept explicit for consumers that call this a relation catalog. */
69
+ observedRelationCatalog: ObservedRelation[];
70
+ traversal: {
71
+ seedIds: string[];
72
+ steps: GraphRetrievalStep[];
73
+ maxSeeds: number;
74
+ maxSteps: number;
75
+ maxLimit: number;
76
+ };
77
+ coverage: 'complete' | 'partial' | 'unknown';
78
+ partialReasons: string[];
79
+ missingEvidence: string[];
80
+ evidence: GraphEvidence[];
81
+ /** Search retrieves material; the model must still judge whether it answers the question. */
82
+ sufficiency: 'needs_model_verification' | 'insufficient';
83
+ /** Deliberately never inferred from an empty result set. */
84
+ absenceConfirmed: false;
85
+ }
86
+ /**
87
+ * Retrieve canonical Graph v2 records in three explicit phases:
88
+ * candidate discovery, bounded typed edge traversal, and evidence reporting.
89
+ *
90
+ * A provider is optional by design. Without one this function performs only
91
+ * deterministic lexical discovery and marks that fact in the receipt; it does
92
+ * not present lexical matches as semantic understanding.
93
+ */
94
+ export declare function retrieveGraph(os: PersonalOs, input: GraphRetrievalInput, provider?: EmbeddingProvider): Promise<GraphRetrievalResponse>;