granite-mem 0.1.1 → 0.1.2

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 +229 -0
  2. package/dist/index.js +1116 -286
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -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: {
@@ -122,6 +141,9 @@ function initVault(dir) {
122
141
  console.log('Next: mem new fleeting "My first note"');
123
142
  }
124
143
 
144
+ // src/commands/new.ts
145
+ import fs6 from "fs";
146
+
125
147
  // src/core/vault.ts
126
148
  import fs3 from "fs";
127
149
  import path3 from "path";
@@ -162,19 +184,31 @@ import matter from "gray-matter";
162
184
  function parseFrontmatter(content) {
163
185
  const { data, content: body } = matter(content);
164
186
  const fm = {
165
- id: data.id ?? "",
166
- title: data.title ?? "",
167
- type: data.type ?? "",
168
- created: data.created ?? "",
169
- modified: data.modified ?? "",
187
+ id: toFrontmatterString(data.id),
188
+ title: toFrontmatterString(data.title),
189
+ type: toFrontmatterString(data.type),
190
+ created: toFrontmatterString(data.created),
191
+ modified: toFrontmatterString(data.modified),
170
192
  tags: data.tags ?? [],
171
- aliases: data.aliases ?? []
193
+ aliases: data.aliases ?? [],
194
+ status: data.status ?? "active",
195
+ source: data.source ?? "human"
172
196
  };
197
+ for (const [key, value] of Object.entries(data)) {
198
+ if (!(key in fm)) {
199
+ fm[key] = value;
200
+ }
201
+ }
173
202
  return { frontmatter: fm, body };
174
203
  }
175
204
  function serializeFrontmatter(fm, body) {
176
205
  return matter.stringify(body, fm);
177
206
  }
207
+ function toFrontmatterString(value) {
208
+ if (value === null || value === void 0) return "";
209
+ if (value instanceof Date) return value.toISOString();
210
+ return String(value);
211
+ }
178
212
 
179
213
  // src/core/note.ts
180
214
  function createNote(vaultRoot, config, typeName, title, bodyOverride) {
@@ -211,7 +245,9 @@ function createNote(vaultRoot, config, typeName, title, bodyOverride) {
211
245
  created: now,
212
246
  modified: now,
213
247
  tags: [],
214
- aliases: []
248
+ aliases: [],
249
+ status: "active",
250
+ source: "human"
215
251
  };
216
252
  const body = bodyOverride ?? typeConfig.template;
217
253
  const content = serializeFrontmatter(frontmatter, body);
@@ -262,153 +298,22 @@ function jsonSuccess(data) {
262
298
  function jsonError(error) {
263
299
  return JSON.stringify({ success: false, error }, null, 2);
264
300
  }
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
- }
301
+ var VALID_STATUSES = ["inbox", "active", "archived"];
302
+ var VALID_SOURCES = ["human", "agent", "extraction"];
303
+ function validateStatus(value) {
304
+ if (!VALID_STATUSES.includes(value)) {
305
+ throw new Error(`Invalid status: "${value}". Expected one of: ${VALID_STATUSES.join(", ")}`);
319
306
  }
320
307
  }
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);
365
- }
366
- const editor = config.defaults.editor === "$EDITOR" ? process.env.EDITOR || "vi" : config.defaults.editor;
367
- execSync(`${editor} "${note.filepath}"`, { stdio: "inherit" });
368
- }
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;
308
+ function validateSource(value) {
309
+ if (!VALID_SOURCES.includes(value)) {
310
+ throw new Error(`Invalid source: "${value}". Expected one of: ${VALID_SOURCES.join(", ")}`);
397
311
  }
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
312
  }
408
313
 
409
314
  // src/core/index-db.ts
410
315
  import Database from "better-sqlite3";
411
- import fs7 from "fs";
316
+ import fs5 from "fs";
412
317
  import path5 from "path";
413
318
 
414
319
  // src/core/wikilinks.ts
@@ -454,7 +359,7 @@ function resolveWikilinks(links, allNotes) {
454
359
  }
455
360
 
456
361
  // src/core/index-db.ts
457
- var SCHEMA_VERSION = 1;
362
+ var SCHEMA_VERSION = 2;
458
363
  var SCHEMA_SQL = `
459
364
  CREATE TABLE IF NOT EXISTS notes (
460
365
  slug TEXT PRIMARY KEY,
@@ -466,7 +371,9 @@ CREATE TABLE IF NOT EXISTS notes (
466
371
  tags TEXT,
467
372
  aliases TEXT,
468
373
  body TEXT NOT NULL,
469
- filepath TEXT NOT NULL
374
+ filepath TEXT NOT NULL,
375
+ status TEXT NOT NULL DEFAULT 'active',
376
+ source TEXT NOT NULL DEFAULT 'human'
470
377
  );
471
378
 
472
379
  CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
@@ -505,78 +412,928 @@ CREATE TABLE IF NOT EXISTS links (
505
412
  CREATE INDEX IF NOT EXISTS idx_links_target ON links(target_slug);
506
413
  CREATE INDEX IF NOT EXISTS idx_links_source ON links(source_slug);
507
414
 
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;
415
+ CREATE TABLE IF NOT EXISTS meta (
416
+ key TEXT PRIMARY KEY,
417
+ value TEXT
418
+ );
419
+ `;
420
+ function createDatabase(dbPath) {
421
+ fs5.mkdirSync(path5.dirname(dbPath), { recursive: true });
422
+ const db = new Database(dbPath);
423
+ db.pragma("journal_mode = WAL");
424
+ db.exec(SCHEMA_SQL);
425
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run("schema_version", String(SCHEMA_VERSION));
426
+ return db;
427
+ }
428
+ function openDatabase(vaultRoot) {
429
+ const dbPath = getIndexDbPath(vaultRoot);
430
+ if (!fs5.existsSync(dbPath)) {
431
+ return createDatabase(dbPath);
432
+ }
433
+ const db = new Database(dbPath);
434
+ db.pragma("journal_mode = WAL");
435
+ const row = db.prepare("SELECT value FROM meta WHERE key = ?").get("schema_version");
436
+ const currentVersion = row ? Number(row.value) : 0;
437
+ if (currentVersion < SCHEMA_VERSION) {
438
+ db.close();
439
+ fs5.unlinkSync(dbPath);
440
+ return createDatabase(dbPath);
441
+ }
442
+ return db;
443
+ }
444
+ function rebuildIndex(vaultRoot, config, db) {
445
+ const notes = listNotes(vaultRoot, config);
446
+ db.exec("DELETE FROM links");
447
+ db.exec("DELETE FROM notes");
448
+ db.exec("INSERT INTO notes_fts(notes_fts) VALUES('rebuild')");
449
+ const insertNote = db.prepare(`
450
+ INSERT INTO notes (slug, id, title, type, created, modified, tags, aliases, body, filepath, status, source)
451
+ VALUES (@slug, @id, @title, @type, @created, @modified, @tags, @aliases, @body, @filepath, @status, @source)
452
+ `);
453
+ const insertLink = db.prepare(`
454
+ INSERT INTO links (source_slug, target_slug, target_raw, context)
455
+ VALUES (@source_slug, @target_slug, @target_raw, @context)
456
+ `);
457
+ const transaction = db.transaction(() => {
458
+ for (const note of notes) {
459
+ insertNote.run({
460
+ slug: note.slug,
461
+ id: note.frontmatter.id,
462
+ title: note.frontmatter.title,
463
+ type: note.frontmatter.type,
464
+ created: note.frontmatter.created,
465
+ modified: note.frontmatter.modified,
466
+ tags: JSON.stringify(note.frontmatter.tags),
467
+ aliases: JSON.stringify(note.frontmatter.aliases),
468
+ body: note.body,
469
+ filepath: note.filepath,
470
+ status: note.frontmatter.status ?? "active",
471
+ source: note.frontmatter.source ?? "human"
472
+ });
473
+ const links = parseWikilinks(note.body);
474
+ const resolved = resolveLinksAgainstNotes(links, notes);
475
+ for (const link of resolved) {
476
+ const bodyLines = note.body.split("\n");
477
+ const contextLine = bodyLines.find((l) => l.includes(link.raw)) ?? "";
478
+ insertLink.run({
479
+ source_slug: note.slug,
480
+ target_slug: link.resolved_slug ?? null,
481
+ target_raw: link.target,
482
+ context: contextLine.trim()
483
+ });
484
+ }
485
+ }
486
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run("last_rebuild", (/* @__PURE__ */ new Date()).toISOString());
487
+ });
488
+ transaction();
489
+ }
490
+ function syncNoteInIndex(vaultRoot, config, db, note) {
491
+ const noteCountInDb = db.prepare("SELECT COUNT(*) as c FROM notes").get();
492
+ const noteCountOnDisk = countNoteFiles(vaultRoot, config);
493
+ const noteExists = db.prepare("SELECT 1 FROM notes WHERE slug = ?").get(note.slug);
494
+ const allowedCount = noteExists ? noteCountOnDisk : noteCountOnDisk - 1;
495
+ if (noteCountInDb.c !== allowedCount) {
496
+ rebuildIndex(vaultRoot, config, db);
497
+ return;
498
+ }
499
+ upsertNoteAndLinks(db, note);
500
+ }
501
+ function ensureIndex(vaultRoot, config) {
502
+ const db = openDatabase(vaultRoot);
503
+ if (config.index.auto_rebuild) {
504
+ rebuildIndex(vaultRoot, config, db);
505
+ }
506
+ return db;
507
+ }
508
+ function countNoteFiles(vaultRoot, config) {
509
+ let count = 0;
510
+ for (const typeConfig of Object.values(config.note_types)) {
511
+ const folder = path5.join(vaultRoot, typeConfig.folder);
512
+ if (!fs5.existsSync(folder)) continue;
513
+ count += fs5.readdirSync(folder).filter((file) => file.endsWith(".md")).length;
514
+ }
515
+ return count;
516
+ }
517
+ function upsertNoteAndLinks(db, note) {
518
+ const upsertNote = db.prepare(`
519
+ INSERT INTO notes (slug, id, title, type, created, modified, tags, aliases, body, filepath)
520
+ VALUES (@slug, @id, @title, @type, @created, @modified, @tags, @aliases, @body, @filepath)
521
+ ON CONFLICT(slug) DO UPDATE SET
522
+ id = excluded.id,
523
+ title = excluded.title,
524
+ type = excluded.type,
525
+ created = excluded.created,
526
+ modified = excluded.modified,
527
+ tags = excluded.tags,
528
+ aliases = excluded.aliases,
529
+ body = excluded.body,
530
+ filepath = excluded.filepath
531
+ `);
532
+ const deleteLinks = db.prepare("DELETE FROM links WHERE source_slug = ?");
533
+ const insertLink = db.prepare(`
534
+ INSERT INTO links (source_slug, target_slug, target_raw, context)
535
+ VALUES (@source_slug, @target_slug, @target_raw, @context)
536
+ `);
537
+ const transaction = db.transaction(() => {
538
+ upsertNote.run({
539
+ slug: note.slug,
540
+ id: note.frontmatter.id,
541
+ title: note.frontmatter.title,
542
+ type: note.frontmatter.type,
543
+ created: note.frontmatter.created,
544
+ modified: note.frontmatter.modified,
545
+ tags: JSON.stringify(note.frontmatter.tags),
546
+ aliases: JSON.stringify(note.frontmatter.aliases),
547
+ body: note.body,
548
+ filepath: note.filepath
549
+ });
550
+ deleteLinks.run(note.slug);
551
+ const links = parseWikilinks(note.body);
552
+ for (const link of links) {
553
+ const resolvedSlug = resolveLinkTarget(db, link.target);
554
+ const contextLine = note.body.split("\n").find((line) => line.includes(link.raw)) ?? "";
555
+ insertLink.run({
556
+ source_slug: note.slug,
557
+ target_slug: resolvedSlug,
558
+ target_raw: link.target,
559
+ context: contextLine.trim()
560
+ });
561
+ }
562
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run("last_rebuild", (/* @__PURE__ */ new Date()).toISOString());
563
+ });
564
+ transaction();
565
+ }
566
+ function resolveLinksAgainstNotes(links, allNotes) {
567
+ return links.map((link) => {
568
+ const targetSlug = slugify(link.target);
569
+ let found = allNotes.find((note) => note.slug === targetSlug);
570
+ if (!found) {
571
+ found = allNotes.find((note) => note.frontmatter.title.toLowerCase() === link.target.toLowerCase());
572
+ }
573
+ if (!found) {
574
+ found = allNotes.find(
575
+ (note) => note.frontmatter.aliases.some((alias) => alias.toLowerCase() === link.target.toLowerCase())
576
+ );
577
+ }
578
+ return {
579
+ ...link,
580
+ resolved: !!found,
581
+ resolved_slug: found?.slug
582
+ };
583
+ });
584
+ }
585
+ function resolveLinkTarget(db, target) {
586
+ const targetSlug = slugify(target);
587
+ const exact = db.prepare(`
588
+ SELECT slug
589
+ FROM notes
590
+ WHERE slug = ? OR lower(title) = ?
591
+ LIMIT 1
592
+ `).get(targetSlug, target.toLowerCase());
593
+ if (exact) return exact.slug;
594
+ const rows = db.prepare("SELECT slug, aliases FROM notes").all();
595
+ for (const row of rows) {
596
+ try {
597
+ const aliases = JSON.parse(row.aliases || "[]");
598
+ if (aliases.some((alias) => typeof alias === "string" && alias.toLowerCase() === target.toLowerCase())) {
599
+ return row.slug;
600
+ }
601
+ } catch {
602
+ continue;
603
+ }
604
+ }
605
+ return null;
606
+ }
607
+
608
+ // src/core/suggest.ts
609
+ function suggestLinks(db, note) {
610
+ const existingLinks = new Set(
611
+ parseWikilinks(note.body).map((l) => l.target.toLowerCase())
612
+ );
613
+ const allNotes = db.prepare("SELECT slug, title, aliases FROM notes WHERE slug != ?").all(note.slug);
614
+ const bodyLower = note.body.toLowerCase();
615
+ const suggestions = [];
616
+ for (const other of allNotes) {
617
+ const titleLower = other.title.toLowerCase();
618
+ if (existingLinks.has(titleLower) || existingLinks.has(other.slug)) continue;
619
+ let mentions = 0;
620
+ let idx = 0;
621
+ while ((idx = bodyLower.indexOf(titleLower, idx)) !== -1) {
622
+ mentions++;
623
+ idx += titleLower.length;
624
+ }
625
+ const aliases = JSON.parse(other.aliases || "[]");
626
+ for (const alias of aliases) {
627
+ const aliasLower = alias.toLowerCase();
628
+ if (existingLinks.has(aliasLower)) continue;
629
+ let aidx = 0;
630
+ while ((aidx = bodyLower.indexOf(aliasLower, aidx)) !== -1) {
631
+ mentions++;
632
+ aidx += aliasLower.length;
633
+ }
634
+ }
635
+ if (mentions > 0) {
636
+ suggestions.push({
637
+ target_slug: other.slug,
638
+ target_title: other.title,
639
+ mentions
640
+ });
641
+ }
642
+ }
643
+ return suggestions.sort((a, b) => b.mentions - a.mentions);
644
+ }
645
+
646
+ // src/core/recommendations.ts
647
+ var MAX_LINK_RECOMMENDATIONS = 3;
648
+ var MAX_TAG_RECOMMENDATIONS = 3;
649
+ var MAX_ADDITION_RECOMMENDATIONS = 3;
650
+ var STOP_WORDS = /* @__PURE__ */ new Set([
651
+ "the",
652
+ "and",
653
+ "for",
654
+ "with",
655
+ "from",
656
+ "that",
657
+ "this",
658
+ "into",
659
+ "about",
660
+ "your",
661
+ "have",
662
+ "will",
663
+ "them",
664
+ "they",
665
+ "what",
666
+ "when",
667
+ "where",
668
+ "which",
669
+ "their",
670
+ "there",
671
+ "here",
672
+ "then",
673
+ "than",
674
+ "also",
675
+ "just",
676
+ "should",
677
+ "could",
678
+ "would",
679
+ "into",
680
+ "over",
681
+ "under",
682
+ "after",
683
+ "before",
684
+ "using",
685
+ "used",
686
+ "make",
687
+ "made",
688
+ "more",
689
+ "most",
690
+ "some",
691
+ "such",
692
+ "each",
693
+ "many",
694
+ "only",
695
+ "through",
696
+ "between",
697
+ "within",
698
+ "write",
699
+ "notes",
700
+ "note",
701
+ "links",
702
+ "link",
703
+ "summary",
704
+ "details",
705
+ "source",
706
+ "date",
707
+ "role",
708
+ "context",
709
+ "contact",
710
+ "attendees",
711
+ "agenda",
712
+ "decisions",
713
+ "actions",
714
+ "goal",
715
+ "status",
716
+ "people",
717
+ "rationale",
718
+ "decision",
719
+ "options",
720
+ "considered",
721
+ "active",
722
+ "points",
723
+ "take",
724
+ "idea",
725
+ "ideas",
726
+ "project",
727
+ "meeting",
728
+ "person",
729
+ "reference",
730
+ "permanent",
731
+ "fleeting",
732
+ "how",
733
+ "know",
734
+ "work",
735
+ "works",
736
+ "what",
737
+ "prompted",
738
+ "them",
739
+ "what",
740
+ "plus",
741
+ "latest",
742
+ "recent",
743
+ "capture",
744
+ "record",
745
+ "refine",
746
+ "later",
747
+ "local",
748
+ "first",
749
+ "short",
750
+ "clear",
751
+ "split",
752
+ "related",
753
+ "relevant",
754
+ "title",
755
+ "titles"
756
+ ]);
757
+ function recommendNote(vaultRoot, config, note, options = {}) {
758
+ const strategy = options.strategy ?? "rebuild";
759
+ const db = strategy === "incremental" ? openDatabase(vaultRoot) : ensureIndex(vaultRoot, config);
760
+ try {
761
+ if (strategy === "incremental") {
762
+ syncNoteInIndex(vaultRoot, config, db, note);
763
+ }
764
+ return getRecommendations(db, note, config);
765
+ } finally {
766
+ db.close();
767
+ }
768
+ }
769
+ function getRecommendations(db, note, config) {
770
+ const nearbyNotes = getNearbyNotes(db, note);
771
+ const existingTargets = getExistingTargets(db, note);
772
+ const mentionLinks = getMentionRecommendations(db, note);
773
+ const excludedSlugs = new Set(mentionLinks.map((link) => link.slug));
774
+ const similarLinks = getSearchRecommendations(db, note, existingTargets, excludedSlugs);
775
+ const links = [...mentionLinks, ...similarLinks].sort((a, b) => b.rank - a.rank || a.title.localeCompare(b.title)).slice(0, MAX_LINK_RECOMMENDATIONS);
776
+ const allNearbyNotes = mergeNearbyNotes(nearbyNotes, links);
777
+ return {
778
+ additions: getAdditionRecommendations(note, config),
779
+ links: links.map(({ tags: _tags, rank: _rank, ...link }) => link),
780
+ tags: getTagRecommendations(note, allNearbyNotes),
781
+ next_steps: getNextSteps(note, allNearbyNotes)
782
+ };
783
+ }
784
+ function formatRecommendations(recommendations) {
785
+ const lines = [];
786
+ if (recommendations.additions.length === 0 && recommendations.links.length === 0 && recommendations.tags.length === 0 && recommendations.next_steps.length === 0) {
787
+ return lines;
788
+ }
789
+ if (recommendations.additions.length > 0) {
790
+ lines.push(" Add now:");
791
+ for (const item of recommendations.additions) {
792
+ lines.push(` ${item.text}`);
793
+ }
794
+ }
795
+ if (recommendations.links.length > 0) {
796
+ lines.push(" Link now:");
797
+ for (const link of recommendations.links) {
798
+ lines.push(` [[${link.slug}]] \u2014 ${link.reason}`);
799
+ }
800
+ }
801
+ if (recommendations.tags.length > 0) {
802
+ lines.push(" Tag now:");
803
+ for (const tag of recommendations.tags) {
804
+ const notes = tag.source_slugs.length === 1 ? "1 nearby note" : `${tag.source_slugs.length} nearby notes`;
805
+ lines.push(` ${tag.tag} \u2014 seen on ${notes}`);
806
+ }
807
+ }
808
+ if (recommendations.next_steps.length > 0) {
809
+ lines.push(" Next note:");
810
+ for (const step of recommendations.next_steps) {
811
+ const hint = step.title_hint ? ` Title hint: ${step.title_hint}.` : "";
812
+ lines.push(` ${step.type} \u2014 ${step.reason}${hint}`);
813
+ }
814
+ }
815
+ return lines;
816
+ }
817
+ function getExistingTargets(db, note) {
818
+ const existing = /* @__PURE__ */ new Set();
819
+ const rows = db.prepare(`
820
+ SELECT target_slug, target_raw
821
+ FROM links
822
+ WHERE source_slug = ?
823
+ `).all(note.slug);
824
+ for (const row of rows) {
825
+ existing.add(row.target_raw.toLowerCase());
826
+ if (row.target_slug) {
827
+ existing.add(row.target_slug.toLowerCase());
828
+ }
829
+ }
830
+ return existing;
831
+ }
832
+ function getMentionRecommendations(db, note) {
833
+ const raw = suggestLinks(db, note);
834
+ const recommendations = [];
835
+ for (const suggestion of raw) {
836
+ const metadata = getNoteMetadata(db, suggestion.target_slug);
837
+ if (!metadata) continue;
838
+ recommendations.push({
839
+ slug: metadata.slug,
840
+ title: metadata.title,
841
+ type: metadata.type,
842
+ reason: `mentioned ${suggestion.mentions} time${suggestion.mentions === 1 ? "" : "s"}`,
843
+ source: "mention",
844
+ tags: metadata.tags,
845
+ rank: 100 + suggestion.mentions
846
+ });
847
+ }
848
+ return recommendations;
849
+ }
850
+ function getSearchRecommendations(db, note, existingTargets, excludedSlugs) {
851
+ const query = buildSearchQuery(note);
852
+ if (!query) return [];
853
+ const rows = db.prepare(`
854
+ SELECT
855
+ n.slug,
856
+ n.title,
857
+ n.type,
858
+ n.tags,
859
+ bm25(notes_fts, 8.0, 1.5, 0.5) AS score
860
+ FROM notes_fts
861
+ JOIN notes n ON n.rowid = notes_fts.rowid
862
+ WHERE notes_fts MATCH ? AND n.slug != ?
863
+ ORDER BY score
864
+ LIMIT 12
865
+ `).all(query, note.slug);
866
+ const out = [];
867
+ for (const row of rows) {
868
+ if (existingTargets.has(row.slug.toLowerCase()) || excludedSlugs.has(row.slug)) continue;
869
+ out.push({
870
+ slug: row.slug,
871
+ title: row.title,
872
+ type: row.type,
873
+ reason: "shared keywords",
874
+ source: "search",
875
+ tags: parseJsonArray(row.tags),
876
+ rank: Math.max(1, 40 - Math.round(row.score * 10))
877
+ });
878
+ }
879
+ return out;
880
+ }
881
+ function getTagRecommendations(note, nearbyNotes) {
882
+ const existingTags = new Set(note.frontmatter.tags.map((tag) => tag.toLowerCase()));
883
+ const noteTokens = new Set(tokenize(`${note.frontmatter.title} ${note.body}`));
884
+ const counts = /* @__PURE__ */ new Map();
885
+ for (const nearby of nearbyNotes) {
886
+ const weight = getNearbyWeight(nearby.source);
887
+ for (const tag of nearby.tags) {
888
+ const normalized = tag.trim().toLowerCase();
889
+ if (!normalized || existingTags.has(normalized)) continue;
890
+ const entry = counts.get(normalized) ?? { weight: 0, sourceSlugs: /* @__PURE__ */ new Set() };
891
+ entry.weight += weight;
892
+ if (tagMatchesNoteTokens(normalized, noteTokens)) {
893
+ entry.weight += 2;
894
+ }
895
+ entry.sourceSlugs.add(nearby.slug);
896
+ counts.set(normalized, entry);
897
+ }
898
+ }
899
+ return [...counts.entries()].map(([tag, info]) => ({
900
+ tag,
901
+ weight: info.weight,
902
+ source_slugs: [...info.sourceSlugs]
903
+ })).sort((a, b) => b.weight - a.weight || a.tag.localeCompare(b.tag)).slice(0, MAX_TAG_RECOMMENDATIONS);
904
+ }
905
+ function getNextSteps(note, nearbyNotes) {
906
+ const projectLink = nearbyNotes.find((link) => link.type === "project");
907
+ const personLink = nearbyNotes.find((link) => link.type === "person");
908
+ switch (note.frontmatter.type) {
909
+ case "fleeting":
910
+ return [{
911
+ type: "permanent",
912
+ title_hint: note.frontmatter.title,
913
+ reason: "Promote this into a durable note if it keeps mattering."
914
+ }];
915
+ case "reference":
916
+ return [{
917
+ type: "permanent",
918
+ title_hint: `Idea from ${note.frontmatter.title}`,
919
+ reason: "Distill one durable idea from this source."
920
+ }];
921
+ case "person":
922
+ return [{
923
+ type: projectLink ? "meeting" : "project",
924
+ title_hint: projectLink ? `${note.frontmatter.title} + ${projectLink.title}` : `${note.frontmatter.title} collaboration`,
925
+ reason: projectLink ? "Capture the latest interaction connected to this person." : "Anchor this person to a company, project, or collaboration note."
926
+ }];
927
+ case "meeting":
928
+ return [{
929
+ type: "decision",
930
+ title_hint: `Decision from ${note.frontmatter.title}`,
931
+ reason: "Extract the main decision so it can be linked independently."
932
+ }];
933
+ case "project":
934
+ return [{
935
+ type: personLink ? "meeting" : "decision",
936
+ title_hint: personLink ? `${note.frontmatter.title} sync` : `${note.frontmatter.title} decision`,
937
+ reason: personLink ? "Capture the next concrete conversation for this project." : "Record the decision that drives this project forward."
938
+ }];
939
+ case "decision":
940
+ return [{
941
+ type: "project",
942
+ title_hint: `${note.frontmatter.title} rollout`,
943
+ reason: "Attach this decision to the project where it applies."
944
+ }];
945
+ case "permanent":
946
+ return [{
947
+ type: projectLink ? "decision" : "project",
948
+ title_hint: projectLink ? `Decision from ${note.frontmatter.title}` : `${note.frontmatter.title} in practice`,
949
+ reason: projectLink ? "Capture the concrete decision this idea supports." : "Anchor this idea in a project, company, or active thread."
950
+ }];
951
+ default:
952
+ return [];
953
+ }
954
+ }
955
+ function getAdditionRecommendations(note, config) {
956
+ const recommendations = [];
957
+ const body = note.body;
958
+ const existingLinks = parseWikilinks(body);
959
+ const templateBody = config?.note_types[note.frontmatter.type]?.template ?? "";
960
+ switch (note.frontmatter.type) {
961
+ case "person":
962
+ if (isSectionSparse(body, "Role", templateBody)) {
963
+ recommendations.push({ text: "Add a one-line role, company, or why this person matters." });
964
+ }
965
+ if (isSectionSparse(body, "Context", templateBody)) {
966
+ recommendations.push({ text: "Add how you know them or what you work on together." });
967
+ }
968
+ if (existingLinks.length === 0) {
969
+ recommendations.push({ text: "Link one project, company, or topic in the Links section." });
970
+ }
971
+ break;
972
+ case "reference":
973
+ if (!hasUrl(body) || body.includes("URL or citation here.")) {
974
+ recommendations.push({ text: "Add the source URL or citation." });
975
+ }
976
+ if (!hasMeaningfulBullets(body, "Key Points")) {
977
+ recommendations.push({ text: "Write 2-3 key points in your own words." });
978
+ }
979
+ if (existingLinks.length === 0) {
980
+ recommendations.push({ text: "Link one permanent note, project, or topic this source relates to." });
981
+ }
982
+ break;
983
+ case "project":
984
+ if (isSectionSparse(body, "Goal", templateBody)) {
985
+ recommendations.push({ text: "Add a one-line goal so the project has a clear anchor." });
986
+ }
987
+ if (isSectionSparse(body, "People", templateBody)) {
988
+ recommendations.push({ text: "Link the people involved in the project." });
989
+ }
990
+ if (!hasMeaningfulBullets(body, "Key Decisions")) {
991
+ recommendations.push({ text: "Record one key decision or current status update." });
992
+ }
993
+ break;
994
+ case "meeting":
995
+ if (!hasAnyWikilinks(body)) {
996
+ recommendations.push({ text: "Link the attendees directly in the note." });
997
+ }
998
+ if (!hasMeaningfulBullets(body, "Actions")) {
999
+ recommendations.push({ text: "Capture at least one next action." });
1000
+ }
1001
+ if (!hasMeaningfulBullets(body, "Decisions")) {
1002
+ recommendations.push({ text: "Capture one decision or open question." });
1003
+ }
1004
+ break;
1005
+ case "permanent":
1006
+ if (isSectionSparse(body, "Summary", templateBody)) {
1007
+ recommendations.push({ text: "Write a one-line summary before expanding the idea." });
1008
+ }
1009
+ if (existingLinks.length === 0) {
1010
+ recommendations.push({ text: "Link one project, company, person, or adjacent idea." });
1011
+ }
1012
+ break;
1013
+ case "decision":
1014
+ if (isSectionSparse(body, "Context", templateBody)) {
1015
+ recommendations.push({ text: "Add the context that forced this decision." });
1016
+ }
1017
+ if (isSectionSparse(body, "Decision", templateBody)) {
1018
+ recommendations.push({ text: "State the decision in one sentence." });
1019
+ }
1020
+ if (existingLinks.length === 0) {
1021
+ recommendations.push({ text: "Link the project, meeting, or note affected by this decision." });
1022
+ }
1023
+ break;
1024
+ case "fleeting":
1025
+ if (tokenize(body).length < 8) {
1026
+ recommendations.push({ text: "Add one more sentence if this capture is worth keeping." });
1027
+ }
1028
+ break;
1029
+ default:
1030
+ break;
1031
+ }
1032
+ if (config) {
1033
+ const typeTemplate = config.note_types[note.frontmatter.type]?.template ?? "";
1034
+ if (body.trim() === typeTemplate.trim() && recommendations.length === 0) {
1035
+ recommendations.push({ text: "Fill the main sections before asking for related notes." });
1036
+ }
1037
+ }
1038
+ return recommendations.slice(0, MAX_ADDITION_RECOMMENDATIONS);
1039
+ }
1040
+ function buildSearchQuery(note) {
1041
+ const counts = /* @__PURE__ */ new Map();
1042
+ const titleTokens = tokenize(note.frontmatter.title);
1043
+ const bodyTokens = tokenize(note.body);
1044
+ for (const token of titleTokens) {
1045
+ counts.set(token, (counts.get(token) ?? 0) + 3);
1046
+ }
1047
+ for (const token of bodyTokens) {
1048
+ counts.set(token, (counts.get(token) ?? 0) + 1);
1049
+ }
1050
+ const terms = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([token]) => token).slice(0, 8);
1051
+ return terms.map((token) => `"${token}"`).join(" OR ");
1052
+ }
1053
+ function tokenize(text) {
1054
+ const matches = text.toLowerCase().match(/[\p{L}\p{N}]{3,}/gu) ?? [];
1055
+ return matches.filter((token) => !STOP_WORDS.has(token));
1056
+ }
1057
+ function tagMatchesNoteTokens(tag, noteTokens) {
1058
+ const tagTokens = tokenize(tag.replace(/[-_]/g, " "));
1059
+ return tagTokens.some((token) => noteTokens.has(token));
1060
+ }
1061
+ function getNearbyNotes(db, note) {
1062
+ const bySlug = /* @__PURE__ */ new Map();
1063
+ const outgoingRows = db.prepare(`
1064
+ SELECT DISTINCT n.slug, n.title, n.type, n.tags
1065
+ FROM links l
1066
+ JOIN notes n ON n.slug = l.target_slug
1067
+ WHERE l.source_slug = ?
1068
+ `).all(note.slug);
1069
+ for (const row of outgoingRows) {
1070
+ bySlug.set(row.slug, {
1071
+ slug: row.slug,
1072
+ title: row.title,
1073
+ type: row.type,
1074
+ tags: parseJsonArray(row.tags),
1075
+ source: "outgoing"
1076
+ });
1077
+ }
1078
+ const backlinkRows = db.prepare(`
1079
+ SELECT DISTINCT n.slug, n.title, n.type, n.tags
1080
+ FROM links l
1081
+ JOIN notes n ON n.slug = l.source_slug
1082
+ WHERE l.target_slug = ? AND n.slug != ?
1083
+ `).all(note.slug, note.slug);
1084
+ for (const row of backlinkRows) {
1085
+ if (bySlug.has(row.slug)) continue;
1086
+ bySlug.set(row.slug, {
1087
+ slug: row.slug,
1088
+ title: row.title,
1089
+ type: row.type,
1090
+ tags: parseJsonArray(row.tags),
1091
+ source: "backlink"
1092
+ });
1093
+ }
1094
+ return [...bySlug.values()];
1095
+ }
1096
+ function mergeNearbyNotes(nearbyNotes, links) {
1097
+ const merged = /* @__PURE__ */ new Map();
1098
+ for (const nearby of nearbyNotes) {
1099
+ merged.set(nearby.slug, nearby);
1100
+ }
1101
+ for (const link of links) {
1102
+ if (merged.has(link.slug)) continue;
1103
+ merged.set(link.slug, {
1104
+ slug: link.slug,
1105
+ title: link.title,
1106
+ type: link.type,
1107
+ tags: link.tags,
1108
+ source: link.source
1109
+ });
1110
+ }
1111
+ return [...merged.values()];
1112
+ }
1113
+ function getNearbyWeight(source) {
1114
+ switch (source) {
1115
+ case "outgoing":
1116
+ case "mention":
1117
+ return 2;
1118
+ case "backlink":
1119
+ case "search":
1120
+ default:
1121
+ return 1;
1122
+ }
1123
+ }
1124
+ function isSectionSparse(body, heading, templateBody) {
1125
+ const section = getSectionContent(body, heading);
1126
+ if (!section) return true;
1127
+ const cleaned = section.replace(/^- \[ \]\s*$/gm, "").replace(/^-\s*$/gm, "").trim();
1128
+ const templateSection = templateBody ? getSectionContent(templateBody, heading).trim() : "";
1129
+ if (templateSection && cleaned === templateSection) {
1130
+ return true;
1131
+ }
1132
+ return cleaned.length < 8;
1133
+ }
1134
+ function hasMeaningfulBullets(body, heading) {
1135
+ const section = getSectionContent(body, heading);
1136
+ if (!section) return false;
1137
+ return section.split("\n").some((line) => /^-/.test(line.trim()) && line.replace(/^-+\s*/, "").trim().length > 2);
1138
+ }
1139
+ function getSectionContent(body, heading) {
1140
+ const lines = body.split("\n");
1141
+ const headingLine = `## ${heading}`;
1142
+ const startIndex = lines.findIndex((line) => line.trim() === headingLine);
1143
+ if (startIndex === -1) return "";
1144
+ const collected = [];
1145
+ for (let index = startIndex + 1; index < lines.length; index++) {
1146
+ if (lines[index].startsWith("## ")) break;
1147
+ collected.push(lines[index]);
1148
+ }
1149
+ return collected.join("\n");
1150
+ }
1151
+ function hasUrl(body) {
1152
+ return /https?:\/\/\S+/i.test(body);
1153
+ }
1154
+ function hasAnyWikilinks(body) {
1155
+ return parseWikilinks(body).length > 0;
1156
+ }
1157
+ function getNoteMetadata(db, slug) {
1158
+ if (!slug) return null;
1159
+ const row = db.prepare(`
1160
+ SELECT slug, title, type, tags
1161
+ FROM notes
1162
+ WHERE slug = ?
1163
+ `).get(slug);
1164
+ if (!row) return null;
1165
+ return {
1166
+ slug: row.slug,
1167
+ title: row.title,
1168
+ type: row.type,
1169
+ tags: parseJsonArray(row.tags)
1170
+ };
1171
+ }
1172
+ function parseJsonArray(raw) {
1173
+ if (!raw) return [];
1174
+ try {
1175
+ const value = JSON.parse(raw);
1176
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
1177
+ } catch {
1178
+ return [];
1179
+ }
1180
+ }
1181
+
1182
+ // src/commands/new.ts
1183
+ function newNote(title, options) {
1184
+ const vaultRoot = requireVaultRoot();
1185
+ const config = loadConfig(vaultRoot);
1186
+ const resolvedType = options.type || config.defaults.note_type;
1187
+ const typeConfig = config.note_types[resolvedType];
1188
+ const bodyOverride = typeConfig?.slug_format === "date" ? title + "\n" : void 0;
1189
+ const note = createNote(vaultRoot, config, resolvedType, title, bodyOverride);
1190
+ if (options.source || options.status) {
1191
+ if (options.source) validateSource(options.source);
1192
+ if (options.status) validateStatus(options.status);
1193
+ const raw = fs6.readFileSync(note.filepath, "utf-8");
1194
+ const { frontmatter, body } = parseFrontmatter(raw);
1195
+ if (options.source) frontmatter.source = options.source;
1196
+ if (options.status) frontmatter.status = options.status;
1197
+ fs6.writeFileSync(note.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
1198
+ note.frontmatter = frontmatter;
1199
+ }
1200
+ const recommendationStrategy = typeConfig?.slug_format === "date" ? "incremental" : "rebuild";
1201
+ const recommendations = recommendNote(vaultRoot, config, note, { strategy: recommendationStrategy });
1202
+ const lines = note.body.split("\n").length;
1203
+ const overLimit = typeConfig && typeConfig.line_limit && lines > typeConfig.line_limit;
1204
+ if (options.json) {
1205
+ console.log(jsonSuccess({
1206
+ slug: note.slug,
1207
+ title: note.frontmatter.title,
1208
+ type: resolvedType,
1209
+ status: note.frontmatter.status,
1210
+ source: note.frontmatter.source,
1211
+ filepath: note.filepath,
1212
+ suggestions: recommendations.links.map((link) => ({
1213
+ slug: link.slug,
1214
+ title: link.title,
1215
+ type: link.type
1216
+ })),
1217
+ recommendations
1218
+ }));
1219
+ return;
1220
+ }
1221
+ if (overLimit) {
1222
+ const action = typeConfig.warn_only ? "Warning" : "Error";
1223
+ console.warn(`${action}: Note exceeds ${typeConfig.line_limit} line limit for type "${resolvedType}"`);
1224
+ }
1225
+ console.log(note.filepath);
1226
+ if (typeConfig?.instructions) {
1227
+ console.log("");
1228
+ console.log(` \u{1F4A1} ${typeConfig.instructions}`);
1229
+ }
1230
+ const recommendationLines = formatRecommendations(recommendations);
1231
+ if (recommendationLines.length > 0) {
1232
+ console.log("");
1233
+ console.log("Recommendations:");
1234
+ for (const line of recommendationLines) {
1235
+ console.log(line);
1236
+ }
1237
+ }
1238
+ }
1239
+
1240
+ // src/commands/add.ts
1241
+ import fs7 from "fs";
1242
+ function addNote(text, options = {}) {
1243
+ const vaultRoot = requireVaultRoot();
1244
+ const config = loadConfig(vaultRoot);
1245
+ const typeName = config.defaults.note_type;
1246
+ let content;
1247
+ if (text) {
1248
+ content = text;
1249
+ } else if (!process.stdin.isTTY) {
1250
+ content = fs7.readFileSync(0, "utf-8").trim();
1251
+ } else {
1252
+ console.error('Usage: mem add "some text" or echo "text" | mem add');
1253
+ process.exit(1);
1254
+ }
1255
+ if (!content) {
1256
+ console.error("No content provided.");
1257
+ process.exit(1);
1258
+ }
1259
+ const firstLine = content.split("\n")[0];
1260
+ const title = firstLine.length > 60 ? firstLine.slice(0, 60).trim() + "..." : firstLine;
1261
+ const note = createNote(vaultRoot, config, typeName, title, content + "\n");
1262
+ const recommendations = recommendNote(vaultRoot, config, note, { strategy: "incremental" });
1263
+ if (options.json) {
1264
+ console.log(jsonSuccess({
1265
+ slug: note.slug,
1266
+ title: note.frontmatter.title,
1267
+ type: typeName,
1268
+ filepath: note.filepath,
1269
+ recommendations
1270
+ }));
1271
+ return;
1272
+ }
1273
+ console.log(note.filepath);
1274
+ const recommendationLines = formatRecommendations(recommendations);
1275
+ if (recommendationLines.length > 0) {
1276
+ console.log("");
1277
+ console.log("Recommendations:");
1278
+ for (const line of recommendationLines) {
1279
+ console.log(line);
1280
+ }
1281
+ }
520
1282
  }
521
- function openDatabase(vaultRoot) {
522
- const dbPath = getIndexDbPath(vaultRoot);
523
- if (!fs7.existsSync(dbPath)) {
524
- return createDatabase(dbPath);
1283
+
1284
+ // src/commands/open.ts
1285
+ import { execSync } from "child_process";
1286
+ function openNote(slug) {
1287
+ const vaultRoot = requireVaultRoot();
1288
+ const config = loadConfig(vaultRoot);
1289
+ const note = findNoteBySlug(vaultRoot, config, slug);
1290
+ if (!note) {
1291
+ console.error(`Note not found: "${slug}"`);
1292
+ process.exit(1);
525
1293
  }
526
- const db = new Database(dbPath);
527
- db.pragma("journal_mode = WAL");
528
- return db;
1294
+ const editor = config.defaults.editor === "$EDITOR" ? process.env.EDITOR || "vi" : config.defaults.editor;
1295
+ execSync(`${editor} "${note.filepath}"`, { stdio: "inherit" });
529
1296
  }
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
- }
1297
+
1298
+ // src/commands/show.ts
1299
+ import fs8 from "fs";
1300
+ function showCommand(slug, options) {
1301
+ const vaultRoot = requireVaultRoot();
1302
+ const config = loadConfig(vaultRoot);
1303
+ const note = findNoteBySlug(vaultRoot, config, slug);
1304
+ if (!note) {
1305
+ if (options.json) {
1306
+ console.log(jsonError(`Note not found: ${slug}`));
1307
+ } else {
1308
+ console.error(`Note not found: ${slug}`);
569
1309
  }
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);
1310
+ process.exit(1);
578
1311
  }
579
- return db;
1312
+ if (options.json) {
1313
+ console.log(jsonSuccess({
1314
+ slug: note.slug,
1315
+ title: note.frontmatter.title,
1316
+ type: note.frontmatter.type,
1317
+ created: note.frontmatter.created,
1318
+ modified: note.frontmatter.modified,
1319
+ tags: note.frontmatter.tags,
1320
+ aliases: note.frontmatter.aliases,
1321
+ status: note.frontmatter.status,
1322
+ source: note.frontmatter.source,
1323
+ body: note.body,
1324
+ filepath: note.filepath
1325
+ }));
1326
+ return;
1327
+ }
1328
+ if (options.body) {
1329
+ process.stdout.write(note.body);
1330
+ return;
1331
+ }
1332
+ console.log(`# ${note.frontmatter.title} (${note.slug})`);
1333
+ console.log(`# type: ${note.frontmatter.type} | modified: ${note.frontmatter.modified.slice(0, 10)}`);
1334
+ console.log("");
1335
+ const content = fs8.readFileSync(note.filepath, "utf-8");
1336
+ console.log(content);
580
1337
  }
581
1338
 
582
1339
  // src/core/search.ts
@@ -665,44 +1422,6 @@ function backlinksCommand(slug, options) {
665
1422
  }
666
1423
  }
667
1424
 
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
1425
  // src/commands/suggest-links.ts
707
1426
  function suggestLinksCommand(slug, options) {
708
1427
  const vaultRoot = requireVaultRoot();
@@ -734,15 +1453,50 @@ function suggestLinksCommand(slug, options) {
734
1453
  }
735
1454
  }
736
1455
 
1456
+ // src/commands/recommend.ts
1457
+ function recommendCommand(slug, options) {
1458
+ const vaultRoot = requireVaultRoot();
1459
+ const config = loadConfig(vaultRoot);
1460
+ const note = findNoteBySlug(vaultRoot, config, slug);
1461
+ if (!note) {
1462
+ if (options.json) {
1463
+ console.log(jsonError(`Note not found: ${slug}`));
1464
+ } else {
1465
+ console.error(`Note not found: "${slug}"`);
1466
+ }
1467
+ process.exit(1);
1468
+ }
1469
+ const recommendations = recommendNote(vaultRoot, config, note);
1470
+ if (options.json) {
1471
+ console.log(jsonSuccess({
1472
+ slug: note.slug,
1473
+ title: note.frontmatter.title,
1474
+ type: note.frontmatter.type,
1475
+ recommendations
1476
+ }));
1477
+ return;
1478
+ }
1479
+ const lines = formatRecommendations(recommendations);
1480
+ if (lines.length === 0) {
1481
+ console.log(`No recommendations found for "${note.frontmatter.title}".`);
1482
+ return;
1483
+ }
1484
+ console.log(`Recommendations for "${note.frontmatter.title}":`);
1485
+ console.log("");
1486
+ for (const line of lines) {
1487
+ console.log(line);
1488
+ }
1489
+ }
1490
+
737
1491
  // src/core/doctor.ts
738
- import fs8 from "fs";
1492
+ import fs9 from "fs";
739
1493
  import path6 from "path";
740
1494
  function runDoctor(vaultRoot, config, db) {
741
1495
  const issues = [];
742
1496
  const notes = listNotes(vaultRoot, config);
743
1497
  for (const [name, typeConfig] of Object.entries(config.note_types)) {
744
1498
  const folder = path6.join(vaultRoot, typeConfig.folder);
745
- if (!fs8.existsSync(folder)) {
1499
+ if (!fs9.existsSync(folder)) {
746
1500
  issues.push({
747
1501
  level: "error",
748
1502
  file: `granite.yml`,
@@ -878,7 +1632,7 @@ ${errors.length} error(s), ${warnings.length} warning(s), ${infos.length} info(s
878
1632
  import { Hono } from "hono";
879
1633
  import { serve } from "@hono/node-server";
880
1634
  import path7 from "path";
881
- import fs9 from "fs";
1635
+ import fs10 from "fs";
882
1636
  function createApp(vaultRoot, config) {
883
1637
  const app = new Hono();
884
1638
  app.get("/api/notes", (c) => {
@@ -968,12 +1722,12 @@ function createApp(vaultRoot, config) {
968
1722
  app.get("/*", (c) => {
969
1723
  const reqPath = c.req.path === "/" ? "/index.html" : c.req.path;
970
1724
  const filePath = path7.join(publicDir, reqPath);
971
- if (!fs9.existsSync(filePath)) {
1725
+ if (!fs10.existsSync(filePath)) {
972
1726
  const indexPath = path7.join(publicDir, "index.html");
973
- const html = fs9.readFileSync(indexPath, "utf-8");
1727
+ const html = fs10.readFileSync(indexPath, "utf-8");
974
1728
  return c.html(html);
975
1729
  }
976
- const content = fs9.readFileSync(filePath);
1730
+ const content = fs10.readFileSync(filePath);
977
1731
  const ext = path7.extname(filePath);
978
1732
  const mimeTypes = {
979
1733
  ".html": "text/html",
@@ -1024,6 +1778,45 @@ Usage: mem new <type> <title>`);
1024
1778
  }
1025
1779
 
1026
1780
  // src/commands/list.ts
1781
+ var AVAILABLE_FIELDS = ["slug", "title", "type", "created", "modified", "tags", "aliases", "status", "source", "filepath"];
1782
+ function pickFields(note, fields) {
1783
+ const out = {};
1784
+ for (const f of fields) {
1785
+ switch (f) {
1786
+ case "slug":
1787
+ out.slug = note.slug;
1788
+ break;
1789
+ case "title":
1790
+ out.title = note.frontmatter.title;
1791
+ break;
1792
+ case "type":
1793
+ out.type = note.frontmatter.type;
1794
+ break;
1795
+ case "created":
1796
+ out.created = note.frontmatter.created;
1797
+ break;
1798
+ case "modified":
1799
+ out.modified = note.frontmatter.modified;
1800
+ break;
1801
+ case "tags":
1802
+ out.tags = note.frontmatter.tags;
1803
+ break;
1804
+ case "aliases":
1805
+ out.aliases = note.frontmatter.aliases;
1806
+ break;
1807
+ case "status":
1808
+ out.status = note.frontmatter.status;
1809
+ break;
1810
+ case "source":
1811
+ out.source = note.frontmatter.source;
1812
+ break;
1813
+ case "filepath":
1814
+ out.filepath = note.filepath;
1815
+ break;
1816
+ }
1817
+ }
1818
+ return out;
1819
+ }
1027
1820
  function listCommand(options) {
1028
1821
  const vaultRoot = requireVaultRoot();
1029
1822
  const config = loadConfig(vaultRoot);
@@ -1031,17 +1824,28 @@ function listCommand(options) {
1031
1824
  if (options.type) {
1032
1825
  notes = notes.filter((n) => n.frontmatter.type === options.type);
1033
1826
  }
1827
+ if (options.status) {
1828
+ notes = notes.filter((n) => n.frontmatter.status === options.status);
1829
+ }
1830
+ if (options.source) {
1831
+ notes = notes.filter((n) => n.frontmatter.source === options.source);
1832
+ }
1833
+ if (options.since) {
1834
+ notes = notes.filter((n) => n.frontmatter.modified >= options.since);
1835
+ }
1034
1836
  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
- }));
1837
+ if (options.json !== void 0 && options.json !== false) {
1838
+ let fields;
1839
+ if (typeof options.json === "string") {
1840
+ fields = options.json.split(",").map((f) => f.trim()).filter((f) => AVAILABLE_FIELDS.includes(f));
1841
+ if (fields.length === 0) {
1842
+ console.log(`Available fields: ${AVAILABLE_FIELDS.join(", ")}`);
1843
+ return;
1844
+ }
1845
+ } else {
1846
+ fields = AVAILABLE_FIELDS;
1847
+ }
1848
+ const out = notes.map((n) => pickFields(n, fields));
1045
1849
  console.log(jsonSuccess(out));
1046
1850
  return;
1047
1851
  }
@@ -1052,14 +1856,15 @@ function listCommand(options) {
1052
1856
  for (const note of notes) {
1053
1857
  const date = note.frontmatter.modified.slice(0, 10);
1054
1858
  const type = note.frontmatter.type.padEnd(10);
1055
- console.log(` ${date} ${type} ${note.frontmatter.title} (${note.slug})`);
1859
+ const status = note.frontmatter.status?.padEnd(8) ?? "active ";
1860
+ console.log(` ${date} ${type} ${status} ${note.frontmatter.title} (${note.slug})`);
1056
1861
  }
1057
1862
  console.log(`
1058
1863
  ${notes.length} note(s)`);
1059
1864
  }
1060
1865
 
1061
1866
  // src/commands/edit.ts
1062
- import fs10 from "fs";
1867
+ import fs11 from "fs";
1063
1868
  import { execSync as execSync2 } from "child_process";
1064
1869
  function editCommand(slug, options) {
1065
1870
  const vaultRoot = requireVaultRoot();
@@ -1069,9 +1874,9 @@ function editCommand(slug, options) {
1069
1874
  console.error(`Note not found: ${slug}`);
1070
1875
  process.exit(1);
1071
1876
  }
1072
- const hasFlags = options.body !== void 0 || options.append !== void 0 || options.title !== void 0 || options.tag !== void 0 || options.alias !== void 0;
1877
+ 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
1878
  if (hasFlags) {
1074
- let { frontmatter, body } = parseFrontmatter(fs10.readFileSync(note.filepath, "utf-8"));
1879
+ let { frontmatter, body } = parseFrontmatter(fs11.readFileSync(note.filepath, "utf-8"));
1075
1880
  if (options.title) {
1076
1881
  frontmatter.title = options.title;
1077
1882
  }
@@ -1087,6 +1892,14 @@ function editCommand(slug, options) {
1087
1892
  for (const a of newAliases) existing.add(a);
1088
1893
  frontmatter.aliases = [...existing];
1089
1894
  }
1895
+ if (options.status) {
1896
+ validateStatus(options.status);
1897
+ frontmatter.status = options.status;
1898
+ }
1899
+ if (options.source) {
1900
+ validateSource(options.source);
1901
+ frontmatter.source = options.source;
1902
+ }
1090
1903
  if (options.body !== void 0) {
1091
1904
  body = options.body + "\n";
1092
1905
  }
@@ -1094,26 +1907,40 @@ function editCommand(slug, options) {
1094
1907
  body = body.trimEnd() + "\n" + options.append + "\n";
1095
1908
  }
1096
1909
  frontmatter.modified = (/* @__PURE__ */ new Date()).toISOString();
1097
- fs10.writeFileSync(note.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
1910
+ fs11.writeFileSync(note.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
1098
1911
  console.log(note.filepath);
1912
+ const recommendationStrategy = options.title !== void 0 || options.alias !== void 0 ? "rebuild" : "incremental";
1913
+ printRecommendations(vaultRoot, config, note.filepath, recommendationStrategy);
1099
1914
  } else {
1100
1915
  const editor = process.env.EDITOR || "vi";
1101
- const statBefore = fs10.statSync(note.filepath).mtimeMs;
1916
+ const statBefore = fs11.statSync(note.filepath).mtimeMs;
1102
1917
  try {
1103
1918
  execSync2(`${editor} "${note.filepath}"`, { stdio: "inherit" });
1104
1919
  } catch {
1105
1920
  console.error(`Failed to open editor: ${editor}`);
1106
1921
  process.exit(1);
1107
1922
  }
1108
- const statAfter = fs10.statSync(note.filepath).mtimeMs;
1923
+ const statAfter = fs11.statSync(note.filepath).mtimeMs;
1109
1924
  if (statAfter !== statBefore) {
1110
- const { frontmatter, body } = parseFrontmatter(fs10.readFileSync(note.filepath, "utf-8"));
1925
+ const { frontmatter, body } = parseFrontmatter(fs11.readFileSync(note.filepath, "utf-8"));
1111
1926
  frontmatter.modified = (/* @__PURE__ */ new Date()).toISOString();
1112
- fs10.writeFileSync(note.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
1927
+ fs11.writeFileSync(note.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
1113
1928
  console.log(`Updated: ${note.filepath}`);
1929
+ printRecommendations(vaultRoot, config, note.filepath, "rebuild");
1114
1930
  }
1115
1931
  }
1116
1932
  }
1933
+ function printRecommendations(vaultRoot, config, filepath, strategy) {
1934
+ const updatedNote = readNote(filepath);
1935
+ const recommendations = recommendNote(vaultRoot, config, updatedNote, { strategy });
1936
+ const lines = formatRecommendations(recommendations);
1937
+ if (lines.length === 0) return;
1938
+ console.log("");
1939
+ console.log("Recommendations:");
1940
+ for (const line of lines) {
1941
+ console.log(line);
1942
+ }
1943
+ }
1117
1944
 
1118
1945
  // src/index.ts
1119
1946
  var program = new Command();
@@ -1121,16 +1948,16 @@ program.name("mem").description("Granite \u2014 a local-first markdown memory sy
1121
1948
  program.command("init").description("Initialize a new vault in the current directory").action(() => {
1122
1949
  initVault(process.cwd());
1123
1950
  });
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) => {
1951
+ 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
1952
  newNote(title, options);
1126
1953
  });
1127
1954
  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
1955
  addNote(text, options);
1129
1956
  });
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) => {
1957
+ 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
1958
  listCommand(options);
1132
1959
  });
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) => {
1960
+ 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
1961
  editCommand(slug, options);
1135
1962
  });
1136
1963
  program.command("open").description("Open a note in your editor (alias for edit)").argument("<slug>", "Note slug").action((slug) => {
@@ -1148,6 +1975,9 @@ program.command("backlinks").description("Show notes that link to a given note")
1148
1975
  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
1976
  suggestLinksCommand(slug, options);
1150
1977
  });
1978
+ 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) => {
1979
+ recommendCommand(slug, options);
1980
+ });
1151
1981
  program.command("types").description("List available note types").action(() => {
1152
1982
  typesCommand();
1153
1983
  });