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:
parent
ffdec0feee
commit
623af869b1
23 changed files with 401 additions and 688 deletions
11
README.md
11
README.md
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ export class ScopedModelsSelectorComponent
|
|||
"^A all",
|
||||
"^X clear",
|
||||
"^P provider",
|
||||
`${process.platform === "darwin" ? "⌥↑↓" : "Alt+↑↓"} reorder`,
|
||||
"Alt+↑↓ reorder",
|
||||
"^S save",
|
||||
countText,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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], () => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
180
src/resources/extensions/sf/safety/autonomous-rollback.js
Normal file
180
src/resources/extensions/sf/safety/autonomous-rollback.js
Normal 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.",
|
||||
});
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ const CREDIBLE_RESOLUTION_KINDS = new Set([
|
|||
"agent-fix",
|
||||
"human-clear",
|
||||
"promoted-to-requirement",
|
||||
"adversarial-finding",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
149
src/resources/extensions/sf/tests/autonomous-rollback.test.mjs
Normal file
149
src/resources/extensions/sf/tests/autonomous-rollback.test.mjs
Normal 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");
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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], () => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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(),
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue