brainbank 0.1.0-beta.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.
Files changed (137) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +155 -0
  3. package/assets/architecture.png +0 -0
  4. package/bin/brainbank +18 -0
  5. package/bin/brainbank-mcp +19 -0
  6. package/dist/chunk-3YBCD6DI.js +117 -0
  7. package/dist/chunk-3YBCD6DI.js.map +1 -0
  8. package/dist/chunk-63GBCDS5.js +3249 -0
  9. package/dist/chunk-63GBCDS5.js.map +1 -0
  10. package/dist/chunk-DMFMTOHF.js +123 -0
  11. package/dist/chunk-DMFMTOHF.js.map +1 -0
  12. package/dist/chunk-FQYKWB2Q.js +136 -0
  13. package/dist/chunk-FQYKWB2Q.js.map +1 -0
  14. package/dist/chunk-IMJJ2VEM.js +74 -0
  15. package/dist/chunk-IMJJ2VEM.js.map +1 -0
  16. package/dist/chunk-M744PCJQ.js +43 -0
  17. package/dist/chunk-M744PCJQ.js.map +1 -0
  18. package/dist/chunk-O3J6ZIXK.js +82 -0
  19. package/dist/chunk-O3J6ZIXK.js.map +1 -0
  20. package/dist/chunk-OPH7GZ7U.js +124 -0
  21. package/dist/chunk-OPH7GZ7U.js.map +1 -0
  22. package/dist/chunk-PXEWQMN7.js +89 -0
  23. package/dist/chunk-PXEWQMN7.js.map +1 -0
  24. package/dist/chunk-RDQYDLYZ.js +69 -0
  25. package/dist/chunk-RDQYDLYZ.js.map +1 -0
  26. package/dist/chunk-VIIHPCC4.js +254 -0
  27. package/dist/chunk-VIIHPCC4.js.map +1 -0
  28. package/dist/chunk-WCQVDF3K.js +14 -0
  29. package/dist/chunk-WCQVDF3K.js.map +1 -0
  30. package/dist/cli.d.ts +1 -0
  31. package/dist/cli.js +3076 -0
  32. package/dist/cli.js.map +1 -0
  33. package/dist/haiku-expander-YRSIPGKP.js +8 -0
  34. package/dist/haiku-expander-YRSIPGKP.js.map +1 -0
  35. package/dist/haiku-pruner-SHAXUPY6.js +8 -0
  36. package/dist/haiku-pruner-SHAXUPY6.js.map +1 -0
  37. package/dist/http-server-QUXHLWUM.js +9 -0
  38. package/dist/http-server-QUXHLWUM.js.map +1 -0
  39. package/dist/index.d.ts +2161 -0
  40. package/dist/index.js +357 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/local-embedding-NZQTILGV.js +8 -0
  43. package/dist/local-embedding-NZQTILGV.js.map +1 -0
  44. package/dist/mcp.d.ts +2 -0
  45. package/dist/mcp.js +334 -0
  46. package/dist/mcp.js.map +1 -0
  47. package/dist/openai-embedding-ZP5TSUJG.js +8 -0
  48. package/dist/openai-embedding-ZP5TSUJG.js.map +1 -0
  49. package/dist/perplexity-context-embedding-GI5PHE6X.js +9 -0
  50. package/dist/perplexity-context-embedding-GI5PHE6X.js.map +1 -0
  51. package/dist/perplexity-embedding-KZRYGJRC.js +10 -0
  52. package/dist/perplexity-embedding-KZRYGJRC.js.map +1 -0
  53. package/dist/plugin-IKQ6IRSJ.js +32 -0
  54. package/dist/plugin-IKQ6IRSJ.js.map +1 -0
  55. package/dist/resolve-ASGLBNUC.js +10 -0
  56. package/dist/resolve-ASGLBNUC.js.map +1 -0
  57. package/dist/stats-tui-ZY2NQSEA.js +1904 -0
  58. package/dist/stats-tui-ZY2NQSEA.js.map +1 -0
  59. package/package.json +96 -0
  60. package/src/brainbank.ts +617 -0
  61. package/src/cli/commands/collection.ts +77 -0
  62. package/src/cli/commands/context.ts +179 -0
  63. package/src/cli/commands/daemon.ts +100 -0
  64. package/src/cli/commands/docs.ts +71 -0
  65. package/src/cli/commands/files.ts +69 -0
  66. package/src/cli/commands/help.ts +77 -0
  67. package/src/cli/commands/index.ts +482 -0
  68. package/src/cli/commands/kv.ts +140 -0
  69. package/src/cli/commands/mcp-export.ts +273 -0
  70. package/src/cli/commands/mcp.ts +6 -0
  71. package/src/cli/commands/reembed.ts +30 -0
  72. package/src/cli/commands/scan.ts +336 -0
  73. package/src/cli/commands/search.ts +203 -0
  74. package/src/cli/commands/stats.ts +68 -0
  75. package/src/cli/commands/status.ts +47 -0
  76. package/src/cli/commands/watch.ts +47 -0
  77. package/src/cli/factory/brain-context.ts +43 -0
  78. package/src/cli/factory/builtin-registration.ts +87 -0
  79. package/src/cli/factory/config-loader.ts +77 -0
  80. package/src/cli/factory/index.ts +69 -0
  81. package/src/cli/factory/plugin-loader.ts +325 -0
  82. package/src/cli/index.ts +71 -0
  83. package/src/cli/server-client.ts +178 -0
  84. package/src/cli/tui/index-tui.tsx +667 -0
  85. package/src/cli/tui/stats-data.ts +523 -0
  86. package/src/cli/tui/stats-search.ts +262 -0
  87. package/src/cli/tui/stats-tui.tsx +1465 -0
  88. package/src/cli/tui/tree-scanner.ts +650 -0
  89. package/src/cli/utils.ts +137 -0
  90. package/src/config.ts +49 -0
  91. package/src/constants.ts +21 -0
  92. package/src/db/adapter.ts +112 -0
  93. package/src/db/metadata.ts +130 -0
  94. package/src/db/migrations.ts +66 -0
  95. package/src/db/sqlite-adapter.ts +218 -0
  96. package/src/db/tracker.ts +91 -0
  97. package/src/engine/index-api.ts +81 -0
  98. package/src/engine/reembed.ts +206 -0
  99. package/src/engine/search-api.ts +218 -0
  100. package/src/index.ts +154 -0
  101. package/src/lib/fts.ts +57 -0
  102. package/src/lib/languages.ts +180 -0
  103. package/src/lib/logger.ts +126 -0
  104. package/src/lib/math.ts +87 -0
  105. package/src/lib/provider-key.ts +20 -0
  106. package/src/lib/prune.ts +71 -0
  107. package/src/lib/rrf.ts +133 -0
  108. package/src/lib/write-lock.ts +108 -0
  109. package/src/mcp/mcp-server.ts +195 -0
  110. package/src/mcp/workspace-factory.ts +68 -0
  111. package/src/mcp/workspace-pool.ts +224 -0
  112. package/src/plugin.ts +381 -0
  113. package/src/providers/embeddings/embedding-worker-thread.ts +95 -0
  114. package/src/providers/embeddings/embedding-worker.ts +141 -0
  115. package/src/providers/embeddings/local-embedding.ts +115 -0
  116. package/src/providers/embeddings/openai-embedding.ts +167 -0
  117. package/src/providers/embeddings/perplexity-context-embedding.ts +195 -0
  118. package/src/providers/embeddings/perplexity-embedding.ts +165 -0
  119. package/src/providers/embeddings/resolve.ts +34 -0
  120. package/src/providers/pruners/haiku-expander.ts +166 -0
  121. package/src/providers/pruners/haiku-pruner.ts +112 -0
  122. package/src/providers/vector/hnsw-index.ts +174 -0
  123. package/src/providers/vector/hnsw-loader.ts +129 -0
  124. package/src/search/bm25-boost.ts +69 -0
  125. package/src/search/context-builder.ts +251 -0
  126. package/src/search/keyword/composite-bm25-search.ts +47 -0
  127. package/src/search/types.ts +37 -0
  128. package/src/search/vector/composite-vector-search.ts +61 -0
  129. package/src/search/vector/mmr.ts +64 -0
  130. package/src/services/collection.ts +384 -0
  131. package/src/services/daemon.ts +87 -0
  132. package/src/services/http-server.ts +336 -0
  133. package/src/services/kv-service.ts +64 -0
  134. package/src/services/plugin-registry.ts +77 -0
  135. package/src/services/watch.ts +340 -0
  136. package/src/services/webhook-server.ts +100 -0
  137. package/src/types.ts +493 -0
@@ -0,0 +1,115 @@
1
+ /**
2
+ * BrainBank — Local Embedding Provider
3
+ *
4
+ * Uses @xenova/transformers with all-MiniLM-L6-v2 (384 dims, WASM).
5
+ * Downloads ~23MB on first use, cached locally.
6
+ * No external API calls — runs entirely in-process.
7
+ */
8
+
9
+ import type { EmbeddingProvider } from '@/types.ts';
10
+
11
+ /** Minimal interface for @xenova/transformers pipeline results. */
12
+ interface XenovaPipelineOutput {
13
+ data: Float32Array;
14
+ }
15
+
16
+ /** Callable pipeline returned by @xenova/transformers. */
17
+ interface XenovaPipeline {
18
+ (texts: string | string[], options: { pooling: string; normalize: boolean }): Promise<XenovaPipelineOutput>;
19
+ }
20
+
21
+ /** Configuration environment of @xenova/transformers. */
22
+ interface XenovaEnv {
23
+ cacheDir: string;
24
+ allowLocalModels: boolean;
25
+ }
26
+
27
+ /** Shape of the @xenova/transformers module used here. */
28
+ interface XenovaModule {
29
+ pipeline(task: string, model: string, options?: { quantized?: boolean }): Promise<XenovaPipeline>;
30
+ env: XenovaEnv;
31
+ }
32
+
33
+ export class LocalEmbedding implements EmbeddingProvider {
34
+ readonly dims: number = 384;
35
+
36
+ private _pipeline: XenovaPipeline | null = null;
37
+ private _modelName: string;
38
+ private _cacheDir: string;
39
+
40
+ constructor(options: { model?: string; cacheDir?: string } = {}) {
41
+ this._modelName = options.model ?? 'Xenova/all-MiniLM-L6-v2';
42
+ this._cacheDir = options.cacheDir ?? '.model-cache';
43
+ }
44
+
45
+ private _pipelinePromise: Promise<XenovaPipeline> | null = null;
46
+
47
+ /**
48
+ * Lazy-load the transformer pipeline.
49
+ * Singleton — created once and reused.
50
+ * Promise-deduped to prevent concurrent downloads.
51
+ */
52
+ private async _getPipeline(): Promise<XenovaPipeline> {
53
+ if (this._pipeline) return this._pipeline;
54
+ if (this._pipelinePromise) return this._pipelinePromise;
55
+
56
+ this._pipelinePromise = (async () => {
57
+ const mod = await import(/* webpackIgnore: true */ '@xenova/transformers' as string) as XenovaModule;
58
+ const { pipeline, env } = mod;
59
+ env.cacheDir = this._cacheDir;
60
+ env.allowLocalModels = true;
61
+
62
+ this._pipeline = await pipeline('feature-extraction', this._modelName, {
63
+ quantized: true,
64
+ });
65
+
66
+ return this._pipeline!;
67
+ })();
68
+
69
+ try {
70
+ return await this._pipelinePromise;
71
+ } finally {
72
+ this._pipelinePromise = null;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Embed a single text string.
78
+ * Returns a normalized Float32Array of length 384.
79
+ */
80
+ async embed(text: string): Promise<Float32Array> {
81
+ const pipe = await this._getPipeline();
82
+ const output = await pipe(text, { pooling: 'mean', normalize: true });
83
+ return output.data as Float32Array;
84
+ }
85
+
86
+ /**
87
+ * Embed multiple texts using real batch processing.
88
+ * Chunks into groups of BATCH_SIZE to balance throughput vs memory.
89
+ */
90
+ async embedBatch(texts: string[]): Promise<Float32Array[]> {
91
+ if (texts.length === 0) return [];
92
+
93
+ const BATCH_SIZE = 32;
94
+ const pipe = await this._getPipeline();
95
+ const results: Float32Array[] = [];
96
+
97
+ for (let i = 0; i < texts.length; i += BATCH_SIZE) {
98
+ const batch = texts.slice(i, i + BATCH_SIZE);
99
+ const output = await pipe(batch, { pooling: 'mean', normalize: true });
100
+
101
+ // output.data is a flat Float32Array — must copy, not view,
102
+ // because the pipeline may reuse the underlying buffer
103
+ for (let j = 0; j < batch.length; j++) {
104
+ const start = j * this.dims;
105
+ results.push(output.data.slice(start, start + this.dims) as Float32Array);
106
+ }
107
+ }
108
+
109
+ return results;
110
+ }
111
+
112
+ async close(): Promise<void> {
113
+ this._pipeline = null;
114
+ }
115
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * BrainBank — OpenAI Embedding Provider
3
+ *
4
+ * Uses OpenAI's embedding API via fetch (no SDK dependency).
5
+ * Supports text-embedding-3-small, text-embedding-3-large, and ada-002.
6
+ *
7
+ * Usage:
8
+ * const brain = new BrainBank({
9
+ * embeddingProvider: new OpenAIEmbedding({ model: 'text-embedding-3-small' }),
10
+ * });
11
+ */
12
+
13
+ import type { EmbeddingProvider } from '@/types.ts';
14
+
15
+ const DEFAULT_MODEL = 'text-embedding-3-small';
16
+ const DEFAULT_DIMS: Record<string, number> = {
17
+ 'text-embedding-3-small': 1536,
18
+ 'text-embedding-3-large': 3072,
19
+ 'text-embedding-ada-002': 1536,
20
+ };
21
+ const API_URL = 'https://api.openai.com/v1/embeddings';
22
+ const MAX_BATCH = 100;
23
+ const REQUEST_TIMEOUT_MS = 30_000;
24
+ const BATCH_DELAY_MS = 100;
25
+
26
+ export interface OpenAIEmbeddingOptions {
27
+ /** OpenAI API key. Falls back to OPENAI_API_KEY env var. */
28
+ apiKey?: string;
29
+ /** Model name. Default: 'text-embedding-3-small' */
30
+ model?: string;
31
+ /** Vector dimensions. If omitted, uses model default. text-embedding-3-* supports custom dims. */
32
+ dims?: number;
33
+ /** Base URL override (for Azure, proxies, etc.) */
34
+ baseUrl?: string;
35
+ /** Request timeout in ms. Default: 30000 */
36
+ timeout?: number;
37
+ }
38
+
39
+ export class OpenAIEmbedding implements EmbeddingProvider {
40
+ readonly dims: number;
41
+
42
+ private _apiKey: string;
43
+ private _model: string;
44
+ private _baseUrl: string;
45
+ private _requestDims: number | undefined;
46
+ private _timeout: number;
47
+
48
+ constructor(options: OpenAIEmbeddingOptions = {}) {
49
+ this._apiKey = options.apiKey ?? process.env.OPENAI_API_KEY ?? '';
50
+ this._model = options.model ?? DEFAULT_MODEL;
51
+ this._baseUrl = options.baseUrl ?? API_URL;
52
+ this._timeout = options.timeout ?? REQUEST_TIMEOUT_MS;
53
+
54
+ // Custom dims only supported by text-embedding-3-*
55
+ if (options.dims && this._model.startsWith('text-embedding-3')) {
56
+ this._requestDims = options.dims;
57
+ this.dims = options.dims;
58
+ } else {
59
+ this.dims = options.dims ?? DEFAULT_DIMS[this._model] ?? 1536;
60
+ }
61
+ }
62
+
63
+ async embed(text: string): Promise<Float32Array> {
64
+ const results = await this._request([text]);
65
+ return results[0];
66
+ }
67
+
68
+ async embedBatch(texts: string[]): Promise<Float32Array[]> {
69
+ if (texts.length === 0) return [];
70
+
71
+ const results: Float32Array[] = [];
72
+
73
+ for (let i = 0; i < texts.length; i += MAX_BATCH) {
74
+ if (i > 0) await sleep(BATCH_DELAY_MS);
75
+ const batch = texts.slice(i, i + MAX_BATCH);
76
+ const embeddings = await this._request(batch);
77
+ results.push(...embeddings);
78
+ }
79
+
80
+ return results;
81
+ }
82
+
83
+ async close(): Promise<void> {
84
+ // No resources to release
85
+ }
86
+
87
+ private _isTokenLimitError(errText: string): boolean {
88
+ return errText.includes('maximum input length') ||
89
+ errText.includes('maximum context length') ||
90
+ errText.includes('too many tokens');
91
+ }
92
+
93
+ private async _request(input: string[], retryDepth: number = 0): Promise<Float32Array[]> {
94
+ if (!this._apiKey) {
95
+ throw new Error('OpenAI API key required. Set OPENAI_API_KEY env var or pass apiKey option.');
96
+ }
97
+
98
+ const MAX_CHARS = 24_000;
99
+ const safeInput = input.map(t => t.length > MAX_CHARS ? t.slice(0, MAX_CHARS) : t);
100
+
101
+ const body: { model: string; input: string[]; dimensions?: number } = {
102
+ model: this._model, input: safeInput,
103
+ };
104
+ if (this._requestDims) body.dimensions = this._requestDims;
105
+
106
+ const controller = new AbortController();
107
+ const timer = setTimeout(() => controller.abort(), this._timeout);
108
+
109
+ let res: Response;
110
+ try {
111
+ res = await fetch(this._baseUrl, {
112
+ method: 'POST',
113
+ headers: {
114
+ 'Content-Type': 'application/json',
115
+ 'Authorization': `Bearer ${this._apiKey}`,
116
+ },
117
+ body: JSON.stringify(body),
118
+ signal: controller.signal,
119
+ });
120
+ } catch (err: unknown) {
121
+ clearTimeout(timer);
122
+ if (err instanceof Error && err.name === 'AbortError') {
123
+ throw new Error(`OpenAI embedding request timed out after ${this._timeout}ms.`);
124
+ }
125
+ throw err;
126
+ } finally {
127
+ clearTimeout(timer);
128
+ }
129
+
130
+ if (!res.ok) {
131
+ return this._handleApiError(res, safeInput, retryDepth);
132
+ }
133
+
134
+ const json = await res.json() as {
135
+ data: Array<{ embedding: number[]; index: number }>;
136
+ };
137
+ return json.data.sort((a, b) => a.index - b.index).map(d => new Float32Array(d.embedding));
138
+ }
139
+
140
+ /** Handle API errors with token-limit retry logic. */
141
+ private async _handleApiError(
142
+ res: Response, safeInput: string[], retryDepth: number,
143
+ ): Promise<Float32Array[]> {
144
+ const err = await res.text();
145
+ const isTokenLimit = res.status === 400 && this._isTokenLimitError(err);
146
+
147
+ // Batch token limit → retry each item individually with aggressive truncation
148
+ if (isTokenLimit && safeInput.length > 1) {
149
+ const results: Float32Array[] = [];
150
+ for (const text of safeInput) {
151
+ const r = await this._request([text.slice(0, 8_000)]);
152
+ results.push(r[0]);
153
+ }
154
+ return results;
155
+ }
156
+ // Single item still failing → truncate to ~2k tokens (max 1 retry)
157
+ if (isTokenLimit && safeInput.length === 1 && retryDepth < 1) {
158
+ return this._request([safeInput[0].slice(0, 6_000)], retryDepth + 1);
159
+ }
160
+ throw new Error(`OpenAI embedding API error (${res.status}): ${err}`);
161
+ }
162
+ }
163
+
164
+ /** Simple delay helper. */
165
+ function sleep(ms: number): Promise<void> {
166
+ return new Promise(resolve => setTimeout(resolve, ms));
167
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * BrainBank — Perplexity Contextualized Embedding Provider
3
+ *
4
+ * Uses Perplexity's contextualized embeddings API for document-aware vectors.
5
+ * Chunks from the same document share context, improving retrieval quality.
6
+ *
7
+ * Models: pplx-embed-context-v1-0.6b (1024d), pplx-embed-context-v1-4b (2560d).
8
+ *
9
+ * Key difference from standard: input is string[][] (docs × chunks) and the
10
+ * response has a nested structure. This provider adapts the flat BrainBank
11
+ * EmbeddingProvider interface to the nested Perplexity API:
12
+ * - embed(text) → wraps as [[text]]
13
+ * - embedBatch(texts) → wraps as [texts] (one "document" of related chunks)
14
+ *
15
+ * Usage:
16
+ * const brain = new BrainBank({
17
+ * embeddingProvider: new PerplexityContextEmbedding(),
18
+ * });
19
+ */
20
+
21
+ import type { EmbeddingProvider } from '@/types.ts';
22
+ import { decodeBase64Int8 } from './perplexity-embedding.ts';
23
+
24
+ const DEFAULT_MODEL = 'pplx-embed-context-v1-4b';
25
+ const DEFAULT_DIMS: Record<string, number> = {
26
+ 'pplx-embed-context-v1-0.6b': 1024,
27
+ 'pplx-embed-context-v1-4b': 2560,
28
+ };
29
+ const API_URL = 'https://api.perplexity.ai/v1/contextualizedembeddings';
30
+ const MAX_BATCH = 100;
31
+ const REQUEST_TIMEOUT_MS = 30_000;
32
+ const BATCH_DELAY_MS = 100;
33
+
34
+ export interface PerplexityContextEmbeddingOptions {
35
+ /** Perplexity API key. Falls back to PERPLEXITY_API_KEY env var. */
36
+ apiKey?: string;
37
+ /** Model name. Default: 'pplx-embed-context-v1-4b' */
38
+ model?: string;
39
+ /** Vector dimensions (Matryoshka reduction). If omitted, uses model default. */
40
+ dims?: number;
41
+ /** Base URL override. */
42
+ baseUrl?: string;
43
+ /** Request timeout in ms. Default: 30000 */
44
+ timeout?: number;
45
+ }
46
+
47
+ export class PerplexityContextEmbedding implements EmbeddingProvider {
48
+ readonly dims: number;
49
+
50
+ private _apiKey: string;
51
+ private _model: string;
52
+ private _baseUrl: string;
53
+ private _requestDims: number | undefined;
54
+ private _timeout: number;
55
+
56
+ constructor(options: PerplexityContextEmbeddingOptions = {}) {
57
+ this._apiKey = options.apiKey ?? process.env.PERPLEXITY_API_KEY ?? '';
58
+ this._model = options.model ?? DEFAULT_MODEL;
59
+ this._baseUrl = options.baseUrl ?? API_URL;
60
+ this._timeout = options.timeout ?? REQUEST_TIMEOUT_MS;
61
+
62
+ if (options.dims) {
63
+ this._requestDims = options.dims;
64
+ this.dims = options.dims;
65
+ } else {
66
+ this.dims = DEFAULT_DIMS[this._model] ?? 2560;
67
+ }
68
+ }
69
+
70
+ /** Embed a single text. Wraps as [[text]] for the contextualized API. */
71
+ async embed(text: string): Promise<Float32Array> {
72
+ const results = await this._request([[text]]);
73
+ return results[0];
74
+ }
75
+
76
+ /**
77
+ * Embed multiple texts as chunks of contextualized documents.
78
+ * Splits into sub-documents to stay under Perplexity's 32k token/doc limit.
79
+ */
80
+ async embedBatch(texts: string[]): Promise<Float32Array[]> {
81
+ if (texts.length === 0) return [];
82
+
83
+ const docs = splitIntoDocuments(texts);
84
+ const results: Float32Array[] = [];
85
+
86
+ for (let i = 0; i < docs.length; i++) {
87
+ if (i > 0) await sleep(BATCH_DELAY_MS);
88
+ const embeddings = await this._request([docs[i]]);
89
+ results.push(...embeddings);
90
+ }
91
+
92
+ return results;
93
+ }
94
+
95
+ async close(): Promise<void> {
96
+ // No resources to release
97
+ }
98
+
99
+ /** Send a contextualized request. Input is string[][] (docs × chunks). */
100
+ private async _request(input: string[][]): Promise<Float32Array[]> {
101
+ if (!this._apiKey) {
102
+ throw new Error(
103
+ 'BrainBank: Perplexity API key required. Set PERPLEXITY_API_KEY env var or pass apiKey option.',
104
+ );
105
+ }
106
+
107
+ const MAX_CHARS = 24_000;
108
+ const safeInput = input.map(doc =>
109
+ doc.map(chunk => chunk.length > MAX_CHARS ? chunk.slice(0, MAX_CHARS) : chunk),
110
+ );
111
+
112
+ const body: Record<string, unknown> = { model: this._model, input: safeInput };
113
+ if (this._requestDims) body.dimensions = this._requestDims;
114
+
115
+ const controller = new AbortController();
116
+ const timer = setTimeout(() => controller.abort(), this._timeout);
117
+
118
+ let res: Response;
119
+ try {
120
+ res = await fetch(this._baseUrl, {
121
+ method: 'POST',
122
+ headers: {
123
+ 'Content-Type': 'application/json',
124
+ 'Authorization': `Bearer ${this._apiKey}`,
125
+ },
126
+ body: JSON.stringify(body),
127
+ signal: controller.signal,
128
+ });
129
+ } catch (err: unknown) {
130
+ clearTimeout(timer);
131
+ if (err instanceof Error && err.name === 'AbortError') {
132
+ throw new Error(`BrainBank: Perplexity contextualized embedding request timed out after ${this._timeout}ms.`);
133
+ }
134
+ throw err;
135
+ } finally {
136
+ clearTimeout(timer);
137
+ }
138
+
139
+ if (!res.ok) {
140
+ const errText = await res.text();
141
+ throw new Error(`BrainBank: Perplexity contextualized embedding API error (${res.status}): ${errText}`);
142
+ }
143
+
144
+ const json = await res.json() as PerplexityContextResponse;
145
+ return flattenContextResponse(json, this.dims);
146
+ }
147
+ }
148
+
149
+
150
+ interface PerplexityContextResponse {
151
+ data: Array<{
152
+ index: number;
153
+ data: Array<{ index: number; embedding: string }>;
154
+ }>;
155
+ }
156
+
157
+ /** Flatten nested doc → chunk response into a single flat array. */
158
+ function flattenContextResponse(json: PerplexityContextResponse, dims: number): Float32Array[] {
159
+ return json.data
160
+ .sort((a, b) => a.index - b.index)
161
+ .flatMap(doc =>
162
+ doc.data
163
+ .sort((a, b) => a.index - b.index)
164
+ .map(chunk => decodeBase64Int8(chunk.embedding, dims)),
165
+ );
166
+ }
167
+
168
+ /**
169
+ * Split chunks into sub-documents that each stay under the 32k token limit.
170
+ * Uses ~4 chars/token estimate with safety margin (~80k chars ≈ ~20k tokens).
171
+ */
172
+ function splitIntoDocuments(texts: string[]): string[][] {
173
+ const MAX_CHARS_PER_DOC = 80_000;
174
+ const docs: string[][] = [];
175
+ let current: string[] = [];
176
+ let currentChars = 0;
177
+
178
+ for (const text of texts) {
179
+ if (current.length > 0 && currentChars + text.length > MAX_CHARS_PER_DOC) {
180
+ docs.push(current);
181
+ current = [];
182
+ currentChars = 0;
183
+ }
184
+ current.push(text);
185
+ currentChars += text.length;
186
+ }
187
+
188
+ if (current.length > 0) docs.push(current);
189
+ return docs;
190
+ }
191
+
192
+ /** Simple delay helper. */
193
+ function sleep(ms: number): Promise<void> {
194
+ return new Promise(resolve => setTimeout(resolve, ms));
195
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * BrainBank — Perplexity Standard Embedding Provider
3
+ *
4
+ * Uses Perplexity's embedding API via fetch (no SDK dependency).
5
+ * Models: pplx-embed-v1-0.6b (1024d) and pplx-embed-v1-4b (2560d).
6
+ *
7
+ * Perplexity returns base64-encoded signed int8 vectors by default.
8
+ * This provider decodes them to Float32Array for HNSW compatibility.
9
+ *
10
+ * Usage:
11
+ * const brain = new BrainBank({
12
+ * embeddingProvider: new PerplexityEmbedding({ model: 'pplx-embed-v1-4b' }),
13
+ * });
14
+ */
15
+
16
+ import type { EmbeddingProvider } from '@/types.ts';
17
+
18
+ const DEFAULT_MODEL = 'pplx-embed-v1-4b';
19
+ const DEFAULT_DIMS: Record<string, number> = {
20
+ 'pplx-embed-v1-0.6b': 1024,
21
+ 'pplx-embed-v1-4b': 2560,
22
+ };
23
+ const API_URL = 'https://api.perplexity.ai/v1/embeddings';
24
+ const MAX_BATCH = 100;
25
+ const REQUEST_TIMEOUT_MS = 30_000;
26
+ const BATCH_DELAY_MS = 100;
27
+
28
+ export interface PerplexityEmbeddingOptions {
29
+ /** Perplexity API key. Falls back to PERPLEXITY_API_KEY env var. */
30
+ apiKey?: string;
31
+ /** Model name. Default: 'pplx-embed-v1-4b' */
32
+ model?: string;
33
+ /** Vector dimensions (Matryoshka reduction). If omitted, uses model default. */
34
+ dims?: number;
35
+ /** Base URL override. */
36
+ baseUrl?: string;
37
+ /** Request timeout in ms. Default: 30000 */
38
+ timeout?: number;
39
+ }
40
+
41
+ export class PerplexityEmbedding implements EmbeddingProvider {
42
+ readonly dims: number;
43
+
44
+ private _apiKey: string;
45
+ private _model: string;
46
+ private _baseUrl: string;
47
+ private _requestDims: number | undefined;
48
+ private _timeout: number;
49
+
50
+ constructor(options: PerplexityEmbeddingOptions = {}) {
51
+ this._apiKey = options.apiKey ?? process.env.PERPLEXITY_API_KEY ?? '';
52
+ this._model = options.model ?? DEFAULT_MODEL;
53
+ this._baseUrl = options.baseUrl ?? API_URL;
54
+ this._timeout = options.timeout ?? REQUEST_TIMEOUT_MS;
55
+
56
+ if (options.dims) {
57
+ this._requestDims = options.dims;
58
+ this.dims = options.dims;
59
+ } else {
60
+ this.dims = DEFAULT_DIMS[this._model] ?? 2560;
61
+ }
62
+ }
63
+
64
+ async embed(text: string): Promise<Float32Array> {
65
+ const results = await this._request([text]);
66
+ return results[0];
67
+ }
68
+
69
+ async embedBatch(texts: string[]): Promise<Float32Array[]> {
70
+ if (texts.length === 0) return [];
71
+
72
+ const results: Float32Array[] = [];
73
+
74
+ for (let i = 0; i < texts.length; i += MAX_BATCH) {
75
+ if (i > 0) await sleep(BATCH_DELAY_MS);
76
+ const batch = texts.slice(i, i + MAX_BATCH);
77
+ const embeddings = await this._request(batch);
78
+ results.push(...embeddings);
79
+ }
80
+
81
+ return results;
82
+ }
83
+
84
+ async close(): Promise<void> {
85
+ // No resources to release
86
+ }
87
+
88
+ private async _request(input: string[]): Promise<Float32Array[]> {
89
+ if (!this._apiKey) {
90
+ throw new Error(
91
+ 'BrainBank: Perplexity API key required. Set PERPLEXITY_API_KEY env var or pass apiKey option.',
92
+ );
93
+ }
94
+
95
+ const MAX_CHARS = 24_000;
96
+ const safeInput = input.map(t => t.length > MAX_CHARS ? t.slice(0, MAX_CHARS) : t);
97
+
98
+ const body: Record<string, unknown> = { model: this._model, input: safeInput };
99
+ if (this._requestDims) body.dimensions = this._requestDims;
100
+
101
+ const controller = new AbortController();
102
+ const timer = setTimeout(() => controller.abort(), this._timeout);
103
+
104
+ let res: Response;
105
+ try {
106
+ res = await fetch(this._baseUrl, {
107
+ method: 'POST',
108
+ headers: {
109
+ 'Content-Type': 'application/json',
110
+ 'Authorization': `Bearer ${this._apiKey}`,
111
+ },
112
+ body: JSON.stringify(body),
113
+ signal: controller.signal,
114
+ });
115
+ } catch (err: unknown) {
116
+ clearTimeout(timer);
117
+ if (err instanceof Error && err.name === 'AbortError') {
118
+ throw new Error(`BrainBank: Perplexity embedding request timed out after ${this._timeout}ms.`);
119
+ }
120
+ throw err;
121
+ } finally {
122
+ clearTimeout(timer);
123
+ }
124
+
125
+ if (!res.ok) {
126
+ const errText = await res.text();
127
+ throw new Error(`BrainBank: Perplexity embedding API error (${res.status}): ${errText}`);
128
+ }
129
+
130
+ const json = await res.json() as PerplexityStandardResponse;
131
+ return json.data
132
+ .sort((a, b) => a.index - b.index)
133
+ .map(d => decodeBase64Int8(d.embedding, this.dims));
134
+ }
135
+ }
136
+
137
+
138
+ interface PerplexityStandardResponse {
139
+ data: Array<{ index: number; embedding: string }>;
140
+ }
141
+
142
+
143
+ /**
144
+ * Decode a base64-encoded signed int8 embedding to Float32Array.
145
+ * Perplexity returns embeddings as base64(int8[]) by default.
146
+ */
147
+ export function decodeBase64Int8(b64: string, expectedDims: number): Float32Array {
148
+ const binary = atob(b64);
149
+ const bytes = new Int8Array(binary.length);
150
+ for (let i = 0; i < binary.length; i++) {
151
+ bytes[i] = binary.charCodeAt(i) << 24 >> 24; // sign-extend to int8
152
+ }
153
+
154
+ const dims = Math.min(bytes.length, expectedDims);
155
+ const result = new Float32Array(dims);
156
+ for (let i = 0; i < dims; i++) {
157
+ result[i] = bytes[i];
158
+ }
159
+ return result;
160
+ }
161
+
162
+ /** Simple delay helper. */
163
+ function sleep(ms: number): Promise<void> {
164
+ return new Promise(resolve => setTimeout(resolve, ms));
165
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * BrainBank — Embedding Provider Resolver
3
+ *
4
+ * Resolves an EmbeddingProvider from a stored key string.
5
+ * Used by the Initializer to auto-resolve from DB config.
6
+ */
7
+
8
+ import type { EmbeddingProvider } from '@/types.ts';
9
+
10
+ /** Re-export providerKey from lib/ (canonical location). */
11
+ export { providerKey, type EmbeddingKey } from '@/lib/provider-key.ts';
12
+
13
+ /** Resolve an EmbeddingProvider from a key string. Lazy-loads the provider module. */
14
+ export async function resolveEmbedding(key: string): Promise<EmbeddingProvider> {
15
+ switch (key) {
16
+ case 'openai': {
17
+ const { OpenAIEmbedding } = await import('./openai-embedding.ts');
18
+ return new OpenAIEmbedding();
19
+ }
20
+ case 'perplexity': {
21
+ const { PerplexityEmbedding } = await import('./perplexity-embedding.ts');
22
+ return new PerplexityEmbedding();
23
+ }
24
+ case 'perplexity-context': {
25
+ const { PerplexityContextEmbedding } = await import('./perplexity-context-embedding.ts');
26
+ return new PerplexityContextEmbedding();
27
+ }
28
+ case 'local':
29
+ default: {
30
+ const { LocalEmbedding } = await import('./local-embedding.ts');
31
+ return new LocalEmbedding();
32
+ }
33
+ }
34
+ }