Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion App/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@ describe("agent sources local api routes", () => {
"cursor",
"claude_code",
"codex",
"pi",
"opencode",
"openclaw",
"hermes",
Expand Down
18 changes: 18 additions & 0 deletions App/backend/src/adapters/outbound/agent-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
106 changes: 106 additions & 0 deletions App/backend/src/adapters/outbound/agent-source/pi/adapter.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions App/backend/src/adapters/outbound/agent-source/pi/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Pi module. */
export { createPiSourceAdapter } from "./adapter.js";
Original file line number Diff line number Diff line change
@@ -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<PiSessionFile[]> {
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<string[]> {
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<string | null> {
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;
}
Loading