cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

244 lines
7.3 KiB
TypeScript

import type { Dirent } from "node:fs";
import { readdir, rm, stat } from "node:fs/promises";
import path from "node:path";
import { type AgentMessage, type Branch, JsonlSessionRepo } from "@earendil-works/pi-agent-core";
import { BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core/harness/context";
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
import { getLogger } from "@rakazo/logging";
export const PI_SESSION_RETENTION_DAYS = 30;
export const PI_SESSION_MAX_FILES_PER_BOT = 100;
const PI_SESSION_RETENTION_MS = PI_SESSION_RETENTION_DAYS * 24 * 60 * 60 * 1000;
function sessionScopeSegment(value: string, label: string): string {
if (!value) throw new Error(`${label} must be non-empty`);
return Buffer.from(value, "utf8").toString("base64url");
}
export function piSessionUserRoot(sessionsRoot: string, userId: string): string {
return path.join(path.resolve(sessionsRoot), sessionScopeSegment(userId, "userId"));
}
export function piSessionBotRoot(sessionsRoot: string, userId: string, botId: string): string {
return path.join(piSessionUserRoot(sessionsRoot, userId), sessionScopeSegment(botId, "botId"));
}
export function piSessionsRoot(dataDir: string): string {
return path.resolve(dataDir, "pi-sessions");
}
/** Opt-in only. Hosted multi-tenant DATA_DIR transcripts are unencrypted. */
export function isPiSessionRecordingEnabled(source: NodeJS.ProcessEnv = process.env): boolean {
return source.PI_SESSION_RECORDING === "true";
}
/** Returns the Pi JSONL session root when recording is enabled; otherwise undefined. */
export function resolvePiSessionRoot(
dataDir: string,
source: NodeJS.ProcessEnv = process.env,
): string | undefined {
return isPiSessionRecordingEnabled(source) ? piSessionsRoot(dataDir) : undefined;
}
export async function removePiBotSessions(
dataDir: string | undefined,
userId: string | undefined,
botId: string,
): Promise<void> {
if (!dataDir) return;
if (!userId) throw new Error("userId is required to remove Pi bot sessions");
await rm(piSessionBotRoot(piSessionsRoot(dataDir), userId, botId), {
recursive: true,
force: true,
});
}
export async function removePiUserSessions(
dataDir: string | undefined,
userId: string,
): Promise<void> {
if (!dataDir) return;
await rm(piSessionUserRoot(piSessionsRoot(dataDir), userId), {
recursive: true,
force: true,
});
}
export interface PiSessionRetentionOptions {
now?: number;
maxAgeMs?: number;
maxFiles?: number;
}
interface PiSessionFile {
path: string;
mtimeMs: number;
}
async function collectPiSessionFiles(directory: string, files: PiSessionFile[]): Promise<void> {
let entries: Dirent[];
try {
entries = await readdir(directory, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
throw error;
}
await Promise.all(
entries.map(async (entry) => {
const filePath = path.join(directory, entry.name);
if (entry.isDirectory()) {
await collectPiSessionFiles(filePath, files);
return;
}
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) return;
const details = await stat(filePath);
files.push({ path: filePath, mtimeMs: details.mtimeMs });
}),
);
}
export async function prunePiSessionFiles(
root: string,
options: PiSessionRetentionOptions = {},
): Promise<void> {
const maxAgeMs = options.maxAgeMs ?? PI_SESSION_RETENTION_MS;
const maxFiles = options.maxFiles ?? PI_SESSION_MAX_FILES_PER_BOT;
if (!Number.isFinite(maxAgeMs) || maxAgeMs < 0) {
throw new RangeError("maxAgeMs must be a non-negative finite number");
}
if (!Number.isInteger(maxFiles) || maxFiles < 1) {
throw new RangeError("maxFiles must be a positive integer");
}
const files: PiSessionFile[] = [];
await collectPiSessionFiles(root, files);
const cutoff = (options.now ?? Date.now()) - maxAgeMs;
const expired = files.filter((file) => file.mtimeMs < cutoff);
const retained = files.filter((file) => file.mtimeMs >= cutoff);
retained.sort((left, right) => right.mtimeMs - left.mtimeMs);
await Promise.all(
[...expired, ...retained.slice(maxFiles)].map((file) => rm(file.path, { force: true })),
);
}
export interface PiSessionStart {
runId: string;
threadId: string;
botId: string;
userId: string;
traceId?: string;
provider: string;
model: string;
thinkingLevel: string;
systemPrompt: string;
initialMessages: readonly AgentMessage[];
}
export interface PiSessionHandle {
appendMessage(message: AgentMessage): Promise<void>;
}
export interface PiSessionRecorder {
start(input: PiSessionStart): Promise<PiSessionHandle>;
}
/**
* Persists the low-level Agent transcript using Pi's native JSONL session format.
* This is deliberately a recorder only: Rakazo remains responsible for running
* the agent and for its product history in Postgres.
*/
export class PiJsonlSessionRecorder implements PiSessionRecorder {
private readonly cwd: string;
private readonly sessionsRoot: string;
private readonly fs: NodeExecutionEnv;
constructor(sessionsRoot: string, cwd = process.cwd()) {
this.cwd = path.resolve(cwd);
this.sessionsRoot = path.resolve(sessionsRoot);
this.fs = new NodeExecutionEnv({ cwd: this.cwd });
}
async start(input: PiSessionStart): Promise<PiSessionHandle> {
const sessionsRoot = piSessionBotRoot(this.sessionsRoot, input.userId, input.botId);
const repo = new JsonlSessionRepo({
fileSystem: this.fs,
sessionsRoot,
});
const session = await repo.create(
{
id: input.runId,
cwd: this.cwd,
},
BACKGROUND_CONTEXT,
);
try {
await prunePiSessionFiles(sessionsRoot);
} catch (error) {
getLogger().warn("Pi session retention cleanup failed", {
userId: input.userId,
botId: input.botId,
error,
});
}
const handle = new BestEffortPiSession(
await session.createBranch("main", null, BACKGROUND_CONTEXT),
input.runId,
);
await handle.appendCustomEntry("rakazo_context", {
rakazoUserId: input.userId,
rakazoBotId: input.botId,
rakazoRunId: input.runId,
rakazoThreadId: input.threadId,
...(input.traceId ? { rakazoTraceId: input.traceId } : {}),
model: input.model,
provider: input.provider,
thinkingLevel: input.thinkingLevel,
systemPrompt: input.systemPrompt,
});
for (const message of input.initialMessages) {
await handle.appendMessage(message);
}
return handle;
}
}
class BestEffortPiSession implements PiSessionHandle {
private failureLogged = false;
constructor(
private readonly session: Branch,
private readonly runId: string,
) {}
async appendMessage(message: AgentMessage): Promise<void> {
try {
await this.session.appendMessage(message, BACKGROUND_CONTEXT);
} catch (error) {
this.logFailure("message", error);
}
}
async appendCustomEntry(customType: string, data: Record<string, string>): Promise<void> {
try {
await this.session.appendCustomEntry(customType, data, BACKGROUND_CONTEXT);
} catch (error) {
this.logFailure("context", error);
}
}
private logFailure(kind: string, error: unknown): void {
if (this.failureLogged) return;
this.failureLogged = true;
getLogger().warn("Pi session recording failed", {
kind,
runId: this.runId,
error,
});
}
}