512 lines
17 KiB
TypeScript
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
|
|
}
|
|
}
|