{"version":3,"file":"session-recall.mjs","names":[],"sources":["../../../src/services/session-recall.ts"],"sourcesContent":["/**\n * Session Recall Service — full-text search over past conversations.\n *\n * Indexes conversation turns to JSONL on disk and provides in-memory\n * full-text search with relevance ranking. No external dependencies\n * (no SQLite — uses JSONL + in-memory search to match the codebase's\n * file-based persistence pattern).\n *\n * How it works:\n *   1. message_received and after_tool_call hooks feed messages in\n *   2. Messages are appended to ~/.openclawnch/recall/sessions.jsonl\n *   3. On startup, the index is loaded into memory\n *   4. Search queries tokenize and match against the index\n *   5. Results are grouped by session with context windows\n *\n * The in-memory approach works well for single-agent deployments with\n * thousands of conversations. For multi-agent or very large histories,\n * this can be upgraded to SQLite FTS5 later.\n *\n * Inspired by Hermes Agent's session_search_tool.py.\n */\n\nimport { existsSync, readFileSync, appendFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';\nimport { join } from 'node:path';\n\n// ─── Types ───────────────────────────────────────────────────────────────\n\nexport interface RecallEntry {\n  /** Monotonically increasing sequence number. */\n  seq: number;\n  /** Session identifier (e.g., \"telegram-123456789\"). */\n  sessionKey: string;\n  /** Message role: user, assistant, tool, system. */\n  role: 'user' | 'assistant' | 'tool' | 'system';\n  /** Message content (truncated to MAX_ENTRY_CHARS). */\n  content: string;\n  /** Tool name (if role is 'tool'). */\n  toolName?: string;\n  /** User ID. */\n  userId?: string;\n  /** Timestamp in ms. */\n  timestamp: number;\n}\n\nexport interface RecallSearchResult {\n  /** Session key that had matches. */\n  sessionKey: string;\n  /** Matching entries from this session. */\n  matches: RecallEntry[];\n  /** Relevance score (higher = more relevant). */\n  score: number;\n  /** Earliest timestamp in matched entries. */\n  earliestTimestamp: number;\n  /** Latest timestamp in matched entries. */\n  latestTimestamp: number;\n}\n\nexport interface RecallConfig {\n  /** Base directory. Default: ~/.openclawnch/recall/ */\n  baseDir?: string;\n  /** Max characters per entry content. Default: 2000. */\n  maxEntryChars?: number;\n  /** Max entries to keep in memory. Default: 50000. */\n  maxEntries?: number;\n  /** Max search results to return. Default: 5. */\n  maxResults?: number;\n}\n\n// ─── Constants ───────────────────────────────────────────────────────────\n\nconst DEFAULT_MAX_ENTRY_CHARS = 2000;\nconst DEFAULT_MAX_ENTRIES = 50_000;\nconst DEFAULT_MAX_RESULTS = 5;\n\n// ─── Full-Text Search (simple TF-based) ──────────────────────────────────\n\nfunction tokenize(text: string): string[] {\n  return text\n    .toLowerCase()\n    .replace(/[^\\w\\s]/g, ' ')\n    .split(/\\s+/)\n    .filter(t => t.length > 2); // Skip very short tokens\n}\n\nfunction computeRelevance(entryTokens: string[], queryTokens: string[]): number {\n  let score = 0;\n  const entrySet = new Set(entryTokens);\n\n  for (const qt of queryTokens) {\n    if (entrySet.has(qt)) {\n      score += 1;\n    }\n    // Partial match bonus (prefix)\n    for (const et of entryTokens) {\n      if (et.startsWith(qt) && et !== qt) {\n        score += 0.5;\n      }\n    }\n  }\n\n  // Normalize by query length to prevent bias toward longer queries\n  return queryTokens.length > 0 ? score / queryTokens.length : 0;\n}\n\n// ─── Session Recall Service ──────────────────────────────────────────────\n\nclass SessionRecallService {\n  private config: Required<RecallConfig>;\n  private entries: RecallEntry[] = [];\n  private nextSeq = 1;\n  private loaded = false;\n\n  constructor(config: RecallConfig = {}) {\n    this.config = {\n      baseDir: config.baseDir ?? join(\n        process.env.HOME ?? '/tmp', '.openclawnch', 'recall',\n      ),\n      maxEntryChars: config.maxEntryChars ?? DEFAULT_MAX_ENTRY_CHARS,\n      maxEntries: config.maxEntries ?? DEFAULT_MAX_ENTRIES,\n      maxResults: config.maxResults ?? DEFAULT_MAX_RESULTS,\n    };\n  }\n\n  // ── Indexing ───────────────────────────────────────────────────────\n\n  /**\n   * Record a conversation turn for future recall.\n   */\n  recordTurn(entry: Omit<RecallEntry, 'seq'>): void {\n    this.ensureLoaded();\n\n    const record: RecallEntry = {\n      ...entry,\n      content: entry.content.slice(0, this.config.maxEntryChars),\n      seq: this.nextSeq++,\n    };\n\n    this.entries.push(record);\n    this.appendToDisk(record);\n\n    // Evict old entries if over limit\n    if (this.entries.length > this.config.maxEntries) {\n      const excess = this.entries.length - this.config.maxEntries;\n      this.entries.splice(0, excess);\n    }\n  }\n\n  // ── Search ─────────────────────────────────────────────────────────\n\n  /**\n   * Search past conversations for relevant context.\n   * Returns sessions ranked by relevance.\n   */\n  search(query: string, maxResults?: number): RecallSearchResult[] {\n    this.ensureLoaded();\n\n    const limit = maxResults ?? this.config.maxResults;\n    const queryTokens = tokenize(query);\n\n    if (queryTokens.length === 0) return [];\n\n    // Score each entry\n    const scored: Array<{ entry: RecallEntry; score: number }> = [];\n\n    for (const entry of this.entries) {\n      const entryTokens = tokenize(entry.content);\n      const score = computeRelevance(entryTokens, queryTokens);\n      if (score > 0) {\n        scored.push({ entry, score });\n      }\n    }\n\n    // Group by session\n    const sessionScores = new Map<string, { entries: RecallEntry[]; totalScore: number }>();\n\n    for (const { entry, score } of scored) {\n      let session = sessionScores.get(entry.sessionKey);\n      if (!session) {\n        session = { entries: [], totalScore: 0 };\n        sessionScores.set(entry.sessionKey, session);\n      }\n      session.entries.push(entry);\n      session.totalScore += score;\n    }\n\n    // Build results, sorted by total score descending\n    const results: RecallSearchResult[] = [];\n\n    for (const [sessionKey, session] of sessionScores) {\n      // Keep top 10 matching entries per session (most relevant)\n      session.entries.sort((a, b) => {\n        const scoreA = computeRelevance(tokenize(a.content), queryTokens);\n        const scoreB = computeRelevance(tokenize(b.content), queryTokens);\n        return scoreB - scoreA;\n      });\n      const topEntries = session.entries.slice(0, 10);\n\n      const timestamps = topEntries.map(e => e.timestamp);\n      results.push({\n        sessionKey,\n        matches: topEntries,\n        score: session.totalScore,\n        earliestTimestamp: Math.min(...timestamps),\n        latestTimestamp: Math.max(...timestamps),\n      });\n    }\n\n    results.sort((a, b) => b.score - a.score);\n    return results.slice(0, limit);\n  }\n\n  // ── Persistence ────────────────────────────────────────────────────\n\n  private getFilePath(): string {\n    return join(this.config.baseDir, 'sessions.jsonl');\n  }\n\n  private ensureLoaded(): void {\n    if (this.loaded) return;\n    this.loaded = true;\n\n    try {\n      const filePath = this.getFilePath();\n      if (!existsSync(filePath)) return;\n\n      const content = readFileSync(filePath, 'utf8');\n      const lines = content.split('\\n').filter(Boolean);\n\n      for (const line of lines) {\n        try {\n          const entry = JSON.parse(line) as RecallEntry;\n          this.entries.push(entry);\n          if (entry.seq >= this.nextSeq) {\n            this.nextSeq = entry.seq + 1;\n          }\n        } catch {\n          // Skip malformed lines\n        }\n      }\n\n      // Evict old entries if over limit\n      if (this.entries.length > this.config.maxEntries) {\n        const excess = this.entries.length - this.config.maxEntries;\n        this.entries.splice(0, excess);\n      }\n    } catch {\n      // Failed to load — start fresh\n    }\n  }\n\n  private diskWrites = 0;\n  private static readonly COMPACT_INTERVAL = 500; // check every N writes\n  private static readonly MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB\n\n  private appendToDisk(entry: RecallEntry): void {\n    try {\n      const dir = this.config.baseDir;\n      if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n      appendFileSync(this.getFilePath(), JSON.stringify(entry) + '\\n', 'utf8');\n      this.diskWrites++;\n\n      // Periodically check if the file needs compaction\n      if (this.diskWrites % SessionRecallService.COMPACT_INTERVAL === 0) {\n        this.maybeCompact();\n      }\n    } catch {\n      // Best effort\n    }\n  }\n\n  /**\n   * Compact the JSONL file by rewriting it with only the in-memory entries.\n   * Prevents unbounded disk growth — the file is capped at maxEntries lines.\n   */\n  private maybeCompact(): void {\n    try {\n      const filePath = this.getFilePath();\n      if (!existsSync(filePath)) return;\n\n      const stat = statSync(filePath);\n      if (stat.size < SessionRecallService.MAX_FILE_SIZE_BYTES) return;\n\n      // Rewrite with only the entries we have in memory (already evicted to maxEntries)\n      const compacted = this.entries.map(e => JSON.stringify(e)).join('\\n') + '\\n';\n      writeFileSync(filePath, compacted, 'utf8');\n    } catch {\n      // Best effort — don't crash on compaction failure\n    }\n  }\n\n  // ── Diagnostics ────────────────────────────────────────────────────\n\n  getStats(): {\n    totalEntries: number;\n    uniqueSessions: number;\n    oldestTimestamp: number;\n    newestTimestamp: number;\n  } {\n    this.ensureLoaded();\n\n    const sessions = new Set(this.entries.map(e => e.sessionKey));\n    const timestamps = this.entries.map(e => e.timestamp);\n\n    return {\n      totalEntries: this.entries.length,\n      uniqueSessions: sessions.size,\n      oldestTimestamp: timestamps.length > 0 ? Math.min(...timestamps) : 0,\n      newestTimestamp: timestamps.length > 0 ? Math.max(...timestamps) : 0,\n    };\n  }\n}\n\n// ─── Singleton ───────────────────────────────────────────────────────────\n\nlet _instance: SessionRecallService | null = null;\n\nexport function getSessionRecall(config?: RecallConfig): SessionRecallService {\n  if (!_instance) {\n    _instance = new SessionRecallService(config);\n  }\n  return _instance;\n}\n\nexport function resetSessionRecall(): void {\n  _instance = null;\n}\n"],"mappings":"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"}