From 5211aa6fa093d68a352b05d74e2b4f9b9bcb7127 Mon Sep 17 00:00:00 2001 From: Greg von Nessi Date: Fri, 13 Mar 2026 19:13:51 +0000 Subject: [PATCH] Fix 20 bugs found in full codebase review (14 modules) Multi-agent review across all 14 production modules with independent verification scoring. Key fixes: - Config: getConfig() bypassed user config at all entry points; add initRuntimeConfig() cache, wire to MCP server/hooks/dashboard - Config: parseInt/parseFloat NaN from env vars not validated - Ingest: findDebriefTurn returned array index instead of turn.index, breaking debrief edges during incremental ingestion - MCP: days_back=0 silently overrode explicit from/to date filters - MCP: missing params validation on tools/call JSON-RPC handler - MCP: parse error response used id:0 instead of id:null (JSON-RPC spec) - Storage: memory leak in vector index cleanup, stale projectsCache, hash collision in cluster membership, missing transaction wrapper - Retrieval: anyChildEmitted set unconditionally in chain-walker DFS - Hooks: session-start fallback never passed to executeHook(); withRetry() never updated metrics.retryCount --- src/config/loader.ts | 39 +++++++++-- src/config/memory-config.ts | 36 +++++++++- src/dashboard/server.ts | 6 +- src/hooks/hook-utils.ts | 4 +- src/hooks/session-start.ts | 8 ++- src/ingest/brief-debrief-detector.ts | 11 +-- src/mcp/server.ts | 32 ++++++--- src/mcp/tools.ts | 2 +- src/models/embedder.ts | 2 + src/retrieval/chain-walker.ts | 6 +- src/storage/chunk-store.ts | 2 + src/storage/cluster-store.ts | 27 +++----- src/storage/db.ts | 22 ++++-- src/storage/index-entry-store.ts | 100 +++++++++++++-------------- src/storage/keyword-store.ts | 2 +- src/storage/types.ts | 6 +- src/storage/vector-store-cleanup.ts | 12 ++-- src/storage/vector-store.ts | 36 ++++++---- test/config/loader.test.ts | 10 +-- test/storage/cluster-store.test.ts | 4 +- 20 files changed, 237 insertions(+), 130 deletions(-) diff --git a/src/config/loader.ts b/src/config/loader.ts index a7058f1..9515e80 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -264,12 +264,24 @@ function applyEnvMappings(config: ExternalConfig, mappings: EnvMapping[]): void const key = parts[parts.length - 1]; switch (type) { - case 'int': - obj[key] = parseInt(value, 10); + case 'int': { + const parsed = parseInt(value, 10); + if (isNaN(parsed)) { + log.warn(`Invalid integer for ${env}: '${value}'`); + continue; + } + obj[key] = parsed; break; - case 'float': - obj[key] = parseFloat(value); + } + case 'float': { + const parsed = parseFloat(value); + if (isNaN(parsed)) { + log.warn(`Invalid float for ${env}: '${value}'`); + continue; + } + obj[key] = parsed; break; + } case 'boolean': obj[key] = value === 'true'; break; @@ -381,6 +393,25 @@ export function validateExternalConfig(config: ExternalConfig): string[] { } } + // Maintenance validation + if (config.maintenance?.clusterHour !== undefined) { + if (config.maintenance.clusterHour < 0 || config.maintenance.clusterHour > 23) { + errors.push('maintenance.clusterHour must be between 0 and 23 (inclusive)'); + } + } + + // Recency validation + if (config.recency?.halfLifeHours !== undefined) { + if (config.recency.halfLifeHours <= 0) { + errors.push('recency.halfLifeHours must be greater than 0'); + } + } + if (config.recency?.decayFactor !== undefined) { + if (config.recency.decayFactor < 0) { + errors.push('recency.decayFactor must be >= 0'); + } + } + // Length penalty validation if (config.lengthPenalty?.referenceTokens !== undefined) { if (config.lengthPenalty.referenceTokens <= 0) { diff --git a/src/config/memory-config.ts b/src/config/memory-config.ts index baf0c8c..6c98543 100644 --- a/src/config/memory-config.ts +++ b/src/config/memory-config.ts @@ -225,11 +225,45 @@ export const DEFAULT_CONFIG: MemoryConfig = { }, }; +/** + * Cached runtime config, populated by initRuntimeConfig(). + * When set, getConfig() returns this instead of bare defaults, + * ensuring user config files and env vars are respected. + */ +let _runtimeConfig: MemoryConfig | null = null; + +/** + * Initialize the runtime config cache. Call once at startup (e.g., MCP server start). + * This ensures getConfig() respects user config files and env vars. + */ +export function initRuntimeConfig(config: MemoryConfig): void { + _runtimeConfig = config; +} + /** * Get configuration with overrides applied. + * Returns the cached runtime config (from initRuntimeConfig) if available, + * otherwise falls back to DEFAULT_CONFIG. */ export function getConfig(overrides: Partial = {}): MemoryConfig { - return { ...DEFAULT_CONFIG, ...overrides }; + const base = _runtimeConfig ?? DEFAULT_CONFIG; + if (Object.keys(overrides).length === 0) return base; + return { + ...base, + ...overrides, + // Deep-merge nested objects to prevent shallow spread from destroying them + hybridSearch: { ...base.hybridSearch, ...overrides.hybridSearch }, + clusterExpansion: { ...base.clusterExpansion, ...overrides.clusterExpansion }, + mmrReranking: { ...base.mmrReranking, ...overrides.mmrReranking }, + recency: { ...base.recency, ...overrides.recency }, + lengthPenalty: { ...base.lengthPenalty, ...overrides.lengthPenalty }, + repomap: { + ...base.repomap, + ...overrides.repomap, + languages: overrides.repomap?.languages ?? base.repomap.languages, + }, + semanticIndex: { ...base.semanticIndex, ...overrides.semanticIndex }, + }; } /** diff --git a/src/dashboard/server.ts b/src/dashboard/server.ts index b60dd44..0bec13c 100644 --- a/src/dashboard/server.ts +++ b/src/dashboard/server.ts @@ -58,7 +58,11 @@ export function createApp() { } export async function startDashboard(port: number): Promise { - // Ensure database is initialized before starting + // Ensure config and database are initialized before starting + const { initRuntimeConfig } = await import('../config/memory-config.js'); + const { loadConfig, toRuntimeConfig } = await import('../config/loader.js'); + initRuntimeConfig(toRuntimeConfig(loadConfig())); + const { getDb } = await import('../storage/db.js'); getDb(); diff --git a/src/hooks/hook-utils.ts b/src/hooks/hook-utils.ts index 5c3d564..e628e57 100644 --- a/src/hooks/hook-utils.ts +++ b/src/hooks/hook-utils.ts @@ -116,6 +116,7 @@ export async function withRetry( hookName: string, fn: () => Promise, options: RetryOptions = {}, + metrics?: HookMetrics, ): Promise { const { maxRetries = 3, @@ -130,6 +131,7 @@ export async function withRetry( for (let attempt = 0; attempt <= maxRetries; attempt++) { try { if (attempt > 0) { + if (metrics) metrics.retryCount = attempt; const delay = calculateBackoff(attempt - 1, initialDelayMs, maxDelayMs, backoffFactor); logHook({ @@ -229,7 +231,7 @@ export async function executeHook( let result: T; if (options.retry) { - result = await withRetry(hookName, fn, options.retry); + result = await withRetry(hookName, fn, options.retry, metrics); } else { result = await fn(); } diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index ed09510..ee5873a 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -20,7 +20,8 @@ import { getSessionsForProject, getChunksByTimeRange, } from '../storage/chunk-store.js'; -import { getConfig } from '../config/memory-config.js'; +import { getConfig, initRuntimeConfig } from '../config/memory-config.js'; +import { loadConfig, toRuntimeConfig } from '../config/loader.js'; import { approximateTokens } from '../utils/token-counter.js'; import { runStaleMaintenanceTasks } from '../maintenance/scheduler.js'; import { executeHook, logHook, isTransientError, type HookMetrics } from './hook-utils.js'; @@ -193,6 +194,9 @@ export async function handleSessionStart( ): Promise { const { enableRetry = true, maxRetries = 3, gracefulDegradation = true } = options; + // Ensure user config is loaded before getConfig() is used + initRuntimeConfig(toRuntimeConfig(loadConfig())); + // Run stale maintenance tasks in background (prune, recluster) // Covers cases where scheduled cron times were missed (e.g. laptop asleep) runStaleMaintenanceTasks(); @@ -217,7 +221,7 @@ export async function handleSessionStart( retryOn: isTransientError, } : undefined, - fallback: gracefulDegradation ? undefined : undefined, + fallback: gracefulDegradation ? fallbackResult : undefined, project: basename(projectPath) || projectPath, }, ); diff --git a/src/ingest/brief-debrief-detector.ts b/src/ingest/brief-debrief-detector.ts index fc6b290..f7cbb23 100644 --- a/src/ingest/brief-debrief-detector.ts +++ b/src/ingest/brief-debrief-detector.ts @@ -234,13 +234,14 @@ export function detectDebriefPoints( const turn = mainTurns[i]; if (extractSpawnedAgentIds(turn).includes(agentId)) { // Link to the next turn's chunks (all of them) - const nextChunkIds = chunkIdsByTurn.get(i + 1); + const nextTurnIndex = i + 1 < mainTurns.length ? mainTurns[i + 1].index : -1; + const nextChunkIds = nextTurnIndex >= 0 ? chunkIdsByTurn.get(nextTurnIndex) : undefined; if (nextChunkIds && nextChunkIds.length > 0) { debriefPoints.push({ agentId, agentFinalChunkIds: finalChunkIds, parentChunkIds: [...nextChunkIds], - turnIndex: i + 1, + turnIndex: nextTurnIndex, spawnDepth, }); } @@ -281,7 +282,7 @@ function findDebriefTurn(turns: Turn[], agentId: string, _finalChunk: Chunk): nu // Only match if the result explicitly contains this agent's ID // (removed isTaskResult fallback - it's too broad and matches unrelated agents) if (exchange.result.includes(agentId)) { - return i; + return turn.index; } } } @@ -291,7 +292,7 @@ function findDebriefTurn(turns: Turn[], agentId: string, _finalChunk: Chunk): nu if (msg.type === 'user' && msg.message?.content) { const content = msg.message.content; if (typeof content === 'string' && content.includes(agentId)) { - return i; + return turn.index; } } } @@ -303,7 +304,7 @@ function findDebriefTurn(turns: Turn[], agentId: string, _finalChunk: Chunk): nu if (extractSpawnedAgentIds(turn).includes(agentId)) { // Return the next turn if it exists if (i + 1 < turns.length) { - return i + 1; + return turns[i + 1].index; } } } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index b2622a9..f2219df 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -13,6 +13,8 @@ import { createInterface } from 'readline'; import { tools, getTool } from './tools.js'; import { getDb, closeDb } from '../storage/db.js'; +import { initRuntimeConfig } from '../config/memory-config.js'; +import { loadConfig, toRuntimeConfig } from '../config/loader.js'; import { disposeRetrieval } from '../retrieval/context-assembler.js'; import { getChunkCount } from '../storage/chunk-store.js'; import { getEdgeCount } from '../storage/edge-store.js'; @@ -82,7 +84,7 @@ interface McpRequest { */ interface McpResponse { jsonrpc: '2.0'; - id: string | number; + id: string | number | null; result?: unknown; error?: { code: number; @@ -113,7 +115,7 @@ interface HealthStatus { * Create a standardized error response. */ function createErrorResponse( - id: string | number, + id: string | number | null, code: number, message: string, data?: unknown, @@ -176,7 +178,8 @@ export class McpServer { this.running = true; this.startTime = Date.now(); - // Initialize database + // Initialize config and database + initRuntimeConfig(toRuntimeConfig(loadConfig())); getDb(); this.log({ level: 'info', event: 'server_started' }); @@ -229,7 +232,7 @@ export class McpServer { } catch (error) { this.errorCount++; const errorResponse = createErrorResponse( - 0, + null, ErrorCodes.PARSE_ERROR, 'Parse error', errorMessage(error), @@ -326,11 +329,22 @@ export class McpServer { case 'tools/list': return this.handleToolsList(id); - case 'tools/call': - return await this.handleToolsCall( - id, - params as { name: string; arguments: Record }, - ); + case 'tools/call': { + const toolParams = params as + | { name: string; arguments?: Record } + | undefined; + if (!toolParams || typeof toolParams.name !== 'string') { + return createErrorResponse( + id, + ErrorCodes.INVALID_PARAMS, + 'tools/call requires params.name', + ); + } + return await this.handleToolsCall(id, { + name: toolParams.name, + arguments: toolParams.arguments ?? {}, + }); + } case 'ping': return this.handlePing(id); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index ddccc57..bf5fdf6 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -351,7 +351,7 @@ export const listSessionsTool: ToolDefinition = { const daysBack = args.days_back as number | undefined; const limit = (args.limit as number | undefined) ?? 30; - if (daysBack !== null && daysBack !== undefined) { + if (daysBack !== undefined && daysBack > 0 && from === undefined && to === undefined) { to = new Date().toISOString(); from = new Date(Date.now() - daysBack * 24 * 60 * 60 * 1000).toISOString(); } diff --git a/src/models/embedder.ts b/src/models/embedder.ts index 4ef411e..e6e9a73 100644 --- a/src/models/embedder.ts +++ b/src/models/embedder.ts @@ -99,6 +99,8 @@ export class Embedder { throw epError; } } + + // Only set config after pipeline is successfully loaded this.config = config; const loadTimeMs = performance.now() - start; diff --git a/src/retrieval/chain-walker.ts b/src/retrieval/chain-walker.ts index f6b07fc..34ddd43 100644 --- a/src/retrieval/chain-walker.ts +++ b/src/retrieval/chain-walker.ts @@ -180,8 +180,9 @@ async function walkAllPaths( if (agentFilter && chunk.agentId !== agentFilter) { const newSkips = consecutiveSkips + 1; if (newSkips <= maxSkippedConsecutive) { + const before = candidates.length; await dfs(nextId, depth + 1, newSkips); - anyChildEmitted = true; + if (candidates.length > before) anyChildEmitted = true; } pathVisited.delete(nextId); continue; @@ -193,8 +194,9 @@ async function walkAllPaths( // adding to path. The chain doesn't break — we continue traversing — but // this node won't appear in the output or affect the median score. if (chunkTokens > tokenBudget) { + const before = candidates.length; await dfs(nextId, depth + 1, 0); - anyChildEmitted = true; + if (candidates.length > before) anyChildEmitted = true; pathVisited.delete(nextId); continue; } diff --git a/src/storage/chunk-store.ts b/src/storage/chunk-store.ts index 02fed88..8648ead 100644 --- a/src/storage/chunk-store.ts +++ b/src/storage/chunk-store.ts @@ -211,6 +211,7 @@ export function isSessionIngested(sessionId: string): boolean { export function deleteChunk(id: string): boolean { const db = getDb(); const result = db.prepare('DELETE FROM chunks WHERE id = ?').run(id); + invalidateProjectsCache(); return result.changes > 0; } @@ -225,6 +226,7 @@ export function deleteChunks(ids: string[]): number { const db = getDb(); const placeholders = sqlPlaceholders(ids.length); const result = db.prepare(`DELETE FROM chunks WHERE id IN (${placeholders})`).run(...ids); + invalidateProjectsCache(); return result.changes; } diff --git a/src/storage/cluster-store.ts b/src/storage/cluster-store.ts index f94568d..2eca4b7 100644 --- a/src/storage/cluster-store.ts +++ b/src/storage/cluster-store.ts @@ -2,8 +2,13 @@ * CRUD operations for clusters and chunk-cluster assignments. */ +import { createHash } from 'node:crypto'; import { getDb, generateId } from './db.js'; import type { StoredCluster, ClusterInput, ChunkClusterAssignment } from './types.js'; +import { + serializeEmbedding as serializeCentroid, + deserializeEmbedding as deserializeCentroid, +} from '../utils/embedding-utils.js'; /** * Create or update a cluster. @@ -118,12 +123,12 @@ export function getStaleClusters(maxAge?: number): StoredCluster[] { const db = getDb(); let query = 'SELECT * FROM clusters WHERE refreshed_at IS NULL'; - const params: number[] = []; + const params: (string | number)[] = []; if (maxAge !== undefined) { const cutoff = new Date(Date.now() - maxAge).toISOString(); query += ' OR refreshed_at < ?'; - params.push(cutoff as unknown as number); + params.push(cutoff); } const rows = db.prepare(query).all(...params) as DbClusterRow[]; @@ -291,9 +296,7 @@ export function getClusterCount(): number { */ export function computeMembershipHash(chunkIds: string[]): string { const sorted = [...chunkIds].sort(); - // Simple hash: join and use first 16 chars of base64 - const str = sorted.join(','); - return Buffer.from(str).toString('base64').slice(0, 16); + return createHash('sha256').update(sorted.join(',')).digest('hex').slice(0, 16); } // Internal types and helpers @@ -327,17 +330,3 @@ function rowToCluster(row: DbClusterRow): StoredCluster { refreshedAt: row.refreshed_at, }; } - -function serializeCentroid(centroid: number[]): Buffer { - const float32 = new Float32Array(centroid); - return Buffer.from(float32.buffer); -} - -function deserializeCentroid(buffer: Buffer): number[] { - const float32 = new Float32Array( - buffer.buffer, - buffer.byteOffset, - buffer.length / Float32Array.BYTES_PER_ELEMENT, - ); - return Array.from(float32); -} diff --git a/src/storage/db.ts b/src/storage/db.ts index d90d989..3bdf5cd 100644 --- a/src/storage/db.ts +++ b/src/storage/db.ts @@ -272,22 +272,22 @@ export function clearAllData(database?: Database.Database): void { try { d.exec('DELETE FROM index_entry_chunks'); d.exec('DELETE FROM index_entries'); - } catch { - // Tables may not exist yet + } catch (e) { + if (!isTableNotFoundError(e)) throw e; } // Clean up session states if table exists try { d.exec('DELETE FROM session_states'); - } catch { - // Table may not exist yet + } catch (e) { + if (!isTableNotFoundError(e)) throw e; } // Clean up entity tables if they exist try { d.exec('DELETE FROM entity_mentions'); d.exec('DELETE FROM entity_aliases'); d.exec('DELETE FROM entities'); - } catch { - // Tables may not exist yet + } catch (e) { + if (!isTableNotFoundError(e)) throw e; } } @@ -314,6 +314,16 @@ export function getDbStats(database?: Database.Database): { return { chunks, edges, clusters, assignments }; } +/** + * Check if an error is a SQLite "no such table" error. + * + * Used in catch blocks that should only tolerate missing tables + * (e.g. during migrations or when optional tables haven't been created yet). + */ +export function isTableNotFoundError(error: unknown): boolean { + return error instanceof Error && error.message.includes('no such table'); +} + /** * Generate a unique ID. */ diff --git a/src/storage/index-entry-store.ts b/src/storage/index-entry-store.ts index 3f614a7..60b6966 100644 --- a/src/storage/index-entry-store.ts +++ b/src/storage/index-entry-store.ts @@ -13,6 +13,7 @@ import { getDb, generateId, sqlPlaceholders } from './db.js'; import { indexVectorStore } from './vector-store.js'; import type { IndexEntry, IndexEntryInput } from './types.js'; import { createLogger } from '../utils/logger.js'; +import { sanitizeQuery } from './keyword-store.js'; const log = createLogger('index-entry-store'); @@ -247,48 +248,56 @@ export async function deleteIndexEntriesForChunks(chunkIds: string[]): Promise; - - if (affectedRows.length === 0) return 0; - - const affectedIds = affectedRows.map((r) => r.index_entry_id); - - // Remove the chunk references - db.prepare(`DELETE FROM index_entry_chunks WHERE chunk_id IN (${placeholders})`).run(...chunkIds); - - // Find entries that now have no chunk references - const orphanedIds: string[] = []; - for (const entryId of affectedIds) { - const remaining = db - .prepare('SELECT COUNT(*) as count FROM index_entry_chunks WHERE index_entry_id = ?') - .get(entryId) as { count: number }; - - if (remaining.count === 0) { - orphanedIds.push(entryId); - } else { - // Update the chunk_ids JSON to reflect removed references - const currentChunks = db - .prepare('SELECT chunk_id FROM index_entry_chunks WHERE index_entry_id = ?') - .all(entryId) as Array<{ chunk_id: string }>; + const orphanedIds = db.transaction(() => { + // Find affected index entry IDs + const affectedRows = db + .prepare( + `SELECT DISTINCT index_entry_id FROM index_entry_chunks WHERE chunk_id IN (${placeholders})`, + ) + .all(...chunkIds) as Array<{ index_entry_id: string }>; + + if (affectedRows.length === 0) return []; + + const affectedIds = affectedRows.map((r) => r.index_entry_id); + + // Remove the chunk references + db.prepare(`DELETE FROM index_entry_chunks WHERE chunk_id IN (${placeholders})`).run( + ...chunkIds, + ); + + // Find entries that now have no chunk references + const orphaned: string[] = []; + for (const entryId of affectedIds) { + const remaining = db + .prepare('SELECT COUNT(*) as count FROM index_entry_chunks WHERE index_entry_id = ?') + .get(entryId) as { count: number }; + + if (remaining.count === 0) { + orphaned.push(entryId); + } else { + // Update the chunk_ids JSON to reflect removed references + const currentChunks = db + .prepare('SELECT chunk_id FROM index_entry_chunks WHERE index_entry_id = ?') + .all(entryId) as Array<{ chunk_id: string }>; + + db.prepare('UPDATE index_entries SET chunk_ids = ? WHERE id = ?').run( + JSON.stringify(currentChunks.map((c) => c.chunk_id)), + entryId, + ); + } + } - db.prepare('UPDATE index_entries SET chunk_ids = ? WHERE id = ?').run( - JSON.stringify(currentChunks.map((c) => c.chunk_id)), - entryId, - ); + // Delete orphaned entries + if (orphaned.length > 0) { + const orphanPlaceholders = sqlPlaceholders(orphaned.length); + db.prepare(`DELETE FROM index_entries WHERE id IN (${orphanPlaceholders})`).run(...orphaned); } - } - // Delete orphaned entries - if (orphanedIds.length > 0) { - const orphanPlaceholders = sqlPlaceholders(orphanedIds.length); - db.prepare(`DELETE FROM index_entries WHERE id IN (${orphanPlaceholders})`).run(...orphanedIds); + return orphaned; + })(); - // Clean up vectors + // Clean up vectors (async, outside transaction) + if (orphanedIds.length > 0) { await indexVectorStore.deleteBatch(orphanedIds); } @@ -337,19 +346,8 @@ export function searchIndexEntriesByKeyword( ): Array<{ id: string; score: number }> { const db = getDb(); - // Sanitize query for FTS5 - const sanitized = query - .replace(/\b(AND|OR|NOT)\b/g, '') - .replace(/[*"(){}\^~\-]/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - - if (!sanitized) return []; - - const terms = sanitized.split(/\s+/).filter(Boolean); - if (terms.length === 0) return []; - - const ftsQuery = terms.map((t) => `"${t}"`).join(' '); + const ftsQuery = sanitizeQuery(query); + if (!ftsQuery) return []; try { let sql = ` diff --git a/src/storage/keyword-store.ts b/src/storage/keyword-store.ts index 2822156..aed0868 100644 --- a/src/storage/keyword-store.ts +++ b/src/storage/keyword-store.ts @@ -19,7 +19,7 @@ export interface KeywordSearchResult { * Sanitize a query string for FTS5 MATCH syntax. * Escapes special characters and strips FTS5 operators. */ -function sanitizeQuery(query: string): string { +export function sanitizeQuery(query: string): string { if (!query || !query.trim()) return ''; // Remove FTS5 special operators and characters diff --git a/src/storage/types.ts b/src/storage/types.ts index 9724478..72e2272 100644 --- a/src/storage/types.ts +++ b/src/storage/types.ts @@ -33,6 +33,9 @@ export type EdgeType = 'backward' | 'forward'; * | `brief` | 0.9 | Parent spawning sub-agent (× depth penalty) | * | `debrief` | 0.9 | Sub-agent returning to parent (× depth penalty) | * | `cross-session` | 0.7 | Session continuation | + * | `team-spawn` | 0.9 | Team orchestrator spawning a team member | + * | `team-report` | 0.9 | Team member reporting back to orchestrator | + * | `peer-message` | 0.8 | Peer-to-peer message between team members | * * All within-chain edges use m×n all-pairs creation at D-T-D boundaries, * with topic-shift gating to omit edges across topic changes. @@ -152,8 +155,7 @@ export interface StoredEdge { * Input for creating a new edge. * * When creating edges, you specify source, target, and type. The system - * generates an ID and sets createdAt. Use `createOrBoostEdges()` to - * increment linkCount if the same edge already exists. + * generates an ID and sets createdAt via `createEdges()`. */ export interface EdgeInput { /** Source chunk ID */ diff --git a/src/storage/vector-store-cleanup.ts b/src/storage/vector-store-cleanup.ts index 65c72d5..79ac582 100644 --- a/src/storage/vector-store-cleanup.ts +++ b/src/storage/vector-store-cleanup.ts @@ -12,7 +12,7 @@ */ import type Database from 'better-sqlite3-multiple-ciphers'; -import { sqlPlaceholders } from './db.js'; +import { sqlPlaceholders, isTableNotFoundError } from './db.js'; /** * Remove vectors and all related data (chunks, clusters, index entries) by ID. @@ -30,7 +30,7 @@ import { sqlPlaceholders } from './db.js'; */ export function removeVectorsAndRelated( db: Database.Database, - tableName: string, + tableName: 'vectors' | 'index_vectors', ids: string[], ): number { const placeholders = sqlPlaceholders(ids.length); @@ -78,8 +78,8 @@ export function removeVectorsAndRelated( ); } } - } catch { - // index_entry_chunks table may not exist yet + } catch (e) { + if (!isTableNotFoundError(e)) throw e; } return result.changes; @@ -95,7 +95,7 @@ export function removeVectorsAndRelated( */ export function findExpiredVectorIds( db: Database.Database, - tableName: string, + tableName: 'vectors' | 'index_vectors', ttlDays: number, ): string[] { const expiredRows = db @@ -120,7 +120,7 @@ export function findExpiredVectorIds( */ export function findOldestVectorIds( db: Database.Database, - tableName: string, + tableName: 'vectors' | 'index_vectors', overage: number, ): string[] { const toEvict = db diff --git a/src/storage/vector-store.ts b/src/storage/vector-store.ts index 220a3f4..01856da 100644 --- a/src/storage/vector-store.ts +++ b/src/storage/vector-store.ts @@ -62,7 +62,7 @@ * @module storage/vector-store */ -import { getDb, sqlPlaceholders } from './db.js'; +import { getDb, sqlPlaceholders, isTableNotFoundError } from './db.js'; import { angularDistance } from '../utils/angular-distance.js'; import type { VectorSearchResult } from './types.js'; import { serializeEmbedding, deserializeEmbedding } from '../utils/embedding-utils.js'; @@ -98,11 +98,14 @@ export class VectorStore { private expectedDims: number = 512; /** SQL table name for persistence. Default: 'vectors'. */ - private readonly tableName: string; + private readonly tableName: 'vectors' | 'index_vectors'; /** Optional lookup table for resolving entity metadata (e.g. 'index_entries' for index vectors). */ private readonly metadataTable: string | null; - constructor(options?: { tableName?: string; metadataTable?: string | null }) { + constructor(options?: { + tableName?: 'vectors' | 'index_vectors'; + metadataTable?: string | null; + }) { this.tableName = options?.tableName ?? 'vectors'; this.metadataTable = options?.metadataTable ?? null; } @@ -195,8 +198,8 @@ export class VectorStore { for (const row of projectRows) { this.chunkProjectIndex.set(row.id, row.session_slug); } - } catch { - // metadata table may not exist yet (e.g., during migrations) + } catch (e) { + if (!isTableNotFoundError(e)) throw e; } try { @@ -207,8 +210,8 @@ export class VectorStore { for (const row of agentRows) { this.chunkAgentIndex.set(row.id, row.agent_id); } - } catch { - // metadata table may not exist yet + } catch (e) { + if (!isTableNotFoundError(e)) throw e; } try { @@ -219,8 +222,8 @@ export class VectorStore { for (const row of teamRows) { this.chunkTeamIndex.set(row.id, row.team_name); } - } catch { - // metadata table may not exist yet + } catch (e) { + if (!isTableNotFoundError(e)) throw e; } this.loaded = true; @@ -265,8 +268,8 @@ export class VectorStore { if (row?.team_name) { this.chunkTeamIndex.set(id, row.team_name); } - } catch { - // metadata table may not exist + } catch (e) { + if (!isTableNotFoundError(e)) throw e; } } @@ -328,8 +331,8 @@ export class VectorStore { } } } - } catch { - // metadata table may not exist + } catch (e) { + if (!isTableNotFoundError(e)) throw e; } } @@ -468,6 +471,9 @@ export class VectorStore { const result = db.prepare(`DELETE FROM ${this.tableName} WHERE id = ?`).run(id); this.vectors.delete(id); + this.chunkProjectIndex.delete(id); + this.chunkAgentIndex.delete(id); + this.chunkTeamIndex.delete(id); return result.changes > 0; } @@ -488,6 +494,8 @@ export class VectorStore { for (const id of ids) { this.vectors.delete(id); this.chunkProjectIndex.delete(id); + this.chunkAgentIndex.delete(id); + this.chunkTeamIndex.delete(id); } return result.changes; @@ -597,6 +605,8 @@ export class VectorStore { for (const id of ids) { this.vectors.delete(id); this.chunkProjectIndex.delete(id); + this.chunkAgentIndex.delete(id); + this.chunkTeamIndex.delete(id); } } diff --git a/test/config/loader.test.ts b/test/config/loader.test.ts index d49eefc..6e059e3 100644 --- a/test/config/loader.test.ts +++ b/test/config/loader.test.ts @@ -438,7 +438,7 @@ describe('loadConfig', () => { expect(config.llm.enableLabelling).toBe(false); }); - it('produces NaN for non-numeric strings in integer fields', () => { + it('ignores non-numeric strings in integer fields (keeps default)', () => { process.env.CAUSANTIC_CLUSTERING_MIN_CLUSTER_SIZE = 'abc'; const config = loadConfig({ @@ -446,10 +446,11 @@ describe('loadConfig', () => { skipUserConfig: true, }); - expect(config.clustering.minClusterSize).toBeNaN(); + // Non-numeric values are skipped, default is preserved + expect(config.clustering.minClusterSize).toBe(4); }); - it('produces NaN for non-numeric strings in float fields', () => { + it('ignores non-numeric strings in float fields (keeps default)', () => { process.env.CAUSANTIC_CLUSTERING_THRESHOLD = 'not-a-number'; const config = loadConfig({ @@ -457,7 +458,8 @@ describe('loadConfig', () => { skipUserConfig: true, }); - expect(config.clustering.threshold).toBeNaN(); + // Non-numeric values are skipped, default is preserved + expect(config.clustering.threshold).toBe(0.1); }); }); diff --git a/test/storage/cluster-store.test.ts b/test/storage/cluster-store.test.ts index d667746..d336660 100644 --- a/test/storage/cluster-store.test.ts +++ b/test/storage/cluster-store.test.ts @@ -1009,8 +1009,8 @@ describe('computeMembershipHash', () => { it('handles empty membership', () => { const hash = computeMembershipHash([]); expect(typeof hash).toBe('string'); - // Empty input produces empty base64 string - expect(hash).toBe(''); + // SHA-256 of empty string produces a deterministic 16-char hex prefix + expect(hash.length).toBe(16); }); it('handles single member', () => {