import type { MoltbotPluginAPI, PluginConfig, PluginHookAgentContext, MemoryResult, RetainRequest } from './types.js'; import { HindsightEmbedManager } from './embed-manager.js'; import { HindsightClient, type HindsightClientOptions } from './client.js'; import { RetainQueue } from './retain-queue.js'; import { compileSessionPatterns, matchesSessionPattern } from './session-patterns.js'; import { createHash } from 'crypto'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import * as log from './logger.js'; import { configureLogger, setApiLogger, stopLogger } from './logger.js'; import { mkdirSync } from 'fs'; import { homedir } from 'os'; // Debug logging: silent by default, enable with debug: true or logLevel: 'debug' let debugEnabled = false; const debug = (...args: unknown[]) => { if (debugEnabled) log.verbose(args.map(a => typeof a === 'string' ? a.replace(/^\[Hindsight\]\s*/, '') : String(a)).join(' ')); }; // Module-level state let embedManager: HindsightEmbedManager | null = null; let client: HindsightClient | null = null; let clientOptions: HindsightClientOptions | null = null; let initPromise: Promise | null = null; let isInitialized = false; let usingExternalApi = false; // Track if using external API (skip daemon management) // Store the current plugin config for bank ID derivation let currentPluginConfig: PluginConfig | null = null; // Track which banks have had their mission set (to avoid re-setting on every request) const banksWithMissionSet = new Set(); // Use dedicated client instances per bank to avoid cross-session bankId mutation races. const clientsByBankId = new Map(); const MAX_TRACKED_BANK_CLIENTS = 10_000; // In-flight recall deduplication: concurrent recalls for the same bank reuse one promise import type { RecallResponse } from './types.js'; const inflightRecalls = new Map>(); const turnCountBySession = new Map(); const MAX_TRACKED_SESSIONS = 10_000; const DEFAULT_RECALL_TIMEOUT_MS = 10_000; // Cache sender IDs discovered in before_prompt_build (where event.prompt has the metadata // blocks) so agent_end can look them up — event.messages in agent_end is clean history. const senderIdBySession = new Map(); const documentSequenceBySession = new Map(); // Guard against duplicate hook registration within a single runtime load. // Do not tie this to api instance identity, which can be brittle across loader phases. let hooksRegistered = false; // Cooldown + guard to prevent concurrent reinit attempts let lastReinitAttempt = 0; let isReinitInProgress = false; const REINIT_COOLDOWN_MS = 30_000; // Retain queue (external API mode only) let retainQueue: RetainQueue | null = null; let retainQueueFlushTimer: ReturnType | null = null; let isFlushInProgress = false; const DEFAULT_FLUSH_INTERVAL_MS = 60_000; // 1 min /** * Attempt to flush pending retains from the queue. * Each item is sent exactly as it would have been originally — same bank, payload, metadata. */ async function flushRetainQueue(): Promise { if (!retainQueue || isFlushInProgress) return; const pending = retainQueue.size(); if (pending === 0) return; isFlushInProgress = true; let flushed = 0; let failed = 0; try { if (!clientOptions) return; // no client config — can't flush // Cleanup expired items first retainQueue.cleanup(); const items = retainQueue.peek(50); const flushedIds: string[] = []; for (const item of items) { try { let bankClient = clientsByBankId.get(item.bankId); if (!bankClient) { bankClient = new HindsightClient(clientOptions); bankClient.setBankId(item.bankId); clientsByBankId.set(item.bankId, bankClient); } await bankClient.retain({ content: item.content, document_id: item.documentId, metadata: item.metadata, }); flushedIds.push(item.id); flushed++; } catch { // API still down — stop trying this batch failed++; break; } } if (flushedIds.length > 0) retainQueue.removeMany(flushedIds); const remaining = retainQueue.size(); if (flushed > 0) { log.info(`queue flush: ${flushed} queued retains delivered${remaining > 0 ? `, ${remaining} still pending` : ', queue empty'}`); } else if (failed > 0) { debug(`[Hindsight] Queue flush: API still unreachable, ${remaining} retains pending`); } } finally { isFlushInProgress = false; } } const DEFAULT_RECALL_PROMPT_PREAMBLE = 'Relevant memories from past conversations (prioritize recent when conflicting). Only use memories that are directly useful to continue this conversation; ignore the rest:'; function formatCurrentTimeForRecall(date = new Date()): string { const year = date.getUTCFullYear(); const month = String(date.getUTCMonth() + 1).padStart(2, '0'); const day = String(date.getUTCDate()).padStart(2, '0'); const hours = String(date.getUTCHours()).padStart(2, '0'); const minutes = String(date.getUTCMinutes()).padStart(2, '0'); return `${year}-${month}-${day} ${hours}:${minutes}`; } /** * Lazy re-initialization after startup failure. * Called by waitForReady when initPromise rejected but API may now be reachable. * Throttled to one attempt per 30s to avoid hammering a down service. * Only works if initialization was attempted at least once (isInitialized guard). */ async function lazyReinit(configOverride?: PluginConfig): Promise { const now = Date.now(); if (now - lastReinitAttempt < REINIT_COOLDOWN_MS || isReinitInProgress) { return; } const config = configOverride ?? currentPluginConfig; if (!config) { debug('[Hindsight] lazyReinit skipped - no plugin config available'); return; } // Persist config if we only have it from the live hook registration path. currentPluginConfig = config; isReinitInProgress = true; lastReinitAttempt = now; const externalApi = detectExternalApi(config); if (!externalApi.apiUrl) { isReinitInProgress = false; return; // Only external API mode supports lazy reinit } debug('[Hindsight] Attempting lazy re-initialization...'); try { await checkExternalApiHealth(externalApi.apiUrl, externalApi.apiToken); // Health check passed — set up env vars and create client process.env.HINDSIGHT_EMBED_API_URL = externalApi.apiUrl; if (externalApi.apiToken) { process.env.HINDSIGHT_EMBED_API_TOKEN = externalApi.apiToken; } const llmConfig = detectLLMConfig(config); clientOptions = buildClientOptions(llmConfig, config, externalApi); clientsByBankId.clear(); banksWithMissionSet.clear(); client = new HindsightClient(clientOptions); const defaultBankId = deriveBankId(undefined, config); client.setBankId(defaultBankId); if (config.bankMission && usesStaticBank(config)) { await client.setBankMission(config.bankMission); } usingExternalApi = true; isInitialized = true; // Replace the rejected initPromise with a resolved one initPromise = Promise.resolve(); debug('[Hindsight] ✓ Lazy re-initialization succeeded'); } catch (error) { log.warn(`lazy re-init failed (retry in ${REINIT_COOLDOWN_MS / 1000}s): ${error instanceof Error ? error.message : error}`); } finally { isReinitInProgress = false; } } // Global access for hooks (Moltbot loads hooks separately) if (typeof global !== 'undefined') { (global as any).__hindsightClient = { getClient: () => client, waitForReady: async () => { if (isInitialized) {return;} // If initPromise is null, it means service.start() hasn't been called yet // (CLI mode, not gateway mode). Hooks should gracefully no-op. if (!initPromise) { if (currentPluginConfig) { log.warn('waitForReady called before service.start() — attempting lazy initialization fallback'); await lazyReinit(currentPluginConfig); return; } log.warn('waitForReady called before service.start() — hooks will no-op (expected in CLI mode)'); return; } try { await initPromise; } catch { // Init failed (e.g., health check timeout at startup). // Attempt lazy re-initialization so Hindsight recovers // once the API becomes reachable again. if (!isInitialized) { await lazyReinit(); } } }, /** * Get a client configured for a specific agent context. * Derives the bank ID from the context for per-channel isolation. * Also ensures the bank mission is set on first use. */ getClientForContext: async (ctx: PluginHookAgentContext | undefined) => { if (!client) {return null;} const config = currentPluginConfig || {}; if (usesStaticBank(config)) { return client; } const bankId = deriveBankId(ctx, config); let bankClient = clientsByBankId.get(bankId); if (!bankClient) { if (!clientOptions) { return null; } bankClient = new HindsightClient(clientOptions); bankClient.setBankId(bankId); clientsByBankId.set(bankId, bankClient); if (clientsByBankId.size > MAX_TRACKED_BANK_CLIENTS) { const oldestKey = clientsByBankId.keys().next().value; if (oldestKey) { clientsByBankId.delete(oldestKey); banksWithMissionSet.delete(oldestKey); } } } // Set bank mission on first use of this bank (if configured) if (config.bankMission && !usesStaticBank(config) && !banksWithMissionSet.has(bankId)) { try { await bankClient.setBankMission(config.bankMission); banksWithMissionSet.add(bankId); debug(`[Hindsight] Set mission for new bank: ${bankId}`); } catch (error) { // Log but don't fail - bank mission is not critical log.warn(`could not set bank mission for ${bankId}: ${error}`); } } return bankClient; }, getPluginConfig: () => currentPluginConfig, }; } // Get directory of current module const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Default bank name (fallback when channel context not available) const DEFAULT_BANK_NAME = 'openclaw'; function getConfiguredBankId(pluginConfig: PluginConfig): string | undefined { if (typeof pluginConfig.bankId !== 'string') { return undefined; } const trimmed = pluginConfig.bankId.trim(); return trimmed.length > 0 ? trimmed : undefined; } function usesStaticBank(pluginConfig: PluginConfig): boolean { return pluginConfig.dynamicBankId === false; } function getDefaultBankId(pluginConfig: PluginConfig): string { return pluginConfig.bankIdPrefix ? `${pluginConfig.bankIdPrefix}-${DEFAULT_BANK_NAME}` : DEFAULT_BANK_NAME; } function getStaticBankId(pluginConfig: PluginConfig): string { const configuredBankId = getConfiguredBankId(pluginConfig); const baseBankId = configuredBankId || DEFAULT_BANK_NAME; return pluginConfig.bankIdPrefix ? `${pluginConfig.bankIdPrefix}-${baseBankId}` : baseBankId; } /** * Strip plugin-injected memory tags from content to prevent retain feedback loop. * Removes and blocks that were injected * during before_agent_start so they don't get re-stored into the memory bank. */ export function stripMemoryTags(content: string): string { content = content.replace(/[\s\S]*?<\/hindsight_memories>/g, ''); content = content.replace(/[\s\S]*?<\/relevant_memories>/g, ''); return content; } /** * Extract sender_id from OpenClaw's injected inbound metadata blocks. * Checks both "Conversation info (untrusted metadata)" and "Sender (untrusted metadata)" blocks. * Returns the first sender_id / id string found, or undefined if none. */ export function extractSenderIdFromText(text: string): string | undefined { if (!text) return undefined; const metaBlockRe = /[\w\s]+\(untrusted metadata\)[^\n]*\n```json\n([\s\S]*?)\n```/gi; let match: RegExpExecArray | null; while ((match = metaBlockRe.exec(text)) !== null) { try { const obj = JSON.parse(match[1]); const id = obj?.sender_id ?? obj?.id; if (id && typeof id === 'string') return id; } catch { // continue to next block } } return undefined; } /** * Strip OpenClaw sender/conversation metadata envelopes from message content. * These blocks are injected by OpenClaw but are noise for memory storage and recall. */ export function stripMetadataEnvelopes(content: string): string { // Strip: ---\n