@aeriondyseti/vector-memory-mcp 2.4.4 → 2.5.0-dev.1

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.
@@ -1,6 +1,7 @@
1
1
  import { readFile, readdir, stat } from "fs/promises";
2
2
  import { basename, dirname, join } from "path";
3
3
  import type { ParsedMessage, SessionFileInfo } from "../conversation";
4
+ import { normalizeProject } from "../project";
4
5
  import type { SessionLogParser } from "./types";
5
6
 
6
7
  // UUID pattern for session IDs
@@ -18,16 +19,19 @@ function extractAssistantText(
18
19
  }
19
20
 
20
21
  /**
21
- * Extract project name from path-encoded directory name.
22
+ * Fallback project derivation from a path-encoded directory name.
22
23
  * Claude Code encodes paths by replacing `/` with `-`, e.g. `/home/user/project` → `-home-user-project`.
23
24
  * This is a lossy encoding: directory names containing literal dashes (e.g. `my-project`)
24
25
  * cannot be distinguished from path separators, so `my-project` decodes as `my/project`.
25
- * This is a known limitation of Claude Code's encoding scheme.
26
+ *
27
+ * Only used when a session file carries no `cwd` field — the authoritative
28
+ * project source is the `cwd` recorded on each message (see parse()).
26
29
  */
27
30
  function extractProjectFromDir(dirName: string): string {
28
- return dirName.startsWith("-")
31
+ const decoded = dirName.startsWith("-")
29
32
  ? dirName.slice(1).replace(/-/g, "/")
30
33
  : dirName;
34
+ return normalizeProject(decoded);
31
35
  }
32
36
 
33
37
  export class ClaudeCodeSessionParser implements SessionLogParser {
@@ -53,7 +57,10 @@ export class ClaudeCodeSessionParser implements SessionLogParser {
53
57
  ? basename(dirname(dirname(dirname(filePath))))
54
58
  : parentDir;
55
59
 
56
- const project = extractProjectFromDir(projectDir);
60
+ // Project identity: the `cwd` recorded on session entries is the true
61
+ // absolute path. The dash-encoded directory name is a lossy fallback.
62
+ let project = extractProjectFromDir(projectDir);
63
+ let projectFromCwd = false;
57
64
 
58
65
  for (const line of lines) {
59
66
  let entry: Record<string, unknown>;
@@ -64,6 +71,13 @@ export class ClaudeCodeSessionParser implements SessionLogParser {
64
71
  continue;
65
72
  }
66
73
 
74
+ if (!projectFromCwd && typeof entry.cwd === "string" && entry.cwd.length > 0) {
75
+ project = normalizeProject(entry.cwd as string);
76
+ projectFromCwd = true;
77
+ // Retroactively fix messages parsed before the first cwd appeared
78
+ for (const m of messages) m.project = project;
79
+ }
80
+
67
81
  const type = entry.type as string;
68
82
 
69
83
  // Skip non-message entries
@@ -0,0 +1,25 @@
1
+ import { basename } from "path";
2
+
3
+ /**
4
+ * Canonical project identifier: the normalized absolute path of the project
5
+ * root (e.g. `/home/user/Development/my-repo`).
6
+ *
7
+ * This exact function must be used everywhere a project value is produced or
8
+ * compared — memory stamping, waypoint ID hashing, search filters, the
9
+ * consolidation re-key, and the hooks' `?project=` param — so values join
10
+ * byte-for-byte across subsystems.
11
+ */
12
+ export function normalizeProject(value: string): string {
13
+ let p = value.trim();
14
+ if (p.length === 0) return "";
15
+ // Collapse trailing slashes (but keep bare root "/")
16
+ p = p.replace(/\/+$/, "");
17
+ if (p.length === 0) return "/";
18
+ if (!p.startsWith("/")) p = `/${p}`;
19
+ return p;
20
+ }
21
+
22
+ /** Short human-readable name for a project path. */
23
+ export function projectDisplayName(project: string): string {
24
+ return basename(project) || project;
25
+ }
@@ -1,7 +1,7 @@
1
1
  import type { Database } from "bun:sqlite";
2
2
 
3
- /** RRF constant matching the previous LanceDB reranker default */
4
- export const RRF_K = 60;
3
+ /** RRF constant lower K gives sharper top-rank discrimination in the 1/(K+rank) formula */
4
+ export const RRF_K = 10;
5
5
 
6
6
  /**
7
7
  * Maximum parameters per SQLite query to stay within SQLITE_MAX_VARIABLE_NUMBER.
@@ -53,6 +53,11 @@ export function cosineSimilarity(a: Float32Array, b: Float32Array): number {
53
53
  * Brute-force KNN search over a vector blob table.
54
54
  * Loads all vectors, computes cosine similarity, returns top-K results
55
55
  * sorted by descending similarity (ascending distance).
56
+ *
57
+ * `candidates` overrides the candidate query — used to pre-filter the scan
58
+ * (e.g. by project) so filtered searches rank within the filtered set instead
59
+ * of post-filtering a global top-K (which can return false-empty results).
60
+ * The SQL must select `id` and `vector` columns.
56
61
  */
57
62
  type VecTable = "memories_vec" | "conversation_history_vec";
58
63
 
@@ -61,10 +66,13 @@ export function knnSearch(
61
66
  table: VecTable,
62
67
  queryVec: number[],
63
68
  k: number,
69
+ candidates?: { sql: string; params: Array<string | number> },
64
70
  ): Array<{ id: string; distance: number }> {
65
- const rows = db
66
- .prepare(`SELECT id, vector FROM ${table}`)
67
- .all() as Array<{ id: string; vector: Buffer }>;
71
+ const rows = (
72
+ candidates
73
+ ? db.prepare(candidates.sql).all(...candidates.params)
74
+ : db.prepare(`SELECT id, vector FROM ${table}`).all()
75
+ ) as Array<{ id: string; vector: Buffer }>;
68
76
 
69
77
  const qv = new Float32Array(queryVec);
70
78
  const scored = rows.map((r) => {
@@ -116,6 +124,49 @@ export function hybridRRF(
116
124
  return scores;
117
125
  }
118
126
 
127
+ import type { SearchSignals } from "./memory";
128
+
129
+ /**
130
+ * Compute hybrid RRF scores while preserving per-result search signals
131
+ * (cosine similarity, FTS match, rank positions) for confidence scoring.
132
+ */
133
+ export function hybridRRFWithSignals(
134
+ vectorResults: Array<{ id: string; distance: number }>,
135
+ ftsResults: Array<{ id: string }>,
136
+ k: number = RRF_K
137
+ ): Map<string, SearchSignals & { rrfScore: number }> {
138
+ const knnMap = new Map<string, { similarity: number; rank: number }>();
139
+ vectorResults.forEach((r, i) => {
140
+ knnMap.set(r.id, { similarity: 1 - r.distance, rank: i + 1 });
141
+ });
142
+
143
+ const ftsMap = new Map<string, number>();
144
+ ftsResults.forEach((r, i) => {
145
+ ftsMap.set(r.id, i + 1);
146
+ });
147
+
148
+ const allIds = new Set([...knnMap.keys(), ...ftsMap.keys()]);
149
+ const results = new Map<string, SearchSignals & { rrfScore: number }>();
150
+
151
+ for (const id of allIds) {
152
+ const knn = knnMap.get(id);
153
+ const ftsRank = ftsMap.get(id) ?? null;
154
+ let rrfScore = 0;
155
+ if (knn) rrfScore += 1 / (k + knn.rank);
156
+ if (ftsRank !== null) rrfScore += 1 / (k + ftsRank);
157
+
158
+ results.set(id, {
159
+ rrfScore,
160
+ cosineSimilarity: knn?.similarity ?? null,
161
+ ftsMatch: ftsRank !== null,
162
+ knnRank: knn?.rank ?? null,
163
+ ftsRank,
164
+ });
165
+ }
166
+
167
+ return results;
168
+ }
169
+
119
170
  /**
120
171
  * Sort ids by RRF score descending and return top N.
121
172
  */
@@ -0,0 +1,77 @@
1
+ const PATTERN =
2
+ /^(?:past|last)\s+(\d+)\s+(minute|hour|day|week|month|year)s?$/i;
3
+
4
+ export function parseTimeExpr(
5
+ expr: string,
6
+ now: Date = new Date(),
7
+ ): Date | null {
8
+ const match = expr.trim().match(PATTERN);
9
+ if (!match) return null;
10
+
11
+ const n = parseInt(match[1], 10);
12
+ const unit = match[2].toLowerCase();
13
+ const result = new Date(now);
14
+
15
+ switch (unit) {
16
+ case "minute":
17
+ result.setMinutes(result.getMinutes() - n);
18
+ break;
19
+ case "hour":
20
+ result.setHours(result.getHours() - n);
21
+ break;
22
+ case "day":
23
+ result.setDate(result.getDate() - n);
24
+ break;
25
+ case "week":
26
+ result.setDate(result.getDate() - n * 7);
27
+ break;
28
+ case "month":
29
+ result.setMonth(result.getMonth() - n);
30
+ break;
31
+ case "year":
32
+ result.setFullYear(result.getFullYear() - n);
33
+ break;
34
+ }
35
+
36
+ return result;
37
+ }
38
+
39
+ export interface DateFilters {
40
+ after?: Date;
41
+ before?: Date;
42
+ }
43
+
44
+ export function resolveDateFilters(raw: {
45
+ after?: unknown;
46
+ before?: unknown;
47
+ time_expr?: unknown;
48
+ }): DateFilters {
49
+ let after: Date | undefined;
50
+ let before: Date | undefined;
51
+
52
+ if (raw.after !== undefined) {
53
+ after = new Date(raw.after as string);
54
+ if (isNaN(after.getTime())) throw new Error("'after' is not a valid date");
55
+ }
56
+ if (raw.before !== undefined) {
57
+ before = new Date(raw.before as string);
58
+ if (isNaN(before.getTime())) throw new Error("'before' is not a valid date");
59
+ }
60
+
61
+ if (!after && typeof raw.time_expr === "string") {
62
+ const resolved = parseTimeExpr(raw.time_expr);
63
+ if (!resolved) {
64
+ throw new Error(
65
+ `Unsupported time_expr format: "${raw.time_expr}". ` +
66
+ `Use "past N unit" or "last N unit" (e.g. "past 7 days", "last 2 weeks").`,
67
+ );
68
+ }
69
+ after = resolved;
70
+ }
71
+
72
+ if (after && before && after >= before) {
73
+ throw new Error("'after' date must be before 'before' date");
74
+ }
75
+
76
+ return { after, before };
77
+ }
package/server/index.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
+ import { existsSync } from "fs";
4
+ import { join } from "path";
5
+ import arg from "arg";
3
6
  import { loadConfig, parseCliArgs } from "./config/index";
4
7
  import { connectToDatabase } from "./core/connection";
5
- import { backfillVectors } from "./core/migrations";
8
+ import { backfillVectors, repairConversationProjects } from "./core/migrations";
6
9
  import { MemoryRepository } from "./core/memory.repository";
7
10
  import { ConversationRepository } from "./core/conversation.repository";
8
11
  import { EmbeddingsService } from "./core/embeddings.service";
@@ -21,18 +24,39 @@ async function main(): Promise<void> {
21
24
  return;
22
25
  }
23
26
 
27
+ // Consolidate repo-local databases into the global store
28
+ if (args[0] === "consolidate") {
29
+ await runConsolidate(args.slice(1));
30
+ return;
31
+ }
32
+
24
33
  // Parse CLI args and load config
25
34
  const overrides = parseCliArgs(args);
26
35
  const config = loadConfig(overrides);
27
36
 
37
+ // Nudge: a repo-local database exists but the global store is active —
38
+ // suggest consolidating it. Skipped when the user explicitly chose a db.
39
+ if (
40
+ !overrides.dbPath &&
41
+ !process.env.VECTOR_MEMORY_DB_PATH &&
42
+ existsSync(join(process.cwd(), ".vector-memory", "memories.db"))
43
+ ) {
44
+ console.error(
45
+ "[vector-memory-mcp] Found a repo-local .vector-memory/memories.db — " +
46
+ "memories now live in a global store (~/.vector-memory). " +
47
+ "Run `bunx @aeriondyseti/vector-memory-mcp consolidate` to import it."
48
+ );
49
+ }
50
+
28
51
  // Initialize database and backfill any missing vectors before services start
29
52
  const db = connectToDatabase(config.dbPath);
30
53
  const embeddings = new EmbeddingsService(config.embeddingModel, config.embeddingDimension);
31
54
  await backfillVectors(db, embeddings);
55
+ await repairConversationProjects(db, config.dbPath);
32
56
 
33
57
  // Initialize layers
34
58
  const repository = new MemoryRepository(db);
35
- const memoryService = new MemoryService(repository, embeddings);
59
+ const memoryService = new MemoryService(repository, embeddings, config.project);
36
60
 
37
61
  if (config.pluginMode) {
38
62
  console.error("[vector-memory-mcp] Running in plugin mode");
@@ -88,4 +112,70 @@ async function main(): Promise<void> {
88
112
  }
89
113
  }
90
114
 
115
+ async function runConsolidate(argv: string[]): Promise<void> {
116
+ const flags = arg(
117
+ {
118
+ "--recursive": Boolean,
119
+ "--dry-run": Boolean,
120
+ "--archive": Boolean,
121
+ "--force": Boolean,
122
+ "--db-file": String,
123
+ "-d": "--db-file",
124
+ "-r": "--recursive",
125
+ },
126
+ { argv, permissive: true }
127
+ );
128
+
129
+ const root = flags._.find((a) => !a.startsWith("-")) ?? process.cwd();
130
+ const config = loadConfig({ dbPath: flags["--db-file"] });
131
+
132
+ const { ConsolidationService } = await import("./core/consolidation.service");
133
+ const db = connectToDatabase(config.dbPath);
134
+ const embeddings = new EmbeddingsService(
135
+ config.embeddingModel,
136
+ config.embeddingDimension
137
+ );
138
+ const service = new ConsolidationService(db, config.dbPath, embeddings);
139
+
140
+ const summary = await service.consolidate({
141
+ root,
142
+ recursive: flags["--recursive"] ?? false,
143
+ dryRun: flags["--dry-run"] ?? false,
144
+ archive: flags["--archive"] ?? false,
145
+ force: flags["--force"] ?? false,
146
+ });
147
+
148
+ const log = console.error;
149
+ log(`\nConsolidation ${summary.dryRun ? "(dry run) " : ""}-> ${summary.targetDb}`);
150
+ if (summary.backupPath) log(`Backup: ${summary.backupPath}`);
151
+ log(`Import batch: ${summary.importBatch}`);
152
+
153
+ if (summary.sources.length === 0) {
154
+ log(`No repo-local .vector-memory/memories.db found under ${root}.`);
155
+ db.close();
156
+ return;
157
+ }
158
+
159
+ for (const s of summary.sources) {
160
+ log(`\n${s.sourceDb}`);
161
+ log(` project: ${s.project}`);
162
+ log(` memories: ${s.memoriesImported} imported, ${s.memoriesSkipped} skipped, ${s.memoriesRekeyed} re-keyed`);
163
+ log(` conversations: ${s.conversationsImported} imported, ${s.conversationsSkipped} skipped`);
164
+ log(` index state: ${s.indexStateImported} sessions`);
165
+ for (const [oldId, newId] of Object.entries(s.rekeyMap)) {
166
+ log(` re-key: ${oldId} -> ${newId}`);
167
+ }
168
+ for (const ref of s.unresolvedReferences) {
169
+ log(` unresolved reference: ${ref}`);
170
+ }
171
+ for (const err of s.errors) {
172
+ log(` ERROR: ${err}`);
173
+ }
174
+ }
175
+
176
+ const failed = summary.sources.some((s) => s.errors.length > 0);
177
+ db.close();
178
+ if (failed) process.exit(1);
179
+ }
180
+
91
181
  main().catch(console.error);
@@ -1,13 +1,18 @@
1
1
  import { Hono } from "hono";
2
2
  import { cors } from "hono/cors";
3
+ import { createHash } from "crypto";
3
4
  import { createServer } from "net";
4
- import { writeFileSync, mkdirSync, unlinkSync } from "fs";
5
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "fs";
6
+ import { homedir } from "os";
5
7
  import { join } from "path";
6
8
  import type { MemoryService } from "../../core/memory.service";
7
9
  import type { Config } from "../../config/index";
8
10
  import { isDeleted } from "../../core/memory";
9
11
  import { createMcpRoutes } from "./mcp-transport";
10
12
  import type { Memory, SearchIntent } from "../../core/memory";
13
+ import { resolveDateFilters } from "../../core/time-expr";
14
+
15
+ const VALID_INTENTS = new Set(["continuity", "fact_check", "frequent", "associative", "explore"]);
11
16
 
12
17
 
13
18
  /**
@@ -56,27 +61,54 @@ async function findAvailablePort(
56
61
  }
57
62
 
58
63
  /**
59
- * Write a lockfile so hooks can discover which port this server bound to.
60
- * Written atomically after the HTTP server successfully binds.
64
+ * Per-project lock path under the global data directory. Keyed by a hash of
65
+ * the canonical project path so hooks (which know their cwd) can compute the
66
+ * same path without any per-repo files. Must stay in sync with the copy in
67
+ * plugin/hooks/scripts/hooks-lib.ts.
61
68
  */
62
- function writeLockfile(port: number): void {
63
- const dir = join(process.cwd(), ".vector-memory");
64
- mkdirSync(dir, { recursive: true });
65
- writeFileSync(
66
- join(dir, "server.lock"),
67
- JSON.stringify({ port, pid: process.pid }),
68
- "utf8"
69
- );
69
+ export function globalLockPath(project: string): string {
70
+ const hash = createHash("sha256").update(project).digest("hex").slice(0, 16);
71
+ return join(homedir(), ".vector-memory", "locks", `${hash}.lock`);
72
+ }
73
+
74
+ function legacyLockPath(): string {
75
+ return join(process.cwd(), ".vector-memory", "server.lock");
76
+ }
77
+
78
+ /**
79
+ * Write lockfiles so hooks can discover which port this server bound to.
80
+ * Written after the HTTP server successfully binds.
81
+ *
82
+ * Writes the global per-project lock, plus the legacy per-repo lock when a
83
+ * `.vector-memory/` directory already exists in the repo (so pre-2.5 plugins
84
+ * keep working without us creating new per-repo litter). Legacy dual-write
85
+ * is temporary — remove after one stable release cycle.
86
+ */
87
+ function writeLockfiles(port: number, project: string): void {
88
+ const payload = JSON.stringify({ port, pid: process.pid, project });
89
+
90
+ const globalPath = globalLockPath(project);
91
+ mkdirSync(join(homedir(), ".vector-memory", "locks"), { recursive: true });
92
+ writeFileSync(globalPath, payload, "utf8");
93
+
94
+ if (existsSync(join(process.cwd(), ".vector-memory"))) {
95
+ writeFileSync(legacyLockPath(), payload, "utf8");
96
+ }
70
97
  }
71
98
 
72
99
  /**
73
- * Remove the lockfile on clean shutdown so stale files don't linger.
100
+ * Remove this process's lockfiles on clean shutdown. Only deletes a lock
101
+ * whose recorded pid is ours — another session in the same project may have
102
+ * written its own lock since.
74
103
  */
75
- export function removeLockfile(): void {
76
- try {
77
- unlinkSync(join(process.cwd(), ".vector-memory", "server.lock"));
78
- } catch {
79
- // already gone fine
104
+ export function removeLockfiles(project: string): void {
105
+ for (const path of [globalLockPath(project), legacyLockPath()]) {
106
+ try {
107
+ const { pid } = JSON.parse(readFileSync(path, "utf8"));
108
+ if (pid === process.pid) unlinkSync(path);
109
+ } catch {
110
+ // missing or unreadable — fine
111
+ }
80
112
  }
81
113
  }
82
114
 
@@ -132,14 +164,27 @@ export function createHttpApp(memoryService: MemoryService, config: Config): Hon
132
164
  try {
133
165
  const body = await c.req.json();
134
166
  const query = body.query;
135
- const intent = (body.intent as SearchIntent) ?? "fact_check";
136
- const limit = body.limit ?? 10;
137
-
138
167
  if (!query || typeof query !== "string") {
139
168
  return c.json({ error: "Missing or invalid 'query' field" }, 400);
140
169
  }
170
+ if (body.intent && !VALID_INTENTS.has(body.intent)) {
171
+ return c.json({ error: "Invalid 'intent' value" }, 400);
172
+ }
173
+ const intent = (body.intent as SearchIntent) ?? "fact_check";
174
+ const limit = Math.max(1, Math.min(1000, Math.floor(typeof body.limit === "number" ? body.limit : 10)));
141
175
 
142
- const results = await memoryService.search(query, intent, { limit });
176
+ let dateFilters: { after?: Date; before?: Date };
177
+ try {
178
+ dateFilters = resolveDateFilters({ after: body.after, before: body.before, time_expr: body.time_expr });
179
+ } catch (e) {
180
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
181
+ }
182
+
183
+ const results = await memoryService.search(query, intent, {
184
+ limit,
185
+ scope: typeof body.scope === "string" ? body.scope : undefined,
186
+ ...dateFilters,
187
+ });
143
188
 
144
189
  return c.json({
145
190
  results: results.map((r) => ({
@@ -147,6 +192,8 @@ export function createHttpApp(memoryService: MemoryService, config: Config): Hon
147
192
  content: r.content,
148
193
  metadata: r.metadata,
149
194
  source: r.source,
195
+ confidence: r.confidence,
196
+ project: r.project,
150
197
  createdAt: r.createdAt.toISOString(),
151
198
  })),
152
199
  count: results.length,
@@ -161,16 +208,21 @@ export function createHttpApp(memoryService: MemoryService, config: Config): Hon
161
208
  app.post("/store", async (c) => {
162
209
  try {
163
210
  const body = await c.req.json();
164
- const { content, metadata, embeddingText } = body;
211
+ const { content, embeddingText } = body;
165
212
 
166
213
  if (!content || typeof content !== "string") {
167
214
  return c.json({ error: "Missing or invalid 'content' field" }, 400);
168
215
  }
169
216
 
217
+ const metadata = typeof body.metadata === "object" && body.metadata !== null && !Array.isArray(body.metadata)
218
+ ? body.metadata as Record<string, unknown>
219
+ : {};
220
+
170
221
  const memory = await memoryService.store(
171
222
  content,
172
- metadata ?? {},
173
- embeddingText
223
+ metadata,
224
+ typeof embeddingText === "string" ? embeddingText : undefined,
225
+ typeof body.project === "string" ? body.project : undefined
174
226
  );
175
227
 
176
228
  return c.json({
@@ -239,16 +291,14 @@ export function createHttpApp(memoryService: MemoryService, config: Config): Hon
239
291
 
240
292
  const body = await c.req.json().catch(() => ({}));
241
293
  let since: Date | undefined;
242
- if (body.since) {
243
- since = new Date(body.since as string);
294
+ if (typeof body.since === "string") {
295
+ since = new Date(body.since);
244
296
  if (isNaN(since.getTime())) {
245
297
  return c.json({ error: "Invalid 'since' date format" }, 400);
246
298
  }
247
299
  }
248
- const result = await conversationService.indexConversations(
249
- body.path as string | undefined,
250
- since
251
- );
300
+ const path = typeof body.path === "string" ? body.path : undefined;
301
+ const result = await conversationService.indexConversations(path, since);
252
302
 
253
303
  return c.json(result);
254
304
  } catch (error) {
@@ -298,13 +348,13 @@ export async function startHttpServer(
298
348
  fetch: app.fetch,
299
349
  });
300
350
 
301
- writeLockfile(actualPort);
351
+ writeLockfiles(actualPort, config.project);
302
352
  console.error(
303
353
  `[vector-memory-mcp] HTTP server listening on http://${config.httpHost}:${actualPort}`
304
354
  );
305
355
 
306
356
  return {
307
- stop: () => { removeLockfile(); server.stop(); },
357
+ stop: () => { removeLockfiles(config.project); server.stop(); },
308
358
  port: actualPort,
309
359
  };
310
360
  }