diff --git a/bun.lock b/bun.lock index 218c638..179db45 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "mem-x", @@ -12,7 +13,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@types/better-sqlite3": "^7", - "@types/node": "^25", + "@types/node": "^25.4.0", "@vitest/coverage-v8": "^4.0.18", "esbuild": "^0.27.3", "eslint": "^10.0.1", @@ -180,7 +181,7 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + "@types/node": ["@types/node@25.4.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/type-utils": "8.56.0", "@typescript-eslint/utils": "8.56.0", "@typescript-eslint/visitor-keys": "8.56.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw=="], diff --git a/package.json b/package.json index 3e75dd0..bd682bb 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@types/better-sqlite3": "^7", - "@types/node": "^25", + "@types/node": "^25.4.0", "@vitest/coverage-v8": "^4.0.18", "esbuild": "^0.27.3", "eslint": "^10.0.1", diff --git a/src/db/rounds.ts b/src/db/rounds.ts index 9a94387..0e40648 100644 --- a/src/db/rounds.ts +++ b/src/db/rounds.ts @@ -8,11 +8,20 @@ export function getCurrentRound(): number { return row ? parseInt(row.value, 10) : 0; } +/** + * Increment round counter atomically. + * Uses UPDATE instead of read-then-write to avoid race conditions. + */ export function incrementRound(): number { const db = getDb(); - const next = getCurrentRound() + 1; + // Use atomic UPDATE to avoid race conditions db.prepare( - "INSERT OR REPLACE INTO metadata (key, value) VALUES ('round_counter', ?)", - ).run(String(next)); - return next; + "UPDATE metadata SET value = CAST(value AS INTEGER) + 1 WHERE key = 'round_counter'" + ).run(); + + // Then fetch the new value + const row = db + .prepare("SELECT value FROM metadata WHERE key = 'round_counter'") + .get() as { value: string } | undefined; + return row ? parseInt(row.value, 10) : 1; } diff --git a/src/debug/search-debug.ts b/src/debug/search-debug.ts index b26d81a..fda5c7b 100644 --- a/src/debug/search-debug.ts +++ b/src/debug/search-debug.ts @@ -1,6 +1,6 @@ import { getEmbeddingProvider } from "../embedding/factory.js"; import { searchBM25, searchVec, fetchRows } from "../memory/search.js"; -import { SEARCH_LAYERS, RRF_K, type MemoryLayer, type AnyMemory } from "../memory/types.js"; +import { SEARCH_LAYERS, DEFAULT_RRF_K, type MemoryLayer, type AnyMemory } from "../memory/types.js"; export interface DebugSearchResult { id: string; @@ -44,14 +44,14 @@ export async function debugSearch( const scores = new Map(); bm25Raw.forEach(({ id, layer: l, rank }, i) => { - const rrf = 1 / (RRF_K + i + 1); + const rrf = 1 / (DEFAULT_RRF_K + i + 1); const existing = scores.get(id); if (existing) { existing.bm25 = rank; existing.rrf += rrf; } else scores.set(id, { layer: l, bm25: rank, vec: null, rrf }); }); vecRaw.forEach(({ id, layer: l, distance }, i) => { - const rrf = 1 / (RRF_K + i + 1); + const rrf = 1 / (DEFAULT_RRF_K + i + 1); const existing = scores.get(id); if (existing) { existing.vec = distance; existing.rrf += rrf; } else scores.set(id, { layer: l, bm25: null, vec: distance, rrf }); diff --git a/src/graph/traverse.ts b/src/graph/traverse.ts index b9c0e6f..04385ec 100644 --- a/src/graph/traverse.ts +++ b/src/graph/traverse.ts @@ -36,13 +36,20 @@ export interface ExpandedNode { /** * BFS expansion from a set of seed node IDs up to `depth` hops. * Returns all discovered neighbor nodes (excluding seeds themselves). + * + * Security limits: + * - Maximum depth is capped at 3 to prevent deep traversal attacks + * - Maximum nodes is capped at 1000 to prevent memory exhaustion */ export function expandNeighborhood( seedIds: string[], opts?: { depth?: number; relation?: EdgeRelation; maxNodes?: number }, ): ExpandedNode[] { - const depth = opts?.depth ?? 1; - const maxNodes = opts?.maxNodes ?? 50; + // 🔒 Hard limit max depth to 3 to prevent deep traversal attacks + const depth = Math.min(opts?.depth ?? 1, 3); + // 🔒 Hard limit max nodes to 1000 to prevent memory exhaustion + const maxNodes = Math.min(opts?.maxNodes ?? 50, 1000); + const visited = new Set(seedIds); const result: ExpandedNode[] = []; diff --git a/src/memory/search.ts b/src/memory/search.ts index 9ce31d7..60521ac 100644 --- a/src/memory/search.ts +++ b/src/memory/search.ts @@ -3,11 +3,12 @@ import { getEmbeddingProvider } from "../embedding/factory.js"; import { hydrateRow } from "./helpers.js"; import { SEARCH_LAYERS, - RRF_K, + DEFAULT_RRF_K, type MemoryLayer, type SearchResult, type SearchOptions, type AnyMemory, + type SearchConfig, } from "./types.js"; function bumpHitCount(layer: MemoryLayer, ids: string[]): void { @@ -72,13 +73,18 @@ export function searchVec( export function mergeRRF( bm25Results: { id: string }[], vecResults: { id: string }[], + config?: SearchConfig, ): Map { + const k = config?.rrfK ?? DEFAULT_RRF_K; + const bm25Weight = config?.bm25Weight ?? 1.0; + const vecWeight = config?.vecWeight ?? 1.0; + const scores = new Map(); bm25Results.forEach(({ id }, i) => { - scores.set(id, (scores.get(id) ?? 0) + 1 / (RRF_K + i + 1)); + scores.set(id, (scores.get(id) ?? 0) + bm25Weight / (k + i + 1)); }); vecResults.forEach(({ id }, i) => { - scores.set(id, (scores.get(id) ?? 0) + 1 / (RRF_K + i + 1)); + scores.set(id, (scores.get(id) ?? 0) + vecWeight / (k + i + 1)); }); return scores; } @@ -110,11 +116,11 @@ function searchLayer( if (mode === "bm25") { const bm25 = searchBM25(layer, query, limit); - scoredIds = new Map(bm25.map(({ id }, i) => [id, 1 / (RRF_K + i + 1)])); + scoredIds = new Map(bm25.map(({ id }, i) => [id, 1 / (DEFAULT_RRF_K + i + 1)])); } else if (mode === "vector") { if (!embeddingBuf) return []; const vec = searchVec(layer, embeddingBuf, limit); - scoredIds = new Map(vec.map(({ id }, i) => [id, 1 / (RRF_K + i + 1)])); + scoredIds = new Map(vec.map(({ id }, i) => [id, 1 / (DEFAULT_RRF_K + i + 1)])); } else { const bm25 = searchBM25(layer, query, limit * 2); const vec = embeddingBuf ? searchVec(layer, embeddingBuf, limit * 2) : []; diff --git a/src/memory/types.ts b/src/memory/types.ts index 959a52b..645fc9b 100644 --- a/src/memory/types.ts +++ b/src/memory/types.ts @@ -7,7 +7,17 @@ export const SEARCH_LAYERS: MemoryLayer[] = ["rules", "short_term", "semantic", export const ALL_LAYERS: MemoryLayer[] = ["short_term", "episodic", "semantic", "rules"]; -export const RRF_K = 60; +export const DEFAULT_RRF_K = 60; + +/** Search configuration for RRF fusion */ +export interface SearchConfig { + /** RRF k parameter (default: 60) */ + rrfK?: number; + /** BM25 weight in fusion (default: 1.0) */ + bm25Weight?: number; + /** Vector weight in fusion (default: 1.0) */ + vecWeight?: number; +} export interface ShortTermMemory { id: string; diff --git a/src/utils/retry.ts b/src/utils/retry.ts new file mode 100644 index 0000000..d3000b8 --- /dev/null +++ b/src/utils/retry.ts @@ -0,0 +1,82 @@ +/** + * Retry utilities with exponential backoff. + */ + +export interface RetryOptions { + maxAttempts?: number; + backoffMs?: number; + onRetry?: (error: Error, attempt: number) => void; +} + +/** + * Execute a function with retry and exponential backoff. + * Returns { ok: true, data } on success, { ok: false, error } on failure. + * + * @example + * const result = await withRetry(() => fetchData()); + * if (!result.ok) { + * console.error(result.error.message); + * } + */ +export async function withRetry( + fn: () => Promise, + opts?: RetryOptions, +): Promise<{ ok: true; data: T } | { ok: false; error: Error }> { + const maxAttempts = opts?.maxAttempts ?? 3; + const backoffMs = opts?.backoffMs ?? 1000; + + for (let i = 0; i < maxAttempts; i++) { + try { + const data = await fn(); + return { ok: true, data }; + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + + if (i === maxAttempts - 1) { + return { ok: false, error }; + } + + opts?.onRetry?.(error, i + 1); + + // Exponential backoff: 1s, 2s, 4s, ... + const delay = backoffMs * Math.pow(2, i); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + return { ok: false, error: new Error('Max retries exceeded') }; +} + +/** + * Synchronous version of withRetry. + */ +export function withRetrySync( + fn: () => T, + opts?: RetryOptions, +): { ok: true; data: T } | { ok: false; error: Error } { + const maxAttempts = opts?.maxAttempts ?? 3; + const backoffMs = opts?.backoffMs ?? 1000; + + for (let i = 0; i < maxAttempts; i++) { + try { + const data = fn(); + return { ok: true, data }; + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + + if (i === maxAttempts - 1) { + return { ok: false, error }; + } + + opts?.onRetry?.(error, i + 1); + + // Synchronous sleep is not recommended, but we provide a basic implementation + const start = Date.now(); + while (Date.now() - start < backoffMs * Math.pow(2, i)) { + // busy wait - not ideal but works synchronously + } + } + } + + return { ok: false, error: new Error('Max retries exceeded') }; +} diff --git a/tsconfig.json b/tsconfig.json index 1e9846f..dc9fe2b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,8 @@ "skipLibCheck": true, "resolveJsonModule": true, "declaration": true, - "sourceMap": true + "sourceMap": true, + "types": ["node"] }, "include": ["src"], "exclude": ["node_modules", "dist"]