Files
opencode-multi-model/src/core/utils.ts
T

456 lines
12 KiB
TypeScript

import * as os from "node:os";
import * as path from "node:path";
import type { CommandResult, WindowLaunchPlan, WorktreeInfo } from "../types";
const MAX_SUGGESTIONS = 3;
const WINDOW_NAME_LIMIT = 24;
/**
* Runs a command and captures its output without throwing on non-zero exit codes.
*
* @param parts Command segments to pass to the shell.
* @returns The exit status plus captured stdout and stderr.
*
* @example
* ```ts
* const result = await runCommand(["command", "-v", "tmux"]);
* if (!result.ok) {
* return "Error: `tmux` is not installed.";
* }
* ```
*/
export async function runCommand(parts: string[]): Promise<CommandResult> {
const result = await Bun.$`${parts}`.quiet().nothrow();
return {
ok: result.exitCode === 0,
stdout: result.stdout.toString().trim(),
stderr: result.stderr.toString().trim(),
};
}
/**
* Escapes a value for safe use inside a shell command string.
*
* @param value Raw user-provided value.
* @returns A POSIX-safe single-quoted string.
*
* @example
* ```ts
* const command = `opencode --model ${shellQuote("openai/gpt-5.4")}`;
* ```
*/
export function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
/**
* Normalizes requested model ids by trimming whitespace and dropping empty items.
*
* @param models Raw tool input.
* @returns Clean model ids in the original order.
*
* @example
* ```ts
* const normalized = normalizeModels([" openai/gpt-5.4 ", ""]);
* // ["openai/gpt-5.4"]
* ```
*/
export function normalizeModels(models: string[] | undefined): string[] {
return (models ?? []).map((model) => model.trim()).filter(Boolean);
}
/**
* Finds duplicate values while preserving their first repeated occurrence order.
*
* @param values Values to inspect.
* @returns Duplicate entries exactly once each.
*
* @example
* ```ts
* const duplicates = findDuplicates(["a", "b", "a", "b"]);
* // ["a", "b"]
* ```
*/
export function findDuplicates(values: string[]): string[] {
const seen = new Set<string>();
const duplicates = new Set<string>();
for (const value of values) {
if (seen.has(value)) {
duplicates.add(value);
continue;
}
seen.add(value);
}
return [...duplicates];
}
/**
* Builds a short tmux-safe window label from a model id.
*
* @param model Full model id.
* @returns A concise window label.
*
* @example
* ```ts
* const label = createWindowBaseName("openai/gpt-5.4");
* // "gpt-5-4"
* ```
*/
export function createWindowBaseName(model: string): string {
const preferredPart = model.split("/").at(-1) ?? model;
const sanitized = preferredPart
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, WINDOW_NAME_LIMIT);
return sanitized || "model";
}
/**
* Makes window names unique when sanitized model labels collide.
*
* @param models Validated model ids.
* @returns Window plans containing the full model id and unique tmux window name.
*
* @example
* ```ts
* const plans = createWindowPlans(["provider/a", "other/a"]);
* // [{ model: "provider/a", windowName: "a" }, { model: "other/a", windowName: "a-2" }]
* ```
*/
export function createWindowPlans(models: string[]): WindowLaunchPlan[] {
const counts = new Map<string, number>();
return models.map((model) => {
const baseName = createWindowBaseName(model);
const nextCount = (counts.get(baseName) ?? 0) + 1;
counts.set(baseName, nextCount);
if (nextCount === 1) {
return { model, windowName: baseName };
}
const suffix = `-${nextCount}`;
const trimmedBase = baseName.slice(
0,
Math.max(1, WINDOW_NAME_LIMIT - suffix.length),
);
return {
model,
windowName: `${trimmedBase}${suffix}`,
};
});
}
/**
* Computes Levenshtein distance for fuzzy model suggestions.
*
* @param left First string.
* @param right Second string.
* @returns Edit distance between the two strings.
*
* @example
* ```ts
* const distance = levenshtein("gpt5.4", "gpt-5.4");
* // 1
* ```
*/
export function levenshtein(left: string, right: string): number {
const row = Array.from({ length: right.length + 1 }, (_, index) => index);
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
let previous = row[0];
row[0] = leftIndex;
for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
const current = row[rightIndex];
const cost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
row[rightIndex] = Math.min(
row[rightIndex]! + 1,
row[rightIndex - 1]! + 1,
previous! + cost,
);
previous = current;
}
}
return row[right.length]!;
}
/**
* Suggests close model ids for invalid input.
*
* @param requested Invalid requested model id.
* @param allowlist Known valid model ids.
* @returns Up to three likely matches ordered by relevance.
*
* @example
* ```ts
* const suggestions = suggestModels("openai/gpt5.4", ["openai/gpt-5.4", "openai/gpt-5.4-pro"]);
* // ["openai/gpt-5.4", "openai/gpt-5.4-pro"]
* ```
*/
export function suggestModels(
requested: string,
allowlist: string[],
): string[] {
const normalizedRequested = requested.toLowerCase();
return allowlist
.map((candidate) => {
const normalizedCandidate = candidate.toLowerCase();
const distance = levenshtein(normalizedRequested, normalizedCandidate);
const containsBoost =
normalizedCandidate.includes(normalizedRequested) ||
normalizedRequested.includes(normalizedCandidate)
? -2
: 0;
return {
candidate,
score: distance + containsBoost,
};
})
.sort(
(left, right) =>
left.score - right.score ||
left.candidate.localeCompare(right.candidate),
)
.slice(0, MAX_SUGGESTIONS)
.map(({ candidate }) => candidate);
}
/**
* Formats invalid model errors with repair hints.
*
* @param invalidModels Invalid requested model ids.
* @param allowlist Known valid model ids.
* @returns A user-facing error string.
*
* @example
* ```ts
* const message = formatInvalidModelError(["openai/gpt5.4"], ["openai/gpt-5.4"]);
* ```
*/
export function formatInvalidModelError(
invalidModels: string[],
allowlist: string[],
): string {
const firstInvalidModel = invalidModels[0]!;
const suggestions = suggestModels(firstInvalidModel, allowlist);
const suggestionText =
suggestions.length > 0
? ` Did you mean ${suggestions.map((item) => `'${item}'`).join(" or ")}?`
: " Run `opencode models` and try again.";
if (invalidModels.length === 1) {
return `Error: Model name '${firstInvalidModel}' not found.${suggestionText}`;
}
return `Error: Model names not found: ${invalidModels.map((item) => `'${item}'`).join(", ")}.${suggestionText}`;
}
/**
* Launches an OpenCode command in a tmux window.
*
* @param sessionName Existing tmux session name.
* @param plan Window launch plan.
* @param binaryName Binary to launch ('opencode' or 'kilo').
* @returns Result describing whether the command was sent successfully.
*
* @example
* ```ts
* await launchModelInWindow("demo", { model: "openai/gpt-5.4", windowName: "gpt-5-4" });
* ```
*/
export async function launchModelInWindow(
sessionName: string,
plan: WindowLaunchPlan,
binaryName: string = "opencode",
): Promise<CommandResult> {
const launchCommand = `${binaryName} --model ${shellQuote(plan.model)}`;
return runCommand([
"tmux",
"send-keys",
"-t",
`${sessionName}:${plan.windowName}`,
launchCommand,
"C-m",
]);
}
/**
* Sanitizes a session name to be safe for paths and branch names.
*
* @param name Raw session name.
* @returns Sanitized name safe for use in paths and git branches.
*
* @example
* ```ts
* sanitizeName("test session!");
* // "test-session"
* ```
*/
export function sanitizeName(name: string): string {
return name
.replace(/[^a-zA-Z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/**
* Generates the full absolute path for a session's base directory.
*
* @param sessionName Sanitized session name.
* @returns Absolute path to the session directory.
*
* @example
* ```ts
* getSessionPath("my-session");
* // "/home/user/.local/share/opencode/multi-model/my-session"
* ```
*/
export function getSessionPath(sessionName: string): string {
const homedir = os.homedir();
return path.join(
homedir,
".local",
"share",
"opencode",
"multi-model",
sessionName,
);
}
/**
* Generates the full absolute path for a worktree.
*
* @param safeSession Sanitized session name.
* @param windowName Tmux window name.
* @returns Absolute path to the worktree directory.
*
* @example
* ```ts
* getWorktreePath("my-session", "gpt-5-2");
* // "/home/user/.local/share/opencode/multi-model/my-session/gpt-5-2"
* ```
*/
export function getWorktreePath(
safeSession: string,
windowName: string,
): string {
return path.join(getSessionPath(safeSession), windowName);
}
/**
* Retrieves all git worktrees associated with a session.
*
* @param sessionName Sanitized session name.
* @returns Array of worktree info objects filtered to the session.
*
* @example
* ```ts
* const worktrees = await getWorktreesForSession("my-session");
* ```
*/
export async function getWorktreesForSession(
sessionName: string,
): Promise<WorktreeInfo[]> {
const { stdout } = await runCommand([
"git",
"worktree",
"list",
"--porcelain",
]);
const worktrees: WorktreeInfo[] = [];
const sessionPath = getSessionPath(sanitizeName(sessionName));
let currentWorktree: Partial<WorktreeInfo> = {};
for (const line of stdout.split("\n")) {
if (line.startsWith("worktree ")) {
if (currentWorktree.path && currentWorktree.branch) {
worktrees.push(currentWorktree as WorktreeInfo);
}
currentWorktree = {
path: line.slice(9),
};
} else if (line.startsWith("branch ")) {
currentWorktree.branch = line.slice(7).replace(/^refs\/heads\//, "");
}
}
// Add last worktree
if (currentWorktree.path && currentWorktree.branch) {
worktrees.push(currentWorktree as WorktreeInfo);
}
// Filter for session-specific worktrees
return worktrees.filter((wt) => wt.path.startsWith(sessionPath));
}
/**
* Removes a git worktree and deletes its branch on failure.
*
* @param worktreePath Absolute path to the worktree.
* @param branchName Branch name to delete.
* @returns Array of error messages (empty if successful).
*
* @example
* ```ts
* const errors = await undoWorktree("/path/to/worktree", "opencode/session/window");
* if (errors.length > 0) console.error(errors);
* ```
*/
export async function undoWorktree(
worktreePath: string,
branchName: string,
): Promise<string[]> {
const errors: string[] = [];
const removeRes = await runCommand([
"git",
"worktree",
"remove",
"-f",
worktreePath,
]);
if (!removeRes.ok)
errors.push(
`Failed to remove worktree ${worktreePath}: ${removeRes.stderr}`,
);
const branchRes = await runCommand(["git", "branch", "-D", branchName]);
if (!branchRes.ok)
errors.push(`Failed to delete branch ${branchName}: ${branchRes.stderr}`);
return errors;
}
/**
* Determines the binary name to use based on environment variable or default.
*
* Priority: env var OPENCODE_MULTI_MODEL_BINARY > default ("opencode").
*
* @returns Binary name string.
*
* @example
* ```ts
* // With OPENCODE_MULTI_MODEL_BINARY=kilo
* getBinaryName(); // "kilo"
*
* // Without env var
* getBinaryName(); // "opencode"
* ```
*/
export function getBinaryName(): string {
return process.env.OPENCODE_MULTI_MODEL_BINARY || "opencode";
}