@noesis-brain/mcp-server 2.0.1 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +91 -3
  2. package/dist/agent/runner.d.ts +29 -0
  3. package/dist/agent/runner.d.ts.map +1 -0
  4. package/dist/agent/runner.js +306 -0
  5. package/dist/agent/runner.js.map +1 -0
  6. package/dist/api/NoesisClient.d.ts +119 -0
  7. package/dist/api/NoesisClient.d.ts.map +1 -1
  8. package/dist/api/NoesisClient.js +130 -6
  9. package/dist/api/NoesisClient.js.map +1 -1
  10. package/dist/cli/setup.d.ts.map +1 -1
  11. package/dist/cli/setup.js +409 -35
  12. package/dist/cli/setup.js.map +1 -1
  13. package/dist/index.js +31 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/resolve/noteRef.d.ts +101 -0
  16. package/dist/resolve/noteRef.d.ts.map +1 -0
  17. package/dist/resolve/noteRef.js +186 -0
  18. package/dist/resolve/noteRef.js.map +1 -0
  19. package/dist/resolve/rootsFormat.d.ts +12 -0
  20. package/dist/resolve/rootsFormat.d.ts.map +1 -0
  21. package/dist/resolve/rootsFormat.js +63 -0
  22. package/dist/resolve/rootsFormat.js.map +1 -0
  23. package/dist/tools/backlog.d.ts +4 -0
  24. package/dist/tools/backlog.d.ts.map +1 -0
  25. package/dist/tools/backlog.js +161 -0
  26. package/dist/tools/backlog.js.map +1 -0
  27. package/dist/tools/index.d.ts +92 -1
  28. package/dist/tools/index.d.ts.map +1 -1
  29. package/dist/tools/index.js +529 -207
  30. package/dist/tools/index.js.map +1 -1
  31. package/dist/tools/navis.d.ts.map +1 -1
  32. package/dist/tools/navis.js +4 -0
  33. package/dist/tools/navis.js.map +1 -1
  34. package/package.json +8 -3
  35. package/scripts/agent-e2e.mjs +69 -0
  36. package/scripts/noesis-capture-session-end.mjs +94 -0
  37. package/scripts/noesis-capture-watcher.mjs +953 -0
  38. package/skill-templates/noesis-capture.md +166 -0
  39. package/skill-templates/noesis-create-study-note.md +149 -0
  40. package/skill-templates/noesis-find-related.md +89 -0
  41. package/skill-templates/noesis-investigate.md +90 -0
  42. package/skill-templates/noesis-manage-news.md +80 -0
  43. package/skill-templates/noesis-skim-read.md +72 -0
  44. package/templates/claude-md-block.md +5 -1
  45. package/templates/claude-md-restructure-on-sync.md +10 -0
@@ -7,9 +7,12 @@ import * as path from 'path';
7
7
  import * as os from 'os';
8
8
  import * as yaml from 'js-yaml';
9
9
  import { NoesisClient, CLIENT_OS, getActivePathFromMap, expandHome } from '../api/NoesisClient.js';
10
+ import { parseNoteReference, resolvePathReference, rootLocalBase } from '../resolve/noteRef.js';
11
+ import { formatRootsList } from '../resolve/rootsFormat.js';
10
12
  import { initEmbeddingService, generateEmbedding, generateEmbeddingsBatch } from '../services/embedding.js';
11
13
  import { SyncStateManager, determineSyncDirection } from './SyncStateManager.js';
12
14
  import { registerNaviTools } from './navis.js';
15
+ import { registerBacklogTools } from './backlog.js';
13
16
  import { suggestOtherOsPath } from '../utils/suggestPath.js';
14
17
  import { diff3Merge, diffPatch } from 'node-diff3';
15
18
  // ============================================
@@ -115,6 +118,37 @@ function detectProjectForFile(filePath, rootPath) {
115
118
  const parts = relativePath.split(path.sep);
116
119
  return parts[0] || 'Uncategorized';
117
120
  }
121
+ /**
122
+ * Resolve a user-supplied file path to a registered root + relative path,
123
+ * mirroring the sync resolver (see sync_notes): expand `~` / `%USERPROFILE%`
124
+ * against this machine's home, resolve to absolute, then prefix-match the input
125
+ * against each root's active path, preferring the longest (most specific) match.
126
+ * Returns null if the path isn't inside any known root.
127
+ *
128
+ * This is what lets get_note resolve an absolute clipboard path
129
+ * (e.g. C:\Users\me\Noesis\foo.md) against a tilde-stored cloud root (~/Noesis):
130
+ * the backend /notes/by-path route does a literal prefix compare and never
131
+ * expands `~`, so absolute paths can't match cloud roots there — resolving
132
+ * client-side and calling /notes/by-relative instead sidesteps that.
133
+ */
134
+ export function resolvePathToRoot(filePath, roots) {
135
+ const normalizedInput = normalizePath(path.resolve(expandHome(filePath)));
136
+ let best = null;
137
+ for (const r of roots) {
138
+ const rawPath = expandHome(r.path || '');
139
+ if (!rawPath)
140
+ continue;
141
+ const rootPath = normalizePath(path.resolve(rawPath));
142
+ if (normalizedInput === rootPath || normalizedInput.startsWith(rootPath + '/')) {
143
+ if (!best || rootPath.length > best.rootPath.length) {
144
+ best = { id: r.id, rootPath };
145
+ }
146
+ }
147
+ }
148
+ if (!best)
149
+ return null;
150
+ return { rootId: best.id, relativePath: normalizePath(path.relative(best.rootPath, normalizedInput)) };
151
+ }
118
152
  /**
119
153
  * Get a unique file path by appending -1, -2, etc. if file already exists
120
154
  */
@@ -150,6 +184,7 @@ export function registerTools(server, services) {
150
184
  }
151
185
  // Register Navi management tools
152
186
  registerNaviTools(server, client);
187
+ registerBacklogTools(server, client);
153
188
  // Register search_notes tool
154
189
  server.tool('search_notes', 'Search your knowledge base using full-text search. Returns relevant notes ranked by BM25 relevance.', {
155
190
  query: z.string().describe('Search query (natural language or keywords)'),
@@ -222,7 +257,7 @@ export function registerTools(server, services) {
222
257
  };
223
258
  });
224
259
  // 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`).', {
260
+ 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. A path is Noesis-tracked if it contains `/.noesis/`, OR sits under a folder named `Noesis` (the cloud root — e.g. `~/Noesis/…`, `%USERPROFILE%\\Noesis\\…`, or `C:\\Users\\<you>\\Noesis\\…`), OR is under any 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 — and an ABSOLUTE path resolves here even when the cloud stored the root in tilde form. This tool is READ-ONLY (no disk write) and is SAFE TO CALL IN PLAN MODE. RECOVERY RULE: if `Read` returned "File does not exist" for a `.md` path that looks like a note — especially one under a `.noesis`/`Noesis` folder — 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, and DO NOT conclude the note is missing or on another machine until THIS lookup also fails. 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
261
  id: z.number().optional().describe('Note ID'),
227
262
  path: z.string().optional().describe('File path of the note')
228
263
  }, async (args) => {
@@ -236,7 +271,36 @@ export function registerTools(server, services) {
236
271
  isError: true
237
272
  };
238
273
  }
239
- const note = id ? await client.getNote(id) : await client.getNoteByPath(path);
274
+ let note;
275
+ if (id) {
276
+ note = await client.getNote(id);
277
+ }
278
+ else {
279
+ // Unified resolution (M2): the `path` arg also accepts note URLs
280
+ // (https://…/notes/{id}), noesis://note/{id}, and bare numeric ids.
281
+ // Real paths resolve locally against ACTIVE + ARCHIVED roots (tilde/
282
+ // home expansion on both sides, longest prefix, legacy translation
283
+ // into the vault) — the server by-path route stays as the final
284
+ // fallback for anything the local context can't place.
285
+ const ref = parseNoteReference(path);
286
+ if (ref.kind === 'id') {
287
+ note = await client.getNote(ref.id);
288
+ }
289
+ else {
290
+ const ctx = await client.getResolverContext();
291
+ const resolved = resolvePathReference(ctx, ref.path);
292
+ if (resolved) {
293
+ for (const rel of resolved.candidates) {
294
+ note = await client.getNoteByRelativePath(resolved.rootId, rel);
295
+ if (note)
296
+ break;
297
+ }
298
+ }
299
+ if (!note) {
300
+ note = await client.getNoteByPath(path);
301
+ }
302
+ }
303
+ }
240
304
  if (!note) {
241
305
  return {
242
306
  content: [{
@@ -305,6 +369,170 @@ export function registerTools(server, services) {
305
369
  }]
306
370
  };
307
371
  });
372
+ // Register get_note_skim_read tool
373
+ server.tool('get_note_skim_read', "Read a note's SKIM-READ KEY PARTS — the spans the app's Skim-Read feature surfaces as the gist a reader should focus on. READ-ONLY: it computes the key parts on demand and writes NO marks to the note. Accepts a Note ID (`id`) or a file path (`path`). Use this to inspect what Skim-Read extracts for a note, or when iterating on the Skim-Read feature itself (set `fresh:true` to bypass the 30-min server cache after changing extraction logic). Cost: ~1 LLM call per (content+style+intensity), cached 30 min unless `fresh`. Knobs: `style` (gist|balanced|thorough|structure|keyword|question|inverse), `intensity` (light|normal|heavy), `granularities` (section|paragraph|sentence|keyword), `focusQuestion` (with style='question'), `language`. Returns key parts grouped by granularity with each part's importance (0..1) and a short reason. A note with no skimmable gist (a list/tracker/changelog) may correctly return zero parts. NOTE: this tool calls the in-app AI; if it fails with a quota/503/'AI not configured' error, use `apply_note_skim_read` instead — YOU generate the key parts and the server just persists them (no in-app AI needed).", {
374
+ id: z.number().optional().describe('Note ID'),
375
+ path: z.string().optional().describe('File path of the note'),
376
+ style: z.enum(['gist', 'balanced', 'thorough', 'structure', 'keyword', 'question', 'inverse']).optional()
377
+ .describe("Reading strategy (default: balanced)"),
378
+ intensity: z.enum(['light', 'normal', 'heavy']).optional().describe('How many parts to surface (default: normal)'),
379
+ granularities: z.array(z.enum(['section', 'paragraph', 'sentence', 'keyword'])).optional()
380
+ .describe('Which granularities to allow (default: all)'),
381
+ focusQuestion: z.string().optional().describe("Only with style='question': surface only spans relevant to this question"),
382
+ language: z.string().optional().describe("Note language hint (e.g. 'en', 'zh')"),
383
+ fresh: z.boolean().optional().describe('Bypass the 30-min server cache and recompute (use when iterating on extraction logic)')
384
+ }, async (args) => {
385
+ const { id, path, style, intensity, granularities, focusQuestion, language, fresh } = args;
386
+ if (!id && !path) {
387
+ return {
388
+ content: [{ type: 'text', text: 'Error: Please provide either an id or a path.' }],
389
+ isError: true
390
+ };
391
+ }
392
+ // Resolve a path to a note id locally first (mirrors get_note) so tilde-stored
393
+ // roots resolve; then skim-read by id. Fall back to sending the path through.
394
+ let noteId = id;
395
+ if (!noteId && path) {
396
+ const roots = await client.getRoots();
397
+ const resolved = resolvePathToRoot(path, roots);
398
+ const note = resolved
399
+ ? await client.getNoteByRelativePath(resolved.rootId, resolved.relativePath)
400
+ : await client.getNoteByPath(path);
401
+ if (note)
402
+ noteId = note.id;
403
+ }
404
+ let result;
405
+ try {
406
+ result = await client.getNoteSkimRead(noteId
407
+ ? { id: noteId, style, intensity, granularities, focusQuestion, language, fresh }
408
+ : { path, style, intensity, granularities, focusQuestion, language, fresh });
409
+ }
410
+ catch (e) {
411
+ return {
412
+ content: [{ type: 'text', text: `Skim-Read failed: ${e.message}` }],
413
+ isError: true
414
+ };
415
+ }
416
+ const meta = `**Note ID:** ${result.noteId} · **kind:** ${result.noteKind} · **parts:** ${result.keyPartCount} · **model:** ${result.model ?? 'n/a'}${result.cached ? ' · cached' : ' · fresh'}`;
417
+ if (!result.keyParts || result.keyParts.length === 0) {
418
+ return {
419
+ content: [{
420
+ type: 'text',
421
+ text: `# Skim-Read — ${result.title}\n\n${meta}\n\nNo key parts surfaced — this note has no skimmable gist (kind: ${result.noteKind}). For a list / tracker / changelog this is the expected result.`
422
+ }]
423
+ };
424
+ }
425
+ const ORDER = ['section', 'paragraph', 'sentence', 'keyword'];
426
+ const byGran = new Map();
427
+ for (const kp of result.keyParts) {
428
+ if (!byGran.has(kp.granularity))
429
+ byGran.set(kp.granularity, []);
430
+ byGran.get(kp.granularity).push(kp);
431
+ }
432
+ let body = '';
433
+ for (const g of ORDER) {
434
+ const items = byGran.get(g);
435
+ if (!items || items.length === 0)
436
+ continue;
437
+ items.sort((a, b) => b.importance - a.importance);
438
+ body += `\n## ${g.charAt(0).toUpperCase() + g.slice(1)} (${items.length})\n`;
439
+ for (const kp of items) {
440
+ const reason = kp.reason ? ` — ${kp.reason}` : '';
441
+ body += `- [${kp.importance.toFixed(2)}] ${JSON.stringify(kp.quote)}${reason}\n`;
442
+ }
443
+ }
444
+ return {
445
+ content: [{
446
+ type: 'text',
447
+ text: `# Skim-Read — ${result.title}\n\n${meta}\n${body}`
448
+ }]
449
+ };
450
+ });
451
+ server.tool('apply_note_skim_read', [
452
+ "Persist Skim-Read KEY PARTS that YOU generate for a note — the Gemini-free fallback for the app's Skim-Read feature when the in-app AI is rate-limited/503/unconfigured. WRITES marks: the server anchors each verbatim quote against the note's authoritative content and persists the matches as `suggested` ai_skim marks (the same marks the Skim-Read panel renders). The server does NO LLM call — it only anchors + persists what you provide.",
453
+ "",
454
+ "WORKFLOW: (1) read the note's content (get_note); (2) pick the key parts a reader should focus on to get the gist; (3) call this tool with `id` (or `path`) and `keyParts`; (4) if the result lists `unmatchedQuotes`, FIX those quotes (they weren't found verbatim) and call again.",
455
+ "",
456
+ "Each keyPart: { granularity, quote, headingPath?, importance?, reason? }.",
457
+ "- quote: MUST be copied CHARACTER-FOR-CHARACTER (verbatim) from the note — do NOT paraphrase, fix typos, translate, summarize, or add ellipses. Make it long enough to occur exactly once; if a short phrase repeats, include enough surrounding words to be unique. For a 'section' quote the heading line text; 'sentence' the full sentence; 'paragraph' its first ~12 words; 'keyword' the exact token(s).",
458
+ "- granularity: section | paragraph | sentence | keyword.",
459
+ "- importance: 0..1; reserve > 0.8 for the few genuinely load-bearing parts.",
460
+ "- reason: <= 12 words, plainly what the span says (no hype words).",
461
+ "- Only quote VISIBLE prose. Never quote inside code fences (```), YAML frontmatter, HTML tags, or a COLLAPSED `<details>` block (one without the `open` attribute).",
462
+ "- headingPath: ancestor heading texts (top-most first) to disambiguate a repeated quote; [] if unknown.",
463
+ "Knobs (optional): `style`, `intensity` (light|normal|heavy — bounds how many parts persist), `granularities`, `language`, `model` (provenance label). Re-applying replaces prior un-accepted suggestions; accepted/dismissed marks are preserved.",
464
+ ].join('\n'), {
465
+ id: z.number().optional().describe('Note ID'),
466
+ path: z.string().optional().describe('File path of the note'),
467
+ keyParts: z.array(z.object({
468
+ granularity: z.enum(['section', 'paragraph', 'sentence', 'keyword']),
469
+ quote: z.string().describe('Verbatim slice of the note content (character-for-character)'),
470
+ headingPath: z.array(z.string()).optional().describe('Ancestor heading texts, top-most first; [] if unknown'),
471
+ importance: z.number().min(0).max(1).optional().describe('0..1; > 0.8 only for load-bearing parts'),
472
+ reason: z.string().optional().describe('<= 12 words: plainly what the span says'),
473
+ })).min(1).describe('The key parts you generated (verbatim quotes from the note)'),
474
+ style: z.enum(['gist', 'balanced', 'thorough', 'structure', 'keyword', 'question', 'inverse']).optional()
475
+ .describe('Reading strategy hint (default: balanced)'),
476
+ intensity: z.enum(['light', 'normal', 'heavy']).optional().describe('Bounds how many parts persist (default: normal)'),
477
+ granularities: z.array(z.enum(['section', 'paragraph', 'sentence', 'keyword'])).optional()
478
+ .describe('Which granularities to allow (default: all)'),
479
+ language: z.string().optional().describe("Note language hint (e.g. 'en', 'zh')"),
480
+ model: z.string().optional().describe("Provenance label stored on the run (default: 'claude-code')"),
481
+ }, async (args) => {
482
+ const { id, path, keyParts, style, intensity, granularities, language, model } = args;
483
+ if (!id && !path) {
484
+ return {
485
+ content: [{ type: 'text', text: 'Error: Please provide either an id or a path.' }],
486
+ isError: true
487
+ };
488
+ }
489
+ // Resolve a path to a note id locally first (mirrors get_note_skim_read).
490
+ let noteId = id;
491
+ if (!noteId && path) {
492
+ const roots = await client.getRoots();
493
+ const resolved = resolvePathToRoot(path, roots);
494
+ const note = resolved
495
+ ? await client.getNoteByRelativePath(resolved.rootId, resolved.relativePath)
496
+ : await client.getNoteByPath(path);
497
+ if (note)
498
+ noteId = note.id;
499
+ }
500
+ let result;
501
+ try {
502
+ result = await client.applyNoteSkimRead(noteId
503
+ ? { id: noteId, keyParts, style, intensity, granularities, language, model }
504
+ : { path, keyParts, style, intensity, granularities, language, model });
505
+ }
506
+ catch (e) {
507
+ return {
508
+ content: [{ type: 'text', text: `Apply Skim-Read failed: ${e.message}` }],
509
+ isError: true
510
+ };
511
+ }
512
+ const lines = [
513
+ `**Note ID:** ${result.noteId} · **applied:** ${result.keyPartCount}/${result.providedCount} provided · **anchored:** ${result.anchoredCount} · **model:** ${result.model ?? 'n/a'}`,
514
+ ];
515
+ if (result.unmatchedQuotes && result.unmatchedQuotes.length > 0) {
516
+ lines.push('');
517
+ lines.push(`**${result.unmatchedQuotes.length} quote(s) did NOT anchor (not found verbatim in the note). Fix the exact text and re-apply:**`);
518
+ for (const q of result.unmatchedQuotes)
519
+ lines.push(`- ${JSON.stringify(q)}`);
520
+ }
521
+ else if (result.keyPartCount > 0) {
522
+ lines.push('');
523
+ lines.push('All provided quotes anchored. Open the note in Noesis — the Skim-Read panel now shows these key parts.');
524
+ }
525
+ if (result.declined) {
526
+ lines.push('');
527
+ lines.push('(Nothing anchored — no suggestions were persisted.)');
528
+ }
529
+ return {
530
+ content: [{
531
+ type: 'text',
532
+ text: `# Skim-Read applied — ${result.title}\n\n${lines.join('\n')}`
533
+ }]
534
+ };
535
+ });
308
536
  server.tool('get_bookmark_context', 'Read the note content surrounding a specific bookmark/tag. ' +
309
537
  'Accepts either a Noesis bookmark URL of the form ' +
310
538
  '`https://noesisbrain.com/notes/{id}#bm={uuid}` ' +
@@ -356,7 +584,7 @@ export function registerTools(server, services) {
356
584
  return { content: [{ type: 'text', text: lines.join('\n') }] };
357
585
  });
358
586
  // 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.', {
587
+ server.tool('get_chat_session', 'Get a chat session (conversation) and its messages by ID. URL pattern: noesisbrain.com/<id>. Includes main-thread messages and Navi Discussion turns (each turn is labeled with the speaker Navi). Scoped to the authenticated user.', {
360
588
  id: z.number().describe('Chat session ID'),
361
589
  limit: z.number().optional().describe('Max messages to return (default 200, max 500). Returns the latest N messages in chronological order.')
362
590
  }, async (args) => {
@@ -381,8 +609,14 @@ export function registerTools(server, services) {
381
609
  }
382
610
  else {
383
611
  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`;
612
+ // Navi Discussion turns carry their own navi_id/navi_name (each
613
+ // participant speaks under their own persona). Fall back to the
614
+ // session-level Navi for regular assistant turns.
615
+ const perTurnName = m.navi_name;
616
+ const speakerNavi = perTurnName || navi?.name || 'assistant';
617
+ const speaker = m.role === 'user' ? 'user' : speakerNavi;
618
+ const tag = m.message_type === 'discussion' ? ' _(discussion)_' : '';
619
+ out += `\n**${speaker}**${tag} · ${m.created_at}\n${m.content}\n`;
386
620
  }
387
621
  }
388
622
  if (hasMore) {
@@ -457,41 +691,11 @@ export function registerTools(server, services) {
457
691
  });
458
692
  // Register list_roots tool
459
693
  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
- }
694
+ // Single-vault (M3): render the vault + any active roots with this
695
+ // machine's resolved base, plus the archived legacy-translation table.
696
+ const ctx = await client.getResolverContext();
493
697
  return {
494
- content: [{ type: 'text', text }]
698
+ content: [{ type: 'text', text: formatRootsList(ctx) }]
495
699
  };
496
700
  });
497
701
  // Register add_root tool
@@ -799,7 +1003,7 @@ export function registerTools(server, services) {
799
1003
  };
800
1004
  });
801
1005
  // 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.', {
1006
+ 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 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. A path is inside a Noesis root if it contains `/.noesis/`, sits under a folder named `Noesis` (the cloud root — `~/Noesis/…`, `%USERPROFILE%\\Noesis\\…`, `C:\\Users\\<you>\\Noesis\\…`), or is under any directory in `list_roots`; absolute paths work even when the cloud stored the root in tilde form. 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
1007
  root: z.string().optional().describe('Sync only a specific root folder (by name)'),
804
1008
  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
1009
  dryRun: z.boolean().optional().describe('Preview changes without syncing (default: false)'),
@@ -879,7 +1083,7 @@ export function registerTools(server, services) {
879
1083
  }
880
1084
  // Handle specific files sync mode (push-only for specific files)
881
1085
  if (files && files.length > 0) {
882
- return await syncSpecificFiles(files, roots, dryRun, client, force, regenerateMetadata);
1086
+ return await syncSpecificFiles(files, dryRun, client, force, regenerateMetadata);
883
1087
  }
884
1088
  // Filter to specific root if requested
885
1089
  const rootsToSync = root
@@ -904,12 +1108,28 @@ export function registerTools(server, services) {
904
1108
  };
905
1109
  // Track files moved to .noesis folder
906
1110
  const allMovedToNoesis = [];
1111
+ // H4 (F4): the vault IS the notes home, so the "move new files to a
1112
+ // .noesis subfolder" concept is meaningless there — worse, moving files
1113
+ // into dot-folders makes them invisible to future full scans (the
1114
+ // scanner skips dot-dirs) and thus silently clobberable. Identify the
1115
+ // vault so the move-prompt + move-block are skipped for it.
1116
+ const vaultRootId = (await client.getResolverContext()).vaultRootId;
907
1117
  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}`);
1118
+ const isVault = vaultRootId != null && syncRoot.id === vaultRootId;
1119
+ // Fresh-machine bootstrap (M3): a root with a known local path that
1120
+ // doesn't exist on disk yet (e.g. ~/Noesis on a machine that has never
1121
+ // synced) is CREATED, not error-skipped — the sync below then pulls
1122
+ // the cloud notes into it. Roots with NO path for this OS still skip.
1123
+ if (!syncRoot.path || /%[^%/\\]+%/.test(syncRoot.path)) {
1124
+ result.errors.push(!syncRoot.path
1125
+ ? `Root '${syncRoot.name}' has no local path configured for this OS (${CLIENT_OS}) — skipped.`
1126
+ : `Root '${syncRoot.name}' has an unexpanded template token in its path (${syncRoot.path}) — fix it in the Noesis Dashboard; skipped.`);
911
1127
  continue;
912
1128
  }
1129
+ if (!fs.existsSync(syncRoot.path)) {
1130
+ fs.mkdirSync(syncRoot.path, { recursive: true });
1131
+ result.details.push({ file: syncRoot.path, action: 'pulled_create', reason: `created local root directory for '${syncRoot.name}' (fresh machine bootstrap)` });
1132
+ }
913
1133
  // Load sync state for three-way hash comparison
914
1134
  const stateMgr = new SyncStateManager(syncRoot.path);
915
1135
  stateMgr.load();
@@ -952,8 +1172,11 @@ export function registerTools(server, services) {
952
1172
  }
953
1173
  }
954
1174
  }
955
- // If LOCAL ONLY files found and moveNewToNoesis not set, return prompt
956
- if (localOnlyFiles.length > 0 && !moveNewToNoesis && !dryRun) {
1175
+ // If LOCAL ONLY files found and moveNewToNoesis not set, return prompt.
1176
+ // H4: NEVER prompt for the vault — new files there just push-create
1177
+ // (falling through to the loop below); moving them into .noesis would
1178
+ // trap them under a dot-dir the scanner can't see.
1179
+ if (localOnlyFiles.length > 0 && !moveNewToNoesis && !dryRun && !isVault) {
957
1180
  return {
958
1181
  content: [{
959
1182
  type: 'text',
@@ -967,9 +1190,10 @@ export function registerTools(server, services) {
967
1190
  }]
968
1191
  };
969
1192
  }
970
- // If moveNewToNoesis is true and there are LOCAL ONLY files, move them to .noesis
1193
+ // If moveNewToNoesis is true and there are LOCAL ONLY files, move them
1194
+ // to .noesis — but NEVER inside the vault (H4: the dot-folder trap).
971
1195
  const movedToNoesisFiles = [];
972
- if (moveNewToNoesis && localOnlyFiles.length > 0 && !dryRun) {
1196
+ if (moveNewToNoesis && !isVault && localOnlyFiles.length > 0 && !dryRun) {
973
1197
  for (const file of localOnlyFiles) {
974
1198
  const oldPath = file.path;
975
1199
  const fileName = path.basename(oldPath);
@@ -1034,14 +1258,28 @@ export function registerTools(server, services) {
1034
1258
  }
1035
1259
  }
1036
1260
  else if (!localFile && cloudNote) {
1037
- // CLOUD ONLY: Pull to local (create)
1038
- if (dryRun) {
1261
+ // CLOUD ONLY (per the local SCAN) — but the scanner skips
1262
+ // dot-directories, so a note whose rel contains `.noesis/` can be
1263
+ // absent from localMap while a file DOES exist on disk. H4 (F4):
1264
+ // never blind-overwrite such a file. Check the real disk state
1265
+ // first; only a genuinely-absent file is a pure cloud-create.
1266
+ const localPath = path.join(syncRoot.path, relativePath);
1267
+ const existsOnDisk = fs.existsSync(localPath);
1268
+ const diskContent = existsOnDisk ? fs.readFileSync(localPath, 'utf-8') : null;
1269
+ const diskDiffers = diskContent != null && NoesisClient.computeHash(diskContent) !== cloudNote.hash;
1270
+ if (existsOnDisk && diskDiffers) {
1271
+ // Real local edit the scan couldn't see. Preserve it — record a
1272
+ // conflict instead of clobbering; the user resolves via a
1273
+ // per-file sync (the resolver reaches dot-rel paths).
1274
+ result.conflicts.push({ path: relativePath, localModified: '', cloudModified: cloudNote.modified_at });
1275
+ result.details.push({ file: relativePath, action: 'conflict', reason: 'scan-hidden local edit preserved (under a dot-folder) — sync this file directly to resolve' });
1276
+ }
1277
+ else if (dryRun) {
1039
1278
  result.details.push({ file: relativePath, action: 'pulled_create' });
1040
1279
  result.pulled.created++;
1041
1280
  }
1042
1281
  else {
1043
- // Write file locally
1044
- const localPath = path.join(syncRoot.path, relativePath);
1282
+ // Genuinely absent (or byte-identical on disk): safe to write.
1045
1283
  const localDir = path.dirname(localPath);
1046
1284
  if (!fs.existsSync(localDir)) {
1047
1285
  fs.mkdirSync(localDir, { recursive: true });
@@ -1057,8 +1295,13 @@ export function registerTools(server, services) {
1057
1295
  }
1058
1296
  else if (localFile && cloudNote) {
1059
1297
  // BOTH EXIST: Compare hashes
1060
- if (localFile.hash === cloudNote.hash) {
1061
- // Same hash: seed baseline and check for metadata enrichment
1298
+ if (localFile.hash === cloudNote.hash &&
1299
+ cloudNote.content != null &&
1300
+ NoesisClient.computeHash(cloudNote.content) === cloudNote.hash) {
1301
+ // Same hash AND cloud internally consistent: seed baseline + metadata enrichment.
1302
+ // The computeHash(cloudNote.content) === cloudNote.hash guard defends against a
1303
+ // "hash-ahead-of-content" divergence (cloud hash matches local but cloud content
1304
+ // is stale); when detected we fall through to the push/three-way branch below.
1062
1305
  if (!dryRun && localFile.hash) {
1063
1306
  stateMgr.setBaseline(relativePath, { hash: localFile.hash, lastSyncedAt: cloudNote.modified_at }, localFile.content);
1064
1307
  }
@@ -1098,7 +1341,10 @@ export function registerTools(server, services) {
1098
1341
  const baselineLastSyncedAt = baselineMeta?.lastSyncedAt;
1099
1342
  const localMtime = localFile.mtime ? localFile.mtime.getTime() : Date.now();
1100
1343
  const cloudMtime = new Date(cloudNote.modified_at).getTime();
1101
- const direction = force
1344
+ // Push when forced, OR when we reached this branch with equal hashes (only
1345
+ // possible via the divergence fall-through above) — determineSyncDirection
1346
+ // would see equal hashes and skip, so override to repair the stale cloud content.
1347
+ const direction = (force || localFile.hash === cloudNote.hash)
1102
1348
  ? 'push'
1103
1349
  : determineSyncDirection(localFile.hash, cloudNote.hash, baselineHash, localMtime, cloudMtime, cloudNote.edited_online_at, baselineLastSyncedAt);
1104
1350
  if (direction === 'conflict') {
@@ -1144,20 +1390,27 @@ export function registerTools(server, services) {
1144
1390
  keywords: cloudKw.length > 0 ? cloudKw : undefined
1145
1391
  })
1146
1392
  : 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++;
1393
+ // Safety net: never silently destroy content on merge (see per-file branch).
1394
+ if (enrichedContent.length < localFile.content.length * 0.5 && localFile.content.length > 2048) {
1395
+ const lost = Math.round(100 * (1 - enrichedContent.length / localFile.content.length));
1396
+ result.errors.push(`Skipped push for ${relativePath}: merge would drop ${lost}% of content (${localFile.content.length}->${enrichedContent.length} bytes). Left unchanged to avoid data loss.`);
1397
+ }
1398
+ else {
1399
+ const enrichedHash = NoesisClient.computeHash(enrichedContent);
1400
+ const mergedFile = {
1401
+ ...localFile,
1402
+ content: enrichedContent,
1403
+ hash: enrichedHash
1404
+ };
1405
+ // preserveMetadata=true: keep cloud's AI-generated title/description/keywords
1406
+ await client.upsertNote(mergedFile, metadata, { force: true, regenerateMetadata, preserveMetadata: true });
1407
+ // Write enriched content back to local file
1408
+ const localPath = path.join(syncRoot.path, relativePath);
1409
+ fs.writeFileSync(localPath, enrichedContent, 'utf-8');
1410
+ stateMgr.setBaseline(relativePath, { hash: enrichedHash, lastSyncedAt: new Date().toISOString() }, enrichedContent);
1411
+ result.details.push({ file: relativePath, action: 'pushed_update', reason: 'merged' });
1412
+ result.pushed.updated++;
1413
+ }
1161
1414
  }
1162
1415
  }
1163
1416
  else if (direction === 'pull') {
@@ -1734,10 +1987,14 @@ export function registerTools(server, services) {
1734
1987
  isError: true
1735
1988
  };
1736
1989
  }
1737
- // 3. If move_file, physically move the file on disk
1990
+ // 3. If move_file, physically move the file on disk.
1991
+ // H5: expandHome first — oldPath is the server-synthesized file_path,
1992
+ // which for the vault is tilde-form (~/Noesis/...), and new_path may be
1993
+ // tilde too. normalizePath does NOT expand ~, so without this the mkdir/
1994
+ // rename would create a literal '~' directory under the process cwd.
1738
1995
  if (move_file) {
1739
- const normalizedOldPath = normalizePath(oldPath);
1740
- const normalizedNewPath = normalizePath(new_path);
1996
+ const normalizedOldPath = normalizePath(expandHome(oldPath));
1997
+ const normalizedNewPath = normalizePath(expandHome(new_path));
1741
1998
  try {
1742
1999
  // Create destination directory
1743
2000
  fs.mkdirSync(path.dirname(normalizedNewPath), { recursive: true });
@@ -1767,9 +2024,11 @@ export function registerTools(server, services) {
1767
2024
  // 4. Update sync state if applicable
1768
2025
  try {
1769
2026
  const roots = await client.getRootsForSync();
1770
- // Remove baseline from old root
2027
+ // Remove baseline from old root (expandHome so a tilde-form oldPath
2028
+ // matches an expanded root path — H5).
2029
+ const expandedOld = normalizePath(expandHome(oldPath));
1771
2030
  const oldRoot = roots.find(r => {
1772
- return normalizePath(oldPath).startsWith(normalizePath(r.path));
2031
+ return expandedOld.startsWith(normalizePath(expandHome(r.path)));
1773
2032
  });
1774
2033
  if (oldRoot && oldRelativePath) {
1775
2034
  const oldMgr = new SyncStateManager(oldRoot.path);
@@ -1782,8 +2041,8 @@ export function registerTools(server, services) {
1782
2041
  if (newRoot && moveResult.relative_path) {
1783
2042
  const newMgr = new SyncStateManager(newRoot.path);
1784
2043
  newMgr.load();
1785
- // Compute hash from file content if available
1786
- const filePath = normalizePath(new_path);
2044
+ // Compute hash from file content if available (expandHome for the vault tilde path — H5)
2045
+ const filePath = normalizePath(expandHome(new_path));
1787
2046
  if (fs.existsSync(filePath)) {
1788
2047
  const content = fs.readFileSync(filePath, 'utf-8');
1789
2048
  const hash = NoesisClient.computeHash(content);
@@ -2450,12 +2709,26 @@ function scanMarkdownFiles(rootPath, rootId, rootName, detectProjects = false) {
2450
2709
  scan(rootPath);
2451
2710
  return files;
2452
2711
  }
2712
+ /** Adapt a by-relative Note (+ its hash from the root hash map) into CloudNoteRow. */
2713
+ export function adaptCloudNote(n, relativePath, hash) {
2714
+ return {
2715
+ id: n.id,
2716
+ relative_path: n.relative_path ?? relativePath,
2717
+ hash,
2718
+ modified_at: n.modified_at ?? new Date(0).toISOString(),
2719
+ content: n.content ?? '',
2720
+ title: n.title ?? null,
2721
+ description: n.description ?? null,
2722
+ keywords: n.keywords ?? null,
2723
+ edited_online_at: n.edited_online_at ?? null,
2724
+ };
2725
+ }
2453
2726
  /**
2454
2727
  * Sync specific files by path (single-file or multi-file sync mode)
2455
2728
  * @param force - If true, bypass hash check and always re-sync (useful for metadata regeneration)
2456
2729
  * @param regenerateMetadata - If true, backend AI regenerates all metadata fields, overwriting existing values
2457
2730
  */
2458
- async function syncSpecificFiles(filePaths, roots, dryRun, client, force = false, regenerateMetadata = false) {
2731
+ export async function syncSpecificFiles(filePaths, dryRun, client, force = false, regenerateMetadata = false) {
2459
2732
  // Use bidirectional sync result type
2460
2733
  const result = {
2461
2734
  pushed: { created: 0, updated: 0 },
@@ -2469,61 +2742,93 @@ async function syncSpecificFiles(filePaths, roots, dryRun, client, force = false
2469
2742
  const warnings = [];
2470
2743
  // Cache SyncStateManagers per root path
2471
2744
  const syncStateCache = new Map();
2472
- // Cache cloud notes per root to avoid multiple API calls
2473
- const cloudNotesCache = new Map();
2745
+ // H3a: hash-first delta sync. Post-migration one root (the vault) holds the
2746
+ // whole corpus (~50MB+), so priming full content per root — the old
2747
+ // getNotesForSync approach — cost the entire corpus on every per-file sync.
2748
+ // Instead cache a lightweight relative_path→hash map per root (KB), and
2749
+ // fetch a single note's full content ONLY for the file being synced, only
2750
+ // when it actually exists in the cloud.
2751
+ const rootHashCache = new Map();
2752
+ const cloudNoteCache = new Map(); // key: `${rootId}::${rel}`
2753
+ // Unified reference resolution (M2): entries may be note URLs, noesis://
2754
+ // ids, bare ids, or paths of ANY OS's shape — including legacy
2755
+ // pre-migration paths translated through archived roots. Materialization
2756
+ // ALWAYS targets the resolver-derived local path for this machine (never
2757
+ // the caller-typed string), which also retires the old empty-root-path →
2758
+ // cwd footgun and the doubled-segment auto-correction heuristic.
2759
+ const resolverCtx = await client.getResolverContext();
2474
2760
  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
- }
2761
+ const ref = parseNoteReference(filePath);
2762
+ let targetRootId = null;
2763
+ let candidates = [];
2764
+ if (ref.kind === 'id') {
2765
+ const idNote = await client.getNote(ref.id).catch(() => undefined);
2766
+ const rel = idNote?.relative_path;
2767
+ const rootId = idNote?.root_id;
2768
+ if (!idNote || rootId == null || !rel) {
2769
+ result.errors.push(`Note not found for id reference: ${filePath}`);
2770
+ result.details.push({ file: filePath, action: 'error', reason: 'Note id not found' });
2771
+ continue;
2497
2772
  }
2773
+ targetRootId = rootId;
2774
+ candidates = [normalizePath(rel)];
2775
+ }
2776
+ else {
2777
+ const resolved = resolvePathReference(resolverCtx, ref.path);
2778
+ if (resolved) {
2779
+ targetRootId = resolved.rootId;
2780
+ candidates = resolved.candidates.map(c => normalizePath(c));
2781
+ }
2782
+ }
2783
+ if (targetRootId === null || candidates.length === 0) {
2784
+ result.errors.push(`Path '${filePath}' is not inside any registered Noesis root (active or archived). Check list_roots, or reference the note by its URL or id instead.`);
2785
+ result.details.push({ file: filePath, action: 'error', reason: 'Not in configured root' });
2786
+ continue;
2787
+ }
2788
+ const ctxRoot = resolverCtx.roots.find(r => r.id === targetRootId);
2789
+ const rootBase = rootLocalBase(resolverCtx, targetRootId);
2790
+ if (!ctxRoot || !rootBase) {
2791
+ result.errors.push(`Root ${targetRootId} has no usable path on this machine (${resolverCtx.clientOs}) — cannot materialize '${filePath}'.`);
2792
+ result.details.push({ file: filePath, action: 'error', reason: 'No local path for root on this OS' });
2793
+ continue;
2498
2794
  }
2499
- // Check if it's a markdown file (before existence check so cloud-only pulls also validate)
2500
- if (!normalizedPath.endsWith('.md')) {
2795
+ const matchingRoot = { id: ctxRoot.id, name: ctxRoot.name, path: rootBase };
2796
+ // H3a: fetch the root's lightweight relative_path→hash map (KB), pick the
2797
+ // candidate present in it (collapsed form first; local-only pushes use the
2798
+ // first), then fetch that ONE note's full content lazily — never the whole
2799
+ // corpus. cloudNote===null means the note doesn't exist in the cloud.
2800
+ if (!rootHashCache.has(matchingRoot.id)) {
2801
+ const raw = await client.getNoteHashesByRoot(matchingRoot.id);
2802
+ // Normalize keys the same way candidates are (forward-slash), matching
2803
+ // the pre-H3a cloud-map keying so lookups stay byte-identical.
2804
+ const norm = new Map();
2805
+ for (const [rel, h] of raw)
2806
+ norm.set(normalizePath(rel), h);
2807
+ rootHashCache.set(matchingRoot.id, norm);
2808
+ }
2809
+ const rootHashes = rootHashCache.get(matchingRoot.id);
2810
+ const relativePath = candidates.find(c => rootHashes.has(c)) ?? candidates[0];
2811
+ const normalizedPath = `${matchingRoot.path}/${relativePath}`;
2812
+ // Markdown-only (before existence check so cloud-only pulls also validate)
2813
+ if (!relativePath.endsWith('.md')) {
2501
2814
  result.errors.push(`Not a markdown file: ${filePath}`);
2502
2815
  result.details.push({ file: filePath, action: 'error', reason: 'Not a markdown file' });
2503
2816
  continue;
2504
2817
  }
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;
2818
+ // Lazily materialize the single cloud note (content + metadata) only when
2819
+ // the candidate exists in the hash map — the delta-sync payload win.
2820
+ const cloudHash = rootHashes.get(relativePath);
2821
+ let cloudNote = null;
2822
+ if (cloudHash) {
2823
+ const cnKey = `${matchingRoot.id}::${relativePath}`;
2824
+ if (!cloudNoteCache.has(cnKey)) {
2825
+ const n = await client.getNoteByRelativePath(matchingRoot.id, relativePath);
2826
+ cloudNoteCache.set(cnKey, n ? adaptCloudNote(n, relativePath, cloudHash) : null);
2512
2827
  }
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);
2828
+ cloudNote = cloudNoteCache.get(cnKey);
2829
+ }
2830
+ // Missing locally — pull from cloud to the VAULT-DERIVED path
2831
+ if (!fs.existsSync(normalizedPath)) {
2527
2832
  if (cloudNote) {
2528
2833
  // CLOUD ONLY: Pull to local (create)
2529
2834
  if (dryRun) {
@@ -2531,13 +2836,17 @@ async function syncSpecificFiles(filePaths, roots, dryRun, client, force = false
2531
2836
  result.pulled.created++;
2532
2837
  }
2533
2838
  else {
2839
+ const rootDirExisted = fs.existsSync(matchingRoot.path);
2534
2840
  const localDir = path.dirname(normalizedPath);
2535
2841
  if (!fs.existsSync(localDir)) {
2536
2842
  fs.mkdirSync(localDir, { recursive: true });
2537
2843
  }
2844
+ if (!rootDirExisted) {
2845
+ warnings.push(`Created local vault/root directory at ${matchingRoot.path} (fresh machine bootstrap).`);
2846
+ }
2538
2847
  fs.writeFileSync(normalizedPath, cloudNote.content, 'utf-8');
2539
2848
  const stats = fs.statSync(normalizedPath);
2540
- await client.updateFileMetadata(normalizedPath, stats.size, cloudNote.hash);
2849
+ await client.updateFileMetadata({ rootId: matchingRoot.id, relativePath }, stats.size, cloudNote.hash);
2541
2850
  // Set baseline
2542
2851
  if (!syncStateCache.has(matchingRoot.path)) {
2543
2852
  const mgr = new SyncStateManager(matchingRoot.path);
@@ -2551,38 +2860,19 @@ async function syncSpecificFiles(filePaths, roots, dryRun, client, force = false
2551
2860
  }
2552
2861
  }
2553
2862
  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.`);
2863
+ result.errors.push(`'${filePath}' resolved to root '${matchingRoot.name}' but no note exists at '${candidates.join("' or '")}' in the cloud, and there is no local file at ${normalizedPath}. This is a genuinely missing note — try search_notes or list_notes.`);
2555
2864
  result.details.push({ file: filePath, action: 'error', reason: 'Not found locally or in cloud' });
2556
2865
  }
2557
2866
  continue;
2558
2867
  }
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
2868
  try {
2567
- // Read local file content
2869
+ // Read local file content. relativePath + cloudNote were resolved above
2870
+ // (H3a) — the outer relativePath equals path.relative(root, normalizedPath)
2871
+ // by construction, and cloudNote is the single fetched note (or null).
2568
2872
  const localContent = fs.readFileSync(normalizedPath, 'utf-8');
2569
2873
  const stats = fs.statSync(normalizedPath);
2570
- const relativePath = normalizePath(path.relative(matchingRoot.path, normalizedPath));
2571
2874
  const localHash = NoesisClient.computeHash(localContent);
2572
2875
  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
2876
  // Get or create SyncStateManager for this root
2587
2877
  if (!syncStateCache.has(matchingRoot.path)) {
2588
2878
  const mgr = new SyncStateManager(matchingRoot.path);
@@ -2618,8 +2908,17 @@ async function syncSpecificFiles(filePaths, roots, dryRun, client, force = false
2618
2908
  affectedRoots.add(matchingRoot.id);
2619
2909
  }
2620
2910
  }
2621
- else if (localHash === cloudNote.hash) {
2622
- // SAME CONTENT HASH: Check if metadata differs
2911
+ else if (localHash === cloudNote.hash &&
2912
+ cloudNote.content != null &&
2913
+ NoesisClient.computeHash(cloudNote.content) === cloudNote.hash) {
2914
+ // SAME CONTENT HASH (and the cloud is internally consistent): metadata-only diff.
2915
+ // The computeHash(cloudNote.content) === cloudNote.hash guard defends against a
2916
+ // "hash-ahead-of-content" divergence — a cloud row whose stored hash matches local
2917
+ // but whose stored *content* is stale. When detected, we fall through to the push
2918
+ // branch below (which force-pushes on equal hashes) instead of trusting hash-equality
2919
+ // and skipping, so a diverged note can always be repaired. (force is intentionally NOT
2920
+ // part of this gate: a forced re-sync of genuinely-identical content should keep the
2921
+ // original same-hash/metadata behavior, not re-upload the whole body.) Metadata diff:
2623
2922
  const cloudKeywords = parseCloudKeywords(cloudNote.keywords);
2624
2923
  const metadataDiffers = (cloudNote.title && cloudNote.title !== localMetadata.title) ||
2625
2924
  (cloudNote.description && cloudNote.description !== localMetadata.description) ||
@@ -2695,7 +2994,11 @@ async function syncSpecificFiles(filePaths, roots, dryRun, client, force = false
2695
2994
  const baselineHash = baselineMeta?.hash;
2696
2995
  const baselineLastSyncedAt = baselineMeta?.lastSyncedAt;
2697
2996
  const cloudMtime = new Date(cloudNote.modified_at).getTime();
2698
- const direction = force
2997
+ // Push when forced, OR when we reached this branch with matching hashes — which now
2998
+ // only happens via the divergence/force fall-through above (a cloud row whose hash
2999
+ // matches local but whose content is stale). determineSyncDirection would see equal
3000
+ // hashes and return 'skip', so override it to repair the stale cloud content.
3001
+ const direction = (force || localHash === cloudNote.hash)
2699
3002
  ? 'push'
2700
3003
  : determineSyncDirection(localHash, cloudNote.hash, baselineHash, localMtime, cloudMtime, cloudNote.edited_online_at, baselineLastSyncedAt);
2701
3004
  if (direction === 'conflict') {
@@ -2755,26 +3058,35 @@ async function syncSpecificFiles(filePaths, roots, dryRun, client, force = false
2755
3058
  keywords: cloudKw.length > 0 ? cloudKw : undefined
2756
3059
  })
2757
3060
  : 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);
3061
+ // Safety net: a merge must never silently destroy content. If the merged
3062
+ // result lost most of the local body, abort this file's push instead of
3063
+ // overwriting local + cloud with a truncated note.
3064
+ if (enrichedContent.length < localContent.length * 0.5 && localContent.length > 2048) {
3065
+ const lost = Math.round(100 * (1 - enrichedContent.length / localContent.length));
3066
+ result.errors.push(`Skipped push for ${relativePath}: merge would drop ${lost}% of content (${localContent.length}->${enrichedContent.length} bytes). Left unchanged to avoid data loss.`);
3067
+ }
3068
+ else {
3069
+ const enrichedHash = NoesisClient.computeHash(enrichedContent);
3070
+ const localFile = {
3071
+ path: normalizedPath,
3072
+ relativePath,
3073
+ content: enrichedContent,
3074
+ hash: enrichedHash,
3075
+ mtime: stats.mtime,
3076
+ size: stats.size,
3077
+ rootId: matchingRoot.id,
3078
+ rootName: matchingRoot.name,
3079
+ project
3080
+ };
3081
+ // preserveMetadata=true: keep cloud's AI-generated title/description/keywords
3082
+ await client.upsertNote(localFile, localMetadata, { force: true, regenerateMetadata, preserveMetadata: true });
3083
+ // Write enriched content back to local file
3084
+ fs.writeFileSync(normalizedPath, enrichedContent, 'utf-8');
3085
+ stateMgr.setBaseline(relativePath, { hash: enrichedHash, lastSyncedAt: new Date().toISOString() }, enrichedContent);
3086
+ result.details.push({ file: relativePath, action: 'pushed_update', reason: 'merged' });
3087
+ result.pushed.updated++;
3088
+ affectedRoots.add(matchingRoot.id);
3089
+ }
2778
3090
  }
2779
3091
  }
2780
3092
  else if (direction === 'pull') {
@@ -2889,22 +3201,36 @@ async function syncSpecificFiles(filePaths, roots, dryRun, client, force = false
2889
3201
  }]
2890
3202
  };
2891
3203
  }
3204
+ /**
3205
+ * Split leading YAML frontmatter from a markdown document.
3206
+ *
3207
+ * The opening `---` must be the very first line and the closing `---` must be on its
3208
+ * own line (line-anchored). This deliberately does NOT use `indexOf('---', 3)`: a bare
3209
+ * substring search matches a `---` inside a frontmatter value, a `---`/`----` thematic
3210
+ * break in the body, or a `---` inside a code fence — which previously caused notes to
3211
+ * be truncated / their frontmatter duplicated on sync.
3212
+ *
3213
+ * @returns raw = whole matched block incl. fences + trailing newline; inner = the YAML
3214
+ * between the fences; body = everything after the block. If there is no valid
3215
+ * leading frontmatter, raw/inner are '' and body is the full content.
3216
+ */
3217
+ export function splitFrontmatter(content) {
3218
+ const m = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/);
3219
+ if (!m)
3220
+ return { raw: '', inner: '', body: content };
3221
+ return { raw: m[0], inner: m[1], body: content.slice(m[0].length) };
3222
+ }
2892
3223
  /**
2893
3224
  * Parse YAML frontmatter from markdown content using js-yaml
2894
3225
  */
2895
3226
  function parseYamlFrontmatter(content) {
2896
3227
  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) {
3228
+ const { inner } = splitFrontmatter(content);
3229
+ if (!inner) {
2903
3230
  return result;
2904
3231
  }
2905
- const frontmatterStr = content.substring(3, endIndex).trim();
2906
3232
  try {
2907
- const parsed = yaml.load(frontmatterStr);
3233
+ const parsed = yaml.load(inner);
2908
3234
  if (!parsed || typeof parsed !== 'object') {
2909
3235
  return result;
2910
3236
  }
@@ -2952,9 +3278,14 @@ function parseCloudKeywords(keywords) {
2952
3278
  * Update frontmatter in file content with new metadata values
2953
3279
  * Preserves existing frontmatter structure and only updates specified fields
2954
3280
  */
2955
- function updateFrontmatter(content, updates) {
2956
- // If no frontmatter exists, create one
2957
- if (!content.startsWith('---')) {
3281
+ export function updateFrontmatter(content, updates) {
3282
+ const { inner, body } = splitFrontmatter(content);
3283
+ // If no (valid) frontmatter exists, create one
3284
+ if (!inner) {
3285
+ if (content.startsWith('---')) {
3286
+ // Opens with a fence but has no valid closing fence: don't risk mangling it.
3287
+ return content;
3288
+ }
2958
3289
  const frontmatterLines = ['---'];
2959
3290
  if (updates.title)
2960
3291
  frontmatterLines.push(`title: ${updates.title}`);
@@ -2969,13 +3300,8 @@ function updateFrontmatter(content, updates) {
2969
3300
  frontmatterLines.push('---');
2970
3301
  return frontmatterLines.join('\n') + '\n' + content;
2971
3302
  }
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);
3303
+ const frontmatter = inner;
3304
+ const bodyContent = body;
2979
3305
  const lines = frontmatter.split(/\r?\n/);
2980
3306
  const newLines = [];
2981
3307
  const updatedKeys = new Set();
@@ -3038,31 +3364,27 @@ function updateFrontmatter(content, updates) {
3038
3364
  newLines.push(` - ${kw}`);
3039
3365
  }
3040
3366
  }
3041
- return '---\n' + newLines.filter(l => l.trim() !== '').join('\n') + '\n---' + bodyContent;
3367
+ return '---\n' + newLines.filter(l => l.trim() !== '').join('\n') + '\n---\n' + bodyContent;
3042
3368
  }
3043
3369
  /**
3044
3370
  * Parse markdown content into structure: frontmatter, H1 heading, and body
3045
3371
  */
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);
3372
+ export function parseMarkdownStructure(content) {
3373
+ // Extract YAML frontmatter (line-anchored; see splitFrontmatter).
3374
+ const { raw, body: afterFrontmatter } = splitFrontmatter(content);
3375
+ const frontmatter = raw ? raw.replace(/\r?\n$/, '') : ''; // keep fences; reconstruct re-adds the trailing newline
3376
+ const remaining = afterFrontmatter;
3377
+ // Extract the note's H1 heading — ONLY when it is the first non-blank line of the
3378
+ // body. A non-anchored /m search would match a `# ...` line inside a fenced code
3379
+ // block (e.g. a shell comment) and set body to everything after it, silently
3380
+ // dropping the whole note above it. Anchoring to the start keeps all content.
3381
+ const h1Match = remaining.match(/^\s*(#[ \t]+.+?)(\r?\n|$)/);
3059
3382
  let h1 = null;
3060
3383
  let body = remaining;
3061
3384
  if (h1Match) {
3062
3385
  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);
3386
+ // Everything after the matched leading H1 line becomes the body.
3387
+ body = remaining.substring(h1Match[0].length);
3066
3388
  }
3067
3389
  return { frontmatter, h1, body };
3068
3390
  }
@@ -3332,7 +3654,7 @@ async function runConflictCascade(opts) {
3332
3654
  * Merge local content with cloud content
3333
3655
  * Strategy: Cloud's H1 wins (Noesis edits), Local's body wins (local edits)
3334
3656
  */
3335
- function mergeContent(localContent, cloudContent) {
3657
+ export function mergeContent(localContent, cloudContent) {
3336
3658
  const localParsed = parseMarkdownStructure(localContent);
3337
3659
  const cloudParsed = parseMarkdownStructure(cloudContent);
3338
3660
  // Use cloud's H1 if it exists and differs from local, otherwise use local's H1