@context-vault/core 2.8.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.
- package/LICENSE +21 -0
- package/package.json +52 -0
- package/src/capture/file-ops.js +93 -0
- package/src/capture/formatters.js +29 -0
- package/src/capture/import-pipeline.js +46 -0
- package/src/capture/importers.js +387 -0
- package/src/capture/index.js +199 -0
- package/src/capture/ingest-url.js +252 -0
- package/src/constants.js +8 -0
- package/src/core/categories.js +51 -0
- package/src/core/config.js +127 -0
- package/src/core/files.js +108 -0
- package/src/core/frontmatter.js +120 -0
- package/src/core/status.js +146 -0
- package/src/index/db.js +268 -0
- package/src/index/embed.js +101 -0
- package/src/index/index.js +451 -0
- package/src/index.js +62 -0
- package/src/retrieve/index.js +219 -0
- package/src/server/helpers.js +31 -0
- package/src/server/tools/context-status.js +104 -0
- package/src/server/tools/delete-context.js +53 -0
- package/src/server/tools/get-context.js +235 -0
- package/src/server/tools/ingest-url.js +99 -0
- package/src/server/tools/list-context.js +134 -0
- package/src/server/tools/save-context.js +297 -0
- package/src/server/tools/submit-feedback.js +55 -0
- package/src/server/tools.js +111 -0
- package/src/sync/sync.js +235 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture Layer — Public API
|
|
3
|
+
*
|
|
4
|
+
* Writes knowledge entries to vault as .md files and indexes them.
|
|
5
|
+
* captureAndIndex() is the write-through entry point (capture + index + rollback on failure).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { ulid, slugify, kindToPath } from "../core/files.js";
|
|
11
|
+
import { categoryFor } from "../core/categories.js";
|
|
12
|
+
import { parseFrontmatter, formatFrontmatter } from "../core/frontmatter.js";
|
|
13
|
+
import { formatBody } from "./formatters.js";
|
|
14
|
+
import { writeEntryFile } from "./file-ops.js";
|
|
15
|
+
import { indexEntry } from "../index/index.js";
|
|
16
|
+
|
|
17
|
+
export function writeEntry(
|
|
18
|
+
ctx,
|
|
19
|
+
{
|
|
20
|
+
kind,
|
|
21
|
+
title,
|
|
22
|
+
body,
|
|
23
|
+
meta,
|
|
24
|
+
tags,
|
|
25
|
+
source,
|
|
26
|
+
folder,
|
|
27
|
+
identity_key,
|
|
28
|
+
expires_at,
|
|
29
|
+
userId,
|
|
30
|
+
},
|
|
31
|
+
) {
|
|
32
|
+
if (!kind || typeof kind !== "string") {
|
|
33
|
+
throw new Error("writeEntry: kind is required (non-empty string)");
|
|
34
|
+
}
|
|
35
|
+
if (!body || typeof body !== "string" || !body.trim()) {
|
|
36
|
+
throw new Error("writeEntry: body is required (non-empty string)");
|
|
37
|
+
}
|
|
38
|
+
if (tags != null && !Array.isArray(tags)) {
|
|
39
|
+
throw new Error("writeEntry: tags must be an array if provided");
|
|
40
|
+
}
|
|
41
|
+
if (meta != null && typeof meta !== "object") {
|
|
42
|
+
throw new Error("writeEntry: meta must be an object if provided");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const category = categoryFor(kind);
|
|
46
|
+
|
|
47
|
+
// Entity upsert: check for existing file at deterministic path
|
|
48
|
+
let id;
|
|
49
|
+
let createdAt;
|
|
50
|
+
if (category === "entity" && identity_key) {
|
|
51
|
+
const identitySlug = slugify(identity_key);
|
|
52
|
+
const dir = resolve(ctx.config.vaultDir, kindToPath(kind));
|
|
53
|
+
const existingPath = resolve(dir, `${identitySlug}.md`);
|
|
54
|
+
|
|
55
|
+
if (existsSync(existingPath)) {
|
|
56
|
+
// Preserve original ID and created timestamp from existing file
|
|
57
|
+
const raw = readFileSync(existingPath, "utf-8");
|
|
58
|
+
const { meta: fmMeta } = parseFrontmatter(raw);
|
|
59
|
+
id = fmMeta.id || ulid();
|
|
60
|
+
createdAt = fmMeta.created || new Date().toISOString();
|
|
61
|
+
} else {
|
|
62
|
+
id = ulid();
|
|
63
|
+
createdAt = new Date().toISOString();
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
id = ulid();
|
|
67
|
+
createdAt = new Date().toISOString();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const filePath = writeEntryFile(ctx.config.vaultDir, kind, {
|
|
71
|
+
id,
|
|
72
|
+
title,
|
|
73
|
+
body,
|
|
74
|
+
meta,
|
|
75
|
+
tags,
|
|
76
|
+
source,
|
|
77
|
+
createdAt,
|
|
78
|
+
folder,
|
|
79
|
+
category,
|
|
80
|
+
identity_key,
|
|
81
|
+
expires_at,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
id,
|
|
86
|
+
filePath,
|
|
87
|
+
kind,
|
|
88
|
+
category,
|
|
89
|
+
title,
|
|
90
|
+
body,
|
|
91
|
+
meta,
|
|
92
|
+
tags,
|
|
93
|
+
source,
|
|
94
|
+
createdAt,
|
|
95
|
+
identity_key,
|
|
96
|
+
expires_at,
|
|
97
|
+
userId: userId || null,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Update an existing entry's file on disk (merge provided fields with existing).
|
|
103
|
+
* Does NOT re-index — caller must call indexEntry after.
|
|
104
|
+
*
|
|
105
|
+
* @param {{ config, stmts }} ctx
|
|
106
|
+
* @param {object} existing — Row from vault table (from getEntryById)
|
|
107
|
+
* @param {{ title?, body?, tags?, meta?, source?, expires_at? }} updates
|
|
108
|
+
* @returns {object} Entry object suitable for indexEntry
|
|
109
|
+
*/
|
|
110
|
+
export function updateEntryFile(ctx, existing, updates) {
|
|
111
|
+
const raw = readFileSync(existing.file_path, "utf-8");
|
|
112
|
+
const { meta: fmMeta } = parseFrontmatter(raw);
|
|
113
|
+
|
|
114
|
+
const existingMeta = existing.meta ? JSON.parse(existing.meta) : {};
|
|
115
|
+
const existingTags = existing.tags ? JSON.parse(existing.tags) : [];
|
|
116
|
+
|
|
117
|
+
const title = updates.title !== undefined ? updates.title : existing.title;
|
|
118
|
+
const body = updates.body !== undefined ? updates.body : existing.body;
|
|
119
|
+
const tags = updates.tags !== undefined ? updates.tags : existingTags;
|
|
120
|
+
const source =
|
|
121
|
+
updates.source !== undefined ? updates.source : existing.source;
|
|
122
|
+
const expires_at =
|
|
123
|
+
updates.expires_at !== undefined ? updates.expires_at : existing.expires_at;
|
|
124
|
+
|
|
125
|
+
let mergedMeta;
|
|
126
|
+
if (updates.meta !== undefined) {
|
|
127
|
+
mergedMeta = { ...existingMeta, ...(updates.meta || {}) };
|
|
128
|
+
} else {
|
|
129
|
+
mergedMeta = { ...existingMeta };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Build frontmatter
|
|
133
|
+
const fmFields = { id: existing.id };
|
|
134
|
+
for (const [k, v] of Object.entries(mergedMeta)) {
|
|
135
|
+
if (k === "folder") continue;
|
|
136
|
+
if (v !== null && v !== undefined) fmFields[k] = v;
|
|
137
|
+
}
|
|
138
|
+
if (existing.identity_key) fmFields.identity_key = existing.identity_key;
|
|
139
|
+
if (expires_at) fmFields.expires_at = expires_at;
|
|
140
|
+
fmFields.tags = tags;
|
|
141
|
+
fmFields.source = source || "claude-code";
|
|
142
|
+
fmFields.created = fmMeta.created || existing.created_at;
|
|
143
|
+
|
|
144
|
+
const mdBody = formatBody(existing.kind, { title, body, meta: mergedMeta });
|
|
145
|
+
const md = formatFrontmatter(fmFields) + mdBody;
|
|
146
|
+
|
|
147
|
+
writeFileSync(existing.file_path, md);
|
|
148
|
+
|
|
149
|
+
const finalMeta = Object.keys(mergedMeta).length ? mergedMeta : undefined;
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
id: existing.id,
|
|
153
|
+
filePath: existing.file_path,
|
|
154
|
+
kind: existing.kind,
|
|
155
|
+
category: existing.category,
|
|
156
|
+
title,
|
|
157
|
+
body,
|
|
158
|
+
meta: finalMeta,
|
|
159
|
+
tags,
|
|
160
|
+
source,
|
|
161
|
+
createdAt: fmMeta.created || existing.created_at,
|
|
162
|
+
identity_key: existing.identity_key,
|
|
163
|
+
expires_at,
|
|
164
|
+
userId: existing.user_id || null,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function captureAndIndex(ctx, data) {
|
|
169
|
+
// For entity upserts, preserve previous file content for safe rollback
|
|
170
|
+
let previousContent = null;
|
|
171
|
+
if (categoryFor(data.kind) === "entity" && data.identity_key) {
|
|
172
|
+
const identitySlug = slugify(data.identity_key);
|
|
173
|
+
const dir = resolve(ctx.config.vaultDir, kindToPath(data.kind));
|
|
174
|
+
const existingPath = resolve(dir, `${identitySlug}.md`);
|
|
175
|
+
if (existsSync(existingPath)) {
|
|
176
|
+
previousContent = readFileSync(existingPath, "utf-8");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const entry = writeEntry(ctx, data);
|
|
181
|
+
try {
|
|
182
|
+
await indexEntry(ctx, entry);
|
|
183
|
+
return entry;
|
|
184
|
+
} catch (err) {
|
|
185
|
+
// Rollback: restore previous content for entity upserts, delete for new entries
|
|
186
|
+
if (previousContent) {
|
|
187
|
+
try {
|
|
188
|
+
writeFileSync(entry.filePath, previousContent);
|
|
189
|
+
} catch {}
|
|
190
|
+
} else {
|
|
191
|
+
try {
|
|
192
|
+
unlinkSync(entry.filePath);
|
|
193
|
+
} catch {}
|
|
194
|
+
}
|
|
195
|
+
throw new Error(
|
|
196
|
+
`Capture succeeded but indexing failed — file rolled back. ${err.message}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
export function htmlToMarkdown(html) {
|
|
2
|
+
let md = html;
|
|
3
|
+
|
|
4
|
+
// Remove scripts, styles, nav, header, footer, aside
|
|
5
|
+
md = md.replace(/<script[\s\S]*?<\/script>/gi, "");
|
|
6
|
+
md = md.replace(/<style[\s\S]*?<\/style>/gi, "");
|
|
7
|
+
md = md.replace(/<nav[\s\S]*?<\/nav>/gi, "");
|
|
8
|
+
md = md.replace(/<header[\s\S]*?<\/header>/gi, "");
|
|
9
|
+
md = md.replace(/<footer[\s\S]*?<\/footer>/gi, "");
|
|
10
|
+
md = md.replace(/<aside[\s\S]*?<\/aside>/gi, "");
|
|
11
|
+
|
|
12
|
+
// Convert headings
|
|
13
|
+
md = md.replace(
|
|
14
|
+
/<h1[^>]*>([\s\S]*?)<\/h1>/gi,
|
|
15
|
+
(_, c) => `\n# ${stripTags(c).trim()}\n`,
|
|
16
|
+
);
|
|
17
|
+
md = md.replace(
|
|
18
|
+
/<h2[^>]*>([\s\S]*?)<\/h2>/gi,
|
|
19
|
+
(_, c) => `\n## ${stripTags(c).trim()}\n`,
|
|
20
|
+
);
|
|
21
|
+
md = md.replace(
|
|
22
|
+
/<h3[^>]*>([\s\S]*?)<\/h3>/gi,
|
|
23
|
+
(_, c) => `\n### ${stripTags(c).trim()}\n`,
|
|
24
|
+
);
|
|
25
|
+
md = md.replace(
|
|
26
|
+
/<h4[^>]*>([\s\S]*?)<\/h4>/gi,
|
|
27
|
+
(_, c) => `\n#### ${stripTags(c).trim()}\n`,
|
|
28
|
+
);
|
|
29
|
+
md = md.replace(
|
|
30
|
+
/<h5[^>]*>([\s\S]*?)<\/h5>/gi,
|
|
31
|
+
(_, c) => `\n##### ${stripTags(c).trim()}\n`,
|
|
32
|
+
);
|
|
33
|
+
md = md.replace(
|
|
34
|
+
/<h6[^>]*>([\s\S]*?)<\/h6>/gi,
|
|
35
|
+
(_, c) => `\n###### ${stripTags(c).trim()}\n`,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// Convert links
|
|
39
|
+
md = md.replace(
|
|
40
|
+
/<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi,
|
|
41
|
+
(_, href, text) => {
|
|
42
|
+
const cleanText = stripTags(text).trim();
|
|
43
|
+
return cleanText ? `[${cleanText}](${href})` : "";
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Convert code blocks
|
|
48
|
+
md = md.replace(
|
|
49
|
+
/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi,
|
|
50
|
+
(_, c) => `\n\`\`\`\n${decodeEntities(c).trim()}\n\`\`\`\n`,
|
|
51
|
+
);
|
|
52
|
+
md = md.replace(
|
|
53
|
+
/<pre[^>]*>([\s\S]*?)<\/pre>/gi,
|
|
54
|
+
(_, c) => `\n\`\`\`\n${decodeEntities(stripTags(c)).trim()}\n\`\`\`\n`,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Convert inline code
|
|
58
|
+
md = md.replace(
|
|
59
|
+
/<code[^>]*>([\s\S]*?)<\/code>/gi,
|
|
60
|
+
(_, c) => `\`${decodeEntities(c).trim()}\``,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// Convert strong/em
|
|
64
|
+
md = md.replace(
|
|
65
|
+
/<(strong|b)[^>]*>([\s\S]*?)<\/\1>/gi,
|
|
66
|
+
(_, __, c) => `**${stripTags(c).trim()}**`,
|
|
67
|
+
);
|
|
68
|
+
md = md.replace(
|
|
69
|
+
/<(em|i)[^>]*>([\s\S]*?)<\/\1>/gi,
|
|
70
|
+
(_, __, c) => `*${stripTags(c).trim()}*`,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Convert list items
|
|
74
|
+
md = md.replace(
|
|
75
|
+
/<li[^>]*>([\s\S]*?)<\/li>/gi,
|
|
76
|
+
(_, c) => `- ${stripTags(c).trim()}\n`,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Convert paragraphs and line breaks
|
|
80
|
+
md = md.replace(/<br\s*\/?>/gi, "\n");
|
|
81
|
+
md = md.replace(
|
|
82
|
+
/<p[^>]*>([\s\S]*?)<\/p>/gi,
|
|
83
|
+
(_, c) => `\n${stripTags(c).trim()}\n`,
|
|
84
|
+
);
|
|
85
|
+
md = md.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (_, c) => {
|
|
86
|
+
return (
|
|
87
|
+
"\n" +
|
|
88
|
+
stripTags(c)
|
|
89
|
+
.trim()
|
|
90
|
+
.split("\n")
|
|
91
|
+
.map((l) => `> ${l}`)
|
|
92
|
+
.join("\n") +
|
|
93
|
+
"\n"
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Remove remaining HTML tags
|
|
98
|
+
md = stripTags(md);
|
|
99
|
+
|
|
100
|
+
// Decode HTML entities
|
|
101
|
+
md = decodeEntities(md);
|
|
102
|
+
|
|
103
|
+
// Clean up whitespace
|
|
104
|
+
md = md.replace(/\n{3,}/g, "\n\n").trim();
|
|
105
|
+
|
|
106
|
+
return md;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function stripTags(html) {
|
|
110
|
+
return html.replace(/<[^>]+>/g, "");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function decodeEntities(text) {
|
|
114
|
+
return text
|
|
115
|
+
.replace(/&/g, "&")
|
|
116
|
+
.replace(/</g, "<")
|
|
117
|
+
.replace(/>/g, ">")
|
|
118
|
+
.replace(/"/g, '"')
|
|
119
|
+
.replace(/'/g, "'")
|
|
120
|
+
.replace(/ /g, " ")
|
|
121
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
|
|
122
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, n) =>
|
|
123
|
+
String.fromCharCode(parseInt(n, 16)),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Extract the main readable content from an HTML page.
|
|
129
|
+
* Prefers <article> or <main>, falls back to <body>.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} html
|
|
132
|
+
* @param {string} url
|
|
133
|
+
* @returns {{ title: string, body: string }}
|
|
134
|
+
*/
|
|
135
|
+
export function extractHtmlContent(html, url) {
|
|
136
|
+
// Extract <title>
|
|
137
|
+
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
138
|
+
const title = titleMatch
|
|
139
|
+
? stripTags(decodeEntities(titleMatch[1])).trim()
|
|
140
|
+
: "";
|
|
141
|
+
|
|
142
|
+
// Try to extract main content area
|
|
143
|
+
let contentHtml = "";
|
|
144
|
+
|
|
145
|
+
const articleMatch = html.match(/<article[^>]*>([\s\S]*?)<\/article>/i);
|
|
146
|
+
const mainMatch = html.match(/<main[^>]*>([\s\S]*?)<\/main>/i);
|
|
147
|
+
|
|
148
|
+
if (articleMatch) {
|
|
149
|
+
contentHtml = articleMatch[1];
|
|
150
|
+
} else if (mainMatch) {
|
|
151
|
+
contentHtml = mainMatch[1];
|
|
152
|
+
} else {
|
|
153
|
+
// Fall back to <body>
|
|
154
|
+
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
|
155
|
+
contentHtml = bodyMatch ? bodyMatch[1] : html;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const body = htmlToMarkdown(contentHtml);
|
|
159
|
+
|
|
160
|
+
return { title, body };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Fetch a URL, extract readable content, and return an EntryData object.
|
|
165
|
+
*
|
|
166
|
+
* @param {string} url
|
|
167
|
+
* @param {{ kind?: string, tags?: string[], source?: string, maxBodyLength?: number, timeoutMs?: number }} [opts]
|
|
168
|
+
* @returns {Promise<{ kind: string, title: string, body: string, tags: string[], meta: object, source: string }>}
|
|
169
|
+
*/
|
|
170
|
+
export async function ingestUrl(url, opts = {}) {
|
|
171
|
+
const {
|
|
172
|
+
kind = "reference",
|
|
173
|
+
tags = [],
|
|
174
|
+
source,
|
|
175
|
+
maxBodyLength = 50000,
|
|
176
|
+
timeoutMs = 15000,
|
|
177
|
+
} = opts;
|
|
178
|
+
|
|
179
|
+
let domain;
|
|
180
|
+
try {
|
|
181
|
+
domain = new URL(url).hostname;
|
|
182
|
+
} catch {
|
|
183
|
+
throw new Error(`Invalid URL: ${url}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const controller = new AbortController();
|
|
187
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
188
|
+
|
|
189
|
+
let response;
|
|
190
|
+
try {
|
|
191
|
+
response = await fetch(url, {
|
|
192
|
+
signal: controller.signal,
|
|
193
|
+
headers: {
|
|
194
|
+
"User-Agent":
|
|
195
|
+
"ContextVault/1.0 (+https://github.com/fellanH/context-vault)",
|
|
196
|
+
Accept: "text/html,application/xhtml+xml,text/plain,*/*",
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
} catch (err) {
|
|
200
|
+
if (err.name === "AbortError") {
|
|
201
|
+
throw new Error(`Request timed out after ${timeoutMs}ms`);
|
|
202
|
+
}
|
|
203
|
+
throw new Error(`Fetch failed: ${err.message}`);
|
|
204
|
+
} finally {
|
|
205
|
+
clearTimeout(timeout);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!response.ok) {
|
|
209
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const contentType = response.headers.get("content-type") || "";
|
|
213
|
+
const html = await response.text();
|
|
214
|
+
|
|
215
|
+
let title, body;
|
|
216
|
+
|
|
217
|
+
if (
|
|
218
|
+
contentType.includes("text/html") ||
|
|
219
|
+
contentType.includes("application/xhtml")
|
|
220
|
+
) {
|
|
221
|
+
const extracted = extractHtmlContent(html, url);
|
|
222
|
+
title = extracted.title;
|
|
223
|
+
body = extracted.body;
|
|
224
|
+
} else {
|
|
225
|
+
// Plain text or other — use as-is
|
|
226
|
+
title = domain;
|
|
227
|
+
body = html;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Truncate if too long
|
|
231
|
+
if (body.length > maxBodyLength) {
|
|
232
|
+
body = body.slice(0, maxBodyLength) + "\n\n[Content truncated]";
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!body.trim()) {
|
|
236
|
+
throw new Error("No readable content extracted from URL");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
kind,
|
|
241
|
+
title: title || domain,
|
|
242
|
+
body,
|
|
243
|
+
tags: [...tags, "web-import"],
|
|
244
|
+
meta: {
|
|
245
|
+
url,
|
|
246
|
+
domain,
|
|
247
|
+
fetched_at: new Date().toISOString(),
|
|
248
|
+
content_type: contentType.split(";")[0].trim() || "text/html",
|
|
249
|
+
},
|
|
250
|
+
source: source || domain,
|
|
251
|
+
};
|
|
252
|
+
}
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const MAX_BODY_LENGTH = 100 * 1024; // 100KB
|
|
2
|
+
export const MAX_TITLE_LENGTH = 500;
|
|
3
|
+
export const MAX_KIND_LENGTH = 64;
|
|
4
|
+
export const MAX_TAG_LENGTH = 100;
|
|
5
|
+
export const MAX_TAGS_COUNT = 20;
|
|
6
|
+
export const MAX_META_LENGTH = 10 * 1024; // 10KB
|
|
7
|
+
export const MAX_SOURCE_LENGTH = 200;
|
|
8
|
+
export const MAX_IDENTITY_KEY_LENGTH = 200;
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
feedback: "event",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Map category name → directory name on disk */
|
|
34
|
+
const CATEGORY_DIR_NAMES = {
|
|
35
|
+
knowledge: "knowledge",
|
|
36
|
+
entity: "entities",
|
|
37
|
+
event: "events",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Set of valid category directory names (for reindex discovery) */
|
|
41
|
+
export const CATEGORY_DIRS = new Set(Object.values(CATEGORY_DIR_NAMES));
|
|
42
|
+
|
|
43
|
+
export function categoryFor(kind) {
|
|
44
|
+
return KIND_CATEGORY[kind] || "knowledge";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Returns the category directory name for a given kind (e.g. "insight" → "knowledge") */
|
|
48
|
+
export function categoryDirFor(kind) {
|
|
49
|
+
const cat = categoryFor(kind);
|
|
50
|
+
return CATEGORY_DIR_NAMES[cat] || "knowledge";
|
|
51
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
export function parseArgs(argv) {
|
|
6
|
+
const args = {};
|
|
7
|
+
for (let i = 2; i < argv.length; i++) {
|
|
8
|
+
if (argv[i] === "--vault-dir" && argv[i + 1]) args.vaultDir = argv[++i];
|
|
9
|
+
else if (argv[i] === "--data-dir" && argv[i + 1]) args.dataDir = argv[++i];
|
|
10
|
+
else if (argv[i] === "--db-path" && argv[i + 1]) args.dbPath = argv[++i];
|
|
11
|
+
else if (argv[i] === "--dev-dir" && argv[i + 1]) args.devDir = argv[++i];
|
|
12
|
+
else if (argv[i] === "--event-decay-days" && argv[i + 1])
|
|
13
|
+
args.eventDecayDays = Number(argv[++i]);
|
|
14
|
+
}
|
|
15
|
+
return args;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolveConfig() {
|
|
19
|
+
const HOME = homedir();
|
|
20
|
+
const cliArgs = parseArgs(process.argv);
|
|
21
|
+
|
|
22
|
+
const dataDir = resolve(
|
|
23
|
+
cliArgs.dataDir ||
|
|
24
|
+
process.env.CONTEXT_VAULT_DATA_DIR ||
|
|
25
|
+
process.env.CONTEXT_MCP_DATA_DIR ||
|
|
26
|
+
join(HOME, ".context-mcp"),
|
|
27
|
+
);
|
|
28
|
+
const config = {
|
|
29
|
+
vaultDir: join(HOME, "vault"),
|
|
30
|
+
dataDir,
|
|
31
|
+
dbPath: join(dataDir, "vault.db"),
|
|
32
|
+
devDir: join(HOME, "dev"),
|
|
33
|
+
eventDecayDays: 30,
|
|
34
|
+
resolvedFrom: "defaults",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const configPath = join(dataDir, "config.json");
|
|
38
|
+
if (existsSync(configPath)) {
|
|
39
|
+
try {
|
|
40
|
+
const fc = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
41
|
+
if (fc.vaultDir) config.vaultDir = fc.vaultDir;
|
|
42
|
+
if (fc.dataDir) {
|
|
43
|
+
config.dataDir = fc.dataDir;
|
|
44
|
+
config.dbPath = join(resolve(fc.dataDir), "vault.db");
|
|
45
|
+
}
|
|
46
|
+
if (fc.dbPath) config.dbPath = fc.dbPath;
|
|
47
|
+
if (fc.devDir) config.devDir = fc.devDir;
|
|
48
|
+
if (fc.eventDecayDays != null) config.eventDecayDays = fc.eventDecayDays;
|
|
49
|
+
// Hosted account linking (Phase 4)
|
|
50
|
+
if (fc.hostedUrl) config.hostedUrl = fc.hostedUrl;
|
|
51
|
+
if (fc.apiKey) config.apiKey = fc.apiKey;
|
|
52
|
+
if (fc.userId) config.userId = fc.userId;
|
|
53
|
+
if (fc.email) config.email = fc.email;
|
|
54
|
+
if (fc.linkedAt) config.linkedAt = fc.linkedAt;
|
|
55
|
+
config.resolvedFrom = "config file";
|
|
56
|
+
} catch (e) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`[context-vault] Invalid config at ${configPath}: ${e.message}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
config.configPath = configPath;
|
|
63
|
+
|
|
64
|
+
if (
|
|
65
|
+
process.env.CONTEXT_VAULT_VAULT_DIR ||
|
|
66
|
+
process.env.CONTEXT_MCP_VAULT_DIR
|
|
67
|
+
) {
|
|
68
|
+
config.vaultDir =
|
|
69
|
+
process.env.CONTEXT_VAULT_VAULT_DIR || process.env.CONTEXT_MCP_VAULT_DIR;
|
|
70
|
+
config.resolvedFrom = "env";
|
|
71
|
+
}
|
|
72
|
+
if (process.env.CONTEXT_VAULT_DB_PATH || process.env.CONTEXT_MCP_DB_PATH) {
|
|
73
|
+
config.dbPath =
|
|
74
|
+
process.env.CONTEXT_VAULT_DB_PATH || process.env.CONTEXT_MCP_DB_PATH;
|
|
75
|
+
config.resolvedFrom = "env";
|
|
76
|
+
}
|
|
77
|
+
if (process.env.CONTEXT_VAULT_DEV_DIR || process.env.CONTEXT_MCP_DEV_DIR) {
|
|
78
|
+
config.devDir =
|
|
79
|
+
process.env.CONTEXT_VAULT_DEV_DIR || process.env.CONTEXT_MCP_DEV_DIR;
|
|
80
|
+
config.resolvedFrom = "env";
|
|
81
|
+
}
|
|
82
|
+
if (
|
|
83
|
+
process.env.CONTEXT_VAULT_EVENT_DECAY_DAYS != null ||
|
|
84
|
+
process.env.CONTEXT_MCP_EVENT_DECAY_DAYS != null
|
|
85
|
+
) {
|
|
86
|
+
config.eventDecayDays = Number(
|
|
87
|
+
process.env.CONTEXT_VAULT_EVENT_DECAY_DAYS ??
|
|
88
|
+
process.env.CONTEXT_MCP_EVENT_DECAY_DAYS,
|
|
89
|
+
);
|
|
90
|
+
config.resolvedFrom = "env";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (process.env.CONTEXT_VAULT_API_KEY) {
|
|
94
|
+
config.apiKey = process.env.CONTEXT_VAULT_API_KEY;
|
|
95
|
+
}
|
|
96
|
+
if (process.env.CONTEXT_VAULT_HOSTED_URL) {
|
|
97
|
+
config.hostedUrl = process.env.CONTEXT_VAULT_HOSTED_URL;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (cliArgs.vaultDir) {
|
|
101
|
+
config.vaultDir = cliArgs.vaultDir;
|
|
102
|
+
config.resolvedFrom = "CLI args";
|
|
103
|
+
}
|
|
104
|
+
if (cliArgs.dbPath) {
|
|
105
|
+
config.dbPath = cliArgs.dbPath;
|
|
106
|
+
config.resolvedFrom = "CLI args";
|
|
107
|
+
}
|
|
108
|
+
if (cliArgs.devDir) {
|
|
109
|
+
config.devDir = cliArgs.devDir;
|
|
110
|
+
config.resolvedFrom = "CLI args";
|
|
111
|
+
}
|
|
112
|
+
if (cliArgs.eventDecayDays != null) {
|
|
113
|
+
config.eventDecayDays = cliArgs.eventDecayDays;
|
|
114
|
+
config.resolvedFrom = "CLI args";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Resolve all paths to absolute
|
|
118
|
+
config.vaultDir = resolve(config.vaultDir);
|
|
119
|
+
config.dataDir = resolve(config.dataDir);
|
|
120
|
+
config.dbPath = resolve(config.dbPath);
|
|
121
|
+
config.devDir = resolve(config.devDir);
|
|
122
|
+
|
|
123
|
+
// Check existence
|
|
124
|
+
config.vaultDirExists = existsSync(config.vaultDir);
|
|
125
|
+
|
|
126
|
+
return config;
|
|
127
|
+
}
|