diff --git a/.sf/backups/db/sf.db.2026-05-09T20-38-51-863Z b/.sf/backups/db/sf.db.2026-05-09T20-38-51-863Z deleted file mode 100644 index 3a6b0ec91..000000000 Binary files a/.sf/backups/db/sf.db.2026-05-09T20-38-51-863Z and /dev/null differ diff --git a/.sf/backups/db/sf.db.2026-05-09T21-12-32-663Z b/.sf/backups/db/sf.db.2026-05-09T21-12-32-663Z deleted file mode 100644 index 481062aa1..000000000 Binary files a/.sf/backups/db/sf.db.2026-05-09T21-12-32-663Z and /dev/null differ diff --git a/.sf/metrics.db b/.sf/metrics.db index e7ea658c6..9d6f2fb4b 100644 Binary files a/.sf/metrics.db and b/.sf/metrics.db differ diff --git a/.sf/metrics.db-shm b/.sf/metrics.db-shm index 2e6d8edea..3315b42fa 100644 Binary files a/.sf/metrics.db-shm and b/.sf/metrics.db-shm differ diff --git a/.sf/metrics.db-wal b/.sf/metrics.db-wal index f3e7eb8bc..481019a55 100644 Binary files a/.sf/metrics.db-wal and b/.sf/metrics.db-wal differ diff --git a/scripts/generate-features-inventory.mjs b/scripts/generate-features-inventory.mjs index db4606dae..7b233568c 100644 --- a/scripts/generate-features-inventory.mjs +++ b/scripts/generate-features-inventory.mjs @@ -85,7 +85,7 @@ export function parseSfNativeTools() { ); } return uniqueSorted( - tools.filter((tool) => typeof tool === "string" && tool.startsWith("sf_")), + tools.filter((tool) => typeof tool === "string"), ); } diff --git a/src/extension-discovery.ts b/src/extension-discovery.ts index 6523d4406..de7b00dd8 100644 --- a/src/extension-discovery.ts +++ b/src/extension-discovery.ts @@ -13,7 +13,7 @@ * the test suite that validates symlink and manifest edge cases. */ -import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs"; import { join, resolve } from "node:path"; function isExtensionFile(name: string): boolean { @@ -97,6 +97,10 @@ export function discoverExtensionEntryPaths(extensionsDir: string): string[] { } const discovered: string[] = []; + // Track real paths to avoid loading the same file via two names + // (e.g. a real directory and a symlink that points to it). + const seenRealPaths = new Set(); + for (const entry of readdirSync(extensionsDir, { withFileTypes: true })) { const entryPath = join(extensionsDir, entry.name); @@ -104,14 +108,34 @@ export function discoverExtensionEntryPaths(extensionsDir: string): string[] { (entry.isFile() || entry.isSymbolicLink()) && isExtensionFile(entry.name) ) { - discovered.push(entryPath); + const real = tryRealpath(entryPath); + if (!seenRealPaths.has(real)) { + seenRealPaths.add(real); + discovered.push(entryPath); + } continue; } if (entry.isDirectory() || entry.isSymbolicLink()) { - discovered.push(...resolveExtensionEntries(entryPath)); + const entries = resolveExtensionEntries(entryPath); + for (const ep of entries) { + const real = tryRealpath(ep); + if (!seenRealPaths.has(real)) { + seenRealPaths.add(real); + discovered.push(ep); + } + } } } return discovered; } + +/** Resolve a real path, falling back to the original path on error (e.g. dangling symlink). */ +function tryRealpath(p: string): string { + try { + return realpathSync.native(p); + } catch { + return p; + } +} diff --git a/src/resources/extensions/search-the-web/native-search.js b/src/resources/extensions/search-the-web/native-search.js index 7e09831ab..c7f66d6ad 100644 --- a/src/resources/extensions/search-the-web/native-search.js +++ b/src/resources/extensions/search-the-web/native-search.js @@ -37,13 +37,15 @@ export function preferBraveSearch() { ); } /** - * Register model_select and session_start hooks for native Anthropic web search. + * Register model_select, before_provider_request, and session_start hooks for native Anthropic web search. * - * before_provider_request injection runs natively in sdk.ts via webSearchMiddleware — - * nothing is registered here for that event. + * before_provider_request delegates to the webSearchMiddleware singleton so that tests + * exercise the same code path as production (sdk.ts calls it natively first; the extension + * delegate is a no-op in production due to the double-injection guard in the middleware). */ export function registerNativeSearchHooks(pi) { - let isAnthropicProvider = false; + // null = unknown (model_select not yet fired); true/false = provider is/isn't Anthropic. + let isAnthropicProvider = null; // Register the PREFERENCES.md-aware resolver so the native middleware (shared // singleton in web-search-middleware.ts) respects search_provider overrides. // Called here so each test invocation resets the resolver to the current context. @@ -106,6 +108,17 @@ export function registerNativeSearchHooks(pi) { ); } }); + // Delegate before_provider_request to the native middleware singleton. + // In production, sdk.ts already ran applyToPayload before extension hooks fire, + // so the double-injection guard makes this a no-op. In tests (mock PI without + // sdk.ts), this is the only path that exercises the injection logic. + pi.on("before_provider_request", async (event, _ctx) => { + let modelHint = event.model; + if (!modelHint && isAnthropicProvider !== null) { + modelHint = { provider: isAnthropicProvider ? "anthropic" : "not-anthropic" }; + } + return webSearchMiddleware.applyToPayload(event.payload, modelHint); + }); pi.on("session_start", async (_event, _ctx) => { // Reset the shared middleware session budget (#1309). webSearchMiddleware.resetSession(); diff --git a/src/resources/extensions/sf-inturn-guard/extension-manifest.json b/src/resources/extensions/sf-inturn-guard/extension-manifest.json deleted file mode 100644 index 99fe6a761..000000000 --- a/src/resources/extensions/sf-inturn-guard/extension-manifest.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "id": "sf-inturn-guard", - "name": "SF In-Turn Guard", - "version": "1.0.0", - "description": "Detect duplicate tool calls and short retry loops inside one agent turn", - "tier": "bundled", - "requires": { "platform": ">=2.29.0" }, - "provides": { - "commands": ["guard-status", "guard-toggle"], - "hooks": [ - "agent_start", - "turn_start", - "tool_call", - "tool_result", - "agent_end" - ] - } -} diff --git a/src/resources/extensions/sf-notify/extension-manifest.json b/src/resources/extensions/sf-notify/extension-manifest.json deleted file mode 100644 index e790185f2..000000000 --- a/src/resources/extensions/sf-notify/extension-manifest.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "id": "sf-notify", - "name": "SF Notify", - "version": "1.0.0", - "description": "Send completion and attention notifications for long-running agent work", - "tier": "bundled", - "requires": { "platform": ">=2.29.0" }, - "provides": { - "commands": [ - "notify-beep", - "notify-focus", - "notify-save-global", - "notify-say", - "notify-status", - "notify-threshold" - ], - "hooks": ["session_start", "agent_start", "tool_result", "agent_end"] - } -} diff --git a/src/resources/extensions/sf-permissions/extension-manifest.json b/src/resources/extensions/sf-permissions/extension-manifest.json deleted file mode 100644 index 7fbfeeaf1..000000000 --- a/src/resources/extensions/sf-permissions/extension-manifest.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "id": "sf-permissions", - "name": "SF Permissions", - "version": "1.0.0", - "description": "Enforce layered permission levels for shell, file, and skill-scoped tool use", - "tier": "bundled", - "requires": { "platform": ">=2.29.0" }, - "provides": { - "commands": ["permission", "permission-mode"], - "hooks": [ - "session_start", - "before_agent_start", - "agent_end", - "tool_call", - "tool_result" - ] - } -} diff --git a/src/resources/extensions/sf-permissions/package.json b/src/resources/extensions/sf-permissions/package.json deleted file mode 100644 index fe92c651e..000000000 --- a/src/resources/extensions/sf-permissions/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "pi-extension-sf-permissions", - "private": true, - "version": "1.0.0", - "type": "module", - "engines": { - "node": ">=26.1.0" - }, - "pi": { - "extensions": [ - "./index.js" - ] - } -} diff --git a/src/resources/extensions/sf-tui/color-band.js b/src/resources/extensions/sf-tui/color-band.js deleted file mode 100644 index ec23bd9e5..000000000 --- a/src/resources/extensions/sf-tui/color-band.js +++ /dev/null @@ -1,319 +0,0 @@ -/** - * Session Color — TUI colored status band - * - * Displays a colored band in the footer to visually distinguish sessions. - */ -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; - -const DEFAULT_CONFIG = { - enabledByDefault: true, - blockChar: "▁", - blockCount: "full", -}; -const STATE_FILE = path.join(os.homedir(), ".sf", "session-color-state.json"); -const COLOR_PALETTE = [ - 196, 51, 226, 129, 46, 208, 27, 213, 118, 160, 87, 220, 93, 34, 202, 75, 199, - 154, 124, 45, 214, 135, 40, 166, 69, 205, 190, 88, 80, 228, 97, 28, 172, 63, - 197, 82, 130, 39, 219, 106, -]; -const BLOCK_CHARS = [ - { char: "▁", name: "Lower 1/8 block" }, - { char: "▂", name: "Lower 1/4 block" }, - { char: "▄", name: "Lower half block" }, - { char: "█", name: "Full block" }, - { char: "▔", name: "Upper 1/8 block" }, - { char: "▀", name: "Upper half block" }, - { char: "─", name: "Light horizontal" }, - { char: "━", name: "Heavy horizontal" }, - { char: "═", name: "Double horizontal" }, -]; -const RESET = "\x1b[0m"; -// ───────────────────────────────────────────────────────────────────────────── -// Main -// ───────────────────────────────────────────────────────────────────────────── -export function registerSessionColor(pi) { - const state = { - colorIndex: null, - assigned: false, - enabledOverride: null, - blockCharOverride: null, - blockCharIndex: 0, - }; - let currentCtx = null; - let resizeHandler = null; - function setupResizeListener(ctx, config) { - if (resizeHandler) process.stdout.off("resize", resizeHandler); - if (config.blockCount === "full" && state.colorIndex !== null) { - currentCtx = ctx; - resizeHandler = () => { - if (currentCtx && state.colorIndex !== null) { - const isEnabled = state.enabledOverride ?? config.enabledByDefault; - if (isEnabled) updateStatus(currentCtx, config, state); - } - }; - process.stdout.on("resize", resizeHandler); - } - } - registerCommands(pi, state); - // Gate the session-lifecycle work on having a real TUI. The color band is - // pure footer decoration — nothing to render into in headless mode, so - // skip state-file writes and resize listeners entirely. - pi.on("session_start", async (_, ctx) => { - if (!ctx.hasUI) return; - currentCtx = ctx; - initSession(ctx, state, setupResizeListener); - }); - pi.on("session_switch", async (event, ctx) => { - if (!ctx.hasUI) return; - if (event.reason === "new") { - currentCtx = ctx; - initSession(ctx, state, setupResizeListener); - } - }); -} -// ───────────────────────────────────────────────────────────────────────────── -// Session Lifecycle -// ───────────────────────────────────────────────────────────────────────────── -function initSession(ctx, state, setupResize) { - Object.assign(state, { - colorIndex: null, - assigned: false, - enabledOverride: null, - blockCharOverride: null, - blockCharIndex: 0, - }); - const config = getConfig(ctx); - if (!config.enabledByDefault) { - ctx.ui.setStatus("0-color-band", ""); - return; - } - const sessionId = ctx.sessionManager.getSessionId(); - const persisted = readColorState(); - if (persisted?.sessionId === sessionId) { - state.colorIndex = persisted.lastColorIndex; - state.assigned = true; - updateStatus(ctx, config, state); - setupResize(ctx, config); - return; - } - const lastIndex = persisted?.lastColorIndex ?? -1; - const nextIndex = (lastIndex + 1) % COLOR_PALETTE.length; - state.colorIndex = nextIndex; - state.assigned = true; - writeColorState({ - lastColorIndex: nextIndex, - sessionId, - timestamp: Date.now(), - }); - updateStatus(ctx, config, state); - setupResize(ctx, config); -} -// ───────────────────────────────────────────────────────────────────────────── -// Status Display -// ───────────────────────────────────────────────────────────────────────────── -function updateStatus(ctx, config, state) { - if (state.colorIndex === null) return; - const color = COLOR_PALETTE[state.colorIndex]; - const count = - config.blockCount === "full" - ? process.stdout.columns || 80 - : config.blockCount; - const char = state.blockCharOverride ?? config.blockChar; - const block = char.repeat(count); - ctx.ui.setStatus("0-color-band", `\x1b[38;5;${color}m${block}${RESET}`); -} -// ───────────────────────────────────────────────────────────────────────────── -// Persistence -// ───────────────────────────────────────────────────────────────────────────── -function readColorState() { - try { - if (fs.existsSync(STATE_FILE)) { - return JSON.parse(fs.readFileSync(STATE_FILE, "utf8")); - } - } catch {} // file missing or corrupt → return null (no saved state) - return null; -} -function writeColorState(s) { - try { - const dir = path.dirname(STATE_FILE); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(STATE_FILE, JSON.stringify(s, null, 2), "utf8"); - } catch {} // write failure → state not persisted, but operation continues -} -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── -function getConfig(ctx) { - const settings = ctx.settingsManager?.getSettings() ?? {}; - return { ...DEFAULT_CONFIG, ...(settings.sessionColor ?? {}) }; -} -// ───────────────────────────────────────────────────────────────────────────── -// Commands -// ───────────────────────────────────────────────────────────────────────────── -function registerCommands(pi, state) { - pi.registerCommand("color", { - description: "Toggle color band on/off", - handler: async (_, ctx) => { - const config = getConfig(ctx); - const current = state.enabledOverride ?? config.enabledByDefault; - state.enabledOverride = !current; - if (state.enabledOverride) { - ctx.ui.notify("🎨 Color band ON", "info"); - if (state.colorIndex !== null) { - updateStatus(ctx, config, state); - } else { - const persisted = readColorState(); - const nextIndex = - ((persisted?.lastColorIndex ?? -1) + 1) % COLOR_PALETTE.length; - state.colorIndex = nextIndex; - state.assigned = true; - writeColorState({ - lastColorIndex: nextIndex, - sessionId: ctx.sessionManager.getSessionId(), - timestamp: Date.now(), - }); - updateStatus(ctx, config, state); - } - } else { - ctx.ui.notify("⬜ Color band OFF", "warning"); - ctx.ui.setStatus("0-color-band", ""); - } - }, - }); - pi.registerCommand("color-set", { - description: "Set color by index (0-39)", - handler: async (args, ctx) => { - const _config = getConfig(ctx); - const input = typeof args === "string" ? args.trim() : ""; - if (input) { - const index = parseInt(input, 10); - if (Number.isNaN(index) || index < 0 || index >= COLOR_PALETTE.length) { - ctx.ui.notify( - `Invalid index. Use 0-${COLOR_PALETTE.length - 1}`, - "error", - ); - return; - } - setColor(ctx, state, index); - ctx.ui.notify(`Color set to index ${index}`, "info"); - return; - } - if (!ctx.hasUI) { - ctx.ui.notify( - `Usage: /color-set <0-${COLOR_PALETTE.length - 1}>`, - "info", - ); - return; - } - ctx.ui.notify("Color palette:", "info"); - for (let i = 0; i < COLOR_PALETTE.length; i += 10) { - const blocks = COLOR_PALETTE.slice(i, i + 10) - .map((c) => `\x1b[38;5;${c}m██${RESET}`) - .join(" "); - ctx.ui.notify( - `${String(i).padStart(2)}-${Math.min(i + 9, 39)}: ${blocks}`, - "info", - ); - } - const indexStr = await ctx.ui.input( - `Enter index (0-${COLOR_PALETTE.length - 1}):`, - ); - if (!indexStr) return; - const index = parseInt(indexStr, 10); - if (Number.isNaN(index) || index < 0 || index >= COLOR_PALETTE.length) { - ctx.ui.notify("Invalid index", "error"); - return; - } - setColor(ctx, state, index); - ctx.ui.notify(`Color set to index ${index}`, "info"); - }, - }); - pi.registerCommand("color-next", { - description: "Skip to next color", - handler: async (_, ctx) => { - const nextIndex = ((state.colorIndex ?? -1) + 1) % COLOR_PALETTE.length; - setColor(ctx, state, nextIndex); - ctx.ui.notify(`Skipped to color ${nextIndex}`, "info"); - }, - }); - pi.registerCommand("color-char", { - description: "Change block character (cycles if no arg)", - handler: async (args, ctx) => { - const config = getConfig(ctx); - const input = typeof args === "string" ? args.trim() : ""; - if (state.colorIndex === null) { - ctx.ui.notify("No color assigned yet", "error"); - return; - } - if (input) { - state.blockCharOverride = input; - updateStatus(ctx, config, state); - ctx.ui.notify(`Block char set to "${input}"`, "info"); - return; - } - state.blockCharIndex = (state.blockCharIndex + 1) % BLOCK_CHARS.length; - const next = BLOCK_CHARS[state.blockCharIndex]; - state.blockCharOverride = next.char; - updateStatus(ctx, config, state); - ctx.ui.notify(`${next.char} ${next.name}`, "info"); - }, - }); - pi.registerCommand("color-config", { - description: "View color settings", - handler: async (_, ctx) => { - const config = getConfig(ctx); - const isEnabled = state.enabledOverride ?? config.enabledByDefault; - const persisted = readColorState(); - ctx.ui.notify("─── Session Color ───", "info"); - ctx.ui.notify( - `Status: ${isEnabled ? "🎨 ON" : "⬜ OFF"} │ Index: ${state.colorIndex ?? "(none)"}`, - "info", - ); - ctx.ui.notify( - `Char: "${state.blockCharOverride ?? config.blockChar}" │ Palette: ${COLOR_PALETTE.length} colors`, - "info", - ); - if (persisted) - ctx.ui.notify(`Last used: index ${persisted.lastColorIndex}`, "info"); - if (!ctx.hasUI) return; - const action = await ctx.ui.select("Options", [ - "🎨 Preview all colors", - "🔄 Reset sequence", - "❌ Cancel", - ]); - const selectedAction = typeof action === "string" ? action : undefined; - if (!selectedAction) return; - if (selectedAction.startsWith("🎨")) { - for (let i = 0; i < COLOR_PALETTE.length; i += 10) { - const blocks = COLOR_PALETTE.slice(i, i + 10) - .map((c) => `\x1b[38;5;${c}m██${RESET}`) - .join(" "); - ctx.ui.notify(blocks, "info"); - } - } else if (selectedAction.startsWith("🔄")) { - writeColorState({ - lastColorIndex: -1, - sessionId: "", - timestamp: Date.now(), - }); - ctx.ui.notify( - "Sequence reset. Next session starts at color 0.", - "info", - ); - } - }, - }); -} -function setColor(ctx, state, index) { - const config = getConfig(ctx); - state.colorIndex = index; - state.assigned = true; - writeColorState({ - lastColorIndex: index, - sessionId: ctx.sessionManager.getSessionId(), - timestamp: Date.now(), - }); - updateStatus(ctx, config, state); -} diff --git a/src/resources/extensions/sf-tui/emoji.js b/src/resources/extensions/sf-tui/emoji.js deleted file mode 100644 index 9172ffaac..000000000 --- a/src/resources/extensions/sf-tui/emoji.js +++ /dev/null @@ -1,433 +0,0 @@ -/** - * Session Emoji — TUI status line emoji - * - * Displays an emoji in the footer status line. Supports manual selection, - * AI-powered selection based on conversation, or random assignment. - */ -import { complete } from "@singularity-forge/ai"; - -const DEFAULT_CONFIG = { - enabledByDefault: true, - autoAssignMode: "ai", - autoAssignThreshold: 3, - contextMessages: 5, - emojiSet: "default", - customEmojis: [], -}; -const EMOJI_SETS = { - default: ["🚀", "✨", "🎯", "💡", "🔥", "⚡", "🎨", "🌟", "💻", "🎭"], - animals: ["🐱", "🐶", "🐼", "🦊", "🐻", "🦁", "🐯", "🐨", "🐰", "🦉"], - tech: ["💻", "🖥️", "⌨️", "🖱️", "💾", "📱", "🔌", "🔋", "🖨️", "📡"], - fun: ["🎉", "🎊", "🎈", "🎁", "🎂", "🍕", "🍩", "🌮", "🎮", "🎲"], -}; -const ONE_DAY_MS = 24 * 60 * 60 * 1000; -const AI_PROMPTS = { - select: `You are an emoji selector. Given a conversation context and a list of recently used emojis, choose ONE unique emoji that: -1. Represents the main topic/theme of the conversation -2. Is NOT in the recently used list -3. Is relevant and appropriate -4. Stands alone (no skin tone modifiers) - -Output ONLY the single emoji character, nothing else.`, - fromText: `You are an emoji selector. Given a text description, choose ONE emoji that best represents it. -Output ONLY the single emoji character, nothing else.`, -}; -// ───────────────────────────────────────────────────────────────────────────── -// Main -// ───────────────────────────────────────────────────────────────────────────── -export function registerSessionEmoji(pi) { - const state = { - emoji: null, - messageCount: 0, - assigned: false, - selecting: false, - enabledOverride: null, - }; - registerCommands(pi, state); - // Gate the session-lifecycle work on having a real TUI. Headless mode - // (sf headless autonomous, --print, CI) has no footer to render into, and the - // AI auto-assign path would spend tokens choosing an emoji nothing sees. - pi.on("session_start", (_, ctx) => { - if (!ctx.hasUI) return; - return initSession(ctx, pi, state); - }); - pi.on("agent_start", (_, ctx) => { - if (!ctx.hasUI) return; - return handleAgentStart(ctx, pi, state); - }); -} -// ───────────────────────────────────────────────────────────────────────────── -// Session Lifecycle -// ───────────────────────────────────────────────────────────────────────────── -async function initSession(ctx, pi, state) { - Object.assign(state, { - emoji: null, - messageCount: 0, - assigned: false, - selecting: false, - enabledOverride: null, - }); - const config = getConfig(ctx); - if (!config.enabledByDefault) { - ctx.ui.setStatus("0-emoji", ""); - return; - } - const existing = findExistingEmoji(ctx); - if (existing) { - state.emoji = existing; - state.assigned = true; - ctx.ui.setStatus("0-emoji", existing); - return; - } - if (config.autoAssignMode === "immediate") { - await assignEmoji(ctx, pi, state, config); - } else { - ctx.ui.setStatus("0-emoji", `⏳ (${config.autoAssignThreshold})`); - } -} -async function handleAgentStart(ctx, pi, state) { - const config = getConfig(ctx); - const isEnabled = state.enabledOverride ?? config.enabledByDefault; - if (!isEnabled || state.assigned || config.autoAssignMode === "immediate") - return; - state.messageCount++; - if (state.messageCount >= config.autoAssignThreshold) { - await assignEmoji(ctx, pi, state, config); - } else { - ctx.ui.setStatus( - "0-emoji", - `⏳ (${config.autoAssignThreshold - state.messageCount})`, - ); - } -} -// ───────────────────────────────────────────────────────────────────────────── -// Emoji Selection -// ───────────────────────────────────────────────────────────────────────────── -async function assignEmoji(ctx, pi, state, config) { - if (state.assigned || state.selecting) return; - state.selecting = true; - try { - if (config.autoAssignMode === "ai") ctx.ui.setStatus("0-emoji", "🔄"); - const emoji = - config.autoAssignMode === "ai" - ? await selectEmojiWithAI(ctx, config) - : selectRandomEmoji(ctx, config); - state.emoji = emoji; - state.assigned = true; - persistEmoji(ctx, pi, emoji); - ctx.ui.setStatus("0-emoji", emoji); - } finally { - state.selecting = false; - } -} -function selectRandomEmoji(ctx, config) { - const emojis = getEmojiList(config); - const recent = getRecentEmojis(ctx); - const available = emojis.filter((e) => !recent.has(e)); - const pool = available.length > 0 ? available : emojis; - return pool[Math.floor(Math.random() * pool.length)]; -} -async function selectEmojiWithAI(ctx, config) { - if (!ctx.model) return selectRandomEmoji(ctx, config); - try { - const context = getConversationContext(ctx, config.contextMessages); - const recent = getRecentEmojis(ctx); - const prompt = `Conversation context:\n${context || "(No messages yet - choose a welcoming, friendly emoji)"}\n\nRecently used emojis (DO NOT use these):\n${recent.size > 0 ? Array.from(recent).join(", ") : "(none)"}\n\nChoose a unique, topical emoji for this session.`; - const emoji = await callAI(ctx, AI_PROMPTS.select, prompt); - if (emoji) return emoji; - } catch { - // Fall through to random - } - return selectRandomEmoji(ctx, config); -} -async function selectEmojiFromText(ctx, description) { - if (!ctx.model) return null; - try { - return await callAI(ctx, AI_PROMPTS.fromText, description); - } catch { - return null; - } -} -async function callAI(ctx, systemPrompt, userText) { - const apiKey = await ctx.modelRegistry.getApiKey(ctx.model); - const userMessage = { - role: "user", - content: [{ type: "text", text: userText }], - timestamp: Date.now(), - }; - const response = await complete( - ctx.model, - { systemPrompt, messages: [userMessage] }, - { apiKey, maxTokens: 10 }, - ); - const emoji = response.content - .filter((c) => c.type === "text") - .map((c) => c.text.trim()) - .join("") - .slice(0, 10); - return emoji && emoji.length > 0 && emoji.length <= 10 ? emoji : null; -} -// ───────────────────────────────────────────────────────────────────────────── -// Persistence & History -// ───────────────────────────────────────────────────────────────────────────── -function persistEmoji(ctx, pi, emoji) { - const context = - getConversationContext(ctx, 2).slice(0, 100) || "(initial session)"; - pi.appendEntry("session-emoji-history", { - sessionId: ctx.sessionManager.getSessionId(), - emoji, - timestamp: Date.now(), - context, - }); -} -function findExistingEmoji(ctx) { - const sessionId = ctx.sessionManager.getSessionId(); - for (const entry of ctx.sessionManager.getEntries()) { - if ( - entry.type === "custom" && - entry.customType === "session-emoji-history" - ) { - const data = entry.data; - if (data?.sessionId === sessionId) return data.emoji; - } - } - return null; -} -function getRecentEmojis(ctx) { - const cutoff = Date.now() - ONE_DAY_MS; - const recent = new Set(); - for (const entry of ctx.sessionManager.getEntries()) { - if ( - entry.type === "custom" && - entry.customType === "session-emoji-history" - ) { - const data = entry.data; - if (data?.timestamp >= cutoff) recent.add(data.emoji); - } - } - return recent; -} -function getEmojiHistory(ctx) { - const cutoff = Date.now() - ONE_DAY_MS; - const history = []; - for (const entry of ctx.sessionManager.getEntries()) { - if ( - entry.type === "custom" && - entry.customType === "session-emoji-history" - ) { - const data = entry.data; - if (data?.timestamp >= cutoff) history.push(data); - } - } - return history.sort((a, b) => b.timestamp - a.timestamp); -} -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── -function getConfig(ctx) { - const settings = ctx.settingsManager?.getSettings() ?? {}; - return { ...DEFAULT_CONFIG, ...(settings.sessionEmoji ?? {}) }; -} -function getEmojiList(config) { - if (config.emojiSet === "custom" && config.customEmojis?.length > 0) { - return config.customEmojis; - } - return EMOJI_SETS[config.emojiSet] ?? EMOJI_SETS.default; -} -function getConversationContext(ctx, maxMessages) { - const branch = ctx.sessionManager.getBranch(); - const messages = []; - for ( - let i = branch.length - 1; - i >= 0 && messages.length < maxMessages; - i-- - ) { - const entry = branch[i]; - if ( - entry.type === "message" && - "message" in entry && - entry.message.role === "user" - ) { - const content = entry.message.content; - const text = - typeof content === "string" - ? content - : Array.isArray(content) - ? content - .filter((c) => c.type === "text") - .map((c) => c.text) - .join("\n") - : ""; - if (text.trim()) messages.unshift(text); - } - } - return messages.join("\n\n"); -} -function formatTimeAgo(timestamp) { - const mins = Math.round((Date.now() - timestamp) / 60000); - return mins < 60 ? `${mins}m ago` : `${Math.round(mins / 60)}h ago`; -} -// ───────────────────────────────────────────────────────────────────────────── -// Commands -// ───────────────────────────────────────────────────────────────────────────── -function registerCommands(pi, state) { - pi.registerCommand("emoji", { - description: "Toggle session emoji on/off", - handler: async (_, ctx) => { - const config = getConfig(ctx); - const current = state.enabledOverride ?? config.enabledByDefault; - state.enabledOverride = !current; - if (state.enabledOverride) { - ctx.ui.notify("🎨 Session emoji ON", "info"); - ctx.ui.setStatus( - "0-emoji", - state.emoji ?? `⏳ (${config.autoAssignThreshold})`, - ); - } else { - ctx.ui.notify("⬜ Session emoji OFF", "warning"); - ctx.ui.setStatus("0-emoji", ""); - } - }, - }); - pi.registerCommand("emoji-set", { - description: "Set emoji manually (emoji or description)", - handler: async (args, ctx) => { - const input = typeof args === "string" ? args.trim() : ""; - if (!input) { - if (!ctx.hasUI) { - ctx.ui.notify("Usage: /emoji-set ", "info"); - return; - } - const choice = await ctx.ui.select("Set emoji how?", [ - "📝 Enter emoji directly", - "💬 Describe what you want", - "🎲 Pick random from set", - "❌ Cancel", - ]); - const selectedChoice = typeof choice === "string" ? choice : undefined; - if (!selectedChoice || selectedChoice.startsWith("❌")) return; - if (selectedChoice.startsWith("📝")) { - const emoji = await ctx.ui.input("Enter emoji:"); - if (emoji) { - setManualEmoji(ctx, pi, state, emoji.trim()); - ctx.ui.notify(`Emoji set to ${emoji.trim()}`, "info"); - } - } else if (selectedChoice.startsWith("💬")) { - const desc = await ctx.ui.input("Describe the emoji:"); - if (desc) { - ctx.ui.notify("🔄 Selecting...", "info"); - const emoji = await selectEmojiFromText(ctx, desc); - if (emoji) { - setManualEmoji(ctx, pi, state, emoji); - ctx.ui.notify(`Emoji set to ${emoji}`, "info"); - } else { - ctx.ui.notify("Could not select emoji", "error"); - } - } - } else if (selectedChoice.startsWith("🎲")) { - const setChoice = await ctx.ui.select( - "Choose set:", - Object.keys(EMOJI_SETS), - ); - const selectedSet = - typeof setChoice === "string" ? setChoice : undefined; - if (!selectedSet) return; - const emojis = EMOJI_SETS[selectedSet] ?? EMOJI_SETS.default; - const emoji = emojis[Math.floor(Math.random() * emojis.length)]; - setManualEmoji(ctx, pi, state, emoji); - ctx.ui.notify(`Emoji set to ${emoji}`, "info"); - } - return; - } - const emojiRegex = /^[\p{Emoji_Presentation}\p{Emoji}\u200d]+/u; - if (emojiRegex.test(input)) { - const emoji = input.match(emojiRegex)?.[0] ?? input; - setManualEmoji(ctx, pi, state, emoji); - ctx.ui.notify(`Emoji set to ${emoji}`, "info"); - } else { - ctx.ui.notify("🔄 Selecting...", "info"); - const emoji = await selectEmojiFromText(ctx, input); - if (emoji) { - setManualEmoji(ctx, pi, state, emoji); - ctx.ui.notify(`Emoji set to ${emoji}`, "info"); - } else { - ctx.ui.notify("Could not select emoji", "error"); - } - } - }, - }); - pi.registerCommand("emoji-config", { - description: "View emoji settings", - handler: async (_, ctx) => { - const config = getConfig(ctx); - const isEnabled = state.enabledOverride ?? config.enabledByDefault; - ctx.ui.notify("─── Session Emoji ───", "info"); - ctx.ui.notify( - `Status: ${isEnabled ? "🎨 ON" : "⬜ OFF"} │ Current: ${state.emoji ?? "(none)"}`, - "info", - ); - ctx.ui.notify( - `Mode: ${config.autoAssignMode} │ Threshold: ${config.autoAssignThreshold} │ Set: ${config.emojiSet}`, - "info", - ); - if (!ctx.hasUI) return; - const action = await ctx.ui.select("Options", [ - "🎨 Preview sets", - "📋 View history", - "❌ Cancel", - ]); - const selectedAction = typeof action === "string" ? action : undefined; - if (!selectedAction) return; - if (selectedAction.startsWith("🎨")) { - for (const [name, emojis] of Object.entries(EMOJI_SETS)) { - ctx.ui.notify(`${name}: ${emojis.join(" ")}`, "info"); - } - } else if (selectedAction.startsWith("📋")) { - const history = getEmojiHistory(ctx); - if (history.length === 0) { - ctx.ui.notify("No history in past 24h", "info"); - } else { - history.slice(0, 10).forEach((h, i) => { - const current = - h.sessionId === ctx.sessionManager.getSessionId() - ? " (current)" - : ""; - ctx.ui.notify( - `${i + 1}. ${h.emoji} - ${formatTimeAgo(h.timestamp)}${current}`, - "info", - ); - }); - } - } - }, - }); - pi.registerCommand("emoji-history", { - description: "Show emoji history (24h)", - handler: async (_, ctx) => { - const history = getEmojiHistory(ctx); - if (history.length === 0) { - ctx.ui.notify("No history in past 24h", "info"); - return; - } - const unique = new Set(history.map((h) => h.emoji)); - ctx.ui.notify( - `📊 Emoji History - ${history.length} sessions, ${unique.size} unique`, - "info", - ); - history.slice(0, 15).forEach((h, i) => { - const current = - h.sessionId === ctx.sessionManager.getSessionId() ? " (current)" : ""; - ctx.ui.notify( - `${i + 1}. ${h.emoji} - ${formatTimeAgo(h.timestamp)}${current}`, - "info", - ); - }); - }, - }); -} -function setManualEmoji(ctx, pi, state, emoji) { - state.emoji = emoji; - state.assigned = true; - persistEmoji(ctx, pi, emoji); - ctx.ui.setStatus("0-emoji", emoji); -} diff --git a/src/resources/extensions/sf-tui/extension-manifest.json b/src/resources/extensions/sf-tui/extension-manifest.json deleted file mode 100644 index 74da251d9..000000000 --- a/src/resources/extensions/sf-tui/extension-manifest.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "id": "sf-tui", - "name": "SF TUI", - "version": "1.0.0", - "description": "Adds SF-specific header, footer, prompt history, color, emoji, and marketplace UI controls", - "tier": "bundled", - "requires": { "platform": ">=2.29.0" }, - "provides": { - "commands": [ - "color", - "color-char", - "color-config", - "color-next", - "color-set", - "emoji", - "emoji-config", - "emoji-history", - "emoji-set" - ], - "hooks": [ - "session_start", - "session_switch", - "before_agent_start", - "tool_result", - "agent_start", - "agent_end" - ], - "shortcuts": ["Ctrl+Alt+H", "Ctrl+Shift+H", "Ctrl+Alt+M"] - } -} diff --git a/src/resources/extensions/sf-tui/footer.js b/src/resources/extensions/sf-tui/footer.js deleted file mode 100644 index 6e724447b..000000000 --- a/src/resources/extensions/sf-tui/footer.js +++ /dev/null @@ -1,227 +0,0 @@ -import { truncateToWidth, visibleWidth } from "@singularity-forge/tui"; -import { getAutoSession } from "../sf/auto/session.js"; -import { refreshGitStatus } from "./git.js"; -import { renderModeBadge } from "./header.js"; - -const RESET = "\x1b[0m"; -const BOLD = "\x1b[1m"; -const SE = { - ember40: "#ff8838", - gray60: "#8d877a", - stone60: "#6b6659", - paper: "#f7f5f1", - warning: "#ff8838", - success: "#24a148", - error: "#da1e28", -}; -function hexToRgb(hex) { - const cleaned = hex.replace("#", ""); - return { - r: parseInt(cleaned.slice(0, 2), 16), - g: parseInt(cleaned.slice(2, 4), 16), - b: parseInt(cleaned.slice(4, 6), 16), - }; -} -function ansiFg(hex, text, bold = false) { - // Use 16-color ANSI codes for Termius compatibility - // Map hex colors to nearest standard ANSI color - const { r, g, b } = hexToRgb(hex); - const brightness = (r + g + b) / 3; - let colorCode; - if (brightness < 50) { - colorCode = 30; // black - } else if (brightness < 100) { - colorCode = 90; // bright black - } else if (r > g + b) { - colorCode = bold ? 91 : 31; // red - } else if (g > r + b) { - colorCode = bold ? 92 : 32; // green - } else if (b > r + g) { - colorCode = bold ? 94 : 34; // blue - } else if (r > 200 && g > 150) { - colorCode = bold ? 93 : 33; // yellow/orange - } else if (r > 200 && g < 100 && b > 150) { - colorCode = bold ? 95 : 35; // magenta - } else if (g > 200 && b > 150) { - colorCode = bold ? 96 : 36; // cyan - } else if (brightness > 200) { - colorCode = bold ? 97 : 37; // white - } else { - colorCode = bold ? 97 : 37; // default white - } - return `\x1b[${bold ? "1;" : ""}${colorCode}m${text}${RESET}`; -} -function toneHex(tone) { - switch (tone) { - case "accent": - case "warning": - return SE.ember40; - case "success": - return SE.success; - case "error": - return SE.error; - case "text": - return SE.paper; - default: - return SE.gray60; - } -} -function chip(label, value, tone = "text") { - return `${ansiFg(SE.gray60, `${label} `)}${ansiFg(toneHex(tone), value)}`; -} -function join(parts) { - return parts.filter(Boolean).join(ansiFg(SE.stone60, " | ")); -} -function shorten(text, max) { - return text.length > max ? `${text.slice(0, Math.max(0, max - 3))}...` : text; -} - -/** Minimal theme adapter so renderModeBadge (header.js) can run with footer's ANSI helpers. */ -const FOOTER_THEME = { - fg: (tone, text) => ansiFg(toneHex(tone), text), - bold: (text) => `${BOLD}${text}${RESET}`, -}; -function getSessionStats(ctx) { - let cost = 0; - let tokens = 0; - let cxPct = 0; - try { - for (const entry of ctx.sessionManager.getEntries()) { - if (entry.type === "message") { - const msg = entry.message; - if (msg?.role === "assistant" && msg.usage) { - cost += msg.usage.cost?.total || 0; - tokens += (msg.usage.input || 0) + (msg.usage.output || 0); - } - } - } - const cx = ctx.getContextUsage?.(); - if (cx?.percent != null) cxPct = cx.percent; - } catch { - /* ignore */ - } - return { cost, tokens, cxPct }; -} -export function renderFooter(_theme, footerData, ctx, width) { - const git = refreshGitStatus(process.cwd()); - const { cost, tokens, cxPct } = getSessionStats(ctx); - const session = getAutoSession(); - const mode = session?.getMode?.(); - const leftParts = []; - if (git.repo) { - leftParts.push(ansiFg(SE.ember40, git.repo, true)); - } else { - leftParts.push(`${BOLD}${ansiFg(SE.ember40, "SF")}`); - } - if (git.branch) { - leftParts.push(chip("branch", git.branch, "muted")); - const state = git.dirty ? "dirty" : git.untracked ? "new" : "clean"; - leftParts.push( - chip("state", state, state === "clean" ? "success" : "warning"), - ); - if (git.added || git.deleted) { - leftParts.push(chip("diff", `+${git.added}/-${git.deleted}`, "warning")); - } - if (git.ahead || git.behind) { - const syncParts = []; - if (git.ahead) syncParts.push(`↑${git.ahead}`); - if (git.behind) syncParts.push(`↓${git.behind}`); - leftParts.push(chip("sync", syncParts.join(" "), "warning")); - } - if (git.lastCommit) { - leftParts.push( - chip( - "last", - `${git.lastCommit.timeAgo} ${shorten(git.lastCommit.message, 26)}`, - "muted", - ), - ); - } - } - const statuses = Array.from(footerData.getExtensionStatuses().entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([, text]) => String(text ?? "").trim()) - .filter(Boolean); - if (statuses.length) { - leftParts.push(chip("status", statuses.join(" "), "accent")); - } - const rightParts = []; - if (mode) { - rightParts.push(renderModeBadge(FOOTER_THEME, mode, width < 80)); - } - if (ctx.model) { - rightParts.push( - chip("model", `${ctx.model.provider}/${ctx.model.id}`, "text"), - ); - } - if (cost > 0) { - rightParts.push(chip("spent", `$${cost.toFixed(2)}`, "warning")); - } - // Only show ctx% once the session has sent at least one message (avoid "1%" noise from system prompt at startup) - if (tokens > 0) { - const cxTone = cxPct >= 85 ? "error" : cxPct >= 60 ? "warning" : "success"; - rightParts.push(chip("ctx", `${Math.round(cxPct)}%`, cxTone)); - } - let rightLine = join(rightParts); - const maxRightWidth = Math.max(16, Math.floor(width * 0.55)); - if (visibleWidth(rightLine) > maxRightWidth) { - rightLine = truncateToWidth( - rightLine, - maxRightWidth, - ansiFg(SE.gray60, "..."), - ); - } - const rightWidth = visibleWidth(rightLine); - const leftBudget = Math.max(1, width - rightWidth - 2); - const leftLine = truncateToWidth( - join(leftParts), - leftBudget, - ansiFg(SE.gray60, "..."), - ); - const gap = Math.max(1, width - visibleWidth(leftLine) - rightWidth); - const line = leftLine + " ".repeat(gap) + rightLine; - return [truncateToWidth(line, width, ansiFg(SE.gray60, "..."))]; -} - -/** - * Minimal auto-mode footer — shows only mode badge + progress hint. - * Keeps the user aware SF is running autonomously without full footer noise. - */ -export function renderAutoFooter(_theme, footerData, ctx, width) { - const session = getAutoSession(); - const mode = session?.getMode?.() ?? { - workMode: "build", - runControl: "autonomous", - permissionProfile: "normal", - modelMode: "smart", - }; - - const badge = renderModeBadge(FOOTER_THEME, mode, width < 80); - const leftParts = [`${BOLD}${ansiFg(SE.ember40, "SF")}`, badge].filter( - Boolean, - ); - - const statuses = Array.from(footerData.getExtensionStatuses().entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([, text]) => String(text ?? "").trim()) - .filter(Boolean); - if (statuses.length) { - leftParts.push(ansiFg(SE.gray60, statuses.join(" "))); - } - - const rightParts = []; - if (ctx.model) { - rightParts.push(ansiFg(SE.gray60, `${ctx.model.provider}/${ctx.model.id}`)); - } - const { cost } = getSessionStats(ctx); - if (cost > 0) { - rightParts.push(ansiFg(SE.warning, `$${cost.toFixed(2)}`)); - } - - const leftLine = leftParts.join(" "); - const rightLine = rightParts.join(ansiFg(SE.gray60, " · ")); - const rightWidth = visibleWidth(rightLine); - const gap = Math.max(1, width - visibleWidth(leftLine) - rightWidth); - const line = leftLine + " ".repeat(gap) + rightLine; - return [truncateToWidth(line, width, ansiFg(SE.gray60, "..."))]; -} diff --git a/src/resources/extensions/sf-tui/git.js b/src/resources/extensions/sf-tui/git.js deleted file mode 100644 index 8396c7f32..000000000 --- a/src/resources/extensions/sf-tui/git.js +++ /dev/null @@ -1,158 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { basename } from "node:path"; - -let cache = null; -let lastFetch = 0; -function getRepoName(cwd) { - try { - const root = execFileSync("git", ["rev-parse", "--show-toplevel"], { - cwd, - encoding: "utf-8", - stdio: ["pipe", "pipe", "ignore"], - timeout: 1500, - }).trim(); - return root ? basename(root) : basename(cwd) || null; - } catch { - return basename(cwd) || null; - } -} -function getLastCommit(cwd) { - try { - const raw = execFileSync("git", ["log", "-1", "--format=%cr|%s"], { - cwd, - encoding: "utf-8", - stdio: ["pipe", "pipe", "ignore"], - timeout: 1500, - }).trim(); - const sep = raw.indexOf("|"); - if (sep > 0) { - return { - timeAgo: raw.slice(0, sep).replace(/ ago$/, ""), - message: raw.slice(sep + 1), - }; - } - } catch { - /* ignore */ - } - return null; -} -function getDiffStats(cwd) { - try { - const raw = execFileSync("git", ["diff", "HEAD", "--stat"], { - cwd, - encoding: "utf-8", - stdio: ["pipe", "pipe", "ignore"], - timeout: 1500, - }); - let added = 0; - let deleted = 0; - let modified = 0; - for (const line of raw.split("\n")) { - const addMatch = line.match(/(\d+) insertion/); - const delMatch = line.match(/(\d+) deletion/); - if (addMatch || delMatch) { - const a = addMatch ? parseInt(addMatch[1], 10) : 0; - const d = delMatch ? parseInt(delMatch[1], 10) : 0; - if (a) added += a; - if (d) deleted += d; - if (a || d) modified++; - } - } - return { added, deleted, modified }; - } catch { - return { added: 0, deleted: 0, modified: 0 }; - } -} -export function refreshGitStatus(cwd) { - const now = Date.now(); - if (now - lastFetch < 400 && cache) return cache; - lastFetch = now; - const repo = getRepoName(cwd); - let branch = null; - try { - branch = - execFileSync("git", ["branch", "--show-current"], { - cwd, - encoding: "utf-8", - stdio: ["pipe", "pipe", "ignore"], - timeout: 1500, - }).trim() || null; - } catch { - cache = { - repo, - branch: null, - dirty: false, - untracked: false, - ahead: 0, - behind: 0, - added: 0, - deleted: 0, - modified: 0, - lastCommit: null, - }; - return cache; - } - try { - const status = execFileSync("git", ["status", "--porcelain"], { - cwd, - encoding: "utf-8", - stdio: ["pipe", "pipe", "ignore"], - timeout: 1500, - }); - const lines = status.split("\n").filter((l) => l.length > 2); - const dirty = lines.some((l) => { - const x = l[0] ?? " "; - const y = l[1] ?? " "; - return (x !== "?" && x !== " " && x !== "!") || (y !== " " && y !== "?"); - }); - const untracked = lines.some((l) => l.startsWith("??")); - let ahead = 0; - let behind = 0; - try { - const ab = execFileSync( - "git", - ["rev-list", "--left-right", "--count", "HEAD...@{u}"], - { - cwd, - encoding: "utf-8", - stdio: ["pipe", "pipe", "ignore"], - timeout: 1500, - }, - ).trim(); - const [a, b] = ab.split("\t").map((n) => parseInt(n, 10)); - ahead = Number.isNaN(a) ? 0 : a; - behind = Number.isNaN(b) ? 0 : b; - } catch { - /* no upstream */ - } - const diff = getDiffStats(cwd); - const lastCommit = getLastCommit(cwd); - cache = { - repo, - branch, - dirty, - untracked, - ahead, - behind, - ...diff, - lastCommit, - }; - } catch { - cache = { - repo, - branch, - dirty: false, - untracked: false, - ahead: 0, - behind: 0, - added: 0, - deleted: 0, - modified: 0, - lastCommit: getLastCommit(cwd), - }; - } - return cache; -} -export function invalidateGitStatus() { - lastFetch = 0; -} diff --git a/src/resources/extensions/sf-tui/header.js b/src/resources/extensions/sf-tui/header.js deleted file mode 100644 index c4ade3114..000000000 --- a/src/resources/extensions/sf-tui/header.js +++ /dev/null @@ -1,168 +0,0 @@ -import { basename } from "node:path"; -import { truncateToWidth, visibleWidth } from "@singularity-forge/tui"; -import { getAutoSession } from "../sf/auto/session.js"; -import { refreshGitStatus } from "./git.js"; - -function align(left, right, width, ellipsis) { - const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right)); - return truncateToWidth(left + " ".repeat(gap) + right, width, ellipsis); -} - -function compactModeBadge(mode) { - const map = { - chat: "C", - plan: "P", - build: "B", - review: "R", - repair: "F", - research: "S", - }; - return map[mode] ?? "?"; -} - -function compactRunControlBadge(rc) { - const map = { - manual: "M", - assisted: "A", - autonomous: "∞", - }; - return map[rc] ?? "?"; -} - -function compactPermissionBadge(pp) { - const map = { - restricted: "R", - normal: "N", - trusted: "T", - unrestricted: "U", - }; - return map[pp] ?? "?"; -} - -function compactModelModeBadge(mm) { - const map = { - fast: "F", - smart: "S", - deep: "D", - }; - return map[mm] ?? "?"; -} - -function renderModeBadge(theme, mode, compact) { - if (!mode) return ""; - const th = theme; - const paused = mode.paused === true; - if (compact) { - const badges = [ - paused ? th.fg("dim", "P!") : "", - th.fg(paused ? "dim" : "accent", compactModeBadge(mode.workMode)), - th.fg("dim", compactRunControlBadge(mode.runControl)), - th.fg( - paused ? "dim" : "warning", - compactPermissionBadge(mode.permissionProfile), - ), - th.fg(paused ? "dim" : "success", compactModelModeBadge(mode.modelMode)), - ].filter(Boolean); - return `[${badges.join("")}]`; - } - const parts = [ - paused ? th.fg("dim", "paused") : "", - paused ? th.fg("dim", "·") : "", - th.fg(paused ? "dim" : "accent", mode.workMode), - th.fg("dim", "·"), - th.fg("dim", mode.runControl), - th.fg("dim", "·"), - th.fg(paused ? "dim" : "warning", mode.permissionProfile), - th.fg("dim", "·"), - th.fg(paused ? "dim" : "success", mode.modelMode), - ].filter(Boolean); - return parts.join(" "); -} - -export { renderModeBadge }; - -/** - * Minimal auto-mode header — shows only mode badge + project name. - * Keeps the user aware SF is running autonomously without full header noise. - */ -export function renderAutoHeader(theme, ctx, width) { - const th = theme; - const projectName = basename(process.cwd()); - const session = getAutoSession(); - const mode = session?.getMode?.() ?? { - workMode: "build", - runControl: "autonomous", - permissionProfile: "normal", - modelMode: "smart", - }; - - const modeBadge = renderModeBadge(th, mode, width < 80); - const left = [ - th.bold(th.fg("accent", "SF")), - th.fg("dim", "▸"), - th.fg("text", projectName), - modeBadge ? th.fg("dim", "·") : "", - modeBadge, - ] - .filter(Boolean) - .join(" "); - - const model = ctx.model - ? `${ctx.model.provider}/${ctx.model.id}`.replace(/^\/+/, "") - : ""; - const right = model ? th.fg("dim", model) : ""; - - const ellipsis = th.fg("dim", "…"); - return [align(left, right, width, ellipsis)]; -} - -export function renderHeader(theme, ctx, width) { - const th = theme; - const git = refreshGitStatus(process.cwd()); - const projectName = basename(process.cwd()); - const mode = ctx.sessionManager?.getMode?.() ?? getAutoSession().getMode(); - const model = ctx.model - ? `${ctx.model.provider}/${ctx.model.id}`.replace(/^\/+/, "") - : ""; - const modelLabel = model - ? `${th.fg("dim", "model ")}${th.fg("text", model)}` - : ""; - const modeBadge = renderModeBadge(th, mode, width < 80); - const topLeft = [ - th.fg("accent", "╭─"), - th.bold(th.fg("accent", "SF")), - th.fg("dim", "▸"), - th.fg("text", projectName), - modeBadge ? th.fg("dim", "·") : "", - modeBadge, - ] - .filter(Boolean) - .join(" "); - const branchState = git.branch - ? git.dirty - ? th.fg("warning", "modified") - : git.untracked - ? th.fg("warning", "untracked") - : th.fg("success", "clean") - : th.fg("dim", "no git"); - const branchLabel = git.branch - ? `${th.fg("dim", "branch ")}${th.fg("accent", git.branch)} ${th.fg("dim", "·")} ${branchState}` - : branchState; - const sync = []; - if (git.ahead) sync.push(th.fg("success", `↑${git.ahead}`)); - if (git.behind) sync.push(th.fg("warning", `↓${git.behind}`)); - if (git.added || git.deleted) { - sync.push(th.fg("muted", `Δ +${git.added}/-${git.deleted}`)); - } - const bottomRight = sync.join(th.fg("dim", " ")); - const ellipsis = th.fg("dim", "…"); - const top = align(topLeft, modelLabel, width, ellipsis); - if (width < 64) return [top]; - const bottom = align( - `${th.fg("accent", "╰─")} ${branchLabel}`, - bottomRight, - width, - ellipsis, - ); - return [top, bottom]; -} diff --git a/src/resources/extensions/sf-tui/index.js b/src/resources/extensions/sf-tui/index.js deleted file mode 100644 index 3e2e34d60..000000000 --- a/src/resources/extensions/sf-tui/index.js +++ /dev/null @@ -1,581 +0,0 @@ -/** - * SF-TUI — Unified TUI enhancements for Singularity Forge - * - * Features: - * - Powerline footer: git branch, diff stats, last commit, model, cost, context - * - Header: project name + branch + model - * - Prompt history: Ctrl+Alt+H overlay - * - Mode cycling: Ctrl+Shift+M, Ctrl+Shift+R, Ctrl+Shift+A, Ctrl+Shift+S - */ - -import { execSync, spawn } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { platform } from "node:os"; -import { Key } from "@singularity-forge/tui"; -import { getAutoSession } from "../sf/auto/session.js"; -import { isAutoActive } from "../sf/auto.js"; -import { projectRoot } from "../sf/commands/context.js"; -import { - getExperimentalFlag, - setExperimentalFlag, -} from "../sf/experimental.js"; -import { registerSessionColor } from "./color-band.js"; -import { registerSessionEmoji } from "./emoji.js"; -import { renderAutoFooter, renderFooter } from "./footer.js"; -import { invalidateGitStatus } from "./git.js"; -import { renderAutoHeader, renderHeader } from "./header.js"; -import { openMarketplaceOverlay } from "./marketplace.js"; -import { - appendPromptHistory, - openPromptHistoryOverlay, - pushPromptHistory, - readPromptHistory, -} from "./prompt-history.js"; - -const WORK_MODE_CYCLE = [ - "chat", - "plan", - "build", - "review", - "repair", - "research", -]; -const PERMISSION_PROFILE_CYCLE = [ - "restricted", - "normal", - "trusted", - "unrestricted", -]; - -function cycleWorkMode(ctx) { - const session = getAutoSession(); - const current = session.getMode().workMode; - const idx = WORK_MODE_CYCLE.indexOf(current); - const next = WORK_MODE_CYCLE[(idx + 1) % WORK_MODE_CYCLE.length]; - const transition = session.setMode({ workMode: next }); - ctx.ui.notify( - `Mode: ${transition.from.workMode} → ${transition.to.workMode}`, - "info", - ); -} - -function setWorkMode(ctx, mode) { - const session = getAutoSession(); - const transition = session.setMode({ workMode: mode }); - ctx.ui.notify( - `Mode: ${transition.from.workMode} → ${transition.to.workMode}`, - "info", - ); -} - -function setRunControl(ctx, rc) { - const session = getAutoSession(); - const transition = session.setMode({ runControl: rc }); - ctx.ui.notify( - `Run control: ${transition.from.runControl} → ${transition.to.runControl}`, - "info", - ); -} - -function cyclePermissionProfile(ctx) { - const session = getAutoSession(); - const current = session.getMode().permissionProfile; - const idx = PERMISSION_PROFILE_CYCLE.indexOf(current); - const next = - PERMISSION_PROFILE_CYCLE[(idx + 1) % PERMISSION_PROFILE_CYCLE.length]; - const transition = session.setMode({ permissionProfile: next }); - ctx.ui.notify( - `Trust: ${transition.from.permissionProfile} → ${transition.to.permissionProfile}`, - "info", - ); -} - -function installHeader(ctx) { - if (!ctx.hasUI) return; - ctx.ui.setHeader((_tui, theme) => { - return { - render: (width) => { - if (isAutoActive()) { - return renderAutoHeader(theme, ctx, width); - } - return renderHeader(theme, ctx, width); - }, - invalidate: () => {}, - dispose: () => {}, - }; - }); -} -function installFooter(ctx) { - if (!ctx.hasUI) return; - ctx.ui.setFooter((_tui, theme, footerData) => { - return { - render: (width) => { - if (isAutoActive()) { - return renderAutoFooter(theme, footerData, ctx, width); - } - return renderFooter(theme, footerData, ctx, width); - }, - invalidate: () => {}, - dispose: () => {}, - }; - }); -} -export default function sfTui(pi) { - registerSessionEmoji(pi); - registerSessionColor(pi); - const promptHistory = readPromptHistory(); - let promptHistorySessionId = randomUUID(); - let projectBasePath = null; - let wasAutoActive = false; - pi.on("session_start", async (_event, ctx) => { - if (!ctx.hasUI) return; - promptHistorySessionId = - ctx.sessionManager?.getSessionId?.() ?? promptHistorySessionId; - try { - projectBasePath = projectRoot(); - const projectPromptHistory = readPromptHistory(projectBasePath); - promptHistory.splice(0, promptHistory.length, ...projectPromptHistory); - } catch { - projectBasePath = null; - } - installHeader(ctx); - installFooter(ctx); - const openProjectPromptHistory = (overlayCtx) => - openPromptHistoryOverlay(overlayCtx, projectBasePath ?? undefined); - pi.registerShortcut(Key.ctrlAlt("h"), { - description: "Open prompt history", - handler: openProjectPromptHistory, - }); - pi.registerShortcut(Key.ctrlShift("h"), { - description: "Open prompt history (fallback)", - handler: openProjectPromptHistory, - }); - pi.registerShortcut(Key.ctrlAlt("m"), { - description: "Open marketplace browser", - handler: openMarketplaceOverlay, - }); - // Mode cycling shortcuts - pi.registerShortcut(Key.ctrlShift("m"), { - description: "Cycle work mode (chat→plan→build→review→repair→research)", - handler: () => cycleWorkMode(ctx), - }); - pi.registerShortcut(Key.ctrlShift("r"), { - description: "Set work mode to repair", - handler: () => setWorkMode(ctx, "repair"), - }); - pi.registerShortcut(Key.ctrlShift("a"), { - description: "Set run control to autonomous", - handler: () => setRunControl(ctx, "autonomous"), - }); - pi.registerShortcut(Key.ctrlShift("s"), { - description: "Set run control to assisted (step)", - handler: () => setRunControl(ctx, "assisted"), - }); - pi.registerShortcut(Key.ctrlShift("p"), { - description: - "Cycle permission profile (restricted→normal→trusted→unrestricted)", - handler: () => cyclePermissionProfile(ctx), - }); - // Ctrl+G — open current project in $EDITOR (or notify if none) - pi.registerShortcut(Key.ctrl("g"), { - description: "Open project root in $EDITOR", - handler: () => { - const editor = process.env.EDITOR || process.env.VISUAL; - if (!editor) { - ctx.ui.notify( - "No $EDITOR set. Set EDITOR=code (or vim, nano, etc.) in your shell.", - "warning", - ); - return; - } - spawn(editor, [projectRoot() ?? "."], { - stdio: "ignore", - detached: true, - }).unref(); - ctx.ui.notify(`Opened ${editor} ${projectRoot() ?? "."}`, "info"); - }, - }); - // Ctrl+T — toggle reasoning display - pi.registerShortcut(Key.ctrl("t"), { - description: "Toggle extended thinking / reasoning display", - handler: () => { - const current = getExperimentalFlag("show_reasoning"); - setExperimentalFlag("show_reasoning", !current); - ctx.ui.notify(`Reasoning display ${current ? "OFF" : "ON"}`, "info"); - }, - }); - // Ctrl+X B — open background session switcher (BACKGROUND_SESSIONS flag) - pi.registerShortcut(Key.ctrlAlt("b"), { - description: "Open background session switcher", - handler: async () => { - if (!getExperimentalFlag("background_sessions")) { - ctx.sendMessage?.("/tasks"); - return; - } - await openBgSessionSwitcher(ctx); - }, - }); - // Ctrl+X O — open URL from last agent message - pi.registerShortcut(Key.ctrlAlt("o"), { - description: "Open last URL from agent output in browser", - handler: () => { - const entries = ctx.sessionManager?.getEntries?.() ?? []; - const lastText = - [...entries].reverse().find((e) => e.type === "assistant")?.content ?? - ""; - const urlMatch = String(lastText).match(/https?:\/\/[^\s"'<>)]+/); - if (!urlMatch) { - ctx.ui.notify("No URL found in last agent output.", "info"); - return; - } - const url = urlMatch[0]; - try { - const openCmd = - platform() === "darwin" - ? "open" - : platform() === "win32" - ? "start" - : "xdg-open"; - execSync(`${openCmd} "${url}"`, { stdio: "ignore" }); - ctx.ui.notify(`Opened: ${url}`, "info"); - } catch { - ctx.ui.notify(`URL: ${url}`, "info"); - } - }, - }); - // STATUS_LINE — spawn user script every 5s, pipe stdout to footer chip - startStatusLineRunner(ctx); - wasAutoActive = isAutoActive(); - }); - pi.on("before_agent_start", async (event) => { - const prompt = event.prompt?.trim(); - if (prompt) { - pushPromptHistory(promptHistory, prompt); - appendPromptHistory( - prompt, - projectBasePath ?? undefined, - promptHistorySessionId, - ); - pi.appendEntry("sf-prompt-history", { - prompt, - projectRoot: projectBasePath, - sessionId: promptHistorySessionId, - timestamp: Date.now(), - }); - } - }); - pi.on("tool_result", async (_event, ctx) => { - invalidateGitStatus(); - const autoNow = isAutoActive(); - if (!autoNow && wasAutoActive) { - installHeader(ctx); - installFooter(ctx); - } - wasAutoActive = autoNow; - }); - pi.on("agent_end", async (_event, ctx) => { - const autoNow = isAutoActive(); - if (!autoNow) { - installHeader(ctx); - installFooter(ctx); - } - wasAutoActive = autoNow; - }); - // SHOW_FILE tool — renders a file path + optional line range as a code block - // in the agent timeline when the experimental flag is enabled. - pi.registerTool({ - name: "show_file", - description: - "Display a file (or a section of it) as a highlighted code block in the conversation timeline. Use when you want to show the user specific code without just dumping text.", - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: "Absolute or relative path to the file", - }, - start_line: { - type: "number", - description: "First line to display (1-indexed, optional)", - }, - end_line: { - type: "number", - description: "Last line to display (inclusive, optional)", - }, - }, - required: ["path"], - }, - execute: async ({ path: filePath, start_line, end_line }) => { - if (!getExperimentalFlag("show_file")) { - return { - output: - "SHOW_FILE is not enabled. Run /experimental on show_file to enable.", - }; - } - const { readFileSync, existsSync } = await import("node:fs"); - const { resolve } = await import("node:path"); - const abs = resolve(projectRoot() ?? ".", filePath); - if (!existsSync(abs)) { - return { output: `File not found: ${filePath}` }; - } - const raw = readFileSync(abs, "utf-8"); - const lines = raw.split("\n"); - const from = start_line != null ? Math.max(1, start_line) - 1 : 0; - const to = - end_line != null ? Math.min(lines.length, end_line) : lines.length; - const slice = lines.slice(from, to).join("\n"); - const ext = abs.split(".").pop() ?? ""; - return { output: `\`\`\`${ext}\n${slice}\n\`\`\`` }; - }, - renderResult: ({ output }) => output, - }); - - // ASK_USER_ELICITATION — structured form-based ask_user replacement. - // When the flag is on and the agent calls this tool with choices, a TUI - // select overlay is shown instead of a plain text prompt. - pi.registerTool({ - name: "ask_user_elicitation", - description: - "Ask the user a question using a structured form with optional choices. When ASK_USER_ELICITATION is enabled this is preferred over plain ask_user for questions with known choices.", - parameters: { - type: "object", - properties: { - question: { - type: "string", - description: "The question to ask the user", - }, - choices: { - type: "array", - items: { type: "string" }, - description: "Optional list of choices to show as a select menu", - }, - allow_freeform: { - type: "boolean", - description: - "Whether to allow freeform text input in addition to choices", - }, - }, - required: ["question"], - }, - execute: async ({ question, choices, allow_freeform }, ctx) => { - if (!ctx?.hasUI) { - return { output: "No UI available for elicitation." }; - } - if (!getExperimentalFlag("ask_elicitation")) { - return { - output: - "ASK_USER_ELICITATION is not enabled. Run /experimental on ask_elicitation to enable.", - }; - } - if (choices?.length) { - const answer = await ctx.ui.select(question, choices); - if (!answer && allow_freeform) { - const freeform = await ctx.ui.input(question); - return { output: freeform ?? "" }; - } - return { output: answer ?? "" }; - } - const answer = await ctx.ui.input(question); - return { output: answer ?? "" }; - }, - renderResult: ({ output }) => (output ? `**Answer:** ${output}` : ""), - }); - - // MULTI_TURN_AGENTS — persistent named sub-agent sessions via file-backed state. - // Tool that spawns or resumes a named SF child process, relaying messages. - pi.registerTool({ - name: "spawn_agent", - description: - "Spawn or resume a named persistent sub-agent. Sends a message and waits for the response. The agent persists across calls using file-backed state in .sf/agents//.", - parameters: { - type: "object", - properties: { - name: { - type: "string", - description: - "Unique agent name (alphanumeric + hyphens, e.g. 'researcher')", - }, - message: { - type: "string", - description: "Message to send to the agent", - }, - reset: { - type: "boolean", - description: - "If true, clear the agent's state and start fresh (default: false)", - }, - }, - required: ["name", "message"], - }, - execute: async ({ name, message, reset }) => { - if (!getExperimentalFlag("multi_turn_agents")) { - return { - output: - "MULTI_TURN_AGENTS is not enabled. Run /experimental on multi_turn_agents to enable.", - }; - } - if (!/^[a-z0-9-]{1,32}$/i.test(name)) { - return { - output: "Agent name must be 1-32 alphanumeric/hyphen characters.", - }; - } - const { join: pathJoin } = await import("node:path"); - const { mkdirSync, writeFileSync, readFileSync, existsSync } = - await import("node:fs"); - const stateDir = pathJoin( - projectRoot() ?? process.cwd(), - ".sf", - "agents", - name, - ); - mkdirSync(stateDir, { recursive: true }); - const historyPath = pathJoin(stateDir, "history.jsonl"); - if (reset && existsSync(historyPath)) { - writeFileSync(historyPath, "", "utf-8"); - } - // Append user message to history - const entry = JSON.stringify({ - role: "user", - content: message, - ts: Date.now(), - }); - const { appendFileSync } = await import("node:fs"); - appendFileSync(historyPath, `${entry}\n`, "utf-8"); - // Dispatch to SF headless with the conversation history as context - const historyLines = existsSync(historyPath) - ? readFileSync(historyPath, "utf-8") - .trim() - .split("\n") - .filter(Boolean) - .map((l) => { - try { - return JSON.parse(l); - } catch { - return null; - } - }) - .filter(Boolean) - : []; - const contextMsg = historyLines - .slice(-10) // last 10 turns for context - .map((e) => `${e.role === "user" ? "User" : "Agent"}: ${e.content}`) - .join("\n"); - const fullPrompt = `[Agent: ${name}]\n\nConversation history:\n${contextMsg}\n\nRespond to the last user message only.`; - const { execFile } = await import("node:child_process"); - const { promisify } = await import("node:util"); - const execFileAsync = promisify(execFile); - try { - const { stdout } = await execFileAsync( - process.execPath, - [ - "-y", - "node@24", - process.env.SF_LOADER ?? "dist/loader.js", - "headless", - "--print", - fullPrompt, - ], - { - timeout: 60000, - encoding: "utf-8", - env: { ...process.env }, - }, - ); - const response = stdout.trim(); - appendFileSync( - historyPath, - `${JSON.stringify({ role: "assistant", content: response, ts: Date.now() })}\n`, - "utf-8", - ); - return { output: response }; - } catch (err) { - return { - output: `Agent dispatch failed: ${err instanceof Error ? err.message : String(err)}`, - }; - } - }, - renderResult: ({ output }) => output, - }); -} - -/** Run the STATUS_LINE user script on a 5s interval, posting stdout to footer. */ -let _statusLineInterval = null; -function startStatusLineRunner(ctx) { - if (_statusLineInterval) { - clearInterval(_statusLineInterval); - _statusLineInterval = null; - } - const tick = async () => { - if (!getExperimentalFlag("status_line")) return; - const scriptPath = getExperimentalFlag("status_line_script"); - if (!scriptPath || typeof scriptPath !== "string") return; - try { - const { execFile } = await import("node:child_process"); - const { promisify } = await import("node:util"); - const execFileAsync = promisify(execFile); - const { stdout } = await execFileAsync(scriptPath, [], { - timeout: 4000, - encoding: "utf-8", - }); - const text = stdout.trim().slice(0, 120); - if (text) { - ctx.ui.setStatus?.("sf-status-line", text); - } - } catch { - /* Non-fatal — script may be missing or timing out */ - } - }; - _statusLineInterval = setInterval(tick, 5000); - tick(); -} - -// ─── Background Session Switcher ───────────────────────────────────────────── - -const BG_SESSIONS_FILE = ".sf/sessions-queue.json"; - -/** - * Open a TUI overlay listing background sessions from .sf/sessions-queue.json. - * Selecting one sends /resume to the chat. - * - * Purpose: BACKGROUND_SESSIONS parity — let users switch between concurrent - * sessions without leaving the TUI. - * Consumer: Ctrl+Alt+B keybinding (session_start hook). - */ -async function openBgSessionSwitcher(ctx) { - if (!ctx?.hasUI) return; - const { existsSync, readFileSync } = await import("node:fs"); - const { join: pathJoin } = await import("node:path"); - let root; - try { - root = projectRoot(); - } catch { - root = process.cwd(); - } - const queuePath = pathJoin(root ?? process.cwd(), BG_SESSIONS_FILE); - let sessions = []; - if (existsSync(queuePath)) { - try { - sessions = JSON.parse(readFileSync(queuePath, "utf-8")); - } catch { - /* malformed */ - } - } - if (!sessions.length) { - ctx.ui.notify( - "No background sessions queued.\nStart one with /resume or via BACKGROUND_SESSIONS controls.", - "info", - ); - return; - } - const labels = sessions.map( - (s, i) => - `${(i + 1).toString().padStart(2)}. ${s.id ?? "unknown"} — ${s.summary ?? "no summary"}`, - ); - const chosen = await ctx.ui.select("Switch to session:", labels); - if (!chosen) return; - const idx = labels.indexOf(chosen); - const session = sessions[idx]; - if (session?.id) { - ctx.sendMessage?.(`/resume ${session.id}`); - } -} diff --git a/src/resources/extensions/sf-tui/marketplace.js b/src/resources/extensions/sf-tui/marketplace.js deleted file mode 100644 index d49647f26..000000000 --- a/src/resources/extensions/sf-tui/marketplace.js +++ /dev/null @@ -1,346 +0,0 @@ -import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { - Key, - matchesKey, - truncateToWidth, - visibleWidth, -} from "@singularity-forge/tui"; -import { getExperimentalFlag } from "../sf/experimental.js"; - -const CATEGORIES = ["all", "extension", "skill", "theme"]; -const FEATURED = [ - { - id: "agents-filter-output", - name: "Filter Output", - source: "featured", - category: "extension", - description: "Redact secrets from tool results", - }, - { - id: "agents-security", - name: "Security", - source: "featured", - category: "extension", - description: "Block dangerous commands and protected paths", - }, - { - id: "pi-hooks-permission", - name: "Permission", - source: "featured", - category: "extension", - description: "4-level permission control for bash/write/edit", - }, - { - id: "shitty-usage-bar", - name: "Usage Bar", - source: "featured", - category: "extension", - description: "Live AI provider quota & status", - }, - { - id: "rhubarb-bg-notify", - name: "Background Notify", - source: "featured", - category: "extension", - description: "Notify when background tasks complete", - }, - { - id: "pi-dcp", - name: "Dynamic Context Pruning", - source: "featured", - category: "extension", - description: "Intelligent conversation context pruning", - }, - { - id: "pi-powerline-footer", - name: "Powerline Footer", - source: "featured", - category: "extension", - description: "Git-integrated status bar components", - }, -]; -function scanInstalledExtensions(dir, sourceLabel) { - if (!existsSync(dir)) return []; - const items = []; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const extPath = join(dir, entry.name); - const pkgPath = join(extPath, "package.json"); - let name = entry.name; - let description = ""; - try { - if (existsSync(pkgPath)) { - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); - name = pkg.name || name; - description = pkg.description || ""; - } - } catch { - /* ignore */ - } - items.push({ - id: entry.name, - name, - source: sourceLabel, - category: "extension", - description, - path: extPath, - }); - } - return items; -} -function buildCatalog() { - const installed = scanInstalledExtensions( - join(homedir(), ".sf", "agent", "extensions"), - "installed", - ); - const piCompat = scanInstalledExtensions( - join(homedir(), ".pi", "agent", "extensions"), - "pi-compat", - ); - const piLegacy = scanInstalledExtensions( - join(homedir(), ".pi", "extensions"), - "pi-compat", - ); - const all = [...installed, ...piCompat, ...piLegacy]; - const seen = new Set(all.map((i) => i.id)); - for (const f of FEATURED) { - if (!seen.has(f.id)) all.push(f); - } - return all.sort((a, b) => { - if (a.source === "installed" && b.source !== "installed") return -1; - if (b.source === "installed" && a.source !== "installed") return 1; - return a.name.localeCompare(b.name); - }); -} -class MarketplaceOverlay { - tui; - theme; - onClose; - items; - filtered; - sel = 0; - catIdx = 0; - scroll = 0; - cacheW = 0; - cacheL = []; - constructor(tui, theme, items, onClose) { - this.tui = tui; - this.theme = theme; - this.items = items; - this.onClose = onClose; - this.filtered = this.applyFilter(); - } - get category() { - return CATEGORIES[this.catIdx]; - } - applyFilter() { - if (this.category === "all") return this.items; - return this.items.filter((i) => i.category === this.category); - } - handleInput(data) { - if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) { - this.onClose(); - return; - } - if (matchesKey(data, Key.down) || data === "j") { - this.sel = Math.min(this.filtered.length - 1, this.sel + 1); - this.invalidate(); - this.tui.requestRender(); - return; - } - if (matchesKey(data, Key.up) || data === "k") { - this.sel = Math.max(0, this.sel - 1); - this.invalidate(); - this.tui.requestRender(); - return; - } - if (data === "f") { - this.catIdx = (this.catIdx + 1) % CATEGORIES.length; - this.sel = 0; - this.scroll = 0; - this.filtered = this.applyFilter(); - this.invalidate(); - this.tui.requestRender(); - return; - } - if (matchesKey(data, Key.return) || matchesKey(data, Key.enter)) { - const item = this.filtered[this.sel]; - if (item) { - if (item.source === "installed") { - // Already installed — close - this.onClose(); - } else { - // Trigger install via npm - this.onClose(); - installExtensionNpm(item.id, item.name); - } - } else { - this.onClose(); - } - } - } - invalidate() { - this.cacheW = 0; - } - render(width) { - if (this.cacheW === width) return this.cacheL; - const th = this.theme; - const bw = Math.min(90, width - 4); - const iw = bw - 4; - const maxRows = Math.max(6, (process.stdout.rows || 24) - 10); - const pad = (s) => s + " ".repeat(Math.max(0, width - visibleWidth(s))); - const box = (s) => { - const len = visibleWidth(s); - return ( - th.fg("dim", "│ ") + - s + - " ".repeat(Math.max(0, bw - 2 - len)) + - th.fg("dim", " │") - ); - }; - const lines = []; - lines.push(pad(th.fg("dim", "╭" + "─".repeat(bw) + "╮"))); - lines.push(pad(box(th.bold(th.fg("accent", "📦 Marketplace"))))); - lines.push(pad(th.fg("dim", "├" + "─".repeat(bw) + "┤"))); - const filterLabel = - this.category === "all" - ? th.fg("dim", "all") - : th.fg("accent", this.category); - lines.push( - pad( - box( - `${th.fg("dim", "filter:")} ${filterLabel} ${th.fg("dim", "↑/jk navigate • f filter • Enter install • Esc close")}`, - ), - ), - ); - lines.push(pad(box(""))); - const visibleItems = this.filtered; - if (!visibleItems.length) { - lines.push(pad(box(th.fg("dim", "No packages found.")))); - } else { - this.scroll = Math.min( - this.scroll, - Math.max(0, visibleItems.length - maxRows), - ); - this.sel = Math.min(this.sel, visibleItems.length - 1); - if (this.sel < this.scroll) this.scroll = this.sel; - if (this.sel >= this.scroll + maxRows) - this.scroll = this.sel - maxRows + 1; - for ( - let i = this.scroll; - i < Math.min(visibleItems.length, this.scroll + maxRows); - i++ - ) { - const item = visibleItems[i]; - const ptr = i === this.sel ? th.fg("accent", "❯ ") : " "; - const srcIcon = - item.source === "installed" - ? th.fg("success", "● ") - : item.source === "pi-compat" - ? th.fg("warning", "◐ ") - : th.fg("dim", "○ "); - const name = - i === this.sel - ? th.fg("accent", item.name) - : th.fg("text", item.name); - const desc = th.fg( - "dim", - truncateToWidth( - item.description, - Math.max(10, iw - visibleWidth(`${ptr}${srcIcon}${item.name} `)), - ), - ); - lines.push(pad(box(`${ptr}${srcIcon}${name} ${desc}`))); - } - } - lines.push(pad(box(""))); - lines.push(pad(th.fg("dim", "├" + "─".repeat(bw) + "┤"))); - lines.push( - pad( - box( - th.fg( - "dim", - `${visibleItems.length} packages • ${this.items.filter((i) => i.source === "installed").length} installed`, - ), - ), - ), - ); - lines.push(pad(th.fg("dim", "╰" + "─".repeat(bw) + "╯"))); - lines.push(""); - this.cacheL = lines; - this.cacheW = width; - return lines; - } -} -export async function openMarketplaceOverlay(ctx) { - if (!ctx.hasUI) { - ctx.ui.notify("Marketplace requires interactive mode", "error"); - return; - } - const items = buildCatalog(); - await ctx.ui.custom( - (tui, theme, _kb, done) => { - const overlay = new MarketplaceOverlay(tui, theme, items, () => - done(true), - ); - return { - render: (w) => overlay.render(w), - invalidate: () => overlay.invalidate(), - handleInput: (d) => overlay.handleInput(d), - }; - }, - { - overlay: true, - overlayOptions: { - width: "92%", - minWidth: 70, - maxHeight: "88%", - anchor: "center", - backdrop: true, - }, - }, - ); -} - -/** - * Install a marketplace extension via npm into ~/.sf/agent/extensions/. - * - * Purpose: wire the EXTENSIONS experimental flag — press Enter on a featured - * package to trigger `npm install` into the user extensions directory. - * Consumer: MarketplaceOverlay.handleInput on Enter for non-installed items. - */ -export function installExtensionNpm(packageId, displayName) { - if (!getExperimentalFlag("extensions")) { - // Silently skip if EXTENSIONS flag is off; overlay already closed - return; - } - import("node:child_process").then(({ spawn }) => { - const target = join(homedir(), ".sf", "agent", "extensions"); - const proc = spawn("npm", ["install", "--prefix", target, packageId], { - stdio: ["ignore", "pipe", "pipe"], - detached: false, - }); - let stderr = ""; - proc.stderr?.on("data", (d) => { - stderr += d.toString(); - }); - proc.on("close", (code) => { - if (code === 0) { - process.stdout.write( - `\r\n\x1b[32m✓ Installed ${displayName || packageId}\x1b[0m\r\n`, - ); - } else { - process.stdout.write( - `\r\n\x1b[31m✗ Install failed for ${displayName || packageId}: ${stderr.slice(0, 200)}\x1b[0m\r\n`, - ); - } - }); - proc.on("error", () => { - process.stdout.write( - `\r\n\x1b[33m⚠ npm not found — install npm to enable extension installs\x1b[0m\r\n`, - ); - }); - }); -} diff --git a/src/resources/extensions/sf-tui/powerline.js b/src/resources/extensions/sf-tui/powerline.js deleted file mode 100644 index 31f14ff24..000000000 --- a/src/resources/extensions/sf-tui/powerline.js +++ /dev/null @@ -1,157 +0,0 @@ -import { truncateToWidth, visibleWidth } from "@singularity-forge/tui"; - -const RESET = "\x1b[0m"; -function fgCode(color) { - switch (color) { - case "black": - return "30"; - case "red": - return "31"; - case "green": - return "32"; - case "yellow": - return "33"; - case "blue": - return "34"; - case "magenta": - return "35"; - case "cyan": - return "36"; - case "white": - return "37"; - case "brightBlack": - return "90"; - case "brightRed": - return "91"; - case "brightGreen": - return "92"; - case "brightYellow": - return "93"; - case "brightBlue": - return "94"; - case "brightMagenta": - return "95"; - case "brightCyan": - return "96"; - case "brightWhite": - return "97"; - default: - return "39"; - } -} -function bgCode(color) { - switch (color) { - case "black": - return "40"; - case "red": - return "41"; - case "green": - return "42"; - case "yellow": - return "43"; - case "blue": - return "44"; - case "magenta": - return "45"; - case "cyan": - return "46"; - case "white": - return "47"; - case "brightBlack": - return "100"; - case "brightRed": - return "101"; - case "brightGreen": - return "102"; - case "brightYellow": - return "103"; - case "brightBlue": - return "104"; - case "brightMagenta": - return "105"; - case "brightCyan": - return "106"; - case "brightWhite": - return "107"; - default: - return "49"; - } -} -function ansi(fg, bg, bold) { - const codes = []; - if (bold) codes.push("1"); - if (fg) codes.push(fgCode(fg)); - if (bg) codes.push(bgCode(bg)); - return codes.length ? `\x1b[${codes.join(";")}m` : RESET; -} -export function renderPowerline(segments, width, theme) { - if (!segments.length) return ""; - const SEP = ""; - const _SEP_WIDTH = visibleWidth(SEP); - // Build raw segments with separators - const parts = []; - for (let i = 0; i < segments.length; i++) { - const seg = segments[i]; - const next = segments[i + 1]; - const text = ` ${seg.text} `; - const segAnsi = ansi(seg.fg, seg.bg, seg.bold); - parts.push(segAnsi + text); - if (next) { - // Separator uses current bg as fg, next bg as bg - const sepAnsi = ansi(seg.bg, next.bg, false); - parts.push(sepAnsi + SEP); - } else { - // Final separator: current bg as fg, default bg - const sepAnsi = ansi(seg.bg, undefined, false); - parts.push(sepAnsi + SEP); - } - } - const line = parts.join("") + RESET; - const vis = visibleWidth(line); - // If too wide, drop non-essential segments from the right - if (vis > width && segments.length > 2) { - const trimmed = segments.slice(0, -1); - return renderPowerline(trimmed, width, theme); - } - if (vis > width) return truncateToWidth(line, width, ""); - // Pad right to fill width - if (vis < width) { - return line + " ".repeat(width - vis) + RESET; - } - return line; -} -export function renderPowerlineRight(segments, width, theme) { - if (!segments.length) return ""; - const SEP = ""; - // Build right-to-left - const parts = []; - // Start separator: default bg -> first segment bg - const first = segments[0]; - parts.push( - ansi(first.bg, undefined, false) + - SEP + - ansi(first.fg, first.bg, first.bold) + - ` ${first.text} `, - ); - for (let i = 1; i < segments.length; i++) { - const seg = segments[i]; - const prev = segments[i - 1]; - parts.push( - ansi(prev.bg, seg.bg, false) + - SEP + - ansi(seg.fg, seg.bg, seg.bold) + - ` ${seg.text} `, - ); - } - const line = parts.join("") + RESET; - const vis = visibleWidth(line); - if (vis > width && segments.length > 1) { - const trimmed = segments.slice(1); - return renderPowerlineRight(trimmed, width, theme); - } - if (vis > width) return truncateToWidth(line, width, ""); - if (vis < width) { - return " ".repeat(width - vis) + line + RESET; - } - return line; -} diff --git a/src/resources/extensions/sf-tui/prompt-history.js b/src/resources/extensions/sf-tui/prompt-history.js deleted file mode 100644 index 9050eaa04..000000000 --- a/src/resources/extensions/sf-tui/prompt-history.js +++ /dev/null @@ -1,243 +0,0 @@ -import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { dirname, join } from "node:path"; -import { - Key, - matchesKey, - truncateToWidth, - visibleWidth, -} from "@singularity-forge/tui"; - -const LIMIT = 20; -const SCAN_LINE_LIMIT = 2000; -function promptHistoryPath() { - return join(homedir(), ".sf", "agent", "prompt-history.jsonl"); -} -function isEnvTruthy(value) { - return ["1", "true", "TRUE", "yes", "YES"].includes(String(value ?? "")); -} -function parseEntryLine(line) { - try { - const text = line.trim(); - if (!text) return []; - const entry = JSON.parse(text); - if ( - !entry || - typeof entry !== "object" || - entry.version !== 1 || - typeof entry.prompt !== "string" || - entry.prompt.trim().length === 0 || - typeof entry.projectRoot !== "string" || - entry.projectRoot.trim().length === 0 - ) { - return []; - } - return [entry]; - } catch { - return []; - } -} -function readEntries() { - try { - const path = promptHistoryPath(); - if (!existsSync(path)) return []; - return readFileSync(path, "utf-8") - .split(/\r?\n/) - .reverse() - .slice(0, SCAN_LINE_LIMIT) - .flatMap(parseEntryLine); - } catch { - return []; - } -} -function normalizeHistory(history) { - const seen = new Set(); - const merged = []; - for (const item of history) { - const text = String(item ?? "").trim(); - if (!text || seen.has(text)) continue; - seen.add(text); - merged.push(text); - if (merged.length >= LIMIT) break; - } - return merged; -} -function appendEntries(entries) { - try { - const path = promptHistoryPath(); - mkdirSync(dirname(path), { recursive: true }); - appendFileSync( - path, - entries.map((entry) => JSON.stringify(entry)).join("\n") + "\n", - { encoding: "utf-8", mode: 0o600 }, - ); - } catch { - /* non-fatal */ - } -} -export function readPromptHistory(basePath) { - if (!basePath) return []; - return normalizeHistory( - readEntries() - .filter((entry) => entry.projectRoot === basePath) - .map((entry) => entry.prompt), - ); -} -export function appendPromptHistory(prompt, basePath, sessionId) { - if (isEnvTruthy(process.env.SF_SKIP_PROMPT_HISTORY)) return; - if (!basePath) return; - const normalized = normalizeHistory([prompt]); - if (!normalized.length) return; - const now = Date.now(); - const entries = normalized.toReversed().map((prompt, index) => ({ - version: 1, - prompt, - projectRoot: basePath, - sessionId: sessionId ?? null, - timestamp: now - (normalized.length - index - 1), - })); - appendEntries(entries); -} -export function pushPromptHistory(history, text) { - const t = text.trim(); - if (!t || history[0] === t) return; - history.unshift(t); - if (history.length > LIMIT) { - history.length = LIMIT; - } -} -function preview(text, maxWidth) { - const c = text.replace(/\s+/g, " ").trim(); - return c ? truncateToWidth(c, maxWidth, "…") : "(empty)"; -} -class PromptHistoryOverlay { - tui; - theme; - done; - items; - sel = 0; - cacheW = 0; - cacheL = []; - constructor(tui, theme, items, done) { - this.tui = tui; - this.theme = theme; - this.items = items; - this.done = done; - } - handleInput(data) { - if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) { - this.done(null); - return; - } - if (matchesKey(data, Key.return) || matchesKey(data, Key.enter)) { - this.done(this.items[this.sel] ?? null); - return; - } - if (matchesKey(data, Key.down) || data === "j") { - this.sel = Math.min(this.items.length - 1, this.sel + 1); - this.invalidate(); - this.tui.requestRender(); - return; - } - if (matchesKey(data, Key.up) || data === "k") { - this.sel = Math.max(0, this.sel - 1); - this.invalidate(); - this.tui.requestRender(); - return; - } - if (data >= "1" && data <= "9") { - const idx = parseInt(data, 10) - 1; - if (idx >= 0 && idx < this.items.length) { - this.done(this.items[idx] ?? null); - } - } - } - invalidate() { - this.cacheW = 0; - } - render(width) { - if (this.cacheW === width) return this.cacheL; - const th = this.theme; - const bw = Math.min(84, width - 4); - const iw = bw - 4; - const pad = (s) => s + " ".repeat(Math.max(0, width - visibleWidth(s))); - const box = (s) => { - const len = visibleWidth(s); - return ( - th.fg("dim", "│ ") + - s + - " ".repeat(Math.max(0, bw - 2 - len)) + - th.fg("dim", " │") - ); - }; - const lines = []; - lines.push(pad(th.fg("dim", "╭" + "─".repeat(bw) + "╮"))); - lines.push(pad(box(th.bold(th.fg("accent", "📜 Prompt History"))))); - lines.push(pad(th.fg("dim", "├" + "─".repeat(bw) + "┤"))); - lines.push( - pad( - box( - th.fg( - "dim", - "↑/jk navigate • 1-9 quick pick • Enter insert • Esc cancel", - ), - ), - ), - ); - lines.push(pad(box(""))); - for (let i = 0; i < this.items.length; i++) { - const item = this.items[i]; - const p = preview(item, iw - 8); - const ptr = i === this.sel ? th.fg("accent", "❯ ") : " "; - const num = i < 9 ? th.fg("dim", `${i + 1}`) : " "; - const label = i === this.sel ? th.fg("accent", p) : p; - lines.push(pad(box(`${ptr}${num}. ${label}`))); - } - lines.push(pad(box(""))); - lines.push(pad(th.fg("dim", "├" + "─".repeat(bw) + "┤"))); - lines.push(pad(box(th.fg("dim", `${this.items.length} prompts`)))); - lines.push(pad(th.fg("dim", "╰" + "─".repeat(bw) + "╯"))); - lines.push(""); - this.cacheL = lines; - this.cacheW = width; - return lines; - } -} -export async function openPromptHistoryOverlay(ctx, basePath) { - if (!ctx.hasUI) { - ctx.ui.notify("Prompt history requires interactive mode", "error"); - return; - } - const items = readPromptHistory(basePath ?? undefined); - if (!items.length) { - ctx.ui.notify( - "No prompt history yet. Send a message to build history.", - "info", - ); - return; - } - const selected = await ctx.ui.custom( - (tui, theme, _kb, done) => { - const o = new PromptHistoryOverlay(tui, theme, items, done); - return { - render: (w) => o.render(w), - invalidate: () => o.invalidate(), - handleInput: (d) => o.handleInput(d), - }; - }, - { - overlay: true, - overlayOptions: { - width: "90%", - minWidth: 60, - maxHeight: "85%", - anchor: "center", - backdrop: true, - }, - }, - ); - if (selected) { - ctx.ui.setEditorText(selected); - ctx.ui.notify("Inserted prompt from history", "info"); - } -} diff --git a/src/resources/extensions/sf-tui/shared.js b/src/resources/extensions/sf-tui/shared.js deleted file mode 100644 index 31ceb53c9..000000000 --- a/src/resources/extensions/sf-tui/shared.js +++ /dev/null @@ -1,7 +0,0 @@ -import { visibleWidth } from "@singularity-forge/tui"; -export function rightAlign(left, right, width) { - const leftVis = visibleWidth(left); - const rightVis = visibleWidth(right); - const gap = Math.max(1, width - leftVis - rightVis); - return left + " ".repeat(gap) + right; -} diff --git a/src/resources/extensions/sf-usage-bar/extension-manifest.json b/src/resources/extensions/sf-usage-bar/extension-manifest.json deleted file mode 100644 index 15ee06d07..000000000 --- a/src/resources/extensions/sf-usage-bar/extension-manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "id": "sf-usage-bar", - "name": "SF Usage Bar", - "version": "1.0.0", - "description": "Shows configured AI provider usage windows and service status", - "tier": "bundled", - "requires": { "platform": ">=2.29.0" }, - "provides": { - "commands": ["usage"] - } -} diff --git a/src/resources/extensions/slash-commands/audit.js b/src/resources/extensions/sf/commands/legacy/audit.js similarity index 100% rename from src/resources/extensions/slash-commands/audit.js rename to src/resources/extensions/sf/commands/legacy/audit.js diff --git a/src/resources/extensions/slash-commands/create-extension.js b/src/resources/extensions/sf/commands/legacy/create-extension.js similarity index 99% rename from src/resources/extensions/slash-commands/create-extension.js rename to src/resources/extensions/sf/commands/legacy/create-extension.js index 5945a6e2c..72236eaaa 100644 --- a/src/resources/extensions/slash-commands/create-extension.js +++ b/src/resources/extensions/sf/commands/legacy/create-extension.js @@ -1,4 +1,4 @@ -import { showInterviewRound } from "../shared/tui.js"; +import { showInterviewRound } from "../../../shared/tui.js"; export default function createExtension(pi) { pi.registerCommand("create-extension", { description: diff --git a/src/resources/extensions/slash-commands/create-slash-command.js b/src/resources/extensions/sf/commands/legacy/create-slash-command.js similarity index 99% rename from src/resources/extensions/slash-commands/create-slash-command.js rename to src/resources/extensions/sf/commands/legacy/create-slash-command.js index 67b341306..71f1b3638 100644 --- a/src/resources/extensions/slash-commands/create-slash-command.js +++ b/src/resources/extensions/sf/commands/legacy/create-slash-command.js @@ -1,4 +1,4 @@ -import { showInterviewRound } from "../shared/tui.js"; +import { showInterviewRound } from "../../../shared/tui.js"; export default function createSlashCommand(pi) { pi.registerCommand("create-slash-command", { description: diff --git a/src/resources/extensions/slash-commands/index.js b/src/resources/extensions/sf/commands/legacy/index.js similarity index 81% rename from src/resources/extensions/slash-commands/index.js rename to src/resources/extensions/sf/commands/legacy/index.js index 166320d06..cf197c68b 100644 --- a/src/resources/extensions/slash-commands/index.js +++ b/src/resources/extensions/sf/commands/legacy/index.js @@ -1,10 +1,8 @@ import auditCommand from "./audit.js"; -import clearCommand from "./clear.js"; import createExtension from "./create-extension.js"; import createSlashCommand from "./create-slash-command.js"; export default function slashCommands(pi) { createSlashCommand(pi); createExtension(pi); auditCommand(pi); - clearCommand(pi); } diff --git a/src/resources/extensions/sf/extension-manifest.json b/src/resources/extensions/sf/extension-manifest.json index d43adbf8f..220e789f1 100644 --- a/src/resources/extensions/sf/extension-manifest.json +++ b/src/resources/extensions/sf/extension-manifest.json @@ -59,10 +59,12 @@ "add-tests", "agent", "ask", + "audit", "autonomous", "backlog", "capture", "chronicle", + "clear", "cleanup", "cmux", "codebase", @@ -75,6 +77,8 @@ "configure-agent", "control", "cost", + "create-extension", + "create-slash-command", "debug", "delegate", "diff", @@ -94,6 +98,8 @@ "fast", "find", "forensics", + "guard-status", + "guard-toggle", "harness", "help", "history", @@ -114,9 +120,17 @@ "new-milestone", "next", "notifications", + "notify-beep", + "notify-focus", + "notify-save-global", + "notify-say", + "notify-status", + "notify-threshold", "parallel", "park", "pause", + "permission", + "permission-mode", "permission-profile", "plan", "pr-branch", @@ -164,6 +178,7 @@ "unpark", "uok", "update", + "usage", "visualize", "widget", "workflow", @@ -186,6 +201,7 @@ "tool_execution_end", "tool_execution_start", "tool_result", + "turn_start", "turn_end" ], "shortcuts": [ diff --git a/src/resources/extensions/sf-inturn-guard/index.js b/src/resources/extensions/sf/guards/inturn.js similarity index 100% rename from src/resources/extensions/sf-inturn-guard/index.js rename to src/resources/extensions/sf/guards/inturn.js diff --git a/src/resources/extensions/sf/index.js b/src/resources/extensions/sf/index.js index ad6b0f88a..6d1000f1f 100644 --- a/src/resources/extensions/sf/index.js +++ b/src/resources/extensions/sf/index.js @@ -67,4 +67,64 @@ export default async function registerExtension(pi) { `SF TUI setup failed — running with default header/footer: ${err instanceof Error ? err.message : String(err)}`, ); } + + // Register SF usage bar (/usage command — provider usage windows + status). + try { + const { default: registerSFUsageBar } = await import("./ui/usage-bar.js"); + registerSFUsageBar(pi); + } catch (err) { + const { logWarning } = await import("./workflow-logger.js"); + logWarning( + "usage-bar", + `SF usage bar setup failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // Register SF notifications (completion beep/say/focus/threshold commands). + try { + const { default: registerSFNotify } = await import("./notifications/notify.js"); + registerSFNotify(pi); + } catch (err) { + const { logWarning } = await import("./workflow-logger.js"); + logWarning( + "notifications", + `SF notifications setup failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // Register SF in-turn guard (duplicate tool-call loop detection). + try { + const { default: registerSFInturnGuard } = await import("./guards/inturn.js"); + registerSFInturnGuard(pi); + } catch (err) { + const { logWarning } = await import("./workflow-logger.js"); + logWarning( + "inturn-guard", + `SF in-turn guard setup failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // Register SF permissions (layered permission enforcement). + try { + const { default: registerSFPermissions } = await import("./permissions/index.js"); + registerSFPermissions(pi); + } catch (err) { + const { logWarning } = await import("./workflow-logger.js"); + logWarning( + "permissions", + `SF permissions setup failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // Register SF legacy slash commands (/audit, /clear, /create-extension, /create-slash-command). + try { + const { default: registerSFLegacyCommands } = await import("./commands/legacy/index.js"); + registerSFLegacyCommands(pi); + } catch (err) { + const { logWarning } = await import("./workflow-logger.js"); + logWarning( + "legacy-commands", + `SF legacy commands setup failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } } diff --git a/src/resources/extensions/sf-notify/index.js b/src/resources/extensions/sf/notifications/notify.js similarity index 99% rename from src/resources/extensions/sf-notify/index.js rename to src/resources/extensions/sf/notifications/notify.js index e636baaa4..643e620e5 100644 --- a/src/resources/extensions/sf-notify/index.js +++ b/src/resources/extensions/sf/notifications/notify.js @@ -20,7 +20,7 @@ import { replaceMessageTemplates, SAY_MESSAGES, speakMessage, -} from "../shared/notify.js"; +} from "../../shared/notify.js"; const DEFAULT_CONFIG = { thresholdMs: 2000, diff --git a/src/resources/extensions/sf-permissions/index.js b/src/resources/extensions/sf/permissions/index.js similarity index 100% rename from src/resources/extensions/sf-permissions/index.js rename to src/resources/extensions/sf/permissions/index.js diff --git a/src/resources/extensions/sf-permissions/permission-core.js b/src/resources/extensions/sf/permissions/permission-core.js similarity index 100% rename from src/resources/extensions/sf-permissions/permission-core.js rename to src/resources/extensions/sf/permissions/permission-core.js diff --git a/src/resources/extensions/sf/tests/remote-steering-pipeline.test.mjs b/src/resources/extensions/sf/tests/remote-steering-pipeline.test.mjs new file mode 100644 index 000000000..2778e8240 --- /dev/null +++ b/src/resources/extensions/sf/tests/remote-steering-pipeline.test.mjs @@ -0,0 +1,286 @@ +/** + * Integration tests for the full remote steering pipeline: + * parse → apply → format (steer command → mode change → result display) + * + * Purpose: verify that a remote answer containing steering directives flows + * correctly through all three stages and that the session mode actually reflects + * the requested change. + * + * Consumer: CI gate; regression guard for remote-channel mode steering. + */ +import assert from "node:assert"; +import { beforeEach, describe, test, vi } from "vitest"; + +// ── Mock auto/session before importing the module under test ────────────────── +const _modeState = { + workMode: "ask", + runControl: "manual", + permissionProfile: "normal", + modelMode: "auto", +}; + +const mockSession = { + setMode(updates) { + Object.assign(_modeState, updates); + }, + getMode() { + return { ..._modeState }; + }, +}; + +vi.mock("../auto/session.js", () => ({ + getAutoSession: () => mockSession, +})); + +vi.mock("../workflow-logger.js", () => ({ + logWarning: vi.fn(), +})); + +// Import AFTER mocks are registered so module sees the mock. +const { + parseRemoteSteeringDirectives, + applyRemoteSteeringDirectives, + formatRemoteSteeringResults, +} = await import("../remote-steering.js"); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Reset mock mode state before each test. */ +function resetMode() { + Object.assign(_modeState, { + workMode: "ask", + runControl: "manual", + permissionProfile: "normal", + modelMode: "auto", + }); +} + +/** Build a unique source key to avoid throttle carry-over between tests. */ +let _srcCounter = 0; +function uniqueSrc() { + return `test-pipeline-${_srcCounter++}`; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Parse stage +// ───────────────────────────────────────────────────────────────────────────── + +describe("parse stage", () => { + test("parseRemoteSteeringDirectives_when_text_has_mode_directive_returns_steering_true", () => { + const result = parseRemoteSteeringDirectives({ + text: "/mode build", + }); + assert.strictEqual(result.steering, true); + assert.strictEqual(result.directives.length, 1); + assert.deepStrictEqual(result.directives[0], { cmd: "mode", value: "build" }); + }); + + test("parseRemoteSteeringDirectives_when_text_has_all_axes_returns_all_four_directives", () => { + const result = parseRemoteSteeringDirectives({ + text: "/mode build /control autonomous /permission-profile trusted /model-mode deep", + }); + assert.strictEqual(result.steering, true); + assert.strictEqual(result.directives.length, 4); + const cmds = result.directives.map((d) => d.cmd); + assert.ok(cmds.includes("mode")); + assert.ok(cmds.includes("control")); + assert.ok(cmds.includes("permission-profile")); + assert.ok(cmds.includes("model-mode")); + }); + + test("parseRemoteSteeringDirectives_when_answers_array_contains_directive_returns_steering_true", () => { + const result = parseRemoteSteeringDirectives({ + answers: ["/mode review", "some other text"], + }); + assert.strictEqual(result.steering, true); + assert.strictEqual(result.directives[0].value, "review"); + }); + + test("parseRemoteSteeringDirectives_when_value_invalid_ignores_directive", () => { + const result = parseRemoteSteeringDirectives({ + text: "/mode notavalidmode", + }); + assert.strictEqual(result.steering, false); + assert.strictEqual(result.directives.length, 0); + }); + + test("parseRemoteSteeringDirectives_when_null_input_returns_no_steering", () => { + const result = parseRemoteSteeringDirectives(null); + assert.strictEqual(result.steering, false); + assert.deepStrictEqual(result.directives, []); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Apply stage +// ───────────────────────────────────────────────────────────────────────────── + +describe("apply stage", () => { + beforeEach(resetMode); + + test("applyRemoteSteeringDirectives_when_mode_directive_applies_changes_session_workMode", () => { + const src = uniqueSrc(); + const results = applyRemoteSteeringDirectives( + [{ cmd: "mode", value: "build" }], + src, + ); + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].applied, true); + assert.strictEqual(_modeState.workMode, "build"); + }); + + test("applyRemoteSteeringDirectives_when_control_directive_applies_changes_runControl", () => { + const src = uniqueSrc(); + applyRemoteSteeringDirectives([{ cmd: "control", value: "autonomous" }], src); + assert.strictEqual(_modeState.runControl, "autonomous"); + }); + + test("applyRemoteSteeringDirectives_when_permission_profile_directive_applies", () => { + const src = uniqueSrc(); + applyRemoteSteeringDirectives( + [{ cmd: "permission-profile", value: "trusted" }], + src, + ); + assert.strictEqual(_modeState.permissionProfile, "trusted"); + }); + + test("applyRemoteSteeringDirectives_when_model_mode_directive_applies", () => { + const src = uniqueSrc(); + applyRemoteSteeringDirectives([{ cmd: "model-mode", value: "deep" }], src); + assert.strictEqual(_modeState.modelMode, "deep"); + }); + + test("applyRemoteSteeringDirectives_when_unknown_cmd_returns_applied_false", () => { + const src = uniqueSrc(); + const results = applyRemoteSteeringDirectives( + [{ cmd: "notacommand", value: "whatever" }], + src, + ); + assert.strictEqual(results[0].applied, false); + assert.ok(results[0].error); + }); + + test("applyRemoteSteeringDirectives_when_same_source_called_again_immediately_throttles", () => { + // First call should succeed + const src = uniqueSrc(); + const first = applyRemoteSteeringDirectives( + [{ cmd: "mode", value: "build" }], + src, + ); + assert.strictEqual(first[0].applied, true); + + // Second immediate call with the same source must be throttled + const second = applyRemoteSteeringDirectives( + [{ cmd: "mode", value: "review" }], + src, + ); + assert.strictEqual(second[0].applied, false); + assert.ok(second[0].error.toLowerCase().includes("throttled")); + }); + + test("applyRemoteSteeringDirectives_when_different_sources_both_applied", () => { + const first = applyRemoteSteeringDirectives( + [{ cmd: "mode", value: "build" }], + uniqueSrc(), + ); + const second = applyRemoteSteeringDirectives( + [{ cmd: "model-mode", value: "deep" }], + uniqueSrc(), + ); + assert.strictEqual(first[0].applied, true); + assert.strictEqual(second[0].applied, true); + }); + + test("applyRemoteSteeringDirectives_when_empty_directives_returns_empty", () => { + const results = applyRemoteSteeringDirectives([], uniqueSrc()); + assert.deepStrictEqual(results, []); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Format stage +// ───────────────────────────────────────────────────────────────────────────── + +describe("format stage", () => { + test("formatRemoteSteeringResults_when_all_applied_renders_ok_markers", () => { + const results = [ + { cmd: "mode", value: "build", applied: true }, + { cmd: "control", value: "autonomous", applied: true }, + ]; + const text = formatRemoteSteeringResults(results); + assert.ok(text.includes("[ok] /mode build"), `missing ok mode: ${text}`); + assert.ok(text.includes("[ok] /control autonomous"), `missing ok control: ${text}`); + }); + + test("formatRemoteSteeringResults_when_blocked_renders_blocked_marker_and_error", () => { + const results = [ + { cmd: "mode", value: "review", applied: false, error: "Throttled" }, + ]; + const text = formatRemoteSteeringResults(results); + assert.ok(text.includes("[blocked] /mode review"), `missing blocked: ${text}`); + assert.ok(text.includes("Throttled"), `missing error text: ${text}`); + }); + + test("formatRemoteSteeringResults_includes_current_mode_summary", () => { + const results = [{ cmd: "mode", value: "build", applied: true }]; + const text = formatRemoteSteeringResults(results); + assert.ok(text.includes("Current:"), `missing Current: line: ${text}`); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Full pipeline integration +// ───────────────────────────────────────────────────────────────────────────── + +describe("full pipeline: parse → apply → format", () => { + beforeEach(resetMode); + + test("pipeline_when_mode_build_directive_ends_with_build_in_current_line", () => { + const src = uniqueSrc(); + // Step 1: parse + const parsed = parseRemoteSteeringDirectives({ text: "/mode build" }); + assert.strictEqual(parsed.steering, true); + + // Step 2: apply + const applied = applyRemoteSteeringDirectives(parsed.directives, src); + assert.ok(applied.every((r) => r.applied)); + + // Step 3: format + const formatted = formatRemoteSteeringResults(applied); + assert.ok(formatted.includes("build"), `expected 'build' in: ${formatted}`); + assert.ok(formatted.includes("[ok]"), `expected [ok] in: ${formatted}`); + }); + + test("pipeline_when_all_axes_set_mode_state_reflects_all_changes", () => { + const src = uniqueSrc(); + const parsed = parseRemoteSteeringDirectives({ + text: "/mode build /control autonomous /permission-profile trusted /model-mode deep", + }); + assert.strictEqual(parsed.directives.length, 4); + + const applied = applyRemoteSteeringDirectives(parsed.directives, src); + assert.ok(applied.every((r) => r.applied), `Some failed: ${JSON.stringify(applied)}`); + + assert.strictEqual(_modeState.workMode, "build"); + assert.strictEqual(_modeState.runControl, "autonomous"); + assert.strictEqual(_modeState.permissionProfile, "trusted"); + assert.strictEqual(_modeState.modelMode, "deep"); + }); + + test("pipeline_when_throttled_apply_returns_blocked_and_format_shows_blocked", () => { + const src = uniqueSrc(); + // First steering succeeds + const parsed = parseRemoteSteeringDirectives({ text: "/mode build" }); + applyRemoteSteeringDirectives(parsed.directives, src); + + // Same source, immediate second steer + const parsed2 = parseRemoteSteeringDirectives({ text: "/mode review" }); + const applied2 = applyRemoteSteeringDirectives(parsed2.directives, src); + const formatted = formatRemoteSteeringResults(applied2); + + assert.strictEqual(applied2[0].applied, false); + assert.ok(formatted.includes("[blocked]"), `expected blocked: ${formatted}`); + // Mode should not have changed + assert.strictEqual(_modeState.workMode, "build"); + }); +}); diff --git a/src/resources/extensions/sf-usage-bar/index.js b/src/resources/extensions/sf/ui/usage-bar.js similarity index 100% rename from src/resources/extensions/sf-usage-bar/index.js rename to src/resources/extensions/sf/ui/usage-bar.js diff --git a/src/resources/extensions/sf/uok/kernel.js b/src/resources/extensions/sf/uok/kernel.js deleted file mode 100644 index db83c420d..000000000 --- a/src/resources/extensions/sf/uok/kernel.js +++ /dev/null @@ -1,217 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { debugLog } from "../debug-logger.js"; -import { - defaultPermissionProfileForRunControl, - resolvePermissionProfile, - resolveRunControlMode, - runControlModeForSession, -} from "../operating-model.js"; -import { - isDbAvailable, - recordUokRunExit, - recordUokRunStart, -} from "../sf-db.js"; -import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js"; -import { setAuditEnvelopeEnabled } from "./audit-toggle.js"; -import { writeUokDiagnostics } from "./diagnostic-synthesis.js"; -import { resolveUokFlags } from "./flags.js"; -import { createTurnObserver } from "./loop-adapter.js"; -import { - checkAndDrainMissingExit, - resetParityCommitBlock, - signalKernelEnter, -} from "./parity-diff-capture.js"; -import { - hasCurrentParityWarning, - writeParityHeartbeat, - writeParityReport, -} from "./parity-report.js"; - -function refreshParityReport(basePath) { - try { - return writeParityReport(basePath); - } catch (err) { - debugLog("uok-parity-report-write-failed", { - error: err instanceof Error ? err.message : String(err), - }); - return null; - } -} -function resolveKernelPathLabel() { - return "uok-kernel"; -} -/** - * Records an abnormal UOK kernel termination in both durable stores. - * - * Purpose: keep the DB-backed UOK run ledger and JSONL parity heartbeat - * symmetrical when autonomous mode exits via signal and bypasses the async kernel - * finally block. - * - * Consumer: auto signal cleanup and UOK parity tests. - */ -export function recordUokKernelTermination({ - basePath, - runId, - sessionId, - flags, - runControl, - permissionProfile, - status = "signal", - error, -}) { - const endedAt = new Date().toISOString(); - const lifecycleFlags = { - ...(flags ?? {}), - ...(runControl ? { runControl } : {}), - ...(permissionProfile ? { permissionProfile } : {}), - }; - if (runId && isDbAvailable()) { - recordUokRunExit({ - runId, - sessionId, - path: resolveKernelPathLabel(), - flags: lifecycleFlags, - status, - endedAt, - ...(error ? { error } : {}), - }); - } - writeParityHeartbeat(basePath, { - ts: endedAt, - ...(runId ? { runId } : {}), - sessionId, - path: resolveKernelPathLabel(), - flags: lifecycleFlags, - ...(runControl ? { runControl } : {}), - ...(permissionProfile ? { permissionProfile } : {}), - phase: "exit", - status, - ...(error ? { error } : {}), - }); - const report = refreshParityReport(basePath); - try { - writeUokDiagnostics(basePath); - } catch (err) { - debugLog("uok-diagnostics-write-failed", { - error: err instanceof Error ? err.message : String(err), - }); - } - return report; -} -export async function runAutoLoopWithUok(args) { - const { ctx, pi, s, deps, runKernelLoop } = args; - const prefs = deps.loadEffectiveSFPreferences()?.preferences; - const flags = { ...resolveUokFlags(prefs), enabled: true }; - const runControl = resolveRunControlMode( - args.runControl ?? runControlModeForSession(s), - ); - const permissionProfile = resolvePermissionProfile( - args.permissionProfile ?? - prefs?.uok?.permission_profile ?? - defaultPermissionProfileForRunControl(runControl), - ); - // Include workMode and modelMode from session in lifecycle flags - const workMode = s.workMode ?? "chat"; - const modelMode = s.modelMode ?? "smart"; - const lifecycleFlags = { - ...flags, - runControl, - permissionProfile, - workMode, - modelMode, - }; - - const healthVerdict = writeUokDiagnostics(s.basePath); - debugLog("uok-system-health-verdict", healthVerdict); - - const previousReport = refreshParityReport(s.basePath); - const runId = `uok-${randomUUID()}`; - s.currentUokRunId = runId; - resetParityCommitBlock(); - if ( - previousReport && - previousReport.missingExitEvents > 0 && - hasCurrentParityWarning(previousReport) - ) { - checkAndDrainMissingExit( - previousReport.enterEvents, - previousReport.exitEvents, - ); - } - setAuditEnvelopeEnabled(flags.auditEnvelope); - signalKernelEnter(); - const startedAt = new Date().toISOString(); - if (isDbAvailable()) { - recordUokRunStart({ - runId, - sessionId: ctx.sessionManager?.getSessionId?.(), - path: resolveKernelPathLabel(), - flags: lifecycleFlags, - startedAt, - }); - } - writeParityHeartbeat(s.basePath, { - ts: startedAt, - runId, - sessionId: ctx.sessionManager?.getSessionId?.(), - path: resolveKernelPathLabel(), - flags: lifecycleFlags, - runControl, - permissionProfile, - phase: "enter", - }); - if (flags.auditEnvelope) { - emitUokAuditEvent( - s.basePath, - buildAuditEnvelope({ - traceId: `session:${String(s.autoStartTime || Date.now())}`, - category: "orchestration", - type: "uok-kernel-enter", - payload: { - flags: lifecycleFlags, - runControl, - permissionProfile, - workMode, - modelMode, - sessionId: ctx.sessionManager?.getSessionId?.(), - }, - }), - ); - } - const decoratedDeps = { - ...deps, - uokObserver: createTurnObserver({ - basePath: s.basePath, - gitAction: flags.gitopsTurnAction, - gitPush: flags.gitopsTurnPush, - enableAudit: flags.auditEnvelope, - enableGitops: flags.gitops, - enableChaosMonkey: flags.chaosMonkey, - runControl, - permissionProfile, - }), - uokRunControl: runControl, - uokPermissionProfile: permissionProfile, - }; - let status = "ok"; - let error; - try { - await runKernelLoop(ctx, pi, s, decoratedDeps); - } catch (err) { - status = "error"; - error = err instanceof Error ? err.message : String(err); - throw err; - } finally { - recordUokKernelTermination({ - basePath: s.basePath, - runId, - sessionId: ctx.sessionManager?.getSessionId?.(), - flags: lifecycleFlags, - runControl, - permissionProfile, - status, - ...(error ? { error } : {}), - }); - if (s.currentUokRunId === runId) s.currentUokRunId = undefined; - } -} diff --git a/src/resources/extensions/sf/uok/kernel.ts b/src/resources/extensions/sf/uok/kernel.ts new file mode 100644 index 000000000..111b5d02d --- /dev/null +++ b/src/resources/extensions/sf/uok/kernel.ts @@ -0,0 +1,321 @@ +/** + * uok-kernel.ts — Unit-of-Work kernel lifecycle management. + * + * Purpose: wraps the autonomous loop with UOK entry/exit instrumentation — + * DB run ledger, parity heartbeat, audit envelope, and diagnostic snapshot — + * so every run is durable, observable, and crash-recoverable. + * + * Consumer: auto/dispatch.ts and signal handlers (SIGINT/SIGTERM) in the + * autonomous mode boot path. + */ + +import { randomUUID } from "node:crypto"; +import { debugLog } from "../debug-logger.js"; +import { +defaultPermissionProfileForRunControl, +resolvePermissionProfile, +resolveRunControlMode, +runControlModeForSession, +} from "../operating-model.js"; +import { +isDbAvailable, +recordUokRunExit, +recordUokRunStart, +} from "../sf-db.js"; +import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js"; +import { setAuditEnvelopeEnabled } from "./audit-toggle.js"; +import { writeUokDiagnostics } from "./diagnostic-synthesis.js"; +import { resolveUokFlags } from "./flags.js"; +import { createTurnObserver } from "./loop-adapter.js"; +import { +checkAndDrainMissingExit, +resetParityCommitBlock, +signalKernelEnter, +} from "./parity-diff-capture.js"; +import { +hasCurrentParityWarning, +writeParityHeartbeat, +writeParityReport, +} from "./parity-report.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Flags resolved from user preferences for this UOK run. */ +interface UokFlags { +auditEnvelope?: boolean; +gitops?: boolean; +gitopsTurnAction?: string; +gitopsTurnPush?: boolean; +chaosMonkey?: boolean; +enabled?: boolean; +[key: string]: unknown; +} + +/** Lifecycle flags written to the DB run ledger and parity heartbeat. */ +interface LifecycleFlags extends UokFlags { +runControl?: string; +permissionProfile?: string; +workMode?: string; +modelMode?: string; +} + +export interface UokKernelTerminationArgs { +basePath: string; +runId?: string; +sessionId?: string | null; +flags?: UokFlags; +runControl?: string; +permissionProfile?: string; +/** Exit status — "ok" | "error" | "signal". Defaults to "signal". */ +status?: string; +error?: string; +} + +/** Arguments for {@link runAutoLoopWithUok}. */ +export interface RunAutoLoopWithUokArgs { +/** Coding-agent context (opaque; provides sessionManager). */ +ctx: { +sessionManager?: { +getSessionId?: () => string | undefined; +}; +[key: string]: unknown; +}; +/** Pi/provider interface (opaque). */ +pi: unknown; +/** Mutable session state. */ +s: { +basePath: string; +workMode?: string; +modelMode?: string; +autoStartTime?: number; +currentUokRunId?: string; +[key: string]: unknown; +}; +/** Injected dependency bag. */ +deps: { +loadEffectiveSFPreferences?: () => { preferences?: Record } | null; +[key: string]: unknown; +}; +runControl?: string; +permissionProfile?: string; +/** The inner autonomous loop to execute inside the UOK wrapper. */ +runKernelLoop: ( +ctx: RunAutoLoopWithUokArgs["ctx"], +pi: unknown, +s: RunAutoLoopWithUokArgs["s"], +deps: Record, +) => Promise; +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +function refreshParityReport(basePath: string): ReturnType | null { +try { +return writeParityReport(basePath); +} catch (err) { +debugLog("uok-parity-report-write-failed", { +error: err instanceof Error ? err.message : String(err), +}); +return null; +} +} + +function resolveKernelPathLabel(): string { +return "uok-kernel"; +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** + * Records an abnormal UOK kernel termination in both durable stores. + * + * Purpose: keep the DB-backed UOK run ledger and JSONL parity heartbeat + * symmetrical when autonomous mode exits via signal and bypasses the async kernel + * finally block. + * + * Consumer: auto signal cleanup and UOK parity tests. + */ +export function recordUokKernelTermination({ +basePath, +runId, +sessionId, +flags, +runControl, +permissionProfile, +status = "signal", +error, +}: UokKernelTerminationArgs): ReturnType | null { +const endedAt = new Date().toISOString(); +const lifecycleFlags: LifecycleFlags = { +...(flags ?? {}), +...(runControl ? { runControl } : {}), +...(permissionProfile ? { permissionProfile } : {}), +}; +if (runId && isDbAvailable()) { +recordUokRunExit({ +runId, +sessionId, +path: resolveKernelPathLabel(), +flags: lifecycleFlags, +status, +endedAt, +...(error ? { error } : {}), +}); +} +writeParityHeartbeat(basePath, { +ts: endedAt, +...(runId ? { runId } : {}), +sessionId, +path: resolveKernelPathLabel(), +flags: lifecycleFlags, +...(runControl ? { runControl } : {}), +...(permissionProfile ? { permissionProfile } : {}), +phase: "exit", +status, +...(error ? { error } : {}), +}); +const report = refreshParityReport(basePath); +try { +writeUokDiagnostics(basePath); +} catch (err) { +debugLog("uok-diagnostics-write-failed", { +error: err instanceof Error ? err.message : String(err), +}); +} +return report; +} + +/** + * Runs the autonomous loop inside the UOK lifecycle wrapper. + * + * Purpose: instrument every autonomous run with entry/exit DB records, parity + * heartbeats, audit events, and a crash-recovery diagnostic snapshot so that + * incomplete runs can be detected and drained on the next startup. + * + * Consumer: auto/dispatch.ts — called once per autonomous session activation. + */ +export async function runAutoLoopWithUok(args: RunAutoLoopWithUokArgs): Promise { +const { ctx, pi, s, deps, runKernelLoop } = args; +const prefs = deps.loadEffectiveSFPreferences?.()?.preferences as Record | undefined; +const flags: UokFlags = { ...resolveUokFlags(prefs), enabled: true }; +const runControl: string = resolveRunControlMode( +args.runControl ?? runControlModeForSession(s), +); +const permissionProfile: string = resolvePermissionProfile( +args.permissionProfile ?? +(prefs?.uok as Record | undefined)?.permission_profile ?? +defaultPermissionProfileForRunControl(runControl), +); +// Include workMode and modelMode from session in lifecycle flags +const workMode: string = (s.workMode as string | undefined) ?? "chat"; +const modelMode: string = (s.modelMode as string | undefined) ?? "smart"; +const lifecycleFlags: LifecycleFlags = { +...flags, +runControl, +permissionProfile, +workMode, +modelMode, +}; + +const healthVerdict = writeUokDiagnostics(s.basePath); +debugLog("uok-system-health-verdict", healthVerdict); + +const previousReport = refreshParityReport(s.basePath); +const runId = `uok-${randomUUID()}`; +s.currentUokRunId = runId; +resetParityCommitBlock(); +if ( +previousReport && +(previousReport as { missingExitEvents?: number }).missingExitEvents != null && +(previousReport as { missingExitEvents: number }).missingExitEvents > 0 && +hasCurrentParityWarning(previousReport) +) { +checkAndDrainMissingExit( +(previousReport as { enterEvents: number }).enterEvents, +(previousReport as { exitEvents: number }).exitEvents, +); +} +setAuditEnvelopeEnabled(flags.auditEnvelope ?? false); +signalKernelEnter(); +const startedAt = new Date().toISOString(); +const sessionId = ctx.sessionManager?.getSessionId?.(); +if (isDbAvailable()) { +recordUokRunStart({ +runId, +sessionId, +path: resolveKernelPathLabel(), +flags: lifecycleFlags, +startedAt, +}); +} +writeParityHeartbeat(s.basePath, { +ts: startedAt, +runId, +sessionId, +path: resolveKernelPathLabel(), +flags: lifecycleFlags, +runControl, +permissionProfile, +phase: "enter", +}); +if (flags.auditEnvelope) { +emitUokAuditEvent( +s.basePath, +buildAuditEnvelope({ +traceId: `session:${String(s.autoStartTime || Date.now())}`, +category: "orchestration", +type: "uok-kernel-enter", +payload: { +flags: lifecycleFlags, +runControl, +permissionProfile, +workMode, +modelMode, +sessionId, +}, +}), +); +} +const decoratedDeps = { +...deps, +uokObserver: createTurnObserver({ +basePath: s.basePath, +gitAction: flags.gitopsTurnAction, +gitPush: flags.gitopsTurnPush, +enableAudit: flags.auditEnvelope, +enableGitops: flags.gitops, +enableChaosMonkey: flags.chaosMonkey, +runControl, +permissionProfile, +}), +uokRunControl: runControl, +uokPermissionProfile: permissionProfile, +}; +let status = "ok"; +let error: string | undefined; +try { +await runKernelLoop(ctx, pi, s, decoratedDeps); +} catch (err) { +status = "error"; +error = err instanceof Error ? err.message : String(err); +throw err; +} finally { +recordUokKernelTermination({ +basePath: s.basePath, +runId, +sessionId, +flags: lifecycleFlags, +runControl, +permissionProfile, +status, +...(error ? { error } : {}), +}); +if (s.currentUokRunId === runId) s.currentUokRunId = undefined; +} +} diff --git a/src/resources/extensions/slash-commands/clear.js b/src/resources/extensions/slash-commands/clear.js deleted file mode 100644 index c37d4b9df..000000000 --- a/src/resources/extensions/slash-commands/clear.js +++ /dev/null @@ -1,8 +0,0 @@ -export default function clearCommand(pi) { - pi.registerCommand("clear", { - description: "Alias for /new — start a new session", - async handler(_args, ctx) { - await ctx.newSession(); - }, - }); -} diff --git a/src/resources/extensions/slash-commands/extension-manifest.json b/src/resources/extensions/slash-commands/extension-manifest.json deleted file mode 100644 index 85545c6e7..000000000 --- a/src/resources/extensions/slash-commands/extension-manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "id": "slash-commands", - "name": "Slash Commands", - "version": "1.0.0", - "description": "Boilerplate generators for slash commands, extensions, and audit tools", - "tier": "bundled", - "requires": { "platform": ">=2.29.0" }, - "provides": { - "commands": ["create-slash-command", "create-extension", "audit", "clear"] - } -} diff --git a/src/resources/extensions/vectordrive/extension-manifest.json b/src/resources/extensions/vectordrive/extension-manifest.json deleted file mode 100644 index 90b1d6003..000000000 --- a/src/resources/extensions/vectordrive/extension-manifest.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "vectordrive", - "name": "VectorDrive", - "version": "1.0.0", - "description": "Native vector database integration via vectordrive (Rust-based, in-process)", - "tier": "bundled", - "requires": { "platform": ">=2.71.0" }, - "provides": { - "tools": ["vectordrive_info", "vectordrive_store", "vectordrive_search"], - "hooks": ["session_start", "session_shutdown"] - } -} diff --git a/src/resources/extensions/vectordrive/index.js b/src/resources/extensions/vectordrive/index.js deleted file mode 100644 index f2073bd59..000000000 --- a/src/resources/extensions/vectordrive/index.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * VectorDrive Extension for Singularity Forge - * - * Integrates the native Rust vectordrive vector database for semantic - * memory and code search. Works offline with no external services. - */ -import { VectordriveManager } from "./manager.js"; -import { registerVectordriveInfoTool } from "./tool-info.js"; -import { registerVectordriveSearchTool } from "./tool-search.js"; -import { registerVectordriveStoreTool } from "./tool-store.js"; -export default function (pi) { - registerVectordriveInfoTool(pi); - registerVectordriveStoreTool(pi); - registerVectordriveSearchTool(pi); - // Pre-warm the connection on session start - pi.on("session_start", async () => { - const manager = VectordriveManager.getInstance(); - await manager.getStatus(); - }); - pi.on("session_shutdown", async () => { - const manager = VectordriveManager.getInstance(); - await manager.close(); - }); -} diff --git a/src/resources/extensions/vectordrive/manager.js b/src/resources/extensions/vectordrive/manager.js deleted file mode 100644 index 82104a7a5..000000000 --- a/src/resources/extensions/vectordrive/manager.js +++ /dev/null @@ -1,160 +0,0 @@ -/** - * VectorDrive Manager — Singleton wrapping the native vectordrive VectorDb. - * - * Loads the `vectordrive` npm package dynamically (optional dependency), - * creates a persisted VectorDb in `.sf/vectordrive/`, and exposes status - * and search/store operations with graceful degradation. - */ -import { mkdirSync } from "node:fs"; -import { dirname } from "node:path"; - -const DB_DIR = ".sf/vectordrive"; -const DB_PATH = `${DB_DIR}/forge.vectors`; -const DIMENSIONS = 384; -function getDbPath() { - const home = process.env.HOME || process.env.USERPROFILE || "."; - return `${home}/${DB_PATH}`; -} -function ensureDir(path) { - try { - mkdirSync(dirname(path), { recursive: true }); - } catch { - // ignore - } -} -/** Simple text→vector fallback when no embedding model is available. */ -export function textToVector(text, dimensions = DIMENSIONS) { - const vec = new Array(dimensions).fill(0); - const normalized = text.toLowerCase().trim(); - for (let i = 0; i < normalized.length; i++) { - vec[i % dimensions] += normalized.charCodeAt(i) / 65535; - } - const mag = Math.sqrt(vec.reduce((s, v) => s + v * v, 0)); - return mag > 0 ? vec.map((v) => v / mag) : vec; -} -export class VectordriveManager { - static instance; - status = null; - initPromise = null; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - db = null; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vd = null; - static getInstance() { - if (!VectordriveManager.instance) { - VectordriveManager.instance = new VectordriveManager(); - } - return VectordriveManager.instance; - } - async getStatus() { - if (this.status?.initialized) return this.status; - if (this.initPromise) return this.initPromise; - this.initPromise = this.probe(); - return this.initPromise; - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async getDb() { - const status = await this.getStatus(); - if (status.backend !== "vectordrive") return null; - return this.db; - } - async probe() { - const dbPath = getDbPath(); - let vectordrive = null; - try { - const modName = "vectordrive"; - vectordrive = await import(modName); - } catch (err) { - this.status = { - backend: "none", - version: null, - implementation: null, - initialized: true, - vectorCount: 0, - error: `vectordrive package not installed: ${err instanceof Error ? err.message : String(err)}`, - dbPath: null, - }; - return this.status; - } - try { - this.vd = vectordrive; - ensureDir(dbPath); - const VectorDb = vectordrive.VectorDb || vectordrive.VectorDB; - if (typeof VectorDb !== "function") { - throw new Error("vectordrive package does not export VectorDb"); - } - this.db = new VectorDb({ - dimensions: DIMENSIONS, - storagePath: dbPath, - distanceMetric: "cosine", - }); - const count = await this.db.len(); - const version = vectordrive.getVersion?.() ?? null; - const impl = vectordrive.getImplementationType?.() ?? "unknown"; - this.status = { - backend: "vectordrive", - version: version?.version ?? null, - implementation: impl, - initialized: true, - vectorCount: count, - error: null, - dbPath, - }; - return this.status; - } catch (err) { - this.status = { - backend: "none", - version: null, - implementation: null, - initialized: true, - vectorCount: 0, - error: err instanceof Error ? err.message : String(err), - dbPath: null, - }; - return this.status; - } - } - async store(entry) { - const db = await this.getDb(); - if (!db) return false; - try { - await db.insert({ - id: entry.id, - vector: entry.vector, - metadata: entry.metadata, - }); - return true; - } catch { - return false; - } - } - async search(vector, k) { - const db = await this.getDb(); - if (!db) return []; - try { - const results = await db.search({ vector, k }); - return results.map((r) => ({ - id: String(r.id), - score: Number(r.score), - metadata: r.metadata, - })); - } catch { - return []; - } - } - async delete(id) { - const db = await this.getDb(); - if (!db) return false; - try { - return await db.delete(id); - } catch { - return false; - } - } - async close() { - this.db = null; - this.vd = null; - this.status = null; - this.initPromise = null; - } -} diff --git a/src/resources/extensions/vectordrive/tool-info.js b/src/resources/extensions/vectordrive/tool-info.js deleted file mode 100644 index 15cd9d630..000000000 --- a/src/resources/extensions/vectordrive/tool-info.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * VectorDrive Info Tool - * - * Introspects the vectordrive native package status, version, implementation - * type (native vs wasm), and vector count. - */ -import { Type } from "@sinclair/typebox"; -import { VectordriveManager } from "./manager.js"; -export function registerVectordriveInfoTool(pi) { - pi.registerTool({ - name: "vectordrive_info", - label: "VectorDrive Info", - description: - "Check VectorDrive native vector database status. " + - "Returns implementation type (native Rust or WASM), version, " + - "vector count, and database path.", - promptSnippet: "Check VectorDrive database status and capabilities", - parameters: Type.Object({ - refresh: Type.Optional( - Type.Boolean({ - default: false, - description: "Force re-probe instead of using cached status", - }), - ), - }), - async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { - const manager = VectordriveManager.getInstance(); - if (params.refresh) { - await manager.close(); - } - const status = await manager.getStatus(); - const lines = []; - lines.push(`# VectorDrive Status`); - lines.push(""); - lines.push(`- **Backend**: ${status.backend}`); - lines.push(`- **Implementation**: ${status.implementation ?? "n/a"}`); - lines.push(`- **Version**: ${status.version ?? "n/a"}`); - lines.push(`- **Vectors**: ${status.vectorCount}`); - lines.push(`- **Initialized**: ${status.initialized}`); - if (status.dbPath) { - lines.push(`- **DB Path**: ${status.dbPath}`); - } - if (status.error) { - lines.push(`- **Error**: ${status.error}`); - } - const text = lines.join("\n"); - return { - content: [{ type: "text", text }], - details: { status }, - isError: status.backend === "none", - }; - }, - }); -} diff --git a/src/resources/extensions/vectordrive/tool-search.js b/src/resources/extensions/vectordrive/tool-search.js deleted file mode 100644 index f45342ac3..000000000 --- a/src/resources/extensions/vectordrive/tool-search.js +++ /dev/null @@ -1,105 +0,0 @@ -/** - * VectorDrive Search Tool - * - * Semantic search over stored vectors. Accepts a pre-computed query vector - * or raw text (auto-embedded). Falls back to metadata keyword matching - * when vectordrive is offline. - */ -import { Type } from "@sinclair/typebox"; -import { textToVector, VectordriveManager } from "./manager.js"; -export function registerVectordriveSearchTool(pi) { - pi.registerTool({ - name: "vectordrive_search", - label: "VectorDrive Search", - description: - "Search VectorDrive by vector similarity or text query. " + - "Returns the most relevant stored entries with similarity scores. " + - "When no embedding model is available, a simple hash embedding is used — " + - "for best results provide pre-computed vectors via vectordrive_store.", - promptSnippet: "Search VectorDrive memories or code chunks", - promptGuidelines: [ - "Use vectordrive_search to find previously stored memories, code chunks, or documents.", - "Be specific with queries for better results.", - "If you stored code with metadata.file_path, results will include the source location.", - ], - parameters: Type.Object({ - query: Type.String({ - description: "Text query to search for (auto-converted to embedding)", - }), - vector: Type.Optional( - Type.Array(Type.Number(), { - description: - "Optional pre-computed query vector. If provided, overrides 'query' text.", - }), - ), - limit: Type.Optional( - Type.Number({ - default: 10, - description: "Maximum results (1-50)", - minimum: 1, - maximum: 50, - }), - ), - }), - async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { - const manager = VectordriveManager.getInstance(); - const status = await manager.getStatus(); - const limit = Math.min(Math.max(params.limit ?? 10, 1), 50); - if (status.backend === "none") { - return { - content: [ - { - type: "text", - text: `VectorDrive is unavailable: ${status.error ?? "unknown error"}\n\nInstall with: npm install vectordrive`, - }, - ], - details: { status }, - isError: true, - }; - } - const queryVector = - params.vector && params.vector.length > 0 - ? params.vector - : textToVector(params.query); - const results = await manager.search(queryVector, limit); - if (results.length === 0) { - return { - content: [ - { - type: "text", - text: `No results found in VectorDrive for query: "${params.query}"`, - }, - ], - details: { query: params.query, count: 0 }, - isError: false, - }; - } - const lines = []; - lines.push(`# VectorDrive Search Results`); - lines.push(`Query: "${params.query}"`); - lines.push(""); - for (const r of results) { - const meta = r.metadata ?? {}; - const preview = meta.text_preview ?? ""; - lines.push(`## ${r.id} (score: ${r.score.toFixed(4)})`); - if (preview) { - lines.push("```"); - lines.push(String(preview).slice(0, 400)); - lines.push("```"); - } - const metaLines = Object.entries(meta) - .filter(([k]) => k !== "text_preview" && k !== "stored_at") - .map(([k, v]) => `- ${k}: ${v}`); - if (metaLines.length > 0) { - lines.push(...metaLines); - } - lines.push(""); - } - return { - content: [{ type: "text", text: lines.join("\n") }], - details: { results, count: results.length }, - isError: false, - }; - }, - }); -} diff --git a/src/resources/extensions/vectordrive/tool-store.js b/src/resources/extensions/vectordrive/tool-store.js deleted file mode 100644 index 5ae2b3d8f..000000000 --- a/src/resources/extensions/vectordrive/tool-store.js +++ /dev/null @@ -1,108 +0,0 @@ -/** - * VectorDrive Store Tool - * - * Store a vector with metadata in the native VectorDb. - */ -import { Type } from "@sinclair/typebox"; -import { textToVector, VectordriveManager } from "./manager.js"; -export function registerVectordriveStoreTool(pi) { - pi.registerTool({ - name: "vectordrive_store", - label: "VectorDrive Store", - description: - "Store a vector entry in VectorDrive. Accepts either a pre-computed " + - "vector array or raw text (a simple hash embedding is generated automatically). " + - "Metadata is stored as JSON and returned in search results.", - promptSnippet: "Store a memory or code chunk in VectorDrive", - parameters: Type.Object({ - id: Type.String({ - description: - "Unique identifier for this entry (e.g. file-path:line-range)", - }), - text: Type.Optional( - Type.String({ - description: - "Raw text content to store. A simple embedding is auto-generated if 'vector' is not provided.", - }), - ), - vector: Type.Optional( - Type.Array(Type.Number(), { - description: - "Pre-computed embedding vector (384 dimensions). Overrides 'text' if provided.", - }), - ), - metadata: Type.Optional( - Type.Record(Type.String(), Type.Unknown(), { - description: - "Optional metadata object (e.g. { file_path, line_start, language })", - }), - ), - }), - async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { - const manager = VectordriveManager.getInstance(); - const status = await manager.getStatus(); - if (status.backend === "none") { - return { - content: [ - { - type: "text", - text: `VectorDrive is unavailable: ${status.error ?? "unknown error"}\n\nInstall with: npm install vectordrive`, - }, - ], - details: { status }, - isError: true, - }; - } - const id = params.id.trim(); - if (!id) { - return { - content: [{ type: "text", text: "Error: id is required." }], - details: { error: "missing_id" }, - isError: true, - }; - } - let vector; - if (params.vector && params.vector.length > 0) { - vector = params.vector; - } else if (params.text) { - vector = textToVector(params.text); - } else { - return { - content: [ - { - type: "text", - text: "Error: either 'text' or 'vector' must be provided.", - }, - ], - details: { error: "missing_content" }, - isError: true, - }; - } - const metadata = { - ...(params.metadata ?? {}), - stored_at: new Date().toISOString(), - }; - if (params.text) { - metadata.text_preview = params.text.slice(0, 200); - } - const ok = await manager.store({ id, vector, metadata }); - if (!ok) { - return { - content: [{ type: "text", text: "Error: failed to store entry." }], - details: { error: "store_failed" }, - isError: true, - }; - } - return { - content: [ - { - type: "text", - text: `Stored ${id} (${vector.length} dims).`, - }, - ], - details: { id, dimensions: vector.length, metadata }, - isError: false, - }; - }, - }); -} diff --git a/src/tests/app-smoke.test.ts b/src/tests/app-smoke.test.ts index 7eea7ec44..6154d2873 100644 --- a/src/tests/app-smoke.test.ts +++ b/src/tests/app-smoke.test.ts @@ -197,7 +197,6 @@ test("loader sets all 4 SF_ env vars and PI_PACKAGE_DIR", async (_t) => { "sf", "bg-shell", "browser-tools", - "subagent", "search-the-web", ]) { assert.ok( @@ -326,7 +325,6 @@ test("initResources syncs extensions, agents, and skills to target dir", async ( assertExtensionIndexExists(fakeAgentDir, "browser-tools"); assertExtensionIndexExists(fakeAgentDir, "search-the-web"); assertExtensionIndexExists(fakeAgentDir, "context7"); - assertExtensionIndexExists(fakeAgentDir, "subagent"); // Agents synced assert.ok( diff --git a/src/tests/extension-discovery.test.ts b/src/tests/extension-discovery.test.ts index 7e04bcb51..a79634d7e 100644 --- a/src/tests/extension-discovery.test.ts +++ b/src/tests/extension-discovery.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, test } from "vitest"; @@ -138,4 +138,49 @@ describe("discoverExtensionEntryPaths", () => { "cmux should not be discovered", ); }); + + test("deduplicates directories when a symlink points to an already-discovered real dir", (_t) => { + const root = makeTempDir(); + afterEach(() => rmSync(root, { recursive: true, force: true })); + + // Real extension directory + const extDir = join(root, "sf"); + mkdirSync(extDir); + writeFileSync(join(extDir, "index.js"), "export default function() {}"); + + // Symlink pointing to the same directory under a different name + const linkDir = join(root, "sf-link"); + symlinkSync(extDir, linkDir, "dir"); + + const paths = discoverExtensionEntryPaths(root); + // Should only return index.js once, not twice + const indexJsPaths = paths.filter((p) => p.endsWith("index.js")); + assert.equal( + indexJsPaths.length, + 1, + `Expected 1 index.js entry but got ${indexJsPaths.length}: ${JSON.stringify(indexJsPaths)}`, + ); + }); + + test("deduplicates top-level file extensions when a symlink aliases a real .js file", (_t) => { + const root = makeTempDir(); + afterEach(() => rmSync(root, { recursive: true, force: true })); + + // Real top-level extension file + writeFileSync(join(root, "ask-user-questions.js"), "export {}"); + + // Symlink to the same file under a different name + symlinkSync( + join(root, "ask-user-questions.js"), + join(root, "ask-user-questions-alias.js"), + ); + + const paths = discoverExtensionEntryPaths(root); + // Both filenames resolve to the same inode → deduplicated to 1 + assert.equal( + paths.length, + 1, + `Expected 1 entry after dedup but got ${paths.length}: ${JSON.stringify(paths)}`, + ); + }); }); diff --git a/src/tests/features-inventory-generator.test.ts b/src/tests/features-inventory-generator.test.ts index 788bfc3f2..d2a2aa12d 100644 --- a/src/tests/features-inventory-generator.test.ts +++ b/src/tests/features-inventory-generator.test.ts @@ -18,17 +18,14 @@ test("features inventory generator surfaces expected SF native tool, extension, const searchProviders = parseSearchProviders(); const knownProviders = parseKnownProviders(); - assert.ok(sfNativeTools.includes("sf_plan_milestone")); - assert.ok(sfNativeTools.includes("sf_task_complete")); - assert.ok(sfNativeTools.includes("sf_validate_milestone")); + assert.ok(sfNativeTools.includes("plan_milestone")); + assert.ok(sfNativeTools.includes("complete_task")); + assert.ok(sfNativeTools.includes("validate_milestone")); assert.ok(!sfNativeTools.includes("capture_thought")); assert.ok(extensions.includes("sf")); assert.ok(extensions.includes("search-the-web")); - assert.ok(extensions.includes("subagent")); assert.ok(extensions.includes("guardrails")); - assert.ok(extensions.includes("sf-permissions")); - assert.ok(extensions.includes("sf-inturn-guard")); assert.deepEqual(searchProviders, [ "brave", @@ -63,7 +60,7 @@ test("features inventory generator injects a rendered appendix between markers", assert.match(updated, /### Search Providers/); assert.match(updated, /### Known Model Providers/); assert.match(updated, /- `search-the-web` — \[extension-manifest\.json]/); - assert.match(updated, /- `sf_task_complete`/); + assert.match(updated, /- `plan_milestone`/); assert.match(updated, /- `brave`/); assert.match(updated, /- `xiaomi`/); assert.ok(updated.includes(generated)); diff --git a/src/tests/native-search.test.ts b/src/tests/native-search.test.ts index 32e3f9fbe..7cd45fb48 100644 --- a/src/tests/native-search.test.ts +++ b/src/tests/native-search.test.ts @@ -1278,8 +1278,8 @@ test("resolveSearchProvider prefers tavily over minimax in autonomous mode", asy process.env.TAVILY_API_KEY = "test-tavily-key"; process.env.MINIMAX_API_KEY = "test-minimax-key"; - // Tavily should be preferred in autonomous mode - const result = resolveSearchProvider(); + // In auto mode (no explicit preference), tavily wins by registry order. + const result = resolveSearchProvider("auto"); assert.equal( result, "tavily", diff --git a/tsconfig.resources.json b/tsconfig.resources.json index 93f103ae4..1ad999342 100644 --- a/tsconfig.resources.json +++ b/tsconfig.resources.json @@ -8,7 +8,9 @@ "declaration": false, "declarationMap": false, "sourceMap": false, - "tsBuildInfoFile": "dist/.tsbuildinfo/tsconfig.resources.tsbuildinfo" + "tsBuildInfoFile": "dist/.tsbuildinfo/tsconfig.resources.tsbuildinfo", + "allowJs": true, + "checkJs": false }, "include": ["src/resources/**/*.ts"], "exclude": [