import type { Provider, ProviderConfig, IngestOptions, IngestResult, SearchOptions, IndexingProgressCallback, } from "../../types/provider" import type { UnifiedSession } from "../../types/unified" import { logger } from "../../utils/logger" import { COGMEM_PROMPTS } from "./prompts" const DEFAULT_BASE_URL = "https://hifriendbot.com/wp-json/hifriendbot/v1" /** * CogmemAi provider for MemoryBench — v4. * * Combines the best of v1 (full conversation for broad retrieval) with * v3 (individual user turns for precise fact matching). * * Lessons learned: * - v1: Raw full conversation → 70% Hit@K but only 37.5% single-hop * - v2: LLM extraction → Lost original language, 30% Hit@K, 15% accuracy * - v3: User turns only → 62.5% single-hop but 45% Hit@K (lost assistant keywords) * * v4 strategy (dual storage): * Layer 1: Full conversation chunks (user + assistant) — broad keyword coverage * Layer 2: Individual user turns (>50 chars) — precise fact targets * Both layers: Date-prefixed for temporal reasoning * Recall: 100 results for maximum cross-encoder reranking candidates */ export class CogmemAiProvider implements Provider { name = "cogmemai" prompts = COGMEM_PROMPTS concurrency = { default: 5, ingest: 1, // MUST be 1 — concurrent ingest causes server-side subject dedup to merge chunks/turns search: 5, } private apiKey: string = "" private baseUrl: string = DEFAULT_BASE_URL // Track ingested sessions to avoid re-processing (but allow all sessions of a conversation through) private ingestedSessions = new Set() async initialize(config: ProviderConfig): Promise { this.apiKey = config.apiKey if (config.baseUrl) { this.baseUrl = config.baseUrl.replace(/\/+$/, "") } logger.info(`Initialized CogmemAi provider v4 (${this.baseUrl}) — dual-layer storage`) } private async api(endpoint: string, body: Record): Promise { const url = `${this.baseUrl}/cogmemai/${endpoint}` const maxRetries = 5 for (let attempt = 0; attempt <= maxRetries; attempt++) { const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.apiKey}`, }, body: JSON.stringify(body), }) if (res.ok) { return res.json() } if ((res.status === 429 || res.status >= 500) && attempt < maxRetries) { const delay = Math.min(3000 * Math.pow(2, attempt), 60000) + Math.random() * 3000 await new Promise((r) => setTimeout(r, delay)) continue } const text = await res.text() throw new Error(`CogmemAi API error ${res.status}: ${text}`) } throw new Error("Unreachable") } private async storeMemory( content: string, subject: string, importance: number, projectId: string ): Promise { try { const result = (await this.api("store", { content: content.substring(0, 9500), memory_type: "context", category: "general", subject: subject.substring(0, 100), importance, scope: "project", project_id: projectId, })) as { memory_id?: number } return result.memory_id ? String(result.memory_id) : null } catch (e) { logger.warn(`Store failed for ${subject}: ${e}`) return null } } async ingest(sessions: UnifiedSession[], options: IngestOptions): Promise { const memoryIds: string[] = [] const convId = sessions[0]?.sessionId?.replace(/-session_\d+$/, "") || options.containerTag const projectId = `bench_${convId}` // Skip if already ingested (in-memory) const sessionKey = sessions[0]?.sessionId || options.containerTag if (this.ingestedSessions.has(sessionKey)) { logger.info(`Skipping ${sessionKey} — already ingested`) return { documentIds: ["cached"] } } for (const session of sessions) { const sessionDate = session.metadata?.formattedDate || session.metadata?.date || "" const datePrefix = sessionDate ? `[Date: ${sessionDate}] ` : "" const fullLines: string[] = [] for (const msg of session.messages) { const speaker = msg.speaker || (msg.role === "user" ? "Speaker A" : "Speaker B") fullLines.push(`${speaker}: ${msg.content}`) } // === LAYER 1: Full session verbatim — broad context for complex questions === const fullContent = `${datePrefix}${fullLines.join("\n")}` const id = await this.storeMemory( fullContent, `${convId}_${session.sessionId}`, 8, projectId ) if (id) memoryIds.push(id) // === LAYER 2: Individual turns — precise matching for simple fact recall === let turnIdx = 0 for (const msg of session.messages) { if (msg.content.length < 30) continue turnIdx++ const speaker = msg.speaker || (msg.role === "user" ? "Speaker A" : "Speaker B") const turnContent = `${datePrefix}${speaker}: ${msg.content}` const tid = await this.storeMemory( turnContent, `${convId}_${speaker}_${session.sessionId}_t${turnIdx}`, 8, projectId ) if (tid) memoryIds.push(tid) } } this.ingestedSessions.add(sessionKey) logger.info(`Ingested ${sessions.length} sessions, ${memoryIds.length} memories (full sessions + turns)`) return { documentIds: memoryIds } } async awaitIndexing( _result: IngestResult, _containerTag: string, onProgress?: IndexingProgressCallback ): Promise { logger.info("Pausing 10s to let rate limit window reset before search...") await new Promise((r) => setTimeout(r, 10000)) onProgress?.({ completedIds: _result.documentIds, failedIds: [], total: _result.documentIds.length, }) } async search(query: string, options: SearchOptions): Promise { const limit = options.limit || 50 // Use conversation-level project ID (matches ingest) // containerTag = "conv-26-q3-test-v6-100q" -> convId = "conv-26" const convId = options.containerTag?.match(/^(conv-\d+)/)?.[1] || options.containerTag const projectId = `bench_${convId}` // Single query — keep it simple, let the server-side recall engine do its job const result = (await this.api("recall", { query, limit, project_id: projectId, scope: "project", })) as { memories?: Array> } // Filter out any encrypted results (legacy from previous runs) const memories = (result.memories ?? []).filter( (m) => !String(m.content || "").startsWith("ENC1:") ) return memories } async clear(containerTag: string): Promise { try { let allIds: number[] = [] let page = 1 let hasMore = true while (hasMore) { const result = (await this.api("memories", { project_id: `bench_${containerTag}`, limit: 500, page, })) as { memories?: Array<{ id: number }>; has_more?: boolean } const memories = result.memories ?? [] allIds = allIds.concat(memories.map((m) => m.id)) hasMore = memories.length >= 500 page++ } if (allIds.length === 0) return for (let i = 0; i < allIds.length; i += 100) { await this.api("bulk-delete", { memory_ids: allIds.slice(i, i + 100) }) } logger.info(`Cleared ${allIds.length} memories for bench_${containerTag}`) } catch (e) { logger.warn(`Failed to clear: ${e}`) } } private chunkOnTurnBoundaries(content: string, datePrefix: string, maxChars: number): string[] { if (content.length <= maxChars) return [content] const chunks: string[] = [] const lines = content.split("\n") let current = "" for (const line of lines) { if (current.length + line.length + 1 > maxChars) { if (current) chunks.push(current.trim()) current = datePrefix + line } else { current += (current ? "\n" : "") + line } } if (current.trim()) chunks.push(current.trim()) return chunks } } export default CogmemAiProvider