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.
Files changed (3) hide show
  1. package/README.md +72 -33
  2. package/dist/index.js +1743 -42
  3. 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 "mem init" first.`);
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 "mem init" to create ${getDefaultVaultRoot()}.`);
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: mem new "My first note" --type fleeting');
183
+ console.log('Next: granite new "My first note" --type fleeting');
184
184
  }
185
185
 
186
186
  // src/commands/new.ts
187
- import fs6 from "fs";
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 = fs6.readFileSync(note.filepath, "utf-8");
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
- fs6.writeFileSync(note.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
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 fs7 from "fs";
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 = fs7.readFileSync(0, "utf-8").trim();
1756
+ content = fs11.readFileSync(0, "utf-8").trim();
1268
1757
  } else {
1269
- console.error('Usage: mem add "some text" or echo "text" | mem add');
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 fs8 from "fs";
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 = fs8.readFileSync(note.filepath, "utf-8");
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 20
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 fs9 from "fs";
1510
- import path6 from "path";
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 = path6.join(vaultRoot, typeConfig.folder);
1516
- if (!fs9.existsSync(folder)) {
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 path7 from "path";
1652
- import fs10 from "fs";
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 = path7.join(import.meta.dirname, "public");
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 = path7.join(publicDir, reqPath);
1742
- if (!fs10.existsSync(filePath)) {
1743
- const indexPath = path7.join(publicDir, "index.html");
1744
- const html = fs10.readFileSync(indexPath, "utf-8");
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 = fs10.readFileSync(filePath);
1748
- const ext = path7.extname(filePath);
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: mem new <type> <title>`);
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 fs11 from "fs";
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(fs11.readFileSync(note.filepath, "utf-8"));
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
- fs11.writeFileSync(note.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
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 = fs11.statSync(note.filepath).mtimeMs;
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 = fs11.statSync(note.filepath).mtimeMs;
2437
+ const statAfter = fs15.statSync(note.filepath).mtimeMs;
1941
2438
  if (statAfter !== statBefore) {
1942
- const { frontmatter, body } = parseFrontmatter(fs11.readFileSync(note.filepath, "utf-8"));
2439
+ const { frontmatter, body } = parseFrontmatter(fs15.readFileSync(note.filepath, "utf-8"));
1943
2440
  frontmatter.modified = (/* @__PURE__ */ new Date()).toISOString();
1944
- fs11.writeFileSync(note.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
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("mem").description("Granite \u2014 a local-first markdown memory system").version("0.1.0");
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.parse();
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
+ }