context-vault 2.8.5 → 2.8.6
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/bin/cli.js +1 -251
- package/node_modules/@context-vault/core/package.json +1 -1
- package/node_modules/@context-vault/core/src/retrieve/index.js +2 -2
- package/node_modules/@context-vault/core/src/server/tools/save-context.js +10 -0
- package/package.json +2 -2
- package/scripts/local-server.js +0 -793
package/bin/cli.js
CHANGED
|
@@ -21,9 +21,8 @@ import {
|
|
|
21
21
|
} from "node:fs";
|
|
22
22
|
import { join, resolve, dirname } from "node:path";
|
|
23
23
|
import { homedir, platform } from "node:os";
|
|
24
|
-
import { execSync, execFile
|
|
24
|
+
import { execSync, execFile } from "node:child_process";
|
|
25
25
|
import { fileURLToPath } from "node:url";
|
|
26
|
-
import { createServer as createNetServer } from "node:net";
|
|
27
26
|
|
|
28
27
|
const __filename = fileURLToPath(import.meta.url);
|
|
29
28
|
const __dirname = dirname(__filename);
|
|
@@ -224,7 +223,6 @@ ${bold("Commands:")}
|
|
|
224
223
|
${cyan("connect")} --key cv_... Connect AI tools to hosted vault
|
|
225
224
|
${cyan("switch")} local|hosted Switch between local and hosted MCP modes
|
|
226
225
|
${cyan("serve")} Start the MCP server (used by AI clients)
|
|
227
|
-
${cyan("ui")} [--port 3141] Launch web dashboard
|
|
228
226
|
${cyan("reindex")} Rebuild search index from knowledge files
|
|
229
227
|
${cyan("status")} Show vault diagnostics
|
|
230
228
|
${cyan("update")} Check for and install updates
|
|
@@ -232,8 +230,6 @@ ${bold("Commands:")}
|
|
|
232
230
|
${cyan("import")} <path> Import entries from file or directory
|
|
233
231
|
${cyan("export")} Export vault to JSON or CSV
|
|
234
232
|
${cyan("ingest")} <url> Fetch URL and save as vault entry
|
|
235
|
-
${cyan("link")} --key cv_... Link local vault to hosted account
|
|
236
|
-
${cyan("sync")} Sync entries between local and hosted
|
|
237
233
|
${cyan("migrate")} Migrate vault between local and hosted
|
|
238
234
|
|
|
239
235
|
${bold("Options:")}
|
|
@@ -559,17 +555,6 @@ async function runSetup() {
|
|
|
559
555
|
);
|
|
560
556
|
}
|
|
561
557
|
|
|
562
|
-
// Offer to launch UI
|
|
563
|
-
console.log();
|
|
564
|
-
if (!isNonInteractive) {
|
|
565
|
-
const launchUi = await prompt(` Launch web dashboard? (y/N):`, "N");
|
|
566
|
-
if (launchUi.toLowerCase() === "y") {
|
|
567
|
-
console.log();
|
|
568
|
-
runUi();
|
|
569
|
-
return;
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
|
|
573
558
|
// Health check
|
|
574
559
|
console.log(`\n ${dim("[5/5]")}${bold(" Health check...")}\n`);
|
|
575
560
|
const okResults = results.filter((r) => r.ok);
|
|
@@ -608,7 +593,6 @@ async function runSetup() {
|
|
|
608
593
|
``,
|
|
609
594
|
` ${bold("CLI Commands:")}`,
|
|
610
595
|
` context-vault status Show vault health`,
|
|
611
|
-
` context-vault ui Launch web dashboard`,
|
|
612
596
|
` context-vault update Check for updates`,
|
|
613
597
|
];
|
|
614
598
|
const innerWidth = Math.max(...boxLines.map((l) => l.length)) + 2;
|
|
@@ -1179,52 +1163,6 @@ async function runSwitch() {
|
|
|
1179
1163
|
}
|
|
1180
1164
|
}
|
|
1181
1165
|
|
|
1182
|
-
function runUi() {
|
|
1183
|
-
const port = parseInt(getFlag("--port") || "3141", 10);
|
|
1184
|
-
const localServer = join(ROOT, "scripts", "local-server.js");
|
|
1185
|
-
if (!existsSync(localServer)) {
|
|
1186
|
-
console.error(red("Local server not found."));
|
|
1187
|
-
process.exit(1);
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
|
-
// Probe the port before forking
|
|
1191
|
-
const probe = createNetServer();
|
|
1192
|
-
probe.once("error", (e) => {
|
|
1193
|
-
if (e.code === "EADDRINUSE") {
|
|
1194
|
-
console.error(red(` Port ${port} is already in use.`));
|
|
1195
|
-
console.error(` Try: ${cyan(`context-vault ui --port ${port + 1}`)}`);
|
|
1196
|
-
process.exit(1);
|
|
1197
|
-
}
|
|
1198
|
-
// Other error — let the fork handle it
|
|
1199
|
-
probe.close();
|
|
1200
|
-
launchServer(port, localServer);
|
|
1201
|
-
});
|
|
1202
|
-
probe.listen(port, () => {
|
|
1203
|
-
probe.close(() => {
|
|
1204
|
-
launchServer(port, localServer);
|
|
1205
|
-
});
|
|
1206
|
-
});
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
function launchServer(port, localServer) {
|
|
1210
|
-
const child = fork(localServer, [`--port=${port}`], { stdio: "inherit" });
|
|
1211
|
-
child.on("exit", (code) => process.exit(code ?? 0));
|
|
1212
|
-
|
|
1213
|
-
setTimeout(() => {
|
|
1214
|
-
try {
|
|
1215
|
-
const url = `https://app.context-vault.com?local=${port}`;
|
|
1216
|
-
console.log(`Opening ${url}`);
|
|
1217
|
-
const open =
|
|
1218
|
-
PLATFORM === "darwin"
|
|
1219
|
-
? "open"
|
|
1220
|
-
: PLATFORM === "win32"
|
|
1221
|
-
? "start"
|
|
1222
|
-
: "xdg-open";
|
|
1223
|
-
execSync(`${open} ${url}`, { stdio: "ignore" });
|
|
1224
|
-
} catch {}
|
|
1225
|
-
}, 1500);
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
1166
|
async function runReindex() {
|
|
1229
1167
|
console.log(dim("Loading vault..."));
|
|
1230
1168
|
|
|
@@ -1845,185 +1783,6 @@ async function runIngest() {
|
|
|
1845
1783
|
console.log();
|
|
1846
1784
|
}
|
|
1847
1785
|
|
|
1848
|
-
async function runLink() {
|
|
1849
|
-
const apiKey = getFlag("--key");
|
|
1850
|
-
const hostedUrl = getFlag("--url") || "https://api.context-vault.com";
|
|
1851
|
-
|
|
1852
|
-
if (!apiKey) {
|
|
1853
|
-
console.log(`\n ${bold("context-vault link")} --key cv_...\n`);
|
|
1854
|
-
console.log(` Link your local vault to a hosted Context Vault account.\n`);
|
|
1855
|
-
console.log(` Options:`);
|
|
1856
|
-
console.log(` --key <key> API key (required)`);
|
|
1857
|
-
console.log(
|
|
1858
|
-
` --url <url> Hosted server URL (default: https://api.context-vault.com)`,
|
|
1859
|
-
);
|
|
1860
|
-
console.log();
|
|
1861
|
-
return;
|
|
1862
|
-
}
|
|
1863
|
-
|
|
1864
|
-
console.log(dim(" Verifying API key..."));
|
|
1865
|
-
|
|
1866
|
-
let user;
|
|
1867
|
-
try {
|
|
1868
|
-
const response = await fetch(`${hostedUrl}/api/me`, {
|
|
1869
|
-
headers: { Authorization: `Bearer ${apiKey}` },
|
|
1870
|
-
});
|
|
1871
|
-
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
1872
|
-
user = await response.json();
|
|
1873
|
-
} catch (e) {
|
|
1874
|
-
console.error(red(` Verification failed: ${e.message}`));
|
|
1875
|
-
console.error(dim(` Check your API key and server URL.`));
|
|
1876
|
-
process.exit(1);
|
|
1877
|
-
}
|
|
1878
|
-
|
|
1879
|
-
// Store credentials in config
|
|
1880
|
-
const dataDir = join(HOME, ".context-mcp");
|
|
1881
|
-
const configPath = join(dataDir, "config.json");
|
|
1882
|
-
let config = {};
|
|
1883
|
-
if (existsSync(configPath)) {
|
|
1884
|
-
try {
|
|
1885
|
-
config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
1886
|
-
} catch {}
|
|
1887
|
-
}
|
|
1888
|
-
|
|
1889
|
-
config.hostedUrl = hostedUrl;
|
|
1890
|
-
config.apiKey = apiKey;
|
|
1891
|
-
config.userId = user.userId || user.id;
|
|
1892
|
-
config.email = user.email;
|
|
1893
|
-
config.linkedAt = new Date().toISOString();
|
|
1894
|
-
|
|
1895
|
-
mkdirSync(dataDir, { recursive: true });
|
|
1896
|
-
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
1897
|
-
|
|
1898
|
-
console.log();
|
|
1899
|
-
console.log(green(` ✓ Linked to ${user.email}`));
|
|
1900
|
-
console.log(dim(` Tier: ${user.tier || "free"}`));
|
|
1901
|
-
console.log(dim(` Server: ${hostedUrl}`));
|
|
1902
|
-
console.log(dim(` Config: ${configPath}`));
|
|
1903
|
-
console.log();
|
|
1904
|
-
}
|
|
1905
|
-
|
|
1906
|
-
async function runSync() {
|
|
1907
|
-
const dryRun = flags.has("--dry-run");
|
|
1908
|
-
const pushOnly = flags.has("--push-only");
|
|
1909
|
-
const pullOnly = flags.has("--pull-only");
|
|
1910
|
-
|
|
1911
|
-
// Read credentials
|
|
1912
|
-
const dataDir = join(HOME, ".context-mcp");
|
|
1913
|
-
const configPath = join(dataDir, "config.json");
|
|
1914
|
-
let storedConfig = {};
|
|
1915
|
-
if (existsSync(configPath)) {
|
|
1916
|
-
try {
|
|
1917
|
-
storedConfig = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
1918
|
-
} catch {}
|
|
1919
|
-
}
|
|
1920
|
-
|
|
1921
|
-
const apiKey = getFlag("--key") || storedConfig.apiKey;
|
|
1922
|
-
const hostedUrl =
|
|
1923
|
-
getFlag("--url") ||
|
|
1924
|
-
storedConfig.hostedUrl ||
|
|
1925
|
-
"https://api.context-vault.com";
|
|
1926
|
-
|
|
1927
|
-
if (!apiKey) {
|
|
1928
|
-
console.error(
|
|
1929
|
-
red(" Not linked. Run `context-vault link --key cv_...` first."),
|
|
1930
|
-
);
|
|
1931
|
-
process.exit(1);
|
|
1932
|
-
}
|
|
1933
|
-
|
|
1934
|
-
const { resolveConfig } = await import("@context-vault/core/core/config");
|
|
1935
|
-
const { initDatabase, prepareStatements, insertVec, deleteVec } =
|
|
1936
|
-
await import("@context-vault/core/index/db");
|
|
1937
|
-
const { embed } = await import("@context-vault/core/index/embed");
|
|
1938
|
-
const {
|
|
1939
|
-
buildLocalManifest,
|
|
1940
|
-
fetchRemoteManifest,
|
|
1941
|
-
computeSyncPlan,
|
|
1942
|
-
executeSync,
|
|
1943
|
-
} = await import("@context-vault/core/sync");
|
|
1944
|
-
|
|
1945
|
-
const config = resolveConfig();
|
|
1946
|
-
if (!config.vaultDirExists) {
|
|
1947
|
-
console.error(red(` Vault directory not found: ${config.vaultDir}`));
|
|
1948
|
-
process.exit(1);
|
|
1949
|
-
}
|
|
1950
|
-
|
|
1951
|
-
const db = await initDatabase(config.dbPath);
|
|
1952
|
-
const stmts = prepareStatements(db);
|
|
1953
|
-
const ctx = {
|
|
1954
|
-
db,
|
|
1955
|
-
config,
|
|
1956
|
-
stmts,
|
|
1957
|
-
embed,
|
|
1958
|
-
insertVec: (r, e) => insertVec(stmts, r, e),
|
|
1959
|
-
deleteVec: (r) => deleteVec(stmts, r),
|
|
1960
|
-
};
|
|
1961
|
-
|
|
1962
|
-
console.log(dim(" Building manifests..."));
|
|
1963
|
-
const local = buildLocalManifest(ctx);
|
|
1964
|
-
|
|
1965
|
-
let remote;
|
|
1966
|
-
try {
|
|
1967
|
-
remote = await fetchRemoteManifest(hostedUrl, apiKey);
|
|
1968
|
-
} catch (e) {
|
|
1969
|
-
db.close();
|
|
1970
|
-
console.error(red(` Failed to fetch remote manifest: ${e.message}`));
|
|
1971
|
-
process.exit(1);
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
|
-
const plan = computeSyncPlan(local, remote);
|
|
1975
|
-
|
|
1976
|
-
// Apply push-only / pull-only filters
|
|
1977
|
-
if (pushOnly) plan.toPull = [];
|
|
1978
|
-
if (pullOnly) plan.toPush = [];
|
|
1979
|
-
|
|
1980
|
-
console.log();
|
|
1981
|
-
console.log(` ${bold("Sync Plan")}`);
|
|
1982
|
-
console.log(` Push (local → remote): ${plan.toPush.length} entries`);
|
|
1983
|
-
console.log(` Pull (remote → local): ${plan.toPull.length} entries`);
|
|
1984
|
-
console.log(` Up to date: ${plan.upToDate.length} entries`);
|
|
1985
|
-
|
|
1986
|
-
if (plan.toPush.length === 0 && plan.toPull.length === 0) {
|
|
1987
|
-
db.close();
|
|
1988
|
-
console.log(green("\n ✓ Everything in sync."));
|
|
1989
|
-
console.log();
|
|
1990
|
-
return;
|
|
1991
|
-
}
|
|
1992
|
-
|
|
1993
|
-
if (dryRun) {
|
|
1994
|
-
db.close();
|
|
1995
|
-
console.log(dim("\n Dry run — no changes were made."));
|
|
1996
|
-
console.log();
|
|
1997
|
-
return;
|
|
1998
|
-
}
|
|
1999
|
-
|
|
2000
|
-
console.log(dim("\n Syncing..."));
|
|
2001
|
-
|
|
2002
|
-
const result = await executeSync(ctx, {
|
|
2003
|
-
hostedUrl,
|
|
2004
|
-
apiKey,
|
|
2005
|
-
plan,
|
|
2006
|
-
onProgress: (phase, current, total) => {
|
|
2007
|
-
process.stdout.write(
|
|
2008
|
-
`\r ${phase === "push" ? "Pushing" : "Pulling"}... ${current}/${total}`,
|
|
2009
|
-
);
|
|
2010
|
-
},
|
|
2011
|
-
});
|
|
2012
|
-
|
|
2013
|
-
db.close();
|
|
2014
|
-
|
|
2015
|
-
console.log(`\r ${green("✓")} Sync complete `);
|
|
2016
|
-
console.log(` ${green("↑")} ${result.pushed} pushed`);
|
|
2017
|
-
console.log(` ${green("↓")} ${result.pulled} pulled`);
|
|
2018
|
-
if (result.failed > 0) {
|
|
2019
|
-
console.log(` ${red("x")} ${result.failed} failed`);
|
|
2020
|
-
for (const err of result.errors.slice(0, 5)) {
|
|
2021
|
-
console.log(` ${dim(err)}`);
|
|
2022
|
-
}
|
|
2023
|
-
}
|
|
2024
|
-
console.log();
|
|
2025
|
-
}
|
|
2026
|
-
|
|
2027
1786
|
async function runServe() {
|
|
2028
1787
|
await import("../src/server/index.js");
|
|
2029
1788
|
}
|
|
@@ -2052,9 +1811,6 @@ async function main() {
|
|
|
2052
1811
|
case "serve":
|
|
2053
1812
|
await runServe();
|
|
2054
1813
|
break;
|
|
2055
|
-
case "ui":
|
|
2056
|
-
runUi();
|
|
2057
|
-
break;
|
|
2058
1814
|
case "import":
|
|
2059
1815
|
await runImport();
|
|
2060
1816
|
break;
|
|
@@ -2064,12 +1820,6 @@ async function main() {
|
|
|
2064
1820
|
case "ingest":
|
|
2065
1821
|
await runIngest();
|
|
2066
1822
|
break;
|
|
2067
|
-
case "link":
|
|
2068
|
-
await runLink();
|
|
2069
|
-
break;
|
|
2070
|
-
case "sync":
|
|
2071
|
-
await runSync();
|
|
2072
|
-
break;
|
|
2073
1823
|
case "reindex":
|
|
2074
1824
|
await runReindex();
|
|
2075
1825
|
break;
|
|
@@ -16,8 +16,8 @@ const VEC_WEIGHT = 0.6;
|
|
|
16
16
|
*/
|
|
17
17
|
export function buildFtsQuery(query) {
|
|
18
18
|
const words = query
|
|
19
|
-
.split(
|
|
20
|
-
.map((w) => w.replace(/[*"()
|
|
19
|
+
.split(/[\s-]+/)
|
|
20
|
+
.map((w) => w.replace(/[*"():^~{}]/g, ""))
|
|
21
21
|
.filter((w) => w.length > 0);
|
|
22
22
|
if (!words.length) return null;
|
|
23
23
|
return words.map((w) => `"${w}"`).join(" AND ");
|
|
@@ -26,6 +26,7 @@ function validateSaveInput({
|
|
|
26
26
|
meta,
|
|
27
27
|
source,
|
|
28
28
|
identity_key,
|
|
29
|
+
expires_at,
|
|
29
30
|
}) {
|
|
30
31
|
if (kind !== undefined && kind !== null) {
|
|
31
32
|
if (typeof kind !== "string" || kind.length > MAX_KIND_LENGTH) {
|
|
@@ -93,6 +94,14 @@ function validateSaveInput({
|
|
|
93
94
|
);
|
|
94
95
|
}
|
|
95
96
|
}
|
|
97
|
+
if (expires_at !== undefined && expires_at !== null) {
|
|
98
|
+
if (
|
|
99
|
+
typeof expires_at !== "string" ||
|
|
100
|
+
isNaN(new Date(expires_at).getTime())
|
|
101
|
+
) {
|
|
102
|
+
return err("expires_at must be a valid ISO date string", "INVALID_INPUT");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
96
105
|
return null;
|
|
97
106
|
}
|
|
98
107
|
|
|
@@ -178,6 +187,7 @@ export async function handler(
|
|
|
178
187
|
meta,
|
|
179
188
|
source,
|
|
180
189
|
identity_key,
|
|
190
|
+
expires_at,
|
|
181
191
|
});
|
|
182
192
|
if (inputErr) return inputErr;
|
|
183
193
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-vault",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Persistent memory for AI agents — saves and searches knowledge across sessions",
|
|
6
6
|
"bin": {
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"@context-vault/core"
|
|
56
56
|
],
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@context-vault/core": "^2.8.
|
|
58
|
+
"@context-vault/core": "^2.8.6",
|
|
59
59
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
60
60
|
"better-sqlite3": "^12.6.2",
|
|
61
61
|
"sqlite-vec": "^0.1.0"
|
package/scripts/local-server.js
DELETED
|
@@ -1,793 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* local-server.js — Local mode: serves app + vault API with no auth.
|
|
4
|
-
*
|
|
5
|
-
* Uses local SQLite vault. No authentication required.
|
|
6
|
-
* Usage: node local-server.js [--port 3141]
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { createServer } from "node:http";
|
|
10
|
-
import {
|
|
11
|
-
createReadStream,
|
|
12
|
-
existsSync,
|
|
13
|
-
statSync,
|
|
14
|
-
unlinkSync,
|
|
15
|
-
readFileSync,
|
|
16
|
-
writeFileSync,
|
|
17
|
-
mkdirSync,
|
|
18
|
-
} from "node:fs";
|
|
19
|
-
import { join, resolve, dirname, extname } from "node:path";
|
|
20
|
-
import { fileURLToPath } from "node:url";
|
|
21
|
-
import { homedir, platform } from "node:os";
|
|
22
|
-
import { execSync } from "node:child_process";
|
|
23
|
-
import { resolveConfig } from "@context-vault/core/core/config";
|
|
24
|
-
import {
|
|
25
|
-
initDatabase,
|
|
26
|
-
prepareStatements,
|
|
27
|
-
insertVec,
|
|
28
|
-
deleteVec,
|
|
29
|
-
} from "@context-vault/core/index/db";
|
|
30
|
-
import { embed } from "@context-vault/core/index/embed";
|
|
31
|
-
import { captureAndIndex, updateEntryFile } from "@context-vault/core/capture";
|
|
32
|
-
import { indexEntry } from "@context-vault/core/index";
|
|
33
|
-
import { hybridSearch } from "@context-vault/core/retrieve";
|
|
34
|
-
import { gatherVaultStatus } from "@context-vault/core/core/status";
|
|
35
|
-
import { normalizeKind } from "@context-vault/core/core/files";
|
|
36
|
-
import { categoryFor } from "@context-vault/core/core/categories";
|
|
37
|
-
import { parseFile } from "@context-vault/core/capture/importers";
|
|
38
|
-
import { importEntries } from "@context-vault/core/capture/import-pipeline";
|
|
39
|
-
import { ingestUrl } from "@context-vault/core/capture/ingest-url";
|
|
40
|
-
import {
|
|
41
|
-
buildLocalManifest,
|
|
42
|
-
fetchRemoteManifest,
|
|
43
|
-
computeSyncPlan,
|
|
44
|
-
executeSync,
|
|
45
|
-
} from "@context-vault/core/sync";
|
|
46
|
-
|
|
47
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
48
|
-
const LOCAL_ROOT = resolve(__dirname, "..");
|
|
49
|
-
const APP_DIST = resolve(LOCAL_ROOT, "app-dist");
|
|
50
|
-
|
|
51
|
-
const MIME = {
|
|
52
|
-
".html": "text/html",
|
|
53
|
-
".js": "application/javascript",
|
|
54
|
-
".css": "text/css",
|
|
55
|
-
".json": "application/json",
|
|
56
|
-
".ico": "image/x-icon",
|
|
57
|
-
".svg": "image/svg+xml",
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
function formatEntry(row) {
|
|
61
|
-
return {
|
|
62
|
-
id: row.id,
|
|
63
|
-
kind: row.kind,
|
|
64
|
-
category: row.category,
|
|
65
|
-
title: row.title || null,
|
|
66
|
-
body: row.body || null,
|
|
67
|
-
tags: row.tags ? JSON.parse(row.tags) : [],
|
|
68
|
-
meta: row.meta
|
|
69
|
-
? typeof row.meta === "string"
|
|
70
|
-
? JSON.parse(row.meta)
|
|
71
|
-
: row.meta
|
|
72
|
-
: {},
|
|
73
|
-
source: row.source || null,
|
|
74
|
-
identity_key: row.identity_key || null,
|
|
75
|
-
expires_at: row.expires_at || null,
|
|
76
|
-
created_at: row.created_at,
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function validateEntry(data, { requireKind = true, requireBody = true } = {}) {
|
|
81
|
-
if (requireKind && !data.kind)
|
|
82
|
-
return { error: "kind is required", status: 400 };
|
|
83
|
-
if (data.kind && !/^[a-z0-9-]+$/.test(data.kind))
|
|
84
|
-
return {
|
|
85
|
-
error: "kind must be lowercase alphanumeric/hyphens",
|
|
86
|
-
status: 400,
|
|
87
|
-
};
|
|
88
|
-
if (requireBody && !data.body)
|
|
89
|
-
return { error: "body is required", status: 400 };
|
|
90
|
-
if (data.body && data.body.length > 100 * 1024)
|
|
91
|
-
return { error: "body max 100KB", status: 400 };
|
|
92
|
-
if (categoryFor(data.kind) === "entity" && !data.identity_key)
|
|
93
|
-
return {
|
|
94
|
-
error: `Entity kind "${data.kind}" requires identity_key`,
|
|
95
|
-
status: 400,
|
|
96
|
-
};
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function getAllowedOrigin(req) {
|
|
101
|
-
const origin = req.headers["origin"];
|
|
102
|
-
if (!origin) return null;
|
|
103
|
-
const lower = origin.toLowerCase();
|
|
104
|
-
if (
|
|
105
|
-
lower === "https://context-vault.com" ||
|
|
106
|
-
lower === "https://www.context-vault.com"
|
|
107
|
-
)
|
|
108
|
-
return origin;
|
|
109
|
-
if (/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(lower)) return origin;
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
async function main() {
|
|
114
|
-
const portArg = process.argv.find((a) => a.startsWith("--port="));
|
|
115
|
-
const portVal = portArg
|
|
116
|
-
? portArg.split("=")[1]
|
|
117
|
-
: process.argv[process.argv.indexOf("--port") + 1];
|
|
118
|
-
const port = parseInt(portVal || "3141", 10);
|
|
119
|
-
|
|
120
|
-
const config = resolveConfig();
|
|
121
|
-
config.vaultDirExists = existsSync(config.vaultDir);
|
|
122
|
-
let db = await initDatabase(config.dbPath);
|
|
123
|
-
let stmts = prepareStatements(db);
|
|
124
|
-
|
|
125
|
-
const state = {
|
|
126
|
-
db,
|
|
127
|
-
config,
|
|
128
|
-
stmts,
|
|
129
|
-
embed,
|
|
130
|
-
get ctx() {
|
|
131
|
-
return {
|
|
132
|
-
db: state.db,
|
|
133
|
-
config: state.config,
|
|
134
|
-
stmts: state.stmts,
|
|
135
|
-
embed: state.embed,
|
|
136
|
-
insertVec: (r, e) => insertVec(state.stmts, r, e),
|
|
137
|
-
deleteVec: (r) => deleteVec(state.stmts, r),
|
|
138
|
-
userId: null,
|
|
139
|
-
};
|
|
140
|
-
},
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
const server = createServer(async (req, res) => {
|
|
144
|
-
const url = req.url?.replace(/\?.*$/, "") || "/";
|
|
145
|
-
|
|
146
|
-
const allowedOrigin = getAllowedOrigin(req);
|
|
147
|
-
const corsHeaders = allowedOrigin
|
|
148
|
-
? {
|
|
149
|
-
"Access-Control-Allow-Origin": allowedOrigin,
|
|
150
|
-
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
151
|
-
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
152
|
-
Vary: "Origin",
|
|
153
|
-
}
|
|
154
|
-
: { Vary: "Origin" };
|
|
155
|
-
|
|
156
|
-
if (req.method === "OPTIONS") {
|
|
157
|
-
res.writeHead(204, corsHeaders);
|
|
158
|
-
res.end();
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const json = (data, status = 200) => {
|
|
163
|
-
res.writeHead(status, {
|
|
164
|
-
"Content-Type": "application/json",
|
|
165
|
-
...corsHeaders,
|
|
166
|
-
});
|
|
167
|
-
res.end(JSON.stringify(data));
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
const readBody = () =>
|
|
171
|
-
new Promise((resolve) => {
|
|
172
|
-
let body = "";
|
|
173
|
-
req.on("data", (c) => (body += c));
|
|
174
|
-
req.on("end", () => {
|
|
175
|
-
try {
|
|
176
|
-
resolve(body ? JSON.parse(body) : null);
|
|
177
|
-
} catch {
|
|
178
|
-
resolve(null);
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
const ctx = state.ctx;
|
|
184
|
-
|
|
185
|
-
if (url === "/api/local/browse" && req.method === "POST") {
|
|
186
|
-
const os = platform();
|
|
187
|
-
try {
|
|
188
|
-
let selected;
|
|
189
|
-
if (os === "darwin") {
|
|
190
|
-
selected = execSync(
|
|
191
|
-
`osascript -e 'POSIX path of (choose folder with prompt "Select vault folder")'`,
|
|
192
|
-
{ encoding: "utf-8", timeout: 30000 },
|
|
193
|
-
).trim();
|
|
194
|
-
} else if (os === "linux") {
|
|
195
|
-
selected = execSync(
|
|
196
|
-
`zenity --file-selection --directory --title="Select vault folder" 2>/dev/null`,
|
|
197
|
-
{ encoding: "utf-8", timeout: 30000 },
|
|
198
|
-
).trim();
|
|
199
|
-
} else {
|
|
200
|
-
return json(
|
|
201
|
-
{ error: "Folder picker not supported on this platform" },
|
|
202
|
-
501,
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (selected) {
|
|
207
|
-
return json({ path: selected });
|
|
208
|
-
}
|
|
209
|
-
return json({ path: null, cancelled: true });
|
|
210
|
-
} catch {
|
|
211
|
-
// User cancelled the dialog or command failed
|
|
212
|
-
return json({ path: null, cancelled: true });
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
if (url === "/api/local/connect" && req.method === "POST") {
|
|
217
|
-
const data = await readBody();
|
|
218
|
-
if (!data?.vaultDir?.trim())
|
|
219
|
-
return json(
|
|
220
|
-
{ error: "vaultDir is required", code: "INVALID_INPUT" },
|
|
221
|
-
400,
|
|
222
|
-
);
|
|
223
|
-
let vaultPath = data.vaultDir.trim().replace(/^~/, homedir());
|
|
224
|
-
vaultPath = resolve(vaultPath);
|
|
225
|
-
if (!existsSync(vaultPath))
|
|
226
|
-
return json(
|
|
227
|
-
{ error: "Vault folder not found", code: "NOT_FOUND" },
|
|
228
|
-
404,
|
|
229
|
-
);
|
|
230
|
-
if (!statSync(vaultPath).isDirectory())
|
|
231
|
-
return json(
|
|
232
|
-
{ error: "Path is not a directory", code: "INVALID_INPUT" },
|
|
233
|
-
400,
|
|
234
|
-
);
|
|
235
|
-
try {
|
|
236
|
-
try {
|
|
237
|
-
state.db.close();
|
|
238
|
-
} catch {}
|
|
239
|
-
const newConfig = {
|
|
240
|
-
...state.config,
|
|
241
|
-
vaultDir: vaultPath,
|
|
242
|
-
dbPath: join(vaultPath, ".context-vault.db"),
|
|
243
|
-
vaultDirExists: true,
|
|
244
|
-
};
|
|
245
|
-
state.config = newConfig;
|
|
246
|
-
state.db = await initDatabase(newConfig.dbPath);
|
|
247
|
-
state.stmts = prepareStatements(state.db);
|
|
248
|
-
console.log(`[context-vault] Switched to vault: ${vaultPath}`);
|
|
249
|
-
return json({
|
|
250
|
-
userId: "local",
|
|
251
|
-
email: "local@localhost",
|
|
252
|
-
name: "Local",
|
|
253
|
-
tier: "free",
|
|
254
|
-
createdAt: new Date().toISOString(),
|
|
255
|
-
});
|
|
256
|
-
} catch (e) {
|
|
257
|
-
console.error(`[local-server] Connect error: ${e.message}`);
|
|
258
|
-
return json(
|
|
259
|
-
{ error: `Failed to connect: ${e.message}`, code: "CONNECT_FAILED" },
|
|
260
|
-
500,
|
|
261
|
-
);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
if (url === "/api/health" && req.method === "GET") {
|
|
266
|
-
return json({ ok: true, mode: "local" });
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
if (url === "/api/me" && req.method === "GET") {
|
|
270
|
-
return json({
|
|
271
|
-
userId: "local",
|
|
272
|
-
email: "local@localhost",
|
|
273
|
-
name: "Local",
|
|
274
|
-
tier: "free",
|
|
275
|
-
createdAt: new Date().toISOString(),
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
if (url === "/api/billing/usage" && req.method === "GET") {
|
|
280
|
-
const status = gatherVaultStatus(ctx, {});
|
|
281
|
-
const total = status.kindCounts.reduce((s, k) => s + k.c, 0);
|
|
282
|
-
const storageMb =
|
|
283
|
-
Math.round((status.dbSizeBytes / (1024 * 1024)) * 100) / 100;
|
|
284
|
-
return json({
|
|
285
|
-
tier: "free",
|
|
286
|
-
limits: {
|
|
287
|
-
maxEntries: "unlimited",
|
|
288
|
-
requestsPerDay: "unlimited",
|
|
289
|
-
storageMb: 1024,
|
|
290
|
-
exportEnabled: true,
|
|
291
|
-
},
|
|
292
|
-
usage: { requestsToday: 0, entriesUsed: total, storageMb },
|
|
293
|
-
});
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
if (url === "/api/keys" && req.method === "GET") {
|
|
297
|
-
return json({ keys: [] });
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
if (url === "/api/vault/status" && req.method === "GET") {
|
|
301
|
-
const status = gatherVaultStatus(ctx, {});
|
|
302
|
-
return json({
|
|
303
|
-
entries: {
|
|
304
|
-
total: status.kindCounts.reduce((s, k) => s + k.c, 0),
|
|
305
|
-
by_kind: Object.fromEntries(
|
|
306
|
-
status.kindCounts.map((k) => [k.kind, k.c]),
|
|
307
|
-
),
|
|
308
|
-
by_category: Object.fromEntries(
|
|
309
|
-
status.categoryCounts.map((k) => [k.category, k.c]),
|
|
310
|
-
),
|
|
311
|
-
},
|
|
312
|
-
files: { total: status.fileCount, directories: status.subdirs },
|
|
313
|
-
database: {
|
|
314
|
-
size: status.dbSize,
|
|
315
|
-
size_bytes: status.dbSizeBytes,
|
|
316
|
-
stale_paths: status.staleCount,
|
|
317
|
-
expired: status.expiredCount,
|
|
318
|
-
},
|
|
319
|
-
embeddings: status.embeddingStatus,
|
|
320
|
-
embed_model_available: status.embedModelAvailable,
|
|
321
|
-
health:
|
|
322
|
-
status.errors.length === 0 && !status.stalePaths ? "ok" : "degraded",
|
|
323
|
-
errors: status.errors,
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
if (url.startsWith("/api/vault/entries") && req.method === "GET") {
|
|
328
|
-
const idMatch = url.match(/\/api\/vault\/entries\/([^/]+)$/);
|
|
329
|
-
if (idMatch) {
|
|
330
|
-
const entry = stmts.getEntryById.get(idMatch[1]);
|
|
331
|
-
if (!entry)
|
|
332
|
-
return json({ error: "Entry not found", code: "NOT_FOUND" }, 404);
|
|
333
|
-
return json(formatEntry(entry));
|
|
334
|
-
}
|
|
335
|
-
const u = new URL(req.url || "", "http://localhost");
|
|
336
|
-
const kind = u.searchParams.get("kind") || null;
|
|
337
|
-
const category = u.searchParams.get("category") || null;
|
|
338
|
-
const limit = Math.min(
|
|
339
|
-
parseInt(u.searchParams.get("limit") || "20", 10) || 20,
|
|
340
|
-
100,
|
|
341
|
-
);
|
|
342
|
-
const offset = parseInt(u.searchParams.get("offset") || "0", 10) || 0;
|
|
343
|
-
const clauses = ["(expires_at IS NULL OR expires_at > datetime('now'))"];
|
|
344
|
-
const params = [];
|
|
345
|
-
if (kind) {
|
|
346
|
-
clauses.push("kind = ?");
|
|
347
|
-
params.push(normalizeKind(kind));
|
|
348
|
-
}
|
|
349
|
-
if (category) {
|
|
350
|
-
clauses.push("category = ?");
|
|
351
|
-
params.push(category);
|
|
352
|
-
}
|
|
353
|
-
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
354
|
-
const total = ctx.db
|
|
355
|
-
.prepare(`SELECT COUNT(*) as c FROM vault ${where}`)
|
|
356
|
-
.get(...params).c;
|
|
357
|
-
const rows = ctx.db
|
|
358
|
-
.prepare(
|
|
359
|
-
`SELECT * FROM vault ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
|
360
|
-
)
|
|
361
|
-
.all(...params, limit, offset);
|
|
362
|
-
return json({ entries: rows.map(formatEntry), total, limit, offset });
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
if (url === "/api/vault/entries" && req.method === "POST") {
|
|
366
|
-
const data = await readBody();
|
|
367
|
-
if (!data)
|
|
368
|
-
return json({ error: "Invalid JSON body", code: "INVALID_INPUT" }, 400);
|
|
369
|
-
const err = validateEntry(data);
|
|
370
|
-
if (err)
|
|
371
|
-
return json({ error: err.error, code: "INVALID_INPUT" }, err.status);
|
|
372
|
-
try {
|
|
373
|
-
const entry = await captureAndIndex(ctx, {
|
|
374
|
-
kind: data.kind,
|
|
375
|
-
title: data.title,
|
|
376
|
-
body: data.body,
|
|
377
|
-
meta: data.meta,
|
|
378
|
-
tags: data.tags,
|
|
379
|
-
source: data.source || "rest-api",
|
|
380
|
-
identity_key: data.identity_key,
|
|
381
|
-
expires_at: data.expires_at,
|
|
382
|
-
userId: null,
|
|
383
|
-
});
|
|
384
|
-
return json(formatEntry(stmts.getEntryById.get(entry.id)), 201);
|
|
385
|
-
} catch (e) {
|
|
386
|
-
console.error(`[local-server] Create error: ${e.message}`);
|
|
387
|
-
return json(
|
|
388
|
-
{ error: "Failed to create entry", code: "CREATE_FAILED" },
|
|
389
|
-
500,
|
|
390
|
-
);
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
if (url.match(/^\/api\/vault\/entries\/[^/]+$/) && req.method === "PUT") {
|
|
395
|
-
const id = url.split("/").pop();
|
|
396
|
-
const data = await readBody();
|
|
397
|
-
if (!data)
|
|
398
|
-
return json({ error: "Invalid JSON body", code: "INVALID_INPUT" }, 400);
|
|
399
|
-
const err = validateEntry(data, {
|
|
400
|
-
requireKind: false,
|
|
401
|
-
requireBody: false,
|
|
402
|
-
});
|
|
403
|
-
if (err)
|
|
404
|
-
return json({ error: err.error, code: "INVALID_INPUT" }, err.status);
|
|
405
|
-
const existing = stmts.getEntryById.get(id);
|
|
406
|
-
if (!existing)
|
|
407
|
-
return json({ error: "Entry not found", code: "NOT_FOUND" }, 404);
|
|
408
|
-
try {
|
|
409
|
-
const entry = updateEntryFile(ctx, existing, {
|
|
410
|
-
title: data.title,
|
|
411
|
-
body: data.body,
|
|
412
|
-
tags: data.tags,
|
|
413
|
-
meta: data.meta,
|
|
414
|
-
source: data.source,
|
|
415
|
-
expires_at: data.expires_at,
|
|
416
|
-
});
|
|
417
|
-
await indexEntry(ctx, entry);
|
|
418
|
-
return json(formatEntry(stmts.getEntryById.get(id)));
|
|
419
|
-
} catch (e) {
|
|
420
|
-
console.error(`[local-server] Update error: ${e.message}`);
|
|
421
|
-
return json(
|
|
422
|
-
{ error: "Failed to update entry", code: "UPDATE_FAILED" },
|
|
423
|
-
500,
|
|
424
|
-
);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
if (
|
|
429
|
-
url.match(/^\/api\/vault\/entries\/[^/]+$/) &&
|
|
430
|
-
req.method === "DELETE"
|
|
431
|
-
) {
|
|
432
|
-
const id = url.split("/").pop();
|
|
433
|
-
const entry = stmts.getEntryById.get(id);
|
|
434
|
-
if (!entry)
|
|
435
|
-
return json({ error: "Entry not found", code: "NOT_FOUND" }, 404);
|
|
436
|
-
if (entry.file_path) {
|
|
437
|
-
try {
|
|
438
|
-
unlinkSync(entry.file_path);
|
|
439
|
-
} catch {}
|
|
440
|
-
}
|
|
441
|
-
const rowidResult = stmts.getRowid.get(id);
|
|
442
|
-
if (rowidResult?.rowid) {
|
|
443
|
-
try {
|
|
444
|
-
deleteVec(stmts, Number(rowidResult.rowid));
|
|
445
|
-
} catch {}
|
|
446
|
-
}
|
|
447
|
-
stmts.deleteEntry.run(id);
|
|
448
|
-
return json({
|
|
449
|
-
deleted: true,
|
|
450
|
-
id,
|
|
451
|
-
kind: entry.kind,
|
|
452
|
-
title: entry.title || null,
|
|
453
|
-
});
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
if (url === "/api/vault/search" && req.method === "POST") {
|
|
457
|
-
const data = await readBody();
|
|
458
|
-
if (!data || !data.query?.trim())
|
|
459
|
-
return json({ error: "query is required", code: "INVALID_INPUT" }, 400);
|
|
460
|
-
const limit = Math.min(parseInt(data.limit || 20, 10) || 20, 100);
|
|
461
|
-
const offset = parseInt(data.offset || 0, 10) || 0;
|
|
462
|
-
try {
|
|
463
|
-
const results = await hybridSearch(ctx, data.query, {
|
|
464
|
-
kindFilter: data.kind ? normalizeKind(data.kind) : null,
|
|
465
|
-
categoryFilter: data.category || null,
|
|
466
|
-
limit,
|
|
467
|
-
offset,
|
|
468
|
-
decayDays: ctx.config.eventDecayDays || 30,
|
|
469
|
-
userIdFilter: undefined,
|
|
470
|
-
});
|
|
471
|
-
const formatted = results.map((row) => ({
|
|
472
|
-
...formatEntry(row),
|
|
473
|
-
score: Math.round(row.score * 1000) / 1000,
|
|
474
|
-
}));
|
|
475
|
-
return json({
|
|
476
|
-
results: formatted,
|
|
477
|
-
count: formatted.length,
|
|
478
|
-
query: data.query,
|
|
479
|
-
});
|
|
480
|
-
} catch (e) {
|
|
481
|
-
console.error(`[local-server] Search error: ${e.message}`);
|
|
482
|
-
return json({ error: "Search failed", code: "SEARCH_FAILED" }, 500);
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
if (url === "/api/vault/import/bulk" && req.method === "POST") {
|
|
487
|
-
const data = await readBody();
|
|
488
|
-
if (!data || !Array.isArray(data.entries)) {
|
|
489
|
-
return json(
|
|
490
|
-
{
|
|
491
|
-
error: "Invalid body — expected { entries: [...] }",
|
|
492
|
-
code: "INVALID_INPUT",
|
|
493
|
-
},
|
|
494
|
-
400,
|
|
495
|
-
);
|
|
496
|
-
}
|
|
497
|
-
if (data.entries.length > 500) {
|
|
498
|
-
return json(
|
|
499
|
-
{ error: "Maximum 500 entries per request", code: "LIMIT_EXCEEDED" },
|
|
500
|
-
400,
|
|
501
|
-
);
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
const result = await importEntries(ctx, data.entries, {
|
|
505
|
-
source: "bulk-import",
|
|
506
|
-
});
|
|
507
|
-
return json({
|
|
508
|
-
imported: result.imported,
|
|
509
|
-
failed: result.failed,
|
|
510
|
-
errors: result.errors.slice(0, 10).map((e) => e.error),
|
|
511
|
-
});
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
if (url === "/api/vault/import/file" && req.method === "POST") {
|
|
515
|
-
const data = await readBody();
|
|
516
|
-
if (!data?.filename || !data?.content) {
|
|
517
|
-
return json(
|
|
518
|
-
{ error: "filename and content are required", code: "INVALID_INPUT" },
|
|
519
|
-
400,
|
|
520
|
-
);
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
const entries = parseFile(data.filename, data.content, {
|
|
524
|
-
kind: data.kind,
|
|
525
|
-
source: data.source || "file-import",
|
|
526
|
-
});
|
|
527
|
-
if (!entries.length)
|
|
528
|
-
return json({
|
|
529
|
-
imported: 0,
|
|
530
|
-
failed: 0,
|
|
531
|
-
errors: ["No entries parsed from file"],
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
const result = await importEntries(ctx, entries, {
|
|
535
|
-
source: data.source || "file-import",
|
|
536
|
-
});
|
|
537
|
-
return json({
|
|
538
|
-
imported: result.imported,
|
|
539
|
-
failed: result.failed,
|
|
540
|
-
errors: result.errors.slice(0, 10).map((e) => e.error),
|
|
541
|
-
});
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
if (url.startsWith("/api/vault/export") && req.method === "GET") {
|
|
545
|
-
const u = new URL(req.url || "", "http://localhost");
|
|
546
|
-
const format = u.searchParams.get("format") || "json";
|
|
547
|
-
|
|
548
|
-
const rows = ctx.db
|
|
549
|
-
.prepare(
|
|
550
|
-
"SELECT * FROM vault WHERE (expires_at IS NULL OR expires_at > datetime('now')) ORDER BY created_at DESC",
|
|
551
|
-
)
|
|
552
|
-
.all();
|
|
553
|
-
|
|
554
|
-
const entries = rows.map(formatEntry);
|
|
555
|
-
|
|
556
|
-
if (format === "csv") {
|
|
557
|
-
const headers = [
|
|
558
|
-
"id",
|
|
559
|
-
"kind",
|
|
560
|
-
"category",
|
|
561
|
-
"title",
|
|
562
|
-
"body",
|
|
563
|
-
"tags",
|
|
564
|
-
"source",
|
|
565
|
-
"identity_key",
|
|
566
|
-
"expires_at",
|
|
567
|
-
"created_at",
|
|
568
|
-
];
|
|
569
|
-
const csvLines = [headers.join(",")];
|
|
570
|
-
for (const e of entries) {
|
|
571
|
-
const row = headers.map((h) => {
|
|
572
|
-
let val = e[h];
|
|
573
|
-
if (Array.isArray(val)) val = val.join(", ");
|
|
574
|
-
if (val == null) val = "";
|
|
575
|
-
val = String(val);
|
|
576
|
-
if (val.includes(",") || val.includes('"') || val.includes("\n")) {
|
|
577
|
-
val = '"' + val.replace(/"/g, '""') + '"';
|
|
578
|
-
}
|
|
579
|
-
return val;
|
|
580
|
-
});
|
|
581
|
-
csvLines.push(row.join(","));
|
|
582
|
-
}
|
|
583
|
-
res.writeHead(200, {
|
|
584
|
-
"Content-Type": "text/csv",
|
|
585
|
-
...corsHeaders,
|
|
586
|
-
});
|
|
587
|
-
res.end(csvLines.join("\n"));
|
|
588
|
-
return;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
return json({
|
|
592
|
-
entries,
|
|
593
|
-
total: entries.length,
|
|
594
|
-
exported_at: new Date().toISOString(),
|
|
595
|
-
});
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
if (url === "/api/vault/ingest" && req.method === "POST") {
|
|
599
|
-
const data = await readBody();
|
|
600
|
-
if (!data?.url)
|
|
601
|
-
return json({ error: "url is required", code: "INVALID_INPUT" }, 400);
|
|
602
|
-
|
|
603
|
-
try {
|
|
604
|
-
const entry = await ingestUrl(data.url, {
|
|
605
|
-
kind: data.kind,
|
|
606
|
-
tags: data.tags,
|
|
607
|
-
});
|
|
608
|
-
const result = await captureAndIndex(ctx, entry);
|
|
609
|
-
return json(formatEntry(ctx.stmts.getEntryById.get(result.id)), 201);
|
|
610
|
-
} catch (e) {
|
|
611
|
-
return json(
|
|
612
|
-
{ error: `Ingestion failed: ${e.message}`, code: "INGEST_FAILED" },
|
|
613
|
-
500,
|
|
614
|
-
);
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
if (url === "/api/vault/manifest" && req.method === "GET") {
|
|
619
|
-
const rows = ctx.db
|
|
620
|
-
.prepare(
|
|
621
|
-
"SELECT id, kind, title, created_at FROM vault WHERE (expires_at IS NULL OR expires_at > datetime('now')) ORDER BY created_at DESC",
|
|
622
|
-
)
|
|
623
|
-
.all();
|
|
624
|
-
return json({
|
|
625
|
-
entries: rows.map((r) => ({
|
|
626
|
-
id: r.id,
|
|
627
|
-
kind: r.kind,
|
|
628
|
-
title: r.title || null,
|
|
629
|
-
created_at: r.created_at,
|
|
630
|
-
})),
|
|
631
|
-
});
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
if (url === "/api/local/link" && req.method === "GET") {
|
|
635
|
-
const dataDir = join(homedir(), ".context-mcp");
|
|
636
|
-
const configPath = join(dataDir, "config.json");
|
|
637
|
-
let storedConfig = {};
|
|
638
|
-
if (existsSync(configPath)) {
|
|
639
|
-
try {
|
|
640
|
-
storedConfig = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
641
|
-
} catch {}
|
|
642
|
-
}
|
|
643
|
-
return json({
|
|
644
|
-
linked: !!storedConfig.apiKey,
|
|
645
|
-
email: storedConfig.email || null,
|
|
646
|
-
hostedUrl: storedConfig.hostedUrl || null,
|
|
647
|
-
linkedAt: storedConfig.linkedAt || null,
|
|
648
|
-
tier: storedConfig.tier || null,
|
|
649
|
-
});
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
if (url === "/api/local/link" && req.method === "POST") {
|
|
653
|
-
const data = await readBody();
|
|
654
|
-
const dataDir = join(homedir(), ".context-mcp");
|
|
655
|
-
const configPath = join(dataDir, "config.json");
|
|
656
|
-
|
|
657
|
-
let storedConfig = {};
|
|
658
|
-
if (existsSync(configPath)) {
|
|
659
|
-
try {
|
|
660
|
-
storedConfig = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
661
|
-
} catch {}
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
if (!data?.apiKey) {
|
|
665
|
-
// Unlink
|
|
666
|
-
delete storedConfig.apiKey;
|
|
667
|
-
delete storedConfig.hostedUrl;
|
|
668
|
-
delete storedConfig.userId;
|
|
669
|
-
delete storedConfig.email;
|
|
670
|
-
delete storedConfig.linkedAt;
|
|
671
|
-
delete storedConfig.tier;
|
|
672
|
-
writeFileSync(configPath, JSON.stringify(storedConfig, null, 2) + "\n");
|
|
673
|
-
return json({ linked: false });
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
const hostedUrl = data.hostedUrl || "https://api.context-vault.com";
|
|
677
|
-
try {
|
|
678
|
-
const response = await fetch(`${hostedUrl}/api/me`, {
|
|
679
|
-
headers: { Authorization: `Bearer ${data.apiKey}` },
|
|
680
|
-
});
|
|
681
|
-
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
682
|
-
const user = await response.json();
|
|
683
|
-
|
|
684
|
-
storedConfig.apiKey = data.apiKey;
|
|
685
|
-
storedConfig.hostedUrl = hostedUrl;
|
|
686
|
-
storedConfig.userId = user.userId || user.id;
|
|
687
|
-
storedConfig.email = user.email;
|
|
688
|
-
storedConfig.tier = user.tier || "free";
|
|
689
|
-
storedConfig.linkedAt = new Date().toISOString();
|
|
690
|
-
|
|
691
|
-
mkdirSync(dataDir, { recursive: true });
|
|
692
|
-
writeFileSync(configPath, JSON.stringify(storedConfig, null, 2) + "\n");
|
|
693
|
-
|
|
694
|
-
return json({
|
|
695
|
-
linked: true,
|
|
696
|
-
email: user.email,
|
|
697
|
-
tier: user.tier || "free",
|
|
698
|
-
});
|
|
699
|
-
} catch (e) {
|
|
700
|
-
return json(
|
|
701
|
-
{ error: `Verification failed: ${e.message}`, code: "AUTH_FAILED" },
|
|
702
|
-
401,
|
|
703
|
-
);
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
if (url === "/api/local/sync" && req.method === "POST") {
|
|
708
|
-
const dataDir = join(homedir(), ".context-mcp");
|
|
709
|
-
const configPath = join(dataDir, "config.json");
|
|
710
|
-
let storedConfig = {};
|
|
711
|
-
if (existsSync(configPath)) {
|
|
712
|
-
try {
|
|
713
|
-
storedConfig = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
714
|
-
} catch {}
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
if (!storedConfig.apiKey) {
|
|
718
|
-
return json(
|
|
719
|
-
{ error: "Not linked. Use link endpoint first.", code: "NOT_LINKED" },
|
|
720
|
-
400,
|
|
721
|
-
);
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
try {
|
|
725
|
-
const local = buildLocalManifest(ctx);
|
|
726
|
-
const remote = await fetchRemoteManifest(
|
|
727
|
-
storedConfig.hostedUrl,
|
|
728
|
-
storedConfig.apiKey,
|
|
729
|
-
);
|
|
730
|
-
const plan = computeSyncPlan(local, remote);
|
|
731
|
-
const result = await executeSync(ctx, {
|
|
732
|
-
hostedUrl: storedConfig.hostedUrl,
|
|
733
|
-
apiKey: storedConfig.apiKey,
|
|
734
|
-
plan,
|
|
735
|
-
});
|
|
736
|
-
return json(result);
|
|
737
|
-
} catch (e) {
|
|
738
|
-
return json(
|
|
739
|
-
{ error: `Sync failed: ${e.message}`, code: "SYNC_FAILED" },
|
|
740
|
-
500,
|
|
741
|
-
);
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
const filePath = join(
|
|
746
|
-
APP_DIST,
|
|
747
|
-
url === "/" ? "index.html" : url.replace(/^\//, ""),
|
|
748
|
-
);
|
|
749
|
-
if (!filePath.startsWith(APP_DIST)) {
|
|
750
|
-
res.writeHead(403);
|
|
751
|
-
res.end();
|
|
752
|
-
return;
|
|
753
|
-
}
|
|
754
|
-
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
|
755
|
-
const fallback = join(APP_DIST, "index.html");
|
|
756
|
-
if (existsSync(fallback)) {
|
|
757
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
758
|
-
createReadStream(fallback).pipe(res);
|
|
759
|
-
return;
|
|
760
|
-
}
|
|
761
|
-
res.writeHead(404);
|
|
762
|
-
res.end();
|
|
763
|
-
return;
|
|
764
|
-
}
|
|
765
|
-
const type = MIME[extname(filePath)] || "application/octet-stream";
|
|
766
|
-
res.writeHead(200, { "Content-Type": type });
|
|
767
|
-
createReadStream(filePath).pipe(res);
|
|
768
|
-
});
|
|
769
|
-
|
|
770
|
-
server.listen(port, () => {
|
|
771
|
-
console.log(`[context-vault] Local mode: http://localhost:${port}`);
|
|
772
|
-
console.log(`[context-vault] Vault: ${config.vaultDir}`);
|
|
773
|
-
console.log(`[context-vault] No authentication required`);
|
|
774
|
-
});
|
|
775
|
-
|
|
776
|
-
process.on("SIGINT", () => {
|
|
777
|
-
try {
|
|
778
|
-
state.db.close();
|
|
779
|
-
} catch {}
|
|
780
|
-
process.exit(0);
|
|
781
|
-
});
|
|
782
|
-
process.on("SIGTERM", () => {
|
|
783
|
-
try {
|
|
784
|
-
state.db.close();
|
|
785
|
-
} catch {}
|
|
786
|
-
process.exit(0);
|
|
787
|
-
});
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
main().catch((e) => {
|
|
791
|
-
console.error(`[local-server] Fatal: ${e.message}`);
|
|
792
|
-
process.exit(1);
|
|
793
|
-
});
|