@qwickapps/qwickbrain-proxy 1.0.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 (90) hide show
  1. package/.github/workflows/publish.yml +92 -0
  2. package/CHANGELOG.md +47 -0
  3. package/LICENSE +45 -0
  4. package/README.md +165 -0
  5. package/dist/bin/cli.d.ts +3 -0
  6. package/dist/bin/cli.d.ts.map +1 -0
  7. package/dist/bin/cli.js +142 -0
  8. package/dist/bin/cli.js.map +1 -0
  9. package/dist/db/client.d.ts +10 -0
  10. package/dist/db/client.d.ts.map +1 -0
  11. package/dist/db/client.js +23 -0
  12. package/dist/db/client.js.map +1 -0
  13. package/dist/db/schema.d.ts +551 -0
  14. package/dist/db/schema.d.ts.map +1 -0
  15. package/dist/db/schema.js +65 -0
  16. package/dist/db/schema.js.map +1 -0
  17. package/dist/index.d.ts +7 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +5 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/lib/__tests__/cache-manager.test.d.ts +2 -0
  22. package/dist/lib/__tests__/cache-manager.test.d.ts.map +1 -0
  23. package/dist/lib/__tests__/cache-manager.test.js +202 -0
  24. package/dist/lib/__tests__/cache-manager.test.js.map +1 -0
  25. package/dist/lib/__tests__/connection-manager.test.d.ts +2 -0
  26. package/dist/lib/__tests__/connection-manager.test.d.ts.map +1 -0
  27. package/dist/lib/__tests__/connection-manager.test.js +188 -0
  28. package/dist/lib/__tests__/connection-manager.test.js.map +1 -0
  29. package/dist/lib/__tests__/proxy-server.test.d.ts +2 -0
  30. package/dist/lib/__tests__/proxy-server.test.d.ts.map +1 -0
  31. package/dist/lib/__tests__/proxy-server.test.js +205 -0
  32. package/dist/lib/__tests__/proxy-server.test.js.map +1 -0
  33. package/dist/lib/__tests__/qwickbrain-client.test.d.ts +2 -0
  34. package/dist/lib/__tests__/qwickbrain-client.test.d.ts.map +1 -0
  35. package/dist/lib/__tests__/qwickbrain-client.test.js +233 -0
  36. package/dist/lib/__tests__/qwickbrain-client.test.js.map +1 -0
  37. package/dist/lib/cache-manager.d.ts +25 -0
  38. package/dist/lib/cache-manager.d.ts.map +1 -0
  39. package/dist/lib/cache-manager.js +149 -0
  40. package/dist/lib/cache-manager.js.map +1 -0
  41. package/dist/lib/connection-manager.d.ts +26 -0
  42. package/dist/lib/connection-manager.d.ts.map +1 -0
  43. package/dist/lib/connection-manager.js +130 -0
  44. package/dist/lib/connection-manager.js.map +1 -0
  45. package/dist/lib/proxy-server.d.ts +19 -0
  46. package/dist/lib/proxy-server.d.ts.map +1 -0
  47. package/dist/lib/proxy-server.js +258 -0
  48. package/dist/lib/proxy-server.js.map +1 -0
  49. package/dist/lib/qwickbrain-client.d.ts +24 -0
  50. package/dist/lib/qwickbrain-client.d.ts.map +1 -0
  51. package/dist/lib/qwickbrain-client.js +197 -0
  52. package/dist/lib/qwickbrain-client.js.map +1 -0
  53. package/dist/types/config.d.ts +186 -0
  54. package/dist/types/config.d.ts.map +1 -0
  55. package/dist/types/config.js +42 -0
  56. package/dist/types/config.js.map +1 -0
  57. package/dist/types/mcp.d.ts +223 -0
  58. package/dist/types/mcp.d.ts.map +1 -0
  59. package/dist/types/mcp.js +78 -0
  60. package/dist/types/mcp.js.map +1 -0
  61. package/dist/version.d.ts +2 -0
  62. package/dist/version.d.ts.map +1 -0
  63. package/dist/version.js +9 -0
  64. package/dist/version.js.map +1 -0
  65. package/drizzle/0000_fat_rafael_vega.sql +41 -0
  66. package/drizzle/0001_goofy_invisible_woman.sql +2 -0
  67. package/drizzle/meta/0000_snapshot.json +276 -0
  68. package/drizzle/meta/0001_snapshot.json +295 -0
  69. package/drizzle/meta/_journal.json +20 -0
  70. package/drizzle.config.ts +12 -0
  71. package/package.json +65 -0
  72. package/src/bin/cli.ts +158 -0
  73. package/src/db/client.ts +34 -0
  74. package/src/db/schema.ts +68 -0
  75. package/src/index.ts +6 -0
  76. package/src/lib/__tests__/cache-manager.test.ts +264 -0
  77. package/src/lib/__tests__/connection-manager.test.ts +255 -0
  78. package/src/lib/__tests__/proxy-server.test.ts +261 -0
  79. package/src/lib/__tests__/qwickbrain-client.test.ts +310 -0
  80. package/src/lib/cache-manager.ts +201 -0
  81. package/src/lib/connection-manager.ts +156 -0
  82. package/src/lib/proxy-server.ts +320 -0
  83. package/src/lib/qwickbrain-client.ts +260 -0
  84. package/src/types/config.ts +47 -0
  85. package/src/types/mcp.ts +97 -0
  86. package/src/version.ts +11 -0
  87. package/test/fixtures/test-mcp.json +5 -0
  88. package/test-mcp-client.js +67 -0
  89. package/test-proxy.sh +25 -0
  90. package/tsconfig.json +22 -0
@@ -0,0 +1,310 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
+ import { QwickBrainClient } from '../qwickbrain-client.js';
3
+ import type { Config } from '../../types/config.js';
4
+
5
+ // Mock the MCP SDK
6
+ vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
7
+ Client: vi.fn().mockImplementation(() => ({
8
+ connect: vi.fn().mockResolvedValue(undefined),
9
+ close: vi.fn().mockResolvedValue(undefined),
10
+ callTool: vi.fn(),
11
+ listTools: vi.fn().mockResolvedValue({ tools: [] }),
12
+ })),
13
+ }));
14
+
15
+ vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
16
+ StdioClientTransport: vi.fn(),
17
+ }));
18
+
19
+ vi.mock('@modelcontextprotocol/sdk/client/sse.js', () => ({
20
+ SSEClientTransport: vi.fn(),
21
+ }));
22
+
23
+ // Mock fetch for HTTP mode
24
+ global.fetch = vi.fn();
25
+
26
+ describe('QwickBrainClient', () => {
27
+ afterEach(() => {
28
+ vi.clearAllMocks();
29
+ });
30
+
31
+ describe('SSE mode', () => {
32
+ let client: QwickBrainClient;
33
+ let config: Config['qwickbrain'];
34
+
35
+ beforeEach(() => {
36
+ config = {
37
+ mode: 'sse',
38
+ url: 'http://test.local:3000',
39
+ };
40
+
41
+ client = new QwickBrainClient(config);
42
+ });
43
+
44
+ it('should connect in SSE mode', async () => {
45
+ await client.connect();
46
+
47
+ expect(client['mode']).toBe('sse');
48
+ expect(client['client']).toBeDefined();
49
+ });
50
+
51
+ it('should get document via MCP in SSE mode', async () => {
52
+ await client.connect();
53
+
54
+ const mockResponse = {
55
+ content: [
56
+ {
57
+ type: 'text',
58
+ text: JSON.stringify({
59
+ document: {
60
+ content: 'test content',
61
+ metadata: { version: 1 },
62
+ },
63
+ }),
64
+ },
65
+ ],
66
+ };
67
+
68
+ (client['client']!.callTool as any).mockResolvedValue(mockResponse);
69
+
70
+ const result = await client.getDocument('workflow', 'test');
71
+
72
+ expect(result.content).toBe('test content');
73
+ expect(result.metadata).toEqual({ version: 1 });
74
+ });
75
+
76
+ it('should throw on invalid MCP response format', async () => {
77
+ await client.connect();
78
+
79
+ const mockResponse = {
80
+ content: [
81
+ {
82
+ type: 'image', // Invalid type
83
+ data: 'invalid',
84
+ },
85
+ ],
86
+ };
87
+
88
+ (client['client']!.callTool as any).mockResolvedValue(mockResponse);
89
+
90
+ await expect(client.getDocument('workflow', 'test')).rejects.toThrow();
91
+ });
92
+ });
93
+
94
+ describe('HTTP mode', () => {
95
+ let client: QwickBrainClient;
96
+ let config: Config['qwickbrain'];
97
+
98
+ beforeEach(() => {
99
+ config = {
100
+ mode: 'http',
101
+ url: 'http://api.test.com',
102
+ apiKey: 'test-key',
103
+ };
104
+
105
+ client = new QwickBrainClient(config);
106
+ });
107
+
108
+ it('should get document via HTTP', async () => {
109
+ const mockResponse = {
110
+ content: 'test content',
111
+ metadata: { version: 1 },
112
+ };
113
+
114
+ (global.fetch as any).mockResolvedValue({
115
+ ok: true,
116
+ json: async () => mockResponse,
117
+ });
118
+
119
+ const result = await client.getDocument('workflow', 'test');
120
+
121
+ expect(result.content).toBe('test content');
122
+ expect(result.metadata).toEqual({ version: 1 });
123
+
124
+ expect(global.fetch).toHaveBeenCalledWith(
125
+ 'http://api.test.com/mcp/document',
126
+ expect.objectContaining({
127
+ method: 'POST',
128
+ headers: expect.objectContaining({
129
+ 'Content-Type': 'application/json',
130
+ 'Authorization': 'Bearer test-key',
131
+ }),
132
+ })
133
+ );
134
+ });
135
+
136
+ it('should throw on HTTP error', async () => {
137
+ (global.fetch as any).mockResolvedValue({
138
+ ok: false,
139
+ status: 404,
140
+ statusText: 'Not Found',
141
+ });
142
+
143
+ await expect(client.getDocument('workflow', 'test')).rejects.toThrow('HTTP 404');
144
+ });
145
+
146
+ it('should validate HTTP response schema', async () => {
147
+ const invalidResponse = {
148
+ // Missing required 'content' field
149
+ metadata: {},
150
+ };
151
+
152
+ (global.fetch as any).mockResolvedValue({
153
+ ok: true,
154
+ json: async () => invalidResponse,
155
+ });
156
+
157
+ await expect(client.getDocument('workflow', 'test')).rejects.toThrow();
158
+ });
159
+
160
+ it('should get memory via HTTP', async () => {
161
+ const mockResponse = {
162
+ content: 'memory content',
163
+ metadata: {},
164
+ };
165
+
166
+ (global.fetch as any).mockResolvedValue({
167
+ ok: true,
168
+ json: async () => mockResponse,
169
+ });
170
+
171
+ const result = await client.getMemory('test-memory');
172
+
173
+ expect(result.content).toBe('memory content');
174
+
175
+ expect(global.fetch).toHaveBeenCalledWith(
176
+ 'http://api.test.com/mcp/memory',
177
+ expect.objectContaining({
178
+ method: 'POST',
179
+ })
180
+ );
181
+ });
182
+ });
183
+
184
+ describe('MCP mode', () => {
185
+ let client: QwickBrainClient;
186
+ let config: Config['qwickbrain'];
187
+
188
+ beforeEach(() => {
189
+ config = {
190
+ mode: 'mcp',
191
+ command: 'npx',
192
+ args: ['qwickbrain-server'],
193
+ };
194
+
195
+ client = new QwickBrainClient(config);
196
+ });
197
+
198
+ it('should connect in MCP mode', async () => {
199
+ await client.connect();
200
+
201
+ expect(client['mode']).toBe('mcp');
202
+ expect(client['client']).toBeDefined();
203
+ });
204
+
205
+ it('should require command in MCP mode', async () => {
206
+ const invalidConfig: Config['qwickbrain'] = {
207
+ mode: 'mcp',
208
+ // Missing command
209
+ };
210
+
211
+ const invalidClient = new QwickBrainClient(invalidConfig);
212
+
213
+ await expect(invalidClient.connect()).rejects.toThrow('MCP mode requires command');
214
+ });
215
+ });
216
+
217
+ describe('healthCheck', () => {
218
+ it('should return true for successful MCP health check', async () => {
219
+ const config: Config['qwickbrain'] = {
220
+ mode: 'sse',
221
+ url: 'http://test.local:3000',
222
+ };
223
+
224
+ const client = new QwickBrainClient(config);
225
+ await client.connect();
226
+
227
+ const isHealthy = await client.healthCheck();
228
+
229
+ expect(isHealthy).toBe(true);
230
+ });
231
+
232
+ it('should return true for successful HTTP health check', async () => {
233
+ const config: Config['qwickbrain'] = {
234
+ mode: 'http',
235
+ url: 'http://api.test.com',
236
+ };
237
+
238
+ const client = new QwickBrainClient(config);
239
+
240
+ (global.fetch as any).mockResolvedValue({
241
+ ok: true,
242
+ });
243
+
244
+ const isHealthy = await client.healthCheck();
245
+
246
+ expect(isHealthy).toBe(true);
247
+ expect(global.fetch).toHaveBeenCalledWith('http://api.test.com/health');
248
+ });
249
+
250
+ it('should return false on health check failure', async () => {
251
+ const config: Config['qwickbrain'] = {
252
+ mode: 'sse',
253
+ url: 'http://test.local:3000',
254
+ };
255
+
256
+ const client = new QwickBrainClient(config);
257
+ await client.connect();
258
+
259
+ (client['client']!.listTools as any).mockRejectedValue(new Error('Failed'));
260
+
261
+ const isHealthy = await client.healthCheck();
262
+
263
+ expect(isHealthy).toBe(false);
264
+ });
265
+
266
+ it('should return false for missing URL in HTTP mode', async () => {
267
+ const config: Config['qwickbrain'] = {
268
+ mode: 'http',
269
+ // Missing URL
270
+ };
271
+
272
+ const client = new QwickBrainClient(config);
273
+
274
+ const isHealthy = await client.healthCheck();
275
+
276
+ expect(isHealthy).toBe(false);
277
+ });
278
+ });
279
+
280
+ describe('disconnect', () => {
281
+ it('should disconnect MCP client', async () => {
282
+ const config: Config['qwickbrain'] = {
283
+ mode: 'sse',
284
+ url: 'http://test.local:3000',
285
+ };
286
+
287
+ const client = new QwickBrainClient(config);
288
+ await client.connect();
289
+
290
+ const closeMock = client['client']!.close;
291
+
292
+ await client.disconnect();
293
+
294
+ expect(closeMock).toHaveBeenCalled();
295
+ expect(client['client']).toBeNull();
296
+ });
297
+
298
+ it('should handle disconnect when not connected', async () => {
299
+ const config: Config['qwickbrain'] = {
300
+ mode: 'http',
301
+ url: 'http://api.test.com',
302
+ };
303
+
304
+ const client = new QwickBrainClient(config);
305
+
306
+ // Should not throw
307
+ await expect(client.disconnect()).resolves.toBeUndefined();
308
+ });
309
+ });
310
+ });
@@ -0,0 +1,201 @@
1
+ import { eq, and, lt, lte, sql } from 'drizzle-orm';
2
+ import type { DB } from '../db/client.js';
3
+ import { documents, memories } from '../db/schema.js';
4
+ import type { Config } from '../types/config.js';
5
+
6
+ interface CachedItem<T> {
7
+ data: T;
8
+ cachedAt: Date;
9
+ expiresAt: Date;
10
+ age: number; // seconds
11
+ isExpired: boolean;
12
+ }
13
+
14
+ export class CacheManager {
15
+ constructor(
16
+ private db: DB,
17
+ private config: Config['cache']
18
+ ) {}
19
+
20
+ private getTTL(operation: string): number {
21
+ const ttlMap: Record<string, number> = {
22
+ get_workflow: this.config.ttl.workflows,
23
+ get_document: this.config.ttl.documents,
24
+ get_memory: this.config.ttl.memories,
25
+ };
26
+
27
+ return ttlMap[operation] || 0;
28
+ }
29
+
30
+ async getDocument(docType: string, name: string, project?: string): Promise<CachedItem<any> | null> {
31
+ const projectValue = project || '';
32
+
33
+ const [cached] = await this.db
34
+ .select()
35
+ .from(documents)
36
+ .where(
37
+ and(
38
+ eq(documents.docType, docType),
39
+ eq(documents.name, name),
40
+ eq(documents.project, projectValue)
41
+ )
42
+ )
43
+ .limit(1);
44
+
45
+ if (!cached) {
46
+ return null;
47
+ }
48
+
49
+ const now = new Date();
50
+ const age = Math.floor((now.getTime() - cached.cachedAt.getTime()) / 1000);
51
+ // Fix: Compare timestamp values explicitly to avoid Date comparison issues
52
+ const isExpired = now.getTime() > cached.expiresAt.getTime();
53
+
54
+ return {
55
+ data: {
56
+ name: cached.name,
57
+ doc_type: cached.docType,
58
+ project: cached.project,
59
+ content: cached.content,
60
+ metadata: cached.metadata ? JSON.parse(cached.metadata) : {},
61
+ },
62
+ cachedAt: cached.cachedAt,
63
+ expiresAt: cached.expiresAt,
64
+ age,
65
+ isExpired,
66
+ };
67
+ }
68
+
69
+ async setDocument(
70
+ docType: string,
71
+ name: string,
72
+ content: string,
73
+ project?: string,
74
+ metadata?: Record<string, unknown>
75
+ ): Promise<void> {
76
+ const now = new Date();
77
+ const ttl = this.getTTL('get_document');
78
+ const expiresAt = new Date(now.getTime() + ttl * 1000);
79
+
80
+ // Use empty string instead of null for project to make unique constraint work
81
+ // SQLite treats NULL as distinct values in unique constraints
82
+ const projectValue = project || '';
83
+
84
+ await this.db
85
+ .insert(documents)
86
+ .values({
87
+ docType,
88
+ name,
89
+ project: projectValue,
90
+ content,
91
+ metadata: metadata ? JSON.stringify(metadata) : null,
92
+ cachedAt: now,
93
+ expiresAt,
94
+ synced: true,
95
+ })
96
+ .onConflictDoUpdate({
97
+ target: [documents.docType, documents.name, documents.project],
98
+ set: {
99
+ content,
100
+ metadata: metadata ? JSON.stringify(metadata) : null,
101
+ cachedAt: now,
102
+ expiresAt,
103
+ synced: true,
104
+ },
105
+ });
106
+ }
107
+
108
+ async getMemory(name: string, project?: string): Promise<CachedItem<any> | null> {
109
+ const projectValue = project || '';
110
+
111
+ const [cached] = await this.db
112
+ .select()
113
+ .from(memories)
114
+ .where(
115
+ and(
116
+ eq(memories.name, name),
117
+ eq(memories.project, projectValue)
118
+ )
119
+ )
120
+ .limit(1);
121
+
122
+ if (!cached) {
123
+ return null;
124
+ }
125
+
126
+ const now = new Date();
127
+ const age = Math.floor((now.getTime() - cached.cachedAt.getTime()) / 1000);
128
+ // Fix: Compare timestamp values explicitly to avoid Date comparison issues
129
+ const isExpired = now.getTime() > cached.expiresAt.getTime();
130
+
131
+ return {
132
+ data: {
133
+ name: cached.name,
134
+ project: cached.project,
135
+ content: cached.content,
136
+ metadata: cached.metadata ? JSON.parse(cached.metadata) : {},
137
+ },
138
+ cachedAt: cached.cachedAt,
139
+ expiresAt: cached.expiresAt,
140
+ age,
141
+ isExpired,
142
+ };
143
+ }
144
+
145
+ async setMemory(
146
+ name: string,
147
+ content: string,
148
+ project?: string,
149
+ metadata?: Record<string, unknown>
150
+ ): Promise<void> {
151
+ const now = new Date();
152
+ const ttl = this.getTTL('get_memory');
153
+ const expiresAt = new Date(now.getTime() + ttl * 1000);
154
+
155
+ // Use empty string instead of null for project to make unique constraint work
156
+ const projectValue = project || '';
157
+
158
+ await this.db
159
+ .insert(memories)
160
+ .values({
161
+ name,
162
+ project: projectValue,
163
+ content,
164
+ metadata: metadata ? JSON.stringify(metadata) : null,
165
+ cachedAt: now,
166
+ expiresAt,
167
+ synced: true,
168
+ })
169
+ .onConflictDoUpdate({
170
+ target: [memories.name, memories.project],
171
+ set: {
172
+ content,
173
+ metadata: metadata ? JSON.stringify(metadata) : null,
174
+ cachedAt: now,
175
+ expiresAt,
176
+ synced: true,
177
+ },
178
+ });
179
+ }
180
+
181
+ async cleanupExpiredEntries(): Promise<{ documentsDeleted: number; memoriesDeleted: number }> {
182
+ const now = new Date();
183
+
184
+ // Delete expired documents (use lte to include items expiring exactly now)
185
+ const deletedDocs = await this.db
186
+ .delete(documents)
187
+ .where(lte(documents.expiresAt, now))
188
+ .returning({ id: documents.id });
189
+
190
+ // Delete expired memories (use lte to include items expiring exactly now)
191
+ const deletedMems = await this.db
192
+ .delete(memories)
193
+ .where(lte(memories.expiresAt, now))
194
+ .returning({ id: memories.id });
195
+
196
+ return {
197
+ documentsDeleted: deletedDocs.length,
198
+ memoriesDeleted: deletedMems.length,
199
+ };
200
+ }
201
+ }
@@ -0,0 +1,156 @@
1
+ import { EventEmitter } from 'events';
2
+ import { ConnectionState } from '../types/mcp.js';
3
+ import type { Config } from '../types/config.js';
4
+ import type { QwickBrainClient } from './qwickbrain-client.js';
5
+
6
+ export class ConnectionManager extends EventEmitter {
7
+ private state: ConnectionState = 'disconnected';
8
+ private reconnectAttempts = 0;
9
+ private reconnectTimer: NodeJS.Timeout | null = null;
10
+ private healthCheckTimer: NodeJS.Timeout | null = null;
11
+ private qwickbrainClient: QwickBrainClient;
12
+ private config: Config['connection'];
13
+ private isStopped = false;
14
+ private executionLock: Promise<void> = Promise.resolve();
15
+
16
+ constructor(qwickbrainClient: QwickBrainClient, config: Config['connection']) {
17
+ super();
18
+ this.qwickbrainClient = qwickbrainClient;
19
+ this.config = config;
20
+ }
21
+
22
+ getState(): ConnectionState {
23
+ return this.state;
24
+ }
25
+
26
+ setState(state: ConnectionState): void {
27
+ const previousState = this.state;
28
+ this.state = state;
29
+
30
+ if (previousState !== state) {
31
+ this.emit('stateChange', { from: previousState, to: state });
32
+ }
33
+ }
34
+
35
+ async start(): Promise<void> {
36
+ this.isStopped = false;
37
+ await this.healthCheck();
38
+ this.scheduleHealthCheck();
39
+ }
40
+
41
+ stop(): void {
42
+ this.isStopped = true;
43
+ if (this.healthCheckTimer) {
44
+ clearTimeout(this.healthCheckTimer);
45
+ this.healthCheckTimer = null;
46
+ }
47
+ if (this.reconnectTimer) {
48
+ clearTimeout(this.reconnectTimer);
49
+ this.reconnectTimer = null;
50
+ }
51
+ this.setState('offline');
52
+ }
53
+
54
+ async healthCheck(): Promise<boolean> {
55
+ const startTime = Date.now();
56
+
57
+ try {
58
+ const isHealthy = await this.qwickbrainClient.healthCheck();
59
+ const latencyMs = Date.now() - startTime;
60
+
61
+ if (isHealthy) {
62
+ this.setState('connected');
63
+ this.reconnectAttempts = 0;
64
+ this.clearReconnectTimer();
65
+ this.emit('connected', { latencyMs });
66
+ return true;
67
+ } else {
68
+ throw new Error('Health check failed');
69
+ }
70
+ } catch (error) {
71
+ const errorMessage = error instanceof Error ? error.message : String(error);
72
+ this.setState('disconnected');
73
+ this.emit('disconnected', { error: errorMessage });
74
+ this.scheduleReconnect();
75
+ return false;
76
+ }
77
+ }
78
+
79
+ private scheduleHealthCheck(): void {
80
+ // Don't schedule if stopped
81
+ if (this.isStopped) {
82
+ return;
83
+ }
84
+
85
+ if (this.healthCheckTimer) {
86
+ clearTimeout(this.healthCheckTimer);
87
+ }
88
+
89
+ this.healthCheckTimer = setTimeout(async () => {
90
+ await this.healthCheck();
91
+ // Check again before rescheduling to prevent leak after stop()
92
+ if (!this.isStopped) {
93
+ this.scheduleHealthCheck();
94
+ }
95
+ }, this.config.healthCheckInterval);
96
+ }
97
+
98
+ private scheduleReconnect(): void {
99
+ if (this.reconnectTimer) {
100
+ return; // Already scheduled
101
+ }
102
+
103
+ if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
104
+ this.setState('offline');
105
+ this.emit('maxReconnectAttemptsReached');
106
+ return;
107
+ }
108
+
109
+ const delay = Math.min(
110
+ this.config.reconnectBackoff.initial *
111
+ Math.pow(this.config.reconnectBackoff.multiplier, this.reconnectAttempts),
112
+ this.config.reconnectBackoff.max
113
+ );
114
+
115
+ this.reconnectAttempts++;
116
+ this.setState('reconnecting');
117
+
118
+ this.reconnectTimer = setTimeout(async () => {
119
+ this.reconnectTimer = null;
120
+ await this.healthCheck();
121
+ }, delay);
122
+
123
+ this.emit('reconnecting', { attempt: this.reconnectAttempts, delay });
124
+ }
125
+
126
+ private clearReconnectTimer(): void {
127
+ if (this.reconnectTimer) {
128
+ clearTimeout(this.reconnectTimer);
129
+ this.reconnectTimer = null;
130
+ }
131
+ }
132
+
133
+ recordFailure(): void {
134
+ if (this.state === 'connected') {
135
+ this.setState('disconnected');
136
+ this.scheduleReconnect();
137
+ }
138
+ }
139
+
140
+ async execute<T>(fn: () => Promise<T>): Promise<T> {
141
+ // Wait for any ongoing state transitions to complete
142
+ await this.executionLock;
143
+
144
+ // Atomically check state and execute
145
+ if (this.state !== 'connected') {
146
+ throw new Error(`QwickBrain unavailable (state: ${this.state})`);
147
+ }
148
+
149
+ try {
150
+ return await fn();
151
+ } catch (error) {
152
+ this.recordFailure();
153
+ throw error;
154
+ }
155
+ }
156
+ }