@context-vault/core 2.17.1 → 3.0.3

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 (101) hide show
  1. package/dist/capture.d.ts +21 -0
  2. package/dist/capture.d.ts.map +1 -0
  3. package/dist/capture.js +269 -0
  4. package/dist/capture.js.map +1 -0
  5. package/dist/categories.d.ts +6 -0
  6. package/dist/categories.d.ts.map +1 -0
  7. package/dist/categories.js +50 -0
  8. package/dist/categories.js.map +1 -0
  9. package/dist/config.d.ts +4 -0
  10. package/dist/config.d.ts.map +1 -0
  11. package/dist/config.js +190 -0
  12. package/dist/config.js.map +1 -0
  13. package/dist/constants.d.ts +33 -0
  14. package/dist/constants.d.ts.map +1 -0
  15. package/dist/constants.js +23 -0
  16. package/dist/constants.js.map +1 -0
  17. package/dist/db.d.ts +13 -0
  18. package/dist/db.d.ts.map +1 -0
  19. package/dist/db.js +191 -0
  20. package/dist/db.js.map +1 -0
  21. package/dist/embed.d.ts +5 -0
  22. package/dist/embed.d.ts.map +1 -0
  23. package/dist/embed.js +78 -0
  24. package/dist/embed.js.map +1 -0
  25. package/dist/files.d.ts +13 -0
  26. package/dist/files.d.ts.map +1 -0
  27. package/dist/files.js +66 -0
  28. package/dist/files.js.map +1 -0
  29. package/dist/formatters.d.ts +8 -0
  30. package/dist/formatters.d.ts.map +1 -0
  31. package/dist/formatters.js +18 -0
  32. package/dist/formatters.js.map +1 -0
  33. package/dist/frontmatter.d.ts +12 -0
  34. package/dist/frontmatter.d.ts.map +1 -0
  35. package/dist/frontmatter.js +101 -0
  36. package/dist/frontmatter.js.map +1 -0
  37. package/dist/index.d.ts +10 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +297 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/ingest-url.d.ts +20 -0
  42. package/dist/ingest-url.d.ts.map +1 -0
  43. package/dist/ingest-url.js +113 -0
  44. package/dist/ingest-url.js.map +1 -0
  45. package/dist/main.d.ts +14 -0
  46. package/dist/main.d.ts.map +1 -0
  47. package/dist/main.js +25 -0
  48. package/dist/main.js.map +1 -0
  49. package/dist/search.d.ts +18 -0
  50. package/dist/search.d.ts.map +1 -0
  51. package/dist/search.js +238 -0
  52. package/dist/search.js.map +1 -0
  53. package/dist/types.d.ts +176 -0
  54. package/dist/types.d.ts.map +1 -0
  55. package/dist/types.js +2 -0
  56. package/dist/types.js.map +1 -0
  57. package/package.json +66 -16
  58. package/src/capture.ts +308 -0
  59. package/src/categories.ts +54 -0
  60. package/src/{core/config.js → config.ts} +34 -33
  61. package/src/{constants.js → constants.ts} +6 -3
  62. package/src/db.ts +229 -0
  63. package/src/{index/embed.js → embed.ts} +10 -35
  64. package/src/{core/files.js → files.ts} +15 -20
  65. package/src/{capture/formatters.js → formatters.ts} +13 -11
  66. package/src/{core/frontmatter.js → frontmatter.ts} +26 -33
  67. package/src/index.ts +353 -0
  68. package/src/ingest-url.ts +99 -0
  69. package/src/main.ts +111 -0
  70. package/src/{retrieve/index.js → search.ts} +62 -150
  71. package/src/types.ts +166 -0
  72. package/src/capture/file-ops.js +0 -99
  73. package/src/capture/import-pipeline.js +0 -46
  74. package/src/capture/importers.js +0 -387
  75. package/src/capture/index.js +0 -250
  76. package/src/capture/ingest-url.js +0 -252
  77. package/src/consolidation/index.js +0 -112
  78. package/src/core/categories.js +0 -73
  79. package/src/core/error-log.js +0 -54
  80. package/src/core/linking.js +0 -161
  81. package/src/core/migrate-dirs.js +0 -196
  82. package/src/core/status.js +0 -350
  83. package/src/core/telemetry.js +0 -90
  84. package/src/core/temporal.js +0 -146
  85. package/src/index/db.js +0 -586
  86. package/src/index/index.js +0 -583
  87. package/src/index.js +0 -71
  88. package/src/server/helpers.js +0 -44
  89. package/src/server/tools/clear-context.js +0 -47
  90. package/src/server/tools/context-status.js +0 -182
  91. package/src/server/tools/create-snapshot.js +0 -200
  92. package/src/server/tools/delete-context.js +0 -60
  93. package/src/server/tools/get-context.js +0 -765
  94. package/src/server/tools/ingest-project.js +0 -244
  95. package/src/server/tools/ingest-url.js +0 -88
  96. package/src/server/tools/list-buckets.js +0 -116
  97. package/src/server/tools/list-context.js +0 -163
  98. package/src/server/tools/save-context.js +0 -632
  99. package/src/server/tools/session-start.js +0 -285
  100. package/src/server/tools.js +0 -172
  101. package/src/sync/sync.js +0 -235
package/src/db.ts ADDED
@@ -0,0 +1,229 @@
1
+ import { unlinkSync, copyFileSync, existsSync } from "node:fs";
2
+ import { DatabaseSync } from "node:sqlite";
3
+ import type { PreparedStatements } from "./types.js";
4
+
5
+ export class NativeModuleError extends Error {
6
+ originalError: Error;
7
+ constructor(originalError: Error) {
8
+ const diagnostic = formatNativeModuleError(originalError);
9
+ super(diagnostic);
10
+ this.name = "NativeModuleError";
11
+ this.originalError = originalError;
12
+ }
13
+ }
14
+
15
+ function formatNativeModuleError(err: Error): string {
16
+ const msg = err.message || "";
17
+ return [
18
+ `sqlite-vec extension failed to load: ${msg}`,
19
+ "",
20
+ ` Running Node.js: ${process.version} (${process.execPath})`,
21
+ "",
22
+ " Fix: Reinstall context-vault:",
23
+ " npx -y context-vault@latest setup",
24
+ ].join("\n");
25
+ }
26
+
27
+ let _sqliteVec: { load: (db: DatabaseSync) => void } | null = null;
28
+
29
+ async function loadSqliteVec() {
30
+ if (_sqliteVec) return _sqliteVec;
31
+ const vecMod = await import("sqlite-vec");
32
+ _sqliteVec = vecMod;
33
+ return _sqliteVec;
34
+ }
35
+
36
+ function runTransaction(db: DatabaseSync, fn: () => void): void {
37
+ db.exec("BEGIN");
38
+ try {
39
+ fn();
40
+ db.exec("COMMIT");
41
+ } catch (e) {
42
+ db.exec("ROLLBACK");
43
+ throw e;
44
+ }
45
+ }
46
+
47
+ export const SCHEMA_DDL = `
48
+ CREATE TABLE IF NOT EXISTS vault (
49
+ id TEXT PRIMARY KEY,
50
+ kind TEXT NOT NULL,
51
+ category TEXT NOT NULL DEFAULT 'knowledge',
52
+ title TEXT,
53
+ body TEXT NOT NULL,
54
+ meta TEXT,
55
+ tags TEXT,
56
+ source TEXT,
57
+ file_path TEXT UNIQUE,
58
+ identity_key TEXT,
59
+ expires_at TEXT,
60
+ superseded_by TEXT,
61
+ created_at TEXT DEFAULT (datetime('now')),
62
+ updated_at TEXT,
63
+ hit_count INTEGER DEFAULT 0,
64
+ last_accessed_at TEXT,
65
+ source_files TEXT,
66
+ tier TEXT DEFAULT 'working' CHECK(tier IN ('ephemeral', 'working', 'durable')),
67
+ related_to TEXT
68
+ );
69
+
70
+ CREATE INDEX IF NOT EXISTS idx_vault_kind ON vault(kind);
71
+ CREATE INDEX IF NOT EXISTS idx_vault_category ON vault(category);
72
+ CREATE INDEX IF NOT EXISTS idx_vault_category_created ON vault(category, created_at DESC);
73
+ CREATE INDEX IF NOT EXISTS idx_vault_updated ON vault(updated_at DESC);
74
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_vault_identity ON vault(kind, identity_key) WHERE identity_key IS NOT NULL AND category = 'entity';
75
+ CREATE INDEX IF NOT EXISTS idx_vault_superseded ON vault(superseded_by) WHERE superseded_by IS NOT NULL;
76
+ CREATE INDEX IF NOT EXISTS idx_vault_tier ON vault(tier);
77
+
78
+ CREATE VIRTUAL TABLE IF NOT EXISTS vault_fts USING fts5(
79
+ title, body, tags, kind,
80
+ content='vault', content_rowid='rowid'
81
+ );
82
+
83
+ CREATE TRIGGER IF NOT EXISTS vault_ai AFTER INSERT ON vault BEGIN
84
+ INSERT INTO vault_fts(rowid, title, body, tags, kind)
85
+ VALUES (new.rowid, new.title, new.body, new.tags, new.kind);
86
+ END;
87
+ CREATE TRIGGER IF NOT EXISTS vault_ad AFTER DELETE ON vault BEGIN
88
+ INSERT INTO vault_fts(vault_fts, rowid, title, body, tags, kind)
89
+ VALUES ('delete', old.rowid, old.title, old.body, old.tags, old.kind);
90
+ END;
91
+ CREATE TRIGGER IF NOT EXISTS vault_au AFTER UPDATE ON vault BEGIN
92
+ INSERT INTO vault_fts(vault_fts, rowid, title, body, tags, kind)
93
+ VALUES ('delete', old.rowid, old.title, old.body, old.tags, old.kind);
94
+ INSERT INTO vault_fts(rowid, title, body, tags, kind)
95
+ VALUES (new.rowid, new.title, new.body, new.tags, new.kind);
96
+ END;
97
+
98
+ CREATE VIRTUAL TABLE IF NOT EXISTS vault_vec USING vec0(embedding float[384]);
99
+ `;
100
+
101
+ const CURRENT_VERSION = 15;
102
+
103
+ export async function initDatabase(dbPath: string): Promise<DatabaseSync> {
104
+ const sqliteVec = await loadSqliteVec();
105
+
106
+ function createDb(path: string): DatabaseSync {
107
+ const db = new DatabaseSync(path, { allowExtension: true });
108
+ db.exec("PRAGMA journal_mode = WAL");
109
+ db.exec("PRAGMA foreign_keys = ON");
110
+ try {
111
+ sqliteVec.load(db);
112
+ } catch (e) {
113
+ throw new NativeModuleError(e as Error);
114
+ }
115
+ return db;
116
+ }
117
+
118
+ const db = createDb(dbPath);
119
+ const version = (db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version;
120
+
121
+ if (version > 0 && version < 15) {
122
+ console.error(
123
+ `[context-vault] Schema v${version} is outdated. Rebuilding database...`,
124
+ );
125
+
126
+ const backupPath = `${dbPath}.v${version}.backup`;
127
+ try {
128
+ db.close();
129
+ if (existsSync(dbPath)) {
130
+ copyFileSync(dbPath, backupPath);
131
+ console.error(
132
+ `[context-vault] Backed up old database to: ${backupPath}`,
133
+ );
134
+ }
135
+ } catch (backupErr) {
136
+ console.error(
137
+ `[context-vault] Warning: could not backup old database: ${(backupErr as Error).message}`,
138
+ );
139
+ }
140
+
141
+ unlinkSync(dbPath);
142
+ try { unlinkSync(dbPath + "-wal"); } catch {}
143
+ try { unlinkSync(dbPath + "-shm"); } catch {}
144
+
145
+ const freshDb = createDb(dbPath);
146
+ freshDb.exec(SCHEMA_DDL);
147
+ freshDb.exec(`PRAGMA user_version = ${CURRENT_VERSION}`);
148
+ return freshDb;
149
+ }
150
+
151
+ if (version < 15) {
152
+ db.exec(SCHEMA_DDL);
153
+ db.exec(`PRAGMA user_version = ${CURRENT_VERSION}`);
154
+ }
155
+
156
+ return db;
157
+ }
158
+
159
+ export function prepareStatements(db: DatabaseSync): PreparedStatements {
160
+ try {
161
+ return {
162
+ insertEntry: db.prepare(
163
+ `INSERT INTO vault (id, kind, category, title, body, meta, tags, source, file_path, identity_key, expires_at, created_at, updated_at, source_files, tier) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
164
+ ),
165
+ updateEntry: db.prepare(
166
+ `UPDATE vault SET title = ?, body = ?, meta = ?, tags = ?, source = ?, category = ?, identity_key = ?, expires_at = ?, updated_at = datetime('now') WHERE file_path = ?`,
167
+ ),
168
+ deleteEntry: db.prepare(`DELETE FROM vault WHERE id = ?`),
169
+ getRowid: db.prepare(`SELECT rowid FROM vault WHERE id = ?`),
170
+ getRowidByPath: db.prepare(
171
+ `SELECT rowid FROM vault WHERE file_path = ?`,
172
+ ),
173
+ getEntryById: db.prepare(`SELECT * FROM vault WHERE id = ?`),
174
+ getByIdentityKey: db.prepare(
175
+ `SELECT * FROM vault WHERE kind = ? AND identity_key = ?`,
176
+ ),
177
+ upsertByIdentityKey: db.prepare(
178
+ `UPDATE vault SET title = ?, body = ?, meta = ?, tags = ?, source = ?, category = ?, file_path = ?, expires_at = ?, source_files = ?, updated_at = datetime('now') WHERE kind = ? AND identity_key = ?`,
179
+ ),
180
+ updateSourceFiles: db.prepare(
181
+ `UPDATE vault SET source_files = ? WHERE id = ?`,
182
+ ),
183
+ updateRelatedTo: db.prepare(
184
+ `UPDATE vault SET related_to = ? WHERE id = ?`,
185
+ ),
186
+ insertVecStmt: db.prepare(
187
+ `INSERT INTO vault_vec (rowid, embedding) VALUES (?, ?)`,
188
+ ),
189
+ deleteVecStmt: db.prepare(`DELETE FROM vault_vec WHERE rowid = ?`),
190
+ updateSupersededBy: db.prepare(
191
+ `UPDATE vault SET superseded_by = ? WHERE id = ?`,
192
+ ),
193
+ clearSupersededByRef: db.prepare(
194
+ `UPDATE vault SET superseded_by = NULL WHERE superseded_by = ?`,
195
+ ),
196
+ };
197
+ } catch (e) {
198
+ throw new Error(
199
+ `Failed to prepare database statements. The database may be corrupted.\n` +
200
+ `Try deleting and rebuilding: context-vault reindex\n` +
201
+ `Original error: ${(e as Error).message}`,
202
+ );
203
+ }
204
+ }
205
+
206
+ export function insertVec(
207
+ stmts: PreparedStatements,
208
+ rowid: number,
209
+ embedding: Float32Array,
210
+ ): void {
211
+ const safeRowid = BigInt(rowid);
212
+ if (safeRowid < 1n) throw new Error(`Invalid rowid: ${rowid}`);
213
+ stmts.insertVecStmt.run(safeRowid, embedding);
214
+ }
215
+
216
+ export function deleteVec(stmts: PreparedStatements, rowid: number): void {
217
+ const safeRowid = BigInt(rowid);
218
+ if (safeRowid < 1n) throw new Error(`Invalid rowid: ${rowid}`);
219
+ stmts.deleteVecStmt.run(safeRowid);
220
+ }
221
+
222
+ export function testConnection(db: DatabaseSync): boolean {
223
+ try {
224
+ db.prepare("SELECT 1").get();
225
+ return true;
226
+ } catch {
227
+ return false;
228
+ }
229
+ }
@@ -1,36 +1,21 @@
1
- /**
2
- * embed.js — Text embedding via HuggingFace transformers
3
- *
4
- * Graceful degradation: if the embedding model fails to load (offline, first run,
5
- * disk issues), semantic search is disabled but FTS still works.
6
- */
7
-
8
1
  import { join } from "node:path";
9
2
  import { homedir } from "node:os";
10
3
  import { mkdirSync } from "node:fs";
11
4
 
12
- let extractor = null;
13
-
14
- /** @type {null | true | false} null = uninitialized/retry, true = ready, false = permanently failed */
15
- let embedAvailable = null;
16
-
17
- /** In-flight load promise — coalesces concurrent callers onto a single pipeline() call */
18
- let loadingPromise = null;
5
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
+ let extractor: any = null;
7
+ let embedAvailable: boolean | null = null;
8
+ let loadingPromise: Promise<typeof extractor> | null = null;
19
9
 
20
- async function ensurePipeline() {
10
+ async function ensurePipeline(): Promise<typeof extractor> {
21
11
  if (embedAvailable === false) return null;
22
12
  if (extractor) return extractor;
23
13
  if (loadingPromise) return loadingPromise;
24
14
 
25
15
  loadingPromise = (async () => {
26
16
  try {
27
- // Dynamic import — @huggingface/transformers is optional (its transitive
28
- // dep `sharp` can fail to install on some platforms). When missing, the
29
- // server still works with full-text search only.
30
17
  const { pipeline, env } = await import("@huggingface/transformers");
31
18
 
32
- // Redirect model cache to ~/.context-mcp/models/ so it works when the
33
- // package is installed globally in a root-owned directory (e.g. /usr/lib/node_modules/).
34
19
  const modelCacheDir = join(homedir(), ".context-mcp", "models");
35
20
  mkdirSync(modelCacheDir, { recursive: true });
36
21
  env.cacheDir = modelCacheDir;
@@ -47,7 +32,7 @@ async function ensurePipeline() {
47
32
  } catch (e) {
48
33
  embedAvailable = false;
49
34
  console.error(
50
- `[context-vault] Failed to load embedding model: ${e.message}`,
35
+ `[context-vault] Failed to load embedding model: ${(e as Error).message}`,
51
36
  );
52
37
  console.error(
53
38
  `[context-vault] Semantic search disabled. Full-text search still works.`,
@@ -61,12 +46,11 @@ async function ensurePipeline() {
61
46
  return loadingPromise;
62
47
  }
63
48
 
64
- export async function embed(text) {
49
+ export async function embed(text: string): Promise<Float32Array | null> {
65
50
  const ext = await ensurePipeline();
66
51
  if (!ext) return null;
67
52
 
68
53
  const result = await ext([text], { pooling: "mean", normalize: true });
69
- // Health check — force re-init on empty results
70
54
  if (!result?.data?.length) {
71
55
  extractor = null;
72
56
  embedAvailable = null;
@@ -76,12 +60,7 @@ export async function embed(text) {
76
60
  return new Float32Array(result.data);
77
61
  }
78
62
 
79
- /**
80
- * Batch embedding — embed multiple texts in a single pipeline call.
81
- * Returns an array of Float32Array embeddings (one per input text).
82
- * Returns array of nulls if embedding is unavailable.
83
- */
84
- export async function embedBatch(texts) {
63
+ export async function embedBatch(texts: string[]): Promise<(Float32Array | null)[]> {
85
64
  if (!texts.length) return [];
86
65
  const ext = await ensurePipeline();
87
66
  if (!ext) return texts.map(() => null);
@@ -99,19 +78,15 @@ export async function embedBatch(texts) {
99
78
  `Unexpected embedding dimension: ${result.data.length} / ${texts.length} = ${dim}`,
100
79
  );
101
80
  }
102
- // subarray() creates a view into result.data's index-space, correctly
103
- // accounting for any non-zero byteOffset on the source typed array.
104
81
  return texts.map((_, i) => result.data.subarray(i * dim, (i + 1) * dim));
105
82
  }
106
83
 
107
- /** Force re-initialization on next embed call. */
108
- export function resetEmbedPipeline() {
84
+ export function resetEmbedPipeline(): void {
109
85
  extractor = null;
110
86
  embedAvailable = null;
111
87
  loadingPromise = null;
112
88
  }
113
89
 
114
- /** Check if embedding is currently available. */
115
- export function isEmbedAvailable() {
90
+ export function isEmbedAvailable(): boolean | null {
116
91
  return embedAvailable;
117
92
  }
@@ -1,16 +1,10 @@
1
- /**
2
- * files.js — Shared file system utilities used across layers
3
- *
4
- * ULID generation, slugify, kind/dir mapping, directory walking.
5
- */
6
-
7
1
  import { readdirSync } from "node:fs";
8
2
  import { join, resolve, sep } from "node:path";
9
3
  import { categoryDirFor } from "./categories.js";
10
4
 
11
5
  const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
12
6
 
13
- export function ulid() {
7
+ export function ulid(): string {
14
8
  const now = Date.now();
15
9
  let ts = "";
16
10
  let t = now;
@@ -25,7 +19,7 @@ export function ulid() {
25
19
  return ts + rand;
26
20
  }
27
21
 
28
- export function slugify(text, maxLen = 60) {
22
+ export function slugify(text: string, maxLen = 60): string {
29
23
  let slug = text
30
24
  .toLowerCase()
31
25
  .replace(/[^a-z0-9]+/g, "-")
@@ -37,27 +31,23 @@ export function slugify(text, maxLen = 60) {
37
31
  return slug;
38
32
  }
39
33
 
40
- /** Map kind name to its directory name. Kind names are used as-is (no pluralization). */
41
- export function kindToDir(kind) {
34
+ export function kindToDir(kind: string): string {
42
35
  return kind;
43
36
  }
44
37
 
45
- /** Map directory name back to kind name. Directory names equal kind names (identity). */
46
- export function dirToKind(dirName) {
38
+ export function dirToKind(dirName: string): string {
47
39
  return dirName;
48
40
  }
49
41
 
50
- /** Normalize a kind input to its canonical form. Kind names are returned as-is. */
51
- export function normalizeKind(input) {
42
+ export function normalizeKind(input: string): string {
52
43
  return input;
53
44
  }
54
45
 
55
- /** Returns relative path from vault root → kind dir: "knowledge/insights", "events/sessions", etc. */
56
- export function kindToPath(kind) {
46
+ export function kindToPath(kind: string): string {
57
47
  return `${categoryDirFor(kind)}/${kindToDir(kind)}`;
58
48
  }
59
49
 
60
- export function safeJoin(base, ...parts) {
50
+ export function safeJoin(base: string, ...parts: string[]): string {
61
51
  const resolvedBase = resolve(base);
62
52
  const result = resolve(join(base, ...parts));
63
53
  if (!result.startsWith(resolvedBase + sep) && result !== resolvedBase) {
@@ -68,9 +58,14 @@ export function safeJoin(base, ...parts) {
68
58
  return result;
69
59
  }
70
60
 
71
- export function walkDir(dir) {
72
- const results = [];
73
- function walk(currentDir, relDir) {
61
+ export interface WalkResult {
62
+ filePath: string;
63
+ relDir: string;
64
+ }
65
+
66
+ export function walkDir(dir: string): WalkResult[] {
67
+ const results: WalkResult[] = [];
68
+ function walk(currentDir: string, relDir: string) {
74
69
  for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
75
70
  const fullPath = join(currentDir, entry.name);
76
71
  if (entry.isDirectory() && !entry.name.startsWith("_")) {
@@ -1,11 +1,10 @@
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
- */
1
+ interface FormatInput {
2
+ title?: string | null;
3
+ body: string;
4
+ meta?: Record<string, unknown>;
5
+ }
7
6
 
8
- const FORMATTERS = {
7
+ const FORMATTERS: Record<string, (input: FormatInput) => string> = {
9
8
  insight: ({ body }) => "\n" + body + "\n",
10
9
 
11
10
  decision: ({ title, body }) => {
@@ -15,15 +14,18 @@ const FORMATTERS = {
15
14
 
16
15
  pattern: ({ title, body, meta }) => {
17
16
  const t = title || body.slice(0, 80);
18
- const lang = meta?.language || "";
17
+ const lang = (meta?.language as string) || "";
19
18
  return "\n# " + t + "\n\n```" + lang + "\n" + body + "\n```\n";
20
19
  },
21
20
  };
22
21
 
23
- const DEFAULT_FORMATTER = ({ title, body }) =>
22
+ const DEFAULT_FORMATTER = ({ title, body }: FormatInput): string =>
24
23
  title ? "\n# " + title + "\n\n" + body + "\n" : "\n" + body + "\n";
25
24
 
26
- export function formatBody(kind, { title, body, meta }) {
25
+ export function formatBody(
26
+ kind: string,
27
+ input: FormatInput,
28
+ ): string {
27
29
  const fn = FORMATTERS[kind] || DEFAULT_FORMATTER;
28
- return fn({ title, body, meta });
30
+ return fn(input);
29
31
  }
@@ -1,10 +1,6 @@
1
- /**
2
- * frontmatter.js — YAML frontmatter parsing and formatting
3
- */
4
-
5
1
  const NEEDS_QUOTING = /[:#'"{}[\],>|&*?!@`]/;
6
2
 
7
- export function formatFrontmatter(meta) {
3
+ export function formatFrontmatter(meta: Record<string, unknown>): string {
8
4
  const lines = ["---"];
9
5
  for (const [k, v] of Object.entries(meta)) {
10
6
  if (v === undefined || v === null) continue;
@@ -21,18 +17,21 @@ export function formatFrontmatter(meta) {
21
17
  return lines.join("\n");
22
18
  }
23
19
 
24
- export function parseFrontmatter(text) {
20
+ export function parseFrontmatter(text: string): {
21
+ meta: Record<string, unknown>;
22
+ body: string;
23
+ } {
25
24
  const normalized = text.replace(/\r\n/g, "\n");
26
25
  const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
27
26
  if (!match) return { meta: {}, body: normalized.trim() };
28
- const meta = {};
27
+ const meta: Record<string, unknown> = {};
29
28
  for (const line of match[1].split("\n")) {
30
29
  const idx = line.indexOf(":");
31
30
  if (idx === -1) continue;
32
31
  const key = line.slice(0, idx).trim();
33
- let val = line.slice(idx + 1).trim();
34
- // Unquote JSON-quoted strings from formatFrontmatter
32
+ let val: unknown = line.slice(idx + 1).trim() as string;
35
33
  if (
34
+ typeof val === "string" &&
36
35
  val.length >= 2 &&
37
36
  val.startsWith('"') &&
38
37
  val.endsWith('"') &&
@@ -44,15 +43,14 @@ export function parseFrontmatter(text) {
44
43
  /* keep as-is */
45
44
  }
46
45
  }
47
- // Parse arrays: [a, b, c]
48
- if (val.startsWith("[") && val.endsWith("]")) {
46
+ if (typeof val === "string" && val.startsWith("[") && val.endsWith("]")) {
49
47
  try {
50
48
  val = JSON.parse(val);
51
49
  } catch {
52
- val = val
50
+ val = (val as string)
53
51
  .slice(1, -1)
54
52
  .split(",")
55
- .map((s) => s.trim().replace(/^"|"$/g, ""));
53
+ .map((s: string) => s.trim().replace(/^"|"$/g, ""));
56
54
  }
57
55
  }
58
56
  meta[key] = val;
@@ -72,33 +70,33 @@ const RESERVED_FM_KEYS = new Set([
72
70
  "related_to",
73
71
  ]);
74
72
 
75
- export function extractCustomMeta(fmMeta) {
76
- const custom = {};
73
+ export function extractCustomMeta(
74
+ fmMeta: Record<string, unknown>,
75
+ ): Record<string, unknown> | null {
76
+ const custom: Record<string, unknown> = {};
77
77
  for (const [k, v] of Object.entries(fmMeta)) {
78
78
  if (!RESERVED_FM_KEYS.has(k)) custom[k] = v;
79
79
  }
80
80
  return Object.keys(custom).length ? custom : null;
81
81
  }
82
82
 
83
- export function parseEntryFromMarkdown(kind, body, fmMeta) {
83
+ export function parseEntryFromMarkdown(
84
+ kind: string,
85
+ body: string,
86
+ fmMeta: Record<string, unknown>,
87
+ ): { title: string | null; body: string; meta: Record<string, unknown> | null } {
84
88
  if (kind === "insight") {
85
- return {
86
- title: null,
87
- body,
88
- meta: extractCustomMeta(fmMeta),
89
- };
89
+ return { title: null, body, meta: extractCustomMeta(fmMeta) };
90
90
  }
91
91
 
92
92
  if (kind === "decision") {
93
- const titleMatch = body.match(/^## Decision\s*\n+([\s\S]*?)(?=\n## |\n*$)/);
93
+ const titleMatch = body.match(
94
+ /^## Decision\s*\n+([\s\S]*?)(?=\n## |\n*$)/,
95
+ );
94
96
  const rationaleMatch = body.match(/## Rationale\s*\n+([\s\S]*?)$/);
95
97
  const title = titleMatch ? titleMatch[1].trim() : body.slice(0, 100);
96
98
  const rationale = rationaleMatch ? rationaleMatch[1].trim() : body;
97
- return {
98
- title,
99
- body: rationale,
100
- meta: extractCustomMeta(fmMeta),
101
- };
99
+ return { title, body: rationale, meta: extractCustomMeta(fmMeta) };
102
100
  }
103
101
 
104
102
  if (kind === "pattern") {
@@ -106,14 +104,9 @@ export function parseEntryFromMarkdown(kind, body, fmMeta) {
106
104
  const title = titleMatch ? titleMatch[1].trim() : body.slice(0, 80);
107
105
  const codeMatch = body.match(/```[\w]*\n([\s\S]*?)```/);
108
106
  const content = codeMatch ? codeMatch[1].trim() : body;
109
- return {
110
- title,
111
- body: content,
112
- meta: extractCustomMeta(fmMeta),
113
- };
107
+ return { title, body: content, meta: extractCustomMeta(fmMeta) };
114
108
  }
115
109
 
116
- // Generic: use first heading as title, rest as body
117
110
  const headingMatch = body.match(/^#+ (.+)/);
118
111
  return {
119
112
  title: headingMatch ? headingMatch[1].trim() : null,