chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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