charness 3.1.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +11 -13
- package/DEVELOPMENT.md +27 -6
- package/MIGRATION_1_TO_2.md +1 -1
- package/MIGRATION_RENAME_TO_CHARNESS.md +133 -0
- package/README.md +185 -618
- package/SETUP.md +644 -0
- package/dist/adapters/adapterEventRouting.d.ts +2 -1
- package/dist/adapters/adapterEventRouting.d.ts.map +1 -1
- package/dist/adapters/adapterEventRouting.js +3 -0
- package/dist/adapters/adapterEventRouting.js.map +1 -1
- package/dist/adapters/adapterNames.d.ts +13 -0
- package/dist/adapters/adapterNames.d.ts.map +1 -1
- package/dist/adapters/adapterNames.js +16 -0
- package/dist/adapters/adapterNames.js.map +1 -1
- package/dist/adapters/backendUnavailableError.d.ts +11 -0
- package/dist/adapters/backendUnavailableError.d.ts.map +1 -0
- package/dist/adapters/backendUnavailableError.js +19 -0
- package/dist/adapters/backendUnavailableError.js.map +1 -0
- package/dist/adapters/claudeCliAdapter.d.ts +30 -7
- package/dist/adapters/claudeCliAdapter.d.ts.map +1 -1
- package/dist/adapters/claudeCliAdapter.js +71 -17
- package/dist/adapters/claudeCliAdapter.js.map +1 -1
- package/dist/adapters/claudeJsonStreamAdapter.d.ts +36 -3
- package/dist/adapters/claudeJsonStreamAdapter.d.ts.map +1 -1
- package/dist/adapters/claudeJsonStreamAdapter.js +95 -31
- package/dist/adapters/claudeJsonStreamAdapter.js.map +1 -1
- package/dist/adapters/createAdapter.d.ts +3 -14
- package/dist/adapters/createAdapter.d.ts.map +1 -1
- package/dist/adapters/createAdapter.js +9 -19
- package/dist/adapters/createAdapter.js.map +1 -1
- package/dist/adapters/openCodeAdapter.d.ts +140 -12
- package/dist/adapters/openCodeAdapter.d.ts.map +1 -1
- package/dist/adapters/openCodeAdapter.js +386 -120
- package/dist/adapters/openCodeAdapter.js.map +1 -1
- package/dist/apiErrorRetry.d.ts +70 -6
- package/dist/apiErrorRetry.d.ts.map +1 -1
- package/dist/apiErrorRetry.js +312 -34
- package/dist/apiErrorRetry.js.map +1 -1
- package/dist/apiRetryKick.d.ts +26 -1
- package/dist/apiRetryKick.d.ts.map +1 -1
- package/dist/apiRetryKick.js +41 -2
- package/dist/apiRetryKick.js.map +1 -1
- package/dist/bot.d.ts +2 -1
- package/dist/bot.d.ts.map +1 -1
- package/dist/bot.js +743 -190
- package/dist/bot.js.map +1 -1
- package/dist/cli/bot.d.ts +4 -2
- package/dist/cli/bot.d.ts.map +1 -1
- package/dist/cli/bot.js +11 -3
- package/dist/cli/bot.js.map +1 -1
- package/dist/cli/botEntry.js +3 -1
- package/dist/cli/botEntry.js.map +1 -1
- package/dist/cli/claudeProfiles.d.ts +4 -4
- package/dist/cli/claudeProfiles.js +5 -5
- package/dist/cli/claudeProfiles.js.map +1 -1
- package/dist/cli/envLoader.d.ts +6 -5
- package/dist/cli/envLoader.d.ts.map +1 -1
- package/dist/cli/envLoader.js +8 -27
- package/dist/cli/envLoader.js.map +1 -1
- package/dist/cli/hot.d.ts +9 -3
- package/dist/cli/hot.d.ts.map +1 -1
- package/dist/cli/hot.js +55 -19
- package/dist/cli/hot.js.map +1 -1
- package/dist/cli.js +12 -12
- package/dist/cli.js.map +1 -1
- package/dist/configDirFile.d.ts +73 -0
- package/dist/configDirFile.d.ts.map +1 -0
- package/dist/configDirFile.js +216 -0
- package/dist/configDirFile.js.map +1 -0
- package/dist/connectors/jira/adf.d.ts +21 -0
- package/dist/connectors/jira/adf.d.ts.map +1 -1
- package/dist/connectors/jira/adf.js +39 -0
- package/dist/connectors/jira/adf.js.map +1 -1
- package/dist/connectors/jira/answerSink.d.ts +20 -3
- package/dist/connectors/jira/answerSink.d.ts.map +1 -1
- package/dist/connectors/jira/answerSink.js +126 -13
- package/dist/connectors/jira/answerSink.js.map +1 -1
- package/dist/connectors/jira/client.d.ts +4 -0
- package/dist/connectors/jira/client.d.ts.map +1 -1
- package/dist/connectors/jira/client.js +3 -0
- package/dist/connectors/jira/client.js.map +1 -1
- package/dist/connectors/jira/config.d.ts +10 -1
- package/dist/connectors/jira/config.d.ts.map +1 -1
- package/dist/connectors/jira/config.js +11 -2
- package/dist/connectors/jira/config.js.map +1 -1
- package/dist/connectors/jira/connector.d.ts +7 -1
- package/dist/connectors/jira/connector.d.ts.map +1 -1
- package/dist/connectors/jira/connector.js +23 -4
- package/dist/connectors/jira/connector.js.map +1 -1
- package/dist/connectors/jira/contextLedger.d.ts +6 -0
- package/dist/connectors/jira/contextLedger.d.ts.map +1 -1
- package/dist/connectors/jira/contextLedger.js +16 -0
- package/dist/connectors/jira/contextLedger.js.map +1 -1
- package/dist/connectors/jira/contextPreamble.d.ts.map +1 -1
- package/dist/connectors/jira/contextPreamble.js +6 -3
- package/dist/connectors/jira/contextPreamble.js.map +1 -1
- package/dist/connectors/jira/inbound.d.ts +60 -7
- package/dist/connectors/jira/inbound.d.ts.map +1 -1
- package/dist/connectors/jira/inbound.js +169 -16
- package/dist/connectors/jira/inbound.js.map +1 -1
- package/dist/connectors/jira/issueBlocks.d.ts +6 -1
- package/dist/connectors/jira/issueBlocks.d.ts.map +1 -1
- package/dist/connectors/jira/issueBlocks.js +5 -1
- package/dist/connectors/jira/issueBlocks.js.map +1 -1
- package/dist/connectors/jira/prompt.d.ts +2 -0
- package/dist/connectors/jira/prompt.d.ts.map +1 -1
- package/dist/connectors/jira/prompt.js +16 -0
- package/dist/connectors/jira/prompt.js.map +1 -1
- package/dist/connectors/jira/trigger.d.ts +12 -3
- package/dist/connectors/jira/trigger.d.ts.map +1 -1
- package/dist/connectors/jira/trigger.js +8 -1
- package/dist/connectors/jira/trigger.js.map +1 -1
- package/dist/connectors/jira/triggerLog.d.ts +10 -3
- package/dist/connectors/jira/triggerLog.d.ts.map +1 -1
- package/dist/connectors/jira/triggerLog.js +18 -2
- package/dist/connectors/jira/triggerLog.js.map +1 -1
- package/dist/connectors/telegram/commands/autoContinueLimits.d.ts +2 -2
- package/dist/connectors/telegram/commands/autoContinueLimits.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/autoContinueLimits.js +4 -4
- package/dist/connectors/telegram/commands/autoContinueLimits.js.map +1 -1
- package/dist/connectors/telegram/commands/backendSwitch.d.ts +25 -0
- package/dist/connectors/telegram/commands/backendSwitch.d.ts.map +1 -0
- package/dist/connectors/telegram/commands/backendSwitch.js +104 -0
- package/dist/connectors/telegram/commands/backendSwitch.js.map +1 -0
- package/dist/connectors/telegram/commands/callbackMessage.d.ts +12 -0
- package/dist/connectors/telegram/commands/callbackMessage.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/callbackMessage.js +24 -0
- package/dist/connectors/telegram/commands/callbackMessage.js.map +1 -1
- package/dist/connectors/telegram/commands/callbackToast.d.ts +36 -0
- package/dist/connectors/telegram/commands/callbackToast.d.ts.map +1 -0
- package/dist/connectors/telegram/commands/callbackToast.js +83 -0
- package/dist/connectors/telegram/commands/callbackToast.js.map +1 -0
- package/dist/connectors/telegram/commands/claudeAccounts.d.ts +18 -0
- package/dist/connectors/telegram/commands/claudeAccounts.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/claudeAccounts.js +220 -66
- package/dist/connectors/telegram/commands/claudeAccounts.js.map +1 -1
- package/dist/connectors/telegram/commands/displayModes.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/displayModes.js +4 -2
- package/dist/connectors/telegram/commands/displayModes.js.map +1 -1
- package/dist/connectors/telegram/commands/fallbackChain.d.ts +5 -8
- package/dist/connectors/telegram/commands/fallbackChain.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/fallbackChain.js +183 -193
- package/dist/connectors/telegram/commands/fallbackChain.js.map +1 -1
- package/dist/connectors/telegram/commands/limitsBoard.d.ts +40 -0
- package/dist/connectors/telegram/commands/limitsBoard.d.ts.map +1 -0
- package/dist/connectors/telegram/commands/limitsBoard.js +281 -0
- package/dist/connectors/telegram/commands/limitsBoard.js.map +1 -0
- package/dist/connectors/telegram/commands/modelPage.d.ts +130 -0
- package/dist/connectors/telegram/commands/modelPage.d.ts.map +1 -0
- package/dist/connectors/telegram/commands/modelPage.js +120 -0
- package/dist/connectors/telegram/commands/modelPage.js.map +1 -0
- package/dist/connectors/telegram/commands/modelProviders.d.ts +9 -1
- package/dist/connectors/telegram/commands/modelProviders.d.ts.map +1 -1
- package/dist/connectors/telegram/commands/modelProviders.js +203 -111
- package/dist/connectors/telegram/commands/modelProviders.js.map +1 -1
- package/dist/connectors/telegram/commands/stt.d.ts +32 -0
- package/dist/connectors/telegram/commands/stt.d.ts.map +1 -0
- package/dist/connectors/telegram/commands/stt.js +460 -0
- package/dist/connectors/telegram/commands/stt.js.map +1 -0
- package/dist/effortLevels.d.ts +57 -7
- package/dist/effortLevels.d.ts.map +1 -1
- package/dist/effortLevels.js +65 -9
- package/dist/effortLevels.js.map +1 -1
- package/dist/i18n/de.d.ts.map +1 -1
- package/dist/i18n/de.js +160 -17
- package/dist/i18n/de.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +160 -17
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +160 -17
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/fr.d.ts.map +1 -1
- package/dist/i18n/fr.js +160 -17
- package/dist/i18n/fr.js.map +1 -1
- package/dist/i18n/hi.d.ts.map +1 -1
- package/dist/i18n/hi.js +160 -17
- package/dist/i18n/hi.js.map +1 -1
- package/dist/i18n/ja.d.ts.map +1 -1
- package/dist/i18n/ja.js +160 -17
- package/dist/i18n/ja.js.map +1 -1
- package/dist/i18n/ka.d.ts.map +1 -1
- package/dist/i18n/ka.js +160 -17
- package/dist/i18n/ka.js.map +1 -1
- package/dist/i18n/pt.d.ts.map +1 -1
- package/dist/i18n/pt.js +160 -17
- package/dist/i18n/pt.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +160 -17
- package/dist/i18n/ru.js.map +1 -1
- package/dist/i18n/uk.d.ts.map +1 -1
- package/dist/i18n/uk.js +160 -17
- package/dist/i18n/uk.js.map +1 -1
- package/dist/i18n/uz.d.ts.map +1 -1
- package/dist/i18n/uz.js +160 -17
- package/dist/i18n/uz.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +160 -17
- package/dist/i18n/zh.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/installManager.d.ts +36 -6
- package/dist/installManager.d.ts.map +1 -1
- package/dist/installManager.js +124 -26
- package/dist/installManager.js.map +1 -1
- package/dist/instanceLock.d.ts.map +1 -1
- package/dist/instanceLock.js +10 -8
- package/dist/instanceLock.js.map +1 -1
- package/dist/instanceSettings.d.ts +52 -0
- package/dist/instanceSettings.d.ts.map +1 -0
- package/dist/instanceSettings.js +136 -0
- package/dist/instanceSettings.js.map +1 -0
- package/dist/legacyMigration.d.ts +69 -0
- package/dist/legacyMigration.d.ts.map +1 -0
- package/dist/legacyMigration.js +338 -0
- package/dist/legacyMigration.js.map +1 -0
- package/dist/legacyNames.d.ts +107 -0
- package/dist/legacyNames.d.ts.map +1 -0
- package/dist/legacyNames.js +230 -0
- package/dist/legacyNames.js.map +1 -0
- package/dist/pinnedStatus.d.ts +7 -1
- package/dist/pinnedStatus.d.ts.map +1 -1
- package/dist/pinnedStatus.js +1 -1
- package/dist/pinnedStatus.js.map +1 -1
- package/dist/platform/answerSink.d.ts +5 -0
- package/dist/platform/answerSink.d.ts.map +1 -1
- package/dist/platform/answerSink.js.map +1 -1
- package/dist/requests/answerRequest.d.ts +2 -0
- package/dist/requests/answerRequest.d.ts.map +1 -1
- package/dist/requests/answerRequest.js +3 -1
- package/dist/requests/answerRequest.js.map +1 -1
- package/dist/requests/answerStyle.d.ts +11 -0
- package/dist/requests/answerStyle.d.ts.map +1 -0
- package/dist/requests/answerStyle.js +14 -0
- package/dist/requests/answerStyle.js.map +1 -0
- package/dist/requests/answerTail.d.ts.map +1 -1
- package/dist/requests/answerTail.js +2 -0
- package/dist/requests/answerTail.js.map +1 -1
- package/dist/requests/requestHeader.d.ts.map +1 -1
- package/dist/requests/requestHeader.js +3 -0
- package/dist/requests/requestHeader.js.map +1 -1
- package/dist/requests/requestLedger.d.ts +8 -0
- package/dist/requests/requestLedger.d.ts.map +1 -1
- package/dist/requests/requestLedger.js +20 -2
- package/dist/requests/requestLedger.js.map +1 -1
- package/dist/requests/types.d.ts +3 -1
- package/dist/requests/types.d.ts.map +1 -1
- package/dist/scheduler/mcpSurface.d.ts +9 -1
- package/dist/scheduler/mcpSurface.d.ts.map +1 -1
- package/dist/scheduler/mcpSurface.js +49 -16
- package/dist/scheduler/mcpSurface.js.map +1 -1
- package/dist/shutdown.d.ts +14 -0
- package/dist/shutdown.d.ts.map +1 -1
- package/dist/shutdown.js +16 -0
- package/dist/shutdown.js.map +1 -1
- package/dist/state.d.ts +57 -3
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +67 -3
- package/dist/state.js.map +1 -1
- package/dist/stt/sttAutoInstall.d.ts +39 -0
- package/dist/stt/sttAutoInstall.d.ts.map +1 -0
- package/dist/stt/sttAutoInstall.js +158 -0
- package/dist/stt/sttAutoInstall.js.map +1 -0
- package/dist/stt/sttChildEnv.d.ts +11 -0
- package/dist/stt/sttChildEnv.d.ts.map +1 -0
- package/dist/stt/sttChildEnv.js +30 -0
- package/dist/stt/sttChildEnv.js.map +1 -0
- package/dist/stt/sttConfigEdit.d.ts +71 -0
- package/dist/stt/sttConfigEdit.d.ts.map +1 -0
- package/dist/stt/sttConfigEdit.js +144 -0
- package/dist/stt/sttConfigEdit.js.map +1 -0
- package/dist/stt/sttInstall.d.ts +147 -0
- package/dist/stt/sttInstall.d.ts.map +1 -0
- package/dist/stt/sttInstall.js +491 -0
- package/dist/stt/sttInstall.js.map +1 -0
- package/dist/stt/sttInstallEntry.d.ts +2 -0
- package/dist/stt/sttInstallEntry.d.ts.map +1 -0
- package/dist/stt/sttInstallEntry.js +31 -0
- package/dist/stt/sttInstallEntry.js.map +1 -0
- package/dist/stt/sttInstallReporter.d.ts +45 -0
- package/dist/stt/sttInstallReporter.d.ts.map +1 -0
- package/dist/stt/sttInstallReporter.js +57 -0
- package/dist/stt/sttInstallReporter.js.map +1 -0
- package/dist/stt/sttRuntime.d.ts +35 -0
- package/dist/stt/sttRuntime.d.ts.map +1 -0
- package/dist/stt/sttRuntime.js +73 -0
- package/dist/stt/sttRuntime.js.map +1 -0
- package/dist/stt/sttScreenPlan.d.ts +193 -0
- package/dist/stt/sttScreenPlan.d.ts.map +1 -0
- package/dist/stt/sttScreenPlan.js +541 -0
- package/dist/stt/sttScreenPlan.js.map +1 -0
- package/dist/stt/sttSettings.d.ts +201 -0
- package/dist/stt/sttSettings.d.ts.map +1 -0
- package/dist/stt/sttSettings.js +387 -0
- package/dist/stt/sttSettings.js.map +1 -0
- package/dist/stt/sttSidecarOwner.d.ts +115 -0
- package/dist/stt/sttSidecarOwner.d.ts.map +1 -0
- package/dist/stt/sttSidecarOwner.js +475 -0
- package/dist/stt/sttSidecarOwner.js.map +1 -0
- package/dist/stt/transcribeChain.d.ts +36 -0
- package/dist/stt/transcribeChain.d.ts.map +1 -0
- package/dist/stt/transcribeChain.js +42 -0
- package/dist/stt/transcribeChain.js.map +1 -0
- package/dist/types.d.ts +77 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/adapterSwitchSessionIds.d.ts +32 -0
- package/dist/utils/adapterSwitchSessionIds.d.ts.map +1 -0
- package/dist/utils/adapterSwitchSessionIds.js +44 -0
- package/dist/utils/adapterSwitchSessionIds.js.map +1 -0
- package/dist/utils/backendSwitchPlan.d.ts +63 -0
- package/dist/utils/backendSwitchPlan.d.ts.map +1 -0
- package/dist/utils/backendSwitchPlan.js +73 -0
- package/dist/utils/backendSwitchPlan.js.map +1 -0
- package/dist/utils/claudeAccountPickerPlan.d.ts +12 -1
- package/dist/utils/claudeAccountPickerPlan.d.ts.map +1 -1
- package/dist/utils/claudeAccountPickerPlan.js +20 -1
- package/dist/utils/claudeAccountPickerPlan.js.map +1 -1
- package/dist/utils/claudeProfiles.d.ts +7 -0
- package/dist/utils/claudeProfiles.d.ts.map +1 -1
- package/dist/utils/claudeProfiles.js +19 -0
- package/dist/utils/claudeProfiles.js.map +1 -1
- package/dist/utils/claudeTranscriptContent.d.ts +24 -0
- package/dist/utils/claudeTranscriptContent.d.ts.map +1 -0
- package/dist/utils/claudeTranscriptContent.js +107 -0
- package/dist/utils/claudeTranscriptContent.js.map +1 -0
- package/dist/utils/claudeUsage.d.ts +47 -8
- package/dist/utils/claudeUsage.d.ts.map +1 -1
- package/dist/utils/claudeUsage.js +76 -14
- package/dist/utils/claudeUsage.js.map +1 -1
- package/dist/utils/claudeUsageRefresher.d.ts +8 -1
- package/dist/utils/claudeUsageRefresher.d.ts.map +1 -1
- package/dist/utils/claudeUsageRefresher.js +9 -4
- package/dist/utils/claudeUsageRefresher.js.map +1 -1
- package/dist/utils/effortPickerPlan.d.ts +54 -0
- package/dist/utils/effortPickerPlan.d.ts.map +1 -0
- package/dist/utils/effortPickerPlan.js +56 -0
- package/dist/utils/effortPickerPlan.js.map +1 -0
- package/dist/utils/ensureSessionPlan.d.ts +20 -4
- package/dist/utils/ensureSessionPlan.d.ts.map +1 -1
- package/dist/utils/ensureSessionPlan.js +17 -4
- package/dist/utils/ensureSessionPlan.js.map +1 -1
- package/dist/utils/fallbackBridge.d.ts +6 -2
- package/dist/utils/fallbackBridge.d.ts.map +1 -1
- package/dist/utils/fallbackBridge.js +10 -3
- package/dist/utils/fallbackBridge.js.map +1 -1
- package/dist/utils/fallbackChain.d.ts +36 -0
- package/dist/utils/fallbackChain.d.ts.map +1 -1
- package/dist/utils/fallbackChain.js +65 -1
- package/dist/utils/fallbackChain.js.map +1 -1
- package/dist/utils/fallbackSwitchNotice.d.ts +41 -0
- package/dist/utils/fallbackSwitchNotice.d.ts.map +1 -0
- package/dist/utils/fallbackSwitchNotice.js +75 -0
- package/dist/utils/fallbackSwitchNotice.js.map +1 -0
- package/dist/utils/fileSendService.d.ts +33 -10
- package/dist/utils/fileSendService.d.ts.map +1 -1
- package/dist/utils/fileSendService.js +78 -27
- package/dist/utils/fileSendService.js.map +1 -1
- package/dist/utils/jsonStreamHost.d.ts +39 -0
- package/dist/utils/jsonStreamHost.d.ts.map +1 -1
- package/dist/utils/jsonStreamHost.js +60 -1
- package/dist/utils/jsonStreamHost.js.map +1 -1
- package/dist/utils/limitEpisodeRecovery.d.ts +16 -2
- package/dist/utils/limitEpisodeRecovery.d.ts.map +1 -1
- package/dist/utils/limitEpisodeRecovery.js +44 -3
- package/dist/utils/limitEpisodeRecovery.js.map +1 -1
- package/dist/utils/limitSettings.d.ts +25 -0
- package/dist/utils/limitSettings.d.ts.map +1 -0
- package/dist/utils/limitSettings.js +31 -0
- package/dist/utils/limitSettings.js.map +1 -0
- package/dist/utils/limitsBoardPlan.d.ts +52 -0
- package/dist/utils/limitsBoardPlan.d.ts.map +1 -0
- package/dist/utils/limitsBoardPlan.js +93 -0
- package/dist/utils/limitsBoardPlan.js.map +1 -0
- package/dist/utils/openCodeCompactPlugin.d.ts +11 -3
- package/dist/utils/openCodeCompactPlugin.d.ts.map +1 -1
- package/dist/utils/openCodeCompactPlugin.js +37 -5
- package/dist/utils/openCodeCompactPlugin.js.map +1 -1
- package/dist/utils/openCodeUsage.d.ts +93 -0
- package/dist/utils/openCodeUsage.d.ts.map +1 -0
- package/dist/utils/openCodeUsage.js +248 -0
- package/dist/utils/openCodeUsage.js.map +1 -0
- package/dist/utils/operatorSwitchPlan.d.ts +61 -0
- package/dist/utils/operatorSwitchPlan.d.ts.map +1 -0
- package/dist/utils/operatorSwitchPlan.js +32 -0
- package/dist/utils/operatorSwitchPlan.js.map +1 -0
- package/dist/utils/promptDelivery.d.ts +40 -0
- package/dist/utils/promptDelivery.d.ts.map +1 -1
- package/dist/utils/promptDelivery.js +31 -0
- package/dist/utils/promptDelivery.js.map +1 -1
- package/dist/utils/recvPreviewRedaction.d.ts +14 -4
- package/dist/utils/recvPreviewRedaction.d.ts.map +1 -1
- package/dist/utils/recvPreviewRedaction.js +19 -7
- package/dist/utils/recvPreviewRedaction.js.map +1 -1
- package/dist/utils/startupReadiness.d.ts +3 -3
- package/dist/utils/startupReadiness.d.ts.map +1 -1
- package/dist/utils/startupReadiness.js +2 -2
- package/dist/utils/startupReadiness.js.map +1 -1
- package/dist/utils/threadStatusReport.d.ts +3 -1
- package/dist/utils/threadStatusReport.d.ts.map +1 -1
- package/dist/utils/threadStatusReport.js +4 -3
- package/dist/utils/threadStatusReport.js.map +1 -1
- package/dist/utils/timezone.d.ts +23 -0
- package/dist/utils/timezone.d.ts.map +1 -1
- package/dist/utils/timezone.js +38 -0
- package/dist/utils/timezone.js.map +1 -1
- package/dist/utils/transcribeAudio.d.ts +25 -12
- package/dist/utils/transcribeAudio.d.ts.map +1 -1
- package/dist/utils/transcribeAudio.js +39 -21
- package/dist/utils/transcribeAudio.js.map +1 -1
- package/dist/utils/usageRefreshBudget.d.ts +9 -0
- package/dist/utils/usageRefreshBudget.d.ts.map +1 -0
- package/dist/utils/usageRefreshBudget.js +27 -0
- package/dist/utils/usageRefreshBudget.js.map +1 -0
- package/examples/.env.example +13 -9
- package/examples/docker-compose.yml +20 -17
- package/package.json +4 -1
- package/stt/gigaam_sidecar.py +800 -0
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""GigaAM v3 speech-recognition sidecar for Charness.
|
|
3
|
+
|
|
4
|
+
ONE process: it loads the model at start, binds 127.0.0.1 on an OS-chosen port and serves an OpenAI-compatible
|
|
5
|
+
`POST /v1/audio/transcriptions` plus `GET /health`. The bot starts it on demand, adopts it across hot reloads,
|
|
6
|
+
and it takes itself down when it has been idle (`--idle-minutes`) or when its owner process is gone.
|
|
7
|
+
|
|
8
|
+
Long audio is cut at the quietest 50 ms inside the last 8 s before every 25 s boundary; the chunks are
|
|
9
|
+
recognised in parallel on a pool of threads that share ONE onnxruntime session, shortest request first.
|
|
10
|
+
|
|
11
|
+
It claims the bot's record before it loads the model (an exclusive create, so a second start of the same engine
|
|
12
|
+
exits instead of becoming an unrecorded orphan) and completes it with its port once it listens.
|
|
13
|
+
|
|
14
|
+
Exit codes: 0 clean (SIGTERM, idle, owner gone), 1 the model, the socket, the record or the ready file could not be set up, 2 bad usage
|
|
15
|
+
(including a missing token), 4 another live sidecar already holds the record. Everything it has to say goes to `--log-file`: it is externally parented, so its
|
|
16
|
+
stdio may point at a dead pipe.
|
|
17
|
+
|
|
18
|
+
Protocol and layout: `src/stt/README.md`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import contextlib
|
|
25
|
+
import datetime
|
|
26
|
+
import email.parser
|
|
27
|
+
import email.policy
|
|
28
|
+
import hmac
|
|
29
|
+
import http.server
|
|
30
|
+
import io
|
|
31
|
+
import itertools
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
import logging.handlers
|
|
35
|
+
import os
|
|
36
|
+
import queue
|
|
37
|
+
import signal
|
|
38
|
+
import subprocess
|
|
39
|
+
import sys
|
|
40
|
+
import threading
|
|
41
|
+
import time
|
|
42
|
+
import traceback
|
|
43
|
+
|
|
44
|
+
import numpy as np
|
|
45
|
+
|
|
46
|
+
# Bumped with every change of the sidecar's behaviour; the bot hashes it into the adoption check.
|
|
47
|
+
SCRIPT_VERSION = "1"
|
|
48
|
+
MODEL_NAME = "gigaam-v3-e2e-rnnt"
|
|
49
|
+
MODEL_QUANTIZATION = "int8"
|
|
50
|
+
TOKEN_ENV_NAME = "CHARNESS_STT_SIDECAR_TOKEN"
|
|
51
|
+
|
|
52
|
+
EXIT_OK = 0
|
|
53
|
+
EXIT_SETUP_FAILED = 1
|
|
54
|
+
EXIT_USAGE = 2
|
|
55
|
+
# Another live sidecar already holds the bot's record: this process was a second start of the same engine.
|
|
56
|
+
EXIT_DUPLICATE = 4
|
|
57
|
+
|
|
58
|
+
MAX_CHUNK_SECONDS = 25.0
|
|
59
|
+
SILENCE_SEARCH_SECONDS = 8.0
|
|
60
|
+
SILENCE_WINDOW_SECONDS = 0.05
|
|
61
|
+
# The encoder fails on fewer than ~25 ms of audio; a piece this short holds no word anyway.
|
|
62
|
+
MIN_CHUNK_SECONDS = 0.1
|
|
63
|
+
# The sample rates onnx-asr resamples to 16 kHz itself.
|
|
64
|
+
SUPPORTED_SAMPLE_RATES = (8000, 11025, 16000, 22050, 24000, 32000, 44100, 48000)
|
|
65
|
+
|
|
66
|
+
MAX_BODY_BYTES = 25 * 1024 * 1024
|
|
67
|
+
MAX_HEADER_LINE_BYTES = 8192
|
|
68
|
+
DEFAULT_MAX_SECONDS = 600.0
|
|
69
|
+
SOCKET_TIMEOUT_SECONDS = 120.0
|
|
70
|
+
REQUEST_QUEUE_SIZE = 16
|
|
71
|
+
IDLE_CHECK_SECONDS = 5.0
|
|
72
|
+
OWNER_CHECK_SECONDS = 2.0
|
|
73
|
+
PS_TIMEOUT_SECONDS = 3.0
|
|
74
|
+
SERVE_POLL_SECONDS = 0.5
|
|
75
|
+
LOG_MAX_BYTES = 5 * 1024 * 1024
|
|
76
|
+
LOG_BACKUP_COUNT = 1
|
|
77
|
+
FILE_MODE = 0o600
|
|
78
|
+
# The record's port while the model is still loading: the process is there, but nothing listens yet.
|
|
79
|
+
STARTING_PORT = 0
|
|
80
|
+
CLAIM_ATTEMPTS = 3
|
|
81
|
+
SECONDS_PER_MINUTE = 60.0
|
|
82
|
+
BEARER_PREFIX = "Bearer "
|
|
83
|
+
TRANSCRIPTIONS_PATH = "/v1/audio/transcriptions"
|
|
84
|
+
HEALTH_PATH = "/health"
|
|
85
|
+
RESPONSE_FORMATS = ("json", "text")
|
|
86
|
+
# The pool queue's last position: a stop sentinel sorts after every real request.
|
|
87
|
+
STOP_PRIORITY = sys.maxsize
|
|
88
|
+
|
|
89
|
+
logger = logging.getLogger("gigaam_sidecar")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class RequestError(Exception):
|
|
93
|
+
"""A failure that maps to an HTTP status and a message safe to show the client."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, status: int, message: str):
|
|
96
|
+
super().__init__(message)
|
|
97
|
+
self.status = status
|
|
98
|
+
self.message = message
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class RecognitionTimeout(Exception):
|
|
102
|
+
"""A request ran past `--max-seconds`."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ──────────────────────────────── audio ────────────────────────────────
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def split_at_silences(
|
|
109
|
+
audio: np.ndarray,
|
|
110
|
+
sample_rate: int,
|
|
111
|
+
max_seconds: float = MAX_CHUNK_SECONDS,
|
|
112
|
+
search_seconds: float = SILENCE_SEARCH_SECONDS,
|
|
113
|
+
window_seconds: float = SILENCE_WINDOW_SECONDS,
|
|
114
|
+
) -> list[np.ndarray]:
|
|
115
|
+
"""Cut `audio` into pieces of at most `max_seconds`, each cut at the centre of the lowest-energy window
|
|
116
|
+
inside the last `search_seconds` before the boundary — usually a pause between words. Audio that already
|
|
117
|
+
fits is one piece. The pieces joined give back `audio` exactly."""
|
|
118
|
+
window = max(1, int(window_seconds * sample_rate))
|
|
119
|
+
max_samples = max_seconds * sample_rate
|
|
120
|
+
pieces: list[np.ndarray] = []
|
|
121
|
+
start = 0
|
|
122
|
+
while len(audio) - start > max_samples:
|
|
123
|
+
search_start = start + int((max_seconds - search_seconds) * sample_rate)
|
|
124
|
+
search_end = start + int(max_seconds * sample_rate)
|
|
125
|
+
frames = audio[search_start:search_end]
|
|
126
|
+
frames = frames[: len(frames) // window * window].reshape(-1, window)
|
|
127
|
+
quietest = int(np.argmin((frames ** 2).mean(axis=1)))
|
|
128
|
+
cut = search_start + quietest * window + window // 2
|
|
129
|
+
pieces.append(audio[start:cut])
|
|
130
|
+
start = cut
|
|
131
|
+
pieces.append(audio[start:])
|
|
132
|
+
return pieces
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def decode_audio(data: bytes) -> tuple[np.ndarray, int]:
|
|
136
|
+
"""Decode an uploaded file (Telegram sends OGG/Opus) to mono float32 samples and their sample rate."""
|
|
137
|
+
import soundfile
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
samples, sample_rate = soundfile.read(io.BytesIO(data), dtype="float32")
|
|
141
|
+
except Exception as error: # soundfile raises its own LibsndfileError / RuntimeError
|
|
142
|
+
raise RequestError(400, "the audio file could not be decoded") from error
|
|
143
|
+
if samples.ndim > 1:
|
|
144
|
+
samples = samples.mean(axis=1, dtype=np.float32)
|
|
145
|
+
if sample_rate not in SUPPORTED_SAMPLE_RATES:
|
|
146
|
+
raise RequestError(400, f"unsupported sample rate {sample_rate}")
|
|
147
|
+
return np.ascontiguousarray(samples, dtype=np.float32), int(sample_rate)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def make_recognizer(model):
|
|
151
|
+
"""The pool's recognise callback over a loaded model. A piece too short to hold a word is empty text, not
|
|
152
|
+
an error: the encoder cannot take it."""
|
|
153
|
+
|
|
154
|
+
def recognize(samples: np.ndarray, sample_rate: int) -> str:
|
|
155
|
+
if len(samples) < MIN_CHUNK_SECONDS * sample_rate:
|
|
156
|
+
return ""
|
|
157
|
+
return model.recognize(samples, sample_rate=sample_rate)
|
|
158
|
+
|
|
159
|
+
return recognize
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ──────────────────────────────── pool ────────────────────────────────
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class PoolRequest:
|
|
166
|
+
"""One upload's chunks: collects their texts in order and tells the waiting handler when all are in."""
|
|
167
|
+
|
|
168
|
+
def __init__(self, chunk_count: int, sample_rate: int):
|
|
169
|
+
self.sample_rate = sample_rate
|
|
170
|
+
self._texts: list[str | None] = [None] * chunk_count
|
|
171
|
+
self._remaining = chunk_count
|
|
172
|
+
self._error: Exception | None = None
|
|
173
|
+
self._lock = threading.Lock()
|
|
174
|
+
self._finished = threading.Event()
|
|
175
|
+
|
|
176
|
+
def is_finished(self) -> bool:
|
|
177
|
+
return self._finished.is_set()
|
|
178
|
+
|
|
179
|
+
def complete_chunk(self, index: int, text: str) -> None:
|
|
180
|
+
with self._lock:
|
|
181
|
+
if self._finished.is_set():
|
|
182
|
+
return
|
|
183
|
+
self._texts[index] = text
|
|
184
|
+
self._remaining -= 1
|
|
185
|
+
if self._remaining == 0:
|
|
186
|
+
self._finished.set()
|
|
187
|
+
|
|
188
|
+
def fail(self, error: Exception) -> None:
|
|
189
|
+
"""Fail the whole request; the pool drops its chunks that are still queued."""
|
|
190
|
+
with self._lock:
|
|
191
|
+
if not self._finished.is_set():
|
|
192
|
+
self._error = error
|
|
193
|
+
self._finished.set()
|
|
194
|
+
|
|
195
|
+
def wait(self, timeout_seconds: float) -> str:
|
|
196
|
+
"""The chunks' texts joined in order with single spaces; raises the first chunk error, or
|
|
197
|
+
`RecognitionTimeout` after `timeout_seconds` (the pending chunks are then dropped)."""
|
|
198
|
+
if not self._finished.wait(timeout_seconds):
|
|
199
|
+
self.fail(RecognitionTimeout())
|
|
200
|
+
if self._error is not None:
|
|
201
|
+
raise self._error
|
|
202
|
+
return " ".join(text.strip() for text in self._texts if text and text.strip())
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class RecognitionPool:
|
|
206
|
+
"""W threads over one priority queue. A request's priority is its total chunk count, so a short voice
|
|
207
|
+
overtakes the rest of a long one; equal priorities keep arrival order."""
|
|
208
|
+
|
|
209
|
+
def __init__(self, recognize, workers: int):
|
|
210
|
+
self._recognize = recognize
|
|
211
|
+
self._workers = workers
|
|
212
|
+
self._queue: queue.PriorityQueue = queue.PriorityQueue()
|
|
213
|
+
self._sequence = itertools.count()
|
|
214
|
+
self._threads: list[threading.Thread] = []
|
|
215
|
+
|
|
216
|
+
def start(self) -> None:
|
|
217
|
+
for number in range(self._workers):
|
|
218
|
+
thread = threading.Thread(target=self._work, name=f"stt-worker-{number}", daemon=True)
|
|
219
|
+
thread.start()
|
|
220
|
+
self._threads.append(thread)
|
|
221
|
+
|
|
222
|
+
def stop(self) -> None:
|
|
223
|
+
for _ in self._threads:
|
|
224
|
+
self._queue.put((STOP_PRIORITY, next(self._sequence), None, 0, None))
|
|
225
|
+
|
|
226
|
+
def queued_chunks(self) -> int:
|
|
227
|
+
"""Chunks waiting for a worker (approximate: the queue moves while it is read)."""
|
|
228
|
+
return self._queue.qsize()
|
|
229
|
+
|
|
230
|
+
def submit(self, chunks: list[np.ndarray], sample_rate: int) -> PoolRequest:
|
|
231
|
+
request = PoolRequest(len(chunks), sample_rate)
|
|
232
|
+
for index, chunk in enumerate(chunks):
|
|
233
|
+
self._queue.put((len(chunks), next(self._sequence), request, index, chunk))
|
|
234
|
+
return request
|
|
235
|
+
|
|
236
|
+
def _work(self) -> None:
|
|
237
|
+
while True:
|
|
238
|
+
_, _, request, index, samples = self._queue.get()
|
|
239
|
+
if request is None:
|
|
240
|
+
return
|
|
241
|
+
if request.is_finished():
|
|
242
|
+
continue
|
|
243
|
+
try:
|
|
244
|
+
text = self._recognize(samples, request.sample_rate)
|
|
245
|
+
except Exception as error: # one broken chunk fails its request only
|
|
246
|
+
logger.error("chunk %d failed: %s", index, traceback.format_exc())
|
|
247
|
+
request.fail(error)
|
|
248
|
+
continue
|
|
249
|
+
request.complete_chunk(index, text)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ──────────────────────────────── HTTP ────────────────────────────────
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def parse_multipart(content_type: str, body: bytes) -> tuple[dict[str, str], bytes | None]:
|
|
256
|
+
"""The text fields and the `file` part of a multipart/form-data body (`cgi` is gone from Python 3.13)."""
|
|
257
|
+
header = f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode("latin-1", "replace")
|
|
258
|
+
message = email.parser.BytesParser(policy=email.policy.HTTP).parsebytes(header + body)
|
|
259
|
+
if message.get_content_type() != "multipart/form-data" or not message.is_multipart():
|
|
260
|
+
raise RequestError(400, "expected a multipart/form-data body")
|
|
261
|
+
fields: dict[str, str] = {}
|
|
262
|
+
file_bytes: bytes | None = None
|
|
263
|
+
for part in message.iter_parts():
|
|
264
|
+
name = part.get_param("name", header="content-disposition")
|
|
265
|
+
if not isinstance(name, str):
|
|
266
|
+
continue
|
|
267
|
+
payload = part.get_payload(decode=True) or b""
|
|
268
|
+
if name == "file":
|
|
269
|
+
file_bytes = payload
|
|
270
|
+
else:
|
|
271
|
+
fields[name] = payload.decode("utf-8", "replace")
|
|
272
|
+
return fields, file_bytes
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def read_exact(stream, size: int) -> bytes:
|
|
276
|
+
data = stream.read(size)
|
|
277
|
+
if len(data) != size:
|
|
278
|
+
raise RequestError(400, "the request body ended early")
|
|
279
|
+
return data
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def read_chunked_body(stream, limit: int) -> bytes:
|
|
283
|
+
"""Decode `Transfer-Encoding: chunked` — Node's form-data uploads without a Content-Length."""
|
|
284
|
+
body = bytearray()
|
|
285
|
+
while True:
|
|
286
|
+
size_line = stream.readline(MAX_HEADER_LINE_BYTES)
|
|
287
|
+
try:
|
|
288
|
+
size = int(size_line.split(b";", 1)[0].strip(), 16)
|
|
289
|
+
except ValueError:
|
|
290
|
+
raise RequestError(400, "malformed chunked body") from None
|
|
291
|
+
if size == 0:
|
|
292
|
+
break
|
|
293
|
+
if len(body) + size > limit:
|
|
294
|
+
raise RequestError(413, "the audio file is too large")
|
|
295
|
+
body += read_exact(stream, size)
|
|
296
|
+
read_exact(stream, 2) # the CRLF that ends the chunk
|
|
297
|
+
while stream.readline(MAX_HEADER_LINE_BYTES) not in (b"\r\n", b"\n", b""):
|
|
298
|
+
pass # trailers
|
|
299
|
+
return bytes(body)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def read_request_body(handler: http.server.BaseHTTPRequestHandler, limit: int) -> bytes:
|
|
303
|
+
if handler.headers.get("Transfer-Encoding", "").lower() == "chunked":
|
|
304
|
+
return read_chunked_body(handler.rfile, limit)
|
|
305
|
+
try:
|
|
306
|
+
length = int(handler.headers.get("Content-Length", "0"))
|
|
307
|
+
except ValueError:
|
|
308
|
+
raise RequestError(400, "malformed Content-Length") from None
|
|
309
|
+
if length < 0:
|
|
310
|
+
raise RequestError(400, "malformed Content-Length")
|
|
311
|
+
if length > limit:
|
|
312
|
+
raise RequestError(413, "the audio file is too large")
|
|
313
|
+
return read_exact(handler.rfile, length)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class SidecarContext:
|
|
317
|
+
"""What the handlers and the monitors share."""
|
|
318
|
+
|
|
319
|
+
def __init__(
|
|
320
|
+
self, token: str, pool: RecognitionPool, workers: int, idle_seconds: float, max_seconds: float, clock=time.monotonic
|
|
321
|
+
):
|
|
322
|
+
self.token = token
|
|
323
|
+
self.pool = pool
|
|
324
|
+
self.workers = workers
|
|
325
|
+
self.idle_seconds = idle_seconds
|
|
326
|
+
self.max_seconds = max_seconds
|
|
327
|
+
self.clock = clock
|
|
328
|
+
self.lock = threading.Lock()
|
|
329
|
+
self.busy_requests = 0
|
|
330
|
+
self.last_activity = clock()
|
|
331
|
+
self.is_exiting = False
|
|
332
|
+
|
|
333
|
+
def enter_request(self) -> bool:
|
|
334
|
+
"""Count a transcription in flight; false once the idle exit is decided (the client falls back)."""
|
|
335
|
+
with self.lock:
|
|
336
|
+
if self.is_exiting:
|
|
337
|
+
return False
|
|
338
|
+
self.busy_requests += 1
|
|
339
|
+
return True
|
|
340
|
+
|
|
341
|
+
def leave_request(self) -> None:
|
|
342
|
+
with self.lock:
|
|
343
|
+
self.busy_requests -= 1
|
|
344
|
+
self.last_activity = self.clock()
|
|
345
|
+
|
|
346
|
+
def is_idle(self) -> bool:
|
|
347
|
+
"""No transcription in flight, and none for `idle_seconds`. Never true when the idle exit is off (0)."""
|
|
348
|
+
with self.lock:
|
|
349
|
+
return self._is_idle_locked()
|
|
350
|
+
|
|
351
|
+
def _is_idle_locked(self) -> bool:
|
|
352
|
+
return self.idle_seconds > 0 and self.busy_requests == 0 and self.clock() - self.last_activity >= self.idle_seconds
|
|
353
|
+
|
|
354
|
+
def decide_idle_exit(self, stop_accepting, resume_accepting) -> bool:
|
|
355
|
+
"""The idle exit's two-step decision. Stop taking connections first, then re-check under the lock: a request
|
|
356
|
+
that slipped in meanwhile keeps the process alive (accepting resumes), otherwise the exit is final and any
|
|
357
|
+
later request is refused with 503 so its client falls back. True when the process should exit."""
|
|
358
|
+
if not self.is_idle():
|
|
359
|
+
return False
|
|
360
|
+
stop_accepting()
|
|
361
|
+
with self.lock:
|
|
362
|
+
self.is_exiting = self._is_idle_locked()
|
|
363
|
+
is_exit_decided = self.is_exiting
|
|
364
|
+
if not is_exit_decided:
|
|
365
|
+
resume_accepting()
|
|
366
|
+
return is_exit_decided
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class SidecarServer(http.server.ThreadingHTTPServer):
|
|
370
|
+
daemon_threads = True
|
|
371
|
+
request_queue_size = REQUEST_QUEUE_SIZE
|
|
372
|
+
|
|
373
|
+
def __init__(self, address, context: SidecarContext):
|
|
374
|
+
super().__init__(address, SidecarHandler)
|
|
375
|
+
self.context = context
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class SidecarHandler(http.server.BaseHTTPRequestHandler):
|
|
379
|
+
server_version = "CharnessSttSidecar"
|
|
380
|
+
protocol_version = "HTTP/1.1"
|
|
381
|
+
timeout = SOCKET_TIMEOUT_SECONDS
|
|
382
|
+
|
|
383
|
+
def log_message(self, format: str, *args) -> None: # noqa: A002 - the stdlib signature
|
|
384
|
+
logger.info("%s %s", self.address_string(), format % args)
|
|
385
|
+
|
|
386
|
+
def do_GET(self) -> None:
|
|
387
|
+
if self.path != HEALTH_PATH:
|
|
388
|
+
self._reply_error(404, "not found")
|
|
389
|
+
elif not self._check_token():
|
|
390
|
+
self._reply_error(401, "missing or wrong token")
|
|
391
|
+
else:
|
|
392
|
+
context = self.server.context
|
|
393
|
+
self._reply_json(200, {
|
|
394
|
+
"status": "ok",
|
|
395
|
+
"busyRequests": context.busy_requests,
|
|
396
|
+
"queuedChunks": context.pool.queued_chunks(),
|
|
397
|
+
"workers": context.workers,
|
|
398
|
+
"idleMinutes": context.idle_seconds / SECONDS_PER_MINUTE,
|
|
399
|
+
"version": SCRIPT_VERSION,
|
|
400
|
+
"pid": os.getpid(),
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
def do_POST(self) -> None:
|
|
404
|
+
if self.path != TRANSCRIPTIONS_PATH:
|
|
405
|
+
self._reply_error(404, "not found")
|
|
406
|
+
return
|
|
407
|
+
if not self._check_token():
|
|
408
|
+
self._reply_error(401, "missing or wrong token")
|
|
409
|
+
return
|
|
410
|
+
context = self.server.context
|
|
411
|
+
if not context.enter_request():
|
|
412
|
+
self._reply_error(503, "the sidecar is shutting down")
|
|
413
|
+
return
|
|
414
|
+
try:
|
|
415
|
+
self._transcribe(context)
|
|
416
|
+
except RequestError as error:
|
|
417
|
+
self._reply_error(error.status, error.message)
|
|
418
|
+
except RecognitionTimeout:
|
|
419
|
+
self._reply_error(504, "recognition took too long")
|
|
420
|
+
except Exception as error:
|
|
421
|
+
logger.error("request failed: %s", traceback.format_exc())
|
|
422
|
+
self._reply_error(500, f"recognition failed: {type(error).__name__}")
|
|
423
|
+
finally:
|
|
424
|
+
context.leave_request()
|
|
425
|
+
|
|
426
|
+
def _check_token(self) -> bool:
|
|
427
|
+
header = self.headers.get("Authorization", "")
|
|
428
|
+
if not header.startswith(BEARER_PREFIX):
|
|
429
|
+
return False
|
|
430
|
+
return hmac.compare_digest(header[len(BEARER_PREFIX):].encode(), self.server.context.token.encode())
|
|
431
|
+
|
|
432
|
+
def _transcribe(self, context: SidecarContext) -> None:
|
|
433
|
+
started = time.monotonic()
|
|
434
|
+
body = read_request_body(self, MAX_BODY_BYTES)
|
|
435
|
+
fields, file_bytes = parse_multipart(self.headers.get("Content-Type", ""), body)
|
|
436
|
+
if not file_bytes:
|
|
437
|
+
raise RequestError(400, "the file field is required")
|
|
438
|
+
response_format = fields.get("response_format", "json")
|
|
439
|
+
if response_format not in RESPONSE_FORMATS:
|
|
440
|
+
raise RequestError(400, f"response_format must be one of {', '.join(RESPONSE_FORMATS)}")
|
|
441
|
+
samples, sample_rate = decode_audio(file_bytes)
|
|
442
|
+
chunks = split_at_silences(samples, sample_rate)
|
|
443
|
+
request = context.pool.submit(chunks, sample_rate)
|
|
444
|
+
text = request.wait(context.max_seconds)
|
|
445
|
+
logger.info(
|
|
446
|
+
"transcribed %.1fs of audio in %d chunk(s), %.2fs", len(samples) / sample_rate, len(chunks), time.monotonic() - started
|
|
447
|
+
)
|
|
448
|
+
if response_format == "text":
|
|
449
|
+
self._reply(200, text.encode("utf-8"), "text/plain; charset=utf-8")
|
|
450
|
+
else:
|
|
451
|
+
self._reply_json(200, {"text": text})
|
|
452
|
+
|
|
453
|
+
def _reply_json(self, status: int, payload: dict) -> None:
|
|
454
|
+
self._reply(status, json.dumps(payload, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
|
|
455
|
+
|
|
456
|
+
def _reply_error(self, status: int, message: str) -> None:
|
|
457
|
+
self._reply_json(status, {"error": {"message": message}})
|
|
458
|
+
|
|
459
|
+
def _reply(self, status: int, payload: bytes, content_type: str) -> None:
|
|
460
|
+
self.send_response(status)
|
|
461
|
+
self.send_header("Content-Type", content_type)
|
|
462
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
463
|
+
# One request per connection: an error may leave an unread body behind.
|
|
464
|
+
self.send_header("Connection", "close")
|
|
465
|
+
self.end_headers()
|
|
466
|
+
self.wfile.write(payload)
|
|
467
|
+
self.close_connection = True
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# ──────────────────────────────── process ────────────────────────────────
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def write_json_atomic(path: str, payload: dict) -> None:
|
|
474
|
+
temporary = f"{path}.{os.getpid()}.tmp"
|
|
475
|
+
with open(os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, FILE_MODE), "w", encoding="utf-8") as stream:
|
|
476
|
+
json.dump(payload, stream)
|
|
477
|
+
os.replace(temporary, path)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def remove_record_if_owned(record_path: str, own_pid: int) -> bool:
|
|
481
|
+
"""Delete the bot's record of a sidecar only when it names THIS process — a newer generation's record
|
|
482
|
+
stays. A missing or unreadable record is not an error."""
|
|
483
|
+
try:
|
|
484
|
+
with open(record_path, encoding="utf-8") as stream:
|
|
485
|
+
record_pid = json.load(stream).get("pid")
|
|
486
|
+
if record_pid != own_pid:
|
|
487
|
+
return False
|
|
488
|
+
os.unlink(record_path)
|
|
489
|
+
return True
|
|
490
|
+
except (OSError, ValueError, AttributeError):
|
|
491
|
+
return False
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def read_process_start_token(pid: int) -> str | None:
|
|
495
|
+
"""The token the bot records to tell a process from a reused pid: field 22 of `/proc/<pid>/stat` on Linux
|
|
496
|
+
(None when gone or a zombie), elsewhere the `ps` text the bot reads (`lstart` + `command`)."""
|
|
497
|
+
if not sys.platform.startswith("linux"):
|
|
498
|
+
try:
|
|
499
|
+
result = subprocess.run(
|
|
500
|
+
["ps", "-o", "lstart=", "-o", "command=", "-p", str(pid)],
|
|
501
|
+
capture_output=True, text=True, timeout=PS_TIMEOUT_SECONDS, check=False,
|
|
502
|
+
)
|
|
503
|
+
except (OSError, subprocess.SubprocessError):
|
|
504
|
+
return None
|
|
505
|
+
return result.stdout.strip() or None
|
|
506
|
+
try:
|
|
507
|
+
with open(f"/proc/{pid}/stat", encoding="utf-8") as stream:
|
|
508
|
+
stat = stream.read()
|
|
509
|
+
except OSError:
|
|
510
|
+
return None
|
|
511
|
+
fields = stat[stat.rfind(")") + 1:].split()
|
|
512
|
+
if not fields or fields[0] == "Z" or len(fields) < 20:
|
|
513
|
+
return None
|
|
514
|
+
return fields[19]
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def build_record(args: argparse.Namespace, port: int, token: str, start_token: str) -> dict:
|
|
518
|
+
"""What the bot adopts this process from: the sidecar writes it ITSELF, so a bot worker that is replaced while it
|
|
519
|
+
waits for the ready file leaves a process the next worker can find (the token is only in this process and in this
|
|
520
|
+
file). `port` is STARTING_PORT until the server listens."""
|
|
521
|
+
return {
|
|
522
|
+
"pid": os.getpid(),
|
|
523
|
+
"startToken": start_token,
|
|
524
|
+
"port": port,
|
|
525
|
+
"token": token,
|
|
526
|
+
"configHash": args.config_hash,
|
|
527
|
+
"ownerPid": args.owner_pid,
|
|
528
|
+
"ownerStartToken": args.owner_start_token or "",
|
|
529
|
+
"startedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def read_record(record_path: str) -> dict | None:
|
|
534
|
+
try:
|
|
535
|
+
with open(record_path, encoding="utf-8") as stream:
|
|
536
|
+
record = json.load(stream)
|
|
537
|
+
except (OSError, ValueError):
|
|
538
|
+
return None
|
|
539
|
+
return record if isinstance(record, dict) else None
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def is_other_sidecar_alive(record: dict | None) -> bool:
|
|
543
|
+
"""The record names a process that is not this one and is still the process it was (the start token tells a
|
|
544
|
+
reused pid)."""
|
|
545
|
+
if record is None:
|
|
546
|
+
return False
|
|
547
|
+
pid = record.get("pid")
|
|
548
|
+
if not isinstance(pid, int) or pid == os.getpid():
|
|
549
|
+
return False
|
|
550
|
+
return read_process_start_token(pid) == record.get("startToken")
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def claim_record(args: argparse.Namespace, token: str, start_token: str) -> bool:
|
|
554
|
+
"""Take the bot's record BEFORE the slow model load, with an exclusive create (a hard link of a finished file, so
|
|
555
|
+
a reader never sees half a record): of two sidecars started at once — a bot worker replaced mid-start asks again
|
|
556
|
+
— only the first is ever recorded, and the second learns it here instead of becoming an unrecorded orphan.
|
|
557
|
+
False: another live sidecar holds the record. A record of a dead process is replaced."""
|
|
558
|
+
temporary = f"{args.record_file}.{os.getpid()}.claim"
|
|
559
|
+
with open(os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, FILE_MODE), "w", encoding="utf-8") as stream:
|
|
560
|
+
json.dump(build_record(args, STARTING_PORT, token, start_token), stream)
|
|
561
|
+
try:
|
|
562
|
+
for _ in range(CLAIM_ATTEMPTS):
|
|
563
|
+
try:
|
|
564
|
+
os.link(temporary, args.record_file)
|
|
565
|
+
return True
|
|
566
|
+
except FileExistsError:
|
|
567
|
+
if is_other_sidecar_alive(read_record(args.record_file)):
|
|
568
|
+
return False
|
|
569
|
+
with contextlib.suppress(FileNotFoundError):
|
|
570
|
+
os.unlink(args.record_file)
|
|
571
|
+
raise OSError(f"could not claim {args.record_file}")
|
|
572
|
+
finally:
|
|
573
|
+
with contextlib.suppress(OSError):
|
|
574
|
+
os.unlink(temporary)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def publish_port(args: argparse.Namespace, port: int, token: str, start_token: str) -> bool:
|
|
578
|
+
"""Put the listening port into the record, while the record is still this process's (the bot may have stopped
|
|
579
|
+
this start and taken it away)."""
|
|
580
|
+
current = read_record(args.record_file)
|
|
581
|
+
if current is None or current.get("pid") != os.getpid():
|
|
582
|
+
return False
|
|
583
|
+
write_json_atomic(args.record_file, build_record(args, port, token, start_token))
|
|
584
|
+
return True
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def is_owner_alive(owner_pid: int, owner_start_token: str | None) -> bool:
|
|
588
|
+
try:
|
|
589
|
+
os.kill(owner_pid, 0)
|
|
590
|
+
except ProcessLookupError:
|
|
591
|
+
return False
|
|
592
|
+
except PermissionError:
|
|
593
|
+
pass # alive, but another user's
|
|
594
|
+
if owner_start_token is None or not sys.platform.startswith("linux"):
|
|
595
|
+
return True
|
|
596
|
+
return read_process_start_token(owner_pid) == owner_start_token
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def setup_logging(log_file: str) -> None:
|
|
600
|
+
handler = logging.handlers.RotatingFileHandler(
|
|
601
|
+
log_file, maxBytes=LOG_MAX_BYTES, backupCount=LOG_BACKUP_COUNT, encoding="utf-8"
|
|
602
|
+
)
|
|
603
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
|
604
|
+
logger.addHandler(handler)
|
|
605
|
+
logger.setLevel(logging.INFO)
|
|
606
|
+
threading.excepthook = lambda args: logger.error(
|
|
607
|
+
"thread %s crashed: %s", args.thread.name if args.thread else "?", "".join(traceback.format_exception(*args[:3]))
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def detach_stdio() -> None:
|
|
612
|
+
"""The parent may be gone and its pipes with it: point the standard streams at /dev/null."""
|
|
613
|
+
devnull = os.open(os.devnull, os.O_RDWR)
|
|
614
|
+
for descriptor in (0, 1, 2):
|
|
615
|
+
os.dup2(devnull, descriptor)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def load_model(threads: int):
|
|
619
|
+
import onnx_asr
|
|
620
|
+
import onnxruntime
|
|
621
|
+
|
|
622
|
+
options = onnxruntime.SessionOptions()
|
|
623
|
+
options.intra_op_num_threads = threads
|
|
624
|
+
options.inter_op_num_threads = 1
|
|
625
|
+
return onnx_asr.load_model(MODEL_NAME, quantization=MODEL_QUANTIZATION, sess_options=options)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def run_check(args: argparse.Namespace) -> int:
|
|
629
|
+
"""Load the model, recognise 1 s of silence and print the versions — the installer's verify step."""
|
|
630
|
+
from importlib import metadata
|
|
631
|
+
import platform
|
|
632
|
+
|
|
633
|
+
try:
|
|
634
|
+
import soundfile
|
|
635
|
+
|
|
636
|
+
model = load_model(args.threads)
|
|
637
|
+
silence = np.zeros(16000, dtype=np.float32)
|
|
638
|
+
text = make_recognizer(model)(silence, 16000)
|
|
639
|
+
is_opus_readable = "OPUS" in soundfile.available_subtypes("OGG")
|
|
640
|
+
if not is_opus_readable:
|
|
641
|
+
raise RuntimeError("libsndfile cannot read OGG/Opus")
|
|
642
|
+
report = {
|
|
643
|
+
"ok": True,
|
|
644
|
+
"scriptVersion": SCRIPT_VERSION,
|
|
645
|
+
"model": MODEL_NAME,
|
|
646
|
+
"python": platform.python_version(),
|
|
647
|
+
"onnxAsr": metadata.version("onnx-asr"),
|
|
648
|
+
"onnxruntime": metadata.version("onnxruntime"),
|
|
649
|
+
"soundfile": metadata.version("soundfile"),
|
|
650
|
+
"libsndfile": soundfile.__libsndfile_version__,
|
|
651
|
+
"numpy": metadata.version("numpy"),
|
|
652
|
+
"silenceText": text,
|
|
653
|
+
}
|
|
654
|
+
print(json.dumps(report))
|
|
655
|
+
return EXIT_OK
|
|
656
|
+
except Exception as error:
|
|
657
|
+
print(json.dumps({"ok": False, "error": f"{type(error).__name__}: {error}"}))
|
|
658
|
+
return EXIT_SETUP_FAILED
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def run_serve(args: argparse.Namespace, token: str) -> int:
|
|
662
|
+
setup_logging(args.log_file)
|
|
663
|
+
detach_stdio()
|
|
664
|
+
logger.info(
|
|
665
|
+
"starting pid=%d workers=%d threads=%d idle=%ss owner=%d", os.getpid(), args.workers, args.threads, args.idle_seconds, args.owner_pid
|
|
666
|
+
)
|
|
667
|
+
start_token = read_process_start_token(os.getpid())
|
|
668
|
+
if start_token is None:
|
|
669
|
+
logger.error("could not read the own start token")
|
|
670
|
+
return EXIT_SETUP_FAILED
|
|
671
|
+
try:
|
|
672
|
+
is_claimed = claim_record(args, token, start_token)
|
|
673
|
+
except OSError:
|
|
674
|
+
logger.error("could not write the record: %s", traceback.format_exc())
|
|
675
|
+
return EXIT_SETUP_FAILED
|
|
676
|
+
if not is_claimed:
|
|
677
|
+
logger.info("another sidecar already holds the record; exiting")
|
|
678
|
+
return EXIT_DUPLICATE
|
|
679
|
+
loading_started = time.monotonic()
|
|
680
|
+
try:
|
|
681
|
+
model = load_model(args.threads)
|
|
682
|
+
except Exception:
|
|
683
|
+
logger.error("model load failed: %s", traceback.format_exc())
|
|
684
|
+
return EXIT_SETUP_FAILED
|
|
685
|
+
logger.info("model loaded in %.2fs", time.monotonic() - loading_started)
|
|
686
|
+
|
|
687
|
+
pool = RecognitionPool(make_recognizer(model), args.workers)
|
|
688
|
+
pool.start()
|
|
689
|
+
context = SidecarContext(token, pool, args.workers, args.idle_seconds, args.max_seconds)
|
|
690
|
+
try:
|
|
691
|
+
server = SidecarServer(("127.0.0.1", 0), context)
|
|
692
|
+
except OSError:
|
|
693
|
+
logger.error("could not bind: %s", traceback.format_exc())
|
|
694
|
+
return EXIT_SETUP_FAILED
|
|
695
|
+
port = server.server_address[1]
|
|
696
|
+
|
|
697
|
+
stop_event = threading.Event()
|
|
698
|
+
exit_code = [EXIT_OK]
|
|
699
|
+
is_serving = threading.Event()
|
|
700
|
+
|
|
701
|
+
def start_serving() -> None:
|
|
702
|
+
def serve() -> None:
|
|
703
|
+
is_serving.set()
|
|
704
|
+
try:
|
|
705
|
+
server.serve_forever(poll_interval=SERVE_POLL_SECONDS)
|
|
706
|
+
finally:
|
|
707
|
+
is_serving.clear()
|
|
708
|
+
|
|
709
|
+
threading.Thread(target=serve, name="stt-http", daemon=True).start()
|
|
710
|
+
|
|
711
|
+
def request_exit(code: int) -> None:
|
|
712
|
+
exit_code[0] = code
|
|
713
|
+
stop_event.set()
|
|
714
|
+
|
|
715
|
+
def monitor_idle() -> None:
|
|
716
|
+
while not stop_event.wait(IDLE_CHECK_SECONDS):
|
|
717
|
+
if not context.decide_idle_exit(server.shutdown, start_serving):
|
|
718
|
+
continue
|
|
719
|
+
logger.info("idle for %.0fs; exiting", context.idle_seconds)
|
|
720
|
+
remove_record_if_owned(args.record_file, os.getpid())
|
|
721
|
+
request_exit(EXIT_OK)
|
|
722
|
+
return
|
|
723
|
+
|
|
724
|
+
def monitor_owner() -> None:
|
|
725
|
+
while not stop_event.wait(OWNER_CHECK_SECONDS):
|
|
726
|
+
if is_owner_alive(args.owner_pid, args.owner_start_token):
|
|
727
|
+
continue
|
|
728
|
+
logger.info("owner %d is gone; exiting", args.owner_pid)
|
|
729
|
+
remove_record_if_owned(args.record_file, os.getpid())
|
|
730
|
+
logging.shutdown()
|
|
731
|
+
os._exit(EXIT_OK)
|
|
732
|
+
|
|
733
|
+
for handled in (signal.SIGTERM, signal.SIGINT):
|
|
734
|
+
signal.signal(handled, lambda *_: request_exit(EXIT_OK))
|
|
735
|
+
|
|
736
|
+
try:
|
|
737
|
+
if not publish_port(args, port, token, start_token):
|
|
738
|
+
logger.error("the record was taken away while the model loaded; exiting")
|
|
739
|
+
return EXIT_SETUP_FAILED
|
|
740
|
+
write_json_atomic(args.ready_file, {"pid": os.getpid(), "port": port, "version": SCRIPT_VERSION})
|
|
741
|
+
except OSError:
|
|
742
|
+
logger.error("could not write the record or the ready file: %s", traceback.format_exc())
|
|
743
|
+
return EXIT_SETUP_FAILED
|
|
744
|
+
logger.info("ready on 127.0.0.1:%d", port)
|
|
745
|
+
start_serving()
|
|
746
|
+
threading.Thread(target=monitor_owner, name="stt-owner", daemon=True).start()
|
|
747
|
+
if args.idle_seconds > 0:
|
|
748
|
+
threading.Thread(target=monitor_idle, name="stt-idle", daemon=True).start()
|
|
749
|
+
|
|
750
|
+
while not stop_event.wait(1.0):
|
|
751
|
+
pass
|
|
752
|
+
if is_serving.is_set():
|
|
753
|
+
server.shutdown()
|
|
754
|
+
server.server_close()
|
|
755
|
+
pool.stop()
|
|
756
|
+
logger.info("stopped (exit %d)", exit_code[0])
|
|
757
|
+
logging.shutdown()
|
|
758
|
+
os._exit(exit_code[0])
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
762
|
+
parser = argparse.ArgumentParser(description="Charness GigaAM speech-recognition sidecar")
|
|
763
|
+
parser.add_argument("--check", action="store_true", help="load the model, recognise 1 s of silence, print versions")
|
|
764
|
+
parser.add_argument("--ready-file", help="written atomically with {pid, port, version} once the server listens")
|
|
765
|
+
parser.add_argument("--record-file", help="claimed by this process before it loads the model, completed with the port once it listens (the bot adopts it from this file); deleted on an idle exit if it names this pid")
|
|
766
|
+
parser.add_argument("--config-hash", help="the bot's hash of the knobs this process was started with, recorded as is")
|
|
767
|
+
parser.add_argument("--log-file", help="where every log line goes")
|
|
768
|
+
parser.add_argument("--owner-pid", type=int, help="exit within ~2 s of this process dying")
|
|
769
|
+
parser.add_argument("--owner-start-token", help="the owner's /proc start time (field 22), to tell a reused pid")
|
|
770
|
+
parser.add_argument("--idle-minutes", type=float, default=15.0, help="exit after this long with no request; 0 = never")
|
|
771
|
+
parser.add_argument("--idle-seconds", type=float, default=None, help=argparse.SUPPRESS)
|
|
772
|
+
parser.add_argument("--workers", type=int, default=4)
|
|
773
|
+
parser.add_argument("--threads", type=int, default=2, help="onnxruntime intra-op threads per worker")
|
|
774
|
+
parser.add_argument("--max-seconds", type=float, default=DEFAULT_MAX_SECONDS, help="give up on one request after this long")
|
|
775
|
+
args = parser.parse_args(argv)
|
|
776
|
+
if not args.check:
|
|
777
|
+
for name in ("ready_file", "record_file", "log_file", "owner_pid", "config_hash"):
|
|
778
|
+
if getattr(args, name) is None:
|
|
779
|
+
parser.error(f"--{name.replace('_', '-')} is required")
|
|
780
|
+
if args.idle_seconds is None:
|
|
781
|
+
args.idle_seconds = args.idle_minutes * SECONDS_PER_MINUTE
|
|
782
|
+
return args
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def main(argv: list[str]) -> int:
|
|
786
|
+
args = parse_args(argv)
|
|
787
|
+
if args.check:
|
|
788
|
+
return run_check(args)
|
|
789
|
+
token = os.environ.get(TOKEN_ENV_NAME)
|
|
790
|
+
if not token:
|
|
791
|
+
try:
|
|
792
|
+
sys.stderr.write(f"{TOKEN_ENV_NAME} is required\n")
|
|
793
|
+
except OSError:
|
|
794
|
+
pass
|
|
795
|
+
return EXIT_USAGE
|
|
796
|
+
return run_serve(args, token)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
if __name__ == "__main__":
|
|
800
|
+
sys.exit(main(sys.argv[1:]))
|