Skip to content
Merged
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
2 changes: 1 addition & 1 deletion hindsight-integrations/openclaw/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Optional settings in `~/.openclaw/openclaw.json` under `plugins.entries.hindsigh
| `dynamicBankId` | `true` | Enable per-context memory banks |
| `bankId` | — | Static bank ID used when `dynamicBankId` is `false`. |
| `bankIdPrefix` | — | Prefix for bank IDs (e.g. `"prod"`) |
| `retainTags` | `[]` | Tags applied to every retained document, useful for cross-agent/source labeling (e.g. `source_system:openclaw`, `agent:agentname`) |
| `retainTags` | `[]` | Tags applied to every retained document, useful for cross-agent/source labeling (e.g. `source_system:openclaw`, `agent:agentname`). Auto-retain also merges inline per-message tags from `<retain_tags>...</retain_tags>` or `<hindsight_retain_tags>...</hindsight_retain_tags>` blocks in user messages. |
| `retainSource` | `"openclaw"` | `source` value written into retained document metadata |
| `dynamicBankGranularity` | `["agent", "channel", "user"]` | Fields used to derive bank ID. Options: `agent`, `channel`, `user`, `provider` |
| `excludeProviders` | `["heartbeat"]` | Message providers to skip for recall/retain (e.g. `heartbeat`, `slack`, `telegram`, `discord`) |
Expand Down
99 changes: 98 additions & 1 deletion hindsight-integrations/openclaw/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
extractRecallQuery,
formatMemories,
prepareRetentionTranscript,
countUserTurns,
getRetentionTurnIndex,
sliceLastTurnsByUserBoundary,
composeRecallQuery,
truncateRecallQuery,
Expand All @@ -14,6 +16,9 @@
getIdentitySkipReason,
isEphemeralOperationalText,
deriveBankId,
normalizeRetainTags,
extractInlineRetainTags,
stripInlineRetainTags,
} from './index.js';
import type { PluginConfig, MemoryResult } from './types.js';

Expand Down Expand Up @@ -229,9 +234,73 @@
});

// ---------------------------------------------------------------------------
// 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('normalizeRetainTags', () => {
it('trims, deduplicates, and preserves order for string arrays', () => {
expect(normalizeRetainTags([' source_system:openclaw ', 'agent:main', 'agent:main', ''])).toEqual([
'source_system:openclaw',
'agent:main',
]);
});

it('drops non-string values instead of stringifying them', () => {
expect(normalizeRetainTags(['agent:main', { a: 1 } as unknown as string, 42 as unknown as string, null as unknown as string])).toEqual([
'agent:main',
]);
});

it('accepts comma-separated strings', () => {
expect(normalizeRetainTags(' source_system:openclaw, agent:main , agent:main ')).toEqual([
'source_system:openclaw',
'agent:main',
]);
});
});

describe('inline retain tag helpers', () => {
it('extracts retain tags from inline directives', () => {
expect(extractInlineRetainTags('hello <retain_tags> client:acme, type:decision, client:acme </retain_tags> world')).toEqual([
'client:acme',
'type:decision',
]);
});

it('supports hindsight_retain_tags alias and strips directives from content', () => {
const input = 'Keep this.\n<hindsight_retain_tags>scope:user</hindsight_retain_tags>\nNot the directive.';
expect(extractInlineRetainTags(input)).toEqual(['scope:user']);
expect(stripInlineRetainTags(input)).toBe('Keep this.\n\nNot the directive.');
});
});

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

it('merges configured retain tags with inline per-message tags', () => {
const request = buildRetainRequest('hello world', 1, {}, {
retainTags: ['source_system:openclaw', 'agent:main'],
}, 1700000000000, {
turnIndex: 1,
tags: ['client:acme', 'agent:main'],
});

expect(request.tags).toEqual([
'source_system:openclaw',
'agent:main',
'client:acme',
]);
});

it('defaults source metadata to openclaw when unset', () => {
const request = buildRetainRequest('hello world', 1, {}, {}, 1700000000000, { turnIndex: 1 });
expect(request.metadata?.source).toBe('openclaw');
Expand Down Expand Up @@ -380,6 +464,19 @@
expect(result?.transcript).toContain('Here is how to enable dark mode.');
});

it('strips inline retain-tag directives from retained content', () => {
const messages = [
{ role: 'user', content: 'Remember this.\n<retain_tags>client:acme, type:decision</retain_tags>\nActual content.' },
{ role: 'assistant', content: 'Got it.' }
];
const result = prepareRetentionTranscript(messages, baseConfig);
expect(result).not.toBeNull();
expect(result?.transcript).toContain('Remember this.');
expect(result?.transcript).toContain('Actual content.');
expect(result?.transcript).not.toContain('<retain_tags>');

Check failure on line 476 in hindsight-integrations/openclaw/src/index.test.ts

View workflow job for this annotation

GitHub Actions / build-openclaw-integration

src/index.test.ts > prepareRetentionTranscript > strips inline retain-tag directives from retained content

AssertionError: expected '[{"role":"user","content":[{"type":"t…' not to contain '<retain_tags>' Expected: "<retain_tags>" Received: "[{"role":"user","content":[{"type":"text","text":"Remember this.\n<retain_tags>client:acme, type:decision</retain_tags>\nActual content."}]},{"role":"assistant","content":[{"type":"text","text":"Got it."}]}]" ❯ src/index.test.ts:476:36
expect(result?.transcript).not.toContain('client:acme');
});

it('strips memory tags from user message when prependContext is prepended to it', () => {
// Simulates the host prepending prependContext to the user message content
const userContent = `<hindsight_memories>\nRelevant memories:\n- User prefers dark mode [world]\n\nUser message: What is dark mode?\n</hindsight_memories>\nWhat is dark mode?`;
Expand Down
110 changes: 107 additions & 3 deletions hindsight-integrations/openclaw/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,40 @@ export function stripMemoryTags(content: string): string {
return content;
}

/**
* Extract per-message retain tag overrides from inline user content.
*
* Supported forms:
* - <retain_tags>tag:a, tag:b</retain_tags>
* - <hindsight_retain_tags>tag:a, tag:b</hindsight_retain_tags>
*/
export function extractInlineRetainTags(content: string): string[] {
if (!content) return [];

const tags: string[] = [];
const blockRe = /<(?:hindsight_)?retain_tags>([\s\S]*?)<\/(?:hindsight_)?retain_tags>/gi;
let match: RegExpExecArray | null;

while ((match = blockRe.exec(content)) !== null) {
const normalized = normalizeRetainTags(match[1]);
for (const tag of normalized) {
if (!tags.includes(tag)) {
tags.push(tag);
}
}
}

return tags;
}

/**
* Remove inline retain tag directives from message content before storing it.
*/
export function stripInlineRetainTags(content: string): string {
if (!content) return content;
return content.replace(/<(?:hindsight_)?retain_tags>[\s\S]*?<\/(?:hindsight_)?retain_tags>/gi, '');
}

/**
* Extract sender_id from OpenClaw's injected inbound metadata blocks.
* Checks both "Conversation info (untrusted metadata)" and "Sender (untrusted metadata)" blocks.
Expand Down Expand Up @@ -1013,6 +1047,27 @@ async function checkExternalApiHealth(apiUrl: string, apiToken?: string | null):
}
}

export function normalizeRetainTags(value: unknown): string[] {
if (value == null) return [];

const rawItems = Array.isArray(value)
? value
: typeof value === 'string'
? value.split(',')
: [];

const seen = new Set<string>();
const normalized: string[] = [];
for (const item of rawItems) {
if (typeof item !== 'string') continue;
const tag = item.trim();
if (!tag || seen.has(tag)) continue;
seen.add(tag);
normalized.push(tag);
}
return normalized;
}

function getPluginConfig(api: MoltbotPluginAPI): PluginConfig {
const config = api.config.plugins?.entries?.['hindsight-openclaw']?.config || {};
const defaultMission = 'You are an AI assistant helping users across multiple communication channels (Telegram, Slack, Discord, etc.). Remember user preferences, instructions, and important context from conversations to provide personalized assistance.';
Expand All @@ -1034,7 +1089,7 @@ function getPluginConfig(api: MoltbotPluginAPI): PluginConfig {
dynamicBankId: config.dynamicBankId !== false,
bankId: typeof config.bankId === 'string' && config.bankId.trim().length > 0 ? config.bankId.trim() : undefined,
bankIdPrefix: config.bankIdPrefix,
retainTags: Array.isArray(config.retainTags) ? config.retainTags.filter((tag): tag is string => typeof tag === 'string') : undefined,
retainTags: normalizeRetainTags(config.retainTags),
retainSource: typeof config.retainSource === 'string' && config.retainSource.trim().length > 0 ? config.retainSource.trim() : undefined,
excludeProviders: Array.isArray(config.excludeProviders)
? Array.from(new Set(['heartbeat', ...config.excludeProviders.filter((provider): provider is string => typeof provider === 'string')]))
Expand Down Expand Up @@ -1760,6 +1815,25 @@ ${memoriesFormatted}
debug(`[Hindsight Hook] Turn ${turnCount}: chunked retain firing (window: ${windowTurns} turns, ${messagesToRetain.length} messages)`);
}

const inlineRetainTags = normalizeRetainTags(
messagesToRetain.flatMap((msg: any) => {
if (msg?.role !== 'user') {
return [];
}

const content = typeof msg?.content === 'string'
? msg.content
: Array.isArray(msg?.content)
? msg.content
.filter((block: any) => block?.type === 'text' && typeof block?.text === 'string')
.map((block: any) => block.text)
.join('\n')
: '';

return extractInlineRetainTags(content);
}),
);

const retention = prepareRetentionTranscript(messagesToRetain, pluginConfig, retainFullWindow);
if (!retention) {
debug('[Hindsight Hook] No messages to retain (filtered/short/no-user)');
Expand Down Expand Up @@ -1799,6 +1873,7 @@ ${memoriesFormatted}
{
retentionScope: retainFullWindow ? 'window' : 'turn',
windowTurns: retainFullWindow ? (pluginConfig.retainEveryNTurns ?? 1) + (pluginConfig.retainOverlapTurns ?? 0) : undefined,
tags: inlineRetainTags,
},
);

Expand Down Expand Up @@ -1874,7 +1949,7 @@ export function buildRetainRequest(
effectiveCtx: PluginHookAgentContext | undefined,
pluginConfig: PluginConfig,
now = Date.now(),
options?: { retentionScope?: 'turn' | 'window' | 'manual'; windowTurns?: number; turnIndex?: number },
options?: { retentionScope?: 'turn' | 'window' | 'manual'; windowTurns?: number; turnIndex?: number; tags?: string[] },
): RetainRequest {
const resolvedCtx = resolveSessionIdentity(effectiveCtx);
const parsedSession = resolvedCtx?.sessionKey ? parseSessionKey(resolvedCtx.sessionKey) : {};
Expand All @@ -1887,6 +1962,10 @@ export function buildRetainRequest(
const channelId = sanitizeChannelId(effectiveCtx?.channelId, provider) || parsedSession.channel;
const channelType = effectiveCtx?.messageProvider;
const threadId = extractThreadId(channelId);
const mergedTags = normalizeRetainTags([
...(pluginConfig.retainTags ?? []),
...(options?.tags ?? []),
]);

return {
content: transcript,
Expand All @@ -1906,7 +1985,7 @@ export function buildRetainRequest(
sender_id: resolvedCtx?.senderId,
...(options?.windowTurns !== undefined ? { window_turns: String(options.windowTurns) } : {}),
},
tags: pluginConfig.retainTags && pluginConfig.retainTags.length > 0 ? pluginConfig.retainTags : undefined,
tags: mergedTags.length > 0 ? mergedTags : undefined,
};
}

Expand Down Expand Up @@ -1972,6 +2051,7 @@ export function prepareRetentionTranscript(
}

content = stripMemoryTags(content);
content = stripInlineRetainTags(content);
content = stripMetadataEnvelopes(content);

if (content.trim()) {
Expand Down Expand Up @@ -2096,6 +2176,30 @@ function buildToolResultBlock(msg: any): any | null {
return block;
}

export function countUserTurns(messages: any[]): number {
if (!Array.isArray(messages) || messages.length === 0) {
return 0;
}

return messages.reduce((count: number, message: any) => 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
2 changes: 1 addition & 1 deletion hindsight-integrations/openclaw/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export interface PluginConfig {
dynamicBankId?: boolean; // Enable per-channel memory banks (default: true)
bankId?: string; // Static bank ID used when dynamicBankId is false.
bankIdPrefix?: string; // Prefix for bank IDs (e.g. 'prod' -> 'prod-slack-C123')
retainTags?: string[]; // Tags applied to all retained documents (e.g. ['source_system:openclaw', 'agent:agentname'])
retainTags?: string[]; // Tags applied to all retained documents after trimming and deduplication; auto-retain merges these with inline per-message retain-tag directives (e.g. ['source_system:openclaw', 'agent:agentname'])
retainSource?: string; // Source written into retained document metadata (default: 'openclaw')
excludeProviders?: string[]; // Message providers to exclude from recall/retain (e.g. ['telegram', 'discord'])
autoRecall?: boolean; // Auto-recall memories on every prompt (default: true). Set to false when agent has its own recall tool.
Expand Down
Loading