Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 13 additions & 4 deletions src/db/rounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
6 changes: 3 additions & 3 deletions src/debug/search-debug.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -44,14 +44,14 @@ export async function debugSearch(
const scores = new Map<string, { layer: MemoryLayer; bm25: number | null; vec: number | null; rrf: number }>();

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 });
Expand Down
11 changes: 9 additions & 2 deletions src/graph/traverse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(seedIds);
const result: ExpandedNode[] = [];

Expand Down
16 changes: 11 additions & 5 deletions src/memory/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -72,13 +73,18 @@ export function searchVec(
export function mergeRRF(
bm25Results: { id: string }[],
vecResults: { id: string }[],
config?: SearchConfig,
): Map<string, number> {
const k = config?.rrfK ?? DEFAULT_RRF_K;
const bm25Weight = config?.bm25Weight ?? 1.0;
const vecWeight = config?.vecWeight ?? 1.0;

const scores = new Map<string, number>();
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;
}
Expand Down Expand Up @@ -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) : [];
Expand Down
12 changes: 11 additions & 1 deletion src/memory/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
82 changes: 82 additions & 0 deletions src/utils/retry.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
fn: () => Promise<T>,
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<T>(
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') };
}
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
"sourceMap": true,
"types": ["node"]
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
Expand Down