@context-vault/core 2.17.0 → 3.0.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/dist/capture.d.ts +21 -0
- package/dist/capture.d.ts.map +1 -0
- package/dist/capture.js +269 -0
- package/dist/capture.js.map +1 -0
- package/dist/categories.d.ts +6 -0
- package/dist/categories.d.ts.map +1 -0
- package/dist/categories.js +50 -0
- package/dist/categories.js.map +1 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +190 -0
- package/dist/config.js.map +1 -0
- package/dist/constants.d.ts +33 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +23 -0
- package/dist/constants.js.map +1 -0
- package/dist/db.d.ts +13 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +191 -0
- package/dist/db.js.map +1 -0
- package/dist/embed.d.ts +5 -0
- package/dist/embed.d.ts.map +1 -0
- package/dist/embed.js +78 -0
- package/dist/embed.js.map +1 -0
- package/dist/files.d.ts +13 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +66 -0
- package/dist/files.js.map +1 -0
- package/dist/formatters.d.ts +8 -0
- package/dist/formatters.d.ts.map +1 -0
- package/dist/formatters.js +18 -0
- package/dist/formatters.js.map +1 -0
- package/dist/frontmatter.d.ts +12 -0
- package/dist/frontmatter.d.ts.map +1 -0
- package/dist/frontmatter.js +101 -0
- package/dist/frontmatter.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +297 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest-url.d.ts +20 -0
- package/dist/ingest-url.d.ts.map +1 -0
- package/dist/ingest-url.js +113 -0
- package/dist/ingest-url.js.map +1 -0
- package/dist/main.d.ts +14 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +25 -0
- package/dist/main.js.map +1 -0
- package/dist/search.d.ts +18 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +238 -0
- package/dist/search.js.map +1 -0
- package/dist/types.d.ts +176 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +66 -17
- package/src/capture.ts +308 -0
- package/src/categories.ts +54 -0
- package/src/{core/config.js → config.ts} +34 -33
- package/src/{constants.js → constants.ts} +6 -3
- package/src/db.ts +229 -0
- package/src/{index/embed.js → embed.ts} +10 -35
- package/src/files.ts +80 -0
- package/src/{capture/formatters.js → formatters.ts} +13 -11
- package/src/{core/frontmatter.js → frontmatter.ts} +27 -33
- package/src/index.ts +351 -0
- package/src/ingest-url.ts +99 -0
- package/src/main.ts +111 -0
- package/src/search.ts +285 -0
- package/src/types.ts +166 -0
- package/src/capture/file-ops.js +0 -97
- package/src/capture/import-pipeline.js +0 -46
- package/src/capture/importers.js +0 -387
- package/src/capture/index.js +0 -236
- package/src/capture/ingest-url.js +0 -252
- package/src/consolidation/index.js +0 -112
- package/src/core/categories.js +0 -72
- package/src/core/error-log.js +0 -54
- package/src/core/files.js +0 -108
- package/src/core/status.js +0 -350
- package/src/core/telemetry.js +0 -90
- package/src/index/db.js +0 -416
- package/src/index/index.js +0 -522
- package/src/index.js +0 -66
- package/src/retrieve/index.js +0 -500
- package/src/server/helpers.js +0 -44
- package/src/server/tools/clear-context.js +0 -47
- package/src/server/tools/context-status.js +0 -182
- package/src/server/tools/create-snapshot.js +0 -231
- package/src/server/tools/delete-context.js +0 -60
- package/src/server/tools/get-context.js +0 -678
- package/src/server/tools/ingest-project.js +0 -244
- package/src/server/tools/ingest-url.js +0 -88
- package/src/server/tools/list-buckets.js +0 -116
- package/src/server/tools/list-context.js +0 -163
- package/src/server/tools/save-context.js +0 -609
- package/src/server/tools/session-start.js +0 -285
- package/src/server/tools/submit-feedback.js +0 -55
- package/src/server/tools.js +0 -174
- 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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
let
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
export function isEmbedAvailable() {
|
|
90
|
+
export function isEmbedAvailable(): boolean | null {
|
|
116
91
|
return embedAvailable;
|
|
117
92
|
}
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readdirSync } from "node:fs";
|
|
2
|
+
import { join, resolve, sep } from "node:path";
|
|
3
|
+
import { categoryDirFor } from "./categories.js";
|
|
4
|
+
|
|
5
|
+
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
6
|
+
|
|
7
|
+
export function ulid(): string {
|
|
8
|
+
const now = Date.now();
|
|
9
|
+
let ts = "";
|
|
10
|
+
let t = now;
|
|
11
|
+
for (let i = 0; i < 10; i++) {
|
|
12
|
+
ts = CROCKFORD[t & 31] + ts;
|
|
13
|
+
t = Math.floor(t / 32);
|
|
14
|
+
}
|
|
15
|
+
let rand = "";
|
|
16
|
+
for (let i = 0; i < 16; i++) {
|
|
17
|
+
rand += CROCKFORD[Math.floor(Math.random() * 32)];
|
|
18
|
+
}
|
|
19
|
+
return ts + rand;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function slugify(text: string, maxLen = 60): string {
|
|
23
|
+
let slug = text
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
26
|
+
.replace(/^-+|-+$/g, "");
|
|
27
|
+
if (slug.length > maxLen) {
|
|
28
|
+
slug =
|
|
29
|
+
slug.slice(0, maxLen).replace(/-[^-]*$/, "") || slug.slice(0, maxLen);
|
|
30
|
+
}
|
|
31
|
+
return slug;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function kindToDir(kind: string): string {
|
|
35
|
+
return kind;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function dirToKind(dirName: string): string {
|
|
39
|
+
return dirName;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function normalizeKind(input: string): string {
|
|
43
|
+
return input;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function kindToPath(kind: string): string {
|
|
47
|
+
return `${categoryDirFor(kind)}/${kindToDir(kind)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function safeJoin(base: string, ...parts: string[]): string {
|
|
51
|
+
const resolvedBase = resolve(base);
|
|
52
|
+
const result = resolve(join(base, ...parts));
|
|
53
|
+
if (!result.startsWith(resolvedBase + sep) && result !== resolvedBase) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Path traversal blocked: resolved path escapes base directory`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
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) {
|
|
69
|
+
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
|
70
|
+
const fullPath = join(currentDir, entry.name);
|
|
71
|
+
if (entry.isDirectory() && !entry.name.startsWith("_")) {
|
|
72
|
+
walk(fullPath, relDir ? join(relDir, entry.name) : entry.name);
|
|
73
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
74
|
+
results.push({ filePath: fullPath, relDir });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
walk(dir, "");
|
|
79
|
+
return results;
|
|
80
|
+
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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(
|
|
25
|
+
export function formatBody(
|
|
26
|
+
kind: string,
|
|
27
|
+
input: FormatInput,
|
|
28
|
+
): string {
|
|
27
29
|
const fn = FORMATTERS[kind] || DEFAULT_FORMATTER;
|
|
28
|
-
return fn(
|
|
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
|
-
|
|
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;
|
|
@@ -69,35 +67,36 @@ const RESERVED_FM_KEYS = new Set([
|
|
|
69
67
|
"identity_key",
|
|
70
68
|
"expires_at",
|
|
71
69
|
"supersedes",
|
|
70
|
+
"related_to",
|
|
72
71
|
]);
|
|
73
72
|
|
|
74
|
-
export function extractCustomMeta(
|
|
75
|
-
|
|
73
|
+
export function extractCustomMeta(
|
|
74
|
+
fmMeta: Record<string, unknown>,
|
|
75
|
+
): Record<string, unknown> | null {
|
|
76
|
+
const custom: Record<string, unknown> = {};
|
|
76
77
|
for (const [k, v] of Object.entries(fmMeta)) {
|
|
77
78
|
if (!RESERVED_FM_KEYS.has(k)) custom[k] = v;
|
|
78
79
|
}
|
|
79
80
|
return Object.keys(custom).length ? custom : null;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
export function parseEntryFromMarkdown(
|
|
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 } {
|
|
83
88
|
if (kind === "insight") {
|
|
84
|
-
return {
|
|
85
|
-
title: null,
|
|
86
|
-
body,
|
|
87
|
-
meta: extractCustomMeta(fmMeta),
|
|
88
|
-
};
|
|
89
|
+
return { title: null, body, meta: extractCustomMeta(fmMeta) };
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
if (kind === "decision") {
|
|
92
|
-
const titleMatch = body.match(
|
|
93
|
+
const titleMatch = body.match(
|
|
94
|
+
/^## Decision\s*\n+([\s\S]*?)(?=\n## |\n*$)/,
|
|
95
|
+
);
|
|
93
96
|
const rationaleMatch = body.match(/## Rationale\s*\n+([\s\S]*?)$/);
|
|
94
97
|
const title = titleMatch ? titleMatch[1].trim() : body.slice(0, 100);
|
|
95
98
|
const rationale = rationaleMatch ? rationaleMatch[1].trim() : body;
|
|
96
|
-
return {
|
|
97
|
-
title,
|
|
98
|
-
body: rationale,
|
|
99
|
-
meta: extractCustomMeta(fmMeta),
|
|
100
|
-
};
|
|
99
|
+
return { title, body: rationale, meta: extractCustomMeta(fmMeta) };
|
|
101
100
|
}
|
|
102
101
|
|
|
103
102
|
if (kind === "pattern") {
|
|
@@ -105,14 +104,9 @@ export function parseEntryFromMarkdown(kind, body, fmMeta) {
|
|
|
105
104
|
const title = titleMatch ? titleMatch[1].trim() : body.slice(0, 80);
|
|
106
105
|
const codeMatch = body.match(/```[\w]*\n([\s\S]*?)```/);
|
|
107
106
|
const content = codeMatch ? codeMatch[1].trim() : body;
|
|
108
|
-
return {
|
|
109
|
-
title,
|
|
110
|
-
body: content,
|
|
111
|
-
meta: extractCustomMeta(fmMeta),
|
|
112
|
-
};
|
|
107
|
+
return { title, body: content, meta: extractCustomMeta(fmMeta) };
|
|
113
108
|
}
|
|
114
109
|
|
|
115
|
-
// Generic: use first heading as title, rest as body
|
|
116
110
|
const headingMatch = body.match(/^#+ (.+)/);
|
|
117
111
|
return {
|
|
118
112
|
title: headingMatch ? headingMatch[1].trim() : null,
|