feat(openclaw): add dynamic per-channel memory banks (#290)
Add support for per-channel memory isolation in OpenClaw plugin.
Each channel (Slack, Telegram, Discord, etc.) gets its own memory bank,
preventing memory leakage between channels.
Changes:
- Add deriveBankId() to create channel-specific bank IDs
- Bank ID format: {messageProvider}-{channelId} (e.g., slack-C123)
- Add getClientForContext() for context-aware client access
- Update hook handlers to (event, ctx) signature
- Set bank mission on first use per dynamic bank
- Add dynamicBankId and bankIdPrefix config options
Configuration:
- dynamicBankId: true (default) enables per-channel isolation
- bankIdPrefix: optional prefix for namespacing (e.g., "prod")
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d02affd8f2
commit
9a776e9f58
3 changed files with 168 additions and 36 deletions
|
|
@ -55,6 +55,15 @@
|
|||
"hindsightApiToken": {
|
||||
"type": "string",
|
||||
"description": "API token for external Hindsight API authentication. Required if the external API has authentication enabled."
|
||||
},
|
||||
"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.",
|
||||
"default": true
|
||||
},
|
||||
"bankIdPrefix": {
|
||||
"type": "string",
|
||||
"description": "Optional prefix for bank IDs (e.g., 'prod' results in 'prod-slack-C123'). Useful for separating environments."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
|
@ -103,6 +112,14 @@
|
|||
"hindsightApiToken": {
|
||||
"label": "External API Token",
|
||||
"placeholder": "API token if external API requires authentication"
|
||||
},
|
||||
"dynamicBankId": {
|
||||
"label": "Dynamic Bank IDs",
|
||||
"placeholder": "true (isolate memories per channel)"
|
||||
},
|
||||
"bankIdPrefix": {
|
||||
"label": "Bank ID Prefix",
|
||||
"placeholder": "e.g., prod, staging (optional)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@ let initPromise: Promise<void> | null = null;
|
|||
let isInitialized = false;
|
||||
let usingExternalApi = false; // Track if using external API (skip daemon management)
|
||||
|
||||
// Store the current plugin config for bank ID derivation
|
||||
let currentPluginConfig: PluginConfig | null = null;
|
||||
|
||||
// Track which banks have had their mission set (to avoid re-setting on every request)
|
||||
const banksWithMissionSet = new Set<string>();
|
||||
|
||||
// Global access for hooks (Moltbot loads hooks separately)
|
||||
if (typeof global !== 'undefined') {
|
||||
(global as any).__hindsightClient = {
|
||||
|
|
@ -19,6 +25,32 @@ if (typeof global !== 'undefined') {
|
|||
if (isInitialized) return;
|
||||
if (initPromise) await initPromise;
|
||||
},
|
||||
/**
|
||||
* Get a client configured for a specific agent context.
|
||||
* Derives the bank ID from the context for per-channel isolation.
|
||||
* Also ensures the bank mission is set on first use.
|
||||
*/
|
||||
getClientForContext: async (ctx: PluginHookAgentContext | undefined) => {
|
||||
if (!client) return null;
|
||||
const config = currentPluginConfig || {};
|
||||
const bankId = deriveBankId(ctx, config);
|
||||
client.setBankId(bankId);
|
||||
|
||||
// Set bank mission on first use of this bank (if configured)
|
||||
if (config.bankMission && config.dynamicBankId && !banksWithMissionSet.has(bankId)) {
|
||||
try {
|
||||
await client.setBankMission(config.bankMission);
|
||||
banksWithMissionSet.add(bankId);
|
||||
console.log(`[Hindsight] Set mission for new bank: ${bankId}`);
|
||||
} catch (error) {
|
||||
// Log but don't fail - bank mission is not critical
|
||||
console.warn(`[Hindsight] Could not set bank mission for ${bankId}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return client;
|
||||
},
|
||||
getPluginConfig: () => currentPluginConfig,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -26,8 +58,47 @@ if (typeof global !== 'undefined') {
|
|||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Default bank name
|
||||
const BANK_NAME = 'openclaw';
|
||||
// Default bank name (fallback when channel context not available)
|
||||
const DEFAULT_BANK_NAME = 'openclaw';
|
||||
|
||||
/**
|
||||
* Agent context passed to plugin hooks.
|
||||
* These fields are populated by OpenClaw when invoking hooks.
|
||||
*/
|
||||
interface PluginHookAgentContext {
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
workspaceDir?: string;
|
||||
messageProvider?: string;
|
||||
channelId?: string;
|
||||
senderId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a bank ID from the agent context.
|
||||
* Creates channel-specific banks: {messageProvider}-{channelId}
|
||||
* Falls back to default bank when context is unavailable.
|
||||
*/
|
||||
function deriveBankId(
|
||||
ctx: PluginHookAgentContext | undefined,
|
||||
pluginConfig: PluginConfig
|
||||
): string {
|
||||
// If dynamic bank ID is disabled, use static bank
|
||||
if (pluginConfig.dynamicBankId === false) {
|
||||
return pluginConfig.bankIdPrefix
|
||||
? `${pluginConfig.bankIdPrefix}-${DEFAULT_BANK_NAME}`
|
||||
: DEFAULT_BANK_NAME;
|
||||
}
|
||||
|
||||
const channelType = ctx?.messageProvider || 'unknown';
|
||||
const channelId = ctx?.channelId || 'default';
|
||||
|
||||
// Build bank ID: {prefix?}-{channelType}-{channelId}
|
||||
const baseBankId = `${channelType}-${channelId}`;
|
||||
return pluginConfig.bankIdPrefix
|
||||
? `${pluginConfig.bankIdPrefix}-${baseBankId}`
|
||||
: baseBankId;
|
||||
}
|
||||
|
||||
// Provider detection from standard env vars
|
||||
const PROVIDER_DETECTION = [
|
||||
|
|
@ -195,6 +266,10 @@ function getPluginConfig(api: MoltbotPluginAPI): PluginConfig {
|
|||
llmApiKeyEnv: config.llmApiKeyEnv,
|
||||
hindsightApiUrl: config.hindsightApiUrl,
|
||||
hindsightApiToken: config.hindsightApiToken,
|
||||
apiPort: config.apiPort || 9077,
|
||||
// Dynamic bank ID options (default: enabled)
|
||||
dynamicBankId: config.dynamicBankId !== false,
|
||||
bankIdPrefix: config.bankIdPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -206,6 +281,9 @@ export default function (api: MoltbotPluginAPI) {
|
|||
console.log('[Hindsight] Getting plugin config...');
|
||||
const pluginConfig = getPluginConfig(api);
|
||||
|
||||
// Store config globally for bank ID derivation in hooks
|
||||
currentPluginConfig = pluginConfig;
|
||||
|
||||
// Detect LLM configuration (env vars > plugin config > auto-detect)
|
||||
console.log('[Hindsight] Detecting LLM config...');
|
||||
const llmConfig = detectLLMConfig(pluginConfig);
|
||||
|
|
@ -221,6 +299,15 @@ export default function (api: MoltbotPluginAPI) {
|
|||
if (pluginConfig.bankMission) {
|
||||
console.log(`[Hindsight] Custom bank mission configured: "${pluginConfig.bankMission.substring(0, 50)}..."`);
|
||||
}
|
||||
|
||||
// Log dynamic bank ID mode
|
||||
if (pluginConfig.dynamicBankId) {
|
||||
const prefixInfo = pluginConfig.bankIdPrefix ? ` (prefix: ${pluginConfig.bankIdPrefix})` : '';
|
||||
console.log(`[Hindsight] ✓ Dynamic bank IDs enabled${prefixInfo} - each channel gets isolated memory`);
|
||||
} else {
|
||||
console.log(`[Hindsight] Dynamic bank IDs disabled - using static bank: ${DEFAULT_BANK_NAME}`);
|
||||
}
|
||||
|
||||
// Detect external API mode
|
||||
const externalApi = detectExternalApi(pluginConfig);
|
||||
|
||||
|
|
@ -256,12 +343,14 @@ export default function (api: MoltbotPluginAPI) {
|
|||
console.log('[Hindsight] Creating HindsightClient...');
|
||||
client = new HindsightClient(llmConfig.provider, llmConfig.apiKey, llmConfig.model, pluginConfig.embedVersion, pluginConfig.embedPackagePath);
|
||||
|
||||
// Use openclaw bank
|
||||
console.log(`[Hindsight] Using bank: ${BANK_NAME}`);
|
||||
client.setBankId(BANK_NAME);
|
||||
// Set default bank (will be overridden per-request when dynamic bank IDs are enabled)
|
||||
const defaultBankId = deriveBankId(undefined, pluginConfig);
|
||||
console.log(`[Hindsight] Default bank: ${defaultBankId}`);
|
||||
client.setBankId(defaultBankId);
|
||||
|
||||
// Set bank mission
|
||||
if (pluginConfig.bankMission) {
|
||||
// Note: Bank mission will be set per-bank when dynamic bank IDs are enabled
|
||||
// For now, set it on the default bank
|
||||
if (pluginConfig.bankMission && !pluginConfig.dynamicBankId) {
|
||||
console.log(`[Hindsight] Setting bank mission...`);
|
||||
await client.setBankMission(pluginConfig.bankMission);
|
||||
}
|
||||
|
|
@ -290,12 +379,14 @@ export default function (api: MoltbotPluginAPI) {
|
|||
console.log('[Hindsight] Creating HindsightClient...');
|
||||
client = new HindsightClient(llmConfig.provider, llmConfig.apiKey, llmConfig.model, pluginConfig.embedVersion, pluginConfig.embedPackagePath);
|
||||
|
||||
// Use openclaw bank
|
||||
console.log(`[Hindsight] Using bank: ${BANK_NAME}`);
|
||||
client.setBankId(BANK_NAME);
|
||||
// Set default bank (will be overridden per-request when dynamic bank IDs are enabled)
|
||||
const defaultBankId = deriveBankId(undefined, pluginConfig);
|
||||
console.log(`[Hindsight] Default bank: ${defaultBankId}`);
|
||||
client.setBankId(defaultBankId);
|
||||
|
||||
// Set bank mission
|
||||
if (pluginConfig.bankMission) {
|
||||
// Note: Bank mission will be set per-bank when dynamic bank IDs are enabled
|
||||
// For now, set it on the default bank
|
||||
if (pluginConfig.bankMission && !pluginConfig.dynamicBankId) {
|
||||
console.log(`[Hindsight] Setting bank mission...`);
|
||||
await client.setBankMission(pluginConfig.bankMission);
|
||||
}
|
||||
|
|
@ -364,6 +455,7 @@ export default function (api: MoltbotPluginAPI) {
|
|||
if (!isInitialized) {
|
||||
console.log('[Hindsight] Reinitializing...');
|
||||
const reinitPluginConfig = getPluginConfig(api);
|
||||
currentPluginConfig = reinitPluginConfig;
|
||||
const llmConfig = detectLLMConfig(reinitPluginConfig);
|
||||
const externalApi = detectExternalApi(reinitPluginConfig);
|
||||
const apiPort = reinitPluginConfig.apiPort || 9077;
|
||||
|
|
@ -379,9 +471,10 @@ export default function (api: MoltbotPluginAPI) {
|
|||
await checkExternalApiHealth(externalApi.apiUrl);
|
||||
|
||||
client = new HindsightClient(llmConfig.provider, llmConfig.apiKey, llmConfig.model, reinitPluginConfig.embedVersion, reinitPluginConfig.embedPackagePath);
|
||||
client.setBankId(BANK_NAME);
|
||||
const defaultBankId = deriveBankId(undefined, reinitPluginConfig);
|
||||
client.setBankId(defaultBankId);
|
||||
|
||||
if (reinitPluginConfig.bankMission) {
|
||||
if (reinitPluginConfig.bankMission && !reinitPluginConfig.dynamicBankId) {
|
||||
await client.setBankMission(reinitPluginConfig.bankMission);
|
||||
}
|
||||
|
||||
|
|
@ -403,9 +496,10 @@ export default function (api: MoltbotPluginAPI) {
|
|||
await embedManager.start();
|
||||
|
||||
client = new HindsightClient(llmConfig.provider, llmConfig.apiKey, llmConfig.model, reinitPluginConfig.embedVersion, reinitPluginConfig.embedPackagePath);
|
||||
client.setBankId(BANK_NAME);
|
||||
const defaultBankId = deriveBankId(undefined, reinitPluginConfig);
|
||||
client.setBankId(defaultBankId);
|
||||
|
||||
if (reinitPluginConfig.bankMission) {
|
||||
if (reinitPluginConfig.bankMission && !reinitPluginConfig.dynamicBankId) {
|
||||
await client.setBankMission(reinitPluginConfig.bankMission);
|
||||
}
|
||||
|
||||
|
|
@ -438,22 +532,29 @@ export default function (api: MoltbotPluginAPI) {
|
|||
|
||||
console.log('[Hindsight] Plugin loaded successfully');
|
||||
|
||||
// Register agent_end hook for auto-retention
|
||||
console.log('[Hindsight] Registering agent_end hook...');
|
||||
// Store session key for retention
|
||||
// Register agent hooks for auto-recall and auto-retention
|
||||
console.log('[Hindsight] Registering agent hooks...');
|
||||
|
||||
// Store session key and context for retention
|
||||
let currentSessionKey: string | undefined;
|
||||
let currentAgentContext: PluginHookAgentContext | undefined;
|
||||
|
||||
// Auto-recall: Inject relevant memories before agent processes the message
|
||||
api.on('before_agent_start', async (context: any) => {
|
||||
// Hook signature: (event, ctx) where event has {prompt, messages?} and ctx has agent context
|
||||
api.on('before_agent_start', async (event: any, ctx?: PluginHookAgentContext) => {
|
||||
try {
|
||||
// Capture session key
|
||||
if (context.sessionKey) {
|
||||
currentSessionKey = context.sessionKey as string;
|
||||
console.log('[Hindsight] Captured session key:', currentSessionKey);
|
||||
// Capture session key and context for use in agent_end
|
||||
if (ctx?.sessionKey) {
|
||||
currentSessionKey = ctx.sessionKey;
|
||||
}
|
||||
currentAgentContext = ctx;
|
||||
|
||||
// Derive bank ID from context
|
||||
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
|
||||
let prompt = context.prompt;
|
||||
let prompt = event.prompt;
|
||||
if (!prompt || typeof prompt !== 'string' || prompt.length < 5) {
|
||||
return; // Skip very short messages
|
||||
}
|
||||
|
|
@ -477,13 +578,14 @@ export default function (api: MoltbotPluginAPI) {
|
|||
|
||||
await clientGlobal.waitForReady();
|
||||
|
||||
const client = clientGlobal.getClient();
|
||||
// Get client configured for this context's bank (async to handle mission setup)
|
||||
const client = await clientGlobal.getClientForContext(ctx);
|
||||
if (!client) {
|
||||
console.log('[Hindsight] Client not initialized, skipping auto-recall');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Hindsight] Auto-recall for prompt:', prompt.substring(0, 50));
|
||||
console.log(`[Hindsight] Auto-recall for bank ${bankId}, prompt: ${prompt.substring(0, 50)}`);
|
||||
|
||||
// Recall relevant memories (up to 512 tokens)
|
||||
const response = await client.recall({
|
||||
|
|
@ -506,7 +608,7 @@ ${memoriesJson}
|
|||
User message: ${prompt}
|
||||
</hindsight_memories>`;
|
||||
|
||||
console.log(`[Hindsight] Auto-recall: Injecting ${response.results.length} memories`);
|
||||
console.log(`[Hindsight] Auto-recall: Injecting ${response.results.length} memories from bank ${bankId}`);
|
||||
|
||||
// Inject context before the user message
|
||||
return { prependContext: contextMessage };
|
||||
|
|
@ -516,9 +618,15 @@ User message: ${prompt}
|
|||
}
|
||||
});
|
||||
|
||||
api.on('agent_end', async (event: any) => {
|
||||
// Hook signature: (event, ctx) where event has {messages, success, error?, durationMs?}
|
||||
api.on('agent_end', async (event: any, ctx?: PluginHookAgentContext) => {
|
||||
try {
|
||||
console.log('[Hindsight Hook] agent_end triggered');
|
||||
// Use context from this hook, or fall back to context captured in before_agent_start
|
||||
const effectiveCtx = ctx || currentAgentContext;
|
||||
|
||||
// Derive bank ID from context
|
||||
const bankId = deriveBankId(effectiveCtx, pluginConfig);
|
||||
console.log(`[Hindsight Hook] agent_end triggered - bank: ${bankId}`);
|
||||
|
||||
// Check event success and messages
|
||||
if (!event.success || !Array.isArray(event.messages) || event.messages.length === 0) {
|
||||
|
|
@ -535,7 +643,8 @@ User message: ${prompt}
|
|||
|
||||
await clientGlobal.waitForReady();
|
||||
|
||||
const client = clientGlobal.getClient();
|
||||
// Get client configured for this context's bank (async to handle mission setup)
|
||||
const client = await clientGlobal.getClientForContext(effectiveCtx);
|
||||
if (!client) {
|
||||
console.warn('[Hindsight] Client not initialized, skipping retain');
|
||||
return;
|
||||
|
|
@ -566,8 +675,8 @@ User message: ${prompt}
|
|||
return;
|
||||
}
|
||||
|
||||
// Use session key as document ID
|
||||
const documentId = currentSessionKey || 'default-session';
|
||||
// Use session key as document ID (prefer context over captured value)
|
||||
const documentId = effectiveCtx?.sessionKey || currentSessionKey || 'default-session';
|
||||
|
||||
// Retain to Hindsight
|
||||
await client.retain({
|
||||
|
|
@ -576,15 +685,18 @@ User message: ${prompt}
|
|||
metadata: {
|
||||
retained_at: new Date().toISOString(),
|
||||
message_count: event.messages.length,
|
||||
channel_type: effectiveCtx?.messageProvider,
|
||||
channel_id: effectiveCtx?.channelId,
|
||||
sender_id: effectiveCtx?.senderId,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[Hindsight] Retained ${event.messages.length} messages for session ${documentId}`);
|
||||
console.log(`[Hindsight] Retained ${event.messages.length} messages to bank ${bankId} for session ${documentId}`);
|
||||
} catch (error) {
|
||||
console.error('[Hindsight] Error retaining messages:', error);
|
||||
}
|
||||
});
|
||||
console.log('[Hindsight] Hook registered');
|
||||
console.log('[Hindsight] Hooks registered');
|
||||
} catch (error) {
|
||||
console.error('[Hindsight] Plugin loading error:', error);
|
||||
if (error instanceof Error) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
export interface MoltbotPluginAPI {
|
||||
config: MoltbotConfig;
|
||||
registerService(config: ServiceConfig): void;
|
||||
on(event: string, handler: (context: any) => void | Promise<void | { prependContext?: string }>): void;
|
||||
// OpenClaw hook handler signature: (event, ctx?) where ctx contains channel/sender info
|
||||
on(event: string, handler: (event: any, ctx?: any) => void | Promise<void | { prependContext?: string }>): void;
|
||||
// Add more as needed
|
||||
}
|
||||
|
||||
|
|
@ -39,6 +40,8 @@ export interface PluginConfig {
|
|||
apiPort?: number; // Port for openclaw profile daemon (default: 9077)
|
||||
hindsightApiUrl?: string; // External Hindsight API URL (skips local daemon when set)
|
||||
hindsightApiToken?: string; // API token for external Hindsight API authentication
|
||||
dynamicBankId?: boolean; // Enable per-channel memory banks (default: true)
|
||||
bankIdPrefix?: string; // Prefix for bank IDs (e.g. 'prod' -> 'prod-slack-C123')
|
||||
}
|
||||
|
||||
export interface ServiceConfig {
|
||||
|
|
|
|||
Loading…
Reference in a new issue