openlanes 3.0.0 → 3.1.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.
- checksums.yaml +4 -4
- data/Brewfile +42 -0
- data/agents/bb8-resolver.md +49 -0
- data/agents/c3po-operator.md +62 -0
- data/agents/chewie-verifier.md +85 -0
- data/agents/hansolo-reviewer.md +20 -15
- data/agents/lando-broker.md +81 -19
- data/agents/leah-researcher.md +6 -6
- data/agents/luke-backend.md +41 -20
- data/agents/{palpatine-planner.md → luthen-rael-planner.md} +15 -13
- data/agents/palpatine-risk-assessor.md +112 -0
- data/agents/r2d2-mechanic.md +5 -1
- data/agents/rey-frontend.md +38 -19
- data/agents/yoda-writer.md +7 -7
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/plan/verification/1/report.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/fail/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/plan.md +7 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/pull-requests.md +10 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/spec.md +5 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/final.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/report.md +14 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/plan/verification/1/step-01.png +0 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/repo/bin/greet +1 -0
- data/evals/cases/chewie-verifier/verify-medium/pass/run.json +1 -0
- data/evals/cases/chewie-verifier/verify-medium.yml +48 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/pull-requests.md +12 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/fail/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/implementation-plan.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/plan.md +3 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/pull-requests.md +15 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/plan/spec.md +5 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/lib/cart.rb +10 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/repo/spec/cart_spec.rb +9 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum/pass/run.json +1 -0
- data/evals/cases/hansolo-reviewer/coverage-minimum.yml +76 -0
- data/evals/cases/luthen-rael-planner/plan-deep/fail/plan/plan.md +8 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep.yml +1 -1
- data/evals/cases/luthen-rael-planner/plan-fast/fail/plan/plan.md +12 -0
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan.md +2 -2
- data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast.yml +1 -1
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/plan.md +20 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/fail/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/plan.md +25 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/pull-requests.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/plan/spec.md +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/repo/db/migrate/20261020120000_drop_legacy_email.rb +5 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium/pass/run.json +1 -0
- data/evals/cases/palpatine-risk-assessor/risk-medium.yml +47 -0
- data/evals/cases/yoda-writer/spec-medium/pass/plan/spec.md +1 -1
- data/lib/openlanes/adapters/base.rb +24 -3
- data/lib/openlanes/adapters/capabilities.rb +29 -0
- data/lib/openlanes/adapters/claude.rb +32 -10
- data/lib/openlanes/adapters/codex.rb +9 -0
- data/lib/openlanes/adapters/pi.rb +10 -0
- data/lib/openlanes/adapters/stream.rb +145 -1
- data/lib/openlanes/adapters.rb +26 -2
- data/lib/openlanes/agents/agent.rb +4 -3
- data/lib/openlanes/agents/roster.rb +14 -2
- data/lib/openlanes/agents/routing.rb +43 -3
- data/lib/openlanes/apps/definition.rb +152 -0
- data/lib/openlanes/apps/framing.rb +114 -0
- data/lib/openlanes/apps/listing.rb +60 -0
- data/lib/openlanes/apps/ports.rb +168 -0
- data/lib/openlanes/apps/probe.rb +63 -0
- data/lib/openlanes/apps/process_spec.rb +39 -0
- data/lib/openlanes/apps/runner.rb +174 -0
- data/lib/openlanes/apps/status.rb +62 -0
- data/lib/openlanes/apps/supervisor.rb +277 -0
- data/lib/openlanes/apps/watcher.rb +50 -0
- data/lib/openlanes/apps.rb +41 -0
- data/lib/openlanes/cli/api/api.rb +10 -0
- data/lib/openlanes/cli/api/subcommands/docs.rb +63 -0
- data/lib/openlanes/cli/app/app.rb +31 -0
- data/lib/openlanes/cli/app/subcommands/start.rb +114 -0
- data/lib/openlanes/cli/app/subcommands/status.rb +38 -0
- data/lib/openlanes/cli/app/subcommands/stop.rb +44 -0
- data/lib/openlanes/cli/base.rb +89 -16
- data/lib/openlanes/cli/coverage/coverage.rb +68 -0
- data/lib/openlanes/cli/create/create.rb +11 -265
- data/lib/openlanes/cli/eval/eval.rb +4 -1
- data/lib/openlanes/cli/init/init.rb +39 -2
- data/lib/openlanes/cli/linear/subcommands/import.rb +1 -1
- data/lib/openlanes/cli/linear/subcommands/projects.rb +1 -1
- data/lib/openlanes/cli/list_plans/list_plans.rb +8 -2
- data/lib/openlanes/cli/mail/subcommands/ack.rb +52 -0
- data/lib/openlanes/cli/mail/subcommands/render.rb +31 -0
- data/lib/openlanes/cli/mail/subcommands/send.rb +26 -0
- data/lib/openlanes/cli/resync/subcommands/dirs.rb +53 -12
- data/lib/openlanes/cli/resync/subcommands/prs.rb +8 -69
- data/lib/openlanes/cli/review/review.rb +72 -0
- data/lib/openlanes/cli/run/run.rb +22 -454
- data/lib/openlanes/cli/setup/setup.rb +51 -0
- data/lib/openlanes/cli/start/start.rb +7 -64
- data/lib/openlanes/cli/state/state.rb +1 -1
- data/lib/openlanes/cli/state/subcommands/resume.rb +61 -0
- data/lib/openlanes/cli/state/subcommands/sign.rb +1 -1
- data/lib/openlanes/cli/unblock/unblock.rb +11 -205
- data/lib/openlanes/cli/web/web.rb +94 -8
- data/lib/openlanes/cli/worktree/worktree.rb +1 -1
- data/lib/openlanes/cli/worktree/worktree_prune.rb +77 -0
- data/lib/openlanes/cli.rb +37 -2
- data/lib/openlanes/coverage/cobertura.rb +49 -0
- data/lib/openlanes/coverage/config.rb +194 -0
- data/lib/openlanes/coverage/istanbul.rb +31 -0
- data/lib/openlanes/coverage/lcov.rb +38 -0
- data/lib/openlanes/coverage/report.rb +71 -0
- data/lib/openlanes/coverage/simplecov.rb +70 -0
- data/lib/openlanes/coverage.rb +33 -0
- data/lib/openlanes/engine/agent_locks.rb +231 -0
- data/lib/openlanes/engine/arbitration.rb +25 -0
- data/lib/openlanes/engine/board.rb +36 -5
- data/lib/openlanes/engine/conversions.rb +160 -0
- data/lib/openlanes/engine/coverage_gate.rb +217 -0
- data/lib/openlanes/engine/dispatcher.rb +1229 -116
- data/lib/openlanes/engine/event_journal/replay.rb +96 -0
- data/lib/openlanes/engine/event_journal.rb +148 -0
- data/lib/openlanes/engine/extensions.rb +271 -0
- data/lib/openlanes/engine/merging.rb +459 -0
- data/lib/openlanes/engine/narrator.rb +107 -0
- data/lib/openlanes/engine/plan_locks.rb +114 -0
- data/lib/openlanes/engine/pr_outcomes.rb +161 -0
- data/lib/openlanes/engine/pr_watch.rb +83 -0
- data/lib/openlanes/engine/progress_log.rb +38 -105
- data/lib/openlanes/engine/quality_question.rb +125 -0
- data/lib/openlanes/engine/rebase.rb +200 -0
- data/lib/openlanes/engine/run_workflow.rb +776 -0
- data/lib/openlanes/engine/runner.rb +186 -15
- data/lib/openlanes/engine/start_workflow.rb +77 -0
- data/lib/openlanes/engine/state_file.rb +101 -34
- data/lib/openlanes/engine/tally.rb +1 -1
- data/lib/openlanes/engine/verification/app_session.rb +104 -0
- data/lib/openlanes/engine/verification/driver.rb +314 -0
- data/lib/openlanes/engine/verification/outcome.rb +516 -0
- data/lib/openlanes/engine/verification/report.rb +95 -0
- data/lib/openlanes/engine/verification/round.rb +100 -0
- data/lib/openlanes/engine/verification/screenshots.rb +120 -0
- data/lib/openlanes/engine/verification.rb +57 -0
- data/lib/openlanes/engine.rb +30 -1
- data/lib/openlanes/execution/boundary.rb +212 -0
- data/lib/openlanes/execution/child.rb +4 -2
- data/lib/openlanes/execution/clock.rb +36 -6
- data/lib/openlanes/execution/control.rb +32 -0
- data/lib/openlanes/execution/executor.rb +114 -35
- data/lib/openlanes/execution/process_start.rb +63 -0
- data/lib/openlanes/execution/prompt.rb +236 -34
- data/lib/openlanes/execution/transcript.rb +159 -8
- data/lib/openlanes/execution.rb +21 -0
- data/lib/openlanes/lifecycle/adoption.rb +25 -8
- data/lib/openlanes/lifecycle/aging.rb +166 -0
- data/lib/openlanes/lifecycle/allocator.rb +216 -0
- data/lib/openlanes/lifecycle/brief.rb +32 -2
- data/lib/openlanes/lifecycle/completion.rb +12 -6
- data/lib/openlanes/lifecycle/create_workflow.rb +272 -0
- data/lib/openlanes/lifecycle/creator.rb +39 -11
- data/lib/openlanes/lifecycle/lanes.rb +1 -1
- data/lib/openlanes/lifecycle/resync.rb +170 -42
- data/lib/openlanes/lifecycle/unblock_workflow.rb +234 -0
- data/lib/openlanes/lifecycle/unblocker.rb +76 -2
- data/lib/openlanes/lifecycle.rb +37 -1
- data/lib/openlanes/linear/mapping.rb +4 -1
- data/lib/openlanes/linear/unit.rb +1 -1
- data/lib/openlanes/live/chat.rb +665 -0
- data/lib/openlanes/live/choice_converter.rb +166 -0
- data/lib/openlanes/live/choice_judge.rb +109 -0
- data/lib/openlanes/live/claims.rb +9 -1
- data/lib/openlanes/live/command.rb +13 -10
- data/lib/openlanes/live/commands.rb +221 -31
- data/lib/openlanes/live/copilot/brief.rb +128 -0
- data/lib/openlanes/live/copilot/cards.rb +226 -0
- data/lib/openlanes/live/copilot/operator.rb +259 -0
- data/lib/openlanes/live/copilot/slash.rb +82 -0
- data/lib/openlanes/live/copilot.rb +290 -0
- data/lib/openlanes/live/event_bus/base.rb +52 -0
- data/lib/openlanes/live/event_bus/memory_bus.rb +14 -0
- data/lib/openlanes/live/event_bus/redis_bus.rb +10 -0
- data/lib/openlanes/live/event_bus/transaction.rb +27 -0
- data/lib/openlanes/live/event_bus.rb +29 -0
- data/lib/openlanes/live/interviewer.rb +146 -0
- data/lib/openlanes/live/keys.rb +114 -0
- data/lib/openlanes/live/mail.rb +143 -0
- data/lib/openlanes/live/memory_store.rb +36 -1
- data/lib/openlanes/live/memory_subscriber.rb +40 -0
- data/lib/openlanes/live/news.rb +61 -0
- data/lib/openlanes/live/node.rb +20 -0
- data/lib/openlanes/live/outage.rb +225 -0
- data/lib/openlanes/live/outage_report.rb +89 -0
- data/lib/openlanes/live/publisher.rb +42 -2
- data/lib/openlanes/live/run_record.rb +39 -1
- data/lib/openlanes/live/run_state.rb +19 -2
- data/lib/openlanes/live/session.rb +38 -4
- data/lib/openlanes/live/snapshot.rb +103 -42
- data/lib/openlanes/live/stations.rb +12 -8
- data/lib/openlanes/live/store.rb +36 -1
- data/lib/openlanes/live/subscriber.rb +93 -0
- data/lib/openlanes/live.rb +71 -1
- data/lib/openlanes/plans/blocked_file.rb +117 -0
- data/lib/openlanes/plans/choices.rb +228 -0
- data/lib/openlanes/plans/dependencies.rb +304 -0
- data/lib/openlanes/plans/feature.rb +75 -16
- data/lib/openlanes/plans/journal.rb +169 -0
- data/lib/openlanes/plans/last_known_state.rb +82 -0
- data/lib/openlanes/plans/lock.rb +171 -0
- data/lib/openlanes/plans/mailbox.rb +145 -4
- data/lib/openlanes/plans/plan_state.rb +590 -9
- data/lib/openlanes/plans/pull_request_machine.rb +163 -0
- data/lib/openlanes/plans/review_label.rb +139 -0
- data/lib/openlanes/plans/spec.rb +239 -3
- data/lib/openlanes/plans/state_machine.rb +63 -30
- data/lib/openlanes/plans/status.rb +60 -11
- data/lib/openlanes/plans/subject.rb +4 -0
- data/lib/openlanes/plans/tree.rb +101 -10
- data/lib/openlanes/plans/verdict.rb +124 -0
- data/lib/openlanes/plans.rb +19 -0
- data/lib/openlanes/presentation/api_docs.rb +76 -0
- data/lib/openlanes/presentation/documentation.rb +105 -12
- data/lib/openlanes/presentation/pr_changes.rb +90 -0
- data/lib/openlanes/presentation/reporter.rb +66 -7
- data/lib/openlanes/presentation.rb +20 -1
- data/lib/openlanes/setup/check.rb +16 -0
- data/lib/openlanes/setup/machine.rb +179 -0
- data/lib/openlanes/setup.rb +34 -0
- data/lib/openlanes/support/clock.rb +57 -0
- data/lib/openlanes/support/config.rb +318 -6
- data/lib/openlanes/support/log.rb +206 -0
- data/lib/openlanes/support/refusal.rb +15 -0
- data/lib/openlanes/support/runtime.rb +61 -0
- data/lib/openlanes/support/spawner.rb +51 -0
- data/lib/openlanes/support/ui.rb +110 -21
- data/lib/openlanes/vcs/ci_gate.rb +31 -0
- data/lib/openlanes/vcs/github.rb +362 -12
- data/lib/openlanes/vcs/label_migration.rb +132 -0
- data/lib/openlanes/vcs/label_sync.rb +75 -0
- data/lib/openlanes/vcs/publisher.rb +287 -7
- data/lib/openlanes/vcs/refresh.rb +337 -0
- data/lib/openlanes/vcs/rename_date.rb +83 -0
- data/lib/openlanes/vcs/review.rb +66 -0
- data/lib/openlanes/vcs/worktree.rb +210 -14
- data/lib/openlanes/vcs.rb +18 -0
- data/lib/openlanes/version.rb +1 -1
- data/lib/openlanes/web/admin_auth.rb +104 -0
- data/lib/openlanes/web/app.rb +213 -15
- data/lib/openlanes/web/context.rb +33 -9
- data/lib/openlanes/web/demo.rb +39 -14
- data/lib/openlanes/web/demo_board.rb +63 -5
- data/lib/openlanes/web/events.rb +137 -9
- data/lib/openlanes/web/launcher.rb +70 -8
- data/lib/openlanes/web/listen.rb +133 -0
- data/lib/openlanes/web/plan_files.rb +136 -0
- data/lib/openlanes/web/request_log.rb +108 -0
- data/lib/openlanes/web/server.rb +31 -10
- data/lib/openlanes/web/stores.rb +11 -0
- data/lib/openlanes/web.rb +19 -4
- data/lib/openlanes.rb +45 -7
- data/openlanes.gemspec +9 -4
- data/schemas/journal-event.schema.json +72 -0
- data/schemas/plan-state.schema.json +163 -2
- data/skills/openlanes/SKILL.md +2 -2
- data/skills/openlanes-documents/SKILL.md +4 -4
- data/skills/openlanes-documents/references/blocked-template.md +2 -2
- data/skills/openlanes-documents/references/closing-templates.md +1 -1
- data/skills/openlanes-documents/references/plan-template.md +12 -4
- data/skills/openlanes-documents/references/retroactive-spec.md +1 -1
- data/skills/openlanes-run/SKILL.md +5 -5
- data/skills/openlanes-spec/SKILL.md +1 -1
- data/skills/openlanes-status/SKILL.md +1 -1
- data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
- data/web/dist/assets/{index-Bp7W-ZTq.js → index-690ayLS4.js} +2 -2
- data/web/dist/assets/index-6ZmW4slI.js +199 -0
- data/web/dist/assets/{index-Byyxbh_z.js → index-B5KzlXp1.js} +2 -2
- data/web/dist/assets/{index-8dNhRmiO.js → index-BAo_v9bU.js} +2 -2
- data/web/dist/assets/{index-2xPC-eHt.js → index-BDm7g-xG.js} +2 -2
- data/web/dist/assets/{index-D0rmJN3h.js → index-BFH9bC7v.js} +2 -2
- data/web/dist/assets/{index-OR-odsl9.js → index-BVHPV7z4.js} +2 -2
- data/web/dist/assets/{index-C0VmkrO-.js → index-B_CPqjnA.js} +2 -2
- data/web/dist/assets/{index-DDI7zoAS.js → index-BhwBH5ct.js} +2 -2
- data/web/dist/assets/{index-BdR34cXC.js → index-Bq5AdwEt.js} +2 -2
- data/web/dist/assets/{index-uM88Njdj.js → index-CAW6ClM2.js} +2 -2
- data/web/dist/assets/index-Cp86u27u.css +1 -0
- data/web/dist/assets/{index-P3vSkiU-.js → index-Culm4pMO.js} +2 -2
- data/web/dist/assets/{index-DGLUuEXi.js → index-DElDJfp1.js} +2 -2
- data/web/dist/assets/{index-CYxcbpRM.js → index-Dja7osNW.js} +2 -2
- data/web/dist/assets/{index-popk9wbW.js → index-DjhU3FzU.js} +2 -2
- data/web/dist/assets/{index-mRV4XhJZ.js → index-Dutbxj4o.js} +2 -2
- data/web/dist/assets/{index-DB4QwuYn.js → index-RkDhPMDt.js} +2 -2
- data/web/dist/assets/{index-B7-O9ujQ.js → index-_mptZjlC.js} +2 -2
- data/web/dist/assets/logo-DXDCUiD8.png +0 -0
- data/web/dist/index.html +3 -3
- data/web/src/api/fixtures/board.json +203 -28
- metadata +1053 -881
- data/bin/factory-e2e +0 -157
- data/bin/plan-number +0 -164
- data/bin/setup +0 -26
- data/evals/cases/palpatine-planner/plan-deep/fail/plan/plan.md +0 -8
- data/evals/cases/palpatine-planner/plan-fast/fail/plan/plan.md +0 -12
- data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
- data/web/dist/assets/index-CI74IkWP.js +0 -198
- data/web/dist/assets/index-a4EHR-u4.css +0 -1
- data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
- data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-deep/pass/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/fail/run.json +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-backend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/plan-frontend.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/plan/spec.md +0 -0
- /data/evals/cases/{palpatine-planner → luthen-rael-planner}/plan-fast/pass/run.json +0 -0
- /data/exe/{depot → ol} +0 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module OpenLanes
|
|
7
|
+
module Live
|
|
8
|
+
# The conversation with the run's copilot, c3po-operator (W17): one Redis
|
|
9
|
+
# stream per node ({Keys#copilot}) holding every turn, the person's and
|
|
10
|
+
# the copilot's, in the order they were said. The page reads it once with
|
|
11
|
+
# {#history} and then follows it on the event stream it already has
|
|
12
|
+
# (`event: copilot`), so the copilot needs no transport of its own.
|
|
13
|
+
#
|
|
14
|
+
# Who answers is the responder's business, not this class's: the API
|
|
15
|
+
# server hands it {Operator}, which runs c3po-operator, and `--demo` a
|
|
16
|
+
# canned line. Whatever answers writes {Turn}s, and {Turn.check} refuses
|
|
17
|
+
# a card that lacks what the page needs to draw it, whoever wrote it.
|
|
18
|
+
class Copilot
|
|
19
|
+
# A message the person may not send as written.
|
|
20
|
+
class Invalid < Error; end
|
|
21
|
+
|
|
22
|
+
# A message nobody is there to answer.
|
|
23
|
+
class Unanswered < Error; end
|
|
24
|
+
|
|
25
|
+
# Who spoke a turn.
|
|
26
|
+
SPEAKERS = %w[you copilot].freeze
|
|
27
|
+
|
|
28
|
+
# What a turn is: words, a red card asking to confirm a command that
|
|
29
|
+
# cannot be undone, or a quiet card holding a drafted answer.
|
|
30
|
+
KINDS = %w[text confirm draft].freeze
|
|
31
|
+
|
|
32
|
+
# The fields each card cannot be drawn without.
|
|
33
|
+
FIELDS = {
|
|
34
|
+
"text" => [],
|
|
35
|
+
"confirm" => %w[command confirm done],
|
|
36
|
+
"draft" => %w[question draft]
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
# Characters one message may carry. A longer one is a paste gone
|
|
40
|
+
# wrong, and the agent would pay for every token of it.
|
|
41
|
+
LIMIT = 4000
|
|
42
|
+
|
|
43
|
+
# Tokens and model before the copilot has said anything that counts them.
|
|
44
|
+
NO_USAGE = { model: nil, up: 0, down: 0 }.freeze
|
|
45
|
+
|
|
46
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
47
|
+
# @param keys [OpenLanes::Live::Keys]
|
|
48
|
+
# @param responder [#call, nil] the person's {Turn} in, the copilot's
|
|
49
|
+
# {Turn}s out; nil for a reader, which {#say} refuses to write through
|
|
50
|
+
def initialize(store:, keys:, responder: nil)
|
|
51
|
+
@store = store
|
|
52
|
+
@keys = keys
|
|
53
|
+
@responder = responder
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Record the person's message, and nothing more. Answering is
|
|
57
|
+
# {#answer}'s, run apart from the request that brought the message: a
|
|
58
|
+
# real responder is a `claude -p` call, and a POST held open for the
|
|
59
|
+
# whole of it would keep the page from showing the message, or that
|
|
60
|
+
# the copilot is reading, until the answer was already written.
|
|
61
|
+
#
|
|
62
|
+
# A slash command ({Slash}, plan 020.00) is not the copilot's to
|
|
63
|
+
# answer: it is queued for this node's engine under the message's id,
|
|
64
|
+
# and the engine's reply comes back into the conversation as turn
|
|
65
|
+
# `"<id>.1"`. A slash message that is not a command is answered here,
|
|
66
|
+
# at once, with the usage. Neither reaches the responder, so a server
|
|
67
|
+
# with no copilot still takes them, and {Turn#slash?} tells the caller
|
|
68
|
+
# not to {#answer} them.
|
|
69
|
+
#
|
|
70
|
+
# @param input [String, Hash] `CopilotMessage`: `{id, text}`
|
|
71
|
+
# @return [OpenLanes::Live::Copilot::Turn] the person's turn
|
|
72
|
+
# @raise [OpenLanes::Live::Copilot::Invalid] for the API server's 422
|
|
73
|
+
# @raise [OpenLanes::Live::Copilot::Unanswered] when nobody would
|
|
74
|
+
# answer: no copilot for plain text, no live engine for a command
|
|
75
|
+
def say(input)
|
|
76
|
+
turn = message(input)
|
|
77
|
+
slash = Slash.parse(turn.text, id: turn.id)
|
|
78
|
+
# Refused before it is stored: a message kept where nothing will ever
|
|
79
|
+
# answer it reads, on the page, as a copilot that is merely slow.
|
|
80
|
+
raise Unanswered, "the copilot is not running in this server" unless slash || @responder
|
|
81
|
+
if slash.is_a?(Command) && !engine?
|
|
82
|
+
raise Unanswered, "no engine is running to apply #{turn.text.split.first}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
append(turn)
|
|
86
|
+
case slash
|
|
87
|
+
when Command then EventBus.for(@store).append(@keys.commands, { json: JSON.generate(slash.wire) })
|
|
88
|
+
when Slash::Help then append(Turn.new(id: "#{turn.id}.1", from: "copilot", kind: "text", text: slash.text))
|
|
89
|
+
end
|
|
90
|
+
turn
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Store whatever the responder answers to +turn+. A responder that
|
|
94
|
+
# fails is answered for, under the reply id the page waits for, so the
|
|
95
|
+
# failure shows in the conversation rather than as a page reading
|
|
96
|
+
# forever.
|
|
97
|
+
#
|
|
98
|
+
# @param turn [OpenLanes::Live::Copilot::Turn] the person's, from {#say}
|
|
99
|
+
# @return [Array<OpenLanes::Live::Copilot::Turn>] what was stored
|
|
100
|
+
def answer(turn)
|
|
101
|
+
replies = begin
|
|
102
|
+
@responder.call(turn)
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
[Turn.new(id: "#{turn.id}.1", from: "copilot", kind: "text", text: "c3po-operator could not answer: #{e.message}")]
|
|
105
|
+
end
|
|
106
|
+
replies.each { append(it) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The conversation as the page draws it on load: the newest turns,
|
|
110
|
+
# oldest first, and the copilot's latest model and tokens. The usage is
|
|
111
|
+
# looked for in the whole retained stream when the newest turns carry
|
|
112
|
+
# none, so a run of unmetered turns does not reset the header to zero.
|
|
113
|
+
#
|
|
114
|
+
# @param count [Integer]
|
|
115
|
+
# @return [Hash] `CopilotLog`: `{turns:, usage:}`
|
|
116
|
+
def history(count: 100)
|
|
117
|
+
turns = newest_turns(count)
|
|
118
|
+
usage = latest_usage(turns)
|
|
119
|
+
usage ||= latest_usage(newest_turns(Keys::COPILOT_LENGTH)) if turns.size == count
|
|
120
|
+
{ turns:, usage: usage || NO_USAGE }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Turns written after +after+, oldest first, with their stream ids.
|
|
124
|
+
#
|
|
125
|
+
# @param after [String] a stream id
|
|
126
|
+
# @return [Array<Array(String, Hash)>]
|
|
127
|
+
def since(after)
|
|
128
|
+
@store.xread(@keys.copilot, id: after).map { |entry| [entry.id, JSON.parse(entry.fields["json"])] }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [String] the newest turn's stream id, `0-0` when there is none
|
|
132
|
+
def newest = @store.xrevrange(@keys.copilot, count: 1).first&.id || "0-0"
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
# @return [OpenLanes::Live::Copilot::Turn]
|
|
137
|
+
def message(input)
|
|
138
|
+
data = input.is_a?(String) ? JSON.parse(input) : input
|
|
139
|
+
raise Invalid, "a message is a JSON object" unless data.is_a?(Hash)
|
|
140
|
+
|
|
141
|
+
id = data["id"].to_s
|
|
142
|
+
text = data["text"].to_s.strip
|
|
143
|
+
raise Invalid, "id must be 1 to 64 letters, digits, - or _" unless id.match?(Command::ID)
|
|
144
|
+
raise Invalid, "a message needs text" if text.empty?
|
|
145
|
+
raise Invalid, "a message is at most #{LIMIT} characters" if text.length > LIMIT
|
|
146
|
+
|
|
147
|
+
Turn.new(id:, from: "you", kind: "text", text:)
|
|
148
|
+
rescue JSON::ParserError => e
|
|
149
|
+
raise Invalid, "not a message: #{e.message.lines.first.to_s.strip}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @return [Array<Hash>] up to +count+ turns, oldest first
|
|
153
|
+
def newest_turns(count)
|
|
154
|
+
@store.xrevrange(@keys.copilot, count:).reverse.map { JSON.parse(it.fields["json"]) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Whether this node's engine is there to read {Keys#commands}: its
|
|
158
|
+
# snapshot key lives only while it publishes (`EX 30`), so a command
|
|
159
|
+
# queued without one would sit unanswered and the page would read on.
|
|
160
|
+
#
|
|
161
|
+
# @return [Boolean]
|
|
162
|
+
def engine? = !@store.get(@keys.snapshot).nil?
|
|
163
|
+
|
|
164
|
+
# @return [Hash, nil]
|
|
165
|
+
def latest_usage(turns) = turns.reverse_each.map { it["usage"] }.find(&:itself)
|
|
166
|
+
|
|
167
|
+
# Written here, in the API server rather than by the Dispatcher, so the
|
|
168
|
+
# open streams are nudged here too: a turn has no other way to reach a
|
|
169
|
+
# stream that waits on {Keys#news}.
|
|
170
|
+
#
|
|
171
|
+
# @return [void]
|
|
172
|
+
def append(turn)
|
|
173
|
+
EventBus.for(@store).append(@keys.copilot, { json: turn.to_json }, maxlen: Keys::COPILOT_LENGTH)
|
|
174
|
+
News.publish(store: @store, keys: @keys, kind: "copilot")
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
class Copilot
|
|
179
|
+
# One thing said in the conversation: `CopilotTurn` in `types.ts`.
|
|
180
|
+
#
|
|
181
|
+
# @!attribute [r] id
|
|
182
|
+
# @return [String] the person's message id; a reply's is that id, a
|
|
183
|
+
# dot and its place among the replies, so a reply names what it answers
|
|
184
|
+
# @!attribute [r] from
|
|
185
|
+
# @return [String] one of {SPEAKERS}
|
|
186
|
+
# @!attribute [r] kind
|
|
187
|
+
# @return [String] one of {KINDS}
|
|
188
|
+
# @!attribute [r] text
|
|
189
|
+
# @return [String]
|
|
190
|
+
# @!attribute [r] at
|
|
191
|
+
# @return [Time]
|
|
192
|
+
# @!attribute [r] usage
|
|
193
|
+
# @return [Hash, nil] `{model, up, down}`, the copilot's running total
|
|
194
|
+
# after this turn; nil on the person's turns and on answers no model
|
|
195
|
+
# wrote
|
|
196
|
+
# @!attribute [r] fields
|
|
197
|
+
# @return [Hash{String => Object}] what a card needs, per {FIELDS}
|
|
198
|
+
Turn = Data.define(:id, :from, :kind, :text, :at, :usage, :fields) do
|
|
199
|
+
def initialize(at: Time.now, usage: nil, fields: {}, **rest)
|
|
200
|
+
super
|
|
201
|
+
Turn.check(self)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# @return [Hash] as the wire carries it
|
|
205
|
+
def wire = { id:, from:, kind:, text:, at: at.utc.iso8601, usage: }.merge(fields.transform_keys(&:to_sym))
|
|
206
|
+
|
|
207
|
+
# @return [Boolean] whether this is the person's slash message, which
|
|
208
|
+
# the engine or {Slash} answers and the copilot never does
|
|
209
|
+
def slash? = from == "you" && text.start_with?("/")
|
|
210
|
+
|
|
211
|
+
# @return [String]
|
|
212
|
+
def to_json(*) = JSON.generate(wire)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
class Turn
|
|
216
|
+
# The keys every turn has; anything else belongs to {FIELDS}.
|
|
217
|
+
BASE = %w[id from kind text at usage].freeze
|
|
218
|
+
|
|
219
|
+
# @param data [Hash] a turn as the wire carries it
|
|
220
|
+
# @return [OpenLanes::Live::Copilot::Turn]
|
|
221
|
+
# @raise [OpenLanes::Live::Copilot::Invalid]
|
|
222
|
+
def self.parse(data)
|
|
223
|
+
data = data.transform_keys(&:to_s)
|
|
224
|
+
new(id: data["id"].to_s,
|
|
225
|
+
from: data["from"].to_s,
|
|
226
|
+
kind: data["kind"].to_s,
|
|
227
|
+
text: data["text"].to_s,
|
|
228
|
+
at: data["at"] ? Time.iso8601(data["at"]) : Time.now,
|
|
229
|
+
usage: data["usage"],
|
|
230
|
+
fields: data.except(*BASE))
|
|
231
|
+
rescue ArgumentError => e
|
|
232
|
+
raise Invalid, "not a turn: #{e.message}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# @param turn [OpenLanes::Live::Copilot::Turn]
|
|
236
|
+
# @return [void]
|
|
237
|
+
# @raise [OpenLanes::Live::Copilot::Invalid] naming what is wrong
|
|
238
|
+
def self.check(turn)
|
|
239
|
+
raise Invalid, "unknown speaker #{turn.from.inspect}" unless SPEAKERS.include?(turn.from)
|
|
240
|
+
raise Invalid, "unknown kind #{turn.kind.inspect}; known: #{KINDS.join(", ")}" unless KINDS.include?(turn.kind)
|
|
241
|
+
raise Invalid, "only the copilot draws cards" if turn.from == "you" && turn.kind != "text"
|
|
242
|
+
|
|
243
|
+
fields = turn.fields.transform_keys(&:to_s)
|
|
244
|
+
check_names(turn.kind, fields.keys)
|
|
245
|
+
check_text(turn.kind, fields)
|
|
246
|
+
check_command(fields["command"]) if turn.kind == "confirm"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# A card holds exactly its own fields: a missing one cannot be drawn,
|
|
250
|
+
# and an extra one could shadow a base key such as `id` on the wire.
|
|
251
|
+
#
|
|
252
|
+
# @return [void]
|
|
253
|
+
def self.check_names(kind, names)
|
|
254
|
+
wanted = FIELDS.fetch(kind)
|
|
255
|
+
missing = wanted - names
|
|
256
|
+
raise Invalid, "a #{kind} card needs #{missing.join(" and ")}" unless missing.empty?
|
|
257
|
+
|
|
258
|
+
extra = names - wanted
|
|
259
|
+
raise Invalid, "a #{kind} card has no #{extra.join(" or ")}" unless extra.empty?
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Every field but a command is drawn as text, so it has to be some:
|
|
263
|
+
# a blank one would draw an unnamed button or an empty draft.
|
|
264
|
+
#
|
|
265
|
+
# @return [void]
|
|
266
|
+
def self.check_text(kind, fields)
|
|
267
|
+
wrong = (FIELDS.fetch(kind) - %w[command]).reject { fields[it].is_a?(String) && !fields[it].strip.empty? }
|
|
268
|
+
raise Invalid, "a #{kind} card's #{wrong.join(" and ")} must be text" unless wrong.empty?
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# A confirm card is sent through the same gate as the page's own
|
|
272
|
+
# buttons (W13), so it has to be a command that gate would take. It
|
|
273
|
+
# carries no id: the page mints one when the person presses, and an id
|
|
274
|
+
# supplied here would replace it and could repeat one already done.
|
|
275
|
+
#
|
|
276
|
+
# @return [void]
|
|
277
|
+
def self.check_command(command)
|
|
278
|
+
raise Invalid, "a confirm card's command is a JSON object" unless command.is_a?(Hash)
|
|
279
|
+
|
|
280
|
+
command = command.transform_keys(&:to_s)
|
|
281
|
+
raise Invalid, "a confirm card's command carries no id; the page mints one" if command.key?("id")
|
|
282
|
+
|
|
283
|
+
Command.parse(command.merge("id" => "card"))
|
|
284
|
+
rescue Command::Invalid => e
|
|
285
|
+
raise Invalid, "a confirm card's command is not one: #{e.message}"
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Live
|
|
5
|
+
module EventBus
|
|
6
|
+
# What every bus answers, delegated to the store behind it.
|
|
7
|
+
class Base
|
|
8
|
+
# @return [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
9
|
+
attr_reader :store
|
|
10
|
+
|
|
11
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore]
|
|
12
|
+
def initialize(store)
|
|
13
|
+
@store = store
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param channel [String]
|
|
18
|
+
# @param payload [String]
|
|
19
|
+
# @return [Integer] how many subscriptions received it
|
|
20
|
+
def publish(channel, payload) = store.publish(channel, payload)
|
|
21
|
+
|
|
22
|
+
# A subscription of its own; the caller closes it.
|
|
23
|
+
#
|
|
24
|
+
# @param channels [Array<String>]
|
|
25
|
+
# @param patterns [Array<String>]
|
|
26
|
+
# @return [#next_message, #close]
|
|
27
|
+
def subscribe(channels: [], patterns: []) = store.subscriber(channels:, patterns:)
|
|
28
|
+
|
|
29
|
+
# A durable append, in stream order.
|
|
30
|
+
#
|
|
31
|
+
# @param stream [String]
|
|
32
|
+
# @param fields [Hash]
|
|
33
|
+
# @param maxlen [Integer, nil] approximate trim
|
|
34
|
+
# @return [String] the entry id
|
|
35
|
+
def append(stream, fields, maxlen: nil) = store.xadd(stream, fields, maxlen:)
|
|
36
|
+
|
|
37
|
+
# The appends and writes the block makes, applied atomically.
|
|
38
|
+
#
|
|
39
|
+
# @yieldparam tx [OpenLanes::Live::EventBus::Transaction]
|
|
40
|
+
# @return [void]
|
|
41
|
+
def transaction
|
|
42
|
+
store.multi { |raw| yield Transaction.new(raw) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The database pub/sub channel names carry (see {Keys#news}).
|
|
46
|
+
#
|
|
47
|
+
# @return [Integer]
|
|
48
|
+
def db = store.db
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Live
|
|
5
|
+
module EventBus
|
|
6
|
+
# The bus for the suite and `--demo`, over {MemoryStore}: one process,
|
|
7
|
+
# one keyspace, the same contract.
|
|
8
|
+
class MemoryBus < Base
|
|
9
|
+
# @param store [OpenLanes::Live::MemoryStore]
|
|
10
|
+
def initialize(store = MemoryStore.new) = super
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Live
|
|
5
|
+
module EventBus
|
|
6
|
+
# One {Base#transaction} block's writes: {#append} for the streams, and
|
|
7
|
+
# the store's other writes ({Store::WRITES}) beside them, so a site that
|
|
8
|
+
# sets a key and appends an entry together keeps doing both atomically.
|
|
9
|
+
class Transaction
|
|
10
|
+
# @param raw [OpenLanes::Live::Store::Transaction]
|
|
11
|
+
def initialize(raw)
|
|
12
|
+
@raw = raw
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param stream [String]
|
|
16
|
+
# @param fields [Hash]
|
|
17
|
+
# @param maxlen [Integer, nil]
|
|
18
|
+
# @return [nil]
|
|
19
|
+
def append(stream, fields, maxlen: nil) = @raw.xadd(stream, fields, maxlen:)
|
|
20
|
+
|
|
21
|
+
(Store::WRITES - %i[xadd]).each do |name|
|
|
22
|
+
define_method(name) { |*args, **kwargs| @raw.public_send(name, *args, **kwargs) }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenLanes
|
|
4
|
+
module Live
|
|
5
|
+
# The port every publish, subscribe and stream append goes through.
|
|
6
|
+
#
|
|
7
|
+
# Engine, Live and Web used to call `store.publish`, `store.subscriber`
|
|
8
|
+
# and `store.xadd` directly, on whichever of {Store} and {MemoryStore}
|
|
9
|
+
# they were handed, so the two shared a duck type nobody declared. A bus
|
|
10
|
+
# names that surface, and the stores become its backing: {RedisBus} over
|
|
11
|
+
# {Store}, {MemoryBus} over {MemoryStore}. Key layout, stream ids and the
|
|
12
|
+
# wire format are the store's, unchanged, because web cursors rely on them.
|
|
13
|
+
module EventBus
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# The bus over +store+, or +store+ itself when it already is one.
|
|
17
|
+
#
|
|
18
|
+
# @param store [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore, Base]
|
|
19
|
+
# @return [Base]
|
|
20
|
+
def for(store)
|
|
21
|
+
case store
|
|
22
|
+
when Base then store
|
|
23
|
+
when MemoryStore then MemoryBus.new(store)
|
|
24
|
+
else RedisBus.new(store)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module OpenLanes
|
|
6
|
+
module Live
|
|
7
|
+
# Grills a new idea before its spec.md is written (M6): the questions the
|
|
8
|
+
# agents downstream would otherwise block to ask, each as a short context
|
|
9
|
+
# paragraph and lettered options with exactly one recommended. The page
|
|
10
|
+
# asks them one at a time, and the answers reach {Commands#create_plans}
|
|
11
|
+
# as `payload.decisions`, which writes them into the spec.
|
|
12
|
+
#
|
|
13
|
+
# One call to a small model on OpenRouter, as {ChoiceConverter} makes,
|
|
14
|
+
# and every question is checked the same way: written with
|
|
15
|
+
# {Plans::Choices.write} and read back with {Plans::Choices.parse}. A
|
|
16
|
+
# question that does not survive the trip is dropped, not the interview:
|
|
17
|
+
# four good questions are worth asking without the fifth.
|
|
18
|
+
#
|
|
19
|
+
# A seam: the suite sets {Live.interviewer} to nil, and examples about it
|
|
20
|
+
# hand it an adapter double, so nothing in the tests reaches OpenRouter.
|
|
21
|
+
class Interviewer
|
|
22
|
+
# The most questions one idea is asked. A person is interviewed while
|
|
23
|
+
# they wait, so the list stays short (M0).
|
|
24
|
+
LIMIT = 5
|
|
25
|
+
|
|
26
|
+
# Seconds a person waits for the questions before the plan is created
|
|
27
|
+
# without them. The HTTP client alone allows 10 to connect and 30 to
|
|
28
|
+
# read, and a Puma thread and the dialog are held for all of it.
|
|
29
|
+
TIMEOUT = 20
|
|
30
|
+
|
|
31
|
+
# What the model reads with the idea.
|
|
32
|
+
PROMPT = <<~PROMPT.freeze
|
|
33
|
+
A person described a software feature they want built. Agents will turn it into a spec, a plan and
|
|
34
|
+
code without being able to ask anything, so interview the person now. Return `questions`: an array
|
|
35
|
+
of at most #{LIMIT} objects, the decisions that most change what gets built and that the idea leaves open.
|
|
36
|
+
Ask nothing the idea already answers. Each object is:
|
|
37
|
+
|
|
38
|
+
- `context`: one short paragraph saying what must be decided and why it matters.
|
|
39
|
+
- `options`: an array of 2 to 5 objects `{"label": String, "recommended": Boolean}`, each a distinct
|
|
40
|
+
answer on one short line. Exactly one has `"recommended": true`: the one you would choose.
|
|
41
|
+
|
|
42
|
+
Never add an option for "something else" or "type something instead": the page has a text box.
|
|
43
|
+
An idea that leaves nothing open gets an empty array.
|
|
44
|
+
PROMPT
|
|
45
|
+
|
|
46
|
+
# One question, ready for the page.
|
|
47
|
+
#
|
|
48
|
+
# @!attribute [r] context
|
|
49
|
+
# @return [String]
|
|
50
|
+
# @!attribute [r] options
|
|
51
|
+
# @return [Array<String>] labels, in order
|
|
52
|
+
# @!attribute [r] recommended
|
|
53
|
+
# @return [Integer] the index of the recommended option
|
|
54
|
+
Question = Data.define(:context, :options, :recommended) do
|
|
55
|
+
# @return [Hash] as `POST /api/interview` sends it
|
|
56
|
+
def wire = { context:, options:, recommended: }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# An interviewer when OPENROUTER_API_KEY is set, nil otherwise.
|
|
60
|
+
#
|
|
61
|
+
# @param env [#[]]
|
|
62
|
+
# @param config [OpenLanes::Config]
|
|
63
|
+
# @return [OpenLanes::Live::Interviewer, nil]
|
|
64
|
+
def self.from_env(env = ENV, config: Config.current)
|
|
65
|
+
key = env["OPENROUTER_API_KEY"].to_s.strip
|
|
66
|
+
key.empty? ? nil : new(api_key: key, model: config.questions_model || ChoiceConverter::DEFAULT_MODEL)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The model's questions, each cleaned the way {ChoiceConverter.render}
|
|
70
|
+
# cleans one, keeping those that survive.
|
|
71
|
+
#
|
|
72
|
+
# @param output [Hash] `questions`, as the adapter parsed it
|
|
73
|
+
# @return [Array<OpenLanes::Live::Interviewer::Question>] at most {LIMIT}
|
|
74
|
+
def self.render(output)
|
|
75
|
+
output = output.to_h.transform_keys(&:to_sym)
|
|
76
|
+
Array(output[:questions]).filter_map { question(it) }.first(LIMIT)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param raw [Object]
|
|
80
|
+
# @return [OpenLanes::Live::Interviewer::Question, nil]
|
|
81
|
+
def self.question(raw)
|
|
82
|
+
return unless raw.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
body = ChoiceConverter.render(raw)
|
|
85
|
+
choices = Plans::Choices.parse(body)
|
|
86
|
+
Question.new(context: choices.context,
|
|
87
|
+
options: choices.options.map(&:label),
|
|
88
|
+
recommended: choices.options.index(&:recommended))
|
|
89
|
+
rescue ChoiceConverter::Invalid
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
private_class_method :question
|
|
93
|
+
|
|
94
|
+
# @param api_key [String, nil] OpenRouter's; only read to build the default adapter
|
|
95
|
+
# @param model [String] an OpenRouter model id
|
|
96
|
+
# @param adapter [#call, nil] answers `call(node, answers)` with a Hash
|
|
97
|
+
# holding `questions`; built on first use when nil
|
|
98
|
+
# @param timeout [Numeric] seconds before the interview is given up
|
|
99
|
+
def initialize(api_key: nil, model: ChoiceConverter::DEFAULT_MODEL, adapter: nil, timeout: TIMEOUT)
|
|
100
|
+
@api_key = api_key
|
|
101
|
+
@model = model
|
|
102
|
+
@adapter = adapter
|
|
103
|
+
@timeout = timeout
|
|
104
|
+
@mutex = Mutex.new
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @return [String]
|
|
108
|
+
attr_reader :model
|
|
109
|
+
|
|
110
|
+
# Blocks for at most {TIMEOUT} seconds.
|
|
111
|
+
#
|
|
112
|
+
# @param idea [String] Markdown, as the idea dialog sends it
|
|
113
|
+
# @return [Array<OpenLanes::Live::Interviewer::Question>]
|
|
114
|
+
# @raise [StandardError] whatever the adapter raises: no network, a 4xx,
|
|
115
|
+
# bad JSON, or Timeout::Error past the limit
|
|
116
|
+
def call(idea:)
|
|
117
|
+
output = Timeout.timeout(@timeout, Timeout::Error, "no questions within #{@timeout}s") do
|
|
118
|
+
adapter.call(node, { idea: idea.to_s })
|
|
119
|
+
end
|
|
120
|
+
self.class.render(output)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def adapter
|
|
126
|
+
@mutex.synchronize do
|
|
127
|
+
@adapter ||= begin
|
|
128
|
+
require "inquirex-llm"
|
|
129
|
+
Inquirex::LLM::OpenRouterAdapter.new(api_key: @api_key, model:)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def node
|
|
135
|
+
@node ||= begin
|
|
136
|
+
require "inquirex-llm"
|
|
137
|
+
Inquirex::LLM::Node.new(id: :interview,
|
|
138
|
+
verb: :extract,
|
|
139
|
+
prompt: PROMPT,
|
|
140
|
+
schema: Inquirex::LLM::Schema.new(questions: :array),
|
|
141
|
+
from_steps: %i[idea])
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|