/** * Recall tracking — Phase 2. * * Lightweight adaptation of upstream * `extensions/memory-core/src/short-term-promotion.ts::recordShortTermRecalls`. * Upstream stores recalls in a JSON file under `memory/.dreams/`; we store * them in a Postgres table `agent_memory_recalls`. Schema captures what the * future Phase 3 dreaming/promotion algorithm will need: * - which memory row was surfaced (`memory_id`) * - the query that surfaced it (raw + SHA-256 hash for dedupe) * - hybrid score at the time of recall * - the day bucket (for per-day dedupe / frequency counting) * - the recorded timestamp * * Best-effort: failures never block memory_search. The caller fires * {@link RecallTracker.record} without awaiting the result and ignores errors. */ import { createHash } from 'crypto'; import type { Pool } from 'pg'; export interface RecallTracker { /** Record that the given memory ids were surfaced to the model for a query. */ record(params: RecallRecordParams): Promise; /** Backend-specific schema migration. Idempotent. */ migrate(): Promise; } export interface RecallRecordParams { agentId: string; query: string; hits: Array<{ id: string; path: string; score: number }>; nowMs?: number; } export const RECALL_TABLE = 'agent_memory_recalls'; function hashQuery(query: string): string { return createHash('sha256') .update(query.trim().toLowerCase()) .digest('hex') .slice(0, 32); } function dayBucket(nowMs: number): string { const d = new Date(nowMs); const y = d.getUTCFullYear(); const m = String(d.getUTCMonth() + 1).padStart(2, '0'); const day = String(d.getUTCDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } export class PgvectorRecallTracker implements RecallTracker { constructor( private readonly pool: Pool, private readonly table: string = RECALL_TABLE ) {} async migrate(): Promise { // [recall-tracking] debug: create table + indexes if missing await this.pool.query(` CREATE TABLE IF NOT EXISTS ${this.table} ( id BIGSERIAL PRIMARY KEY, agent_id TEXT NOT NULL, memory_id TEXT NOT NULL, memory_path TEXT NOT NULL, query TEXT NOT NULL, query_hash TEXT NOT NULL, score DOUBLE PRECISION NOT NULL, day_bucket TEXT NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await this.pool.query( `CREATE INDEX IF NOT EXISTS ${this.table}_agent_day_idx ON ${this.table} (agent_id, day_bucket)` ); await this.pool.query( `CREATE INDEX IF NOT EXISTS ${this.table}_memory_idx ON ${this.table} (agent_id, memory_id)` ); await this.pool.query( `CREATE UNIQUE INDEX IF NOT EXISTS ${this.table}_dedupe_idx ON ${this.table} (agent_id, memory_id, query_hash, day_bucket)` ); } async record(params: RecallRecordParams): Promise { if (!params.agentId || !params.query.trim() || params.hits.length === 0) return; const nowMs = params.nowMs ?? Date.now(); const qhash = hashQuery(params.query); const bucket = dayBucket(nowMs); // [recall-tracking] debug: upsert one row per (agent, memory, query, day) // Upstream dedupes per-day per-query so repeated searches don't inflate counts. const values: string[] = []; const args: unknown[] = []; let i = 1; for (const hit of params.hits) { values.push( `($${i++}, $${i++}, $${i++}, $${i++}, $${i++}, $${i++}, $${i++}, NOW())` ); args.push( params.agentId, hit.id, hit.path, params.query, qhash, hit.score, bucket ); } const sql = ` INSERT INTO ${this.table} (agent_id, memory_id, memory_path, query, query_hash, score, day_bucket, recorded_at) VALUES ${values.join(', ')} ON CONFLICT (agent_id, memory_id, query_hash, day_bucket) DO UPDATE SET score = GREATEST(${this.table}.score, EXCLUDED.score), recorded_at = NOW() `; await this.pool.query(sql, args); } } /** No-op tracker — used when recall tracking is disabled or the backend isn't pgvector. */ export class NullRecallTracker implements RecallTracker { async record(): Promise {} async migrate(): Promise {} }