@danceiny/gotry 0.0.1-rc.9 → 0.2.0-rc.28
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/README.md +172 -219
- package/README.zh-CN.md +222 -0
- package/bin/gotry-backend.js +53 -0
- package/bin/gotry-booking-copilot.js +53 -0
- package/bin/gotry-bootstrap.js +1788 -0
- package/bin/gotry-inner.js +733 -83
- package/bin/gotry-process-liveness.d.ts +27 -0
- package/bin/gotry-process-liveness.js +97 -0
- package/bin/gotry-runtime-resolution.d.ts +27 -0
- package/bin/gotry-runtime-resolution.js +49 -0
- package/bin/gotry.js +1 -1
- package/client/client.js +203 -0
- package/cordis.gotry-patch.yml +80 -56
- package/data/airline-airports.json +39 -0
- package/data/stations-12306-verify.json +524 -0
- package/dist/capabilities/agent-reach-deep.js +45 -122
- package/dist/capabilities/agent-reach.js +101 -146
- package/dist/capabilities/anything.js +161 -94
- package/dist/capabilities/artifacts.js +370 -0
- package/dist/capabilities/channel-health.js +131 -0
- package/dist/capabilities/channel-registry.js +256 -0
- package/dist/capabilities/doctor.js +553 -0
- package/dist/capabilities/effect.js +394 -0
- package/dist/capabilities/external-event.js +149 -0
- package/dist/capabilities/fact-log.js +55 -0
- package/dist/capabilities/flyai-config.js +337 -0
- package/dist/capabilities/flyai.js +947 -110
- package/dist/capabilities/fx-contract.js +293 -0
- package/dist/capabilities/geo-atlas.js +246 -0
- package/dist/capabilities/ground-transfer.js +676 -0
- package/dist/capabilities/hbcli.js +260 -123
- package/dist/capabilities/hotelbyte-transaction.js +194 -0
- package/dist/capabilities/incident-log.js +131 -61
- package/dist/capabilities/itinerary-artifact.js +190 -0
- package/dist/capabilities/itinerary-deck-artifact.js +194 -0
- package/dist/capabilities/itinerary-deck-export.js +339 -0
- package/dist/capabilities/lavish-local.js +924 -0
- package/dist/capabilities/model-override.js +38 -0
- package/dist/capabilities/opensky.js +54 -43
- package/dist/capabilities/provider-detail-sanitize.js +64 -0
- package/dist/capabilities/resilience.js +191 -0
- package/dist/capabilities/route-provider-conformance.js +341 -0
- package/dist/capabilities/session/action-cache.js +50 -44
- package/dist/capabilities/session/adapters/ctrip-flight.js +120 -62
- package/dist/capabilities/session/adapters/ctrip-hotel.js +222 -0
- package/dist/capabilities/session/adapters/dida-portal.js +274 -0
- package/dist/capabilities/session/adapters/meituan-local.js +35 -54
- package/dist/capabilities/session/adapters/rail-12306.js +342 -0
- package/dist/capabilities/session/benchmark.js +189 -0
- package/dist/capabilities/session/bridge-ledger.js +224 -0
- package/dist/capabilities/session/extension-bridge.js +636 -0
- package/dist/capabilities/session/extension-channel.js +109 -0
- package/dist/capabilities/session/extension-distribution.js +230 -0
- package/dist/capabilities/session/extract.js +31 -21
- package/dist/capabilities/session/golden-score.js +114 -0
- package/dist/capabilities/session/health-watch.js +215 -0
- package/dist/capabilities/session/read-guard.js +75 -24
- package/dist/capabilities/session/static-flight-golden.js +150 -0
- package/dist/capabilities/session/transport.js +97 -83
- package/dist/capabilities/session/wizard.js +117 -0
- package/dist/capabilities/session-consent.js +178 -0
- package/dist/capabilities/session-login.js +170 -0
- package/dist/capabilities/session-search.js +635 -44
- package/dist/capabilities/spawn-bounded.js +203 -0
- package/dist/capabilities/sponsor-plugin.js +341 -0
- package/dist/capabilities/visa-policy.js +140 -0
- package/dist/capabilities/weather.js +243 -138
- package/dist/data/session-golden-20.json +25 -0
- package/dist/data/sf-golden-manifest.json +103 -0
- package/dist/data/sf-static-routes.json +91 -0
- package/dist/scripts/action-cache-tests.js +24 -59
- package/dist/scripts/agent-planning-turn-deadline-e2e.js +242 -0
- package/dist/scripts/agent-planning-turn-deadline-tests.js +185 -0
- package/dist/scripts/agent-reach-deep-tests.js +30 -40
- package/dist/scripts/agent-reach-error-tests.js +354 -0
- package/dist/scripts/agent-reach-tests.js +26 -18
- package/dist/scripts/agent-reach-wrapper-tests.js +50 -50
- package/dist/scripts/anything-path-measure-tests.js +133 -0
- package/dist/scripts/anything-path-measure.js +150 -0
- package/dist/scripts/anything-tests.js +250 -60
- package/dist/scripts/artifact-client-contract-tests.js +220 -0
- package/dist/scripts/artifacts-capability-tests.js +727 -0
- package/dist/scripts/async-collect.js +50 -11
- package/dist/scripts/benchmark-environment-bridge-e2e.js +741 -0
- package/dist/scripts/benchmark-environment-bridge-tests.js +1452 -0
- package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +126 -0
- package/dist/scripts/booking-copilot-availability-policy-tests.js +265 -0
- package/dist/scripts/booking-copilot-bin-proof-tests.js +66 -0
- package/dist/scripts/booking-copilot-cleanup-proof-helper.js +76 -0
- package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +144 -0
- package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +424 -0
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +2839 -0
- package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +309 -0
- package/dist/scripts/booking-copilot-dsh-readiness-proof.js +238 -0
- package/dist/scripts/booking-copilot-dsh-warmer-proof.js +126 -0
- package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +156 -0
- package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +70 -0
- package/dist/scripts/booking-copilot-intent-proof-tests.js +422 -0
- package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +184 -0
- package/dist/scripts/booking-copilot-planner-live-probe.js +95 -0
- package/dist/scripts/booking-copilot-proof-runtime.js +42 -0
- package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +210 -0
- package/dist/scripts/booking-copilot-runtime-proof-tests.js +2349 -0
- package/dist/scripts/booking-copilot-server-proof-tests.js +784 -0
- package/dist/scripts/booking-copilot-startup-proof-tests.js +94 -0
- package/dist/scripts/booking-executor-tests.js +80 -0
- package/dist/scripts/booking-recovery-chain-tests.js +182 -0
- package/dist/scripts/booking-saga-tests.js +125 -0
- package/dist/scripts/booking-surface-contract-proof-tests.js +212 -0
- package/dist/scripts/bootstrap-tests.js +1549 -0
- package/dist/scripts/bridge-events-tests.js +239 -0
- package/dist/scripts/bridge-ledger-tests.js +428 -0
- package/dist/scripts/build-changelog.js +238 -0
- package/dist/scripts/build-metrics-report.js +331 -0
- package/dist/scripts/changelog-tests.js +118 -0
- package/dist/scripts/channel-probe-tests.js +103 -0
- package/dist/scripts/channel-probe.js +169 -0
- package/dist/scripts/channel-registry-tests.js +208 -0
- package/dist/scripts/city-scenario-tier-tests.js +234 -0
- package/dist/scripts/companion-tests.js +25 -70
- package/dist/scripts/dida-cookie-probe.js +87 -0
- package/dist/scripts/dida-runner-stop-tests.js +311 -0
- package/dist/scripts/dida-sw-probe.js +114 -0
- package/dist/scripts/diff-test.js +17 -12
- package/dist/scripts/doctor-tests.js +600 -0
- package/dist/scripts/drill-multiuser-ledger-tests.js +1076 -0
- package/dist/scripts/drill-sdk-descendant-cleanup-tests.js +606 -0
- package/dist/scripts/drill-w2a-sensor-tests.js +788 -0
- package/dist/scripts/dsh-artifact-e2e.js +279 -0
- package/dist/scripts/dsh-artifact-web-e2e.js +675 -0
- package/dist/scripts/dsh-http-proxy-sse-proof.js +90 -0
- package/dist/scripts/dsh-llm-tests.js +437 -0
- package/dist/scripts/dsh-runtime-closure-tests.js +182 -0
- package/dist/scripts/dsh-runtime-closure.js +162 -0
- package/dist/scripts/dsh-session-v3-migration-proof.js +109 -0
- package/dist/scripts/dsh-subprocess-local-proof.js +150 -0
- package/dist/scripts/dsh-target-closure-proof.js +178 -0
- package/dist/scripts/effect-tests.js +539 -0
- package/dist/scripts/engine-run.js +1 -3
- package/dist/scripts/engine-tests.js +16 -10
- package/dist/scripts/evaluation-cadence-tests.js +174 -0
- package/dist/scripts/evaluation-contract-tests.js +268 -0
- package/dist/scripts/extension-distribution-cli.js +39 -0
- package/dist/scripts/extension-distribution-tests.js +443 -0
- package/dist/scripts/extension-tests.js +988 -0
- package/dist/scripts/external-event-tests.js +350 -0
- package/dist/scripts/fact-gate-tests.js +869 -0
- package/dist/scripts/fixtures/extension-bridge-unref-child.mjs +21 -0
- package/dist/scripts/flight-hotel-anchor-363-registered-e2e.js +169 -0
- package/dist/scripts/flight-malformed-tests.js +159 -0
- package/dist/scripts/flyai-setup-tests.js +228 -0
- package/dist/scripts/flyai-setup-tool-tests.js +54 -0
- package/dist/scripts/flyai-tests.js +692 -0
- package/dist/scripts/flyai-tool-registration-contract-tests.js +91 -0
- package/dist/scripts/fx-contract-tests.js +201 -0
- package/dist/scripts/g5-guard-tests.js +150 -0
- package/dist/scripts/g5-guard.js +193 -0
- package/dist/scripts/gen-lunar-anchors.js +106 -0
- package/dist/scripts/geo-atlas-tests.js +283 -0
- package/dist/scripts/gotry-backend-kernel-tests.js +206 -0
- package/dist/scripts/gotry-try-demo-tests.js +79 -0
- package/dist/scripts/gotry-try-demo.js +49 -0
- package/dist/scripts/ground-transfer-tests.js +956 -0
- package/dist/scripts/hbcli-e2e-tests.js +159 -0
- package/dist/scripts/hbcli-live-optin-tests.js +96 -0
- package/dist/scripts/hbcli-release-contract-tests.js +122 -0
- package/dist/scripts/hbcli-tests.js +121 -69
- package/dist/scripts/health-watch-cli.js +73 -0
- package/dist/scripts/hotel-date-gate-tests.js +496 -0
- package/dist/scripts/hotelbyte-reconcile-tests.js +141 -0
- package/dist/scripts/hotelbyte-spawn-e2e-tests.js +373 -0
- package/dist/scripts/i18n-tests.js +41 -0
- package/dist/scripts/incident-tests.js +281 -87
- package/dist/scripts/issue-194-job-id-guard-tests.js +106 -0
- package/dist/scripts/issue-233-cancel-refund-commission-tests.js +392 -0
- package/dist/scripts/issue-271-liveness-tests.js +569 -0
- package/dist/scripts/issue-289-model-retry-real-tests.js +296 -0
- package/dist/scripts/issue-289-model-retry-tests.js +441 -0
- package/dist/scripts/issue-289-web-retry-e2e.js +630 -0
- package/dist/scripts/issue-338-home-city-e2e.js +577 -0
- package/dist/scripts/issue343-real-entry-e2e.js +160 -0
- package/dist/scripts/issue436-persisted-routing-e2e.js +247 -0
- package/dist/scripts/itinerary-artifact-tests.js +368 -0
- package/dist/scripts/itinerary-deck-artifact-tests.js +416 -0
- package/dist/scripts/itinerary-deck-export-tests.js +552 -0
- package/dist/scripts/itinerary-deck-tests.js +435 -0
- package/dist/scripts/itinerary-html-tests.js +426 -0
- package/dist/scripts/journey-tests.js +22 -27
- package/dist/scripts/kernel-manifest-evidence.js +109 -0
- package/dist/scripts/kernel-manifest-gate.js +126 -0
- package/dist/scripts/kernel-manifest-tests.js +212 -0
- package/dist/scripts/kernel-manifest-trace-entry.js +7 -0
- package/dist/scripts/kernel-manifest-trace-hook.mjs +28 -0
- package/dist/scripts/kernel-manifest.js +369 -0
- package/dist/scripts/lavish-local-tests.js +1226 -0
- package/dist/scripts/lavish-product-tools-tests.js +537 -0
- package/dist/scripts/ledger-repair-apply-tests.js +316 -0
- package/dist/scripts/ledger-repair-plan-tests.js +375 -0
- package/dist/scripts/ledger-tests.js +382 -246
- package/dist/scripts/ledger-workflow-crash.js +10 -20
- package/dist/scripts/m3-cohort-tests.js +714 -0
- package/dist/scripts/m3-cohort.js +310 -0
- package/dist/scripts/managed-dsh-cleanup-diagnostic-proof.js +184 -0
- package/dist/scripts/managed-dsh-close-failure-proof.js +172 -0
- package/dist/scripts/managed-dsh-run-port-proof.js +153 -0
- package/dist/scripts/managed-dsh-terminal-proof.js +252 -0
- package/dist/scripts/map-tools-vendor-package-proof.js +365 -0
- package/dist/scripts/memory-capture-tests.js +242 -61
- package/dist/scripts/memory-decay-tests.js +18 -52
- package/dist/scripts/memory-lifecycle-tests.js +657 -0
- package/dist/scripts/memory-lifecycle.js +196 -0
- package/dist/scripts/memory-metrics.js +24 -12
- package/dist/scripts/memory-value-report.js +631 -0
- package/dist/scripts/memory-value-tests.js +328 -0
- package/dist/scripts/metrics-report-tests.js +151 -0
- package/dist/scripts/model-override-e2e.js +137 -0
- package/dist/scripts/nightly-evidence-tests.js +203 -0
- package/dist/scripts/nightly-evidence.js +303 -0
- package/dist/scripts/nudge-digest.js +39 -28
- package/dist/scripts/observability-tests.js +381 -0
- package/dist/scripts/onboarding-tests.js +161 -0
- package/dist/scripts/opensky-check.js +18 -13
- package/dist/scripts/opensky-tests.js +28 -28
- package/dist/scripts/outcome-projection-tests.js +427 -0
- package/dist/scripts/persona-sim-tests.js +603 -0
- package/dist/scripts/persona-sim.js +1081 -0
- package/dist/scripts/persona-surface-guard-tests.js +49 -0
- package/dist/scripts/pnpm-dsh-closure-proof.js +17 -0
- package/dist/scripts/policy-anchor-359-registered-e2e.js +214 -0
- package/dist/scripts/price-drift-tests.js +340 -0
- package/dist/scripts/price-drift-watch.js +447 -0
- package/dist/scripts/probe-poi-tests.js +53 -45
- package/dist/scripts/product-metrics.js +483 -0
- package/dist/scripts/publish-preverify.js +110 -0
- package/dist/scripts/realtime-pricing-tests.js +76 -0
- package/dist/scripts/recall-tests.js +513 -0
- package/dist/scripts/replay-async.js +22 -18
- package/dist/scripts/replay-real.js +18 -18
- package/dist/scripts/replay.js +54 -82
- package/dist/scripts/route-provider-conformance-tests.js +391 -0
- package/dist/scripts/session-attach-diagnose.js +20 -20
- package/dist/scripts/session-attach-poc.js +34 -37
- package/dist/scripts/session-benchmark.js +180 -0
- package/dist/scripts/session-dida-live-e2e.js +199 -0
- package/dist/scripts/session-extract-tests.js +14 -35
- package/dist/scripts/session-link-tests.js +478 -0
- package/dist/scripts/session-login.js +27 -46
- package/dist/scripts/session-tests.js +796 -96
- package/dist/scripts/session-zone-crash.js +49 -0
- package/dist/scripts/session-zone-ledger-tests.js +432 -0
- package/dist/scripts/session-zone-observation-tests.js +492 -0
- package/dist/scripts/session-zone-observe.js +135 -0
- package/dist/scripts/session-zone-wiring-tests.js +566 -0
- package/dist/scripts/session-zones-tests.js +374 -0
- package/dist/scripts/sf-live-benchmark.js +367 -0
- package/dist/scripts/sf-live-challenge-stop-tests.js +453 -0
- package/dist/scripts/sf-live-cli-tests.js +92 -0
- package/dist/scripts/sf-live-options.js +46 -0
- package/dist/scripts/sf-manifest-tests.js +30 -0
- package/dist/scripts/sf-manifest.js +33 -0
- package/dist/scripts/sf-soft-score-tests.js +67 -0
- package/dist/scripts/sf-summary-tests.js +324 -0
- package/dist/scripts/sf-summary.js +525 -0
- package/dist/scripts/share-tests.js +432 -0
- package/dist/scripts/skeleton-check.js +16 -14
- package/dist/scripts/skeleton-integration-test.js +28 -11
- package/dist/scripts/skills-contract-tests.js +51 -46
- package/dist/scripts/smoke-session-gate-tests.js +21 -0
- package/dist/scripts/smoke.js +690 -246
- package/dist/scripts/spawn-bounded-abort-tests.js +244 -0
- package/dist/scripts/sponsor-reuse-tests.js +262 -0
- package/dist/scripts/state-cli-tests.js +226 -101
- package/dist/scripts/state-cli.js +565 -198
- package/dist/scripts/state-repair-cli.js +66 -0
- package/dist/scripts/state-repair-tests.js +140 -0
- package/dist/scripts/static-golden-tests.js +165 -0
- package/dist/scripts/time-eval-tests.js +212 -192
- package/dist/scripts/travel-timeline-tests.js +29 -93
- package/dist/scripts/turn-handoff-collect-tests.js +169 -0
- package/dist/scripts/turn-handoff-collect.js +161 -0
- package/dist/scripts/turn-policy-tests.js +55 -0
- package/dist/scripts/typed-contract-canary.js +176 -0
- package/dist/scripts/tz-resolver-sanity.js +50 -0
- package/dist/scripts/tz-resolver-tests.js +584 -0
- package/dist/scripts/unified-tests.js +60 -20
- package/dist/scripts/visa-policy-tests.js +62 -0
- package/dist/scripts/weather-tests.js +276 -41
- package/dist/scripts/wish-channel-gate-tests.js +54 -0
- package/dist/scripts/write-gate-tests.js +522 -0
- package/dist/scripts/z3-lifecycle-fault-tests.js +188 -0
- package/dist/scripts/z3-lifecycle-tests.js +170 -0
- package/dist/scripts/z3-race-repeat-tests.js +39 -0
- package/dist/scripts/z3-race-tests.js +46 -0
- package/dist/src/artifact-gate.js +829 -0
- package/dist/src/backend/kernel.js +91 -0
- package/dist/src/backend/modules/booking-copilot.js +92 -0
- package/dist/src/backend/modules/booking-executor.js +124 -0
- package/dist/src/backend/modules/session-search.js +551 -0
- package/dist/src/benchmark-agent-conformance.js +578 -0
- package/dist/src/benchmark-environment-bridge.js +515 -0
- package/dist/src/benchmark-headless-child-diagnostics.js +166 -0
- package/dist/src/benchmark-tool-isolation.js +151 -0
- package/dist/src/bookable-facts.js +533 -0
- package/dist/src/booking-saga.js +97 -0
- package/dist/src/booking-surface/availability-policy.js +697 -0
- package/dist/src/booking-surface/booking-intent.js +43 -0
- package/dist/src/booking-surface/cancel-refund-commission.js +379 -0
- package/dist/src/booking-surface/canonical-schema.js +116 -0
- package/dist/src/booking-surface/contracts.js +75 -0
- package/dist/src/booking-surface/dsh-planner.js +1854 -0
- package/dist/src/booking-surface/dsh-plugin.js +378 -0
- package/dist/src/booking-surface/dsh-worker.js +98 -0
- package/dist/src/booking-surface/error-codes.js +231 -0
- package/dist/src/booking-surface/index.js +9 -0
- package/dist/src/booking-surface/managed-dsh-cleanup-diagnostic.js +97 -0
- package/dist/src/booking-surface/managed-dsh-run-port.js +244 -0
- package/dist/src/booking-surface/profile.js +24 -0
- package/dist/src/booking-surface/recovery-chain.js +367 -0
- package/dist/src/booking-surface/runtime.js +1968 -0
- package/dist/src/booking-surface/server.js +674 -0
- package/dist/src/booking-surface/startup.js +168 -0
- package/dist/src/booking-surface/validation.js +261 -0
- package/dist/src/bridge.js +20 -13
- package/dist/src/city-scenario-tier.js +378 -0
- package/dist/src/companions.js +48 -74
- package/dist/src/contracts.js +21 -43
- package/dist/src/dsh-llm.js +171 -115
- package/dist/src/engine.js +276 -193
- package/dist/src/evaluation-cadence.js +97 -0
- package/dist/src/evaluation-contracts.js +283 -0
- package/dist/src/flight-pack-adapter.js +54 -0
- package/dist/src/flight-pack-contract.js +2 -0
- package/dist/src/flyai-setup-tool.js +70 -0
- package/dist/src/gotry-backend.js +125 -0
- package/dist/src/hotel-date-gate.js +118 -0
- package/dist/src/i18n.js +88 -0
- package/dist/src/index.js +2385 -881
- package/dist/src/itinerary-deck.js +139 -0
- package/dist/src/itinerary-doc-shared.js +661 -0
- package/dist/src/itinerary-html.js +131 -0
- package/dist/src/journey.js +155 -108
- package/dist/src/lavish-tools.js +400 -0
- package/dist/src/ledger-repair-apply.js +538 -0
- package/dist/src/ledger-repair-plan.js +463 -0
- package/dist/src/loop.js +331 -161
- package/dist/src/m3-cohort.js +1577 -0
- package/dist/src/memory-capture.js +180 -32
- package/dist/src/memory-decay.js +30 -14
- package/dist/src/memory-lifecycle.js +1310 -0
- package/dist/src/memory-utility.js +27 -40
- package/dist/src/mock-llm.js +36 -61
- package/dist/src/model.js +81 -36
- package/dist/src/outcome-projection.js +746 -0
- package/dist/src/realtime-pricing.js +103 -0
- package/dist/src/recall/card.js +74 -0
- package/dist/src/recall/evaluator.js +140 -0
- package/dist/src/recall/integration.js +17 -0
- package/dist/src/recall/tick.js +166 -0
- package/dist/src/session-link/plan-it-action.js +48 -0
- package/dist/src/session-link/session-link.js +248 -0
- package/dist/src/session-zone-ledger.js +312 -0
- package/dist/src/session-zone-observation.js +354 -0
- package/dist/src/session-zone-wiring.js +658 -0
- package/dist/src/session-zones.js +510 -0
- package/dist/src/share/adapters.js +51 -0
- package/dist/src/share/share-consent.js +76 -0
- package/dist/src/share/share-deck.js +58 -0
- package/dist/src/share/share-token.js +146 -0
- package/dist/src/slot-spec.js +84 -106
- package/dist/src/state-ledger.js +696 -524
- package/dist/src/state-repair.js +299 -0
- package/dist/src/subagent-job-id-guard.js +58 -0
- package/dist/src/time-anchor.js +231 -33
- package/dist/src/tool-packet.js +20 -8
- package/dist/src/travel-slots.js +58 -35
- package/dist/src/travel-timeline.js +44 -63
- package/dist/src/turn-deadline.js +287 -0
- package/dist/src/turn-policy.js +118 -0
- package/dist/src/tz-resolver.js +143 -0
- package/dist/src/unified.js +600 -360
- package/dist/src/wish-pool.js +42 -12
- package/dist/src/write-gate.js +495 -0
- package/dist/src/z3-shared.js +284 -0
- package/extension/README.md +21 -0
- package/extension/README.zh-CN.md +21 -0
- package/extension/background.js +463 -0
- package/extension/content-bridge.js +270 -0
- package/extension/content-main.js +111 -0
- package/extension/manifest.json +60 -0
- package/package.json +407 -14
- package/schemas/booking.intent.schema.json +193 -0
- package/schemas/booking.surface.schema.json +2622 -0
- package/ts/capabilities/agent-reach-deep.ts +114 -0
- package/ts/capabilities/agent-reach.ts +198 -0
- package/ts/capabilities/anything.ts +282 -0
- package/ts/capabilities/artifacts.ts +428 -0
- package/ts/capabilities/fact-log.ts +54 -0
- package/ts/capabilities/flyai-config.ts +388 -0
- package/ts/capabilities/flyai.ts +1274 -0
- package/ts/capabilities/hbcli.ts +253 -71
- package/ts/capabilities/incident-log.ts +88 -59
- package/ts/capabilities/itinerary-artifact.ts +214 -0
- package/ts/capabilities/itinerary-deck-artifact.ts +218 -0
- package/ts/capabilities/itinerary-deck-export.ts +378 -0
- package/ts/capabilities/opensky.ts +121 -0
- package/ts/capabilities/session/action-cache.ts +148 -0
- package/ts/capabilities/session/adapters/ctrip-flight.ts +181 -0
- package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
- package/ts/capabilities/session/adapters/dida-portal.ts +338 -0
- package/ts/capabilities/session/adapters/meituan-local.ts +70 -0
- package/ts/capabilities/session/adapters/rail-12306.ts +392 -0
- package/ts/capabilities/session/benchmark.ts +276 -0
- package/ts/capabilities/session/bridge-ledger.ts +333 -0
- package/ts/capabilities/session/extension-bridge.ts +794 -0
- package/ts/capabilities/session/extension-channel.ts +144 -0
- package/ts/capabilities/session/extension-distribution.ts +266 -0
- package/ts/capabilities/session/extract.ts +54 -0
- package/ts/capabilities/session/golden-score.ts +139 -0
- package/ts/capabilities/session/health-watch.ts +264 -0
- package/ts/capabilities/session/read-guard.ts +141 -0
- package/ts/capabilities/session/static-flight-golden.ts +209 -0
- package/ts/capabilities/session/transport.ts +146 -0
- package/ts/capabilities/session/wizard.ts +140 -0
- package/ts/capabilities/session-consent.ts +211 -0
- package/ts/capabilities/session-login.ts +208 -0
- package/ts/capabilities/session-search.ts +893 -0
- package/ts/capabilities/weather.ts +330 -0
- package/ts/dsh-runtime/vendor/README.md +68 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/LICENSE +21 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +405 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +404 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/client/client.js +1584 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/cordis.patch.yml +5 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/icon.svg +5 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/amap.js +595 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/nominatim.js +63 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/osrm.js +58 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/clients/photon.js +55 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-file.js +87 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config-route.js +131 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/config.js +16 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/geo.js +232 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/index.js +120 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +40 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/staticmap-route.js +115 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/geocode.js +127 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/poi.js +84 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/tools/routes.js +267 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/amap.d.ts +90 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/nominatim.d.ts +17 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/osrm.d.ts +15 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/clients/photon.d.ts +22 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-file.d.ts +33 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config-route.d.ts +23 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/config.d.ts +23 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/geo.d.ts +61 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/index.d.ts +18 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +14 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/staticmap-route.d.ts +31 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/geocode.d.ts +11 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/poi.d.ts +9 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/tools/routes.d.ts +75 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/types.d.ts +68 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types.js +16 -0
- package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +97 -0
- package/ts/package.json +293 -3
- package/ts/scripts/async-collect.ts +91 -0
- package/ts/scripts/map-tools-vendor-package-proof.ts +404 -0
- package/ts/scripts/skeleton-check.ts +1 -1
- package/ts/scripts/skeleton-integration-test.ts +23 -1
- package/ts/scripts/state-cli.ts +586 -0
- package/ts/scripts/turn-handoff-collect.ts +178 -0
- package/ts/src/artifact-gate.ts +991 -0
- package/ts/src/benchmark-agent-conformance.ts +619 -0
- package/ts/src/benchmark-environment-bridge.ts +538 -0
- package/ts/src/benchmark-headless-child-diagnostics.ts +184 -0
- package/ts/src/benchmark-tool-isolation.ts +166 -0
- package/ts/src/bookable-facts.ts +837 -0
- package/ts/src/booking-saga.ts +133 -0
- package/ts/src/booking-surface/availability-policy.ts +522 -0
- package/ts/src/booking-surface/booking-intent.ts +86 -0
- package/ts/src/booking-surface/cancel-refund-commission.ts +488 -0
- package/ts/src/booking-surface/canonical-schema.js +116 -0
- package/ts/src/booking-surface/contracts.ts +475 -0
- package/ts/src/booking-surface/dsh-planner.ts +1949 -0
- package/ts/src/booking-surface/dsh-plugin.js +378 -0
- package/ts/src/booking-surface/dsh-worker.ts +110 -0
- package/ts/src/booking-surface/error-codes.ts +241 -0
- package/ts/src/booking-surface/index.ts +9 -0
- package/ts/src/booking-surface/managed-dsh-cleanup-diagnostic.ts +151 -0
- package/ts/src/booking-surface/managed-dsh-run-port.ts +273 -0
- package/ts/src/booking-surface/profile.ts +42 -0
- package/ts/src/booking-surface/recovery-chain.ts +409 -0
- package/ts/src/booking-surface/runtime.ts +1793 -0
- package/ts/src/booking-surface/server.ts +733 -0
- package/ts/src/booking-surface/startup.ts +201 -0
- package/ts/src/booking-surface/validation.ts +217 -0
- package/ts/src/companions.ts +113 -0
- package/ts/src/contracts.ts +17 -3
- package/ts/src/dsh-llm.ts +152 -17
- package/ts/src/flyai-setup-tool.ts +77 -0
- package/ts/src/index.ts +2123 -200
- package/ts/src/itinerary-deck.ts +189 -0
- package/ts/src/itinerary-doc-shared.ts +741 -0
- package/ts/src/itinerary-html.ts +171 -0
- package/ts/src/loop.ts +234 -26
- package/ts/src/memory-capture.ts +283 -0
- package/ts/src/memory-decay.ts +55 -0
- package/ts/src/memory-utility.ts +70 -0
- package/ts/src/mock-llm.ts +4 -8
- package/ts/src/model.ts +88 -2
- package/ts/src/recall/card.ts +106 -0
- package/ts/src/recall/evaluator.ts +170 -0
- package/ts/src/recall/integration.ts +35 -0
- package/ts/src/recall/tick.ts +213 -0
- package/ts/src/session-link/plan-it-action.ts +78 -0
- package/ts/src/session-link/session-link.ts +275 -0
- package/ts/src/share/adapters.ts +93 -0
- package/ts/src/share/share-consent.ts +105 -0
- package/ts/src/share/share-deck.ts +85 -0
- package/ts/src/share/share-token.ts +166 -0
- package/ts/src/slot-spec.ts +172 -0
- package/ts/src/state-ledger.ts +1166 -0
- package/ts/src/subagent-job-id-guard.ts +72 -0
- package/ts/src/time-anchor.ts +338 -0
- package/ts/src/tool-packet.ts +32 -0
- package/ts/src/travel-slots.ts +219 -0
- package/ts/src/travel-timeline.ts +83 -0
- package/ts/src/turn-deadline.ts +366 -0
- package/ts/src/turn-policy.ts +154 -0
- package/ts/src/unified.ts +444 -105
- package/ts/src/wish-pool.ts +79 -0
- package/dist/scripts/session-attach-wait.js +0 -41
package/ts/src/index.ts
CHANGED
|
@@ -7,41 +7,107 @@
|
|
|
7
7
|
* - gotry_wish_pool_add 「下一次出发」清单(憧憬不被拒绝)
|
|
8
8
|
*
|
|
9
9
|
* 插件形态遵循 dsh 约定(name/inject/Config/apply + ctx.tools.register(defineTool(...))),
|
|
10
|
-
*
|
|
10
|
+
* 对齐 @deepseek-ai/dsh-tools@0.2.0-rc.2 的契约:
|
|
11
11
|
* render 位于 output 对象内,参数属性是 ValueSchemaSpec(支持 type:'json')。
|
|
12
12
|
*
|
|
13
13
|
* @module @gotry/plugin
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { join } from 'node:path'
|
|
16
|
+
import { isAbsolute, join } from 'node:path'
|
|
17
17
|
|
|
18
18
|
import type { Context } from '@deepseek-ai/cordis'
|
|
19
19
|
import z from '@deepseek-ai/schemastery'
|
|
20
|
-
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
20
|
+
import { defineTool, type ToolResult, type ToolRunContext } from '@deepseek-ai/dsh-tools'
|
|
21
21
|
import { ensureStateDir, recordLatency } from './bridge.ts'
|
|
22
22
|
import { segmentsFromCandidate, solveChoiceSegment } from './unified.ts'
|
|
23
23
|
import { checkConnectivity } from '../scripts/skeleton-check.ts'
|
|
24
24
|
import { parseCandidate, parseRequest } from './model.ts'
|
|
25
|
-
import { searchHotels as hbcliSearchHotels } from '../capabilities/hbcli.ts'
|
|
26
25
|
import { installProcessGuards, guardToolExecute } from '../capabilities/incident-log.ts'
|
|
27
26
|
import { interpretArgs, type GotryObservation } from './tool-packet.ts'
|
|
28
27
|
import { projectUtility } from './memory-utility.ts'
|
|
29
28
|
import { pickNudgeWish, type WishPoolEntry } from './wish-pool.ts'
|
|
30
29
|
import { resolveTimelineDate } from './travel-timeline.ts'
|
|
31
|
-
import { ensureLedger, readCompanionsWithFallback, readMotivationWithFallback, readTripsWithFallback, readWishPoolWithFallback } from './state-ledger.ts'
|
|
32
|
-
import {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
30
|
+
import { ensureLedger, openLedgerIfExists, readCompanionsWithFallback, readMotivationWithFallback, readTripsWithFallback, readWishPoolWithFallback } from './state-ledger.ts'
|
|
31
|
+
import {
|
|
32
|
+
applyZoneNote,
|
|
33
|
+
bindZoneSession,
|
|
34
|
+
boundZoneSession,
|
|
35
|
+
isZoneObservableTool,
|
|
36
|
+
observeToolResult,
|
|
37
|
+
promoteWithRouting,
|
|
38
|
+
renderSessionZoneBrief,
|
|
39
|
+
resolveZoneSwitch,
|
|
40
|
+
zoneScopeOfExec,
|
|
41
|
+
zoneSessionRefFromExec,
|
|
42
|
+
} from './session-zone-wiring.ts'
|
|
43
|
+
import { isValidHomeCityPreference, resolveDefaultOrigin, type MergedProfile, type ProfilePatch } from './memory-capture.ts'
|
|
44
|
+
import { applyPlanningWindow } from './loop.ts'
|
|
45
|
+
import { buildTimeAnchor, type PlanningWindow } from './time-anchor.ts'
|
|
46
|
+
import { evaluateHotelStayDates } from './hotel-date-gate.ts'
|
|
47
|
+
import { wmoLabel } from '../capabilities/weather.ts'
|
|
48
|
+
// D-23 收编(issue #115):anything/web/github/video/agent_reach/session_login 六渠道改经
|
|
49
|
+
// 效应注册表(退避/熔断/declined 面统一);能力层直接 import 仅留类型位(reach/reachStatus)
|
|
50
|
+
import { reach, reachStatus } from '../capabilities/agent-reach.ts'
|
|
51
|
+
import { runDoctorChecks, renderDoctorReportMd, runDoctorRepair, createRepairApprovalGate, type DoctorRepairOptions } from '../capabilities/doctor.ts'
|
|
52
|
+
import { EXTENSION_STORE_URL } from '../capabilities/session/extension-bridge.ts'
|
|
53
|
+
import { createConsentGate, approvalFromContext, resolveSessionSearchKind } from '../capabilities/session-consent.ts'
|
|
54
|
+
import { installModelOverride } from '../capabilities/model-override.ts'
|
|
55
|
+
import { listArtifacts, readArtifact } from '../capabilities/artifacts.ts'
|
|
56
|
+
import { generateItineraryArtifact } from '../capabilities/itinerary-artifact.ts'
|
|
57
|
+
import { generateItineraryDeckArtifact } from '../capabilities/itinerary-deck-artifact.ts'
|
|
58
|
+
import { generateItineraryDeckExport } from '../capabilities/itinerary-deck-export.ts'
|
|
59
|
+
import { interpretEffect, declinedObservation, type EffectInterpreter } from '../capabilities/effect.ts'
|
|
60
|
+
import { customerSendCode, customerLogin } from '../capabilities/hbcli.ts'
|
|
61
|
+
import { FLYAI_POI_CATEGORIES, type FlyaiKind, type FlyaiQuery, type FlyaiResult } from '../capabilities/flyai.ts'
|
|
62
|
+
import { createFlyaiSetupTool } from './flyai-setup-tool.ts'
|
|
63
|
+
import { appendFacts, loadFactRegistry } from '../capabilities/fact-log.ts'
|
|
64
|
+
import { factsFromFlyai, factsFromHotel, factsFromSession, factsFromSessionTrain } from './bookable-facts.ts'
|
|
65
|
+
import { hasRecognizedAvailableSeat } from '../capabilities/session/adapters/rail-12306.ts'
|
|
66
|
+
import { gateArtifact, type AirlineAirportMap } from './artifact-gate.ts'
|
|
67
|
+
import { installTurnDeadline, listTurnHandoffTickets } from './turn-deadline.ts'
|
|
68
|
+
import { noteChannelVerdict, recordChannelEvent, readLatestChannelEvents } from '../capabilities/channel-health.ts'
|
|
69
|
+
import { routingAdvice, renderRoutingCard, toolRoutingHeadline, persistedDownChannels, type ChannelIntent } from '../capabilities/channel-registry.ts'
|
|
70
|
+
import { registerBenchmarkEnvironmentBridge, type BenchmarkSubprocessService } from './benchmark-environment-bridge.ts'
|
|
71
|
+
import { installBenchmarkToolIsolation } from './benchmark-tool-isolation.ts'
|
|
72
|
+
import { installBenchmarkAgentConformance } from './benchmark-agent-conformance.ts'
|
|
73
|
+
import { installSubagentJobIdGuard } from './subagent-job-id-guard.ts'
|
|
74
|
+
import { registerLavishTools } from './lavish-tools.ts'
|
|
75
|
+
import {
|
|
76
|
+
createGroundTransferResolver,
|
|
77
|
+
createPublicMapDrivingRouteProvider,
|
|
78
|
+
exposeGroundTransferEvidence,
|
|
79
|
+
resolveGroundTransferPayload,
|
|
80
|
+
type GroundTransferProvider,
|
|
81
|
+
type GroundTransferResolution,
|
|
82
|
+
} from '../capabilities/ground-transfer.ts'
|
|
83
|
+
|
|
84
|
+
/** 航司→机场映射表(issue #46 冲突检测面;data/airline-airports.json,as_of 快照) */
|
|
85
|
+
let airlineAirportMapCache: AirlineAirportMap | null = null
|
|
86
|
+
async function loadAirlineAirportMap(): Promise<AirlineAirportMap | null> {
|
|
87
|
+
if (airlineAirportMapCache) return airlineAirportMapCache
|
|
88
|
+
try {
|
|
89
|
+
const { readFile } = await import('node:fs/promises')
|
|
90
|
+
airlineAirportMapCache = JSON.parse(
|
|
91
|
+
await readFile(join(import.meta.dirname, '..', '..', 'data', 'airline-airports.json'), 'utf-8'),
|
|
92
|
+
) as AirlineAirportMap
|
|
93
|
+
return airlineAirportMapCache
|
|
94
|
+
} catch {
|
|
95
|
+
return null // 映射缺失不阻塞检索主路径;闸工具侧另行显式报错
|
|
96
|
+
}
|
|
97
|
+
}
|
|
41
98
|
|
|
42
99
|
export const name = 'gotry-tools'
|
|
100
|
+
// The benchmark CLI projection adds a required subprocess loader dependency.
|
|
101
|
+
// Keep the plugin's ordinary mode independent of that opt-in-only service.
|
|
43
102
|
export const inject = ['tools', 'systemPrompt']
|
|
44
103
|
|
|
104
|
+
function sessionCwd(exec: unknown): string | undefined {
|
|
105
|
+
const candidate = exec as { agent?: { session?: { header?: { cwd?: unknown } } } } | null
|
|
106
|
+
return typeof candidate?.agent?.session?.header?.cwd === 'string'
|
|
107
|
+
? candidate.agent.session.header.cwd
|
|
108
|
+
: undefined
|
|
109
|
+
}
|
|
110
|
+
|
|
45
111
|
export interface Config {
|
|
46
112
|
/** 状态根目录(动机画像、wish pool、延迟日志) */
|
|
47
113
|
stateRoot: string
|
|
@@ -49,24 +115,55 @@ export interface Config {
|
|
|
49
115
|
timeoutMs: number
|
|
50
116
|
/** hbcli 二进制路径(hotelbyte-cli;空=禁用实时酒店,回退数据包) */
|
|
51
117
|
hbcliBin: string
|
|
118
|
+
/** 账号会话检索总闸(RFC 支柱④「用户明示授权+随时可关」):ask=gotry_session_search 每会话每站点首次调用弹审批卡、会话内记住(默认);allow=用户已在配置明示预授权(直接放行);off=总闸关闭,直接拒绝 */
|
|
119
|
+
sessionAccess: string
|
|
120
|
+
/** 会话双区记忆总闸(issue #255 P4-3):off=默认(缺省/未知值同样 fail-closed 关),接线完全惰性(零账本事件/读回空串/工具不注册);on=创始人本地启用 */
|
|
121
|
+
sessionZones?: string
|
|
122
|
+
/** Owner-local benchmark environment bridge config; empty disables the bridge. */
|
|
123
|
+
benchmarkEnvironmentConfigPath?: string
|
|
124
|
+
/** Trusted absolute path of the installed `lavish-axi@0.1.67` package; empty disables the local Lavish Editor product tools. */
|
|
125
|
+
lavishAxiPackageRoot?: string
|
|
52
126
|
}
|
|
53
127
|
|
|
54
128
|
export const Config: z<Config> = z.object({
|
|
55
129
|
stateRoot: z.string().default('.'),
|
|
56
130
|
timeoutMs: z.number().default(30_000),
|
|
57
131
|
hbcliBin: z.string().default('hbcli'),
|
|
132
|
+
sessionAccess: z.string().default('ask'),
|
|
133
|
+
sessionZones: z.string().default('off'),
|
|
134
|
+
benchmarkEnvironmentConfigPath: z.string().default(''),
|
|
135
|
+
lavishAxiPackageRoot: z.string().default(''),
|
|
58
136
|
})
|
|
59
137
|
|
|
60
138
|
interface FeasibilityResult {
|
|
61
139
|
answer_md?: string
|
|
62
140
|
recommended?: string | null
|
|
63
141
|
verdicts?: Array<Record<string, unknown>>
|
|
142
|
+
ground_transfer?: GroundTransferResolution
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface FeasibilityValidationFailure extends Record<string, Json> {
|
|
146
|
+
ok: false
|
|
147
|
+
code: 'invalid_planning_context' | 'planning_window_rejected'
|
|
148
|
+
summary: string
|
|
149
|
+
rejected: Array<{ segmentId: string; optionId: string; date: string }>
|
|
64
150
|
}
|
|
65
151
|
|
|
66
152
|
interface MotivationProfileInput {
|
|
67
153
|
weights?: Record<string, number>
|
|
68
154
|
evidence?: string[]
|
|
69
155
|
hard?: Record<string, unknown>
|
|
156
|
+
/**
|
|
157
|
+
* 持久化用户常驻城市/默认出发地(issue #338)。
|
|
158
|
+
* - string = 设置默认城市(用户原话指明常驻)
|
|
159
|
+
* - null = 显式清除(审计一行留痕,用于遗忘/迁居)
|
|
160
|
+
* - undefined = 字段未传,保持当前值不动
|
|
161
|
+
* 改值须伴新 evidence(同 weights/hard 的 P0 守卫);evidence 恰好一条且非空时
|
|
162
|
+
* 可省略 homeCityEvidence,多条 evidence 必须显式绑定。
|
|
163
|
+
*/
|
|
164
|
+
homeCity?: string | null
|
|
165
|
+
/** homeCity 对应的本次用户原话;仅与 homeCity 一起传入。 */
|
|
166
|
+
homeCityEvidence?: string
|
|
70
167
|
}
|
|
71
168
|
|
|
72
169
|
interface WishPoolEntryInput {
|
|
@@ -95,6 +192,22 @@ function renderMotivationBrief(stateRoot: string): string {
|
|
|
95
192
|
if (weights.length) lines.push(`- 动机权重: ${weights.map(([k, v]) => `${k}=${v}`).join(', ')}(证据 ${p.evidence?.length ?? 0} 条)`)
|
|
96
193
|
const hard = Object.entries(p.hard ?? {})
|
|
97
194
|
if (hard.length) lines.push(`- 硬约束: ${hard.map(([k, v]) => `${k}=${String(v)}`).join(', ')}`)
|
|
195
|
+
// 持久默认城市(issue #338):生产 brief 复用纯 resolver 的 precedence
|
|
196
|
+
// 契约;这里仍只负责读回渲染,不把 resolver 接入 feasibility/evaluate。
|
|
197
|
+
const profile = p as MergedProfile
|
|
198
|
+
const homeResolution = resolveDefaultOrigin(undefined, profile)
|
|
199
|
+
const homePref = profile.homeCityPreference
|
|
200
|
+
const typedPreferenceIsValid = isValidHomeCityPreference(profile)
|
|
201
|
+
const hasHomeCityData = Object.prototype.hasOwnProperty.call(profile, 'homeCity')
|
|
202
|
+
|| Object.prototype.hasOwnProperty.call(profile, 'homeCityPreference')
|
|
203
|
+
if (homeResolution.source === 'home_default' && homeResolution.origin && typedPreferenceIsValid && homePref) {
|
|
204
|
+
const evidence = homeResolution.homeCityEvidence?.[0] ?? ''
|
|
205
|
+
lines.push(`- 常驻城市: ${homeResolution.origin}(原话证据:${evidence};记录于 ${homePref.updated_at})——本轮未指明出发地时作为软默认;本轮显式出发地优先;不进可行性硬约束,不删候选`)
|
|
206
|
+
} else if (typedPreferenceIsValid && homePref?.value === null) {
|
|
207
|
+
lines.push(`- 常驻城市: 已显式清除(原话证据:${homePref.evidence};记录于 ${homePref.updated_at})——已无持久软默认;每次行程必须由用户明确出发地,不猜测`)
|
|
208
|
+
} else if (hasHomeCityData) {
|
|
209
|
+
lines.push('- 常驻城市: 无可验证的持久默认——本轮请明确出发地;不从旧画像或全局 evidence 猜测')
|
|
210
|
+
}
|
|
98
211
|
// 旅行时间线(memory-design P1):去过的地方不再主动推荐,除非用户点名
|
|
99
212
|
const trips = readTimelineTrips(stateRoot)
|
|
100
213
|
if (trips.length) {
|
|
@@ -107,10 +220,11 @@ function renderMotivationBrief(stateRoot: string): string {
|
|
|
107
220
|
}
|
|
108
221
|
lines.push(`- 愿望池: 用 gotry_wish_pool_list 按条件召回(0..1),勿直接堆砌`)
|
|
109
222
|
if (p.updated_at) lines.push(`- 更新于: ${p.updated_at}`)
|
|
110
|
-
return weights.length || hard.length ? lines.join('\n') : ''
|
|
223
|
+
return weights.length || hard.length || hasHomeCityData ? lines.join('\n') : ''
|
|
111
224
|
}
|
|
112
225
|
|
|
113
|
-
/**
|
|
226
|
+
/**
|
|
227
|
+
* 时间线摘要(brief 用):最近 3 次行程(目的地+年月);账本优先,未迁移回退文件 */
|
|
114
228
|
/** 同行人摘要(brief 用):label + 约束串 */
|
|
115
229
|
function readCompanions(stateRoot: string): Array<{ label: string; brief: string }> {
|
|
116
230
|
return readCompanionsWithFallback(stateRoot)
|
|
@@ -127,14 +241,318 @@ function readTimelineTrips(stateRoot: string): Array<{ destination: string; star
|
|
|
127
241
|
.slice(0, 3)
|
|
128
242
|
}
|
|
129
243
|
|
|
244
|
+
const FLYAI_TOOL_KINDS = ['flight', 'train', 'hotel', 'poi', 'keyword', 'ai', 'marriott-hotel', 'marriott-package'] as const
|
|
245
|
+
|
|
246
|
+
function isFlyaiKind(value: unknown): value is FlyaiKind {
|
|
247
|
+
return typeof value === 'string' && (FLYAI_TOOL_KINDS as readonly string[]).includes(value)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
type FlyaiToolArgs = Record<string, unknown>
|
|
251
|
+
|
|
252
|
+
function textArg(args: FlyaiToolArgs, ...names: string[]): string | undefined {
|
|
253
|
+
for (const name of names) {
|
|
254
|
+
const value = args[name]
|
|
255
|
+
if (value !== undefined) return typeof value === 'string' ? value : undefined
|
|
256
|
+
}
|
|
257
|
+
return undefined
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function numberArg(args: FlyaiToolArgs, ...names: string[]): number | undefined {
|
|
261
|
+
for (const name of names) {
|
|
262
|
+
const value = args[name]
|
|
263
|
+
if (value !== undefined) return typeof value === 'number' ? value : undefined
|
|
264
|
+
}
|
|
265
|
+
return undefined
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function putText(target: FlyaiQuery, key: keyof FlyaiQuery, args: FlyaiToolArgs, ...names: string[]): void {
|
|
269
|
+
const value = textArg(args, ...names)
|
|
270
|
+
if (value !== undefined) (target as unknown as Record<string, unknown>)[key] = value
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function putNumber(target: FlyaiQuery, key: keyof FlyaiQuery, args: FlyaiToolArgs, ...names: string[]): void {
|
|
274
|
+
const value = numberArg(args, ...names)
|
|
275
|
+
if (value !== undefined) (target as unknown as Record<string, unknown>)[key] = value
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function flyaiToolQuery(args: FlyaiToolArgs, signal?: AbortSignal): { query?: FlyaiQuery; error?: string } {
|
|
279
|
+
if (!isFlyaiKind(args.kind)) {
|
|
280
|
+
return { error: `kind 必须是 ${FLYAI_TOOL_KINDS.join('/')},未知值不会按 flight 处理` }
|
|
281
|
+
}
|
|
282
|
+
const kind = args.kind
|
|
283
|
+
const query: FlyaiQuery = { kind }
|
|
284
|
+
putText(query, 'origin', args, 'from', 'origin')
|
|
285
|
+
putText(query, 'destination', args, 'to', 'destination')
|
|
286
|
+
putText(query, 'depDate', args, 'date', 'depDate')
|
|
287
|
+
putText(query, 'depDateStart', args, 'dateStart', 'depDateStart')
|
|
288
|
+
putText(query, 'depDateEnd', args, 'dateEnd', 'depDateEnd')
|
|
289
|
+
putText(query, 'backDate', args, 'backDate')
|
|
290
|
+
putNumber(query, 'journeyType', args, 'journeyType')
|
|
291
|
+
putText(query, 'seatClassName', args, 'seatClassName')
|
|
292
|
+
putText(query, 'transportNo', args, 'transportNo')
|
|
293
|
+
putText(query, 'transferCity', args, 'transferCity')
|
|
294
|
+
putNumber(query, 'depHourStart', args, 'depHourStart')
|
|
295
|
+
putNumber(query, 'depHourEnd', args, 'depHourEnd')
|
|
296
|
+
putNumber(query, 'arrHourStart', args, 'arrHourStart')
|
|
297
|
+
putNumber(query, 'arrHourEnd', args, 'arrHourEnd')
|
|
298
|
+
putNumber(query, 'totalDurationHour', args, 'totalDurationHour')
|
|
299
|
+
putNumber(query, 'maxPrice', args, 'maxPrice')
|
|
300
|
+
putText(query, 'sortType', args, 'sortType')
|
|
301
|
+
|
|
302
|
+
putText(query, 'destName', args, 'destName', 'to')
|
|
303
|
+
putText(query, 'checkInDate', args, 'checkIn', 'checkInDate')
|
|
304
|
+
putText(query, 'checkOutDate', args, 'checkOut', 'checkOutDate')
|
|
305
|
+
putText(query, 'keyWords', args, 'keyWords')
|
|
306
|
+
putText(query, 'poiName', args, 'poiName')
|
|
307
|
+
putText(query, 'hotelTypes', args, 'hotelTypes')
|
|
308
|
+
putText(query, 'sort', args, 'sort')
|
|
309
|
+
putText(query, 'hotelStars', args, 'hotelStars')
|
|
310
|
+
putText(query, 'hotelBedTypes', args, 'hotelBedTypes')
|
|
311
|
+
putText(query, 'cityName', args, 'cityName', 'to')
|
|
312
|
+
putNumber(query, 'poiLevel', args, 'poiLevel')
|
|
313
|
+
putText(query, 'keyword', args, 'keyword')
|
|
314
|
+
putText(query, 'category', args, 'category')
|
|
315
|
+
putText(query, 'query', args, 'query')
|
|
316
|
+
putText(query, 'hotelBrands', args, 'hotelBrands')
|
|
317
|
+
putText(query, 'hotelName', args, 'hotelName')
|
|
318
|
+
putText(query, 'provinceOrCity', args, 'provinceOrCity')
|
|
319
|
+
putNumber(query, 'timeoutMs', args, 'timeoutMs')
|
|
320
|
+
if (signal) query.signal = signal
|
|
321
|
+
|
|
322
|
+
const isCalendarDate = (value: string): boolean => {
|
|
323
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
|
324
|
+
const date = new Date(`${value}T00:00:00.000Z`)
|
|
325
|
+
const [year, month, day] = value.split('-').map(Number)
|
|
326
|
+
return Number.isFinite(date.valueOf())
|
|
327
|
+
&& date.getUTCFullYear() === year
|
|
328
|
+
&& date.getUTCMonth() + 1 === month
|
|
329
|
+
&& date.getUTCDate() === day
|
|
330
|
+
}
|
|
331
|
+
const dateError = (label: string, value: string): string | undefined =>
|
|
332
|
+
isCalendarDate(value) ? undefined : `${label} 必须是有效的 YYYY-MM-DD 日历日期`
|
|
333
|
+
|
|
334
|
+
if (kind === 'flight' || kind === 'train') {
|
|
335
|
+
// Official 1.0.16 requires only --origin; omitting destination/date is a
|
|
336
|
+
// supported exploration query and must reach the upstream command.
|
|
337
|
+
if (!query.origin?.trim()) return { error: `kind=${kind} 需要 from/origin(中文城市名或机场)` }
|
|
338
|
+
const hasExact = Boolean(query.depDate)
|
|
339
|
+
const hasStart = Boolean(query.depDateStart)
|
|
340
|
+
const hasEnd = Boolean(query.depDateEnd)
|
|
341
|
+
if (hasStart !== hasEnd) return { error: 'dateStart/depDateStart 与 dateEnd/depDateEnd 必须成对' }
|
|
342
|
+
if (hasExact && (hasStart || hasEnd)) return { error: 'date 与日期范围不能同时传入,请选择 exact-date 或 range 查询' }
|
|
343
|
+
for (const [label, value] of [
|
|
344
|
+
['date', query.depDate],
|
|
345
|
+
['dateStart', query.depDateStart],
|
|
346
|
+
['dateEnd', query.depDateEnd],
|
|
347
|
+
['backDate', query.backDate],
|
|
348
|
+
] as const) {
|
|
349
|
+
if (value) {
|
|
350
|
+
const error = dateError(label, value)
|
|
351
|
+
if (error) return { error }
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (query.depDateStart && query.depDateEnd && query.depDateStart > query.depDateEnd) {
|
|
355
|
+
return { error: 'dateStart 不能晚于 dateEnd' }
|
|
356
|
+
}
|
|
357
|
+
if (query.depDate && query.backDate && query.backDate < query.depDate) {
|
|
358
|
+
return { error: 'backDate 不能早于 date' }
|
|
359
|
+
}
|
|
360
|
+
if (query.sortType && !/^[1-8]$/.test(query.sortType.trim())) {
|
|
361
|
+
return { error: 'flight/train 的 sortType 只接受 1–8' }
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (kind === 'hotel' || kind === 'marriott-hotel') {
|
|
365
|
+
if (!query.destName?.trim()) return { error: `kind=${kind} 需要 to/destName(目的地)` }
|
|
366
|
+
const hasIn = Boolean(query.checkInDate)
|
|
367
|
+
const hasOut = Boolean(query.checkOutDate)
|
|
368
|
+
if (hasIn !== hasOut) return { error: 'checkIn/checkOut 必须成对' }
|
|
369
|
+
for (const [label, value] of [['checkIn', query.checkInDate], ['checkOut', query.checkOutDate]] as const) {
|
|
370
|
+
if (value) {
|
|
371
|
+
const error = dateError(label, value)
|
|
372
|
+
if (error) return { error }
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (query.checkInDate && query.checkOutDate && query.checkOutDate <= query.checkInDate) {
|
|
376
|
+
return { error: 'checkOut 必须晚于 checkIn' }
|
|
377
|
+
}
|
|
378
|
+
if (kind === 'marriott-hotel') {
|
|
379
|
+
// The official Marriott command has one keyword field covering hotel
|
|
380
|
+
// name/brand; it does not accept generic hotel-types/hotel-stars or
|
|
381
|
+
// the non-existent hotel-brands/hotel-name flags.
|
|
382
|
+
if (query.hotelTypes?.trim() || query.hotelStars?.trim()) {
|
|
383
|
+
return { error: 'marriott-hotel 不支持 hotelTypes/hotelStars;请使用 hotelBrands/hotelName' }
|
|
384
|
+
}
|
|
385
|
+
const marriottKeywords = [query.keyWords, query.hotelBrands, query.hotelName]
|
|
386
|
+
.map(value => value?.trim())
|
|
387
|
+
.filter((value): value is string => Boolean(value))
|
|
388
|
+
query.keyWords = marriottKeywords.length ? marriottKeywords.join(' ') : undefined
|
|
389
|
+
delete query.hotelBrands
|
|
390
|
+
delete query.hotelName
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (kind === 'poi' && !query.cityName?.trim()) return { error: 'kind=poi 需要 cityName(景点所在城市)' }
|
|
394
|
+
if ((kind === 'keyword' || kind === 'ai') && !query.query?.trim()) return { error: `kind=${kind} 需要 query` }
|
|
395
|
+
if (kind === 'marriott-package' && ![query.keyword, query.hotelName, query.provinceOrCity].some(value => value?.trim())) return { error: 'kind=marriott-package 需要 keyword/hotelName/provinceOrCity 至少一项' }
|
|
396
|
+
return { query }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function flyaiToolSummary(query: FlyaiQuery, result: FlyaiResult): string {
|
|
400
|
+
const label = query.kind === 'flight' ? '机票' : query.kind === 'train' ? '火车票' : query.kind === 'hotel' || query.kind === 'marriott-hotel' ? '酒店' : query.kind
|
|
401
|
+
if (result.verdict === 'miss') return `${label}查询完成,官方通道返回 0 条。${result.evidence}`
|
|
402
|
+
if (result.verdict !== 'hit') return `${label}查询未完成:${result.error ?? result.verdict}。${result.setup ? `\n${result.setup}` : ''}\n${result.evidence}`
|
|
403
|
+
const price = (raw: string | undefined, numeric: number | undefined): string => raw?.trim() || (numeric !== undefined && numeric > 0 ? `¥${numeric}` : '价格待询')
|
|
404
|
+
const lines: string[] = []
|
|
405
|
+
if (query.kind === 'flight' || query.kind === 'train') {
|
|
406
|
+
for (const option of (result.options ?? []).slice(0, 8)) {
|
|
407
|
+
const route = option.nonstop === false ? '中转' : option.nonstop === true ? '直达' : '路线待确认'
|
|
408
|
+
const services = option.segments?.map(segment => segment.no).join('→') || option.no
|
|
409
|
+
lines.push(`${route} ${services} ${option.name} ${option.depDateTime.slice(0, 16)}→${option.arrDateTime.slice(0, 16)} ${price(option.priceRaw, option.price)}${option.jumpUrl ? ` · ${option.jumpUrl}` : ''}`)
|
|
410
|
+
}
|
|
411
|
+
} else if (query.kind === 'hotel' || query.kind === 'marriott-hotel') {
|
|
412
|
+
for (const option of (result.hotels ?? []).slice(0, 8)) {
|
|
413
|
+
lines.push(`${option.name} ${price(option.priceRaw, option.price)}${option.score ? ` · ${option.score}` : ''}${option.jumpUrl ? ` · ${option.jumpUrl}` : ''}`)
|
|
414
|
+
}
|
|
415
|
+
} else if (query.kind === 'poi') {
|
|
416
|
+
for (const option of (result.pois ?? []).slice(0, 8)) lines.push(`${option.name}${option.address ? ` · ${option.address}` : ''}${option.jumpUrl ? ` · ${option.jumpUrl}` : ''}`)
|
|
417
|
+
} else if (query.kind === 'keyword') {
|
|
418
|
+
for (const option of (result.keywords ?? []).slice(0, 8)) lines.push(`${option.title}${option.price ? ` · ${option.price}` : ''}${option.jumpUrl ? ` · ${option.jumpUrl}` : ''}`)
|
|
419
|
+
} else if (query.kind === 'marriott-package') {
|
|
420
|
+
for (const option of (result.packages ?? []).slice(0, 8)) lines.push(`${option.name}${option.price ? ` · ${option.price}` : ''}${option.detailUrl ? ` · ${option.detailUrl}` : ''}`)
|
|
421
|
+
} else if (query.kind === 'ai') {
|
|
422
|
+
lines.push(`上游 ai-search data 已原样保留(${JSON.stringify(result.aiData).slice(0, 500)})`)
|
|
423
|
+
}
|
|
424
|
+
return `${label}官方只读查询命中 ${lines.length} 条:\n${lines.join('\n')}\n${result.evidence}`
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function flyaiRoutingIntent(kind: FlyaiKind): ChannelIntent | undefined {
|
|
428
|
+
if (kind === 'flight') return 'search-flight'
|
|
429
|
+
if (kind === 'train') return 'search-train'
|
|
430
|
+
if (kind === 'hotel' || kind === 'marriott-hotel') return 'search-hotel'
|
|
431
|
+
return undefined
|
|
432
|
+
}
|
|
433
|
+
|
|
130
434
|
type Json = string | number | boolean | null | Json[] | { [k: string]: Json }
|
|
131
435
|
type JsonObject = { [k: string]: Json }
|
|
132
436
|
|
|
133
|
-
|
|
437
|
+
/** Benchmark 治理面钉死预算(Round 9,#100/#102):官方任务形态(如 ChinaTravel
|
|
438
|
+
* 单查询 300s)超出内置 60/120s;预算仍必须「按 run 钉死」保证可复现——允许
|
|
439
|
+
* owner 经 env 按次显式设定,缺省维持既有值,非法值启动即抛。 */
|
|
440
|
+
function benchmarkPinnedPolicy(): { softMs: number; hardMs: number; exit: 'converge' } {
|
|
441
|
+
const parse = (name: string, fallback: number): number => {
|
|
442
|
+
const raw = process.env[name]
|
|
443
|
+
if (raw === undefined || raw === '') return fallback
|
|
444
|
+
const value = Number.parseInt(raw, 10)
|
|
445
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
446
|
+
throw new Error(`invalid ${name}: ${JSON.stringify(raw)} (expected a positive safe integer)`)
|
|
447
|
+
}
|
|
448
|
+
return value
|
|
449
|
+
}
|
|
450
|
+
const softMs = parse('GOTRY_BENCHMARK_SOFT_MS', 60_000)
|
|
451
|
+
const hardMs = parse('GOTRY_BENCHMARK_HARD_MS', 120_000)
|
|
452
|
+
if (hardMs <= softMs) throw new Error(`GOTRY_BENCHMARK_HARD_MS (${hardMs}) must exceed GOTRY_BENCHMARK_SOFT_MS (${softMs})`)
|
|
453
|
+
return { softMs, hardMs, exit: 'converge' }
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** 把 hotel-date-gate.message 里的模型转手措辞(请告诉用户 / 请告诉 gotry /
|
|
457
|
+
* 请向用户确认)归一为直接对用户措辞(请补充 / 请提供)。仅做这一类短语的字面
|
|
458
|
+
* 替换,日期示例等可行动细节保留不动;只服务 hotel_search input_required 卡
|
|
459
|
+
* 单点,不动 date-gate 契约、不动其它 presentResult 路径。 */
|
|
460
|
+
function normalizeHotelGateHandoff(message: unknown): string {
|
|
461
|
+
const raw = String(message ?? '')
|
|
462
|
+
if (!raw) return ''
|
|
463
|
+
return raw
|
|
464
|
+
.replace(/请告诉\s*(?:用户|gotry)\s*/g, '请补充 ')
|
|
465
|
+
.replace(/请向用户确认/g, '请提供')
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export interface ApplyTestSeams {
|
|
469
|
+
doctor?: {
|
|
470
|
+
check?: typeof runDoctorChecks
|
|
471
|
+
repair?: DoctorRepairOptions
|
|
472
|
+
}
|
|
473
|
+
/** Isolated test-only effect interpreter; production keeps interpretEffect. */
|
|
474
|
+
effect?: typeof interpretEffect
|
|
475
|
+
/** Test-only clock injection; production derives the reference date from Date. */
|
|
476
|
+
clock?: () => Date
|
|
477
|
+
/** Isolated public map-tool result seam; production uses nested dsh dispatch. */
|
|
478
|
+
groundTransfer?: { provider?: GroundTransferProvider }
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function parseFeasibilityPlanning(raw: unknown, anchor: ReturnType<typeof buildTimeAnchor>): PlanningWindow | null {
|
|
482
|
+
if (raw === undefined) return null
|
|
483
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('planning must be an object')
|
|
484
|
+
const planning = raw as Record<string, unknown>
|
|
485
|
+
const intent = planning['intent']
|
|
486
|
+
const requestedYear = planning['requested_year']
|
|
487
|
+
if (intent !== 'future' && intent !== 'historical') {
|
|
488
|
+
throw new Error('planning.intent must be future or historical')
|
|
489
|
+
}
|
|
490
|
+
if (!Number.isInteger(requestedYear) || Number(requestedYear) < 1_000 || Number(requestedYear) > 9_999) {
|
|
491
|
+
throw new Error('planning.requested_year must be a four-digit integer')
|
|
492
|
+
}
|
|
493
|
+
// referenceDate is always the host clock; the payload cannot supply a stale now.
|
|
494
|
+
return { intent, requestedYear: Number(requestedYear), referenceDate: anchor.today }
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function feasibilityValidationFailure(
|
|
498
|
+
code: FeasibilityValidationFailure['code'],
|
|
499
|
+
summary: string,
|
|
500
|
+
rejected?: FeasibilityValidationFailure['rejected'],
|
|
501
|
+
): FeasibilityValidationFailure {
|
|
502
|
+
return { ok: false, code, summary, rejected: rejected ?? [] }
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}): void {
|
|
506
|
+
const clock = seams.clock ?? (() => new Date())
|
|
507
|
+
const runFlyaiEffect = (seams.effect ?? interpretEffect) as unknown as (fx: {
|
|
508
|
+
effect: 'FLYAI_SEARCH'
|
|
509
|
+
params: FlyaiQuery
|
|
510
|
+
signal?: AbortSignal
|
|
511
|
+
}) => Promise<{ result: FlyaiResult | null; trace: Parameters<typeof declinedObservation>[1] }>
|
|
512
|
+
const groundTransferResolver = createGroundTransferResolver({
|
|
513
|
+
provider: seams.groundTransfer?.provider ?? createPublicMapDrivingRouteProvider(ctx),
|
|
514
|
+
clock,
|
|
515
|
+
})
|
|
516
|
+
const rawBenchmarkEnvironmentConfigPath = config.benchmarkEnvironmentConfigPath ?? ''
|
|
517
|
+
// ADR-24 v2:产品路径装「路由 + wall-clock 双出口」——用户主观时间是唯一
|
|
518
|
+
// 预算,复杂度决定出口结构(converge/handoff)。benchmark opt-in 钉死
|
|
519
|
+
// 固定 policy 保证评测可复现,不走路由。GOTRY_HANDOFF_CHILD=1 是收集器
|
|
520
|
+
// 派生的后台规划会话:唯一出口 converge + 长 leash——前台转后台,后台
|
|
521
|
+
// 必须产出最终交付物,不得再次 handoff(否则无限递归)。
|
|
522
|
+
const handoffChild = process.env.GOTRY_HANDOFF_CHILD === '1'
|
|
523
|
+
installTurnDeadline(ctx, handoffChild
|
|
524
|
+
? { fixedPolicy: { softMs: 300_000, hardMs: 900_000, exit: 'converge' } }
|
|
525
|
+
: rawBenchmarkEnvironmentConfigPath.trim()
|
|
526
|
+
? { fixedPolicy: benchmarkPinnedPolicy() }
|
|
527
|
+
: { stateRoot: process.env.GOTRY_TURN_HANDOFF_ROOT ?? config.stateRoot ?? '.' })
|
|
528
|
+
if (rawBenchmarkEnvironmentConfigPath.trim()) {
|
|
529
|
+
// Benchmark mode is a deliberately minimal kernel: only the model
|
|
530
|
+
// override and the environment bridge are installed besides the pinned
|
|
531
|
+
// turn-deadline policy above. In particular, product prompt variables,
|
|
532
|
+
// process/consent guards, and guarded product tools must not be
|
|
533
|
+
// observable on this path.
|
|
534
|
+
installModelOverride(ctx as unknown as Parameters<typeof installModelOverride>[0])
|
|
535
|
+
const getService = (ctx as unknown as { get?: (name: string, fallback?: unknown) => unknown }).get
|
|
536
|
+
const directSubprocess = (typeof getService === 'function'
|
|
537
|
+
? getService.call(ctx, 'subprocess')
|
|
538
|
+
: undefined) as BenchmarkSubprocessService | undefined
|
|
539
|
+
const projection = registerBenchmarkEnvironmentBridge(
|
|
540
|
+
rawBenchmarkEnvironmentConfigPath,
|
|
541
|
+
tool => ctx.tools.register(tool),
|
|
542
|
+
directSubprocess,
|
|
543
|
+
)
|
|
544
|
+
installBenchmarkToolIsolation(ctx)
|
|
545
|
+
installBenchmarkAgentConformance(ctx, projection)
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
|
|
134
549
|
// 时间感知:注册动态变量,persona 里用 {{current_date}} 引用。
|
|
135
550
|
// 每次 assemble 时取系统时钟——LLM 始终知道「今天是几号」。
|
|
551
|
+
// provider 收到本次组装的 AssembleContext(dsh-system-prompt 契约:
|
|
552
|
+
// `variable(name, provider: (context: AssembleContext) => string | undefined)`);
|
|
553
|
+
// `scope` 是不透明、按身份比较的 ScopeKey —— 分区读回据此分辨会话。
|
|
136
554
|
const sp = (ctx as unknown as Record<string, unknown>)['systemPrompt'] as {
|
|
137
|
-
variable?: (name: string, provider: () => string) => void
|
|
555
|
+
variable?: (name: string, provider: (context?: { scope?: object }) => string) => void
|
|
138
556
|
} | undefined
|
|
139
557
|
sp?.variable?.('current_date', () => {
|
|
140
558
|
const d = new Date()
|
|
@@ -151,12 +569,106 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
151
569
|
// 渲染成紧凑 brief 注入 persona;为空 = 首访。与当轮说法冲突时以用户为准。
|
|
152
570
|
sp?.variable?.('motivation_brief', () => renderMotivationBrief(config.stateRoot ?? '.'))
|
|
153
571
|
|
|
572
|
+
// 会话双区记忆读回(issue #255 P4-3,design/session-dual-zone-memory-design.md §5):
|
|
573
|
+
// 变量恒注册(persona 侧可随时引用),但总闸 off(默认)时恒返回空串 —— 接线惰性,
|
|
574
|
+
// 不建库、不读库、零事件;persona 模板本身不在本 PR 改动,因此开关 off 时注入面
|
|
575
|
+
// 输出逐字节不变。空串 = 首访/无活笔记(与 motivation_brief 同纪律)。
|
|
576
|
+
// 会话双区读回(issue #255 P4-3):**只在总闸 on 时注册**。
|
|
577
|
+
// - 为什么是条件注册:默认关必须与 main **不可区分**——注入面多出一个变量名
|
|
578
|
+
// 就已经是可观测差异(§48 benchmark-environment-bridge-tests 的
|
|
579
|
+
// 「normal product mode keeps its prompt variables」逐项断言会红)。
|
|
580
|
+
// 出厂 persona(cordis.gotry-patch.yml)今天不引用本变量,所以条件注册安全。
|
|
581
|
+
// - **后续约束**:创始人一旦把 `{{session_zone_brief}}` 写进出厂 persona,
|
|
582
|
+
// 本变量必须改为**无条件注册**(关闸时返回空串)——strict 插值下未注册的
|
|
583
|
+
// 引用会让整段 persona 渲染失败。那一步同时要改 §48 的变量清单断言、
|
|
584
|
+
// persona-surface-guard-tests 的注入面清单与 benchmark-environment-bridge-e2e
|
|
585
|
+
// 的 CANONICAL_RAW_VARIABLES。
|
|
586
|
+
// - 读回按**组装 scope** 取会话身份(dsh agent-loop 以 agent 对象为 ScopeKey,
|
|
587
|
+
// 与工具侧 exec.agent 同一身份):同一宿主进程里的两个会话因此读不到对方的
|
|
588
|
+
// resource 层笔记(设计 §1.1);取不到 scope → 不出 resource 段(fail-closed)。
|
|
589
|
+
const zoneSwitch = resolveZoneSwitch(config.sessionZones)
|
|
590
|
+
if (zoneSwitch === 'on') {
|
|
591
|
+
sp?.variable?.('session_zone_brief', assembleContext => renderSessionZoneBrief({
|
|
592
|
+
ledger: openLedgerIfExists(config.stateRoot ?? '.'),
|
|
593
|
+
now: new Date().toISOString(),
|
|
594
|
+
sessionRef: boundZoneSession(assembleContext?.scope),
|
|
595
|
+
zoneSwitch,
|
|
596
|
+
}))
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// 通道路由卡(通道注册表生成,docs/design/tool-orchestration-design.md §2.1/D-8):
|
|
600
|
+
// persona 检索条款只留行为契约,机/火/酒通道顺位与额度口径查卡——prose 教义
|
|
601
|
+
// 变查表教义,注册表加通道卡片自动一致。
|
|
602
|
+
sp?.variable?.('channel_routing_card', () => renderRoutingCard())
|
|
603
|
+
|
|
604
|
+
// 通道健康面接线(issue #107/#108):检索通道 verdict → 会话状态标记 +
|
|
605
|
+
// down/cooldown 事件落持久面(doctor CLI 进程消费)。落盘失败不阻塞检索。
|
|
606
|
+
const noteChannel = async (channel: string, verdict: string): Promise<void> => {
|
|
607
|
+
const ev = noteChannelVerdict(channel, verdict)
|
|
608
|
+
if (ev) await recordChannelEvent(config.stateRoot ?? '.', ev)
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* verdict≠hit 时在平铺 envelope 上追加 routing 建议(§3.3:失败现场教学)。
|
|
612
|
+
* 健康态两源并集(issue #436):进程内会话态 + 本 stateRoot 持久健康面
|
|
613
|
+
* (channel-probe / 外部本地生产者 append 的 channel-health.jsonl)。持久面在
|
|
614
|
+
* **工具结果边界**读入并投影为 down 集合(IO 只在此层,注册表保持纯函数);
|
|
615
|
+
* 读不到/坏行/未来时间戳=不排除——坏输入不能凭空压制通道,与会话态失联同纪律。
|
|
616
|
+
* 持久面只会加排除项:它写的 'ok' 恢复事件不能解除本会话刚发生的失败。
|
|
617
|
+
*/
|
|
618
|
+
const routingField = async (intent: ChannelIntent, excludeChannel: string) => {
|
|
619
|
+
let persistedDown: ReadonlySet<string> | undefined
|
|
620
|
+
try {
|
|
621
|
+
// 同一个捕获时钟贯穿读者与投影:坏时间戳行在 latest-wins 覆盖前就被读者丢弃,
|
|
622
|
+
// 否则一条未来/缺时间戳的新行会顶掉更早的有效 down,把通道错误判回可用。
|
|
623
|
+
const now = Date.now()
|
|
624
|
+
persistedDown = persistedDownChannels(
|
|
625
|
+
await readLatestChannelEvents(config.stateRoot ?? '.', { now, requireValidTimestamp: true }),
|
|
626
|
+
now,
|
|
627
|
+
)
|
|
628
|
+
} catch { /* 健康面缺席=不启用持久排除,建议表退回会话态口径 */ }
|
|
629
|
+
return { routing: routingAdvice(intent, { excludeChannel, ...(persistedDown ? { persistedDownChannels: persistedDown } : {}) }) }
|
|
630
|
+
}
|
|
631
|
+
|
|
154
632
|
// D-NEW 进程护栏(Z3 WASM crash 教训):dsh 0.1.1-rc.1 缺 uncaughtException
|
|
155
633
|
// handler,插件异常穿透即杀进程。我们在 gotry 侧挂护栏:同步 fsync 写事故证据
|
|
156
634
|
// (gotry-state/incidents.jsonl),handler 自身不再抛,不阻塞后续控制流。
|
|
157
635
|
// 不调 process.exit——让 dsh/上级容器决定生死,我们只留现场。
|
|
158
636
|
installProcessGuards(config.stateRoot ?? '.', { uncaughtException: 'gotry-tools', unhandledRejection: 'gotry-tools' })
|
|
159
637
|
|
|
638
|
+
// 账号会话授权闸(RFC 支柱④「用户明示授权 + 站点白名单 + 随时可关」进代码;v2 每会话一次):
|
|
639
|
+
// 会话面工具动用用户本人登录态,**每会话每站点首次调用**弹 dsh 原生审批卡——
|
|
640
|
+
// allowed-once 记入会话 granted 集,后续同站调用免弹;用户拒绝 = 本会话吊销
|
|
641
|
+
// (denied 集,不再弹卡也不再执行——拒绝是裁决,不反复骚扰);无审批通道
|
|
642
|
+
// (headless 无用户在场)一律 fail-closed 拒绝。sessionAccess: ask(默认)|allow|off。
|
|
643
|
+
// v1 教训(2026-08-29 founder 实测「每次都要弹,经常无法点击」):逐调用弹卡 = 骚扰,
|
|
644
|
+
// 会话态收归 capabilities/session-consent.ts。防御:极简宿主/mock ctx 无事件总线时跳过。
|
|
645
|
+
// issue #194 A-轨道 guard 先注册;生产 Cordis 的 prepend 保证它在授权闸
|
|
646
|
+
// 前运行,smoke 的轻量事件采集器也继续把授权闸作为最后一个观察项。
|
|
647
|
+
installSubagentJobIdGuard(ctx)
|
|
648
|
+
const ctxOn = (ctx as unknown as { on?: unknown }).on
|
|
649
|
+
if (typeof ctxOn === 'function') {
|
|
650
|
+
const gate = createConsentGate({
|
|
651
|
+
access: () => config.sessionAccess ?? 'ask',
|
|
652
|
+
approval: approvalFromContext(ctx),
|
|
653
|
+
})
|
|
654
|
+
// site 绑定(#308):dsh pre-execute 实际派发的 arguments 原样进入 gate;
|
|
655
|
+
// gate 与 execute 共用 resolveSessionSearchKind/interpretArgs,因此 wrapped
|
|
656
|
+
// query.*、缺省 flight、unknown/malformed 的选择与授权和效应路径保持一致。
|
|
657
|
+
ctx.on('tools/pre-execute', gate)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// issue #284 修复闸:gotry_doctor.action='repair' 走同一审批缝(scope-keyed,
|
|
661
|
+
// WeakMap<agent>)——同 agent + 同 scope 复用批准,不同 scope 重问,rejected/cancelled
|
|
662
|
+
// = 本会话吊销,unavailable 通道不记。同口径 session-consent,不增新持久面。
|
|
663
|
+
// 注册早于 gotry_doctor(下方),保证工具面闭包捕获当前会话的闸实例。
|
|
664
|
+
const repairApprovalGate = createRepairApprovalGate({
|
|
665
|
+
approval: approvalFromContext(ctx),
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
// LLM_MODEL → dsh 会话面模型覆盖(issue #77;机制与分层见
|
|
669
|
+
// capabilities/model-override.ts 头注)。GOTRY_LLM_MODEL 未设时零行为变化。
|
|
670
|
+
installModelOverride(ctx as unknown as Parameters<typeof installModelOverride>[0])
|
|
671
|
+
|
|
160
672
|
// D-NEW 收尾:全部工具 execute 统一异常隔离——单个工具抛错/拒绝不再沿 cordis
|
|
161
673
|
// 传到 dsh 主循环,降级为结构化错误返回给 LLM + incident 落盘(incident-log.ts)。
|
|
162
674
|
const registerGuarded = (tool: ReturnType<typeof defineTool>): void => {
|
|
@@ -164,9 +676,48 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
164
676
|
if (typeof t.execute === 'function') {
|
|
165
677
|
t.execute = guardToolExecute(String(t.name), config.stateRoot ?? '.', t.execute as (args: never, exec: unknown) => never)
|
|
166
678
|
}
|
|
679
|
+
// 工具观察捕获缝(issue #255 P4-3):总闸 on 且工具在闭集白名单内才包裹——
|
|
680
|
+
// off(默认)时 execute 一层不多包,行为逐字节不变。捕获只投影形状字段
|
|
681
|
+
// (verdict/条数/价格带),证据只走指针;捕获失败永不影响工具返回值。
|
|
682
|
+
if (zoneSwitch === 'on' && typeof t.execute === 'function' && isZoneObservableTool(t.name)) {
|
|
683
|
+
const inner = t.execute as (args: never, exec: unknown) => Promise<unknown>
|
|
684
|
+
const toolName = String(t.name)
|
|
685
|
+
t.execute = async (args: never, exec: unknown): Promise<unknown> => {
|
|
686
|
+
const result = await inner(args, exec)
|
|
687
|
+
try {
|
|
688
|
+
const sessionRef = zoneSessionRefFromExec(exec)
|
|
689
|
+
if (sessionRef) {
|
|
690
|
+
bindZoneSession(zoneScopeOfExec(exec), sessionRef)
|
|
691
|
+
observeToolResult(openLedgerIfExists(config.stateRoot ?? '.') ?? ensureLedger(config.stateRoot ?? '.'), {
|
|
692
|
+
tool: toolName,
|
|
693
|
+
result,
|
|
694
|
+
session_ref: sessionRef,
|
|
695
|
+
ts: new Date().toISOString(),
|
|
696
|
+
})
|
|
697
|
+
}
|
|
698
|
+
} catch { /* 记忆面永不把产品主路径带红 */ }
|
|
699
|
+
return result
|
|
700
|
+
}
|
|
701
|
+
}
|
|
167
702
|
ctx.tools.register(t as unknown as ReturnType<typeof defineTool>)
|
|
168
703
|
}
|
|
169
704
|
|
|
705
|
+
// Issue #443: register the five local editor tools only from trusted host
|
|
706
|
+
// configuration. Tool arguments cannot select process or filesystem authority.
|
|
707
|
+
registerLavishTools({
|
|
708
|
+
ctx,
|
|
709
|
+
register: registerGuarded,
|
|
710
|
+
getConfig: () => ({ lavishAxiPackageRoot: config.lavishAxiPackageRoot ?? '' }),
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
// 工具描述首行生成(issue #113,L1):检索工具描述统一前置注册表生成的
|
|
714
|
+
// 「服务意图 × 通道顺位」卡——模型选工具时(读描述)与失败后(读 routing)
|
|
715
|
+
// 两个决策点拿到同一张表;注册表加行,描述自动一致。非检索工具原样返回。
|
|
716
|
+
const routed = (tool: string, desc: string): string => {
|
|
717
|
+
const head = toolRoutingHeadline(tool)
|
|
718
|
+
return head ? `${head}\n${desc}` : desc
|
|
719
|
+
}
|
|
720
|
+
|
|
170
721
|
registerGuarded(defineTool({
|
|
171
722
|
name: 'gotry_feasibility_check',
|
|
172
723
|
description:
|
|
@@ -177,11 +728,100 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
177
728
|
+ 'structure { request: { motivation weights, hard constraints, window, budget, home hubs }, candidates: [ { id, label, services, transfers, stay, minDays } ] }. '
|
|
178
729
|
+ 'Returns per-candidate verdicts, unsat cores with minimal-modification suggestions, '
|
|
179
730
|
+ 'a wish-pool entry for infeasible aspirations, and a ready-to-show markdown answer.',
|
|
731
|
+
// D-30 第五刀(issue #112):payload json blob → 结构化对象(request/candidates 模型可见,
|
|
732
|
+
// 嵌套形状权威在 unified 引擎契约,工具面不越层强约束)
|
|
180
733
|
parameters: {
|
|
181
734
|
payload: {
|
|
182
|
-
type: '
|
|
735
|
+
type: 'object',
|
|
736
|
+
additionalProperties: false,
|
|
183
737
|
required: true,
|
|
184
|
-
|
|
738
|
+
properties: {
|
|
739
|
+
request: { type: 'object', additionalProperties: true, required: true, description: '引擎请求:{ motivation weights, hard constraints, window, budget, home hubs }' },
|
|
740
|
+
candidates: { type: 'array', items: { type: 'object', additionalProperties: true }, required: true, description: '候选列表:[{ id, label, date?, services, transfers, stay, minDays }]' },
|
|
741
|
+
planning: {
|
|
742
|
+
type: 'object', additionalProperties: false,
|
|
743
|
+
description: '可选显式规划上下文;dated 候选省略时默认按宿主时钟作 future 下界过滤,指定 future 年份再加年末上界,historical 仅用于明确历史/回测查询;完全 dateless 输入保持旧可行性计算',
|
|
744
|
+
properties: {
|
|
745
|
+
intent: { type: 'string', enum: ['future', 'historical'], required: true },
|
|
746
|
+
requested_year: { type: 'integer', required: true, description: '用户明确请求的四位年份,不传宿主当前年份' },
|
|
747
|
+
},
|
|
748
|
+
},
|
|
749
|
+
ground_transfer: {
|
|
750
|
+
type: 'object',
|
|
751
|
+
additionalProperties: false,
|
|
752
|
+
description: '可选显式地面接驳覆盖:仅 destination 位置、显式经纬度和 mode=driving;路线同时绑定抵达方向(A→B)与返程方向(B→A),两方向分别请求/缓存/降级,价格仍来自静态包;缓存 TTL 由能力层固定',
|
|
753
|
+
properties: {
|
|
754
|
+
candidate_id: { type: 'string', description: '要绑定的候选 id;不会按相似度推断' },
|
|
755
|
+
transfer_index: { type: 'integer', description: 'dest_transfers 的零基位置;不会按 mode 推断' },
|
|
756
|
+
position: { type: 'string', description: '当前切片固定为 destination' },
|
|
757
|
+
mode: { type: 'string', description: '当前只接受 driving;其它/缺省模式显式回退静态分钟' },
|
|
758
|
+
origin: {
|
|
759
|
+
type: 'object',
|
|
760
|
+
additionalProperties: false,
|
|
761
|
+
description: '旧版单对坐标:等同于 outbound.origin(能力层自动把返程方向设为其互换)',
|
|
762
|
+
properties: {
|
|
763
|
+
longitude: { type: 'number' },
|
|
764
|
+
latitude: { type: 'number' },
|
|
765
|
+
},
|
|
766
|
+
},
|
|
767
|
+
destination: {
|
|
768
|
+
type: 'object',
|
|
769
|
+
additionalProperties: false,
|
|
770
|
+
description: '旧版单对坐标:等同于 outbound.destination',
|
|
771
|
+
properties: {
|
|
772
|
+
longitude: { type: 'number' },
|
|
773
|
+
latitude: { type: 'number' },
|
|
774
|
+
},
|
|
775
|
+
},
|
|
776
|
+
outbound: {
|
|
777
|
+
type: 'object',
|
|
778
|
+
additionalProperties: false,
|
|
779
|
+
description: '显式抵达方向坐标对(A→B):不指定时回退 origin/destination',
|
|
780
|
+
properties: {
|
|
781
|
+
origin: {
|
|
782
|
+
type: 'object',
|
|
783
|
+
additionalProperties: false,
|
|
784
|
+
properties: {
|
|
785
|
+
longitude: { type: 'number' },
|
|
786
|
+
latitude: { type: 'number' },
|
|
787
|
+
},
|
|
788
|
+
},
|
|
789
|
+
destination: {
|
|
790
|
+
type: 'object',
|
|
791
|
+
additionalProperties: false,
|
|
792
|
+
properties: {
|
|
793
|
+
longitude: { type: 'number' },
|
|
794
|
+
latitude: { type: 'number' },
|
|
795
|
+
},
|
|
796
|
+
},
|
|
797
|
+
},
|
|
798
|
+
},
|
|
799
|
+
return: {
|
|
800
|
+
type: 'object',
|
|
801
|
+
additionalProperties: false,
|
|
802
|
+
description: '显式返程方向坐标对(B→A):不指定时回退 outbound 互换',
|
|
803
|
+
properties: {
|
|
804
|
+
origin: {
|
|
805
|
+
type: 'object',
|
|
806
|
+
additionalProperties: false,
|
|
807
|
+
properties: {
|
|
808
|
+
longitude: { type: 'number' },
|
|
809
|
+
latitude: { type: 'number' },
|
|
810
|
+
},
|
|
811
|
+
},
|
|
812
|
+
destination: {
|
|
813
|
+
type: 'object',
|
|
814
|
+
additionalProperties: false,
|
|
815
|
+
properties: {
|
|
816
|
+
longitude: { type: 'number' },
|
|
817
|
+
latitude: { type: 'number' },
|
|
818
|
+
},
|
|
819
|
+
},
|
|
820
|
+
},
|
|
821
|
+
},
|
|
822
|
+
},
|
|
823
|
+
},
|
|
824
|
+
},
|
|
185
825
|
},
|
|
186
826
|
},
|
|
187
827
|
output: {
|
|
@@ -196,10 +836,38 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
196
836
|
// 路径——候选形态枚举求解,~6ms/次)。unified 内部有 try-catch 护栏覆盖 wasm 异常。
|
|
197
837
|
const started = Date.now()
|
|
198
838
|
const payload = args.payload as Record<string, unknown>
|
|
839
|
+
const anchor = buildTimeAnchor(clock())
|
|
840
|
+
let planning: PlanningWindow | null
|
|
841
|
+
try {
|
|
842
|
+
planning = parseFeasibilityPlanning(payload['planning'], anchor)
|
|
843
|
+
} catch (error) {
|
|
844
|
+
const summary = error instanceof Error ? error.message : 'planning context is invalid'
|
|
845
|
+
return feasibilityValidationFailure('invalid_planning_context', summary)
|
|
846
|
+
}
|
|
199
847
|
const req = parseRequest(payload['request'] as Record<string, unknown>)
|
|
200
848
|
const cands = (payload['candidates'] as Record<string, unknown>[]).map(parseCandidate)
|
|
201
|
-
const
|
|
202
|
-
|
|
849
|
+
const execution = _exec && typeof _exec === 'object' ? _exec as ToolRunContext : undefined
|
|
850
|
+
let solverCandidates = cands
|
|
851
|
+
let groundTransferEvidence: GroundTransferResolution | undefined
|
|
852
|
+
if (payload['ground_transfer'] !== undefined) {
|
|
853
|
+
const prepared = await resolveGroundTransferPayload(
|
|
854
|
+
payload['ground_transfer'],
|
|
855
|
+
cands,
|
|
856
|
+
groundTransferResolver,
|
|
857
|
+
{ signal: execution?.signal, execution },
|
|
858
|
+
)
|
|
859
|
+
solverCandidates = prepared.candidates
|
|
860
|
+
groundTransferEvidence = prepared.resolution
|
|
861
|
+
}
|
|
862
|
+
const spec = segmentsFromCandidate(req, solverCandidates)
|
|
863
|
+
const planningCheck = applyPlanningWindow(spec, planning, anchor, { defaultDatedFuture: true })
|
|
864
|
+
if (planningCheck.error) {
|
|
865
|
+
return feasibilityValidationFailure('planning_window_rejected', planningCheck.error, planningCheck.rejected)
|
|
866
|
+
}
|
|
867
|
+
const solved = solveChoiceSegment(planningCheck.spec, req) as Record<string, unknown>
|
|
868
|
+
const result = groundTransferEvidence
|
|
869
|
+
? exposeGroundTransferEvidence(solved, groundTransferEvidence)
|
|
870
|
+
: solved
|
|
203
871
|
const dir = await ensureStateDir(config.stateRoot)
|
|
204
872
|
await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, 'feasibility_check:in-process-unified').catch(() => {})
|
|
205
873
|
return { ok: true, ...result, latency_ms: Date.now() - started, via: 'in-process-unified' }
|
|
@@ -234,11 +902,32 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
234
902
|
+ 'never the principal/sponsor distinction. MERGE semantics (T1): call again with just the NEW '
|
|
235
903
|
+ 'facts learned this turn (weights delta optional but MUST bring fresh evidence; evidence = user quotes); '
|
|
236
904
|
+ 'existing history is never deleted. Requires evidence on every call (P0 anti-fabrication rule).',
|
|
905
|
+
// D-30 第五刀(issue #112):profile blob → 结构化闭合对象;evidence(P0 反伪造红线)进嵌套
|
|
906
|
+
// schema 由宿主权闸——无证据的保存请求入口即被结构化拒绝(ToolFailure 形状)
|
|
907
|
+
// issue #338:homeCity 字段(string=设置,null=显式清除,undefined=不动);单条非空
|
|
908
|
+
// evidence 可省略 binding,多条 evidence 必须显式 homeCityEvidence。
|
|
909
|
+
// dsh 参数 schema 仅支持单值 type,string|null 用 oneOf 表达。
|
|
237
910
|
parameters: {
|
|
238
911
|
profile: {
|
|
239
|
-
type: '
|
|
912
|
+
type: 'object',
|
|
913
|
+
additionalProperties: false,
|
|
240
914
|
required: true,
|
|
241
|
-
|
|
915
|
+
properties: {
|
|
916
|
+
weights: { type: 'object', additionalProperties: true, description: '动机权重增量,如 { escape_rest: 0.7 }(可选,权重变更须伴新证据)' },
|
|
917
|
+
evidence: { type: 'array', items: { type: 'string' }, required: true, description: '本轮新事实的用户原话数组(P0:必带)' },
|
|
918
|
+
hard: { type: 'object', additionalProperties: true, description: '硬约束:{ wake_not_before, min_arrival_energy_pct }(可选)' },
|
|
919
|
+
homeCity: {
|
|
920
|
+
oneOf: [
|
|
921
|
+
{ type: 'string', description: '设置持久常驻城市/默认出发地(用户原话指明常驻),如 "上海"' },
|
|
922
|
+
{ type: 'null', description: '显式清除常驻城市(用于遗忘/迁居)' },
|
|
923
|
+
],
|
|
924
|
+
description: '持久常驻城市/默认出发地(string=设置,null=显式清除);改值须伴新 evidence(P0);恰一条非空可省略 binding,多条须显式;不删候选/不压当轮 origin',
|
|
925
|
+
},
|
|
926
|
+
homeCityEvidence: {
|
|
927
|
+
type: 'string',
|
|
928
|
+
description: 'homeCity 对应的 exact 用户原话;显式传入时必须逐字出现在本次 profile.evidence 中且为新证据(同值同绑定重复调用例外为幂等);evidence 恰好一条非空时可省略并绑定该条,多条时必须显式传入',
|
|
929
|
+
},
|
|
930
|
+
},
|
|
242
931
|
},
|
|
243
932
|
},
|
|
244
933
|
output: {
|
|
@@ -246,17 +935,48 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
246
935
|
schema: { type: 'json' },
|
|
247
936
|
render: (_args, value) => [{ type: 'text', text: `动机画像已保存:${String((value as { path?: string }).path ?? '')}` }],
|
|
248
937
|
},
|
|
249
|
-
async execute(args
|
|
938
|
+
async execute(args, _exec: unknown) {
|
|
250
939
|
// T1 接线:本工具是增量补丁语义(契约 18——模型每轮把对话新事实带进来),
|
|
251
940
|
// 经 mergeProfile 守门合并进既有画像(追加不删史/幂等/权重变更须伴新证据),
|
|
252
941
|
// 不再整档覆盖。首次调用(无档案)= 全量建立。
|
|
253
942
|
// ADR-15:守门+事件+投影在账本单事务内完成,evidence 红线拒绝即回滚,账本无痕。
|
|
254
|
-
|
|
943
|
+
// issue #338:homeCity 同走 mergeProfile 守门(null=显式清除)。单条非空
|
|
944
|
+
// evidence 可提供省略的 unambiguous binding;多条 evidence 不按数组位置推断。
|
|
945
|
+
const incoming = (args.profile ?? {}) as { weights?: Record<string, number>; evidence?: string[]; hard?: Record<string, unknown>; homeCity?: string | null; homeCityEvidence?: string }
|
|
255
946
|
if (!incoming.evidence?.length) {
|
|
256
947
|
throw new Error('refusing to save a motivation profile without evidence (P0 anti-fabrication rule)')
|
|
257
948
|
}
|
|
949
|
+
// issue #338:trim 后空白拒绝(零事件落账)。空白字符串「无值」≠
|
|
950
|
+
// 「显式清除」——后者是 null,前者是没有声明。不擅自解释,显式拒收。
|
|
951
|
+
if (typeof incoming.homeCity === 'string' && incoming.homeCity.trim().length === 0) {
|
|
952
|
+
throw new Error('gotry_motivation_save.homeCity 是空白字符串,既不是声明也不是清除;请明确传城市名或 null')
|
|
953
|
+
}
|
|
954
|
+
let boundHomeCityEvidence: string | undefined
|
|
955
|
+
if (incoming.homeCity !== undefined) {
|
|
956
|
+
if (incoming.homeCityEvidence !== undefined) {
|
|
957
|
+
if (typeof incoming.homeCityEvidence !== 'string' || incoming.homeCityEvidence.trim().length === 0) {
|
|
958
|
+
throw new Error('gotry_motivation_save.homeCityEvidence 为空白;homeCity 必须绑定本次 evidence 中的一条 exact 用户原话')
|
|
959
|
+
}
|
|
960
|
+
if (!incoming.evidence.some(e => e === incoming.homeCityEvidence)) {
|
|
961
|
+
throw new Error('gotry_motivation_save.homeCityEvidence 必须逐字匹配本次 profile.evidence 中的一条用户原话')
|
|
962
|
+
}
|
|
963
|
+
boundHomeCityEvidence = incoming.homeCityEvidence
|
|
964
|
+
} else if (incoming.evidence.length === 1 && typeof incoming.evidence[0] === 'string' && incoming.evidence[0].trim().length > 0) {
|
|
965
|
+
boundHomeCityEvidence = incoming.evidence[0]
|
|
966
|
+
} else {
|
|
967
|
+
throw new Error('gotry_motivation_save.homeCityEvidence 缺失或有歧义;evidence 恰好一条非空时可省略,多条 evidence 必须显式绑定 exact 用户原话')
|
|
968
|
+
}
|
|
969
|
+
} else if (incoming.homeCityEvidence !== undefined) {
|
|
970
|
+
throw new Error('gotry_motivation_save.homeCityEvidence 只能与 homeCity 一起传入')
|
|
971
|
+
}
|
|
258
972
|
const ledger = ensureLedger(config.stateRoot)
|
|
259
|
-
const res = ledger.appendMotivationPatch({
|
|
973
|
+
const res = ledger.appendMotivationPatch({
|
|
974
|
+
weights: incoming.weights,
|
|
975
|
+
evidence: incoming.evidence,
|
|
976
|
+
hard: incoming.hard,
|
|
977
|
+
homeCity: incoming.homeCity,
|
|
978
|
+
homeCityEvidence: boundHomeCityEvidence,
|
|
979
|
+
} as ProfilePatch)
|
|
260
980
|
const profileJson = JSON.parse(JSON.stringify(res.profile)) as JsonObject
|
|
261
981
|
return res.saved
|
|
262
982
|
? { ok: true, saved: true, path: ledger.dbPath, profile: profileJson, summary: '画像已合并入账本(单事务)' }
|
|
@@ -276,7 +996,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
276
996
|
entry: {
|
|
277
997
|
type: 'json',
|
|
278
998
|
required: true,
|
|
279
|
-
description: '{ name, reason?, conditions: { days, budget_cny, best_months }, muted?: boolean }',
|
|
999
|
+
description: '{ name, reason?, conditions: { days, budget_cny, best_months, channels? }, muted?: boolean }',
|
|
280
1000
|
},
|
|
281
1001
|
},
|
|
282
1002
|
output: {
|
|
@@ -304,20 +1024,25 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
304
1024
|
'Surface AT MOST ONE "next departure" wish whose fulfilment conditions match the user\'s current window '
|
|
305
1025
|
+ '(0..1 rule: never more than one nudge per turn, never push when nothing matches — 憧憬不被拒绝,也不被硬推). '
|
|
306
1026
|
+ 'Muted wishes never surface. Surfacing records a recalled event in the memory-utility sidecar. '
|
|
1027
|
+
+ 'A wish whose conditions.channels names a channel that is currently down (out-of-band health facts) is vetoed for this recall — 不推当下走不通的憧憬. '
|
|
307
1028
|
+ 'action="confirm-outcome" records the user-confirmed real-world outcome (attribution helpful/harmful/neutral) — '
|
|
308
1029
|
+ 'ONLY pass attribution the user explicitly stated; the agent must never self-attribute usefulness.',
|
|
1030
|
+
// D-30 第四刀(issue #112):query blob → 平铺 typed;全字段可选 → interpretArgs 容忍层
|
|
309
1031
|
parameters: {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
},
|
|
1032
|
+
action: { type: 'string', description: 'recall(默认,召回下一次出发建议)或 confirm-outcome(确认某条建议成/不成)' },
|
|
1033
|
+
days: { type: 'integer', description: '回看天数窗(recall)' },
|
|
1034
|
+
budgetCny: { type: 'number', description: '预算上限(recall 过滤)' },
|
|
1035
|
+
month: { type: 'integer', description: '出行月份 1-12(recall)' },
|
|
1036
|
+
wishId: { type: 'string', description: 'confirm-outcome 目标愿望 id' },
|
|
1037
|
+
attribution: { type: 'string', enum: ['helpful', 'harmful', 'neutral'], description: 'confirm-outcome 归因' },
|
|
1038
|
+
detail: { type: 'string', description: '一句话细节(用户原话)' },
|
|
1039
|
+
tripStart: { type: 'string', description: '确认成行时挂时间线的行程起始日' },
|
|
315
1040
|
},
|
|
316
1041
|
output: {
|
|
317
1042
|
schema: { type: 'json' },
|
|
318
1043
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }],
|
|
319
1044
|
},
|
|
320
|
-
async execute(args
|
|
1045
|
+
async execute(args, _exec: unknown) {
|
|
321
1046
|
const q = unwrapQuery<{ action?: string; days?: number; budgetCny?: number; month?: number; wishId?: string; attribution?: 'helpful' | 'harmful' | 'neutral'; detail?: string; tripStart?: string }>(args, 'action')
|
|
322
1047
|
const ledger = ensureLedger(config.stateRoot)
|
|
323
1048
|
const now = new Date().toISOString()
|
|
@@ -341,11 +1066,20 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
341
1066
|
const r = ledger.confirmOutcome({ wishId: q.wishId, attribution: q.attribution, detail: q.detail, trip: tripInput })
|
|
342
1067
|
return { ok: true, recorded: r.recorded, wish_id: q.wishId, status: q.attribution, ...(r.trip ? { trip: r.trip } : {}) } as never
|
|
343
1068
|
}
|
|
344
|
-
// recall:0..1 条件匹配(判定归 wish-pool 纯函数),muted
|
|
1069
|
+
// recall:0..1 条件匹配(判定归 wish-pool 纯函数),muted 永不召回,无命中不硬推;
|
|
1070
|
+
// 通道否证(接缝第 2 段):持久健康面里处于 down 的通道集合进 context——
|
|
1071
|
+
// conditions.channels 命名依赖通道的愿望在被否证时不当场推荐(读不到健康面=不启用)。
|
|
345
1072
|
const pool = ledger.readWishPool()
|
|
346
1073
|
const candidates = pool.filter(e => !e.muted && typeof e.wish_id === 'string')
|
|
347
1074
|
const month = q.month ?? new Date().getMonth() + 1
|
|
348
|
-
|
|
1075
|
+
let channelDown: ReadonlySet<string> | undefined
|
|
1076
|
+
try {
|
|
1077
|
+
const latest = await readLatestChannelEvents(config.stateRoot)
|
|
1078
|
+
const down = new Set<string>()
|
|
1079
|
+
for (const [ch, ev] of latest) if (ev.state === 'down') down.add(ch)
|
|
1080
|
+
channelDown = down
|
|
1081
|
+
} catch { /* 健康面缺席=不启用否证,召回行为与旧版一致 */ }
|
|
1082
|
+
const match = pickNudgeWish(candidates as WishPoolEntry[], { days: q.days, budgetCny: q.budgetCny, month, channelDown })
|
|
349
1083
|
if (!match) {
|
|
350
1084
|
return { ok: true, suggestion: null, summary: `无可成行的憧憬匹配当前窗口(${candidates.length} 条在册,0..1 纪律:不硬推)` } as never
|
|
351
1085
|
}
|
|
@@ -360,7 +1094,52 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
360
1094
|
summary: `「下一次出发」候选(0..1):${String(match.entry['name'])}——成行条件 ${JSON.stringify(match.entry['conditions'])},本次窗口命中 ${match.score}/3 项(${match.hits.join('+')});效用状态 ${utility?.status ?? 'unknown'}`,
|
|
361
1095
|
} as never
|
|
362
1096
|
},
|
|
363
|
-
presentCall: args => ({ card: 'generic', title: '「下一次出发」召回', kind: 'search', rawInput: args
|
|
1097
|
+
presentCall: args => ({ card: 'generic', title: '「下一次出发」召回', kind: 'search', rawInput: args }),
|
|
1098
|
+
}))
|
|
1099
|
+
|
|
1100
|
+
// ADR-24 v2 复访交付面:用户回问「行程规划好了吗」时,模型用这个只读工具
|
|
1101
|
+
// 查后台工单状态;settled 附交付物摘录(完整内容在工单同目录
|
|
1102
|
+
// .deliverable.md)。工单由 scripts/turn-handoff-collect.ts 收集结算。
|
|
1103
|
+
registerGuarded(defineTool({
|
|
1104
|
+
name: 'gotry_turn_handoff_list',
|
|
1105
|
+
description:
|
|
1106
|
+
'List background deep-planning handoff tickets (read-only). Call this whenever the user asks about '
|
|
1107
|
+
+ 'a previously handed-off plan («规划好了吗» / «上次那个行程»): open = still being worked in the '
|
|
1108
|
+
+ 'background (ETA ' + '约 1 小时' + '), settled = deliverable ready (excerpt included, full text in the '
|
|
1109
|
+
+ 'ticket\'s .deliverable.md), failed = honest failure note. Never fabricate a deliverable that is not here.',
|
|
1110
|
+
// D-30 第三刀(issue #112):query blob → 平铺 typed;全字段可选 → interpretArgs 容忍层(同 session 刀法)
|
|
1111
|
+
parameters: {
|
|
1112
|
+
ticketId: { type: 'string', description: '只看这一单,如 th-...(缺省列全部)' },
|
|
1113
|
+
},
|
|
1114
|
+
output: {
|
|
1115
|
+
schema: { type: 'json' },
|
|
1116
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }],
|
|
1117
|
+
},
|
|
1118
|
+
async execute(args, _exec: unknown) {
|
|
1119
|
+
const q = interpretArgs<{ ticketId?: string }>(args)
|
|
1120
|
+
const root = process.env.GOTRY_TURN_HANDOFF_ROOT ?? config.stateRoot ?? '.'
|
|
1121
|
+
const all = await listTurnHandoffTickets(root)
|
|
1122
|
+
const tickets = (q.ticketId ? all.filter(t => t.id === q.ticketId) : all).slice(0, 10)
|
|
1123
|
+
if (tickets.length === 0) {
|
|
1124
|
+
return {
|
|
1125
|
+
ok: true, count: 0,
|
|
1126
|
+
summary: q.ticketId
|
|
1127
|
+
? `没有 id 为 ${q.ticketId} 的后台规划工单(可能已被清理或记错 id)`
|
|
1128
|
+
: '当前没有后台深度规划工单(无挂起、无历史)',
|
|
1129
|
+
} as never
|
|
1130
|
+
}
|
|
1131
|
+
const lines = tickets.map(t => {
|
|
1132
|
+
const head = `[${t.status}] ${t.id} ${t.objective.slice(0, 40)}`
|
|
1133
|
+
if (t.status === 'open') return `${head}——后台规划中,ETA ${t.etaLabel}`
|
|
1134
|
+
if (t.status === 'failed') return `${head}——失败:${t.error ?? '未知原因'}(请重新发起规划)`
|
|
1135
|
+
return `${head}——已交付,摘要:${(t.deliverableExcerpt ?? '').slice(0, 120).replace(/\n/g, ' ')}`
|
|
1136
|
+
})
|
|
1137
|
+
return {
|
|
1138
|
+
ok: true, count: tickets.length, tickets,
|
|
1139
|
+
summary: `后台规划工单 ${tickets.length} 张:\n${lines.join('\n')}`,
|
|
1140
|
+
} as never
|
|
1141
|
+
},
|
|
1142
|
+
presentCall: args => ({ card: 'generic', title: '后台规划工单查询', kind: 'search', rawInput: args }),
|
|
364
1143
|
}))
|
|
365
1144
|
|
|
366
1145
|
registerGuarded(defineTool({
|
|
@@ -370,11 +1149,19 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
370
1149
|
+ 'never a hard filter (「爸爸65轻度高血压」→ 不排高海拔/控制步行量;「晕车」→ 优先火车/备提示). '
|
|
371
1150
|
+ 'evidence MUST be the user\'s verbatim words (append-only, traceable). '
|
|
372
1151
|
+ 'NEGATIVE LIST: passport/ID/phone numbers are rejected on sight — such fields never enter storage.',
|
|
1152
|
+
// D-30 第五刀(issue #112):companion blob → 结构化闭合对象;label/evidence required 由宿主权闸
|
|
1153
|
+
// (evidence append-only 溯源 P0;additionalProperties:false 与负面清单同向——护照/证件号类
|
|
1154
|
+
// 杂散字段入口即拒)
|
|
373
1155
|
parameters: {
|
|
374
1156
|
companion: {
|
|
375
|
-
type: '
|
|
1157
|
+
type: 'object',
|
|
1158
|
+
additionalProperties: false,
|
|
376
1159
|
required: true,
|
|
377
|
-
|
|
1160
|
+
properties: {
|
|
1161
|
+
label: { type: 'string', required: true, description: '称呼,如 爸爸' },
|
|
1162
|
+
constraints: { type: 'object', additionalProperties: true, description: '{ mobility?: "步行≤4h", health?: ["轻度高血压"], prefs?: ["怕吵"] }' },
|
|
1163
|
+
evidence: { type: 'string', required: true, description: '用户原话(append-only 可溯源,P0)' },
|
|
1164
|
+
},
|
|
378
1165
|
},
|
|
379
1166
|
},
|
|
380
1167
|
output: {
|
|
@@ -404,19 +1191,21 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
404
1191
|
+ '(「去年国庆去了大理」) — dates resolve via the time anchor (词表外日期会拒绝,请用户给绝对日期), evidence MUST be the user\'s verbatim words. '
|
|
405
1192
|
+ 'Append-only and idempotent (same destination+start+source = same trip); overlapping same-destination trips are rejected for human adjudication. '
|
|
406
1193
|
+ 'Past trips power origin resolution and 「去过不再推」 ranking — never a hard filter.',
|
|
1194
|
+
// D-30 第四刀(issue #112):平铺 typed;destination/evidence required → 宿主权入口拒缺参
|
|
1195
|
+
// (evidence 是 P0 溯源红线,进 schema 由宿主权闸,不再依赖 execute 散文报错)
|
|
407
1196
|
parameters: {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
},
|
|
1197
|
+
destination: { type: 'string', required: true, description: '目的地,如 大理' },
|
|
1198
|
+
start: { type: 'string', required: true, description: '行程起始:YYYY-MM-DD 或绝对日期表达(解析不了 execute 结构化报错不猜)' },
|
|
1199
|
+
end: { type: 'string', description: '行程结束,形态同 start(可选)' },
|
|
1200
|
+
companions: { type: 'array', items: { type: 'string' }, description: '同行人,如 ["爸爸"]' },
|
|
1201
|
+
evidence: { type: 'string', required: true, description: '用户原话(溯源 P0 红线)' },
|
|
413
1202
|
},
|
|
414
1203
|
output: {
|
|
415
1204
|
schema: { type: 'json' },
|
|
416
1205
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }],
|
|
417
1206
|
},
|
|
418
|
-
async execute(args
|
|
419
|
-
const q =
|
|
1207
|
+
async execute(args, _exec: unknown) {
|
|
1208
|
+
const q = args
|
|
420
1209
|
if (!q.destination) return { ok: false, summary: 'destination 必填' } as never
|
|
421
1210
|
if (!q.evidence) return { ok: false, summary: 'evidence 必填(用户原话,溯源 P0)' } as never
|
|
422
1211
|
const anchor = buildTimeAnchor(new Date())
|
|
@@ -432,51 +1221,181 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
432
1221
|
if (!res.appended && res.reason) return { ok: false, summary: res.reason } as never
|
|
433
1222
|
return { ok: true, appended: res.appended, trip_id: res.tripId, total: res.total, summary: res.appended ? `已入时间线:${q.destination} @ ${start}` : `已存在(幂等跳过):${res.tripId}` } as never
|
|
434
1223
|
},
|
|
435
|
-
presentCall: args => ({ card: 'generic', title: '记录旅行', kind: 'edit', rawInput: args
|
|
1224
|
+
presentCall: args => ({ card: 'generic', title: '记录旅行', kind: 'edit', rawInput: args }),
|
|
436
1225
|
}))
|
|
437
1226
|
|
|
1227
|
+
// 会话双区记忆工具面(issue #255 P4-3):**只在总闸 on 时注册**——off(默认)时
|
|
1228
|
+
// 工具清单逐项不变,模型看不到任何分区入口(接线惰性的第一道)。
|
|
1229
|
+
if (zoneSwitch === 'on') {
|
|
1230
|
+
registerGuarded(defineTool({
|
|
1231
|
+
name: 'gotry_session_zone_note',
|
|
1232
|
+
description:
|
|
1233
|
+
'Session working-zone memory (hot context, issue #255): capture a STRUCTURED fragment of what is still in play this session '
|
|
1234
|
+
+ '(destination / date window / party size / budget stance = intent tier, 24h; search shapes = resource tier, 30min), '
|
|
1235
|
+
+ 'revise it with optimistic CAS when the user corrects a detail, or drop it when it stops being true. '
|
|
1236
|
+
+ 'Payload must be typed slot-spec-family fields — NEVER raw conversation text, IDs, phone numbers, URLs or credentials '
|
|
1237
|
+
+ '(the write gate rejects those shapes). Evidence is a pointer (session + observation index), never content. '
|
|
1238
|
+
+ 'action="propose" only PROPOSES moving a note into the durable notebook: it writes NOTHING; the owner must confirm in their own words, '
|
|
1239
|
+
+ 'after which you call gotry_session_zone_promote with that quote. The model may never self-promote. '
|
|
1240
|
+
+ 'action="deny" records that the owner declined to remember it long-term (writes nothing; stop re-proposing it this session).',
|
|
1241
|
+
parameters: {
|
|
1242
|
+
action: { type: 'string', required: true, enum: ['capture', 'revise', 'drop', 'propose', 'deny'], description: 'capture=新片段入工作区;revise=CAS 修订(需 noteId+expectedRev);drop=更正即弃;propose=提议晋升(零写入);deny=owner 否决提议(零写入)' },
|
|
1243
|
+
tier: { type: 'string', enum: ['resource', 'intent'], description: 'capture 必填:intent(目的地/日期/人数/预算立场,24h)或 resource(检索形态,30min)' },
|
|
1244
|
+
kind: { type: 'string', enum: ['availability', 'price_band', 'destination', 'date_window', 'party_size', 'budget_stance'], description: 'capture 必填;revise 可选(改 kind)' },
|
|
1245
|
+
payload: { type: 'object', additionalProperties: true, description: '结构化片段,如 { city: "大理" } / { start: "2026-11-01", end: "2026-11-05" };禁放原文/证件/电话/URL/凭证' },
|
|
1246
|
+
noteId: { type: 'string', description: 'revise/drop/propose 的目标笔记 id' },
|
|
1247
|
+
expectedRev: { type: 'integer', description: 'revise 的父 rev(必须等于当前 rev,否则 stale_rev 拒绝)' },
|
|
1248
|
+
promoteKind: { type: 'string', enum: ['trip_fact', 'preference', 'constraint', 'lesson'], description: 'propose 时提议的持久条目类别' },
|
|
1249
|
+
},
|
|
1250
|
+
output: {
|
|
1251
|
+
schema: { type: 'json' },
|
|
1252
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }],
|
|
1253
|
+
},
|
|
1254
|
+
async execute(args, exec: unknown) {
|
|
1255
|
+
const sessionRef = zoneSessionRefFromExec(exec)
|
|
1256
|
+
if (!sessionRef) {
|
|
1257
|
+
return { ok: false, code: 'bad_session', summary: '无会话身份(exec.agent.session.id 缺失):分区不记忆无主体的片段' } as never
|
|
1258
|
+
}
|
|
1259
|
+
bindZoneSession(zoneScopeOfExec(exec), sessionRef)
|
|
1260
|
+
const r = applyZoneNote(ensureLedger(config.stateRoot), {
|
|
1261
|
+
action: args.action as never,
|
|
1262
|
+
...(args.tier !== undefined ? { tier: args.tier } : {}),
|
|
1263
|
+
...(args.kind !== undefined ? { kind: args.kind } : {}),
|
|
1264
|
+
...(args.payload !== undefined ? { payload: args.payload as Record<string, unknown> } : {}),
|
|
1265
|
+
...(args.noteId !== undefined ? { noteId: args.noteId } : {}),
|
|
1266
|
+
...(args.expectedRev !== undefined ? { expectedRev: args.expectedRev } : {}),
|
|
1267
|
+
...(args.promoteKind !== undefined ? { promoteKind: args.promoteKind } : {}),
|
|
1268
|
+
session_ref: sessionRef,
|
|
1269
|
+
ts: new Date().toISOString(),
|
|
1270
|
+
})
|
|
1271
|
+
if (!r.ok) return { ok: false, code: r.code, summary: r.detail } as never
|
|
1272
|
+
const summary = r.proposed
|
|
1273
|
+
? `已提议(未写入):${r.ask ?? ''}`
|
|
1274
|
+
: `${r.action}${r.appended ? ' 已落账' : ' 幂等跳过'}${r.noteId ? `:${r.noteId.slice(0, 60)}` : ''}`
|
|
1275
|
+
return { ok: true, action: r.action, appended: r.appended, note_id: r.noteId, requires_owner_confirm: r.requiresOwnerConfirm === true, summary } as never
|
|
1276
|
+
},
|
|
1277
|
+
presentCall: args => ({ card: 'generic', title: `会话记忆:${String(args.action ?? '')}`, kind: 'edit', rawInput: args }),
|
|
1278
|
+
}))
|
|
1279
|
+
|
|
1280
|
+
registerGuarded(defineTool({
|
|
1281
|
+
name: 'gotry_session_zone_promote',
|
|
1282
|
+
description:
|
|
1283
|
+
'Owner-confirmed promotion into the durable trip notebook (issue #255) — the ONLY write path into durable memory. '
|
|
1284
|
+
+ 'Requires ownerQuote: the user\'s OWN words confirming they want this remembered long-term; your own summary is not a confirmation. '
|
|
1285
|
+
+ 'Routing rule: a preference ALSO goes through the motivation-profile gate (pass motivation.{weights|hard|homeCity}), '
|
|
1286
|
+
+ 'a trip fact through the timeline gate (pass trip.{destination,start}), a companion constraint through the companion gate (pass companion.{label,constraints}). '
|
|
1287
|
+
+ 'Without the required routed payload the promotion is REJECTED — the notebook never becomes a parallel store for a fact that already has an owner. '
|
|
1288
|
+
+ 'Generic constraints and lessons live in the notebook itself. The notebook write and the routed gate write are one transaction: they cannot diverge.',
|
|
1289
|
+
parameters: {
|
|
1290
|
+
noteId: { type: 'string', required: true, description: '源工作区笔记 id(必须未过期)' },
|
|
1291
|
+
kind: { type: 'string', required: true, enum: ['trip_fact', 'preference', 'constraint', 'lesson'], description: '持久条目类别' },
|
|
1292
|
+
ownerQuote: { type: 'string', required: true, description: '用户本人确认要长期记住的原话(≤200 字;模型自述不算确认)' },
|
|
1293
|
+
surface: { type: 'string', required: true, enum: ['user_reply', 'approval_card'], description: '确认表面:用户回复 或 审批卡' },
|
|
1294
|
+
payload: { type: 'object', additionalProperties: true, description: '可选:覆盖源笔记载荷的结构化断言' },
|
|
1295
|
+
motivation: { type: 'object', additionalProperties: true, description: 'preference 必填:{ weights?, hard?, homeCity? } 至少一项(经动机画像闸)' },
|
|
1296
|
+
trip: { type: 'object', additionalProperties: true, description: 'trip_fact 必填:{ destination, start(YYYY-MM-DD), end?, companions? }(经时间线闸)' },
|
|
1297
|
+
companion: { type: 'object', additionalProperties: true, description: '同行人约束必填:{ label, constraints:{ mobility?, health?, prefs? } }(经同行人闸)' },
|
|
1298
|
+
},
|
|
1299
|
+
output: {
|
|
1300
|
+
schema: { type: 'json' },
|
|
1301
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }],
|
|
1302
|
+
},
|
|
1303
|
+
async execute(args, exec: unknown) {
|
|
1304
|
+
const sessionRef = zoneSessionRefFromExec(exec)
|
|
1305
|
+
if (sessionRef) bindZoneSession(zoneScopeOfExec(exec), sessionRef)
|
|
1306
|
+
const r = promoteWithRouting(ensureLedger(config.stateRoot), {
|
|
1307
|
+
noteId: String(args.noteId ?? ''),
|
|
1308
|
+
kind: String(args.kind ?? ''),
|
|
1309
|
+
ownerQuote: String(args.ownerQuote ?? ''),
|
|
1310
|
+
surface: String(args.surface ?? ''),
|
|
1311
|
+
...(args.payload !== undefined ? { payload: args.payload as Record<string, unknown> } : {}),
|
|
1312
|
+
...(args.motivation !== undefined ? { motivation: args.motivation as never } : {}),
|
|
1313
|
+
...(args.trip !== undefined ? { trip: args.trip as never } : {}),
|
|
1314
|
+
...(args.companion !== undefined ? { companion: args.companion as never } : {}),
|
|
1315
|
+
ts: new Date().toISOString(),
|
|
1316
|
+
})
|
|
1317
|
+
if (!r.ok) return { ok: false, code: r.code, summary: r.detail } as never
|
|
1318
|
+
const routed = r.routed ? `;并经 ${r.routed.authority} 闸${r.routed.applied ? '落地' : '幂等跳过'}` : ''
|
|
1319
|
+
return {
|
|
1320
|
+
ok: true,
|
|
1321
|
+
appended: r.appended,
|
|
1322
|
+
entry_id: r.entryId,
|
|
1323
|
+
authority: r.authority,
|
|
1324
|
+
routed: r.routed ? JSON.parse(JSON.stringify(r.routed)) as JsonObject : null,
|
|
1325
|
+
summary: `${r.appended ? '已写入笔记本' : '笔记本幂等跳过'}${routed}`,
|
|
1326
|
+
} as never
|
|
1327
|
+
},
|
|
1328
|
+
presentCall: args => ({ card: 'generic', title: '笔记本晋升(owner 确认)', kind: 'edit', rawInput: args }),
|
|
1329
|
+
}))
|
|
1330
|
+
}
|
|
1331
|
+
|
|
438
1332
|
registerGuarded(defineTool({
|
|
439
1333
|
name: 'gotry_hotel_search',
|
|
440
|
-
description:
|
|
1334
|
+
description: routed('gotry_hotel_search',
|
|
441
1335
|
'Search hotels via hotelbyte-cli (real-time when hbcli credentials exist, falls back to the static pack with explicit evidence tagging). '
|
|
442
|
-
+ 'Input: destination city name + optional dates/
|
|
443
|
-
+ 'the code layer resolves them against the time anchor
|
|
444
|
-
+ '
|
|
445
|
-
+ '
|
|
1336
|
+
+ 'Input: destination city name + optional dates/adults. Dates accept verbatim natural expressions (下周五 / 8.20 / 下周五+3) — '
|
|
1337
|
+
+ 'the code layer resolves them against the time anchor. **Dates are required for a supplier search** (issue #283): missing, blank, '
|
|
1338
|
+
+ 'single-side, unresolvable, or checkOut ≤ checkIn returns an actionable input_required result WITHOUT spawning the vendor CLI; '
|
|
1339
|
+
+ 'only after a complete valid stay window is fixed do we forward to hbcli. '
|
|
1340
|
+
+ 'Output: hotel list with evidence chain ([realtime-API:hbcli] + fetch timestamp, '
|
|
1341
|
+
+ 'or [static-pack:estimate]) per the L4 invariant. Independent read-only: safe to issue in the same step as other independent searches; '
|
|
1342
|
+
+ 'the host abort signal is forwarded to the spawned process tree.'),
|
|
1343
|
+
// 并发安全(issue #519):只读检索,execute 不写共享会话态(延迟/事实落盘 append-only 且失败不阻塞),
|
|
1344
|
+
// 私有进程组接收宿主 signal;取消有界返回,未知组外后代不冒充已回收。
|
|
1345
|
+
isConcurrencySafe: () => true,
|
|
1346
|
+
// D-30 第二刀(issue #112):query blob → 平铺 typed 契约(同 flyai 刀法)。
|
|
446
1347
|
parameters: {
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
},
|
|
1348
|
+
destination: { type: 'string', required: true, description: '目的地城市,如 大理' },
|
|
1349
|
+
// issue #283 闸:日期字段对模型面仍是契约必填(required 标在 description,schema 不强制,
|
|
1350
|
+
// 让 gate 产出更可行动的错误——dsh schema 拒绝信息太泛)
|
|
1351
|
+
checkIn: { type: 'string', description: '入住日:YYYY-MM-DD 或自然表达(下周五/8.20/下周五+3);REQUIRED——缺失/空白/无法解析由 date-gate 返回 input_required,不查' },
|
|
1352
|
+
checkOut: { type: 'string', description: 'checkOut 退房日:YYYY-MM-DD 或自然表达,形态同 checkIn;REQUIRED——必须晚于入住日,否则不查' },
|
|
1353
|
+
adults: { type: 'integer', description: '成人数(顶层字段,默认 2)' },
|
|
452
1354
|
},
|
|
453
1355
|
output: {
|
|
454
1356
|
schema: { type: 'json' },
|
|
455
1357
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 400)) }],
|
|
456
1358
|
},
|
|
457
|
-
async execute(args
|
|
458
|
-
const q =
|
|
1359
|
+
async execute(args, _exec) {
|
|
1360
|
+
const q = args
|
|
459
1361
|
if (!q.destination) throw new Error('gotry_hotel_search requires destination')
|
|
460
1362
|
const started = Date.now()
|
|
1363
|
+
const execSignal = (_exec as { signal?: AbortSignal } | undefined)?.signal
|
|
461
1364
|
const fallbackPath = join(import.meta.dirname, '..', '..', 'data', 'hotels_2026.json')
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
1365
|
+
// issue #283 闸:完整、有效且退房晚于入住才能派发 hbcli;失败直接返回
|
|
1366
|
+
// input_required,不发供应商命令,不把当前窗口查询当所需日期结果。
|
|
1367
|
+
// 复用既有 time-anchor/slot-spec:词表内自然表达 → 绝对日期;
|
|
1368
|
+
// 词表外 → unresolved,闸失败,不再走「无日期降级」伪装默认窗口价。
|
|
1369
|
+
const gate = evaluateHotelStayDates(q.checkIn, q.checkOut)
|
|
1370
|
+
if (!gate.ok) {
|
|
1371
|
+
// 闸失败:不调用 interpretEffect,不调 hbcli;延迟日志不写(no spawn)。
|
|
1372
|
+
// summary 用「需要 X 信息才能查询酒店」开头的可行动句式,presentResult 直接
|
|
1373
|
+
// 渲成「酒店:<dest> 缺日期」行动卡(替代旧版「无结果」空卡)。
|
|
1374
|
+
const summary = `需要补充${(gate.missing ?? []).map(m => m === 'checkIn' ? '入住日' : '退房日').join('和')}才能查询酒店「${q.destination ?? ''}」:${gate.message}`
|
|
1375
|
+
return {
|
|
1376
|
+
ok: false,
|
|
1377
|
+
verdict: gate.verdict,
|
|
1378
|
+
reason: gate.reason,
|
|
1379
|
+
missing: gate.missing,
|
|
1380
|
+
raw: gate.raw,
|
|
1381
|
+
destination: q.destination,
|
|
1382
|
+
message: gate.message,
|
|
1383
|
+
summary,
|
|
1384
|
+
evidence: gate.evidence,
|
|
1385
|
+
latency_ms: Date.now() - started,
|
|
1386
|
+
} as never
|
|
475
1387
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
)
|
|
1388
|
+
// 闸通过:绝对日期已就位;slot 解析过程产生的原话 → 绝对日期 注记回显
|
|
1389
|
+
// (工具层 date_notes 与既有 D-10 切片 B 形态兼容)
|
|
1390
|
+
const dateNotes = [...gate.notes]
|
|
1391
|
+
// 效应解译层(ADR-18):渠道选择/韧性(重试/熔断)在解译器,result 原样透传——
|
|
1392
|
+
// 断路拒绝时返回平铺失败面(不发起查询,不伪装成 miss)
|
|
1393
|
+
const itp = await interpretEffect({
|
|
1394
|
+
effect: 'HBCLI_HOTEL_SEARCH',
|
|
1395
|
+
params: { destination: q.destination, checkIn: gate.checkIn, checkOut: gate.checkOut, adults: q.adults, hbcliBin: config.hbcliBin, timeoutMs: config.timeoutMs, fallbackPath, signal: execSignal },
|
|
1396
|
+
})
|
|
1397
|
+
if (!itp.result) return declinedObservation('HBCLI_HOTEL_SEARCH', itp.trace)
|
|
1398
|
+
const resp = itp.result
|
|
480
1399
|
const dir = await ensureStateDir(config.stateRoot)
|
|
481
1400
|
const isLive = resp.via === 'hbcli-realtime'
|
|
482
1401
|
const evidence = isLive ? resp.evidence : '[静态包:估算]'
|
|
@@ -494,18 +1413,127 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
494
1413
|
} as never
|
|
495
1414
|
return JSON.parse(JSON.stringify(payload)) as never
|
|
496
1415
|
},
|
|
497
|
-
presentCall: args => ({ card: 'generic', title: `酒店搜索:${
|
|
1416
|
+
presentCall: args => ({ card: 'generic', title: `酒店搜索:${args.destination ?? ''}`, kind: 'search', rawInput: args }),
|
|
498
1417
|
presentResult: (_args, value) => {
|
|
499
|
-
|
|
500
|
-
|
|
1418
|
+
// 闸失败:渲染为「input_required」行动卡,引导模型向用户追问日期;
|
|
1419
|
+
// 不再退化为「无结果」空卡——闸拒绝的可行动语义必须显式进 UI。
|
|
1420
|
+
// UI 面禁用内部工程词(check_in_blank / 闸拒绝 / ISO 字符串),只露「入住日/退房日」
|
|
1421
|
+
// 与具体日期示例供模型追问。
|
|
1422
|
+
const r = value as { hotels?: unknown; via?: string; destination?: string; summary?: string; verdict?: string; reason?: string; missing?: string[]; message?: string }
|
|
1423
|
+
if (r.verdict === 'input_required') {
|
|
1424
|
+
const missingLabels = (r.missing ?? []).map(m => m === 'checkIn' ? '入住日' : '退房日')
|
|
1425
|
+
const fieldText = missingLabels.length ? missingLabels.join('和') : '入住日和退房日'
|
|
1426
|
+
const dateHint = !missingLabels.length || missingLabels.length === 2
|
|
1427
|
+
? '例如 2026-09-18 / 2026-09-20'
|
|
1428
|
+
: missingLabels[0] === '入住日' ? '例如 2026-09-18' : '例如 2026-09-20'
|
|
1429
|
+
return {
|
|
1430
|
+
card: 'generic',
|
|
1431
|
+
title: `酒店:${r.destination ?? ''} 需要${fieldText}`,
|
|
1432
|
+
// P2 措辞修正:直接对用户说话的句式,不再绕一道「请告诉.../请向用户确认」转给模型;
|
|
1433
|
+
// gate.message 里残留的字段指引也归一为直接对用户措辞,保留日期示例等可行动细节。
|
|
1434
|
+
content: [{ type: 'text', text: `请补充 ${fieldText}(${dateHint})后再次查询。\n${normalizeHotelGateHandoff(r.message)}` }],
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
const h = r.hotels
|
|
1438
|
+
const liveCount = Array.isArray(h) ? h.length : 0
|
|
1439
|
+
// 静态包是 {meta, stays} 对象而非数组(issue #24:此前计数恒 0 → UI 显示「无结果」)
|
|
1440
|
+
const stays = !Array.isArray(h) && h && typeof h === 'object' ? (h as { stays?: unknown[] }).stays : undefined
|
|
1441
|
+
const staticCount = Array.isArray(stays) ? stays.length : 0
|
|
1442
|
+
const tag = r.via === 'hbcli-realtime'
|
|
1443
|
+
? (liveCount ? `实时 ${liveCount} 家` : '实时')
|
|
1444
|
+
: staticCount ? `静态包 ${staticCount} 块` : (liveCount ? `${liveCount} 家` : '无结果')
|
|
501
1445
|
return {
|
|
502
1446
|
card: 'generic',
|
|
503
|
-
title: `酒店:${r.destination ?? ''} ${
|
|
1447
|
+
title: `酒店:${r.destination ?? ''} ${tag}`,
|
|
504
1448
|
content: [{ type: 'text', text: String(r.summary ?? '') }],
|
|
505
1449
|
}
|
|
506
1450
|
},
|
|
507
1451
|
}))
|
|
508
1452
|
|
|
1453
|
+
// ---- hotelbyte 客户认证面(hbcli auth customer-*;新邮箱即注册,无需密码)----
|
|
1454
|
+
// 两步流:send_code(外发邮件,须用户明示同意)→ 用户从收件箱报码 → login。
|
|
1455
|
+
// 刻意不走 interpretEffect 效应层:发码/验码非幂等(超时重试会重复发信/浪费
|
|
1456
|
+
// 一次性验证码),直调能力层 fail-fast;凭据写 hbcli customer 档(CLI 侧最低
|
|
1457
|
+
// 优先级,不劫持既有 portal/API-key 凭据),token 值不进 gotry 会话/日志。
|
|
1458
|
+
|
|
1459
|
+
registerGuarded(defineTool({
|
|
1460
|
+
name: 'gotry_hotel_auth_send_code',
|
|
1461
|
+
description:
|
|
1462
|
+
'Send an email verification code to the user for HotelByte customer login (step 1 of 2). '
|
|
1463
|
+
+ '**Only call after the user explicitly agreed to login/register with that email** — this sends a real email and the backend rate-limits sends. '
|
|
1464
|
+
+ 'Step 2 is gotry_hotel_auth_login with the code the user reads from their inbox (never guess or fabricate the code). '
|
|
1465
|
+
+ 'Requires hbcli with auth support (npm staicli ≥ 0.0.4); on older versions it fails with unknown-command via the error surface.',
|
|
1466
|
+
parameters: {
|
|
1467
|
+
email: { type: 'string', required: true, description: '用户邮箱(验证码发送目标,须用户自己提供)' },
|
|
1468
|
+
},
|
|
1469
|
+
output: {
|
|
1470
|
+
schema: { type: 'json' },
|
|
1471
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 400)) }],
|
|
1472
|
+
},
|
|
1473
|
+
async execute(args, _exec) {
|
|
1474
|
+
const q = args as { email?: string }
|
|
1475
|
+
const email = String(q.email ?? '').trim()
|
|
1476
|
+
if (!email || !email.includes('@')) {
|
|
1477
|
+
return { ok: false, summary: `需要有效邮箱才能发送验证码(收到:${email || '空'})——请向用户要邮箱,不猜` } as never
|
|
1478
|
+
}
|
|
1479
|
+
const started = Date.now()
|
|
1480
|
+
const r = await customerSendCode(email, { hbcliBin: config.hbcliBin, timeoutMs: config.timeoutMs })
|
|
1481
|
+
return JSON.parse(JSON.stringify({
|
|
1482
|
+
ok: r.via === 'hbcli-realtime',
|
|
1483
|
+
summary: r.summary,
|
|
1484
|
+
evidence: r.evidence,
|
|
1485
|
+
via: r.via,
|
|
1486
|
+
detail: r.result,
|
|
1487
|
+
latency_ms: Date.now() - started,
|
|
1488
|
+
})) as never
|
|
1489
|
+
},
|
|
1490
|
+
presentCall: args => ({ card: 'generic', title: `发送验证码:${(args as { email?: string }).email ?? ''}`, kind: 'execute', rawInput: args }),
|
|
1491
|
+
}))
|
|
1492
|
+
|
|
1493
|
+
registerGuarded(defineTool({
|
|
1494
|
+
name: 'gotry_hotel_auth_login',
|
|
1495
|
+
description:
|
|
1496
|
+
'Complete HotelByte customer login with email + code (step 2 of 2; pairs with gotry_hotel_auth_send_code). '
|
|
1497
|
+
+ 'The code comes from the user\'s inbox — never guess. A NEW email is auto-registered (no password needed): verified email = account. '
|
|
1498
|
+
+ 'On success the credential is stored in hbcli\'s customer profile (lowest precedence — existing portal/API-key credentials are NOT hijacked), '
|
|
1499
|
+
+ 'and subsequent hotel search/rates run realtime under that identity. Optional attributionToken (from an advisor share link) binds the user '
|
|
1500
|
+
+ 'to that advisor. Login failure consumes the one-shot code: re-send a new code before retrying.',
|
|
1501
|
+
parameters: {
|
|
1502
|
+
email: { type: 'string', required: true, description: '用户邮箱(与发送验证码时一致)' },
|
|
1503
|
+
code: { type: 'string', required: true, description: '用户从邮箱收到的验证码(一次性凭据;失败即作废,需重新发码)' },
|
|
1504
|
+
attributionToken: { type: 'string', description: '顾问归因令牌(顾问分享链接携带;可选,绑定后该客户计入顾问客户簿)' },
|
|
1505
|
+
},
|
|
1506
|
+
output: {
|
|
1507
|
+
schema: { type: 'json' },
|
|
1508
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 400)) }],
|
|
1509
|
+
},
|
|
1510
|
+
async execute(args, _exec) {
|
|
1511
|
+
const q = args as { email?: string; code?: string; attributionToken?: string }
|
|
1512
|
+
const email = String(q.email ?? '').trim()
|
|
1513
|
+
const code = String(q.code ?? '').trim()
|
|
1514
|
+
if (!email || !email.includes('@')) {
|
|
1515
|
+
return { ok: false, summary: `需要有效邮箱才能登录(收到:${email || '空'})——请向用户确认邮箱` } as never
|
|
1516
|
+
}
|
|
1517
|
+
if (!code) {
|
|
1518
|
+
return { ok: false, summary: '需要邮箱验证码才能登录——请让用户从收件箱查收并报码(先 gotry_hotel_auth_send_code)' } as never
|
|
1519
|
+
}
|
|
1520
|
+
const started = Date.now()
|
|
1521
|
+
const r = await customerLogin(
|
|
1522
|
+
{ email, code, attributionToken: q.attributionToken ? String(q.attributionToken) : undefined },
|
|
1523
|
+
{ hbcliBin: config.hbcliBin, timeoutMs: config.timeoutMs },
|
|
1524
|
+
)
|
|
1525
|
+
return JSON.parse(JSON.stringify({
|
|
1526
|
+
ok: r.via === 'hbcli-realtime' && (r.result as { status?: string } | null)?.status === 'logged_in',
|
|
1527
|
+
summary: r.summary,
|
|
1528
|
+
evidence: r.evidence,
|
|
1529
|
+
via: r.via,
|
|
1530
|
+
detail: r.result,
|
|
1531
|
+
latency_ms: Date.now() - started,
|
|
1532
|
+
})) as never
|
|
1533
|
+
},
|
|
1534
|
+
presentCall: args => ({ card: 'generic', title: `客户登录:${(args as { email?: string }).email ?? ''}`, kind: 'execute', rawInput: args }),
|
|
1535
|
+
}))
|
|
1536
|
+
|
|
509
1537
|
registerGuarded(defineTool({
|
|
510
1538
|
name: 'gotry_skeleton_check',
|
|
511
1539
|
description:
|
|
@@ -533,30 +1561,34 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
533
1561
|
|
|
534
1562
|
registerGuarded(defineTool({
|
|
535
1563
|
name: 'gotry_weather_check',
|
|
536
|
-
description:
|
|
1564
|
+
description: routed('gotry_weather_check',
|
|
537
1565
|
'Check weather for a destination: forecast (≤16 days) or historical climate (seasonality baseline). '
|
|
538
1566
|
+ 'Free Open-Meteo API, no key required. Input: place name (Chinese ok) or lat/lng. '
|
|
539
1567
|
+ 'Returns daily temp range, precipitation probability, weather code — with evidence chain tagging '
|
|
540
|
-
+ '[实时API:open-meteo@ts]. Use to ground seasonal advice in real data instead of LLM guessing.',
|
|
1568
|
+
+ '[实时API:open-meteo@ts]. Use to ground seasonal advice in real data instead of LLM guessing.'),
|
|
541
1569
|
parameters: {
|
|
542
|
-
query
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
},
|
|
1570
|
+
// D-30 第二刀(issue #112):query blob → 平铺 typed 契约(同 flyai 刀法)。
|
|
1571
|
+
place: { type: 'string', description: '城市名(中文可),如 大理;与 lat/lng 二选一,都没给 execute 结构化报错' },
|
|
1572
|
+
lat: { type: 'number', description: '纬度(与 lng 成对;给了则跳过地理编码)' },
|
|
1573
|
+
lng: { type: 'number', description: '经度(与 lat 成对)' },
|
|
1574
|
+
month: { type: 'integer', description: '气候基线月份 1-12(mode=climate 或给了 month 未指 mode 时用)' },
|
|
1575
|
+
mode: { type: 'string', enum: ['forecast', 'climate'], description: '默认 forecast' },
|
|
1576
|
+
days: { type: 'integer', description: '预报天数(≤16,默认 7)' },
|
|
547
1577
|
},
|
|
548
1578
|
output: {
|
|
549
1579
|
schema: { type: 'json' },
|
|
550
1580
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
|
|
551
1581
|
},
|
|
552
|
-
async execute(args
|
|
553
|
-
const q =
|
|
1582
|
+
async execute(args, _exec) {
|
|
1583
|
+
const q = args
|
|
554
1584
|
const started = Date.now()
|
|
555
1585
|
let lat: number | undefined = q.lat, lng: number | undefined = q.lng
|
|
556
1586
|
let placeLabel = q.place ?? `${q.lat},${q.lng}`
|
|
557
1587
|
if (lat === undefined || lng === undefined) {
|
|
558
1588
|
if (!q.place) throw new Error('gotry_weather_check requires place name or lat/lng')
|
|
559
|
-
const
|
|
1589
|
+
const geoItp = await interpretEffect({ effect: 'WEATHER_GEOCODE', params: { name: q.place } })
|
|
1590
|
+
if (!geoItp.result) return declinedObservation('WEATHER_GEOCODE', geoItp.trace)
|
|
1591
|
+
const geo = geoItp.result
|
|
560
1592
|
if (!geo.ok || geo.results.length === 0) {
|
|
561
1593
|
return JSON.parse(JSON.stringify({ ok: false, summary: `地点「${q.place}」地理编码失败:${geo.error ?? '无结果'}`, evidence: geo.evidence })) as Record<string, never>
|
|
562
1594
|
}
|
|
@@ -565,9 +1597,11 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
565
1597
|
placeLabel = `${hit.name}(${hit.admin1 ?? hit.country ?? ''})`
|
|
566
1598
|
}
|
|
567
1599
|
const isClimate = q.mode === 'climate' || (q.month !== undefined && q.mode !== 'forecast')
|
|
568
|
-
const
|
|
569
|
-
? await
|
|
570
|
-
: await
|
|
1600
|
+
const wxItp = isClimate
|
|
1601
|
+
? await interpretEffect({ effect: 'WEATHER_CLIMATE', params: { latitude: lat, longitude: lng, month: q.month ?? new Date().getMonth() + 1 } })
|
|
1602
|
+
: await interpretEffect({ effect: 'WEATHER_FORECAST', params: { latitude: lat, longitude: lng, days: q.days } })
|
|
1603
|
+
if (!wxItp.result) return declinedObservation(isClimate ? 'WEATHER_CLIMATE' : 'WEATHER_FORECAST', wxItp.trace)
|
|
1604
|
+
const r = wxItp.result
|
|
571
1605
|
const dir = await ensureStateDir(config.stateRoot)
|
|
572
1606
|
await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `weather:${r.via}`).catch(() => {})
|
|
573
1607
|
const dailyLines = (r.daily ?? []).slice(0, 7).map(d =>
|
|
@@ -581,10 +1615,10 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
581
1615
|
latency_ms: Date.now() - started,
|
|
582
1616
|
})) as Record<string, never>
|
|
583
1617
|
},
|
|
584
|
-
presentCall: args => ({ card: 'generic', title: `天气:${
|
|
1618
|
+
presentCall: args => ({ card: 'generic', title: `天气:${args.place ?? `${args.lat ?? ''},${args.lng ?? ''}`}`, kind: 'fetch', rawInput: args }),
|
|
585
1619
|
presentResult: (args, value) => {
|
|
586
1620
|
const r = value as { summary?: string }
|
|
587
|
-
const place = String(
|
|
1621
|
+
const place = String(args.place ?? '')
|
|
588
1622
|
const failed = String(r.summary ?? '').includes('降级') || String(r.summary ?? '').includes('unavailable')
|
|
589
1623
|
return {
|
|
590
1624
|
card: 'generic',
|
|
@@ -596,7 +1630,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
596
1630
|
|
|
597
1631
|
registerGuarded(defineTool({
|
|
598
1632
|
name: 'gotry_flight_verify',
|
|
599
|
-
description:
|
|
1633
|
+
description: routed('gotry_flight_verify',
|
|
600
1634
|
'Verify whether a flight callsign is currently observable on the OpenSky ADS-B network. '
|
|
601
1635
|
+ 'Free anonymous API (~400 credits/day, 4 req/s burst). Three-valued semantics: '
|
|
602
1636
|
+ 'observed = strong positive (the aircraft is currently being broadcast); '
|
|
@@ -604,25 +1638,26 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
604
1638
|
+ 'a missing signal does NOT disprove the flight); '
|
|
605
1639
|
+ 'unavailable = API failure, gracefully degraded. '
|
|
606
1640
|
+ 'Use to ground "is this flight actually flying right now?" in real data, complementing '
|
|
607
|
-
+ 'the OpenFlights skeleton (historical connectivity) and the static flight pack (planned schedule).',
|
|
1641
|
+
+ 'the OpenFlights skeleton (historical connectivity) and the static flight pack (planned schedule).'),
|
|
1642
|
+
// D-30 第四刀(issue #112):平铺 typed;callsign required → 宿主权即拒
|
|
608
1643
|
parameters: {
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
description: '{ callsign: "EK329", airport?: "OMDB", timeoutMs?: 10000 }',
|
|
613
|
-
},
|
|
1644
|
+
callsign: { type: 'string', required: true, description: '航班呼号,如 EK329' },
|
|
1645
|
+
airport: { type: 'string', description: '预期机场 IATA,如 OMDB(观测命中时展示)' },
|
|
1646
|
+
timeoutMs: { type: 'integer', description: '超时毫秒,默认 10000' },
|
|
614
1647
|
},
|
|
615
1648
|
output: {
|
|
616
1649
|
schema: { type: 'json' },
|
|
617
1650
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 500)) }],
|
|
618
1651
|
},
|
|
619
|
-
async execute(args
|
|
620
|
-
const q =
|
|
1652
|
+
async execute(args, _exec: unknown) {
|
|
1653
|
+
const q = args
|
|
621
1654
|
const started = Date.now()
|
|
622
1655
|
if (!q.callsign) {
|
|
623
1656
|
return JSON.parse(JSON.stringify({ verdict: 'unavailable', evidence: '[校验不可用:无 callsign]', summary: 'callsign 必填' })) as Record<string, never>
|
|
624
1657
|
}
|
|
625
|
-
const
|
|
1658
|
+
const itp = await interpretEffect({ effect: 'OPENSKY_FLIGHT_VERIFY', params: { callsign: q.callsign, airport: q.airport, timeoutMs: q.timeoutMs } })
|
|
1659
|
+
if (!itp.result) return declinedObservation('OPENSKY_FLIGHT_VERIFY', itp.trace)
|
|
1660
|
+
const r = itp.result
|
|
626
1661
|
const dir = await ensureStateDir(config.stateRoot)
|
|
627
1662
|
await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `flight_verify:${r.via}`).catch(() => {})
|
|
628
1663
|
const summary = r.verdict === 'observed'
|
|
@@ -637,108 +1672,395 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
637
1672
|
latency_ms: Date.now() - started,
|
|
638
1673
|
})) as Record<string, never>
|
|
639
1674
|
},
|
|
640
|
-
presentCall: args => ({ card: 'generic', title: `飞行校验:${
|
|
1675
|
+
presentCall: args => ({ card: 'generic', title: `飞行校验:${args.callsign ?? ''}`, kind: 'fetch', rawInput: args }),
|
|
641
1676
|
}))
|
|
642
1677
|
|
|
643
1678
|
registerGuarded(defineTool({
|
|
644
1679
|
name: 'gotry_flyai_search',
|
|
645
|
-
description:
|
|
646
|
-
'Live
|
|
647
|
-
+ '
|
|
648
|
-
+ '
|
|
1680
|
+
description: routed('gotry_flyai_search',
|
|
1681
|
+
'Live travel search through the Fliggy official FlyAI channel (read-only, no key; booking/comparison happens by the HUMAN on the jumpUrl page). '
|
|
1682
|
+
+ 'kind="flight"|"train": from/origin is required; to/destination and date/dateStart+dateEnd are optional for official exploration, and exact-date results split 直达/中转. '
|
|
1683
|
+
+ 'kind="hotel"|"marriott-hotel": to/destName=目的地中文, checkIn/checkOut (YYYY-MM-DD,成对可选——未定档期可不填先摸底). '
|
|
1684
|
+
+ 'kind="poi": cityName=城市; kind="keyword"|"ai": query; kind="marriott-package": keyword. '
|
|
1685
|
+
+ 'Hotel prices may be masked upstream (priceRaw like "¥7xx"): always present the mask as a range, and let the human open jumpUrl for the real price. '
|
|
1686
|
+
+ 'Evidence [实时API:flyai@ts]. verdict=needs-setup (anonymous trial quota exhausted, upstream 429) is a CONFIG issue, not a search failure: surface the setup hint once, do NOT retry this tool this session — switch to gotry_session_search or web search. '
|
|
1687
|
+
+ 'Errors (rate-limit Sentinel / invalid dates) degrade as structured errors with the upstream message — surface them, never guess.'),
|
|
1688
|
+
// D-30 第一刀(issue #112):query json blob → 平铺 typed 契约。内部 cliBin、
|
|
1689
|
+
// cliPrefixArgs、credentials、endpoint 不进入模型可见 schema;from/to/date 与
|
|
1690
|
+
// checkIn/checkOut 保留为兼容别名,adapter 的完整公共筛选面在同一平铺入口透传。
|
|
649
1691
|
parameters: {
|
|
650
|
-
|
|
1692
|
+
kind: { type: 'string', enum: [...FLYAI_TOOL_KINDS], required: true, description: '检索类型:flight/train/hotel/poi/keyword/ai/marriott-hotel/marriott-package' },
|
|
1693
|
+
from: { type: 'string', description: '兼容字段:机/火出发城市;等价于 origin' },
|
|
1694
|
+
to: { type: 'string', description: '兼容字段:机/火到达城市或酒店目的地;等价于 destination/destName' },
|
|
1695
|
+
date: { type: 'string', description: '兼容字段:机/火 exact-date YYYY-MM-DD;等价于 depDate' },
|
|
1696
|
+
origin: { type: 'string', description: '机/火出发城市或机场' },
|
|
1697
|
+
destination: { type: 'string', description: '机/火到达城市或机场' },
|
|
1698
|
+
depDate: { type: 'string', description: '机/火 exact-date YYYY-MM-DD' },
|
|
1699
|
+
dateStart: { type: 'string', description: '机/火日期范围起点 YYYY-MM-DD,与 dateEnd 成对' },
|
|
1700
|
+
dateEnd: { type: 'string', description: '机/火日期范围终点 YYYY-MM-DD,与 dateStart 成对' },
|
|
1701
|
+
depDateStart: { type: 'string', description: '机/火日期范围起点 YYYY-MM-DD' },
|
|
1702
|
+
depDateEnd: { type: 'string', description: '机/火日期范围终点 YYYY-MM-DD' },
|
|
1703
|
+
backDate: { type: 'string', description: '机票返程日期 YYYY-MM-DD' },
|
|
1704
|
+
journeyType: { type: 'integer', enum: [1, 2], description: '机票 1=直达,2=中转' },
|
|
1705
|
+
seatClassName: { type: 'string', description: '机/火舱位或坐席,逗号分隔' },
|
|
1706
|
+
transportNo: { type: 'string', description: '航班号或车次,逗号分隔' },
|
|
1707
|
+
transferCity: { type: 'string', description: '中转城市,逗号分隔' },
|
|
1708
|
+
depHourStart: { type: 'integer', description: '出发时段起点 0–23' },
|
|
1709
|
+
depHourEnd: { type: 'integer', description: '出发时段终点 0–23' },
|
|
1710
|
+
arrHourStart: { type: 'integer', description: '到达时段起点 0–23' },
|
|
1711
|
+
arrHourEnd: { type: 'integer', description: '到达时段终点 0–23' },
|
|
1712
|
+
totalDurationHour: { type: 'number', description: '机票总时长上限(小时)' },
|
|
1713
|
+
maxPrice: { type: 'number', description: '最高价格(CNY)' },
|
|
1714
|
+
sortType: { type: 'string', description: '机/火排序或万豪套餐 price_asc/price_desc' },
|
|
1715
|
+
destName: { type: 'string', description: '酒店/万豪酒店目的地' },
|
|
1716
|
+
checkIn: { type: 'string', description: '兼容字段:酒店入住日期 YYYY-MM-DD' },
|
|
1717
|
+
checkOut: { type: 'string', description: '兼容字段:酒店退房日期 YYYY-MM-DD' },
|
|
1718
|
+
checkInDate: { type: 'string', description: '酒店入住日期 YYYY-MM-DD' },
|
|
1719
|
+
checkOutDate: { type: 'string', description: '酒店退房日期 YYYY-MM-DD' },
|
|
1720
|
+
keyWords: { type: 'string', description: '酒店关键词;hotel/marriott-hotel 均支持' },
|
|
1721
|
+
poiName: { type: 'string', description: '酒店周边景点名' },
|
|
1722
|
+
hotelTypes: { type: 'string', description: '酒店类型,逗号分隔;仅 kind=hotel' },
|
|
1723
|
+
sort: { type: 'string', enum: ['distance_asc', 'rate_desc', 'price_asc', 'price_desc', 'no_rank'], description: '酒店排序' },
|
|
1724
|
+
hotelStars: { type: 'string', description: '酒店星级,1–5 逗号分隔;仅 kind=hotel' },
|
|
1725
|
+
hotelBedTypes: { type: 'string', description: '床型,逗号分隔' },
|
|
1726
|
+
cityName: { type: 'string', description: '景点所在城市;kind=poi 必填' },
|
|
1727
|
+
poiLevel: { type: 'integer', description: '景点等级 1–5' },
|
|
1728
|
+
keyword: { type: 'string', description: '景点关键词或万豪套餐单维度关键词' },
|
|
1729
|
+
category: { type: 'string', enum: [...FLYAI_POI_CATEGORIES], description: '景点官方闭集类别' },
|
|
1730
|
+
query: { type: 'string', description: 'keyword/ai 的查询词' },
|
|
1731
|
+
hotelBrands: { type: 'string', description: 'kind=marriott-hotel 的品牌关键词(与 hotelName 合并为官方 --key-words)' },
|
|
1732
|
+
hotelName: { type: 'string', description: '万豪酒店名;marriott-hotel 合并为 --key-words,marriott-package 使用 --hotel-name' },
|
|
1733
|
+
provinceOrCity: { type: 'string', description: '万豪套餐的省份或城市;与 keyword/hotelName 至少填写一项' },
|
|
1734
|
+
timeoutMs: { type: 'integer', description: '本次查询本地超时毫秒' },
|
|
651
1735
|
},
|
|
652
|
-
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text:
|
|
653
|
-
async execute(args
|
|
654
|
-
const
|
|
655
|
-
const
|
|
656
|
-
|
|
657
|
-
|
|
1736
|
+
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: JSON.stringify({ ...(v as Record<string, unknown>), raw: undefined }) }] },
|
|
1737
|
+
async execute(args, exec) {
|
|
1738
|
+
const raw = args as unknown as FlyaiToolArgs
|
|
1739
|
+
const signal = (exec as { signal?: AbortSignal } | null | undefined)?.signal
|
|
1740
|
+
const built = flyaiToolQuery(raw, signal)
|
|
1741
|
+
if (built.error || !built.query) {
|
|
1742
|
+
return JSON.parse(JSON.stringify({ ok: false, verdict: 'error', summary: `未发起 FlyAI 查询:${built.error ?? '参数无效'}`, evidence: '[校验:gotry_flyai_search] rejected' })) as Record<string, never>
|
|
658
1743
|
}
|
|
659
|
-
const
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
1744
|
+
const q = built.query
|
|
1745
|
+
const todayYmd = new Date().toISOString().slice(0, 10)
|
|
1746
|
+
if ((q.kind === 'flight' || q.kind === 'train') && q.depDate && /^\d{4}-\d{2}-\d{2}$/.test(q.depDate) && q.depDate < todayYmd) {
|
|
1747
|
+
return JSON.parse(JSON.stringify({ ok: false, verdict: 'error', kind: q.kind, summary: `未发起查询:日期 ${q.depDate} 已是过去(今天 ${todayYmd})。`, evidence: '[校验:gotry_flyai_search] past exact date' })) as Record<string, never>
|
|
1748
|
+
}
|
|
1749
|
+
if ((q.kind === 'hotel' || q.kind === 'marriott-hotel') && q.checkInDate && q.checkOutDate
|
|
1750
|
+
&& /^\d{4}-\d{2}-\d{2}$/.test(q.checkInDate) && /^\d{4}-\d{2}-\d{2}$/.test(q.checkOutDate)
|
|
1751
|
+
&& (q.checkInDate < todayYmd || q.checkOutDate <= q.checkInDate)) {
|
|
1752
|
+
return JSON.parse(JSON.stringify({ ok: false, verdict: 'error', kind: q.kind, summary: `未发起查询:入住 ${q.checkInDate} / 退房 ${q.checkOutDate} 不是未来合法区间。`, evidence: '[校验:gotry_flyai_search] invalid stay dates' })) as Record<string, never>
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
const itp = await runFlyaiEffect({ effect: 'FLYAI_SEARCH', params: q, signal })
|
|
1756
|
+
if (!itp.result) return declinedObservation('FLYAI_SEARCH', itp.trace)
|
|
1757
|
+
const r = itp.result as FlyaiResult
|
|
1758
|
+
await noteChannel('flyai', r.verdict)
|
|
1759
|
+
const fetchedAt = new Date().toISOString()
|
|
1760
|
+
if ((q.kind === 'flight' || q.kind === 'train') && q.origin && q.destination && q.depDate && !q.depDateStart && !q.depDateEnd) {
|
|
1761
|
+
const avMap = await loadAirlineAirportMap()
|
|
1762
|
+
await appendFacts(config.stateRoot ?? '.', factsFromFlyai({ kind: q.kind, origin: q.origin, destination: q.destination, date: q.depDate }, r, fetchedAt, avMap?.city_alias))
|
|
1763
|
+
} else if ((q.kind === 'hotel' || q.kind === 'marriott-hotel') && q.destName && q.checkInDate && q.checkOutDate) {
|
|
1764
|
+
await appendFacts(config.stateRoot ?? '.', factsFromHotel({
|
|
1765
|
+
source: q.kind === 'hotel' ? 'flyai-hotel' : 'flyai-marriott-hotel',
|
|
1766
|
+
destination: q.destName, checkIn: q.checkInDate, checkOut: q.checkOutDate,
|
|
1767
|
+
verdict: r.verdict, options: r.hotels?.length ?? 0, evidence: r.evidence, fetchedAt,
|
|
1768
|
+
}))
|
|
1769
|
+
}
|
|
1770
|
+
const intent = flyaiRoutingIntent(q.kind)
|
|
1771
|
+
const routing = r.verdict !== 'hit' && intent ? await routingField(intent, 'flyai') : {}
|
|
1772
|
+
return JSON.parse(JSON.stringify({ ...r, summary: flyaiToolSummary(q, r), ...routing })) as Record<string, never>
|
|
665
1773
|
},
|
|
666
|
-
presentCall: args => ({ card: 'generic', title: `官方检索:${
|
|
1774
|
+
presentCall: args => ({ card: 'generic', title: `官方检索:${args.kind}`, kind: 'fetch', rawInput: args }),
|
|
667
1775
|
presentResult: (args, value) => {
|
|
668
|
-
const
|
|
669
|
-
|
|
1776
|
+
const isHotel = args.kind === 'hotel'
|
|
1777
|
+
const r = value as { ok?: boolean; options?: unknown[]; hotels?: unknown[] }
|
|
1778
|
+
const n = Math.max((r.options ?? []).length, (r.hotels ?? []).length)
|
|
1779
|
+
return { card: 'generic', title: `${isHotel ? '飞猪酒店' : '飞猪检索'}:${r.ok && n > 0 ? `${n} 条` : '降级'}`, content: [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }] }
|
|
670
1780
|
},
|
|
671
1781
|
}))
|
|
672
1782
|
|
|
1783
|
+
registerGuarded(createFlyaiSetupTool((seams.effect ?? interpretEffect) as unknown as EffectInterpreter, config.stateRoot))
|
|
1784
|
+
|
|
673
1785
|
registerGuarded(defineTool({
|
|
674
|
-
name: '
|
|
1786
|
+
name: 'gotry_session_login',
|
|
675
1787
|
description:
|
|
676
|
-
'
|
|
677
|
-
+ '
|
|
678
|
-
+ '
|
|
679
|
-
+ '
|
|
680
|
-
+
|
|
1788
|
+
'Productized login bootstrap for the account session channel (call this when gotry_session_search returns needs-login — the user never needs a terminal). ' + 'AUTO-DETECTION FIRST: it reads ticket-cookie NAMES before anything else — if the user already logged in (on the external site) it confirms instantly WITHOUT opening any page. '
|
|
1789
|
+
+ 'OPENS the site login entry in the USER\'S OWN Chrome (foreground tab, left open for the user) and waits for the user to finish logging in on the external site. '
|
|
1790
|
+
+ 'GoTry NEVER collects, stores, or transmits credentials: no passwords, no SMS codes, no cookie values — it only checks the boolean fact "already logged in" (reads cookie NAMES only, zero values). '
|
|
1791
|
+
+ 'Transport: the GoTry Session Bridge browser extension (one-time install, ZERO Chrome system dialogs). '
|
|
1792
|
+
+ `verdict logged-in (tickets detected) | pending (login tab opened, user not done yet — offer to re-check later) | needs-extension (one-time extension install: the verdict surfaces the Chrome Web Store installUrl as a clickable link for dsh UI to render — installation is a browser concern, not gotry's). `
|
|
1793
|
+
+ 'Evidence [会话:<site>-login@ts].',
|
|
1794
|
+
// D-30 第三刀(issue #112):query blob → 平铺 typed;全字段可选 → interpretArgs 容忍层
|
|
1795
|
+
parameters: {
|
|
1796
|
+
waitSeconds: { type: 'integer', description: '等待用户完成登录的上限秒数,默认 90,至多 300;不传用默认' },
|
|
1797
|
+
},
|
|
1798
|
+
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String((v as { evidence?: string }).evidence ?? JSON.stringify(v).slice(0, 600)) }] },
|
|
1799
|
+
async execute(args, _exec: unknown) {
|
|
1800
|
+
const q = unwrapQuery<{ waitSeconds?: number }>(args, 'waitSeconds')
|
|
1801
|
+
// D-23 收编(issue #115):登录引导入效应注册表(SESSION_LOGIN,浏览器族永不重试不熔断)
|
|
1802
|
+
const itpL = await interpretEffect({ effect: 'SESSION_LOGIN', params: { waitSeconds: q?.waitSeconds } })
|
|
1803
|
+
if (!itpL.result) return declinedObservation('SESSION_LOGIN', itpL.trace)
|
|
1804
|
+
const r = itpL.result
|
|
1805
|
+
const summary = r.verdict === 'logged-in'
|
|
1806
|
+
? `${r.site} 登录完成确认(票据 cookie 名已检出,只读名字)。说明:登录是在携程官网、用你自己的浏览器完成的——gotry 全程未接触任何密码/验证码/cookie 值。现在可以继续会话检索了。${r.evidence}`
|
|
1807
|
+
: r.verdict === 'pending'
|
|
1808
|
+
? `登录入口已在你的 Chrome 打开;请在弹出的标签页里正常登录携程(登录由你在官网完成,不属于 gotry)。完成后说一声"继续",我再确认。gotry 只检查"是否已登录",永不收集你的账号信息。${r.evidence}`
|
|
1809
|
+
: r.verdict === 'needs-extension'
|
|
1810
|
+
? `需要一次性安装 GoTry Session Bridge 浏览器扩展:这是浏览器的事,gotry 不弹面板不开剪贴板——请直接在 Chrome 应用商店一键装(add-to-chrome 自动更新)。storeUrl=${EXTENSION_STORE_URL}. 装好即生效,装完后告诉我「重试」即可。${r.evidence}`
|
|
1811
|
+
: r.verdict === 'needs-attach'
|
|
1812
|
+
? `cdp 车道需要一次性开启你 Chrome 的远程调试开关:在你的 Chrome 地址栏打开 chrome://inspect/#remote-debugging 并打开开关,然后说一声"重试"(默认走扩展车道,无需此步)。${r.evidence}`
|
|
1813
|
+
: `登录引导未完成:${r.error ?? '未知原因'} ${r.evidence}`
|
|
1814
|
+
return JSON.parse(JSON.stringify({ ...r, summary })) as Record<string, never>
|
|
1815
|
+
},
|
|
1816
|
+
presentCall: _args => ({ card: 'generic', title: '登录携程(用你自己的浏览器,不在 gotry 输入)', kind: 'fetch', rawInput: {} }),
|
|
1817
|
+
presentResult: (_args, value) => {
|
|
1818
|
+
const r = value as { verdict?: string; tickets?: string[]; installUrl?: string }
|
|
1819
|
+
const needsExt = r.verdict === 'needs-extension'
|
|
1820
|
+
const label = r.verdict === 'logged-in'
|
|
1821
|
+
? `已登录(${(r.tickets ?? []).length} 票据)`
|
|
1822
|
+
: r.verdict === 'pending'
|
|
1823
|
+
? '等待你在携程页面完成登录'
|
|
1824
|
+
: needsExt
|
|
1825
|
+
? '🧩 需装 GoTry Session Bridge 扩展(浏览器商店一键装,自动更新)'
|
|
1826
|
+
: r.verdict ?? '降级'
|
|
1827
|
+
const content: Array<{ type: 'text'; text: string }> = [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }]
|
|
1828
|
+
// needs-extension:把商店 URL 渲成可点链接(浏览器自己当安装器,gotry 不插手)
|
|
1829
|
+
// dsh presentResult 若支持 actions 字段,URL 可被原生渲染;否则 content 里也保留文本兜底
|
|
1830
|
+
if (needsExt && r.installUrl) content.push({ type: 'text', text: `安装链接:${r.installUrl}` })
|
|
1831
|
+
return { card: 'generic', title: `账号登录:${label}`, content }
|
|
1832
|
+
},
|
|
1833
|
+
}))
|
|
1834
|
+
|
|
1835
|
+
registerGuarded(defineTool({
|
|
1836
|
+
name: 'gotry_session_search',
|
|
1837
|
+
description: routed('gotry_session_search',
|
|
1838
|
+
'Search on the USER\'S OWN browser session (Ctrip kind="flight"(default)/"hotel"; 12306 kind="train" — public query face, no login needed). '
|
|
1839
|
+
+ 'Consent gate: the FIRST call in a session asks the user via the runtime approval card; once granted it holds for the session, a refusal revokes it for the session (no repeat prompting). '
|
|
1840
|
+
+ 'Transport: GoTry Session Bridge browser extension (one-time install) — the agent side never talks to Chrome debugging, ZERO system dialogs; read-only by construction (the extension never issues requests; it only passively forwards the site\'s own search responses; agent NEVER touches credentials/captcha; on captcha it stops and returns challenged). '
|
|
1841
|
+
+ 'kind="flight": from/to 中文城市名 + date YYYY-MM-DD — sniffs the site search API for structured options. Evidence [会话:ctrip-flight@ts]. '
|
|
1842
|
+
+ 'kind="hotel": to=目的地中文, cityId? = the numeric city= in a hotels.ctrip.com list URL (web-search it when the destination is outside the built-in city table), checkIn?/checkOut? (YYYY-MM-DD), adults?; hotel prices are the user\'s real logged-in prices. Evidence [会话:ctrip-hotel@ts]. '
|
|
1843
|
+
+ 'kind="dida": Dida supplier-portal realtime hotel rates on the employee\'s own logged-in session (hotel-be portal integration line) — no from/to needed; the portal find page\'s own requests are sniffed passively. Rates carry ratePlanId/referenceNo for the server-side booking chain. Evidence [会话:dida-portal@ts]. '
|
|
1844
|
+
+ 'kind="train": from/to/date(YYYY-MM-DD) + fromStationTelecode?/toStationTelecode? (three-letter codes in the kyfw query URL, for cities outside the built-in table) — 12306 left-ticket query (public face): train codes, times, durations, seat availability; the list API carries NO prices (prices live on the 12306 page). Evidence [会话:train-12306@ts]. '
|
|
1845
|
+
+ 'verdict needs-login = call gotry_session_login (opens the Ctrip login entry in the user\'s own foreground tab — no terminal, no credentials through GoTry); '
|
|
1846
|
+
+ `needs-extension = one-time browser-extension install (Chrome Web Store one-click, installUrl is also surfaced as a clickable link in the verdict field for dsh UI to render) — the DEFAULT transport; cdp (chrome://inspect remote debugging) is a diagnostic fallback only via GOTRY_SESSION_TRANSPORT=cdp. `
|
|
1847
|
+
+ 'Rate-limited (≥30s between same-site calls; a challenged/timeout verdict means STOP — never retry, fall back to other tools).'),
|
|
1848
|
+
// D-30 第二刀(issue #112):query blob → 平铺 typed 契约(逐字段 schema 模型可见)。
|
|
1849
|
+
// 与 flyai 刀的差异:本工具三意图无公共 required 字段(kind 缺省=flight),根 schema 是
|
|
1850
|
+
// 隐式开放对象,宿主权无法用 required 拒 legacy blob——故保留 interpretArgs 容忍层
|
|
1851
|
+
// (docs/design/tool-orchestration-design.md §4③「interpretArgs 留作旧形态容忍层」),blob 调用在
|
|
1852
|
+
// execute 内归一后走原条件闸,结构化报错不崩。flyai 因 kind required 仍在宿主权即拒。
|
|
681
1853
|
parameters: {
|
|
682
|
-
|
|
1854
|
+
kind: { type: 'string', enum: ['flight', 'hotel', 'train', 'dida'], description: '默认 flight 机票;hotel 携程酒店(用户登录态真实价);train 12306 余票(公开面);dida 供应商门户实时价(员工登录态)' },
|
|
1855
|
+
from: { type: 'string', description: '出发城市中文(词表内),如 上海——kind=flight|train 必填' },
|
|
1856
|
+
to: { type: 'string', description: '到达城市中文——kind=flight|train 必填;kind=hotel 时为目的地(必填)' },
|
|
1857
|
+
date: { type: 'string', description: '出发日期 YYYY-MM-DD——kind=flight|train 必填,须为今天或未来' },
|
|
1858
|
+
cityId: { type: 'integer', description: '携程酒店 city= 数字,仅 kind=hotel;目的地码表外时 web 搜 hotels.ctrip.com list URL 取 city=' },
|
|
1859
|
+
checkIn: { type: 'string', description: '入住日 YYYY-MM-DD,仅 kind=hotel 可选' },
|
|
1860
|
+
checkOut: { type: 'string', description: '退房日 YYYY-MM-DD,仅 kind=hotel 可选' },
|
|
1861
|
+
adults: { type: 'integer', description: '成人数,仅 kind=hotel 可选' },
|
|
1862
|
+
fromStationTelecode: { type: 'string', description: '出发站三位电报码,仅 kind=train;城市码表外时填(kyfw 查询页 URL 里)' },
|
|
1863
|
+
toStationTelecode: { type: 'string', description: '到达站三位电报码,仅 kind=train,同上' },
|
|
683
1864
|
},
|
|
684
1865
|
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String((v as { summary?: string }).summary ?? JSON.stringify(v).slice(0, 600)) }] },
|
|
685
|
-
async execute(args
|
|
686
|
-
const
|
|
1866
|
+
async execute(args, _exec) {
|
|
1867
|
+
const selectedKind = resolveSessionSearchKind(args)
|
|
1868
|
+
if (!selectedKind.ok) return { ok: false, summary: selectedKind.reason } as const
|
|
1869
|
+
const q = { ...unwrapQuery<{ kind?: string; from?: string; to?: string; date?: string; cityId?: number | string; checkIn?: string; checkOut?: string; adults?: number; fromStationTelecode?: string; toStationTelecode?: string }>(args), kind: selectedKind.kind }
|
|
1870
|
+
const runSessionEffect = seams.effect ?? interpretEffect
|
|
1871
|
+
// ---- Dida 供应商门户(2026-09-09 实装;hotel-be portal integration 迁移线)----
|
|
1872
|
+
if (q.kind === 'dida') {
|
|
1873
|
+
const itpD = await runSessionEffect({
|
|
1874
|
+
effect: 'SESSION_DIDA_SEARCH',
|
|
1875
|
+
params: {
|
|
1876
|
+
auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
|
|
1877
|
+
},
|
|
1878
|
+
})
|
|
1879
|
+
if (!itpD.result) return declinedObservation('SESSION_DIDA_SEARCH', itpD.trace)
|
|
1880
|
+
const rD = itpD.result
|
|
1881
|
+
await noteChannel('session:dida-portal', rD.verdict)
|
|
1882
|
+
const topD = (rD.rates ?? []).slice(0, 8).map(x => `${x.hotelName ?? x.hotelId ?? '?'} ${x.roomName ?? ''} ${x.ratePlanId ?? ''} ${x.currency ?? ''}${x.price} 库存${x.inventory ?? '?'}${x.referenceNo ? ` ref=${x.referenceNo.slice(0, 12)}…` : ''}`)
|
|
1883
|
+
const summaryD = rD.verdict === 'hit'
|
|
1884
|
+
? `Dida 门户实时价(员工本人登录态,被动嗅探)前 ${topD.length} 条(预订由服务端适配器凭 referenceNo 续链,gotry 不碰下单):\n${topD.join('\n')}\n${rD.evidence}`
|
|
1885
|
+
: rD.verdict === 'cooldown'
|
|
1886
|
+
? `会话检索节律闸冷却中(两次会话检索需 ≥30s 间隔)——稍候重试或先用其他工具推进。${rD.evidence}`
|
|
1887
|
+
: rD.verdict === 'needs-login'
|
|
1888
|
+
? `${rD.error ?? ''} ${rD.evidence}`
|
|
1889
|
+
: `Dida 门户会话检索未取回(${rD.verdict}):${rD.error ?? ''} ${rD.evidence}`
|
|
1890
|
+
return JSON.parse(JSON.stringify({
|
|
1891
|
+
...rD, summary: summaryD,
|
|
1892
|
+
...(rD.verdict !== 'hit' ? await routingField('search-hotel', 'session:dida-portal') : {}),
|
|
1893
|
+
})) as Record<string, never>
|
|
1894
|
+
}
|
|
1895
|
+
// ---- 会话酒店(2026-09-03 实装;2026-09-02 迪拜 session:用户要携程找酒店,会话面却只有机票)----
|
|
1896
|
+
// ---- 会话火车(2026-09-03 实装;12306 公开查询面,无登录闸)----
|
|
1897
|
+
if (q.kind === 'train') {
|
|
1898
|
+
if (!q.from || !q.to || !q.date) {
|
|
1899
|
+
return { ok: false, summary: 'kind=train 需要 from/to(中文城市/车站名)与 date(YYYY-MM-DD);城市电报码表外带 fromStationTelecode/toStationTelecode(kyfw 查询页 URL 里的三位码)' } as const
|
|
1900
|
+
}
|
|
1901
|
+
const nowT = new Date()
|
|
1902
|
+
const todayT = `${nowT.getFullYear()}-${String(nowT.getMonth() + 1).padStart(2, '0')}-${String(nowT.getDate()).padStart(2, '0')}`
|
|
1903
|
+
if (q.date < todayT) {
|
|
1904
|
+
return JSON.parse(JSON.stringify({
|
|
1905
|
+
ok: false, verdict: 'error',
|
|
1906
|
+
summary: `未发起查询:日期 ${q.date} 已是过去(今天 ${todayT}),过去不存在在售火车票。向用户确认日期后再查。`,
|
|
1907
|
+
})) as Record<string, never>
|
|
1908
|
+
}
|
|
1909
|
+
const itpT = await runSessionEffect({
|
|
1910
|
+
effect: 'SESSION_TRAIN_SEARCH',
|
|
1911
|
+
params: {
|
|
1912
|
+
from: q.from, to: q.to, date: q.date,
|
|
1913
|
+
fromStationTelecode: q.fromStationTelecode, toStationTelecode: q.toStationTelecode,
|
|
1914
|
+
auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
|
|
1915
|
+
},
|
|
1916
|
+
})
|
|
1917
|
+
if (!itpT.result) return declinedObservation('SESSION_TRAIN_SEARCH', itpT.trace)
|
|
1918
|
+
const rT = itpT.result
|
|
1919
|
+
await noteChannel('session:12306-train', rT.verdict)
|
|
1920
|
+
// #355:only the invocation-produced binding may enter the fact log;
|
|
1921
|
+
// malformed/transport/challenge states remain no-fact, and Y alone is
|
|
1922
|
+
// not rendered as bookable without a recognized available seat token.
|
|
1923
|
+
await appendFacts(config.stateRoot ?? '.', factsFromSessionTrain({ from: q.from, to: q.to, date: q.date }, rT, clock()))
|
|
1924
|
+
const topT = (rT.trains ?? []).slice(0, 10).map(t => `${t.trainCode} ${t.depTime}→${t.arrTime} 历时${Math.round(t.durationMin / 60 * 10) / 10}h ${t.canWebBuy === 'Y' && hasRecognizedAvailableSeat(t) ? '可订' : '余票待确认'}${Object.entries(t.seats).filter(([, v]) => v && v !== '--' && v !== '无').slice(0, 3).map(([k, v]) => `${k}:${v}`).join(' ')}`)
|
|
1925
|
+
const summaryT = rT.verdict === 'hit'
|
|
1926
|
+
? `${q.from}→${q.to} ${q.date} 余票(12306 公开查询面,${(rT.trains ?? []).length} 趟)前 ${topT.length} 条(列表接口不含票价,票价以 12306 落地页为准):\n${topT.join('\n')}\n${rT.evidence}`
|
|
1927
|
+
: rT.verdict === 'cooldown'
|
|
1928
|
+
? `会话火车检索节律闸冷却中(两次会话检索需 ≥30s 间隔)——稍候重试或先用其他工具推进。${rT.evidence}`
|
|
1929
|
+
: `会话火车检索未取回(${rT.verdict}):${rT.error ?? ''} ${rT.evidence}`
|
|
1930
|
+
return JSON.parse(JSON.stringify({
|
|
1931
|
+
...rT, summary: summaryT,
|
|
1932
|
+
...(rT.verdict !== 'hit' ? await routingField('search-train', 'session:12306-train') : {}),
|
|
1933
|
+
})) as Record<string, never>
|
|
1934
|
+
}
|
|
1935
|
+
if (q.kind === 'hotel') {
|
|
1936
|
+
if (!q.to) {
|
|
1937
|
+
return { ok: false, summary: 'kind=hotel 需要 to(目的地中文;城市码表外带 cityId=携程酒店 list 页 URL 里的 city= 数字)' } as const
|
|
1938
|
+
}
|
|
1939
|
+
const itp = await runSessionEffect({
|
|
1940
|
+
effect: 'SESSION_HOTEL_SEARCH',
|
|
1941
|
+
params: {
|
|
1942
|
+
to: q.to, cityId: q.cityId, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults,
|
|
1943
|
+
auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
|
|
1944
|
+
},
|
|
1945
|
+
})
|
|
1946
|
+
if (!itp.result) return declinedObservation('SESSION_HOTEL_SEARCH', itp.trace)
|
|
1947
|
+
const r = itp.result
|
|
1948
|
+
await noteChannel('session:ctrip-hotel', r.verdict)
|
|
1949
|
+
// D-26(issue #118):exact-date 会话酒店 hit/miss 落账(摸底无档期不落;cooldown 是节律闸非结论,不落)
|
|
1950
|
+
if (r.verdict === 'hit' || r.verdict === 'miss') {
|
|
1951
|
+
await appendFacts(config.stateRoot ?? '.', factsFromHotel({
|
|
1952
|
+
source: 'session:ctrip-hotel', destination: q.to ?? '', checkIn: q.checkIn, checkOut: q.checkOut,
|
|
1953
|
+
verdict: r.verdict, options: r.hotels?.length ?? 0, evidence: r.evidence, fetchedAt: new Date().toISOString(),
|
|
1954
|
+
}))
|
|
1955
|
+
}
|
|
1956
|
+
const top = (r.hotels ?? []).slice(0, 8).map(h => `${h.name}${h.star ? `(${h.star}星)` : ''} ${h.priceRaw ?? (h.price > 0 ? `¥${h.price}` : '价待询')}${h.score ? ` 评分${h.score}` : ''}`)
|
|
1957
|
+
const summary = r.verdict === 'hit'
|
|
1958
|
+
? `${q.to} 酒店(携程,用户本人登录态,真实价)前 ${top.length} 家(预订以 jumpUrl 落地页为准):\n${top.join('\n')}\n${r.evidence}`
|
|
1959
|
+
: r.verdict === 'cooldown'
|
|
1960
|
+
? `会话酒店检索节律闸冷却中(两次会话检索需 ≥30s 间隔)——稍候重试或先用其他工具推进。${r.evidence}`
|
|
1961
|
+
: `会话酒店检索未取回(${r.verdict}):${r.error ?? ''} ${r.evidence}`
|
|
1962
|
+
return JSON.parse(JSON.stringify({
|
|
1963
|
+
...r, summary,
|
|
1964
|
+
...(r.verdict !== 'hit' ? await routingField('search-hotel', 'session:ctrip-hotel') : {}),
|
|
1965
|
+
})) as Record<string, never>
|
|
1966
|
+
}
|
|
687
1967
|
if (!q.from || !q.to || !q.date) {
|
|
688
|
-
return { ok: false, summary: '需要 from/to(中文城市名,词表内)与 date(YYYY-MM-DD)' } as const
|
|
1968
|
+
return { ok: false, summary: '需要 from/to(中文城市名,词表内)与 date(YYYY-MM-DD);酒店/火车检索分别用 kind=hotel / kind=train' } as const
|
|
689
1969
|
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
1970
|
+
// 效应解译层(ADR-18):SESSION 通道策略=永不重试/不熔断,节律闸在渠道内;
|
|
1971
|
+
// 解译器只做分发与证据拼装,verdict 语义(risk 型 needs-login/challenged)原样透传
|
|
1972
|
+
const itp = await runSessionEffect({
|
|
1973
|
+
effect: 'SESSION_FLIGHT_SEARCH',
|
|
1974
|
+
params: {
|
|
1975
|
+
from: q.from, to: q.to, date: q.date,
|
|
1976
|
+
// ADR-15 收尾:ReadGuard 审计在生产工具路径同样落盘(此前仅测试传隔离 stateRoot 才有 JSONL)
|
|
1977
|
+
auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
|
|
1978
|
+
},
|
|
694
1979
|
})
|
|
1980
|
+
if (!itp.result) return declinedObservation('SESSION_FLIGHT_SEARCH', itp.trace)
|
|
1981
|
+
const r = itp.result
|
|
1982
|
+
await noteChannel('session:ctrip-flight', r.verdict)
|
|
1983
|
+
// issue #46 事实落账(ADR-19):会话面 exact-date 结论同样进事实注册表(独立 source 并列标注)
|
|
1984
|
+
const avMapS = await loadAirlineAirportMap()
|
|
1985
|
+
await appendFacts(config.stateRoot ?? '.', factsFromSession({ origin: q.from, destination: q.to, date: q.date }, r, new Date().toISOString(), avMapS?.city_alias))
|
|
695
1986
|
const top = (r.options ?? []).slice(0, 8).map(o => `${o.flightNo} ${o.airline} ${o.depDateTime.slice(11, 16)}→${o.arrDateTime.slice(11, 16)} ¥${o.price}`)
|
|
696
1987
|
const summary = r.verdict === 'hit'
|
|
697
1988
|
? `${q.from}→${q.to} ${q.date} 会话检索(携程,用户本人登录态)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}`
|
|
698
1989
|
: `会话检索未取回(${r.verdict}):${r.error ?? ''} ${r.evidence}`
|
|
699
|
-
return JSON.parse(JSON.stringify({
|
|
1990
|
+
return JSON.parse(JSON.stringify({
|
|
1991
|
+
...r, summary,
|
|
1992
|
+
...(r.verdict !== 'hit' ? await routingField('search-flight', 'session:ctrip-flight') : {}),
|
|
1993
|
+
})) as Record<string, never>
|
|
700
1994
|
},
|
|
701
|
-
presentCall: args =>
|
|
702
|
-
|
|
703
|
-
const
|
|
704
|
-
const
|
|
705
|
-
|
|
1995
|
+
presentCall: args => {
|
|
1996
|
+
const selected = resolveSessionSearchKind(args)
|
|
1997
|
+
const kind = selected.ok ? selected.kind : undefined
|
|
1998
|
+
const raw = args as { to?: unknown; from?: unknown }
|
|
1999
|
+
const callTitle = kind === 'hotel' ? `会话酒店:${raw.to ?? ''}` : kind === 'train' ? `会话火车:${raw.from ?? ''}` : kind === 'dida' ? '会话酒店:Dida 门户实时价' : `会话检索:${raw.from ?? ''}`
|
|
2000
|
+
return { card: 'generic', title: callTitle, kind: 'fetch', rawInput: args }
|
|
2001
|
+
},
|
|
2002
|
+
presentResult: (args, value) => {
|
|
2003
|
+
const selected = resolveSessionSearchKind(args)
|
|
2004
|
+
const kind = selected.ok ? selected.kind : undefined
|
|
2005
|
+
const r = value as { verdict?: string; options?: unknown[]; hotels?: unknown[]; installUrl?: string }
|
|
2006
|
+
const isHotel = kind === 'hotel'
|
|
2007
|
+
const n = Math.max((r.options ?? []).length, (r.hotels ?? []).length)
|
|
2008
|
+
const needsExt = r.verdict === 'needs-extension'
|
|
2009
|
+
const label = r.verdict === 'hit'
|
|
2010
|
+
? `会话 ${n} 条`
|
|
2011
|
+
: r.verdict === 'needs-login'
|
|
2012
|
+
? '需登录'
|
|
2013
|
+
: needsExt
|
|
2014
|
+
? '🧩 需装 GoTry Session Bridge 扩展(浏览器商店一键装,自动更新)'
|
|
2015
|
+
: r.verdict ?? '降级'
|
|
2016
|
+
const content: Array<{ type: 'text'; text: string }> = [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }]
|
|
2017
|
+
if (needsExt && r.installUrl) content.push({ type: 'text', text: `安装链接:${r.installUrl}` })
|
|
2018
|
+
const face = kind === 'hotel' ? '会话酒店' : kind === 'train' ? '会话火车' : kind === 'dida' ? '会话酒店:Dida' : '会话检索'
|
|
2019
|
+
return { card: 'generic', title: `${face}:${label}`, content }
|
|
706
2020
|
},
|
|
707
2021
|
}))
|
|
708
2022
|
|
|
709
2023
|
registerGuarded(defineTool({
|
|
710
2024
|
name: 'gotry_anything_search',
|
|
711
|
-
description:
|
|
2025
|
+
description: routed('gotry_anything_search',
|
|
712
2026
|
'Travel-domain search via hotel-byte CLI → hotel-be Anything (cities/hotels/destinations) — NOT general web search; for general internet facts use gotry_agent_reach. ' +
|
|
713
2027
|
'Mixed destinations (cities / metropolitan areas / high-level regions) + hotels in one call. ' +
|
|
714
2028
|
'Returns candidates with type, name, optional coordinates and hotel-id. ' +
|
|
715
2029
|
'Three-valued semantics: hit = ≥1 candidate; miss = 0 candidates (try synonyms or contentType=city/hotel); ' +
|
|
716
2030
|
'unavailable = hbcli failed (degraded, never blocks). ' +
|
|
717
2031
|
'Use as the first stop when the user mentions a place/city/hotel name and you need to ground it in real catalog data ' +
|
|
718
|
-
'(OpenFlights skeleton tells you connectivity; Anything tells you what EXISTS at a city/region).',
|
|
2032
|
+
'(OpenFlights skeleton tells you connectivity; Anything tells you what EXISTS at a city/region). Independent read-only: safe in the same step as other independent searches; host abort is forwarded to the process tree.'),
|
|
2033
|
+
// 并发安全(issue #519):同 hotel_search,只读 + append-only 延迟日志 + 宿主取消信号。
|
|
2034
|
+
isConcurrencySafe: () => true,
|
|
2035
|
+
// D-30 第四刀(issue #112):平铺 typed;keyword required → 宿主权即拒
|
|
719
2036
|
parameters: {
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
},
|
|
2037
|
+
keyword: { type: 'string', required: true, description: '搜索关键词' },
|
|
2038
|
+
contentType: { type: 'string', enum: ['city', 'hotel'], description: '内容类型(缺省由上游推断)' },
|
|
2039
|
+
parentDestinationId: { type: 'string', description: '父目的地 id(上游形态)' },
|
|
2040
|
+
timeoutMs: { type: 'integer', description: '超时毫秒,默认 12000' },
|
|
725
2041
|
},
|
|
726
2042
|
output: {
|
|
727
2043
|
schema: { type: 'json' },
|
|
728
2044
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 800)) }],
|
|
729
2045
|
},
|
|
730
|
-
async execute(args
|
|
731
|
-
const q =
|
|
2046
|
+
async execute(args, _exec: unknown) {
|
|
2047
|
+
const q = args
|
|
2048
|
+
const rawQuery = String(q.keyword ?? '')
|
|
732
2049
|
const started = Date.now()
|
|
2050
|
+
const execSignal = (_exec as { signal?: AbortSignal } | undefined)?.signal
|
|
733
2051
|
if (!q.keyword) {
|
|
734
2052
|
return JSON.parse(JSON.stringify({ ok: false, verdict: 'error', summary: 'keyword 必填', evidence: '[hbcli-anything@error] empty' })) as Record<string, never>
|
|
735
2053
|
}
|
|
736
|
-
|
|
2054
|
+
// hbcliBin 直通 config(缺省 'hbcli',能力层按已知安装位回退)——issue #195 教训:
|
|
2055
|
+
// 此前 params 只透 q,config.hbcliBin 永远到不了能力层
|
|
2056
|
+
const itpA = await interpretEffect({ effect: 'ANYTHING_SEARCH', params: { ...q, hbcliBin: config.hbcliBin, signal: execSignal } })
|
|
2057
|
+
if (!itpA.result) return declinedObservation('ANYTHING_SEARCH', itpA.trace)
|
|
2058
|
+
const r = itpA.result
|
|
737
2059
|
const dir = await ensureStateDir(config.stateRoot)
|
|
738
2060
|
await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `anything:${r.via}`).catch(() => {})
|
|
739
2061
|
const top5 = (r.hits ?? []).slice(0, 5)
|
|
740
2062
|
const summary = r.verdict === 'hit'
|
|
741
|
-
? `${q.keyword} → hit (${r.hits?.length ?? 0} 候选项)\n${top5.map((h, i) => ` ${i + 1}. [${h.type}] ${h.name}${h.latitude !== undefined && h.longitude !== undefined ? ` @ (${h.latitude.toFixed(3)},${h.longitude.toFixed(3)})` : ''}`).join('\n')}\n${r.evidence}`
|
|
2063
|
+
? `${q.keyword} → hit (${r.hits?.length ?? 0} 候选项)\n${top5.map((h, i) => ` ${i + 1}. [${h.type}] ${h.name}${h.star ? ` ${h.star}★` : ''}${h.latitude !== undefined && h.longitude !== undefined ? ` @ (${h.latitude.toFixed(3)},${h.longitude.toFixed(3)})` : ''}${h.hotelId ? ` hotelId=${h.hotelId}` : h.destinationId ? ` destinationId=${h.destinationId}` : ''}`).join('\n')}\n${r.evidence}`
|
|
742
2064
|
: r.verdict === 'miss'
|
|
743
2065
|
? `${q.keyword} → miss (酒店-be 一切正常但无候选)\n${r.evidence}`
|
|
744
2066
|
: `${q.keyword} → unavailable (${r.error})\n${r.evidence}`
|
|
@@ -749,7 +2071,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
749
2071
|
latency_ms: Date.now() - started,
|
|
750
2072
|
})) as Record<string, never>
|
|
751
2073
|
},
|
|
752
|
-
presentCall: args => ({ card: 'generic', title: `Anything search:${
|
|
2074
|
+
presentCall: args => ({ card: 'generic', title: `Anything search:${args.keyword ?? ''}`, kind: 'search', rawInput: args }),
|
|
753
2075
|
presentResult: (_args, value) => {
|
|
754
2076
|
const r = value as { hits?: unknown[]; total_candidates?: number; verdict?: string; keyword?: string; summary?: string }
|
|
755
2077
|
const n = Array.isArray(r.hits) ? r.hits.length : (r.total_candidates ?? 0)
|
|
@@ -763,30 +2085,30 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
763
2085
|
|
|
764
2086
|
registerGuarded(defineTool({
|
|
765
2087
|
name: 'gotry_web_search',
|
|
766
|
-
description:
|
|
2088
|
+
description: routed('gotry_web_search',
|
|
767
2089
|
'Read any public URL as markdown (Jina Reader, free, no key). ' +
|
|
768
2090
|
'Use as the "last mile" web reader when hotel-be Anything or gotry tools lack the answer. ' +
|
|
769
2091
|
'NOT a general-purpose search engine — only fetches a URL you already know. ' +
|
|
770
2092
|
'Three-valued: ok / error(非法 URL/超时)/not-reachable(r.jina.ai 不可用).' +
|
|
771
|
-
'Contract with gotry capabilities/anything.ts: 同构(L4 证据链 + 降级不阻塞 + 三值)。',
|
|
2093
|
+
'Contract with gotry capabilities/anything.ts: 同构(L4 证据链 + 降级不阻塞 + 三值)。'),
|
|
2094
|
+
// D-30 第四刀(issue #112):平铺 typed;url required → 宿主权即拒(字符串 blob 形态退役)
|
|
772
2095
|
parameters: {
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
required: true,
|
|
776
|
-
description: '{ url: "https://example.com", timeoutMs?: 20000 }',
|
|
777
|
-
},
|
|
2096
|
+
url: { type: 'string', required: true, description: '完整 URL,https://…' },
|
|
2097
|
+
timeoutMs: { type: 'integer', description: '超时毫秒,默认 20000' },
|
|
778
2098
|
},
|
|
779
2099
|
output: {
|
|
780
2100
|
schema: { type: 'json' },
|
|
781
2101
|
render: (_args, value) => [{ type: 'text', text: String((value as { content?: string }).content?.slice(0, 800) ?? JSON.stringify(value).slice(0, 800)) }],
|
|
782
2102
|
},
|
|
783
|
-
async execute(args
|
|
784
|
-
const q =
|
|
2103
|
+
async execute(args, _exec: unknown) {
|
|
2104
|
+
const q = args
|
|
785
2105
|
const started = Date.now()
|
|
786
2106
|
if (!q.url) {
|
|
787
2107
|
return JSON.parse(JSON.stringify({ ok: false, summary: 'url 必填', evidence: '[agent-reach:error] empty url' })) as Record<string, never>
|
|
788
2108
|
}
|
|
789
|
-
const
|
|
2109
|
+
const itpW = await interpretEffect({ effect: 'WEB_READ', params: { url: q.url, timeoutMs: q.timeoutMs } })
|
|
2110
|
+
if (!itpW.result) return declinedObservation('WEB_READ', itpW.trace)
|
|
2111
|
+
const r = itpW.result
|
|
790
2112
|
const dir = await ensureStateDir(config.stateRoot)
|
|
791
2113
|
await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `agent-reach:${r.via}`).catch(() => {})
|
|
792
2114
|
const summary = r.ok
|
|
@@ -798,7 +2120,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
798
2120
|
latency_ms: Date.now() - started,
|
|
799
2121
|
})) as Record<string, never>
|
|
800
2122
|
},
|
|
801
|
-
presentCall: args => ({ card: 'generic', title: `读网页:${
|
|
2123
|
+
presentCall: args => ({ card: 'generic', title: `读网页:${args.url ?? ''}`, kind: 'fetch', rawInput: args }),
|
|
802
2124
|
}))
|
|
803
2125
|
|
|
804
2126
|
registerGuarded(defineTool({
|
|
@@ -808,23 +2130,23 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
808
2130
|
'If yt-dlp is installed on this machine, returns the subtitle text (vtt, zh-Hans/zh/en preference). ' +
|
|
809
2131
|
'If NOT installed, degrades gracefully with install instructions — never blocks. ' +
|
|
810
2132
|
'Evidence chain: [agent-reach:yt-dlp@ts] / [@not-installed@ts].',
|
|
2133
|
+
// D-30 第四刀(issue #112):平铺 typed;url required → 宿主权即拒
|
|
811
2134
|
parameters: {
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
required: true,
|
|
815
|
-
description: '{ url: "https://www.youtube.com/watch?v=...", lang?: "zh-Hans,zh,en" }',
|
|
816
|
-
},
|
|
2135
|
+
url: { type: 'string', required: true, description: '视频页 URL(youtube 等)' },
|
|
2136
|
+
lang: { type: 'string', description: '字幕语言优先级,如 zh-Hans,zh,en' },
|
|
817
2137
|
},
|
|
818
2138
|
output: {
|
|
819
2139
|
schema: { type: 'json' },
|
|
820
2140
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
|
|
821
2141
|
},
|
|
822
|
-
async execute(args
|
|
823
|
-
const q =
|
|
2142
|
+
async execute(args, _exec: unknown) {
|
|
2143
|
+
const q = args
|
|
824
2144
|
if (!q.url) {
|
|
825
2145
|
return JSON.parse(JSON.stringify({ ok: false, summary: 'url 必填' })) as Record<string, never>
|
|
826
2146
|
}
|
|
827
|
-
const
|
|
2147
|
+
const itpV = await interpretEffect({ effect: 'VIDEO_SUBTITLE', params: { url: q.url, lang: q.lang } })
|
|
2148
|
+
if (!itpV.result) return declinedObservation('VIDEO_SUBTITLE', itpV.trace)
|
|
2149
|
+
const r = itpV.result
|
|
828
2150
|
const summary = r.verdict === 'found'
|
|
829
2151
|
? `${q.url} 字幕提取成功 (${r.latencyMs}ms)\n${r.evidence}\n---\n${(r.subtitles ?? '').slice(0, 800)}`
|
|
830
2152
|
: r.verdict === 'not-installed'
|
|
@@ -836,7 +2158,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
836
2158
|
latency_ms: r.latencyMs,
|
|
837
2159
|
})) as Record<string, never>
|
|
838
2160
|
},
|
|
839
|
-
presentCall: args => ({ card: 'generic', title: `视频字幕:${
|
|
2161
|
+
presentCall: args => ({ card: 'generic', title: `视频字幕:${args.url ?? ''}`, kind: 'fetch', rawInput: args }),
|
|
840
2162
|
}))
|
|
841
2163
|
|
|
842
2164
|
registerGuarded(defineTool({
|
|
@@ -846,23 +2168,23 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
846
2168
|
'If gh is installed and authenticated, returns repos with name/description/stars/url. ' +
|
|
847
2169
|
'If NOT installed, degrades with install instructions — never blocks. ' +
|
|
848
2170
|
'Evidence chain: [agent-reach:gh@ts] / [@not-installed@ts].',
|
|
2171
|
+
// D-30 第四刀(issue #112):平铺 typed;query required → 宿主权即拒(顶层字段与旧内层同名,形态从对象变字符串)
|
|
849
2172
|
parameters: {
|
|
850
|
-
query: {
|
|
851
|
-
|
|
852
|
-
required: true,
|
|
853
|
-
description: '{ query: "agent-reach", limit?: 5 }',
|
|
854
|
-
},
|
|
2173
|
+
query: { type: 'string', required: true, description: 'GitHub 搜索关键词,如 agent-reach' },
|
|
2174
|
+
limit: { type: 'integer', description: '结果数上限,默认 5' },
|
|
855
2175
|
},
|
|
856
2176
|
output: {
|
|
857
2177
|
schema: { type: 'json' },
|
|
858
2178
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
|
|
859
2179
|
},
|
|
860
|
-
async execute(args
|
|
861
|
-
const q =
|
|
2180
|
+
async execute(args, _exec: unknown) {
|
|
2181
|
+
const q = args
|
|
862
2182
|
if (!q.query) {
|
|
863
2183
|
return JSON.parse(JSON.stringify({ ok: false, summary: 'query 必填' })) as Record<string, never>
|
|
864
2184
|
}
|
|
865
|
-
const
|
|
2185
|
+
const itpG = await interpretEffect({ effect: 'GITHUB_SEARCH', params: { query: q.query, limit: q.limit } })
|
|
2186
|
+
if (!itpG.result) return declinedObservation('GITHUB_SEARCH', itpG.trace)
|
|
2187
|
+
const r = itpG.result
|
|
866
2188
|
const summary = r.verdict === 'found'
|
|
867
2189
|
? `${q.query} → ${r.repos?.length ?? 0} repos\n${(r.repos ?? []).map((x, i) => ` ${i + 1}. ${x.name} ★${x.stars ?? '?'} — ${(x.description ?? '').slice(0, 60)}`).join('\n')}\n${r.evidence}`
|
|
868
2190
|
: r.verdict === 'not-installed'
|
|
@@ -874,7 +2196,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
874
2196
|
latency_ms: r.latencyMs,
|
|
875
2197
|
})) as Record<string, never>
|
|
876
2198
|
},
|
|
877
|
-
presentCall: args => ({ card: 'generic', title: `GitHub 搜索:${
|
|
2199
|
+
presentCall: args => ({ card: 'generic', title: `GitHub 搜索:${args.query ?? ''}`, kind: 'search', rawInput: args }),
|
|
878
2200
|
}))
|
|
879
2201
|
|
|
880
2202
|
registerGuarded(defineTool({
|
|
@@ -888,28 +2210,35 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
888
2210
|
'Action "status" runs the real `agent-reach doctor` (.venv/bin/agent-reach). ' +
|
|
889
2211
|
'Channels needing cookies/setup return the upstream check() guidance verbatim — do NOT give up there: hand the user the exact configure command, then offer to re-check. ' +
|
|
890
2212
|
'Evidence chain: [agent-reach:<channel>.<method>@ts].',
|
|
2213
|
+
// D-30 第三刀(issue #112):query blob → 平铺 typed;全字段可选 → interpretArgs 容忍层。
|
|
2214
|
+
// action 不设 enum:反射桥按 D-4a' 透传上游新增渠道/方法,gotry 零改动(生态开放面)
|
|
891
2215
|
parameters: {
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
},
|
|
2216
|
+
action: { type: 'string', description: 'status(默认,无 channel 时)或 reach' },
|
|
2217
|
+
channel: { type: 'string', description: '上游渠道名,如 web/v2ex/xueqiu(action=reach 时)' },
|
|
2218
|
+
method: { type: 'string', description: '上游方法名,如 read/get_hot_topics/get_stock_quote' },
|
|
2219
|
+
args: { type: 'string', description: '空格分隔参数' },
|
|
2220
|
+
timeoutMs: { type: 'integer', description: '超时毫秒' },
|
|
897
2221
|
},
|
|
898
2222
|
output: {
|
|
899
2223
|
schema: { type: 'json' },
|
|
900
2224
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 800)) }],
|
|
901
2225
|
},
|
|
902
|
-
async execute(args
|
|
903
|
-
const q = unwrapQuery<{ action?: string; channel?: string; method?: string; args?: string; timeoutMs?: number }>(args
|
|
2226
|
+
async execute(args, _exec: unknown) {
|
|
2227
|
+
const q = unwrapQuery<{ action?: string; channel?: string; method?: string; args?: string; timeoutMs?: number }>(args)
|
|
904
2228
|
const started = Date.now()
|
|
905
2229
|
const dir = await ensureStateDir(config.stateRoot)
|
|
906
2230
|
|
|
2231
|
+
// D-23 收编(issue #115):status/reach 两分支收敛为单一 AGENT_REACH 效应——
|
|
2232
|
+
// 解译器横切(trace/declined 面)统一,渲染逻辑留在工具层不变
|
|
2233
|
+
const itpAR = await interpretEffect({ effect: 'AGENT_REACH', params: { action: q.action, channel: q.channel, method: q.method, args: q.args, timeoutMs: q.timeoutMs } })
|
|
2234
|
+
if (!itpAR.result) return declinedObservation('AGENT_REACH', itpAR.trace)
|
|
2235
|
+
|
|
907
2236
|
if (q.action === 'status' || (!q.action && !q.channel)) {
|
|
908
|
-
const st =
|
|
2237
|
+
const st = itpAR.result as Awaited<ReturnType<typeof reachStatus>>
|
|
909
2238
|
await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, 'agent-reach:doctor').catch(() => {})
|
|
910
2239
|
const summary = st.via === 'agent-reach-cli'
|
|
911
2240
|
? `Agent Reach doctor(上游 CLI,原样透传):\n${st.output}\n${st.evidence}`
|
|
912
|
-
: `Agent Reach
|
|
2241
|
+
: `Agent Reach 未装配(.venv 缺失)——可选依赖,机/酒/网页社媒读取之外的工具不受影响。\n补装:终端跑 npx @danceiny/gotry doctor --fix;整体依赖状态可调 gotry_doctor 工具查看。\n${st.evidence}`
|
|
913
2242
|
return JSON.parse(JSON.stringify({
|
|
914
2243
|
ok: st.ok, via: st.via, output: st.output, evidence: st.evidence, summary,
|
|
915
2244
|
latency_ms: Date.now() - started,
|
|
@@ -919,7 +2248,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
919
2248
|
if (!q.channel || !q.method) {
|
|
920
2249
|
return JSON.parse(JSON.stringify({ ok: false, summary: 'channel 与 method 必填(或 action=status);清单可先随便调一次,inventory 会带回上游渠道/方法表' })) as Record<string, never>
|
|
921
2250
|
}
|
|
922
|
-
const r =
|
|
2251
|
+
const r = itpAR.result as Awaited<ReturnType<typeof reach>>
|
|
923
2252
|
// 长结果干净截断:数组按条目边界保留(dsh 工具上限会拦腰断 JSON,模型只能看到半条)
|
|
924
2253
|
const dataStr = (v: unknown): unknown => {
|
|
925
2254
|
if (Array.isArray(v)) {
|
|
@@ -944,7 +2273,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
944
2273
|
: r.verdict === 'needs-setup'
|
|
945
2274
|
? `${q.channel}.${q.method} → 需配置(上游 check() 原话): ${r.setup ?? ''}\n${r.evidence}`
|
|
946
2275
|
: r.verdict === 'not-installed'
|
|
947
|
-
? `${q.channel}.${q.method} →
|
|
2276
|
+
? `${q.channel}.${q.method} → 上游未装配(可选依赖): ${r.setup ?? ''}\n整体依赖状态可调 gotry_doctor 查看;补装: npx @danceiny/gotry doctor --fix\n${r.evidence}`
|
|
948
2277
|
: `${q.channel}.${q.method} → ${r.error ?? 'error'}${r.inventory ? `\n上游清单: ${JSON.stringify(r.inventory).slice(0, 1200)}` : ''}\n${r.evidence}`
|
|
949
2278
|
return JSON.parse(JSON.stringify({
|
|
950
2279
|
ok: r.ok, channel: r.channel, method: q.method, verdict: r.verdict,
|
|
@@ -953,10 +2282,10 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
953
2282
|
latency_ms: Date.now() - started,
|
|
954
2283
|
})) as Record<string, never>
|
|
955
2284
|
},
|
|
956
|
-
presentCall: args => ({ card: 'generic', title: `Agent Reach:${String(
|
|
2285
|
+
presentCall: args => ({ card: 'generic', title: `Agent Reach:${String(args.channel ?? '')}.${String(args.method ?? 'status')}`, kind: 'fetch', rawInput: args }),
|
|
957
2286
|
presentResult: (args, value) => {
|
|
958
2287
|
const r = value as { verdict?: string; summary?: string }
|
|
959
|
-
const q = (args
|
|
2288
|
+
const q = (args as { channel?: string; method?: string }) ?? {}
|
|
960
2289
|
const icon = r.verdict === 'found' ? '✅' : r.verdict === 'needs-setup' ? '🔧' : r.verdict === 'not-installed' ? '📦' : '❌'
|
|
961
2290
|
return {
|
|
962
2291
|
card: 'generic',
|
|
@@ -965,4 +2294,598 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
965
2294
|
}
|
|
966
2295
|
},
|
|
967
2296
|
}))
|
|
2297
|
+
|
|
2298
|
+
// ---- 依赖体检面(2026-09-02 迪拜 session 复盘:可选依赖未装配时,LLM 需要一个
|
|
2299
|
+
// 统一的状态入口来「显示状态 + 引导补装」,而不是在 not-installed 里各自摸索)----
|
|
2300
|
+
|
|
2301
|
+
registerGuarded(defineTool({
|
|
2302
|
+
name: 'gotry_doctor',
|
|
2303
|
+
description:
|
|
2304
|
+
'Check optional-dependency health for ALL gotry tools (read-only by default). Coverage: GoTry Session Bridge extension / Agent Reach (.venv) / hbcli (hotel realtime) / FlyAI key + recent trial-quota exhaustion time / dsh-calendar mount state / dsh-better-sidebar / dsh-map-tools / dsh-tool-ask-user. '
|
|
2305
|
+
+ 'Call this when ANY gotry tool returns not-installed / needs-setup, when the user asks 「体检/依赖状态/工具为什么不可用」, or BEFORE leaning on a channel for a plan. '
|
|
2306
|
+
+ 'Returns per-item status (ok/degraded/missing) with exact fix commands. '
|
|
2307
|
+
+ 'Action=diagnose (default): pure read-only — never installs anything, only renders the report. '
|
|
2308
|
+
+ 'Action=repair: optional autonomous repair — only attempts items the user named via `items` (stable doctor item ids, e.g. ["agent-reach","sidebar"]); empty/missing items means repair every auto-repairable gap. Items requiring browser-store install / credentials / API key / profile config / package reinstall / Node upgrade are NEVER auto-installed — they are reported as needs-user-action / unavailable and require the user to handle them. '
|
|
2309
|
+
+ 'Repair flow is observable: diagnosis → scope-keyed plan (selected auto-repairable items + skipped) → runtime approval card (per agent, per scope; same agent+scope reuses approval, different scope re-asks) → execute installer → recheck → per-item installed/failed with retry command. '
|
|
2310
|
+
+ 'Rejected/cancelled = no execution; unavailable approval channel = no execution (clear structured outcome). LLM keys are the dsh host\'s business and are deliberately out of scope. '
|
|
2311
|
+
+ 'A markdown report is rendered for the workspace (gotry-state/doctor-report.md) so the sidebar workbench can preview it.',
|
|
2312
|
+
// D-30 第三刀(issue #112):query blob → 平铺 typed;全字段可选 → interpretArgs 容忍层
|
|
2313
|
+
// issue #284 增量:action + items 两参数;默认 diagnose 保持读-only 不变(向后兼容)
|
|
2314
|
+
parameters: {
|
|
2315
|
+
action: { type: 'string', enum: ['diagnose', 'repair'], description: '默认 diagnose;repair 时按 items 范围补装可选依赖,user-action / unavailable 项永不冒充 auto' },
|
|
2316
|
+
items: { type: 'array', items: { type: 'string', enum: ['node', 'extension', 'agent-reach', 'hbcli', 'flyai', 'sidebar', 'llm-key', 'calendar', 'map-tools', 'ask-user'] }, description: 'repair 时的 doctor item id 范围;空 = 全部 auto-repairable' },
|
|
2317
|
+
writeReport: { type: 'boolean', description: '默认 true,把 markdown 报告写进 gotry-state/doctor-report.md(侧栏工作台可预览)' },
|
|
2318
|
+
},
|
|
2319
|
+
output: {
|
|
2320
|
+
schema: { type: 'json' },
|
|
2321
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 900)) }],
|
|
2322
|
+
},
|
|
2323
|
+
async execute(args, _exec: unknown) {
|
|
2324
|
+
const q = unwrapQuery<{ action?: 'diagnose' | 'repair'; items?: string[]; writeReport?: boolean }>(args, 'action')
|
|
2325
|
+
const action = q?.action === 'repair' ? 'repair' : 'diagnose'
|
|
2326
|
+
const check = seams.doctor?.check ?? runDoctorChecks
|
|
2327
|
+
const report = await check({ stateRoot: config.stateRoot ?? '.' })
|
|
2328
|
+
|
|
2329
|
+
// repair 时必须把复检后的报告写盘,避免侧栏仍显示修复前状态。
|
|
2330
|
+
let reportPath: string | undefined
|
|
2331
|
+
const writeReport = async (current: typeof report): Promise<void> => {
|
|
2332
|
+
if (q?.writeReport === false) return
|
|
2333
|
+
try {
|
|
2334
|
+
const { writeFile } = await import('node:fs/promises')
|
|
2335
|
+
const dir = await ensureStateDir(config.stateRoot)
|
|
2336
|
+
reportPath = join(dir, 'doctor-report.md')
|
|
2337
|
+
await writeFile(reportPath, renderDoctorReportMd(current), 'utf-8')
|
|
2338
|
+
} catch { reportPath = undefined }
|
|
2339
|
+
}
|
|
2340
|
+
const icon = (s: string) => (s === 'ok' ? '✅' : s === 'degraded' ? '⚠️' : '❌')
|
|
2341
|
+
const reportLines = (current: typeof report) => current.items.map(i => `${icon(i.status)} ${i.label}:${i.detail}${i.fix ? `\n ↳ 修复: ${i.fix}` : ''}`)
|
|
2342
|
+
|
|
2343
|
+
if (action === 'diagnose') {
|
|
2344
|
+
await writeReport(report)
|
|
2345
|
+
const diagSummary = `${report.summary}\n${reportLines(report).join('\n')}${reportPath ? `\n报告已写: ${reportPath}(侧栏工作台可直接预览)` : '\n(报告写盘失败,仅本对话展示)'}`
|
|
2346
|
+
// 读-only 路径:保持向后兼容,模型拿到的 verdict/items 与 issue #284 之前一致
|
|
2347
|
+
return JSON.parse(JSON.stringify({
|
|
2348
|
+
ok: true,
|
|
2349
|
+
action: 'diagnose',
|
|
2350
|
+
verdict: report.ok ? 'all-clear' : 'needs-attention',
|
|
2351
|
+
items: report.items,
|
|
2352
|
+
report_path: reportPath,
|
|
2353
|
+
evidence: `[doctor@${new Date().toISOString()}]`,
|
|
2354
|
+
summary: diagSummary,
|
|
2355
|
+
})) as Record<string, never>
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
// action='repair':从诊断 → 选定 auto-repairable → scope-keyed 审批 → 真执行(走 bootstrap.runOnboardingFix)→ 复检。
|
|
2359
|
+
// 范围过滤:items 非空时只考虑这些 id(未知 id 静默剔除,落 skipped 不冒充 auto)。
|
|
2360
|
+
// runDoctorRepair 内部懒加载 bin/gotry-bootstrap.js,自适应 status→level 适配 + 真实安装器。
|
|
2361
|
+
const requestedItems = Array.isArray(q?.items) && q!.items!.length > 0 ? new Set(q!.items!) : null
|
|
2362
|
+
const scopedReport = requestedItems
|
|
2363
|
+
? { ...report, items: report.items.filter((i) => requestedItems.has(i.id)) }
|
|
2364
|
+
: report
|
|
2365
|
+
const exec = _exec as { agent?: object; callId?: string; signal?: AbortSignal } | undefined
|
|
2366
|
+
// 生产 repair 让 bootstrap 安装编排复用本工具的同一诊断注册表做终态复检;
|
|
2367
|
+
// fixture 若显式提供 recheck,则保留其隔离实现,再单独取一次工具报告。
|
|
2368
|
+
let recheck: typeof report | undefined
|
|
2369
|
+
const injectedRepair = seams.doctor?.repair
|
|
2370
|
+
const repairOptions: DoctorRepairOptions = {
|
|
2371
|
+
...injectedRepair,
|
|
2372
|
+
recheck: injectedRepair?.recheck ?? (async () => {
|
|
2373
|
+
recheck = await check({ stateRoot: config.stateRoot ?? '.' })
|
|
2374
|
+
return recheck.items.map((item) => ({
|
|
2375
|
+
label: item.label,
|
|
2376
|
+
level: item.status,
|
|
2377
|
+
detail: item.detail,
|
|
2378
|
+
fix: item.fix,
|
|
2379
|
+
}))
|
|
2380
|
+
}),
|
|
2381
|
+
}
|
|
2382
|
+
const repair = await runDoctorRepair(
|
|
2383
|
+
scopedReport,
|
|
2384
|
+
(scopeKey, reason) => repairApprovalGate.request(exec?.agent, scopeKey, reason, { callId: exec?.callId, signal: exec?.signal }),
|
|
2385
|
+
repairOptions,
|
|
2386
|
+
)
|
|
2387
|
+
if (repair.approval === 'granted' && !recheck) {
|
|
2388
|
+
recheck = await check({ stateRoot: config.stateRoot ?? '.' })
|
|
2389
|
+
}
|
|
2390
|
+
const finalReport = recheck ?? report
|
|
2391
|
+
await writeReport(finalReport)
|
|
2392
|
+
const diagSummary = `${report.summary}\n${reportLines(report).join('\n')}`
|
|
2393
|
+
// 整合诊断 + 修复双视角 summary(模型可分清哪段是体检、哪段是修复)
|
|
2394
|
+
const repairLines: string[] = []
|
|
2395
|
+
for (const r of repair.repairs) {
|
|
2396
|
+
repairLines.push(`${r.status === 'installed' ? '✅' : '❌'} ${r.label}: ${r.reason}${r.nextAction ? ` → ${r.nextAction}` : ''}`)
|
|
2397
|
+
}
|
|
2398
|
+
for (const s of repair.skipped) {
|
|
2399
|
+
repairLines.push(`⏭ ${s.label}: ${s.reason}(bucket=${s.bucket})`)
|
|
2400
|
+
}
|
|
2401
|
+
const recheckSummary = recheck
|
|
2402
|
+
? `\n\n[recheck] ${recheck.summary}\n${reportLines(recheck).join('\n')}`
|
|
2403
|
+
: ''
|
|
2404
|
+
const reportSummary = reportPath ? `\n报告已写: ${reportPath}(侧栏工作台可直接预览)` : '\n(报告写盘失败,仅本对话展示)'
|
|
2405
|
+
const combinedSummary = `${diagSummary}\n\n[repair] verdict=${repair.verdict}, approval=${repair.approval}, plan=${repair.selected.length} 项 auto / ${repair.skipped.length} 项 skipped\n${repairLines.join('\n')}\n${repair.summary}${recheckSummary}${reportSummary}`
|
|
2406
|
+
return JSON.parse(JSON.stringify({
|
|
2407
|
+
ok: repair.ok,
|
|
2408
|
+
action: 'repair',
|
|
2409
|
+
verdict: repair.verdict,
|
|
2410
|
+
diagnosis: report,
|
|
2411
|
+
recheck,
|
|
2412
|
+
plan: {
|
|
2413
|
+
selected: repair.selected,
|
|
2414
|
+
needs_user_action: repair.skipped.filter((s) => s.bucket === 'user-action').map((s) => ({ id: s.id, label: s.label, reason: s.reason })),
|
|
2415
|
+
unavailable: repair.skipped.filter((s) => s.bucket === 'unavailable').map((s) => ({ id: s.id, label: s.label, reason: s.reason })),
|
|
2416
|
+
scope_key: repair.scopeKey,
|
|
2417
|
+
},
|
|
2418
|
+
approval: repair.approval,
|
|
2419
|
+
repairs: repair.repairs,
|
|
2420
|
+
skipped: repair.skipped,
|
|
2421
|
+
evidence: `[doctor@${new Date().toISOString()}]`,
|
|
2422
|
+
summary: combinedSummary,
|
|
2423
|
+
report_path: reportPath,
|
|
2424
|
+
})) as Record<string, never>
|
|
2425
|
+
},
|
|
2426
|
+
presentCall: args => {
|
|
2427
|
+
const q = unwrapQuery<{ action?: string }>(args, 'action')
|
|
2428
|
+
const a = q?.action === 'repair'
|
|
2429
|
+
return { card: 'generic', title: a ? '🩺 依赖体检+修复(待批准)' : '🩺 依赖体检', kind: 'execute', rawInput: args }
|
|
2430
|
+
},
|
|
2431
|
+
presentResult: (_args, value) => {
|
|
2432
|
+
const r = value as { verdict?: string; summary?: string; action?: string; approval?: string }
|
|
2433
|
+
const deniedLabel = r.approval === 'cancelled'
|
|
2434
|
+
? '↩️ 已取消'
|
|
2435
|
+
: r.approval === 'unavailable'
|
|
2436
|
+
? '⚠️ 无审批通道'
|
|
2437
|
+
: '🚫 已拒绝'
|
|
2438
|
+
const title = r.action === 'repair'
|
|
2439
|
+
? `🩺 修复:${r.verdict === 'repaired' ? '✅ 已就位' : r.verdict === 'partial-repair' ? '⚠️ 部分成功' : r.verdict === 'repair-blocked' ? '❌ 复检未通过' : r.verdict === 'approval-denied' ? deniedLabel : '🔧 无可补装项'}`
|
|
2440
|
+
: `🩺 依赖体检:${r.verdict === 'all-clear' ? '✅ 全部就绪' : '🔧 有项待处理'}`
|
|
2441
|
+
return {
|
|
2442
|
+
card: 'generic',
|
|
2443
|
+
title,
|
|
2444
|
+
content: [{ type: 'text', text: String(r.summary ?? '') }],
|
|
2445
|
+
}
|
|
2446
|
+
},
|
|
2447
|
+
}))
|
|
2448
|
+
|
|
2449
|
+
// ---- 产物面(issue #25):agent 生成的文件可在 dsh 内发现与阅读,不再只是本地文件名 ----
|
|
2450
|
+
|
|
2451
|
+
registerGuarded(defineTool({
|
|
2452
|
+
name: 'gotry_artifacts_list',
|
|
2453
|
+
description:
|
|
2454
|
+
'List GoTry artifacts — deep-planning deliverables (async runs from the state ledger) plus agent-written markdown/HTML itinerary files ' +
|
|
2455
|
+
'in the working directory (trip plans as .md, .html or .htm). READ-ONLY discovery. ' +
|
|
2456
|
+
'Opening a listed .html/.htm goes through the host native HTML preview, which the client labels as an HTML preview because its scripts may run; gotry_artifacts_read is the source-text path. ' +
|
|
2457
|
+
'Generated itinerary HTML documents (gotry_itinerary_render) land in the same working-directory view. ' +
|
|
2458
|
+
'Use when the user asks to see/open/revisit a previously generated artifact ' +
|
|
2459
|
+
'(「看看刚才生成的行程」「上次的规划在哪」「打开那个 md/html」) — list first, then read with gotry_artifacts_read. ' +
|
|
2460
|
+
'Optional `offset` (zero-based nonnegative integer) and `search` (literal, case-insensitive substring over id/title/filename; ' +
|
|
2461
|
+
'trim then empty = no filter; no regex/glob semantics, body content is never searched) let you page past the default 20 / cap 50 ' +
|
|
2462
|
+
'to enumerate every eligible artifact and find an older one by name. `total` is the post-filter total; `nextOffset` is present ' +
|
|
2463
|
+
'only when another page exists; an offset past the end returns an empty page with the known `total` and no `nextOffset`. ' +
|
|
2464
|
+
'The list is not a stable snapshot across filesystem mutations.',
|
|
2465
|
+
// D-30 第三刀(issue #112):query blob → 平铺 typed;全字段可选 → interpretArgs 容忍层
|
|
2466
|
+
parameters: {
|
|
2467
|
+
limit: { type: 'integer', description: '最多返回条数,默认 20,上限 50' },
|
|
2468
|
+
offset: { type: 'integer', description: '零-based 非负整数,跳过前 N 条;越界返回空页 + 准确 total + 无 nextOffset' },
|
|
2469
|
+
search: { type: 'string', description: '字面大小写无关子串(匹配 id/title/filename);trim 后空 = 不过滤;非正则/非 glob,不搜内容' },
|
|
2470
|
+
},
|
|
2471
|
+
output: {
|
|
2472
|
+
schema: { type: 'json' },
|
|
2473
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
|
|
2474
|
+
presentationMeta: (_args, value) => {
|
|
2475
|
+
const r = value as { artifacts?: Array<{ path?: string }>; total?: number; truncated?: boolean; offset?: number; limit?: number }
|
|
2476
|
+
const paths = Array.isArray(r.artifacts)
|
|
2477
|
+
? r.artifacts.map(a => String(a.path ?? '')).filter(Boolean)
|
|
2478
|
+
: []
|
|
2479
|
+
return {
|
|
2480
|
+
shape: 'paths',
|
|
2481
|
+
paths,
|
|
2482
|
+
truncated: Boolean(r.truncated),
|
|
2483
|
+
total: r.total ?? paths.length,
|
|
2484
|
+
}
|
|
2485
|
+
},
|
|
2486
|
+
},
|
|
2487
|
+
async execute(args, exec: unknown) {
|
|
2488
|
+
const q = unwrapQuery<{ limit?: number; offset?: number; search?: string }>(args, 'limit')
|
|
2489
|
+
let r: Awaited<ReturnType<typeof listArtifacts>>
|
|
2490
|
+
try {
|
|
2491
|
+
r = await listArtifacts({
|
|
2492
|
+
stateRoot: config.stateRoot ?? '.',
|
|
2493
|
+
cwd: sessionCwd(exec),
|
|
2494
|
+
limit: q.limit,
|
|
2495
|
+
offset: q.offset,
|
|
2496
|
+
search: q.search,
|
|
2497
|
+
})
|
|
2498
|
+
} catch (err) {
|
|
2499
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
2500
|
+
return JSON.parse(JSON.stringify({ ok: false, error: msg, summary: msg, hint: 'limit 必须是有限整数(零/负夹到 1,大值封顶 50);offset 必须是非负安全整数(≤ Number.MAX_SAFE_INTEGER);search 必须是字符串(trim 后空 = 不过滤,非字符串 predictably 拒绝)' })) as Record<string, never>
|
|
2501
|
+
}
|
|
2502
|
+
const lines = r.artifacts.map(a =>
|
|
2503
|
+
`- [${a.source}] ${a.title}${a.status ? `(${a.status})` : ''} — ${a.path}${a.updated ? ` @ ${a.updated.slice(0, 16).replace('T', ' ')}` : ''}`)
|
|
2504
|
+
// Echo the requested offset (post-validation), not the clamped page start,
|
|
2505
|
+
// so a caller that asked for an offset past the end can tell.
|
|
2506
|
+
const requestedOffset = q.offset ?? 0
|
|
2507
|
+
const position = r.artifacts.length > 0
|
|
2508
|
+
? `${requestedOffset + 1}-${requestedOffset + r.artifacts.length}`
|
|
2509
|
+
: '无'
|
|
2510
|
+
const sizeInfo = `第 ${position}/${r.total} 项(limit=${r.limit})`
|
|
2511
|
+
const searchInfo = r.search ? `,搜索「${r.search}」` : ''
|
|
2512
|
+
const summary = r.artifacts.length
|
|
2513
|
+
? `在册产物 ${r.artifacts.length}/${r.total} 项${r.truncated ? `(后续页 offset=${r.nextOffset})` : ''}${searchInfo},${sizeInfo}:\n${lines.join('\n')}`
|
|
2514
|
+
: r.total > 0
|
|
2515
|
+
// Beyond the end with a known total — never claim "no artifacts".
|
|
2516
|
+
? `空页(${requestedOffset + 1} 起越界;总在册 ${r.total} 项,本次 limit=${r.limit})${searchInfo},${sizeInfo}`
|
|
2517
|
+
: `无在册产物(异步深度规划交付与工作目录 md/html 文件都会出现在这里)${searchInfo ? `(搜索「${r.search}」无匹配)` : ''}`
|
|
2518
|
+
return JSON.parse(JSON.stringify({
|
|
2519
|
+
ok: true,
|
|
2520
|
+
artifacts: r.artifacts,
|
|
2521
|
+
total: r.total,
|
|
2522
|
+
truncated: r.truncated,
|
|
2523
|
+
offset: requestedOffset,
|
|
2524
|
+
limit: r.limit,
|
|
2525
|
+
...(r.nextOffset !== undefined ? { nextOffset: r.nextOffset } : {}),
|
|
2526
|
+
...(r.search !== undefined ? { search: r.search } : {}),
|
|
2527
|
+
summary,
|
|
2528
|
+
})) as Record<string, never>
|
|
2529
|
+
},
|
|
2530
|
+
// Host presentationMeta remains persisted with the ToolResult for Host-side
|
|
2531
|
+
// consumers; the packaged public Client separately renders the runtime block
|
|
2532
|
+
// for these custom wire names in DSH Web.
|
|
2533
|
+
presentResult: (_args, result: ToolResult) => {
|
|
2534
|
+
if (result.isError || typeof result.meta !== 'object' || result.meta === null || Array.isArray(result.meta)) return undefined
|
|
2535
|
+
const meta = result.meta as { shape?: unknown; paths?: unknown; truncated?: unknown; total?: unknown }
|
|
2536
|
+
if (meta.shape !== 'paths' || !Array.isArray(meta.paths) || !meta.paths.every(p => typeof p === 'string') ||
|
|
2537
|
+
typeof meta.truncated !== 'boolean' || typeof meta.total !== 'number' || !Number.isInteger(meta.total) || meta.total < 0) {
|
|
2538
|
+
return undefined
|
|
2539
|
+
}
|
|
2540
|
+
return {
|
|
2541
|
+
card: 'search' as const,
|
|
2542
|
+
shape: 'paths' as const,
|
|
2543
|
+
title: `产物:${meta.total} 项在册${meta.truncated ? '(已截断)' : ''}`,
|
|
2544
|
+
paths: meta.paths,
|
|
2545
|
+
truncated: meta.truncated,
|
|
2546
|
+
total: meta.total,
|
|
2547
|
+
}
|
|
2548
|
+
},
|
|
2549
|
+
}))
|
|
2550
|
+
|
|
2551
|
+
registerGuarded(defineTool({
|
|
2552
|
+
name: 'gotry_artifacts_read',
|
|
2553
|
+
description:
|
|
2554
|
+
'Read one GoTry artifact as a line-numbered file view rendered directly in the chat UI. ' +
|
|
2555
|
+
'Input: the path from gotry_artifacts_list, or a bare async ticket id (e.g. dp-xxxx). ' +
|
|
2556
|
+
'Optional offset (1-based) / limit window for paging large files. ' +
|
|
2557
|
+
'READ-ONLY; text artifacts only (md/txt/json/jsonl/csv/log/yaml/html/htm). ' +
|
|
2558
|
+
'HTML/HTM is returned as source text by this tool only — it does not parse markup, run scripts or inline event handlers, or fetch anything; opening a listed HTML file is a separate action the client labels as an interactive HTML preview.',
|
|
2559
|
+
// D-30 第三刀(issue #112):query blob → 平铺 typed;path required → 宿主权入口拒畸形参数(同 flyai/hotel 刀法)
|
|
2560
|
+
parameters: {
|
|
2561
|
+
path: { type: 'string', required: true, description: 'list 返回的路径或工单 id' },
|
|
2562
|
+
offset: { type: 'integer', description: '起始行号(1 起)' },
|
|
2563
|
+
limit: { type: 'integer', description: '窗口行数,默认 400' },
|
|
2564
|
+
},
|
|
2565
|
+
output: {
|
|
2566
|
+
schema: { type: 'json' },
|
|
2567
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { content?: string }).content ?? JSON.stringify(value).slice(0, 600)) }],
|
|
2568
|
+
presentationMeta: (_args, value) => {
|
|
2569
|
+
const r = value as { path?: string; offset?: number; lines?: Array<{ number: number; text: string }>; totalLines?: number; lang?: string; source?: string; version?: string }
|
|
2570
|
+
return {
|
|
2571
|
+
path: String(r.path ?? ''),
|
|
2572
|
+
offset: r.offset ?? 1,
|
|
2573
|
+
lines: Array.isArray(r.lines) ? r.lines.map(line => ({ number: line.number, text: line.text })) : [],
|
|
2574
|
+
totalLines: r.totalLines ?? 0,
|
|
2575
|
+
...(r.lang ? { lang: r.lang } : {}),
|
|
2576
|
+
...(r.source ? { source: r.source } : {}),
|
|
2577
|
+
...(r.version ? { version: r.version } : {}),
|
|
2578
|
+
}
|
|
2579
|
+
},
|
|
2580
|
+
},
|
|
2581
|
+
async execute(args, exec) {
|
|
2582
|
+
const q = args
|
|
2583
|
+
if (!q.path) throw new Error('gotry_artifacts_read requires path')
|
|
2584
|
+
if (!q.path) return JSON.parse(JSON.stringify({ ok: false, error: 'path 必填(来自 gotry_artifacts_list)' })) as Record<string, never>
|
|
2585
|
+
const r = await readArtifact({ stateRoot: config.stateRoot ?? '.', cwd: sessionCwd(exec), path: q.path, offset: q.offset, limit: q.limit })
|
|
2586
|
+
if (!r.ok) return JSON.parse(JSON.stringify(r)) as Record<string, never>
|
|
2587
|
+
// 身份/来源展示(issue #285 第 1 条「显示身份/来源」):行号视图保留 read 卡的
|
|
2588
|
+
// path/offset/lines/totalLines/lang;fallback content 用 source label 前缀,
|
|
2589
|
+
// 让无法渲染 read 卡的 UI 也能区分「本会话选了哪个产物」,避免把旧摘要当新内容。
|
|
2590
|
+
return JSON.parse(JSON.stringify({
|
|
2591
|
+
...r,
|
|
2592
|
+
source: r.source,
|
|
2593
|
+
summary: `${r.path}(${r.totalLines} 行)第 ${r.offset}-${r.offset + r.lines.length - 1} 行${r.windowed ? `(共 ${r.totalLines} 行,可翻页)` : ''}`,
|
|
2594
|
+
})) as Record<string, never>
|
|
2595
|
+
},
|
|
2596
|
+
// Keep the standard location payload for Host consumers. The Web client only
|
|
2597
|
+
// renders its built-in keyed tools, so this is not claimed as native custom-card E2E.
|
|
2598
|
+
presentCall: args => ({
|
|
2599
|
+
card: 'generic',
|
|
2600
|
+
title: `读产物:${args.path ?? ''}`,
|
|
2601
|
+
kind: 'read',
|
|
2602
|
+
rawInput: args,
|
|
2603
|
+
...(args.path ? { locations: [{ path: String(args.path), line: 1 }] } : {}),
|
|
2604
|
+
}),
|
|
2605
|
+
presentResult: (_args, result: ToolResult) => {
|
|
2606
|
+
if (result.isError || typeof result.meta !== 'object' || result.meta === null || Array.isArray(result.meta)) return undefined
|
|
2607
|
+
const meta = result.meta as { path?: unknown; source?: unknown; offset?: unknown; lines?: unknown; totalLines?: unknown; lang?: unknown }
|
|
2608
|
+
if (typeof meta.path !== 'string' || meta.path === '' || typeof meta.offset !== 'number' || !Number.isInteger(meta.offset) || meta.offset < 1 ||
|
|
2609
|
+
!Array.isArray(meta.lines) || !meta.lines.every(line => typeof line === 'object' && line !== null && !Array.isArray(line) &&
|
|
2610
|
+
typeof (line as { number?: unknown }).number === 'number' && Number.isInteger((line as { number: number }).number) &&
|
|
2611
|
+
typeof (line as { text?: unknown }).text === 'string') ||
|
|
2612
|
+
typeof meta.totalLines !== 'number' || !Number.isInteger(meta.totalLines) || meta.totalLines < 0) {
|
|
2613
|
+
return undefined
|
|
2614
|
+
}
|
|
2615
|
+
const source = typeof meta.source === 'string' ? meta.source : 'unknown'
|
|
2616
|
+
const identityLine = `source: ${source} · path: ${meta.path} · ${meta.totalLines} lines`
|
|
2617
|
+
const rawContent = result.content
|
|
2618
|
+
.filter(block => block.type === 'text')
|
|
2619
|
+
.map(block => block.text)
|
|
2620
|
+
.join('\n')
|
|
2621
|
+
return {
|
|
2622
|
+
card: 'read' as const,
|
|
2623
|
+
title: meta.path.split('/').pop() ?? meta.path,
|
|
2624
|
+
path: meta.path,
|
|
2625
|
+
offset: meta.offset,
|
|
2626
|
+
lines: meta.lines as Array<{ number: number; text: string }>,
|
|
2627
|
+
totalLines: meta.totalLines,
|
|
2628
|
+
...(typeof meta.lang === 'string' ? { lang: meta.lang } : {}),
|
|
2629
|
+
content: [
|
|
2630
|
+
{ type: 'text', text: identityLine },
|
|
2631
|
+
{ type: 'text', text: rawContent },
|
|
2632
|
+
],
|
|
2633
|
+
}
|
|
2634
|
+
},
|
|
2635
|
+
}))
|
|
2636
|
+
|
|
2637
|
+
// ---- 行程 HTML 产物生成(issue #442,父 #438):显式行程 + 注册表事实 → 会话工作目录内的新文件 ----
|
|
2638
|
+
// 只新建(独占 O_EXCL,绝不覆盖);事实只从 config.stateRoot 注册表按 id 取,
|
|
2639
|
+
// 不收调用方自带事实对象;空 fact_ids = 明确未核验计划(无整体「已验证」结论)。
|
|
2640
|
+
|
|
2641
|
+
registerGuarded(defineTool({
|
|
2642
|
+
name: 'gotry_itinerary_render',
|
|
2643
|
+
description:
|
|
2644
|
+
'Generate a local, self-contained HTML itinerary document as a NEW file in the session working directory (never overwrites; '
|
|
2645
|
+
+ 'the file can then be listed/read with gotry_artifacts_list / gotry_artifacts_read). '
|
|
2646
|
+
+ 'Input: title, the explicit itinerary object { trip_start, trip_end, stays:[{place,check_in,check_out}], '
|
|
2647
|
+
+ 'od_segments:[{from,to,date,mode,legs}] } (same shape as gotry_fact_gate itinerary; nights/budget fields are not accepted), '
|
|
2648
|
+
+ 'and fact_ids — the ids of already-recorded bookable facts to project as evidence. Facts are loaded ONLY from the session fact '
|
|
2649
|
+
+ 'registry (stateRoot bookable-facts log): caller-supplied fact objects are never accepted, and unknown/duplicate/over-limit ids are rejected. '
|
|
2650
|
+
+ 'An empty fact_ids array is legitimate and renders an explicitly unverified plan — this document never carries an overall "verified" badge, '
|
|
2651
|
+
+ 'only per-fact bookability/tier/source labels. No Markdown-to-facts guessing, no date/night/budget arithmetic, no upstream queries. '
|
|
2652
|
+
+ 'Optional basename must match gotry-itinerary-<ASCII token>.html; when omitted a random name is generated. '
|
|
2653
|
+
+ 'The result returns the final absolute path.',
|
|
2654
|
+
parameters: {
|
|
2655
|
+
title: { type: 'string', required: true, description: '文档标题(作为页面标题与 h1,原样转义)' },
|
|
2656
|
+
itinerary: { type: 'object', additionalProperties: true, required: true, description: '结构化行程 { trip_start, trip_end, stays:[{place,check_in,check_out}], od_segments:[{from,to,date,mode,legs}] }; 不做夜数/预算/时间运算' },
|
|
2657
|
+
fact_ids: { type: 'array', items: { type: 'string' }, required: true, description: '要投影的事实 id 数组(来自本会话 exact-date 工具结果登记的注册表);必填,空数组 = 明确未核验计划' },
|
|
2658
|
+
basename: { type: 'string', description: '可选文件名,必须形如 gotry-itinerary-<ASCII token>.html;不传则用 crypto 随机名' },
|
|
2659
|
+
},
|
|
2660
|
+
output: {
|
|
2661
|
+
schema: { type: 'json' },
|
|
2662
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 800)) }],
|
|
2663
|
+
},
|
|
2664
|
+
async execute(args, exec) {
|
|
2665
|
+
const q = args as { title?: unknown; itinerary?: unknown; fact_ids?: unknown; basename?: unknown }
|
|
2666
|
+
// 写产物不允许猜测落点:宿主必须给出显式绝对会话工作目录,否则 fail closed
|
|
2667
|
+
//(绝不回落 process.cwd()——那会把产物写进 Node 进程的偶然目录)
|
|
2668
|
+
const cwd = sessionCwd(exec)
|
|
2669
|
+
if (typeof cwd !== 'string' || cwd.trim() === '' || !isAbsolute(cwd)) {
|
|
2670
|
+
const error = typeof cwd !== 'string' || cwd.trim() === ''
|
|
2671
|
+
? '会话工作目录缺失:拒绝在未知目录写产物'
|
|
2672
|
+
: `会话工作目录必须是绝对路径:${cwd}`
|
|
2673
|
+
return JSON.parse(JSON.stringify({
|
|
2674
|
+
ok: false,
|
|
2675
|
+
error,
|
|
2676
|
+
hint: '产物只写到宿主会话的显式绝对工作目录(dsh 会话 header.cwd);本工具不猜测落点',
|
|
2677
|
+
})) as Record<string, never>
|
|
2678
|
+
}
|
|
2679
|
+
const r = await generateItineraryArtifact(
|
|
2680
|
+
{ title: q.title, itinerary: q.itinerary, fact_ids: q.fact_ids, basename: q.basename },
|
|
2681
|
+
{ stateRoot: config.stateRoot ?? '.', cwd },
|
|
2682
|
+
)
|
|
2683
|
+
const summary = r.ok
|
|
2684
|
+
? `已生成行程 HTML 产物(未写入任何整体「已验证」结论):${r.path}\n`
|
|
2685
|
+
+ `投影事实 ${r.fact_ids.length} 条(全部来自当前注册表),文件 ${r.bytes} 字节。`
|
|
2686
|
+
+ `${r.fact_ids.length === 0 ? '本次为空事实列表:文档明确为未核验计划。' : ''}`
|
|
2687
|
+
+ '可用 gotry_artifacts_list / gotry_artifacts_read 再次查看。'
|
|
2688
|
+
: `未生成产物(未写入任何文件):${r.error}`
|
|
2689
|
+
+ `${r.errors?.length ? `\n${r.errors.slice(0, 8).map(e => `- ${e}`).join('\n')}` : ''}`
|
|
2690
|
+
+ `${r.hint ? `\n修正提示:${r.hint}` : ''}`
|
|
2691
|
+
return JSON.parse(JSON.stringify({ ...r, summary })) as Record<string, never>
|
|
2692
|
+
},
|
|
2693
|
+
presentCall: args => ({ card: 'generic', title: `生成行程 HTML:${String(args.title ?? '')}`.slice(0, 80), kind: 'execute', rawInput: args }),
|
|
2694
|
+
presentResult: (_args, value) => {
|
|
2695
|
+
const r = value as { ok?: boolean; path?: string; bytes?: number; error?: string }
|
|
2696
|
+
return {
|
|
2697
|
+
card: 'generic',
|
|
2698
|
+
title: r.ok ? `行程 HTML 已生成:${String(r.path ?? '').split('/').pop() ?? ''}` : '行程 HTML 未生成',
|
|
2699
|
+
content: [{ type: 'text', text: r.ok ? `最终路径:${String(r.path ?? '')}(${r.bytes ?? 0} 字节)` : String(r.error ?? '') }],
|
|
2700
|
+
}
|
|
2701
|
+
},
|
|
2702
|
+
}))
|
|
2703
|
+
|
|
2704
|
+
registerGuarded(defineTool({
|
|
2705
|
+
name: 'gotry_itinerary_deck_render',
|
|
2706
|
+
description:
|
|
2707
|
+
'Generate a local, self-contained HTML itinerary DECK (slide-form, CSS scroll-snap paging, deterministic slide derivation) as a NEW file in the session working directory (never overwrites; '
|
|
2708
|
+
+ 'the file can then be listed/read with gotry_artifacts_list / gotry_artifacts_read). '
|
|
2709
|
+
+ 'Input: title, the explicit itinerary object { trip_start, trip_end, stays:[{place,check_in,check_out}], '
|
|
2710
|
+
+ 'od_segments:[{from,to,date,mode,legs}] } (same shape as gotry_itinerary_render; nights/budget fields are not accepted), '
|
|
2711
|
+
+ 'and fact_ids — the ids of already-recorded bookable facts to project as evidence. Facts are loaded ONLY from the session fact '
|
|
2712
|
+
+ 'registry (stateRoot bookable-facts log): caller-supplied fact objects are never accepted, and unknown/duplicate/over-limit ids are rejected. '
|
|
2713
|
+
+ 'An empty fact_ids array is legitimate and renders an explicitly unverified plan — this deck never carries an overall "verified" badge, '
|
|
2714
|
+
+ 'only per-fact bookability/tier/source labels. No Markdown-to-facts guessing, no date/night/budget arithmetic, no upstream queries. '
|
|
2715
|
+
+ 'Optional basename must match gotry-deck-<ASCII token>.html; when omitted a random name is generated. '
|
|
2716
|
+
+ 'The result returns the final absolute path.',
|
|
2717
|
+
parameters: {
|
|
2718
|
+
title: { type: 'string', required: true, description: 'deck 标题(作为封面 h1,原样转义)' },
|
|
2719
|
+
itinerary: { type: 'object', additionalProperties: true, required: true, description: '结构化行程 { trip_start, trip_end, stays:[{place,check_in,check_out}], od_segments:[{from,to,date,mode,legs}] }; 不做夜数/预算/时间运算' },
|
|
2720
|
+
fact_ids: { type: 'array', items: { type: 'string' }, required: true, description: '要投影的事实 id 数组(来自本会话 exact-date 工具结果登记的注册表);必填,空数组 = 明确未核验计划' },
|
|
2721
|
+
basename: { type: 'string', description: '可选文件名,必须形如 gotry-deck-<ASCII token>.html;不传则用 crypto 随机名' },
|
|
2722
|
+
},
|
|
2723
|
+
output: {
|
|
2724
|
+
schema: { type: 'json' },
|
|
2725
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 800)) }],
|
|
2726
|
+
},
|
|
2727
|
+
async execute(args, exec) {
|
|
2728
|
+
const q = args as { title?: unknown; itinerary?: unknown; fact_ids?: unknown; basename?: unknown }
|
|
2729
|
+
// 写产物不允许猜测落点:宿主必须给出显式绝对会话工作目录,否则 fail closed
|
|
2730
|
+
//(绝不回落 process.cwd()——那会把产物写进 Node 进程的偶然目录)
|
|
2731
|
+
const cwd = sessionCwd(exec)
|
|
2732
|
+
if (typeof cwd !== 'string' || cwd.trim() === '' || !isAbsolute(cwd)) {
|
|
2733
|
+
const error = typeof cwd !== 'string' || cwd.trim() === ''
|
|
2734
|
+
? '会话工作目录缺失:拒绝在未知目录写产物'
|
|
2735
|
+
: `会话工作目录必须是绝对路径:${cwd}`
|
|
2736
|
+
return JSON.parse(JSON.stringify({
|
|
2737
|
+
ok: false,
|
|
2738
|
+
error,
|
|
2739
|
+
hint: '产物只写到宿主会话的显式绝对工作目录(dsh 会话 header.cwd);本工具不猜测落点',
|
|
2740
|
+
})) as Record<string, never>
|
|
2741
|
+
}
|
|
2742
|
+
const r = await generateItineraryDeckArtifact(
|
|
2743
|
+
{ title: q.title, itinerary: q.itinerary, fact_ids: q.fact_ids, basename: q.basename },
|
|
2744
|
+
{ stateRoot: config.stateRoot ?? '.', cwd },
|
|
2745
|
+
)
|
|
2746
|
+
const summary = r.ok
|
|
2747
|
+
? `已生成行程 deck 产物(未写入任何整体「已验证」结论):${r.path}\n`
|
|
2748
|
+
+ `投影事实 ${r.fact_ids.length} 条(全部来自当前注册表),文件 ${r.bytes} 字节。`
|
|
2749
|
+
+ `${r.fact_ids.length === 0 ? '本次为空事实列表:deck 明确为未核验计划。' : ''}`
|
|
2750
|
+
+ '可用 gotry_artifacts_list / gotry_artifacts_read 再次查看。'
|
|
2751
|
+
: `未生成产物(未写入任何文件):${r.error}`
|
|
2752
|
+
+ `${r.errors?.length ? `\n${r.errors.slice(0, 8).map(e => `- ${e}`).join('\n')}` : ''}`
|
|
2753
|
+
+ `${r.hint ? `\n修正提示:${r.hint}` : ''}`
|
|
2754
|
+
return JSON.parse(JSON.stringify({ ...r, summary })) as Record<string, never>
|
|
2755
|
+
},
|
|
2756
|
+
presentCall: args => ({ card: 'generic', title: `生成行程 deck:${String(args.title ?? '')}`.slice(0, 80), kind: 'execute', rawInput: args }),
|
|
2757
|
+
presentResult: (_args, value) => {
|
|
2758
|
+
const r = value as { ok?: boolean; path?: string; bytes?: number; error?: string }
|
|
2759
|
+
return {
|
|
2760
|
+
card: 'generic',
|
|
2761
|
+
title: r.ok ? `行程 deck 已生成:${String(r.path ?? '').split('/').pop() ?? ''}` : '行程 deck 未生成',
|
|
2762
|
+
content: [{ type: 'text', text: r.ok ? `最终路径:${String(r.path ?? '')}(${r.bytes ?? 0} 字节)` : String(r.error ?? '') }],
|
|
2763
|
+
}
|
|
2764
|
+
},
|
|
2765
|
+
}))
|
|
2766
|
+
registerGuarded(defineTool({
|
|
2767
|
+
name: 'gotry_deck_export',
|
|
2768
|
+
description:
|
|
2769
|
+
'Export a local, self-contained HTML itinerary DECK as a shareable static bundle (NEW directory contents; never overwrites) into a host-provided target directory. '
|
|
2770
|
+
+ 'Writes three files at the target_dir top level, all basename-prefixed so multiple bundles can coexist in the same dir: '
|
|
2771
|
+
+ '`<basename>.html` (the rendered deck), `<basename>.manifest.json` (provenance: sha256, bytes, fact counts by kind, source tags, evidence-chain summary, exported_at, optional target_url), '
|
|
2772
|
+
+ 'and `<basename>.qr.svg` (real QR matrix — issue #569, encoding the target_url when provided, or a `local bundle; not yet hosted` marker when omitted). '
|
|
2773
|
+
+ 'Input: title, the explicit itinerary object { trip_start, trip_end, stays:[{place,check_in,check_out}], od_segments:[{from,to,date,mode,legs}] } (same shape as gotry_itinerary_render; nights/budget fields are not accepted), '
|
|
2774
|
+
+ 'fact_ids (loaded ONLY from the session fact registry), optional basename (default = random hex), optional target_url (recorded in manifest, used by QR when slice 3b lands). '
|
|
2775
|
+
+ 'target_dir must be an absolute directory path provided by the host (no fallback to process cwd). All three files use O_CREAT|O_EXCL — any pre-existing file at the target slot rejects the call with zero bytes written.',
|
|
2776
|
+
parameters: {
|
|
2777
|
+
title: { type: 'string', required: true, description: 'deck 标题(原样转义)' },
|
|
2778
|
+
itinerary: { type: 'object', additionalProperties: true, required: true, description: '结构化行程 { trip_start, trip_end, stays:[{place,check_in,check_out}], od_segments:[{from,to,date,mode,legs}] }; 不做夜数/预算/时间运算' },
|
|
2779
|
+
fact_ids: { type: 'array', items: { type: 'string' }, required: true, description: '事实 id 数组(来自本会话 exact-date 工具结果登记的注册表)' },
|
|
2780
|
+
basename: { type: 'string', description: '可选 bundle stem,默认随机 hex;决定三件文件名(<basename>.html / manifest.json / qr.svg)' },
|
|
2781
|
+
target_url: { type: 'string', description: '可选:目标托管 URL,写入 manifest;切片 3b QR 编码将引用此字段' },
|
|
2782
|
+
},
|
|
2783
|
+
output: {
|
|
2784
|
+
schema: { type: 'json' },
|
|
2785
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 800)) }],
|
|
2786
|
+
},
|
|
2787
|
+
async execute(args, exec) {
|
|
2788
|
+
const q = args as { title?: unknown; itinerary?: unknown; fact_ids?: unknown; basename?: unknown; target_url?: unknown }
|
|
2789
|
+
// 写产物不允许猜测落点:宿主必须给出显式绝对 target_dir,否则 fail closed
|
|
2790
|
+
const targetDir = sessionCwd(exec)
|
|
2791
|
+
if (typeof targetDir !== 'string' || targetDir.trim() === '' || !isAbsolute(targetDir)) {
|
|
2792
|
+
const error = typeof targetDir !== 'string' || targetDir.trim() === ''
|
|
2793
|
+
? 'target_dir 缺失:拒绝在未知目录写产物'
|
|
2794
|
+
: `target_dir 必须是绝对路径:${targetDir}`
|
|
2795
|
+
return JSON.parse(JSON.stringify({
|
|
2796
|
+
ok: false,
|
|
2797
|
+
error,
|
|
2798
|
+
hint: '产物只写到宿主会话的显式绝对工作目录(dsh 会话 header.cwd);本工具不猜测落点',
|
|
2799
|
+
})) as Record<string, never>
|
|
2800
|
+
}
|
|
2801
|
+
const r = await generateItineraryDeckExport(
|
|
2802
|
+
{ title: q.title, itinerary: q.itinerary, fact_ids: q.fact_ids, basename: q.basename, target_url: q.target_url },
|
|
2803
|
+
{ stateRoot: config.stateRoot ?? '.', targetDir },
|
|
2804
|
+
)
|
|
2805
|
+
const summary = r.ok
|
|
2806
|
+
? `已生成 deck 静态导出 bundle(target_dir):${r.target_dir}\n`
|
|
2807
|
+
+ `三件文件:html=${r.files.html}\nmanifest=${r.files.manifest}\nqr.svg=${r.files.qr}(真矩阵,#569 切片 3b 落地;target_url 见 manifest.target_url)\n`
|
|
2808
|
+
+ `总字节 ${r.bytes},投影事实 ${r.fact_ids.length} 条(全部来自当前注册表)。`
|
|
2809
|
+
+ '可直接上传到任意静态托管(Vercel/Netlify/GH Pages)。'
|
|
2810
|
+
: `未生成产物(未写入任何文件):${r.error}`
|
|
2811
|
+
+ `${r.errors?.length ? `\n${r.errors.slice(0, 8).map(e => `- ${e}`).join('\n')}` : ''}`
|
|
2812
|
+
+ `${r.hint ? `\n修正提示:${r.hint}` : ''}`
|
|
2813
|
+
return JSON.parse(JSON.stringify({ ...r, summary })) as Record<string, never>
|
|
2814
|
+
},
|
|
2815
|
+
presentCall: args => ({ card: 'generic', title: `导出 deck bundle:${String(args.title ?? '')}`.slice(0, 80), kind: 'execute', rawInput: args }),
|
|
2816
|
+
presentResult: (_args, value) => {
|
|
2817
|
+
const r = value as { ok?: boolean; target_dir?: string; files?: { html?: string; manifest?: string; qr?: string }; error?: string }
|
|
2818
|
+
return {
|
|
2819
|
+
card: 'generic',
|
|
2820
|
+
title: r.ok ? `deck bundle 已导出:${String(r.target_dir ?? '')}` : 'deck bundle 未导出',
|
|
2821
|
+
content: [{ type: 'text', text: r.ok ? `三件文件:${r.files?.html ?? ''} / ${r.files?.manifest ?? ''} / ${r.files?.qr ?? ''}` : String(r.error ?? '') }],
|
|
2822
|
+
}
|
|
2823
|
+
},
|
|
2824
|
+
}))
|
|
2825
|
+
|
|
2826
|
+
// ---- 产物事实闸(issue #46,ADR-19):含可下单事实的产物交付前必过 ----
|
|
2827
|
+
// 每个航班号/时刻/机场/价格/政策断言必须回溯到本会话 exact-date 工具结果
|
|
2828
|
+
// (bookable-facts 侧车,query_id 可重放);无法回溯即 blocked——不得宣称「已验证方案」。
|
|
2829
|
+
|
|
2830
|
+
registerGuarded(defineTool({
|
|
2831
|
+
name: 'gotry_fact_gate',
|
|
2832
|
+
description:
|
|
2833
|
+
'Factuality gate for itinerary artifacts (issue #46): call BEFORE delivering any artifact that contains bookable facts '
|
|
2834
|
+
+ '(flight numbers, times, airports, prices, visa/entry policies). Every bookable claim in the markdown is checked against '
|
|
2835
|
+
+ 'the session fact registry (exact-date tool results recorded by gotry_flyai_search / gotry_session_search — hit AND miss). '
|
|
2836
|
+
+ 'Blocked when: a claimed flight is absent from the exact-date source (never backfill from route pages/history/adjacent dates); '
|
|
2837
|
+
+ 'route+date never queried; times contradict the snapshot; carrier→airport mapping conflicts (FD=DMK vs VZ=BKK are NOT interchangeable); '
|
|
2838
|
+
+ '「联程」without protected_connection=true; policy claims without 「截至 YYYY-MM-DD」; unconditional ✓ on unverified claims. '
|
|
2839
|
+
+ 'Optional itinerary object enables machine invariants (hotel_nights + onboard_nights = total nights, O&D segments vs flight legs, '
|
|
2840
|
+
+ 'budget floor ≥ sum of item minimums). verdict=blocked ⇒ fix or downgrade wording — never present as a verified plan.',
|
|
2841
|
+
// D-30 第三刀(issue #112):query blob → 平铺 typed;markdown/path 二选一无公共 required
|
|
2842
|
+
// → interpretArgs 容忍层;itinerary 保 object+additionalProperties:true(嵌套形状归一在闸内,
|
|
2843
|
+
// #118 D-26 渲染原语切片再收紧),给模型「这是对象」的可见信号
|
|
2844
|
+
parameters: {
|
|
2845
|
+
markdown: { type: 'string', description: '产物全文;与 path 二选一' },
|
|
2846
|
+
path: { type: 'string', description: '产物路径(artifacts_list 返回的);与 markdown 二选一' },
|
|
2847
|
+
tripYear: { type: 'integer', description: '行程年份,如 2027(日期归一用)' },
|
|
2848
|
+
itinerary: { type: 'object', additionalProperties: true, description: '结构化行程 { trip_start, trip_end, stays:[{place,check_in,check_out}], onboard_nights, od_segments:[{from,to,date,mode,legs}], budget_items:[{label,min_cny,max_cny}], claimed_floor_cny? }' },
|
|
2849
|
+
},
|
|
2850
|
+
output: {
|
|
2851
|
+
schema: { type: 'json' },
|
|
2852
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 800)) }],
|
|
2853
|
+
},
|
|
2854
|
+
async execute(args, exec: unknown) {
|
|
2855
|
+
const q = unwrapQuery<{ markdown?: string; path?: string; tripYear?: number; itinerary?: Record<string, unknown> }>(args, 'markdown')
|
|
2856
|
+
const avMap = await loadAirlineAirportMap()
|
|
2857
|
+
if (!avMap) {
|
|
2858
|
+
return JSON.parse(JSON.stringify({ ok: false, summary: '航司机场映射缺失(data/airline-airports.json)——事实闸不可用,fail closed:产物不得宣称已验证' })) as Record<string, never>
|
|
2859
|
+
}
|
|
2860
|
+
let markdown = q.markdown
|
|
2861
|
+
if (!markdown && q.path) {
|
|
2862
|
+
const r = await readArtifact({ stateRoot: config.stateRoot ?? '.', cwd: sessionCwd(exec), path: q.path })
|
|
2863
|
+
if (!r.ok) return JSON.parse(JSON.stringify({ ok: false, summary: `产物读取失败:${String((r as { error?: string }).error ?? '')}` })) as Record<string, never>
|
|
2864
|
+
markdown = r.content
|
|
2865
|
+
}
|
|
2866
|
+
if (!markdown) return JSON.parse(JSON.stringify({ ok: false, summary: '需要 markdown(产物全文)或 path(产物路径)' })) as Record<string, never>
|
|
2867
|
+
const itinerary = q.itinerary as import('./bookable-facts.ts').ItineraryFacts | undefined
|
|
2868
|
+
const registry = await loadFactRegistry(config.stateRoot ?? '.')
|
|
2869
|
+
// 政策复核提醒桥接(issue #359):闸侧 opts.tripStart 与 renderer 同一传入
|
|
2870
|
+
// 才能让合法 `renderPolicyFact(f, tripStart)` 行通过公开闸。已加载的
|
|
2871
|
+
// itinerary.trip_start 是权威源(同次闸同时驱动 itineraryInvariants),不再
|
|
2872
|
+
// 额外要求调用方传 opts.tripStart;空 itinerary/无 trip_start 即无提醒。
|
|
2873
|
+
const tripStart = typeof itinerary?.trip_start === 'string' ? itinerary.trip_start : undefined
|
|
2874
|
+
const report = gateArtifact(markdown, registry, avMap, { trip_year: q.tripYear, itinerary, tripStart })
|
|
2875
|
+
const lines = report.violations.slice(0, 20).map(v => ` L${v.line} [${v.kind}] ${v.detail}`)
|
|
2876
|
+
const summary = report.verdict === 'pass'
|
|
2877
|
+
? `事实闸 PASS:${report.traceable}/${report.claims_checked} 可下单 claim 全部回溯到 exact-date 工具结果(query_id 可重放)——可宣称「已验证方案」。`
|
|
2878
|
+
: `事实闸 BLOCKED(${report.violations.length} 违例,${report.traceable}/${report.claims_checked} claim 可回溯)——不得宣称「已验证方案」:\n${lines.join('\n')}\n修正路径:逐条改成 exact-date 源返回的事实,或降级为「未确认/当前不可售,到 D-xx 复核」;exact-date miss 的 route+date 不得用历史班期/相邻日期/航线页填充。`
|
|
2879
|
+
return JSON.parse(JSON.stringify({ ok: true, ...report, summary })) as Record<string, never>
|
|
2880
|
+
},
|
|
2881
|
+
presentCall: args => ({ card: 'generic', title: '产物事实闸', kind: 'execute', rawInput: args }),
|
|
2882
|
+
presentResult: (_args, value) => {
|
|
2883
|
+
const r = value as { verdict?: string; violations?: unknown[]; summary?: string }
|
|
2884
|
+
return {
|
|
2885
|
+
card: 'generic',
|
|
2886
|
+
title: `事实闸:${r.verdict === 'pass' ? '✅ pass' : `⛔ blocked(${r.violations?.length ?? '?'})`}`,
|
|
2887
|
+
content: [{ type: 'text', text: String(r.summary ?? '') }],
|
|
2888
|
+
}
|
|
2889
|
+
},
|
|
2890
|
+
}))
|
|
968
2891
|
}
|