diff --git a/integrations/omp/README.md b/integrations/omp/README.md
new file mode 100644
index 000000000..a54c6b594
--- /dev/null
+++ b/integrations/omp/README.md
@@ -0,0 +1,83 @@
+
+
+
+
+
+ agentmemory for Oh My Pi (omp)
+
+
+
+ Your omp sessions remember everything. No more re-explaining.
+ Persistent cross-session memory via agentmemory — shared with Claude Code, Codex CLI, Gemini CLI, Hermes, OpenClaw, pi, and more.
+
+
+---
+
+## Quick setup
+
+Start the agentmemory server in a separate terminal:
+
+```bash
+npx @agentmemory/agentmemory
+```
+
+Install the extension into omp (links this folder into omp's plugin directory):
+
+```bash
+omp plugin install ./integrations/omp
+```
+
+Then restart omp for the extension to load. Verify it was picked up:
+
+```bash
+omp plugin doctor
+# ✔ plugins_directory: Found at ...
+# ✔ plugin:agentmemory-omp-extension: v0.9.28
+```
+
+Alternatively, `omp install ./integrations/omp` is an alias for the same command.
+
+> **Note:** Use `omp plugin install` only. Do **not** copy this folder to
+> `~/.omp/agent/extensions/agentmemory/` — omp scans that directory too, so the
+> extension would load twice and every tool call would be observed twice.
+
+## 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 omp
+- 15 lifecycle hooks — injects relevant memories at every stage of the agent loop (before/after tool calls, session start/end, context updates, and consolidation events)
+
+## Environment variables
+
+| Variable | Default | Description |
+|---|---|---|
+| `AGENTMEMORY_URL` | `http://localhost:3111` | agentmemory server URL |
+| `AGENTMEMORY_SECRET` | (none) | Bearer token for protected instances |
+| `AGENTMEMORY_PROJECT_NAME` | (unset) | Overrides the project name used in session/observation payloads. Defaults to the git repo basename (or cwd basename if not a git repo). |
+| `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. |
+| `AGENTMEMORY_INJECT_CONTEXT` | (off) | When set to `1`, injects file enrichment context (via `/enrich`) into the conversation for files touched by tools. |
+| `AGENTMEMORY_CONSOLIDATION_ENABLED` | (on) | When set to `0`, disables background memory consolidation jobs. |
+
+## Smoke test
+
+Run omp 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 omp's extension API, not MCP, so it can hook directly into the agent lifecycle with 15 granular hooks covering the full conversation loop.
+- One local agentmemory server can be shared across omp, pi, pi2, Hermes, OpenClaw, Claude Code, Codex CLI, and Gemini CLI.
+
+## See also
+
+- [agentmemory main README](../../README.md)
+- [pi integration](../pi/README.md)
+- [Hermes integration](../hermes/README.md)
+- [OpenClaw integration](../openclaw/README.md)
diff --git a/integrations/omp/index.ts b/integrations/omp/index.ts
new file mode 100644
index 000000000..9301048f1
--- /dev/null
+++ b/integrations/omp/index.ts
@@ -0,0 +1,586 @@
+import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
+import path from "node:path";
+import crypto from "node:crypto";
+import { execSync } from "node:child_process";
+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 DEDUP_WINDOW_MS = 5 * 60 * 1000;
+const DEFAULT_TIMEOUT_MS = 5000;
+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 getBaseUrl(): string {
+ return DEFAULT_URL.replace(/\/+$/, "");
+}
+
+// Mirrors src/state/schema.ts primitives — the extension is a standalone
+// package that connects to the server over HTTP, so the id helpers are
+// inlined instead of imported from server internals.
+function generateId(prefix: string): string {
+ const ts = Date.now().toString(36);
+ const rand = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
+ return `${prefix}_${ts}_${rand}`;
+}
+
+function fingerprintId(prefix: string, content: string): string {
+ const hash = crypto.createHash("sha256").update(content).digest("hex");
+ return `${prefix}_${hash.slice(0, 16)}`;
+}
+
+function isBase64Image(val: unknown): val is string {
+ if (typeof val !== "string") return false;
+ return val.startsWith("data:image/") || val.startsWith("iVBORw0KGgo") || val.startsWith("/9j/");
+}
+
+function stripImageData(text: string): string {
+ if (isBase64Image(text)) return "[image data]";
+ return text;
+}
+
+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");
+}
+
+function resolveProject(cwd?: string): string {
+ const explicit = process.env["AGENTMEMORY_PROJECT_NAME"];
+ if (explicit && explicit.trim()) return explicit.trim();
+ const dir = cwd && cwd.trim() ? cwd : process.cwd();
+ try {
+ const top = execSync("git rev-parse --show-toplevel", {
+ cwd: dir,
+ stdio: ["ignore", "pipe", "ignore"],
+ timeout: 500,
+ }).toString().trim();
+ if (top) return path.basename(top);
+ } catch {}
+ return path.basename(dir);
+}
+
+async function callAgentMemory(
+ pathname: string,
+ options?: {
+ method?: "GET" | "POST";
+ body?: unknown;
+ baseUrl?: string;
+ timeoutMs?: number;
+ },
+): Promise {
+ const baseUrl = (options?.baseUrl || getBaseUrl()).replace(/\/+$/, "");
+ const method = options?.method || "POST";
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
+ const url = `${baseUrl}/agentmemory/${pathname.replace(/^\/+/, "")}`;
+ const headers: Record = {};
+ 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,
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ 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(getBaseUrl(), process.env["AGENTMEMORY_SECRET"]);
+ }
+
+ let sessionId = generateId("ephemeral");
+ let currentProject = resolveProject();
+ let currentCwd = process.cwd();
+ let lastPrompt = "";
+ let lastHealthOk = false;
+ let cachedContext: string | undefined;
+
+ const recentHashes = new Map();
+ const pendingPosts = new Set>();
+ const fileStash = new Set();
+
+ function trackPost(promise: Promise): void {
+ pendingPosts.add(promise);
+ void promise.finally(() => pendingPosts.delete(promise));
+ }
+
+ function isDuplicate(data: string): boolean {
+ const hash = fingerprintId("dedup", data);
+ const now = Date.now();
+ const prev = recentHashes.get(hash);
+ if (prev && now - prev < DEDUP_WINDOW_MS) return true;
+ if (recentHashes.size > 500) {
+ for (const [k, ts] of recentHashes) {
+ if (now - ts >= DEDUP_WINDOW_MS) recentHashes.delete(k);
+ }
+ }
+ recentHashes.set(hash, now);
+ return false;
+ }
+
+ function observePayload(hookType: string, data: Record) {
+ return {
+ hookType,
+ sessionId,
+ project: currentProject,
+ cwd: currentCwd,
+ timestamp: new Date().toISOString(),
+ data,
+ };
+ }
+
+ async function getHealth() {
+ return await callAgentMemory("health", { method: "GET", timeoutMs: 2000 });
+ }
+
+ 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");
+ }
+
+ const FILE_TOOL_NAMES = new Set(["read", "write", "edit", "glob", "grep"]);
+ const FILE_PARAM_KEYS = ["file_path", "path", "file", "pattern"];
+
+ function extractFilePaths(input: Record): string[] {
+ const files: string[] = [];
+ for (const key of FILE_PARAM_KEYS) {
+ const val = input[key];
+ if (typeof val === "string" && val.length > 0) files.push(val);
+ }
+ return files;
+ }
+
+ 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 ${getBaseUrl()}`, "warning");
+ return;
+ }
+ ctx.ui.notify(
+ `agentmemory ${health.status || health.health?.status || "unknown"}${health.version ? ` v${health.version}` : ""}`,
+ "info",
+ );
+ },
+ });
+
+ const { z } = pi.zod;
+
+ pi.registerTool({
+ name: "memory_health",
+ label: "Memory Health",
+ description: "Check whether the local agentmemory server is reachable and healthy",
+ parameters: z.object({}),
+ async execute() {
+ const health = await getHealth();
+ if (!health) {
+ return {
+ content: [{ type: "text", text: `agentmemory is unreachable at ${getBaseUrl()}` }],
+ 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: z.object({
+ query: z.string().describe("What to search for in memory"),
+ limit: z.number().int().min(1).max(10).default(5).describe("Maximum results"),
+ }),
+ async execute(_toolCallId, params) {
+ const result = await callAgentMemory<{ results?: SmartSearchResult[] }>("smart-search", {
+ body: { query: params.query, limit: params.limit ?? 5 },
+ timeoutMs: 3000,
+ });
+ 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: z.object({
+ content: z.string().describe("What should be remembered"),
+ type: z.string().default("fact").describe("Memory type"),
+ }),
+ async execute(_toolCallId, params) {
+ const result = await callAgentMemory>("remember", {
+ body: { content: params.content, type: params.type || "fact" },
+ timeoutMs: 3000,
+ });
+ 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,
+ };
+ },
+ });
+
+ // session_start — register session and load project profile
+ pi.on("session_start", async (_event, ctx) => {
+ const sessionFile = ctx.sessionManager.getSessionFile();
+ sessionId = sessionFile
+ ? path.basename(sessionFile).replace(/\.[^.]+$/, "")
+ : generateId("ephemeral");
+ currentProject = resolveProject();
+ currentCwd = process.cwd();
+ await refreshStatus(ctx);
+ if (lastHealthOk) {
+ await callAgentMemory("session/start", {
+ body: { sessionId, project: currentProject, cwd: currentCwd },
+ timeoutMs: 2000,
+ });
+ }
+ });
+
+ // before_agent_start — recall relevant memories and inject into system prompt
+ pi.on("before_agent_start", async (event, ctx) => {
+ currentProject = resolveProject();
+ const eventCwd = (event as Record).systemPromptOptions as
+ | { cwd?: string }
+ | undefined;
+ currentCwd = eventCwd?.cwd || process.cwd();
+ const prompt = (event as Record).prompt as string | undefined;
+ lastPrompt = prompt?.trim() || "";
+ if (!lastPrompt) return;
+
+ await refreshStatus(ctx);
+
+ let recallBlock = "";
+ if (lastHealthOk) {
+ trackPost(callAgentMemory("observe", {
+ body: {
+ hookType: "prompt_submit",
+ sessionId,
+ project: currentProject,
+ cwd: currentCwd,
+ timestamp: new Date().toISOString(),
+ data: { prompt: lastPrompt },
+ },
+ timeoutMs: 3000,
+ }));
+
+ const result = await callAgentMemory<{ results?: SmartSearchResult[] }>("smart-search", {
+ body: { query: lastPrompt, limit: 5 },
+ timeoutMs: 3000,
+ });
+ const results = result?.results || [];
+ if (results.length) {
+ recallBlock = [
+ "Relevant long-term memory from agentmemory:",
+ formatSearchResults(results),
+ ].join("\n");
+ }
+ }
+
+ const systemPrompt = (event as Record).systemPrompt as string | undefined;
+ return {
+ systemPrompt: [systemPrompt, TOOL_GUIDANCE, recallBlock].filter(Boolean).join("\n\n"),
+ };
+ });
+
+ // tool_call — stash file paths for enrich pipeline (Claude pre-tool-use pattern)
+ pi.on("tool_call", (event) => {
+ const toolName = (event.toolName || "").toLowerCase();
+ if (!FILE_TOOL_NAMES.has(toolName)) return;
+ const input = event.input as Record | undefined;
+ if (!input) return;
+ for (const fp of extractFilePaths(input)) {
+ fileStash.add(fp);
+ }
+ });
+
+ // tool_result — per-tool observe (primary data collection hook)
+ pi.on("tool_result", (event) => {
+ if (!lastHealthOk) return;
+
+ const output = stripImageData(getText(event.content));
+ const input = JSON.stringify(event.input ?? {});
+ if (isDuplicate(`${event.toolName}:${input}:${output}`)) return;
+
+ trackPost(callAgentMemory("observe", {
+ body: observePayload("post_tool_use", {
+ tool_name: event.toolName,
+ tool_input: input.slice(0, 8000),
+ tool_output: output.slice(0, 8000),
+ is_error: event.isError ?? false,
+ }),
+ timeoutMs: 3000,
+ }));
+ });
+
+ // context — truncate large tool outputs; optionally inject file enrichment
+ pi.on("context", async (event) => {
+ const MAX_TOOL_OUTPUT = 8000;
+ const trimmed = event.messages.map((msg) => {
+ if (msg.role !== "toolResult") return msg;
+ const content = msg.content.map((chunk) => {
+ if (chunk.type !== "text" || chunk.text.length <= MAX_TOOL_OUTPUT) return chunk;
+ return { ...chunk, text: chunk.text.slice(0, MAX_TOOL_OUTPUT) + "\n[... truncated by agentmemory]" };
+ });
+ return { ...msg, content };
+ });
+
+ const enrichEnabled = process.env["AGENTMEMORY_INJECT_CONTEXT"] === "1";
+ if (enrichEnabled && fileStash.size > 0 && lastHealthOk) {
+ const files = [...fileStash].slice(0, 20);
+ fileStash.clear();
+ try {
+ const result = await callAgentMemory<{ context?: string }>("enrich", {
+ body: { sessionId, files, toolName: "enrich_inject" },
+ timeoutMs: 2000,
+ });
+ if (result?.context) {
+ trimmed.push({ role: "system", content: result.context } as never);
+ }
+ } catch {}
+ }
+
+ return { messages: trimmed };
+ });
+
+ // session_before_compact — fetch context summary before compaction
+ pi.on("session_before_compact", async () => {
+ if (!lastHealthOk) return;
+ const result = await callAgentMemory<{ context?: string }>("context", {
+ body: { sessionId, project: currentProject, budget: 1500 },
+ timeoutMs: 5000,
+ });
+ cachedContext = result?.context;
+ });
+
+ // session.compacting — inject cached context into compaction output
+ pi.on("session.compacting", async () => {
+ if (cachedContext) {
+ const ctx = cachedContext;
+ cachedContext = undefined;
+ return { context: [ctx] };
+ }
+ // Cache missed (e.g. session_before_compact is still awaiting the
+ // request or the server was unreachable) — fetch fresh context so the
+ // compaction still gets the summary.
+ if (!lastHealthOk) return;
+ const result = await callAgentMemory<{ context?: string }>("context", {
+ body: { sessionId, project: currentProject, budget: 1500 },
+ timeoutMs: 5000,
+ });
+ if (result?.context) {
+ return { context: [result.context] };
+ }
+ });
+
+ // agent_end — capture conversation turn summary
+ pi.on("agent_end", (event) => {
+ if (!lastHealthOk || !lastPrompt) return;
+ const assistantText = getLastAssistantText(event.messages as unknown[]);
+ if (!assistantText) return;
+
+ if (isDuplicate(`conversation:${lastPrompt}:${assistantText}`)) return;
+
+ trackPost(callAgentMemory("observe", {
+ body: observePayload("post_tool_use", {
+ tool_name: "conversation",
+ tool_input: lastPrompt.slice(0, 8000),
+ tool_output: assistantText.slice(0, 8000),
+ }),
+ timeoutMs: 3000,
+ }));
+ });
+
+ // session_shutdown — drain in-flight posts, consolidate, summarize, end session.
+ // Handler must return within 2s; heavy work runs in a fire-and-forget chain.
+ pi.on("session_shutdown", () => {
+ if (!lastHealthOk) return;
+
+ void (async () => {
+ // Drain pending observe POSTs with a 1s hard cap. Most pendingPosts
+ // were dispatched before session_shutdown fired; the race prevents
+ // a single stalled request from blocking the entire drain.
+ await Promise.race([
+ Promise.allSettled([...pendingPosts]),
+ (() => { const { promise, resolve } = Promise.withResolvers(); setTimeout(resolve, 1000); return promise; })(),
+ ]);
+
+ if (process.env["AGENTMEMORY_CONSOLIDATION_ENABLED"] !== "0") {
+ await callAgentMemory("crystals/auto", {
+ body: { olderThanDays: 0 },
+ timeoutMs: 60000,
+ });
+ await callAgentMemory("consolidate-pipeline", {
+ body: { tier: "all", force: true },
+ timeoutMs: 120000,
+ });
+ }
+
+ await callAgentMemory("summarize", {
+ body: { sessionId },
+ timeoutMs: 120000,
+ });
+
+ await callAgentMemory("session/end", {
+ body: {
+ sessionId,
+ project: currentProject,
+ cwd: currentCwd,
+ timestamp: new Date().toISOString(),
+ },
+ timeoutMs: 5000,
+ });
+ })();
+ });
+
+ // tool_approval_requested — observe permission prompts
+ pi.on("tool_approval_requested", (event) => {
+ if (!lastHealthOk) return;
+ trackPost(callAgentMemory("observe", {
+ body: observePayload("permission_prompt", {
+ tool_name: event.toolName,
+ tool_call_id: (event as Record).toolCallId,
+ }),
+ timeoutMs: 2000,
+ }));
+ });
+
+ // tool_approval_resolved — observe permission responses
+ pi.on("tool_approval_resolved", (event) => {
+ if (!lastHealthOk) return;
+ trackPost(callAgentMemory("observe", {
+ body: observePayload("permission_reply", {
+ tool_name: event.toolName,
+ approved: (event as Record).approved,
+ }),
+ timeoutMs: 2000,
+ }));
+ });
+
+ // auto_compaction_start — observe compaction begin
+ pi.on("auto_compaction_start", () => {
+ if (!lastHealthOk) return;
+ trackPost(callAgentMemory("observe", {
+ body: observePayload("auto_compaction_start", {}),
+ timeoutMs: 2000,
+ }));
+ });
+
+ // auto_compaction_end — observe compaction end
+ pi.on("auto_compaction_end", () => {
+ if (!lastHealthOk) return;
+ trackPost(callAgentMemory("observe", {
+ body: observePayload("auto_compaction_end", {}),
+ timeoutMs: 2000,
+ }));
+ });
+
+ // auto_retry_start — observe retry begin
+ pi.on("auto_retry_start", () => {
+ if (!lastHealthOk) return;
+ trackPost(callAgentMemory("observe", {
+ body: observePayload("auto_retry_start", {}),
+ timeoutMs: 2000,
+ }));
+ });
+
+ // auto_retry_end — observe retry end
+ pi.on("auto_retry_end", () => {
+ if (!lastHealthOk) return;
+ trackPost(callAgentMemory("observe", {
+ body: observePayload("auto_retry_end", {}),
+ timeoutMs: 2000,
+ }));
+ });
+}
diff --git a/integrations/omp/package.json b/integrations/omp/package.json
new file mode 100644
index 000000000..9924255a2
--- /dev/null
+++ b/integrations/omp/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "agentmemory-omp-extension",
+ "private": true,
+ "type": "module",
+ "version": "0.9.28",
+ "omp": {
+ "name": "agentmemory",
+ "description": "Persistent cross-session memory for omp — captures sessions, tools, approvals, and compaction; injects smart-search recall.",
+ "extensions": ["./index.ts"]
+ }
+}
\ No newline at end of file
diff --git a/integrations/omp/security.ts b/integrations/omp/security.ts
new file mode 100644
index 000000000..4db0e5295
--- /dev/null
+++ b/integrations/omp/security.ts
@@ -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);
+ }
+ };
+}