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.
- package/LICENSE +21 -0
- package/README.md +383 -0
- package/bin/cli.js +588 -0
- package/package.json +30 -0
- package/smithery.yaml +10 -0
- package/src/capture/README.md +23 -0
- package/src/capture/file-ops.js +75 -0
- package/src/capture/formatters.js +29 -0
- package/src/capture/index.js +91 -0
- package/src/core/README.md +20 -0
- package/src/core/categories.js +50 -0
- package/src/core/config.js +76 -0
- package/src/core/files.js +114 -0
- package/src/core/frontmatter.js +108 -0
- package/src/core/status.js +105 -0
- package/src/index/README.md +28 -0
- package/src/index/db.js +138 -0
- package/src/index/embed.js +56 -0
- package/src/index/index.js +258 -0
- package/src/retrieve/README.md +19 -0
- package/src/retrieve/index.js +173 -0
- package/src/server/README.md +44 -0
- package/src/server/helpers.js +29 -0
- package/src/server/index.js +82 -0
- package/src/server/tools.js +211 -0
- package/ui/Context.applescript +36 -0
- package/ui/index.html +1377 -0
- package/ui/serve.js +473 -0
|
@@ -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
|
+
}
|