@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.
- package/README.md +91 -3
- package/dist/agent/runner.d.ts +29 -0
- package/dist/agent/runner.d.ts.map +1 -0
- package/dist/agent/runner.js +306 -0
- package/dist/agent/runner.js.map +1 -0
- package/dist/api/NoesisClient.d.ts +119 -0
- package/dist/api/NoesisClient.d.ts.map +1 -1
- package/dist/api/NoesisClient.js +130 -6
- package/dist/api/NoesisClient.js.map +1 -1
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +409 -35
- package/dist/cli/setup.js.map +1 -1
- package/dist/index.js +31 -1
- package/dist/index.js.map +1 -1
- package/dist/resolve/noteRef.d.ts +101 -0
- package/dist/resolve/noteRef.d.ts.map +1 -0
- package/dist/resolve/noteRef.js +186 -0
- package/dist/resolve/noteRef.js.map +1 -0
- package/dist/resolve/rootsFormat.d.ts +12 -0
- package/dist/resolve/rootsFormat.d.ts.map +1 -0
- package/dist/resolve/rootsFormat.js +63 -0
- package/dist/resolve/rootsFormat.js.map +1 -0
- package/dist/tools/backlog.d.ts +4 -0
- package/dist/tools/backlog.d.ts.map +1 -0
- package/dist/tools/backlog.js +161 -0
- package/dist/tools/backlog.js.map +1 -0
- package/dist/tools/index.d.ts +92 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +529 -207
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/navis.d.ts.map +1 -1
- package/dist/tools/navis.js +4 -0
- package/dist/tools/navis.js.map +1 -1
- package/package.json +8 -3
- package/scripts/agent-e2e.mjs +69 -0
- package/scripts/noesis-capture-session-end.mjs +94 -0
- package/scripts/noesis-capture-watcher.mjs +953 -0
- package/skill-templates/noesis-capture.md +166 -0
- package/skill-templates/noesis-create-study-note.md +149 -0
- package/skill-templates/noesis-find-related.md +89 -0
- package/skill-templates/noesis-investigate.md +90 -0
- package/skill-templates/noesis-manage-news.md +80 -0
- package/skill-templates/noesis-skim-read.md +72 -0
- package/templates/claude-md-block.md +5 -1
- package/templates/claude-md-restructure-on-sync.md +10 -0
package/dist/tools/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
385
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1038
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
2473
|
-
|
|
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
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
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
|
-
|
|
2500
|
-
|
|
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
|
-
//
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
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
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
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(
|
|
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(`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
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
|
-
|
|
2898
|
-
if (!
|
|
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(
|
|
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
|
-
|
|
2957
|
-
|
|
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
|
-
|
|
2973
|
-
const
|
|
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
|
|
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
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
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
|
-
//
|
|
3064
|
-
|
|
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
|