diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ad3ed3f80a..5859339b72 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "mem0", "source": "./mem0-plugin", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.", - "version": "0.2.8" + "version": "0.2.9" } ] } diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 0be75823ed..9576f25456 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "mem0", "source": "./mem0-plugin", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.", - "version": "0.2.8" + "version": "0.2.9" } ] } diff --git a/mem0-plugin/.claude-plugin/plugin.json b/mem0-plugin/.claude-plugin/plugin.json index f5a140aed4..163731f335 100644 --- a/mem0-plugin/.claude-plugin/plugin.json +++ b/mem0-plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.2.8", + "version": "0.2.9", "description": "Persistent memory for Claude Code. Remembers decisions, patterns, and preferences across sessions.", "author": { "name": "Mem0", diff --git a/mem0-plugin/.codex-plugin/plugin.json b/mem0-plugin/.codex-plugin/plugin.json index 52bbaa59d3..44aab8b6e4 100644 --- a/mem0-plugin/.codex-plugin/plugin.json +++ b/mem0-plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.2.8", + "version": "0.2.9", "description": "Persistent memory for Codex. Remembers decisions, patterns, and preferences across sessions.", "author": { "name": "Mem0", diff --git a/mem0-plugin/.cursor-plugin/plugin.json b/mem0-plugin/.cursor-plugin/plugin.json index 588ca33a93..d82f5e06e2 100644 --- a/mem0-plugin/.cursor-plugin/plugin.json +++ b/mem0-plugin/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mem0", - "version": "0.2.8", + "version": "0.2.9", "description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.", "author": { "name": "Mem0", diff --git a/mem0-plugin/.opencode-plugin/CHANGELOG.md b/mem0-plugin/.opencode-plugin/CHANGELOG.md new file mode 100644 index 0000000000..1b88159a71 --- /dev/null +++ b/mem0-plugin/.opencode-plugin/CHANGELOG.md @@ -0,0 +1,47 @@ +# Changelog + +All notable changes to the `@mem0/opencode-plugin` will be documented in this file. + +## 0.1.3 — File-context injection, session summaries & activity timeline + +### Added + +- **File-context injection (`tool.execute.before` / Read):** Before the agent reads a file, the plugin searches mem0 for memories referencing that file path and injects prior work as system context. Gates on file size (>= 1,500 bytes). Gives the agent "I've worked on this file before" awareness automatically. +- **Stop hook session summary (`experimental.session.compacting`):** Enhanced session compaction to store a structured `session_summary` memory with `infer=True`, letting the mem0 backend AI extract key facts (request, decisions, learnings, next steps). Previously only stored a raw stats string. +- **SessionStart activity timeline:** The initial memory loading now formats recent memories with type icons (⚖️ decision, 🔴 bug_fix, 🔵 task_learning, etc.) and relative age indicators (2h ago, 1d ago) instead of bare text. Provides a visual "Recent Activity" timeline on first message. + +### Changed + +- **`experimental.session.compacting` handler:** Now stores `metadata.type=session_summary` with `metadata.source=opencode-stop` instead of `metadata.type=session_state` with `metadata.source=pre-compaction`. Includes a structured prompt that instructs mem0's AI to extract request, decisions, learnings, and next steps. +- **Initial context formatting:** Memories shown on first message now include type icons and age labels for quick scanning. + +## 0.1.2 — Automatic coding categories & global search + +### Added + +- **Auto-configured coding categories:** The plugin now automatically sets up 17 coding categories (e.g. `architecture_decisions`, `api_design`, `security`, `debugging_notes`) on the Mem0 project at startup. Runs in the background on every session start via `autoSetupCategories()`, is fully idempotent, and never blocks initialization. Uses SHA-256 fingerprints of the category list and API key — stored in `~/.mem0/categories_setup.json` — to skip redundant API calls on subsequent sessions. +- **Global search mode (`global_search` setting):** New `global_search` toggle in `~/.mem0/settings.json` (default: `false`). When enabled, all `search_memories` and `get_memories` calls use `{"OR": [{"user_id": "*"}]}` instead of the per-user per-project `AND` filter — returning all memories across all users and all `app_id` scopes. Writes (`add_memory`) still tag with the current `user_id` and `app_id`. Applies to all plugin search paths: initial load, per-message recall, resume detection, error-pattern lookup, and compaction context. +- **`/mem0:switch-project --global` / `--no-global`:** Enables or disables global search via the switch-project skill. Persists to `~/.mem0/settings.json`. No manual config editing needed. +- **`MEM0_GLOBAL_SEARCH` environment variable:** Exported to child shells via the `shell.env` hook (`"true"` or `"false"`). + +### Changed + +- **Search filters are now dynamic:** All search paths throughout the plugin construct filters based on the `global_search` setting instead of always using `AND [user_id, app_id]`. +- **Resume-context searches broadened:** Resume and error-pattern searches no longer include `metadata.type` sub-filters (`session_state`, `decision`, `anti_pattern`, `bug_fix`), broadening recall. +- **System context message updated:** Informs the model when global search is active (`"Global search is ON — searches return all memories across all users and projects. Writes still use user_id=..., app_id=..."`). +- **`/mem0:onboard` Step 5 is no longer interactive:** Removed the manual category installation prompt. Categories now configure automatically in the background; the onboarding step only verifies status and stores a fallback `project_profile` memory if the background run hasn't finished yet. +- **`/mem0:switch-project` skill expanded:** Description and execution updated to document the `--global` and `--no-global` flags alongside the existing project-name argument. + +## 0.1.1 + +- CI/CD publish flow test (`#5288`). +- Fixed tsconfig, added `publishConfig` and bun lockfile (`#5273`). +- Renamed package to `@mem0/opencode-plugin` (`#5272`). +- Added plugin array to bundled `opencode.json` (`#5271`). + +## 0.1.0 — Initial release + +- **OpenCode plugin** (`@mem0/opencode-plugin` on npm): Pure TypeScript plugin using the `mem0ai` TS SDK — no Python, no shell scripts. Hooks into all 6 OpenCode events (`chat.message`, `tool.execute.before`, `tool.execute.after`, `experimental.chat.system.transform`, `experimental.session.compacting`, `shell.env`). Features: session start memory loading, per-prompt semantic search, error pattern detection with memory lookup, resume/remember intent detection, auto-capture every 3rd message, periodic save nudges, full metadata defaults injection (confidence, source, type, session_id, files, branch), identity injection for search/get/delete filters, type-filtered error pre-fetch (anti_pattern + bug_fix), pre-compaction memory capture, MEMORY.md write blocking, and secret redaction. +- **16 OpenCode-native skills** bundled in `opencode-skills/`: `context-loader`, `dream`, `export`, `forget`, `health`, `import`, `list-projects`, `mem0` (SDK reference), `memory-reviewer`, `onboard`, `peek`, `pin`, `remember`, `stats`, `switch-project`, `tour`. All skills are pure MCP-tool-based — no Python scripts, no shell scripts, no Claude Code dependencies. +- **Auto-install skills and commands (`installSkills()`):** On plugin load, copies all 16 skills to `.opencode/skills/` and creates command wrapper files in `.opencode/commands/` so they appear in the OpenCode `/` palette. +- **CLI installer (`cli.ts`):** `bunx @mem0/opencode-plugin install` auto-configures plugin and MCP server in `~/.config/opencode/opencode.json`. diff --git a/mem0-plugin/.opencode-plugin/opencode-mem0.ts b/mem0-plugin/.opencode-plugin/opencode-mem0.ts index 4982722999..e91add6a1c 100644 --- a/mem0-plugin/.opencode-plugin/opencode-mem0.ts +++ b/mem0-plugin/.opencode-plugin/opencode-mem0.ts @@ -66,6 +66,38 @@ function redact(text: string): string { return out; } +function formatAge(createdAt: string): string { + try { + const dt = new Date(createdAt); + const now = Date.now(); + const seconds = Math.floor((now - dt.getTime()) / 1000); + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + const days = Math.floor(seconds / 86400); + if (days === 1) return "1d ago"; + if (days < 30) return `${days}d ago`; + return `${Math.floor(days / 30)}mo ago`; + } catch { + return ""; + } +} + +const TYPE_ICONS: Record = { + decision: "⚖️", + anti_pattern: "🔴", + bug_fix: "🔴", + convention: "🔄", + task_learning: "🔵", + user_preference: "🟣", + session_summary: "📋", + session_state: "📋", + project_profile: "📖", + compact_summary: "📋", + auto_capture: "✅", +}; + +const FILE_READ_GATE_MIN_BYTES = 1500; + function loadGlobalSearch(): boolean { try { const settingsPath = join(homedir(), ".mem0", "settings.json"); @@ -308,9 +340,16 @@ const Mem0Plugin: Plugin = async (ctx) => { const memories = extractMemories(res); if (memories.length > 0) { const memLines = memories - .map((m) => `- ${m.memory}`) + .map((m) => { + const meta = (m as any).metadata ?? {}; + const cat = meta.type ?? "unknown"; + const icon = TYPE_ICONS[cat] ?? "❓"; + const age = (m as any).created_at ? formatAge((m as any).created_at) : ""; + const ageStr = age ? ` (${age})` : ""; + return `- ${icon} [${cat}]${ageStr} ${m.memory.slice(0, 120)}`; + }) .join("\n"); - systemContext.push(`Prior context from mem0:\n${memLines}`); + systemContext.push(`### Recent Activity\n\n${memLines}`); } } catch {} } @@ -445,6 +484,41 @@ const Mem0Plugin: Plugin = async (ctx) => { "tool.execute.before": async (input: any, output: any) => { const toolName: string = input?.tool ?? ""; + // File-context injection: before reading a file, search mem0 for prior work on it + if (toolName === "read" || toolName === "Read") { + const filePath = String(output?.args?.file_path ?? output?.args?.filePath ?? ""); + if (filePath && filePath.length > 0) { + try { + const absPath = filePath.startsWith("/") ? filePath : resolve(process.cwd(), filePath); + const { statSync } = await import("fs"); + const stat = statSync(absPath); + if (stat.isFile() && stat.size >= FILE_READ_GATE_MIN_BYTES) { + const searchFilters = globalSearch + ? { OR: [{ user_id: "*" }] } + : { AND: [{ user_id: userId }, { app_id: appId }] }; + const relPath = filePath.startsWith("/") + ? filePath.replace(process.cwd() + "/", "") + : filePath; + const res = await mem0.search(relPath, { + filters: searchFilters, + topK: 5, + }); + stats.searches++; + const memories = extractMemories(res); + if (memories.length > 0) { + const lines = memories.map((m) => { + const text = m.memory.slice(0, 150).replace(/\n/g, " "); + return `- ${text} [mem0:${m.id.slice(0, 8)}]`; + }); + systemContext.push( + `Prior work on \`${relPath}\`:\n${lines.join("\n")}`, + ); + } + } + } catch {} + } + } + if (WRITE_TOOLS.has(toolName)) { const fp = String( output?.args?.file_path ?? output?.args?.filePath ?? "", @@ -611,15 +685,22 @@ const Mem0Plugin: Plugin = async (ctx) => { ) => { try { const compactSessionId = input?.sessionID ?? sessionId; - const summaryContent = `Session compacting. Project: ${appId}. Branch: ${branch}. Session: ${compactSessionId}. Stats: ${stats.adds} memories stored, ${stats.searches} searches, ${stats.messages} messages.`; + + // Session summary capture: store a structured summary of the session + const summaryPrompt = [ + `Session summary for project ${appId} (branch: ${branch}).`, + `Session: ${compactSessionId}.`, + `Stats: ${stats.adds} memories stored, ${stats.searches} searches, ${stats.messages} messages.`, + `Extract and remember: what was requested, what was investigated, key decisions made, what was completed, and what needs to happen next.`, + ].join(" "); Promise.resolve().then(async () => { try { - await mem0.add([{ role: "user", content: summaryContent }], { + await mem0.add([{ role: "user", content: summaryPrompt }], { user_id: userId, app_id: appId, metadata: { - type: "session_state", - source: "pre-compaction", + type: "session_summary", + source: "opencode-stop", session_id: compactSessionId, branch, }, diff --git a/mem0-plugin/.opencode-plugin/package.json b/mem0-plugin/.opencode-plugin/package.json index 8ddba82357..53ebcac6e1 100644 --- a/mem0-plugin/.opencode-plugin/package.json +++ b/mem0-plugin/.opencode-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@mem0/opencode-plugin", - "version": "0.1.2", + "version": "0.1.3", "type": "module", "description": "Mem0 persistent memory plugin for OpenCode — add, search, and manage memories across sessions", "main": "dist/index.js", diff --git a/mem0-plugin/CHANGELOG.md b/mem0-plugin/CHANGELOG.md index 72b1cc586b..4e6bdc907e 100644 --- a/mem0-plugin/CHANGELOG.md +++ b/mem0-plugin/CHANGELOG.md @@ -2,11 +2,29 @@ All notable changes to the Mem0 plugin will be documented in this file. +## 0.2.9 — File-context injection, session summaries & activity timeline + +### Added + +- **File-context injection (`PreToolUse/Read` hook):** Before Claude reads a file, the new `on_file_read.sh` hook searches mem0 for memories that reference that file path and injects a compact timeline of prior work as `additionalContext`. Gives Claude "I've seen this file before and here's what I remember" context automatically. Gates on file size (>= 1,500 bytes), 5-second hard timeout, silent skip on any failure. Applies to Claude Code, Codex, and Cursor (`on_file_read_cursor.sh`). +- **Stop hook session summary (`on_stop.sh`):** On session end, parses the transcript JSONL, extracts the last assistant message and files touched, builds a structured prompt, and stores it via the mem0 API with `infer=True` — letting the platform's backend AI extract structured facts (request, decisions, learnings, next steps). Memories are stored as `metadata.type=session_summary` with 90-day expiry. Guards: skips subagent sessions (`agent_id` present), dedup via marker file, always exits 0. Applies to Claude Code, Codex, and Cursor (`on_stop_cursor.sh`). +- **SessionStart activity timeline (`session_timeline.py`):** On startup (when project has existing memories), fetches the 10 most recent memories and renders a compact timeline with type icons, age indicators, and short text below the existing banner. Shows recent decisions, bug fixes, and session summaries at a glance. 5-second timeout — if the API is slow, the timeline silently skips while the banner still displays. +- **`scripts/file_context.py`:** Core Python module for file-context injection. Searches mem0 cloud API with both relative and absolute file paths, deduplicates results, formats as compact timeline with type icons and memory age. +- **`scripts/capture_session_summary.py`:** Core Python module for Stop hook. Reads transcript JSONL (tail 3,000 lines), extracts last assistant message, extracts file paths from tool_input fields, strips system tags and `` blocks, stores via mem0 API. +- **`scripts/session_timeline.py`:** Core Python module for SessionStart timeline. Fetches recent memories from mem0 API, formats with type icons, relative age, and short text. + +### Changed + +- **`on_session_start.sh`:** On startup (when memories > 0), calls `session_timeline.py` to inject a compact recent activity timeline below the existing banner and rubric instructions. +- **`hooks/hooks.json`:** Added `PreToolUse` matcher for `Read` (5s timeout) and `Stop` hook (30s timeout). +- **`hooks/codex-hooks.json`:** Added `PreToolUse` matcher for `Read` (5s timeout) and `Stop` hook (30s timeout). +- **`hooks/cursor-hooks.json`:** Added `preToolUse` matcher for `Read` (5s timeout) and `stop` hook (30s timeout). + ## 0.2.8 — Automatic coding categories & global search ### Added -- **Global search mode (`global_search` setting):** New `global_search` toggle in `~/.mem0/settings.json` (default: `false`). When enabled, `search_memories` and `get_memories` calls use `{"OR": [{"user_id": "*"}]}` instead of the per-user per-project `AND` filter — returning all memories across all users and all `app_id` scopes in the platform project. Writes (`add_memory`) still tag with the current `user_id` and `app_id`. Solves the team-shared-memory use case where multiple team members need access to all memories regardless of which repo or user created them. Works on Claude Code, Cursor, and Codex (not OpenCode, which has its own TypeScript identity logic). +- **Global search mode (`global_search` setting):** New `global_search` toggle in `~/.mem0/settings.json` (default: `false`). When enabled, `search_memories` and `get_memories` calls use `{"OR": [{"user_id": "*"}]}` instead of the per-user per-project `AND` filter — returning all memories across all users and all `app_id` scopes in the platform project. Writes (`add_memory`) still tag with the current `user_id` and `app_id`. Solves the team-shared-memory use case where multiple team members need access to all memories regardless of which repo or user created them. Works on Claude Code, Cursor, and Codex. - **`/mem0:switch-project --global` / `--no-global`:** Enables or disables global search via the switch-project skill. Persists to `~/.mem0/settings.json`. No manual config editing needed. - **Session banner scope indicator:** Banner shows `scope=global` when global search is active instead of `project=`. - **Global-aware memory count:** Session start memory count query uses the global filter when `global_search` is enabled. @@ -20,17 +38,10 @@ All notable changes to the Mem0 plugin will be documented in this file. - **`/mem0:onboard` Step 5 is no longer interactive:** Removed the `Install coding categories? [Y/n]` prompt. Categories now configure automatically in the background; the onboarding step only verifies status and applies them if the background run hasn't finished yet — mirroring how Step 4 (project-file import) already works. - **Session-start "new project" hint** now notes that coding categories install automatically in the background. -## 0.1.0 — OpenCode & Antigravity +## 0.1.0 — Antigravity ### Added -- **OpenCode plugin** (`@mem0/opencode-plugin` on npm): Pure TypeScript plugin using the `mem0ai` TS SDK — no Python, no shell scripts. Hooks into all 6 OpenCode events (`chat.message`, `tool.execute.before`, `tool.execute.after`, `experimental.chat.system.transform`, `experimental.session.compacting`, `shell.env`). Features: session start memory loading, per-prompt semantic search, error pattern detection with memory lookup, resume/remember intent detection, auto-capture every 3rd message, periodic save nudges, full metadata defaults injection (confidence, source, type, session_id, files, branch), identity injection for search/get/delete filters, type-filtered error pre-fetch (anti_pattern + bug_fix), pre-compaction memory capture, MEMORY.md write blocking, and secret redaction. -- **16 OpenCode-native skills** bundled in `opencode-skills/`: `context-loader`, `dream`, `export`, `forget`, `health`, `import`, `list-projects`, `mem0` (SDK reference), `memory-reviewer`, `onboard`, `peek`, `pin`, `remember`, `stats`, `switch-project`, `tour`. All skills are pure MCP-tool-based — no Python scripts, no shell scripts, no Claude Code dependencies. -- **Auto-install skills and commands (`installSkills()`):** On plugin load, copies all 16 skills to `.opencode/skills/` and creates command wrapper files in `.opencode/commands/` so they appear in the OpenCode `/` palette. No manual setup needed. -- **`extractUserText()` handler:** Robust text extraction from OpenCode response shapes — handles `parts[]` array, `content[]` array, `message.content`, and plain string responses. -- **Identity resolution:** `getUserId()` uses `os.userInfo().username` (matching Claude Code's `${USER}` convention) with `MEM0_USER_ID` env override. `getProjectId()` uses git remote with `MEM0_APP_ID` env override. -- **Context injection via `experimental.chat.system.transform`:** All memory context (session start memories, per-prompt search results, error-related memories, compaction context) injected as system context. -- **CLI installer (`cli.ts`):** `bunx @mem0/opencode-plugin install` auto-configures plugin and MCP server in `~/.config/opencode/opencode.json`. - **Antigravity plugin** (`.antigravity/`): Restructured to follow the same shared-infrastructure pattern as Claude Code, Cursor, and Codex. Self-contained plugin directory with `plugin.json`, `mcp_config.json`, `hooks/hooks.json` (own file), `scripts/` (symlink → `../scripts/`), and `skills/` (symlink → `../skills/`). Installable via `agy plugin install .antigravity` or `npx degit mem0ai/mem0/mem0-plugin/.antigravity ~/.gemini/config/plugins/mem0`. Uses `contextFileName: "AGENTS.md"` per Antigravity convention. - **Codex hooks parity:** Added missing `PreToolUse` Write/Edit/MultiEdit block and `PreCompact` hook to Codex hooks config, bringing it to full parity with Claude Code. diff --git a/mem0-plugin/hooks.json b/mem0-plugin/hooks.json index ddf38b9003..9e631e618c 100644 --- a/mem0-plugin/hooks.json +++ b/mem0-plugin/hooks.json @@ -51,6 +51,29 @@ "timeout": 3 } ] + }, + { + "matcher": "Read", + "hooks": [ + { + "name": "mem0-file-context", + "type": "command", + "command": "ANTIGRAVITY_PLUGIN_ROOT=${extensionPath} CLAUDE_PLUGIN_ROOT=${extensionPath} bash ${extensionPath}/scripts/on_file_read.sh", + "timeout": 5 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "name": "mem0-session-summary", + "type": "command", + "command": "ANTIGRAVITY_PLUGIN_ROOT=${extensionPath} CLAUDE_PLUGIN_ROOT=${extensionPath} bash ${extensionPath}/scripts/on_stop.sh", + "timeout": 30 + } + ] } ], "PostToolUse": [ diff --git a/mem0-plugin/hooks/codex-hooks.json b/mem0-plugin/hooks/codex-hooks.json index 4756f28a28..383006e889 100644 --- a/mem0-plugin/hooks/codex-hooks.json +++ b/mem0-plugin/hooks/codex-hooks.json @@ -20,6 +20,16 @@ "timeout": 3 } ] + }, + { + "matcher": "Read", + "hooks": [ + { + "type": "command", + "command": "${PLUGIN_ROOT}/scripts/on_file_read.sh", + "timeout": 5 + } + ] } ], "SessionStart": [ @@ -68,6 +78,17 @@ ] } ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "${PLUGIN_ROOT}/scripts/on_stop.sh", + "timeout": 30 + } + ] + } + ], "PreCompact": [ { "hooks": [ diff --git a/mem0-plugin/hooks/cursor-hooks.json b/mem0-plugin/hooks/cursor-hooks.json index e5737ac376..1a07cb23b4 100644 --- a/mem0-plugin/hooks/cursor-hooks.json +++ b/mem0-plugin/hooks/cursor-hooks.json @@ -16,6 +16,11 @@ "command": "${CURSOR_PLUGIN_ROOT}/scripts/enforce_metadata_defaults.sh", "matcher": "mcp__mem0__add_memory|mcp__plugin_mem0_mem0__add_memory|mcp__mem0__search_memories|mcp__plugin_mem0_mem0__search_memories|mcp__mem0__get_memories|mcp__plugin_mem0_mem0__get_memories|mcp__mem0__delete_all_memories|mcp__plugin_mem0_mem0__delete_all_memories", "timeout": 3 + }, + { + "command": "${CURSOR_PLUGIN_ROOT}/scripts/on_file_read_cursor.sh", + "matcher": "Read", + "timeout": 5 } ], "postToolUse": [ @@ -30,6 +35,12 @@ "timeout": 12 } ], + "stop": [ + { + "command": "${CURSOR_PLUGIN_ROOT}/scripts/on_stop_cursor.sh", + "timeout": 30 + } + ], "preCompact": [ { "command": "${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact_cursor.sh" diff --git a/mem0-plugin/hooks/hooks.json b/mem0-plugin/hooks/hooks.json index b4f5648aa6..1792caf5b0 100644 --- a/mem0-plugin/hooks/hooks.json +++ b/mem0-plugin/hooks/hooks.json @@ -54,6 +54,16 @@ "timeout": 3 } ] + }, + { + "matcher": "Read", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_file_read.sh", + "timeout": 5 + } + ] } ], "PostToolUse": [ @@ -78,6 +88,17 @@ ] } ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_stop.sh", + "timeout": 30 + } + ] + } + ], "PreCompact": [ { "hooks": [ diff --git a/mem0-plugin/plugin.json b/mem0-plugin/plugin.json index 2f9086c5a8..0521b9ad05 100644 --- a/mem0-plugin/plugin.json +++ b/mem0-plugin/plugin.json @@ -1,7 +1,7 @@ { "id": "mem0", "name": "mem0", - "version": "0.1.0", + "version": "0.1.1", "description": "Persistent semantic memory for Antigravity agents. Cross-session, user-level recall via the Mem0 Platform MCP server. 16 slash commands, lifecycle hooks for auto-capture and metadata enforcement.", "author": { "name": "Mem0", "email": "support@mem0.ai" }, "publisher": "mem0ai", diff --git a/mem0-plugin/scripts/_formatting.py b/mem0-plugin/scripts/_formatting.py new file mode 100644 index 0000000000..eb5e23368d --- /dev/null +++ b/mem0-plugin/scripts/_formatting.py @@ -0,0 +1,51 @@ +"""Shared formatting helpers for mem0 plugin hooks. + +Constants and utilities used by file_context.py, session_timeline.py, +and any future hook that displays memories. +""" + +from __future__ import annotations + +TYPE_ICONS = { + "decision": "⚖️", + "anti_pattern": "\U0001f534", + "bug_fix": "\U0001f534", + "convention": "\U0001f504", + "task_learning": "\U0001f535", + "user_preference": "\U0001f7e3", + "session_summary": "\U0001f4cb", + "session_state": "\U0001f4cb", + "project_profile": "\U0001f4d6", + "compact_summary": "\U0001f4cb", + "auto_capture": "✅", + "environmental": "🌐", + "health_check": "🩺", +} + + +def format_age(memory: dict) -> str: + """Format how long ago a memory was created, e.g. '2h ago', '3d ago'.""" + created = memory.get("created_at", "") + if not created: + return "" + try: + from datetime import datetime, timezone + + if created.endswith("Z"): + created = created[:-1] + "+00:00" + dt = datetime.fromisoformat(created) + now = datetime.now(timezone.utc) + delta = now - dt + seconds = int(delta.total_seconds()) + if seconds < 3600: + return f"{seconds // 60}m ago" + if seconds < 86400: + return f"{seconds // 3600}h ago" + days = seconds // 86400 + if days == 1: + return "1d ago" + if days < 30: + return f"{days}d ago" + return f"{days // 30}mo ago" + except Exception: + return "" diff --git a/mem0-plugin/scripts/_search.py b/mem0-plugin/scripts/_search.py index 81a38785a1..8f32862f22 100644 --- a/mem0-plugin/scripts/_search.py +++ b/mem0-plugin/scripts/_search.py @@ -37,23 +37,28 @@ def search_memories( min_score: float = 0.0, rerank: bool = False, threshold: float = 0.3, + global_search: bool = False, ) -> list[dict]: if not api_key: return [] - base_clauses: list[dict] = [{"user_id": user_id}, {"app_id": project_id}] - if metadata_type: - base_clauses.append({"metadata": {"type": metadata_type}}) - if metadata_filters: - for key, value in metadata_filters.items(): - base_clauses.append({"metadata": {key: value}}) + if global_search: + filters: dict = {"OR": [{"user_id": "*"}]} + else: + base_clauses: list[dict] = [{"user_id": user_id}, {"app_id": project_id}] + if metadata_type: + base_clauses.append({"metadata": {"type": metadata_type}}) + if metadata_filters: + for key, value in metadata_filters.items(): + base_clauses.append({"metadata": {key: value}}) + filters = {"AND": base_clauses} base_payload: dict = {"query": query, "top_k": top_k, "threshold": threshold} if rerank: base_payload["rerank"] = True try: - payload = {**base_payload, "filters": {"AND": list(base_clauses)}} + payload = {**base_payload, "filters": filters} results = _do_search(api_key, payload)[:top_k] if min_score > 0: diff --git a/mem0-plugin/scripts/capture_session_summary.py b/mem0-plugin/scripts/capture_session_summary.py new file mode 100644 index 0000000000..47d0eff19e --- /dev/null +++ b/mem0-plugin/scripts/capture_session_summary.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Capture a structured session summary on Stop hook. + +Runs on every Stop (end of each assistant turn). Each invocation reads +the transcript JSONL, extracts the latest assistant message and all +files touched so far, then stores via mem0 API with infer=True. Uses +run_id=session_id to scope infer dedup to the session, so the final +stored summary reflects the most recent turn — not just the first. + +Input: JSON on stdin with transcript_path, session_id, cwd, agent_id +Output: stderr logs only (exit 0 always — must not block) +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import sys +import urllib.error +import urllib.request +from datetime import date, timedelta + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _identity import resolve_api_key, resolve_user_id +from _project import resolve_branch, resolve_project_id + +log = logging.getLogger("mem0-session-summary") +log.setLevel(logging.DEBUG) +_handler = logging.StreamHandler(sys.stderr) +_handler.setFormatter(logging.Formatter("[mem0-session-summary] %(message)s")) +log.addHandler(_handler) + +if os.environ.get("MEM0_DEBUG"): + _log_dir = os.path.expanduser("~/.mem0") + try: + os.makedirs(_log_dir, exist_ok=True) + _fh = logging.FileHandler(os.path.join(_log_dir, "hooks.log")) + _fh.setFormatter(logging.Formatter("[mem0-session-summary] %(asctime)s %(message)s")) + log.addHandler(_fh) + except OSError: + pass + +API_URL = "https://api.mem0.ai" +MAX_TAIL_LINES = 3000 +MAX_SUMMARY_CHARS = 50000 +SUMMARY_EXPIRY_DAYS = 90 + +SYSTEM_TAG_RE = re.compile( + r"<(?:system-reminder|private|claude-mem-context|persisted-output|system_instruction)>" + r".*?" + r"", + re.DOTALL, +) + + +def tail_lines(filepath: str, n: int) -> list[str]: + try: + with open(filepath, "rb") as f: + f.seek(0, 2) + file_size = f.tell() + if file_size == 0: + return [] + chunk_size = min(file_size, n * 4096) + f.seek(max(0, file_size - chunk_size)) + data = f.read().decode("utf-8", errors="replace") + return data.splitlines()[-n:] + except OSError: + return [] + + +def extract_last_assistant_message(lines: list[str]) -> str: + """Walk transcript backwards, return text content of the last assistant message.""" + for line in reversed(lines): + line = line.strip() + if not line: + continue + if '"type":"assistant"' not in line and '"type": "assistant"' not in line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if entry.get("type") != "assistant": + continue + message = entry.get("message", {}) + content = message.get("content", []) + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for block in content: + if isinstance(block, str): + parts.append(block) + elif isinstance(block, dict) and block.get("type") == "text": + parts.append(block.get("text", "")) + return "\n".join(parts).strip() + return "" + + +def extract_files_touched(lines: list[str]) -> list[str]: + """Extract unique file paths from tool_use content blocks in transcript.""" + files = set() + file_ext_re = re.compile( + r"[a-zA-Z0-9_./-]+\.(?:py|ts|tsx|js|jsx|rs|go|rb|java|sh|yaml|yml|json|toml|md|sql|css|html)" + ) + for line in lines: + line = line.strip() + if not line: + continue + if '"tool_use"' not in line and '"file_path"' not in line: + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + content = entry.get("message", {}).get("content", []) + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict) or block.get("type") != "tool_use": + continue + inp = block.get("input", {}) + if not isinstance(inp, dict): + continue + fp = inp.get("file_path", "") + if fp: + files.add(fp) + command = inp.get("command", "") + if command: + for match in file_ext_re.findall(command): + files.add(match) + return sorted(files)[:20] + + +def strip_tags(text: str) -> str: + return SYSTEM_TAG_RE.sub("", text).strip() + + +def build_summary_prompt(assistant_msg: str, files: list[str]) -> str: + """Build a structured prompt that helps mem0's AI extract a good summary.""" + files_section = "" + if files: + file_list = ", ".join(files[:10]) + files_section = f"\n\nFiles touched during this session: {file_list}" + + return ( + f"Session summary — store the following as a structured session summary.\n\n" + f"What the assistant accomplished in this session:\n" + f"{assistant_msg[:MAX_SUMMARY_CHARS]}" + f"{files_section}\n\n" + f"Extract and remember: what was requested, what was investigated, " + f"key decisions made, what was completed, and what needs to happen next." + ) + + +def store_summary( + api_key: str, + summary_prompt: str, + user_id: str, + session_id: str, + project_id: str, + branch: str, + files: list[str], +) -> bool: + expires = (date.today() + timedelta(days=SUMMARY_EXPIRY_DAYS)).isoformat() + metadata = { + "type": "session_summary", + "source": "stop-hook", + "session_id": session_id, + } + if branch: + metadata["branch"] = branch + if files: + metadata["files_touched"] = json.dumps(files[:20]) + + body = { + "messages": [{"role": "user", "content": summary_prompt}], + "user_id": user_id, + "app_id": project_id, + "run_id": session_id, + "metadata": metadata, + "infer": True, + "expiration_date": expires, + } + + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request( + f"{API_URL}/v3/memories/add/", + data=data, + headers={ + "Content-Type": "application/json", + "Authorization": f"Token {api_key}", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + if resp.status in (200, 201): + log.info("Session summary stored") + return True + log.warning("API returned status %d", resp.status) + return False + except urllib.error.URLError as e: + log.warning("API call failed: %s", e) + return False + + +def main(): + api_key = resolve_api_key() + if not api_key: + log.debug("MEM0_API_KEY not set, skipping") + return + + try: + hook_input = json.loads(sys.stdin.read()) + except (json.JSONDecodeError, OSError): + log.debug("No valid JSON on stdin") + return + + # Guard: skip subagent sessions (only root sessions get summaries) + agent_id = hook_input.get("agent_id", "") + if agent_id: + log.debug("Subagent session (agent_id=%s), skipping", agent_id) + return + + transcript_path = hook_input.get("transcript_path", "") + if not transcript_path: + log.debug("No transcript_path provided") + return + + session_id = hook_input.get("session_id", "") + cwd = hook_input.get("cwd") or None + + user_id = resolve_user_id() + project_id = resolve_project_id(cwd) + branch = resolve_branch(cwd) + + lines = tail_lines(transcript_path, MAX_TAIL_LINES) + if not lines: + log.debug("Transcript empty or unreadable: %s", transcript_path) + return + + assistant_msg = extract_last_assistant_message(lines) + if not assistant_msg or len(assistant_msg.strip()) < 100: + log.debug("Assistant message too short (%d chars) — skipping", len(assistant_msg.strip())) + return + + assistant_msg = strip_tags(assistant_msg) + files = extract_files_touched(lines) + + summary_prompt = build_summary_prompt(assistant_msg, files) + + log.info("Capturing session summary (%d chars, %d files)", len(assistant_msg), len(files)) + store_summary(api_key, summary_prompt, user_id, session_id, project_id, branch, files) + + +if __name__ == "__main__": + try: + main() + except Exception as e: + log.error("Unexpected error: %s", e) + sys.exit(0) diff --git a/mem0-plugin/scripts/file_context.py b/mem0-plugin/scripts/file_context.py new file mode 100644 index 0000000000..8256ae6fae --- /dev/null +++ b/mem0-plugin/scripts/file_context.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""File-context injection for PreToolUse/Read hook. + +When Claude is about to read a file, this script searches mem0 for +memories that reference that file path and returns a compact timeline +of prior work. This gives Claude context like "last time you fixed a +null pointer here" before it reads the file. + +Modeled after claude-mem's file-context handler but adapted for mem0's +cloud API architecture. + +Input: file_path (positional arg), env vars for identity +Output: JSON to stdout with hookSpecificOutput.additionalContext +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _formatting import TYPE_ICONS, format_age +from _identity import resolve_api_key, resolve_user_id +from _project import resolve_project_id +from _search import search_memories + +FILE_READ_GATE_MIN_BYTES = 1500 +MAX_RESULTS = 5 +SEARCH_TIMEOUT = 5 + + +def gate_file(file_path: str, cwd: str) -> str | None: + """Return the resolved absolute path if the file passes gating, else None.""" + if not file_path: + return None + p = Path(file_path) + if not p.is_absolute(): + p = Path(cwd) / p + try: + p = p.resolve() + if not p.is_file(): + return None + if p.stat().st_size < FILE_READ_GATE_MIN_BYTES: + return None + return str(p) + except OSError: + return None + + +def relative_path(abs_path: str, cwd: str) -> str: + try: + return os.path.relpath(abs_path, cwd) + except ValueError: + return abs_path + + +def format_timeline(memories: list[dict], file_path: str) -> str: + """Format memories into a compact timeline for context injection.""" + if not memories: + return "" + + rel = file_path + lines = [ + f"Prior work on `{rel}` — {len(memories)} memories found.", + "Need details? Use `search_memories` with the memory ID.", + "", + ] + + for m in memories: + mid = m.get("id", "?")[:8] + text = (m.get("memory", "") or "")[:150].replace("\n", " ").strip() + meta = m.get("metadata") or {} + cat = meta.get("type", "unknown") + icon = TYPE_ICONS.get(cat, "❓") + age = format_age(m) + age_str = f" ({age})" if age else "" + lines.append(f"- {icon} [{cat}]{age_str} {text} [mem0:{mid}]") + + return "\n".join(lines) + + +def search_file_context( + api_key: str, user_id: str, project_id: str, file_path: str, cwd: str +) -> str: + """Search mem0 for memories related to a file path.""" + global_search = os.environ.get("MEM0_GLOBAL_SEARCH", "false") == "true" + rel = relative_path(file_path, cwd) + basename = os.path.basename(file_path) + + query = f"{rel} {basename}" if rel != basename else rel + results = search_memories( + api_key, user_id, project_id, query, + top_k=MAX_RESULTS, threshold=0.3, + global_search=global_search, + ) + + results = results[:MAX_RESULTS] + + return format_timeline(results, rel) + + +def main(): + if len(sys.argv) < 2: + sys.exit(0) + + file_path = sys.argv[1] + cwd = sys.argv[2] if len(sys.argv) > 2 else os.getcwd() + + api_key = resolve_api_key() + if not api_key: + sys.exit(0) + + resolved = gate_file(file_path, cwd) + if not resolved: + sys.exit(0) + + user_id = resolve_user_id() + project_id = resolve_project_id(cwd) + + timeline = search_file_context(api_key, user_id, project_id, resolved, cwd) + if not timeline: + sys.exit(0) + + print(timeline, end="") + + +if __name__ == "__main__": + try: + main() + except Exception: + pass + sys.exit(0) diff --git a/mem0-plugin/scripts/on_file_read.sh b/mem0-plugin/scripts/on_file_read.sh new file mode 100755 index 0000000000..d190d81d99 --- /dev/null +++ b/mem0-plugin/scripts/on_file_read.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Hook: PreToolUse (matcher: Read) +# +# Injects prior work context before Claude reads a file. Searches mem0 +# for memories referencing the file path and returns a compact timeline. +# +# Modeled after claude-mem's file-context handler, adapted for mem0 cloud API. +# +# Input: JSON on stdin with tool_name, tool_input (file_path), cwd +# Output: JSON with hookSpecificOutput.additionalContext + permissionDecision +# +# Must never block the Read — silent exit on any failure. + +set -uo pipefail + +INPUT=$(cat) + +# Extract file path from tool_input +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""' 2>/dev/null || echo "") +if [ -z "$FILE_PATH" ]; then + exit 0 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Resolve API key (covers Desktop app users who set it in shell profile) +if [ -z "${MEM0_API_KEY:-}" ]; then + . "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true +fi +if [ -z "${MEM0_API_KEY:-}" ]; then + exit 0 +fi +CWD=$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".") + +# Call the Python worker — it handles gating (file size, existence) +TIMELINE=$(python3 "$SCRIPT_DIR/file_context.py" "$FILE_PATH" "$CWD" 2>/dev/null || echo "") + +if [ -z "$TIMELINE" ]; then + exit 0 +fi + +# Return context injection with permissionDecision: allow +jq -cn --arg ctx "$TIMELINE" '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + additionalContext: $ctx, + permissionDecision: "allow" + } +}' 2>/dev/null || true + +exit 0 diff --git a/mem0-plugin/scripts/on_file_read_cursor.sh b/mem0-plugin/scripts/on_file_read_cursor.sh new file mode 100755 index 0000000000..c0531d4e18 --- /dev/null +++ b/mem0-plugin/scripts/on_file_read_cursor.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Hook: preToolUse (matcher: Read) — Cursor variant +# +# Same as on_file_read.sh but uses CURSOR_PLUGIN_ROOT for path resolution +# and sources Cursor-specific identity. + +set -uo pipefail + +INPUT=$(cat) + +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""' 2>/dev/null || echo "") +if [ -z "$FILE_PATH" ]; then + exit 0 +fi + +if [ -z "${MEM0_API_KEY:-}" ]; then + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + . "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true +fi +if [ -z "${MEM0_API_KEY:-}" ]; then + exit 0 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CWD=$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".") + +TIMELINE=$(python3 "$SCRIPT_DIR/file_context.py" "$FILE_PATH" "$CWD" 2>/dev/null || echo "") + +if [ -z "$TIMELINE" ]; then + exit 0 +fi + +jq -cn --arg ctx "$TIMELINE" '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + additionalContext: $ctx, + permissionDecision: "allow" + } +}' 2>/dev/null || true + +exit 0 diff --git a/mem0-plugin/scripts/on_session_start.sh b/mem0-plugin/scripts/on_session_start.sh index a4300b9e60..b5732002cc 100755 --- a/mem0-plugin/scripts/on_session_start.sh +++ b/mem0-plugin/scripts/on_session_start.sh @@ -156,6 +156,14 @@ if [ "$SOURCE" = "startup" ]; then echo "New project with 0 memories. Invoke the mem0:onboard skill to import project files. Coding categories install automatically in the background." else echo "Search mem0 for recent decisions and task learnings before responding. Run 2 parallel searches: one for decision type, one for task_learning type." + + # Inject compact recent activity timeline (non-blocking, 5s timeout) + # Use perl alarm as portable timeout (macOS lacks GNU timeout) + _TIMELINE=$(MEM0_CWD="$MEM0_CWD_RESOLVED" perl -e 'alarm 5; exec @ARGV' python3 "$SCRIPT_DIR/session_timeline.py" 2>/dev/null || echo "") + if [ -n "$_TIMELINE" ]; then + echo "" + echo "$_TIMELINE" + fi fi _PROJ_KEY=$(printf '%s' "$MEM0_CWD_RESOLVED" | tr '/' '-') diff --git a/mem0-plugin/scripts/on_stop.sh b/mem0-plugin/scripts/on_stop.sh new file mode 100755 index 0000000000..dce5e82f18 --- /dev/null +++ b/mem0-plugin/scripts/on_stop.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Hook: Stop +# +# Captures a structured session summary when a Claude Code session ends. +# Parses the transcript, extracts the last assistant message and files +# touched, then stores via mem0 API with infer=True for AI extraction. +# +# Guards: +# - Skips subagent sessions (agent_id present) +# - Skips if no API key +# - Skips if no transcript_path +# - Dedup via marker file +# +# Input: JSON on stdin with transcript_path, session_id, agent_id, cwd +# Output: Nothing to stdout (background capture). Always exits 0. + +set -uo pipefail + +if [ -n "${MEM0_DEBUG:-}" ]; then + mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" +fi + +INPUT=$(cat) + +# Guard: skip subagent sessions +AGENT_ID=$(echo "$INPUT" | jq -r '.agent_id // ""' 2>/dev/null || echo "") +if [ -n "$AGENT_ID" ]; then + exit 0 +fi + +# Resolve identity if needed +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true + +if [ -z "${MEM0_API_KEY:-}" ]; then + exit 0 +fi + +TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null || echo "") +if [ -z "$TRANSCRIPT_PATH" ]; then + exit 0 +fi + +# Run capture in the background — fires every turn now, so avoid blocking +echo "$INPUT" | python3 "$SCRIPT_DIR/capture_session_summary.py" 2>/dev/null & + +# Telemetry +python3 "$SCRIPT_DIR/telemetry.py" session_stop 2>/dev/null & + +exit 0 diff --git a/mem0-plugin/scripts/on_stop_cursor.sh b/mem0-plugin/scripts/on_stop_cursor.sh new file mode 100755 index 0000000000..ac24c02533 --- /dev/null +++ b/mem0-plugin/scripts/on_stop_cursor.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Hook: stop — Cursor variant +# +# Same as on_stop.sh but uses CURSOR_PLUGIN_ROOT and Cursor-specific +# identity resolution. + +set -uo pipefail + +if [ -n "${MEM0_DEBUG:-}" ]; then + mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" +fi + +INPUT=$(cat) + +AGENT_ID=$(echo "$INPUT" | jq -r '.agent_id // ""' 2>/dev/null || echo "") +if [ -n "$AGENT_ID" ]; then + exit 0 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true + +if [ -z "${MEM0_API_KEY:-}" ]; then + exit 0 +fi + +TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null || echo "") +if [ -z "$TRANSCRIPT_PATH" ]; then + exit 0 +fi + +echo "$INPUT" | python3 "$SCRIPT_DIR/capture_session_summary.py" 2>/dev/null || true + +python3 "$SCRIPT_DIR/telemetry.py" session_stop 2>/dev/null & + +exit 0 diff --git a/mem0-plugin/scripts/session_timeline.py b/mem0-plugin/scripts/session_timeline.py new file mode 100644 index 0000000000..86e22f7fbf --- /dev/null +++ b/mem0-plugin/scripts/session_timeline.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Fetch recent memories and format a compact timeline for SessionStart. + +Searches mem0 cloud API for the most recent memories in the project +and formats them as a compact activity timeline injected below the +existing SessionStart banner. + +Input: env vars for identity (MEM0_API_KEY, MEM0_RESOLVED_USER_ID, etc.) +Output: Compact timeline text to stdout (empty if nothing found) +""" + +from __future__ import annotations + +import json +import os +import sys +import urllib.request + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _formatting import TYPE_ICONS, format_age +from _identity import resolve_api_key, resolve_user_id +from _project import resolve_project_id + +API_URL = "https://api.mem0.ai" +MAX_RECENT = 10 +MAX_SUMMARIES = 3 +FETCH_TIMEOUT = 5 + + +def fetch_recent_memories(api_key: str, user_id: str, project_id: str) -> list[dict]: + """Fetch the most recent memories for this project via GET list endpoint.""" + global_search = os.environ.get("MEM0_GLOBAL_SEARCH", "false") == "true" + + if global_search: + filters = {"OR": [{"user_id": "*"}]} + else: + filters = {"AND": [{"user_id": user_id}, {"app_id": project_id}]} + + body = json.dumps({"filters": filters}).encode() + req = urllib.request.Request( + f"{API_URL}/v3/memories/?page=1&page_size={MAX_RECENT}", + data=body, + headers={ + "Authorization": f"Token {api_key}", + "Content-Type": "application/json", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=FETCH_TIMEOUT) as r: + result = json.loads(r.read()) + if isinstance(result, dict) and "results" in result: + return result["results"][:MAX_RECENT] + if isinstance(result, list): + return result[:MAX_RECENT] + return [] + except Exception: + return [] + + +def format_timeline(memories: list[dict]) -> str: + """Format memories into a compact recent activity timeline.""" + if not memories: + return "" + + lines = ["### Recent Activity", ""] + + for m in memories: + mid = m.get("id", "?")[:8] + text = (m.get("memory", "") or "")[:120].replace("\n", " ").strip() + meta = m.get("metadata") or {} + cat = meta.get("type", "unknown") + icon = TYPE_ICONS.get(cat, "❓") + age = format_age(m) + age_str = f" ({age})" if age else "" + lines.append(f"- {icon} [{cat}]{age_str} {text} [mem0:{mid}]") + + lines.append("") + lines.append("Search mem0 for details on any of these, or for past decisions and task learnings relevant to the current task.") + + return "\n".join(lines) + + +def main(): + api_key = resolve_api_key() + if not api_key: + return + + user_id = resolve_user_id() + project_id = resolve_project_id(os.environ.get("MEM0_CWD")) + + memories = fetch_recent_memories(api_key, user_id, project_id) + if not memories: + return + + timeline = format_timeline(memories) + if timeline: + print(timeline, end="") + + +if __name__ == "__main__": + try: + main() + except Exception: + pass + sys.exit(0)