fleet-memory/hindsight-integrations/opencode/src/hooks.ts
DK09876 e1c6220f0e
feat: add OpenCode persistent memory plugin (#853)
* feat: add OpenCode persistent memory plugin

Add hindsight-opencode integration with:
- Three custom tools: hindsight_retain, hindsight_recall, hindsight_reflect
- Auto-retain on session.idle with document_id deduplication
- Memory injection on session start via system transform hook
- Memory preservation during context window compaction
- Sliding window retain with retainOverlapTurns support
- 4-level config hierarchy (defaults, user file, plugin options, env vars)
- Dynamic bank ID derivation (agent, project, channel, user dimensions)
- CI job, release script entry, docs page

79 tests across 6 test files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address review findings for opencode integration

1. Pre-compaction retain now uses shared retainSession() helper,
   respecting retainMode, documentId, and session_id metadata
   consistently with idle-retain (was bypassing retention policy).

2. System transform recall is only consumed after successful injection.
   If Hindsight is briefly unavailable, the plugin retries on the next
   LLM call instead of permanently skipping recall for the session.

3. Config validation for retainMode and recallBudget — typos like
   "full_session" or "maximum" now log a warning and fall back to
   the default instead of silently changing retention semantics.

85 tests (6 new covering compaction documentId, recall retry, and
config validation).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: docs/tools findings from second review round

1. Remove "session" from supported dynamic bank fields in docs —
   the implementation can't vary bank ID per session since it's
   derived once at plugin startup.

2. Explicit tools (retain, reflect) now call ensureBankMission()
   before API calls, so bankMission/retainMission are applied even
   when the agent uses tools exclusively without triggering hooks.

3. Added tests for mission setup via tools path.

88 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: recall retry semantics and README bank scoping clarity

1. recallForContext now returns { context, ok } to distinguish
   "no results" (ok=true) from "API error" (ok=false). System
   transform consumes the session on ok=true even with 0 results,
   so empty banks don't cause repeated queries. Only transient API
   failures preserve retry.

2. README clarifies that channel/user bank dimensions are process-
   scoped (set via env vars before launch), not per-session dynamic
   within a running OpenCode process.

89 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: review fixes for opencode integration

- Rename CI job from build-opencode-integration to test-opencode-integration
  to match naming convention for integrations that run tests
- Fix tsconfig module resolution to Node16 (consistent with other integrations)
- Extract shared makeConfig test helper to avoid duplication across 3 test files

* fix: remove unused PluginState import from tools.ts

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Nicolò Boschi <boschi1997@gmail.com>
2026-04-07 10:11:57 +02:00

308 lines
11 KiB
TypeScript

/**
* 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<string>;
/** Track sessions we've already injected recall into */
recalledSessions: Set<string>;
/** Track last retained turn count per session to avoid duplicates */
lastRetainedTurn: Map<string, number>;
}
interface EventInput {
event: {
type: string;
properties: Record<string, unknown>;
};
}
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<void>;
'experimental.session.compacting': (
input: CompactingInput,
output: CompactingOutput,
) => Promise<void>;
'experimental.chat.system.transform': (
input: SystemTransformInput,
output: SystemTransformOutput,
) => Promise<void>;
}
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<RecallOutcome> {
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 =
`<hindsight_memories>\n` +
`${config.recallPromptPreamble}\n` +
`Current time: ${formatCurrentTime()} UTC\n\n` +
`${formatted}\n` +
`</hindsight_memories>`;
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<Message[]> {
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<void> {
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<void> {
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<void> => {
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<void> => {
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<void> => {
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,
};
}