chore: import upstream snapshot with attribution
CI / test (20, macos-latest) (push) Waiting to run
CI / test (20, ubuntu-latest) (push) Waiting to run
CI / test (22, macos-latest) (push) Waiting to run
CI / test (22, ubuntu-latest) (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 13:01:18 +08:00
commit 979fb22d7c
613 changed files with 113494 additions and 0 deletions
+229
View File
@@ -0,0 +1,229 @@
<h1 align="center">
<img src="https://github.com/opencode-ai.png?size=80" alt="OpenCode" width="28" height="28" align="center" />
&nbsp;agentmemory for OpenCode
</h1>
<p align="center">
<strong>Your OpenCode agents remember everything. No more re-explaining.</strong><br/>
<sub>Persistent cross-session memory via <a href="https://github.com/rohitg00/agentmemory">agentmemory</a> — 95.2% retrieval accuracy on <a href="https://arxiv.org/abs/2410.10813">LongMemEval-S</a>.</sub>
</p>
<p align="center">
<img src="https://img.shields.io/badge/MCP-53_tools-1f6feb?style=flat-square" alt="53 MCP tools" />
<img src="https://img.shields.io/badge/Plugin-22_hooks-1f6feb?style=flat-square" alt="22 hooks" />
<img src="https://img.shields.io/badge/Commands-2_slash-1f6feb?style=flat-square" alt="2 slash commands" />
<img src="https://img.shields.io/badge/R@5-95.2%25-00875f?style=flat-square" alt="95.2% R@5" />
</p>
---
## Quick start
### 1. Start the agentmemory server
```bash
npx @agentmemory/agentmemory
```
The server starts on `http://localhost:3111`.
### 2. Configure the MCP server
Add to `~/.config/opencode/opencode.json` or your project's `.opencode/opencode.json`:
```json
{
"mcp": {
"agentmemory": {
"type": "local",
"command": ["npx", "-y", "@agentmemory/mcp"],
"enabled": true
}
}
}
```
### 3. Install the plugin
Add to `~/.config/opencode/opencode.json`:
```json
{
"plugin": ["./plugins/agentmemory-capture.ts"]
}
```
Copy the plugin file from this repo:
```bash
mkdir -p ~/.config/opencode/plugins
cp plugin/opencode/agentmemory-capture.ts ~/.config/opencode/plugins/
```
### 4. Add the slash commands
Copy the commands into your project or global `.opencode/commands/` directory:
```bash
mkdir -p ~/.config/opencode/commands
cp plugin/opencode/commands/recall.md ~/.config/opencode/commands/
cp plugin/opencode/commands/remember.md ~/.config/opencode/commands/
```
Restart OpenCode or open a new session. The plugin auto-captures everything.
## What gets captured
### Session lifecycle
| Event | Hook | agentmemory API |
|---|---|---|
| Session start | `session.created` | POST /session/start |
| Idle → summarize | `session.idle` + `session.status` (idle) | POST /summarize |
| Status transitions | `session.status` (idle/busy/retry) | POST /observe |
| Compaction | `session.compacted` | POST /summarize + POST /observe |
| Metadata updates | `session.updated` | POST /observe |
| Code change tracking | `session.diff` | POST /observe |
| Session delete | `session.deleted` | POST /session/end |
| Session error | `session.error` | POST /observe |
### Messages & prompts
| Event | Hook | agentmemory API |
|---|---|---|
| User prompt (rich) | `chat.message` | POST /observe |
| User prompt metadata | `message.updated` (user) | POST /observe |
| Assistant response | `message.updated` (assistant) | POST /observe |
| Message removed (undo) | `message.removed` | POST /observe |
### Parts & steps
| Event | Hook | agentmemory API |
|---|---|---|
| Subagent start | `message.part.updated` (subtask) | POST /observe |
| Tool completed | `message.part.updated` (tool completed) | POST /observe |
| Tool error | `message.part.updated` (tool error) | POST /observe |
| Step finish (cost/tokens) | `message.part.updated` (step-finish) | POST /observe |
| Reasoning trace | `message.part.updated` (reasoning) | POST /observe |
| Patch applied | `message.part.updated` (patch) | POST /observe |
| Auto/manual compaction | `message.part.updated` (compaction) | POST /observe |
| Agent selection | `message.part.updated` (agent) | POST /observe |
| API retry | `message.part.updated` (retry) | POST /observe |
### File enrichment pipeline
| Event | Hook | agentmemory API |
|---|---|---|
| File tool params | `tool.execute.before` → stash paths | — |
| File edited | `file.edited` → stash paths | — |
| File part attached | `message.part.updated` (file) → stash paths | — |
| Enrichment inject | `experimental.chat.system.transform` | POST /enrich → `output.system[]` |
| Memory context inject | `experimental.chat.system.transform` | POST /context → `output.system[]` |
### Permissions
| Event | Hook | agentmemory API |
|---|---|---|
| Permission prompt | `permission.updated` | POST /observe |
| Permission reply | `permission.replied` | POST /observe |
### Tasks & commands
| Event | Hook | agentmemory API |
|---|---|---|
| Task tracking (w/ priority) | `todo.updated` | POST /observe |
| Command executed | `command.executed` | POST /observe |
### Model & config
| Event | Hook | agentmemory API |
|---|---|---|
| LLM parameters | `chat.params` | POST /observe |
| Config loaded | `config` | POST /observe |
| Compaction (WIP) | `experimental.session.compacting` | POST /context → `output.context[]` |
### File enrichment + memory injection (two-layer pipeline)
`experimental.chat.system.transform` fires before every LLM call and injects two layers of context:
1. **Memory context** (once per session): calls `/agentmemory/context` and injects project profile, recent session summaries, and important past observations into the system prompt. This is the OpenCode equivalent of Claude's MEMORY.md bridge — instead of syncing to a markdown file, context is injected directly into the system prompt.
2. **File enrichment** (every turn with stashed files): calls `/agentmemory/enrich` with files stashed by `tool.execute.before`, `file.edited`, and `message.part.updated` (file parts). File-specific context (past observations, related bugs, semantic search) is injected into the system prompt.
```text
System prompt = [OpenCode instructions] + [memory context] + [file enrichment] + [user message]
^ ^
first turn only every file-touching turn
```
**Differences from Claude's PreToolUse:**
| Dimension | Claude (PreToolUse) | OpenCode (two-hop pipeline) |
|---|---|---|
| Injection mechanism | stdout → context window | `output.system[]` → system prompt |
| Timing | Same turn (parallel with tool) | Next turn (before next LLM call) |
| File set | Per-tool (immediate) | Batched (all files since last enrichment) |
| Coverage | Edit/Write/Read/Glob/Grep only | Edit/Write/Read/Glob/Grep only |
| What gets injected | `<agentmemory-file-context>` + bug memories | Identical `/enrich` response |
## MEMORY.md vs AGENTS.md: how context flows
Claude Code and OpenCode take fundamentally different approaches to injecting memory context into the agent's system prompt.
### Claude Code: file-backed bridge (two-hop)
```
agentmemory ──write──▶ MEMORY.md ──read──▶ Claude system prompt
```
- The `claude-bridge/sync` endpoint serializes agentmemory observations into a `MEMORY.md` file in the project root
- Claude Code reads `MEMORY.md` on session start and prepends it to the system prompt
- **Sync is periodic** — sessions only get fresh context when the bridge last ran (session end, pre-compact)
- **Coupling**: memory data lives in a git-trackable file, visible to CI, team members, and other tools
### OpenCode: direct injection (one-hop)
```
agentmemory ──push──▶ OpenCode system prompt
```
- `experimental.chat.system.transform` calls `/context` at runtime and pushes the response directly into `output.system[]`
- **Always current** — context is fetched at session start (once) and before file-touching turns (per-batch)
- **No file intermediary** — no stale copies, no merge conflicts, no disk I/O
- `AGENTS.md` is a static instruction file for project conventions, coding standards, and tool guidance — agentmemory does not read or write it
### Tradeoffs
| Dimension | Claude (MEMORY.md bridge) | OpenCode (direct injection) |
|---|---|---|
| Freshness | Stale between syncs | Always current (fetched at call time) |
| Visibility | Human-readable file in repo | In-memory injection only |
| Simplicity | Two moving parts (bridge + file) | One step (API → system prompt) |
| Team sharing | File is git-trackable, CI-friendly | Memory shared via agentmemory server API |
| Integration | Any tool can read MEMORY.md | Requires OpenCode plugin SDK |
### Why OpenCode goes direct
agentmemory already persists everything in SQLite (`data/state_store.db`). Adding an intermediate MEMORY.md file would duplicate data, introduce sync lag, and require the model to re-parse structured context from markdown. Direct injection delivers the same data with lower latency and zero staleness — the agent always sees what agentmemory knows right now.
## Slash commands
- `/recall <query>` — Search past observations and lessons
- `/remember <text>` — Save an insight to long-term memory
## Session instruction injection
Agentmemory usage instructions are injected into the system prompt on the first turn of every session via `experimental.chat.system.transform` (alongside memory context from `/context`). This is functionally equivalent to Claude Code's skills mechanism — the agent learns which `agentmemory_memory_*` tools to use and when, without needing separate skill invocations.
## What's not covered (vs Claude Code plugin)
| Claude feature | Reason |
|---|---|
| SubagentStop | OpenCode's `SubtaskPart` type has no completion/result fields; subtask lifecycle ends are not exposed as distinct events in the OpenCode SDK |
| TaskCompleted | No team/teammate concept in OpenCode; `todo.updated` captures task state changes as a partial equivalent |
| Stop | `session.compacted` event handler exists; `experimental.session.compacting` injection hook defined in SDK but Go binary (v1.14.41) doesn't wire it — will auto-activate when upstream implements it |
| Skills (remember/recall/forget/session-history) | Covered by injected system instructions via `experimental.chat.system.transform` — agent receives usage guidance on first turn |
| Consolidation pipeline (crystals/auto + consolidate-pipeline) | Now called on `session.deleted` — mirrors Claude's `CONSOLIDATION_ENABLED=true` behavior |
| Claude MEMORY.md bridge | OpenCode-specific; OpenCode uses its own AGENTS.md mechanism, not Claude's MEMORY.md |
All other Claude Code hooks have direct or pipeline equivalents in this plugin. 12 of 12 Claude hook types covered.
+687
View File
@@ -0,0 +1,687 @@
import type { Plugin } from "@opencode-ai/plugin";
const API = process.env.AGENTMEMORY_URL || "http://localhost:3111";
const FILE_TOOLS = new Set(["Read", "Write", "Edit", "Glob", "Grep"]);
const FILE_KEYS = ["filePath", "file_path", "path", "file", "pattern"];
const MAX_STASHED_FILES = 20;
const DEBUG = process.env.OPENCODE_AGENTMEMORY_DEBUG === "1";
const SECRET = process.env.AGENTMEMORY_SECRET || "";
function authHeaders(): Record<string, string> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (SECRET) headers["Authorization"] = `Bearer ${SECRET}`;
return headers;
}
async function post(path: string, body: Record<string, unknown>, timeoutMs = 5000): Promise<void> {
try {
await fetch(`${API}/agentmemory${path}`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (e) {
if (DEBUG) console.error(`[agentmemory] POST ${path} failed:`, (e as Error).message);
}
}
async function postJson(path: string, body: Record<string, unknown>): Promise<unknown | null> {
try {
const res = await fetch(`${API}/agentmemory${path}`, {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(body),
signal: AbortSignal.timeout(5000),
});
return res.ok ? await res.json() : null;
} catch (e) {
if (DEBUG) console.error(`[agentmemory] POST ${path} failed:`, (e as Error).message);
return null;
}
}
async function observe(
sessionId: string,
hookType: string,
data: Record<string, unknown>,
): Promise<void> {
await post("/observe", {
hookType,
sessionId,
project: projectPath,
cwd: projectPath,
timestamp: new Date().toISOString(),
data,
});
}
let activeSessionId: string | null = null;
let pendingConfig: Record<string, unknown> | null = null;
let projectPath: string | null = null;
const stashedFiles = new Map<string, Set<string>>();
const seenSubtaskIds = new Map<string, Set<string>>();
const seenToolCallIds = new Map<string, Set<string>>();
const contextInjectedSessions = new Set<string>();
// cache the context returned by POST /session/start so the chat
// system-transform hook can inject it without a second /context fetch.
// Auto-injection now happens at session.created (immediately) AND at
// the first prompt_submit (fallback for older OpenCode builds that
// don't implement experimental.chat.system.transform).
const startContextCache = new Map<string, string>();
function stashFor(sid: string): Set<string> {
let s = stashedFiles.get(sid);
if (!s) { s = new Set<string>(); stashedFiles.set(sid, s); }
return s;
}
function subtaskSetFor(sid: string): Set<string> {
let s = seenSubtaskIds.get(sid);
if (!s) { s = new Set<string>(); seenSubtaskIds.set(sid, s); }
return s;
}
function toolCallSetFor(sid: string): Set<string> {
let s = seenToolCallIds.get(sid);
if (!s) { s = new Set<string>(); seenToolCallIds.set(sid, s); }
return s;
}
function pruneSessionMaps(sid: string): void {
stashedFiles.delete(sid);
seenSubtaskIds.delete(sid);
seenToolCallIds.delete(sid);
}
function safeSlice(v: unknown, max: number): string {
if (typeof v === "string") return v.slice(0, max);
if (v == null) return "";
try { return JSON.stringify(v).slice(0, max); } catch { return ""; }
}
const AGENTMEMORY_INSTRUCTIONS = `<agentmemory-instructions>
You have access to agentmemory for persistent cross-session memory. Use these tools proactively.
CORE TOOLS:
memory_save — Save an insight, decision, or fact to long-term memory.
Required: content (text), concepts (2-5 comma-separated keywords), type (pattern/preference/architecture/bug/workflow/fact)
Optional: files (comma-separated paths)
Use when: user says "remember this", after discovering a bug, after making an architectural decision, after learning a project convention.
memory_recall — Search past observations by keywords.
Use when: user says "recall", "what did we do", "do you remember", or needs context from past sessions.
memory_smart_search — Hybrid semantic+keyword search with progressive disclosure.
Use when: you need the most relevant past context, fuzzy/conceptual searches, or recall doesn't find what you need.
memory_sessions — List recent sessions with status and observation counts.
Use when: user asks about session/past history, "what did we work on".
memory_file_history — Get past observations about specific files (across all sessions).
Use when: you're about to edit a file and want to know its history, common pitfalls, or past edits.
memory_lesson_save — Save a lesson learned (what worked, what to avoid).
Use when: you discover a pattern that could help future sessions avoid mistakes.
memory_lesson_recall — Search lessons by query. Returns lessons sorted by confidence.
Use when: before making a decision, check if past lessons apply.
memory_governance_delete — Delete specific memories. Requires explicit user confirmation.
Use when: user says "forget this", "delete that memory".
memory_patterns — Detect recurring patterns across sessions.
Use when: you want to understand project-level trends over time.
memory_consolidate — Run the 4-tier memory consolidation pipeline.
Use when: you want to compress and organize accumulated session observations.
All memory tools start with \`agentmemory_memory_\`. Use the exact names as they appear in your tool list. Tool results are JSON. Always check what was returned before presenting to the user.
</agentmemory-instructions>`;
function extractFilePaths(args: Record<string, unknown>): string[] {
const files: string[] = [];
for (const key of FILE_KEYS) {
const val = args[key];
if (typeof val === "string" && val.length > 0) {
files.push(val);
}
}
return files;
}
function extractErrorMessage(err: unknown): string {
if (typeof err === "string") return err;
if (err && typeof err === "object") {
const e = err as Record<string, unknown>;
if (typeof e.message === "string") return e.message;
if (e.data && typeof e.data === "object") {
const d = e.data as Record<string, unknown>;
if (typeof d.message === "string") return d.message;
}
if (typeof e.name === "string") return e.name;
try { return JSON.stringify(err); } catch { return ""; }
}
return String(err ?? "");
}
export const AgentmemoryCapturePlugin: Plugin = async (ctx) => {
projectPath = ctx.worktree || ctx.project?.id || process.cwd();
return {
event: async ({ event }) => {
const type = event.type;
const props = (event as any).properties || {};
// ── session.created ──
if (type === "session.created") {
const info = props.info as Record<string, unknown> | undefined;
activeSessionId = (info?.id as string) || props.sessionID || null;
if (!activeSessionId) return;
stashedFiles.set(activeSessionId, new Set());
seenSubtaskIds.delete(activeSessionId);
seenToolCallIds.delete(activeSessionId);
contextInjectedSessions.delete(activeSessionId);
// Snapshot the session id locally — `activeSessionId` is mutable
// and another `session.created` event during the await could
// rebind it, causing context to be cached against the wrong key.
const sessionId = activeSessionId;
const startResult = await postJson("/session/start", {
sessionId,
title: info?.title ?? null,
parentID: info?.parentID ?? null,
version: info?.version ?? null,
project: projectPath,
cwd: projectPath,
});
// cache the context returned at session/start so the
// chat.system.transform hook injects it without a second fetch.
const startCtx = (startResult as any)?.context;
if (typeof startCtx === "string" && startCtx.length > 0) {
startContextCache.set(sessionId, startCtx);
}
if (pendingConfig) {
await observe(sessionId, "config_loaded", pendingConfig);
pendingConfig = null;
}
}
// ── session.idle ── (summarize handled in session.status idle branch)
// ── session.status ──
if (type === "session.status") {
const status = props.status as Record<string, unknown> | undefined;
const sid = props.sessionID || activeSessionId;
if (!sid || !status) return;
if (status.type === "idle") {
await post("/summarize", { sessionId: sid });
}
await observe(sid, "session_status", {
status_type: status.type,
attempt: status.attempt ?? null,
message: safeSlice(status.message, 2000),
});
}
// ── session.compacted ──
if (type === "session.compacted") {
const sid = props.sessionID || activeSessionId;
if (sid) {
await post("/summarize", { sessionId: sid });
await observe(sid, "session_compacted", {});
}
}
// ── session.updated ──
if (type === "session.updated") {
const info = props.info as Record<string, unknown> | undefined;
const sid = (info?.id as string) || props.sessionID || activeSessionId;
if (!sid) return;
await observe(sid, "session_updated", {
title: info?.title ?? null,
parentID: info?.parentID ?? null,
additions: (info?.summary as any)?.additions ?? null,
deletions: (info?.summary as any)?.deletions ?? null,
files: (info?.summary as any)?.files ?? null,
});
}
// ── session.diff ──
if (type === "session.diff") {
const sid = props.sessionID || activeSessionId;
if (!sid || !Array.isArray(props.diff)) return;
const diffs = props.diff as Array<Record<string, unknown>>;
await observe(sid, "session_diff", {
files: diffs.map(d => d.file),
additions: diffs.reduce((s, d) => s + ((d.additions as number) || 0), 0),
deletions: diffs.reduce((s, d) => s + ((d.deletions as number) || 0), 0),
diffs: diffs.slice(0, 50),
});
}
// ── session.deleted ──
if (type === "session.deleted") {
const sid = props.info?.id || props.sessionID || activeSessionId;
if (!sid) {
if (DEBUG) console.error("[agentmemory] session.deleted with no session ID");
return;
}
await post("/session/end", { sessionId: sid });
post("/crystals/auto", { olderThanDays: 7 }, 30000);
post("/consolidate-pipeline", { tier: "all", force: true }, 30000);
if (sid === activeSessionId) activeSessionId = null;
stashedFiles.delete(sid);
startContextCache.delete(sid);
seenSubtaskIds.delete(sid);
seenToolCallIds.delete(sid);
contextInjectedSessions.delete(sid);
}
// ── session.error ──
if (type === "session.error") {
const sid = props.sessionID || activeSessionId;
if (sid) {
await observe(sid, "post_tool_failure", {
tool_name: "session.error",
tool_input: "",
tool_output: safeSlice(props.error, 8000),
});
}
}
// ── message.updated ──
if (type === "message.updated") {
const info = props.info as Record<string, unknown> | undefined;
if (!info) return;
if (info.role === "assistant") {
const sid = props.sessionID || (info.sessionID as string) || activeSessionId;
if (!sid) return;
const tokens = info.tokens as Record<string, unknown> | undefined;
const error = info.error ? extractErrorMessage(info.error) : null;
await observe(sid, "assistant_message", {
messageID: info.id,
parentID: info.parentID,
modelID: info.modelID,
providerID: info.providerID,
mode: info.mode,
cost: info.cost ?? 0,
tokens: {
input: tokens?.input ?? 0,
output: tokens?.output ?? 0,
reasoning: tokens?.reasoning ?? 0,
cache_read: (tokens?.cache as any)?.read ?? 0,
cache_write: (tokens?.cache as any)?.write ?? 0,
},
finish: info.finish ?? null,
error,
duration_ms: (info.time && typeof (info.time as any).completed === "number")
? (info.time as any).completed - ((info.time as any).created || 0)
: null,
});
}
}
// ── message.removed ──
if (type === "message.removed") {
const sid = props.sessionID || activeSessionId;
if (sid) {
await observe(sid, "message_removed", {
messageID: props.messageID,
});
}
}
// ── message.part.updated ──
if (type === "message.part.updated") {
const part = props.part as Record<string, unknown> | undefined;
if (!part) return;
const sid = (part.sessionID as string) || props.sessionID || activeSessionId;
if (!sid) return;
if (part.type === "subtask") {
const subtaskId = part.id as string;
if (!subtaskId) return;
const subtaskSet = subtaskSetFor(sid);
if (subtaskSet.has(subtaskId)) return;
subtaskSet.add(subtaskId);
await observe(sid, "subagent_start", {
subtask_id: part.id,
agent: part.agent,
prompt: safeSlice(part.prompt, 4000),
description: safeSlice(part.description, 2000),
});
return;
}
if (part.type === "tool") {
const state = part.state as Record<string, unknown> | undefined;
if (!state) return;
const callId = part.callID as string;
if (!callId) return;
const toolName = part.tool as string;
if (state.status === "completed") {
const callSet = toolCallSetFor(sid);
if (callSet.has(callId)) return;
callSet.add(callId);
const st = state as Record<string, unknown>;
const rawTime = (st.time as any) || {};
const startTime = typeof rawTime.start === "number" ? rawTime.start : null;
const endTime = typeof rawTime.end === "number" ? rawTime.end : null;
await observe(sid, "post_tool_use", {
tool_name: toolName,
call_id: callId,
tool_input: safeSlice(st.input, 4000),
tool_output: safeSlice(st.output, 8000),
title: st.title ?? null,
metadata: st.metadata || {},
duration_ms: (startTime != null && endTime != null) ? endTime - startTime : null,
attachments: Array.isArray(st.attachments)
? (st.attachments as Array<Record<string, unknown>>).map(a => a.filename || a.url)
: [],
});
} else if (state.status === "error") {
const callSet = toolCallSetFor(sid);
if (callSet.has(callId)) return;
callSet.add(callId);
const st = state as Record<string, unknown>;
const rawTime = (st.time as any) || {};
const startTime = typeof rawTime.start === "number" ? rawTime.start : null;
const endTime = typeof rawTime.end === "number" ? rawTime.end : null;
await observe(sid, "post_tool_failure", {
tool_name: toolName,
call_id: callId,
tool_input: safeSlice(st.input, 4000),
tool_output: safeSlice(st.error, 8000),
duration_ms: (startTime != null && endTime != null) ? endTime - startTime : null,
});
}
return;
}
if (part.type === "step-finish") {
await observe(sid, "step_finish", {
messageID: part.messageID,
reason: part.reason ?? null,
cost: (part as any).cost ?? 0,
input_tokens: ((part as any).tokens?.input as number) ?? 0,
output_tokens: ((part as any).tokens?.output as number) ?? 0,
reasoning_tokens: ((part as any).tokens?.reasoning as number) ?? 0,
});
return;
}
if (part.type === "reasoning") {
await observe(sid, "reasoning", {
messageID: part.messageID,
text: safeSlice((part as any).text, 4000),
});
return;
}
if (part.type === "file") {
const filename = (part as any).filename || (part as any).url || null;
if (filename) stashFor(sid).add(filename);
return;
}
if (part.type === "patch") {
await observe(sid, "patch_applied", {
messageID: part.messageID,
hash: (part as any).hash,
files: (part as any).files || [],
});
return;
}
if (part.type === "compaction") {
await observe(sid, "compaction_event", {
messageID: part.messageID,
auto: (part as any).auto ?? false,
});
return;
}
if (part.type === "agent") {
await observe(sid, "agent_selected", {
messageID: part.messageID,
name: (part as any).name,
});
return;
}
if (part.type === "retry") {
await observe(sid, "retry_attempt", {
messageID: part.messageID,
attempt: (part as any).attempt,
error: safeSlice((part as any).error, 2000),
});
return;
}
}
// ── file.edited ──
if (type === "file.edited") {
const sid = props.sessionID || activeSessionId;
if (sid && typeof props.file === "string" && props.file.length > 0) {
const stash = stashFor(sid);
stash.add(props.file);
if (stash.size > MAX_STASHED_FILES) {
const keep = [...stash].slice(-MAX_STASHED_FILES);
stash.clear();
for (const f of keep) stash.add(f);
}
}
}
// ── permission.updated ──
if (type === "permission.updated") {
const sid = props.sessionID || activeSessionId;
if (!sid) return;
await observe(sid, "notification", {
notification_type: "permission_prompt",
permission: props.type || "unknown",
pattern: Array.isArray(props.pattern)
? props.pattern.join(", ")
: (props.pattern || ""),
tool_call_id: props.callID || null,
title: props.title || props.type || "",
metadata: props.metadata || {},
});
}
// ── permission.replied ──
if (type === "permission.replied") {
const sid = props.sessionID || activeSessionId;
if (!sid) return;
await observe(sid, "permission_replied", {
permission_id: props.permissionID || props.requestID || "",
response: props.response || props.reply || "",
});
}
// ── todo.updated ──
if (type === "todo.updated") {
const sid = props.sessionID || activeSessionId;
const todos = Array.isArray(props.todos) ? props.todos.slice(0, 100) : [];
if (!sid || todos.length === 0) return;
const completed = todos.filter((t: any) => t.status === "completed");
const active = todos.filter((t: any) => t.status !== "completed");
await observe(sid, "task_completed", {
completed: completed.map((t: any) => ({ content: t.content, priority: t.priority })),
in_progress: active.map((t: any) => ({ content: t.content, priority: t.priority })),
total: todos.length,
});
}
// ── command.executed ──
if (type === "command.executed") {
const sid = props.sessionID || activeSessionId;
if (sid) {
await observe(sid, "command_executed", {
name: props.name,
arguments: props.arguments || "",
});
}
}
},
// ── chat.message ──
"chat.message": async (input, output) => {
const sid = input.sessionID || activeSessionId;
if (!sid) return;
const parts = output.parts || [];
const files = parts
.filter((p: any) => p.type === "file")
.map((p: any) => p.filename || p.url)
.filter(Boolean);
for (const f of files) {
const stash = stashFor(sid);
stash.add(f);
if (stash.size > MAX_STASHED_FILES) {
const keep = [...stash].slice(-MAX_STASHED_FILES);
stash.clear();
for (const k of keep) stash.add(k);
}
}
const textParts = parts.filter((p: any) => p.type === "text" && !p.synthetic && !p.ignored);
const userText = textParts.map((p: any) => p.text || "").join("\n");
await observe(sid, "prompt_submit", {
agent: input.agent ?? null,
model: input.model ?? null,
variant: input.variant ?? null,
prompt: userText.slice(0, 8000),
files: files.slice(0, 20),
parts_summary: parts.map((p: any) => p.type).filter(Boolean),
});
},
// ── chat.params ──
"chat.params": async (input, output) => {
if (!input.model || !output) return;
const sid = input.sessionID || activeSessionId;
if (!sid) return;
await observe(sid, "llm_params", {
agent: input.agent,
model: `${input.model.providerID}/${input.model.id}`,
provider_url: input.model.api?.url ?? null,
temperature: output.temperature,
topP: output.topP,
max_output_tokens: input.model.limit?.output ?? null,
context_limit: input.model.limit?.context ?? null,
cost_1k_input: input.model.cost?.input ?? 0,
cost_1k_output: input.model.cost?.output ?? 0,
});
},
// ── tool.execute.before ──
"tool.execute.before": async (input, output) => {
if (!FILE_TOOLS.has(input.tool)) return;
const sid = input.sessionID || activeSessionId;
if (!sid) return;
const args = output.args as Record<string, unknown> | undefined;
if (!args) return;
const stash = stashFor(sid);
for (const fp of extractFilePaths(args)) {
stash.add(fp);
}
if (stash.size > MAX_STASHED_FILES) {
const keep = [...stash].slice(-MAX_STASHED_FILES);
stash.clear();
for (const f of keep) stash.add(f);
}
},
// ── experimental.chat.system.transform ──
"experimental.chat.system.transform": async (input, output) => {
const sid = input.sessionID || activeSessionId;
if (!sid) return;
if (!contextInjectedSessions.has(sid)) {
if (!Array.isArray(output.system)) return;
output.system.push(AGENTMEMORY_INSTRUCTIONS);
// prefer the context already fetched at session.created;
// fall back to a fresh /context call if the cache missed (e.g.
// session resumed across plugin reloads).
let ctx = startContextCache.get(sid);
if (typeof ctx !== "string" || ctx.length === 0) {
const result = await postJson("/context", {
sessionId: sid,
project: projectPath,
});
ctx = (result as any)?.context;
} else {
startContextCache.delete(sid);
}
if (typeof ctx === "string" && ctx.length > 0) {
output.system.push(ctx);
}
contextInjectedSessions.add(sid);
}
const stash = stashFor(sid);
if (stash.size === 0) return;
const files = [...stash].slice(0, 10);
const enrichResult = await postJson("/enrich", {
sessionId: sid,
files,
toolName: "enrich_inject",
});
const enrichCtx = (enrichResult as any)?.context;
if (typeof enrichCtx === "string" && enrichCtx.length > 0) {
if (Array.isArray(output.system)) {
output.system.push(enrichCtx);
}
for (const f of files) stash.delete(f);
}
},
// ── experimental.session.compacting (WIP) ──
"experimental.session.compacting": async (input, output) => {
const sid = input.sessionID || activeSessionId;
if (!sid) return;
const result = await postJson("/context", {
sessionId: sid,
project: projectPath,
});
const ctx = (result as any)?.context;
if (typeof ctx === "string" && ctx.length > 0) {
if (Array.isArray(output.context)) {
output.context.push(ctx);
}
}
},
// ── config ──
config: async (input) => {
const payload: Record<string, unknown> = {
theme: input.theme ?? null,
model: input.model ?? null,
autoupdate: input.autoupdate ?? null,
agents: typeof input.agent === "object" && input.agent !== null && !Array.isArray(input.agent)
? Object.keys(input.agent as Record<string, unknown>)
: Array.isArray(input.agent) ? input.agent : [],
mcp_servers: typeof input.mcp === "object" && input.mcp !== null && !Array.isArray(input.mcp)
? Object.keys(input.mcp as Record<string, unknown>)
: Array.isArray(input.mcp) ? input.mcp : [],
providers: typeof input.provider === "object" && input.provider !== null && !Array.isArray(input.provider)
? Object.keys(input.provider as Record<string, unknown>)
: Array.isArray(input.provider) ? input.provider : [],
permission: input.permission ?? null,
};
if (activeSessionId) {
await observe(activeSessionId, "config_loaded", payload);
} else {
pendingConfig = payload;
}
},
};
};
+19
View File
@@ -0,0 +1,19 @@
Search past session observations and lessons for relevant context. Wrap the `memory_smart_search` and `memory_lesson_recall` MCP tools.
## Usage
```
/recall [query]
```
## Instructions
1. Call `memory_smart_search` with the query and `limit: 10` (hybrid BM25 + vector + graph search).
2. Call `memory_lesson_recall` with the same query and `limit: 5` (lesson search).
3. Combine results and present to the user:
- Group by session
- Show type, title, and narrative for each observation
- Highlight high-importance (>= 7) observations
- Show lessons separately with confidence scores
4. If no results, suggest 2-3 alternative search terms.
5. **Never hallucinate results.** Only present what the MCP tools actually return.
+19
View File
@@ -0,0 +1,19 @@
Explicitly save an insight, decision, or learning to agentmemory for future sessions. Wraps the `memory_save` MCP tool.
## Usage
```
/remember [what to remember]
```
## Instructions
1. Analyze what needs to be remembered — extract the core insight, decision, or fact.
2. Extract 2-5 searchable concepts (lowercased keyword phrases). Prefer specific terms ("jwt-refresh-rotation" over "auth").
3. Extract relevant file paths the memory references.
4. Call `memory_save` with:
- `content` — full text to remember (preserve user's phrasing)
- `concepts` — extracted concept list
- `files` — extracted file list (empty array if none)
- `type` — choose from: pattern, preference, architecture, bug, workflow, fact
5. Confirm the save and show the concepts tagged so the user knows retrieval terms.
+12
View File
@@ -0,0 +1,12 @@
{
"name": "agentmemory-capture",
"version": "0.9.20",
"description": "OpenCode plugin for agentmemory — full Claude Code hook parity: session lifecycle (create/idle/status/compacted/update/diff/delete/error), messages & prompts (chat.message, message.updated user+assistant, message.removed), tool lifecycle (ToolPart states with timing), part tracking (subtask, step-finish, reasoning, file, patch, compaction, agent, retry), file enrichment pipeline, permissions, task tracking (w/ priority), commands, config & model tracking. 22 hooks, 2 slash commands.",
"author": {
"name": "Rohit Ghumare",
"url": "https://github.com/rohitg00"
},
"license": "Apache-2.0",
"homepage": "https://github.com/rohitg00/agentmemory",
"repository": "https://github.com/rohitg00/agentmemory"
}