@qwickapps/qwickbrain-proxy 1.0.2 → 1.0.3

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 (63) hide show
  1. package/.claude/engineering/bugs/BUG-qwickbrain-proxy-cache-and-design.md +840 -0
  2. package/.github/workflows/publish.yml +13 -0
  3. package/CHANGELOG.md +17 -0
  4. package/dist/db/schema.d.ts +63 -6
  5. package/dist/db/schema.d.ts.map +1 -1
  6. package/dist/db/schema.js +17 -2
  7. package/dist/db/schema.js.map +1 -1
  8. package/dist/lib/__tests__/cache-manager.test.js +146 -83
  9. package/dist/lib/__tests__/cache-manager.test.js.map +1 -1
  10. package/dist/lib/__tests__/proxy-server.test.js +16 -44
  11. package/dist/lib/__tests__/proxy-server.test.js.map +1 -1
  12. package/dist/lib/__tests__/sse-invalidation-listener.test.d.ts +2 -0
  13. package/dist/lib/__tests__/sse-invalidation-listener.test.d.ts.map +1 -0
  14. package/dist/lib/__tests__/sse-invalidation-listener.test.js +245 -0
  15. package/dist/lib/__tests__/sse-invalidation-listener.test.js.map +1 -0
  16. package/dist/lib/__tests__/write-queue-manager.test.d.ts +2 -0
  17. package/dist/lib/__tests__/write-queue-manager.test.d.ts.map +1 -0
  18. package/dist/lib/__tests__/write-queue-manager.test.js +291 -0
  19. package/dist/lib/__tests__/write-queue-manager.test.js.map +1 -0
  20. package/dist/lib/cache-manager.d.ts +35 -6
  21. package/dist/lib/cache-manager.d.ts.map +1 -1
  22. package/dist/lib/cache-manager.js +154 -41
  23. package/dist/lib/cache-manager.js.map +1 -1
  24. package/dist/lib/proxy-server.d.ts +5 -0
  25. package/dist/lib/proxy-server.d.ts.map +1 -1
  26. package/dist/lib/proxy-server.js +150 -84
  27. package/dist/lib/proxy-server.js.map +1 -1
  28. package/dist/lib/qwickbrain-client.d.ts +4 -0
  29. package/dist/lib/qwickbrain-client.d.ts.map +1 -1
  30. package/dist/lib/qwickbrain-client.js +131 -2
  31. package/dist/lib/qwickbrain-client.js.map +1 -1
  32. package/dist/lib/sse-invalidation-listener.d.ts +27 -0
  33. package/dist/lib/sse-invalidation-listener.d.ts.map +1 -0
  34. package/dist/lib/sse-invalidation-listener.js +145 -0
  35. package/dist/lib/sse-invalidation-listener.js.map +1 -0
  36. package/dist/lib/tools.d.ts +21 -0
  37. package/dist/lib/tools.d.ts.map +1 -0
  38. package/dist/lib/tools.js +488 -0
  39. package/dist/lib/tools.js.map +1 -0
  40. package/dist/lib/write-queue-manager.d.ts +88 -0
  41. package/dist/lib/write-queue-manager.d.ts.map +1 -0
  42. package/dist/lib/write-queue-manager.js +191 -0
  43. package/dist/lib/write-queue-manager.js.map +1 -0
  44. package/dist/types/config.d.ts +7 -42
  45. package/dist/types/config.d.ts.map +1 -1
  46. package/dist/types/config.js +1 -6
  47. package/dist/types/config.js.map +1 -1
  48. package/drizzle/0002_lru_cache_migration.sql +94 -0
  49. package/drizzle/meta/_journal.json +7 -0
  50. package/package.json +6 -2
  51. package/scripts/rebuild-sqlite.sh +26 -0
  52. package/src/db/schema.ts +17 -2
  53. package/src/lib/__tests__/cache-manager.test.ts +180 -90
  54. package/src/lib/__tests__/proxy-server.test.ts +16 -51
  55. package/src/lib/__tests__/sse-invalidation-listener.test.ts +326 -0
  56. package/src/lib/__tests__/write-queue-manager.test.ts +383 -0
  57. package/src/lib/cache-manager.ts +198 -46
  58. package/src/lib/proxy-server.ts +190 -86
  59. package/src/lib/qwickbrain-client.ts +142 -2
  60. package/src/lib/sse-invalidation-listener.ts +171 -0
  61. package/src/lib/tools.ts +500 -0
  62. package/src/lib/write-queue-manager.ts +271 -0
  63. package/src/types/config.ts +1 -6
@@ -7,6 +7,9 @@ import {
7
7
  import { ConnectionManager } from './connection-manager.js';
8
8
  import { CacheManager } from './cache-manager.js';
9
9
  import { QwickBrainClient } from './qwickbrain-client.js';
10
+ import { WriteQueueManager } from './write-queue-manager.js';
11
+ import { SSEInvalidationListener } from './sse-invalidation-listener.js';
12
+ import { QWICKBRAIN_TOOLS, requiresConnection } from './tools.js';
10
13
  import type { Config } from '../types/config.js';
11
14
  import type { DB } from '../db/client.js';
12
15
  import type { MCPResponse, MCPResponseMetadata } from '../types/mcp.js';
@@ -17,6 +20,8 @@ export class ProxyServer {
17
20
  private connectionManager: ConnectionManager;
18
21
  private cacheManager: CacheManager;
19
22
  private qwickbrainClient: QwickBrainClient;
23
+ private writeQueueManager: WriteQueueManager;
24
+ private sseInvalidationListener: SSEInvalidationListener | null = null;
20
25
  private config: Config;
21
26
 
22
27
  constructor(db: DB, config: Config) {
@@ -41,6 +46,16 @@ export class ProxyServer {
41
46
  );
42
47
 
43
48
  this.cacheManager = new CacheManager(db, config.cache);
49
+ this.writeQueueManager = new WriteQueueManager(db, this.qwickbrainClient);
50
+
51
+ // Initialize SSE invalidation listener if in SSE mode
52
+ if (config.qwickbrain.mode === 'sse' && config.qwickbrain.url) {
53
+ this.sseInvalidationListener = new SSEInvalidationListener(
54
+ config.qwickbrain.url,
55
+ this.cacheManager,
56
+ config.qwickbrain.apiKey
57
+ );
58
+ }
44
59
 
45
60
  this.setupHandlers();
46
61
  this.setupConnectionListeners();
@@ -61,6 +76,10 @@ export class ProxyServer {
61
76
  this.onConnectionRestored().catch(err => {
62
77
  console.error('Background sync error:', err);
63
78
  });
79
+ // Sync pending write operations
80
+ this.syncWriteQueue().catch(err => {
81
+ console.error('Write queue sync error:', err);
82
+ });
64
83
  });
65
84
 
66
85
  this.connectionManager.on('disconnected', ({ error }) => {
@@ -90,61 +109,21 @@ export class ProxyServer {
90
109
  console.error('Background cache sync complete');
91
110
  }
92
111
 
112
+ private async syncWriteQueue(): Promise<void> {
113
+ const pendingCount = await this.writeQueueManager.getPendingCount();
114
+ if (pendingCount === 0) {
115
+ return;
116
+ }
117
+
118
+ console.error(`Syncing ${pendingCount} pending write operations...`);
119
+ const { synced, failed } = await this.writeQueueManager.syncPendingOperations();
120
+ console.error(`Write queue sync complete: ${synced} synced, ${failed} failed`);
121
+ }
122
+
93
123
  private setupHandlers(): void {
124
+ // Static tool listing - always returns all tools regardless of connection state
94
125
  this.server.setRequestHandler(ListToolsRequestSchema, async () => {
95
- // Try to fetch tools from upstream if connected
96
- if (this.connectionManager.getState() === 'connected') {
97
- try {
98
- const tools = await this.connectionManager.execute(async () => {
99
- return await this.qwickbrainClient.listTools();
100
- });
101
- return { tools };
102
- } catch (error) {
103
- console.error('Failed to list tools from upstream:', error);
104
- }
105
- }
106
-
107
- // Fallback to minimal tool set when offline or error
108
- return {
109
- tools: [
110
- {
111
- name: 'get_workflow',
112
- description: 'Get a workflow definition by name (cached)',
113
- inputSchema: {
114
- type: 'object',
115
- properties: {
116
- name: { type: 'string', description: 'Workflow name' },
117
- },
118
- required: ['name'],
119
- },
120
- },
121
- {
122
- name: 'get_document',
123
- description: 'Get a document by name and type (cached)',
124
- inputSchema: {
125
- type: 'object',
126
- properties: {
127
- name: { type: 'string', description: 'Document name' },
128
- doc_type: { type: 'string', description: 'Document type (rule, frd, design, etc.)' },
129
- project: { type: 'string', description: 'Project name (optional)' },
130
- },
131
- required: ['name', 'doc_type'],
132
- },
133
- },
134
- {
135
- name: 'get_memory',
136
- description: 'Get a memory/context document by name (cached)',
137
- inputSchema: {
138
- type: 'object',
139
- properties: {
140
- name: { type: 'string', description: 'Memory name' },
141
- project: { type: 'string', description: 'Project name (optional)' },
142
- },
143
- required: ['name'],
144
- },
145
- },
146
- ],
147
- };
126
+ return { tools: QWICKBRAIN_TOOLS };
148
127
  });
149
128
 
150
129
  this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -171,8 +150,54 @@ export class ProxyServer {
171
150
  args?.project as string | undefined
172
151
  );
173
152
  break;
153
+ case 'create_document':
154
+ case 'update_document':
155
+ result = await this.handleCreateDocument(
156
+ args?.doc_type as string,
157
+ args?.name as string,
158
+ args?.content as string,
159
+ args?.project as string | undefined,
160
+ args?.metadata as Record<string, unknown> | undefined
161
+ );
162
+ break;
163
+ case 'set_memory':
164
+ case 'update_memory':
165
+ result = await this.handleSetMemory(
166
+ args?.name as string,
167
+ args?.content as string,
168
+ args?.project as string | undefined,
169
+ args?.metadata as Record<string, unknown> | undefined
170
+ );
171
+ break;
174
172
  default:
175
173
  // Generic forwarding for all other tools (analyze_repository, search_codebase, etc.)
174
+ // Check if tool requires connection
175
+ if (requiresConnection(name) && this.connectionManager.getState() !== 'connected') {
176
+ // Return offline error for non-cacheable tools
177
+ return {
178
+ content: [
179
+ {
180
+ type: 'text',
181
+ text: JSON.stringify({
182
+ error: {
183
+ code: 'OFFLINE',
184
+ message: `QwickBrain offline - "${name}" requires active connection`,
185
+ suggestions: [
186
+ 'Check internet connection',
187
+ 'Wait for automatic reconnection',
188
+ 'Cached tools (get_workflow, get_document, get_memory) work offline',
189
+ ],
190
+ },
191
+ _metadata: {
192
+ source: 'cache',
193
+ status: this.connectionManager.getState(),
194
+ },
195
+ }, null, 2),
196
+ },
197
+ ],
198
+ isError: true,
199
+ };
200
+ }
176
201
  result = await this.handleGenericTool(name, args || {});
177
202
  break;
178
203
  }
@@ -226,17 +251,17 @@ export class ProxyServer {
226
251
  name: string,
227
252
  project?: string
228
253
  ): Promise<MCPResponse> {
229
- // Try cache first
254
+ // Try cache first (LRU cache never expires, always valid if present)
230
255
  const cached = await this.cacheManager.getDocument(docType, name, project);
231
256
 
232
- if (cached && !cached.isExpired) {
257
+ if (cached) {
233
258
  return {
234
259
  data: cached.data,
235
260
  _metadata: this.createMetadata('cache', cached.age),
236
261
  };
237
262
  }
238
263
 
239
- // Try remote if connected
264
+ // Not cached - try remote if connected
240
265
  if (this.connectionManager.getState() === 'connected') {
241
266
  try {
242
267
  const result = await this.connectionManager.execute(async () => {
@@ -258,22 +283,11 @@ export class ProxyServer {
258
283
  };
259
284
  } catch (error) {
260
285
  console.error('Failed to fetch from QwickBrain:', error);
261
- // Fall through to stale cache
286
+ // Fall through to error
262
287
  }
263
288
  }
264
289
 
265
- // Try stale cache
266
- if (cached) {
267
- return {
268
- data: cached.data,
269
- _metadata: {
270
- ...this.createMetadata('stale_cache', cached.age),
271
- warning: `QwickBrain unavailable - serving cached data (${cached.age}s old)`,
272
- },
273
- };
274
- }
275
-
276
- // No cache, no connection
290
+ // No cache and remote failed/unavailable
277
291
  return {
278
292
  error: {
279
293
  code: 'UNAVAILABLE',
@@ -289,16 +303,17 @@ export class ProxyServer {
289
303
  }
290
304
 
291
305
  private async handleGetMemory(name: string, project?: string): Promise<MCPResponse> {
292
- // Similar logic to handleGetDocument but for memories
306
+ // Try cache first (LRU cache never expires, always valid if present)
293
307
  const cached = await this.cacheManager.getMemory(name, project);
294
308
 
295
- if (cached && !cached.isExpired) {
309
+ if (cached) {
296
310
  return {
297
311
  data: cached.data,
298
312
  _metadata: this.createMetadata('cache', cached.age),
299
313
  };
300
314
  }
301
315
 
316
+ // Not cached - try remote if connected
302
317
  if (this.connectionManager.getState() === 'connected') {
303
318
  try {
304
319
  const result = await this.connectionManager.execute(async () => {
@@ -313,19 +328,11 @@ export class ProxyServer {
313
328
  };
314
329
  } catch (error) {
315
330
  console.error('Failed to fetch memory from QwickBrain:', error);
331
+ // Fall through to error
316
332
  }
317
333
  }
318
334
 
319
- if (cached) {
320
- return {
321
- data: cached.data,
322
- _metadata: {
323
- ...this.createMetadata('stale_cache', cached.age),
324
- warning: `QwickBrain unavailable - serving cached memory (${cached.age}s old)`,
325
- },
326
- };
327
- }
328
-
335
+ // No cache and remote failed/unavailable
329
336
  return {
330
337
  error: {
331
338
  code: 'UNAVAILABLE',
@@ -336,6 +343,94 @@ export class ProxyServer {
336
343
  };
337
344
  }
338
345
 
346
+ private async handleCreateDocument(
347
+ docType: string,
348
+ name: string,
349
+ content: string,
350
+ project?: string,
351
+ metadata?: Record<string, unknown>
352
+ ): Promise<MCPResponse> {
353
+ // Always update local cache first
354
+ await this.cacheManager.setDocument(docType, name, content, project, metadata);
355
+
356
+ // If connected, sync immediately
357
+ if (this.connectionManager.getState() === 'connected') {
358
+ try {
359
+ await this.connectionManager.execute(async () => {
360
+ await this.qwickbrainClient.createDocument(docType, name, content, project, metadata);
361
+ });
362
+
363
+ return {
364
+ data: { success: true },
365
+ _metadata: this.createMetadata('live'),
366
+ };
367
+ } catch (error) {
368
+ console.error('Failed to create document on QwickBrain:', error);
369
+ // Fall through to queue
370
+ }
371
+ }
372
+
373
+ // If offline or sync failed, queue for later
374
+ await this.writeQueueManager.queueOperation('create_document', {
375
+ docType,
376
+ name,
377
+ content,
378
+ project,
379
+ metadata,
380
+ });
381
+
382
+ return {
383
+ data: { success: true, queued: true },
384
+ _metadata: {
385
+ ...this.createMetadata('cache'),
386
+ warning: 'Operation queued - will sync when connection restored',
387
+ },
388
+ };
389
+ }
390
+
391
+ private async handleSetMemory(
392
+ name: string,
393
+ content: string,
394
+ project?: string,
395
+ metadata?: Record<string, unknown>
396
+ ): Promise<MCPResponse> {
397
+ // Always update local cache first
398
+ await this.cacheManager.setMemory(name, content, project, metadata);
399
+
400
+ // If connected, sync immediately
401
+ if (this.connectionManager.getState() === 'connected') {
402
+ try {
403
+ await this.connectionManager.execute(async () => {
404
+ await this.qwickbrainClient.setMemory(name, content, project, metadata);
405
+ });
406
+
407
+ return {
408
+ data: { success: true },
409
+ _metadata: this.createMetadata('live'),
410
+ };
411
+ } catch (error) {
412
+ console.error('Failed to set memory on QwickBrain:', error);
413
+ // Fall through to queue
414
+ }
415
+ }
416
+
417
+ // If offline or sync failed, queue for later
418
+ await this.writeQueueManager.queueOperation('set_memory', {
419
+ name,
420
+ content,
421
+ project,
422
+ metadata,
423
+ });
424
+
425
+ return {
426
+ data: { success: true, queued: true },
427
+ _metadata: {
428
+ ...this.createMetadata('cache'),
429
+ warning: 'Operation queued - will sync when connection restored',
430
+ },
431
+ };
432
+ }
433
+
339
434
  private async handleGenericTool(name: string, args: Record<string, unknown>): Promise<MCPResponse> {
340
435
  // Generic tool forwarding - no caching for non-document tools
341
436
  if (this.connectionManager.getState() !== 'connected') {
@@ -388,15 +483,18 @@ export class ProxyServer {
388
483
  }
389
484
 
390
485
  async start(): Promise<void> {
391
- // Clean up expired cache entries on startup
392
- const { documentsDeleted, memoriesDeleted } = await this.cacheManager.cleanupExpiredEntries();
393
- if (documentsDeleted > 0 || memoriesDeleted > 0) {
394
- console.error(`Cache cleanup: removed ${documentsDeleted} documents, ${memoriesDeleted} memories`);
395
- }
486
+ // LRU cache handles eviction automatically when storage limit reached
487
+ // No need for startup cleanup with LRU-based cache
396
488
 
397
489
  // Start connection manager (handles connection gracefully, doesn't throw)
398
490
  await this.connectionManager.start();
399
491
 
492
+ // Start SSE invalidation listener if configured
493
+ if (this.sseInvalidationListener) {
494
+ await this.sseInvalidationListener.start();
495
+ console.error('SSE cache invalidation listener started');
496
+ }
497
+
400
498
  // Start MCP server
401
499
  const transport = new StdioServerTransport();
402
500
  await this.server.connect(transport);
@@ -406,6 +504,12 @@ export class ProxyServer {
406
504
 
407
505
  async stop(): Promise<void> {
408
506
  this.connectionManager.stop();
507
+
508
+ // Stop SSE invalidation listener
509
+ if (this.sseInvalidationListener) {
510
+ this.sseInvalidationListener.stop();
511
+ }
512
+
409
513
  try {
410
514
  await this.qwickbrainClient.disconnect();
411
515
  } catch (error) {
@@ -232,8 +232,11 @@ export class QwickBrainClient {
232
232
  if (this.mode === 'mcp' || this.mode === 'sse') {
233
233
  // For MCP/SSE mode, check if client is connected
234
234
  if (!this.client) {
235
- // Don't create new connection in health check - just return false
236
- return false;
235
+ // Initialize connection on first health check
236
+ await this.connect();
237
+ if (!this.client) {
238
+ return false;
239
+ }
237
240
  }
238
241
  // Try listing tools as health check
239
242
  await this.client.listTools();
@@ -310,6 +313,143 @@ export class QwickBrainClient {
310
313
  }
311
314
  }
312
315
 
316
+ async createDocument(
317
+ docType: string,
318
+ name: string,
319
+ content: string,
320
+ project?: string,
321
+ metadata?: Record<string, unknown>
322
+ ): Promise<void> {
323
+ if (this.mode === 'mcp' || this.mode === 'sse') {
324
+ if (!this.client) {
325
+ throw new Error('MCP client not connected');
326
+ }
327
+ await this.client.callTool({
328
+ name: 'create_document',
329
+ arguments: {
330
+ doc_type: docType,
331
+ name,
332
+ content,
333
+ project,
334
+ metadata,
335
+ },
336
+ });
337
+ } else {
338
+ if (!this.config.url) {
339
+ throw new Error('HTTP mode requires url to be configured');
340
+ }
341
+ const response = await fetch(`${this.config.url}/mcp/document/create`, {
342
+ method: 'POST',
343
+ headers: {
344
+ 'Content-Type': 'application/json',
345
+ ...(this.config.apiKey && { 'Authorization': `Bearer ${this.config.apiKey}` }),
346
+ },
347
+ body: JSON.stringify({ doc_type: docType, name, content, project, metadata }),
348
+ });
349
+ if (!response.ok) {
350
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
351
+ }
352
+ }
353
+ }
354
+
355
+ async setMemory(
356
+ name: string,
357
+ content: string,
358
+ project?: string,
359
+ metadata?: Record<string, unknown>
360
+ ): Promise<void> {
361
+ if (this.mode === 'mcp' || this.mode === 'sse') {
362
+ if (!this.client) {
363
+ throw new Error('MCP client not connected');
364
+ }
365
+ await this.client.callTool({
366
+ name: 'set_memory',
367
+ arguments: {
368
+ name,
369
+ content,
370
+ project,
371
+ metadata,
372
+ },
373
+ });
374
+ } else {
375
+ if (!this.config.url) {
376
+ throw new Error('HTTP mode requires url to be configured');
377
+ }
378
+ const response = await fetch(`${this.config.url}/mcp/memory/set`, {
379
+ method: 'POST',
380
+ headers: {
381
+ 'Content-Type': 'application/json',
382
+ ...(this.config.apiKey && { 'Authorization': `Bearer ${this.config.apiKey}` }),
383
+ },
384
+ body: JSON.stringify({ name, content, project, metadata }),
385
+ });
386
+ if (!response.ok) {
387
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
388
+ }
389
+ }
390
+ }
391
+
392
+ async deleteDocument(docType: string, name: string, project?: string): Promise<void> {
393
+ if (this.mode === 'mcp' || this.mode === 'sse') {
394
+ if (!this.client) {
395
+ throw new Error('MCP client not connected');
396
+ }
397
+ await this.client.callTool({
398
+ name: 'delete_document',
399
+ arguments: {
400
+ doc_type: docType,
401
+ name,
402
+ project,
403
+ },
404
+ });
405
+ } else {
406
+ if (!this.config.url) {
407
+ throw new Error('HTTP mode requires url to be configured');
408
+ }
409
+ const response = await fetch(`${this.config.url}/mcp/document/delete`, {
410
+ method: 'POST',
411
+ headers: {
412
+ 'Content-Type': 'application/json',
413
+ ...(this.config.apiKey && { 'Authorization': `Bearer ${this.config.apiKey}` }),
414
+ },
415
+ body: JSON.stringify({ doc_type: docType, name, project }),
416
+ });
417
+ if (!response.ok) {
418
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
419
+ }
420
+ }
421
+ }
422
+
423
+ async deleteMemory(name: string, project?: string): Promise<void> {
424
+ if (this.mode === 'mcp' || this.mode === 'sse') {
425
+ if (!this.client) {
426
+ throw new Error('MCP client not connected');
427
+ }
428
+ await this.client.callTool({
429
+ name: 'delete_memory',
430
+ arguments: {
431
+ name,
432
+ project,
433
+ },
434
+ });
435
+ } else {
436
+ if (!this.config.url) {
437
+ throw new Error('HTTP mode requires url to be configured');
438
+ }
439
+ const response = await fetch(`${this.config.url}/mcp/memory/delete`, {
440
+ method: 'POST',
441
+ headers: {
442
+ 'Content-Type': 'application/json',
443
+ ...(this.config.apiKey && { 'Authorization': `Bearer ${this.config.apiKey}` }),
444
+ },
445
+ body: JSON.stringify({ name, project }),
446
+ });
447
+ if (!response.ok) {
448
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
449
+ }
450
+ }
451
+ }
452
+
313
453
  async disconnect(): Promise<void> {
314
454
  if ((this.mode === 'mcp' || this.mode === 'sse') && this.client && this.transport) {
315
455
  await this.client.close();