videobook 0.0.0-stage → 0.2.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/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +195 -2
- package/bin/videobook.js +7 -0
- package/dist/acestep/client.js +203 -0
- package/dist/acestep/config.js +62 -0
- package/dist/acestep/errors.js +8 -0
- package/dist/acestep/process.js +175 -0
- package/dist/alive-captions/analyze.js +117 -0
- package/dist/alive-captions/audio.js +179 -0
- package/dist/alive-captions/frames.js +59 -0
- package/dist/alive-captions/model.js +37 -0
- package/dist/alive-captions/schema.js +49 -0
- package/dist/alive-captions/track.js +9 -0
- package/dist/api-key.js +33 -0
- package/dist/app-state.js +56 -0
- package/dist/app.js +257 -0
- package/dist/archive-b2.js +332 -0
- package/dist/archive-store.js +169 -0
- package/dist/artifact-label.js +36 -0
- package/dist/artifact-refs.js +49 -0
- package/dist/asset-tag-extraction.js +169 -0
- package/dist/asset-tags.js +228 -0
- package/dist/asset-usage.js +23 -0
- package/dist/audio-cue.js +71 -0
- package/dist/audio-encoding.js +32 -0
- package/dist/audio-sync-request.js +1 -0
- package/dist/audio-sync.js +223 -0
- package/dist/audio-trim-range.js +31 -0
- package/dist/book-context.js +31 -0
- package/dist/book-manager.js +374 -0
- package/dist/catalog-gc.js +26 -0
- package/dist/chain-executor.js +815 -0
- package/dist/chain-generation.js +115 -0
- package/dist/chain-plan.js +746 -0
- package/dist/chain-step-review.js +60 -0
- package/dist/character-name.js +25 -0
- package/dist/chat-generation-routing.js +73 -0
- package/dist/chat-generation-settings.js +78 -0
- package/dist/chat-models.js +44 -0
- package/dist/chat-prompt-rewrite.js +67 -0
- package/dist/chat-router.js +628 -0
- package/dist/chat-view-actions.js +49 -0
- package/dist/cli/args.js +242 -0
- package/dist/cli/config-command.js +103 -0
- package/dist/cli/doctor-command.js +289 -0
- package/dist/cli/entry.js +2 -0
- package/dist/cli/key-report.js +16 -0
- package/dist/cli/main.js +104 -0
- package/dist/cli/mcp-command.js +88 -0
- package/dist/cli/mcp-relay.js +51 -0
- package/dist/cli/open-browser.js +14 -0
- package/dist/cli/run-command.js +231 -0
- package/dist/cli/serve-command.js +62 -0
- package/dist/cli/setup-command.js +60 -0
- package/dist/cli/stdio-guard.js +8 -0
- package/dist/cold-open-model.js +7 -0
- package/dist/cold-open.js +79 -0
- package/dist/command-reference.js +95 -0
- package/dist/content-store.js +74 -0
- package/dist/cost.js +8 -0
- package/dist/crop-geometry.js +23 -0
- package/dist/dancing-workflow.js +447 -0
- package/dist/data-home.js +44 -0
- package/dist/diagnostics.js +109 -0
- package/dist/dialog-quality.js +34 -0
- package/dist/downloads-dest.js +56 -0
- package/dist/editing-moments.js +194 -0
- package/dist/elevenlabs-audio-extension.js +66 -0
- package/dist/engine.js +54 -0
- package/dist/env-file.js +91 -0
- package/dist/env-secrets.js +148 -0
- package/dist/ffmpeg-binary.js +12 -0
- package/dist/ffmpeg.js +425 -0
- package/dist/ffprobe-binary.js +77 -0
- package/dist/forget-asset.js +216 -0
- package/dist/frame-utils.js +27 -0
- package/dist/generation-credit-error.js +11 -0
- package/dist/generation-input-mentions.js +117 -0
- package/dist/generation-input-policy.js +68 -0
- package/dist/generation-reference-mentions.js +97 -0
- package/dist/goal-characters.js +49 -0
- package/dist/goal-plan-v3.js +273 -0
- package/dist/goal-plan.js +220 -0
- package/dist/goal-planner-prompt.js +28 -0
- package/dist/goal-prompt-budget.js +4 -0
- package/dist/goal-reference-images.js +25 -0
- package/dist/goal-state.js +261 -0
- package/dist/goal-stills.js +153 -0
- package/dist/goal-store.js +144 -0
- package/dist/goal-styles.js +144 -0
- package/dist/goal-v3-runtime.js +157 -0
- package/dist/guides/character_schema_guide.md +70 -0
- package/dist/guides/gpt_image_2_5_prompting_guide.md +21 -0
- package/dist/guides/grok_imagine_prompting_guide.md +72 -0
- package/dist/guides/image_reproduction_guide.md +84 -0
- package/dist/guides/kling_3_0_prompting_guide.md +136 -0
- package/dist/guides/nano_banana_pro_prompting_guide.md +28 -0
- package/dist/guides/seedance_2_prompting_guide.md +172 -0
- package/dist/http-access.js +49 -0
- package/dist/http-byte-range.js +31 -0
- package/dist/hydration.js +56 -0
- package/dist/image-compress.js +77 -0
- package/dist/image-edit-default.js +1 -0
- package/dist/image-edit-models.js +41 -0
- package/dist/image-edit-settings.js +11 -0
- package/dist/image-region-edit.js +32 -0
- package/dist/image-subject-map.js +523 -0
- package/dist/image.js +46 -0
- package/dist/index.js +12 -0
- package/dist/job-deadline.js +60 -0
- package/dist/jobs/_alibaba-upload.js +30 -0
- package/dist/jobs/_audio-chunks.js +31 -0
- package/dist/jobs/_download-into-asset.js +15 -0
- package/dist/jobs/_fal-shared.js +22 -0
- package/dist/jobs/_fal-upload.js +35 -0
- package/dist/jobs/_image-edit-adapter.js +213 -0
- package/dist/jobs/_kie-poll.js +119 -0
- package/dist/jobs/_kie-upload.js +32 -0
- package/dist/jobs/_lease.js +163 -0
- package/dist/jobs/_persist-output.js +19 -0
- package/dist/jobs/_storage-retry.js +21 -0
- package/dist/jobs/_transcribe.js +245 -0
- package/dist/jobs/_translate-transcript.js +270 -0
- package/dist/jobs/analyze-alive-captions.js +10 -0
- package/dist/jobs/analyze-editing-moments.js +104 -0
- package/dist/jobs/analyze-jokes.js +143 -0
- package/dist/jobs/analyze-keyword-placement.js +155 -0
- package/dist/jobs/analyze-music.js +14 -0
- package/dist/jobs/analyze-video.js +282 -0
- package/dist/jobs/apply-cuts.js +83 -0
- package/dist/jobs/apply-sfx.js +107 -0
- package/dist/jobs/archive-clip.js +458 -0
- package/dist/jobs/assemble-goal.js +98 -0
- package/dist/jobs/audio-boost.js +71 -0
- package/dist/jobs/audio-mix.js +161 -0
- package/dist/jobs/backfill-asset-tags.js +144 -0
- package/dist/jobs/burn-watermark.js +316 -0
- package/dist/jobs/change-video-speed.js +176 -0
- package/dist/jobs/clean-video-audio.js +207 -0
- package/dist/jobs/cold-open.js +166 -0
- package/dist/jobs/combine-goal.js +108 -0
- package/dist/jobs/complete-provider-task.js +185 -0
- package/dist/jobs/copy-library-asset.js +33 -0
- package/dist/jobs/cost-estimate-assessment.js +113 -0
- package/dist/jobs/crop-image.js +47 -0
- package/dist/jobs/crop-video.js +71 -0
- package/dist/jobs/describe-image.js +181 -0
- package/dist/jobs/detect-scenes.js +33 -0
- package/dist/jobs/doctor-fix.js +100 -0
- package/dist/jobs/download-video.js +427 -0
- package/dist/jobs/enqueue-search-index.js +24 -0
- package/dist/jobs/even-split.js +122 -0
- package/dist/jobs/export-sequence.js +365 -0
- package/dist/jobs/extend-audio.js +229 -0
- package/dist/jobs/extract-audio.js +72 -0
- package/dist/jobs/extract-frame.js +58 -0
- package/dist/jobs/extract-notebook-audio.js +82 -0
- package/dist/jobs/extract-notebook-frame.js +93 -0
- package/dist/jobs/extract-video-clip.js +8 -0
- package/dist/jobs/extract-video-poster.js +55 -0
- package/dist/jobs/extract-waveform.js +25 -0
- package/dist/jobs/finish-goal.js +255 -0
- package/dist/jobs/forget-asset.js +11 -0
- package/dist/jobs/generate-acestep-cover.js +219 -0
- package/dist/jobs/generate-media.js +70 -0
- package/dist/jobs/image-subjects.js +1059 -0
- package/dist/jobs/index.js +18 -0
- package/dist/jobs/inject-first-frame.js +175 -0
- package/dist/jobs/isolate-vocals.js +69 -0
- package/dist/jobs/kie-paygate.js +177 -0
- package/dist/jobs/label-music-sections.js +5 -0
- package/dist/jobs/osg-placement.js +135 -0
- package/dist/jobs/pad-video.js +72 -0
- package/dist/jobs/plan-goal.js +97 -0
- package/dist/jobs/post-record.js +21 -0
- package/dist/jobs/post-to-x.js +21 -0
- package/dist/jobs/prepare-search-reference.js +190 -0
- package/dist/jobs/prepare-temporal-search.js +16 -0
- package/dist/jobs/process-audio.js +67 -0
- package/dist/jobs/process-image.js +58 -0
- package/dist/jobs/process-upload.js +166 -0
- package/dist/jobs/process-video.js +438 -0
- package/dist/jobs/register-all.js +161 -0
- package/dist/jobs/relayout-notebook.js +78 -0
- package/dist/jobs/remove-sound.js +299 -0
- package/dist/jobs/render-project.js +380 -0
- package/dist/jobs/render-qr-outro.js +138 -0
- package/dist/jobs/render-timeline.js +283 -0
- package/dist/jobs/repair-search-indexes.js +42 -0
- package/dist/jobs/replace-video-audio.js +64 -0
- package/dist/jobs/restore-archive-clip.js +53 -0
- package/dist/jobs/reverse-video.js +53 -0
- package/dist/jobs/rewrite-script.js +173 -0
- package/dist/jobs/run-notebook.js +3335 -0
- package/dist/jobs/semantic-index.js +797 -0
- package/dist/jobs/smooth-scene-concat.js +257 -0
- package/dist/jobs/splice-video.js +280 -0
- package/dist/jobs/split-video.js +127 -0
- package/dist/jobs/suggest-cuts.js +149 -0
- package/dist/jobs/suggest-sfx.js +135 -0
- package/dist/jobs/sync-video-to-audio.js +329 -0
- package/dist/jobs/tag-asset.js +266 -0
- package/dist/jobs/task-failure.js +16 -0
- package/dist/jobs/timewarp-video.js +152 -0
- package/dist/jobs/transcribe-video-audio.js +117 -0
- package/dist/jobs/transcribe.js +35 -0
- package/dist/jobs/translate-transcript.js +55 -0
- package/dist/jobs/trim-audio.js +133 -0
- package/dist/jobs/trim-video.js +291 -0
- package/dist/jobs/xai-paygate.js +26 -0
- package/dist/keyword-placement-cache.js +74 -0
- package/dist/lazy-render.js +142 -0
- package/dist/library-browse.js +78 -0
- package/dist/lightning-defaults.js +206 -0
- package/dist/load-env.js +12 -0
- package/dist/local-search-models.js +206 -0
- package/dist/local-upload.js +318 -0
- package/dist/logger.js +69 -0
- package/dist/media/adapters/alibaba.js +20 -0
- package/dist/media/adapters/elevenlabs.js +58 -0
- package/dist/media/adapters/fal.js +45 -0
- package/dist/media/adapters/index.js +26 -0
- package/dist/media/adapters/kie.js +120 -0
- package/dist/media/adapters/pending.js +21 -0
- package/dist/media/adapters/types.js +5 -0
- package/dist/media/adapters/xai.js +18 -0
- package/dist/media/catalog.js +236 -0
- package/dist/media/complete.js +191 -0
- package/dist/media/cost.js +45 -0
- package/dist/media/descriptor.js +157 -0
- package/dist/media/endpoints.js +11 -0
- package/dist/media/frame-options.js +18 -0
- package/dist/media/insert-video-timing.js +17 -0
- package/dist/media/json-schema.js +75 -0
- package/dist/media/legacy-task-types.js +129 -0
- package/dist/media/listing.js +24 -0
- package/dist/media/model-id.js +14 -0
- package/dist/media/preview.js +86 -0
- package/dist/media/prompt-dialects.js +65 -0
- package/dist/media/ref-fields.js +106 -0
- package/dist/media/refs.js +131 -0
- package/dist/media/request.js +188 -0
- package/dist/media/types.js +6 -0
- package/dist/media/wire.js +161 -0
- package/dist/media-library.js +535 -0
- package/dist/media-output-path.js +33 -0
- package/dist/multyculty-workflow.js +757 -0
- package/dist/notebook-action-catalog.js +608 -0
- package/dist/notebook-cell-outputs.js +726 -0
- package/dist/notebook-credit-retry.js +45 -0
- package/dist/notebook-fixtures.js +8 -0
- package/dist/notebook-generation-contract.js +77 -0
- package/dist/notebook-generation-controls.js +145 -0
- package/dist/notebook-goal-layout.js +40 -0
- package/dist/notebook-graph.js +37 -0
- package/dist/notebook-image-edit.js +84 -0
- package/dist/notebook-initial-generation-prompt.js +222 -0
- package/dist/notebook-live-analysis.js +941 -0
- package/dist/notebook-media-ops.js +579 -0
- package/dist/notebook-mutations.js +20 -0
- package/dist/notebook-placement.js +41 -0
- package/dist/notebook-provider-failure.js +87 -0
- package/dist/notebook-published-post.js +26 -0
- package/dist/notebook-reference-resolver.js +227 -0
- package/dist/notebook-relayout.js +270 -0
- package/dist/notebook-run-plan.js +519 -0
- package/dist/notebook-shots.js +162 -0
- package/dist/notebook-workflow.js +856 -0
- package/dist/one-password.js +200 -0
- package/dist/openai-intelligence.js +99 -0
- package/dist/original-media-file.js +12 -0
- package/dist/osg-assets.js +36 -0
- package/dist/osg-placement-cache.js +64 -0
- package/dist/osg-settings.js +27 -0
- package/dist/paid-tools.js +22 -0
- package/dist/platform-guidance.js +26 -0
- package/dist/posts/dropbox-auth.js +91 -0
- package/dist/posts/dropbox-upload.js +132 -0
- package/dist/posts/icloud-drive.js +134 -0
- package/dist/posts/icloud-status.js +64 -0
- package/dist/posts/post-contract.js +2 -0
- package/dist/posts/post-copy-format.js +43 -0
- package/dist/posts/post-copy.js +193 -0
- package/dist/posts/post-folder.js +18 -0
- package/dist/posts/post-metrics.js +119 -0
- package/dist/posts/post-store.js +340 -0
- package/dist/posts/x-post-target.js +43 -0
- package/dist/preview-payload.js +90 -0
- package/dist/prompt-surfaces.js +22 -0
- package/dist/provider-prompt-transform.js +77 -0
- package/dist/provider-status-link.js +21 -0
- package/dist/provider-task-record.js +78 -0
- package/dist/provider-task-status.js +47 -0
- package/dist/provider-usage.js +294 -0
- package/dist/queue.js +810 -0
- package/dist/reference-video-duration.js +17 -0
- package/dist/remotion/BlurFillVideo.js +51 -0
- package/dist/remotion/KeywordGraphicsOverlay.js +68 -0
- package/dist/remotion/OsgOverlay.js +30 -0
- package/dist/remotion/QrOutroComposition.js +47 -0
- package/dist/remotion/RenderComposition.js +11 -0
- package/dist/remotion/browser.js +59 -0
- package/dist/remotion/constants.js +35 -0
- package/dist/remotion/index.js +66 -0
- package/dist/remotion/keyword-graphics.js +151 -0
- package/dist/remotion/keyword-placement.js +182 -0
- package/dist/remotion/osg-spec.js +198 -0
- package/dist/remotion/public/qr-outro-impact.wav +0 -0
- package/dist/remotion/qr-outro-audio.js +1 -0
- package/dist/remotion/qr-outro-spec.js +211 -0
- package/dist/remotion/shared-bundle.js +37 -0
- package/dist/remotion/subtitles/AliveCaptions.js +83 -0
- package/dist/remotion/subtitles/Highlight.js +58 -0
- package/dist/remotion/subtitles/Impact.js +67 -0
- package/dist/remotion/subtitles/SubtitleOverlay.js +9 -0
- package/dist/remotion/subtitles/alive-layout.js +321 -0
- package/dist/remotion/subtitles/alive-types.js +17 -0
- package/dist/remotion/subtitles/chunker.js +68 -0
- package/dist/remotion/subtitles/segments.js +42 -0
- package/dist/remotion/subtitles/styles.js +4 -0
- package/dist/remotion/subtitles/types.js +1 -0
- package/dist/remotion/util.js +18 -0
- package/dist/remotion-cancellation.js +15 -0
- package/dist/rename-helpers.js +204 -0
- package/dist/rhythm/analyze.js +128 -0
- package/dist/rhythm/contract.js +14 -0
- package/dist/rhythm/draft-model.js +19 -0
- package/dist/rhythm/energy.js +133 -0
- package/dist/rhythm/fft.js +44 -0
- package/dist/rhythm/model.js +71 -0
- package/dist/rhythm/music-analysis.js +124 -0
- package/dist/rhythm/music-draft.js +261 -0
- package/dist/rhythm/onset.js +101 -0
- package/dist/rhythm/run-worker.js +51 -0
- package/dist/rhythm/score.js +47 -0
- package/dist/rhythm/section-labels.js +168 -0
- package/dist/rhythm/sequence-rhythm.js +67 -0
- package/dist/rhythm/synthesize.js +103 -0
- package/dist/rhythm/timing.js +98 -0
- package/dist/rhythm/track.js +320 -0
- package/dist/rhythm/visual-fit.js +4 -0
- package/dist/rhythm/worker.js +25 -0
- package/dist/scene-detect.js +200 -0
- package/dist/scene-transition.js +12 -0
- package/dist/scheduled-posts.js +200 -0
- package/dist/search-index-coverage.js +13 -0
- package/dist/search-index-media.js +71 -0
- package/dist/search-reference-store.js +60 -0
- package/dist/search-scoring.js +17 -0
- package/dist/serve.js +125 -0
- package/dist/server-lock.js +143 -0
- package/dist/server.js +42 -0
- package/dist/srt.js +16 -0
- package/dist/tag-backfill-state.js +121 -0
- package/dist/tag-settings.js +30 -0
- package/dist/tag-vocabulary.js +237 -0
- package/dist/test-queue-cleanup.js +1 -0
- package/dist/time-parse.js +50 -0
- package/dist/timeline-helpers.js +554 -0
- package/dist/timewarp.js +87 -0
- package/dist/tools/_image-inputs.js +9 -0
- package/dist/tools/_media-refs.js +84 -0
- package/dist/tools/_shared.js +59 -0
- package/dist/tools/_suggest-shared.js +279 -0
- package/dist/tools/add-timeline-audio.js +73 -0
- package/dist/tools/alive-captions.js +157 -0
- package/dist/tools/analyze-jokes.js +56 -0
- package/dist/tools/analyze-video.js +66 -0
- package/dist/tools/animate-keywords.js +217 -0
- package/dist/tools/api-status.js +42 -0
- package/dist/tools/application-commands.js +34 -0
- package/dist/tools/apply-cuts.js +112 -0
- package/dist/tools/apply-sfx.js +152 -0
- package/dist/tools/apply-watermark.js +670 -0
- package/dist/tools/archive-clip.js +74 -0
- package/dist/tools/asset-history.js +61 -0
- package/dist/tools/asset-manifest.js +24 -0
- package/dist/tools/asset-tags.js +151 -0
- package/dist/tools/assign-slot.js +97 -0
- package/dist/tools/audio-boost.js +107 -0
- package/dist/tools/audio-mix.js +121 -0
- package/dist/tools/book-usage.js +16 -0
- package/dist/tools/chain.js +136 -0
- package/dist/tools/change-video-speed.js +105 -0
- package/dist/tools/chat-history.js +5 -0
- package/dist/tools/chat.js +848 -0
- package/dist/tools/clean-video-audio.js +97 -0
- package/dist/tools/clear-failed-tasks.js +66 -0
- package/dist/tools/cold-open.js +114 -0
- package/dist/tools/command-plan.js +1680 -0
- package/dist/tools/commands.js +1666 -0
- package/dist/tools/concat-images.js +236 -0
- package/dist/tools/connect-service.js +55 -0
- package/dist/tools/copy-file-to-clipboard.js +58 -0
- package/dist/tools/create-character.js +114 -0
- package/dist/tools/create-qart.js +253 -0
- package/dist/tools/create-qr-outro.js +149 -0
- package/dist/tools/create-qrbtf.js +215 -0
- package/dist/tools/create-watermark.js +97 -0
- package/dist/tools/crop-asset.js +18 -0
- package/dist/tools/crop-image.js +120 -0
- package/dist/tools/crop-video.js +97 -0
- package/dist/tools/delete-asset.js +104 -0
- package/dist/tools/describe-image.js +69 -0
- package/dist/tools/detect-scenes.js +44 -0
- package/dist/tools/doctor.js +126 -0
- package/dist/tools/download-video.js +114 -0
- package/dist/tools/duplicate-asset.js +184 -0
- package/dist/tools/edit-timeline.js +59 -0
- package/dist/tools/editing-moments.js +105 -0
- package/dist/tools/estimate-cost.js +171 -0
- package/dist/tools/even-split.js +295 -0
- package/dist/tools/export-asset-file.js +22 -0
- package/dist/tools/export-asset.js +140 -0
- package/dist/tools/export-sequence.js +250 -0
- package/dist/tools/export-to-downloads.js +54 -0
- package/dist/tools/export-transcript-srt.js +79 -0
- package/dist/tools/extend-audio.js +100 -0
- package/dist/tools/extract-audio.js +174 -0
- package/dist/tools/extract-frame.js +108 -0
- package/dist/tools/extract-notebook-audio.js +173 -0
- package/dist/tools/extract-notebook-frame.js +160 -0
- package/dist/tools/extract-video-clip.js +101 -0
- package/dist/tools/generate-media.js +157 -0
- package/dist/tools/generation-payload.js +48 -0
- package/dist/tools/get-timeline.js +12 -0
- package/dist/tools/get-watermark-config.js +38 -0
- package/dist/tools/goal.js +644 -0
- package/dist/tools/image-subjects.js +711 -0
- package/dist/tools/index.js +323 -0
- package/dist/tools/inject-first-frame.js +154 -0
- package/dist/tools/invert-image.js +70 -0
- package/dist/tools/isolate-vocals.js +66 -0
- package/dist/tools/list-assets.js +98 -0
- package/dist/tools/list-cell-generations.js +225 -0
- package/dist/tools/list-elevenlabs-voices.js +163 -0
- package/dist/tools/log-wrapper.js +29 -0
- package/dist/tools/make-cover-song.js +95 -0
- package/dist/tools/mark-asset-seen.js +23 -0
- package/dist/tools/media-library.js +245 -0
- package/dist/tools/move-timeline-audio.js +35 -0
- package/dist/tools/music-analysis.js +60 -0
- package/dist/tools/music-draft.js +29 -0
- package/dist/tools/pad-video.js +87 -0
- package/dist/tools/post-record.js +83 -0
- package/dist/tools/post.js +168 -0
- package/dist/tools/prompt-history.js +164 -0
- package/dist/tools/provider-key-status.js +248 -0
- package/dist/tools/qrbtf-render.js +849 -0
- package/dist/tools/read-asset-metadata.js +26 -0
- package/dist/tools/read-file-at-commit.js +29 -0
- package/dist/tools/read-transcript.js +41 -0
- package/dist/tools/reconcile-project-work.js +1452 -0
- package/dist/tools/recover-asset.js +28 -0
- package/dist/tools/remove-sound.js +72 -0
- package/dist/tools/remove-timeline-audio.js +34 -0
- package/dist/tools/remove-timeline-slot.js +40 -0
- package/dist/tools/rename-all.js +434 -0
- package/dist/tools/rename-asset.js +34 -0
- package/dist/tools/render-settings.js +47 -0
- package/dist/tools/render-timeline.js +120 -0
- package/dist/tools/render.js +384 -0
- package/dist/tools/reorder-character-images.js +45 -0
- package/dist/tools/replace-video-audio.js +133 -0
- package/dist/tools/restore-archive-clips.js +209 -0
- package/dist/tools/restore-asset-version.js +37 -0
- package/dist/tools/retry-generation.js +54 -0
- package/dist/tools/reverse-video.js +74 -0
- package/dist/tools/rewrite-prompt.js +388 -0
- package/dist/tools/rewrite-script.js +67 -0
- package/dist/tools/schema-from-apicity.js +66 -0
- package/dist/tools/search-archive.js +150 -0
- package/dist/tools/semantic-search.js +1214 -0
- package/dist/tools/sequence-rhythm.js +43 -0
- package/dist/tools/set-character-metadata.js +84 -0
- package/dist/tools/set-env-secret.js +40 -0
- package/dist/tools/set-osg.js +143 -0
- package/dist/tools/show-folder.js +37 -0
- package/dist/tools/smooth-scene-concat.js +122 -0
- package/dist/tools/splice-video.js +149 -0
- package/dist/tools/split-video.js +202 -0
- package/dist/tools/suggest-cuts.js +45 -0
- package/dist/tools/suggest-sfx.js +43 -0
- package/dist/tools/sync-video-to-audio.js +115 -0
- package/dist/tools/tag-backfill.js +177 -0
- package/dist/tools/timewarp-video.js +85 -0
- package/dist/tools/tool-def.js +1 -0
- package/dist/tools/tool-json-schema.js +116 -0
- package/dist/tools/transcribe.js +90 -0
- package/dist/tools/translate-subtitles.js +59 -0
- package/dist/tools/trim-audio.js +139 -0
- package/dist/tools/trim-video.js +168 -0
- package/dist/tools/update-script.js +25 -0
- package/dist/tools/update-timeline-audio.js +40 -0
- package/dist/tools/upload-file.js +41 -0
- package/dist/tools/verify-x-mentions.js +141 -0
- package/dist/tools/video-editing-command-contract.js +291 -0
- package/dist/tools/video-source-reference.js +30 -0
- package/dist/tools/video-time-input.js +18 -0
- package/dist/tools/videobook-domain.js +2021 -0
- package/dist/tools/wait-for-asset-ready.js +143 -0
- package/dist/tools/workspace-actions.js +54 -0
- package/dist/transcript-projection.js +202 -0
- package/dist/transcript-srt.js +33 -0
- package/dist/upload.js +76 -0
- package/dist/usage-providers.js +25 -0
- package/dist/video-analysis-projection.js +46 -0
- package/dist/video-poster.js +33 -0
- package/dist/video-proxy.js +99 -0
- package/dist/vision-regions.js +111 -0
- package/dist/voice-actions.js +90 -0
- package/dist/voice-audio.js +55 -0
- package/dist/voice-config.js +122 -0
- package/dist/voice-continuation.js +54 -0
- package/dist/voice-contract.js +1 -0
- package/dist/voice-http.js +145 -0
- package/dist/voice-reply-progress.js +55 -0
- package/dist/voice-session.js +992 -0
- package/dist/watermark-badge.js +259 -0
- package/dist/watermark-config.js +111 -0
- package/dist/watermark-handle-position.js +55 -0
- package/dist/watermark-platform.js +15 -0
- package/dist/watermark-timing.js +16 -0
- package/dist/workspace-action-catalog.js +315 -0
- package/dist/workspace-action-contract.js +21 -0
- package/dist/workspace-action-groups.js +98 -0
- package/dist/workspace-action-surfaces.js +7 -0
- package/dist/x/account.js +88 -0
- package/dist/x/authenticated-handle.js +30 -0
- package/dist/x/oauth-flow.js +191 -0
- package/dist/x/post-contract.js +1 -0
- package/dist/x/post-metadata.js +44 -0
- package/dist/x/post-to-x.js +456 -0
- package/dist/x/published-post-cell.js +90 -0
- package/dist/x/x-tokens.js +69 -0
- package/dist/ytdlp-binary.js +65 -0
- package/dist/zod-slots.js +334 -0
- package/package.json +84 -4
- package/ui/assets/alive-captions-BWb5nLKj.css +1 -0
- package/ui/assets/alive-captions-DPEr1nKM.js +1 -0
- package/ui/assets/confirm-bodies-D8ouHXKy.js +1 -0
- package/ui/assets/confirm-bodies-DrMhaXC6.css +1 -0
- package/ui/assets/fullscreen-B7nw4YbE.js +1 -0
- package/ui/assets/goal-ui-DK-xTCK0.js +2 -0
- package/ui/assets/goal-ui-DXdUM2KF.css +1 -0
- package/ui/assets/index-DzPAnCUj.css +1 -0
- package/ui/assets/index-kS1905s5.js +37 -0
- package/ui/assets/inspector-MlzQbTVo.js +1 -0
- package/ui/assets/jobs-ui-CoinOWx0.js +1 -0
- package/ui/assets/music-video-Cbe80kmQ.js +1 -0
- package/ui/assets/music-video-DPaiKKEq.css +1 -0
- package/ui/assets/ui-Oirr9VOf.css +1 -0
- package/ui/assets/ui-xo_Y1yQg.js +2 -0
- package/ui/assets/vendor-D2rfiZ5j.css +1 -0
- package/ui/assets/vendor-DfTCWgKm.js +19 -0
- package/ui/assets/vendor-flow-BXbeWxW7.js +7 -0
- package/ui/assets/vendor-flow-BnuhLJ6X.css +1 -0
- package/ui/assets/vendor-icons-CGqhCIXV.js +1 -0
- package/ui/assets/vendor-mcp-BTlEJcHV.js +67 -0
- package/ui/assets/vendor-react-Dj7D1fD4.js +9 -0
- package/ui/assets/vendor-remotion-CH1HXrlc.js +95 -0
- package/ui/assets/videobook-Qlp9HQFu.js +20 -0
- package/ui/favicon.svg +8 -0
- package/ui/index.html +126 -0
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
import { createOpenAi } from "./usage-providers.js";
|
|
2
|
+
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { OpenAiError } from "@apicity/openai";
|
|
4
|
+
import { connectOpenAiRealtime } from "@apicity/openai/realtime";
|
|
5
|
+
import { XaiError } from "@apicity/xai";
|
|
6
|
+
import { connectXaiRealtime } from "@apicity/xai/realtime";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { getApiKey } from "./api-key.js";
|
|
9
|
+
import { withBookContext } from "./book-context.js";
|
|
10
|
+
import { discoverVoiceActions, voiceWorkspaceCells, voiceWorkspaceContext, voiceWorkspaceSignature } from "./voice-actions.js";
|
|
11
|
+
import { DEFAULT_VOICE_MODEL, DEFAULT_XAI_VOICE_MODEL, VOICE_TRANSCRIPTION_MODEL, VOICE_ACTIVATION_PHRASE, VOICE_PCM_RATE, openAiVoiceActivationEvent, voiceInstructions, voiceSessionConfig, xaiVoiceActivationEvent, xaiVoiceSessionConfig } from "./voice-config.js";
|
|
12
|
+
import { recordVoiceUsage, recordTranscriptionUsage } from "./provider-usage.js";
|
|
13
|
+
import { chatActionPlanSchema, prepareVoiceChatAction } from "./tools/chat.js";
|
|
14
|
+
import { appendVoiceHistory } from "./tools/chat-history.js";
|
|
15
|
+
import { appendDiagnostic } from "./diagnostics.js";
|
|
16
|
+
import { VoiceReplyProgress } from "./voice-reply-progress.js";
|
|
17
|
+
import { isVoiceTurnContinuation, joinVoiceTranscripts } from "./voice-continuation.js";
|
|
18
|
+
class VoiceError extends Error {
|
|
19
|
+
status;
|
|
20
|
+
constructor(message, status) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.status = status;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const providerErrorSchema = z.object({ code: z.string().nullish(), type: z.string().nullish() });
|
|
26
|
+
const quotaMessage = "OpenAI API quota is exhausted. Check credits and usage limits in OpenAI settings before restarting voice.";
|
|
27
|
+
const rateLimitMessage = "OpenAI is temporarily rate limiting voice. Wait before starting voice again.";
|
|
28
|
+
const providerMessages = new Map([
|
|
29
|
+
["credit_balance_exhausted", "OpenAI API credits are exhausted. Add credits in OpenAI billing, then start voice again."],
|
|
30
|
+
["organization_spend_limit_exceeded", "The OpenAI organization spend limit has been reached. Review the limit in OpenAI settings before restarting voice."],
|
|
31
|
+
["project_spend_limit_exceeded", "The OpenAI project spend limit has been reached. Review the project limit in OpenAI settings before restarting voice."],
|
|
32
|
+
["organization_usage_limit_exceeded", "The OpenAI organization usage limit has been reached. Review usage limits in OpenAI settings before restarting voice."],
|
|
33
|
+
["insufficient_quota", quotaMessage],
|
|
34
|
+
["rate_limit_exceeded", rateLimitMessage],
|
|
35
|
+
["slow_down", rateLimitMessage],
|
|
36
|
+
]);
|
|
37
|
+
function providerErrorMessage(details, fallbackCode, provider = "openai") {
|
|
38
|
+
const parsed = providerErrorSchema.safeParse(details);
|
|
39
|
+
const code = (parsed.success ? parsed.data.code : undefined) ?? fallbackCode;
|
|
40
|
+
const message = (code ? providerMessages.get(code) : undefined)
|
|
41
|
+
?? (parsed.success && parsed.data.type === "insufficient_quota" ? quotaMessage : undefined)
|
|
42
|
+
?? (parsed.success && parsed.data.type === "rate_limit_error" ? rateLimitMessage : undefined);
|
|
43
|
+
return message?.replaceAll("OpenAI", provider === "xai" ? "xAI" : "OpenAI");
|
|
44
|
+
}
|
|
45
|
+
export function voiceError(error) {
|
|
46
|
+
if (error instanceof VoiceError)
|
|
47
|
+
return error;
|
|
48
|
+
if (error instanceof XaiError) {
|
|
49
|
+
const body = z.object({ error: z.unknown() }).safeParse(error.body);
|
|
50
|
+
const message = providerErrorMessage(body.success ? body.data.error : undefined, error.code, "xai");
|
|
51
|
+
if (message)
|
|
52
|
+
return new VoiceError(message, error.status);
|
|
53
|
+
if (error.status === 401 || error.status === 403)
|
|
54
|
+
return new VoiceError("xAI voice access was denied. Check XAI_API_KEY and voice model access on the server.", 503);
|
|
55
|
+
if (error.status === 429)
|
|
56
|
+
return new VoiceError("xAI voice reached an account limit. Check xAI credits and usage limits before retrying.", 429);
|
|
57
|
+
return new VoiceError("xAI voice could not connect. Check the voice model and network, then try again.", 502);
|
|
58
|
+
}
|
|
59
|
+
if (error instanceof OpenAiError) {
|
|
60
|
+
const body = z.object({ error: z.unknown() }).safeParse(error.body);
|
|
61
|
+
const message = providerErrorMessage(body.success ? body.data.error : undefined, error.code);
|
|
62
|
+
if (message)
|
|
63
|
+
return new VoiceError(message, error.status);
|
|
64
|
+
if (error.status === 401 || error.status === 403)
|
|
65
|
+
return new VoiceError("OpenAI voice access was denied. Check OPENAI_API_KEY and model access on the server.", 503);
|
|
66
|
+
if (error.status === 429)
|
|
67
|
+
return new VoiceError("OpenAI rejected voice because of an account limit. Check OpenAI billing and usage limits before retrying.", 429);
|
|
68
|
+
return new VoiceError("OpenAI voice could not connect. Check the voice model and network, then try again.", 502);
|
|
69
|
+
}
|
|
70
|
+
return new VoiceError("Voice disconnected. Start voice again to reconnect.", 502);
|
|
71
|
+
}
|
|
72
|
+
function deferred() {
|
|
73
|
+
let resolve;
|
|
74
|
+
const promise = new Promise((done) => { resolve = done; });
|
|
75
|
+
return { promise, resolve };
|
|
76
|
+
}
|
|
77
|
+
const actionSchema = z.object({ tool: z.string().min(1).max(120), params: z.record(z.string(), z.unknown()) }).strict();
|
|
78
|
+
const discoverySchema = z.object({ query: z.string().min(1).max(160) }).strict();
|
|
79
|
+
const reviewSchema = z.object({ decision: z.enum(["apply", "cancel", "revise"]), prompt: z.string().max(8000).optional(), model: z.string().max(200).optional(), settings: z.record(z.string().max(120), z.union([z.string().max(400), z.number(), z.boolean()])).optional() }).strict();
|
|
80
|
+
export class VoiceSession {
|
|
81
|
+
books;
|
|
82
|
+
bookId;
|
|
83
|
+
notebookId;
|
|
84
|
+
engine;
|
|
85
|
+
idleTimeoutMs;
|
|
86
|
+
onClose;
|
|
87
|
+
providerName;
|
|
88
|
+
inputRate;
|
|
89
|
+
id = randomUUID();
|
|
90
|
+
token = randomBytes(32).toString("hex");
|
|
91
|
+
model;
|
|
92
|
+
provider;
|
|
93
|
+
control = null;
|
|
94
|
+
// Provider-specific, because the two realtime APIs take different session
|
|
95
|
+
// shapes. Set once the connection is open.
|
|
96
|
+
pushSession = null;
|
|
97
|
+
sendActivation = null;
|
|
98
|
+
sendContextNote = null;
|
|
99
|
+
workspaceSignature = null;
|
|
100
|
+
callId = null;
|
|
101
|
+
negotiation = null;
|
|
102
|
+
abort = new AbortController();
|
|
103
|
+
stopped = false;
|
|
104
|
+
closing = null;
|
|
105
|
+
listener = null;
|
|
106
|
+
backlog = [];
|
|
107
|
+
seen = new Set();
|
|
108
|
+
captions = new Map();
|
|
109
|
+
turns = new Map();
|
|
110
|
+
currentTurn = "";
|
|
111
|
+
responseTurns = new Map();
|
|
112
|
+
responseItems = new Map();
|
|
113
|
+
interruptedResponses = new Set();
|
|
114
|
+
pendingAssistantHistory = new Map();
|
|
115
|
+
activeResponse = false;
|
|
116
|
+
greeted = false;
|
|
117
|
+
activationResponseId = null;
|
|
118
|
+
activationItems = new Set();
|
|
119
|
+
activationPending = false;
|
|
120
|
+
activationCaptionFinal = false;
|
|
121
|
+
replyProgress = new VoiceReplyProgress();
|
|
122
|
+
abandonedResponses = new Set();
|
|
123
|
+
recoveringTurn = null;
|
|
124
|
+
lastAction = null;
|
|
125
|
+
needsResponse = false;
|
|
126
|
+
speaking = false;
|
|
127
|
+
waitingAction = null;
|
|
128
|
+
reviewCall = null;
|
|
129
|
+
toolChain = Promise.resolve();
|
|
130
|
+
pendingTools = 0;
|
|
131
|
+
audioAttached = false;
|
|
132
|
+
audioClose;
|
|
133
|
+
writes = Promise.resolve();
|
|
134
|
+
lastActivity = Date.now();
|
|
135
|
+
startedAt = Date.now();
|
|
136
|
+
usage = { responses: 0, inputTokens: 0, outputTokens: 0 };
|
|
137
|
+
pendingUsage = new Set();
|
|
138
|
+
timer;
|
|
139
|
+
constructor(books, bookId, notebookId, engine, apiKey, idleTimeoutMs, onClose, providerName = "openai", inputRate = VOICE_PCM_RATE) {
|
|
140
|
+
this.books = books;
|
|
141
|
+
this.bookId = bookId;
|
|
142
|
+
this.notebookId = notebookId;
|
|
143
|
+
this.engine = engine;
|
|
144
|
+
this.idleTimeoutMs = idleTimeoutMs;
|
|
145
|
+
this.onClose = onClose;
|
|
146
|
+
this.providerName = providerName;
|
|
147
|
+
this.inputRate = inputRate;
|
|
148
|
+
this.model = providerName === "xai" ? process.env.VIDEOBOOK_XAI_VOICE_MODEL?.trim() || DEFAULT_XAI_VOICE_MODEL : process.env.VIDEOBOOK_VOICE_MODEL?.trim() || DEFAULT_VOICE_MODEL;
|
|
149
|
+
this.provider = createOpenAi({ apiKey, timeout: 20_000 });
|
|
150
|
+
this.apiKey = apiKey;
|
|
151
|
+
this.timer = setInterval(() => {
|
|
152
|
+
if (!this.scopeAlive())
|
|
153
|
+
void this.close("Workspace closed. Start voice in the current project.");
|
|
154
|
+
else if (Date.now() - this.lastActivity > this.idleTimeoutMs)
|
|
155
|
+
void this.close("Comms idle. Start voice to resume.");
|
|
156
|
+
else if (Date.now() - this.startedAt > 30 * 60_000)
|
|
157
|
+
void this.close("Comms session ended. Start voice to resume.");
|
|
158
|
+
else {
|
|
159
|
+
try {
|
|
160
|
+
this.recoverReply();
|
|
161
|
+
this.syncWorkspace();
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
this.fail(voiceError(error).message);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}, Math.min(1000, idleTimeoutMs));
|
|
168
|
+
this.timer.unref();
|
|
169
|
+
}
|
|
170
|
+
apiKey;
|
|
171
|
+
owns(token) {
|
|
172
|
+
const candidate = Buffer.from(token);
|
|
173
|
+
const expected = Buffer.from(this.token);
|
|
174
|
+
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
|
|
175
|
+
}
|
|
176
|
+
scopeAlive() {
|
|
177
|
+
const book = this.books.get(this.bookId);
|
|
178
|
+
if (!book.ok || book.value.engine !== this.engine)
|
|
179
|
+
return false;
|
|
180
|
+
return !this.notebookId || this.engine.notebooks.read(this.notebookId).ok;
|
|
181
|
+
}
|
|
182
|
+
async start(sdp, signal) {
|
|
183
|
+
const onAbort = () => { void this.close("Voice start cancelled."); };
|
|
184
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
185
|
+
try {
|
|
186
|
+
if (signal.aborted || this.stopped)
|
|
187
|
+
throw new VoiceError("Voice start cancelled.", 499);
|
|
188
|
+
if (this.providerName === "xai")
|
|
189
|
+
return await this.startXai();
|
|
190
|
+
this.negotiation = this.provider.post.v1.realtime.calls({
|
|
191
|
+
sdp, session: voiceSessionConfig(voiceWorkspaceContext(this.engine, this.notebookId), this.model),
|
|
192
|
+
});
|
|
193
|
+
const call = await this.negotiation;
|
|
194
|
+
this.callId = call.callId;
|
|
195
|
+
if (this.stopped) {
|
|
196
|
+
await this.provider.post.v1.realtime.calls.hangup(call.callId).catch(() => undefined);
|
|
197
|
+
throw new VoiceError("Voice start cancelled.", 499);
|
|
198
|
+
}
|
|
199
|
+
const connection = await connectOpenAiRealtime({
|
|
200
|
+
apiKey: this.apiKey, callId: call.callId, signal: this.abort.signal, timeout: 10_000,
|
|
201
|
+
onEvent: (event) => { try {
|
|
202
|
+
this.receive(event);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
this.fail("Voice could not process an event. Start voice to reconnect.");
|
|
206
|
+
} },
|
|
207
|
+
onError: (error) => { this.fail(voiceError(error).message); },
|
|
208
|
+
onClose: () => { if (!this.stopped)
|
|
209
|
+
this.fail("Voice connection lost. Start voice to reconnect."); },
|
|
210
|
+
});
|
|
211
|
+
this.control = connection;
|
|
212
|
+
if (this.stopped)
|
|
213
|
+
throw new VoiceError("Voice start cancelled.", 499);
|
|
214
|
+
// Only instructions change mid-call; resending model or audio settings
|
|
215
|
+
// would renegotiate a live call for nothing.
|
|
216
|
+
this.pushSession = (context) => connection.send({
|
|
217
|
+
type: "session.update",
|
|
218
|
+
session: { type: "realtime", instructions: voiceInstructions(context) },
|
|
219
|
+
});
|
|
220
|
+
this.sendActivation = () => connection.send(openAiVoiceActivationEvent());
|
|
221
|
+
this.sendContextNote = (text) => connection.send({ type: "conversation.item.create", item: { type: "message", role: "system", content: [{ type: "input_text", text }] } });
|
|
222
|
+
this.workspaceSignature = this.signature();
|
|
223
|
+
return { id: this.id, token: this.token, provider: "openai", transport: "webrtc", sdp: call.sdp, model: this.model, idleTimeoutMs: this.idleTimeoutMs };
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
signal.removeEventListener("abort", onAbort);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
async startXai() {
|
|
230
|
+
const configured = deferred();
|
|
231
|
+
let setupError;
|
|
232
|
+
// Unset in production: voice talks to xAI. Set, it points the same client at
|
|
233
|
+
// another realtime endpoint -- a local gateway, or the integration test's
|
|
234
|
+
// server, which is the only way to watch what we send the model.
|
|
235
|
+
const endpoint = process.env.VIDEOBOOK_XAI_REALTIME_URL?.trim();
|
|
236
|
+
const connection = await connectXaiRealtime({
|
|
237
|
+
apiKey: this.apiKey, model: this.model, signal: this.abort.signal, timeout: 10_000,
|
|
238
|
+
...(endpoint ? { baseURL: endpoint } : {}),
|
|
239
|
+
onEvent: event => {
|
|
240
|
+
if (event.type === "session.updated")
|
|
241
|
+
configured.resolve();
|
|
242
|
+
if (event.type === "error") {
|
|
243
|
+
setupError = new XaiError("xAI voice configuration failed", 502, { error: event.error });
|
|
244
|
+
configured.resolve();
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
this.receive(event);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
this.fail("Voice could not process an event. Start voice to reconnect.");
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
onError: error => { setupError = error; configured.resolve(); if (this.control)
|
|
254
|
+
this.fail(voiceError(error).message); },
|
|
255
|
+
onClose: () => { configured.resolve(); if (!this.stopped)
|
|
256
|
+
this.fail("xAI voice connection lost. Start voice to reconnect."); },
|
|
257
|
+
});
|
|
258
|
+
this.control = connection;
|
|
259
|
+
if (this.stopped) {
|
|
260
|
+
connection.close();
|
|
261
|
+
throw new VoiceError("Voice start cancelled.", 499);
|
|
262
|
+
}
|
|
263
|
+
this.pushSession = (context) => connection.send({
|
|
264
|
+
type: "session.update",
|
|
265
|
+
session: xaiVoiceSessionConfig(context, this.inputRate),
|
|
266
|
+
});
|
|
267
|
+
this.sendActivation = () => connection.send(xaiVoiceActivationEvent());
|
|
268
|
+
this.sendContextNote = (text) => connection.send({ type: "conversation.item.create", item: { type: "message", role: "user", content: [{ type: "input_text", text }] } });
|
|
269
|
+
this.pushSession(voiceWorkspaceContext(this.engine, this.notebookId));
|
|
270
|
+
this.workspaceSignature = this.signature();
|
|
271
|
+
const deadline = setTimeout(() => { setupError = new XaiError("xAI voice setup timed out", 504); configured.resolve(); }, 10_000);
|
|
272
|
+
const aborted = () => configured.resolve();
|
|
273
|
+
this.abort.signal.addEventListener("abort", aborted, { once: true });
|
|
274
|
+
try {
|
|
275
|
+
await configured.promise;
|
|
276
|
+
if (setupError)
|
|
277
|
+
throw setupError;
|
|
278
|
+
if (this.stopped)
|
|
279
|
+
throw new VoiceError("Voice start cancelled.", 499);
|
|
280
|
+
return { id: this.id, token: this.token, provider: "xai", transport: "pcm", sampleRate: VOICE_PCM_RATE, model: this.model, idleTimeoutMs: this.idleTimeoutMs };
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
clearTimeout(deadline);
|
|
284
|
+
this.abort.signal.removeEventListener("abort", aborted);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
attachAudio(close) {
|
|
288
|
+
if (this.stopped || this.providerName !== "xai" || this.audioAttached)
|
|
289
|
+
throw new VoiceError("Voice audio is unavailable or already connected.", 409);
|
|
290
|
+
this.audioAttached = true;
|
|
291
|
+
this.audioClose = close;
|
|
292
|
+
}
|
|
293
|
+
appendAudio(audio) {
|
|
294
|
+
if (this.stopped || !this.audioAttached || this.providerName !== "xai")
|
|
295
|
+
throw new VoiceError("Voice audio is closed.", 409);
|
|
296
|
+
if (audio.length === 0 || audio.length > 16_000 || audio.length % 2 !== 0)
|
|
297
|
+
throw new VoiceError("Invalid voice audio frame.", 400);
|
|
298
|
+
this.control?.send({ type: "input_audio_buffer.append", audio: audio.toString("base64") });
|
|
299
|
+
}
|
|
300
|
+
interruptPlayback(responseId, itemId, audioEndMs) {
|
|
301
|
+
this.interruptTranscript(responseId);
|
|
302
|
+
if (this.stopped || this.providerName !== "xai" || this.abandonedResponses.has(responseId) || !itemId || audioEndMs === undefined || !this.responseItems.get(responseId)?.has(itemId))
|
|
303
|
+
return;
|
|
304
|
+
this.control?.send({ type: "conversation.item.truncate", item_id: itemId, content_index: 0, audio_end_ms: audioEndMs });
|
|
305
|
+
}
|
|
306
|
+
subscribe(listener) {
|
|
307
|
+
if (this.listener || this.stopped)
|
|
308
|
+
throw new VoiceError("Voice session is already connected or closed.", 409);
|
|
309
|
+
this.listener = listener;
|
|
310
|
+
this.backlog.splice(0).forEach(listener);
|
|
311
|
+
}
|
|
312
|
+
emit(event) {
|
|
313
|
+
if (this.listener)
|
|
314
|
+
this.listener(event);
|
|
315
|
+
else {
|
|
316
|
+
this.backlog.push(event);
|
|
317
|
+
if (this.backlog.length > 256)
|
|
318
|
+
this.fail("Voice event connection was not established. Start voice again.");
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
greet() {
|
|
322
|
+
if (this.greeted || this.stopped || !this.control || !this.sendActivation)
|
|
323
|
+
return;
|
|
324
|
+
this.greeted = true;
|
|
325
|
+
if (this.currentTurn || this.speaking || this.activeResponse || this.needsResponse || this.pendingTools > 0 || this.waitingAction || this.reviewCall)
|
|
326
|
+
return;
|
|
327
|
+
this.activationPending = true;
|
|
328
|
+
this.activeResponse = true;
|
|
329
|
+
this.sendActivation();
|
|
330
|
+
}
|
|
331
|
+
isLineResponse(responseId) {
|
|
332
|
+
return !!responseId && responseId === this.activationResponseId;
|
|
333
|
+
}
|
|
334
|
+
dropLine() {
|
|
335
|
+
this.activationPending = false;
|
|
336
|
+
this.activeResponse = false;
|
|
337
|
+
}
|
|
338
|
+
finalizeActivationCaption(responseId) {
|
|
339
|
+
if (!responseId || responseId !== this.activationResponseId || this.activationCaptionFinal)
|
|
340
|
+
return;
|
|
341
|
+
const id = [...(this.responseItems.get(responseId) ?? [])][0] ?? `activation:${responseId}`;
|
|
342
|
+
this.activationItems.add(id);
|
|
343
|
+
this.caption(id, "assistant", VOICE_ACTIVATION_PHRASE, true);
|
|
344
|
+
this.activationCaptionFinal = true;
|
|
345
|
+
}
|
|
346
|
+
turn(id) {
|
|
347
|
+
let turn = this.turns.get(id);
|
|
348
|
+
if (!turn) {
|
|
349
|
+
turn = { text: "", saved: deferred(), final: false };
|
|
350
|
+
this.turns.set(id, turn);
|
|
351
|
+
}
|
|
352
|
+
return turn;
|
|
353
|
+
}
|
|
354
|
+
save(operation) {
|
|
355
|
+
this.writes = this.writes.then(async () => {
|
|
356
|
+
if (!this.scopeAlive())
|
|
357
|
+
return;
|
|
358
|
+
await operation();
|
|
359
|
+
}).catch(() => { this.fail("Could not save voice history. Voice stopped; your typed draft is safe."); });
|
|
360
|
+
}
|
|
361
|
+
caption(id, role, text, final, userTurnId) {
|
|
362
|
+
if (this.captions.get(id)?.final)
|
|
363
|
+
return;
|
|
364
|
+
const value = { id, role, text: text.slice(0, 16_000), final };
|
|
365
|
+
this.captions.set(id, value);
|
|
366
|
+
this.emit({ type: "caption", caption: value });
|
|
367
|
+
if (!final)
|
|
368
|
+
return;
|
|
369
|
+
if (role === "assistant" && userTurnId && !this.turn(userTurnId).final) {
|
|
370
|
+
const waiting = this.pendingAssistantHistory.get(userTurnId) ?? [];
|
|
371
|
+
waiting.push(value);
|
|
372
|
+
this.pendingAssistantHistory.set(userTurnId, waiting);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const turn = role === "user" ? this.turn(id) : null;
|
|
376
|
+
if (turn) {
|
|
377
|
+
turn.final = true;
|
|
378
|
+
turn.text = value.text;
|
|
379
|
+
}
|
|
380
|
+
this.save(async () => {
|
|
381
|
+
if (turn && value.text.trim()) {
|
|
382
|
+
const result = await this.engine.prompts.record({ surface: "chat", prompt: value.text, context: { source: "voice", sessionId: this.id, turnId: id, model: this.model, notebookId: this.notebookId } });
|
|
383
|
+
if (!result.ok) {
|
|
384
|
+
turn.saved.resolve(false);
|
|
385
|
+
throw new Error(result.error.message);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (!this.activationItems.has(value.id)) {
|
|
389
|
+
await appendVoiceHistory(this.engine, { ...value, sessionId: this.id, notebookId: this.notebookId, model: this.model });
|
|
390
|
+
}
|
|
391
|
+
if (turn) {
|
|
392
|
+
for (const pending of this.pendingAssistantHistory.get(id) ?? []) {
|
|
393
|
+
const latest = this.captions.get(pending.id) ?? pending;
|
|
394
|
+
await appendVoiceHistory(this.engine, { ...latest, text: latest.interrupted ? "" : latest.text, sessionId: this.id, notebookId: this.notebookId, model: this.model });
|
|
395
|
+
}
|
|
396
|
+
this.pendingAssistantHistory.delete(id);
|
|
397
|
+
}
|
|
398
|
+
turn?.saved.resolve(true);
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
receive(event) {
|
|
402
|
+
if (this.stopped)
|
|
403
|
+
return;
|
|
404
|
+
if (event.event_id && this.seen.has(event.event_id))
|
|
405
|
+
return;
|
|
406
|
+
if (event.event_id)
|
|
407
|
+
this.seen.add(event.event_id);
|
|
408
|
+
if (this.seen.size > 20_000) {
|
|
409
|
+
void this.close("Comms session limit reached. Start voice to resume.");
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (!this.scopeAlive()) {
|
|
413
|
+
void this.close("Workspace closed.");
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const responseId = event.response_id ?? (typeof event.response?.id === "string" ? event.response.id : undefined);
|
|
417
|
+
if (responseId && this.abandonedResponses.has(responseId) && event.type !== "response.done")
|
|
418
|
+
return;
|
|
419
|
+
if (responseId)
|
|
420
|
+
this.replyProgress.progress(responseId, Date.now());
|
|
421
|
+
const id = event.item_id ?? "";
|
|
422
|
+
if (id && event.response_id && (event.type.startsWith("response.output_audio") || event.type.startsWith("response.audio."))) {
|
|
423
|
+
const items = this.responseItems.get(event.response_id) ?? new Set();
|
|
424
|
+
items.add(id);
|
|
425
|
+
this.responseItems.set(event.response_id, items);
|
|
426
|
+
if (this.interruptedResponses.has(event.response_id))
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (event.type === "input_audio_buffer.speech_started") {
|
|
430
|
+
this.lastActivity = Date.now();
|
|
431
|
+
this.currentTurn = id;
|
|
432
|
+
this.replyProgress.beginTurn();
|
|
433
|
+
this.recoveringTurn = null;
|
|
434
|
+
this.turn(id);
|
|
435
|
+
this.caption(id, "user", "", false);
|
|
436
|
+
this.speaking = true;
|
|
437
|
+
this.needsResponse = false;
|
|
438
|
+
this.activeResponse = false;
|
|
439
|
+
this.emit({ type: "activity", phase: "listening", interrupt: this.providerName === "xai" });
|
|
440
|
+
}
|
|
441
|
+
else if (event.type === "input_audio_buffer.speech_stopped") {
|
|
442
|
+
this.speaking = false;
|
|
443
|
+
this.lastActivity = Date.now();
|
|
444
|
+
this.replyProgress.expect(this.lastActivity);
|
|
445
|
+
this.emit({ type: "activity", phase: "thinking" });
|
|
446
|
+
}
|
|
447
|
+
else if (event.type === "conversation.item.input_audio_transcription.delta") {
|
|
448
|
+
this.caption(id, "user", (this.captions.get(id)?.text ?? "") + (event.delta ?? ""), false);
|
|
449
|
+
}
|
|
450
|
+
else if (event.type === "conversation.item.input_audio_transcription.updated") {
|
|
451
|
+
this.caption(id, "user", event.transcript ?? "", false);
|
|
452
|
+
}
|
|
453
|
+
else if (event.type === "conversation.item.input_audio_transcription.completed") {
|
|
454
|
+
if (id && this.providerName === "openai")
|
|
455
|
+
recordTranscriptionUsage(this.engine, VOICE_TRANSCRIPTION_MODEL, this.id, id, event);
|
|
456
|
+
this.caption(id, "user", event.transcript ?? "", true);
|
|
457
|
+
}
|
|
458
|
+
else if (event.type === "conversation.item.input_audio_transcription.failed") {
|
|
459
|
+
if (id && this.providerName === "openai")
|
|
460
|
+
recordTranscriptionUsage(this.engine, VOICE_TRANSCRIPTION_MODEL, this.id, id, event);
|
|
461
|
+
this.fail(providerErrorMessage(event.error, undefined, this.providerName) ?? "Could not transcribe this turn. Start voice and try again.");
|
|
462
|
+
}
|
|
463
|
+
else if (this.providerName === "xai" && (event.type === "response.output_audio.delta" || event.type === "response.audio.delta")) {
|
|
464
|
+
if (event.response_id && event.item_id && event.delta && !this.interruptedResponses.has(event.response_id)) {
|
|
465
|
+
this.emit({ type: "audio", audio: event.delta, responseId: event.response_id, itemId: event.item_id });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
else if (this.providerName === "xai" && (event.type === "response.output_audio.done" || event.type === "response.audio.done")) {
|
|
469
|
+
if (event.response_id)
|
|
470
|
+
this.emit({ type: "audio-done", responseId: event.response_id });
|
|
471
|
+
}
|
|
472
|
+
else if (event.type === "response.output_audio_transcript.delta") {
|
|
473
|
+
const line = this.isLineResponse(responseId);
|
|
474
|
+
if (line && id)
|
|
475
|
+
this.activationItems.add(id);
|
|
476
|
+
this.caption(id, "assistant", line ? VOICE_ACTIVATION_PHRASE : (this.captions.get(id)?.text ?? "") + (event.delta ?? ""), false);
|
|
477
|
+
}
|
|
478
|
+
else if (event.type === "response.output_audio_transcript.done") {
|
|
479
|
+
const line = this.isLineResponse(responseId);
|
|
480
|
+
if (line && id)
|
|
481
|
+
this.activationItems.add(id);
|
|
482
|
+
this.caption(id, "assistant", line ? VOICE_ACTIVATION_PHRASE : event.transcript ?? this.captions.get(id)?.text ?? "", true, event.response_id ? this.responseTurns.get(event.response_id) : undefined);
|
|
483
|
+
if (line)
|
|
484
|
+
this.activationCaptionFinal = true;
|
|
485
|
+
}
|
|
486
|
+
else if (event.type === "conversation.item.truncated") {
|
|
487
|
+
const caption = this.captions.get(id);
|
|
488
|
+
if (caption && !caption.interrupted) {
|
|
489
|
+
const interrupted = { ...caption, final: true, interrupted: true };
|
|
490
|
+
this.captions.set(id, interrupted);
|
|
491
|
+
this.emit({ type: "caption", caption: interrupted });
|
|
492
|
+
if (!this.activationItems.has(id)) {
|
|
493
|
+
this.save(() => appendVoiceHistory(this.engine, { ...interrupted, text: "", sessionId: this.id, notebookId: this.notebookId, model: this.model }));
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
else if (event.type === "response.created") {
|
|
498
|
+
this.activeResponse = true;
|
|
499
|
+
if (responseId) {
|
|
500
|
+
this.pendingUsage.add(responseId);
|
|
501
|
+
recordVoiceUsage(this.engine, this.providerName, this.model, this.id, responseId, { ...event.response, status: "in_progress" });
|
|
502
|
+
this.responseTurns.set(responseId, this.currentTurn);
|
|
503
|
+
this.replyProgress.started(responseId, Date.now());
|
|
504
|
+
if (this.activationPending && (this.providerName === "xai" || event.response?.metadata?.purpose === "activation")) {
|
|
505
|
+
this.activationPending = false;
|
|
506
|
+
this.activationResponseId = responseId;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
else if (event.type === "response.done") {
|
|
511
|
+
if (responseId) {
|
|
512
|
+
this.pendingUsage.delete(responseId);
|
|
513
|
+
recordVoiceUsage(this.engine, this.providerName, this.model, this.id, responseId, event.response);
|
|
514
|
+
}
|
|
515
|
+
const completion = this.replyProgress.finished(responseId, Date.now());
|
|
516
|
+
const usage = event.response?.usage;
|
|
517
|
+
this.usage.responses += 1;
|
|
518
|
+
this.usage.inputTokens += typeof usage?.input_tokens === "number" ? usage.input_tokens : 0;
|
|
519
|
+
this.usage.outputTokens += typeof usage?.output_tokens === "number" ? usage.output_tokens : 0;
|
|
520
|
+
if (completion === "stale")
|
|
521
|
+
return;
|
|
522
|
+
this.activeResponse = false;
|
|
523
|
+
if (completion === "retry")
|
|
524
|
+
this.needsResponse = true;
|
|
525
|
+
if (event.response?.status === "failed") {
|
|
526
|
+
const details = z.object({ error: z.unknown().optional() }).safeParse(event.response.status_details);
|
|
527
|
+
const guidance = providerErrorMessage(details.success ? details.data.error : undefined, undefined, this.providerName);
|
|
528
|
+
if (guidance) {
|
|
529
|
+
this.fail(guidance);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (this.activationPending || this.isLineResponse(responseId)) {
|
|
533
|
+
this.dropLine();
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
this.fail(guidance ?? "The voice reply failed. Start voice to retry.");
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
this.finalizeActivationCaption(responseId);
|
|
540
|
+
this.continueResponse();
|
|
541
|
+
}
|
|
542
|
+
else if (event.type === "response.function_call_arguments.done" && event.call_id) {
|
|
543
|
+
const key = `call:${event.call_id}`;
|
|
544
|
+
if (this.seen.has(key))
|
|
545
|
+
return;
|
|
546
|
+
this.seen.add(key);
|
|
547
|
+
const turnId = (event.response_id && this.responseTurns.get(event.response_id)) || this.currentTurn;
|
|
548
|
+
this.pendingTools += 1;
|
|
549
|
+
this.toolChain = this.toolChain.then(() => this.runTool(event, turnId)).catch(() => { this.fail("Voice action failed. Check the command and try again."); }).finally(() => { this.pendingTools -= 1; this.continueResponse(); });
|
|
550
|
+
}
|
|
551
|
+
else if (event.type === "error") {
|
|
552
|
+
if (event.error?.code === "response_cancel_not_active") {
|
|
553
|
+
if (this.replyProgress.cancelled(Date.now())) {
|
|
554
|
+
this.activeResponse = false;
|
|
555
|
+
this.needsResponse = true;
|
|
556
|
+
this.continueResponse();
|
|
557
|
+
}
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const guidance = providerErrorMessage(event.error, undefined, this.providerName);
|
|
561
|
+
if (guidance) {
|
|
562
|
+
this.fail(guidance);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (this.activationPending || this.isLineResponse(responseId) || event.error?.code === "conversation_already_has_active_response") {
|
|
566
|
+
this.dropLine();
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
this.fail("Voice service reported an error. Start voice again; check model access if it repeats.");
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
continueResponse() {
|
|
573
|
+
if (!this.needsResponse || this.activeResponse || this.pendingTools > 0 || this.speaking || this.stopped)
|
|
574
|
+
return;
|
|
575
|
+
this.needsResponse = false;
|
|
576
|
+
this.activeResponse = true;
|
|
577
|
+
this.replyProgress.expect(Date.now());
|
|
578
|
+
this.control?.send({ type: "response.create" });
|
|
579
|
+
}
|
|
580
|
+
recoverReply() {
|
|
581
|
+
if (this.stopped || !this.control || !this.currentTurn || this.speaking || this.pendingTools > 0)
|
|
582
|
+
return;
|
|
583
|
+
const recovery = this.replyProgress.check(Date.now());
|
|
584
|
+
if (!recovery)
|
|
585
|
+
return;
|
|
586
|
+
if (recovery.type === "failed") {
|
|
587
|
+
this.fail("The voice service stopped responding. Start voice to try again.");
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
this.emit({ type: "activity", phase: "thinking", interrupt: this.providerName === "xai", retry: true });
|
|
591
|
+
this.recoveringTurn = this.currentTurn;
|
|
592
|
+
if (recovery.responseId) {
|
|
593
|
+
this.abandonedResponses.add(recovery.responseId);
|
|
594
|
+
this.interruptTranscript(recovery.responseId);
|
|
595
|
+
this.control.send({ type: "response.cancel", response_id: recovery.responseId });
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
this.activeResponse = false;
|
|
599
|
+
this.needsResponse = true;
|
|
600
|
+
this.continueResponse();
|
|
601
|
+
}
|
|
602
|
+
signature() {
|
|
603
|
+
try {
|
|
604
|
+
return voiceWorkspaceSignature(this.engine, this.notebookId);
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
return this.workspaceSignature ?? "";
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Push the board back into the live session when it has actually changed.
|
|
612
|
+
* The instructions carry a snapshot taken at connect, and a session lasts half
|
|
613
|
+
* an hour, so without this the model argues about cells it made itself. Only
|
|
614
|
+
* between turns: a session.update mid-response would land in the middle of the
|
|
615
|
+
* model's own sentence.
|
|
616
|
+
*/
|
|
617
|
+
syncWorkspace() {
|
|
618
|
+
if (!this.pushSession || !this.control || this.stopped)
|
|
619
|
+
return;
|
|
620
|
+
if (this.activeResponse || this.speaking || this.pendingTools > 0 || this.needsResponse)
|
|
621
|
+
return;
|
|
622
|
+
const signature = this.signature();
|
|
623
|
+
if (signature === this.workspaceSignature)
|
|
624
|
+
return;
|
|
625
|
+
this.workspaceSignature = signature;
|
|
626
|
+
this.pushSession(voiceWorkspaceContext(this.engine, this.notebookId));
|
|
627
|
+
}
|
|
628
|
+
output(callId, result, turnId) {
|
|
629
|
+
if (this.stopped)
|
|
630
|
+
return;
|
|
631
|
+
const serialized = JSON.stringify(this.withWorkspace(result));
|
|
632
|
+
const output = serialized.length <= 40_000 ? serialized : JSON.stringify({ truncated: true, resultText: serialized.slice(0, 36_000) });
|
|
633
|
+
this.control?.send({ type: "conversation.item.create", item: { type: "function_call_output", call_id: callId, output } });
|
|
634
|
+
if (turnId === this.currentTurn) {
|
|
635
|
+
this.needsResponse = true;
|
|
636
|
+
this.continueResponse();
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* A tool call outlives the turn that asked for it whenever the speaker keeps
|
|
641
|
+
* talking -- a second clause, or just "hello?" -- because every new speech
|
|
642
|
+
* segment starts a new turn. Continuation clauses reuse the pending
|
|
643
|
+
* prepare_workspace_action against the joined transcript. A genuinely new
|
|
644
|
+
* request still has to be answered so the model does not hang believing work
|
|
645
|
+
* is running: say it was dropped and let the model re-plan. The stale turn
|
|
646
|
+
* never provokes a reply of its own; output() only continues the response
|
|
647
|
+
* for the current turn.
|
|
648
|
+
*/
|
|
649
|
+
abandon(callId, turnId) {
|
|
650
|
+
this.output(callId, {
|
|
651
|
+
status: "failed",
|
|
652
|
+
message: "Dropped: the speaker talked again, so this call belongs to an earlier turn and did not go through. Nothing changed in the workspace and nothing is running. Answer the newest request, repeating this action for it if it is still wanted.",
|
|
653
|
+
}, turnId);
|
|
654
|
+
}
|
|
655
|
+
async waitTurnText(turnId) {
|
|
656
|
+
const turn = this.turn(turnId);
|
|
657
|
+
if (turn.final)
|
|
658
|
+
return turn.text;
|
|
659
|
+
const timeout = setTimeout(() => turn.saved.resolve(false), 10_000);
|
|
660
|
+
await turn.saved.promise.finally(() => clearTimeout(timeout));
|
|
661
|
+
return turn.text;
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Bind a superseded prepare_workspace_action to the conversation: join the
|
|
665
|
+
* original turn with later continuation clauses, or signal abandon when the
|
|
666
|
+
* speaker started a new request.
|
|
667
|
+
*/
|
|
668
|
+
async resolvePreparePrompt(originalTurnId) {
|
|
669
|
+
const original = (await this.waitTurnText(originalTurnId)).trim();
|
|
670
|
+
if (this.stopped)
|
|
671
|
+
return null;
|
|
672
|
+
let latestId = this.currentTurn || originalTurnId;
|
|
673
|
+
let latest = latestId === originalTurnId ? original : (await this.waitTurnText(latestId)).trim();
|
|
674
|
+
if (this.stopped)
|
|
675
|
+
return null;
|
|
676
|
+
if (this.currentTurn && this.currentTurn !== latestId) {
|
|
677
|
+
latestId = this.currentTurn;
|
|
678
|
+
latest = (await this.waitTurnText(latestId)).trim();
|
|
679
|
+
}
|
|
680
|
+
if (!latestId || latestId === originalTurnId) {
|
|
681
|
+
if (!original)
|
|
682
|
+
return null;
|
|
683
|
+
return { prompt: original, outputTurnId: originalTurnId };
|
|
684
|
+
}
|
|
685
|
+
if (!isVoiceTurnContinuation(original, latest))
|
|
686
|
+
return null;
|
|
687
|
+
return { prompt: joinVoiceTranscripts(original, latest), outputTurnId: latestId };
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Every tool result carries the current board. The model reaches for a tool
|
|
691
|
+
* exactly when it is about to act on an address, so this is the cheapest
|
|
692
|
+
* moment to correct a stale picture. Cells only -- the conversation is
|
|
693
|
+
* already in its context and would crowd out the result itself.
|
|
694
|
+
*/
|
|
695
|
+
withWorkspace(result) {
|
|
696
|
+
if (!result || typeof result !== "object" || Array.isArray(result))
|
|
697
|
+
return result;
|
|
698
|
+
try {
|
|
699
|
+
return { ...result, workspace: voiceWorkspaceCells(this.engine, this.notebookId) };
|
|
700
|
+
}
|
|
701
|
+
catch {
|
|
702
|
+
return result;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
async runTool(event, turnId) {
|
|
706
|
+
const callId = event.call_id;
|
|
707
|
+
if (this.stopped)
|
|
708
|
+
return;
|
|
709
|
+
if (turnId !== this.currentTurn && event.name !== "prepare_workspace_action") {
|
|
710
|
+
this.abandon(callId, turnId);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
try {
|
|
714
|
+
if (!this.control || !turnId)
|
|
715
|
+
throw new Error("Wait for a spoken user request.");
|
|
716
|
+
const args = JSON.parse(event.arguments ?? "{}");
|
|
717
|
+
if (event.name === "find_workspace_actions") {
|
|
718
|
+
const { query } = discoverySchema.parse(args);
|
|
719
|
+
this.output(callId, { actions: discoverVoiceActions(this.engine, query) }, turnId);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
if (event.name === "resolve_pending_review") {
|
|
723
|
+
this.output(callId, await this.resolveReview(callId, reviewSchema.parse(args)), turnId);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (event.name !== "prepare_workspace_action")
|
|
727
|
+
throw new Error("Unknown voice action.");
|
|
728
|
+
if (this.recoveringTurn === turnId && this.lastAction?.turnId === turnId) {
|
|
729
|
+
this.output(callId, this.lastAction.outcome, turnId);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (this.reviewCall || this.waitingAction)
|
|
733
|
+
throw new Error("A review is already on screen. Use resolve_pending_review to apply, cancel, or revise it before requesting another workspace action.");
|
|
734
|
+
const resolved = await this.resolvePreparePrompt(turnId);
|
|
735
|
+
if (this.stopped)
|
|
736
|
+
return;
|
|
737
|
+
if (!resolved?.prompt) {
|
|
738
|
+
if (turnId !== this.currentTurn) {
|
|
739
|
+
this.abandon(callId, turnId);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
throw new Error("The user transcript could not be saved. Ask the user to repeat the request.");
|
|
743
|
+
}
|
|
744
|
+
let prompt = resolved.prompt;
|
|
745
|
+
let outputTurnId = resolved.outputTurnId;
|
|
746
|
+
if (!this.scopeAlive())
|
|
747
|
+
throw new Error("The workspace closed before this action ran. Nothing was prepared.");
|
|
748
|
+
const parsed = actionSchema.parse(args);
|
|
749
|
+
if (parsed.params.book_id !== undefined && parsed.params.book_id !== this.bookId)
|
|
750
|
+
throw new Error("Voice actions must stay in the current project.");
|
|
751
|
+
if (parsed.params.notebook_id !== undefined && parsed.params.notebook_id !== this.notebookId)
|
|
752
|
+
throw new Error("Voice actions must stay in the current notebook.");
|
|
753
|
+
let result;
|
|
754
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
755
|
+
const decision = {
|
|
756
|
+
...parsed,
|
|
757
|
+
message: prompt,
|
|
758
|
+
params: typeof parsed.params.prompt === "string"
|
|
759
|
+
? { ...parsed.params, prompt }
|
|
760
|
+
: parsed.params,
|
|
761
|
+
};
|
|
762
|
+
result = await withBookContext({ bookId: this.bookId, engine: this.engine }, () => prepareVoiceChatAction(this.engine, { book_id: this.bookId, notebook_id: this.notebookId, prompt }, decision));
|
|
763
|
+
if (this.stopped)
|
|
764
|
+
return;
|
|
765
|
+
if (this.currentTurn && this.currentTurn !== outputTurnId) {
|
|
766
|
+
const newer = (await this.waitTurnText(this.currentTurn)).trim();
|
|
767
|
+
if (!isVoiceTurnContinuation(prompt, newer)) {
|
|
768
|
+
this.abandon(callId, turnId);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
prompt = joinVoiceTranscripts(prompt, newer);
|
|
772
|
+
outputTurnId = this.currentTurn;
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
break;
|
|
776
|
+
}
|
|
777
|
+
if (!result) {
|
|
778
|
+
this.abandon(callId, turnId);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const raw = result.content[0]?.text ?? "null";
|
|
782
|
+
if (result.isError) {
|
|
783
|
+
this.output(callId, { status: "failed", message: raw }, outputTurnId);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
const value = JSON.parse(raw);
|
|
787
|
+
const plan = chatActionPlanSchema.safeParse(value);
|
|
788
|
+
if (!plan.success) {
|
|
789
|
+
this.output(callId, { status: "completed", result: value }, outputTurnId);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const done = deferred();
|
|
793
|
+
this.waitingAction = { callId, kind: "action", done };
|
|
794
|
+
this.emit({ type: "action", callId, prompt, plan: plan.data });
|
|
795
|
+
const deadline = setTimeout(() => done.resolve({ status: "failed", message: "The command UI did not acknowledge this action. Check its current status before retrying." }), 60_000);
|
|
796
|
+
const outcome = await done.promise.finally(() => clearTimeout(deadline));
|
|
797
|
+
this.lastAction = { turnId: outputTurnId, callId, outcome };
|
|
798
|
+
if (this.waitingAction?.callId === callId)
|
|
799
|
+
this.waitingAction = null;
|
|
800
|
+
if (outcome.status === "review")
|
|
801
|
+
this.reviewCall = callId;
|
|
802
|
+
this.output(callId, outcome, outputTurnId);
|
|
803
|
+
}
|
|
804
|
+
catch (error) {
|
|
805
|
+
this.output(callId, { status: "failed", message: error instanceof z.ZodError ? "Invalid action parameters. Use the discovered schema." : error instanceof Error ? error.message : "The voice action failed." }, turnId);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Hand a spoken decision about the on-screen review to the command UI and
|
|
810
|
+
* wait for what actually happened. The review stays open on a revise, so the
|
|
811
|
+
* speaker can keep changing it before spending anything.
|
|
812
|
+
*/
|
|
813
|
+
async resolveReview(callId, command) {
|
|
814
|
+
if (!this.reviewCall)
|
|
815
|
+
throw new Error("There is no review on screen to act on.");
|
|
816
|
+
if (this.waitingAction)
|
|
817
|
+
throw new Error("The command UI is still working on the last spoken decision.");
|
|
818
|
+
if (command.decision === "revise" && !command.prompt?.trim())
|
|
819
|
+
throw new Error("Revise needs the whole replacement prompt.");
|
|
820
|
+
const done = deferred();
|
|
821
|
+
this.waitingAction = { callId, kind: "review", done };
|
|
822
|
+
this.emit({ type: "review", callId, command });
|
|
823
|
+
const deadline = setTimeout(() => done.resolve({ status: "failed", message: "The command UI did not act on that. Check the review on screen." }), 60_000);
|
|
824
|
+
const outcome = await done.promise.finally(() => clearTimeout(deadline));
|
|
825
|
+
if (this.waitingAction?.callId === callId)
|
|
826
|
+
this.waitingAction = null;
|
|
827
|
+
if (outcome.status !== "review")
|
|
828
|
+
this.reviewCall = null;
|
|
829
|
+
return outcome;
|
|
830
|
+
}
|
|
831
|
+
acknowledge(callId, outcome) {
|
|
832
|
+
if (this.stopped)
|
|
833
|
+
throw new VoiceError("Voice session is closed.", 410);
|
|
834
|
+
this.lastActivity = Date.now();
|
|
835
|
+
if (this.waitingAction?.callId === callId) {
|
|
836
|
+
if (outcome.status === "review" && this.waitingAction.kind === "action")
|
|
837
|
+
this.reviewCall = callId;
|
|
838
|
+
this.waitingAction.done.resolve(outcome);
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (this.reviewCall !== callId)
|
|
842
|
+
throw new VoiceError("This voice action is no longer pending.", 409);
|
|
843
|
+
if (outcome.status !== "review")
|
|
844
|
+
this.reviewCall = null;
|
|
845
|
+
if (this.lastAction?.callId === callId)
|
|
846
|
+
this.lastAction.outcome = outcome;
|
|
847
|
+
// A spoken decision reports this outcome and then returns it as the tool
|
|
848
|
+
// result. Telling the model twice would make it answer twice.
|
|
849
|
+
if (this.waitingAction)
|
|
850
|
+
return;
|
|
851
|
+
this.contextMessage({ applicationOutcome: outcome });
|
|
852
|
+
}
|
|
853
|
+
interruptTranscript(responseId) {
|
|
854
|
+
if (this.stopped)
|
|
855
|
+
return;
|
|
856
|
+
this.interruptedResponses.add(responseId);
|
|
857
|
+
for (const id of this.responseItems.get(responseId) ?? []) {
|
|
858
|
+
if (this.activationItems.has(id))
|
|
859
|
+
continue;
|
|
860
|
+
const caption = this.captions.get(id);
|
|
861
|
+
if (!caption || caption.interrupted)
|
|
862
|
+
continue;
|
|
863
|
+
const interrupted = { ...caption, final: true, interrupted: true };
|
|
864
|
+
this.captions.set(id, interrupted);
|
|
865
|
+
this.emit({ type: "caption", caption: interrupted });
|
|
866
|
+
this.save(() => appendVoiceHistory(this.engine, { ...interrupted, text: "", sessionId: this.id, notebookId: this.notebookId, model: this.model }));
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
contextMessage(value) {
|
|
870
|
+
if (this.stopped)
|
|
871
|
+
return;
|
|
872
|
+
this.sendContextNote?.(`Application update (data): ${JSON.stringify(value)}`);
|
|
873
|
+
if (value.applicationOutcome) {
|
|
874
|
+
this.needsResponse = true;
|
|
875
|
+
this.continueResponse();
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
fail(message) {
|
|
879
|
+
if (this.stopped)
|
|
880
|
+
return;
|
|
881
|
+
if (this.listener)
|
|
882
|
+
this.listener({ type: "error", message });
|
|
883
|
+
void this.close(message);
|
|
884
|
+
}
|
|
885
|
+
close(reason = "Comms off.") {
|
|
886
|
+
if (this.closing)
|
|
887
|
+
return this.closing;
|
|
888
|
+
this.stopped = true;
|
|
889
|
+
for (const responseId of this.pendingUsage)
|
|
890
|
+
recordVoiceUsage(this.engine, this.providerName, this.model, this.id, responseId, { status: "uncertain" });
|
|
891
|
+
this.pendingUsage.clear();
|
|
892
|
+
clearInterval(this.timer);
|
|
893
|
+
this.abort.abort();
|
|
894
|
+
this.control?.close();
|
|
895
|
+
this.audioClose?.();
|
|
896
|
+
this.audioClose = undefined;
|
|
897
|
+
for (const turn of this.turns.values())
|
|
898
|
+
turn.saved.resolve(false);
|
|
899
|
+
this.waitingAction?.done.resolve({ status: "failed", message: "Voice stopped. Check the command UI for accepted actions." });
|
|
900
|
+
this.listener?.({ type: "closed", reason });
|
|
901
|
+
this.listener = null;
|
|
902
|
+
this.backlog = [];
|
|
903
|
+
this.closing = (async () => {
|
|
904
|
+
const negotiated = await this.negotiation?.catch(() => null);
|
|
905
|
+
this.callId ??= negotiated?.callId ?? null;
|
|
906
|
+
if (this.callId)
|
|
907
|
+
await this.provider.post.v1.realtime.calls.hangup(this.callId).catch(() => undefined);
|
|
908
|
+
await this.writes;
|
|
909
|
+
if (this.scopeAlive())
|
|
910
|
+
await appendDiagnostic(this.engine, "voice-session-closed", { operationId: this.id, bookId: this.bookId, notebookId: this.notebookId, provider: this.providerName, voiceModel: this.model, count: this.usage.responses, inputTokens: this.usage.inputTokens, outputTokens: this.usage.outputTokens, durationMs: Date.now() - this.startedAt }).catch(() => undefined);
|
|
911
|
+
this.apiKey = "";
|
|
912
|
+
})().finally(this.onClose);
|
|
913
|
+
return this.closing;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
export class VoiceSessions {
|
|
917
|
+
books;
|
|
918
|
+
sessions = new Map();
|
|
919
|
+
closed = false;
|
|
920
|
+
constructor(books) {
|
|
921
|
+
this.books = books;
|
|
922
|
+
}
|
|
923
|
+
async start(input, signal) {
|
|
924
|
+
if (this.closed)
|
|
925
|
+
throw new VoiceError("Voice service is shutting down.", 503);
|
|
926
|
+
if (this.sessions.size >= 4)
|
|
927
|
+
throw new VoiceError("Four voice sessions are already active. Stop another session first.", 429);
|
|
928
|
+
const book = this.books.get(input.bookId);
|
|
929
|
+
if (!book.ok)
|
|
930
|
+
throw new VoiceError("Open an available project before starting voice.", 404);
|
|
931
|
+
if (input.notebookId && !book.value.engine.notebooks.read(input.notebookId).ok)
|
|
932
|
+
throw new VoiceError("This notebook is no longer available.", 404);
|
|
933
|
+
const configured = Number(process.env.VIDEOBOOK_VOICE_IDLE_TIMEOUT_MS ?? 120_000);
|
|
934
|
+
const idle = Number.isFinite(configured) ? Math.min(600_000, Math.max(10_000, configured)) : 120_000;
|
|
935
|
+
const attempt = async (provider) => {
|
|
936
|
+
if (signal.aborted || this.closed)
|
|
937
|
+
throw new VoiceError("Voice start cancelled.", 499);
|
|
938
|
+
if (this.sessions.size >= 4)
|
|
939
|
+
throw new VoiceError("Four voice sessions are already active. Stop another session first.", 429);
|
|
940
|
+
const keyName = provider === "xai" ? "XAI_API_KEY" : "OPENAI_API_KEY";
|
|
941
|
+
const apiKey = getApiKey(keyName);
|
|
942
|
+
if (!apiKey)
|
|
943
|
+
throw new VoiceError(`Voice needs ${keyName} on the server. Configure it, then start voice again.`, 503);
|
|
944
|
+
const session = new VoiceSession(this.books, input.bookId, input.notebookId, book.value.engine, apiKey, idle, () => this.sessions.delete(session.id), provider, input.sampleRate);
|
|
945
|
+
this.sessions.set(session.id, session);
|
|
946
|
+
try {
|
|
947
|
+
return await session.start(input.sdp ?? "", signal);
|
|
948
|
+
}
|
|
949
|
+
catch (error) {
|
|
950
|
+
await session.close();
|
|
951
|
+
throw voiceError(error);
|
|
952
|
+
}
|
|
953
|
+
};
|
|
954
|
+
if (input.mode !== "auto")
|
|
955
|
+
return await attempt(input.mode ?? "openai");
|
|
956
|
+
try {
|
|
957
|
+
return await attempt("openai");
|
|
958
|
+
}
|
|
959
|
+
catch (error) {
|
|
960
|
+
const first = voiceError(error);
|
|
961
|
+
if (signal.aborted || this.closed || !(first.status >= 500 || [401, 403, 404, 408, 429].includes(first.status)))
|
|
962
|
+
throw first;
|
|
963
|
+
try {
|
|
964
|
+
return { ...await attempt("xai"), fallbackReason: first.message };
|
|
965
|
+
}
|
|
966
|
+
catch (failure) {
|
|
967
|
+
const last = voiceError(failure);
|
|
968
|
+
if (signal.aborted || this.closed)
|
|
969
|
+
throw last;
|
|
970
|
+
throw new VoiceError(`OpenAI: ${first.message} xAI: ${last.message}`, last.status);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
get(id, token) {
|
|
975
|
+
const session = this.sessions.get(id);
|
|
976
|
+
if (!session || !session.owns(token))
|
|
977
|
+
throw new VoiceError("Voice session was not found. Start voice again.", 404);
|
|
978
|
+
return session;
|
|
979
|
+
}
|
|
980
|
+
async stop(id, token, interruptedResponseId) {
|
|
981
|
+
if (!this.sessions.has(id))
|
|
982
|
+
return;
|
|
983
|
+
const session = this.get(id, token);
|
|
984
|
+
if (interruptedResponseId)
|
|
985
|
+
session.interruptTranscript(interruptedResponseId);
|
|
986
|
+
await session.close();
|
|
987
|
+
}
|
|
988
|
+
async close() {
|
|
989
|
+
this.closed = true;
|
|
990
|
+
await Promise.all([...this.sessions.values()].map((session) => session.close("Server is shutting down.")));
|
|
991
|
+
}
|
|
992
|
+
}
|