import * as fs from "node:fs/promises"; import * as path from "node:path"; import { getAgentDir, isEnoent } from "@oh-my-pi/pi-utils"; import { getMemoryRoot } from "../memories"; import { AgentRegistry } from "../registry/agent-registry"; import { validateRelativePath } from "./skill-protocol"; import type { InternalResource, InternalUrl, ProtocolHandler } from "./types"; const DEFAULT_MEMORY_FILE = "memory_summary.md"; const MEMORY_NAMESPACE = "root"; /** * Snapshot of memory roots for every registered session, deduped. * Each session has its own cwd (possibly a worktree), so subagents and main * may see different roots. */ export function memoryRootsFromRegistry(): string[] { const agentDir = getAgentDir(); const roots: string[] = []; for (const ref of AgentRegistry.global().list()) { const sm = ref.session?.sessionManager; if (!sm) continue; const root = getMemoryRoot(agentDir, sm.getCwd()); if (root && !roots.includes(root)) roots.push(root); } return roots; } function ensureWithinRoot(targetPath: string, rootPath: string): void { if (targetPath !== rootPath && !targetPath.startsWith(`${rootPath}${path.sep}`)) { throw new Error("memory:// URL escapes memory root"); } } function toMemoryValidationError(error: unknown): Error { const message = error instanceof Error ? error.message : String(error); return new Error(message.replace("skill://", "memory://")); } /** * Resolve a memory:// URL to an absolute filesystem path under memory root. */ export function resolveMemoryUrlToPath(url: InternalUrl, memoryRoot: string): string { const namespace = url.rawHost || url.hostname; if (!namespace) { throw new Error("memory:// URL requires a namespace: memory://root"); } if (namespace !== MEMORY_NAMESPACE) { throw new Error(`Unknown memory namespace: ${namespace}. Supported: ${MEMORY_NAMESPACE}`); } const rawPathname = url.rawPathname ?? url.pathname; const hasPath = rawPathname && rawPathname !== "/" && rawPathname !== ""; if (!hasPath) { return path.resolve(memoryRoot, DEFAULT_MEMORY_FILE); } let relativePath: string; try { relativePath = decodeURIComponent(rawPathname.slice(1)); } catch { throw new Error(`Invalid URL encoding in memory:// path: ${url.href}`); } try { validateRelativePath(relativePath); } catch (error) { throw toMemoryValidationError(error); } return path.resolve(memoryRoot, relativePath); } async function tryResolveInRoot(url: InternalUrl, memoryRoot: string): Promise { const resolved = path.resolve(memoryRoot); let resolvedRoot: string; try { resolvedRoot = await fs.realpath(resolved); } catch (error) { if (isEnoent(error)) return undefined; throw error; } const targetPath = resolveMemoryUrlToPath(url, resolvedRoot); ensureWithinRoot(targetPath, resolvedRoot); const parentDir = path.dirname(targetPath); try { const realParent = await fs.realpath(parentDir); ensureWithinRoot(realParent, resolvedRoot); } catch (error) { if (!isEnoent(error)) throw error; } let realTargetPath: string; try { realTargetPath = await fs.realpath(targetPath); } catch (error) { if (isEnoent(error)) return undefined; throw error; } ensureWithinRoot(realTargetPath, resolvedRoot); const stat = await fs.stat(realTargetPath); if (!stat.isFile()) { throw new Error(`memory:// URL must resolve to a file: ${url.href}`); } const content = await Bun.file(realTargetPath).text(); const ext = path.extname(realTargetPath).toLowerCase(); const contentType: InternalResource["contentType"] = ext === ".md" ? "text/markdown" : "text/plain"; return { url: url.href, content, contentType, size: Buffer.byteLength(content, "utf-8"), sourcePath: realTargetPath, notes: [], }; } /** * Protocol handler for memory:// URLs. * * Walks every active session's memory root. Worktree-based subagents have * their own root; first one containing the file wins. Parent and subagent * sharing a cwd see the same file regardless of order. */ export class MemoryProtocolHandler implements ProtocolHandler { readonly scheme = "memory"; readonly immutable = true; async resolve(url: InternalUrl): Promise { const roots = memoryRootsFromRegistry(); if (roots.length === 0) { throw new Error( "Memory artifacts are not available for this project yet. Run a session with memories enabled first.", ); } let anyExists = false; for (const root of roots) { try { await fs.stat(root); anyExists = true; } catch (error) { if (isEnoent(error)) continue; throw error; } const result = await tryResolveInRoot(url, root); if (result) return result; } if (!anyExists) { throw new Error( "Memory artifacts are not available for this project yet. Run a session with memories enabled first.", ); } throw new Error(`Memory file not found: ${url.href}`); } }