granite-mem 0.1.1 → 0.1.3
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 +230 -0
- package/dist/index.js +1168 -321
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
|
|
6
6
|
// src/commands/init.ts
|
|
7
|
-
import
|
|
8
|
-
import
|
|
7
|
+
import fs3 from "fs";
|
|
8
|
+
import path3 from "path";
|
|
9
9
|
|
|
10
10
|
// src/core/config.ts
|
|
11
11
|
import fs from "fs";
|
|
@@ -47,7 +47,12 @@ var DEFAULT_CONFIG = {
|
|
|
47
47
|
template: "## Role\n\n## Context\n\nHow I know them, what we work on.\n\n## Contact\n\n## Notes\n\n- \n\n## Links\n\n",
|
|
48
48
|
line_limit: 150,
|
|
49
49
|
warn_only: true,
|
|
50
|
-
instructions: "Fill in their role and context. Add timestamped notes as you interact (meetings, emails, calls). Link to projects, companies, or topics they relate to."
|
|
50
|
+
instructions: "Fill in their role and context. Add timestamped notes as you interact (meetings, emails, calls). Link to projects, companies, or topics they relate to.",
|
|
51
|
+
fields: {
|
|
52
|
+
role: { type: "text", description: "Job title or role" },
|
|
53
|
+
org: { type: "text", description: "Organization or company" },
|
|
54
|
+
contact: { type: "text", description: "Email, Slack, phone" }
|
|
55
|
+
}
|
|
51
56
|
},
|
|
52
57
|
meeting: {
|
|
53
58
|
folder: "notes/meetings",
|
|
@@ -55,7 +60,12 @@ var DEFAULT_CONFIG = {
|
|
|
55
60
|
template: "## Attendees\n\n- \n\n## Agenda\n\n- \n\n## Notes\n\n\n\n## Decisions\n\n- \n\n## Actions\n\n- [ ] \n",
|
|
56
61
|
line_limit: 300,
|
|
57
62
|
warn_only: true,
|
|
58
|
-
instructions: "List attendees as [[person]] links. Capture decisions and action items clearly. Link to relevant projects or topics."
|
|
63
|
+
instructions: "List attendees as [[person]] links. Capture decisions and action items clearly. Link to relevant projects or topics.",
|
|
64
|
+
fields: {
|
|
65
|
+
attendees: { type: "list", of: "wikilink", description: "People present" },
|
|
66
|
+
date: { type: "date", description: "Meeting date" },
|
|
67
|
+
project: { type: "wikilink", description: "Related project" }
|
|
68
|
+
}
|
|
59
69
|
},
|
|
60
70
|
project: {
|
|
61
71
|
folder: "notes/projects",
|
|
@@ -63,7 +73,11 @@ var DEFAULT_CONFIG = {
|
|
|
63
73
|
template: "## Goal\n\n## Status\n\n## People\n\n- \n\n## Key Decisions\n\n- \n\n## Links\n\n",
|
|
64
74
|
line_limit: 300,
|
|
65
75
|
warn_only: true,
|
|
66
|
-
instructions: "Define the goal clearly. Keep status updated. Link to people involved, meeting notes, and related permanent notes."
|
|
76
|
+
instructions: "Define the goal clearly. Keep status updated. Link to people involved, meeting notes, and related permanent notes.",
|
|
77
|
+
fields: {
|
|
78
|
+
people: { type: "list", of: "wikilink", description: "Team members involved" },
|
|
79
|
+
project_status: { type: "enum", options: ["planning", "active", "paused", "completed"], default: "active", description: "Project lifecycle" }
|
|
80
|
+
}
|
|
67
81
|
},
|
|
68
82
|
decision: {
|
|
69
83
|
folder: "notes/decisions",
|
|
@@ -71,7 +85,12 @@ var DEFAULT_CONFIG = {
|
|
|
71
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",
|
|
72
86
|
line_limit: 200,
|
|
73
87
|
warn_only: false,
|
|
74
|
-
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."
|
|
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" }
|
|
93
|
+
}
|
|
75
94
|
}
|
|
76
95
|
},
|
|
77
96
|
defaults: {
|
|
@@ -97,55 +116,75 @@ function loadConfig(vaultRoot) {
|
|
|
97
116
|
return parsed;
|
|
98
117
|
}
|
|
99
118
|
|
|
119
|
+
// src/core/vault.ts
|
|
120
|
+
import fs2 from "fs";
|
|
121
|
+
import os from "os";
|
|
122
|
+
import path2 from "path";
|
|
123
|
+
var DEFAULT_VAULT_DIRNAME = ".granite";
|
|
124
|
+
function hasVaultConfig(dir) {
|
|
125
|
+
return fs2.existsSync(path2.join(dir, CONFIG_FILENAME));
|
|
126
|
+
}
|
|
127
|
+
function getDefaultVaultRoot(homeDir = os.homedir()) {
|
|
128
|
+
return path2.resolve(homeDir, DEFAULT_VAULT_DIRNAME);
|
|
129
|
+
}
|
|
130
|
+
function findVaultRoot(from) {
|
|
131
|
+
let dir = from ? path2.resolve(from) : process.cwd();
|
|
132
|
+
while (true) {
|
|
133
|
+
if (hasVaultConfig(dir)) {
|
|
134
|
+
return dir;
|
|
135
|
+
}
|
|
136
|
+
const parent = path2.dirname(dir);
|
|
137
|
+
if (parent === dir) break;
|
|
138
|
+
dir = parent;
|
|
139
|
+
}
|
|
140
|
+
const defaultVaultRoot = getDefaultVaultRoot();
|
|
141
|
+
return hasVaultConfig(defaultVaultRoot) ? defaultVaultRoot : null;
|
|
142
|
+
}
|
|
143
|
+
function requireVaultRoot(from) {
|
|
144
|
+
const root = findVaultRoot(from);
|
|
145
|
+
if (!root) {
|
|
146
|
+
throw new Error(`No Granite vault found. Run "mem init" to create ${getDefaultVaultRoot()}.`);
|
|
147
|
+
}
|
|
148
|
+
return root;
|
|
149
|
+
}
|
|
150
|
+
function getIndexDbPath(vaultRoot) {
|
|
151
|
+
if (path2.basename(path2.resolve(vaultRoot)) === DEFAULT_VAULT_DIRNAME) {
|
|
152
|
+
return path2.join(vaultRoot, "index.db");
|
|
153
|
+
}
|
|
154
|
+
return path2.join(vaultRoot, ".granite", "index.db");
|
|
155
|
+
}
|
|
156
|
+
|
|
100
157
|
// src/commands/init.ts
|
|
101
|
-
function initVault(dir) {
|
|
102
|
-
const targetDir =
|
|
103
|
-
|
|
158
|
+
function initVault(dir = getDefaultVaultRoot()) {
|
|
159
|
+
const targetDir = path3.resolve(dir);
|
|
160
|
+
fs3.mkdirSync(targetDir, { recursive: true });
|
|
161
|
+
if (fs3.existsSync(path3.join(targetDir, CONFIG_FILENAME))) {
|
|
104
162
|
console.error(`Vault already exists in ${targetDir}`);
|
|
105
163
|
process.exit(1);
|
|
106
164
|
}
|
|
107
165
|
writeDefaultConfig(targetDir);
|
|
108
|
-
|
|
166
|
+
fs3.mkdirSync(path3.dirname(getIndexDbPath(targetDir)), { recursive: true });
|
|
109
167
|
const config = loadConfig(targetDir);
|
|
110
168
|
for (const typeConfig of Object.values(config.note_types)) {
|
|
111
|
-
|
|
169
|
+
fs3.mkdirSync(path3.join(targetDir, typeConfig.folder), { recursive: true });
|
|
112
170
|
}
|
|
113
171
|
console.log(`Vault initialized in ${targetDir}`);
|
|
114
172
|
console.log("");
|
|
115
173
|
console.log("Created:");
|
|
116
174
|
console.log(` ${CONFIG_FILENAME}`);
|
|
117
|
-
|
|
175
|
+
const indexDir = path3.relative(targetDir, path3.dirname(getIndexDbPath(targetDir)));
|
|
176
|
+
if (indexDir) {
|
|
177
|
+
console.log(` ${indexDir}/`);
|
|
178
|
+
}
|
|
118
179
|
for (const [name, typeConfig] of Object.entries(config.note_types)) {
|
|
119
180
|
console.log(` ${typeConfig.folder}/ (${name})`);
|
|
120
181
|
}
|
|
121
182
|
console.log("");
|
|
122
|
-
console.log('Next: mem new
|
|
183
|
+
console.log('Next: mem new "My first note" --type fleeting');
|
|
123
184
|
}
|
|
124
185
|
|
|
125
|
-
// src/
|
|
126
|
-
import
|
|
127
|
-
import path3 from "path";
|
|
128
|
-
function findVaultRoot(from) {
|
|
129
|
-
let dir = from ? path3.resolve(from) : process.cwd();
|
|
130
|
-
while (true) {
|
|
131
|
-
if (fs3.existsSync(path3.join(dir, CONFIG_FILENAME))) {
|
|
132
|
-
return dir;
|
|
133
|
-
}
|
|
134
|
-
const parent = path3.dirname(dir);
|
|
135
|
-
if (parent === dir) return null;
|
|
136
|
-
dir = parent;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
function requireVaultRoot(from) {
|
|
140
|
-
const root = findVaultRoot(from);
|
|
141
|
-
if (!root) {
|
|
142
|
-
throw new Error('Not inside a Granite vault. Run "mem init" to create one.');
|
|
143
|
-
}
|
|
144
|
-
return root;
|
|
145
|
-
}
|
|
146
|
-
function getIndexDbPath(vaultRoot) {
|
|
147
|
-
return path3.join(vaultRoot, ".granite", "index.db");
|
|
148
|
-
}
|
|
186
|
+
// src/commands/new.ts
|
|
187
|
+
import fs6 from "fs";
|
|
149
188
|
|
|
150
189
|
// src/core/note.ts
|
|
151
190
|
import fs4 from "fs";
|
|
@@ -162,19 +201,31 @@ import matter from "gray-matter";
|
|
|
162
201
|
function parseFrontmatter(content) {
|
|
163
202
|
const { data, content: body } = matter(content);
|
|
164
203
|
const fm = {
|
|
165
|
-
id: data.id
|
|
166
|
-
title: data.title
|
|
167
|
-
type: data.type
|
|
168
|
-
created: data.created
|
|
169
|
-
modified: data.modified
|
|
204
|
+
id: toFrontmatterString(data.id),
|
|
205
|
+
title: toFrontmatterString(data.title),
|
|
206
|
+
type: toFrontmatterString(data.type),
|
|
207
|
+
created: toFrontmatterString(data.created),
|
|
208
|
+
modified: toFrontmatterString(data.modified),
|
|
170
209
|
tags: data.tags ?? [],
|
|
171
|
-
aliases: data.aliases ?? []
|
|
210
|
+
aliases: data.aliases ?? [],
|
|
211
|
+
status: data.status ?? "active",
|
|
212
|
+
source: data.source ?? "human"
|
|
172
213
|
};
|
|
214
|
+
for (const [key, value] of Object.entries(data)) {
|
|
215
|
+
if (!(key in fm)) {
|
|
216
|
+
fm[key] = value;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
173
219
|
return { frontmatter: fm, body };
|
|
174
220
|
}
|
|
175
221
|
function serializeFrontmatter(fm, body) {
|
|
176
222
|
return matter.stringify(body, fm);
|
|
177
223
|
}
|
|
224
|
+
function toFrontmatterString(value) {
|
|
225
|
+
if (value === null || value === void 0) return "";
|
|
226
|
+
if (value instanceof Date) return value.toISOString();
|
|
227
|
+
return String(value);
|
|
228
|
+
}
|
|
178
229
|
|
|
179
230
|
// src/core/note.ts
|
|
180
231
|
function createNote(vaultRoot, config, typeName, title, bodyOverride) {
|
|
@@ -211,7 +262,9 @@ function createNote(vaultRoot, config, typeName, title, bodyOverride) {
|
|
|
211
262
|
created: now,
|
|
212
263
|
modified: now,
|
|
213
264
|
tags: [],
|
|
214
|
-
aliases: []
|
|
265
|
+
aliases: [],
|
|
266
|
+
status: "active",
|
|
267
|
+
source: "human"
|
|
215
268
|
};
|
|
216
269
|
const body = bodyOverride ?? typeConfig.template;
|
|
217
270
|
const content = serializeFrontmatter(frontmatter, body);
|
|
@@ -262,153 +315,22 @@ function jsonSuccess(data) {
|
|
|
262
315
|
function jsonError(error) {
|
|
263
316
|
return JSON.stringify({ success: false, error }, null, 2);
|
|
264
317
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
function
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const resolvedType = options.type || config.defaults.note_type;
|
|
271
|
-
const typeConfig = config.note_types[resolvedType];
|
|
272
|
-
const bodyOverride = typeConfig?.slug_format === "date" ? title + "\n" : void 0;
|
|
273
|
-
const note = createNote(vaultRoot, config, resolvedType, title, bodyOverride);
|
|
274
|
-
const lines = note.body.split("\n").length;
|
|
275
|
-
const overLimit = typeConfig && typeConfig.line_limit && lines > typeConfig.line_limit;
|
|
276
|
-
const textToScan = (note.body + " " + title).toLowerCase();
|
|
277
|
-
const allNotes = listNotes(vaultRoot, config).filter((n) => n.slug !== note.slug);
|
|
278
|
-
const suggestions = [];
|
|
279
|
-
for (const other of allNotes) {
|
|
280
|
-
const otherTitle = other.frontmatter.title.toLowerCase();
|
|
281
|
-
if (otherTitle.length < 3) continue;
|
|
282
|
-
if (textToScan.includes(otherTitle)) {
|
|
283
|
-
suggestions.push({ slug: other.slug, title: other.frontmatter.title, type: other.frontmatter.type });
|
|
284
|
-
continue;
|
|
285
|
-
}
|
|
286
|
-
for (const alias of other.frontmatter.aliases) {
|
|
287
|
-
if (alias.length < 3) continue;
|
|
288
|
-
if (textToScan.includes(alias.toLowerCase())) {
|
|
289
|
-
suggestions.push({ slug: other.slug, title: other.frontmatter.title, type: other.frontmatter.type });
|
|
290
|
-
break;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
if (options.json) {
|
|
295
|
-
console.log(jsonSuccess({
|
|
296
|
-
slug: note.slug,
|
|
297
|
-
title: note.frontmatter.title,
|
|
298
|
-
type: resolvedType,
|
|
299
|
-
filepath: note.filepath,
|
|
300
|
-
suggestions
|
|
301
|
-
}));
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
if (overLimit) {
|
|
305
|
-
const action = typeConfig.warn_only ? "Warning" : "Error";
|
|
306
|
-
console.warn(`${action}: Note exceeds ${typeConfig.line_limit} line limit for type "${resolvedType}"`);
|
|
307
|
-
}
|
|
308
|
-
console.log(note.filepath);
|
|
309
|
-
if (typeConfig?.instructions) {
|
|
310
|
-
console.log("");
|
|
311
|
-
console.log(` \u{1F4A1} ${typeConfig.instructions}`);
|
|
312
|
-
}
|
|
313
|
-
if (suggestions.length > 0) {
|
|
314
|
-
console.log("");
|
|
315
|
-
console.log(" \u{1F517} Linked notes detected:");
|
|
316
|
-
for (const s of suggestions) {
|
|
317
|
-
console.log(` [[${s.slug}]] \u2014 ${s.title} (${s.type})`);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// src/commands/add.ts
|
|
323
|
-
import fs5 from "fs";
|
|
324
|
-
function addNote(text, options = {}) {
|
|
325
|
-
const vaultRoot = requireVaultRoot();
|
|
326
|
-
const config = loadConfig(vaultRoot);
|
|
327
|
-
const typeName = config.defaults.note_type;
|
|
328
|
-
let content;
|
|
329
|
-
if (text) {
|
|
330
|
-
content = text;
|
|
331
|
-
} else if (!process.stdin.isTTY) {
|
|
332
|
-
content = fs5.readFileSync(0, "utf-8").trim();
|
|
333
|
-
} else {
|
|
334
|
-
console.error('Usage: mem add "some text" or echo "text" | mem add');
|
|
335
|
-
process.exit(1);
|
|
336
|
-
}
|
|
337
|
-
if (!content) {
|
|
338
|
-
console.error("No content provided.");
|
|
339
|
-
process.exit(1);
|
|
340
|
-
}
|
|
341
|
-
const firstLine = content.split("\n")[0];
|
|
342
|
-
const title = firstLine.length > 60 ? firstLine.slice(0, 60).trim() + "..." : firstLine;
|
|
343
|
-
const note = createNote(vaultRoot, config, typeName, title, content + "\n");
|
|
344
|
-
if (options.json) {
|
|
345
|
-
console.log(jsonSuccess({
|
|
346
|
-
slug: note.slug,
|
|
347
|
-
title: note.frontmatter.title,
|
|
348
|
-
type: typeName,
|
|
349
|
-
filepath: note.filepath
|
|
350
|
-
}));
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
console.log(note.filepath);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// src/commands/open.ts
|
|
357
|
-
import { execSync } from "child_process";
|
|
358
|
-
function openNote(slug) {
|
|
359
|
-
const vaultRoot = requireVaultRoot();
|
|
360
|
-
const config = loadConfig(vaultRoot);
|
|
361
|
-
const note = findNoteBySlug(vaultRoot, config, slug);
|
|
362
|
-
if (!note) {
|
|
363
|
-
console.error(`Note not found: "${slug}"`);
|
|
364
|
-
process.exit(1);
|
|
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(", ")}`);
|
|
365
323
|
}
|
|
366
|
-
const editor = config.defaults.editor === "$EDITOR" ? process.env.EDITOR || "vi" : config.defaults.editor;
|
|
367
|
-
execSync(`${editor} "${note.filepath}"`, { stdio: "inherit" });
|
|
368
324
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
function showCommand(slug, options) {
|
|
373
|
-
const vaultRoot = requireVaultRoot();
|
|
374
|
-
const config = loadConfig(vaultRoot);
|
|
375
|
-
const note = findNoteBySlug(vaultRoot, config, slug);
|
|
376
|
-
if (!note) {
|
|
377
|
-
if (options.json) {
|
|
378
|
-
console.log(jsonError(`Note not found: ${slug}`));
|
|
379
|
-
} else {
|
|
380
|
-
console.error(`Note not found: ${slug}`);
|
|
381
|
-
}
|
|
382
|
-
process.exit(1);
|
|
383
|
-
}
|
|
384
|
-
if (options.json) {
|
|
385
|
-
console.log(jsonSuccess({
|
|
386
|
-
slug: note.slug,
|
|
387
|
-
title: note.frontmatter.title,
|
|
388
|
-
type: note.frontmatter.type,
|
|
389
|
-
created: note.frontmatter.created,
|
|
390
|
-
modified: note.frontmatter.modified,
|
|
391
|
-
tags: note.frontmatter.tags,
|
|
392
|
-
aliases: note.frontmatter.aliases,
|
|
393
|
-
body: note.body,
|
|
394
|
-
filepath: note.filepath
|
|
395
|
-
}));
|
|
396
|
-
return;
|
|
325
|
+
function validateSource(value) {
|
|
326
|
+
if (!VALID_SOURCES.includes(value)) {
|
|
327
|
+
throw new Error(`Invalid source: "${value}". Expected one of: ${VALID_SOURCES.join(", ")}`);
|
|
397
328
|
}
|
|
398
|
-
if (options.body) {
|
|
399
|
-
process.stdout.write(note.body);
|
|
400
|
-
return;
|
|
401
|
-
}
|
|
402
|
-
console.log(`# ${note.frontmatter.title} (${note.slug})`);
|
|
403
|
-
console.log(`# type: ${note.frontmatter.type} | modified: ${note.frontmatter.modified.slice(0, 10)}`);
|
|
404
|
-
console.log("");
|
|
405
|
-
const content = fs6.readFileSync(note.filepath, "utf-8");
|
|
406
|
-
console.log(content);
|
|
407
329
|
}
|
|
408
330
|
|
|
409
331
|
// src/core/index-db.ts
|
|
410
332
|
import Database from "better-sqlite3";
|
|
411
|
-
import
|
|
333
|
+
import fs5 from "fs";
|
|
412
334
|
import path5 from "path";
|
|
413
335
|
|
|
414
336
|
// src/core/wikilinks.ts
|
|
@@ -454,7 +376,7 @@ function resolveWikilinks(links, allNotes) {
|
|
|
454
376
|
}
|
|
455
377
|
|
|
456
378
|
// src/core/index-db.ts
|
|
457
|
-
var SCHEMA_VERSION =
|
|
379
|
+
var SCHEMA_VERSION = 2;
|
|
458
380
|
var SCHEMA_SQL = `
|
|
459
381
|
CREATE TABLE IF NOT EXISTS notes (
|
|
460
382
|
slug TEXT PRIMARY KEY,
|
|
@@ -466,7 +388,9 @@ CREATE TABLE IF NOT EXISTS notes (
|
|
|
466
388
|
tags TEXT,
|
|
467
389
|
aliases TEXT,
|
|
468
390
|
body TEXT NOT NULL,
|
|
469
|
-
filepath TEXT NOT NULL
|
|
391
|
+
filepath TEXT NOT NULL,
|
|
392
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
393
|
+
source TEXT NOT NULL DEFAULT 'human'
|
|
470
394
|
);
|
|
471
395
|
|
|
472
396
|
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
|
|
@@ -505,78 +429,928 @@ CREATE TABLE IF NOT EXISTS links (
|
|
|
505
429
|
CREATE INDEX IF NOT EXISTS idx_links_target ON links(target_slug);
|
|
506
430
|
CREATE INDEX IF NOT EXISTS idx_links_source ON links(source_slug);
|
|
507
431
|
|
|
508
|
-
CREATE TABLE IF NOT EXISTS meta (
|
|
509
|
-
key TEXT PRIMARY KEY,
|
|
510
|
-
value TEXT
|
|
511
|
-
);
|
|
512
|
-
`;
|
|
513
|
-
function createDatabase(dbPath) {
|
|
514
|
-
|
|
515
|
-
const db = new Database(dbPath);
|
|
516
|
-
db.pragma("journal_mode = WAL");
|
|
517
|
-
db.exec(SCHEMA_SQL);
|
|
518
|
-
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run("schema_version", String(SCHEMA_VERSION));
|
|
519
|
-
return db;
|
|
432
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
433
|
+
key TEXT PRIMARY KEY,
|
|
434
|
+
value TEXT
|
|
435
|
+
);
|
|
436
|
+
`;
|
|
437
|
+
function createDatabase(dbPath) {
|
|
438
|
+
fs5.mkdirSync(path5.dirname(dbPath), { recursive: true });
|
|
439
|
+
const db = new Database(dbPath);
|
|
440
|
+
db.pragma("journal_mode = WAL");
|
|
441
|
+
db.exec(SCHEMA_SQL);
|
|
442
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run("schema_version", String(SCHEMA_VERSION));
|
|
443
|
+
return db;
|
|
444
|
+
}
|
|
445
|
+
function openDatabase(vaultRoot) {
|
|
446
|
+
const dbPath = getIndexDbPath(vaultRoot);
|
|
447
|
+
if (!fs5.existsSync(dbPath)) {
|
|
448
|
+
return createDatabase(dbPath);
|
|
449
|
+
}
|
|
450
|
+
const db = new Database(dbPath);
|
|
451
|
+
db.pragma("journal_mode = WAL");
|
|
452
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = ?").get("schema_version");
|
|
453
|
+
const currentVersion = row ? Number(row.value) : 0;
|
|
454
|
+
if (currentVersion < SCHEMA_VERSION) {
|
|
455
|
+
db.close();
|
|
456
|
+
fs5.unlinkSync(dbPath);
|
|
457
|
+
return createDatabase(dbPath);
|
|
458
|
+
}
|
|
459
|
+
return db;
|
|
460
|
+
}
|
|
461
|
+
function rebuildIndex(vaultRoot, config, db) {
|
|
462
|
+
const notes = listNotes(vaultRoot, config);
|
|
463
|
+
db.exec("DELETE FROM links");
|
|
464
|
+
db.exec("DELETE FROM notes");
|
|
465
|
+
db.exec("INSERT INTO notes_fts(notes_fts) VALUES('rebuild')");
|
|
466
|
+
const insertNote = db.prepare(`
|
|
467
|
+
INSERT INTO notes (slug, id, title, type, created, modified, tags, aliases, body, filepath, status, source)
|
|
468
|
+
VALUES (@slug, @id, @title, @type, @created, @modified, @tags, @aliases, @body, @filepath, @status, @source)
|
|
469
|
+
`);
|
|
470
|
+
const insertLink = db.prepare(`
|
|
471
|
+
INSERT INTO links (source_slug, target_slug, target_raw, context)
|
|
472
|
+
VALUES (@source_slug, @target_slug, @target_raw, @context)
|
|
473
|
+
`);
|
|
474
|
+
const transaction = db.transaction(() => {
|
|
475
|
+
for (const note of notes) {
|
|
476
|
+
insertNote.run({
|
|
477
|
+
slug: note.slug,
|
|
478
|
+
id: note.frontmatter.id,
|
|
479
|
+
title: note.frontmatter.title,
|
|
480
|
+
type: note.frontmatter.type,
|
|
481
|
+
created: note.frontmatter.created,
|
|
482
|
+
modified: note.frontmatter.modified,
|
|
483
|
+
tags: JSON.stringify(note.frontmatter.tags),
|
|
484
|
+
aliases: JSON.stringify(note.frontmatter.aliases),
|
|
485
|
+
body: note.body,
|
|
486
|
+
filepath: note.filepath,
|
|
487
|
+
status: note.frontmatter.status ?? "active",
|
|
488
|
+
source: note.frontmatter.source ?? "human"
|
|
489
|
+
});
|
|
490
|
+
const links = parseWikilinks(note.body);
|
|
491
|
+
const resolved = resolveLinksAgainstNotes(links, notes);
|
|
492
|
+
for (const link of resolved) {
|
|
493
|
+
const bodyLines = note.body.split("\n");
|
|
494
|
+
const contextLine = bodyLines.find((l) => l.includes(link.raw)) ?? "";
|
|
495
|
+
insertLink.run({
|
|
496
|
+
source_slug: note.slug,
|
|
497
|
+
target_slug: link.resolved_slug ?? null,
|
|
498
|
+
target_raw: link.target,
|
|
499
|
+
context: contextLine.trim()
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run("last_rebuild", (/* @__PURE__ */ new Date()).toISOString());
|
|
504
|
+
});
|
|
505
|
+
transaction();
|
|
506
|
+
}
|
|
507
|
+
function syncNoteInIndex(vaultRoot, config, db, note) {
|
|
508
|
+
const noteCountInDb = db.prepare("SELECT COUNT(*) as c FROM notes").get();
|
|
509
|
+
const noteCountOnDisk = countNoteFiles(vaultRoot, config);
|
|
510
|
+
const noteExists = db.prepare("SELECT 1 FROM notes WHERE slug = ?").get(note.slug);
|
|
511
|
+
const allowedCount = noteExists ? noteCountOnDisk : noteCountOnDisk - 1;
|
|
512
|
+
if (noteCountInDb.c !== allowedCount) {
|
|
513
|
+
rebuildIndex(vaultRoot, config, db);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
upsertNoteAndLinks(db, note);
|
|
517
|
+
}
|
|
518
|
+
function ensureIndex(vaultRoot, config) {
|
|
519
|
+
const db = openDatabase(vaultRoot);
|
|
520
|
+
if (config.index.auto_rebuild) {
|
|
521
|
+
rebuildIndex(vaultRoot, config, db);
|
|
522
|
+
}
|
|
523
|
+
return db;
|
|
524
|
+
}
|
|
525
|
+
function countNoteFiles(vaultRoot, config) {
|
|
526
|
+
let count = 0;
|
|
527
|
+
for (const typeConfig of Object.values(config.note_types)) {
|
|
528
|
+
const folder = path5.join(vaultRoot, typeConfig.folder);
|
|
529
|
+
if (!fs5.existsSync(folder)) continue;
|
|
530
|
+
count += fs5.readdirSync(folder).filter((file) => file.endsWith(".md")).length;
|
|
531
|
+
}
|
|
532
|
+
return count;
|
|
533
|
+
}
|
|
534
|
+
function upsertNoteAndLinks(db, note) {
|
|
535
|
+
const upsertNote = db.prepare(`
|
|
536
|
+
INSERT INTO notes (slug, id, title, type, created, modified, tags, aliases, body, filepath)
|
|
537
|
+
VALUES (@slug, @id, @title, @type, @created, @modified, @tags, @aliases, @body, @filepath)
|
|
538
|
+
ON CONFLICT(slug) DO UPDATE SET
|
|
539
|
+
id = excluded.id,
|
|
540
|
+
title = excluded.title,
|
|
541
|
+
type = excluded.type,
|
|
542
|
+
created = excluded.created,
|
|
543
|
+
modified = excluded.modified,
|
|
544
|
+
tags = excluded.tags,
|
|
545
|
+
aliases = excluded.aliases,
|
|
546
|
+
body = excluded.body,
|
|
547
|
+
filepath = excluded.filepath
|
|
548
|
+
`);
|
|
549
|
+
const deleteLinks = db.prepare("DELETE FROM links WHERE source_slug = ?");
|
|
550
|
+
const insertLink = db.prepare(`
|
|
551
|
+
INSERT INTO links (source_slug, target_slug, target_raw, context)
|
|
552
|
+
VALUES (@source_slug, @target_slug, @target_raw, @context)
|
|
553
|
+
`);
|
|
554
|
+
const transaction = db.transaction(() => {
|
|
555
|
+
upsertNote.run({
|
|
556
|
+
slug: note.slug,
|
|
557
|
+
id: note.frontmatter.id,
|
|
558
|
+
title: note.frontmatter.title,
|
|
559
|
+
type: note.frontmatter.type,
|
|
560
|
+
created: note.frontmatter.created,
|
|
561
|
+
modified: note.frontmatter.modified,
|
|
562
|
+
tags: JSON.stringify(note.frontmatter.tags),
|
|
563
|
+
aliases: JSON.stringify(note.frontmatter.aliases),
|
|
564
|
+
body: note.body,
|
|
565
|
+
filepath: note.filepath
|
|
566
|
+
});
|
|
567
|
+
deleteLinks.run(note.slug);
|
|
568
|
+
const links = parseWikilinks(note.body);
|
|
569
|
+
for (const link of links) {
|
|
570
|
+
const resolvedSlug = resolveLinkTarget(db, link.target);
|
|
571
|
+
const contextLine = note.body.split("\n").find((line) => line.includes(link.raw)) ?? "";
|
|
572
|
+
insertLink.run({
|
|
573
|
+
source_slug: note.slug,
|
|
574
|
+
target_slug: resolvedSlug,
|
|
575
|
+
target_raw: link.target,
|
|
576
|
+
context: contextLine.trim()
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run("last_rebuild", (/* @__PURE__ */ new Date()).toISOString());
|
|
580
|
+
});
|
|
581
|
+
transaction();
|
|
582
|
+
}
|
|
583
|
+
function resolveLinksAgainstNotes(links, allNotes) {
|
|
584
|
+
return links.map((link) => {
|
|
585
|
+
const targetSlug = slugify(link.target);
|
|
586
|
+
let found = allNotes.find((note) => note.slug === targetSlug);
|
|
587
|
+
if (!found) {
|
|
588
|
+
found = allNotes.find((note) => note.frontmatter.title.toLowerCase() === link.target.toLowerCase());
|
|
589
|
+
}
|
|
590
|
+
if (!found) {
|
|
591
|
+
found = allNotes.find(
|
|
592
|
+
(note) => note.frontmatter.aliases.some((alias) => alias.toLowerCase() === link.target.toLowerCase())
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
return {
|
|
596
|
+
...link,
|
|
597
|
+
resolved: !!found,
|
|
598
|
+
resolved_slug: found?.slug
|
|
599
|
+
};
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
function resolveLinkTarget(db, target) {
|
|
603
|
+
const targetSlug = slugify(target);
|
|
604
|
+
const exact = db.prepare(`
|
|
605
|
+
SELECT slug
|
|
606
|
+
FROM notes
|
|
607
|
+
WHERE slug = ? OR lower(title) = ?
|
|
608
|
+
LIMIT 1
|
|
609
|
+
`).get(targetSlug, target.toLowerCase());
|
|
610
|
+
if (exact) return exact.slug;
|
|
611
|
+
const rows = db.prepare("SELECT slug, aliases FROM notes").all();
|
|
612
|
+
for (const row of rows) {
|
|
613
|
+
try {
|
|
614
|
+
const aliases = JSON.parse(row.aliases || "[]");
|
|
615
|
+
if (aliases.some((alias) => typeof alias === "string" && alias.toLowerCase() === target.toLowerCase())) {
|
|
616
|
+
return row.slug;
|
|
617
|
+
}
|
|
618
|
+
} catch {
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// src/core/suggest.ts
|
|
626
|
+
function suggestLinks(db, note) {
|
|
627
|
+
const existingLinks = new Set(
|
|
628
|
+
parseWikilinks(note.body).map((l) => l.target.toLowerCase())
|
|
629
|
+
);
|
|
630
|
+
const allNotes = db.prepare("SELECT slug, title, aliases FROM notes WHERE slug != ?").all(note.slug);
|
|
631
|
+
const bodyLower = note.body.toLowerCase();
|
|
632
|
+
const suggestions = [];
|
|
633
|
+
for (const other of allNotes) {
|
|
634
|
+
const titleLower = other.title.toLowerCase();
|
|
635
|
+
if (existingLinks.has(titleLower) || existingLinks.has(other.slug)) continue;
|
|
636
|
+
let mentions = 0;
|
|
637
|
+
let idx = 0;
|
|
638
|
+
while ((idx = bodyLower.indexOf(titleLower, idx)) !== -1) {
|
|
639
|
+
mentions++;
|
|
640
|
+
idx += titleLower.length;
|
|
641
|
+
}
|
|
642
|
+
const aliases = JSON.parse(other.aliases || "[]");
|
|
643
|
+
for (const alias of aliases) {
|
|
644
|
+
const aliasLower = alias.toLowerCase();
|
|
645
|
+
if (existingLinks.has(aliasLower)) continue;
|
|
646
|
+
let aidx = 0;
|
|
647
|
+
while ((aidx = bodyLower.indexOf(aliasLower, aidx)) !== -1) {
|
|
648
|
+
mentions++;
|
|
649
|
+
aidx += aliasLower.length;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (mentions > 0) {
|
|
653
|
+
suggestions.push({
|
|
654
|
+
target_slug: other.slug,
|
|
655
|
+
target_title: other.title,
|
|
656
|
+
mentions
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return suggestions.sort((a, b) => b.mentions - a.mentions);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// src/core/recommendations.ts
|
|
664
|
+
var MAX_LINK_RECOMMENDATIONS = 3;
|
|
665
|
+
var MAX_TAG_RECOMMENDATIONS = 3;
|
|
666
|
+
var MAX_ADDITION_RECOMMENDATIONS = 3;
|
|
667
|
+
var STOP_WORDS = /* @__PURE__ */ new Set([
|
|
668
|
+
"the",
|
|
669
|
+
"and",
|
|
670
|
+
"for",
|
|
671
|
+
"with",
|
|
672
|
+
"from",
|
|
673
|
+
"that",
|
|
674
|
+
"this",
|
|
675
|
+
"into",
|
|
676
|
+
"about",
|
|
677
|
+
"your",
|
|
678
|
+
"have",
|
|
679
|
+
"will",
|
|
680
|
+
"them",
|
|
681
|
+
"they",
|
|
682
|
+
"what",
|
|
683
|
+
"when",
|
|
684
|
+
"where",
|
|
685
|
+
"which",
|
|
686
|
+
"their",
|
|
687
|
+
"there",
|
|
688
|
+
"here",
|
|
689
|
+
"then",
|
|
690
|
+
"than",
|
|
691
|
+
"also",
|
|
692
|
+
"just",
|
|
693
|
+
"should",
|
|
694
|
+
"could",
|
|
695
|
+
"would",
|
|
696
|
+
"into",
|
|
697
|
+
"over",
|
|
698
|
+
"under",
|
|
699
|
+
"after",
|
|
700
|
+
"before",
|
|
701
|
+
"using",
|
|
702
|
+
"used",
|
|
703
|
+
"make",
|
|
704
|
+
"made",
|
|
705
|
+
"more",
|
|
706
|
+
"most",
|
|
707
|
+
"some",
|
|
708
|
+
"such",
|
|
709
|
+
"each",
|
|
710
|
+
"many",
|
|
711
|
+
"only",
|
|
712
|
+
"through",
|
|
713
|
+
"between",
|
|
714
|
+
"within",
|
|
715
|
+
"write",
|
|
716
|
+
"notes",
|
|
717
|
+
"note",
|
|
718
|
+
"links",
|
|
719
|
+
"link",
|
|
720
|
+
"summary",
|
|
721
|
+
"details",
|
|
722
|
+
"source",
|
|
723
|
+
"date",
|
|
724
|
+
"role",
|
|
725
|
+
"context",
|
|
726
|
+
"contact",
|
|
727
|
+
"attendees",
|
|
728
|
+
"agenda",
|
|
729
|
+
"decisions",
|
|
730
|
+
"actions",
|
|
731
|
+
"goal",
|
|
732
|
+
"status",
|
|
733
|
+
"people",
|
|
734
|
+
"rationale",
|
|
735
|
+
"decision",
|
|
736
|
+
"options",
|
|
737
|
+
"considered",
|
|
738
|
+
"active",
|
|
739
|
+
"points",
|
|
740
|
+
"take",
|
|
741
|
+
"idea",
|
|
742
|
+
"ideas",
|
|
743
|
+
"project",
|
|
744
|
+
"meeting",
|
|
745
|
+
"person",
|
|
746
|
+
"reference",
|
|
747
|
+
"permanent",
|
|
748
|
+
"fleeting",
|
|
749
|
+
"how",
|
|
750
|
+
"know",
|
|
751
|
+
"work",
|
|
752
|
+
"works",
|
|
753
|
+
"what",
|
|
754
|
+
"prompted",
|
|
755
|
+
"them",
|
|
756
|
+
"what",
|
|
757
|
+
"plus",
|
|
758
|
+
"latest",
|
|
759
|
+
"recent",
|
|
760
|
+
"capture",
|
|
761
|
+
"record",
|
|
762
|
+
"refine",
|
|
763
|
+
"later",
|
|
764
|
+
"local",
|
|
765
|
+
"first",
|
|
766
|
+
"short",
|
|
767
|
+
"clear",
|
|
768
|
+
"split",
|
|
769
|
+
"related",
|
|
770
|
+
"relevant",
|
|
771
|
+
"title",
|
|
772
|
+
"titles"
|
|
773
|
+
]);
|
|
774
|
+
function recommendNote(vaultRoot, config, note, options = {}) {
|
|
775
|
+
const strategy = options.strategy ?? "rebuild";
|
|
776
|
+
const db = strategy === "incremental" ? openDatabase(vaultRoot) : ensureIndex(vaultRoot, config);
|
|
777
|
+
try {
|
|
778
|
+
if (strategy === "incremental") {
|
|
779
|
+
syncNoteInIndex(vaultRoot, config, db, note);
|
|
780
|
+
}
|
|
781
|
+
return getRecommendations(db, note, config);
|
|
782
|
+
} finally {
|
|
783
|
+
db.close();
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
function getRecommendations(db, note, config) {
|
|
787
|
+
const nearbyNotes = getNearbyNotes(db, note);
|
|
788
|
+
const existingTargets = getExistingTargets(db, note);
|
|
789
|
+
const mentionLinks = getMentionRecommendations(db, note);
|
|
790
|
+
const excludedSlugs = new Set(mentionLinks.map((link) => link.slug));
|
|
791
|
+
const similarLinks = getSearchRecommendations(db, note, existingTargets, excludedSlugs);
|
|
792
|
+
const links = [...mentionLinks, ...similarLinks].sort((a, b) => b.rank - a.rank || a.title.localeCompare(b.title)).slice(0, MAX_LINK_RECOMMENDATIONS);
|
|
793
|
+
const allNearbyNotes = mergeNearbyNotes(nearbyNotes, links);
|
|
794
|
+
return {
|
|
795
|
+
additions: getAdditionRecommendations(note, config),
|
|
796
|
+
links: links.map(({ tags: _tags, rank: _rank, ...link }) => link),
|
|
797
|
+
tags: getTagRecommendations(note, allNearbyNotes),
|
|
798
|
+
next_steps: getNextSteps(note, allNearbyNotes)
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
function formatRecommendations(recommendations) {
|
|
802
|
+
const lines = [];
|
|
803
|
+
if (recommendations.additions.length === 0 && recommendations.links.length === 0 && recommendations.tags.length === 0 && recommendations.next_steps.length === 0) {
|
|
804
|
+
return lines;
|
|
805
|
+
}
|
|
806
|
+
if (recommendations.additions.length > 0) {
|
|
807
|
+
lines.push(" Add now:");
|
|
808
|
+
for (const item of recommendations.additions) {
|
|
809
|
+
lines.push(` ${item.text}`);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (recommendations.links.length > 0) {
|
|
813
|
+
lines.push(" Link now:");
|
|
814
|
+
for (const link of recommendations.links) {
|
|
815
|
+
lines.push(` [[${link.slug}]] \u2014 ${link.reason}`);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
if (recommendations.tags.length > 0) {
|
|
819
|
+
lines.push(" Tag now:");
|
|
820
|
+
for (const tag of recommendations.tags) {
|
|
821
|
+
const notes = tag.source_slugs.length === 1 ? "1 nearby note" : `${tag.source_slugs.length} nearby notes`;
|
|
822
|
+
lines.push(` ${tag.tag} \u2014 seen on ${notes}`);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
if (recommendations.next_steps.length > 0) {
|
|
826
|
+
lines.push(" Next note:");
|
|
827
|
+
for (const step of recommendations.next_steps) {
|
|
828
|
+
const hint = step.title_hint ? ` Title hint: ${step.title_hint}.` : "";
|
|
829
|
+
lines.push(` ${step.type} \u2014 ${step.reason}${hint}`);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
return lines;
|
|
833
|
+
}
|
|
834
|
+
function getExistingTargets(db, note) {
|
|
835
|
+
const existing = /* @__PURE__ */ new Set();
|
|
836
|
+
const rows = db.prepare(`
|
|
837
|
+
SELECT target_slug, target_raw
|
|
838
|
+
FROM links
|
|
839
|
+
WHERE source_slug = ?
|
|
840
|
+
`).all(note.slug);
|
|
841
|
+
for (const row of rows) {
|
|
842
|
+
existing.add(row.target_raw.toLowerCase());
|
|
843
|
+
if (row.target_slug) {
|
|
844
|
+
existing.add(row.target_slug.toLowerCase());
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return existing;
|
|
848
|
+
}
|
|
849
|
+
function getMentionRecommendations(db, note) {
|
|
850
|
+
const raw = suggestLinks(db, note);
|
|
851
|
+
const recommendations = [];
|
|
852
|
+
for (const suggestion of raw) {
|
|
853
|
+
const metadata = getNoteMetadata(db, suggestion.target_slug);
|
|
854
|
+
if (!metadata) continue;
|
|
855
|
+
recommendations.push({
|
|
856
|
+
slug: metadata.slug,
|
|
857
|
+
title: metadata.title,
|
|
858
|
+
type: metadata.type,
|
|
859
|
+
reason: `mentioned ${suggestion.mentions} time${suggestion.mentions === 1 ? "" : "s"}`,
|
|
860
|
+
source: "mention",
|
|
861
|
+
tags: metadata.tags,
|
|
862
|
+
rank: 100 + suggestion.mentions
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
return recommendations;
|
|
866
|
+
}
|
|
867
|
+
function getSearchRecommendations(db, note, existingTargets, excludedSlugs) {
|
|
868
|
+
const query = buildSearchQuery(note);
|
|
869
|
+
if (!query) return [];
|
|
870
|
+
const rows = db.prepare(`
|
|
871
|
+
SELECT
|
|
872
|
+
n.slug,
|
|
873
|
+
n.title,
|
|
874
|
+
n.type,
|
|
875
|
+
n.tags,
|
|
876
|
+
bm25(notes_fts, 8.0, 1.5, 0.5) AS score
|
|
877
|
+
FROM notes_fts
|
|
878
|
+
JOIN notes n ON n.rowid = notes_fts.rowid
|
|
879
|
+
WHERE notes_fts MATCH ? AND n.slug != ?
|
|
880
|
+
ORDER BY score
|
|
881
|
+
LIMIT 12
|
|
882
|
+
`).all(query, note.slug);
|
|
883
|
+
const out = [];
|
|
884
|
+
for (const row of rows) {
|
|
885
|
+
if (existingTargets.has(row.slug.toLowerCase()) || excludedSlugs.has(row.slug)) continue;
|
|
886
|
+
out.push({
|
|
887
|
+
slug: row.slug,
|
|
888
|
+
title: row.title,
|
|
889
|
+
type: row.type,
|
|
890
|
+
reason: "shared keywords",
|
|
891
|
+
source: "search",
|
|
892
|
+
tags: parseJsonArray(row.tags),
|
|
893
|
+
rank: Math.max(1, 40 - Math.round(row.score * 10))
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
return out;
|
|
897
|
+
}
|
|
898
|
+
function getTagRecommendations(note, nearbyNotes) {
|
|
899
|
+
const existingTags = new Set(note.frontmatter.tags.map((tag) => tag.toLowerCase()));
|
|
900
|
+
const noteTokens = new Set(tokenize(`${note.frontmatter.title} ${note.body}`));
|
|
901
|
+
const counts = /* @__PURE__ */ new Map();
|
|
902
|
+
for (const nearby of nearbyNotes) {
|
|
903
|
+
const weight = getNearbyWeight(nearby.source);
|
|
904
|
+
for (const tag of nearby.tags) {
|
|
905
|
+
const normalized = tag.trim().toLowerCase();
|
|
906
|
+
if (!normalized || existingTags.has(normalized)) continue;
|
|
907
|
+
const entry = counts.get(normalized) ?? { weight: 0, sourceSlugs: /* @__PURE__ */ new Set() };
|
|
908
|
+
entry.weight += weight;
|
|
909
|
+
if (tagMatchesNoteTokens(normalized, noteTokens)) {
|
|
910
|
+
entry.weight += 2;
|
|
911
|
+
}
|
|
912
|
+
entry.sourceSlugs.add(nearby.slug);
|
|
913
|
+
counts.set(normalized, entry);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return [...counts.entries()].map(([tag, info]) => ({
|
|
917
|
+
tag,
|
|
918
|
+
weight: info.weight,
|
|
919
|
+
source_slugs: [...info.sourceSlugs]
|
|
920
|
+
})).sort((a, b) => b.weight - a.weight || a.tag.localeCompare(b.tag)).slice(0, MAX_TAG_RECOMMENDATIONS);
|
|
921
|
+
}
|
|
922
|
+
function getNextSteps(note, nearbyNotes) {
|
|
923
|
+
const projectLink = nearbyNotes.find((link) => link.type === "project");
|
|
924
|
+
const personLink = nearbyNotes.find((link) => link.type === "person");
|
|
925
|
+
switch (note.frontmatter.type) {
|
|
926
|
+
case "fleeting":
|
|
927
|
+
return [{
|
|
928
|
+
type: "permanent",
|
|
929
|
+
title_hint: note.frontmatter.title,
|
|
930
|
+
reason: "Promote this into a durable note if it keeps mattering."
|
|
931
|
+
}];
|
|
932
|
+
case "reference":
|
|
933
|
+
return [{
|
|
934
|
+
type: "permanent",
|
|
935
|
+
title_hint: `Idea from ${note.frontmatter.title}`,
|
|
936
|
+
reason: "Distill one durable idea from this source."
|
|
937
|
+
}];
|
|
938
|
+
case "person":
|
|
939
|
+
return [{
|
|
940
|
+
type: projectLink ? "meeting" : "project",
|
|
941
|
+
title_hint: projectLink ? `${note.frontmatter.title} + ${projectLink.title}` : `${note.frontmatter.title} collaboration`,
|
|
942
|
+
reason: projectLink ? "Capture the latest interaction connected to this person." : "Anchor this person to a company, project, or collaboration note."
|
|
943
|
+
}];
|
|
944
|
+
case "meeting":
|
|
945
|
+
return [{
|
|
946
|
+
type: "decision",
|
|
947
|
+
title_hint: `Decision from ${note.frontmatter.title}`,
|
|
948
|
+
reason: "Extract the main decision so it can be linked independently."
|
|
949
|
+
}];
|
|
950
|
+
case "project":
|
|
951
|
+
return [{
|
|
952
|
+
type: personLink ? "meeting" : "decision",
|
|
953
|
+
title_hint: personLink ? `${note.frontmatter.title} sync` : `${note.frontmatter.title} decision`,
|
|
954
|
+
reason: personLink ? "Capture the next concrete conversation for this project." : "Record the decision that drives this project forward."
|
|
955
|
+
}];
|
|
956
|
+
case "decision":
|
|
957
|
+
return [{
|
|
958
|
+
type: "project",
|
|
959
|
+
title_hint: `${note.frontmatter.title} rollout`,
|
|
960
|
+
reason: "Attach this decision to the project where it applies."
|
|
961
|
+
}];
|
|
962
|
+
case "permanent":
|
|
963
|
+
return [{
|
|
964
|
+
type: projectLink ? "decision" : "project",
|
|
965
|
+
title_hint: projectLink ? `Decision from ${note.frontmatter.title}` : `${note.frontmatter.title} in practice`,
|
|
966
|
+
reason: projectLink ? "Capture the concrete decision this idea supports." : "Anchor this idea in a project, company, or active thread."
|
|
967
|
+
}];
|
|
968
|
+
default:
|
|
969
|
+
return [];
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
function getAdditionRecommendations(note, config) {
|
|
973
|
+
const recommendations = [];
|
|
974
|
+
const body = note.body;
|
|
975
|
+
const existingLinks = parseWikilinks(body);
|
|
976
|
+
const templateBody = config?.note_types[note.frontmatter.type]?.template ?? "";
|
|
977
|
+
switch (note.frontmatter.type) {
|
|
978
|
+
case "person":
|
|
979
|
+
if (isSectionSparse(body, "Role", templateBody)) {
|
|
980
|
+
recommendations.push({ text: "Add a one-line role, company, or why this person matters." });
|
|
981
|
+
}
|
|
982
|
+
if (isSectionSparse(body, "Context", templateBody)) {
|
|
983
|
+
recommendations.push({ text: "Add how you know them or what you work on together." });
|
|
984
|
+
}
|
|
985
|
+
if (existingLinks.length === 0) {
|
|
986
|
+
recommendations.push({ text: "Link one project, company, or topic in the Links section." });
|
|
987
|
+
}
|
|
988
|
+
break;
|
|
989
|
+
case "reference":
|
|
990
|
+
if (!hasUrl(body) || body.includes("URL or citation here.")) {
|
|
991
|
+
recommendations.push({ text: "Add the source URL or citation." });
|
|
992
|
+
}
|
|
993
|
+
if (!hasMeaningfulBullets(body, "Key Points")) {
|
|
994
|
+
recommendations.push({ text: "Write 2-3 key points in your own words." });
|
|
995
|
+
}
|
|
996
|
+
if (existingLinks.length === 0) {
|
|
997
|
+
recommendations.push({ text: "Link one permanent note, project, or topic this source relates to." });
|
|
998
|
+
}
|
|
999
|
+
break;
|
|
1000
|
+
case "project":
|
|
1001
|
+
if (isSectionSparse(body, "Goal", templateBody)) {
|
|
1002
|
+
recommendations.push({ text: "Add a one-line goal so the project has a clear anchor." });
|
|
1003
|
+
}
|
|
1004
|
+
if (isSectionSparse(body, "People", templateBody)) {
|
|
1005
|
+
recommendations.push({ text: "Link the people involved in the project." });
|
|
1006
|
+
}
|
|
1007
|
+
if (!hasMeaningfulBullets(body, "Key Decisions")) {
|
|
1008
|
+
recommendations.push({ text: "Record one key decision or current status update." });
|
|
1009
|
+
}
|
|
1010
|
+
break;
|
|
1011
|
+
case "meeting":
|
|
1012
|
+
if (!hasAnyWikilinks(body)) {
|
|
1013
|
+
recommendations.push({ text: "Link the attendees directly in the note." });
|
|
1014
|
+
}
|
|
1015
|
+
if (!hasMeaningfulBullets(body, "Actions")) {
|
|
1016
|
+
recommendations.push({ text: "Capture at least one next action." });
|
|
1017
|
+
}
|
|
1018
|
+
if (!hasMeaningfulBullets(body, "Decisions")) {
|
|
1019
|
+
recommendations.push({ text: "Capture one decision or open question." });
|
|
1020
|
+
}
|
|
1021
|
+
break;
|
|
1022
|
+
case "permanent":
|
|
1023
|
+
if (isSectionSparse(body, "Summary", templateBody)) {
|
|
1024
|
+
recommendations.push({ text: "Write a one-line summary before expanding the idea." });
|
|
1025
|
+
}
|
|
1026
|
+
if (existingLinks.length === 0) {
|
|
1027
|
+
recommendations.push({ text: "Link one project, company, person, or adjacent idea." });
|
|
1028
|
+
}
|
|
1029
|
+
break;
|
|
1030
|
+
case "decision":
|
|
1031
|
+
if (isSectionSparse(body, "Context", templateBody)) {
|
|
1032
|
+
recommendations.push({ text: "Add the context that forced this decision." });
|
|
1033
|
+
}
|
|
1034
|
+
if (isSectionSparse(body, "Decision", templateBody)) {
|
|
1035
|
+
recommendations.push({ text: "State the decision in one sentence." });
|
|
1036
|
+
}
|
|
1037
|
+
if (existingLinks.length === 0) {
|
|
1038
|
+
recommendations.push({ text: "Link the project, meeting, or note affected by this decision." });
|
|
1039
|
+
}
|
|
1040
|
+
break;
|
|
1041
|
+
case "fleeting":
|
|
1042
|
+
if (tokenize(body).length < 8) {
|
|
1043
|
+
recommendations.push({ text: "Add one more sentence if this capture is worth keeping." });
|
|
1044
|
+
}
|
|
1045
|
+
break;
|
|
1046
|
+
default:
|
|
1047
|
+
break;
|
|
1048
|
+
}
|
|
1049
|
+
if (config) {
|
|
1050
|
+
const typeTemplate = config.note_types[note.frontmatter.type]?.template ?? "";
|
|
1051
|
+
if (body.trim() === typeTemplate.trim() && recommendations.length === 0) {
|
|
1052
|
+
recommendations.push({ text: "Fill the main sections before asking for related notes." });
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
return recommendations.slice(0, MAX_ADDITION_RECOMMENDATIONS);
|
|
1056
|
+
}
|
|
1057
|
+
function buildSearchQuery(note) {
|
|
1058
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1059
|
+
const titleTokens = tokenize(note.frontmatter.title);
|
|
1060
|
+
const bodyTokens = tokenize(note.body);
|
|
1061
|
+
for (const token of titleTokens) {
|
|
1062
|
+
counts.set(token, (counts.get(token) ?? 0) + 3);
|
|
1063
|
+
}
|
|
1064
|
+
for (const token of bodyTokens) {
|
|
1065
|
+
counts.set(token, (counts.get(token) ?? 0) + 1);
|
|
1066
|
+
}
|
|
1067
|
+
const terms = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([token]) => token).slice(0, 8);
|
|
1068
|
+
return terms.map((token) => `"${token}"`).join(" OR ");
|
|
1069
|
+
}
|
|
1070
|
+
function tokenize(text) {
|
|
1071
|
+
const matches = text.toLowerCase().match(/[\p{L}\p{N}]{3,}/gu) ?? [];
|
|
1072
|
+
return matches.filter((token) => !STOP_WORDS.has(token));
|
|
1073
|
+
}
|
|
1074
|
+
function tagMatchesNoteTokens(tag, noteTokens) {
|
|
1075
|
+
const tagTokens = tokenize(tag.replace(/[-_]/g, " "));
|
|
1076
|
+
return tagTokens.some((token) => noteTokens.has(token));
|
|
1077
|
+
}
|
|
1078
|
+
function getNearbyNotes(db, note) {
|
|
1079
|
+
const bySlug = /* @__PURE__ */ new Map();
|
|
1080
|
+
const outgoingRows = db.prepare(`
|
|
1081
|
+
SELECT DISTINCT n.slug, n.title, n.type, n.tags
|
|
1082
|
+
FROM links l
|
|
1083
|
+
JOIN notes n ON n.slug = l.target_slug
|
|
1084
|
+
WHERE l.source_slug = ?
|
|
1085
|
+
`).all(note.slug);
|
|
1086
|
+
for (const row of outgoingRows) {
|
|
1087
|
+
bySlug.set(row.slug, {
|
|
1088
|
+
slug: row.slug,
|
|
1089
|
+
title: row.title,
|
|
1090
|
+
type: row.type,
|
|
1091
|
+
tags: parseJsonArray(row.tags),
|
|
1092
|
+
source: "outgoing"
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
const backlinkRows = db.prepare(`
|
|
1096
|
+
SELECT DISTINCT n.slug, n.title, n.type, n.tags
|
|
1097
|
+
FROM links l
|
|
1098
|
+
JOIN notes n ON n.slug = l.source_slug
|
|
1099
|
+
WHERE l.target_slug = ? AND n.slug != ?
|
|
1100
|
+
`).all(note.slug, note.slug);
|
|
1101
|
+
for (const row of backlinkRows) {
|
|
1102
|
+
if (bySlug.has(row.slug)) continue;
|
|
1103
|
+
bySlug.set(row.slug, {
|
|
1104
|
+
slug: row.slug,
|
|
1105
|
+
title: row.title,
|
|
1106
|
+
type: row.type,
|
|
1107
|
+
tags: parseJsonArray(row.tags),
|
|
1108
|
+
source: "backlink"
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
return [...bySlug.values()];
|
|
1112
|
+
}
|
|
1113
|
+
function mergeNearbyNotes(nearbyNotes, links) {
|
|
1114
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1115
|
+
for (const nearby of nearbyNotes) {
|
|
1116
|
+
merged.set(nearby.slug, nearby);
|
|
1117
|
+
}
|
|
1118
|
+
for (const link of links) {
|
|
1119
|
+
if (merged.has(link.slug)) continue;
|
|
1120
|
+
merged.set(link.slug, {
|
|
1121
|
+
slug: link.slug,
|
|
1122
|
+
title: link.title,
|
|
1123
|
+
type: link.type,
|
|
1124
|
+
tags: link.tags,
|
|
1125
|
+
source: link.source
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
return [...merged.values()];
|
|
1129
|
+
}
|
|
1130
|
+
function getNearbyWeight(source) {
|
|
1131
|
+
switch (source) {
|
|
1132
|
+
case "outgoing":
|
|
1133
|
+
case "mention":
|
|
1134
|
+
return 2;
|
|
1135
|
+
case "backlink":
|
|
1136
|
+
case "search":
|
|
1137
|
+
default:
|
|
1138
|
+
return 1;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
function isSectionSparse(body, heading, templateBody) {
|
|
1142
|
+
const section = getSectionContent(body, heading);
|
|
1143
|
+
if (!section) return true;
|
|
1144
|
+
const cleaned = section.replace(/^- \[ \]\s*$/gm, "").replace(/^-\s*$/gm, "").trim();
|
|
1145
|
+
const templateSection = templateBody ? getSectionContent(templateBody, heading).trim() : "";
|
|
1146
|
+
if (templateSection && cleaned === templateSection) {
|
|
1147
|
+
return true;
|
|
1148
|
+
}
|
|
1149
|
+
return cleaned.length < 8;
|
|
1150
|
+
}
|
|
1151
|
+
function hasMeaningfulBullets(body, heading) {
|
|
1152
|
+
const section = getSectionContent(body, heading);
|
|
1153
|
+
if (!section) return false;
|
|
1154
|
+
return section.split("\n").some((line) => /^-/.test(line.trim()) && line.replace(/^-+\s*/, "").trim().length > 2);
|
|
1155
|
+
}
|
|
1156
|
+
function getSectionContent(body, heading) {
|
|
1157
|
+
const lines = body.split("\n");
|
|
1158
|
+
const headingLine = `## ${heading}`;
|
|
1159
|
+
const startIndex = lines.findIndex((line) => line.trim() === headingLine);
|
|
1160
|
+
if (startIndex === -1) return "";
|
|
1161
|
+
const collected = [];
|
|
1162
|
+
for (let index = startIndex + 1; index < lines.length; index++) {
|
|
1163
|
+
if (lines[index].startsWith("## ")) break;
|
|
1164
|
+
collected.push(lines[index]);
|
|
1165
|
+
}
|
|
1166
|
+
return collected.join("\n");
|
|
1167
|
+
}
|
|
1168
|
+
function hasUrl(body) {
|
|
1169
|
+
return /https?:\/\/\S+/i.test(body);
|
|
1170
|
+
}
|
|
1171
|
+
function hasAnyWikilinks(body) {
|
|
1172
|
+
return parseWikilinks(body).length > 0;
|
|
1173
|
+
}
|
|
1174
|
+
function getNoteMetadata(db, slug) {
|
|
1175
|
+
if (!slug) return null;
|
|
1176
|
+
const row = db.prepare(`
|
|
1177
|
+
SELECT slug, title, type, tags
|
|
1178
|
+
FROM notes
|
|
1179
|
+
WHERE slug = ?
|
|
1180
|
+
`).get(slug);
|
|
1181
|
+
if (!row) return null;
|
|
1182
|
+
return {
|
|
1183
|
+
slug: row.slug,
|
|
1184
|
+
title: row.title,
|
|
1185
|
+
type: row.type,
|
|
1186
|
+
tags: parseJsonArray(row.tags)
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
function parseJsonArray(raw) {
|
|
1190
|
+
if (!raw) return [];
|
|
1191
|
+
try {
|
|
1192
|
+
const value = JSON.parse(raw);
|
|
1193
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
1194
|
+
} catch {
|
|
1195
|
+
return [];
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// src/commands/new.ts
|
|
1200
|
+
function newNote(title, options) {
|
|
1201
|
+
const vaultRoot = requireVaultRoot();
|
|
1202
|
+
const config = loadConfig(vaultRoot);
|
|
1203
|
+
const resolvedType = options.type || config.defaults.note_type;
|
|
1204
|
+
const typeConfig = config.note_types[resolvedType];
|
|
1205
|
+
const bodyOverride = typeConfig?.slug_format === "date" ? title + "\n" : void 0;
|
|
1206
|
+
const note = createNote(vaultRoot, config, resolvedType, title, bodyOverride);
|
|
1207
|
+
if (options.source || options.status) {
|
|
1208
|
+
if (options.source) validateSource(options.source);
|
|
1209
|
+
if (options.status) validateStatus(options.status);
|
|
1210
|
+
const raw = fs6.readFileSync(note.filepath, "utf-8");
|
|
1211
|
+
const { frontmatter, body } = parseFrontmatter(raw);
|
|
1212
|
+
if (options.source) frontmatter.source = options.source;
|
|
1213
|
+
if (options.status) frontmatter.status = options.status;
|
|
1214
|
+
fs6.writeFileSync(note.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
|
|
1215
|
+
note.frontmatter = frontmatter;
|
|
1216
|
+
}
|
|
1217
|
+
const recommendationStrategy = typeConfig?.slug_format === "date" ? "incremental" : "rebuild";
|
|
1218
|
+
const recommendations = recommendNote(vaultRoot, config, note, { strategy: recommendationStrategy });
|
|
1219
|
+
const lines = note.body.split("\n").length;
|
|
1220
|
+
const overLimit = typeConfig && typeConfig.line_limit && lines > typeConfig.line_limit;
|
|
1221
|
+
if (options.json) {
|
|
1222
|
+
console.log(jsonSuccess({
|
|
1223
|
+
slug: note.slug,
|
|
1224
|
+
title: note.frontmatter.title,
|
|
1225
|
+
type: resolvedType,
|
|
1226
|
+
status: note.frontmatter.status,
|
|
1227
|
+
source: note.frontmatter.source,
|
|
1228
|
+
filepath: note.filepath,
|
|
1229
|
+
suggestions: recommendations.links.map((link) => ({
|
|
1230
|
+
slug: link.slug,
|
|
1231
|
+
title: link.title,
|
|
1232
|
+
type: link.type
|
|
1233
|
+
})),
|
|
1234
|
+
recommendations
|
|
1235
|
+
}));
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
if (overLimit) {
|
|
1239
|
+
const action = typeConfig.warn_only ? "Warning" : "Error";
|
|
1240
|
+
console.warn(`${action}: Note exceeds ${typeConfig.line_limit} line limit for type "${resolvedType}"`);
|
|
1241
|
+
}
|
|
1242
|
+
console.log(note.filepath);
|
|
1243
|
+
if (typeConfig?.instructions) {
|
|
1244
|
+
console.log("");
|
|
1245
|
+
console.log(` \u{1F4A1} ${typeConfig.instructions}`);
|
|
1246
|
+
}
|
|
1247
|
+
const recommendationLines = formatRecommendations(recommendations);
|
|
1248
|
+
if (recommendationLines.length > 0) {
|
|
1249
|
+
console.log("");
|
|
1250
|
+
console.log("Recommendations:");
|
|
1251
|
+
for (const line of recommendationLines) {
|
|
1252
|
+
console.log(line);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// src/commands/add.ts
|
|
1258
|
+
import fs7 from "fs";
|
|
1259
|
+
function addNote(text, options = {}) {
|
|
1260
|
+
const vaultRoot = requireVaultRoot();
|
|
1261
|
+
const config = loadConfig(vaultRoot);
|
|
1262
|
+
const typeName = config.defaults.note_type;
|
|
1263
|
+
let content;
|
|
1264
|
+
if (text) {
|
|
1265
|
+
content = text;
|
|
1266
|
+
} else if (!process.stdin.isTTY) {
|
|
1267
|
+
content = fs7.readFileSync(0, "utf-8").trim();
|
|
1268
|
+
} else {
|
|
1269
|
+
console.error('Usage: mem add "some text" or echo "text" | mem add');
|
|
1270
|
+
process.exit(1);
|
|
1271
|
+
}
|
|
1272
|
+
if (!content) {
|
|
1273
|
+
console.error("No content provided.");
|
|
1274
|
+
process.exit(1);
|
|
1275
|
+
}
|
|
1276
|
+
const firstLine = content.split("\n")[0];
|
|
1277
|
+
const title = firstLine.length > 60 ? firstLine.slice(0, 60).trim() + "..." : firstLine;
|
|
1278
|
+
const note = createNote(vaultRoot, config, typeName, title, content + "\n");
|
|
1279
|
+
const recommendations = recommendNote(vaultRoot, config, note, { strategy: "incremental" });
|
|
1280
|
+
if (options.json) {
|
|
1281
|
+
console.log(jsonSuccess({
|
|
1282
|
+
slug: note.slug,
|
|
1283
|
+
title: note.frontmatter.title,
|
|
1284
|
+
type: typeName,
|
|
1285
|
+
filepath: note.filepath,
|
|
1286
|
+
recommendations
|
|
1287
|
+
}));
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
console.log(note.filepath);
|
|
1291
|
+
const recommendationLines = formatRecommendations(recommendations);
|
|
1292
|
+
if (recommendationLines.length > 0) {
|
|
1293
|
+
console.log("");
|
|
1294
|
+
console.log("Recommendations:");
|
|
1295
|
+
for (const line of recommendationLines) {
|
|
1296
|
+
console.log(line);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
520
1299
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
1300
|
+
|
|
1301
|
+
// src/commands/open.ts
|
|
1302
|
+
import { execSync } from "child_process";
|
|
1303
|
+
function openNote(slug) {
|
|
1304
|
+
const vaultRoot = requireVaultRoot();
|
|
1305
|
+
const config = loadConfig(vaultRoot);
|
|
1306
|
+
const note = findNoteBySlug(vaultRoot, config, slug);
|
|
1307
|
+
if (!note) {
|
|
1308
|
+
console.error(`Note not found: "${slug}"`);
|
|
1309
|
+
process.exit(1);
|
|
525
1310
|
}
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
return db;
|
|
1311
|
+
const editor = config.defaults.editor === "$EDITOR" ? process.env.EDITOR || "vi" : config.defaults.editor;
|
|
1312
|
+
execSync(`${editor} "${note.filepath}"`, { stdio: "inherit" });
|
|
529
1313
|
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
`);
|
|
543
|
-
const transaction = db.transaction(() => {
|
|
544
|
-
for (const note of notes) {
|
|
545
|
-
insertNote.run({
|
|
546
|
-
slug: note.slug,
|
|
547
|
-
id: note.frontmatter.id,
|
|
548
|
-
title: note.frontmatter.title,
|
|
549
|
-
type: note.frontmatter.type,
|
|
550
|
-
created: note.frontmatter.created,
|
|
551
|
-
modified: note.frontmatter.modified,
|
|
552
|
-
tags: JSON.stringify(note.frontmatter.tags),
|
|
553
|
-
aliases: JSON.stringify(note.frontmatter.aliases),
|
|
554
|
-
body: note.body,
|
|
555
|
-
filepath: note.filepath
|
|
556
|
-
});
|
|
557
|
-
const links = parseWikilinks(note.body);
|
|
558
|
-
const resolved = resolveWikilinks(links, notes);
|
|
559
|
-
for (const link of resolved) {
|
|
560
|
-
const bodyLines = note.body.split("\n");
|
|
561
|
-
const contextLine = bodyLines.find((l) => l.includes(link.raw)) ?? "";
|
|
562
|
-
insertLink.run({
|
|
563
|
-
source_slug: note.slug,
|
|
564
|
-
target_slug: link.resolved_slug ?? null,
|
|
565
|
-
target_raw: link.target,
|
|
566
|
-
context: contextLine.trim()
|
|
567
|
-
});
|
|
568
|
-
}
|
|
1314
|
+
|
|
1315
|
+
// src/commands/show.ts
|
|
1316
|
+
import fs8 from "fs";
|
|
1317
|
+
function showCommand(slug, options) {
|
|
1318
|
+
const vaultRoot = requireVaultRoot();
|
|
1319
|
+
const config = loadConfig(vaultRoot);
|
|
1320
|
+
const note = findNoteBySlug(vaultRoot, config, slug);
|
|
1321
|
+
if (!note) {
|
|
1322
|
+
if (options.json) {
|
|
1323
|
+
console.log(jsonError(`Note not found: ${slug}`));
|
|
1324
|
+
} else {
|
|
1325
|
+
console.error(`Note not found: ${slug}`);
|
|
569
1326
|
}
|
|
570
|
-
|
|
571
|
-
});
|
|
572
|
-
transaction();
|
|
573
|
-
}
|
|
574
|
-
function ensureIndex(vaultRoot, config) {
|
|
575
|
-
const db = openDatabase(vaultRoot);
|
|
576
|
-
if (config.index.auto_rebuild) {
|
|
577
|
-
rebuildIndex(vaultRoot, config, db);
|
|
1327
|
+
process.exit(1);
|
|
578
1328
|
}
|
|
579
|
-
|
|
1329
|
+
if (options.json) {
|
|
1330
|
+
console.log(jsonSuccess({
|
|
1331
|
+
slug: note.slug,
|
|
1332
|
+
title: note.frontmatter.title,
|
|
1333
|
+
type: note.frontmatter.type,
|
|
1334
|
+
created: note.frontmatter.created,
|
|
1335
|
+
modified: note.frontmatter.modified,
|
|
1336
|
+
tags: note.frontmatter.tags,
|
|
1337
|
+
aliases: note.frontmatter.aliases,
|
|
1338
|
+
status: note.frontmatter.status,
|
|
1339
|
+
source: note.frontmatter.source,
|
|
1340
|
+
body: note.body,
|
|
1341
|
+
filepath: note.filepath
|
|
1342
|
+
}));
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
if (options.body) {
|
|
1346
|
+
process.stdout.write(note.body);
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
console.log(`# ${note.frontmatter.title} (${note.slug})`);
|
|
1350
|
+
console.log(`# type: ${note.frontmatter.type} | modified: ${note.frontmatter.modified.slice(0, 10)}`);
|
|
1351
|
+
console.log("");
|
|
1352
|
+
const content = fs8.readFileSync(note.filepath, "utf-8");
|
|
1353
|
+
console.log(content);
|
|
580
1354
|
}
|
|
581
1355
|
|
|
582
1356
|
// src/core/search.ts
|
|
@@ -665,44 +1439,6 @@ function backlinksCommand(slug, options) {
|
|
|
665
1439
|
}
|
|
666
1440
|
}
|
|
667
1441
|
|
|
668
|
-
// src/core/suggest.ts
|
|
669
|
-
function suggestLinks(db, note) {
|
|
670
|
-
const existingLinks = new Set(
|
|
671
|
-
parseWikilinks(note.body).map((l) => l.target.toLowerCase())
|
|
672
|
-
);
|
|
673
|
-
const allNotes = db.prepare("SELECT slug, title, aliases FROM notes WHERE slug != ?").all(note.slug);
|
|
674
|
-
const bodyLower = note.body.toLowerCase();
|
|
675
|
-
const suggestions = [];
|
|
676
|
-
for (const other of allNotes) {
|
|
677
|
-
const titleLower = other.title.toLowerCase();
|
|
678
|
-
if (existingLinks.has(titleLower) || existingLinks.has(other.slug)) continue;
|
|
679
|
-
let mentions = 0;
|
|
680
|
-
let idx = 0;
|
|
681
|
-
while ((idx = bodyLower.indexOf(titleLower, idx)) !== -1) {
|
|
682
|
-
mentions++;
|
|
683
|
-
idx += titleLower.length;
|
|
684
|
-
}
|
|
685
|
-
const aliases = JSON.parse(other.aliases || "[]");
|
|
686
|
-
for (const alias of aliases) {
|
|
687
|
-
const aliasLower = alias.toLowerCase();
|
|
688
|
-
if (existingLinks.has(aliasLower)) continue;
|
|
689
|
-
let aidx = 0;
|
|
690
|
-
while ((aidx = bodyLower.indexOf(aliasLower, aidx)) !== -1) {
|
|
691
|
-
mentions++;
|
|
692
|
-
aidx += aliasLower.length;
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
if (mentions > 0) {
|
|
696
|
-
suggestions.push({
|
|
697
|
-
target_slug: other.slug,
|
|
698
|
-
target_title: other.title,
|
|
699
|
-
mentions
|
|
700
|
-
});
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
return suggestions.sort((a, b) => b.mentions - a.mentions);
|
|
704
|
-
}
|
|
705
|
-
|
|
706
1442
|
// src/commands/suggest-links.ts
|
|
707
1443
|
function suggestLinksCommand(slug, options) {
|
|
708
1444
|
const vaultRoot = requireVaultRoot();
|
|
@@ -734,15 +1470,50 @@ function suggestLinksCommand(slug, options) {
|
|
|
734
1470
|
}
|
|
735
1471
|
}
|
|
736
1472
|
|
|
1473
|
+
// src/commands/recommend.ts
|
|
1474
|
+
function recommendCommand(slug, options) {
|
|
1475
|
+
const vaultRoot = requireVaultRoot();
|
|
1476
|
+
const config = loadConfig(vaultRoot);
|
|
1477
|
+
const note = findNoteBySlug(vaultRoot, config, slug);
|
|
1478
|
+
if (!note) {
|
|
1479
|
+
if (options.json) {
|
|
1480
|
+
console.log(jsonError(`Note not found: ${slug}`));
|
|
1481
|
+
} else {
|
|
1482
|
+
console.error(`Note not found: "${slug}"`);
|
|
1483
|
+
}
|
|
1484
|
+
process.exit(1);
|
|
1485
|
+
}
|
|
1486
|
+
const recommendations = recommendNote(vaultRoot, config, note);
|
|
1487
|
+
if (options.json) {
|
|
1488
|
+
console.log(jsonSuccess({
|
|
1489
|
+
slug: note.slug,
|
|
1490
|
+
title: note.frontmatter.title,
|
|
1491
|
+
type: note.frontmatter.type,
|
|
1492
|
+
recommendations
|
|
1493
|
+
}));
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
const lines = formatRecommendations(recommendations);
|
|
1497
|
+
if (lines.length === 0) {
|
|
1498
|
+
console.log(`No recommendations found for "${note.frontmatter.title}".`);
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
console.log(`Recommendations for "${note.frontmatter.title}":`);
|
|
1502
|
+
console.log("");
|
|
1503
|
+
for (const line of lines) {
|
|
1504
|
+
console.log(line);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
737
1508
|
// src/core/doctor.ts
|
|
738
|
-
import
|
|
1509
|
+
import fs9 from "fs";
|
|
739
1510
|
import path6 from "path";
|
|
740
1511
|
function runDoctor(vaultRoot, config, db) {
|
|
741
1512
|
const issues = [];
|
|
742
1513
|
const notes = listNotes(vaultRoot, config);
|
|
743
1514
|
for (const [name, typeConfig] of Object.entries(config.note_types)) {
|
|
744
1515
|
const folder = path6.join(vaultRoot, typeConfig.folder);
|
|
745
|
-
if (!
|
|
1516
|
+
if (!fs9.existsSync(folder)) {
|
|
746
1517
|
issues.push({
|
|
747
1518
|
level: "error",
|
|
748
1519
|
file: `granite.yml`,
|
|
@@ -878,7 +1649,7 @@ ${errors.length} error(s), ${warnings.length} warning(s), ${infos.length} info(s
|
|
|
878
1649
|
import { Hono } from "hono";
|
|
879
1650
|
import { serve } from "@hono/node-server";
|
|
880
1651
|
import path7 from "path";
|
|
881
|
-
import
|
|
1652
|
+
import fs10 from "fs";
|
|
882
1653
|
function createApp(vaultRoot, config) {
|
|
883
1654
|
const app = new Hono();
|
|
884
1655
|
app.get("/api/notes", (c) => {
|
|
@@ -968,12 +1739,12 @@ function createApp(vaultRoot, config) {
|
|
|
968
1739
|
app.get("/*", (c) => {
|
|
969
1740
|
const reqPath = c.req.path === "/" ? "/index.html" : c.req.path;
|
|
970
1741
|
const filePath = path7.join(publicDir, reqPath);
|
|
971
|
-
if (!
|
|
1742
|
+
if (!fs10.existsSync(filePath)) {
|
|
972
1743
|
const indexPath = path7.join(publicDir, "index.html");
|
|
973
|
-
const html =
|
|
1744
|
+
const html = fs10.readFileSync(indexPath, "utf-8");
|
|
974
1745
|
return c.html(html);
|
|
975
1746
|
}
|
|
976
|
-
const content =
|
|
1747
|
+
const content = fs10.readFileSync(filePath);
|
|
977
1748
|
const ext = path7.extname(filePath);
|
|
978
1749
|
const mimeTypes = {
|
|
979
1750
|
".html": "text/html",
|
|
@@ -1024,6 +1795,45 @@ Usage: mem new <type> <title>`);
|
|
|
1024
1795
|
}
|
|
1025
1796
|
|
|
1026
1797
|
// src/commands/list.ts
|
|
1798
|
+
var AVAILABLE_FIELDS = ["slug", "title", "type", "created", "modified", "tags", "aliases", "status", "source", "filepath"];
|
|
1799
|
+
function pickFields(note, fields) {
|
|
1800
|
+
const out = {};
|
|
1801
|
+
for (const f of fields) {
|
|
1802
|
+
switch (f) {
|
|
1803
|
+
case "slug":
|
|
1804
|
+
out.slug = note.slug;
|
|
1805
|
+
break;
|
|
1806
|
+
case "title":
|
|
1807
|
+
out.title = note.frontmatter.title;
|
|
1808
|
+
break;
|
|
1809
|
+
case "type":
|
|
1810
|
+
out.type = note.frontmatter.type;
|
|
1811
|
+
break;
|
|
1812
|
+
case "created":
|
|
1813
|
+
out.created = note.frontmatter.created;
|
|
1814
|
+
break;
|
|
1815
|
+
case "modified":
|
|
1816
|
+
out.modified = note.frontmatter.modified;
|
|
1817
|
+
break;
|
|
1818
|
+
case "tags":
|
|
1819
|
+
out.tags = note.frontmatter.tags;
|
|
1820
|
+
break;
|
|
1821
|
+
case "aliases":
|
|
1822
|
+
out.aliases = note.frontmatter.aliases;
|
|
1823
|
+
break;
|
|
1824
|
+
case "status":
|
|
1825
|
+
out.status = note.frontmatter.status;
|
|
1826
|
+
break;
|
|
1827
|
+
case "source":
|
|
1828
|
+
out.source = note.frontmatter.source;
|
|
1829
|
+
break;
|
|
1830
|
+
case "filepath":
|
|
1831
|
+
out.filepath = note.filepath;
|
|
1832
|
+
break;
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
return out;
|
|
1836
|
+
}
|
|
1027
1837
|
function listCommand(options) {
|
|
1028
1838
|
const vaultRoot = requireVaultRoot();
|
|
1029
1839
|
const config = loadConfig(vaultRoot);
|
|
@@ -1031,17 +1841,28 @@ function listCommand(options) {
|
|
|
1031
1841
|
if (options.type) {
|
|
1032
1842
|
notes = notes.filter((n) => n.frontmatter.type === options.type);
|
|
1033
1843
|
}
|
|
1844
|
+
if (options.status) {
|
|
1845
|
+
notes = notes.filter((n) => n.frontmatter.status === options.status);
|
|
1846
|
+
}
|
|
1847
|
+
if (options.source) {
|
|
1848
|
+
notes = notes.filter((n) => n.frontmatter.source === options.source);
|
|
1849
|
+
}
|
|
1850
|
+
if (options.since) {
|
|
1851
|
+
notes = notes.filter((n) => n.frontmatter.modified >= options.since);
|
|
1852
|
+
}
|
|
1034
1853
|
notes.sort((a, b) => b.frontmatter.modified.localeCompare(a.frontmatter.modified));
|
|
1035
|
-
if (options.json) {
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1854
|
+
if (options.json !== void 0 && options.json !== false) {
|
|
1855
|
+
let fields;
|
|
1856
|
+
if (typeof options.json === "string") {
|
|
1857
|
+
fields = options.json.split(",").map((f) => f.trim()).filter((f) => AVAILABLE_FIELDS.includes(f));
|
|
1858
|
+
if (fields.length === 0) {
|
|
1859
|
+
console.log(`Available fields: ${AVAILABLE_FIELDS.join(", ")}`);
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1862
|
+
} else {
|
|
1863
|
+
fields = AVAILABLE_FIELDS;
|
|
1864
|
+
}
|
|
1865
|
+
const out = notes.map((n) => pickFields(n, fields));
|
|
1045
1866
|
console.log(jsonSuccess(out));
|
|
1046
1867
|
return;
|
|
1047
1868
|
}
|
|
@@ -1052,14 +1873,15 @@ function listCommand(options) {
|
|
|
1052
1873
|
for (const note of notes) {
|
|
1053
1874
|
const date = note.frontmatter.modified.slice(0, 10);
|
|
1054
1875
|
const type = note.frontmatter.type.padEnd(10);
|
|
1055
|
-
|
|
1876
|
+
const status = note.frontmatter.status?.padEnd(8) ?? "active ";
|
|
1877
|
+
console.log(` ${date} ${type} ${status} ${note.frontmatter.title} (${note.slug})`);
|
|
1056
1878
|
}
|
|
1057
1879
|
console.log(`
|
|
1058
1880
|
${notes.length} note(s)`);
|
|
1059
1881
|
}
|
|
1060
1882
|
|
|
1061
1883
|
// src/commands/edit.ts
|
|
1062
|
-
import
|
|
1884
|
+
import fs11 from "fs";
|
|
1063
1885
|
import { execSync as execSync2 } from "child_process";
|
|
1064
1886
|
function editCommand(slug, options) {
|
|
1065
1887
|
const vaultRoot = requireVaultRoot();
|
|
@@ -1069,9 +1891,9 @@ function editCommand(slug, options) {
|
|
|
1069
1891
|
console.error(`Note not found: ${slug}`);
|
|
1070
1892
|
process.exit(1);
|
|
1071
1893
|
}
|
|
1072
|
-
const hasFlags = options.body !== void 0 || options.append !== void 0 || options.title !== void 0 || options.tag !== void 0 || options.alias !== void 0;
|
|
1894
|
+
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;
|
|
1073
1895
|
if (hasFlags) {
|
|
1074
|
-
let { frontmatter, body } = parseFrontmatter(
|
|
1896
|
+
let { frontmatter, body } = parseFrontmatter(fs11.readFileSync(note.filepath, "utf-8"));
|
|
1075
1897
|
if (options.title) {
|
|
1076
1898
|
frontmatter.title = options.title;
|
|
1077
1899
|
}
|
|
@@ -1087,6 +1909,14 @@ function editCommand(slug, options) {
|
|
|
1087
1909
|
for (const a of newAliases) existing.add(a);
|
|
1088
1910
|
frontmatter.aliases = [...existing];
|
|
1089
1911
|
}
|
|
1912
|
+
if (options.status) {
|
|
1913
|
+
validateStatus(options.status);
|
|
1914
|
+
frontmatter.status = options.status;
|
|
1915
|
+
}
|
|
1916
|
+
if (options.source) {
|
|
1917
|
+
validateSource(options.source);
|
|
1918
|
+
frontmatter.source = options.source;
|
|
1919
|
+
}
|
|
1090
1920
|
if (options.body !== void 0) {
|
|
1091
1921
|
body = options.body + "\n";
|
|
1092
1922
|
}
|
|
@@ -1094,43 +1924,57 @@ function editCommand(slug, options) {
|
|
|
1094
1924
|
body = body.trimEnd() + "\n" + options.append + "\n";
|
|
1095
1925
|
}
|
|
1096
1926
|
frontmatter.modified = (/* @__PURE__ */ new Date()).toISOString();
|
|
1097
|
-
|
|
1927
|
+
fs11.writeFileSync(note.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
|
|
1098
1928
|
console.log(note.filepath);
|
|
1929
|
+
const recommendationStrategy = options.title !== void 0 || options.alias !== void 0 ? "rebuild" : "incremental";
|
|
1930
|
+
printRecommendations(vaultRoot, config, note.filepath, recommendationStrategy);
|
|
1099
1931
|
} else {
|
|
1100
1932
|
const editor = process.env.EDITOR || "vi";
|
|
1101
|
-
const statBefore =
|
|
1933
|
+
const statBefore = fs11.statSync(note.filepath).mtimeMs;
|
|
1102
1934
|
try {
|
|
1103
1935
|
execSync2(`${editor} "${note.filepath}"`, { stdio: "inherit" });
|
|
1104
1936
|
} catch {
|
|
1105
1937
|
console.error(`Failed to open editor: ${editor}`);
|
|
1106
1938
|
process.exit(1);
|
|
1107
1939
|
}
|
|
1108
|
-
const statAfter =
|
|
1940
|
+
const statAfter = fs11.statSync(note.filepath).mtimeMs;
|
|
1109
1941
|
if (statAfter !== statBefore) {
|
|
1110
|
-
const { frontmatter, body } = parseFrontmatter(
|
|
1942
|
+
const { frontmatter, body } = parseFrontmatter(fs11.readFileSync(note.filepath, "utf-8"));
|
|
1111
1943
|
frontmatter.modified = (/* @__PURE__ */ new Date()).toISOString();
|
|
1112
|
-
|
|
1944
|
+
fs11.writeFileSync(note.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
|
|
1113
1945
|
console.log(`Updated: ${note.filepath}`);
|
|
1946
|
+
printRecommendations(vaultRoot, config, note.filepath, "rebuild");
|
|
1114
1947
|
}
|
|
1115
1948
|
}
|
|
1116
1949
|
}
|
|
1950
|
+
function printRecommendations(vaultRoot, config, filepath, strategy) {
|
|
1951
|
+
const updatedNote = readNote(filepath);
|
|
1952
|
+
const recommendations = recommendNote(vaultRoot, config, updatedNote, { strategy });
|
|
1953
|
+
const lines = formatRecommendations(recommendations);
|
|
1954
|
+
if (lines.length === 0) return;
|
|
1955
|
+
console.log("");
|
|
1956
|
+
console.log("Recommendations:");
|
|
1957
|
+
for (const line of lines) {
|
|
1958
|
+
console.log(line);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1117
1961
|
|
|
1118
1962
|
// src/index.ts
|
|
1119
1963
|
var program = new Command();
|
|
1120
1964
|
program.name("mem").description("Granite \u2014 a local-first markdown memory system").version("0.1.0");
|
|
1121
|
-
program.command("init").description("Initialize
|
|
1122
|
-
initVault(
|
|
1965
|
+
program.command("init").description("Initialize the default vault in ~/.granite").action(() => {
|
|
1966
|
+
initVault();
|
|
1123
1967
|
});
|
|
1124
|
-
program.command("new").description("Create a new note").argument("<title>", "Note title").option("-t, --type <type>", "Note type (e.g. fleeting, permanent, reference)").option("--json", "Output as JSON (agent-friendly)").action((title, options) => {
|
|
1968
|
+
program.command("new").description("Create a new note").argument("<title>", "Note title").option("-t, --type <type>", "Note type (e.g. fleeting, permanent, reference)").option("--source <source>", "Set source (human, agent, extraction)").option("--status <status>", "Set status (inbox, active, archived)").option("--json", "Output as JSON (agent-friendly)").action((title, options) => {
|
|
1125
1969
|
newNote(title, options);
|
|
1126
1970
|
});
|
|
1127
1971
|
program.command("add").description("Quick-capture a fleeting note (reads stdin if no text given)").argument("[text]", "Note text (or pipe via stdin)").option("--json", "Output as JSON (agent-friendly)").action((text, options) => {
|
|
1128
1972
|
addNote(text, options);
|
|
1129
1973
|
});
|
|
1130
|
-
program.command("list").alias("ls").description("List notes").option("-t, --type <type>", "Filter by note type").option("--json", "Output as JSON (
|
|
1974
|
+
program.command("list").alias("ls").description("List notes").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>", "Filter notes modified since date (YYYY-MM-DD)").option("--json [fields]", "Output as JSON; optionally specify fields (e.g. --json slug,title,type)").action((options) => {
|
|
1131
1975
|
listCommand(options);
|
|
1132
1976
|
});
|
|
1133
|
-
program.command("edit").description("Edit a note (opens $EDITOR, or use flags for programmatic edits)").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)").action((slug, options) => {
|
|
1977
|
+
program.command("edit").description("Edit a note (opens $EDITOR, or use flags for programmatic edits)").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)").action((slug, options) => {
|
|
1134
1978
|
editCommand(slug, options);
|
|
1135
1979
|
});
|
|
1136
1980
|
program.command("open").description("Open a note in your editor (alias for edit)").argument("<slug>", "Note slug").action((slug) => {
|
|
@@ -1148,6 +1992,9 @@ program.command("backlinks").description("Show notes that link to a given note")
|
|
|
1148
1992
|
program.command("suggest-links").description("Suggest wikilinks based on unlinked mentions").argument("<slug>", "Note slug").option("--json", "Output as JSON (agent-friendly)").action((slug, options) => {
|
|
1149
1993
|
suggestLinksCommand(slug, options);
|
|
1150
1994
|
});
|
|
1995
|
+
program.command("recommend").description("Suggest links, tags, and the next note to create").argument("<slug>", "Note slug").option("--json", "Output as JSON (agent-friendly)").action((slug, options) => {
|
|
1996
|
+
recommendCommand(slug, options);
|
|
1997
|
+
});
|
|
1151
1998
|
program.command("types").description("List available note types").action(() => {
|
|
1152
1999
|
typesCommand();
|
|
1153
2000
|
});
|