memory-journal-mcp 4.3.1 → 4.4.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 (103) hide show
  1. package/.dockerignore +131 -122
  2. package/.gitattributes +29 -0
  3. package/.github/workflows/docker-publish.yml +1 -1
  4. package/.github/workflows/lint-and-test.yml +1 -2
  5. package/.github/workflows/secrets-scanning.yml +0 -1
  6. package/.github/workflows/security-update.yml +6 -6
  7. package/.vscode/settings.json +17 -15
  8. package/CHANGELOG.md +1065 -11
  9. package/DOCKER_README.md +51 -33
  10. package/Dockerfile +12 -11
  11. package/README.md +68 -33
  12. package/SECURITY.md +225 -158
  13. package/dist/cli.js +7 -0
  14. package/dist/cli.js.map +1 -1
  15. package/dist/constants/ServerInstructions.d.ts +1 -1
  16. package/dist/constants/ServerInstructions.d.ts.map +1 -1
  17. package/dist/constants/ServerInstructions.js +70 -26
  18. package/dist/constants/ServerInstructions.js.map +1 -1
  19. package/dist/constants/icons.d.ts +2 -0
  20. package/dist/constants/icons.d.ts.map +1 -1
  21. package/dist/constants/icons.js +6 -0
  22. package/dist/constants/icons.js.map +1 -1
  23. package/dist/database/SqliteAdapter.d.ts +51 -10
  24. package/dist/database/SqliteAdapter.d.ts.map +1 -1
  25. package/dist/database/SqliteAdapter.js +140 -31
  26. package/dist/database/SqliteAdapter.js.map +1 -1
  27. package/dist/filtering/ToolFilter.d.ts +1 -1
  28. package/dist/filtering/ToolFilter.d.ts.map +1 -1
  29. package/dist/filtering/ToolFilter.js +7 -1
  30. package/dist/filtering/ToolFilter.js.map +1 -1
  31. package/dist/github/GitHubIntegration.d.ts +74 -2
  32. package/dist/github/GitHubIntegration.d.ts.map +1 -1
  33. package/dist/github/GitHubIntegration.js +508 -7
  34. package/dist/github/GitHubIntegration.js.map +1 -1
  35. package/dist/handlers/prompts/index.js +1 -0
  36. package/dist/handlers/prompts/index.js.map +1 -1
  37. package/dist/handlers/resources/index.d.ts.map +1 -1
  38. package/dist/handlers/resources/index.js +257 -13
  39. package/dist/handlers/resources/index.js.map +1 -1
  40. package/dist/handlers/tools/index.d.ts.map +1 -1
  41. package/dist/handlers/tools/index.js +590 -7
  42. package/dist/handlers/tools/index.js.map +1 -1
  43. package/dist/server/McpServer.d.ts +2 -0
  44. package/dist/server/McpServer.d.ts.map +1 -1
  45. package/dist/server/McpServer.js +69 -26
  46. package/dist/server/McpServer.js.map +1 -1
  47. package/dist/types/index.d.ts +97 -0
  48. package/dist/types/index.d.ts.map +1 -1
  49. package/dist/types/index.js.map +1 -1
  50. package/dist/utils/logger.d.ts +1 -0
  51. package/dist/utils/logger.d.ts.map +1 -1
  52. package/dist/utils/logger.js +8 -1
  53. package/dist/utils/logger.js.map +1 -1
  54. package/dist/utils/security-utils.d.ts +8 -0
  55. package/dist/utils/security-utils.d.ts.map +1 -1
  56. package/dist/utils/security-utils.js +32 -0
  57. package/dist/utils/security-utils.js.map +1 -1
  58. package/dist/vector/VectorSearchManager.d.ts +2 -1
  59. package/dist/vector/VectorSearchManager.d.ts.map +1 -1
  60. package/dist/vector/VectorSearchManager.js +100 -34
  61. package/dist/vector/VectorSearchManager.js.map +1 -1
  62. package/docker-compose.yml +46 -37
  63. package/mcp-config-example.json +0 -2
  64. package/package.json +12 -11
  65. package/releases/v4.3.1.md +69 -0
  66. package/releases/v4.4.0.md +120 -0
  67. package/server.json +3 -3
  68. package/src/cli.ts +11 -0
  69. package/src/constants/ServerInstructions.ts +70 -26
  70. package/src/constants/icons.ts +7 -0
  71. package/src/database/SqliteAdapter.ts +162 -32
  72. package/src/filtering/ToolFilter.ts +7 -1
  73. package/src/github/GitHubIntegration.ts +588 -8
  74. package/src/handlers/prompts/index.ts +1 -0
  75. package/src/handlers/resources/index.ts +318 -12
  76. package/src/handlers/tools/index.ts +681 -12
  77. package/src/server/McpServer.ts +79 -37
  78. package/src/types/index.ts +98 -0
  79. package/src/utils/logger.ts +10 -1
  80. package/src/utils/security-utils.ts +35 -0
  81. package/src/vector/VectorSearchManager.ts +110 -39
  82. package/tests/constants/icons.test.ts +102 -0
  83. package/tests/constants/server-instructions.test.ts +549 -0
  84. package/tests/database/sqlite-adapter.bench.ts +63 -0
  85. package/tests/database/sqlite-adapter.test.ts +555 -0
  86. package/tests/filtering/tool-filter.test.ts +266 -0
  87. package/tests/github/github-integration.test.ts +1024 -0
  88. package/tests/handlers/github-resource-handlers.test.ts +473 -0
  89. package/tests/handlers/github-tool-handlers.test.ts +556 -0
  90. package/tests/handlers/prompt-handlers.test.ts +91 -0
  91. package/tests/handlers/resource-handlers.test.ts +339 -0
  92. package/tests/handlers/tool-handlers.test.ts +497 -0
  93. package/tests/handlers/vector-tool-handlers.test.ts +238 -0
  94. package/tests/server/mcp-server.bench.ts +55 -0
  95. package/tests/server/mcp-server.test.ts +675 -0
  96. package/tests/utils/logger.test.ts +180 -0
  97. package/tests/utils/mcp-logger.test.ts +212 -0
  98. package/tests/utils/progress-utils.test.ts +156 -0
  99. package/tests/utils/security-utils.test.ts +82 -0
  100. package/tests/vector/vector-search-manager.test.ts +335 -0
  101. package/tests/vector/vector-search.bench.ts +53 -0
  102. package/.github/workflows/DOCKER_DEPLOYMENT_SETUP.md +0 -387
  103. package/.github/workflows/dependabot-auto-merge.yml +0 -42
@@ -31,14 +31,22 @@ import { getPrompts, getPrompt } from '../handlers/prompts/index.js'
31
31
  import { generateInstructions } from '../constants/ServerInstructions.js'
32
32
  import pkg from '../../package.json' with { type: 'json' }
33
33
 
34
+ /** Session timeout for stateful HTTP mode (30 minutes) */
35
+ const SESSION_TIMEOUT_MS = 30 * 60 * 1000
36
+
37
+ /** Session timeout sweep interval (5 minutes) */
38
+ const SESSION_SWEEP_INTERVAL_MS = 5 * 60 * 1000
39
+
34
40
  export interface ServerOptions {
35
41
  transport: 'stdio' | 'http'
36
42
  port?: number
43
+ host?: string
37
44
  dbPath: string
38
45
  toolFilter?: string
39
46
  defaultProjectNumber?: number
40
47
  autoRebuildIndex?: boolean
41
48
  statelessHttp?: boolean
49
+ corsOrigin?: string
42
50
  }
43
51
 
44
52
  /**
@@ -107,14 +115,13 @@ export async function createServer(options: ServerOptions): Promise<void> {
107
115
  }
108
116
  : undefined
109
117
 
118
+ // Get all tools once (unfiltered) for both instruction generation and registration
119
+ const allTools = getTools(db, null, vectorManager, github, { defaultProjectNumber })
120
+ const allToolNames = new Set(allTools.map((t) => (t as { name: string }).name))
121
+
110
122
  // Generate dynamic instructions based on enabled tools, resources, prompts, and latest entry
111
123
  const instructions = generateInstructions(
112
- filterConfig?.enabledTools ??
113
- new Set(
114
- getTools(db, null, vectorManager, github, { defaultProjectNumber }).map(
115
- (t) => (t as { name: string }).name
116
- )
117
- ),
124
+ filterConfig?.enabledTools ?? allToolNames,
118
125
  resources.map((r) => {
119
126
  const res = r as { uri: string; name: string; description?: string }
120
127
  return { uri: res.uri, name: res.name, description: res.description }
@@ -140,8 +147,10 @@ export async function createServer(options: ServerOptions): Promise<void> {
140
147
  }
141
148
  )
142
149
 
143
- // Get filtered tools and register them dynamically
144
- const tools = getTools(db, filterConfig, vectorManager, github, { defaultProjectNumber })
150
+ // Apply filter to get the set of tools to register
151
+ const tools = filterConfig
152
+ ? getTools(db, filterConfig, vectorManager, github, { defaultProjectNumber })
153
+ : allTools
145
154
  for (const tool of tools) {
146
155
  const toolDef = tool as {
147
156
  name: string
@@ -394,12 +403,21 @@ export async function createServer(options: ServerOptions): Promise<void> {
394
403
  } else {
395
404
  // HTTP transport with SSE support
396
405
  const port = options.port ?? 3000
406
+ const host = options.host ?? 'localhost'
407
+ const corsOrigin = options.corsOrigin ?? process.env['MCP_CORS_ORIGIN'] ?? '*'
397
408
  const app: Express = express()
398
409
 
399
- // Manual CORS middleware for browser-based clients (e.g., MCP Inspector)
410
+ // Security headers middleware
411
+ app.use((_req: Request, res: Response, next: () => void) => {
412
+ res.setHeader('X-Content-Type-Options', 'nosniff')
413
+ res.setHeader('X-Frame-Options', 'DENY')
414
+ next()
415
+ })
416
+
417
+ // CORS middleware for browser-based clients (e.g., MCP Inspector)
418
+ // Origin is configurable via --cors-origin flag or MCP_CORS_ORIGIN env var
400
419
  app.use((req: Request, res: Response, next: () => void) => {
401
- // Set CORS headers on all responses
402
- res.setHeader('Access-Control-Allow-Origin', '*')
420
+ res.setHeader('Access-Control-Allow-Origin', corsOrigin)
403
421
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
404
422
  res.setHeader(
405
423
  'Access-Control-Allow-Headers',
@@ -416,22 +434,14 @@ export async function createServer(options: ServerOptions): Promise<void> {
416
434
  next()
417
435
  })
418
436
 
419
- // JSON body parser for MCP requests
420
- app.use(express.json())
437
+ // JSON body parser with size limit to prevent memory exhaustion (DoS)
438
+ app.use(express.json({ limit: '1mb' }))
421
439
 
422
440
  // Explicit OPTIONS handler for /mcp - MUST be before other /mcp routes
423
441
  // Using app.all to intercept before Express 5's auto-OPTIONS
424
442
  app.all('/mcp', (req: Request, res: Response, next: () => void) => {
425
- // Set CORS headers on ALL responses
426
- res.setHeader('Access-Control-Allow-Origin', '*')
427
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
428
- res.setHeader(
429
- 'Access-Control-Allow-Headers',
430
- 'Content-Type, Accept, mcp-session-id, Last-Event-ID, mcp-protocol-version'
431
- )
432
- res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id')
433
-
434
- // For OPTIONS, respond immediately with CORS headers
443
+ // CORS headers are already set by the middleware above.
444
+ // For OPTIONS, respond immediately.
435
445
  if (req.method === 'OPTIONS') {
436
446
  res.status(204).end()
437
447
  return
@@ -479,11 +489,12 @@ export async function createServer(options: ServerOptions): Promise<void> {
479
489
  })
480
490
 
481
491
  // Start HTTP server
482
- const httpServer = app.listen(port, () => {
492
+ const httpServer = app.listen(port, host, () => {
483
493
  logger.info('MCP server started on HTTP (stateless)', {
484
494
  module: 'McpServer',
485
495
  port,
486
- endpoint: `http://localhost:${port}/mcp`,
496
+ host,
497
+ endpoint: `http://${host}:${port}/mcp`,
487
498
  })
488
499
  })
489
500
 
@@ -521,8 +532,37 @@ export async function createServer(options: ServerOptions): Promise<void> {
521
532
  // === STATEFUL MODE ===
522
533
  // Session-based transport with SSE support for notifications
523
534
 
524
- // Session transport storage
535
+ // Session transport storage with last-activity timestamps
525
536
  const transports = new Map<string, StreamableHTTPServerTransport>()
537
+ const sessionLastActivity = new Map<string, number>()
538
+
539
+ /** Update the last-activity timestamp for a session */
540
+ const touchSession = (sid: string): void => {
541
+ sessionLastActivity.set(sid, Date.now())
542
+ }
543
+
544
+ /** Sweep expired sessions (called periodically) */
545
+ const sweepExpiredSessions = (): void => {
546
+ const now = Date.now()
547
+ for (const [sid, lastActivity] of sessionLastActivity) {
548
+ if (now - lastActivity > SESSION_TIMEOUT_MS && transports.has(sid)) {
549
+ logger.info('Expiring idle HTTP session', {
550
+ module: 'McpServer',
551
+ sessionId: sid,
552
+ idleMinutes: Math.round((now - lastActivity) / 60_000),
553
+ })
554
+ const t = transports.get(sid)
555
+ if (t) {
556
+ void t.close()
557
+ }
558
+ transports.delete(sid)
559
+ sessionLastActivity.delete(sid)
560
+ }
561
+ }
562
+ }
563
+
564
+ // Start session timeout sweep (runs every 5 minutes)
565
+ const sessionSweepTimer = setInterval(sweepExpiredSessions, SESSION_SWEEP_INTERVAL_MS)
526
566
 
527
567
  // POST /mcp - Handle JSON-RPC requests
528
568
  app.post('/mcp', (req: Request, res: Response): void => {
@@ -533,7 +573,8 @@ export async function createServer(options: ServerOptions): Promise<void> {
533
573
  let httpTransport: StreamableHTTPServerTransport | undefined
534
574
 
535
575
  if (sessionId && transports.has(sessionId)) {
536
- // Reuse existing transport
576
+ // Reuse existing transport and refresh session activity
577
+ touchSession(sessionId)
537
578
  httpTransport = transports.get(sessionId)
538
579
  } else if (sessionId === undefined && isInitializeRequest(req.body)) {
539
580
  // New initialization request - create transport
@@ -545,6 +586,7 @@ export async function createServer(options: ServerOptions): Promise<void> {
545
586
  sessionId: sid,
546
587
  })
547
588
  transports.set(sid, newTransport)
589
+ touchSession(sid)
548
590
  },
549
591
  })
550
592
 
@@ -557,6 +599,7 @@ export async function createServer(options: ServerOptions): Promise<void> {
557
599
  sessionId: sid,
558
600
  })
559
601
  transports.delete(sid)
602
+ sessionLastActivity.delete(sid)
560
603
  }
561
604
  }
562
605
 
@@ -614,6 +657,9 @@ export async function createServer(options: ServerOptions): Promise<void> {
614
657
  return
615
658
  }
616
659
 
660
+ // Refresh session activity on SSE reconnect
661
+ touchSession(sessionId)
662
+
617
663
  const lastEventId = req.headers['last-event-id']
618
664
  if (lastEventId !== undefined) {
619
665
  logger.debug('Client reconnecting with Last-Event-ID', {
@@ -656,11 +702,12 @@ export async function createServer(options: ServerOptions): Promise<void> {
656
702
  })
657
703
 
658
704
  // Start HTTP server
659
- const httpServer = app.listen(port, () => {
705
+ const httpServer = app.listen(port, host, () => {
660
706
  logger.info('MCP server started on HTTP (stateful)', {
661
707
  module: 'McpServer',
662
708
  port,
663
- endpoint: `http://localhost:${port}/mcp`,
709
+ host,
710
+ endpoint: `http://${host}:${port}/mcp`,
664
711
  })
665
712
  })
666
713
 
@@ -689,6 +736,8 @@ export async function createServer(options: ServerOptions): Promise<void> {
689
736
  }
690
737
  }
691
738
  transports.clear()
739
+ sessionLastActivity.clear()
740
+ clearInterval(sessionSweepTimer)
692
741
 
693
742
  httpServer.close()
694
743
  db.close()
@@ -697,14 +746,7 @@ export async function createServer(options: ServerOptions): Promise<void> {
697
746
  })()
698
747
  })
699
748
 
700
- // Keep process alive with a heartbeat timer
701
- // setInterval keeps the event loop active and prevents exit
702
- setInterval(
703
- () => {
704
- // Heartbeat - keeps event loop active
705
- },
706
- 1000 * 60 * 60
707
- ) // 1 hour interval (just needs to exist)
749
+ // sessionSweepTimer keeps the event loop active (no additional heartbeat needed)
708
750
  }
709
751
  }
710
752
  }
@@ -266,6 +266,17 @@ export interface JournalEntry {
266
266
  autoContext: string | null
267
267
  deletedAt: string | null
268
268
  tags: string[]
269
+ // GitHub integration fields
270
+ projectNumber?: number | null
271
+ projectOwner?: string | null
272
+ issueNumber?: number | null
273
+ issueUrl?: string | null
274
+ prNumber?: number | null
275
+ prUrl?: string | null
276
+ prStatus?: string | null
277
+ workflowRunId?: number | null
278
+ workflowName?: string | null
279
+ workflowStatus?: string | null
269
280
  }
270
281
 
271
282
  /**
@@ -298,6 +309,30 @@ export interface Embedding {
298
309
  modelName: string
299
310
  }
300
311
 
312
+ /**
313
+ * Importance scoring breakdown showing weighted component contributions
314
+ */
315
+ export interface ImportanceBreakdown {
316
+ /** Significance type contribution (weight: 0.30) */
317
+ significance: number
318
+ /** Relationship count contribution (weight: 0.35) */
319
+ relationships: number
320
+ /** Causal relationship contribution (weight: 0.20) */
321
+ causal: number
322
+ /** Recency decay contribution (weight: 0.15) */
323
+ recency: number
324
+ }
325
+
326
+ /**
327
+ * Importance calculation result with total score and component breakdown
328
+ */
329
+ export interface ImportanceResult {
330
+ /** Total importance score (0.0-1.0) */
331
+ score: number
332
+ /** Weighted component contributions */
333
+ breakdown: ImportanceBreakdown
334
+ }
335
+
301
336
  // ============================================================================
302
337
  // GitHub Integration Types
303
338
  // ============================================================================
@@ -320,6 +355,7 @@ export interface GitHubIssue {
320
355
  title: string
321
356
  url: string
322
357
  state: 'OPEN' | 'CLOSED'
358
+ milestone?: { number: number; title: string } | null
323
359
  }
324
360
 
325
361
  /**
@@ -332,6 +368,23 @@ export interface GitHubPullRequest {
332
368
  state: 'OPEN' | 'CLOSED' | 'MERGED'
333
369
  }
334
370
 
371
+ /**
372
+ * GitHub milestone information
373
+ */
374
+ export interface GitHubMilestone {
375
+ number: number
376
+ title: string
377
+ description: string | null
378
+ state: 'open' | 'closed'
379
+ url: string
380
+ dueOn: string | null
381
+ openIssues: number
382
+ closedIssues: number
383
+ createdAt: string
384
+ updatedAt: string
385
+ creator: string | null
386
+ }
387
+
335
388
  /**
336
389
  * GitHub workflow run information
337
390
  */
@@ -359,6 +412,51 @@ export interface ProjectContext {
359
412
  issues: GitHubIssue[]
360
413
  pullRequests: GitHubPullRequest[]
361
414
  workflowRuns: GitHubWorkflowRun[]
415
+ milestones: GitHubMilestone[]
416
+ }
417
+
418
+ // ============================================================================
419
+ // Repository Insights/Traffic Types
420
+ // ============================================================================
421
+
422
+ /**
423
+ * Repository statistics (stars, forks, watchers)
424
+ */
425
+ export interface RepoStats {
426
+ stars: number
427
+ forks: number
428
+ watchers: number
429
+ openIssues: number
430
+ size: number // KB
431
+ defaultBranch: string
432
+ }
433
+
434
+ /**
435
+ * Aggregated traffic data (14-day rolling)
436
+ */
437
+ export interface TrafficData {
438
+ clones: { total: number; unique: number; dailyAvg: number }
439
+ views: { total: number; unique: number; dailyAvg: number }
440
+ period: string // e.g. "14 days"
441
+ }
442
+
443
+ /**
444
+ * Traffic referrer source
445
+ */
446
+ export interface TrafficReferrer {
447
+ referrer: string
448
+ count: number
449
+ uniques: number
450
+ }
451
+
452
+ /**
453
+ * Popular repository path
454
+ */
455
+ export interface PopularPath {
456
+ path: string
457
+ title: string
458
+ count: number
459
+ uniques: number
362
460
  }
363
461
 
364
462
  // ============================================================================
@@ -3,8 +3,11 @@
3
3
  *
4
4
  * Centralized logging to stderr only (stdout reserved for MCP protocol).
5
5
  * Follows RFC 5424 severity levels.
6
+ * Automatically sanitizes error fields to prevent token leakage.
6
7
  */
7
8
 
9
+ import { sanitizeErrorForLogging } from './security-utils.js'
10
+
8
11
  type LogLevel = 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical'
9
12
 
10
13
  interface LogContext {
@@ -57,7 +60,13 @@ class Logger {
57
60
  private log(level: LogLevel, message: string, context?: LogContext): void {
58
61
  if (!this.shouldLog(level)) return
59
62
 
60
- const formatted = this.formatMessage(level, message, context)
63
+ // Sanitize error fields to prevent token leakage in logs
64
+ let sanitizedContext = context
65
+ if (context?.['error'] != null && typeof context['error'] === 'string') {
66
+ sanitizedContext = { ...context, error: sanitizeErrorForLogging(context['error']) }
67
+ }
68
+
69
+ const formatted = this.formatMessage(level, message, sanitizedContext)
61
70
 
62
71
  // Always write to stderr (stdout is reserved for MCP protocol)
63
72
  console.error(formatted)
@@ -168,3 +168,38 @@ export function assertNoPathTraversal(filename: string): void {
168
168
  throw new PathTraversalError(filename)
169
169
  }
170
170
  }
171
+
172
+ // ============================================================================
173
+ // Error Message Sanitization
174
+ // ============================================================================
175
+
176
+ /**
177
+ * Patterns that may contain sensitive tokens in error messages.
178
+ * Used to scrub error output before logging.
179
+ */
180
+ const TOKEN_PATTERNS = [
181
+ // GitHub personal access tokens (classic and fine-grained)
182
+ /ghp_[A-Za-z0-9_]{36,}/g,
183
+ /github_pat_[A-Za-z0-9_]{82,}/g,
184
+ // Authorization headers in error dumps
185
+ /Authorization:\s*(?:token|Bearer)\s+\S+/gi,
186
+ // Generic Bearer tokens
187
+ /Bearer\s+[A-Za-z0-9._\-~+/]+=*/gi,
188
+ ] as const
189
+
190
+ /**
191
+ * Sanitizes an error message by replacing any detected tokens with '[REDACTED]'.
192
+ * This is a defense-in-depth measure for error logging paths.
193
+ *
194
+ * @param message - The error message to sanitize
195
+ * @returns The sanitized message with tokens replaced
196
+ */
197
+ export function sanitizeErrorForLogging(message: string): string {
198
+ let sanitized = message
199
+ for (const pattern of TOKEN_PATTERNS) {
200
+ // Reset lastIndex for global regex patterns
201
+ pattern.lastIndex = 0
202
+ sanitized = sanitized.replace(pattern, '[REDACTED]')
203
+ }
204
+ return sanitized
205
+ }
@@ -20,6 +20,12 @@ const DEFAULT_MODEL = 'Xenova/all-MiniLM-L6-v2'
20
20
  /** Embedding dimensions for all-MiniLM-L6-v2 */
21
21
  const EMBEDDING_DIMENSIONS = 384
22
22
 
23
+ /** Number of entries to embed concurrently during rebuild */
24
+ const REBUILD_BATCH_SIZE = 5
25
+
26
+ /** Number of entries to fetch per page during rebuild */
27
+ const REBUILD_PAGE_SIZE = 200
28
+
23
29
  /** Search result with similarity score */
24
30
  export interface SemanticSearchResult {
25
31
  entryId: number
@@ -225,7 +231,8 @@ export class VectorSearchManager {
225
231
  }
226
232
 
227
233
  /**
228
- * Rebuild index from database entries
234
+ * Rebuild index from database entries.
235
+ * Uses paginated fetching and parallel batch embedding for performance.
229
236
  * @param db - Database adapter
230
237
  * @param progress - Optional progress context for notifications
231
238
  */
@@ -240,66 +247,130 @@ export class VectorSearchManager {
240
247
 
241
248
  logger.info('Rebuilding vector index from database...', { module: 'VectorSearch' })
242
249
 
243
- // Step 1: Get all entries from database
244
- const entries = db.getRecentEntries(10000) // Get up to 10k entries
245
- const dbIds = new Set(entries.map((e) => String(e.id)))
250
+ // Step 1: Get total entry count and build ID set for orphan detection
251
+ const totalEntries = db.getActiveEntryCount()
252
+ const dbIds = new Set<string>()
253
+
254
+ // Collect all active entry IDs via pagination (avoids loading all content at once)
255
+ for (let offset = 0; offset < totalEntries; offset += REBUILD_PAGE_SIZE) {
256
+ const page = db.getEntriesPage(offset, REBUILD_PAGE_SIZE)
257
+ for (const entry of page) {
258
+ dbIds.add(String(entry.id))
259
+ }
260
+ }
261
+
262
+ // Step 2: Clean up existing index items
263
+ // If the index is corrupted (e.g., from a process kill), recreate it
264
+ try {
265
+ const indexItems = await this.index.listItems()
266
+ let orphansRemoved = 0
267
+ for (const item of indexItems) {
268
+ if (!dbIds.has(item.id)) {
269
+ try {
270
+ await this.index.deleteItem(item.id)
271
+ orphansRemoved++
272
+ } catch {
273
+ // Ignore errors during cleanup
274
+ }
275
+ }
276
+ }
277
+ if (orphansRemoved > 0) {
278
+ logger.info(`Cleaned up ${String(orphansRemoved)} orphaned vector entries`, {
279
+ module: 'VectorSearch',
280
+ })
281
+ }
246
282
 
247
- // Step 2: Clean up orphaned entries from the index
248
- // These are vector entries for database records that were permanently deleted
249
- const indexItems = await this.index.listItems()
250
- let orphansRemoved = 0
251
- for (const item of indexItems) {
252
- if (!dbIds.has(item.id)) {
283
+ // Step 3: Delete all remaining items to prepare for clean re-index
284
+ // This avoids the double-delete overhead of calling addEntry (upsert) per item
285
+ const remainingItems = await this.index.listItems()
286
+ for (const item of remainingItems) {
253
287
  try {
254
288
  await this.index.deleteItem(item.id)
255
- orphansRemoved++
256
289
  } catch {
257
- // Ignore errors during cleanup
290
+ // Ignore
258
291
  }
259
292
  }
260
- }
261
- if (orphansRemoved > 0) {
262
- logger.info(`Cleaned up ${String(orphansRemoved)} orphaned vector entries`, {
293
+ } catch (indexError) {
294
+ // Index files are corrupted — recreate from scratch
295
+ logger.warning('Vector index corrupted, recreating...', {
263
296
  module: 'VectorSearch',
297
+ error: indexError instanceof Error ? indexError.message : String(indexError),
264
298
  })
299
+ // Delete and recreate the vectra index directory
300
+ if (fs.existsSync(this.indexPath)) {
301
+ fs.rmSync(this.indexPath, { recursive: true, force: true })
302
+ fs.mkdirSync(this.indexPath, { recursive: true })
303
+ }
304
+ this.index = new LocalIndex(this.indexPath)
305
+ await this.index.createIndex()
306
+ logger.info('Recreated vector index after corruption', { module: 'VectorSearch' })
265
307
  }
266
308
 
267
- // Step 3: Re-index all current entries
268
- const total = entries.length
269
-
270
- // Send initial progress
271
- await sendProgress(progress, 0, total, 'Starting vector index rebuild...')
309
+ // Step 4: Re-index all entries using paginated fetch
310
+ // Embeddings are generated in parallel batches (CPU-bound, safe),
311
+ // but vectra insertions are sequential (file I/O, not concurrency-safe)
312
+ await sendProgress(progress, 0, totalEntries, 'Starting vector index rebuild...')
272
313
 
273
314
  let indexed = 0
274
- for (const entry of entries) {
275
- // Delete existing item first to avoid "already exists" error
276
- try {
277
- await this.index.deleteItem(String(entry.id))
278
- } catch {
279
- // Item may not exist, ignore
280
- }
315
+ for (let offset = 0; offset < totalEntries; offset += REBUILD_PAGE_SIZE) {
316
+ const page = db.getEntriesPage(offset, REBUILD_PAGE_SIZE)
317
+
318
+ // Generate embeddings in parallel batches
319
+ for (let i = 0; i < page.length; i += REBUILD_BATCH_SIZE) {
320
+ const batch = page.slice(i, i + REBUILD_BATCH_SIZE)
321
+
322
+ // Parallel embedding generation
323
+ const embeddings = await Promise.all(
324
+ batch.map(async (entry) => {
325
+ try {
326
+ return { entry, embedding: await this.generateEmbedding(entry.content) }
327
+ } catch {
328
+ return { entry, embedding: null }
329
+ }
330
+ })
331
+ )
281
332
 
282
- const success = await this.addEntry(entry.id, entry.content)
283
- if (success) indexed++
333
+ // Sequential vectra insertion (file I/O not concurrency-safe)
334
+ for (const { entry, embedding } of embeddings) {
335
+ if (embedding !== null) {
336
+ try {
337
+ await this.index.insertItem({
338
+ id: String(entry.id),
339
+ vector: embedding,
340
+ metadata: {
341
+ entryId: entry.id,
342
+ contentPreview: entry.content.slice(0, 100),
343
+ },
344
+ })
345
+ indexed++
346
+ } catch (error) {
347
+ logger.error('Failed to insert entry into vector index', {
348
+ module: 'VectorSearch',
349
+ entityId: entry.id,
350
+ error: error instanceof Error ? error.message : String(error),
351
+ })
352
+ }
353
+ }
354
+ }
284
355
 
285
- // Report progress every 10 entries to avoid flooding
286
- if (indexed % 10 === 0 || indexed === total) {
287
- await sendProgress(
288
- progress,
289
- indexed,
290
- total,
291
- `Indexed ${String(indexed)} of ${String(total)} entries`
292
- )
356
+ // Report progress every 10 entries to avoid flooding
357
+ if (indexed % 10 === 0 || indexed === totalEntries) {
358
+ await sendProgress(
359
+ progress,
360
+ indexed,
361
+ totalEntries,
362
+ `Indexed ${String(indexed)} of ${String(totalEntries)} entries`
363
+ )
364
+ }
293
365
  }
294
366
  }
295
367
 
296
368
  // Force index to refresh by re-listing items
297
369
  // This ensures the internal query structures are updated and ready for search
298
- // The 100ms delay was insufficient because it didn't refresh the internal state
299
370
  await this.index.listItems()
300
371
 
301
372
  // Final progress
302
- await sendProgress(progress, indexed, total, 'Vector index rebuild complete')
373
+ await sendProgress(progress, indexed, totalEntries, 'Vector index rebuild complete')
303
374
 
304
375
  logger.info(`Rebuilt vector index with ${String(indexed)} entries`, {
305
376
  module: 'VectorSearch',