@noesis-brain/mcp-server 2.1.0 → 2.1.2

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.
@@ -13,6 +13,7 @@ import { initEmbeddingService, generateEmbedding, generateEmbeddingsBatch } from
13
13
  import { SyncStateManager, determineSyncDirection } from './SyncStateManager.js';
14
14
  import { registerNaviTools } from './navis.js';
15
15
  import { registerBacklogTools } from './backlog.js';
16
+ import { registerExamTools } from './exam.js';
16
17
  import { suggestOtherOsPath } from '../utils/suggestPath.js';
17
18
  import { diff3Merge, diffPatch } from 'node-diff3';
18
19
  // ============================================
@@ -185,6 +186,7 @@ export function registerTools(server, services) {
185
186
  // Register Navi management tools
186
187
  registerNaviTools(server, client);
187
188
  registerBacklogTools(server, client);
189
+ registerExamTools(server, client);
188
190
  // Register search_notes tool
189
191
  server.tool('search_notes', 'Search your knowledge base using full-text search. Returns relevant notes ranked by BM25 relevance.', {
190
192
  query: z.string().describe('Search query (natural language or keywords)'),
@@ -1390,20 +1392,27 @@ export function registerTools(server, services) {
1390
1392
  keywords: cloudKw.length > 0 ? cloudKw : undefined
1391
1393
  })
1392
1394
  : mergedContent;
1393
- const enrichedHash = NoesisClient.computeHash(enrichedContent);
1394
- const mergedFile = {
1395
- ...localFile,
1396
- content: enrichedContent,
1397
- hash: enrichedHash
1398
- };
1399
- // preserveMetadata=true: keep cloud's AI-generated title/description/keywords
1400
- await client.upsertNote(mergedFile, metadata, { force: true, regenerateMetadata, preserveMetadata: true });
1401
- // Write enriched content back to local file
1402
- const localPath = path.join(syncRoot.path, relativePath);
1403
- fs.writeFileSync(localPath, enrichedContent, 'utf-8');
1404
- stateMgr.setBaseline(relativePath, { hash: enrichedHash, lastSyncedAt: new Date().toISOString() }, enrichedContent);
1405
- result.details.push({ file: relativePath, action: 'pushed_update', reason: 'merged' });
1406
- result.pushed.updated++;
1395
+ // Safety net: never silently destroy content on merge (see per-file branch).
1396
+ if (enrichedContent.length < localFile.content.length * 0.5 && localFile.content.length > 2048) {
1397
+ const lost = Math.round(100 * (1 - enrichedContent.length / localFile.content.length));
1398
+ 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.`);
1399
+ }
1400
+ else {
1401
+ const enrichedHash = NoesisClient.computeHash(enrichedContent);
1402
+ const mergedFile = {
1403
+ ...localFile,
1404
+ content: enrichedContent,
1405
+ hash: enrichedHash
1406
+ };
1407
+ // preserveMetadata=true: keep cloud's AI-generated title/description/keywords
1408
+ await client.upsertNote(mergedFile, metadata, { force: true, regenerateMetadata, preserveMetadata: true });
1409
+ // Write enriched content back to local file
1410
+ const localPath = path.join(syncRoot.path, relativePath);
1411
+ fs.writeFileSync(localPath, enrichedContent, 'utf-8');
1412
+ stateMgr.setBaseline(relativePath, { hash: enrichedHash, lastSyncedAt: new Date().toISOString() }, enrichedContent);
1413
+ result.details.push({ file: relativePath, action: 'pushed_update', reason: 'merged' });
1414
+ result.pushed.updated++;
1415
+ }
1407
1416
  }
1408
1417
  }
1409
1418
  else if (direction === 'pull') {
@@ -3051,26 +3060,35 @@ export async function syncSpecificFiles(filePaths, dryRun, client, force = false
3051
3060
  keywords: cloudKw.length > 0 ? cloudKw : undefined
3052
3061
  })
3053
3062
  : mergedContent;
3054
- const enrichedHash = NoesisClient.computeHash(enrichedContent);
3055
- const localFile = {
3056
- path: normalizedPath,
3057
- relativePath,
3058
- content: enrichedContent,
3059
- hash: enrichedHash,
3060
- mtime: stats.mtime,
3061
- size: stats.size,
3062
- rootId: matchingRoot.id,
3063
- rootName: matchingRoot.name,
3064
- project
3065
- };
3066
- // preserveMetadata=true: keep cloud's AI-generated title/description/keywords
3067
- await client.upsertNote(localFile, localMetadata, { force: true, regenerateMetadata, preserveMetadata: true });
3068
- // Write enriched content back to local file
3069
- fs.writeFileSync(normalizedPath, enrichedContent, 'utf-8');
3070
- stateMgr.setBaseline(relativePath, { hash: enrichedHash, lastSyncedAt: new Date().toISOString() }, enrichedContent);
3071
- result.details.push({ file: relativePath, action: 'pushed_update', reason: 'merged' });
3072
- result.pushed.updated++;
3073
- affectedRoots.add(matchingRoot.id);
3063
+ // Safety net: a merge must never silently destroy content. If the merged
3064
+ // result lost most of the local body, abort this file's push instead of
3065
+ // overwriting local + cloud with a truncated note.
3066
+ if (enrichedContent.length < localContent.length * 0.5 && localContent.length > 2048) {
3067
+ const lost = Math.round(100 * (1 - enrichedContent.length / localContent.length));
3068
+ result.errors.push(`Skipped push for ${relativePath}: merge would drop ${lost}% of content (${localContent.length}->${enrichedContent.length} bytes). Left unchanged to avoid data loss.`);
3069
+ }
3070
+ else {
3071
+ const enrichedHash = NoesisClient.computeHash(enrichedContent);
3072
+ const localFile = {
3073
+ path: normalizedPath,
3074
+ relativePath,
3075
+ content: enrichedContent,
3076
+ hash: enrichedHash,
3077
+ mtime: stats.mtime,
3078
+ size: stats.size,
3079
+ rootId: matchingRoot.id,
3080
+ rootName: matchingRoot.name,
3081
+ project
3082
+ };
3083
+ // preserveMetadata=true: keep cloud's AI-generated title/description/keywords
3084
+ await client.upsertNote(localFile, localMetadata, { force: true, regenerateMetadata, preserveMetadata: true });
3085
+ // Write enriched content back to local file
3086
+ fs.writeFileSync(normalizedPath, enrichedContent, 'utf-8');
3087
+ stateMgr.setBaseline(relativePath, { hash: enrichedHash, lastSyncedAt: new Date().toISOString() }, enrichedContent);
3088
+ result.details.push({ file: relativePath, action: 'pushed_update', reason: 'merged' });
3089
+ result.pushed.updated++;
3090
+ affectedRoots.add(matchingRoot.id);
3091
+ }
3074
3092
  }
3075
3093
  }
3076
3094
  else if (direction === 'pull') {
@@ -3185,22 +3203,36 @@ export async function syncSpecificFiles(filePaths, dryRun, client, force = false
3185
3203
  }]
3186
3204
  };
3187
3205
  }
3206
+ /**
3207
+ * Split leading YAML frontmatter from a markdown document.
3208
+ *
3209
+ * The opening `---` must be the very first line and the closing `---` must be on its
3210
+ * own line (line-anchored). This deliberately does NOT use `indexOf('---', 3)`: a bare
3211
+ * substring search matches a `---` inside a frontmatter value, a `---`/`----` thematic
3212
+ * break in the body, or a `---` inside a code fence — which previously caused notes to
3213
+ * be truncated / their frontmatter duplicated on sync.
3214
+ *
3215
+ * @returns raw = whole matched block incl. fences + trailing newline; inner = the YAML
3216
+ * between the fences; body = everything after the block. If there is no valid
3217
+ * leading frontmatter, raw/inner are '' and body is the full content.
3218
+ */
3219
+ export function splitFrontmatter(content) {
3220
+ const m = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/);
3221
+ if (!m)
3222
+ return { raw: '', inner: '', body: content };
3223
+ return { raw: m[0], inner: m[1], body: content.slice(m[0].length) };
3224
+ }
3188
3225
  /**
3189
3226
  * Parse YAML frontmatter from markdown content using js-yaml
3190
3227
  */
3191
3228
  function parseYamlFrontmatter(content) {
3192
3229
  const result = {};
3193
- // Check for YAML frontmatter (--- at start)
3194
- if (!content.startsWith('---')) {
3195
- return result;
3196
- }
3197
- const endIndex = content.indexOf('---', 3);
3198
- if (endIndex === -1) {
3230
+ const { inner } = splitFrontmatter(content);
3231
+ if (!inner) {
3199
3232
  return result;
3200
3233
  }
3201
- const frontmatterStr = content.substring(3, endIndex).trim();
3202
3234
  try {
3203
- const parsed = yaml.load(frontmatterStr);
3235
+ const parsed = yaml.load(inner);
3204
3236
  if (!parsed || typeof parsed !== 'object') {
3205
3237
  return result;
3206
3238
  }
@@ -3248,9 +3280,14 @@ function parseCloudKeywords(keywords) {
3248
3280
  * Update frontmatter in file content with new metadata values
3249
3281
  * Preserves existing frontmatter structure and only updates specified fields
3250
3282
  */
3251
- function updateFrontmatter(content, updates) {
3252
- // If no frontmatter exists, create one
3253
- if (!content.startsWith('---')) {
3283
+ export function updateFrontmatter(content, updates) {
3284
+ const { inner, body } = splitFrontmatter(content);
3285
+ // If no (valid) frontmatter exists, create one
3286
+ if (!inner) {
3287
+ if (content.startsWith('---')) {
3288
+ // Opens with a fence but has no valid closing fence: don't risk mangling it.
3289
+ return content;
3290
+ }
3254
3291
  const frontmatterLines = ['---'];
3255
3292
  if (updates.title)
3256
3293
  frontmatterLines.push(`title: ${updates.title}`);
@@ -3265,13 +3302,8 @@ function updateFrontmatter(content, updates) {
3265
3302
  frontmatterLines.push('---');
3266
3303
  return frontmatterLines.join('\n') + '\n' + content;
3267
3304
  }
3268
- // Find end of frontmatter
3269
- const endIndex = content.indexOf('---', 3);
3270
- if (endIndex === -1) {
3271
- return content; // Invalid frontmatter, return unchanged
3272
- }
3273
- const frontmatter = content.substring(3, endIndex);
3274
- const bodyContent = content.substring(endIndex + 3);
3305
+ const frontmatter = inner;
3306
+ const bodyContent = body;
3275
3307
  const lines = frontmatter.split(/\r?\n/);
3276
3308
  const newLines = [];
3277
3309
  const updatedKeys = new Set();
@@ -3334,31 +3366,27 @@ function updateFrontmatter(content, updates) {
3334
3366
  newLines.push(` - ${kw}`);
3335
3367
  }
3336
3368
  }
3337
- return '---\n' + newLines.filter(l => l.trim() !== '').join('\n') + '\n---' + bodyContent;
3369
+ return '---\n' + newLines.filter(l => l.trim() !== '').join('\n') + '\n---\n' + bodyContent;
3338
3370
  }
3339
3371
  /**
3340
3372
  * Parse markdown content into structure: frontmatter, H1 heading, and body
3341
3373
  */
3342
- function parseMarkdownStructure(content) {
3343
- let frontmatter = '';
3344
- let remaining = content;
3345
- // Extract YAML frontmatter (--- ... ---)
3346
- if (content.startsWith('---')) {
3347
- const endIndex = content.indexOf('---', 3);
3348
- if (endIndex !== -1) {
3349
- frontmatter = content.substring(0, endIndex + 3);
3350
- remaining = content.substring(endIndex + 3).replace(/^\r?\n/, ''); // Remove leading newline after frontmatter
3351
- }
3352
- }
3353
- // Extract first H1 heading (# ...)
3354
- const h1Match = remaining.match(/^(#\s+.+?)(\r?\n|$)/m);
3374
+ export function parseMarkdownStructure(content) {
3375
+ // Extract YAML frontmatter (line-anchored; see splitFrontmatter).
3376
+ const { raw, body: afterFrontmatter } = splitFrontmatter(content);
3377
+ const frontmatter = raw ? raw.replace(/\r?\n$/, '') : ''; // keep fences; reconstruct re-adds the trailing newline
3378
+ const remaining = afterFrontmatter;
3379
+ // Extract the note's H1 heading — ONLY when it is the first non-blank line of the
3380
+ // body. A non-anchored /m search would match a `# ...` line inside a fenced code
3381
+ // block (e.g. a shell comment) and set body to everything after it, silently
3382
+ // dropping the whole note above it. Anchoring to the start keeps all content.
3383
+ const h1Match = remaining.match(/^\s*(#[ \t]+.+?)(\r?\n|$)/);
3355
3384
  let h1 = null;
3356
3385
  let body = remaining;
3357
3386
  if (h1Match) {
3358
3387
  h1 = h1Match[1];
3359
- // Get everything after the H1 (including the newline after it)
3360
- const h1EndIndex = remaining.indexOf(h1Match[0]) + h1Match[0].length;
3361
- body = remaining.substring(h1EndIndex);
3388
+ // Everything after the matched leading H1 line becomes the body.
3389
+ body = remaining.substring(h1Match[0].length);
3362
3390
  }
3363
3391
  return { frontmatter, h1, body };
3364
3392
  }
@@ -3628,7 +3656,7 @@ async function runConflictCascade(opts) {
3628
3656
  * Merge local content with cloud content
3629
3657
  * Strategy: Cloud's H1 wins (Noesis edits), Local's body wins (local edits)
3630
3658
  */
3631
- function mergeContent(localContent, cloudContent) {
3659
+ export function mergeContent(localContent, cloudContent) {
3632
3660
  const localParsed = parseMarkdownStructure(localContent);
3633
3661
  const cloudParsed = parseMarkdownStructure(cloudContent);
3634
3662
  // Use cloud's H1 if it exists and differs from local, otherwise use local's H1