diff --git a/package-lock.json b/package-lock.json index b9e54e4..5b6e5c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,7 +73,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2615,7 +2614,6 @@ "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", @@ -3096,7 +3094,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3399,7 +3396,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3814,7 +3810,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -3880,7 +3875,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4810,7 +4804,6 @@ "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.3.0", "@jest/types": "30.3.0", @@ -6041,26 +6034,6 @@ "thenify-all": "^1.0.0" } }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "optional": true, - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -6809,17 +6782,6 @@ "node": ">= 12" } }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-support": { "version": "0.5.13", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", @@ -7298,7 +7260,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -7355,7 +7316,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/__tests__/detect.test.ts b/src/__tests__/detect.test.ts index 4092525..fcfa408 100644 --- a/src/__tests__/detect.test.ts +++ b/src/__tests__/detect.test.ts @@ -128,6 +128,20 @@ describe('findEntryPoint', () => { touch(dir, 'bot.py') expect(findEntryPoint(dir)).toBe('bot.py') }) + + it('prefers bot.py over agent.py for pipecat framework', () => { + const dir = makeTempDir() + touch(dir, 'agent.py') + touch(dir, 'bot.py') + expect(findEntryPoint(dir, 'pipecat')).toBe('bot.py') + }) + + it('keeps agent.py priority for livekit framework', () => { + const dir = makeTempDir() + touch(dir, 'agent.py') + touch(dir, 'bot.py') + expect(findEntryPoint(dir, 'livekit')).toBe('agent.py') + }) }) describe('detectFramework edge cases', () => { @@ -166,6 +180,18 @@ describe('detect (full pipeline)', () => { expect(result!.additionalFiles['pyproject.toml']).toBeDefined() }) + it('detects a Pipecat project and prefers bot.py over agent.py', () => { + const dir = makeTempDir() + touch(dir, 'requirements.txt', 'pipecat-ai\n') + touch(dir, 'agent.py', 'from pipecat.pipeline import Pipeline\n') + touch(dir, 'bot.py', 'from pipecat.pipeline import Pipeline\n') + + const result = detect(dir) + expect(result).not.toBeNull() + expect(result!.framework).toBe('pipecat') + expect(result!.entryPointPath).toBe('bot.py') + }) + it('returns null without a project file', () => { const dir = makeTempDir() touch(dir, 'agent.py') diff --git a/src/constants.ts b/src/constants.ts index 0ba4b37..d8c7cde 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -72,5 +72,15 @@ export const MAX_FILE_SIZE_BYTES = 50 * 1024 /** Files that indicate a Python project, checked in priority order. */ export const PROJECT_FILES = ['pyproject.toml', 'requirements.txt', 'Pipfile', 'setup.py'] as const -/** Entry point filenames to look for, in priority order. */ +/** Entry point filenames to look for, in priority order (default / LiveKit). */ export const ENTRY_POINT_NAMES = ['agent.py', 'main.py', 'bot.py', 'app.py'] as const + +/** Entry point filenames for Pipecat projects — bot.py takes priority (Pipecat Cloud convention). */ +export const PIPECAT_ENTRY_POINT_NAMES = ['bot.py', 'main.py', 'agent.py', 'app.py'] as const + +/** OTel packages required by coval_tracing.py. */ +export const OTEL_PACKAGES = [ + 'opentelemetry-api>=1.0.0', + 'opentelemetry-sdk>=1.0.0', + 'opentelemetry-exporter-otlp-proto-http>=1.0.0', +] as const diff --git a/src/detect.ts b/src/detect.ts index cdaace0..bb07004 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -1,6 +1,11 @@ import { readFileSync, readdirSync, existsSync } from 'node:fs' import { join } from 'node:path' -import { FRAMEWORKS, PROJECT_FILES, ENTRY_POINT_NAMES } from './constants.js' +import { + FRAMEWORKS, + PROJECT_FILES, + ENTRY_POINT_NAMES, + PIPECAT_ENTRY_POINT_NAMES, +} from './constants.js' import type { Framework, DetectionResult } from './types.js' /** Safely read a file, returning null on error. */ @@ -45,9 +50,10 @@ export const detectFramework = (dir: string): Framework => { return FRAMEWORKS.GENERIC } -/** Find the most likely entry point file. */ -export const findEntryPoint = (dir: string): string | null => { - for (const name of ENTRY_POINT_NAMES) { +/** Find the most likely entry point file. Uses framework-specific priority when provided. */ +export const findEntryPoint = (dir: string, framework?: Framework): string | null => { + const names = framework === FRAMEWORKS.PIPECAT ? PIPECAT_ENTRY_POINT_NAMES : ENTRY_POINT_NAMES + for (const name of names) { if (existsSync(join(dir, name))) return name } @@ -80,7 +86,7 @@ export const detect = (dir: string): DetectionResult | null => { if (!projectFile) return null const framework = detectFramework(dir) - const entryPointPath = findEntryPoint(dir) + const entryPointPath = findEntryPoint(dir, framework) if (!entryPointPath) return null return { diff --git a/src/files.ts b/src/files.ts index 47b9a5e..56c9d1b 100644 --- a/src/files.ts +++ b/src/files.ts @@ -1,8 +1,9 @@ import { readFileSync, writeFileSync, copyFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' import { createTwoFilesPatch } from 'diff' import chalk from 'chalk' import * as p from '@clack/prompts' -import { MAX_FILE_SIZE_BYTES } from './constants.js' +import { MAX_FILE_SIZE_BYTES, OTEL_PACKAGES } from './constants.js' export const readFile = (path: string): string => { const content = readFileSync(path, 'utf-8') @@ -42,3 +43,48 @@ export const writeFile = (path: string, content: string): void => { } export const fileExists = (path: string): boolean => existsSync(path) + +/** + * Add missing OTel packages to the project's dependency file. + * Handles requirements.txt (plain append) and pyproject.toml (injects into dependencies array). + * Returns the list of packages that were added, or an empty array if none were missing. + */ +/** + * Add missing OTel packages to the project's dependency file. + * Prefers requirements.txt when it exists (used by pip/Docker), falling back + * to pyproject.toml. Returns the list of packages added, or [] if none. + */ +export const addOtelDeps = (dir: string, projectFile: string): readonly string[] => { + // Always prefer requirements.txt when present — pip/Docker deployments use it + // regardless of whether pyproject.toml was detected as the primary project file. + const reqTxt = join(dir, 'requirements.txt') + const targetFile = existsSync(reqTxt) ? 'requirements.txt' : projectFile + const filePath = join(dir, targetFile) + const content = readFileSync(filePath, 'utf-8') + + const missing = OTEL_PACKAGES.filter((pkg) => { + const name = pkg.split('>=')[0] + return !content.includes(name) + }) + + if (missing.length === 0) return [] + + if (targetFile === 'requirements.txt') { + writeFileSync(filePath, content.trimEnd() + '\n' + missing.join('\n') + '\n', 'utf-8') + } else if (targetFile === 'pyproject.toml') { + // Match the closing ] that sits on its own line to avoid matching ] inside + // package extras like pipecat-ai[daily,openai]>=0.0.60 + const updated = content.replace( + /(\[project\][\s\S]*?dependencies\s*=\s*\[)([\s\S]*?)(\n\])/, + (_, open: string, inner: string, close: string) => { + // Ensure the last existing dep has a trailing comma before appending + const innerNormalized = inner.trimEnd().endsWith(',') ? inner : inner.trimEnd() + ',' + const additions = missing.map((p) => ` "${p}",`).join('\n') + return `${open}${innerNormalized}\n${additions}${close}` + }, + ) + if (updated !== content) writeFileSync(filePath, updated, 'utf-8') + } + + return missing +} diff --git a/src/index.ts b/src/index.ts index 8ff7305..8374968 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import chalk from 'chalk' import { getApiKey, verifyApiKey } from './auth.js' import { detect } from './detect.js' import { callWizardLLM, getLLMConfig } from './llm.js' -import { readFile, backupFile, showDiff, writeFile, fileExists } from './files.js' +import { readFile, backupFile, showDiff, writeFile, fileExists, addOtelDeps } from './files.js' import { sendTestSpan } from './validate.js' import { FRAMEWORK_LABELS, VERIFY_RESULTS, COVAL_TRACING_FILE } from './constants.js' @@ -116,6 +116,11 @@ const main = async () => { p.log.success(`${isCreate ? 'Created' : 'Updated'} ${COVAL_TRACING_FILE}`) p.log.success(`Modified ${detection.entryPointPath}`) + const addedDeps = addOtelDeps(targetDir, detection.projectFile) + if (addedDeps.length > 0) { + p.log.success(`Added OTel packages to ${detection.projectFile}: ${addedDeps.join(', ')}`) + } + // ── Validate ────────────────────────────────────────────────────────── let shouldValidate = true if (!autoYes) { diff --git a/src/prompts.ts b/src/prompts.ts index c73a77b..8dbc2a2 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -323,27 +323,30 @@ def create_tool_call_span(name: str = "", call_id: str = "", arguments: str = "" const FRAMEWORK_RULES: Record = { pipecat: `## Pipecat Framework Rules - Inject \`setup_coval_tracing()\` BEFORE \`PipelineTask()\`, \`PipelineRunner()\`, or \`Pipeline()\` construction -- Extract the simulation ID from \`args.body\` in the \`on_dialin_connected\` handler if present: +- Extract the simulation ID from the SIP headers in \`args.body\` at the top of \`bot()\`, right after \`setup_coval_tracing()\`: \`\`\`python - sim_id = (args.body or {}).get("dialin_settings", {}).get("custom_context", {}).get("coval_simulation_id") + sip_headers = (body or {}).get("dialin_settings", {}).get("sip_headers", {}) + sim_id = sip_headers.get("X-Coval-Simulation-Id") or sip_headers.get("x-coval-simulation-id") if sim_id: set_simulation_id(sim_id) \`\`\` -- If there is NO \`on_dialin_connected\` handler, add a TODO comment for where to call \`set_simulation_id()\` + Place this immediately after \`body = getattr(args, "body", None) or {}\` (or after \`setup_coval_tracing()\` if body is extracted later). Do NOT put it inside \`on_dialin_connected\`. +- If there is NO \`body\` variable extracted from \`args\`, add the body extraction then the sim ID lines above - Add \`enable_metrics=True\` and \`enable_tracing=True\` to PipelineTask if not already present`, livekit: `## LiveKit Agents Framework Rules - Inject \`setup_coval_tracing()\` BEFORE \`AgentSession()\` or \`VoicePipelineAgent()\` construction - Extract the simulation ID from the SIP participant attributes: \`\`\`python - async def _check_sim_id(participant): + def _check_sim_id(participant): sim_id = participant.attributes.get("sip.h.X-Coval-Simulation-Id") if sim_id: set_simulation_id(sim_id) - ctx.room.on("participant_connected", lambda p: asyncio.ensure_future(_check_sim_id(p))) - ctx.room.on("participant_attributes_changed", lambda old, p: asyncio.ensure_future(_check_sim_id(p))) + ctx.room.on("participant_connected", _check_sim_id) + ctx.room.on("participant_attributes_changed", lambda old, p: _check_sim_id(p)) \`\`\` +- Do NOT use \`asyncio.ensure_future\` — \`set_simulation_id\` is synchronous, so \`_check_sim_id\` must be a plain \`def\` - After \`await session.start()\`, add \`instrument_session(session)\` - Import \`instrument_session\` from \`coval_tracing\` alongside \`setup_coval_tracing\` and \`set_simulation_id\``,