mirror of
https://github.com/bendtherules/opencode-multi-model.git
synced 2026-08-18 13:42:21 +00:00
Impl: tool-npm-package
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
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-4o");
|
||||
* // "/home/user/.local/share/opencode/multi-model/my-session/gpt-4o"
|
||||
* ```
|
||||
*/
|
||||
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(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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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";
|
||||
}
|
||||
Reference in New Issue
Block a user