@noesis-brain/mcp-server 2.0.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/LICENSE +21 -0
- package/README.md +218 -0
- package/dist/api/NoesisClient.d.ts +501 -0
- package/dist/api/NoesisClient.d.ts.map +1 -0
- package/dist/api/NoesisClient.js +654 -0
- package/dist/api/NoesisClient.js.map +1 -0
- package/dist/cli/setup.d.ts +8 -0
- package/dist/cli/setup.d.ts.map +1 -0
- package/dist/cli/setup.js +148 -0
- package/dist/cli/setup.js.map +1 -0
- package/dist/database/PostgresAdapter.d.ts +385 -0
- package/dist/database/PostgresAdapter.d.ts.map +1 -0
- package/dist/database/PostgresAdapter.js +1043 -0
- package/dist/database/PostgresAdapter.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +126 -0
- package/dist/index.js.map +1 -0
- package/dist/services/embedding.d.ts +38 -0
- package/dist/services/embedding.d.ts.map +1 -0
- package/dist/services/embedding.js +126 -0
- package/dist/services/embedding.js.map +1 -0
- package/dist/tools/SyncStateManager.d.ts +65 -0
- package/dist/tools/SyncStateManager.d.ts.map +1 -0
- package/dist/tools/SyncStateManager.js +217 -0
- package/dist/tools/SyncStateManager.js.map +1 -0
- package/dist/tools/index.d.ts +14 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +3345 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/navis.d.ts +11 -0
- package/dist/tools/navis.d.ts.map +1 -0
- package/dist/tools/navis.js +231 -0
- package/dist/tools/navis.js.map +1 -0
- package/dist/types/index.d.ts +104 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +5 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/suggestPath.d.ts +15 -0
- package/dist/utils/suggestPath.d.ts.map +1 -0
- package/dist/utils/suggestPath.js +52 -0
- package/dist/utils/suggestPath.js.map +1 -0
- package/package.json +71 -0
- package/scripts/noesis-sync.mjs +469 -0
- package/skill-templates/noesis-refine-note.md +92 -0
- package/skill-templates/noesis-sync.md +110 -0
- package/templates/claude-md-block.md +22 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* noesis-sync.mjs — Standalone sync script for Noesis cloud API.
|
|
4
|
+
* Zero dependencies (Node.js 18+ built-ins only).
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node noesis-sync.mjs # auto-detect from CWD
|
|
8
|
+
* node noesis-sync.mjs --files a.md b.md # sync specific files
|
|
9
|
+
* node noesis-sync.mjs --root my-notes # sync a named root
|
|
10
|
+
* node noesis-sync.mjs --dry-run # preview only
|
|
11
|
+
*
|
|
12
|
+
* Credentials: reads from ~/.claude/.mcp.json → mcpServers.noesis.env
|
|
13
|
+
* or env vars NOESIS_API_TOKEN / NOESIS_API_URL.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { readFileSync, statSync, existsSync, readdirSync } from 'node:fs';
|
|
18
|
+
import { resolve, dirname, basename, relative, join, sep } from 'node:path';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
|
|
21
|
+
// ── Path helpers ────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Tilde-expand `~/Noesis/...` against this machine's home directory.
|
|
25
|
+
* Cloud-flow roots store `~` in the DB because the cloud doesn't know each
|
|
26
|
+
* machine's $HOME — we expand here at sync time. No-op for already-
|
|
27
|
+
* absolute paths.
|
|
28
|
+
*/
|
|
29
|
+
function expandHome(p) {
|
|
30
|
+
if (!p) return '';
|
|
31
|
+
if (p === '~') return homedir();
|
|
32
|
+
if (p.startsWith('~/') || p.startsWith('~\\')) {
|
|
33
|
+
return join(homedir(), p.slice(2));
|
|
34
|
+
}
|
|
35
|
+
return p;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Config ──────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function loadCredentials() {
|
|
41
|
+
// Env vars take priority
|
|
42
|
+
let token = process.env.NOESIS_API_TOKEN;
|
|
43
|
+
let url = process.env.NOESIS_API_URL;
|
|
44
|
+
|
|
45
|
+
if (token && url) return { token, url };
|
|
46
|
+
|
|
47
|
+
// Fall back to ~/.claude/.mcp.json
|
|
48
|
+
const mcpPath = join(homedir(), '.claude', '.mcp.json');
|
|
49
|
+
if (!existsSync(mcpPath)) {
|
|
50
|
+
console.error(`ERROR: No credentials. Set NOESIS_API_TOKEN + NOESIS_API_URL or configure ${mcpPath}`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
const mcp = JSON.parse(readFileSync(mcpPath, 'utf-8'));
|
|
54
|
+
const env = mcp?.mcpServers?.noesis?.env;
|
|
55
|
+
if (!env?.NOESIS_API_TOKEN || !env?.NOESIS_API_URL) {
|
|
56
|
+
console.error('ERROR: mcpServers.noesis.env missing NOESIS_API_TOKEN or NOESIS_API_URL in', mcpPath);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
return { token: env.NOESIS_API_TOKEN, url: env.NOESIS_API_URL };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── HTTP helpers ────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
async function api(method, path, body, { token, url }) {
|
|
65
|
+
const res = await fetch(`${url.replace(/\/$/, '')}${path}`, {
|
|
66
|
+
method,
|
|
67
|
+
headers: {
|
|
68
|
+
'Authorization': `Bearer ${token}`,
|
|
69
|
+
'Content-Type': 'application/json',
|
|
70
|
+
},
|
|
71
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const err = await res.json().catch(() => ({}));
|
|
75
|
+
throw new Error(`API ${method} ${path} → ${res.status}: ${err.error || res.statusText}`);
|
|
76
|
+
}
|
|
77
|
+
return res.json();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Hashing (matches NoesisClient.computeHash) ─────────────────────
|
|
81
|
+
|
|
82
|
+
function computeHash(content) {
|
|
83
|
+
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
84
|
+
return createHash('sha256').update(normalized, 'utf8').digest('hex');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Git/root detection (mirrors tools/index.ts) ─────────────────────
|
|
88
|
+
|
|
89
|
+
function findGitRoot(startPath) {
|
|
90
|
+
let current = resolve(startPath);
|
|
91
|
+
while (current !== dirname(current)) {
|
|
92
|
+
if (existsSync(join(current, '.git'))) return current;
|
|
93
|
+
current = dirname(current);
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function detectRootFromCwd() {
|
|
99
|
+
const gitRoot = findGitRoot(process.cwd());
|
|
100
|
+
if (!gitRoot) return null;
|
|
101
|
+
return { rootPath: dirname(gitRoot), projectName: basename(gitRoot) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function detectProjectForFile(filePath, rootPath) {
|
|
105
|
+
const rel = relative(rootPath, filePath);
|
|
106
|
+
const parts = rel.split(sep).filter(Boolean);
|
|
107
|
+
// Walk up from file to find .git
|
|
108
|
+
let current = resolve(filePath);
|
|
109
|
+
while (current !== resolve(rootPath) && current !== dirname(current)) {
|
|
110
|
+
current = dirname(current);
|
|
111
|
+
if (existsSync(join(current, '.git'))) return basename(current);
|
|
112
|
+
}
|
|
113
|
+
return parts[0] || basename(dirname(filePath));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Path normalization ───────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Normalize a file path: forward slashes + lowercase drive letter on Windows.
|
|
120
|
+
*/
|
|
121
|
+
function normalizePath(p) {
|
|
122
|
+
let normalized = p.replace(/\\/g, '/');
|
|
123
|
+
if (normalized.length >= 2 && normalized[1] === ':') {
|
|
124
|
+
normalized = normalized[0].toLowerCase() + normalized.slice(1);
|
|
125
|
+
}
|
|
126
|
+
return normalized;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── File scanning ───────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
function scanMarkdownFiles(rootPath) {
|
|
132
|
+
const files = [];
|
|
133
|
+
function walk(dir) {
|
|
134
|
+
let entries;
|
|
135
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
136
|
+
for (const e of entries) {
|
|
137
|
+
const full = join(dir, e.name);
|
|
138
|
+
if (e.isDirectory()) {
|
|
139
|
+
if (e.name.startsWith('.') && e.name !== '.noesis' && e.name !== '.claude-notes') continue;
|
|
140
|
+
if (e.name === 'node_modules' || e.name === 'dist' || e.name === '.git') continue;
|
|
141
|
+
walk(full);
|
|
142
|
+
} else if (e.name.endsWith('.md')) {
|
|
143
|
+
files.push(full);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
walk(rootPath);
|
|
148
|
+
return files;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Parse YAML frontmatter ──────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
function parseFrontmatter(content) {
|
|
154
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
155
|
+
if (!match) return {};
|
|
156
|
+
const meta = {};
|
|
157
|
+
const lines = match[1].split('\n');
|
|
158
|
+
let i = 0;
|
|
159
|
+
while (i < lines.length) {
|
|
160
|
+
const keyMatch = lines[i].match(/^(\w[\w-]*):\s*(.*)/);
|
|
161
|
+
if (!keyMatch) { i++; continue; }
|
|
162
|
+
const key = keyMatch[1];
|
|
163
|
+
const inline = keyMatch[2].trim();
|
|
164
|
+
|
|
165
|
+
// Block scalar: >-, >, |-, |
|
|
166
|
+
if (/^[>|]-?$/.test(inline)) {
|
|
167
|
+
const parts = [];
|
|
168
|
+
i++;
|
|
169
|
+
while (i < lines.length && /^\s+/.test(lines[i])) {
|
|
170
|
+
parts.push(lines[i].trim());
|
|
171
|
+
i++;
|
|
172
|
+
}
|
|
173
|
+
meta[key] = parts.join(inline.startsWith('>') ? ' ' : '\n');
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Array: key with no inline value, followed by " - item" lines
|
|
178
|
+
if (inline === '') {
|
|
179
|
+
const items = [];
|
|
180
|
+
let j = i + 1;
|
|
181
|
+
while (j < lines.length && /^\s+-\s+/.test(lines[j])) {
|
|
182
|
+
items.push(lines[j].replace(/^\s+-\s+/, '').trim());
|
|
183
|
+
j++;
|
|
184
|
+
}
|
|
185
|
+
if (items.length > 0) {
|
|
186
|
+
meta[key] = items;
|
|
187
|
+
i = j;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Simple key: value
|
|
193
|
+
if (inline) {
|
|
194
|
+
meta[key] = inline.replace(/^["']|["']$/g, '');
|
|
195
|
+
}
|
|
196
|
+
i++;
|
|
197
|
+
}
|
|
198
|
+
return meta;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Main sync logic ─────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
async function syncFiles(filePaths, { creds, dryRun, force, rootFilter }) {
|
|
204
|
+
// Get roots
|
|
205
|
+
const { roots } = await api('GET', '/api/mcp/roots?forSync=true', null, creds);
|
|
206
|
+
let targetRoots = roots;
|
|
207
|
+
|
|
208
|
+
// Auto-detect or create root if none match
|
|
209
|
+
const detected = detectRootFromCwd();
|
|
210
|
+
|
|
211
|
+
if (rootFilter) {
|
|
212
|
+
targetRoots = roots.filter(r => r.name.toLowerCase().includes(rootFilter.toLowerCase()));
|
|
213
|
+
if (targetRoots.length === 0) {
|
|
214
|
+
console.error(`No root matching "${rootFilter}". Available: ${roots.map(r => r.name).join(', ')}`);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// If syncing specific files, resolve their roots
|
|
220
|
+
if (filePaths.length > 0) {
|
|
221
|
+
return await syncSpecificFiles(filePaths, targetRoots, detected, creds, dryRun, force);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Full root sync — auto-detect from CWD
|
|
225
|
+
if (!rootFilter && detected) {
|
|
226
|
+
const match = targetRoots.find(r =>
|
|
227
|
+
resolve(r.path).toLowerCase() === resolve(detected.rootPath).toLowerCase()
|
|
228
|
+
);
|
|
229
|
+
if (match) {
|
|
230
|
+
targetRoots = [match];
|
|
231
|
+
} else if (!dryRun) {
|
|
232
|
+
// Auto-create root
|
|
233
|
+
console.log(`Creating new root: ${basename(detected.rootPath)} → ${detected.rootPath}`);
|
|
234
|
+
const { root } = await api('POST', '/api/mcp/roots', {
|
|
235
|
+
name: basename(detected.rootPath),
|
|
236
|
+
path: detected.rootPath,
|
|
237
|
+
}, creds);
|
|
238
|
+
targetRoots = [{ ...root, last_scanned_at: null }];
|
|
239
|
+
} else {
|
|
240
|
+
console.log(`[dry-run] Would create root: ${detected.rootPath}`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (targetRoots.length === 0) {
|
|
246
|
+
console.error('No roots to sync. Run from a git repo or specify --root.');
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let totalAdded = 0, totalUpdated = 0, totalSkipped = 0;
|
|
251
|
+
|
|
252
|
+
for (const root of targetRoots) {
|
|
253
|
+
// Tilde-expand cloud-flow paths (~/Noesis/...) once at the top of the
|
|
254
|
+
// loop so all downstream uses see the absolute on-disk path.
|
|
255
|
+
const rootPath = expandHome(root.path);
|
|
256
|
+
if (!rootPath) {
|
|
257
|
+
console.log(`\nSkipping root '${root.name}': no path configured for this OS`);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
console.log(`\nSyncing root: ${root.name} (${rootPath})`);
|
|
261
|
+
|
|
262
|
+
const mdFiles = scanMarkdownFiles(rootPath);
|
|
263
|
+
console.log(` Found ${mdFiles.length} markdown files`);
|
|
264
|
+
|
|
265
|
+
// Get existing hashes from cloud
|
|
266
|
+
const { hashes } = await api('GET', `/api/mcp/roots/${root.id}/hashes`, null, creds);
|
|
267
|
+
|
|
268
|
+
let added = 0, updated = 0, skipped = 0;
|
|
269
|
+
|
|
270
|
+
for (const filePath of mdFiles) {
|
|
271
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
272
|
+
const hash = computeHash(content);
|
|
273
|
+
const stats = statSync(filePath);
|
|
274
|
+
const relativePath = normalizePath(relative(rootPath, filePath));
|
|
275
|
+
const existingHash = hashes[relativePath];
|
|
276
|
+
|
|
277
|
+
if (existingHash === hash && !force) { skipped++; continue; }
|
|
278
|
+
const action = existingHash ? 'update' : 'create';
|
|
279
|
+
|
|
280
|
+
if (dryRun) {
|
|
281
|
+
console.log(` [dry-run] Would ${action}: ${relativePath}`);
|
|
282
|
+
action === 'create' ? added++ : updated++;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const project = detectProjectForFile(filePath, rootPath);
|
|
287
|
+
const metadata = parseFrontmatter(content);
|
|
288
|
+
|
|
289
|
+
await api('POST', '/api/mcp/notes/upsert', {
|
|
290
|
+
file: {
|
|
291
|
+
path: normalizePath(filePath),
|
|
292
|
+
relativePath,
|
|
293
|
+
content,
|
|
294
|
+
hash,
|
|
295
|
+
mtime: stats.mtime,
|
|
296
|
+
size: stats.size,
|
|
297
|
+
rootId: root.id,
|
|
298
|
+
rootName: root.name,
|
|
299
|
+
project,
|
|
300
|
+
},
|
|
301
|
+
metadata,
|
|
302
|
+
force,
|
|
303
|
+
}, creds);
|
|
304
|
+
|
|
305
|
+
console.log(` ${action === 'create' ? '+' : '~'} ${relativePath}`);
|
|
306
|
+
action === 'create' ? added++ : updated++;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
console.log(` Done: +${added} ~${updated} =${skipped}`);
|
|
310
|
+
totalAdded += added; totalUpdated += updated; totalSkipped += skipped;
|
|
311
|
+
|
|
312
|
+
if (!dryRun && (added > 0 || updated > 0)) {
|
|
313
|
+
await api('POST', '/api/mcp/sync/log', {
|
|
314
|
+
rootId: root.id,
|
|
315
|
+
filesScanned: mdFiles.length,
|
|
316
|
+
filesAdded: added,
|
|
317
|
+
filesUpdated: updated,
|
|
318
|
+
filesDeleted: 0,
|
|
319
|
+
source: 'noesis-sync-script',
|
|
320
|
+
}, creds);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
console.log(`\nTotal: +${totalAdded} ~${totalUpdated} =${totalSkipped}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function syncSpecificFiles(filePaths, roots, detected, creds, dryRun, force) {
|
|
328
|
+
let added = 0, updated = 0, errors = 0;
|
|
329
|
+
const affectedRoots = new Map();
|
|
330
|
+
|
|
331
|
+
for (const filePath of filePaths) {
|
|
332
|
+
const absPath = normalizePath(resolve(filePath));
|
|
333
|
+
|
|
334
|
+
if (!absPath.endsWith('.md')) {
|
|
335
|
+
console.error(` SKIP (not .md): ${filePath}`);
|
|
336
|
+
errors++; continue;
|
|
337
|
+
}
|
|
338
|
+
if (!existsSync(absPath)) {
|
|
339
|
+
console.error(` SKIP (not found): ${filePath}`);
|
|
340
|
+
errors++; continue;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Find matching root (case-insensitive path comparison for Windows)
|
|
344
|
+
let matchingRoot = roots.find(r => absPath.startsWith(normalizePath(resolve(r.path))));
|
|
345
|
+
|
|
346
|
+
if (!matchingRoot && detected) {
|
|
347
|
+
// Try auto-creating root from CWD detection
|
|
348
|
+
const existingByPath = roots.find(r =>
|
|
349
|
+
normalizePath(resolve(r.path)) === normalizePath(resolve(detected.rootPath))
|
|
350
|
+
);
|
|
351
|
+
if (existingByPath) {
|
|
352
|
+
matchingRoot = existingByPath;
|
|
353
|
+
} else if (!dryRun) {
|
|
354
|
+
console.log(`Creating root: ${basename(detected.rootPath)} → ${detected.rootPath}`);
|
|
355
|
+
const { root } = await api('POST', '/api/mcp/roots', {
|
|
356
|
+
name: basename(detected.rootPath),
|
|
357
|
+
path: normalizePath(detected.rootPath),
|
|
358
|
+
}, creds);
|
|
359
|
+
matchingRoot = root;
|
|
360
|
+
roots.push(root);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!matchingRoot) {
|
|
365
|
+
console.error(` SKIP (no root): ${filePath}`);
|
|
366
|
+
errors++; continue;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const content = readFileSync(absPath, 'utf-8');
|
|
370
|
+
const hash = computeHash(content);
|
|
371
|
+
const stats = statSync(absPath);
|
|
372
|
+
const relativePath = normalizePath(relative(matchingRoot.path, absPath));
|
|
373
|
+
const project = detectProjectForFile(absPath, matchingRoot.path);
|
|
374
|
+
const metadata = parseFrontmatter(content);
|
|
375
|
+
|
|
376
|
+
if (dryRun) {
|
|
377
|
+
console.log(` [dry-run] Would sync: ${relativePath} → root "${matchingRoot.name}"`);
|
|
378
|
+
added++; continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const result = await api('POST', '/api/mcp/notes/upsert', {
|
|
382
|
+
file: {
|
|
383
|
+
path: absPath,
|
|
384
|
+
relativePath,
|
|
385
|
+
content,
|
|
386
|
+
hash,
|
|
387
|
+
mtime: stats.mtime,
|
|
388
|
+
size: stats.size,
|
|
389
|
+
rootId: matchingRoot.id,
|
|
390
|
+
rootName: matchingRoot.name,
|
|
391
|
+
project,
|
|
392
|
+
},
|
|
393
|
+
metadata,
|
|
394
|
+
force,
|
|
395
|
+
}, creds);
|
|
396
|
+
|
|
397
|
+
const action = result.action || 'synced';
|
|
398
|
+
console.log(` ${action}: ${relativePath}`);
|
|
399
|
+
action === 'created' ? added++ : updated++;
|
|
400
|
+
|
|
401
|
+
// Track for sync log
|
|
402
|
+
if (!affectedRoots.has(matchingRoot.id)) {
|
|
403
|
+
affectedRoots.set(matchingRoot.id, { root: matchingRoot, added: 0, updated: 0, scanned: 0 });
|
|
404
|
+
}
|
|
405
|
+
const rInfo = affectedRoots.get(matchingRoot.id);
|
|
406
|
+
rInfo.scanned++;
|
|
407
|
+
action === 'created' ? rInfo.added++ : rInfo.updated++;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Log sync for each affected root
|
|
411
|
+
if (!dryRun) {
|
|
412
|
+
for (const [rootId, info] of affectedRoots) {
|
|
413
|
+
if (info.added > 0 || info.updated > 0) {
|
|
414
|
+
await api('POST', '/api/mcp/sync/log', {
|
|
415
|
+
rootId,
|
|
416
|
+
filesScanned: info.scanned,
|
|
417
|
+
filesAdded: info.added,
|
|
418
|
+
filesUpdated: info.updated,
|
|
419
|
+
filesDeleted: 0,
|
|
420
|
+
source: 'noesis-sync-script',
|
|
421
|
+
}, creds);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
console.log(`\nDone: +${added} ~${updated}${errors ? ` errors:${errors}` : ''}`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── CLI ─────────────────────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
const args = process.argv.slice(2);
|
|
432
|
+
const dryRun = args.includes('--dry-run');
|
|
433
|
+
const force = args.includes('--force');
|
|
434
|
+
const filesIdx = args.indexOf('--files');
|
|
435
|
+
const rootIdx = args.indexOf('--root');
|
|
436
|
+
|
|
437
|
+
let files = [];
|
|
438
|
+
let rootFilter = null;
|
|
439
|
+
|
|
440
|
+
if (filesIdx !== -1) {
|
|
441
|
+
// Collect all args after --files until next flag or end
|
|
442
|
+
for (let i = filesIdx + 1; i < args.length; i++) {
|
|
443
|
+
if (args[i].startsWith('--')) break;
|
|
444
|
+
files.push(args[i]);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (rootIdx !== -1 && args[rootIdx + 1]) {
|
|
449
|
+
rootFilter = args[rootIdx + 1];
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Positional arg that isn't a flag — treat as root name or file
|
|
453
|
+
if (files.length === 0 && !rootFilter) {
|
|
454
|
+
for (const a of args) {
|
|
455
|
+
if (a.startsWith('--')) continue;
|
|
456
|
+
if (a.endsWith('.md')) files.push(a);
|
|
457
|
+
else rootFilter = a;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const creds = loadCredentials();
|
|
462
|
+
console.log(`Noesis sync → ${creds.url}`);
|
|
463
|
+
if (dryRun) console.log('[DRY RUN MODE]');
|
|
464
|
+
if (force) console.log('[FORCE MODE]');
|
|
465
|
+
|
|
466
|
+
syncFiles(files, { creds, dryRun, force, rootFilter }).catch(err => {
|
|
467
|
+
console.error('Sync failed:', err.message);
|
|
468
|
+
process.exit(1);
|
|
469
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Polish a Noesis note's metadata and structure. Use a scope (today/session/catalog/cascade) or pass a specific note to iterate over multiple notes.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Refine Noesis notes: fill missing metadata (title, description, keywords, aliases), tighten structure (heading hierarchy, frontmatter completeness), refresh AI-rated quality and importance scores. Works on a single note or a batch discovered by scope.
|
|
6
|
+
|
|
7
|
+
## Arguments
|
|
8
|
+
|
|
9
|
+
$ARGUMENTS
|
|
10
|
+
|
|
11
|
+
Accepted forms:
|
|
12
|
+
|
|
13
|
+
- (no args) — ask the user to pick a scope via `AskUserQuestion`.
|
|
14
|
+
- `<path-or-filename>.md` — refine this specific note.
|
|
15
|
+
- `<topic-or-query>` — search for matching notes and offer them to the user.
|
|
16
|
+
- `today` — discover and refine notes referenced in today's conversation logs.
|
|
17
|
+
- `session [id]` — discover and refine notes referenced in a specific session (or the latest).
|
|
18
|
+
- `catalog <name>` — refine all notes in a named Noesis catalog.
|
|
19
|
+
- `cascade <anchor>` — refine the anchor note plus its semantically-similar neighbors.
|
|
20
|
+
- `--limit <N>` — cap how many notes a batch mode processes (default: 15).
|
|
21
|
+
- `--dry-run` — show what would change without writing.
|
|
22
|
+
|
|
23
|
+
## Workflow
|
|
24
|
+
|
|
25
|
+
### Step 1 — Parse mode
|
|
26
|
+
|
|
27
|
+
From `$ARGUMENTS`, determine `MODE` using the first matching rule:
|
|
28
|
+
|
|
29
|
+
| Priority | Pattern | Mode |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| 1 | `cascade <anchor>` | CASCADE |
|
|
32
|
+
| 2 | `today` (anywhere) | TODAY |
|
|
33
|
+
| 3 | `catalog <name>` | CATALOG |
|
|
34
|
+
| 4 | `session [<id>]` | SESSION |
|
|
35
|
+
| 5 | Ends in `.md` (path or filename) | SINGLE |
|
|
36
|
+
| 6 | Non-empty string (treated as search query) | SEARCH |
|
|
37
|
+
| 7 | Empty | ASK |
|
|
38
|
+
|
|
39
|
+
For `ASK`, prompt the user via `AskUserQuestion` with options for each mode and proceed once they pick.
|
|
40
|
+
|
|
41
|
+
### Step 2 — Discover candidate notes
|
|
42
|
+
|
|
43
|
+
Cap the candidate list per mode:
|
|
44
|
+
|
|
45
|
+
- **SINGLE**: just the one note. Resolve filename to a path via `mcp__noesis__search_notes` if needed.
|
|
46
|
+
- **SEARCH**: call `mcp__noesis__search_notes(query)` with the user's query; show the top 10, ask the user to pick one or more.
|
|
47
|
+
- **TODAY**: read `~/.claude/logs/console_*.clog` files dated today, extract note paths and search terms mentioned, deduplicate, cap at `--limit` (default 15).
|
|
48
|
+
- **SESSION**: same as TODAY but scoped to the chosen session UUID (or the latest if unspecified).
|
|
49
|
+
- **CATALOG**: call `mcp__noesis__list_notes(catalog: <name>)`, cap at `--limit` (default 25).
|
|
50
|
+
- **CASCADE**: resolve the anchor note's ID via `mcp__noesis__get_note(path)`, then call `mcp__noesis__find_similar_notes(note_id, limit: --limit ?? 10)`. Include the anchor itself.
|
|
51
|
+
|
|
52
|
+
Show the discovered list to the user and ask them to confirm or trim before refining. Sort by ascending `quality_score` (worst first) so the most-needed refinement happens first.
|
|
53
|
+
|
|
54
|
+
### Step 3 — Refine each note sequentially
|
|
55
|
+
|
|
56
|
+
For each candidate note:
|
|
57
|
+
|
|
58
|
+
1. **Read current state:** `mcp__noesis__get_note(path|id)` to get the full metadata + content.
|
|
59
|
+
2. **Fill missing metadata:** call `mcp__noesis__enhance_note_metadata(note_id)` with the relevant fields requested (`title`, `description`, `keywords`, `aliases`). The MCP tool uses AI to derive values from the note content. Apply with user approval if the change looks substantial; auto-apply if filling pure gaps (e.g., empty `keywords`).
|
|
60
|
+
3. **Structural suggestions** (light touch — skip if `--dry-run`):
|
|
61
|
+
- Frontmatter completeness: title / description / keywords / aliases / date / updated / status / importance_score / quality_score.
|
|
62
|
+
- Heading hierarchy: surface any H3+ without parent H2, or duplicate H1s.
|
|
63
|
+
- Suggest and apply with `Edit` after user approval per file.
|
|
64
|
+
4. **Refresh scores:** call `mcp__noesis__rate_quality(note_id)` and `mcp__noesis__rate_importance(note_id)` — these run an AI rating pass; track the delta vs the prior values.
|
|
65
|
+
5. **Push:** if anything changed locally, call `mcp__noesis__sync_notes(files: [<absolute path>])` to push.
|
|
66
|
+
|
|
67
|
+
### Step 4 — Report
|
|
68
|
+
|
|
69
|
+
Per-note line:
|
|
70
|
+
|
|
71
|
+
- `refined <path>: metadata filled (Δ desc / kw / aliases), quality <old> → <new>, importance <old> → <new>`
|
|
72
|
+
- `refined <path>: no changes needed`
|
|
73
|
+
- `skipped <path> (<reason>)`
|
|
74
|
+
|
|
75
|
+
Final tally:
|
|
76
|
+
|
|
77
|
+
- `Total: T. Refined: R. Score delta: avg +X quality / +Y importance. Skipped: S. Errors: E.`
|
|
78
|
+
|
|
79
|
+
## Constraints
|
|
80
|
+
|
|
81
|
+
- Never overwrite a user-authored description with an AI-generated one without confirmation. The MCP tool returns suggestions; you apply them only after the user OKs substantial replacements.
|
|
82
|
+
- Never touch note `content` body text in refinement — only frontmatter and structural markers (headings, list normalization). Body rewrites are out of scope; that's an editing task, not a refinement.
|
|
83
|
+
- One note at a time — sequential, not parallel. Each push uses `sync_notes(files:[...])` so conflicts surface per-note.
|
|
84
|
+
- If a sync push returns conflict, hand off to `/noesis-sync` (which has the conflict-resolution flow embedded) or stop the batch and report.
|
|
85
|
+
|
|
86
|
+
## Examples
|
|
87
|
+
|
|
88
|
+
- `/noesis-refine-note c:/path/to/note.md` — refine a single note.
|
|
89
|
+
- `/noesis-refine-note today` — refine notes touched today.
|
|
90
|
+
- `/noesis-refine-note catalog Work` — refine the Work catalog.
|
|
91
|
+
- `/noesis-refine-note cascade c:/path/to/anchor.md` — refine the anchor and its semantic neighbors.
|
|
92
|
+
- `/noesis-refine-note today --dry-run` — preview only.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Sync local Noesis notes with the cloud — pulls web-UI edits, pushes local changes, resolves conflicts inline.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
The one-stop sync command for Noesis notes. Discovers notes edited via the Noesis web UI (Quick Fix), pulls them locally first, pushes your local changes, and resolves any conflicts inline with your input.
|
|
6
|
+
|
|
7
|
+
## Arguments
|
|
8
|
+
|
|
9
|
+
$ARGUMENTS
|
|
10
|
+
|
|
11
|
+
Accepted forms:
|
|
12
|
+
- (no args) — auto-scope: sync files edited in this conversation, else the current project root.
|
|
13
|
+
- `--files <absolute-paths>` — push these specific files (still pulls web-UI edits first for the matching root).
|
|
14
|
+
- `<root-name>` — bulk sync the named root (full scan).
|
|
15
|
+
- `--dry-run` — preview all actions without writing anything.
|
|
16
|
+
- `--skip-online-edits` — skip the web-UI-edit pull step (advanced; rarely needed).
|
|
17
|
+
|
|
18
|
+
## Workflow
|
|
19
|
+
|
|
20
|
+
CRITICAL: never start backend servers, write temporary scripts, or improvise HTTP calls. The cloud API at noesis-notes.vercel.app is always available through the MCP tool or the bundled script.
|
|
21
|
+
|
|
22
|
+
### Step 1 — Discover web-UI edits (unless `--skip-online-edits`)
|
|
23
|
+
|
|
24
|
+
Call `mcp__noesis__list_edited_online_notes` (filter by `root_id` if `$ARGUMENTS` names a root). For each returned note inspect the `localStatus`:
|
|
25
|
+
|
|
26
|
+
- `unchanged` — local file matches the last sync baseline; pull is safe and silent.
|
|
27
|
+
- `not_on_disk` — file does not exist locally; pull creates it (auto-pull, no conflict possible).
|
|
28
|
+
- `also_modified` — local file has diverged from the baseline. Tell the user the conflict cascade will run for those entries and ask `AskUserQuestion` to confirm before proceeding. If they decline, skip Step 2 for the `also_modified` entries and continue to Step 3 for the rest.
|
|
29
|
+
|
|
30
|
+
If `list_edited_online_notes` returns an empty list, skip to Step 3.
|
|
31
|
+
|
|
32
|
+
### Step 2 — Pull web-UI edits
|
|
33
|
+
|
|
34
|
+
For each pending note from Step 1 (excluding any the user declined), call:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
mcp__noesis__sync_notes(files: ["<absolute path = root_path + '/' + relative_path>"])
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Process one file at a time so per-file conflicts surface immediately. Track each result for the Step 5 summary.
|
|
41
|
+
|
|
42
|
+
### Step 3 — Push local changes
|
|
43
|
+
|
|
44
|
+
Apply scoping:
|
|
45
|
+
|
|
46
|
+
1. **Specific files just edited in this conversation** → pass `files: [<paths>]` (do NOT sync everything else).
|
|
47
|
+
2. **User passed `--files <paths>`** → use those paths.
|
|
48
|
+
3. **User passed a root name** → pass `root: "<name>"` (slow full scan).
|
|
49
|
+
4. **No context, no args** → pass `root` matching the current project directory if it maps to a registered root; otherwise fall back to `files` of the files edited in this conversation.
|
|
50
|
+
|
|
51
|
+
Choose the tier:
|
|
52
|
+
|
|
53
|
+
- **Tier 1 (preferred):** call `mcp__noesis__sync_notes` with the resolved scope (and `dryRun: true` if `--dry-run`).
|
|
54
|
+
- **Tier 2 (MCP unavailable):** run `node "{{NOESIS_MCP_SCRIPT_PATH}}" [args]` — the bundled script reads its token from `~/.claude.json` → `mcpServers.noesis.env.NOESIS_API_TOKEN` and talks to the cloud API directly. Pass the same CLI args (`--files`, `--root`, `--dry-run`).
|
|
55
|
+
- **Tier 3 (no Node.js):** call the cloud HTTPS endpoints directly via `curl`. Read the token from `~/.claude.json` as above.
|
|
56
|
+
```bash
|
|
57
|
+
curl -H "Authorization: Bearer $TOKEN" https://noesis-notes.vercel.app/api/mcp/roots?forSync=true
|
|
58
|
+
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
|
59
|
+
https://noesis-notes.vercel.app/api/mcp/notes/upsert \
|
|
60
|
+
-d '{"file":{"path":"...","relativePath":"...","content":"...","hash":"...","rootId":N,"rootName":"...","project":"..."},"metadata":{}}'
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Step 4 — Resolve conflicts inline (if any)
|
|
64
|
+
|
|
65
|
+
If Step 2 or Step 3 reported `conflicts.length > 0` (Tier C of the bidirectional sync — overlapping hunks that anchor reapply and `node-diff3` could not auto-merge), do this PER conflicting file rather than reporting "run another skill":
|
|
66
|
+
|
|
67
|
+
1. **Gather three sides:**
|
|
68
|
+
- **CLOUD** — `mcp__noesis__get_note(id)` (id is in the conflict payload, or resolve via `mcp__noesis__list_notes` filtered to the relative path).
|
|
69
|
+
- **LOCAL** — `Read` the local file at the resolved absolute path.
|
|
70
|
+
- **BASE** — read `.noesis/baseline/<relative_path>` under the root. If the baseline file is missing (legacy sync state), tell the user and ask whether to treat as a two-way merge or skip the file.
|
|
71
|
+
|
|
72
|
+
2. **Classify each pair of overlapping hunks:**
|
|
73
|
+
- **Trivial overlap** (one side adds, other doesn't touch; or one side is a strict superset) → auto-merge silently with the inclusive result.
|
|
74
|
+
- **Mixed overlap** → resolve the trivial parts automatically; STOP and ask only about the irreconcilable parts.
|
|
75
|
+
- **Semantic overlap** (opposing edits, contradictory rewrites) → present the diff and ask via `AskUserQuestion`: `keep-local` / `keep-cloud` / paste merged text. NEVER silently overwrite.
|
|
76
|
+
|
|
77
|
+
3. **Apply the merge:**
|
|
78
|
+
- `Write` the merged content to the local file.
|
|
79
|
+
- Call `mcp__noesis__sync_notes(files: [<absolute path>])` to push the resolved version.
|
|
80
|
+
- If the push returns 409 (cloud changed since this step started), the cloud was updated concurrently — repeat Step 4 for that file from the top.
|
|
81
|
+
- After a clean push, call `POST /api/mcp/notes/:id/clear-conflict` (via `Bash` + `curl` with the API token) to clear the cloud `conflict_marker`.
|
|
82
|
+
|
|
83
|
+
Process one file at a time. Do NOT batch a chosen-text response across multiple files.
|
|
84
|
+
|
|
85
|
+
### Step 5 — Report
|
|
86
|
+
|
|
87
|
+
Print one line per file processed:
|
|
88
|
+
|
|
89
|
+
- `pulled (web-UI edit applied locally)` — Step 2 succeeded with no conflict.
|
|
90
|
+
- `pushed (local change applied to cloud)` — Step 3 succeeded with no conflict.
|
|
91
|
+
- `merged auto for <path>` — Step 4 resolved trivial overlap without user input.
|
|
92
|
+
- `merged by user for <path>` — Step 4 resolved with explicit user choice.
|
|
93
|
+
- `skipped <path> (<reason>)` — user declined, baseline missing, or other.
|
|
94
|
+
|
|
95
|
+
End with a final tally: `Pulled: N. Pushed: M. Merged: K (auto X / by-user Y). Skipped: Z. Errors: E.`
|
|
96
|
+
|
|
97
|
+
## Constraints
|
|
98
|
+
|
|
99
|
+
- Never silently overwrite opposing edits — always present the diff and ask.
|
|
100
|
+
- Never modify cloud-side content directly via `PUT /api/notes/:id` — go through `sync_notes` so the optimistic-concurrency 409 catches concurrent changes.
|
|
101
|
+
- Never strip frontmatter or H1 from either side during merge — preserve verbatim from whichever side wrote them.
|
|
102
|
+
- One file at a time for conflict resolution.
|
|
103
|
+
|
|
104
|
+
## Examples
|
|
105
|
+
|
|
106
|
+
- `/noesis-sync` — auto-scope to recent conversation edits or current project root.
|
|
107
|
+
- `/noesis-sync --dry-run` — preview the full flow without writing.
|
|
108
|
+
- `/noesis-sync --files <absolute-path>` — push a specific file (still pulls web-UI edits first).
|
|
109
|
+
- `/noesis-sync <root-name>` — bulk sync a named root.
|
|
110
|
+
- `/noesis-sync --skip-online-edits` — push only, skip the web-UI pull step.
|