@noesis-brain/mcp-server 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +91 -3
  2. package/dist/agent/runner.d.ts +25 -0
  3. package/dist/agent/runner.d.ts.map +1 -0
  4. package/dist/agent/runner.js +218 -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 +157 -0
  26. package/dist/tools/backlog.js.map +1 -0
  27. package/dist/tools/index.d.ts +52 -1
  28. package/dist/tools/index.d.ts.map +1 -1
  29. package/dist/tools/index.js +433 -137
  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 +907 -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') {
@@ -1734,10 +1980,14 @@ export function registerTools(server, services) {
1734
1980
  isError: true
1735
1981
  };
1736
1982
  }
1737
- // 3. If move_file, physically move the file on disk
1983
+ // 3. If move_file, physically move the file on disk.
1984
+ // H5: expandHome first — oldPath is the server-synthesized file_path,
1985
+ // which for the vault is tilde-form (~/Noesis/...), and new_path may be
1986
+ // tilde too. normalizePath does NOT expand ~, so without this the mkdir/
1987
+ // rename would create a literal '~' directory under the process cwd.
1738
1988
  if (move_file) {
1739
- const normalizedOldPath = normalizePath(oldPath);
1740
- const normalizedNewPath = normalizePath(new_path);
1989
+ const normalizedOldPath = normalizePath(expandHome(oldPath));
1990
+ const normalizedNewPath = normalizePath(expandHome(new_path));
1741
1991
  try {
1742
1992
  // Create destination directory
1743
1993
  fs.mkdirSync(path.dirname(normalizedNewPath), { recursive: true });
@@ -1767,9 +2017,11 @@ export function registerTools(server, services) {
1767
2017
  // 4. Update sync state if applicable
1768
2018
  try {
1769
2019
  const roots = await client.getRootsForSync();
1770
- // Remove baseline from old root
2020
+ // Remove baseline from old root (expandHome so a tilde-form oldPath
2021
+ // matches an expanded root path — H5).
2022
+ const expandedOld = normalizePath(expandHome(oldPath));
1771
2023
  const oldRoot = roots.find(r => {
1772
- return normalizePath(oldPath).startsWith(normalizePath(r.path));
2024
+ return expandedOld.startsWith(normalizePath(expandHome(r.path)));
1773
2025
  });
1774
2026
  if (oldRoot && oldRelativePath) {
1775
2027
  const oldMgr = new SyncStateManager(oldRoot.path);
@@ -1782,8 +2034,8 @@ export function registerTools(server, services) {
1782
2034
  if (newRoot && moveResult.relative_path) {
1783
2035
  const newMgr = new SyncStateManager(newRoot.path);
1784
2036
  newMgr.load();
1785
- // Compute hash from file content if available
1786
- const filePath = normalizePath(new_path);
2037
+ // Compute hash from file content if available (expandHome for the vault tilde path — H5)
2038
+ const filePath = normalizePath(expandHome(new_path));
1787
2039
  if (fs.existsSync(filePath)) {
1788
2040
  const content = fs.readFileSync(filePath, 'utf-8');
1789
2041
  const hash = NoesisClient.computeHash(content);
@@ -2450,12 +2702,26 @@ function scanMarkdownFiles(rootPath, rootId, rootName, detectProjects = false) {
2450
2702
  scan(rootPath);
2451
2703
  return files;
2452
2704
  }
2705
+ /** Adapt a by-relative Note (+ its hash from the root hash map) into CloudNoteRow. */
2706
+ export function adaptCloudNote(n, relativePath, hash) {
2707
+ return {
2708
+ id: n.id,
2709
+ relative_path: n.relative_path ?? relativePath,
2710
+ hash,
2711
+ modified_at: n.modified_at ?? new Date(0).toISOString(),
2712
+ content: n.content ?? '',
2713
+ title: n.title ?? null,
2714
+ description: n.description ?? null,
2715
+ keywords: n.keywords ?? null,
2716
+ edited_online_at: n.edited_online_at ?? null,
2717
+ };
2718
+ }
2453
2719
  /**
2454
2720
  * Sync specific files by path (single-file or multi-file sync mode)
2455
2721
  * @param force - If true, bypass hash check and always re-sync (useful for metadata regeneration)
2456
2722
  * @param regenerateMetadata - If true, backend AI regenerates all metadata fields, overwriting existing values
2457
2723
  */
2458
- async function syncSpecificFiles(filePaths, roots, dryRun, client, force = false, regenerateMetadata = false) {
2724
+ export async function syncSpecificFiles(filePaths, dryRun, client, force = false, regenerateMetadata = false) {
2459
2725
  // Use bidirectional sync result type
2460
2726
  const result = {
2461
2727
  pushed: { created: 0, updated: 0 },
@@ -2469,61 +2735,93 @@ async function syncSpecificFiles(filePaths, roots, dryRun, client, force = false
2469
2735
  const warnings = [];
2470
2736
  // Cache SyncStateManagers per root path
2471
2737
  const syncStateCache = new Map();
2472
- // Cache cloud notes per root to avoid multiple API calls
2473
- const cloudNotesCache = new Map();
2738
+ // H3a: hash-first delta sync. Post-migration one root (the vault) holds the
2739
+ // whole corpus (~50MB+), so priming full content per root — the old
2740
+ // getNotesForSync approach — cost the entire corpus on every per-file sync.
2741
+ // Instead cache a lightweight relative_path→hash map per root (KB), and
2742
+ // fetch a single note's full content ONLY for the file being synced, only
2743
+ // when it actually exists in the cloud.
2744
+ const rootHashCache = new Map();
2745
+ const cloudNoteCache = new Map(); // key: `${rootId}::${rel}`
2746
+ // Unified reference resolution (M2): entries may be note URLs, noesis://
2747
+ // ids, bare ids, or paths of ANY OS's shape — including legacy
2748
+ // pre-migration paths translated through archived roots. Materialization
2749
+ // ALWAYS targets the resolver-derived local path for this machine (never
2750
+ // the caller-typed string), which also retires the old empty-root-path →
2751
+ // cwd footgun and the doubled-segment auto-correction heuristic.
2752
+ const resolverCtx = await client.getResolverContext();
2474
2753
  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
- }
2754
+ const ref = parseNoteReference(filePath);
2755
+ let targetRootId = null;
2756
+ let candidates = [];
2757
+ if (ref.kind === 'id') {
2758
+ const idNote = await client.getNote(ref.id).catch(() => undefined);
2759
+ const rel = idNote?.relative_path;
2760
+ const rootId = idNote?.root_id;
2761
+ if (!idNote || rootId == null || !rel) {
2762
+ result.errors.push(`Note not found for id reference: ${filePath}`);
2763
+ result.details.push({ file: filePath, action: 'error', reason: 'Note id not found' });
2764
+ continue;
2765
+ }
2766
+ targetRootId = rootId;
2767
+ candidates = [normalizePath(rel)];
2768
+ }
2769
+ else {
2770
+ const resolved = resolvePathReference(resolverCtx, ref.path);
2771
+ if (resolved) {
2772
+ targetRootId = resolved.rootId;
2773
+ candidates = resolved.candidates.map(c => normalizePath(c));
2497
2774
  }
2498
2775
  }
2499
- // Check if it's a markdown file (before existence check so cloud-only pulls also validate)
2500
- if (!normalizedPath.endsWith('.md')) {
2776
+ if (targetRootId === null || candidates.length === 0) {
2777
+ 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.`);
2778
+ result.details.push({ file: filePath, action: 'error', reason: 'Not in configured root' });
2779
+ continue;
2780
+ }
2781
+ const ctxRoot = resolverCtx.roots.find(r => r.id === targetRootId);
2782
+ const rootBase = rootLocalBase(resolverCtx, targetRootId);
2783
+ if (!ctxRoot || !rootBase) {
2784
+ result.errors.push(`Root ${targetRootId} has no usable path on this machine (${resolverCtx.clientOs}) — cannot materialize '${filePath}'.`);
2785
+ result.details.push({ file: filePath, action: 'error', reason: 'No local path for root on this OS' });
2786
+ continue;
2787
+ }
2788
+ const matchingRoot = { id: ctxRoot.id, name: ctxRoot.name, path: rootBase };
2789
+ // H3a: fetch the root's lightweight relative_path→hash map (KB), pick the
2790
+ // candidate present in it (collapsed form first; local-only pushes use the
2791
+ // first), then fetch that ONE note's full content lazily — never the whole
2792
+ // corpus. cloudNote===null means the note doesn't exist in the cloud.
2793
+ if (!rootHashCache.has(matchingRoot.id)) {
2794
+ const raw = await client.getNoteHashesByRoot(matchingRoot.id);
2795
+ // Normalize keys the same way candidates are (forward-slash), matching
2796
+ // the pre-H3a cloud-map keying so lookups stay byte-identical.
2797
+ const norm = new Map();
2798
+ for (const [rel, h] of raw)
2799
+ norm.set(normalizePath(rel), h);
2800
+ rootHashCache.set(matchingRoot.id, norm);
2801
+ }
2802
+ const rootHashes = rootHashCache.get(matchingRoot.id);
2803
+ const relativePath = candidates.find(c => rootHashes.has(c)) ?? candidates[0];
2804
+ const normalizedPath = `${matchingRoot.path}/${relativePath}`;
2805
+ // Markdown-only (before existence check so cloud-only pulls also validate)
2806
+ if (!relativePath.endsWith('.md')) {
2501
2807
  result.errors.push(`Not a markdown file: ${filePath}`);
2502
2808
  result.details.push({ file: filePath, action: 'error', reason: 'Not a markdown file' });
2503
2809
  continue;
2504
2810
  }
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);
2811
+ // Lazily materialize the single cloud note (content + metadata) only when
2812
+ // the candidate exists in the hash map — the delta-sync payload win.
2813
+ const cloudHash = rootHashes.get(relativePath);
2814
+ let cloudNote = null;
2815
+ if (cloudHash) {
2816
+ const cnKey = `${matchingRoot.id}::${relativePath}`;
2817
+ if (!cloudNoteCache.has(cnKey)) {
2818
+ const n = await client.getNoteByRelativePath(matchingRoot.id, relativePath);
2819
+ cloudNoteCache.set(cnKey, n ? adaptCloudNote(n, relativePath, cloudHash) : null);
2524
2820
  }
2525
- const cloudNotesMap = cloudNotesCache.get(matchingRoot.id);
2526
- const cloudNote = cloudNotesMap.get(relativePath);
2821
+ cloudNote = cloudNoteCache.get(cnKey);
2822
+ }
2823
+ // Missing locally — pull from cloud to the VAULT-DERIVED path
2824
+ if (!fs.existsSync(normalizedPath)) {
2527
2825
  if (cloudNote) {
2528
2826
  // CLOUD ONLY: Pull to local (create)
2529
2827
  if (dryRun) {
@@ -2531,13 +2829,17 @@ async function syncSpecificFiles(filePaths, roots, dryRun, client, force = false
2531
2829
  result.pulled.created++;
2532
2830
  }
2533
2831
  else {
2832
+ const rootDirExisted = fs.existsSync(matchingRoot.path);
2534
2833
  const localDir = path.dirname(normalizedPath);
2535
2834
  if (!fs.existsSync(localDir)) {
2536
2835
  fs.mkdirSync(localDir, { recursive: true });
2537
2836
  }
2837
+ if (!rootDirExisted) {
2838
+ warnings.push(`Created local vault/root directory at ${matchingRoot.path} (fresh machine bootstrap).`);
2839
+ }
2538
2840
  fs.writeFileSync(normalizedPath, cloudNote.content, 'utf-8');
2539
2841
  const stats = fs.statSync(normalizedPath);
2540
- await client.updateFileMetadata(normalizedPath, stats.size, cloudNote.hash);
2842
+ await client.updateFileMetadata({ rootId: matchingRoot.id, relativePath }, stats.size, cloudNote.hash);
2541
2843
  // Set baseline
2542
2844
  if (!syncStateCache.has(matchingRoot.path)) {
2543
2845
  const mgr = new SyncStateManager(matchingRoot.path);
@@ -2551,38 +2853,19 @@ async function syncSpecificFiles(filePaths, roots, dryRun, client, force = false
2551
2853
  }
2552
2854
  }
2553
2855
  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.`);
2856
+ 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
2857
  result.details.push({ file: filePath, action: 'error', reason: 'Not found locally or in cloud' });
2556
2858
  }
2557
2859
  continue;
2558
2860
  }
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
2861
  try {
2567
- // Read local file content
2862
+ // Read local file content. relativePath + cloudNote were resolved above
2863
+ // (H3a) — the outer relativePath equals path.relative(root, normalizedPath)
2864
+ // by construction, and cloudNote is the single fetched note (or null).
2568
2865
  const localContent = fs.readFileSync(normalizedPath, 'utf-8');
2569
2866
  const stats = fs.statSync(normalizedPath);
2570
- const relativePath = normalizePath(path.relative(matchingRoot.path, normalizedPath));
2571
2867
  const localHash = NoesisClient.computeHash(localContent);
2572
2868
  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
2869
  // Get or create SyncStateManager for this root
2587
2870
  if (!syncStateCache.has(matchingRoot.path)) {
2588
2871
  const mgr = new SyncStateManager(matchingRoot.path);
@@ -2618,8 +2901,17 @@ async function syncSpecificFiles(filePaths, roots, dryRun, client, force = false
2618
2901
  affectedRoots.add(matchingRoot.id);
2619
2902
  }
2620
2903
  }
2621
- else if (localHash === cloudNote.hash) {
2622
- // SAME CONTENT HASH: Check if metadata differs
2904
+ else if (localHash === cloudNote.hash &&
2905
+ cloudNote.content != null &&
2906
+ NoesisClient.computeHash(cloudNote.content) === cloudNote.hash) {
2907
+ // SAME CONTENT HASH (and the cloud is internally consistent): metadata-only diff.
2908
+ // The computeHash(cloudNote.content) === cloudNote.hash guard defends against a
2909
+ // "hash-ahead-of-content" divergence — a cloud row whose stored hash matches local
2910
+ // but whose stored *content* is stale. When detected, we fall through to the push
2911
+ // branch below (which force-pushes on equal hashes) instead of trusting hash-equality
2912
+ // and skipping, so a diverged note can always be repaired. (force is intentionally NOT
2913
+ // part of this gate: a forced re-sync of genuinely-identical content should keep the
2914
+ // original same-hash/metadata behavior, not re-upload the whole body.) Metadata diff:
2623
2915
  const cloudKeywords = parseCloudKeywords(cloudNote.keywords);
2624
2916
  const metadataDiffers = (cloudNote.title && cloudNote.title !== localMetadata.title) ||
2625
2917
  (cloudNote.description && cloudNote.description !== localMetadata.description) ||
@@ -2695,7 +2987,11 @@ async function syncSpecificFiles(filePaths, roots, dryRun, client, force = false
2695
2987
  const baselineHash = baselineMeta?.hash;
2696
2988
  const baselineLastSyncedAt = baselineMeta?.lastSyncedAt;
2697
2989
  const cloudMtime = new Date(cloudNote.modified_at).getTime();
2698
- const direction = force
2990
+ // Push when forced, OR when we reached this branch with matching hashes — which now
2991
+ // only happens via the divergence/force fall-through above (a cloud row whose hash
2992
+ // matches local but whose content is stale). determineSyncDirection would see equal
2993
+ // hashes and return 'skip', so override it to repair the stale cloud content.
2994
+ const direction = (force || localHash === cloudNote.hash)
2699
2995
  ? 'push'
2700
2996
  : determineSyncDirection(localHash, cloudNote.hash, baselineHash, localMtime, cloudMtime, cloudNote.edited_online_at, baselineLastSyncedAt);
2701
2997
  if (direction === 'conflict') {