From c4610130479899218aaccdea1dd521796ab1a02a Mon Sep 17 00:00:00 2001 From: Anton Evseev <78427278+slayoffer@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:09:02 +1000 Subject: [PATCH] fix(openclaw): shell safety, HTTP dual-mode, lazy reinit, per-user banks (#388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - exec→execFile: bypass shell entirely, preventing injection via special characters in chat history - HTTP dual-mode: client can now talk directly to the Hindsight API via HTTP (setBankMission, retain, recall) when apiUrl is configured, bypassing the subprocess/CLI entirely for production deployments - HindsightClientOptions: replace 5 positional constructor args with a typed options object for clarity and extensibility - sanitize(): strip null bytes from strings — Node 22 rejects them in execFile() args - recall timeout: accept optional timeoutMs parameter for both HTTP and subprocess modes; subprocess gets a longer 30s default - In-flight recall dedup: concurrent recalls for the same bank reuse one promise instead of firing duplicate requests - Timeout/abort handling: graceful warn-level logging instead of error spam when recall times out - Error cause chaining: wrap errors with { cause } for better debugging stack traces - lazyReinit: recover from startup health check failure with 30s cooldown and concurrency guard - Per-user banks: derive bank ID from senderId (not channelId) for proper memory isolation per user across channels - buildClientOptions(): centralized helper replaces 7 duplicated constructor call sites Co-authored-by: Claude Opus 4.6 --- .../openclaw/openclaw.plugin.json | 4 +- .../openclaw/src/client.test.ts | 105 ++------ hindsight-integrations/openclaw/src/client.ts | 244 +++++++++++++----- hindsight-integrations/openclaw/src/index.ts | 227 ++++++++++++---- 4 files changed, 373 insertions(+), 207 deletions(-) diff --git a/hindsight-integrations/openclaw/openclaw.plugin.json b/hindsight-integrations/openclaw/openclaw.plugin.json index f68ae9eb..b3036995 100644 --- a/hindsight-integrations/openclaw/openclaw.plugin.json +++ b/hindsight-integrations/openclaw/openclaw.plugin.json @@ -57,12 +57,12 @@ }, "dynamicBankId": { "type": "boolean", - "description": "Enable per-channel memory banks. When true, memories are isolated by channel (e.g., slack-C123, telegram-456). When false, all channels share a single 'openclaw' bank.", + "description": "Enable per-user memory banks. When true, memories are isolated by user per channel (e.g., slack-U123, telegram-456789). When false, all users share a single 'openclaw' bank.", "default": true }, "bankIdPrefix": { "type": "string", - "description": "Optional prefix for bank IDs (e.g., 'prod' results in 'prod-slack-C123'). Useful for separating environments." + "description": "Optional prefix for bank IDs (e.g., 'prod' results in 'prod-slack-U123'). Useful for separating environments." } }, "additionalProperties": false diff --git a/hindsight-integrations/openclaw/src/client.test.ts b/hindsight-integrations/openclaw/src/client.test.ts index 09c338cc..8d846321 100644 --- a/hindsight-integrations/openclaw/src/client.test.ts +++ b/hindsight-integrations/openclaw/src/client.test.ts @@ -1,104 +1,31 @@ import { describe, it, expect } from 'vitest'; -import { HindsightClient, escapeShellArg } from './client.js'; +import { HindsightClient } from './client.js'; describe('HindsightClient', () => { it('should create instance with provider and API key', () => { - const client = new HindsightClient('openai', 'test-key', 'gpt-4'); + const client = new HindsightClient({ llmProvider: 'openai', llmApiKey: 'test-key', llmModel: 'gpt-4' }); expect(client).toBeInstanceOf(HindsightClient); }); it('should set bank ID', () => { - const client = new HindsightClient('openai', 'test-key'); + const client = new HindsightClient({ llmProvider: 'openai', llmApiKey: 'test-key' }); client.setBankId('test-bank'); // No error thrown means success expect(true).toBe(true); }); - it('should handle content escaping for single quotes', () => { - const client = new HindsightClient('openai', 'test-key'); - // This test validates the client is instantiated correctly - // Actual CLI calls would require mocking - expect(client).toBeDefined(); - }); -}); - -describe('escapeShellArg', () => { - it('should return unchanged string when no special characters', () => { - expect(escapeShellArg('hello world')).toBe('hello world'); - expect(escapeShellArg('simple text 123')).toBe('simple text 123'); - }); - - it('should escape single quotes', () => { - expect(escapeShellArg("it's")).toBe("it'\\''s"); - expect(escapeShellArg("don't")).toBe("don'\\''t"); - expect(escapeShellArg("'quoted'")).toBe("'\\''quoted'\\''"); - }); - - it('should preserve dollar signs (protected by single quotes)', () => { - // These are NOT escaped - single quotes protect them - expect(escapeShellArg('$HOME')).toBe('$HOME'); - expect(escapeShellArg('cost is $100')).toBe('cost is $100'); - }); - - it('should preserve backticks (protected by single quotes)', () => { - expect(escapeShellArg('`ls`')).toBe('`ls`'); - expect(escapeShellArg('run `command`')).toBe('run `command`'); - }); - - it('should preserve exclamation marks (protected by single quotes)', () => { - expect(escapeShellArg('hello!')).toBe('hello!'); - expect(escapeShellArg('wow! amazing!')).toBe('wow! amazing!'); - }); - - it('should preserve glob patterns (protected by single quotes)', () => { - expect(escapeShellArg('*.txt')).toBe('*.txt'); - expect(escapeShellArg('file?.log')).toBe('file?.log'); - expect(escapeShellArg('[abc]')).toBe('[abc]'); - }); - - it('should preserve parentheses and braces (protected by single quotes)', () => { - expect(escapeShellArg('(subshell)')).toBe('(subshell)'); - expect(escapeShellArg('{a,b,c}')).toBe('{a,b,c}'); - }); - - it('should preserve redirection and control operators (protected by single quotes)', () => { - expect(escapeShellArg('a > b')).toBe('a > b'); - expect(escapeShellArg('cmd | grep')).toBe('cmd | grep'); - expect(escapeShellArg('a && b')).toBe('a && b'); - expect(escapeShellArg('a; b')).toBe('a; b'); - }); - - it('should preserve backslashes (protected by single quotes)', () => { - expect(escapeShellArg('path\\to\\file')).toBe('path\\to\\file'); - }); - - it('should preserve double quotes (protected by single quotes)', () => { - expect(escapeShellArg('"quoted"')).toBe('"quoted"'); - }); - - it('should preserve hash (protected by single quotes)', () => { - expect(escapeShellArg('# comment')).toBe('# comment'); - }); - - it('should preserve tilde (protected by single quotes)', () => { - expect(escapeShellArg('~user')).toBe('~user'); - }); - - it('should preserve newlines (protected by single quotes)', () => { - expect(escapeShellArg('line1\nline2')).toBe('line1\nline2'); - }); - - it('should handle complex mixed content', () => { - expect(escapeShellArg("It's $100! Run `ls`")).toBe("It'\\''s $100! Run `ls`"); - expect(escapeShellArg("user's file*.txt")).toBe("user'\\''s file*.txt"); - }); - - it('should handle empty string', () => { - expect(escapeShellArg('')).toBe(''); - }); - - it('should handle multiple consecutive single quotes', () => { - expect(escapeShellArg("''")).toBe("'\\'''\\''"); - expect(escapeShellArg("'''")).toBe("'\\'''\\'''\\''"); + it('should create instance with embed package path', () => { + const client = new HindsightClient({ llmProvider: 'openai', llmApiKey: 'test-key', llmModel: 'gpt-4', embedPackagePath: '/path/to/hindsight' }); + expect(client).toBeInstanceOf(HindsightClient); + }); + + it('should create instance in HTTP mode', () => { + const client = new HindsightClient({ + llmProvider: 'openai', + llmApiKey: 'test-key', + apiUrl: 'https://api.example.com/', + apiToken: 'bearer-token', + }); + expect(client).toBeInstanceOf(HindsightClient); }); }); diff --git a/hindsight-integrations/openclaw/src/client.ts b/hindsight-integrations/openclaw/src/client.ts index b7a0a62c..2690cb85 100644 --- a/hindsight-integrations/openclaw/src/client.ts +++ b/hindsight-integrations/openclaw/src/client.ts @@ -1,4 +1,4 @@ -import { exec } from 'child_process'; +import { execFile } from 'child_process'; import { promisify } from 'util'; import { writeFile, mkdir, rm } from 'fs/promises'; import { tmpdir } from 'os'; @@ -11,7 +11,13 @@ import type { RecallResponse, } from './types.js'; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); + +const MAX_BUFFER = 5 * 1024 * 1024; // 5 MB — large transcripts can exceed default 1 MB +const DEFAULT_TIMEOUT_MS = 15_000; + +/** Strip null bytes from strings — Node 22 rejects them in execFile() args */ +const sanitize = (s: string) => s.replace(/\0/g, ''); /** * Sanitize a string for use as a cross-platform filename. @@ -22,80 +28,101 @@ function sanitizeFilename(name: string): string { return name.replace(/[\\/:*?"<>|\x00-\x1f]/g, '_').slice(0, 200) || 'content'; } -/** - * Escape a string for use as a single-quoted shell argument. - * - * In POSIX shells, single-quoted strings treat ALL characters literally - * except for the single quote itself. To include a literal single quote, - * we use the pattern: end quote + escaped quote + start quote = '\'' - * - * Example: "It's $100" becomes 'It'\''s $100' - * Shell interprets: 'It' + \' + 's $100' = It's $100 - * - * This handles ALL shell-special characters including: - * - $ (variable expansion) - * - ` (command substitution) - * - ! (history expansion) - * - ? * [ ] (glob patterns) - * - ( ) { } (subshell/brace expansion) - * - < > | & ; (redirection/control) - * - \ " # ~ newlines - * - * @param arg - The string to escape - * @returns The escaped string (without surrounding quotes - caller adds those) - */ -export function escapeShellArg(arg: string): string { - // Replace single quotes with the escape sequence: '\'' - // This ends the current single-quoted string, adds an escaped literal quote, - // and starts a new single-quoted string. - return arg.replace(/'/g, "'\\''"); +export interface HindsightClientOptions { + llmProvider: string; + llmApiKey: string; + llmModel?: string; + embedVersion?: string; + embedPackagePath?: string; + apiUrl?: string; // Direct HTTP mode — bypass subprocess + apiToken?: string; // Auth header for HTTP mode } export class HindsightClient { - private bankId: string = 'default'; // Always use default bank + private bankId: string = 'default'; private llmProvider: string; private llmApiKey: string; private llmModel?: string; private embedVersion: string; private embedPackagePath?: string; + private apiUrl?: string; + private apiToken?: string; - constructor(llmProvider: string, llmApiKey: string, llmModel?: string, embedVersion: string = 'latest', embedPackagePath?: string) { - this.llmProvider = llmProvider; - this.llmApiKey = llmApiKey; - this.llmModel = llmModel; - this.embedVersion = embedVersion || 'latest'; - this.embedPackagePath = embedPackagePath; + constructor(opts: HindsightClientOptions) { + this.llmProvider = opts.llmProvider; + this.llmApiKey = opts.llmApiKey; + this.llmModel = opts.llmModel; + this.embedVersion = opts.embedVersion || 'latest'; + this.embedPackagePath = opts.embedPackagePath; + this.apiUrl = opts.apiUrl?.replace(/\/$/, ''); // strip trailing slash + this.apiToken = opts.apiToken; + } + + private get httpMode(): boolean { + return !!this.apiUrl; } /** - * Get the command prefix to run hindsight-embed (either local or from PyPI) + * Get the command and base args to run hindsight-embed. + * Returns [command, ...baseArgs] for use with execFile/spawn (no shell). */ - private getEmbedCommandPrefix(): string { + private getEmbedCommand(): string[] { if (this.embedPackagePath) { - // Local package: uv run --directory hindsight-embed - return `uv run --directory ${this.embedPackagePath} hindsight-embed`; - } else { - // PyPI package: uvx hindsight-embed@version - const embedPackage = this.embedVersion ? `hindsight-embed@${this.embedVersion}` : 'hindsight-embed@latest'; - return `uvx ${embedPackage}`; + return ['uv', 'run', '--directory', this.embedPackagePath, 'hindsight-embed']; } + const embedPackage = this.embedVersion ? `hindsight-embed@${this.embedVersion}` : 'hindsight-embed@latest'; + return ['uvx', embedPackage]; + } + + private httpHeaders(): Record { + const headers: Record = { 'Content-Type': 'application/json' }; + if (this.apiToken) { + headers['Authorization'] = `Bearer ${this.apiToken}`; + } + return headers; } setBankId(bankId: string): void { this.bankId = bankId; } + // --- setBankMission --- + async setBankMission(mission: string): Promise { if (!mission || mission.trim().length === 0) { return; } - const escapedMission = escapeShellArg(mission); - const embedCmd = this.getEmbedCommandPrefix(); - const cmd = `${embedCmd} --profile openclaw bank mission ${this.bankId} '${escapedMission}'`; + if (this.httpMode) { + return this.setBankMissionHttp(mission); + } + return this.setBankMissionSubprocess(mission); + } + private async setBankMissionHttp(mission: string): Promise { try { - const { stdout } = await execAsync(cmd); + const url = `${this.apiUrl}/v1/default/banks/${encodeURIComponent(this.bankId)}`; + const res = await fetch(url, { + method: 'PUT', + headers: this.httpHeaders(), + body: JSON.stringify({ mission }), + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`HTTP ${res.status}: ${body}`); + } + console.log(`[Hindsight] Bank mission set via HTTP`); + } catch (error) { + console.warn(`[Hindsight] Could not set bank mission (bank may not exist yet): ${error}`); + } + } + + private async setBankMissionSubprocess(mission: string): Promise { + const [cmd, ...baseArgs] = this.getEmbedCommand(); + const args = [...baseArgs, '--profile', 'openclaw', 'bank', 'mission', this.bankId, sanitize(mission)]; + try { + const { stdout } = await execFileAsync(cmd, args, { maxBuffer: MAX_BUFFER }); console.log(`[Hindsight] Bank mission set: ${stdout.trim()}`); } catch (error) { // Don't fail if mission set fails - bank might not exist yet, will be created on first retain @@ -103,24 +130,65 @@ export class HindsightClient { } } + // --- retain --- + async retain(request: RetainRequest): Promise { + if (this.httpMode) { + return this.retainHttp(request); + } + return this.retainSubprocess(request); + } + + private async retainHttp(request: RetainRequest): Promise { + const url = `${this.apiUrl}/v1/default/banks/${encodeURIComponent(this.bankId)}/memories`; + const body = { + items: [{ + content: request.content, + document_id: request.document_id || 'conversation', + metadata: request.metadata, + }], + async: true, + }; + + const res = await fetch(url, { + method: 'POST', + headers: this.httpHeaders(), + body: JSON.stringify(body), + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Failed to retain memory (HTTP ${res.status}): ${text}`); + } + + const data = await res.json(); + console.log(`[Hindsight] Retained via HTTP (async): ${JSON.stringify(data).substring(0, 200)}`); + + return { + message: 'Memory queued for background processing', + document_id: request.document_id || 'conversation', + memory_unit_ids: [], + }; + } + + private async retainSubprocess(request: RetainRequest): Promise { const docId = request.document_id || 'conversation'; // Write content to a temp file to avoid E2BIG (ARG_MAX) errors when passing - // large conversations as shell arguments via execAsync. + // large conversations as arguments. const tempDir = join(tmpdir(), `hindsight_${randomBytes(8).toString('hex')}`); const safeFilename = sanitizeFilename(docId); const tempFile = join(tempDir, `${safeFilename}.txt`); try { await mkdir(tempDir, { recursive: true }); - await writeFile(tempFile, request.content, 'utf8'); + await writeFile(tempFile, sanitize(request.content), 'utf8'); - const escapedTempFile = escapeShellArg(tempFile); - const embedCmd = this.getEmbedCommandPrefix(); - const cmd = `${embedCmd} --profile openclaw memory retain-files ${this.bankId} '${escapedTempFile}' --async`; + const [cmd, ...baseArgs] = this.getEmbedCommand(); + const args = [...baseArgs, '--profile', 'openclaw', 'memory', 'retain-files', this.bankId, tempFile, '--async']; - const { stdout } = await execAsync(cmd); + const { stdout } = await execFileAsync(cmd, args, { maxBuffer: MAX_BUFFER }); console.log(`[Hindsight] Retained (async): ${stdout.trim()}`); return { @@ -129,21 +197,73 @@ export class HindsightClient { memory_unit_ids: [], }; } catch (error) { - throw new Error(`Failed to retain memory: ${error}`); + throw new Error(`Failed to retain memory: ${error}`, { cause: error }); } finally { await rm(tempDir, { recursive: true, force: true }).catch(() => {}); } } - async recall(request: RecallRequest): Promise { - const query = escapeShellArg(request.query); - const maxTokens = request.max_tokens || 1024; + // --- recall --- - const embedCmd = this.getEmbedCommandPrefix(); - const cmd = `${embedCmd} --profile openclaw memory recall ${this.bankId} '${query}' --output json --max-tokens ${maxTokens}`; + async recall(request: RecallRequest, timeoutMs?: number): Promise { + if (this.httpMode) { + return this.recallHttp(request, timeoutMs); + } + return this.recallSubprocess(request, timeoutMs); + } + + private async recallHttp(request: RecallRequest, timeoutMs?: number): Promise { + const url = `${this.apiUrl}/v1/default/banks/${encodeURIComponent(this.bankId)}/memories/recall`; + // Defense-in-depth: truncate query to stay under API's 500-token limit + const MAX_QUERY_CHARS = 800; + const query = request.query.length > MAX_QUERY_CHARS + ? (console.warn(`[Hindsight] Truncating recall query from ${request.query.length} to ${MAX_QUERY_CHARS} chars`), + request.query.substring(0, MAX_QUERY_CHARS)) + : request.query; + const body = { + query, + max_tokens: request.max_tokens || 1024, + }; + + const res = await fetch(url, { + method: 'POST', + headers: this.httpHeaders(), + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs ?? DEFAULT_TIMEOUT_MS), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Failed to recall memories (HTTP ${res.status}): ${text}`); + } + + const response = await res.json() as { results?: any[] }; + const results = response.results || []; + + return { + results: results.map((r: any) => ({ + content: r.text || r.content || '', + score: r.score ?? 1.0, + metadata: { + document_id: r.document_id, + chunk_id: r.chunk_id, + ...r.metadata, + }, + })), + }; + } + + private async recallSubprocess(request: RecallRequest, timeoutMs?: number): Promise { + const query = sanitize(request.query); + const maxTokens = request.max_tokens || 1024; + const [cmd, ...baseArgs] = this.getEmbedCommand(); + const args = [...baseArgs, '--profile', 'openclaw', 'memory', 'recall', this.bankId, query, '--output', 'json', '--max-tokens', String(maxTokens)]; try { - const { stdout } = await execAsync(cmd); + const { stdout } = await execFileAsync(cmd, args, { + maxBuffer: MAX_BUFFER, + timeout: timeoutMs ?? 30_000, // subprocess gets a longer default + }); // Parse JSON output - returns { entities: {...}, results: [...] } const response = JSON.parse(stdout); @@ -161,7 +281,7 @@ export class HindsightClient { })), }; } catch (error) { - throw new Error(`Failed to recall memories: ${error}`); + throw new Error(`Failed to recall memories: ${error}`, { cause: error }); } } } diff --git a/hindsight-integrations/openclaw/src/index.ts b/hindsight-integrations/openclaw/src/index.ts index 0eba3089..d11da8b0 100644 --- a/hindsight-integrations/openclaw/src/index.ts +++ b/hindsight-integrations/openclaw/src/index.ts @@ -1,6 +1,6 @@ import type { MoltbotPluginAPI, PluginConfig } from './types.js'; import { HindsightEmbedManager } from './embed-manager.js'; -import { HindsightClient } from './client.js'; +import { HindsightClient, type HindsightClientOptions } from './client.js'; import { dirname } from 'path'; import { fileURLToPath } from 'url'; @@ -17,13 +17,90 @@ let currentPluginConfig: PluginConfig | null = null; // Track which banks have had their mission set (to avoid re-setting on every request) const banksWithMissionSet = new Set(); +// In-flight recall deduplication: concurrent recalls for the same bank reuse one promise +import type { RecallResponse } from './types.js'; +const inflightRecalls = new Map>(); +const RECALL_TIMEOUT_MS = 10_000; + +// Cooldown + guard to prevent concurrent reinit attempts +let lastReinitAttempt = 0; +let isReinitInProgress = false; +const REINIT_COOLDOWN_MS = 30_000; + +/** + * 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. + */ +async function lazyReinit(): Promise { + const now = Date.now(); + if (now - lastReinitAttempt < REINIT_COOLDOWN_MS || isReinitInProgress) { + return; + } + isReinitInProgress = true; + lastReinitAttempt = now; + + const config = currentPluginConfig; + if (!config) { + isReinitInProgress = false; + return; + } + + const externalApi = detectExternalApi(config); + if (!externalApi.apiUrl) { + isReinitInProgress = false; + return; // Only external API mode supports lazy reinit + } + + console.log('[Hindsight] Attempting lazy re-initialization...'); + try { + await checkExternalApiHealth(externalApi.apiUrl); + + // 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); + client = new HindsightClient(buildClientOptions(llmConfig, config, externalApi)); + const defaultBankId = deriveBankId(undefined, config); + client.setBankId(defaultBankId); + + if (config.bankMission && !config.dynamicBankId) { + await client.setBankMission(config.bankMission); + } + + usingExternalApi = true; + isInitialized = true; + // Replace the rejected initPromise with a resolved one + initPromise = Promise.resolve(); + console.log('[Hindsight] ✓ Lazy re-initialization succeeded'); + } catch (error) { + console.warn(`[Hindsight] Lazy re-initialization failed (will 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) {await initPromise;} + if (initPromise) { + 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. @@ -76,7 +153,7 @@ interface PluginHookAgentContext { /** * Derive a bank ID from the agent context. - * Creates channel-specific banks: {messageProvider}-{channelId} + * Creates per-user banks: {messageProvider}-{senderId} * Falls back to default bank when context is unavailable. */ function deriveBankId( @@ -91,10 +168,10 @@ function deriveBankId( } const channelType = ctx?.messageProvider || 'unknown'; - const channelId = ctx?.channelId || 'default'; + const userId = ctx?.senderId || 'default'; - // Build bank ID: {prefix?}-{channelType}-{channelId} - const baseBankId = `${channelType}-${channelId}`; + // Build bank ID: {prefix?}-{channelType}-{senderId} + const baseBankId = `${channelType}-${userId}`; return pluginConfig.bankIdPrefix ? `${pluginConfig.bankIdPrefix}-${baseBankId}` : baseBankId; @@ -233,6 +310,25 @@ function detectExternalApi(pluginConfig?: PluginConfig): { return { apiUrl, apiToken }; } +/** + * Build HindsightClientOptions from LLM config, plugin config, and external API settings. + */ +function buildClientOptions( + llmConfig: { provider: string; apiKey: string; model?: string }, + pluginCfg: PluginConfig, + externalApi: { apiUrl: string | null; apiToken: string | null }, +): HindsightClientOptions { + return { + llmProvider: llmConfig.provider, + llmApiKey: llmConfig.apiKey, + 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. @@ -352,9 +448,9 @@ export default function (api: MoltbotPluginAPI) { console.log('[Hindsight] External API mode - skipping local daemon...'); await checkExternalApiHealth(externalApi.apiUrl); - // Initialize client (CLI commands will use external API via env vars) - console.log('[Hindsight] Creating HindsightClient...'); - client = new HindsightClient(llmConfig.provider, llmConfig.apiKey, llmConfig.model, pluginConfig.embedVersion, pluginConfig.embedPackagePath); + // Initialize client with direct HTTP mode + console.log('[Hindsight] Creating HindsightClient (HTTP mode)...'); + client = new HindsightClient(buildClientOptions(llmConfig, pluginConfig, externalApi)); // Set default bank (will be overridden per-request when dynamic bank IDs are enabled) const defaultBankId = deriveBankId(undefined, pluginConfig); @@ -388,9 +484,9 @@ export default function (api: MoltbotPluginAPI) { console.log('[Hindsight] Starting embedded server...'); await embedManager.start(); - // Initialize client - console.log('[Hindsight] Creating HindsightClient...'); - client = new HindsightClient(llmConfig.provider, llmConfig.apiKey, llmConfig.model, pluginConfig.embedVersion, pluginConfig.embedPackagePath); + // Initialize client (local daemon mode — no apiUrl) + console.log('[Hindsight] Creating HindsightClient (subprocess mode)...'); + client = new HindsightClient(buildClientOptions(llmConfig, pluginConfig, { apiUrl: null, apiToken: null })); // Set default bank (will be overridden per-request when dynamic bank IDs are enabled) const defaultBankId = deriveBankId(undefined, pluginConfig); @@ -484,7 +580,7 @@ export default function (api: MoltbotPluginAPI) { await checkExternalApiHealth(externalApi.apiUrl); - client = new HindsightClient(llmConfig.provider, llmConfig.apiKey, llmConfig.model, reinitPluginConfig.embedVersion, reinitPluginConfig.embedPackagePath); + client = new HindsightClient(buildClientOptions(llmConfig, reinitPluginConfig, externalApi)); const defaultBankId = deriveBankId(undefined, reinitPluginConfig); client.setBankId(defaultBankId); @@ -509,7 +605,7 @@ export default function (api: MoltbotPluginAPI) { await embedManager.start(); - client = new HindsightClient(llmConfig.provider, llmConfig.apiKey, llmConfig.model, reinitPluginConfig.embedVersion, reinitPluginConfig.embedPackagePath); + client = new HindsightClient(buildClientOptions(llmConfig, reinitPluginConfig, { apiUrl: null, apiToken: null })); const defaultBankId = deriveBankId(undefined, reinitPluginConfig); client.setBankId(defaultBankId); @@ -573,45 +669,53 @@ export default function (api: MoltbotPluginAPI) { const bankId = deriveBankId(ctx, pluginConfig); console.log(`[Hindsight] before_agent_start - bank: ${bankId}, channel: ${ctx?.messageProvider}/${ctx?.channelId}`); - // Get the user's latest message for recall - // Prefer rawMessage (clean user text) over prompt (envelope-formatted) - let prompt = event.rawMessage ?? event.prompt; - if (!prompt || typeof prompt !== 'string' || prompt.length < 5) { - return; // Skip very short messages + // 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. + let recallQuery = event.rawMessage; + if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.trim().length < 5) { + // Fall back to prompt but strip envelope formatting + recallQuery = event.prompt; + if (!recallQuery || typeof recallQuery !== 'string' || recallQuery.length < 5) { + return; + } + + // 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*$/, ''); + + recallQuery = cleaned.trim() || recallQuery; } - // Strip envelope-formatted prompts from any channel - // The prompt may contain: System: lines, abort hints, [Channel ...] header, [from: ...] suffix - let cleaned = prompt; - - // 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 - // Handles any channel: Telegram, Slack, Discord, WhatsApp, Signal, etc. - // Uses [\s\S]+ instead of .+ to support multiline messages - 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*$/, ''); - - prompt = cleaned.trim() || prompt; - + let prompt = recallQuery.trim(); if (prompt.length < 5) { return; // Skip very short messages after extraction } + // Truncate — Hindsight API recall has a 500 token limit; 800 chars stays safely under even with non-ASCII + const MAX_RECALL_QUERY_CHARS = 800; + if (prompt.length > MAX_RECALL_QUERY_CHARS) { + prompt = prompt.substring(0, MAX_RECALL_QUERY_CHARS); + } + // Wait for client to be ready const clientGlobal = (global as any).__hindsightClient; if (!clientGlobal) { @@ -630,11 +734,20 @@ export default function (api: MoltbotPluginAPI) { console.log(`[Hindsight] Auto-recall for bank ${bankId}, prompt: ${prompt.substring(0, 50)}`); - // Recall relevant memories - const response = await client.recall({ - query: prompt, - max_tokens: 2048, - }); + // Recall with deduplication: reuse in-flight request for same bank + const recallKey = bankId; + const existing = inflightRecalls.get(recallKey); + let recallPromise: Promise; + if (existing) { + console.log(`[Hindsight] Reusing in-flight recall for bank ${bankId}`); + recallPromise = existing; + } else { + recallPromise = client.recall({ query: prompt, max_tokens: 2048 }, RECALL_TIMEOUT_MS); + inflightRecalls.set(recallKey, recallPromise); + void recallPromise.catch(() => {}).finally(() => inflightRecalls.delete(recallKey)); + } + + const response = await recallPromise; if (!response.results || response.results.length === 0) { console.log('[Hindsight] No memories found for auto-recall'); @@ -656,7 +769,13 @@ User message: ${prompt} // Inject context before the user message return { prependContext: contextMessage }; } catch (error) { - console.error('[Hindsight] Auto-recall error:', error); + if (error instanceof DOMException && error.name === 'TimeoutError') { + console.warn(`[Hindsight] Auto-recall timed out after ${RECALL_TIMEOUT_MS}ms, skipping memory injection`); + } else if (error instanceof Error && error.name === 'AbortError') { + console.warn(`[Hindsight] Auto-recall aborted after ${RECALL_TIMEOUT_MS}ms, skipping memory injection`); + } else { + console.error('[Hindsight] Auto-recall error:', error); + } return; } }); @@ -740,7 +859,7 @@ User message: ${prompt} document_id: documentId, metadata: { retained_at: new Date().toISOString(), - message_count: event.messages.length, + message_count: String(event.messages.length), channel_type: effectiveCtx?.messageProvider, channel_id: effectiveCtx?.channelId, sender_id: effectiveCtx?.senderId,