@aeriondyseti/vector-memory-mcp 2.4.4-dev.1 → 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,10 +1,12 @@
1
1
  import { randomUUID, createHash } from "crypto";
2
+ import { basename } from "path";
2
3
  import type { Memory, SearchIntent, IntentProfile, HybridRow } from "./memory";
3
4
  import { isDeleted, computeConfidence } from "./memory";
4
- import type { SearchResult, SearchOptions } from "./conversation";
5
+ import type { SearchResult, SearchOptions, HistoryFilters } from "./conversation";
5
6
  import type { MemoryRepository } from "./memory.repository";
6
7
  import type { EmbeddingsService } from "./embeddings.service";
7
8
  import type { ConversationHistoryService } from "./conversation.service";
9
+ import { normalizeProject } from "./project";
8
10
 
9
11
  // Jitter values halved from original (0.02/0.05/0.15) because RRF_K=10 produces
10
12
  // ~6x more score spread than K=60, amplifying jitter's disruption effect.
@@ -18,14 +20,23 @@ const INTENT_PROFILES: Record<SearchIntent, IntentProfile> = {
18
20
 
19
21
  const sigmoid = (x: number): number => 1 / (1 + Math.exp(-x));
20
22
 
23
+ // Modest same-project ranking boost for scope:"all" searches — same-repo
24
+ // memories win ties without hiding cross-project results.
25
+ const CURRENT_PROJECT_BOOST = 1.15;
26
+
21
27
  export class MemoryService {
22
28
  private conversationService: ConversationHistoryService | null = null;
23
29
 
24
30
  constructor(
25
31
  private repository: MemoryRepository,
26
- private embeddings: EmbeddingsService
32
+ private embeddings: EmbeddingsService,
33
+ private project: string | null = null
27
34
  ) {}
28
35
 
36
+ getProject(): string | null {
37
+ return this.project;
38
+ }
39
+
29
40
  setConversationService(service: ConversationHistoryService): void {
30
41
  this.conversationService = service;
31
42
  }
@@ -45,7 +56,8 @@ export class MemoryService {
45
56
  async store(
46
57
  content: string,
47
58
  metadata: Record<string, unknown> = {},
48
- embeddingText?: string
59
+ embeddingText?: string,
60
+ project?: string
49
61
  ): Promise<Memory> {
50
62
  const id = randomUUID();
51
63
  const now = new Date();
@@ -63,6 +75,7 @@ export class MemoryService {
63
75
  usefulness: 0,
64
76
  accessCount: 0,
65
77
  lastAccessed: now, // Initialize to createdAt for fair discovery
78
+ project: project !== undefined ? normalizeProject(project) : this.project,
66
79
  };
67
80
 
68
81
  await this.repository.insert(memory);
@@ -200,11 +213,51 @@ export class MemoryService {
200
213
  // Widen the candidate pool to account for offset
201
214
  const effectiveLimit = offset + limit;
202
215
 
216
+ // Resolve project scope: "all" = no filter (with same-project ranking
217
+ // boost), "project" = current project, anything else = explicit path.
218
+ const scope = options?.scope ?? "all";
219
+ const projectFilter: string | undefined =
220
+ scope === "all"
221
+ ? undefined
222
+ : scope === "project"
223
+ ? (this.project ?? undefined)
224
+ : normalizeProject(scope);
225
+
226
+ const hasDateFilters = options?.after || options?.before;
227
+ const memoryFilters =
228
+ hasDateFilters || projectFilter !== undefined
229
+ ? {
230
+ after: options?.after,
231
+ before: options?.before,
232
+ project: projectFilter,
233
+ }
234
+ : undefined;
235
+
236
+ // Merge top-level date filters into history filters so after/before
237
+ // apply uniformly. Explicit history_after/history_before take precedence,
238
+ // as does an explicit historyFilters.project.
239
+ const historyFilters = options?.historyFilters;
240
+ const effectiveHistoryFilters: HistoryFilters | undefined =
241
+ hasDateFilters || projectFilter !== undefined || historyFilters
242
+ ? {
243
+ ...historyFilters,
244
+ after: historyFilters?.after ?? options?.after,
245
+ before: historyFilters?.before ?? options?.before,
246
+ project: historyFilters?.project ?? projectFilter,
247
+ }
248
+ : historyFilters;
249
+
250
+ // Same-project boost only applies to unscoped searches
251
+ const boost = (resultProject: string | null): number =>
252
+ scope === "all" && this.project && resultProject === this.project
253
+ ? CURRENT_PROJECT_BOOST
254
+ : 1;
255
+
203
256
  // Run memory + history queries in parallel
204
257
  const memoryPromise =
205
258
  !historyOnly
206
259
  ? this.repository
207
- .findHybrid(queryEmbedding, query, effectiveLimit * 5)
260
+ .findHybrid(queryEmbedding, query, effectiveLimit * 5, memoryFilters)
208
261
  .then((candidates) =>
209
262
  candidates
210
263
  .filter((m) => includeDeleted || !isDeleted(m))
@@ -215,8 +268,11 @@ export class MemoryService {
215
268
  createdAt: candidate.createdAt,
216
269
  updatedAt: candidate.updatedAt,
217
270
  source: "memory" as const,
218
- score: this.computeMemoryScore(candidate, profile, now),
271
+ score:
272
+ this.computeMemoryScore(candidate, profile, now) *
273
+ boost(candidate.project),
219
274
  confidence: computeConfidence(candidate.signals),
275
+ project: candidate.project,
220
276
  supersededBy: candidate.supersededBy,
221
277
  usefulness: candidate.usefulness,
222
278
  accessCount: candidate.accessCount,
@@ -232,24 +288,28 @@ export class MemoryService {
232
288
  query,
233
289
  queryEmbedding,
234
290
  historyOnly ? effectiveLimit * 5 : effectiveLimit * 3,
235
- options?.historyFilters
291
+ effectiveHistoryFilters
236
292
  )
237
293
  .then((historyRows) =>
238
- historyRows.map((row) => ({
239
- id: row.id,
240
- content: row.content,
241
- metadata: row.metadata,
242
- createdAt: row.createdAt,
243
- updatedAt: row.createdAt,
244
- source: "conversation_history" as const,
245
- score: row.rrfScore * historyWeight,
246
- confidence: computeConfidence(row.signals),
247
- supersededBy: null,
248
- sessionId: (row.metadata?.session_id as string) ?? "",
249
- role: (row.metadata?.role as string) ?? "unknown",
250
- messageIndexStart: (row.metadata?.message_index_start as number) ?? 0,
251
- messageIndexEnd: (row.metadata?.message_index_end as number) ?? 0,
252
- }))
294
+ historyRows.map((row) => {
295
+ const rowProject = (row.metadata?.project as string) ?? null;
296
+ return {
297
+ id: row.id,
298
+ content: row.content,
299
+ metadata: row.metadata,
300
+ createdAt: row.createdAt,
301
+ updatedAt: row.createdAt,
302
+ source: "conversation_history" as const,
303
+ score: row.rrfScore * historyWeight * boost(rowProject),
304
+ confidence: computeConfidence(row.signals),
305
+ project: rowProject,
306
+ supersededBy: null,
307
+ sessionId: (row.metadata?.session_id as string) ?? "",
308
+ role: (row.metadata?.role as string) ?? "unknown",
309
+ messageIndexStart: (row.metadata?.message_index_start as number) ?? 0,
310
+ messageIndexEnd: (row.metadata?.message_index_end as number) ?? 0,
311
+ };
312
+ })
253
313
  )
254
314
  : Promise.resolve([] as SearchResult[]);
255
315
 
@@ -294,8 +354,17 @@ export class MemoryService {
294
354
  ].join("-");
295
355
  }
296
356
 
357
+ /**
358
+ * Resolve a caller-supplied project (possibly a legacy display name or
359
+ * relative value) or fall back to the server's configured project.
360
+ */
361
+ private resolveProject(project?: string): string | undefined {
362
+ if (project && project.trim().length > 0) return normalizeProject(project);
363
+ return this.project ?? undefined;
364
+ }
365
+
297
366
  async setWaypoint(args: {
298
- project: string;
367
+ project?: string;
299
368
  branch?: string;
300
369
  summary: string;
301
370
  completed?: string[];
@@ -310,6 +379,7 @@ export class MemoryService {
310
379
  await this.trackAccess(args.memory_ids);
311
380
  }
312
381
 
382
+ const project = this.resolveProject(args.project);
313
383
  const now = new Date();
314
384
  const date = now.toISOString().slice(0, 10);
315
385
  const time = now.toISOString().slice(11, 16);
@@ -321,7 +391,7 @@ export class MemoryService {
321
391
  return items.map((i) => `- ${i}`).join("\n");
322
392
  };
323
393
 
324
- const content = `# Waypoint - ${args.project}
394
+ const content = `# Waypoint - ${project ?? "unknown project"}
325
395
  **Date:** ${date} ${time} | **Branch:** ${args.branch ?? "unknown"}
326
396
 
327
397
  ## Summary
@@ -345,14 +415,14 @@ ${list(args.memory_ids)}`;
345
415
  const metadata: Record<string, unknown> = {
346
416
  ...(args.metadata ?? {}),
347
417
  type: "waypoint",
348
- project: args.project,
418
+ project: project ?? null,
349
419
  date,
350
420
  branch: args.branch ?? "unknown",
351
421
  memory_ids: args.memory_ids ?? [],
352
422
  };
353
423
 
354
424
  const memory: Memory = {
355
- id: MemoryService.waypointId(args.project),
425
+ id: MemoryService.waypointId(project),
356
426
  content,
357
427
  embedding: new Array(this.embeddings.dimension).fill(0),
358
428
  metadata,
@@ -362,35 +432,83 @@ ${list(args.memory_ids)}`;
362
432
  usefulness: 0,
363
433
  accessCount: 0,
364
434
  lastAccessed: now, // Initialize to now for consistency
435
+ project: project ?? null,
365
436
  };
366
437
 
438
+ // NOTE: deliberately no UUID_ZERO "global latest" copy — in a shared
439
+ // database that becomes last-writer-wins across projects. Readers that
440
+ // don't know their project resolve it from cwd instead.
367
441
  await this.repository.upsert(memory);
368
442
 
369
- // Always update the global (no-project) waypoint so the session-start
370
- // hook can find the most recent waypoint without knowing the project name.
371
- const globalId = MemoryService.UUID_ZERO;
372
- if (memory.id !== globalId) {
373
- await this.repository.upsert({ ...memory, id: globalId });
374
- }
375
-
376
443
  return memory;
377
444
  }
378
445
 
446
+ /**
447
+ * Find the latest waypoint for a project, trying legacy ID schemes in
448
+ * order and migrating hits to the canonical ID:
449
+ * 1. canonical: waypointId(normalized absolute path)
450
+ * 2. legacy skill-supplied display name: waypointId(basename)
451
+ * 3. legacy UUID-formatted IDs for both of the above
452
+ * 4. UUID_ZERO "global latest" — only when its metadata.project matches,
453
+ * so one project's pre-migration waypoint never leaks into another
454
+ */
379
455
  async getLatestWaypoint(project?: string): Promise<Memory | null> {
380
- const waypoint = await this.get(MemoryService.waypointId(project));
381
- if (waypoint) return waypoint;
456
+ const resolved = this.resolveProject(project);
457
+ const canonicalId = MemoryService.waypointId(resolved);
458
+
459
+ const waypoint = await this.get(canonicalId);
460
+ if (waypoint && !isDeleted(waypoint)) return waypoint;
461
+
462
+ const candidateIds: string[] = [];
463
+ if (resolved) {
464
+ const display = basename(resolved);
465
+ candidateIds.push(MemoryService.waypointId(display));
466
+ const legacyPath = MemoryService.legacyWaypointId(resolved);
467
+ if (legacyPath) candidateIds.push(legacyPath);
468
+ const legacyDisplay = MemoryService.legacyWaypointId(display);
469
+ if (legacyDisplay) candidateIds.push(legacyDisplay);
470
+ } else {
471
+ const legacyId = MemoryService.legacyWaypointId(resolved);
472
+ if (legacyId) candidateIds.push(legacyId);
473
+ }
382
474
 
383
- // Fallback: try legacy UUID-formatted waypoint ID and migrate on read
384
- const legacyId = MemoryService.legacyWaypointId(project);
385
- if (!legacyId) return null;
475
+ for (const id of candidateIds) {
476
+ if (id === canonicalId) continue;
477
+ const legacy = await this.repository.findById(id);
478
+ if (!legacy || isDeleted(legacy)) continue;
479
+
480
+ // Migrate: write under canonical ID, delete old
481
+ await this.repository.upsert({
482
+ ...legacy,
483
+ id: canonicalId,
484
+ project: resolved ?? legacy.project,
485
+ });
486
+ await this.repository.markDeleted(id);
487
+ return { ...legacy, id: canonicalId, project: resolved ?? legacy.project };
488
+ }
386
489
 
387
- const legacy = await this.repository.findById(legacyId);
388
- if (!legacy) return null;
490
+ // Last resort: the pre-migration UUID_ZERO copy, guarded by project match
491
+ if (resolved && canonicalId !== MemoryService.UUID_ZERO) {
492
+ const global = await this.repository.findById(MemoryService.UUID_ZERO);
493
+ if (global && !isDeleted(global)) {
494
+ const metaProject = (global.metadata.project as string | undefined) ?? "";
495
+ const matches =
496
+ metaProject.length > 0 &&
497
+ (normalizeProject(metaProject) === resolved ||
498
+ metaProject.trim().toLowerCase() ===
499
+ basename(resolved).toLowerCase());
500
+ if (matches) {
501
+ await this.repository.upsert({
502
+ ...global,
503
+ id: canonicalId,
504
+ project: resolved,
505
+ });
506
+ await this.repository.markDeleted(MemoryService.UUID_ZERO);
507
+ return { ...global, id: canonicalId, project: resolved };
508
+ }
509
+ }
510
+ }
389
511
 
390
- // Migrate: write under new ID, delete old
391
- const newId = MemoryService.waypointId(project);
392
- await this.repository.upsert({ ...legacy, id: newId });
393
- await this.repository.markDeleted(legacyId);
394
- return { ...legacy, id: newId };
512
+ return null;
395
513
  }
396
514
  }
@@ -11,6 +11,8 @@ export interface Memory {
11
11
  usefulness: number;
12
12
  accessCount: number;
13
13
  lastAccessed: Date | null;
14
+ /** Canonical project path this memory belongs to (null = untagged/legacy). */
15
+ project: string | null;
14
16
  }
15
17
 
16
18
  export function isDeleted(memory: Memory): boolean {
@@ -28,6 +30,7 @@ export function memoryToDict(memory: Memory): Record<string, unknown> {
28
30
  usefulness: memory.usefulness,
29
31
  accessCount: memory.accessCount,
30
32
  lastAccessed: memory.lastAccessed?.toISOString() ?? null,
33
+ project: memory.project,
31
34
  };
32
35
  }
33
36
 
@@ -1,5 +1,8 @@
1
1
  import type { Database } from "bun:sqlite";
2
+ import { readFile } from "fs/promises";
3
+ import { dirname, join } from "path";
2
4
  import type { EmbeddingsService } from "./embeddings.service";
5
+ import { normalizeProject } from "./project";
3
6
  import { serializeVector } from "./sqlite-utils";
4
7
 
5
8
  /**
@@ -62,7 +65,8 @@ export function runMigrations(db: Database): void {
62
65
  superseded_by TEXT,
63
66
  usefulness REAL NOT NULL DEFAULT 0.0,
64
67
  access_count INTEGER NOT NULL DEFAULT 0,
65
- last_accessed INTEGER
68
+ last_accessed INTEGER,
69
+ project TEXT
66
70
  )
67
71
  `);
68
72
 
@@ -109,11 +113,185 @@ export function runMigrations(db: Database): void {
109
113
  )
110
114
  `);
111
115
 
116
+ // -- Conversation index state (replaces conversation_index_state.json) --
117
+ db.exec(`
118
+ CREATE TABLE IF NOT EXISTS conversation_index_state (
119
+ session_id TEXT PRIMARY KEY,
120
+ file_path TEXT NOT NULL,
121
+ project TEXT NOT NULL,
122
+ last_modified INTEGER NOT NULL,
123
+ chunk_count INTEGER NOT NULL,
124
+ message_count INTEGER NOT NULL,
125
+ indexed_at INTEGER NOT NULL,
126
+ first_message_at INTEGER NOT NULL,
127
+ last_message_at INTEGER NOT NULL
128
+ )
129
+ `);
130
+
131
+ // -- Versioned migrations (non-idempotent schema changes) --
132
+ runVersionedMigrations(db);
133
+
112
134
  // -- Indexes --
113
135
  db.exec(`CREATE INDEX IF NOT EXISTS idx_conversation_session_id ON conversation_history(session_id)`);
114
136
  db.exec(`CREATE INDEX IF NOT EXISTS idx_conversation_project ON conversation_history(project)`);
115
137
  db.exec(`CREATE INDEX IF NOT EXISTS idx_conversation_role ON conversation_history(role)`);
116
138
  db.exec(`CREATE INDEX IF NOT EXISTS idx_conversation_created_at ON conversation_history(created_at)`);
139
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_created_at ON memories(created_at)`);
140
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project)`);
141
+ }
142
+
143
+ /** Current schema version. Bump when adding a versioned migration below. */
144
+ const SCHEMA_VERSION = 1;
145
+
146
+ function getUserVersion(db: Database): number {
147
+ const row = db.prepare("PRAGMA user_version").get() as
148
+ | { user_version: number }
149
+ | null;
150
+ return row?.user_version ?? 0;
151
+ }
152
+
153
+ /**
154
+ * Non-idempotent migrations (e.g. ALTER TABLE) gated by PRAGMA user_version.
155
+ *
156
+ * Concurrency-safe for multiple processes opening the same database: the
157
+ * version is re-checked inside BEGIN IMMEDIATE, so the loser of a startup
158
+ * race blocks on busy_timeout, then sees the bumped version and no-ops.
159
+ */
160
+ function runVersionedMigrations(db: Database): void {
161
+ if (getUserVersion(db) >= SCHEMA_VERSION) return;
162
+
163
+ db.exec("BEGIN IMMEDIATE");
164
+ try {
165
+ const version = getUserVersion(db);
166
+
167
+ if (version < 1) {
168
+ // v1: project column on memories (fresh databases get it via CREATE
169
+ // TABLE above; pre-existing databases need the ALTER).
170
+ const columns = db
171
+ .prepare("PRAGMA table_info(memories)")
172
+ .all() as Array<{ name: string }>;
173
+ if (!columns.some((c) => c.name === "project")) {
174
+ db.exec("ALTER TABLE memories ADD COLUMN project TEXT");
175
+ }
176
+
177
+ // Backfill from metadata where a project was recorded (waypoints).
178
+ // Values are stored raw — they may be legacy display names rather than
179
+ // canonical paths; consolidation re-stamps them with the real project.
180
+ db.exec(`
181
+ UPDATE memories
182
+ SET project = json_extract(metadata, '$.project')
183
+ WHERE project IS NULL
184
+ AND json_extract(metadata, '$.project') IS NOT NULL
185
+ `);
186
+
187
+ db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
188
+ }
189
+
190
+ db.exec("COMMIT");
191
+ } catch (e) {
192
+ db.exec("ROLLBACK");
193
+ throw e;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Repair legacy `conversation_history.project` values.
199
+ *
200
+ * Rows indexed before the cwd-based parser carry lossy dash-decoded project
201
+ * values (no leading slash; any dash in a directory name decoded as "/").
202
+ * This re-derives the true project from each session file's `cwd` field.
203
+ * Rows whose session file is gone get a best-effort "/" prefix so they gain
204
+ * the canonical-path invariant and are not re-scanned on every startup.
205
+ *
206
+ * Identifies legacy rows by the missing leading slash, so it converges to a
207
+ * no-op once all rows are repaired.
208
+ */
209
+ export async function repairConversationProjects(
210
+ db: Database,
211
+ dbPath: string,
212
+ ): Promise<void> {
213
+ const legacy = db
214
+ .prepare(
215
+ "SELECT DISTINCT session_id FROM conversation_history WHERE project NOT LIKE '/%'",
216
+ )
217
+ .all() as Array<{ session_id: string }>;
218
+
219
+ if (legacy.length === 0) return;
220
+
221
+ // session_id -> file_path, from the index state table or the legacy JSON
222
+ const filePaths = new Map<string, string>();
223
+ const stateRows = db
224
+ .prepare("SELECT session_id, file_path FROM conversation_index_state")
225
+ .all() as Array<{ session_id: string; file_path: string }>;
226
+ for (const row of stateRows) filePaths.set(row.session_id, row.file_path);
227
+
228
+ if (filePaths.size === 0) {
229
+ try {
230
+ const raw = await readFile(
231
+ join(dirname(dbPath), "conversation_index_state.json"),
232
+ "utf-8",
233
+ );
234
+ const entries = JSON.parse(raw) as Array<{
235
+ sessionId: string;
236
+ filePath: string;
237
+ }>;
238
+ for (const e of entries) filePaths.set(e.sessionId, e.filePath);
239
+ } catch {
240
+ // No legacy state file — fall through to best-effort repair
241
+ }
242
+ }
243
+
244
+ console.error(
245
+ `[vector-memory-mcp] Repairing project values for ${legacy.length} legacy sessions...`,
246
+ );
247
+
248
+ const updateExact = db.prepare(`
249
+ UPDATE conversation_history
250
+ SET project = ?, metadata = json_set(metadata, '$.project', ?)
251
+ WHERE session_id = ?
252
+ `);
253
+ const updateBestEffort = db.prepare(`
254
+ UPDATE conversation_history
255
+ SET project = '/' || project
256
+ WHERE session_id = ? AND project NOT LIKE '/%'
257
+ `);
258
+ const updateState = db.prepare(
259
+ "UPDATE conversation_index_state SET project = ? WHERE session_id = ?",
260
+ );
261
+
262
+ for (const { session_id } of legacy) {
263
+ const filePath = filePaths.get(session_id);
264
+ const cwd = filePath ? await readSessionCwd(filePath) : null;
265
+ if (cwd) {
266
+ const project = normalizeProject(cwd);
267
+ updateExact.run(project, project, session_id);
268
+ updateState.run(project, session_id);
269
+ } else {
270
+ updateBestEffort.run(session_id);
271
+ }
272
+ }
273
+ }
274
+
275
+ /** Read the first `cwd` value from a Claude Code session JSONL file. */
276
+ async function readSessionCwd(filePath: string): Promise<string | null> {
277
+ let content: string;
278
+ try {
279
+ content = await readFile(filePath, "utf-8");
280
+ } catch {
281
+ return null;
282
+ }
283
+ for (const line of content.split("\n")) {
284
+ if (!line.includes('"cwd"')) continue;
285
+ try {
286
+ const entry = JSON.parse(line) as { cwd?: unknown };
287
+ if (typeof entry.cwd === "string" && entry.cwd.length > 0) {
288
+ return entry.cwd;
289
+ }
290
+ } catch {
291
+ // malformed line — keep scanning
292
+ }
293
+ }
294
+ return null;
117
295
  }
118
296
 
119
297
  /**
@@ -215,27 +393,30 @@ export async function backfillVectors(
215
393
  "INSERT OR REPLACE INTO conversation_history_vec (id, vector) VALUES (?, ?)",
216
394
  );
217
395
 
218
- // Batch embed in chunks of 32
396
+ // Batch embed in chunks of 32. Embedding happens OUTSIDE the write
397
+ // transaction and each batch commits separately — holding the write lock
398
+ // across model inference would block every other process sharing the db.
219
399
  const BATCH_SIZE = 32;
220
- db.exec("BEGIN");
221
- try {
222
- for (let i = 0; i < missingConvos.length; i += BATCH_SIZE) {
223
- const batch = missingConvos.slice(i, i + BATCH_SIZE);
224
- const vecs = await embeddings.embedBatch(batch.map((r) => r.content));
400
+ for (let i = 0; i < missingConvos.length; i += BATCH_SIZE) {
401
+ const batch = missingConvos.slice(i, i + BATCH_SIZE);
402
+ const vecs = await embeddings.embedBatch(batch.map((r) => r.content));
403
+
404
+ db.exec("BEGIN");
405
+ try {
225
406
  for (let j = 0; j < batch.length; j++) {
226
407
  insertConvoVec.run(batch[j].id, serializeVector(vecs[j]));
227
408
  }
409
+ db.exec("COMMIT");
410
+ } catch (e) {
411
+ db.exec("ROLLBACK");
412
+ throw e;
413
+ }
228
414
 
229
- if ((i + BATCH_SIZE) % 100 < BATCH_SIZE) {
230
- console.error(
231
- `[vector-memory-mcp] ...${Math.min(i + BATCH_SIZE, missingConvos.length)}/${missingConvos.length} conversation chunks`,
232
- );
233
- }
415
+ if ((i + BATCH_SIZE) % 100 < BATCH_SIZE) {
416
+ console.error(
417
+ `[vector-memory-mcp] ...${Math.min(i + BATCH_SIZE, missingConvos.length)}/${missingConvos.length} conversation chunks`,
418
+ );
234
419
  }
235
- db.exec("COMMIT");
236
- } catch (e) {
237
- db.exec("ROLLBACK");
238
- throw e;
239
420
  }
240
421
 
241
422
  console.error(
@@ -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
+ }