@noesis-brain/mcp-server 2.1.5 → 2.5.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 (49) hide show
  1. package/README.md +10 -1
  2. package/dist/agent/identity.d.ts +33 -0
  3. package/dist/agent/identity.d.ts.map +1 -0
  4. package/dist/agent/identity.js +61 -0
  5. package/dist/agent/identity.js.map +1 -0
  6. package/dist/agent/runner.d.ts +103 -6
  7. package/dist/agent/runner.d.ts.map +1 -1
  8. package/dist/agent/runner.js +375 -64
  9. package/dist/agent/runner.js.map +1 -1
  10. package/dist/agent/tokenState.d.ts +81 -0
  11. package/dist/agent/tokenState.d.ts.map +1 -0
  12. package/dist/agent/tokenState.js +173 -0
  13. package/dist/agent/tokenState.js.map +1 -0
  14. package/dist/agent/webGuard.d.ts +48 -0
  15. package/dist/agent/webGuard.d.ts.map +1 -0
  16. package/dist/agent/webGuard.js +173 -0
  17. package/dist/agent/webGuard.js.map +1 -0
  18. package/dist/api/NoesisClient.d.ts +19 -1
  19. package/dist/api/NoesisClient.d.ts.map +1 -1
  20. package/dist/api/NoesisClient.js +55 -3
  21. package/dist/api/NoesisClient.js.map +1 -1
  22. package/dist/cli/setup.js +1 -1
  23. package/dist/cli/setup.js.map +1 -1
  24. package/dist/machineLabel.d.ts +16 -0
  25. package/dist/machineLabel.d.ts.map +1 -0
  26. package/dist/machineLabel.js +43 -0
  27. package/dist/machineLabel.js.map +1 -0
  28. package/dist/tools/backlog.d.ts +21 -0
  29. package/dist/tools/backlog.d.ts.map +1 -1
  30. package/dist/tools/backlog.js +123 -1
  31. package/dist/tools/backlog.js.map +1 -1
  32. package/dist/tools/claudeSessions.d.ts +83 -0
  33. package/dist/tools/claudeSessions.d.ts.map +1 -0
  34. package/dist/tools/claudeSessions.js +330 -0
  35. package/dist/tools/claudeSessions.js.map +1 -0
  36. package/dist/tools/index.d.ts +1 -6
  37. package/dist/tools/index.d.ts.map +1 -1
  38. package/dist/tools/index.js +166 -37
  39. package/dist/tools/index.js.map +1 -1
  40. package/dist/tools/sessionIndex.d.ts +66 -0
  41. package/dist/tools/sessionIndex.d.ts.map +1 -0
  42. package/dist/tools/sessionIndex.js +241 -0
  43. package/dist/tools/sessionIndex.js.map +1 -0
  44. package/package.json +1 -1
  45. package/scripts/noesis-capture-watcher.mjs +722 -107
  46. package/scripts/start-agent.sh +51 -12
  47. package/skill-templates/noesis-refine-relations.md +163 -0
  48. package/skill-templates/noesis-ticket.md +79 -0
  49. package/templates/claude-md-block.md +7 -0
@@ -49,12 +49,7 @@ type CloudNoteRow = {
49
49
  };
50
50
  /** Adapt a by-relative Note (+ its hash from the root hash map) into CloudNoteRow. */
51
51
  export declare function adaptCloudNote(n: Note, relativePath: string, hash: string): CloudNoteRow;
52
- /**
53
- * Sync specific files by path (single-file or multi-file sync mode)
54
- * @param force - If true, bypass hash check and always re-sync (useful for metadata regeneration)
55
- * @param regenerateMetadata - If true, backend AI regenerates all metadata fields, overwriting existing values
56
- */
57
- export declare function syncSpecificFiles(filePaths: string[], dryRun: boolean, client: NoesisClient, force?: boolean, regenerateMetadata?: boolean): Promise<{
52
+ export declare function syncSpecificFiles(filePaths: string[], dryRun: boolean, client: NoesisClient, force?: boolean, regenerateMetadata?: boolean, stampSession?: 'auto' | 'always' | 'never', sessionId?: string): Promise<{
58
53
  content: Array<{
59
54
  type: 'text';
60
55
  text: string;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/tools/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAOH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACzE,OAAO,EAAE,YAAY,EAAyC,KAAK,IAAI,EAA2D,MAAM,wBAAwB,CAAC;AAsIjK;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,GAC1C;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAejD;AAqBD,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,YAAY,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,YAAY,GAAG,IAAI,CA64F7E;AA0DD;;;;GAIG;AACH,KAAK,YAAY,GAAG;IAClB,EAAE,EAAE,MAAM,CAAC;IACX,aAAa,EAAE,MAAM,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,CAAC;IACnC,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC,CAAC;AAEF,sFAAsF;AACtF,wBAAgB,cAAc,CAAC,CAAC,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,YAAY,CAYxF;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,SAAS,EAAE,MAAM,EAAE,EACnB,MAAM,EAAE,OAAO,EACf,MAAM,EAAE,YAAY,EACpB,KAAK,GAAE,OAAe,EACtB,kBAAkB,GAAE,OAAe,GAClC,OAAO,CAAC;IAAE,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,CAigBhF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAI9F;AA8ED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,OAAO,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,GACrE,MAAM,CAkFR;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;CACd,CAqBA;AA4SD;;;GAGG;AACH,wBAAgB,YAAY,CAAC,YAAY,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAY/E"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/tools/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAOH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACzE,OAAO,EAAE,YAAY,EAAyC,KAAK,IAAI,EAA2D,MAAM,wBAAwB,CAAC;AAwIjK;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,GAC1C;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAejD;AAqBD,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,YAAY,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,YAAY,GAAG,IAAI,CAk/F7E;AA0DD;;;;GAIG;AACH,KAAK,YAAY,GAAG;IAClB,EAAE,EAAE,MAAM,CAAC;IACX,aAAa,EAAE,MAAM,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,CAAC;IACnC,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC,CAAC;AAEF,sFAAsF;AACtF,wBAAgB,cAAc,CAAC,CAAC,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,YAAY,CAYxF;AA0CD,wBAAsB,iBAAiB,CACrC,SAAS,EAAE,MAAM,EAAE,EACnB,MAAM,EAAE,OAAO,EACf,MAAM,EAAE,YAAY,EACpB,KAAK,GAAE,OAAe,EACtB,kBAAkB,GAAE,OAAe,EACnC,YAAY,GAAE,MAAM,GAAG,QAAQ,GAAG,OAAgB,EAClD,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC;IAAE,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,CA6gBhF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAI9F;AA8ED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,OAAO,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,GACrE,MAAM,CAkFR;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;CACd,CAqBA;AA4SD;;;GAGG;AACH,wBAAgB,YAAY,CAAC,YAAY,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAY/E"}
@@ -11,6 +11,8 @@ import { parseNoteReference, resolvePathReference, rootLocalBase } from '../reso
11
11
  import { formatRootsList } from '../resolve/rootsFormat.js';
12
12
  import { initEmbeddingService, generateEmbedding, generateEmbeddingsBatch } from '../services/embedding.js';
13
13
  import { SyncStateManager, determineSyncDirection } from './SyncStateManager.js';
14
+ import { currentSessionRef, upsertSessionFrontmatter } from './claudeSessions.js';
15
+ import { buildIndex, matchSessions } from './sessionIndex.js';
14
16
  import { registerNaviTools } from './navis.js';
15
17
  import { registerBacklogTools } from './backlog.js';
16
18
  import { registerExamTools } from './exam.js';
@@ -934,6 +936,77 @@ export function registerTools(server, services) {
934
936
  }]
935
937
  };
936
938
  });
939
+ // Register find_claude_sessions_for_note tool
940
+ server.tool('find_claude_sessions_for_note', "Recover which Claude Code sessions worked on a note, by scanning this machine's session transcripts for mentions of the note's path. Returns each session's id, folder and last-active time, plus a ready-to-run `claude --resume` command. Use this to backfill notes that predate automatic session stamping, or whenever the user asks 'which session wrote this note / can I resume it'. READ-ONLY by default (plan-mode safe): it only writes when apply is true, which stamps the results into the note's `claude_sessions:` frontmatter. Results are cached in an incremental index, so the first run is the slow one.", {
941
+ note: z.string().describe('Absolute path to the note (.md) on this machine. A relative path or bare filename also works but matches more weakly.'),
942
+ since: z.string().optional().describe('ISO date; ignore transcripts older than this. Defaults to 90 days ago — widen it to search further back.'),
943
+ apply: z.boolean().optional().describe("Write the matches into the note's `claude_sessions:` frontmatter (default: false). Leave false in plan mode."),
944
+ timeBudgetMs: z.number().optional().describe('Stop indexing after this many ms and report partial progress (default: 20000). Re-run to continue where it stopped.')
945
+ }, async (args) => {
946
+ const { note, since, apply = false, timeBudgetMs } = args;
947
+ const notePath = expandHome(note);
948
+ const absolutePath = path.isAbsolute(notePath) ? normalizePath(notePath) : undefined;
949
+ const defaultSince = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
950
+ const sinceDate = since ? new Date(since) : defaultSince;
951
+ const build = await buildIndex({
952
+ since: Number.isNaN(sinceDate.getTime()) ? defaultSince : sinceDate,
953
+ timeBudgetMs,
954
+ });
955
+ const matches = matchSessions(build.index, {
956
+ absolutePath,
957
+ relativePath: absolutePath ? undefined : normalizePath(notePath),
958
+ });
959
+ const lines = [];
960
+ lines.push(`**Claude Code sessions for** \`${normalizePath(notePath)}\``);
961
+ lines.push('');
962
+ lines.push(`Indexed ${build.scanned} new + ${build.reused} cached of ${build.total} transcripts` +
963
+ (build.skippedOld > 0 ? ` (${build.skippedOld} older than the cutoff)` : '') + '.');
964
+ if (build.truncated) {
965
+ lines.push('');
966
+ lines.push('⚠️ Time budget reached — the index is partial. Re-run to continue where it stopped.');
967
+ }
968
+ lines.push('');
969
+ if (matches.length === 0) {
970
+ lines.push('No sessions mention this note.');
971
+ lines.push('');
972
+ lines.push('That is expected when the work happened on another machine (transcripts are local), ' +
973
+ 'longer ago than the cutoff, or before this note had its current path.');
974
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
975
+ }
976
+ for (const m of matches) {
977
+ const when = m.last_active ? m.last_active.replace('T', ' ').replace(/\..*$/, ' UTC') : 'unknown';
978
+ lines.push(`- **${m.title || `Session ${m.id.slice(0, 8)}`}** — ${when}` +
979
+ (m.confidence !== 'absolute' ? ` _(${m.confidence} match — weaker)_` : ''));
980
+ lines.push(` \`cd "${m.cwd}" && claude --resume ${m.id}\``);
981
+ }
982
+ if (apply) {
983
+ try {
984
+ const content = fs.readFileSync(notePath, 'utf-8');
985
+ let updated = content;
986
+ // Oldest first so the newest ends up at the head of the list after each merge.
987
+ for (const m of [...matches].reverse()) {
988
+ if (!m.cwd)
989
+ continue;
990
+ const { confidence, ...ref } = m;
991
+ updated = upsertSessionFrontmatter(updated, ref);
992
+ }
993
+ if (updated !== content) {
994
+ fs.writeFileSync(notePath, updated, 'utf-8');
995
+ lines.push('', `✅ Wrote ${matches.length} session(s) into the note's frontmatter. Run \`sync_notes\` to push it.`);
996
+ }
997
+ else {
998
+ lines.push('', 'Note frontmatter already lists these sessions — nothing to write.');
999
+ }
1000
+ }
1001
+ catch (e) {
1002
+ lines.push('', `⚠️ Could not update the note: ${e.message}`);
1003
+ }
1004
+ }
1005
+ else {
1006
+ lines.push('', '_Read-only. Pass `apply: true` to write these into the note\'s frontmatter._');
1007
+ }
1008
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1009
+ });
937
1010
  // Register list_edited_online_notes tool
938
1011
  server.tool('list_edited_online_notes', 'List notes that were edited via the Noesis web UI (Quick Fix) and have pending local sync. For each note, reports whether the local file is unchanged (safe to pull), also_modified (conflict-cascade will run), or not_on_disk. Use this before running sync_notes to preview what will happen.', {
939
1012
  root_id: z.number().optional().describe('Filter to a specific root ID (optional; omit to list all roots)')
@@ -1011,9 +1084,14 @@ export function registerTools(server, services) {
1011
1084
  dryRun: z.boolean().optional().describe('Preview changes without syncing (default: false)'),
1012
1085
  force: z.boolean().optional().describe('Force re-sync even if file hash is unchanged. Useful for regenerating AI metadata on existing files (default: false)'),
1013
1086
  regenerateMetadata: z.boolean().optional().describe('Regenerate all metadata using AI, overwriting existing values. Use when you want AI to improve/replace current title, description, and keywords (default: false)'),
1014
- moveNewToNoesis: z.boolean().optional().describe('Move new local-only files to .noesis folder before syncing. Keeps ad-hoc notes separate from project files (default: false)')
1087
+ moveNewToNoesis: z.boolean().optional().describe('Move new local-only files to .noesis folder before syncing. Keeps ad-hoc notes separate from project files (default: false)'),
1088
+ stampSession: z.enum(['auto', 'always', 'never']).optional().describe("Record the calling Claude Code session (id + folder + last-active) in the note's `claude_sessions:` frontmatter, so the note can be traced back to the conversation that wrote it and resumed with `claude --resume`. 'auto' (default) stamps only on a real push; 'never' disables it; 'always' stamps even an otherwise-unchanged file, which turns that sync into a push."),
1089
+ sessionId: z.string().optional().describe('Override the session id to record. Defaults to CLAUDE_CODE_SESSION_ID, which Claude Code injects into every MCP server it spawns. Use this only when that value is known to be wrong — e.g. after /clear, which rotates the id without respawning the MCP server.')
1015
1090
  }, async (args) => {
1016
- const { root, files, dryRun = false, force = false, regenerateMetadata = false, moveNewToNoesis = false } = args;
1091
+ const { root, files, dryRun = false, force = false, regenerateMetadata = false, moveNewToNoesis = false, stampSession = 'auto', sessionId } = args;
1092
+ // Resolved once per sync run, not per file: two bounded transcript reads (~300 KB total).
1093
+ const resolvedSession = stampSession === 'never' ? null : currentSessionRef(sessionId);
1094
+ const sessionRef = resolvedSession?.ref ?? null;
1017
1095
  // Get roots for sync
1018
1096
  let roots = await client.getRootsForSync();
1019
1097
  // If no roots configured, try auto-detection from CWD
@@ -1085,7 +1163,7 @@ export function registerTools(server, services) {
1085
1163
  }
1086
1164
  // Handle specific files sync mode (push-only for specific files)
1087
1165
  if (files && files.length > 0) {
1088
- return await syncSpecificFiles(files, dryRun, client, force, regenerateMetadata);
1166
+ return await syncSpecificFiles(files, dryRun, client, force, regenerateMetadata, stampSession, sessionId);
1089
1167
  }
1090
1168
  // Filter to specific root if requested
1091
1169
  const rootsToSync = root
@@ -1252,9 +1330,10 @@ export function registerTools(server, services) {
1252
1330
  }
1253
1331
  else {
1254
1332
  const metadata = parseYamlFrontmatter(localFile.content);
1255
- await client.upsertNote(localFile, metadata, { force, regenerateMetadata });
1256
- if (localFile.hash)
1257
- stateMgr.setBaseline(relativePath, { hash: localFile.hash, lastSyncedAt: new Date().toISOString() }, localFile.content);
1333
+ const s = stampForPush(localFile.path || path.join(syncRoot.path, relativePath), localFile.content, localFile.hash || '', localFile.size, localFile.mtime, sessionRef, dryRun);
1334
+ await client.upsertNote({ ...localFile, content: s.content, hash: s.hash }, metadata, { force, regenerateMetadata });
1335
+ if (s.hash)
1336
+ stateMgr.setBaseline(relativePath, { hash: s.hash, lastSyncedAt: new Date().toISOString() }, s.content);
1258
1337
  result.details.push({ file: relativePath, action: 'pushed_create' });
1259
1338
  result.pushed.created++;
1260
1339
  }
@@ -1399,17 +1478,19 @@ export function registerTools(server, services) {
1399
1478
  }
1400
1479
  else {
1401
1480
  const enrichedHash = NoesisClient.computeHash(enrichedContent);
1481
+ const localPath = path.join(syncRoot.path, relativePath);
1482
+ // Stamp AFTER updateFrontmatter — see the per-file branch for why.
1483
+ const s = stampForPush(localPath, enrichedContent, enrichedHash, localFile.size, localFile.mtime, sessionRef, dryRun);
1402
1484
  const mergedFile = {
1403
1485
  ...localFile,
1404
- content: enrichedContent,
1405
- hash: enrichedHash
1486
+ content: s.content,
1487
+ hash: s.hash
1406
1488
  };
1407
1489
  // preserveMetadata=true: keep cloud's AI-generated title/description/keywords
1408
1490
  await client.upsertNote(mergedFile, metadata, { force: true, regenerateMetadata, preserveMetadata: true });
1409
1491
  // 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);
1492
+ fs.writeFileSync(localPath, s.content, 'utf-8');
1493
+ stateMgr.setBaseline(relativePath, { hash: s.hash, lastSyncedAt: new Date().toISOString() }, s.content);
1413
1494
  result.details.push({ file: relativePath, action: 'pushed_update', reason: 'merged' });
1414
1495
  result.pushed.updated++;
1415
1496
  }
@@ -1838,21 +1919,24 @@ export function registerTools(server, services) {
1838
1919
  /**
1839
1920
  * Update document relations (bidirectional)
1840
1921
  */
1841
- server.tool('update_relations', 'Update document relations. Without relations: returns note + other notes for analysis. With relations: updates and creates inverse relations.', {
1922
+ server.tool('update_relations', "Update document relations. Without `relations`: returns the note, its CURRENT relations, and other notes for analysis. With `relations`: REPLACES the note's entire relation set — send the complete desired set, not just additions, or the omitted ones are deleted. Read the current set first (`get_note`, or this tool with no `relations`) and union your additions into it. Relations are stored one-way on this note; the app derives backlinks, so no inverse is written on the target.", {
1842
1923
  note_id: z.number().describe('The note ID to update relations for'),
1843
1924
  relations: z.array(z.object({
1844
- type: z.enum(['references', 'implements', 'extends', 'supersedes']).describe('Relation type'),
1925
+ // Mirrors SUGGESTABLE_RELATION_TYPES in the backend
1926
+ // (src/backend/services/aiTasks/relations/infer.ts) — the source of truth for what
1927
+ // the app stores. 'related' is what the web UI and POST /:id/relations/accept write;
1928
+ // omitting it here made an MCP write silently destroy links it could not recreate.
1929
+ type: z.enum(['related', 'references', 'implements', 'extends', 'supersedes']).describe('Relation type'),
1845
1930
  target_id: z.number().describe('Target note ID'),
1846
1931
  context: z.string().optional().describe('Optional context explaining the relation')
1847
- })).optional().describe('Relations to set (omit to get notes for analysis)')
1932
+ })).optional().describe('The COMPLETE relation set to store (omit to get notes for analysis)')
1848
1933
  }, async (args) => {
1849
1934
  const { note_id, relations } = args;
1850
1935
  // If relations provided, update them
1851
1936
  if (relations !== undefined) {
1852
1937
  const result = await client.updateRelations(note_id, relations);
1853
- let message = `✅ Updated relations for note ${note_id}\n\n`;
1854
- message += `- Relations set: ${relations.length}\n`;
1855
- message += `- Inverse relations created: ${result.inversesCreated}\n\n`;
1938
+ let message = `✅ Replaced the relation set for note ${note_id}\n\n`;
1939
+ message += `- Relations now stored: ${result.updated}\n\n`;
1856
1940
  if (relations.length > 0) {
1857
1941
  message += `**Relations:**\n`;
1858
1942
  for (const rel of relations) {
@@ -1917,12 +2001,14 @@ export function registerTools(server, services) {
1917
2001
  return { content: [{ type: 'text', text: message }] };
1918
2002
  });
1919
2003
  // Register get_relation_graph tool
1920
- server.tool('get_relation_graph', 'Traverse the relation graph from a starting note, following relation links up to N hops deep. Returns all reachable notes with their depth and the relation that led to them.', {
2004
+ server.tool('get_relation_graph', 'Traverse the relation graph from a starting note up to N hops deep, in BOTH directions — notes this one links to AND notes that link to it. Returns every reachable note with its depth and one relation that led to it. An empty result means the note genuinely has no neighbours, not merely that it links to nothing.', {
1921
2005
  note_id: z.number().describe('Starting note ID'),
1922
- depth: z.number().min(1).max(4).optional().describe('Maximum traversal depth (default: 2, max: 4)')
2006
+ // Bounded by MAX_DEPTH in the backend's services/graphService.ts. Advertising 4 while
2007
+ // the server clamped to 3 would have been a silently broken promise.
2008
+ depth: z.number().min(1).max(3).optional().describe('Maximum traversal depth (default: 2, max: 3)')
1923
2009
  }, async (args) => {
1924
2010
  const { note_id, depth = 2 } = args;
1925
- const results = await client.getRelationGraph(note_id, Math.min(Math.max(depth, 1), 4));
2011
+ const results = await client.getRelationGraph(note_id, Math.min(Math.max(depth, 1), 3));
1926
2012
  if (results.length === 0) {
1927
2013
  return {
1928
2014
  content: [{
@@ -1944,7 +2030,10 @@ export function registerTools(server, services) {
1944
2030
  output += `\n--- Depth ${d} ---\n`;
1945
2031
  for (const n of notes) {
1946
2032
  const desc = n.description ? `\n ${n.description.substring(0, 120)}${n.description.length > 120 ? '...' : ''}` : '';
1947
- output += `- **${n.title}** [ID: ${n.id}] via ${n.relation_type} from note ${n.from_id}\n ${n.file_path}${desc}\n`;
2033
+ // from_id/relation_type name ONE of possibly several edges; a node can be reached
2034
+ // by more than one, so treat the attribution as a hint and never assume it exists.
2035
+ const via = n.relation_type && n.from_id ? ` via ${n.relation_type} from note ${n.from_id}` : '';
2036
+ output += `- **${n.title}** [ID: ${n.id}]${via}\n ${n.file_path ?? '(path unavailable)'}${desc}\n`;
1948
2037
  }
1949
2038
  }
1950
2039
  return {
@@ -2730,7 +2819,39 @@ export function adaptCloudNote(n, relativePath, hash) {
2730
2819
  * @param force - If true, bypass hash check and always re-sync (useful for metadata regeneration)
2731
2820
  * @param regenerateMetadata - If true, backend AI regenerates all metadata fields, overwriting existing values
2732
2821
  */
2733
- export async function syncSpecificFiles(filePaths, dryRun, client, force = false, regenerateMetadata = false) {
2822
+ /**
2823
+ * Stamp the calling Claude Code session into a note that is about to be PUSHED, writing the
2824
+ * result back to disk so local, cloud and the sync baseline all agree on the same bytes.
2825
+ *
2826
+ * Returns the caller's values unchanged — and touches nothing — when the stamp is a no-op, which
2827
+ * is the common case: `upsertSessionFrontmatter` returns the original string unless this session
2828
+ * is new to the note or its last_active has moved by more than a few minutes. That guard is what
2829
+ * keeps a repeat sync from turning "skipped (unchanged)" into a full push plus a version snapshot.
2830
+ */
2831
+ function stampForPush(filePath, content, hash, size, mtime, ref, dryRun) {
2832
+ // Callers that already hold real stat values pass them through untouched on the no-op path;
2833
+ // the full-root callers do not use size/mtime from the result, so a stat here is the only
2834
+ // place they would ever be needed.
2835
+ const passthrough = () => ({
2836
+ content,
2837
+ hash,
2838
+ size: size ?? Buffer.byteLength(content, 'utf-8'),
2839
+ mtime: mtime ?? new Date(),
2840
+ });
2841
+ if (!ref || dryRun)
2842
+ return passthrough();
2843
+ const stamped = upsertSessionFrontmatter(content, ref);
2844
+ if (stamped === content)
2845
+ return passthrough();
2846
+ fs.writeFileSync(filePath, stamped, 'utf-8');
2847
+ const st = fs.statSync(filePath);
2848
+ return { content: stamped, hash: NoesisClient.computeHash(stamped), size: st.size, mtime: st.mtime };
2849
+ }
2850
+ export async function syncSpecificFiles(filePaths, dryRun, client, force = false, regenerateMetadata = false, stampSession = 'auto', sessionId) {
2851
+ // Resolved once per sync run: ~300 KB of bounded transcript reads, not once per file.
2852
+ const resolved = stampSession === 'never' ? null : currentSessionRef(sessionId);
2853
+ const sessionRef = resolved?.ref ?? null;
2854
+ const sessionNote = resolved?.note;
2734
2855
  // Use bidirectional sync result type
2735
2856
  const result = {
2736
2857
  pushed: { created: 0, updated: 0 },
@@ -2892,19 +3013,20 @@ export async function syncSpecificFiles(filePaths, dryRun, client, force = false
2892
3013
  }
2893
3014
  else {
2894
3015
  const project = detectProjectForFile(normalizedPath, matchingRoot.path);
3016
+ const s = stampForPush(normalizedPath, localContent, localHash, stats.size, stats.mtime, sessionRef, dryRun);
2895
3017
  const localFile = {
2896
3018
  path: normalizedPath,
2897
3019
  relativePath,
2898
- content: localContent,
2899
- hash: localHash,
2900
- mtime: stats.mtime,
2901
- size: stats.size,
3020
+ content: s.content,
3021
+ hash: s.hash,
3022
+ mtime: s.mtime,
3023
+ size: s.size,
2902
3024
  rootId: matchingRoot.id,
2903
3025
  rootName: matchingRoot.name,
2904
3026
  project
2905
3027
  };
2906
3028
  await client.upsertNote(localFile, localMetadata, { force, regenerateMetadata });
2907
- stateMgr.setBaseline(relativePath, { hash: localHash, lastSyncedAt: new Date().toISOString() }, localContent);
3029
+ stateMgr.setBaseline(relativePath, { hash: s.hash, lastSyncedAt: new Date().toISOString() }, s.content);
2908
3030
  result.details.push({ file: relativePath, action: 'pushed_create' });
2909
3031
  result.pushed.created++;
2910
3032
  affectedRoots.add(matchingRoot.id);
@@ -2957,13 +3079,14 @@ export async function syncSpecificFiles(filePaths, dryRun, client, force = false
2957
3079
  }
2958
3080
  else {
2959
3081
  const project = detectProjectForFile(normalizedPath, matchingRoot.path);
3082
+ const s = stampForPush(normalizedPath, localContent, localHash, stats.size, stats.mtime, sessionRef, dryRun);
2960
3083
  const localFile = {
2961
3084
  path: normalizedPath,
2962
3085
  relativePath,
2963
- content: localContent,
2964
- hash: localHash,
2965
- mtime: stats.mtime,
2966
- size: stats.size,
3086
+ content: s.content,
3087
+ hash: s.hash,
3088
+ mtime: s.mtime,
3089
+ size: s.size,
2967
3090
  rootId: matchingRoot.id,
2968
3091
  rootName: matchingRoot.name,
2969
3092
  project
@@ -3069,13 +3192,17 @@ export async function syncSpecificFiles(filePaths, dryRun, client, force = false
3069
3192
  }
3070
3193
  else {
3071
3194
  const enrichedHash = NoesisClient.computeHash(enrichedContent);
3195
+ // Stamp AFTER updateFrontmatter, never before: updateFrontmatter rewrites the
3196
+ // frontmatter line-by-line and ends by dropping blank lines, so running it over a
3197
+ // freshly-stamped block would reflow work we just did.
3198
+ const s = stampForPush(normalizedPath, enrichedContent, enrichedHash, stats.size, stats.mtime, sessionRef, dryRun);
3072
3199
  const localFile = {
3073
3200
  path: normalizedPath,
3074
3201
  relativePath,
3075
- content: enrichedContent,
3076
- hash: enrichedHash,
3077
- mtime: stats.mtime,
3078
- size: stats.size,
3202
+ content: s.content,
3203
+ hash: s.hash,
3204
+ mtime: s.mtime,
3205
+ size: s.size,
3079
3206
  rootId: matchingRoot.id,
3080
3207
  rootName: matchingRoot.name,
3081
3208
  project
@@ -3083,8 +3210,10 @@ export async function syncSpecificFiles(filePaths, dryRun, client, force = false
3083
3210
  // preserveMetadata=true: keep cloud's AI-generated title/description/keywords
3084
3211
  await client.upsertNote(localFile, localMetadata, { force: true, regenerateMetadata, preserveMetadata: true });
3085
3212
  // 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);
3213
+ fs.writeFileSync(normalizedPath, s.content, 'utf-8');
3214
+ // Baseline must be the STAMPED bytes — the ones now on disk. Recording the
3215
+ // pre-stamp content here would make the next sync see a phantom local edit.
3216
+ stateMgr.setBaseline(relativePath, { hash: s.hash, lastSyncedAt: new Date().toISOString() }, s.content);
3088
3217
  result.details.push({ file: relativePath, action: 'pushed_update', reason: 'merged' });
3089
3218
  result.pushed.updated++;
3090
3219
  affectedRoots.add(matchingRoot.id);