chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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" />
|
||||
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.
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user