@noesis-brain/mcp-server 2.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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +218 -0
  3. package/dist/api/NoesisClient.d.ts +501 -0
  4. package/dist/api/NoesisClient.d.ts.map +1 -0
  5. package/dist/api/NoesisClient.js +654 -0
  6. package/dist/api/NoesisClient.js.map +1 -0
  7. package/dist/cli/setup.d.ts +8 -0
  8. package/dist/cli/setup.d.ts.map +1 -0
  9. package/dist/cli/setup.js +148 -0
  10. package/dist/cli/setup.js.map +1 -0
  11. package/dist/database/PostgresAdapter.d.ts +385 -0
  12. package/dist/database/PostgresAdapter.d.ts.map +1 -0
  13. package/dist/database/PostgresAdapter.js +1043 -0
  14. package/dist/database/PostgresAdapter.js.map +1 -0
  15. package/dist/index.d.ts +31 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +126 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/services/embedding.d.ts +38 -0
  20. package/dist/services/embedding.d.ts.map +1 -0
  21. package/dist/services/embedding.js +126 -0
  22. package/dist/services/embedding.js.map +1 -0
  23. package/dist/tools/SyncStateManager.d.ts +65 -0
  24. package/dist/tools/SyncStateManager.d.ts.map +1 -0
  25. package/dist/tools/SyncStateManager.js +217 -0
  26. package/dist/tools/SyncStateManager.js.map +1 -0
  27. package/dist/tools/index.d.ts +14 -0
  28. package/dist/tools/index.d.ts.map +1 -0
  29. package/dist/tools/index.js +3345 -0
  30. package/dist/tools/index.js.map +1 -0
  31. package/dist/tools/navis.d.ts +11 -0
  32. package/dist/tools/navis.d.ts.map +1 -0
  33. package/dist/tools/navis.js +231 -0
  34. package/dist/tools/navis.js.map +1 -0
  35. package/dist/types/index.d.ts +104 -0
  36. package/dist/types/index.d.ts.map +1 -0
  37. package/dist/types/index.js +5 -0
  38. package/dist/types/index.js.map +1 -0
  39. package/dist/utils/suggestPath.d.ts +15 -0
  40. package/dist/utils/suggestPath.d.ts.map +1 -0
  41. package/dist/utils/suggestPath.js +52 -0
  42. package/dist/utils/suggestPath.js.map +1 -0
  43. package/package.json +71 -0
  44. package/scripts/noesis-sync.mjs +469 -0
  45. package/skill-templates/noesis-refine-note.md +92 -0
  46. package/skill-templates/noesis-sync.md +110 -0
  47. package/templates/claude-md-block.md +22 -0
@@ -0,0 +1,1043 @@
1
+ /**
2
+ * PostgreSQL adapter for md-manager MCP server
3
+ * Connects to Neon PostgreSQL database
4
+ */
5
+ import { Pool } from 'pg';
6
+ import * as crypto from 'crypto';
7
+ export class PostgresAdapter {
8
+ pool;
9
+ userId;
10
+ constructor(connectionString, userId) {
11
+ this.pool = new Pool({
12
+ connectionString,
13
+ ssl: { rejectUnauthorized: false }
14
+ });
15
+ this.userId = userId ?? null;
16
+ }
17
+ /**
18
+ * Build user_id filter condition for queries
19
+ * Returns { condition, params, nextParamIndex } for adding to WHERE clauses
20
+ */
21
+ buildUserFilter(startParamIndex = 1) {
22
+ if (this.userId) {
23
+ return {
24
+ condition: `user_id = $${startParamIndex}`,
25
+ params: [this.userId],
26
+ nextParamIndex: startParamIndex + 1
27
+ };
28
+ }
29
+ return {
30
+ condition: 'TRUE', // No filter when userId not set
31
+ params: [],
32
+ nextParamIndex: startParamIndex
33
+ };
34
+ }
35
+ /**
36
+ * Search notes using PostgreSQL full-text search (tsvector)
37
+ */
38
+ async searchNotes(query, options = {}) {
39
+ const { limit = 10, root } = options;
40
+ try {
41
+ const searchTerms = this.extractSearchTerms(query);
42
+ if (!searchTerms) {
43
+ return [];
44
+ }
45
+ // Use PostgreSQL full-text search with ts_rank
46
+ const userFilter = this.buildUserFilter(1);
47
+ let sql = `
48
+ SELECT
49
+ n.id,
50
+ n.title,
51
+ n.content,
52
+ n.file_path,
53
+ n.description,
54
+ n.points,
55
+ n.is_favorite,
56
+ n.modified_at,
57
+ ts_rank(n.fts_vector, plainto_tsquery('english', $${userFilter.nextParamIndex})) as relevance_score
58
+ FROM notes n
59
+ WHERE n.fts_vector @@ plainto_tsquery('english', $${userFilter.nextParamIndex})
60
+ AND COALESCE(n.is_trashed, FALSE) = FALSE
61
+ AND ${userFilter.condition}
62
+ `;
63
+ const params = [...userFilter.params, searchTerms];
64
+ let paramIndex = userFilter.nextParamIndex + 1;
65
+ if (root) {
66
+ sql += ` AND n.file_path LIKE $${paramIndex}`;
67
+ params.push(`%${root}%`);
68
+ paramIndex++;
69
+ }
70
+ sql += ` ORDER BY relevance_score DESC, (COALESCE(n.points, 0) + CASE WHEN n.is_favorite THEN 50 ELSE 0 END) DESC LIMIT $${paramIndex}`;
71
+ params.push(limit);
72
+ const result = await this.pool.query(sql, params);
73
+ return result.rows.map(row => ({
74
+ id: row.id,
75
+ title: row.title || 'Untitled',
76
+ file_path: row.file_path,
77
+ content: row.content || '',
78
+ excerpt: this.generateExcerpt(row.content || '', query),
79
+ relevance: this.normalizeRelevance(row.relevance_score),
80
+ modified_at: row.modified_at
81
+ }));
82
+ }
83
+ catch (error) {
84
+ console.error('FTS search error:', error);
85
+ return this.fallbackSearch(query, options);
86
+ }
87
+ }
88
+ /**
89
+ * Fallback to ILIKE search if FTS fails
90
+ */
91
+ async fallbackSearch(query, options) {
92
+ const { limit = 10, root } = options;
93
+ const likePattern = `%${query}%`;
94
+ const userFilter = this.buildUserFilter(1);
95
+ let sql = `
96
+ SELECT
97
+ id,
98
+ title,
99
+ content,
100
+ file_path,
101
+ description,
102
+ points,
103
+ is_favorite,
104
+ modified_at
105
+ FROM notes
106
+ WHERE (title ILIKE $${userFilter.nextParamIndex} OR description ILIKE $${userFilter.nextParamIndex} OR content ILIKE $${userFilter.nextParamIndex})
107
+ AND COALESCE(is_trashed, FALSE) = FALSE
108
+ AND ${userFilter.condition}
109
+ `;
110
+ const params = [...userFilter.params, likePattern];
111
+ let paramIndex = userFilter.nextParamIndex + 1;
112
+ if (root) {
113
+ sql += ` AND file_path LIKE $${paramIndex}`;
114
+ params.push(`%${root}%`);
115
+ paramIndex++;
116
+ }
117
+ sql += ` ORDER BY (CASE WHEN is_favorite THEN 1000 ELSE 0 END + COALESCE(points, 0)) DESC, modified_at DESC LIMIT $${paramIndex}`;
118
+ params.push(limit);
119
+ const result = await this.pool.query(sql, params);
120
+ return result.rows.map(row => ({
121
+ id: row.id,
122
+ title: row.title || 'Untitled',
123
+ file_path: row.file_path,
124
+ content: row.content || '',
125
+ excerpt: this.generateExcerpt(row.content || '', query),
126
+ relevance: 50,
127
+ modified_at: row.modified_at
128
+ }));
129
+ }
130
+ /**
131
+ * Get a note by ID
132
+ */
133
+ async getNote(id) {
134
+ const userFilter = this.buildUserFilter(2);
135
+ const result = await this.pool.query(`SELECT * FROM notes WHERE id = $1 AND ${userFilter.condition}`, [id, ...userFilter.params]);
136
+ return result.rows[0];
137
+ }
138
+ /**
139
+ * Get a note by file path
140
+ */
141
+ async getNoteByPath(filePath) {
142
+ const userFilter = this.buildUserFilter(2);
143
+ const result = await this.pool.query(`SELECT * FROM notes WHERE file_path = $1 AND ${userFilter.condition}`, [filePath, ...userFilter.params]);
144
+ return result.rows[0];
145
+ }
146
+ /**
147
+ * List all notes
148
+ */
149
+ async listNotes(options = {}) {
150
+ const { limit = 50, offset = 0, root } = options;
151
+ const userFilter = this.buildUserFilter(1);
152
+ let sql = `
153
+ SELECT * FROM notes
154
+ WHERE COALESCE(is_trashed, FALSE) = FALSE
155
+ AND ${userFilter.condition}
156
+ `;
157
+ const params = [...userFilter.params];
158
+ let paramIndex = userFilter.nextParamIndex;
159
+ if (root) {
160
+ sql += ` AND file_path LIKE $${paramIndex}`;
161
+ params.push(`%${root}%`);
162
+ paramIndex++;
163
+ }
164
+ sql += ` ORDER BY modified_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
165
+ params.push(limit, offset);
166
+ const result = await this.pool.query(sql, params);
167
+ return result.rows;
168
+ }
169
+ /**
170
+ * Get recent notes
171
+ */
172
+ async getRecentNotes(days = 7, limit = 20) {
173
+ const cutoffDate = new Date();
174
+ cutoffDate.setDate(cutoffDate.getDate() - days);
175
+ const userFilter = this.buildUserFilter(3);
176
+ const result = await this.pool.query(`
177
+ SELECT * FROM notes
178
+ WHERE modified_at >= $1
179
+ AND COALESCE(is_trashed, FALSE) = FALSE
180
+ AND ${userFilter.condition}
181
+ ORDER BY modified_at DESC
182
+ LIMIT $2
183
+ `, [cutoffDate.toISOString(), limit, ...userFilter.params]);
184
+ return result.rows;
185
+ }
186
+ /**
187
+ * Get all roots (watched directories)
188
+ */
189
+ async getRoots() {
190
+ const userFilter = this.buildUserFilter(1);
191
+ const result = await this.pool.query(`
192
+ SELECT id, path, name, COALESCE(is_visible, TRUE) as is_visible
193
+ FROM roots
194
+ WHERE COALESCE(is_visible, TRUE) = TRUE
195
+ AND ${userFilter.condition}
196
+ `, userFilter.params);
197
+ return result.rows;
198
+ }
199
+ /**
200
+ * Expand CamelCase terms to include space-separated versions.
201
+ * E.g., "PrintController" becomes "PrintController Print Controller"
202
+ * This improves search recall for technical component names.
203
+ */
204
+ expandCamelCase(searchQuery) {
205
+ // Find CamelCase words (e.g., PrintController, BtXmlExecutor)
206
+ const camelCasePattern = /\b([A-Z][a-z]+(?:[A-Z][a-z]*)+)\b/g;
207
+ const expandedTerms = [];
208
+ let match;
209
+ while ((match = camelCasePattern.exec(searchQuery)) !== null) {
210
+ const original = match[1];
211
+ // Split CamelCase: "PrintController" -> "Print Controller"
212
+ const spaced = original.replace(/([a-z])([A-Z])/g, '$1 $2');
213
+ if (spaced !== original) {
214
+ expandedTerms.push(spaced);
215
+ }
216
+ }
217
+ // Return original query plus any expanded terms
218
+ return expandedTerms.length > 0 ? `${searchQuery} ${expandedTerms.join(' ')}` : searchQuery;
219
+ }
220
+ /**
221
+ * Extract search terms from natural language query
222
+ */
223
+ extractSearchTerms(query) {
224
+ // First expand CamelCase terms for better recall
225
+ const expandedQuery = this.expandCamelCase(query);
226
+ const stopWords = new Set([
227
+ 'what', 'is', 'are', 'the', 'a', 'an', 'how', 'can', 'do', 'does', 'tell', 'me',
228
+ 'about', 'show', 'find', 'search', 'look', 'for', 'in', 'on', 'at', 'to', 'from',
229
+ 'you', 'know', 'your', 'my', 'i', 'we', 'they', 'it', 'this', 'that', 'with',
230
+ 'and', 'or', 'but', 'not', 'have', 'has', 'had', 'be', 'been', 'being',
231
+ 'would', 'could', 'should', 'will', 'shall', 'may', 'might', 'must',
232
+ 'there', 'here', 'where', 'when', 'why', 'which', 'who', 'whom'
233
+ ]);
234
+ const cleanedQuery = expandedQuery
235
+ .replace(/-/g, ' ')
236
+ .replace(/['']s?\b/g, '')
237
+ .replace(/[?!.,;:()\[\]{}"]/g, ' ');
238
+ const words = cleanedQuery.toLowerCase()
239
+ .split(/\s+/)
240
+ .filter(word => word.length > 2 && !stopWords.has(word));
241
+ if (words.length === 0) {
242
+ return '';
243
+ }
244
+ // Join with spaces for plainto_tsquery
245
+ return words.join(' ');
246
+ }
247
+ /**
248
+ * Generate an excerpt from content highlighting the search terms
249
+ */
250
+ generateExcerpt(content, query, maxLength = 200) {
251
+ if (!content)
252
+ return '';
253
+ const lowerContent = content.toLowerCase();
254
+ const searchTerms = query.toLowerCase().split(/\s+/).filter(t => t.length > 2);
255
+ let startIndex = 0;
256
+ for (const term of searchTerms) {
257
+ const index = lowerContent.indexOf(term);
258
+ if (index !== -1) {
259
+ startIndex = Math.max(0, index - 50);
260
+ break;
261
+ }
262
+ }
263
+ let excerpt = content.substring(startIndex, startIndex + maxLength);
264
+ if (startIndex > 0) {
265
+ excerpt = '...' + excerpt.substring(excerpt.indexOf(' ') + 1);
266
+ }
267
+ if (startIndex + maxLength < content.length) {
268
+ excerpt = excerpt.substring(0, excerpt.lastIndexOf(' ')) + '...';
269
+ }
270
+ return excerpt.replace(/\n+/g, ' ').trim();
271
+ }
272
+ /**
273
+ * Normalize ts_rank relevance score to 0-100 percentage
274
+ */
275
+ normalizeRelevance(score) {
276
+ // ts_rank returns values between 0 and 1 typically
277
+ const normalized = Math.min(100, Math.max(0, score * 100));
278
+ return Math.round(normalized);
279
+ }
280
+ /**
281
+ * Get notes for pulling to local machine
282
+ * Returns notes with relative_path for reconstruction on another machine
283
+ */
284
+ async getNotesForPull(options = {}) {
285
+ const { root, rootId } = options;
286
+ let sql = `
287
+ SELECT
288
+ n.id,
289
+ n.relative_path,
290
+ n.content,
291
+ n.title,
292
+ r.name as root_name,
293
+ r.path as root_path,
294
+ n.modified_at
295
+ FROM notes n
296
+ JOIN roots r ON n.root_id = r.id
297
+ WHERE COALESCE(n.is_trashed, FALSE) = FALSE
298
+ AND n.relative_path IS NOT NULL
299
+ `;
300
+ const params = [];
301
+ let paramIndex = 1;
302
+ if (rootId) {
303
+ sql += ` AND n.root_id = $${paramIndex}`;
304
+ params.push(rootId);
305
+ paramIndex++;
306
+ }
307
+ else if (root) {
308
+ sql += ` AND r.name ILIKE $${paramIndex}`;
309
+ params.push(`%${root}%`);
310
+ paramIndex++;
311
+ }
312
+ sql += ` ORDER BY r.name, n.relative_path`;
313
+ const result = await this.pool.query(sql, params);
314
+ return result.rows;
315
+ }
316
+ /**
317
+ * Get a root by name
318
+ */
319
+ async getRootByName(name) {
320
+ const userFilter = this.buildUserFilter(2);
321
+ const result = await this.pool.query(`SELECT id, path, name FROM roots WHERE name ILIKE $1 AND ${userFilter.condition}`, [`%${name}%`, ...userFilter.params]);
322
+ return result.rows[0];
323
+ }
324
+ /**
325
+ * Upsert a note (insert or update based on file_path + root_id)
326
+ * Returns 'created' | 'updated' | 'skipped'
327
+ */
328
+ async upsertNote(file, metadata) {
329
+ // Check if note exists by relative_path OR file_path (fallback for notes with NULL relative_path)
330
+ const userFilter = this.buildUserFilter(4);
331
+ const existing = await this.pool.query(`SELECT id, hash FROM notes WHERE root_id = $1 AND (relative_path = $2 OR file_path = $3) AND ${userFilter.condition}`, [file.rootId, file.relativePath, file.path, ...userFilter.params]);
332
+ if (existing.rows.length > 0) {
333
+ const currentHash = existing.rows[0].hash;
334
+ // Skip if hash matches (no changes)
335
+ if (currentHash === file.hash) {
336
+ return 'skipped';
337
+ }
338
+ // Update existing note (also sets relative_path and project in case they were NULL)
339
+ await this.pool.query(`
340
+ UPDATE notes SET
341
+ content = $1,
342
+ hash = $2,
343
+ title = $3,
344
+ description = $4,
345
+ keywords = $5::jsonb,
346
+ file_path = $6,
347
+ relative_path = $7,
348
+ file_size = $8,
349
+ project = $9,
350
+ modified_at = NOW(),
351
+ indexed_at = NOW()
352
+ WHERE id = $10
353
+ `, [
354
+ file.content,
355
+ file.hash,
356
+ metadata.title || this.extractTitleFromContent(file.content, file.relativePath),
357
+ metadata.description || null,
358
+ JSON.stringify(metadata.keywords || []),
359
+ file.path,
360
+ file.relativePath,
361
+ file.size,
362
+ file.project || null,
363
+ existing.rows[0].id
364
+ ]);
365
+ return 'updated';
366
+ }
367
+ // Insert new note (include user_id and project if configured)
368
+ await this.pool.query(`
369
+ INSERT INTO notes (
370
+ root_id, file_path, relative_path, content, hash,
371
+ title, description, keywords, file_size, project, modified_at, indexed_at, user_id
372
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, NOW(), NOW(), $11)
373
+ `, [
374
+ file.rootId,
375
+ file.path,
376
+ file.relativePath,
377
+ file.content,
378
+ file.hash,
379
+ metadata.title || this.extractTitleFromContent(file.content, file.relativePath),
380
+ metadata.description || null,
381
+ JSON.stringify(metadata.keywords || []),
382
+ file.size,
383
+ file.project || null,
384
+ this.userId
385
+ ]);
386
+ return 'created';
387
+ }
388
+ /**
389
+ * Get all roots with their paths and last scanned times
390
+ */
391
+ async getRootsForSync() {
392
+ const userFilter = this.buildUserFilter(1);
393
+ const result = await this.pool.query(`
394
+ SELECT id, name, path, last_scanned_at
395
+ FROM roots
396
+ WHERE COALESCE(is_visible, TRUE) = TRUE
397
+ AND ${userFilter.condition}
398
+ `, userFilter.params);
399
+ return result.rows.map(row => ({
400
+ id: row.id,
401
+ name: row.name,
402
+ path: row.path,
403
+ lastScannedAt: row.last_scanned_at
404
+ }));
405
+ }
406
+ /**
407
+ * Get a root by its path (for auto-detection)
408
+ */
409
+ async getRootByPath(rootPath) {
410
+ const userFilter = this.buildUserFilter(2);
411
+ const result = await this.pool.query(`SELECT id, name, path FROM roots WHERE path = $1 AND ${userFilter.condition}`, [rootPath, ...userFilter.params]);
412
+ return result.rows[0];
413
+ }
414
+ /**
415
+ * Create a new root (for auto-creation during sync)
416
+ */
417
+ async createRoot(options) {
418
+ const { name, path, type = 'folder' } = options;
419
+ const result = await this.pool.query(`
420
+ INSERT INTO roots (name, path, type, is_active, is_visible, user_id)
421
+ VALUES ($1, $2, $3, FALSE, TRUE, $4)
422
+ RETURNING id, name, path
423
+ `, [name, path, type, this.userId]);
424
+ return result.rows[0];
425
+ }
426
+ /**
427
+ * Log a sync operation (for Dashboard sync activity display)
428
+ */
429
+ async logSyncOperation(options) {
430
+ await this.pool.query(`
431
+ INSERT INTO sync_logs (root_id, files_scanned, files_added, files_updated, files_deleted, source, machine_name, notes)
432
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
433
+ `, [
434
+ options.rootId,
435
+ options.filesScanned,
436
+ options.filesAdded,
437
+ options.filesUpdated,
438
+ options.filesDeleted,
439
+ options.source,
440
+ options.machineName || null,
441
+ options.notes || null
442
+ ]);
443
+ }
444
+ /**
445
+ * Get recent sync logs for a root
446
+ */
447
+ async getSyncLogs(rootId, limit = 10) {
448
+ let sql = `
449
+ SELECT id, root_id, synced_at, files_scanned, files_added,
450
+ files_updated, files_deleted, source, machine_name, notes
451
+ FROM sync_logs
452
+ `;
453
+ const params = [];
454
+ let paramIndex = 1;
455
+ if (rootId) {
456
+ sql += ` WHERE root_id = $${paramIndex}`;
457
+ params.push(rootId);
458
+ paramIndex++;
459
+ }
460
+ sql += ` ORDER BY synced_at DESC LIMIT $${paramIndex}`;
461
+ params.push(limit);
462
+ const result = await this.pool.query(sql, params);
463
+ return result.rows.map(row => ({
464
+ id: row.id,
465
+ rootId: row.root_id,
466
+ syncedAt: row.synced_at,
467
+ filesScanned: row.files_scanned,
468
+ filesAdded: row.files_added,
469
+ filesUpdated: row.files_updated,
470
+ filesDeleted: row.files_deleted,
471
+ source: row.source,
472
+ machineName: row.machine_name,
473
+ notes: row.notes
474
+ }));
475
+ }
476
+ /**
477
+ * Update last_scanned_at for a root
478
+ */
479
+ async updateRootScanTime(rootId) {
480
+ await this.pool.query('UPDATE roots SET last_scanned_at = NOW() WHERE id = $1', [rootId]);
481
+ }
482
+ /**
483
+ * Get last sync time from settings
484
+ */
485
+ async getLastSyncTime() {
486
+ const result = await this.pool.query("SELECT value FROM settings WHERE key = 'last_sync_time'");
487
+ return result.rows[0]?.value || null;
488
+ }
489
+ /**
490
+ * Set last sync time in settings
491
+ */
492
+ async setLastSyncTime(timestamp) {
493
+ await this.pool.query(`
494
+ INSERT INTO settings (key, value) VALUES ('last_sync_time', $1)
495
+ ON CONFLICT (key) DO UPDATE SET value = $1
496
+ `, [timestamp]);
497
+ }
498
+ /**
499
+ * Get count of notes per root
500
+ */
501
+ async getNoteCountByRoot() {
502
+ const userFilter = this.buildUserFilter(1);
503
+ const result = await this.pool.query(`
504
+ SELECT root_id, COUNT(*) as count
505
+ FROM notes
506
+ WHERE COALESCE(is_trashed, FALSE) = FALSE
507
+ AND ${userFilter.condition}
508
+ GROUP BY root_id
509
+ `, userFilter.params);
510
+ const counts = new Map();
511
+ for (const row of result.rows) {
512
+ counts.set(row.root_id, parseInt(row.count, 10));
513
+ }
514
+ return counts;
515
+ }
516
+ /**
517
+ * Get all note hashes for a root (for comparison with local files)
518
+ */
519
+ async getNoteHashesByRoot(rootId) {
520
+ const userFilter = this.buildUserFilter(2);
521
+ const result = await this.pool.query(`SELECT relative_path, hash FROM notes WHERE root_id = $1 AND relative_path IS NOT NULL AND ${userFilter.condition}`, [rootId, ...userFilter.params]);
522
+ const hashes = new Map();
523
+ for (const row of result.rows) {
524
+ hashes.set(row.relative_path, row.hash || '');
525
+ }
526
+ return hashes;
527
+ }
528
+ /**
529
+ * Extract title from markdown content (H1 heading, frontmatter, or filename)
530
+ * @param content - The markdown content
531
+ * @param filename - Optional filename to use as fallback (e.g., "my-note.md")
532
+ */
533
+ extractTitleFromContent(content, filename) {
534
+ // Try to find first H1 heading (single #, not ## or more)
535
+ const h1Match = content.match(/^#\s+(.+)$/m);
536
+ if (h1Match) {
537
+ return h1Match[1].trim();
538
+ }
539
+ // Fall back to filename (without extension) - this makes notes searchable by filename
540
+ if (filename) {
541
+ const baseName = filename.replace(/\.md$/i, '').split('/').pop() || filename;
542
+ // Convert kebab-case or snake_case to Title Case
543
+ const title = baseName
544
+ .replace(/[-_]/g, ' ')
545
+ .replace(/\b\w/g, c => c.toUpperCase());
546
+ return title;
547
+ }
548
+ // Last resort: use first non-empty line
549
+ const lines = content.split('\n').filter(l => l.trim());
550
+ if (lines.length > 0) {
551
+ const firstLine = lines[0].replace(/^#+\s*/, '').trim();
552
+ return firstLine.substring(0, 100);
553
+ }
554
+ return 'Untitled';
555
+ }
556
+ /**
557
+ * Compute SHA-256 hash of content
558
+ */
559
+ static computeHash(content) {
560
+ return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
561
+ }
562
+ /**
563
+ * Get note with metadata for AI enhancement
564
+ * Returns current metadata and content for analysis
565
+ */
566
+ async getNoteForEnhancement(id) {
567
+ const userFilter = this.buildUserFilter(2);
568
+ const result = await this.pool.query(`
569
+ SELECT
570
+ n.id, n.title, n.description, n.keywords,
571
+ COALESCE(n.aliases, '{}') as aliases,
572
+ n.content, n.file_path, n.ai_enhanced_at,
573
+ r.name as root_name
574
+ FROM notes n
575
+ LEFT JOIN roots r ON n.root_id = r.id
576
+ WHERE n.id = $1 AND COALESCE(n.is_trashed, FALSE) = FALSE
577
+ AND n.${userFilter.condition}
578
+ `, [id, ...userFilter.params]);
579
+ if (result.rows.length === 0)
580
+ return null;
581
+ const row = result.rows[0];
582
+ return {
583
+ id: row.id,
584
+ title: row.title || 'Untitled',
585
+ description: row.description,
586
+ keywords: row.keywords || [],
587
+ aliases: row.aliases || [],
588
+ content: row.content || '',
589
+ file_path: row.file_path,
590
+ ai_enhanced_at: row.ai_enhanced_at,
591
+ root_name: row.root_name
592
+ };
593
+ }
594
+ /**
595
+ * Get notes needing enhancement (missing description or keywords)
596
+ */
597
+ async getNotesNeedingEnhancement(options = {}) {
598
+ const { root, limit = 50, important_only = false } = options;
599
+ const userFilter = this.buildUserFilter(1);
600
+ let sql = `
601
+ SELECT
602
+ id, title, file_path,
603
+ (description IS NOT NULL AND description != '') as has_description,
604
+ (keywords IS NOT NULL AND jsonb_array_length(keywords) > 0) as has_keywords,
605
+ COALESCE(is_favorite, FALSE) as is_favorite,
606
+ (content LIKE '%⭐%') as has_stars,
607
+ COALESCE(points, 0) as points,
608
+ ai_enhanced_at
609
+ FROM notes
610
+ WHERE COALESCE(is_trashed, FALSE) = FALSE
611
+ AND (description IS NULL OR description = '' OR keywords IS NULL OR jsonb_array_length(keywords) = 0)
612
+ AND ${userFilter.condition}
613
+ `;
614
+ const params = [...userFilter.params];
615
+ let paramIndex = userFilter.nextParamIndex;
616
+ // Filter to important notes only (favorite, has stars, or has points)
617
+ if (important_only) {
618
+ sql += ` AND (is_favorite = TRUE OR content LIKE '%⭐%' OR points > 0)`;
619
+ }
620
+ if (root) {
621
+ sql += ` AND file_path LIKE $${paramIndex}`;
622
+ params.push(`%${root}%`);
623
+ paramIndex++;
624
+ }
625
+ // Order by importance: favorites first, then by points, then by recency
626
+ sql += ` ORDER BY is_favorite DESC, points DESC, ai_enhanced_at NULLS FIRST, modified_at DESC LIMIT $${paramIndex}`;
627
+ params.push(limit);
628
+ const result = await this.pool.query(sql, params);
629
+ return result.rows;
630
+ }
631
+ /**
632
+ * Update note metadata (for AI enhancement)
633
+ * Phase 6.1: Updates title, description, keywords, and aliases
634
+ */
635
+ async updateNoteMetadata(id, metadata) {
636
+ const updates = [];
637
+ const params = [];
638
+ let paramIndex = 1;
639
+ if (metadata.title !== undefined) {
640
+ updates.push(`title = $${paramIndex}`);
641
+ params.push(metadata.title);
642
+ paramIndex++;
643
+ }
644
+ if (metadata.description !== undefined) {
645
+ updates.push(`description = $${paramIndex}`);
646
+ params.push(metadata.description);
647
+ paramIndex++;
648
+ }
649
+ if (metadata.keywords !== undefined) {
650
+ updates.push(`keywords = $${paramIndex}::jsonb`);
651
+ params.push(JSON.stringify(metadata.keywords));
652
+ paramIndex++;
653
+ }
654
+ if (metadata.aliases !== undefined) {
655
+ updates.push(`aliases = $${paramIndex}`);
656
+ params.push(metadata.aliases);
657
+ paramIndex++;
658
+ }
659
+ if (updates.length === 0) {
660
+ return false;
661
+ }
662
+ // Always update ai_enhanced_at timestamp
663
+ updates.push('ai_enhanced_at = NOW()');
664
+ params.push(id);
665
+ const sql = `UPDATE notes SET ${updates.join(', ')} WHERE id = $${paramIndex}`;
666
+ const result = await this.pool.query(sql, params);
667
+ return (result.rowCount ?? 0) > 0;
668
+ }
669
+ // ============================================
670
+ // Phase 6.2: Smart Scoring & Relations
671
+ // ============================================
672
+ /**
673
+ * Get note with current scores for analysis
674
+ */
675
+ async getNoteForScoring(id) {
676
+ const userFilter = this.buildUserFilter(2);
677
+ const result = await this.pool.query(`
678
+ SELECT
679
+ id, title, description, keywords, content, file_path,
680
+ importance_score, quality_score,
681
+ COALESCE(relations, '[]'::jsonb) as relations,
682
+ COALESCE(is_favorite, FALSE) as is_favorite,
683
+ COALESCE(points, 0) as points
684
+ FROM notes
685
+ WHERE id = $1 AND COALESCE(is_trashed, FALSE) = FALSE
686
+ AND ${userFilter.condition}
687
+ `, [id, ...userFilter.params]);
688
+ if (result.rows.length === 0)
689
+ return null;
690
+ const row = result.rows[0];
691
+ return {
692
+ id: row.id,
693
+ title: row.title || 'Untitled',
694
+ description: row.description,
695
+ keywords: row.keywords || [],
696
+ content: row.content || '',
697
+ file_path: row.file_path,
698
+ importance_score: row.importance_score,
699
+ quality_score: row.quality_score,
700
+ relations: row.relations || [],
701
+ is_favorite: row.is_favorite,
702
+ points: row.points
703
+ };
704
+ }
705
+ /**
706
+ * Update importance score (0-100)
707
+ */
708
+ async updateImportanceScore(id, score) {
709
+ const userFilter = this.buildUserFilter(3);
710
+ const result = await this.pool.query(`UPDATE notes SET importance_score = $1 WHERE id = $2 AND COALESCE(is_trashed, FALSE) = FALSE AND ${userFilter.condition}`, [score, id, ...userFilter.params]);
711
+ return (result.rowCount ?? 0) > 0;
712
+ }
713
+ /**
714
+ * Update quality score (0-100)
715
+ */
716
+ async updateQualityScore(id, score) {
717
+ const userFilter = this.buildUserFilter(3);
718
+ const result = await this.pool.query(`UPDATE notes SET quality_score = $1 WHERE id = $2 AND COALESCE(is_trashed, FALSE) = FALSE AND ${userFilter.condition}`, [score, id, ...userFilter.params]);
719
+ return (result.rowCount ?? 0) > 0;
720
+ }
721
+ /**
722
+ * Update relations for a note (bidirectional)
723
+ * Also creates inverse relations on target notes
724
+ */
725
+ async updateRelations(id, relations) {
726
+ // Map of forward relation types to their inverse
727
+ const inverseTypes = {
728
+ 'references': 'referenced_by',
729
+ 'referenced_by': 'references',
730
+ 'implements': 'implemented_by',
731
+ 'implemented_by': 'implements',
732
+ 'extends': 'extended_by',
733
+ 'extended_by': 'extends',
734
+ 'supersedes': 'superseded_by',
735
+ 'superseded_by': 'supersedes'
736
+ };
737
+ // Update the source note's relations
738
+ const userFilter = this.buildUserFilter(3);
739
+ await this.pool.query(`UPDATE notes SET relations = $1::jsonb WHERE id = $2 AND ${userFilter.condition}`, [JSON.stringify(relations), id, ...userFilter.params]);
740
+ // Create inverse relations on target notes
741
+ let inversesCreated = 0;
742
+ for (const rel of relations) {
743
+ const inverseType = inverseTypes[rel.type];
744
+ if (!inverseType)
745
+ continue;
746
+ // Get current relations of target note
747
+ const targetUserFilter = this.buildUserFilter(2);
748
+ const targetResult = await this.pool.query(`SELECT relations FROM notes WHERE id = $1 AND ${targetUserFilter.condition}`, [rel.target_id, ...targetUserFilter.params]);
749
+ if (targetResult.rows.length === 0)
750
+ continue;
751
+ const targetRelations = targetResult.rows[0].relations || [];
752
+ // Check if inverse relation already exists
753
+ const existingInverse = targetRelations.find(r => r.type === inverseType && r.target_id === id);
754
+ if (!existingInverse) {
755
+ // Add inverse relation
756
+ targetRelations.push({
757
+ type: inverseType,
758
+ target_id: id,
759
+ context: rel.context
760
+ });
761
+ await this.pool.query('UPDATE notes SET relations = $1::jsonb WHERE id = $2', [JSON.stringify(targetRelations), rel.target_id]);
762
+ inversesCreated++;
763
+ }
764
+ }
765
+ return { updated: 1, inversesCreated };
766
+ }
767
+ /**
768
+ * Get other notes for relation analysis (excluding the source note)
769
+ */
770
+ async getNotesForRelationAnalysis(excludeId, options = {}) {
771
+ const { root, limit = 50 } = options;
772
+ const userFilter = this.buildUserFilter(2);
773
+ let sql = `
774
+ SELECT id, title, description, keywords, file_path
775
+ FROM notes
776
+ WHERE id != $1 AND COALESCE(is_trashed, FALSE) = FALSE
777
+ AND ${userFilter.condition}
778
+ `;
779
+ const params = [excludeId, ...userFilter.params];
780
+ let paramIndex = userFilter.nextParamIndex;
781
+ if (root) {
782
+ sql += ` AND file_path LIKE $${paramIndex}`;
783
+ params.push(`%${root}%`);
784
+ paramIndex++;
785
+ }
786
+ sql += ` ORDER BY COALESCE(importance_score, 0) DESC, modified_at DESC LIMIT $${paramIndex}`;
787
+ params.push(limit);
788
+ const result = await this.pool.query(sql, params);
789
+ return result.rows.map(row => ({
790
+ id: row.id,
791
+ title: row.title || 'Untitled',
792
+ description: row.description,
793
+ keywords: row.keywords || [],
794
+ file_path: row.file_path
795
+ }));
796
+ }
797
+ /**
798
+ * Get knowledge base statistics for health analysis
799
+ */
800
+ async getKnowledgeBaseStats(options = {}) {
801
+ const { root, limit = 10 } = options;
802
+ const userFilter = this.buildUserFilter(1);
803
+ // Build WHERE clause
804
+ let whereClause = `COALESCE(is_trashed, FALSE) = FALSE AND ${userFilter.condition}`;
805
+ const params = [...userFilter.params];
806
+ let paramIndex = userFilter.nextParamIndex;
807
+ if (root) {
808
+ whereClause += ` AND file_path LIKE $${paramIndex}`;
809
+ params.push(`%${root}%`);
810
+ paramIndex++;
811
+ }
812
+ // Get aggregate stats
813
+ const statsResult = await this.pool.query(`
814
+ SELECT
815
+ COUNT(*) as total,
816
+ COUNT(importance_score) as with_importance,
817
+ COUNT(quality_score) as with_quality,
818
+ COUNT(*) FILTER (WHERE jsonb_array_length(COALESCE(relations, '[]'::jsonb)) > 0) as with_relations,
819
+ COUNT(*) FILTER (WHERE description IS NOT NULL AND description != '') as with_description,
820
+ COUNT(*) FILTER (WHERE keywords IS NOT NULL AND jsonb_array_length(keywords) > 0) as with_keywords
821
+ FROM notes
822
+ WHERE ${whereClause}
823
+ `, params);
824
+ const stats = statsResult.rows[0];
825
+ // Get low quality docs (score < 50 or NULL, prioritize NULL)
826
+ const lowQualityParams = [...params, limit];
827
+ const lowQualityResult = await this.pool.query(`
828
+ SELECT id, title, quality_score, file_path
829
+ FROM notes
830
+ WHERE ${whereClause} AND (quality_score IS NULL OR quality_score < 50)
831
+ ORDER BY quality_score NULLS FIRST, modified_at DESC
832
+ LIMIT $${paramIndex}
833
+ `, lowQualityParams);
834
+ // Get low importance docs (score < 30 or NULL)
835
+ const lowImportanceResult = await this.pool.query(`
836
+ SELECT id, title, importance_score, file_path
837
+ FROM notes
838
+ WHERE ${whereClause} AND (importance_score IS NULL OR importance_score < 30)
839
+ ORDER BY importance_score NULLS FIRST, modified_at DESC
840
+ LIMIT $${paramIndex}
841
+ `, lowQualityParams);
842
+ // Get orphan docs (no relations)
843
+ const orphansResult = await this.pool.query(`
844
+ SELECT id, title, file_path
845
+ FROM notes
846
+ WHERE ${whereClause} AND (relations IS NULL OR jsonb_array_length(relations) = 0)
847
+ ORDER BY modified_at DESC
848
+ LIMIT $${paramIndex}
849
+ `, lowQualityParams);
850
+ // Get docs missing metadata
851
+ const missingMetadataResult = await this.pool.query(`
852
+ SELECT
853
+ id, title, file_path,
854
+ (description IS NULL OR description = '') as missing_desc,
855
+ (keywords IS NULL OR jsonb_array_length(keywords) = 0) as missing_kw
856
+ FROM notes
857
+ WHERE ${whereClause}
858
+ AND ((description IS NULL OR description = '') OR (keywords IS NULL OR jsonb_array_length(keywords) = 0))
859
+ ORDER BY modified_at DESC
860
+ LIMIT $${paramIndex}
861
+ `, lowQualityParams);
862
+ return {
863
+ total: parseInt(stats.total),
864
+ withImportanceScore: parseInt(stats.with_importance),
865
+ withQualityScore: parseInt(stats.with_quality),
866
+ withRelations: parseInt(stats.with_relations),
867
+ withDescription: parseInt(stats.with_description),
868
+ withKeywords: parseInt(stats.with_keywords),
869
+ lowQuality: lowQualityResult.rows,
870
+ lowImportance: lowImportanceResult.rows,
871
+ orphans: orphansResult.rows,
872
+ missingMetadata: missingMetadataResult.rows.map(row => ({
873
+ id: row.id,
874
+ title: row.title || 'Untitled',
875
+ file_path: row.file_path,
876
+ missing: [
877
+ ...(row.missing_desc ? ['description'] : []),
878
+ ...(row.missing_kw ? ['keywords'] : [])
879
+ ]
880
+ }))
881
+ };
882
+ }
883
+ // ============================================
884
+ // Phase 6.3: Semantic Search with Embeddings
885
+ // ============================================
886
+ /**
887
+ * Get notes without embeddings (for batch processing)
888
+ */
889
+ async getNotesWithoutEmbeddings(options = {}) {
890
+ const { limit = 50, root } = options;
891
+ const userFilter = this.buildUserFilter(1);
892
+ let sql = `
893
+ SELECT id, title, content, file_path
894
+ FROM notes
895
+ WHERE embedding IS NULL
896
+ AND COALESCE(is_trashed, FALSE) = FALSE
897
+ AND ${userFilter.condition}
898
+ `;
899
+ const params = [...userFilter.params];
900
+ let paramIndex = userFilter.nextParamIndex;
901
+ if (root) {
902
+ sql += ` AND file_path LIKE $${paramIndex}`;
903
+ params.push(`%${root}%`);
904
+ paramIndex++;
905
+ }
906
+ sql += ` ORDER BY COALESCE(importance_score, 0) DESC, modified_at DESC LIMIT $${paramIndex}`;
907
+ params.push(limit);
908
+ const result = await this.pool.query(sql, params);
909
+ return result.rows;
910
+ }
911
+ /**
912
+ * Update a note's embedding vector
913
+ */
914
+ async updateNoteEmbedding(id, embedding) {
915
+ // Convert array to PostgreSQL vector format: [1,2,3] -> '[1,2,3]'
916
+ const vectorString = `[${embedding.join(',')}]`;
917
+ const userFilter = this.buildUserFilter(3);
918
+ const result = await this.pool.query(`UPDATE notes SET embedding = $1::vector WHERE id = $2 AND ${userFilter.condition}`, [vectorString, id, ...userFilter.params]);
919
+ return (result.rowCount ?? 0) > 0;
920
+ }
921
+ /**
922
+ * Search notes by embedding similarity (cosine distance)
923
+ */
924
+ async searchByEmbedding(embedding, options = {}) {
925
+ const { limit = 10, root } = options;
926
+ const vectorString = `[${embedding.join(',')}]`;
927
+ const userFilter = this.buildUserFilter(2);
928
+ let sql = `
929
+ SELECT
930
+ id, title, description, file_path,
931
+ 1 - (embedding <=> $1::vector) as similarity
932
+ FROM notes
933
+ WHERE embedding IS NOT NULL
934
+ AND COALESCE(is_trashed, FALSE) = FALSE
935
+ AND ${userFilter.condition}
936
+ `;
937
+ const params = [vectorString, ...userFilter.params];
938
+ let paramIndex = userFilter.nextParamIndex;
939
+ if (root) {
940
+ sql += ` AND file_path LIKE $${paramIndex}`;
941
+ params.push(`%${root}%`);
942
+ paramIndex++;
943
+ }
944
+ sql += ` ORDER BY embedding <=> $1::vector LIMIT $${paramIndex}`;
945
+ params.push(limit);
946
+ const result = await this.pool.query(sql, params);
947
+ return result.rows.map(row => ({
948
+ id: row.id,
949
+ title: row.title || 'Untitled',
950
+ description: row.description,
951
+ file_path: row.file_path,
952
+ similarity: Math.round(parseFloat(row.similarity) * 100) / 100
953
+ }));
954
+ }
955
+ /**
956
+ * Get a note's embedding for similarity comparison
957
+ */
958
+ async getNoteEmbedding(id) {
959
+ const userFilter = this.buildUserFilter(2);
960
+ const result = await this.pool.query(`
961
+ SELECT id, title, embedding
962
+ FROM notes
963
+ WHERE id = $1 AND COALESCE(is_trashed, FALSE) = FALSE
964
+ AND ${userFilter.condition}
965
+ `, [id, ...userFilter.params]);
966
+ if (result.rows.length === 0)
967
+ return null;
968
+ const row = result.rows[0];
969
+ // Parse PostgreSQL vector format back to array
970
+ let embedding = null;
971
+ if (row.embedding) {
972
+ // PostgreSQL returns vector as string like '[0.1,0.2,...]'
973
+ const vectorStr = row.embedding.toString();
974
+ embedding = JSON.parse(vectorStr.replace(/^\[/, '[').replace(/\]$/, ']'));
975
+ }
976
+ return {
977
+ id: row.id,
978
+ title: row.title || 'Untitled',
979
+ embedding
980
+ };
981
+ }
982
+ /**
983
+ * Find similar notes by embedding
984
+ */
985
+ async findSimilarNotes(noteId, options = {}) {
986
+ const { limit = 10 } = options;
987
+ // Get the note's embedding first
988
+ const userFilter = this.buildUserFilter(2);
989
+ const noteResult = await this.pool.query(`SELECT embedding FROM notes WHERE id = $1 AND ${userFilter.condition}`, [noteId, ...userFilter.params]);
990
+ if (noteResult.rows.length === 0 || !noteResult.rows[0].embedding) {
991
+ return [];
992
+ }
993
+ // Find similar notes (excluding the source note)
994
+ const similarUserFilter = this.buildUserFilter(4);
995
+ const result = await this.pool.query(`
996
+ SELECT
997
+ id, title, description, file_path,
998
+ 1 - (embedding <=> $1) as similarity
999
+ FROM notes
1000
+ WHERE id != $2
1001
+ AND embedding IS NOT NULL
1002
+ AND COALESCE(is_trashed, FALSE) = FALSE
1003
+ AND ${similarUserFilter.condition}
1004
+ ORDER BY embedding <=> $1
1005
+ LIMIT $3
1006
+ `, [noteResult.rows[0].embedding, noteId, limit, ...similarUserFilter.params]);
1007
+ return result.rows.map(row => ({
1008
+ id: row.id,
1009
+ title: row.title || 'Untitled',
1010
+ description: row.description,
1011
+ file_path: row.file_path,
1012
+ similarity: Math.round(parseFloat(row.similarity) * 100) / 100
1013
+ }));
1014
+ }
1015
+ /**
1016
+ * Get embedding statistics
1017
+ */
1018
+ async getEmbeddingStats() {
1019
+ const userFilter = this.buildUserFilter(1);
1020
+ const result = await this.pool.query(`
1021
+ SELECT
1022
+ COUNT(*) as total,
1023
+ COUNT(embedding) as with_embeddings,
1024
+ COUNT(*) - COUNT(embedding) as without_embeddings
1025
+ FROM notes
1026
+ WHERE COALESCE(is_trashed, FALSE) = FALSE
1027
+ AND ${userFilter.condition}
1028
+ `, userFilter.params);
1029
+ const row = result.rows[0];
1030
+ return {
1031
+ total: parseInt(row.total),
1032
+ withEmbeddings: parseInt(row.with_embeddings),
1033
+ withoutEmbeddings: parseInt(row.without_embeddings)
1034
+ };
1035
+ }
1036
+ /**
1037
+ * Close the database connection pool
1038
+ */
1039
+ async close() {
1040
+ await this.pool.end();
1041
+ }
1042
+ }
1043
+ //# sourceMappingURL=PostgresAdapter.js.map