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.
- package/README.md +229 -0
- package/dist/index.js +1116 -286
- 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
|
-
|
|
267
|
-
function
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const resolvedType = options.type || config.defaults.note_type;
|
|
271
|
-
const typeConfig = config.note_types[resolvedType];
|
|
272
|
-
const bodyOverride = typeConfig?.slug_format === "date" ? title + "\n" : void 0;
|
|
273
|
-
const note = createNote(vaultRoot, config, resolvedType, title, bodyOverride);
|
|
274
|
-
const lines = note.body.split("\n").length;
|
|
275
|
-
const overLimit = typeConfig && typeConfig.line_limit && lines > typeConfig.line_limit;
|
|
276
|
-
const textToScan = (note.body + " " + title).toLowerCase();
|
|
277
|
-
const allNotes = listNotes(vaultRoot, config).filter((n) => n.slug !== note.slug);
|
|
278
|
-
const suggestions = [];
|
|
279
|
-
for (const other of allNotes) {
|
|
280
|
-
const otherTitle = other.frontmatter.title.toLowerCase();
|
|
281
|
-
if (otherTitle.length < 3) continue;
|
|
282
|
-
if (textToScan.includes(otherTitle)) {
|
|
283
|
-
suggestions.push({ slug: other.slug, title: other.frontmatter.title, type: other.frontmatter.type });
|
|
284
|
-
continue;
|
|
285
|
-
}
|
|
286
|
-
for (const alias of other.frontmatter.aliases) {
|
|
287
|
-
if (alias.length < 3) continue;
|
|
288
|
-
if (textToScan.includes(alias.toLowerCase())) {
|
|
289
|
-
suggestions.push({ slug: other.slug, title: other.frontmatter.title, type: other.frontmatter.type });
|
|
290
|
-
break;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
if (options.json) {
|
|
295
|
-
console.log(jsonSuccess({
|
|
296
|
-
slug: note.slug,
|
|
297
|
-
title: note.frontmatter.title,
|
|
298
|
-
type: resolvedType,
|
|
299
|
-
filepath: note.filepath,
|
|
300
|
-
suggestions
|
|
301
|
-
}));
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
if (overLimit) {
|
|
305
|
-
const action = typeConfig.warn_only ? "Warning" : "Error";
|
|
306
|
-
console.warn(`${action}: Note exceeds ${typeConfig.line_limit} line limit for type "${resolvedType}"`);
|
|
307
|
-
}
|
|
308
|
-
console.log(note.filepath);
|
|
309
|
-
if (typeConfig?.instructions) {
|
|
310
|
-
console.log("");
|
|
311
|
-
console.log(` \u{1F4A1} ${typeConfig.instructions}`);
|
|
312
|
-
}
|
|
313
|
-
if (suggestions.length > 0) {
|
|
314
|
-
console.log("");
|
|
315
|
-
console.log(" \u{1F517} Linked notes detected:");
|
|
316
|
-
for (const s of suggestions) {
|
|
317
|
-
console.log(` [[${s.slug}]] \u2014 ${s.title} (${s.type})`);
|
|
318
|
-
}
|
|
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
|
-
|
|
323
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
|
527
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
`);
|
|
543
|
-
const transaction = db.transaction(() => {
|
|
544
|
-
for (const note of notes) {
|
|
545
|
-
insertNote.run({
|
|
546
|
-
slug: note.slug,
|
|
547
|
-
id: note.frontmatter.id,
|
|
548
|
-
title: note.frontmatter.title,
|
|
549
|
-
type: note.frontmatter.type,
|
|
550
|
-
created: note.frontmatter.created,
|
|
551
|
-
modified: note.frontmatter.modified,
|
|
552
|
-
tags: JSON.stringify(note.frontmatter.tags),
|
|
553
|
-
aliases: JSON.stringify(note.frontmatter.aliases),
|
|
554
|
-
body: note.body,
|
|
555
|
-
filepath: note.filepath
|
|
556
|
-
});
|
|
557
|
-
const links = parseWikilinks(note.body);
|
|
558
|
-
const resolved = resolveWikilinks(links, notes);
|
|
559
|
-
for (const link of resolved) {
|
|
560
|
-
const bodyLines = note.body.split("\n");
|
|
561
|
-
const contextLine = bodyLines.find((l) => l.includes(link.raw)) ?? "";
|
|
562
|
-
insertLink.run({
|
|
563
|
-
source_slug: note.slug,
|
|
564
|
-
target_slug: link.resolved_slug ?? null,
|
|
565
|
-
target_raw: link.target,
|
|
566
|
-
context: contextLine.trim()
|
|
567
|
-
});
|
|
568
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
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
|
|
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 (!
|
|
1725
|
+
if (!fs10.existsSync(filePath)) {
|
|
972
1726
|
const indexPath = path7.join(publicDir, "index.html");
|
|
973
|
-
const html =
|
|
1727
|
+
const html = fs10.readFileSync(indexPath, "utf-8");
|
|
974
1728
|
return c.html(html);
|
|
975
1729
|
}
|
|
976
|
-
const content =
|
|
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
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
1923
|
+
const statAfter = fs11.statSync(note.filepath).mtimeMs;
|
|
1109
1924
|
if (statAfter !== statBefore) {
|
|
1110
|
-
const { frontmatter, body } = parseFrontmatter(
|
|
1925
|
+
const { frontmatter, body } = parseFrontmatter(fs11.readFileSync(note.filepath, "utf-8"));
|
|
1111
1926
|
frontmatter.modified = (/* @__PURE__ */ new Date()).toISOString();
|
|
1112
|
-
|
|
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 (
|
|
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
|
});
|