granite-mem 0.1.4 → 0.1.6
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 +38 -38
- package/dist/index.js +633 -50
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -81,48 +81,48 @@ npm link
|
|
|
81
81
|
Create the default vault in `~/.granite` and start capturing:
|
|
82
82
|
|
|
83
83
|
```bash
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
84
|
+
granite init
|
|
85
|
+
granite add "Talked to Alice about local-first sync tradeoffs"
|
|
86
|
+
granite new "Local-first sync tradeoffs" --type permanent
|
|
87
|
+
granite list
|
|
88
|
+
granite search "sync"
|
|
89
89
|
```
|
|
90
90
|
|
|
91
91
|
Start one long-running interface when you need it:
|
|
92
92
|
|
|
93
93
|
```bash
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
granite serve # local web UI
|
|
95
|
+
granite mcp # MCP server for agent clients
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
`
|
|
98
|
+
`granite new` does more than create a file. It can immediately suggest related links, tags, and the next note to create, which is the core of Granite's value loop.
|
|
99
99
|
|
|
100
100
|
## Example Workflow
|
|
101
101
|
|
|
102
102
|
Capture something quickly:
|
|
103
103
|
|
|
104
104
|
```bash
|
|
105
|
-
|
|
105
|
+
granite add "Users want fewer note types, but stronger defaults."
|
|
106
106
|
```
|
|
107
107
|
|
|
108
108
|
Turn it into a durable note:
|
|
109
109
|
|
|
110
110
|
```bash
|
|
111
|
-
|
|
111
|
+
granite new "Strong defaults beat infinite flexibility" --type permanent
|
|
112
112
|
```
|
|
113
113
|
|
|
114
114
|
Find connections:
|
|
115
115
|
|
|
116
116
|
```bash
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
granite suggest-links strong-defaults-beat-infinite-flexibility
|
|
118
|
+
granite recommend strong-defaults-beat-infinite-flexibility
|
|
119
|
+
granite backlinks strong-defaults-beat-infinite-flexibility
|
|
120
120
|
```
|
|
121
121
|
|
|
122
122
|
Open the local UI:
|
|
123
123
|
|
|
124
124
|
```bash
|
|
125
|
-
|
|
125
|
+
granite serve
|
|
126
126
|
```
|
|
127
127
|
|
|
128
128
|
Then browse notes, search the vault, inspect backlinks, and explore the graph locally.
|
|
@@ -172,12 +172,12 @@ This keeps the system transparent, portable, and inspectable.
|
|
|
172
172
|
Many commands support JSON output:
|
|
173
173
|
|
|
174
174
|
```bash
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
175
|
+
granite new "Sync constraints" --type permanent --json
|
|
176
|
+
granite list --json
|
|
177
|
+
granite show sync-constraints --json
|
|
178
|
+
granite search "constraints" --json
|
|
179
|
+
granite backlinks sync-constraints --json
|
|
180
|
+
granite recommend sync-constraints --json
|
|
181
181
|
```
|
|
182
182
|
|
|
183
183
|
That makes Granite a useful substrate for local workflows, scripts, and agent memory.
|
|
@@ -189,13 +189,13 @@ Granite ships with an MCP server so LLM clients can control the vault directly t
|
|
|
189
189
|
Start it over stdio for local MCP clients:
|
|
190
190
|
|
|
191
191
|
```bash
|
|
192
|
-
|
|
192
|
+
granite mcp --vault /path/to/vault
|
|
193
193
|
```
|
|
194
194
|
|
|
195
195
|
Start it over Streamable HTTP:
|
|
196
196
|
|
|
197
197
|
```bash
|
|
198
|
-
|
|
198
|
+
granite mcp --transport http --host 127.0.0.1 --port 3321
|
|
199
199
|
```
|
|
200
200
|
|
|
201
201
|
The server exposes:
|
|
@@ -208,7 +208,7 @@ Example stdio client configuration:
|
|
|
208
208
|
|
|
209
209
|
```json
|
|
210
210
|
{
|
|
211
|
-
"command": "
|
|
211
|
+
"command": "granite",
|
|
212
212
|
"args": ["mcp", "--vault", "/path/to/vault"]
|
|
213
213
|
}
|
|
214
214
|
```
|
|
@@ -216,21 +216,21 @@ Example stdio client configuration:
|
|
|
216
216
|
## Commands
|
|
217
217
|
|
|
218
218
|
```bash
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
219
|
+
granite init
|
|
220
|
+
granite new <title> [--type <type>] [--json]
|
|
221
|
+
granite add [text] [--json]
|
|
222
|
+
granite list [--type <type>] [--json]
|
|
223
|
+
granite edit <slug>
|
|
224
|
+
granite open <slug>
|
|
225
|
+
granite show <slug> [--json] [--body]
|
|
226
|
+
granite search <query> [--json]
|
|
227
|
+
granite backlinks <slug> [--json]
|
|
228
|
+
granite suggest-links <slug> [--json]
|
|
229
|
+
granite recommend <slug> [--json]
|
|
230
|
+
granite types
|
|
231
|
+
granite doctor
|
|
232
|
+
granite serve [-p <port>]
|
|
233
|
+
granite mcp [--vault <path>] [--transport <stdio|http>]
|
|
234
234
|
```
|
|
235
235
|
|
|
236
236
|
## Development
|
package/dist/index.js
CHANGED
|
@@ -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";
|
|
@@ -1200,6 +1200,489 @@ function parseJsonArray(raw) {
|
|
|
1200
1200
|
}
|
|
1201
1201
|
}
|
|
1202
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
|
+
|
|
1203
1686
|
// src/commands/new.ts
|
|
1204
1687
|
function newNote(title, options) {
|
|
1205
1688
|
const vaultRoot = requireVaultRoot();
|
|
@@ -1211,17 +1694,19 @@ function newNote(title, options) {
|
|
|
1211
1694
|
if (options.source || options.status) {
|
|
1212
1695
|
if (options.source) validateSource(options.source);
|
|
1213
1696
|
if (options.status) validateStatus(options.status);
|
|
1214
|
-
const raw =
|
|
1697
|
+
const raw = fs10.readFileSync(note.filepath, "utf-8");
|
|
1215
1698
|
const { frontmatter, body } = parseFrontmatter(raw);
|
|
1216
1699
|
if (options.source) frontmatter.source = options.source;
|
|
1217
1700
|
if (options.status) frontmatter.status = options.status;
|
|
1218
|
-
|
|
1701
|
+
fs10.writeFileSync(note.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
|
|
1219
1702
|
note.frontmatter = frontmatter;
|
|
1220
1703
|
}
|
|
1221
1704
|
const recommendationStrategy = typeConfig?.slug_format === "date" ? "incremental" : "rebuild";
|
|
1222
1705
|
const recommendations = recommendNote(vaultRoot, config, note, { strategy: recommendationStrategy });
|
|
1223
1706
|
const lines = note.body.split("\n").length;
|
|
1224
1707
|
const overLimit = typeConfig && typeConfig.line_limit && lines > typeConfig.line_limit;
|
|
1708
|
+
const sync = getSyncManager(vaultRoot, config);
|
|
1709
|
+
sync?.trackAndPush(note, "create");
|
|
1225
1710
|
if (options.json) {
|
|
1226
1711
|
console.log(jsonSuccess({
|
|
1227
1712
|
slug: note.slug,
|
|
@@ -1259,7 +1744,7 @@ function newNote(title, options) {
|
|
|
1259
1744
|
}
|
|
1260
1745
|
|
|
1261
1746
|
// src/commands/add.ts
|
|
1262
|
-
import
|
|
1747
|
+
import fs11 from "fs";
|
|
1263
1748
|
function addNote(text, options = {}) {
|
|
1264
1749
|
const vaultRoot = requireVaultRoot();
|
|
1265
1750
|
const config = loadConfig(vaultRoot);
|
|
@@ -1268,9 +1753,9 @@ function addNote(text, options = {}) {
|
|
|
1268
1753
|
if (text) {
|
|
1269
1754
|
content = text;
|
|
1270
1755
|
} else if (!process.stdin.isTTY) {
|
|
1271
|
-
content =
|
|
1756
|
+
content = fs11.readFileSync(0, "utf-8").trim();
|
|
1272
1757
|
} else {
|
|
1273
|
-
console.error('Usage:
|
|
1758
|
+
console.error('Usage: granite add "some text" or echo "text" | granite add');
|
|
1274
1759
|
process.exit(1);
|
|
1275
1760
|
}
|
|
1276
1761
|
if (!content) {
|
|
@@ -1281,6 +1766,8 @@ function addNote(text, options = {}) {
|
|
|
1281
1766
|
const title = firstLine.length > 60 ? firstLine.slice(0, 60).trim() + "..." : firstLine;
|
|
1282
1767
|
const note = createNote(vaultRoot, config, typeName, title, content + "\n");
|
|
1283
1768
|
const recommendations = recommendNote(vaultRoot, config, note, { strategy: "incremental" });
|
|
1769
|
+
const sync = getSyncManager(vaultRoot, config);
|
|
1770
|
+
sync?.trackAndPush(note, "create");
|
|
1284
1771
|
if (options.json) {
|
|
1285
1772
|
console.log(jsonSuccess({
|
|
1286
1773
|
slug: note.slug,
|
|
@@ -1317,7 +1804,7 @@ function openNote(slug) {
|
|
|
1317
1804
|
}
|
|
1318
1805
|
|
|
1319
1806
|
// src/commands/show.ts
|
|
1320
|
-
import
|
|
1807
|
+
import fs12 from "fs";
|
|
1321
1808
|
function showCommand(slug, options) {
|
|
1322
1809
|
const vaultRoot = requireVaultRoot();
|
|
1323
1810
|
const config = loadConfig(vaultRoot);
|
|
@@ -1353,7 +1840,7 @@ function showCommand(slug, options) {
|
|
|
1353
1840
|
console.log(`# ${note.frontmatter.title} (${note.slug})`);
|
|
1354
1841
|
console.log(`# type: ${note.frontmatter.type} | modified: ${note.frontmatter.modified.slice(0, 10)}`);
|
|
1355
1842
|
console.log("");
|
|
1356
|
-
const content =
|
|
1843
|
+
const content = fs12.readFileSync(note.filepath, "utf-8");
|
|
1357
1844
|
console.log(content);
|
|
1358
1845
|
}
|
|
1359
1846
|
|
|
@@ -1511,14 +1998,14 @@ function recommendCommand(slug, options) {
|
|
|
1511
1998
|
}
|
|
1512
1999
|
|
|
1513
2000
|
// src/core/doctor.ts
|
|
1514
|
-
import
|
|
1515
|
-
import
|
|
2001
|
+
import fs13 from "fs";
|
|
2002
|
+
import path10 from "path";
|
|
1516
2003
|
function runDoctor(vaultRoot, config, db) {
|
|
1517
2004
|
const issues = [];
|
|
1518
2005
|
const notes = listNotes(vaultRoot, config);
|
|
1519
2006
|
for (const [name, typeConfig] of Object.entries(config.note_types)) {
|
|
1520
|
-
const folder =
|
|
1521
|
-
if (!
|
|
2007
|
+
const folder = path10.join(vaultRoot, typeConfig.folder);
|
|
2008
|
+
if (!fs13.existsSync(folder)) {
|
|
1522
2009
|
issues.push({
|
|
1523
2010
|
level: "error",
|
|
1524
2011
|
file: `granite.yml`,
|
|
@@ -1653,8 +2140,8 @@ ${errors.length} error(s), ${warnings.length} warning(s), ${infos.length} info(s
|
|
|
1653
2140
|
// src/web/server.ts
|
|
1654
2141
|
import { Hono } from "hono";
|
|
1655
2142
|
import { serve } from "@hono/node-server";
|
|
1656
|
-
import
|
|
1657
|
-
import
|
|
2143
|
+
import path11 from "path";
|
|
2144
|
+
import fs14 from "fs";
|
|
1658
2145
|
function createApp(vaultRoot, config) {
|
|
1659
2146
|
const app = new Hono();
|
|
1660
2147
|
app.get("/api/notes", (c) => {
|
|
@@ -1723,6 +2210,8 @@ function createApp(vaultRoot, config) {
|
|
|
1723
2210
|
}
|
|
1724
2211
|
try {
|
|
1725
2212
|
const note = createNote(vaultRoot, config, type, title, noteBody || void 0);
|
|
2213
|
+
const sync = getSyncManager(vaultRoot, config);
|
|
2214
|
+
sync?.trackAndPush(note, "create");
|
|
1726
2215
|
return c.json({
|
|
1727
2216
|
slug: note.slug,
|
|
1728
2217
|
title: note.frontmatter.title,
|
|
@@ -1740,17 +2229,17 @@ function createApp(vaultRoot, config) {
|
|
|
1740
2229
|
db.close();
|
|
1741
2230
|
return c.json({ nodes, edges });
|
|
1742
2231
|
});
|
|
1743
|
-
const publicDir =
|
|
2232
|
+
const publicDir = path11.join(import.meta.dirname, "public");
|
|
1744
2233
|
app.get("/*", (c) => {
|
|
1745
2234
|
const reqPath = c.req.path === "/" ? "/index.html" : c.req.path;
|
|
1746
|
-
const filePath =
|
|
1747
|
-
if (!
|
|
1748
|
-
const indexPath =
|
|
1749
|
-
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");
|
|
1750
2239
|
return c.html(html);
|
|
1751
2240
|
}
|
|
1752
|
-
const content =
|
|
1753
|
-
const ext =
|
|
2241
|
+
const content = fs14.readFileSync(filePath);
|
|
2242
|
+
const ext = path11.extname(filePath);
|
|
1754
2243
|
const mimeTypes = {
|
|
1755
2244
|
".html": "text/html",
|
|
1756
2245
|
".css": "text/css",
|
|
@@ -1796,7 +2285,7 @@ function typesCommand() {
|
|
|
1796
2285
|
}
|
|
1797
2286
|
console.log(`Default type: ${config.defaults.note_type}`);
|
|
1798
2287
|
console.log(`
|
|
1799
|
-
Usage:
|
|
2288
|
+
Usage: granite new <type> <title>`);
|
|
1800
2289
|
}
|
|
1801
2290
|
|
|
1802
2291
|
// src/commands/list.ts
|
|
@@ -1886,7 +2375,7 @@ ${notes.length} note(s)`);
|
|
|
1886
2375
|
}
|
|
1887
2376
|
|
|
1888
2377
|
// src/commands/edit.ts
|
|
1889
|
-
import
|
|
2378
|
+
import fs15 from "fs";
|
|
1890
2379
|
import { execSync as execSync2 } from "child_process";
|
|
1891
2380
|
function editCommand(slug, options) {
|
|
1892
2381
|
const vaultRoot = requireVaultRoot();
|
|
@@ -1898,7 +2387,7 @@ function editCommand(slug, options) {
|
|
|
1898
2387
|
}
|
|
1899
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;
|
|
1900
2389
|
if (hasFlags) {
|
|
1901
|
-
let { frontmatter, body } = parseFrontmatter(
|
|
2390
|
+
let { frontmatter, body } = parseFrontmatter(fs15.readFileSync(note.filepath, "utf-8"));
|
|
1902
2391
|
if (options.title) {
|
|
1903
2392
|
frontmatter.title = options.title;
|
|
1904
2393
|
}
|
|
@@ -1929,24 +2418,30 @@ function editCommand(slug, options) {
|
|
|
1929
2418
|
body = body.trimEnd() + "\n" + options.append + "\n";
|
|
1930
2419
|
}
|
|
1931
2420
|
frontmatter.modified = (/* @__PURE__ */ new Date()).toISOString();
|
|
1932
|
-
|
|
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");
|
|
1933
2425
|
console.log(note.filepath);
|
|
1934
2426
|
const recommendationStrategy = options.title !== void 0 || options.alias !== void 0 ? "rebuild" : "incremental";
|
|
1935
2427
|
printRecommendations(vaultRoot, config, note.filepath, recommendationStrategy);
|
|
1936
2428
|
} else {
|
|
1937
2429
|
const editor = process.env.EDITOR || "vi";
|
|
1938
|
-
const statBefore =
|
|
2430
|
+
const statBefore = fs15.statSync(note.filepath).mtimeMs;
|
|
1939
2431
|
try {
|
|
1940
2432
|
execSync2(`${editor} "${note.filepath}"`, { stdio: "inherit" });
|
|
1941
2433
|
} catch {
|
|
1942
2434
|
console.error(`Failed to open editor: ${editor}`);
|
|
1943
2435
|
process.exit(1);
|
|
1944
2436
|
}
|
|
1945
|
-
const statAfter =
|
|
2437
|
+
const statAfter = fs15.statSync(note.filepath).mtimeMs;
|
|
1946
2438
|
if (statAfter !== statBefore) {
|
|
1947
|
-
const { frontmatter, body } = parseFrontmatter(
|
|
2439
|
+
const { frontmatter, body } = parseFrontmatter(fs15.readFileSync(note.filepath, "utf-8"));
|
|
1948
2440
|
frontmatter.modified = (/* @__PURE__ */ new Date()).toISOString();
|
|
1949
|
-
|
|
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");
|
|
1950
2445
|
console.log(`Updated: ${note.filepath}`);
|
|
1951
2446
|
printRecommendations(vaultRoot, config, note.filepath, "rebuild");
|
|
1952
2447
|
}
|
|
@@ -1965,7 +2460,7 @@ function printRecommendations(vaultRoot, config, filepath, strategy) {
|
|
|
1965
2460
|
}
|
|
1966
2461
|
|
|
1967
2462
|
// src/commands/mcp.ts
|
|
1968
|
-
import
|
|
2463
|
+
import path13 from "path";
|
|
1969
2464
|
|
|
1970
2465
|
// src/mcp/server.ts
|
|
1971
2466
|
import { serve as serve2 } from "@hono/node-server";
|
|
@@ -2280,7 +2775,7 @@ function registerTools(server, runtime) {
|
|
|
2280
2775
|
});
|
|
2281
2776
|
server.registerTool("granite_capture_note", {
|
|
2282
2777
|
title: "Capture Granite Note",
|
|
2283
|
-
description: "Quick-capture a note from free-form text, similar to
|
|
2778
|
+
description: "Quick-capture a note from free-form text, similar to granite add.",
|
|
2284
2779
|
inputSchema: {
|
|
2285
2780
|
text: z.string().describe("Raw capture text."),
|
|
2286
2781
|
type: z.string().optional().describe("Optional note type override. Defaults to the vault default type."),
|
|
@@ -2663,8 +3158,8 @@ function corsHeaders(origin, allowedOrigins) {
|
|
|
2663
3158
|
}
|
|
2664
3159
|
|
|
2665
3160
|
// src/mcp/runtime.ts
|
|
2666
|
-
import
|
|
2667
|
-
import
|
|
3161
|
+
import fs16 from "fs";
|
|
3162
|
+
import path12 from "path";
|
|
2668
3163
|
var GraniteMcpRuntime = class {
|
|
2669
3164
|
vaultRoot;
|
|
2670
3165
|
config;
|
|
@@ -2673,7 +3168,7 @@ var GraniteMcpRuntime = class {
|
|
|
2673
3168
|
lastSignature;
|
|
2674
3169
|
lastIndexCheckAt = 0;
|
|
2675
3170
|
constructor(vaultRoot, options = {}) {
|
|
2676
|
-
this.vaultRoot =
|
|
3171
|
+
this.vaultRoot = path12.resolve(vaultRoot);
|
|
2677
3172
|
this.config = loadConfig(this.vaultRoot);
|
|
2678
3173
|
this.db = openDatabase(this.vaultRoot);
|
|
2679
3174
|
this.indexCheckIntervalMs = options.indexCheckIntervalMs ?? 1500;
|
|
@@ -2826,7 +3321,7 @@ var GraniteMcpRuntime = class {
|
|
|
2826
3321
|
};
|
|
2827
3322
|
}
|
|
2828
3323
|
readVaultConfigRaw() {
|
|
2829
|
-
return
|
|
3324
|
+
return fs16.readFileSync(path12.join(this.vaultRoot, CONFIG_FILENAME), "utf-8");
|
|
2830
3325
|
}
|
|
2831
3326
|
readVaultTypesJson() {
|
|
2832
3327
|
return JSON.stringify({
|
|
@@ -2839,7 +3334,7 @@ var GraniteMcpRuntime = class {
|
|
|
2839
3334
|
}
|
|
2840
3335
|
readNoteMarkdown(slug) {
|
|
2841
3336
|
const note = this.requireNote(slug);
|
|
2842
|
-
return
|
|
3337
|
+
return fs16.readFileSync(note.filepath, "utf-8");
|
|
2843
3338
|
}
|
|
2844
3339
|
completeSlugs(prefix = "") {
|
|
2845
3340
|
const normalizedPrefix = prefix.toLowerCase();
|
|
@@ -2900,7 +3395,7 @@ var GraniteMcpRuntime = class {
|
|
|
2900
3395
|
};
|
|
2901
3396
|
}
|
|
2902
3397
|
applyMutations(filepath, input) {
|
|
2903
|
-
const raw =
|
|
3398
|
+
const raw = fs16.readFileSync(filepath, "utf-8");
|
|
2904
3399
|
const { frontmatter, body: existingBody } = parseFrontmatter(raw);
|
|
2905
3400
|
let body = existingBody;
|
|
2906
3401
|
if (input.title !== void 0) {
|
|
@@ -2929,7 +3424,7 @@ ${input.append}
|
|
|
2929
3424
|
`;
|
|
2930
3425
|
}
|
|
2931
3426
|
frontmatter.modified = (/* @__PURE__ */ new Date()).toISOString();
|
|
2932
|
-
|
|
3427
|
+
fs16.writeFileSync(filepath, serializeFrontmatter(frontmatter, body), "utf-8");
|
|
2933
3428
|
}
|
|
2934
3429
|
afterWrite(slug, fullRebuild) {
|
|
2935
3430
|
if (fullRebuild) {
|
|
@@ -2954,16 +3449,16 @@ ${input.append}
|
|
|
2954
3449
|
let noteCount = 0;
|
|
2955
3450
|
let latestNoteMtimeMs = 0;
|
|
2956
3451
|
for (const typeConfig of Object.values(this.config.note_types)) {
|
|
2957
|
-
const folder =
|
|
2958
|
-
if (!
|
|
3452
|
+
const folder = path12.join(this.vaultRoot, typeConfig.folder);
|
|
3453
|
+
if (!fs16.existsSync(folder)) {
|
|
2959
3454
|
continue;
|
|
2960
3455
|
}
|
|
2961
|
-
for (const entry of
|
|
3456
|
+
for (const entry of fs16.readdirSync(folder)) {
|
|
2962
3457
|
if (!entry.endsWith(".md")) {
|
|
2963
3458
|
continue;
|
|
2964
3459
|
}
|
|
2965
3460
|
noteCount += 1;
|
|
2966
|
-
const stat =
|
|
3461
|
+
const stat = fs16.statSync(path12.join(folder, entry));
|
|
2967
3462
|
latestNoteMtimeMs = Math.max(latestNoteMtimeMs, stat.mtimeMs);
|
|
2968
3463
|
}
|
|
2969
3464
|
}
|
|
@@ -2975,8 +3470,8 @@ ${input.append}
|
|
|
2975
3470
|
};
|
|
2976
3471
|
}
|
|
2977
3472
|
getConfigMtimeMs() {
|
|
2978
|
-
const configPath =
|
|
2979
|
-
return
|
|
3473
|
+
const configPath = path12.join(this.vaultRoot, CONFIG_FILENAME);
|
|
3474
|
+
return fs16.existsSync(configPath) ? fs16.statSync(configPath).mtimeMs : 0;
|
|
2980
3475
|
}
|
|
2981
3476
|
getLastRebuildMs() {
|
|
2982
3477
|
const value = this.getMetaValue("last_rebuild");
|
|
@@ -3038,10 +3533,10 @@ async function mcpCommand(options) {
|
|
|
3038
3533
|
function resolveVaultRoot(explicitVault) {
|
|
3039
3534
|
const fromEnv = process.env.GRANITE_VAULT;
|
|
3040
3535
|
if (explicitVault) {
|
|
3041
|
-
return
|
|
3536
|
+
return path13.resolve(explicitVault);
|
|
3042
3537
|
}
|
|
3043
3538
|
if (fromEnv) {
|
|
3044
|
-
return
|
|
3539
|
+
return path13.resolve(fromEnv);
|
|
3045
3540
|
}
|
|
3046
3541
|
return requireVaultRoot();
|
|
3047
3542
|
}
|
|
@@ -3064,9 +3559,85 @@ function parsePort(value) {
|
|
|
3064
3559
|
return parsed;
|
|
3065
3560
|
}
|
|
3066
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
|
+
|
|
3067
3638
|
// src/index.ts
|
|
3068
3639
|
var program = new Command();
|
|
3069
|
-
program.name("
|
|
3640
|
+
program.name("granite").description("Granite \u2014 a local-first markdown memory system").version(GRANITE_VERSION);
|
|
3070
3641
|
program.command("init").description("Initialize the default vault in ~/.granite").action(() => {
|
|
3071
3642
|
initVault();
|
|
3072
3643
|
});
|
|
@@ -3112,6 +3683,18 @@ program.command("serve").description("Start the local web UI").option("-p, --por
|
|
|
3112
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) => {
|
|
3113
3684
|
await mcpCommand(options);
|
|
3114
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
|
+
});
|
|
3115
3698
|
await program.parseAsync();
|
|
3116
3699
|
function collectValues(value, previous) {
|
|
3117
3700
|
return [...previous, value];
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "granite-mem",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "A local-first markdown memory system for humans and agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"
|
|
7
|
+
"granite": "./dist/index.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"dist"
|