src / config.ts
src / config.ts
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
export const VERSION = "0.3.1";
export const DEFAULT_PORT = 8790;
export const DEFAULT_PROFILE = "llm-browser";
export const DEFAULT_RESEARCH_BUDGET = 24_000;
export const KNOWN_ENGINES = ["duckduckgo", "startpage", "mojeek"] as const;
export type EngineName = (typeof KNOWN_ENGINES)[number];
// Where the user changes settings and how they run commands. These differ between the MCP
// server, configured in LM Studio's mcp.json, and the LM Studio plugin, configured in its settings.
export interface Host {
secretSetting: string;
browserSetting: string;
// Said after a settings change that needs a restart to take effect, or "".
afterChange: string;
portInUse: string;
// How the user runs a command such as "approve duckduckgo".
command: (args: string) => string;
}
export function mcpHost(cli: string = path.resolve(process.argv[1] ?? "simplesearch.js")): Host {
return {
secretSetting: "SIMPLESEARCH_SECRET in mcp.json",
browserSetting: "SIMPLESEARCH_BROWSER in mcp.json",
afterChange: " and restart LM Studio",
portInUse: "probably by another copy of the simpleSearch server",
command: (args) => `run this in PowerShell: node "${cli}" ${args}`,
};
}
export interface Config {
host: Host;
secret: string;
port: number;
browserPath: string | null;
browserPathSource: "env" | "detected" | "none";
profile: string;
engines: EngineName[];
ruleUpdates: "approve" | "auto";
dataDir: string;
researchBudget: number;
researchReads: number;
enforceBudget: boolean;
problems: string[];
}
export function isEngine(name: string): name is EngineName {
return (KNOWN_ENGINES as readonly string[]).includes(name);
}
function fileExists(p: string): boolean {
try {
return fs.statSync(p).isFile();
} catch {
return false;
}
}
// Windows folders the browser lives in. LM Studio starts plugins with only part of the usual
// environment, so each one falls back to its standard place when its variable is missing.
export function windowsFolders(env: NodeJS.ProcessEnv = process.env, home: string = os.homedir()) {
const drive = env.SystemDrive || "C:";
const programFiles = [env.ProgramFiles, env.ProgramW6432, `${drive}\\Program Files`, env["ProgramFiles(x86)"], `${drive}\\Program Files (x86)`];
return {
programFiles: [...new Set(programFiles.filter((p): p is string => !!p))],
localAppData: env.LOCALAPPDATA || path.join(home, "AppData", "Local"),
appData: env.APPDATA || path.join(home, "AppData", "Roaming"),
};
}
// The usual variables that were missing at start-up, by name only, for the log.
export function missingWindowsVariables(env: NodeJS.ProcessEnv = process.env): string[] {
if (process.platform !== "win32") return [];
return ["ProgramFiles", "LOCALAPPDATA", "APPDATA", "USERPROFILE"].filter((k) => !env[k]);
}
// Common install locations for Zen. SIMPLESEARCH_BROWSER overrides all of these.
export function browserCandidates(platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, home: string = os.homedir()): string[] {
if (platform === "win32") {
const w = windowsFolders(env, home);
return [
...w.programFiles.map((p) => path.win32.join(p, "Zen Browser", "zen.exe")),
path.win32.join(w.localAppData, "Zen Browser", "zen.exe"),
path.win32.join(w.localAppData, "Programs", "Zen Browser", "zen.exe"),
path.win32.join(w.localAppData, "Programs", "Zen", "zen.exe"),
];
}
return [
"/Applications/Zen.app/Contents/MacOS/zen",
"/Applications/Zen Browser.app/Contents/MacOS/zen",
path.join(home, "Applications", "Zen.app", "Contents", "MacOS", "zen"),
"/usr/bin/zen-browser",
"/usr/bin/zen",
"/opt/zen-browser/zen",
"/opt/zen/zen",
];
}
function detectBrowser(): string | null {
return browserCandidates().find(fileExists) ?? null;
}
export function loadConfig(
env: Record<string, string | undefined> = process.env,
host: Host = mcpHost(),
defaultDataDir: string = path.join(__dirname, "data")
): Config {
const problems: string[] = [];
const secret = (env.SIMPLESEARCH_SECRET ?? "").trim();
if (!secret) {
problems.push(`The secret is not set. Copy it from the extension's options page into ${host.secretSetting}.`);
} else if (secret.length < 16) {
problems.push(`The secret looks too short. Copy the full secret from the extension's options page into ${host.secretSetting}.`);
}
let port = DEFAULT_PORT;
if (env.SIMPLESEARCH_PORT) {
const n = Number(env.SIMPLESEARCH_PORT);
if (Number.isInteger(n) && n > 1024 && n < 65536) port = n;
else problems.push(`SIMPLESEARCH_PORT is not a valid port number, so ${DEFAULT_PORT} is used instead.`);
}
let browserPath: string | null;
let browserPathSource: Config["browserPathSource"];
if (env.SIMPLESEARCH_BROWSER) {
browserPath = env.SIMPLESEARCH_BROWSER;
browserPathSource = "env";
if (!fileExists(browserPath)) {
problems.push(`Nothing exists at ${browserPath}, the browser program set in ${host.browserSetting}.`);
}
} else {
browserPath = detectBrowser();
browserPathSource = browserPath ? "detected" : "none";
if (!browserPath) {
problems.push(
`Zen was not found in the usual install locations. Set ${host.browserSetting} to the full path of zen.exe, or open the browser profile yourself before searching.`
);
}
}
const profile = (env.SIMPLESEARCH_PROFILE ?? "").trim() || DEFAULT_PROFILE;
let engines: EngineName[] = [...KNOWN_ENGINES];
if (env.SIMPLESEARCH_ENGINES) {
const requested = env.SIMPLESEARCH_ENGINES.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
const unknown = requested.filter((e) => !isEngine(e));
if (unknown.length) problems.push(`Unknown engines were ignored: ${unknown.join(", ")}`);
const valid = requested.filter(isEngine);
if (valid.length) engines = [...new Set(valid)];
}
const ruleUpdates = (env.SIMPLESEARCH_RULE_UPDATES ?? "").trim().toLowerCase() === "auto" ? "auto" : "approve";
let researchBudget = DEFAULT_RESEARCH_BUDGET;
if (env.SIMPLESEARCH_RESEARCH_BUDGET) {
const n = Number(env.SIMPLESEARCH_RESEARCH_BUDGET);
if (Number.isInteger(n) && n >= 2000) researchBudget = n;
else problems.push(`SIMPLESEARCH_RESEARCH_BUDGET must be a whole number of tokens, at least 2000, so ${DEFAULT_RESEARCH_BUDGET} is used instead.`);
}
let researchReads = 6;
if (env.SIMPLESEARCH_RESEARCH_READS) {
const n = Number(env.SIMPLESEARCH_RESEARCH_READS);
if (Number.isInteger(n) && n >= 1) researchReads = n;
else problems.push("SIMPLESEARCH_RESEARCH_READS must be a whole number of page reads, so 6 is used instead.");
}
const enforceBudget = (env.SIMPLESEARCH_BUDGET_ENFORCE ?? "").trim().toLowerCase() !== "off";
const dataDir = env.SIMPLESEARCH_DATA ? path.resolve(env.SIMPLESEARCH_DATA) : defaultDataDir;
return { host, secret, port, browserPath, browserPathSource, profile, engines, ruleUpdates, dataDir, researchBudget, researchReads, enforceBudget, problems };
}
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
export const VERSION = "0.3.1";
export const DEFAULT_PORT = 8790;
export const DEFAULT_PROFILE = "llm-browser";
export const DEFAULT_RESEARCH_BUDGET = 24_000;
export const KNOWN_ENGINES = ["duckduckgo", "startpage", "mojeek"] as const;
export type EngineName = (typeof KNOWN_ENGINES)[number];
// Where the user changes settings and how they run commands. These differ between the MCP
// server, configured in LM Studio's mcp.json, and the LM Studio plugin, configured in its settings.
export interface Host {
secretSetting: string;
browserSetting: string;
// Said after a settings change that needs a restart to take effect, or "".
afterChange: string;
portInUse: string;
// How the user runs a command such as "approve duckduckgo".
command: (args: string) => string;
}
export function mcpHost(cli: string = path.resolve(process.argv[1] ?? "simplesearch.js")): Host {
return {
secretSetting: "SIMPLESEARCH_SECRET in mcp.json",
browserSetting: "SIMPLESEARCH_BROWSER in mcp.json",
afterChange: " and restart LM Studio",
portInUse: "probably by another copy of the simpleSearch server",
command: (args) => `run this in PowerShell: node "${cli}" ${args}`,
};
}
export interface Config {
host: Host;
secret: string;
port: number;
browserPath: string | null;
browserPathSource: "env" | "detected" | "none";
profile: string;
engines: EngineName[];
ruleUpdates: "approve" | "auto";
dataDir: string;
researchBudget: number;
researchReads: number;
enforceBudget: boolean;
problems: string[];
}
export function isEngine(name: string): name is EngineName {
return (KNOWN_ENGINES as readonly string[]).includes(name);
}
function fileExists(p: string): boolean {
try {
return fs.statSync(p).isFile();
} catch {
return false;
}
}
// Windows folders the browser lives in. LM Studio starts plugins with only part of the usual
// environment, so each one falls back to its standard place when its variable is missing.
export function windowsFolders(env: NodeJS.ProcessEnv = process.env, home: string = os.homedir()) {
const drive = env.SystemDrive || "C:";
const programFiles = [env.ProgramFiles, env.ProgramW6432, `${drive}\\Program Files`, env["ProgramFiles(x86)"], `${drive}\\Program Files (x86)`];
return {
programFiles: [...new Set(programFiles.filter((p): p is string => !!p))],
localAppData: env.LOCALAPPDATA || path.join(home, "AppData", "Local"),
appData: env.APPDATA || path.join(home, "AppData", "Roaming"),
};
}
// The usual variables that were missing at start-up, by name only, for the log.
export function missingWindowsVariables(env: NodeJS.ProcessEnv = process.env): string[] {
if (process.platform !== "win32") return [];
return ["ProgramFiles", "LOCALAPPDATA", "APPDATA", "USERPROFILE"].filter((k) => !env[k]);
}
// Common install locations for Zen. SIMPLESEARCH_BROWSER overrides all of these.
export function browserCandidates(platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, home: string = os.homedir()): string[] {
if (platform === "win32") {
const w = windowsFolders(env, home);
return [
...w.programFiles.map((p) => path.win32.join(p, "Zen Browser", "zen.exe")),
path.win32.join(w.localAppData, "Zen Browser", "zen.exe"),
path.win32.join(w.localAppData, "Programs", "Zen Browser", "zen.exe"),
path.win32.join(w.localAppData, "Programs", "Zen", "zen.exe"),
];
}
return [
"/Applications/Zen.app/Contents/MacOS/zen",
"/Applications/Zen Browser.app/Contents/MacOS/zen",
path.join(home, "Applications", "Zen.app", "Contents", "MacOS", "zen"),
"/usr/bin/zen-browser",
"/usr/bin/zen",
"/opt/zen-browser/zen",
"/opt/zen/zen",
];
}
function detectBrowser(): string | null {
return browserCandidates().find(fileExists) ?? null;
}
export function loadConfig(
env: Record<string, string | undefined> = process.env,
host: Host = mcpHost(),
defaultDataDir: string = path.join(__dirname, "data")
): Config {
const problems: string[] = [];
const secret = (env.SIMPLESEARCH_SECRET ?? "").trim();
if (!secret) {
problems.push(`The secret is not set. Copy it from the extension's options page into ${host.secretSetting}.`);
} else if (secret.length < 16) {
problems.push(`The secret looks too short. Copy the full secret from the extension's options page into ${host.secretSetting}.`);
}
let port = DEFAULT_PORT;
if (env.SIMPLESEARCH_PORT) {
const n = Number(env.SIMPLESEARCH_PORT);
if (Number.isInteger(n) && n > 1024 && n < 65536) port = n;
else problems.push(`SIMPLESEARCH_PORT is not a valid port number, so ${DEFAULT_PORT} is used instead.`);
}
let browserPath: string | null;
let browserPathSource: Config["browserPathSource"];
if (env.SIMPLESEARCH_BROWSER) {
browserPath = env.SIMPLESEARCH_BROWSER;
browserPathSource = "env";
if (!fileExists(browserPath)) {
problems.push(`Nothing exists at ${browserPath}, the browser program set in ${host.browserSetting}.`);
}
} else {
browserPath = detectBrowser();
browserPathSource = browserPath ? "detected" : "none";
if (!browserPath) {
problems.push(
`Zen was not found in the usual install locations. Set ${host.browserSetting} to the full path of zen.exe, or open the browser profile yourself before searching.`
);
}
}
const profile = (env.SIMPLESEARCH_PROFILE ?? "").trim() || DEFAULT_PROFILE;
let engines: EngineName[] = [...KNOWN_ENGINES];
if (env.SIMPLESEARCH_ENGINES) {
const requested = env.SIMPLESEARCH_ENGINES.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
const unknown = requested.filter((e) => !isEngine(e));
if (unknown.length) problems.push(`Unknown engines were ignored: ${unknown.join(", ")}`);
const valid = requested.filter(isEngine);
if (valid.length) engines = [...new Set(valid)];
}
const ruleUpdates = (env.SIMPLESEARCH_RULE_UPDATES ?? "").trim().toLowerCase() === "auto" ? "auto" : "approve";
let researchBudget = DEFAULT_RESEARCH_BUDGET;
if (env.SIMPLESEARCH_RESEARCH_BUDGET) {
const n = Number(env.SIMPLESEARCH_RESEARCH_BUDGET);
if (Number.isInteger(n) && n >= 2000) researchBudget = n;
else problems.push(`SIMPLESEARCH_RESEARCH_BUDGET must be a whole number of tokens, at least 2000, so ${DEFAULT_RESEARCH_BUDGET} is used instead.`);
}
let researchReads = 6;
if (env.SIMPLESEARCH_RESEARCH_READS) {
const n = Number(env.SIMPLESEARCH_RESEARCH_READS);
if (Number.isInteger(n) && n >= 1) researchReads = n;
else problems.push("SIMPLESEARCH_RESEARCH_READS must be a whole number of page reads, so 6 is used instead.");
}
const enforceBudget = (env.SIMPLESEARCH_BUDGET_ENFORCE ?? "").trim().toLowerCase() !== "off";
const dataDir = env.SIMPLESEARCH_DATA ? path.resolve(env.SIMPLESEARCH_DATA) : defaultDataDir;
return { host, secret, port, browserPath, browserPathSource, profile, engines, ruleUpdates, dataDir, researchBudget, researchReads, enforceBudget, problems };
}