context-vault 2.0.1

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.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * file-ops.js — Capture-specific file operations
3
+ *
4
+ * Writes markdown entry files with frontmatter to the vault directory.
5
+ */
6
+
7
+ import { mkdirSync, writeFileSync } from "node:fs";
8
+ import { resolve, relative } from "node:path";
9
+ import { formatFrontmatter } from "../core/frontmatter.js";
10
+ import { slugify, kindToPath } from "../core/files.js";
11
+ import { formatBody } from "./formatters.js";
12
+
13
+ function safeFolderPath(vaultDir, kind, folder) {
14
+ const base = resolve(vaultDir, kindToPath(kind));
15
+ if (!folder) return base;
16
+ const resolved = resolve(base, folder);
17
+ const rel = relative(base, resolved);
18
+ if (rel.startsWith("..") || resolve(base, rel) !== resolved) {
19
+ throw new Error(`Folder path escapes vault: "${folder}"`);
20
+ }
21
+ return resolved;
22
+ }
23
+
24
+ export function writeEntryFile(vaultDir, kind, { id, title, body, meta, tags, source, createdAt, folder, category, identity_key, expires_at }) {
25
+ // P5: folder is now a top-level param; also accept from meta for backward compat
26
+ const resolvedFolder = folder || meta?.folder || "";
27
+ const dir = safeFolderPath(vaultDir, kind, resolvedFolder);
28
+
29
+ try {
30
+ mkdirSync(dir, { recursive: true });
31
+ } catch (e) {
32
+ throw new Error(`Failed to create directory "${dir}": ${e.message}`);
33
+ }
34
+
35
+ const created = createdAt || new Date().toISOString();
36
+ const fmFields = { id };
37
+
38
+ // Add kind-specific meta fields to frontmatter (flattened, not nested)
39
+ if (meta) {
40
+ for (const [k, v] of Object.entries(meta)) {
41
+ if (k === "folder") continue;
42
+ if (v !== null && v !== undefined) fmFields[k] = v;
43
+ }
44
+ }
45
+
46
+ if (identity_key) fmFields.identity_key = identity_key;
47
+ if (expires_at) fmFields.expires_at = expires_at;
48
+ fmFields.tags = tags || [];
49
+ fmFields.source = source || "claude-code";
50
+ fmFields.created = created;
51
+
52
+ const mdBody = formatBody(kind, { title, body, meta });
53
+
54
+ // Entity kinds: deterministic filename from identity_key (no ULID suffix)
55
+ let filename;
56
+ if (category === "entity" && identity_key) {
57
+ const identitySlug = slugify(identity_key);
58
+ filename = identitySlug ? `${identitySlug}.md` : `${id.slice(-8).toLowerCase()}.md`;
59
+ } else {
60
+ const slug = slugify((title || body).slice(0, 40));
61
+ const shortId = id.slice(-8).toLowerCase();
62
+ filename = slug ? `${slug}-${shortId}.md` : `${shortId}.md`;
63
+ }
64
+
65
+ const filePath = resolve(dir, filename);
66
+ const md = formatFrontmatter(fmFields) + mdBody;
67
+
68
+ try {
69
+ writeFileSync(filePath, md);
70
+ } catch (e) {
71
+ throw new Error(`Failed to write entry file "${filePath}": ${e.message}`);
72
+ }
73
+
74
+ return filePath;
75
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * formatters.js — Kind-specific markdown body templates
3
+ *
4
+ * Maps entry kinds to their markdown body format.
5
+ * Default formatter used for unknown kinds.
6
+ */
7
+
8
+ const FORMATTERS = {
9
+ insight: ({ body }) => "\n" + body + "\n",
10
+
11
+ decision: ({ title, body }) => {
12
+ const t = title || body.slice(0, 80);
13
+ return "\n## Decision\n\n" + t + "\n\n## Rationale\n\n" + body + "\n";
14
+ },
15
+
16
+ pattern: ({ title, body, meta }) => {
17
+ const t = title || body.slice(0, 80);
18
+ const lang = meta?.language || "";
19
+ return "\n# " + t + "\n\n```" + lang + "\n" + body + "\n```\n";
20
+ },
21
+ };
22
+
23
+ const DEFAULT_FORMATTER = ({ title, body }) =>
24
+ title ? "\n# " + title + "\n\n" + body + "\n" : "\n" + body + "\n";
25
+
26
+ export function formatBody(kind, { title, body, meta }) {
27
+ const fn = FORMATTERS[kind] || DEFAULT_FORMATTER;
28
+ return fn({ title, body, meta });
29
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Capture Layer — Public API
3
+ *
4
+ * Writes knowledge entries to vault as .md files.
5
+ * That is its entire job. It does not index, embed, or query.
6
+ *
7
+ * Agent Constraint: Only imports from ../core. Never imports ../index or ../retrieve.
8
+ */
9
+
10
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
+ import { ulid, slugify, kindToPath } from "../core/files.js";
13
+ import { categoryFor } from "../core/categories.js";
14
+ import { parseFrontmatter } from "../core/frontmatter.js";
15
+ import { writeEntryFile } from "./file-ops.js";
16
+
17
+ export function writeEntry(ctx, { kind, title, body, meta, tags, source, folder, identity_key, expires_at }) {
18
+ if (!kind || typeof kind !== "string") {
19
+ throw new Error("writeEntry: kind is required (non-empty string)");
20
+ }
21
+ if (!body || typeof body !== "string" || !body.trim()) {
22
+ throw new Error("writeEntry: body is required (non-empty string)");
23
+ }
24
+ if (tags != null && !Array.isArray(tags)) {
25
+ throw new Error("writeEntry: tags must be an array if provided");
26
+ }
27
+ if (meta != null && typeof meta !== "object") {
28
+ throw new Error("writeEntry: meta must be an object if provided");
29
+ }
30
+
31
+ const category = categoryFor(kind);
32
+
33
+ // Entity upsert: check for existing file at deterministic path
34
+ let id;
35
+ let createdAt;
36
+ if (category === "entity" && identity_key) {
37
+ const identitySlug = slugify(identity_key);
38
+ const dir = resolve(ctx.config.vaultDir, kindToPath(kind));
39
+ const existingPath = resolve(dir, `${identitySlug}.md`);
40
+
41
+ if (existsSync(existingPath)) {
42
+ // Preserve original ID and created timestamp from existing file
43
+ const raw = readFileSync(existingPath, "utf-8");
44
+ const { meta: fmMeta } = parseFrontmatter(raw);
45
+ id = fmMeta.id || ulid();
46
+ createdAt = fmMeta.created || new Date().toISOString();
47
+ } else {
48
+ id = ulid();
49
+ createdAt = new Date().toISOString();
50
+ }
51
+ } else {
52
+ id = ulid();
53
+ createdAt = new Date().toISOString();
54
+ }
55
+
56
+ const filePath = writeEntryFile(ctx.config.vaultDir, kind, {
57
+ id, title, body, meta, tags, source, createdAt, folder,
58
+ category, identity_key, expires_at,
59
+ });
60
+
61
+ return { id, filePath, kind, category, title, body, meta, tags, source, createdAt, identity_key, expires_at };
62
+ }
63
+
64
+ export async function captureAndIndex(ctx, data, indexFn) {
65
+ // For entity upserts, preserve previous file content for safe rollback
66
+ let previousContent = null;
67
+ if (categoryFor(data.kind) === "entity" && data.identity_key) {
68
+ const identitySlug = slugify(data.identity_key);
69
+ const dir = resolve(ctx.config.vaultDir, kindToPath(data.kind));
70
+ const existingPath = resolve(dir, `${identitySlug}.md`);
71
+ if (existsSync(existingPath)) {
72
+ previousContent = readFileSync(existingPath, "utf-8");
73
+ }
74
+ }
75
+
76
+ const entry = writeEntry(ctx, data);
77
+ try {
78
+ await indexFn(ctx, entry);
79
+ return entry;
80
+ } catch (err) {
81
+ // Rollback: restore previous content for entity upserts, delete for new entries
82
+ if (previousContent) {
83
+ try { writeFileSync(entry.filePath, previousContent); } catch {}
84
+ } else {
85
+ try { unlinkSync(entry.filePath); } catch {}
86
+ }
87
+ throw new Error(
88
+ `Capture succeeded but indexing failed — file rolled back. ${err.message}`
89
+ );
90
+ }
91
+ }
@@ -0,0 +1,20 @@
1
+ # Core
2
+
3
+ Shared utilities with zero dependencies on other layers. Every other layer imports from here; this layer imports from nothing except Node.js builtins.
4
+
5
+ ## Modules
6
+
7
+ | File | Exports | Purpose |
8
+ |------|---------|---------|
9
+ | `config.js` | `resolveConfig()`, `parseArgs()` | 4-step config resolution: defaults → config file → env vars → CLI args |
10
+ | `frontmatter.js` | `formatFrontmatter()`, `parseFrontmatter()`, `parseEntryFromMarkdown()`, `extractCustomMeta()` | YAML frontmatter serialization/deserialization and kind-specific markdown parsing |
11
+ | `files.js` | `ulid()`, `slugify()`, `kindToDir()`, `dirToKind()`, `walkDir()` | ID generation, text normalization, kind/directory mapping, recursive `.md` file discovery |
12
+ | `status.js` | `gatherVaultStatus(ctx)` | Collects diagnostic data: file counts, DB size, stale path detection, kind counts |
13
+
14
+ ## Dependency Rule
15
+
16
+ ```
17
+ core/ → node:fs, node:path, node:os (only)
18
+ ```
19
+
20
+ Never import from `capture/`, `index/`, `retrieve/`, or `server/`.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * categories.js — Static kind→category mapping
3
+ *
4
+ * Three categories with distinct write semantics:
5
+ * knowledge — append-only, enduring (default)
6
+ * entity — upsert by identity_key, enduring
7
+ * event — append-only, decaying relevance
8
+ */
9
+
10
+ const KIND_CATEGORY = {
11
+ // Knowledge — append-only, enduring
12
+ insight: "knowledge",
13
+ decision: "knowledge",
14
+ pattern: "knowledge",
15
+ prompt: "knowledge",
16
+ note: "knowledge",
17
+ document: "knowledge",
18
+ reference: "knowledge",
19
+ // Entity — upsert, enduring
20
+ contact: "entity",
21
+ project: "entity",
22
+ tool: "entity",
23
+ source: "entity",
24
+ // Event — append-only, decaying
25
+ conversation: "event",
26
+ message: "event",
27
+ session: "event",
28
+ task: "event",
29
+ log: "event",
30
+ };
31
+
32
+ /** Map category name → directory name on disk */
33
+ const CATEGORY_DIR_NAMES = {
34
+ knowledge: "knowledge",
35
+ entity: "entities",
36
+ event: "events",
37
+ };
38
+
39
+ /** Set of valid category directory names (for reindex discovery) */
40
+ export const CATEGORY_DIRS = new Set(Object.values(CATEGORY_DIR_NAMES));
41
+
42
+ export function categoryFor(kind) {
43
+ return KIND_CATEGORY[kind] || "knowledge";
44
+ }
45
+
46
+ /** Returns the category directory name for a given kind (e.g. "insight" → "knowledge") */
47
+ export function categoryDirFor(kind) {
48
+ const cat = categoryFor(kind);
49
+ return CATEGORY_DIR_NAMES[cat] || "knowledge";
50
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * config.js — CLI argument parsing and configuration resolution
3
+ *
4
+ * Resolution chain (highest priority last):
5
+ * 1. Convention defaults
6
+ * 2. Config file (~/.context-mcp/config.json)
7
+ * 3. Environment variables (CONTEXT_MCP_*)
8
+ * 4. CLI arguments
9
+ */
10
+
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { join, resolve } from "node:path";
13
+ import { homedir } from "node:os";
14
+
15
+ export function parseArgs(argv) {
16
+ const args = {};
17
+ for (let i = 2; i < argv.length; i++) {
18
+ if (argv[i] === "--vault-dir" && argv[i + 1]) args.vaultDir = argv[++i];
19
+ else if (argv[i] === "--data-dir" && argv[i + 1]) args.dataDir = argv[++i];
20
+ else if (argv[i] === "--db-path" && argv[i + 1]) args.dbPath = argv[++i];
21
+ else if (argv[i] === "--dev-dir" && argv[i + 1]) args.devDir = argv[++i];
22
+ }
23
+ return args;
24
+ }
25
+
26
+ export function resolveConfig() {
27
+ const HOME = homedir();
28
+ const cliArgs = parseArgs(process.argv);
29
+
30
+ // 1. Convention defaults
31
+ const dataDir = resolve(cliArgs.dataDir || process.env.CONTEXT_MCP_DATA_DIR || join(HOME, ".context-mcp"));
32
+ const config = {
33
+ vaultDir: join(HOME, "vault"),
34
+ dataDir,
35
+ dbPath: join(dataDir, "vault.db"),
36
+ devDir: join(HOME, "dev"),
37
+ resolvedFrom: "defaults",
38
+ };
39
+
40
+ // 2. Config file
41
+ const configPath = join(dataDir, "config.json");
42
+ if (existsSync(configPath)) {
43
+ try {
44
+ const fc = JSON.parse(readFileSync(configPath, "utf-8"));
45
+ if (fc.vaultDir) config.vaultDir = fc.vaultDir;
46
+ if (fc.dataDir) { config.dataDir = fc.dataDir; config.dbPath = join(resolve(fc.dataDir), "vault.db"); }
47
+ if (fc.dbPath) config.dbPath = fc.dbPath;
48
+ if (fc.devDir) config.devDir = fc.devDir;
49
+ config.resolvedFrom = "config file";
50
+ } catch (e) {
51
+ throw new Error(`[context-mcp] Invalid config at ${configPath}: ${e.message}`);
52
+ }
53
+ }
54
+ config.configPath = configPath;
55
+
56
+ // 3. Environment variable overrides
57
+ if (process.env.CONTEXT_MCP_VAULT_DIR) { config.vaultDir = process.env.CONTEXT_MCP_VAULT_DIR; config.resolvedFrom = "env"; }
58
+ if (process.env.CONTEXT_MCP_DB_PATH) { config.dbPath = process.env.CONTEXT_MCP_DB_PATH; config.resolvedFrom = "env"; }
59
+ if (process.env.CONTEXT_MCP_DEV_DIR) { config.devDir = process.env.CONTEXT_MCP_DEV_DIR; config.resolvedFrom = "env"; }
60
+
61
+ // 4. CLI arg overrides (highest priority)
62
+ if (cliArgs.vaultDir) { config.vaultDir = cliArgs.vaultDir; config.resolvedFrom = "CLI args"; }
63
+ if (cliArgs.dbPath) { config.dbPath = cliArgs.dbPath; config.resolvedFrom = "CLI args"; }
64
+ if (cliArgs.devDir) { config.devDir = cliArgs.devDir; config.resolvedFrom = "CLI args"; }
65
+
66
+ // Resolve all paths to absolute
67
+ config.vaultDir = resolve(config.vaultDir);
68
+ config.dataDir = resolve(config.dataDir);
69
+ config.dbPath = resolve(config.dbPath);
70
+ config.devDir = resolve(config.devDir);
71
+
72
+ // Check existence
73
+ config.vaultDirExists = existsSync(config.vaultDir);
74
+
75
+ return config;
76
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * files.js — Shared file system utilities used across layers
3
+ *
4
+ * ULID generation, slugify, kind/dir mapping, directory walking.
5
+ */
6
+
7
+ import { readdirSync } from "node:fs";
8
+ import { join, resolve, sep } from "node:path";
9
+ import { categoryDirFor } from "./categories.js";
10
+
11
+ // ─── ULID Generator (Crockford Base32) ────────────────────────────────────────
12
+
13
+ const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
14
+
15
+ export function ulid() {
16
+ const now = Date.now();
17
+ let ts = "";
18
+ let t = now;
19
+ for (let i = 0; i < 10; i++) {
20
+ ts = CROCKFORD[t & 31] + ts;
21
+ t = Math.floor(t / 32);
22
+ }
23
+ let rand = "";
24
+ for (let i = 0; i < 16; i++) {
25
+ rand += CROCKFORD[Math.floor(Math.random() * 32)];
26
+ }
27
+ return ts + rand;
28
+ }
29
+
30
+ // ─── Slugify ──────────────────────────────────────────────────────────────────
31
+
32
+ export function slugify(text, maxLen = 60) {
33
+ let slug = text
34
+ .toLowerCase()
35
+ .replace(/[^a-z0-9]+/g, "-")
36
+ .replace(/^-+|-+$/g, "");
37
+ if (slug.length > maxLen) {
38
+ slug = slug.slice(0, maxLen).replace(/-[^-]*$/, "") || slug.slice(0, maxLen);
39
+ }
40
+ return slug;
41
+ }
42
+
43
+ // ─── Kind ↔ Directory Mapping ────────────────────────────────────────────────
44
+
45
+ const PLURAL_MAP = {
46
+ insight: "insights",
47
+ decision: "decisions",
48
+ pattern: "patterns",
49
+ status: "statuses",
50
+ analysis: "analyses",
51
+ contact: "contacts",
52
+ project: "projects",
53
+ tool: "tools",
54
+ source: "sources",
55
+ conversation: "conversations",
56
+ message: "messages",
57
+ session: "sessions",
58
+ log: "logs",
59
+ };
60
+
61
+ const SINGULAR_MAP = Object.fromEntries(
62
+ Object.entries(PLURAL_MAP).map(([k, v]) => [v, k])
63
+ );
64
+
65
+ export function kindToDir(kind) {
66
+ if (PLURAL_MAP[kind]) return PLURAL_MAP[kind];
67
+ return kind.endsWith("s") ? kind : kind + "s";
68
+ }
69
+
70
+ export function dirToKind(dirName) {
71
+ if (SINGULAR_MAP[dirName]) return SINGULAR_MAP[dirName];
72
+ return dirName.replace(/s$/, "");
73
+ }
74
+
75
+ /** Normalize a kind input (singular or plural) to its canonical singular form. */
76
+ export function normalizeKind(input) {
77
+ if (PLURAL_MAP[input]) return input; // Already a known singular kind
78
+ if (SINGULAR_MAP[input]) return SINGULAR_MAP[input]; // Known plural → singular
79
+ return input; // Unknown — use as-is (don't strip 's')
80
+ }
81
+
82
+ /** Returns relative path from vault root → kind dir: "knowledge/insights", "events/sessions", etc. */
83
+ export function kindToPath(kind) {
84
+ return `${categoryDirFor(kind)}/${kindToDir(kind)}`;
85
+ }
86
+
87
+ // ─── Safe Path Join ─────────────────────────────────────────────────────────
88
+
89
+ export function safeJoin(base, ...parts) {
90
+ const resolvedBase = resolve(base);
91
+ const result = resolve(join(base, ...parts));
92
+ if (!result.startsWith(resolvedBase + sep) && result !== resolvedBase) {
93
+ throw new Error(`Path traversal blocked: resolved path escapes base directory`);
94
+ }
95
+ return result;
96
+ }
97
+
98
+ // ─── Recursive Directory Walk ────────────────────────────────────────────────
99
+
100
+ export function walkDir(dir) {
101
+ const results = [];
102
+ function walk(currentDir, relDir) {
103
+ for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
104
+ const fullPath = join(currentDir, entry.name);
105
+ if (entry.isDirectory() && !entry.name.startsWith("_")) {
106
+ walk(fullPath, relDir ? join(relDir, entry.name) : entry.name);
107
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
108
+ results.push({ filePath: fullPath, relDir });
109
+ }
110
+ }
111
+ }
112
+ walk(dir, "");
113
+ return results;
114
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * frontmatter.js — YAML frontmatter parsing and formatting
3
+ */
4
+
5
+ // ─── YAML Frontmatter Helpers ────────────────────────────────────────────────
6
+
7
+ const NEEDS_QUOTING = /[:#'"{}[\],>|&*?!@`]/;
8
+
9
+ export function formatFrontmatter(meta) {
10
+ const lines = ["---"];
11
+ for (const [k, v] of Object.entries(meta)) {
12
+ if (v === undefined || v === null) continue;
13
+ if (Array.isArray(v)) {
14
+ lines.push(`${k}: [${v.map((i) => JSON.stringify(i)).join(", ")}]`);
15
+ } else {
16
+ const str = String(v);
17
+ lines.push(`${k}: ${NEEDS_QUOTING.test(str) ? JSON.stringify(str) : str}`);
18
+ }
19
+ }
20
+ lines.push("---");
21
+ return lines.join("\n");
22
+ }
23
+
24
+ export function parseFrontmatter(text) {
25
+ const normalized = text.replace(/\r\n/g, "\n");
26
+ const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
27
+ if (!match) return { meta: {}, body: normalized.trim() };
28
+ const meta = {};
29
+ for (const line of match[1].split("\n")) {
30
+ const idx = line.indexOf(":");
31
+ if (idx === -1) continue;
32
+ const key = line.slice(0, idx).trim();
33
+ let val = line.slice(idx + 1).trim();
34
+ // Unquote JSON-quoted strings from formatFrontmatter
35
+ if (val.length >= 2 && val.startsWith('"') && val.endsWith('"') && !val.startsWith('["')) {
36
+ try { val = JSON.parse(val); } catch { /* keep as-is */ }
37
+ }
38
+ // Parse arrays: [a, b, c]
39
+ if (val.startsWith("[") && val.endsWith("]")) {
40
+ try {
41
+ val = JSON.parse(val);
42
+ } catch {
43
+ val = val
44
+ .slice(1, -1)
45
+ .split(",")
46
+ .map((s) => s.trim().replace(/^"|"$/g, ""));
47
+ }
48
+ }
49
+ meta[key] = val;
50
+ }
51
+ return { meta, body: match[2].trim() };
52
+ }
53
+
54
+ // ─── Extract Custom Meta ────────────────────────────────────────────────────
55
+
56
+ const RESERVED_FM_KEYS = new Set(["id", "tags", "source", "created", "identity_key", "expires_at"]);
57
+
58
+ export function extractCustomMeta(fmMeta) {
59
+ const custom = {};
60
+ for (const [k, v] of Object.entries(fmMeta)) {
61
+ if (!RESERVED_FM_KEYS.has(k)) custom[k] = v;
62
+ }
63
+ return Object.keys(custom).length ? custom : null;
64
+ }
65
+
66
+ // ─── Parse Entry From Markdown ──────────────────────────────────────────────
67
+
68
+ export function parseEntryFromMarkdown(kind, body, fmMeta) {
69
+ if (kind === "insight") {
70
+ return {
71
+ title: null,
72
+ body,
73
+ meta: extractCustomMeta(fmMeta),
74
+ };
75
+ }
76
+
77
+ if (kind === "decision") {
78
+ const titleMatch = body.match(/^## Decision\s*\n+([\s\S]*?)(?=\n## |\n*$)/);
79
+ const rationaleMatch = body.match(/## Rationale\s*\n+([\s\S]*?)$/);
80
+ const title = titleMatch ? titleMatch[1].trim() : body.slice(0, 100);
81
+ const rationale = rationaleMatch ? rationaleMatch[1].trim() : body;
82
+ return {
83
+ title,
84
+ body: rationale,
85
+ meta: extractCustomMeta(fmMeta),
86
+ };
87
+ }
88
+
89
+ if (kind === "pattern") {
90
+ const titleMatch = body.match(/^# (.+)/);
91
+ const title = titleMatch ? titleMatch[1].trim() : body.slice(0, 80);
92
+ const codeMatch = body.match(/```[\w]*\n([\s\S]*?)```/);
93
+ const content = codeMatch ? codeMatch[1].trim() : body;
94
+ return {
95
+ title,
96
+ body: content,
97
+ meta: extractCustomMeta(fmMeta),
98
+ };
99
+ }
100
+
101
+ // Generic: use first heading as title, rest as body
102
+ const headingMatch = body.match(/^#+ (.+)/);
103
+ return {
104
+ title: headingMatch ? headingMatch[1].trim() : null,
105
+ body,
106
+ meta: extractCustomMeta(fmMeta),
107
+ };
108
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * status.js — Vault status/diagnostics data gathering
3
+ */
4
+
5
+ import { existsSync, readdirSync, statSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { walkDir } from "./files.js";
8
+
9
+ /**
10
+ * Gather raw vault status data for formatting by consumers.
11
+ *
12
+ * @param {{ db, config }} ctx
13
+ * @returns {{ fileCount, subdirs, kindCounts, dbSize, stalePaths, resolvedFrom, embeddingStatus, errors }}
14
+ */
15
+ export function gatherVaultStatus(ctx) {
16
+ const { db, config } = ctx;
17
+ const errors = [];
18
+
19
+ // Count files in vault subdirs (auto-discover)
20
+ let fileCount = 0;
21
+ const subdirs = [];
22
+ try {
23
+ if (existsSync(config.vaultDir)) {
24
+ for (const d of readdirSync(config.vaultDir, { withFileTypes: true })) {
25
+ if (d.isDirectory()) {
26
+ const dir = join(config.vaultDir, d.name);
27
+ const count = walkDir(dir).length;
28
+ fileCount += count;
29
+ if (count > 0) subdirs.push({ name: d.name, count });
30
+ }
31
+ }
32
+ }
33
+ } catch (e) {
34
+ errors.push(`File scan failed: ${e.message}`);
35
+ }
36
+
37
+ // Count DB rows by kind
38
+ let kindCounts = [];
39
+ try {
40
+ kindCounts = db.prepare("SELECT kind, COUNT(*) as c FROM vault GROUP BY kind").all();
41
+ } catch (e) {
42
+ errors.push(`Kind count query failed: ${e.message}`);
43
+ }
44
+
45
+ // Count DB rows by category
46
+ let categoryCounts = [];
47
+ try {
48
+ categoryCounts = db.prepare("SELECT category, COUNT(*) as c FROM vault GROUP BY category").all();
49
+ } catch (e) {
50
+ errors.push(`Category count query failed: ${e.message}`);
51
+ }
52
+
53
+ // DB file size
54
+ let dbSize = "n/a";
55
+ let dbSizeBytes = 0;
56
+ try {
57
+ if (existsSync(config.dbPath)) {
58
+ dbSizeBytes = statSync(config.dbPath).size;
59
+ dbSize = dbSizeBytes > 1024 * 1024
60
+ ? `${(dbSizeBytes / 1024 / 1024).toFixed(1)}MB`
61
+ : `${(dbSizeBytes / 1024).toFixed(1)}KB`;
62
+ }
63
+ } catch (e) {
64
+ errors.push(`DB size check failed: ${e.message}`);
65
+ }
66
+
67
+ // Check for stale paths (count all mismatches, not just a sample)
68
+ let stalePaths = false;
69
+ let staleCount = 0;
70
+ try {
71
+ const result = db.prepare(
72
+ "SELECT COUNT(*) as c FROM vault WHERE file_path NOT LIKE ? || '%'"
73
+ ).get(config.vaultDir);
74
+ staleCount = result.c;
75
+ stalePaths = staleCount > 0;
76
+ } catch (e) {
77
+ errors.push(`Stale path check failed: ${e.message}`);
78
+ }
79
+
80
+ // Embedding/vector status
81
+ let embeddingStatus = null;
82
+ try {
83
+ const total = db.prepare("SELECT COUNT(*) as c FROM vault").get().c;
84
+ const indexed = db.prepare(
85
+ "SELECT COUNT(*) as c FROM vault WHERE rowid IN (SELECT rowid FROM vault_vec)"
86
+ ).get().c;
87
+ embeddingStatus = { indexed, total, missing: total - indexed };
88
+ } catch (e) {
89
+ errors.push(`Embedding status check failed: ${e.message}`);
90
+ }
91
+
92
+ return {
93
+ fileCount,
94
+ subdirs,
95
+ kindCounts,
96
+ categoryCounts,
97
+ dbSize,
98
+ dbSizeBytes,
99
+ stalePaths,
100
+ staleCount,
101
+ embeddingStatus,
102
+ resolvedFrom: config.resolvedFrom,
103
+ errors,
104
+ };
105
+ }