fix(openclaw): shell safety, HTTP dual-mode, lazy reinit, per-user banks (#388)

- 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 <noreply@anthropic.com>
This commit is contained in:
Anton Evseev 2026-02-18 22:09:02 +10:00 committed by GitHub
parent 7c99feb018
commit c461013047
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 373 additions and 207 deletions

View file

@ -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

View file

@ -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);
});
});

View file

@ -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 <path> 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<string, string> {
const headers: Record<string, string> = { '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<void> {
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<void> {
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<void> {
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<RetainResponse> {
if (this.httpMode) {
return this.retainHttp(request);
}
return this.retainSubprocess(request);
}
private async retainHttp(request: RetainRequest): Promise<RetainResponse> {
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<RetainResponse> {
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<RecallResponse> {
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<RecallResponse> {
if (this.httpMode) {
return this.recallHttp(request, timeoutMs);
}
return this.recallSubprocess(request, timeoutMs);
}
private async recallHttp(request: RecallRequest, timeoutMs?: number): Promise<RecallResponse> {
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<RecallResponse> {
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 });
}
}
}

View file

@ -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<string>();
// 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 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<void> {
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<RecallResponse>;
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,