@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.
- package/README.md +10 -1
- package/dist/agent/identity.d.ts +33 -0
- package/dist/agent/identity.d.ts.map +1 -0
- package/dist/agent/identity.js +61 -0
- package/dist/agent/identity.js.map +1 -0
- package/dist/agent/runner.d.ts +103 -6
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +375 -64
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/tokenState.d.ts +81 -0
- package/dist/agent/tokenState.d.ts.map +1 -0
- package/dist/agent/tokenState.js +173 -0
- package/dist/agent/tokenState.js.map +1 -0
- package/dist/agent/webGuard.d.ts +48 -0
- package/dist/agent/webGuard.d.ts.map +1 -0
- package/dist/agent/webGuard.js +173 -0
- package/dist/agent/webGuard.js.map +1 -0
- package/dist/api/NoesisClient.d.ts +19 -1
- package/dist/api/NoesisClient.d.ts.map +1 -1
- package/dist/api/NoesisClient.js +55 -3
- package/dist/api/NoesisClient.js.map +1 -1
- package/dist/cli/setup.js +1 -1
- package/dist/cli/setup.js.map +1 -1
- package/dist/machineLabel.d.ts +16 -0
- package/dist/machineLabel.d.ts.map +1 -0
- package/dist/machineLabel.js +43 -0
- package/dist/machineLabel.js.map +1 -0
- package/dist/tools/backlog.d.ts +21 -0
- package/dist/tools/backlog.d.ts.map +1 -1
- package/dist/tools/backlog.js +123 -1
- package/dist/tools/backlog.js.map +1 -1
- package/dist/tools/claudeSessions.d.ts +83 -0
- package/dist/tools/claudeSessions.d.ts.map +1 -0
- package/dist/tools/claudeSessions.js +330 -0
- package/dist/tools/claudeSessions.js.map +1 -0
- package/dist/tools/index.d.ts +1 -6
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +166 -37
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/sessionIndex.d.ts +66 -0
- package/dist/tools/sessionIndex.d.ts.map +1 -0
- package/dist/tools/sessionIndex.js +241 -0
- package/dist/tools/sessionIndex.js.map +1 -0
- package/package.json +1 -1
- package/scripts/noesis-capture-watcher.mjs +722 -107
- package/scripts/start-agent.sh +51 -12
- package/skill-templates/noesis-refine-relations.md +163 -0
- package/skill-templates/noesis-ticket.md +79 -0
- package/templates/claude-md-block.md +7 -0
package/dist/tools/index.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/tools/index.js
CHANGED
|
@@ -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
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
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:
|
|
1405
|
-
hash:
|
|
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
|
-
|
|
1411
|
-
|
|
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',
|
|
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
|
-
|
|
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('
|
|
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 = `✅
|
|
1854
|
-
message += `- Relations
|
|
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,
|
|
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
|
-
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
2899
|
-
hash:
|
|
2900
|
-
mtime:
|
|
2901
|
-
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:
|
|
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:
|
|
2964
|
-
hash:
|
|
2965
|
-
mtime:
|
|
2966
|
-
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:
|
|
3076
|
-
hash:
|
|
3077
|
-
mtime:
|
|
3078
|
-
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,
|
|
3087
|
-
|
|
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);
|