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:
parent
7c99feb018
commit
c461013047
4 changed files with 373 additions and 207 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue