remove: SF voice IVR / ElevenLabs paging — migrated to centralcloud

Per operator-direction 2026-05-17 (R089 — Migrate Voice IVR / ElevenLabs
On-Call Paging Infrastructure out of SF). Migration target landed in
centralcloud monorepo:
  - centralcloud_core/lib/centralcloud_core/voice.ex (TwiML + ElevenLabs)
  - centralcloud_staff/lib/.../controllers/voice_controller.ex (Phoenix)
  - centralcloud_staff/lib/.../controllers/voice_prompt_controller.ex
  - centralcloud_staff/lib/.../router.ex (/twilio scope)

SF removal:
  - web/app/api/voice/route.ts
  - web/app/api/voice/prompt/route.ts
  - web/app/api/voice/ directory
  - src/tests/integration/web-voice-ivr-contract.test.ts

Operator-paging infra was historical drift in SF (per-project compiler);
belongs in centralcloud (org-level ops). R088 (Pre-Removal Test-Import
Safety Gate) not yet built — operator manually verified safety scan:
TWILIO_/ELEVENLABS_ env vars only referenced in the deleted files; no
internal SF callers; centralcloud version verified present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-17 17:42:16 +02:00
parent ffdec0feee
commit 623af869b1
23 changed files with 401 additions and 688 deletions

View file

@ -23,7 +23,9 @@ One command. Walk away. Come back to a built project with clean git history.
<pre><code>npm install -g singularity-forge@latest</code></pre>
> SF now provisions a managed [RTK](https://github.com/rtk-ai/rtk) binary on supported macOS, Linux, and Windows installs to compress shell-command output in `bash`, `async_bash`, `bg_shell`, and verification flows. SF forces `RTK_TELEMETRY_DISABLED=1` for all managed invocations. Set `SF_RTK_DISABLED=1` to disable the integration.
> **Supported platforms: Linux x86-64 / Linux arm64.** macOS support removed 2026-05-17 per operator direction.
> SF now provisions a managed [RTK](https://github.com/rtk-ai/rtk) binary on supported Linux and Windows installs to compress shell-command output in `bash`, `async_bash`, `bg_shell`, and verification flows. SF forces `RTK_TELEMETRY_DISABLED=1` for all managed invocations. Set `SF_RTK_DISABLED=1` to disable the integration.
> **Node runtime:** SF targets Node.js 26.1+. Use the repo `.mise.toml`, `.node-version`, or `.nvmrc` pins when developing from source.
@ -416,14 +418,14 @@ On first run, SF launches a branded setup wizard that walks you through LLM prov
| `/sf logs` | Browse activity, debug, and metrics logs |
| `/sf export --html` | Generate HTML report for current or completed milestone |
| `/worktree` (`/wt`) | Git worktree lifecycle — create, switch, merge, remove |
| `/voice` | Toggle real-time speech-to-text (macOS, Linux) |
| `/voice` | Toggle real-time speech-to-text (Linux) |
| `/exit` | Graceful shutdown — saves session state before exiting |
| `/kill` | Kill SF process immediately |
| `/clear` | Start a new session (alias for `/new`) |
| `Ctrl+Alt+G` | Toggle dashboard overlay |
| `Ctrl+Alt+V` | Toggle voice transcription |
| `Ctrl+Alt+B` | Show background shell processes |
| `Alt+V` | Paste clipboard image (macOS) |
| `Alt+V` | Paste clipboard image |
| `sf config` | Re-run the setup wizard (LLM provider + tool keys) |
| `sf update` | Update SF to the latest version |
| `sf headless [cmd]` | Machine surface for `/sf` commands (CI, cron, scripts) |
@ -610,9 +612,8 @@ SF ships with 24 extensions, all loaded automatically:
| **Async Jobs** | Background bash commands with job tracking and cancellation |
| **Subagent** | Delegated tasks with isolated context windows |
| **GitHub** | Full-suite GitHub issues and PR management via `/gh` command |
| **Mac Tools** | macOS native app automation via Accessibility APIs |
| **MCP Client** | Client-side connections to external MCP tool servers via @modelcontextprotocol/sdk; SF does not expose its workflow as MCP |
| **Voice** | Real-time speech-to-text transcription (macOS, Linux — Ubuntu 22.04+) |
| **Voice** | Real-time speech-to-text transcription (Linux — Ubuntu 22.04+) |
| **Slash Commands** | Custom command creation |
| **Ask User Questions** | Structured user input with single/multi-select |
| **Secure Env Collect** | Masked secret collection without manual .env editing |

View file

@ -6,13 +6,11 @@
* Request retry/fallback stays in the caller so SF can move to the next model.
*/
import { retryWithBackoff } from "@google/gemini-cli-core";
import type {
Content,
GenerateContentParameters,
ThinkingConfig,
} from "@google/genai";
import { createGeminiCliContentGenerator } from "@singularity-forge/google-gemini-cli-provider";
import { resolveWireModelId } from "../model-identity.js";
import { calculateCost } from "../models.js";
import type {
@ -189,6 +187,11 @@ export const streamGoogleGeminiCli: StreamFunction<
if (nextReq !== undefined) {
req = nextReq as GenerateContentParameters;
}
const [{ retryWithBackoff }, { createGeminiCliContentGenerator }] =
await Promise.all([
import("@google/gemini-cli-core"),
import("@singularity-forge/google-gemini-cli-provider"),
]);
// cli-core handles auth + project discovery through the helper package.
const server = await createGeminiCliContentGenerator({
modelId: req.model,

View file

@ -13,15 +13,11 @@ import type {
} from "../../../core/keybindings.js";
import { theme } from "../theme/theme.js";
const isMac = process.platform === "darwin";
/**
* Convert a key identifier to a platform-appropriate display string.
* On macOS, "alt+" is shown as "⌥" (Option key symbol).
* Convert a key identifier to a display string.
*/
export function formatKeyForDisplay(key: string): string {
if (!isMac) return key;
return key.replace(/\balt\+/gi, "⌥");
return key;
}
/**

View file

@ -167,10 +167,7 @@ export class LoginDialogComponent extends Container implements Focusable {
const urlLink = `\x1b]8;;${url}\x07${theme.fg("accent", displayUrl)}\x1b]8;;\x07`;
this.contentContainer.addChild(new Text(urlLink, 1, 0));
const clickHint =
process.platform === "darwin"
? "Cmd+click to open"
: "Ctrl+click to open";
const clickHint = "Ctrl+click to open";
this.contentContainer.addChild(new Text(theme.fg("dim", clickHint), 1, 0));
if (fullUrlLines.length > 0) {
@ -198,8 +195,7 @@ export class LoginDialogComponent extends Container implements Focusable {
() => {},
);
} else {
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
execFile(openCmd, [url], () => {});
execFile("xdg-open", [url], () => {});
}
this.tui.requestRender();

View file

@ -208,7 +208,7 @@ export class ScopedModelsSelectorComponent
"^A all",
"^X clear",
"^P provider",
`${process.platform === "darwin" ? "⌥↑↓" : "Alt+↑↓"} reorder`,
"Alt+↑↓ reorder",
"^S save",
countText,
];

View file

@ -290,7 +290,7 @@ export class SettingsSelectorComponent extends Container {
{
id: "follow-up-mode",
label: "Follow-up mode",
description: `${process.platform === "darwin" ? "⌥Enter" : "Alt+Enter"} queues follow-up messages until agent stops. 'one-at-a-time': deliver one, wait for response. 'all': deliver all at once.`,
description: `Alt+Enter queues follow-up messages until agent stops. 'one-at-a-time': deliver one, wait for response. 'all': deliver all at once.`,
currentValue: config.followUpMode,
values: ["one-at-a-time", "all"],
},

View file

@ -1359,7 +1359,7 @@ export class TreeSelectorComponent extends Container implements Focusable {
new TruncatedText(
theme.fg(
"muted",
` ↑/↓: move. ←/→: page. ^←/^→ or ${process.platform === "darwin" ? "⌥←/⌥→" : "Alt+←/Alt+→"}: fold/branch. Shift+L: label. `,
` ↑/↓: move. ←/→: page. ^←/^→ or Alt+←/Alt+→: fold/branch. Shift+L: label. `,
) + theme.fg("muted", "^D/^T/^U/^L/^A: filters (^O/⇧^O cycle)"),
0,
0,

View file

@ -179,8 +179,7 @@ function openBrowser(url: string): void {
() => {},
);
} else {
const cmd = process.platform === "darwin" ? "open" : "xdg-open";
execFile(cmd, [url], () => {});
execFile("xdg-open", [url], () => {});
}
}

View file

@ -10,6 +10,7 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { compareToLastGreen, getLastGreenEntry } from "../last-green.js";
import { maybeRollbackCrashLoop } from "../safety/autonomous-rollback.js";
export const CRASH_LOOP_WINDOW_MS = 90_000;
export const CRASH_LOOP_THRESHOLD = 3;
@ -105,11 +106,17 @@ export const crashLoopGate = {
ctx.options,
);
if (result.stuck) {
const rollback =
ctx.autoRollback === true || ctx.options?.autoRollback === true
? maybeRollbackCrashLoop(ctx.basePath ?? ctx.cwd, result, ctx.options)
: null;
return {
outcome: "fail",
failureClass: "verification",
rationale: result.reason,
findings: result.signature,
findings: rollback
? { ...result.signature, rollback }
: result.signature,
};
}
return {

View file

@ -135,6 +135,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set([
"workspace",
"subscription",
"allow_flat_rate_providers",
"deploy",
"planning_depth",
"min_request_interval_ms",
"context_compact_at",

View file

@ -0,0 +1,180 @@
/**
* autonomous-rollback.js revert autonomous commits after regression quarantine.
*
* Purpose: give M048/S04 a bounded rollback path for crash-loop regressions
* without letting generic detectors mutate git state by accident.
*
* Consumer: crash-loop quarantine handling and future daemon supervisor policy.
*/
import { execFileSync } from "node:child_process";
import { recordSelfFeedback } from "../self-feedback.js";
export const AUTONOMOUS_COMMIT_PATTERNS = [
/sf-snapshot-/i,
/autonomous-/i,
/SF-Task:/i,
/singularity-forge autonomous/i,
];
/**
* Find the newest commit that looks autonomous by subject, body, or author.
*
* Purpose: keep rollback scoped to SF-authored changes rather than reverting
* arbitrary operator commits after a detector fires.
*
* Consumer: revertAutonomousRegression().
*/
export function findLastAutonomousCommit(basePath, options = {}) {
const maxCount = options.maxCount ?? 30;
const runGit = options.runGit ?? defaultRunGit;
const output = runGit(basePath, [
"log",
`-${maxCount}`,
"--format=%H%x00%an%x00%ae%x00%s%x00%b%x1e",
]);
return (
parseGitLog(output).find((commit) => isAutonomousCommit(commit)) ?? null
);
}
/**
* Revert the newest autonomous commit and record rollback self-feedback.
*
* Purpose: close the M048/S04 loop from crash-loop quarantine to actionable
* rollback evidence while leaving non-autonomous commits untouched.
*
* Consumer: daemon/server supervisor when crashLoopGate reports quarantine.
*/
export function revertAutonomousRegression(
basePath,
signature = {},
options = {},
) {
const runGit = options.runGit ?? defaultRunGit;
const record =
options.recordSelfFeedback ??
((entry) => recordSelfFeedback(entry, basePath));
const commit =
options.commit ??
findLastAutonomousCommit(basePath, { ...options, runGit });
if (!commit) {
const result = {
ok: false,
reverted: false,
reason: "no-autonomous-commit",
signature,
};
recordRollbackFeedback(record, result, commit);
return result;
}
try {
runGit(basePath, ["revert", "--no-edit", commit.sha]);
const result = {
ok: true,
reverted: true,
reason: "reverted-autonomous-commit",
commit,
signature,
};
recordRollbackFeedback(record, result, commit);
return result;
} catch (error) {
const result = {
ok: false,
reverted: false,
reason: "git-revert-failed",
error: error instanceof Error ? error.message : String(error),
commit,
signature,
};
recordRollbackFeedback(record, result, commit);
return result;
}
}
/**
* Apply rollback when a crash-loop detector result is quarantined.
*
* Purpose: keep gate/run-control callers on one small API: pass the detector
* result, get either no-op or rollback outcome.
*
* Consumer: crashLoopGate and future embedded supervisor backoff policy.
*/
export function maybeRollbackCrashLoop(basePath, detectorResult, options = {}) {
if (!detectorResult?.stuck) {
return { ok: true, reverted: false, reason: "not-quarantined" };
}
return revertAutonomousRegression(
basePath,
detectorResult.signature ?? {},
options,
);
}
function defaultRunGit(basePath, args) {
return execFileSync("git", args, {
cwd: basePath,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 30_000,
});
}
function parseGitLog(output) {
return String(output ?? "")
.split("\x1e")
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => {
const [sha, authorName, authorEmail, subject, ...bodyParts] =
entry.split("\x00");
return {
sha,
authorName,
authorEmail,
subject,
body: bodyParts.join("\x00"),
};
})
.filter((commit) => /^[0-9a-f]{7,40}$/i.test(commit.sha ?? ""));
}
function isAutonomousCommit(commit) {
const haystack = [
commit.authorName,
commit.authorEmail,
commit.subject,
commit.body,
]
.filter(Boolean)
.join("\n");
return AUTONOMOUS_COMMIT_PATTERNS.some((pattern) => pattern.test(haystack));
}
function recordRollbackFeedback(record, result, commit) {
record({
kind: "runaway-loop:crash-loop-rollback",
severity: result.ok ? "medium" : "high",
summary: result.ok
? `Reverted autonomous regression commit ${commit?.sha ?? "unknown"}.`
: `Crash-loop rollback did not revert a commit: ${result.reason}.`,
evidence: {
reason: result.reason,
commit: commit
? {
sha: commit.sha,
subject: commit.subject,
authorName: commit.authorName,
authorEmail: commit.authorEmail,
}
: null,
signature: result.signature,
error: result.error ?? null,
},
suggestedFix: result.ok
? "Verify the loop resumes on the reverted parent and keep the crash signature for regression tests."
: "Inspect the crash-loop signature and revert manually if the failed commit is not machine-identifiable.",
});
}

View file

@ -41,6 +41,7 @@ const CREDIBLE_RESOLUTION_KINDS = new Set([
"agent-fix",
"human-clear",
"promoted-to-requirement",
"adversarial-finding",
]);
/**

View file

@ -0,0 +1,149 @@
/**
* autonomous-rollback.test.mjs M048/S04 rollback controller contracts.
*
* Purpose: prove crash-loop rollback only targets autonomous-looking commits
* and records operator-visible self-feedback for success and failure.
*/
import assert from "node:assert/strict";
import { test } from "vitest";
import {
findLastAutonomousCommit,
maybeRollbackCrashLoop,
revertAutonomousRegression,
} from "../safety/autonomous-rollback.js";
function gitLog(commits) {
return commits
.map((commit) =>
[
commit.sha,
commit.authorName ?? "Human",
commit.authorEmail ?? "human@example.com",
commit.subject ?? "manual change",
commit.body ?? "",
].join("\x00"),
)
.join("\x1e");
}
test("findLastAutonomousCommit_when_subject_has_sf_snapshot_returns_newest_match", () => {
const output = gitLog([
{ sha: "aaaaaaaa", subject: "manual edit" },
{ sha: "bbbbbbbb", subject: "sf-snapshot-M048 detector patch" },
]);
const commit = findLastAutonomousCommit("/repo", {
runGit: () => output,
});
assert.equal(commit.sha, "bbbbbbbb");
assert.equal(commit.subject, "sf-snapshot-M048 detector patch");
});
test("findLastAutonomousCommit_when_body_has_sf_task_returns_match", () => {
const output = gitLog([
{
sha: "cccccccc",
subject: "feat: add detector",
body: "SF-Task: M048/S04/T01",
},
]);
const commit = findLastAutonomousCommit("/repo", {
runGit: () => output,
});
assert.equal(commit.sha, "cccccccc");
});
test("findLastAutonomousCommit_when_no_autonomous_marker_returns_null", () => {
const output = gitLog([{ sha: "dddddddd", subject: "human commit" }]);
const commit = findLastAutonomousCommit("/repo", {
runGit: () => output,
});
assert.equal(commit, null);
});
test("revertAutonomousRegression_when_commit_found_runs_git_revert_and_records_feedback", () => {
const calls = [];
const feedback = [];
const result = revertAutonomousRegression(
"/repo",
{ sourceHash: "source-a" },
{
runGit: (_cwd, args) => {
calls.push(args);
if (args[0] === "log") {
return gitLog([
{ sha: "eeeeeeee", subject: "sf-snapshot-M048 bad change" },
]);
}
return "";
},
recordSelfFeedback: (entry) => feedback.push(entry),
},
);
assert.equal(result.ok, true);
assert.equal(result.reverted, true);
assert.deepEqual(calls.at(-1), ["revert", "--no-edit", "eeeeeeee"]);
assert.equal(feedback.length, 1);
assert.equal(feedback[0].kind, "runaway-loop:crash-loop-rollback");
assert.equal(feedback[0].severity, "medium");
});
test("revertAutonomousRegression_when_no_commit_records_blocking_feedback", () => {
const feedback = [];
const result = revertAutonomousRegression(
"/repo",
{ sourceHash: "source-a" },
{
runGit: () => gitLog([{ sha: "ffffffff", subject: "manual commit" }]),
recordSelfFeedback: (entry) => feedback.push(entry),
},
);
assert.equal(result.ok, false);
assert.equal(result.reason, "no-autonomous-commit");
assert.equal(feedback[0].severity, "high");
});
test("revertAutonomousRegression_when_git_revert_fails_records_failure", () => {
const feedback = [];
const result = revertAutonomousRegression(
"/repo",
{ sourceHash: "source-a" },
{
runGit: (_cwd, args) => {
if (args[0] === "log") {
return gitLog([
{ sha: "11111111", subject: "autonomous-M048 bad change" },
]);
}
throw new Error("merge conflict");
},
recordSelfFeedback: (entry) => feedback.push(entry),
},
);
assert.equal(result.ok, false);
assert.equal(result.reason, "git-revert-failed");
assert.match(result.error, /merge conflict/);
assert.equal(feedback[0].severity, "high");
});
test("maybeRollbackCrashLoop_when_not_stuck_is_noop", () => {
const result = maybeRollbackCrashLoop(
"/repo",
{ stuck: false, signature: {} },
{
runGit: () => {
throw new Error("should not run");
},
},
);
assert.equal(result.ok, true);
assert.equal(result.reverted, false);
assert.equal(result.reason, "not-quarantined");
});

View file

@ -151,6 +151,13 @@ describe("selectInlineFixCandidates", () => {
requirementId: "R1",
},
}),
entry({
id: "adversarial-reviewed",
kind: "adversarial-finding",
resolvedAt: "2026-05-10T00:00:00Z",
resolvedEvidence: { kind: "adversarial-finding" },
resolvedReason: "challenge review accepted as the closing evidence",
}),
]);
expect(selectInlineFixCandidates(dir)).toEqual([]);
});

View file

@ -11,7 +11,6 @@ import { execSync, spawnSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { snapshotGeminiCliAccount } from "@singularity-forge/google-gemini-cli-provider";
import { visibleWidth } from "@singularity-forge/tui";
import { sfHome } from "../sf-home.js";
@ -209,6 +208,9 @@ async function fetchGeminiUsage(_modelRegistry) {
};
}
try {
const { snapshotGeminiCliAccount } = await import(
"@singularity-forge/google-gemini-cli-provider"
);
const snapshot = await snapshotGeminiCliAccount();
if (!snapshot) {
return {

View file

@ -1,13 +1,10 @@
/**
* Shared notification utilities for bundled extensions
*
* Provides cross-platform beep, speech, and bring-to-front helpers.
* macOS features are fully supported; Linux/others gracefully degrade.
* Provides beep and notification helpers for Linux.
* macOS support removed 2026-05-17 per operator direction.
*/
import * as child_process from "node:child_process";
import * as fsPromises from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { promisify } from "node:util";
const execAsync = promisify(child_process.exec);
@ -40,123 +37,35 @@ export const SAY_MESSAGES = [
"All done in {dirname}",
"{session dir} needs your attention",
];
const TERMINAL_BUNDLE_IDS = {
"com.googlecode.iterm2": "iTerm2",
"iTerm.app": "iTerm2",
};
// ─────────────────────────────────────────────────────────────────────────────
// Platform Detection
// Platform Detection (stubs — Linux only)
// ─────────────────────────────────────────────────────────────────────────────
export function isMacOS() {
return process.platform === "darwin";
return false;
}
let hasSayCommand = false;
export async function checkSayAvailable() {
if (!isMacOS()) {
hasSayCommand = false;
return false;
}
try {
await execAsync("which say");
hasSayCommand = true;
return true;
} catch {
hasSayCommand = false;
return false;
}
return false;
}
export function isSayAvailable() {
return hasSayCommand;
return false;
}
// ─────────────────────────────────────────────────────────────────────────────
// Terminal Detection
// Terminal Detection (Linux: always returns empty info)
// ─────────────────────────────────────────────────────────────────────────────
export async function detectTerminalInfo() {
const info = {};
if (!isMacOS()) return info;
try {
info.terminalPid = process.ppid;
info.terminalApp = process.env.TERM_PROGRAM;
if (!info.terminalTTY) {
try {
const { stdout } = await execAsync(`ps -p ${process.ppid} -o tty=`);
const tty = stdout.trim();
if (tty && tty !== "??") {
info.terminalTTY = tty.startsWith("/dev/") ? tty : "/dev/" + tty;
}
} catch {}
}
if (!info.terminalTTY && info.terminalPid) {
try {
const { stdout } = await execAsync(
`lsof -p ${info.terminalPid} 2>/dev/null | grep -m1 "/dev/ttys" | awk '{print $9}'`,
);
const tty = stdout.trim();
if (tty?.startsWith("/dev/")) info.terminalTTY = tty;
} catch {}
}
if (!info.terminalApp) {
try {
const { stdout } = await execAsync(
`lsappinfo info -only bundleID ${info.terminalPid}`,
);
const match = stdout.match(/"CFBundleIdentifier"="([^"]+)"/);
if (match) info.terminalApp = match[1];
} catch {
info.terminalApp = "com.googlecode.iterm2";
}
}
} catch {}
return info;
return {};
}
export async function isTerminalInBackground(info) {
if (!isMacOS()) return false;
try {
const { stdout } = await execAsync(
"lsappinfo front | awk '{print $1}' | xargs -I {} lsappinfo info -only bundleID {}",
);
const match = stdout.match(/"CFBundleIdentifier"="([^"]+)"/);
if (!match) return false;
const frontBundleId = match[1];
if (info.terminalApp && !frontBundleId.includes(info.terminalApp))
return true;
const knownTerminals = Object.keys(TERMINAL_BUNDLE_IDS).filter((k) =>
k.includes("."),
);
return !knownTerminals.some((id) => frontBundleId.includes(id));
} catch {
return false;
}
export async function isTerminalInBackground(_info) {
return false;
}
// ─────────────────────────────────────────────────────────────────────────────
// terminal-notifier
// terminal-notifier (macOS-only — always unavailable)
// ─────────────────────────────────────────────────────────────────────────────
let terminalNotifierAvailable = false;
let terminalNotifierChecked = false;
const TERMINAL_NOTIFIER_PATHS = [
"/Applications/terminal-notifier.app/Contents/MacOS/terminal-notifier",
"/usr/local/bin/terminal-notifier",
"/opt/homebrew/bin/terminal-notifier",
];
export async function checkTerminalNotifierAvailable() {
if (!isMacOS() || terminalNotifierChecked) return terminalNotifierAvailable;
try {
await execAsync("which terminal-notifier");
for (const p of TERMINAL_NOTIFIER_PATHS) {
try {
await execAsync(`test -f "${p}"`);
terminalNotifierAvailable = true;
break;
} catch {}
}
} catch {
terminalNotifierAvailable = false;
}
terminalNotifierChecked = true;
return terminalNotifierAvailable;
return false;
}
export function isTerminalNotifierAvailable() {
return terminalNotifierAvailable;
return false;
}
// ─────────────────────────────────────────────────────────────────────────────
// Message Helpers
@ -177,15 +86,8 @@ export function replaceMessageTemplates(message) {
// ─────────────────────────────────────────────────────────────────────────────
// Notification Actions
// ─────────────────────────────────────────────────────────────────────────────
export function playBeep(soundName = "Tink") {
if (isMacOS()) {
child_process
.spawn("afplay", [`/System/Library/Sounds/${soundName}.aiff`], {
detached: true,
stdio: "ignore",
})
.unref();
} else if (process.platform === "linux") {
export function playBeep(_soundName = "Tink") {
if (process.platform === "linux") {
try {
child_process
.spawn("paplay", ["/usr/share/sounds/freedesktop/stereo/bell.oga"], {
@ -201,92 +103,25 @@ export function playBeep(soundName = "Tink") {
}
}
export function displayOSXNotification(message, soundName, _terminalInfo) {
if (!isMacOS()) {
if (soundName) playBeep(soundName);
return;
}
const finalMessage = replaceMessageTemplates(message);
const terminalBundleId = "com.googlecode.iterm2";
if (terminalNotifierAvailable) {
const args = [
"-message",
finalMessage,
"-title",
"SF Notify",
"-activate",
terminalBundleId,
];
if (soundName) args.push("-sound", soundName);
child_process
.spawn("terminal-notifier", args, { detached: true, stdio: "ignore" })
.unref();
return;
}
const escapedMessage = finalMessage
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"');
const terminalAppName = "iTerm2";
let script = `tell application "${terminalAppName}" to display notification "${escapedMessage}" with title "SF Notify"`;
if (soundName) script += ` sound name "${soundName}"`;
child_process
.spawn("osascript", ["-e", script], { detached: true, stdio: "ignore" })
.unref();
// macOS-specific notification removed. Degrade to beep only.
if (soundName) playBeep(soundName);
}
export function speakMessage(message) {
if (!isSayAvailable()) return;
const finalMessage = replaceMessageTemplates(message).replace(/"/g, '\\"');
child_process
.spawn("say", ["-v", "Daniel", finalMessage], {
detached: true,
stdio: "ignore",
})
.unref();
export function speakMessage(_message) {
// macOS `say` command removed. No-op on Linux.
}
export async function bringTerminalToFront(info) {
if (!isMacOS()) return;
try {
let script;
if (info.terminalTTY) {
script = `tell application "iTerm2"
repeat with w in windows
set tabIdx to 0
repeat with t in tabs of w
set tabIdx to tabIdx + 1
repeat with s in sessions of t
if tty of s is "${info.terminalTTY}" then
tell w to select tab tabIdx
activate
return
end if
end repeat
end repeat
end repeat
end tell`;
} else {
script = `tell application "iTerm2" to activate`;
}
const tmpFile = path.join(os.tmpdir(), `sf-terminal-${Date.now()}.scpt`);
try {
await fsPromises.writeFile(tmpFile, script, "utf8");
await execAsync(`osascript "${tmpFile}"`);
} finally {
try {
await fsPromises.unlink(tmpFile);
} catch {}
}
} catch {}
export async function bringTerminalToFront(_info) {
// macOS osascript removed. No-op on Linux.
}
export async function notifyOnConfirm(config, terminalInfo, options) {
const eff = {
beep: options?.beep ?? config.beep,
beepSound: options?.beepSound ?? config.beepSound,
bringToFront: options?.bringToFront ?? config.bringToFront,
say: isSayAvailable() ? (options?.say ?? config.say) : false,
say: false,
sayMessage: options?.sayMessage ?? config.sayMessage,
};
const tasks = [];
if (eff.bringToFront) tasks.push(bringTerminalToFront(terminalInfo));
if (eff.beep) playBeep(eff.beepSound);
if (eff.say) speakMessage(eff.sayMessage);
await Promise.all(tasks);
}

View file

@ -1,4 +1,4 @@
import { execFileSync, spawn } from "node:child_process";
import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import * as readline from "node:readline";
@ -17,34 +17,8 @@ import {
} from "./linux-ready.js";
const __extensionDir = import.meta.dirname;
const SWIFT_SRC = path.join(__extensionDir, "speech-recognizer.swift");
const RECOGNIZER_BIN = path.join(__extensionDir, "speech-recognizer");
const PYTHON_SCRIPT = path.join(__extensionDir, "speech-recognizer.py");
const IS_DARWIN = process.platform === "darwin";
const IS_LINUX = process.platform === "linux";
function ensureBinary() {
if (fs.existsSync(RECOGNIZER_BIN)) return true;
try {
execFileSync(
"swiftc",
[
SWIFT_SRC,
"-o",
RECOGNIZER_BIN,
"-framework",
"Speech",
"-framework",
"AVFoundation",
],
{
timeout: 60000,
},
);
return true;
} catch {
return false;
}
}
let linuxReady = false;
function ensureLinuxReady(ctx) {
if (linuxReady) return true;
@ -103,7 +77,7 @@ function ensureLinuxReady(ctx) {
return true;
}
export default function (pi) {
if (!IS_DARWIN && !IS_LINUX) return;
if (!IS_LINUX) return;
let active = false;
let recognizerProcess = null;
let flashOn = true;
@ -205,15 +179,7 @@ export default function (pi) {
setVoiceFooter(ctx, false);
return;
}
if (IS_DARWIN) {
if (!ensureBinary()) {
ctx.ui.notify(
"Voice: failed to compile speech recognizer (need Xcode CLI tools)",
"error",
);
return;
}
} else if (IS_LINUX) {
if (IS_LINUX) {
if (!ensureLinuxReady(ctx)) {
return;
}
@ -237,15 +203,9 @@ export default function (pi) {
}
}
function startRecognizer(onPartial, onFinal, onError, onReady) {
if (IS_LINUX) {
recognizerProcess = spawn(linuxPython(), [PYTHON_SCRIPT], {
stdio: ["pipe", "pipe", "pipe"],
});
} else {
recognizerProcess = spawn(RECOGNIZER_BIN, [], {
stdio: ["pipe", "pipe", "pipe"],
});
}
recognizerProcess = spawn(linuxPython(), [PYTHON_SCRIPT], {
stdio: ["pipe", "pipe", "pipe"],
});
const rl = readline.createInterface({ input: recognizerProcess.stdout });
rl.on("line", (line) => {
if (line === "READY") {

View file

@ -153,9 +153,8 @@ export function writePreseededAuthFile(tempHome: string): void {
}
function createBrowserOpenStub(binDir: string, logPath: string): void {
const command = process.platform === "darwin" ? "open" : "xdg-open";
const script = `#!/bin/sh\nprintf '%s\n' "$1" >> "${logPath}"\nexit 0\n`;
const scriptPath = join(binDir, command);
const scriptPath = join(binDir, "xdg-open");
writeFileSync(scriptPath, script, "utf-8");
chmodSync(scriptPath, 0o755);
}

View file

@ -1,143 +0,0 @@
import assert from "node:assert/strict";
import { afterEach, describe, test, vi } from "vitest";
const voiceRoute = await import("../../../web/app/api/voice/route.ts");
const voicePromptRoute = await import(
"../../../web/app/api/voice/prompt/route.ts"
);
const ENV_KEYS = [
"ELEVENLABS_API_KEY",
"ELEVENLABS_VOICE_ID",
"ELEVENLABS_MODEL_ID",
"TWILIO_IVR_AI_NUMBER",
"TWILIO_IVR_AI_NUMBERS",
"TWILIO_IVR_ONCALL_NUMBER",
"TWILIO_IVR_ONCALL_NUMBERS",
"TWILIO_ONCALL_NUMBER",
"ONCALL_NUMBER",
] as const;
const originalEnv = Object.fromEntries(
ENV_KEYS.map((key) => [key, process.env[key]]),
) as Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
function setEnv(
values: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>,
): void {
for (const key of ENV_KEYS) {
const value = values[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
afterEach(() => {
setEnv(originalEnv);
vi.unstubAllGlobals();
});
describe("voice IVR", () => {
test("voice_GET_when_oncall_number_configured_renders_menu", async () => {
setEnv({
TWILIO_IVR_ONCALL_NUMBER: "+16175551212",
});
const response = await voiceRoute.GET(
new Request("http://localhost/api/voice"),
);
const xml = await response.text();
assert.equal(
response.headers.get("content-type"),
"text/xml; charset=utf-8",
);
assert.match(xml, /<Gather input="dtmf" numDigits="1" timeout="6"/);
assert.match(xml, /Welcome to Singularity Forge\./);
assert.match(xml, /Press 1 to reach the on-call line\./);
assert.match(xml, /Press 9 to repeat this menu\./);
assert.doesNotMatch(xml, /Press 2 for the AI assistant\./);
});
test("voice_POST_when_digit_one_dials_oncall_number", async () => {
setEnv({
TWILIO_IVR_ONCALL_NUMBERS: "+16175551212,+16175551213",
});
const response = await voiceRoute.POST(
new Request("http://localhost/api/voice", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: "Digits=1",
}),
);
const xml = await response.text();
assert.match(xml, /<Dial answerOnBridge="true" timeout="20">/);
assert.match(xml, /<Number>\+16175551212<\/Number>/);
assert.match(xml, /<Number>\+16175551213<\/Number>/);
});
test("voice_GET_when_elevenlabs_configured_uses_prompt_audio", async () => {
setEnv({
ELEVENLABS_API_KEY: "test-api-key",
ELEVENLABS_VOICE_ID: "voice-123",
ELEVENLABS_MODEL_ID: "eleven_flash_v2_5",
TWILIO_IVR_ONCALL_NUMBER: "+16175551212",
});
const response = await voiceRoute.GET(
new Request("https://example.com/api/voice"),
);
const xml = await response.text();
assert.match(xml, /<Play>https:\/\/example\.com\/api\/voice\/prompt\?/);
assert.match(xml, /text=Welcome\+to\+Singularity\+Forge\./);
});
test("voice_prompt_GET_when_configured_returns_audio", async () => {
setEnv({
ELEVENLABS_API_KEY: "test-api-key",
ELEVENLABS_VOICE_ID: "voice-123",
ELEVENLABS_MODEL_ID: "eleven_flash_v2_5",
});
const fetchCalls: Array<[string, RequestInit | undefined]> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
fetchCalls.push([String(input), init]);
return new Response(new Uint8Array([1, 2, 3]), {
headers: {
"content-type": "audio/mpeg",
},
});
}),
);
const response = await voicePromptRoute.GET(
new Request(
"https://example.com/api/voice/prompt?text=Welcome%20to%20Singularity%20Forge.",
),
);
const body = new Uint8Array(await response.arrayBuffer());
assert.equal(response.status, 200);
assert.equal(response.headers.get("content-type"), "audio/mpeg");
assert.deepEqual(Array.from(body), [1, 2, 3]);
assert.equal(fetchCalls.length, 1);
assert.match(
fetchCalls[0]![0],
/https:\/\/api\.elevenlabs\.io\/v1\/text-to-speech\/voice-123\?output_format=mp3_44100_128/,
);
assert.equal(
(fetchCalls[0]![1]?.headers as Record<string, string>)["xi-api-key"],
"test-api-key",
);
});
});

View file

@ -44,14 +44,9 @@ afterEach(() => {
});
test("resolveRtkAssetName maps supported release assets correctly", () => {
assert.equal(
resolveRtkAssetName("darwin", "arm64"),
"rtk-aarch64-apple-darwin.tar.gz",
);
assert.equal(
resolveRtkAssetName("darwin", "x64"),
"rtk-x86_64-apple-darwin.tar.gz",
);
// darwin removed 2026-05-17 per operator direction — no Mac support
assert.equal(resolveRtkAssetName("darwin", "arm64"), null);
assert.equal(resolveRtkAssetName("darwin", "x64"), null);
assert.equal(
resolveRtkAssetName("linux", "arm64"),
"rtk-aarch64-unknown-linux-gnu.tar.gz",

View file

@ -35,8 +35,7 @@ function openBrowser(url: string): void {
() => {},
);
} else {
const cmd = process.platform === "darwin" ? "open" : "xdg-open";
execFile(cmd, [url], () => {});
execFile("xdg-open", [url], () => {});
}
}

View file

@ -1,92 +0,0 @@
/**
* ElevenLabs prompt synthesizer for the SF Twilio IVR.
*
* Purpose: keep spoken IVR prompts outside Twilio's built-in TTS so the voice
* can be swapped to ElevenLabs without changing the call-control webhook.
*
* Consumer: the Twilio IVR route requests this endpoint when ElevenLabs prompt
* synthesis is configured.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function noStoreHeaders(): HeadersInit {
return {
"Cache-Control": "no-store",
};
}
function getElevenLabsConfig(): {
apiKey: string;
voiceId: string;
modelId: string;
} | null {
const env = process.env;
const apiKey = env.ELEVENLABS_API_KEY?.trim() ?? "";
const voiceId = env.ELEVENLABS_VOICE_ID?.trim() ?? "";
if (!apiKey || !voiceId) return null;
return {
apiKey,
modelId: env.ELEVENLABS_MODEL_ID?.trim() || "eleven_flash_v2_5",
voiceId,
};
}
function getPromptText(request: Request): string | null {
const url = new URL(request.url);
const text = url.searchParams.get("text")?.trim();
return text ? text : null;
}
/**
* Synthesize a spoken prompt with ElevenLabs and return audio bytes.
*
* Purpose: feed Twilio `<Play>` with a consistent prompt voice when the IVR is
* configured to use ElevenLabs.
*
* Consumer: Twilio IVR TwiML route as an audio asset source.
*/
export async function GET(request: Request): Promise<Response> {
const config = getElevenLabsConfig();
const text = getPromptText(request);
if (!config || !text) {
return new Response("ElevenLabs prompt synthesis is not configured.", {
status: 503,
headers: noStoreHeaders(),
});
}
const response = await fetch(
`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(config.voiceId)}?output_format=mp3_44100_128`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"xi-api-key": config.apiKey,
Accept: "audio/mpeg",
},
body: JSON.stringify({
text,
model_id: config.modelId,
}),
},
);
if (!response.ok) {
const message = await response.text().catch(() => "");
return new Response(message || "ElevenLabs prompt synthesis failed.", {
status: response.status,
headers: noStoreHeaders(),
});
}
return new Response(response.body, {
status: 200,
headers: {
"Cache-Control": "no-store",
"Content-Type": response.headers.get("content-type") || "audio/mpeg",
},
});
}

View file

@ -1,182 +0,0 @@
/**
* Twilio IVR webhook for customer calls into the SF voice bridge.
*
* Purpose: give the public Twilio number a deterministic menu that can route
* callers to the on-call line or to an optional AI assistant without adding a
* separate voice service.
*
* Consumer: Twilio Programmable Voice webhook hits this route on inbound calls.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type VoiceRoutingConfig = {
onCallNumbers: string[];
aiNumbers: string[];
};
type VoicePromptConfig = {
useElevenLabs: boolean;
voiceId: string | null;
modelId: string;
apiKey: string | null;
};
function noStoreHeaders(): HeadersInit {
return {
"Cache-Control": "no-store",
"Content-Type": "text/xml; charset=utf-8",
};
}
function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function splitPhoneList(value: string | undefined): string[] {
if (!value) return [];
return value
.split(/[\s,]+/)
.map((entry) => entry.trim())
.filter(Boolean);
}
function getRoutingConfig(): VoiceRoutingConfig {
const env = process.env;
const onCallNumbers = splitPhoneList(
env.TWILIO_IVR_ONCALL_NUMBERS ??
env.TWILIO_IVR_ONCALL_NUMBER ??
env.TWILIO_ONCALL_NUMBER ??
env.ONCALL_NUMBER,
);
const aiNumbers = splitPhoneList(
env.TWILIO_IVR_AI_NUMBERS ?? env.TWILIO_IVR_AI_NUMBER,
);
return { aiNumbers, onCallNumbers };
}
function getPromptConfig(): VoicePromptConfig {
const env = process.env;
const apiKey = env.ELEVENLABS_API_KEY?.trim() ?? null;
const voiceId = env.ELEVENLABS_VOICE_ID?.trim() ?? null;
return {
apiKey,
modelId: env.ELEVENLABS_MODEL_ID?.trim() || "eleven_flash_v2_5",
useElevenLabs: Boolean(apiKey && voiceId),
voiceId,
};
}
function buildTwiml(body: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>\n<Response>\n${body}\n</Response>\n`;
}
function buildMenuPrompt(config: VoiceRoutingConfig): string {
const lines = [
`Welcome to Singularity Forge.`,
`Press 1 to reach the on-call line.`,
];
if (config.aiNumbers.length > 0) {
lines.push(`Press 2 for the AI assistant.`);
}
lines.push(`Press 9 to repeat this menu.`);
return lines.join(" ");
}
function buildPromptVerb(promptText: string, requestUrl: string): string {
const promptConfig = getPromptConfig();
if (!promptConfig.useElevenLabs) {
return `<Say voice="alice" language="en-US">${escapeXml(promptText)}</Say>`;
}
const promptUrl = new URL("/api/voice/prompt", requestUrl);
promptUrl.searchParams.set("text", promptText);
return `<Play>${escapeXml(promptUrl.toString())}</Play>`;
}
function buildDialTargets(numbers: string[]): string {
if (numbers.length === 0) {
return `<Say voice="alice" language="en-US">The requested destination is not configured.</Say>\n<Hangup/>`;
}
return [
`<Dial answerOnBridge="true" timeout="20">`,
...numbers.map((number) => ` <Number>${escapeXml(number)}</Number>`),
`</Dial>`,
].join("\n");
}
function buildMenuResponse(
requestUrl: string,
config: VoiceRoutingConfig,
): string {
return buildTwiml(
[
`<Gather input="dtmf" numDigits="1" timeout="6" action="/api/voice" method="POST">`,
` ${buildPromptVerb(buildMenuPrompt(config), requestUrl)}`,
`</Gather>`,
`<Say voice="alice" language="en-US">Sorry, we did not get a selection.</Say>`,
`<Redirect method="POST">/api/voice</Redirect>`,
].join("\n"),
);
}
function buildDigitsResponse(
digits: string | null,
requestUrl: string,
config: VoiceRoutingConfig,
): string {
if (digits === "1") {
return buildTwiml(buildDialTargets(config.onCallNumbers));
}
if (digits === "2" && config.aiNumbers.length > 0) {
return buildTwiml(buildDialTargets(config.aiNumbers));
}
return buildMenuResponse(requestUrl, config);
}
async function readDigits(request: Request): Promise<string | null> {
if (request.method === "GET") return null;
const body = await request.text();
if (!body) return null;
const params = new URLSearchParams(body);
return params.get("Digits") ?? params.get("digits");
}
/**
* Return the initial IVR menu for inbound Twilio calls.
*
* Purpose: present a stable entrypoint for customers so the Twilio number can
* route them either to on-call or to an optional AI assistant.
*
* Consumer: Twilio inbound call webhook and local integration tests.
*/
export async function GET(request: Request): Promise<Response> {
return new Response(buildMenuResponse(request.url, getRoutingConfig()), {
headers: noStoreHeaders(),
});
}
/**
* Route the Twilio call according to the selected IVR digit.
*
* Purpose: let the same public number handle both menu playback and the actual
* customer transfer without adding a second webhook surface.
*
* Consumer: Twilio posts DTMF selections back to this route after the caller
* chooses an IVR option.
*/
export async function POST(request: Request): Promise<Response> {
const digits = await readDigits(request);
const responseXml =
digits === null
? buildMenuResponse(request.url, getRoutingConfig())
: buildDigitsResponse(digits, request.url, getRoutingConfig());
return new Response(responseXml, {
headers: noStoreHeaders(),
});
}