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.
Files changed (3) hide show
  1. package/README.md +230 -0
  2. package/dist/index.js +1168 -321
  3. 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 fs2 from "fs";
8
- import path2 from "path";
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 = path2.resolve(dir);
103
- if (fs2.existsSync(path2.join(targetDir, CONFIG_FILENAME))) {
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
- fs2.mkdirSync(path2.join(targetDir, ".granite"), { recursive: true });
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
- fs2.mkdirSync(path2.join(targetDir, typeConfig.folder), { recursive: true });
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
- console.log(" .granite/");
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 fleeting "My first note"');
183
+ console.log('Next: mem new "My first note" --type fleeting');
123
184
  }
124
185
 
125
- // src/core/vault.ts
126
- import fs3 from "fs";
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
- // src/commands/new.ts
267
- function newNote(title, options) {
268
- const vaultRoot = requireVaultRoot();
269
- const config = loadConfig(vaultRoot);
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
- // src/commands/show.ts
371
- import fs6 from "fs";
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 fs7 from "fs";
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 = 1;
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
- fs7.mkdirSync(path5.dirname(dbPath), { recursive: true });
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
- function openDatabase(vaultRoot) {
522
- const dbPath = getIndexDbPath(vaultRoot);
523
- if (!fs7.existsSync(dbPath)) {
524
- return createDatabase(dbPath);
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 db = new Database(dbPath);
527
- db.pragma("journal_mode = WAL");
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
- function rebuildIndex(vaultRoot, config, db) {
531
- const notes = listNotes(vaultRoot, config);
532
- db.exec("DELETE FROM links");
533
- db.exec("DELETE FROM notes");
534
- db.exec("INSERT INTO notes_fts(notes_fts) VALUES('rebuild')");
535
- const insertNote = 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
- `);
539
- const insertLink = db.prepare(`
540
- INSERT INTO links (source_slug, target_slug, target_raw, context)
541
- VALUES (@source_slug, @target_slug, @target_raw, @context)
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
- db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run("last_rebuild", (/* @__PURE__ */ new Date()).toISOString());
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
- return db;
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 fs8 from "fs";
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 (!fs8.existsSync(folder)) {
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 fs9 from "fs";
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 (!fs9.existsSync(filePath)) {
1742
+ if (!fs10.existsSync(filePath)) {
972
1743
  const indexPath = path7.join(publicDir, "index.html");
973
- const html = fs9.readFileSync(indexPath, "utf-8");
1744
+ const html = fs10.readFileSync(indexPath, "utf-8");
974
1745
  return c.html(html);
975
1746
  }
976
- const content = fs9.readFileSync(filePath);
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
- const out = notes.map((n) => ({
1037
- slug: n.slug,
1038
- title: n.frontmatter.title,
1039
- type: n.frontmatter.type,
1040
- created: n.frontmatter.created,
1041
- modified: n.frontmatter.modified,
1042
- tags: n.frontmatter.tags,
1043
- filepath: n.filepath
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
- console.log(` ${date} ${type} ${note.frontmatter.title} (${note.slug})`);
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 fs10 from "fs";
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(fs10.readFileSync(note.filepath, "utf-8"));
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
- fs10.writeFileSync(note.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
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 = fs10.statSync(note.filepath).mtimeMs;
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 = fs10.statSync(note.filepath).mtimeMs;
1940
+ const statAfter = fs11.statSync(note.filepath).mtimeMs;
1109
1941
  if (statAfter !== statBefore) {
1110
- const { frontmatter, body } = parseFrontmatter(fs10.readFileSync(note.filepath, "utf-8"));
1942
+ const { frontmatter, body } = parseFrontmatter(fs11.readFileSync(note.filepath, "utf-8"));
1111
1943
  frontmatter.modified = (/* @__PURE__ */ new Date()).toISOString();
1112
- fs10.writeFileSync(note.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
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 a new vault in the current directory").action(() => {
1122
- initVault(process.cwd());
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 (agent-friendly)").action((options) => {
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
  });