import { createHash } from "crypto" import { existsSync, mkdirSync, unlinkSync } from "fs" import { DEFAULT_SYSTEM_INSTRUCTIONS, type BtwConfig } from "./config" export interface HintEntry { text: string pinned: boolean } export interface HintFile { hints: HintEntry[] } export type ParsedCommand = | { action: "clear"; which: "all" | "last" | number } | { action: "status" } | { action: "help" } | { action: "debug" } | { action: "set"; text: string; pinned: boolean } | { action: "error"; message: string } export const BTW_HANDLED = "__BTW_HANDLED__" // Throws to prevent the LLM call after a /btw command. // See https://github.com/anomalyco/opencode/issues/9306 export async function cancelCommand(): Promise { throw new Error(BTW_HANDLED) } export const BTW_HELP = `[btw] Usage: /btw Add a transient hint (auto-clears after model turn) /btw pin Add a persistent hint (stays until cleared) /btw clear Remove all hints /btw clear last Remove the most recently added hint /btw clear Remove hint #N /btw Show all active hints /btw debug Toggle debug mode (verbose toast logging) /btw help Show this help message` export function projectHash(directory: string): string { return createHash("md5").update(directory).digest("hex").slice(0, 12) } export function btwDir(directory: string): string { return `${process.env.HOME}/.cache/opencode/btw/${projectHash(directory)}` } export function hintPath(dir: string, sessionID: string): string { const safe = sessionID.replace(/[^a-zA-Z0-9_-]/g, "_") return `${dir}/${safe}.json` } export function ensureDir(dir: string): void { try { mkdirSync(dir, { recursive: true }) } catch {} } // ─── Debug Mode ────────────────────────────────────────────────────── export function debugMarkerPath(): string { return `${process.env.HOME}/.cache/opencode/btw/.debug` } export function isDebugEnabled(config?: BtwConfig): boolean { // When config explicitly sets debug, it takes priority if (config && "debug" in config) return config.debug try { return existsSync(debugMarkerPath()) } catch { return false } } export async function toggleDebug(): Promise { const enabled = isDebugEnabled() if (enabled) { try { unlinkSync(debugMarkerPath()) } catch {} return false } else { ensureDir(`${process.env.HOME}/.cache/opencode/btw`) await Bun.write(debugMarkerPath(), "") return true } } export async function readHints(filePath: string): Promise { try { const file = Bun.file(filePath) if (await file.exists()) { const data = await file.json() // Handle new array format if (Array.isArray(data?.hints) && data.hints.length > 0) { return data.hints as HintEntry[] } // Handle legacy single-hint format if (data?.text) { return [{ text: data.text, pinned: data.pinned ?? false }] } } } catch {} return [] } export async function writeHints( filePath: string, hints: HintEntry[], ): Promise { if (hints.length === 0) { await clearHints(filePath) return } await Bun.write(filePath, JSON.stringify({ hints } satisfies HintFile)) } export async function addHint( filePath: string, entry: HintEntry, ): Promise { const existing = await readHints(filePath) existing.push(entry) await writeHints(filePath, existing) } export async function clearHints(filePath: string): Promise { try { unlinkSync(filePath) } catch {} } export async function removeTransient(filePath: string): Promise { const hints = await readHints(filePath) const pinned = hints.filter((h) => h.pinned) if (pinned.length === hints.length) return false // nothing removed await writeHints(filePath, pinned) return true } export async function removeLast(filePath: string): Promise { const hints = await readHints(filePath) if (hints.length === 0) return null const removed = hints.pop()! await writeHints(filePath, hints) return removed } export async function removeAt( filePath: string, index: number, ): Promise { const hints = await readHints(filePath) if (index < 0 || index >= hints.length) return null const [removed] = hints.splice(index, 1) await writeHints(filePath, hints) return removed } export function parseCommand(rawArgs: string, config?: BtwConfig): ParsedCommand { const args = rawArgs.trim() if (args === "clear" || args === "reset" || args === "clear all") { return { action: "clear", which: "all" } } if (args === "help") { return { action: "help" } } if (args === "debug") { return { action: "debug" } } if (args === "clear last") { return { action: "clear", which: "last" } } const clearNumMatch = args.match(/^clear\s+(\d+)$/) if (clearNumMatch) { const num = parseInt(clearNumMatch[1], 10) if (num < 1) { return { action: "error", message: "Hint numbers start at 1" } } return { action: "clear", which: num } } if (!args) { return { action: "status" } } if (args === "pin" || args.startsWith("pin ")) { const text = args.slice(3).trim() if (!text) { return { action: "error", message: "Usage: /btw pin " } } return { action: "set", text, pinned: true } } // Use config.defaultPinned to determine default hint type const defaultPinned = config?.defaultPinned ?? false return { action: "set", text: args, pinned: defaultPinned } } export function buildSystemBlock(hints: HintEntry[], config?: BtwConfig): string { if (hints.length === 0) return "" const hintList = hints .map((h, i) => hints.length === 1 ? h.text : `${i + 1}. ${h.text}`) .join("\n") const instructions = config?.injection?.systemInstructions ?? DEFAULT_SYSTEM_INSTRUCTIONS return [instructions, "", "### Current Preferences", hintList].join("\n") } export function buildUserHint(hints: HintEntry[], config?: BtwConfig): string { if (hints.length === 0) return "" const prefix = config?.injection?.userMessagePrefix ?? "BTW, " if (hints.length === 1) return `${prefix}${hints[0].text}` // Derive multi-hint header from prefix: "BTW, " → "BTW:", "Hey, " → "Hey:" const label = prefix.replace(/[,:\s]+$/, "") return `${label}:\n${hints.map((h, i) => `${i + 1}. ${h.text}`).join("\n")}` }