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(
|
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.
|
* 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";
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
function isExtensionFile(name: string): boolean {
|
function isExtensionFile(name: string): boolean {
|
||||||
|
|
@ -97,6 +97,10 @@ export function discoverExtensionEntryPaths(extensionsDir: string): string[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
const discovered: 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 })) {
|
for (const entry of readdirSync(extensionsDir, { withFileTypes: true })) {
|
||||||
const entryPath = join(extensionsDir, entry.name);
|
const entryPath = join(extensionsDir, entry.name);
|
||||||
|
|
||||||
|
|
@ -104,14 +108,34 @@ export function discoverExtensionEntryPaths(extensionsDir: string): string[] {
|
||||||
(entry.isFile() || entry.isSymbolicLink()) &&
|
(entry.isFile() || entry.isSymbolicLink()) &&
|
||||||
isExtensionFile(entry.name)
|
isExtensionFile(entry.name)
|
||||||
) {
|
) {
|
||||||
|
const real = tryRealpath(entryPath);
|
||||||
|
if (!seenRealPaths.has(real)) {
|
||||||
|
seenRealPaths.add(real);
|
||||||
discovered.push(entryPath);
|
discovered.push(entryPath);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
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;
|
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 —
|
* before_provider_request delegates to the webSearchMiddleware singleton so that tests
|
||||||
* nothing is registered here for that event.
|
* 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) {
|
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
|
// Register the PREFERENCES.md-aware resolver so the native middleware (shared
|
||||||
// singleton in web-search-middleware.ts) respects search_provider overrides.
|
// singleton in web-search-middleware.ts) respects search_provider overrides.
|
||||||
// Called here so each test invocation resets the resolver to the current context.
|
// 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) => {
|
pi.on("session_start", async (_event, _ctx) => {
|
||||||
// Reset the shared middleware session budget (#1309).
|
// Reset the shared middleware session budget (#1309).
|
||||||
webSearchMiddleware.resetSession();
|
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) {
|
export default function createExtension(pi) {
|
||||||
pi.registerCommand("create-extension", {
|
pi.registerCommand("create-extension", {
|
||||||
description:
|
description:
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { showInterviewRound } from "../shared/tui.js";
|
import { showInterviewRound } from "../../../shared/tui.js";
|
||||||
export default function createSlashCommand(pi) {
|
export default function createSlashCommand(pi) {
|
||||||
pi.registerCommand("create-slash-command", {
|
pi.registerCommand("create-slash-command", {
|
||||||
description:
|
description:
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import auditCommand from "./audit.js";
|
import auditCommand from "./audit.js";
|
||||||
import clearCommand from "./clear.js";
|
|
||||||
import createExtension from "./create-extension.js";
|
import createExtension from "./create-extension.js";
|
||||||
import createSlashCommand from "./create-slash-command.js";
|
import createSlashCommand from "./create-slash-command.js";
|
||||||
export default function slashCommands(pi) {
|
export default function slashCommands(pi) {
|
||||||
createSlashCommand(pi);
|
createSlashCommand(pi);
|
||||||
createExtension(pi);
|
createExtension(pi);
|
||||||
auditCommand(pi);
|
auditCommand(pi);
|
||||||
clearCommand(pi);
|
|
||||||
}
|
}
|
||||||
|
|
@ -59,10 +59,12 @@
|
||||||
"add-tests",
|
"add-tests",
|
||||||
"agent",
|
"agent",
|
||||||
"ask",
|
"ask",
|
||||||
|
"audit",
|
||||||
"autonomous",
|
"autonomous",
|
||||||
"backlog",
|
"backlog",
|
||||||
"capture",
|
"capture",
|
||||||
"chronicle",
|
"chronicle",
|
||||||
|
"clear",
|
||||||
"cleanup",
|
"cleanup",
|
||||||
"cmux",
|
"cmux",
|
||||||
"codebase",
|
"codebase",
|
||||||
|
|
@ -75,6 +77,8 @@
|
||||||
"configure-agent",
|
"configure-agent",
|
||||||
"control",
|
"control",
|
||||||
"cost",
|
"cost",
|
||||||
|
"create-extension",
|
||||||
|
"create-slash-command",
|
||||||
"debug",
|
"debug",
|
||||||
"delegate",
|
"delegate",
|
||||||
"diff",
|
"diff",
|
||||||
|
|
@ -94,6 +98,8 @@
|
||||||
"fast",
|
"fast",
|
||||||
"find",
|
"find",
|
||||||
"forensics",
|
"forensics",
|
||||||
|
"guard-status",
|
||||||
|
"guard-toggle",
|
||||||
"harness",
|
"harness",
|
||||||
"help",
|
"help",
|
||||||
"history",
|
"history",
|
||||||
|
|
@ -114,9 +120,17 @@
|
||||||
"new-milestone",
|
"new-milestone",
|
||||||
"next",
|
"next",
|
||||||
"notifications",
|
"notifications",
|
||||||
|
"notify-beep",
|
||||||
|
"notify-focus",
|
||||||
|
"notify-save-global",
|
||||||
|
"notify-say",
|
||||||
|
"notify-status",
|
||||||
|
"notify-threshold",
|
||||||
"parallel",
|
"parallel",
|
||||||
"park",
|
"park",
|
||||||
"pause",
|
"pause",
|
||||||
|
"permission",
|
||||||
|
"permission-mode",
|
||||||
"permission-profile",
|
"permission-profile",
|
||||||
"plan",
|
"plan",
|
||||||
"pr-branch",
|
"pr-branch",
|
||||||
|
|
@ -164,6 +178,7 @@
|
||||||
"unpark",
|
"unpark",
|
||||||
"uok",
|
"uok",
|
||||||
"update",
|
"update",
|
||||||
|
"usage",
|
||||||
"visualize",
|
"visualize",
|
||||||
"widget",
|
"widget",
|
||||||
"workflow",
|
"workflow",
|
||||||
|
|
@ -186,6 +201,7 @@
|
||||||
"tool_execution_end",
|
"tool_execution_end",
|
||||||
"tool_execution_start",
|
"tool_execution_start",
|
||||||
"tool_result",
|
"tool_result",
|
||||||
|
"turn_start",
|
||||||
"turn_end"
|
"turn_end"
|
||||||
],
|
],
|
||||||
"shortcuts": [
|
"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)}`,
|
`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,
|
replaceMessageTemplates,
|
||||||
SAY_MESSAGES,
|
SAY_MESSAGES,
|
||||||
speakMessage,
|
speakMessage,
|
||||||
} from "../shared/notify.js";
|
} from "../../shared/notify.js";
|
||||||
|
|
||||||
const DEFAULT_CONFIG = {
|
const DEFAULT_CONFIG = {
|
||||||
thresholdMs: 2000,
|
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",
|
"sf",
|
||||||
"bg-shell",
|
"bg-shell",
|
||||||
"browser-tools",
|
"browser-tools",
|
||||||
"subagent",
|
|
||||||
"search-the-web",
|
"search-the-web",
|
||||||
]) {
|
]) {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|
@ -326,7 +325,6 @@ test("initResources syncs extensions, agents, and skills to target dir", async (
|
||||||
assertExtensionIndexExists(fakeAgentDir, "browser-tools");
|
assertExtensionIndexExists(fakeAgentDir, "browser-tools");
|
||||||
assertExtensionIndexExists(fakeAgentDir, "search-the-web");
|
assertExtensionIndexExists(fakeAgentDir, "search-the-web");
|
||||||
assertExtensionIndexExists(fakeAgentDir, "context7");
|
assertExtensionIndexExists(fakeAgentDir, "context7");
|
||||||
assertExtensionIndexExists(fakeAgentDir, "subagent");
|
|
||||||
|
|
||||||
// Agents synced
|
// Agents synced
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import assert from "node:assert/strict";
|
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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, describe, test } from "vitest";
|
import { afterEach, describe, test } from "vitest";
|
||||||
|
|
@ -138,4 +138,49 @@ describe("discoverExtensionEntryPaths", () => {
|
||||||
"cmux should not be discovered",
|
"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 searchProviders = parseSearchProviders();
|
||||||
const knownProviders = parseKnownProviders();
|
const knownProviders = parseKnownProviders();
|
||||||
|
|
||||||
assert.ok(sfNativeTools.includes("sf_plan_milestone"));
|
assert.ok(sfNativeTools.includes("plan_milestone"));
|
||||||
assert.ok(sfNativeTools.includes("sf_task_complete"));
|
assert.ok(sfNativeTools.includes("complete_task"));
|
||||||
assert.ok(sfNativeTools.includes("sf_validate_milestone"));
|
assert.ok(sfNativeTools.includes("validate_milestone"));
|
||||||
assert.ok(!sfNativeTools.includes("capture_thought"));
|
assert.ok(!sfNativeTools.includes("capture_thought"));
|
||||||
|
|
||||||
assert.ok(extensions.includes("sf"));
|
assert.ok(extensions.includes("sf"));
|
||||||
assert.ok(extensions.includes("search-the-web"));
|
assert.ok(extensions.includes("search-the-web"));
|
||||||
assert.ok(extensions.includes("subagent"));
|
|
||||||
assert.ok(extensions.includes("guardrails"));
|
assert.ok(extensions.includes("guardrails"));
|
||||||
assert.ok(extensions.includes("sf-permissions"));
|
|
||||||
assert.ok(extensions.includes("sf-inturn-guard"));
|
|
||||||
|
|
||||||
assert.deepEqual(searchProviders, [
|
assert.deepEqual(searchProviders, [
|
||||||
"brave",
|
"brave",
|
||||||
|
|
@ -63,7 +60,7 @@ test("features inventory generator injects a rendered appendix between markers",
|
||||||
assert.match(updated, /### Search Providers/);
|
assert.match(updated, /### Search Providers/);
|
||||||
assert.match(updated, /### Known Model Providers/);
|
assert.match(updated, /### Known Model Providers/);
|
||||||
assert.match(updated, /- `search-the-web` — \[extension-manifest\.json]/);
|
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, /- `brave`/);
|
||||||
assert.match(updated, /- `xiaomi`/);
|
assert.match(updated, /- `xiaomi`/);
|
||||||
assert.ok(updated.includes(generated));
|
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.TAVILY_API_KEY = "test-tavily-key";
|
||||||
process.env.MINIMAX_API_KEY = "test-minimax-key";
|
process.env.MINIMAX_API_KEY = "test-minimax-key";
|
||||||
|
|
||||||
// Tavily should be preferred in autonomous mode
|
// In auto mode (no explicit preference), tavily wins by registry order.
|
||||||
const result = resolveSearchProvider();
|
const result = resolveSearchProvider("auto");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
result,
|
result,
|
||||||
"tavily",
|
"tavily",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@
|
||||||
"declaration": false,
|
"declaration": false,
|
||||||
"declarationMap": false,
|
"declarationMap": false,
|
||||||
"sourceMap": false,
|
"sourceMap": false,
|
||||||
"tsBuildInfoFile": "dist/.tsbuildinfo/tsconfig.resources.tsbuildinfo"
|
"tsBuildInfoFile": "dist/.tsbuildinfo/tsconfig.resources.tsbuildinfo",
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": false
|
||||||
},
|
},
|
||||||
"include": ["src/resources/**/*.ts"],
|
"include": ["src/resources/**/*.ts"],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue