Polish Feynman harness and stabilize Pi web runtime
This commit is contained in:
32
src/pi/launch.ts
Normal file
32
src/pi/launch.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
import { buildPiArgs, buildPiEnv, type PiRuntimeOptions, resolvePiPaths } from "./runtime.js";
|
||||
|
||||
export async function launchPiChat(options: PiRuntimeOptions): Promise<void> {
|
||||
const { piCliPath, promisePolyfillPath } = resolvePiPaths(options.appRoot);
|
||||
if (!existsSync(piCliPath)) {
|
||||
throw new Error(`Pi CLI not found: ${piCliPath}`);
|
||||
}
|
||||
if (!existsSync(promisePolyfillPath)) {
|
||||
throw new Error(`Promise polyfill not found: ${promisePolyfillPath}`);
|
||||
}
|
||||
|
||||
const child = spawn(process.execPath, ["--import", promisePolyfillPath, piCliPath, ...buildPiArgs(options)], {
|
||||
cwd: options.workingDir,
|
||||
stdio: "inherit",
|
||||
env: buildPiEnv(options),
|
||||
});
|
||||
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exitCode = code ?? 0;
|
||||
resolvePromise();
|
||||
});
|
||||
});
|
||||
}
|
||||
99
src/pi/runtime.ts
Normal file
99
src/pi/runtime.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
BROWSER_FALLBACK_PATHS,
|
||||
MERMAID_FALLBACK_PATHS,
|
||||
PANDOC_FALLBACK_PATHS,
|
||||
resolveExecutable,
|
||||
} from "../system/executables.js";
|
||||
|
||||
export type PiRuntimeOptions = {
|
||||
appRoot: string;
|
||||
workingDir: string;
|
||||
sessionDir: string;
|
||||
feynmanAgentDir: string;
|
||||
feynmanVersion?: string;
|
||||
thinkingLevel?: string;
|
||||
explicitModelSpec?: string;
|
||||
oneShotPrompt?: string;
|
||||
initialPrompt?: string;
|
||||
systemPrompt: string;
|
||||
};
|
||||
|
||||
export function resolvePiPaths(appRoot: string) {
|
||||
return {
|
||||
piPackageRoot: resolve(appRoot, "node_modules", "@mariozechner", "pi-coding-agent"),
|
||||
piCliPath: resolve(appRoot, "node_modules", "@mariozechner", "pi-coding-agent", "dist", "cli.js"),
|
||||
promisePolyfillPath: resolve(appRoot, "dist", "system", "promise-polyfill.js"),
|
||||
researchToolsPath: resolve(appRoot, "extensions", "research-tools.ts"),
|
||||
skillsPath: resolve(appRoot, "skills"),
|
||||
promptTemplatePath: resolve(appRoot, "prompts"),
|
||||
piWorkspaceNodeModulesPath: resolve(appRoot, ".pi", "npm", "node_modules"),
|
||||
};
|
||||
}
|
||||
|
||||
export function validatePiInstallation(appRoot: string): string[] {
|
||||
const paths = resolvePiPaths(appRoot);
|
||||
const missing: string[] = [];
|
||||
|
||||
if (!existsSync(paths.piCliPath)) missing.push(paths.piCliPath);
|
||||
if (!existsSync(paths.promisePolyfillPath)) missing.push(paths.promisePolyfillPath);
|
||||
if (!existsSync(paths.researchToolsPath)) missing.push(paths.researchToolsPath);
|
||||
if (!existsSync(paths.skillsPath)) missing.push(paths.skillsPath);
|
||||
if (!existsSync(paths.promptTemplatePath)) missing.push(paths.promptTemplatePath);
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
export function buildPiArgs(options: PiRuntimeOptions): string[] {
|
||||
const paths = resolvePiPaths(options.appRoot);
|
||||
const args = [
|
||||
"--session-dir",
|
||||
options.sessionDir,
|
||||
"--extension",
|
||||
paths.researchToolsPath,
|
||||
"--skill",
|
||||
paths.skillsPath,
|
||||
"--prompt-template",
|
||||
paths.promptTemplatePath,
|
||||
"--system-prompt",
|
||||
options.systemPrompt,
|
||||
];
|
||||
|
||||
if (options.explicitModelSpec) {
|
||||
args.push("--model", options.explicitModelSpec);
|
||||
}
|
||||
if (options.thinkingLevel) {
|
||||
args.push("--thinking", options.thinkingLevel);
|
||||
}
|
||||
if (options.oneShotPrompt) {
|
||||
args.push("-p", options.oneShotPrompt);
|
||||
} else if (options.initialPrompt) {
|
||||
args.push(options.initialPrompt);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
export function buildPiEnv(options: PiRuntimeOptions): NodeJS.ProcessEnv {
|
||||
const paths = resolvePiPaths(options.appRoot);
|
||||
|
||||
return {
|
||||
...process.env,
|
||||
PI_CODING_AGENT_DIR: options.feynmanAgentDir,
|
||||
FEYNMAN_CODING_AGENT_DIR: options.feynmanAgentDir,
|
||||
FEYNMAN_VERSION: options.feynmanVersion,
|
||||
FEYNMAN_PI_NPM_ROOT: paths.piWorkspaceNodeModulesPath,
|
||||
FEYNMAN_SESSION_DIR: options.sessionDir,
|
||||
PI_SESSION_DIR: options.sessionDir,
|
||||
FEYNMAN_MEMORY_DIR: resolve(dirname(options.feynmanAgentDir), "memory"),
|
||||
FEYNMAN_NODE_EXECUTABLE: process.execPath,
|
||||
FEYNMAN_BIN_PATH: resolve(options.appRoot, "bin", "feynman.js"),
|
||||
PANDOC_PATH: process.env.PANDOC_PATH ?? resolveExecutable("pandoc", PANDOC_FALLBACK_PATHS),
|
||||
PI_SKIP_VERSION_CHECK: process.env.PI_SKIP_VERSION_CHECK ?? "1",
|
||||
MERMAID_CLI_PATH: process.env.MERMAID_CLI_PATH ?? resolveExecutable("mmdc", MERMAID_FALLBACK_PATHS),
|
||||
PUPPETEER_EXECUTABLE_PATH:
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH ?? resolveExecutable("google-chrome", BROWSER_FALLBACK_PATHS),
|
||||
};
|
||||
}
|
||||
121
src/pi/settings.ts
Normal file
121
src/pi/settings.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export type ThinkingLevel = "off" | "low" | "medium" | "high";
|
||||
|
||||
export function parseModelSpec(spec: string, modelRegistry: ModelRegistry) {
|
||||
const trimmed = spec.trim();
|
||||
const separator = trimmed.includes(":") ? ":" : trimmed.includes("/") ? "/" : null;
|
||||
if (!separator) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [provider, ...rest] = trimmed.split(separator);
|
||||
const id = rest.join(separator);
|
||||
if (!provider || !id) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return modelRegistry.find(provider, id);
|
||||
}
|
||||
|
||||
export function normalizeThinkingLevel(value: string | undefined): ThinkingLevel | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.toLowerCase();
|
||||
if (normalized === "off" || normalized === "low" || normalized === "medium" || normalized === "high") {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function choosePreferredModel(
|
||||
availableModels: Array<{ provider: string; id: string }>,
|
||||
): { provider: string; id: string } | undefined {
|
||||
const preferences = [
|
||||
{ provider: "anthropic", id: "claude-opus-4-6" },
|
||||
{ provider: "anthropic", id: "claude-opus-4-5" },
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5" },
|
||||
{ provider: "openai", id: "gpt-5.4" },
|
||||
{ provider: "openai", id: "gpt-5" },
|
||||
];
|
||||
|
||||
for (const preferred of preferences) {
|
||||
const match = availableModels.find(
|
||||
(model) => model.provider === preferred.provider && model.id === preferred.id,
|
||||
);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
return availableModels[0];
|
||||
}
|
||||
|
||||
export function readJson(path: string): Record<string, unknown> {
|
||||
if (!existsSync(path)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeFeynmanSettings(
|
||||
settingsPath: string,
|
||||
bundledSettingsPath: string,
|
||||
defaultThinkingLevel: ThinkingLevel,
|
||||
authPath: string,
|
||||
): void {
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
||||
} catch {
|
||||
settings = {};
|
||||
}
|
||||
} else if (existsSync(bundledSettingsPath)) {
|
||||
try {
|
||||
settings = JSON.parse(readFileSync(bundledSettingsPath, "utf8"));
|
||||
} catch {
|
||||
settings = {};
|
||||
}
|
||||
}
|
||||
|
||||
if (!settings.defaultThinkingLevel) {
|
||||
settings.defaultThinkingLevel = defaultThinkingLevel;
|
||||
}
|
||||
if (settings.editorPaddingX === undefined) {
|
||||
settings.editorPaddingX = 1;
|
||||
}
|
||||
settings.theme = "feynman";
|
||||
settings.quietStartup = true;
|
||||
settings.collapseChangelog = true;
|
||||
|
||||
const authStorage = AuthStorage.create(authPath);
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
const availableModels = modelRegistry.getAvailable().map((model) => ({
|
||||
provider: model.provider,
|
||||
id: model.id,
|
||||
}));
|
||||
|
||||
if ((!settings.defaultProvider || !settings.defaultModel) && availableModels.length > 0) {
|
||||
const preferredModel = choosePreferredModel(availableModels);
|
||||
if (preferredModel) {
|
||||
settings.defaultProvider = preferredModel.provider;
|
||||
settings.defaultModel = preferredModel.id;
|
||||
}
|
||||
}
|
||||
|
||||
mkdirSync(dirname(settingsPath), { recursive: true });
|
||||
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
||||
}
|
||||
Reference in New Issue
Block a user