Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 134 additions & 2 deletions hindsight-integrations/openclaw/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { describe, it, expect } from 'vitest';
import {
import openclawPlugin, {
stripMemoryTags,
extractRecallQuery,
formatMemories,
prepareRetentionTranscript,
countUserTurns,
getRetentionTurnIndex,
sliceLastTurnsByUserBoundary,
composeRecallQuery,
truncateRecallQuery,
Expand Down Expand Up @@ -229,9 +231,36 @@ describe('formatMemories', () => {
});

// ---------------------------------------------------------------------------
// prepareRetentionTranscript
// retention helpers
// ---------------------------------------------------------------------------

describe('countUserTurns', () => {
it('counts user messages across a resumed conversation history', () => {
expect(countUserTurns([
{ role: 'user', content: 'turn 1' },
{ role: 'assistant', content: 'reply 1' },
{ role: 'system', content: 'meta' },
{ role: 'user', content: 'turn 2' },
{ role: 'assistant', content: 'reply 2' },
{ role: 'user', content: 'turn 3' },
])).toBe(3);
});
});

describe('getRetentionTurnIndex', () => {
it('uses the full conversation turn count for per-turn retention', () => {
expect(getRetentionTurnIndex(7, 1)).toBe(7);
});

it('derives a stable window sequence for chunked retention', () => {
expect(getRetentionTurnIndex(6, 3)).toBe(2);
});

it('returns null when a chunk boundary has not been reached', () => {
expect(getRetentionTurnIndex(5, 3)).toBeNull();
});
});

describe('buildRetainRequest', () => {
it('adds configured source metadata and retain tags', () => {
const request = buildRetainRequest('hello world', 2, {
Expand Down Expand Up @@ -313,6 +342,21 @@ describe('buildRetainRequest', () => {
sender_id: '12345',
});
});

it('supports resumed conversations by accepting an explicit later turn index', () => {
const request = buildRetainRequest('hello world', 2, {
agentId: 'main',
sessionKey: 'agent:main:discord:channel:123',
messageProvider: 'discord',
channelId: 'channel:123',
senderId: 'user:456',
}, {
retainSource: 'openclaw',
}, 1700000000000, { turnIndex: 7 });

expect(request.documentId).toBe('openclaw:agent:main:discord:channel:123:turn:000007');
expect(request.metadata?.turn_index).toBe('7');
});
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -754,6 +798,94 @@ describe('session identity helpers', () => {
// waitForReady — CLI mode no-op (initPromise is null before service.start())
// ---------------------------------------------------------------------------

describe('agent_end retention hook', () => {
it('uses resumed conversation history to number retained turns', async () => {
const handlers = new Map<string, (event: any, ctx?: any) => Promise<unknown>>();
const api = {
config: {
plugins: {
entries: {
hindsight: {
config: {
autoRecall: false,
autoRetain: true,
dynamicBankId: true,
},
},
},
},
},
registerService: () => {},
on: (event: string, handler: (event: any, ctx?: any) => Promise<unknown>) => {
handlers.set(event, handler);
},
logger: {
info: () => {},
warn: () => {},
error: () => {},
},
};

openclawPlugin(api as any);
const agentEnd = handlers.get('agent_end');
expect(agentEnd).toBeDefined();

const retained: any[] = [];
const hindsight = (global as any).__hindsightClient;
const originalWaitForReady = hindsight.waitForReady;
const originalGetClientForContext = hindsight.getClientForContext;

hindsight.waitForReady = async () => {};
hindsight.getClientForContext = async () => ({
retain: async (request: any) => {
retained.push(request);
},
});

try {
await agentEnd?.(
{
success: true,
context: {
sessionEntry: {
messages: [
{ role: 'user', content: 'turn 1' },
{ role: 'assistant', content: 'reply 1' },
{ role: 'user', content: 'turn 2' },
{ role: 'assistant', content: 'reply 2' },
{ role: 'user', content: 'turn 3' },
{ role: 'assistant', content: 'reply 3' },
{ role: 'user', content: 'turn 4' },
{ role: 'assistant', content: 'reply 4' },
{ role: 'user', content: 'turn 5' },
{ role: 'assistant', content: 'reply 5' },
{ role: 'user', content: 'turn 6' },
{ role: 'assistant', content: 'reply 6' },
{ role: 'user', content: 'turn 7' },
{ role: 'assistant', content: 'reply 7' },
],
},
},
},
{
agentId: 'main',
sessionKey: 'agent:main:discord:channel:123',
messageProvider: 'discord',
channelId: 'channel:123',
senderId: 'user:456',
},
);
} finally {
hindsight.waitForReady = originalWaitForReady;
hindsight.getClientForContext = originalGetClientForContext;
}

expect(retained).toHaveLength(1);
expect(retained[0].documentId).toBe('openclaw:agent:main:discord:channel:123:turn:000007');
expect(retained[0].metadata?.turn_index).toBe('7');
});
});

describe('waitForReady (CLI mode)', () => {
it('returns without error when initPromise is null (service.start not called)', async () => {
// The module sets up global.__hindsightClient on import.
Expand Down
57 changes: 46 additions & 11 deletions hindsight-integrations/openclaw/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@ function toStringMetadata(
}
return out;
}
const turnCountBySession = new Map<string, number>();
const MAX_TRACKED_SESSIONS = 10_000;
const DEFAULT_RECALL_TIMEOUT_MS = 10_000;

Expand Down Expand Up @@ -1739,25 +1738,32 @@ ${memoriesFormatted}
let messagesToRetain = allMessages;
let retainFullWindow = false;

if (retainEveryN > 1) {
const sessionTrackingKey = `${bankId}:${effectiveCtx?.sessionKey || 'session'}`;
const turnCount = (turnCountBySession.get(sessionTrackingKey) || 0) + 1;
setCappedMapValue(turnCountBySession, sessionTrackingKey, turnCount);
const conversationTurnCount = countUserTurns(allMessages);
if (conversationTurnCount <= 0) {
debug('[Hindsight Hook] No user turns found, skipping retain');
return;
}

if (turnCount % retainEveryN !== 0) {
const nextRetainAt = Math.ceil(turnCount / retainEveryN) * retainEveryN;
debug(`[Hindsight Hook] Turn ${turnCount}/${retainEveryN}, skipping retain (next at turn ${nextRetainAt})`);
return;
}
const retainTurnIndex = getRetentionTurnIndex(conversationTurnCount, retainEveryN);
if (retainTurnIndex === null) {
const nextRetainAt = Math.ceil(conversationTurnCount / retainEveryN) * retainEveryN;
debug(`[Hindsight Hook] Turn ${conversationTurnCount}/${retainEveryN}, skipping retain (next at turn ${nextRetainAt})`);
return;
}

if (retainEveryN > 1) {
// Deliberately require the exact boundary turn from persisted history.
// If a hook run is skipped at turn N, we do not try to backfill that
// missed window on turn N+1; the next retained window is the next exact
// multiple. That keeps window numbering deterministic across resumes.
// Sliding window in turns: N turns + configured overlap turns.
// We slice by actual turn boundaries (user-role messages), so this
// remains stable even when system/tool messages are present.
const overlapTurns = pluginConfig.retainOverlapTurns ?? 0;
const windowTurns = retainEveryN + overlapTurns;
messagesToRetain = sliceLastTurnsByUserBoundary(allMessages, windowTurns);
retainFullWindow = true;
debug(`[Hindsight Hook] Turn ${turnCount}: chunked retain firing (window: ${windowTurns} turns, ${messagesToRetain.length} messages)`);
debug(`[Hindsight Hook] Turn ${conversationTurnCount}: chunked retain firing (window: ${windowTurns} turns, ${messagesToRetain.length} messages)`);
}

const retention = prepareRetentionTranscript(messagesToRetain, pluginConfig, retainFullWindow);
Expand Down Expand Up @@ -1799,6 +1805,7 @@ ${memoriesFormatted}
{
retentionScope: retainFullWindow ? 'window' : 'turn',
windowTurns: retainFullWindow ? (pluginConfig.retainEveryNTurns ?? 1) + (pluginConfig.retainOverlapTurns ?? 0) : undefined,
turnIndex: retainTurnIndex,
},
);

Expand Down Expand Up @@ -1856,6 +1863,10 @@ function getSessionDocumentBase(effectiveCtx: PluginHookAgentContext | undefined
}

function nextDocumentSequence(effectiveCtx: PluginHookAgentContext | undefined): number {
// Legacy best-effort fallback for direct callers that do not pass turnIndex.
// The agent_end hook always supplies a history-derived turn index, which is
// restart-safe. This counter is process-local only, so callers that need
// stable document ids across resumes should pass options.turnIndex.
const sequenceKey = effectiveCtx?.sessionKey || 'session';
const next = (documentSequenceBySession.get(sequenceKey) || 0) + 1;
setCappedMapValue(documentSequenceBySession, sequenceKey, next);
Expand Down Expand Up @@ -2096,6 +2107,30 @@ function buildToolResultBlock(msg: any): any | null {
return block;
}

export function countUserTurns(messages: Array<{ role?: string }>): number {
if (!Array.isArray(messages) || messages.length === 0) {
return 0;
}

return messages.reduce((count: number, message) => count + (message?.role === 'user' ? 1 : 0), 0);
}

export function getRetentionTurnIndex(conversationTurnCount: number, retainEveryN: number): number | null {
if (conversationTurnCount <= 0 || retainEveryN <= 0) {
return null;
}

if (retainEveryN === 1) {
return conversationTurnCount;
}

if (conversationTurnCount % retainEveryN !== 0) {
return null;
}

return Math.floor(conversationTurnCount / retainEveryN);
}

export function sliceLastTurnsByUserBoundary(messages: any[], turns: number): any[] {
if (!Array.isArray(messages) || messages.length === 0 || turns <= 0) {
return [];
Expand Down