fix: consolidate extensions into sf, migrate kernel.ts, fix test suite
- Fold sf-usage-bar, sf-notify, sf-inturn-guard, sf-permissions, slash-commands into sf extension (ui/, notifications/, guards/, permissions/, commands/legacy/) - Delete vectordrive extension - Migrate uok/kernel.js to TypeScript (kernel.ts) with full interfaces - Add allowJs/checkJs:false to tsconfig.resources.json for incremental TS migration - Add symlink dedup to extension-discovery.ts (seenRealPaths Set) - Add before_provider_request delegate back to native-search.js so session budget tests exercise the middleware end-to-end - Fix parseSfNativeTools() to return all SF manifest tools (drop sf_ filter) - Fix test assertions: plan_milestone/complete_task/validate_milestone - Remove subagent from app-smoke.test.ts (folded into sf/subagent/) - Remove sf-permissions/sf-inturn-guard/subagent from features-inventory test - Fix resolveSearchProvider autonomous mode test to pass 'auto' explicitly - Remove legacy /clear slash command (conflicts with built-in clear_terminal) - Update web-command-parity-contract.test.ts for clear removal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
24592507c3
commit
adb449d642
51 changed files with 786 additions and 3474 deletions
Binary file not shown.
Binary file not shown.
BIN
.sf/metrics.db
BIN
.sf/metrics.db
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -85,7 +85,7 @@ export function parseSfNativeTools() {
|
|||
);
|
||||
}
|
||||
return uniqueSorted(
|
||||
tools.filter((tool) => typeof tool === "string" && tool.startsWith("sf_")),
|
||||
tools.filter((tool) => typeof tool === "string"),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
|
||||
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)
|
||||
) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 <emoji|description>", "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);
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
|
@ -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, "..."))];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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/<name>/.",
|
||||
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 <sessionId> 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 <description> 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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
@ -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:
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
replaceMessageTemplates,
|
||||
SAY_MESSAGES,
|
||||
speakMessage,
|
||||
} from "../shared/notify.js";
|
||||
} from "../../shared/notify.js";
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
thresholdMs: 2000,
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
321
src/resources/extensions/sf/uok/kernel.ts
Normal file
321
src/resources/extensions/sf/uok/kernel.ts
Normal file
|
|
@ -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<string, unknown> } | 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<string, unknown>,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function refreshParityReport(basePath: string): ReturnType<typeof writeParityReport> | 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<typeof writeParityReport> | 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<void> {
|
||||
const { ctx, pi, s, deps, runKernelLoop } = args;
|
||||
const prefs = deps.loadEffectiveSFPreferences?.()?.preferences as Record<string, unknown> | 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<string, unknown> | 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue