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 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, fork } from "node:child_process";
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;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@context-vault/core",
3
- "version": "2.8.5",
3
+ "version": "2.8.6",
4
4
  "type": "module",
5
5
  "description": "Shared core: capture, index, retrieve, tools, and utilities for context-vault",
6
6
  "main": "src/index.js",
@@ -16,8 +16,8 @@ const VEC_WEIGHT = 0.6;
16
16
  */
17
17
  export function buildFtsQuery(query) {
18
18
  const words = query
19
- .split(/\s+/)
20
- .map((w) => w.replace(/[*"()\-:^~{}]/g, ""))
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.5",
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.5",
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"
@@ -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
- });