chore: typecheck and build readiness

This commit is contained in:
Mohamed Boudra
2025-11-24 22:31:41 +01:00
parent a95184cc5a
commit 30d1b63b2b
25 changed files with 1502 additions and 440 deletions
@@ -19,6 +19,7 @@ import type {
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "./agent-sdk-types.js";
import { resolveAgentModel } from "./model-resolver.js";
export type AgentLifecycleStatus =
| "initializing"
@@ -31,6 +32,7 @@ export type AgentSnapshot = {
id: string;
provider: AgentProvider;
cwd: string;
model: string | null;
createdAt: Date;
updatedAt: Date;
lastUserMessageAt: Date | null;
@@ -203,9 +205,14 @@ export class AgentManager {
config: AgentSessionConfig,
agentId?: string
): Promise<AgentSnapshot> {
const client = this.requireClient(config.provider);
const session = await client.createSession(config);
return this.registerSession(session, config, agentId ?? this.idFactory());
const normalizedConfig = await this.normalizeConfig(config);
const client = this.requireClient(normalizedConfig.provider);
const session = await client.createSession(normalizedConfig);
return this.registerSession(
session,
normalizedConfig,
agentId ?? this.idFactory()
);
}
async resumeAgent(
@@ -213,17 +220,22 @@ export class AgentManager {
overrides?: Partial<AgentSessionConfig>,
agentId?: string
): Promise<AgentSnapshot> {
const client = this.requireClient(handle.provider);
const session = await client.resumeSession(handle, overrides);
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
const mergedConfig = {
...metadata,
...overrides,
provider: handle.provider,
} as AgentSessionConfig;
const normalizedConfig = await this.normalizeConfig(mergedConfig);
const resumeOverrides =
normalizedConfig.model !== mergedConfig.model
? { ...overrides, model: normalizedConfig.model }
: overrides;
const client = this.requireClient(handle.provider);
const session = await client.resumeSession(handle, resumeOverrides);
return this.registerSession(
session,
mergedConfig,
normalizedConfig,
agentId ?? this.idFactory()
);
}
@@ -608,6 +620,7 @@ export class AgentManager {
id: agent.id,
provider: agent.provider,
cwd: agent.cwd,
model: agent.config.model ?? null,
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
lastUserMessageAt: agent.lastUserMessageAt,
@@ -623,6 +636,26 @@ export class AgentManager {
};
}
private async normalizeConfig(
config: AgentSessionConfig
): Promise<AgentSessionConfig> {
const normalized: AgentSessionConfig = { ...config };
const resolvedModel = await resolveAgentModel({
provider: normalized.provider,
requestedModel: normalized.model,
cwd: normalized.cwd,
});
if (resolvedModel) {
normalized.model = resolvedModel;
} else if (typeof normalized.model === "string") {
const trimmed = normalized.model.trim();
normalized.model = trimmed.length > 0 ? trimmed : undefined;
}
return normalized;
}
private requireClient(provider: AgentProvider): AgentClient {
const client = this.clients.get(provider);
if (!client) {
@@ -12,6 +12,7 @@ function createSnapshot(overrides?: Partial<AgentSnapshot>): AgentSnapshot {
id: "agent-test",
provider: "claude",
cwd: "/tmp/project",
model: null,
createdAt: now,
updatedAt: now,
lastUserMessageAt: null,
@@ -0,0 +1,20 @@
const AUDIO_EXTENSIONS: Array<{ match: (format: string) => boolean; ext: string }> = [
{ match: (format) => format.includes("webm"), ext: "webm" },
{ match: (format) => format.includes("ogg"), ext: "ogg" },
{ match: (format) => format.includes("mp3"), ext: "mp3" },
{ match: (format) => format.includes("wav"), ext: "wav" },
{ match: (format) => format.includes("m4a") || format.includes("aac"), ext: "m4a" },
{ match: (format) => format.includes("mp4"), ext: "mp4" },
{ match: (format) => format.includes("flac"), ext: "flac" },
];
export function inferAudioExtension(format: string | undefined): string {
const normalized = (format || "webm").toLowerCase();
const candidate = AUDIO_EXTENSIONS.find((entry) => entry.match(normalized));
return candidate?.ext ?? "webm";
}
export function sanitizeForFilename(segment: string | undefined, fallback: string): string {
const value = segment && segment.length > 0 ? segment : fallback;
return value.replace(/[^a-zA-Z0-9-_]/g, "_").slice(0, 64);
}
@@ -1,7 +1,7 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import path from "node:path";
import readline from "node:readline";
import { fileURLToPath } from "node:url";
import { fileURLToPath, URL } from "node:url";
import {
query,
@@ -105,7 +105,9 @@ function emptySdkMessageStream(): AsyncIterable<SDKUserMessage> {
}
function resolveCodexBinary(): string {
const repoRoot = path.resolve(fileURLToPath(new URL("../../../../..", import.meta.url)));
const repoRoot = path.resolve(
fileURLToPath(new URL("../../../../..", import.meta.url))
);
const packageRoot = path.join(repoRoot, "node_modules", "@openai", "codex-sdk");
const vendorDir = path.join(packageRoot, "vendor");
@@ -144,7 +146,7 @@ type CodexModelInfo = {
type PendingRequest = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
timer: ReturnType<typeof setTimeout>;
};
class CodexAppServerClient {
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveAgentModel } from "./model-resolver.js";
vi.mock("./model-catalog.js", () => ({
fetchProviderModelCatalog: vi.fn(),
}));
import { fetchProviderModelCatalog } from "./model-catalog.js";
const mockedFetch = vi.mocked(fetchProviderModelCatalog);
describe("resolveAgentModel", () => {
beforeEach(() => {
mockedFetch.mockReset();
});
it("returns the trimmed requested model when provided", async () => {
const result = await resolveAgentModel({
provider: "codex",
requestedModel: " gpt-5.1 ",
cwd: "/tmp",
});
expect(result).toBe("gpt-5.1");
expect(mockedFetch).not.toHaveBeenCalled();
});
it("uses the default model from the provider catalog when no model specified", async () => {
mockedFetch.mockResolvedValue([
{ id: "claude-3.5-haiku", isDefault: false } as any,
{ id: "claude-3.5-sonnet", isDefault: true } as any,
]);
const result = await resolveAgentModel({ provider: "claude", cwd: "~/repo" });
expect(result).toBe("claude-3.5-sonnet");
expect(mockedFetch).toHaveBeenCalledWith("claude", {
cwd: expect.stringMatching(/repo$/),
});
});
it("falls back to the first model when none are flagged as default", async () => {
mockedFetch.mockResolvedValue([
{ id: "model-a", isDefault: false } as any,
{ id: "model-b", isDefault: false } as any,
]);
const result = await resolveAgentModel({ provider: "codex" });
expect(result).toBe("model-a");
});
it("returns undefined when the catalog lookup fails", async () => {
mockedFetch.mockRejectedValue(new Error("boom"));
const result = await resolveAgentModel({ provider: "codex" });
expect(result).toBeUndefined();
});
});
@@ -0,0 +1,32 @@
import { fetchProviderModelCatalog } from "./model-catalog.js";
import type { AgentProvider } from "./agent-sdk-types.js";
import { expandTilde } from "../terminal-mcp/tmux.js";
type ResolveAgentModelOptions = {
provider: AgentProvider;
requestedModel?: string | null;
cwd?: string;
};
export async function resolveAgentModel(
options: ResolveAgentModelOptions
): Promise<string | undefined> {
const trimmed = options.requestedModel?.trim();
if (trimmed) {
return trimmed;
}
try {
const models = await fetchProviderModelCatalog(options.provider, {
cwd: options.cwd ? expandTilde(options.cwd) : undefined,
});
const preferred = models.find((model) => model.isDefault) ?? models[0];
return preferred?.id;
} catch (error) {
console.warn(
`[AgentModelResolver] Failed to resolve default model for ${options.provider}:`,
error
);
return undefined;
}
}
@@ -0,0 +1,50 @@
import { mkdir, writeFile } from "fs/promises";
import { join, resolve } from "path";
import { inferAudioExtension, sanitizeForFilename } from "./audio-utils.js";
const debugDir = process.env.STT_DEBUG_AUDIO_DIR
? resolve(process.env.STT_DEBUG_AUDIO_DIR)
: null;
let announced = false;
export interface DebugAudioMetadata {
sessionId: string;
agentId?: string;
requestId?: string;
label?: string;
format: string;
}
export async function maybePersistDebugAudio(
audio: Buffer,
metadata: DebugAudioMetadata
): Promise<string | null> {
if (!debugDir) {
return null;
}
if (!announced) {
console.log(`[STT][Debug] Raw audio capture enabled at ${debugDir}`);
announced = true;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const folder = join(debugDir, sanitizeForFilename(metadata.sessionId, "session"));
await mkdir(folder, { recursive: true });
const parts = [timestamp];
if (metadata.agentId) {
parts.push(sanitizeForFilename(metadata.agentId, "agent"));
}
if (metadata.label) {
parts.push(sanitizeForFilename(metadata.label, "source"));
}
if (metadata.requestId) {
parts.push(sanitizeForFilename(metadata.requestId, "request"));
}
const ext = inferAudioExtension(metadata.format);
const filePath = join(folder, `${parts.join("_")}.${ext}`);
await writeFile(filePath, audio);
return filePath;
}
@@ -1,4 +1,17 @@
import { transcribeAudio, type TranscriptionResult } from "./stt-openai.js";
import { maybePersistDebugAudio } from "./stt-debug.js";
interface TranscriptionMetadata {
agentId?: string;
requestId?: string;
label?: string;
}
export interface SessionTranscriptionResult extends TranscriptionResult {
debugRecordingPath?: string;
byteLength: number;
format: string;
}
/**
* Per-session STT manager
@@ -16,12 +29,30 @@ export class STTManager {
*/
public async transcribe(
audio: Buffer,
format: string
): Promise<TranscriptionResult> {
format: string,
metadata?: TranscriptionMetadata
): Promise<SessionTranscriptionResult> {
const context = metadata?.label ? ` (${metadata.label})` : "";
console.log(
`[STT-Manager ${this.sessionId}] Transcribing ${audio.length} bytes of ${format} audio`
`[STT-Manager ${this.sessionId}] Transcribing ${audio.length} bytes of ${format} audio${context}`
);
let debugRecordingPath: string | null = null;
try {
debugRecordingPath = await maybePersistDebugAudio(audio, {
sessionId: this.sessionId,
agentId: metadata?.agentId,
requestId: metadata?.requestId,
label: metadata?.label,
format,
});
} catch (error) {
console.warn(
`[STT-Manager ${this.sessionId}] Failed to persist debug audio:`,
error
);
}
const result = await transcribeAudio(audio, format);
// Filter out low-confidence transcriptions (non-speech sounds)
@@ -34,6 +65,9 @@ export class STTManager {
return {
...result,
text: "",
byteLength: audio.length,
format,
debugRecordingPath: debugRecordingPath ?? undefined,
};
}
@@ -43,7 +77,12 @@ export class STTManager {
}`
);
return result;
return {
...result,
debugRecordingPath: debugRecordingPath ?? undefined,
byteLength: audio.length,
format,
};
}
/**
+13 -22
View File
@@ -3,10 +3,11 @@ import { writeFile, unlink } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
import { v4 as uuidv4 } from "uuid";
import { inferAudioExtension } from "./audio-utils.js";
export interface STTConfig {
apiKey: string;
model?: "whisper-1";
model?: "whisper-1" | "gpt-4o-transcribe" | "gpt-4o-mini-transcribe" | (string & {});
confidenceThreshold?: number; // Default: -3.0
}
@@ -49,7 +50,7 @@ export async function transcribeAudio(
try {
// Map format to file extension
const ext = getFileExtension(format);
const ext = inferAudioExtension(format);
// Write audio buffer to temporary file
// OpenAI API requires file upload, not raw buffer
@@ -61,11 +62,15 @@ export async function transcribeAudio(
);
// Call OpenAI Whisper API
const modelToUse = config.model ?? "whisper-1";
const supportsLogprobs =
modelToUse === "gpt-4o-transcribe" || modelToUse === "gpt-4o-mini-transcribe";
const response = await openaiClient.audio.transcriptions.create({
file: await import("fs").then((fs) => fs.createReadStream(tempFilePath!)),
language: "en",
model: config.model ?? "gpt-4o-transcribe",
include: ["logprobs"],
model: modelToUse,
...(supportsLogprobs ? { include: ["logprobs"] as ["logprobs"] } : {}),
response_format: "json", // Get language and duration info
});
@@ -77,7 +82,9 @@ export async function transcribeAudio(
// Analyze logprobs if available
let avgLogprob: number | undefined;
let isLowConfidence = false;
const logprobs = response.logprobs as LogprobToken[] | undefined;
const logprobs = supportsLogprobs
? (response.logprobs as LogprobToken[] | undefined)
: undefined;
if (logprobs && logprobs.length > 0) {
// Calculate average logprob
@@ -117,6 +124,7 @@ export async function transcribeAudio(
logprobs: logprobs,
avgLogprob: avgLogprob,
isLowConfidence: isLowConfidence,
language: (response as { language?: string }).language,
};
} catch (error: any) {
console.error("[STT] Transcription error:", error);
@@ -132,23 +140,6 @@ export async function transcribeAudio(
}
}
}
function getFileExtension(format: string): string {
// Map mime types or format strings to file extensions
const formatLower = format.toLowerCase();
if (formatLower.includes("webm")) return "webm";
if (formatLower.includes("ogg")) return "ogg";
if (formatLower.includes("mp3")) return "mp3";
if (formatLower.includes("wav")) return "wav";
if (formatLower.includes("m4a")) return "m4a";
if (formatLower.includes("mp4")) return "mp4";
if (formatLower.includes("flac")) return "flac";
// Default to webm (common browser format)
return "webm";
}
export function isSTTInitialized(): boolean {
return openaiClient !== null && config !== null;
}
+5 -2
View File
@@ -3,7 +3,7 @@ import express from "express";
import basicAuth from "express-basic-auth";
import { createServer as createHTTPServer } from "http";
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
import { initializeSTT } from "./agent/stt-openai.js";
import { initializeSTT, type STTConfig } from "./agent/stt-openai.js";
import { initializeTTS } from "./agent/tts-openai.js";
import { listConversations, deleteConversation } from "./persistence.js";
import { AgentManager } from "./agent/agent-manager.js";
@@ -101,9 +101,12 @@ async function main() {
? parseFloat(process.env.STT_CONFIDENCE_THRESHOLD)
: undefined; // Will default to -3.0 in stt-openai.ts
const sttModel = process.env.STT_MODEL as STTConfig["model"];
initializeSTT({
apiKey,
confidenceThreshold: sttConfidenceThreshold
confidenceThreshold: sttConfidenceThreshold,
...(sttModel ? { model: sttModel } : {}),
});
// Initialize TTS
+10
View File
@@ -228,6 +228,7 @@ export const AgentSnapshotPayloadSchema = z.object({
id: z.string(),
provider: AgentProviderSchema,
cwd: z.string(),
model: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
lastUserMessageAt: z.string().nullable(),
@@ -254,6 +255,7 @@ export function serializeAgentSnapshot(
const { createdAt, updatedAt, lastUserMessageAt, ...rest } = snapshot;
return {
...rest,
model: snapshot.model,
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
@@ -334,6 +336,9 @@ export const SendAgentAudioSchema = z.object({
format: z.string(),
isLast: z.boolean(),
requestId: z.string().optional(), // Client-provided ID for tracking transcription
mode: z
.enum(["transcribe_only", "auto_run"])
.optional(),
});
const GitSetupOptionsSchema = z.object({
@@ -531,6 +536,11 @@ export const TranscriptionResultMessageSchema = z.object({
language: z.string().optional(),
duration: z.number().optional(),
requestId: z.string().optional(), // Echoed back from request for tracking
avgLogprob: z.number().optional(),
isLowConfidence: z.boolean().optional(),
byteLength: z.number().optional(),
format: z.string().optional(),
debugRecordingPath: z.string().optional(),
}),
});
@@ -13,6 +13,7 @@ function createSnapshot(overrides?: Partial<AgentSnapshot>): AgentSnapshot {
id: "agent-1",
provider: "claude",
cwd: "/tmp/project",
model: null,
createdAt: now,
updatedAt: now,
lastUserMessageAt: null,
+28 -3
View File
@@ -545,6 +545,7 @@ export class Session {
id: record.id,
provider,
cwd: record.cwd,
model: record.config?.model ?? null,
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
@@ -971,7 +972,8 @@ export class Session {
private async handleSendAgentAudio(
msg: Extract<SessionInboundMessage, { type: "send_agent_audio" }>
): Promise<void> {
const { agentId, audio, format, isLast, requestId } = msg;
const { agentId, audio, format, isLast, requestId, mode } = msg;
const shouldAutoRun = mode === "auto_run";
// Decode base64
const audioBuffer = Buffer.from(audio, "base64");
@@ -992,7 +994,11 @@ export class Session {
try {
// Transcribe the audio
const result = await this.sttManager.transcribe(audioBuffer, format);
const result = await this.sttManager.transcribe(audioBuffer, format, {
agentId,
requestId,
label: shouldAutoRun ? "dictation:auto_run" : "dictation",
});
const transcriptText = result.text.trim();
if (!transcriptText) {
@@ -1014,9 +1020,21 @@ export class Session {
language: result.language,
duration: result.duration,
requestId,
avgLogprob: result.avgLogprob,
isLowConfidence: result.isLowConfidence,
byteLength: result.byteLength,
format: result.format,
debugRecordingPath: result.debugRecordingPath,
},
});
if (!shouldAutoRun) {
console.log(
`[Session ${this.clientId}] Completed transcription for agent ${agentId} (requestId: ${requestId ?? "n/a"})`
);
return;
}
try {
await this.interruptAgentIfRunning(agentId);
} catch (error) {
@@ -2225,7 +2243,9 @@ export class Session {
});
try {
const result = await this.sttManager.transcribe(audio, format);
const result = await this.sttManager.transcribe(audio, format, {
label: this.isRealtimeMode ? "realtime" : "buffered",
});
const transcriptText = result.text.trim();
if (!transcriptText) {
@@ -2255,6 +2275,11 @@ export class Session {
text: result.text,
language: result.language,
duration: result.duration,
avgLogprob: result.avgLogprob,
isLowConfidence: result.isLowConfidence,
byteLength: result.byteLength,
format: result.format,
debugRecordingPath: result.debugRecordingPath,
},
});
+3 -3
View File
@@ -119,13 +119,13 @@ export function validateBranchSlug(slug: string): {
return { valid: false, error: "Branch name too long (max 100 characters)" };
}
// Check for valid characters: lowercase letters, numbers, hyphens
const validPattern = /^[a-z0-9-]+$/;
// Check for valid characters: lowercase letters, numbers, hyphens, forward slashes
const validPattern = /^[a-z0-9-/]+$/;
if (!validPattern.test(slug)) {
return {
valid: false,
error:
"Branch name must contain only lowercase letters, numbers, and hyphens",
"Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes",
};
}