chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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.
|
||||
Executable
+28
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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" />
|
||||
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 |
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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" />
|
||||
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)" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "agentmemory",
|
||||
"version": "0.9.4",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./plugin.mjs"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -0,0 +1,77 @@
|
||||
<p align="center">
|
||||
<img src="../../assets/banner.png" alt="agentmemory" width="640" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">
|
||||
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)
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "agentmemory-pi-extension",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user