granite-mem 0.1.6 → 0.1.8
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 +34 -14
- package/dist/index.js +1002 -910
- package/dist/public/app.js +252 -300
- package/dist/public/graph.js +11 -14
- package/dist/public/index.html +115 -102
- package/dist/public/markdown-renderer.js +49 -51
- package/dist/public/style.css +595 -558
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -16,85 +16,53 @@ var DEFAULT_CONFIG = {
|
|
|
16
16
|
vault_name: "My Vault",
|
|
17
17
|
version: 1,
|
|
18
18
|
note_types: {
|
|
19
|
-
|
|
20
|
-
folder: "notes/
|
|
21
|
-
description: "Quick captures, inbox items",
|
|
22
|
-
template: "",
|
|
23
|
-
line_limit: 50,
|
|
24
|
-
warn_only: true,
|
|
25
|
-
slug_format: "date",
|
|
26
|
-
instructions: "Write a short thought, observation, or idea. Keep it raw \u2014 refine later into a permanent note."
|
|
27
|
-
},
|
|
28
|
-
permanent: {
|
|
29
|
-
folder: "notes/permanent",
|
|
19
|
+
note: {
|
|
20
|
+
folder: "notes/notes",
|
|
30
21
|
description: "Refined, linked notes \u2014 one idea per note",
|
|
31
22
|
template: "## Summary\n\n## Details\n\n## Links\n",
|
|
32
23
|
line_limit: 200,
|
|
33
24
|
warn_only: false,
|
|
34
|
-
instructions: "One atomic idea per note. Write a clear summary, then expand in details. Link to related notes with [[wikilinks]]. If the note grows too long, split it."
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
description: "Notes on external sources (articles, books, talks)",
|
|
39
|
-
template: "## Source\n\nURL or citation here.\n\n## Date\n\n## Key Points\n\n- \n\n## My Take\n\n",
|
|
40
|
-
line_limit: 300,
|
|
41
|
-
warn_only: true,
|
|
42
|
-
instructions: "Capture the source, the key points in your own words, and your reaction. Link to permanent notes that relate."
|
|
25
|
+
instructions: "One atomic idea per note. Write a clear summary, then expand in details. Link to related notes with [[wikilinks]]. If the note grows too long, split it.",
|
|
26
|
+
frontmatter_defaults: {
|
|
27
|
+
durability: "canonical"
|
|
28
|
+
}
|
|
43
29
|
},
|
|
44
|
-
|
|
45
|
-
folder: "notes/
|
|
46
|
-
description: "
|
|
47
|
-
template: "##
|
|
48
|
-
line_limit:
|
|
30
|
+
source: {
|
|
31
|
+
folder: "notes/sources",
|
|
32
|
+
description: "Imported or observed source material kept close to the original",
|
|
33
|
+
template: "## Summary\n\n## Key Facts\n\n- \n\n## Raw Content\n\n## Links\n",
|
|
34
|
+
line_limit: 400,
|
|
49
35
|
warn_only: true,
|
|
50
|
-
instructions: "
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
org: { type: "text", description: "Organization or company" },
|
|
54
|
-
contact: { type: "text", description: "Email, Slack, phone" }
|
|
36
|
+
instructions: "Keep the source close to the original. Capture provenance in frontmatter, summarize the essentials, and link to durable notes or syntheses.",
|
|
37
|
+
frontmatter_defaults: {
|
|
38
|
+
durability: "canonical"
|
|
55
39
|
}
|
|
56
40
|
},
|
|
57
|
-
|
|
58
|
-
folder: "notes/
|
|
59
|
-
description: "
|
|
60
|
-
template: "##
|
|
41
|
+
synthesis: {
|
|
42
|
+
folder: "notes/syntheses",
|
|
43
|
+
description: "Compiled knowledge that connects multiple notes or sources",
|
|
44
|
+
template: "## Scope\n\n## Executive Summary\n\n## Main Themes\n\n## Open Questions\n\n## Links\n",
|
|
61
45
|
line_limit: 300,
|
|
62
46
|
warn_only: true,
|
|
63
|
-
instructions: "
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
date: { type: "date", description: "Meeting date" },
|
|
67
|
-
project: { type: "wikilink", description: "Related project" }
|
|
47
|
+
instructions: "Use syntheses for durable compiled knowledge. Keep the scope explicit, summarize clearly, and point back to the notes or sources in derived_from.",
|
|
48
|
+
frontmatter_defaults: {
|
|
49
|
+
durability: "canonical"
|
|
68
50
|
}
|
|
69
51
|
},
|
|
70
|
-
|
|
71
|
-
folder: "notes/
|
|
72
|
-
description: "
|
|
73
|
-
template: "## Goal\n\n##
|
|
52
|
+
output: {
|
|
53
|
+
folder: "notes/outputs",
|
|
54
|
+
description: "Audience-specific outputs such as briefs, reports, or slides",
|
|
55
|
+
template: "## Goal\n\n## Audience\n\n## Output\n\n## References\n",
|
|
74
56
|
line_limit: 300,
|
|
75
57
|
warn_only: true,
|
|
76
|
-
instructions: "
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
project_status: { type: "enum", options: ["planning", "active", "paused", "completed"], default: "active", description: "Project lifecycle" }
|
|
80
|
-
}
|
|
81
|
-
},
|
|
82
|
-
decision: {
|
|
83
|
-
folder: "notes/decisions",
|
|
84
|
-
description: "Record of a decision \u2014 context, options, outcome",
|
|
85
|
-
template: "## Context\n\nWhat prompted this decision.\n\n## Options Considered\n\n1. \n2. \n\n## Decision\n\n\n\n## Rationale\n\n## Status\n\nActive\n",
|
|
86
|
-
line_limit: 200,
|
|
87
|
-
warn_only: false,
|
|
88
|
-
instructions: "Document the context, what options were considered, what was decided, and why. This is a permanent record \u2014 future-you will thank you. Link to the project or topic.",
|
|
89
|
-
fields: {
|
|
90
|
-
context_for: { type: "wikilink", description: "Project or area this decision belongs to" },
|
|
91
|
-
decision_status: { type: "enum", options: ["active", "superseded", "revisit"], default: "active", description: "Decision lifecycle" },
|
|
92
|
-
superseded_by: { type: "wikilink", description: "Replacement decision if superseded" }
|
|
58
|
+
instructions: "Outputs are situational deliverables. Keep them readable, point back to the durable knowledge they derive from, and make them easy to regenerate or archive.",
|
|
59
|
+
frontmatter_defaults: {
|
|
60
|
+
durability: "ephemeral"
|
|
93
61
|
}
|
|
94
62
|
}
|
|
95
63
|
},
|
|
96
64
|
defaults: {
|
|
97
|
-
note_type: "
|
|
65
|
+
note_type: "note",
|
|
98
66
|
editor: "$EDITOR"
|
|
99
67
|
},
|
|
100
68
|
index: {
|
|
@@ -147,6 +115,9 @@ function requireVaultRoot(from) {
|
|
|
147
115
|
}
|
|
148
116
|
return root;
|
|
149
117
|
}
|
|
118
|
+
function getGraniteDir(vaultRoot) {
|
|
119
|
+
return path2.join(vaultRoot, ".granite");
|
|
120
|
+
}
|
|
150
121
|
function getIndexDbPath(vaultRoot) {
|
|
151
122
|
if (path2.basename(path2.resolve(vaultRoot)) === DEFAULT_VAULT_DIRNAME) {
|
|
152
123
|
return path2.join(vaultRoot, "index.db");
|
|
@@ -180,11 +151,18 @@ function initVault(dir = getDefaultVaultRoot()) {
|
|
|
180
151
|
console.log(` ${typeConfig.folder}/ (${name})`);
|
|
181
152
|
}
|
|
182
153
|
console.log("");
|
|
183
|
-
console.log(
|
|
154
|
+
console.log("The knowledge loop: capture \u2192 compile \u2192 query \u2192 output \u2192 lint");
|
|
155
|
+
console.log("");
|
|
156
|
+
console.log(" source \u2192 note \u2192 synthesis \u2192 output");
|
|
157
|
+
console.log("");
|
|
158
|
+
console.log("Get started:");
|
|
159
|
+
console.log(' granite add "Your first thought" # Capture something');
|
|
160
|
+
console.log(' granite new "Topic" --type source # Import source material');
|
|
161
|
+
console.log(" granite status # See what to do next");
|
|
184
162
|
}
|
|
185
163
|
|
|
186
164
|
// src/commands/new.ts
|
|
187
|
-
import
|
|
165
|
+
import fs6 from "fs";
|
|
188
166
|
|
|
189
167
|
// src/core/note.ts
|
|
190
168
|
import fs4 from "fs";
|
|
@@ -198,6 +176,55 @@ function slugify(title) {
|
|
|
198
176
|
|
|
199
177
|
// src/core/frontmatter.ts
|
|
200
178
|
import matter from "gray-matter";
|
|
179
|
+
|
|
180
|
+
// src/core/json-output.ts
|
|
181
|
+
function jsonSuccess(data) {
|
|
182
|
+
return JSON.stringify({ success: true, data }, null, 2);
|
|
183
|
+
}
|
|
184
|
+
function jsonError(error) {
|
|
185
|
+
return JSON.stringify({ success: false, error }, null, 2);
|
|
186
|
+
}
|
|
187
|
+
var VALID_STATUSES = ["inbox", "active", "archived"];
|
|
188
|
+
var VALID_SOURCES = ["human", "agent", "extraction"];
|
|
189
|
+
var VALID_REVIEW_STATES = ["draft", "reviewed", "locked"];
|
|
190
|
+
var VALID_DURABILITY = ["canonical", "working", "ephemeral"];
|
|
191
|
+
function validateStatus(value) {
|
|
192
|
+
if (!VALID_STATUSES.includes(value)) {
|
|
193
|
+
throw new Error(`Invalid status: "${value}". Expected one of: ${VALID_STATUSES.join(", ")}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function validateSource(value) {
|
|
197
|
+
if (!VALID_SOURCES.includes(value)) {
|
|
198
|
+
throw new Error(`Invalid source: "${value}". Expected one of: ${VALID_SOURCES.join(", ")}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function validateReviewState(value) {
|
|
202
|
+
if (!VALID_REVIEW_STATES.includes(value)) {
|
|
203
|
+
throw new Error(`Invalid review_state: "${value}". Expected one of: ${VALID_REVIEW_STATES.join(", ")}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function validateDurability(value) {
|
|
207
|
+
if (!VALID_DURABILITY.includes(value)) {
|
|
208
|
+
throw new Error(`Invalid durability: "${value}". Expected one of: ${VALID_DURABILITY.join(", ")}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function normalizeStatus(value, fallback = "active") {
|
|
212
|
+
return typeof value === "string" && VALID_STATUSES.includes(value) ? value : fallback;
|
|
213
|
+
}
|
|
214
|
+
function normalizeSource(value, fallback = "human") {
|
|
215
|
+
return typeof value === "string" && VALID_SOURCES.includes(value) ? value : fallback;
|
|
216
|
+
}
|
|
217
|
+
function normalizeReviewState(value, fallback = "draft") {
|
|
218
|
+
return typeof value === "string" && VALID_REVIEW_STATES.includes(value) ? value : fallback;
|
|
219
|
+
}
|
|
220
|
+
function normalizeDurability(value, fallback = "working") {
|
|
221
|
+
return typeof value === "string" && VALID_DURABILITY.includes(value) ? value : fallback;
|
|
222
|
+
}
|
|
223
|
+
function normalizeStringArray(value) {
|
|
224
|
+
return Array.isArray(value) ? value.map((item) => String(item)) : [];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// src/core/frontmatter.ts
|
|
201
228
|
function parseFrontmatter(content) {
|
|
202
229
|
const { data, content: body } = matter(content);
|
|
203
230
|
const fm = {
|
|
@@ -206,10 +233,13 @@ function parseFrontmatter(content) {
|
|
|
206
233
|
type: toFrontmatterString(data.type),
|
|
207
234
|
created: toFrontmatterString(data.created),
|
|
208
235
|
modified: toFrontmatterString(data.modified),
|
|
209
|
-
tags: data.tags
|
|
210
|
-
aliases: data.aliases
|
|
211
|
-
status: data.status
|
|
212
|
-
source: data.source
|
|
236
|
+
tags: normalizeStringArray(data.tags),
|
|
237
|
+
aliases: normalizeStringArray(data.aliases),
|
|
238
|
+
status: normalizeStatus(data.status),
|
|
239
|
+
source: normalizeSource(data.source),
|
|
240
|
+
review_state: normalizeReviewState(data.review_state),
|
|
241
|
+
durability: normalizeDurability(data.durability),
|
|
242
|
+
derived_from: normalizeStringArray(data.derived_from)
|
|
213
243
|
};
|
|
214
244
|
for (const [key, value] of Object.entries(data)) {
|
|
215
245
|
if (!(key in fm)) {
|
|
@@ -255,6 +285,7 @@ function createNote(vaultRoot, config, typeName, title, bodyOverride) {
|
|
|
255
285
|
}
|
|
256
286
|
}
|
|
257
287
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
288
|
+
const defaults = typeConfig.frontmatter_defaults ?? {};
|
|
258
289
|
const frontmatter = {
|
|
259
290
|
id: uuidv4(),
|
|
260
291
|
title,
|
|
@@ -263,8 +294,11 @@ function createNote(vaultRoot, config, typeName, title, bodyOverride) {
|
|
|
263
294
|
modified: now,
|
|
264
295
|
tags: [],
|
|
265
296
|
aliases: [],
|
|
266
|
-
status:
|
|
267
|
-
source:
|
|
297
|
+
status: normalizeStatus(defaults.status),
|
|
298
|
+
source: normalizeSource(defaults.source),
|
|
299
|
+
review_state: normalizeReviewState(defaults.review_state),
|
|
300
|
+
durability: normalizeDurability(defaults.durability),
|
|
301
|
+
derived_from: normalizeStringArray(defaults.derived_from)
|
|
268
302
|
};
|
|
269
303
|
const body = bodyOverride ?? typeConfig.template;
|
|
270
304
|
const content = serializeFrontmatter(frontmatter, body);
|
|
@@ -308,26 +342,6 @@ function findNoteBySlug(vaultRoot, config, slug) {
|
|
|
308
342
|
return null;
|
|
309
343
|
}
|
|
310
344
|
|
|
311
|
-
// src/core/json-output.ts
|
|
312
|
-
function jsonSuccess(data) {
|
|
313
|
-
return JSON.stringify({ success: true, data }, null, 2);
|
|
314
|
-
}
|
|
315
|
-
function jsonError(error) {
|
|
316
|
-
return JSON.stringify({ success: false, error }, null, 2);
|
|
317
|
-
}
|
|
318
|
-
var VALID_STATUSES = ["inbox", "active", "archived"];
|
|
319
|
-
var VALID_SOURCES = ["human", "agent", "extraction"];
|
|
320
|
-
function validateStatus(value) {
|
|
321
|
-
if (!VALID_STATUSES.includes(value)) {
|
|
322
|
-
throw new Error(`Invalid status: "${value}". Expected one of: ${VALID_STATUSES.join(", ")}`);
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
function validateSource(value) {
|
|
326
|
-
if (!VALID_SOURCES.includes(value)) {
|
|
327
|
-
throw new Error(`Invalid source: "${value}". Expected one of: ${VALID_SOURCES.join(", ")}`);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
345
|
// src/core/index-db.ts
|
|
332
346
|
import Database from "better-sqlite3";
|
|
333
347
|
import fs5 from "fs";
|
|
@@ -748,8 +762,7 @@ var STOP_WORDS = /* @__PURE__ */ new Set([
|
|
|
748
762
|
"meeting",
|
|
749
763
|
"person",
|
|
750
764
|
"reference",
|
|
751
|
-
"
|
|
752
|
-
"fleeting",
|
|
765
|
+
"note",
|
|
753
766
|
"how",
|
|
754
767
|
"know",
|
|
755
768
|
"work",
|
|
@@ -924,50 +937,31 @@ function getTagRecommendations(note, nearbyNotes) {
|
|
|
924
937
|
})).sort((a, b) => b.weight - a.weight || a.tag.localeCompare(b.tag)).slice(0, MAX_TAG_RECOMMENDATIONS);
|
|
925
938
|
}
|
|
926
939
|
function getNextSteps(note, nearbyNotes) {
|
|
927
|
-
const
|
|
928
|
-
const personLink = nearbyNotes.find((link) => link.type === "person");
|
|
940
|
+
const canonicalLink = nearbyNotes.find((link) => link.type === "note" || link.type === "synthesis");
|
|
929
941
|
switch (note.frontmatter.type) {
|
|
930
|
-
case "
|
|
931
|
-
return [{
|
|
932
|
-
type: "permanent",
|
|
933
|
-
title_hint: note.frontmatter.title,
|
|
934
|
-
reason: "Promote this into a durable note if it keeps mattering."
|
|
935
|
-
}];
|
|
936
|
-
case "reference":
|
|
937
|
-
return [{
|
|
938
|
-
type: "permanent",
|
|
939
|
-
title_hint: `Idea from ${note.frontmatter.title}`,
|
|
940
|
-
reason: "Distill one durable idea from this source."
|
|
941
|
-
}];
|
|
942
|
-
case "person":
|
|
943
|
-
return [{
|
|
944
|
-
type: projectLink ? "meeting" : "project",
|
|
945
|
-
title_hint: projectLink ? `${note.frontmatter.title} + ${projectLink.title}` : `${note.frontmatter.title} collaboration`,
|
|
946
|
-
reason: projectLink ? "Capture the latest interaction connected to this person." : "Anchor this person to a company, project, or collaboration note."
|
|
947
|
-
}];
|
|
948
|
-
case "meeting":
|
|
942
|
+
case "source":
|
|
949
943
|
return [{
|
|
950
|
-
type: "
|
|
951
|
-
title_hint:
|
|
952
|
-
reason: "
|
|
944
|
+
type: "synthesis",
|
|
945
|
+
title_hint: `${note.frontmatter.title} synthesis`,
|
|
946
|
+
reason: "Compile the main ideas from this source into a durable synthesis."
|
|
953
947
|
}];
|
|
954
|
-
case "
|
|
948
|
+
case "note":
|
|
955
949
|
return [{
|
|
956
|
-
type:
|
|
957
|
-
title_hint:
|
|
958
|
-
reason:
|
|
950
|
+
type: canonicalLink ? "synthesis" : "source",
|
|
951
|
+
title_hint: canonicalLink ? `${note.frontmatter.title} synthesis` : `${note.frontmatter.title} source`,
|
|
952
|
+
reason: canonicalLink ? "Compile this durable idea with adjacent notes into a reusable synthesis." : "Attach this durable idea to an explicit source or source note."
|
|
959
953
|
}];
|
|
960
|
-
case "
|
|
954
|
+
case "synthesis":
|
|
961
955
|
return [{
|
|
962
|
-
type: "
|
|
963
|
-
title_hint: `${note.frontmatter.title}
|
|
964
|
-
reason: "
|
|
956
|
+
type: "output",
|
|
957
|
+
title_hint: `${note.frontmatter.title} brief`,
|
|
958
|
+
reason: "Turn this durable synthesis into a situational output for a specific audience."
|
|
965
959
|
}];
|
|
966
|
-
case "
|
|
960
|
+
case "output":
|
|
967
961
|
return [{
|
|
968
|
-
type:
|
|
969
|
-
title_hint:
|
|
970
|
-
reason:
|
|
962
|
+
type: canonicalLink ? canonicalLink.type : "synthesis",
|
|
963
|
+
title_hint: canonicalLink ? canonicalLink.title : `${note.frontmatter.title} synthesis`,
|
|
964
|
+
reason: canonicalLink ? "Link this output back to the durable knowledge it should depend on." : "Promote durable insights from this output into a reusable synthesis."
|
|
971
965
|
}];
|
|
972
966
|
default:
|
|
973
967
|
return [];
|
|
@@ -979,72 +973,45 @@ function getAdditionRecommendations(note, config) {
|
|
|
979
973
|
const existingLinks = parseWikilinks(body);
|
|
980
974
|
const templateBody = config?.note_types[note.frontmatter.type]?.template ?? "";
|
|
981
975
|
switch (note.frontmatter.type) {
|
|
982
|
-
case "
|
|
983
|
-
if (isSectionSparse(body, "
|
|
984
|
-
recommendations.push({ text: "
|
|
976
|
+
case "source":
|
|
977
|
+
if (isSectionSparse(body, "Summary", templateBody)) {
|
|
978
|
+
recommendations.push({ text: "Write a short summary so this source is understandable without rereading the whole document." });
|
|
985
979
|
}
|
|
986
|
-
if (
|
|
987
|
-
recommendations.push({ text: "
|
|
980
|
+
if (!hasMeaningfulBullets(body, "Key Facts")) {
|
|
981
|
+
recommendations.push({ text: "Capture 2-3 concrete facts worth preserving from the source." });
|
|
988
982
|
}
|
|
989
983
|
if (existingLinks.length === 0) {
|
|
990
|
-
recommendations.push({ text: "Link one
|
|
984
|
+
recommendations.push({ text: "Link one note or synthesis that this source supports." });
|
|
991
985
|
}
|
|
992
986
|
break;
|
|
993
|
-
case "
|
|
994
|
-
if (
|
|
995
|
-
recommendations.push({ text: "
|
|
996
|
-
}
|
|
997
|
-
if (!hasMeaningfulBullets(body, "Key Points")) {
|
|
998
|
-
recommendations.push({ text: "Write 2-3 key points in your own words." });
|
|
987
|
+
case "note":
|
|
988
|
+
if (isSectionSparse(body, "Summary", templateBody)) {
|
|
989
|
+
recommendations.push({ text: "Write a one-line summary before expanding the idea." });
|
|
999
990
|
}
|
|
1000
991
|
if (existingLinks.length === 0) {
|
|
1001
|
-
recommendations.push({ text: "Link one
|
|
1002
|
-
}
|
|
1003
|
-
break;
|
|
1004
|
-
case "project":
|
|
1005
|
-
if (isSectionSparse(body, "Goal", templateBody)) {
|
|
1006
|
-
recommendations.push({ text: "Add a one-line goal so the project has a clear anchor." });
|
|
1007
|
-
}
|
|
1008
|
-
if (isSectionSparse(body, "People", templateBody)) {
|
|
1009
|
-
recommendations.push({ text: "Link the people involved in the project." });
|
|
1010
|
-
}
|
|
1011
|
-
if (!hasMeaningfulBullets(body, "Key Decisions")) {
|
|
1012
|
-
recommendations.push({ text: "Record one key decision or current status update." });
|
|
992
|
+
recommendations.push({ text: "Link one source, synthesis, or adjacent durable idea." });
|
|
1013
993
|
}
|
|
1014
994
|
break;
|
|
1015
|
-
case "
|
|
1016
|
-
if (
|
|
1017
|
-
recommendations.push({ text: "
|
|
1018
|
-
}
|
|
1019
|
-
if (!hasMeaningfulBullets(body, "Actions")) {
|
|
1020
|
-
recommendations.push({ text: "Capture at least one next action." });
|
|
1021
|
-
}
|
|
1022
|
-
if (!hasMeaningfulBullets(body, "Decisions")) {
|
|
1023
|
-
recommendations.push({ text: "Capture one decision or open question." });
|
|
995
|
+
case "synthesis":
|
|
996
|
+
if (isSectionSparse(body, "Scope", templateBody)) {
|
|
997
|
+
recommendations.push({ text: "Define the scope so this synthesis has a clear boundary." });
|
|
1024
998
|
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
if (isSectionSparse(body, "Summary", templateBody)) {
|
|
1028
|
-
recommendations.push({ text: "Write a one-line summary before expanding the idea." });
|
|
999
|
+
if (isSectionSparse(body, "Executive Summary", templateBody)) {
|
|
1000
|
+
recommendations.push({ text: "Write a short executive summary before expanding the synthesis." });
|
|
1029
1001
|
}
|
|
1030
1002
|
if (existingLinks.length === 0) {
|
|
1031
|
-
recommendations.push({ text: "Link
|
|
1003
|
+
recommendations.push({ text: "Link the notes or sources this synthesis pulls together." });
|
|
1032
1004
|
}
|
|
1033
1005
|
break;
|
|
1034
|
-
case "
|
|
1035
|
-
if (isSectionSparse(body, "
|
|
1036
|
-
recommendations.push({ text: "
|
|
1006
|
+
case "output":
|
|
1007
|
+
if (isSectionSparse(body, "Goal", templateBody)) {
|
|
1008
|
+
recommendations.push({ text: "State the goal so the output is grounded in a concrete need." });
|
|
1037
1009
|
}
|
|
1038
|
-
if (isSectionSparse(body, "
|
|
1039
|
-
recommendations.push({ text: "
|
|
1010
|
+
if (isSectionSparse(body, "Audience", templateBody)) {
|
|
1011
|
+
recommendations.push({ text: "Name the audience so the tone and content stay focused." });
|
|
1040
1012
|
}
|
|
1041
1013
|
if (existingLinks.length === 0) {
|
|
1042
|
-
recommendations.push({ text: "Link the
|
|
1043
|
-
}
|
|
1044
|
-
break;
|
|
1045
|
-
case "fleeting":
|
|
1046
|
-
if (tokenize(body).length < 8) {
|
|
1047
|
-
recommendations.push({ text: "Add one more sentence if this capture is worth keeping." });
|
|
1014
|
+
recommendations.push({ text: "Link the source note or synthesis this output derives from." });
|
|
1048
1015
|
}
|
|
1049
1016
|
break;
|
|
1050
1017
|
default:
|
|
@@ -1169,12 +1136,6 @@ function getSectionContent(body, heading) {
|
|
|
1169
1136
|
}
|
|
1170
1137
|
return collected.join("\n");
|
|
1171
1138
|
}
|
|
1172
|
-
function hasUrl(body) {
|
|
1173
|
-
return /https?:\/\/\S+/i.test(body);
|
|
1174
|
-
}
|
|
1175
|
-
function hasAnyWikilinks(body) {
|
|
1176
|
-
return parseWikilinks(body).length > 0;
|
|
1177
|
-
}
|
|
1178
1139
|
function getNoteMetadata(db, slug) {
|
|
1179
1140
|
if (!slug) return null;
|
|
1180
1141
|
const row = db.prepare(`
|
|
@@ -1200,489 +1161,6 @@ function parseJsonArray(raw) {
|
|
|
1200
1161
|
}
|
|
1201
1162
|
}
|
|
1202
1163
|
|
|
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
|
-
|
|
1686
1164
|
// src/commands/new.ts
|
|
1687
1165
|
function newNote(title, options) {
|
|
1688
1166
|
const vaultRoot = requireVaultRoot();
|
|
@@ -1691,22 +1169,27 @@ function newNote(title, options) {
|
|
|
1691
1169
|
const typeConfig = config.note_types[resolvedType];
|
|
1692
1170
|
const bodyOverride = typeConfig?.slug_format === "date" ? title + "\n" : void 0;
|
|
1693
1171
|
const note = createNote(vaultRoot, config, resolvedType, title, bodyOverride);
|
|
1694
|
-
if (options.source || options.status) {
|
|
1695
|
-
if (options.source) validateSource(options.source);
|
|
1696
|
-
if (options.status) validateStatus(options.status);
|
|
1697
|
-
|
|
1172
|
+
if (options.source !== void 0 || options.status !== void 0 || options.reviewState !== void 0 || options.durability !== void 0 || options.derivedFrom !== void 0) {
|
|
1173
|
+
if (options.source !== void 0) validateSource(options.source);
|
|
1174
|
+
if (options.status !== void 0) validateStatus(options.status);
|
|
1175
|
+
if (options.reviewState !== void 0) validateReviewState(options.reviewState);
|
|
1176
|
+
if (options.durability !== void 0) validateDurability(options.durability);
|
|
1177
|
+
const raw = fs6.readFileSync(note.filepath, "utf-8");
|
|
1698
1178
|
const { frontmatter, body } = parseFrontmatter(raw);
|
|
1699
|
-
if (options.source) frontmatter.source = options.source;
|
|
1700
|
-
if (options.status) frontmatter.status = options.status;
|
|
1701
|
-
|
|
1179
|
+
if (options.source !== void 0) frontmatter.source = options.source;
|
|
1180
|
+
if (options.status !== void 0) frontmatter.status = options.status;
|
|
1181
|
+
if (options.reviewState !== void 0) frontmatter.review_state = options.reviewState;
|
|
1182
|
+
if (options.durability !== void 0) frontmatter.durability = options.durability;
|
|
1183
|
+
if (options.derivedFrom !== void 0) {
|
|
1184
|
+
frontmatter.derived_from = options.derivedFrom.split(",").map((value) => value.trim()).filter(Boolean);
|
|
1185
|
+
}
|
|
1186
|
+
fs6.writeFileSync(note.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
|
|
1702
1187
|
note.frontmatter = frontmatter;
|
|
1703
1188
|
}
|
|
1704
1189
|
const recommendationStrategy = typeConfig?.slug_format === "date" ? "incremental" : "rebuild";
|
|
1705
1190
|
const recommendations = recommendNote(vaultRoot, config, note, { strategy: recommendationStrategy });
|
|
1706
1191
|
const lines = note.body.split("\n").length;
|
|
1707
1192
|
const overLimit = typeConfig && typeConfig.line_limit && lines > typeConfig.line_limit;
|
|
1708
|
-
const sync = getSyncManager(vaultRoot, config);
|
|
1709
|
-
sync?.trackAndPush(note, "create");
|
|
1710
1193
|
if (options.json) {
|
|
1711
1194
|
console.log(jsonSuccess({
|
|
1712
1195
|
slug: note.slug,
|
|
@@ -1714,6 +1197,9 @@ function newNote(title, options) {
|
|
|
1714
1197
|
type: resolvedType,
|
|
1715
1198
|
status: note.frontmatter.status,
|
|
1716
1199
|
source: note.frontmatter.source,
|
|
1200
|
+
review_state: note.frontmatter.review_state,
|
|
1201
|
+
durability: note.frontmatter.durability,
|
|
1202
|
+
derived_from: note.frontmatter.derived_from,
|
|
1717
1203
|
filepath: note.filepath,
|
|
1718
1204
|
suggestions: recommendations.links.map((link) => ({
|
|
1719
1205
|
slug: link.slug,
|
|
@@ -1741,19 +1227,36 @@ function newNote(title, options) {
|
|
|
1741
1227
|
console.log(line);
|
|
1742
1228
|
}
|
|
1743
1229
|
}
|
|
1230
|
+
console.log("");
|
|
1231
|
+
switch (resolvedType) {
|
|
1232
|
+
case "source":
|
|
1233
|
+
console.log(`Next: Fill the note, then compile \u2192 granite recommend ${note.slug}`);
|
|
1234
|
+
break;
|
|
1235
|
+
case "note":
|
|
1236
|
+
console.log(`Next: Link to related notes \u2192 granite suggest-links ${note.slug}`);
|
|
1237
|
+
break;
|
|
1238
|
+
case "synthesis":
|
|
1239
|
+
console.log(`Next: Review connections \u2192 granite backlinks ${note.slug}`);
|
|
1240
|
+
break;
|
|
1241
|
+
case "output":
|
|
1242
|
+
console.log(`Next: Verify provenance \u2192 granite show ${note.slug}`);
|
|
1243
|
+
break;
|
|
1244
|
+
default:
|
|
1245
|
+
console.log(`Next: granite recommend ${note.slug}`);
|
|
1246
|
+
}
|
|
1744
1247
|
}
|
|
1745
1248
|
|
|
1746
1249
|
// src/commands/add.ts
|
|
1747
|
-
import
|
|
1250
|
+
import fs7 from "fs";
|
|
1748
1251
|
function addNote(text, options = {}) {
|
|
1749
1252
|
const vaultRoot = requireVaultRoot();
|
|
1750
1253
|
const config = loadConfig(vaultRoot);
|
|
1751
1254
|
const typeName = config.defaults.note_type;
|
|
1752
|
-
let content;
|
|
1255
|
+
let content = "";
|
|
1753
1256
|
if (text) {
|
|
1754
1257
|
content = text;
|
|
1755
1258
|
} else if (!process.stdin.isTTY) {
|
|
1756
|
-
content =
|
|
1259
|
+
content = fs7.readFileSync(0, "utf-8").trim();
|
|
1757
1260
|
} else {
|
|
1758
1261
|
console.error('Usage: granite add "some text" or echo "text" | granite add');
|
|
1759
1262
|
process.exit(1);
|
|
@@ -1766,8 +1269,6 @@ function addNote(text, options = {}) {
|
|
|
1766
1269
|
const title = firstLine.length > 60 ? firstLine.slice(0, 60).trim() + "..." : firstLine;
|
|
1767
1270
|
const note = createNote(vaultRoot, config, typeName, title, content + "\n");
|
|
1768
1271
|
const recommendations = recommendNote(vaultRoot, config, note, { strategy: "incremental" });
|
|
1769
|
-
const sync = getSyncManager(vaultRoot, config);
|
|
1770
|
-
sync?.trackAndPush(note, "create");
|
|
1771
1272
|
if (options.json) {
|
|
1772
1273
|
console.log(jsonSuccess({
|
|
1773
1274
|
slug: note.slug,
|
|
@@ -1787,6 +1288,9 @@ function addNote(text, options = {}) {
|
|
|
1787
1288
|
console.log(line);
|
|
1788
1289
|
}
|
|
1789
1290
|
}
|
|
1291
|
+
console.log("");
|
|
1292
|
+
console.log(`Captured as "${note.frontmatter.title}" (${note.slug})`);
|
|
1293
|
+
console.log(`Next: Refine \u2192 granite edit ${note.slug} | Find links \u2192 granite suggest-links ${note.slug}`);
|
|
1790
1294
|
}
|
|
1791
1295
|
|
|
1792
1296
|
// src/commands/open.ts
|
|
@@ -1799,12 +1303,13 @@ function openNote(slug) {
|
|
|
1799
1303
|
console.error(`Note not found: "${slug}"`);
|
|
1800
1304
|
process.exit(1);
|
|
1801
1305
|
}
|
|
1306
|
+
const existingNote = note;
|
|
1802
1307
|
const editor = config.defaults.editor === "$EDITOR" ? process.env.EDITOR || "vi" : config.defaults.editor;
|
|
1803
|
-
execSync(`${editor} "${
|
|
1308
|
+
execSync(`${editor} "${existingNote.filepath}"`, { stdio: "inherit" });
|
|
1804
1309
|
}
|
|
1805
1310
|
|
|
1806
1311
|
// src/commands/show.ts
|
|
1807
|
-
import
|
|
1312
|
+
import fs8 from "fs";
|
|
1808
1313
|
function showCommand(slug, options) {
|
|
1809
1314
|
const vaultRoot = requireVaultRoot();
|
|
1810
1315
|
const config = loadConfig(vaultRoot);
|
|
@@ -1817,30 +1322,36 @@ function showCommand(slug, options) {
|
|
|
1817
1322
|
}
|
|
1818
1323
|
process.exit(1);
|
|
1819
1324
|
}
|
|
1325
|
+
const existingNote = note;
|
|
1820
1326
|
if (options.json) {
|
|
1821
1327
|
console.log(jsonSuccess({
|
|
1822
|
-
slug:
|
|
1823
|
-
title:
|
|
1824
|
-
type:
|
|
1825
|
-
created:
|
|
1826
|
-
modified:
|
|
1827
|
-
tags:
|
|
1828
|
-
aliases:
|
|
1829
|
-
status:
|
|
1830
|
-
source:
|
|
1831
|
-
|
|
1832
|
-
|
|
1328
|
+
slug: existingNote.slug,
|
|
1329
|
+
title: existingNote.frontmatter.title,
|
|
1330
|
+
type: existingNote.frontmatter.type,
|
|
1331
|
+
created: existingNote.frontmatter.created,
|
|
1332
|
+
modified: existingNote.frontmatter.modified,
|
|
1333
|
+
tags: existingNote.frontmatter.tags,
|
|
1334
|
+
aliases: existingNote.frontmatter.aliases,
|
|
1335
|
+
status: existingNote.frontmatter.status,
|
|
1336
|
+
source: existingNote.frontmatter.source,
|
|
1337
|
+
review_state: existingNote.frontmatter.review_state,
|
|
1338
|
+
durability: existingNote.frontmatter.durability,
|
|
1339
|
+
derived_from: existingNote.frontmatter.derived_from,
|
|
1340
|
+
body: existingNote.body,
|
|
1341
|
+
filepath: existingNote.filepath
|
|
1833
1342
|
}));
|
|
1834
1343
|
return;
|
|
1835
1344
|
}
|
|
1836
1345
|
if (options.body) {
|
|
1837
|
-
process.stdout.write(
|
|
1346
|
+
process.stdout.write(existingNote.body);
|
|
1838
1347
|
return;
|
|
1839
1348
|
}
|
|
1840
|
-
console.log(`# ${
|
|
1841
|
-
console.log(
|
|
1349
|
+
console.log(`# ${existingNote.frontmatter.title} (${existingNote.slug})`);
|
|
1350
|
+
console.log(
|
|
1351
|
+
`# type: ${existingNote.frontmatter.type} | modified: ${existingNote.frontmatter.modified.slice(0, 10)} | review: ${existingNote.frontmatter.review_state} | durability: ${existingNote.frontmatter.durability}`
|
|
1352
|
+
);
|
|
1842
1353
|
console.log("");
|
|
1843
|
-
const content =
|
|
1354
|
+
const content = fs8.readFileSync(existingNote.filepath, "utf-8");
|
|
1844
1355
|
console.log(content);
|
|
1845
1356
|
}
|
|
1846
1357
|
|
|
@@ -1881,6 +1392,8 @@ function searchCommand(query, options) {
|
|
|
1881
1392
|
}
|
|
1882
1393
|
if (results.length === 0) {
|
|
1883
1394
|
console.log("No results found.");
|
|
1395
|
+
console.log("");
|
|
1396
|
+
console.log(`Nothing in the vault matches "${query}". Capture it: granite add "${query}"`);
|
|
1884
1397
|
return;
|
|
1885
1398
|
}
|
|
1886
1399
|
for (const r of results) {
|
|
@@ -1888,6 +1401,9 @@ function searchCommand(query, options) {
|
|
|
1888
1401
|
console.log(` ${r.snippet}`);
|
|
1889
1402
|
console.log("");
|
|
1890
1403
|
}
|
|
1404
|
+
console.log(`${results.length} result(s) for "${query}"`);
|
|
1405
|
+
console.log("");
|
|
1406
|
+
console.log(`Dive deeper: granite show ${results[0].slug} | granite backlinks ${results[0].slug}`);
|
|
1891
1407
|
}
|
|
1892
1408
|
|
|
1893
1409
|
// src/core/backlinks.ts
|
|
@@ -1918,6 +1434,9 @@ function backlinksCommand(slug, options) {
|
|
|
1918
1434
|
}
|
|
1919
1435
|
if (backlinks.length === 0) {
|
|
1920
1436
|
console.log(`No backlinks found for "${slug}".`);
|
|
1437
|
+
console.log("");
|
|
1438
|
+
console.log("This note is an orphan \u2014 nothing links to it.");
|
|
1439
|
+
console.log(`Connect it: granite suggest-links ${slug} | granite recommend ${slug}`);
|
|
1921
1440
|
return;
|
|
1922
1441
|
}
|
|
1923
1442
|
console.log(`Backlinks to "${slug}":`);
|
|
@@ -1929,6 +1448,7 @@ function backlinksCommand(slug, options) {
|
|
|
1929
1448
|
}
|
|
1930
1449
|
console.log("");
|
|
1931
1450
|
}
|
|
1451
|
+
console.log(`${backlinks.length} note(s) link here. Strengthen: granite suggest-links ${slug}`);
|
|
1932
1452
|
}
|
|
1933
1453
|
|
|
1934
1454
|
// src/commands/suggest-links.ts
|
|
@@ -1944,22 +1464,27 @@ function suggestLinksCommand(slug, options) {
|
|
|
1944
1464
|
}
|
|
1945
1465
|
process.exit(1);
|
|
1946
1466
|
}
|
|
1467
|
+
const existingNote = note;
|
|
1947
1468
|
const db = ensureIndex(vaultRoot, config);
|
|
1948
|
-
const suggestions = suggestLinks(db,
|
|
1469
|
+
const suggestions = suggestLinks(db, existingNote);
|
|
1949
1470
|
db.close();
|
|
1950
1471
|
if (options.json) {
|
|
1951
1472
|
console.log(jsonSuccess(suggestions));
|
|
1952
1473
|
return;
|
|
1953
1474
|
}
|
|
1954
1475
|
if (suggestions.length === 0) {
|
|
1955
|
-
console.log(
|
|
1476
|
+
console.log(`No unlinked mentions found for "${existingNote.frontmatter.title}".`);
|
|
1477
|
+
console.log("");
|
|
1478
|
+
console.log(`All mentions are linked. See what else to do: granite recommend ${slug}`);
|
|
1956
1479
|
return;
|
|
1957
1480
|
}
|
|
1958
|
-
console.log(`
|
|
1481
|
+
console.log(`Unlinked mentions in "${existingNote.frontmatter.title}":`);
|
|
1959
1482
|
console.log("");
|
|
1960
1483
|
for (const s of suggestions) {
|
|
1961
1484
|
console.log(` \u2192 [[${s.target_title}]] \u2014 ${s.mentions} mention${s.mentions > 1 ? "s" : ""}`);
|
|
1962
1485
|
}
|
|
1486
|
+
console.log("");
|
|
1487
|
+
console.log(`Add [[wikilinks]] to strengthen the graph. Then: granite recommend ${slug}`);
|
|
1963
1488
|
}
|
|
1964
1489
|
|
|
1965
1490
|
// src/commands/recommend.ts
|
|
@@ -1975,37 +1500,48 @@ function recommendCommand(slug, options) {
|
|
|
1975
1500
|
}
|
|
1976
1501
|
process.exit(1);
|
|
1977
1502
|
}
|
|
1978
|
-
const
|
|
1503
|
+
const existingNote = note;
|
|
1504
|
+
const recommendations = recommendNote(vaultRoot, config, existingNote);
|
|
1979
1505
|
if (options.json) {
|
|
1980
1506
|
console.log(jsonSuccess({
|
|
1981
|
-
slug:
|
|
1982
|
-
title:
|
|
1983
|
-
type:
|
|
1507
|
+
slug: existingNote.slug,
|
|
1508
|
+
title: existingNote.frontmatter.title,
|
|
1509
|
+
type: existingNote.frontmatter.type,
|
|
1984
1510
|
recommendations
|
|
1985
1511
|
}));
|
|
1986
1512
|
return;
|
|
1987
1513
|
}
|
|
1988
1514
|
const lines = formatRecommendations(recommendations);
|
|
1989
1515
|
if (lines.length === 0) {
|
|
1990
|
-
console.log(`
|
|
1516
|
+
console.log(`"${existingNote.frontmatter.title}" is well-connected. No actions needed.`);
|
|
1517
|
+
console.log("");
|
|
1518
|
+
console.log("Keep the loop going: granite status");
|
|
1991
1519
|
return;
|
|
1992
1520
|
}
|
|
1993
|
-
console.log(`Recommendations for "${
|
|
1521
|
+
console.log(`Recommendations for "${existingNote.frontmatter.title}" (${existingNote.frontmatter.type}):`);
|
|
1994
1522
|
console.log("");
|
|
1995
1523
|
for (const line of lines) {
|
|
1996
1524
|
console.log(line);
|
|
1997
1525
|
}
|
|
1526
|
+
console.log("");
|
|
1527
|
+
if (recommendations.next_steps.length > 0) {
|
|
1528
|
+
const step = recommendations.next_steps[0];
|
|
1529
|
+
const hint = step.title_hint ? ` "${step.title_hint}"` : "";
|
|
1530
|
+
console.log(`The vault is asking for: granite new${hint} --type ${step.type}`);
|
|
1531
|
+
} else if (recommendations.links.length > 0) {
|
|
1532
|
+
console.log(`Strengthen this note: granite edit ${slug} --append $'See also: [[${recommendations.links[0].slug}]]'`);
|
|
1533
|
+
}
|
|
1998
1534
|
}
|
|
1999
1535
|
|
|
2000
1536
|
// src/core/doctor.ts
|
|
2001
|
-
import
|
|
2002
|
-
import
|
|
1537
|
+
import fs9 from "fs";
|
|
1538
|
+
import path6 from "path";
|
|
2003
1539
|
function runDoctor(vaultRoot, config, db) {
|
|
2004
1540
|
const issues = [];
|
|
2005
1541
|
const notes = listNotes(vaultRoot, config);
|
|
2006
1542
|
for (const [name, typeConfig] of Object.entries(config.note_types)) {
|
|
2007
|
-
const folder =
|
|
2008
|
-
if (!
|
|
1543
|
+
const folder = path6.join(vaultRoot, typeConfig.folder);
|
|
1544
|
+
if (!fs9.existsSync(folder)) {
|
|
2009
1545
|
issues.push({
|
|
2010
1546
|
level: "error",
|
|
2011
1547
|
file: `granite.yml`,
|
|
@@ -2027,6 +1563,26 @@ function runDoctor(vaultRoot, config, db) {
|
|
|
2027
1563
|
if (!fm.created) {
|
|
2028
1564
|
issues.push({ level: "warning", file: note.filepath, message: "Missing frontmatter field: created" });
|
|
2029
1565
|
}
|
|
1566
|
+
try {
|
|
1567
|
+
validateReviewState(fm.review_state);
|
|
1568
|
+
} catch (error) {
|
|
1569
|
+
issues.push({ level: "warning", file: note.filepath, message: error.message });
|
|
1570
|
+
}
|
|
1571
|
+
try {
|
|
1572
|
+
validateDurability(fm.durability);
|
|
1573
|
+
} catch (error) {
|
|
1574
|
+
issues.push({ level: "warning", file: note.filepath, message: error.message });
|
|
1575
|
+
}
|
|
1576
|
+
if (!Array.isArray(fm.derived_from)) {
|
|
1577
|
+
issues.push({ level: "warning", file: note.filepath, message: "Invalid derived_from: expected a list of note IDs or slugs" });
|
|
1578
|
+
}
|
|
1579
|
+
if ((fm.type === "synthesis" || fm.type === "output") && (!Array.isArray(fm.derived_from) || fm.derived_from.length === 0)) {
|
|
1580
|
+
issues.push({
|
|
1581
|
+
level: "warning",
|
|
1582
|
+
file: note.filepath,
|
|
1583
|
+
message: `Type "${fm.type}" should declare derived_from to preserve provenance`
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
2030
1586
|
}
|
|
2031
1587
|
for (const note of notes) {
|
|
2032
1588
|
const links = parseWikilinks(note.body);
|
|
@@ -2107,19 +1663,33 @@ function runDoctor(vaultRoot, config, db) {
|
|
|
2107
1663
|
}
|
|
2108
1664
|
|
|
2109
1665
|
// src/commands/doctor.ts
|
|
2110
|
-
function doctorCommand() {
|
|
1666
|
+
function doctorCommand(options = {}) {
|
|
2111
1667
|
const vaultRoot = requireVaultRoot();
|
|
2112
1668
|
const config = loadConfig(vaultRoot);
|
|
2113
1669
|
const db = ensureIndex(vaultRoot, config);
|
|
2114
1670
|
const issues = runDoctor(vaultRoot, config, db);
|
|
2115
1671
|
db.close();
|
|
2116
|
-
if (issues.length === 0) {
|
|
2117
|
-
console.log("No issues found. Vault is healthy.");
|
|
2118
|
-
return;
|
|
2119
|
-
}
|
|
2120
1672
|
const errors = issues.filter((i) => i.level === "error");
|
|
2121
1673
|
const warnings = issues.filter((i) => i.level === "warning");
|
|
2122
1674
|
const infos = issues.filter((i) => i.level === "info");
|
|
1675
|
+
if (options.json) {
|
|
1676
|
+
console.log(jsonSuccess({
|
|
1677
|
+
healthy: errors.length === 0,
|
|
1678
|
+
issues,
|
|
1679
|
+
counts: {
|
|
1680
|
+
errors: errors.length,
|
|
1681
|
+
warnings: warnings.length,
|
|
1682
|
+
infos: infos.length
|
|
1683
|
+
}
|
|
1684
|
+
}));
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
if (issues.length === 0) {
|
|
1688
|
+
console.log("Vault is healthy. No issues found.");
|
|
1689
|
+
console.log("");
|
|
1690
|
+
console.log("Keep the loop going: granite status");
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
2123
1693
|
const print = (label, items) => {
|
|
2124
1694
|
if (items.length === 0) return;
|
|
2125
1695
|
console.log(`
|
|
@@ -2134,14 +1704,26 @@ ${label}:`);
|
|
|
2134
1704
|
print("Info", infos);
|
|
2135
1705
|
console.log(`
|
|
2136
1706
|
${errors.length} error(s), ${warnings.length} warning(s), ${infos.length} info(s)`);
|
|
1707
|
+
console.log("");
|
|
1708
|
+
const brokenLinks = issues.filter((i) => i.message.includes("Broken wikilink") || i.message.includes("broken"));
|
|
1709
|
+
const overLimit = issues.filter((i) => i.message.includes("line limit") || i.message.includes("exceeds"));
|
|
1710
|
+
if (brokenLinks.length > 0) {
|
|
1711
|
+
console.log(`Fix broken links: ${brokenLinks.length} wikilink(s) point to notes that don't exist.`);
|
|
1712
|
+
console.log(" \u2192 Create the missing notes or fix the [[wikilinks]].");
|
|
1713
|
+
}
|
|
1714
|
+
if (overLimit.length > 0) {
|
|
1715
|
+
console.log(`Split oversized notes: ${overLimit.length} note(s) exceed their line limit.`);
|
|
1716
|
+
console.log(" \u2192 Break them into smaller, linked notes. One idea per note.");
|
|
1717
|
+
}
|
|
2137
1718
|
if (errors.length > 0) process.exit(1);
|
|
2138
1719
|
}
|
|
2139
1720
|
|
|
2140
1721
|
// src/web/server.ts
|
|
2141
1722
|
import { Hono } from "hono";
|
|
2142
1723
|
import { serve } from "@hono/node-server";
|
|
2143
|
-
import
|
|
2144
|
-
import
|
|
1724
|
+
import path7 from "path";
|
|
1725
|
+
import fs10 from "fs";
|
|
1726
|
+
import { fileURLToPath } from "url";
|
|
2145
1727
|
function createApp(vaultRoot, config) {
|
|
2146
1728
|
const app = new Hono();
|
|
2147
1729
|
app.get("/api/notes", (c) => {
|
|
@@ -2179,6 +1761,11 @@ function createApp(vaultRoot, config) {
|
|
|
2179
1761
|
modified: note.frontmatter.modified,
|
|
2180
1762
|
tags: note.frontmatter.tags,
|
|
2181
1763
|
aliases: note.frontmatter.aliases,
|
|
1764
|
+
status: note.frontmatter.status,
|
|
1765
|
+
source: note.frontmatter.source,
|
|
1766
|
+
review_state: note.frontmatter.review_state,
|
|
1767
|
+
durability: note.frontmatter.durability,
|
|
1768
|
+
derived_from: note.frontmatter.derived_from,
|
|
2182
1769
|
body: note.body,
|
|
2183
1770
|
outgoing_links: resolvedLinks,
|
|
2184
1771
|
backlinks
|
|
@@ -2210,13 +1797,14 @@ function createApp(vaultRoot, config) {
|
|
|
2210
1797
|
}
|
|
2211
1798
|
try {
|
|
2212
1799
|
const note = createNote(vaultRoot, config, type, title, noteBody || void 0);
|
|
2213
|
-
const sync = getSyncManager(vaultRoot, config);
|
|
2214
|
-
sync?.trackAndPush(note, "create");
|
|
2215
1800
|
return c.json({
|
|
2216
1801
|
slug: note.slug,
|
|
2217
1802
|
title: note.frontmatter.title,
|
|
2218
1803
|
type: note.frontmatter.type,
|
|
2219
|
-
created: note.frontmatter.created
|
|
1804
|
+
created: note.frontmatter.created,
|
|
1805
|
+
review_state: note.frontmatter.review_state,
|
|
1806
|
+
durability: note.frontmatter.durability,
|
|
1807
|
+
derived_from: note.frontmatter.derived_from
|
|
2220
1808
|
});
|
|
2221
1809
|
} catch (err) {
|
|
2222
1810
|
return c.json({ error: err.message }, 400);
|
|
@@ -2229,17 +1817,17 @@ function createApp(vaultRoot, config) {
|
|
|
2229
1817
|
db.close();
|
|
2230
1818
|
return c.json({ nodes, edges });
|
|
2231
1819
|
});
|
|
2232
|
-
const publicDir =
|
|
1820
|
+
const publicDir = path7.join(path7.dirname(fileURLToPath(import.meta.url)), "public");
|
|
2233
1821
|
app.get("/*", (c) => {
|
|
2234
1822
|
const reqPath = c.req.path === "/" ? "/index.html" : c.req.path;
|
|
2235
|
-
const filePath =
|
|
2236
|
-
if (!
|
|
2237
|
-
const indexPath =
|
|
2238
|
-
const html =
|
|
1823
|
+
const filePath = path7.join(publicDir, reqPath);
|
|
1824
|
+
if (!fs10.existsSync(filePath)) {
|
|
1825
|
+
const indexPath = path7.join(publicDir, "index.html");
|
|
1826
|
+
const html = fs10.readFileSync(indexPath, "utf-8");
|
|
2239
1827
|
return c.html(html);
|
|
2240
1828
|
}
|
|
2241
|
-
const content =
|
|
2242
|
-
const ext =
|
|
1829
|
+
const content = fs10.readFileSync(filePath);
|
|
1830
|
+
const ext = path7.extname(filePath);
|
|
2243
1831
|
const mimeTypes = {
|
|
2244
1832
|
".html": "text/html",
|
|
2245
1833
|
".css": "text/css",
|
|
@@ -2273,7 +1861,7 @@ function serveCommand(options) {
|
|
|
2273
1861
|
function typesCommand() {
|
|
2274
1862
|
const vaultRoot = requireVaultRoot();
|
|
2275
1863
|
const config = loadConfig(vaultRoot);
|
|
2276
|
-
console.log("
|
|
1864
|
+
console.log("Note types \u2014 the natural flow is: source \u2192 note \u2192 synthesis \u2192 output\n");
|
|
2277
1865
|
for (const [name, tc] of Object.entries(config.note_types)) {
|
|
2278
1866
|
console.log(` ${name}`);
|
|
2279
1867
|
console.log(` ${tc.description}`);
|
|
@@ -2281,15 +1869,25 @@ function typesCommand() {
|
|
|
2281
1869
|
if (tc.instructions) {
|
|
2282
1870
|
console.log(` guide: ${tc.instructions}`);
|
|
2283
1871
|
}
|
|
1872
|
+
if (tc.frontmatter_defaults) {
|
|
1873
|
+
const defaults = Object.entries(tc.frontmatter_defaults).map(([k, v]) => `${k}: ${v}`).join(", ");
|
|
1874
|
+
console.log(` defaults: ${defaults}`);
|
|
1875
|
+
}
|
|
2284
1876
|
console.log("");
|
|
2285
1877
|
}
|
|
2286
1878
|
console.log(`Default type: ${config.defaults.note_type}`);
|
|
2287
|
-
console.log(
|
|
2288
|
-
|
|
1879
|
+
console.log("");
|
|
1880
|
+
console.log("When to use what:");
|
|
1881
|
+
console.log(" source Raw material \u2014 articles, tweets, papers. Keep it close to the original.");
|
|
1882
|
+
console.log(" note One durable idea. If it's two ideas, make two notes.");
|
|
1883
|
+
console.log(" synthesis Compile multiple notes into new understanding. The most valuable type.");
|
|
1884
|
+
console.log(" output Audience-specific deliverable. Ephemeral \u2014 always derived_from something durable.");
|
|
1885
|
+
console.log("");
|
|
1886
|
+
console.log('Usage: granite new "Title" --type <type>');
|
|
2289
1887
|
}
|
|
2290
1888
|
|
|
2291
1889
|
// src/commands/list.ts
|
|
2292
|
-
var AVAILABLE_FIELDS = ["slug", "title", "type", "created", "modified", "tags", "aliases", "status", "source", "filepath"];
|
|
1890
|
+
var AVAILABLE_FIELDS = ["slug", "title", "type", "created", "modified", "tags", "aliases", "status", "source", "review_state", "durability", "derived_from", "filepath"];
|
|
2293
1891
|
function pickFields(note, fields) {
|
|
2294
1892
|
const out = {};
|
|
2295
1893
|
for (const f of fields) {
|
|
@@ -2321,6 +1919,15 @@ function pickFields(note, fields) {
|
|
|
2321
1919
|
case "source":
|
|
2322
1920
|
out.source = note.frontmatter.source;
|
|
2323
1921
|
break;
|
|
1922
|
+
case "review_state":
|
|
1923
|
+
out.review_state = note.frontmatter.review_state;
|
|
1924
|
+
break;
|
|
1925
|
+
case "durability":
|
|
1926
|
+
out.durability = note.frontmatter.durability;
|
|
1927
|
+
break;
|
|
1928
|
+
case "derived_from":
|
|
1929
|
+
out.derived_from = note.frontmatter.derived_from;
|
|
1930
|
+
break;
|
|
2324
1931
|
case "filepath":
|
|
2325
1932
|
out.filepath = note.filepath;
|
|
2326
1933
|
break;
|
|
@@ -2362,6 +1969,12 @@ function listCommand(options) {
|
|
|
2362
1969
|
}
|
|
2363
1970
|
if (notes.length === 0) {
|
|
2364
1971
|
console.log("No notes found.");
|
|
1972
|
+
if (options.type) {
|
|
1973
|
+
console.log(`
|
|
1974
|
+
No ${options.type} notes yet. Create one: granite new "Title" --type ${options.type}`);
|
|
1975
|
+
} else {
|
|
1976
|
+
console.log('\nVault is empty. Start: granite add "Your first thought"');
|
|
1977
|
+
}
|
|
2365
1978
|
return;
|
|
2366
1979
|
}
|
|
2367
1980
|
for (const note of notes) {
|
|
@@ -2372,10 +1985,13 @@ function listCommand(options) {
|
|
|
2372
1985
|
}
|
|
2373
1986
|
console.log(`
|
|
2374
1987
|
${notes.length} note(s)`);
|
|
1988
|
+
if (options.status === "inbox") {
|
|
1989
|
+
console.log("\nProcess inbox: granite show <slug> \u2192 granite edit <slug> --status active");
|
|
1990
|
+
}
|
|
2375
1991
|
}
|
|
2376
1992
|
|
|
2377
1993
|
// src/commands/edit.ts
|
|
2378
|
-
import
|
|
1994
|
+
import fs11 from "fs";
|
|
2379
1995
|
import { execSync as execSync2 } from "child_process";
|
|
2380
1996
|
function editCommand(slug, options) {
|
|
2381
1997
|
const vaultRoot = requireVaultRoot();
|
|
@@ -2385,9 +2001,10 @@ function editCommand(slug, options) {
|
|
|
2385
2001
|
console.error(`Note not found: ${slug}`);
|
|
2386
2002
|
process.exit(1);
|
|
2387
2003
|
}
|
|
2388
|
-
const
|
|
2004
|
+
const existingNote = note;
|
|
2005
|
+
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 || options.reviewState !== void 0 || options.durability !== void 0 || options.derivedFrom !== void 0;
|
|
2389
2006
|
if (hasFlags) {
|
|
2390
|
-
let { frontmatter, body } = parseFrontmatter(
|
|
2007
|
+
let { frontmatter, body } = parseFrontmatter(fs11.readFileSync(existingNote.filepath, "utf-8"));
|
|
2391
2008
|
if (options.title) {
|
|
2392
2009
|
frontmatter.title = options.title;
|
|
2393
2010
|
}
|
|
@@ -2403,14 +2020,25 @@ function editCommand(slug, options) {
|
|
|
2403
2020
|
for (const a of newAliases) existing.add(a);
|
|
2404
2021
|
frontmatter.aliases = [...existing];
|
|
2405
2022
|
}
|
|
2406
|
-
if (options.status) {
|
|
2023
|
+
if (options.status !== void 0) {
|
|
2407
2024
|
validateStatus(options.status);
|
|
2408
2025
|
frontmatter.status = options.status;
|
|
2409
2026
|
}
|
|
2410
|
-
if (options.source) {
|
|
2027
|
+
if (options.source !== void 0) {
|
|
2411
2028
|
validateSource(options.source);
|
|
2412
2029
|
frontmatter.source = options.source;
|
|
2413
2030
|
}
|
|
2031
|
+
if (options.reviewState !== void 0) {
|
|
2032
|
+
validateReviewState(options.reviewState);
|
|
2033
|
+
frontmatter.review_state = options.reviewState;
|
|
2034
|
+
}
|
|
2035
|
+
if (options.durability !== void 0) {
|
|
2036
|
+
validateDurability(options.durability);
|
|
2037
|
+
frontmatter.durability = options.durability;
|
|
2038
|
+
}
|
|
2039
|
+
if (options.derivedFrom !== void 0) {
|
|
2040
|
+
frontmatter.derived_from = options.derivedFrom.split(",").map((value) => value.trim()).filter(Boolean);
|
|
2041
|
+
}
|
|
2414
2042
|
if (options.body !== void 0) {
|
|
2415
2043
|
body = options.body + "\n";
|
|
2416
2044
|
}
|
|
@@ -2418,32 +2046,26 @@ function editCommand(slug, options) {
|
|
|
2418
2046
|
body = body.trimEnd() + "\n" + options.append + "\n";
|
|
2419
2047
|
}
|
|
2420
2048
|
frontmatter.modified = (/* @__PURE__ */ new Date()).toISOString();
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
const updatedForSync = readNote(note.filepath);
|
|
2424
|
-
sync?.trackAndPush(updatedForSync, "update");
|
|
2425
|
-
console.log(note.filepath);
|
|
2049
|
+
fs11.writeFileSync(existingNote.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
|
|
2050
|
+
console.log(existingNote.filepath);
|
|
2426
2051
|
const recommendationStrategy = options.title !== void 0 || options.alias !== void 0 ? "rebuild" : "incremental";
|
|
2427
|
-
printRecommendations(vaultRoot, config,
|
|
2052
|
+
printRecommendations(vaultRoot, config, existingNote.filepath, recommendationStrategy);
|
|
2428
2053
|
} else {
|
|
2429
2054
|
const editor = process.env.EDITOR || "vi";
|
|
2430
|
-
const statBefore =
|
|
2055
|
+
const statBefore = fs11.statSync(existingNote.filepath).mtimeMs;
|
|
2431
2056
|
try {
|
|
2432
|
-
execSync2(`${editor} "${
|
|
2057
|
+
execSync2(`${editor} "${existingNote.filepath}"`, { stdio: "inherit" });
|
|
2433
2058
|
} catch {
|
|
2434
2059
|
console.error(`Failed to open editor: ${editor}`);
|
|
2435
2060
|
process.exit(1);
|
|
2436
2061
|
}
|
|
2437
|
-
const statAfter =
|
|
2062
|
+
const statAfter = fs11.statSync(existingNote.filepath).mtimeMs;
|
|
2438
2063
|
if (statAfter !== statBefore) {
|
|
2439
|
-
const { frontmatter, body } = parseFrontmatter(
|
|
2064
|
+
const { frontmatter, body } = parseFrontmatter(fs11.readFileSync(existingNote.filepath, "utf-8"));
|
|
2440
2065
|
frontmatter.modified = (/* @__PURE__ */ new Date()).toISOString();
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
sync?.trackAndPush(updatedForSync, "update");
|
|
2445
|
-
console.log(`Updated: ${note.filepath}`);
|
|
2446
|
-
printRecommendations(vaultRoot, config, note.filepath, "rebuild");
|
|
2066
|
+
fs11.writeFileSync(existingNote.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
|
|
2067
|
+
console.log(`Updated: ${existingNote.filepath}`);
|
|
2068
|
+
printRecommendations(vaultRoot, config, existingNote.filepath, "rebuild");
|
|
2447
2069
|
}
|
|
2448
2070
|
}
|
|
2449
2071
|
}
|
|
@@ -2451,16 +2073,102 @@ function printRecommendations(vaultRoot, config, filepath, strategy) {
|
|
|
2451
2073
|
const updatedNote = readNote(filepath);
|
|
2452
2074
|
const recommendations = recommendNote(vaultRoot, config, updatedNote, { strategy });
|
|
2453
2075
|
const lines = formatRecommendations(recommendations);
|
|
2454
|
-
if (lines.length
|
|
2076
|
+
if (lines.length > 0) {
|
|
2077
|
+
console.log("");
|
|
2078
|
+
console.log("Recommendations:");
|
|
2079
|
+
for (const line of lines) {
|
|
2080
|
+
console.log(line);
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2455
2083
|
console.log("");
|
|
2456
|
-
console.log(
|
|
2457
|
-
|
|
2458
|
-
|
|
2084
|
+
console.log(`Next: Check connections \u2192 granite suggest-links ${updatedNote.slug} | Compile \u2192 granite recommend ${updatedNote.slug}`);
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
// src/commands/status.ts
|
|
2088
|
+
function statusCommand(options = {}) {
|
|
2089
|
+
const vaultRoot = requireVaultRoot();
|
|
2090
|
+
const config = loadConfig(vaultRoot);
|
|
2091
|
+
const notes = listNotes(vaultRoot, config);
|
|
2092
|
+
const byType = {};
|
|
2093
|
+
for (const note of notes) {
|
|
2094
|
+
byType[note.frontmatter.type] = (byType[note.frontmatter.type] ?? 0) + 1;
|
|
2095
|
+
}
|
|
2096
|
+
const inbox = notes.filter((n) => n.frontmatter.status === "inbox");
|
|
2097
|
+
const drafts = notes.filter((n) => n.frontmatter.review_state === "draft" && n.frontmatter.status === "active");
|
|
2098
|
+
const orphans = [];
|
|
2099
|
+
const db = ensureIndex(vaultRoot, config);
|
|
2100
|
+
const issues = runDoctor(vaultRoot, config, db);
|
|
2101
|
+
for (const note of notes.slice(0, 50)) {
|
|
2102
|
+
const bl = getBacklinks(db, note.slug);
|
|
2103
|
+
if (bl.length === 0 && note.outgoing_links.length === 0) {
|
|
2104
|
+
orphans.push(note.slug);
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
db.close();
|
|
2108
|
+
const errors = issues.filter((i) => i.level === "error");
|
|
2109
|
+
const nextActions = [];
|
|
2110
|
+
if (notes.length === 0) {
|
|
2111
|
+
nextActions.push('Your vault is empty. Start capturing: granite add "Your first thought"');
|
|
2112
|
+
} else {
|
|
2113
|
+
if (inbox.length > 0) {
|
|
2114
|
+
nextActions.push(`Process your inbox: ${inbox.length} note(s) waiting \u2192 granite show ${inbox[0].slug}`);
|
|
2115
|
+
}
|
|
2116
|
+
if (drafts.length > 0) {
|
|
2117
|
+
nextActions.push(`Review drafts: ${drafts.length} active draft(s) \u2192 granite show ${drafts[0].slug}`);
|
|
2118
|
+
}
|
|
2119
|
+
if (errors.length > 0) {
|
|
2120
|
+
nextActions.push(`Fix ${errors.length} error(s) found by doctor \u2192 granite doctor`);
|
|
2121
|
+
}
|
|
2122
|
+
if (orphans.length > 0) {
|
|
2123
|
+
nextActions.push(`Connect ${orphans.length} orphan note(s) \u2192 granite suggest-links ${orphans[0]}`);
|
|
2124
|
+
}
|
|
2125
|
+
const syntheses = byType["synthesis"] ?? 0;
|
|
2126
|
+
const notesAndSources = (byType["note"] ?? 0) + (byType["source"] ?? 0);
|
|
2127
|
+
if (notesAndSources >= 5 && syntheses === 0) {
|
|
2128
|
+
nextActions.push("You have enough notes to compile a synthesis \u2192 granite recommend <slug>");
|
|
2129
|
+
}
|
|
2130
|
+
if (nextActions.length === 0) {
|
|
2131
|
+
nextActions.push("Vault is healthy. Keep capturing or run: granite doctor");
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
if (options.json) {
|
|
2135
|
+
console.log(jsonSuccess({
|
|
2136
|
+
vault_name: config.vault_name,
|
|
2137
|
+
note_count: notes.length,
|
|
2138
|
+
notes_by_type: byType,
|
|
2139
|
+
inbox_count: inbox.length,
|
|
2140
|
+
draft_count: drafts.length,
|
|
2141
|
+
orphan_count: orphans.length,
|
|
2142
|
+
error_count: errors.length,
|
|
2143
|
+
next_actions: nextActions
|
|
2144
|
+
}));
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
2147
|
+
console.log(`${config.vault_name}`);
|
|
2148
|
+
console.log("");
|
|
2149
|
+
const typeLine = Object.entries(byType).map(([type, count]) => `${count} ${type}${count > 1 ? "s" : ""}`).join(", ");
|
|
2150
|
+
console.log(` ${notes.length} notes: ${typeLine || "none"}`);
|
|
2151
|
+
if (inbox.length > 0 || drafts.length > 0 || orphans.length > 0) {
|
|
2152
|
+
const parts = [];
|
|
2153
|
+
if (inbox.length > 0) parts.push(`${inbox.length} inbox`);
|
|
2154
|
+
if (drafts.length > 0) parts.push(`${drafts.length} drafts`);
|
|
2155
|
+
if (orphans.length > 0) parts.push(`${orphans.length} orphans`);
|
|
2156
|
+
console.log(` Attention: ${parts.join(", ")}`);
|
|
2157
|
+
}
|
|
2158
|
+
if (errors.length > 0) {
|
|
2159
|
+
console.log(` Health: ${errors.length} error(s) \u2014 run granite doctor`);
|
|
2160
|
+
}
|
|
2161
|
+
console.log("");
|
|
2162
|
+
console.log("Next:");
|
|
2163
|
+
for (const action of nextActions) {
|
|
2164
|
+
console.log(` \u2192 ${action}`);
|
|
2459
2165
|
}
|
|
2460
2166
|
}
|
|
2461
2167
|
|
|
2462
2168
|
// src/commands/mcp.ts
|
|
2463
|
-
import
|
|
2169
|
+
import fs13 from "fs";
|
|
2170
|
+
import path9 from "path";
|
|
2171
|
+
import { spawn as spawn2 } from "child_process";
|
|
2464
2172
|
|
|
2465
2173
|
// src/mcp/server.ts
|
|
2466
2174
|
import { serve as serve2 } from "@hono/node-server";
|
|
@@ -2514,6 +2222,9 @@ var noteSummarySchema = z.object({
|
|
|
2514
2222
|
aliases: z.array(z.string()),
|
|
2515
2223
|
status: z.enum(["inbox", "active", "archived"]),
|
|
2516
2224
|
source: z.enum(["human", "agent", "extraction"]),
|
|
2225
|
+
review_state: z.enum(["draft", "reviewed", "locked"]),
|
|
2226
|
+
durability: z.enum(["canonical", "working", "ephemeral"]),
|
|
2227
|
+
derived_from: z.array(z.string()),
|
|
2517
2228
|
filepath: z.string(),
|
|
2518
2229
|
resource_uri: z.string()
|
|
2519
2230
|
});
|
|
@@ -2590,11 +2301,40 @@ function createGraniteMcpServer(runtime) {
|
|
|
2590
2301
|
{
|
|
2591
2302
|
capabilities: { logging: {} },
|
|
2592
2303
|
instructions: [
|
|
2593
|
-
"Granite
|
|
2594
|
-
"
|
|
2595
|
-
"
|
|
2596
|
-
"
|
|
2597
|
-
|
|
2304
|
+
"# Granite \u2014 Knowledge Compilation System",
|
|
2305
|
+
"",
|
|
2306
|
+
"You are operating a local-first markdown knowledge base. You are the primary writer and gardener of this vault \u2014 the human rarely edits notes directly.",
|
|
2307
|
+
"",
|
|
2308
|
+
"## Core Loop",
|
|
2309
|
+
"",
|
|
2310
|
+
"Granite follows a continuous knowledge compilation loop:",
|
|
2311
|
+
"",
|
|
2312
|
+
"1. **Capture** \u2014 Ingest raw information quickly (granite_capture_note or granite_create_note). Don't over-structure at this stage.",
|
|
2313
|
+
"2. **Compile** \u2014 Turn raw captures and sources into durable, linked notes and syntheses. Use granite_recommend_note_actions to find what to connect.",
|
|
2314
|
+
"3. **Query** \u2014 Search and traverse the vault (granite_search_notes, granite_get_backlinks, granite_suggest_links) to answer questions by researching across notes.",
|
|
2315
|
+
"4. **Output** \u2014 Generate audience-specific deliverables (type: output) that derive from durable notes. Always set derived_from.",
|
|
2316
|
+
"5. **Lint** \u2014 Run granite_run_doctor and granite_recommend_note_actions regularly to find gaps, broken links, and opportunities to strengthen the vault.",
|
|
2317
|
+
"",
|
|
2318
|
+
"Every interaction should advance the vault. Queries become notes. Outputs feed back in. Knowledge compounds.",
|
|
2319
|
+
"",
|
|
2320
|
+
"## Note Types",
|
|
2321
|
+
"",
|
|
2322
|
+
"- **note**: Atomic, durable ideas \u2014 one idea per note, well-linked. The backbone of the vault.",
|
|
2323
|
+
"- **source**: Imported material kept close to the original. Capture provenance, summarize essentials.",
|
|
2324
|
+
"- **synthesis**: Compiled knowledge connecting multiple notes or sources. The most valuable type.",
|
|
2325
|
+
"- **output**: Situational deliverables (reports, briefs). Ephemeral by default, always derived_from something durable.",
|
|
2326
|
+
"",
|
|
2327
|
+
"The natural flow is: source \u2192 note \u2192 synthesis \u2192 output",
|
|
2328
|
+
"",
|
|
2329
|
+
"## Working Principles",
|
|
2330
|
+
"",
|
|
2331
|
+
"- **Read before writing.** Use granite_search_notes and granite_list_notes to understand what already exists before creating new notes. Avoid duplicates.",
|
|
2332
|
+
"- **Link aggressively.** Use [[wikilinks]] in note bodies. After writing, check granite_suggest_links to find missed connections.",
|
|
2333
|
+
"- **Act on recommendations.** Every create/update returns recommendations \u2014 follow them. They are the compiler's suggestions for what to connect or write next.",
|
|
2334
|
+
"- **Use the right type.** Don't dump everything into notes. Sources stay as sources. Syntheses emerge when you have enough connected notes.",
|
|
2335
|
+
"- **Set metadata intentionally.** Use source: agent when you write, source: human when the human dictates. Use review_state: draft for first passes. Set durability to match the note's role.",
|
|
2336
|
+
"- **Prefer read tools and resources first.** Resources (granite://vault/overview, granite://notes/{slug}) are lightweight reads. Use tools for writes and complex queries."
|
|
2337
|
+
].join("\n")
|
|
2598
2338
|
}
|
|
2599
2339
|
);
|
|
2600
2340
|
registerTools(server, runtime);
|
|
@@ -2665,7 +2405,7 @@ async function withResponseCleanup(response, cleanup) {
|
|
|
2665
2405
|
function registerTools(server, runtime) {
|
|
2666
2406
|
server.registerTool("granite_get_vault_overview", {
|
|
2667
2407
|
title: "Granite Vault Overview",
|
|
2668
|
-
description: "
|
|
2408
|
+
description: "Start here. Get a snapshot of the vault: note counts by type, recent activity, and configuration. Use this to orient yourself before reading or writing notes.",
|
|
2669
2409
|
inputSchema: {
|
|
2670
2410
|
recent_limit: z.number().int().min(1).max(20).optional().describe("How many recent notes to include. Defaults to 10.")
|
|
2671
2411
|
},
|
|
@@ -2680,7 +2420,7 @@ function registerTools(server, runtime) {
|
|
|
2680
2420
|
});
|
|
2681
2421
|
server.registerTool("granite_list_note_types", {
|
|
2682
2422
|
title: "Granite Note Types",
|
|
2683
|
-
description: "List the note types configured in the vault.",
|
|
2423
|
+
description: "List the note types configured in the vault, including their templates, line limits, and instructions. Check this before creating a note to use the right type.",
|
|
2684
2424
|
outputSchema: z.object({
|
|
2685
2425
|
default_type: z.string(),
|
|
2686
2426
|
note_types: z.array(noteTypeInfoSchema)
|
|
@@ -2695,7 +2435,7 @@ function registerTools(server, runtime) {
|
|
|
2695
2435
|
});
|
|
2696
2436
|
server.registerTool("granite_list_notes", {
|
|
2697
2437
|
title: "List Granite Notes",
|
|
2698
|
-
description: "
|
|
2438
|
+
description: "Browse the vault with filters. Use this to find existing notes before creating new ones (avoid duplicates), to review inbox notes that need processing, or to find notes by type/status/source.",
|
|
2699
2439
|
inputSchema: {
|
|
2700
2440
|
type: z.string().optional().describe("Filter by note type."),
|
|
2701
2441
|
status: z.enum(["inbox", "active", "archived"]).optional().describe("Filter by note status."),
|
|
@@ -2717,7 +2457,7 @@ function registerTools(server, runtime) {
|
|
|
2717
2457
|
});
|
|
2718
2458
|
server.registerTool("granite_get_note", {
|
|
2719
2459
|
title: "Get Granite Note",
|
|
2720
|
-
description: "Read a
|
|
2460
|
+
description: "Read a note in full: frontmatter, body, and resolved outgoing wikilinks. Use this to understand a note before updating it, to follow links in the knowledge graph, or to gather context for a synthesis.",
|
|
2721
2461
|
inputSchema: {
|
|
2722
2462
|
slug: z.string().describe("The note slug.")
|
|
2723
2463
|
},
|
|
@@ -2735,7 +2475,7 @@ function registerTools(server, runtime) {
|
|
|
2735
2475
|
});
|
|
2736
2476
|
server.registerTool("granite_search_notes", {
|
|
2737
2477
|
title: "Search Granite Notes",
|
|
2738
|
-
description: "
|
|
2478
|
+
description: "Full-text search across all notes. Use this to research a topic before answering questions, to find related notes before creating a new one, or to discover connections across the vault.",
|
|
2739
2479
|
inputSchema: {
|
|
2740
2480
|
query: z.string().describe("Full-text query for the SQLite FTS index."),
|
|
2741
2481
|
limit: z.number().int().min(1).max(50).optional().describe("Maximum number of search results. Defaults to 10.")
|
|
@@ -2754,7 +2494,7 @@ function registerTools(server, runtime) {
|
|
|
2754
2494
|
});
|
|
2755
2495
|
server.registerTool("granite_create_note", {
|
|
2756
2496
|
title: "Create Granite Note",
|
|
2757
|
-
description: "Create a
|
|
2497
|
+
description: "Create a structured note. Use this when you have a clear title and know the type. For raw captures, prefer granite_capture_note instead. Always search first to avoid duplicates. Returns recommendations \u2014 act on them to strengthen the vault.",
|
|
2758
2498
|
inputSchema: {
|
|
2759
2499
|
title: z.string().describe("Note title."),
|
|
2760
2500
|
type: z.string().optional().describe("Note type. Defaults to the vault default type."),
|
|
@@ -2762,7 +2502,10 @@ function registerTools(server, runtime) {
|
|
|
2762
2502
|
tags: z.array(z.string()).optional().describe("Tags to add immediately."),
|
|
2763
2503
|
aliases: z.array(z.string()).optional().describe("Aliases to add immediately."),
|
|
2764
2504
|
status: z.enum(["inbox", "active", "archived"]).optional().describe("Initial note status."),
|
|
2765
|
-
source: z.enum(["human", "agent", "extraction"]).optional().describe("Initial note source.")
|
|
2505
|
+
source: z.enum(["human", "agent", "extraction"]).optional().describe("Initial note source."),
|
|
2506
|
+
review_state: z.enum(["draft", "reviewed", "locked"]).optional().describe("Initial review state."),
|
|
2507
|
+
durability: z.enum(["canonical", "working", "ephemeral"]).optional().describe("Initial durability."),
|
|
2508
|
+
derived_from: z.array(z.string()).optional().describe("Source note IDs or slugs this note derives from.")
|
|
2766
2509
|
},
|
|
2767
2510
|
outputSchema: z.object({
|
|
2768
2511
|
note: noteDetailsSchema,
|
|
@@ -2771,18 +2514,21 @@ function registerTools(server, runtime) {
|
|
|
2771
2514
|
annotations: writeAnnotations
|
|
2772
2515
|
}, async (args) => {
|
|
2773
2516
|
const result = runtime.createNote(args);
|
|
2774
|
-
return toolResult(result,
|
|
2517
|
+
return toolResult(result, buildWriteSummary("Created", result, runtime));
|
|
2775
2518
|
});
|
|
2776
2519
|
server.registerTool("granite_capture_note", {
|
|
2777
2520
|
title: "Capture Granite Note",
|
|
2778
|
-
description: "Quick-capture
|
|
2521
|
+
description: "Quick-capture from raw text \u2014 the fastest way to get information into the vault. The title is auto-generated. Use this for rapid ingestion (conversations, ideas, observations). The note lands in inbox status, ready to be refined later. Returns recommendations for immediate linking.",
|
|
2779
2522
|
inputSchema: {
|
|
2780
2523
|
text: z.string().describe("Raw capture text."),
|
|
2781
2524
|
type: z.string().optional().describe("Optional note type override. Defaults to the vault default type."),
|
|
2782
2525
|
tags: z.array(z.string()).optional().describe("Tags to add immediately."),
|
|
2783
2526
|
aliases: z.array(z.string()).optional().describe("Aliases to add immediately."),
|
|
2784
2527
|
status: z.enum(["inbox", "active", "archived"]).optional().describe("Initial note status."),
|
|
2785
|
-
source: z.enum(["human", "agent", "extraction"]).optional().describe("Initial note source.")
|
|
2528
|
+
source: z.enum(["human", "agent", "extraction"]).optional().describe("Initial note source."),
|
|
2529
|
+
review_state: z.enum(["draft", "reviewed", "locked"]).optional().describe("Initial review state."),
|
|
2530
|
+
durability: z.enum(["canonical", "working", "ephemeral"]).optional().describe("Initial durability."),
|
|
2531
|
+
derived_from: z.array(z.string()).optional().describe("Source note IDs or slugs this note derives from.")
|
|
2786
2532
|
},
|
|
2787
2533
|
outputSchema: z.object({
|
|
2788
2534
|
note: noteDetailsSchema,
|
|
@@ -2791,11 +2537,11 @@ function registerTools(server, runtime) {
|
|
|
2791
2537
|
annotations: writeAnnotations
|
|
2792
2538
|
}, async (args) => {
|
|
2793
2539
|
const result = runtime.captureNote(args);
|
|
2794
|
-
return toolResult(result,
|
|
2540
|
+
return toolResult(result, buildWriteSummary("Captured", result, runtime));
|
|
2795
2541
|
});
|
|
2796
2542
|
server.registerTool("granite_update_note", {
|
|
2797
2543
|
title: "Update Granite Note",
|
|
2798
|
-
description: "Update
|
|
2544
|
+
description: "Update an existing note. Use this to refine captures into durable notes, add tags and links, change status (inbox\u2192active\u2192archived), append new information, or promote review_state. Use append to add without replacing the body.",
|
|
2799
2545
|
inputSchema: {
|
|
2800
2546
|
slug: z.string().describe("Slug of the note to update."),
|
|
2801
2547
|
title: z.string().optional().describe("Replace the note title."),
|
|
@@ -2804,7 +2550,10 @@ function registerTools(server, runtime) {
|
|
|
2804
2550
|
tags: z.array(z.string()).optional().describe("Tags to add."),
|
|
2805
2551
|
aliases: z.array(z.string()).optional().describe("Aliases to add."),
|
|
2806
2552
|
status: z.enum(["inbox", "active", "archived"]).optional().describe("New note status."),
|
|
2807
|
-
source: z.enum(["human", "agent", "extraction"]).optional().describe("New note source.")
|
|
2553
|
+
source: z.enum(["human", "agent", "extraction"]).optional().describe("New note source."),
|
|
2554
|
+
review_state: z.enum(["draft", "reviewed", "locked"]).optional().describe("New review state."),
|
|
2555
|
+
durability: z.enum(["canonical", "working", "ephemeral"]).optional().describe("New durability."),
|
|
2556
|
+
derived_from: z.array(z.string()).optional().describe("Source note IDs or slugs this note derives from.")
|
|
2808
2557
|
},
|
|
2809
2558
|
outputSchema: z.object({
|
|
2810
2559
|
note: noteDetailsSchema,
|
|
@@ -2813,11 +2562,11 @@ function registerTools(server, runtime) {
|
|
|
2813
2562
|
annotations: writeAnnotations
|
|
2814
2563
|
}, async ({ slug, ...updates }) => {
|
|
2815
2564
|
const result = runtime.updateNote(slug, updates);
|
|
2816
|
-
return toolResult(result,
|
|
2565
|
+
return toolResult(result, buildWriteSummary("Updated", result, runtime));
|
|
2817
2566
|
});
|
|
2818
2567
|
server.registerTool("granite_get_backlinks", {
|
|
2819
2568
|
title: "Granite Backlinks",
|
|
2820
|
-
description: "
|
|
2569
|
+
description: "Find all notes that link to a given note. Use this to understand a note's role in the knowledge graph, to find context for a synthesis, or to check the impact before modifying a note.",
|
|
2821
2570
|
inputSchema: {
|
|
2822
2571
|
slug: z.string().describe("Slug of the target note.")
|
|
2823
2572
|
},
|
|
@@ -2835,7 +2584,7 @@ function registerTools(server, runtime) {
|
|
|
2835
2584
|
});
|
|
2836
2585
|
server.registerTool("granite_suggest_links", {
|
|
2837
2586
|
title: "Suggest Granite Links",
|
|
2838
|
-
description: "
|
|
2587
|
+
description: "Find unlinked mentions \u2014 places where a note references another note's title or alias without a [[wikilink]]. Use this after creating or updating a note to strengthen the knowledge graph.",
|
|
2839
2588
|
inputSchema: {
|
|
2840
2589
|
slug: z.string().describe("Slug of the note to inspect.")
|
|
2841
2590
|
},
|
|
@@ -2853,7 +2602,7 @@ function registerTools(server, runtime) {
|
|
|
2853
2602
|
});
|
|
2854
2603
|
server.registerTool("granite_recommend_note_actions", {
|
|
2855
2604
|
title: "Recommend Granite Actions",
|
|
2856
|
-
description: "
|
|
2605
|
+
description: "The heart of Granite's compile loop. Returns suggested links, tags, content additions, and next notes to create. Call this after every write operation and act on the results \u2014 this is how the vault grows into a connected knowledge base instead of a pile of files.",
|
|
2857
2606
|
inputSchema: {
|
|
2858
2607
|
slug: z.string().describe("Slug of the note to analyze.")
|
|
2859
2608
|
},
|
|
@@ -2871,7 +2620,7 @@ function registerTools(server, runtime) {
|
|
|
2871
2620
|
});
|
|
2872
2621
|
server.registerTool("granite_run_doctor", {
|
|
2873
2622
|
title: "Run Granite Doctor",
|
|
2874
|
-
description: "
|
|
2623
|
+
description: "Lint the vault. Finds broken wikilinks, missing frontmatter fields, notes exceeding line limits, and other structural issues. Run this periodically to maintain vault integrity \u2014 part of the lint phase of the knowledge loop.",
|
|
2875
2624
|
outputSchema: z.object({
|
|
2876
2625
|
issues: z.array(doctorIssueSchema),
|
|
2877
2626
|
counts: z.object({
|
|
@@ -2951,7 +2700,7 @@ function registerResources(server, runtime) {
|
|
|
2951
2700
|
function registerPrompts(server, runtime) {
|
|
2952
2701
|
server.registerPrompt("granite_refine_note", {
|
|
2953
2702
|
title: "Refine Granite Note",
|
|
2954
|
-
description: "
|
|
2703
|
+
description: "Turn a raw capture or draft into a durable, well-structured note. Use this on inbox notes to promote them to active status.",
|
|
2955
2704
|
argsSchema: {
|
|
2956
2705
|
slug: z.string().describe("Slug of the note to refine.")
|
|
2957
2706
|
}
|
|
@@ -2965,7 +2714,7 @@ function registerPrompts(server, runtime) {
|
|
|
2965
2714
|
content: {
|
|
2966
2715
|
type: "text",
|
|
2967
2716
|
text: [
|
|
2968
|
-
"Refine the attached Granite note into a durable, well-structured
|
|
2717
|
+
"Refine the attached Granite note into a durable, well-structured note.",
|
|
2969
2718
|
"Keep the meaning intact, avoid inventing facts, preserve useful wikilinks, and use Granite-style headings when appropriate."
|
|
2970
2719
|
].join(" ")
|
|
2971
2720
|
}
|
|
@@ -2986,7 +2735,7 @@ function registerPrompts(server, runtime) {
|
|
|
2986
2735
|
});
|
|
2987
2736
|
server.registerPrompt("granite_review_connections", {
|
|
2988
2737
|
title: "Review Granite Connections",
|
|
2989
|
-
description: "
|
|
2738
|
+
description: "Analyze a note's place in the knowledge graph and propose better links, tags, and the most valuable follow-up note to create.",
|
|
2990
2739
|
argsSchema: {
|
|
2991
2740
|
slug: z.string().describe("Slug of the note to inspect.")
|
|
2992
2741
|
}
|
|
@@ -3028,6 +2777,142 @@ function registerPrompts(server, runtime) {
|
|
|
3028
2777
|
]
|
|
3029
2778
|
};
|
|
3030
2779
|
});
|
|
2780
|
+
server.registerPrompt("granite_process_inbox", {
|
|
2781
|
+
title: "Process Granite Inbox",
|
|
2782
|
+
description: "Review all inbox notes and decide what to do with each: refine into a durable note, merge into an existing note, archive, or delete. This is the compile phase of the knowledge loop."
|
|
2783
|
+
}, async () => {
|
|
2784
|
+
const inboxNotes = runtime.listNotes({ status: "inbox", limit: 50 });
|
|
2785
|
+
const overview = runtime.getVaultOverview(5);
|
|
2786
|
+
return {
|
|
2787
|
+
description: "Process the inbox: triage, refine, and compile captured notes.",
|
|
2788
|
+
messages: [
|
|
2789
|
+
{
|
|
2790
|
+
role: "user",
|
|
2791
|
+
content: {
|
|
2792
|
+
type: "text",
|
|
2793
|
+
text: [
|
|
2794
|
+
"Process the Granite inbox. For each inbox note below, decide:",
|
|
2795
|
+
"",
|
|
2796
|
+
"1. **Refine** \u2014 If it contains a durable idea, refine it into a well-structured note (update body, set status: active, review_state: reviewed).",
|
|
2797
|
+
"2. **Merge** \u2014 If it overlaps with an existing note, append the new information to that note and archive the inbox note.",
|
|
2798
|
+
"3. **Promote to source** \u2014 If it's raw reference material, update its type to source and refine it.",
|
|
2799
|
+
"4. **Archive** \u2014 If it's been processed or is no longer relevant, set status: archived.",
|
|
2800
|
+
"",
|
|
2801
|
+
"After processing each note, run granite_suggest_links to find connections, and granite_recommend_note_actions to see what to write next.",
|
|
2802
|
+
"",
|
|
2803
|
+
`Vault context: ${overview.note_count} notes total (${Object.entries(overview.notes_by_type).map(([t, c]) => `${c} ${t}s`).join(", ")}).`,
|
|
2804
|
+
"",
|
|
2805
|
+
`Inbox notes to process (${inboxNotes.length}):`,
|
|
2806
|
+
"",
|
|
2807
|
+
...inboxNotes.map((n) => `- **${n.slug}**: "${n.title}" (type: ${n.type}, created: ${n.created})`)
|
|
2808
|
+
].join("\n")
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
]
|
|
2812
|
+
};
|
|
2813
|
+
});
|
|
2814
|
+
server.registerPrompt("granite_compile_synthesis", {
|
|
2815
|
+
title: "Compile Granite Synthesis",
|
|
2816
|
+
description: "Analyze a set of related notes and compile them into a synthesis note \u2014 the most valuable operation in the knowledge loop. Creates durable compiled knowledge from scattered notes.",
|
|
2817
|
+
argsSchema: {
|
|
2818
|
+
topic: z.string().describe("The topic or theme to synthesize notes around.")
|
|
2819
|
+
}
|
|
2820
|
+
}, async ({ topic }) => {
|
|
2821
|
+
const searchResults = runtime.search(topic, 20);
|
|
2822
|
+
const noteDetails = searchResults.map((r) => {
|
|
2823
|
+
try {
|
|
2824
|
+
return runtime.getNote(r.slug);
|
|
2825
|
+
} catch {
|
|
2826
|
+
return null;
|
|
2827
|
+
}
|
|
2828
|
+
}).filter(Boolean);
|
|
2829
|
+
return {
|
|
2830
|
+
description: `Compile a synthesis on "${topic}" from ${noteDetails.length} related notes.`,
|
|
2831
|
+
messages: [
|
|
2832
|
+
{
|
|
2833
|
+
role: "user",
|
|
2834
|
+
content: {
|
|
2835
|
+
type: "text",
|
|
2836
|
+
text: [
|
|
2837
|
+
`Compile a synthesis note on "${topic}" from the related notes below.`,
|
|
2838
|
+
"",
|
|
2839
|
+
"A good synthesis:",
|
|
2840
|
+
"- Connects ideas across multiple sources and notes",
|
|
2841
|
+
"- Identifies patterns, tensions, and open questions",
|
|
2842
|
+
"- Uses [[wikilinks]] to link back to every source note",
|
|
2843
|
+
"- Sets derived_from to the slugs of the notes it draws from",
|
|
2844
|
+
"- Is more valuable than any individual note because it creates new understanding",
|
|
2845
|
+
"",
|
|
2846
|
+
"Steps:",
|
|
2847
|
+
"1. Read the related notes below",
|
|
2848
|
+
"2. Create a synthesis note (type: synthesis) with granite_create_note",
|
|
2849
|
+
"3. Write a body that connects the key ideas, with [[wikilinks]] to sources",
|
|
2850
|
+
"4. Set derived_from to the source note slugs",
|
|
2851
|
+
"5. Run granite_recommend_note_actions on the new synthesis",
|
|
2852
|
+
"",
|
|
2853
|
+
`Related notes (${noteDetails.length} found for "${topic}"):`,
|
|
2854
|
+
"",
|
|
2855
|
+
...noteDetails.map((n) => n ? [
|
|
2856
|
+
`### ${n.title} (${n.slug}, type: ${n.type})`,
|
|
2857
|
+
n.body.slice(0, 500) + (n.body.length > 500 ? "..." : ""),
|
|
2858
|
+
""
|
|
2859
|
+
].join("\n") : "")
|
|
2860
|
+
].join("\n")
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
]
|
|
2864
|
+
};
|
|
2865
|
+
});
|
|
2866
|
+
server.registerPrompt("granite_vault_health_review", {
|
|
2867
|
+
title: "Granite Vault Health Review",
|
|
2868
|
+
description: "Run a comprehensive vault review: structural health, content gaps, orphan notes, and suggestions for strengthening the knowledge graph. The lint phase of the knowledge loop."
|
|
2869
|
+
}, async () => {
|
|
2870
|
+
const doctorResult = runtime.runDoctor();
|
|
2871
|
+
const overview = runtime.getVaultOverview(10);
|
|
2872
|
+
const notes = runtime.listNotes({ limit: 200 });
|
|
2873
|
+
const orphanCandidates = [];
|
|
2874
|
+
for (const note of notes.slice(0, 50)) {
|
|
2875
|
+
const backlinks = runtime.getBacklinks(note.slug);
|
|
2876
|
+
if (backlinks.length === 0) {
|
|
2877
|
+
orphanCandidates.push(`${note.slug} ("${note.title}", type: ${note.type})`);
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
return {
|
|
2881
|
+
description: "Comprehensive vault health review.",
|
|
2882
|
+
messages: [
|
|
2883
|
+
{
|
|
2884
|
+
role: "user",
|
|
2885
|
+
content: {
|
|
2886
|
+
type: "text",
|
|
2887
|
+
text: [
|
|
2888
|
+
"Run a comprehensive health review of this Granite vault and take action to improve it.",
|
|
2889
|
+
"",
|
|
2890
|
+
"## Structural Issues (from granite_run_doctor)",
|
|
2891
|
+
"",
|
|
2892
|
+
doctorResult.issues.length === 0 ? "No structural issues found." : doctorResult.issues.map((i) => `- [${i.level}] ${i.file}: ${i.message}`).join("\n"),
|
|
2893
|
+
"",
|
|
2894
|
+
"## Vault Overview",
|
|
2895
|
+
"",
|
|
2896
|
+
`${overview.note_count} notes: ${Object.entries(overview.notes_by_type).map(([t, c]) => `${c} ${t}s`).join(", ")}`,
|
|
2897
|
+
"",
|
|
2898
|
+
"## Orphan Notes (no backlinks)",
|
|
2899
|
+
"",
|
|
2900
|
+
orphanCandidates.length === 0 ? "No orphans found in the first 50 notes." : orphanCandidates.map((o) => `- ${o}`).join("\n"),
|
|
2901
|
+
"",
|
|
2902
|
+
"## Actions to Take",
|
|
2903
|
+
"",
|
|
2904
|
+
"1. Fix any structural errors reported by doctor",
|
|
2905
|
+
"2. For each orphan note, use granite_suggest_links and granite_recommend_note_actions to find connections",
|
|
2906
|
+
"3. If orphan notes should be linked from other notes, update those notes to add [[wikilinks]]",
|
|
2907
|
+
"4. Look for clusters of notes that could be compiled into a synthesis",
|
|
2908
|
+
"5. Check if any inbox notes need processing",
|
|
2909
|
+
"6. Report a summary of what you found and what you fixed"
|
|
2910
|
+
].join("\n")
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
]
|
|
2914
|
+
};
|
|
2915
|
+
});
|
|
3031
2916
|
}
|
|
3032
2917
|
function toolResult(structuredContent, summary) {
|
|
3033
2918
|
return {
|
|
@@ -3044,6 +2929,18 @@ function createNoteResourceLink(name, uri) {
|
|
|
3044
2929
|
description: "Read the markdown resource for this note."
|
|
3045
2930
|
};
|
|
3046
2931
|
}
|
|
2932
|
+
function buildWriteSummary(verb, result, runtime) {
|
|
2933
|
+
const lines = [`${verb} "${result.note.title}" (${result.note.slug}).`];
|
|
2934
|
+
const instructions = runtime.getTypeInstructions(result.note.type);
|
|
2935
|
+
if (instructions) {
|
|
2936
|
+
lines.push("", `Type guidance for "${result.note.type}": ${instructions}`);
|
|
2937
|
+
}
|
|
2938
|
+
const recSummary = recommendationSummary(result.recommendations);
|
|
2939
|
+
if (recSummary) {
|
|
2940
|
+
lines.push("", `Recommendations: ${recSummary}. Act on these to strengthen the vault.`);
|
|
2941
|
+
}
|
|
2942
|
+
return lines.join("\n");
|
|
2943
|
+
}
|
|
3047
2944
|
function recommendationSummary(recommendations) {
|
|
3048
2945
|
return [
|
|
3049
2946
|
`${recommendations.links.length} link suggestion(s)`,
|
|
@@ -3158,8 +3055,8 @@ function corsHeaders(origin, allowedOrigins) {
|
|
|
3158
3055
|
}
|
|
3159
3056
|
|
|
3160
3057
|
// src/mcp/runtime.ts
|
|
3161
|
-
import
|
|
3162
|
-
import
|
|
3058
|
+
import fs12 from "fs";
|
|
3059
|
+
import path8 from "path";
|
|
3163
3060
|
var GraniteMcpRuntime = class {
|
|
3164
3061
|
vaultRoot;
|
|
3165
3062
|
config;
|
|
@@ -3168,7 +3065,7 @@ var GraniteMcpRuntime = class {
|
|
|
3168
3065
|
lastSignature;
|
|
3169
3066
|
lastIndexCheckAt = 0;
|
|
3170
3067
|
constructor(vaultRoot, options = {}) {
|
|
3171
|
-
this.vaultRoot =
|
|
3068
|
+
this.vaultRoot = path8.resolve(vaultRoot);
|
|
3172
3069
|
this.config = loadConfig(this.vaultRoot);
|
|
3173
3070
|
this.db = openDatabase(this.vaultRoot);
|
|
3174
3071
|
this.indexCheckIntervalMs = options.indexCheckIntervalMs ?? 1500;
|
|
@@ -3280,7 +3177,10 @@ var GraniteMcpRuntime = class {
|
|
|
3280
3177
|
tags: input.tags,
|
|
3281
3178
|
aliases: input.aliases,
|
|
3282
3179
|
status: input.status,
|
|
3283
|
-
source: input.source
|
|
3180
|
+
source: input.source,
|
|
3181
|
+
review_state: input.review_state,
|
|
3182
|
+
durability: input.durability,
|
|
3183
|
+
derived_from: input.derived_from
|
|
3284
3184
|
};
|
|
3285
3185
|
if (hasMetadataMutations(metadataMutations)) {
|
|
3286
3186
|
this.applyMutations(created.filepath, metadataMutations);
|
|
@@ -3301,7 +3201,10 @@ var GraniteMcpRuntime = class {
|
|
|
3301
3201
|
tags: input.tags,
|
|
3302
3202
|
aliases: input.aliases,
|
|
3303
3203
|
status: input.status,
|
|
3304
|
-
source: input.source
|
|
3204
|
+
source: input.source,
|
|
3205
|
+
review_state: input.review_state,
|
|
3206
|
+
durability: input.durability,
|
|
3207
|
+
derived_from: input.derived_from
|
|
3305
3208
|
});
|
|
3306
3209
|
}
|
|
3307
3210
|
updateNote(slug, input) {
|
|
@@ -3321,7 +3224,7 @@ var GraniteMcpRuntime = class {
|
|
|
3321
3224
|
};
|
|
3322
3225
|
}
|
|
3323
3226
|
readVaultConfigRaw() {
|
|
3324
|
-
return
|
|
3227
|
+
return fs12.readFileSync(path8.join(this.vaultRoot, CONFIG_FILENAME), "utf-8");
|
|
3325
3228
|
}
|
|
3326
3229
|
readVaultTypesJson() {
|
|
3327
3230
|
return JSON.stringify({
|
|
@@ -3334,12 +3237,15 @@ var GraniteMcpRuntime = class {
|
|
|
3334
3237
|
}
|
|
3335
3238
|
readNoteMarkdown(slug) {
|
|
3336
3239
|
const note = this.requireNote(slug);
|
|
3337
|
-
return
|
|
3240
|
+
return fs12.readFileSync(note.filepath, "utf-8");
|
|
3338
3241
|
}
|
|
3339
3242
|
completeSlugs(prefix = "") {
|
|
3340
3243
|
const normalizedPrefix = prefix.toLowerCase();
|
|
3341
3244
|
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
3245
|
}
|
|
3246
|
+
getTypeInstructions(typeName) {
|
|
3247
|
+
return this.config.note_types[typeName]?.instructions;
|
|
3248
|
+
}
|
|
3343
3249
|
noteResourceUri(slug) {
|
|
3344
3250
|
return `granite://notes/${encodeURIComponent(slug)}`;
|
|
3345
3251
|
}
|
|
@@ -3390,12 +3296,15 @@ var GraniteMcpRuntime = class {
|
|
|
3390
3296
|
aliases: note.frontmatter.aliases,
|
|
3391
3297
|
status: note.frontmatter.status,
|
|
3392
3298
|
source: note.frontmatter.source,
|
|
3299
|
+
review_state: note.frontmatter.review_state,
|
|
3300
|
+
durability: note.frontmatter.durability,
|
|
3301
|
+
derived_from: note.frontmatter.derived_from,
|
|
3393
3302
|
filepath: note.filepath,
|
|
3394
3303
|
resource_uri: this.noteResourceUri(note.slug)
|
|
3395
3304
|
};
|
|
3396
3305
|
}
|
|
3397
3306
|
applyMutations(filepath, input) {
|
|
3398
|
-
const raw =
|
|
3307
|
+
const raw = fs12.readFileSync(filepath, "utf-8");
|
|
3399
3308
|
const { frontmatter, body: existingBody } = parseFrontmatter(raw);
|
|
3400
3309
|
let body = existingBody;
|
|
3401
3310
|
if (input.title !== void 0) {
|
|
@@ -3415,6 +3324,17 @@ var GraniteMcpRuntime = class {
|
|
|
3415
3324
|
validateSource(input.source);
|
|
3416
3325
|
frontmatter.source = input.source;
|
|
3417
3326
|
}
|
|
3327
|
+
if (input.review_state !== void 0) {
|
|
3328
|
+
validateReviewState(input.review_state);
|
|
3329
|
+
frontmatter.review_state = input.review_state;
|
|
3330
|
+
}
|
|
3331
|
+
if (input.durability !== void 0) {
|
|
3332
|
+
validateDurability(input.durability);
|
|
3333
|
+
frontmatter.durability = input.durability;
|
|
3334
|
+
}
|
|
3335
|
+
if (input.derived_from !== void 0) {
|
|
3336
|
+
frontmatter.derived_from = [...input.derived_from];
|
|
3337
|
+
}
|
|
3418
3338
|
if (input.body !== void 0) {
|
|
3419
3339
|
body = ensureTrailingNewline(input.body);
|
|
3420
3340
|
}
|
|
@@ -3424,7 +3344,7 @@ ${input.append}
|
|
|
3424
3344
|
`;
|
|
3425
3345
|
}
|
|
3426
3346
|
frontmatter.modified = (/* @__PURE__ */ new Date()).toISOString();
|
|
3427
|
-
|
|
3347
|
+
fs12.writeFileSync(filepath, serializeFrontmatter(frontmatter, body), "utf-8");
|
|
3428
3348
|
}
|
|
3429
3349
|
afterWrite(slug, fullRebuild) {
|
|
3430
3350
|
if (fullRebuild) {
|
|
@@ -3449,16 +3369,16 @@ ${input.append}
|
|
|
3449
3369
|
let noteCount = 0;
|
|
3450
3370
|
let latestNoteMtimeMs = 0;
|
|
3451
3371
|
for (const typeConfig of Object.values(this.config.note_types)) {
|
|
3452
|
-
const folder =
|
|
3453
|
-
if (!
|
|
3372
|
+
const folder = path8.join(this.vaultRoot, typeConfig.folder);
|
|
3373
|
+
if (!fs12.existsSync(folder)) {
|
|
3454
3374
|
continue;
|
|
3455
3375
|
}
|
|
3456
|
-
for (const entry of
|
|
3376
|
+
for (const entry of fs12.readdirSync(folder)) {
|
|
3457
3377
|
if (!entry.endsWith(".md")) {
|
|
3458
3378
|
continue;
|
|
3459
3379
|
}
|
|
3460
3380
|
noteCount += 1;
|
|
3461
|
-
const stat =
|
|
3381
|
+
const stat = fs12.statSync(path8.join(folder, entry));
|
|
3462
3382
|
latestNoteMtimeMs = Math.max(latestNoteMtimeMs, stat.mtimeMs);
|
|
3463
3383
|
}
|
|
3464
3384
|
}
|
|
@@ -3470,8 +3390,8 @@ ${input.append}
|
|
|
3470
3390
|
};
|
|
3471
3391
|
}
|
|
3472
3392
|
getConfigMtimeMs() {
|
|
3473
|
-
const configPath =
|
|
3474
|
-
return
|
|
3393
|
+
const configPath = path8.join(this.vaultRoot, CONFIG_FILENAME);
|
|
3394
|
+
return fs12.existsSync(configPath) ? fs12.statSync(configPath).mtimeMs : 0;
|
|
3475
3395
|
}
|
|
3476
3396
|
getLastRebuildMs() {
|
|
3477
3397
|
const value = this.getMetaValue("last_rebuild");
|
|
@@ -3504,15 +3424,215 @@ function mergeUnique(existing, incoming) {
|
|
|
3504
3424
|
return [...merged];
|
|
3505
3425
|
}
|
|
3506
3426
|
function hasMetadataMutations(input) {
|
|
3507
|
-
return (input.tags?.length ?? 0) > 0 || (input.aliases?.length ?? 0) > 0 || input.status !== void 0 || input.source !== void 0;
|
|
3427
|
+
return (input.tags?.length ?? 0) > 0 || (input.aliases?.length ?? 0) > 0 || input.status !== void 0 || input.source !== void 0 || input.review_state !== void 0 || input.durability !== void 0 || (input.derived_from?.length ?? 0) > 0;
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3430
|
+
// src/tunnel.ts
|
|
3431
|
+
import { spawn } from "child_process";
|
|
3432
|
+
async function startTunnel(options) {
|
|
3433
|
+
if (options.provider === "cloudflare") {
|
|
3434
|
+
return startCloudflareTunnel(options);
|
|
3435
|
+
}
|
|
3436
|
+
return startTailscaleFunnel(options);
|
|
3437
|
+
}
|
|
3438
|
+
function stopTunnel(tunnelProcess) {
|
|
3439
|
+
if (!tunnelProcess.killed) {
|
|
3440
|
+
tunnelProcess.kill("SIGTERM");
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
function spawnError(binary, installUrl, err) {
|
|
3444
|
+
if (err.code === "ENOENT") {
|
|
3445
|
+
return new Error(`${binary} is not installed. Install it from ${installUrl}`);
|
|
3446
|
+
}
|
|
3447
|
+
return new Error(`Failed to start ${binary}: ${err.message}`);
|
|
3448
|
+
}
|
|
3449
|
+
function startCloudflareTunnel(options) {
|
|
3450
|
+
return new Promise((resolve, reject) => {
|
|
3451
|
+
const localUrl = `http://${options.host}:${options.port}`;
|
|
3452
|
+
const child = spawn("cloudflared", ["tunnel", "--url", localUrl], {
|
|
3453
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
3454
|
+
});
|
|
3455
|
+
let settled = false;
|
|
3456
|
+
const settle = (fn) => {
|
|
3457
|
+
if (settled) return;
|
|
3458
|
+
settled = true;
|
|
3459
|
+
fn();
|
|
3460
|
+
};
|
|
3461
|
+
const onData = (chunk) => {
|
|
3462
|
+
const match = chunk.toString().match(/https:\/\/[^\s]+\.trycloudflare\.com/);
|
|
3463
|
+
if (match) {
|
|
3464
|
+
clearTimeout(timer);
|
|
3465
|
+
settle(() => resolve({ url: match[0], process: child }));
|
|
3466
|
+
}
|
|
3467
|
+
};
|
|
3468
|
+
child.stderr?.on("data", onData);
|
|
3469
|
+
child.stdout?.on("data", onData);
|
|
3470
|
+
child.on("error", (err) => {
|
|
3471
|
+
clearTimeout(timer);
|
|
3472
|
+
settle(() => reject(spawnError(
|
|
3473
|
+
"cloudflared",
|
|
3474
|
+
"https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
|
|
3475
|
+
err
|
|
3476
|
+
)));
|
|
3477
|
+
});
|
|
3478
|
+
child.on("exit", (code) => {
|
|
3479
|
+
clearTimeout(timer);
|
|
3480
|
+
settle(() => reject(new Error(`cloudflared exited with code ${code} before establishing tunnel`)));
|
|
3481
|
+
});
|
|
3482
|
+
const timer = setTimeout(() => {
|
|
3483
|
+
child.kill("SIGTERM");
|
|
3484
|
+
settle(() => reject(new Error("Timed out waiting for cloudflared to establish tunnel")));
|
|
3485
|
+
}, 3e4);
|
|
3486
|
+
});
|
|
3487
|
+
}
|
|
3488
|
+
async function getTailscaleHostname() {
|
|
3489
|
+
return new Promise((resolve, reject) => {
|
|
3490
|
+
const status = spawn("tailscale", ["status", "--json"], {
|
|
3491
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
3492
|
+
});
|
|
3493
|
+
let settled = false;
|
|
3494
|
+
let output = "";
|
|
3495
|
+
status.stdout?.on("data", (chunk) => {
|
|
3496
|
+
output += chunk.toString();
|
|
3497
|
+
});
|
|
3498
|
+
status.on("error", (err) => {
|
|
3499
|
+
if (!settled) {
|
|
3500
|
+
settled = true;
|
|
3501
|
+
reject(spawnError("tailscale", "https://tailscale.com/download", err));
|
|
3502
|
+
}
|
|
3503
|
+
});
|
|
3504
|
+
status.on("exit", (code) => {
|
|
3505
|
+
if (settled) return;
|
|
3506
|
+
settled = true;
|
|
3507
|
+
if (code !== 0) {
|
|
3508
|
+
reject(new Error(`tailscale status exited with code ${code}. Is Tailscale running?`));
|
|
3509
|
+
return;
|
|
3510
|
+
}
|
|
3511
|
+
try {
|
|
3512
|
+
const parsed = JSON.parse(output);
|
|
3513
|
+
const dnsName = parsed.Self?.DNSName ?? "";
|
|
3514
|
+
const hostname = dnsName.replace(/\.$/, "");
|
|
3515
|
+
if (!hostname) {
|
|
3516
|
+
reject(new Error("Could not determine Tailscale hostname"));
|
|
3517
|
+
} else {
|
|
3518
|
+
resolve(hostname);
|
|
3519
|
+
}
|
|
3520
|
+
} catch {
|
|
3521
|
+
reject(new Error("Failed to parse tailscale status output"));
|
|
3522
|
+
}
|
|
3523
|
+
});
|
|
3524
|
+
});
|
|
3525
|
+
}
|
|
3526
|
+
async function startTailscaleFunnel(options) {
|
|
3527
|
+
const hostname = await getTailscaleHostname();
|
|
3528
|
+
const publicUrl = `https://${hostname}:${options.port}`;
|
|
3529
|
+
return new Promise((resolve, reject) => {
|
|
3530
|
+
const child = spawn("tailscale", ["funnel", String(options.port)], {
|
|
3531
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
3532
|
+
});
|
|
3533
|
+
let settled = false;
|
|
3534
|
+
child.on("error", (err) => {
|
|
3535
|
+
if (!settled) {
|
|
3536
|
+
settled = true;
|
|
3537
|
+
reject(new Error(`Failed to start tailscale funnel: ${err.message}`));
|
|
3538
|
+
}
|
|
3539
|
+
});
|
|
3540
|
+
const timer = setTimeout(() => {
|
|
3541
|
+
settled = true;
|
|
3542
|
+
resolve({ url: publicUrl, process: child });
|
|
3543
|
+
}, 2e3);
|
|
3544
|
+
child.on("exit", (exitCode) => {
|
|
3545
|
+
if (!settled) {
|
|
3546
|
+
settled = true;
|
|
3547
|
+
clearTimeout(timer);
|
|
3548
|
+
reject(new Error(
|
|
3549
|
+
`tailscale funnel exited with code ${exitCode}. Make sure Tailscale Funnel is enabled: https://tailscale.com/kb/1223/funnel`
|
|
3550
|
+
));
|
|
3551
|
+
}
|
|
3552
|
+
});
|
|
3553
|
+
});
|
|
3508
3554
|
}
|
|
3509
3555
|
|
|
3510
3556
|
// src/commands/mcp.ts
|
|
3557
|
+
function pidPath(vaultRoot) {
|
|
3558
|
+
return path9.join(getGraniteDir(vaultRoot), "mcp.pid");
|
|
3559
|
+
}
|
|
3560
|
+
function urlPath(vaultRoot) {
|
|
3561
|
+
return path9.join(getGraniteDir(vaultRoot), "mcp.url");
|
|
3562
|
+
}
|
|
3563
|
+
function ensureGraniteDir(vaultRoot) {
|
|
3564
|
+
fs13.mkdirSync(getGraniteDir(vaultRoot), { recursive: true });
|
|
3565
|
+
}
|
|
3566
|
+
function readPid(vaultRoot) {
|
|
3567
|
+
try {
|
|
3568
|
+
const raw = fs13.readFileSync(pidPath(vaultRoot), "utf-8").trim();
|
|
3569
|
+
const pid = Number.parseInt(raw, 10);
|
|
3570
|
+
if (Number.isNaN(pid)) return null;
|
|
3571
|
+
try {
|
|
3572
|
+
process.kill(pid, 0);
|
|
3573
|
+
return pid;
|
|
3574
|
+
} catch {
|
|
3575
|
+
return null;
|
|
3576
|
+
}
|
|
3577
|
+
} catch {
|
|
3578
|
+
return null;
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
function writeDaemonState(vaultRoot, pid, url) {
|
|
3582
|
+
ensureGraniteDir(vaultRoot);
|
|
3583
|
+
fs13.writeFileSync(pidPath(vaultRoot), String(pid), "utf-8");
|
|
3584
|
+
fs13.writeFileSync(urlPath(vaultRoot), url, "utf-8");
|
|
3585
|
+
}
|
|
3586
|
+
function cleanupFiles(vaultRoot) {
|
|
3587
|
+
try {
|
|
3588
|
+
fs13.unlinkSync(pidPath(vaultRoot));
|
|
3589
|
+
} catch {
|
|
3590
|
+
}
|
|
3591
|
+
try {
|
|
3592
|
+
fs13.unlinkSync(urlPath(vaultRoot));
|
|
3593
|
+
} catch {
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3511
3596
|
async function mcpCommand(options) {
|
|
3512
|
-
const
|
|
3597
|
+
const tunnel = options.tunnel;
|
|
3598
|
+
const transport = tunnel ? "http" : parseTransport(options.transport);
|
|
3513
3599
|
const vaultRoot = resolveVaultRoot(options.vault);
|
|
3600
|
+
if (options.background) {
|
|
3601
|
+
if (transport !== "http") {
|
|
3602
|
+
console.error("--background requires --transport http or --tunnel");
|
|
3603
|
+
process.exit(1);
|
|
3604
|
+
}
|
|
3605
|
+
const running = readPid(vaultRoot);
|
|
3606
|
+
if (running) {
|
|
3607
|
+
console.error(`MCP server already running (PID ${running}). Use "granite mcp stop" first.`);
|
|
3608
|
+
process.exit(1);
|
|
3609
|
+
}
|
|
3610
|
+
const logFile = path9.join(getGraniteDir(vaultRoot), "mcp.log");
|
|
3611
|
+
ensureGraniteDir(vaultRoot);
|
|
3612
|
+
const fd = fs13.openSync(logFile, "a");
|
|
3613
|
+
const child = spawn2(process.execPath, process.argv.slice(1), {
|
|
3614
|
+
detached: true,
|
|
3615
|
+
stdio: ["ignore", fd, fd],
|
|
3616
|
+
env: { ...process.env, GRANITE_MCP_DAEMONIZED: "1" }
|
|
3617
|
+
});
|
|
3618
|
+
child.unref();
|
|
3619
|
+
fs13.closeSync(fd);
|
|
3620
|
+
if (!child.pid) {
|
|
3621
|
+
console.error("Failed to start background process");
|
|
3622
|
+
process.exit(1);
|
|
3623
|
+
}
|
|
3624
|
+
writeDaemonState(vaultRoot, child.pid, "(starting...)");
|
|
3625
|
+
console.error(`MCP server starting in background (PID ${child.pid})`);
|
|
3626
|
+
console.error(`Log: ${logFile}`);
|
|
3627
|
+
console.error(`Stop with: granite mcp stop`);
|
|
3628
|
+
process.exit(0);
|
|
3629
|
+
}
|
|
3630
|
+
const isDaemon = process.env.GRANITE_MCP_DAEMONIZED === "1";
|
|
3514
3631
|
const runtime = new GraniteMcpRuntime(vaultRoot);
|
|
3632
|
+
let tunnelProcess = null;
|
|
3515
3633
|
const shutdown = () => {
|
|
3634
|
+
if (tunnelProcess) stopTunnel(tunnelProcess);
|
|
3635
|
+
if (isDaemon) cleanupFiles(vaultRoot);
|
|
3516
3636
|
runtime.close();
|
|
3517
3637
|
process.exit(0);
|
|
3518
3638
|
};
|
|
@@ -3520,23 +3640,74 @@ async function mcpCommand(options) {
|
|
|
3520
3640
|
process.once("SIGTERM", shutdown);
|
|
3521
3641
|
if (transport === "http") {
|
|
3522
3642
|
const port = parsePort(options.port);
|
|
3643
|
+
const host = options.host ?? "127.0.0.1";
|
|
3644
|
+
const localUrl = `http://${host}:${port}/mcp`;
|
|
3523
3645
|
startGraniteMcpHttpServer(runtime, {
|
|
3524
|
-
host
|
|
3646
|
+
host,
|
|
3525
3647
|
port,
|
|
3526
3648
|
allowedOrigins: options.allowOrigin ?? [],
|
|
3527
3649
|
jsonResponse: options.jsonResponse ?? false
|
|
3528
3650
|
});
|
|
3651
|
+
if (isDaemon) writeDaemonState(vaultRoot, process.pid, localUrl);
|
|
3652
|
+
if (tunnel) {
|
|
3653
|
+
try {
|
|
3654
|
+
console.error(`
|
|
3655
|
+
Starting ${tunnel} tunnel...`);
|
|
3656
|
+
const result = await startTunnel({ provider: tunnel, port, host });
|
|
3657
|
+
tunnelProcess = result.process;
|
|
3658
|
+
const publicMcpUrl = `${result.url}/mcp`;
|
|
3659
|
+
console.error(`
|
|
3660
|
+
\u{1F687} Tunnel active!
|
|
3661
|
+
`);
|
|
3662
|
+
console.error(` Public MCP endpoint: ${publicMcpUrl}`);
|
|
3663
|
+
console.error(` Provider: ${tunnel}`);
|
|
3664
|
+
console.error(`
|
|
3665
|
+
Use this URL in your remote MCP client configuration.
|
|
3666
|
+
`);
|
|
3667
|
+
if (isDaemon) writeDaemonState(vaultRoot, process.pid, publicMcpUrl);
|
|
3668
|
+
} catch (err) {
|
|
3669
|
+
console.error(`
|
|
3670
|
+
Failed to start tunnel: ${err instanceof Error ? err.message : err}`);
|
|
3671
|
+
shutdown();
|
|
3672
|
+
}
|
|
3673
|
+
}
|
|
3529
3674
|
return;
|
|
3530
3675
|
}
|
|
3531
3676
|
await startGraniteMcpStdioServer(runtime);
|
|
3532
3677
|
}
|
|
3678
|
+
function mcpStopCommand(options) {
|
|
3679
|
+
const vaultRoot = resolveVaultRoot(options.vault);
|
|
3680
|
+
const pid = readPid(vaultRoot);
|
|
3681
|
+
if (!pid) {
|
|
3682
|
+
console.error("No running MCP server found.");
|
|
3683
|
+
process.exit(1);
|
|
3684
|
+
}
|
|
3685
|
+
process.kill(pid, "SIGTERM");
|
|
3686
|
+
cleanupFiles(vaultRoot);
|
|
3687
|
+
console.error(`MCP server stopped (PID ${pid}).`);
|
|
3688
|
+
}
|
|
3689
|
+
function mcpStatusCommand(options) {
|
|
3690
|
+
const vaultRoot = resolveVaultRoot(options.vault);
|
|
3691
|
+
const pid = readPid(vaultRoot);
|
|
3692
|
+
if (!pid) {
|
|
3693
|
+
console.error("MCP server is not running.");
|
|
3694
|
+
process.exit(1);
|
|
3695
|
+
}
|
|
3696
|
+
let url = "";
|
|
3697
|
+
try {
|
|
3698
|
+
url = fs13.readFileSync(urlPath(vaultRoot), "utf-8").trim();
|
|
3699
|
+
} catch {
|
|
3700
|
+
}
|
|
3701
|
+
console.error(`MCP server is running (PID ${pid})`);
|
|
3702
|
+
if (url) console.error(` Endpoint: ${url}`);
|
|
3703
|
+
}
|
|
3533
3704
|
function resolveVaultRoot(explicitVault) {
|
|
3534
3705
|
const fromEnv = process.env.GRANITE_VAULT;
|
|
3535
3706
|
if (explicitVault) {
|
|
3536
|
-
return
|
|
3707
|
+
return path9.resolve(explicitVault);
|
|
3537
3708
|
}
|
|
3538
3709
|
if (fromEnv) {
|
|
3539
|
-
return
|
|
3710
|
+
return path9.resolve(fromEnv);
|
|
3540
3711
|
}
|
|
3541
3712
|
return requireVaultRoot();
|
|
3542
3713
|
}
|
|
@@ -3559,141 +3730,62 @@ function parsePort(value) {
|
|
|
3559
3730
|
return parsed;
|
|
3560
3731
|
}
|
|
3561
3732
|
|
|
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
|
-
|
|
3638
3733
|
// src/index.ts
|
|
3639
3734
|
var program = new Command();
|
|
3640
|
-
program.name("granite").description("Granite \u2014 a local-first
|
|
3641
|
-
program.command("init").description("
|
|
3735
|
+
program.name("granite").description("Granite \u2014 a local-first knowledge compiler. capture \u2192 compile \u2192 query \u2192 output \u2192 lint").version(GRANITE_VERSION);
|
|
3736
|
+
program.command("init").description("Create a new vault and start the knowledge loop").action(() => {
|
|
3642
3737
|
initVault();
|
|
3643
3738
|
});
|
|
3644
|
-
program.command("
|
|
3739
|
+
program.command("status").description("See where your vault stands and what to do next").option("--json", "Output as JSON").action((options) => {
|
|
3740
|
+
statusCommand(options);
|
|
3741
|
+
});
|
|
3742
|
+
program.command("new").description("Create a structured note \u2014 search first to avoid duplicates").argument("<title>", "Note title").option("-t, --type <type>", "Note type (note, source, synthesis, output)").option("--source <source>", "Who created it (human, agent, extraction)").option("--status <status>", "Lifecycle state (inbox, active, archived)").option("--review-state <state>", "Editorial state (draft, reviewed, locked)").option("--durability <durability>", "Knowledge permanence (canonical, working, ephemeral)").option("--derived-from <refs>", "Provenance: slugs this note derives from (comma-separated)").option("--json", "Output as JSON (agent-friendly)").action((title, options) => {
|
|
3645
3743
|
newNote(title, options);
|
|
3646
3744
|
});
|
|
3647
|
-
program.command("add").description("Quick-capture
|
|
3745
|
+
program.command("add").description("Quick-capture raw text into the vault \u2014 the fastest way to get something in").argument("[text]", "Note text (or pipe via stdin)").option("--json", "Output as JSON (agent-friendly)").action((text, options) => {
|
|
3648
3746
|
addNote(text, options);
|
|
3649
3747
|
});
|
|
3650
|
-
program.command("list").alias("ls").description("
|
|
3748
|
+
program.command("list").alias("ls").description("Browse the vault \u2014 filter by type, status, source, or date").option("-t, --type <type>", "Filter by note type").option("-s, --status <status>", "Filter by status (inbox, active, archived)").option("--source <source>", "Filter by source (human, agent, extraction)").option("--since <date>", "Only notes modified since (YYYY-MM-DD)").option("--json [fields]", "Output as JSON; optionally select fields (e.g. --json slug,title,type)").action((options) => {
|
|
3651
3749
|
listCommand(options);
|
|
3652
3750
|
});
|
|
3653
|
-
program.command("
|
|
3654
|
-
editCommand(slug, options);
|
|
3655
|
-
});
|
|
3656
|
-
program.command("open").description("Open a note in your editor (alias for edit)").argument("<slug>", "Note slug").action((slug) => {
|
|
3657
|
-
openNote(slug);
|
|
3658
|
-
});
|
|
3659
|
-
program.command("show").alias("cat").description("Display a note by slug").argument("<slug>", "Note slug").option("--json", "Output as JSON (agent-friendly)").option("--body", "Output body only (no frontmatter)").action((slug, options) => {
|
|
3751
|
+
program.command("show").alias("cat").description("Read a note in full \u2014 frontmatter, body, and metadata").argument("<slug>", "Note slug").option("--json", "Output as JSON (agent-friendly)").option("--body", "Output body only (for piping)").action((slug, options) => {
|
|
3660
3752
|
showCommand(slug, options);
|
|
3661
3753
|
});
|
|
3662
|
-
program.command("search").description("
|
|
3754
|
+
program.command("search").description("Research a topic across the vault \u2014 use before creating to avoid duplicates").argument("<query>", "Search query").option("--json", "Output as JSON (agent-friendly)").action((query, options) => {
|
|
3663
3755
|
searchCommand(query, options);
|
|
3664
3756
|
});
|
|
3665
|
-
program.command("
|
|
3757
|
+
program.command("edit").description("Refine a note \u2014 append, rewrite, promote status, add tags and links").argument("<slug>", "Note slug").option("--body <text>", "Replace the note body").option("--append <text>", "Append text to the note body").option("--title <title>", "Update the note title").option("--tag <tags>", "Add tags (comma-separated)").option("--alias <aliases>", "Add aliases (comma-separated)").option("--status <status>", "Set status (inbox, active, archived)").option("--source <source>", "Set source (human, agent, extraction)").option("--review-state <state>", "Set review state (draft, reviewed, locked)").option("--durability <durability>", "Set durability (canonical, working, ephemeral)").option("--derived-from <refs>", "Set derived_from references (comma-separated slugs)").action((slug, options) => {
|
|
3758
|
+
editCommand(slug, options);
|
|
3759
|
+
});
|
|
3760
|
+
program.command("open").description("Open a note in your editor").argument("<slug>", "Note slug").action((slug) => {
|
|
3761
|
+
openNote(slug);
|
|
3762
|
+
});
|
|
3763
|
+
program.command("backlinks").description("See a note's role in the graph \u2014 who links here and why").argument("<slug>", "Note slug").option("--json", "Output as JSON (agent-friendly)").action((slug, options) => {
|
|
3666
3764
|
backlinksCommand(slug, options);
|
|
3667
3765
|
});
|
|
3668
|
-
program.command("suggest-links").description("
|
|
3766
|
+
program.command("suggest-links").description("Find unlinked mentions \u2014 strengthen the graph by adding missed [[wikilinks]]").argument("<slug>", "Note slug").option("--json", "Output as JSON (agent-friendly)").action((slug, options) => {
|
|
3669
3767
|
suggestLinksCommand(slug, options);
|
|
3670
3768
|
});
|
|
3671
|
-
program.command("recommend").description("
|
|
3769
|
+
program.command("recommend").description("The heart of the compile loop \u2014 what to link, tag, and write next").argument("<slug>", "Note slug").option("--json", "Output as JSON (agent-friendly)").action((slug, options) => {
|
|
3672
3770
|
recommendCommand(slug, options);
|
|
3673
3771
|
});
|
|
3674
|
-
program.command("
|
|
3675
|
-
|
|
3772
|
+
program.command("doctor").description("Health-check the vault \u2014 broken links, missing fields, line limit violations").option("--json", "Output as JSON").action((options) => {
|
|
3773
|
+
doctorCommand(options);
|
|
3676
3774
|
});
|
|
3677
|
-
program.command("
|
|
3678
|
-
|
|
3775
|
+
program.command("types").description("See the note types and the natural flow: source \u2192 note \u2192 synthesis \u2192 output").action(() => {
|
|
3776
|
+
typesCommand();
|
|
3679
3777
|
});
|
|
3680
|
-
program.command("serve").description("Start the local web UI").option("-p, --port <port>", "Port number", "4321").action((options) => {
|
|
3778
|
+
program.command("serve").description("Start the local web UI \u2014 browse, search, and visualize the knowledge graph").option("-p, --port <port>", "Port number", "4321").action((options) => {
|
|
3681
3779
|
serveCommand(options);
|
|
3682
3780
|
});
|
|
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) => {
|
|
3781
|
+
var mcpCmd = 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").option("--tunnel <provider>", "Expose MCP over internet via tunnel (cloudflare or tailscale)").option("--background", "Run MCP server as a background daemon").action(async (options) => {
|
|
3684
3782
|
await mcpCommand(options);
|
|
3685
3783
|
});
|
|
3686
|
-
|
|
3687
|
-
|
|
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);
|
|
3784
|
+
mcpCmd.command("stop").description("Stop a background MCP server").option("--vault <path>", "Vault root").action((options) => {
|
|
3785
|
+
mcpStopCommand(options);
|
|
3694
3786
|
});
|
|
3695
|
-
|
|
3696
|
-
|
|
3787
|
+
mcpCmd.command("status").description("Show status of background MCP server").option("--vault <path>", "Vault root").action((options) => {
|
|
3788
|
+
mcpStatusCommand(options);
|
|
3697
3789
|
});
|
|
3698
3790
|
await program.parseAsync();
|
|
3699
3791
|
function collectValues(value, previous) {
|