@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.
- package/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +22 -0
- package/.cursor-plugin/plugin.json +21 -0
- package/.mcp.json +8 -0
- package/CHANGELOG.md +42 -0
- package/LICENSE +21 -0
- package/README.md +277 -0
- package/assets/logo.svg +19 -0
- package/bin/cli.mjs +135 -0
- package/bin/commands/attention.mjs +139 -0
- package/bin/commands/decide.mjs +104 -0
- package/bin/commands/doctor.mjs +150 -0
- package/bin/commands/heartbeat.mjs +21 -0
- package/bin/commands/hooks.mjs +41 -0
- package/bin/commands/install.mjs +86 -0
- package/bin/commands/journal.mjs +54 -0
- package/bin/commands/link.mjs +18 -0
- package/bin/commands/list.mjs +51 -0
- package/bin/commands/local.mjs +118 -0
- package/bin/commands/note.mjs +61 -0
- package/bin/commands/reindex.mjs +48 -0
- package/bin/commands/remap.mjs +76 -0
- package/bin/commands/search.mjs +55 -0
- package/bin/commands/serve.mjs +16 -0
- package/bin/commands/show.mjs +61 -0
- package/bin/commands/split.mjs +56 -0
- package/bin/commands/status.mjs +136 -0
- package/bin/commands/uninstall.mjs +58 -0
- package/bin/commands/where.mjs +29 -0
- package/bin/lib/args.mjs +117 -0
- package/bin/lib/bindings.mjs +404 -0
- package/bin/lib/entry.mjs +35 -0
- package/bin/lib/git.mjs +149 -0
- package/bin/lib/heartbeat.mjs +118 -0
- package/bin/lib/hooks.mjs +144 -0
- package/bin/lib/ignore.mjs +122 -0
- package/bin/lib/import-legacy.mjs +183 -0
- package/bin/lib/index.mjs +574 -0
- package/bin/lib/install-cli.mjs +100 -0
- package/bin/lib/install-skills.mjs +120 -0
- package/bin/lib/mcp.mjs +389 -0
- package/bin/lib/okf.mjs +746 -0
- package/bin/lib/output.mjs +112 -0
- package/bin/lib/pkg.mjs +22 -0
- package/bin/lib/resolve.mjs +302 -0
- package/bin/lib/uninstall.mjs +56 -0
- package/hooks/session-start.sh +4 -0
- package/mcp.json +11 -0
- package/package.json +43 -0
- package/plugin.json +21 -0
- package/rules/mental.mdc +18 -0
- package/skills/mental/SKILL.md +277 -0
- 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
|
+
}
|