import * as fs from "node:fs/promises"; import * as path from "node:path"; import { isEnoent } from "@f5-sales-demo/pi-utils"; import { validateRelativePath } from "./skill-protocol"; import type { InternalResource, InternalUrl, ProtocolHandler } from "./types"; const DEFAULT_MEMORY_FILE = "memory_summary.md"; const MEMORY_NAMESPACE = "root"; /** * Options for the memory:// URL protocol. */ export interface MemoryProtocolOptions { /** * Returns the absolute path to the current project's memory root. */ getMemoryRoot: () => string; } 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); } /** * Protocol handler for memory:// URLs. * * URL forms: * - memory://root - Reads memory_summary.md * - memory://root/ - Reads a relative file under memory root */ export class MemoryProtocolHandler implements ProtocolHandler { readonly scheme = "memory"; constructor(private readonly options: MemoryProtocolOptions) {} async resolve(url: InternalUrl): Promise { const memoryRoot = path.resolve(this.options.getMemoryRoot()); let resolvedRoot: string; try { resolvedRoot = await fs.realpath(memoryRoot); } catch (error) { if (isEnoent(error)) { throw new Error( "Memory artifacts are not available for this project yet. Run a session with memories enabled first.", ); } 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)) { throw new Error(`Memory file not found: ${url.href}`); } 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: [], }; } }