@nac3/forge-cli 0.2.0-alpha.1
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/LICENSE +45 -0
- package/README.md +371 -0
- package/dist/bin/yf.d.ts +5 -0
- package/dist/bin/yf.d.ts.map +1 -0
- package/dist/bin/yf.js +86 -0
- package/dist/bin/yf.js.map +1 -0
- package/dist/chat/claude.d.ts +100 -0
- package/dist/chat/claude.d.ts.map +1 -0
- package/dist/chat/claude.js +228 -0
- package/dist/chat/claude.js.map +1 -0
- package/dist/chat/ingest_session.d.ts +97 -0
- package/dist/chat/ingest_session.d.ts.map +1 -0
- package/dist/chat/ingest_session.js +99 -0
- package/dist/chat/ingest_session.js.map +1 -0
- package/dist/chat/panel.d.ts +15 -0
- package/dist/chat/panel.d.ts.map +1 -0
- package/dist/chat/panel.js +1526 -0
- package/dist/chat/panel.js.map +1 -0
- package/dist/chat/persistence.d.ts +37 -0
- package/dist/chat/persistence.d.ts.map +1 -0
- package/dist/chat/persistence.js +91 -0
- package/dist/chat/persistence.js.map +1 -0
- package/dist/chat/server.d.ts +34 -0
- package/dist/chat/server.d.ts.map +1 -0
- package/dist/chat/server.js +1540 -0
- package/dist/chat/server.js.map +1 -0
- package/dist/chat/spec_extract.d.ts +35 -0
- package/dist/chat/spec_extract.d.ts.map +1 -0
- package/dist/chat/spec_extract.js +152 -0
- package/dist/chat/spec_extract.js.map +1 -0
- package/dist/chat/spec_plan.d.ts +65 -0
- package/dist/chat/spec_plan.d.ts.map +1 -0
- package/dist/chat/spec_plan.js +160 -0
- package/dist/chat/spec_plan.js.map +1 -0
- package/dist/chat/spec_scaffold.d.ts +95 -0
- package/dist/chat/spec_scaffold.d.ts.map +1 -0
- package/dist/chat/spec_scaffold.js +220 -0
- package/dist/chat/spec_scaffold.js.map +1 -0
- package/dist/chat/tools/git.d.ts +59 -0
- package/dist/chat/tools/git.d.ts.map +1 -0
- package/dist/chat/tools/git.js +313 -0
- package/dist/chat/tools/git.js.map +1 -0
- package/dist/chat/tools/github.d.ts +59 -0
- package/dist/chat/tools/github.d.ts.map +1 -0
- package/dist/chat/tools/github.js +310 -0
- package/dist/chat/tools/github.js.map +1 -0
- package/dist/chat/tools/lifecycle.d.ts +82 -0
- package/dist/chat/tools/lifecycle.d.ts.map +1 -0
- package/dist/chat/tools/lifecycle.js +295 -0
- package/dist/chat/tools/lifecycle.js.map +1 -0
- package/dist/chat/tools/manual.d.ts +26 -0
- package/dist/chat/tools/manual.d.ts.map +1 -0
- package/dist/chat/tools/manual.js +164 -0
- package/dist/chat/tools/manual.js.map +1 -0
- package/dist/chat/tools/reader.d.ts +80 -0
- package/dist/chat/tools/reader.d.ts.map +1 -0
- package/dist/chat/tools/reader.js +471 -0
- package/dist/chat/tools/reader.js.map +1 -0
- package/dist/chat/tools.d.ts +106 -0
- package/dist/chat/tools.d.ts.map +1 -0
- package/dist/chat/tools.js +587 -0
- package/dist/chat/tools.js.map +1 -0
- package/dist/codegen/e2e.d.ts +106 -0
- package/dist/codegen/e2e.d.ts.map +1 -0
- package/dist/codegen/e2e.js +931 -0
- package/dist/codegen/e2e.js.map +1 -0
- package/dist/codegen/v3_flow_emit.d.ts +70 -0
- package/dist/codegen/v3_flow_emit.d.ts.map +1 -0
- package/dist/codegen/v3_flow_emit.js +225 -0
- package/dist/codegen/v3_flow_emit.js.map +1 -0
- package/dist/commands/_stub.d.ts +2 -0
- package/dist/commands/_stub.d.ts.map +1 -0
- package/dist/commands/_stub.js +21 -0
- package/dist/commands/_stub.js.map +1 -0
- package/dist/commands/app.d.ts +31 -0
- package/dist/commands/app.d.ts.map +1 -0
- package/dist/commands/app.js +331 -0
- package/dist/commands/app.js.map +1 -0
- package/dist/commands/chat.d.ts +18 -0
- package/dist/commands/chat.d.ts.map +1 -0
- package/dist/commands/chat.js +76 -0
- package/dist/commands/chat.js.map +1 -0
- package/dist/commands/deploy.d.ts +21 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +121 -0
- package/dist/commands/deploy.js.map +1 -0
- package/dist/commands/doctor.d.ts +14 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +280 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/figma.d.ts +32 -0
- package/dist/commands/figma.d.ts.map +1 -0
- package/dist/commands/figma.js +141 -0
- package/dist/commands/figma.js.map +1 -0
- package/dist/commands/gen-flow-tests.d.ts +8 -0
- package/dist/commands/gen-flow-tests.d.ts.map +1 -0
- package/dist/commands/gen-flow-tests.js +78 -0
- package/dist/commands/gen-flow-tests.js.map +1 -0
- package/dist/commands/gen-tests.d.ts +9 -0
- package/dist/commands/gen-tests.d.ts.map +1 -0
- package/dist/commands/gen-tests.js +118 -0
- package/dist/commands/gen-tests.js.map +1 -0
- package/dist/commands/license.d.ts +14 -0
- package/dist/commands/license.d.ts.map +1 -0
- package/dist/commands/license.js +182 -0
- package/dist/commands/license.js.map +1 -0
- package/dist/commands/log.d.ts +19 -0
- package/dist/commands/log.d.ts.map +1 -0
- package/dist/commands/log.js +101 -0
- package/dist/commands/log.js.map +1 -0
- package/dist/commands/migrate.d.ts +118 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +1410 -0
- package/dist/commands/migrate.js.map +1 -0
- package/dist/commands/mobile.d.ts +27 -0
- package/dist/commands/mobile.d.ts.map +1 -0
- package/dist/commands/mobile.js +90 -0
- package/dist/commands/mobile.js.map +1 -0
- package/dist/commands/new.d.ts +32 -0
- package/dist/commands/new.d.ts.map +1 -0
- package/dist/commands/new.js +107 -0
- package/dist/commands/new.js.map +1 -0
- package/dist/commands/pilot.d.ts +8 -0
- package/dist/commands/pilot.d.ts.map +1 -0
- package/dist/commands/pilot.js +104 -0
- package/dist/commands/pilot.js.map +1 -0
- package/dist/commands/projects.d.ts +21 -0
- package/dist/commands/projects.d.ts.map +1 -0
- package/dist/commands/projects.js +238 -0
- package/dist/commands/projects.js.map +1 -0
- package/dist/commands/publish.d.ts +35 -0
- package/dist/commands/publish.d.ts.map +1 -0
- package/dist/commands/publish.js +194 -0
- package/dist/commands/publish.js.map +1 -0
- package/dist/commands/repo.d.ts +59 -0
- package/dist/commands/repo.d.ts.map +1 -0
- package/dist/commands/repo.js +178 -0
- package/dist/commands/repo.js.map +1 -0
- package/dist/commands/review-screens.d.ts +28 -0
- package/dist/commands/review-screens.d.ts.map +1 -0
- package/dist/commands/review-screens.js +345 -0
- package/dist/commands/review-screens.js.map +1 -0
- package/dist/commands/scenarios.d.ts +23 -0
- package/dist/commands/scenarios.d.ts.map +1 -0
- package/dist/commands/scenarios.js +304 -0
- package/dist/commands/scenarios.js.map +1 -0
- package/dist/commands/ship.d.ts +18 -0
- package/dist/commands/ship.d.ts.map +1 -0
- package/dist/commands/ship.js +41 -0
- package/dist/commands/ship.js.map +1 -0
- package/dist/commands/test.d.ts +29 -0
- package/dist/commands/test.d.ts.map +1 -0
- package/dist/commands/test.js +62 -0
- package/dist/commands/test.js.map +1 -0
- package/dist/commands/tunnel.d.ts +22 -0
- package/dist/commands/tunnel.d.ts.map +1 -0
- package/dist/commands/tunnel.js +77 -0
- package/dist/commands/tunnel.js.map +1 -0
- package/dist/commands/validate.d.ts +14 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/validate.js +51 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/commands/vault.d.ts +32 -0
- package/dist/commands/vault.d.ts.map +1 -0
- package/dist/commands/vault.js +489 -0
- package/dist/commands/vault.js.map +1 -0
- package/dist/commands/voice.d.ts +16 -0
- package/dist/commands/voice.d.ts.map +1 -0
- package/dist/commands/voice.js +69 -0
- package/dist/commands/voice.js.map +1 -0
- package/dist/core/cascade_router.d.ts +90 -0
- package/dist/core/cascade_router.d.ts.map +1 -0
- package/dist/core/cascade_router.js +131 -0
- package/dist/core/cascade_router.js.map +1 -0
- package/dist/core/cf_tunnel.d.ts +52 -0
- package/dist/core/cf_tunnel.d.ts.map +1 -0
- package/dist/core/cf_tunnel.js +134 -0
- package/dist/core/cf_tunnel.js.map +1 -0
- package/dist/core/gha_dispatcher.d.ts +48 -0
- package/dist/core/gha_dispatcher.d.ts.map +1 -0
- package/dist/core/gha_dispatcher.js +198 -0
- package/dist/core/gha_dispatcher.js.map +1 -0
- package/dist/core/logger.d.ts +89 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +245 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/mode.d.ts +26 -0
- package/dist/core/mode.d.ts.map +1 -0
- package/dist/core/mode.js +122 -0
- package/dist/core/mode.js.map +1 -0
- package/dist/core/pairing.d.ts +40 -0
- package/dist/core/pairing.d.ts.map +1 -0
- package/dist/core/pairing.js +145 -0
- package/dist/core/pairing.js.map +1 -0
- package/dist/core/pilot_setup.d.ts +29 -0
- package/dist/core/pilot_setup.d.ts.map +1 -0
- package/dist/core/pilot_setup.js +119 -0
- package/dist/core/pilot_setup.js.map +1 -0
- package/dist/core/polar.d.ts +81 -0
- package/dist/core/polar.d.ts.map +1 -0
- package/dist/core/polar.js +175 -0
- package/dist/core/polar.js.map +1 -0
- package/dist/core/project_picker.d.ts +56 -0
- package/dist/core/project_picker.d.ts.map +1 -0
- package/dist/core/project_picker.js +86 -0
- package/dist/core/project_picker.js.map +1 -0
- package/dist/core/projects.d.ts +58 -0
- package/dist/core/projects.d.ts.map +1 -0
- package/dist/core/projects.js +146 -0
- package/dist/core/projects.js.map +1 -0
- package/dist/core/projects_sync.d.ts +80 -0
- package/dist/core/projects_sync.d.ts.map +1 -0
- package/dist/core/projects_sync.js +278 -0
- package/dist/core/projects_sync.js.map +1 -0
- package/dist/core/remote_runner.d.ts +70 -0
- package/dist/core/remote_runner.d.ts.map +1 -0
- package/dist/core/remote_runner.js +133 -0
- package/dist/core/remote_runner.js.map +1 -0
- package/dist/core/repo_state.d.ts +24 -0
- package/dist/core/repo_state.d.ts.map +1 -0
- package/dist/core/repo_state.js +109 -0
- package/dist/core/repo_state.js.map +1 -0
- package/dist/core/target.d.ts +31 -0
- package/dist/core/target.d.ts.map +1 -0
- package/dist/core/target.js +121 -0
- package/dist/core/target.js.map +1 -0
- package/dist/deploy/aws.d.ts +43 -0
- package/dist/deploy/aws.d.ts.map +1 -0
- package/dist/deploy/aws.js +173 -0
- package/dist/deploy/aws.js.map +1 -0
- package/dist/figma/api.d.ts +35 -0
- package/dist/figma/api.d.ts.map +1 -0
- package/dist/figma/api.js +40 -0
- package/dist/figma/api.js.map +1 -0
- package/dist/figma/decorator.d.ts +74 -0
- package/dist/figma/decorator.d.ts.map +1 -0
- package/dist/figma/decorator.js +210 -0
- package/dist/figma/decorator.js.map +1 -0
- package/dist/figma/heuristics.d.ts +29 -0
- package/dist/figma/heuristics.d.ts.map +1 -0
- package/dist/figma/heuristics.js +110 -0
- package/dist/figma/heuristics.js.map +1 -0
- package/dist/figma/normalize.d.ts +33 -0
- package/dist/figma/normalize.d.ts.map +1 -0
- package/dist/figma/normalize.js +101 -0
- package/dist/figma/normalize.js.map +1 -0
- package/dist/figma/tokens.d.ts +23 -0
- package/dist/figma/tokens.d.ts.map +1 -0
- package/dist/figma/tokens.js +111 -0
- package/dist/figma/tokens.js.map +1 -0
- package/dist/figma/types.d.ts +118 -0
- package/dist/figma/types.d.ts.map +1 -0
- package/dist/figma/types.js +12 -0
- package/dist/figma/types.js.map +1 -0
- package/dist/i18n/index.d.ts +48 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +135 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/i18n/types.d.ts +52 -0
- package/dist/i18n/types.d.ts.map +1 -0
- package/dist/i18n/types.js +85 -0
- package/dist/i18n/types.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/lan/mdns_packet.d.ts +74 -0
- package/dist/lan/mdns_packet.d.ts.map +1 -0
- package/dist/lan/mdns_packet.js +247 -0
- package/dist/lan/mdns_packet.js.map +1 -0
- package/dist/lan/mdns_service.d.ts +102 -0
- package/dist/lan/mdns_service.d.ts.map +1 -0
- package/dist/lan/mdns_service.js +206 -0
- package/dist/lan/mdns_service.js.map +1 -0
- package/dist/license/activate.d.ts +33 -0
- package/dist/license/activate.d.ts.map +1 -0
- package/dist/license/activate.js +135 -0
- package/dist/license/activate.js.map +1 -0
- package/dist/license/fingerprint.d.ts +2 -0
- package/dist/license/fingerprint.d.ts.map +1 -0
- package/dist/license/fingerprint.js +29 -0
- package/dist/license/fingerprint.js.map +1 -0
- package/dist/license/hito4_client.d.ts +24 -0
- package/dist/license/hito4_client.d.ts.map +1 -0
- package/dist/license/hito4_client.js +103 -0
- package/dist/license/hito4_client.js.map +1 -0
- package/dist/license/index.d.ts +22 -0
- package/dist/license/index.d.ts.map +1 -0
- package/dist/license/index.js +125 -0
- package/dist/license/index.js.map +1 -0
- package/dist/license/types.d.ts +38 -0
- package/dist/license/types.d.ts.map +1 -0
- package/dist/license/types.js +9 -0
- package/dist/license/types.js.map +1 -0
- package/dist/migrate/ai-apply.d.ts +198 -0
- package/dist/migrate/ai-apply.d.ts.map +1 -0
- package/dist/migrate/ai-apply.js +833 -0
- package/dist/migrate/ai-apply.js.map +1 -0
- package/dist/migrate/ai-decorator.d.ts +87 -0
- package/dist/migrate/ai-decorator.d.ts.map +1 -0
- package/dist/migrate/ai-decorator.js +203 -0
- package/dist/migrate/ai-decorator.js.map +1 -0
- package/dist/migrate/apply.d.ts +28 -0
- package/dist/migrate/apply.d.ts.map +1 -0
- package/dist/migrate/apply.js +119 -0
- package/dist/migrate/apply.js.map +1 -0
- package/dist/migrate/audit.d.ts +9 -0
- package/dist/migrate/audit.d.ts.map +1 -0
- package/dist/migrate/audit.js +197 -0
- package/dist/migrate/audit.js.map +1 -0
- package/dist/migrate/diff.d.ts +28 -0
- package/dist/migrate/diff.d.ts.map +1 -0
- package/dist/migrate/diff.js +154 -0
- package/dist/migrate/diff.js.map +1 -0
- package/dist/migrate/html-orchestrator.d.ts +81 -0
- package/dist/migrate/html-orchestrator.d.ts.map +1 -0
- package/dist/migrate/html-orchestrator.js +233 -0
- package/dist/migrate/html-orchestrator.js.map +1 -0
- package/dist/migrate/html-walker.d.ts +93 -0
- package/dist/migrate/html-walker.d.ts.map +1 -0
- package/dist/migrate/html-walker.js +288 -0
- package/dist/migrate/html-walker.js.map +1 -0
- package/dist/migrate/js-template-walker.d.ts +118 -0
- package/dist/migrate/js-template-walker.d.ts.map +1 -0
- package/dist/migrate/js-template-walker.js +644 -0
- package/dist/migrate/js-template-walker.js.map +1 -0
- package/dist/migrate/manifest-validator.d.ts +30 -0
- package/dist/migrate/manifest-validator.d.ts.map +1 -0
- package/dist/migrate/manifest-validator.js +261 -0
- package/dist/migrate/manifest-validator.js.map +1 -0
- package/dist/migrate/overrides.d.ts +58 -0
- package/dist/migrate/overrides.d.ts.map +1 -0
- package/dist/migrate/overrides.js +193 -0
- package/dist/migrate/overrides.js.map +1 -0
- package/dist/migrate/plugin-scope.d.ts +42 -0
- package/dist/migrate/plugin-scope.d.ts.map +1 -0
- package/dist/migrate/plugin-scope.js +94 -0
- package/dist/migrate/plugin-scope.js.map +1 -0
- package/dist/migrate/types.d.ts +45 -0
- package/dist/migrate/types.d.ts.map +1 -0
- package/dist/migrate/types.js +9 -0
- package/dist/migrate/types.js.map +1 -0
- package/dist/migrate/verb-inference.d.ts +37 -0
- package/dist/migrate/verb-inference.d.ts.map +1 -0
- package/dist/migrate/verb-inference.js +274 -0
- package/dist/migrate/verb-inference.js.map +1 -0
- package/dist/nac3/attrs.d.ts +87 -0
- package/dist/nac3/attrs.d.ts.map +1 -0
- package/dist/nac3/attrs.js +134 -0
- package/dist/nac3/attrs.js.map +1 -0
- package/dist/nac3/scenario_dsl.d.ts +71 -0
- package/dist/nac3/scenario_dsl.d.ts.map +1 -0
- package/dist/nac3/scenario_dsl.js +191 -0
- package/dist/nac3/scenario_dsl.js.map +1 -0
- package/dist/nac3/tokens.d.ts +126 -0
- package/dist/nac3/tokens.d.ts.map +1 -0
- package/dist/nac3/tokens.js +138 -0
- package/dist/nac3/tokens.js.map +1 -0
- package/dist/reader/parsers/csv.d.ts +42 -0
- package/dist/reader/parsers/csv.d.ts.map +1 -0
- package/dist/reader/parsers/csv.js +221 -0
- package/dist/reader/parsers/csv.js.map +1 -0
- package/dist/reader/parsers/docx.d.ts +31 -0
- package/dist/reader/parsers/docx.d.ts.map +1 -0
- package/dist/reader/parsers/docx.js +51 -0
- package/dist/reader/parsers/docx.js.map +1 -0
- package/dist/reader/parsers/epub.d.ts +39 -0
- package/dist/reader/parsers/epub.d.ts.map +1 -0
- package/dist/reader/parsers/epub.js +265 -0
- package/dist/reader/parsers/epub.js.map +1 -0
- package/dist/reader/parsers/html.d.ts +40 -0
- package/dist/reader/parsers/html.d.ts.map +1 -0
- package/dist/reader/parsers/html.js +386 -0
- package/dist/reader/parsers/html.js.map +1 -0
- package/dist/reader/parsers/md.d.ts +30 -0
- package/dist/reader/parsers/md.d.ts.map +1 -0
- package/dist/reader/parsers/md.js +199 -0
- package/dist/reader/parsers/md.js.map +1 -0
- package/dist/reader/parsers/pdf.d.ts +39 -0
- package/dist/reader/parsers/pdf.d.ts.map +1 -0
- package/dist/reader/parsers/pdf.js +220 -0
- package/dist/reader/parsers/pdf.js.map +1 -0
- package/dist/reader/parsers/rtf.d.ts +37 -0
- package/dist/reader/parsers/rtf.d.ts.map +1 -0
- package/dist/reader/parsers/rtf.js +347 -0
- package/dist/reader/parsers/rtf.js.map +1 -0
- package/dist/reader/parsers/source.d.ts +32 -0
- package/dist/reader/parsers/source.d.ts.map +1 -0
- package/dist/reader/parsers/source.js +122 -0
- package/dist/reader/parsers/source.js.map +1 -0
- package/dist/reader/parsers/txt.d.ts +25 -0
- package/dist/reader/parsers/txt.d.ts.map +1 -0
- package/dist/reader/parsers/txt.js +56 -0
- package/dist/reader/parsers/txt.js.map +1 -0
- package/dist/reader/parsers/xlsx.d.ts +33 -0
- package/dist/reader/parsers/xlsx.d.ts.map +1 -0
- package/dist/reader/parsers/xlsx.js +143 -0
- package/dist/reader/parsers/xlsx.js.map +1 -0
- package/dist/reader/registry.d.ts +39 -0
- package/dist/reader/registry.d.ts.map +1 -0
- package/dist/reader/registry.js +172 -0
- package/dist/reader/registry.js.map +1 -0
- package/dist/reader/search.d.ts +27 -0
- package/dist/reader/search.d.ts.map +1 -0
- package/dist/reader/search.js +77 -0
- package/dist/reader/search.js.map +1 -0
- package/dist/reader/state.d.ts +56 -0
- package/dist/reader/state.d.ts.map +1 -0
- package/dist/reader/state.js +179 -0
- package/dist/reader/state.js.map +1 -0
- package/dist/reader/types.d.ts +119 -0
- package/dist/reader/types.d.ts.map +1 -0
- package/dist/reader/types.js +23 -0
- package/dist/reader/types.js.map +1 -0
- package/dist/ship/run.d.ts +26 -0
- package/dist/ship/run.d.ts.map +1 -0
- package/dist/ship/run.js +123 -0
- package/dist/ship/run.js.map +1 -0
- package/dist/template/index.d.ts +50 -0
- package/dist/template/index.d.ts.map +1 -0
- package/dist/template/index.js +140 -0
- package/dist/template/index.js.map +1 -0
- package/dist/ui/colors.d.ts +13 -0
- package/dist/ui/colors.d.ts.map +1 -0
- package/dist/ui/colors.js +26 -0
- package/dist/ui/colors.js.map +1 -0
- package/dist/validate/index.d.ts +19 -0
- package/dist/validate/index.d.ts.map +1 -0
- package/dist/validate/index.js +181 -0
- package/dist/validate/index.js.map +1 -0
- package/dist/vault/catalog.d.ts +55 -0
- package/dist/vault/catalog.d.ts.map +1 -0
- package/dist/vault/catalog.js +424 -0
- package/dist/vault/catalog.js.map +1 -0
- package/dist/vault/crypto.d.ts +82 -0
- package/dist/vault/crypto.d.ts.map +1 -0
- package/dist/vault/crypto.js +173 -0
- package/dist/vault/crypto.js.map +1 -0
- package/dist/vault/git_askpass.d.ts +26 -0
- package/dist/vault/git_askpass.d.ts.map +1 -0
- package/dist/vault/git_askpass.js +104 -0
- package/dist/vault/git_askpass.js.map +1 -0
- package/dist/vault/migrator.d.ts +57 -0
- package/dist/vault/migrator.d.ts.map +1 -0
- package/dist/vault/migrator.js +204 -0
- package/dist/vault/migrator.js.map +1 -0
- package/dist/vault/redactor.d.ts +73 -0
- package/dist/vault/redactor.d.ts.map +1 -0
- package/dist/vault/redactor.js +182 -0
- package/dist/vault/redactor.js.map +1 -0
- package/dist/vault/store.d.ts +132 -0
- package/dist/vault/store.d.ts.map +1 -0
- package/dist/vault/store.js +335 -0
- package/dist/vault/store.js.map +1 -0
- package/dist/version.d.ts +8 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/dist/voice/chunker.d.ts +43 -0
- package/dist/voice/chunker.d.ts.map +1 -0
- package/dist/voice/chunker.js +133 -0
- package/dist/voice/chunker.js.map +1 -0
- package/dist/voice/config.d.ts +14 -0
- package/dist/voice/config.d.ts.map +1 -0
- package/dist/voice/config.js +51 -0
- package/dist/voice/config.js.map +1 -0
- package/dist/voice/intents.d.ts +71 -0
- package/dist/voice/intents.d.ts.map +1 -0
- package/dist/voice/intents.js +0 -0
- package/dist/voice/intents.js.map +1 -0
- package/dist/voice/providers/elevenlabs.d.ts +53 -0
- package/dist/voice/providers/elevenlabs.d.ts.map +1 -0
- package/dist/voice/providers/elevenlabs.js +159 -0
- package/dist/voice/providers/elevenlabs.js.map +1 -0
- package/dist/voice/providers/google.d.ts +56 -0
- package/dist/voice/providers/google.d.ts.map +1 -0
- package/dist/voice/providers/google.js +253 -0
- package/dist/voice/providers/google.js.map +1 -0
- package/dist/voice/providers/whisper.d.ts +44 -0
- package/dist/voice/providers/whisper.d.ts.map +1 -0
- package/dist/voice/providers/whisper.js +179 -0
- package/dist/voice/providers/whisper.js.map +1 -0
- package/dist/voice/registry.d.ts +35 -0
- package/dist/voice/registry.d.ts.map +1 -0
- package/dist/voice/registry.js +48 -0
- package/dist/voice/registry.js.map +1 -0
- package/dist/voice/router.d.ts +62 -0
- package/dist/voice/router.d.ts.map +1 -0
- package/dist/voice/router.js +175 -0
- package/dist/voice/router.js.map +1 -0
- package/dist/voice/types.d.ts +116 -0
- package/dist/voice/types.d.ts.map +1 -0
- package/dist/voice/types.js +22 -0
- package/dist/voice/types.js.map +1 -0
- package/dist/voice/voiceprint/enrollment.d.ts +36 -0
- package/dist/voice/voiceprint/enrollment.d.ts.map +1 -0
- package/dist/voice/voiceprint/enrollment.js +71 -0
- package/dist/voice/voiceprint/enrollment.js.map +1 -0
- package/dist/voice/voiceprint/identify.d.ts +16 -0
- package/dist/voice/voiceprint/identify.d.ts.map +1 -0
- package/dist/voice/voiceprint/identify.js +20 -0
- package/dist/voice/voiceprint/identify.js.map +1 -0
- package/dist/voice/voiceprint/liveness.d.ts +90 -0
- package/dist/voice/voiceprint/liveness.d.ts.map +1 -0
- package/dist/voice/voiceprint/liveness.js +251 -0
- package/dist/voice/voiceprint/liveness.js.map +1 -0
- package/dist/voice/voiceprint/match.d.ts +54 -0
- package/dist/voice/voiceprint/match.d.ts.map +1 -0
- package/dist/voice/voiceprint/match.js +88 -0
- package/dist/voice/voiceprint/match.js.map +1 -0
- package/dist/voice/voiceprint/providers/local-mfcc-stub.d.ts +44 -0
- package/dist/voice/voiceprint/providers/local-mfcc-stub.d.ts.map +1 -0
- package/dist/voice/voiceprint/providers/local-mfcc-stub.js +92 -0
- package/dist/voice/voiceprint/providers/local-mfcc-stub.js.map +1 -0
- package/dist/voice/voiceprint/store.d.ts +60 -0
- package/dist/voice/voiceprint/store.d.ts.map +1 -0
- package/dist/voice/voiceprint/store.js +155 -0
- package/dist/voice/voiceprint/store.js.map +1 -0
- package/dist/voice/voiceprint/trust.d.ts +90 -0
- package/dist/voice/voiceprint/trust.d.ts.map +1 -0
- package/dist/voice/voiceprint/trust.js +150 -0
- package/dist/voice/voiceprint/trust.js.map +1 -0
- package/dist/voice/voiceprint/types.d.ts +100 -0
- package/dist/voice/voiceprint/types.d.ts.map +1 -0
- package/dist/voice/voiceprint/types.js +23 -0
- package/dist/voice/voiceprint/types.js.map +1 -0
- package/dist/voice/wake.d.ts +64 -0
- package/dist/voice/wake.d.ts.map +1 -0
- package/dist/voice/wake.js +143 -0
- package/dist/voice/wake.js.map +1 -0
- package/docs/manuals/manual.ar.html +91 -0
- package/docs/manuals/manual.de.html +100 -0
- package/docs/manuals/manual.en.html +118 -0
- package/docs/manuals/manual.es.html +120 -0
- package/docs/manuals/manual.fr.html +102 -0
- package/docs/manuals/manual.hi.html +93 -0
- package/docs/manuals/manual.it.html +93 -0
- package/docs/manuals/manual.ja.html +97 -0
- package/docs/manuals/manual.pt.html +103 -0
- package/docs/manuals/manual.zh.html +89 -0
- package/package.json +94 -0
- package/src/i18n/catalogs/ar.json +86 -0
- package/src/i18n/catalogs/de.json +86 -0
- package/src/i18n/catalogs/en.json +86 -0
- package/src/i18n/catalogs/es.json +86 -0
- package/src/i18n/catalogs/fr.json +86 -0
- package/src/i18n/catalogs/hi.json +86 -0
- package/src/i18n/catalogs/it.json +86 -0
- package/src/i18n/catalogs/ja.json +86 -0
- package/src/i18n/catalogs/pt.json +86 -0
- package/src/i18n/catalogs/zh.json +86 -0
- package/templates/react-app/README.md +43 -0
- package/templates/react-app/index.html +12 -0
- package/templates/react-app/package.json +35 -0
- package/templates/react-app/src/App.tsx +106 -0
- package/templates/react-app/src/main.tsx +21 -0
- package/templates/react-app/src/nac/manifest.ts +46 -0
- package/templates/react-app/src/styles.css +68 -0
- package/templates/react-app/tsconfig.json +19 -0
- package/templates/react-app/vite.config.ts +12 -0
- package/templates/react-app/yujin.forge.json +15 -0
|
@@ -0,0 +1,1540 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Yujin Forge -- chat panel HTTP server.
|
|
3
|
+
*
|
|
4
|
+
* Routes (all on the local loopback port; CORS not needed):
|
|
5
|
+
*
|
|
6
|
+
* GET / serves the panel HTML
|
|
7
|
+
* GET /api/health { ok: true, version, project }
|
|
8
|
+
* POST /api/chat { messages } -> { ok, message }
|
|
9
|
+
*
|
|
10
|
+
* The server binds to 127.0.0.1 only -- never 0.0.0.0. Forge
|
|
11
|
+
* chat is a local-developer tool; exposing it on the LAN would
|
|
12
|
+
* leak the API key + the project source-aware system prompt.
|
|
13
|
+
*/
|
|
14
|
+
import { createServer } from 'node:http';
|
|
15
|
+
import { promises as fs } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { ClaudeClient, ConfigurationError, ClaudeApiError } from './claude.js';
|
|
18
|
+
import { renderPanelHtml } from './panel.js';
|
|
19
|
+
import { TranscriptStore } from './persistence.js';
|
|
20
|
+
import { FORGE_TOOL_SPECS, runForgeTool } from './tools.js';
|
|
21
|
+
import { Vault } from '../vault/store.js';
|
|
22
|
+
import { SLOT_CATALOG, SLOT_KINDS } from '../vault/catalog.js';
|
|
23
|
+
import { resolveMode, persistMode, modePromptSuffix, FORGE_MODES, } from '../core/mode.js';
|
|
24
|
+
import { resolveTarget, persistTarget, normaliseTarget, FORGE_TARGETS, } from '../core/target.js';
|
|
25
|
+
import { readPilotState, completePilotSetup, welcomePromptSuffix, shouldAutoComplete, } from '../core/pilot_setup.js';
|
|
26
|
+
import { readRegistry, upsertProject, setActive, activeSlug, deriveSlugFromPath, scanForProjects, } from '../core/projects.js';
|
|
27
|
+
import { configDir } from '../license/index.js';
|
|
28
|
+
import { VoiceRouter } from '../voice/router.js';
|
|
29
|
+
import { buildDefaultRegistry } from '../voice/registry.js';
|
|
30
|
+
import { matchReaderIntent } from '../voice/intents.js';
|
|
31
|
+
import { setRecapSummarizer } from './tools/reader.js';
|
|
32
|
+
import { parseDocument } from '../reader/registry.js';
|
|
33
|
+
import { SUPPORTED_LANGUAGES, LANGUAGE_DISPLAY_NAMES, BCP47, getCatalog, setLanguage, currentLanguage, normaliseLanguageTag, } from '../i18n/index.js';
|
|
34
|
+
import { listAvailableManuals } from './tools/manual.js';
|
|
35
|
+
import { recordIngest, getIngest, setExtraction, setPlan, setScaffold, clearScaffold, listIngest } from './ingest_session.js';
|
|
36
|
+
import { extractSpec } from './spec_extract.js';
|
|
37
|
+
import { generatePlan } from './spec_plan.js';
|
|
38
|
+
import { executeScaffold, rollbackScaffold } from './spec_scaffold.js';
|
|
39
|
+
import { VERSION } from '../version.js';
|
|
40
|
+
export async function startChatServer(opts) {
|
|
41
|
+
const projectName = await readProjectName(opts.projectRoot);
|
|
42
|
+
const claude = opts.claude ?? new ClaudeClient();
|
|
43
|
+
const voice = opts.voice ?? new VoiceRouter({
|
|
44
|
+
configDir: configDir(),
|
|
45
|
+
registry: buildDefaultRegistry(),
|
|
46
|
+
});
|
|
47
|
+
const store = new TranscriptStore(opts.projectRoot, VERSION);
|
|
48
|
+
/* V1.26b -- install the Claude-backed recap summariser so
|
|
49
|
+
forge.reader.recap({mode:'summary'}) can call it. The
|
|
50
|
+
reader still gates via env var YF_ENABLE_CLAUDE_RECAP=1
|
|
51
|
+
before actually invoking; this just makes the function
|
|
52
|
+
available. */
|
|
53
|
+
setRecapSummarizer(async (blocks, options) => {
|
|
54
|
+
const lang = options.language || 'es';
|
|
55
|
+
/* i18n-exempt: developer-facing system prompt that
|
|
56
|
+
instructs Claude in Spanish; Claude understands it and
|
|
57
|
+
is asked to respond in `lang`. Not surfaced to the user. */
|
|
58
|
+
const sys = 'Sos un resumidor breve. Te paso una lista de bloques de texto que el usuario acaba de escuchar via reader. '
|
|
59
|
+
+ 'Devolves un resumen de 1 a 2 oraciones en idioma ' + lang + ', sin agregar informacion que no este en los bloques. '
|
|
60
|
+
+ 'Sin vinetas. Solo prosa breve y clara.';
|
|
61
|
+
const reply = await claude.chat({
|
|
62
|
+
messages: [
|
|
63
|
+
{ role: 'user', content: 'Resumi estos bloques:\n\n' + blocks.map((b, i) => '[' + (i + 1) + '] ' + b).join('\n') },
|
|
64
|
+
],
|
|
65
|
+
system: sys,
|
|
66
|
+
maxTokens: 200,
|
|
67
|
+
});
|
|
68
|
+
return reply.text.trim();
|
|
69
|
+
});
|
|
70
|
+
const server = createServer(async (req, res) => {
|
|
71
|
+
// Every response carries a request-id (for client + server
|
|
72
|
+
// log correlation) + cache-control (we never want browsers
|
|
73
|
+
// to cache panel HTML / API responses since the panel is
|
|
74
|
+
// dynamic). Anti-debt headers requested in the 600-suite
|
|
75
|
+
// followup list -- see docs/CROSS_BROWSER_REPORT_600.md.
|
|
76
|
+
const requestId = generateRequestId();
|
|
77
|
+
res.setHeader('x-request-id', requestId);
|
|
78
|
+
res.setHeader('cache-control', 'no-store, must-revalidate');
|
|
79
|
+
res.setHeader('x-yujin-version', VERSION);
|
|
80
|
+
try {
|
|
81
|
+
await route(req, res, {
|
|
82
|
+
projectRoot: opts.projectRoot,
|
|
83
|
+
projectName,
|
|
84
|
+
port: opts.port,
|
|
85
|
+
claude,
|
|
86
|
+
voice,
|
|
87
|
+
store,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
sendJson(res, 500, {
|
|
92
|
+
ok: false,
|
|
93
|
+
error: err instanceof Error ? err.message : String(err),
|
|
94
|
+
request_id: requestId,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
// Aggressive socket cleanup. Default keepAliveTimeout (5s)
|
|
99
|
+
// can keep sockets pending when the panel is in a test or
|
|
100
|
+
// headless context that does not close them gracefully. The
|
|
101
|
+
// 600-suite followup observed force-killed webkit workers
|
|
102
|
+
// when the suite ended -- shorter timeouts let Node drop the
|
|
103
|
+
// sockets cleanly so Playwright cleanup is immediate.
|
|
104
|
+
server.keepAliveTimeout = 1000;
|
|
105
|
+
server.headersTimeout = 5000;
|
|
106
|
+
server.requestTimeout = 30000;
|
|
107
|
+
await new Promise((resolve, reject) => {
|
|
108
|
+
server.once('error', reject);
|
|
109
|
+
server.listen(opts.port, '127.0.0.1', () => resolve());
|
|
110
|
+
});
|
|
111
|
+
const url = 'http://127.0.0.1:' + opts.port + '/';
|
|
112
|
+
return {
|
|
113
|
+
server,
|
|
114
|
+
url,
|
|
115
|
+
store,
|
|
116
|
+
close: () => new Promise((resolve) => {
|
|
117
|
+
server.close(() => resolve());
|
|
118
|
+
}),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
async function route(req, res, ctx) {
|
|
122
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
123
|
+
if (req.method === 'GET' && url.pathname === '/') {
|
|
124
|
+
// Stateless lang resolution: query string > cookie >
|
|
125
|
+
// server-wide setLanguage. This isolates per-request
|
|
126
|
+
// rendering so concurrent clients with different langs
|
|
127
|
+
// do not race on the shared current_language state.
|
|
128
|
+
const reqLang = resolveRequestLang(req, url);
|
|
129
|
+
res.statusCode = 200;
|
|
130
|
+
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
131
|
+
res.end(renderPanelHtml({
|
|
132
|
+
projectRoot: ctx.projectRoot,
|
|
133
|
+
projectName: ctx.projectName,
|
|
134
|
+
port: ctx.port,
|
|
135
|
+
lang: reqLang,
|
|
136
|
+
}));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (req.method === 'GET' && url.pathname === '/api/health') {
|
|
140
|
+
sendJson(res, 200, {
|
|
141
|
+
ok: true,
|
|
142
|
+
version: VERSION,
|
|
143
|
+
project: { name: ctx.projectName, root: ctx.projectRoot },
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (req.method === 'POST' && url.pathname === '/api/chat') {
|
|
148
|
+
await handleChat(req, res, ctx);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
/* Vault surface (V0.4 of HITO 0). Same-origin: the server only
|
|
152
|
+
listens on 127.0.0.1 and the browser blocks cross-origin
|
|
153
|
+
fetches from other sites by default (no CORS headers set).
|
|
154
|
+
Plaintext NEVER returns through any of these endpoints --
|
|
155
|
+
only set accepts it, and only list/has/info return metadata. */
|
|
156
|
+
if (req.method === 'GET' && url.pathname === '/api/vault/list') {
|
|
157
|
+
await handleVaultList(res);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (req.method === 'POST' && url.pathname === '/api/vault/set') {
|
|
161
|
+
await handleVaultSet(req, res);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (req.method === 'POST' && url.pathname === '/api/vault/remove') {
|
|
165
|
+
await handleVaultRemove(req, res);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (req.method === 'GET' && url.pathname.startsWith('/api/vault/has/')) {
|
|
169
|
+
const slot = url.pathname.slice('/api/vault/has/'.length);
|
|
170
|
+
await handleVaultHas(res, slot);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (req.method === 'GET' && url.pathname === '/api/vault/catalog') {
|
|
174
|
+
await handleVaultCatalog(res, url);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
/* SQ 0.8 -- dual mode read/write. */
|
|
178
|
+
if (req.method === 'GET' && url.pathname === '/api/forge/mode') {
|
|
179
|
+
await handleModeGet(req, res, ctx);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (req.method === 'POST' && url.pathname === '/api/forge/mode') {
|
|
183
|
+
await handleModeSet(req, res, ctx);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
/* SQ 0.10 -- pre-dev target query + persistence. */
|
|
187
|
+
if (req.method === 'GET' && url.pathname === '/api/forge/target') {
|
|
188
|
+
await handleTargetGet(res, ctx);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (req.method === 'POST' && url.pathname === '/api/forge/target') {
|
|
192
|
+
await handleTargetSet(req, res, ctx);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
/* PLAN #10 -- project registry endpoints. */
|
|
196
|
+
if (req.method === 'GET' && url.pathname === '/api/forge/projects') {
|
|
197
|
+
await handleProjectsList(res);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (req.method === 'POST' && url.pathname === '/api/forge/projects/active') {
|
|
201
|
+
await handleProjectsSwitch(req, res);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (req.method === 'GET' && url.pathname === '/api/forge/projects/active') {
|
|
205
|
+
await handleProjectsActive(res);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (req.method === 'POST' && url.pathname === '/api/forge/projects/scan') {
|
|
209
|
+
await handleProjectsScan(req, res);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (req.method === 'POST' && url.pathname === '/api/forge/projects/add') {
|
|
213
|
+
await handleProjectsAdd(req, res);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (req.method === 'POST' && url.pathname.startsWith('/api/vault/test/')) {
|
|
217
|
+
const slot = url.pathname.slice('/api/vault/test/'.length);
|
|
218
|
+
await handleVaultTest(req, res, slot);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
/* Voice surface (V1.5 of HITO 1). Same-origin only (loopback +
|
|
222
|
+
no CORS). Audio bytes flow on /stt; JSON on /tts; the response
|
|
223
|
+
is binary audio. */
|
|
224
|
+
if (req.method === 'GET' && url.pathname === '/api/voice/config') {
|
|
225
|
+
await handleVoiceConfigGet(res, ctx);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (req.method === 'POST' && url.pathname === '/api/voice/config') {
|
|
229
|
+
await handleVoiceConfigSet(req, res, ctx);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (req.method === 'POST' && url.pathname === '/api/voice/stt') {
|
|
233
|
+
await handleVoiceStt(req, res, ctx);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (req.method === 'POST' && url.pathname === '/api/voice/tts') {
|
|
237
|
+
await handleVoiceTts(req, res, ctx);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
/* Direct reader-tool dispatch (V1.32). The voice intent matcher
|
|
241
|
+
surfaces a tool + args on the STT response; this endpoint
|
|
242
|
+
lets the client execute it without a Claude round-trip.
|
|
243
|
+
Whitelisted to forge.reader.* only -- write-class tools
|
|
244
|
+
(git_commit, git_push, run_app, create_github_repo, ...)
|
|
245
|
+
are NOT reachable here, they keep their approval flow
|
|
246
|
+
through /api/chat. */
|
|
247
|
+
if (req.method === 'POST' && url.pathname === '/api/forge/tool') {
|
|
248
|
+
await handleForgeToolDispatch(req, res, ctx);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
/* Spec doc ingest (V1.37 + V1.38 -- bloque 4.5 scaffolding).
|
|
252
|
+
Client uploads a spec file (PDF / DOCX / HTML / md / ...)
|
|
253
|
+
and the server parses it via the existing reader pipeline,
|
|
254
|
+
returning the NormalisedDocument shape. Real plan-building
|
|
255
|
+
+ file scaffolding lands in V1.41..V1.46. */
|
|
256
|
+
if (req.method === 'POST' && url.pathname === '/api/forge/ingest') {
|
|
257
|
+
await handleForgeIngest(req, res, ctx);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
/* Spec extraction via Claude (V1.40). After an ingest, the
|
|
261
|
+
panel calls this to ask Claude for structured
|
|
262
|
+
requirements/entities/endpoints/components. */
|
|
263
|
+
if (req.method === 'POST' && url.pathname === '/api/forge/ingest/extract') {
|
|
264
|
+
await handleForgeIngestExtract(req, res, ctx);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
/* List active ingest sessions (V1.40 -- for debugging + the
|
|
268
|
+
panel's "previously ingested" picker, future slice). */
|
|
269
|
+
if (req.method === 'GET' && url.pathname === '/api/forge/ingest') {
|
|
270
|
+
sendJson(res, 200, { ok: true, sessions: listIngest() });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
/* Plan generation (V1.41). Asks Claude to convert the V1.40
|
|
274
|
+
extraction into a concrete file scaffold plan. */
|
|
275
|
+
if (req.method === 'POST' && url.pathname === '/api/forge/ingest/plan') {
|
|
276
|
+
await handleForgeIngestPlan(req, res, ctx);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
/* Scaffold approval + execution (V1.42 + V1.43). The body
|
|
280
|
+
must include {doc_id, approve:true}; only then does the
|
|
281
|
+
scaffolder touch the filesystem. */
|
|
282
|
+
if (req.method === 'POST' && url.pathname === '/api/forge/ingest/scaffold') {
|
|
283
|
+
await handleForgeIngestScaffold(req, res, ctx);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
/* Progress stream (V1.44). Server-sent events fire as the
|
|
287
|
+
scaffolder writes each file. The handler does the same
|
|
288
|
+
work as /scaffold but emits incremental updates. */
|
|
289
|
+
if (req.method === 'POST' && url.pathname === '/api/forge/ingest/scaffold/stream') {
|
|
290
|
+
await handleForgeIngestScaffoldStream(req, res, ctx);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
/* Rollback (V1.45). Undoes a scaffold via its recorded
|
|
294
|
+
rollback log. */
|
|
295
|
+
if (req.method === 'POST' && url.pathname === '/api/forge/ingest/rollback') {
|
|
296
|
+
await handleForgeIngestRollback(req, res, ctx);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
/* i18n surface (Fase F.3). The panel uses these to populate
|
|
300
|
+
the language selector + drive the current panel language. */
|
|
301
|
+
if (req.method === 'GET' && url.pathname === '/api/i18n/languages') {
|
|
302
|
+
const available = await listAvailableManuals();
|
|
303
|
+
sendJson(res, 200, {
|
|
304
|
+
ok: true,
|
|
305
|
+
current: currentLanguage(),
|
|
306
|
+
languages: SUPPORTED_LANGUAGES.map((l) => ({
|
|
307
|
+
code: l,
|
|
308
|
+
display: LANGUAGE_DISPLAY_NAMES[l],
|
|
309
|
+
bcp47: BCP47[l],
|
|
310
|
+
manual_available: available.includes(l),
|
|
311
|
+
})),
|
|
312
|
+
});
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (req.method === 'GET' && url.pathname.startsWith('/api/i18n/catalog/')) {
|
|
316
|
+
const langRaw = url.pathname.slice('/api/i18n/catalog/'.length);
|
|
317
|
+
const lang = normaliseLanguageTag(langRaw);
|
|
318
|
+
sendJson(res, 200, {
|
|
319
|
+
ok: true,
|
|
320
|
+
lang,
|
|
321
|
+
catalog: getCatalog(lang),
|
|
322
|
+
});
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (req.method === 'POST' && url.pathname === '/api/i18n/language') {
|
|
326
|
+
let body;
|
|
327
|
+
try {
|
|
328
|
+
body = JSON.parse(await readBody(req));
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (!body || typeof body.lang !== 'string') {
|
|
335
|
+
sendJson(res, 400, { ok: false, error: 'lang (string) required' });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const resolved = setLanguage(body.lang);
|
|
339
|
+
sendJson(res, 200, { ok: true, current: resolved });
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
sendJson(res, 404, { ok: false, error: 'not found' });
|
|
343
|
+
}
|
|
344
|
+
const SUPPORTED_STT_FORMATS = new Set(['webm', 'ogg', 'wav', 'mp3', 'flac', 'mp4']);
|
|
345
|
+
const SUPPORTED_TTS_FORMATS = new Set(['mp3', 'wav', 'ogg', 'opus']);
|
|
346
|
+
/** Cap audio uploads at 25 MB. Larger payloads usually mean a
|
|
347
|
+
* recording loop got stuck; refuse loudly so the panel surfaces
|
|
348
|
+
* the problem instead of OOMing the server. */
|
|
349
|
+
const STT_MAX_AUDIO_BYTES = 25 * 1024 * 1024;
|
|
350
|
+
const TTS_AUDIO_MIME = {
|
|
351
|
+
mp3: 'audio/mpeg',
|
|
352
|
+
wav: 'audio/wav',
|
|
353
|
+
ogg: 'audio/ogg',
|
|
354
|
+
opus: 'audio/ogg',
|
|
355
|
+
};
|
|
356
|
+
async function handleVoiceConfigGet(res, ctx) {
|
|
357
|
+
try {
|
|
358
|
+
const cfg = await ctx.voice.loadConfig();
|
|
359
|
+
const providers = ctx.voice.registeredProviders();
|
|
360
|
+
sendJson(res, 200, { ok: true, config: cfg, providers });
|
|
361
|
+
}
|
|
362
|
+
catch (err) {
|
|
363
|
+
sendJson(res, 500, {
|
|
364
|
+
ok: false,
|
|
365
|
+
error: err instanceof Error ? err.message : String(err),
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async function handleVoiceConfigSet(req, res, ctx) {
|
|
370
|
+
let body;
|
|
371
|
+
try {
|
|
372
|
+
body = JSON.parse(await readBody(req));
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (!body || typeof body !== 'object') {
|
|
379
|
+
sendJson(res, 400, { ok: false, error: 'body must be a VoiceConfig object' });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const cfg = body;
|
|
383
|
+
if (!cfg.stt || !cfg.tts) {
|
|
384
|
+
sendJson(res, 400, { ok: false, error: 'config must include stt + tts blocks' });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
await ctx.voice.saveConfig(cfg);
|
|
389
|
+
sendJson(res, 200, { ok: true, config: cfg });
|
|
390
|
+
}
|
|
391
|
+
catch (err) {
|
|
392
|
+
sendJson(res, 400, {
|
|
393
|
+
ok: false,
|
|
394
|
+
error: err instanceof Error ? err.message : String(err),
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
async function handleVoiceStt(req, res, ctx) {
|
|
399
|
+
const format = String(req.headers['x-audio-format'] ?? '').toLowerCase();
|
|
400
|
+
if (!SUPPORTED_STT_FORMATS.has(format)) {
|
|
401
|
+
sendJson(res, 400, {
|
|
402
|
+
ok: false,
|
|
403
|
+
error: 'X-Audio-Format header missing or unsupported. Supported: '
|
|
404
|
+
+ Array.from(SUPPORTED_STT_FORMATS).join(', '),
|
|
405
|
+
});
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const languageHint = typeof req.headers['x-audio-language'] === 'string'
|
|
409
|
+
? String(req.headers['x-audio-language'])
|
|
410
|
+
: undefined;
|
|
411
|
+
let audio;
|
|
412
|
+
try {
|
|
413
|
+
audio = await readBinaryBody(req, STT_MAX_AUDIO_BYTES);
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
sendJson(res, 413, {
|
|
417
|
+
ok: false,
|
|
418
|
+
error: err instanceof Error ? err.message : String(err),
|
|
419
|
+
});
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (audio.length === 0) {
|
|
423
|
+
sendJson(res, 400, { ok: false, error: 'audio body is empty' });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
/* Optional active document hint (V1.31). Lets the voice intent
|
|
427
|
+
matcher resolve commands like "siguiente" or "buscar X" against
|
|
428
|
+
the currently-open reader session. Client sets this header to
|
|
429
|
+
the doc_id returned by the last forge.reader.open call. */
|
|
430
|
+
const activeDocId = typeof req.headers['x-active-doc-id'] === 'string'
|
|
431
|
+
? String(req.headers['x-active-doc-id']).trim()
|
|
432
|
+
: '';
|
|
433
|
+
try {
|
|
434
|
+
const result = await ctx.voice.transcribe({
|
|
435
|
+
audio,
|
|
436
|
+
format: format,
|
|
437
|
+
...(languageHint ? { languageHint } : {}),
|
|
438
|
+
});
|
|
439
|
+
/* Reader intent shortcut (V1.31). After successful STT, try
|
|
440
|
+
to match the transcript against the reader-intent
|
|
441
|
+
catalogue. On a hit, surface the proposed tool + args so
|
|
442
|
+
the client can dispatch directly without a Claude
|
|
443
|
+
round-trip. On a miss, matched_intent is null and the
|
|
444
|
+
client passes the transcript to Claude as before. */
|
|
445
|
+
const matched = matchReaderIntent(result.text, {
|
|
446
|
+
...(activeDocId ? { active_doc_id: activeDocId } : {}),
|
|
447
|
+
});
|
|
448
|
+
sendJson(res, 200, {
|
|
449
|
+
ok: true,
|
|
450
|
+
transcript: result,
|
|
451
|
+
matched_intent: matched,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
catch (err) {
|
|
455
|
+
sendJson(res, 502, {
|
|
456
|
+
ok: false,
|
|
457
|
+
error: err instanceof Error ? err.message : String(err),
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Allow-list for the V1.32 direct-dispatch endpoint. Only
|
|
463
|
+
* read-only reader tools are exposed here. Any write-class
|
|
464
|
+
* tool (commit / push / run / repo create / branch switch /
|
|
465
|
+
* file write) MUST go through /api/chat so the approval flow
|
|
466
|
+
* runs.
|
|
467
|
+
*/
|
|
468
|
+
const FORGE_TOOL_DIRECT_ALLOWLIST = new Set([
|
|
469
|
+
'forge.reader.open',
|
|
470
|
+
'forge.reader.list_documents',
|
|
471
|
+
'forge.reader.read_section',
|
|
472
|
+
'forge.reader.next_block',
|
|
473
|
+
'forge.reader.search',
|
|
474
|
+
'forge.reader.bookmark_set',
|
|
475
|
+
'forge.reader.bookmark_jump',
|
|
476
|
+
'forge.reader.recap',
|
|
477
|
+
/* Fase F.8 -- HTML user manuals in 10 languages. Read-only,
|
|
478
|
+
same safety profile as the reader tools. */
|
|
479
|
+
'forge.manual.open',
|
|
480
|
+
]);
|
|
481
|
+
async function handleForgeToolDispatch(req, res, ctx) {
|
|
482
|
+
let body;
|
|
483
|
+
try {
|
|
484
|
+
body = JSON.parse(await readBody(req));
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (!body || typeof body.tool !== 'string' || typeof body.args !== 'object' || body.args === null) {
|
|
491
|
+
sendJson(res, 400, { ok: false, error: 'tool (string) + args (object) required' });
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (!FORGE_TOOL_DIRECT_ALLOWLIST.has(body.tool)) {
|
|
495
|
+
/* Loud refusal. The caller picked a tool that requires
|
|
496
|
+
approval flow; route them to /api/chat instead. */
|
|
497
|
+
sendJson(res, 403, {
|
|
498
|
+
ok: false,
|
|
499
|
+
error: 'tool not allowed via direct dispatch (use /api/chat for approval-gated tools): ' + body.tool,
|
|
500
|
+
});
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
try {
|
|
504
|
+
const r = await runForgeTool(body.tool, body.args, { projectRoot: ctx.projectRoot });
|
|
505
|
+
sendJson(res, 200, {
|
|
506
|
+
ok: true,
|
|
507
|
+
tool: body.tool,
|
|
508
|
+
result: r.result,
|
|
509
|
+
is_error: r.is_error ?? false,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
catch (err) {
|
|
513
|
+
sendJson(res, 500, {
|
|
514
|
+
ok: false,
|
|
515
|
+
error: err instanceof Error ? err.message : String(err),
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/** V1.37 + V1.38 -- ingest a spec doc via drag-drop / upload.
|
|
520
|
+
*
|
|
521
|
+
* Accepts POST with:
|
|
522
|
+
* - Header X-Filename: the original filename (used for
|
|
523
|
+
* extension-based format detection + slug).
|
|
524
|
+
* - Body: raw binary file contents (up to INGEST_MAX_BYTES).
|
|
525
|
+
*
|
|
526
|
+
* Pipeline:
|
|
527
|
+
* 1. Read the binary body.
|
|
528
|
+
* 2. detectFormat(filename, buffer) -> auto-detects format.
|
|
529
|
+
* 3. parseDocument({...}) -> NormalisedDocument.
|
|
530
|
+
* 4. Return summary: title, format, sections count, byte
|
|
531
|
+
* count, language, first 3 paragraph excerpts.
|
|
532
|
+
*
|
|
533
|
+
* Does NOT persist the file or open a reader session yet --
|
|
534
|
+
* that's V1.39+ (plan building + scaffold). For V1.37/V1.38
|
|
535
|
+
* the endpoint is a single-shot parse so the panel can confirm
|
|
536
|
+
* "yes, Forge can read this spec" before the user commits to
|
|
537
|
+
* the full ingest flow.
|
|
538
|
+
*/
|
|
539
|
+
const INGEST_MAX_BYTES = 32 * 1024 * 1024;
|
|
540
|
+
async function handleForgeIngest(req, res, ctx) {
|
|
541
|
+
const filenameHdr = req.headers['x-filename'];
|
|
542
|
+
const filename = typeof filenameHdr === 'string' && filenameHdr.trim() !== ''
|
|
543
|
+
? filenameHdr.trim()
|
|
544
|
+
: 'spec.bin';
|
|
545
|
+
/* Basic filename sanitisation: forbid path separators so an
|
|
546
|
+
attacker cannot use this endpoint to scribble outside any
|
|
547
|
+
directory we might later derive from the name. */
|
|
548
|
+
if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
|
|
549
|
+
sendJson(res, 400, {
|
|
550
|
+
ok: false,
|
|
551
|
+
error: 'X-Filename must not contain path separators or "..".',
|
|
552
|
+
});
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
let buffer;
|
|
556
|
+
try {
|
|
557
|
+
buffer = await readBinaryBody(req, INGEST_MAX_BYTES);
|
|
558
|
+
}
|
|
559
|
+
catch (err) {
|
|
560
|
+
sendJson(res, 413, {
|
|
561
|
+
ok: false,
|
|
562
|
+
error: err instanceof Error ? err.message : String(err),
|
|
563
|
+
});
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (buffer.length === 0) {
|
|
567
|
+
sendJson(res, 400, { ok: false, error: 'body is empty' });
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
const doc = await parseDocument({ filename, buffer });
|
|
572
|
+
/* V1.40 -- record the session so /extract + /plan +
|
|
573
|
+
/scaffold can read it later. */
|
|
574
|
+
recordIngest(doc);
|
|
575
|
+
/* Compose a compact summary the panel can render. */
|
|
576
|
+
const firstParas = [];
|
|
577
|
+
for (const s of doc.sections) {
|
|
578
|
+
for (const b of s.blocks) {
|
|
579
|
+
if (b.kind === 'paragraph') {
|
|
580
|
+
firstParas.push(b.text);
|
|
581
|
+
if (firstParas.length >= 3)
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (firstParas.length >= 3)
|
|
586
|
+
break;
|
|
587
|
+
}
|
|
588
|
+
sendJson(res, 200, {
|
|
589
|
+
ok: true,
|
|
590
|
+
ingested: {
|
|
591
|
+
doc_id: doc.id,
|
|
592
|
+
filename: doc.filename,
|
|
593
|
+
format: doc.format,
|
|
594
|
+
bytes: doc.bytes,
|
|
595
|
+
title: doc.title,
|
|
596
|
+
language: doc.language ?? null,
|
|
597
|
+
sections: doc.sections.length,
|
|
598
|
+
first_paragraphs: firstParas,
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
catch (err) {
|
|
603
|
+
sendJson(res, 422, {
|
|
604
|
+
ok: false,
|
|
605
|
+
error: 'parse failed: ' + (err instanceof Error ? err.message : String(err)),
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
/** V1.40 -- run Claude extraction on a previously ingested spec. */
|
|
610
|
+
async function handleForgeIngestExtract(req, res, ctx) {
|
|
611
|
+
let body;
|
|
612
|
+
try {
|
|
613
|
+
body = JSON.parse(await readBody(req));
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (!body || typeof body.doc_id !== 'string' || body.doc_id.trim() === '') {
|
|
620
|
+
sendJson(res, 400, { ok: false, error: 'doc_id (non-empty string) required' });
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const session = getIngest(body.doc_id);
|
|
624
|
+
if (!session) {
|
|
625
|
+
sendJson(res, 404, { ok: false, error: 'no ingest session for doc_id ' + body.doc_id });
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
/* If the extraction was already computed and the caller did
|
|
629
|
+
not pass force=true, return the cached value. */
|
|
630
|
+
if (session.extraction && body.force !== true) {
|
|
631
|
+
sendJson(res, 200, {
|
|
632
|
+
ok: true,
|
|
633
|
+
doc_id: session.doc_id,
|
|
634
|
+
extraction: session.extraction,
|
|
635
|
+
cached: true,
|
|
636
|
+
});
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
try {
|
|
640
|
+
const opts = {
|
|
641
|
+
title: session.doc.title,
|
|
642
|
+
text: session.flat_text,
|
|
643
|
+
};
|
|
644
|
+
if (session.doc.language)
|
|
645
|
+
opts.language = session.doc.language;
|
|
646
|
+
const extraction = await extractSpec(ctx.claude, opts);
|
|
647
|
+
setExtraction(session.doc_id, extraction);
|
|
648
|
+
sendJson(res, 200, {
|
|
649
|
+
ok: true,
|
|
650
|
+
doc_id: session.doc_id,
|
|
651
|
+
extraction,
|
|
652
|
+
cached: false,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
catch (err) {
|
|
656
|
+
sendJson(res, 502, {
|
|
657
|
+
ok: false,
|
|
658
|
+
error: err instanceof Error ? err.message : String(err),
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
/** V1.41 -- generate a file scaffold plan from a previously
|
|
663
|
+
* extracted spec. Requires the extraction (V1.40) to exist;
|
|
664
|
+
* if not, returns a clear 409 telling the caller to run
|
|
665
|
+
* extract first. */
|
|
666
|
+
async function handleForgeIngestPlan(req, res, ctx) {
|
|
667
|
+
let body;
|
|
668
|
+
try {
|
|
669
|
+
body = JSON.parse(await readBody(req));
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (!body || typeof body.doc_id !== 'string' || body.doc_id.trim() === '') {
|
|
676
|
+
sendJson(res, 400, { ok: false, error: 'doc_id (non-empty string) required' });
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const session = getIngest(body.doc_id);
|
|
680
|
+
if (!session) {
|
|
681
|
+
sendJson(res, 404, { ok: false, error: 'no ingest session for doc_id ' + body.doc_id });
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
if (!session.extraction) {
|
|
685
|
+
sendJson(res, 409, {
|
|
686
|
+
ok: false,
|
|
687
|
+
error: 'no extraction yet for doc_id ' + body.doc_id
|
|
688
|
+
+ ' -- call POST /api/forge/ingest/extract first',
|
|
689
|
+
});
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
if (session.plan && body.force !== true) {
|
|
693
|
+
sendJson(res, 200, {
|
|
694
|
+
ok: true,
|
|
695
|
+
doc_id: session.doc_id,
|
|
696
|
+
plan: session.plan,
|
|
697
|
+
cached: true,
|
|
698
|
+
});
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
try {
|
|
702
|
+
const opts = {
|
|
703
|
+
extraction: session.extraction,
|
|
704
|
+
title: session.doc.title,
|
|
705
|
+
};
|
|
706
|
+
if (session.doc.language)
|
|
707
|
+
opts.language = session.doc.language;
|
|
708
|
+
const plan = await generatePlan(ctx.claude, opts);
|
|
709
|
+
setPlan(session.doc_id, plan);
|
|
710
|
+
sendJson(res, 200, {
|
|
711
|
+
ok: true,
|
|
712
|
+
doc_id: session.doc_id,
|
|
713
|
+
plan,
|
|
714
|
+
cached: false,
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
catch (err) {
|
|
718
|
+
sendJson(res, 502, {
|
|
719
|
+
ok: false,
|
|
720
|
+
error: err instanceof Error ? err.message : String(err),
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
/** V1.42 + V1.43 -- approve + execute the scaffold plan.
|
|
725
|
+
* The approve flag is required + must be true. */
|
|
726
|
+
async function handleForgeIngestScaffold(req, res, ctx) {
|
|
727
|
+
let body;
|
|
728
|
+
try {
|
|
729
|
+
body = JSON.parse(await readBody(req));
|
|
730
|
+
}
|
|
731
|
+
catch {
|
|
732
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (!body || typeof body.doc_id !== 'string' || body.doc_id.trim() === '') {
|
|
736
|
+
sendJson(res, 400, { ok: false, error: 'doc_id (non-empty string) required' });
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (body.approve !== true) {
|
|
740
|
+
sendJson(res, 403, {
|
|
741
|
+
ok: false,
|
|
742
|
+
error: 'scaffold requires explicit {approve: true} in the body. This step writes files to disk; deliberate gating prevents accidental scaffolds.',
|
|
743
|
+
});
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
const session = getIngest(body.doc_id);
|
|
747
|
+
if (!session) {
|
|
748
|
+
sendJson(res, 404, { ok: false, error: 'no ingest session for doc_id ' + body.doc_id });
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (!session.plan) {
|
|
752
|
+
sendJson(res, 409, {
|
|
753
|
+
ok: false,
|
|
754
|
+
error: 'no plan yet for doc_id ' + body.doc_id
|
|
755
|
+
+ ' -- call POST /api/forge/ingest/plan first',
|
|
756
|
+
});
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
try {
|
|
760
|
+
const force = body.force === true;
|
|
761
|
+
const report = await executeScaffold({
|
|
762
|
+
plan: session.plan,
|
|
763
|
+
projectRoot: ctx.projectRoot,
|
|
764
|
+
force,
|
|
765
|
+
});
|
|
766
|
+
setScaffold(session.doc_id, report);
|
|
767
|
+
sendJson(res, 200, {
|
|
768
|
+
ok: true,
|
|
769
|
+
doc_id: session.doc_id,
|
|
770
|
+
created: report.created,
|
|
771
|
+
overwritten: report.overwritten,
|
|
772
|
+
skipped: report.skipped,
|
|
773
|
+
errors: report.errors,
|
|
774
|
+
total_writes: report.rollback_log.length,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
catch (err) {
|
|
778
|
+
sendJson(res, 500, {
|
|
779
|
+
ok: false,
|
|
780
|
+
error: err instanceof Error ? err.message : String(err),
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
/** V1.44 -- stream scaffold progress as Server-Sent Events.
|
|
785
|
+
* Same approval gate as /scaffold; each ProgressEvent fires
|
|
786
|
+
* as a separate SSE message so the panel can update a live
|
|
787
|
+
* progress bar. */
|
|
788
|
+
async function handleForgeIngestScaffoldStream(req, res, ctx) {
|
|
789
|
+
let body;
|
|
790
|
+
try {
|
|
791
|
+
body = JSON.parse(await readBody(req));
|
|
792
|
+
}
|
|
793
|
+
catch {
|
|
794
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
if (!body || typeof body.doc_id !== 'string' || body.doc_id.trim() === '') {
|
|
798
|
+
sendJson(res, 400, { ok: false, error: 'doc_id (non-empty string) required' });
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
if (body.approve !== true) {
|
|
802
|
+
sendJson(res, 403, {
|
|
803
|
+
ok: false,
|
|
804
|
+
error: 'scaffold/stream requires explicit {approve: true} in the body.',
|
|
805
|
+
});
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
const session = getIngest(body.doc_id);
|
|
809
|
+
if (!session) {
|
|
810
|
+
sendJson(res, 404, { ok: false, error: 'no ingest session for doc_id ' + body.doc_id });
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (!session.plan) {
|
|
814
|
+
sendJson(res, 409, {
|
|
815
|
+
ok: false,
|
|
816
|
+
error: 'no plan yet for doc_id ' + body.doc_id,
|
|
817
|
+
});
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
/* Switch to SSE response. */
|
|
821
|
+
res.statusCode = 200;
|
|
822
|
+
res.setHeader('content-type', 'text/event-stream; charset=utf-8');
|
|
823
|
+
res.setHeader('cache-control', 'no-cache');
|
|
824
|
+
res.setHeader('connection', 'keep-alive');
|
|
825
|
+
const force = body.force === true;
|
|
826
|
+
try {
|
|
827
|
+
const report = await executeScaffold({
|
|
828
|
+
plan: session.plan,
|
|
829
|
+
projectRoot: ctx.projectRoot,
|
|
830
|
+
force,
|
|
831
|
+
onProgress: (ev) => {
|
|
832
|
+
try {
|
|
833
|
+
res.write('event: progress\ndata: ' + JSON.stringify(ev) + '\n\n');
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
/* connection closed mid-stream -- the scaffolder
|
|
837
|
+
keeps writing, we just stop emitting. */
|
|
838
|
+
}
|
|
839
|
+
},
|
|
840
|
+
});
|
|
841
|
+
setScaffold(session.doc_id, report);
|
|
842
|
+
res.write('event: complete\ndata: ' + JSON.stringify({
|
|
843
|
+
doc_id: session.doc_id,
|
|
844
|
+
created: report.created,
|
|
845
|
+
overwritten: report.overwritten,
|
|
846
|
+
skipped: report.skipped,
|
|
847
|
+
errors: report.errors,
|
|
848
|
+
total_writes: report.rollback_log.length,
|
|
849
|
+
}) + '\n\n');
|
|
850
|
+
res.end();
|
|
851
|
+
}
|
|
852
|
+
catch (err) {
|
|
853
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
854
|
+
try {
|
|
855
|
+
res.write('event: error\ndata: ' + JSON.stringify({ error: msg }) + '\n\n');
|
|
856
|
+
}
|
|
857
|
+
catch { /* connection already closed */ }
|
|
858
|
+
res.end();
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
/** V1.45 -- rollback a scaffold. */
|
|
862
|
+
async function handleForgeIngestRollback(req, res, ctx) {
|
|
863
|
+
/* ctx is needed for projectRoot validation in the future; */
|
|
864
|
+
/* unused for now keeps the signature parallel to siblings. */
|
|
865
|
+
void ctx;
|
|
866
|
+
let body;
|
|
867
|
+
try {
|
|
868
|
+
body = JSON.parse(await readBody(req));
|
|
869
|
+
}
|
|
870
|
+
catch {
|
|
871
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
if (!body || typeof body.doc_id !== 'string' || body.doc_id.trim() === '') {
|
|
875
|
+
sendJson(res, 400, { ok: false, error: 'doc_id (non-empty string) required' });
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
const session = getIngest(body.doc_id);
|
|
879
|
+
if (!session) {
|
|
880
|
+
sendJson(res, 404, { ok: false, error: 'no ingest session for doc_id ' + body.doc_id });
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
if (!session.scaffold) {
|
|
884
|
+
sendJson(res, 409, {
|
|
885
|
+
ok: false,
|
|
886
|
+
error: 'no scaffold to rollback for doc_id ' + body.doc_id,
|
|
887
|
+
});
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
try {
|
|
891
|
+
const result = await rollbackScaffold(session.scaffold.rollback_log);
|
|
892
|
+
clearScaffold(session.doc_id);
|
|
893
|
+
sendJson(res, 200, {
|
|
894
|
+
ok: true,
|
|
895
|
+
doc_id: session.doc_id,
|
|
896
|
+
undone: result.undone,
|
|
897
|
+
errors: result.errors,
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
catch (err) {
|
|
901
|
+
sendJson(res, 500, {
|
|
902
|
+
ok: false,
|
|
903
|
+
error: err instanceof Error ? err.message : String(err),
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
async function handleVoiceTts(req, res, ctx) {
|
|
908
|
+
let body;
|
|
909
|
+
try {
|
|
910
|
+
body = JSON.parse(await readBody(req));
|
|
911
|
+
}
|
|
912
|
+
catch {
|
|
913
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
if (!body || typeof body.text !== 'string' || body.text.trim() === '') {
|
|
917
|
+
sendJson(res, 400, { ok: false, error: 'text (non-empty string) required' });
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
if (body.format !== undefined && !SUPPORTED_TTS_FORMATS.has(body.format)) {
|
|
921
|
+
sendJson(res, 400, {
|
|
922
|
+
ok: false,
|
|
923
|
+
error: 'format unsupported. Supported: ' + Array.from(SUPPORTED_TTS_FORMATS).join(', '),
|
|
924
|
+
});
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
try {
|
|
928
|
+
const result = await ctx.voice.synthesize({
|
|
929
|
+
text: body.text,
|
|
930
|
+
...(body.voice ? { voice: String(body.voice) } : {}),
|
|
931
|
+
...(typeof body.speed === 'number' ? { speed: body.speed } : {}),
|
|
932
|
+
...(body.language ? { language: String(body.language) } : {}),
|
|
933
|
+
...(body.format ? { format: body.format } : {}),
|
|
934
|
+
...(body.ssml === true ? { ssml: true } : {}),
|
|
935
|
+
});
|
|
936
|
+
res.statusCode = 200;
|
|
937
|
+
res.setHeader('content-type', TTS_AUDIO_MIME[result.format]);
|
|
938
|
+
res.setHeader('content-length', String(result.audio.length));
|
|
939
|
+
res.setHeader('x-voice-provider', result.provider);
|
|
940
|
+
res.setHeader('x-voice-voice', result.voice);
|
|
941
|
+
res.setHeader('x-voice-latency-ms', String(result.latency_ms));
|
|
942
|
+
res.end(result.audio);
|
|
943
|
+
}
|
|
944
|
+
catch (err) {
|
|
945
|
+
sendJson(res, 502, {
|
|
946
|
+
ok: false,
|
|
947
|
+
error: err instanceof Error ? err.message : String(err),
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
async function handleVaultList(res) {
|
|
952
|
+
try {
|
|
953
|
+
const vault = await Vault.open({ configDir: configDir() });
|
|
954
|
+
sendJson(res, 200, { ok: true, slots: vault.list() });
|
|
955
|
+
}
|
|
956
|
+
catch (err) {
|
|
957
|
+
sendJson(res, 500, {
|
|
958
|
+
ok: false,
|
|
959
|
+
error: err instanceof Error ? err.message : String(err),
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
async function handleVaultSet(req, res) {
|
|
964
|
+
let body;
|
|
965
|
+
try {
|
|
966
|
+
body = JSON.parse(await readBody(req));
|
|
967
|
+
}
|
|
968
|
+
catch {
|
|
969
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
if (typeof body.slot !== 'string' || typeof body.plaintext !== 'string') {
|
|
973
|
+
sendJson(res, 400, { ok: false, error: 'slot + plaintext (both strings) required' });
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
/* Optional kind / expiry validation. */
|
|
977
|
+
let kindOpt;
|
|
978
|
+
if (body.kind !== undefined) {
|
|
979
|
+
if (typeof body.kind !== 'string' || !SLOT_KINDS.includes(body.kind)) {
|
|
980
|
+
sendJson(res, 400, { ok: false, error: 'invalid kind. Allowed: ' + SLOT_KINDS.join(', ') });
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
kindOpt = body.kind;
|
|
984
|
+
}
|
|
985
|
+
let expiryOpt;
|
|
986
|
+
if (body.expiry === null) {
|
|
987
|
+
expiryOpt = null;
|
|
988
|
+
}
|
|
989
|
+
else if (body.expiry !== undefined) {
|
|
990
|
+
if (typeof body.expiry !== 'string') {
|
|
991
|
+
sendJson(res, 400, { ok: false, error: 'expiry must be a string ISO 8601 or null' });
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
const d = new Date(body.expiry);
|
|
995
|
+
if (isNaN(d.getTime())) {
|
|
996
|
+
sendJson(res, 400, { ok: false, error: 'expiry is not a valid ISO 8601 timestamp' });
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
expiryOpt = d.toISOString();
|
|
1000
|
+
}
|
|
1001
|
+
try {
|
|
1002
|
+
const vault = await Vault.open({ configDir: configDir() });
|
|
1003
|
+
await vault.set(body.slot, body.plaintext, {
|
|
1004
|
+
...(kindOpt ? { kind: kindOpt } : {}),
|
|
1005
|
+
...(expiryOpt !== undefined ? { expiry: expiryOpt } : {}),
|
|
1006
|
+
});
|
|
1007
|
+
/* Echo metadata only -- never the plaintext, never the
|
|
1008
|
+
ciphertext. The 4-char prefix on list() is the only
|
|
1009
|
+
visible confirmation. */
|
|
1010
|
+
const meta = vault.list().find((s) => s.name === body.slot);
|
|
1011
|
+
sendJson(res, 200, { ok: true, slot: meta });
|
|
1012
|
+
}
|
|
1013
|
+
catch (err) {
|
|
1014
|
+
sendJson(res, 400, {
|
|
1015
|
+
ok: false,
|
|
1016
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
async function handleVaultCatalog(res, url) {
|
|
1021
|
+
const kindFilter = url.searchParams.get('kind');
|
|
1022
|
+
let entries = SLOT_CATALOG;
|
|
1023
|
+
if (kindFilter && SLOT_KINDS.includes(kindFilter)) {
|
|
1024
|
+
entries = entries.filter((e) => e.kind === kindFilter);
|
|
1025
|
+
}
|
|
1026
|
+
sendJson(res, 200, {
|
|
1027
|
+
ok: true,
|
|
1028
|
+
slots: entries.map((e) => ({
|
|
1029
|
+
name: e.name, kind: e.kind, description: e.description,
|
|
1030
|
+
...(e.obtainUrl ? { obtain_url: e.obtainUrl } : {}),
|
|
1031
|
+
has_probe: !!e.test,
|
|
1032
|
+
})),
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
async function handleModeGet(req, res, ctx) {
|
|
1036
|
+
const mode = await resolveModeForRequest(req, ctx.projectRoot);
|
|
1037
|
+
const projectResolved = await resolveMode({ projectRoot: ctx.projectRoot });
|
|
1038
|
+
sendJson(res, 200, {
|
|
1039
|
+
ok: true,
|
|
1040
|
+
mode,
|
|
1041
|
+
source: projectResolved.source,
|
|
1042
|
+
available: FORGE_MODES,
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
async function handleModeSet(req, res, ctx) {
|
|
1046
|
+
let body;
|
|
1047
|
+
try {
|
|
1048
|
+
body = JSON.parse(await readBody(req));
|
|
1049
|
+
}
|
|
1050
|
+
catch {
|
|
1051
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
if (typeof body.mode !== 'string' || !FORGE_MODES.includes(body.mode)) {
|
|
1055
|
+
sendJson(res, 400, { ok: false, error: 'mode must be one of: ' + FORGE_MODES.join(', ') });
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
const mode = body.mode;
|
|
1059
|
+
try {
|
|
1060
|
+
await persistMode(ctx.projectRoot, mode);
|
|
1061
|
+
}
|
|
1062
|
+
catch (err) {
|
|
1063
|
+
sendJson(res, 500, { ok: false,
|
|
1064
|
+
error: err instanceof Error ? err.message : String(err) });
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
/* Set cookie so the next request renders in the new mode
|
|
1068
|
+
even before the user reloads. The cookie is the source of
|
|
1069
|
+
truth for the running browser; the persisted file is the
|
|
1070
|
+
source of truth for the NEXT session. */
|
|
1071
|
+
res.setHeader('set-cookie', 'yf-mode=' + mode + '; Path=/; Max-Age=' + (60 * 60 * 24 * 365) + '; SameSite=Lax');
|
|
1072
|
+
sendJson(res, 200, { ok: true, mode });
|
|
1073
|
+
}
|
|
1074
|
+
async function handleProjectsList(res) {
|
|
1075
|
+
const reg = await readRegistry();
|
|
1076
|
+
sendJson(res, 200, { ok: true, registry: reg });
|
|
1077
|
+
}
|
|
1078
|
+
async function handleProjectsActive(res) {
|
|
1079
|
+
const slug = await activeSlug();
|
|
1080
|
+
const reg = await readRegistry();
|
|
1081
|
+
const entry = slug ? reg.projects.find((p) => p.slug === slug) : null;
|
|
1082
|
+
sendJson(res, 200, { ok: true, active: slug, project: entry ?? null });
|
|
1083
|
+
}
|
|
1084
|
+
async function handleProjectsSwitch(req, res) {
|
|
1085
|
+
let body;
|
|
1086
|
+
try {
|
|
1087
|
+
body = JSON.parse(await readBody(req));
|
|
1088
|
+
}
|
|
1089
|
+
catch {
|
|
1090
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON' });
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
if (typeof body.slug !== 'string') {
|
|
1094
|
+
sendJson(res, 400, { ok: false, error: 'slug (string) required' });
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
try {
|
|
1098
|
+
const reg = await setActive(body.slug);
|
|
1099
|
+
sendJson(res, 200, { ok: true, active: reg.active_slug, registry: reg });
|
|
1100
|
+
}
|
|
1101
|
+
catch (err) {
|
|
1102
|
+
sendJson(res, 404, { ok: false,
|
|
1103
|
+
error: err instanceof Error ? err.message : String(err) });
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
async function handleProjectsScan(req, res) {
|
|
1107
|
+
let body;
|
|
1108
|
+
try {
|
|
1109
|
+
const raw = await readBody(req);
|
|
1110
|
+
body = raw ? JSON.parse(raw) : {};
|
|
1111
|
+
}
|
|
1112
|
+
catch {
|
|
1113
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON' });
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
const candidates = typeof body.root === 'string'
|
|
1117
|
+
? [body.root]
|
|
1118
|
+
: [process.env.HOME ?? '', '/tmp'].filter((s) => s.length > 0);
|
|
1119
|
+
let found = 0;
|
|
1120
|
+
for (const root of candidates) {
|
|
1121
|
+
const matches = await scanForProjects(root, 4);
|
|
1122
|
+
for (const projPath of matches) {
|
|
1123
|
+
const slug = await deriveSlugFromPath(projPath);
|
|
1124
|
+
await upsertProject({ slug, path: projPath });
|
|
1125
|
+
found += 1;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
const reg = await readRegistry();
|
|
1129
|
+
sendJson(res, 200, { ok: true, found, registry: reg });
|
|
1130
|
+
}
|
|
1131
|
+
async function handleProjectsAdd(req, res) {
|
|
1132
|
+
let body;
|
|
1133
|
+
try {
|
|
1134
|
+
body = JSON.parse(await readBody(req));
|
|
1135
|
+
}
|
|
1136
|
+
catch {
|
|
1137
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON' });
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
if (typeof body.path !== 'string') {
|
|
1141
|
+
sendJson(res, 400, { ok: false, error: 'path (string) required' });
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
const slug = await deriveSlugFromPath(body.path);
|
|
1145
|
+
await upsertProject({ slug, path: body.path });
|
|
1146
|
+
const reg = await readRegistry();
|
|
1147
|
+
sendJson(res, 200, { ok: true, slug, registry: reg });
|
|
1148
|
+
}
|
|
1149
|
+
async function handleTargetGet(res, ctx) {
|
|
1150
|
+
const r = await resolveTarget(ctx.projectRoot);
|
|
1151
|
+
sendJson(res, 200, {
|
|
1152
|
+
ok: true,
|
|
1153
|
+
target: r.target,
|
|
1154
|
+
feature_split: r.feature_split,
|
|
1155
|
+
available: FORGE_TARGETS,
|
|
1156
|
+
pending_question: r.target === null, /* Pilot uses this to know whether to prompt */
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
async function handleTargetSet(req, res, ctx) {
|
|
1160
|
+
let body;
|
|
1161
|
+
try {
|
|
1162
|
+
body = JSON.parse(await readBody(req));
|
|
1163
|
+
}
|
|
1164
|
+
catch {
|
|
1165
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
const target = normaliseTarget(body.target);
|
|
1169
|
+
if (!target) {
|
|
1170
|
+
sendJson(res, 400, {
|
|
1171
|
+
ok: false, error: 'target must be one of: ' + FORGE_TARGETS.join(', '),
|
|
1172
|
+
});
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
let featureSplitArg;
|
|
1176
|
+
if (body.feature_split !== undefined && body.feature_split !== null) {
|
|
1177
|
+
if (typeof body.feature_split !== 'object') {
|
|
1178
|
+
sendJson(res, 400, { ok: false, error: 'feature_split must be an object' });
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
featureSplitArg = body.feature_split;
|
|
1182
|
+
}
|
|
1183
|
+
try {
|
|
1184
|
+
await persistTarget(ctx.projectRoot, target, featureSplitArg);
|
|
1185
|
+
}
|
|
1186
|
+
catch (err) {
|
|
1187
|
+
sendJson(res, 500, { ok: false,
|
|
1188
|
+
error: err instanceof Error ? err.message : String(err) });
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
const r = await resolveTarget(ctx.projectRoot);
|
|
1192
|
+
sendJson(res, 200, { ok: true, target: r.target, feature_split: r.feature_split });
|
|
1193
|
+
}
|
|
1194
|
+
async function handleVaultTest(req, res, slot) {
|
|
1195
|
+
let timeoutMs = 5000;
|
|
1196
|
+
/* Optional body for { timeout_ms }. */
|
|
1197
|
+
try {
|
|
1198
|
+
const raw = await readBody(req);
|
|
1199
|
+
if (raw && raw.length > 0) {
|
|
1200
|
+
const body = JSON.parse(raw);
|
|
1201
|
+
if (typeof body.timeout_ms === 'number' && body.timeout_ms > 0 && body.timeout_ms <= 30000) {
|
|
1202
|
+
timeoutMs = body.timeout_ms;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
catch { /* ignore -- use default */ }
|
|
1207
|
+
try {
|
|
1208
|
+
const vault = await Vault.open({ configDir: configDir() });
|
|
1209
|
+
if (!vault.has(slot)) {
|
|
1210
|
+
sendJson(res, 404, { ok: false, error: 'slot not found: ' + slot });
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
const result = await vault.test(slot, { timeoutMs });
|
|
1214
|
+
sendJson(res, 200, { ok: true, slot, result });
|
|
1215
|
+
}
|
|
1216
|
+
catch (err) {
|
|
1217
|
+
sendJson(res, 500, {
|
|
1218
|
+
ok: false,
|
|
1219
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
async function handleVaultRemove(req, res) {
|
|
1224
|
+
let body;
|
|
1225
|
+
try {
|
|
1226
|
+
body = JSON.parse(await readBody(req));
|
|
1227
|
+
}
|
|
1228
|
+
catch {
|
|
1229
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
if (typeof body.slot !== 'string') {
|
|
1233
|
+
sendJson(res, 400, { ok: false, error: 'slot (string) required' });
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
try {
|
|
1237
|
+
const vault = await Vault.open({ configDir: configDir() });
|
|
1238
|
+
const removed = await vault.remove(body.slot);
|
|
1239
|
+
sendJson(res, 200, { ok: true, removed });
|
|
1240
|
+
}
|
|
1241
|
+
catch (err) {
|
|
1242
|
+
sendJson(res, 500, {
|
|
1243
|
+
ok: false,
|
|
1244
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
async function handleVaultHas(res, slot) {
|
|
1249
|
+
try {
|
|
1250
|
+
const vault = await Vault.open({ configDir: configDir() });
|
|
1251
|
+
sendJson(res, 200, { ok: true, slot, present: vault.has(slot) });
|
|
1252
|
+
}
|
|
1253
|
+
catch (err) {
|
|
1254
|
+
sendJson(res, 500, {
|
|
1255
|
+
ok: false,
|
|
1256
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
async function handleChat(req, res, ctx) {
|
|
1261
|
+
const raw = await readBody(req);
|
|
1262
|
+
let body;
|
|
1263
|
+
try {
|
|
1264
|
+
body = JSON.parse(raw);
|
|
1265
|
+
}
|
|
1266
|
+
catch {
|
|
1267
|
+
sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
const msgs = body.messages;
|
|
1271
|
+
if (!Array.isArray(msgs) || msgs.length === 0) {
|
|
1272
|
+
sendJson(res, 400, { ok: false, error: 'messages[] required' });
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
const normalized = [];
|
|
1276
|
+
for (const m of msgs) {
|
|
1277
|
+
if (typeof m !== 'object' || m === null)
|
|
1278
|
+
continue;
|
|
1279
|
+
const r = m['role'];
|
|
1280
|
+
const c = m['content'];
|
|
1281
|
+
if ((r === 'user' || r === 'assistant') && typeof c === 'string') {
|
|
1282
|
+
normalized.push({ role: r, content: c });
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
if (normalized.length === 0) {
|
|
1286
|
+
sendJson(res, 400, { ok: false, error: 'no valid user/assistant messages' });
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
try {
|
|
1290
|
+
/* Resolve mode (didactico vs tecnico) per-request -- read
|
|
1291
|
+
from the cookie if set, else from yujin.forge.json,
|
|
1292
|
+
else default. */
|
|
1293
|
+
const reqMode = await resolveModeForRequest(req, ctx.projectRoot);
|
|
1294
|
+
/* Pilot first-run setup (SQ 0.7 + 0.10). If pilot has not
|
|
1295
|
+
run yet, inject a welcome suffix that asks the user
|
|
1296
|
+
target + mode in plain prose. Also schedule auto-complete
|
|
1297
|
+
after the reply. */
|
|
1298
|
+
const pilotState = await readPilotState(ctx.projectRoot);
|
|
1299
|
+
const lastUserMsg = normalized.filter((m) => m.role === 'user').slice(-1)[0];
|
|
1300
|
+
const lastUserText = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : '';
|
|
1301
|
+
const reply = await ctx.claude.chat({
|
|
1302
|
+
messages: normalized,
|
|
1303
|
+
system: buildSystemPrompt(ctx, reqMode, pilotState),
|
|
1304
|
+
maxTokens: 1024,
|
|
1305
|
+
tools: FORGE_TOOL_SPECS,
|
|
1306
|
+
runTool: async (name, input) => {
|
|
1307
|
+
const r = await runForgeTool(name, input, { projectRoot: ctx.projectRoot });
|
|
1308
|
+
return {
|
|
1309
|
+
result: r.result,
|
|
1310
|
+
...(r.is_error ? { is_error: true } : {}),
|
|
1311
|
+
};
|
|
1312
|
+
},
|
|
1313
|
+
});
|
|
1314
|
+
// Append the latest user message + the assistant's reply to
|
|
1315
|
+
// the persistent transcript. Older messages may already be
|
|
1316
|
+
// present from previous turns; we only need to capture the
|
|
1317
|
+
// *new* turn so the on-disk file matches conversation order.
|
|
1318
|
+
const lastUser = normalized[normalized.length - 1];
|
|
1319
|
+
if (lastUser && lastUser.role === 'user') {
|
|
1320
|
+
// Only append if this user message isn't already the last
|
|
1321
|
+
// recorded one (prevents dup on retry).
|
|
1322
|
+
const recent = ctx.store.messages();
|
|
1323
|
+
const tail = recent[recent.length - 1];
|
|
1324
|
+
if (!tail || tail.role !== 'user' || tail.content !== lastUser.content) {
|
|
1325
|
+
ctx.store.append(lastUser);
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
ctx.store.append({ role: 'assistant', content: reply.text });
|
|
1329
|
+
void ctx.store.flush();
|
|
1330
|
+
/* Pilot first-run auto-complete (SQ 0.10). If Pilot is
|
|
1331
|
+
not yet completed, decide based on the user's first
|
|
1332
|
+
message whether to mark setup done with defaults. */
|
|
1333
|
+
if (!pilotState.pilot_completed) {
|
|
1334
|
+
const decision = shouldAutoComplete(pilotState, lastUserText);
|
|
1335
|
+
if (decision.complete) {
|
|
1336
|
+
try {
|
|
1337
|
+
await completePilotSetup(ctx.projectRoot);
|
|
1338
|
+
}
|
|
1339
|
+
catch { /* non-fatal */ }
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
sendJson(res, 200, {
|
|
1343
|
+
ok: true,
|
|
1344
|
+
message: { role: 'assistant', text: reply.text },
|
|
1345
|
+
tokens: { in: reply.tokensIn, out: reply.tokensOut },
|
|
1346
|
+
model: reply.model,
|
|
1347
|
+
/* Slice 4: surface the audit trail so the panel can render
|
|
1348
|
+
the action trace (which tools Claude called + with what
|
|
1349
|
+
args + what came back). Empty array when no tools fired. */
|
|
1350
|
+
tool_rounds: reply.toolRounds.map((r) => ({
|
|
1351
|
+
tool: r.tool,
|
|
1352
|
+
input: r.input,
|
|
1353
|
+
display: typeof r.result === 'object' && r.result !== null && 'display' in r.result
|
|
1354
|
+
? r.result.display
|
|
1355
|
+
: undefined,
|
|
1356
|
+
is_error: r.is_error ?? false,
|
|
1357
|
+
})),
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
catch (err) {
|
|
1361
|
+
if (err instanceof ConfigurationError) {
|
|
1362
|
+
sendJson(res, 503, {
|
|
1363
|
+
ok: false,
|
|
1364
|
+
code: 'no_api_key',
|
|
1365
|
+
error: err.message,
|
|
1366
|
+
});
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
if (err instanceof ClaudeApiError) {
|
|
1370
|
+
sendJson(res, 502, {
|
|
1371
|
+
ok: false,
|
|
1372
|
+
code: 'claude_api_error',
|
|
1373
|
+
error: err.message,
|
|
1374
|
+
});
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
sendJson(res, 502, {
|
|
1378
|
+
ok: false,
|
|
1379
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
function buildSystemPrompt(ctx, mode = 'didactico', pilotState = {
|
|
1384
|
+
pilot_completed: true, target_pending: false, mode_pending: false,
|
|
1385
|
+
}) {
|
|
1386
|
+
return [
|
|
1387
|
+
'You are Yujin Forge -- a friendly assistant embedded in a developer\'s React project.',
|
|
1388
|
+
'',
|
|
1389
|
+
'PRINCIPLES:',
|
|
1390
|
+
'- Reply in the user\'s language. Default to Spanish if unclear.',
|
|
1391
|
+
'- Keep replies short + conversational.',
|
|
1392
|
+
'- Ask one clarifying question at a time.',
|
|
1393
|
+
'- When proposing code changes, paste minimal diffs the user can apply manually.',
|
|
1394
|
+
' Direct AST mutation lands when the write-class tools ship.',
|
|
1395
|
+
'',
|
|
1396
|
+
'TOOLS:',
|
|
1397
|
+
'- forge.read_manifest: inspect the NAC-3 manifest in the project.',
|
|
1398
|
+
' Use it BEFORE asking the user what is in their app.',
|
|
1399
|
+
'- forge.consult_nac_spec: search docs/SPEC.md for canonical',
|
|
1400
|
+
' answers about NAC-3. Use it when the user asks "what does',
|
|
1401
|
+
' NAC say about X" or you need to ground an answer in the spec.',
|
|
1402
|
+
'- forge.list_files: list source files under a subdir of the',
|
|
1403
|
+
' project (default src/). Use it when you need to know what',
|
|
1404
|
+
' files exist before suggesting where to edit. Filter with',
|
|
1405
|
+
' the glob arg (e.g. "*.tsx") to narrow the result.',
|
|
1406
|
+
'- forge.read_file: read a specific source file by relative',
|
|
1407
|
+
' path. Use AFTER forge.list_files when you need the actual',
|
|
1408
|
+
' contents. Refuses binary files + caps at 64KB by default.',
|
|
1409
|
+
'- Tool calls are silent to the user. Summarise what you found',
|
|
1410
|
+
' in plain language afterwards.',
|
|
1411
|
+
'',
|
|
1412
|
+
'CONTEXT:',
|
|
1413
|
+
'- Project: ' + ctx.projectName,
|
|
1414
|
+
'- Root: ' + ctx.projectRoot,
|
|
1415
|
+
'- Forge: v' + VERSION,
|
|
1416
|
+
modePromptSuffix(mode),
|
|
1417
|
+
welcomePromptSuffix(pilotState),
|
|
1418
|
+
].join('\n');
|
|
1419
|
+
}
|
|
1420
|
+
function sendJson(res, status, body) {
|
|
1421
|
+
res.statusCode = status;
|
|
1422
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
1423
|
+
res.end(JSON.stringify(body));
|
|
1424
|
+
}
|
|
1425
|
+
/** Resolve the active Forge mode for a single request.
|
|
1426
|
+
* Layer order: yf-mode cookie -> yujin.forge.json -> default.
|
|
1427
|
+
* Stays out of server-wide state for race-safe concurrent
|
|
1428
|
+
* rendering, mirroring the i18n stateless pattern. */
|
|
1429
|
+
async function resolveModeForRequest(req, projectRoot) {
|
|
1430
|
+
/* 1. Cookie. */
|
|
1431
|
+
const cookie = req.headers.cookie ?? '';
|
|
1432
|
+
for (const part of cookie.split(';')) {
|
|
1433
|
+
const eq = part.indexOf('=');
|
|
1434
|
+
if (eq < 0)
|
|
1435
|
+
continue;
|
|
1436
|
+
const k = part.slice(0, eq).trim();
|
|
1437
|
+
if (k !== 'yf-mode')
|
|
1438
|
+
continue;
|
|
1439
|
+
const v = part.slice(eq + 1).trim();
|
|
1440
|
+
if (FORGE_MODES.includes(v)) {
|
|
1441
|
+
return v;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
/* 2. Project config + default (handled by resolveMode). */
|
|
1445
|
+
const r = await resolveMode({ projectRoot });
|
|
1446
|
+
return r.mode;
|
|
1447
|
+
}
|
|
1448
|
+
/** Resolve the language for the current request without
|
|
1449
|
+
* mutating server-wide state. Priority chain:
|
|
1450
|
+
* 1. ?lang=xx query string
|
|
1451
|
+
* 2. yf-lang cookie
|
|
1452
|
+
* 3. server-wide setLanguage (legacy default)
|
|
1453
|
+
* Unknown / unsupported codes fall back to the server-wide
|
|
1454
|
+
* current_language. This keeps the lang switching UX working
|
|
1455
|
+
* for clients without cookie support while making concurrent
|
|
1456
|
+
* renders race-safe. */
|
|
1457
|
+
function resolveRequestLang(req, url) {
|
|
1458
|
+
const qp = url.searchParams.get('lang');
|
|
1459
|
+
if (qp && SUPPORTED_LANGUAGES.includes(qp)) {
|
|
1460
|
+
return qp;
|
|
1461
|
+
}
|
|
1462
|
+
const cookie = req.headers.cookie ?? '';
|
|
1463
|
+
for (const part of cookie.split(';')) {
|
|
1464
|
+
const eq = part.indexOf('=');
|
|
1465
|
+
if (eq < 0)
|
|
1466
|
+
continue;
|
|
1467
|
+
const k = part.slice(0, eq).trim();
|
|
1468
|
+
if (k !== 'yf-lang')
|
|
1469
|
+
continue;
|
|
1470
|
+
const v = part.slice(eq + 1).trim();
|
|
1471
|
+
if (SUPPORTED_LANGUAGES.includes(v)) {
|
|
1472
|
+
return v;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
return undefined; /* defer to server-wide current_language */
|
|
1476
|
+
}
|
|
1477
|
+
/** Short hex request id -- not a UUID, no dependency. Used in
|
|
1478
|
+
* the x-request-id response header so client and server logs
|
|
1479
|
+
* can be correlated. 16 hex chars give ~64 bits of entropy --
|
|
1480
|
+
* way more than enough for a local dev server. */
|
|
1481
|
+
function generateRequestId() {
|
|
1482
|
+
const bytes = new Uint8Array(8);
|
|
1483
|
+
if (typeof globalThis.crypto !== 'undefined' && globalThis.crypto.getRandomValues) {
|
|
1484
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
1485
|
+
}
|
|
1486
|
+
else {
|
|
1487
|
+
for (let i = 0; i < bytes.length; i++)
|
|
1488
|
+
bytes[i] = Math.floor(Math.random() * 256);
|
|
1489
|
+
}
|
|
1490
|
+
let s = '';
|
|
1491
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
1492
|
+
s += bytes[i].toString(16).padStart(2, '0');
|
|
1493
|
+
}
|
|
1494
|
+
return s;
|
|
1495
|
+
}
|
|
1496
|
+
async function readBody(req) {
|
|
1497
|
+
const chunks = [];
|
|
1498
|
+
for await (const chunk of req) {
|
|
1499
|
+
chunks.push(chunk);
|
|
1500
|
+
if (chunks.reduce((s, c) => s + c.length, 0) > 1_000_000) {
|
|
1501
|
+
throw new Error('request body too large (>1MB)');
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
1505
|
+
}
|
|
1506
|
+
/** Read a raw binary body up to maxBytes. Used by /api/voice/stt
|
|
1507
|
+
* where the body is the audio recording (typically a few MB).
|
|
1508
|
+
* Throws loudly when the cap is exceeded so the panel surfaces
|
|
1509
|
+
* the size issue. */
|
|
1510
|
+
async function readBinaryBody(req, maxBytes) {
|
|
1511
|
+
const chunks = [];
|
|
1512
|
+
let total = 0;
|
|
1513
|
+
for await (const chunk of req) {
|
|
1514
|
+
const buf = chunk;
|
|
1515
|
+
total += buf.length;
|
|
1516
|
+
if (total > maxBytes) {
|
|
1517
|
+
throw new Error('request body too large (>' + maxBytes + ' bytes)');
|
|
1518
|
+
}
|
|
1519
|
+
chunks.push(buf);
|
|
1520
|
+
}
|
|
1521
|
+
return Buffer.concat(chunks);
|
|
1522
|
+
}
|
|
1523
|
+
async function readProjectName(projectRoot) {
|
|
1524
|
+
try {
|
|
1525
|
+
const raw = await fs.readFile(path.join(projectRoot, 'yujin.forge.json'), 'utf-8');
|
|
1526
|
+
const parsed = JSON.parse(raw);
|
|
1527
|
+
if (parsed && typeof parsed.project_name === 'string')
|
|
1528
|
+
return parsed.project_name;
|
|
1529
|
+
}
|
|
1530
|
+
catch { /* fall through */ }
|
|
1531
|
+
try {
|
|
1532
|
+
const raw = await fs.readFile(path.join(projectRoot, 'package.json'), 'utf-8');
|
|
1533
|
+
const parsed = JSON.parse(raw);
|
|
1534
|
+
if (parsed && typeof parsed.name === 'string')
|
|
1535
|
+
return parsed.name;
|
|
1536
|
+
}
|
|
1537
|
+
catch { /* fall through */ }
|
|
1538
|
+
return path.basename(projectRoot);
|
|
1539
|
+
}
|
|
1540
|
+
//# sourceMappingURL=server.js.map
|