@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,3345 @@
1
+ /**
2
+ * Tool registry for md-manager MCP server
3
+ */
4
+ import { z } from 'zod';
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import * as os from 'os';
8
+ import * as yaml from 'js-yaml';
9
+ import { NoesisClient, CLIENT_OS, getActivePathFromMap, expandHome } from '../api/NoesisClient.js';
10
+ import { initEmbeddingService, generateEmbedding, generateEmbeddingsBatch } from '../services/embedding.js';
11
+ import { SyncStateManager, determineSyncDirection } from './SyncStateManager.js';
12
+ import { registerNaviTools } from './navis.js';
13
+ import { suggestOtherOsPath } from '../utils/suggestPath.js';
14
+ import { diff3Merge, diffPatch } from 'node-diff3';
15
+ // ============================================
16
+ // Path normalization
17
+ // ============================================
18
+ /**
19
+ * Normalize a file path for consistent comparison and storage.
20
+ * - Replaces backslashes with forward slashes
21
+ * - On Windows, lowercases the drive letter (D: → d:) to prevent case-sensitive duplicates
22
+ */
23
+ function normalizePath(p) {
24
+ let normalized = p.replace(/\\/g, '/');
25
+ // Windows drive letters are case-insensitive; normalize to lowercase
26
+ if (normalized.length >= 2 && normalized[1] === ':') {
27
+ normalized = normalized[0].toLowerCase() + normalized.slice(1);
28
+ }
29
+ return normalized;
30
+ }
31
+ // ============================================
32
+ // Utility functions for root/project detection
33
+ // ============================================
34
+ /**
35
+ * Find the nearest .git directory by walking up from startPath
36
+ */
37
+ function findGitRoot(startPath) {
38
+ let currentPath = path.resolve(startPath);
39
+ while (currentPath !== path.dirname(currentPath)) {
40
+ const gitPath = path.join(currentPath, '.git');
41
+ if (fs.existsSync(gitPath)) {
42
+ return currentPath;
43
+ }
44
+ currentPath = path.dirname(currentPath);
45
+ }
46
+ return null;
47
+ }
48
+ /**
49
+ * Detect root folder and project name from current working directory.
50
+ *
51
+ * When knownRoots are provided (matching CWD to a registered root):
52
+ * - Uses path prefix matching first (most reliable for registered roots)
53
+ * - Falls back to git-based detection only if prefix matching fails
54
+ *
55
+ * When knownRoots are NOT provided (auto-creating a new root):
56
+ * - Uses git-based detection to find the nearest repo root
57
+ */
58
+ function detectRootFromCwd(knownRoots) {
59
+ const cwd = process.cwd();
60
+ // When matching against known roots, prefer prefix matching over git detection.
61
+ // Git detection can return a sub-repo path that doesn't match the registered parent root.
62
+ if (knownRoots && knownRoots.length > 0) {
63
+ let bestMatch = null;
64
+ let bestMatchLength = 0;
65
+ for (const root of knownRoots) {
66
+ // Tilde-expand cloud-flow paths (~/Noesis/...) so they can be
67
+ // compared against an absolute cwd. Roots without a path on this OS
68
+ // expand to '' and are skipped.
69
+ const rawPath = expandHome(root.path);
70
+ if (!rawPath)
71
+ continue;
72
+ const rootPath = path.resolve(rawPath);
73
+ const normalizedCwd = path.resolve(cwd);
74
+ if (normalizedCwd.toLowerCase().startsWith(rootPath.toLowerCase())) {
75
+ if (rootPath.length > bestMatchLength) {
76
+ bestMatch = root;
77
+ bestMatchLength = rootPath.length;
78
+ }
79
+ }
80
+ }
81
+ if (bestMatch) {
82
+ const rootPath = path.resolve(bestMatch.path);
83
+ const relativeToCwd = path.relative(rootPath, cwd);
84
+ const parts = relativeToCwd.split(path.sep).filter(Boolean);
85
+ const projectName = parts[0] || path.basename(cwd);
86
+ return { rootPath: bestMatch.path, projectName };
87
+ }
88
+ }
89
+ // Fallback: Git-based detection (primarily for auto-creating new roots)
90
+ const gitRoot = findGitRoot(cwd);
91
+ if (gitRoot) {
92
+ return {
93
+ rootPath: path.dirname(gitRoot), // Parent of git repo
94
+ projectName: path.basename(gitRoot)
95
+ };
96
+ }
97
+ return null;
98
+ }
99
+ /**
100
+ * Detect project name for a file by finding its nearest .git folder
101
+ */
102
+ function detectProjectForFile(filePath, rootPath) {
103
+ const fileDir = path.dirname(filePath);
104
+ const gitRoot = findGitRoot(fileDir);
105
+ if (gitRoot) {
106
+ // Normalize both paths for comparison (handles Windows/Unix path separator differences)
107
+ const normalizedGitRoot = path.resolve(gitRoot);
108
+ const normalizedRootPath = path.resolve(rootPath);
109
+ if (normalizedGitRoot.startsWith(normalizedRootPath)) {
110
+ return path.basename(gitRoot);
111
+ }
112
+ }
113
+ // Fallback: use first directory component of relative path
114
+ const relativePath = path.relative(rootPath, filePath);
115
+ const parts = relativePath.split(path.sep);
116
+ return parts[0] || 'Uncategorized';
117
+ }
118
+ /**
119
+ * Get a unique file path by appending -1, -2, etc. if file already exists
120
+ */
121
+ function getUniqueFilePath(filePath) {
122
+ if (!fs.existsSync(filePath))
123
+ return filePath;
124
+ const dir = path.dirname(filePath);
125
+ const ext = path.extname(filePath);
126
+ const base = path.basename(filePath, ext);
127
+ let counter = 1;
128
+ let newPath = filePath;
129
+ while (fs.existsSync(newPath)) {
130
+ newPath = path.join(dir, `${base}-${counter}${ext}`);
131
+ counter++;
132
+ }
133
+ return newPath;
134
+ }
135
+ /**
136
+ * Register all MCP tools with the server
137
+ */
138
+ export function registerTools(server, services) {
139
+ const { client, geminiApiKey } = services;
140
+ // Initialize embedding service if API key is provided
141
+ let embeddingsEnabled = false;
142
+ if (geminiApiKey) {
143
+ try {
144
+ initEmbeddingService(geminiApiKey);
145
+ embeddingsEnabled = true;
146
+ }
147
+ catch (error) {
148
+ console.error('Failed to initialize embedding service:', error);
149
+ }
150
+ }
151
+ // Register Navi management tools
152
+ registerNaviTools(server, client);
153
+ // Register search_notes tool
154
+ server.tool('search_notes', 'Search your knowledge base using full-text search. Returns relevant notes ranked by BM25 relevance.', {
155
+ query: z.string().describe('Search query (natural language or keywords)'),
156
+ limit: z.number().optional().describe('Maximum number of results (default: 10, max: 50)'),
157
+ root: z.string().optional().describe('Optional: filter to specific root folder'),
158
+ catalog: z.string().optional().describe('Optional: filter to notes in a specific catalog (e.g., "Work", "Claude")')
159
+ }, async (args) => {
160
+ const { query, limit = 10, root, catalog } = args;
161
+ // Clamp limit to max 50
162
+ const effectiveLimit = Math.min(limit, 50);
163
+ const results = await client.searchNotes(query, {
164
+ limit: effectiveLimit,
165
+ root,
166
+ catalog
167
+ });
168
+ if (results.length === 0) {
169
+ return {
170
+ content: [{
171
+ type: 'text',
172
+ text: `No notes found matching "${query}".\n\nTry:\n- Using different keywords\n- Checking for typos\n- Using broader search terms`
173
+ }]
174
+ };
175
+ }
176
+ const formatted = results.map((note, index) => {
177
+ const relevance = note.relevance ?? 0;
178
+ const relevanceLabel = relevance >= 80 ? '🎯' :
179
+ relevance >= 60 ? '✓' :
180
+ relevance >= 40 ? '○' : '·';
181
+ return `${index + 1}. ${relevanceLabel} **${note.title}** [ID: ${note.id}] (${relevance}% match)
182
+ Path: ${note.file_path}
183
+ ${note.excerpt}
184
+ ${note.modified_at ? `Modified: ${new Date(note.modified_at).toLocaleDateString()}` : ''}`;
185
+ }).join('\n\n');
186
+ return {
187
+ content: [{
188
+ type: 'text',
189
+ text: `Found ${results.length} note${results.length === 1 ? '' : 's'} for "${query}":\n\n${formatted}`
190
+ }]
191
+ };
192
+ });
193
+ // Register search_by_related_code tool
194
+ server.tool('search_by_related_code', 'Find notes that reference a codebase. The query matches a path fragment or codebase label (case-insensitive) against the managed codebase registry, then returns every note linked to a matching codebase.', {
195
+ path: z.string().describe('Codebase path or label fragment (e.g., "bt-ui-apps", "Cloud Connector Service")'),
196
+ limit: z.number().optional().describe('Maximum results (default: 20, max: 50)')
197
+ }, async (args) => {
198
+ const { path, limit = 20 } = args;
199
+ const results = await client.searchByRelatedCode(path, Math.min(limit, 50));
200
+ if (results.length === 0) {
201
+ return {
202
+ content: [{
203
+ type: 'text',
204
+ text: `No notes found referencing a codebase matching "${path}".`
205
+ }]
206
+ };
207
+ }
208
+ const formatted = results.map((note, i) => {
209
+ const desc = note.description ? `\n ${note.description.substring(0, 150)}${note.description.length > 150 ? '...' : ''}` : '';
210
+ const matched = note.matched_label
211
+ ? `${note.matched_label} · ${note.matched_path}`
212
+ : note.matched_path;
213
+ return `${i + 1}. **${note.title}** [ID: ${note.id}]
214
+ Path: ${note.file_path}
215
+ Matched codebase: ${matched}${desc}`;
216
+ }).join('\n\n');
217
+ return {
218
+ content: [{
219
+ type: 'text',
220
+ text: `Found ${results.length} note(s) referencing a codebase matching "${path}":\n\n${formatted}`
221
+ }]
222
+ };
223
+ });
224
+ // Register get_note tool
225
+ server.tool('get_note', 'PRIMARY READ TOOL FOR NOESIS NOTES — USE THIS INSTEAD OF the `Read` filesystem tool for ANY path inside a registered Noesis root (paths containing `/.noesis/`, or any path under a directory returned by `list_roots`). The path is a Noesis identifier, not just a filesystem coordinate; the file may not exist on this machine\'s disk but the content always exists in the Noesis cloud and works across machines. This tool is READ-ONLY (no disk write) and is SAFE TO CALL IN PLAN MODE. RECOVERY RULE: if you already called `Read` on a path inside a Noesis-watched root and got "File does not exist", YOUR IMMEDIATE NEXT ACTION IS THIS TOOL with the same path — DO NOT use Glob or Grep to hunt for similar filenames, DO NOT ask the user to disambiguate the path, DO NOT suggest the user typed it wrong. Trigger phrases: "read this note", "summarize this file", "what does X say", "open this", "check this bug report", "review this doc" — when the referenced path is Noesis-tracked, use THIS tool. For a writable local copy (e.g., `Edit`/`Write` operations, git diffs, or any task that needs the file on local disk), use `sync_notes(files: [path])` instead, which materializes the file onto disk at the canonical path. Accepts either a Note ID (`id`) or a file path (`path`).', {
226
+ id: z.number().optional().describe('Note ID'),
227
+ path: z.string().optional().describe('File path of the note')
228
+ }, async (args) => {
229
+ const { id, path } = args;
230
+ if (!id && !path) {
231
+ return {
232
+ content: [{
233
+ type: 'text',
234
+ text: 'Error: Please provide either an id or a path to get the note.'
235
+ }],
236
+ isError: true
237
+ };
238
+ }
239
+ const note = id ? await client.getNote(id) : await client.getNoteByPath(path);
240
+ if (!note) {
241
+ return {
242
+ content: [{
243
+ type: 'text',
244
+ text: `Note not found: ${id ? `id=${id}` : `path=${path}`}`
245
+ }],
246
+ isError: true
247
+ };
248
+ }
249
+ // Build rich metadata output
250
+ let metadata = `# ${note.title}\n\n**ID:** ${note.id}\n**Path:** ${note.file_path}\n**Modified:** ${note.modified_at || 'Unknown'}`;
251
+ // Online-edit warning: prepended at the very top of the response so Claude sees it before any local Edit/Write.
252
+ const editedOnlineAt = note.edited_online_at;
253
+ const conflictMarker = note.conflict_marker;
254
+ let warningPrefix = '';
255
+ if (editedOnlineAt) {
256
+ warningPrefix += `⚠ This note was edited online (${editedOnlineAt}) — pull the cloud version (\`mcp__noesis__pull_notes\`) before any local Edit/Write.\n\n`;
257
+ metadata += `\n**Edited Online At:** ${editedOnlineAt}`;
258
+ }
259
+ if (conflictMarker) {
260
+ warningPrefix += `⚠ This note has an unresolved sync conflict — run \`/noesis-sync\` or hand-edit the local file and re-run \`mcp__noesis__sync_notes\`.\n\n`;
261
+ metadata += `\n**Conflict Marker:** present`;
262
+ }
263
+ if (note.description) {
264
+ metadata += `\n**Description:** ${note.description}`;
265
+ }
266
+ if (note.keywords && Array.isArray(note.keywords) && note.keywords.length > 0) {
267
+ metadata += `\n**Keywords:** ${note.keywords.join(', ')}`;
268
+ }
269
+ if (note.aliases && Array.isArray(note.aliases) && note.aliases.length > 0) {
270
+ metadata += `\n**Aliases:** ${note.aliases.join(', ')}`;
271
+ }
272
+ if (note.catalogs && Array.isArray(note.catalogs) && note.catalogs.length > 0) {
273
+ metadata += `\n**Catalogs:** ${note.catalogs.join(', ')}`;
274
+ }
275
+ if (note.importance_score != null) {
276
+ metadata += `\n**Importance:** ${note.importance_score}/100`;
277
+ }
278
+ if (note.quality_score != null) {
279
+ metadata += `\n**Quality:** ${note.quality_score}/100`;
280
+ }
281
+ // Relations (from JSONB column)
282
+ const relations = note.relations;
283
+ if (relations && Array.isArray(relations) && relations.length > 0) {
284
+ metadata += `\n\n**Relations:**`;
285
+ for (const rel of relations) {
286
+ metadata += `\n- ${rel.type} → note ${rel.target_id}`;
287
+ if (rel.context)
288
+ metadata += ` (${rel.context})`;
289
+ }
290
+ }
291
+ // Related codebases (resolved from the managed registry)
292
+ const relatedCodebases = note.related_codebases;
293
+ if (relatedCodebases && Array.isArray(relatedCodebases) && relatedCodebases.length > 0) {
294
+ metadata += `\n\n**Related Codebase:**`;
295
+ for (const c of relatedCodebases) {
296
+ const label = c?.label ? `${c.label} · ` : '';
297
+ metadata += `\n- ${label}${c?.path ?? ''}`;
298
+ }
299
+ }
300
+ metadata += `\n\n---\n\n${note.content}`;
301
+ return {
302
+ content: [{
303
+ type: 'text',
304
+ text: warningPrefix + metadata
305
+ }]
306
+ };
307
+ });
308
+ server.tool('get_bookmark_context', 'Read the note content surrounding a specific bookmark/tag. ' +
309
+ 'Accepts either a Noesis bookmark URL of the form ' +
310
+ '`https://noesisbrain.com/notes/{id}#bm={uuid}` ' +
311
+ '(parse it to extract note_id and bookmark_id) ' +
312
+ 'OR explicit note_id + bookmark_id params. ' +
313
+ 'Use this whenever the user shares a `#bm=` URL and asks what is written around it, ' +
314
+ 'or asks Claude to "read the content around this tag/bookmark". ' +
315
+ 'Do NOT use WebFetch on these URLs — the `#bm=` fragment is client-side only.', {
316
+ url: z.string().optional().describe('Full Noesis bookmark URL, e.g. https://noesisbrain.com/notes/2312#bm=384e924d-...'),
317
+ note_id: z.number().optional().describe('Note ID (alternative to url)'),
318
+ bookmark_id: z.string().optional().describe('Bookmark UUID (alternative to url)'),
319
+ context_paragraphs: z.number().optional().describe('Number of paragraphs to show before and after the bookmarked passage (default 2, max 5)'),
320
+ }, async (args) => {
321
+ let noteId = args.note_id;
322
+ let bookmarkId = args.bookmark_id;
323
+ if (args.url) {
324
+ const m = args.url.match(/\/notes\/(\d+)[^#]*#.*bm=([A-Za-z0-9_-]+)/);
325
+ if (m) {
326
+ noteId = parseInt(m[1], 10);
327
+ bookmarkId = m[2];
328
+ }
329
+ }
330
+ if (!noteId || !bookmarkId) {
331
+ return {
332
+ content: [{ type: 'text', text: 'Error: provide either url or both note_id + bookmark_id.' }],
333
+ isError: true
334
+ };
335
+ }
336
+ const result = await client.getBookmarkContext(noteId, bookmarkId, args.context_paragraphs ?? 2);
337
+ if (!result) {
338
+ return {
339
+ content: [{ type: 'text', text: `Bookmark not found (note=${noteId}, bookmark=${bookmarkId})` }],
340
+ isError: true
341
+ };
342
+ }
343
+ const { bookmark, note_title, context_before, context_anchor, context_after } = result;
344
+ const lines = [
345
+ `## Bookmark in "${note_title}" (Note ${noteId})`,
346
+ `**Label:** ${bookmark.label} **Type:** ${bookmark.type} **Color:** ${bookmark.color}`,
347
+ '',
348
+ ];
349
+ if (context_before) {
350
+ lines.push('**Context before:**', context_before, '');
351
+ }
352
+ lines.push(`**[BOOKMARK: ${bookmark.label}]**`, context_anchor, '');
353
+ if (context_after) {
354
+ lines.push('**Context after:**', context_after);
355
+ }
356
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
357
+ });
358
+ // Register get_chat_session tool
359
+ server.tool('get_chat_session', 'Get a chat session (conversation) and its main-thread messages by ID. URL pattern: noesisbrain.com/<id>. Scoped to the authenticated user.', {
360
+ id: z.number().describe('Chat session ID'),
361
+ limit: z.number().optional().describe('Max messages to return (default 200, max 500). Returns the latest N messages in chronological order.')
362
+ }, async (args) => {
363
+ const { id, limit } = args;
364
+ const result = await client.getChatSession(id, limit != null ? { limit } : {});
365
+ if (!result) {
366
+ return {
367
+ content: [{ type: 'text', text: `Chat session not found: id=${id}` }],
368
+ isError: true
369
+ };
370
+ }
371
+ const { session, navi, messages, hasMore } = result;
372
+ const title = session.title || 'Untitled';
373
+ const naviLine = navi
374
+ ? `**Navi:** ${navi.name}${navi.description ? ` — ${navi.description}` : ''}`
375
+ : '**Navi:** _(none)_';
376
+ let out = `# Chat session ${session.id} — "${title}"\n${naviLine}\n`;
377
+ out += `**Created:** ${session.created_at} · **Updated:** ${session.updated_at}\n`;
378
+ out += `**Messages:** ${messages.length}${hasMore ? ' (older messages omitted)' : ''}\n\n---\n`;
379
+ if (messages.length === 0) {
380
+ out += '\n_No messages in this session yet._\n';
381
+ }
382
+ else {
383
+ for (const m of messages) {
384
+ const speaker = m.role === 'user' ? 'user' : (navi?.name || 'assistant');
385
+ out += `\n**${speaker}** · ${m.created_at}\n${m.content}\n`;
386
+ }
387
+ }
388
+ if (hasMore) {
389
+ const next = Math.min((limit ?? 200) * 2, 500);
390
+ out += `\n_Older messages were omitted. Pass \`limit: ${next}\` to fetch more._\n`;
391
+ }
392
+ return { content: [{ type: 'text', text: out }] };
393
+ });
394
+ // Register list_catalogs tool
395
+ server.tool('list_catalogs', 'List all note catalogs (categories) in the knowledge base with note counts.', {}, async () => {
396
+ const catalogs = await client.listCatalogs();
397
+ if (catalogs.length === 0) {
398
+ return {
399
+ content: [{
400
+ type: 'text',
401
+ text: 'No catalogs found.'
402
+ }]
403
+ };
404
+ }
405
+ const formatted = catalogs.map((cat, index) => {
406
+ const builtin = cat.is_builtin ? ' (built-in)' : '';
407
+ let line = `${index + 1}. **${cat.name}**${builtin} — ${cat.note_count} note${cat.note_count === 1 ? '' : 's'} [color: ${cat.color}]`;
408
+ if (cat.description) {
409
+ line += `\n ${cat.description}`;
410
+ }
411
+ return line;
412
+ }).join('\n');
413
+ return {
414
+ content: [{
415
+ type: 'text',
416
+ text: `Catalogs (${catalogs.length}):\n\n${formatted}`
417
+ }]
418
+ };
419
+ });
420
+ // Register list_notes tool
421
+ server.tool('list_notes', 'List notes in the knowledge base with optional filtering.', {
422
+ limit: z.number().optional().describe('Maximum number of notes to return (default: 20)'),
423
+ root: z.string().optional().describe('Filter to specific root folder'),
424
+ catalog: z.string().optional().describe('Filter to notes in a specific catalog (e.g., "Work", "Claude")'),
425
+ recent: z.number().optional().describe('Only show notes modified in the last N days')
426
+ }, async (args) => {
427
+ const { limit = 20, root, catalog, recent } = args;
428
+ let notes;
429
+ if (recent) {
430
+ notes = await client.getRecentNotes(recent, limit);
431
+ }
432
+ else {
433
+ notes = await client.listNotes({ limit, root, catalog });
434
+ }
435
+ if (notes.length === 0) {
436
+ return {
437
+ content: [{
438
+ type: 'text',
439
+ text: 'No notes found matching the criteria.'
440
+ }]
441
+ };
442
+ }
443
+ const formatted = notes.map((note, index) => {
444
+ const fav = note.is_favorite ? '⭐ ' : '';
445
+ const pts = note.points ? ` [${note.points} pts]` : '';
446
+ return `${index + 1}. ${fav}**${note.title || 'Untitled'}**${pts}\n ${note.file_path}`;
447
+ }).join('\n\n');
448
+ const title = recent
449
+ ? `Notes modified in the last ${recent} days (${notes.length}):`
450
+ : `Notes (${notes.length}):`;
451
+ return {
452
+ content: [{
453
+ type: 'text',
454
+ text: `${title}\n\n${formatted}`
455
+ }]
456
+ };
457
+ });
458
+ // Register list_roots tool
459
+ server.tool('list_roots', 'List all watched root directories in the knowledge base.', {}, async () => {
460
+ const roots = await client.getRoots();
461
+ if (roots.length === 0) {
462
+ return {
463
+ content: [{
464
+ type: 'text',
465
+ text: 'No root directories configured.'
466
+ }]
467
+ };
468
+ }
469
+ // Phase32: each root now has a per-OS path map. Show all configured
470
+ // entries with an [active] marker on the CLIENT_OS row.
471
+ const lines = [];
472
+ let anyMissing = false;
473
+ roots.forEach((root, index) => {
474
+ lines.push(`${index + 1}. **${root.name}**`);
475
+ for (const key of ['win32', 'darwin', 'linux']) {
476
+ const v = root.local_paths?.[key];
477
+ const label = key === 'win32' ? 'Windows' : key === 'darwin' ? 'macOS ' : 'Linux ';
478
+ const marker = key === CLIENT_OS ? ' [active]' : '';
479
+ if (v) {
480
+ lines.push(` ${label}: ${v}${marker}`);
481
+ }
482
+ else if (key === CLIENT_OS) {
483
+ lines.push(` ${label}: (not configured for this OS)${marker}`);
484
+ anyMissing = true;
485
+ }
486
+ }
487
+ lines.push('');
488
+ });
489
+ let text = `Watched directories (${roots.length}):\n\n${lines.join('\n').trimEnd()}`;
490
+ if (anyMissing) {
491
+ text += `\n\nTip: roots missing a path for the current OS (${CLIENT_OS}) cannot be used for get_note/sync_notes on this machine. Add the missing path in Noesis Dashboard.`;
492
+ }
493
+ return {
494
+ content: [{ type: 'text', text }]
495
+ };
496
+ });
497
+ // Register add_root tool
498
+ server.tool('add_root', 'Add a new watched root directory to the knowledge base. Provide an OS-specific path via path_win, path_mac, or path_linux; at least one is required. If only one is supplied AND it is under the user\'s home directory, the other OS slot is auto-suggested. Legacy `path` parameter is accepted and auto-routed to the matching OS slot.', {
499
+ name: z.string().describe('Display name for this root (e.g., "My Notes", "Work Projects")'),
500
+ path_win: z.string().optional().describe('Windows absolute path (e.g., "C:/Users/me/notes")'),
501
+ path_mac: z.string().optional().describe('macOS absolute path (e.g., "~/notes" or "/Users/me/notes")'),
502
+ path_linux: z.string().optional().describe('Linux absolute path (e.g., "/home/me/notes")'),
503
+ path: z.string().optional().describe('Legacy single-path form. Auto-routed to the matching OS slot by shape (drive letter → win32; /Users/... → darwin; /... → linux). Prefer path_win/path_mac/path_linux for new code.'),
504
+ }, async (args) => {
505
+ try {
506
+ // Build initial local_paths map.
507
+ const local_paths = {};
508
+ if (args.path_win)
509
+ local_paths['win32'] = normalizePath(args.path_win);
510
+ if (args.path_mac)
511
+ local_paths['darwin'] = args.path_mac;
512
+ if (args.path_linux)
513
+ local_paths['linux'] = args.path_linux;
514
+ // Legacy: route a single `path` arg by shape.
515
+ if (args.path && Object.keys(local_paths).length === 0) {
516
+ const p = args.path;
517
+ const key = /^[A-Za-z]:[\\/]/.test(p) ? 'win32'
518
+ : (p.startsWith('/Users/') || p.startsWith('~/')) ? 'darwin'
519
+ : p.startsWith('/') ? 'linux'
520
+ : 'win32';
521
+ local_paths[key] = key === 'win32' ? normalizePath(p) : p;
522
+ process.stderr.write(`[noesis-mcp] add_root: legacy 'path' routed to local_paths.${key}. Prefer path_${key === 'win32' ? 'win' : key === 'darwin' ? 'mac' : 'linux'} for new code.\n`);
523
+ }
524
+ if (Object.keys(local_paths).length === 0) {
525
+ return {
526
+ content: [{ type: 'text', text: 'At least one of path_win, path_mac, path_linux, or legacy path is required.' }],
527
+ isError: true,
528
+ };
529
+ }
530
+ // Suggest the other-OS slot if not supplied AND source is home-relative.
531
+ // Suggestion lands ONLY for win32↔darwin (the two surfaces in v1). Linux
532
+ // users fill in their own path; no auto-suggestion to or from linux.
533
+ if (local_paths.win32 && !local_paths.darwin) {
534
+ const s = suggestOtherOsPath(local_paths.win32, 'win32');
535
+ if (s) {
536
+ local_paths.darwin = s;
537
+ process.stderr.write(`[noesis-mcp] add_root: auto-filled darwin path as ${s} — edit via the Dashboard if wrong.\n`);
538
+ }
539
+ }
540
+ else if (local_paths.darwin && !local_paths.win32) {
541
+ const s = suggestOtherOsPath(local_paths.darwin, 'darwin');
542
+ if (s) {
543
+ local_paths.win32 = s;
544
+ process.stderr.write(`[noesis-mcp] add_root: auto-filled win32 path as ${s} (contains %USERNAME% template token) — edit via the Dashboard if wrong.\n`);
545
+ }
546
+ }
547
+ const root = await client.createRoot({ name: args.name, local_paths });
548
+ const pathLines = [];
549
+ for (const key of ['win32', 'darwin', 'linux']) {
550
+ const v = root.local_paths?.[key];
551
+ if (v) {
552
+ const label = key === 'win32' ? 'Windows' : key === 'darwin' ? 'macOS' : 'Linux';
553
+ pathLines.push(` - ${label}: ${v}${key === CLIENT_OS ? ' [active]' : ''}`);
554
+ }
555
+ }
556
+ return {
557
+ content: [{
558
+ type: 'text',
559
+ text: `Root added successfully:\n\n- **ID:** ${root.id}\n- **Name:** ${root.name}\n- **Paths:**\n${pathLines.join('\n')}`
560
+ }]
561
+ };
562
+ }
563
+ catch (err) {
564
+ const message = err?.message || String(err);
565
+ if (message.includes('already exists') || message.includes('409')) {
566
+ return {
567
+ content: [{
568
+ type: 'text',
569
+ text: `A root with that path already exists for this OS.`
570
+ }],
571
+ isError: true
572
+ };
573
+ }
574
+ throw err;
575
+ }
576
+ });
577
+ // Register pull_notes tool
578
+ server.tool('pull_notes', 'Pull notes from Noesis cloud database to create local .md files. Use this to sync notes to a new machine.', {
579
+ destination: z.string().describe('Local folder path where notes will be created (e.g., "C:/projects/my-notes")'),
580
+ root: z.string().optional().describe('Optional: only pull notes from a specific root (by name)'),
581
+ overwrite: z.boolean().optional().describe('Overwrite existing files (default: false, skip existing)'),
582
+ dryRun: z.boolean().optional().describe('Preview what would be pulled without creating files (default: false)')
583
+ }, async (args) => {
584
+ const { destination, root, overwrite = false, dryRun = false } = args;
585
+ // Validate destination path
586
+ const destPath = path.resolve(destination);
587
+ // SAFETY: Check if destination is inside any watched root
588
+ // This prevents creating duplicate notes when sync_notes runs later
589
+ const roots = await client.getRoots();
590
+ for (const watchedRoot of roots) {
591
+ // Normalize paths for comparison (handle trailing slashes, case)
592
+ const normalizedDest = path.normalize(destPath).toLowerCase();
593
+ const normalizedRoot = path.normalize(watchedRoot.path || '').toLowerCase();
594
+ if (!normalizedRoot)
595
+ continue;
596
+ // Add path separator to ensure proper directory boundary matching
597
+ // e.g., "C:\temp_cGit" should not match "C:\temp_cGit2"
598
+ const destWithSep = normalizedDest.endsWith(path.sep) ? normalizedDest : normalizedDest + path.sep;
599
+ const rootWithSep = normalizedRoot.endsWith(path.sep) ? normalizedRoot : normalizedRoot + path.sep;
600
+ // Check if destPath is inside rootPath or vice versa
601
+ if (destWithSep.startsWith(rootWithSep) || rootWithSep.startsWith(destWithSep)) {
602
+ return {
603
+ content: [{
604
+ type: 'text',
605
+ text: `❌ **Error: Cannot pull to this destination**\n\n` +
606
+ `The destination "${destPath}" overlaps with watched root "${watchedRoot.name}" (${watchedRoot.path}).\n\n` +
607
+ `This would cause duplicate notes when sync_notes runs later.\n\n` +
608
+ `**Solution:** Choose a destination outside of all watched roots, such as:\n` +
609
+ `- \`C:/pulled-notes\`\n` +
610
+ `- \`D:/backup/notes\`\n` +
611
+ `- Any folder NOT inside: ${roots.map(r => r.path).join(', ')}`
612
+ }]
613
+ };
614
+ }
615
+ }
616
+ // Get notes from database
617
+ const notes = await client.getNotesForPull({ root });
618
+ if (notes.length === 0) {
619
+ return {
620
+ content: [{
621
+ type: 'text',
622
+ text: root
623
+ ? `No notes found in root "${root}".`
624
+ : 'No notes found in the database.'
625
+ }]
626
+ };
627
+ }
628
+ // Group notes by root for reporting
629
+ const notesByRoot = new Map();
630
+ for (const note of notes) {
631
+ if (!notesByRoot.has(note.root_name)) {
632
+ notesByRoot.set(note.root_name, []);
633
+ }
634
+ notesByRoot.get(note.root_name).push(note);
635
+ }
636
+ // If dry run, just report what would happen
637
+ if (dryRun) {
638
+ let report = `**Dry Run - Would pull ${notes.length} notes to ${destPath}:**\n\n`;
639
+ for (const [rootName, rootNotes] of notesByRoot) {
640
+ report += `**${rootName}** (${rootNotes.length} notes):\n`;
641
+ for (const note of rootNotes.slice(0, 10)) {
642
+ const filePath = path.join(destPath, note.relative_path);
643
+ const exists = fs.existsSync(filePath);
644
+ const action = exists ? (overwrite ? '⚠️ OVERWRITE' : '⏭️ SKIP') : '✅ CREATE';
645
+ report += ` ${action}: ${note.relative_path}\n`;
646
+ }
647
+ if (rootNotes.length > 10) {
648
+ report += ` ... and ${rootNotes.length - 10} more\n`;
649
+ }
650
+ report += '\n';
651
+ }
652
+ return {
653
+ content: [{
654
+ type: 'text',
655
+ text: report
656
+ }]
657
+ };
658
+ }
659
+ // Create destination directory if it doesn't exist
660
+ if (!fs.existsSync(destPath)) {
661
+ fs.mkdirSync(destPath, { recursive: true });
662
+ }
663
+ // Pull notes
664
+ const results = {
665
+ created: 0,
666
+ skipped: 0,
667
+ overwritten: 0,
668
+ errors: []
669
+ };
670
+ for (const note of notes) {
671
+ try {
672
+ const filePath = path.join(destPath, note.relative_path);
673
+ const fileDir = path.dirname(filePath);
674
+ // Check if file exists
675
+ if (fs.existsSync(filePath)) {
676
+ if (overwrite) {
677
+ // Create directory if needed
678
+ if (!fs.existsSync(fileDir)) {
679
+ fs.mkdirSync(fileDir, { recursive: true });
680
+ }
681
+ fs.writeFileSync(filePath, note.content, 'utf-8');
682
+ results.overwritten++;
683
+ }
684
+ else {
685
+ results.skipped++;
686
+ }
687
+ }
688
+ else {
689
+ // Create directory if needed
690
+ if (!fs.existsSync(fileDir)) {
691
+ fs.mkdirSync(fileDir, { recursive: true });
692
+ }
693
+ fs.writeFileSync(filePath, note.content, 'utf-8');
694
+ results.created++;
695
+ }
696
+ }
697
+ catch (error) {
698
+ results.errors.push(`${note.relative_path}: ${error instanceof Error ? error.message : 'Unknown error'}`);
699
+ }
700
+ }
701
+ // Build result message
702
+ let message = `**Pull complete!**\n\n`;
703
+ message += `📁 Destination: ${destPath}\n`;
704
+ message += `✅ Created: ${results.created} files\n`;
705
+ if (results.overwritten > 0) {
706
+ message += `⚠️ Overwritten: ${results.overwritten} files\n`;
707
+ }
708
+ if (results.skipped > 0) {
709
+ message += `⏭️ Skipped: ${results.skipped} existing files\n`;
710
+ }
711
+ if (results.errors.length > 0) {
712
+ message += `\n❌ Errors (${results.errors.length}):\n`;
713
+ for (const error of results.errors.slice(0, 5)) {
714
+ message += ` - ${error}\n`;
715
+ }
716
+ if (results.errors.length > 5) {
717
+ message += ` ... and ${results.errors.length - 5} more errors\n`;
718
+ }
719
+ }
720
+ message += `\n**Notes by root:**\n`;
721
+ for (const [rootName, rootNotes] of notesByRoot) {
722
+ message += ` - ${rootName}: ${rootNotes.length} notes\n`;
723
+ }
724
+ return {
725
+ content: [{
726
+ type: 'text',
727
+ text: message
728
+ }]
729
+ };
730
+ });
731
+ // Register list_edited_online_notes tool
732
+ server.tool('list_edited_online_notes', 'List notes that were edited via the Noesis web UI (Quick Fix) and have pending local sync. For each note, reports whether the local file is unchanged (safe to pull), also_modified (conflict-cascade will run), or not_on_disk. Use this before running sync_notes to preview what will happen.', {
733
+ root_id: z.number().optional().describe('Filter to a specific root ID (optional; omit to list all roots)')
734
+ }, async (args) => {
735
+ const { root_id } = args;
736
+ const editedNotes = await client.getEditedOnlineNotes(root_id);
737
+ if (editedNotes.length === 0) {
738
+ return {
739
+ content: [{
740
+ type: 'text',
741
+ text: 'No notes with pending online edits found. All notes are in sync.'
742
+ }]
743
+ };
744
+ }
745
+ // Group by root_path to load SyncStateManager once per root
746
+ const byRoot = new Map();
747
+ for (const note of editedNotes) {
748
+ const key = note.root_path;
749
+ if (!byRoot.has(key))
750
+ byRoot.set(key, []);
751
+ byRoot.get(key).push(note);
752
+ }
753
+ const rows = [];
754
+ let safeToPull = 0;
755
+ for (const [rootPath, notes] of byRoot) {
756
+ // Load baseline state once for this root
757
+ const syncManager = new SyncStateManager(rootPath);
758
+ syncManager.load();
759
+ for (const note of notes) {
760
+ const localFilePath = path.join(rootPath, note.relative_path);
761
+ let localStatus;
762
+ if (!fs.existsSync(localFilePath)) {
763
+ localStatus = 'not_on_disk';
764
+ }
765
+ else {
766
+ const localContent = fs.readFileSync(localFilePath, 'utf-8');
767
+ const localHash = NoesisClient.computeHash(localContent);
768
+ const baselineHash = syncManager.getBaseline(note.relative_path);
769
+ if (baselineHash === undefined) {
770
+ // No baseline yet — compare local hash to cloud hash directly
771
+ localStatus = localHash === note.hash ? 'unchanged' : 'also_modified';
772
+ }
773
+ else {
774
+ localStatus = localHash === baselineHash ? 'unchanged' : 'also_modified';
775
+ }
776
+ }
777
+ if (localStatus === 'unchanged')
778
+ safeToPull++;
779
+ const statusLabel = localStatus === 'unchanged' ? '✓ safe to pull'
780
+ : localStatus === 'also_modified' ? '⚡ will conflict-cascade'
781
+ : '? not on disk';
782
+ const editedAt = new Date(note.edited_online_at).toLocaleString();
783
+ const title = note.title || note.relative_path;
784
+ rows.push(`- **${title}** [ID: ${note.id}]\n` +
785
+ ` File: ${note.relative_path}\n` +
786
+ ` Edited online: ${editedAt}\n` +
787
+ ` Local: ${statusLabel}`);
788
+ }
789
+ }
790
+ const count = editedNotes.length;
791
+ const noun = count === 1 ? 'note' : 'notes';
792
+ return {
793
+ content: [{
794
+ type: 'text',
795
+ text: `**${count} ${noun} with pending online edits** (${safeToPull} safe to pull, ${count - safeToPull} will conflict-cascade):\n\n` +
796
+ rows.join('\n\n') +
797
+ '\n\n**Next step:** Run `sync_notes` with the file paths (absolute) to pull. Safe notes pull automatically; others run the conflict cascade (Tier A → B → C).'
798
+ }]
799
+ };
800
+ });
801
+ // Register sync_notes tool (bidirectional sync)
802
+ server.tool('sync_notes', 'Bidirectional sync for the listed files. If a `files` path is MISSING LOCALLY but present in the cloud, this materializes the cloud content onto local disk at that path — creating any missing parent directories — and sets a sync baseline so future edits diff correctly. Use this whenever the user references a path inside a registered Noesis root (`list_roots`) and the file is not yet on this machine (e.g., the note was created by Claude Code on another laptop and pushed to Noesis); after the call the file will exist on disk at the canonical path and can be read, edited, and re-pushed normally. If both sides have content and differ, conflicts are detected and reported. Calling without `root` or `files` performs a FULL root scan, which is slow and should only be used for intentional bulk syncs. PLAN-MODE GUIDANCE: This tool writes to disk so plan mode forbids the actual call, but you SHOULD propose calling it as Step 1 of any plan that needs a local file for a Noesis-tracked path that is missing on this machine — DO NOT ask the user "did you mean a different path?" or fall back to filesystem search when the path is inside a registered Noesis root. For read-only intent in plan mode (just reading content), prefer `get_note(path)` instead — it is read-only and plan-safe.', {
803
+ root: z.string().optional().describe('Sync only a specific root folder (by name)'),
804
+ files: z.array(z.string()).optional().describe('Sync specific file paths only. IMPORTANT: Use absolute paths (e.g., "C:/projects/docs/file.md"). Relative paths may resolve incorrectly.'),
805
+ dryRun: z.boolean().optional().describe('Preview changes without syncing (default: false)'),
806
+ force: z.boolean().optional().describe('Force re-sync even if file hash is unchanged. Useful for regenerating AI metadata on existing files (default: false)'),
807
+ regenerateMetadata: z.boolean().optional().describe('Regenerate all metadata using AI, overwriting existing values. Use when you want AI to improve/replace current title, description, and keywords (default: false)'),
808
+ moveNewToNoesis: z.boolean().optional().describe('Move new local-only files to .noesis folder before syncing. Keeps ad-hoc notes separate from project files (default: false)')
809
+ }, async (args) => {
810
+ const { root, files, dryRun = false, force = false, regenerateMetadata = false, moveNewToNoesis = false } = args;
811
+ // Get roots for sync
812
+ let roots = await client.getRootsForSync();
813
+ // If no roots configured, try auto-detection from CWD
814
+ if (roots.length === 0) {
815
+ const detected = detectRootFromCwd();
816
+ if (detected) {
817
+ // Check if this root already exists
818
+ const existingRoot = await client.getRootByPath(normalizePath(detected.rootPath));
819
+ if (existingRoot) {
820
+ roots = [{
821
+ id: existingRoot.id,
822
+ name: existingRoot.name,
823
+ path: existingRoot.path || getActivePathFromMap(existingRoot.local_paths),
824
+ local_paths: existingRoot.local_paths || {},
825
+ lastScannedAt: null,
826
+ }];
827
+ }
828
+ else if (!dryRun) {
829
+ // Auto-create the root — populate only the CLIENT_OS key; other slots
830
+ // can be filled later from the Dashboard or another machine.
831
+ const newRoot = await client.createRoot({
832
+ name: path.basename(detected.rootPath),
833
+ local_paths: { [CLIENT_OS]: normalizePath(detected.rootPath) },
834
+ });
835
+ roots = [{
836
+ id: newRoot.id,
837
+ name: newRoot.name,
838
+ path: newRoot.path || getActivePathFromMap(newRoot.local_paths),
839
+ local_paths: newRoot.local_paths || {},
840
+ lastScannedAt: null,
841
+ }];
842
+ }
843
+ else {
844
+ return {
845
+ content: [{
846
+ type: 'text',
847
+ text: `**Dry Run - Auto-Detection:**\n\nNo roots configured. Would auto-create root from current directory:\n\n📁 **Root:** ${detected.rootPath}\n📦 **Project:** ${detected.projectName}\n\nRun without --dryRun to create this root and sync files.`
848
+ }]
849
+ };
850
+ }
851
+ }
852
+ else {
853
+ return {
854
+ content: [{
855
+ type: 'text',
856
+ text: 'No root directories configured and could not auto-detect from current directory.\n\nTo sync, either:\n1. Run from a git repository directory\n2. Add roots through the Noesis web UI'
857
+ }]
858
+ };
859
+ }
860
+ }
861
+ // Safety guard: require explicit scope when roots exist.
862
+ // Never sync based on CWD auto-detection alone — process.cwd() is fixed
863
+ // at MCP server startup and doesn't reflect which file the user means.
864
+ // Use `files` for specific files, `root` for full root sync.
865
+ if (!root && !files) {
866
+ const rootList = roots
867
+ .map(r => `- \`${r.name}\`: ${r.path}`)
868
+ .join('\n');
869
+ return {
870
+ content: [{
871
+ type: 'text',
872
+ text: `No scope specified. Please use one of:\n\n` +
873
+ `**Sync specific files** (recommended):\n` +
874
+ `sync_notes({ files: ["C:/full/path/to/file.md"] })\n\n` +
875
+ `**Sync an entire root:**\n${rootList}\n` +
876
+ `Example: sync_notes({ root: "${roots[0].name}" })`
877
+ }]
878
+ };
879
+ }
880
+ // Handle specific files sync mode (push-only for specific files)
881
+ if (files && files.length > 0) {
882
+ return await syncSpecificFiles(files, roots, dryRun, client, force, regenerateMetadata);
883
+ }
884
+ // Filter to specific root if requested
885
+ const rootsToSync = root
886
+ ? roots.filter(r => r.name.toLowerCase().includes(root.toLowerCase()))
887
+ : roots;
888
+ if (rootsToSync.length === 0) {
889
+ return {
890
+ content: [{
891
+ type: 'text',
892
+ text: `No root found matching "${root}". Available roots:\n${roots.map(r => `- ${r.name}: ${r.path}`).join('\n')}`
893
+ }]
894
+ };
895
+ }
896
+ // Initialize bidirectional sync result
897
+ const result = {
898
+ pushed: { created: 0, updated: 0 },
899
+ pulled: { created: 0, updated: 0 },
900
+ skipped: 0,
901
+ conflicts: [],
902
+ errors: [],
903
+ details: []
904
+ };
905
+ // Track files moved to .noesis folder
906
+ const allMovedToNoesis = [];
907
+ for (const syncRoot of rootsToSync) {
908
+ // Check if root path exists
909
+ if (!fs.existsSync(syncRoot.path)) {
910
+ result.errors.push(`Root path not found: ${syncRoot.path}`);
911
+ continue;
912
+ }
913
+ // Load sync state for three-way hash comparison
914
+ const stateMgr = new SyncStateManager(syncRoot.path);
915
+ stateMgr.load();
916
+ // Get cloud notes with hashes and timestamps
917
+ const cloudNotes = await client.getNotesForSync(syncRoot.id);
918
+ const cloudMap = new Map();
919
+ for (const note of cloudNotes) {
920
+ if (note.relative_path) {
921
+ // Normalize for consistent comparison with local paths
922
+ const normalizedPath = normalizePath(note.relative_path);
923
+ cloudMap.set(normalizedPath, {
924
+ id: note.id,
925
+ hash: note.hash,
926
+ modified_at: note.modified_at,
927
+ content: note.content,
928
+ title: note.title,
929
+ description: note.description,
930
+ keywords: note.keywords,
931
+ edited_online_at: note.edited_online_at,
932
+ });
933
+ }
934
+ }
935
+ // Scan local .md files (with project detection)
936
+ const localFiles = scanMarkdownFiles(syncRoot.path, syncRoot.id, syncRoot.name, true);
937
+ const localMap = new Map();
938
+ for (const file of localFiles) {
939
+ localMap.set(file.relativePath, file);
940
+ }
941
+ // Get all unique paths (union of local and cloud)
942
+ const allPaths = new Set([...localMap.keys(), ...cloudMap.keys()]);
943
+ // Collect LOCAL ONLY files (exist locally, not in cloud, not already in .noesis)
944
+ const localOnlyFiles = [];
945
+ for (const relativePath of allPaths) {
946
+ const localFile = localMap.get(relativePath);
947
+ const cloudNote = cloudMap.get(relativePath);
948
+ if (localFile && !cloudNote) {
949
+ // Skip files already in .noesis folder
950
+ if (!relativePath.startsWith('.noesis/')) {
951
+ localOnlyFiles.push(localFile);
952
+ }
953
+ }
954
+ }
955
+ // If LOCAL ONLY files found and moveNewToNoesis not set, return prompt
956
+ if (localOnlyFiles.length > 0 && !moveNewToNoesis && !dryRun) {
957
+ return {
958
+ content: [{
959
+ type: 'text',
960
+ text: `Found ${localOnlyFiles.length} new local note(s) not yet in cloud:\n` +
961
+ localOnlyFiles.map(f => `- ${f.relativePath}`).join('\n') +
962
+ `\n\n📁 Would you like to move them to \`.noesis\` folder before syncing?\n` +
963
+ `This keeps Noesis notes separate from project files.\n\n` +
964
+ `**Options:**\n` +
965
+ `- Run sync with \`moveNewToNoesis: true\` to move and sync\n` +
966
+ `- Run sync again without the parameter to sync from current location`
967
+ }]
968
+ };
969
+ }
970
+ // If moveNewToNoesis is true and there are LOCAL ONLY files, move them to .noesis
971
+ const movedToNoesisFiles = [];
972
+ if (moveNewToNoesis && localOnlyFiles.length > 0 && !dryRun) {
973
+ for (const file of localOnlyFiles) {
974
+ const oldPath = file.path;
975
+ const fileName = path.basename(oldPath);
976
+ // Create .noesis folder in the same directory as the file (project level)
977
+ const fileDir = path.dirname(oldPath);
978
+ const noesisDir = path.join(fileDir, '.noesis');
979
+ if (!fs.existsSync(noesisDir)) {
980
+ fs.mkdirSync(noesisDir, { recursive: true });
981
+ }
982
+ const newPath = path.join(noesisDir, fileName);
983
+ // Handle name collision
984
+ const finalPath = getUniqueFilePath(newPath);
985
+ fs.renameSync(oldPath, finalPath);
986
+ // Update localMap with new relative path (preserve parent folder structure)
987
+ const relativeDir = path.dirname(file.relativePath);
988
+ const newRelativePath = relativeDir ? `${relativeDir}/.noesis/${path.basename(finalPath)}` : `.noesis/${path.basename(finalPath)}`;
989
+ localMap.delete(file.relativePath);
990
+ stateMgr.removeBaseline(file.relativePath);
991
+ // Re-read file to get updated stats
992
+ const content = fs.readFileSync(finalPath, 'utf-8');
993
+ const stats = fs.statSync(finalPath);
994
+ const hash = NoesisClient.computeHash(content);
995
+ localMap.set(newRelativePath, {
996
+ ...file,
997
+ path: normalizePath(finalPath),
998
+ relativePath: newRelativePath,
999
+ content,
1000
+ hash,
1001
+ mtime: stats.mtime,
1002
+ size: stats.size
1003
+ });
1004
+ movedToNoesisFiles.push(`${file.relativePath} → ${newRelativePath}`);
1005
+ }
1006
+ // Update allPaths to reflect the moves
1007
+ allPaths.clear();
1008
+ for (const key of localMap.keys()) {
1009
+ allPaths.add(key);
1010
+ }
1011
+ for (const key of cloudMap.keys()) {
1012
+ allPaths.add(key);
1013
+ }
1014
+ // Track moved files for the summary
1015
+ allMovedToNoesis.push(...movedToNoesisFiles);
1016
+ }
1017
+ for (const relativePath of allPaths) {
1018
+ const localFile = localMap.get(relativePath);
1019
+ const cloudNote = cloudMap.get(relativePath);
1020
+ try {
1021
+ if (localFile && !cloudNote) {
1022
+ // LOCAL ONLY: Push to cloud (create)
1023
+ if (dryRun) {
1024
+ result.details.push({ file: relativePath, action: 'pushed_create' });
1025
+ result.pushed.created++;
1026
+ }
1027
+ else {
1028
+ const metadata = parseYamlFrontmatter(localFile.content);
1029
+ await client.upsertNote(localFile, metadata, { force, regenerateMetadata });
1030
+ if (localFile.hash)
1031
+ stateMgr.setBaseline(relativePath, { hash: localFile.hash, lastSyncedAt: new Date().toISOString() }, localFile.content);
1032
+ result.details.push({ file: relativePath, action: 'pushed_create' });
1033
+ result.pushed.created++;
1034
+ }
1035
+ }
1036
+ else if (!localFile && cloudNote) {
1037
+ // CLOUD ONLY: Pull to local (create)
1038
+ if (dryRun) {
1039
+ result.details.push({ file: relativePath, action: 'pulled_create' });
1040
+ result.pulled.created++;
1041
+ }
1042
+ else {
1043
+ // Write file locally
1044
+ const localPath = path.join(syncRoot.path, relativePath);
1045
+ const localDir = path.dirname(localPath);
1046
+ if (!fs.existsSync(localDir)) {
1047
+ fs.mkdirSync(localDir, { recursive: true });
1048
+ }
1049
+ fs.writeFileSync(localPath, cloudNote.content, 'utf-8');
1050
+ // Update DB file_size to match actual written file (may differ due to line endings)
1051
+ const stats = fs.statSync(localPath);
1052
+ await client.updateFileMetadata(normalizePath(localPath), stats.size, cloudNote.hash);
1053
+ stateMgr.setBaseline(relativePath, { hash: cloudNote.hash, lastSyncedAt: cloudNote.modified_at }, cloudNote.content);
1054
+ result.details.push({ file: relativePath, action: 'pulled_create' });
1055
+ result.pulled.created++;
1056
+ }
1057
+ }
1058
+ else if (localFile && cloudNote) {
1059
+ // BOTH EXIST: Compare hashes
1060
+ if (localFile.hash === cloudNote.hash) {
1061
+ // Same hash: seed baseline and check for metadata enrichment
1062
+ if (!dryRun && localFile.hash) {
1063
+ stateMgr.setBaseline(relativePath, { hash: localFile.hash, lastSyncedAt: cloudNote.modified_at }, localFile.content);
1064
+ }
1065
+ // Check if cloud has enriched metadata that local lacks
1066
+ if (!dryRun) {
1067
+ const localMetadata = parseYamlFrontmatter(localFile.content);
1068
+ const cloudKw = parseCloudKeywords(cloudNote.keywords);
1069
+ const metadataDiffers = (cloudNote.title && cloudNote.title !== localMetadata.title) ||
1070
+ (cloudNote.description && cloudNote.description !== localMetadata.description) ||
1071
+ (cloudKw.length > 0 && JSON.stringify(cloudKw.sort()) !== JSON.stringify((localMetadata.keywords || []).sort()));
1072
+ if (metadataDiffers) {
1073
+ const localPath = path.join(syncRoot.path, relativePath);
1074
+ const updatedContent = updateFrontmatter(localFile.content, {
1075
+ title: cloudNote.title || undefined,
1076
+ description: cloudNote.description || undefined,
1077
+ keywords: cloudKw.length > 0 ? cloudKw : undefined
1078
+ });
1079
+ fs.writeFileSync(localPath, updatedContent, 'utf-8');
1080
+ result.details.push({ file: relativePath, action: 'pulled_update', reason: 'metadata from cloud' });
1081
+ result.pulled.updated++;
1082
+ continue;
1083
+ }
1084
+ }
1085
+ // Hashes match but cloud still has edited_online_at set — clear the stale flag
1086
+ if (!dryRun && cloudNote.edited_online_at) {
1087
+ const localPath = path.join(syncRoot.path, relativePath);
1088
+ const stats = fs.statSync(localPath);
1089
+ await client.updateFileMetadata(normalizePath(localPath), stats.size, localFile.hash);
1090
+ }
1091
+ result.details.push({ file: relativePath, action: 'skipped', reason: 'unchanged' });
1092
+ result.skipped++;
1093
+ }
1094
+ else {
1095
+ // Different hash: use three-way comparison with baseline
1096
+ const baselineMeta = stateMgr.getBaselineMeta(relativePath);
1097
+ const baselineHash = baselineMeta?.hash;
1098
+ const baselineLastSyncedAt = baselineMeta?.lastSyncedAt;
1099
+ const localMtime = localFile.mtime ? localFile.mtime.getTime() : Date.now();
1100
+ const cloudMtime = new Date(cloudNote.modified_at).getTime();
1101
+ const direction = force
1102
+ ? 'push'
1103
+ : determineSyncDirection(localFile.hash, cloudNote.hash, baselineHash, localMtime, cloudMtime, cloudNote.edited_online_at, baselineLastSyncedAt);
1104
+ if (direction === 'conflict') {
1105
+ await runConflictCascade({
1106
+ relativePath,
1107
+ localPath: path.join(syncRoot.path, relativePath),
1108
+ localContent: localFile.content,
1109
+ cloudNote: {
1110
+ id: cloudNote.id,
1111
+ content: cloudNote.content,
1112
+ hash: cloudNote.hash,
1113
+ modified_at: cloudNote.modified_at,
1114
+ edited_online_at: cloudNote.edited_online_at,
1115
+ },
1116
+ baselineHash,
1117
+ baselineLastSyncedAt,
1118
+ baselineContent: stateMgr.getBaselineContent(relativePath),
1119
+ localFile,
1120
+ localMtime: localFile.mtime ?? new Date(),
1121
+ dryRun,
1122
+ client,
1123
+ stateMgr,
1124
+ result,
1125
+ });
1126
+ }
1127
+ else if (direction === 'push') {
1128
+ // Local changed: smart merge - cloud H1 + local body, preserve cloud metadata
1129
+ if (dryRun) {
1130
+ result.details.push({ file: relativePath, action: 'pushed_update', reason: 'merged' });
1131
+ result.pushed.updated++;
1132
+ }
1133
+ else {
1134
+ const metadata = parseYamlFrontmatter(localFile.content);
1135
+ // Merge: use cloud's H1 (Noesis title edits) + local's body (local content edits)
1136
+ const mergedContent = mergeContent(localFile.content, cloudNote.content);
1137
+ // Enrich frontmatter with cloud metadata before push
1138
+ const cloudKw = parseCloudKeywords(cloudNote.keywords);
1139
+ const hasCloudMetadata = cloudNote.title || cloudNote.description || cloudKw.length > 0;
1140
+ const enrichedContent = hasCloudMetadata
1141
+ ? updateFrontmatter(mergedContent, {
1142
+ title: cloudNote.title || undefined,
1143
+ description: cloudNote.description || undefined,
1144
+ keywords: cloudKw.length > 0 ? cloudKw : undefined
1145
+ })
1146
+ : mergedContent;
1147
+ const enrichedHash = NoesisClient.computeHash(enrichedContent);
1148
+ const mergedFile = {
1149
+ ...localFile,
1150
+ content: enrichedContent,
1151
+ hash: enrichedHash
1152
+ };
1153
+ // preserveMetadata=true: keep cloud's AI-generated title/description/keywords
1154
+ await client.upsertNote(mergedFile, metadata, { force: true, regenerateMetadata, preserveMetadata: true });
1155
+ // Write enriched content back to local file
1156
+ const localPath = path.join(syncRoot.path, relativePath);
1157
+ fs.writeFileSync(localPath, enrichedContent, 'utf-8');
1158
+ stateMgr.setBaseline(relativePath, { hash: enrichedHash, lastSyncedAt: new Date().toISOString() }, enrichedContent);
1159
+ result.details.push({ file: relativePath, action: 'pushed_update', reason: 'merged' });
1160
+ result.pushed.updated++;
1161
+ }
1162
+ }
1163
+ else if (direction === 'pull') {
1164
+ // Cloud changed: pull to local
1165
+ if (dryRun) {
1166
+ result.details.push({ file: relativePath, action: 'pulled_update' });
1167
+ result.pulled.updated++;
1168
+ }
1169
+ else {
1170
+ const localPath = path.join(syncRoot.path, relativePath);
1171
+ fs.writeFileSync(localPath, cloudNote.content, 'utf-8');
1172
+ // Update DB file_size to match actual written file (may differ due to line endings)
1173
+ const stats = fs.statSync(localPath);
1174
+ await client.updateFileMetadata(normalizePath(localPath), stats.size, cloudNote.hash);
1175
+ stateMgr.setBaseline(relativePath, { hash: cloudNote.hash, lastSyncedAt: cloudNote.modified_at }, cloudNote.content);
1176
+ result.details.push({ file: relativePath, action: 'pulled_update' });
1177
+ result.pulled.updated++;
1178
+ }
1179
+ }
1180
+ }
1181
+ }
1182
+ }
1183
+ catch (error) {
1184
+ const errorMsg = error instanceof Error ? error.message : 'Unknown error';
1185
+ result.errors.push(`${relativePath}: ${errorMsg}`);
1186
+ result.details.push({ file: relativePath, action: 'error', reason: errorMsg });
1187
+ }
1188
+ }
1189
+ // Prune stale baselines for files no longer on either side, then save
1190
+ if (!dryRun) {
1191
+ stateMgr.pruneStale(allPaths);
1192
+ stateMgr.save();
1193
+ }
1194
+ // Update root scan time (unless dry run)
1195
+ if (!dryRun) {
1196
+ await client.updateRootScanTime(syncRoot.id);
1197
+ // Log sync operation for Dashboard
1198
+ try {
1199
+ await client.logSyncOperation({
1200
+ rootId: syncRoot.id,
1201
+ filesScanned: localFiles.length,
1202
+ filesAdded: result.pushed.created,
1203
+ filesUpdated: result.pushed.updated,
1204
+ filesDeleted: 0,
1205
+ source: 'mcp-bidirectional',
1206
+ machineName: os.hostname(),
1207
+ notes: result.conflicts.length > 0 ? `${result.conflicts.length} conflicts` : undefined
1208
+ });
1209
+ }
1210
+ catch (logError) {
1211
+ console.error('Failed to log sync operation:', logError);
1212
+ }
1213
+ }
1214
+ }
1215
+ // Build response message
1216
+ let message = dryRun ? '**Dry Run - Bidirectional Sync Preview:**\n\n' : '**Bidirectional Sync Complete!**\n\n';
1217
+ const totalPushed = result.pushed.created + result.pushed.updated;
1218
+ const totalPulled = result.pulled.created + result.pulled.updated;
1219
+ message += `📊 **Summary:**\n`;
1220
+ if (allMovedToNoesis.length > 0) {
1221
+ message += `- 📂 Moved to .noesis: ${allMovedToNoesis.length}\n`;
1222
+ }
1223
+ message += `- ⬆️ Pushed to cloud: ${totalPushed} (${result.pushed.created} new, ${result.pushed.updated} updated)\n`;
1224
+ message += `- ⬇️ Pulled from cloud: ${totalPulled} (${result.pulled.created} new, ${result.pulled.updated} updated)\n`;
1225
+ message += `- ⏭️ Skipped: ${result.skipped} (unchanged)\n`;
1226
+ if (result.conflicts.length > 0) {
1227
+ message += `- ⚠️ Conflicts: ${result.conflicts.length} (manual resolution needed)\n`;
1228
+ }
1229
+ if (result.errors.length > 0) {
1230
+ message += `- ❌ Errors: ${result.errors.length}\n`;
1231
+ }
1232
+ message += `\n📁 **Roots synced:** ${rootsToSync.map(r => r.name).join(', ')}\n`;
1233
+ // Show moved files
1234
+ if (allMovedToNoesis.length > 0) {
1235
+ message += '\n**📂 Moved to .noesis:**\n';
1236
+ for (const moved of allMovedToNoesis.slice(0, 10)) {
1237
+ message += `- ${moved}\n`;
1238
+ }
1239
+ if (allMovedToNoesis.length > 10) {
1240
+ message += `... and ${allMovedToNoesis.length - 10} more\n`;
1241
+ }
1242
+ }
1243
+ // Show changes (limit to first 20)
1244
+ const showDetails = result.details.filter(d => d.action !== 'skipped').slice(0, 20);
1245
+ if (showDetails.length > 0) {
1246
+ message += '\n**Changes:**\n';
1247
+ for (const detail of showDetails) {
1248
+ const icon = detail.action === 'pushed_create' ? '⬆️✨' :
1249
+ detail.action === 'pushed_update' ? '⬆️🔄' :
1250
+ detail.action === 'pulled_create' ? '⬇️✨' :
1251
+ detail.action === 'pulled_update' ? '⬇️🔄' :
1252
+ detail.action === 'conflict' ? '⚠️' :
1253
+ detail.action === 'error' ? '❌' : '⏭️';
1254
+ message += `${icon} ${detail.file}`;
1255
+ if (detail.reason)
1256
+ message += ` (${detail.reason})`;
1257
+ message += '\n';
1258
+ }
1259
+ const remaining = result.details.filter(d => d.action !== 'skipped').length - 20;
1260
+ if (remaining > 0) {
1261
+ message += `... and ${remaining} more\n`;
1262
+ }
1263
+ }
1264
+ // Show conflicts (with structured BASE/LOCAL/CLOUD blocks when available).
1265
+ if (result.conflicts.length > 0) {
1266
+ message += '\n**⚠️ Conflicts (not synced):**\n';
1267
+ for (const conflict of result.conflicts.slice(0, 5)) {
1268
+ message += `- ${conflict.path}\n`;
1269
+ message += ` Local: ${new Date(conflict.localModified).toLocaleString()}\n`;
1270
+ message += ` Cloud: ${new Date(conflict.cloudModified).toLocaleString()}\n`;
1271
+ const structured = conflict.structuredText;
1272
+ if (structured)
1273
+ message += structured;
1274
+ }
1275
+ if (result.conflicts.length > 5) {
1276
+ message += `... and ${result.conflicts.length - 5} more conflicts\n`;
1277
+ }
1278
+ message += '\n_Resolve via Path 1 (edit the local file and re-run `mcp__noesis__sync_notes`) or Path 2 (run `/noesis-sync`)._\n';
1279
+ }
1280
+ if (result.errors.length > 0) {
1281
+ message += '\n**Errors:**\n';
1282
+ for (const error of result.errors.slice(0, 5)) {
1283
+ message += `❌ ${error}\n`;
1284
+ }
1285
+ if (result.errors.length > 5) {
1286
+ message += `... and ${result.errors.length - 5} more errors\n`;
1287
+ }
1288
+ }
1289
+ return {
1290
+ content: [{
1291
+ type: 'text',
1292
+ text: message
1293
+ }]
1294
+ };
1295
+ });
1296
+ // Register sync_status tool
1297
+ server.tool('sync_status', 'Check Noesis sync status: last sync time, root directories, and file counts.', {}, async () => {
1298
+ const roots = await client.getRootsForSync();
1299
+ const noteCounts = await client.getNoteCountByRoot();
1300
+ const lastSync = await client.getLastSyncTime();
1301
+ if (roots.length === 0) {
1302
+ return {
1303
+ content: [{
1304
+ type: 'text',
1305
+ text: 'No root directories configured. Add roots through the md-manager UI first.'
1306
+ }]
1307
+ };
1308
+ }
1309
+ let message = '**Sync Status**\n\n';
1310
+ message += `🕐 **Last sync:** ${lastSync ? new Date(lastSync).toLocaleString() : 'Never'}\n\n`;
1311
+ message += `📁 **Roots (${roots.length}):**\n`;
1312
+ for (const syncRoot of roots) {
1313
+ const count = noteCounts.get(syncRoot.id) || 0;
1314
+ const exists = fs.existsSync(syncRoot.path);
1315
+ const status = exists ? '✅' : '❌ (not found)';
1316
+ message += `\n**${syncRoot.name}** ${status}\n`;
1317
+ message += ` Path: ${syncRoot.path}\n`;
1318
+ message += ` Notes in DB: ${count}\n`;
1319
+ if (exists) {
1320
+ // Count local files
1321
+ const localFiles = scanMarkdownFiles(syncRoot.path, syncRoot.id, syncRoot.name);
1322
+ message += ` Local .md files: ${localFiles.length}\n`;
1323
+ // Check for pending changes
1324
+ const dbHashes = await client.getNoteHashesByRoot(syncRoot.id);
1325
+ let pending = 0;
1326
+ for (const file of localFiles) {
1327
+ const dbHash = dbHashes.get(file.relativePath);
1328
+ if (!dbHash || dbHash !== file.hash) {
1329
+ pending++;
1330
+ }
1331
+ }
1332
+ if (pending > 0) {
1333
+ message += ` ⚠️ Pending changes: ${pending} files\n`;
1334
+ }
1335
+ }
1336
+ if (syncRoot.lastScannedAt) {
1337
+ message += ` Last scanned: ${new Date(syncRoot.lastScannedAt).toLocaleString()}\n`;
1338
+ }
1339
+ }
1340
+ return {
1341
+ content: [{
1342
+ type: 'text',
1343
+ text: message
1344
+ }]
1345
+ };
1346
+ });
1347
+ // ============================================
1348
+ // Phase 6.1: AI Metadata Enhancement Tools
1349
+ // ============================================
1350
+ // Register enhance_note_metadata tool
1351
+ server.tool('enhance_note_metadata', 'Get a note\'s content and current metadata for AI analysis. Use this to review and improve document metadata (title, description, keywords, aliases) for better searchability. Returns the note content so YOU (Claude) can analyze it and suggest improvements.', {
1352
+ note_id: z.number().describe('The ID of the note to enhance'),
1353
+ apply_suggestions: z.object({
1354
+ title: z.string().optional(),
1355
+ description: z.string().optional(),
1356
+ keywords: z.array(z.string()).optional(),
1357
+ aliases: z.array(z.string()).optional()
1358
+ }).optional().describe('If provided, apply these metadata updates to the note')
1359
+ }, async (args) => {
1360
+ const { note_id, apply_suggestions } = args;
1361
+ // If suggestions provided, apply them
1362
+ if (apply_suggestions) {
1363
+ const updated = await client.updateNoteMetadata(note_id, apply_suggestions);
1364
+ if (!updated) {
1365
+ return {
1366
+ content: [{
1367
+ type: 'text',
1368
+ text: `Failed to update note ${note_id}. Note may not exist.`
1369
+ }],
1370
+ isError: true
1371
+ };
1372
+ }
1373
+ // Build update summary
1374
+ const updates = [];
1375
+ if (apply_suggestions.title)
1376
+ updates.push(`title: "${apply_suggestions.title}"`);
1377
+ if (apply_suggestions.description)
1378
+ updates.push(`description: "${apply_suggestions.description.substring(0, 50)}..."`);
1379
+ if (apply_suggestions.keywords?.length)
1380
+ updates.push(`keywords: [${apply_suggestions.keywords.join(', ')}]`);
1381
+ if (apply_suggestions.aliases?.length)
1382
+ updates.push(`aliases: [${apply_suggestions.aliases.join(', ')}]`);
1383
+ return {
1384
+ content: [{
1385
+ type: 'text',
1386
+ text: `✅ **Metadata updated for note ${note_id}**\n\nUpdates applied:\n${updates.map(u => `- ${u}`).join('\n')}\n\n_The FTS vector will be automatically regenerated on next search._`
1387
+ }]
1388
+ };
1389
+ }
1390
+ // Otherwise, get note for analysis
1391
+ const note = await client.getNoteForEnhancement(note_id);
1392
+ if (!note) {
1393
+ return {
1394
+ content: [{
1395
+ type: 'text',
1396
+ text: `Note ${note_id} not found or is trashed.`
1397
+ }],
1398
+ isError: true
1399
+ };
1400
+ }
1401
+ // Format for Claude to analyze
1402
+ let message = `**Note Analysis: ${note.title}**\n\n`;
1403
+ message += `**File:** ${note.file_path}\n\n`;
1404
+ message += `**Current Metadata:**\n`;
1405
+ message += `- Project/Root: ${note.root_name || '_(unknown)_'}\n`;
1406
+ message += `- Title: ${note.title}\n`;
1407
+ message += `- Description: ${note.description || '_(empty)_'}\n`;
1408
+ message += `- Keywords: ${note.keywords.length > 0 ? note.keywords.join(', ') : '_(empty)_'}\n`;
1409
+ message += `- Aliases: ${note.aliases.length > 0 ? note.aliases.join(', ') : '_(empty)_'}\n`;
1410
+ message += `- Last AI Enhanced: ${note.ai_enhanced_at || 'Never'}\n\n`;
1411
+ // Truncate content if too long, but provide enough for analysis
1412
+ const contentPreview = note.content.length > 3000
1413
+ ? note.content.substring(0, 3000) + '\n\n...[truncated]...'
1414
+ : note.content;
1415
+ message += `**Content Preview:**\n\`\`\`markdown\n${contentPreview}\n\`\`\`\n\n`;
1416
+ message += `---\n\n`;
1417
+ message += `**Instructions for Claude:**\n`;
1418
+ message += `Analyze this document and suggest improved metadata. Consider:\n`;
1419
+ message += `1. **Title**: Should be descriptive and searchable (e.g., "AI Chat Implementation with RAG and Gemini")\n`;
1420
+ message += `2. **Description**: 1-2 sentence summary of what this document is about\n`;
1421
+ message += `3. **Keywords**: 5-10 terms including:\n`;
1422
+ message += ` - The main project/system/product this document is about (identify from content)\n`;
1423
+ message += ` - If root folder "${note.root_name}" is a meaningful project name (not generic like ".claude-notes", "docs"), include it\n`;
1424
+ message += ` - Technical terms, technologies, and concepts mentioned\n`;
1425
+ message += ` - Common search terms users might use to find this document\n`;
1426
+ message += `4. **Aliases**: Alternative names or phrases that could refer to this topic\n\n`;
1427
+ message += `To apply your suggestions, call this tool again with the \`apply_suggestions\` parameter.`;
1428
+ return {
1429
+ content: [{
1430
+ type: 'text',
1431
+ text: message
1432
+ }]
1433
+ };
1434
+ });
1435
+ // Register list_notes_needing_enhancement tool
1436
+ server.tool('list_notes_needing_enhancement', 'List notes that are missing metadata (description or keywords). Use important_only=true to prioritize notes you have favorited, starred (⭐ in content), or rated with points.', {
1437
+ root: z.string().optional().describe('Filter to a specific root folder'),
1438
+ catalog: z.string().optional().describe('Filter to notes in a specific catalog (e.g., "Work", "Claude")'),
1439
+ limit: z.number().optional().describe('Maximum number of notes to return (default: 20, max: 50)'),
1440
+ important_only: z.boolean().optional().describe('Only show notes that are favorited, have ⭐ in content, or have points > 0')
1441
+ }, async (args) => {
1442
+ const { root, catalog, limit = 20, important_only = false } = args;
1443
+ const effectiveLimit = Math.min(limit, 50);
1444
+ const notes = await client.getNotesNeedingEnhancement({ root, catalog, limit: effectiveLimit, importantOnly: important_only });
1445
+ if (notes.length === 0) {
1446
+ const context = important_only ? 'important ' : '';
1447
+ return {
1448
+ content: [{
1449
+ type: 'text',
1450
+ text: root
1451
+ ? `All ${context}notes in "${root}" have complete metadata! 🎉`
1452
+ : `All ${context}notes have complete metadata! 🎉`
1453
+ }]
1454
+ };
1455
+ }
1456
+ const header = important_only
1457
+ ? `**Important Notes Needing Enhancement (${notes.length}):**\n_(Favorited, starred, or rated)_\n\n`
1458
+ : `**Notes Needing Enhancement (${notes.length}):**\n\n`;
1459
+ let message = header;
1460
+ for (const note of notes) {
1461
+ const missing = [];
1462
+ if (!note.has_description)
1463
+ missing.push('description');
1464
+ if (!note.has_keywords)
1465
+ missing.push('keywords');
1466
+ // Build importance indicators
1467
+ const importance = [];
1468
+ if (note.is_favorite)
1469
+ importance.push('★ FAV');
1470
+ if (note.has_stars)
1471
+ importance.push('⭐ STARRED');
1472
+ if (note.points > 0)
1473
+ importance.push(`PTS:${note.points}`);
1474
+ message += `${note.id}. **${note.title}**\n`;
1475
+ if (importance.length > 0) {
1476
+ message += ` ${importance.join(' | ')}\n`;
1477
+ }
1478
+ message += ` Missing: ${missing.join(', ')}\n`;
1479
+ message += ` Path: ${note.file_path}\n\n`;
1480
+ }
1481
+ message += `---\n`;
1482
+ message += `Use \`enhance_note_metadata\` with a note ID to analyze and improve its metadata.`;
1483
+ return {
1484
+ content: [{
1485
+ type: 'text',
1486
+ text: message
1487
+ }]
1488
+ };
1489
+ });
1490
+ // ============================================
1491
+ // Phase 6.2: Smart Scoring & Relations Tools
1492
+ // ============================================
1493
+ /**
1494
+ * Rate document importance (0-100)
1495
+ */
1496
+ server.tool('rate_importance', 'Rate a document\'s importance score (0-100). Without score: returns note for analysis. With score: updates the importance_score.', {
1497
+ note_id: z.number().describe('The note ID to rate'),
1498
+ score: z.number().min(0).max(100).optional().describe('Importance score 0-100 (omit to get note for analysis)')
1499
+ }, async (args) => {
1500
+ const { note_id, score } = args;
1501
+ // If score provided, update it
1502
+ if (score !== undefined) {
1503
+ const updated = await client.updateImportanceScore(note_id, score);
1504
+ if (!updated) {
1505
+ return {
1506
+ content: [{ type: 'text', text: `Note ${note_id} not found or is trashed.` }],
1507
+ isError: true
1508
+ };
1509
+ }
1510
+ return {
1511
+ content: [{ type: 'text', text: `✅ Updated importance score for note ${note_id} to **${score}/100**` }]
1512
+ };
1513
+ }
1514
+ // Otherwise, return note for analysis
1515
+ const note = await client.getNoteForScoring(note_id);
1516
+ if (!note) {
1517
+ return {
1518
+ content: [{ type: 'text', text: `Note ${note_id} not found or is trashed.` }],
1519
+ isError: true
1520
+ };
1521
+ }
1522
+ const contentPreview = note.content.substring(0, 1000) + (note.content.length > 1000 ? '...' : '');
1523
+ let message = `**Rate Importance for:** ${note.title}\n\n`;
1524
+ message += `**Current Scores:**\n`;
1525
+ message += `- Importance: ${note.importance_score !== null ? `${note.importance_score}/100` : 'Not rated'}\n`;
1526
+ message += `- Quality: ${note.quality_score !== null ? `${note.quality_score}/100` : 'Not rated'}\n\n`;
1527
+ message += `**Signals:**\n`;
1528
+ message += `- Favorite: ${note.is_favorite ? 'Yes ⭐' : 'No'}\n`;
1529
+ message += `- Points: ${note.points}\n`;
1530
+ message += `- Relations: ${note.relations.length} connections\n\n`;
1531
+ message += `**Keywords:** ${note.keywords.length > 0 ? note.keywords.join(', ') : 'None'}\n\n`;
1532
+ message += `**Description:** ${note.description || 'None'}\n\n`;
1533
+ message += `**Content Preview:**\n\`\`\`\n${contentPreview}\n\`\`\`\n\n`;
1534
+ message += `---\n`;
1535
+ message += `Analyze this document and call \`rate_importance\` with a score 0-100.\n`;
1536
+ message += `Consider: unique content, topic breadth, reference frequency, practical value.`;
1537
+ return { content: [{ type: 'text', text: message }] };
1538
+ });
1539
+ /**
1540
+ * Rate document quality (0-100)
1541
+ */
1542
+ server.tool('rate_quality', 'Rate a document\'s quality score (0-100). Without score: returns note for analysis. With score: updates the quality_score.', {
1543
+ note_id: z.number().describe('The note ID to rate'),
1544
+ score: z.number().min(0).max(100).optional().describe('Quality score 0-100 (omit to get note for analysis)')
1545
+ }, async (args) => {
1546
+ const { note_id, score } = args;
1547
+ // If score provided, update it
1548
+ if (score !== undefined) {
1549
+ const updated = await client.updateQualityScore(note_id, score);
1550
+ if (!updated) {
1551
+ return {
1552
+ content: [{ type: 'text', text: `Note ${note_id} not found or is trashed.` }],
1553
+ isError: true
1554
+ };
1555
+ }
1556
+ return {
1557
+ content: [{ type: 'text', text: `✅ Updated quality score for note ${note_id} to **${score}/100**` }]
1558
+ };
1559
+ }
1560
+ // Otherwise, return note for analysis
1561
+ const note = await client.getNoteForScoring(note_id);
1562
+ if (!note) {
1563
+ return {
1564
+ content: [{ type: 'text', text: `Note ${note_id} not found or is trashed.` }],
1565
+ isError: true
1566
+ };
1567
+ }
1568
+ const contentPreview = note.content.substring(0, 1000) + (note.content.length > 1000 ? '...' : '');
1569
+ let message = `**Rate Quality for:** ${note.title}\n\n`;
1570
+ message += `**Current Scores:**\n`;
1571
+ message += `- Importance: ${note.importance_score !== null ? `${note.importance_score}/100` : 'Not rated'}\n`;
1572
+ message += `- Quality: ${note.quality_score !== null ? `${note.quality_score}/100` : 'Not rated'}\n\n`;
1573
+ message += `**Metadata Completeness:**\n`;
1574
+ message += `- Has description: ${note.description ? 'Yes ✅' : 'No ❌'}\n`;
1575
+ message += `- Has keywords: ${note.keywords.length > 0 ? `Yes (${note.keywords.length}) ✅` : 'No ❌'}\n`;
1576
+ message += `- Has relations: ${note.relations.length > 0 ? `Yes (${note.relations.length}) ✅` : 'No ❌'}\n\n`;
1577
+ message += `**Content Preview:**\n\`\`\`\n${contentPreview}\n\`\`\`\n\n`;
1578
+ message += `---\n`;
1579
+ message += `Analyze this document and call \`rate_quality\` with a score 0-100.\n`;
1580
+ message += `Consider: structure, completeness, clarity, metadata quality, up-to-date content.`;
1581
+ return { content: [{ type: 'text', text: message }] };
1582
+ });
1583
+ /**
1584
+ * Update document relations (bidirectional)
1585
+ */
1586
+ server.tool('update_relations', 'Update document relations. Without relations: returns note + other notes for analysis. With relations: updates and creates inverse relations.', {
1587
+ note_id: z.number().describe('The note ID to update relations for'),
1588
+ relations: z.array(z.object({
1589
+ type: z.enum(['references', 'implements', 'extends', 'supersedes']).describe('Relation type'),
1590
+ target_id: z.number().describe('Target note ID'),
1591
+ context: z.string().optional().describe('Optional context explaining the relation')
1592
+ })).optional().describe('Relations to set (omit to get notes for analysis)')
1593
+ }, async (args) => {
1594
+ const { note_id, relations } = args;
1595
+ // If relations provided, update them
1596
+ if (relations !== undefined) {
1597
+ const result = await client.updateRelations(note_id, relations);
1598
+ let message = `✅ Updated relations for note ${note_id}\n\n`;
1599
+ message += `- Relations set: ${relations.length}\n`;
1600
+ message += `- Inverse relations created: ${result.inversesCreated}\n\n`;
1601
+ if (relations.length > 0) {
1602
+ message += `**Relations:**\n`;
1603
+ for (const rel of relations) {
1604
+ message += `- ${rel.type} → note ${rel.target_id}`;
1605
+ if (rel.context)
1606
+ message += ` (${rel.context})`;
1607
+ message += `\n`;
1608
+ }
1609
+ }
1610
+ return { content: [{ type: 'text', text: message }] };
1611
+ }
1612
+ // Otherwise, return note + other notes for analysis
1613
+ const note = await client.getNoteForScoring(note_id);
1614
+ if (!note) {
1615
+ return {
1616
+ content: [{ type: 'text', text: `Note ${note_id} not found or is trashed.` }],
1617
+ isError: true
1618
+ };
1619
+ }
1620
+ const otherNotes = await client.getNotesForRelationAnalysis(note_id, { limit: 30 });
1621
+ let message = `**Find Relations for:** ${note.title} (ID: ${note.id})\n\n`;
1622
+ message += `**Description:** ${note.description || 'None'}\n`;
1623
+ message += `**Keywords:** ${note.keywords.length > 0 ? note.keywords.join(', ') : 'None'}\n\n`;
1624
+ if (note.relations.length > 0) {
1625
+ message += `**Current Relations:**\n`;
1626
+ for (const rel of note.relations) {
1627
+ message += `- ${rel.type} → note ${rel.target_id}`;
1628
+ if (rel.context)
1629
+ message += ` (${rel.context})`;
1630
+ message += `\n`;
1631
+ }
1632
+ message += `\n`;
1633
+ }
1634
+ // Display related codebases (resolved from the managed registry)
1635
+ const relatedCodebases = note.related_codebases;
1636
+ if (relatedCodebases && Array.isArray(relatedCodebases) && relatedCodebases.length > 0) {
1637
+ message += `**Related Codebase:**\n`;
1638
+ for (const c of relatedCodebases) {
1639
+ const label = c?.label ? `${c.label} · ` : '';
1640
+ message += `- ${label}${c?.path ?? ''}\n`;
1641
+ }
1642
+ message += `\n`;
1643
+ }
1644
+ message += `**Other Notes to Consider:**\n\n`;
1645
+ for (const other of otherNotes) {
1646
+ message += `**${other.id}.** ${other.title}\n`;
1647
+ if (other.description) {
1648
+ message += ` ${other.description.substring(0, 100)}${other.description.length > 100 ? '...' : ''}\n`;
1649
+ }
1650
+ if (other.keywords.length > 0) {
1651
+ message += ` Keywords: ${other.keywords.slice(0, 5).join(', ')}\n`;
1652
+ }
1653
+ message += `\n`;
1654
+ }
1655
+ message += `---\n`;
1656
+ message += `**Relation Types:**\n`;
1657
+ message += `- \`references\`: This doc mentions/links to another\n`;
1658
+ message += `- \`implements\`: This doc implements a plan/design from another\n`;
1659
+ message += `- \`extends\`: This doc builds on/extends another\n`;
1660
+ message += `- \`supersedes\`: This doc replaces/obsoletes another\n\n`;
1661
+ message += `Call \`update_relations\` with detected relations.`;
1662
+ return { content: [{ type: 'text', text: message }] };
1663
+ });
1664
+ // Register get_relation_graph tool
1665
+ server.tool('get_relation_graph', 'Traverse the relation graph from a starting note, following relation links up to N hops deep. Returns all reachable notes with their depth and the relation that led to them.', {
1666
+ note_id: z.number().describe('Starting note ID'),
1667
+ depth: z.number().min(1).max(4).optional().describe('Maximum traversal depth (default: 2, max: 4)')
1668
+ }, async (args) => {
1669
+ const { note_id, depth = 2 } = args;
1670
+ const results = await client.getRelationGraph(note_id, Math.min(Math.max(depth, 1), 4));
1671
+ if (results.length === 0) {
1672
+ return {
1673
+ content: [{
1674
+ type: 'text',
1675
+ text: `No connected notes found within ${depth} hops of note ${note_id}.`
1676
+ }]
1677
+ };
1678
+ }
1679
+ // Group by depth for display
1680
+ const byDepth = new Map();
1681
+ for (const n of results) {
1682
+ const d = n.depth;
1683
+ if (!byDepth.has(d))
1684
+ byDepth.set(d, []);
1685
+ byDepth.get(d).push(n);
1686
+ }
1687
+ let output = `Relation graph from note ${note_id} (${results.length} connected note${results.length === 1 ? '' : 's'}, max depth ${depth}):\n`;
1688
+ for (const [d, notes] of [...byDepth.entries()].sort((a, b) => a[0] - b[0])) {
1689
+ output += `\n--- Depth ${d} ---\n`;
1690
+ for (const n of notes) {
1691
+ const desc = n.description ? `\n ${n.description.substring(0, 120)}${n.description.length > 120 ? '...' : ''}` : '';
1692
+ output += `- **${n.title}** [ID: ${n.id}] via ${n.relation_type} from note ${n.from_id}\n ${n.file_path}${desc}\n`;
1693
+ }
1694
+ }
1695
+ return {
1696
+ content: [{
1697
+ type: 'text',
1698
+ text: output
1699
+ }]
1700
+ };
1701
+ });
1702
+ /**
1703
+ * Move / re-path a note, preserving all metadata
1704
+ */
1705
+ server.tool('move_note', 'Move a note to a new file path, preserving all metadata (favorites, points, scores, relations, embeddings). Optionally moves the physical file on disk.', {
1706
+ note_id: z.number().describe('The ID of the note to move'),
1707
+ new_path: z.string().describe('New absolute file path for the note'),
1708
+ new_root_name: z.string().optional().describe('Target root name; auto-detected from path if omitted'),
1709
+ move_file: z.boolean().optional().describe('Physically move the file on disk (default: false)')
1710
+ }, async (args) => {
1711
+ const { note_id, new_path, new_root_name, move_file = false } = args;
1712
+ // 1. Fetch current note — save old path for display and sync state
1713
+ const currentNote = await client.getNote(note_id);
1714
+ if (!currentNote) {
1715
+ return {
1716
+ content: [{ type: 'text', text: `Note ${note_id} not found or is trashed.` }],
1717
+ isError: true
1718
+ };
1719
+ }
1720
+ const oldPath = currentNote.file_path;
1721
+ const oldRelativePath = currentNote.relative_path || '';
1722
+ // Resolve old root name for display and potential revert
1723
+ let oldRootName;
1724
+ if (currentNote.root_id) {
1725
+ const roots = await client.getRoots();
1726
+ const oldRoot = roots.find(r => r.id === currentNote.root_id);
1727
+ oldRootName = oldRoot?.name;
1728
+ }
1729
+ // 2. Update DB first — if this fails, no filesystem changes were made
1730
+ const moveResult = await client.moveNote(note_id, new_path, { newRootName: new_root_name });
1731
+ if (!moveResult) {
1732
+ return {
1733
+ content: [{ type: 'text', text: `Failed to move note ${note_id}. The target path may conflict with an existing note, or the path is not within any watched root directory.` }],
1734
+ isError: true
1735
+ };
1736
+ }
1737
+ // 3. If move_file, physically move the file on disk
1738
+ if (move_file) {
1739
+ const normalizedOldPath = normalizePath(oldPath);
1740
+ const normalizedNewPath = normalizePath(new_path);
1741
+ try {
1742
+ // Create destination directory
1743
+ fs.mkdirSync(path.dirname(normalizedNewPath), { recursive: true });
1744
+ // Move file with cross-drive fallback
1745
+ try {
1746
+ fs.renameSync(normalizedOldPath, normalizedNewPath);
1747
+ }
1748
+ catch (err) {
1749
+ if (err.code === 'EXDEV') {
1750
+ fs.copyFileSync(normalizedOldPath, normalizedNewPath);
1751
+ fs.unlinkSync(normalizedOldPath);
1752
+ }
1753
+ else {
1754
+ throw err;
1755
+ }
1756
+ }
1757
+ }
1758
+ catch (fileErr) {
1759
+ // Revert DB change
1760
+ await client.moveNote(note_id, oldPath, { newRootName: oldRootName });
1761
+ return {
1762
+ content: [{ type: 'text', text: `Failed to move file on disk: ${fileErr.message}\nDatabase change has been reverted.` }],
1763
+ isError: true
1764
+ };
1765
+ }
1766
+ }
1767
+ // 4. Update sync state if applicable
1768
+ try {
1769
+ const roots = await client.getRootsForSync();
1770
+ // Remove baseline from old root
1771
+ const oldRoot = roots.find(r => {
1772
+ return normalizePath(oldPath).startsWith(normalizePath(r.path));
1773
+ });
1774
+ if (oldRoot && oldRelativePath) {
1775
+ const oldMgr = new SyncStateManager(oldRoot.path);
1776
+ oldMgr.load();
1777
+ oldMgr.removeBaseline(oldRelativePath);
1778
+ oldMgr.save();
1779
+ }
1780
+ // Set baseline in new root
1781
+ const newRoot = roots.find(r => r.name === moveResult.root_name);
1782
+ if (newRoot && moveResult.relative_path) {
1783
+ const newMgr = new SyncStateManager(newRoot.path);
1784
+ newMgr.load();
1785
+ // Compute hash from file content if available
1786
+ const filePath = normalizePath(new_path);
1787
+ if (fs.existsSync(filePath)) {
1788
+ const content = fs.readFileSync(filePath, 'utf-8');
1789
+ const hash = NoesisClient.computeHash(content);
1790
+ newMgr.setBaseline(moveResult.relative_path, hash);
1791
+ }
1792
+ newMgr.save();
1793
+ }
1794
+ }
1795
+ catch {
1796
+ // Sync state update is best-effort; move already succeeded
1797
+ }
1798
+ // 5. Format response
1799
+ let message = `Moved note ${note_id}:\n\n`;
1800
+ message += `**From:** ${oldPath}\n`;
1801
+ message += `**To:** ${moveResult.file_path}\n`;
1802
+ if (oldRootName && oldRootName !== moveResult.root_name) {
1803
+ message += `**Root:** ${oldRootName} → ${moveResult.root_name}\n`;
1804
+ }
1805
+ if (move_file) {
1806
+ message += `**File:** physically moved on disk\n`;
1807
+ }
1808
+ message += `\nAll metadata preserved (favorites, points, scores, relations, embeddings).`;
1809
+ return { content: [{ type: 'text', text: message }] };
1810
+ });
1811
+ /**
1812
+ * Update note signals (favorite, points)
1813
+ */
1814
+ server.tool('update_signals', 'Update a note\'s favorite status and/or points. Use to mark notes as favorite or adjust their point value (0-150).', {
1815
+ note_id: z.number().describe('The note ID to update'),
1816
+ is_favorite: z.boolean().optional().describe('Set favorite status'),
1817
+ points: z.number().min(0).max(150).optional().describe('Set points value (0-150)')
1818
+ }, async (args) => {
1819
+ const { note_id, is_favorite, points } = args;
1820
+ if (is_favorite === undefined && points === undefined) {
1821
+ return {
1822
+ content: [{ type: 'text', text: 'At least one of is_favorite or points is required.' }],
1823
+ isError: true
1824
+ };
1825
+ }
1826
+ const success = await client.updateNoteSignals(note_id, { is_favorite, points });
1827
+ if (!success) {
1828
+ return {
1829
+ content: [{ type: 'text', text: `Failed to update signals for note ${note_id}. Note may not exist.` }],
1830
+ isError: true
1831
+ };
1832
+ }
1833
+ const parts = [];
1834
+ if (is_favorite !== undefined)
1835
+ parts.push(`favorite: ${is_favorite ? 'Yes ⭐' : 'No'}`);
1836
+ if (points !== undefined)
1837
+ parts.push(`points: ${points}`);
1838
+ return {
1839
+ content: [{ type: 'text', text: `Updated note ${note_id}: ${parts.join(', ')}` }]
1840
+ };
1841
+ });
1842
+ /**
1843
+ * Trash a note (soft-delete)
1844
+ */
1845
+ server.tool('trash_note', 'Soft-delete a note by marking it as trashed. The note is hidden from searches but can be recovered. Use for orphaned or duplicate records.', {
1846
+ note_id: z.number().describe('The note ID to trash')
1847
+ }, async (args) => {
1848
+ const { note_id } = args;
1849
+ // Fetch note info for confirmation message
1850
+ const note = await client.getNote(note_id);
1851
+ if (!note) {
1852
+ return {
1853
+ content: [{ type: 'text', text: `Note ${note_id} not found.` }],
1854
+ isError: true
1855
+ };
1856
+ }
1857
+ const success = await client.trashNote(note_id);
1858
+ if (!success) {
1859
+ return {
1860
+ content: [{ type: 'text', text: `Failed to trash note ${note_id}.` }],
1861
+ isError: true
1862
+ };
1863
+ }
1864
+ return {
1865
+ content: [{ type: 'text', text: `Trashed note ${note_id}: "${note.title}"\nPath was: ${note.file_path}` }]
1866
+ };
1867
+ });
1868
+ // Register set_note_catalogs tool
1869
+ server.tool('set_note_catalogs', 'Set the catalogs (categories) for a note. Replaces all existing catalog assignments.', {
1870
+ note_id: z.number().describe('The note ID'),
1871
+ catalogs: z.array(z.string()).describe('Array of catalog names to assign (e.g., ["Work", "Claude"])')
1872
+ }, async (args) => {
1873
+ const { note_id, catalogs } = args;
1874
+ const note = await client.getNote(note_id);
1875
+ if (!note) {
1876
+ return {
1877
+ content: [{ type: 'text', text: `Note ${note_id} not found.` }],
1878
+ isError: true
1879
+ };
1880
+ }
1881
+ await client.setNoteCatalogs(note_id, catalogs);
1882
+ const catalogList = catalogs.length > 0 ? catalogs.join(', ') : '(none)';
1883
+ return {
1884
+ content: [{ type: 'text', text: `Updated catalogs for note ${note_id} ("${note.title}"): ${catalogList}` }]
1885
+ };
1886
+ });
1887
+ // Register set_note_related_codes tool
1888
+ server.tool('set_note_related_codes', 'Link a note to managed codebases by ID. Replaces the note\'s full reference list. Prefer codebase_ids (use list_codebases / find_or_create_codebase to obtain them). The legacy related_codes:string[] shape is also accepted: each path is resolved via find-or-create on the server.', {
1889
+ note_id: z.number().describe('The note ID'),
1890
+ codebase_ids: z.array(z.number()).optional().describe('Primary: array of codebase IDs from the registry'),
1891
+ related_codes: z.array(z.string()).optional().describe('Legacy fallback: raw codebase paths. The server auto-creates a codebase row for each new path.'),
1892
+ }, async (args) => {
1893
+ const { note_id, codebase_ids, related_codes } = args;
1894
+ if (!codebase_ids && !related_codes) {
1895
+ return {
1896
+ content: [{ type: 'text', text: 'Provide either codebase_ids or related_codes.' }],
1897
+ isError: true,
1898
+ };
1899
+ }
1900
+ const note = await client.getNote(note_id);
1901
+ if (!note) {
1902
+ return {
1903
+ content: [{ type: 'text', text: `Note ${note_id} not found.` }],
1904
+ isError: true,
1905
+ };
1906
+ }
1907
+ const payload = codebase_ids ?? related_codes;
1908
+ await client.setNoteRelatedCodes(note_id, payload);
1909
+ const summary = Array.isArray(payload) && payload.length > 0
1910
+ ? payload.map((p) => String(p)).join(', ')
1911
+ : '(none)';
1912
+ return {
1913
+ content: [{ type: 'text', text: `Updated related codebases for note ${note_id} ("${note.title}"): ${summary}` }],
1914
+ };
1915
+ });
1916
+ // ============================================
1917
+ // CODEBASES (managed registry)
1918
+ // ============================================
1919
+ const fmtCodebase = (c) => {
1920
+ const head = c.label ? `${c.label} (#${c.id})` : `#${c.id}`;
1921
+ const tail = [c.path, c.branch && `branch=${c.branch}`, c.repo_url, c.is_archived && '(archived)']
1922
+ .filter(Boolean).join(' · ');
1923
+ return `${head} — ${tail}`;
1924
+ };
1925
+ server.tool('list_codebases', 'List the user\'s managed codebases (the registry that backs each note\'s related codes). By default excludes archived rows.', {
1926
+ include_archived: z.boolean().optional().describe('Set true to include archived codebases (default false).'),
1927
+ }, async ({ include_archived }) => {
1928
+ const rows = await client.listCodebases(!!include_archived);
1929
+ if (rows.length === 0) {
1930
+ return { content: [{ type: 'text', text: 'No codebases registered.' }] };
1931
+ }
1932
+ const text = rows.map(fmtCodebase).join('\n');
1933
+ return { content: [{ type: 'text', text }] };
1934
+ });
1935
+ server.tool('get_codebase', 'Fetch a single codebase by ID, including label / branch / description / repo_url.', { id: z.number().describe('Codebase ID') }, async ({ id }) => {
1936
+ const c = await client.getCodebase(id);
1937
+ const lines = [
1938
+ `ID: ${c.id}`,
1939
+ `Path: ${c.path}`,
1940
+ c.label && `Label: ${c.label}`,
1941
+ c.branch && `Branch: ${c.branch}`,
1942
+ c.description && `Description: ${c.description}`,
1943
+ c.repo_url && `Repo URL: ${c.repo_url}`,
1944
+ c.is_archived && `Status: archived`,
1945
+ ].filter(Boolean).join('\n');
1946
+ return { content: [{ type: 'text', text: lines }] };
1947
+ });
1948
+ server.tool('create_codebase', 'Create a new managed codebase entry. The path is the canonical disk location; label / branch / description / repo_url are optional metadata.', {
1949
+ path: z.string().describe('Filesystem path of the codebase (e.g., "C:/temp_cGit/my-repo"). Backslashes are auto-converted to forward slashes.'),
1950
+ label: z.string().optional().describe('Human-readable name (e.g., "Cloud Connector Service").'),
1951
+ branch: z.string().optional().describe('Active branch or stream name.'),
1952
+ description: z.string().optional().describe('Free-text description of the codebase.'),
1953
+ repo_url: z.string().optional().describe('Optional remote URL (GitHub, etc.).'),
1954
+ }, async (input) => {
1955
+ const c = await client.createCodebase(input);
1956
+ return { content: [{ type: 'text', text: `Created codebase: ${fmtCodebase(c)}` }] };
1957
+ });
1958
+ server.tool('find_or_create_codebase', 'Return the codebase row matching this path (case-insensitive), creating one if it does not exist. Use this in skills that discover codebase paths from note content and need to convert them into codebase IDs for set_note_related_codes.', {
1959
+ path: z.string().describe('Filesystem path of the codebase. Backslashes are normalized to forward slashes; case is preserved on first create.'),
1960
+ label: z.string().optional().describe('Used only when the row is newly created.'),
1961
+ }, async (input) => {
1962
+ const c = await client.findOrCreateCodebase(input);
1963
+ return { content: [{ type: 'text', text: `Resolved codebase: ${fmtCodebase(c)}` }] };
1964
+ });
1965
+ server.tool('update_codebase', 'Update fields on a managed codebase. Pass only the fields you want to change. Pass is_archived=true to archive (hides from the picker without breaking existing note references).', {
1966
+ id: z.number().describe('Codebase ID'),
1967
+ path: z.string().optional(),
1968
+ label: z.string().nullable().optional(),
1969
+ branch: z.string().nullable().optional(),
1970
+ description: z.string().nullable().optional(),
1971
+ repo_url: z.string().nullable().optional(),
1972
+ is_archived: z.boolean().optional(),
1973
+ }, async ({ id, ...patch }) => {
1974
+ const c = await client.updateCodebase(id, patch);
1975
+ return { content: [{ type: 'text', text: `Updated codebase: ${fmtCodebase(c)}` }] };
1976
+ });
1977
+ server.tool('delete_codebase', 'Permanently delete a codebase AND remove its reference from every note that links to it. Destructive. Prefer update_codebase with is_archived=true unless you really want it gone.', { id: z.number().describe('Codebase ID') }, async ({ id }) => {
1978
+ const r = await client.deleteCodebase(id);
1979
+ return { content: [{ type: 'text', text: `Deleted codebase ${id}; unlinked from ${r.unlinked_from_note_count} note(s).` }] };
1980
+ });
1981
+ /**
1982
+ * Analyze knowledge base health
1983
+ */
1984
+ server.tool('analyze_knowledge_base', 'Analyze knowledge base health. Returns stats, low-quality docs, orphans, and recommendations.', {
1985
+ root: z.string().optional().describe('Filter to specific root folder'),
1986
+ limit: z.number().min(1).max(50).optional().describe('Max docs to show per category (default: 10)')
1987
+ }, async (args) => {
1988
+ const { root, limit } = args;
1989
+ const stats = await client.getKnowledgeBaseStats({ root, limit: limit || 10 });
1990
+ let message = `# Knowledge Base Health Report\n\n`;
1991
+ // Overall stats
1992
+ message += `## Overview\n\n`;
1993
+ message += `| Metric | Count | % |\n`;
1994
+ message += `|--------|-------|---|\n`;
1995
+ message += `| Total Notes | ${stats.total} | 100% |\n`;
1996
+ message += `| With Importance Score | ${stats.withImportanceScore} | ${Math.round(stats.withImportanceScore / stats.total * 100)}% |\n`;
1997
+ message += `| With Quality Score | ${stats.withQualityScore} | ${Math.round(stats.withQualityScore / stats.total * 100)}% |\n`;
1998
+ message += `| With Relations | ${stats.withRelations} | ${Math.round(stats.withRelations / stats.total * 100)}% |\n`;
1999
+ message += `| With Description | ${stats.withDescription} | ${Math.round(stats.withDescription / stats.total * 100)}% |\n`;
2000
+ message += `| With Keywords | ${stats.withKeywords} | ${Math.round(stats.withKeywords / stats.total * 100)}% |\n\n`;
2001
+ // Low quality docs
2002
+ if (stats.lowQuality.length > 0) {
2003
+ message += `## Low Quality Documents (score < 50)\n\n`;
2004
+ for (const doc of stats.lowQuality.slice(0, 5)) {
2005
+ message += `- **${doc.id}.** ${doc.title} (quality: ${doc.quality_score ?? 'unrated'})\n`;
2006
+ }
2007
+ if (stats.lowQuality.length > 5) {
2008
+ message += `- ... and ${stats.lowQuality.length - 5} more\n`;
2009
+ }
2010
+ message += `\n`;
2011
+ }
2012
+ // Low importance docs
2013
+ if (stats.lowImportance.length > 0) {
2014
+ message += `## Low Importance Documents (score < 30)\n\n`;
2015
+ for (const doc of stats.lowImportance.slice(0, 5)) {
2016
+ message += `- **${doc.id}.** ${doc.title} (importance: ${doc.importance_score ?? 'unrated'})\n`;
2017
+ }
2018
+ if (stats.lowImportance.length > 5) {
2019
+ message += `- ... and ${stats.lowImportance.length - 5} more\n`;
2020
+ }
2021
+ message += `\n`;
2022
+ }
2023
+ // Orphan docs
2024
+ if (stats.orphans.length > 0) {
2025
+ message += `## Orphan Documents (no relations)\n\n`;
2026
+ for (const doc of stats.orphans.slice(0, 5)) {
2027
+ message += `- **${doc.id}.** ${doc.title}\n`;
2028
+ }
2029
+ if (stats.orphans.length > 5) {
2030
+ message += `- ... and ${stats.orphans.length - 5} more\n`;
2031
+ }
2032
+ message += `\n`;
2033
+ }
2034
+ // Missing metadata
2035
+ if (stats.missingMetadata.length > 0) {
2036
+ message += `## Missing Metadata\n\n`;
2037
+ for (const doc of stats.missingMetadata.slice(0, 5)) {
2038
+ message += `- **${doc.id}.** ${doc.title} (missing: ${doc.missing.join(', ')})\n`;
2039
+ }
2040
+ if (stats.missingMetadata.length > 5) {
2041
+ message += `- ... and ${stats.missingMetadata.length - 5} more\n`;
2042
+ }
2043
+ message += `\n`;
2044
+ }
2045
+ // Recommendations
2046
+ message += `## Recommendations\n\n`;
2047
+ const unscoredImportance = stats.total - stats.withImportanceScore;
2048
+ const unscoredQuality = stats.total - stats.withQualityScore;
2049
+ if (unscoredImportance > 0) {
2050
+ message += `- 📊 ${unscoredImportance} notes need importance scoring. Use \`rate_importance\` to score them.\n`;
2051
+ }
2052
+ if (unscoredQuality > 0) {
2053
+ message += `- 📊 ${unscoredQuality} notes need quality scoring. Use \`rate_quality\` to score them.\n`;
2054
+ }
2055
+ if (stats.orphans.length > 0) {
2056
+ message += `- 🔗 ${stats.orphans.length} orphan notes found. Use \`update_relations\` to connect them.\n`;
2057
+ }
2058
+ if (stats.missingMetadata.length > 0) {
2059
+ message += `- 📝 ${stats.missingMetadata.length} notes missing metadata. Use \`enhance_note_metadata\` to improve them.\n`;
2060
+ }
2061
+ if (unscoredImportance === 0 && unscoredQuality === 0 && stats.orphans.length === 0 && stats.missingMetadata.length === 0) {
2062
+ message += `✅ Knowledge base is in great shape!\n`;
2063
+ }
2064
+ return { content: [{ type: 'text', text: message }] };
2065
+ });
2066
+ // ============================================
2067
+ // Phase 6.3: Semantic Search with Embeddings
2068
+ // ============================================
2069
+ /**
2070
+ * Generate embeddings for notes without them
2071
+ */
2072
+ server.tool('generate_embeddings', 'Generate embeddings for notes that don\'t have them. Uses Gemini API to create 768-dim vectors for semantic search. Processes in batches with progress reporting.', {
2073
+ batch_size: z.number().min(1).max(50).optional().describe('Number of notes to process (default: 10, max: 50)'),
2074
+ root: z.string().optional().describe('Filter to specific root folder')
2075
+ }, async (args) => {
2076
+ const { batch_size = 10, root } = args;
2077
+ if (!embeddingsEnabled) {
2078
+ return {
2079
+ content: [{
2080
+ type: 'text',
2081
+ text: '❌ Embedding service not available. GEMINI_API_KEY may not be configured.'
2082
+ }],
2083
+ isError: true
2084
+ };
2085
+ }
2086
+ // Get notes without embeddings
2087
+ const notes = await client.getNotesWithoutEmbeddings({ limit: batch_size, root });
2088
+ if (notes.length === 0) {
2089
+ const context = root ? ` in "${root}"` : '';
2090
+ return {
2091
+ content: [{
2092
+ type: 'text',
2093
+ text: `✅ All notes${context} already have embeddings!`
2094
+ }]
2095
+ };
2096
+ }
2097
+ let message = `**Generating embeddings for ${notes.length} notes...**\n\n`;
2098
+ // Prepare texts for batch processing
2099
+ const texts = notes.map(note => ({
2100
+ id: note.id,
2101
+ text: `${note.title}\n\n${note.content}`.trim()
2102
+ }));
2103
+ const results = await generateEmbeddingsBatch(texts);
2104
+ // Store embeddings in database
2105
+ let success = 0;
2106
+ let failed = 0;
2107
+ const details = [];
2108
+ for (const result of results) {
2109
+ try {
2110
+ const updated = await client.updateNoteEmbedding(result.id, result.embedding);
2111
+ if (updated) {
2112
+ success++;
2113
+ const note = notes.find(n => n.id === result.id);
2114
+ details.push(`✅ ${result.id}: ${note?.title || 'Unknown'}`);
2115
+ }
2116
+ else {
2117
+ failed++;
2118
+ details.push(`❌ ${result.id}: Failed to update`);
2119
+ }
2120
+ }
2121
+ catch (error) {
2122
+ failed++;
2123
+ details.push(`❌ ${result.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
2124
+ }
2125
+ }
2126
+ // Check how many notes still need embeddings
2127
+ const stats = await client.getEmbeddingStats();
2128
+ message += `**Results:**\n`;
2129
+ message += `- ✅ Generated: ${success}\n`;
2130
+ if (failed > 0) {
2131
+ message += `- ❌ Failed: ${failed}\n`;
2132
+ }
2133
+ message += `\n**Progress:**\n`;
2134
+ for (const detail of details.slice(0, 10)) {
2135
+ message += `${detail}\n`;
2136
+ }
2137
+ if (details.length > 10) {
2138
+ message += `... and ${details.length - 10} more\n`;
2139
+ }
2140
+ message += `\n**Overall Stats:**\n`;
2141
+ message += `- Total notes: ${stats.total}\n`;
2142
+ message += `- With embeddings: ${stats.withEmbeddings}\n`;
2143
+ message += `- Without embeddings: ${stats.withoutEmbeddings}\n`;
2144
+ if (stats.withoutEmbeddings > 0) {
2145
+ message += `\n_Run again to process more notes._`;
2146
+ }
2147
+ return { content: [{ type: 'text', text: message }] };
2148
+ });
2149
+ /**
2150
+ * Semantic search using vector similarity
2151
+ */
2152
+ server.tool('search_semantic', 'Search notes using semantic similarity. Converts your query to an embedding and finds notes with similar meaning, not just matching keywords.', {
2153
+ query: z.string().describe('Natural language search query'),
2154
+ limit: z.number().min(1).max(20).optional().describe('Maximum results (default: 10, max: 20)'),
2155
+ root: z.string().optional().describe('Filter to specific root folder')
2156
+ }, async (args) => {
2157
+ const { query, limit = 10, root } = args;
2158
+ if (!embeddingsEnabled) {
2159
+ return {
2160
+ content: [{
2161
+ type: 'text',
2162
+ text: '❌ Semantic search not available. GEMINI_API_KEY may not be configured.\n\nUse `search_notes` for keyword-based search instead.'
2163
+ }],
2164
+ isError: true
2165
+ };
2166
+ }
2167
+ try {
2168
+ // Generate embedding for query
2169
+ const queryEmbedding = await generateEmbedding(query);
2170
+ // Search by similarity
2171
+ const results = await client.searchByEmbedding(queryEmbedding, { limit, root });
2172
+ if (results.length === 0) {
2173
+ return {
2174
+ content: [{
2175
+ type: 'text',
2176
+ text: `No semantically similar notes found for "${query}".\n\nTry:\n- Rephrasing your query\n- Using more descriptive terms\n- Checking if notes have embeddings (use \`generate_embeddings\` first)`
2177
+ }]
2178
+ };
2179
+ }
2180
+ let message = `**Semantic Search Results for:** "${query}"\n\n`;
2181
+ for (let i = 0; i < results.length; i++) {
2182
+ const note = results[i];
2183
+ const similarityPct = Math.round(note.similarity * 100);
2184
+ const icon = similarityPct >= 80 ? '🎯' :
2185
+ similarityPct >= 60 ? '✓' :
2186
+ similarityPct >= 40 ? '○' : '·';
2187
+ message += `${i + 1}. ${icon} **${note.title}** [ID: ${note.id}] (${similarityPct}% similar)\n`;
2188
+ message += ` Path: ${note.file_path}\n`;
2189
+ if (note.description) {
2190
+ const desc = note.description.length > 100
2191
+ ? note.description.substring(0, 100) + '...'
2192
+ : note.description;
2193
+ message += ` ${desc}\n`;
2194
+ }
2195
+ message += `\n`;
2196
+ }
2197
+ return { content: [{ type: 'text', text: message }] };
2198
+ }
2199
+ catch (error) {
2200
+ return {
2201
+ content: [{
2202
+ type: 'text',
2203
+ text: `Error performing semantic search: ${error instanceof Error ? error.message : 'Unknown error'}`
2204
+ }],
2205
+ isError: true
2206
+ };
2207
+ }
2208
+ });
2209
+ /**
2210
+ * Find notes similar to a given note
2211
+ */
2212
+ server.tool('find_similar_notes', 'Find notes that are semantically similar to a given note. Useful for discovering related content and potential document relationships.', {
2213
+ note_id: z.number().describe('The ID of the note to find similar notes for'),
2214
+ limit: z.number().min(1).max(20).optional().describe('Maximum results (default: 5, max: 20)')
2215
+ }, async (args) => {
2216
+ const { note_id, limit = 5 } = args;
2217
+ if (!embeddingsEnabled) {
2218
+ return {
2219
+ content: [{
2220
+ type: 'text',
2221
+ text: '❌ Similarity search not available. GEMINI_API_KEY may not be configured.'
2222
+ }],
2223
+ isError: true
2224
+ };
2225
+ }
2226
+ try {
2227
+ // Get source note info first
2228
+ const sourceNote = await client.getNote(note_id);
2229
+ if (!sourceNote) {
2230
+ return {
2231
+ content: [{
2232
+ type: 'text',
2233
+ text: `Note ${note_id} not found.`
2234
+ }],
2235
+ isError: true
2236
+ };
2237
+ }
2238
+ // Find similar notes
2239
+ const similarNotes = await client.findSimilarNotes(note_id, { limit });
2240
+ if (similarNotes.length === 0) {
2241
+ return {
2242
+ content: [{
2243
+ type: 'text',
2244
+ text: `No similar notes found for "${sourceNote.title}".\n\nThis could mean:\n- The source note doesn't have an embedding yet\n- Other notes don't have embeddings yet (use \`generate_embeddings\`)\n- This note's content is unique in your knowledge base`
2245
+ }]
2246
+ };
2247
+ }
2248
+ let message = `**Notes Similar to:** ${sourceNote.title} (ID: ${note_id})\n\n`;
2249
+ for (let i = 0; i < similarNotes.length; i++) {
2250
+ const note = similarNotes[i];
2251
+ const similarityPct = Math.round(note.similarity * 100);
2252
+ const icon = similarityPct >= 80 ? '🎯' :
2253
+ similarityPct >= 60 ? '✓' :
2254
+ similarityPct >= 40 ? '○' : '·';
2255
+ message += `${i + 1}. ${icon} **${note.title}** [ID: ${note.id}] (${similarityPct}% similar)\n`;
2256
+ message += ` Path: ${note.file_path}\n`;
2257
+ if (note.description) {
2258
+ const desc = note.description.length > 100
2259
+ ? note.description.substring(0, 100) + '...'
2260
+ : note.description;
2261
+ message += ` ${desc}\n`;
2262
+ }
2263
+ message += `\n`;
2264
+ }
2265
+ message += `---\n`;
2266
+ message += `_Tip: Use \`update_relations\` to link related documents._`;
2267
+ return { content: [{ type: 'text', text: message }] };
2268
+ }
2269
+ catch (error) {
2270
+ return {
2271
+ content: [{
2272
+ type: 'text',
2273
+ text: `Error finding similar notes: ${error instanceof Error ? error.message : 'Unknown error'}`
2274
+ }],
2275
+ isError: true
2276
+ };
2277
+ }
2278
+ });
2279
+ // ─── Daily News MCP Tools ───
2280
+ server.tool('get_news_preferences', 'Get your Daily News preferences including topic weights, RSS sources, seed URLs, language setting, and daily article limit. Use this to understand and analyze your news reading patterns.', {}, async () => {
2281
+ try {
2282
+ const data = await client.getNewsPreferences();
2283
+ const settings = data.settings || {};
2284
+ const prefs = settings.preferences || {};
2285
+ const sources = data.sources || [];
2286
+ const seeds = data.seeds || [];
2287
+ let message = `**Daily News Preferences**\n\n`;
2288
+ message += `**Language:** ${settings.preferred_language || 'en'}\n`;
2289
+ message += `**Daily article limit:** ${settings.daily_article_limit || 20}\n`;
2290
+ message += `**Last refresh:** ${settings.last_refresh_at || 'never'}\n\n`;
2291
+ if (prefs.topics && Object.keys(prefs.topics).length > 0) {
2292
+ message += `**Topic Weights:**\n`;
2293
+ for (const [topic, weight] of Object.entries(prefs.topics).sort(([, a], [, b]) => b - a)) {
2294
+ message += `- ${topic}: ${Math.round(weight * 100)}%\n`;
2295
+ }
2296
+ message += `\n`;
2297
+ }
2298
+ if (prefs.sources && Object.keys(prefs.sources).length > 0) {
2299
+ message += `**Source Weights:**\n`;
2300
+ for (const [source, weight] of Object.entries(prefs.sources).sort(([, a], [, b]) => b - a)) {
2301
+ message += `- ${source}: ${Math.round(weight * 100)}%\n`;
2302
+ }
2303
+ message += `\n`;
2304
+ }
2305
+ message += `**RSS Sources (${sources.length}):**\n`;
2306
+ for (const s of sources) {
2307
+ message += `- ${s.is_active ? '✓' : '✗'} ${s.name} (${s.topic || 'no topic'}, ${s.language}) — ${s.url}\n`;
2308
+ }
2309
+ message += `\n`;
2310
+ message += `**Seed URLs (${seeds.length}):**\n`;
2311
+ for (const s of seeds) {
2312
+ message += `- ${s.domain || s.url} → topics: ${(s.extracted_topics || []).join(', ') || 'none'}\n`;
2313
+ }
2314
+ return { content: [{ type: 'text', text: message }] };
2315
+ }
2316
+ catch (error) {
2317
+ return {
2318
+ content: [{ type: 'text', text: `Error fetching news preferences: ${error instanceof Error ? error.message : 'Unknown error'}` }],
2319
+ isError: true
2320
+ };
2321
+ }
2322
+ });
2323
+ server.tool('update_news_preferences', 'Update your Daily News preferences. Can change language, daily article limit, or the full preference profile (topic/source weights). Use after analyzing preferences to push refinements.', {
2324
+ preferred_language: z.string().optional().describe('Preferred language code (e.g., "en", "zh-TW", "ja")'),
2325
+ daily_article_limit: z.number().optional().describe('Max articles shown per day (5-100)'),
2326
+ preferences: z.object({
2327
+ topics: z.record(z.number()).optional().describe('Topic weights (0-1), e.g., {"AI": 0.9, "Science": 0.3}'),
2328
+ sources: z.record(z.number()).optional().describe('Source weights (0-1), e.g., {"bbc.com": 0.8}'),
2329
+ keywords: z.array(z.string()).optional().describe('Interest keywords'),
2330
+ }).optional().describe('Full preference profile to update'),
2331
+ }, async (args) => {
2332
+ try {
2333
+ const updateData = {};
2334
+ if (args.preferred_language || args.daily_article_limit) {
2335
+ updateData.settings = {};
2336
+ if (args.preferred_language)
2337
+ updateData.settings.preferred_language = args.preferred_language;
2338
+ if (args.daily_article_limit)
2339
+ updateData.settings.daily_article_limit = args.daily_article_limit;
2340
+ }
2341
+ if (args.preferences) {
2342
+ updateData.preferences = args.preferences;
2343
+ }
2344
+ await client.updateNewsPreferences(updateData);
2345
+ const changes = [];
2346
+ if (args.preferred_language)
2347
+ changes.push(`Language → ${args.preferred_language}`);
2348
+ if (args.daily_article_limit)
2349
+ changes.push(`Daily limit → ${args.daily_article_limit}`);
2350
+ if (args.preferences?.topics)
2351
+ changes.push(`Topics updated (${Object.keys(args.preferences.topics).length} entries)`);
2352
+ if (args.preferences?.sources)
2353
+ changes.push(`Sources updated (${Object.keys(args.preferences.sources).length} entries)`);
2354
+ if (args.preferences?.keywords)
2355
+ changes.push(`Keywords updated (${args.preferences.keywords.length} entries)`);
2356
+ return {
2357
+ content: [{ type: 'text', text: `✅ News preferences updated:\n${changes.map(c => `- ${c}`).join('\n')}` }]
2358
+ };
2359
+ }
2360
+ catch (error) {
2361
+ return {
2362
+ content: [{ type: 'text', text: `Error updating preferences: ${error instanceof Error ? error.message : 'Unknown error'}` }],
2363
+ isError: true
2364
+ };
2365
+ }
2366
+ });
2367
+ server.tool('add_news_source', 'Add an RSS/Atom feed as a news source. Use after discovering RSS feeds via web search to register them in the Daily News tool.', {
2368
+ name: z.string().describe('Display name for the source (e.g., "TechCrunch", "BBC World")'),
2369
+ url: z.string().describe('RSS or Atom feed URL'),
2370
+ topic: z.string().optional().describe('Topic category (e.g., "Technology", "World", "Science")'),
2371
+ language: z.string().optional().describe('Source language code (e.g., "en", "zh-TW", "ja")'),
2372
+ }, async (args) => {
2373
+ try {
2374
+ const domain = (() => {
2375
+ try {
2376
+ return new URL(args.url).hostname.replace(/^www\./, '');
2377
+ }
2378
+ catch {
2379
+ return undefined;
2380
+ }
2381
+ })();
2382
+ const result = await client.addNewsSource({
2383
+ name: args.name,
2384
+ url: args.url,
2385
+ domain,
2386
+ topic: args.topic,
2387
+ language: args.language || 'en',
2388
+ source_type: 'rss',
2389
+ });
2390
+ return {
2391
+ content: [{ type: 'text', text: `✅ News source added: **${args.name}**\n- URL: ${args.url}\n- Topic: ${args.topic || 'unset'}\n- Language: ${args.language || 'en'}` }]
2392
+ };
2393
+ }
2394
+ catch (error) {
2395
+ return {
2396
+ content: [{ type: 'text', text: `Error adding news source: ${error instanceof Error ? error.message : 'Unknown error'}` }],
2397
+ isError: true
2398
+ };
2399
+ }
2400
+ });
2401
+ }
2402
+ /**
2403
+ * Scan directory for .md files recursively
2404
+ * @param detectProjects - If true, detect project from .git folder for each file
2405
+ */
2406
+ function scanMarkdownFiles(rootPath, rootId, rootName, detectProjects = false) {
2407
+ const files = [];
2408
+ function scan(dir) {
2409
+ try {
2410
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
2411
+ for (const entry of entries) {
2412
+ const fullPath = path.join(dir, entry.name);
2413
+ // Skip hidden files/directories and node_modules
2414
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') {
2415
+ continue;
2416
+ }
2417
+ if (entry.isDirectory()) {
2418
+ scan(fullPath);
2419
+ }
2420
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
2421
+ try {
2422
+ const content = fs.readFileSync(fullPath, 'utf-8');
2423
+ const stats = fs.statSync(fullPath);
2424
+ const relativePath = normalizePath(path.relative(rootPath, fullPath));
2425
+ // Detect project if enabled
2426
+ const project = detectProjects ? detectProjectForFile(fullPath, rootPath) : undefined;
2427
+ files.push({
2428
+ path: normalizePath(fullPath),
2429
+ relativePath,
2430
+ content,
2431
+ hash: NoesisClient.computeHash(content),
2432
+ mtime: stats.mtime,
2433
+ size: stats.size,
2434
+ rootId,
2435
+ rootName,
2436
+ project
2437
+ });
2438
+ }
2439
+ catch (err) {
2440
+ // Skip files that can't be read
2441
+ console.error(`Error reading ${fullPath}:`, err);
2442
+ }
2443
+ }
2444
+ }
2445
+ }
2446
+ catch (err) {
2447
+ console.error(`Error scanning ${dir}:`, err);
2448
+ }
2449
+ }
2450
+ scan(rootPath);
2451
+ return files;
2452
+ }
2453
+ /**
2454
+ * Sync specific files by path (single-file or multi-file sync mode)
2455
+ * @param force - If true, bypass hash check and always re-sync (useful for metadata regeneration)
2456
+ * @param regenerateMetadata - If true, backend AI regenerates all metadata fields, overwriting existing values
2457
+ */
2458
+ async function syncSpecificFiles(filePaths, roots, dryRun, client, force = false, regenerateMetadata = false) {
2459
+ // Use bidirectional sync result type
2460
+ const result = {
2461
+ pushed: { created: 0, updated: 0 },
2462
+ pulled: { created: 0, updated: 0 },
2463
+ skipped: 0,
2464
+ conflicts: [],
2465
+ errors: [],
2466
+ details: []
2467
+ };
2468
+ const affectedRoots = new Set();
2469
+ const warnings = [];
2470
+ // Cache SyncStateManagers per root path
2471
+ const syncStateCache = new Map();
2472
+ // Cache cloud notes per root to avoid multiple API calls
2473
+ const cloudNotesCache = new Map();
2474
+ for (const filePath of filePaths) {
2475
+ // Normalize path — with auto-correction for doubled directory segments.
2476
+ // expandHome handles `~/...`, `~\...`, and `%USERPROFILE%\...` (the Windows
2477
+ // display form clients copy from the Noesis frontend) before path.resolve,
2478
+ // which itself doesn't expand `~` or env vars.
2479
+ let normalizedPath = normalizePath(path.resolve(expandHome(filePath)));
2480
+ // Check if resolved path falls inside any known root
2481
+ const inKnownRoot = roots.some(r => normalizedPath.startsWith(normalizePath(path.resolve(r.path))));
2482
+ if (!inKnownRoot) {
2483
+ // Look for consecutive duplicate segments (e.g., md-manager/md-manager)
2484
+ const segments = normalizedPath.split('/');
2485
+ let corrected = false;
2486
+ for (let i = 1; i < segments.length; i++) {
2487
+ if (segments[i] === segments[i - 1]) {
2488
+ const deduped = [...segments.slice(0, i), ...segments.slice(i + 1)];
2489
+ const candidate = deduped.join('/');
2490
+ if (roots.some(r => candidate.startsWith(normalizePath(path.resolve(r.path))))) {
2491
+ warnings.push(`Auto-corrected doubled path segment "${segments[i]}": ${filePath} -> ${candidate}`);
2492
+ normalizedPath = candidate;
2493
+ corrected = true;
2494
+ break;
2495
+ }
2496
+ }
2497
+ }
2498
+ }
2499
+ // Check if it's a markdown file (before existence check so cloud-only pulls also validate)
2500
+ if (!normalizedPath.endsWith('.md')) {
2501
+ result.errors.push(`Not a markdown file: ${filePath}`);
2502
+ result.details.push({ file: filePath, action: 'error', reason: 'Not a markdown file' });
2503
+ continue;
2504
+ }
2505
+ // Check if file exists locally — if not, try pulling from cloud
2506
+ if (!fs.existsSync(normalizedPath)) {
2507
+ const matchingRoot = roots.find(r => normalizedPath.startsWith(normalizePath(path.resolve(r.path))));
2508
+ if (!matchingRoot) {
2509
+ result.errors.push(`File not in any configured root: ${filePath}`);
2510
+ result.details.push({ file: filePath, action: 'error', reason: 'Not in configured root' });
2511
+ continue;
2512
+ }
2513
+ const relativePath = normalizePath(path.relative(matchingRoot.path, normalizedPath));
2514
+ // Fetch cloud notes (cached)
2515
+ if (!cloudNotesCache.has(matchingRoot.id)) {
2516
+ const cloudNotes = await client.getNotesForSync(matchingRoot.id);
2517
+ const cloudMap = new Map();
2518
+ for (const note of cloudNotes) {
2519
+ if (note.relative_path) {
2520
+ cloudMap.set(normalizePath(note.relative_path), note);
2521
+ }
2522
+ }
2523
+ cloudNotesCache.set(matchingRoot.id, cloudMap);
2524
+ }
2525
+ const cloudNotesMap = cloudNotesCache.get(matchingRoot.id);
2526
+ const cloudNote = cloudNotesMap.get(relativePath);
2527
+ if (cloudNote) {
2528
+ // CLOUD ONLY: Pull to local (create)
2529
+ if (dryRun) {
2530
+ result.details.push({ file: relativePath, action: 'pulled_create' });
2531
+ result.pulled.created++;
2532
+ }
2533
+ else {
2534
+ const localDir = path.dirname(normalizedPath);
2535
+ if (!fs.existsSync(localDir)) {
2536
+ fs.mkdirSync(localDir, { recursive: true });
2537
+ }
2538
+ fs.writeFileSync(normalizedPath, cloudNote.content, 'utf-8');
2539
+ const stats = fs.statSync(normalizedPath);
2540
+ await client.updateFileMetadata(normalizedPath, stats.size, cloudNote.hash);
2541
+ // Set baseline
2542
+ if (!syncStateCache.has(matchingRoot.path)) {
2543
+ const mgr = new SyncStateManager(matchingRoot.path);
2544
+ mgr.load();
2545
+ syncStateCache.set(matchingRoot.path, mgr);
2546
+ }
2547
+ syncStateCache.get(matchingRoot.path).setBaseline(relativePath, cloudNote.hash);
2548
+ result.details.push({ file: relativePath, action: 'pulled_create' });
2549
+ result.pulled.created++;
2550
+ affectedRoots.add(matchingRoot.id);
2551
+ }
2552
+ }
2553
+ else {
2554
+ result.errors.push(`Path '${filePath}' is inside root '${matchingRoot.name}' but no note at relative path '${relativePath}' exists in the cloud. This is a genuinely missing note — not a stale local cache. Try search_notes or list_notes to find similar paths in this root.`);
2555
+ result.details.push({ file: filePath, action: 'error', reason: 'Not found locally or in cloud' });
2556
+ }
2557
+ continue;
2558
+ }
2559
+ // Find which root this file belongs to
2560
+ const matchingRoot = roots.find(r => normalizedPath.startsWith(normalizePath(path.resolve(r.path))));
2561
+ if (!matchingRoot) {
2562
+ result.errors.push(`Path '${filePath}' is not inside any registered Noesis root. Call list_roots to see what's registered, or use add_root to register a parent directory before syncing this path.`);
2563
+ result.details.push({ file: filePath, action: 'error', reason: 'Not in configured root' });
2564
+ continue;
2565
+ }
2566
+ try {
2567
+ // Read local file content
2568
+ const localContent = fs.readFileSync(normalizedPath, 'utf-8');
2569
+ const stats = fs.statSync(normalizedPath);
2570
+ const relativePath = normalizePath(path.relative(matchingRoot.path, normalizedPath));
2571
+ const localHash = NoesisClient.computeHash(localContent);
2572
+ const localMtime = stats.mtime.getTime();
2573
+ // Get cloud notes for this root (cached)
2574
+ if (!cloudNotesCache.has(matchingRoot.id)) {
2575
+ const cloudNotes = await client.getNotesForSync(matchingRoot.id);
2576
+ const cloudMap = new Map();
2577
+ for (const note of cloudNotes) {
2578
+ if (note.relative_path) {
2579
+ cloudMap.set(normalizePath(note.relative_path), note);
2580
+ }
2581
+ }
2582
+ cloudNotesCache.set(matchingRoot.id, cloudMap);
2583
+ }
2584
+ const cloudNotesMap = cloudNotesCache.get(matchingRoot.id);
2585
+ const cloudNote = cloudNotesMap.get(relativePath);
2586
+ // Get or create SyncStateManager for this root
2587
+ if (!syncStateCache.has(matchingRoot.path)) {
2588
+ const mgr = new SyncStateManager(matchingRoot.path);
2589
+ mgr.load();
2590
+ syncStateCache.set(matchingRoot.path, mgr);
2591
+ }
2592
+ const stateMgr = syncStateCache.get(matchingRoot.path);
2593
+ // Parse local frontmatter metadata
2594
+ const localMetadata = parseYamlFrontmatter(localContent);
2595
+ if (!cloudNote) {
2596
+ // LOCAL ONLY: Push to cloud (create)
2597
+ if (dryRun) {
2598
+ result.details.push({ file: relativePath, action: 'pushed_create' });
2599
+ result.pushed.created++;
2600
+ }
2601
+ else {
2602
+ const project = detectProjectForFile(normalizedPath, matchingRoot.path);
2603
+ const localFile = {
2604
+ path: normalizedPath,
2605
+ relativePath,
2606
+ content: localContent,
2607
+ hash: localHash,
2608
+ mtime: stats.mtime,
2609
+ size: stats.size,
2610
+ rootId: matchingRoot.id,
2611
+ rootName: matchingRoot.name,
2612
+ project
2613
+ };
2614
+ await client.upsertNote(localFile, localMetadata, { force, regenerateMetadata });
2615
+ stateMgr.setBaseline(relativePath, { hash: localHash, lastSyncedAt: new Date().toISOString() }, localContent);
2616
+ result.details.push({ file: relativePath, action: 'pushed_create' });
2617
+ result.pushed.created++;
2618
+ affectedRoots.add(matchingRoot.id);
2619
+ }
2620
+ }
2621
+ else if (localHash === cloudNote.hash) {
2622
+ // SAME CONTENT HASH: Check if metadata differs
2623
+ const cloudKeywords = parseCloudKeywords(cloudNote.keywords);
2624
+ const metadataDiffers = (cloudNote.title && cloudNote.title !== localMetadata.title) ||
2625
+ (cloudNote.description && cloudNote.description !== localMetadata.description) ||
2626
+ (cloudKeywords.length > 0 && JSON.stringify(cloudKeywords.sort()) !== JSON.stringify((localMetadata.keywords || []).sort()));
2627
+ if (metadataDiffers) {
2628
+ // Cloud has different metadata - pull it to local
2629
+ const cloudMtime = new Date(cloudNote.modified_at).getTime();
2630
+ if (cloudMtime > localMtime || force) {
2631
+ // Cloud is newer or force mode - pull metadata to local
2632
+ if (dryRun) {
2633
+ result.details.push({ file: relativePath, action: 'pulled_update', reason: 'metadata from cloud' });
2634
+ result.pulled.updated++;
2635
+ }
2636
+ else {
2637
+ // Update local file frontmatter with cloud metadata
2638
+ const updatedContent = updateFrontmatter(localContent, {
2639
+ title: cloudNote.title || undefined,
2640
+ description: cloudNote.description || undefined,
2641
+ keywords: cloudKeywords.length > 0 ? cloudKeywords : undefined
2642
+ });
2643
+ fs.writeFileSync(normalizedPath, updatedContent, 'utf-8');
2644
+ result.details.push({ file: relativePath, action: 'pulled_update', reason: 'metadata from cloud' });
2645
+ result.pulled.updated++;
2646
+ affectedRoots.add(matchingRoot.id);
2647
+ }
2648
+ }
2649
+ else {
2650
+ // Local is newer - push metadata to cloud.
2651
+ // Merge local over cloud so a missing/unparsed key falls back to cloud instead of wiping it.
2652
+ // Explicit empty values (description: "", keywords: []) still propagate — only `undefined` falls back.
2653
+ if (dryRun) {
2654
+ result.details.push({ file: relativePath, action: 'pushed_update', reason: 'metadata to cloud' });
2655
+ result.pushed.updated++;
2656
+ }
2657
+ else {
2658
+ const project = detectProjectForFile(normalizedPath, matchingRoot.path);
2659
+ const localFile = {
2660
+ path: normalizedPath,
2661
+ relativePath,
2662
+ content: localContent,
2663
+ hash: localHash,
2664
+ mtime: stats.mtime,
2665
+ size: stats.size,
2666
+ rootId: matchingRoot.id,
2667
+ rootName: matchingRoot.name,
2668
+ project
2669
+ };
2670
+ const mergedMetadata = {
2671
+ title: localMetadata.title !== undefined ? localMetadata.title : (cloudNote.title || undefined),
2672
+ description: localMetadata.description !== undefined ? localMetadata.description : (cloudNote.description || undefined),
2673
+ keywords: localMetadata.keywords !== undefined ? localMetadata.keywords : (cloudKeywords.length > 0 ? cloudKeywords : undefined)
2674
+ };
2675
+ await client.upsertNote(localFile, mergedMetadata, { force: true, regenerateMetadata });
2676
+ result.details.push({ file: relativePath, action: 'pushed_update', reason: 'metadata to cloud' });
2677
+ result.pushed.updated++;
2678
+ affectedRoots.add(matchingRoot.id);
2679
+ }
2680
+ }
2681
+ }
2682
+ else {
2683
+ // Content and metadata unchanged
2684
+ result.details.push({ file: relativePath, action: 'skipped', reason: 'unchanged' });
2685
+ result.skipped++;
2686
+ }
2687
+ // Record baseline for same-hash files (seed state on first sync)
2688
+ if (!dryRun) {
2689
+ stateMgr.setBaseline(relativePath, { hash: localHash, lastSyncedAt: cloudNote.modified_at }, localContent);
2690
+ }
2691
+ }
2692
+ else {
2693
+ // DIFFERENT CONTENT HASH: Use three-way comparison with baseline
2694
+ const baselineMeta = stateMgr.getBaselineMeta(relativePath);
2695
+ const baselineHash = baselineMeta?.hash;
2696
+ const baselineLastSyncedAt = baselineMeta?.lastSyncedAt;
2697
+ const cloudMtime = new Date(cloudNote.modified_at).getTime();
2698
+ const direction = force
2699
+ ? 'push'
2700
+ : determineSyncDirection(localHash, cloudNote.hash, baselineHash, localMtime, cloudMtime, cloudNote.edited_online_at, baselineLastSyncedAt);
2701
+ if (direction === 'conflict') {
2702
+ const project = detectProjectForFile(normalizedPath, matchingRoot.path);
2703
+ const localFile = {
2704
+ path: normalizedPath,
2705
+ relativePath,
2706
+ content: localContent,
2707
+ hash: localHash,
2708
+ mtime: stats.mtime,
2709
+ size: stats.size,
2710
+ rootId: matchingRoot.id,
2711
+ rootName: matchingRoot.name,
2712
+ project,
2713
+ };
2714
+ await runConflictCascade({
2715
+ relativePath,
2716
+ localPath: normalizedPath,
2717
+ localContent,
2718
+ cloudNote: {
2719
+ id: cloudNote.id,
2720
+ content: cloudNote.content,
2721
+ hash: cloudNote.hash,
2722
+ modified_at: cloudNote.modified_at,
2723
+ edited_online_at: cloudNote.edited_online_at,
2724
+ },
2725
+ baselineHash,
2726
+ baselineLastSyncedAt,
2727
+ baselineContent: stateMgr.getBaselineContent(relativePath),
2728
+ localFile,
2729
+ localMtime: stats.mtime,
2730
+ dryRun,
2731
+ client,
2732
+ stateMgr,
2733
+ result,
2734
+ });
2735
+ if (result.pushed.updated > 0)
2736
+ affectedRoots.add(matchingRoot.id);
2737
+ }
2738
+ else if (direction === 'push') {
2739
+ // Local changed - smart merge: cloud H1 + local body, preserve cloud metadata
2740
+ if (dryRun) {
2741
+ result.details.push({ file: relativePath, action: 'pushed_update', reason: 'merged' });
2742
+ result.pushed.updated++;
2743
+ }
2744
+ else {
2745
+ const project = detectProjectForFile(normalizedPath, matchingRoot.path);
2746
+ // Merge: use cloud's H1 (Noesis title edits) + local's body (local content edits)
2747
+ const mergedContent = mergeContent(localContent, cloudNote.content);
2748
+ // Enrich frontmatter with cloud metadata before push
2749
+ const cloudKw = parseCloudKeywords(cloudNote.keywords);
2750
+ const hasCloudMetadata = cloudNote.title || cloudNote.description || cloudKw.length > 0;
2751
+ const enrichedContent = hasCloudMetadata
2752
+ ? updateFrontmatter(mergedContent, {
2753
+ title: cloudNote.title || undefined,
2754
+ description: cloudNote.description || undefined,
2755
+ keywords: cloudKw.length > 0 ? cloudKw : undefined
2756
+ })
2757
+ : mergedContent;
2758
+ const enrichedHash = NoesisClient.computeHash(enrichedContent);
2759
+ const localFile = {
2760
+ path: normalizedPath,
2761
+ relativePath,
2762
+ content: enrichedContent,
2763
+ hash: enrichedHash,
2764
+ mtime: stats.mtime,
2765
+ size: stats.size,
2766
+ rootId: matchingRoot.id,
2767
+ rootName: matchingRoot.name,
2768
+ project
2769
+ };
2770
+ // preserveMetadata=true: keep cloud's AI-generated title/description/keywords
2771
+ await client.upsertNote(localFile, localMetadata, { force: true, regenerateMetadata, preserveMetadata: true });
2772
+ // Write enriched content back to local file
2773
+ fs.writeFileSync(normalizedPath, enrichedContent, 'utf-8');
2774
+ stateMgr.setBaseline(relativePath, { hash: enrichedHash, lastSyncedAt: new Date().toISOString() }, enrichedContent);
2775
+ result.details.push({ file: relativePath, action: 'pushed_update', reason: 'merged' });
2776
+ result.pushed.updated++;
2777
+ affectedRoots.add(matchingRoot.id);
2778
+ }
2779
+ }
2780
+ else if (direction === 'pull') {
2781
+ // Cloud changed - pull content to local
2782
+ if (dryRun) {
2783
+ result.details.push({ file: relativePath, action: 'pulled_update' });
2784
+ result.pulled.updated++;
2785
+ }
2786
+ else {
2787
+ fs.writeFileSync(normalizedPath, cloudNote.content, 'utf-8');
2788
+ const stats = fs.statSync(normalizedPath);
2789
+ await client.updateFileMetadata(normalizedPath, stats.size, cloudNote.hash);
2790
+ stateMgr.setBaseline(relativePath, { hash: cloudNote.hash, lastSyncedAt: cloudNote.modified_at }, cloudNote.content);
2791
+ result.details.push({ file: relativePath, action: 'pulled_update' });
2792
+ result.pulled.updated++;
2793
+ affectedRoots.add(matchingRoot.id);
2794
+ }
2795
+ }
2796
+ }
2797
+ }
2798
+ catch (error) {
2799
+ const errorMsg = error instanceof Error ? error.message : 'Unknown error';
2800
+ result.errors.push(`${filePath}: ${errorMsg}`);
2801
+ result.details.push({ file: filePath, action: 'error', reason: errorMsg });
2802
+ }
2803
+ }
2804
+ // Save sync state baselines (unless dry run)
2805
+ if (!dryRun) {
2806
+ for (const mgr of syncStateCache.values()) {
2807
+ mgr.save();
2808
+ }
2809
+ }
2810
+ // Update root scan times and log sync for affected roots (unless dry run)
2811
+ if (!dryRun && affectedRoots.size > 0) {
2812
+ for (const rootId of affectedRoots) {
2813
+ await client.updateRootScanTime(rootId);
2814
+ // Log sync operation for Dashboard
2815
+ try {
2816
+ await client.logSyncOperation({
2817
+ rootId,
2818
+ filesScanned: filePaths.length,
2819
+ filesAdded: result.pushed.created,
2820
+ filesUpdated: result.pushed.updated + result.pulled.updated,
2821
+ filesDeleted: 0,
2822
+ source: 'mcp-bidirectional',
2823
+ machineName: os.hostname(),
2824
+ notes: result.conflicts.length > 0 ? `${result.conflicts.length} conflicts` : 'Specific files sync'
2825
+ });
2826
+ }
2827
+ catch (logError) {
2828
+ console.error('Failed to log sync operation:', logError);
2829
+ }
2830
+ }
2831
+ }
2832
+ // Build response message
2833
+ const fileCount = filePaths.length;
2834
+ let message = dryRun
2835
+ ? `**Dry Run - Bidirectional Sync Preview (${fileCount} file${fileCount === 1 ? '' : 's'}):**\n\n`
2836
+ : `**Bidirectional Sync Complete (${fileCount} file${fileCount === 1 ? '' : 's'})!**\n\n`;
2837
+ const totalPushed = result.pushed.created + result.pushed.updated;
2838
+ const totalPulled = result.pulled.created + result.pulled.updated;
2839
+ message += `📊 **Summary:**\n`;
2840
+ message += `- ⬆️ Pushed to cloud: ${totalPushed} (${result.pushed.created} new, ${result.pushed.updated} updated)\n`;
2841
+ message += `- ⬇️ Pulled from cloud: ${totalPulled} (${result.pulled.created} new, ${result.pulled.updated} updated)\n`;
2842
+ message += `- ⏭️ Skipped: ${result.skipped} (unchanged)\n`;
2843
+ if (result.conflicts.length > 0) {
2844
+ message += `- ⚠️ Conflicts: ${result.conflicts.length} (manual resolution needed)\n`;
2845
+ }
2846
+ if (result.errors.length > 0) {
2847
+ message += `- ❌ Errors: ${result.errors.length}\n`;
2848
+ }
2849
+ if (warnings.length > 0) {
2850
+ message += `\n**Path Corrections:**\n`;
2851
+ for (const w of warnings) {
2852
+ message += `- ${w}\n`;
2853
+ }
2854
+ }
2855
+ // Show changes
2856
+ const showDetails = result.details.filter(d => d.action !== 'skipped');
2857
+ if (showDetails.length > 0) {
2858
+ message += '\n**Changes:**\n';
2859
+ for (const detail of showDetails) {
2860
+ const icon = detail.action === 'pushed_create' ? '⬆️✨' :
2861
+ detail.action === 'pushed_update' ? '⬆️🔄' :
2862
+ detail.action === 'pulled_create' ? '⬇️✨' :
2863
+ detail.action === 'pulled_update' ? '⬇️🔄' :
2864
+ detail.action === 'conflict' ? '⚠️' :
2865
+ detail.action === 'error' ? '❌' : '⏭️';
2866
+ message += `${icon} ${detail.file}`;
2867
+ if (detail.reason)
2868
+ message += ` (${detail.reason})`;
2869
+ message += '\n';
2870
+ }
2871
+ }
2872
+ // Show conflicts (with structured BASE/LOCAL/CLOUD blocks when available).
2873
+ if (result.conflicts.length > 0) {
2874
+ message += '\n**⚠️ Conflicts (not synced):**\n';
2875
+ for (const conflict of result.conflicts) {
2876
+ message += `- ${conflict.path}\n`;
2877
+ message += ` Local: ${new Date(conflict.localModified).toLocaleString()}\n`;
2878
+ message += ` Cloud: ${new Date(conflict.cloudModified).toLocaleString()}\n`;
2879
+ const structured = conflict.structuredText;
2880
+ if (structured)
2881
+ message += structured;
2882
+ }
2883
+ message += '\n_Resolve via Path 1 (edit the local file and re-run `mcp__noesis__sync_notes`) or Path 2 (run `/noesis-sync`)._\n';
2884
+ }
2885
+ return {
2886
+ content: [{
2887
+ type: 'text',
2888
+ text: message
2889
+ }]
2890
+ };
2891
+ }
2892
+ /**
2893
+ * Parse YAML frontmatter from markdown content using js-yaml
2894
+ */
2895
+ function parseYamlFrontmatter(content) {
2896
+ const result = {};
2897
+ // Check for YAML frontmatter (--- at start)
2898
+ if (!content.startsWith('---')) {
2899
+ return result;
2900
+ }
2901
+ const endIndex = content.indexOf('---', 3);
2902
+ if (endIndex === -1) {
2903
+ return result;
2904
+ }
2905
+ const frontmatterStr = content.substring(3, endIndex).trim();
2906
+ try {
2907
+ const parsed = yaml.load(frontmatterStr);
2908
+ if (!parsed || typeof parsed !== 'object') {
2909
+ return result;
2910
+ }
2911
+ // Extract title
2912
+ if (typeof parsed.title === 'string') {
2913
+ result.title = parsed.title;
2914
+ }
2915
+ // Extract description
2916
+ if (typeof parsed.description === 'string') {
2917
+ result.description = parsed.description;
2918
+ }
2919
+ // Extract keywords (support both 'keywords' and 'tags')
2920
+ const keywordsValue = parsed.keywords ?? parsed.tags;
2921
+ if (Array.isArray(keywordsValue)) {
2922
+ result.keywords = keywordsValue.filter((k) => typeof k === 'string');
2923
+ }
2924
+ else if (typeof keywordsValue === 'string') {
2925
+ // Handle comma-separated string
2926
+ result.keywords = keywordsValue.split(',').map(k => k.trim()).filter(k => k);
2927
+ }
2928
+ }
2929
+ catch (error) {
2930
+ // If YAML parsing fails, return empty result
2931
+ console.error('Failed to parse YAML frontmatter:', error);
2932
+ }
2933
+ return result;
2934
+ }
2935
+ /**
2936
+ * Parse cloud keywords from various formats (string JSON, array, or null)
2937
+ */
2938
+ function parseCloudKeywords(keywords) {
2939
+ if (!keywords)
2940
+ return [];
2941
+ if (Array.isArray(keywords))
2942
+ return keywords;
2943
+ try {
2944
+ const parsed = JSON.parse(keywords);
2945
+ return Array.isArray(parsed) ? parsed : [];
2946
+ }
2947
+ catch {
2948
+ return [];
2949
+ }
2950
+ }
2951
+ /**
2952
+ * Update frontmatter in file content with new metadata values
2953
+ * Preserves existing frontmatter structure and only updates specified fields
2954
+ */
2955
+ function updateFrontmatter(content, updates) {
2956
+ // If no frontmatter exists, create one
2957
+ if (!content.startsWith('---')) {
2958
+ const frontmatterLines = ['---'];
2959
+ if (updates.title)
2960
+ frontmatterLines.push(`title: ${updates.title}`);
2961
+ if (updates.description)
2962
+ frontmatterLines.push(`description: ${updates.description}`);
2963
+ if (updates.keywords && updates.keywords.length > 0) {
2964
+ frontmatterLines.push('keywords:');
2965
+ for (const kw of updates.keywords) {
2966
+ frontmatterLines.push(` - ${kw}`);
2967
+ }
2968
+ }
2969
+ frontmatterLines.push('---');
2970
+ return frontmatterLines.join('\n') + '\n' + content;
2971
+ }
2972
+ // Find end of frontmatter
2973
+ const endIndex = content.indexOf('---', 3);
2974
+ if (endIndex === -1) {
2975
+ return content; // Invalid frontmatter, return unchanged
2976
+ }
2977
+ const frontmatter = content.substring(3, endIndex);
2978
+ const bodyContent = content.substring(endIndex + 3);
2979
+ const lines = frontmatter.split(/\r?\n/);
2980
+ const newLines = [];
2981
+ const updatedKeys = new Set();
2982
+ let i = 0;
2983
+ let lastReplacedKey = null;
2984
+ while (i < lines.length) {
2985
+ const line = lines[i];
2986
+ const colonIndex = line.indexOf(':');
2987
+ // Indented or non-key lines: continuation/sub-items of previous key
2988
+ if (colonIndex === -1 || line.match(/^\s/)) {
2989
+ if (lastReplacedKey === null) {
2990
+ newLines.push(line);
2991
+ }
2992
+ // else: skip — belongs to a replaced key
2993
+ i++;
2994
+ continue;
2995
+ }
2996
+ // New top-level key — reset sub-item tracking
2997
+ lastReplacedKey = null;
2998
+ const key = line.substring(0, colonIndex).trim().toLowerCase();
2999
+ if (key === 'title' && updates.title !== undefined) {
3000
+ if (!updatedKeys.has('title')) {
3001
+ newLines.push(`title: ${updates.title}`);
3002
+ updatedKeys.add('title');
3003
+ }
3004
+ lastReplacedKey = 'title';
3005
+ }
3006
+ else if (key === 'description' && updates.description !== undefined) {
3007
+ if (!updatedKeys.has('description')) {
3008
+ newLines.push(`description: ${updates.description}`);
3009
+ updatedKeys.add('description');
3010
+ }
3011
+ lastReplacedKey = 'description';
3012
+ }
3013
+ else if ((key === 'keywords' || key === 'tags') && updates.keywords !== undefined) {
3014
+ if (!updatedKeys.has('keywords')) {
3015
+ newLines.push('keywords:');
3016
+ for (const kw of updates.keywords) {
3017
+ newLines.push(` - ${kw}`);
3018
+ }
3019
+ updatedKeys.add('keywords');
3020
+ }
3021
+ lastReplacedKey = 'keywords';
3022
+ }
3023
+ else {
3024
+ newLines.push(line);
3025
+ }
3026
+ i++;
3027
+ }
3028
+ // Add any new keys that weren't in original frontmatter
3029
+ if (updates.title !== undefined && !updatedKeys.has('title')) {
3030
+ newLines.push(`title: ${updates.title}`);
3031
+ }
3032
+ if (updates.description !== undefined && !updatedKeys.has('description')) {
3033
+ newLines.push(`description: ${updates.description}`);
3034
+ }
3035
+ if (updates.keywords !== undefined && !updatedKeys.has('keywords')) {
3036
+ newLines.push('keywords:');
3037
+ for (const kw of updates.keywords) {
3038
+ newLines.push(` - ${kw}`);
3039
+ }
3040
+ }
3041
+ return '---\n' + newLines.filter(l => l.trim() !== '').join('\n') + '\n---' + bodyContent;
3042
+ }
3043
+ /**
3044
+ * Parse markdown content into structure: frontmatter, H1 heading, and body
3045
+ */
3046
+ function parseMarkdownStructure(content) {
3047
+ let frontmatter = '';
3048
+ let remaining = content;
3049
+ // Extract YAML frontmatter (--- ... ---)
3050
+ if (content.startsWith('---')) {
3051
+ const endIndex = content.indexOf('---', 3);
3052
+ if (endIndex !== -1) {
3053
+ frontmatter = content.substring(0, endIndex + 3);
3054
+ remaining = content.substring(endIndex + 3).replace(/^\r?\n/, ''); // Remove leading newline after frontmatter
3055
+ }
3056
+ }
3057
+ // Extract first H1 heading (# ...)
3058
+ const h1Match = remaining.match(/^(#\s+.+?)(\r?\n|$)/m);
3059
+ let h1 = null;
3060
+ let body = remaining;
3061
+ if (h1Match) {
3062
+ h1 = h1Match[1];
3063
+ // Get everything after the H1 (including the newline after it)
3064
+ const h1EndIndex = remaining.indexOf(h1Match[0]) + h1Match[0].length;
3065
+ body = remaining.substring(h1EndIndex);
3066
+ }
3067
+ return { frontmatter, h1, body };
3068
+ }
3069
+ /**
3070
+ * Reconstruct markdown content from parts
3071
+ */
3072
+ function reconstructMarkdown(frontmatter, h1, body) {
3073
+ let result = '';
3074
+ if (frontmatter) {
3075
+ result += frontmatter + '\n';
3076
+ }
3077
+ if (h1) {
3078
+ result += h1 + '\n';
3079
+ }
3080
+ result += body;
3081
+ return result;
3082
+ }
3083
+ /**
3084
+ * Tier A — anchor-first reapply.
3085
+ *
3086
+ * Derive cloud-side hunks from `diffPatch(B, C)`. For each hunk, pull a few lines of
3087
+ * surrounding context from B as `prefix`/`suffix`. Search L for a unique
3088
+ * `prefix + originalLines + suffix` match. If found, splice C's replacement lines in.
3089
+ *
3090
+ * Returns null when any hunk's anchor isn't unique in L (or original drifted) — caller
3091
+ * falls through to tier B.
3092
+ *
3093
+ * Cheap, deterministic, and handles the common typo case cleanly without producing the
3094
+ * line-noise that diff3Merge sometimes emits when adjacent lines diverge.
3095
+ */
3096
+ function tierAAnchorReapply(local, base, cloud) {
3097
+ const baseLines = base.split(/\r?\n/);
3098
+ const cloudLines = cloud.split(/\r?\n/);
3099
+ const patches = diffPatch(baseLines, cloudLines);
3100
+ if (patches.length === 0) {
3101
+ return { kind: 'merged', merged: local, hunks: 0 };
3102
+ }
3103
+ const CONTEXT = 3;
3104
+ const splices = [];
3105
+ for (const p of patches) {
3106
+ const baseStart = p.buffer1.offset;
3107
+ const baseEnd = baseStart + p.buffer1.length;
3108
+ const originalLines = p.buffer1.chunk;
3109
+ const replacementLines = p.buffer2.chunk;
3110
+ const prefixLines = baseLines.slice(Math.max(0, baseStart - CONTEXT), baseStart);
3111
+ const suffixLines = baseLines.slice(baseEnd, Math.min(baseLines.length, baseEnd + CONTEXT));
3112
+ // Search L's text for a unique `prefix + original + suffix` match (line-joined).
3113
+ const localLines = local.split(/\r?\n/);
3114
+ const needle = [...prefixLines, ...originalLines, ...suffixLines];
3115
+ const matchIdx = findUniqueLineRun(localLines, needle);
3116
+ if (matchIdx === -1)
3117
+ return null;
3118
+ const localOriginalStart = matchIdx + prefixLines.length;
3119
+ const localOriginalEnd = localOriginalStart + originalLines.length;
3120
+ splices.push({ localStart: localOriginalStart, localEnd: localOriginalEnd, replacement: replacementLines });
3121
+ }
3122
+ // Apply all splices in reverse order so indices stay valid.
3123
+ let mergedLines = local.split(/\r?\n/);
3124
+ splices.sort((a, b) => b.localStart - a.localStart);
3125
+ for (const s of splices) {
3126
+ mergedLines = [
3127
+ ...mergedLines.slice(0, s.localStart),
3128
+ ...s.replacement,
3129
+ ...mergedLines.slice(s.localEnd),
3130
+ ];
3131
+ }
3132
+ return { kind: 'merged', merged: mergedLines.join('\n'), hunks: splices.length };
3133
+ }
3134
+ /** Find the unique starting line index where `needle` appears in `haystack`. -1 if absent or non-unique. */
3135
+ function findUniqueLineRun(haystack, needle) {
3136
+ if (needle.length === 0 || needle.length > haystack.length)
3137
+ return -1;
3138
+ let foundIdx = -1;
3139
+ for (let i = 0; i <= haystack.length - needle.length; i++) {
3140
+ let match = true;
3141
+ for (let j = 0; j < needle.length; j++) {
3142
+ if (haystack[i + j] !== needle[j]) {
3143
+ match = false;
3144
+ break;
3145
+ }
3146
+ }
3147
+ if (match) {
3148
+ if (foundIdx !== -1)
3149
+ return -1; // non-unique
3150
+ foundIdx = i;
3151
+ }
3152
+ }
3153
+ return foundIdx;
3154
+ }
3155
+ /**
3156
+ * Tier B — node-diff3 line-level 3-way merge.
3157
+ * Returns merged text only when no conflict regions; null otherwise.
3158
+ */
3159
+ function tierBDiff3(local, base, cloud) {
3160
+ const regions = diff3Merge(local, base, cloud, { stringSeparator: /\r?\n/ });
3161
+ const conflictRegions = [];
3162
+ const mergedParts = [];
3163
+ for (const r of regions) {
3164
+ if (r.ok) {
3165
+ mergedParts.push(r.ok.join('\n'));
3166
+ }
3167
+ else if (r.conflict) {
3168
+ conflictRegions.push({
3169
+ aLines: r.conflict.a,
3170
+ oLines: r.conflict.o,
3171
+ bLines: r.conflict.b,
3172
+ });
3173
+ }
3174
+ }
3175
+ if (conflictRegions.length > 0) {
3176
+ return { kind: 'conflict', regions: conflictRegions };
3177
+ }
3178
+ return { kind: 'merged', merged: mergedParts.join('\n') };
3179
+ }
3180
+ /**
3181
+ * Format the structured BASE/LOCAL/CLOUD report for a conflicted file (tier C).
3182
+ * Goes into the sync_notes tool's text response so Claude (or the user) can act.
3183
+ */
3184
+ function formatConflictText(relativePath, regions) {
3185
+ let s = `\n⚠ Conflict in \`${relativePath}\` — ${regions.length} unresolvable hunk${regions.length === 1 ? '' : 's'}\n`;
3186
+ regions.forEach((r, i) => {
3187
+ s += `\nHunk ${i + 1}:\n`;
3188
+ s += '<<< BASE (last synced)\n';
3189
+ s += r.oLines.join('\n') + '\n';
3190
+ s += '=== LOCAL\n';
3191
+ s += r.aLines.join('\n') + '\n';
3192
+ s += '=== CLOUD (edited online)\n';
3193
+ s += r.bLines.join('\n') + '\n';
3194
+ s += '>>>\n';
3195
+ });
3196
+ return s;
3197
+ }
3198
+ /**
3199
+ * Run the three-tier cascade on a single conflicted file. Used by BOTH the
3200
+ * bidirectional root-scan path AND the --files specific-file path so they share
3201
+ * one merge engine. Mutates `result` in place.
3202
+ *
3203
+ * Returns: nothing. Side effects:
3204
+ * - On tier A or tier B success: writes merged text to localPath, pushes via
3205
+ * upsert with lastSyncedHash, updates baseline (hash + content + lastSyncedAt).
3206
+ * - On tier C: posts /mark-conflict to surface the web banner; appends the
3207
+ * structured BASE/LOCAL/CLOUD payload onto result.conflicts[].structuredText.
3208
+ * - When baselineContent is undefined (v1-upgraded entry): skips A and B,
3209
+ * jumps to tier C with base: null.
3210
+ */
3211
+ async function runConflictCascade(opts) {
3212
+ const { relativePath, localPath, localContent, cloudNote, baselineHash, baselineLastSyncedAt, baselineContent, localFile, localMtime, dryRun, client, stateMgr, result } = opts;
3213
+ const conflictEntry = {
3214
+ path: relativePath,
3215
+ localModified: localMtime.toISOString(),
3216
+ cloudModified: cloudNote.modified_at,
3217
+ };
3218
+ if (baselineContent === undefined) {
3219
+ // v1-upgraded entry: no baseline file, jump to tier C with base: null
3220
+ result.conflicts.push(conflictEntry);
3221
+ result.details.push({
3222
+ file: relativePath,
3223
+ action: 'conflict',
3224
+ reason: 'baseline content missing (v1-upgraded entry); resolve via Path 1 or Path 2',
3225
+ });
3226
+ if (!dryRun) {
3227
+ try {
3228
+ await client.markConflict(cloudNote.id, {
3229
+ relativePath,
3230
+ reason: 'cloud-edited-online',
3231
+ baselineLastSyncedAt: baselineLastSyncedAt ?? null,
3232
+ cloudEditedOnlineAt: cloudNote.edited_online_at ?? null,
3233
+ base: null,
3234
+ local: localContent,
3235
+ cloud: cloudNote.content,
3236
+ });
3237
+ }
3238
+ catch (markErr) {
3239
+ console.warn(`Failed to mark conflict for ${relativePath}:`, markErr);
3240
+ }
3241
+ }
3242
+ return;
3243
+ }
3244
+ // Tier A — anchor-first reapply
3245
+ let mergedText = null;
3246
+ let mergedTier = null;
3247
+ const aResult = tierAAnchorReapply(localContent, baselineContent, cloudNote.content);
3248
+ if (aResult) {
3249
+ mergedText = aResult.merged;
3250
+ mergedTier = 'A';
3251
+ }
3252
+ else {
3253
+ // Tier B — node-diff3 line-level 3-way merge
3254
+ const bResult = tierBDiff3(localContent, baselineContent, cloudNote.content);
3255
+ if (bResult.kind === 'merged') {
3256
+ mergedText = bResult.merged;
3257
+ mergedTier = 'B';
3258
+ }
3259
+ else {
3260
+ // Tier C — overlapping hunks; emit structured report
3261
+ result.conflicts.push(conflictEntry);
3262
+ const conflictText = formatConflictText(relativePath, bResult.regions);
3263
+ result.details.push({
3264
+ file: relativePath,
3265
+ action: 'conflict',
3266
+ reason: `${bResult.regions.length} unresolvable hunk(s)`,
3267
+ });
3268
+ // Stash the formatted text on the entry so the response footer can render it.
3269
+ result.conflicts[result.conflicts.length - 1].structuredText = conflictText;
3270
+ if (!dryRun) {
3271
+ try {
3272
+ await client.markConflict(cloudNote.id, {
3273
+ relativePath,
3274
+ reason: 'cloud-edited-online',
3275
+ baselineHash,
3276
+ baselineLastSyncedAt: baselineLastSyncedAt ?? null,
3277
+ cloudEditedOnlineAt: cloudNote.edited_online_at ?? null,
3278
+ regions: bResult.regions,
3279
+ base: baselineContent,
3280
+ local: localContent,
3281
+ cloud: cloudNote.content,
3282
+ });
3283
+ }
3284
+ catch (markErr) {
3285
+ console.warn(`Failed to mark conflict for ${relativePath}:`, markErr);
3286
+ }
3287
+ }
3288
+ return;
3289
+ }
3290
+ }
3291
+ // Tier A or Tier B produced merged text → push and write back
3292
+ if (mergedText !== null && mergedTier !== null) {
3293
+ if (dryRun) {
3294
+ result.details.push({
3295
+ file: relativePath,
3296
+ action: 'pushed_update',
3297
+ reason: `would auto-merge (tier ${mergedTier})`,
3298
+ });
3299
+ result.pushed.updated++;
3300
+ return;
3301
+ }
3302
+ const mergedHash = NoesisClient.computeHash(mergedText);
3303
+ const mergedFile = { ...localFile, content: mergedText, hash: mergedHash };
3304
+ try {
3305
+ // lastSyncedHash = the cloud hash we just merged against, NOT baselineHash.
3306
+ // The backend's 409 guard rejects when cloud changed AFTER we saw it; we saw cloud=cloudNote.hash
3307
+ // when computing the merge, so that's the version this push supersedes. Using baselineHash here
3308
+ // would always 409 because cloud.edited_online_at is set + cloud.hash !== baselineHash by definition.
3309
+ await client.upsertNote(mergedFile, {}, {
3310
+ force: true,
3311
+ regenerateMetadata: false,
3312
+ preserveMetadata: true,
3313
+ lastSyncedHash: cloudNote.hash,
3314
+ });
3315
+ fs.writeFileSync(localPath, mergedText, 'utf-8');
3316
+ stateMgr.setBaseline(relativePath, { hash: mergedHash, lastSyncedAt: new Date().toISOString() }, mergedText);
3317
+ result.details.push({
3318
+ file: relativePath,
3319
+ action: 'pushed_update',
3320
+ reason: `auto-merged (tier ${mergedTier})`,
3321
+ });
3322
+ result.pushed.updated++;
3323
+ }
3324
+ catch (mergeErr) {
3325
+ const msg = mergeErr instanceof Error ? mergeErr.message : 'Unknown merge error';
3326
+ result.errors.push(`${relativePath}: merge push failed: ${msg}`);
3327
+ result.details.push({ file: relativePath, action: 'error', reason: msg });
3328
+ }
3329
+ }
3330
+ }
3331
+ /**
3332
+ * Merge local content with cloud content
3333
+ * Strategy: Cloud's H1 wins (Noesis edits), Local's body wins (local edits)
3334
+ */
3335
+ function mergeContent(localContent, cloudContent) {
3336
+ const localParsed = parseMarkdownStructure(localContent);
3337
+ const cloudParsed = parseMarkdownStructure(cloudContent);
3338
+ // Use cloud's H1 if it exists and differs from local, otherwise use local's H1
3339
+ const h1ToUse = cloudParsed.h1 || localParsed.h1;
3340
+ // Use local's body (user's local edits)
3341
+ const bodyToUse = localParsed.body;
3342
+ // Keep local's frontmatter (will be ignored by backend with preserveMetadata)
3343
+ return reconstructMarkdown(localParsed.frontmatter, h1ToUse, bodyToUse);
3344
+ }
3345
+ //# sourceMappingURL=index.js.map