granite-mem 0.1.3 → 0.1.5
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 +72 -33
- package/dist/index.js +1743 -42
- package/package.json +8 -4
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { Command } from "commander";
|
|
4
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
5
5
|
|
|
6
6
|
// src/commands/init.ts
|
|
7
7
|
import fs3 from "fs";
|
|
@@ -109,7 +109,7 @@ function writeDefaultConfig(dir) {
|
|
|
109
109
|
function loadConfig(vaultRoot) {
|
|
110
110
|
const configPath = path.join(vaultRoot, CONFIG_FILENAME);
|
|
111
111
|
if (!fs.existsSync(configPath)) {
|
|
112
|
-
throw new Error(`No ${CONFIG_FILENAME} found in ${vaultRoot}. Run "
|
|
112
|
+
throw new Error(`No ${CONFIG_FILENAME} found in ${vaultRoot}. Run "granite init" first.`);
|
|
113
113
|
}
|
|
114
114
|
const raw = fs.readFileSync(configPath, "utf-8");
|
|
115
115
|
const parsed = yaml.load(raw);
|
|
@@ -143,7 +143,7 @@ function findVaultRoot(from) {
|
|
|
143
143
|
function requireVaultRoot(from) {
|
|
144
144
|
const root = findVaultRoot(from);
|
|
145
145
|
if (!root) {
|
|
146
|
-
throw new Error(`No Granite vault found. Run "
|
|
146
|
+
throw new Error(`No Granite vault found. Run "granite init" to create ${getDefaultVaultRoot()}.`);
|
|
147
147
|
}
|
|
148
148
|
return root;
|
|
149
149
|
}
|
|
@@ -180,11 +180,11 @@ function initVault(dir = getDefaultVaultRoot()) {
|
|
|
180
180
|
console.log(` ${typeConfig.folder}/ (${name})`);
|
|
181
181
|
}
|
|
182
182
|
console.log("");
|
|
183
|
-
console.log('Next:
|
|
183
|
+
console.log('Next: granite new "My first note" --type fleeting');
|
|
184
184
|
}
|
|
185
185
|
|
|
186
186
|
// src/commands/new.ts
|
|
187
|
-
import
|
|
187
|
+
import fs10 from "fs";
|
|
188
188
|
|
|
189
189
|
// src/core/note.ts
|
|
190
190
|
import fs4 from "fs";
|
|
@@ -533,8 +533,8 @@ function countNoteFiles(vaultRoot, config) {
|
|
|
533
533
|
}
|
|
534
534
|
function upsertNoteAndLinks(db, note) {
|
|
535
535
|
const upsertNote = db.prepare(`
|
|
536
|
-
INSERT INTO notes (slug, id, title, type, created, modified, tags, aliases, body, filepath)
|
|
537
|
-
VALUES (@slug, @id, @title, @type, @created, @modified, @tags, @aliases, @body, @filepath)
|
|
536
|
+
INSERT INTO notes (slug, id, title, type, created, modified, tags, aliases, body, filepath, status, source)
|
|
537
|
+
VALUES (@slug, @id, @title, @type, @created, @modified, @tags, @aliases, @body, @filepath, @status, @source)
|
|
538
538
|
ON CONFLICT(slug) DO UPDATE SET
|
|
539
539
|
id = excluded.id,
|
|
540
540
|
title = excluded.title,
|
|
@@ -544,7 +544,9 @@ function upsertNoteAndLinks(db, note) {
|
|
|
544
544
|
tags = excluded.tags,
|
|
545
545
|
aliases = excluded.aliases,
|
|
546
546
|
body = excluded.body,
|
|
547
|
-
filepath = excluded.filepath
|
|
547
|
+
filepath = excluded.filepath,
|
|
548
|
+
status = excluded.status,
|
|
549
|
+
source = excluded.source
|
|
548
550
|
`);
|
|
549
551
|
const deleteLinks = db.prepare("DELETE FROM links WHERE source_slug = ?");
|
|
550
552
|
const insertLink = db.prepare(`
|
|
@@ -562,7 +564,9 @@ function upsertNoteAndLinks(db, note) {
|
|
|
562
564
|
tags: JSON.stringify(note.frontmatter.tags),
|
|
563
565
|
aliases: JSON.stringify(note.frontmatter.aliases),
|
|
564
566
|
body: note.body,
|
|
565
|
-
filepath: note.filepath
|
|
567
|
+
filepath: note.filepath,
|
|
568
|
+
status: note.frontmatter.status ?? "active",
|
|
569
|
+
source: note.frontmatter.source ?? "human"
|
|
566
570
|
});
|
|
567
571
|
deleteLinks.run(note.slug);
|
|
568
572
|
const links = parseWikilinks(note.body);
|
|
@@ -1196,6 +1200,489 @@ function parseJsonArray(raw) {
|
|
|
1196
1200
|
}
|
|
1197
1201
|
}
|
|
1198
1202
|
|
|
1203
|
+
// src/core/sync/manager.ts
|
|
1204
|
+
import fs9 from "fs";
|
|
1205
|
+
import path9 from "path";
|
|
1206
|
+
|
|
1207
|
+
// src/core/sync/client.ts
|
|
1208
|
+
var SyncClient = class {
|
|
1209
|
+
baseUrl;
|
|
1210
|
+
apiKey;
|
|
1211
|
+
timeoutMs;
|
|
1212
|
+
constructor(config, timeoutMs = 5e3) {
|
|
1213
|
+
this.baseUrl = config.server.replace(/\/$/, "");
|
|
1214
|
+
this.apiKey = config.api_key;
|
|
1215
|
+
this.timeoutMs = timeoutMs;
|
|
1216
|
+
}
|
|
1217
|
+
async request(method, endpoint, body) {
|
|
1218
|
+
const controller = new AbortController();
|
|
1219
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
1220
|
+
try {
|
|
1221
|
+
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
|
1222
|
+
method,
|
|
1223
|
+
headers: {
|
|
1224
|
+
"Authorization": `Bearer ${this.apiKey}`,
|
|
1225
|
+
"Content-Type": "application/json"
|
|
1226
|
+
},
|
|
1227
|
+
body: body ? JSON.stringify(body) : void 0,
|
|
1228
|
+
signal: controller.signal
|
|
1229
|
+
});
|
|
1230
|
+
if (!response.ok) {
|
|
1231
|
+
const text = await response.text().catch(() => "");
|
|
1232
|
+
throw new SyncApiError(response.status, text || response.statusText);
|
|
1233
|
+
}
|
|
1234
|
+
return await response.json();
|
|
1235
|
+
} finally {
|
|
1236
|
+
clearTimeout(timer);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
async push(payload) {
|
|
1240
|
+
return this.request("POST", "/v1/sync/push", payload);
|
|
1241
|
+
}
|
|
1242
|
+
async pull(sinceSeq, deviceId) {
|
|
1243
|
+
return this.request("GET", `/v1/sync/pull?since_seq=${sinceSeq}&device_id=${deviceId}`);
|
|
1244
|
+
}
|
|
1245
|
+
async quickPull(sinceSeq, deviceId, timeoutMs = 200) {
|
|
1246
|
+
const oldTimeout = this.timeoutMs;
|
|
1247
|
+
this.timeoutMs = timeoutMs;
|
|
1248
|
+
try {
|
|
1249
|
+
return await this.pull(sinceSeq, deviceId);
|
|
1250
|
+
} catch {
|
|
1251
|
+
return null;
|
|
1252
|
+
} finally {
|
|
1253
|
+
this.timeoutMs = oldTimeout;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
async listDevices() {
|
|
1257
|
+
return this.request("GET", "/v1/sync/devices");
|
|
1258
|
+
}
|
|
1259
|
+
async ping() {
|
|
1260
|
+
try {
|
|
1261
|
+
await this.request("GET", "/v1/sync/ping");
|
|
1262
|
+
return true;
|
|
1263
|
+
} catch {
|
|
1264
|
+
return false;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
};
|
|
1268
|
+
var SyncApiError = class extends Error {
|
|
1269
|
+
constructor(statusCode, body) {
|
|
1270
|
+
super(`Sync API error ${statusCode}: ${body}`);
|
|
1271
|
+
this.statusCode = statusCode;
|
|
1272
|
+
this.body = body;
|
|
1273
|
+
this.name = "SyncApiError";
|
|
1274
|
+
}
|
|
1275
|
+
};
|
|
1276
|
+
|
|
1277
|
+
// src/core/sync/device.ts
|
|
1278
|
+
import fs6 from "fs";
|
|
1279
|
+
import path6 from "path";
|
|
1280
|
+
import os2 from "os";
|
|
1281
|
+
import { v4 as uuidv42 } from "uuid";
|
|
1282
|
+
var DEVICE_FILE = "device.json";
|
|
1283
|
+
function getDeviceFilePath(vaultRoot) {
|
|
1284
|
+
const graniteDir = path6.join(vaultRoot, ".granite");
|
|
1285
|
+
fs6.mkdirSync(graniteDir, { recursive: true });
|
|
1286
|
+
return path6.join(graniteDir, DEVICE_FILE);
|
|
1287
|
+
}
|
|
1288
|
+
function getOrCreateDeviceId(vaultRoot, deviceName) {
|
|
1289
|
+
const filePath = getDeviceFilePath(vaultRoot);
|
|
1290
|
+
if (fs6.existsSync(filePath)) {
|
|
1291
|
+
const raw = fs6.readFileSync(filePath, "utf-8");
|
|
1292
|
+
return JSON.parse(raw);
|
|
1293
|
+
}
|
|
1294
|
+
const info = {
|
|
1295
|
+
device_id: uuidv42(),
|
|
1296
|
+
device_name: deviceName || os2.hostname(),
|
|
1297
|
+
created: (/* @__PURE__ */ new Date()).toISOString()
|
|
1298
|
+
};
|
|
1299
|
+
fs6.writeFileSync(filePath, JSON.stringify(info, null, 2), "utf-8");
|
|
1300
|
+
return info;
|
|
1301
|
+
}
|
|
1302
|
+
function getDeviceId(vaultRoot) {
|
|
1303
|
+
return getOrCreateDeviceId(vaultRoot).device_id;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// src/core/sync/changelog.ts
|
|
1307
|
+
import Database2 from "better-sqlite3";
|
|
1308
|
+
import fs7 from "fs";
|
|
1309
|
+
import path7 from "path";
|
|
1310
|
+
import crypto from "crypto";
|
|
1311
|
+
var SYNC_DB_FILE = "sync.db";
|
|
1312
|
+
var SYNC_SCHEMA_SQL = `
|
|
1313
|
+
CREATE TABLE IF NOT EXISTS changelog (
|
|
1314
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1315
|
+
note_id TEXT NOT NULL,
|
|
1316
|
+
operation TEXT NOT NULL,
|
|
1317
|
+
timestamp TEXT NOT NULL,
|
|
1318
|
+
device_id TEXT NOT NULL,
|
|
1319
|
+
checksum TEXT NOT NULL,
|
|
1320
|
+
synced INTEGER NOT NULL DEFAULT 0
|
|
1321
|
+
);
|
|
1322
|
+
|
|
1323
|
+
CREATE INDEX IF NOT EXISTS idx_changelog_note_id ON changelog(note_id);
|
|
1324
|
+
CREATE INDEX IF NOT EXISTS idx_changelog_synced ON changelog(synced);
|
|
1325
|
+
|
|
1326
|
+
CREATE TABLE IF NOT EXISTS sync_meta (
|
|
1327
|
+
key TEXT PRIMARY KEY,
|
|
1328
|
+
value TEXT
|
|
1329
|
+
);
|
|
1330
|
+
`;
|
|
1331
|
+
function getSyncDbPath(vaultRoot) {
|
|
1332
|
+
const graniteDir = path7.join(vaultRoot, ".granite");
|
|
1333
|
+
fs7.mkdirSync(graniteDir, { recursive: true });
|
|
1334
|
+
return path7.join(graniteDir, SYNC_DB_FILE);
|
|
1335
|
+
}
|
|
1336
|
+
function openSyncDb(vaultRoot) {
|
|
1337
|
+
const dbPath = getSyncDbPath(vaultRoot);
|
|
1338
|
+
const db = new Database2(dbPath);
|
|
1339
|
+
db.pragma("journal_mode = WAL");
|
|
1340
|
+
db.exec(SYNC_SCHEMA_SQL);
|
|
1341
|
+
return db;
|
|
1342
|
+
}
|
|
1343
|
+
function computeChecksum(content) {
|
|
1344
|
+
return crypto.createHash("sha256").update(content, "utf-8").digest("hex");
|
|
1345
|
+
}
|
|
1346
|
+
function recordChange(db, noteId, operation, deviceId, fileContent) {
|
|
1347
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1348
|
+
const checksum = computeChecksum(fileContent);
|
|
1349
|
+
const stmt = db.prepare(`
|
|
1350
|
+
INSERT INTO changelog (note_id, operation, timestamp, device_id, checksum, synced)
|
|
1351
|
+
VALUES (?, ?, ?, ?, ?, 0)
|
|
1352
|
+
`);
|
|
1353
|
+
const result = stmt.run(noteId, operation, now, deviceId, checksum);
|
|
1354
|
+
return {
|
|
1355
|
+
seq: result.lastInsertRowid,
|
|
1356
|
+
note_id: noteId,
|
|
1357
|
+
operation,
|
|
1358
|
+
timestamp: now,
|
|
1359
|
+
device_id: deviceId,
|
|
1360
|
+
checksum,
|
|
1361
|
+
synced: false
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
function getPendingChanges(db) {
|
|
1365
|
+
return db.prepare("SELECT * FROM changelog WHERE synced = 0 ORDER BY seq ASC").all();
|
|
1366
|
+
}
|
|
1367
|
+
function markChangesSynced(db, upToSeq) {
|
|
1368
|
+
db.prepare("UPDATE changelog SET synced = 1 WHERE seq <= ?").run(upToSeq);
|
|
1369
|
+
}
|
|
1370
|
+
function getLastServerSeq(db) {
|
|
1371
|
+
const row = db.prepare("SELECT value FROM sync_meta WHERE key = 'last_server_seq'").get();
|
|
1372
|
+
return row ? Number(row.value) : 0;
|
|
1373
|
+
}
|
|
1374
|
+
function setLastServerSeq(db, seq) {
|
|
1375
|
+
db.prepare("INSERT OR REPLACE INTO sync_meta (key, value) VALUES ('last_server_seq', ?)").run(String(seq));
|
|
1376
|
+
}
|
|
1377
|
+
function getLastSyncTime(db) {
|
|
1378
|
+
const row = db.prepare("SELECT value FROM sync_meta WHERE key = 'last_sync'").get();
|
|
1379
|
+
return row?.value ?? null;
|
|
1380
|
+
}
|
|
1381
|
+
function setLastSyncTime(db) {
|
|
1382
|
+
db.prepare("INSERT OR REPLACE INTO sync_meta (key, value) VALUES ('last_sync', ?)").run((/* @__PURE__ */ new Date()).toISOString());
|
|
1383
|
+
}
|
|
1384
|
+
function getPendingCount(db) {
|
|
1385
|
+
const row = db.prepare("SELECT COUNT(*) as c FROM changelog WHERE synced = 0").get();
|
|
1386
|
+
return row.c;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// src/core/sync/conflict.ts
|
|
1390
|
+
import fs8 from "fs";
|
|
1391
|
+
import path8 from "path";
|
|
1392
|
+
var CONFLICTS_DIR = ".granite/conflicts";
|
|
1393
|
+
function getConflictsDir(vaultRoot) {
|
|
1394
|
+
const dir = path8.join(vaultRoot, CONFLICTS_DIR);
|
|
1395
|
+
fs8.mkdirSync(dir, { recursive: true });
|
|
1396
|
+
return dir;
|
|
1397
|
+
}
|
|
1398
|
+
function detectConflict(localNote, remoteChange) {
|
|
1399
|
+
if (remoteChange.operation === "delete") return false;
|
|
1400
|
+
if (!remoteChange.frontmatter) return false;
|
|
1401
|
+
const localModified = new Date(localNote.frontmatter.modified).getTime();
|
|
1402
|
+
const remoteModified = new Date(remoteChange.frontmatter.modified).getTime();
|
|
1403
|
+
return localModified !== remoteModified && remoteChange.checksum !== "";
|
|
1404
|
+
}
|
|
1405
|
+
function resolveConflict(vaultRoot, localNote, remoteChange, deviceId) {
|
|
1406
|
+
if (!remoteChange.frontmatter || remoteChange.body === void 0) return null;
|
|
1407
|
+
const localModified = new Date(localNote.frontmatter.modified).getTime();
|
|
1408
|
+
const remoteModified = new Date(remoteChange.frontmatter.modified).getTime();
|
|
1409
|
+
const remoteWins = remoteModified > localModified;
|
|
1410
|
+
if (remoteWins) {
|
|
1411
|
+
const conflictFile2 = saveConflictBackup(vaultRoot, localNote, deviceId);
|
|
1412
|
+
return {
|
|
1413
|
+
note_id: localNote.frontmatter.id,
|
|
1414
|
+
local_modified: localNote.frontmatter.modified,
|
|
1415
|
+
remote_modified: remoteChange.frontmatter.modified,
|
|
1416
|
+
resolved: true,
|
|
1417
|
+
conflict_file: conflictFile2
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
const conflictFile = saveRemoteConflictBackup(vaultRoot, remoteChange);
|
|
1421
|
+
return {
|
|
1422
|
+
note_id: localNote.frontmatter.id,
|
|
1423
|
+
local_modified: localNote.frontmatter.modified,
|
|
1424
|
+
remote_modified: remoteChange.frontmatter.modified,
|
|
1425
|
+
resolved: true,
|
|
1426
|
+
conflict_file: conflictFile
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1429
|
+
function saveConflictBackup(vaultRoot, note, deviceId) {
|
|
1430
|
+
const conflictsDir = getConflictsDir(vaultRoot);
|
|
1431
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
1432
|
+
const filename = `${note.frontmatter.id}_${deviceId}_${timestamp}.md`;
|
|
1433
|
+
const filePath = path8.join(conflictsDir, filename);
|
|
1434
|
+
const content = serializeFrontmatter(note.frontmatter, note.body);
|
|
1435
|
+
fs8.writeFileSync(filePath, content, "utf-8");
|
|
1436
|
+
return filePath;
|
|
1437
|
+
}
|
|
1438
|
+
function saveRemoteConflictBackup(vaultRoot, change) {
|
|
1439
|
+
const conflictsDir = getConflictsDir(vaultRoot);
|
|
1440
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
1441
|
+
const filename = `${change.note_id}_remote_${timestamp}.md`;
|
|
1442
|
+
const filePath = path8.join(conflictsDir, filename);
|
|
1443
|
+
if (change.frontmatter && change.body !== void 0) {
|
|
1444
|
+
const content = serializeFrontmatter(change.frontmatter, change.body);
|
|
1445
|
+
fs8.writeFileSync(filePath, content, "utf-8");
|
|
1446
|
+
}
|
|
1447
|
+
return filePath;
|
|
1448
|
+
}
|
|
1449
|
+
function listConflicts(vaultRoot) {
|
|
1450
|
+
const conflictsDir = path8.join(vaultRoot, CONFLICTS_DIR);
|
|
1451
|
+
if (!fs8.existsSync(conflictsDir)) return [];
|
|
1452
|
+
return fs8.readdirSync(conflictsDir).filter((f) => f.endsWith(".md")).map((f) => path8.join(conflictsDir, f));
|
|
1453
|
+
}
|
|
1454
|
+
function clearResolvedConflicts(vaultRoot) {
|
|
1455
|
+
const files = listConflicts(vaultRoot);
|
|
1456
|
+
for (const f of files) {
|
|
1457
|
+
fs8.unlinkSync(f);
|
|
1458
|
+
}
|
|
1459
|
+
return files.length;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// src/core/sync/manager.ts
|
|
1463
|
+
var SyncManager = class {
|
|
1464
|
+
vaultRoot;
|
|
1465
|
+
config;
|
|
1466
|
+
syncConfig;
|
|
1467
|
+
client;
|
|
1468
|
+
deviceId;
|
|
1469
|
+
constructor(vaultRoot, config) {
|
|
1470
|
+
if (!config.sync?.enabled) {
|
|
1471
|
+
throw new Error("Sync is not enabled. Add a sync section to granite.yml.");
|
|
1472
|
+
}
|
|
1473
|
+
this.vaultRoot = vaultRoot;
|
|
1474
|
+
this.config = config;
|
|
1475
|
+
this.syncConfig = config.sync;
|
|
1476
|
+
this.client = new SyncClient(this.syncConfig);
|
|
1477
|
+
this.deviceId = getDeviceId(vaultRoot);
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* Track a local mutation and push it in the background (fire-and-forget).
|
|
1481
|
+
* This is the hook called after every note create/update/delete.
|
|
1482
|
+
*/
|
|
1483
|
+
trackAndPush(note, operation) {
|
|
1484
|
+
const db = openSyncDb(this.vaultRoot);
|
|
1485
|
+
try {
|
|
1486
|
+
const content = fs9.existsSync(note.filepath) ? fs9.readFileSync(note.filepath, "utf-8") : "";
|
|
1487
|
+
recordChange(db, note.frontmatter.id, operation, this.deviceId, content);
|
|
1488
|
+
} finally {
|
|
1489
|
+
db.close();
|
|
1490
|
+
}
|
|
1491
|
+
this.push().catch(() => {
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
/**
|
|
1495
|
+
* Push all pending local changes to the relay server.
|
|
1496
|
+
*/
|
|
1497
|
+
async push() {
|
|
1498
|
+
const db = openSyncDb(this.vaultRoot);
|
|
1499
|
+
try {
|
|
1500
|
+
const pending = getPendingChanges(db);
|
|
1501
|
+
if (pending.length === 0) {
|
|
1502
|
+
return { pushed: 0, server_seq: getLastServerSeq(db) };
|
|
1503
|
+
}
|
|
1504
|
+
const changes = [];
|
|
1505
|
+
for (const entry of pending) {
|
|
1506
|
+
const note = this.findNoteById(entry.note_id);
|
|
1507
|
+
changes.push({
|
|
1508
|
+
note_id: entry.note_id,
|
|
1509
|
+
operation: entry.operation,
|
|
1510
|
+
timestamp: entry.timestamp,
|
|
1511
|
+
checksum: entry.checksum,
|
|
1512
|
+
slug: note?.slug ?? "",
|
|
1513
|
+
frontmatter: note?.frontmatter,
|
|
1514
|
+
body: note?.body
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
const lastSeq = getLastServerSeq(db);
|
|
1518
|
+
const result = await this.client.push({
|
|
1519
|
+
device_id: this.deviceId,
|
|
1520
|
+
last_server_seq: lastSeq,
|
|
1521
|
+
changes
|
|
1522
|
+
});
|
|
1523
|
+
const maxSeq = Math.max(...pending.map((p) => p.seq));
|
|
1524
|
+
markChangesSynced(db, maxSeq);
|
|
1525
|
+
setLastServerSeq(db, result.server_seq);
|
|
1526
|
+
setLastSyncTime(db);
|
|
1527
|
+
return { pushed: result.accepted, server_seq: result.server_seq };
|
|
1528
|
+
} finally {
|
|
1529
|
+
db.close();
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* Pull remote changes and apply them locally.
|
|
1534
|
+
*/
|
|
1535
|
+
async pull() {
|
|
1536
|
+
const db = openSyncDb(this.vaultRoot);
|
|
1537
|
+
try {
|
|
1538
|
+
const lastSeq = getLastServerSeq(db);
|
|
1539
|
+
const response = await this.client.pull(lastSeq, this.deviceId);
|
|
1540
|
+
let applied = 0;
|
|
1541
|
+
let conflicts = 0;
|
|
1542
|
+
for (const change of response.changes) {
|
|
1543
|
+
const result = this.applyRemoteChange(change);
|
|
1544
|
+
if (result === "applied") applied++;
|
|
1545
|
+
if (result === "conflict") conflicts++;
|
|
1546
|
+
}
|
|
1547
|
+
setLastServerSeq(db, response.server_seq);
|
|
1548
|
+
setLastSyncTime(db);
|
|
1549
|
+
return { applied, conflicts };
|
|
1550
|
+
} finally {
|
|
1551
|
+
db.close();
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
/**
|
|
1555
|
+
* Quick pull with a tight timeout — used before read commands.
|
|
1556
|
+
* Returns silently if server is unreachable.
|
|
1557
|
+
*/
|
|
1558
|
+
async quickPull() {
|
|
1559
|
+
const db = openSyncDb(this.vaultRoot);
|
|
1560
|
+
try {
|
|
1561
|
+
const lastSeq = getLastServerSeq(db);
|
|
1562
|
+
const response = await this.client.quickPull(lastSeq, this.deviceId);
|
|
1563
|
+
if (!response) return;
|
|
1564
|
+
for (const change of response.changes) {
|
|
1565
|
+
this.applyRemoteChange(change);
|
|
1566
|
+
}
|
|
1567
|
+
setLastServerSeq(db, response.server_seq);
|
|
1568
|
+
setLastSyncTime(db);
|
|
1569
|
+
} finally {
|
|
1570
|
+
db.close();
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
/**
|
|
1574
|
+
* Full bidirectional sync: push then pull.
|
|
1575
|
+
*/
|
|
1576
|
+
async sync() {
|
|
1577
|
+
const pushResult = await this.push();
|
|
1578
|
+
const pullResult = await this.pull();
|
|
1579
|
+
return {
|
|
1580
|
+
pushed: pushResult.pushed,
|
|
1581
|
+
pulled: pullResult.applied,
|
|
1582
|
+
conflicts: pullResult.conflicts
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Get current sync status.
|
|
1587
|
+
*/
|
|
1588
|
+
status() {
|
|
1589
|
+
const db = openSyncDb(this.vaultRoot);
|
|
1590
|
+
try {
|
|
1591
|
+
const deviceInfo = getOrCreateDeviceId(this.vaultRoot);
|
|
1592
|
+
return {
|
|
1593
|
+
device_id: deviceInfo.device_id,
|
|
1594
|
+
device_name: deviceInfo.device_name,
|
|
1595
|
+
last_sync: getLastSyncTime(db),
|
|
1596
|
+
pending_changes: getPendingCount(db),
|
|
1597
|
+
server_seq: getLastServerSeq(db)
|
|
1598
|
+
};
|
|
1599
|
+
} finally {
|
|
1600
|
+
db.close();
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
/**
|
|
1604
|
+
* List connected devices.
|
|
1605
|
+
*/
|
|
1606
|
+
async devices() {
|
|
1607
|
+
return this.client.listDevices();
|
|
1608
|
+
}
|
|
1609
|
+
/**
|
|
1610
|
+
* List unresolved conflict files.
|
|
1611
|
+
*/
|
|
1612
|
+
conflicts() {
|
|
1613
|
+
return listConflicts(this.vaultRoot);
|
|
1614
|
+
}
|
|
1615
|
+
applyRemoteChange(change) {
|
|
1616
|
+
switch (change.operation) {
|
|
1617
|
+
case "create":
|
|
1618
|
+
return this.applyRemoteCreate(change);
|
|
1619
|
+
case "update":
|
|
1620
|
+
return this.applyRemoteUpdate(change);
|
|
1621
|
+
case "delete":
|
|
1622
|
+
return this.applyRemoteDelete(change);
|
|
1623
|
+
default:
|
|
1624
|
+
return "skipped";
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
applyRemoteCreate(change) {
|
|
1628
|
+
if (!change.frontmatter || change.body === void 0) return "skipped";
|
|
1629
|
+
const existing = this.findNoteById(change.note_id);
|
|
1630
|
+
if (existing) return "skipped";
|
|
1631
|
+
const typeConfig = this.config.note_types[change.frontmatter.type];
|
|
1632
|
+
if (!typeConfig) return "skipped";
|
|
1633
|
+
const folder = path9.join(this.vaultRoot, typeConfig.folder);
|
|
1634
|
+
fs9.mkdirSync(folder, { recursive: true });
|
|
1635
|
+
const slug = change.slug || change.note_id;
|
|
1636
|
+
const filepath = path9.join(folder, `${slug}.md`);
|
|
1637
|
+
const content = serializeFrontmatter(change.frontmatter, change.body);
|
|
1638
|
+
fs9.writeFileSync(filepath, content, "utf-8");
|
|
1639
|
+
return "applied";
|
|
1640
|
+
}
|
|
1641
|
+
applyRemoteUpdate(change) {
|
|
1642
|
+
if (!change.frontmatter || change.body === void 0) return "skipped";
|
|
1643
|
+
const localNote = this.findNoteById(change.note_id);
|
|
1644
|
+
if (!localNote) {
|
|
1645
|
+
return this.applyRemoteCreate(change);
|
|
1646
|
+
}
|
|
1647
|
+
const localContent = fs9.readFileSync(localNote.filepath, "utf-8");
|
|
1648
|
+
const localChecksum = computeChecksum(localContent);
|
|
1649
|
+
if (localChecksum === change.checksum) return "skipped";
|
|
1650
|
+
if (detectConflict(localNote, change)) {
|
|
1651
|
+
resolveConflict(this.vaultRoot, localNote, change, this.deviceId);
|
|
1652
|
+
const localTime = new Date(localNote.frontmatter.modified).getTime();
|
|
1653
|
+
const remoteTime = new Date(change.frontmatter.modified).getTime();
|
|
1654
|
+
if (remoteTime > localTime) {
|
|
1655
|
+
const content2 = serializeFrontmatter(change.frontmatter, change.body);
|
|
1656
|
+
fs9.writeFileSync(localNote.filepath, content2, "utf-8");
|
|
1657
|
+
}
|
|
1658
|
+
return "conflict";
|
|
1659
|
+
}
|
|
1660
|
+
const content = serializeFrontmatter(change.frontmatter, change.body);
|
|
1661
|
+
fs9.writeFileSync(localNote.filepath, content, "utf-8");
|
|
1662
|
+
return "applied";
|
|
1663
|
+
}
|
|
1664
|
+
applyRemoteDelete(change) {
|
|
1665
|
+
const localNote = this.findNoteById(change.note_id);
|
|
1666
|
+
if (!localNote) return "skipped";
|
|
1667
|
+
if (fs9.existsSync(localNote.filepath)) {
|
|
1668
|
+
fs9.unlinkSync(localNote.filepath);
|
|
1669
|
+
}
|
|
1670
|
+
return "applied";
|
|
1671
|
+
}
|
|
1672
|
+
findNoteById(noteId) {
|
|
1673
|
+
const allNotes = listNotes(this.vaultRoot, this.config);
|
|
1674
|
+
return allNotes.find((n) => n.frontmatter.id === noteId) ?? null;
|
|
1675
|
+
}
|
|
1676
|
+
};
|
|
1677
|
+
function getSyncManager(vaultRoot, config) {
|
|
1678
|
+
if (!config.sync?.enabled) return null;
|
|
1679
|
+
try {
|
|
1680
|
+
return new SyncManager(vaultRoot, config);
|
|
1681
|
+
} catch {
|
|
1682
|
+
return null;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1199
1686
|
// src/commands/new.ts
|
|
1200
1687
|
function newNote(title, options) {
|
|
1201
1688
|
const vaultRoot = requireVaultRoot();
|
|
@@ -1207,17 +1694,19 @@ function newNote(title, options) {
|
|
|
1207
1694
|
if (options.source || options.status) {
|
|
1208
1695
|
if (options.source) validateSource(options.source);
|
|
1209
1696
|
if (options.status) validateStatus(options.status);
|
|
1210
|
-
const raw =
|
|
1697
|
+
const raw = fs10.readFileSync(note.filepath, "utf-8");
|
|
1211
1698
|
const { frontmatter, body } = parseFrontmatter(raw);
|
|
1212
1699
|
if (options.source) frontmatter.source = options.source;
|
|
1213
1700
|
if (options.status) frontmatter.status = options.status;
|
|
1214
|
-
|
|
1701
|
+
fs10.writeFileSync(note.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
|
|
1215
1702
|
note.frontmatter = frontmatter;
|
|
1216
1703
|
}
|
|
1217
1704
|
const recommendationStrategy = typeConfig?.slug_format === "date" ? "incremental" : "rebuild";
|
|
1218
1705
|
const recommendations = recommendNote(vaultRoot, config, note, { strategy: recommendationStrategy });
|
|
1219
1706
|
const lines = note.body.split("\n").length;
|
|
1220
1707
|
const overLimit = typeConfig && typeConfig.line_limit && lines > typeConfig.line_limit;
|
|
1708
|
+
const sync = getSyncManager(vaultRoot, config);
|
|
1709
|
+
sync?.trackAndPush(note, "create");
|
|
1221
1710
|
if (options.json) {
|
|
1222
1711
|
console.log(jsonSuccess({
|
|
1223
1712
|
slug: note.slug,
|
|
@@ -1255,7 +1744,7 @@ function newNote(title, options) {
|
|
|
1255
1744
|
}
|
|
1256
1745
|
|
|
1257
1746
|
// src/commands/add.ts
|
|
1258
|
-
import
|
|
1747
|
+
import fs11 from "fs";
|
|
1259
1748
|
function addNote(text, options = {}) {
|
|
1260
1749
|
const vaultRoot = requireVaultRoot();
|
|
1261
1750
|
const config = loadConfig(vaultRoot);
|
|
@@ -1264,9 +1753,9 @@ function addNote(text, options = {}) {
|
|
|
1264
1753
|
if (text) {
|
|
1265
1754
|
content = text;
|
|
1266
1755
|
} else if (!process.stdin.isTTY) {
|
|
1267
|
-
content =
|
|
1756
|
+
content = fs11.readFileSync(0, "utf-8").trim();
|
|
1268
1757
|
} else {
|
|
1269
|
-
console.error('Usage:
|
|
1758
|
+
console.error('Usage: granite add "some text" or echo "text" | granite add');
|
|
1270
1759
|
process.exit(1);
|
|
1271
1760
|
}
|
|
1272
1761
|
if (!content) {
|
|
@@ -1277,6 +1766,8 @@ function addNote(text, options = {}) {
|
|
|
1277
1766
|
const title = firstLine.length > 60 ? firstLine.slice(0, 60).trim() + "..." : firstLine;
|
|
1278
1767
|
const note = createNote(vaultRoot, config, typeName, title, content + "\n");
|
|
1279
1768
|
const recommendations = recommendNote(vaultRoot, config, note, { strategy: "incremental" });
|
|
1769
|
+
const sync = getSyncManager(vaultRoot, config);
|
|
1770
|
+
sync?.trackAndPush(note, "create");
|
|
1280
1771
|
if (options.json) {
|
|
1281
1772
|
console.log(jsonSuccess({
|
|
1282
1773
|
slug: note.slug,
|
|
@@ -1313,7 +1804,7 @@ function openNote(slug) {
|
|
|
1313
1804
|
}
|
|
1314
1805
|
|
|
1315
1806
|
// src/commands/show.ts
|
|
1316
|
-
import
|
|
1807
|
+
import fs12 from "fs";
|
|
1317
1808
|
function showCommand(slug, options) {
|
|
1318
1809
|
const vaultRoot = requireVaultRoot();
|
|
1319
1810
|
const config = loadConfig(vaultRoot);
|
|
@@ -1349,12 +1840,13 @@ function showCommand(slug, options) {
|
|
|
1349
1840
|
console.log(`# ${note.frontmatter.title} (${note.slug})`);
|
|
1350
1841
|
console.log(`# type: ${note.frontmatter.type} | modified: ${note.frontmatter.modified.slice(0, 10)}`);
|
|
1351
1842
|
console.log("");
|
|
1352
|
-
const content =
|
|
1843
|
+
const content = fs12.readFileSync(note.filepath, "utf-8");
|
|
1353
1844
|
console.log(content);
|
|
1354
1845
|
}
|
|
1355
1846
|
|
|
1356
1847
|
// src/core/search.ts
|
|
1357
|
-
function searchNotes(db, query) {
|
|
1848
|
+
function searchNotes(db, query, limit = 20) {
|
|
1849
|
+
const cappedLimit = Math.max(1, Math.min(limit, 100));
|
|
1358
1850
|
const stmt = db.prepare(`
|
|
1359
1851
|
SELECT
|
|
1360
1852
|
n.slug,
|
|
@@ -1365,9 +1857,9 @@ function searchNotes(db, query) {
|
|
|
1365
1857
|
JOIN notes n ON n.rowid = notes_fts.rowid
|
|
1366
1858
|
WHERE notes_fts MATCH ?
|
|
1367
1859
|
ORDER BY rank
|
|
1368
|
-
LIMIT
|
|
1860
|
+
LIMIT ?
|
|
1369
1861
|
`);
|
|
1370
|
-
const rows = stmt.all(query);
|
|
1862
|
+
const rows = stmt.all(query, cappedLimit);
|
|
1371
1863
|
return rows.map((r) => ({
|
|
1372
1864
|
slug: r.slug,
|
|
1373
1865
|
title: r.title,
|
|
@@ -1506,14 +1998,14 @@ function recommendCommand(slug, options) {
|
|
|
1506
1998
|
}
|
|
1507
1999
|
|
|
1508
2000
|
// src/core/doctor.ts
|
|
1509
|
-
import
|
|
1510
|
-
import
|
|
2001
|
+
import fs13 from "fs";
|
|
2002
|
+
import path10 from "path";
|
|
1511
2003
|
function runDoctor(vaultRoot, config, db) {
|
|
1512
2004
|
const issues = [];
|
|
1513
2005
|
const notes = listNotes(vaultRoot, config);
|
|
1514
2006
|
for (const [name, typeConfig] of Object.entries(config.note_types)) {
|
|
1515
|
-
const folder =
|
|
1516
|
-
if (!
|
|
2007
|
+
const folder = path10.join(vaultRoot, typeConfig.folder);
|
|
2008
|
+
if (!fs13.existsSync(folder)) {
|
|
1517
2009
|
issues.push({
|
|
1518
2010
|
level: "error",
|
|
1519
2011
|
file: `granite.yml`,
|
|
@@ -1648,8 +2140,8 @@ ${errors.length} error(s), ${warnings.length} warning(s), ${infos.length} info(s
|
|
|
1648
2140
|
// src/web/server.ts
|
|
1649
2141
|
import { Hono } from "hono";
|
|
1650
2142
|
import { serve } from "@hono/node-server";
|
|
1651
|
-
import
|
|
1652
|
-
import
|
|
2143
|
+
import path11 from "path";
|
|
2144
|
+
import fs14 from "fs";
|
|
1653
2145
|
function createApp(vaultRoot, config) {
|
|
1654
2146
|
const app = new Hono();
|
|
1655
2147
|
app.get("/api/notes", (c) => {
|
|
@@ -1718,6 +2210,8 @@ function createApp(vaultRoot, config) {
|
|
|
1718
2210
|
}
|
|
1719
2211
|
try {
|
|
1720
2212
|
const note = createNote(vaultRoot, config, type, title, noteBody || void 0);
|
|
2213
|
+
const sync = getSyncManager(vaultRoot, config);
|
|
2214
|
+
sync?.trackAndPush(note, "create");
|
|
1721
2215
|
return c.json({
|
|
1722
2216
|
slug: note.slug,
|
|
1723
2217
|
title: note.frontmatter.title,
|
|
@@ -1735,17 +2229,17 @@ function createApp(vaultRoot, config) {
|
|
|
1735
2229
|
db.close();
|
|
1736
2230
|
return c.json({ nodes, edges });
|
|
1737
2231
|
});
|
|
1738
|
-
const publicDir =
|
|
2232
|
+
const publicDir = path11.join(import.meta.dirname, "public");
|
|
1739
2233
|
app.get("/*", (c) => {
|
|
1740
2234
|
const reqPath = c.req.path === "/" ? "/index.html" : c.req.path;
|
|
1741
|
-
const filePath =
|
|
1742
|
-
if (!
|
|
1743
|
-
const indexPath =
|
|
1744
|
-
const html =
|
|
2235
|
+
const filePath = path11.join(publicDir, reqPath);
|
|
2236
|
+
if (!fs14.existsSync(filePath)) {
|
|
2237
|
+
const indexPath = path11.join(publicDir, "index.html");
|
|
2238
|
+
const html = fs14.readFileSync(indexPath, "utf-8");
|
|
1745
2239
|
return c.html(html);
|
|
1746
2240
|
}
|
|
1747
|
-
const content =
|
|
1748
|
-
const ext =
|
|
2241
|
+
const content = fs14.readFileSync(filePath);
|
|
2242
|
+
const ext = path11.extname(filePath);
|
|
1749
2243
|
const mimeTypes = {
|
|
1750
2244
|
".html": "text/html",
|
|
1751
2245
|
".css": "text/css",
|
|
@@ -1791,7 +2285,7 @@ function typesCommand() {
|
|
|
1791
2285
|
}
|
|
1792
2286
|
console.log(`Default type: ${config.defaults.note_type}`);
|
|
1793
2287
|
console.log(`
|
|
1794
|
-
Usage:
|
|
2288
|
+
Usage: granite new <type> <title>`);
|
|
1795
2289
|
}
|
|
1796
2290
|
|
|
1797
2291
|
// src/commands/list.ts
|
|
@@ -1881,7 +2375,7 @@ ${notes.length} note(s)`);
|
|
|
1881
2375
|
}
|
|
1882
2376
|
|
|
1883
2377
|
// src/commands/edit.ts
|
|
1884
|
-
import
|
|
2378
|
+
import fs15 from "fs";
|
|
1885
2379
|
import { execSync as execSync2 } from "child_process";
|
|
1886
2380
|
function editCommand(slug, options) {
|
|
1887
2381
|
const vaultRoot = requireVaultRoot();
|
|
@@ -1893,7 +2387,7 @@ function editCommand(slug, options) {
|
|
|
1893
2387
|
}
|
|
1894
2388
|
const hasFlags = options.body !== void 0 || options.append !== void 0 || options.title !== void 0 || options.tag !== void 0 || options.alias !== void 0 || options.status !== void 0 || options.source !== void 0;
|
|
1895
2389
|
if (hasFlags) {
|
|
1896
|
-
let { frontmatter, body } = parseFrontmatter(
|
|
2390
|
+
let { frontmatter, body } = parseFrontmatter(fs15.readFileSync(note.filepath, "utf-8"));
|
|
1897
2391
|
if (options.title) {
|
|
1898
2392
|
frontmatter.title = options.title;
|
|
1899
2393
|
}
|
|
@@ -1924,24 +2418,30 @@ function editCommand(slug, options) {
|
|
|
1924
2418
|
body = body.trimEnd() + "\n" + options.append + "\n";
|
|
1925
2419
|
}
|
|
1926
2420
|
frontmatter.modified = (/* @__PURE__ */ new Date()).toISOString();
|
|
1927
|
-
|
|
2421
|
+
fs15.writeFileSync(note.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
|
|
2422
|
+
const sync = getSyncManager(vaultRoot, config);
|
|
2423
|
+
const updatedForSync = readNote(note.filepath);
|
|
2424
|
+
sync?.trackAndPush(updatedForSync, "update");
|
|
1928
2425
|
console.log(note.filepath);
|
|
1929
2426
|
const recommendationStrategy = options.title !== void 0 || options.alias !== void 0 ? "rebuild" : "incremental";
|
|
1930
2427
|
printRecommendations(vaultRoot, config, note.filepath, recommendationStrategy);
|
|
1931
2428
|
} else {
|
|
1932
2429
|
const editor = process.env.EDITOR || "vi";
|
|
1933
|
-
const statBefore =
|
|
2430
|
+
const statBefore = fs15.statSync(note.filepath).mtimeMs;
|
|
1934
2431
|
try {
|
|
1935
2432
|
execSync2(`${editor} "${note.filepath}"`, { stdio: "inherit" });
|
|
1936
2433
|
} catch {
|
|
1937
2434
|
console.error(`Failed to open editor: ${editor}`);
|
|
1938
2435
|
process.exit(1);
|
|
1939
2436
|
}
|
|
1940
|
-
const statAfter =
|
|
2437
|
+
const statAfter = fs15.statSync(note.filepath).mtimeMs;
|
|
1941
2438
|
if (statAfter !== statBefore) {
|
|
1942
|
-
const { frontmatter, body } = parseFrontmatter(
|
|
2439
|
+
const { frontmatter, body } = parseFrontmatter(fs15.readFileSync(note.filepath, "utf-8"));
|
|
1943
2440
|
frontmatter.modified = (/* @__PURE__ */ new Date()).toISOString();
|
|
1944
|
-
|
|
2441
|
+
fs15.writeFileSync(note.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
|
|
2442
|
+
const sync = getSyncManager(vaultRoot, config);
|
|
2443
|
+
const updatedForSync = readNote(note.filepath);
|
|
2444
|
+
sync?.trackAndPush(updatedForSync, "update");
|
|
1945
2445
|
console.log(`Updated: ${note.filepath}`);
|
|
1946
2446
|
printRecommendations(vaultRoot, config, note.filepath, "rebuild");
|
|
1947
2447
|
}
|
|
@@ -1959,9 +2459,1185 @@ function printRecommendations(vaultRoot, config, filepath, strategy) {
|
|
|
1959
2459
|
}
|
|
1960
2460
|
}
|
|
1961
2461
|
|
|
2462
|
+
// src/commands/mcp.ts
|
|
2463
|
+
import path13 from "path";
|
|
2464
|
+
|
|
2465
|
+
// src/mcp/server.ts
|
|
2466
|
+
import { serve as serve2 } from "@hono/node-server";
|
|
2467
|
+
import { Hono as Hono2 } from "hono";
|
|
2468
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2469
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2470
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
2471
|
+
import * as z from "zod/v4";
|
|
2472
|
+
|
|
2473
|
+
// src/version.ts
|
|
2474
|
+
var GRANITE_VERSION = "0.1.2";
|
|
2475
|
+
|
|
2476
|
+
// src/mcp/server.ts
|
|
2477
|
+
var readOnlyAnnotations = {
|
|
2478
|
+
readOnlyHint: true,
|
|
2479
|
+
destructiveHint: false,
|
|
2480
|
+
idempotentHint: true,
|
|
2481
|
+
openWorldHint: false
|
|
2482
|
+
};
|
|
2483
|
+
var writeAnnotations = {
|
|
2484
|
+
readOnlyHint: false,
|
|
2485
|
+
destructiveHint: false,
|
|
2486
|
+
idempotentHint: false,
|
|
2487
|
+
openWorldHint: false
|
|
2488
|
+
};
|
|
2489
|
+
var fieldDefinitionSchema = z.object({
|
|
2490
|
+
type: z.enum(["text", "date", "number", "boolean", "wikilink", "list", "enum"]),
|
|
2491
|
+
of: z.string().optional(),
|
|
2492
|
+
options: z.array(z.string()).optional(),
|
|
2493
|
+
required: z.boolean().optional(),
|
|
2494
|
+
default: z.string().optional(),
|
|
2495
|
+
description: z.string().optional()
|
|
2496
|
+
});
|
|
2497
|
+
var noteTypeInfoSchema = z.object({
|
|
2498
|
+
name: z.string(),
|
|
2499
|
+
description: z.string(),
|
|
2500
|
+
folder: z.string(),
|
|
2501
|
+
line_limit: z.number(),
|
|
2502
|
+
warn_only: z.boolean(),
|
|
2503
|
+
slug_format: z.enum(["title", "date"]),
|
|
2504
|
+
instructions: z.string().optional(),
|
|
2505
|
+
fields: z.record(z.string(), fieldDefinitionSchema).optional()
|
|
2506
|
+
});
|
|
2507
|
+
var noteSummarySchema = z.object({
|
|
2508
|
+
slug: z.string(),
|
|
2509
|
+
title: z.string(),
|
|
2510
|
+
type: z.string(),
|
|
2511
|
+
created: z.string(),
|
|
2512
|
+
modified: z.string(),
|
|
2513
|
+
tags: z.array(z.string()),
|
|
2514
|
+
aliases: z.array(z.string()),
|
|
2515
|
+
status: z.enum(["inbox", "active", "archived"]),
|
|
2516
|
+
source: z.enum(["human", "agent", "extraction"]),
|
|
2517
|
+
filepath: z.string(),
|
|
2518
|
+
resource_uri: z.string()
|
|
2519
|
+
});
|
|
2520
|
+
var wikiLinkSchema = z.object({
|
|
2521
|
+
raw: z.string(),
|
|
2522
|
+
target: z.string(),
|
|
2523
|
+
display: z.string(),
|
|
2524
|
+
resolved: z.boolean(),
|
|
2525
|
+
resolved_slug: z.string().optional()
|
|
2526
|
+
});
|
|
2527
|
+
var noteDetailsSchema = noteSummarySchema.extend({
|
|
2528
|
+
body: z.string(),
|
|
2529
|
+
frontmatter: z.record(z.string(), z.unknown()),
|
|
2530
|
+
outgoing_links: z.array(wikiLinkSchema)
|
|
2531
|
+
});
|
|
2532
|
+
var searchResultSchema = z.object({
|
|
2533
|
+
slug: z.string(),
|
|
2534
|
+
title: z.string(),
|
|
2535
|
+
snippet: z.string(),
|
|
2536
|
+
score: z.number()
|
|
2537
|
+
});
|
|
2538
|
+
var backlinkSchema = z.object({
|
|
2539
|
+
source_slug: z.string(),
|
|
2540
|
+
source_title: z.string(),
|
|
2541
|
+
context: z.string()
|
|
2542
|
+
});
|
|
2543
|
+
var linkSuggestionSchema = z.object({
|
|
2544
|
+
target_slug: z.string(),
|
|
2545
|
+
target_title: z.string(),
|
|
2546
|
+
mentions: z.number()
|
|
2547
|
+
});
|
|
2548
|
+
var recommendationSchema = z.object({
|
|
2549
|
+
additions: z.array(z.object({ text: z.string() })),
|
|
2550
|
+
links: z.array(z.object({
|
|
2551
|
+
slug: z.string(),
|
|
2552
|
+
title: z.string(),
|
|
2553
|
+
type: z.string(),
|
|
2554
|
+
reason: z.string(),
|
|
2555
|
+
source: z.enum(["mention", "search"])
|
|
2556
|
+
})),
|
|
2557
|
+
tags: z.array(z.object({
|
|
2558
|
+
tag: z.string(),
|
|
2559
|
+
weight: z.number(),
|
|
2560
|
+
source_slugs: z.array(z.string())
|
|
2561
|
+
})),
|
|
2562
|
+
next_steps: z.array(z.object({
|
|
2563
|
+
type: z.string(),
|
|
2564
|
+
title_hint: z.string().optional(),
|
|
2565
|
+
reason: z.string()
|
|
2566
|
+
}))
|
|
2567
|
+
});
|
|
2568
|
+
var doctorIssueSchema = z.object({
|
|
2569
|
+
level: z.enum(["error", "warning", "info"]),
|
|
2570
|
+
file: z.string(),
|
|
2571
|
+
message: z.string()
|
|
2572
|
+
});
|
|
2573
|
+
var vaultOverviewSchema = z.object({
|
|
2574
|
+
vault_root: z.string(),
|
|
2575
|
+
vault_name: z.string(),
|
|
2576
|
+
default_type: z.string(),
|
|
2577
|
+
auto_rebuild: z.boolean(),
|
|
2578
|
+
index_last_rebuild: z.string().optional(),
|
|
2579
|
+
note_count: z.number(),
|
|
2580
|
+
notes_by_type: z.record(z.string(), z.number()),
|
|
2581
|
+
recent_notes: z.array(noteSummarySchema)
|
|
2582
|
+
});
|
|
2583
|
+
function createGraniteMcpServer(runtime) {
|
|
2584
|
+
const server = new McpServer(
|
|
2585
|
+
{
|
|
2586
|
+
name: "granite",
|
|
2587
|
+
version: GRANITE_VERSION,
|
|
2588
|
+
title: "Granite MCP Server"
|
|
2589
|
+
},
|
|
2590
|
+
{
|
|
2591
|
+
capabilities: { logging: {} },
|
|
2592
|
+
instructions: [
|
|
2593
|
+
"Granite exposes a local-first markdown vault.",
|
|
2594
|
+
"Prefer read tools and resources first, then write with granite_create_note or granite_update_note.",
|
|
2595
|
+
"Resources are read-only views; notes live on disk and the SQLite index is derived state.",
|
|
2596
|
+
"Use granite_recommend_note_actions to preserve Granite\u2019s capture -> link -> recommend loop."
|
|
2597
|
+
].join(" ")
|
|
2598
|
+
}
|
|
2599
|
+
);
|
|
2600
|
+
registerTools(server, runtime);
|
|
2601
|
+
registerResources(server, runtime);
|
|
2602
|
+
registerPrompts(server, runtime);
|
|
2603
|
+
return server;
|
|
2604
|
+
}
|
|
2605
|
+
async function startGraniteMcpStdioServer(runtime) {
|
|
2606
|
+
const server = createGraniteMcpServer(runtime);
|
|
2607
|
+
const transport = new StdioServerTransport();
|
|
2608
|
+
await server.connect(transport);
|
|
2609
|
+
console.error(`Granite MCP server listening on stdio for ${runtime.vaultRoot}`);
|
|
2610
|
+
}
|
|
2611
|
+
function startGraniteMcpHttpServer(runtime, options) {
|
|
2612
|
+
const app = createGraniteMcpHttpApp(runtime, options);
|
|
2613
|
+
console.error(`Granite MCP server listening on http://${options.host}:${options.port}/mcp`);
|
|
2614
|
+
console.error(`Health check: http://${options.host}:${options.port}/health`);
|
|
2615
|
+
console.error(`Vault: ${runtime.vaultRoot}`);
|
|
2616
|
+
serve2({
|
|
2617
|
+
fetch: app.fetch,
|
|
2618
|
+
hostname: options.host,
|
|
2619
|
+
port: options.port
|
|
2620
|
+
});
|
|
2621
|
+
}
|
|
2622
|
+
async function withResponseCleanup(response, cleanup) {
|
|
2623
|
+
let cleanedUp = false;
|
|
2624
|
+
const cleanupOnce = async () => {
|
|
2625
|
+
if (cleanedUp) {
|
|
2626
|
+
return;
|
|
2627
|
+
}
|
|
2628
|
+
cleanedUp = true;
|
|
2629
|
+
await cleanup();
|
|
2630
|
+
};
|
|
2631
|
+
if (!response.body) {
|
|
2632
|
+
await cleanupOnce();
|
|
2633
|
+
return response;
|
|
2634
|
+
}
|
|
2635
|
+
const reader = response.body.getReader();
|
|
2636
|
+
const wrappedBody = new ReadableStream({
|
|
2637
|
+
async pull(controller) {
|
|
2638
|
+
try {
|
|
2639
|
+
const { done, value } = await reader.read();
|
|
2640
|
+
if (done) {
|
|
2641
|
+
controller.close();
|
|
2642
|
+
await cleanupOnce();
|
|
2643
|
+
return;
|
|
2644
|
+
}
|
|
2645
|
+
controller.enqueue(value);
|
|
2646
|
+
} catch (error) {
|
|
2647
|
+
controller.error(error);
|
|
2648
|
+
await cleanupOnce();
|
|
2649
|
+
}
|
|
2650
|
+
},
|
|
2651
|
+
async cancel(reason) {
|
|
2652
|
+
try {
|
|
2653
|
+
await reader.cancel(reason);
|
|
2654
|
+
} finally {
|
|
2655
|
+
await cleanupOnce();
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
});
|
|
2659
|
+
return new Response(wrappedBody, {
|
|
2660
|
+
status: response.status,
|
|
2661
|
+
statusText: response.statusText,
|
|
2662
|
+
headers: response.headers
|
|
2663
|
+
});
|
|
2664
|
+
}
|
|
2665
|
+
function registerTools(server, runtime) {
|
|
2666
|
+
server.registerTool("granite_get_vault_overview", {
|
|
2667
|
+
title: "Granite Vault Overview",
|
|
2668
|
+
description: "Summarize the current Granite vault, including counts by type and recent notes.",
|
|
2669
|
+
inputSchema: {
|
|
2670
|
+
recent_limit: z.number().int().min(1).max(20).optional().describe("How many recent notes to include. Defaults to 10.")
|
|
2671
|
+
},
|
|
2672
|
+
outputSchema: vaultOverviewSchema,
|
|
2673
|
+
annotations: readOnlyAnnotations
|
|
2674
|
+
}, async ({ recent_limit }) => {
|
|
2675
|
+
const overview = runtime.getVaultOverview(recent_limit ?? 10);
|
|
2676
|
+
return toolResult(
|
|
2677
|
+
overview,
|
|
2678
|
+
`Vault "${overview.vault_name}" with ${overview.note_count} notes.`
|
|
2679
|
+
);
|
|
2680
|
+
});
|
|
2681
|
+
server.registerTool("granite_list_note_types", {
|
|
2682
|
+
title: "Granite Note Types",
|
|
2683
|
+
description: "List the note types configured in the vault.",
|
|
2684
|
+
outputSchema: z.object({
|
|
2685
|
+
default_type: z.string(),
|
|
2686
|
+
note_types: z.array(noteTypeInfoSchema)
|
|
2687
|
+
}),
|
|
2688
|
+
annotations: readOnlyAnnotations
|
|
2689
|
+
}, async () => {
|
|
2690
|
+
const noteTypes = runtime.listNoteTypes();
|
|
2691
|
+
return toolResult({
|
|
2692
|
+
default_type: runtime.getDefaultNoteType(),
|
|
2693
|
+
note_types: noteTypes
|
|
2694
|
+
}, `Loaded ${noteTypes.length} Granite note types.`);
|
|
2695
|
+
});
|
|
2696
|
+
server.registerTool("granite_list_notes", {
|
|
2697
|
+
title: "List Granite Notes",
|
|
2698
|
+
description: "List notes from the Granite vault with optional filters.",
|
|
2699
|
+
inputSchema: {
|
|
2700
|
+
type: z.string().optional().describe("Filter by note type."),
|
|
2701
|
+
status: z.enum(["inbox", "active", "archived"]).optional().describe("Filter by note status."),
|
|
2702
|
+
source: z.enum(["human", "agent", "extraction"]).optional().describe("Filter by note source."),
|
|
2703
|
+
since: z.string().optional().describe("Only notes modified on or after this ISO or YYYY-MM-DD value."),
|
|
2704
|
+
limit: z.number().int().min(1).max(200).optional().describe("Maximum number of notes to return. Defaults to 25.")
|
|
2705
|
+
},
|
|
2706
|
+
outputSchema: z.object({
|
|
2707
|
+
notes: z.array(noteSummarySchema),
|
|
2708
|
+
total: z.number()
|
|
2709
|
+
}),
|
|
2710
|
+
annotations: readOnlyAnnotations
|
|
2711
|
+
}, async (args) => {
|
|
2712
|
+
const notes = runtime.listNotes(args);
|
|
2713
|
+
return toolResult({
|
|
2714
|
+
notes,
|
|
2715
|
+
total: notes.length
|
|
2716
|
+
}, `Returned ${notes.length} note(s).`);
|
|
2717
|
+
});
|
|
2718
|
+
server.registerTool("granite_get_note", {
|
|
2719
|
+
title: "Get Granite Note",
|
|
2720
|
+
description: "Read a Granite note by slug, including frontmatter, body, and resolved outgoing links.",
|
|
2721
|
+
inputSchema: {
|
|
2722
|
+
slug: z.string().describe("The note slug.")
|
|
2723
|
+
},
|
|
2724
|
+
outputSchema: noteDetailsSchema,
|
|
2725
|
+
annotations: readOnlyAnnotations
|
|
2726
|
+
}, async ({ slug }) => {
|
|
2727
|
+
const note = runtime.getNote(slug);
|
|
2728
|
+
return {
|
|
2729
|
+
...toolResult(note, `Loaded "${note.title}" (${note.slug}).`),
|
|
2730
|
+
content: [
|
|
2731
|
+
{ type: "text", text: `Loaded "${note.title}" (${note.slug}).` },
|
|
2732
|
+
createNoteResourceLink(note.title, note.resource_uri)
|
|
2733
|
+
]
|
|
2734
|
+
};
|
|
2735
|
+
});
|
|
2736
|
+
server.registerTool("granite_search_notes", {
|
|
2737
|
+
title: "Search Granite Notes",
|
|
2738
|
+
description: "Run full-text search against the Granite index.",
|
|
2739
|
+
inputSchema: {
|
|
2740
|
+
query: z.string().describe("Full-text query for the SQLite FTS index."),
|
|
2741
|
+
limit: z.number().int().min(1).max(50).optional().describe("Maximum number of search results. Defaults to 10.")
|
|
2742
|
+
},
|
|
2743
|
+
outputSchema: z.object({
|
|
2744
|
+
query: z.string(),
|
|
2745
|
+
results: z.array(searchResultSchema)
|
|
2746
|
+
}),
|
|
2747
|
+
annotations: readOnlyAnnotations
|
|
2748
|
+
}, async ({ query, limit }) => {
|
|
2749
|
+
const results = runtime.search(query, limit ?? 10);
|
|
2750
|
+
return toolResult({
|
|
2751
|
+
query,
|
|
2752
|
+
results
|
|
2753
|
+
}, `Found ${results.length} result(s) for "${query}".`);
|
|
2754
|
+
});
|
|
2755
|
+
server.registerTool("granite_create_note", {
|
|
2756
|
+
title: "Create Granite Note",
|
|
2757
|
+
description: "Create a new note in the Granite vault.",
|
|
2758
|
+
inputSchema: {
|
|
2759
|
+
title: z.string().describe("Note title."),
|
|
2760
|
+
type: z.string().optional().describe("Note type. Defaults to the vault default type."),
|
|
2761
|
+
body: z.string().optional().describe("Optional note body. If omitted, Granite uses the type template."),
|
|
2762
|
+
tags: z.array(z.string()).optional().describe("Tags to add immediately."),
|
|
2763
|
+
aliases: z.array(z.string()).optional().describe("Aliases to add immediately."),
|
|
2764
|
+
status: z.enum(["inbox", "active", "archived"]).optional().describe("Initial note status."),
|
|
2765
|
+
source: z.enum(["human", "agent", "extraction"]).optional().describe("Initial note source.")
|
|
2766
|
+
},
|
|
2767
|
+
outputSchema: z.object({
|
|
2768
|
+
note: noteDetailsSchema,
|
|
2769
|
+
recommendations: recommendationSchema
|
|
2770
|
+
}),
|
|
2771
|
+
annotations: writeAnnotations
|
|
2772
|
+
}, async (args) => {
|
|
2773
|
+
const result = runtime.createNote(args);
|
|
2774
|
+
return toolResult(result, `Created "${result.note.title}" (${result.note.slug}).`);
|
|
2775
|
+
});
|
|
2776
|
+
server.registerTool("granite_capture_note", {
|
|
2777
|
+
title: "Capture Granite Note",
|
|
2778
|
+
description: "Quick-capture a note from free-form text, similar to granite add.",
|
|
2779
|
+
inputSchema: {
|
|
2780
|
+
text: z.string().describe("Raw capture text."),
|
|
2781
|
+
type: z.string().optional().describe("Optional note type override. Defaults to the vault default type."),
|
|
2782
|
+
tags: z.array(z.string()).optional().describe("Tags to add immediately."),
|
|
2783
|
+
aliases: z.array(z.string()).optional().describe("Aliases to add immediately."),
|
|
2784
|
+
status: z.enum(["inbox", "active", "archived"]).optional().describe("Initial note status."),
|
|
2785
|
+
source: z.enum(["human", "agent", "extraction"]).optional().describe("Initial note source.")
|
|
2786
|
+
},
|
|
2787
|
+
outputSchema: z.object({
|
|
2788
|
+
note: noteDetailsSchema,
|
|
2789
|
+
recommendations: recommendationSchema
|
|
2790
|
+
}),
|
|
2791
|
+
annotations: writeAnnotations
|
|
2792
|
+
}, async (args) => {
|
|
2793
|
+
const result = runtime.captureNote(args);
|
|
2794
|
+
return toolResult(result, `Captured "${result.note.title}" (${result.note.slug}).`);
|
|
2795
|
+
});
|
|
2796
|
+
server.registerTool("granite_update_note", {
|
|
2797
|
+
title: "Update Granite Note",
|
|
2798
|
+
description: "Update frontmatter or body fields for an existing Granite note.",
|
|
2799
|
+
inputSchema: {
|
|
2800
|
+
slug: z.string().describe("Slug of the note to update."),
|
|
2801
|
+
title: z.string().optional().describe("Replace the note title."),
|
|
2802
|
+
body: z.string().optional().describe("Replace the entire note body."),
|
|
2803
|
+
append: z.string().optional().describe("Append text to the existing note body."),
|
|
2804
|
+
tags: z.array(z.string()).optional().describe("Tags to add."),
|
|
2805
|
+
aliases: z.array(z.string()).optional().describe("Aliases to add."),
|
|
2806
|
+
status: z.enum(["inbox", "active", "archived"]).optional().describe("New note status."),
|
|
2807
|
+
source: z.enum(["human", "agent", "extraction"]).optional().describe("New note source.")
|
|
2808
|
+
},
|
|
2809
|
+
outputSchema: z.object({
|
|
2810
|
+
note: noteDetailsSchema,
|
|
2811
|
+
recommendations: recommendationSchema
|
|
2812
|
+
}),
|
|
2813
|
+
annotations: writeAnnotations
|
|
2814
|
+
}, async ({ slug, ...updates }) => {
|
|
2815
|
+
const result = runtime.updateNote(slug, updates);
|
|
2816
|
+
return toolResult(result, `Updated "${result.note.title}" (${result.note.slug}).`);
|
|
2817
|
+
});
|
|
2818
|
+
server.registerTool("granite_get_backlinks", {
|
|
2819
|
+
title: "Granite Backlinks",
|
|
2820
|
+
description: "List notes that link to a given Granite note.",
|
|
2821
|
+
inputSchema: {
|
|
2822
|
+
slug: z.string().describe("Slug of the target note.")
|
|
2823
|
+
},
|
|
2824
|
+
outputSchema: z.object({
|
|
2825
|
+
slug: z.string(),
|
|
2826
|
+
backlinks: z.array(backlinkSchema)
|
|
2827
|
+
}),
|
|
2828
|
+
annotations: readOnlyAnnotations
|
|
2829
|
+
}, async ({ slug }) => {
|
|
2830
|
+
const backlinks = runtime.getBacklinks(slug);
|
|
2831
|
+
return toolResult({
|
|
2832
|
+
slug,
|
|
2833
|
+
backlinks
|
|
2834
|
+
}, `Found ${backlinks.length} backlink(s) for "${slug}".`);
|
|
2835
|
+
});
|
|
2836
|
+
server.registerTool("granite_suggest_links", {
|
|
2837
|
+
title: "Suggest Granite Links",
|
|
2838
|
+
description: "Suggest missing wikilinks for a Granite note based on mentions.",
|
|
2839
|
+
inputSchema: {
|
|
2840
|
+
slug: z.string().describe("Slug of the note to inspect.")
|
|
2841
|
+
},
|
|
2842
|
+
outputSchema: z.object({
|
|
2843
|
+
slug: z.string(),
|
|
2844
|
+
suggestions: z.array(linkSuggestionSchema)
|
|
2845
|
+
}),
|
|
2846
|
+
annotations: readOnlyAnnotations
|
|
2847
|
+
}, async ({ slug }) => {
|
|
2848
|
+
const suggestions = runtime.suggestLinks(slug);
|
|
2849
|
+
return toolResult({
|
|
2850
|
+
slug,
|
|
2851
|
+
suggestions
|
|
2852
|
+
}, `Found ${suggestions.length} suggested link(s) for "${slug}".`);
|
|
2853
|
+
});
|
|
2854
|
+
server.registerTool("granite_recommend_note_actions", {
|
|
2855
|
+
title: "Recommend Granite Actions",
|
|
2856
|
+
description: "Recommend follow-up links, tags, additions, and next notes for a Granite note.",
|
|
2857
|
+
inputSchema: {
|
|
2858
|
+
slug: z.string().describe("Slug of the note to analyze.")
|
|
2859
|
+
},
|
|
2860
|
+
outputSchema: z.object({
|
|
2861
|
+
slug: z.string(),
|
|
2862
|
+
recommendations: recommendationSchema
|
|
2863
|
+
}),
|
|
2864
|
+
annotations: readOnlyAnnotations
|
|
2865
|
+
}, async ({ slug }) => {
|
|
2866
|
+
const recommendations = runtime.recommend(slug);
|
|
2867
|
+
return toolResult({
|
|
2868
|
+
slug,
|
|
2869
|
+
recommendations
|
|
2870
|
+
}, recommendationSummary(recommendations));
|
|
2871
|
+
});
|
|
2872
|
+
server.registerTool("granite_run_doctor", {
|
|
2873
|
+
title: "Run Granite Doctor",
|
|
2874
|
+
description: "Validate vault health and report structural issues.",
|
|
2875
|
+
outputSchema: z.object({
|
|
2876
|
+
issues: z.array(doctorIssueSchema),
|
|
2877
|
+
counts: z.object({
|
|
2878
|
+
errors: z.number(),
|
|
2879
|
+
warnings: z.number(),
|
|
2880
|
+
info: z.number()
|
|
2881
|
+
})
|
|
2882
|
+
}),
|
|
2883
|
+
annotations: readOnlyAnnotations
|
|
2884
|
+
}, async () => {
|
|
2885
|
+
const result = runtime.runDoctor();
|
|
2886
|
+
return toolResult(
|
|
2887
|
+
result,
|
|
2888
|
+
`${result.counts.errors} error(s), ${result.counts.warnings} warning(s), ${result.counts.info} info item(s).`
|
|
2889
|
+
);
|
|
2890
|
+
});
|
|
2891
|
+
}
|
|
2892
|
+
function registerResources(server, runtime) {
|
|
2893
|
+
server.registerResource("granite-vault-config", "granite://vault/config", {
|
|
2894
|
+
title: "Granite Config",
|
|
2895
|
+
description: "Raw granite.yml configuration for the current vault.",
|
|
2896
|
+
mimeType: "text/yaml"
|
|
2897
|
+
}, async () => ({
|
|
2898
|
+
contents: [{
|
|
2899
|
+
uri: "granite://vault/config",
|
|
2900
|
+
text: runtime.readVaultConfigRaw(),
|
|
2901
|
+
mimeType: "text/yaml"
|
|
2902
|
+
}]
|
|
2903
|
+
}));
|
|
2904
|
+
server.registerResource("granite-vault-overview", "granite://vault/overview", {
|
|
2905
|
+
title: "Granite Vault Overview",
|
|
2906
|
+
description: "Structured summary of the current vault.",
|
|
2907
|
+
mimeType: "application/json"
|
|
2908
|
+
}, async () => ({
|
|
2909
|
+
contents: [{
|
|
2910
|
+
uri: "granite://vault/overview",
|
|
2911
|
+
text: runtime.readVaultOverviewJson(),
|
|
2912
|
+
mimeType: "application/json"
|
|
2913
|
+
}]
|
|
2914
|
+
}));
|
|
2915
|
+
server.registerResource("granite-note-types", "granite://vault/types", {
|
|
2916
|
+
title: "Granite Note Types",
|
|
2917
|
+
description: "Structured list of the note types configured in the current vault.",
|
|
2918
|
+
mimeType: "application/json"
|
|
2919
|
+
}, async () => ({
|
|
2920
|
+
contents: [{
|
|
2921
|
+
uri: "granite://vault/types",
|
|
2922
|
+
text: runtime.readVaultTypesJson(),
|
|
2923
|
+
mimeType: "application/json"
|
|
2924
|
+
}]
|
|
2925
|
+
}));
|
|
2926
|
+
const noteTemplate = new ResourceTemplate("granite://notes/{slug}", {
|
|
2927
|
+
list: void 0,
|
|
2928
|
+
complete: {
|
|
2929
|
+
slug: async (value) => runtime.completeSlugs(value)
|
|
2930
|
+
}
|
|
2931
|
+
});
|
|
2932
|
+
server.registerResource("granite-note", noteTemplate, {
|
|
2933
|
+
title: "Granite Note",
|
|
2934
|
+
description: "Read a Granite note as markdown with YAML frontmatter.",
|
|
2935
|
+
mimeType: "text/markdown"
|
|
2936
|
+
}, async (uri, variables) => {
|
|
2937
|
+
const variable = variables.slug;
|
|
2938
|
+
const slug = Array.isArray(variable) ? variable[0] : variable;
|
|
2939
|
+
if (!slug) {
|
|
2940
|
+
throw new Error("Resource URI is missing the note slug.");
|
|
2941
|
+
}
|
|
2942
|
+
return {
|
|
2943
|
+
contents: [{
|
|
2944
|
+
uri: uri.toString(),
|
|
2945
|
+
text: runtime.readNoteMarkdown(decodeURIComponent(slug)),
|
|
2946
|
+
mimeType: "text/markdown"
|
|
2947
|
+
}]
|
|
2948
|
+
};
|
|
2949
|
+
});
|
|
2950
|
+
}
|
|
2951
|
+
function registerPrompts(server, runtime) {
|
|
2952
|
+
server.registerPrompt("granite_refine_note", {
|
|
2953
|
+
title: "Refine Granite Note",
|
|
2954
|
+
description: "Create a prompt for turning a note into a cleaner permanent note draft.",
|
|
2955
|
+
argsSchema: {
|
|
2956
|
+
slug: z.string().describe("Slug of the note to refine.")
|
|
2957
|
+
}
|
|
2958
|
+
}, async ({ slug }) => {
|
|
2959
|
+
const note = runtime.getNote(slug);
|
|
2960
|
+
return {
|
|
2961
|
+
description: `Refine ${note.slug} into a durable Granite note.`,
|
|
2962
|
+
messages: [
|
|
2963
|
+
{
|
|
2964
|
+
role: "user",
|
|
2965
|
+
content: {
|
|
2966
|
+
type: "text",
|
|
2967
|
+
text: [
|
|
2968
|
+
"Refine the attached Granite note into a durable, well-structured permanent note.",
|
|
2969
|
+
"Keep the meaning intact, avoid inventing facts, preserve useful wikilinks, and use Granite-style headings when appropriate."
|
|
2970
|
+
].join(" ")
|
|
2971
|
+
}
|
|
2972
|
+
},
|
|
2973
|
+
{
|
|
2974
|
+
role: "user",
|
|
2975
|
+
content: {
|
|
2976
|
+
type: "resource",
|
|
2977
|
+
resource: {
|
|
2978
|
+
uri: note.resource_uri,
|
|
2979
|
+
text: runtime.readNoteMarkdown(slug),
|
|
2980
|
+
mimeType: "text/markdown"
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
]
|
|
2985
|
+
};
|
|
2986
|
+
});
|
|
2987
|
+
server.registerPrompt("granite_review_connections", {
|
|
2988
|
+
title: "Review Granite Connections",
|
|
2989
|
+
description: "Create a prompt for improving note links, tags, and next steps.",
|
|
2990
|
+
argsSchema: {
|
|
2991
|
+
slug: z.string().describe("Slug of the note to inspect.")
|
|
2992
|
+
}
|
|
2993
|
+
}, async ({ slug }) => {
|
|
2994
|
+
const note = runtime.getNote(slug);
|
|
2995
|
+
const backlinks = runtime.getBacklinks(slug);
|
|
2996
|
+
const recommendations = runtime.recommend(slug);
|
|
2997
|
+
return {
|
|
2998
|
+
description: `Review the connections around ${note.slug}.`,
|
|
2999
|
+
messages: [
|
|
3000
|
+
{
|
|
3001
|
+
role: "user",
|
|
3002
|
+
content: {
|
|
3003
|
+
type: "text",
|
|
3004
|
+
text: [
|
|
3005
|
+
"Review this Granite note and propose better links, tags, and the most useful follow-up note.",
|
|
3006
|
+
"Prefer concrete suggestions grounded in the vault context below."
|
|
3007
|
+
].join(" ")
|
|
3008
|
+
}
|
|
3009
|
+
},
|
|
3010
|
+
{
|
|
3011
|
+
role: "user",
|
|
3012
|
+
content: {
|
|
3013
|
+
type: "resource",
|
|
3014
|
+
resource: {
|
|
3015
|
+
uri: note.resource_uri,
|
|
3016
|
+
text: runtime.readNoteMarkdown(slug),
|
|
3017
|
+
mimeType: "text/markdown"
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
},
|
|
3021
|
+
{
|
|
3022
|
+
role: "user",
|
|
3023
|
+
content: {
|
|
3024
|
+
type: "text",
|
|
3025
|
+
text: JSON.stringify({ backlinks, recommendations }, null, 2)
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
]
|
|
3029
|
+
};
|
|
3030
|
+
});
|
|
3031
|
+
}
|
|
3032
|
+
function toolResult(structuredContent, summary) {
|
|
3033
|
+
return {
|
|
3034
|
+
content: [{ type: "text", text: summary }],
|
|
3035
|
+
structuredContent: asStructuredContent(structuredContent)
|
|
3036
|
+
};
|
|
3037
|
+
}
|
|
3038
|
+
function createNoteResourceLink(name, uri) {
|
|
3039
|
+
return {
|
|
3040
|
+
type: "resource_link",
|
|
3041
|
+
name,
|
|
3042
|
+
uri,
|
|
3043
|
+
mimeType: "text/markdown",
|
|
3044
|
+
description: "Read the markdown resource for this note."
|
|
3045
|
+
};
|
|
3046
|
+
}
|
|
3047
|
+
function recommendationSummary(recommendations) {
|
|
3048
|
+
return [
|
|
3049
|
+
`${recommendations.links.length} link suggestion(s)`,
|
|
3050
|
+
`${recommendations.tags.length} tag suggestion(s)`,
|
|
3051
|
+
`${recommendations.next_steps.length} next-step suggestion(s)`
|
|
3052
|
+
].join(", ");
|
|
3053
|
+
}
|
|
3054
|
+
function asStructuredContent(value) {
|
|
3055
|
+
return JSON.parse(JSON.stringify(value));
|
|
3056
|
+
}
|
|
3057
|
+
function createGraniteMcpHttpApp(runtime, options) {
|
|
3058
|
+
const app = new Hono2();
|
|
3059
|
+
const allowedHosts = buildAllowedHosts(options.host, options.port);
|
|
3060
|
+
const allowedOrigins = buildAllowedOrigins(options.host, options.port, options.allowedOrigins ?? []);
|
|
3061
|
+
app.use("/mcp", async (c, next) => {
|
|
3062
|
+
const requestHost = (c.req.header("host") ?? "").toLowerCase();
|
|
3063
|
+
if (allowedHosts.size > 0 && !allowedHosts.has(requestHost)) {
|
|
3064
|
+
return c.json({
|
|
3065
|
+
jsonrpc: "2.0",
|
|
3066
|
+
error: { code: -32e3, message: "Forbidden host header." },
|
|
3067
|
+
id: null
|
|
3068
|
+
}, 403);
|
|
3069
|
+
}
|
|
3070
|
+
const origin = c.req.header("origin");
|
|
3071
|
+
if (origin && !allowedOrigins.has(origin)) {
|
|
3072
|
+
return c.json({
|
|
3073
|
+
jsonrpc: "2.0",
|
|
3074
|
+
error: { code: -32e3, message: "Forbidden origin." },
|
|
3075
|
+
id: null
|
|
3076
|
+
}, 403);
|
|
3077
|
+
}
|
|
3078
|
+
if (c.req.method === "OPTIONS") {
|
|
3079
|
+
return new Response(null, {
|
|
3080
|
+
status: 204,
|
|
3081
|
+
headers: corsHeaders(origin, allowedOrigins)
|
|
3082
|
+
});
|
|
3083
|
+
}
|
|
3084
|
+
await next();
|
|
3085
|
+
});
|
|
3086
|
+
app.get("/health", (c) => c.json({ status: "ok", name: "granite-mcp", version: GRANITE_VERSION }));
|
|
3087
|
+
app.all("/mcp", async (c) => {
|
|
3088
|
+
const origin = c.req.header("origin");
|
|
3089
|
+
const server = createGraniteMcpServer(runtime);
|
|
3090
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
3091
|
+
sessionIdGenerator: void 0,
|
|
3092
|
+
enableJsonResponse: options.jsonResponse ?? false
|
|
3093
|
+
});
|
|
3094
|
+
try {
|
|
3095
|
+
await server.connect(transport);
|
|
3096
|
+
const response = await transport.handleRequest(c.req.raw);
|
|
3097
|
+
const managedResponse = await withResponseCleanup(response, async () => {
|
|
3098
|
+
await server.close();
|
|
3099
|
+
});
|
|
3100
|
+
return withCors(managedResponse, origin, allowedOrigins);
|
|
3101
|
+
} catch (error) {
|
|
3102
|
+
await server.close();
|
|
3103
|
+
throw error;
|
|
3104
|
+
}
|
|
3105
|
+
});
|
|
3106
|
+
return app;
|
|
3107
|
+
}
|
|
3108
|
+
function buildAllowedHosts(host, port) {
|
|
3109
|
+
const normalizedHost = host.toLowerCase();
|
|
3110
|
+
if (normalizedHost === "0.0.0.0" || normalizedHost === "::" || normalizedHost === "[::]") {
|
|
3111
|
+
return /* @__PURE__ */ new Set();
|
|
3112
|
+
}
|
|
3113
|
+
const allowed = /* @__PURE__ */ new Set([`${normalizedHost}:${port}`]);
|
|
3114
|
+
if (normalizedHost === "127.0.0.1" || normalizedHost === "localhost") {
|
|
3115
|
+
allowed.add(`127.0.0.1:${port}`);
|
|
3116
|
+
allowed.add(`localhost:${port}`);
|
|
3117
|
+
}
|
|
3118
|
+
if (normalizedHost === "::1" || normalizedHost === "[::1]") {
|
|
3119
|
+
allowed.add(`[::1]:${port}`);
|
|
3120
|
+
}
|
|
3121
|
+
return allowed;
|
|
3122
|
+
}
|
|
3123
|
+
function buildAllowedOrigins(host, port, extraOrigins) {
|
|
3124
|
+
const allowed = new Set(extraOrigins);
|
|
3125
|
+
const normalizedHost = host.toLowerCase();
|
|
3126
|
+
if (normalizedHost === "::1" || normalizedHost === "[::1]") {
|
|
3127
|
+
allowed.add(`http://[::1]:${port}`);
|
|
3128
|
+
} else if (normalizedHost !== "0.0.0.0" && normalizedHost !== "::" && normalizedHost !== "[::]") {
|
|
3129
|
+
allowed.add(`http://${normalizedHost}:${port}`);
|
|
3130
|
+
}
|
|
3131
|
+
if (normalizedHost === "127.0.0.1" || normalizedHost === "localhost") {
|
|
3132
|
+
allowed.add(`http://127.0.0.1:${port}`);
|
|
3133
|
+
allowed.add(`http://localhost:${port}`);
|
|
3134
|
+
}
|
|
3135
|
+
return allowed;
|
|
3136
|
+
}
|
|
3137
|
+
function withCors(response, origin, allowedOrigins) {
|
|
3138
|
+
const headers = new Headers(response.headers);
|
|
3139
|
+
for (const [key, value] of corsHeaders(origin, allowedOrigins)) {
|
|
3140
|
+
headers.set(key, value);
|
|
3141
|
+
}
|
|
3142
|
+
return new Response(response.body, {
|
|
3143
|
+
status: response.status,
|
|
3144
|
+
statusText: response.statusText,
|
|
3145
|
+
headers
|
|
3146
|
+
});
|
|
3147
|
+
}
|
|
3148
|
+
function corsHeaders(origin, allowedOrigins) {
|
|
3149
|
+
const headers = new Headers();
|
|
3150
|
+
if (origin && allowedOrigins.has(origin)) {
|
|
3151
|
+
headers.set("Access-Control-Allow-Origin", origin);
|
|
3152
|
+
headers.set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
3153
|
+
headers.set("Access-Control-Allow-Headers", "Content-Type, MCP-Protocol-Version, Last-Event-ID");
|
|
3154
|
+
headers.set("Access-Control-Expose-Headers", "MCP-Session-Id, MCP-Protocol-Version");
|
|
3155
|
+
headers.set("Vary", "Origin");
|
|
3156
|
+
}
|
|
3157
|
+
return headers;
|
|
3158
|
+
}
|
|
3159
|
+
|
|
3160
|
+
// src/mcp/runtime.ts
|
|
3161
|
+
import fs16 from "fs";
|
|
3162
|
+
import path12 from "path";
|
|
3163
|
+
var GraniteMcpRuntime = class {
|
|
3164
|
+
vaultRoot;
|
|
3165
|
+
config;
|
|
3166
|
+
db;
|
|
3167
|
+
indexCheckIntervalMs;
|
|
3168
|
+
lastSignature;
|
|
3169
|
+
lastIndexCheckAt = 0;
|
|
3170
|
+
constructor(vaultRoot, options = {}) {
|
|
3171
|
+
this.vaultRoot = path12.resolve(vaultRoot);
|
|
3172
|
+
this.config = loadConfig(this.vaultRoot);
|
|
3173
|
+
this.db = openDatabase(this.vaultRoot);
|
|
3174
|
+
this.indexCheckIntervalMs = options.indexCheckIntervalMs ?? 1500;
|
|
3175
|
+
}
|
|
3176
|
+
close() {
|
|
3177
|
+
this.db.close();
|
|
3178
|
+
}
|
|
3179
|
+
getVaultOverview(recentLimit = 10) {
|
|
3180
|
+
const notes = this.readAllNotes().sort((a, b) => b.frontmatter.modified.localeCompare(a.frontmatter.modified));
|
|
3181
|
+
const byType = {};
|
|
3182
|
+
for (const note of notes) {
|
|
3183
|
+
byType[note.frontmatter.type] = (byType[note.frontmatter.type] ?? 0) + 1;
|
|
3184
|
+
}
|
|
3185
|
+
return {
|
|
3186
|
+
vault_root: this.vaultRoot,
|
|
3187
|
+
vault_name: this.config.vault_name,
|
|
3188
|
+
default_type: this.config.defaults.note_type,
|
|
3189
|
+
auto_rebuild: this.config.index.auto_rebuild,
|
|
3190
|
+
index_last_rebuild: this.getMetaValue("last_rebuild"),
|
|
3191
|
+
note_count: notes.length,
|
|
3192
|
+
notes_by_type: byType,
|
|
3193
|
+
recent_notes: notes.slice(0, clampLimit(recentLimit, 20)).map((note) => this.toNoteSummary(note))
|
|
3194
|
+
};
|
|
3195
|
+
}
|
|
3196
|
+
listNoteTypes() {
|
|
3197
|
+
return Object.entries(this.config.note_types).map(([name, typeConfig]) => ({
|
|
3198
|
+
name,
|
|
3199
|
+
description: typeConfig.description,
|
|
3200
|
+
folder: typeConfig.folder,
|
|
3201
|
+
line_limit: typeConfig.line_limit,
|
|
3202
|
+
warn_only: typeConfig.warn_only,
|
|
3203
|
+
slug_format: typeConfig.slug_format ?? "title",
|
|
3204
|
+
instructions: typeConfig.instructions,
|
|
3205
|
+
fields: typeConfig.fields
|
|
3206
|
+
}));
|
|
3207
|
+
}
|
|
3208
|
+
getDefaultNoteType() {
|
|
3209
|
+
return this.config.defaults.note_type;
|
|
3210
|
+
}
|
|
3211
|
+
listNotes(input = {}) {
|
|
3212
|
+
let notes = this.readAllNotes();
|
|
3213
|
+
if (input.type) {
|
|
3214
|
+
notes = notes.filter((note) => note.frontmatter.type === input.type);
|
|
3215
|
+
}
|
|
3216
|
+
if (input.status) {
|
|
3217
|
+
notes = notes.filter((note) => note.frontmatter.status === input.status);
|
|
3218
|
+
}
|
|
3219
|
+
if (input.source) {
|
|
3220
|
+
notes = notes.filter((note) => note.frontmatter.source === input.source);
|
|
3221
|
+
}
|
|
3222
|
+
if (input.since) {
|
|
3223
|
+
const since = input.since;
|
|
3224
|
+
notes = notes.filter((note) => note.frontmatter.modified >= since);
|
|
3225
|
+
}
|
|
3226
|
+
notes.sort((a, b) => b.frontmatter.modified.localeCompare(a.frontmatter.modified));
|
|
3227
|
+
return notes.slice(0, clampLimit(input.limit ?? 25, 200)).map((note) => this.toNoteSummary(note));
|
|
3228
|
+
}
|
|
3229
|
+
getNote(slug) {
|
|
3230
|
+
const note = this.requireNote(slug);
|
|
3231
|
+
const allNotes = this.readAllNotes();
|
|
3232
|
+
const outgoingLinks = resolveWikilinks(parseWikilinks(note.body), allNotes);
|
|
3233
|
+
return {
|
|
3234
|
+
...this.toNoteSummary(note),
|
|
3235
|
+
body: note.body,
|
|
3236
|
+
frontmatter: note.frontmatter,
|
|
3237
|
+
outgoing_links: outgoingLinks
|
|
3238
|
+
};
|
|
3239
|
+
}
|
|
3240
|
+
search(query, limit = 10) {
|
|
3241
|
+
this.refreshIndex();
|
|
3242
|
+
return searchNotes(this.db, query, clampLimit(limit, 50));
|
|
3243
|
+
}
|
|
3244
|
+
getBacklinks(slug) {
|
|
3245
|
+
this.requireNote(slug);
|
|
3246
|
+
this.refreshIndex();
|
|
3247
|
+
return getBacklinks(this.db, slug);
|
|
3248
|
+
}
|
|
3249
|
+
suggestLinks(slug) {
|
|
3250
|
+
const note = this.requireNote(slug);
|
|
3251
|
+
this.refreshIndex();
|
|
3252
|
+
return suggestLinks(this.db, note);
|
|
3253
|
+
}
|
|
3254
|
+
recommend(slug) {
|
|
3255
|
+
const note = this.requireNote(slug);
|
|
3256
|
+
this.refreshIndex();
|
|
3257
|
+
return getRecommendations(this.db, note, this.config);
|
|
3258
|
+
}
|
|
3259
|
+
runDoctor() {
|
|
3260
|
+
this.refreshIndex();
|
|
3261
|
+
const issues = runDoctor(this.vaultRoot, this.config, this.db);
|
|
3262
|
+
return {
|
|
3263
|
+
counts: {
|
|
3264
|
+
errors: issues.filter((issue) => issue.level === "error").length,
|
|
3265
|
+
warnings: issues.filter((issue) => issue.level === "warning").length,
|
|
3266
|
+
info: issues.filter((issue) => issue.level === "info").length
|
|
3267
|
+
},
|
|
3268
|
+
issues
|
|
3269
|
+
};
|
|
3270
|
+
}
|
|
3271
|
+
createNote(input) {
|
|
3272
|
+
const resolvedType = input.type ?? this.config.defaults.note_type;
|
|
3273
|
+
const typeConfig = this.config.note_types[resolvedType];
|
|
3274
|
+
if (!typeConfig) {
|
|
3275
|
+
throw new Error(`Unknown note type: "${resolvedType}"`);
|
|
3276
|
+
}
|
|
3277
|
+
const bodyOverride = input.body !== void 0 ? ensureTrailingNewline(input.body) : typeConfig.slug_format === "date" ? ensureTrailingNewline(input.title) : void 0;
|
|
3278
|
+
const created = createNote(this.vaultRoot, this.config, resolvedType, input.title, bodyOverride);
|
|
3279
|
+
const metadataMutations = {
|
|
3280
|
+
tags: input.tags,
|
|
3281
|
+
aliases: input.aliases,
|
|
3282
|
+
status: input.status,
|
|
3283
|
+
source: input.source
|
|
3284
|
+
};
|
|
3285
|
+
if (hasMetadataMutations(metadataMutations)) {
|
|
3286
|
+
this.applyMutations(created.filepath, metadataMutations);
|
|
3287
|
+
}
|
|
3288
|
+
return this.afterWrite(created.slug, true);
|
|
3289
|
+
}
|
|
3290
|
+
captureNote(input) {
|
|
3291
|
+
const content = input.text.trim();
|
|
3292
|
+
if (!content) {
|
|
3293
|
+
throw new Error("Capture text cannot be empty.");
|
|
3294
|
+
}
|
|
3295
|
+
const firstLine = content.split("\n")[0] ?? "Untitled";
|
|
3296
|
+
const title = firstLine.length > 60 ? `${firstLine.slice(0, 60).trim()}...` : firstLine;
|
|
3297
|
+
return this.createNote({
|
|
3298
|
+
title,
|
|
3299
|
+
type: input.type,
|
|
3300
|
+
body: ensureTrailingNewline(content),
|
|
3301
|
+
tags: input.tags,
|
|
3302
|
+
aliases: input.aliases,
|
|
3303
|
+
status: input.status,
|
|
3304
|
+
source: input.source
|
|
3305
|
+
});
|
|
3306
|
+
}
|
|
3307
|
+
updateNote(slug, input) {
|
|
3308
|
+
const note = this.requireNote(slug);
|
|
3309
|
+
const needsFullRebuild = input.title !== void 0 || (input.aliases?.length ?? 0) > 0;
|
|
3310
|
+
this.applyMutations(note.filepath, input);
|
|
3311
|
+
if (needsFullRebuild) {
|
|
3312
|
+
return this.afterWrite(slug, true);
|
|
3313
|
+
}
|
|
3314
|
+
this.refreshIndex();
|
|
3315
|
+
const updated = readNote(note.filepath);
|
|
3316
|
+
syncNoteInIndex(this.vaultRoot, this.config, this.db, updated);
|
|
3317
|
+
this.captureSignature();
|
|
3318
|
+
return {
|
|
3319
|
+
note: this.getNote(updated.slug),
|
|
3320
|
+
recommendations: getRecommendations(this.db, updated, this.config)
|
|
3321
|
+
};
|
|
3322
|
+
}
|
|
3323
|
+
readVaultConfigRaw() {
|
|
3324
|
+
return fs16.readFileSync(path12.join(this.vaultRoot, CONFIG_FILENAME), "utf-8");
|
|
3325
|
+
}
|
|
3326
|
+
readVaultTypesJson() {
|
|
3327
|
+
return JSON.stringify({
|
|
3328
|
+
default_type: this.config.defaults.note_type,
|
|
3329
|
+
note_types: this.listNoteTypes()
|
|
3330
|
+
}, null, 2);
|
|
3331
|
+
}
|
|
3332
|
+
readVaultOverviewJson() {
|
|
3333
|
+
return JSON.stringify(this.getVaultOverview(), null, 2);
|
|
3334
|
+
}
|
|
3335
|
+
readNoteMarkdown(slug) {
|
|
3336
|
+
const note = this.requireNote(slug);
|
|
3337
|
+
return fs16.readFileSync(note.filepath, "utf-8");
|
|
3338
|
+
}
|
|
3339
|
+
completeSlugs(prefix = "") {
|
|
3340
|
+
const normalizedPrefix = prefix.toLowerCase();
|
|
3341
|
+
return this.readAllNotes().sort((a, b) => b.frontmatter.modified.localeCompare(a.frontmatter.modified)).map((note) => note.slug).filter((slug) => slug.toLowerCase().startsWith(normalizedPrefix)).slice(0, 25);
|
|
3342
|
+
}
|
|
3343
|
+
noteResourceUri(slug) {
|
|
3344
|
+
return `granite://notes/${encodeURIComponent(slug)}`;
|
|
3345
|
+
}
|
|
3346
|
+
refreshIndex(force = false) {
|
|
3347
|
+
const now = Date.now();
|
|
3348
|
+
if (!force && now - this.lastIndexCheckAt < this.indexCheckIntervalMs) {
|
|
3349
|
+
return;
|
|
3350
|
+
}
|
|
3351
|
+
const currentConfigMtimeMs = this.getConfigMtimeMs();
|
|
3352
|
+
if (!this.lastSignature || currentConfigMtimeMs !== this.lastSignature.configMtimeMs) {
|
|
3353
|
+
this.config = loadConfig(this.vaultRoot);
|
|
3354
|
+
}
|
|
3355
|
+
const signature = this.computeSignature();
|
|
3356
|
+
let shouldRebuild = false;
|
|
3357
|
+
if (force) {
|
|
3358
|
+
shouldRebuild = true;
|
|
3359
|
+
} else if (this.config.index.auto_rebuild) {
|
|
3360
|
+
if (!this.lastSignature) {
|
|
3361
|
+
shouldRebuild = signature.noteCount !== this.getIndexedNoteCount() || signature.latestMutationMs > this.getLastRebuildMs();
|
|
3362
|
+
} else {
|
|
3363
|
+
shouldRebuild = signature.noteCount !== this.lastSignature.noteCount || signature.latestMutationMs !== this.lastSignature.latestMutationMs || signature.configMtimeMs !== this.lastSignature.configMtimeMs;
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
if (shouldRebuild) {
|
|
3367
|
+
rebuildIndex(this.vaultRoot, this.config, this.db);
|
|
3368
|
+
}
|
|
3369
|
+
this.lastSignature = signature;
|
|
3370
|
+
this.lastIndexCheckAt = now;
|
|
3371
|
+
}
|
|
3372
|
+
readAllNotes() {
|
|
3373
|
+
return listNotes(this.vaultRoot, this.config);
|
|
3374
|
+
}
|
|
3375
|
+
requireNote(slug) {
|
|
3376
|
+
const note = findNoteBySlug(this.vaultRoot, this.config, slug);
|
|
3377
|
+
if (!note) {
|
|
3378
|
+
throw new Error(`Note not found: ${slug}`);
|
|
3379
|
+
}
|
|
3380
|
+
return note;
|
|
3381
|
+
}
|
|
3382
|
+
toNoteSummary(note) {
|
|
3383
|
+
return {
|
|
3384
|
+
slug: note.slug,
|
|
3385
|
+
title: note.frontmatter.title,
|
|
3386
|
+
type: note.frontmatter.type,
|
|
3387
|
+
created: note.frontmatter.created,
|
|
3388
|
+
modified: note.frontmatter.modified,
|
|
3389
|
+
tags: note.frontmatter.tags,
|
|
3390
|
+
aliases: note.frontmatter.aliases,
|
|
3391
|
+
status: note.frontmatter.status,
|
|
3392
|
+
source: note.frontmatter.source,
|
|
3393
|
+
filepath: note.filepath,
|
|
3394
|
+
resource_uri: this.noteResourceUri(note.slug)
|
|
3395
|
+
};
|
|
3396
|
+
}
|
|
3397
|
+
applyMutations(filepath, input) {
|
|
3398
|
+
const raw = fs16.readFileSync(filepath, "utf-8");
|
|
3399
|
+
const { frontmatter, body: existingBody } = parseFrontmatter(raw);
|
|
3400
|
+
let body = existingBody;
|
|
3401
|
+
if (input.title !== void 0) {
|
|
3402
|
+
frontmatter.title = input.title;
|
|
3403
|
+
}
|
|
3404
|
+
if (input.tags && input.tags.length > 0) {
|
|
3405
|
+
frontmatter.tags = mergeUnique(frontmatter.tags, input.tags);
|
|
3406
|
+
}
|
|
3407
|
+
if (input.aliases && input.aliases.length > 0) {
|
|
3408
|
+
frontmatter.aliases = mergeUnique(frontmatter.aliases, input.aliases);
|
|
3409
|
+
}
|
|
3410
|
+
if (input.status !== void 0) {
|
|
3411
|
+
validateStatus(input.status);
|
|
3412
|
+
frontmatter.status = input.status;
|
|
3413
|
+
}
|
|
3414
|
+
if (input.source !== void 0) {
|
|
3415
|
+
validateSource(input.source);
|
|
3416
|
+
frontmatter.source = input.source;
|
|
3417
|
+
}
|
|
3418
|
+
if (input.body !== void 0) {
|
|
3419
|
+
body = ensureTrailingNewline(input.body);
|
|
3420
|
+
}
|
|
3421
|
+
if (input.append !== void 0) {
|
|
3422
|
+
body = `${body.trimEnd()}
|
|
3423
|
+
${input.append}
|
|
3424
|
+
`;
|
|
3425
|
+
}
|
|
3426
|
+
frontmatter.modified = (/* @__PURE__ */ new Date()).toISOString();
|
|
3427
|
+
fs16.writeFileSync(filepath, serializeFrontmatter(frontmatter, body), "utf-8");
|
|
3428
|
+
}
|
|
3429
|
+
afterWrite(slug, fullRebuild) {
|
|
3430
|
+
if (fullRebuild) {
|
|
3431
|
+
this.refreshIndex(true);
|
|
3432
|
+
} else {
|
|
3433
|
+
this.refreshIndex();
|
|
3434
|
+
const note2 = this.requireNote(slug);
|
|
3435
|
+
syncNoteInIndex(this.vaultRoot, this.config, this.db, note2);
|
|
3436
|
+
this.captureSignature();
|
|
3437
|
+
}
|
|
3438
|
+
const note = this.requireNote(slug);
|
|
3439
|
+
return {
|
|
3440
|
+
note: this.getNote(note.slug),
|
|
3441
|
+
recommendations: getRecommendations(this.db, note, this.config)
|
|
3442
|
+
};
|
|
3443
|
+
}
|
|
3444
|
+
captureSignature() {
|
|
3445
|
+
this.lastSignature = this.computeSignature();
|
|
3446
|
+
this.lastIndexCheckAt = Date.now();
|
|
3447
|
+
}
|
|
3448
|
+
computeSignature() {
|
|
3449
|
+
let noteCount = 0;
|
|
3450
|
+
let latestNoteMtimeMs = 0;
|
|
3451
|
+
for (const typeConfig of Object.values(this.config.note_types)) {
|
|
3452
|
+
const folder = path12.join(this.vaultRoot, typeConfig.folder);
|
|
3453
|
+
if (!fs16.existsSync(folder)) {
|
|
3454
|
+
continue;
|
|
3455
|
+
}
|
|
3456
|
+
for (const entry of fs16.readdirSync(folder)) {
|
|
3457
|
+
if (!entry.endsWith(".md")) {
|
|
3458
|
+
continue;
|
|
3459
|
+
}
|
|
3460
|
+
noteCount += 1;
|
|
3461
|
+
const stat = fs16.statSync(path12.join(folder, entry));
|
|
3462
|
+
latestNoteMtimeMs = Math.max(latestNoteMtimeMs, stat.mtimeMs);
|
|
3463
|
+
}
|
|
3464
|
+
}
|
|
3465
|
+
const configMtimeMs = this.getConfigMtimeMs();
|
|
3466
|
+
return {
|
|
3467
|
+
noteCount,
|
|
3468
|
+
latestMutationMs: Math.max(latestNoteMtimeMs, configMtimeMs),
|
|
3469
|
+
configMtimeMs
|
|
3470
|
+
};
|
|
3471
|
+
}
|
|
3472
|
+
getConfigMtimeMs() {
|
|
3473
|
+
const configPath = path12.join(this.vaultRoot, CONFIG_FILENAME);
|
|
3474
|
+
return fs16.existsSync(configPath) ? fs16.statSync(configPath).mtimeMs : 0;
|
|
3475
|
+
}
|
|
3476
|
+
getLastRebuildMs() {
|
|
3477
|
+
const value = this.getMetaValue("last_rebuild");
|
|
3478
|
+
return value ? Date.parse(value) || 0 : 0;
|
|
3479
|
+
}
|
|
3480
|
+
getMetaValue(key) {
|
|
3481
|
+
const row = this.db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
|
|
3482
|
+
return row?.value;
|
|
3483
|
+
}
|
|
3484
|
+
getIndexedNoteCount() {
|
|
3485
|
+
const row = this.db.prepare("SELECT COUNT(*) as count FROM notes").get();
|
|
3486
|
+
return row.count;
|
|
3487
|
+
}
|
|
3488
|
+
};
|
|
3489
|
+
function ensureTrailingNewline(value) {
|
|
3490
|
+
return value.endsWith("\n") ? value : `${value}
|
|
3491
|
+
`;
|
|
3492
|
+
}
|
|
3493
|
+
function clampLimit(value, max) {
|
|
3494
|
+
return Math.max(1, Math.min(value, max));
|
|
3495
|
+
}
|
|
3496
|
+
function mergeUnique(existing, incoming) {
|
|
3497
|
+
const merged = new Set(existing);
|
|
3498
|
+
for (const value of incoming) {
|
|
3499
|
+
const trimmed = value.trim();
|
|
3500
|
+
if (trimmed) {
|
|
3501
|
+
merged.add(trimmed);
|
|
3502
|
+
}
|
|
3503
|
+
}
|
|
3504
|
+
return [...merged];
|
|
3505
|
+
}
|
|
3506
|
+
function hasMetadataMutations(input) {
|
|
3507
|
+
return (input.tags?.length ?? 0) > 0 || (input.aliases?.length ?? 0) > 0 || input.status !== void 0 || input.source !== void 0;
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3510
|
+
// src/commands/mcp.ts
|
|
3511
|
+
async function mcpCommand(options) {
|
|
3512
|
+
const transport = parseTransport(options.transport);
|
|
3513
|
+
const vaultRoot = resolveVaultRoot(options.vault);
|
|
3514
|
+
const runtime = new GraniteMcpRuntime(vaultRoot);
|
|
3515
|
+
const shutdown = () => {
|
|
3516
|
+
runtime.close();
|
|
3517
|
+
process.exit(0);
|
|
3518
|
+
};
|
|
3519
|
+
process.once("SIGINT", shutdown);
|
|
3520
|
+
process.once("SIGTERM", shutdown);
|
|
3521
|
+
if (transport === "http") {
|
|
3522
|
+
const port = parsePort(options.port);
|
|
3523
|
+
startGraniteMcpHttpServer(runtime, {
|
|
3524
|
+
host: options.host ?? "127.0.0.1",
|
|
3525
|
+
port,
|
|
3526
|
+
allowedOrigins: options.allowOrigin ?? [],
|
|
3527
|
+
jsonResponse: options.jsonResponse ?? false
|
|
3528
|
+
});
|
|
3529
|
+
return;
|
|
3530
|
+
}
|
|
3531
|
+
await startGraniteMcpStdioServer(runtime);
|
|
3532
|
+
}
|
|
3533
|
+
function resolveVaultRoot(explicitVault) {
|
|
3534
|
+
const fromEnv = process.env.GRANITE_VAULT;
|
|
3535
|
+
if (explicitVault) {
|
|
3536
|
+
return path13.resolve(explicitVault);
|
|
3537
|
+
}
|
|
3538
|
+
if (fromEnv) {
|
|
3539
|
+
return path13.resolve(fromEnv);
|
|
3540
|
+
}
|
|
3541
|
+
return requireVaultRoot();
|
|
3542
|
+
}
|
|
3543
|
+
function parseTransport(value) {
|
|
3544
|
+
const transport = value ?? "stdio";
|
|
3545
|
+
if (transport !== "stdio" && transport !== "http") {
|
|
3546
|
+
throw new Error(`Invalid MCP transport: ${transport}. Expected "stdio" or "http".`);
|
|
3547
|
+
}
|
|
3548
|
+
return transport;
|
|
3549
|
+
}
|
|
3550
|
+
function parsePort(value) {
|
|
3551
|
+
const raw = (value ?? process.env.MCP_PORT ?? "3321").trim();
|
|
3552
|
+
if (!/^\d+$/.test(raw)) {
|
|
3553
|
+
throw new Error(`Invalid MCP port: ${raw}`);
|
|
3554
|
+
}
|
|
3555
|
+
const parsed = Number.parseInt(raw, 10);
|
|
3556
|
+
if (parsed <= 0) {
|
|
3557
|
+
throw new Error(`Invalid MCP port: ${raw}`);
|
|
3558
|
+
}
|
|
3559
|
+
return parsed;
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
// src/commands/sync.ts
|
|
3563
|
+
async function syncCommand(options) {
|
|
3564
|
+
const vaultRoot = requireVaultRoot();
|
|
3565
|
+
const config = loadConfig(vaultRoot);
|
|
3566
|
+
const manager = new SyncManager(vaultRoot, config);
|
|
3567
|
+
const result = await manager.sync();
|
|
3568
|
+
if (options.json) {
|
|
3569
|
+
console.log(JSON.stringify(result));
|
|
3570
|
+
return;
|
|
3571
|
+
}
|
|
3572
|
+
console.log(`Sync complete: ${result.pushed} pushed, ${result.pulled} pulled`);
|
|
3573
|
+
if (result.conflicts > 0) {
|
|
3574
|
+
console.log(` ${result.conflicts} conflict(s) resolved (backups in .granite/conflicts/)`);
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
async function syncStatusCommand(options) {
|
|
3578
|
+
const vaultRoot = requireVaultRoot();
|
|
3579
|
+
const config = loadConfig(vaultRoot);
|
|
3580
|
+
const manager = new SyncManager(vaultRoot, config);
|
|
3581
|
+
const status = manager.status();
|
|
3582
|
+
const conflictFiles = manager.conflicts();
|
|
3583
|
+
if (options.json) {
|
|
3584
|
+
console.log(JSON.stringify({ ...status, conflicts: conflictFiles.length }));
|
|
3585
|
+
return;
|
|
3586
|
+
}
|
|
3587
|
+
console.log(`Device: ${status.device_name} (${status.device_id.slice(0, 8)}...)`);
|
|
3588
|
+
console.log(`Last sync: ${status.last_sync ?? "never"}`);
|
|
3589
|
+
console.log(`Pending changes: ${status.pending_changes}`);
|
|
3590
|
+
console.log(`Server seq: ${status.server_seq}`);
|
|
3591
|
+
if (conflictFiles.length > 0) {
|
|
3592
|
+
console.log(`Conflicts: ${conflictFiles.length} file(s) in .granite/conflicts/`);
|
|
3593
|
+
}
|
|
3594
|
+
}
|
|
3595
|
+
async function syncDevicesCommand(options) {
|
|
3596
|
+
const vaultRoot = requireVaultRoot();
|
|
3597
|
+
const config = loadConfig(vaultRoot);
|
|
3598
|
+
const manager = new SyncManager(vaultRoot, config);
|
|
3599
|
+
const devices = await manager.devices();
|
|
3600
|
+
if (options.json) {
|
|
3601
|
+
console.log(JSON.stringify(devices));
|
|
3602
|
+
return;
|
|
3603
|
+
}
|
|
3604
|
+
if (devices.length === 0) {
|
|
3605
|
+
console.log("No devices registered yet.");
|
|
3606
|
+
return;
|
|
3607
|
+
}
|
|
3608
|
+
for (const d of devices) {
|
|
3609
|
+
console.log(` ${d.device_name} (${d.device_id.slice(0, 8)}...) \u2014 last seen ${d.last_seen}`);
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
function syncConflictsCommand(options) {
|
|
3613
|
+
const vaultRoot = requireVaultRoot();
|
|
3614
|
+
if (options.clear) {
|
|
3615
|
+
const cleared = clearResolvedConflicts(vaultRoot);
|
|
3616
|
+
if (options.json) {
|
|
3617
|
+
console.log(JSON.stringify({ cleared }));
|
|
3618
|
+
} else {
|
|
3619
|
+
console.log(`Cleared ${cleared} conflict file(s).`);
|
|
3620
|
+
}
|
|
3621
|
+
return;
|
|
3622
|
+
}
|
|
3623
|
+
const files = listConflicts(vaultRoot);
|
|
3624
|
+
if (options.json) {
|
|
3625
|
+
console.log(JSON.stringify({ conflicts: files }));
|
|
3626
|
+
return;
|
|
3627
|
+
}
|
|
3628
|
+
if (files.length === 0) {
|
|
3629
|
+
console.log("No conflicts.");
|
|
3630
|
+
return;
|
|
3631
|
+
}
|
|
3632
|
+
console.log(`${files.length} conflict file(s):`);
|
|
3633
|
+
for (const f of files) {
|
|
3634
|
+
console.log(` ${f}`);
|
|
3635
|
+
}
|
|
3636
|
+
}
|
|
3637
|
+
|
|
1962
3638
|
// src/index.ts
|
|
1963
3639
|
var program = new Command();
|
|
1964
|
-
program.name("
|
|
3640
|
+
program.name("granite").description("Granite \u2014 a local-first markdown memory system").version(GRANITE_VERSION);
|
|
1965
3641
|
program.command("init").description("Initialize the default vault in ~/.granite").action(() => {
|
|
1966
3642
|
initVault();
|
|
1967
3643
|
});
|
|
@@ -2004,4 +3680,29 @@ program.command("doctor").description("Validate vault health").action(() => {
|
|
|
2004
3680
|
program.command("serve").description("Start the local web UI").option("-p, --port <port>", "Port number", "4321").action((options) => {
|
|
2005
3681
|
serveCommand(options);
|
|
2006
3682
|
});
|
|
2007
|
-
program.
|
|
3683
|
+
program.command("mcp").description("Start Granite as an MCP server").option("--vault <path>", "Vault root. Defaults to the current Granite vault or $GRANITE_VAULT").option("--transport <transport>", "Transport to use: stdio or http", parseTransportOption, "stdio").option("--host <host>", "Host for HTTP transport", "127.0.0.1").option("--port <port>", "Port for HTTP transport", "3321").option("--allow-origin <origin>", "Allow an HTTP Origin for browser-based HTTP clients", collectValues, []).option("--json-response", "Prefer JSON HTTP responses instead of SSE streams").action(async (options) => {
|
|
3684
|
+
await mcpCommand(options);
|
|
3685
|
+
});
|
|
3686
|
+
var syncCmd = program.command("sync").description("Sync vault across devices").option("--json", "Output as JSON").action(async (options) => {
|
|
3687
|
+
await syncCommand(options);
|
|
3688
|
+
});
|
|
3689
|
+
syncCmd.command("status").description("Show sync status").option("--json", "Output as JSON").action(async (options) => {
|
|
3690
|
+
await syncStatusCommand(options);
|
|
3691
|
+
});
|
|
3692
|
+
syncCmd.command("devices").description("List connected devices").option("--json", "Output as JSON").action(async (options) => {
|
|
3693
|
+
await syncDevicesCommand(options);
|
|
3694
|
+
});
|
|
3695
|
+
syncCmd.command("conflicts").description("List or clear conflict files").option("--clear", "Remove all conflict backup files").option("--json", "Output as JSON").action((options) => {
|
|
3696
|
+
syncConflictsCommand(options);
|
|
3697
|
+
});
|
|
3698
|
+
await program.parseAsync();
|
|
3699
|
+
function collectValues(value, previous) {
|
|
3700
|
+
return [...previous, value];
|
|
3701
|
+
}
|
|
3702
|
+
function parseTransportOption(value) {
|
|
3703
|
+
try {
|
|
3704
|
+
return parseTransport(value);
|
|
3705
|
+
} catch (error) {
|
|
3706
|
+
throw new InvalidArgumentError(error instanceof Error ? error.message : String(error));
|
|
3707
|
+
}
|
|
3708
|
+
}
|