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,384 @@
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, SearchResult } from '@/types.ts';
15
+
16
+ import { sanitizeFTS, normalizeBM25 } from '@/lib/fts.ts';
17
+ import { vecToBuffer } from '@/lib/math.ts';
18
+ import { fuseRankedLists } from '@/lib/rrf.ts';
19
+
20
+ export interface CollectionItem {
21
+ id: number;
22
+ collection: string;
23
+ content: string;
24
+ metadata: Record<string, unknown>;
25
+ tags: string[];
26
+ createdAt: number;
27
+ expiresAt?: number;
28
+ score?: number;
29
+ }
30
+
31
+ export interface CollectionSearchOptions {
32
+ /** Max results. Default: 5 */
33
+ k?: number;
34
+ /** Search mode. Default: 'hybrid' */
35
+ mode?: 'hybrid' | 'vector' | 'keyword';
36
+ /** Minimum score threshold. Default: 0.15 */
37
+ minScore?: number;
38
+ /** Filter by tags (item must have ALL specified tags). */
39
+ tags?: string[];
40
+ }
41
+
42
+ export interface CollectionAddOptions {
43
+ /** Metadata key-value pairs. */
44
+ metadata?: Record<string, unknown>;
45
+ /** Tags for filtering. */
46
+ tags?: string[];
47
+ /** Time-to-live duration string (e.g. '7d', '24h', '30m'). */
48
+ ttl?: string;
49
+ }
50
+
51
+ export class Collection {
52
+ constructor(
53
+ private _name: string,
54
+ private _db: DatabaseAdapter,
55
+ private _embedding: EmbeddingProvider,
56
+ private _hnsw: HNSWIndex,
57
+ private _vecs: Map<number, Float32Array>,
58
+ ) {}
59
+
60
+ /** Collection name. */
61
+ get name(): string { return this._name; }
62
+
63
+ /** Add an item. Returns its ID. */
64
+ async add(content: string, options: CollectionAddOptions | Record<string, unknown> = {}): Promise<number> {
65
+ // Support both signatures: add(content, { metadata, tags, ttl }) and add(content, metadata)
66
+ const opts = 'tags' in options || 'ttl' in options || 'metadata' in options
67
+ ? options as CollectionAddOptions
68
+ : { metadata: options as Record<string, unknown> };
69
+
70
+ const metadata = opts.metadata ?? {};
71
+ const tags = opts.tags ?? [];
72
+ const expiresAt = opts.ttl ? Math.floor(Date.now() / 1000) + parseDuration(opts.ttl) : null;
73
+
74
+ // Embed FIRST — if this throws, no orphaned rows are left in kv_data
75
+ const vec = await this._embedding.embed(content);
76
+
77
+ const result = this._db.prepare(
78
+ 'INSERT INTO kv_data (collection, content, meta_json, tags_json, expires_at) VALUES (?, ?, ?, ?, ?)'
79
+ ).run(this._name, content, JSON.stringify(metadata), JSON.stringify(tags), expiresAt);
80
+
81
+ const id = Number(result.lastInsertRowid);
82
+ this._db.prepare(
83
+ 'INSERT INTO kv_vectors (data_id, embedding) VALUES (?, ?)'
84
+ ).run(id, vecToBuffer(vec));
85
+
86
+ this._hnsw.add(vec, id);
87
+ this._vecs.set(id, vec);
88
+
89
+ return id;
90
+ }
91
+
92
+ /** Update an item's content (re-embeds). Returns the new ID. */
93
+ async update(id: number, content: string, options?: CollectionAddOptions): Promise<number> {
94
+ const row = this._db.prepare(
95
+ 'SELECT * FROM kv_data WHERE id = ? AND collection = ?'
96
+ ).get(id, this._name) as KvDataRow | undefined;
97
+
98
+ if (!row) throw new Error(`BrainBank: Item ${id} not found in collection '${this._name}'.`);
99
+
100
+ // Merge: keep original metadata/tags unless overridden
101
+ const metadata = options?.metadata ?? JSON.parse(row.meta_json || '{}');
102
+ const tags = options?.tags ?? JSON.parse(row.tags_json || '[]');
103
+ const ttl = options?.ttl;
104
+
105
+ this._removeById(id);
106
+ return this.add(content, { metadata, tags, ...(ttl ? { ttl } : {}) });
107
+ }
108
+
109
+ /** Add multiple items. Returns their IDs. */
110
+ async addMany(items: { content: string; metadata?: Record<string, unknown>; tags?: string[]; ttl?: string }[]): Promise<number[]> {
111
+ if (items.length === 0) return [];
112
+
113
+ // Batch embed all texts at once
114
+ const texts = items.map(i => i.content);
115
+ const vecs = await this._embedding.embedBatch(texts);
116
+
117
+ // Commit DB rows atomically. HNSW is updated ONLY after this succeeds.
118
+ // If the transaction throws, execution never reaches the HNSW loop below.
119
+ const ids: number[] = [];
120
+ const insertData = this._db.prepare(
121
+ 'INSERT INTO kv_data (collection, content, meta_json, tags_json, expires_at) VALUES (?, ?, ?, ?, ?)'
122
+ );
123
+ const insertVec = this._db.prepare(
124
+ 'INSERT INTO kv_vectors (data_id, embedding) VALUES (?, ?)'
125
+ );
126
+
127
+ this._db.transaction(() => {
128
+ for (let i = 0; i < items.length; i++) {
129
+ const item = items[i];
130
+ const expiresAt = item.ttl ? Math.floor(Date.now() / 1000) + parseDuration(item.ttl) : null;
131
+
132
+ const result = insertData.run(
133
+ this._name,
134
+ item.content,
135
+ JSON.stringify(item.metadata ?? {}),
136
+ JSON.stringify(item.tags ?? []),
137
+ expiresAt,
138
+ );
139
+
140
+ const id = Number(result.lastInsertRowid);
141
+ insertVec.run(id, vecToBuffer(vecs[i]));
142
+ ids.push(id);
143
+ }
144
+ });
145
+
146
+ // HNSW + cache updated after successful commit — no orphan risk on rollback.
147
+ for (let i = 0; i < ids.length; i++) {
148
+ this._hnsw.add(vecs[i], ids[i]);
149
+ this._vecs.set(ids[i], vecs[i]);
150
+ }
151
+
152
+ return ids;
153
+ }
154
+
155
+ /** Search this collection. */
156
+ async search(query: string, options: CollectionSearchOptions = {}): Promise<CollectionItem[]> {
157
+ const { k = 5, mode = 'hybrid', minScore = 0.15, tags } = options;
158
+
159
+ // Auto-prune expired items before search
160
+ this._pruneExpired();
161
+
162
+ if (mode === 'keyword') return this._filterByTags(this._searchBM25(query, k, minScore), tags);
163
+ if (mode === 'vector') return this._filterByTags(await this._searchVector(query, k, minScore), tags);
164
+
165
+ // Hybrid: vector + BM25 → generic RRF (no SearchResult conversion)
166
+ const [vectorHits, bm25Hits] = await Promise.all([
167
+ this._searchVector(query, k, 0),
168
+ Promise.resolve(this._searchBM25(query, k, 0)),
169
+ ]);
170
+
171
+ const fused = fuseRankedLists(
172
+ [vectorHits, bm25Hits],
173
+ h => String(h.id),
174
+ h => h.score ?? 0,
175
+ );
176
+
177
+ const results: CollectionItem[] = fused
178
+ .map(({ item, score }) => ({ ...item, score }))
179
+ .filter(r => r.score >= minScore)
180
+ .slice(0, k);
181
+
182
+ return this._filterByTags(results, tags);
183
+ }
184
+
185
+ /** Search and return results as SearchResult[] for use in hybrid search pipelines. */
186
+ async searchAsResults(query: string, k: number): Promise<SearchResult[]> {
187
+ const hits = await this.search(query, { k });
188
+ return hits.map(h => ({
189
+ type: 'collection' as const,
190
+ score: h.score ?? 0,
191
+ content: h.content,
192
+ metadata: { ...h.metadata, id: h.id, collection: this._name },
193
+ }));
194
+ }
195
+
196
+ /** List items (newest first). */
197
+ list(options: { limit?: number; offset?: number; tags?: string[] } = {}): CollectionItem[] {
198
+ const { limit = 20, offset = 0, tags } = options;
199
+
200
+ // Auto-prune expired items
201
+ this._pruneExpired();
202
+
203
+ const rows = this._db.prepare(
204
+ 'SELECT * FROM kv_data WHERE collection = ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?'
205
+ ).all(this._name, Math.floor(Date.now() / 1000), limit, offset) as KvDataRow[];
206
+ return this._filterByTags(rows.map(r => this._rowToItem(r)), tags);
207
+ }
208
+
209
+ /** Count items in this collection. */
210
+ count(): number {
211
+ return (this._db.prepare(
212
+ 'SELECT COUNT(*) as c FROM kv_data WHERE collection = ? AND (expires_at IS NULL OR expires_at > ?)'
213
+ ).get(this._name, Math.floor(Date.now() / 1000)) as CountRow).c;
214
+ }
215
+
216
+ /** Keep only the N most recent items, remove the rest. */
217
+ async trim(options: { keep: number }): Promise<{ removed: number }> {
218
+ const before = this.count();
219
+ if (before <= options.keep) return { removed: 0 };
220
+
221
+ // Get IDs to remove (oldest first, beyond the keep window)
222
+ const toRemove = this._db.prepare(`
223
+ SELECT id FROM kv_data
224
+ WHERE collection = ?
225
+ ORDER BY created_at DESC, id DESC
226
+ LIMIT -1 OFFSET ?
227
+ `).all(this._name, options.keep) as Pick<KvDataRow, 'id'>[];
228
+
229
+ for (const row of toRemove) {
230
+ this._removeById(row.id);
231
+ }
232
+
233
+ return { removed: toRemove.length };
234
+ }
235
+
236
+ /** Remove items older than a duration string (e.g. '30d', '12h'). */
237
+ async prune(options: { olderThan: string }): Promise<{ removed: number }> {
238
+ const seconds = parseDuration(options.olderThan);
239
+ const cutoff = Math.floor(Date.now() / 1000) - seconds;
240
+
241
+ const toRemove = this._db.prepare(
242
+ 'SELECT id FROM kv_data WHERE collection = ? AND created_at < ?'
243
+ ).all(this._name, cutoff) as Pick<KvDataRow, 'id'>[];
244
+
245
+ for (const row of toRemove) {
246
+ this._removeById(row.id);
247
+ }
248
+
249
+ return { removed: toRemove.length };
250
+ }
251
+
252
+ /** Remove a specific item by ID. */
253
+ remove(id: number): void {
254
+ this._removeById(id);
255
+ }
256
+
257
+ /** Clear all items in this collection. */
258
+ clear(): void {
259
+ const rows = this._db.prepare(
260
+ 'SELECT id FROM kv_data WHERE collection = ?'
261
+ ).all(this._name) as Pick<KvDataRow, 'id'>[];
262
+
263
+ for (const row of rows) {
264
+ this._removeById(row.id);
265
+ }
266
+ }
267
+
268
+
269
+ private _removeById(id: number): void {
270
+ // DB first — can fail (disk full, lock). If it throws, HNSW+cache stay consistent.
271
+ this._db.prepare('DELETE FROM kv_data WHERE id = ?').run(id);
272
+ // HNSW + cache after — these always succeed
273
+ this._hnsw.remove(id);
274
+ this._vecs.delete(id);
275
+ }
276
+
277
+ private async _searchVector(query: string, k: number, minScore: number): Promise<CollectionItem[]> {
278
+ if (this._hnsw.size === 0) return [];
279
+
280
+ const queryVec = await this._embedding.embed(query);
281
+ // Adaptive over-fetch: proportional to total/collection density, clamped [3, 50]
282
+ const searchK = this._adaptiveSearchK(k);
283
+ const hits = this._hnsw.search(queryVec, searchK);
284
+
285
+ const ids = hits.map(h => h.id);
286
+ if (ids.length === 0) return [];
287
+
288
+ const scoreMap = new Map(hits.map(h => [h.id, h.score]));
289
+ const placeholders = ids.map(() => '?').join(',');
290
+
291
+ const rows = this._db.prepare(
292
+ `SELECT * FROM kv_data WHERE id IN (${placeholders}) AND collection = ?`
293
+ ).all(...ids, this._name) as KvDataRow[];
294
+
295
+ return rows
296
+ .map(r => ({ ...this._rowToItem(r), score: scoreMap.get(r.id) ?? 0 }))
297
+ .filter(r => r.score >= minScore)
298
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
299
+ .slice(0, k);
300
+ }
301
+
302
+ /** Compute adaptive over-fetch multiplier based on collection density in shared HNSW. */
303
+ private _adaptiveSearchK(k: number): number {
304
+ const totalSize = this._hnsw.size;
305
+ if (totalSize === 0) return 0;
306
+ const collectionCount = this.count();
307
+ if (collectionCount === 0) return Math.min(k * 3, totalSize);
308
+ const ratio = Math.ceil(totalSize / collectionCount);
309
+ const multiplier = Math.max(3, Math.min(ratio, 50));
310
+ return Math.min(k * multiplier, totalSize);
311
+ }
312
+
313
+ private _searchBM25(query: string, k: number, minScore: number): CollectionItem[] {
314
+ const ftsQuery = sanitizeFTS(query);
315
+ if (!ftsQuery) return [];
316
+
317
+ try {
318
+ const rows = this._db.prepare(`
319
+ SELECT d.*, bm25(fts_kv, 5.0, 1.0) AS score
320
+ FROM fts_kv f
321
+ JOIN kv_data d ON d.id = f.rowid
322
+ WHERE fts_kv MATCH ? AND d.collection = ?
323
+ ORDER BY score ASC
324
+ LIMIT ?
325
+ `).all(ftsQuery, this._name, k) as (KvDataRow & { score: number })[];
326
+
327
+ return rows
328
+ .map(r => ({
329
+ ...this._rowToItem(r),
330
+ score: normalizeBM25(r.score),
331
+ }))
332
+ .filter(r => (r.score ?? 0) >= minScore);
333
+ } catch {
334
+ return [];
335
+ }
336
+ }
337
+
338
+ private _rowToItem(r: KvDataRow): CollectionItem {
339
+ return {
340
+ id: r.id,
341
+ collection: r.collection,
342
+ content: r.content,
343
+ metadata: JSON.parse(r.meta_json || '{}') as Record<string, unknown>,
344
+ tags: JSON.parse(r.tags_json || '[]') as string[],
345
+ createdAt: r.created_at,
346
+ expiresAt: r.expires_at ?? undefined,
347
+ };
348
+ }
349
+
350
+ /** Filter results by tags (item must have ALL specified tags). */
351
+ private _filterByTags(items: CollectionItem[], tags?: string[]): CollectionItem[] {
352
+ if (!tags || tags.length === 0) return items;
353
+ return items.filter(item =>
354
+ tags.every(t => item.tags.includes(t))
355
+ );
356
+ }
357
+
358
+ /** Remove expired items (TTL). Called automatically on search/list. */
359
+ private _pruneExpired(): void {
360
+ const now = Math.floor(Date.now() / 1000);
361
+ const expired = this._db.prepare(
362
+ 'SELECT id FROM kv_data WHERE collection = ? AND expires_at IS NOT NULL AND expires_at <= ?'
363
+ ).all(this._name, now) as Pick<KvDataRow, 'id'>[];
364
+
365
+ for (const row of expired) {
366
+ this._removeById(row.id);
367
+ }
368
+ }
369
+ }
370
+
371
+ /** Parse a duration string like '30d', '12h', '5m' to seconds. */
372
+ function parseDuration(s: string): number {
373
+ const match = s.match(/^(\d+)([dhms])$/);
374
+ if (!match) throw new Error(`Invalid duration: "${s}". Use format like '30d', '12h', '5m'.`);
375
+
376
+ const n = parseInt(match[1], 10);
377
+ switch (match[2]) {
378
+ case 'd': return n * 86400;
379
+ case 'h': return n * 3600;
380
+ case 'm': return n * 60;
381
+ case 's': return n;
382
+ default: return n;
383
+ }
384
+ }
@@ -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
+ }