@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.
@@ -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) => {
@@ -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) => ({
@@ -148,6 +193,7 @@ export function createHttpApp(memoryService: MemoryService, config: Config): Hon
148
193
  metadata: r.metadata,
149
194
  source: r.source,
150
195
  confidence: r.confidence,
196
+ project: r.project,
151
197
  createdAt: r.createdAt.toISOString(),
152
198
  })),
153
199
  count: results.length,
@@ -162,16 +208,21 @@ export function createHttpApp(memoryService: MemoryService, config: Config): Hon
162
208
  app.post("/store", async (c) => {
163
209
  try {
164
210
  const body = await c.req.json();
165
- const { content, metadata, embeddingText } = body;
211
+ const { content, embeddingText } = body;
166
212
 
167
213
  if (!content || typeof content !== "string") {
168
214
  return c.json({ error: "Missing or invalid 'content' field" }, 400);
169
215
  }
170
216
 
217
+ const metadata = typeof body.metadata === "object" && body.metadata !== null && !Array.isArray(body.metadata)
218
+ ? body.metadata as Record<string, unknown>
219
+ : {};
220
+
171
221
  const memory = await memoryService.store(
172
222
  content,
173
- metadata ?? {},
174
- embeddingText
223
+ metadata,
224
+ typeof embeddingText === "string" ? embeddingText : undefined,
225
+ typeof body.project === "string" ? body.project : undefined
175
226
  );
176
227
 
177
228
  return c.json({
@@ -240,16 +291,14 @@ export function createHttpApp(memoryService: MemoryService, config: Config): Hon
240
291
 
241
292
  const body = await c.req.json().catch(() => ({}));
242
293
  let since: Date | undefined;
243
- if (body.since) {
244
- since = new Date(body.since as string);
294
+ if (typeof body.since === "string") {
295
+ since = new Date(body.since);
245
296
  if (isNaN(since.getTime())) {
246
297
  return c.json({ error: "Invalid 'since' date format" }, 400);
247
298
  }
248
299
  }
249
- const result = await conversationService.indexConversations(
250
- body.path as string | undefined,
251
- since
252
- );
300
+ const path = typeof body.path === "string" ? body.path : undefined;
301
+ const result = await conversationService.indexConversations(path, since);
253
302
 
254
303
  return c.json(result);
255
304
  } catch (error) {
@@ -299,13 +348,13 @@ export async function startHttpServer(
299
348
  fetch: app.fetch,
300
349
  });
301
350
 
302
- writeLockfile(actualPort);
351
+ writeLockfiles(actualPort, config.project);
303
352
  console.error(
304
353
  `[vector-memory-mcp] HTTP server listening on http://${config.httpHost}:${actualPort}`
305
354
  );
306
355
 
307
356
  return {
308
- stop: () => { removeLockfile(); server.stop(); },
357
+ stop: () => { removeLockfiles(config.project); server.stop(); },
309
358
  port: actualPort,
310
359
  };
311
360
  }
@@ -3,6 +3,7 @@ import type { MemoryService } from "../../core/memory.service";
3
3
  import type { ConversationHistoryService } from "../../core/conversation.service";
4
4
  import type { SearchIntent } from "../../core/memory";
5
5
  import type { HistoryFilters, SearchResult } from "../../core/conversation";
6
+ import { resolveDateFilters } from "../../core/time-expr";
6
7
  import { DEBUG } from "../../config/index";
7
8
 
8
9
  /**
@@ -59,6 +60,32 @@ function requireString(args: Record<string, unknown> | undefined, field: string)
59
60
  return value;
60
61
  }
61
62
 
63
+ const VALID_INTENTS = new Set(["continuity", "fact_check", "frequent", "associative", "explore"]);
64
+
65
+ function asIntent(value: unknown): SearchIntent {
66
+ if (typeof value === "string" && VALID_INTENTS.has(value)) return value as SearchIntent;
67
+ return "fact_check";
68
+ }
69
+
70
+ function asInt(value: unknown, fallback: number, min: number, max: number): number {
71
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
72
+ return Math.max(min, Math.min(max, Math.floor(value)));
73
+ }
74
+
75
+ function asBool(value: unknown, fallback: boolean): boolean {
76
+ return typeof value === "boolean" ? value : fallback;
77
+ }
78
+
79
+ function asOptionalString(value: unknown): string | undefined {
80
+ return typeof value === "string" ? value : undefined;
81
+ }
82
+
83
+ function asObject(value: unknown): Record<string, unknown> {
84
+ return typeof value === "object" && value !== null && !Array.isArray(value)
85
+ ? value as Record<string, unknown>
86
+ : {};
87
+ }
88
+
62
89
  export async function handleStoreMemories(
63
90
  args: Record<string, unknown> | undefined,
64
91
  service: MemoryService
@@ -67,6 +94,7 @@ export async function handleStoreMemories(
67
94
  content: string;
68
95
  embedding_text?: string;
69
96
  metadata?: Record<string, unknown>;
97
+ project?: string;
70
98
  }>;
71
99
  try {
72
100
  memories = asArray(args?.memories, "memories");
@@ -79,7 +107,8 @@ export async function handleStoreMemories(
79
107
  const memory = await service.store(
80
108
  item.content,
81
109
  item.metadata ?? {},
82
- item.embedding_text
110
+ item.embedding_text,
111
+ typeof item.project === "string" ? item.project : undefined
83
112
  );
84
113
  ids.push(memory.id);
85
114
  }
@@ -182,13 +211,13 @@ export async function handleSearchMemories(
182
211
  if (typeof query !== "string" || query.trim() === "") {
183
212
  return errorResult("query is required and must be a non-empty string");
184
213
  }
185
- const intent = (args?.intent as SearchIntent) ?? "fact_check";
186
- const limit = (args?.limit as number) ?? 10;
187
- const offset = (args?.offset as number) ?? 0;
188
- const includeDeleted = (args?.include_deleted as boolean) ?? false;
189
- const historyOnly = (args?.history_only as boolean) ?? false;
214
+ const intent = asIntent(args?.intent);
215
+ const limit = asInt(args?.limit, 10, 1, 1000);
216
+ const offset = asInt(args?.offset, 0, 0, 10000);
217
+ const includeDeleted = asBool(args?.include_deleted, false);
218
+ const historyOnly = asBool(args?.history_only, false);
190
219
  // history_only implies include_history
191
- const includeHistory = historyOnly ? true : (args?.include_history as boolean | undefined);
220
+ const includeHistory = historyOnly ? true : (typeof args?.include_history === "boolean" ? args.include_history : undefined);
192
221
 
193
222
  let historyFilters: HistoryFilters;
194
223
  try {
@@ -197,13 +226,27 @@ export async function handleSearchMemories(
197
226
  return errorResult(errorText(e));
198
227
  }
199
228
 
229
+ let dateFilters: { after?: Date; before?: Date };
230
+ try {
231
+ dateFilters = resolveDateFilters({
232
+ after: args?.after,
233
+ before: args?.before,
234
+ time_expr: args?.time_expr,
235
+ });
236
+ } catch (e) {
237
+ return errorResult(errorText(e));
238
+ }
239
+
200
240
  const results = await service.search(query, intent, {
201
241
  limit,
242
+ scope: asOptionalString(args?.scope),
202
243
  includeDeleted,
203
244
  includeHistory,
204
245
  historyOnly,
205
246
  historyFilters,
206
247
  offset,
248
+ after: dateFilters.after,
249
+ before: dateFilters.before,
207
250
  });
208
251
 
209
252
  if (results.length === 0) {
@@ -240,7 +283,11 @@ function formatMemoryDetail(
240
283
  }
241
284
 
242
285
  function formatSearchResult(r: SearchResult, includeDeleted: boolean): string {
243
- let result = `[${r.source}] ID: ${r.id}\nConfidence: ${r.confidence.toFixed(2)}\nContent: ${r.content}`;
286
+ let result = `[${r.source}] ID: ${r.id}\nConfidence: ${r.confidence.toFixed(2)}`;
287
+ if (r.project) {
288
+ result += `\nProject: ${r.project}`;
289
+ }
290
+ result += `\nContent: ${r.content}`;
244
291
  if (r.metadata && Object.keys(r.metadata).length > 0) {
245
292
  result += `\nMetadata: ${JSON.stringify(r.metadata)}`;
246
293
  }
@@ -305,25 +352,23 @@ export async function handleSetWaypoint(
305
352
  args: Record<string, unknown> | undefined,
306
353
  service: MemoryService
307
354
  ): Promise<CallToolResult> {
308
- let project: string;
309
355
  let summary: string;
310
356
  try {
311
- project = requireString(args, "project");
312
357
  summary = requireString(args, "summary");
313
358
  } catch (e) {
314
359
  return errorResult(errorText(e));
315
360
  }
316
361
 
317
362
  const memory = await service.setWaypoint({
318
- project,
319
- branch: args?.branch as string | undefined,
363
+ project: asOptionalString(args?.project),
364
+ branch: asOptionalString(args?.branch),
320
365
  summary,
321
- completed: (args?.completed as string[] | undefined) ?? [],
322
- in_progress_blocked: (args?.in_progress_blocked as string[] | undefined) ?? [],
323
- key_decisions: (args?.key_decisions as string[] | undefined) ?? [],
324
- next_steps: (args?.next_steps as string[] | undefined) ?? [],
325
- memory_ids: (args?.memory_ids as string[] | undefined) ?? [],
326
- metadata: (args?.metadata as Record<string, unknown>) ?? {},
366
+ completed: args?.completed ? asArray(args.completed, "completed") : [],
367
+ in_progress_blocked: args?.in_progress_blocked ? asArray(args.in_progress_blocked, "in_progress_blocked") : [],
368
+ key_decisions: args?.key_decisions ? asArray(args.key_decisions, "key_decisions") : [],
369
+ next_steps: args?.next_steps ? asArray(args.next_steps, "next_steps") : [],
370
+ memory_ids: args?.memory_ids ? asArray(args.memory_ids, "memory_ids") : [],
371
+ metadata: asObject(args?.metadata),
327
372
  });
328
373
 
329
374
  return {
@@ -335,7 +380,7 @@ export async function handleGetWaypoint(
335
380
  args: Record<string, unknown> | undefined,
336
381
  service: MemoryService
337
382
  ): Promise<CallToolResult> {
338
- const project = args?.project as string | undefined;
383
+ const project = asOptionalString(args?.project);
339
384
  const waypoint = await service.getLatestWaypoint(project);
340
385
 
341
386
  if (!waypoint) {
@@ -365,8 +410,8 @@ function parseHistoryFilters(
365
410
  args: Record<string, unknown> | undefined
366
411
  ): HistoryFilters {
367
412
  return {
368
- sessionId: args?.session_id as string | undefined,
369
- role: args?.role_filter as string | undefined,
413
+ sessionId: asOptionalString(args?.session_id),
414
+ role: asOptionalString(args?.role_filter),
370
415
  after: parseDate(args?.history_after, "history_after"),
371
416
  before: parseDate(args?.history_before, "history_before"),
372
417
  };
@@ -394,8 +439,8 @@ export async function handleIndexConversations(
394
439
  if ("error" in conv) return conv.error;
395
440
  const conversationService = conv.service;
396
441
 
397
- const path = args?.path as string | undefined;
398
- const sinceStr = args?.since as string | undefined;
442
+ const path = asOptionalString(args?.path);
443
+ const sinceStr = asOptionalString(args?.since);
399
444
  const since = sinceStr ? new Date(sinceStr) : undefined;
400
445
  if (since && isNaN(since.getTime())) {
401
446
  return errorResult("Invalid 'since' date format");
@@ -425,8 +470,8 @@ export async function handleListIndexedSessions(
425
470
  if ("error" in conv) return conv.error;
426
471
  const conversationService = conv.service;
427
472
 
428
- const limit = (args?.limit as number) ?? 20;
429
- const offset = (args?.offset as number) ?? 0;
473
+ const limit = asInt(args?.limit, 20, 1, 1000);
474
+ const offset = asInt(args?.offset, 0, 0, 10000);
430
475
  const { sessions, total } =
431
476
  await conversationService.listIndexedSessions(limit, offset);
432
477
 
@@ -464,7 +509,7 @@ export async function handleReindexSession(
464
509
  if ("error" in conv) return conv.error;
465
510
  const conversationService = conv.service;
466
511
 
467
- const sessionId = args?.session_id as string | undefined;
512
+ const sessionId = asOptionalString(args?.session_id);
468
513
  if (!sessionId) {
469
514
  return errorResult("session_id is required");
470
515
  }