chore: import upstream snapshot with attribution
CI / test (20, macos-latest) (push) Has been cancelled
CI / test (20, ubuntu-latest) (push) Has been cancelled
CI / test (22, macos-latest) (push) Has been cancelled
CI / test (22, ubuntu-latest) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:01:18 +08:00
commit 979fb22d7c
613 changed files with 113494 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
# @agentmemory/fs-watcher
Filesystem connector for agentmemory. Watches one or more directories and emits an observation to the running agentmemory server every time a file changes.
Part of the data-source-connectors effort tracked in issue #62.
## Install
```bash
npm install -g @agentmemory/fs-watcher
```
Or run without installing:
```bash
npx @agentmemory/fs-watcher ~/work/my-repo
```
## Usage
```bash
# CLI args win over env.
agentmemory-fs-watcher ~/work/my-repo ~/notes
# Or set env once in your shell.
export AGENTMEMORY_FS_WATCH_DIRS=~/work/my-repo,~/notes
export AGENTMEMORY_URL=http://localhost:3111
export AGENTMEMORY_SECRET=... # only if the server requires auth
agentmemory-fs-watcher
```
Every file change inside the watched roots becomes a `post_tool_use` observation whose `data.changeKind` is `file_change` or `file_delete`. The first 4 KB of each text file is included as `data.content` so retrieval can match by substring; larger files are truncated with `data.truncated: true`. Binary files are not read (set `AGENTMEMORY_FS_WATCH_ALLOW_BINARY=1` to override).
Session id and project are required by the observe endpoint — set them via env, or the watcher generates a per-process `fs-watcher-<ts>-<rand>` session id and uses the first root's directory name as the project.
Requires Node.js **>=20 LTS**. Recursive `fs.watch` needs Node 19.1.0+ on Linux; Node 20 is the minimum supported LTS line.
## Configuration
| Variable | Default | Meaning |
|---|---|---|
| `AGENTMEMORY_FS_WATCH_DIRS` | — | Comma-separated list of directories to watch |
| `AGENTMEMORY_FS_WATCH_IGNORE` | — | Comma-separated regex patterns to ignore (applied to relative paths) |
| `AGENTMEMORY_FS_WATCH_ALLOW_BINARY` | `0` | `1` to include binary files in the preview read |
| `AGENTMEMORY_URL` | `http://localhost:3111` | agentmemory server URL |
| `AGENTMEMORY_SECRET` | — | Bearer token, required if the server has `AGENTMEMORY_SECRET` set |
| `AGENTMEMORY_PROJECT` | — | Optional project label attached to each observation |
| `AGENTMEMORY_SESSION_ID` | — | Optional session id to attribute observations to |
## Defaults
Ignored out of the box: `.git/`, `node_modules/`, `dist/`, `build/`, `.next/`, `.turbo/`, `coverage/`, `.DS_Store`, `*.log`, `*.lock`. Extend with `AGENTMEMORY_FS_WATCH_IGNORE`.
Text extensions read for preview: common source, config, and docs (`.ts/.js/.py/.go/.rs/.md/.yaml/...`). Unknown extensions are recorded as a path-only observation without content.
Writes are debounced 500 ms per path so a stream of saves from your editor becomes a single observation.
## Notes
- Uses Node's built-in `fs.watch` with `{ recursive: true }`. Works natively on macOS, Linux, and Windows 10+. No native deps.
- If `fs.watch` errors on a specific root (permission, platform quirk), the watcher logs and continues on the others.
- The process must keep running. Use a process manager (`launchd`, `systemd`, `pm2`) to supervise it.
- This connector is intentionally one-way: it writes observations and never reads the agentmemory store.
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env node
import { FilesystemWatcher, configFromEnv } from "./watcher.mjs";
const cliArgs = process.argv.slice(2);
const envCfg = configFromEnv(process.env);
const roots = cliArgs.length > 0 ? cliArgs : envCfg.roots;
if (!roots || roots.length === 0) {
process.stderr.write(
"agentmemory-fs-watcher: no directories to watch.\n" +
"Usage: agentmemory-fs-watcher <dir> [<dir>...]\n" +
"Or set AGENTMEMORY_FS_WATCH_DIRS=path1,path2\n",
);
process.exit(2);
}
const watcher = new FilesystemWatcher({ ...envCfg, roots });
watcher.start();
process.stderr.write(
`[fs-watcher] emitting to ${envCfg.baseUrl || "http://localhost:3111"}\n`,
);
const shutdown = () => {
watcher.stop();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
@@ -0,0 +1,22 @@
{
"name": "@agentmemory/fs-watcher",
"version": "0.1.0",
"description": "Filesystem connector for agentmemory — emits observations on file changes.",
"type": "module",
"bin": {
"agentmemory-fs-watcher": "./bin.mjs"
},
"main": "./watcher.mjs",
"exports": {
".": "./watcher.mjs"
},
"files": ["watcher.mjs", "bin.mjs", "README.md"],
"engines": { "node": ">=20" },
"license": "Apache-2.0",
"homepage": "https://github.com/rohitg00/agentmemory/tree/main/integrations/filesystem-watcher",
"repository": {
"type": "git",
"url": "https://github.com/rohitg00/agentmemory.git",
"directory": "integrations/filesystem-watcher"
}
}
+317
View File
@@ -0,0 +1,317 @@
import { watch, promises as fsp, statSync } from "node:fs";
import { resolve, relative, join, extname, sep, basename } from "node:path";
import { randomBytes } from "node:crypto";
const TEXT_EXTENSIONS = new Set([
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
".py", ".rb", ".go", ".rs", ".java", ".kt", ".swift",
".c", ".cc", ".cpp", ".h", ".hpp",
".md", ".mdx", ".txt", ".rst",
".json", ".yaml", ".yml", ".toml", ".ini", ".env",
".html", ".css", ".scss", ".vue", ".svelte",
".sh", ".bash", ".zsh", ".fish",
".sql", ".graphql", ".proto",
]);
const DEFAULT_IGNORE = [
/(?:^|\/)\.git(?:\/|$)/,
/(?:^|\/)node_modules(?:\/|$)/,
/(?:^|\/)dist(?:\/|$)/,
/(?:^|\/)build(?:\/|$)/,
/(?:^|\/)\.next(?:\/|$)/,
/(?:^|\/)\.turbo(?:\/|$)/,
/(?:^|\/)coverage(?:\/|$)/,
/(?:^|\/)\.DS_Store$/,
/\.log$/,
/\.lock$/,
];
const MAX_PREVIEW_BYTES = 4096;
const DEBOUNCE_MS = 500;
const REDACTED = "[REDACTED]";
const PEM_BEGIN_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
const PEM_END_RE = /-----END [A-Z ]*PRIVATE KEY-----/;
const JWT_RE = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g;
const JWT_MIN_LEN = 100;
function isDotEnvPath(path) {
const name = basename(path).toLowerCase();
return name === ".env" || name.startsWith(".env.");
}
function isSensitiveKey(key) {
const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase();
return [
"apikey",
"accesstoken",
"accesskey",
"authorization",
"bearer",
"clientsecret",
"password",
"passwd",
"privatekey",
"pwd",
"secret",
"token",
].some((needle) => normalized.includes(needle));
}
function redactJwtTokens(line) {
return line.replace(JWT_RE, (match) => (match.length >= JWT_MIN_LEN ? REDACTED : match));
}
function redactSensitiveLine(line) {
if (PEM_BEGIN_RE.test(line) || PEM_END_RE.test(line)) {
return line;
}
const assignment = line.match(
/^(\s*(?:export\s+)?["']?([A-Za-z_][A-Za-z0-9_.-]*)["']?\s*([=:])\s*)(.*)$/,
);
if (assignment && isSensitiveKey(assignment[2])) {
const bearer = assignment[3] === ":" ? assignment[4].match(/^(Bearer\s+).+/i) : null;
return `${assignment[1]}${bearer ? bearer[1] : ""}${REDACTED}`;
}
const bearerRedacted = line.replace(
/\b(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}\b/gi,
`$1${REDACTED}`,
);
return redactJwtTokens(bearerRedacted);
}
function redactPemBlocks(preview) {
const lines = preview.split("\n");
const out = [];
let inBlock = false;
for (const line of lines) {
if (!inBlock) {
const beginMatch = line.match(PEM_BEGIN_RE);
if (!beginMatch) {
out.push(line);
continue;
}
const beginIdx = beginMatch.index;
const endMatch = line.match(PEM_END_RE);
if (endMatch && endMatch.index > beginIdx) {
const before = line.slice(0, beginIdx);
const after = line.slice(endMatch.index + endMatch[0].length);
out.push(`${before}${beginMatch[0]}${REDACTED}${endMatch[0]}${after}`);
} else {
out.push(`${line.slice(0, beginIdx)}${beginMatch[0]}`);
out.push(REDACTED);
inBlock = true;
}
} else {
const endMatch = line.match(PEM_END_RE);
if (endMatch) {
out.push(`${endMatch[0]}${line.slice(endMatch.index + endMatch[0].length)}`);
inBlock = false;
}
}
}
return out.join("\n");
}
function redactSensitivePreview(preview) {
return redactPemBlocks(preview).split("\n").map(redactSensitiveLine).join("\n");
}
export class FilesystemWatcher {
constructor(config = {}) {
this.roots = (config.roots || []).map((r) => resolve(r));
this.baseUrl = (config.baseUrl || "http://localhost:3111").replace(/\/+$/, "");
this.secret = config.secret;
this.project =
config.project ||
(this.roots[0] ? basename(this.roots[0]) : "filesystem-watcher");
this.sessionId =
config.sessionId ||
`fs-watcher-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`;
this.ignore = [...DEFAULT_IGNORE, ...(config.ignorePatterns || [])];
this.allowBinary = Boolean(config.allowBinary);
this.logger = config.logger || console;
this.watchers = [];
this.pendingByPath = new Map();
}
isIgnored(path) {
return this.ignore.some((re) => re.test(path));
}
isTextFile(path) {
if (this.allowBinary) return true;
const ext = extname(path).toLowerCase();
return TEXT_EXTENSIONS.has(ext) || isDotEnvPath(path);
}
async readPreview(path) {
try {
const fh = await fsp.open(path, "r");
try {
const buf = Buffer.alloc(MAX_PREVIEW_BYTES);
const { bytesRead } = await fh.read(buf, 0, MAX_PREVIEW_BYTES, 0);
return buf.slice(0, bytesRead).toString("utf-8");
} finally {
await fh.close();
}
} catch {
return null;
}
}
async emit(event) {
const headers = { "content-type": "application/json" };
if (this.secret) headers.authorization = `Bearer ${this.secret}`;
try {
const res = await fetch(`${this.baseUrl}/agentmemory/observe`, {
method: "POST",
headers,
body: JSON.stringify(event),
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
this.logger.warn?.(
`[fs-watcher] observe ${res.status}: ${await res.text().catch(() => "")}`,
);
}
} catch (err) {
this.logger.warn?.(`[fs-watcher] observe failed: ${err?.message || err}`);
}
}
schedule(rootDir, relPath) {
const key = join(rootDir, relPath);
const existing = this.pendingByPath.get(key);
if (existing) clearTimeout(existing.timer);
const timer = setTimeout(() => {
this.pendingByPath.delete(key);
this.flush(rootDir, relPath).catch((err) =>
this.logger.warn?.(`[fs-watcher] flush failed: ${err?.message || err}`),
);
}, DEBOUNCE_MS);
this.pendingByPath.set(key, { timer });
}
async flush(rootDir, relPath) {
const absPath = join(rootDir, relPath);
if (this.isIgnored(relPath)) return;
let exists = true;
let size = 0;
try {
const st = statSync(absPath);
if (!st.isFile()) return;
size = st.size;
} catch {
exists = false;
}
const changeKind = exists ? "file_change" : "file_delete";
let preview = null;
if (exists && this.isTextFile(absPath)) {
preview = await this.readPreview(absPath);
if (preview !== null) preview = redactSensitivePreview(preview);
}
const truncated = exists && size > MAX_PREVIEW_BYTES;
const payload = {
hookType: "post_tool_use",
sessionId: this.sessionId,
project: this.project,
cwd: rootDir,
timestamp: new Date().toISOString(),
data: {
source: "filesystem-watcher",
changeKind,
files: [relPath],
content: this.formatContent(relPath, changeKind, preview, {
size,
truncated,
}),
rootDir,
absPath,
size,
truncated,
},
};
await this.emit(payload);
}
formatContent(relPath, changeKind, preview, { size, truncated }) {
if (changeKind === "file_delete") return `deleted: ${relPath}`;
const head = `${relPath} (${size} bytes${truncated ? ", truncated" : ""})`;
if (preview === null) return head;
return `${head}\n\n${preview}`;
}
start() {
if (this.roots.length === 0) {
throw new Error("filesystem-watcher: at least one root directory is required");
}
const failures = [];
for (const root of this.roots) {
try {
const handle = watch(
root,
{ recursive: true, persistent: true },
(_eventType, filename) => {
if (!filename) return;
const rel = filename.split(sep).join("/");
if (this.isIgnored(rel)) return;
this.schedule(root, rel);
},
);
handle.on("error", (err) => {
this.logger.warn?.(`[fs-watcher] watch error on ${root}: ${err?.message || err}`);
});
this.watchers.push(handle);
this.logger.info?.(`[fs-watcher] watching ${root}`);
} catch (err) {
const msg = err?.message || String(err);
failures.push(`${root}: ${msg}`);
this.logger.error?.(`[fs-watcher] failed to watch ${root}: ${msg}`);
}
}
if (this.watchers.length === 0) {
throw new Error(
`filesystem-watcher: could not watch any of the configured roots. ` +
`If you are on Node 18 + Linux, recursive fs.watch requires Node >=19.1.0; upgrade to Node 20 LTS or newer. ` +
`Failures: ${failures.join("; ")}`,
);
}
}
stop() {
for (const w of this.watchers) {
try {
w.close();
} catch {}
}
this.watchers = [];
for (const { timer } of this.pendingByPath.values()) {
clearTimeout(timer);
}
this.pendingByPath.clear();
}
}
// Small helper used by tests and bin.mjs to parse env.
export function configFromEnv(env = process.env) {
const roots = (env.AGENTMEMORY_FS_WATCH_DIRS || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const extraIgnore = (env.AGENTMEMORY_FS_WATCH_IGNORE || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((s) => new RegExp(s));
return {
roots,
baseUrl: env.AGENTMEMORY_URL,
secret: env.AGENTMEMORY_SECRET,
project: env.AGENTMEMORY_PROJECT || null,
sessionId: env.AGENTMEMORY_SESSION_ID || null,
ignorePatterns: extraIgnore,
allowBinary: env.AGENTMEMORY_FS_WATCH_ALLOW_BINARY === "1",
};
}
export { relative as _relativeForTests };
+130
View File
@@ -0,0 +1,130 @@
<p align="center">
<img src="../../assets/banner.png" alt="agentmemory" width="640" />
</p>
<h1 align="center">
<img src="https://github.com/NousResearch.png?size=80" alt="Hermes Agent" width="28" height="28" align="center" />
&nbsp;agentmemory for Hermes Agent
</h1>
<p align="center">
<strong>Your Hermes agent remembers everything. No more re-explaining.</strong><br/>
<sub>Persistent cross-session memory via <a href="https://github.com/rohitg00/agentmemory">agentmemory</a> — 95.2% retrieval accuracy on <a href="https://arxiv.org/abs/2410.10813">LongMemEval-S</a>. Cross-agent shared with Claude Code, Cursor, OpenCode, and more.</sub>
</p>
<p align="center">
<img src="https://img.shields.io/badge/MCP-43_tools-1f6feb?style=flat-square" alt="43 MCP tools" />
<img src="https://img.shields.io/badge/Hooks-6_lifecycle-1f6feb?style=flat-square" alt="6 lifecycle hooks" />
<img src="https://img.shields.io/badge/R@5-95.2%25-00875f?style=flat-square" alt="95.2% R@5" />
<img src="https://img.shields.io/badge/Self--hosted-yes-00875f?style=flat-square" alt="Self-hosted" />
<img src="https://img.shields.io/badge/License-Apache_2.0-blue?style=flat-square" alt="Apache 2.0" />
</p>
---
## Install it in 30 seconds
**Paste this prompt into Hermes** and it does the whole setup for you:
```text
Install agentmemory for Hermes. Run `npx @agentmemory/agentmemory` in a
separate terminal to start the memory server on localhost:3111. Then
add this to `~/.hermes/config.yaml` so Hermes can use agentmemory as
an MCP server with all 43 memory tools:
mcp_servers:
agentmemory:
command: npx
args: ["-y", "@agentmemory/mcp"]
memory:
provider: agentmemory
Verify it's working with
`curl http://localhost:3111/agentmemory/health` — it should return
{"status":"healthy"}. Open the real-time viewer at
http://localhost:3113 to watch memories being captured live.
If I want deeper integration — pre-LLM context injection, turn-level
capture, memory-write mirroring to MEMORY.md, and system prompt block
injection — copy `integrations/hermes` from the agentmemory repo to
`~/.hermes/plugins/agentmemory` instead. That gives me the
6-hook memory provider plugin on top of the MCP server.
```
That's it. Hermes handles the rest.
## Quick setup
### Option 1: MCP server (zero code)
Add to `~/.hermes/config.yaml`:
```yaml
mcp_servers:
agentmemory:
command: npx
args: ["-y", "@agentmemory/mcp"]
memory:
provider: agentmemory
```
This gives Hermes access to all 43 MCP tools and enables the agentmemory memory provider. Start the server separately:
```bash
npx @agentmemory/agentmemory
```
### Option 2: Memory provider plugin (deeper integration)
Copy this folder to your Hermes plugins directory:
```bash
cp -r integrations/hermes ~/.hermes/plugins/agentmemory
```
Start the agentmemory server:
```bash
npx @agentmemory/agentmemory
```
The plugin auto-detects the running server and hooks into the Hermes agent loop. Make sure `memory.provider` is set to `agentmemory` in `~/.hermes/config.yaml`:
- `prefetch()` injects relevant memories before each LLM call
- `sync_turn()` captures every conversation turn in the background
- `on_session_end()` marks sessions complete for summarization
- `on_pre_compress()` re-injects context before compaction
- `on_memory_write()` mirrors MEMORY.md writes to agentmemory
- `system_prompt_block()` injects project profile at session start
### Environment variables
| Variable | Default | Description |
|---|---|---|
| `AGENTMEMORY_URL` | `http://localhost:3111` | agentmemory server URL |
| `AGENTMEMORY_SECRET` | (none) | Auth token for protected instances |
| `AGENTMEMORY_REQUIRE_HTTPS` | (off) | When set to `1`, refuse to send the bearer token over plaintext HTTP to a non-loopback host. Sends only when `AGENTMEMORY_URL` is `https://...` or points at `localhost`/`127.0.0.1`/`::1`. With this off, the plugin warns once on stderr but still sends. |
The plugin reads `~/.agentmemory/.env` (or `$XDG_CONFIG_HOME/agentmemory/.env`) at import time and populates any missing values into the process environment via `os.environ.setdefault`. Anything you set in the shell takes precedence; the file is only used to fill gaps. This means `hermes memory status` reports the plugin as available even when the agentmemory service is launched by systemd or another process manager that loads `~/.agentmemory/.env` directly without exporting it to the Hermes CLI shell (#250).
## What Hermes gets
- 95.2% retrieval accuracy (LongMemEval-S, ICLR 2025)
- Hybrid search: BM25 + vector + knowledge graph
- Memory versioning, decay, and auto-forget
- Cross-agent: memories from Claude Code, Cursor, Gemini CLI all accessible
- Real-time viewer at http://localhost:3113
## How it works
Hermes has two memory files (MEMORY.md, USER.md) and SQLite full-text search. agentmemory adds structured memory on top:
| Hermes built-in | agentmemory adds |
|---|---|
| MEMORY.md (flat text) | Structured observations with facts, concepts, files |
| USER.md (preferences) | Project profiles with top patterns and conventions |
| SQLite FTS5 (session search) | BM25 + vector + knowledge graph (95.2% R@5) |
| Skills (self-improving) | Skill extraction from completed sessions |
| Single agent | Cross-agent memory via MCP + REST |
+388
View File
@@ -0,0 +1,388 @@
"""
agentmemory memory provider for Hermes Agent.
Drop this folder into ~/.hermes/plugins/agentmemory/
or install via: hermes plugin install agentmemory
Requires agentmemory server running: npx @agentmemory/agentmemory
"""
from __future__ import annotations
import json
import os
import sys
import threading
import time
from pathlib import Path
from typing import Any, Callable
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from urllib.error import URLError
try:
from agent.memory_provider import MemoryProvider
except ImportError:
from abc import ABC, abstractmethod
class MemoryProvider(ABC):
@property
@abstractmethod
def name(self) -> str: ...
@abstractmethod
def is_available(self) -> bool: ...
@abstractmethod
def initialize(self, session_id: str, **kwargs: Any) -> None: ...
@abstractmethod
def get_tool_schemas(self) -> list[dict]: ...
@abstractmethod
def handle_tool_call(self, name: str, args: dict) -> str: ...
def get_config_schema(self) -> list[dict]: return []
def save_config(self, values: dict, hermes_home: str) -> None: pass
def system_prompt_block(self) -> str: return ""
def prefetch(self, query: str, **kwargs: Any) -> str: return ""
def queue_prefetch(self, query: str, **kwargs: Any) -> None: pass
def sync_turn(self, user: str, assistant: str, **kwargs: Any) -> None: pass
def on_session_end(self, messages: list, **kwargs: Any) -> None: pass
def on_pre_compress(self, messages: list, **kwargs: Any) -> None: pass
def on_memory_write(self, action: str, target: str, content: str, **kwargs: Any) -> None: pass
def shutdown(self, **kwargs: Any) -> None: pass
DEFAULT_BASE_URL = "http://localhost:3111"
TIMEOUT = 5
LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
_plaintext_bearer_warned = False
# agentmemory's documented runtime config lives at ~/.agentmemory/.env.
# When agentmemory is launched as a systemd user service (or any other
# process manager that loads that file directly), those values never
# reach an interactive shell. `hermes memory status` then reads
# os.environ in the Hermes CLI process, finds AGENTMEMORY_URL /
# AGENTMEMORY_SECRET unset, and reports the plugin as "Missing" even
# though the service is healthy and live sessions can use it (#250).
#
# Preload the file at plugin-import time using os.environ.setdefault so
# we never override anything the user explicitly set in the shell. The
# preload is best-effort and silent on any failure (file absent,
# unreadable, malformed) — the plugin falls back to its existing default
# (http://localhost:3111) and Hermes status reflects that.
def _preload_agentmemory_dotenv() -> None:
candidates: list[Path] = []
home = os.environ.get("HOME")
if home:
candidates.append(Path(home) / ".agentmemory" / ".env")
xdg_config = os.environ.get("XDG_CONFIG_HOME")
if xdg_config:
candidates.append(Path(xdg_config) / "agentmemory" / ".env")
for path in candidates:
try:
if not path.is_file():
continue
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
if key:
os.environ.setdefault(key, value)
except (OSError, UnicodeDecodeError):
continue
# Guarantee AGENTMEMORY_URL is set so `hermes memory status` never
# reports it as Missing when a user runs agentmemory at the default
# localhost:3111 (or via systemd with the URL line commented out in
# ~/.agentmemory/.env because it matches the default). #520.
os.environ.setdefault("AGENTMEMORY_URL", DEFAULT_BASE_URL)
_preload_agentmemory_dotenv()
def _validate_url(base: str) -> bool:
if not base:
return False
try:
parsed = urlparse(base)
# .port raises ValueError on a non-numeric or out-of-range port
_ = parsed.port
except ValueError:
return False
if parsed.scheme not in ("http", "https"):
return False
return bool(parsed.hostname)
def _uses_plaintext_bearer_auth(base: str, secret: str = "") -> bool:
if not secret:
return False
parsed = urlparse(base)
return parsed.scheme == "http" and (parsed.hostname or "").lower() not in LOOPBACK_HOSTS
def _plaintext_bearer_auth_message(base: str) -> str:
return f"agentmemory: AGENTMEMORY_SECRET is configured for plaintext HTTP to {base}. Bearer tokens and memory payloads can be observed on the network; use HTTPS or an SSH tunnel."
def _warn_plaintext_bearer_auth(message: str) -> None:
print(message, file=sys.stderr)
def _check_plaintext_bearer_guard(
base: str,
secret: str = "",
warn: Callable[[str], None] | None = None,
) -> None:
global _plaintext_bearer_warned
if not _uses_plaintext_bearer_auth(base, secret):
return
message = _plaintext_bearer_auth_message(base)
if os.environ.get("AGENTMEMORY_REQUIRE_HTTPS") == "1":
raise RuntimeError(message)
if not _plaintext_bearer_warned:
_plaintext_bearer_warned = True
(warn or _warn_plaintext_bearer_auth)(message)
def _reset_plaintext_bearer_guard_for_tests() -> None:
global _plaintext_bearer_warned
_plaintext_bearer_warned = False
def _api(base: str, path: str, body: dict | None = None, method: str = "POST", secret: str = "") -> dict | None:
if not _validate_url(base):
return None
url = f"{base}/agentmemory/{path}"
headers = {"Content-Type": "application/json"}
auth = secret or os.environ.get("AGENTMEMORY_SECRET", "")
_check_plaintext_bearer_guard(base, auth)
if auth:
headers["Authorization"] = f"Bearer {auth}"
data = json.dumps(body).encode() if body else None
req = Request(url, data=data, headers=headers, method=method)
try:
with urlopen(req, timeout=TIMEOUT) as resp:
return json.loads(resp.read().decode())
except (URLError, TimeoutError, json.JSONDecodeError):
return None
def _api_bg(base: str, path: str, body: dict | None = None) -> None:
t = threading.Thread(target=_api, args=(base, path, body), daemon=True)
t.start()
class AgentMemoryProvider(MemoryProvider):
@property
def name(self) -> str:
return "agentmemory"
def is_available(self) -> bool:
# Hermes contract: no network calls in is_available.
base = os.environ.get("AGENTMEMORY_URL", DEFAULT_BASE_URL)
return _validate_url(base)
def initialize(self, session_id: str, **kwargs: Any) -> None:
self._base = os.environ.get("AGENTMEMORY_URL", DEFAULT_BASE_URL)
self._session_id = session_id
self._project = kwargs.get("cwd", os.getcwd())
if os.environ.get("AGENTMEMORY_REQUIRE_HTTPS") == "1":
_check_plaintext_bearer_guard(self._base, os.environ.get("AGENTMEMORY_SECRET", ""))
_api(self._base, "session/start", {
"sessionId": session_id,
"project": self._project,
"cwd": self._project,
})
def get_config_schema(self) -> list[dict]:
return [
{
"key": "url",
"description": "agentmemory server URL",
"default": DEFAULT_BASE_URL,
"env_var": "AGENTMEMORY_URL",
},
{
"key": "secret",
"description": "agentmemory auth secret (optional)",
"secret": True,
"required": False,
"env_var": "AGENTMEMORY_SECRET",
},
]
def save_config(self, values: dict, hermes_home: str) -> None:
config_path = Path(hermes_home) / "agentmemory.json"
config_path.write_text(json.dumps(values, indent=2))
def system_prompt_block(self) -> str:
result = _api(self._base, "context", {
"sessionId": self._session_id,
"project": self._project,
})
if result and result.get("context"):
return result["context"]
return ""
def prefetch(self, query: str, **kwargs: Any) -> str:
result = _api(self._base, "smart-search", {
"query": query,
"limit": 5,
})
if not result or not result.get("results"):
return ""
lines = []
for r in result["results"][:5]:
obs = r.get("observation", r)
title = obs.get("title", "")
narrative = obs.get("narrative", "")
if title:
lines.append(f"- {title}: {narrative[:200]}")
return "\n".join(lines) if lines else ""
def queue_prefetch(self, query: str, **kwargs: Any) -> None:
_api_bg(self._base, "smart-search", {"query": query, "limit": 3})
def get_tool_schemas(self) -> list[dict]:
return [
{
"name": "memory_recall",
"description": "Search agentmemory for past observations by keyword",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"},
"limit": {"type": "integer", "description": "Max results", "default": 10},
},
"required": ["query"],
},
},
{
"name": "memory_save",
"description": "Save an insight, decision, or pattern to long-term memory",
"parameters": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "What to remember"},
"type": {
"type": "string",
"enum": ["pattern", "preference", "architecture", "bug", "workflow", "fact"],
"description": "Memory type",
},
},
"required": ["content"],
},
},
{
"name": "memory_search",
"description": "Hybrid semantic + keyword search across all memories",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer", "default": 5},
},
"required": ["query"],
},
},
]
def handle_tool_call(self, name: str, args: dict) -> str:
# Hermes stores the return value as the tool result `content` in the
# session history. Anthropic-protocol providers reject non-string
# content with a 400 on the next request, so always serialize to a
# JSON string here — matches what agentmemory's main MCP server does
# in src/mcp/standalone.ts (`{ type: "text", text: JSON.stringify(...) }`).
if name == "memory_recall":
result = _api(self._base, "search", {
"query": args["query"],
"limit": args.get("limit", 10),
})
if not result:
return json.dumps({"results": []})
items = []
for r in result.get("results", []):
obs = r.get("observation", r)
items.append({
"title": obs.get("title", ""),
"type": obs.get("type", ""),
"narrative": obs.get("narrative", ""),
"importance": obs.get("importance", 0),
"timestamp": obs.get("timestamp", ""),
})
return json.dumps({"results": items})
if name == "memory_save":
result = _api(self._base, "remember", {
"content": args["content"],
"type": args.get("type", "fact"),
})
return json.dumps(result or {"success": False})
if name == "memory_search":
result = _api(self._base, "smart-search", {
"query": args["query"],
"limit": args.get("limit", 5),
})
if not result:
return json.dumps({"results": []})
items = []
for r in result.get("results", []):
obs = r.get("observation", r)
items.append({
"title": obs.get("title", ""),
"narrative": obs.get("narrative", "")[:300],
"score": r.get("combinedScore", r.get("score", 0)),
})
return json.dumps({"results": items})
return json.dumps({"error": f"Unknown tool: {name}"})
def sync_turn(self, user: str, assistant: str, **kwargs: Any) -> None:
_api_bg(self._base, "observe", {
"hookType": "post_tool_use",
"sessionId": kwargs.get("session_id", self._session_id),
"project": self._project,
"cwd": self._project,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"data": {
"tool_name": "conversation",
"tool_input": user[:500],
"tool_output": assistant[:2000],
},
})
def on_session_end(self, messages: list, **kwargs: Any) -> None:
_api(self._base, "session/end", {
"sessionId": kwargs.get("session_id", self._session_id),
})
def on_pre_compress(self, messages: list, **kwargs: Any) -> None:
result = _api(self._base, "context", {
"sessionId": kwargs.get("session_id", self._session_id),
"project": self._project,
})
if result and result.get("context"):
messages.insert(0, {
"role": "user",
"content": f"[agentmemory context before compaction]\n{result['context']}",
})
def on_memory_write(self, action: str, target: str, content: str, **kwargs: Any) -> None:
if action in ("add", "update") and content:
_api_bg(self._base, "remember", {
"content": content,
"type": "fact",
})
def shutdown(self, **kwargs: Any) -> None:
pass
def register(ctx: Any) -> None:
ctx.register_memory_provider(AgentMemoryProvider())
+12
View File
@@ -0,0 +1,12 @@
name: agentmemory
version: 0.8.0
description: "Persistent cross-session memory for Hermes Agent via agentmemory. 95.2% retrieval accuracy on LongMemEval."
author: "Rohit Ghumare"
homepage: "https://github.com/rohitg00/agentmemory"
hooks:
- prefetch
- sync_turn
- on_session_end
- on_pre_compress
- on_memory_write
- system_prompt_block
+155
View File
@@ -0,0 +1,155 @@
<p align="center">
<img src="../../assets/banner.png" alt="agentmemory" width="640" />
</p>
<h1 align="center">
<img src="https://github.com/openclaw.png?size=80" alt="OpenClaw" width="28" height="28" align="center" />
&nbsp;agentmemory for OpenClaw
</h1>
<p align="center">
<strong>Your OpenClaw agents remember everything. No more re-explaining.</strong><br/>
<sub>Persistent cross-session memory via <a href="https://github.com/rohitg00/agentmemory">agentmemory</a> — 95.2% retrieval accuracy on <a href="https://arxiv.org/abs/2410.10813">LongMemEval-S</a>.</sub>
</p>
<p align="center">
<img src="https://img.shields.io/badge/MCP-43_tools-1f6feb?style=flat-square" alt="43 MCP tools" />
<img src="https://img.shields.io/badge/Plugin-memory_slot-1f6feb?style=flat-square" alt="OpenClaw memory plugin" />
<img src="https://img.shields.io/badge/R@5-95.2%25-00875f?style=flat-square" alt="95.2% R@5" />
<img src="https://img.shields.io/badge/Self--hosted-yes-00875f?style=flat-square" alt="Self-hosted" />
<img src="https://img.shields.io/badge/License-Apache_2.0-blue?style=flat-square" alt="Apache 2.0" />
</p>
---
## Install it in 30 seconds
**Paste this prompt into OpenClaw** and it does the whole setup for you:
```text
Install agentmemory for OpenClaw. Run `npx @agentmemory/agentmemory` in a separate terminal to start the memory server on localhost:3111.
For zero-code setup, add this MCP server so OpenClaw gets all 43 memory tools:
{
"mcpServers": {
"agentmemory": {
"command": "npx",
"args": ["-y", "@agentmemory/mcp"]
}
}
}
For deeper memory integration, copy `integrations/openclaw` from the agentmemory repo to `~/.openclaw/extensions/agentmemory`, then enable it in `~/.openclaw/openclaw.json`:
{
"plugins": {
"slots": {
"memory": "agentmemory"
},
"entries": {
"agentmemory": {
"enabled": true,
"config": {
"base_url": "http://localhost:3111",
"token_budget": 2000,
"min_confidence": 0.5,
"fallback_on_error": true,
"timeout_ms": 5000
}
}
}
}
}
Restart OpenClaw. Verify with `curl http://localhost:3111/agentmemory/health`. Open http://localhost:3113 for the real-time viewer.
```
That's it. OpenClaw handles the rest.
## Option 1: MCP server (zero code)
Start the agentmemory server in a separate terminal:
```bash
npx @agentmemory/agentmemory
```
Then add to your OpenClaw MCP config:
```json
{
"mcpServers": {
"agentmemory": {
"command": "npx",
"args": ["-y", "@agentmemory/mcp"]
}
}
}
```
OpenClaw now has access to all 43 MCP tools including `memory_recall`, `memory_save`, `memory_smart_search`, `memory_timeline`, `memory_profile`, and more.
## Option 2: OpenClaw memory plugin (deeper integration)
Copy this folder into OpenClaw's extension directory:
```bash
mkdir -p ~/.openclaw/extensions
cp -r integrations/openclaw ~/.openclaw/extensions/agentmemory
```
Then enable it in `~/.openclaw/openclaw.json`:
```json
{
"plugins": {
"slots": {
"memory": "agentmemory"
},
"entries": {
"agentmemory": {
"enabled": true,
"config": {
"base_url": "http://localhost:3111",
"token_budget": 2000,
"min_confidence": 0.5,
"fallback_on_error": true,
"timeout_ms": 5000
}
}
}
}
}
```
What the plugin does:
- claims the `plugins.slots.memory = "agentmemory"` slot via `api.registerMemoryCapability({ promptBuilder })` so OpenClaw recognises it as the active memory plugin
- recalls relevant long-term memory before the agent starts (via the `before_agent_start` hook)
- captures completed conversation turns after the agent finishes (via the `agent_end` hook)
- shares the same backend with Claude Code, Codex CLI, Gemini CLI, Hermes, pi, and other agents
### Memory runtime (current scope)
The plugin currently registers a `promptBuilder` only — not a full `MemoryPluginRuntime` adapter. OpenClaw's `MemoryRuntimeBackendConfig` type today is `{ backend: "builtin" }` or `{ backend: "qmd" }`; both are openclaw-internal backends that don't fit agentmemory's external REST shape. The hook-driven recall + capture flow above is the working integration path. If you need OpenClaw's in-process memory-runtime APIs (e.g. `getMemorySearchManager`) backed by agentmemory, file an upstream request against `openclaw` for an `"external"` backend type and we'll wire `runtime` here once the contract supports it.
## Troubleshooting
**Plugin validates but does not load** — make sure the folder contains `package.json`, `openclaw.plugin.json`, and `plugin.mjs`, and that `plugins.slots.memory` is set to `agentmemory`.
**`plugins.slots.memory = "agentmemory"` reports `unavailable`** — upgrade to v0.9.11+. Older versions of this plugin registered hooks but never called `api.registerMemoryCapability(...)`, so the memory-slot machinery did not consider the slot claimed. The current plugin registers a memory capability (prompt builder) at startup, which is the documented OpenClaw API for occupying the slot.
**Connection refused on port 3111** — the agentmemory server is not running. Start it with `npx @agentmemory/agentmemory`.
**No memories returned** — open `http://localhost:3113` and verify observations are being captured.
## See also
- [agentmemory main README](../../README.md)
- [Hermes integration](../hermes/README.md)
- [pi integration](../pi/README.md)
## License
Apache-2.0 (same as agentmemory)
@@ -0,0 +1,27 @@
{
"id": "agentmemory",
"kind": "memory",
"name": "agentmemory",
"description": "Persistent cross-session memory for OpenClaw via agentmemory.",
"version": "0.9.4",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"base_url": { "type": "string" },
"token_budget": { "type": "number" },
"min_confidence": { "type": "number" },
"fallback_on_error": { "type": "boolean" },
"timeout_ms": { "type": "number" }
}
},
"uiHints": {
"enabled": { "label": "Enabled" },
"base_url": { "label": "Base URL", "help": "agentmemory REST server base URL" },
"token_budget": { "label": "Token Budget", "help": "Approximate context budget to inject before the agent starts" },
"min_confidence": { "label": "Min Confidence" },
"fallback_on_error": { "label": "Fallback On Error" },
"timeout_ms": { "label": "Timeout (ms)" }
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "agentmemory",
"version": "0.9.4",
"type": "module",
"openclaw": {
"extensions": [
"./plugin.mjs"
]
}
}
+217
View File
@@ -0,0 +1,217 @@
/**
* agentmemory plugin for OpenClaw
*
* Deeper integration than raw MCP:
* - claims the plugins.slots.memory slot via api.registerMemoryCapability({ promptBuilder })
* - recalls relevant memories before the agent starts (before_agent_start hook)
* - captures completed conversation turns after the agent finishes (agent_end hook)
*
* Requires the agentmemory server on localhost:3111.
* Start it with: npx @agentmemory/agentmemory
*/
const DEFAULT_BASE_URL = "http://localhost:3111";
const DEFAULT_TIMEOUT_MS = 5000;
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
const configSchema = {
type: "object",
additionalProperties: false,
properties: {
enabled: { type: "boolean" },
base_url: { type: "string" },
token_budget: { type: "number" },
min_confidence: { type: "number" },
fallback_on_error: { type: "boolean" },
timeout_ms: { type: "number" },
},
};
function extractText(content) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.flatMap((block) => {
if (!block || typeof block !== "object") return [];
if (block.type === "text" && typeof block.text === "string") return [block.text];
return [];
})
.join("\n")
.trim();
}
function lastAssistantText(messages) {
for (const message of [...messages].reverse()) {
if (!message || typeof message !== "object") continue;
if (message.role !== "assistant") continue;
const text = extractText(message.content);
if (text) return text;
}
return "";
}
function latestUserText(messages) {
for (const message of [...messages].reverse()) {
if (!message || typeof message !== "object") continue;
if (message.role !== "user") continue;
const text = extractText(message.content);
if (text) return text;
}
return "";
}
function formatResults(results) {
if (!Array.isArray(results) || results.length === 0) return "";
return results
.slice(0, 5)
.map((result, index) => {
const obs = result?.observation ?? result ?? {};
const title = (obs.title || `Memory ${index + 1}`).trim();
const narrative = (obs.narrative || "").trim();
const type = (obs.type || "memory").trim();
return `- ${title} (${type})${narrative ? `: ${narrative}` : ""}`;
})
.join("\n");
}
function normalizedHostname(hostname) {
return hostname.replace(/^\[|\]$/g, "").toLowerCase();
}
function usesPlaintextBearerAuth(baseUrl, secret) {
if (!secret) return false;
try {
const parsed = new URL(baseUrl);
return parsed.protocol === "http:" && !LOOPBACK_HOSTS.has(normalizedHostname(parsed.hostname));
} catch {
return false;
}
}
function plaintextBearerAuthMessage(baseUrl) {
return `agentmemory: AGENTMEMORY_SECRET is configured for plaintext HTTP to ${baseUrl}. Bearer tokens and memory payloads can be observed on the network; use HTTPS or an SSH tunnel.`;
}
export function createPlaintextBearerAuthGuard(warn, env) {
let warned = false;
return function guardPlaintextBearerAuth(baseUrl, secret) {
if (!usesPlaintextBearerAuth(baseUrl, secret)) return;
const message = plaintextBearerAuthMessage(baseUrl);
if ((env || process.env).AGENTMEMORY_REQUIRE_HTTPS === "1") throw new Error(message);
if (!warned) {
warned = true;
warn(message);
}
};
}
function createClient(cfg, api) {
const baseUrl = String(cfg.base_url || DEFAULT_BASE_URL).replace(/\/+$/, "");
const timeoutMs = Number(cfg.timeout_ms || DEFAULT_TIMEOUT_MS);
const fallbackOnError = cfg.fallback_on_error !== false;
const secret = process.env.AGENTMEMORY_SECRET;
const guardPlaintextBearerAuth = createPlaintextBearerAuthGuard(
(message) => api.logger.warn?.(message),
);
if (process.env.AGENTMEMORY_REQUIRE_HTTPS === "1") {
guardPlaintextBearerAuth(baseUrl, secret);
}
async function postJson(path, payload) {
guardPlaintextBearerAuth(baseUrl, secret);
const headers = { "Content-Type": "application/json" };
if (secret) headers.Authorization = `Bearer ${secret}`;
try {
const res = await fetch(`${baseUrl}${path}`, {
method: "POST",
headers,
body: JSON.stringify(payload),
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) {
if (fallbackOnError) return null;
const body = await res.text().catch(() => "");
throw new Error(`agentmemory ${path} failed: ${res.status} ${body}`);
}
return await res.json();
} catch (error) {
if (!fallbackOnError) throw error;
api.logger.warn?.(`agentmemory: ${String(error)}`);
return null;
}
}
return { postJson, baseUrl };
}
const plugin = {
id: "agentmemory",
name: "agentmemory",
description: "Shared cross-session memory via the local agentmemory server.",
configSchema,
register(api) {
const cfg = {
enabled: api.pluginConfig?.enabled !== false,
base_url: api.pluginConfig?.base_url || DEFAULT_BASE_URL,
token_budget: api.pluginConfig?.token_budget || 2000,
min_confidence: api.pluginConfig?.min_confidence || 0.5,
fallback_on_error: api.pluginConfig?.fallback_on_error !== false,
timeout_ms: api.pluginConfig?.timeout_ms || DEFAULT_TIMEOUT_MS,
};
const client = createClient(cfg, api);
if (typeof api.registerMemoryCapability === "function") {
api.registerMemoryCapability({
// OpenClaw passes { availableTools: Set<string>, citationsMode? }. We
// don't currently branch on tool availability, but accept the params
// object so the signature matches MemoryPromptSectionBuilder exactly.
promptBuilder: (_params) => [
"Long-term memory provider: agentmemory (external REST service on " +
client.baseUrl +
").",
"agentmemory recalls relevant prior observations before each turn via the before_agent_start hook and captures completed turns via agent_end.",
"Treat recalled context as background, not authoritative — prefer current workspace state and explicit user instructions when they conflict.",
],
});
}
api.on("before_agent_start", async (event) => {
if (!cfg.enabled) return;
const prompt = typeof event?.prompt === "string" ? event.prompt.trim() : "";
if (!prompt) return;
const result = await client.postJson("/agentmemory/smart-search", {
query: prompt,
limit: 5,
});
const block = formatResults(result?.results || []);
if (!block) return;
return {
prependContext: `Relevant long-term memory from agentmemory:\n${block}`,
};
});
api.on("agent_end", async (event) => {
if (!cfg.enabled || !event?.success || !Array.isArray(event.messages)) return;
const userText = latestUserText(event.messages);
const assistantText = lastAssistantText(event.messages);
if (!userText || !assistantText) return;
const sessionId =
event.sessionId ||
event.sessionKey ||
event.runId ||
`openclaw-${Date.now()}`;
await client.postJson("/agentmemory/observe", {
hookType: "post_tool_use",
sessionId,
timestamp: new Date().toISOString(),
data: {
tool_name: "conversation",
tool_input: userText.slice(0, 1000),
tool_output: assistantText.slice(0, 4000),
},
});
});
},
};
export default plugin;
+27
View File
@@ -0,0 +1,27 @@
name: agentmemory
version: 0.8.1
description: "Persistent cross-session memory for OpenClaw via agentmemory. 95.2% retrieval accuracy on LongMemEval-S."
author: "Rohit Ghumare"
homepage: "https://github.com/rohitg00/agentmemory"
license: Apache-2.0
category: memory
tags:
- memory
- persistence
- mcp
- context
hooks:
- on_session_start
- on_pre_llm_call
- on_post_tool_use
- on_session_end
config:
enabled: true
base_url: http://localhost:3111
token_budget: 2000
min_confidence: 0.5
fallback_on_error: true
timeout_ms: 5000
+77
View File
@@ -0,0 +1,77 @@
<p align="center">
<img src="../../assets/banner.png" alt="agentmemory" width="640" />
</p>
<h1 align="center">
&nbsp;agentmemory for pi
</h1>
<p align="center">
<strong>Your pi sessions remember everything. No more re-explaining.</strong><br/>
<sub>Persistent cross-session memory via <a href="https://github.com/rohitg00/agentmemory">agentmemory</a> — shared with Claude Code, Codex CLI, Gemini CLI, Hermes, OpenClaw, and more.</sub>
</p>
---
## Quick setup
Start the agentmemory server in a separate terminal:
```bash
npx @agentmemory/agentmemory
```
Copy this folder into pi's global extensions directory:
```bash
mkdir -p ~/.pi/agent/extensions/agentmemory
cp integrations/pi/index.ts ~/.pi/agent/extensions/agentmemory/index.ts
```
Then enable it in `~/.pi/agent/settings.json` if you prefer explicit loading:
```json
{
"extensions": ["~/.pi/agent/extensions/agentmemory"]
}
```
If you place it under `~/.pi/agent/extensions/agentmemory/`, pi will also auto-discover it and `/reload` can hot-reload it.
## What it adds
- `memory_health` — confirm the shared memory server is reachable
- `memory_search` — search prior decisions, bugs, workflows, and preferences
- `memory_save` — write durable facts back to long-term memory
- `/agentmemory-status` — check health from inside pi
- `before_agent_start` recall — injects relevant memories into the prompt
- `agent_end` capture — saves completed conversation turns back to agentmemory
## Environment variables
| Variable | Default | Description |
|---|---|---|
| `AGENTMEMORY_URL` | `http://localhost:3111` | agentmemory server URL |
| `AGENTMEMORY_SECRET` | (none) | Bearer token for protected instances |
| `AGENTMEMORY_REQUIRE_HTTPS` | (off) | When set to `1`, refuse to send a bearer token over plaintext HTTP to a non-loopback host. Sends the token only when `AGENTMEMORY_URL` is `https://...` or points at `localhost`/`127.0.0.1`/`::1`. With this off, the plugin warns once but still sends. |
## Smoke test
Run pi and ask it to use the `memory_health` tool, or call the command directly:
```text
/agentmemory-status
```
You should see `agentmemory healthy` and a footer status like `🧠 agentmemory`.
## Notes
- This extension uses pi's extension API, not MCP, so it can hook directly into the agent lifecycle.
- One local agentmemory server can be shared across pi, pi2, Hermes, OpenClaw, Claude Code, Codex CLI, and Gemini CLI.
## See also
- [agentmemory main README](../../README.md)
- [Hermes integration](../hermes/README.md)
- [OpenClaw integration](../openclaw/README.md)
+275
View File
@@ -0,0 +1,275 @@
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "typebox";
import path from "node:path";
import crypto from "node:crypto";
import { createPlaintextBearerAuthGuard } from "./security.js";
type TextBlock = { type?: string; text?: string };
type AssistantMessage = { role?: string; content?: unknown };
type SmartSearchResult = {
title?: string;
narrative?: string;
type?: string;
combinedScore?: number;
score?: number;
observation?: {
title?: string;
narrative?: string;
type?: string;
};
};
type HealthResponse = {
status?: string;
service?: string;
version?: string;
health?: {
status?: string;
notes?: string[];
};
};
const DEFAULT_URL = process.env.AGENTMEMORY_URL || "http://localhost:3111";
const guardPlaintextBearerAuth = createPlaintextBearerAuthGuard();
const TOOL_GUIDANCE = [
"agentmemory is available for cross-session memory.",
"Use memory_search to recall prior decisions, preferences, bugs, and workflows.",
"Use memory_save when you discover durable facts worth remembering beyond this session.",
].join(" ");
function normalizeBaseUrl(url: string): string {
return url.replace(/\/+$/, "");
}
function getText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.flatMap((part) => {
if (!part || typeof part !== "object") return [] as string[];
const block = part as TextBlock;
if (block.type === "text" && typeof block.text === "string") return [block.text];
return [] as string[];
})
.join("\n")
.trim();
}
function getLastAssistantText(messages: unknown[]): string {
for (const msg of [...messages].reverse()) {
if (!msg || typeof msg !== "object") continue;
const assistant = msg as AssistantMessage;
if (assistant.role !== "assistant") continue;
const text = getText(assistant.content);
if (text) return text;
}
return "";
}
function formatSearchResults(results: SmartSearchResult[]): string {
if (!results.length) return "No relevant memories found.";
return results
.slice(0, 5)
.map((result, index) => {
const obs = result.observation ?? result;
const title = obs.title?.trim() || `Memory ${index + 1}`;
const narrative = obs.narrative?.trim() || "";
const type = obs.type?.trim() || "memory";
const score = result.combinedScore ?? result.score;
const scoreText = typeof score === "number" ? ` [score=${score.toFixed(3)}]` : "";
return `- ${title} (${type})${scoreText}${narrative ? `: ${narrative}` : ""}`;
})
.join("\n");
}
async function callAgentMemory<T>(
pathname: string,
options?: {
method?: "GET" | "POST";
body?: unknown;
baseUrl?: string;
},
): Promise<T | null> {
const baseUrl = normalizeBaseUrl(options?.baseUrl || process.env.AGENTMEMORY_URL || DEFAULT_URL);
const method = options?.method || "POST";
const url = `${baseUrl}/agentmemory/${pathname.replace(/^\/+/, "")}`;
const headers: Record<string, string> = {};
const secret = process.env.AGENTMEMORY_SECRET;
guardPlaintextBearerAuth(baseUrl, secret);
if (options?.body !== undefined) headers["Content-Type"] = "application/json";
if (secret) headers.Authorization = `Bearer ${secret}`;
try {
const response = await fetch(url, {
method,
headers,
body: options?.body !== undefined ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) return null;
return (await response.json()) as T;
} catch {
return null;
}
}
export default function agentmemoryExtension(pi: ExtensionAPI) {
if (process.env.AGENTMEMORY_REQUIRE_HTTPS === "1") {
guardPlaintextBearerAuth(
normalizeBaseUrl(process.env.AGENTMEMORY_URL || DEFAULT_URL),
process.env.AGENTMEMORY_SECRET,
);
}
let sessionId = `ephemeral-${crypto.randomUUID().slice(0, 8)}`;
let currentProject = process.cwd();
let lastPrompt = "";
let lastHealthOk = false;
async function getHealth() {
return await callAgentMemory<HealthResponse>("health", { method: "GET" });
}
async function refreshStatus(ctx: { ui: { setStatus: (key: string, text: string) => void } }) {
const health = await getHealth();
lastHealthOk = !!health && (health.status === "healthy" || health.health?.status === "healthy");
ctx.ui.setStatus("agentmemory", lastHealthOk ? "🧠 agentmemory" : "🧠 agentmemory off");
}
pi.registerCommand("agentmemory-status", {
description: "Check local agentmemory server health",
handler: async (_args, ctx) => {
const health = await getHealth();
if (!health) {
ctx.ui.notify("agentmemory is unreachable at http://localhost:3111", "warning");
return;
}
ctx.ui.notify(
`agentmemory ${health.status || health.health?.status || "unknown"}${health.version ? ` v${health.version}` : ""}`,
"info",
);
},
});
pi.registerTool({
name: "memory_health",
label: "Memory Health",
description: "Check whether the local agentmemory server is reachable and healthy",
parameters: Type.Object({}),
async execute() {
const health = await getHealth();
if (!health) {
return {
content: [{ type: "text", text: "agentmemory is unreachable at http://localhost:3111" }],
details: { ok: false },
};
}
return {
content: [
{
type: "text",
text: `agentmemory status: ${health.status || health.health?.status || "unknown"}${health.version ? ` (v${health.version})` : ""}`,
},
],
details: health,
};
},
});
pi.registerTool({
name: "memory_search",
label: "Memory Search",
description: "Search agentmemory for cross-session project memory, prior decisions, bugs, and user preferences",
parameters: Type.Object({
query: Type.String({ description: "What to search for in memory" }),
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 10, default: 5, description: "Maximum results" })),
}),
async execute(_toolCallId, params) {
const result = await callAgentMemory<{ results?: SmartSearchResult[] }>("smart-search", {
body: { query: params.query, limit: params.limit ?? 5 },
});
const results = result?.results || [];
return {
content: [{ type: "text", text: formatSearchResults(results) }],
details: { query: params.query, results },
};
},
});
pi.registerTool({
name: "memory_save",
label: "Memory Save",
description: "Save a durable fact, convention, workflow, preference, or bug fix into agentmemory",
parameters: Type.Object({
content: Type.String({ description: "What should be remembered" }),
type: Type.Optional(
Type.String({
description: "Memory type",
default: "fact",
}),
),
}),
async execute(_toolCallId, params) {
const result = await callAgentMemory<Record<string, unknown>>("remember", {
body: { content: params.content, type: params.type || "fact" },
});
if (!result) {
return {
content: [{ type: "text", text: "Failed to save memory to agentmemory." }],
details: { ok: false },
};
}
return {
content: [{ type: "text", text: `Saved memory (${params.type || "fact"}): ${params.content}` }],
details: result,
};
},
});
pi.on("session_start", async (_event, ctx) => {
const sessionFile = ctx.sessionManager.getSessionFile();
sessionId = sessionFile ? path.basename(sessionFile).replace(/\.[^.]+$/, "") : `ephemeral-${crypto.randomUUID().slice(0, 8)}`;
currentProject = process.cwd();
await refreshStatus(ctx);
});
pi.on("before_agent_start", async (event, ctx) => {
currentProject = event.systemPromptOptions.cwd || process.cwd();
lastPrompt = event.prompt?.trim() || "";
if (!lastPrompt) return;
const result = await callAgentMemory<{ results?: SmartSearchResult[] }>("smart-search", {
body: { query: lastPrompt, limit: 5 },
});
const results = result?.results || [];
const recallBlock = results.length
? [
"Relevant long-term memory from agentmemory:",
formatSearchResults(results),
].join("\n")
: "";
await refreshStatus(ctx);
return {
systemPrompt: [event.systemPrompt, TOOL_GUIDANCE, recallBlock].filter(Boolean).join("\n\n"),
};
});
pi.on("agent_end", async (event) => {
if (!lastHealthOk || !lastPrompt) return;
const assistantText = getLastAssistantText(event.messages as unknown[]);
if (!assistantText) return;
void callAgentMemory("observe", {
body: {
hookType: "post_tool_use",
sessionId,
project: currentProject,
cwd: currentProject,
timestamp: new Date().toISOString(),
data: {
tool_name: "conversation",
tool_input: lastPrompt.slice(0, 500),
tool_output: assistantText.slice(0, 4000),
},
},
});
});
}
+5
View File
@@ -0,0 +1,5 @@
{
"name": "agentmemory-pi-extension",
"private": true,
"type": "module"
}
+35
View File
@@ -0,0 +1,35 @@
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
function normalizedHostname(hostname: string): string {
return hostname.replace(/^\[|\]$/g, "").toLowerCase();
}
export function usesPlaintextBearerAuth(baseUrl: string, secret?: string): boolean {
if (!secret) return false;
try {
const parsed = new URL(baseUrl);
return parsed.protocol === "http:" && !LOOPBACK_HOSTS.has(normalizedHostname(parsed.hostname));
} catch {
return false;
}
}
export function plaintextBearerAuthMessage(baseUrl: string): string {
return `agentmemory: AGENTMEMORY_SECRET is configured for plaintext HTTP to ${baseUrl}. Bearer tokens and memory payloads can be observed on the network; use HTTPS or an SSH tunnel.`;
}
export function createPlaintextBearerAuthGuard(
warn: (message: string) => void = (message) => console.warn(message),
env?: { AGENTMEMORY_REQUIRE_HTTPS?: string },
): (baseUrl: string, secret?: string) => void {
let warned = false;
return (baseUrl, secret) => {
if (!usesPlaintextBearerAuth(baseUrl, secret)) return;
const message = plaintextBearerAuthMessage(baseUrl);
if ((env || process.env).AGENTMEMORY_REQUIRE_HTTPS === "1") throw new Error(message);
if (!warned) {
warned = true;
warn(message);
}
};
}