@brainbank/mcp 0.2.0 → 0.3.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.
@@ -0,0 +1,224 @@
1
+ /**
2
+ * WorkspacePool — BrainBank instance lifecycle manager.
3
+ *
4
+ * Manages cached BrainBank instances per workspace with:
5
+ * - Memory-pressure eviction (oldest idle first)
6
+ * - TTL eviction for inactive workspaces
7
+ * - Active-operation tracking (prevents mid-query eviction)
8
+ * - Hot-reload of stale HNSW indices
9
+ */
10
+
11
+ import type { BrainBank } from 'brainbank';
12
+
13
+ /** Pool configuration. */
14
+ export interface PoolOptions {
15
+ /** Max total estimated memory in MB. Default: 2048. */
16
+ maxMemoryMB?: number;
17
+ /** Minutes of inactivity before eviction. Default: 30. */
18
+ ttlMinutes?: number;
19
+ /** Factory function to create a BrainBank for a repo path. */
20
+ factory: (repoPath: string) => Promise<BrainBank>;
21
+ /** Called when a workspace is evicted. */
22
+ onEvict?: (repoPath: string) => void;
23
+ /** Called when an error occurs during pool operations. */
24
+ onError?: (repoPath: string, err: unknown) => void;
25
+ }
26
+
27
+ /** Internal pool entry. */
28
+ interface PoolEntry {
29
+ brain: BrainBank;
30
+ repoPath: string;
31
+ lastAccess: number;
32
+ createdAt: number;
33
+ activeOps: number;
34
+ }
35
+
36
+ /** Public pool statistics. */
37
+ export interface PoolStats {
38
+ size: number;
39
+ totalMemoryMB: number;
40
+ entries: PoolEntryStats[];
41
+ }
42
+
43
+ /** Per-entry statistics. */
44
+ export interface PoolEntryStats {
45
+ repoPath: string;
46
+ lastAccessAgo: string;
47
+ memoryMB: number;
48
+ activeOps: number;
49
+ }
50
+
51
+ const DEFAULT_MAX_MEMORY_MB = 2048;
52
+ const DEFAULT_TTL_MINUTES = 30;
53
+ const EVICTION_INTERVAL_MS = 60_000;
54
+
55
+ /** Format milliseconds as a human-readable "ago" string. */
56
+ function formatAgo(ms: number): string {
57
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s ago`;
58
+ if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m ago`;
59
+ return `${Math.round(ms / 3_600_000)}h ago`;
60
+ }
61
+
62
+ export class WorkspacePool {
63
+ private _pool = new Map<string, PoolEntry>();
64
+ private _timer: ReturnType<typeof setInterval> | null = null;
65
+ private _maxMemoryBytes: number;
66
+ private _ttlMs: number;
67
+ private _factory: (repoPath: string) => Promise<BrainBank>;
68
+ private _onEvict?: (repoPath: string) => void;
69
+ private _onError?: (repoPath: string, err: unknown) => void;
70
+
71
+ constructor(options: PoolOptions) {
72
+ this._maxMemoryBytes = (options.maxMemoryMB ?? DEFAULT_MAX_MEMORY_MB) * 1024 * 1024;
73
+ this._ttlMs = (options.ttlMinutes ?? DEFAULT_TTL_MINUTES) * 60 * 1000;
74
+ this._factory = options.factory;
75
+ this._onEvict = options.onEvict;
76
+ this._onError = options.onError;
77
+
78
+ this._timer = setInterval(() => this._evictStale(), EVICTION_INTERVAL_MS);
79
+ // Don't hold the process open for the timer
80
+ if (this._timer.unref) this._timer.unref();
81
+ }
82
+
83
+ /** Number of cached workspaces. */
84
+ get size(): number {
85
+ return this._pool.size;
86
+ }
87
+
88
+ /**
89
+ * Get a BrainBank for the given repo path.
90
+ * Returns a cached instance (with hot-reload) or creates a new one.
91
+ */
92
+ async get(repoPath: string): Promise<BrainBank> {
93
+ const key = repoPath.replace(/\/+$/, '');
94
+
95
+ const existing = this._pool.get(key);
96
+ if (existing) {
97
+ existing.lastAccess = Date.now();
98
+ try { await existing.brain.ensureFresh(); } catch { /* stale is better than nothing */ }
99
+ return existing.brain;
100
+ }
101
+
102
+ this._evictByMemoryPressure();
103
+
104
+ const brain = await this._factory(key);
105
+ this._pool.set(key, {
106
+ brain,
107
+ repoPath: key,
108
+ lastAccess: Date.now(),
109
+ createdAt: Date.now(),
110
+ activeOps: 0,
111
+ });
112
+
113
+ return brain;
114
+ }
115
+
116
+ /**
117
+ * Execute an operation with active-op tracking.
118
+ * Prevents the workspace from being evicted while the operation runs.
119
+ */
120
+ async withBrain<T>(repoPath: string, fn: (brain: BrainBank) => Promise<T>): Promise<T> {
121
+ const brain = await this.get(repoPath);
122
+ const key = repoPath.replace(/\/+$/, '');
123
+ const entry = this._pool.get(key);
124
+ if (entry) entry.activeOps++;
125
+
126
+ try {
127
+ return await fn(brain);
128
+ } finally {
129
+ if (entry) {
130
+ entry.activeOps--;
131
+ entry.lastAccess = Date.now();
132
+ }
133
+ }
134
+ }
135
+
136
+ /** Manually evict a specific workspace. */
137
+ evict(repoPath: string): void {
138
+ const key = repoPath.replace(/\/+$/, '');
139
+ this._evictEntry(key);
140
+ }
141
+
142
+ /** Get pool statistics. */
143
+ stats(): PoolStats {
144
+ const now = Date.now();
145
+ let totalMemory = 0;
146
+ const entries: PoolEntryStats[] = [];
147
+
148
+ for (const entry of this._pool.values()) {
149
+ const memBytes = entry.brain.memoryHint();
150
+ const memMB = Math.round(memBytes / 1024 / 1024 * 100) / 100;
151
+ totalMemory += memBytes;
152
+
153
+ entries.push({
154
+ repoPath: entry.repoPath,
155
+ lastAccessAgo: formatAgo(now - entry.lastAccess),
156
+ memoryMB: memMB,
157
+ activeOps: entry.activeOps,
158
+ });
159
+ }
160
+
161
+ return {
162
+ size: this._pool.size,
163
+ totalMemoryMB: Math.round(totalMemory / 1024 / 1024 * 100) / 100,
164
+ entries,
165
+ };
166
+ }
167
+
168
+ /** Close all entries and stop the eviction timer. */
169
+ close(): void {
170
+ if (this._timer) {
171
+ clearInterval(this._timer);
172
+ this._timer = null;
173
+ }
174
+ for (const key of [...this._pool.keys()]) {
175
+ this._evictEntry(key);
176
+ }
177
+ }
178
+
179
+ /** Evict workspaces that haven't been accessed within the TTL. */
180
+ private _evictStale(): void {
181
+ const cutoff = Date.now() - this._ttlMs;
182
+ for (const [key, entry] of this._pool) {
183
+ if (entry.lastAccess < cutoff && entry.activeOps === 0) {
184
+ this._evictEntry(key);
185
+ }
186
+ }
187
+ }
188
+
189
+ /** Evict oldest idle entries until total memory is under the limit. */
190
+ private _evictByMemoryPressure(): void {
191
+ let totalMemory = 0;
192
+ for (const entry of this._pool.values()) {
193
+ totalMemory += entry.brain.memoryHint();
194
+ }
195
+
196
+ if (totalMemory < this._maxMemoryBytes) return;
197
+
198
+ // Sort by lastAccess ascending (oldest first), filter idle
199
+ const candidates = [...this._pool.entries()]
200
+ .filter(([, e]) => e.activeOps === 0)
201
+ .sort(([, a], [, b]) => a.lastAccess - b.lastAccess);
202
+
203
+ for (const [key, entry] of candidates) {
204
+ if (totalMemory < this._maxMemoryBytes) break;
205
+ totalMemory -= entry.brain.memoryHint();
206
+ this._evictEntry(key);
207
+ }
208
+ }
209
+
210
+ /** Evict a single entry by key. */
211
+ private _evictEntry(key: string): void {
212
+ const entry = this._pool.get(key);
213
+ if (!entry) return;
214
+
215
+ try {
216
+ entry.brain.close();
217
+ } catch (err: unknown) {
218
+ this._onError?.(key, err);
219
+ }
220
+
221
+ this._pool.delete(key);
222
+ this._onEvict?.(key);
223
+ }
224
+ }