feat(hearth): expose provider settings as remote slash commands

- shared registry in src/hearth/provider-commands.ts (16 settings)
- adds /thinking /effort /speed /reasoning /tier /budget
- adds /codeexec /websearch /computeruse toggles
- adds /compact /cleartools /clearthinking /pruning
- adds /sendreasoning /toolstream /seqtools
- adds /settings overview
- wired into TuiHost + daemon command paths
- writes ~/.soulforge/config.json via saveGlobalConfig

Co-Authored-By: SoulForge <[email protected]>
This commit is contained in:
PouiiroandSoulForge committed 2026-05-11 21:30:42 +02:00
1 parent 0864110c20
commit 1092f3505d
4 files changed
+612

No files matched your search

+17
View File
@@ -33,6 +33,11 @@ import { PairingRegistry } from "./pairing.js";
import { checkPeer } from "./peer-auth.js";
import { describeTool, evaluatePolicy } from "./policy.js";
import { attachFrameReader, writeFrame } from "./protocol.js";
import {
handleSettingsCommand,
SETTINGS_COMMAND_NAMES,
settingsHelpLines,
} from "./provider-commands.js";
import { installGlobalRedaction, redact } from "./redact.js";
import { ChatWorkspaceRegistry } from "./registry.js";
import { SurfaceHost } from "./surface-host.js";
@@ -751,6 +756,15 @@ export class HearthDaemon {
const ws = this.workspaces.get(surfaceId, msg.externalId);
const hasBridge = hearthBridge.getBinding(surfaceId, msg.externalId) !== null;
// Provider-settings commands — shared with TuiHost via provider-commands.ts.
// These write the global config, so they work regardless of bridge binding.
if (SETTINGS_COMMAND_NAMES.includes(cmd.name)) {
const handled = await handleSettingsCommand(cmd.name, cmd.args, (text) =>
surface.notify(msg.externalId, text),
);
if (handled) return;
}
// Bridge mode: a TUI tab is bound to this chat. /tab, /tabs, /stop,
// /mute and /unmute route through the bridge. /pair, /new, /close still
// manage daemon-side state if someone wants it.
@@ -1208,6 +1222,9 @@ export class HearthDaemon {
"\u2501\u2501 Pairing \u2501\u2501",
"/pair [CODE] pair this chat",
"/unpair revoke",
"",
...settingsHelpLines(),
"",
"/help this list",
].join("\n"),
);
+333
View File
@@ -0,0 +1,333 @@
/**
* Provider-settings slash commands for Hearth surfaces (Telegram/Discord).
*
* Mirrors a useful subset of the TUI's /provider-settings UI as one-shot
* remote commands. Writes go to the *global* config (~/.soulforge/config.json)
* via saveGlobalConfig — same path the settings UI uses — so changes apply
* to every new turn after the patch is written.
*
* The implementation is intentionally provider-agnostic: each setting is a
* SettingDef with a cycle/toggle/budget shape, identical to the React UI in
* ProviderSettings.tsx. The handler is invoked from both TuiHost and the
* daemon-side surface command switch with a small adapter (`notify`).
*/
import { loadConfig, saveGlobalConfig } from "../config/index.js";
import type {
AppConfig,
ContextManagementConfig,
EffortLevel,
PerformanceConfig,
ThinkingMode,
} from "../types/index.js";
type Notify = (text: string) => void | Promise<void>;
type SettingType = "cycle" | "toggle" | "budget";
interface SettingDef {
/** Slash command (without leading "/"). */
cmd: string;
/** Human label for /help. */
label: string;
/** Allowed values for "cycle". Booleans use on/off. Budget uses ints. */
type: SettingType;
options?: readonly string[];
/** Read current value out of an effective config. */
read: (cfg: AppConfig) => string;
/** Build a saveGlobalConfig patch for the parsed value. */
patch: (raw: string) => Partial<AppConfig>;
}
const THINKING_MODES = ["off", "disabled", "auto", "adaptive", "enabled"] as const;
const EFFORT_LEVELS = ["off", "low", "medium", "high", "xhigh", "max"] as const;
const SPEEDS = ["off", "standard", "fast"] as const;
const OAI_EFFORTS = ["off", "none", "minimal", "low", "medium", "high", "xhigh"] as const;
const SERVICE_TIERS = ["off", "auto", "default", "flex", "priority"] as const;
const PRUNING = ["none", "main", "subagents", "both"] as const;
function showBool(v: boolean | undefined, fallback = false): string {
return (v ?? fallback) ? "on" : "off";
}
function parseBool(raw: string): boolean | null {
const v = raw.trim().toLowerCase();
if (v === "on" || v === "true" || v === "1" || v === "yes") return true;
if (v === "off" || v === "false" || v === "0" || v === "no") return false;
return null;
}
/** All supported settings. Keep cmd names short — they're typed on a phone. */
export const SETTINGS: SettingDef[] = [
{
cmd: "thinking",
label: "thinking mode",
type: "cycle",
options: THINKING_MODES,
read: (c) => c.thinking?.mode ?? "off",
patch: (raw) => ({ thinking: { mode: raw as ThinkingMode } }),
},
{
cmd: "budget",
label: "thinking budget tokens",
type: "budget",
read: (c) => String(c.thinking?.budgetTokens ?? 10000),
patch: (raw) => ({
thinking: { mode: "enabled", budgetTokens: Number.parseInt(raw, 10) },
}),
},
{
cmd: "effort",
label: "reasoning effort",
type: "cycle",
options: EFFORT_LEVELS,
read: (c) => c.performance?.effort ?? "off",
patch: (raw) => ({
performance: { effort: raw as EffortLevel | "off" } as PerformanceConfig,
}),
},
{
cmd: "speed",
label: "speed (Opus 4.6)",
type: "cycle",
options: SPEEDS,
read: (c) => c.performance?.speed ?? "off",
patch: (raw) => ({
performance: { speed: raw as "off" | "standard" | "fast" } as PerformanceConfig,
}),
},
{
cmd: "reasoning",
label: "OpenAI reasoning effort",
type: "cycle",
options: OAI_EFFORTS,
read: (c) => c.performance?.openaiReasoningEffort ?? "off",
patch: (raw) => ({
performance: { openaiReasoningEffort: raw } as PerformanceConfig,
}),
},
{
cmd: "tier",
label: "OpenAI service tier",
type: "cycle",
options: SERVICE_TIERS,
read: (c) => c.performance?.serviceTier ?? "off",
patch: (raw) => ({
performance: { serviceTier: raw } as PerformanceConfig,
}),
},
{
cmd: "sendreasoning",
label: "send reasoning across turns",
type: "toggle",
read: (c) => showBool(c.performance?.sendReasoning),
patch: (raw) => ({
performance: { sendReasoning: parseBool(raw) ?? false } as PerformanceConfig,
}),
},
{
cmd: "toolstream",
label: "tool arg streaming",
type: "toggle",
read: (c) => showBool(c.performance?.toolStreaming, true),
patch: (raw) => ({
performance: { toolStreaming: parseBool(raw) ?? true } as PerformanceConfig,
}),
},
{
cmd: "seqtools",
label: "sequential tool use",
type: "toggle",
read: (c) => showBool(c.performance?.disableParallelToolUse),
patch: (raw) => ({
performance: { disableParallelToolUse: parseBool(raw) ?? false } as PerformanceConfig,
}),
},
{
cmd: "codeexec",
label: "code execution tool",
type: "toggle",
read: (c) => showBool(c.codeExecution, true),
patch: (raw) => ({ codeExecution: parseBool(raw) ?? true }),
},
{
cmd: "websearch",
label: "web search tool",
type: "toggle",
read: (c) => showBool(c.webSearch, true),
patch: (raw) => ({ webSearch: parseBool(raw) ?? true }),
},
{
cmd: "computeruse",
label: "computer use tool",
type: "toggle",
read: (c) => showBool(c.computerUse),
patch: (raw) => ({ computerUse: parseBool(raw) ?? false }),
},
{
cmd: "compact",
label: "server-side compaction",
type: "toggle",
read: (c) => showBool(c.contextManagement?.compact),
patch: (raw) => ({
contextManagement: { compact: parseBool(raw) ?? false } as ContextManagementConfig,
}),
},
{
cmd: "cleartools",
label: "clear old tool results (server)",
type: "toggle",
read: (c) => showBool(c.contextManagement?.clearToolUses),
patch: (raw) => ({
contextManagement: { clearToolUses: parseBool(raw) ?? false } as ContextManagementConfig,
}),
},
{
cmd: "clearthinking",
label: "preserve thinking blocks",
type: "toggle",
read: (c) => showBool(c.contextManagement?.clearThinking, true),
patch: (raw) => ({
contextManagement: { clearThinking: parseBool(raw) ?? true } as ContextManagementConfig,
}),
},
{
cmd: "pruning",
label: "tool-result pruning target",
type: "cycle",
options: PRUNING,
read: (c) => c.contextManagement?.pruningTarget ?? "none",
patch: (raw) => ({
contextManagement: {
pruningTarget: raw as "none" | "main" | "subagents" | "both",
} as ContextManagementConfig,
}),
},
];
/** Slash-command names this module handles, with the leading "/". */
export const SETTINGS_COMMAND_NAMES: readonly string[] = [
...SETTINGS.map((s) => `/${s.cmd}`),
"/settings",
];
function findSetting(cmdName: string): SettingDef | undefined {
const bare = cmdName.startsWith("/") ? cmdName.slice(1) : cmdName;
return SETTINGS.find((s) => s.cmd === bare);
}
/** Validate an incoming raw value against a SettingDef. Returns the canonical
* string to pass to .patch(), or an error message. */
export function validateSettingValue(
setting: SettingDef,
raw: string,
): { ok: true; value: string } | { ok: false; error: string } {
const v = raw.trim();
if (!v) return { ok: false, error: "missing value" };
if (setting.type === "toggle") {
const b = parseBool(v);
if (b === null) return { ok: false, error: `expected on|off, got "${v}"` };
return { ok: true, value: b ? "on" : "off" };
}
if (setting.type === "cycle") {
const lower = v.toLowerCase();
const match = setting.options?.find((o) => o.toLowerCase() === lower);
if (!match) {
return {
ok: false,
error: `expected ${setting.options?.join("|") ?? "(none)"} — got "${v}"`,
};
}
return { ok: true, value: match };
}
// budget — integer
const n = Number.parseInt(v, 10);
if (!Number.isFinite(n) || n < 1024) {
return { ok: false, error: "expected integer ≥ 1024" };
}
return { ok: true, value: String(n) };
}
/** Render the /settings overview — current values for every setting. */
export function renderSettingsOverview(cfg: AppConfig): string {
const lines = ["━━ Provider settings ━━"];
for (const s of SETTINGS) {
lines.push(`/${s.cmd.padEnd(15)} ${s.read(cfg).padEnd(10)} ${s.label}`);
}
lines.push("");
lines.push("set: /<cmd> <value>");
lines.push("show: /<cmd>");
return lines.join("\n");
}
/** /<cmd> with no arg → show. With arg → validate, patch global config. */
export interface HandleSettingsOptions {
/** Override config readers — used by tests. */
load?: () => AppConfig;
save?: (patch: Partial<AppConfig>) => void;
}
export async function handleSettingsCommand(
cmdName: string,
args: string[],
notify: Notify,
opts: HandleSettingsOptions = {},
): Promise<boolean> {
const load = opts.load ?? loadConfig;
const save = opts.save ?? saveGlobalConfig;
if (cmdName === "/settings") {
await notify(renderSettingsOverview(load()));
return true;
}
const setting = findSetting(cmdName);
if (!setting) return false;
const raw = args.join(" ").trim();
if (!raw) {
const current = setting.read(load());
const opts = setting.options?.join("|") ?? (setting.type === "toggle" ? "on|off" : "<int>");
await notify(`/${setting.cmd}: ${current}\noptions: ${opts}`);
return true;
}
const parsed = validateSettingValue(setting, raw);
if (!parsed.ok) {
await notify(`✗ ${parsed.error}`);
return true;
}
try {
save(setting.patch(parsed.value));
} catch (err) {
await notify(`✗ save failed: ${err instanceof Error ? err.message : String(err)}`);
return true;
}
await notify(`✓ ${setting.cmd} → ${parsed.value}`);
return true;
}
/** Lines appended to the in-chat /help output. */
export function settingsHelpLines(): string[] {
return [
"━━ Provider settings ━━",
"/settings show all current values",
"/thinking [mode] off | auto | enabled | adaptive | disabled",
"/budget [N] thinking budget tokens (≥1024)",
"/effort [level] off | low | medium | high | xhigh | max",
"/speed [mode] off | standard | fast (Opus 4.6)",
"/reasoning [level] OpenAI reasoning effort",
"/tier [tier] OpenAI service tier (flex|priority|auto…)",
"/sendreasoning on|off send reasoning across turns",
"/toolstream on|off stream tool args incrementally",
"/seqtools on|off sequential tool use",
"/codeexec on|off code execution tool",
"/websearch on|off web search tool",
"/computeruse on|off computer use tool",
"/compact on|off server-side context compaction",
"/cleartools on|off clear old tool results (server)",
"/clearthinking on|off preserve thinking blocks",
"/pruning [target] none | main | subagents | both",
];
}
+17
View File
@@ -39,6 +39,11 @@ import {
} from "./config.js";
import { generatePairingCode, PairingRegistry } from "./pairing.js";
import { socketRequest } from "./protocol.js";
import {
handleSettingsCommand,
SETTINGS_COMMAND_NAMES,
settingsHelpLines,
} from "./provider-commands.js";
import { redact } from "./redact.js";
import { SurfaceHost } from "./surface-host.js";
import {
@@ -236,6 +241,15 @@ export class TuiHost {
const surface = this.host.getSurface(surfaceId);
if (!surface) return;
// Provider-settings commands — handled uniformly out of the switch so
// tui-host and daemon share the same logic. Returns true when consumed.
if (SETTINGS_COMMAND_NAMES.includes(cmd.name)) {
const handled = await handleSettingsCommand(cmd.name, cmd.args, (text) =>
surface.notify(msg.externalId, text),
);
if (handled) return;
}
switch (cmd.name) {
case "/tabs":
case "/list": {
@@ -856,6 +870,9 @@ export class TuiHost {
"\u2501\u2501 Pairing \u2501\u2501",
"/pair [CODE] pair this chat \u00b7 redeem a code",
"/unpair revoke pairing",
"",
...settingsHelpLines(),
"",
"/help this list",
].join("\n"),
);
+245
View File
@@ -0,0 +1,245 @@
/**
* Tests for the /thinking, /effort, /websearch, etc. settings commands that
* Hearth surfaces (Telegram/Discord) expose remotely. We mock loadConfig +
* saveGlobalConfig so the tests never touch ~/.soulforge.
*/
import { beforeEach, describe, expect, test } from "bun:test";
import {
handleSettingsCommand,
renderSettingsOverview,
SETTINGS,
SETTINGS_COMMAND_NAMES,
settingsHelpLines,
validateSettingValue,
} from "../src/hearth/provider-commands.js";
import type { AppConfig } from "../src/types/index.js";
const EMPTY_CFG: AppConfig = {
defaultModel: "none",
routerRules: [],
editor: { command: "nvim", args: [] },
theme: { name: "dark", transparent: true },
nvimConfig: "default",
editorIntegration: {
diagnostics: true,
symbols: true,
hover: true,
references: true,
definition: true,
codeActions: true,
editorContext: true,
rename: true,
lspStatus: true,
format: true,
syncEditorOnEdit: false,
},
codeExecution: true,
webSearch: true,
};
interface Captured {
patches: Partial<AppConfig>[];
notes: string[];
}
function makeHarness(initial: AppConfig = EMPTY_CFG): {
notify: (text: string) => void;
load: () => AppConfig;
save: (patch: Partial<AppConfig>) => void;
captured: Captured;
} {
const captured: Captured = { patches: [], notes: [] };
return {
notify: (text) => captured.notes.push(text),
load: () => initial,
save: (patch) => captured.patches.push(patch),
captured,
};
}
describe("provider-commands — registry", () => {
test("every setting has a /<cmd> entry in SETTINGS_COMMAND_NAMES", () => {
for (const s of SETTINGS) {
expect(SETTINGS_COMMAND_NAMES).toContain(`/${s.cmd}`);
}
expect(SETTINGS_COMMAND_NAMES).toContain("/settings");
});
test("settingsHelpLines has a line per setting", () => {
const help = settingsHelpLines().join("\n");
for (const s of SETTINGS) {
expect(help).toContain(`/${s.cmd}`);
}
});
test("no duplicate cmd names", () => {
const names = SETTINGS.map((s) => s.cmd);
expect(new Set(names).size).toBe(names.length);
});
});
describe("provider-commands — validateSettingValue", () => {
const thinking = SETTINGS.find((s) => s.cmd === "thinking");
const budget = SETTINGS.find((s) => s.cmd === "budget");
const codeexec = SETTINGS.find((s) => s.cmd === "codeexec");
test("cycle accepts known option (case-insensitive)", () => {
expect(thinking).toBeDefined();
if (!thinking) return;
const r = validateSettingValue(thinking, "ENABLED");
expect(r.ok).toBe(true);
if (r.ok) expect(r.value).toBe("enabled");
});
test("cycle rejects unknown option", () => {
if (!thinking) return;
const r = validateSettingValue(thinking, "nope");
expect(r.ok).toBe(false);
});
test("toggle accepts on/off/true/false/1/0", () => {
if (!codeexec) return;
for (const v of ["on", "true", "1", "yes"]) {
const r = validateSettingValue(codeexec, v);
expect(r.ok).toBe(true);
if (r.ok) expect(r.value).toBe("on");
}
for (const v of ["off", "false", "0", "no"]) {
const r = validateSettingValue(codeexec, v);
expect(r.ok).toBe(true);
if (r.ok) expect(r.value).toBe("off");
}
});
test("toggle rejects nonsense", () => {
if (!codeexec) return;
const r = validateSettingValue(codeexec, "maybe");
expect(r.ok).toBe(false);
});
test("budget requires integer ≥ 1024", () => {
if (!budget) return;
expect(validateSettingValue(budget, "2048").ok).toBe(true);
expect(validateSettingValue(budget, "500").ok).toBe(false);
expect(validateSettingValue(budget, "abc").ok).toBe(false);
});
test("empty raw fails", () => {
if (!thinking) return;
expect(validateSettingValue(thinking, " ").ok).toBe(false);
});
});
describe("provider-commands — handleSettingsCommand", () => {
let h: ReturnType<typeof makeHarness>;
beforeEach(() => {
h = makeHarness();
});
test("unknown command returns false (not handled)", async () => {
const handled = await handleSettingsCommand("/bogus", [], h.notify, {
load: h.load,
save: h.save,
});
expect(handled).toBe(false);
expect(h.captured.patches).toEqual([]);
});
test("/settings prints overview", async () => {
const handled = await handleSettingsCommand("/settings", [], h.notify, {
load: h.load,
save: h.save,
});
expect(handled).toBe(true);
expect(h.captured.notes[0]).toContain("Provider settings");
expect(h.captured.patches).toEqual([]);
});
test("/thinking with no args shows current + options", async () => {
await handleSettingsCommand("/thinking", [], h.notify, { load: h.load, save: h.save });
expect(h.captured.notes[0]).toContain("/thinking");
expect(h.captured.notes[0]).toContain("off"); // default
expect(h.captured.notes[0]).toContain("enabled"); // option in help
expect(h.captured.patches).toEqual([]);
});
test("/thinking enabled writes thinking.mode patch", async () => {
await handleSettingsCommand("/thinking", ["enabled"], h.notify, {
load: h.load,
save: h.save,
});
expect(h.captured.patches.length).toBe(1);
expect(h.captured.patches[0]).toEqual({ thinking: { mode: "enabled" } });
expect(h.captured.notes[0]).toContain("✓");
});
test("/effort high writes performance.effort patch", async () => {
await handleSettingsCommand("/effort", ["high"], h.notify, { load: h.load, save: h.save });
expect(h.captured.patches[0]).toEqual({ performance: { effort: "high" } });
});
test("/budget 4096 writes thinking patch with mode=enabled", async () => {
await handleSettingsCommand("/budget", ["4096"], h.notify, { load: h.load, save: h.save });
expect(h.captured.patches[0]).toEqual({
thinking: { mode: "enabled", budgetTokens: 4096 },
});
});
test("/codeexec off writes top-level codeExecution=false", async () => {
await handleSettingsCommand("/codeexec", ["off"], h.notify, { load: h.load, save: h.save });
expect(h.captured.patches[0]).toEqual({ codeExecution: false });
});
test("/websearch on writes top-level webSearch=true", async () => {
await handleSettingsCommand("/websearch", ["on"], h.notify, { load: h.load, save: h.save });
expect(h.captured.patches[0]).toEqual({ webSearch: true });
});
test("/compact on writes contextManagement.compact=true", async () => {
await handleSettingsCommand("/compact", ["on"], h.notify, { load: h.load, save: h.save });
expect(h.captured.patches[0]).toEqual({ contextManagement: { compact: true } });
});
test("/pruning subagents writes contextManagement.pruningTarget", async () => {
await handleSettingsCommand("/pruning", ["subagents"], h.notify, {
load: h.load,
save: h.save,
});
expect(h.captured.patches[0]).toEqual({ contextManagement: { pruningTarget: "subagents" } });
});
test("invalid value reports error, no save", async () => {
await handleSettingsCommand("/thinking", ["nope"], h.notify, { load: h.load, save: h.save });
expect(h.captured.patches).toEqual([]);
expect(h.captured.notes[0]).toContain("✗");
});
test("save throwing is reported, not propagated", async () => {
const throwSave = () => {
throw new Error("disk full");
};
await handleSettingsCommand("/effort", ["low"], h.notify, {
load: h.load,
save: throwSave,
});
expect(h.captured.notes[0]).toContain("✗");
expect(h.captured.notes[0]).toContain("disk full");
});
});
describe("provider-commands — renderSettingsOverview", () => {
test("includes every cmd and reads current values", () => {
const cfg: AppConfig = {
...EMPTY_CFG,
thinking: { mode: "enabled", budgetTokens: 8192 },
performance: { effort: "high", speed: "fast" },
webSearch: false,
};
const out = renderSettingsOverview(cfg);
for (const s of SETTINGS) expect(out).toContain(`/${s.cmd}`);
expect(out).toContain("enabled"); // thinking.mode
expect(out).toContain("8192"); // budgetTokens
expect(out).toContain("high"); // effort
expect(out).toContain("fast"); // speed
});
});