Polish Feynman harness and stabilize Pi web runtime
This commit is contained in:
78
src/config/commands.ts
Normal file
78
src/config/commands.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
import { FEYNMAN_CONFIG_PATH, loadFeynmanConfig, saveFeynmanConfig } from "./feynman-config.js";
|
||||
|
||||
function coerceConfigValue(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "true") return true;
|
||||
if (trimmed === "false") return false;
|
||||
if (trimmed === "null") return null;
|
||||
if (trimmed === "") return "";
|
||||
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function getNestedValue(record: Record<string, unknown>, path: string): unknown {
|
||||
return path.split(".").reduce<unknown>((current, segment) => {
|
||||
if (!current || typeof current !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
return (current as Record<string, unknown>)[segment];
|
||||
}, record);
|
||||
}
|
||||
|
||||
function setNestedValue(record: Record<string, unknown>, path: string, value: unknown): void {
|
||||
const segments = path.split(".");
|
||||
let current: Record<string, unknown> = record;
|
||||
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
const existing = current[segment];
|
||||
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
|
||||
current[segment] = {};
|
||||
}
|
||||
current = current[segment] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
current[segments[segments.length - 1]!] = value;
|
||||
}
|
||||
|
||||
export function printConfig(): void {
|
||||
console.log(JSON.stringify(loadFeynmanConfig(), null, 2));
|
||||
}
|
||||
|
||||
export function printConfigPath(): void {
|
||||
console.log(FEYNMAN_CONFIG_PATH);
|
||||
}
|
||||
|
||||
export function editConfig(): void {
|
||||
if (!existsSync(FEYNMAN_CONFIG_PATH)) {
|
||||
saveFeynmanConfig(loadFeynmanConfig());
|
||||
}
|
||||
|
||||
const editor = process.env.VISUAL || process.env.EDITOR || "vi";
|
||||
const result = spawnSync(editor, [FEYNMAN_CONFIG_PATH], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Failed to open editor: ${editor}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function printConfigValue(key: string): void {
|
||||
const config = loadFeynmanConfig() as Record<string, unknown>;
|
||||
const value = getNestedValue(config, key);
|
||||
console.log(typeof value === "string" ? value : JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
export function setConfigValue(key: string, rawValue: string): void {
|
||||
const config = loadFeynmanConfig() as Record<string, unknown>;
|
||||
setNestedValue(config, key, coerceConfigValue(rawValue));
|
||||
saveFeynmanConfig(config as ReturnType<typeof loadFeynmanConfig>);
|
||||
console.log(`Updated ${key}`);
|
||||
}
|
||||
270
src/config/feynman-config.ts
Normal file
270
src/config/feynman-config.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import { getDefaultSessionDir, getFeynmanConfigPath } from "./paths.js";
|
||||
|
||||
export type WebSearchProviderId = "auto" | "perplexity" | "gemini-api" | "gemini-browser";
|
||||
export type PiWebSearchProvider = "auto" | "perplexity" | "gemini";
|
||||
|
||||
export type WebSearchConfig = Record<string, unknown> & {
|
||||
provider?: PiWebSearchProvider;
|
||||
perplexityApiKey?: string;
|
||||
geminiApiKey?: string;
|
||||
chromeProfile?: string;
|
||||
feynmanWebProvider?: WebSearchProviderId;
|
||||
};
|
||||
|
||||
export type FeynmanConfig = {
|
||||
version: 1;
|
||||
sessionDir?: string;
|
||||
webSearch?: WebSearchConfig;
|
||||
preview?: {
|
||||
lastSetupAt?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type WebSearchProviderDefinition = {
|
||||
id: WebSearchProviderId;
|
||||
label: string;
|
||||
description: string;
|
||||
runtimeProvider: PiWebSearchProvider;
|
||||
requiresApiKey: boolean;
|
||||
};
|
||||
|
||||
export type WebSearchStatus = {
|
||||
selected: WebSearchProviderDefinition;
|
||||
configPath: string;
|
||||
perplexityConfigured: boolean;
|
||||
geminiApiConfigured: boolean;
|
||||
chromeProfile?: string;
|
||||
browserHint: string;
|
||||
};
|
||||
|
||||
export const FEYNMAN_CONFIG_PATH = getFeynmanConfigPath();
|
||||
export const LEGACY_WEB_SEARCH_CONFIG_PATH = resolve(process.env.HOME ?? "", ".pi", "web-search.json");
|
||||
export const DEFAULT_WEB_SEARCH_PROVIDER: WebSearchProviderId = "gemini-browser";
|
||||
|
||||
export const WEB_SEARCH_PROVIDERS: ReadonlyArray<WebSearchProviderDefinition> = [
|
||||
{
|
||||
id: "auto",
|
||||
label: "Auto",
|
||||
description: "Prefer Perplexity when configured, otherwise fall back to Gemini.",
|
||||
runtimeProvider: "auto",
|
||||
requiresApiKey: false,
|
||||
},
|
||||
{
|
||||
id: "perplexity",
|
||||
label: "Perplexity API",
|
||||
description: "Use Perplexity Sonar directly for web answers and source lists.",
|
||||
runtimeProvider: "perplexity",
|
||||
requiresApiKey: true,
|
||||
},
|
||||
{
|
||||
id: "gemini-api",
|
||||
label: "Gemini API",
|
||||
description: "Use Gemini directly with an API key.",
|
||||
runtimeProvider: "gemini",
|
||||
requiresApiKey: true,
|
||||
},
|
||||
{
|
||||
id: "gemini-browser",
|
||||
label: "Gemini Browser",
|
||||
description: "Use your signed-in Chromium browser session through pi-web-access.",
|
||||
runtimeProvider: "gemini",
|
||||
requiresApiKey: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function readJsonFile<T>(path: string): T | undefined {
|
||||
if (!existsSync(path)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWebSearchConfig(value: unknown): WebSearchConfig | undefined {
|
||||
if (!value || typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { ...(value as WebSearchConfig) };
|
||||
}
|
||||
|
||||
function migrateLegacyWebSearchConfig(): WebSearchConfig | undefined {
|
||||
return normalizeWebSearchConfig(readJsonFile<WebSearchConfig>(LEGACY_WEB_SEARCH_CONFIG_PATH));
|
||||
}
|
||||
|
||||
export function loadFeynmanConfig(configPath = FEYNMAN_CONFIG_PATH): FeynmanConfig {
|
||||
const config = readJsonFile<FeynmanConfig>(configPath);
|
||||
if (config && typeof config === "object") {
|
||||
return {
|
||||
version: 1,
|
||||
sessionDir: typeof config.sessionDir === "string" && config.sessionDir.trim() ? config.sessionDir : undefined,
|
||||
webSearch: normalizeWebSearchConfig(config.webSearch),
|
||||
preview: config.preview && typeof config.preview === "object" ? { ...config.preview } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const legacyWebSearch = migrateLegacyWebSearchConfig();
|
||||
return {
|
||||
version: 1,
|
||||
sessionDir: getDefaultSessionDir(),
|
||||
webSearch: legacyWebSearch,
|
||||
};
|
||||
}
|
||||
|
||||
export function saveFeynmanConfig(config: FeynmanConfig, configPath = FEYNMAN_CONFIG_PATH): void {
|
||||
mkdirSync(dirname(configPath), { recursive: true });
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
...(config.sessionDir ? { sessionDir: config.sessionDir } : {}),
|
||||
...(config.webSearch ? { webSearch: config.webSearch } : {}),
|
||||
...(config.preview ? { preview: config.preview } : {}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
export function getConfiguredSessionDir(config = loadFeynmanConfig()): string {
|
||||
return typeof config.sessionDir === "string" && config.sessionDir.trim()
|
||||
? config.sessionDir
|
||||
: getDefaultSessionDir();
|
||||
}
|
||||
|
||||
export function loadWebSearchConfig(): WebSearchConfig {
|
||||
return loadFeynmanConfig().webSearch ?? {};
|
||||
}
|
||||
|
||||
export function saveWebSearchConfig(config: WebSearchConfig): void {
|
||||
const current = loadFeynmanConfig();
|
||||
saveFeynmanConfig({
|
||||
...current,
|
||||
webSearch: config,
|
||||
});
|
||||
}
|
||||
|
||||
export function getWebSearchProviderById(id: WebSearchProviderId): WebSearchProviderDefinition {
|
||||
return WEB_SEARCH_PROVIDERS.find((provider) => provider.id === id) ?? WEB_SEARCH_PROVIDERS[0];
|
||||
}
|
||||
|
||||
export function hasPerplexityApiKey(config: WebSearchConfig = loadWebSearchConfig()): boolean {
|
||||
return typeof config.perplexityApiKey === "string" && config.perplexityApiKey.trim().length > 0;
|
||||
}
|
||||
|
||||
export function hasGeminiApiKey(config: WebSearchConfig = loadWebSearchConfig()): boolean {
|
||||
return typeof config.geminiApiKey === "string" && config.geminiApiKey.trim().length > 0;
|
||||
}
|
||||
|
||||
export function hasConfiguredWebProvider(config: WebSearchConfig = loadWebSearchConfig()): boolean {
|
||||
return hasPerplexityApiKey(config) || hasGeminiApiKey(config) || getConfiguredWebSearchProvider(config).id === DEFAULT_WEB_SEARCH_PROVIDER;
|
||||
}
|
||||
|
||||
export function getConfiguredWebSearchProvider(
|
||||
config: WebSearchConfig = loadWebSearchConfig(),
|
||||
): WebSearchProviderDefinition {
|
||||
const explicit = config.feynmanWebProvider;
|
||||
if (explicit === "auto" || explicit === "perplexity" || explicit === "gemini-api" || explicit === "gemini-browser") {
|
||||
return getWebSearchProviderById(explicit);
|
||||
}
|
||||
|
||||
if (config.provider === "perplexity") {
|
||||
return getWebSearchProviderById("perplexity");
|
||||
}
|
||||
|
||||
if (config.provider === "gemini") {
|
||||
return hasGeminiApiKey(config)
|
||||
? getWebSearchProviderById("gemini-api")
|
||||
: getWebSearchProviderById("gemini-browser");
|
||||
}
|
||||
|
||||
return getWebSearchProviderById(DEFAULT_WEB_SEARCH_PROVIDER);
|
||||
}
|
||||
|
||||
export function configureWebSearchProvider(
|
||||
current: WebSearchConfig,
|
||||
providerId: WebSearchProviderId,
|
||||
values: { apiKey?: string; chromeProfile?: string } = {},
|
||||
): WebSearchConfig {
|
||||
const next: WebSearchConfig = { ...current };
|
||||
next.feynmanWebProvider = providerId;
|
||||
|
||||
switch (providerId) {
|
||||
case "auto":
|
||||
next.provider = "auto";
|
||||
if (typeof values.chromeProfile === "string" && values.chromeProfile.trim()) {
|
||||
next.chromeProfile = values.chromeProfile.trim();
|
||||
}
|
||||
return next;
|
||||
case "perplexity":
|
||||
next.provider = "perplexity";
|
||||
if (typeof values.apiKey === "string" && values.apiKey.trim()) {
|
||||
next.perplexityApiKey = values.apiKey.trim();
|
||||
}
|
||||
if (typeof values.chromeProfile === "string" && values.chromeProfile.trim()) {
|
||||
next.chromeProfile = values.chromeProfile.trim();
|
||||
}
|
||||
return next;
|
||||
case "gemini-api":
|
||||
next.provider = "gemini";
|
||||
if (typeof values.apiKey === "string" && values.apiKey.trim()) {
|
||||
next.geminiApiKey = values.apiKey.trim();
|
||||
}
|
||||
if (typeof values.chromeProfile === "string" && values.chromeProfile.trim()) {
|
||||
next.chromeProfile = values.chromeProfile.trim();
|
||||
}
|
||||
return next;
|
||||
case "gemini-browser":
|
||||
next.provider = "gemini";
|
||||
delete next.geminiApiKey;
|
||||
if (typeof values.chromeProfile === "string") {
|
||||
const profile = values.chromeProfile.trim();
|
||||
if (profile) {
|
||||
next.chromeProfile = profile;
|
||||
} else {
|
||||
delete next.chromeProfile;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
export function getWebSearchStatus(config: WebSearchConfig = loadWebSearchConfig()): WebSearchStatus {
|
||||
const selected = getConfiguredWebSearchProvider(config);
|
||||
return {
|
||||
selected,
|
||||
configPath: FEYNMAN_CONFIG_PATH,
|
||||
perplexityConfigured: hasPerplexityApiKey(config),
|
||||
geminiApiConfigured: hasGeminiApiKey(config),
|
||||
chromeProfile: typeof config.chromeProfile === "string" && config.chromeProfile.trim()
|
||||
? config.chromeProfile.trim()
|
||||
: undefined,
|
||||
browserHint: selected.id === "gemini-browser" ? "selected" : "fallback only",
|
||||
};
|
||||
}
|
||||
|
||||
export function formatWebSearchDoctorLines(config: WebSearchConfig = loadWebSearchConfig()): string[] {
|
||||
const status = getWebSearchStatus(config);
|
||||
const configured = [];
|
||||
if (status.perplexityConfigured) configured.push("Perplexity API");
|
||||
if (status.geminiApiConfigured) configured.push("Gemini API");
|
||||
if (status.selected.id === "gemini-browser" || status.chromeProfile) configured.push("Gemini Browser");
|
||||
|
||||
return [
|
||||
`web research provider: ${status.selected.label}`,
|
||||
` runtime route: ${status.selected.runtimeProvider}`,
|
||||
` configured credentials: ${configured.length > 0 ? configured.join(", ") : "none"}`,
|
||||
` browser mode: ${status.browserHint}${status.chromeProfile ? ` (profile: ${status.chromeProfile})` : ""}`,
|
||||
` config path: ${status.configPath}`,
|
||||
];
|
||||
}
|
||||
43
src/config/paths.ts
Normal file
43
src/config/paths.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export function getFeynmanHome(): string {
|
||||
return resolve(process.env.FEYNMAN_HOME ?? homedir(), ".feynman");
|
||||
}
|
||||
|
||||
export function getFeynmanAgentDir(home = getFeynmanHome()): string {
|
||||
return resolve(home, "agent");
|
||||
}
|
||||
|
||||
export function getFeynmanMemoryDir(home = getFeynmanHome()): string {
|
||||
return resolve(home, "memory");
|
||||
}
|
||||
|
||||
export function getFeynmanStateDir(home = getFeynmanHome()): string {
|
||||
return resolve(home, ".state");
|
||||
}
|
||||
|
||||
export function getDefaultSessionDir(home = getFeynmanHome()): string {
|
||||
return resolve(home, "sessions");
|
||||
}
|
||||
|
||||
export function getFeynmanConfigPath(home = getFeynmanHome()): string {
|
||||
return resolve(home, "config.json");
|
||||
}
|
||||
|
||||
export function getBootstrapStatePath(home = getFeynmanHome()): string {
|
||||
return resolve(getFeynmanStateDir(home), "bootstrap.json");
|
||||
}
|
||||
|
||||
export function ensureFeynmanHome(home = getFeynmanHome()): void {
|
||||
for (const dir of [
|
||||
home,
|
||||
getFeynmanAgentDir(home),
|
||||
getFeynmanMemoryDir(home),
|
||||
getFeynmanStateDir(home),
|
||||
getDefaultSessionDir(home),
|
||||
]) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user