diff --git a/package.json b/package.json index fec4f6454..cb2b16028 100644 --- a/package.json +++ b/package.json @@ -23,12 +23,14 @@ "format:check": "prettier --check .", "start": "npm run start --workspace=@getpaseo/server", "android": "npm run android --workspace=@getpaseo/app", - "android:release": "ANDROID_VARIANT=productionRelease npm run android --workspace=@getpaseo/app", - "android:dev-release": "ANDROID_VARIANT=devRelease npm run android --workspace=@getpaseo/app", + "android:fast": "npm run android:fast --workspace=@getpaseo/app", + "android:debug": "npm run android:debug --workspace=@getpaseo/app", + "android:prod": "npm run android:prod --workspace=@getpaseo/app", + "android:release": "npm run android:prod --workspace=@getpaseo/app", "ios": "npm run ios --workspace=@getpaseo/app", "web": "npm run web --workspace=@getpaseo/app", - "desktop": "npm run dev --workspace=@getpaseo/desktop", - "desktop:build": "npm run build --workspace=@getpaseo/desktop", + "dev:desktop": "npm run dev --workspace=@getpaseo/desktop", + "build:desktop": "npm run build --workspace=@getpaseo/desktop", "cli": "npx tsx packages/cli/src/index.js" }, "devDependencies": { diff --git a/packages/app/app.config.js b/packages/app/app.config.js index c85292f8b..b58349265 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -1,11 +1,26 @@ -// App name and package ID are controlled by Gradle product flavors (dev/production) -// See plugins/with-android-product-flavors.js for flavor configuration - const pkg = require("./package.json"); +const appVariant = process.env.APP_VARIANT ?? "prod"; + +const variants = { + prod: { + name: "Paseo", + packageId: "sh.paseo", + }, + debug: { + name: "Paseo Debug", + packageId: "sh.paseo.debug", + }, + fast: { + name: "Paseo Dev", + packageId: "sh.paseo.dev", + }, +}; + +const variant = variants[appVariant] ?? variants.prod; export default { expo: { - name: "Paseo", + name: variant.name, slug: "voice-mobile", version: pkg.version, orientation: "portrait", @@ -26,7 +41,7 @@ export default { "This app needs access to the microphone for voice commands.", ITSAppUsesNonExemptEncryption: false, }, - bundleIdentifier: "sh.paseo", + bundleIdentifier: variant.packageId, }, android: { adaptiveIcon: { @@ -45,8 +60,7 @@ export default { "CAMERA", "android.permission.CAMERA", ], - // Base package - Gradle product flavors override this per variant - package: "sh.paseo", + package: variant.packageId, }, web: { output: "single", @@ -91,7 +105,6 @@ export default { }, }, ], - "./plugins/with-android-product-flavors", ], experiments: { typedRoutes: true, diff --git a/packages/app/eas.json b/packages/app/eas.json index 57d519620..c9e4a7aaf 100644 --- a/packages/app/eas.json +++ b/packages/app/eas.json @@ -3,20 +3,36 @@ "version": ">= 14.0.0" }, "build": { - "development": { + "debug": { "developmentClient": true, "distribution": "internal", - "channel": "development" + "channel": "debug", + "env": { + "APP_VARIANT": "debug" + }, + "android": { + "gradleCommand": ":app:assembleDebug" + } }, - "preview": { + "fast": { + "developmentClient": true, "distribution": "internal", - "channel": "preview" + "channel": "fast", + "env": { + "APP_VARIANT": "fast" + }, + "android": { + "gradleCommand": ":app:assembleDebugOptimized" + } }, - "production": { - "channel": "production" + "prod": { + "channel": "production", + "env": { + "APP_VARIANT": "prod" + } } }, "submit": { - "production": {} + "prod": {} } } diff --git a/packages/app/package.json b/packages/app/package.json index 102234d6d..a86cc45a0 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -5,8 +5,11 @@ "scripts": { "start": "expo start", "reset-project": "node ./scripts/reset-project.js", - "android": "ANDROID_VARIANT=${ANDROID_VARIANT:-devDebug} expo run:android --variant=${ANDROID_VARIANT:-devDebug}", - "android:release": "ANDROID_VARIANT=${ANDROID_VARIANT:-productionRelease} expo run:android --variant=${ANDROID_VARIANT:-productionRelease}", + "android": "npm run android:fast", + "android:fast": "APP_VARIANT=fast expo run:android --variant=debugOptimized", + "android:debug": "APP_VARIANT=debug expo run:android --variant=debug", + "android:prod": "APP_VARIANT=prod expo run:android --variant=release", + "android:release": "npm run android:prod", "ios": "expo run:ios", "ios:release": "expo run:ios --configuration Release", "web": "expo start --web", diff --git a/packages/app/plugins/with-android-product-flavors.js b/packages/app/plugins/with-android-product-flavors.js deleted file mode 100644 index 95a97f9b4..000000000 --- a/packages/app/plugins/with-android-product-flavors.js +++ /dev/null @@ -1,68 +0,0 @@ -const { withAppBuildGradle } = require("expo/config-plugins"); - -/** - * Expo config plugin to add Android product flavors (dev/production) - * This allows building both dev and production variants without re-running prebuild - * - * Variants created: - * - devDebug: Development build with dev tools - * - devRelease: Development build optimized - * - productionDebug: Production build with dev tools - * - productionRelease: Production build optimized (for app store) - */ -function withAndroidProductFlavors(config) { - return withAppBuildGradle(config, (config) => { - let buildGradle = config.modResults.contents; - - // Add debuggableVariants to the react block - buildGradle = buildGradle.replace( - '// debuggableVariants = ["liteDebug", "prodDebug"]', - 'debuggableVariants = ["devDebug", "productionDebug"]' - ); - - // Add product flavors after signingConfigs block - const signingConfigsEnd = buildGradle.indexOf( - " }\n buildTypes {", - buildGradle.indexOf("signingConfigs {") - ); - - if (signingConfigsEnd === -1) { - console.warn( - "Could not find signingConfigs end in build.gradle, skipping product flavors" - ); - return config; - } - - const productFlavorsBlock = ` } - flavorDimensions "environment" - productFlavors { - dev { - dimension "environment" - applicationId "sh.paseo.dev" - resValue "string", "app_name", "Paseo (Dev)" - } - production { - dimension "environment" - applicationId "sh.paseo" - resValue "string", "app_name", "Paseo" - } - } - buildTypes {`; - - buildGradle = buildGradle.replace( - " }\n buildTypes {", - productFlavorsBlock - ); - - // Remove applicationId from defaultConfig since flavors handle it - buildGradle = buildGradle.replace( - /applicationId ['"]com\.moboudra\.paseo['"]\n\s*/, - "" - ); - - config.modResults.contents = buildGradle; - return config; - }); -} - -module.exports = withAndroidProductFlavors; diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 2fabc4483..2dea62247 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -357,7 +357,7 @@ describe("curateAgentActivity", () => { ]; const result = curateAgentActivity(timeline); - expect(result).toBe('[speak] {"text":"hello from voice"}'); + expect(result).toBe('[Speak] {"text":"hello from voice"}'); }); test("shows MCP tool input JSON", () => { @@ -389,7 +389,22 @@ describe("curateAgentActivity", () => { ]; const result = curateAgentActivity(timeline); - expect(result).toBe('[paseo_voice.speak] {"text":"hello from namespaced tool"}'); + expect(result).toBe('[Speak] {"text":"hello from namespaced tool"}'); + }); + + test("shows claude mcp speak tool input as Speak", () => { + const timeline: AgentTimelineItem[] = [ + { + type: "tool_call", + callId: "m3", + name: "mcp__paseo_voice__speak", + input: { text: "hello from claude mcp" }, + status: "completed", + }, + ]; + + const result = curateAgentActivity(timeline); + expect(result).toBe('[Speak] {"text":"hello from claude mcp"}'); }); }); }); diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index e5fc4e515..f6c8d2613 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -1,5 +1,5 @@ import type { AgentTimelineItem } from "./agent-sdk-types.js"; -import { extractPrincipalParam } from "../../utils/tool-call-parsers.js"; +import { extractPrincipalParam, normalizeToolDisplayName } from "../../utils/tool-call-parsers.js"; import { isLikelyExternalToolName } from "./tool-name-normalization.js"; const DEFAULT_MAX_ITEMS = 40; @@ -147,16 +147,17 @@ export function curateAgentActivity( break; case "tool_call": { flushBuffers(lines, buffers); + const displayName = normalizeToolDisplayName(item.name); const inputJson = formatToolInputJson(item.input); if (isLikelyExternalToolName(item.name) && inputJson) { - lines.push(`[${item.name}] ${inputJson}`); + lines.push(`[${displayName}] ${inputJson}`); break; } const principal = extractPrincipalParam(item.input); if (principal) { - lines.push(`[${item.name}] ${principal}`); + lines.push(`[${displayName}] ${principal}`); } else { - lines.push(`[${item.name}]`); + lines.push(`[${displayName}]`); } break; } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 07d5eee70..78d278841 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1350,7 +1350,15 @@ class CodexAppServerAgentSession implements AgentSession { if (ids.includes(this.currentThreadId)) { return; } - await this.client.request("thread/resume", { threadId: this.currentThreadId }); + const params: Record = { threadId: this.currentThreadId }; + if (this.config.systemPrompt?.trim()) { + params.developerInstructions = this.config.systemPrompt.trim(); + } + const codexConfig = this.buildCodexInnerConfig(); + if (codexConfig) { + params.config = codexConfig; + } + await this.client.request("thread/resume", params); } catch (error) { this.logger.warn({ error }, "Failed to resume Codex thread, starting new thread"); this.currentThreadId = null; @@ -1438,6 +1446,13 @@ class CodexAppServerAgentSession implements AgentSession { if (options?.outputSchema) { params.outputSchema = options.outputSchema; } + if (this.config.systemPrompt?.trim()) { + params.developerInstructions = this.config.systemPrompt.trim(); + } + const codexConfig = this.buildCodexInnerConfig(); + if (codexConfig) { + params.config = codexConfig; + } await this.client.request("turn/start", params, TURN_START_TIMEOUT_MS); @@ -1707,6 +1722,25 @@ class CodexAppServerAgentSession implements AgentSession { const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID]; const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy; const sandbox = this.config.sandboxMode ?? preset.sandbox; + const innerConfig = this.buildCodexInnerConfig(); + const response = (await this.client.request("thread/start", { + model, + cwd: this.config.cwd ?? null, + approvalPolicy, + sandbox, + ...(this.config.systemPrompt?.trim() + ? { developerInstructions: this.config.systemPrompt.trim() } + : {}), + ...(innerConfig ? { config: innerConfig } : {}), + })) as CodexThreadStartResponse; + const threadId = response?.thread?.id; + if (!threadId) { + throw new Error("Codex app-server did not return thread id"); + } + this.currentThreadId = threadId; + } + + private buildCodexInnerConfig(): Record | null { const innerConfig: Record = {}; if (this.config.mcpServers) { const mcpServers: Record = {}; @@ -1718,21 +1752,7 @@ class CodexAppServerAgentSession implements AgentSession { if (this.config.extra?.codex) { Object.assign(innerConfig, this.config.extra.codex); } - const response = (await this.client.request("thread/start", { - model, - cwd: this.config.cwd ?? null, - approvalPolicy, - sandbox, - ...(this.config.systemPrompt?.trim() - ? { developerInstructions: this.config.systemPrompt.trim() } - : {}), - ...(Object.keys(innerConfig).length > 0 ? { config: innerConfig } : {}), - })) as CodexThreadStartResponse; - const threadId = response?.thread?.id; - if (!threadId) { - throw new Error("Codex app-server did not return thread id"); - } - this.currentThreadId = threadId; + return Object.keys(innerConfig).length > 0 ? innerConfig : null; } private async buildUserInput(prompt: AgentPromptInput): Promise { diff --git a/packages/server/src/server/agent/tool-name-normalization.ts b/packages/server/src/server/agent/tool-name-normalization.ts index 795976524..d6784e7b1 100644 --- a/packages/server/src/server/agent/tool-name-normalization.ts +++ b/packages/server/src/server/agent/tool-name-normalization.ts @@ -1,5 +1,5 @@ const TOOL_TOKEN_REGEX = /[a-z0-9]+/g; -const NAMESPACE_SEPARATOR_REGEX = /[.:/]|__/; +const STANDARD_NAMESPACE_SEPARATOR_REGEX = /[.:/]/; export function normalizeToolName(name: string): string { return name.trim().toLowerCase(); @@ -20,7 +20,23 @@ export function isSpeakToolName(name: string): boolean { } export function isLikelyNamespacedToolName(name: string): boolean { - return NAMESPACE_SEPARATOR_REGEX.test(normalizeToolName(name)); + const normalized = normalizeToolName(name); + if (STANDARD_NAMESPACE_SEPARATOR_REGEX.test(normalized)) { + return true; + } + if (!normalized.includes("__")) { + return false; + } + + // Keep `__` handling strict to avoid false positives on arbitrary custom names. + const segments = normalized.split("__").filter((segment) => segment.length > 0); + if (segments.length >= 3) { + return true; + } + if (segments.length === 2 && segments[1]!.includes("_")) { + return true; + } + return false; } export function isLikelyExternalToolName(name: string): boolean { diff --git a/packages/server/src/server/voice-permission-policy.ts b/packages/server/src/server/voice-permission-policy.ts index 8c29305ae..144876e8d 100644 --- a/packages/server/src/server/voice-permission-policy.ts +++ b/packages/server/src/server/voice-permission-policy.ts @@ -57,7 +57,7 @@ export function isVoicePermissionAllowed(request: AgentPermissionRequest): boole return false; } - const mentionsAllowedTooling = containsAny(metadataText, ALLOWED_TOKEN_SET); + const mentionsAllowedTooling = containsAny(metadataText, SPEAK_TOKEN_SET); const mentionsDeniedTooling = containsAny(metadataText, DENIED_TOKEN_SET); return mentionsAllowedTooling && !mentionsDeniedTooling; } diff --git a/packages/server/src/utils/tool-call-parsers.test.ts b/packages/server/src/utils/tool-call-parsers.test.ts index 446f113d8..983c9add5 100644 --- a/packages/server/src/utils/tool-call-parsers.test.ts +++ b/packages/server/src/utils/tool-call-parsers.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest"; import { stripShellWrapperPrefix, extractPrincipalParam, + normalizeToolDisplayName, stripCwdPrefix, } from "./tool-call-parsers.js"; @@ -90,6 +91,26 @@ describe("extractPrincipalParam", () => { }); }); +describe("normalizeToolDisplayName", () => { + test("normalizes plain speak name", () => { + expect(normalizeToolDisplayName("speak")).toBe("Speak"); + }); + + test("normalizes codex namespaced speak name", () => { + expect(normalizeToolDisplayName("paseo_voice.speak")).toBe("Speak"); + }); + + test("normalizes claude mcp speak name", () => { + expect(normalizeToolDisplayName("mcp__paseo_voice__speak")).toBe("Speak"); + }); + + test("keeps non-speak names unchanged", () => { + expect(normalizeToolDisplayName("paseo__create_agent")).toBe( + "paseo__create_agent" + ); + }); +}); + describe("stripCwdPrefix", () => { test("strips cwd prefix from file path", () => { expect(stripCwdPrefix("/Users/dev/project/src/file.ts", "/Users/dev/project")).toBe("src/file.ts"); diff --git a/packages/server/src/utils/tool-call-parsers.ts b/packages/server/src/utils/tool-call-parsers.ts index a8cf81df0..e90a2f218 100644 --- a/packages/server/src/utils/tool-call-parsers.ts +++ b/packages/server/src/utils/tool-call-parsers.ts @@ -63,6 +63,21 @@ export function stripCwdPrefix(filePath: string, cwd?: string): string { // This is used for display purposes to show the actual command being run. const SHELL_WRAPPER_PREFIX_PATTERN = /^\/bin\/(?:zsh|bash|sh)\s+(?:-[a-zA-Z]+\s+)?/; const CD_AND_PATTERN = /^cd\s+(?:"[^"]+"|'[^']+'|\S+)\s+&&\s+/; +const TOOL_TOKEN_REGEX = /[a-z0-9]+/g; + +export function normalizeToolDisplayName(toolName: string): string { + const normalized = toolName.trim().toLowerCase(); + if (!normalized) { + return toolName; + } + + const tokens = normalized.match(TOOL_TOKEN_REGEX) ?? []; + const leaf = tokens[tokens.length - 1]; + if (leaf === "speak") { + return "Speak"; + } + return toolName; +} export function stripShellWrapperPrefix(command: string): string { const prefixMatch = command.match(SHELL_WRAPPER_PREFIX_PATTERN);