@@ -64,6 +64,14 @@ export type MemorySearchResult = {
6464 source : "fts" | "vec" ;
6565} ;
6666
67+ type RecallTimings = {
68+ ftsMs : number ;
69+ vecMs : number ;
70+ fuseMs : number ;
71+ dedupeMs : number ;
72+ totalMs : number ;
73+ } ;
74+
6775// =============================================================================
6876// Schema Initialization
6977// =============================================================================
@@ -102,6 +110,7 @@ export function initializeMemoryTables(db: Database): void {
102110
103111 db . exec ( `CREATE INDEX IF NOT EXISTS idx_memory_messages_session ON memory_messages(session_id)` ) ;
104112 db . exec ( `CREATE INDEX IF NOT EXISTS idx_memory_messages_hash ON memory_messages(hash)` ) ;
113+ db . exec ( `CREATE INDEX IF NOT EXISTS idx_memory_sessions_active ON memory_sessions(active, id)` ) ;
105114
106115 // FTS5 for memory search
107116 db . exec ( `
@@ -263,19 +272,26 @@ export async function addMessage(
263272 const now = new Date ( ) . toISOString ( ) ;
264273 const hash = await hashContent ( content ) ;
265274
266- // Ensure session exists (createSession may generate a new ID for "new")
267- let session = getSession ( db , sessionId ) ;
268- if ( ! session ) {
269- session = createSession ( db , sessionId , options . title || "" ) ;
270- } else if ( options . title && ! session . title ) {
271- db . prepare ( `UPDATE memory_sessions SET title = ? WHERE id = ?` ) . run (
272- options . title ,
273- sessionId
274- ) ;
275+ // Preserve "new" behavior, which generates an ID.
276+ let resolvedSessionId = sessionId ;
277+ if ( sessionId === "new" ) {
278+ resolvedSessionId = crypto . randomUUID ( ) . slice ( 0 , 8 ) ;
275279 }
276280
277- // Use the resolved session ID (may differ from input if "new" was passed)
278- const resolvedSessionId = session . id ;
281+ // Ensure session exists without a pre-read on every message.
282+ db . prepare (
283+ `INSERT OR IGNORE INTO memory_sessions (id, title, created_at, updated_at, active)
284+ VALUES (?, ?, ?, ?, 1)`
285+ ) . run ( resolvedSessionId , options . title || "" , now , now ) ;
286+
287+ // If title is provided later, fill it only when current title is empty.
288+ if ( options . title ) {
289+ db . prepare (
290+ `UPDATE memory_sessions
291+ SET title = ?
292+ WHERE id = ? AND (title = '' OR title IS NULL)`
293+ ) . run ( options . title , resolvedSessionId ) ;
294+ }
279295
280296 const metadataStr = options . metadata
281297 ? JSON . stringify ( options . metadata )
@@ -347,24 +363,37 @@ export function searchMemoryFTS(
347363) : MemorySearchResult [ ] {
348364 const ftsQuery = buildMemoryFTS5Query ( query ) ;
349365 if ( ! ftsQuery ) return [ ] ;
366+ const candidateLimit = Math . max ( limit * 3 , limit ) ;
350367
368+ // Rank candidate rowids in FTS first, then join to payload tables.
369+ // This keeps the expensive bm25 ordering on the smallest possible row shape.
351370 const sql = `
371+ WITH ranked AS (
372+ SELECT
373+ rowid,
374+ bm25(memory_fts, 5.0, 1.0, 1.0) as bm25_score
375+ FROM memory_fts
376+ WHERE memory_fts MATCH ?
377+ ORDER BY bm25_score ASC
378+ LIMIT ?
379+ )
352380 SELECT
353381 m.session_id,
354382 s.title as session_title,
355383 m.id as message_id,
356384 m.role,
357385 m.content,
358- bm25(memory_fts, 5.0, 1.0, 1.0) as bm25_score
359- FROM memory_fts f
360- JOIN memory_messages m ON m.id = f .rowid
386+ r. bm25_score
387+ FROM ranked r
388+ JOIN memory_messages m ON m.id = r .rowid
361389 JOIN memory_sessions s ON s.id = m.session_id
362- WHERE memory_fts MATCH ? AND s.active = 1
363- ORDER BY bm25_score ASC
390+ WHERE s.active = 1
391+ ORDER BY r. bm25_score ASC
364392 LIMIT ?
365393 ` ;
366394
367- const rows = db . prepare ( sql ) . all ( ftsQuery , limit ) as {
395+ const stmt = getMemoryFtsStmt ( db , sql ) ;
396+ const rows = stmt . all ( ftsQuery , candidateLimit , limit ) as {
368397 session_id : string ;
369398 session_title : string ;
370399 message_id : number ;
@@ -385,6 +414,17 @@ export function searchMemoryFTS(
385414 } ) ) ;
386415}
387416
417+ const memoryFtsStmtCache = new WeakMap < Database , ReturnType < Database [ "prepare" ] > > ( ) ;
418+
419+ function getMemoryFtsStmt ( db : Database , sql : string ) {
420+ let stmt = memoryFtsStmtCache . get ( db ) ;
421+ if ( ! stmt ) {
422+ stmt = db . prepare ( sql ) ;
423+ memoryFtsStmtCache . set ( db , stmt ) ;
424+ }
425+ return stmt ;
426+ }
427+
388428/**
389429 * Search memory using vector similarity.
390430 * Two-step pattern: query vectors_vec first, then JOIN separately.
@@ -654,16 +694,22 @@ export async function recallMemories(
654694 maxTokens ?: number ;
655695 } = { }
656696) : Promise < { results : MemorySearchResult [ ] ; synthesis ?: string } > {
697+ const startedAt = performance . now ( ) ;
698+ const shouldTraceRecall = process . env . SMRITI_BENCH_TRACE === "1" ;
657699 const limit = options . limit ?? 10 ;
658700
659701 // Run FTS and vector search
702+ const ftsStartedAt = performance . now ( ) ;
660703 const ftsResults = searchMemoryFTS ( db , query , limit ) ;
704+ const ftsMs = performance . now ( ) - ftsStartedAt ;
661705 let vecResults : MemorySearchResult [ ] = [ ] ;
706+ const vecStartedAt = performance . now ( ) ;
662707 try {
663708 vecResults = await searchMemoryVec ( db , query , limit ) ;
664709 } catch {
665710 // Vector search may fail if no embeddings exist
666711 }
712+ const vecMs = performance . now ( ) - vecStartedAt ;
667713
668714 // Convert to RankedResult format for RRF
669715 const toRanked = ( results : MemorySearchResult [ ] ) : RankedResult [ ] =>
@@ -676,24 +722,33 @@ export async function recallMemories(
676722 } ) ) ;
677723
678724 // Fuse results with RRF
725+ const fuseStartedAt = performance . now ( ) ;
679726 const fused = reciprocalRankFusion (
680727 [ toRanked ( ftsResults ) , toRanked ( vecResults ) ] ,
681728 [ 1.0 , 1.0 ]
682729 ) ;
730+ const fuseMs = performance . now ( ) - fuseStartedAt ;
683731
684732 // Deduplicate by session, keeping best score per session
733+ const dedupeStartedAt = performance . now ( ) ;
685734 const sessionSeen = new Map < string , boolean > ( ) ;
686735 const dedupedResults : MemorySearchResult [ ] = [ ] ;
736+ const originalByKey = new Map < string , MemorySearchResult > ( ) ;
737+ for ( const result of ftsResults ) {
738+ originalByKey . set ( `${ result . session_id } :${ result . message_id } ` , result ) ;
739+ }
740+ for ( const result of vecResults ) {
741+ const key = `${ result . session_id } :${ result . message_id } ` ;
742+ // Prefer vector entry if both are present because it typically carries the better semantic score.
743+ originalByKey . set ( key , result ) ;
744+ }
687745
688746 for ( const r of fused ) {
689747 const [ sessionId ] = r . file . split ( ":" ) ;
690748 if ( ! sessionId ) continue ;
691749
692750 // Find the original result to preserve all fields
693- const original =
694- [ ...ftsResults , ...vecResults ] . find (
695- ( o ) => `${ o . session_id } :${ o . message_id } ` === r . file
696- ) || null ;
751+ const original = originalByKey . get ( r . file ) ?? null ;
697752
698753 if ( original && ! sessionSeen . has ( sessionId ) ) {
699754 sessionSeen . set ( sessionId , true ) ;
@@ -711,6 +766,7 @@ export async function recallMemories(
711766 } ) ;
712767 }
713768 }
769+ const dedupeMs = performance . now ( ) - dedupeStartedAt ;
714770
715771 const results = dedupedResults . slice ( 0 , limit ) ;
716772
@@ -730,6 +786,24 @@ export async function recallMemories(
730786 } ) ;
731787 }
732788
789+ if ( shouldTraceRecall ) {
790+ const timings : RecallTimings = {
791+ ftsMs,
792+ vecMs,
793+ fuseMs,
794+ dedupeMs,
795+ totalMs : performance . now ( ) - startedAt ,
796+ } ;
797+ console . error (
798+ `[recall.trace] q="${ query . slice ( 0 , 64 ) } " ` +
799+ `fts=${ timings . ftsMs . toFixed ( 3 ) } ms ` +
800+ `vec=${ timings . vecMs . toFixed ( 3 ) } ms ` +
801+ `fuse=${ timings . fuseMs . toFixed ( 3 ) } ms ` +
802+ `dedupe=${ timings . dedupeMs . toFixed ( 3 ) } ms ` +
803+ `total=${ timings . totalMs . toFixed ( 3 ) } ms`
804+ ) ;
805+ }
806+
733807 return { results, synthesis } ;
734808}
735809
0 commit comments