fleet-memory/hindsight-integrations/openclaw/src/index.ts
YUAN TIANJIAN 72fd3d59db
feat(openclaw): add config-aware history backfill CLI (#878)
* Add OpenClaw history backfill CLI

* Fix backfill resume and local daemon behavior

* Fix backfill checkpoint finalization semantics

* Fix symlinked backfill CLI entrypoint detection

* fix(ci): skip PR status write for fork approvals

---------

Co-authored-by: Nicolò Boschi <boschi1997@gmail.com>
2026-04-09 10:44:19 +02:00

1706 lines
69 KiB
TypeScript

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<void> | 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<string>();
// Use dedicated client instances per bank to avoid cross-session bankId mutation races.
const clientsByBankId = new Map<string, HindsightClient>();
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<string, Promise<RecallResponse>>();
const turnCountBySession = new Map<string, number>();
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<string, string>();
const documentSequenceBySession = new Map<string, number>();
// 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<typeof setInterval> | 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<void> {
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<void> {
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 <hindsight_memories> and <relevant_memories> 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(/<hindsight_memories>[\s\S]*?<\/hindsight_memories>/g, '');
content = content.replace(/<relevant_memories>[\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<Label> (untrusted metadata):\n```json\n{...}\n```\n<message>\n---
content = content.replace(/^---\n[\w\s]+\(untrusted metadata\)[^\n]*\n```json[\s\S]*?```\n\n?/im, '').replace(/\n---$/, '');
// Strip: <Label> (untrusted metadata):\n```json\n{...}\n``` (without --- wrapper)
content = content.replace(/[\w\s]+\(untrusted metadata\)[^\n]*\n```json[\s\S]*?```\n?/gim, '');
return content.trim();
}
/**
* Extract a recall query from a hook event's rawMessage or prompt.
*
* Prefers rawMessage (clean user text). Falls back to prompt, stripping
* envelope formatting (System: lines, [Channel ...] headers, [from: X] footers).
*
* Returns null when no usable query (< 5 chars) can be extracted.
*/
export function extractRecallQuery(
rawMessage: string | undefined,
prompt: string | undefined,
): string | null {
// Reject known metadata/system message patterns — these are not user queries
const METADATA_PATTERNS = [
/^\s*conversation info\s*\(untrusted metadata\)/i,
/^\s*\(untrusted metadata\)/i,
/^\s*system:/i,
];
const isMetadata = (s: string) => METADATA_PATTERNS.some(p => p.test(s));
let recallQuery = rawMessage;
// Strip sender metadata envelope before any checks
if (recallQuery) {
recallQuery = stripMetadataEnvelopes(recallQuery);
}
if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.trim().length < 5 || isMetadata(recallQuery)) {
recallQuery = prompt;
// Strip metadata envelopes from prompt too, then check if anything useful remains
if (recallQuery) {
recallQuery = stripMetadataEnvelopes(recallQuery);
}
if (!recallQuery || recallQuery.length < 5) {
return null;
}
// Strip envelope-formatted prompts from any channel
let cleaned = recallQuery;
// Remove leading "System: ..." lines (from prependSystemEvents)
cleaned = cleaned.replace(/^(?:System:.*\n)+\n?/, '');
// Remove session abort hint
cleaned = cleaned.replace(
/^Note: The previous agent run was aborted[^\n]*\n\n/,
'',
);
// Extract message after [ChannelName ...] envelope header
const envelopeMatch = cleaned.match(
/\[[A-Z][A-Za-z]*(?:\s[^\]]+)?\]\s*([\s\S]+)$/,
);
if (envelopeMatch) {
cleaned = envelopeMatch[1];
}
// Remove trailing [from: SenderName] metadata (group chats)
cleaned = cleaned.replace(/\n\[from:[^\]]*\]\s*$/, '');
// Strip metadata envelopes again after channel envelope extraction, in case
// the metadata block appeared after the [ChannelName] header
cleaned = stripMetadataEnvelopes(cleaned);
recallQuery = cleaned.trim() || recallQuery;
}
const trimmed = recallQuery.trim();
if (trimmed.length < 5 || isMetadata(trimmed)) return null;
return trimmed;
}
export function composeRecallQuery(
latestQuery: string,
messages: any[] | undefined,
recallContextTurns: number,
recallRoles: Array<'user' | 'assistant' | 'system' | 'tool'> = ['user', 'assistant'],
): string {
const latest = latestQuery.trim();
if (recallContextTurns <= 1 || !Array.isArray(messages) || messages.length === 0) {
return latest;
}
const allowedRoles = new Set(recallRoles);
const contextualMessages = sliceLastTurnsByUserBoundary(messages, recallContextTurns);
const contextLines = contextualMessages
.map((msg: any) => {
const role = msg?.role;
if (!allowedRoles.has(role)) {
return null;
}
let content = '';
if (typeof msg?.content === 'string') {
content = msg.content;
} else if (Array.isArray(msg?.content)) {
content = msg.content
.filter((block: any) => block?.type === 'text' && typeof block?.text === 'string')
.map((block: any) => block.text)
.join('\n');
}
content = stripMemoryTags(content).trim();
content = stripMetadataEnvelopes(content);
if (!content) {
return null;
}
if (role === 'user' && content === latest) {
return null;
}
return `${role}: ${content}`;
})
.filter((line: string | null): line is string => Boolean(line));
if (contextLines.length === 0) {
return latest;
}
return [
'Prior context:',
contextLines.join('\n'),
latest,
].join('\n\n');
}
export function truncateRecallQuery(query: string, latestQuery: string, maxChars: number): string {
if (maxChars <= 0) {
return query;
}
const latest = latestQuery.trim();
if (query.length <= maxChars) {
return query;
}
const latestOnly = latest.length <= maxChars ? latest : latest.slice(0, maxChars);
if (!query.includes('Prior context:')) {
return latestOnly;
}
// New order: Prior context at top, latest user message at bottom.
// Truncate by dropping oldest context lines first to preserve the suffix.
const contextMarker = 'Prior context:\n\n';
const markerIndex = query.indexOf(contextMarker);
if (markerIndex === -1) {
return latestOnly;
}
const suffixMarker = '\n\n' + latest;
const suffixIndex = query.lastIndexOf(suffixMarker);
if (suffixIndex === -1) {
return latestOnly;
}
const suffix = query.slice(suffixIndex); // \n\n<latest>
if (suffix.length >= maxChars) {
return latestOnly;
}
const contextBody = query.slice(markerIndex + contextMarker.length, suffixIndex);
const contextLines = contextBody.split('\n').filter(Boolean);
const keptContextLines: string[] = [];
// Add context lines from newest (bottom) to oldest (top), stopping when we exceed maxChars
for (let i = contextLines.length - 1; i >= 0; i--) {
keptContextLines.unshift(contextLines[i]);
const candidate = `${contextMarker}${keptContextLines.join('\n')}${suffix}`;
if (candidate.length > maxChars) {
keptContextLines.shift();
break;
}
}
if (keptContextLines.length > 0) {
return `${contextMarker}${keptContextLines.join('\n')}${suffix}`;
}
return latestOnly;
}
/**
* Derive a bank ID from the agent context.
* Uses configurable dynamicBankGranularity to determine bank segmentation.
* Falls back to default bank when context is unavailable.
*/
/**
* Parse the OpenClaw sessionKey to extract context fields.
* Format: "agent:{agentId}:{provider}:{channelType}:{channelId}[:{extra}]"
* Example: "agent:c0der:telegram:group:-1003825475854:topic:42"
*/
function parseSessionKey(sessionKey: string): { agentId?: string; provider?: string; channel?: string } {
const parts = sessionKey.split(':');
if (parts.length < 5 || parts[0] !== 'agent') return {};
// parts[1] = agentId, parts[2] = provider, parts[3] = channelType, parts[4..] = channelId + extras
return {
agentId: parts[1],
provider: parts[2],
// Rejoin from channelType onward as the channel identifier (e.g. "group:-1003825475854:topic:42")
channel: parts.slice(3).join(':'),
};
}
export function deriveBankId(ctx: PluginHookAgentContext | undefined, pluginConfig: PluginConfig): string {
if (pluginConfig.dynamicBankId === false) {
return getStaticBankId(pluginConfig);
}
// When no context is available, fall back to the static default bank.
if (!ctx) {
return getDefaultBankId(pluginConfig);
}
const fields = pluginConfig.dynamicBankGranularity?.length ? pluginConfig.dynamicBankGranularity : ['agent', 'channel', 'user'];
// Validate field names at runtime — typos silently produce 'unknown' segments
const validFields = new Set(['agent', 'channel', 'user', 'provider']);
for (const f of fields) {
if (!validFields.has(f)) {
log.warn(`unknown dynamicBankGranularity field "${f}" — will resolve to "unknown". Valid: agent, channel, user, provider`);
}
}
// Parse sessionKey as fallback when direct context fields are missing
const sessionParsed = ctx?.sessionKey ? parseSessionKey(ctx.sessionKey) : {};
// Warn when 'user' is in active fields but senderId is missing — bank ID will contain "anonymous"
if (fields.includes('user') && ctx && !ctx.senderId) {
debug('[Hindsight] senderId not available in context — bank ID will use "anonymous". Ensure your OpenClaw provider passes senderId.');
}
const fieldMap: Record<string, string> = {
agent: ctx?.agentId || sessionParsed.agentId || 'default',
channel: ctx?.channelId || sessionParsed.channel || 'unknown',
user: ctx?.senderId || 'anonymous',
provider: ctx?.messageProvider || sessionParsed.provider || 'unknown',
};
const baseBankId = fields
.map(f => encodeURIComponent(fieldMap[f] || 'unknown'))
.join('::');
return pluginConfig.bankIdPrefix
? `${pluginConfig.bankIdPrefix}-${baseBankId}`
: baseBankId;
}
export function formatMemories(results: MemoryResult[]): string {
if (!results || results.length === 0) return '';
return results
.map(r => {
const type = r.type ? ` [${r.type}]` : '';
const date = r.mentioned_at ? ` (${r.mentioned_at})` : '';
return `- ${r.text}${type}${date}`;
})
.join('\n\n');
}
// Provider detection from standard env vars
const PROVIDER_DETECTION = [
{ name: 'openai', keyEnv: 'OPENAI_API_KEY' },
{ name: 'anthropic', keyEnv: 'ANTHROPIC_API_KEY' },
{ name: 'gemini', keyEnv: 'GEMINI_API_KEY' },
{ name: 'groq', keyEnv: 'GROQ_API_KEY' },
{ name: 'ollama', keyEnv: '' },
{ name: 'openai-codex', keyEnv: '' },
{ name: 'claude-code', keyEnv: '' },
];
export function detectLLMConfig(pluginConfig?: PluginConfig): {
provider?: string;
apiKey?: string;
model?: string;
baseUrl?: string;
source: string;
} {
// Override values from HINDSIGHT_API_LLM_* env vars (highest priority)
const overrideProvider = process.env.HINDSIGHT_API_LLM_PROVIDER;
const overrideModel = process.env.HINDSIGHT_API_LLM_MODEL;
const overrideKey = process.env.HINDSIGHT_API_LLM_API_KEY;
const overrideBaseUrl = process.env.HINDSIGHT_API_LLM_BASE_URL;
// Priority 1: If provider is explicitly set via env var, use that
if (overrideProvider) {
// Providers that don't require an API key (use OAuth or local models)
const noKeyRequired = ['ollama', 'openai-codex', 'claude-code'];
if (!overrideKey && !noKeyRequired.includes(overrideProvider)) {
throw new Error(
`HINDSIGHT_API_LLM_PROVIDER is set to "${overrideProvider}" but HINDSIGHT_API_LLM_API_KEY is not set.\n` +
`Please set: export HINDSIGHT_API_LLM_API_KEY=your-api-key`
);
}
return {
provider: overrideProvider,
apiKey: overrideKey || '',
model: overrideModel,
baseUrl: overrideBaseUrl,
source: 'HINDSIGHT_API_LLM_PROVIDER override',
};
}
// Priority 2: Plugin config llmProvider/llmModel
if (pluginConfig?.llmProvider) {
const providerInfo = PROVIDER_DETECTION.find(p => p.name === pluginConfig.llmProvider);
// Resolve API key: llmApiKeyEnv > provider's standard keyEnv
let apiKey = '';
if (pluginConfig.llmApiKeyEnv) {
apiKey = process.env[pluginConfig.llmApiKeyEnv] || '';
} else if (providerInfo?.keyEnv) {
apiKey = process.env[providerInfo.keyEnv] || '';
}
// Providers that don't require an API key (use OAuth or local models)
const noKeyRequired = ['ollama', 'openai-codex', 'claude-code'];
if (!apiKey && !noKeyRequired.includes(pluginConfig.llmProvider)) {
const keySource = pluginConfig.llmApiKeyEnv || providerInfo?.keyEnv || 'unknown';
throw new Error(
`Plugin config llmProvider is set to "${pluginConfig.llmProvider}" but no API key found.\n` +
`Expected env var: ${keySource}\n` +
`Set the env var or use llmApiKeyEnv in plugin config to specify a custom env var name.`
);
}
return {
provider: pluginConfig.llmProvider,
apiKey,
model: pluginConfig.llmModel || overrideModel,
baseUrl: overrideBaseUrl,
source: 'plugin config',
};
}
// Priority 3: Auto-detect from standard provider env vars
for (const providerInfo of PROVIDER_DETECTION) {
const apiKey = providerInfo.keyEnv ? process.env[providerInfo.keyEnv] : '';
// Skip providers that don't use API keys in auto-detection (must be explicitly requested)
const noKeyRequired = ['ollama', 'openai-codex', 'claude-code'];
if (noKeyRequired.includes(providerInfo.name)) {
continue;
}
if (apiKey) {
return {
provider: providerInfo.name,
apiKey,
model: overrideModel,
baseUrl: overrideBaseUrl,
source: `auto-detected from ${providerInfo.keyEnv}`,
};
}
}
// No configuration found - show helpful error
// Allow empty LLM config if using external Hindsight API (server handles LLM)
const externalApiCheck = detectExternalApi(pluginConfig);
if (externalApiCheck.apiUrl) {
return {
provider: undefined,
apiKey: undefined,
model: undefined,
baseUrl: undefined,
source: 'external-api-mode-no-llm',
};
}
throw new Error(
`No LLM configuration found for Hindsight memory plugin.\n\n` +
`Option 1: Set a standard provider API key (auto-detect):\n` +
` export OPENAI_API_KEY=sk-your-key\n` +
` export ANTHROPIC_API_KEY=your-key\n` +
` export GEMINI_API_KEY=your-key\n` +
` export GROQ_API_KEY=your-key\n\n` +
`Option 2: Use Codex or Claude Code (no API key needed):\n` +
` export HINDSIGHT_API_LLM_PROVIDER=openai-codex # Requires 'codex auth login'\n` +
` export HINDSIGHT_API_LLM_PROVIDER=claude-code # Requires Claude Code CLI\n\n` +
`Option 3: Set llmProvider in openclaw.json plugin config:\n` +
` "llmProvider": "openai"\n\n` +
`Option 4: Override with Hindsight-specific env vars:\n` +
` export HINDSIGHT_API_LLM_PROVIDER=openai\n` +
` export HINDSIGHT_API_LLM_API_KEY=sk-your-key\n` +
` export HINDSIGHT_API_LLM_BASE_URL=https://openrouter.ai/api/v1 # Optional\n\n` +
`The model will be selected automatically by Hindsight. To override: export HINDSIGHT_API_LLM_MODEL=your-model`
);
}
/**
* Detect external Hindsight API configuration.
* Priority: env vars > plugin config
*/
export function detectExternalApi(pluginConfig?: PluginConfig): {
apiUrl: string | null;
apiToken: string | null;
} {
const apiUrl = process.env.HINDSIGHT_EMBED_API_URL || pluginConfig?.hindsightApiUrl || null;
const apiToken = process.env.HINDSIGHT_EMBED_API_TOKEN || pluginConfig?.hindsightApiToken || null;
return { apiUrl, apiToken };
}
/**
* Build HindsightClientOptions from LLM config, plugin config, and external API settings.
*/
export function buildClientOptions(
llmConfig: { provider?: string; apiKey?: string; model?: string },
pluginCfg: PluginConfig,
externalApi: { apiUrl: string | null; apiToken: string | null },
): HindsightClientOptions {
return {
llmModel: llmConfig.model,
embedVersion: pluginCfg.embedVersion,
embedPackagePath: pluginCfg.embedPackagePath,
apiUrl: externalApi.apiUrl ?? undefined,
apiToken: externalApi.apiToken ?? undefined,
};
}
/**
* Health check for external Hindsight API.
* Retries up to 3 times with 2s delay — container DNS may not be ready on first boot.
*/
async function checkExternalApiHealth(apiUrl: string, apiToken?: string | null): Promise<void> {
const healthUrl = `${apiUrl.replace(/\/$/, '')}/health`;
const maxRetries = 3;
const retryDelay = 2000;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
debug(`[Hindsight] Checking external API health at ${healthUrl}... (attempt ${attempt}/${maxRetries})`);
const headers: Record<string, string> = {};
if (apiToken) {
headers['Authorization'] = `Bearer ${apiToken}`;
}
const response = await fetch(healthUrl, { signal: AbortSignal.timeout(10000), headers });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json() as { status?: string };
debug(`[Hindsight] External API health: ${JSON.stringify(data)}`);
return;
} catch (error) {
if (attempt < maxRetries) {
debug(`[Hindsight] Health check attempt ${attempt} failed, retrying in ${retryDelay}ms...`);
await new Promise(resolve => setTimeout(resolve, retryDelay));
} else {
throw new Error(`Cannot connect to external Hindsight API at ${apiUrl}: ${error}`, { cause: error });
}
}
}
}
function getPluginConfig(api: MoltbotPluginAPI): PluginConfig {
const config = api.config.plugins?.entries?.['hindsight-openclaw']?.config || {};
const defaultMission = 'You are an AI assistant helping users across multiple communication channels (Telegram, Slack, Discord, etc.). Remember user preferences, instructions, and important context from conversations to provide personalized assistance.';
const envBankId = typeof process.env.HINDSIGHT_BANK_ID === 'string' && process.env.HINDSIGHT_BANK_ID.trim().length > 0
? process.env.HINDSIGHT_BANK_ID.trim()
: undefined;
return {
bankMission: config.bankMission || defaultMission,
embedPort: config.embedPort || 0,
daemonIdleTimeout: config.daemonIdleTimeout !== undefined ? config.daemonIdleTimeout : 0,
embedVersion: config.embedVersion || 'latest',
embedPackagePath: config.embedPackagePath,
llmProvider: config.llmProvider,
llmModel: config.llmModel,
llmApiKeyEnv: config.llmApiKeyEnv,
hindsightApiUrl: config.hindsightApiUrl,
hindsightApiToken: config.hindsightApiToken,
apiPort: config.apiPort || 9077,
// Dynamic bank ID options (default: enabled)
dynamicBankId: config.dynamicBankId !== false,
bankId: envBankId || (typeof config.bankId === 'string' && config.bankId.trim().length > 0 ? config.bankId.trim() : undefined),
bankIdPrefix: config.bankIdPrefix,
retainTags: Array.isArray(config.retainTags) ? config.retainTags.filter((tag): tag is string => typeof tag === 'string') : undefined,
retainSource: typeof config.retainSource === 'string' && config.retainSource.trim().length > 0 ? config.retainSource.trim() : undefined,
excludeProviders: Array.isArray(config.excludeProviders)
? Array.from(new Set(['heartbeat', ...config.excludeProviders.filter((provider): provider is string => typeof provider === 'string')]))
: ['heartbeat'],
autoRecall: config.autoRecall !== false, // Default: true (on) — backward compatible
dynamicBankGranularity: Array.isArray(config.dynamicBankGranularity) ? config.dynamicBankGranularity : undefined,
autoRetain: config.autoRetain !== false, // Default: true
retainRoles: Array.isArray(config.retainRoles) ? config.retainRoles : undefined,
recallBudget: config.recallBudget || 'mid',
recallMaxTokens: config.recallMaxTokens || 1024,
recallTypes: Array.isArray(config.recallTypes) ? config.recallTypes : ['world', 'experience'],
recallRoles: Array.isArray(config.recallRoles) ? config.recallRoles : ['user', 'assistant'],
retainEveryNTurns: typeof config.retainEveryNTurns === 'number' && config.retainEveryNTurns >= 1 ? config.retainEveryNTurns : 1,
retainOverlapTurns: typeof config.retainOverlapTurns === 'number' && config.retainOverlapTurns >= 0 ? config.retainOverlapTurns : 0,
recallTopK: typeof config.recallTopK === 'number' ? config.recallTopK : undefined,
recallContextTurns: typeof config.recallContextTurns === 'number' && config.recallContextTurns >= 1 ? config.recallContextTurns : 1,
recallMaxQueryChars: typeof config.recallMaxQueryChars === 'number' && config.recallMaxQueryChars >= 1 ? config.recallMaxQueryChars : 800,
recallPromptPreamble:
typeof config.recallPromptPreamble === 'string' && config.recallPromptPreamble.trim().length > 0
? config.recallPromptPreamble
: DEFAULT_RECALL_PROMPT_PREAMBLE,
recallInjectionPosition: typeof config.recallInjectionPosition === 'string' && ['prepend', 'append', 'user'].includes(config.recallInjectionPosition) ? config.recallInjectionPosition as PluginConfig['recallInjectionPosition'] : undefined,
recallTimeoutMs: typeof config.recallTimeoutMs === 'number' && config.recallTimeoutMs >= 1000 ? config.recallTimeoutMs : undefined,
ignoreSessionPatterns: Array.isArray(config.ignoreSessionPatterns) ? config.ignoreSessionPatterns : [],
statelessSessionPatterns: Array.isArray(config.statelessSessionPatterns) ? config.statelessSessionPatterns : [],
skipStatelessSessions: config.skipStatelessSessions !== false,
debug: config.debug ?? false,
};
}
export default function (api: MoltbotPluginAPI) {
try {
log.info('plugin entry invoked');
debug('[Hindsight] Plugin loading...');
// Get plugin config first (needed for debug flag and service registration)
const pluginConfig = getPluginConfig(api);
// If logLevel is 'debug', also enable legacy debug flag
debugEnabled = pluginConfig.debug ?? (pluginConfig.logLevel === 'debug');
// Configure structured logger — route through OpenClaw's api.logger for consistent formatting
if (api.logger) setApiLogger(api.logger);
configureLogger({
logLevel: pluginConfig.logLevel ?? (pluginConfig.debug ? 'debug' : 'info'),
logSummaryIntervalMs: pluginConfig.logSummaryIntervalMs,
});
// Store config globally for bank ID derivation in hooks
currentPluginConfig = pluginConfig;
debug('[Hindsight] Plugin loaded successfully (deferred heavy init to gateway start)');
// Register background service for cleanup
// IMPORTANT: Heavy initialization (LLM detection, daemon start, API health checks)
// happens in service.start() which is ONLY called on gateway start,
// not on every CLI command.
debug('[Hindsight] Registering service...');
log.info('registering plugin service');
api.registerService({
id: 'hindsight-memory',
async start() {
log.info('service.start invoked');
debug('[Hindsight] Service start called - beginning heavy initialization...');
// Detect LLM configuration (env vars > plugin config > auto-detect)
debug('[Hindsight] Detecting LLM config...');
const llmConfig = detectLLMConfig(pluginConfig);
const baseUrlInfo = llmConfig.baseUrl ? `, base URL: ${llmConfig.baseUrl}` : '';
const modelInfo = llmConfig.model || 'default';
if (llmConfig.provider === 'ollama') {
debug(`[Hindsight] ✓ Using provider: ${llmConfig.provider}, model: ${modelInfo} (${llmConfig.source})`);
} else {
debug(`[Hindsight] ✓ Using provider: ${llmConfig.provider}, model: ${modelInfo} (${llmConfig.source}${baseUrlInfo})`);
}
if (pluginConfig.bankMission) {
debug(`[Hindsight] Custom bank mission configured: "${pluginConfig.bankMission.substring(0, 50)}..."`);
}
// Log bank ID mode
if (pluginConfig.dynamicBankId) {
const prefixInfo = pluginConfig.bankIdPrefix ? ` (prefix: ${pluginConfig.bankIdPrefix})` : '';
debug(`[Hindsight] ✓ Dynamic bank IDs enabled${prefixInfo} - each channel gets isolated memory`);
} else {
const sourceInfo = getConfiguredBankId(pluginConfig) ? 'configured' : 'default';
debug(`[Hindsight] Dynamic bank IDs disabled - using ${sourceInfo} static bank: ${getStaticBankId(pluginConfig)}`);
}
// Detect external API mode
const externalApi = detectExternalApi(pluginConfig);
// Get API port from config (default: 9077)
const apiPort = pluginConfig.apiPort || 9077;
if (externalApi.apiUrl) {
// External API mode - skip local daemon
usingExternalApi = true;
debug(`[Hindsight] ✓ Using external API: ${externalApi.apiUrl}`);
// Initialize retain queue (external API mode only)
try {
const queueDir = pluginConfig.retainQueuePath
? dirname(pluginConfig.retainQueuePath)
: join(homedir(), '.openclaw', 'data');
mkdirSync(queueDir, { recursive: true });
const queuePath = pluginConfig.retainQueuePath || join(queueDir, 'hindsight-retain-queue.jsonl');
const queueFlushInterval = pluginConfig.retainQueueFlushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
const queueMaxAge = pluginConfig.retainQueueMaxAgeMs ?? -1;
retainQueue = new RetainQueue({ filePath: queuePath, maxAgeMs: queueMaxAge });
const pending = retainQueue.size();
if (pending > 0) {
log.info(`retain queue: ${pending} items pending from previous session, will flush shortly`);
}
debug(`[Hindsight] Retain queue initialized: ${queuePath}`);
// Periodic flush timer
if (queueFlushInterval > 0) {
retainQueueFlushTimer = setInterval(flushRetainQueue, queueFlushInterval);
retainQueueFlushTimer.unref?.();
}
} catch (error) {
log.warn(`could not initialize retain queue: ${error}`);
}
// Set env vars so CLI commands (uvx hindsight-embed) use external API
process.env.HINDSIGHT_EMBED_API_URL = externalApi.apiUrl;
if (externalApi.apiToken) {
process.env.HINDSIGHT_EMBED_API_TOKEN = externalApi.apiToken;
debug('[Hindsight] API token configured');
}
} else {
debug(`[Hindsight] Daemon idle timeout: ${pluginConfig.daemonIdleTimeout}s (0 = never timeout)`);
debug(`[Hindsight] API Port: ${apiPort}`);
}
// Initialize (runs synchronously in service.start())
debug('[Hindsight] Starting initialization...');
initPromise = (async () => {
try {
if (usingExternalApi && externalApi.apiUrl) {
// External API mode - check health, skip daemon startup
debug('[Hindsight] External API mode - skipping local daemon...');
await checkExternalApiHealth(externalApi.apiUrl, externalApi.apiToken);
// Initialize client with direct HTTP mode
debug('[Hindsight] Creating HindsightClient (HTTP mode)...');
clientOptions = buildClientOptions(llmConfig, pluginConfig, externalApi);
clientsByBankId.clear();
banksWithMissionSet.clear();
client = new HindsightClient(clientOptions);
// Set default bank (will be overridden per-request when dynamic bank IDs are enabled)
const defaultBankId = deriveBankId(undefined, pluginConfig);
debug(`[Hindsight] Default bank: ${defaultBankId}`);
client.setBankId(defaultBankId);
// Note: Bank mission will be set per-bank when dynamic bank IDs are enabled
// For now, set it on the default bank
if (pluginConfig.bankMission && usesStaticBank(pluginConfig)) {
debug(`[Hindsight] Setting bank mission...`);
await client.setBankMission(pluginConfig.bankMission);
}
if (!isInitialized) {
const mode = 'external API';
const autoRecall = pluginConfig.autoRecall !== false;
const autoRetain = pluginConfig.autoRetain !== false;
log.info(`initialized (mode: ${mode}, bank: ${defaultBankId}, autoRecall: ${autoRecall}, autoRetain: ${autoRetain})`);
}
isInitialized = true;
debug('[Hindsight] ✓ Ready (external API mode)');
} else {
// Local daemon mode - start hindsight-embed daemon
debug('[Hindsight] Creating HindsightEmbedManager...');
embedManager = new HindsightEmbedManager(
apiPort,
llmConfig.provider || "",
llmConfig.apiKey || "",
llmConfig.model,
llmConfig.baseUrl,
pluginConfig.daemonIdleTimeout,
pluginConfig.embedVersion,
pluginConfig.embedPackagePath
);
// Start the embedded server
debug('[Hindsight] Starting embedded server...');
await embedManager.start();
// Initialize client (local daemon mode — no apiUrl)
debug('[Hindsight] Creating HindsightClient (subprocess mode)...');
clientOptions = buildClientOptions(llmConfig, pluginConfig, { apiUrl: null, apiToken: null });
clientsByBankId.clear();
banksWithMissionSet.clear();
client = new HindsightClient(clientOptions);
// Set default bank (will be overridden per-request when dynamic bank IDs are enabled)
const defaultBankId = deriveBankId(undefined, pluginConfig);
debug(`[Hindsight] Default bank: ${defaultBankId}`);
client.setBankId(defaultBankId);
// Note: Bank mission will be set per-bank when dynamic bank IDs are enabled
// For now, set it on the default bank
if (pluginConfig.bankMission && usesStaticBank(pluginConfig)) {
debug(`[Hindsight] Setting bank mission...`);
await client.setBankMission(pluginConfig.bankMission);
}
if (!isInitialized) {
const mode = 'local daemon';
const autoRecall = pluginConfig.autoRecall !== false;
const autoRetain = pluginConfig.autoRetain !== false;
log.info(`initialized (mode: ${mode}, bank: ${defaultBankId}, autoRecall: ${autoRecall}, autoRetain: ${autoRetain})`);
}
isInitialized = true;
debug('[Hindsight] ✓ Ready');
}
} catch (error) {
log.error('initialization error', error);
throw error;
}
})();
// Wait for initialization to complete
try {
await initPromise;
} catch (error) {
log.error('initial initialization failed', error);
// Continue to health check below
}
// External API mode: check external API health
if (usingExternalApi) {
const externalApi = detectExternalApi(pluginConfig);
if (externalApi.apiUrl && isInitialized) {
try {
await checkExternalApiHealth(externalApi.apiUrl, externalApi.apiToken);
debug('[Hindsight] External API is healthy');
return;
} catch (error) {
log.error('external API health check failed', error);
// Reset state for reinitialization attempt
client = null;
clientOptions = null;
clientsByBankId.clear();
banksWithMissionSet.clear();
isInitialized = false;
}
}
} else {
// Local daemon mode: check daemon health (handles SIGUSR1 restart case)
if (embedManager && isInitialized) {
const healthy = await embedManager.checkHealth();
if (healthy) {
debug('[Hindsight] Daemon is healthy');
return;
}
debug('[Hindsight] Daemon is not responding - reinitializing...');
// Reset state for reinitialization
embedManager = null;
client = null;
clientOptions = null;
clientsByBankId.clear();
banksWithMissionSet.clear();
isInitialized = false;
}
}
// Reinitialize if needed (fresh start or recovery)
if (!isInitialized) {
debug('[Hindsight] Reinitializing...');
const reinitPluginConfig = getPluginConfig(api);
currentPluginConfig = reinitPluginConfig;
const llmConfig = detectLLMConfig(reinitPluginConfig);
const externalApi = detectExternalApi(reinitPluginConfig);
const apiPort = reinitPluginConfig.apiPort || 9077;
if (externalApi.apiUrl) {
// External API mode
usingExternalApi = true;
process.env.HINDSIGHT_EMBED_API_URL = externalApi.apiUrl;
if (externalApi.apiToken) {
process.env.HINDSIGHT_EMBED_API_TOKEN = externalApi.apiToken;
}
await checkExternalApiHealth(externalApi.apiUrl, externalApi.apiToken);
clientOptions = buildClientOptions(llmConfig, reinitPluginConfig, externalApi);
clientsByBankId.clear();
banksWithMissionSet.clear();
client = new HindsightClient(clientOptions);
const defaultBankId = deriveBankId(undefined, reinitPluginConfig);
client.setBankId(defaultBankId);
if (reinitPluginConfig.bankMission && usesStaticBank(reinitPluginConfig)) {
await client.setBankMission(reinitPluginConfig.bankMission);
}
isInitialized = true;
debug('[Hindsight] Reinitialization complete (external API mode)');
} else {
// Local daemon mode
embedManager = new HindsightEmbedManager(
apiPort,
llmConfig.provider || "",
llmConfig.apiKey || "",
llmConfig.model,
llmConfig.baseUrl,
reinitPluginConfig.daemonIdleTimeout,
reinitPluginConfig.embedVersion,
reinitPluginConfig.embedPackagePath
);
await embedManager.start();
clientOptions = buildClientOptions(llmConfig, reinitPluginConfig, { apiUrl: null, apiToken: null });
clientsByBankId.clear();
banksWithMissionSet.clear();
client = new HindsightClient(clientOptions);
const defaultBankId = deriveBankId(undefined, reinitPluginConfig);
client.setBankId(defaultBankId);
if (reinitPluginConfig.bankMission && usesStaticBank(reinitPluginConfig)) {
await client.setBankMission(reinitPluginConfig.bankMission);
}
isInitialized = true;
debug('[Hindsight] Reinitialization complete');
}
}
},
async stop() {
try {
debug('[Hindsight] Service stopping...');
// Only stop daemon if in local mode
if (!usingExternalApi && embedManager) {
await embedManager.stop();
embedManager = null;
}
// Close retain queue
if (retainQueueFlushTimer) {
clearInterval(retainQueueFlushTimer);
retainQueueFlushTimer = null;
}
if (retainQueue) {
const pending = retainQueue.size();
if (pending > 0) {
debug(`[Hindsight] Service stopping with ${pending} queued retains (will resume on next start)`);
}
retainQueue.close();
retainQueue = null;
}
client = null;
clientOptions = null;
clientsByBankId.clear();
banksWithMissionSet.clear();
isInitialized = false;
stopLogger();
debug('[Hindsight] Service stopped');
} catch (error) {
log.error('service stop error', error);
throw error;
}
},
});
debug('[Hindsight] Plugin loaded successfully');
// Register agent hooks for auto-recall and auto-retention
if (hooksRegistered) {
debug('[Hindsight] Hooks already registered in this runtime, skipping duplicate hook registration');
return;
}
hooksRegistered = true;
debug('[Hindsight] Registering agent hooks...');
log.info('registering agent hooks');
// Auto-recall: Inject relevant memories before agent processes the message
// Hook signature: (event, ctx) where event has {prompt, messages?} and ctx has agent context
api.on('before_prompt_build', async (event: any, ctx?: PluginHookAgentContext) => {
try {
// Check if this provider is excluded
if (ctx?.messageProvider && pluginConfig.excludeProviders?.includes(ctx.messageProvider)) {
debug(`[Hindsight] Skipping recall for excluded provider: ${ctx.messageProvider}`);
return;
}
// Session pattern filtering
const sessionKey = ctx?.sessionKey;
if (sessionKey) {
const ignorePatterns = compileSessionPatterns(pluginConfig.ignoreSessionPatterns ?? []);
if (ignorePatterns.length > 0 && matchesSessionPattern(sessionKey, ignorePatterns)) {
debug(`[Hindsight] Skipping recall: session '${sessionKey}' matches ignoreSessionPatterns`);
return;
}
const skipStateless = pluginConfig.skipStatelessSessions !== false;
if (skipStateless) {
const statelessPatterns = compileSessionPatterns(pluginConfig.statelessSessionPatterns ?? []);
if (statelessPatterns.length > 0 && matchesSessionPattern(sessionKey, statelessPatterns)) {
debug(`[Hindsight] Skipping recall: session '${sessionKey}' matches statelessSessionPatterns (skipStatelessSessions=true)`);
return;
}
}
}
// Skip auto-recall when disabled (agent has its own recall tool)
if (!pluginConfig.autoRecall) {
debug('[Hindsight] Auto-recall disabled via config, skipping');
return;
}
// Derive bank ID from context — enrich ctx.senderId from the inbound metadata
// block when it's missing (agent-phase hooks don't carry senderId in ctx directly).
const senderIdFromPrompt = !ctx?.senderId ? extractSenderIdFromText(event.prompt ?? event.rawMessage ?? '') : undefined;
const effectiveCtxForRecall = senderIdFromPrompt ? { ...ctx, senderId: senderIdFromPrompt } : ctx;
// Cache the resolved sender ID keyed by sessionKey so agent_end can use it.
// event.messages in agent_end is clean history without the metadata blocks.
const resolvedSenderId = effectiveCtxForRecall?.senderId;
const sessionKeyForCache = ctx?.sessionKey;
if (resolvedSenderId && sessionKeyForCache) {
senderIdBySession.set(sessionKeyForCache, resolvedSenderId);
if (senderIdBySession.size > MAX_TRACKED_SESSIONS) {
const oldest = senderIdBySession.keys().next().value;
if (oldest) senderIdBySession.delete(oldest);
}
}
const bankId = deriveBankId(effectiveCtxForRecall, pluginConfig);
debug(`[Hindsight] before_prompt_build - bank: ${bankId}, channel: ${ctx?.messageProvider}/${ctx?.channelId}`);
debug(`[Hindsight] event keys: ${Object.keys(event).join(', ')}`);
debug(`[Hindsight] event.context keys: ${Object.keys(event.context ?? {}).join(', ')}`);
// Get the user's latest message for recall — only the raw user text, not the full prompt
// rawMessage is clean user text; prompt includes envelope, system events, media notes, etc.
debug(`[Hindsight] extractRecallQuery input lengths - raw: ${event.rawMessage?.length ?? 0}, prompt: ${event.prompt?.length ?? 0}`);
const extracted = extractRecallQuery(event.rawMessage, event.prompt);
if (!extracted) {
debug('[Hindsight] extractRecallQuery returned null, skipping recall');
return;
}
debug(`[Hindsight] extractRecallQuery result length: ${extracted.length}`);
const recallContextTurns = pluginConfig.recallContextTurns ?? 1;
const recallMaxQueryChars = pluginConfig.recallMaxQueryChars ?? 800;
const sessionMessages = event.context?.sessionEntry?.messages ?? event.messages ?? [];
const messageCount = sessionMessages.length;
debug(`[Hindsight] event.messages count: ${messageCount}, roles: ${sessionMessages.map((m: any) => m.role).join(',')}`);
if (recallContextTurns > 1 && messageCount === 0) {
debug('[Hindsight] recallContextTurns > 1 but event.messages is empty — prior context unavailable at before_agent_start for this provider');
}
const recallRoles = pluginConfig.recallRoles ?? ['user', 'assistant'];
const composedPrompt = composeRecallQuery(extracted, sessionMessages, recallContextTurns, recallRoles);
let prompt = truncateRecallQuery(composedPrompt, extracted, recallMaxQueryChars);
// Final defensive cap
if (prompt.length > recallMaxQueryChars) {
prompt = prompt.substring(0, recallMaxQueryChars);
}
// Wait for client to be ready
const clientGlobal = (global as any).__hindsightClient;
if (!clientGlobal) {
debug('[Hindsight] Client global not available, skipping auto-recall');
return;
}
await clientGlobal.waitForReady();
// Get client configured for this context's bank (async to handle mission setup)
const client = await clientGlobal.getClientForContext(effectiveCtxForRecall);
if (!client) {
debug('[Hindsight] Client not initialized, skipping auto-recall');
return;
}
debug(`[Hindsight] Auto-recall for bank ${bankId}, full query:\n---\n${prompt}\n---`);
// Recall with deduplication: reuse in-flight request for same bank
const normalizedPrompt = prompt.trim().toLowerCase().replace(/\s+/g, ' ');
const queryHash = createHash('sha256').update(normalizedPrompt).digest('hex').slice(0, 16);
const recallKey = `${bankId}::${queryHash}`;
const existing = inflightRecalls.get(recallKey);
let recallPromise: Promise<RecallResponse>;
if (existing) {
debug(`[Hindsight] Reusing in-flight recall for bank ${bankId}`);
recallPromise = existing;
} else {
const recallTimeoutMs = pluginConfig.recallTimeoutMs ?? DEFAULT_RECALL_TIMEOUT_MS;
recallPromise = client.recall({ query: prompt, max_tokens: pluginConfig.recallMaxTokens || 1024, budget: pluginConfig.recallBudget, types: pluginConfig.recallTypes }, recallTimeoutMs);
inflightRecalls.set(recallKey, recallPromise);
void recallPromise.catch(() => {}).finally(() => inflightRecalls.delete(recallKey));
}
const response = await recallPromise;
if (!response.results || response.results.length === 0) {
debug('[Hindsight] No memories found for auto-recall');
return;
}
debug(`[Hindsight] Raw recall response (${response.results.length} results before topK):\n${response.results.map((r: any, i: number) => ` [${i}] score=${r.score?.toFixed(3) ?? 'n/a'} type=${r.type ?? 'n/a'}: ${JSON.stringify(r.content ?? r.text ?? r).substring(0, 200)}`).join('\n')}`);
const results = pluginConfig.recallTopK ? response.results.slice(0, pluginConfig.recallTopK) : response.results;
debug(`[Hindsight] After topK (${pluginConfig.recallTopK ?? 'unlimited'}): ${results.length} results injected`);
// Format memories as JSON with all fields from recall
const memoriesFormatted = formatMemories(results);
const contextMessage = `<hindsight_memories>
${pluginConfig.recallPromptPreamble || DEFAULT_RECALL_PROMPT_PREAMBLE}
Current time - ${formatCurrentTimeForRecall()}
${memoriesFormatted}
</hindsight_memories>`;
debug(`[Hindsight] Auto-recall: Injecting ${results.length} memories from bank ${bankId}`);
log.info(`injecting ${results.length} memories into context (bank: ${bankId})`);
log.trackRecall(bankId, results.length);
// Inject recalled memories. Position is configurable to preserve prompt caching
// when agents have large static system prompts.
const position = pluginConfig.recallInjectionPosition || 'prepend';
switch (position) {
case 'append':
return { appendSystemContext: contextMessage };
case 'user':
return { prependContext: contextMessage };
case 'prepend':
default:
return { prependSystemContext: contextMessage };
}
} catch (error) {
if (error instanceof DOMException && error.name === 'TimeoutError') {
log.warn(`[Hindsight] Auto-recall timed out after ${pluginConfig.recallTimeoutMs ?? DEFAULT_RECALL_TIMEOUT_MS}ms, skipping memory injection`);
} else if (error instanceof Error && error.name === 'AbortError') {
log.warn(`[Hindsight] Auto-recall aborted after ${pluginConfig.recallTimeoutMs ?? DEFAULT_RECALL_TIMEOUT_MS}ms, skipping memory injection`);
} else {
log.error('auto-recall error', error);
}
return;
}
});
// Hook signature: (event, ctx) where event has {messages, success, error?, durationMs?}
api.on('agent_end', async (event: any, ctx?: PluginHookAgentContext) => {
try {
// Avoid cross-session contamination: only use context carried by this event.
const eventSessionKey = typeof event?.sessionKey === 'string' ? event.sessionKey : undefined;
const effectiveCtx = ctx || (eventSessionKey ? ({ sessionKey: eventSessionKey } as PluginHookAgentContext) : undefined);
// Check if this provider is excluded
if (effectiveCtx?.messageProvider && pluginConfig.excludeProviders?.includes(effectiveCtx.messageProvider)) {
debug(`[Hindsight] Skipping retain for excluded provider: ${effectiveCtx.messageProvider}`);
return;
}
// Session pattern filtering
const agentEndSessionKey = effectiveCtx?.sessionKey;
if (agentEndSessionKey) {
const ignorePatterns = compileSessionPatterns(pluginConfig.ignoreSessionPatterns ?? []);
if (ignorePatterns.length > 0 && matchesSessionPattern(agentEndSessionKey, ignorePatterns)) {
debug(`[Hindsight] Skipping retain: session '${agentEndSessionKey}' matches ignoreSessionPatterns`);
return;
}
const statelessPatterns = compileSessionPatterns(pluginConfig.statelessSessionPatterns ?? []);
if (statelessPatterns.length > 0 && matchesSessionPattern(agentEndSessionKey, statelessPatterns)) {
debug(`[Hindsight] Skipping retain: session '${agentEndSessionKey}' matches statelessSessionPatterns`);
return;
}
}
// Derive bank ID from context — enrich ctx.senderId from the session cache.
// event.messages in agent_end is clean history without OpenClaw's metadata blocks;
// the sender ID was captured during before_prompt_build where event.prompt has them.
const sessionKeyForLookup = effectiveCtx?.sessionKey;
const senderIdFromCache = !effectiveCtx?.senderId && sessionKeyForLookup
? senderIdBySession.get(sessionKeyForLookup)
: undefined;
const effectiveCtxForRetain = senderIdFromCache ? { ...effectiveCtx, senderId: senderIdFromCache } : effectiveCtx;
const bankId = deriveBankId(effectiveCtxForRetain, pluginConfig);
debug(`[Hindsight Hook] agent_end triggered - bank: ${bankId}`);
if (event.success === false) {
debug('[Hindsight Hook] Agent run failed, skipping retention');
return;
}
if (!Array.isArray(event.context?.sessionEntry?.messages ?? event.messages) || (event.context?.sessionEntry?.messages ?? event.messages ?? []).length === 0) {
debug('[Hindsight Hook] No messages in event, skipping retention');
return;
}
if (pluginConfig.autoRetain === false) {
debug('[Hindsight Hook] autoRetain is disabled, skipping retention');
return;
}
// Chunked retention: skip non-Nth turns and use a sliding window when firing
const retainEveryN = pluginConfig.retainEveryNTurns ?? 1;
const allMessages = event.context?.sessionEntry?.messages ?? event.messages ?? [];
let messagesToRetain = allMessages;
let retainFullWindow = false;
if (retainEveryN > 1) {
const sessionTrackingKey = `${bankId}:${effectiveCtx?.sessionKey || 'session'}`;
const turnCount = (turnCountBySession.get(sessionTrackingKey) || 0) + 1;
turnCountBySession.set(sessionTrackingKey, turnCount);
if (turnCountBySession.size > MAX_TRACKED_SESSIONS) {
const oldestKey = turnCountBySession.keys().next().value;
if (oldestKey) {
turnCountBySession.delete(oldestKey);
}
}
if (turnCount % retainEveryN !== 0) {
const nextRetainAt = Math.ceil(turnCount / retainEveryN) * retainEveryN;
debug(`[Hindsight Hook] Turn ${turnCount}/${retainEveryN}, skipping retain (next at turn ${nextRetainAt})`);
return;
}
// Sliding window in turns: N turns + configured overlap turns.
// We slice by actual turn boundaries (user-role messages), so this
// remains stable even when system/tool messages are present.
const overlapTurns = pluginConfig.retainOverlapTurns ?? 0;
const windowTurns = retainEveryN + overlapTurns;
messagesToRetain = sliceLastTurnsByUserBoundary(allMessages, windowTurns);
retainFullWindow = true;
debug(`[Hindsight Hook] Turn ${turnCount}: chunked retain firing (window: ${windowTurns} turns, ${messagesToRetain.length} messages)`);
}
const retention = prepareRetentionTranscript(messagesToRetain, pluginConfig, retainFullWindow);
if (!retention) {
debug('[Hindsight Hook] No messages to retain (filtered/short/no-user)');
return;
}
const { transcript, messageCount } = retention;
// Wait for client to be ready
const clientGlobal = (global as any).__hindsightClient;
if (!clientGlobal) {
log.warn('client global not found, skipping retain');
return;
}
await clientGlobal.waitForReady();
// Get client configured for this context's bank (async to handle mission setup)
const client = await clientGlobal.getClientForContext(effectiveCtxForRetain);
if (!client) {
log.warn('client not initialized, skipping retain');
return;
}
const retainNow = Date.now();
const retainRequest = buildRetainRequest(
transcript,
messageCount,
effectiveCtxForRetain,
pluginConfig,
retainNow,
{
retentionScope: retainFullWindow ? 'window' : 'turn',
windowTurns: retainFullWindow ? (pluginConfig.retainEveryNTurns ?? 1) + (pluginConfig.retainOverlapTurns ?? 0) : undefined,
},
);
// Retain to Hindsight
debug(`[Hindsight] Retaining to bank ${bankId}, document: ${retainRequest.document_id}, chars: ${transcript.length}\n---\n${transcript.substring(0, 500)}${transcript.length > 500 ? '\n...(truncated)' : ''}\n---`);
try {
await client.retain(retainRequest);
log.trackRetain(bankId, messageCount);
debug(`[Hindsight] Retained ${messageCount} messages to bank ${bankId} for session ${retainRequest.document_id}`);
// After a successful retain, try flushing any queued items
if (retainQueue && retainQueue.size() > 0) {
flushRetainQueue().catch(() => {});
}
} catch (retainError) {
// Queue the failed retain for later delivery (external API mode only)
if (retainQueue) {
retainQueue.enqueue(bankId, retainRequest, retainRequest.metadata);
const pending = retainQueue.size();
log.warn(`API unreachable — retain queued (${pending} pending, bank: ${bankId}): ${retainError instanceof Error ? retainError.message : retainError}`);
} else {
log.error('error retaining messages', retainError);
}
}
} catch (error) {
log.error('error retaining messages', error);
}
});
debug('[Hindsight] Hooks registered');
log.info('agent hooks registered');
} catch (error) {
log.error('plugin loading error', error);
if (error instanceof Error) {
log.error('error stack', error.stack);
}
throw error;
}
}
// Export client getter for tools
function sanitizeDocumentIdPart(value: string | undefined, fallback: string): string {
const normalized = (value || '').trim();
if (!normalized) return fallback;
return normalized
.replace(/[^a-zA-Z0-9:_-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '') || fallback;
}
function getSessionDocumentBase(effectiveCtx: PluginHookAgentContext | undefined): string {
const sessionKeyPart = sanitizeDocumentIdPart(effectiveCtx?.sessionKey, 'session');
return `openclaw:${sessionKeyPart}`;
}
function nextDocumentSequence(effectiveCtx: PluginHookAgentContext | undefined): number {
const sequenceKey = effectiveCtx?.sessionKey || 'session';
const next = (documentSequenceBySession.get(sequenceKey) || 0) + 1;
documentSequenceBySession.set(sequenceKey, next);
if (documentSequenceBySession.size > MAX_TRACKED_SESSIONS) {
const oldestKey = documentSequenceBySession.keys().next().value;
if (oldestKey) {
documentSequenceBySession.delete(oldestKey);
}
}
return next;
}
function extractThreadId(channelId: string | undefined): string | undefined {
if (!channelId) return undefined;
const match = channelId.match(/(?:^|:)topic:([^:]+)$/);
return match?.[1];
}
export function buildRetainRequest(
transcript: string,
messageCount: number,
effectiveCtx: PluginHookAgentContext | undefined,
pluginConfig: PluginConfig,
now = Date.now(),
options?: { retentionScope?: 'turn' | 'window' | 'manual'; windowTurns?: number; turnIndex?: number },
): RetainRequest {
const parsedSession = effectiveCtx?.sessionKey ? parseSessionKey(effectiveCtx.sessionKey) : {};
const turnIndex = options?.turnIndex ?? nextDocumentSequence(effectiveCtx);
const retentionScope = options?.retentionScope || 'turn';
const documentBase = getSessionDocumentBase(effectiveCtx);
const documentKind = retentionScope === 'window' ? 'window' : 'turn';
const documentId = `${documentBase}:${documentKind}:${String(turnIndex).padStart(6, '0')}`;
const channelId = effectiveCtx?.channelId || parsedSession.channel;
const provider = effectiveCtx?.messageProvider || parsedSession.provider;
const threadId = extractThreadId(channelId);
return {
content: transcript,
document_id: documentId,
metadata: {
retained_at: new Date(now).toISOString(),
message_count: String(messageCount),
source: pluginConfig.retainSource || 'openclaw',
retention_scope: retentionScope,
turn_index: String(turnIndex),
session_key: effectiveCtx?.sessionKey,
agent_id: effectiveCtx?.agentId || parsedSession.agentId,
provider,
channel_type: effectiveCtx?.messageProvider,
channel_id: channelId,
thread_id: threadId,
sender_id: effectiveCtx?.senderId,
...(options?.windowTurns !== undefined ? { window_turns: String(options.windowTurns) } : {}),
},
tags: pluginConfig.retainTags && pluginConfig.retainTags.length > 0 ? pluginConfig.retainTags : undefined,
};
}
export function prepareRetentionTranscript(
messages: any[],
pluginConfig: PluginConfig,
retainFullWindow = false
): { transcript: string; messageCount: number } | null {
if (!messages || messages.length === 0) {
return null;
}
let targetMessages: any[];
if (retainFullWindow) {
// Chunked retention: retain the full sliding window (already sliced by caller)
targetMessages = messages;
} else {
// Default: retain only the last turn (user message + assistant responses)
let lastUserIdx = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
lastUserIdx = i;
break;
}
}
if (lastUserIdx === -1) {
return null; // No user message found in turn
}
targetMessages = messages.slice(lastUserIdx);
}
// Role filtering
const allowedRoles = new Set(pluginConfig.retainRoles || ['user', 'assistant']);
const filteredMessages = targetMessages.filter((m: any) => allowedRoles.has(m.role));
if (filteredMessages.length === 0) {
return null; // No messages to retain
}
// Format messages into a transcript
const transcriptParts = filteredMessages
.map((msg: any) => {
const role = msg.role || 'unknown';
let content = '';
// Handle different content formats
if (typeof msg.content === 'string') {
content = msg.content;
} else if (Array.isArray(msg.content)) {
content = msg.content
.filter((block: any) => block.type === 'text')
.map((block: any) => block.text)
.join('\n');
}
// Strip plugin-injected memory tags and metadata envelopes to prevent feedback loop
content = stripMemoryTags(content);
content = stripMetadataEnvelopes(content);
return content.trim() ? `[role: ${role}]\n${content}\n[${role}:end]` : null;
})
.filter(Boolean);
const transcript = transcriptParts.join('\n\n');
if (!transcript.trim() || transcript.length < 10) {
return null; // Transcript too short
}
return { transcript, messageCount: transcriptParts.length };
}
export function sliceLastTurnsByUserBoundary(messages: any[], turns: number): any[] {
if (!Array.isArray(messages) || messages.length === 0 || turns <= 0) {
return [];
}
let userTurnsSeen = 0;
let startIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]?.role === 'user') {
userTurnsSeen += 1;
if (userTurnsSeen >= turns) {
startIndex = i;
break;
}
}
}
if (startIndex === -1) {
return messages;
}
return messages.slice(startIndex);
}
export function getClient() {
return client;
}