brainbank 0.7.0 → 0.8.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.
Files changed (156) hide show
  1. package/README.md +76 -1398
  2. package/bin/brainbank +5 -1
  3. package/dist/{chunk-N2OJRXSB.js → chunk-3HVCONGF.js} +1 -1
  4. package/dist/{chunk-N2OJRXSB.js.map → chunk-3HVCONGF.js.map} +1 -1
  5. package/dist/{chunk-CCXVL56V.js → chunk-3JZIM5AU.js} +6 -3
  6. package/dist/chunk-3JZIM5AU.js.map +1 -0
  7. package/dist/{chunk-6XOXM7MI.js → chunk-5KU2PP34.js} +2 -2
  8. package/dist/{chunk-6XOXM7MI.js.map → chunk-5KU2PP34.js.map} +1 -1
  9. package/dist/chunk-7JDCHUJV.js +89 -0
  10. package/dist/chunk-7JDCHUJV.js.map +1 -0
  11. package/dist/{chunk-B77KABWH.js → chunk-7T2ZCZQA.js} +17 -15
  12. package/dist/chunk-7T2ZCZQA.js.map +1 -0
  13. package/dist/chunk-E3J37GDA.js +74 -0
  14. package/dist/chunk-E3J37GDA.js.map +1 -0
  15. package/dist/chunk-JEFWMS5Z.js +217 -0
  16. package/dist/chunk-JEFWMS5Z.js.map +1 -0
  17. package/dist/chunk-JRVYSTMP.js +3256 -0
  18. package/dist/chunk-JRVYSTMP.js.map +1 -0
  19. package/dist/chunk-OLDHLOMT.js +69 -0
  20. package/dist/chunk-OLDHLOMT.js.map +1 -0
  21. package/dist/{chunk-424UFCY7.js → chunk-QTZNB6AK.js} +6 -2
  22. package/dist/chunk-QTZNB6AK.js.map +1 -0
  23. package/dist/chunk-RFF7HMP6.js +109 -0
  24. package/dist/chunk-RFF7HMP6.js.map +1 -0
  25. package/dist/{chunk-ZNLN2VWV.js → chunk-TD3TEFI3.js} +1 -1
  26. package/dist/chunk-TD3TEFI3.js.map +1 -0
  27. package/dist/cli.js +1036 -341
  28. package/dist/cli.js.map +1 -1
  29. package/dist/haiku-expander-WOVJIVXD.js +8 -0
  30. package/dist/haiku-pruner-DB77ZQLJ.js +8 -0
  31. package/dist/http-server-GIRELCCL.js +9 -0
  32. package/dist/index.d.ts +1774 -611
  33. package/dist/index.js +282 -70
  34. package/dist/index.js.map +1 -1
  35. package/dist/{local-embedding-ZIMTK6PU.js → local-embedding-2RNCC5EU.js} +2 -2
  36. package/dist/{openai-embedding-VQZCZQYT.js → openai-embedding-Z5I4K4CN.js} +2 -2
  37. package/dist/perplexity-context-embedding-V5YUMXDR.js +9 -0
  38. package/dist/{perplexity-embedding-227WQY4R.js → perplexity-embedding-X2S72OAC.js} +2 -2
  39. package/dist/plugin-FF4Q34TI.js +32 -0
  40. package/dist/{qwen3-reranker-3MHEENT5.js → qwen3-reranker-HVIQOLKS.js} +2 -2
  41. package/dist/{resolve-CUJWY6HP.js → resolve-Q5D6HECY.js} +2 -2
  42. package/package.json +25 -52
  43. package/src/brainbank.ts +620 -0
  44. package/src/cli/commands/collection.ts +77 -0
  45. package/src/cli/commands/context.ts +171 -0
  46. package/src/cli/commands/daemon.ts +100 -0
  47. package/src/cli/commands/docs.ts +71 -0
  48. package/src/cli/commands/files.ts +69 -0
  49. package/src/cli/commands/help.ts +72 -0
  50. package/src/cli/commands/index.ts +282 -0
  51. package/src/cli/commands/kv.ts +140 -0
  52. package/src/cli/commands/mcp.ts +13 -0
  53. package/src/cli/commands/reembed.ts +30 -0
  54. package/src/cli/commands/scan.ts +365 -0
  55. package/src/cli/commands/search.ts +130 -0
  56. package/src/cli/commands/stats.ts +44 -0
  57. package/src/cli/commands/status.ts +47 -0
  58. package/src/cli/commands/watch.ts +43 -0
  59. package/src/cli/factory/brain-context.ts +43 -0
  60. package/src/cli/factory/builtin-registration.ts +123 -0
  61. package/src/cli/factory/config-loader.ts +72 -0
  62. package/src/cli/factory/index.ts +65 -0
  63. package/src/cli/factory/plugin-loader.ts +146 -0
  64. package/src/cli/index.ts +63 -0
  65. package/src/cli/server-client.ts +135 -0
  66. package/src/cli/utils.ts +121 -0
  67. package/src/config.ts +50 -0
  68. package/src/constants.ts +13 -0
  69. package/src/db/adapter.ts +112 -0
  70. package/src/db/metadata.ts +130 -0
  71. package/src/db/migrations.ts +66 -0
  72. package/src/db/sqlite-adapter.ts +208 -0
  73. package/src/db/tracker.ts +91 -0
  74. package/src/engine/index-api.ts +85 -0
  75. package/src/engine/reembed.ts +206 -0
  76. package/src/engine/search-api.ts +222 -0
  77. package/src/index.ts +159 -0
  78. package/src/lib/fts.ts +57 -0
  79. package/src/lib/languages.ts +180 -0
  80. package/src/lib/logger.ts +125 -0
  81. package/src/lib/math.ts +87 -0
  82. package/src/lib/provider-key.ts +20 -0
  83. package/src/lib/prune.ts +71 -0
  84. package/src/lib/rerank.ts +33 -0
  85. package/src/lib/rrf.ts +133 -0
  86. package/src/lib/write-lock.ts +108 -0
  87. package/src/plugin.ts +323 -0
  88. package/src/providers/embeddings/embedding-worker-thread.ts +95 -0
  89. package/src/providers/embeddings/embedding-worker.ts +141 -0
  90. package/src/providers/embeddings/local-embedding.ts +115 -0
  91. package/src/providers/embeddings/openai-embedding.ts +167 -0
  92. package/src/providers/embeddings/perplexity-context-embedding.ts +195 -0
  93. package/src/providers/embeddings/perplexity-embedding.ts +165 -0
  94. package/src/providers/embeddings/resolve.ts +34 -0
  95. package/src/providers/pruners/haiku-expander.ts +152 -0
  96. package/src/providers/pruners/haiku-pruner.ts +112 -0
  97. package/src/providers/rerankers/qwen3-reranker.ts +180 -0
  98. package/src/providers/vector/hnsw-index.ts +174 -0
  99. package/src/providers/vector/hnsw-loader.ts +129 -0
  100. package/src/search/bm25-boost.ts +61 -0
  101. package/src/search/context-builder.ts +298 -0
  102. package/src/search/keyword/composite-bm25-search.ts +62 -0
  103. package/src/search/types.ts +35 -0
  104. package/src/search/vector/composite-vector-search.ts +76 -0
  105. package/src/search/vector/mmr.ts +64 -0
  106. package/src/services/collection.ts +405 -0
  107. package/src/services/daemon.ts +87 -0
  108. package/src/services/http-server.ts +288 -0
  109. package/src/services/kv-service.ts +65 -0
  110. package/src/services/plugin-registry.ts +109 -0
  111. package/src/services/watch.ts +348 -0
  112. package/src/services/webhook-server.ts +100 -0
  113. package/src/types.ts +504 -0
  114. package/dist/base-3SNc_CeY.d.ts +0 -593
  115. package/dist/chunk-424UFCY7.js.map +0 -1
  116. package/dist/chunk-7EZR47JV.js +0 -232
  117. package/dist/chunk-7EZR47JV.js.map +0 -1
  118. package/dist/chunk-B77KABWH.js.map +0 -1
  119. package/dist/chunk-CCXVL56V.js.map +0 -1
  120. package/dist/chunk-DI3H6JVZ.js +0 -2432
  121. package/dist/chunk-DI3H6JVZ.js.map +0 -1
  122. package/dist/chunk-FGL32LUJ.js +0 -754
  123. package/dist/chunk-FGL32LUJ.js.map +0 -1
  124. package/dist/chunk-JRSKWF6K.js +0 -313
  125. package/dist/chunk-JRSKWF6K.js.map +0 -1
  126. package/dist/chunk-U2Q2XGPZ.js +0 -42
  127. package/dist/chunk-U2Q2XGPZ.js.map +0 -1
  128. package/dist/chunk-VQ27YUHH.js +0 -629
  129. package/dist/chunk-VQ27YUHH.js.map +0 -1
  130. package/dist/chunk-VVXYZIIB.js +0 -304
  131. package/dist/chunk-VVXYZIIB.js.map +0 -1
  132. package/dist/chunk-YOLKSYWK.js +0 -79
  133. package/dist/chunk-YOLKSYWK.js.map +0 -1
  134. package/dist/chunk-ZNLN2VWV.js.map +0 -1
  135. package/dist/code.d.ts +0 -33
  136. package/dist/code.js +0 -9
  137. package/dist/docs.d.ts +0 -21
  138. package/dist/docs.js +0 -9
  139. package/dist/git.d.ts +0 -33
  140. package/dist/git.js +0 -9
  141. package/dist/memory.d.ts +0 -17
  142. package/dist/memory.js +0 -9
  143. package/dist/notes.d.ts +0 -17
  144. package/dist/notes.js +0 -10
  145. package/dist/perplexity-context-embedding-KSVSZXMD.js +0 -9
  146. package/dist/resolve-CUJWY6HP.js.map +0 -1
  147. /package/dist/{code.js.map → haiku-expander-WOVJIVXD.js.map} +0 -0
  148. /package/dist/{docs.js.map → haiku-pruner-DB77ZQLJ.js.map} +0 -0
  149. /package/dist/{git.js.map → http-server-GIRELCCL.js.map} +0 -0
  150. /package/dist/{local-embedding-ZIMTK6PU.js.map → local-embedding-2RNCC5EU.js.map} +0 -0
  151. /package/dist/{memory.js.map → openai-embedding-Z5I4K4CN.js.map} +0 -0
  152. /package/dist/{notes.js.map → perplexity-context-embedding-V5YUMXDR.js.map} +0 -0
  153. /package/dist/{openai-embedding-VQZCZQYT.js.map → perplexity-embedding-X2S72OAC.js.map} +0 -0
  154. /package/dist/{perplexity-context-embedding-KSVSZXMD.js.map → plugin-FF4Q34TI.js.map} +0 -0
  155. /package/dist/{perplexity-embedding-227WQY4R.js.map → qwen3-reranker-HVIQOLKS.js.map} +0 -0
  156. /package/dist/{qwen3-reranker-3MHEENT5.js.map → resolve-Q5D6HECY.js.map} +0 -0
@@ -0,0 +1,64 @@
1
+ /**
2
+ * BrainBank — Maximum Marginal Relevance (MMR)
3
+ *
4
+ * Diversifies vector search results to avoid returning redundant items.
5
+ * λ=1.0 → pure relevance, λ=0.0 → pure diversity.
6
+ * Default λ=0.7 balances both.
7
+ */
8
+
9
+ import type { VectorIndex, SearchHit } from '@/types.ts';
10
+ import { cosineSimilarity } from '@/lib/math.ts';
11
+
12
+ /**
13
+ * Search with Maximum Marginal Relevance for diversified results.
14
+ *
15
+ * Algorithm:
16
+ * 1. Get 3x candidates from HNSW
17
+ * 2. Greedily select items that maximize: λ * relevance - (1-λ) * max_sim_to_selected
18
+ */
19
+ export function searchMMR(
20
+ index: VectorIndex,
21
+ query: Float32Array,
22
+ vectorCache: Map<number, Float32Array>,
23
+ k: number,
24
+ lambda: number = 0.7,
25
+ ): SearchHit[] {
26
+ // Get more candidates than needed
27
+ const candidates = index.search(query, k * 3);
28
+ if (candidates.length <= k) return candidates;
29
+
30
+ const selected: SearchHit[] = [];
31
+ const remaining = [...candidates];
32
+
33
+ while (selected.length < k && remaining.length > 0) {
34
+ let bestScore = -Infinity;
35
+ let bestIdx = 0;
36
+
37
+ for (let i = 0; i < remaining.length; i++) {
38
+ const relevance = remaining[i].score;
39
+
40
+ // Max similarity to any already-selected item
41
+ let maxSim = 0;
42
+ for (const sel of selected) {
43
+ const candidateVec = vectorCache.get(remaining[i].id);
44
+ const selectedVec = vectorCache.get(sel.id);
45
+ if (candidateVec && selectedVec) {
46
+ maxSim = Math.max(maxSim, cosineSimilarity(candidateVec, selectedVec));
47
+ }
48
+ }
49
+
50
+ // MMR score: balance relevance vs diversity
51
+ const mmrScore = lambda * relevance - (1 - lambda) * maxSim;
52
+
53
+ if (mmrScore > bestScore) {
54
+ bestScore = mmrScore;
55
+ bestIdx = i;
56
+ }
57
+ }
58
+
59
+ selected.push(remaining[bestIdx]);
60
+ remaining.splice(bestIdx, 1);
61
+ }
62
+
63
+ return selected;
64
+ }
@@ -0,0 +1,405 @@
1
+ /**
2
+ * BrainBank — Collection
3
+ *
4
+ * Universal key-value store with vector + BM25 hybrid search.
5
+ * The foundation primitive — store anything, search semantically.
6
+ *
7
+ * const errors = brain.collection('debug_errors');
8
+ * await errors.add('Fixed null check in api handler', { file: 'api.ts' });
9
+ * const hits = await errors.search('null pointer');
10
+ */
11
+
12
+ import type { DatabaseAdapter, KvDataRow, CountRow } from '@/db/adapter.ts';
13
+ import type { HNSWIndex } from '@/providers/vector/hnsw-index.ts';
14
+ import type { EmbeddingProvider, Reranker, SearchResult } from '@/types.ts';
15
+
16
+ import { sanitizeFTS, normalizeBM25 } from '@/lib/fts.ts';
17
+ import { vecToBuffer } from '@/lib/math.ts';
18
+ import { rerank } from '@/lib/rerank.ts';
19
+ import { fuseRankedLists } from '@/lib/rrf.ts';
20
+
21
+ export interface CollectionItem {
22
+ id: number;
23
+ collection: string;
24
+ content: string;
25
+ metadata: Record<string, unknown>;
26
+ tags: string[];
27
+ createdAt: number;
28
+ expiresAt?: number;
29
+ score?: number;
30
+ }
31
+
32
+ export interface CollectionSearchOptions {
33
+ /** Max results. Default: 5 */
34
+ k?: number;
35
+ /** Search mode. Default: 'hybrid' */
36
+ mode?: 'hybrid' | 'vector' | 'keyword';
37
+ /** Minimum score threshold. Default: 0.15 */
38
+ minScore?: number;
39
+ /** Filter by tags (item must have ALL specified tags). */
40
+ tags?: string[];
41
+ }
42
+
43
+ export interface CollectionAddOptions {
44
+ /** Metadata key-value pairs. */
45
+ metadata?: Record<string, unknown>;
46
+ /** Tags for filtering. */
47
+ tags?: string[];
48
+ /** Time-to-live duration string (e.g. '7d', '24h', '30m'). */
49
+ ttl?: string;
50
+ }
51
+
52
+ export class Collection {
53
+ constructor(
54
+ private _name: string,
55
+ private _db: DatabaseAdapter,
56
+ private _embedding: EmbeddingProvider,
57
+ private _hnsw: HNSWIndex,
58
+ private _vecs: Map<number, Float32Array>,
59
+ private _reranker?: Reranker,
60
+ ) {}
61
+
62
+ /** Collection name. */
63
+ get name(): string { return this._name; }
64
+
65
+ /** Add an item. Returns its ID. */
66
+ async add(content: string, options: CollectionAddOptions | Record<string, unknown> = {}): Promise<number> {
67
+ // Support both signatures: add(content, { metadata, tags, ttl }) and add(content, metadata)
68
+ const opts = 'tags' in options || 'ttl' in options || 'metadata' in options
69
+ ? options as CollectionAddOptions
70
+ : { metadata: options as Record<string, unknown> };
71
+
72
+ const metadata = opts.metadata ?? {};
73
+ const tags = opts.tags ?? [];
74
+ const expiresAt = opts.ttl ? Math.floor(Date.now() / 1000) + parseDuration(opts.ttl) : null;
75
+
76
+ // Embed FIRST — if this throws, no orphaned rows are left in kv_data
77
+ const vec = await this._embedding.embed(content);
78
+
79
+ const result = this._db.prepare(
80
+ 'INSERT INTO kv_data (collection, content, meta_json, tags_json, expires_at) VALUES (?, ?, ?, ?, ?)'
81
+ ).run(this._name, content, JSON.stringify(metadata), JSON.stringify(tags), expiresAt);
82
+
83
+ const id = Number(result.lastInsertRowid);
84
+ this._db.prepare(
85
+ 'INSERT INTO kv_vectors (data_id, embedding) VALUES (?, ?)'
86
+ ).run(id, vecToBuffer(vec));
87
+
88
+ this._hnsw.add(vec, id);
89
+ this._vecs.set(id, vec);
90
+
91
+ return id;
92
+ }
93
+
94
+ /** Update an item's content (re-embeds). Returns the new ID. */
95
+ async update(id: number, content: string, options?: CollectionAddOptions): Promise<number> {
96
+ const row = this._db.prepare(
97
+ 'SELECT * FROM kv_data WHERE id = ? AND collection = ?'
98
+ ).get(id, this._name) as KvDataRow | undefined;
99
+
100
+ if (!row) throw new Error(`BrainBank: Item ${id} not found in collection '${this._name}'.`);
101
+
102
+ // Merge: keep original metadata/tags unless overridden
103
+ const metadata = options?.metadata ?? JSON.parse(row.meta_json || '{}');
104
+ const tags = options?.tags ?? JSON.parse(row.tags_json || '[]');
105
+ const ttl = options?.ttl;
106
+
107
+ this._removeById(id);
108
+ return this.add(content, { metadata, tags, ...(ttl ? { ttl } : {}) });
109
+ }
110
+
111
+ /** Add multiple items. Returns their IDs. */
112
+ async addMany(items: { content: string; metadata?: Record<string, unknown>; tags?: string[]; ttl?: string }[]): Promise<number[]> {
113
+ if (items.length === 0) return [];
114
+
115
+ // Batch embed all texts at once
116
+ const texts = items.map(i => i.content);
117
+ const vecs = await this._embedding.embedBatch(texts);
118
+
119
+ // Commit DB rows atomically. HNSW is updated ONLY after this succeeds.
120
+ // If the transaction throws, execution never reaches the HNSW loop below.
121
+ const ids: number[] = [];
122
+ const insertData = this._db.prepare(
123
+ 'INSERT INTO kv_data (collection, content, meta_json, tags_json, expires_at) VALUES (?, ?, ?, ?, ?)'
124
+ );
125
+ const insertVec = this._db.prepare(
126
+ 'INSERT INTO kv_vectors (data_id, embedding) VALUES (?, ?)'
127
+ );
128
+
129
+ this._db.transaction(() => {
130
+ for (let i = 0; i < items.length; i++) {
131
+ const item = items[i];
132
+ const expiresAt = item.ttl ? Math.floor(Date.now() / 1000) + parseDuration(item.ttl) : null;
133
+
134
+ const result = insertData.run(
135
+ this._name,
136
+ item.content,
137
+ JSON.stringify(item.metadata ?? {}),
138
+ JSON.stringify(item.tags ?? []),
139
+ expiresAt,
140
+ );
141
+
142
+ const id = Number(result.lastInsertRowid);
143
+ insertVec.run(id, vecToBuffer(vecs[i]));
144
+ ids.push(id);
145
+ }
146
+ });
147
+
148
+ // HNSW + cache updated after successful commit — no orphan risk on rollback.
149
+ for (let i = 0; i < ids.length; i++) {
150
+ this._hnsw.add(vecs[i], ids[i]);
151
+ this._vecs.set(ids[i], vecs[i]);
152
+ }
153
+
154
+ return ids;
155
+ }
156
+
157
+ /** Search this collection. */
158
+ async search(query: string, options: CollectionSearchOptions = {}): Promise<CollectionItem[]> {
159
+ const { k = 5, mode = 'hybrid', minScore = 0.15, tags } = options;
160
+
161
+ // Auto-prune expired items before search
162
+ this._pruneExpired();
163
+
164
+ if (mode === 'keyword') return this._filterByTags(this._searchBM25(query, k, minScore), tags);
165
+ if (mode === 'vector') return this._filterByTags(await this._searchVector(query, k, minScore), tags);
166
+
167
+ // Hybrid: vector + BM25 → generic RRF (no SearchResult conversion)
168
+ const [vectorHits, bm25Hits] = await Promise.all([
169
+ this._searchVector(query, k, 0),
170
+ Promise.resolve(this._searchBM25(query, k, 0)),
171
+ ]);
172
+
173
+ const fused = fuseRankedLists(
174
+ [vectorHits, bm25Hits],
175
+ h => String(h.id),
176
+ h => h.score ?? 0,
177
+ );
178
+
179
+ const results: CollectionItem[] = fused
180
+ .map(({ item, score }) => ({ ...item, score }))
181
+ .filter(r => r.score >= minScore)
182
+ .slice(0, k);
183
+
184
+ // Apply re-ranking if available
185
+ if (this._reranker && results.length > 1) {
186
+ const asSearchResults: SearchResult[] = results.map(r => ({
187
+ type: 'collection' as const,
188
+ score: r.score ?? 0,
189
+ content: r.content,
190
+ metadata: { id: r.id },
191
+ }));
192
+ const reranked = await rerank(query, asSearchResults, this._reranker);
193
+ const rerankedById = new Map(
194
+ reranked.map(r => [r.type === 'collection' ? r.metadata.id : undefined, r.score]),
195
+ );
196
+ const blended = results.map(r => ({ ...r, score: rerankedById.get(r.id) ?? r.score ?? 0 }));
197
+ return this._filterByTags(
198
+ blended.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)),
199
+ tags,
200
+ );
201
+ }
202
+
203
+ return this._filterByTags(results, tags);
204
+ }
205
+
206
+ /** Search and return results as SearchResult[] for use in hybrid search pipelines. */
207
+ async searchAsResults(query: string, k: number): Promise<SearchResult[]> {
208
+ const hits = await this.search(query, { k });
209
+ return hits.map(h => ({
210
+ type: 'collection' as const,
211
+ score: h.score ?? 0,
212
+ content: h.content,
213
+ metadata: { ...h.metadata, id: h.id, collection: this._name },
214
+ }));
215
+ }
216
+
217
+ /** List items (newest first). */
218
+ list(options: { limit?: number; offset?: number; tags?: string[] } = {}): CollectionItem[] {
219
+ const { limit = 20, offset = 0, tags } = options;
220
+
221
+ // Auto-prune expired items
222
+ this._pruneExpired();
223
+
224
+ const rows = this._db.prepare(
225
+ 'SELECT * FROM kv_data WHERE collection = ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?'
226
+ ).all(this._name, Math.floor(Date.now() / 1000), limit, offset) as KvDataRow[];
227
+ return this._filterByTags(rows.map(r => this._rowToItem(r)), tags);
228
+ }
229
+
230
+ /** Count items in this collection. */
231
+ count(): number {
232
+ return (this._db.prepare(
233
+ 'SELECT COUNT(*) as c FROM kv_data WHERE collection = ? AND (expires_at IS NULL OR expires_at > ?)'
234
+ ).get(this._name, Math.floor(Date.now() / 1000)) as CountRow).c;
235
+ }
236
+
237
+ /** Keep only the N most recent items, remove the rest. */
238
+ async trim(options: { keep: number }): Promise<{ removed: number }> {
239
+ const before = this.count();
240
+ if (before <= options.keep) return { removed: 0 };
241
+
242
+ // Get IDs to remove (oldest first, beyond the keep window)
243
+ const toRemove = this._db.prepare(`
244
+ SELECT id FROM kv_data
245
+ WHERE collection = ?
246
+ ORDER BY created_at DESC, id DESC
247
+ LIMIT -1 OFFSET ?
248
+ `).all(this._name, options.keep) as Pick<KvDataRow, 'id'>[];
249
+
250
+ for (const row of toRemove) {
251
+ this._removeById(row.id);
252
+ }
253
+
254
+ return { removed: toRemove.length };
255
+ }
256
+
257
+ /** Remove items older than a duration string (e.g. '30d', '12h'). */
258
+ async prune(options: { olderThan: string }): Promise<{ removed: number }> {
259
+ const seconds = parseDuration(options.olderThan);
260
+ const cutoff = Math.floor(Date.now() / 1000) - seconds;
261
+
262
+ const toRemove = this._db.prepare(
263
+ 'SELECT id FROM kv_data WHERE collection = ? AND created_at < ?'
264
+ ).all(this._name, cutoff) as Pick<KvDataRow, 'id'>[];
265
+
266
+ for (const row of toRemove) {
267
+ this._removeById(row.id);
268
+ }
269
+
270
+ return { removed: toRemove.length };
271
+ }
272
+
273
+ /** Remove a specific item by ID. */
274
+ remove(id: number): void {
275
+ this._removeById(id);
276
+ }
277
+
278
+ /** Clear all items in this collection. */
279
+ clear(): void {
280
+ const rows = this._db.prepare(
281
+ 'SELECT id FROM kv_data WHERE collection = ?'
282
+ ).all(this._name) as Pick<KvDataRow, 'id'>[];
283
+
284
+ for (const row of rows) {
285
+ this._removeById(row.id);
286
+ }
287
+ }
288
+
289
+
290
+ private _removeById(id: number): void {
291
+ // DB first — can fail (disk full, lock). If it throws, HNSW+cache stay consistent.
292
+ this._db.prepare('DELETE FROM kv_data WHERE id = ?').run(id);
293
+ // HNSW + cache after — these always succeed
294
+ this._hnsw.remove(id);
295
+ this._vecs.delete(id);
296
+ }
297
+
298
+ private async _searchVector(query: string, k: number, minScore: number): Promise<CollectionItem[]> {
299
+ if (this._hnsw.size === 0) return [];
300
+
301
+ const queryVec = await this._embedding.embed(query);
302
+ // Adaptive over-fetch: proportional to total/collection density, clamped [3, 50]
303
+ const searchK = this._adaptiveSearchK(k);
304
+ const hits = this._hnsw.search(queryVec, searchK);
305
+
306
+ const ids = hits.map(h => h.id);
307
+ if (ids.length === 0) return [];
308
+
309
+ const scoreMap = new Map(hits.map(h => [h.id, h.score]));
310
+ const placeholders = ids.map(() => '?').join(',');
311
+
312
+ const rows = this._db.prepare(
313
+ `SELECT * FROM kv_data WHERE id IN (${placeholders}) AND collection = ?`
314
+ ).all(...ids, this._name) as KvDataRow[];
315
+
316
+ return rows
317
+ .map(r => ({ ...this._rowToItem(r), score: scoreMap.get(r.id) ?? 0 }))
318
+ .filter(r => r.score >= minScore)
319
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
320
+ .slice(0, k);
321
+ }
322
+
323
+ /** Compute adaptive over-fetch multiplier based on collection density in shared HNSW. */
324
+ private _adaptiveSearchK(k: number): number {
325
+ const totalSize = this._hnsw.size;
326
+ if (totalSize === 0) return 0;
327
+ const collectionCount = this.count();
328
+ if (collectionCount === 0) return Math.min(k * 3, totalSize);
329
+ const ratio = Math.ceil(totalSize / collectionCount);
330
+ const multiplier = Math.max(3, Math.min(ratio, 50));
331
+ return Math.min(k * multiplier, totalSize);
332
+ }
333
+
334
+ private _searchBM25(query: string, k: number, minScore: number): CollectionItem[] {
335
+ const ftsQuery = sanitizeFTS(query);
336
+ if (!ftsQuery) return [];
337
+
338
+ try {
339
+ const rows = this._db.prepare(`
340
+ SELECT d.*, bm25(fts_kv, 5.0, 1.0) AS score
341
+ FROM fts_kv f
342
+ JOIN kv_data d ON d.id = f.rowid
343
+ WHERE fts_kv MATCH ? AND d.collection = ?
344
+ ORDER BY score ASC
345
+ LIMIT ?
346
+ `).all(ftsQuery, this._name, k) as (KvDataRow & { score: number })[];
347
+
348
+ return rows
349
+ .map(r => ({
350
+ ...this._rowToItem(r),
351
+ score: normalizeBM25(r.score),
352
+ }))
353
+ .filter(r => (r.score ?? 0) >= minScore);
354
+ } catch {
355
+ return [];
356
+ }
357
+ }
358
+
359
+ private _rowToItem(r: KvDataRow): CollectionItem {
360
+ return {
361
+ id: r.id,
362
+ collection: r.collection,
363
+ content: r.content,
364
+ metadata: JSON.parse(r.meta_json || '{}') as Record<string, unknown>,
365
+ tags: JSON.parse(r.tags_json || '[]') as string[],
366
+ createdAt: r.created_at,
367
+ expiresAt: r.expires_at ?? undefined,
368
+ };
369
+ }
370
+
371
+ /** Filter results by tags (item must have ALL specified tags). */
372
+ private _filterByTags(items: CollectionItem[], tags?: string[]): CollectionItem[] {
373
+ if (!tags || tags.length === 0) return items;
374
+ return items.filter(item =>
375
+ tags.every(t => item.tags.includes(t))
376
+ );
377
+ }
378
+
379
+ /** Remove expired items (TTL). Called automatically on search/list. */
380
+ private _pruneExpired(): void {
381
+ const now = Math.floor(Date.now() / 1000);
382
+ const expired = this._db.prepare(
383
+ 'SELECT id FROM kv_data WHERE collection = ? AND expires_at IS NOT NULL AND expires_at <= ?'
384
+ ).all(this._name, now) as Pick<KvDataRow, 'id'>[];
385
+
386
+ for (const row of expired) {
387
+ this._removeById(row.id);
388
+ }
389
+ }
390
+ }
391
+
392
+ /** Parse a duration string like '30d', '12h', '5m' to seconds. */
393
+ function parseDuration(s: string): number {
394
+ const match = s.match(/^(\d+)([dhms])$/);
395
+ if (!match) throw new Error(`Invalid duration: "${s}". Use format like '30d', '12h', '5m'.`);
396
+
397
+ const n = parseInt(match[1], 10);
398
+ switch (match[2]) {
399
+ case 'd': return n * 86400;
400
+ case 'h': return n * 3600;
401
+ case 'm': return n * 60;
402
+ case 's': return n;
403
+ default: return n;
404
+ }
405
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Daemon — PID file management for the BrainBank HTTP server.
3
+ *
4
+ * PID file: ~/.cache/brainbank/server.pid
5
+ * Format: JSON { pid: number, port: number }
6
+ *
7
+ * Used by:
8
+ * - `brainbank daemon` to write PID on start
9
+ * - `brainbank daemon stop` to find and kill the daemon
10
+ * - `brainbank status` to report server state
11
+ * - CLI commands to detect running server for delegation
12
+ */
13
+
14
+ import * as fs from 'node:fs';
15
+ import * as path from 'node:path';
16
+ import * as os from 'node:os';
17
+
18
+ export const DEFAULT_PORT = 8181;
19
+
20
+ interface PidInfo {
21
+ pid: number;
22
+ port: number;
23
+ }
24
+
25
+ /** Directory for PID file and other cache data. */
26
+ function cacheDir(): string {
27
+ return path.join(os.homedir(), '.cache', 'brainbank');
28
+ }
29
+
30
+ /** Full path to the PID file. */
31
+ function pidPath(): string {
32
+ return path.join(cacheDir(), 'server.pid');
33
+ }
34
+
35
+ /** Write the PID file after server starts. */
36
+ export function writePid(pid: number, port: number): void {
37
+ const dir = cacheDir();
38
+ fs.mkdirSync(dir, { recursive: true });
39
+ fs.writeFileSync(pidPath(), JSON.stringify({ pid, port } as PidInfo));
40
+ }
41
+
42
+ /** Read PID info from file. Returns null if missing or malformed. */
43
+ export function readPid(): PidInfo | null {
44
+ try {
45
+ const raw = fs.readFileSync(pidPath(), 'utf8');
46
+ const info = JSON.parse(raw) as PidInfo;
47
+ if (typeof info.pid !== 'number' || typeof info.port !== 'number') return null;
48
+ return info;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /** Remove the PID file. */
55
+ export function removePid(): void {
56
+ try {
57
+ fs.unlinkSync(pidPath());
58
+ } catch {
59
+ // File doesn't exist — safe to ignore
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Check if the server process is still alive.
65
+ * Uses `kill(pid, 0)` which doesn't actually send a signal —
66
+ * it just checks whether the process exists.
67
+ */
68
+ export function isServerRunning(): PidInfo | null {
69
+ const info = readPid();
70
+ if (!info) return null;
71
+
72
+ try {
73
+ process.kill(info.pid, 0);
74
+ return info;
75
+ } catch {
76
+ // Process not running — stale PID file
77
+ removePid();
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /** Get the server URL if running, or null. */
83
+ export function getServerUrl(): string | null {
84
+ const info = isServerRunning();
85
+ if (!info) return null;
86
+ return `http://localhost:${info.port}`;
87
+ }