Skip to content
Open
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
33 changes: 20 additions & 13 deletions hindsight-docs/docs-integrations/opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ description: "Add long-term memory to OpenCode with Hindsight. Automatically cap

# OpenCode

Persistent long-term memory plugin for [OpenCode](https://opencode.ai) using [Hindsight](https://vectorize.io/hindsight). Automatically captures conversations, recalls relevant context on session start, and provides retain/recall/reflect tools the agent can call directly.
Persistent long-term memory plugin for [OpenCode](https://opencode.ai) using [Hindsight](https://vectorize.io/hindsight). Automatically captures conversations, recalls relevant context on every turn, and provides retain/recall/reflect tools the agent can call directly.

## Quick Start

Expand Down Expand Up @@ -66,11 +66,11 @@ The plugin registers three tools the agent can call explicitly:

### Auto-Retain

When the session goes idle (`session.idle` event), the plugin automatically retains the conversation transcript to Hindsight. Configurable via `retainEveryNTurns` to control frequency.
After each agent response (when the `session.idle` event fires), the plugin automatically retains the full conversation transcript to Hindsight as an upsert. This ensures even one-shot prompts are captured reliably. A pre-compaction retain serves as a backup before context is compressed.

### Session Recall
### Per-Turn Recall

When a new session starts, the plugin recalls relevant project context and injects it into the system prompt, giving the agent access to memories from prior sessions.
On every turn, the plugin recalls relevant memories keyed on the latest user message and injects them into the system prompt. This ensures injected memories are always contextually relevant to the current question, not stale from a previous turn.

### Compaction Hook

Expand All @@ -94,16 +94,22 @@ This ensures memories survive context window trimming.
"autoRecall": true,
"autoRetain": true,
"recallBudget": "mid",
"recallMaxTokens": 1024,
"recallTypes": ["observation", "world", "experience"],
"recallContextTurns": 1,
"recallTags": [],
"recallTagsMatch": "any",
"retainContext": "conversation between OpenCode Agent and the User",
"retainTags": [],
"retainEveryNTurns": 3,
"debug": false
}]
]
}
```

> **Note:** The plugin performs one recall API call per turn and one retain upsert per agent response.
> If you want to reduce API load, you can disable `autoRecall` or `autoRetain`, or lower `recallMaxTokens`.

### Config File

Create `~/.hindsight/opencode.json` for persistent configuration that applies across all projects:
Expand All @@ -120,17 +126,19 @@ Create `~/.hindsight/opencode.json` for persistent configuration that applies ac

| Variable | Description | Default |
|---|---|---|
| `HINDSIGHT_API_URL` | Hindsight API base URL | *(required)* |
| `HINDSIGHT_API_URL` | Hindsight API base URL | `https://api.hindsight.vectorize.io` |
| `HINDSIGHT_API_TOKEN` | API key for authentication | |
| `HINDSIGHT_BANK_ID` | Static memory bank ID | `opencode` |
| `HINDSIGHT_AGENT_NAME` | Agent name for dynamic bank IDs | `opencode` |
| `HINDSIGHT_AUTO_RECALL` | Auto-recall on session start | `true` |
| `HINDSIGHT_AUTO_RECALL` | Auto-recall on every turn | `true` |
| `HINDSIGHT_AUTO_RETAIN` | Auto-retain on session idle | `true` |
| `HINDSIGHT_RETAIN_MODE` | `full-session` or `last-turn` | `full-session` |
| `HINDSIGHT_RECALL_BUDGET` | Recall budget: `low`, `mid`, `high` | `mid` |
| `HINDSIGHT_RECALL_MAX_TOKENS` | Max tokens for recall results | `1024` |
| `HINDSIGHT_RECALL_MAX_QUERY_CHARS` | Max chars for recall query | `800` |
| `HINDSIGHT_RECALL_CONTEXT_TURNS` | Context turns for recall query | `1` |
| `HINDSIGHT_RECALL_TAGS` | Comma-separated tags to filter recall results | |
| `HINDSIGHT_RECALL_TAGS_MATCH` | Tag match mode: `any`, `all`, `any_strict`, `all_strict` | `any` |
| `HINDSIGHT_RETAIN_TAGS` | Comma-separated tags for retained documents | |
| `HINDSIGHT_DYNAMIC_BANK_ID` | Enable dynamic bank ID derivation | `false` |
| `HINDSIGHT_BANK_MISSION` | Bank mission/context for reflect | |

Expand Down Expand Up @@ -168,8 +176,7 @@ export HINDSIGHT_USER_ID="user123"
## How It Works

1. **Plugin loads** when OpenCode starts — creates a `HindsightClient`, derives the bank ID, and registers tools + hooks
2. **Session starts** — `session.created` event triggers, plugin marks session for recall injection
3. **System transform** — on the first LLM call, recalled memories are injected into the system prompt
4. **Agent works** — can call `hindsight_recall` and `hindsight_retain` explicitly during the session
5. **Session idles** — `session.idle` event triggers auto-retain of the conversation
6. **Compaction** — if the context window fills up, memories are preserved through the compaction
2. **Every turn** — `system.transform` hook recalls relevant memories keyed on the latest user message and injects them into the system prompt
3. **Agent works** — can call `hindsight_recall` and `hindsight_retain` explicitly during the session
4. **Agent responds** — `session.idle` event fires after each agent response, triggering auto-retain (upsert) of the conversation
5. **Compaction** — if the context window fills up, memories are preserved through the compaction
20 changes: 2 additions & 18 deletions hindsight-integrations/opencode/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ describe("loadConfig", () => {
expect(config.autoRetain).toBe(true);
expect(config.recallBudget).toBe("mid");
expect(config.recallMaxTokens).toBe(1024);
expect(config.retainContext).toBe("opencode");
expect(config.recallContextTurns).toBe(1);
expect(config.retainContext).toBe("conversation between OpenCode Agent and the User");
expect(config.agentName).toBe("opencode");
expect(config.dynamicBankId).toBe(false);
expect(config.debug).toBe(false);
Expand Down Expand Up @@ -127,28 +128,11 @@ describe("loadConfig", () => {
expect(config.debug).toBe(false); // stays default
});

it("invalid retainMode falls back to full-session with warning", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const config = loadConfig({ retainMode: "full_session" });
expect(config.retainMode).toBe("full-session");
expect(spy).toHaveBeenCalledWith(expect.stringContaining("Unknown retainMode"));
spy.mockRestore();
});

it("invalid recallBudget falls back to mid with warning", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const config = loadConfig({ recallBudget: "maximum" });
expect(config.recallBudget).toBe("mid");
expect(spy).toHaveBeenCalledWith(expect.stringContaining("Unknown recallBudget"));
spy.mockRestore();
});

it("valid retainMode and recallBudget pass without warning", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const config = loadConfig({ retainMode: "last-turn", recallBudget: "high" });
expect(config.retainMode).toBe("last-turn");
expect(config.recallBudget).toBe("high");
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
});
38 changes: 14 additions & 24 deletions hindsight-integrations/opencode/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,11 @@ export interface HindsightConfig {
recallTypes: string[];
recallContextTurns: number;
recallMaxQueryChars: number;
recallPromptPreamble: string;
recallTags: string[];
recallTagsMatch: "any" | "all" | "any_strict" | "all_strict";

// Retain
autoRetain: boolean;
retainMode: string;
retainEveryNTurns: number;
retainOverlapTurns: number;
retainContext: string;
retainTags: string[];
retainMetadata: Record<string, string>;
Expand All @@ -53,27 +49,31 @@ export interface HindsightConfig {
debug: boolean;
}

// IMPORTANT: These defaults control per-turn recall and per-idle retain volume.
// Changing any of the following values has a direct impact on API load and
// injected token count:
//
// - autoRecall: true → one recall API call per turn (system.transform)
// - autoRetain: true → one retain upsert per session.idle event
// - recallMaxTokens → max tokens injected into the system prompt each turn
// - recallTypes → broader types = more recall results = more tokens
//
// If you reduce recallMaxTokens or disable autoRecall/autoRetain, do so
// deliberately — the defaults are tuned for balanced memory quality vs cost.
const DEFAULTS: HindsightConfig = {
// Recall
autoRecall: true,
recallBudget: "mid",
recallMaxTokens: 1024,
recallTypes: ["world", "experience"],
recallTypes: ["observation", "world", "experience"],
recallContextTurns: 1,
recallMaxQueryChars: 800,
recallTags: [],
recallTagsMatch: "any",
recallPromptPreamble:
"Relevant memories from past conversations (prioritize recent when " +
"conflicting). Only use memories that are directly useful to continue " +
"this conversation; ignore the rest:",

// Retain
// Retain — upserts the full conversation on every session.idle event
autoRetain: true,
retainMode: "full-session",
retainEveryNTurns: 3,
retainOverlapTurns: 2,
retainContext: "opencode",
retainContext: "conversation between OpenCode Agent and the User",
retainTags: [],
retainMetadata: {},

Expand Down Expand Up @@ -102,7 +102,6 @@ const ENV_OVERRIDES: Record<string, [keyof HindsightConfig, "string" | "bool" |
HINDSIGHT_AGENT_NAME: ["agentName", "string"],
HINDSIGHT_AUTO_RECALL: ["autoRecall", "bool"],
HINDSIGHT_AUTO_RETAIN: ["autoRetain", "bool"],
HINDSIGHT_RETAIN_MODE: ["retainMode", "string"],
HINDSIGHT_RECALL_BUDGET: ["recallBudget", "string"],
HINDSIGHT_RECALL_MAX_TOKENS: ["recallMaxTokens", "int"],
HINDSIGHT_RECALL_MAX_QUERY_CHARS: ["recallMaxQueryChars", "int"],
Expand Down Expand Up @@ -190,15 +189,6 @@ export function loadConfig(pluginOptions?: Record<string, unknown>): HindsightCo
const result = config as unknown as HindsightConfig;

// Validate enum-like fields to catch typos early
const VALID_RETAIN_MODES = ["full-session", "last-turn"];
if (!VALID_RETAIN_MODES.includes(result.retainMode)) {
console.error(
`[Hindsight] Unknown retainMode "${result.retainMode}" — ` +
`valid: ${VALID_RETAIN_MODES.join(", ")}. Falling back to "full-session".`
);
result.retainMode = "full-session";
}

const VALID_TAGS_MATCH = ["any", "all", "any_strict", "all_strict"];
if (!VALID_TAGS_MATCH.includes(result.recallTagsMatch)) {
console.error(
Expand Down
14 changes: 5 additions & 9 deletions hindsight-integrations/opencode/src/content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,33 +152,30 @@ describe("prepareRetentionTranscript", () => {
];

it("retains last turn by default", () => {
const { transcript, messageCount } = prepareRetentionTranscript(messages);
expect(messageCount).toBe(2);
const transcript = prepareRetentionTranscript(messages);
expect(transcript).toContain("[role: user]");
expect(transcript).toContain("How are you?");
expect(transcript).toContain("I am doing well");
expect(transcript).not.toContain("Hello");
});

it("retains full window when requested", () => {
const { transcript, messageCount } = prepareRetentionTranscript(messages, true);
expect(messageCount).toBe(4);
const transcript = prepareRetentionTranscript(messages, true);
expect(transcript).toContain("Hello");
expect(transcript).toContain("How are you?");
});

it("returns null for empty messages", () => {
const { transcript, messageCount } = prepareRetentionTranscript([]);
const transcript = prepareRetentionTranscript([]);
expect(transcript).toBeNull();
expect(messageCount).toBe(0);
});

it("strips memory tags from content", () => {
const msgs = [
{ role: "user", content: "Query <hindsight_memories>data</hindsight_memories>" },
{ role: "assistant", content: "Response" },
];
const { transcript } = prepareRetentionTranscript(msgs);
const transcript = prepareRetentionTranscript(msgs);
expect(transcript).not.toContain("hindsight_memories");
expect(transcript).toContain("Query");
});
Expand All @@ -188,8 +185,7 @@ describe("prepareRetentionTranscript", () => {
{ role: "user", content: "<hindsight_memories>only tags</hindsight_memories>" },
{ role: "assistant", content: "Response" },
];
const { transcript, messageCount } = prepareRetentionTranscript(msgs, true);
expect(messageCount).toBe(1); // only assistant message
const transcript = prepareRetentionTranscript(msgs, true);
expect(transcript).toContain("Response");
});
});
12 changes: 6 additions & 6 deletions hindsight-integrations/opencode/src/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,8 @@ export function sliceLastTurnsByUserBoundary(messages: Message[], turns: number)
export function prepareRetentionTranscript(
messages: Message[],
retainFullWindow: boolean = false
): { transcript: string | null; messageCount: number } {
if (!messages.length) return { transcript: null, messageCount: 0 };
): string | null {
if (!messages.length) return null;

let targetMessages: Message[];
if (retainFullWindow) {
Expand All @@ -158,7 +158,7 @@ export function prepareRetentionTranscript(
break;
}
}
if (lastUserIdx === -1) return { transcript: null, messageCount: 0 };
if (lastUserIdx === -1) return null;
targetMessages = messages.slice(lastUserIdx);
}

Expand All @@ -169,10 +169,10 @@ export function prepareRetentionTranscript(
parts.push(`[role: ${msg.role}]\n${content}\n[${msg.role}:end]`);
}

if (!parts.length) return { transcript: null, messageCount: 0 };
if (!parts.length) return null;

const transcript = parts.join("\n\n");
if (transcript.trim().length < 10) return { transcript: null, messageCount: 0 };
if (transcript.trim().length < 10) return null;

return { transcript, messageCount: parts.length };
return transcript;
}
Loading