From e62d48581f4f2ae7d5a72463769434ed5caaa2a1 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Sat, 30 May 2026 14:11:54 +0530 Subject: [PATCH 1/9] Add XMem agent plugin bundles --- plugin/xmem-claude/.claude-plugin/plugin.json | 9 + plugin/xmem-claude/README.md | 49 +++++ plugin/xmem-claude/commands/index.md | 19 ++ plugin/xmem-claude/commands/logout.md | 16 ++ plugin/xmem-claude/commands/project-config.md | 25 +++ plugin/xmem-claude/commands/session.md | 14 ++ plugin/xmem-claude/hooks/hooks.json | 27 +++ plugin/xmem-claude/package.json | 17 ++ plugin/xmem-claude/scripts/add-memory.cjs | 29 +++ plugin/xmem-claude/scripts/context-hook.cjs | 34 ++++ .../scripts/save-project-memory.cjs | 29 +++ plugin/xmem-claude/scripts/search-memory.cjs | 24 +++ plugin/xmem-claude/scripts/summary-hook.cjs | 33 ++++ plugin/xmem-claude/skills/xmem-save/SKILL.md | 28 +++ .../xmem-claude/skills/xmem-search/SKILL.md | 17 ++ plugin/xmem-codex/.codex-plugin/plugin.json | 32 ++++ plugin/xmem-codex/README.md | 33 ++++ plugin/xmem-codex/package.json | 20 ++ plugin/xmem-codex/scripts/save-memory.cjs | 32 ++++ plugin/xmem-codex/scripts/search-memory.cjs | 24 +++ plugin/xmem-codex/scripts/status.cjs | 8 + plugin/xmem-codex/skills/xmem-save/SKILL.md | 28 +++ plugin/xmem-codex/skills/xmem-search/SKILL.md | 17 ++ plugin/xmem-codex/skills/xmem-status/SKILL.md | 17 ++ plugin/xmem-cursor/.cursor-plugin/plugin.json | 14 ++ plugin/xmem-cursor/.cursor/mcp.json | 8 + plugin/xmem-cursor/.mcp.json | 8 + plugin/xmem-cursor/README.md | 37 ++++ plugin/xmem-cursor/commands/xmem-config.md | 19 ++ plugin/xmem-cursor/commands/xmem-logout.md | 21 ++ plugin/xmem-cursor/commands/xmem-setup.md | 21 ++ plugin/xmem-cursor/hooks/hooks.json | 18 ++ plugin/xmem-cursor/package.json | 15 ++ plugin/xmem-cursor/rules/xmem.mdc | 20 ++ plugin/xmem-cursor/scripts/mcp-server.cjs | 124 ++++++++++++ plugin/xmem-cursor/scripts/session-end.cjs | 29 +++ plugin/xmem-cursor/scripts/session-start.cjs | 18 ++ plugin/xmem-cursor/scripts/status.cjs | 8 + .../xmem-cursor/skills/memory-init/SKILL.md | 8 + .../xmem-cursor/skills/memory-save/SKILL.md | 10 + .../xmem-cursor/skills/memory-search/SKILL.md | 9 + plugin/xmem-hermes/README.md | 55 ++++++ plugin/xmem-hermes/package.json | 30 +++ plugin/xmem-hermes/src/cli.js | 181 ++++++++++++++++++ plugin/xmem-hermes/test/cli.test.js | 37 ++++ plugin/xmem-openclaw/README.md | 51 +++++ plugin/xmem-openclaw/client.ts | 70 +++++++ plugin/xmem-openclaw/commands/cli.ts | 14 ++ plugin/xmem-openclaw/commands/slash.ts | 49 +++++ plugin/xmem-openclaw/config.ts | 78 ++++++++ plugin/xmem-openclaw/hooks/capture.ts | 13 ++ plugin/xmem-openclaw/hooks/recall.ts | 14 ++ plugin/xmem-openclaw/index.ts | 78 ++++++++ plugin/xmem-openclaw/logger.ts | 22 +++ plugin/xmem-openclaw/memory.ts | 33 ++++ plugin/xmem-openclaw/openclaw.plugin.json | 64 +++++++ plugin/xmem-openclaw/package.json | 39 ++++ plugin/xmem-openclaw/runtime.ts | 68 +++++++ plugin/xmem-openclaw/tools/search.ts | 34 ++++ plugin/xmem-openclaw/tools/status.ts | 20 ++ plugin/xmem-openclaw/tools/store.ts | 28 +++ 61 files changed, 1946 insertions(+) create mode 100644 plugin/xmem-claude/.claude-plugin/plugin.json create mode 100644 plugin/xmem-claude/README.md create mode 100644 plugin/xmem-claude/commands/index.md create mode 100644 plugin/xmem-claude/commands/logout.md create mode 100644 plugin/xmem-claude/commands/project-config.md create mode 100644 plugin/xmem-claude/commands/session.md create mode 100644 plugin/xmem-claude/hooks/hooks.json create mode 100644 plugin/xmem-claude/package.json create mode 100644 plugin/xmem-claude/scripts/add-memory.cjs create mode 100644 plugin/xmem-claude/scripts/context-hook.cjs create mode 100644 plugin/xmem-claude/scripts/save-project-memory.cjs create mode 100644 plugin/xmem-claude/scripts/search-memory.cjs create mode 100644 plugin/xmem-claude/scripts/summary-hook.cjs create mode 100644 plugin/xmem-claude/skills/xmem-save/SKILL.md create mode 100644 plugin/xmem-claude/skills/xmem-search/SKILL.md create mode 100644 plugin/xmem-codex/.codex-plugin/plugin.json create mode 100644 plugin/xmem-codex/README.md create mode 100644 plugin/xmem-codex/package.json create mode 100644 plugin/xmem-codex/scripts/save-memory.cjs create mode 100644 plugin/xmem-codex/scripts/search-memory.cjs create mode 100644 plugin/xmem-codex/scripts/status.cjs create mode 100644 plugin/xmem-codex/skills/xmem-save/SKILL.md create mode 100644 plugin/xmem-codex/skills/xmem-search/SKILL.md create mode 100644 plugin/xmem-codex/skills/xmem-status/SKILL.md create mode 100644 plugin/xmem-cursor/.cursor-plugin/plugin.json create mode 100644 plugin/xmem-cursor/.cursor/mcp.json create mode 100644 plugin/xmem-cursor/.mcp.json create mode 100644 plugin/xmem-cursor/README.md create mode 100644 plugin/xmem-cursor/commands/xmem-config.md create mode 100644 plugin/xmem-cursor/commands/xmem-logout.md create mode 100644 plugin/xmem-cursor/commands/xmem-setup.md create mode 100644 plugin/xmem-cursor/hooks/hooks.json create mode 100644 plugin/xmem-cursor/package.json create mode 100644 plugin/xmem-cursor/rules/xmem.mdc create mode 100644 plugin/xmem-cursor/scripts/mcp-server.cjs create mode 100644 plugin/xmem-cursor/scripts/session-end.cjs create mode 100644 plugin/xmem-cursor/scripts/session-start.cjs create mode 100644 plugin/xmem-cursor/scripts/status.cjs create mode 100644 plugin/xmem-cursor/skills/memory-init/SKILL.md create mode 100644 plugin/xmem-cursor/skills/memory-save/SKILL.md create mode 100644 plugin/xmem-cursor/skills/memory-search/SKILL.md create mode 100644 plugin/xmem-hermes/README.md create mode 100644 plugin/xmem-hermes/package.json create mode 100644 plugin/xmem-hermes/src/cli.js create mode 100644 plugin/xmem-hermes/test/cli.test.js create mode 100644 plugin/xmem-openclaw/README.md create mode 100644 plugin/xmem-openclaw/client.ts create mode 100644 plugin/xmem-openclaw/commands/cli.ts create mode 100644 plugin/xmem-openclaw/commands/slash.ts create mode 100644 plugin/xmem-openclaw/config.ts create mode 100644 plugin/xmem-openclaw/hooks/capture.ts create mode 100644 plugin/xmem-openclaw/hooks/recall.ts create mode 100644 plugin/xmem-openclaw/index.ts create mode 100644 plugin/xmem-openclaw/logger.ts create mode 100644 plugin/xmem-openclaw/memory.ts create mode 100644 plugin/xmem-openclaw/openclaw.plugin.json create mode 100644 plugin/xmem-openclaw/package.json create mode 100644 plugin/xmem-openclaw/runtime.ts create mode 100644 plugin/xmem-openclaw/tools/search.ts create mode 100644 plugin/xmem-openclaw/tools/status.ts create mode 100644 plugin/xmem-openclaw/tools/store.ts diff --git a/plugin/xmem-claude/.claude-plugin/plugin.json b/plugin/xmem-claude/.claude-plugin/plugin.json new file mode 100644 index 0000000..f1bbf74 --- /dev/null +++ b/plugin/xmem-claude/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "xmem-claude", + "version": "0.1.0", + "description": "Persistent XMem memory across Claude Code sessions", + "author": { + "name": "XMem", + "email": "support@xmem.in" + } +} diff --git a/plugin/xmem-claude/README.md b/plugin/xmem-claude/README.md new file mode 100644 index 0000000..ffbcd68 --- /dev/null +++ b/plugin/xmem-claude/README.md @@ -0,0 +1,49 @@ +# XMem Claude Plugin + +Claude Code plugin for persistent memory through XMem. + +This package is inspired by `claude-supermemory`, but talks directly to XMem: + +- loads relevant project memory on `SessionStart` +- stores a redacted transcript tail on `Stop` +- provides `xmem-search` and `xmem-save` skills +- includes commands for indexing, config, session checks, and logout + +## Install + +From Claude Code, install this plugin from the local folder once it is published or linked by the plugin marketplace flow for this repo. + +## Configuration + +Use environment variables. Do not commit secrets. + +```bash +export XMEM_API_KEY="xmem_..." +export XMEM_API_URL="https://api.xmem.in" +export XMEM_USER_ID="your-user-id" +``` + +`XMEM_API_URL` defaults to `https://api.xmem.in`. `XMEM_USER_ID` falls back to the local OS username; production API keys scope requests to the authenticated key owner. + +Optional project config can live at `.claude/.xmem-claude/config.json`: + +```json +{ + "apiUrl": "https://api.xmem.in", + "userId": "your-user-id" +} +``` + +Prefer environment variables for API keys. + +## Commands + +- `/xmem-claude:index` - explore the current repo and save a project summary +- `/xmem-claude:project-config` - show configuration options +- `/xmem-claude:session` - check whether memory is configured +- `/xmem-claude:logout` - remove project-local config + +## Skills + +- `xmem-search` - search prior XMem memories +- `xmem-save` - save durable project knowledge diff --git a/plugin/xmem-claude/commands/index.md b/plugin/xmem-claude/commands/index.md new file mode 100644 index 0000000..973cd4b --- /dev/null +++ b/plugin/xmem-claude/commands/index.md @@ -0,0 +1,19 @@ +--- +description: Index the current codebase into XMem for future Claude Code context +allowed-tools: ["Read", "Glob", "Grep", "Bash"] +--- + +# Index Codebase Into XMem + +Explore the repository and save a concise architecture summary into XMem. + +1. Read `README.md`, package manifests, config files, and entry points. +2. Identify the stack, runtime commands, major modules, API routes, data stores, and conventions. +3. Skip dependency folders, generated output, lock files, virtual environments, and secrets. +4. Save the final summary: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/save-project-memory.cjs" "SUMMARY_HERE" +``` + +Include important files and decisions, but do not save secrets. diff --git a/plugin/xmem-claude/commands/logout.md b/plugin/xmem-claude/commands/logout.md new file mode 100644 index 0000000..0fbbdc4 --- /dev/null +++ b/plugin/xmem-claude/commands/logout.md @@ -0,0 +1,16 @@ +--- +description: Remove local XMem Claude plugin project config +allowed-tools: ["Bash"] +--- + +# XMem Logout + +This plugin does not store credentials by default. It reads `XMEM_API_KEY` from the environment. + +To remove project-local config: + +```bash +node -e "const fs=require('fs'); const p='.claude/.xmem-claude/config.json'; if(fs.existsSync(p)){fs.rmSync(p); console.log('Removed '+p)}else{console.log('No project config found')}" +``` + +Also unset `XMEM_API_KEY` in your shell if you want to disconnect this terminal session. diff --git a/plugin/xmem-claude/commands/project-config.md b/plugin/xmem-claude/commands/project-config.md new file mode 100644 index 0000000..b09a00d --- /dev/null +++ b/plugin/xmem-claude/commands/project-config.md @@ -0,0 +1,25 @@ +--- +description: Show XMem Claude plugin configuration options +allowed-tools: ["Read", "Write", "Bash"] +--- + +# XMem Claude Configuration + +The plugin reads credentials from environment variables first: + +```bash +export XMEM_API_KEY="xmem_..." +export XMEM_API_URL="https://api.xmem.in" +export XMEM_USER_ID="your-user-id" +``` + +Optional project config lives at `.claude/.xmem-claude/config.json`: + +```json +{ + "apiUrl": "https://api.xmem.in", + "userId": "your-user-id" +} +``` + +Avoid storing API keys in project config. Prefer environment variables or your shell secret manager. diff --git a/plugin/xmem-claude/commands/session.md b/plugin/xmem-claude/commands/session.md new file mode 100644 index 0000000..a9db158 --- /dev/null +++ b/plugin/xmem-claude/commands/session.md @@ -0,0 +1,14 @@ +--- +description: Check whether XMem Claude memory is configured +allowed-tools: ["Bash"] +--- + +# XMem Session + +Check plugin configuration without printing secrets: + +```bash +node -e "console.log(process.env.XMEM_API_KEY ? 'XMEM_API_KEY is set' : 'XMEM_API_KEY is not set'); console.log('XMEM_API_URL=' + (process.env.XMEM_API_URL || 'https://api.xmem.in'))" +``` + +If the key is missing, set `XMEM_API_KEY` before starting Claude Code. diff --git a/plugin/xmem-claude/hooks/hooks.json b/plugin/xmem-claude/hooks/hooks.json new file mode 100644 index 0000000..3de0b72 --- /dev/null +++ b/plugin/xmem-claude/hooks/hooks.json @@ -0,0 +1,27 @@ +{ + "description": "XMem: Persistent memory for Claude Code", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/context-hook.cjs\"", + "timeout": 30 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/summary-hook.cjs\"", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/plugin/xmem-claude/package.json b/plugin/xmem-claude/package.json new file mode 100644 index 0000000..89c2590 --- /dev/null +++ b/plugin/xmem-claude/package.json @@ -0,0 +1,17 @@ +{ + "name": "xmem-claude", + "version": "0.1.0", + "description": "Persistent XMem memory for Claude Code sessions", + "private": true, + "type": "commonjs", + "engines": { + "node": ">=18.0.0" + }, + "keywords": [ + "claude-code", + "plugin", + "xmem", + "memory" + ], + "license": "MIT" +} diff --git a/plugin/xmem-claude/scripts/add-memory.cjs b/plugin/xmem-claude/scripts/add-memory.cjs new file mode 100644 index 0000000..f7e32d5 --- /dev/null +++ b/plugin/xmem-claude/scripts/add-memory.cjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +const { createClient } = require("./lib/xmem-client.cjs"); +const { projectName, redactSecrets, truncate } = require("./lib/plugin-utils.cjs"); + +async function main() { + const content = process.argv.slice(2).join(" ").trim(); + if (!content) { + console.log('Usage: node add-memory.cjs "content to save"'); + return; + } + + try { + const cwd = process.cwd(); + const client = createClient(cwd); + await client.ingest(truncate(redactSecrets(content)), { + source: "claude-code", + type: "manual", + project: projectName(cwd), + }); + console.log(`Saved memory to XMem for project: ${projectName(cwd)}`); + } catch (error) { + console.log(`XMem save failed: ${error.message}`); + } +} + +main().catch((error) => { + console.error(`Fatal error: ${error.message}`); + process.exit(1); +}); diff --git a/plugin/xmem-claude/scripts/context-hook.cjs b/plugin/xmem-claude/scripts/context-hook.cjs new file mode 100644 index 0000000..2e1bbbf --- /dev/null +++ b/plugin/xmem-claude/scripts/context-hook.cjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +const { createClient, formatResults } = require("./lib/xmem-client.cjs"); +const { projectName, readStdin, writeJson } = require("./lib/plugin-utils.cjs"); + +async function main() { + const input = await readStdin(); + const cwd = input.cwd || process.cwd(); + const project = projectName(cwd); + + try { + const client = createClient(cwd); + const data = await client.search(`Claude Code project context, architecture, decisions, conventions for ${project}`, 6); + const formatted = formatResults(data); + + writeJson({ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: `\n${formatted}\n`, + }, + }); + } catch (error) { + writeJson({ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: `\nXMem memory unavailable: ${error.message}\nSet XMEM_API_KEY to enable Claude memory.\n`, + }, + }); + } +} + +main().catch((error) => { + console.error(`XMem fatal: ${error.message}`); + process.exit(1); +}); diff --git a/plugin/xmem-claude/scripts/save-project-memory.cjs b/plugin/xmem-claude/scripts/save-project-memory.cjs new file mode 100644 index 0000000..2e5df5e --- /dev/null +++ b/plugin/xmem-claude/scripts/save-project-memory.cjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +const { createClient } = require("./lib/xmem-client.cjs"); +const { projectName, redactSecrets, truncate } = require("./lib/plugin-utils.cjs"); + +async function main() { + const content = process.argv.slice(2).join(" ").trim(); + if (!content) { + console.log('Usage: node save-project-memory.cjs "project knowledge to save"'); + return; + } + + try { + const cwd = process.cwd(); + const client = createClient(cwd); + await client.ingest(truncate(redactSecrets(content)), { + source: "claude-code", + type: "project-knowledge", + project: projectName(cwd), + }); + console.log(`Saved project memory to XMem for: ${projectName(cwd)}`); + } catch (error) { + console.log(`XMem project save failed: ${error.message}`); + } +} + +main().catch((error) => { + console.error(`Fatal error: ${error.message}`); + process.exit(1); +}); diff --git a/plugin/xmem-claude/scripts/search-memory.cjs b/plugin/xmem-claude/scripts/search-memory.cjs new file mode 100644 index 0000000..d017915 --- /dev/null +++ b/plugin/xmem-claude/scripts/search-memory.cjs @@ -0,0 +1,24 @@ +#!/usr/bin/env node +const { createClient, formatResults } = require("./lib/xmem-client.cjs"); +const { redactSecrets } = require("./lib/plugin-utils.cjs"); + +async function main() { + const query = process.argv.slice(2).join(" ").trim(); + if (!query) { + console.log('Usage: node search-memory.cjs "query"'); + return; + } + + try { + const client = createClient(process.cwd()); + const data = await client.search(redactSecrets(query), 10); + console.log(formatResults(data)); + } catch (error) { + console.log(`XMem search failed: ${error.message}`); + } +} + +main().catch((error) => { + console.error(`Fatal error: ${error.message}`); + process.exit(1); +}); diff --git a/plugin/xmem-claude/scripts/summary-hook.cjs b/plugin/xmem-claude/scripts/summary-hook.cjs new file mode 100644 index 0000000..9f29b14 --- /dev/null +++ b/plugin/xmem-claude/scripts/summary-hook.cjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +const { createClient } = require("./lib/xmem-client.cjs"); +const { projectName, readStdin, transcriptTail, writeJson } = require("./lib/plugin-utils.cjs"); + +async function main() { + const input = await readStdin(); + const cwd = input.cwd || process.cwd(); + const content = transcriptTail(input.transcript_path, input.session_id, cwd); + + if (!content) { + writeJson({ continue: true }); + return; + } + + try { + const client = createClient(cwd); + await client.ingest(content, { + source: "claude-code", + type: "session-summary", + project: projectName(cwd), + session_id: input.session_id || "", + }); + } catch (error) { + console.error(`XMem: ${error.message}`); + } + + writeJson({ continue: true }); +} + +main().catch((error) => { + console.error(`XMem fatal: ${error.message}`); + process.exit(1); +}); diff --git a/plugin/xmem-claude/skills/xmem-save/SKILL.md b/plugin/xmem-claude/skills/xmem-save/SKILL.md new file mode 100644 index 0000000..a59ebcc --- /dev/null +++ b/plugin/xmem-claude/skills/xmem-save/SKILL.md @@ -0,0 +1,28 @@ +--- +name: xmem-save +description: Save important project knowledge, decisions, conventions, bug fixes, or implementation context into XMem. +allowed-tools: Bash(node:*) +--- + +# XMem Save + +Use this skill when the user asks to remember or save something for future Claude Code sessions. + +Format the memory with useful context: + +```text +[SAVE:] +Project: +Decision or fact: +Relevant files: +Why it matters: +[/SAVE] +``` + +Then run: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/save-project-memory.cjs" "FORMATTED_CONTENT" +``` + +Never include API keys, tokens, passwords, or other secrets in the saved content. diff --git a/plugin/xmem-claude/skills/xmem-search/SKILL.md b/plugin/xmem-claude/skills/xmem-search/SKILL.md new file mode 100644 index 0000000..3bf13d8 --- /dev/null +++ b/plugin/xmem-claude/skills/xmem-search/SKILL.md @@ -0,0 +1,17 @@ +--- +name: xmem-search +description: Search XMem for prior Claude Code sessions, project decisions, implementation notes, and remembered coding context. +allowed-tools: Bash(node:*) +--- + +# XMem Search + +Use this skill when the user asks to recall previous work, earlier implementation details, project decisions, or saved coding memory. + +Run: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/search-memory.cjs" "USER_QUERY_HERE" +``` + +Summarize the returned memories clearly. If nothing useful is returned, ask a sharper follow-up question or try a more specific query. diff --git a/plugin/xmem-codex/.codex-plugin/plugin.json b/plugin/xmem-codex/.codex-plugin/plugin.json new file mode 100644 index 0000000..07f10d9 --- /dev/null +++ b/plugin/xmem-codex/.codex-plugin/plugin.json @@ -0,0 +1,32 @@ +{ + "name": "xmem-codex", + "version": "0.1.0", + "description": "Persistent XMem memory for Codex sessions", + "author": { + "name": "XMem", + "email": "support@xmem.in", + "url": "https://xmem.in" + }, + "homepage": "https://xmem.in", + "repository": "https://github.com/XortexAI/XMem", + "license": "MIT", + "keywords": ["codex", "xmem", "memory", "coding-agent"], + "skills": "./skills/", + "interface": { + "displayName": "XMem Codex", + "shortDescription": "Persistent memory for Codex sessions", + "longDescription": "Search and save project knowledge, decisions, and prior session context in XMem from Codex.", + "developerName": "XMem", + "category": "Productivity", + "capabilities": ["Memory", "Search", "Write"], + "websiteURL": "https://xmem.in", + "privacyPolicyURL": "https://xmem.in/privacy", + "termsOfServiceURL": "https://xmem.in/terms", + "defaultPrompt": [ + "Search XMem for this project", + "Save this decision to XMem", + "Check XMem Codex setup" + ], + "brandColor": "#0EA5E9" + } +} diff --git a/plugin/xmem-codex/README.md b/plugin/xmem-codex/README.md new file mode 100644 index 0000000..d1e0263 --- /dev/null +++ b/plugin/xmem-codex/README.md @@ -0,0 +1,33 @@ +# XMem Codex Plugin + +Codex plugin for searching and saving XMem memory from the main XMem repository. + +This is inspired by `codex-supermemory`, but intentionally kept repo-local and dependency-free for now. + +## Configuration + +Use environment variables. Do not commit secrets. + +```bash +export XMEM_API_KEY="xmem_..." +export XMEM_API_URL="https://api.xmem.in" +export XMEM_USER_ID="your-user-id" +``` + +`XMEM_API_URL` defaults to `https://api.xmem.in`. `XMEM_USER_ID` falls back to the local OS username. + +## Skills + +- `xmem-search` - search prior XMem memories +- `xmem-save` - save durable project knowledge +- `xmem-status` - verify environment configuration without printing secrets + +## Scripts + +```bash +node plugin/xmem-codex/scripts/search-memory.cjs "query" +node plugin/xmem-codex/scripts/save-memory.cjs "memory to save" +node plugin/xmem-codex/scripts/status.cjs +``` + +The scripts redact obvious API key patterns before saving content. diff --git a/plugin/xmem-codex/package.json b/plugin/xmem-codex/package.json new file mode 100644 index 0000000..e26126a --- /dev/null +++ b/plugin/xmem-codex/package.json @@ -0,0 +1,20 @@ +{ + "name": "xmem-codex", + "version": "0.1.0", + "description": "Persistent XMem memory for Codex sessions", + "private": true, + "type": "commonjs", + "engines": { + "node": ">=18.0.0" + }, + "keywords": [ + "codex", + "xmem", + "memory", + "coding-agent" + ], + "license": "MIT", + "scripts": { + "check": "node --check scripts/search-memory.cjs && node --check scripts/save-memory.cjs && node --check scripts/status.cjs" + } +} diff --git a/plugin/xmem-codex/scripts/save-memory.cjs b/plugin/xmem-codex/scripts/save-memory.cjs new file mode 100644 index 0000000..0eb1126 --- /dev/null +++ b/plugin/xmem-codex/scripts/save-memory.cjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +const path = require("node:path"); +const { saveMemory } = require("./lib/xmem-client.cjs"); + +function projectName() { + return path.basename(process.cwd()); +} + +async function main() { + const content = process.argv.slice(2).join(" ").trim(); + + if (!content) { + console.log('Usage: node scripts/save-memory.cjs "content to save"'); + return; + } + + try { + await saveMemory(content, { + source: "codex", + type: "manual", + project: projectName(), + }); + console.log(`Saved memory to XMem for project: ${projectName()}`); + } catch (error) { + console.log(`XMem save failed: ${error.message}`); + } +} + +main().catch((error) => { + console.error(`Fatal error: ${error.message}`); + process.exit(1); +}); diff --git a/plugin/xmem-codex/scripts/search-memory.cjs b/plugin/xmem-codex/scripts/search-memory.cjs new file mode 100644 index 0000000..ba6f636 --- /dev/null +++ b/plugin/xmem-codex/scripts/search-memory.cjs @@ -0,0 +1,24 @@ +#!/usr/bin/env node +const { formatResults, searchMemory } = require("./lib/xmem-client.cjs"); + +async function main() { + const args = process.argv.slice(2); + const query = args.join(" ").trim(); + + if (!query) { + console.log('Usage: node scripts/search-memory.cjs "query"'); + return; + } + + try { + const data = await searchMemory(query, 10); + console.log(formatResults(data)); + } catch (error) { + console.log(`XMem search failed: ${error.message}`); + } +} + +main().catch((error) => { + console.error(`Fatal error: ${error.message}`); + process.exit(1); +}); diff --git a/plugin/xmem-codex/scripts/status.cjs b/plugin/xmem-codex/scripts/status.cjs new file mode 100644 index 0000000..dcf2e5b --- /dev/null +++ b/plugin/xmem-codex/scripts/status.cjs @@ -0,0 +1,8 @@ +#!/usr/bin/env node +const { config, DEFAULT_API_URL } = require("./lib/xmem-client.cjs"); + +const cfg = config(); + +console.log(cfg.apiKey ? "XMEM_API_KEY is set" : "XMEM_API_KEY is not set"); +console.log(`XMEM_API_URL=${cfg.apiUrl || DEFAULT_API_URL}`); +console.log(`XMEM_USER_ID=${cfg.userId || "codex"}`); diff --git a/plugin/xmem-codex/skills/xmem-save/SKILL.md b/plugin/xmem-codex/skills/xmem-save/SKILL.md new file mode 100644 index 0000000..6e177f5 --- /dev/null +++ b/plugin/xmem-codex/skills/xmem-save/SKILL.md @@ -0,0 +1,28 @@ +--- +name: xmem-save +description: Save important project knowledge, decisions, conventions, bug fixes, or implementation context into XMem. +allowed-tools: Bash(node:*) +--- + +# XMem Save + +Use this skill when the user asks to remember or save durable project knowledge for future Codex sessions. + +Format the memory: + +```text +[SAVE:] +Project: +Decision or fact: +Relevant files: +Why it matters: +[/SAVE] +``` + +Then run: + +```bash +node plugin/xmem-codex/scripts/save-memory.cjs "FORMATTED_CONTENT" +``` + +Never save API keys, tokens, passwords, private customer data, or other secrets. diff --git a/plugin/xmem-codex/skills/xmem-search/SKILL.md b/plugin/xmem-codex/skills/xmem-search/SKILL.md new file mode 100644 index 0000000..981ac9d --- /dev/null +++ b/plugin/xmem-codex/skills/xmem-search/SKILL.md @@ -0,0 +1,17 @@ +--- +name: xmem-search +description: Search XMem for prior Codex sessions, project decisions, implementation notes, and saved coding context. +allowed-tools: Bash(node:*) +--- + +# XMem Search + +Use this skill when the user asks to recall prior work, implementation details, project decisions, or saved coding memory. + +Run: + +```bash +node plugin/xmem-codex/scripts/search-memory.cjs "USER_QUERY_HERE" +``` + +Present the returned memories clearly, including the most relevant details and any file paths mentioned. If the results are thin, try a more specific query before giving up. diff --git a/plugin/xmem-codex/skills/xmem-status/SKILL.md b/plugin/xmem-codex/skills/xmem-status/SKILL.md new file mode 100644 index 0000000..e71e55c --- /dev/null +++ b/plugin/xmem-codex/skills/xmem-status/SKILL.md @@ -0,0 +1,17 @@ +--- +name: xmem-status +description: Check whether the XMem Codex plugin has the environment variables needed to search and save memory. +allowed-tools: Bash(node:*) +--- + +# XMem Status + +Use this skill when XMem memory commands fail or the user asks whether the plugin is configured. + +Run: + +```bash +node plugin/xmem-codex/scripts/status.cjs +``` + +Do not print API key values. If `XMEM_API_KEY` is missing, ask the user to set it in their shell or secret manager. diff --git a/plugin/xmem-cursor/.cursor-plugin/plugin.json b/plugin/xmem-cursor/.cursor-plugin/plugin.json new file mode 100644 index 0000000..f1aed5f --- /dev/null +++ b/plugin/xmem-cursor/.cursor-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "name": "xmem-cursor", + "description": "Persistent AI memory for Cursor powered by XMem", + "version": "0.1.0", + "author": { + "name": "XMem", + "email": "support@xmem.in" + }, + "keywords": ["memory", "xmem", "ai", "mcp", "cursor"], + "rules": "rules", + "skills": "skills", + "commands": "commands", + "mcpServers": ".mcp.json" +} diff --git a/plugin/xmem-cursor/.cursor/mcp.json b/plugin/xmem-cursor/.cursor/mcp.json new file mode 100644 index 0000000..40d8f22 --- /dev/null +++ b/plugin/xmem-cursor/.cursor/mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "xmem": { + "command": "node", + "args": ["${CURSOR_PLUGIN_ROOT}/scripts/mcp-server.cjs"] + } + } +} diff --git a/plugin/xmem-cursor/.mcp.json b/plugin/xmem-cursor/.mcp.json new file mode 100644 index 0000000..40d8f22 --- /dev/null +++ b/plugin/xmem-cursor/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "xmem": { + "command": "node", + "args": ["${CURSOR_PLUGIN_ROOT}/scripts/mcp-server.cjs"] + } + } +} diff --git a/plugin/xmem-cursor/README.md b/plugin/xmem-cursor/README.md new file mode 100644 index 0000000..f4ca7bd --- /dev/null +++ b/plugin/xmem-cursor/README.md @@ -0,0 +1,37 @@ +# XMem Cursor Plugin + +Cursor plugin for persistent coding memory through XMem. + +This mirrors the strong structure of `cursor-supermemory`: + +- `.cursor-plugin/plugin.json` for plugin metadata +- `.mcp.json` and `.cursor/mcp.json` for MCP registration +- `rules/` with proactive memory guidance +- `skills/` for search, save, and setup +- `commands/` for setup/config/logout +- `hooks/` and small Node scripts for session lifecycle + +## Configuration + +Use environment variables. Do not commit secrets. + +```bash +export XMEM_API_KEY="xmem_..." +export XMEM_API_URL="https://api.xmem.in" +export XMEM_USER_ID="your-user-id" +``` + +`XMEM_API_URL` defaults to `https://api.xmem.in`. `XMEM_USER_ID` falls back to the local OS username. + +## MCP Tools + +- `xmem_status` - show safe config status +- `xmem_search` - search prior memory +- `xmem_add` - save project knowledge + +## Local Checks + +```bash +npm run check +node scripts/status.cjs +``` diff --git a/plugin/xmem-cursor/commands/xmem-config.md b/plugin/xmem-cursor/commands/xmem-config.md new file mode 100644 index 0000000..a601015 --- /dev/null +++ b/plugin/xmem-cursor/commands/xmem-config.md @@ -0,0 +1,19 @@ +--- +description: Show XMem Cursor configuration +--- + +# XMem Config + +The plugin reads: + +- `XMEM_API_KEY` or `XMEM_CURSOR_API_KEY` +- `XMEM_API_URL` or `XMEM_CURSOR_API_URL` (defaults to `https://api.xmem.in`) +- `XMEM_USER_ID` or `XMEM_CURSOR_USER_ID` (falls back to your OS username) + +Run: + +```bash +node "${CURSOR_PLUGIN_ROOT}/scripts/status.cjs" +``` + +The status command never prints the API key value. diff --git a/plugin/xmem-cursor/commands/xmem-logout.md b/plugin/xmem-cursor/commands/xmem-logout.md new file mode 100644 index 0000000..bf21bf0 --- /dev/null +++ b/plugin/xmem-cursor/commands/xmem-logout.md @@ -0,0 +1,21 @@ +--- +description: Disconnect XMem from the current Cursor shell +--- + +# XMem Logout + +This plugin does not store API keys by default. It reads them from environment variables. + +Unset the key in your shell to disconnect: + +```bash +unset XMEM_API_KEY +unset XMEM_CURSOR_API_KEY +``` + +On PowerShell: + +```powershell +Remove-Item Env:XMEM_API_KEY -ErrorAction SilentlyContinue +Remove-Item Env:XMEM_CURSOR_API_KEY -ErrorAction SilentlyContinue +``` diff --git a/plugin/xmem-cursor/commands/xmem-setup.md b/plugin/xmem-cursor/commands/xmem-setup.md new file mode 100644 index 0000000..70fd3dd --- /dev/null +++ b/plugin/xmem-cursor/commands/xmem-setup.md @@ -0,0 +1,21 @@ +--- +description: Configure XMem memory for Cursor +--- + +# XMem Setup + +Set the API key in your shell or secret manager before starting Cursor: + +```bash +export XMEM_API_KEY="xmem_..." +export XMEM_API_URL="https://api.xmem.in" +export XMEM_USER_ID="your-user-id" +``` + +Then verify: + +```bash +node "${CURSOR_PLUGIN_ROOT}/scripts/status.cjs" +``` + +Do not commit API keys to project files. diff --git a/plugin/xmem-cursor/hooks/hooks.json b/plugin/xmem-cursor/hooks/hooks.json new file mode 100644 index 0000000..122c25b --- /dev/null +++ b/plugin/xmem-cursor/hooks/hooks.json @@ -0,0 +1,18 @@ +{ + "hooks": { + "sessionStart": [ + { + "type": "command", + "command": "node \"${CURSOR_PLUGIN_ROOT}/scripts/session-start.cjs\"", + "timeout": 20 + } + ], + "sessionEnd": [ + { + "type": "command", + "command": "node \"${CURSOR_PLUGIN_ROOT}/scripts/session-end.cjs\"", + "timeout": 30 + } + ] + } +} diff --git a/plugin/xmem-cursor/package.json b/plugin/xmem-cursor/package.json new file mode 100644 index 0000000..444ffd7 --- /dev/null +++ b/plugin/xmem-cursor/package.json @@ -0,0 +1,15 @@ +{ + "name": "xmem-cursor", + "version": "0.1.0", + "description": "Persistent AI memory for Cursor powered by XMem", + "private": true, + "type": "commonjs", + "engines": { + "node": ">=18.0.0" + }, + "keywords": ["cursor", "xmem", "memory", "mcp"], + "license": "MIT", + "scripts": { + "check": "node --check scripts/lib/xmem-client.cjs && node --check scripts/mcp-server.cjs && node --check scripts/session-start.cjs && node --check scripts/session-end.cjs && node --check scripts/status.cjs" + } +} diff --git a/plugin/xmem-cursor/rules/xmem.mdc b/plugin/xmem-cursor/rules/xmem.mdc new file mode 100644 index 0000000..6168612 --- /dev/null +++ b/plugin/xmem-cursor/rules/xmem.mdc @@ -0,0 +1,20 @@ +--- +description: XMem persistent memory tools - use them proactively +alwaysApply: true +--- + +You have access to XMem MCP tools for persistent coding memory across Cursor sessions: + +- `xmem_search`: Search previous project memories, decisions, and solved bugs. +- `xmem_add`: Save important decisions, fixes, conventions, and implementation notes. +- `xmem_status`: Check whether XMem is configured without printing secrets. + +When to use: +- User mentions previous work, past sessions, "last time", or asks why something was done -> search first. +- User says "remember", "save this", or "don't forget" -> save immediately. +- After solving a significant bug -> save root cause and fix. +- When discovering architecture, commands, conventions, or integration details -> save concise project knowledge. + +Scope: +- Use project-specific language in saved content so future searches can find it. +- Never save API keys, tokens, passwords, private customer data, or other secrets. diff --git a/plugin/xmem-cursor/scripts/mcp-server.cjs b/plugin/xmem-cursor/scripts/mcp-server.cjs new file mode 100644 index 0000000..1d250e7 --- /dev/null +++ b/plugin/xmem-cursor/scripts/mcp-server.cjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +const { addMemory, config, formatResults, projectName, searchMemory } = require("./lib/xmem-client.cjs"); + +let buffer = ""; + +function send(id, result, error) { + const payload = error + ? { jsonrpc: "2.0", id, error: { code: -32000, message: error.message || String(error) } } + : { jsonrpc: "2.0", id, result }; + process.stdout.write(`${JSON.stringify(payload)}\n`); +} + +function tool(name, description, inputSchema) { + return { name, description, inputSchema }; +} + +const tools = [ + tool("xmem_status", "Show XMem Cursor configuration without printing secrets.", { + type: "object", + properties: {}, + }), + tool("xmem_search", "Search XMem memory for prior coding context, project decisions, and solved bugs.", { + type: "object", + properties: { + query: { type: "string", description: "Focused search query" }, + limit: { type: "number", description: "Maximum results", default: 10 }, + }, + required: ["query"], + }), + tool("xmem_add", "Save important project knowledge to XMem memory.", { + type: "object", + properties: { + content: { type: "string", description: "Memory content to save" }, + type: { type: "string", description: "Memory type, such as architecture or error-solution" }, + }, + required: ["content"], + }), +]; + +async function handle(message) { + const { id, method, params = {} } = message; + + try { + if (method === "initialize") { + send(id, { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "xmem", version: "0.1.0" }, + }); + return; + } + + if (method === "notifications/initialized") return; + + if (method === "tools/list") { + send(id, { tools }); + return; + } + + if (method === "tools/call") { + const name = params.name; + const args = params.arguments || {}; + + if (name === "xmem_status") { + const cfg = config(); + send(id, { + content: [ + { + type: "text", + text: JSON.stringify( + { + apiKeyConfigured: Boolean(cfg.apiKey), + apiUrl: cfg.apiUrl, + userId: cfg.userId, + project: projectName(), + }, + null, + 2, + ), + }, + ], + }); + return; + } + + if (name === "xmem_search") { + const data = await searchMemory(args.query, { limit: args.limit || 10 }); + send(id, { content: [{ type: "text", text: formatResults(data) }] }); + return; + } + + if (name === "xmem_add") { + await addMemory(args.content, { + source: "cursor", + type: args.type || "manual", + project: projectName(), + }); + send(id, { content: [{ type: "text", text: `Saved memory to XMem for project: ${projectName()}` }] }); + return; + } + + throw new Error(`Unknown tool: ${name}`); + } + + send(id, {}, new Error(`Unsupported method: ${method}`)); + } catch (error) { + send(id, null, error); + } +} + +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() || ""; + for (const line of lines) { + if (!line.trim()) continue; + try { + handle(JSON.parse(line)); + } catch (error) { + send(null, null, error); + } + } +}); diff --git a/plugin/xmem-cursor/scripts/session-end.cjs b/plugin/xmem-cursor/scripts/session-end.cjs new file mode 100644 index 0000000..8885796 --- /dev/null +++ b/plugin/xmem-cursor/scripts/session-end.cjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +const fs = require("node:fs"); +const { addMemory, projectName, redactSecrets, truncate } = require("./lib/xmem-client.cjs"); + +function transcriptFromEnv() { + const file = process.env.CURSOR_TRANSCRIPT_PATH || process.env.TRANSCRIPT_PATH || ""; + if (!file || !fs.existsSync(file)) return ""; + return truncate(redactSecrets(fs.readFileSync(file, "utf8"))); +} + +async function main() { + const transcript = transcriptFromEnv(); + if (!transcript) return; + + try { + await addMemory(`[Cursor session]\nProject: ${projectName()}\n\n${transcript}`, { + source: "cursor", + type: "session-summary", + project: projectName(), + }); + } catch (error) { + console.error(`XMem: ${error.message}`); + } +} + +main().catch((error) => { + console.error(`XMem fatal: ${error.message}`); + process.exit(1); +}); diff --git a/plugin/xmem-cursor/scripts/session-start.cjs b/plugin/xmem-cursor/scripts/session-start.cjs new file mode 100644 index 0000000..6afa360 --- /dev/null +++ b/plugin/xmem-cursor/scripts/session-start.cjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +const { formatResults, projectName, searchMemory } = require("./lib/xmem-client.cjs"); + +async function main() { + try { + const data = await searchMemory(`Cursor project context architecture decisions conventions for ${projectName()}`, { + limit: 6, + }); + console.log(`\n${formatResults(data)}\n`); + } catch (error) { + console.log(`\nXMem memory unavailable: ${error.message}\nSet XMEM_API_KEY to enable Cursor memory.\n`); + } +} + +main().catch((error) => { + console.error(`XMem fatal: ${error.message}`); + process.exit(1); +}); diff --git a/plugin/xmem-cursor/scripts/status.cjs b/plugin/xmem-cursor/scripts/status.cjs new file mode 100644 index 0000000..3cfae9c --- /dev/null +++ b/plugin/xmem-cursor/scripts/status.cjs @@ -0,0 +1,8 @@ +#!/usr/bin/env node +const { config, projectName } = require("./lib/xmem-client.cjs"); + +const cfg = config(); +console.log(cfg.apiKey ? "XMEM_API_KEY is set" : "XMEM_API_KEY is not set"); +console.log(`XMEM_API_URL=${cfg.apiUrl}`); +console.log(`XMEM_USER_ID=${cfg.userId}`); +console.log(`Project=${projectName()}`); diff --git a/plugin/xmem-cursor/skills/memory-init/SKILL.md b/plugin/xmem-cursor/skills/memory-init/SKILL.md new file mode 100644 index 0000000..d26b07d --- /dev/null +++ b/plugin/xmem-cursor/skills/memory-init/SKILL.md @@ -0,0 +1,8 @@ +--- +name: memory-init +description: Check or initialize XMem memory setup for Cursor. +--- + +1. Call `xmem_status`. +2. If `XMEM_API_KEY` is missing, ask the user to set it in their shell or secret manager. +3. If configured, explain that XMem search/add tools are ready. diff --git a/plugin/xmem-cursor/skills/memory-save/SKILL.md b/plugin/xmem-cursor/skills/memory-save/SKILL.md new file mode 100644 index 0000000..765cfe0 --- /dev/null +++ b/plugin/xmem-cursor/skills/memory-save/SKILL.md @@ -0,0 +1,10 @@ +--- +name: memory-save +description: Save important information to XMem persistent memory. Use when the user explicitly asks to remember something, or when you've solved a significant problem worth preserving. +--- + +1. Extract the key insight, decision, or solution to save. +2. Choose a type: preference, architecture, error-solution, project-config, learned-pattern, or conversation. +3. Call `xmem_add` with concise, searchable content. +4. Do not include secrets or raw credentials. +5. Confirm to the user that the memory was saved. diff --git a/plugin/xmem-cursor/skills/memory-search/SKILL.md b/plugin/xmem-cursor/skills/memory-search/SKILL.md new file mode 100644 index 0000000..5f096ab --- /dev/null +++ b/plugin/xmem-cursor/skills/memory-search/SKILL.md @@ -0,0 +1,9 @@ +--- +name: memory-search +description: Search XMem persistent memory for relevant information from past coding sessions. Use when the user asks about previous work, past bugs, architectural decisions, or anything that may have been worked on before. +--- + +1. Call `xmem_search` with a focused query based on what the user is asking. +2. If results are found, surface the relevant memories with enough context to be useful. +3. If no results are found, say that no prior XMem memory matched this topic. +4. For broad questions, try more specific project, file, or feature terms. diff --git a/plugin/xmem-hermes/README.md b/plugin/xmem-hermes/README.md new file mode 100644 index 0000000..aa6eec7 --- /dev/null +++ b/plugin/xmem-hermes/README.md @@ -0,0 +1,55 @@ +# xmem-hermes + +Hermes Agent MCP connector for XMem persistent memory. + +This package connects Hermes to XMem through the XMem MCP server. It writes local connector configuration and agent-facing memory instructions, while keeping XMem credentials in environment variables. + +## Install + +```bash +npx xmem-hermes@latest install +``` + +For local development: + +```bash +node src/cli.js install --config-root ./tmp/hermes +``` + +## Authentication + +Set credentials in your shell, OS secret store, or the client environment before launching Hermes: + +```bash +export XMEM_API_URL="https://api.xmem.in" +export XMEM_API_KEY="xmem_..." +export XMEM_USERNAME="your-xmem-username" +``` + +The installer writes `${XMEM_API_KEY}` as a placeholder instead of copying secret values into config files. + +## Commands + +| Command | Description | +| --- | --- | +| `install` | Write `.hermes/config.yaml` and `HERMES.md`. | +| `doctor` | Check whether generated connector files exist. | +| `smoke-test` | Verify XMem API access via environment variables without printing secrets. | + +## Smoke Test + +```bash +XMEM_API_KEY="xmem_..." XMEM_USERNAME="connector-test" npm run smoke +``` + +The smoke test calls XMem search with a low-risk read query. It never logs the API key. + +## Notes + +- Requires the XMem MCP server to be available as `uvx xmem-mcp`. +- Uses stdio transport by default for local agent clients. +- You can override the API URL with `--api-url` during install. + +## License + +Apache-2.0 diff --git a/plugin/xmem-hermes/package.json b/plugin/xmem-hermes/package.json new file mode 100644 index 0000000..3b65c95 --- /dev/null +++ b/plugin/xmem-hermes/package.json @@ -0,0 +1,30 @@ +{ + "name": "xmem-hermes", + "version": "1.0.0", + "description": "Hermes Agent MCP connector for XMem persistent memory.", + "type": "module", + "bin": { + "xmem-hermes": "./src/cli.js" + }, + "scripts": { + "build": "node --check src/cli.js", + "test": "node --test", + "smoke": "node src/cli.js smoke-test" + }, + "keywords": [ + "xmem", + "memory", + "hermes", + "mcp", + "connector" + ], + "author": "XMem", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/XortexAI/xmem-hermes.git" + }, + "engines": { + "node": ">=20" + } +} diff --git a/plugin/xmem-hermes/src/cli.js b/plugin/xmem-hermes/src/cli.js new file mode 100644 index 0000000..b7ce3fd --- /dev/null +++ b/plugin/xmem-hermes/src/cli.js @@ -0,0 +1,181 @@ +#!/usr/bin/env node +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +const CONNECTOR = { + dir: "xmem-hermes", + bin: "xmem-hermes", + id: "hermes", + display: "Hermes", + description: "Hermes Agent MCP connector for XMem persistent memory.", + defaultUserId: "hermes_user", + category: "hermes", + installTarget: ".hermes/config.yaml and HERMES.md", +}; +const SECRET_PLACEHOLDER = "${XMEM_API_KEY}"; + +function parseArgs(argv) { + const command = argv[0] || "help"; + const options = { + command, + dryRun: false, + apiUrl: process.env.XMEM_API_URL || "https://api.xmem.in", + }; + for (let i = 1; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--dry-run") options.dryRun = true; + else if (arg === "--config-root") options.configRoot = argv[++i]; + else if (arg === "--api-url") options.apiUrl = argv[++i]; + else if (arg === "--mcp-command") options.mcpCommand = argv[++i]; + else if (arg === "--help" || arg === "-h") options.command = "help"; + } + return options; +} + +function configRoot(options) { + return options.configRoot || homedir(); +} + +function ensureParent(filePath) { + mkdirSync(dirname(filePath), { recursive: true }); +} + +function write(filePath, content, dryRun) { + if (dryRun) { + console.log("[dry-run] would write " + filePath); + return; + } + ensureParent(filePath); + writeFileSync(filePath, content, "utf8"); + console.log("wrote " + filePath); +} + +function mcpServer(options) { + const parts = (options.mcpCommand || "uvx xmem-mcp").split(/\s+/).filter(Boolean); + return { + command: parts[0], + args: parts.slice(1), + env: { + TRANSPORT: "stdio", + XMEM_API_URL: options.apiUrl, + XMEM_API_KEY: SECRET_PLACEHOLDER, + XMEM_USERNAME: "${XMEM_USERNAME}", + DEFAULT_USER_ID: CONNECTOR.defaultUserId, + }, + }; +} + +function yamlConfig(options) { + const server = mcpServer(options); + return [ + "mcp_servers:", + " xmem:", + " command: " + server.command, + " args:", + ...server.args.map((arg) => " - " + arg), + " env:", + " TRANSPORT: stdio", + " XMEM_API_URL: " + options.apiUrl, + " XMEM_API_KEY: " + SECRET_PLACEHOLDER, + " XMEM_USERNAME: ${XMEM_USERNAME}", + " DEFAULT_USER_ID: " + CONNECTOR.defaultUserId, + "", + ].join("\n"); +} + +function memoryInstructions() { + return [ + "# XMem memory", + "", + "Use the xmem MCP tools when the user asks you to remember, recall, search, or connect project context across sessions.", + "", + "- Save durable user preferences in user scope.", + "- Save repository workflows, architecture, and decisions in project scope.", + "- Search or retrieve before answering questions that depend on prior sessions.", + "- Never store secrets, API keys, tokens, private keys, or credential material.", + "", + ].join("\n"); +} + +function install(options) { + const root = configRoot(options); + write(join(root, ".hermes", "config.yaml"), yamlConfig(options), options.dryRun); + write(join(root, "HERMES.md"), memoryInstructions(), options.dryRun); + console.log(CONNECTOR.display + " connector install complete."); + console.log("Keep XMEM_API_KEY in your environment; it was not copied into generated files."); +} + +function doctor(options) { + const root = configRoot(options); + const expected = [".hermes/config.yaml", "HERMES.md"]; + let ok = true; + for (const file of expected) { + const filePath = join(root, file); + const present = existsSync(filePath); + ok = ok && present; + console.log((present ? "ok " : "missing ") + file); + if (present) { + const content = readFileSync(filePath, "utf8"); + if (process.env.XMEM_API_KEY && content.includes(process.env.XMEM_API_KEY)) { + console.error("secret value found in " + file); + process.exitCode = 1; + } + } + } + if (!ok) process.exitCode = 1; +} + +async function smokeTest() { + const apiKey = process.env.XMEM_API_KEY; + const apiUrl = (process.env.XMEM_API_URL || "https://api.xmem.in").replace(/\/$/, ""); + const username = process.env.XMEM_USERNAME || CONNECTOR.defaultUserId; + if (!apiKey) { + console.error("XMEM_API_KEY is required for smoke-test."); + process.exit(1); + } + const response = await fetch(apiUrl + "/v1/memory/search", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + apiKey, + "X-XMem-Username": username, + }, + body: JSON.stringify({ + query: "xmem connector smoke test", + user_id: CONNECTOR.defaultUserId, + top_k: 1, + }), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok || body.status === "error") { + console.error("XMem smoke test failed: HTTP " + response.status); + process.exit(1); + } + const data = body.data || body; + const count = Array.isArray(data.results) ? data.results.length : 0; + console.log("XMem smoke test ok for " + CONNECTOR.display + " (" + count + " result(s))."); +} + +function help() { + console.log(` +${CONNECTOR.bin} - XMem connector for ${CONNECTOR.display} + +Commands: + install Write connector config files + doctor Check generated files + smoke-test Verify XMem API access from environment + +Options: + --config-root Write config under a custom root + --api-url XMem API URL + --mcp-command MCP launch command, default: uvx xmem-mcp + --dry-run Print intended writes +`); +} + +const options = parseArgs(process.argv.slice(2)); +if (options.command === "install") install(options); +else if (options.command === "doctor") doctor(options); +else if (options.command === "smoke-test") await smokeTest(); +else help(); diff --git a/plugin/xmem-hermes/test/cli.test.js b/plugin/xmem-hermes/test/cli.test.js new file mode 100644 index 0000000..c780bea --- /dev/null +++ b/plugin/xmem-hermes/test/cli.test.js @@ -0,0 +1,37 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import assert from "node:assert/strict"; + +test("installer writes config without copying secret values", () => { + const root = mkdtempSync(join(tmpdir(), "xmem-hermes-")); + const secret = "test_secret_should_not_be_written"; + try { + const result = spawnSync(process.execPath, ["src/cli.js", "install", "--config-root", root], { + cwd: process.cwd(), + env: { ...process.env, XMEM_API_KEY: secret }, + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + + const doctor = spawnSync(process.execPath, ["src/cli.js", "doctor", "--config-root", root], { + cwd: process.cwd(), + env: { ...process.env, XMEM_API_KEY: secret }, + encoding: "utf8", + }); + assert.equal(doctor.status, 0, doctor.stderr); + + for (const file of [".hermes/config.yaml", "HERMES.md"]) { + const content = readFileSync(join(root, file), "utf8"); + assert.ok(!content.includes(secret), file + " leaked secret"); + assert.ok( + content.includes("XMem") || content.includes("xmem") || content.includes("XMEM"), + file + " should mention XMem", + ); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/plugin/xmem-openclaw/README.md b/plugin/xmem-openclaw/README.md new file mode 100644 index 0000000..b9ee7da --- /dev/null +++ b/plugin/xmem-openclaw/README.md @@ -0,0 +1,51 @@ +# XMem OpenClaw Plugin + +Long-term memory for OpenClaw powered by XMem. + +This mirrors `openclaw-supermemory`'s shape: a root `openclaw.plugin.json`, OpenClaw runtime entrypoint, tools, hooks, slash commands, config parsing, and memory runtime integration. + +## Setup + +Use environment variables or plugin config. Do not commit secrets. + +```bash +export XMEM_API_KEY="xmem_..." +export XMEM_API_URL="https://api.xmem.in" +export XMEM_USER_ID="your-user-id" +``` + +## Tools + +- `xmem_search` - search long-term XMem memory +- `xmem_store` - save important information to XMem +- `xmem_status` - show safe connection status without printing secrets + +## Slash Commands + +- `/remember ` - manually save something to XMem +- `/recall ` - search XMem memories +- `/xmem-status` - show safe plugin status + +## Configuration + +```json +{ + "plugins": { + "entries": { + "xmem-openclaw": { + "enabled": true, + "config": { + "apiKey": "${XMEM_API_KEY}", + "apiUrl": "https://api.xmem.in", + "autoRecall": true, + "autoCapture": true, + "maxRecallResults": 8, + "debug": false + } + } + } + } +} +``` + +Prefer environment variables or a secret manager for API keys. diff --git a/plugin/xmem-openclaw/client.ts b/plugin/xmem-openclaw/client.ts new file mode 100644 index 0000000..cd68600 --- /dev/null +++ b/plugin/xmem-openclaw/client.ts @@ -0,0 +1,70 @@ +import type { XMemOpenClawConfig } from "./config.ts" +import { redactSecrets, truncate } from "./memory.ts" + +export type XMemSearchResult = { + domain?: string + content?: string + score?: number + metadata?: Record +} + +export class XMemClient { + constructor(private cfg: XMemOpenClawConfig) {} + + status() { + return { + apiKeyConfigured: Boolean(this.cfg.apiKey), + apiUrl: this.cfg.apiUrl, + userId: this.cfg.userId, + } + } + + private async request(pathname: string, payload: Record) { + if (!this.cfg.apiKey) { + throw new Error("XMEM_API_KEY is not configured.") + } + + const response = await fetch(`${this.cfg.apiUrl}${pathname}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.cfg.apiKey}`, + }, + body: JSON.stringify(payload), + }) + + let body: any + try { + body = await response.json() + } catch { + body = { error: await response.text() } + } + + if (!response.ok || body?.status === "error") { + throw new Error(body?.error || body?.detail || `XMem request failed with HTTP ${response.status}`) + } + + return body?.data ?? body + } + + async search(query: string, limit = 8): Promise { + const data = await this.request("/v1/memory/search", { + query: redactSecrets(query), + user_id: this.cfg.userId, + top_k: limit, + domains: ["profile", "temporal", "summary"], + }) + return data?.results || [] + } + + async addMemory(text: string, metadata: Record = {}) { + return this.request("/v1/memory/ingest", { + user_query: truncate(redactSecrets(text)), + agent_response: "", + user_id: this.cfg.userId, + session_datetime: new Date().toISOString(), + effort_level: "low", + metadata, + }) + } +} diff --git a/plugin/xmem-openclaw/commands/cli.ts b/plugin/xmem-openclaw/commands/cli.ts new file mode 100644 index 0000000..5f17787 --- /dev/null +++ b/plugin/xmem-openclaw/commands/cli.ts @@ -0,0 +1,14 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk" +import type { XMemClient } from "../client.ts" + +export function registerCli(api: OpenClawPluginApi, client?: XMemClient): void { + api.registerCommand?.({ + name: "xmem-status", + description: "Show XMem memory plugin status", + acceptsArgs: false, + requireAuth: false, + handler: async () => ({ + text: client ? JSON.stringify(client.status(), null, 2) : "XMem is not configured. Set XMEM_API_KEY.", + }), + }) +} diff --git a/plugin/xmem-openclaw/commands/slash.ts b/plugin/xmem-openclaw/commands/slash.ts new file mode 100644 index 0000000..ace7d88 --- /dev/null +++ b/plugin/xmem-openclaw/commands/slash.ts @@ -0,0 +1,49 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk" +import type { XMemClient } from "../client.ts" +import { detectCategory } from "../memory.ts" + +export function registerStubCommands(api: OpenClawPluginApi): void { + api.registerCommand({ + name: "remember", + description: "Save something to XMem", + acceptsArgs: true, + requireAuth: true, + handler: async () => ({ text: "XMem is not configured. Set XMEM_API_KEY or configure the plugin API key." }), + }) + api.registerCommand({ + name: "recall", + description: "Search XMem memories", + acceptsArgs: true, + requireAuth: true, + handler: async () => ({ text: "XMem is not configured. Set XMEM_API_KEY or configure the plugin API key." }), + }) +} + +export function registerCommands(api: OpenClawPluginApi, client: XMemClient): void { + api.registerCommand({ + name: "remember", + description: "Save something to XMem", + acceptsArgs: true, + requireAuth: true, + handler: async (ctx: { args?: string }) => { + const text = ctx.args?.trim() + if (!text) return { text: "Usage: /remember " } + await client.addMemory(text, { type: detectCategory(text), source: "openclaw_command" }) + const preview = text.length > 60 ? `${text.slice(0, 60)}...` : text + return { text: `Remembered in XMem: "${preview}"` } + }, + }) + api.registerCommand({ + name: "recall", + description: "Search XMem memories", + acceptsArgs: true, + requireAuth: true, + handler: async (ctx: { args?: string }) => { + const query = ctx.args?.trim() + if (!query) return { text: "Usage: /recall " } + const results = await client.search(query, 8) + if (!results.length) return { text: `No XMem memories found for: "${query}"` } + return { text: `Found ${results.length} XMem memories:\n\n${results.map((r, i) => `${i + 1}. ${r.content || ""}`).join("\n")}` } + }, + }) +} diff --git a/plugin/xmem-openclaw/config.ts b/plugin/xmem-openclaw/config.ts new file mode 100644 index 0000000..07fc5fb --- /dev/null +++ b/plugin/xmem-openclaw/config.ts @@ -0,0 +1,78 @@ +import os from "node:os" + +export type XMemOpenClawConfig = { + apiKey: string + apiUrl: string + userId: string + autoRecall: boolean + autoCapture: boolean + maxRecallResults: number + debug: boolean +} + +const DEFAULT_API_URL = "https://api.xmem.in" +const ALLOWED_KEYS = [ + "apiKey", + "apiUrl", + "userId", + "autoRecall", + "autoCapture", + "maxRecallResults", + "debug", +] + +function assertAllowedKeys(value: Record): void { + const unknown = Object.keys(value).filter((key) => !ALLOWED_KEYS.includes(key)) + if (unknown.length > 0) { + throw new Error(`xmem-openclaw config has unknown keys: ${unknown.join(", ")}`) + } +} + +function resolveEnvVars(value: string): string { + return value.replace(/\$\{([^}]+)\}/g, (_, envVar: string) => process.env[envVar] || "") +} + +function safeUsername(): string { + try { + return os.userInfo().username || "openclaw" + } catch { + return "openclaw" + } +} + +export function parseConfig(raw: Record = {}): XMemOpenClawConfig { + if (raw && typeof raw === "object" && Object.keys(raw).length > 0) { + assertAllowedKeys(raw) + } + + const envApiKey = process.env.XMEM_API_KEY || process.env.XMEM_OPENCLAW_API_KEY || "" + const envApiUrl = process.env.XMEM_API_URL || process.env.XMEM_OPENCLAW_API_URL || "" + const envUserId = process.env.XMEM_USER_ID || process.env.XMEM_OPENCLAW_USER_ID || "" + + return { + apiKey: resolveEnvVars(String(raw.apiKey || envApiKey || "")), + apiUrl: resolveEnvVars(String(raw.apiUrl || envApiUrl || DEFAULT_API_URL)).replace(/\/+$/, ""), + userId: resolveEnvVars(String(raw.userId || envUserId || safeUsername())), + autoRecall: raw.autoRecall !== false, + autoCapture: raw.autoCapture !== false, + maxRecallResults: Number(raw.maxRecallResults || 8), + debug: Boolean(raw.debug), + } +} + +export const xmemOpenClawConfigSchema = { + jsonSchema: { + type: "object", + additionalProperties: false, + properties: { + apiKey: { type: "string" }, + apiUrl: { type: "string" }, + userId: { type: "string" }, + autoRecall: { type: "boolean" }, + autoCapture: { type: "boolean" }, + maxRecallResults: { type: "number", minimum: 1, maximum: 20 }, + debug: { type: "boolean" }, + }, + }, + parse: parseConfig, +} diff --git a/plugin/xmem-openclaw/hooks/capture.ts b/plugin/xmem-openclaw/hooks/capture.ts new file mode 100644 index 0000000..42a0ac0 --- /dev/null +++ b/plugin/xmem-openclaw/hooks/capture.ts @@ -0,0 +1,13 @@ +import type { XMemClient } from "../client.ts" +import { detectCategory } from "../memory.ts" + +export function buildCaptureHandler(client: XMemClient) { + return async (event: Record) => { + const text = String(event.text || event.output || event.message || "") + if (text.trim().length < 80) return + await client.addMemory(text, { + type: detectCategory(text), + source: "openclaw_auto_capture", + }) + } +} diff --git a/plugin/xmem-openclaw/hooks/recall.ts b/plugin/xmem-openclaw/hooks/recall.ts new file mode 100644 index 0000000..87df914 --- /dev/null +++ b/plugin/xmem-openclaw/hooks/recall.ts @@ -0,0 +1,14 @@ +import type { XMemClient } from "../client.ts" +import type { XMemOpenClawConfig } from "../config.ts" + +export function buildRecallHandler(client: XMemClient, cfg: XMemOpenClawConfig) { + return async (event: Record) => { + const prompt = String(event.prompt || event.input || event.message || "") + if (!prompt.trim()) return + const results = await client.search(prompt, cfg.maxRecallResults) + if (!results.length) return + return { + additionalContext: `\n${results.map((r, i) => `${i + 1}. ${r.content || ""}`).join("\n\n")}\n`, + } + } +} diff --git a/plugin/xmem-openclaw/index.ts b/plugin/xmem-openclaw/index.ts new file mode 100644 index 0000000..5902974 --- /dev/null +++ b/plugin/xmem-openclaw/index.ts @@ -0,0 +1,78 @@ +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import type { OpenClawPluginApi } from "openclaw/plugin-sdk" +import { XMemClient } from "./client.ts" +import { registerCli } from "./commands/cli.ts" +import { registerCommands, registerStubCommands } from "./commands/slash.ts" +import { parseConfig, xmemOpenClawConfigSchema } from "./config.ts" +import { buildCaptureHandler } from "./hooks/capture.ts" +import { buildRecallHandler } from "./hooks/recall.ts" +import { initLogger } from "./logger.ts" +import { buildMemoryRuntime, buildPromptSection } from "./runtime.ts" +import { registerSearchTool } from "./tools/search.ts" +import { registerStatusTool } from "./tools/status.ts" +import { registerStoreTool } from "./tools/store.ts" + +try { + const stateDir = process.env.OPENCLAW_STATE_DIR || path.join(os.homedir(), ".openclaw") + const storePath = path.join(stateDir, "memory", "main.sqlite") + if (!fs.existsSync(storePath)) { + fs.mkdirSync(path.dirname(storePath), { recursive: true }) + fs.writeFileSync(storePath, "") + } +} catch {} + +export default { + id: "xmem-openclaw", + name: "XMem", + description: "OpenClaw powered by XMem memory", + kind: "memory" as const, + configSchema: xmemOpenClawConfigSchema, + + register(api: OpenClawPluginApi) { + const cfg = parseConfig(api.pluginConfig) + initLogger(api.logger, cfg.debug) + + if (!cfg.apiKey) { + registerCli(api) + api.logger.info("xmem: not configured - set XMEM_API_KEY or plugin apiKey") + registerStubCommands(api) + return + } + + const client = new XMemClient(cfg) + registerCli(api, client) + + const runtime = buildMemoryRuntime(client) + if (typeof api.registerMemoryCapability === "function") { + api.registerMemoryCapability({ + runtime, + promptBuilder: buildPromptSection, + flushPlanResolver: () => null, + }) + } else { + api.registerMemoryRuntime?.(runtime) + api.registerMemoryPromptSection?.(buildPromptSection) + api.registerMemoryFlushPlan?.(() => null) + } + + registerSearchTool(api, client) + registerStoreTool(api, client) + registerStatusTool(api, client) + registerSearchTool(api, client, "xmem-search") + registerStoreTool(api, client, "xmem-save") + registerStatusTool(api, client, "xmem-status") + + if (cfg.autoRecall) api.on("before_prompt_build", buildRecallHandler(client, cfg)) + if (cfg.autoCapture) api.on("agent_end", buildCaptureHandler(client)) + + registerCommands(api, client) + + api.registerService({ + id: "xmem-openclaw", + start: () => api.logger.info("xmem: connected"), + stop: () => api.logger.info("xmem: stopped"), + }) + }, +} diff --git a/plugin/xmem-openclaw/logger.ts b/plugin/xmem-openclaw/logger.ts new file mode 100644 index 0000000..09995d4 --- /dev/null +++ b/plugin/xmem-openclaw/logger.ts @@ -0,0 +1,22 @@ +let debugEnabled = false +let pluginLogger: { debug?: (...args: unknown[]) => void; info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void } | undefined + +export function initLogger(logger: typeof pluginLogger, debug: boolean): void { + pluginLogger = logger + debugEnabled = debug +} + +export const log = { + debug(...args: unknown[]) { + if (debugEnabled) pluginLogger?.debug?.(...args) + }, + info(...args: unknown[]) { + pluginLogger?.info?.(...args) + }, + warn(...args: unknown[]) { + pluginLogger?.warn?.(...args) + }, + error(...args: unknown[]) { + pluginLogger?.error?.(...args) + }, +} diff --git a/plugin/xmem-openclaw/memory.ts b/plugin/xmem-openclaw/memory.ts new file mode 100644 index 0000000..6741497 --- /dev/null +++ b/plugin/xmem-openclaw/memory.ts @@ -0,0 +1,33 @@ +export const MEMORY_CATEGORIES = [ + "preference", + "architecture", + "error-solution", + "project-config", + "learned-pattern", + "conversation", +] as const + +export type MemoryCategory = (typeof MEMORY_CATEGORIES)[number] + +export function detectCategory(text: string): MemoryCategory { + const value = text.toLowerCase() + if (/(prefer|preference|style|always|never)/.test(value)) return "preference" + if (/(architecture|design|pattern|module|service|api)/.test(value)) return "architecture" + if (/(bug|error|fix|failure|root cause|regression)/.test(value)) return "error-solution" + if (/(config|env|setting|secret|deploy|command)/.test(value)) return "project-config" + if (/(learned|lesson|note|remember)/.test(value)) return "learned-pattern" + return "conversation" +} + +export function redactSecrets(text: string): string { + return String(text || "") + .replace(/xmem_[A-Za-z0-9_-]{12,}/g, "[redacted-xmem-key]") + .replace(/sk-[A-Za-z0-9_-]{16,}/g, "[redacted-api-key]") + .replace(/(api[_-]?key|authorization|bearer|token)(\s*[:=]\s*)([^\s"'`]+)/gi, "$1$2[redacted]") +} + +export function truncate(text: string, limit = 12000): string { + const value = String(text || "").trim() + if (value.length <= limit) return value + return `${value.slice(0, limit)}\n\n[truncated]` +} diff --git a/plugin/xmem-openclaw/openclaw.plugin.json b/plugin/xmem-openclaw/openclaw.plugin.json new file mode 100644 index 0000000..ab93a00 --- /dev/null +++ b/plugin/xmem-openclaw/openclaw.plugin.json @@ -0,0 +1,64 @@ +{ + "id": "xmem-openclaw", + "kind": "memory", + "contracts": { + "tools": [ + "xmem_search", + "xmem_store", + "xmem_status" + ] + }, + "uiHints": { + "apiKey": { + "label": "XMem API Key", + "sensitive": true, + "placeholder": "xmem_...", + "help": "Your XMem API key from the dashboard, or use ${XMEM_API_KEY}" + }, + "apiUrl": { + "label": "XMem API URL", + "placeholder": "https://api.xmem.in", + "help": "XMem API base URL", + "advanced": true + }, + "userId": { + "label": "User ID", + "placeholder": "openclaw", + "help": "Optional user id for local/static-key setups", + "advanced": true + }, + "autoRecall": { + "label": "Auto-Recall", + "help": "Inject relevant memories before AI turns" + }, + "autoCapture": { + "label": "Auto-Capture", + "help": "Store useful conversation turns after AI responses" + }, + "maxRecallResults": { + "label": "Max Recall Results", + "placeholder": "8", + "help": "Maximum memories to inject per turn", + "advanced": true + }, + "debug": { + "label": "Debug Logging", + "help": "Enable verbose plugin logging", + "advanced": true + } + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "apiKey": { "type": "string" }, + "apiUrl": { "type": "string" }, + "userId": { "type": "string" }, + "autoRecall": { "type": "boolean" }, + "autoCapture": { "type": "boolean" }, + "maxRecallResults": { "type": "number", "minimum": 1, "maximum": 20 }, + "debug": { "type": "boolean" } + }, + "required": [] + } +} diff --git a/plugin/xmem-openclaw/package.json b/plugin/xmem-openclaw/package.json new file mode 100644 index 0000000..cd27a9a --- /dev/null +++ b/plugin/xmem-openclaw/package.json @@ -0,0 +1,39 @@ +{ + "name": "@xmem/openclaw", + "version": "0.1.0", + "type": "module", + "description": "OpenClaw XMem memory plugin", + "license": "MIT", + "files": [ + "README.md", + "openclaw.plugin.json", + "index.ts", + "client.ts", + "commands", + "hooks", + "tools", + "config.ts", + "logger.ts", + "memory.ts", + "runtime.ts", + "types" + ], + "scripts": { + "check-json": "node -e \"JSON.parse(require('fs').readFileSync('openclaw.plugin.json','utf8')); JSON.parse(require('fs').readFileSync('package.json','utf8')); console.log('json ok')\"" + }, + "peerDependencies": { + "openclaw": ">=2026.5.7" + }, + "dependencies": { + "@sinclair/typebox": "0.34.47" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ], + "compat": { + "pluginApi": ">=2026.5.7", + "minGatewayVersion": "2026.5.7" + } + } +} diff --git a/plugin/xmem-openclaw/runtime.ts b/plugin/xmem-openclaw/runtime.ts new file mode 100644 index 0000000..84bfbc1 --- /dev/null +++ b/plugin/xmem-openclaw/runtime.ts @@ -0,0 +1,68 @@ +import type { XMemClient } from "./client.ts" + +type MemoryProviderStatus = { + backend: "builtin" + provider: string + model?: string + files?: number + chunks?: number + custom?: Record +} + +type RegisteredMemorySearchManager = { + status(): MemoryProviderStatus + probeEmbeddingAvailability(): Promise<{ ok: boolean; error?: string }> + probeVectorAvailability(): Promise + sync?(): Promise + close?(): Promise +} + +export function buildMemoryRuntime(client: XMemClient) { + const manager: RegisteredMemorySearchManager = { + status() { + return { + backend: "builtin", + provider: "xmem", + model: "xmem-remote", + files: 0, + chunks: 0, + custom: client.status(), + } + }, + async probeEmbeddingAvailability() { + try { + await client.search("connection probe", 1) + return { ok: true } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "XMem unavailable" } + } + }, + async probeVectorAvailability() { + return true + }, + async sync() {}, + async close() {}, + } + + return { + async getMemorySearchManager() { + return { manager } + }, + resolveMemoryBackendConfig() { + return { backend: "builtin" as const } + }, + } +} + +export function buildPromptSection(params: { availableTools: Set }): string[] { + const hasSearch = params.availableTools.has("xmem_search") + const hasStore = params.availableTools.has("xmem_store") + if (!hasSearch && !hasStore) return [] + return [ + "## Memory (XMem)", + "", + "Memory is managed by XMem remote APIs. Do not store secrets in memory.", + hasSearch ? "Use xmem_search to look up prior project context, decisions, and solved bugs." : "", + hasStore ? "Use xmem_store when the user asks you to remember something important." : "", + ].filter(Boolean) +} diff --git a/plugin/xmem-openclaw/tools/search.ts b/plugin/xmem-openclaw/tools/search.ts new file mode 100644 index 0000000..a3eb42f --- /dev/null +++ b/plugin/xmem-openclaw/tools/search.ts @@ -0,0 +1,34 @@ +import { Type } from "@sinclair/typebox" +import type { OpenClawPluginApi } from "openclaw/plugin-sdk" +import type { XMemClient } from "../client.ts" + +export function registerSearchTool(api: OpenClawPluginApi, client: XMemClient, toolName = "xmem_search"): void { + api.registerTool( + { + name: toolName, + label: "XMem Search", + description: "Search XMem long-term memories for relevant coding context.", + parameters: Type.Object({ + query: Type.String({ description: "Search query" }), + limit: Type.Optional(Type.Number({ description: "Max results (default: 8)" })), + }), + async execute(_toolCallId: string, params: { query: string; limit?: number }) { + const results = await client.search(params.query, params.limit ?? 8) + if (results.length === 0) { + return { content: [{ type: "text" as const, text: "No relevant XMem memories found." }] } + } + const text = results + .map((r, i) => { + const score = typeof r.score === "number" ? ` (${Math.round(r.score * 100)}%)` : "" + return `${i + 1}. ${r.content || ""}${score}` + }) + .join("\n") + return { + content: [{ type: "text" as const, text: `Found ${results.length} XMem memories:\n\n${text}` }], + details: { count: results.length, memories: results }, + } + }, + }, + { name: toolName }, + ) +} diff --git a/plugin/xmem-openclaw/tools/status.ts b/plugin/xmem-openclaw/tools/status.ts new file mode 100644 index 0000000..e717393 --- /dev/null +++ b/plugin/xmem-openclaw/tools/status.ts @@ -0,0 +1,20 @@ +import { Type } from "@sinclair/typebox" +import type { OpenClawPluginApi } from "openclaw/plugin-sdk" +import type { XMemClient } from "../client.ts" + +export function registerStatusTool(api: OpenClawPluginApi, client: XMemClient, toolName = "xmem_status"): void { + api.registerTool( + { + name: toolName, + label: "XMem Status", + description: "Show XMem memory plugin status without printing secrets.", + parameters: Type.Object({}), + async execute() { + return { + content: [{ type: "text" as const, text: JSON.stringify(client.status(), null, 2) }], + } + }, + }, + { name: toolName }, + ) +} diff --git a/plugin/xmem-openclaw/tools/store.ts b/plugin/xmem-openclaw/tools/store.ts new file mode 100644 index 0000000..3376046 --- /dev/null +++ b/plugin/xmem-openclaw/tools/store.ts @@ -0,0 +1,28 @@ +import { Type } from "@sinclair/typebox" +import type { OpenClawPluginApi } from "openclaw/plugin-sdk" +import type { XMemClient } from "../client.ts" +import { detectCategory, MEMORY_CATEGORIES } from "../memory.ts" + +export function registerStoreTool(api: OpenClawPluginApi, client: XMemClient, toolName = "xmem_store"): void { + api.registerTool( + { + name: toolName, + label: "XMem Store", + description: "Save important information to XMem long-term memory.", + parameters: Type.Object({ + text: Type.String({ description: "Information to remember" }), + category: Type.Optional(Type.Unsafe({ type: "string", enum: [...MEMORY_CATEGORIES] })), + }), + async execute(_toolCallId: string, params: { text: string; category?: string }) { + const category = params.category ?? detectCategory(params.text) + await client.addMemory(params.text, { + type: category, + source: "openclaw_tool", + }) + const preview = params.text.length > 80 ? `${params.text.slice(0, 80)}...` : params.text + return { content: [{ type: "text" as const, text: `Stored in XMem: "${preview}"` }] } + }, + }, + { name: toolName }, + ) +} From 16f94d1fd2aa65bd958f72c970e7e0d1d71185bc Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Sat, 30 May 2026 14:16:34 +0530 Subject: [PATCH 2/9] Add XMem OpenCode plugin bundle --- .../.github/workflows/release.yml | 59 ++ plugin/xmem-opencode/.gitignore | 34 ++ plugin/xmem-opencode/README.md | 128 +++++ plugin/xmem-opencode/bun.lock | 90 +++ plugin/xmem-opencode/package.json | 48 ++ plugin/xmem-opencode/src/cli.ts | 373 ++++++++++++ plugin/xmem-opencode/src/config.ts | 143 +++++ plugin/xmem-opencode/src/index.ts | 375 ++++++++++++ plugin/xmem-opencode/src/services/auth.ts | 148 +++++ plugin/xmem-opencode/src/services/client.ts | 119 ++++ .../xmem-opencode/src/services/compaction.ts | 544 ++++++++++++++++++ plugin/xmem-opencode/src/services/context.ts | 61 ++ plugin/xmem-opencode/src/services/jsonc.ts | 79 +++ plugin/xmem-opencode/src/services/logger.ts | 15 + plugin/xmem-opencode/src/services/privacy.ts | 12 + plugin/xmem-opencode/src/services/tags.ts | 33 ++ plugin/xmem-opencode/src/types/index.ts | 9 + plugin/xmem-opencode/tsconfig.json | 26 + 18 files changed, 2296 insertions(+) create mode 100644 plugin/xmem-opencode/.github/workflows/release.yml create mode 100644 plugin/xmem-opencode/.gitignore create mode 100644 plugin/xmem-opencode/README.md create mode 100644 plugin/xmem-opencode/bun.lock create mode 100644 plugin/xmem-opencode/package.json create mode 100644 plugin/xmem-opencode/src/cli.ts create mode 100644 plugin/xmem-opencode/src/config.ts create mode 100644 plugin/xmem-opencode/src/index.ts create mode 100644 plugin/xmem-opencode/src/services/auth.ts create mode 100644 plugin/xmem-opencode/src/services/client.ts create mode 100644 plugin/xmem-opencode/src/services/compaction.ts create mode 100644 plugin/xmem-opencode/src/services/context.ts create mode 100644 plugin/xmem-opencode/src/services/jsonc.ts create mode 100644 plugin/xmem-opencode/src/services/logger.ts create mode 100644 plugin/xmem-opencode/src/services/privacy.ts create mode 100644 plugin/xmem-opencode/src/services/tags.ts create mode 100644 plugin/xmem-opencode/src/types/index.ts create mode 100644 plugin/xmem-opencode/tsconfig.json diff --git a/plugin/xmem-opencode/.github/workflows/release.yml b/plugin/xmem-opencode/.github/workflows/release.yml new file mode 100644 index 0000000..541c312 --- /dev/null +++ b/plugin/xmem-opencode/.github/workflows/release.yml @@ -0,0 +1,59 @@ +name: Publish Package + +on: + push: + branches: + - main + paths: + - "package.json" + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org' + + - name: Upgrade npm for trusted publishing support + run: npm install -g npm@latest + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install + + - name: Check if version changed + id: version-check + run: | + PACKAGE_NAME=$(jq -r '.name' package.json) + LOCAL_VERSION=$(jq -r '.version' package.json) + NPM_VERSION=$(npm view "$PACKAGE_NAME" version 2>/dev/null || echo "0.0.0") + if [ "$LOCAL_VERSION" = "$NPM_VERSION" ]; then + echo "Version $LOCAL_VERSION already published, skipping." + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "Publishing $LOCAL_VERSION (npm has $NPM_VERSION)" + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Type check + if: steps.version-check.outputs.changed == 'true' + run: bun run typecheck + + - name: Build + if: steps.version-check.outputs.changed == 'true' + run: bun run build + + - name: Publish + if: steps.version-check.outputs.changed == 'true' + run: npm publish --access public --provenance diff --git a/plugin/xmem-opencode/.gitignore b/plugin/xmem-opencode/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/plugin/xmem-opencode/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/plugin/xmem-opencode/README.md b/plugin/xmem-opencode/README.md new file mode 100644 index 0000000..39cb101 --- /dev/null +++ b/plugin/xmem-opencode/README.md @@ -0,0 +1,128 @@ +# opencode-xmem + +OpenCode plugin for persistent memory using [XMem](https://xmem.in). + +Your agent remembers what you tell it — across sessions, across projects. + +## Installation + +### For Humans + +```bash +bunx opencode-xmem@latest install +``` + +This registers the plugin and opens browser login to connect your XMem account. + +**Or let your agent do it** — paste this into OpenCode: + +``` +Install opencode-xmem by following https://raw.githubusercontent.com/xmem-ai/opencode-xmem/main/README.md +``` + +### For LLM Agents + +
+Instructions for LLM Agents (click to expand) + +#### Step 1: Run the installer + +```bash +bunx opencode-xmem@latest install --no-tui +``` + +This will: + +- Register the plugin in `~/.config/opencode/opencode.jsonc` +- Create the `/xmem-init`, `/xmem-login`, and `/xmem-logout` commands + +#### Step 2: Verify the config + +```bash +cat ~/.config/opencode/opencode.jsonc +``` + +Should contain: + +```json +{ + "plugin": ["opencode-xmem@latest"] +} +``` + +#### Step 3: Authenticate + +```bash +bunx opencode-xmem@latest login +``` + +Or set credentials manually: + +```bash +export XMEM_API_KEY="xmem_..." +export XMEM_USERNAME="your_username" +export XMEM_API_URL="https://api.xmem.in" +``` + +#### Step 4: Restart OpenCode + +Restart OpenCode to load the plugin. + +
+ +## Commands + +| Command | Description | +|---------|-------------| +| `install` | Register plugin and create slash commands | +| `login` | Browser auth via xmem.in/auth/connect | +| `logout` | Clear ~/.xmem-opencode/credentials.json | + +## Agent Tool: `xmem` + +The plugin exposes an `xmem` tool to the coding agent: + +| Mode | Description | +|------|-------------| +| `add` | Store a memory (`content`, optional `scope`) | +| `search` | Search raw memory records (`query`, optional `scope`) | +| `recall` | Get synthesized answer from memories (`query`, optional `scope`) | +| `code` | Query indexed codebase (`query`, `orgId`, `repo`) | +| `help` | Show usage guide | + +**Scopes:** + +- `user` — cross-project preferences +- `project` — project-specific knowledge (default) + +## Configuration + +Optional config at `~/.config/opencode/xmem.jsonc`: + +```jsonc +{ + "maxMemories": 5, + "maxProjectMemories": 10, + "autoRecallEveryPrompt": true, + "compactionThreshold": 0.80, + "defaultOrgId": "your-org", + "defaultRepo": "your-repo" +} +``` + +## Features + +- **Auto-recall** — Injects relevant memories on the first message of each session +- **Keyword detection** — Nudges the agent when you say "remember this" +- **Preemptive compaction** — Saves session summaries to XMem before context overflow +- **Code memory** — Query indexed repos via the `code` tool mode + +## Links + +- [XMem](https://xmem.in) +- [Docs](https://xmem.in/docs#opencode) +- [Connect manually](https://xmem.in/auth/connect?client=opencode) + +## License + +Apache-2.0 diff --git a/plugin/xmem-opencode/bun.lock b/plugin/xmem-opencode/bun.lock new file mode 100644 index 0000000..7bde959 --- /dev/null +++ b/plugin/xmem-opencode/bun.lock @@ -0,0 +1,90 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "opencode-xmem", + "dependencies": { + "@opencode-ai/plugin": "^1.0.162", + "xmem-ai": "^2.0.1", + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.7.3", + }, + }, + }, + "packages": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.15.11", "", { "dependencies": { "@opencode-ai/sdk": "1.15.11", "effect": "4.0.0-beta.66", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.2.15", "@opentui/keymap": ">=0.2.15", "@opentui/solid": ">=0.2.15" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-RDvYDCHO0+3OAGD590oDQqtryrENrUW04SjtoA4sgRV2efpZeBFjx3TnonBsXKq6nWqYg172nhltUEZsihGnXQ=="], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.15.11", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-IyYyDVsO8SKbKbkSadHpDuYnYC+2vmEeLU+rW+rH2M54Sigq6l3gDHno16+U6SRut+lowbph7v/ry3WbV67V3w=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "effect": ["effect@4.0.0-beta.66", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-4arEr62cziFa8BBVDUwJCJJmaVepXf/kRg7KtC0h8+bufngscrHbwWFhr9c+HonwOF+31U3iD3xUJmw9KzX7Dw=="], + + "fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="], + + "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + + "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + + "msgpackr": ["msgpackr@1.11.12", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], + + "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], + + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "uuid": ["uuid@13.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "xmem-ai": ["xmem-ai@2.0.1", "", {}, "sha512-LgUOXc7i0cORM6n+iPquLKh5gbVDHYC1N7X9+GGtryETWC5+BrZyUmXz8VrBMI0AdTE8bxpeB929FbxeTkffPg=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + } +} diff --git a/plugin/xmem-opencode/package.json b/plugin/xmem-opencode/package.json new file mode 100644 index 0000000..d8ff207 --- /dev/null +++ b/plugin/xmem-opencode/package.json @@ -0,0 +1,48 @@ +{ + "name": "opencode-xmem", + "version": "1.0.0", + "description": "OpenCode plugin that gives coding agents persistent memory using XMem", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "bin": { + "opencode-xmem": "./dist/cli.js" + }, + "scripts": { + "build": "bun build ./src/index.ts --outdir ./dist --target node && bun build ./src/cli.ts --outfile ./dist/cli.js --target node && tsc --emitDeclarationOnly", + "dev": "tsc --watch", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "opencode", + "plugin", + "xmem", + "memory", + "ai", + "coding-agent" + ], + "author": "XMem", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/xmem-ai/opencode-xmem" + }, + "dependencies": { + "@opencode-ai/plugin": "^1.0.162", + "xmem-ai": "^2.0.1" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.7.3" + }, + "opencode": { + "type": "plugin", + "hooks": [ + "chat.message", + "event" + ] + }, + "files": [ + "dist" + ] +} diff --git a/plugin/xmem-opencode/src/cli.ts b/plugin/xmem-opencode/src/cli.ts new file mode 100644 index 0000000..167aac5 --- /dev/null +++ b/plugin/xmem-opencode/src/cli.ts @@ -0,0 +1,373 @@ +#!/usr/bin/env node +import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import * as readline from "node:readline"; +import { stripJsoncComments } from "./services/jsonc.js"; +import { startAuthFlow, clearCredentials, loadCredentials } from "./services/auth.js"; +import { writeInstallDefaults, CONFIG_FILE } from "./config.js"; + +const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode"); +const OPENCODE_COMMAND_DIR = join(OPENCODE_CONFIG_DIR, "command"); +const OH_MY_OPENCODE_CONFIG = join(OPENCODE_CONFIG_DIR, "oh-my-opencode.json"); +const PLUGIN_NAME = "opencode-xmem@latest"; + +const XMEM_INIT_COMMAND = `--- +description: Initialize XMem with comprehensive codebase knowledge +--- + +# Initializing XMem + +You are initializing persistent memory for this codebase using XMem. + +## What to Remember + +### 1. Procedures (Rules & Workflows) +- Build, test, lint commands +- Branching and commit conventions + +### 2. Preferences (Style & Conventions) +- Coding style preferences +- Framework and library choices + +### 3. Architecture & Context +- Key directories and data flow +- Known issues and solutions + +## Memory Scopes + +**Project-scoped** (\`scope: "project"\`): +- Build/test/lint commands, architecture, team conventions + +**User-scoped** (\`scope: "user"\`): +- Personal coding preferences across all projects + +## Saving Memories + +Use the \`xmem\` tool for each distinct insight: + +\`\`\` +xmem(mode: "add", content: "...", scope: "project") +\`\`\` + +## Your Task + +1. Check existing memories: \`xmem(mode: "recall", query: "project context", scope: "project")\` +2. Research the codebase thoroughly +3. Save memories incrementally as you discover insights +4. Summarize what was learned +`; + +const XMEM_LOGIN_COMMAND = `--- +description: Authenticate with XMem via browser +--- + +# XMem Login + +Run this command to authenticate the user with XMem: + +\`\`\`bash +bunx opencode-xmem@latest login +\`\`\` + +This will: +1. Start a local server on port 19878 +2. Open the browser to XMem's authentication page +3. After the user logs in, save credentials to ~/.xmem-opencode/credentials.json + +Wait for the command to complete, then inform the user whether authentication succeeded or failed. +`; + +const XMEM_LOGOUT_COMMAND = `--- +description: Log out from XMem and clear credentials +--- + +# XMem Logout + +Run this command to log out and clear XMem credentials: + +\`\`\`bash +bunx opencode-xmem@latest logout +\`\`\` + +This will remove the saved credentials from ~/.xmem-opencode/credentials.json. +`; + +function createReadline(): readline.Interface { + return readline.createInterface({ input: process.stdin, output: process.stdout }); +} + +async function confirm(rl: readline.Interface, question: string): Promise { + return new Promise((resolve) => { + rl.question(`${question} (y/n) `, (answer) => { + resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"); + }); + }); +} + +function findOpencodeConfig(): string | null { + const candidates = [ + join(OPENCODE_CONFIG_DIR, "opencode.jsonc"), + join(OPENCODE_CONFIG_DIR, "opencode.json"), + ]; + for (const path of candidates) { + if (existsSync(path)) return path; + } + return null; +} + +function addPluginToConfig(configPath: string): boolean { + try { + const content = readFileSync(configPath, "utf-8"); + if (content.includes("opencode-xmem")) { + console.log("✓ Plugin already registered in config"); + return true; + } + + const jsonContent = stripJsoncComments(content); + let config: Record; + try { + config = JSON.parse(jsonContent); + } catch { + console.error("✗ Failed to parse config file"); + return false; + } + + const plugins = (config.plugin as string[]) || []; + plugins.push(PLUGIN_NAME); + config.plugin = plugins; + + if (configPath.endsWith(".jsonc")) { + if (content.includes('"plugin"')) { + const newContent = content.replace( + /("plugin"\s*:\s*\[)([^\]]*?)(\])/, + (_match, start, middle, end) => { + const trimmed = middle.trim(); + if (trimmed === "") { + return `${start}\n "${PLUGIN_NAME}"\n ${end}`; + } + return `${start}${middle.trimEnd()},\n "${PLUGIN_NAME}"\n ${end}`; + } + ); + writeFileSync(configPath, newContent); + } else { + const newContent = content.replace(/^(\s*\{)/, `$1\n "plugin": ["${PLUGIN_NAME}"],`); + writeFileSync(configPath, newContent); + } + } else { + writeFileSync(configPath, JSON.stringify(config, null, 2)); + } + + console.log(`✓ Added plugin to ${configPath}`); + return true; + } catch (err) { + console.error("✗ Failed to update config:", err); + return false; + } +} + +function createNewConfig(): boolean { + const configPath = join(OPENCODE_CONFIG_DIR, "opencode.jsonc"); + mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true }); + writeFileSync(configPath, `{\n "plugin": ["${PLUGIN_NAME}"]\n}\n`); + console.log(`✓ Created ${configPath}`); + return true; +} + +function createCommands(): boolean { + mkdirSync(OPENCODE_COMMAND_DIR, { recursive: true }); + writeFileSync(join(OPENCODE_COMMAND_DIR, "xmem-init.md"), XMEM_INIT_COMMAND); + writeFileSync(join(OPENCODE_COMMAND_DIR, "xmem-login.md"), XMEM_LOGIN_COMMAND); + writeFileSync(join(OPENCODE_COMMAND_DIR, "xmem-logout.md"), XMEM_LOGOUT_COMMAND); + console.log("✓ Created /xmem-init, /xmem-login, and /xmem-logout commands"); + return true; +} + +function isOhMyOpencodeInstalled(): boolean { + const configPath = findOpencodeConfig(); + if (!configPath) return false; + try { + return readFileSync(configPath, "utf-8").includes("oh-my-opencode"); + } catch { + return false; + } +} + +function isAutoCompactAlreadyDisabled(): boolean { + if (!existsSync(OH_MY_OPENCODE_CONFIG)) return false; + try { + const config = JSON.parse(readFileSync(OH_MY_OPENCODE_CONFIG, "utf-8")); + const disabledHooks = config.disabled_hooks as string[] | undefined; + return disabledHooks?.includes("anthropic-context-window-limit-recovery") ?? false; + } catch { + return false; + } +} + +function disableAutoCompactHook(): boolean { + try { + let config: Record = {}; + if (existsSync(OH_MY_OPENCODE_CONFIG)) { + config = JSON.parse(readFileSync(OH_MY_OPENCODE_CONFIG, "utf-8")); + } + const disabledHooks = (config.disabled_hooks as string[]) || []; + if (!disabledHooks.includes("anthropic-context-window-limit-recovery")) { + disabledHooks.push("anthropic-context-window-limit-recovery"); + } + config.disabled_hooks = disabledHooks; + writeFileSync(OH_MY_OPENCODE_CONFIG, JSON.stringify(config, null, 2)); + console.log("✓ Disabled anthropic-context-window-limit-recovery hook in oh-my-opencode.json"); + return true; + } catch (err) { + console.error("✗ Failed to update oh-my-opencode.json:", err); + return false; + } +} + +interface InstallOptions { + tui: boolean; + disableAutoCompact: boolean; +} + +async function install(options: InstallOptions): Promise { + console.log("\n🧠 opencode-xmem installer\n"); + + writeInstallDefaults(existsSync(CONFIG_FILE)); + + const rl = options.tui ? createReadline() : null; + + console.log("Step 1: Register plugin in OpenCode config"); + const configPath = findOpencodeConfig(); + + if (configPath) { + if (options.tui) { + const shouldModify = await confirm(rl!, `Add plugin to ${configPath}?`); + if (shouldModify) addPluginToConfig(configPath); + else console.log("Skipped."); + } else { + addPluginToConfig(configPath); + } + } else { + if (options.tui) { + const shouldCreate = await confirm(rl!, "No OpenCode config found. Create one?"); + if (shouldCreate) createNewConfig(); + else console.log("Skipped."); + } else { + createNewConfig(); + } + } + + console.log("\nStep 2: Create /xmem-init, /xmem-login, and /xmem-logout commands"); + if (options.tui) { + const shouldCreate = await confirm(rl!, "Add xmem commands?"); + if (shouldCreate) createCommands(); + else console.log("Skipped."); + } else { + createCommands(); + } + + if (isOhMyOpencodeInstalled()) { + console.log("\nStep 3: Configure Oh My OpenCode"); + if (isAutoCompactAlreadyDisabled()) { + console.log("✓ anthropic-context-window-limit-recovery hook already disabled"); + } else if (options.tui) { + const shouldDisable = await confirm( + rl!, + "Disable anthropic-context-window-limit-recovery hook to let XMem handle context?" + ); + if (shouldDisable) disableAutoCompactHook(); + else console.log("Skipped."); + } else if (options.disableAutoCompact) { + disableAutoCompactHook(); + } else { + console.log("Skipped. Use --disable-context-recovery to disable the hook in non-interactive mode."); + } + } + + if (rl) rl.close(); + + console.log("\n" + "─".repeat(50)); + console.log("\n🔑 Final step: Authenticate with XMem\n"); + + if (options.tui) { + return login(); + } + + console.log("Run this command to authenticate:"); + console.log(" bunx opencode-xmem@latest login"); + console.log("\nOr set credentials manually:"); + console.log(' export XMEM_API_KEY="xmem_..."'); + console.log(' export XMEM_USERNAME="your_username"'); + console.log(' export XMEM_API_URL="https://api.xmem.in"'); + console.log("\n" + "─".repeat(50)); + console.log("\n✓ Setup complete! Restart OpenCode to activate.\n"); + return 0; +} + +async function login(): Promise { + const existing = loadCredentials(); + if (existing) { + console.log("Already authenticated. Use 'logout' first to re-authenticate."); + return 0; + } + + const result = await startAuthFlow(); + + if (result.success) { + console.log("\n✓ Successfully authenticated with XMem!"); + console.log("Restart OpenCode to activate.\n"); + return 0; + } else { + console.error(`\n✗ Authentication failed: ${result.error}`); + return 1; + } +} + +function logout(): number { + if (clearCredentials()) { + console.log("✓ Logged out. Credentials cleared."); + return 0; + } else { + console.log("No credentials found."); + return 0; + } +} + +function printHelp(): void { + console.log(` +opencode-xmem - Persistent memory for OpenCode agents + +Commands: + install Install and configure the plugin + --no-tui Non-interactive mode (for LLM agents) + --disable-context-recovery Disable Oh My OpenCode's context hook + login Authenticate with XMem (opens browser) + logout Clear stored credentials + +Examples: + bunx opencode-xmem@latest install + bunx opencode-xmem@latest login + bunx opencode-xmem@latest logout +`); +} + +const args = process.argv.slice(2); + +if (args.length === 0 || args[0] === "help" || args[0] === "--help" || args[0] === "-h") { + printHelp(); + process.exit(0); +} + +if (args[0] === "install") { + const noTui = args.includes("--no-tui"); + const disableAutoCompact = args.includes("--disable-context-recovery"); + install({ tui: !noTui, disableAutoCompact }).then((code) => process.exit(code)); +} else if (args[0] === "login") { + login().then((code) => process.exit(code)); +} else if (args[0] === "logout") { + process.exit(logout()); +} else { + console.error(`Unknown command: ${args[0]}`); + printHelp(); + process.exit(1); +} diff --git a/plugin/xmem-opencode/src/config.ts b/plugin/xmem-opencode/src/config.ts new file mode 100644 index 0000000..333e295 --- /dev/null +++ b/plugin/xmem-opencode/src/config.ts @@ -0,0 +1,143 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { stripJsoncComments } from "./services/jsonc.js"; +import { loadCredentials } from "./services/auth.js"; + +const CONFIG_DIR = join(homedir(), ".config", "opencode"); +const CONFIG_FILES = [ + join(CONFIG_DIR, "xmem.jsonc"), + join(CONFIG_DIR, "xmem.json"), +]; + +interface XMemConfig { + apiKey?: string; + apiUrl?: string; + username?: string; + maxMemories?: number; + maxProjectMemories?: number; + keywordPatterns?: string[]; + compactionThreshold?: number; + autoRecallEveryPrompt?: boolean; + defaultOrgId?: string; + defaultRepo?: string; +} + +const DEFAULT_KEYWORD_PATTERNS = [ + "remember", + "memorize", + "save\\s+this", + "note\\s+this", + "keep\\s+in\\s+mind", + "don'?t\\s+forget", + "learn\\s+this", + "store\\s+this", + "record\\s+this", + "make\\s+a\\s+note", + "take\\s+note", + "jot\\s+down", + "commit\\s+to\\s+memory", + "remember\\s+that", + "never\\s+forget", + "always\\s+remember", +]; + +const DEFAULTS = { + apiUrl: "https://api.xmem.in", + maxMemories: 5, + maxProjectMemories: 10, + compactionThreshold: 0.80, + autoRecallEveryPrompt: false, +}; + +function isValidRegex(pattern: string): boolean { + try { + new RegExp(pattern); + return true; + } catch { + return false; + } +} + +function validateCompactionThreshold(value: number | undefined): number { + if (value === undefined || typeof value !== "number" || isNaN(value)) { + return DEFAULTS.compactionThreshold; + } + if (value <= 0 || value > 1) return DEFAULTS.compactionThreshold; + return value; +} + +function loadRawConfig(): { config: XMemConfig; existed: boolean } { + for (const path of CONFIG_FILES) { + if (existsSync(path)) { + try { + const content = readFileSync(path, "utf-8"); + const json = stripJsoncComments(content); + return { config: JSON.parse(json) as XMemConfig, existed: true }; + } catch { + return { config: {}, existed: true }; + } + } + } + return { config: {}, existed: false }; +} + +const { config: fileConfig, existed: configExisted } = loadRawConfig(); +const credentials = loadCredentials(); + +function getApiKey(): string | undefined { + if (process.env.XMEM_API_KEY) return process.env.XMEM_API_KEY; + if (fileConfig.apiKey) return fileConfig.apiKey; + return credentials?.apiKey; +} + +function getApiUrl(): string { + if (process.env.XMEM_API_URL) return process.env.XMEM_API_URL; + if (fileConfig.apiUrl) return fileConfig.apiUrl; + if (credentials?.apiUrl) return credentials.apiUrl; + return DEFAULTS.apiUrl; +} + +function getUsername(): string | undefined { + if (process.env.XMEM_USERNAME) return process.env.XMEM_USERNAME; + if (fileConfig.username) return fileConfig.username; + return credentials?.username; +} + +export const XMEM_API_KEY = getApiKey(); +export const XMEM_API_URL = getApiUrl(); +export const XMEM_USERNAME = getUsername(); +export const CONFIG_FILE = CONFIG_FILES[1]!; + +export const CONFIG = { + apiUrl: XMEM_API_URL, + username: XMEM_USERNAME, + maxMemories: fileConfig.maxMemories ?? DEFAULTS.maxMemories, + maxProjectMemories: fileConfig.maxProjectMemories ?? DEFAULTS.maxProjectMemories, + keywordPatterns: [ + ...DEFAULT_KEYWORD_PATTERNS, + ...(fileConfig.keywordPatterns ?? []).filter(isValidRegex), + ], + compactionThreshold: validateCompactionThreshold(fileConfig.compactionThreshold), + autoRecallEveryPrompt: + fileConfig.autoRecallEveryPrompt ?? + (configExisted ? true : DEFAULTS.autoRecallEveryPrompt), + defaultOrgId: fileConfig.defaultOrgId, + defaultRepo: fileConfig.defaultRepo, +}; + +export function isConfigured(): boolean { + return !!(XMEM_API_KEY && XMEM_USERNAME); +} + +export function writeInstallDefaults(isExistingInstall: boolean): void { + const current = loadRawConfig().config; + const next: XMemConfig = { ...current }; + if (isExistingInstall) { + if (next.autoRecallEveryPrompt === undefined) next.autoRecallEveryPrompt = true; + } else { + next.autoRecallEveryPrompt = false; + } + mkdirSync(CONFIG_DIR, { recursive: true }); + writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2)); +} diff --git a/plugin/xmem-opencode/src/index.ts b/plugin/xmem-opencode/src/index.ts new file mode 100644 index 0000000..c7f979e --- /dev/null +++ b/plugin/xmem-opencode/src/index.ts @@ -0,0 +1,375 @@ +import type { Plugin, PluginInput } from "@opencode-ai/plugin"; +import type { Part } from "@opencode-ai/sdk"; +import { tool } from "@opencode-ai/plugin"; + +import { xmemClient } from "./services/client.js"; +import { formatContextForPrompt, formatSearchResults } from "./services/context.js"; +import { getTags, resolveUserId } from "./services/tags.js"; +import { stripPrivateContent, isFullyPrivate } from "./services/privacy.js"; +import { createCompactionHook, type CompactionContext } from "./services/compaction.js"; + +import { isConfigured, CONFIG } from "./config.js"; +import { log } from "./services/logger.js"; +import type { MemoryScope } from "./types/index.js"; + +const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g; +const INLINE_CODE_PATTERN = /`[^`]+`/g; + +const MEMORY_KEYWORD_PATTERN = new RegExp(`\\b(${CONFIG.keywordPatterns.join("|")})\\b`, "i"); + +const MEMORY_NUDGE_MESSAGE = `[MEMORY TRIGGER DETECTED] +The user wants you to remember something. You MUST use the \`xmem\` tool with \`mode: "add"\` to save this information. + +Extract the key information the user wants remembered and save it as a concise, searchable memory. +- Use \`scope: "project"\` for project-specific preferences (e.g., "run lint with tests") +- Use \`scope: "user"\` for cross-project preferences (e.g., "prefers concise responses") + +DO NOT skip this step. The user explicitly asked you to remember.`; + +function removeCodeBlocks(text: string): string { + return text.replace(CODE_BLOCK_PATTERN, "").replace(INLINE_CODE_PATTERN, ""); +} + +function detectMemoryKeyword(text: string): boolean { + const textWithoutCode = removeCodeBlocks(text); + return MEMORY_KEYWORD_PATTERN.test(textWithoutCode); +} + +export const XMemPlugin: Plugin = async (ctx: PluginInput) => { + const { directory } = ctx; + const tags = getTags(directory); + const injectedSessions = new Set(); + log("Plugin init", { directory, tags, configured: isConfigured() }); + + if (!isConfigured()) { + log("Plugin disabled - XMem credentials not set. Run: bunx opencode-xmem login"); + } + + const modelLimits = new Map(); + + (async () => { + try { + const response = await ctx.client.provider.list(); + if (response.data?.all) { + for (const provider of response.data.all) { + if (provider.models) { + for (const [modelId, model] of Object.entries(provider.models)) { + if (model.limit?.context) { + modelLimits.set(`${provider.id}/${modelId}`, model.limit.context); + } + } + } + } + } + log("Model limits loaded", { count: modelLimits.size }); + } catch (error) { + log("Failed to fetch model limits", { error: String(error) }); + } + })(); + + const getModelLimit = (providerID: string, modelID: string): number | undefined => { + return modelLimits.get(`${providerID}/${modelID}`); + }; + + const compactionHook = + isConfigured() && ctx.client + ? createCompactionHook(ctx as CompactionContext, tags, { + threshold: CONFIG.compactionThreshold, + getModelLimit, + }) + : null; + + return { + "chat.message": async (input, output) => { + if (!isConfigured()) return; + + const start = Date.now(); + + try { + const textParts = output.parts.filter( + (p): p is Part & { type: "text"; text: string } => p.type === "text" + ); + + if (textParts.length === 0) return; + + const userMessage = textParts.map((p) => p.text).join("\n"); + if (!userMessage.trim()) return; + + if (detectMemoryKeyword(userMessage)) { + const nudgePart: Part = { + id: `prt_xmem-nudge-${Date.now()}`, + sessionID: input.sessionID, + messageID: output.message.id, + type: "text", + text: MEMORY_NUDGE_MESSAGE, + synthetic: true, + }; + output.parts.push(nudgePart); + } + + const isFirstMessage = !injectedSessions.has(input.sessionID); + + if (isFirstMessage) { + injectedSessions.add(input.sessionID); + + let memoryContext = ""; + + if (CONFIG.autoRecallEveryPrompt) { + const [userRetrieveResult, projectSearchResult, userSearchResult] = await Promise.all([ + xmemClient.retrieve(userMessage, tags.user), + xmemClient.search(userMessage, tags.project, CONFIG.maxProjectMemories), + xmemClient.search(userMessage, tags.user, CONFIG.maxMemories), + ]); + + memoryContext = formatContextForPrompt( + userRetrieveResult.success ? userRetrieveResult : null, + projectSearchResult.success ? projectSearchResult : null, + userSearchResult.success ? userSearchResult : null + ); + } else { + const userRetrieveResult = await xmemClient.retrieve("user preferences and context", tags.user); + memoryContext = formatContextForPrompt( + userRetrieveResult.success ? userRetrieveResult : null, + null, + null + ); + } + + if (memoryContext) { + const contextPart: Part = { + id: `prt_xmem-context-${Date.now()}`, + sessionID: input.sessionID, + messageID: output.message.id, + type: "text", + text: memoryContext, + synthetic: true, + }; + + output.parts.unshift(contextPart); + + log("chat.message: context injected", { + duration: Date.now() - start, + contextLength: memoryContext.length, + }); + } + } + } catch (error) { + log("chat.message: ERROR", { error: String(error) }); + } + }, + + tool: { + xmem: tool({ + description: + "Manage and query the XMem persistent memory system. Use 'add' to store knowledge, 'search' for raw records, 'recall' for synthesized answers, 'code' for indexed codebase queries.", + args: { + mode: tool.schema.enum(["add", "search", "recall", "code", "help"]).optional(), + content: tool.schema.string().optional(), + query: tool.schema.string().optional(), + scope: tool.schema.enum(["user", "project"]).optional(), + orgId: tool.schema.string().optional(), + repo: tool.schema.string().optional(), + limit: tool.schema.number().optional(), + }, + async execute(args: { + mode?: string; + content?: string; + query?: string; + scope?: MemoryScope; + orgId?: string; + repo?: string; + limit?: number; + }) { + if (!isConfigured()) { + return JSON.stringify({ + success: false, + error: "XMem not configured. Run: bunx opencode-xmem login", + }); + } + + const mode = args.mode || "help"; + + try { + switch (mode) { + case "help": { + return JSON.stringify({ + success: true, + message: "XMem Usage Guide", + commands: [ + { command: "add", description: "Store a new memory", args: ["content", "scope?"] }, + { command: "search", description: "Search raw memory records", args: ["query", "scope?"] }, + { command: "recall", description: "Get synthesized answer from memories", args: ["query", "scope?"] }, + { command: "code", description: "Query indexed codebase", args: ["query", "orgId?", "repo?"] }, + ], + scopes: { + user: "Cross-project preferences and knowledge", + project: "Project-specific knowledge (default)", + }, + }); + } + + case "add": { + if (!args.content) { + return JSON.stringify({ success: false, error: "content parameter is required for add mode" }); + } + + const sanitizedContent = stripPrivateContent(args.content); + if (isFullyPrivate(args.content)) { + return JSON.stringify({ success: false, error: "Cannot store fully private content" }); + } + + const scope = args.scope || "project"; + const userId = resolveUserId(scope, directory); + + const result = await xmemClient.ingest(sanitizedContent, userId); + + if (!result.success) { + return JSON.stringify({ success: false, error: result.error || "Failed to add memory" }); + } + + return JSON.stringify({ + success: true, + message: `Memory added to ${scope} scope`, + scope, + model: result.model, + }); + } + + case "search": { + if (!args.query) { + return JSON.stringify({ success: false, error: "query parameter is required for search mode" }); + } + + const scope = args.scope; + + if (scope === "user") { + const result = await xmemClient.search(args.query, tags.user, args.limit); + if (!result.success) { + return JSON.stringify({ success: false, error: result.error || "Failed to search memories" }); + } + return formatSearchResults(args.query, scope, result.results || [], args.limit); + } + + if (scope === "project") { + const result = await xmemClient.search(args.query, tags.project, args.limit); + if (!result.success) { + return JSON.stringify({ success: false, error: result.error || "Failed to search memories" }); + } + return formatSearchResults(args.query, scope, result.results || [], args.limit); + } + + const [userResult, projectResult] = await Promise.all([ + xmemClient.search(args.query, tags.user, args.limit), + xmemClient.search(args.query, tags.project, args.limit), + ]); + + if (!userResult.success || !projectResult.success) { + return JSON.stringify({ + success: false, + error: userResult.error || projectResult.error || "Failed to search memories", + }); + } + + const combined = [ + ...(userResult.results || []).map((r) => ({ ...r, scope: "user" as const })), + ...(projectResult.results || []).map((r) => ({ ...r, scope: "project" as const })), + ].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); + + return JSON.stringify({ + success: true, + query: args.query, + count: combined.length, + results: combined.slice(0, args.limit || 10).map((r) => ({ + domain: r.domain, + content: r.content, + score: Math.round((r.score ?? 0) * 100), + scope: r.scope, + })), + }); + } + + case "recall": { + if (!args.query) { + return JSON.stringify({ success: false, error: "query parameter is required for recall mode" }); + } + + const scope = args.scope || "user"; + const userId = resolveUserId(scope, directory); + const result = await xmemClient.retrieve(args.query, userId, args.limit); + + if (!result.success) { + return JSON.stringify({ success: false, error: result.error || "Failed to recall memories" }); + } + + return JSON.stringify({ + success: true, + query: args.query, + scope, + answer: result.answer, + confidence: result.confidence, + sources: (result.sources || []).slice(0, args.limit || 5).map((s) => ({ + domain: s.domain, + content: s.content, + score: Math.round((s.score ?? 0) * 100), + })), + }); + } + + case "code": { + if (!args.query) { + return JSON.stringify({ success: false, error: "query parameter is required for code mode" }); + } + + const orgId = args.orgId || CONFIG.defaultOrgId; + const repo = args.repo || CONFIG.defaultRepo; + + if (!orgId || !repo) { + return JSON.stringify({ + success: false, + error: "orgId and repo are required for code mode (set in xmem.jsonc or pass as args)", + }); + } + + const result = await xmemClient.codeQuery(args.query, orgId, repo, tags.user, args.limit); + + if (!result.success) { + return JSON.stringify({ success: false, error: result.error || "Failed to query code" }); + } + + return JSON.stringify({ + success: true, + query: args.query, + orgId, + repo, + answer: result.answer, + confidence: result.confidence, + sources: (result.sources || []).slice(0, args.limit || 5).map((s) => ({ + domain: s.domain, + content: s.content, + score: Math.round((s.score ?? 0) * 100), + })), + }); + } + + default: + return JSON.stringify({ success: false, error: `Unknown mode: ${mode}` }); + } + } catch (error) { + return JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }, + }), + }, + + event: async (input: { event: { type: string; properties?: unknown } }) => { + if (compactionHook) { + await compactionHook.event(input); + } + }, + }; +}; + +export default XMemPlugin; diff --git a/plugin/xmem-opencode/src/services/auth.ts b/plugin/xmem-opencode/src/services/auth.ts new file mode 100644 index 0000000..7d3470a --- /dev/null +++ b/plugin/xmem-opencode/src/services/auth.ts @@ -0,0 +1,148 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { exec } from "node:child_process"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +const CREDENTIALS_DIR = join(homedir(), ".xmem-opencode"); +const CREDENTIALS_FILE = join(CREDENTIALS_DIR, "credentials.json"); +const AUTH_PORT = 19878; +const AUTH_BASE_URL = process.env.XMEM_AUTH_URL || "https://xmem.in/auth/connect"; +const CLIENT_NAME = "opencode"; + +export interface Credentials { + apiKey: string; + apiUrl: string; + username: string; + createdAt: string; +} + +export function loadCredentials(): Credentials | null { + if (!existsSync(CREDENTIALS_FILE)) return null; + try { + const content = readFileSync(CREDENTIALS_FILE, "utf-8"); + return JSON.parse(content) as Credentials; + } catch { + return null; + } +} + +export function saveCredentials(credentials: Omit): void { + mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 }); + const stored: Credentials = { + ...credentials, + createdAt: new Date().toISOString(), + }; + writeFileSync(CREDENTIALS_FILE, JSON.stringify(stored, null, 2), { mode: 0o600 }); +} + +export function clearCredentials(): boolean { + if (!existsSync(CREDENTIALS_FILE)) return false; + rmSync(CREDENTIALS_FILE); + return true; +} + +function openBrowser(url: string): void { + const platform = process.platform; + + const commands: Record = { + darwin: `open "${url}"`, + win32: `start "" "${url}"`, + linux: `xdg-open "${url}"`, + }; + + const cmd = commands[platform] ?? `xdg-open "${url}"`; + exec(cmd, (err) => { + if (err) console.error("Failed to open browser:", err.message); + }); +} + +export interface AuthResult { + success: boolean; + credentials?: Credentials; + error?: string; +} + +export function startAuthFlow(timeoutMs = 120000): Promise { + return new Promise((resolve) => { + let resolved = false; + + const server = createServer((req: IncomingMessage, res: ServerResponse) => { + if (resolved) return; + + const url = new URL(req.url || "/", `http://localhost:${AUTH_PORT}`); + + if (url.pathname === "/callback") { + const apiKey = url.searchParams.get("apikey"); + const username = url.searchParams.get("username"); + const apiUrl = url.searchParams.get("apiurl") || "https://api.xmem.in"; + + if (apiKey && username) { + const credentials = { apiKey, apiUrl, username, createdAt: new Date().toISOString() }; + saveCredentials({ apiKey, apiUrl, username }); + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(` + + + Success + +
+

✓ Connected!

+

XMem is now connected to OpenCode. You can close this window and return to your terminal.

+
+ + + `); + resolved = true; + server.close(); + resolve({ success: true, credentials }); + } else { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(` + + + Error + +
+

✗ Connection Failed

+

Missing API key or username. Please try again.

+
+ + + `); + resolved = true; + server.close(); + resolve({ success: false, error: "Missing API key or username" }); + } + } else { + res.writeHead(404); + res.end("Not Found"); + } + }); + + server.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + resolve({ success: false, error: `Port ${AUTH_PORT} is already in use` }); + } else { + resolve({ success: false, error: err.message }); + } + }); + + server.listen(AUTH_PORT, () => { + const callbackUrl = `http://localhost:${AUTH_PORT}/callback`; + const authUrl = `${AUTH_BASE_URL}?callback=${encodeURIComponent(callbackUrl)}&client=${CLIENT_NAME}`; + + console.log("Opening browser for XMem authentication..."); + console.log(`If it doesn't open, visit: ${authUrl}`); + openBrowser(authUrl); + }); + + setTimeout(() => { + if (!resolved) { + resolved = true; + server.close(); + resolve({ success: false, error: "Authentication timed out" }); + } + }, timeoutMs); + }); +} diff --git a/plugin/xmem-opencode/src/services/client.ts b/plugin/xmem-opencode/src/services/client.ts new file mode 100644 index 0000000..aee6873 --- /dev/null +++ b/plugin/xmem-opencode/src/services/client.ts @@ -0,0 +1,119 @@ +import { XMemClient } from "xmem-ai"; +import type { RetrieveResult, SearchResult, SourceRecord } from "xmem-ai"; +import { CONFIG, isConfigured, XMEM_API_KEY, XMEM_API_URL, XMEM_USERNAME } from "../config.js"; +import { log } from "./logger.js"; + +const TIMEOUT_MS = 30000; + +function withTimeout(promise: Promise, ms: number): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms) + ), + ]); +} + +export class XMemServiceClient { + private client: XMemClient | null = null; + + private getClient(): XMemClient { + if (!this.client) { + if (!isConfigured()) { + throw new Error("XMem not configured. Run: bunx opencode-xmem login"); + } + this.client = new XMemClient(XMEM_API_URL, XMEM_API_KEY!, XMEM_USERNAME!); + } + return this.client; + } + + async ingest(content: string, userId: string, agentResponse?: string) { + log("ingest: start", { userId, contentLength: content.length }); + try { + const result = await withTimeout( + this.getClient().ingest({ + user_query: content, + user_id: userId, + agent_response: agentResponse, + }), + TIMEOUT_MS + ); + log("ingest: success", { userId }); + return { success: true as const, ...result }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log("ingest: error", { error: errorMessage }); + return { success: false as const, error: errorMessage }; + } + } + + async search(query: string, userId: string, topK = CONFIG.maxMemories) { + log("search: start", { userId, query }); + try { + const result = await withTimeout( + this.getClient().search({ + query, + user_id: userId, + top_k: topK, + }), + TIMEOUT_MS + ); + log("search: success", { count: result.results?.length || 0 }); + return { success: true as const, ...result }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log("search: error", { error: errorMessage }); + return { success: false as const, error: errorMessage, results: [], total: 0 }; + } + } + + async retrieve(query: string, userId: string, topK = CONFIG.maxMemories) { + log("retrieve: start", { userId, query }); + try { + const result = await withTimeout( + this.getClient().retrieve({ + query, + user_id: userId, + top_k: topK, + }), + TIMEOUT_MS + ); + log("retrieve: success", { userId }); + return { success: true as const, ...result }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log("retrieve: error", { error: errorMessage }); + return { success: false as const, error: errorMessage, answer: "", sources: [], confidence: 0, model: "" }; + } + } + + async codeQuery(query: string, orgId: string, repo: string, userId?: string, topK = 5) { + log("codeQuery: start", { orgId, repo, query }); + try { + const result = await withTimeout( + this.getClient().codeQuery({ + query, + org_id: orgId, + repo, + user_id: userId, + top_k: topK, + }), + TIMEOUT_MS + ); + log("codeQuery: success", { orgId, repo }); + return { success: true as const, ...result }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log("codeQuery: error", { error: errorMessage }); + return { success: false as const, error: errorMessage, answer: "", sources: [], confidence: 0 }; + } + } + + async searchProjectMemories(userId: string, limit = CONFIG.maxProjectMemories) { + return this.search("project knowledge preferences architecture conventions", userId, limit); + } +} + +export const xmemClient = new XMemServiceClient(); + +export type { RetrieveResult, SearchResult, SourceRecord }; diff --git a/plugin/xmem-opencode/src/services/compaction.ts b/plugin/xmem-opencode/src/services/compaction.ts new file mode 100644 index 0000000..12b1c49 --- /dev/null +++ b/plugin/xmem-opencode/src/services/compaction.ts @@ -0,0 +1,544 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { xmemClient } from "./client.js"; +import { log } from "./logger.js"; +import { CONFIG } from "../config.js"; + +const MESSAGE_STORAGE = join(homedir(), ".opencode", "messages"); +const PART_STORAGE = join(homedir(), ".opencode", "parts"); + +const DEFAULT_THRESHOLD = 0.80; +const MIN_TOKENS_FOR_COMPACTION = 50_000; +const COMPACTION_COOLDOWN_MS = 30_000; +const DEFAULT_CONTEXT_LIMIT = 200_000; + +interface CompactionState { + lastCompactionTime: Map; + compactionInProgress: Set; + summarizedSessions: Set; +} + +interface TokenInfo { + input: number; + output: number; + cache: { read: number; write: number }; +} + +interface MessageInfo { + id: string; + role: string; + sessionID: string; + providerID?: string; + modelID?: string; + tokens?: TokenInfo; + summary?: boolean; + finish?: boolean; +} + +interface StoredMessage { + agent?: string; + model?: { providerID?: string; modelID?: string }; +} + +interface SummarizeContext { + sessionID: string; + providerID: string; + modelID: string; + usageRatio: number; + directory: string; + agent?: string; +} + +export interface CompactionOptions { + threshold?: number; + getModelLimit?: (providerID: string, modelID: string) => number | undefined; +} + +function createCompactionPrompt(projectMemories: string[]): string { + const memoriesSection = + projectMemories.length > 0 + ? ` +## Project Knowledge (from XMem) +The following project-specific knowledge should be preserved and referenced in the summary: +${projectMemories.map((m) => `- ${m}`).join("\n")} +` + : ""; + + return `[COMPACTION CONTEXT INJECTION] + +When summarizing this session, you MUST include the following sections in your summary: + +## 1. User Requests (As-Is) +- List all original user requests exactly as they were stated +- Preserve the user's exact wording and intent + +## 2. Final Goal +- What the user ultimately wanted to achieve +- The end result or deliverable expected + +## 3. Work Completed +- What has been done so far +- Files created/modified +- Features implemented +- Problems solved + +## 4. Remaining Tasks +- What still needs to be done +- Pending items from the original request +- Follow-up tasks identified during the work + +## 5. MUST NOT Do (Critical Constraints) +- Things that were explicitly forbidden +- Approaches that failed and should not be retried +- User's explicit restrictions or preferences +- Anti-patterns identified during the session +${memoriesSection} +This context is critical for maintaining continuity after compaction. +`; +} + +function getMessageDir(sessionID: string): string | null { + if (!existsSync(MESSAGE_STORAGE)) return null; + + const directPath = join(MESSAGE_STORAGE, sessionID); + if (existsSync(directPath)) return directPath; + + for (const dir of readdirSync(MESSAGE_STORAGE)) { + const sessionPath = join(MESSAGE_STORAGE, dir, sessionID); + if (existsSync(sessionPath)) return sessionPath; + } + + return null; +} + +function getOrCreateMessageDir(sessionID: string): string { + if (!existsSync(MESSAGE_STORAGE)) { + mkdirSync(MESSAGE_STORAGE, { recursive: true }); + } + + const directPath = join(MESSAGE_STORAGE, sessionID); + if (existsSync(directPath)) return directPath; + + for (const dir of readdirSync(MESSAGE_STORAGE)) { + const sessionPath = join(MESSAGE_STORAGE, dir, sessionID); + if (existsSync(sessionPath)) return sessionPath; + } + + mkdirSync(directPath, { recursive: true }); + return directPath; +} + +function findNearestMessageWithFields(messageDir: string): StoredMessage | null { + try { + const files = readdirSync(messageDir) + .filter((f) => f.endsWith(".json")) + .sort() + .reverse(); + + for (const file of files) { + try { + const content = readFileSync(join(messageDir, file), "utf-8"); + const msg = JSON.parse(content) as StoredMessage; + if (msg.agent && msg.model?.providerID && msg.model?.modelID) { + return msg; + } + } catch { + continue; + } + } + } catch { + return null; + } + return null; +} + +function generateMessageId(): string { + const timestamp = Date.now().toString(16); + const random = Math.random().toString(36).substring(2, 14); + return `msg_${timestamp}${random}`; +} + +function generatePartId(): string { + const timestamp = Date.now().toString(16); + const random = Math.random().toString(36).substring(2, 10); + return `prt_${timestamp}${random}`; +} + +function injectHookMessage( + sessionID: string, + hookContent: string, + originalMessage: { + agent?: string; + model?: { providerID?: string; modelID?: string }; + path?: { cwd?: string; root?: string }; + } +): boolean { + if (!hookContent || hookContent.trim().length === 0) { + log("[compaction] attempted to inject empty content, skipping"); + return false; + } + + const messageDir = getOrCreateMessageDir(sessionID); + const fallback = findNearestMessageWithFields(messageDir); + + const now = Date.now(); + const messageID = generateMessageId(); + const partID = generatePartId(); + + const resolvedAgent = originalMessage.agent ?? fallback?.agent ?? "general"; + const resolvedModel = + originalMessage.model?.providerID && originalMessage.model?.modelID + ? { providerID: originalMessage.model.providerID, modelID: originalMessage.model.modelID } + : fallback?.model?.providerID && fallback?.model?.modelID + ? { providerID: fallback.model.providerID, modelID: fallback.model.modelID } + : undefined; + + const messageMeta = { + id: messageID, + sessionID, + role: "user", + time: { created: now }, + agent: resolvedAgent, + model: resolvedModel, + path: originalMessage.path?.cwd + ? { cwd: originalMessage.path.cwd, root: originalMessage.path.root ?? "/" } + : undefined, + }; + + const textPart = { + id: partID, + type: "text", + text: hookContent, + synthetic: true, + time: { start: now, end: now }, + messageID, + sessionID, + }; + + try { + writeFileSync(join(messageDir, `${messageID}.json`), JSON.stringify(messageMeta, null, 2)); + + const partDir = join(PART_STORAGE, messageID); + if (!existsSync(partDir)) { + mkdirSync(partDir, { recursive: true }); + } + writeFileSync(join(partDir, `${partID}.json`), JSON.stringify(textPart, null, 2)); + + log("[compaction] hook message injected", { sessionID, messageID }); + return true; + } catch (err) { + log("[compaction] failed to inject hook message", { error: String(err) }); + return false; + } +} + +export interface CompactionContext { + directory: string; + client: { + session: { + summarize: (params: { + path: { id: string }; + body: { providerID: string; modelID: string }; + query: { directory: string }; + }) => Promise; + messages: (params: { + path: { id: string }; + query: { directory: string }; + }) => Promise<{ data?: Array<{ info: MessageInfo }> }>; + promptAsync: (params: { + path: { id: string }; + body: { agent?: string; parts: Array<{ type: string; text: string }> }; + query: { directory: string }; + }) => Promise; + }; + tui: { + showToast: (params: { + body: { title: string; message: string; variant: string; duration: number }; + }) => Promise; + }; + }; +} + +export function createCompactionHook( + ctx: CompactionContext, + tags: { user: string; project: string }, + options?: CompactionOptions +) { + const state: CompactionState = { + lastCompactionTime: new Map(), + compactionInProgress: new Set(), + summarizedSessions: new Set(), + }; + + const threshold = options?.threshold ?? DEFAULT_THRESHOLD; + const getModelLimit = options?.getModelLimit; + + async function fetchProjectMemoriesForCompaction(): Promise { + try { + const result = await xmemClient.searchProjectMemories(tags.project, CONFIG.maxProjectMemories); + if (!result.success) return []; + return (result.results || []).map((r) => r.content).filter(Boolean); + } catch (err) { + log("[compaction] failed to fetch project memories", { error: String(err) }); + return []; + } + } + + async function injectCompactionContext(summarizeCtx: SummarizeContext): Promise { + log("[compaction] injecting context", { sessionID: summarizeCtx.sessionID }); + + const projectMemories = await fetchProjectMemoriesForCompaction(); + const prompt = createCompactionPrompt(projectMemories); + + const success = injectHookMessage(summarizeCtx.sessionID, prompt, { + agent: summarizeCtx.agent, + model: { providerID: summarizeCtx.providerID, modelID: summarizeCtx.modelID }, + path: { cwd: summarizeCtx.directory }, + }); + + if (success) { + log("[compaction] context injected with project memories", { + sessionID: summarizeCtx.sessionID, + memoriesCount: projectMemories.length, + }); + } + } + + async function saveSummaryAsMemory(sessionID: string, summaryContent: string): Promise { + if (!summaryContent || summaryContent.length < 100) { + log("[compaction] summary too short to save", { sessionID, length: summaryContent.length }); + return; + } + + try { + const result = await xmemClient.ingest( + `[Session Summary]\n${summaryContent}`, + tags.project + ); + + if (result.success) { + log("[compaction] summary saved as memory", { sessionID }); + } else { + log("[compaction] failed to save summary", { error: result.error }); + } + } catch (err) { + log("[compaction] failed to save summary", { error: String(err) }); + } + } + + async function checkAndTriggerCompaction(sessionID: string, lastAssistant: MessageInfo): Promise { + if (state.compactionInProgress.has(sessionID)) return; + + const lastCompaction = state.lastCompactionTime.get(sessionID) ?? 0; + if (Date.now() - lastCompaction < COMPACTION_COOLDOWN_MS) return; + + if (lastAssistant.summary === true) return; + + const tokens = lastAssistant.tokens; + if (!tokens) return; + + let modelID = lastAssistant.modelID ?? ""; + let providerID = lastAssistant.providerID ?? ""; + let agent: string | undefined; + + const messageDir = getMessageDir(sessionID); + const storedMessage = messageDir ? findNearestMessageWithFields(messageDir) : null; + + if (!providerID || !modelID) { + if (storedMessage?.model?.providerID) providerID = storedMessage.model.providerID; + if (storedMessage?.model?.modelID) modelID = storedMessage.model.modelID; + } + agent = storedMessage?.agent; + + const configLimit = getModelLimit?.(providerID, modelID); + const contextLimit = configLimit ?? DEFAULT_CONTEXT_LIMIT; + const totalUsed = tokens.input + tokens.cache.read + tokens.output; + + if (totalUsed < MIN_TOKENS_FOR_COMPACTION) return; + + const usageRatio = totalUsed / contextLimit; + + log("[compaction] checking", { + sessionID, + totalUsed, + contextLimit, + usageRatio: usageRatio.toFixed(2), + threshold, + }); + + if (usageRatio < threshold) return; + + state.compactionInProgress.add(sessionID); + state.lastCompactionTime.set(sessionID, Date.now()); + + if (!providerID || !modelID) { + state.compactionInProgress.delete(sessionID); + return; + } + + await ctx.client.tui + .showToast({ + body: { + title: "Preemptive Compaction", + message: `Context at ${(usageRatio * 100).toFixed(0)}% - compacting with XMem context...`, + variant: "warning", + duration: 3000, + }, + }) + .catch(() => {}); + + log("[compaction] triggering compaction", { sessionID, usageRatio }); + + try { + await injectCompactionContext({ + sessionID, + providerID, + modelID, + usageRatio, + directory: ctx.directory, + agent, + }); + + state.summarizedSessions.add(sessionID); + + await ctx.client.session.summarize({ + path: { id: sessionID }, + body: { providerID, modelID }, + query: { directory: ctx.directory }, + }); + + await ctx.client.tui + .showToast({ + body: { + title: "Compaction Complete", + message: "Session compacted with XMem context. Resuming...", + variant: "success", + duration: 2000, + }, + }) + .catch(() => {}); + + state.compactionInProgress.delete(sessionID); + + setTimeout(async () => { + try { + const msgDir = getMessageDir(sessionID); + const stored = msgDir ? findNearestMessageWithFields(msgDir) : null; + + await ctx.client.session.promptAsync({ + path: { id: sessionID }, + body: { + agent: stored?.agent, + parts: [{ type: "text", text: "Continue" }], + }, + query: { directory: ctx.directory }, + }); + } catch {} + }, 500); + } catch (err) { + log("[compaction] compaction failed", { sessionID, error: String(err) }); + state.compactionInProgress.delete(sessionID); + } + } + + async function handleSummaryMessage(sessionID: string): Promise { + if (!state.summarizedSessions.has(sessionID)) return; + + state.summarizedSessions.delete(sessionID); + log("[compaction] capturing summary for memory", { sessionID }); + + try { + const resp = await ctx.client.session.messages({ + path: { id: sessionID }, + query: { directory: ctx.directory }, + }); + + const messages = (resp.data ?? resp) as Array<{ + info: MessageInfo; + parts?: Array<{ type: string; text?: string }>; + }>; + + const summaryMessage = messages.find( + (m) => m.info.role === "assistant" && m.info.summary === true + ); + + if (summaryMessage?.parts) { + const textParts = summaryMessage.parts.filter((p) => p.type === "text" && p.text); + const summaryContent = textParts.map((p) => p.text).join("\n"); + + if (summaryContent) { + await saveSummaryAsMemory(sessionID, summaryContent); + } + } + } catch (err) { + log("[compaction] failed to capture summary", { error: String(err) }); + } + } + + return { + async event({ event }: { event: { type: string; properties?: unknown } }) { + const props = event.properties as Record | undefined; + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined; + if (sessionInfo?.id) { + state.lastCompactionTime.delete(sessionInfo.id); + state.compactionInProgress.delete(sessionInfo.id); + state.summarizedSessions.delete(sessionInfo.id); + } + return; + } + + if (event.type === "message.updated") { + const info = props?.info as MessageInfo | undefined; + if (!info) return; + + const sessionID = info.sessionID; + if (!sessionID) return; + + if (info.role === "assistant" && info.summary === true && info.finish) { + await handleSummaryMessage(sessionID); + return; + } + + if (info.role !== "assistant" || !info.finish) return; + + await checkAndTriggerCompaction(sessionID, info); + return; + } + + if (event.type === "session.idle") { + const sessionID = props?.sessionID as string | undefined; + if (!sessionID) return; + + try { + const resp = await ctx.client.session.messages({ + path: { id: sessionID }, + query: { directory: ctx.directory }, + }); + + const messages = (resp.data ?? resp) as Array<{ info: MessageInfo }>; + const assistants = messages + .filter((m) => m.info.role === "assistant") + .map((m) => m.info); + + if (assistants.length === 0) return; + + const lastAssistant = assistants[assistants.length - 1]!; + + if (!lastAssistant.providerID || !lastAssistant.modelID) { + const msgDir = getMessageDir(sessionID); + const stored = msgDir ? findNearestMessageWithFields(msgDir) : null; + if (stored?.model?.providerID && stored?.model?.modelID) { + lastAssistant.providerID = stored.model.providerID; + lastAssistant.modelID = stored.model.modelID; + } + } + + await checkAndTriggerCompaction(sessionID, lastAssistant); + } catch {} + } + }, + }; +} diff --git a/plugin/xmem-opencode/src/services/context.ts b/plugin/xmem-opencode/src/services/context.ts new file mode 100644 index 0000000..9131f66 --- /dev/null +++ b/plugin/xmem-opencode/src/services/context.ts @@ -0,0 +1,61 @@ +import type { RetrieveResult, SearchResult, SourceRecord } from "./client.js"; + +function formatSource(source: SourceRecord): string { + const score = Math.round((source.score ?? 0) * 100); + return `- [${source.domain}] (${score}%) ${source.content}`; +} + +export function formatContextForPrompt( + userRetrieve: RetrieveResult | null, + projectSearch: SearchResult | null, + userSearch: SearchResult | null +): string { + const parts: string[] = ["[XMEM]"]; + + if (userRetrieve?.answer) { + parts.push("\nRelevant Context:"); + parts.push(userRetrieve.answer); + + if (userRetrieve.sources?.length) { + parts.push("\nSources:"); + userRetrieve.sources.slice(0, 5).forEach((s) => parts.push(formatSource(s))); + } + } + + const projectResults = projectSearch?.results || []; + if (projectResults.length > 0) { + parts.push("\nProject Knowledge:"); + projectResults.forEach((s) => parts.push(formatSource(s))); + } + + const userResults = userSearch?.results || []; + if (userResults.length > 0) { + parts.push("\nUser Memories:"); + userResults.forEach((s) => parts.push(formatSource(s))); + } + + if (parts.length === 1) { + return ""; + } + + return parts.join("\n"); +} + +export function formatSearchResults( + query: string, + scope: string | undefined, + results: SourceRecord[], + limit?: number +): string { + return JSON.stringify({ + success: true, + query, + scope, + count: results.length, + results: results.slice(0, limit || 10).map((r) => ({ + domain: r.domain, + content: r.content, + score: Math.round((r.score ?? 0) * 100), + })), + }); +} diff --git a/plugin/xmem-opencode/src/services/jsonc.ts b/plugin/xmem-opencode/src/services/jsonc.ts new file mode 100644 index 0000000..b4c8c40 --- /dev/null +++ b/plugin/xmem-opencode/src/services/jsonc.ts @@ -0,0 +1,79 @@ +/** + * Strips comments from JSONC content while respecting string boundaries. + */ +export function stripJsoncComments(content: string): string { + let result = ""; + let i = 0; + let inString = false; + let inSingleLineComment = false; + let inMultiLineComment = false; + + while (i < content.length) { + const char = content[i]; + const nextChar = content[i + 1]; + + if (!inSingleLineComment && !inMultiLineComment) { + if (char === '"') { + let backslashCount = 0; + let j = i - 1; + while (j >= 0 && content[j] === "\\") { + backslashCount++; + j--; + } + if (backslashCount % 2 === 0) { + inString = !inString; + } + result += char; + i++; + continue; + } + } + + if (inString) { + result += char; + i++; + continue; + } + + if (!inSingleLineComment && !inMultiLineComment) { + if (char === "/" && nextChar === "/") { + inSingleLineComment = true; + i += 2; + continue; + } + + if (char === "/" && nextChar === "*") { + inMultiLineComment = true; + i += 2; + continue; + } + } + + if (inSingleLineComment) { + if (char === "\n") { + inSingleLineComment = false; + result += char; + } + i++; + continue; + } + + if (inMultiLineComment) { + if (char === "*" && nextChar === "/") { + inMultiLineComment = false; + i += 2; + continue; + } + if (char === "\n") { + result += char; + } + i++; + continue; + } + + result += char; + i++; + } + + return result.replace(/,\s*([}\]])/g, "$1"); +} diff --git a/plugin/xmem-opencode/src/services/logger.ts b/plugin/xmem-opencode/src/services/logger.ts new file mode 100644 index 0000000..f730116 --- /dev/null +++ b/plugin/xmem-opencode/src/services/logger.ts @@ -0,0 +1,15 @@ +import { appendFileSync, writeFileSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; + +const LOG_FILE = join(homedir(), ".opencode-xmem.log"); + +writeFileSync(LOG_FILE, `\n--- Session started: ${new Date().toISOString()} ---\n`, { flag: "a" }); + +export function log(message: string, data?: unknown) { + const timestamp = new Date().toISOString(); + const line = data + ? `[${timestamp}] ${message}: ${JSON.stringify(data)}\n` + : `[${timestamp}] ${message}\n`; + appendFileSync(LOG_FILE, line); +} diff --git a/plugin/xmem-opencode/src/services/privacy.ts b/plugin/xmem-opencode/src/services/privacy.ts new file mode 100644 index 0000000..885e858 --- /dev/null +++ b/plugin/xmem-opencode/src/services/privacy.ts @@ -0,0 +1,12 @@ +export function containsPrivateTag(content: string): boolean { + return /[\s\S]*?<\/private>/i.test(content); +} + +export function stripPrivateContent(content: string): string { + return content.replace(/[\s\S]*?<\/private>/gi, "[REDACTED]"); +} + +export function isFullyPrivate(content: string): boolean { + const stripped = stripPrivateContent(content).trim(); + return stripped === "[REDACTED]" || stripped === ""; +} diff --git a/plugin/xmem-opencode/src/services/tags.ts b/plugin/xmem-opencode/src/services/tags.ts new file mode 100644 index 0000000..f0c0e53 --- /dev/null +++ b/plugin/xmem-opencode/src/services/tags.ts @@ -0,0 +1,33 @@ +import { createHash } from "node:crypto"; +import { CONFIG, XMEM_USERNAME } from "../config.js"; + +function sha256(input: string): string { + return createHash("sha256").update(input).digest("hex").slice(0, 16); +} + +export function getUserId(): string { + return XMEM_USERNAME || "anonymous"; +} + +export function getProjectUserId(directory: string): string { + const username = XMEM_USERNAME || "anonymous"; + return `${username}_project_${sha256(directory)}`; +} + +export function getUserIds(directory: string): { user: string; project: string } { + return { + user: getUserId(), + project: getProjectUserId(directory), + }; +} + +export function resolveUserId(scope: "user" | "project", directory: string): string { + const ids = getUserIds(directory); + return scope === "user" ? ids.user : ids.project; +} + +export function getTags(directory: string): { user: string; project: string } { + return getUserIds(directory); +} + +export { CONFIG }; diff --git a/plugin/xmem-opencode/src/types/index.ts b/plugin/xmem-opencode/src/types/index.ts new file mode 100644 index 0000000..eca36cb --- /dev/null +++ b/plugin/xmem-opencode/src/types/index.ts @@ -0,0 +1,9 @@ +export type MemoryScope = "user" | "project"; + +export type MemoryType = + | "project-config" + | "architecture" + | "error-solution" + | "preference" + | "learned-pattern" + | "conversation"; diff --git a/plugin/xmem-opencode/tsconfig.json b/plugin/xmem-opencode/tsconfig.json new file mode 100644 index 0000000..5ea1402 --- /dev/null +++ b/plugin/xmem-opencode/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": false, + "verbatimModuleSyntax": true, + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From 21da785e1f1e784fd41da4a23b4b741c9eaa342e Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Sat, 30 May 2026 14:19:46 +0530 Subject: [PATCH 3/9] Address plugin review feedback --- plugin/xmem-hermes/src/cli.js | 42 +++++++++++++++++++++++++---- plugin/xmem-hermes/test/cli.test.js | 35 ++++++++++++++++++++++++ plugin/xmem-openclaw/client.ts | 5 ++-- plugin/xmem-openclaw/config.ts | 5 ++-- 4 files changed, 78 insertions(+), 9 deletions(-) diff --git a/plugin/xmem-hermes/src/cli.js b/plugin/xmem-hermes/src/cli.js index b7ce3fd..5b8bd1f 100644 --- a/plugin/xmem-hermes/src/cli.js +++ b/plugin/xmem-hermes/src/cli.js @@ -20,19 +20,37 @@ function parseArgs(argv) { const options = { command, dryRun: false, + force: false, apiUrl: process.env.XMEM_API_URL || "https://api.xmem.in", }; for (let i = 1; i < argv.length; i += 1) { const arg = argv[i]; if (arg === "--dry-run") options.dryRun = true; - else if (arg === "--config-root") options.configRoot = argv[++i]; - else if (arg === "--api-url") options.apiUrl = argv[++i]; - else if (arg === "--mcp-command") options.mcpCommand = argv[++i]; + else if (arg === "--force") options.force = true; + else if (arg === "--config-root") { + options.configRoot = readValue(argv, i, arg); + i += 1; + } else if (arg === "--api-url") { + options.apiUrl = readValue(argv, i, arg); + i += 1; + } else if (arg === "--mcp-command") { + options.mcpCommand = readValue(argv, i, arg); + i += 1; + } else if (arg === "--help" || arg === "-h") options.command = "help"; } return options; } +function readValue(argv, index, arg) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + console.error(arg + " requires a value."); + process.exit(1); + } + return value; +} + function configRoot(options) { return options.configRoot || homedir(); } @@ -100,8 +118,21 @@ function memoryInstructions() { function install(options) { const root = configRoot(options); - write(join(root, ".hermes", "config.yaml"), yamlConfig(options), options.dryRun); - write(join(root, "HERMES.md"), memoryInstructions(), options.dryRun); + const targets = [ + [join(root, ".hermes", "config.yaml"), yamlConfig(options)], + [join(root, "HERMES.md"), memoryInstructions()], + ]; + if (!options.force && !options.dryRun) { + const existing = targets.map(([filePath]) => filePath).filter((filePath) => existsSync(filePath)); + if (existing.length > 0) { + console.error("Refusing to overwrite existing file(s): " + existing.join(", ")); + console.error("Re-run with --force to replace them."); + process.exit(1); + } + } + for (const [filePath, content] of targets) { + write(filePath, content, options.dryRun); + } console.log(CONNECTOR.display + " connector install complete."); console.log("Keep XMEM_API_KEY in your environment; it was not copied into generated files."); } @@ -170,6 +201,7 @@ Options: --config-root Write config under a custom root --api-url XMem API URL --mcp-command MCP launch command, default: uvx xmem-mcp + --force Replace existing generated files --dry-run Print intended writes `); } diff --git a/plugin/xmem-hermes/test/cli.test.js b/plugin/xmem-hermes/test/cli.test.js index c780bea..143a067 100644 --- a/plugin/xmem-hermes/test/cli.test.js +++ b/plugin/xmem-hermes/test/cli.test.js @@ -35,3 +35,38 @@ test("installer writes config without copying secret values", () => { rmSync(root, { recursive: true, force: true }); } }); + +test("installer refuses to overwrite existing files unless forced", () => { + const root = mkdtempSync(join(tmpdir(), "xmem-hermes-")); + try { + const first = spawnSync(process.execPath, ["src/cli.js", "install", "--config-root", root], { + cwd: process.cwd(), + encoding: "utf8", + }); + assert.equal(first.status, 0, first.stderr); + + const second = spawnSync(process.execPath, ["src/cli.js", "install", "--config-root", root], { + cwd: process.cwd(), + encoding: "utf8", + }); + assert.notEqual(second.status, 0); + assert.match(second.stderr, /Refusing to overwrite/); + + const forced = spawnSync(process.execPath, ["src/cli.js", "install", "--config-root", root, "--force"], { + cwd: process.cwd(), + encoding: "utf8", + }); + assert.equal(forced.status, 0, forced.stderr); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("installer reports missing option values", () => { + const result = spawnSync(process.execPath, ["src/cli.js", "install", "--api-url"], { + cwd: process.cwd(), + encoding: "utf8", + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /--api-url requires a value/); +}); diff --git a/plugin/xmem-openclaw/client.ts b/plugin/xmem-openclaw/client.ts index cd68600..a557807 100644 --- a/plugin/xmem-openclaw/client.ts +++ b/plugin/xmem-openclaw/client.ts @@ -33,11 +33,12 @@ export class XMemClient { body: JSON.stringify(payload), }) + const text = await response.text() let body: any try { - body = await response.json() + body = JSON.parse(text) } catch { - body = { error: await response.text() } + body = { error: text } } if (!response.ok || body?.status === "error") { diff --git a/plugin/xmem-openclaw/config.ts b/plugin/xmem-openclaw/config.ts index 07fc5fb..a1caf2c 100644 --- a/plugin/xmem-openclaw/config.ts +++ b/plugin/xmem-openclaw/config.ts @@ -40,8 +40,9 @@ function safeUsername(): string { } } -export function parseConfig(raw: Record = {}): XMemOpenClawConfig { - if (raw && typeof raw === "object" && Object.keys(raw).length > 0) { +export function parseConfig(rawConfig: Record | null | undefined = {}): XMemOpenClawConfig { + const raw = rawConfig ?? {} + if (Object.keys(raw).length > 0) { assertAllowedKeys(raw) } From 56be9b21e9a10381a970106472b473f0c0d30853 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Sat, 30 May 2026 14:28:09 +0530 Subject: [PATCH 4/9] Harden OpenClaw plugin error handling --- plugin/xmem-openclaw/commands/slash.ts | 23 +++++++++++++++++------ plugin/xmem-openclaw/hooks/capture.ts | 17 +++++++++++------ plugin/xmem-openclaw/hooks/recall.ts | 18 ++++++++++++------ plugin/xmem-openclaw/index.ts | 22 +++++++++++----------- plugin/xmem-openclaw/memory.ts | 4 +++- 5 files changed, 54 insertions(+), 30 deletions(-) diff --git a/plugin/xmem-openclaw/commands/slash.ts b/plugin/xmem-openclaw/commands/slash.ts index ace7d88..29fe176 100644 --- a/plugin/xmem-openclaw/commands/slash.ts +++ b/plugin/xmem-openclaw/commands/slash.ts @@ -1,5 +1,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk" import type { XMemClient } from "../client.ts" +import { log } from "../logger.ts" import { detectCategory } from "../memory.ts" export function registerStubCommands(api: OpenClawPluginApi): void { @@ -28,9 +29,14 @@ export function registerCommands(api: OpenClawPluginApi, client: XMemClient): vo handler: async (ctx: { args?: string }) => { const text = ctx.args?.trim() if (!text) return { text: "Usage: /remember " } - await client.addMemory(text, { type: detectCategory(text), source: "openclaw_command" }) - const preview = text.length > 60 ? `${text.slice(0, 60)}...` : text - return { text: `Remembered in XMem: "${preview}"` } + try { + await client.addMemory(text, { type: detectCategory(text), source: "openclaw_command" }) + const preview = text.length > 60 ? `${text.slice(0, 60)}...` : text + return { text: `Remembered in XMem: "${preview}"` } + } catch (error) { + log.warn("xmem: /remember failed", error) + return { text: "Failed to save memory to XMem. Check plugin logs for details." } + } }, }) api.registerCommand({ @@ -41,9 +47,14 @@ export function registerCommands(api: OpenClawPluginApi, client: XMemClient): vo handler: async (ctx: { args?: string }) => { const query = ctx.args?.trim() if (!query) return { text: "Usage: /recall " } - const results = await client.search(query, 8) - if (!results.length) return { text: `No XMem memories found for: "${query}"` } - return { text: `Found ${results.length} XMem memories:\n\n${results.map((r, i) => `${i + 1}. ${r.content || ""}`).join("\n")}` } + try { + const results = await client.search(query, 8) + if (!results.length) return { text: `No XMem memories found for: "${query}"` } + return { text: `Found ${results.length} XMem memories:\n\n${results.map((r, i) => `${i + 1}. ${r.content || ""}`).join("\n")}` } + } catch (error) { + log.warn("xmem: /recall failed", error) + return { text: "Failed to search XMem memories. Check plugin logs for details." } + } }, }) } diff --git a/plugin/xmem-openclaw/hooks/capture.ts b/plugin/xmem-openclaw/hooks/capture.ts index 42a0ac0..dc718e7 100644 --- a/plugin/xmem-openclaw/hooks/capture.ts +++ b/plugin/xmem-openclaw/hooks/capture.ts @@ -1,13 +1,18 @@ import type { XMemClient } from "../client.ts" +import { log } from "../logger.ts" import { detectCategory } from "../memory.ts" export function buildCaptureHandler(client: XMemClient) { return async (event: Record) => { - const text = String(event.text || event.output || event.message || "") - if (text.trim().length < 80) return - await client.addMemory(text, { - type: detectCategory(text), - source: "openclaw_auto_capture", - }) + try { + const text = String(event.text || event.output || event.message || "") + if (text.trim().length < 80) return + await client.addMemory(text, { + type: detectCategory(text), + source: "openclaw_auto_capture", + }) + } catch (error) { + log.warn("xmem: auto-capture failed", error) + } } } diff --git a/plugin/xmem-openclaw/hooks/recall.ts b/plugin/xmem-openclaw/hooks/recall.ts index 87df914..7236a5a 100644 --- a/plugin/xmem-openclaw/hooks/recall.ts +++ b/plugin/xmem-openclaw/hooks/recall.ts @@ -1,14 +1,20 @@ import type { XMemClient } from "../client.ts" import type { XMemOpenClawConfig } from "../config.ts" +import { log } from "../logger.ts" export function buildRecallHandler(client: XMemClient, cfg: XMemOpenClawConfig) { return async (event: Record) => { - const prompt = String(event.prompt || event.input || event.message || "") - if (!prompt.trim()) return - const results = await client.search(prompt, cfg.maxRecallResults) - if (!results.length) return - return { - additionalContext: `\n${results.map((r, i) => `${i + 1}. ${r.content || ""}`).join("\n\n")}\n`, + try { + const prompt = String(event.prompt || event.input || event.message || "") + if (!prompt.trim()) return + const results = await client.search(prompt, cfg.maxRecallResults) + if (!results.length) return + return { + additionalContext: `\n${results.map((r, i) => `${i + 1}. ${r.content || ""}`).join("\n\n")}\n`, + } + } catch (error) { + log.debug("xmem: auto-recall failed", error) + return } } } diff --git a/plugin/xmem-openclaw/index.ts b/plugin/xmem-openclaw/index.ts index 5902974..f81589a 100644 --- a/plugin/xmem-openclaw/index.ts +++ b/plugin/xmem-openclaw/index.ts @@ -14,14 +14,16 @@ import { registerSearchTool } from "./tools/search.ts" import { registerStatusTool } from "./tools/status.ts" import { registerStoreTool } from "./tools/store.ts" -try { - const stateDir = process.env.OPENCLAW_STATE_DIR || path.join(os.homedir(), ".openclaw") - const storePath = path.join(stateDir, "memory", "main.sqlite") - if (!fs.existsSync(storePath)) { - fs.mkdirSync(path.dirname(storePath), { recursive: true }) - fs.writeFileSync(storePath, "") - } -} catch {} +function ensureOpenClawMemoryStore(): void { + try { + const stateDir = process.env.OPENCLAW_STATE_DIR || path.join(os.homedir(), ".openclaw") + const storePath = path.join(stateDir, "memory", "main.sqlite") + if (!fs.existsSync(storePath)) { + fs.mkdirSync(path.dirname(storePath), { recursive: true }) + fs.writeFileSync(storePath, "") + } + } catch {} +} export default { id: "xmem-openclaw", @@ -33,6 +35,7 @@ export default { register(api: OpenClawPluginApi) { const cfg = parseConfig(api.pluginConfig) initLogger(api.logger, cfg.debug) + ensureOpenClawMemoryStore() if (!cfg.apiKey) { registerCli(api) @@ -60,9 +63,6 @@ export default { registerSearchTool(api, client) registerStoreTool(api, client) registerStatusTool(api, client) - registerSearchTool(api, client, "xmem-search") - registerStoreTool(api, client, "xmem-save") - registerStatusTool(api, client, "xmem-status") if (cfg.autoRecall) api.on("before_prompt_build", buildRecallHandler(client, cfg)) if (cfg.autoCapture) api.on("agent_end", buildCaptureHandler(client)) diff --git a/plugin/xmem-openclaw/memory.ts b/plugin/xmem-openclaw/memory.ts index 6741497..f0430c1 100644 --- a/plugin/xmem-openclaw/memory.ts +++ b/plugin/xmem-openclaw/memory.ts @@ -23,7 +23,9 @@ export function redactSecrets(text: string): string { return String(text || "") .replace(/xmem_[A-Za-z0-9_-]{12,}/g, "[redacted-xmem-key]") .replace(/sk-[A-Za-z0-9_-]{16,}/g, "[redacted-api-key]") - .replace(/(api[_-]?key|authorization|bearer|token)(\s*[:=]\s*)([^\s"'`]+)/gi, "$1$2[redacted]") + .replace(/(authorization\s*[:=]\s*bearer\s+)[^\s"'`]+/gi, "$1[redacted]") + .replace(/(bearer\s+)[^\s"'`]+/gi, "$1[redacted]") + .replace(/((?:api[_-]?key|authorization|token)\s*[:=]\s*)[^\s"'`]+/gi, "$1[redacted]") } export function truncate(text: string, limit = 12000): string { From edd020b51241ce3f2d2b7d801506cac5278c2e54 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Sat, 30 May 2026 14:36:53 +0530 Subject: [PATCH 5/9] Add Cursor XMem client helper --- .../xmem-cursor/scripts/lib/xmem-client.cjs | 116 ++++++++++++++++++ plugin/xmem-cursor/scripts/mcp-server.cjs | 3 +- 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 plugin/xmem-cursor/scripts/lib/xmem-client.cjs diff --git a/plugin/xmem-cursor/scripts/lib/xmem-client.cjs b/plugin/xmem-cursor/scripts/lib/xmem-client.cjs new file mode 100644 index 0000000..f561435 --- /dev/null +++ b/plugin/xmem-cursor/scripts/lib/xmem-client.cjs @@ -0,0 +1,116 @@ +const os = require("node:os"); +const path = require("node:path"); + +const DEFAULT_API_URL = "https://api.xmem.in"; + +function config() { + const userId = (() => { + try { + return os.userInfo().username || "cursor"; + } catch { + return "cursor"; + } + })(); + + return { + apiKey: process.env.XMEM_API_KEY || process.env.XMEM_CURSOR_API_KEY || "", + apiUrl: String(process.env.XMEM_API_URL || process.env.XMEM_CURSOR_API_URL || DEFAULT_API_URL).replace(/\/+$/, ""), + userId: process.env.XMEM_USER_ID || process.env.XMEM_CURSOR_USER_ID || userId, + }; +} + +function projectName(cwd = process.cwd()) { + return path.basename(cwd || process.cwd()) || "cursor-project"; +} + +function redactSecrets(text) { + return String(text || "") + .replace(/xmem_[A-Za-z0-9_-]{12,}/g, "[redacted-xmem-key]") + .replace(/sk-[A-Za-z0-9_-]{16,}/g, "[redacted-api-key]") + .replace(/(authorization\s*[:=]\s*bearer\s+)[^\s"'`]+/gi, "$1[redacted]") + .replace(/(bearer\s+)[^\s"'`]+/gi, "$1[redacted]") + .replace(/((?:api[_-]?key|authorization|token)\s*[:=]\s*)[^\s"'`]+/gi, "$1[redacted]"); +} + +function truncate(text, limit = 12000) { + const value = String(text || "").trim(); + if (value.length <= limit) return value; + return `${value.slice(0, limit)}\n\n[truncated]`; +} + +async function request(pathname, payload) { + const cfg = config(); + if (!cfg.apiKey) { + throw new Error("XMEM_API_KEY is not configured."); + } + + const response = await fetch(`${cfg.apiUrl}${pathname}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${cfg.apiKey}`, + }, + body: JSON.stringify(payload), + }); + + const text = await response.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = { error: text }; + } + + if (!response.ok || body?.status === "error") { + throw new Error(body?.error || body?.detail || `XMem request failed with HTTP ${response.status}`); + } + + return body?.data ?? body; +} + +function searchMemory(query, options = {}) { + const cfg = config(); + return request("/v1/memory/search", { + query: redactSecrets(query), + user_id: cfg.userId, + top_k: options.limit || options.topK || 8, + domains: ["profile", "temporal", "summary"], + }); +} + +function addMemory(content, metadata = {}) { + const cfg = config(); + return request("/v1/memory/ingest", { + user_query: truncate(redactSecrets(content)), + agent_response: "", + user_id: cfg.userId, + session_datetime: new Date().toISOString(), + effort_level: "low", + metadata, + }); +} + +function formatResults(data) { + const results = data?.results || []; + if (!results.length) return "No XMem memories matched."; + + return results + .slice(0, 10) + .map((item, index) => { + const score = typeof item.score === "number" ? ` score=${item.score.toFixed(3)}` : ""; + const domain = item.domain ? ` domain=${item.domain}` : ""; + return `${index + 1}.${domain}${score}\n${item.content || ""}`.trim(); + }) + .join("\n\n"); +} + +module.exports = { + DEFAULT_API_URL, + addMemory, + config, + formatResults, + projectName, + redactSecrets, + searchMemory, + truncate, +}; diff --git a/plugin/xmem-cursor/scripts/mcp-server.cjs b/plugin/xmem-cursor/scripts/mcp-server.cjs index 1d250e7..30c1619 100644 --- a/plugin/xmem-cursor/scripts/mcp-server.cjs +++ b/plugin/xmem-cursor/scripts/mcp-server.cjs @@ -113,7 +113,8 @@ process.stdin.on("data", (chunk) => { buffer += chunk; const lines = buffer.split(/\r?\n/); buffer = lines.pop() || ""; - for (const line of lines) { + for (const rawLine of lines) { + const line = rawLine.replace(/^\uFEFF/, ""); if (!line.trim()) continue; try { handle(JSON.parse(line)); From c67a032a916fac0a372a6452d6214395728fb075 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Sat, 30 May 2026 14:43:41 +0530 Subject: [PATCH 6/9] Avoid shell interpolation in OpenCode auth --- plugin/xmem-opencode/src/services/auth.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/plugin/xmem-opencode/src/services/auth.ts b/plugin/xmem-opencode/src/services/auth.ts index 7d3470a..cc7f78d 100644 --- a/plugin/xmem-opencode/src/services/auth.ts +++ b/plugin/xmem-opencode/src/services/auth.ts @@ -1,6 +1,6 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { exec } from "node:child_process"; +import { execFile } from "node:child_process"; import { join } from "node:path"; import { homedir } from "node:os"; @@ -43,18 +43,13 @@ export function clearCredentials(): boolean { } function openBrowser(url: string): void { - const platform = process.platform; + const platform = process.platform; - const commands: Record = { - darwin: `open "${url}"`, - win32: `start "" "${url}"`, - linux: `xdg-open "${url}"`, - }; - - const cmd = commands[platform] ?? `xdg-open "${url}"`; - exec(cmd, (err) => { - if (err) console.error("Failed to open browser:", err.message); - }); + const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"; + const args = platform === "win32" ? ["/c", "start", "", url] : [url]; + execFile(command, args, (err) => { + if (err) console.error("Failed to open browser:", err.message); + }); } export interface AuthResult { From 9b2fe9213b90e9531db0c43ac99ae9d8e0336b7f Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Sat, 30 May 2026 14:47:52 +0530 Subject: [PATCH 7/9] Clean up failed OpenCode summaries --- plugin/xmem-opencode/src/services/compaction.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/plugin/xmem-opencode/src/services/compaction.ts b/plugin/xmem-opencode/src/services/compaction.ts index 12b1c49..d7545f9 100644 --- a/plugin/xmem-opencode/src/services/compaction.ts +++ b/plugin/xmem-opencode/src/services/compaction.ts @@ -438,6 +438,7 @@ export function createCompactionHook( }, 500); } catch (err) { log("[compaction] compaction failed", { sessionID, error: String(err) }); + state.summarizedSessions.delete(sessionID); state.compactionInProgress.delete(sessionID); } } From 090a92a0f2f2b643a9abc8d2b65f51723ab8f581 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Sat, 30 May 2026 15:03:57 +0530 Subject: [PATCH 8/9] Add missing Claude and Codex plugin helpers --- .../xmem-claude/scripts/lib/plugin-utils.cjs | 120 ++++++++++++++++++ .../xmem-claude/scripts/lib/xmem-client.cjs | 99 +++++++++++++++ plugin/xmem-codex/scripts/lib/xmem-client.cjs | 110 ++++++++++++++++ 3 files changed, 329 insertions(+) create mode 100644 plugin/xmem-claude/scripts/lib/plugin-utils.cjs create mode 100644 plugin/xmem-claude/scripts/lib/xmem-client.cjs create mode 100644 plugin/xmem-codex/scripts/lib/xmem-client.cjs diff --git a/plugin/xmem-claude/scripts/lib/plugin-utils.cjs b/plugin/xmem-claude/scripts/lib/plugin-utils.cjs new file mode 100644 index 0000000..64768d7 --- /dev/null +++ b/plugin/xmem-claude/scripts/lib/plugin-utils.cjs @@ -0,0 +1,120 @@ +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const MAX_TEXT_LENGTH = 12000; + +function readStdin() { + return new Promise((resolve) => { + let data = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + data += chunk; + }); + process.stdin.on("end", () => { + if (!data.trim()) { + resolve({}); + return; + } + try { + resolve(JSON.parse(data)); + } catch { + resolve({}); + } + }); + }); +} + +function writeJson(value) { + process.stdout.write(`${JSON.stringify(value)}\n`); +} + +function projectName(cwd = process.cwd()) { + return path.basename(path.resolve(cwd)); +} + +function projectConfigPath(cwd = process.cwd()) { + return path.join(path.resolve(cwd), ".claude", ".xmem-claude", "config.json"); +} + +function globalConfigPath() { + return path.join(os.homedir(), ".xmem-claude", "settings.json"); +} + +function readJsonFile(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return {}; + } +} + +function loadConfig(cwd = process.cwd()) { + return { + ...readJsonFile(globalConfigPath()), + ...readJsonFile(projectConfigPath(cwd)), + }; +} + +function truncate(text, limit = MAX_TEXT_LENGTH) { + const value = String(text || "").trim(); + if (value.length <= limit) return value; + return `${value.slice(0, limit)}\n\n[truncated]`; +} + +function redactSecrets(text) { + return String(text || "") + .replace(/xmem_[A-Za-z0-9_-]{12,}/g, "[redacted-xmem-key]") + .replace(/sk-[A-Za-z0-9_-]{16,}/g, "[redacted-api-key]") + .replace(/(authorization\s*[:=]\s*bearer\s+)[^\s"'`]+/gi, "$1[redacted]") + .replace(/(bearer\s+)[^\s"'`]+/gi, "$1[redacted]") + .replace(/((?:api[_-]?key|authorization|token)\s*[:=]\s*)[^\s"'`]+/gi, "$1[redacted]"); +} + +function extractText(value) { + if (!value) return ""; + if (typeof value === "string") return value; + if (Array.isArray(value)) return value.map(extractText).filter(Boolean).join("\n"); + if (typeof value === "object") { + if (typeof value.text === "string") return value.text; + if (typeof value.content === "string") return value.content; + if (value.content) return extractText(value.content); + if (value.message) return extractText(value.message); + } + return ""; +} + +function transcriptTail(transcriptPath, sessionId, cwd) { + if (!transcriptPath || !fs.existsSync(transcriptPath)) return ""; + const lines = fs.readFileSync(transcriptPath, "utf8").split(/\r?\n/).filter(Boolean); + const entries = []; + + for (const line of lines) { + try { + const item = JSON.parse(line); + const role = item.type || item.role || item.message?.role || "entry"; + const text = extractText(item.message || item.content || item); + if (text.trim()) entries.push(`${role}: ${text.trim()}`); + } catch { + if (line.trim()) entries.push(line.trim()); + } + } + + const body = entries.slice(-40).join("\n\n"); + if (!body.trim()) return ""; + + return truncate(redactSecrets(`[Claude Code session]\nProject: ${projectName(cwd)}\nSession: ${sessionId || "unknown"}\n\n${body}`)); +} + +module.exports = { + extractText, + globalConfigPath, + loadConfig, + projectConfigPath, + projectName, + readStdin, + redactSecrets, + transcriptTail, + truncate, + writeJson, +}; diff --git a/plugin/xmem-claude/scripts/lib/xmem-client.cjs b/plugin/xmem-claude/scripts/lib/xmem-client.cjs new file mode 100644 index 0000000..29aa8c7 --- /dev/null +++ b/plugin/xmem-claude/scripts/lib/xmem-client.cjs @@ -0,0 +1,99 @@ +const os = require("node:os"); +const { loadConfig } = require("./plugin-utils.cjs"); + +const DEFAULT_API_URL = "https://api.xmem.in"; + +class XMemClient { + constructor(options = {}) { + this.apiUrl = String(options.apiUrl || DEFAULT_API_URL).replace(/\/+$/, ""); + this.apiKey = options.apiKey; + this.userId = options.userId; + } + + async request(path, payload) { + if (!this.apiKey) { + throw new Error("XMEM_API_KEY is not configured."); + } + + const response = await fetch(`${this.apiUrl}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify(payload), + }); + + const text = await response.text(); + let body = null; + try { + body = JSON.parse(text); + } catch { + body = { error: text }; + } + + if (!response.ok || body?.status === "error") { + throw new Error(body?.error || body?.detail || `XMem request failed with HTTP ${response.status}`); + } + + return body?.data ?? body; + } + + ingest(content, metadata = {}) { + return this.request("/v1/memory/ingest", { + user_query: content, + agent_response: "", + user_id: this.userId, + session_datetime: new Date().toISOString(), + effort_level: "low", + metadata, + }); + } + + search(query, topK = 8) { + return this.request("/v1/memory/search", { + query, + user_id: this.userId, + top_k: topK, + domains: ["profile", "temporal", "summary"], + }); + } +} + +function createClient(cwd = process.cwd()) { + const config = loadConfig(cwd); + const userInfo = (() => { + try { + return os.userInfo().username; + } catch { + return "claude-code"; + } + })(); + + return new XMemClient({ + apiKey: process.env.XMEM_API_KEY || process.env.XMEM_CLAUDE_API_KEY || config.apiKey, + apiUrl: process.env.XMEM_API_URL || process.env.XMEM_CLAUDE_API_URL || config.apiUrl || DEFAULT_API_URL, + userId: process.env.XMEM_USER_ID || process.env.XMEM_CLAUDE_USER_ID || config.userId || userInfo || "claude-code", + }); +} + +function formatResults(data) { + const results = data?.results || []; + if (!results.length) return "No XMem memories matched."; + + return results + .slice(0, 8) + .map((item, index) => { + const score = typeof item.score === "number" ? ` score=${item.score.toFixed(3)}` : ""; + const domain = item.domain ? ` domain=${item.domain}` : ""; + return `${index + 1}.${domain}${score}\n${item.content || ""}`.trim(); + }) + .join("\n\n"); +} + +module.exports = { + DEFAULT_API_URL, + XMemClient, + createClient, + formatResults, +}; diff --git a/plugin/xmem-codex/scripts/lib/xmem-client.cjs b/plugin/xmem-codex/scripts/lib/xmem-client.cjs new file mode 100644 index 0000000..ce5d1e9 --- /dev/null +++ b/plugin/xmem-codex/scripts/lib/xmem-client.cjs @@ -0,0 +1,110 @@ +const os = require("node:os"); + +const DEFAULT_API_URL = "https://api.xmem.in"; + +function config() { + return { + apiKey: process.env.XMEM_API_KEY || process.env.XMEM_CODEX_API_KEY || "", + apiUrl: (process.env.XMEM_API_URL || process.env.XMEM_CODEX_API_URL || DEFAULT_API_URL).replace(/\/+$/, ""), + userId: process.env.XMEM_USER_ID || process.env.XMEM_CODEX_USER_ID || safeUsername(), + }; +} + +function safeUsername() { + try { + return os.userInfo().username || "codex"; + } catch { + return "codex"; + } +} + +function redactSecrets(text) { + return String(text || "") + .replace(/xmem_[A-Za-z0-9_-]{12,}/g, "[redacted-xmem-key]") + .replace(/sk-[A-Za-z0-9_-]{16,}/g, "[redacted-api-key]") + .replace(/(authorization\s*[:=]\s*bearer\s+)[^\s"'`]+/gi, "$1[redacted]") + .replace(/(bearer\s+)[^\s"'`]+/gi, "$1[redacted]") + .replace(/((?:api[_-]?key|authorization|token)\s*[:=]\s*)[^\s"'`]+/gi, "$1[redacted]"); +} + +function truncate(text, limit = 12000) { + const value = String(text || "").trim(); + if (value.length <= limit) return value; + return `${value.slice(0, limit)}\n\n[truncated]`; +} + +async function request(path, payload) { + const cfg = config(); + if (!cfg.apiKey) { + throw new Error("XMEM_API_KEY is not configured."); + } + + const response = await fetch(`${cfg.apiUrl}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${cfg.apiKey}`, + }, + body: JSON.stringify(payload), + }); + + const text = await response.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = { error: text }; + } + + if (!response.ok || body?.status === "error") { + throw new Error(body?.error || body?.detail || `XMem request failed with HTTP ${response.status}`); + } + + return body?.data ?? body; +} + +async function searchMemory(query, topK = 10) { + const cfg = config(); + return request("/v1/memory/search", { + query: redactSecrets(query), + user_id: cfg.userId, + top_k: topK, + domains: ["profile", "temporal", "summary"], + }); +} + +async function saveMemory(content, metadata = {}) { + const cfg = config(); + return request("/v1/memory/ingest", { + user_query: truncate(redactSecrets(content)), + agent_response: "", + user_id: cfg.userId, + session_datetime: new Date().toISOString(), + effort_level: "low", + metadata, + }); +} + +function formatResults(data) { + const results = data?.results || []; + if (!results.length) return "No XMem memories matched."; + + return results + .slice(0, 10) + .map((item, index) => { + const score = typeof item.score === "number" ? ` score=${item.score.toFixed(3)}` : ""; + const domain = item.domain ? ` domain=${item.domain}` : ""; + return `${index + 1}.${domain}${score}\n${item.content || ""}`.trim(); + }) + .join("\n\n"); +} + +module.exports = { + DEFAULT_API_URL, + config, + formatResults, + redactSecrets, + saveMemory, + searchMemory, + truncate, +}; From 12e3387ab1603cf91cce937325945b3aadfd3d0e Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Sat, 30 May 2026 15:14:46 +0530 Subject: [PATCH 9/9] Fix OpenCode recall and remove inert release workflow --- .../.github/workflows/release.yml | 59 ------------------- plugin/xmem-opencode/src/index.ts | 9 ++- 2 files changed, 6 insertions(+), 62 deletions(-) delete mode 100644 plugin/xmem-opencode/.github/workflows/release.yml diff --git a/plugin/xmem-opencode/.github/workflows/release.yml b/plugin/xmem-opencode/.github/workflows/release.yml deleted file mode 100644 index 541c312..0000000 --- a/plugin/xmem-opencode/.github/workflows/release.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Publish Package - -on: - push: - branches: - - main - paths: - - "package.json" - -jobs: - publish: - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: '24' - registry-url: 'https://registry.npmjs.org' - - - name: Upgrade npm for trusted publishing support - run: npm install -g npm@latest - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - - - name: Install dependencies - run: bun install - - - name: Check if version changed - id: version-check - run: | - PACKAGE_NAME=$(jq -r '.name' package.json) - LOCAL_VERSION=$(jq -r '.version' package.json) - NPM_VERSION=$(npm view "$PACKAGE_NAME" version 2>/dev/null || echo "0.0.0") - if [ "$LOCAL_VERSION" = "$NPM_VERSION" ]; then - echo "Version $LOCAL_VERSION already published, skipping." - echo "changed=false" >> "$GITHUB_OUTPUT" - else - echo "Publishing $LOCAL_VERSION (npm has $NPM_VERSION)" - echo "changed=true" >> "$GITHUB_OUTPUT" - fi - - - name: Type check - if: steps.version-check.outputs.changed == 'true' - run: bun run typecheck - - - name: Build - if: steps.version-check.outputs.changed == 'true' - run: bun run build - - - name: Publish - if: steps.version-check.outputs.changed == 'true' - run: npm publish --access public --provenance diff --git a/plugin/xmem-opencode/src/index.ts b/plugin/xmem-opencode/src/index.ts index c7f979e..bb53405 100644 --- a/plugin/xmem-opencode/src/index.ts +++ b/plugin/xmem-opencode/src/index.ts @@ -108,10 +108,9 @@ export const XMemPlugin: Plugin = async (ctx: PluginInput) => { } const isFirstMessage = !injectedSessions.has(input.sessionID); + const shouldInjectContext = CONFIG.autoRecallEveryPrompt || isFirstMessage; - if (isFirstMessage) { - injectedSessions.add(input.sessionID); - + if (shouldInjectContext) { let memoryContext = ""; if (CONFIG.autoRecallEveryPrompt) { @@ -152,6 +151,10 @@ export const XMemPlugin: Plugin = async (ctx: PluginInput) => { contextLength: memoryContext.length, }); } + + if (isFirstMessage) { + injectedSessions.add(input.sessionID); + } } } catch (error) { log("chat.message: ERROR", { error: String(error) });