import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { StringEnum } from "@earendil-works/pi-ai"; import Supermemory from "supermemory"; import { Type, type Static } from "typebox"; import { createHash } from "node:crypto"; import { execSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { homedir, hostname } from "node:os"; import { basename, dirname, join, resolve, sep } from "node:path"; const SOURCE = "pi"; const PLUGIN_VERSION = "0.1.0"; const DEFAULT_BASE_URL = "http://localhost:6767"; const CONFIG_PATHS = [ join(homedir(), ".pi", "supermemory.json"), join(homedir(), ".pi", "agent", "supermemory.json"), ]; const USER_ENTITY_CONTEXT = `Developer coding sessions from Pi for a persistent user profile. EXTRACT: - User preferences: preferred languages, frameworks, package managers, editors, workflows, communication style - Stable habits: testing style, code review expectations, formatting preferences, privacy preferences - Durable decisions that apply across projects - Long-lived learnings the user explicitly wants remembered SKIP: - Project-specific architecture unless it reflects a durable user preference - One-off assistant suggestions the user did not accept - Low-level implementation details that only matter inside the current repository`; const PROJECT_ENTITY_CONTEXT = `Project/codebase knowledge from Pi coding sessions. EXTRACT: - Architecture: repo structure, services, modules, data flow, integration boundaries - Conventions: naming, component patterns, API patterns, tests, style rules - Decisions: chosen approaches, tradeoffs, migrations, rejected alternatives - Setup: commands, environment requirements, deployment notes, debugging workflows - Implementation lessons: bugs fixed, root causes, reusable project-specific context SKIP: - Generic user preferences that are not specific to this project - Verbatim assistant explanations unless they became accepted project decisions - Transient command output with no lasting project value`; const DEFAULT_SIGNAL_KEYWORDS = [ "prefer", "like", "love", "use", "hate", "dislike", "avoid", "remember", "forget", "note", "important", "decision", "decided", "chose", "choose", "picked", "switched", "moved", "migrated", "architecture", "pattern", "approach", "design", "tradeoff", "implementation", "refactor", "upgrade", "deprecate", "bug", "fix", "fixed", "solved", "solution", "stack", "framework", "library", "tool", "database", "tests", "deploy", ]; type Scope = "user" | "project" | "both"; interface Config { apiKey?: string; baseUrl: string; similarityThreshold: number; maxMemories: number; maxProfileItems: number; injectProfile: boolean; autoRecall: boolean; autoCapture: boolean; captureToolResults: boolean; captureMinChars: number; captureMaxChars: number; signalExtraction: boolean; signalKeywords: string[]; containerTagPrefix: string; userContainerTag?: string; projectContainerTag?: string; debug: boolean; } interface Tags { user: string; project: string; projectName: string; } interface SearchResultItem { id?: string; memory?: string; content?: string; chunk?: string; similarity?: number; score?: number; title?: string; updatedAt?: string; context?: unknown; documents?: Array<{ id?: string; documentId?: string }>; metadata?: Record | null; } interface StatusDetails { configured: boolean; baseUrl: string; userTag: string; projectTag: string; projectName: string; lastRecallCount: number; lastCapture?: string; error?: string; } const SaveParams = Type.Object({ content: Type.String({ description: "Stable preference, project decision, setup detail, bug fix, or workflow to save" }), scope: Type.Optional(StringEnum(["project", "user"] as const, { description: "Where to save the memory. Defaults to project." })), }); type SaveParams = Static; const SearchParams = Type.Object({ query: Type.String({ description: "Natural language query to search memories" }), scope: Type.Optional(StringEnum(["both", "project", "user"] as const, { description: "Memory scope to search. Defaults to both." })), }); type SearchParams = Static; const ForgetParams = Type.Object({ content: Type.String({ description: "Memory content or natural language description to forget" }), scope: Type.Optional(StringEnum(["both", "project", "user"] as const, { description: "Memory scope to forget from. Defaults to both." })), }); type ForgetParams = Static; function sha256(input: string): string { return createHash("sha256").update(input).digest("hex").slice(0, 16); } function safeExec(command: string, cwd?: string): string | null { try { return execSync(command, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }).trim() || null; } catch { return null; } } function getGitRoot(directory: string): string | null { const isolateWorktrees = process.env.SUPERMEMORY_ISOLATE_WORKTREES === "true"; if (isolateWorktrees) return safeExec("git rev-parse --show-toplevel", directory); const gitCommonDir = safeExec("git rev-parse --git-common-dir", directory); if (!gitCommonDir) return null; if (gitCommonDir === ".git") return safeExec("git rev-parse --show-toplevel", directory); const resolved = resolve(directory, gitCommonDir); if (basename(resolved) === ".git" && !resolved.includes(`${sep}.git${sep}`)) return dirname(resolved); return safeExec("git rev-parse --show-toplevel", directory); } function getGitRepoName(directory: string): string | null { const remoteUrl = safeExec("git remote get-url origin", directory); if (!remoteUrl) return null; const match = remoteUrl.match(/[/:]([^/]+?)(?:\.git)?$/); return match?.[1] ?? null; } function readJson(path: string): Record { if (!existsSync(path)) return {}; try { const parsed = JSON.parse(readFileSync(path, "utf8")); return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record : {}; } catch { return {}; } } function envBool(value: string | undefined): boolean | undefined { if (value === undefined) return undefined; if (["1", "true", "yes", "on"].includes(value.toLowerCase())) return true; if (["0", "false", "no", "off"].includes(value.toLowerCase())) return false; return undefined; } function asNumber(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; } function asStringArray(value: unknown, fallback: string[]): string[] { if (!Array.isArray(value)) return fallback; return value.filter((x): x is string => typeof x === "string" && x.trim().length > 0); } function normalizeBaseUrl(value: unknown): string { const raw = typeof value === "string" && value.trim() ? value.trim() : DEFAULT_BASE_URL; try { const url = new URL(raw); if (url.protocol !== "http:" && url.protocol !== "https:") return DEFAULT_BASE_URL; return raw.replace(/\/$/, ""); } catch { return DEFAULT_BASE_URL; } } function isLocalhostUrl(value: string): boolean { try { const url = new URL(value); return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname); } catch { return false; } } function loadConfig(ctx?: ExtensionContext): Config { const globalConfig = CONFIG_PATHS.reduce((acc, path) => ({ ...acc, ...readJson(path) }), {} as Record); const projectConfig = ctx?.isProjectTrusted?.() ? readJson(join(ctx.cwd, ".pi", "supermemory.json")) : {}; const fileConfig = { ...globalConfig, ...projectConfig }; const apiKey = process.env.SUPERMEMORY_PI_API_KEY || process.env.SUPERMEMORY_API_KEY || (typeof fileConfig.apiKey === "string" ? fileConfig.apiKey : undefined); return { apiKey, baseUrl: normalizeBaseUrl(process.env.SUPERMEMORY_API_URL || process.env.SUPERMEMORY_BASE_URL || fileConfig.baseUrl), similarityThreshold: asNumber(fileConfig.similarityThreshold, 0.6), maxMemories: asNumber(fileConfig.maxMemories, 5), maxProfileItems: asNumber(fileConfig.maxProfileItems, 5), injectProfile: envBool(process.env.SUPERMEMORY_INJECT_PROFILE) ?? (typeof fileConfig.injectProfile === "boolean" ? fileConfig.injectProfile : true), autoRecall: envBool(process.env.SUPERMEMORY_AUTO_RECALL) ?? (typeof fileConfig.autoRecall === "boolean" ? fileConfig.autoRecall : true), autoCapture: envBool(process.env.SUPERMEMORY_AUTO_CAPTURE) ?? (typeof fileConfig.autoCapture === "boolean" ? fileConfig.autoCapture : true), captureToolResults: envBool(process.env.SUPERMEMORY_CAPTURE_TOOLS) ?? (typeof fileConfig.captureToolResults === "boolean" ? fileConfig.captureToolResults : true), captureMinChars: asNumber(fileConfig.captureMinChars, 40), captureMaxChars: asNumber(fileConfig.captureMaxChars, 12_000), signalExtraction: typeof fileConfig.signalExtraction === "boolean" ? fileConfig.signalExtraction : true, signalKeywords: asStringArray(fileConfig.signalKeywords, DEFAULT_SIGNAL_KEYWORDS).map((s) => s.toLowerCase()), containerTagPrefix: typeof fileConfig.containerTagPrefix === "string" ? fileConfig.containerTagPrefix : "pi", userContainerTag: typeof fileConfig.userContainerTag === "string" ? fileConfig.userContainerTag : undefined, projectContainerTag: typeof fileConfig.projectContainerTag === "string" ? fileConfig.projectContainerTag : undefined, debug: envBool(process.env.SUPERMEMORY_DEBUG) ?? (typeof fileConfig.debug === "boolean" ? fileConfig.debug : false), }; } function getTags(config: Config, cwd: string): Tags { const gitRoot = getGitRoot(cwd); const basePath = gitRoot || cwd; const email = safeExec("git config user.email", cwd); const userSeed = email || process.env.USER || process.env.USERNAME || hostname(); const projectName = getGitRepoName(basePath) || basename(basePath) || "unknown"; return { user: config.userContainerTag || `${config.containerTagPrefix}_user_${sha256(userSeed)}`, project: config.projectContainerTag || `${config.containerTagPrefix}_project_${sha256(basePath)}`, projectName, }; } function stripPrivateContent(content: string): string { return content.replace(/[\s\S]*?<\/private>/gi, "[REDACTED]"); } function cleanContent(content: string): string { return stripPrivateContent(content) .replace(/\[SUPERMEMORY(?: LOCAL)? CONTEXT\][\s\S]*?\[END SUPERMEMORY(?: LOCAL)? CONTEXT\]\s*/gi, "") .replace(/[\s\S]*?<\/supermemory-context>\s*/gi, "") .trim(); } function hasSignal(content: string, config: Config): boolean { if (!config.signalExtraction) return true; const lower = content.toLowerCase(); return config.signalKeywords.some((keyword) => lower.includes(keyword)); } function textFromContent(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content.map((part) => { if (!part || typeof part !== "object") return ""; const item = part as { type?: string; text?: string; [key: string]: unknown }; if (item.type === "text" && typeof item.text === "string") return item.text; if (typeof item.text === "string") return item.text; return ""; }).filter(Boolean).join("\n"); } function formatSearchResults(results: SearchResultItem[], limit: number): string { const items = results.slice(0, limit).map((r, i) => { const body = (r.memory || r.chunk || r.content || String(r.context ?? "")).trim(); const score = r.similarity ?? r.score; const suffix = typeof score === "number" ? ` (${score.toFixed(2)})` : ""; return body ? `${i + 1}. ${body}${suffix}` : ""; }).filter(Boolean); return items.length ? items.join("\n") : "No memories found."; } function formatProfile(profile: unknown, maxItems: number): string[] { if (!profile) return []; if (typeof profile === "string") return profile.trim() ? [profile.trim()] : []; if (typeof profile !== "object") return []; const p = profile as { static?: unknown; dynamic?: unknown }; return [...(Array.isArray(p.static) ? p.static : []), ...(Array.isArray(p.dynamic) ? p.dynamic : [])] .filter((x): x is string => typeof x === "string" && x.trim().length > 0) .map((x) => x.trim()) .slice(0, maxItems); } const FORGET_STOPWORDS = new Set([ "a", "an", "and", "are", "as", "ask", "both", "delete", "forget", "from", "in", "item", "memory", "of", "remove", "scope", "scopes", "that", "the", "this", "to", "tool", "your", ]); function normalizeForMatch(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").replace(/\s+/g, " ").trim(); } function forgetQueryMatchesCandidate(query: string, candidate: string): boolean { const normalizedQuery = normalizeForMatch(query); const normalizedCandidate = normalizeForMatch(candidate); if (!normalizedQuery || !normalizedCandidate) return false; if (normalizedCandidate.includes(normalizedQuery)) return true; const tokens = normalizedQuery .split(" ") .filter((token) => token.length >= 3 && !FORGET_STOPWORDS.has(token)); return tokens.length > 0 && tokens.every((token) => normalizedCandidate.includes(token)); } class SupermemoryAdapter { private client: Supermemory | null = null; constructor(private config: Config) {} get configured(): boolean { return !!this.config.apiKey || isLocalhostUrl(this.config.baseUrl); } private getClient(): Supermemory { if (!this.config.apiKey) { throw new Error("Missing SUPERMEMORY_API_KEY. For hosted Supermemory, set SUPERMEMORY_API_KEY. Localhost servers can be used without a key through this adapter's local HTTP path."); } if (!this.client) { this.client = new Supermemory({ apiKey: this.config.apiKey, baseURL: this.config.baseUrl, timeout: 30_000, maxRetries: 1, defaultHeaders: { "x-sm-source": SOURCE, "x-sm-client": SOURCE, "x-sm-plugin-version": PLUGIN_VERSION, }, }); } return this.client; } private async localRequest(path: string, init: RequestInit): Promise { if (!isLocalhostUrl(this.config.baseUrl)) { throw new Error("Unauthenticated requests are only allowed for localhost Supermemory Local. Set SUPERMEMORY_API_KEY for non-local URLs."); } const response = await fetch(`${this.config.baseUrl}${path}`, { ...init, headers: { "Content-Type": "application/json", "x-sm-source": SOURCE, "x-sm-client": SOURCE, "x-sm-plugin-version": PLUGIN_VERSION, ...(init.headers ?? {}), }, }); if (!response.ok) { const text = await response.text().catch(() => ""); throw new Error(`Supermemory Local HTTP ${response.status}${text ? `: ${text}` : ""}`); } return await response.json() as T; } async health(): Promise<{ ok: true } | { ok: false; error: string }> { try { if (!this.configured) return { ok: false, error: "missing API key" }; if (this.config.apiKey) { await this.getClient().search.memories({ q: "health check", limit: 1, threshold: 1 }); } else { await this.localRequest("/v4/search", { method: "POST", body: JSON.stringify({ q: "health check", limit: 1, threshold: 1 }), }); } return { ok: true }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) }; } } async search(query: string, tag: string, limit = this.config.maxMemories): Promise { const body = { q: query, containerTag: tag, threshold: this.config.similarityThreshold, limit, searchMode: "hybrid" as const, }; const result = this.config.apiKey ? await this.getClient().search.memories(body) : await this.localRequest<{ results?: SearchResultItem[] }>("/v4/search", { method: "POST", body: JSON.stringify(body) }); return (result.results ?? []) as SearchResultItem[]; } async profile(tag: string, query?: string): Promise { if (!this.config.injectProfile) return []; const body = { containerTag: tag, q: query, threshold: this.config.similarityThreshold, }; const result = this.config.apiKey ? await this.getClient().profile(body) : await this.localRequest<{ profile?: unknown }>("/v4/profile", { method: "POST", body: JSON.stringify(body) }); return formatProfile((result as { profile?: unknown }).profile, this.config.maxProfileItems); } private async request(path: string, init: RequestInit): Promise { if (!this.config.apiKey) return this.localRequest(path, init); const response = await fetch(`${this.config.baseUrl}${path}`, { ...init, headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.config.apiKey}`, "x-sm-source": SOURCE, "x-sm-client": SOURCE, "x-sm-plugin-version": PLUGIN_VERSION, ...(init.headers ?? {}), }, }); if (!response.ok) { const text = await response.text().catch(() => ""); throw new Error(`Supermemory HTTP ${response.status}${text ? `: ${text}` : ""}`); } return await response.json() as T; } async add(content: string, tag: string, metadata: Record, entityContext: string): Promise<{ id?: string }> { // Store explicit saves as memory entries, not just documents. This makes // supermemory_forget reliable because /v4/memories can delete memory IDs. const body = { containerTag: tag, memories: [{ content, isStatic: true, metadata: { sm_source: SOURCE, sm_client: SOURCE, sm_plugin_version: PLUGIN_VERSION, ...metadata, }, }], entityContext, }; const result = await this.request<{ memories?: Array<{ id?: string }> }>("/v4/memories", { method: "POST", body: JSON.stringify(body), }); return { id: result.memories?.[0]?.id }; } async addDocument(content: string, tag: string, metadata: Record, entityContext: string): Promise<{ id?: string }> { const body = { content, containerTag: tag, metadata: { sm_source: SOURCE, sm_client: SOURCE, sm_plugin_version: PLUGIN_VERSION, ...metadata, }, entityContext, }; const result = this.config.apiKey ? await this.getClient().add(body) : await this.localRequest<{ id?: string }>("/v3/documents", { method: "POST", body: JSON.stringify(body) }); return result as { id?: string }; } async forget(content: string, tag: string): Promise<{ removed: number; memoryIds: string[]; documentIds: string[] }> { const matches = await this.search(content, tag, 5); const preciseMatches = matches.filter((item) => { const text = item.memory || item.chunk || item.content || ""; return forgetQueryMatchesCandidate(content, text); }); const memoryIds = [...new Set(preciseMatches.filter((item) => item.memory).map((item) => item.id).filter((id): id is string => !!id))]; const documentIds = [...new Set(preciseMatches.flatMap((item) => item.documents ?? []).map((doc) => doc.id ?? doc.documentId).filter((id): id is string => !!id))]; let removed = 0; for (const id of memoryIds) { await this.request<{ id?: string; message?: string }>("/v4/memories", { method: "DELETE", body: JSON.stringify({ id, containerTag: tag }), }); removed += 1; } // Search can return document chunks when no memory entry was generated. Delete // those source documents as a fallback so old smoke-test data can still be removed. for (const id of documentIds) { await this.request>(`/v3/documents/${encodeURIComponent(id)}`, { method: "DELETE", }); removed += 1; } if (removed === 0) { await this.request<{ id?: string; message?: string }>("/v4/memories", { method: "DELETE", body: JSON.stringify({ content, containerTag: tag }), }); } return { removed, memoryIds, documentIds }; } } export default function piSupermemory(pi: ExtensionAPI) { let config = loadConfig(); let adapter = new SupermemoryAdapter(config); let tags: Tags | undefined; let lastRecallCount = 0; let lastCapture: string | undefined; let lastError: string | undefined; function refresh(ctx?: ExtensionContext) { config = loadConfig(ctx); adapter = new SupermemoryAdapter(config); tags = getTags(config, ctx?.cwd ?? process.cwd()); } function log(...args: unknown[]) { if (config.debug) console.error("[pi-supermemory]", ...args); } function statusDetails(ctx: ExtensionContext): StatusDetails { refresh(ctx); return { configured: adapter.configured, baseUrl: config.baseUrl, userTag: tags!.user, projectTag: tags!.project, projectName: tags!.projectName, lastRecallCount, lastCapture, error: lastError, }; } function memoryModeLabel(): string { if (!adapter.configured) return "not configured"; if (isLocalhostUrl(config.baseUrl)) return "local"; return "remote"; } function setSupermemoryStatus(ctx: ExtensionContext, state?: "ready" | "recalling" | "error") { const theme = ctx.ui.theme; const brand = theme.fg("accent", "supermemory"); if (!adapter.configured) { ctx.ui.setStatus("supermemory", `${brand}: ${theme.fg("warning", "not configured")}`); return; } if (state === "recalling") { ctx.ui.setStatus("supermemory", `${brand}: ${theme.fg("accent", "recalling")}`); return; } if (lastError || state === "error") { ctx.ui.setStatus("supermemory", `${brand}: ${theme.fg("warning", "error")}`); return; } const mode = theme.fg(isLocalhostUrl(config.baseUrl) ? "success" : "muted", memoryModeLabel()); const recalled = lastRecallCount > 0 ? ` ${theme.fg("muted", `${lastRecallCount} recalled`)}` : ""; ctx.ui.setStatus("supermemory", `${brand}: ${mode}${recalled}`); } async function recall(query: string, ctx: ExtensionContext): Promise { refresh(ctx); if (!config.autoRecall || !adapter.configured || !tags) return ""; setSupermemoryStatus(ctx, "recalling"); try { const [profileFacts, userResults, projectResults] = await Promise.all([ adapter.profile(tags.user, query), adapter.search(query, tags.user, Math.ceil(config.maxMemories / 2)), adapter.search(query, tags.project, Math.ceil(config.maxMemories / 2)), ]); const sections: string[] = []; if (profileFacts.length > 0) sections.push(`[User Profile]\n${profileFacts.map((x, i) => `${i + 1}. ${x}`).join("\n")}`); const combined = [...userResults, ...projectResults]; const seen = new Set(); const memories = combined.filter((item) => { const body = (item.memory || item.chunk || item.content || "").trim().toLowerCase(); if (!body || seen.has(body)) return false; seen.add(body); return true; }).slice(0, config.maxMemories); if (memories.length > 0) sections.push(`[Relevant Memories]\n${formatSearchResults(memories, config.maxMemories)}`); lastRecallCount = profileFacts.length + memories.length; lastError = undefined; setSupermemoryStatus(ctx); if (sections.length === 0) return ""; return `[SUPERMEMORY LOCAL CONTEXT]\n${sections.join("\n\n")}\n[END SUPERMEMORY LOCAL CONTEXT]`; } catch (error) { lastError = error instanceof Error ? error.message : String(error); setSupermemoryStatus(ctx, "error"); log("recall failed", lastError); return ""; } } async function saveMemory(content: string, scope: "user" | "project", ctx: ExtensionContext, source = "tool") { refresh(ctx); if (!adapter.configured || !tags) throw new Error("Supermemory is not configured. Set SUPERMEMORY_API_KEY or SUPERMEMORY_PI_API_KEY."); const cleaned = cleanContent(content); if (!cleaned) throw new Error("Nothing to save after privacy redaction/cleanup."); const tag = scope === "user" ? tags.user : tags.project; const entityContext = scope === "user" ? USER_ENTITY_CONTEXT : PROJECT_ENTITY_CONTEXT; const result = await adapter.add(cleaned, tag, { type: scope === "user" ? "user-preference" : "project-knowledge", source, project: tags.projectName, timestamp: new Date().toISOString(), }, entityContext); lastCapture = `saved ${scope} memory${result.id ? ` (${result.id})` : ""}`; lastError = undefined; return result; } async function autoCapture(event: { messages?: unknown[] }, ctx: ExtensionContext) { refresh(ctx); if (!config.autoCapture || !adapter.configured || !tags) return; const parts: string[] = []; for (const message of event.messages ?? []) { if (!message || typeof message !== "object") continue; const m = message as { role?: string; content?: unknown; toolName?: string; details?: unknown; isError?: boolean }; if (m.role === "user" || m.role === "assistant") { const text = cleanContent(textFromContent(m.content)); if (text) parts.push(`${m.role.toUpperCase()}: ${text}`); } else if (config.captureToolResults && m.role === "toolResult") { const toolText = cleanContent(textFromContent(m.content)); const toolName = m.toolName || "tool"; if (["edit", "write", "bash"].includes(toolName) && toolText) { parts.push(`TOOL ${toolName}${m.isError ? " FAILED" : ""}: ${toolText.slice(0, 1200)}`); } } } const content = parts.join("\n\n").slice(0, config.captureMaxChars); if (content.length < config.captureMinChars || !hasSignal(content, config)) return; try { await adapter.addDocument(`[Pi session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}]\n${content}`, tags.user, { type: "conversation", source: "auto-capture", project: tags.projectName, timestamp: new Date().toISOString(), }, USER_ENTITY_CONTEXT); lastCapture = `auto-captured ${content.length} chars`; lastError = undefined; } catch (error) { lastError = error instanceof Error ? error.message : String(error); log("capture failed", lastError); } } pi.on("session_start", async (_event, ctx) => { refresh(ctx); setSupermemoryStatus(ctx, "ready"); }); pi.on("before_agent_start", async (event, ctx) => { const context = await recall(event.prompt, ctx); if (!context) return; return { systemPrompt: `${event.systemPrompt}\n\n${context}\n\nWhen using Supermemory context, treat it as private local context from the user's machine. Do not quote irrelevant memories.`, }; }); pi.on("agent_end", async (event, ctx) => { await autoCapture(event as { messages?: unknown[] }, ctx); setSupermemoryStatus(ctx); }); pi.registerTool({ name: "supermemory_save", label: "Supermemory Save", description: "Save stable user preferences or project knowledge to private Supermemory memory. Content inside ... is redacted before saving.", promptSnippet: "Save durable user preferences or project knowledge to Supermemory Local memory", promptGuidelines: [ "Use supermemory_save when the user asks to remember/save a durable preference, project decision, setup detail, architecture note, or bug fix.", "Do not use supermemory_save for transient command output or secrets. Wrap sensitive content in tags if it appears in user text.", ], parameters: SaveParams, async execute(_id, params: SaveParams, _signal, _onUpdate, ctx) { const scope = params.scope ?? "project"; const result = await saveMemory(params.content, scope, ctx, "tool"); return { content: [{ type: "text", text: `Saved ${scope} memory${result.id ? ` (${result.id})` : ""}.` }], details: { scope, id: result.id, tags }, }; }, }); pi.registerTool({ name: "supermemory_search", label: "Supermemory Search", description: "Search private Supermemory memories by natural-language query across user, project, or both scopes.", promptSnippet: "Search Supermemory Local memories for user or project context", promptGuidelines: ["Use supermemory_search when the user asks what you remember or when past project/user context would help."], parameters: SearchParams, async execute(_id, params: SearchParams, _signal, _onUpdate, ctx) { refresh(ctx); if (!adapter.configured || !tags) throw new Error("Supermemory is not configured. Set SUPERMEMORY_API_KEY or SUPERMEMORY_PI_API_KEY."); const scope = (params.scope ?? "both") as Scope; const searches: Promise[] = []; if (scope === "both" || scope === "user") searches.push(adapter.search(params.query, tags.user)); if (scope === "both" || scope === "project") searches.push(adapter.search(params.query, tags.project)); const results = (await Promise.all(searches)).flat(); return { content: [{ type: "text", text: formatSearchResults(results, config.maxMemories * 2) }], details: { scope, count: results.length, tags }, }; }, }); pi.registerTool({ name: "supermemory_forget", label: "Supermemory Forget", description: "Forget a memory from private Supermemory user/project scopes by content or natural-language description.", promptSnippet: "Remove outdated or incorrect memories from Supermemory Local", promptGuidelines: ["Use supermemory_forget when the user asks you to forget or remove outdated/incorrect remembered information."], parameters: ForgetParams, async execute(_id, params: ForgetParams, _signal, _onUpdate, ctx) { refresh(ctx); if (!adapter.configured || !tags) throw new Error("Supermemory is not configured. Set SUPERMEMORY_API_KEY or SUPERMEMORY_PI_API_KEY."); const scope = (params.scope ?? "both") as Scope; const targetTags = [ ...(scope === "both" || scope === "user" ? [tags.user] : []), ...(scope === "both" || scope === "project" ? [tags.project] : []), ]; const results = await Promise.allSettled(targetTags.map((tag) => adapter.forget(params.content, tag))); const removed = results.reduce((count, result) => count + (result.status === "fulfilled" ? result.value.removed : 0), 0); const ok = results.filter((r) => r.status === "fulfilled").length; return { content: [{ type: "text", text: `Forgot ${removed} matching item(s) across ${ok}/${targetTags.length} scope(s).` }], details: { scope, ok, removed, results, tags }, }; }, }); pi.registerTool({ name: "supermemory_profile", label: "Supermemory Profile", description: "Fetch private Supermemory user profile facts for the active Pi user scope.", promptSnippet: "Fetch the user's Supermemory profile facts from local memory", parameters: Type.Object({ query: Type.Optional(Type.String({ description: "Optional query to focus profile recall" })), }), async execute(_id, params: { query?: string }, _signal, _onUpdate, ctx) { refresh(ctx); if (!adapter.configured || !tags) throw new Error("Supermemory is not configured. Set SUPERMEMORY_API_KEY or SUPERMEMORY_PI_API_KEY."); const facts = await adapter.profile(tags.user, params.query); return { content: [{ type: "text", text: facts.length ? facts.map((x, i) => `${i + 1}. ${x}`).join("\n") : "No profile facts found." }], details: { count: facts.length, userTag: tags.user }, }; }, }); pi.registerTool({ name: "supermemory_status", label: "Supermemory Status", description: "Check Supermemory configuration, local connection, and active user/project memory scopes.", promptSnippet: "Check Supermemory Local connection and memory scope status", parameters: Type.Object({}), async execute(_id, _params, _signal, _onUpdate, ctx) { const details = statusDetails(ctx); const health = await adapter.health(); const text = [ `Supermemory: ${health.ok ? "connected" : "not connected"}`, `Base URL: ${details.baseUrl}`, `API key: ${config.apiKey ? "set" : isLocalhostUrl(config.baseUrl) ? "local auto" : "missing"}`, `User tag: ${details.userTag}`, `Project tag: ${details.projectTag}`, `Project: ${details.projectName}`, health.ok ? undefined : `Error: ${health.error}`, ].filter(Boolean).join("\n"); return { content: [{ type: "text", text }], details: { ...details, health } }; }, }); pi.registerCommand("supermemory-status", { description: "Show Supermemory Local status and active memory scopes", handler: async (_args, ctx) => { const details = statusDetails(ctx); const health = await adapter.health(); ctx.ui.notify( `Supermemory ${health.ok ? "connected" : "not connected"}\n${details.baseUrl}\nuser: ${details.userTag}\nproject: ${details.projectTag}${health.ok ? "" : `\n${health.error}`}`, health.ok ? "info" : "warning", ); }, }); pi.registerCommand("supermemory-search", { description: "Search Supermemory memories: /supermemory-search ", handler: async (args, ctx) => { refresh(ctx); if (!args.trim()) return ctx.ui.notify("Usage: /supermemory-search ", "warning"); if (!adapter.configured || !tags) return ctx.ui.notify("Supermemory is not configured", "error"); try { const results = [...await adapter.search(args, tags.user), ...await adapter.search(args, tags.project)]; ctx.ui.notify(formatSearchResults(results, config.maxMemories * 2), "info"); } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); } }, }); pi.registerCommand("supermemory-profile", { description: "Show Supermemory user profile facts", handler: async (args, ctx) => { refresh(ctx); if (!adapter.configured || !tags) return ctx.ui.notify("Supermemory is not configured", "error"); try { const facts = await adapter.profile(tags.user, args.trim() || undefined); ctx.ui.notify(facts.length ? facts.map((x, i) => `${i + 1}. ${x}`).join("\n") : "No profile facts found.", "info"); } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); } }, }); pi.registerCommand("supermemory-save", { description: "Save project memory: /supermemory-save ", handler: async (args, ctx) => { if (!args.trim()) return ctx.ui.notify("Usage: /supermemory-save ", "warning"); try { const result = await saveMemory(args, "project", ctx, "command"); ctx.ui.notify(`Saved project memory${result.id ? ` (${result.id})` : ""}`, "info"); } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); } }, }); }