diff --git a/hindsight-integrations/openclaw/src/index.test.ts b/hindsight-integrations/openclaw/src/index.test.ts index 1f99cd669..a2125a6bc 100644 --- a/hindsight-integrations/openclaw/src/index.test.ts +++ b/hindsight-integrations/openclaw/src/index.test.ts @@ -836,6 +836,72 @@ describe('session identity helpers', () => { }); }); + it('allows agent:*:main sessions through when agent banking is enabled', () => { + const result = getIdentitySkipReason( + { sessionKey: 'agent:project-alpha:main' }, + { dynamicBankGranularity: ['agent'] }, + ); + expect(result.reason).toBeUndefined(); + expect(result.resolvedCtx?.agentId).toBe('project-alpha'); + expect(result.resolvedCtx?.senderId).toBe('agent-user:project-alpha'); + }); + + it('allows provider main when agent banking is enabled', () => { + const result = getIdentitySkipReason( + { sessionKey: 'agent:main:main' }, + { dynamicBankGranularity: ['agent'] }, + ); + expect(result.reason).toBeUndefined(); + }); + + it('still skips cron/heartbeat/subagent providers when agent banking is enabled', () => { + const result = getIdentitySkipReason( + { sessionKey: 'agent:main:cron:nightly:cleanup' }, + { dynamicBankGranularity: ['agent'] }, + ); + expect(result.reason).toEqual({ + kind: 'final', + detail: 'operational session agent:main:cron:nightly:cleanup', + }); + }); + + it('synthesizes sender identity for anonymous CLI sessions when agent banking is enabled', () => { + const result = getIdentitySkipReason( + { agentId: 'project-beta', messageProvider: 'cli', senderId: 'anonymous' }, + { dynamicBankGranularity: ['agent'] }, + ); + expect(result.reason).toBeUndefined(); + expect(result.resolvedCtx?.senderId).toBe('agent-user:project-beta'); + }); + + it('allows agent:*:main sessions through when a static bankId is configured', () => { + const result = getIdentitySkipReason( + { sessionKey: 'agent:main:main' }, + { dynamicBankId: false, bankId: 'shared-bank' }, + ); + expect(result.reason).toBeUndefined(); + expect(result.resolvedCtx?.senderId).toBe('agent-user:main'); + }); + + it('does not broaden the carve-out when dynamicBankId is false but bankId is missing', () => { + const result = getIdentitySkipReason( + { sessionKey: 'agent:main:main' }, + { dynamicBankId: false }, + ); + expect(result.reason).toEqual({ + kind: 'final', + detail: 'internal main session agent:main:main', + }); + }); + + it('preserves default skip behavior when agent banking is not enabled', () => { + const result = getIdentitySkipReason({ sessionKey: 'agent:main:main' }, {}); + expect(result.reason).toEqual({ + kind: 'final', + detail: 'internal main session agent:main:main', + }); + }); + it('detects ephemeral operational text with or without transcript wrappers', () => { expect(isEphemeralOperationalText('A new session was started via /reset.')).toBe(true); expect( diff --git a/hindsight-integrations/openclaw/src/index.ts b/hindsight-integrations/openclaw/src/index.ts index d0d5c7860..cb49b5fb7 100644 --- a/hindsight-integrations/openclaw/src/index.ts +++ b/hindsight-integrations/openclaw/src/index.ts @@ -741,6 +741,7 @@ interface ResolveAndCacheIdentityOptions { ctx?: PluginHookAgentContext; senderIdHint?: string; dispatchChannel?: string; + pluginConfig?: PluginConfig; } function resolveAndCacheIdentity( @@ -787,7 +788,7 @@ function resolveAndCacheIdentity( cacheSessionIdentity(sessionKey, resolvedCtx); - const { reason: skipReason } = getIdentitySkipReason(resolvedCtx); + const { reason: skipReason } = getIdentitySkipReason(resolvedCtx, options.pluginConfig); if (sessionKey) { if (skipReason) { setCappedMapValue(skipHindsightTurnBySession, sessionKey, skipReason); @@ -801,15 +802,30 @@ function resolveAndCacheIdentity( export function getIdentitySkipReason( ctx: PluginHookAgentContext | undefined, + pluginConfig?: PluginConfig, ): { resolvedCtx: PluginHookAgentContext | undefined; reason?: IdentitySkipReason } { const resolvedCtx = resolveSessionIdentity(ctx); const sessionKey = resolvedCtx?.sessionKey; + // The "internal main" / "operational provider main" / "anonymous sender" filters + // exist to keep the default multi-tenant bank from being polluted by CLI/main + // sessions that lack a stable identity. They should NOT fire when the user has + // explicitly opted into a routing scheme that expects those sessions: + // - dynamicBankGranularity includes 'agent' → each agent (including 'main') + // gets its own bank + // - dynamicBankId === false with a configured bankId → user pinned a single + // named bank and wants every session retained into it + const agentBanking = pluginConfig?.dynamicBankGranularity?.includes('agent') ?? false; + const staticBanking = + pluginConfig?.dynamicBankId === false && + typeof pluginConfig?.bankId === 'string' && + pluginConfig.bankId.length > 0; + const allowCliSessions = agentBanking || staticBanking; if (typeof sessionKey === 'string') { if (/^agent:[^:]+:(cron|heartbeat|subagent):/.test(sessionKey)) { return { resolvedCtx, reason: finalSkipReason(`operational session ${sessionKey}`) }; } - if (/^agent:[^:]+:main$/.test(sessionKey)) { + if (!allowCliSessions && /^agent:[^:]+:main$/.test(sessionKey)) { return { resolvedCtx, reason: finalSkipReason(`internal main session ${sessionKey}`) }; } if (/^temp:/.test(sessionKey)) { @@ -817,14 +833,21 @@ export function getIdentitySkipReason( } } - if (resolvedCtx?.messageProvider && ['cron', 'heartbeat', 'subagent', 'main'].includes(resolvedCtx.messageProvider)) { + const operationalProviders = allowCliSessions + ? ['cron', 'heartbeat', 'subagent'] + : ['cron', 'heartbeat', 'subagent', 'main']; + if (resolvedCtx?.messageProvider && operationalProviders.includes(resolvedCtx.messageProvider)) { return { resolvedCtx, reason: finalSkipReason(`operational provider ${resolvedCtx.messageProvider}`) }; } if (!resolvedCtx?.messageProvider || resolvedCtx.messageProvider === 'unknown') { return { resolvedCtx, reason: retryableSkipReason('missing stable message provider') }; } if (!resolvedCtx?.senderId || resolvedCtx.senderId === 'anonymous') { - return { resolvedCtx, reason: retryableSkipReason('missing stable sender identity') }; + if (allowCliSessions && resolvedCtx?.agentId) { + resolvedCtx.senderId = `agent-user:${resolvedCtx.agentId}`; + } else { + return { resolvedCtx, reason: retryableSkipReason('missing stable sender identity') }; + } } if ( resolvedCtx.messageProvider === 'telegram' && @@ -1520,6 +1543,7 @@ export default function (api: MoltbotPluginAPI) { ctx?.senderId, }, dispatchChannel, + pluginConfig, }); if (skipReason) { @@ -1543,7 +1567,7 @@ export default function (api: MoltbotPluginAPI) { api.on('before_agent_start', async (event: any, ctx?: PluginHookAgentContext) => { try { const sessionKey = ctx?.sessionKey ?? (typeof event?.sessionKey === 'string' ? event.sessionKey : undefined); - const { resolvedCtx, skipReason } = resolveAndCacheIdentity({ sessionKey, ctx }); + const { resolvedCtx, skipReason } = resolveAndCacheIdentity({ sessionKey, ctx, pluginConfig }); if (sessionKey && skipReason) { debug(`[Hindsight] before_agent_start skipping session ${sessionKey}: ${formatIdentitySkipReason(skipReason)}`); @@ -1604,6 +1628,7 @@ export default function (api: MoltbotPluginAPI) { sessionKey: sessionKeyForCache, ctx, senderIdHint: senderIdFromPrompt, + pluginConfig, }); if (identitySkipReason) { debug(`[Hindsight] Skipping recall for session ${sessionKeyForCache}: ${formatIdentitySkipReason(identitySkipReason)}`); @@ -1769,7 +1794,7 @@ ${memoriesFormatted} } const { effectiveCtx: effectiveCtxForRetain, resolvedCtx: resolvedCtxForRetain, skipReason: identitySkipReason } = - resolveAndCacheIdentity({ sessionKey: sessionKeyForLookup, ctx: effectiveCtx }); + resolveAndCacheIdentity({ sessionKey: sessionKeyForLookup, ctx: effectiveCtx, pluginConfig }); if (identitySkipReason) { debug(`[Hindsight Hook] Skipping retain for session ${sessionKeyForLookup}: ${formatIdentitySkipReason(identitySkipReason)}`); @@ -2138,7 +2163,7 @@ function buildAnthropicStructuredMessages( function extractStructuredBlocks(content: any, role: string): any[] { if (typeof content === 'string') { - const cleaned = stripMetadataEnvelopes(stripMemoryTags(content)).trim(); + const cleaned = stripMetadataEnvelopes(stripInlineRetainTags(stripMemoryTags(content))).trim(); return cleaned ? [{ type: 'text', text: cleaned }] : []; } if (!Array.isArray(content)) return []; @@ -2149,7 +2174,7 @@ function extractStructuredBlocks(content: any, role: string): any[] { const blockType = block.type; if (blockType === 'text') { - const cleaned = stripMetadataEnvelopes(stripMemoryTags(block.text ?? '')).trim(); + const cleaned = stripMetadataEnvelopes(stripInlineRetainTags(stripMemoryTags(block.text ?? ''))).trim(); if (cleaned) blocks.push({ type: 'text', text: cleaned }); } else if (blockType === 'toolCall' && role === 'assistant') { const name = typeof block.name === 'string' ? block.name : 'unknown'; diff --git a/hindsight-integrations/openclaw/tests/hooks.integration.test.ts b/hindsight-integrations/openclaw/tests/hooks.integration.test.ts index a1a0e139c..6b6f4e081 100644 --- a/hindsight-integrations/openclaw/tests/hooks.integration.test.ts +++ b/hindsight-integrations/openclaw/tests/hooks.integration.test.ts @@ -476,13 +476,13 @@ describe('agent_end hook', () => { expect(retainSpy).toHaveBeenCalledOnce(); // HindsightClient.retain signature: (bankId, content, options?) + // Default retainFormat is 'json' with Anthropic-shaped typed blocks. const [, content] = retainSpy.mock.calls[0]; - expect(content).toContain('[role: user]'); - expect(content).toContain('I love TypeScript.'); - expect(content).toContain('[user:end]'); - expect(content).toContain('[role: assistant]'); - expect(content).toContain('TypeScript is great!'); - expect(content).toContain('[assistant:end]'); + const parsed = JSON.parse(content); + expect(parsed).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'I love TypeScript.' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'TypeScript is great!' }] }, + ]); }); it('includes session key in documentId', async () => { @@ -675,9 +675,13 @@ describe('agent_end hook', () => { expect(retainSpy).toHaveBeenCalledOnce(); const [, content, options] = retainSpy.mock.calls[0]; - // Only the last turn (from last user message onwards) is retained - expect(content).toContain('[role: user]\nI work as a data scientist.\n[user:end]'); - expect(content).toContain("[role: assistant]\nThat's a fascinating career!\n[assistant:end]"); + // Only the last turn (from last user message onwards) is retained. + // Default retainFormat is 'json' with Anthropic-shaped typed blocks. + const parsed = JSON.parse(content); + expect(parsed).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'I work as a data scientist.' }] }, + { role: 'assistant', content: [{ type: 'text', text: "That's a fascinating career!" }] }, + ]); expect(content).not.toContain('My name is Carol.'); expect(options?.metadata?.message_count).toBe('2'); });