@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.
- package/LICENSE +21 -0
- package/README.md +218 -0
- package/dist/api/NoesisClient.d.ts +501 -0
- package/dist/api/NoesisClient.d.ts.map +1 -0
- package/dist/api/NoesisClient.js +654 -0
- package/dist/api/NoesisClient.js.map +1 -0
- package/dist/cli/setup.d.ts +8 -0
- package/dist/cli/setup.d.ts.map +1 -0
- package/dist/cli/setup.js +148 -0
- package/dist/cli/setup.js.map +1 -0
- package/dist/database/PostgresAdapter.d.ts +385 -0
- package/dist/database/PostgresAdapter.d.ts.map +1 -0
- package/dist/database/PostgresAdapter.js +1043 -0
- package/dist/database/PostgresAdapter.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +126 -0
- package/dist/index.js.map +1 -0
- package/dist/services/embedding.d.ts +38 -0
- package/dist/services/embedding.d.ts.map +1 -0
- package/dist/services/embedding.js +126 -0
- package/dist/services/embedding.js.map +1 -0
- package/dist/tools/SyncStateManager.d.ts +65 -0
- package/dist/tools/SyncStateManager.d.ts.map +1 -0
- package/dist/tools/SyncStateManager.js +217 -0
- package/dist/tools/SyncStateManager.js.map +1 -0
- package/dist/tools/index.d.ts +14 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +3345 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/navis.d.ts +11 -0
- package/dist/tools/navis.d.ts.map +1 -0
- package/dist/tools/navis.js +231 -0
- package/dist/tools/navis.js.map +1 -0
- package/dist/types/index.d.ts +104 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +5 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/suggestPath.d.ts +15 -0
- package/dist/utils/suggestPath.d.ts.map +1 -0
- package/dist/utils/suggestPath.js +52 -0
- package/dist/utils/suggestPath.js.map +1 -0
- package/package.json +71 -0
- package/scripts/noesis-sync.mjs +469 -0
- package/skill-templates/noesis-refine-note.md +92 -0
- package/skill-templates/noesis-sync.md +110 -0
- 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
|