/** * Hook implementations for the Hindsight OpenCode plugin. * * Hooks: * - event (session.created) → recall memories and inject into system prompt * - event (session.idle) → auto-retain conversation transcript * - experimental.session.compacting → inject memories into compaction context */ import type { HindsightClient } from '@vectorize-io/hindsight-client'; import type { HindsightConfig } from './config.js'; import { debugLog } from './config.js'; import { formatMemories, formatCurrentTime, stripMemoryTags, composeRecallQuery, truncateRecallQuery, prepareRetentionTranscript, sliceLastTurnsByUserBoundary, type Message, } from './content.js'; import { ensureBankMission } from './bank.js'; export interface PluginState { turnCount: number; missionsSet: Set; /** Track sessions we've already injected recall into */ recalledSessions: Set; /** Track last retained turn count per session to avoid duplicates */ lastRetainedTurn: Map; } interface EventInput { event: { type: string; properties: Record; }; } interface CompactingInput { sessionID: string; } interface CompactingOutput { context: string[]; prompt?: string; } interface SystemTransformInput { sessionID?: string; model: unknown; } interface SystemTransformOutput { system: string[]; } type OpencodeClient = { session: { messages: (opts: { path: { id: string } }) => Promise<{ data?: Array<{ role: string; parts?: Array<{ type: string; text?: string }> }> }>; }; }; export interface HindsightHooks { event: (input: EventInput) => Promise; 'experimental.session.compacting': ( input: CompactingInput, output: CompactingOutput, ) => Promise; 'experimental.chat.system.transform': ( input: SystemTransformInput, output: SystemTransformOutput, ) => Promise; } export function createHooks( hindsightClient: HindsightClient, bankId: string, config: HindsightConfig, state: PluginState, opencodeClient: OpencodeClient, ): HindsightHooks { interface RecallOutcome { /** formatted context string, or null if no results */ context: string | null; /** true if the API call succeeded (even with 0 results) */ ok: boolean; } /** Recall memories and format as context string */ async function recallForContext(query: string): Promise { try { const response = await hindsightClient.recall(bankId, query, { budget: config.recallBudget as 'low' | 'mid' | 'high', maxTokens: config.recallMaxTokens, types: config.recallTypes, }); const results = response.results || []; if (!results.length) return { context: null, ok: true }; const formatted = formatMemories(results); const context = `\n` + `${config.recallPromptPreamble}\n` + `Current time: ${formatCurrentTime()} UTC\n\n` + `${formatted}\n` + ``; return { context, ok: true }; } catch (e) { debugLog(config, 'Recall failed:', e); return { context: null, ok: false }; } } /** Extract plain-text messages from an OpenCode session */ async function getSessionMessages(sessionId: string): Promise { try { const response = await opencodeClient.session.messages({ path: { id: sessionId }, }); const rawMessages = response.data || []; const messages: Message[] = []; for (const msg of rawMessages) { const role = msg.role; if (role !== 'user' && role !== 'assistant') continue; const textParts = (msg.parts || []) .filter((p: { type: string; text?: string }) => p.type === 'text' && p.text) .map((p: { type: string; text?: string }) => p.text!); if (textParts.length) { messages.push({ role, content: textParts.join('\n') }); } } return messages; } catch (e) { debugLog(config, 'Failed to get session messages:', e); return []; } } /** * Retain messages for a session, respecting retainMode and documentId semantics. * Used by both idle-retain and pre-compaction retain. */ async function retainSession(sessionId: string, messages: Message[]): Promise { const retainFullWindow = config.retainMode === 'full-session'; let targetMessages: Message[]; let documentId: string; if (retainFullWindow) { targetMessages = messages; // Full-session upserts the same document each time documentId = sessionId; } else { // Sliding window: retainEveryNTurns + overlap const windowTurns = config.retainEveryNTurns + config.retainOverlapTurns; targetMessages = sliceLastTurnsByUserBoundary(messages, windowTurns); // Chunked mode: unique document per chunk documentId = `${sessionId}-${Date.now()}`; } const { transcript } = prepareRetentionTranscript(targetMessages, true); if (!transcript) return; await ensureBankMission(hindsightClient, bankId, config, state.missionsSet); await hindsightClient.retain(bankId, transcript, { documentId, context: config.retainContext, tags: config.retainTags.length ? config.retainTags : undefined, metadata: Object.keys(config.retainMetadata).length ? { ...config.retainMetadata, session_id: sessionId } : { session_id: sessionId }, async: true, }); } /** Auto-retain conversation transcript */ async function handleSessionIdle(sessionId: string): Promise { if (!config.autoRetain) return; const messages = await getSessionMessages(sessionId); if (!messages.length) return; // Count user turns const userTurns = messages.filter((m) => m.role === 'user').length; const lastRetained = state.lastRetainedTurn.get(sessionId) || 0; // Only retain if enough new turns since last retain if (userTurns - lastRetained < config.retainEveryNTurns) return; try { await retainSession(sessionId, messages); state.lastRetainedTurn.set(sessionId, userTurns); debugLog(config, `Auto-retained ${messages.length} messages for session ${sessionId}`); } catch (e) { debugLog(config, 'Auto-retain failed:', e); } } const event = async (input: EventInput): Promise => { try { const { event: evt } = input; if (evt.type === 'session.idle') { const sessionId = (evt.properties as { sessionID?: string }).sessionID; if (sessionId) { await handleSessionIdle(sessionId); } } if (evt.type === 'session.created') { const session = evt.properties.info as { id?: string; title?: string } | undefined; const sessionId = session?.id; if (sessionId && config.autoRecall && !state.recalledSessions.has(sessionId)) { state.recalledSessions.add(sessionId); // Cap tracked sessions if (state.recalledSessions.size > 1000) { const first = state.recalledSessions.values().next().value; if (first) state.recalledSessions.delete(first); } } } } catch (e) { debugLog(config, 'Event hook error:', e); } }; const compacting = async ( input: CompactingInput, output: CompactingOutput, ): Promise => { try { // First, retain what we have before compaction (using shared retention logic) const messages = await getSessionMessages(input.sessionID); if (messages.length && config.autoRetain) { try { await retainSession(input.sessionID, messages); debugLog(config, 'Pre-compaction retain completed'); } catch (e) { debugLog(config, 'Pre-compaction retain failed:', e); } } // Then recall relevant memories to inject into compaction context if (messages.length) { const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user'); if (lastUserMsg) { const query = composeRecallQuery( lastUserMsg.content, messages, config.recallContextTurns, ); const truncated = truncateRecallQuery( query, lastUserMsg.content, config.recallMaxQueryChars, ); const { context } = await recallForContext(truncated); if (context) { output.context.push(context); } } } } catch (e) { debugLog(config, 'Compaction hook error:', e); } }; const systemTransform = async ( input: SystemTransformInput, output: SystemTransformOutput, ): Promise => { try { if (!config.autoRecall) return; const sessionId = input.sessionID; if (!sessionId) return; // Only inject on first message of a session (tracked by recalledSessions) if (!state.recalledSessions.has(sessionId)) return; await ensureBankMission(hindsightClient, bankId, config, state.missionsSet); // Use a generic project-context query for session start const query = `project context and recent work`; const { context, ok } = await recallForContext(query); // Consume after a successful API round-trip (even with 0 results). // Only preserve retry for transient API failures (ok=false). if (ok) { state.recalledSessions.delete(sessionId); } if (context) { output.system.push(context); debugLog(config, `Injected recall context for session ${sessionId}`); } } catch (e) { debugLog(config, 'System transform hook error:', e); } }; return { event, 'experimental.session.compacting': compacting, 'experimental.chat.system.transform': systemTransform, }; }