Files
platform/src/servers/api/pi/pi-bridge.ts
T
2026-02-26 01:39:38 +00:00

512 lines
17 KiB
TypeScript

import { join, relative } from "path";
import { homedir } from "node:os";
import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
import type { Subprocess } from "bun";
import type { PiEvent, MessageCost } from "./types";
import { readApiKeys } from "../server-settings/pi-mono";
import { readSearxngConfig } from "../server-settings/searxng";
import { PI_CONFIG_DIR, DATA_PATH, getHomeDir, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir, getNativeResourcesDir, getGlobalResourcesDir } from "../../data-path";
import { ensureDockerContainer } from "../terminal/websocket";
import { logger } from "./logger";
import { parseFrontmatter } from "../skills/skills";
export type PiEventHandler = (event: PiEvent) => void;
type PathOverrides = { global: string; user: string };
function collectSkillFlags(email: string, containerPaths?: PathOverrides): string[] {
const flags: string[] = [];
const pairs: Array<[hostDir: string, outputDir: string]> = [
[getGlobalSkillsDir(), containerPaths?.global ?? getGlobalSkillsDir()],
[getUserSkillsDir(email), containerPaths?.user ?? getUserSkillsDir(email)],
];
for (const [hostDir, outputDir] of pairs) {
if (!existsSync(hostDir)) continue;
for (const entry of readdirSync(hostDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (existsSync(join(hostDir, entry.name, 'SKILL.md'))) {
flags.push('--skill', `${outputDir}/${entry.name}`);
}
}
}
return flags;
}
function collectExtensionFlags(email: string, containerPaths?: PathOverrides): string[] {
const flags: string[] = [];
const pairs: Array<[hostDir: string, outputDir: string]> = [
[getGlobalExtensionsDir(), containerPaths?.global ?? getGlobalExtensionsDir()],
[getUserExtensionsDir(email), containerPaths?.user ?? getUserExtensionsDir(email)],
];
for (const [hostDir, outputDir] of pairs) {
if (!existsSync(hostDir)) continue;
for (const entry of readdirSync(hostDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (existsSync(join(hostDir, entry.name, 'index.ts'))) {
flags.push('--extension', `${outputDir}/${entry.name}/index.ts`);
}
}
}
return flags;
}
export function generateResourceSkill(outputDir: string): string | null {
const nativeDir = getNativeResourcesDir();
const globalDir = getGlobalResourcesDir();
// Collect all resource dirs (global overrides native)
const resourceDirs = new Map<string, string>();
for (const dir of [nativeDir, globalDir]) {
if (!existsSync(dir)) continue;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (existsSync(join(dir, entry.name, 'RESOURCE.md'))) {
resourceDirs.set(entry.name, dir);
}
}
}
if (resourceDirs.size === 0) return null;
const sections: string[] = [];
for (const [name, baseDir] of resourceDirs) {
const resourceMd = join(baseDir, name, 'RESOURCE.md');
let mdContent = '';
try { mdContent = readFileSync(resourceMd, 'utf-8'); } catch { continue; }
const { frontmatter } = parseFrontmatter(mdContent);
// Merge native + global config
let nativeConfig: Record<string, string> = {};
let globalConfig: Record<string, string> = {};
try { nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8')); } catch {}
try { globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8')); } catch {}
const config: Record<string, string> = {};
for (const key of Object.keys(nativeConfig)) config[key] = globalConfig[key] ?? nativeConfig[key]!;
for (const key of Object.keys(globalConfig)) if (!(key in config)) config[key] = globalConfig[key]!;
const hasValues = Object.values(config).some((v) => v !== '');
const configLines = Object.entries(config)
.filter(([, v]) => v)
.map(([k, v]) => /key|secret|password|token/i.test(k) ? `- **${k}**: (configured)` : `- **${k}**: ${v}`);
sections.push([
`### ${frontmatter.name || name}`,
hasValues ? 'Status: **configured**' : 'Status: not configured',
...configLines,
].join('\n'));
}
const skillContent = [
'---',
'name: Available Resources',
'description: External services and APIs configured on this Officer instance',
'---',
'',
'These are external services available to you. Use their configured URLs directly via HTTP requests.',
'Do NOT try to install local alternatives (like tesseract, whisper, etc.) — use the configured HTTP APIs instead.',
'',
...sections,
].join('\n');
const skillDir = join(outputDir, '.generated', 'available-resources');
try {
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, 'SKILL.md'), skillContent, 'utf-8');
return skillDir;
} catch {
logger.error(`Failed to write resource skill to ${skillDir}`);
return null;
}
}
function buildResourcesEnv(): string {
const nativeDir = getNativeResourcesDir();
const globalDir = getGlobalResourcesDir();
const resourceDirs = new Map<string, string>();
for (const dir of [nativeDir, globalDir]) {
if (!existsSync(dir)) continue;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (existsSync(join(dir, entry.name, 'RESOURCE.md')) || existsSync(join(dir, entry.name, 'config.json'))) {
resourceDirs.set(entry.name, dir);
}
}
}
const result: Record<string, Record<string, string>> = {};
for (const [name] of resourceDirs) {
let nativeConfig: Record<string, string> = {};
let globalConfig: Record<string, string> = {};
try { nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8')); } catch {}
try { globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8')); } catch {}
const config: Record<string, string> = {};
for (const key of Object.keys(nativeConfig)) config[key] = globalConfig[key] ?? nativeConfig[key]!;
for (const key of Object.keys(globalConfig)) if (!(key in config)) config[key] = globalConfig[key]!;
// Only include resources that have at least one non-empty value
if (Object.values(config).some((v) => v !== '')) {
result[name] = config;
}
}
return JSON.stringify(result);
}
function getGoogleConfigPath(): string {
return join(homedir(), '.config', 'officer.dev', 'google-oauth.json');
}
function getGoogleTokenPath(email: string): string {
return join(DATA_PATH, email, 'integrations', 'google.json');
}
type SandboxOptions = {
userId: number;
username: string;
email: string;
homeDir: string;
};
export async function spawnPi(
cwd: string,
model: string,
email: string,
onEvent: PiEventHandler,
sandbox?: SandboxOptions,
): Promise<Subprocess> {
let proc: Subprocess;
if (sandbox) {
const container = await ensureDockerContainer(sandbox.email, sandbox.userId, sandbox.homeDir, sandbox.username);
const storedKeys = await readApiKeys();
const searxng = await readSearxngConfig();
const dockerPath = Bun.which('docker') ?? 'docker';
const containerId = container.dockerId;
const containerHome = `/home/${sandbox.username}`;
const containerPiConfig = `${containerHome}/.pi/agent`;
// Collect skill/extension flags using container-side paths
const skillFlags = collectSkillFlags(sandbox.email, {
global: '/officer/skills',
user: '/officer/user/skills',
});
const extensionFlags = collectExtensionFlags(sandbox.email, {
global: '/officer/extensions',
user: '/officer/user/extensions',
});
// Generate resource context skill (host-side, mounted into container)
const resourceSkillHost = generateResourceSkill(DATA_PATH);
const resourceSkillFlags = resourceSkillHost ? ['--skill', '/officer/generated/available-resources'] : [];
const piArgs = [
'pi', '--mode', 'rpc',
'--no-skills', '--no-prompt-templates', '--no-themes',
...skillFlags,
...extensionFlags,
...resourceSkillFlags,
];
if (model) piArgs.push('--model', model);
const resourcesEnv = buildResourcesEnv();
const googleConfigHost = getGoogleConfigPath();
const googleTokenHost = join(DATA_PATH, sandbox.email, 'integrations');
const envFlags = [
'-e', `PI_CODING_AGENT_DIR=${containerPiConfig}`,
'-e', `HOME=${containerHome}`,
'-e', `OFFICER_USER_HOME=${containerHome}`,
'-e', `OFFICER_USER_ROOT=/officer/user`,
'-e', `PI_TOOLS_DIRS=/officer/tools:/officer/user/tools`,
'-e', `PI_SEARXNG_URL=${searxng.url}`,
'-e', `OFFICER_RESOURCES=${resourcesEnv}`,
'-e', `OFFICER_GOOGLE_CONFIG_PATH=/officer/google-oauth.json`,
'-e', `OFFICER_GOOGLE_TOKEN_PATH=/officer/user/integrations/google.json`,
'-e', `OFFICER_EMAIL_DB=/officer/emails.db`,
];
for (const [key, value] of Object.entries(storedKeys)) {
if (value?.trim()) envFlags.push('-e', `${key}=${value.trim()}`);
}
const rel = relative(sandbox.homeDir, cwd);
const workdir = rel && !rel.startsWith('..') ? join(containerHome, rel) : containerHome;
proc = Bun.spawn([
dockerPath, 'exec', '-i',
'-u', `${sandbox.username}`,
'-w', workdir,
...envFlags,
containerId,
...piArgs,
], {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
});
logger.info('Spawned Pi in container', {
containerId,
model,
skills: skillFlags.filter((f) => f !== '--skill').length,
extensions: extensionFlags.filter((f) => f !== '--extension').length,
});
} else {
const storedKeys = await readApiKeys();
const searxng = await readSearxngConfig();
const skillFlags = collectSkillFlags(email);
const extensionFlags = collectExtensionFlags(email);
// Generate resource context skill
const resourceSkillDir = generateResourceSkill(DATA_PATH);
const resourceSkillFlags = resourceSkillDir ? ['--skill', resourceSkillDir] : [];
const args = ['pi', '--mode', 'rpc', '--no-skills', '--no-prompt-templates', '--no-themes', ...skillFlags, ...extensionFlags, ...resourceSkillFlags];
if (model) args.push('--model', model);
if (!existsSync(cwd)) {
mkdirSync(cwd, { recursive: true });
}
const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':');
proc = Bun.spawn(args, {
cwd,
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, ...storedKeys, HOME: getHomeDir(email), OFFICER_USER_HOME: getHomeDir(email), OFFICER_USER_ROOT: join(DATA_PATH, email), PI_CODING_AGENT_DIR: PI_CONFIG_DIR, PI_TOOLS_DIRS: toolsDirs, PI_SEARXNG_URL: searxng.url, OFFICER_RESOURCES: buildResourcesEnv(), OFFICER_GOOGLE_CONFIG_PATH: getGoogleConfigPath(), OFFICER_GOOGLE_TOKEN_PATH: getGoogleTokenPath(email), OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db') },
});
logger.info('Spawned Pi locally', {
model,
skills: skillFlags.filter((f) => f !== '--skill').length,
extensions: extensionFlags.filter((f) => f !== '--extension').length,
});
}
// Read stdout JSON event stream (runs in background)
const stdout = proc.stdout as ReadableStream<Uint8Array>;
const reader = stdout.getReader();
const decoder = new TextDecoder();
let buffer = '';
let streamBuffer = '';
(async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line) as Record<string, unknown>;
const piEvent = parsePiEvent(event, streamBuffer);
if (piEvent) {
if (piEvent.type === 'delta') {
streamBuffer += piEvent.text;
} else if (piEvent.type === 'text' || piEvent.type === 'tool:start') {
streamBuffer = '';
}
onEvent(piEvent);
}
} catch {
// Skip unparseable lines
}
}
}
} catch {
// Process ended
}
})();
// Stderr → debug log
const stderr = proc.stderr as ReadableStream<Uint8Array>;
const stderrReader = stderr.getReader();
const stderrDecoder = new TextDecoder();
(async () => {
try {
while (true) {
const { done, value } = await stderrReader.read();
if (done) break;
const text = stderrDecoder.decode(value, { stream: true });
if (text.trim()) logger.info('Pi stderr', { text: text.trim() });
}
} catch {
// Process ended
}
})();
// Handle process exit
proc.exited.then((code) => {
logger.info('Pi process exited', { code });
});
return proc;
}
function parsePiEvent(event: Record<string, unknown>, currentStreamBuffer: string): PiEvent | null {
const type = event.type as string;
// Handle response (success/failure for commands)
if (type === 'response') {
if (event.command === 'prompt' && !event.success) {
const errorMsg = (event.error as string) ?? 'Prompt failed';
return { type: 'error', message: errorMsg };
}
return null;
}
switch (type) {
case 'agent_start':
// No event to emit, just resets state
return null;
case 'message_update': {
const ame = event.assistantMessageEvent as Record<string, unknown> | undefined;
if (ame?.type === 'text_delta') {
const delta = ame.delta as string;
return { type: 'delta', text: delta };
}
return null;
}
case 'message_end': {
if (currentStreamBuffer) {
return { type: 'text', text: currentStreamBuffer };
}
return null;
}
case 'tool_execution_start': {
const toolCallId = (event.toolCallId as string) ?? '';
const toolName = (event.toolName as string) ?? 'unknown';
const args = (event.args as Record<string, unknown>) ?? {};
return {
type: 'tool:start',
toolCallId,
toolName,
toolInput: args,
};
}
case 'tool_execution_end': {
const toolCallId = (event.toolCallId as string) ?? '';
const result = event.result;
let resultObj: Record<string, unknown> | null = null;
if (typeof result === 'object' && result !== null) {
resultObj = result as Record<string, unknown>;
} else if (typeof result === 'string') {
try { resultObj = JSON.parse(result); } catch { /* not JSON */ }
}
const isError = (event.isError as boolean) ?? (resultObj?.isError as boolean) ?? false;
const output = result != null ? (typeof result === 'string' ? result : JSON.stringify(result)) : '';
return {
type: 'tool:result',
toolCallId,
output,
isError,
};
}
case 'agent_end': {
const cost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
const messages = event.messages as Array<Record<string, unknown>> | undefined;
if (messages) {
for (const msg of messages) {
const usage = msg.usage as Record<string, unknown> | undefined;
if (!usage) continue;
cost.inputTokens += (usage.input as number) ?? 0;
cost.outputTokens += (usage.output as number) ?? 0;
const usageCost = usage.cost as Record<string, unknown> | undefined;
if (usageCost) cost.totalUSD += (usageCost.total as number) ?? 0;
}
}
return { type: 'result', cost };
}
case 'extension_ui_request': {
// Will be handled separately
return null;
}
default:
return null;
}
}
function writeRpcCommand(proc: Subprocess, command: Record<string, unknown>): void {
const stdin = proc.stdin;
if (!stdin || typeof stdin === 'number') return;
try {
const writer = stdin as { write(data: string): void; flush(): void };
writer.write(JSON.stringify(command) + '\n');
writer.flush();
} catch (err) {
logger.error('writeRpcCommand error', { error: String(err) });
}
}
export function setThinkingLevel(
process: Subprocess,
level: string,
): void {
writeRpcCommand(process, {
type: 'set_thinking_level',
level,
});
}
export function sendPrompt(
process: Subprocess,
prompt: string,
requestId: string
): void {
writeRpcCommand(process, {
type: 'prompt',
id: requestId,
message: prompt,
});
}
export function abort(
process: Subprocess,
requestId: string
): void {
writeRpcCommand(process, {
type: 'abort',
id: requestId,
});
}
export function cancelExtensionUi(
process: Subprocess,
id: unknown
): void {
writeRpcCommand(process, {
type: 'extension_ui_response',
id,
cancelled: true,
});
}
export function killPi(process: Subprocess): void {
try {
process.kill();
} catch {
// Already dead
}
}