diff --git a/App/backend/README.md b/App/backend/README.md index f4d9fe2e..39ae84bf 100644 --- a/App/backend/README.md +++ b/App/backend/README.md @@ -26,7 +26,7 @@ npm run db:migrate - `adapters/inbound/local-api`: Fastify routes, runtime-token authentication, CORS, SSE, and the Composio MCP bridge. - `adapters/outbound/agent-source`: built-in history readers for Cursor, Claude - Code, Codex, OpenCode, OpenClaw, Hermes, and WorkBuddy. + Code, Codex, Pi, OpenCode, OpenClaw, Hermes, and WorkBuddy. - `adapters/outbound/skill-writer`: Memory skill, hook, command, and plugin installation for the supported agents. - `adapters/outbound/agent-adapter`: manifest, loader, and registry contracts @@ -114,6 +114,7 @@ Every route in this table requires the local runtime token. | Cursor | Windows: `%APPDATA%\Cursor\User`; macOS: `~/Library/Application Support/Cursor/User`; Linux: `${XDG_CONFIG_HOME:-~/.config}/Cursor/User` (`workspaceStorage/*/state.vscdb` and `globalStorage/state.vscdb`) | `~/.cursor/skills/memmy-memory/` and `~/.cursor/hooks.json` | | Claude Code | `~/.claude/projects/**/*.jsonl` | `~/.claude/CLAUDE.md`, `skills/memmy-memory/`, hooks, and the resume command | | Codex | `~/.codex/sessions/**/rollout-*.jsonl` | `~/.codex/AGENTS.md`, `skills/memmy-memory/`, and hooks | +| Pi | `${PI_CODING_AGENT_SESSION_DIR:-~/.pi/agent/sessions}/**/*.jsonl` | `~/.pi/agent/AGENTS.md`, `skills/memmy-memory/`, and native extension | | OpenCode | `${XDG_DATA_HOME:-~/.local/share}/opencode/opencode.db` | `${XDG_CONFIG_HOME:-~/.config}/opencode/AGENTS.md`, `skills/memmy-memory/`, plugin, and resume command | | OpenClaw | SQLite databases under `~/.openclaw/` | Workspace `AGENTS.md`, `~/.openclaw/skills/memmy-memory/`, and the Memory extension | | Hermes | `~/.hermes/sessions/**/*.jsonl` and `~/.hermes/state.db` | `~/.hermes/SOUL.md`, `skills/memmy-memory/`, and Memory/resume plugins | diff --git a/App/backend/src/adapters/inbound/local-api/tests/agent-sources-route.test.ts b/App/backend/src/adapters/inbound/local-api/tests/agent-sources-route.test.ts index b01b9f0b..e6bae39e 100644 --- a/App/backend/src/adapters/inbound/local-api/tests/agent-sources-route.test.ts +++ b/App/backend/src/adapters/inbound/local-api/tests/agent-sources-route.test.ts @@ -415,6 +415,7 @@ describe("agent sources local api routes", () => { "cursor", "claude_code", "codex", + "pi", "opencode", "openclaw", "hermes", diff --git a/App/backend/src/adapters/outbound/agent-paths.ts b/App/backend/src/adapters/outbound/agent-paths.ts index 6fd41175..6fe96cca 100644 --- a/App/backend/src/adapters/outbound/agent-paths.ts +++ b/App/backend/src/adapters/outbound/agent-paths.ts @@ -52,6 +52,24 @@ export function resolveCodexSessionsDirectory(options: ResolveAgentPathOptions = return createAgentPathRuntime(options).pathApi.join(resolveCodexHomeDirectory(options), "sessions"); } +export function resolvePiHomeDirectory(options: ResolveAgentPathOptions = {}): string { + const runtime = createAgentPathRuntime(options); + return resolveConfiguredDirectory( + runtime.environment.PI_CODING_AGENT_DIR, + runtime.pathApi.join(runtime.homeDirectory, ".pi", "agent"), + runtime + ); +} + +export function resolvePiSessionsDirectory(options: ResolveAgentPathOptions = {}): string { + const runtime = createAgentPathRuntime(options); + return resolveConfiguredDirectory( + runtime.environment.PI_CODING_AGENT_SESSION_DIR, + runtime.pathApi.join(resolvePiHomeDirectory(options), "sessions"), + runtime + ); +} + export function resolveOpencodeConfigDirectory(options: ResolveAgentPathOptions = {}): string { const runtime = createAgentPathRuntime(options); const xdgConfigRoot = resolveConfiguredDirectory( diff --git a/App/backend/src/adapters/outbound/agent-source/pi/adapter.ts b/App/backend/src/adapters/outbound/agent-source/pi/adapter.ts new file mode 100644 index 00000000..d84bc43b --- /dev/null +++ b/App/backend/src/adapters/outbound/agent-source/pi/adapter.ts @@ -0,0 +1,106 @@ +/** Pi source adapter module. */ +import { access } from "node:fs/promises"; +import { resolvePiSessionsDirectory } from "../../agent-paths.js"; +import { collectConversationWindow, remainingMessageCapacity } from "../conversation-window.js"; +import { redactSecrets } from "../secret-redactor.js"; +import type { ConversationMessage, ScanOptions, SourceAdapter, SourceDescriptor } from "../types.js"; +import { discoverPiSessions } from "./session-discovery.js"; +import { readPiSession, type RawPiMessage } from "./session-reader.js"; + +const PI_SOURCE_ID = "pi"; + +export interface CreatePiSourceAdapterDeps { + sessionsRoot?: string; + descriptor?: SourceDescriptor; +} + +export function createPiSourceAdapter(deps: CreatePiSourceAdapterDeps = {}): SourceAdapter { + const sessionsRoot = deps.sessionsRoot ?? resolvePiSessionsDirectory(); + const descriptor = deps.descriptor ?? Object.freeze({ + sourceId: PI_SOURCE_ID, + displayName: "Pi", + builtin: true, + dataPath: sessionsRoot + }); + + return { + descriptor, + async detect() { + try { + await access(sessionsRoot); + return true; + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + return false; + } + throw error; + } + }, + async *scan(options: ScanOptions) { + throwIfAborted(options.signal); + options.onProgress?.({ sourceId: descriptor.sourceId, phase: "discover", current: 0, total: 1 }); + const sessions = await discoverPiSessions({ + root: sessionsRoot, + order: options.order === "recent_first" ? "recent_first" : "path_asc", + maxSessions: options.maxScanTargets + }); + options.onProgress?.({ sourceId: descriptor.sourceId, phase: "discover", current: sessions.length, total: sessions.length }); + + let emittedMessages = 0; + for (const [sessionIndex, session] of sessions.entries()) { + throwIfAborted(options.signal); + if (options.maxMessages !== undefined && emittedMessages >= options.maxMessages) { + break; + } + options.onProgress?.({ + sourceId: descriptor.sourceId, + phase: "read", + current: sessionIndex, + total: sessions.length, + message: session.sessionFilePath + }); + const messages = await collectConversationWindow( + readPiSession(session.sessionFilePath, options.signal), + options.since, + options.signal, + remainingMessageCapacity(options.maxMessages, emittedMessages) + ); + for (const rawMessage of messages) { + throwIfAborted(options.signal); + if (options.maxMessages !== undefined && emittedMessages >= options.maxMessages) { + break; + } + emittedMessages += 1; + yield toConversationMessage(descriptor.sourceId, rawMessage, session.workspacePath, session.gitRoot); + } + } + options.onProgress?.({ sourceId: descriptor.sourceId, phase: "done", current: emittedMessages, total: emittedMessages }); + } + }; +} + +function toConversationMessage( + sourceId: string, + rawMessage: RawPiMessage, + workspacePath: string | null, + gitRoot: string | null +): ConversationMessage { + return { + ...rawMessage, + sourceId, + content: redactSecrets(rawMessage.content), + workspacePath, + gitRoot, + rawMeta: Object.freeze({}) + }; +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new DOMException("Pi source scan aborted", "AbortError"); + } +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} diff --git a/App/backend/src/adapters/outbound/agent-source/pi/index.ts b/App/backend/src/adapters/outbound/agent-source/pi/index.ts new file mode 100644 index 00000000..bba100fa --- /dev/null +++ b/App/backend/src/adapters/outbound/agent-source/pi/index.ts @@ -0,0 +1,2 @@ +/** Pi module. */ +export { createPiSourceAdapter } from "./adapter.js"; diff --git a/App/backend/src/adapters/outbound/agent-source/pi/session-discovery.ts b/App/backend/src/adapters/outbound/agent-source/pi/session-discovery.ts new file mode 100644 index 00000000..add146df --- /dev/null +++ b/App/backend/src/adapters/outbound/agent-source/pi/session-discovery.ts @@ -0,0 +1,87 @@ +/** Pi session discovery module. */ +import { existsSync } from "node:fs"; +import { stat } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { readJsonlObjects } from "../jsonl-lines.js"; +import { readDirectoryIfExists } from "../read-directory.js"; + +export interface PiSessionFile { + sessionFilePath: string; + workspacePath: string | null; + gitRoot: string | null; +} + +export interface DiscoverPiSessionsOptions { + root: string; + order?: "path_asc" | "recent_first"; + maxSessions?: number; +} + +export async function discoverPiSessions(options: DiscoverPiSessionsOptions): Promise { + const files = await listSessionFiles(options.root, options.order ?? "path_asc", options.maxSessions); + const sessions: PiSessionFile[] = []; + + for (const sessionFilePath of files) { + const workspacePath = await readSessionCwd(sessionFilePath); + sessions.push({ + sessionFilePath, + workspacePath, + gitRoot: workspacePath ? findGitRoot(workspacePath) : null + }); + } + + return sessions; +} + +async function listSessionFiles( + root: string, + order: "path_asc" | "recent_first", + maxSessions: number | undefined +): Promise { + const files: Array<{ path: string; mtimeMs: number }> = []; + const directories = [root]; + + for (let directoryIndex = 0; directoryIndex < directories.length; directoryIndex += 1) { + const currentDirectory = directories[directoryIndex]!; + for (const entry of await readDirectoryIfExists(currentDirectory)) { + const path = join(currentDirectory, entry.name); + if (entry.isDirectory()) { + directories.push(path); + } else if (entry.isFile() && entry.name.endsWith(".jsonl")) { + const fileStat = await stat(path); + files.push({ path, mtimeMs: fileStat.mtimeMs }); + } + } + } + + return files + .sort((left, right) => order === "recent_first" + ? right.mtimeMs - left.mtimeMs || right.path.localeCompare(left.path) + : left.path.localeCompare(right.path)) + .slice(0, maxSessions ?? files.length) + .map((file) => file.path); +} + +async function readSessionCwd(filePath: string): Promise { + try { + for await (const record of readJsonlObjects(filePath)) { + if (record.type === "session") { + return typeof record.cwd === "string" ? record.cwd : null; + } + } + } catch { + return null; + } + return null; +} + +function findGitRoot(workspacePath: string): string | null { + let current = workspacePath; + while (current !== dirname(current)) { + if (existsSync(join(current, ".git"))) { + return current; + } + current = dirname(current); + } + return existsSync(join(current, ".git")) ? current : null; +} diff --git a/App/backend/src/adapters/outbound/agent-source/pi/session-reader.ts b/App/backend/src/adapters/outbound/agent-source/pi/session-reader.ts new file mode 100644 index 00000000..2bfdddaf --- /dev/null +++ b/App/backend/src/adapters/outbound/agent-source/pi/session-reader.ts @@ -0,0 +1,172 @@ +/** Pi session reader module. */ +import { basename } from "node:path"; +import { readJsonlObjects, type JsonObject } from "../jsonl-lines.js"; + +export interface RawPiMessage { + messageId: string; + conversationId: string; + role: "user" | "assistant" | "tool" | "system"; + content: string; + createdAt: string; +} + +interface PiEntry { + id: string; + parentId: string | null; + record: JsonObject; +} + +export async function* readPiSession(filePath: string, signal?: AbortSignal): AsyncIterable { + const entries: PiEntry[] = []; + const handledEntryIds = new Set(); + let sessionId = basename(filePath, ".jsonl"); + + for await (const record of readJsonlObjects(filePath, signal)) { + if (record.type === "session" && typeof record.id === "string") { + sessionId = record.id; + } + if (typeof record.id === "string") { + entries.push({ + id: record.id, + parentId: typeof record.parentId === "string" ? record.parentId : null, + record + }); + } + collectHandledEntryIds(record, handledEntryIds); + } + + const activeEntryIds = collectActiveBranchIds(entries); + for (const entry of entries) { + if (!activeEntryIds.has(entry.id) || handledEntryIds.has(entry.id)) { + continue; + } + const message = toRawPiMessage(entry.record, sessionId, entry.id); + if (message) { + yield message; + } + } +} + +function collectHandledEntryIds(record: JsonObject, handledEntryIds: Set): void { + if (record.type !== "custom" || record.customType !== "memmy-memory-capture" || !isRecord(record.data)) { + return; + } + if (!Array.isArray(record.data.entryIds)) { + return; + } + for (const entryId of record.data.entryIds) { + if (typeof entryId === "string") handledEntryIds.add(entryId); + } +} + +function collectActiveBranchIds(entries: readonly PiEntry[]): Set { + const byId = new Map(entries.map((entry) => [entry.id, entry])); + const activeIds = new Set(); + let current = entries.at(-1); + while (current && !activeIds.has(current.id)) { + activeIds.add(current.id); + current = current.parentId ? byId.get(current.parentId) : undefined; + } + return activeIds; +} + +function toRawPiMessage(record: JsonObject, sessionId: string, entryId: string): RawPiMessage | null { + if (record.type !== "message" || !isRecord(record.message)) { + return null; + } + const message = record.message; + const role = message.role; + if (role !== "user" && role !== "assistant" && role !== "toolResult" && role !== "system") { + return null; + } + const content = renderContent(message.content, role, message); + if (!content) { + return null; + } + return { + messageId: `${sessionId}:${entryId}`, + conversationId: sessionId, + role: role === "toolResult" ? "tool" : role, + content, + createdAt: normalizeTimestamp(record.timestamp ?? message.timestamp) + }; +} + +function renderContent(content: unknown, role: string, message: Record): string | null { + if (typeof content === "string") { + return content.trim() || null; + } + if (!Array.isArray(content)) { + return null; + } + + const parts: string[] = []; + for (const item of content) { + if (!isRecord(item) || item.type === "thinking") { + continue; + } + if (item.type === "text" && typeof item.text === "string" && item.text.trim()) { + parts.push(item.text.trim()); + continue; + } + if (item.type === "toolCall") { + parts.push(renderToolCall(item)); + continue; + } + if (role === "toolResult") { + const text = typeof item.text === "string" ? item.text : formatValue(item); + if (text.trim()) { + parts.push(text.trim()); + } + } + } + const rendered = parts.filter(Boolean).join("\n\n"); + if (role !== "toolResult" || !rendered) { + return rendered || null; + } + return [ + `Tool: ${normalizeString(message.toolName) || "tool"}`, + normalizeString(message.toolCallId) ? `Call ID: ${normalizeString(message.toolCallId)}` : "", + message.isError === true ? "Status: error" : "", + `Output:\n${rendered}` + ].filter(Boolean).join("\n\n"); +} + +function renderToolCall(item: Record): string { + return [ + `Tool: ${normalizeString(item.name) || "tool"}`, + normalizeString(item.id) ? `Call ID: ${normalizeString(item.id)}` : "", + item.arguments !== undefined ? `Input:\n${formatValue(item.arguments)}` : "" + ].filter(Boolean).join("\n\n"); +} + +function formatValue(value: unknown): string { + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function normalizeTimestamp(value: unknown): string { + if (typeof value === "number") { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? new Date(0).toISOString() : date.toISOString(); + } + if (typeof value === "string") { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? new Date(0).toISOString() : date.toISOString(); + } + return new Date(0).toISOString(); +} + +function normalizeString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/App/backend/src/adapters/outbound/agent-source/pi/tests/adapter.test.ts b/App/backend/src/adapters/outbound/agent-source/pi/tests/adapter.test.ts new file mode 100644 index 00000000..866041cb --- /dev/null +++ b/App/backend/src/adapters/outbound/agent-source/pi/tests/adapter.test.ts @@ -0,0 +1,109 @@ +/** Pi source adapter tests. */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createPiSourceAdapter } from "../index.js"; +import { discoverPiSessions } from "../session-discovery.js"; +import { readPiSession } from "../session-reader.js"; + +let tempDirectory: string | undefined; + +afterEach(() => { + if (tempDirectory) { + rmSync(tempDirectory, { recursive: true, force: true }); + tempDirectory = undefined; + } +}); + +describe("Pi source adapter", () => { + it("reads the active branch with text and tool traces but excludes thinking", async () => { + const fixture = createFixture(); + const messages = await collect(readPiSession(fixture.sessionPath)); + + expect(messages).toEqual([ + expect.objectContaining({ messageId: "pi-session-1:user-1", role: "user", content: expect.stringContaining("sk-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN") }), + expect.objectContaining({ role: "assistant", content: expect.stringContaining("Tool: bash") }), + expect.objectContaining({ role: "tool", content: expect.stringContaining("Tool: bash") }), + expect.objectContaining({ role: "assistant", content: "Done" }) + ]); + expect(messages.map((message) => message.content).join("\n")).not.toContain("private reasoning"); + expect(messages.map((message) => message.content).join("\n")).not.toContain("abandoned answer"); + }); + + it("discovers nested sessions and streams redacted messages", async () => { + const fixture = createFixture(); + const adapter = createPiSourceAdapter({ sessionsRoot: fixture.sessionsRoot }); + + await expect(discoverPiSessions({ root: fixture.sessionsRoot })).resolves.toEqual([ + expect.objectContaining({ sessionFilePath: fixture.sessionPath, workspacePath: fixture.workspacePath }) + ]); + const messages = await collect(adapter.scan({})); + expect(messages[0]).toEqual(expect.objectContaining({ + sourceId: "pi", + conversationId: "pi-session-1", + content: "Use OPENAI_API_KEY=[REDACTED:openai_api_key]", + workspacePath: fixture.workspacePath + })); + }); + + it("treats a missing sessions directory as empty history", async () => { + const sessionsRoot = join(tmpdir(), `memmy-missing-pi-${crypto.randomUUID()}`); + await expect(discoverPiSessions({ root: sessionsRoot })).resolves.toEqual([]); + await expect(collect(createPiSourceAdapter({ sessionsRoot }).scan({}))).resolves.toEqual([]); + }); + + it("honors scan limits and aborts", async () => { + const fixture = createFixture(); + const adapter = createPiSourceAdapter({ sessionsRoot: fixture.sessionsRoot }); + await expect(collect(adapter.scan({ maxMessages: 2 }))).resolves.toHaveLength(2); + + const controller = new AbortController(); + controller.abort(); + await expect(collect(adapter.scan({ signal: controller.signal }))).rejects.toThrow("Pi source scan aborted"); + }); + + it("skips entries already handled by the live extension", async () => { + const fixture = createFixture([ + { + type: "custom", + id: "capture-1", + parentId: "assistant-2", + timestamp: "2026-08-01T00:00:06.000Z", + customType: "memmy-memory-capture", + data: { entryIds: ["user-1", "assistant-1", "tool-1", "assistant-2"], status: "succeeded" } + } + ]); + + await expect(collect(readPiSession(fixture.sessionPath))).resolves.toEqual([]); + }); +}); + +function createFixture(extraRows: Array> = []): { sessionsRoot: string; sessionPath: string; workspacePath: string } { + tempDirectory = mkdtempSync(join(tmpdir(), "memmy-pi-source-")); + const sessionsRoot = join(tempDirectory, "sessions"); + const workspacePath = join(tempDirectory, "workspace"); + const sessionDirectory = join(sessionsRoot, "--workspace--"); + const sessionPath = join(sessionDirectory, "2026-08-01T00-00-00-000Z_pi-session-1.jsonl"); + mkdirSync(sessionDirectory, { recursive: true }); + mkdirSync(workspacePath, { recursive: true }); + const rows = [ + { type: "session", version: 3, id: "pi-session-1", timestamp: "2026-08-01T00:00:00.000Z", cwd: workspacePath }, + { type: "message", id: "user-1", parentId: null, timestamp: "2026-08-01T00:00:01.000Z", message: { role: "user", content: [{ type: "text", text: "Use OPENAI_API_KEY=sk-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN" }] } }, + { type: "message", id: "abandoned", parentId: "user-1", timestamp: "2026-08-01T00:00:02.000Z", message: { role: "assistant", content: [{ type: "text", text: "abandoned answer" }] } }, + { type: "message", id: "assistant-1", parentId: "user-1", timestamp: "2026-08-01T00:00:03.000Z", message: { role: "assistant", content: [{ type: "thinking", thinking: "private reasoning" }, { type: "toolCall", id: "call-1", name: "bash", arguments: { command: "pwd" } }] } }, + { type: "message", id: "tool-1", parentId: "assistant-1", timestamp: "2026-08-01T00:00:04.000Z", message: { role: "toolResult", toolCallId: "call-1", toolName: "bash", content: [{ type: "text", text: "command output" }] } }, + { type: "message", id: "assistant-2", parentId: "tool-1", timestamp: "2026-08-01T00:00:05.000Z", message: { role: "assistant", content: [{ type: "text", text: "Done" }] } }, + ...extraRows + ]; + writeFileSync(sessionPath, `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`, "utf8"); + return { sessionsRoot, sessionPath, workspacePath }; +} + +async function collect(iterable: AsyncIterable): Promise { + const items: T[] = []; + for await (const item of iterable) { + items.push(item); + } + return items; +} diff --git a/App/backend/src/adapters/outbound/agent-source/tests/agent-paths.test.ts b/App/backend/src/adapters/outbound/agent-source/tests/agent-paths.test.ts index 09abf57b..dc5bb199 100644 --- a/App/backend/src/adapters/outbound/agent-source/tests/agent-paths.test.ts +++ b/App/backend/src/adapters/outbound/agent-source/tests/agent-paths.test.ts @@ -12,6 +12,8 @@ import { resolveOpencodeDatabasePath, resolveOpenclawConfigPath, resolveOpenclawStateDirectory, + resolvePiHomeDirectory, + resolvePiSessionsDirectory, resolveWorkbuddyHomeDirectory, resolveWorkbuddyProjectsDirectory } from "../../agent-paths.js"; @@ -25,6 +27,8 @@ const ENVIRONMENT_VARIABLES = [ "OPENCODE_CONFIG_DIR", "OPENCLAW_CONFIG_PATH", "OPENCLAW_STATE_DIR", + "PI_CODING_AGENT_DIR", + "PI_CODING_AGENT_SESSION_DIR", "WORKBUDDY_CONFIG_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME" @@ -48,6 +52,8 @@ describe("agent paths", () => { process.env.HERMES_HOME = "/tmp/hermes-home"; process.env.OPENCLAW_STATE_DIR = "/tmp/openclaw-state"; process.env.OPENCLAW_CONFIG_PATH = "/tmp/openclaw-config.json"; + process.env.PI_CODING_AGENT_DIR = "/tmp/pi-home"; + process.env.PI_CODING_AGENT_SESSION_DIR = "/tmp/pi-sessions"; process.env.WORKBUDDY_CONFIG_DIR = "/tmp/workbuddy-home"; expect(resolveClaudeCodeHomeDirectory()).toBe("/tmp/claude-home"); @@ -55,6 +61,8 @@ describe("agent paths", () => { expect(resolveHermesHomeDirectory()).toBe("/tmp/hermes-home"); expect(resolveOpenclawStateDirectory()).toBe("/tmp/openclaw-state"); expect(resolveOpenclawConfigPath()).toBe("/tmp/openclaw-config.json"); + expect(resolvePiHomeDirectory()).toBe("/tmp/pi-home"); + expect(resolvePiSessionsDirectory()).toBe("/tmp/pi-sessions"); expect(resolveWorkbuddyHomeDirectory()).toBe("/tmp/workbuddy-home"); }); @@ -80,7 +88,7 @@ describe("agent paths", () => { expect(resolveOpencodeConfigDirectory()).toBe("/tmp/custom-opencode"); }); - it("resolves all seven Agent source paths on macOS", () => { + it("resolves all eight Agent source paths on macOS", () => { const options = { platform: "darwin" as const, homeDirectory: "/Users/alice", @@ -93,6 +101,7 @@ describe("agent paths", () => { codex: resolveCodexSessionsDirectory(options), opencode: resolveOpencodeDatabasePath(options), openclaw: resolveOpenclawStateDirectory(options), + pi: resolvePiSessionsDirectory(options), hermes: resolveHermesHomeDirectory(options), workbuddy: resolveWorkbuddyProjectsDirectory(options) }).toEqual({ @@ -101,12 +110,13 @@ describe("agent paths", () => { codex: "/Users/alice/.codex/sessions", opencode: "/Users/alice/.local/share/opencode/opencode.db", openclaw: "/Users/alice/.openclaw", + pi: "/Users/alice/.pi/agent/sessions", hermes: "/Users/alice/.hermes", workbuddy: "/Users/alice/.workbuddy/projects" }); }); - it("resolves all seven Agent source paths on Windows", () => { + it("resolves all eight Agent source paths on Windows", () => { const options = { platform: "win32", homeDirectory: "C:\\Users\\alice", @@ -121,6 +131,7 @@ describe("agent paths", () => { codex: resolveCodexSessionsDirectory(options), opencode: resolveOpencodeDatabasePath(options), openclaw: resolveOpenclawStateDirectory(options), + pi: resolvePiSessionsDirectory(options), hermes: resolveHermesHomeDirectory(options), workbuddy: resolveWorkbuddyProjectsDirectory(options) }).toEqual({ @@ -129,6 +140,7 @@ describe("agent paths", () => { codex: "C:\\Users\\alice\\.codex\\sessions", opencode: "C:\\Users\\alice\\.local\\share\\opencode\\opencode.db", openclaw: "C:\\Users\\alice\\.openclaw", + pi: "C:\\Users\\alice\\.pi\\agent\\sessions", hermes: "C:\\Users\\alice\\.hermes", workbuddy: "C:\\Users\\alice\\.workbuddy\\projects" }); diff --git a/App/backend/src/adapters/outbound/skill-writer/pi/index.ts b/App/backend/src/adapters/outbound/skill-writer/pi/index.ts new file mode 100644 index 00000000..7887ec51 --- /dev/null +++ b/App/backend/src/adapters/outbound/skill-writer/pi/index.ts @@ -0,0 +1,2 @@ +/** Pi module. */ +export { createPiSkillTarget } from "./target.js"; diff --git a/App/backend/src/adapters/outbound/skill-writer/pi/target.ts b/App/backend/src/adapters/outbound/skill-writer/pi/target.ts new file mode 100644 index 00000000..425f781f --- /dev/null +++ b/App/backend/src/adapters/outbound/skill-writer/pi/target.ts @@ -0,0 +1,141 @@ +/** Pi skill target module. */ +import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { resolvePiHomeDirectory } from "../../agent-paths.js"; +import { readMemmyMemoryServiceConfig } from "../memmy-runtime-config.js"; +import { removeMemmySkillDirectory, replaceMemmySkillDirectory } from "../skill-directory.js"; +import { renderMemmyPiExtension } from "../templates/memmy-pi-extension.js"; +import { renderMemmyPluginSkillManifest } from "../templates/memmy-plugin.js"; +import { renderMemmySkillBootstrapManifest } from "../templates/memmy-skill-directory.js"; +import type { SkillManifest, SkillTarget } from "../types.js"; + +const PI_TARGET_ID = "pi"; +const START_MARKER = ""; +const END_MARKER = ""; +const TARGET_FILE_NAME = "AGENTS.md"; +const EXTENSION_DIRECTORY_NAME = "extensions"; +const EXTENSION_FILE_NAME = "memmy-memory.ts"; +const CONFIG_FILE_NAME = "memmy-memory-config.json"; + +export interface CreatePiSkillTargetDeps { + rootDirectory?: string; + memmyConfigPath?: string; +} + +export function createPiSkillTarget(deps: CreatePiSkillTargetDeps = {}): SkillTarget { + const rootDirectory = deps.rootDirectory ?? resolvePiHomeDirectory(); + const memmyConfigPath = deps.memmyConfigPath ?? join(homedir(), ".memmy", "config.yaml"); + + return { + targetId: PI_TARGET_ID, + displayName: "Pi", + async resolveRootDirectory() { + return resolveExistingDirectory(rootDirectory); + }, + async install(manifest) { + const root = await requirePiRoot(rootDirectory); + await installSkill(root, manifest); + }, + async uninstall() { + const root = await resolveExistingDirectory(rootDirectory); + if (!root) return; + await removeBootstrap(root); + await removeMemmySkillDirectory(root); + }, + async isInstalled() { + const root = await resolveExistingDirectory(rootDirectory); + if (!root) return false; + return (await readTextFile(join(root, TARGET_FILE_NAME))).includes(START_MARKER); + }, + async installPlugin() { + const root = await requirePiRoot(rootDirectory); + const extensionDirectory = join(root, EXTENSION_DIRECTORY_NAME); + await mkdir(extensionDirectory, { recursive: true }); + await writeFileAtomically(join(extensionDirectory, EXTENSION_FILE_NAME), renderMemmyPiExtension()); + await writeFileAtomically( + join(extensionDirectory, CONFIG_FILE_NAME), + `${JSON.stringify({ + memmy_config_path: memmyConfigPath, + ...(await readMemmyMemoryServiceConfig(memmyConfigPath)) + }, null, 2)}\n` + ); + await installSkill(root, renderMemmyPluginSkillManifest(PI_TARGET_ID)); + }, + async uninstallPlugin() { + const root = await resolveExistingDirectory(rootDirectory); + if (!root) return; + await rm(join(root, EXTENSION_DIRECTORY_NAME, EXTENSION_FILE_NAME), { force: true }); + await rm(join(root, EXTENSION_DIRECTORY_NAME, CONFIG_FILE_NAME), { force: true }); + await removeBootstrap(root); + await removeMemmySkillDirectory(root); + } + }; +} + +async function installSkill(root: string, manifest: SkillManifest): Promise { + const filePath = join(root, TARGET_FILE_NAME); + const existing = await readTextFile(filePath); + await writeFileAtomically(filePath, upsertMarkerBlock(existing, renderMemmySkillBootstrapManifest(manifest))); + await replaceMemmySkillDirectory(root, manifest); +} + +async function removeBootstrap(root: string): Promise { + const filePath = join(root, TARGET_FILE_NAME); + const existing = await readTextFile(filePath); + if (existing.includes(START_MARKER)) { + await writeFileAtomically(filePath, existing.replace(markerPattern(), "")); + } +} + +function upsertMarkerBlock(existing: string, manifest: SkillManifest): string { + const block = `${manifest.marker}\n${manifest.content.trimEnd()}\n${END_MARKER}\n`; + if (markerPattern().test(existing)) { + return existing.replace(markerPattern(), block); + } + const separator = existing && !existing.endsWith("\n") ? "\n" : ""; + return `${existing}${separator}${block}`; +} + +function markerPattern(): RegExp { + return new RegExp(`${escapeRegExp(START_MARKER)}\\n[\\s\\S]*?${escapeRegExp(END_MARKER)}\\n?`, "m"); +} + +async function requirePiRoot(directory: string): Promise { + const root = await resolveExistingDirectory(directory); + if (!root) throw new Error("Pi is not installed or its directory is unavailable"); + return root; +} + +async function resolveExistingDirectory(directory: string): Promise { + try { + return (await stat(directory)).isDirectory() ? directory : null; + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") return null; + throw error; + } +} + +async function readTextFile(filePath: string): Promise { + try { + return await readFile(filePath, "utf8"); + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") return ""; + throw error; + } +} + +async function writeFileAtomically(filePath: string, content: string): Promise { + await mkdir(dirname(filePath), { recursive: true }); + const tempPath = join(dirname(filePath), `.${basename(filePath)}.${process.pid}.${Date.now()}.tmp`); + await writeFile(tempPath, content, "utf8"); + await rename(tempPath, filePath); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} diff --git a/App/backend/src/adapters/outbound/skill-writer/pi/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/pi/tests/target.test.ts new file mode 100644 index 00000000..7ca772a1 --- /dev/null +++ b/App/backend/src/adapters/outbound/skill-writer/pi/tests/target.test.ts @@ -0,0 +1,249 @@ +/** Pi skill target tests. */ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { createPiSkillTarget } from "../index.js"; +import { renderMemmyDefaultSkillManifest } from "../../templates/memmy-default.js"; + +let tempDirectory: string | undefined; + +afterEach(() => { + if (tempDirectory) { + rmSync(tempDirectory, { recursive: true, force: true }); + tempDirectory = undefined; + } +}); + +describe("Pi skill target", () => { + it("installs the Pi extension, config, bootstrap, and skill idempotently", async () => { + const fixture = createFixture(); + const target = createPiSkillTarget(fixture); + writeFileSync(join(fixture.rootDirectory, "AGENTS.md"), "manual instructions\n", "utf8"); + + await target.installPlugin?.("pi"); + await target.installPlugin?.("pi"); + + const extension = readFileSync(join(fixture.rootDirectory, "extensions", "memmy-memory.ts"), "utf8"); + expect(extension).toContain('pi.on("before_agent_start"'); + expect(extension).toContain('pi.on("agent_settled"'); + expect(extension).toContain('pi.on("input"'); + expect(extension).toContain('pi.registerCommand("memmy-resume"'); + expect(extension).not.toContain('pi.on("agent_end"'); + const config = JSON.parse(readFileSync(join(fixture.rootDirectory, "extensions", "memmy-memory-config.json"), "utf8")); + expect(config).toEqual({ + memmy_config_path: fixture.memmyConfigPath, + endpoint: "http://127.0.0.1:18960", + token: "test-token" + }); + const agents = readFileSync(join(fixture.rootDirectory, "AGENTS.md"), "utf8"); + expect(agents.match(//gu)).toHaveLength(1); + expect(agents).toContain("manual instructions"); + expect(readFileSync(join(fixture.rootDirectory, "skills", "memmy-memory", "SKILL.md"), "utf8")) + .toContain("A Memmy Memory Hook or plugin is installed for this agent."); + }); + + it("uninstalls only Memmy-owned files", async () => { + const fixture = createFixture(); + const target = createPiSkillTarget(fixture); + const unrelatedExtension = join(fixture.rootDirectory, "extensions", "unrelated.ts"); + writeFileSync(unrelatedExtension, "export default () => {};\n", "utf8"); + await target.installPlugin?.("pi"); + + await target.uninstallPlugin?.("pi"); + + expect(existsSync(unrelatedExtension)).toBe(true); + expect(existsSync(join(fixture.rootDirectory, "extensions", "memmy-memory.ts"))).toBe(false); + expect(existsSync(join(fixture.rootDirectory, "skills", "memmy-memory"))).toBe(false); + expect(readFileSync(join(fixture.rootDirectory, "AGENTS.md"), "utf8")).toBe(""); + }); + + it("does not create the Pi directory when Pi is unavailable", async () => { + tempDirectory = mkdtempSync(join(tmpdir(), "memmy-pi-missing-")); + const rootDirectory = join(tempDirectory, ".pi", "agent"); + const target = createPiSkillTarget({ rootDirectory }); + await expect(target.install(renderMemmyDefaultSkillManifest("pi"))).rejects.toThrow("Pi is not installed"); + expect(existsSync(rootDirectory)).toBe(false); + }); + + it("awaits settled capture, preserves status, redacts secrets, and marks handled entries", async () => { + const fixture = createFixture(); + const requests: Array<{ path: string; body: Record }> = []; + let releaseComplete: (() => void) | undefined; + const completeGate = new Promise((resolve) => { + releaseComplete = resolve; + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + const path = new URL(input instanceof Request ? input.url : String(input)).pathname; + const body = init?.body ? JSON.parse(String(init.body)) as Record : {}; + requests.push({ path, body }); + if (path === "/api/v1/sessions/open") return jsonResponse({ sessionId: "pi-memory-session" }); + if (path === "/api/v1/turns/start") return jsonResponse({ + turnId: "pi-live-turn", + episodeId: "pi-episode", + sourceMemoryIds: ["memory-1"], + injectedContext: { markdown: "prior context" } + }); + if (path === "/api/v1/turns/pi-live-turn/complete") { + await completeGate; + return jsonResponse({ turnId: "pi-live-turn" }); + } + return jsonResponse({}, 404); + }; + const target = createPiSkillTarget(fixture); + await target.installPlugin?.("pi"); + + try { + const extensionPath = join(fixture.rootDirectory, "extensions", "memmy-memory.ts"); + const extensionModule = await import(`${pathToFileURL(extensionPath).href}?test=${crypto.randomUUID()}`) as { + default: (pi: unknown) => void; + }; + const handlers = new Map unknown>(); + const markers: Array<{ customType: string; data: unknown }> = []; + extensionModule.default({ + on(event: string, handler: (...args: never[]) => unknown) { + handlers.set(event, handler); + }, + registerCommand() {}, + appendEntry(customType: string, data: unknown) { + markers.push({ customType, data }); + } + }); + const secret = "sk-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN"; + const branch = [ + sessionEntry("parent", null, "system", "setup"), + sessionEntry("user-1", "parent", "user", `First ${secret}`), + sessionEntry("assistant-1", "user-1", "assistant", "Partial answer"), + sessionEntry("user-2", "assistant-1", "user", "Follow-up password=hunter2"), + sessionEntry("assistant-2", "user-2", "assistant", "", "error", `Failed with ${secret}`) + ]; + const context = extensionContext(branch, "parent"); + await handlers.get("before_agent_start")?.({ prompt: `First ${secret}` }, context as never); + + let settled = false; + const settledPromise = Promise.resolve(handlers.get("agent_settled")?.({}, context as never)).then(() => { + settled = true; + }); + await waitFor(() => requests.some((item) => item.path.endsWith("/complete"))); + expect(settled).toBe(false); + releaseComplete?.(); + await settledPromise; + + expect(requests.find((item) => item.path.endsWith("/start"))?.body.query).toBe("First [REDACTED:openai_api_key]"); + expect(requests.find((item) => item.path.endsWith("/complete"))?.body).toMatchObject({ + query: "First [REDACTED:openai_api_key]\n\nFollow-up password=[REDACTED:password]", + answer: "Partial answer", + status: "failed" + }); + expect(markers).toEqual([expect.objectContaining({ + customType: "memmy-memory-capture", + data: expect.objectContaining({ entryIds: ["user-1", "assistant-1", "user-2", "assistant-2"], status: "failed" }) + })]); + } finally { + releaseComplete?.(); + globalThis.fetch = originalFetch; + } + }); + + it("does not complete aborted runs but marks their entries handled", async () => { + const fixture = createFixture(); + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const path = new URL(input instanceof Request ? input.url : String(input)).pathname; + if (path === "/api/v1/sessions/open") return jsonResponse({ sessionId: "pi-memory-session" }); + if (path === "/api/v1/turns/start") return jsonResponse({ + turnId: "pi-aborted-turn", + episodeId: "pi-episode", + injectedContext: { markdown: "" } + }); + throw new Error(`Unexpected request: ${path}`); + }; + const target = createPiSkillTarget(fixture); + await target.installPlugin?.("pi"); + const extensionPath = join(fixture.rootDirectory, "extensions", "memmy-memory.ts"); + const extensionModule = await import(`${pathToFileURL(extensionPath).href}?test=${crypto.randomUUID()}`) as { + default: (pi: unknown) => void; + }; + const handlers = new Map unknown>(); + const markers: Array<{ customType: string; data: unknown }> = []; + extensionModule.default({ + on(event: string, handler: (...args: never[]) => unknown) { + handlers.set(event, handler); + }, + registerCommand() {}, + appendEntry(customType: string, data: unknown) { + markers.push({ customType, data }); + } + }); + try { + const branch = [ + sessionEntry("parent", null, "system", "setup"), + sessionEntry("user-1", "parent", "user", "cancel me"), + sessionEntry("assistant-1", "user-1", "assistant", "partial", "aborted") + ]; + const context = extensionContext(branch, "parent"); + await handlers.get("before_agent_start")?.({ prompt: "cancel me" }, context as never); + await handlers.get("agent_settled")?.({}, context as never); + expect(markers).toEqual([expect.objectContaining({ data: expect.objectContaining({ status: "aborted" }) })]); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + +function createFixture(): { rootDirectory: string; memmyConfigPath: string } { + tempDirectory = mkdtempSync(join(tmpdir(), "memmy-pi-target-")); + const rootDirectory = join(tempDirectory, ".pi", "agent"); + const memmyConfigPath = join(tempDirectory, ".memmy", "config.yaml"); + mkdirSync(join(rootDirectory, "extensions"), { recursive: true }); + mkdirSync(join(tempDirectory, ".memmy"), { recursive: true }); + writeFileSync(memmyConfigPath, 'storage:\n endpoint: "http://127.0.0.1:18960"\n token: "test-token"\n', "utf8"); + return { rootDirectory, memmyConfigPath }; +} + +function sessionEntry( + id: string, + parentId: string | null, + role: string, + text: string, + stopReason = "stop", + errorMessage?: string +): Record { + return { + type: "message", + id, + parentId, + timestamp: new Date().toISOString(), + message: { + role, + content: [{ type: "text", text }], + ...(role === "assistant" ? { stopReason, errorMessage } : {}) + } + }; +} + +function extensionContext(branch: Array>, leafId: string): Record { + return { + cwd: "/tmp/pi-project", + ui: { notify() {} }, + sessionManager: { + getSessionId: () => "pi-session-1", + getLeafId: () => leafId, + getBranch: () => branch + } + }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +async function waitFor(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("Timed out waiting for condition"); +} diff --git a/App/backend/src/adapters/outbound/skill-writer/templates/memmy-pi-extension.ts b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-pi-extension.ts new file mode 100644 index 00000000..4bc68265 --- /dev/null +++ b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-pi-extension.ts @@ -0,0 +1,461 @@ +/** Pi Memmy extension template. */ + +export function renderMemmyPiExtension(): string { + return String.raw`import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const SOURCE = "pi"; +const CONFIG_URL = new URL("./memmy-memory-config.json", import.meta.url); +const DEFAULT_MEMMY_CONFIG_PATH = join(homedir(), ".memmy", "config.yaml"); +const FETCH_TIMEOUT_MS = 45000; +const SEARCH_LIMIT = 20; +const DISPLAY_LIMIT = 5; +const RESUME_STATE_TTL_MS = 10 * 60 * 1000; +const RESUME_CONTEXT_MAX_CHARS = 24000; + +export default function memmyPiExtension(pi: ExtensionAPI): void { + const pendingTurns = new Map(); + let pendingResume: PendingResume | null = null; + let selectedResumeContext = ""; + let captureQueue = Promise.resolve(); + let turnSequence = 0; + + pi.on("before_agent_start", async (event, ctx) => { + const query = sanitizeText(event.prompt); + if (!query || isResumeCommand(query)) { + return; + } + let injectedContext = selectedResumeContext; + selectedResumeContext = ""; + const startParentId = ctx.sessionManager.getLeafId(); + turnSequence += 1; + const requestedTurnId = "pi-turn-" + hashText([ + ctx.sessionManager.getSessionId(), + startParentId || "root", + query, + String(turnSequence) + ].join("\u0000")); + try { + const memmy = await createMemmyClient(); + const externalSessionId = "pi-memory-" + ctx.sessionManager.getSessionId(); + const opened = await memmy.post("/api/v1/sessions/open", { + sessionId: externalSessionId, + source: SOURCE, + workspacePath: ctx.cwd || undefined + }); + const sessionId = normalizeText(opened.sessionId) || externalSessionId; + const turn = await memmy.post("/api/v1/turns/start", { + adapterId: "memmy-pi-extension", + requestId: "pi-start:" + requestedTurnId, + sessionId, + turnId: requestedTurnId, + query: redactSecrets(query), + source: SOURCE + }); + pendingTurns.set(requestedTurnId, { + sessionId, + turnId: normalizeText(turn.turnId) || requestedTurnId, + episodeId: normalizeText(turn.episodeId) || undefined, + sourceMemoryIds: Array.isArray(turn.sourceMemoryIds) ? turn.sourceMemoryIds : undefined, + initialQuery: query, + startParentId + }); + const recalled = normalizeText(turn.injectedContext && turn.injectedContext.markdown); + injectedContext = [injectedContext, recalled].filter(Boolean).join("\n\n"); + } catch { + pendingTurns.delete(requestedTurnId); + } + + if (injectedContext) { + return { + message: { + customType: "memmy-memory-context", + content: renderMemoryContext(injectedContext, query), + display: true + } + }; + } + }); + + pi.on("agent_settled", async (_event, ctx) => { + const captures = settledCaptures(ctx, pendingTurns); + if (!captures.length) { + return; + } + for (const capture of captures) { + pendingTurns.delete(capture.pendingKey); + if (capture.stopReason === "aborted") { + markSessionEntriesHandled(pi, capture.entryIds, "aborted"); + continue; + } + if (!capture.answer) { + continue; + } + const job = captureQueue.then(async () => { + await completeTurn(capture.turn, capture.query, capture.answer, capture.status); + markSessionEntriesHandled(pi, capture.entryIds, capture.status); + }); + captureQueue = job.catch(() => undefined); + await job.catch(() => undefined); + } + }); + + pi.on("session_shutdown", async () => { + pendingTurns.clear(); + pendingResume = null; + selectedResumeContext = ""; + await captureQueue; + }); + + pi.on("input", async (event, ctx) => { + if (event.source === "extension" || !/^[1-5]$/u.test(event.text.trim())) { + return { action: "continue" }; + } + const selection = Number(event.text.trim()); + const state = pendingResume; + if (!state || Date.now() - state.createdAt > RESUME_STATE_TTL_MS) { + pendingResume = null; + return { action: "continue" }; + } + const candidate = state.candidates.find((item) => item.index === selection); + if (!candidate) { + return { action: "continue" }; + } + try { + const memmy = await createMemmyClient(); + const detail = await memmy.get("/api/v1/memory/" + encodeURIComponent(candidate.episodeId)); + pendingResume = null; + selectedResumeContext = buildResumeContext(candidate, detail); + ctx.ui.notify("Resuming Memmy episode " + candidate.episodeId, "info"); + return { + action: "transform", + text: "Continue Memmy episode " + candidate.episodeId + ": " + (candidate.title || candidate.episodeId), + images: event.images + }; + } catch (error) { + ctx.ui.notify("Memmy resume failed: " + formatError(error), "warning"); + return { action: "handled" }; + } + }); + + pi.registerCommand("memmy-resume", { + description: "Find and resume a prior Memmy episode", + handler: async (args, ctx) => { + const query = normalizeText(args); + if (!query) { + ctx.ui.notify("Usage: /memmy-resume ", "warning"); + return; + } + if (query === "cancel") { + pendingResume = null; + ctx.ui.notify("Memmy resume selection cancelled.", "info"); + return; + } + try { + const memmy = await createMemmyClient(); + const result = await memmy.post("/api/v1/memory/search", { + query, + layers: ["L1"], + limit: SEARCH_LIMIT, + verbose: true, + source: SOURCE + }); + const candidates = await buildEpisodeCandidates(memmy, result); + pendingResume = { createdAt: Date.now(), candidates }; + ctx.ui.notify(formatResumeCandidates(query, candidates), "info"); + } catch (error) { + ctx.ui.notify("Memmy resume search failed: " + formatError(error), "warning"); + } + } + }); +} + +interface PendingTurn { + sessionId: string; + turnId: string; + episodeId?: string; + sourceMemoryIds?: unknown[]; + initialQuery: string; + startParentId: string | null; +} + +interface SettledCapture { + pendingKey: string; + turn: PendingTurn; + query: string; + answer: string; + status: "succeeded" | "failed"; + stopReason: string; + entryIds: string[]; +} + +interface ResumeCandidate { + index: number; + episodeId: string; + title: string; + summary: string; +} + +interface PendingResume { + createdAt: number; + candidates: ResumeCandidate[]; +} + +async function completeTurn( + turn: PendingTurn, + query: string, + answer: string, + status: "succeeded" | "failed" +): Promise { + const memmy = await createMemmyClient(); + await memmy.post("/api/v1/turns/" + encodeURIComponent(turn.turnId) + "/complete", { + adapterId: "memmy-pi-extension", + requestId: "pi-complete:" + turn.turnId + ":" + hashText(answer), + sessionId: turn.sessionId, + episodeId: turn.episodeId, + query: redactSecrets(query), + answer: redactSecrets(answer), + status, + source: SOURCE, + sourceMemoryIds: turn.sourceMemoryIds + }); +} + +function settledCaptures(ctx: ExtensionContext, pendingTurns: Map): SettledCapture[] { + const branch = ctx.sessionManager.getBranch(); + const claimedUserEntryIds = new Set(); + const located = [...pendingTurns].flatMap(([pendingKey, turn]) => { + const parentIndex = turn.startParentId ? branch.findIndex((entry) => entry.id === turn.startParentId) : -1; + const firstUserIndex = branch.findIndex((entry, index) => + index > parentIndex && + entry.type === "message" && + entry.message.role === "user" && + !claimedUserEntryIds.has(entry.id) && + messageText(entry.message) === turn.initialQuery + ); + if (firstUserIndex < 0) return []; + claimedUserEntryIds.add(branch[firstUserIndex]!.id); + return [{ pendingKey, turn, firstUserIndex }]; + }).sort((left, right) => left.firstUserIndex - right.firstUserIndex); + const captures: SettledCapture[] = []; + for (const [index, current] of located.entries()) { + const nextStartIndex = located[index + 1]?.firstUserIndex ?? branch.length; + const runEntries = branch.slice(current.firstUserIndex, nextStartIndex).filter((entry) => entry.type === "message"); + const userTexts = runEntries + .filter((entry) => entry.type === "message" && entry.message.role === "user") + .map((entry) => entry.type === "message" ? messageText(entry.message) : "") + .filter(Boolean); + const assistantEntries = runEntries.filter((entry) => + entry.type === "message" && entry.message.role === "assistant" + ); + const lastAssistant = assistantEntries.at(-1); + if (!lastAssistant || lastAssistant.type !== "message" || lastAssistant.message.role !== "assistant") { + continue; + } + const stopReason = normalizeText(lastAssistant.message.stopReason); + const assistantTexts = assistantEntries + .map((entry) => entry.type === "message" ? messageText(entry.message) : "") + .filter(Boolean); + const errorMessage = sanitizeText(lastAssistant.message.errorMessage); + captures.push({ + pendingKey: current.pendingKey, + turn: current.turn, + query: userTexts.join("\n\n"), + answer: assistantTexts.join("\n\n") || (stopReason === "error" ? errorMessage : ""), + status: stopReason === "error" ? "failed" : "succeeded", + stopReason, + entryIds: runEntries.map((entry) => entry.id) + }); + } + return captures; +} + +function messageText(message: { content: unknown }): string { + if (typeof message.content === "string") return sanitizeText(message.content); + if (!Array.isArray(message.content)) return ""; + return sanitizeText(message.content + .filter((part): part is { type: "text"; text: string } => isRecord(part) && part.type === "text" && typeof part.text === "string") + .map((part) => part.text) + .join("\n")); +} + +function markSessionEntriesHandled( + pi: ExtensionAPI, + entryIds: string[], + status: "succeeded" | "failed" | "aborted" +): void { + if (!entryIds.length) return; + pi.appendEntry("memmy-memory-capture", { entryIds, status }); +} + +async function buildEpisodeCandidates(memmy: MemmyClient, result: Record): Promise { + const hits = extractHits(result).slice(0, SEARCH_LIMIT); + const candidates = new Map>(); + for (const hit of hits) { + const memoryId = normalizeText(hit.id || hit.memoryId || hit.refId); + if (!memoryId) continue; + const detail = await memmy.get("/api/v1/memory/" + encodeURIComponent(memoryId)).catch(() => ({})); + const refs = isRecord(detail.refs) ? detail.refs : {}; + const episode = isRecord(refs.episode) ? refs.episode : {}; + const episodeId = normalizeText(episode.id || detail.episodeId || hit.episodeId) || + (memoryId.startsWith("episode_") ? memoryId : ""); + if (!episodeId || candidates.has(episodeId)) continue; + candidates.set(episodeId, { + episodeId, + title: normalizeText(episode.title || detail.title || hit.title) || episodeId, + summary: normalizeText(episode.summary || detail.summary || hit.summary || hit.body) + }); + if (candidates.size >= DISPLAY_LIMIT) break; + } + return [...candidates.values()].map((candidate, index) => ({ ...candidate, index: index + 1 })); +} + +function extractHits(result: Record): Record[] { + const debug = isRecord(result.debug) ? result.debug : {}; + for (const value of [result.hits, debug.hits, result.results, debug.results, result.memories, debug.memories]) { + if (Array.isArray(value)) return value.filter(isRecord); + } + return []; +} + +function formatResumeCandidates(query: string, candidates: ResumeCandidate[]): string { + if (!candidates.length) return "No L1 Memmy memories found for: \"" + query + "\""; + return [ + "Memmy resume candidates for \"" + query + "\":", + "", + ...candidates.map((candidate) => candidate.index + ". " + candidate.episodeId + + "\n " + truncate(candidate.title, 160) + + (candidate.summary ? "\n " + truncate(candidate.summary, 260) : "")), + "", + "Enter 1-5 to resume, or /memmy-resume cancel." + ].join("\n"); +} + +function buildResumeContext(candidate: ResumeCandidate, detail: Record): string { + return truncate([ + "The user selected this prior Memmy episode and wants to continue it.", + "Episode id: " + candidate.episodeId, + "Episode title: " + candidate.title, + normalizeText(detail.body) ? "Episode detail:\n" + normalizeText(detail.body) : "", + JSON.stringify(detail, null, 2) + ].filter(Boolean).join("\n\n"), RESUME_CONTEXT_MAX_CHARS); +} + +function renderMemoryContext(markdown: string, query: string): string { + return [ + "", + markdown, + "", + "", + "", + query, + "" + ].join("\n"); +} + +interface MemmyClient { + get(path: string): Promise>; + post(path: string, body: Record): Promise>; +} + +async function createMemmyClient(): Promise { + const localConfig = await readJsonConfig(); + const configPath = normalizeText(process.env.MEMMY_CONFIG) || normalizeText(localConfig.memmy_config_path) || DEFAULT_MEMMY_CONFIG_PATH; + const runtimeConfig = await readYamlConfig(configPath).catch(() => ({})); + const baseUrl = normalizeText(runtimeConfig.endpoint || localConfig.endpoint || "http://127.0.0.1:18960").replace(/\/+$/u, ""); + const token = normalizeText(runtimeConfig.token || localConfig.token); + return { + async get(path) { + return request(new URL(path, baseUrl), { method: "GET", headers: token ? { authorization: "Bearer " + token } : {} }); + }, + async post(path, body) { + const headers: Record = { "content-type": "application/json" }; + if (token) headers.authorization = "Bearer " + token; + return request(new URL(path, baseUrl), { method: "POST", headers, body: JSON.stringify({ ...body, source: SOURCE }) }); + } + }; +} + +async function request(url: URL, init: RequestInit): Promise> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const response = await fetch(url, { ...init, signal: controller.signal }); + const text = await response.text(); + const data = text ? JSON.parse(text) : {}; + if (!response.ok) throw new Error(normalizeText(data?.error?.message) || response.statusText || "Memmy HTTP " + response.status); + return isRecord(data) ? data : {}; + } finally { + clearTimeout(timeout); + } +} + +async function readJsonConfig(): Promise> { + try { + const value = JSON.parse(await readFile(CONFIG_URL, "utf8")); + return isRecord(value) ? value : {}; + } catch { + return {}; + } +} + +async function readYamlConfig(path: string): Promise> { + const content = await readFile(path, "utf8"); + const values: Record = {}; + for (const line of content.split(/\r?\n/u)) { + const match = line.match(/^\s+(endpoint|token):\s*(.*?)\s*$/u); + if (match && !values[match[1]]) values[match[1]] = match[2].replace(/^['"]|['"]$/gu, ""); + } + return values; +} + +function isResumeCommand(value: string): boolean { + return /^\/?memmy-resume(?:\s|$)/u.test(value.trim()); +} + +function sanitizeText(value: unknown): string { + return normalizeText(value).replace(/\u0000/gu, "").trim(); +} + +function redactSecrets(input: string): string { + return redactBase64Runs(input + .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gu, "[REDACTED:ssh_private_key]") + .replace(/\b(Authorization\s*:\s*Bearer\s+)[A-Za-z0-9._~+/=-]+/giu, "$1[REDACTED:authorization_bearer]") + .replace(/\bsk-ant-api\d{2}-[A-Za-z0-9_-]{40,}\b/gu, "[REDACTED:anthropic_api_key]") + .replace(/\bsk-(?:proj-)?[A-Za-z0-9_-]{40,}\b/gu, "[REDACTED:openai_api_key]") + .replace(/\bAIza[A-Za-z0-9_-]{32,}\b/gu, "[REDACTED:google_api_key]") + .replace(/\b([A-Za-z0-9_]*password[A-Za-z0-9_]*\s*[:=]\s*)(?:"[^"\n]+"|'[^'\n]+'|[^\s#&]+)/giu, "$1[REDACTED:password]")); +} + +function redactBase64Runs(input: string): string { + return input.replace(/(^|[^A-Za-z0-9_])([A-Za-z0-9+/]{32,}={0,2})(?=$|[^A-Za-z0-9_])/gu, "$1[REDACTED:base64_secret]"); +} + +function normalizeText(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function truncate(value: string, limit: number): string { + return value.length <= limit ? value : value.slice(0, limit - 1) + "…"; +} + +function hashText(value: string): string { + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +`; +} diff --git a/App/backend/src/analytics/agent-source-analytics.ts b/App/backend/src/analytics/agent-source-analytics.ts index d8b89b89..955c2856 100644 --- a/App/backend/src/analytics/agent-source-analytics.ts +++ b/App/backend/src/analytics/agent-source-analytics.ts @@ -29,7 +29,7 @@ export type AgentSourceInstallType = export type AgentSourceKind = "hook" | "native_plugin" | "skill" | "managed_skill"; const HOOK_AGENT_SOURCE_IDS = new Set(["cursor", "claude_code", "codex"]); -const NATIVE_PLUGIN_AGENT_SOURCE_IDS = new Set(["opencode", "openclaw", "hermes"]); +const NATIVE_PLUGIN_AGENT_SOURCE_IDS = new Set(["pi", "opencode", "openclaw", "hermes"]); const AGENT_SOURCE_ANALYTICS_SOURCE = "memmy-backend"; export type AgentSourceLifecycleAnalytics = { diff --git a/App/backend/src/services/agent-source-auto-inject-service.ts b/App/backend/src/services/agent-source-auto-inject-service.ts index 1ae9ac98..05309704 100644 --- a/App/backend/src/services/agent-source-auto-inject-service.ts +++ b/App/backend/src/services/agent-source-auto-inject-service.ts @@ -3,8 +3,8 @@ import type { AgentSourceAutoInjectResult, ScanPreferences } from "@memmy/local- import type { PermissionManager } from "../permission/index.js"; import type { AgentSourceService } from "./agent-source-service.js"; -const AUTO_INJECT_AGENT_SOURCE_IDS = new Set(["cursor", "claude_code", "codex", "opencode", "openclaw", "hermes", "workbuddy"]); -const HOOK_OR_PLUGIN_AGENT_SOURCE_IDS = new Set(["cursor", "claude_code", "codex", "opencode", "openclaw", "hermes"]); +const AUTO_INJECT_AGENT_SOURCE_IDS = new Set(["cursor", "claude_code", "codex", "pi", "opencode", "openclaw", "hermes", "workbuddy"]); +const HOOK_OR_PLUGIN_AGENT_SOURCE_IDS = new Set(["cursor", "claude_code", "codex", "pi", "opencode", "openclaw", "hermes"]); export interface AgentSourceAutoInjectService { runOnce(): Promise; diff --git a/App/backend/src/services/builtin-agent-source-registry.ts b/App/backend/src/services/builtin-agent-source-registry.ts index d88cc177..f9c84c32 100644 --- a/App/backend/src/services/builtin-agent-source-registry.ts +++ b/App/backend/src/services/builtin-agent-source-registry.ts @@ -4,6 +4,7 @@ import { createCursorSourceAdapter } from "../adapters/outbound/agent-source/cur import { createHermesSourceAdapter } from "../adapters/outbound/agent-source/hermes/index.js"; import { createOpenclawSourceAdapter } from "../adapters/outbound/agent-source/openclaw/index.js"; import { createOpencodeSourceAdapter } from "../adapters/outbound/agent-source/opencode/index.js"; +import { createPiSourceAdapter } from "../adapters/outbound/agent-source/pi/index.js"; import { createSourceRegistry, type SourceRegistry } from "../adapters/outbound/agent-source/source-registry.js"; import { createWorkbuddySourceAdapter } from "../adapters/outbound/agent-source/workbuddy/index.js"; @@ -12,6 +13,7 @@ export function createBuiltinAgentSourceRegistry(): SourceRegistry { createCursorSourceAdapter(), createClaudeCodeSourceAdapter(), createCodexSourceAdapter(), + createPiSourceAdapter(), createOpencodeSourceAdapter(), createOpenclawSourceAdapter(), createHermesSourceAdapter(), diff --git a/App/backend/src/services/index.ts b/App/backend/src/services/index.ts index 90179556..325ad319 100644 --- a/App/backend/src/services/index.ts +++ b/App/backend/src/services/index.ts @@ -15,6 +15,7 @@ import { createCursorSkillTarget } from "../adapters/outbound/skill-writer/curso import { createHermesSkillTarget } from "../adapters/outbound/skill-writer/hermes/index.js"; import { createOpenclawSkillTarget } from "../adapters/outbound/skill-writer/openclaw/index.js"; import { createOpencodeSkillTarget } from "../adapters/outbound/skill-writer/opencode/index.js"; +import { createPiSkillTarget } from "../adapters/outbound/skill-writer/pi/index.js"; import { createWorkbuddySkillTarget } from "../adapters/outbound/skill-writer/workbuddy/index.js"; import { createSkillTargetRegistry, type SkillTargetRegistry } from "../adapters/outbound/skill-writer/target-registry.js"; import type { CloudClient } from "../adapters/outbound/cloud-client/index.js"; @@ -126,6 +127,7 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba createCursorSkillTarget({ memmyConfigPath: options.memmyConfigPath }), createClaudeCodeSkillTarget({ memmyConfigPath: options.memmyConfigPath }), createCodexSkillTarget({ memmyConfigPath: options.memmyConfigPath }), + createPiSkillTarget({ memmyConfigPath: options.memmyConfigPath }), createOpencodeSkillTarget(), createOpenclawSkillTarget({ memmyConfigPath: options.memmyConfigPath }), createHermesSkillTarget({ memmyConfigPath: options.memmyConfigPath }), diff --git a/App/backend/src/services/tests/agent-source-auto-inject-service.test.ts b/App/backend/src/services/tests/agent-source-auto-inject-service.test.ts index 74892a83..a229a011 100644 --- a/App/backend/src/services/tests/agent-source-auto-inject-service.test.ts +++ b/App/backend/src/services/tests/agent-source-auto-inject-service.test.ts @@ -39,11 +39,12 @@ describe("agent source auto inject service", () => { await expect(service.runOnce()).resolves.toEqual({ ok: true, skipped: false, - installed: ["cursor", "opencode", "openclaw", "workbuddy"], + installed: ["cursor", "pi", "opencode", "openclaw", "workbuddy"], failed: [] }); expect(calls).toEqual([ "plugin:cursor:auto_inject", + "plugin:pi:auto_inject", "plugin:opencode:auto_inject", "plugin:openclaw:auto_inject", "skill:workbuddy", @@ -113,6 +114,7 @@ function createAgentSources(calls: string[]) { return [ source("cursor", "not_connected", true), source("codex", "skill_installed", true), + source("pi", "not_connected", true), source("opencode", "not_connected", true), source("openclaw", "not_connected", true), source("workbuddy", "not_connected", true), diff --git a/App/backend/src/services/tests/builtin-agent-source-registry.test.ts b/App/backend/src/services/tests/builtin-agent-source-registry.test.ts index 6177ef32..7dcccec7 100644 --- a/App/backend/src/services/tests/builtin-agent-source-registry.test.ts +++ b/App/backend/src/services/tests/builtin-agent-source-registry.test.ts @@ -9,6 +9,7 @@ describe("built-in agent source registry", () => { "cursor", "claude_code", "codex", + "pi", "opencode", "openclaw", "hermes", diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index d3c6284c..22155c09 100644 --- a/App/frontend/desktop/src/i18n/messages.ts +++ b/App/frontend/desktop/src/i18n/messages.ts @@ -310,7 +310,7 @@ export const zhCNMessages = { "onboarding.permission.title": "Memmy 需要你的授权", "onboarding.permission.subtitle": "为了让所有 AI 都记住同一个你", "onboarding.permission.scanTitle": "扫描已有 Agent 对话", - "onboarding.permission.scanBody": "读取 Cursor / Codex / WorkBuddy 等本地历史对话,生成你的记忆", + "onboarding.permission.scanBody": "读取 Cursor / Codex / Pi / WorkBuddy 等本地历史对话,生成你的记忆", "onboarding.permission.writeTitle": "允许其他Agent使用Memmy的记忆", "onboarding.permission.writeBody": "将会通过插件 / CLI / 修改 AGENTS.md 等方式引导消费记忆", "onboarding.permission.notice": "你可以随时在「记忆管理 -> 接入源管理」中调整授权", @@ -678,7 +678,7 @@ export const zhCNMessages = { "memory.daemonStopped": "已停止", "memory.preferences": "扫描行为", "memory.autoScan": "自动扫描已知 Agent", - "memory.autoScanDescription": "启动时扫描 Cursor / Codex / Claude Code / WorkBuddy 等已安装 Agent 的新对话", + "memory.autoScanDescription": "启动时扫描 Cursor / Codex / Pi / Claude Code / WorkBuddy 等已安装 Agent 的新对话", "memory.watchFiles": "自动增量同步", "memory.watchFilesDescription": "自动跟进 Agent 会话文件的新增内容", "memory.autoInject": "新发现 Agent 自动安装 Hook/插件", @@ -1670,7 +1670,7 @@ export const enUSMessages: Record = { "onboarding.permission.title": "Memmy needs your authorization", "onboarding.permission.subtitle": "Let all AI remember the same you", "onboarding.permission.scanTitle": "Scan existing Agent conversations", - "onboarding.permission.scanBody": "Read local Cursor / Codex / WorkBuddy histories and generate your memory", + "onboarding.permission.scanBody": "Read local Cursor / Codex / Pi / WorkBuddy histories and generate your memory", "onboarding.permission.writeTitle": "Allow other Agents to use Memmy memory", "onboarding.permission.writeBody": "Guide memory consumption through plugins / CLI / modifying AGENTS.md, etc.", "onboarding.permission.notice": "You can change this later in Memory -> Sources", @@ -2038,7 +2038,7 @@ export const enUSMessages: Record = { "memory.daemonStopped": "Stopped", "memory.preferences": "Scan behavior", "memory.autoScan": "Auto-scan known Agents", - "memory.autoScanDescription": "Scan new conversations from installed Agents such as Cursor / Codex / Claude Code / WorkBuddy on startup", + "memory.autoScanDescription": "Scan new conversations from installed Agents such as Cursor / Codex / Pi / Claude Code / WorkBuddy on startup", "memory.watchFiles": "Auto incremental sync", "memory.watchFilesDescription": "Automatically follow newly written Agent conversation files", "memory.autoInject": "Auto-install Hooks/plugins for new Agents", diff --git a/App/frontend/desktop/src/pages/agent-source-logos.ts b/App/frontend/desktop/src/pages/agent-source-logos.ts index 527cde23..fff93c67 100644 --- a/App/frontend/desktop/src/pages/agent-source-logos.ts +++ b/App/frontend/desktop/src/pages/agent-source-logos.ts @@ -12,6 +12,7 @@ export const MEMORY_AGENT_SOURCE_VALUES = [ "cursor", "claude_code", "codex", + "pi", "opencode", "openclaw", "hermes", @@ -24,6 +25,7 @@ const AGENT_SOURCE_DISPLAY_NAMES: Record = { cursor: "Cursor", claude_code: "Claude Code", codex: "Codex", + pi: "Pi", opencode: "OpenCode", openclaw: "OpenClaw", hermes: "Hermes", diff --git a/App/frontend/desktop/src/pages/first-encounter-relay-challenge.tsx b/App/frontend/desktop/src/pages/first-encounter-relay-challenge.tsx index 05736268..61b2e199 100644 --- a/App/frontend/desktop/src/pages/first-encounter-relay-challenge.tsx +++ b/App/frontend/desktop/src/pages/first-encounter-relay-challenge.tsx @@ -22,7 +22,7 @@ export interface FirstEncounterRelayOptInProps { onOpenConnections: () => void; } -const RELAY_AGENT_IDS = new Set(["cursor", "claude_code", "codex", "opencode", "openclaw", "hermes", "workbuddy"]); +const RELAY_AGENT_IDS = new Set(["cursor", "claude_code", "codex", "pi", "opencode", "openclaw", "hermes", "workbuddy"]); type RelayFeedback = | { kind: "copied" } | { kind: "copy_failed" } diff --git a/App/frontend/desktop/src/pages/memory-sources-page.tsx b/App/frontend/desktop/src/pages/memory-sources-page.tsx index 1c2a24f9..e1f1740e 100644 --- a/App/frontend/desktop/src/pages/memory-sources-page.tsx +++ b/App/frontend/desktop/src/pages/memory-sources-page.tsx @@ -1253,7 +1253,7 @@ function SourceStatusBadge(props: { source: Pick{t(labelKey)}; } -const NATIVE_PLUGIN_AGENT_SOURCE_IDS = new Set(["opencode", "openclaw", "hermes"]); +const NATIVE_PLUGIN_AGENT_SOURCE_IDS = new Set(["pi", "opencode", "openclaw", "hermes"]); const HOOK_AGENT_SOURCE_IDS = new Set(["codex", "claude_code", "cursor"]); export function resolveAgentSourceStatusLabelKey(source: Pick): MessageKey { diff --git a/App/frontend/desktop/src/pages/memory/tests/memory-runtime-fixtures.ts b/App/frontend/desktop/src/pages/memory/tests/memory-runtime-fixtures.ts index a3f19e09..9b65d30a 100644 --- a/App/frontend/desktop/src/pages/memory/tests/memory-runtime-fixtures.ts +++ b/App/frontend/desktop/src/pages/memory/tests/memory-runtime-fixtures.ts @@ -559,6 +559,7 @@ function filterMemoryItems(input: PanelItemsInput): PanelItemsOutput { "cursor", "claude_code", "codex", + "pi", "opencode", "openclaw", "hermes" diff --git a/App/frontend/desktop/src/pages/memory/tests/sources-sub-page.test.tsx b/App/frontend/desktop/src/pages/memory/tests/sources-sub-page.test.tsx index cc1258e8..d594d00f 100644 --- a/App/frontend/desktop/src/pages/memory/tests/sources-sub-page.test.tsx +++ b/App/frontend/desktop/src/pages/memory/tests/sources-sub-page.test.tsx @@ -38,7 +38,7 @@ describe("SourcesSubPage", () => { }); it("同步按钮在扫描中旋转,完成后进入不可重复点击的勾选状态", () => { - const sourceIds = ["cursor", "claude_code", "codex", "opencode", "openclaw", "hermes", "workbuddy"]; + const sourceIds = ["cursor", "claude_code", "codex", "pi", "opencode", "openclaw", "hermes", "workbuddy"]; for (const sourceId of sourceIds) { const otherSourceId = sourceIds.find((candidate) => candidate !== sourceId)!; expect(resolveAgentSourceScanButtonState(sourceId, true, sourceId, new Set())).toBe("running"); @@ -265,6 +265,8 @@ describe("SourcesSubPage", () => { expect(resolveAgentSourceConnectionAction(createSource("hermes", "skill_installed"))).toBe("install_plugin"); expect(resolveAgentSourceConnectionAction(createSource("hermes", "plugin_installed"))).toBe("remove_plugin"); expect(resolveAgentSourceConnectionAction(createSource("opencode", "plugin_installed"))).toBe("remove_plugin"); + expect(resolveAgentSourceConnectionAction(createSource("pi", "not_connected"))).toBe("install_plugin"); + expect(resolveAgentSourceConnectionAction(createSource("pi", "plugin_installed"))).toBe("remove_plugin"); expect(resolveAgentSourceConnectionAction(createSource("cursor", "not_connected"))).toBe("install_hook"); expect(resolveAgentSourceConnectionAction(createSource("codex", "skill_installed"))).toBe("install_hook"); expect(resolveAgentSourceConnectionAction(createSource("claude_code", "plugin_installed"))).toBe("remove_hook"); diff --git a/docs/cn/memory/sources.mdx b/docs/cn/memory/sources.mdx index bc3baec4..ff12ced9 100644 --- a/docs/cn/memory/sources.mdx +++ b/docs/cn/memory/sources.mdx @@ -1,6 +1,6 @@ --- title: Agent 来源与扫描 -description: 了解 Memmy 如何扫描六种内置 Agent 的本地历史,以及安装的 Hook、插件和实时记忆效果。 +description: 了解 Memmy 如何扫描七种内置 Agent 的本地历史,以及安装的 Hook、插件和实时记忆效果。 icon: ScanSearch --- @@ -22,6 +22,7 @@ Agent Source 是 Memmy 读取外部 Agent 本地历史的适配器。来源扫 | Cursor | `~/Library/Application Support/Cursor/User/workspaceStorage/**/state.vscdb` 和 `globalStorage/state.vscdb` | Hook | | Claude Code | `~/.claude/projects/**/*.jsonl` | Hook | | Codex | `~/.codex/sessions///
/rollout-*.jsonl` | Hook | +| Pi | `${PI_CODING_AGENT_SESSION_DIR:-~/.pi/agent/sessions}/**/*.jsonl` | 原生 Extension | | OpenCode | `~/.local/share/opencode/opencode.db` | 原生插件 | | OpenClaw | `~/.openclaw` 下的 conversation / memory SQLite | Memory 插件 | | Hermes | `~/.hermes/sessions/**/*.jsonl` 和 `~/.hermes/state.db` | Memory Provider 插件 | @@ -89,6 +90,7 @@ Memmy 通过 Agent 原生支持的 **Hook** 或**插件**接入实时对话。 | Cursor | Hook | `beforeSubmitPrompt`、`afterAgentResponse`、`stop` | 建立回合、记录回复、在回合结束时自动采集,并支持 `/memmy-resume` | | Claude Code | Hook | `UserPromptSubmit`、`Stop` | 请求前召回并注入相关记忆,结束时自动采集,支持 `/memmy-resume` | | Codex | Hook | `UserPromptSubmit`、`Stop` | 请求前召回并注入相关记忆,结束时自动采集,支持 `/memmy-resume` | +| Pi | 原生 Extension | `before_agent_start`、`agent_settled`、`input` | 请求前召回,只采集完全稳定的回合,并支持 `/memmy-resume` | | OpenCode | 原生插件 | 消息、工具、文本完成和会话事件 | 自动召回、采集回复与工具轨迹,并提供原生记忆工具和 `/memmy-resume` | | OpenClaw | Memory 插件 | `before_prompt_build`、`agent_end` | 构建提示词前注入记忆,Agent 结束时采集完整回合,并提供原生记忆工具 | | Hermes | Memory Provider 插件 | `prefetch`、`sync_turn` 等 Provider 生命周期 | 自动召回和采集,镜像 Hermes 的显式记忆写入,并提供原生记忆工具 | @@ -108,11 +110,11 @@ flowchart LR 安装后的共同效果: -- **自动召回**:Claude Code、Codex、OpenCode、OpenClaw 和 Hermes 会在正常请求执行前检索相关记忆并注入上下文。 +- **自动召回**:Claude Code、Codex、Pi、OpenCode、OpenClaw 和 Hermes 会在正常请求执行前检索相关记忆并注入上下文。 - **自动采集**:Hook 或插件会在回合结束时提交用户请求、Agent 回答和成功/失败状态,不需要 Agent 手动执行 `memmy-memory add`。 - **任务接续**:输入 `/memmy-resume ` 会搜索最多 5 个候选 L1 episode;继续输入 `1`–`5` 可读取完整 episode 并注入接续上下文。 - **按需查询**:随接入安装的 Skill 保留 `memmy-memory search` 和 `memmy-memory get`,只在自动上下文不足时使用。 -- **来源标记**:采集结果会记录 `cursor`、`claude_code`、`codex`、`opencode`、`openclaw` 或 `hermes`,便于过滤和追踪来源。 +- **来源标记**:采集结果会记录 `cursor`、`claude_code`、`codex`、`pi`、`opencode`、`openclaw` 或 `hermes`,便于过滤和追踪来源。
@@ -120,7 +122,7 @@ flowchart LR Cursor 当前的 Hook 重点负责自动采集和 /memmy-resume
- 普通请求需要额外记忆时,由随 Hook 安装的 Skill 执行按需搜索;其他五个接入会在普通请求前自动注入召回结果。 + 普通请求需要额外记忆时,由随 Hook 安装的 Skill 执行按需搜索;其他六个接入会在普通请求前自动注入召回结果。
### Cursor:三个 Hook @@ -171,6 +173,21 @@ Memmy 会在 `~/.cursor/hooks.json` 中追加自己的 Hook 条目,不会覆 - `~/.codex/AGENTS.md` - `~/.codex/skills/memmy-memory/SKILL.md` +### Pi:原生 Extension + +| 事件 | 时机 | 行为 | +| --- | --- | --- | +| `before_agent_start` | Prompt 展开后、Agent 循环前 | 打开共享 Memmy 会话、启动回合并注入召回上下文;Memory 不可用时不阻断 Pi | +| `agent_settled` | 重试、压缩重试和队列续跑全部结束后 | 对一次用户运行只采集一次最终 Assistant 回答 | +| `input` | 正常 Prompt 展开前 | 将待选择的 `/memmy-resume` 候选转换为携带完整 Episode 上下文的续接请求 | + +默认写入或更新: + +- `~/.pi/agent/extensions/memmy-memory.ts` +- `~/.pi/agent/extensions/memmy-memory-config.json` +- `~/.pi/agent/AGENTS.md` +- `~/.pi/agent/skills/memmy-memory/SKILL.md` + ### OpenCode:原生插件 | 回调 | 效果 | @@ -242,7 +259,7 @@ Hermes 同样只允许一个活动的 Memory Provider;检测到其他 Provider ### 安装、验证与移除 1. 打开 **记忆管理 → 跨Agent接入**。 -2. Cursor、Claude Code、Codex 点击 **安装 Hook**;OpenCode、OpenClaw、Hermes 点击 **安装插件**。 +2. Cursor、Claude Code、Codex 点击 **安装 Hook**;Pi、OpenCode、OpenClaw、Hermes 点击 **安装插件**。 3. 如果 Agent 正在运行,重启 Agent 或新建会话,让它重新加载配置。 4. 完成一轮普通对话,再到 Memmy 的记忆或日志页面确认对应来源出现新记录。 5. 输入 `/memmy-resume <关键词>`,确认能看到候选并用 `1`–`5` 选择一个 episode。 diff --git a/docs/en/memory/sources.mdx b/docs/en/memory/sources.mdx index 52652446..24ba0fed 100644 --- a/docs/en/memory/sources.mdx +++ b/docs/en/memory/sources.mdx @@ -1,6 +1,6 @@ --- title: Agent Sources & Scanning -description: Learn how Memmy scans local history from six built-in Agents and which Hooks, plugins, and live-memory behaviors it installs. +description: Learn how Memmy scans local history from seven built-in Agents and which Hooks, plugins, and live-memory behaviors it installs. icon: ScanSearch --- @@ -22,6 +22,7 @@ An Agent Source is Memmy's adapter for reading local history from an external Ag | Cursor | `~/Library/Application Support/Cursor/User/workspaceStorage/**/state.vscdb` and `globalStorage/state.vscdb` | Hook | | Claude Code | `~/.claude/projects/**/*.jsonl` | Hook | | Codex | `~/.codex/sessions///
/rollout-*.jsonl` | Hook | +| Pi | `${PI_CODING_AGENT_SESSION_DIR:-~/.pi/agent/sessions}/**/*.jsonl` | Native extension | | OpenCode | `~/.local/share/opencode/opencode.db` | Native plugin | | OpenClaw | Conversation and memory SQLite databases under `~/.openclaw` | Memory plugin | | Hermes | `~/.hermes/sessions/**/*.jsonl` and `~/.hermes/state.db` | Memory Provider plugin | @@ -89,6 +90,7 @@ Memmy connects to live Agent conversations through each host's native **Hooks** | Cursor | Hook | `beforeSubmitPrompt`, `afterAgentResponse`, `stop` | Starts turns, records responses, captures completed turns, and supports `/memmy-resume` | | Claude Code | Hook | `UserPromptSubmit`, `Stop` | Recalls and injects memory before a request, captures the completed turn, and supports `/memmy-resume` | | Codex | Hook | `UserPromptSubmit`, `Stop` | Recalls and injects memory before a request, captures the completed turn, and supports `/memmy-resume` | +| Pi | Native extension | `before_agent_start`, `agent_settled`, `input` | Recalls memory before a request, captures only fully settled turns, and supports `/memmy-resume` | | OpenCode | Native plugin | Message, tool, text-completion, and session events | Recalls memory, captures responses and tool traces, and exposes native memory tools and `/memmy-resume` | | OpenClaw | Memory plugin | `before_prompt_build`, `agent_end` | Injects memory while building the prompt, captures the completed turn, and exposes native memory tools | | Hermes | Memory Provider plugin | Provider lifecycle methods such as `prefetch` and `sync_turn` | Recalls and captures automatically, mirrors explicit Hermes memory writes, and exposes native memory tools | @@ -108,11 +110,11 @@ flowchart LR Common results after installation: -- **Automatic recall:** Claude Code, Codex, OpenCode, OpenClaw, and Hermes retrieve and inject relevant memory before normal requests run. +- **Automatic recall:** Claude Code, Codex, Pi, OpenCode, OpenClaw, and Hermes retrieve and inject relevant memory before normal requests run. - **Automatic capture:** the Hook or plugin submits the user request, Agent answer, and success/failure status at the end of a turn. The Agent does not need to run `memmy-memory add` manually. - **Task resumption:** `/memmy-resume ` returns up to five L1 episode candidates. Enter `1`–`5` to load the complete episode and inject continuation context. - **On-demand lookup:** the bundled Skill keeps `memmy-memory search` and `memmy-memory get` for cases where automatic context is insufficient. -- **Source attribution:** captured turns carry `cursor`, `claude_code`, `codex`, `opencode`, `openclaw`, or `hermes`, so you can filter and trace their origin. +- **Source attribution:** captured turns carry `cursor`, `claude_code`, `codex`, `pi`, `opencode`, `openclaw`, or `hermes`, so you can filter and trace their origin.
@@ -120,7 +122,7 @@ Common results after installation: Cursor's current Hook focuses on automatic capture and /memmy-resume
- When a normal Cursor request needs additional memory, the bundled Skill performs an on-demand lookup. The other five integrations inject recalled context before normal requests. + When a normal Cursor request needs additional memory, the bundled Skill performs an on-demand lookup. The other six integrations inject recalled context before normal requests.
### Cursor: three Hooks @@ -171,6 +173,21 @@ Written or updated by default: - `~/.codex/AGENTS.md` - `~/.codex/skills/memmy-memory/SKILL.md` +### Pi: native extension + +| Event | Timing | Behavior | +| --- | --- | --- | +| `before_agent_start` | After prompt expansion, before the agent loop | Opens the shared Memmy session, starts the turn, and injects recalled context without blocking Pi if Memory is unavailable | +| `agent_settled` | After retries, compaction retries, and queued continuations finish | Captures the final Assistant response exactly once for the user run | +| `input` | Before normal prompt expansion | Converts a pending `/memmy-resume` candidate selection into a continuation request with full episode context | + +Written or updated by default: + +- `~/.pi/agent/extensions/memmy-memory.ts` +- `~/.pi/agent/extensions/memmy-memory-config.json` +- `~/.pi/agent/AGENTS.md` +- `~/.pi/agent/skills/memmy-memory/SKILL.md` + ### OpenCode: native plugin | Callback | Effect | @@ -242,7 +259,7 @@ Hermes likewise allows one active Memory Provider. Memmy asks for confirmation b ### Install, verify, and remove 1. Open **Memory → Cross-Agent access**. -2. For Cursor, Claude Code, and Codex, click **Install Hook**. For OpenCode, OpenClaw, and Hermes, click **Install plugin**. +2. For Cursor, Claude Code, and Codex, click **Install Hook**. For Pi, OpenCode, OpenClaw, and Hermes, click **Install plugin**. 3. If the Agent is already running, restart it or open a new session so it reloads its configuration. 4. Complete a normal turn, then confirm that the corresponding source appears in Memmy's memory or log views. 5. Enter `/memmy-resume `, confirm that candidates appear, and select an episode with `1`–`5`.