@aeriondyseti/vector-memory-mcp 2.2.2 → 2.2.6-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.
Files changed (32) hide show
  1. package/README.md +33 -13
  2. package/package.json +12 -12
  3. package/{src → server}/config/index.ts +10 -2
  4. package/{src/db → server/core}/connection.ts +10 -2
  5. package/{src/db → server/core}/conversation.repository.ts +1 -1
  6. package/{src/services → server/core}/conversation.service.ts +2 -2
  7. package/server/core/embeddings.service.ts +125 -0
  8. package/{src/db → server/core}/memory.repository.ts +5 -1
  9. package/{src/services → server/core}/memory.service.ts +20 -4
  10. package/server/core/migration.service.ts +882 -0
  11. package/server/core/migrations.ts +263 -0
  12. package/{src/services → server/core}/parsers/claude-code.parser.ts +1 -1
  13. package/{src/services → server/core}/parsers/types.ts +1 -1
  14. package/{src → server}/index.ts +16 -48
  15. package/{src → server/transports}/http/mcp-transport.ts +2 -2
  16. package/{src → server/transports}/http/server.ts +6 -4
  17. package/{src → server/transports}/mcp/handlers.ts +5 -5
  18. package/server/transports/mcp/resources.ts +20 -0
  19. package/{src → server/transports}/mcp/server.ts +14 -3
  20. package/server/utils/formatting.ts +143 -0
  21. package/scripts/lancedb-extract.ts +0 -116
  22. package/scripts/migrate-from-lancedb.ts +0 -56
  23. package/scripts/smoke-test.ts +0 -699
  24. package/scripts/test-runner.ts +0 -76
  25. package/scripts/warmup.ts +0 -72
  26. package/src/db/migrations.ts +0 -108
  27. package/src/migration.ts +0 -203
  28. package/src/services/embeddings.service.ts +0 -48
  29. /package/{src/types → server/core}/conversation.ts +0 -0
  30. /package/{src/types → server/core}/memory.ts +0 -0
  31. /package/{src/db → server/core}/sqlite-utils.ts +0 -0
  32. /package/{src → server/transports}/mcp/tools.ts +0 -0
@@ -0,0 +1,263 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import type { EmbeddingsService } from "./embeddings.service.js";
3
+ import { serializeVector } from "./sqlite-utils.js";
4
+
5
+ /**
6
+ * Pre-migration step: remove vec0 virtual table entries from sqlite_master
7
+ * and drop their shadow tables using the sqlite3 CLI.
8
+ *
9
+ * Must run BEFORE bun:sqlite opens the database because:
10
+ * - bun:sqlite cannot modify sqlite_master (no writable_schema support)
11
+ * - DROP TABLE on a virtual table requires the extension module to be loaded
12
+ * - SQLite 3.51+ has defensive mode on by default, requiring .dbconfig override
13
+ *
14
+ * Safe to call on any database — it's a no-op if there are no vec0 tables.
15
+ */
16
+ export function removeVec0Tables(dbPath: string): void {
17
+ const result = Bun.spawnSync({
18
+ cmd: ["sqlite3", dbPath],
19
+ stdin: new TextEncoder().encode(
20
+ [
21
+ ".dbconfig defensive off",
22
+ ".dbconfig writable_schema on",
23
+ // Drop shadow tables (regular tables, no extension needed)
24
+ "DROP TABLE IF EXISTS memories_vec_rowids;",
25
+ "DROP TABLE IF EXISTS memories_vec_chunks;",
26
+ "DROP TABLE IF EXISTS memories_vec_info;",
27
+ "DROP TABLE IF EXISTS memories_vec_vector_chunks00;",
28
+ "DROP TABLE IF EXISTS memories_vec_migration_tmp;",
29
+ "DROP TABLE IF EXISTS conversation_history_vec_rowids;",
30
+ "DROP TABLE IF EXISTS conversation_history_vec_chunks;",
31
+ "DROP TABLE IF EXISTS conversation_history_vec_info;",
32
+ "DROP TABLE IF EXISTS conversation_history_vec_vector_chunks00;",
33
+ "DROP TABLE IF EXISTS conversation_history_vec_migration_tmp;",
34
+ // Remove orphaned vec0 virtual table entries from schema
35
+ "DELETE FROM sqlite_master WHERE sql LIKE '%vec0%';",
36
+ ].join("\n"),
37
+ ),
38
+ });
39
+ if (result.exitCode !== 0) {
40
+ const stderr = result.stderr.toString().trim();
41
+ if (!stderr.includes("unable to open database")) {
42
+ throw new Error(`vec0 cleanup failed: ${stderr}`);
43
+ }
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Run all schema migrations. Safe to call on every startup (uses IF NOT EXISTS).
49
+ *
50
+ * IMPORTANT: Call removeVec0Tables(dbPath) before opening the database
51
+ * with bun:sqlite if the database may contain vec0 virtual tables.
52
+ */
53
+ export function runMigrations(db: Database): void {
54
+ // -- Memories --
55
+ db.exec(`
56
+ CREATE TABLE IF NOT EXISTS memories (
57
+ id TEXT PRIMARY KEY,
58
+ content TEXT NOT NULL,
59
+ metadata TEXT NOT NULL DEFAULT '{}',
60
+ created_at INTEGER NOT NULL,
61
+ updated_at INTEGER NOT NULL,
62
+ superseded_by TEXT,
63
+ usefulness REAL NOT NULL DEFAULT 0.0,
64
+ access_count INTEGER NOT NULL DEFAULT 0,
65
+ last_accessed INTEGER
66
+ )
67
+ `);
68
+
69
+ db.exec(`
70
+ CREATE TABLE IF NOT EXISTS memories_vec (
71
+ id TEXT PRIMARY KEY,
72
+ vector BLOB NOT NULL
73
+ )
74
+ `);
75
+
76
+ db.exec(`
77
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
78
+ id UNINDEXED,
79
+ content
80
+ )
81
+ `);
82
+
83
+ // -- Conversation History --
84
+ db.exec(`
85
+ CREATE TABLE IF NOT EXISTS conversation_history (
86
+ id TEXT PRIMARY KEY,
87
+ content TEXT NOT NULL,
88
+ metadata TEXT NOT NULL DEFAULT '{}',
89
+ created_at INTEGER NOT NULL,
90
+ session_id TEXT NOT NULL,
91
+ role TEXT NOT NULL,
92
+ message_index_start INTEGER NOT NULL,
93
+ message_index_end INTEGER NOT NULL,
94
+ project TEXT NOT NULL
95
+ )
96
+ `);
97
+
98
+ db.exec(`
99
+ CREATE TABLE IF NOT EXISTS conversation_history_vec (
100
+ id TEXT PRIMARY KEY,
101
+ vector BLOB NOT NULL
102
+ )
103
+ `);
104
+
105
+ db.exec(`
106
+ CREATE VIRTUAL TABLE IF NOT EXISTS conversation_history_fts USING fts5(
107
+ id UNINDEXED,
108
+ content
109
+ )
110
+ `);
111
+
112
+ // -- Indexes --
113
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_conversation_session_id ON conversation_history(session_id)`);
114
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_conversation_project ON conversation_history(project)`);
115
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_conversation_role ON conversation_history(role)`);
116
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_conversation_created_at ON conversation_history(created_at)`);
117
+ }
118
+
119
+ /**
120
+ * Backfill missing vectors in memories_vec and conversation_history_vec.
121
+ *
122
+ * After the vec0-to-BLOB migration, existing rows may lack vector embeddings.
123
+ * This re-embeds their content and inserts into the _vec tables.
124
+ * Idempotent: skips rows that already have vectors. Fast no-op when fully backfilled.
125
+ */
126
+ export async function backfillVectors(
127
+ db: Database,
128
+ embeddings: EmbeddingsService,
129
+ ): Promise<void> {
130
+ // Fast sentinel check: skip the LEFT JOIN queries entirely when backfill is done
131
+ const sentinel = db
132
+ .prepare("SELECT 1 FROM memories_vec LIMIT 1")
133
+ .get();
134
+ const memoriesExist = db.prepare("SELECT 1 FROM memories LIMIT 1").get();
135
+ const convosExist = db.prepare("SELECT 1 FROM conversation_history LIMIT 1").get();
136
+
137
+ // If vec tables have data and source tables have data, backfill is likely complete.
138
+ // Only run the expensive LEFT JOIN when there's reason to suspect gaps.
139
+ const convoSentinel = db
140
+ .prepare("SELECT 1 FROM conversation_history_vec LIMIT 1")
141
+ .get();
142
+ const mayNeedMemoryBackfill = memoriesExist && !sentinel;
143
+ const mayNeedConvoBackfill = convosExist && !convoSentinel;
144
+
145
+ // If both vec tables are populated, do a quick count check to confirm
146
+ if (!mayNeedMemoryBackfill && !mayNeedConvoBackfill) {
147
+ if (memoriesExist) {
148
+ const gap = db.prepare(
149
+ `SELECT 1 FROM memories m LEFT JOIN memories_vec v ON m.id = v.id
150
+ WHERE v.id IS NULL OR length(v.vector) = 0 LIMIT 1`,
151
+ ).get();
152
+ if (!gap && convosExist) {
153
+ const convoGap = db.prepare(
154
+ `SELECT 1 FROM conversation_history c LEFT JOIN conversation_history_vec v ON c.id = v.id
155
+ WHERE v.id IS NULL OR length(v.vector) = 0 LIMIT 1`,
156
+ ).get();
157
+ if (!convoGap) return;
158
+ } else if (!gap && !convosExist) {
159
+ return;
160
+ }
161
+ } else {
162
+ return; // No data at all
163
+ }
164
+ }
165
+
166
+ // ── Memories ──────────────────────────────────────────────────────
167
+ const missingMemories = db
168
+ .prepare(
169
+ `SELECT m.id, m.content, json_extract(m.metadata, '$.type') AS type
170
+ FROM memories m
171
+ LEFT JOIN memories_vec v ON m.id = v.id
172
+ WHERE v.id IS NULL OR length(v.vector) = 0`,
173
+ )
174
+ .all() as Array<{ id: string; content: string; type: string | null }>;
175
+
176
+ if (missingMemories.length > 0) {
177
+ console.error(
178
+ `[vector-memory-mcp] Backfilling vectors for ${missingMemories.length} memories...`,
179
+ );
180
+
181
+ const insertVec = db.prepare(
182
+ "INSERT OR REPLACE INTO memories_vec (id, vector) VALUES (?, ?)",
183
+ );
184
+
185
+ const zeroVector = serializeVector(
186
+ new Array(embeddings.dimension).fill(0),
187
+ );
188
+
189
+ // Separate waypoints from content that needs embedding
190
+ const toEmbed = missingMemories.filter((r) => r.type !== "waypoint");
191
+ const waypoints = missingMemories.filter((r) => r.type === "waypoint");
192
+
193
+ // Batch embed all non-waypoint content
194
+ const vectors = toEmbed.length > 0
195
+ ? await embeddings.embedBatch(toEmbed.map((r) => r.content))
196
+ : [];
197
+
198
+ db.exec("BEGIN");
199
+ try {
200
+ for (const row of waypoints) {
201
+ insertVec.run(row.id, zeroVector);
202
+ }
203
+ for (let i = 0; i < toEmbed.length; i++) {
204
+ insertVec.run(toEmbed[i].id, serializeVector(vectors[i]));
205
+ }
206
+ db.exec("COMMIT");
207
+ } catch (e) {
208
+ db.exec("ROLLBACK");
209
+ throw e;
210
+ }
211
+
212
+ console.error(
213
+ `[vector-memory-mcp] Backfilled ${missingMemories.length} memory vectors`,
214
+ );
215
+ }
216
+
217
+ // ── Conversation history ──────────────────────────────────────────
218
+ const missingConvos = db
219
+ .prepare(
220
+ `SELECT c.id, c.content
221
+ FROM conversation_history c
222
+ LEFT JOIN conversation_history_vec v ON c.id = v.id
223
+ WHERE v.id IS NULL OR length(v.vector) = 0`,
224
+ )
225
+ .all() as Array<{ id: string; content: string }>;
226
+
227
+ if (missingConvos.length > 0) {
228
+ console.error(
229
+ `[vector-memory-mcp] Backfilling vectors for ${missingConvos.length} conversation chunks...`,
230
+ );
231
+
232
+ const insertConvoVec = db.prepare(
233
+ "INSERT OR REPLACE INTO conversation_history_vec (id, vector) VALUES (?, ?)",
234
+ );
235
+
236
+ // Batch embed in chunks of 32
237
+ const BATCH_SIZE = 32;
238
+ db.exec("BEGIN");
239
+ try {
240
+ for (let i = 0; i < missingConvos.length; i += BATCH_SIZE) {
241
+ const batch = missingConvos.slice(i, i + BATCH_SIZE);
242
+ const vecs = await embeddings.embedBatch(batch.map((r) => r.content));
243
+ for (let j = 0; j < batch.length; j++) {
244
+ insertConvoVec.run(batch[j].id, serializeVector(vecs[j]));
245
+ }
246
+
247
+ if ((i + BATCH_SIZE) % 100 < BATCH_SIZE) {
248
+ console.error(
249
+ `[vector-memory-mcp] ...${Math.min(i + BATCH_SIZE, missingConvos.length)}/${missingConvos.length} conversation chunks`,
250
+ );
251
+ }
252
+ }
253
+ db.exec("COMMIT");
254
+ } catch (e) {
255
+ db.exec("ROLLBACK");
256
+ throw e;
257
+ }
258
+
259
+ console.error(
260
+ `[vector-memory-mcp] Backfilled ${missingConvos.length} conversation vectors`,
261
+ );
262
+ }
263
+ }
@@ -1,6 +1,6 @@
1
1
  import { readFile, readdir, stat } from "fs/promises";
2
2
  import { basename, dirname, join } from "path";
3
- import type { ParsedMessage, SessionFileInfo } from "../../types/conversation.js";
3
+ import type { ParsedMessage, SessionFileInfo } from "../conversation.js";
4
4
  import type { SessionLogParser } from "./types.js";
5
5
 
6
6
  // UUID pattern for session IDs
@@ -1,4 +1,4 @@
1
- import type { ParsedMessage, SessionFileInfo } from "../../types/conversation.js";
1
+ import type { ParsedMessage, SessionFileInfo } from "../conversation.js";
2
2
 
3
3
  /** Interface for parsing session log files into structured messages */
4
4
  export interface SessionLogParser {
@@ -1,34 +1,15 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { loadConfig, parseCliArgs } from "./config/index.js";
4
- import { connectToDatabase } from "./db/connection.js";
5
- import { MemoryRepository } from "./db/memory.repository.js";
6
- import { ConversationRepository } from "./db/conversation.repository.js";
7
- import { EmbeddingsService } from "./services/embeddings.service.js";
8
- import { MemoryService } from "./services/memory.service.js";
9
- import { ConversationHistoryService } from "./services/conversation.service.js";
10
- import { startServer } from "./mcp/server.js";
11
- import { startHttpServer, removeLockfile } from "./http/server.js";
12
- import { isLanceDbDirectory, migrate, formatMigrationSummary } from "./migration.js";
13
-
14
- async function runMigrate(args: string[]): Promise<void> {
15
- const overrides = parseCliArgs(args.slice(1)); // skip "migrate"
16
- const config = loadConfig(overrides);
17
-
18
- const source = config.dbPath;
19
- const target = source.endsWith(".sqlite") ? source.replace(/\.sqlite$/, "-migrated.sqlite") : source + ".sqlite";
20
-
21
- if (!isLanceDbDirectory(source)) {
22
- console.error(
23
- `[vector-memory-mcp] No LanceDB data found at ${source}\n` +
24
- ` Nothing to migrate. The server will create a fresh SQLite database on startup.`
25
- );
26
- return;
27
- }
28
-
29
- const result = await migrate({ source, target });
30
- console.error(formatMigrationSummary(source, target, result));
31
- }
4
+ import { connectToDatabase } from "./core/connection.js";
5
+ import { backfillVectors } from "./core/migrations.js";
6
+ import { MemoryRepository } from "./core/memory.repository.js";
7
+ import { ConversationRepository } from "./core/conversation.repository.js";
8
+ import { EmbeddingsService } from "./core/embeddings.service.js";
9
+ import { MemoryService } from "./core/memory.service.js";
10
+ import { ConversationHistoryService } from "./core/conversation.service.js";
11
+ import { startServer } from "./transports/mcp/server.js";
12
+ import { startHttpServer } from "./transports/http/server.js";
32
13
 
33
14
  async function main(): Promise<void> {
34
15
  const args = process.argv.slice(2);
@@ -40,35 +21,23 @@ async function main(): Promise<void> {
40
21
  return;
41
22
  }
42
23
 
43
- // Check for migrate command
44
- if (args[0] === "migrate") {
45
- await runMigrate(args);
46
- return;
47
- }
48
-
49
24
  // Parse CLI args and load config
50
25
  const overrides = parseCliArgs(args);
51
26
  const config = loadConfig(overrides);
52
27
 
53
- // Detect legacy LanceDB data and warn
54
- if (isLanceDbDirectory(config.dbPath)) {
55
- console.error(
56
- `[vector-memory-mcp] ⚠️ Legacy LanceDB data detected at ${config.dbPath}\n` +
57
- ` Your data must be migrated to the new SQLite format.\n` +
58
- ` Run: vector-memory-mcp migrate\n` +
59
- ` Or: bun run src/index.ts migrate\n`
60
- );
61
- process.exit(1);
62
- }
63
-
64
- // Initialize database
28
+ // Initialize database and backfill any missing vectors before services start
65
29
  const db = connectToDatabase(config.dbPath);
30
+ const embeddings = new EmbeddingsService(config.embeddingModel, config.embeddingDimension);
31
+ await backfillVectors(db, embeddings);
66
32
 
67
33
  // Initialize layers
68
34
  const repository = new MemoryRepository(db);
69
- const embeddings = new EmbeddingsService(config.embeddingModel, config.embeddingDimension);
70
35
  const memoryService = new MemoryService(repository, embeddings);
71
36
 
37
+ if (config.pluginMode) {
38
+ console.error("[vector-memory-mcp] Running in plugin mode");
39
+ }
40
+
72
41
  // Conditionally initialize conversation history indexing
73
42
  if (config.conversationHistory.enabled) {
74
43
  const conversationRepository = new ConversationRepository(db);
@@ -88,7 +57,6 @@ async function main(): Promise<void> {
88
57
  // Graceful shutdown handler
89
58
  const shutdown = () => {
90
59
  console.error("[vector-memory-mcp] Shutting down...");
91
- removeLockfile();
92
60
  if (httpStop) httpStop();
93
61
  db.close();
94
62
  process.exit(0);
@@ -24,8 +24,8 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
24
24
  import { tools } from "../mcp/tools.js";
25
25
  import { handleToolCall } from "../mcp/handlers.js";
26
26
  import { SERVER_INSTRUCTIONS } from "../mcp/server.js";
27
- import { VERSION } from "../config/index.js";
28
- import type { MemoryService } from "../services/memory.service.js";
27
+ import { VERSION } from "../../config/index.js";
28
+ import type { MemoryService } from "../../core/memory.service.js";
29
29
 
30
30
  interface Session {
31
31
  server: Server;
@@ -3,11 +3,12 @@ import { cors } from "hono/cors";
3
3
  import { createServer } from "net";
4
4
  import { writeFileSync, mkdirSync, unlinkSync } from "fs";
5
5
  import { join } from "path";
6
- import type { MemoryService } from "../services/memory.service.js";
7
- import type { Config } from "../config/index.js";
8
- import { isDeleted } from "../types/memory.js";
6
+ import type { MemoryService } from "../../core/memory.service.js";
7
+ import type { Config } from "../../config/index.js";
8
+ import { isDeleted } from "../../core/memory.js";
9
9
  import { createMcpRoutes } from "./mcp-transport.js";
10
- import type { Memory, SearchIntent } from "../types/memory.js";
10
+ import type { Memory, SearchIntent } from "../../core/memory.js";
11
+
11
12
 
12
13
  /**
13
14
  * Check if a port is available by attempting to bind to it
@@ -109,6 +110,7 @@ export function createHttpApp(memoryService: MemoryService, config: Config): Hon
109
110
  embeddingModel: config.embeddingModel,
110
111
  embeddingDimension: config.embeddingDimension,
111
112
  historyEnabled: config.conversationHistory.enabled,
113
+ pluginMode: config.pluginMode,
112
114
  },
113
115
  });
114
116
  });
@@ -1,9 +1,9 @@
1
1
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
- import type { MemoryService } from "../services/memory.service.js";
3
- import type { ConversationHistoryService } from "../services/conversation.service.js";
4
- import type { SearchIntent } from "../types/memory.js";
5
- import type { HistoryFilters, SearchResult } from "../types/conversation.js";
6
- import { DEBUG } from "../config/index.js";
2
+ import type { MemoryService } from "../../core/memory.service.js";
3
+ import type { ConversationHistoryService } from "../../core/conversation.service.js";
4
+ import type { SearchIntent } from "../../core/memory.js";
5
+ import type { HistoryFilters, SearchResult } from "../../core/conversation.js";
6
+ import { DEBUG } from "../../config/index.js";
7
7
 
8
8
  /**
9
9
  * Safely coerce a tool argument to an array. Handles the case where the MCP
@@ -0,0 +1,20 @@
1
+ export const resources: Array<{
2
+ uri: string;
3
+ name: string;
4
+ description: string;
5
+ mimeType: string;
6
+ }> = [];
7
+
8
+ const RESOURCE_CONTENT: Record<string, string> = {};
9
+
10
+ export function readResource(uri: string): {
11
+ contents: Array<{ uri: string; mimeType: string; text: string }>;
12
+ } {
13
+ const text = RESOURCE_CONTENT[uri];
14
+ if (!text) {
15
+ throw new Error(`Resource not found: ${uri}`);
16
+ }
17
+ return {
18
+ contents: [{ uri, mimeType: "text/markdown", text }],
19
+ };
20
+ }
@@ -3,12 +3,15 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
3
3
  import {
4
4
  CallToolRequestSchema,
5
5
  ListToolsRequestSchema,
6
+ ListResourcesRequestSchema,
7
+ ReadResourceRequestSchema,
6
8
  } from "@modelcontextprotocol/sdk/types.js";
9
+ import { resources, readResource } from "./resources.js";
7
10
 
8
11
  import { tools } from "./tools.js";
9
12
  import { handleToolCall } from "./handlers.js";
10
- import type { MemoryService } from "../services/memory.service.js";
11
- import { VERSION } from "../config/index.js";
13
+ import type { MemoryService } from "../../core/memory.service.js";
14
+ import { VERSION } from "../../config/index.js";
12
15
 
13
16
  export const SERVER_INSTRUCTIONS = `This server is the user's canonical memory system. It provides persistent, semantic vector memory that survives across conversations and sessions.
14
17
 
@@ -20,7 +23,7 @@ export function createServer(memoryService: MemoryService): Server {
20
23
  const server = new Server(
21
24
  { name: "vector-memory-mcp", version: VERSION },
22
25
  {
23
- capabilities: { tools: {} },
26
+ capabilities: { tools: {}, resources: {} },
24
27
  instructions: SERVER_INSTRUCTIONS,
25
28
  }
26
29
  );
@@ -34,6 +37,14 @@ export function createServer(memoryService: MemoryService): Server {
34
37
  return handleToolCall(name, args, memoryService);
35
38
  });
36
39
 
40
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
41
+ return { resources };
42
+ });
43
+
44
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
45
+ return readResource(request.params.uri);
46
+ });
47
+
37
48
  return server;
38
49
  }
39
50
 
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Shared text formatting utilities.
3
+ *
4
+ * Provides ANSI styling, Nerd Font icons, horizontal rules, structured
5
+ * message builders, debug logging, and time formatting. Used by both the
6
+ * MCP server and the Claude Code plugin hooks.
7
+ */
8
+
9
+ // ── ANSI escape codes ───────────────────────────────────────────────
10
+
11
+ export const ansi = {
12
+ reset: "\x1b[0m",
13
+ bold: "\x1b[1m",
14
+ dim: "\x1b[2m",
15
+ italic: "\x1b[3m",
16
+ underline: "\x1b[4m",
17
+
18
+ // Foreground colors
19
+ red: "\x1b[31m",
20
+ green: "\x1b[32m",
21
+ yellow: "\x1b[33m",
22
+ blue: "\x1b[34m",
23
+ magenta: "\x1b[35m",
24
+ cyan: "\x1b[36m",
25
+ white: "\x1b[37m",
26
+ gray: "\x1b[90m",
27
+ } as const;
28
+
29
+ // ── Nerd Font glyphs (single-width) ────────────────────────────────
30
+
31
+ export const icon = {
32
+ check: "\uf00c", // nf-fa-check
33
+ cross: "\uf00d", // nf-fa-close
34
+ book: "\uf02d", // nf-fa-book
35
+ branch: "\ue0a0", // Powerline branch
36
+ clock: "\uf017", // nf-fa-clock_o
37
+ warning: "\uf071", // nf-fa-warning
38
+ bolt: "\uf0e7", // nf-fa-bolt
39
+ brain: "\uf5dc", // nf-mdi-brain
40
+ search: "\uf002", // nf-fa-search
41
+ gear: "\uf013", // nf-fa-gear
42
+ database: "\uf1c0", // nf-fa-database
43
+ arrow: "\uf061", // nf-fa-arrow_right
44
+ dot: "\u00b7", // middle dot (standard unicode)
45
+ } as const;
46
+
47
+ // ── Rule line ───────────────────────────────────────────────────────
48
+
49
+ const RULE_WIDTH = 42;
50
+
51
+ /**
52
+ * Create a horizontal rule with an optional inline title.
53
+ * e.g. "── Vector Memory ──────────────────────"
54
+ */
55
+ export function rule(title?: string): string {
56
+ if (!title) {
57
+ return `${ansi.cyan}${"─".repeat(RULE_WIDTH)}${ansi.reset}`;
58
+ }
59
+ const label = ` ${ansi.bold}${title}${ansi.reset} `;
60
+ // "── " prefix = 3 visual chars
61
+ const prefix = `${ansi.cyan}── ${ansi.reset}`;
62
+ // Calculate remaining dashes (account for title visual length)
63
+ const remaining = RULE_WIDTH - 3 - title.length - 2; // 2 for spaces around title
64
+ const suffix = `${ansi.cyan}${"─".repeat(Math.max(1, remaining))}${ansi.reset}`;
65
+ return `${prefix}${label}${suffix}`;
66
+ }
67
+
68
+ // ── System message builder ──────────────────────────────────────────
69
+
70
+ export interface MessageLine {
71
+ icon?: string;
72
+ iconColor?: string;
73
+ text: string;
74
+ }
75
+
76
+ /**
77
+ * Build a user-facing systemMessage with horizontal rules and content lines.
78
+ *
79
+ * Output format:
80
+ * ── Title ──────────────────────────────
81
+ * icon text
82
+ * icon text
83
+ * ──────────────────────────────────────
84
+ *
85
+ * Prepends an empty line so the content starts below the hook label prefix.
86
+ */
87
+ export function buildSystemMessage(
88
+ title: string,
89
+ lines: MessageLine[]
90
+ ): string {
91
+ const parts = [
92
+ "", // push below "HookName says:" prefix
93
+ rule(title),
94
+ ];
95
+
96
+ for (const line of lines) {
97
+ if (line.icon) {
98
+ const color = line.iconColor ?? "";
99
+ const reset = line.iconColor ? ansi.reset : "";
100
+ parts.push(` ${color}${line.icon}${reset} ${line.text}`);
101
+ } else {
102
+ parts.push(` ${line.text}`);
103
+ }
104
+ }
105
+
106
+ parts.push(rule());
107
+ return parts.join("\n");
108
+ }
109
+
110
+ // ── Diagnostic logging ──────────────────────────────────────────────
111
+
112
+ /**
113
+ * Log a diagnostic message to stderr when VECTOR_MEMORY_DEBUG=1.
114
+ */
115
+ export function debug(label: string, message: string): void {
116
+ if (process.env.VECTOR_MEMORY_DEBUG !== "1") return;
117
+ console.error(
118
+ `${ansi.gray}[${label}]${ansi.reset} ${ansi.dim}${message}${ansi.reset}`
119
+ );
120
+ }
121
+
122
+ // ── Time formatting ─────────────────────────────────────────────────
123
+
124
+ export function timeAgo(iso: string): string {
125
+ const now = Date.now();
126
+ const then = new Date(iso).getTime();
127
+ if (Number.isNaN(then)) {
128
+ debug("timeAgo", `invalid ISO string: ${iso}`);
129
+ return "unknown";
130
+ }
131
+ const seconds = Math.floor((now - then) / 1000);
132
+ if (seconds < 0) {
133
+ debug("timeAgo", `negative delta (${seconds}s) — clock skew or future timestamp`);
134
+ return "just now";
135
+ }
136
+ if (seconds < 60) return `${seconds}s ago`;
137
+ const minutes = Math.floor(seconds / 60);
138
+ if (minutes < 60) return `${minutes}m ago`;
139
+ const hours = Math.floor(minutes / 60);
140
+ if (hours < 24) return `${hours}h ago`;
141
+ const days = Math.floor(hours / 24);
142
+ return `${days}d ago`;
143
+ }