refactor(uok): rename scheduler-v2 and plan-v2 to drop v2 suffix

v1 no longer exists — the suffix is just noise. Update all import sites
and rename the test file to match.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-09 14:45:02 +02:00
parent 9450b4a11d
commit 5dbd318a76
15 changed files with 1063 additions and 990 deletions

1585
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -29,7 +29,7 @@
"test": "vitest run packages/daemon/src --root ../.. --config vitest.config.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.93.0",
"@anthropic-ai/sdk": "^0.95.1",
"@singularity-forge/rpc-client": "^2.75.3",
"discord.js": "^14.25.1",
"yaml": "^2.8.0",

View file

@ -23,21 +23,21 @@
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.93.0",
"@anthropic-ai/vertex-sdk": "^0.14.4",
"@aws-sdk/client-bedrock-runtime": "^3.983.0",
"@google/gemini-cli-core": "0.40.1",
"@google/genai": "^1.40.0",
"@anthropic-ai/sdk": "^0.95.1",
"@anthropic-ai/vertex-sdk": "^0.16.0",
"@aws-sdk/client-bedrock-runtime": "^3.1045.0",
"@google/gemini-cli-core": "^0.41.2",
"@google/genai": "^2.0.1",
"@mistralai/mistralai": "^2.2.1",
"@singularity-forge/google-gemini-cli-provider": "^2.75.3",
"@sinclair/typebox": "^0.34.41",
"ajv": "^8.17.1",
"@sinclair/typebox": "^0.34.49",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"chalk": "^5.6.2",
"jsonrepair": "^3.14.0",
"openai": "^6.26.0",
"proxy-agent": "^6.5.0",
"undici": "^7.24.2",
"openai": "^6.37.0",
"proxy-agent": "^8.0.1",
"undici": "^8.2.0",
"yaml": "^2.8.3",
"zod-to-json-schema": "^3.24.6"
},

View file

@ -23,18 +23,18 @@
"@mariozechner/jiti": "^2.6.2",
"@silvia-odwyer/photon-node": "^0.3.4",
"chalk": "^5.5.0",
"diff": "^8.0.2",
"diff": "^9.0.0",
"express": "^5.2.1",
"extract-zip": "^2.0.1",
"file-type": "^21.1.1",
"hosted-git-info": "^9.0.2",
"file-type": "^21.3.4",
"hosted-git-info": "^9.0.3",
"ignore": "^7.0.5",
"marked": "^15.0.12",
"minimatch": "^10.2.3",
"marked": "^18.0.3",
"minimatch": "^10.2.5",
"proper-lockfile": "^4.1.2",
"strip-ansi": "^7.1.0",
"undici": "^7.24.2",
"yaml": "^2.8.2"
"strip-ansi": "^7.2.0",
"undici": "^8.2.0",
"yaml": "^2.8.4"
},
"devDependencies": {
"@types/diff": "^7.0.2",

View file

@ -18,7 +18,7 @@
"dependencies": {
"chalk": "^5.6.2",
"get-east-asian-width": "^1.3.0",
"marked": "^15.0.12",
"marked": "^18.0.3",
"mime-types": "^3.0.1"
},
"devDependencies": {

View file

@ -90,7 +90,7 @@ import {
import { selectReactiveDispatchBatch } from "./uok/execution-graph.js";
import { resolveUokFlags } from "./uok/flags.js";
import { UokGateRunner } from "./uok/gate-runner.js";
import { hasFinalizedMilestoneContext } from "./uok/plan-v2.js";
import { hasFinalizedMilestoneContext } from "./uok/plan.js";
import {
decideUnitRuntimeDispatch,
readUnitRuntimeRecord,

View file

@ -92,7 +92,7 @@ import {
ensurePlanV2Graph as ensurePlanningFlowGraph,
isEmptyPlanV2GraphResult,
isMissingFinalizedContextResult,
} from "../uok/plan-v2.js";
} from "../uok/plan.js";
import { buildUokProgressEvent } from "../uok/progress-event.js";
import {
clearUnitRuntimeRecord,

View file

@ -74,7 +74,7 @@ import { getMilestoneSlices, isDbAvailable } from "./sf-db.js";
import { deriveState } from "./state.js";
import { resolveUokFlags } from "./uok/flags.js";
import { UokGateRunner } from "./uok/gate-runner.js";
import { ensurePlanV2Graph as ensurePlanningFlowGraph } from "./uok/plan-v2.js";
import { ensurePlanV2Graph as ensurePlanningFlowGraph } from "./uok/plan.js";
import {
clearUnitRuntimeRecord,
listUnitRuntimeRecords,

View file

@ -18,7 +18,7 @@ import {
ExecutionGraphSchedulerV2,
ProgressStream,
WorkerPool,
} from "../uok/scheduler-v2.js";
} from "../uok/scheduler.js";
afterEach(() => {
closeDatabase();

View file

@ -152,7 +152,7 @@ export {
isEmptyPlanV2GraphResult,
isExecutionEntryPhase,
isMissingFinalizedContextResult,
} from "./plan-v2.js";
} from "./plan.js";
export {
buildUokProgressEvent,
UOK_PROGRESS_EVENT_TYPES,
@ -164,7 +164,7 @@ export {
ExecutionGraphSchedulerV2,
ProgressStream,
WorkerPool,
} from "./scheduler-v2.js";
} from "./scheduler.js";
export { SecurityGate } from "./security-gate.js";
// ─── Task State Machine ────────────────────────────────────────────────────
export {

View file

@ -0,0 +1,143 @@
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

@ -0,0 +1,92 @@
/**
* 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",
},
});
}

181
web/app/api/voice/route.ts Normal file
View file

@ -0,0 +1,181 @@
/**
* 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,
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(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, getRoutingConfig());
return new Response(responseXml, {
headers: noStoreHeaders(),
});
}