fleet-memory/hindsight-integrations/opencode/src/content.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

178 lines
5.8 KiB
TypeScript

/**
* Content processing utilities.
*
* Port of the Claude Code plugin's content.py:
* - Memory tag stripping (anti-feedback-loop)
* - Recall query composition and truncation
* - Memory formatting for context injection
* - Retention transcript formatting
*/
/** Strip <hindsight_memories> and <relevant_memories> blocks to prevent retain feedback loops. */
export function stripMemoryTags(content: string): string {
content = content.replace(/<hindsight_memories>[\s\S]*?<\/hindsight_memories>/g, '');
content = content.replace(/<relevant_memories>[\s\S]*?<\/relevant_memories>/g, '');
return content;
}
export interface RecallResult {
text: string;
type?: string | null;
mentioned_at?: string | null;
}
/** Format recall results into human-readable text for context injection. */
export function formatMemories(results: RecallResult[]): string {
if (!results.length) return '';
return results
.map((r) => {
const typeStr = r.type ? ` [${r.type}]` : '';
const dateStr = r.mentioned_at ? ` (${r.mentioned_at})` : '';
return `- ${r.text}${typeStr}${dateStr}`;
})
.join('\n\n');
}
/** Format current UTC time for recall context. */
export function formatCurrentTime(): string {
const now = new Date();
const y = now.getUTCFullYear();
const m = String(now.getUTCMonth() + 1).padStart(2, '0');
const d = String(now.getUTCDate()).padStart(2, '0');
const h = String(now.getUTCHours()).padStart(2, '0');
const min = String(now.getUTCMinutes()).padStart(2, '0');
return `${y}-${m}-${d} ${h}:${min}`;
}
export interface Message {
role: string;
content: string;
}
/**
* Compose a multi-turn recall query from conversation history.
*
* When recallContextTurns > 1, includes prior context above the latest query.
*/
export function composeRecallQuery(
latestQuery: string,
messages: Message[],
recallContextTurns: number,
): string {
const latest = latestQuery.trim();
if (recallContextTurns <= 1 || !messages.length) return latest;
const contextual = sliceLastTurnsByUserBoundary(messages, recallContextTurns);
const contextLines: string[] = [];
for (const msg of contextual) {
const content = stripMemoryTags(msg.content).trim();
if (!content) continue;
if (msg.role === 'user' && content === latest) continue;
contextLines.push(`${msg.role}: ${content}`);
}
if (!contextLines.length) return latest;
return ['Prior context:', contextLines.join('\n'), latest].join('\n\n');
}
/**
* Truncate a composed recall query to maxChars.
* Preserves the latest user message, drops oldest context lines first.
*/
export function truncateRecallQuery(query: string, latestQuery: string, maxChars: number): string {
if (maxChars <= 0 || query.length <= maxChars) return query;
const latest = latestQuery.trim();
const latestOnly = latest.length > maxChars ? latest.slice(0, maxChars) : latest;
if (!query.includes('Prior context:')) return latestOnly;
const contextMarker = 'Prior context:\n\n';
const markerIndex = query.indexOf(contextMarker);
if (markerIndex === -1) return latestOnly;
const suffix = '\n\n' + latest;
const suffixIndex = query.lastIndexOf(suffix);
if (suffixIndex === -1) return latestOnly;
if (suffix.length >= maxChars) return latestOnly;
const contextBody = query.slice(markerIndex + contextMarker.length, suffixIndex);
const contextLines = contextBody.split('\n').filter(Boolean);
const kept: string[] = [];
for (let i = contextLines.length - 1; i >= 0; i--) {
kept.unshift(contextLines[i]);
const candidate = `${contextMarker}${kept.join('\n')}${suffix}`;
if (candidate.length > maxChars) {
kept.shift();
break;
}
}
if (kept.length) return `${contextMarker}${kept.join('\n')}${suffix}`;
return latestOnly;
}
/** Slice messages to the last N turns, where a turn starts at a user message. */
export function sliceLastTurnsByUserBoundary(messages: Message[], turns: number): Message[] {
if (!messages.length || turns <= 0) return [];
let userTurnsSeen = 0;
let startIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
userTurnsSeen++;
if (userTurnsSeen >= turns) {
startIndex = i;
break;
}
}
}
return startIndex === -1 ? [...messages] : messages.slice(startIndex);
}
/**
* Format messages into a retention transcript.
*
* Uses [role: ...]...[role:end] markers for structured retention.
*/
export function prepareRetentionTranscript(
messages: Message[],
retainFullWindow: boolean = false,
): { transcript: string | null; messageCount: number } {
if (!messages.length) return { transcript: null, messageCount: 0 };
let targetMessages: Message[];
if (retainFullWindow) {
targetMessages = messages;
} else {
// Default: retain only the last turn
let lastUserIdx = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
lastUserIdx = i;
break;
}
}
if (lastUserIdx === -1) return { transcript: null, messageCount: 0 };
targetMessages = messages.slice(lastUserIdx);
}
const parts: string[] = [];
for (const msg of targetMessages) {
const content = stripMemoryTags(msg.content).trim();
if (!content) continue;
parts.push(`[role: ${msg.role}]\n${content}\n[${msg.role}:end]`);
}
if (!parts.length) return { transcript: null, messageCount: 0 };
const transcript = parts.join('\n\n');
if (transcript.trim().length < 10) return { transcript: null, messageCount: 0 };
return { transcript, messageCount: parts.length };
}