@balacode/mental 0.2.0

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 (53) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/.cursor-plugin/plugin.json +21 -0
  4. package/.mcp.json +8 -0
  5. package/CHANGELOG.md +42 -0
  6. package/LICENSE +21 -0
  7. package/README.md +277 -0
  8. package/assets/logo.svg +19 -0
  9. package/bin/cli.mjs +135 -0
  10. package/bin/commands/attention.mjs +139 -0
  11. package/bin/commands/decide.mjs +104 -0
  12. package/bin/commands/doctor.mjs +150 -0
  13. package/bin/commands/heartbeat.mjs +21 -0
  14. package/bin/commands/hooks.mjs +41 -0
  15. package/bin/commands/install.mjs +86 -0
  16. package/bin/commands/journal.mjs +54 -0
  17. package/bin/commands/link.mjs +18 -0
  18. package/bin/commands/list.mjs +51 -0
  19. package/bin/commands/local.mjs +118 -0
  20. package/bin/commands/note.mjs +61 -0
  21. package/bin/commands/reindex.mjs +48 -0
  22. package/bin/commands/remap.mjs +76 -0
  23. package/bin/commands/search.mjs +55 -0
  24. package/bin/commands/serve.mjs +16 -0
  25. package/bin/commands/show.mjs +61 -0
  26. package/bin/commands/split.mjs +56 -0
  27. package/bin/commands/status.mjs +136 -0
  28. package/bin/commands/uninstall.mjs +58 -0
  29. package/bin/commands/where.mjs +29 -0
  30. package/bin/lib/args.mjs +117 -0
  31. package/bin/lib/bindings.mjs +404 -0
  32. package/bin/lib/entry.mjs +35 -0
  33. package/bin/lib/git.mjs +149 -0
  34. package/bin/lib/heartbeat.mjs +118 -0
  35. package/bin/lib/hooks.mjs +144 -0
  36. package/bin/lib/ignore.mjs +122 -0
  37. package/bin/lib/import-legacy.mjs +183 -0
  38. package/bin/lib/index.mjs +574 -0
  39. package/bin/lib/install-cli.mjs +100 -0
  40. package/bin/lib/install-skills.mjs +120 -0
  41. package/bin/lib/mcp.mjs +389 -0
  42. package/bin/lib/okf.mjs +746 -0
  43. package/bin/lib/output.mjs +112 -0
  44. package/bin/lib/pkg.mjs +22 -0
  45. package/bin/lib/resolve.mjs +302 -0
  46. package/bin/lib/uninstall.mjs +56 -0
  47. package/hooks/session-start.sh +4 -0
  48. package/mcp.json +11 -0
  49. package/package.json +43 -0
  50. package/plugin.json +21 -0
  51. package/rules/mental.mdc +18 -0
  52. package/skills/mental/SKILL.md +277 -0
  53. package/skills/mental/references/templates.md +186 -0
@@ -0,0 +1,574 @@
1
+ /**
2
+ * Derived SQLite index for an OKF bundle.
3
+ * Markdown remains SoT. Deleting the db must not lose knowledge.
4
+ *
5
+ * Path: ${XDG_CACHE_HOME:-~/.cache}/mental/<uuid>.sqlite
6
+ */
7
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
8
+ import { createRequire } from "node:module";
9
+ import { dirname, join, relative } from "node:path";
10
+ import { parseFrontmatter } from "./okf.mjs";
11
+
12
+ const require = createRequire(import.meta.url);
13
+ /** Bump when the concepts table shape changes; mismatch drops and recreates. */
14
+ export const INDEX_VERSION = "2";
15
+
16
+ const SNIPPET_CHARS = 160;
17
+
18
+ /**
19
+ * @param {string} home
20
+ * @param {string} id
21
+ * @param {NodeJS.ProcessEnv} [env]
22
+ */
23
+ export function indexPath(home, id, env = process.env) {
24
+ const xdg = env.XDG_CACHE_HOME && env.XDG_CACHE_HOME.trim() ? env.XDG_CACHE_HOME : join(home, ".cache");
25
+ return join(xdg, "mental", `${id}.sqlite`);
26
+ }
27
+
28
+ function loadDatabaseSync() {
29
+ try {
30
+ const mod = require("node:sqlite");
31
+ return mod.DatabaseSync ?? null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * @param {Array<{ type: string, status: string, tags: string[], kind?: string }>} items
39
+ * @param {{ type?: string, status?: string, tag?: string, kind?: string }} [filters]
40
+ */
41
+ export function filterConcepts(items, { type, status, tag, kind } = {}) {
42
+ let out = items;
43
+ if (type) out = out.filter((c) => c.type.toLowerCase() === String(type).toLowerCase());
44
+ if (status) out = out.filter((c) => c.status === status);
45
+ if (tag) out = out.filter((c) => c.tags.includes(String(tag)));
46
+ if (kind) out = out.filter((c) => String(c.kind || "").toLowerCase() === String(kind).toLowerCase());
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * Bundle-relative dest for a markdown link. `./` and `../` resolve against
52
+ * the source file; anything else is treated as already bundle-relative (OKF).
53
+ * @param {string} src
54
+ * @param {string} dest
55
+ */
56
+ export function normalizeDest(src, dest) {
57
+ let d = String(dest || "")
58
+ .split("#")[0]
59
+ .trim()
60
+ .replace(/\\/g, "/");
61
+ if (!d) return "";
62
+ if (d.startsWith("/")) d = d.slice(1);
63
+ if (d.startsWith("./") || d.startsWith("../")) {
64
+ const srcDir = src.includes("/") ? src.slice(0, src.lastIndexOf("/")) : "";
65
+ const segs = [...(srcDir ? srcDir.split("/") : []), ...d.split("/")];
66
+ const parts = [];
67
+ for (const s of segs) {
68
+ if (s === "." || s === "") continue;
69
+ if (s === "..") parts.pop();
70
+ else parts.push(s);
71
+ }
72
+ d = parts.join("/");
73
+ }
74
+ if (d && !d.endsWith(".md")) d += ".md";
75
+ return d;
76
+ }
77
+
78
+ /**
79
+ * @param {string} root bundle directory
80
+ * @returns {Array<{
81
+ * path: string,
82
+ * type: string,
83
+ * title: string,
84
+ * description: string,
85
+ * status: string,
86
+ * kind: string,
87
+ * from: string,
88
+ * against: string,
89
+ * tags: string[],
90
+ * mtime: number,
91
+ * body: string,
92
+ * searchable: string,
93
+ * abs: string,
94
+ * }>}
95
+ */
96
+ export function listConcepts(root) {
97
+ /** @type {ReturnType<typeof listConcepts>} */
98
+ const out = [];
99
+ if (!existsSync(root)) return out;
100
+ walk(root, root, out);
101
+ return out;
102
+ }
103
+
104
+ /**
105
+ * @param {string} base
106
+ * @param {string} dir
107
+ * @param {ReturnType<typeof listConcepts>} out
108
+ */
109
+ function walk(base, dir, out) {
110
+ let names;
111
+ try {
112
+ names = readdirSync(dir);
113
+ } catch {
114
+ return;
115
+ }
116
+ for (const name of names) {
117
+ if (name.startsWith(".") || name === "status") continue;
118
+ const abs = join(dir, name);
119
+ let st;
120
+ try {
121
+ st = statSync(abs);
122
+ } catch {
123
+ continue;
124
+ }
125
+ if (st.isDirectory()) {
126
+ walk(base, abs, out);
127
+ continue;
128
+ }
129
+ if (!name.endsWith(".md")) continue;
130
+ const rel = relative(base, abs).split("\\").join("/");
131
+ if (rel === "index.md" || rel === "log.md") continue;
132
+ let text;
133
+ try {
134
+ text = readFileSync(abs, "utf8");
135
+ } catch {
136
+ continue;
137
+ }
138
+ const { data, body } = parseFrontmatter(text);
139
+ const type = String(data.type || inferType(rel));
140
+ const title = String(data.title || rel.replace(/\.md$/, ""));
141
+ const description = data.description ? String(data.description) : "";
142
+ const searchable = [title, description, body].filter(Boolean).join("\n");
143
+ out.push({
144
+ path: rel,
145
+ type,
146
+ title,
147
+ description,
148
+ status: String(data.status || ""),
149
+ kind: data.kind ? String(data.kind) : "",
150
+ from: data.from ? String(data.from) : "",
151
+ against: data.against ? String(data.against) : "",
152
+ tags: Array.isArray(data.tags) ? data.tags.map(String) : data.tags ? [String(data.tags)] : [],
153
+ mtime: Math.floor(st.mtimeMs),
154
+ body,
155
+ searchable,
156
+ abs,
157
+ });
158
+ }
159
+ }
160
+
161
+ function inferType(rel) {
162
+ if (rel.startsWith("journal/")) return "Journal";
163
+ if (rel.startsWith("decisions/")) return "Decision";
164
+ if (rel.startsWith("notes/")) return "Note";
165
+ if (rel.startsWith("attention/")) return "Attention";
166
+ return "Note";
167
+ }
168
+
169
+ const LINK_RE = /\[[^\]]*\]\(([^)]+)\)/g;
170
+
171
+ /**
172
+ * @param {string} src
173
+ * @param {string} body
174
+ * @returns {Array<{ src: string, dest: string, raw: string }>}
175
+ */
176
+ export function extractLinks(src, body) {
177
+ /** @type {Array<{ src: string, dest: string, raw: string }>} */
178
+ const out = [];
179
+ let m;
180
+ const re = new RegExp(LINK_RE.source, "g");
181
+ while ((m = re.exec(body))) {
182
+ const raw = m[1].trim();
183
+ if (!raw || raw.startsWith("http:") || raw.startsWith("https:") || raw.startsWith("mailto:")) continue;
184
+ const dest = normalizeDest(src, raw);
185
+ if (!dest) continue;
186
+ out.push({ src, dest, raw });
187
+ }
188
+ return out;
189
+ }
190
+
191
+ const SCHEMA_SQL = `
192
+ PRAGMA journal_mode = WAL;
193
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
194
+ CREATE TABLE IF NOT EXISTS concepts (
195
+ path TEXT PRIMARY KEY,
196
+ type TEXT NOT NULL,
197
+ title TEXT,
198
+ description TEXT,
199
+ status TEXT,
200
+ kind TEXT,
201
+ from_val TEXT,
202
+ against TEXT,
203
+ tags_json TEXT,
204
+ mtime INTEGER,
205
+ body_text TEXT
206
+ );
207
+ CREATE TABLE IF NOT EXISTS links (
208
+ src TEXT NOT NULL,
209
+ dest TEXT NOT NULL,
210
+ raw TEXT
211
+ );
212
+ CREATE VIRTUAL TABLE IF NOT EXISTS concepts_fts USING fts5(path, title, body_text);
213
+ `;
214
+
215
+ /**
216
+ * @param {import("node:sqlite").DatabaseSync} db
217
+ */
218
+ function ensureSchema(db) {
219
+ db.exec("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);");
220
+ let version = "0";
221
+ try {
222
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'version'").get();
223
+ version = row?.value ? String(row.value) : "0";
224
+ } catch {
225
+ version = "0";
226
+ }
227
+ if (version !== INDEX_VERSION) {
228
+ db.exec("DROP TABLE IF EXISTS concepts; DROP TABLE IF EXISTS links; DROP TABLE IF EXISTS concepts_fts;");
229
+ }
230
+ db.exec(SCHEMA_SQL);
231
+ }
232
+
233
+ /**
234
+ * @param {{ root: string, id: string, home: string, env?: NodeJS.ProcessEnv }} opts
235
+ * @returns {{ ok: boolean, path: string | null, concepts: number, backend: "sqlite" | "none", error?: string }}
236
+ */
237
+ export function reindexBundle({ root, id, home, env = process.env }) {
238
+ const file = indexPath(home, id, env);
239
+ const DatabaseSync = loadDatabaseSync();
240
+ if (!DatabaseSync) {
241
+ return { ok: false, path: file, concepts: 0, backend: "none", error: "node:sqlite unavailable" };
242
+ }
243
+ const concepts = listConcepts(root);
244
+ try {
245
+ mkdirSync(dirname(file), { recursive: true });
246
+ const db = new DatabaseSync(file);
247
+ ensureSchema(db);
248
+ db.exec("DELETE FROM concepts; DELETE FROM links; DELETE FROM concepts_fts;");
249
+ const insC = db.prepare(
250
+ "INSERT INTO concepts (path, type, title, description, status, kind, from_val, against, tags_json, mtime, body_text) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
251
+ );
252
+ const insL = db.prepare("INSERT INTO links (src, dest, raw) VALUES (?, ?, ?)");
253
+ const insF = db.prepare("INSERT INTO concepts_fts (path, title, body_text) VALUES (?, ?, ?)");
254
+ for (const c of concepts) {
255
+ insC.run(
256
+ c.path,
257
+ c.type,
258
+ c.title,
259
+ c.description,
260
+ c.status,
261
+ c.kind,
262
+ c.from,
263
+ c.against,
264
+ JSON.stringify(c.tags),
265
+ c.mtime,
266
+ c.searchable,
267
+ );
268
+ insF.run(c.path, c.title, c.searchable);
269
+ for (const l of extractLinks(c.path, c.body)) insL.run(l.src, l.dest, l.raw);
270
+ }
271
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run("version", INDEX_VERSION);
272
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run("root", root);
273
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run("builtAt", new Date().toISOString());
274
+ db.close();
275
+ return { ok: true, path: file, concepts: concepts.length, backend: "sqlite" };
276
+ } catch (err) {
277
+ return {
278
+ ok: false,
279
+ path: file,
280
+ concepts: concepts.length,
281
+ backend: "none",
282
+ error: err instanceof Error ? err.message : String(err),
283
+ };
284
+ }
285
+ }
286
+
287
+ /**
288
+ * @param {{
289
+ * type?: string,
290
+ * status?: string,
291
+ * tag?: string,
292
+ * kind?: string,
293
+ * }} filters
294
+ * @returns {{ sql: string, params: string[] }}
295
+ */
296
+ function filterSql(filters) {
297
+ /** @type {string[]} */
298
+ const clauses = [];
299
+ /** @type {string[]} */
300
+ const params = [];
301
+ if (filters.type) {
302
+ clauses.push("lower(c.type) = lower(?)");
303
+ params.push(filters.type);
304
+ }
305
+ if (filters.status) {
306
+ clauses.push("c.status = ?");
307
+ params.push(filters.status);
308
+ }
309
+ if (filters.kind) {
310
+ clauses.push("lower(c.kind) = lower(?)");
311
+ params.push(filters.kind);
312
+ }
313
+ if (filters.tag) {
314
+ clauses.push("EXISTS (SELECT 1 FROM json_each(c.tags_json) WHERE value = ?)");
315
+ params.push(filters.tag);
316
+ }
317
+ return { sql: clauses.length ? ` AND ${clauses.join(" AND ")}` : "", params };
318
+ }
319
+
320
+ /**
321
+ * @param {{
322
+ * root: string,
323
+ * id?: string | null,
324
+ * home?: string | null,
325
+ * env?: NodeJS.ProcessEnv,
326
+ * q: string,
327
+ * type?: string,
328
+ * status?: string,
329
+ * tag?: string,
330
+ * kind?: string,
331
+ * limit?: number,
332
+ * }} opts
333
+ */
334
+ export function searchBundle({
335
+ root,
336
+ id = null,
337
+ home = null,
338
+ env = process.env,
339
+ q,
340
+ type,
341
+ status,
342
+ tag,
343
+ kind,
344
+ limit = 50,
345
+ }) {
346
+ const needle = q.trim().toLowerCase();
347
+ const filters = { type, status, tag, kind };
348
+ if (id && home) {
349
+ const file = indexPath(home, id, env);
350
+ const DatabaseSync = loadDatabaseSync();
351
+ if (DatabaseSync && existsSync(file)) {
352
+ maybeUpgradeIndex({ root, id, home, env, DatabaseSync, file });
353
+ try {
354
+ return { backend: "sqlite", hits: searchSqlite(DatabaseSync, file, needle, { ...filters, limit }) };
355
+ } catch {
356
+ // fall through to scan
357
+ }
358
+ }
359
+ }
360
+ return { backend: "scan", hits: searchScan(listConcepts(root), needle, { ...filters, limit }) };
361
+ }
362
+
363
+ /**
364
+ * Rebuild if the on-disk schema predates INDEX_VERSION.
365
+ * @param {{ root: string, id: string, home: string, env: NodeJS.ProcessEnv, DatabaseSync: typeof import("node:sqlite").DatabaseSync, file: string }} opts
366
+ */
367
+ function maybeUpgradeIndex({ root, id, home, env, DatabaseSync, file }) {
368
+ let version = "0";
369
+ const db = new DatabaseSync(file);
370
+ try {
371
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'version'").get();
372
+ version = row?.value ? String(row.value) : "0";
373
+ } catch {
374
+ version = "0";
375
+ } finally {
376
+ db.close();
377
+ }
378
+ if (version !== INDEX_VERSION) reindexBundle({ root, id, home, env });
379
+ }
380
+
381
+ /**
382
+ * @param {typeof import("node:sqlite").DatabaseSync} DatabaseSync
383
+ */
384
+ function searchSqlite(DatabaseSync, file, needle, { type, status, tag, kind, limit }) {
385
+ const db = new DatabaseSync(file);
386
+ try {
387
+ const { sql: extra, params: filterParams } = filterSql({ type, status, tag, kind });
388
+ const like = db.prepare(
389
+ `SELECT c.path AS path, c.type AS type, c.title AS title, c.description AS description,
390
+ c.status AS status, c.kind AS kind, c.tags_json AS tags_json, c.body_text AS body_text
391
+ FROM concepts c
392
+ WHERE (lower(c.title) LIKE ? OR lower(c.body_text) LIKE ? OR lower(c.path) LIKE ?)${extra}
393
+ LIMIT ?`,
394
+ );
395
+ const pat = `%${needle}%`;
396
+ let rows = [];
397
+ try {
398
+ const fts = db.prepare(
399
+ `SELECT c.path AS path, c.type AS type, c.title AS title, c.description AS description,
400
+ c.status AS status, c.kind AS kind, c.tags_json AS tags_json,
401
+ snippet(concepts_fts, 2, '', '', '…', 32) AS snippet
402
+ FROM concepts_fts
403
+ JOIN concepts c ON c.path = concepts_fts.path
404
+ WHERE concepts_fts MATCH ?${extra}
405
+ ORDER BY bm25(concepts_fts)
406
+ LIMIT ?`,
407
+ );
408
+ const tokens = needle
409
+ .replace(/[^\p{L}\p{N}\s]+/gu, " ")
410
+ .trim()
411
+ .split(/\s+/)
412
+ .filter(Boolean);
413
+ const q = tokens.length ? tokens.map((t) => `${t}*`).join(" AND ") : needle;
414
+ rows = fts.all(q, ...filterParams, limit);
415
+ } catch {
416
+ rows = [];
417
+ }
418
+ if (!rows || rows.length === 0) {
419
+ try {
420
+ rows = like.all(pat, pat, pat, ...filterParams, limit);
421
+ } catch {
422
+ rows = [];
423
+ }
424
+ }
425
+ return (rows || []).map((r) => rowToHit(r, needle)).slice(0, limit);
426
+ } finally {
427
+ db.close();
428
+ }
429
+ }
430
+
431
+ /**
432
+ * @param {Record<string, unknown>} r
433
+ * @param {string} needle
434
+ */
435
+ function rowToHit(r, needle) {
436
+ const body = String(r.body_text ?? r.snippet ?? "");
437
+ const snippet = r.snippet != null && String(r.snippet).trim() ? String(r.snippet).trim() : scanSnippet(body, needle);
438
+ return {
439
+ path: String(r.path ?? ""),
440
+ type: String(r.type ?? ""),
441
+ title: String(r.title || ""),
442
+ description: String(r.description || ""),
443
+ status: String(r.status || ""),
444
+ kind: String(r.kind || ""),
445
+ tags: parseTagsJson(r.tags_json),
446
+ snippet,
447
+ };
448
+ }
449
+
450
+ function parseTagsJson(raw) {
451
+ try {
452
+ const v = JSON.parse(String(raw || "[]"));
453
+ return Array.isArray(v) ? v.map(String) : [];
454
+ } catch {
455
+ return [];
456
+ }
457
+ }
458
+
459
+ /**
460
+ * @param {string} text
461
+ * @param {string} needle
462
+ */
463
+ function scanSnippet(text, needle, max = SNIPPET_CHARS) {
464
+ const compact = String(text || "").replace(/\s+/g, " ").trim();
465
+ if (!compact) return "";
466
+ const lower = compact.toLowerCase();
467
+ const i = needle ? lower.indexOf(needle) : 0;
468
+ if (i < 0) return compact.slice(0, max);
469
+ const start = Math.max(0, i - 40);
470
+ let s = compact.slice(start, start + max);
471
+ if (start > 0) s = `…${s}`;
472
+ if (start + max < compact.length) s = `${s}…`;
473
+ return s;
474
+ }
475
+
476
+ function searchScan(concepts, needle, { type, status, tag, kind, limit }) {
477
+ const hits = filterConcepts(concepts, { type, status, tag, kind })
478
+ .filter((c) => `${c.title}\n${c.searchable}`.toLowerCase().includes(needle))
479
+ .map((c) => ({
480
+ path: c.path,
481
+ type: c.type,
482
+ title: c.title,
483
+ description: c.description,
484
+ status: c.status,
485
+ kind: c.kind,
486
+ tags: c.tags,
487
+ snippet: scanSnippet(c.searchable, needle),
488
+ }));
489
+ return hits.slice(0, limit);
490
+ }
491
+
492
+ /**
493
+ * Concepts that link to `path` (bundle-relative). SQLite first, file-scan fallback.
494
+ * @param {{
495
+ * root: string,
496
+ * path: string,
497
+ * id?: string | null,
498
+ * home?: string | null,
499
+ * env?: NodeJS.ProcessEnv,
500
+ * }} opts
501
+ * @returns {Array<{ path: string, type: string, title: string }>}
502
+ */
503
+ export function listBacklinks({ root, path: rel, id = null, home = null, env = process.env }) {
504
+ const target = normalizeDest("", rel) || rel;
505
+ if (id && home) {
506
+ const file = indexPath(home, id, env);
507
+ const DatabaseSync = loadDatabaseSync();
508
+ if (DatabaseSync && existsSync(file)) {
509
+ maybeUpgradeIndex({ root, id, home, env, DatabaseSync, file });
510
+ try {
511
+ return backlinksSqlite(DatabaseSync, file, target);
512
+ } catch {
513
+ // fall through to scan
514
+ }
515
+ }
516
+ }
517
+ return backlinksScan(listConcepts(root), target);
518
+ }
519
+
520
+ /**
521
+ * @param {typeof import("node:sqlite").DatabaseSync} DatabaseSync
522
+ * @param {string} file
523
+ * @param {string} target
524
+ */
525
+ function backlinksSqlite(DatabaseSync, file, target) {
526
+ const db = new DatabaseSync(file);
527
+ try {
528
+ const rows = db
529
+ .prepare(
530
+ `SELECT DISTINCT c.path AS path, c.type AS type, c.title AS title
531
+ FROM links l
532
+ JOIN concepts c ON c.path = l.src
533
+ WHERE l.dest = ? AND l.src != ?
534
+ ORDER BY c.path`,
535
+ )
536
+ .all(target, target);
537
+ return (rows || []).map((r) => ({
538
+ path: String(r.path ?? ""),
539
+ type: String(r.type ?? ""),
540
+ title: String(r.title || ""),
541
+ }));
542
+ } finally {
543
+ db.close();
544
+ }
545
+ }
546
+
547
+ /**
548
+ * @param {ReturnType<typeof listConcepts>} concepts
549
+ * @param {string} target
550
+ */
551
+ function backlinksScan(concepts, target) {
552
+ /** @type {Array<{ path: string, type: string, title: string }>} */
553
+ const out = [];
554
+ for (const c of concepts) {
555
+ if (c.path === target) continue;
556
+ if (extractLinks(c.path, c.body).some((l) => l.dest === target)) {
557
+ out.push({ path: c.path, type: c.type, title: c.title });
558
+ }
559
+ }
560
+ return out.sort((a, b) => a.path.localeCompare(b.path));
561
+ }
562
+
563
+ /**
564
+ * Rebuild sqlite after an OKF write so the next search sees the new file.
565
+ * @param {{ root: string, id: string | null, indexed?: object }} where
566
+ * @param {string | null} home
567
+ * @param {NodeJS.ProcessEnv} [env]
568
+ */
569
+ export function refreshIndex(where, home, env = process.env) {
570
+ if (!where?.id || !home) {
571
+ return where?.indexed ?? { ok: false, path: null, concepts: 0, backend: "none" };
572
+ }
573
+ return reindexBundle({ root: where.root, id: where.id, home, env });
574
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Put `mental` on PATH. Last `mental install` (or `npm i -g @balacode/mental`) wins.
3
+ *
4
+ * `npm install -g` this package into the active npm prefix, then expose the
5
+ * same binary at `~/.local/bin/mental` when that dir is the user's PATH bin
6
+ * and differs from the npm prefix (as on this machine).
7
+ */
8
+ import { chmodSync, existsSync, lstatSync, mkdirSync, symlinkSync, unlinkSync } from "node:fs";
9
+ import { dirname, join, resolve } from "node:path";
10
+ import { spawnSync } from "node:child_process";
11
+ import { CMD, PKG_ROOT } from "./pkg.mjs";
12
+
13
+ function runNpm(args, env) {
14
+ return spawnSync("npm", args, { encoding: "utf8", env });
15
+ }
16
+
17
+ function npmGlobalPrefix(env) {
18
+ const r = runNpm(["prefix", "-g"], env);
19
+ const p = (r.stdout || "").trim();
20
+ return p || null;
21
+ }
22
+
23
+ function npmGlobalBin(prefix) {
24
+ // Unix npm: <prefix>/bin/<cmd>
25
+ return join(prefix, "bin", CMD);
26
+ }
27
+
28
+ function replaceWithSymlink(dest, target) {
29
+ mkdirSync(dirname(dest), { recursive: true });
30
+ try {
31
+ unlinkSync(dest);
32
+ } catch {
33
+ // missing
34
+ }
35
+ symlinkSync(target, dest);
36
+ try {
37
+ chmodSync(dest, 0o755);
38
+ } catch {
39
+ // symlink chmod is a no-op on some systems
40
+ }
41
+ }
42
+
43
+ /**
44
+ * @param {{ home: string, env?: NodeJS.ProcessEnv }} opts
45
+ * @returns {{
46
+ * ok: boolean,
47
+ * bin: string | null,
48
+ * target: string | null,
49
+ * npm: boolean,
50
+ * message: string,
51
+ * }}
52
+ */
53
+ export function installGlobalCli({ home, env = process.env }) {
54
+ const npm = runNpm(
55
+ ["install", "-g", "--no-fund", "--no-audit", "--no-package-lock", PKG_ROOT],
56
+ env,
57
+ );
58
+ const prefix = npmGlobalPrefix(env);
59
+ const npmBin = prefix ? npmGlobalBin(prefix) : null;
60
+ const pathBin = join(home, ".local", "bin", CMD);
61
+ const fallback = join(PKG_ROOT, "bin", "cli.mjs");
62
+ const target =
63
+ npmBin && existsSync(npmBin) ? resolve(npmBin) : resolve(fallback);
64
+
65
+ try {
66
+ if (resolve(pathBin) !== target) replaceWithSymlink(pathBin, target);
67
+ else if (!existsSync(pathBin) && existsSync(target)) replaceWithSymlink(pathBin, target);
68
+ } catch (err) {
69
+ return {
70
+ ok: false,
71
+ bin: pathBin,
72
+ target,
73
+ npm: npm.status === 0,
74
+ message: err instanceof Error ? err.message : String(err),
75
+ };
76
+ }
77
+
78
+ const bin = existsSync(pathBin) ? pathBin : existsSync(target) ? target : null;
79
+ const ok = Boolean(bin);
80
+ return {
81
+ ok,
82
+ bin,
83
+ target,
84
+ npm: npm.status === 0,
85
+ message: ok
86
+ ? `${CMD} → ${target}`
87
+ : (npm.stderr || npm.stdout || "failed to install CLI on PATH").trim(),
88
+ };
89
+ }
90
+
91
+ /**
92
+ * @param {string} dest
93
+ */
94
+ export function isMentalBin(dest) {
95
+ try {
96
+ return existsSync(dest) || lstatSync(dest).isSymbolicLink();
97
+ } catch {
98
+ return false;
99
+ }
100
+ }