diff --git a/.env.example b/.env.example index f6af07c3..8231264a 100644 --- a/.env.example +++ b/.env.example @@ -134,7 +134,8 @@ FRAME_ANCESTORS='self',https://ai.jordanmakes.dev # Production CSP frame-ancesto # DEV example: FRAME_ANCESTORS='self',https://ai.jordanmakes.dev,http://localhost:8080,http://localhost:3000 DEFAULT_MODEL=gpt-5-mini # Default model for reflect responses MODEL_PROFILE_CATALOG_PATH= # Optional YAML path for backend model profile catalog (defaults to bundled catalog) -DEFAULT_PROFILE_ID=openai-text-medium # Default backend model profile ID used by catalog resolver +DEFAULT_PROFILE_ID=openai-text-medium # Default response model profile ID used when planner does not select a valid profile +PLANNER_PROFILE_ID=openai-text-fast # Planner model profile ID (can differ from DEFAULT_PROFILE_ID) REALTIME_DEFAULT_MODEL=gpt-realtime-mini # Default model for realtime voice sessions REALTIME_DEFAULT_VOICE=echo # Default voice for realtime voice sessions REALTIME_TURN_DETECTION=server_vad # server_vad or semantic_vad for realtime voice turns diff --git a/AGENTS.md b/AGENTS.md index 5f7e45a4..f8c906a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,11 +72,16 @@ Example: ## Testing & Validation -- Run ESLint by default after any file edit. +- Run `pnpm lint:fix` by default after any file edit (robot/local workflow). +- Use `pnpm lint` as non-mutating verification (CI and final gate checks). +- `pnpm format:check` and `pnpm format:write` are changed-file aware by default. + Optional CI override: set `FORMAT_BASE_REF` to check/write against a base branch range. +- If a file type is intentionally outside prettier/eslint globs (for example `.env.example`), keep formatting consistent manually and call that out in the summary. - Prefer linting only the touched files when the repo tooling supports it cleanly. - If the repo only exposes a broader lint command, run that broader command and note the wider validation scope. - Review: `pnpm review` - Packaging validation for deployable service changes or cleanup that can affect runtime packaging: `docker compose -f deploy/compose.yml build` +- PR readiness gate for large or cross-cutting changes: run both `pnpm review` and `docker compose -f deploy/compose.yml build` before marking the change review-ready. - `@footnote-*` tags: `pnpm validate-footnote-tags` - OpenAPI linking: `pnpm validate-openapi-links` diff --git a/cursor.rules b/cursor.rules index 06e57a42..89e27a34 100644 --- a/cursor.rules +++ b/cursor.rules @@ -188,7 +188,10 @@ const Logger = logger.child({ module: '' }); - Preserve provenance comments, cost tracking, and licensing headers. - Never remove risk annotations or audit metadata without explicit reason. - Maintain backward compatibility unless explicitly breaking for a versioned release. -- After any file edit, run ESLint by default before wrapping up the change. +- After any file edit, run `pnpm lint:fix` by default (robot/local workflow). +- Use `pnpm lint` as the non-mutating CI/final verification gate. +- `pnpm format:check` / `pnpm format:write` operate on changed files by default; set `FORMAT_BASE_REF` in CI to evaluate a base-ref range. +- If a file is outside formatter/parser coverage (for example `.env.example`), preserve style manually and note the limitation in the change summary. - Prefer linting only the touched files when the repo tooling supports it cleanly. - If the repo only exposes a broader lint command, run that broader command and call out the wider scope in the summary. @@ -257,13 +260,15 @@ const Logger = logger.child({ module: '' }); ### Recommended Workflow 1. **Complete implementation** -2. **Run ESLint by default** on the touched files when possible; otherwise run the broader repo lint command and note the wider scope -3. **Run automated validation**: `pnpm review` (validates `@footnote-*` tags, OpenAPI code links, types, linting) -4. **Run packaging validation when the change can affect deployable services**: `docker compose -f deploy/compose.yml build` -5. **Use Cursor's Bugbot (Review PR)** for automated code quality analysis -6. **Use inline chat (`Ctrl+K`)** with project-specific prompts (see `.cursor/footnote-prompts.md`) -7. **Accept suggested simplifications or comments** in-place -8. **Open human PR review** for logic, ethics, and integration focus +2. **Run `pnpm lint:fix` by default** (robot/local cleanup) +3. **Run `pnpm lint`** for non-mutating verification +4. **Run automated validation**: `pnpm review` (validates `@footnote-*` tags, OpenAPI code links, types, linting) +5. **Run packaging validation when the change can affect deployable services**: `docker compose -f deploy/compose.yml build` +6. **PR-readiness gate for large/cross-cutting changes**: run both `pnpm review` and `docker compose -f deploy/compose.yml build` before marking review-ready +7. **Use Cursor's Bugbot (Review PR)** for automated code quality analysis +8. **Use inline chat (`Ctrl+K`)** with project-specific prompts (see `.cursor/footnote-prompts.md`) +9. **Accept suggested simplifications or comments** in-place +10. **Open human PR review** for logic, ethics, and integration focus ### Integration with Existing Tools - **Review pipeline**: `pnpm review` diff --git a/package.json b/package.json index 46bb4453..f32164ff 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,10 @@ "review": "node scripts/review.cjs", "test:annotation-governance": "pnpm exec tsx --test scripts/annotation-schema.test.ts scripts/validate-footnote-tags.test.ts scripts/review.test.ts", "type-check": "pnpm exec tsc --noEmit", + "format:check": "node scripts/format-changed.cjs --check", + "format:write": "node scripts/format-changed.cjs --write", + "lint": "pnpm run format:check && pnpm run lint-check", + "lint:fix": "pnpm run format:write && pnpm exec eslint packages/ --fix", "lint-check": "pnpm exec eslint packages/", "backend:prepare": "pnpm --filter @footnote/config-spec run build:dev && pnpm --filter @footnote/prompts run build:dev" }, diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts index f71748f0..958a8d12 100644 --- a/packages/agent-runtime/src/index.ts +++ b/packages/agent-runtime/src/index.ts @@ -138,6 +138,10 @@ export interface GenerationRequest { * Retrieval settings. Omit this field when search should stay disabled. */ search?: GenerationSearchRequest; + /** + * Optional stable caller/user identifier reserved for future memory flows. + */ + userId?: string; /** * Optional cancellation signal forwarded from backend orchestration. */ @@ -235,6 +239,11 @@ export interface GenerationResult { * Runtime-reported provenance classification, when available. */ provenance?: GenerationProvenance; + /** + * Placeholder memory retrieval payload reserved for future memory features. + * Current flows should leave this undefined. + */ + memoryRetrievals?: []; } /** diff --git a/packages/backend/src/config/sections/modelProfiles.ts b/packages/backend/src/config/sections/modelProfiles.ts index 1903279d..42845ab5 100644 --- a/packages/backend/src/config/sections/modelProfiles.ts +++ b/packages/backend/src/config/sections/modelProfiles.ts @@ -116,6 +116,13 @@ export const buildModelProfilesSection = ( const defaultProfileId = parseOptionalTrimmedString(env.DEFAULT_PROFILE_ID) || envDefaultValues.DEFAULT_PROFILE_ID; + // Response generation fallback profile. + // Used when callers provide no selector or an invalid/disabled selector. + const plannerProfileId = + parseOptionalTrimmedString(env.PLANNER_PROFILE_ID) || + envDefaultValues.PLANNER_PROFILE_ID; + // Planner execution profile. + // Kept separate so planner cost/latency can be tuned independently. let effectiveCatalogPath = preferredCatalogPath; let entries: unknown[] | null = null; @@ -167,6 +174,7 @@ export const buildModelProfilesSection = ( return { defaultProfileId, + plannerProfileId, catalogPath: effectiveCatalogPath, catalog, }; diff --git a/packages/backend/src/config/types.ts b/packages/backend/src/config/types.ts index 918d9581..bc1caa55 100644 --- a/packages/backend/src/config/types.ts +++ b/packages/backend/src/config/types.ts @@ -62,6 +62,7 @@ export type RuntimeConfig = { }; modelProfiles: { defaultProfileId: string; + plannerProfileId: string; catalogPath: string; catalog: ModelProfile[]; }; diff --git a/packages/backend/src/services/chatOrchestrator.ts b/packages/backend/src/services/chatOrchestrator.ts index 81319936..519e846a 100644 --- a/packages/backend/src/services/chatOrchestrator.ts +++ b/packages/backend/src/services/chatOrchestrator.ts @@ -16,6 +16,7 @@ import { type CreateChatServiceOptions, } from './chatService.js'; import { createChatPlanner, type ChatPlan } from './chatPlanner.js'; +import type { ChatGenerationPlan } from './chatGenerationTypes.js'; import { normalizeDiscordConversation } from './chatConversationNormalization.js'; import { resolveActiveProfileOverlayPrompt, @@ -41,6 +42,7 @@ const buildPlannerPayload = ( JSON.stringify({ action: plan.action, modality: plan.modality, + profileId: plan.profileId, reaction: plan.reaction, imageRequest: plan.imageRequest, riskTier: plan.riskTier, @@ -64,31 +66,56 @@ export const createChatOrchestrator = ({ typeof logger.child === 'function' ? logger.child({ module: 'chatOrchestrator' }) : logger; + const catalogProfiles = runtimeConfig.modelProfiles.catalog; + const enabledProfiles = catalogProfiles.filter( + (profile) => profile.enabled + ); + const enabledProfilesById = new Map( + enabledProfiles.map((profile) => [profile.id, profile]) + ); - // Resolve one startup default profile that drives both planner and response - // generation. This keeps routing deterministic unless a future planner - // branch chooses profile ids explicitly. + // Resolver remains authoritative for all profile-id/tier/raw selector + // resolution and fail-open behavior. const modelProfileResolver = createModelProfileResolver({ - catalog: runtimeConfig.modelProfiles.catalog, + catalog: catalogProfiles, defaultProfileId: runtimeConfig.modelProfiles.defaultProfileId, legacyDefaultModel: runtimeConfig.openai.defaultModel, warn: chatOrchestratorLogger, }); - const defaultGenerationProfile = modelProfileResolver.resolve(defaultModel); - // One resolved profile is reused for planner + generation so both paths - // target the same provider/model/capability defaults. + const plannerProfile = modelProfileResolver.resolve( + runtimeConfig.modelProfiles.plannerProfileId + ); + // Startup fallback profile for end-user response generation. + // Planner may override this per-request with one catalog profile id. + const defaultResponseProfile = modelProfileResolver.resolve(defaultModel); + + // Bounded profile payload sent to planner prompt context. + // Description is trimmed to keep planner context predictable. + const plannerProfileOptions = enabledProfiles.map((profile) => ({ + id: profile.id, + description: profile.description.slice(0, 180), + costClass: profile.costClass, + latencyClass: profile.latencyClass, + capabilities: { + canUseSearch: profile.capabilities.canUseSearch, + }, + })); + // TODO(phase-5-provider-tool-registry): Add deterministic fallback ranking + // metadata for planner/executor handoff (for example, preferred + // search-capable backup profile ids by policy). // ChatService handles final message generation and trace/cost wiring. const chatService = createChatService({ generationRuntime, storeTrace, buildResponseMetadata, - defaultModel: defaultGenerationProfile.providerModel, - defaultProvider: defaultGenerationProfile.provider, - defaultCapabilities: defaultGenerationProfile.capabilities, + defaultModel: defaultResponseProfile.providerModel, + defaultProvider: defaultResponseProfile.provider, + defaultCapabilities: defaultResponseProfile.capabilities, recordUsage, }); const chatPlanner = createChatPlanner({ + availableProfiles: plannerProfileOptions, executePlanner: async ({ messages, model, @@ -101,8 +128,8 @@ export const createChatOrchestrator = ({ const plannerResult = await generationRuntime.generate({ messages, model, - provider: defaultGenerationProfile.provider, - capabilities: defaultGenerationProfile.capabilities, + provider: plannerProfile.provider, + capabilities: plannerProfile.capabilities, maxOutputTokens, reasoningEffort, verbosity, @@ -114,7 +141,7 @@ export const createChatOrchestrator = ({ usage: plannerResult.usage, }; }, - defaultModel: defaultGenerationProfile.providerModel, + defaultModel: plannerProfile.providerModel, recordUsage, }); @@ -150,32 +177,81 @@ export const createChatOrchestrator = ({ planned, chatOrchestratorLogger ); + // Planner-selected profile is advisory. + // Runtime resolution here is authoritative and fail-open. + let selectedResponseProfile = defaultResponseProfile; + if (plan.profileId) { + const selectedProfile = enabledProfilesById.get(plan.profileId); + if (selectedProfile) { + selectedResponseProfile = selectedProfile; + } else { + chatOrchestratorLogger.warn( + 'planner selected invalid or disabled profile id; falling back to default profile', + { + selectedProfileId: plan.profileId, + defaultProfileId: defaultResponseProfile.id, + surface: normalizedRequest.surface, + } + ); + } + } + + // Keep selected profile, but drop search when profile capabilities do + // not allow it. This avoids silently forcing a different model. + let generationForExecution: ChatGenerationPlan = plan.generation; + if ( + generationForExecution.search && + !selectedResponseProfile.capabilities.canUseSearch + ) { + // TODO: Before dropping search, attempt rerouting to a search-capable profile. Emit structured fields for observability, maybe: + // - searchFallbackApplied + // - originalProfileId + // - effectiveProfileId + generationForExecution = { + ...generationForExecution, + search: undefined, + }; + chatOrchestratorLogger.warn( + 'planner requested search but selected profile does not support search; running without search', + { + selectedProfileId: selectedResponseProfile.id, + surface: normalizedRequest.surface, + } + ); + } + // Persist the effective profile id in planner payload/snapshot so traces + // reflect what was actually executed. + const executionPlan: ChatPlan = { + ...plan, + generation: generationForExecution, + profileId: selectedResponseProfile.id, + }; // Non-message actions return early and skip model generation. - if (plan.action === 'ignore') { + if (executionPlan.action === 'ignore') { return { action: 'ignore', metadata: null, }; } - if (plan.action === 'react') { + if (executionPlan.action === 'react') { return { action: 'react', - reaction: plan.reaction ?? '👍', + reaction: executionPlan.reaction ?? '👍', metadata: null, }; } - if (plan.action === 'image' && plan.imageRequest) { + if (executionPlan.action === 'image' && executionPlan.imageRequest) { return { action: 'image', - imageRequest: plan.imageRequest, + imageRequest: executionPlan.imageRequest, metadata: null, }; } - if (plan.action === 'image' && !plan.imageRequest) { + if (executionPlan.action === 'image' && !executionPlan.imageRequest) { // Invalid image action should not block response flow. chatOrchestratorLogger.warn( `Chat planner returned image without imageRequest; falling back to ignore. surface=${normalizedRequest.surface} trigger=${normalizedRequest.trigger.kind} latestUserInputLength=${normalizedRequest.latestUserInput.length}` @@ -226,7 +302,7 @@ export const createChatOrchestrator = ({ '// BEGIN Planner Output', '// This planner decision was made by the backend and should be treated as authoritative for this response.', '// ==========', - buildPlannerPayload(plan, surfacePolicy), + buildPlannerPayload(executionPlan, surfacePolicy), '// ==========', '// END Planner Output', '// ==========', @@ -241,26 +317,27 @@ export const createChatOrchestrator = ({ conversationSnapshot: JSON.stringify({ request: normalizedRequest, planner: { - action: plan.action, - modality: plan.modality, - riskTier: plan.riskTier, - generation: plan.generation, + action: executionPlan.action, + modality: executionPlan.modality, + profileId: executionPlan.profileId, + riskTier: executionPlan.riskTier, + generation: executionPlan.generation, ...(surfacePolicy && { surfacePolicy }), }, }), - plannerTemperament: plan.generation.temperament, - riskTier: plan.riskTier, - model: defaultGenerationProfile.providerModel, - provider: defaultGenerationProfile.provider, - capabilities: defaultGenerationProfile.capabilities, - generation: plan.generation, + plannerTemperament: executionPlan.generation.temperament, + riskTier: executionPlan.riskTier, + model: selectedResponseProfile.providerModel, + provider: selectedResponseProfile.provider, + capabilities: selectedResponseProfile.capabilities, + generation: executionPlan.generation, }); // Message action is the only branch that returns provenance metadata. return { action: 'message', message: response.message, - modality: plan.modality, + modality: executionPlan.modality, metadata: response.metadata, }; }; diff --git a/packages/backend/src/services/chatPlanner.ts b/packages/backend/src/services/chatPlanner.ts index 35ed6674..8db99b1c 100644 --- a/packages/backend/src/services/chatPlanner.ts +++ b/packages/backend/src/services/chatPlanner.ts @@ -65,6 +65,7 @@ const UNICODE_SINGLE_EMOJI_PATTERN = export type ChatPlan = { action: ChatPlannerAction; modality: 'text' | 'tts'; + profileId?: string; reaction?: string; imageRequest?: ChatImageRequest; riskTier: RiskTier; @@ -72,9 +73,25 @@ export type ChatPlan = { generation: ChatGenerationPlan; }; +export type ChatPlannerProfileOption = { + // Stable profile key the planner can return in `profileId`. + id: string; + // Human-readable intent hint shown to planner; not used for matching. + description: string; + // Coarse planning hint only; runtime does not enforce cost from this field. + costClass?: 'low' | 'medium' | 'high'; + // Coarse planning hint only; runtime does not enforce latency from this field. + latencyClass?: 'low' | 'medium' | 'high'; + capabilities: { + // Planner hint about whether search is feasible for this profile. + canUseSearch: boolean; + }; +}; + type CreateChatPlannerOptions = { executePlanner: ChatPlannerExecutor; defaultModel?: string; + availableProfiles?: ChatPlannerProfileOption[]; recordUsage?: (record: BackendLLMCostRecord) => void; }; @@ -106,6 +123,7 @@ type ChatPlannerExecutor = ( ) => Promise; type PlannerCandidate = Partial & { + profileId?: unknown; reasoning?: unknown; generation?: Partial & { search?: Partial & { @@ -279,6 +297,17 @@ const stripJsonFences = (content: string): string => .replace(/```$/i, '') .trim(); +const normalizeProfileId = (value: unknown): string | undefined => { + if (typeof value !== 'string') { + return undefined; + } + + // Blank ids are treated as "no planner preference" so orchestrator can + // fail open to its default response profile. + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; +}; + /** * Keeps react actions strict so downstream transport never receives plain text * where an emoji token is expected. @@ -480,6 +509,7 @@ const normalizePlan = ( const normalizedPlan: ChatPlan = { action: actionCandidate, modality: normalizeModality(candidate.modality, capabilities), + profileId: normalizeProfileId(candidate.profileId), riskTier: normalizeRiskTier(candidate.riskTier), reasoning: typeof candidate.reasoning === 'string' && @@ -599,13 +629,35 @@ const normalizePlan = ( export const createChatPlanner = ({ executePlanner, defaultModel = runtimeConfig.openai.defaultModel, + availableProfiles = [], recordUsage = recordBackendLLMUsage, }: CreateChatPlannerOptions) => { + // Keep planner context intentionally narrow. + // We expose only decision-relevant fields, not full raw catalog config. + const plannerProfileContext = + availableProfiles.length > 0 + ? JSON.stringify( + availableProfiles.map((profile) => ({ + id: profile.id, + description: profile.description, + costClass: profile.costClass, + latencyClass: profile.latencyClass, + capabilities: profile.capabilities, + })) + ) + : '[]'; + const planChat = async (request: PostChatRequest): Promise => { const plannerPrompt = renderPrompt('chat.planner.system').content; const requestSummary = summarizeRequest(request); const plannerMessages: RuntimeMessage[] = [ { role: 'system', content: plannerPrompt }, + { + // The prompt instructs planner to choose one id from this list. + // The orchestrator still validates the chosen id before use. + role: 'system', + content: `Planner profile options (bounded): ${plannerProfileContext}`, + }, { role: 'system', content: `Planner request summary: ${requestSummary}`, diff --git a/packages/backend/test/chatOrchestrator.test.ts b/packages/backend/test/chatOrchestrator.test.ts index 869b2fe8..08106b5b 100644 --- a/packages/backend/test/chatOrchestrator.test.ts +++ b/packages/backend/test/chatOrchestrator.test.ts @@ -58,33 +58,35 @@ test('web requests go through planner and are coerced to message when planner pi let finalMessages: Array<{ role: string; content: string }> = []; const orchestrator = createChatOrchestrator({ - generationRuntime: createGenerationRuntime(async ({ messages, maxOutputTokens }) => { - callCount += 1; - if (maxOutputTokens === 700) { + generationRuntime: createGenerationRuntime( + async ({ messages, maxOutputTokens }) => { + callCount += 1; + if (maxOutputTokens === 700) { + return { + text: JSON.stringify({ + action: 'react', + modality: 'text', + reaction: '👍', + riskTier: 'Low', + reasoning: 'A reaction would normally be enough.', + generation: { + reasoningEffort: 'low', + verbosity: 'low', + }, + }), + model: 'gpt-5-mini', + }; + } + + finalMessages = messages; return { - text: JSON.stringify({ - action: 'react', - modality: 'text', - reaction: '👍', - riskTier: 'Low', - reasoning: 'A reaction would normally be enough.', - generation: { - reasoningEffort: 'low', - verbosity: 'low', - }, - }), + text: 'coerced web reply', model: 'gpt-5-mini', + provenance: 'Inferred', + citations: [], }; } - - finalMessages = messages; - return { - text: 'coerced web reply', - model: 'gpt-5-mini', - provenance: 'Inferred', - citations: [], - }; - }), + ), storeTrace: async () => undefined, buildResponseMetadata: () => createMetadata(), defaultModel: 'gpt-5-mini', @@ -124,30 +126,33 @@ test('discord requests preserve non-message planner actions', async () => { let callCount = 0; const orchestrator = createChatOrchestrator({ - generationRuntime: createGenerationRuntime(async ({ maxOutputTokens }) => { - callCount += 1; - if (maxOutputTokens === 700) { - return { - text: JSON.stringify({ - action: 'image', - modality: 'text', - imageRequest: { - prompt: 'draw a chative skyline', - }, - riskTier: 'Low', - reasoning: 'The user explicitly asked for an image.', - generation: { - reasoningEffort: 'low', - verbosity: 'low', - }, - }), - model: 'gpt-5-mini', - }; + generationRuntime: createGenerationRuntime( + async ({ maxOutputTokens }) => { + callCount += 1; + if (maxOutputTokens === 700) { + return { + text: JSON.stringify({ + action: 'image', + modality: 'text', + imageRequest: { + prompt: 'draw a chative skyline', + }, + riskTier: 'Low', + reasoning: + 'The user explicitly asked for an image.', + generation: { + reasoningEffort: 'low', + verbosity: 'low', + }, + }), + model: 'gpt-5-mini', + }; + } + throw new Error( + 'message generation should not run for image actions' + ); } - throw new Error( - 'message generation should not run for image actions' - ); - }), + ), storeTrace: async () => undefined, buildResponseMetadata: () => createMetadata(), defaultModel: 'gpt-5-mini', @@ -163,18 +168,31 @@ test('discord requests preserve non-message planner actions', async () => { test('message plans pass planner generation options into chatService', async () => { let finalMessages: Array<{ role: string; content: string }> = []; - const plannerProfile = + const expectedResponseProfile = runtimeConfig.modelProfiles.catalog.find( (profile) => profile.id === runtimeConfig.modelProfiles.defaultProfileId && profile.enabled ) ?? runtimeConfig.modelProfiles.catalog.find((profile) => profile.enabled); - assert.ok(plannerProfile); + const expectedPlannerProfile = + runtimeConfig.modelProfiles.catalog.find( + (profile) => + profile.id === runtimeConfig.modelProfiles.plannerProfileId && + profile.enabled + ) ?? + runtimeConfig.modelProfiles.catalog.find((profile) => profile.enabled); + assert.ok(expectedResponseProfile); + assert.ok(expectedPlannerProfile); const orchestrator = createChatOrchestrator({ generationRuntime: createGenerationRuntime(async (request) => { if (request.maxOutputTokens === 700) { + assert.equal(request.provider, expectedPlannerProfile.provider); + assert.equal( + request.model, + expectedPlannerProfile.providerModel + ); return { text: JSON.stringify({ action: 'message', @@ -206,7 +224,7 @@ test('message plans pass planner generation options into chatService', async () assert.equal(request.search.intent, 'current_facts'); assert.equal(request.reasoningEffort, 'medium'); assert.equal(request.verbosity, 'medium'); - assert.equal(request.provider, plannerProfile.provider); + assert.equal(request.provider, expectedResponseProfile.provider); assert.equal(request.capabilities?.canUseSearch, true); return { text: 'message with retrieval', @@ -234,43 +252,188 @@ test('message plans pass planner generation options into chatService', async () ); }); -test('discord requests use backend profile overlay when runtime overlay is configured', async () => { - let finalMessages: Array<{ role: string; content: string }> = []; - const originalProfile = runtimeConfig.profile; +test('planner-selected profile id controls response model selection', async () => { + let observedResponseModel: string | undefined; + const selectedProfile = + runtimeConfig.modelProfiles.catalog.find( + (profile) => profile.id === 'openai-text-quality' && profile.enabled + ) ?? + runtimeConfig.modelProfiles.catalog.find((profile) => profile.enabled); + assert.ok(selectedProfile); + + const orchestrator = createChatOrchestrator({ + generationRuntime: createGenerationRuntime(async (request) => { + if (request.maxOutputTokens === 700) { + return { + text: JSON.stringify({ + action: 'message', + modality: 'text', + profileId: selectedProfile.id, + riskTier: 'Low', + reasoning: + 'Use a richer response profile for this request.', + generation: { + reasoningEffort: 'medium', + verbosity: 'medium', + temperament: { + tightness: 4, + rationale: 3, + attribution: 4, + caution: 3, + extent: 4, + }, + }, + }), + model: 'gpt-5-mini', + }; + } + + observedResponseModel = request.model; + return { + text: 'profile-specific reply', + model: request.model, + provenance: 'Inferred', + citations: [], + }; + }), + storeTrace: async () => undefined, + buildResponseMetadata: () => createMetadata(), + defaultModel: runtimeConfig.modelProfiles.defaultProfileId, + recordUsage: () => undefined, + }); + + const response = await orchestrator.runChat(createChatRequest()); + + assert.equal(response.action, 'message'); + assert.equal(observedResponseModel, selectedProfile.providerModel); +}); + +test('invalid planner-selected profile id falls back to default response profile', async () => { + let observedResponseModel: string | undefined; + const warnings: Array<{ message: string; meta?: unknown }> = []; + const originalWarn = logger.warn; + logger.warn = ((message: string, meta?: unknown) => { + warnings.push({ message, meta }); + return logger; + }) as typeof logger.warn; + + const defaultProfile = + runtimeConfig.modelProfiles.catalog.find( + (profile) => + profile.id === runtimeConfig.modelProfiles.defaultProfileId && + profile.enabled + ) ?? + runtimeConfig.modelProfiles.catalog.find((profile) => profile.enabled); + assert.ok(defaultProfile); + + try { + const orchestrator = createChatOrchestrator({ + generationRuntime: createGenerationRuntime(async (request) => { + if (request.maxOutputTokens === 700) { + return { + text: JSON.stringify({ + action: 'message', + modality: 'text', + profileId: 'missing-profile-id', + riskTier: 'Low', + reasoning: 'Try a profile that does not exist.', + generation: { + reasoningEffort: 'low', + verbosity: 'low', + temperament: { + tightness: 4, + rationale: 3, + attribution: 4, + caution: 3, + extent: 4, + }, + }, + }), + model: 'gpt-5-mini', + }; + } + observedResponseModel = request.model; + return { + text: 'fallback profile reply', + model: request.model, + provenance: 'Inferred', + citations: [], + }; + }), + storeTrace: async () => undefined, + buildResponseMetadata: () => createMetadata(), + defaultModel: runtimeConfig.modelProfiles.defaultProfileId, + recordUsage: () => undefined, + }); + + await orchestrator.runChat(createChatRequest()); + } finally { + logger.warn = originalWarn; + } + + assert.equal(observedResponseModel, defaultProfile.providerModel); + const fallbackWarning = warnings.find((warning) => + /planner selected invalid or disabled profile id/i.test(warning.message) + ); + assert.ok(fallbackWarning); +}); + +test('search is dropped when selected profile does not support search', async () => { + let observedSearch: unknown; + const warnings: Array<{ message: string; meta?: unknown }> = []; + const originalWarn = logger.warn; + const originalModelProfiles = runtimeConfig.modelProfiles; const runtimeConfigMutable = runtimeConfig as unknown as { - profile: BotProfileConfig; + modelProfiles: typeof runtimeConfig.modelProfiles; }; - runtimeConfigMutable.profile = { - id: 'ari-vendor', - displayName: 'Ari', - mentionAliases: [], - promptOverlay: { - source: 'inline', - text: 'You are Ari. Speak with clear structure and practical focus.', - path: null, - length: 58, - }, + runtimeConfigMutable.modelProfiles = { + ...runtimeConfig.modelProfiles, + defaultProfileId: 'openai-text-fast', + plannerProfileId: runtimeConfig.modelProfiles.plannerProfileId, + catalog: runtimeConfig.modelProfiles.catalog.map((profile) => + profile.id === 'openai-text-fast' + ? { + ...profile, + capabilities: { + ...profile.capabilities, + canUseSearch: false, + }, + } + : profile + ), }; + logger.warn = ((message: string, meta?: unknown) => { + warnings.push({ message, meta }); + return logger; + }) as typeof logger.warn; + try { const orchestrator = createChatOrchestrator({ - generationRuntime: createGenerationRuntime(async ({ messages, maxOutputTokens }) => { - if (maxOutputTokens === 700) { + generationRuntime: createGenerationRuntime(async (request) => { + if (request.maxOutputTokens === 700) { return { text: JSON.stringify({ action: 'message', modality: 'text', + profileId: 'openai-text-fast', riskTier: 'Low', - reasoning: 'A normal text response is appropriate.', + reasoning: + 'Use search even though selected profile cannot search.', generation: { - reasoningEffort: 'low', - verbosity: 'low', + reasoningEffort: 'medium', + verbosity: 'medium', temperament: { tightness: 4, rationale: 3, attribution: 4, caution: 3, - extent: 3, + extent: 4, + }, + search: { + query: 'latest OpenAI policy update', + contextSize: 'low', + intent: 'current_facts', }, }, }), @@ -278,16 +441,90 @@ test('discord requests use backend profile overlay when runtime overlay is confi }; } - finalMessages = messages; + observedSearch = request.search; return { - text: 'overlay persona reply', - model: 'gpt-5-mini', + text: 'search-disabled reply', + model: request.model, provenance: 'Inferred', citations: [], }; }), storeTrace: async () => undefined, buildResponseMetadata: () => createMetadata(), + defaultModel: runtimeConfig.modelProfiles.defaultProfileId, + recordUsage: () => undefined, + }); + + await orchestrator.runChat(createChatRequest()); + } finally { + logger.warn = originalWarn; + runtimeConfigMutable.modelProfiles = originalModelProfiles; + } + + assert.equal(observedSearch, undefined); + const mismatchWarning = warnings.find((warning) => + /selected profile does not support search/i.test(warning.message) + ); + assert.ok(mismatchWarning); +}); + +test('discord requests use backend profile overlay when runtime overlay is configured', async () => { + let finalMessages: Array<{ role: string; content: string }> = []; + const originalProfile = runtimeConfig.profile; + const runtimeConfigMutable = runtimeConfig as unknown as { + profile: BotProfileConfig; + }; + runtimeConfigMutable.profile = { + id: 'ari-vendor', + displayName: 'Ari', + mentionAliases: [], + promptOverlay: { + source: 'inline', + text: 'You are Ari. Speak with clear structure and practical focus.', + path: null, + length: 58, + }, + }; + + try { + const orchestrator = createChatOrchestrator({ + generationRuntime: createGenerationRuntime( + async ({ messages, maxOutputTokens }) => { + if (maxOutputTokens === 700) { + return { + text: JSON.stringify({ + action: 'message', + modality: 'text', + riskTier: 'Low', + reasoning: + 'A normal text response is appropriate.', + generation: { + reasoningEffort: 'low', + verbosity: 'low', + temperament: { + tightness: 4, + rationale: 3, + attribution: 4, + caution: 3, + extent: 3, + }, + }, + }), + model: 'gpt-5-mini', + }; + } + + finalMessages = messages; + return { + text: 'overlay persona reply', + model: 'gpt-5-mini', + provenance: 'Inferred', + citations: [], + }; + } + ), + storeTrace: async () => undefined, + buildResponseMetadata: () => createMetadata(), defaultModel: 'gpt-5-mini', recordUsage: () => undefined, }); @@ -306,7 +543,10 @@ test('discord requests use backend profile overlay when runtime overlay is confi finalMessages[0]?.content, renderConversationPromptLayers('discord-chat').systemPrompt ); - assert.match(finalMessages[1]?.content ?? '', /BEGIN Bot Profile Overlay/); + assert.match( + finalMessages[1]?.content ?? '', + /BEGIN Bot Profile Overlay/ + ); assert.match(finalMessages[1]?.content ?? '', /Profile ID: ari-vendor/); } finally { runtimeConfigMutable.profile = originalProfile; @@ -339,31 +579,34 @@ test('discord profileId mismatch warns and falls back to backend runtime profile try { const orchestrator = createChatOrchestrator({ - generationRuntime: createGenerationRuntime(async ({ messages, maxOutputTokens }) => { - if (maxOutputTokens === 700) { + generationRuntime: createGenerationRuntime( + async ({ messages, maxOutputTokens }) => { + if (maxOutputTokens === 700) { + return { + text: JSON.stringify({ + action: 'message', + modality: 'text', + riskTier: 'Low', + reasoning: + 'A normal text response is appropriate.', + generation: { + reasoningEffort: 'low', + verbosity: 'low', + }, + }), + model: 'gpt-5-mini', + }; + } + + finalMessages = messages; return { - text: JSON.stringify({ - action: 'message', - modality: 'text', - riskTier: 'Low', - reasoning: 'A normal text response is appropriate.', - generation: { - reasoningEffort: 'low', - verbosity: 'low', - }, - }), + text: 'mismatch fallback reply', model: 'gpt-5-mini', + provenance: 'Inferred', + citations: [], }; } - - finalMessages = messages; - return { - text: 'mismatch fallback reply', - model: 'gpt-5-mini', - provenance: 'Inferred', - citations: [], - }; - }), + ), storeTrace: async () => undefined, buildResponseMetadata: () => createMetadata(), defaultModel: 'gpt-5-mini', @@ -391,9 +634,7 @@ test('discord requests are trimmed/formatted in backend before planner and gener let generationConversation: Array<{ role: string; content: string }> = []; const conversation = Array.from({ length: 30 }, (_, index) => ({ - role: (index % 2 === 0 ? 'user' : 'assistant') as - | 'user' - | 'assistant', + role: (index % 2 === 0 ? 'user' : 'assistant') as 'user' | 'assistant', content: `raw message ${index + 1}`, authorName: index % 2 === 0 ? 'Jordan' : 'Footnote', authorId: index % 2 === 0 ? 'user-1' : 'bot-1', @@ -402,39 +643,41 @@ test('discord requests are trimmed/formatted in backend before planner and gener })); const orchestrator = createChatOrchestrator({ - generationRuntime: createGenerationRuntime(async ({ messages, maxOutputTokens }) => { - if (maxOutputTokens === 700) { - plannerConversation = messages; - return { - text: JSON.stringify({ - action: 'message', - modality: 'text', - riskTier: 'Low', - reasoning: 'Answer with a normal message.', - generation: { - reasoningEffort: 'low', - verbosity: 'low', - temperament: { - tightness: 4, - rationale: 3, - attribution: 4, - caution: 3, - extent: 3, + generationRuntime: createGenerationRuntime( + async ({ messages, maxOutputTokens }) => { + if (maxOutputTokens === 700) { + plannerConversation = messages; + return { + text: JSON.stringify({ + action: 'message', + modality: 'text', + riskTier: 'Low', + reasoning: 'Answer with a normal message.', + generation: { + reasoningEffort: 'low', + verbosity: 'low', + temperament: { + tightness: 4, + rationale: 3, + attribution: 4, + caution: 3, + extent: 3, + }, }, - }, - }), + }), + model: 'gpt-5-mini', + }; + } + + generationConversation = messages; + return { + text: 'backend-normalized reply', model: 'gpt-5-mini', + provenance: 'Inferred', + citations: [], }; } - - generationConversation = messages; - return { - text: 'backend-normalized reply', - model: 'gpt-5-mini', - provenance: 'Inferred', - citations: [], - }; - }), + ), storeTrace: async () => undefined, buildResponseMetadata: () => createMetadata(), defaultModel: 'gpt-5-mini', @@ -452,12 +695,13 @@ test('discord requests are trimmed/formatted in backend before planner and gener assert.equal(response.action, 'message'); assert.equal(response.message, 'backend-normalized reply'); assert.equal( - plannerConversation.filter((message) => message.role !== 'system').length, + plannerConversation.filter((message) => message.role !== 'system') + .length, 24 ); assert.match( - plannerConversation.find((message) => message.role !== 'system')?.content ?? - '', + plannerConversation.find((message) => message.role !== 'system') + ?.content ?? '', /^\[0\] At \d{4}-\d{2}-\d{2} \d{2}:\d{2} Jordan said:/ ); assert.match( diff --git a/packages/backend/test/chatPlanner.test.ts b/packages/backend/test/chatPlanner.test.ts index d463f676..e1be9fb5 100644 --- a/packages/backend/test/chatPlanner.test.ts +++ b/packages/backend/test/chatPlanner.test.ts @@ -9,7 +9,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import type { PostChatRequest } from '@footnote/contracts/web'; -import { createChatPlanner } from '../src/services/chatPlanner.js'; +import { + createChatPlanner, + type ChatPlannerProfileOption, +} from '../src/services/chatPlanner.js'; const createChatRequest = ( overrides: Partial = {} @@ -26,12 +29,16 @@ const createChatRequest = ( ...overrides, }); -const createPlanner = (normalizedText: string) => +const createPlanner = ( + normalizedText: string, + availableProfiles: ChatPlannerProfileOption[] = [] +) => createChatPlanner({ executePlanner: async () => ({ text: normalizedText, model: 'gpt-5-mini', }), + availableProfiles, }); test('chatPlanner parses plain JSON output from the backend-native planner prompt', async () => { @@ -39,6 +46,7 @@ test('chatPlanner parses plain JSON output from the backend-native planner promp JSON.stringify({ action: 'message', modality: 'text', + profileId: 'openai-text-medium', riskTier: 'Low', reasoning: 'The user is asking a question that needs a reply.', generation: { @@ -62,6 +70,7 @@ test('chatPlanner parses plain JSON output from the backend-native planner promp const plan = await planner.planChat(createChatRequest()); assert.equal(plan.action, 'message'); + assert.equal(plan.profileId, 'openai-text-medium'); assert.ok(plan.generation.search); assert.equal( plan.generation.search?.query, @@ -70,6 +79,74 @@ test('chatPlanner parses plain JSON output from the backend-native planner promp assert.equal(plan.generation.search?.intent, 'current_facts'); }); +test('chatPlanner forwards bounded profile options context and normalizes blank profileId', async () => { + let capturedMessages: Array<{ role: string; content: string }> = []; + const availableProfiles: ChatPlannerProfileOption[] = [ + { + id: 'openai-text-fast', + description: 'Fast profile for short planner tasks.', + costClass: 'low', + latencyClass: 'low', + capabilities: { canUseSearch: false }, + }, + { + id: 'openai-text-medium', + description: 'Balanced profile for chat responses.', + costClass: 'medium', + latencyClass: 'medium', + capabilities: { canUseSearch: true }, + }, + ]; + + const planner = createChatPlanner({ + availableProfiles, + executePlanner: async ({ messages }) => { + capturedMessages = messages; + return { + text: JSON.stringify({ + action: 'message', + modality: 'text', + profileId: ' ', + riskTier: 'Low', + reasoning: 'Use safe defaults.', + generation: { + reasoningEffort: 'low', + verbosity: 'low', + temperament: { + tightness: 4, + rationale: 3, + attribution: 4, + caution: 3, + extent: 4, + }, + }, + }), + model: 'gpt-5-mini', + }; + }, + }); + + const plan = await planner.planChat(createChatRequest()); + + assert.equal(plan.profileId, undefined); + const profileContextMessage = + capturedMessages.find((message) => + message.content.startsWith('Planner profile options (bounded): ') + )?.content ?? ''; + assert.match( + profileContextMessage, + /^Planner profile options \(bounded\): \[/ + ); + const encodedProfiles = profileContextMessage.replace( + 'Planner profile options (bounded): ', + '' + ); + const parsedProfiles = JSON.parse( + encodedProfiles + ) as ChatPlannerProfileOption[]; + assert.deepEqual(parsedProfiles, availableProfiles); +}); + test('chatPlanner fails open to a valid fallback generation config when planner JSON is invalid', async () => { const planner = createPlanner('{not-valid-json'); const plan = await planner.planChat(createChatRequest()); @@ -101,12 +178,7 @@ test('repo_explainer search plans normalize repo hints and medium context', asyn query: 'How does Discord provenance work in Footnote?', contextSize: 'low', intent: 'repo_explainer', - repoHints: [ - 'Discord', - 'provenance', - 'discord', - 'wiki', - ], + repoHints: ['Discord', 'provenance', 'discord', 'wiki'], }, }, }) diff --git a/packages/backend/test/chatService.test.ts b/packages/backend/test/chatService.test.ts index 5cfb9f5d..182c67c2 100644 --- a/packages/backend/test/chatService.test.ts +++ b/packages/backend/test/chatService.test.ts @@ -406,10 +406,30 @@ test('runChatMessages forwards planner-selected generation settings to Generatio assert.equal(seenRequest?.verbosity, 'medium'); assert.equal(seenRequest?.provider, 'openai'); assert.equal(seenRequest?.capabilities?.canUseSearch, true); + assert.equal(seenRequest?.userId, undefined); assert.equal(seenRequest?.search?.query, 'latest OpenAI policy update'); assert.equal(seenRequest?.search?.intent, 'current_facts'); }); +test('runChatMessages tolerates optional memory retrievals field on runtime results', async () => { + const chatService = createChatService({ + generationRuntime: createRuntime({ + memoryRetrievals: [], + }), + storeTrace: async () => undefined, + buildResponseMetadata: () => createMetadata(), + defaultModel: 'gpt-5-mini', + recordUsage: () => undefined, + }); + + const response = await chatService.runChatMessages({ + messages: [{ role: 'user', content: 'What changed?' }], + conversationSnapshot: 'What changed?', + }); + + assert.equal(response.message, 'chat response'); +}); + test('runChatMessages drops blank search queries before building the runtime request', async () => { let seenRequest: | import('@footnote/agent-runtime').GenerationRequest diff --git a/packages/backend/test/modelProfileCatalog.test.ts b/packages/backend/test/modelProfileCatalog.test.ts index 9b03e57c..2ada3800 100644 --- a/packages/backend/test/modelProfileCatalog.test.ts +++ b/packages/backend/test/modelProfileCatalog.test.ts @@ -72,6 +72,7 @@ test('buildModelProfilesSection loads valid catalog YAML with profile defaults', ); assert.equal(section.defaultProfileId, 'openai-text-fast'); + assert.equal(section.plannerProfileId, 'openai-text-fast'); assert.equal(section.catalog.length, 1); assert.equal(section.catalog[0]?.providerModel, 'gpt-5-mini'); assert.equal(section.catalog[0]?.capabilities.canUseSearch, true); @@ -107,12 +108,14 @@ test('buildModelProfilesSection warns and skips invalid profile entries', () => { MODEL_PROFILE_CATALOG_PATH: yamlPath, DEFAULT_PROFILE_ID: 'openai-text-fast', + PLANNER_PROFILE_ID: 'openai-text-quality', }, process.cwd(), (message) => warnings.push(message) ); assert.equal(section.catalog.length, 1); + assert.equal(section.plannerProfileId, 'openai-text-quality'); assert.match(warnings.join('\n'), /Ignoring invalid model profile/i); }); @@ -157,7 +160,10 @@ test('buildModelProfilesSection falls back to bundled defaults when custom catal assert.equal(section.catalog.length, 1); assert.equal(section.catalog[0]?.id, 'openai-text-fast'); - assert.match(warnings.join('\n'), /Using bundled model profile catalog fallback/i); + assert.match( + warnings.join('\n'), + /Using bundled model profile catalog fallback/i + ); }); test('buildModelProfilesSection reports catalogPath from the source that produced the final catalog', () => { @@ -202,7 +208,8 @@ test('buildModelProfilesSection reports catalogPath from the source that produce }); test('model profile resolver handles id, tier, and raw selectors with fail-open fallback', () => { - const warnings: Array<{ message: string; meta?: Record }> = []; + const warnings: Array<{ message: string; meta?: Record }> = + []; const resolver = createModelProfileResolver({ catalog: createCatalog(), defaultProfileId: 'openai-text-fast', @@ -217,7 +224,10 @@ test('model profile resolver handles id, tier, and raw selectors with fail-open assert.equal(resolver.resolve('text-fast').id, 'openai-text-fast'); assert.equal(resolver.resolve('openai/gpt-5.2').providerModel, 'gpt-5.2'); assert.equal(resolver.resolve('gpt-5-nano').providerModel, 'gpt-5-nano'); - assert.equal(resolver.resolve('gpt-5-nano').capabilities.canUseSearch, false); + assert.equal( + resolver.resolve('gpt-5-nano').capabilities.canUseSearch, + false + ); assert.equal(resolver.resolve('%%%').id, 'openai-text-fast'); assert.match( warnings.map((warning) => warning.message).join('\n'), @@ -226,7 +236,8 @@ test('model profile resolver handles id, tier, and raw selectors with fail-open }); test('model profile resolver falls back to legacy DEFAULT_MODEL when catalog has no enabled profiles', () => { - const warnings: Array<{ message: string; meta?: Record }> = []; + const warnings: Array<{ message: string; meta?: Record }> = + []; const resolver = createModelProfileResolver({ catalog: [ { @@ -249,7 +260,8 @@ test('model profile resolver falls back to legacy DEFAULT_MODEL when catalog has }); test('model profile resolver synthesizes raw profile when multiple enabled catalog entries share provider/model', () => { - const warnings: Array<{ message: string; meta?: Record }> = []; + const warnings: Array<{ message: string; meta?: Record }> = + []; const duplicateCatalog: ModelProfile[] = [ ...createCatalog(), { diff --git a/packages/config-spec/src/env-spec.ts b/packages/config-spec/src/env-spec.ts index 157df922..0b452c4b 100644 --- a/packages/config-spec/src/env-spec.ts +++ b/packages/config-spec/src/env-spec.ts @@ -1166,6 +1166,19 @@ export const envEntries = [ defaultValue: literal('openai-text-medium'), usedBy: ['packages/backend/src/config.ts'], }), + defineEnv({ + key: 'PLANNER_PROFILE_ID', + owner: 'backend', + stage: 'runtime', + section: 'openai', + required: false, + secret: false, + kind: 'string', + description: + 'Model profile ID used for planner calls so planner and response profiles can differ.', + defaultValue: literal('openai-text-fast'), + usedBy: ['packages/backend/src/config.ts'], + }), defineEnv({ key: 'REALTIME_DEFAULT_MODEL', owner: 'shared', diff --git a/packages/prompts/src/defaults.yaml b/packages/prompts/src/defaults.yaml index 1f522b46..36e2cdcd 100644 --- a/packages/prompts/src/defaults.yaml +++ b/packages/prompts/src/defaults.yaml @@ -210,6 +210,7 @@ chat: { "action": "message" | "react" | "ignore" | "image", "modality": "text" | "tts", + "profileId": "required when action is 'message' (one profile id from Planner profile options)", "reaction"?: "emoji-only text when action is react", "imageRequest"?: { "prompt": string, @@ -278,6 +279,7 @@ chat: - The request summary will tell you which surface is calling. - Web requests still need an internal plan, but the final user-visible output must be suitable for a message response. - When in doubt, pick a safe message response instead of a more surprising action. + - profileId: required when action is "message" (one profile id from Planner profile options). Risk guidance: - Low: everyday or low-stakes requests. diff --git a/scripts/format-changed.cjs b/scripts/format-changed.cjs new file mode 100644 index 00000000..a5610b20 --- /dev/null +++ b/scripts/format-changed.cjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +/* eslint-env node */ +/* global process, console */ +/** + * @description: Runs Prettier on changed files only, with optional base-ref support for CI. + * @footnote-scope: utility + * @footnote-module: FormatChangedScript + * @footnote-risk: low - Formatting scope mistakes may skip style checks but do not alter runtime behavior. + * @footnote-ethics: low - Developer tooling has minimal direct user-facing ethical impact. + */ + +const { spawnSync } = require('node:child_process'); + +const SUPPORTED_EXTENSIONS = new Set([ + '.ts', + '.tsx', + '.js', + '.jsx', + '.cjs', + '.mjs', + '.json', + '.md', + '.yaml', + '.yml', +]); + +const mode = process.argv.includes('--write') ? 'write' : 'check'; +const baseRef = process.env.FORMAT_BASE_REF?.trim(); + +const runGit = (args) => { + const result = spawnSync('git', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status !== 0) { + const message = result.stderr?.trim() || `git ${args.join(' ')}`; + throw new Error(message); + } + return result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +}; + +const isSupportedFile = (path) => { + const lower = path.toLowerCase(); + for (const extension of SUPPORTED_EXTENSIONS) { + if (lower.endsWith(extension)) { + return true; + } + } + return false; +}; + +const listChangedFiles = () => { + if (baseRef) { + return runGit([ + 'diff', + '--name-only', + '--diff-filter=ACMR', + `${baseRef}...HEAD`, + ]); + } + + const unstaged = runGit(['diff', '--name-only', '--diff-filter=ACMR']); + const staged = runGit([ + 'diff', + '--cached', + '--name-only', + '--diff-filter=ACMR', + ]); + const untracked = runGit(['ls-files', '--others', '--exclude-standard']); + return [...unstaged, ...staged, ...untracked]; +}; + +const uniqueSorted = (values) => [...new Set(values)].sort(); + +const runPrettier = (modeArg, files) => { + const pnpmBinary = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; + let prettierResult = spawnSync( + pnpmBinary, + ['exec', 'prettier', `--${modeArg}`, ...files], + { stdio: 'inherit' } + ); + + // On Windows, direct `pnpm.cmd` spawn can fail in some environments. + // Fall back to invoking the active pnpm CLI entrypoint through Node. + if (prettierResult.error && process.env.npm_execpath) { + prettierResult = spawnSync( + process.execPath, + [ + process.env.npm_execpath, + 'exec', + 'prettier', + `--${modeArg}`, + ...files, + ], + { stdio: 'inherit' } + ); + } + + return prettierResult; +}; + +try { + const changedFiles = + uniqueSorted(listChangedFiles()).filter(isSupportedFile); + if (changedFiles.length === 0) { + console.log('No changed files matched Prettier-supported extensions.'); + process.exit(0); + } + + console.log( + `Running Prettier (${mode}) on ${changedFiles.length} changed file(s).` + ); + const prettierResult = runPrettier(mode, changedFiles); + if (prettierResult.error) { + console.error( + `format-changed failed to start prettier: ${prettierResult.error.message}` + ); + process.exit(1); + } + process.exit(prettierResult.status ?? 1); +} catch (error) { + console.error( + `format-changed failed: ${error instanceof Error ? error.message : String(error)}` + ); + process.exit(1); +}