@noesis-brain/mcp-server 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +218 -0
- package/dist/api/NoesisClient.d.ts +501 -0
- package/dist/api/NoesisClient.d.ts.map +1 -0
- package/dist/api/NoesisClient.js +654 -0
- package/dist/api/NoesisClient.js.map +1 -0
- package/dist/cli/setup.d.ts +8 -0
- package/dist/cli/setup.d.ts.map +1 -0
- package/dist/cli/setup.js +148 -0
- package/dist/cli/setup.js.map +1 -0
- package/dist/database/PostgresAdapter.d.ts +385 -0
- package/dist/database/PostgresAdapter.d.ts.map +1 -0
- package/dist/database/PostgresAdapter.js +1043 -0
- package/dist/database/PostgresAdapter.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +126 -0
- package/dist/index.js.map +1 -0
- package/dist/services/embedding.d.ts +38 -0
- package/dist/services/embedding.d.ts.map +1 -0
- package/dist/services/embedding.js +126 -0
- package/dist/services/embedding.js.map +1 -0
- package/dist/tools/SyncStateManager.d.ts +65 -0
- package/dist/tools/SyncStateManager.d.ts.map +1 -0
- package/dist/tools/SyncStateManager.js +217 -0
- package/dist/tools/SyncStateManager.js.map +1 -0
- package/dist/tools/index.d.ts +14 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +3345 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/navis.d.ts +11 -0
- package/dist/tools/navis.d.ts.map +1 -0
- package/dist/tools/navis.js +231 -0
- package/dist/tools/navis.js.map +1 -0
- package/dist/types/index.d.ts +104 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +5 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/suggestPath.d.ts +15 -0
- package/dist/utils/suggestPath.d.ts.map +1 -0
- package/dist/utils/suggestPath.js +52 -0
- package/dist/utils/suggestPath.js.map +1 -0
- package/package.json +71 -0
- package/scripts/noesis-sync.mjs +469 -0
- package/skill-templates/noesis-refine-note.md +92 -0
- package/skill-templates/noesis-sync.md +110 -0
- package/templates/claude-md-block.md +22 -0
|
@@ -0,0 +1,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
|