@danceiny/gotry 0.2.0-rc.27 → 0.2.0-rc.29
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 +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +56 -12
- package/data/yunnan-pack.json +30 -27
- package/dist/capabilities/anything.js +26 -0
- package/dist/capabilities/channel-registry.js +4 -4
- package/dist/capabilities/doctor.js +14 -7
- package/dist/capabilities/effect.js +1 -1
- package/dist/capabilities/ground-transfer.js +168 -1
- package/dist/capabilities/provider-detail-sanitize.js +64 -0
- package/dist/capabilities/route-provider-conformance.js +341 -0
- package/dist/capabilities/session/extension-bridge.js +3 -3
- package/dist/capabilities/session-search.js +1 -1
- 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 +136 -2
- package/dist/scripts/booking-copilot-dsh-warmer-proof.js +5 -2
- package/dist/scripts/bootstrap-tests.js +77 -7
- package/dist/scripts/city-scenario-tier-tests.js +234 -0
- package/dist/scripts/doctor-tests.js +39 -3
- 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-llm-tests.js +437 -0
- package/dist/scripts/g5-guard-tests.js +20 -1
- package/dist/scripts/g5-guard.js +5 -1
- package/dist/scripts/gotry-backend-kernel-tests.js +1 -1
- package/dist/scripts/m3-cohort-tests.js +714 -0
- package/dist/scripts/m3-cohort.js +310 -0
- package/dist/scripts/nightly-evidence-tests.js +44 -2
- package/dist/scripts/nightly-evidence.js +21 -4
- package/dist/scripts/outcome-projection-tests.js +427 -0
- package/dist/scripts/persona-sim-tests.js +621 -0
- package/dist/scripts/persona-sim.js +1081 -0
- package/dist/scripts/replay-async.js +2 -0
- package/dist/scripts/replay.js +2 -0
- package/dist/scripts/route-provider-conformance-tests.js +391 -0
- package/dist/scripts/session-dida-live-e2e.js +1 -1
- 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 +17 -1
- package/dist/scripts/spawn-bounded-abort-tests.js +3 -0
- package/dist/scripts/state-cli.js +16 -0
- package/dist/scripts/time-eval-tests.js +2 -0
- package/dist/scripts/unified-tests.js +79 -2
- package/dist/scripts/z3-race-tests.js +6 -1
- package/dist/src/backend/modules/session-search.js +1 -1
- package/dist/src/city-scenario-tier.js +378 -0
- package/dist/src/dsh-llm.js +84 -14
- package/dist/src/index.js +166 -7
- package/dist/src/loop.js +16 -5
- package/dist/src/m3-cohort.js +1577 -0
- package/dist/src/outcome-projection.js +746 -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 +7 -4
- package/dist/src/state-ledger.js +293 -40
- package/dist/src/unified.js +69 -5
- package/extension/README.md +1 -1
- package/extension/README.zh-CN.md +1 -1
- package/extension/background.js +1 -1
- package/extension/content-bridge.js +1 -1
- package/extension/content-main.js +1 -1
- package/extension/manifest.json +2 -2
- package/package.json +1 -1
- package/ts/capabilities/anything.ts +27 -0
- package/ts/capabilities/session/extension-bridge.ts +3 -3
- package/ts/capabilities/session-search.ts +1 -1
- package/ts/scripts/state-cli.ts +18 -0
- package/ts/src/contracts.ts +4 -0
- package/ts/src/dsh-llm.ts +111 -9
- package/ts/src/index.ts +181 -8
- package/ts/src/loop.ts +15 -5
- package/ts/src/state-ledger.ts +294 -40
- package/ts/src/unified.ts +91 -18
package/README.md
CHANGED
|
@@ -153,7 +153,7 @@ Trust is structural, not promised:
|
|
|
153
153
|
## Project Status and Roadmap
|
|
154
154
|
Current boundary: deterministic planning and evidence-bound read capabilities are available; booking and real-user acceptance remain outside the shipped claim. See the [architecture](docs/architecture.md) and [roadmap](docs/roadmap.md).
|
|
155
155
|
|
|
156
|
-
**v0.0
|
|
156
|
+
**v0.2.0-rc.28** on npm (`latest`). Pre-1.0: the core loop works end to end; evaluation is still at deterministic contracts and validators, with no external scores or uplift claims. External W2A events are contract-only and inert; no real sensor bridge or consumer is active yet.
|
|
157
157
|
|
|
158
158
|
**Working today**
|
|
159
159
|
|
|
@@ -209,7 +209,7 @@ Documents ship as bilingual pairs (`x.md` English + `x.zh-CN.md` 中文); diverg
|
|
|
209
209
|
|
|
210
210
|
**Built with**: DeepSeek Harness 0.2.0-rc.2 (root-pinned) · Cordis · Z3 (WASM) · loopx (pipx) · hotelbyte-cli · Agent-Reach v1.5.0 · OpenFlights · TypeScript
|
|
211
211
|
|
|
212
|
-
**Version baseline: `v0.0
|
|
212
|
+
**Version baseline: `v0.2.0-rc.28` (npm `latest`).** Verification gates: `scripts/run-all-tests.sh`; release flow: `scripts/publish-npm.sh`.
|
|
213
213
|
|
|
214
214
|
<a href="https://www.star-history.com/?repos=danceiny%2Fgotry&type=date&legend=top-left">
|
|
215
215
|
<picture>
|
package/README.zh-CN.md
CHANGED
|
@@ -155,7 +155,7 @@ npx @danceiny/gotry "我想从深圳休整两天,预算 3000" # headless 一
|
|
|
155
155
|
## 状态与路线图
|
|
156
156
|
当前边界:确定性规划与有证据边界的只读能力已经可用;预订与真实用户验收仍不属于当前交付声明。见[架构权威文档](docs/architecture.zh-CN.md)与[路线图](docs/roadmap.zh-CN.md)。
|
|
157
157
|
|
|
158
|
-
npm `latest`:**v0.0
|
|
158
|
+
npm `latest`:**v0.2.0-rc.28**。未到 1.0:核心链路已端到端可用;评测仍停留在确定性合同与校验器阶段,无外部分数、无 uplift 声明。外部 W2A 事件仍是合同层、默认 inert;尚无真实 sensor 桥或 consumer 激活。
|
|
159
159
|
|
|
160
160
|
**今天可用**
|
|
161
161
|
|
|
@@ -211,7 +211,7 @@ AI agent:[`AGENTS.md`](AGENTS.zh-CN.md) 是绑定契约——入场先清扫
|
|
|
211
211
|
|
|
212
212
|
**Built with**: DeepSeek Harness 0.2.0-rc.2 (root-pinned) · Cordis · Z3 (WASM) · loopx (pipx) · hotelbyte-cli · Agent-Reach v1.5.0 · OpenFlights · TypeScript
|
|
213
213
|
|
|
214
|
-
**版本基线:`v0.0
|
|
214
|
+
**版本基线:`v0.2.0-rc.28`(npm `latest`)。** 验证闸:`scripts/run-all-tests.sh`;发布流程:`scripts/publish-npm.sh`。
|
|
215
215
|
|
|
216
216
|
<a href="https://www.star-history.com/?repos=danceiny%2Fgotry&type=date&legend=top-left">
|
|
217
217
|
<picture>
|
package/bin/gotry-bootstrap.js
CHANGED
|
@@ -634,6 +634,42 @@ function probe(cmd, args, timeoutMs = 10_000) {
|
|
|
634
634
|
})
|
|
635
635
|
}
|
|
636
636
|
|
|
637
|
+
/**
|
|
638
|
+
* 探测命令 stdout,失败(spawn 错误/非零退出/超时)返回 null。
|
|
639
|
+
* 在 'close' 而非 'exit' 结算:'exit' 触发时 stdout 管道可能还没读完,输出会丢成空串
|
|
640
|
+
* (与 ts/capabilities/doctor.ts 的 probeStdout 同一处修复,#622)。
|
|
641
|
+
*/
|
|
642
|
+
function probeStdout(cmd, args, timeoutMs = 10_000) {
|
|
643
|
+
return new Promise((resolve) => {
|
|
644
|
+
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
645
|
+
let out = ''
|
|
646
|
+
let done = false
|
|
647
|
+
const timer = setTimeout(() => {
|
|
648
|
+
if (!done) { done = true; try { child.kill('SIGKILL') } catch { /* ignore */ } resolve(null) }
|
|
649
|
+
}, timeoutMs)
|
|
650
|
+
child.on('error', () => { if (!done) { done = true; clearTimeout(timer); resolve(null) } })
|
|
651
|
+
child.stdout?.on('data', (d) => { out += d.toString() })
|
|
652
|
+
child.on('close', (code) => { if (!done) { done = true; clearTimeout(timer); resolve(code === 0 ? out.trim() : null) } })
|
|
653
|
+
})
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* 按 `hbcli --json auth whoami` 的输出判凭证(hotelbyte-cli 三档 api_key/portal/customer 任一
|
|
658
|
+
* configured 即有效),不看退出码——whoami 没有凭证时也退 0,按退出码判会把「没登录」报成「凭证有效」(#623)。
|
|
659
|
+
* 与工具层 ts/capabilities/doctor.ts 同语义:输出不可解析(超旧版/异常)保守视为有效;取不到输出(null)视为未确认。
|
|
660
|
+
* 两处实现的一致性由 bootstrap-tests 的 CLI↔工具层对拍守住。
|
|
661
|
+
*/
|
|
662
|
+
function classifyHbcliWhoami(out) {
|
|
663
|
+
if (out === null) return { ok: false, customer: false }
|
|
664
|
+
try {
|
|
665
|
+
const w = JSON.parse(out)
|
|
666
|
+
const customer = !!w?.customer?.configured
|
|
667
|
+
return { ok: !!(w?.api_key?.configured || w?.portal?.configured || customer), customer }
|
|
668
|
+
} catch {
|
|
669
|
+
return { ok: true, customer: false }
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
637
673
|
const say = (s) => console.log(s)
|
|
638
674
|
|
|
639
675
|
/** hbcli 最低版本(可用 GOTRY_MIN_HBCLI_VERSION 覆盖)。
|
|
@@ -664,7 +700,7 @@ async function hbcliVersion(bin) {
|
|
|
664
700
|
const timer = setTimeout(() => { if (!done) { done = true; try { child.kill('SIGKILL') } catch { /* ignore */ } resolve(null) } }, 10_000)
|
|
665
701
|
child.on('error', () => { if (!done) { done = true; clearTimeout(timer); resolve(null) } })
|
|
666
702
|
child.stdout?.on('data', (d) => { out += d.toString() })
|
|
667
|
-
child.on('
|
|
703
|
+
child.on('close', (code) => { if (!done) { done = true; clearTimeout(timer); resolve(code === 0 ? parseVersion(out.trim()) : null) } })
|
|
668
704
|
})
|
|
669
705
|
}
|
|
670
706
|
|
|
@@ -855,7 +891,7 @@ async function doctorChecks() {
|
|
|
855
891
|
// 扩展
|
|
856
892
|
const extManifest = join(homedir(), '.gotry', 'extension', 'manifest.json')
|
|
857
893
|
const extOk = existsSync(extManifest)
|
|
858
|
-
items.push({ label: '
|
|
894
|
+
items.push({ label: 'Stai Travel Bridge 扩展', ok: extOk, level: extOk ? 'ok' : 'missing', detail: extOk ? `已就位(${extManifest})` : '未安装——影响面:gotry_session_search / gotry_session_login(账号会话通道)不可用;携程机票/酒店实时检索、12306 余票、Dida 实时报价都依赖此通道。其它工具(机票 FlyAI、酒店 hbcli、地图、天气等)不受影响', fix: extOk ? undefined : '在 Chrome 应用商店一键安装(自动更新): https://chromewebstore.google.com/detail/gotry-session-bridge/oeajpiccmonococjcegddlooeeohlbgd' })
|
|
859
895
|
// agent-reach(.venv 装在包内)
|
|
860
896
|
const venvPython = join(repoRoot, '.venv/bin/python')
|
|
861
897
|
const reachBin = join(repoRoot, '.venv/bin/agent-reach')
|
|
@@ -872,8 +908,15 @@ async function doctorChecks() {
|
|
|
872
908
|
if (v && !versionAtLeast(v, MIN_HBCLI_VERSION)) {
|
|
873
909
|
items.push({ label: 'hbcli(酒店实时源)', ok: false, level: 'missing', detail: `版本过旧(v${v.join('.')} < v${MIN_HBCLI_VERSION})——trade.* / search/checkAvail 会 401(issue #142)`, fix: 'npm install -g staicli --registry=https://registry.npmjs.org/' })
|
|
874
910
|
} else {
|
|
875
|
-
|
|
876
|
-
|
|
911
|
+
// 凭证按 whoami 的 JSON 三档判定,不再看退出码(#623:whoami 无凭证也退 0,旧判法恒报「凭证有效」)
|
|
912
|
+
const cred = classifyHbcliWhoami(await probeStdout(hbBin === 'hbcli(PATH)' ? 'hbcli' : hbBin, ['--json', 'auth', 'whoami']))
|
|
913
|
+
items.push({
|
|
914
|
+
label: 'hbcli(酒店实时源)', ok: cred.ok, level: cred.ok ? 'ok' : 'degraded',
|
|
915
|
+
detail: cred.ok
|
|
916
|
+
? `已安装且凭证有效(${hbBin}, v${v ? v.join('.') : '?'}${cred.customer ? ',customer 档在用(客户邮箱验证码登录)' : ''})`
|
|
917
|
+
: '二进制在,但凭证未配置/失效——酒店检索将降级静态包(非实时)',
|
|
918
|
+
fix: cred.ok ? undefined : 'hbcli auth customer-send-code --email you@mail.com 收码后 hbcli auth customer-login --email you@mail.com --code <收件码>(客户邮箱验证码登录,新邮箱即注册,需 npm staicli ≥ 0.0.4);B 端租户注册走 hbcli auth register(先 auth send-code);或 hbcli auth set-credentials --app-key hotelbyte_api_demo --app-secret hotelbyte_api_demo(快速试用沙箱;正式 key 向 HotelByte 申请)',
|
|
919
|
+
})
|
|
877
920
|
}
|
|
878
921
|
} else {
|
|
879
922
|
items.push({ label: 'hbcli(酒店实时源)', ok: false, level: 'missing', detail: '未安装——酒店检索降级静态包(公开渠道估算,非实时,仅覆盖内置场景)', fix: 'npx @danceiny/gotry doctor --fix' })
|
|
@@ -1121,7 +1164,7 @@ function classifyDoctorGap(item, opts = {}) {
|
|
|
1121
1164
|
return installerEnabled('hbcli', env) && autoSupported ? 'auto' : 'unavailable'
|
|
1122
1165
|
}
|
|
1123
1166
|
if (label.startsWith('dsh-better-sidebar')) return installerEnabled('sidebar', env) && autoSupported ? 'auto' : 'unavailable'
|
|
1124
|
-
if (label.startsWith('
|
|
1167
|
+
if (label.startsWith('Stai Travel Bridge')) return 'user-action' // 浏览器商店一键装
|
|
1125
1168
|
if (label.startsWith('FlyAI')) return 'user-action' // 上游控制台申请 key
|
|
1126
1169
|
if (label.startsWith('dsh-calendar')) return 'user-action' // profile cordis.patch.yml 配置
|
|
1127
1170
|
if (label.startsWith('dsh-map-tools')) return 'unavailable' // 随包 vendor,缺失即重装 gotry
|
|
@@ -1400,7 +1443,7 @@ function copyExtensionDir(srcDir, dstDir) {
|
|
|
1400
1443
|
|
|
1401
1444
|
/** 会话检索扩展(issue #21 方案 C):包内 extension/ → ~/.gotry/extension 幂等落位 + 一次性加载指引 */
|
|
1402
1445
|
async function setupExtension() {
|
|
1403
|
-
say('[gotry-setup]
|
|
1446
|
+
say('[gotry-setup] Stai Travel Bridge 扩展(会话检索数据面 issue #21;一次性安装,替代逐连接弹窗)')
|
|
1404
1447
|
const srcDir = join(repoRoot, 'extension')
|
|
1405
1448
|
const srcManifest = join(srcDir, 'manifest.json')
|
|
1406
1449
|
if (!existsSync(srcManifest)) {
|
|
@@ -1422,7 +1465,7 @@ async function setupExtension() {
|
|
|
1422
1465
|
return { ok: false }
|
|
1423
1466
|
}
|
|
1424
1467
|
say(` ✓ 已落位 ${dstDir}(v${srcVersion};manifest 带固定 key,unpacked 扩展 ID 恒为 olpgkofjhhiiiahdkkbcninhjmegghfe)`)
|
|
1425
|
-
say(' 推荐(免下面三步):Chrome 应用商店一键安装
|
|
1468
|
+
say(' 推荐(免下面三步):Chrome 应用商店一键安装 Stai Travel Bridge(自动更新): https://chromewebstore.google.com/detail/gotry-session-bridge/oeajpiccmonococjcegddlooeeohlbgd')
|
|
1426
1469
|
say(' 本地加载(每台浏览器一次,约 30 秒):Chrome 打开 chrome://extensions → 右上角开启「开发者模式」→「加载已解压的扩展程序」→ 选择 ~/.gotry/extension')
|
|
1427
1470
|
say(' 获取/更新本地通道扩展可走 GitHub Releases:npx @danceiny/gotry setup --extension-from=github(自动下载校验落位;手动下载: github.com/Danceiny/gotry/releases 标签 ext-*)')
|
|
1428
1471
|
say(' 装好即生效,零系统弹窗;扩展卡片开关=总闸(与 gotry 授权闸 sessionAccess 双重控制)')
|
|
@@ -1435,7 +1478,7 @@ async function setupExtension() {
|
|
|
1435
1478
|
* 任何失败显式降级 bundled 包内副本——安装外部产物永远不挡 gotry 本体。
|
|
1436
1479
|
*/
|
|
1437
1480
|
async function setupExtensionFromGithub() {
|
|
1438
|
-
say('[gotry-setup]
|
|
1481
|
+
say('[gotry-setup] Stai Travel Bridge 扩展 · GitHub Releases 下载通道(dist-manifest → tar.gz → SHA256 → key 钉扎)')
|
|
1439
1482
|
const destDir = join(homedir(), '.gotry', 'extension')
|
|
1440
1483
|
const cliScript = join(repoRoot, 'ts', 'scripts', 'extension-distribution-cli.ts')
|
|
1441
1484
|
const releaseBase = process.env.GOTRY_EXTENSION_RELEASE_BASE // 镜像/测试基址覆盖;缺省 GitHub 官方
|
|
@@ -1466,7 +1509,7 @@ async function setupExtensionFromGithub() {
|
|
|
1466
1509
|
if (r && r.ok) {
|
|
1467
1510
|
if (r.action === 'installed') {
|
|
1468
1511
|
say(` ✓ 已从 GitHub Releases 落位 ${destDir}(v${r.version}${r.previousVersion ? `,旧 v${r.previousVersion}` : ''})`)
|
|
1469
|
-
say(' 已装过旧版的浏览器:chrome://extensions →
|
|
1512
|
+
say(' 已装过旧版的浏览器:chrome://extensions → Stai Travel Bridge 卡片 → 「重新加载」一次即生效(新装跳过)。')
|
|
1470
1513
|
return { ok: true }
|
|
1471
1514
|
}
|
|
1472
1515
|
// up-to-date(check-only 下远端更新也会走到这里,只报告不落盘)
|
|
@@ -1685,7 +1728,7 @@ if (WIZARD) {
|
|
|
1685
1728
|
}
|
|
1686
1729
|
say('')
|
|
1687
1730
|
say('[gotry-wizard] ────────────────────────────────')
|
|
1688
|
-
say('[gotry-wizard]
|
|
1731
|
+
say('[gotry-wizard] Stai Travel Bridge 是浏览器的事——在 Chrome 应用商店一键装:')
|
|
1689
1732
|
say('[gotry-wizard] https://chromewebstore.google.com/detail/gotry-session-bridge/oeajpiccmonococjcegddlooeeohlbgd')
|
|
1690
1733
|
say('[gotry-wizard] 装好即生效;装完后我会自动检测到(下方探活等待,最长 120s)。')
|
|
1691
1734
|
say('[gotry-wizard] 全过程 gotry 不动你的剪贴板、不开你的 Chrome、不弹任何面板。')
|
|
@@ -1706,7 +1749,7 @@ if (WIZARD) {
|
|
|
1706
1749
|
}
|
|
1707
1750
|
|
|
1708
1751
|
if (process.platform === 'win32') {
|
|
1709
|
-
say('[gotry-setup]
|
|
1752
|
+
say('[gotry-setup] Stai Travel Bridge 扩展:推荐 Chrome 应用商店一键安装 https://chromewebstore.google.com/detail/gotry-session-bridge/oeajpiccmonococjcegddlooeeohlbgd ;本地通道:手动把包内 extension/ 目录拷到 %USERPROFILE%\\.gotry\\extension,再在 chrome://extensions 开发者模式「加载已解压的扩展程序」')
|
|
1710
1753
|
process.exit(AUTO ? 0 : 1)
|
|
1711
1754
|
}
|
|
1712
1755
|
if (AUTO && (process.env.CI || process.env.GOTRY_SETUP_SKIP === '1')) {
|
|
@@ -1717,7 +1760,7 @@ if (WIZARD) {
|
|
|
1717
1760
|
const results = []
|
|
1718
1761
|
// gotry 自留面只剩扩展是否就位;hbcli/agent-reach/dsh-better-sidebar 由各自宿主生态自管。
|
|
1719
1762
|
if (process.env.GOTRY_SETUP_EXTENSION !== '0') results.push(await (EXTENSION_FROM === 'github' ? setupExtensionFromGithub() : setupExtension()))
|
|
1720
|
-
else say('[gotry-setup]
|
|
1763
|
+
else say('[gotry-setup] Stai Travel Bridge 扩展:GOTRY_SETUP_EXTENSION=0 跳过')
|
|
1721
1764
|
const failed = results.filter((r) => !r.ok).length
|
|
1722
1765
|
if (failed > 0) {
|
|
1723
1766
|
say(`[gotry-setup] ${failed} 项未就绪——gotry 本体不受影响;可稍后重跑: npx @danceiny/gotry setup`)
|
|
@@ -1741,4 +1784,5 @@ export {
|
|
|
1741
1784
|
classifyDoctorGap, buildOnboardingPlan, onboardingSkipReason,
|
|
1742
1785
|
runOnboardingFix, promptOnboarding, renderClassifiedPlan, runOnboarding, orchestrateWebLaunch,
|
|
1743
1786
|
doctorFixAutoSupported, platformAutoUnavailableReason, installerEnabled,
|
|
1787
|
+
classifyHbcliWhoami,
|
|
1744
1788
|
}
|
package/data/yunnan-pack.json
CHANGED
|
@@ -29,33 +29,6 @@
|
|
|
29
29
|
]
|
|
30
30
|
},
|
|
31
31
|
"legs": [
|
|
32
|
-
{
|
|
33
|
-
"id": "yn0",
|
|
34
|
-
"note": "昆明→丽江(衔接 8.4 FD582 KMG 11:25 落地后,下午转飞丽江;M-1 工作窗口外)",
|
|
35
|
-
"date": "8.4 下午",
|
|
36
|
-
"services": [
|
|
37
|
-
{
|
|
38
|
-
"id": "MU5819",
|
|
39
|
-
"dep": "14:30",
|
|
40
|
-
"arr": "15:35",
|
|
41
|
-
"price_cny": 520,
|
|
42
|
-
"price_note": "东航,1h5m 直飞"
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
"id": "8L9662",
|
|
46
|
-
"dep": "16:40",
|
|
47
|
-
"arr": "17:45",
|
|
48
|
-
"price_cny": 480,
|
|
49
|
-
"price_note": "祥鹏,1h5m 直飞"
|
|
50
|
-
}
|
|
51
|
-
],
|
|
52
|
-
"evidence": [
|
|
53
|
-
{
|
|
54
|
-
"tag": "[demo-reconcil:已]",
|
|
55
|
-
"note": "8.4 FD582 → 11:25 KMG → 当天转飞丽江(已挖)"
|
|
56
|
-
}
|
|
57
|
-
]
|
|
58
|
-
},
|
|
59
32
|
{
|
|
60
33
|
"id": "yn1",
|
|
61
34
|
"note": "深圳→丽江(直飞,丽江进大理出的开口程第一段)",
|
|
@@ -153,6 +126,36 @@
|
|
|
153
126
|
"weekday": "any"
|
|
154
127
|
}
|
|
155
128
|
],
|
|
129
|
+
"advisory_legs": [
|
|
130
|
+
{
|
|
131
|
+
"id": "yn0",
|
|
132
|
+
"advisory_reason": "本段缺 buffer_min / origin_transfer_min / dest_transfer_min 三个真值,待 founder 校准(flights_2026.json 的 f3 evidence 早已写明:yn0 衔接仍写,引擎层不挂接)。替它填数等于编造真实世界的事实(ADR-10 翻译不造数),所以只记录、不挂接引擎;三个整数补齐后把整段搬回 legs 即可参与求解(issue #635)。",
|
|
133
|
+
"note": "昆明→丽江(衔接 8.4 FD582 KMG 11:25 落地后,下午转飞丽江;M-1 工作窗口外)",
|
|
134
|
+
"date": "8.4 下午",
|
|
135
|
+
"services": [
|
|
136
|
+
{
|
|
137
|
+
"id": "MU5819",
|
|
138
|
+
"dep": "14:30",
|
|
139
|
+
"arr": "15:35",
|
|
140
|
+
"price_cny": 520,
|
|
141
|
+
"price_note": "东航,1h5m 直飞"
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
"id": "8L9662",
|
|
145
|
+
"dep": "16:40",
|
|
146
|
+
"arr": "17:45",
|
|
147
|
+
"price_cny": 480,
|
|
148
|
+
"price_note": "祥鹏,1h5m 直飞"
|
|
149
|
+
}
|
|
150
|
+
],
|
|
151
|
+
"evidence": [
|
|
152
|
+
{
|
|
153
|
+
"tag": "[demo-reconcil:已]",
|
|
154
|
+
"note": "8.4 FD582 → 11:25 KMG → 当天转飞丽江(已挖)"
|
|
155
|
+
}
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
],
|
|
156
159
|
"stays": [
|
|
157
160
|
{
|
|
158
161
|
"id": "yns1",
|
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
* - miss: hbcli exit 0,但 candidates 为空(查无结果)
|
|
20
20
|
* - err: hbcli exit ≠ 0 或超时(降级,降级产物 标 [实时API:hbcli-anything@error@ts])
|
|
21
21
|
*
|
|
22
|
+
* 离线开关(issue #617;与会话通道 GOTRY_SESSION_LIVE 同形,方向相反以保持产品默认不变):
|
|
23
|
+
* GOTRY_HBCLI_LIVE=0|false|off(不分大小写)→ 零 spawn、不碰凭据与后端,走同一条降级面
|
|
24
|
+
* (verdict=error,证据标 [实时API:hbcli-anything@offline@ts]);未设或任何其他取值 = 与此前
|
|
25
|
+
* 完全一致(hbcli 可用即 live)。判定见 hbcliLiveEnabled;mock/dry-run harness(nightly-evidence --dry-run、
|
|
26
|
+
* persona-sim、replay*)缺省把它置 0,operator 显式设置的值不覆盖。
|
|
27
|
+
*
|
|
22
28
|
* 契约(与 hbcli/weather/opensky 同构,L4 不变量):
|
|
23
29
|
* - 永不抛错:网络/超时/解析失败一律降级返回;
|
|
24
30
|
* - 证据链标注:成功 [实时API:hbcli-anything@ts];失败 [实时API:hbcli-anything@error@ts];
|
|
@@ -84,6 +90,15 @@ function failureReason(r, cleanupError) {
|
|
|
84
90
|
? 'hbcli 未返回可判定结果(进程异常结束,本轮按降级处理)'
|
|
85
91
|
: `hbcli 退出码 ${r.code},未返回可判定结果(本轮按降级处理)`) + suffix;
|
|
86
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* 实时 hbcli 通道是否开启(issue #617)。只有显式写 0 / false / off(不分大小写,忽略首尾空白)
|
|
95
|
+
* 才关闭;未设或任何其他取值都视为开启——终端用户依赖的实时源缺省不丢。调用时读取,
|
|
96
|
+
* 测试与 harness 可在进程内切换。
|
|
97
|
+
*/
|
|
98
|
+
export function hbcliLiveEnabled(env = process.env) {
|
|
99
|
+
const v = (env.GOTRY_HBCLI_LIVE ?? '').trim().toLowerCase();
|
|
100
|
+
return v !== '0' && v !== 'false' && v !== 'off';
|
|
101
|
+
}
|
|
87
102
|
/** Anything 通用搜索 — 任何搜索失败走降级;不抛错 */
|
|
88
103
|
export async function anythingSearch(q) {
|
|
89
104
|
const started = Date.now();
|
|
@@ -107,6 +122,17 @@ export async function anythingSearch(q) {
|
|
|
107
122
|
latencyMs: 0, verdict: 'error', error: 'aborted by host signal',
|
|
108
123
|
};
|
|
109
124
|
}
|
|
125
|
+
// 离线开关:位置在入参校验与 pre-abort 之后,既有入参的既有结果/原因串逐字节不变;
|
|
126
|
+
// 关闭时与「未装配 hbcli」同形降级(ok:false / hbcli-anything-error / verdict:error + 人话原因,
|
|
127
|
+
// 无进程噪音,也不含 timeout 字样——效应层只重试 timeout 类),且不 spawn、不重试。
|
|
128
|
+
if (!hbcliLiveEnabled()) {
|
|
129
|
+
return {
|
|
130
|
+
ok: false, via: 'hbcli-anything-error',
|
|
131
|
+
evidence: `[实时API:hbcli-anything@offline@${ts}] GOTRY_HBCLI_LIVE switched off; live channel closed, zero spawn`,
|
|
132
|
+
latencyMs: Date.now() - started, verdict: 'error',
|
|
133
|
+
error: 'hbcli 实时通道已关闭(GOTRY_HBCLI_LIVE 离线开关;本轮按降级处理,未发起查询)',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
110
136
|
let r = { code: null, stdout: '', stderr: '', aborted: false, timedOut: false };
|
|
111
137
|
for (let i = 0; i < candidates.length; i++) {
|
|
112
138
|
r = await sh(candidates[i], args, { timeoutMs: q.timeoutMs ?? 12_000, env: process.env, signal: q.signal });
|
|
@@ -52,7 +52,7 @@ export const CHANNELS = [
|
|
|
52
52
|
evidenceTag: '[会话:ctrip-flight@ts]',
|
|
53
53
|
tier: 'session',
|
|
54
54
|
efficiencyRank: 2,
|
|
55
|
-
setup: '一次性装
|
|
55
|
+
setup: '一次性装 Stai Travel Bridge 扩展 + 授权卡 + (如需)官网登录',
|
|
56
56
|
hint: 'kind=flight',
|
|
57
57
|
},
|
|
58
58
|
{
|
|
@@ -64,7 +64,7 @@ export const CHANNELS = [
|
|
|
64
64
|
evidenceTag: '[会话:ctrip-hotel@ts]',
|
|
65
65
|
tier: 'session',
|
|
66
66
|
efficiencyRank: 2,
|
|
67
|
-
setup: '一次性装
|
|
67
|
+
setup: '一次性装 Stai Travel Bridge 扩展 + 授权卡 + (如需)官网登录',
|
|
68
68
|
hint: 'kind=hotel',
|
|
69
69
|
},
|
|
70
70
|
{
|
|
@@ -76,7 +76,7 @@ export const CHANNELS = [
|
|
|
76
76
|
evidenceTag: '[会话:dida-portal@ts]',
|
|
77
77
|
tier: 'session',
|
|
78
78
|
efficiencyRank: 2,
|
|
79
|
-
setup: '一次性装
|
|
79
|
+
setup: '一次性装 Stai Travel Bridge 扩展 + 授权卡 + hotel-be portal 自动登录跳板(dida 官网由人完成登录)',
|
|
80
80
|
hint: 'kind=dida',
|
|
81
81
|
},
|
|
82
82
|
{
|
|
@@ -88,7 +88,7 @@ export const CHANNELS = [
|
|
|
88
88
|
evidenceTag: '[会话:train-12306@ts]',
|
|
89
89
|
tier: 'session',
|
|
90
90
|
efficiencyRank: 2,
|
|
91
|
-
setup: '一次性装
|
|
91
|
+
setup: '一次性装 Stai Travel Bridge 扩展(公开查询面,无需登录)',
|
|
92
92
|
hint: 'kind=train',
|
|
93
93
|
},
|
|
94
94
|
{
|
|
@@ -52,10 +52,17 @@ function probe(bin, args, timeoutMs = 8_000) {
|
|
|
52
52
|
} });
|
|
53
53
|
});
|
|
54
54
|
}
|
|
55
|
-
/**
|
|
56
|
-
|
|
55
|
+
/**
|
|
56
|
+
* 探测命令 stdout(--version),失败返回 null。
|
|
57
|
+
*
|
|
58
|
+
* 在 'close' 而非 'exit' 结算:'exit' 触发时 stdout 管道可能还没读完(Node 文档明言),
|
|
59
|
+
* 秒退进程的输出会丢成空串,而空串走「不可解析 = 保守视为有效」分支,把无凭证的 hbcli
|
|
60
|
+
* 报成 ok。'close' 在 stdio 全部关闭后才触发;超时仍由下方定时器兜底。
|
|
61
|
+
* `spawnImpl` 仅供测试注入假子进程以确定性复现「exit 先于 data」的次序。
|
|
62
|
+
*/
|
|
63
|
+
export function probeStdout(bin, args, timeoutMs = 8_000, spawnImpl = spawn) {
|
|
57
64
|
return new Promise((resolveProbe) => {
|
|
58
|
-
const child =
|
|
65
|
+
const child = spawnImpl(bin, args, { stdio: ['ignore', 'pipe', 'ignore'], env: process.env });
|
|
59
66
|
let out = '';
|
|
60
67
|
let done = false;
|
|
61
68
|
const timer = setTimeout(() => {
|
|
@@ -74,7 +81,7 @@ function probeStdout(bin, args, timeoutMs = 8_000) {
|
|
|
74
81
|
resolveProbe(null);
|
|
75
82
|
} });
|
|
76
83
|
child.stdout?.on('data', (d) => { out += d.toString(); });
|
|
77
|
-
child.on('
|
|
84
|
+
child.on('close', (code) => { if (!done) {
|
|
78
85
|
done = true;
|
|
79
86
|
clearTimeout(timer);
|
|
80
87
|
resolveProbe(code === 0 ? out.trim() : null);
|
|
@@ -117,14 +124,14 @@ export async function runDoctorChecks(opts = {}) {
|
|
|
117
124
|
items.push(nodeOk(nodeVersion)
|
|
118
125
|
? { id: 'node', label: 'Node 运行时', status: 'ok', detail: `Node ${nodeVersion}(≥22.15)` }
|
|
119
126
|
: { id: 'node', label: 'Node 运行时', status: 'missing', detail: `Node ${nodeVersion} 过旧(gotry 需 ≥22.15)`, fix: '升级 Node.js 至 22.15+(https://nodejs.org)' });
|
|
120
|
-
// 1.
|
|
127
|
+
// 1. Stai Travel Bridge 扩展(账号会话通道的传输层;装在哪由 bootstrap setup 管理)
|
|
121
128
|
// D-24 自适应(#117):离线只能探测本地 unpacked 通道;商店版无本地文件面——
|
|
122
129
|
// missing detail 明示「商店版用户可忽略本项」,不再把商店版用户误导成本地通道缺失
|
|
123
130
|
const extManifest = join(home, '.gotry', 'extension', 'manifest.json');
|
|
124
131
|
items.push(existsSync(extManifest)
|
|
125
|
-
? { id: 'extension', label: '
|
|
132
|
+
? { id: 'extension', label: 'Stai Travel Bridge 扩展', status: 'ok', detail: `已就位(本地通道:${extManifest})` }
|
|
126
133
|
: {
|
|
127
|
-
id: 'extension', label: '
|
|
134
|
+
id: 'extension', label: 'Stai Travel Bridge 扩展', status: 'missing',
|
|
128
135
|
detail: '本地通道未落位(~/.gotry/extension/manifest.json 不存在)。影响面:gotry_session_search / gotry_session_login(账号会话通道)不可用;携程机票/酒店实时检索、12306 余票、Dida 实时报价都依赖此通道。其它工具(机票 FlyAI、酒店 hbcli、地图、天气等)不受影响。若你已从 Chrome 商店安装(自动更新,商店版 ID oeajpicc…),本项可忽略——会话检索可用性以运行时为准(gotry_session_search 的 verdict)。若未安装:应用商店一键装即可',
|
|
129
136
|
fix: '在 Chrome 应用商店一键安装(自动更新): https://chromewebstore.google.com/detail/gotry-session-bridge/oeajpiccmonococjcegddlooeeohlbgd',
|
|
130
137
|
});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* 断路器,per-效应策略表)→ 渠道 handler(现能力层函数,零改写);
|
|
10
10
|
* - mock 解译器 makeMockInterpreter:夹具回放(CI/本地无网确定性);
|
|
11
11
|
* - 浏览器解译 = SESSION_* 效应(用户本人登录态的扩展桥车道,2026-08-29 起默认传输:
|
|
12
|
-
* MV3
|
|
12
|
+
* MV3 Stai Travel Bridge + 本地桥长轮询,零系统弹窗;ReadGuard+授权闸不变,
|
|
13
13
|
* 非 CUA 视觉点击——本仓已按零 Python 依赖与 a11y/DOM 优先判死后者,
|
|
14
14
|
* docs/design/effect-interpreter.md §4)。
|
|
15
15
|
*
|
|
@@ -13,14 +13,52 @@
|
|
|
13
13
|
* layer. Cache, freshness, and fallback are isolated per direction so a
|
|
14
14
|
* miss / error / stale / mismatch in one direction never borrows the
|
|
15
15
|
* dynamic value of the other.
|
|
16
|
+
*
|
|
17
|
+
* A provider's OWN claims about its answer — declared `mode`, resolved
|
|
18
|
+
* origin/destination — are verified against the request whenever the provider
|
|
19
|
+
* makes them: a contradiction is refused with the conformance vocabulary
|
|
20
|
+
* (`mode_mismatch` / `direction_mismatch`) and falls back to the static estimate,
|
|
21
|
+
* so a transit duration is never bound as driving and a route between two other
|
|
22
|
+
* places is never bound as the airport transfer (issue #429 GAP-429-1/2). All
|
|
23
|
+
* three fields are OPTIONAL and absent means unverifiable: see
|
|
24
|
+
* `PublicMapDrivingRouteResult` for why absent is still accepted today.
|
|
25
|
+
*
|
|
26
|
+
* A provider failure becomes a `fallbackReason`, and `exposeGroundTransferEvidence`
|
|
27
|
+
* carries that text into the tool result and each matching verdict's
|
|
28
|
+
* `transfer_evidence`. It is therefore an EXPOSED surface: every provider-authored
|
|
29
|
+
* failure string passes through the shared `sanitizeFaultDetail` first, so markup,
|
|
30
|
+
* credential assignments and URLs from a provider response body can never reach it
|
|
31
|
+
* (issue #429 GAP-429-3). The historical 400-character bound and the historical
|
|
32
|
+
* empty-case wording are preserved, so existing `GROUND_TRANSFER_*` /
|
|
33
|
+
* `map_driving_route` reason strings are unchanged byte for byte.
|
|
16
34
|
*/
|
|
17
35
|
import { ToolCallId } from '@deepseek-ai/dsh-llm';
|
|
36
|
+
import { sanitizeFaultDetail } from './provider-detail-sanitize.js';
|
|
18
37
|
export const GROUND_TRANSFER_MODE = 'driving';
|
|
19
38
|
export const GROUND_TRANSFER_POSITION = 'destination';
|
|
20
39
|
export const GROUND_TRANSFER_STATIC_MODE = 'taxi';
|
|
21
40
|
export const GROUND_TRANSFER_STATIC_MODE_MISMATCH = 'ground_transfer_static_mode_mismatch';
|
|
22
41
|
export const GROUND_TRANSFER_STATIC_PRICE_EVIDENCE = '[静态包:估算]';
|
|
23
42
|
export const GROUND_TRANSFER_DEFAULT_MAX_AGE_S = 15 * 60;
|
|
43
|
+
/**
|
|
44
|
+
* Refusal classifications for a provider that contradicts its own request. The
|
|
45
|
+
* two names are the #429 conformance gate's vocabulary verbatim
|
|
46
|
+
* (`RouteErrorCode`), reused here as the `<code>:<direction>:<detail>` reason
|
|
47
|
+
* prefix this module already uses for `map_driving_route_provider_error` and
|
|
48
|
+
* `stale_route_requery_error`. They are duplicated as literals on purpose: the
|
|
49
|
+
* gate must stay free of product callers, so nothing here imports it.
|
|
50
|
+
*/
|
|
51
|
+
export const GROUND_TRANSFER_ROUTE_MODE_MISMATCH = 'mode_mismatch';
|
|
52
|
+
export const GROUND_TRANSFER_ROUTE_DIRECTION_MISMATCH = 'direction_mismatch';
|
|
53
|
+
/**
|
|
54
|
+
* Tolerance for verifying a provider's echoed endpoint against the requested
|
|
55
|
+
* one: 1e-4 degrees ≈ 11 m of latitude. A provider legitimately snaps a
|
|
56
|
+
* coordinate to the nearest routable road node, so an exact string match would
|
|
57
|
+
* refuse honest answers; anything beyond this bound is a different place, not a
|
|
58
|
+
* snap. Deliberately tight — refusing falls back to the static estimate, which
|
|
59
|
+
* is the safe direction.
|
|
60
|
+
*/
|
|
61
|
+
export const GROUND_TRANSFER_ECHO_TOLERANCE_DEG = 1e-4;
|
|
24
62
|
const GROUND_TRANSFER_CACHE_LIMIT = 32;
|
|
25
63
|
function asRecord(value) {
|
|
26
64
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
@@ -49,6 +87,50 @@ function coordinatePair(value) {
|
|
|
49
87
|
function coordinateText(value) {
|
|
50
88
|
return `${String(value.longitude)},${String(value.latitude)}`;
|
|
51
89
|
}
|
|
90
|
+
/** Parse a canonical `"lon,lat"` text back into a validated coordinate. */
|
|
91
|
+
function coordinateFromText(value) {
|
|
92
|
+
const parts = value.split(',');
|
|
93
|
+
if (parts.length !== 2)
|
|
94
|
+
return null;
|
|
95
|
+
const [longitudeText, latitudeText] = parts;
|
|
96
|
+
if (longitudeText.trim() === '' || latitudeText.trim() === '')
|
|
97
|
+
return null;
|
|
98
|
+
return coordinate({ longitude: Number(longitudeText), latitude: Number(latitudeText) });
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Does a provider's echoed endpoint name the place that was requested?
|
|
102
|
+
*
|
|
103
|
+
* Compared within `GROUND_TRANSFER_ECHO_TOLERANCE_DEG` so a road-node snap still
|
|
104
|
+
* matches. An echo that cannot be parsed is NOT a match: present-but-unverifiable
|
|
105
|
+
* fails closed rather than being accepted as the requested pair.
|
|
106
|
+
*/
|
|
107
|
+
function sameEndpoint(echoed, requested) {
|
|
108
|
+
const left = coordinateFromText(echoed);
|
|
109
|
+
const right = coordinateFromText(requested);
|
|
110
|
+
if (left === null || right === null)
|
|
111
|
+
return false;
|
|
112
|
+
return Math.abs(left.longitude - right.longitude) <= GROUND_TRANSFER_ECHO_TOLERANCE_DEG
|
|
113
|
+
&& Math.abs(left.latitude - right.latitude) <= GROUND_TRANSFER_ECHO_TOLERANCE_DEG;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Normalize one of the provider's OWN optional claims into a comparable string.
|
|
117
|
+
*
|
|
118
|
+
* Absent (`undefined` / `null`) stays absent — that is the wired driving tool and
|
|
119
|
+
* its behaviour is unchanged. A present-but-unusable value is NOT dropped: it is
|
|
120
|
+
* carried as a non-empty string so the enforcement below can refuse it, because
|
|
121
|
+
* silently dropping a contradicting claim is exactly the relabel this seam
|
|
122
|
+
* forbids. A `{ longitude, latitude }` object is accepted as the canonical text.
|
|
123
|
+
*/
|
|
124
|
+
function declaredClaim(value) {
|
|
125
|
+
if (value === undefined || value === null)
|
|
126
|
+
return undefined;
|
|
127
|
+
if (typeof value === 'string')
|
|
128
|
+
return value.trim() === '' ? '(blank)' : value.trim();
|
|
129
|
+
const asCoordinate = coordinate(value);
|
|
130
|
+
if (asCoordinate !== null)
|
|
131
|
+
return coordinateText(asCoordinate);
|
|
132
|
+
return typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
133
|
+
}
|
|
52
134
|
function cacheKey(direction) {
|
|
53
135
|
return JSON.stringify({
|
|
54
136
|
mode: GROUND_TRANSFER_MODE,
|
|
@@ -66,9 +148,33 @@ function nowMs(clock) {
|
|
|
66
148
|
function asOf(clock) {
|
|
67
149
|
return new Date(nowMs(clock)).toISOString();
|
|
68
150
|
}
|
|
151
|
+
/** Historical bound for an exposed provider failure string (unchanged). */
|
|
152
|
+
const GROUND_TRANSFER_FAULT_DETAIL_MAX_CHARS = 400;
|
|
153
|
+
/**
|
|
154
|
+
* Normalize a provider failure into an exposable label. The text reaches the tool
|
|
155
|
+
* result via `exposeGroundTransferEvidence`, so it is scrubbed of markup,
|
|
156
|
+
* credential assignments and URLs before anything else (#429 GAP-429-3); the
|
|
157
|
+
* historical length bound and empty-case wording are preserved.
|
|
158
|
+
*/
|
|
69
159
|
function safeErrorMessage(value) {
|
|
70
160
|
const message = value instanceof Error ? value.message : String(value);
|
|
71
|
-
return message
|
|
161
|
+
return sanitizeFaultDetail(message, {
|
|
162
|
+
maxChars: GROUND_TRANSFER_FAULT_DETAIL_MAX_CHARS,
|
|
163
|
+
emptyPlaceholder: 'unknown provider error',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
/** Bound for a provider-authored claim quoted inside a refusal reason. */
|
|
167
|
+
const GROUND_TRANSFER_CLAIM_DETAIL_MAX_CHARS = 120;
|
|
168
|
+
/**
|
|
169
|
+
* A provider's own claim (mode / echoed endpoint / provider id) is provider-authored
|
|
170
|
+
* text, and a refusal reason reaches the tool result via
|
|
171
|
+
* `exposeGroundTransferEvidence`. Quote it only through the shared sanitizer.
|
|
172
|
+
*/
|
|
173
|
+
function safeClaim(value) {
|
|
174
|
+
return sanitizeFaultDetail(value, {
|
|
175
|
+
maxChars: GROUND_TRANSFER_CLAIM_DETAIL_MAX_CHARS,
|
|
176
|
+
emptyPlaceholder: '(absent)',
|
|
177
|
+
});
|
|
72
178
|
}
|
|
73
179
|
function staticCache(status = 'bypass') {
|
|
74
180
|
return { status, ageS: null, asOf: null };
|
|
@@ -189,14 +295,58 @@ function parseRouteResult(value) {
|
|
|
189
295
|
// zero minutes into the solver.
|
|
190
296
|
|| (distanceM > 0 && durationS === 0))
|
|
191
297
|
throw new Error('map_driving_route returned an invalid public result');
|
|
298
|
+
// The provider's own claims are CARRIED, never dropped: `routeClaimRefusal`
|
|
299
|
+
// below checks them when they exist. Absent stays absent (the wired driving
|
|
300
|
+
// tool sends none of the three), and re-parsing an already-parsed result is
|
|
301
|
+
// idempotent because the carried values are already canonical.
|
|
302
|
+
const declaredMode = declaredClaim(record['mode']);
|
|
303
|
+
const resolvedOrigin = declaredClaim(record['resolvedOrigin']);
|
|
304
|
+
const resolvedDestination = declaredClaim(record['resolvedDestination']);
|
|
192
305
|
return {
|
|
193
306
|
provider,
|
|
194
307
|
distanceM,
|
|
195
308
|
durationS,
|
|
196
309
|
...(typeof record['polyline'] === 'string' ? { polyline: record['polyline'] } : {}),
|
|
197
310
|
...(Array.isArray(record['steps']) ? { steps: record['steps'] } : {}),
|
|
311
|
+
...(declaredMode !== undefined ? { mode: declaredMode } : {}),
|
|
312
|
+
...(resolvedOrigin !== undefined ? { resolvedOrigin } : {}),
|
|
313
|
+
...(resolvedDestination !== undefined ? { resolvedDestination } : {}),
|
|
198
314
|
};
|
|
199
315
|
}
|
|
316
|
+
/**
|
|
317
|
+
* Verify the provider's OWN claims against the request for this direction, and
|
|
318
|
+
* return a typed refusal when one contradicts it (#429 GAP-429-1/2).
|
|
319
|
+
*
|
|
320
|
+
* ABSENT = UNVERIFIABLE, and absent is ACCEPTED — not because an unverified
|
|
321
|
+
* answer is as good as a verified one, but because the only wired provider (the
|
|
322
|
+
* registered `map_driving_route` tool) is driving by construction and echoes no
|
|
323
|
+
* origin/destination, so for it nothing changes byte for byte. Any future
|
|
324
|
+
* non-driving or address-resolving adapter must supply these fields; the #429
|
|
325
|
+
* conformance gate already refuses an adapter that does not.
|
|
326
|
+
*
|
|
327
|
+
* PRESENT and contradicting = REFUSAL. The caller falls back to the static
|
|
328
|
+
* transfer estimate with its original price label, so a mislabelled or
|
|
329
|
+
* misrouted answer never reaches the solver.
|
|
330
|
+
*/
|
|
331
|
+
function routeClaimRefusal(direction, request, route) {
|
|
332
|
+
if (route.mode !== undefined && route.mode !== GROUND_TRANSFER_MODE) {
|
|
333
|
+
return {
|
|
334
|
+
code: GROUND_TRANSFER_ROUTE_MODE_MISMATCH,
|
|
335
|
+
detail: `provider ${safeClaim(route.provider)} returned mode '${safeClaim(route.mode)}' for a '${GROUND_TRANSFER_MODE}' request; a declared mode is never relabelled as ${GROUND_TRANSFER_MODE}; static transfer preserved`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
const { resolvedOrigin, resolvedDestination } = route;
|
|
339
|
+
if (resolvedOrigin === undefined && resolvedDestination === undefined)
|
|
340
|
+
return null;
|
|
341
|
+
if ((resolvedOrigin !== undefined && !sameEndpoint(resolvedOrigin, request.origin))
|
|
342
|
+
|| (resolvedDestination !== undefined && !sameEndpoint(resolvedDestination, request.destination))) {
|
|
343
|
+
return {
|
|
344
|
+
code: GROUND_TRANSFER_ROUTE_DIRECTION_MISMATCH,
|
|
345
|
+
detail: `provider ${safeClaim(route.provider)} resolved ${safeClaim(resolvedOrigin ?? '(absent)')}>${safeClaim(resolvedDestination ?? '(absent)')} but the ${direction.direction} request was ${request.origin}>${request.destination}; direction binding is verified against the response, not assumed from the request; static transfer preserved`,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
200
350
|
function buildRouteFact(direction, route, clock, cache) {
|
|
201
351
|
return {
|
|
202
352
|
direction: direction.direction,
|
|
@@ -272,6 +422,23 @@ async function resolveDirection(direction, staticMinutes, provider, clock, cache
|
|
|
272
422
|
execution: context.execution,
|
|
273
423
|
});
|
|
274
424
|
const route = parseRouteResult(raw);
|
|
425
|
+
const refusal = routeClaimRefusal(direction, requestForProvider, route);
|
|
426
|
+
if (refusal !== null) {
|
|
427
|
+
// Fail closed: no fact is built and nothing is written to the cache, so a
|
|
428
|
+
// refused answer can never be served later as a cache hit. The static
|
|
429
|
+
// minutes, price and `[静态包:估算]` label are left untouched.
|
|
430
|
+
return {
|
|
431
|
+
applied: false,
|
|
432
|
+
direction: direction.direction,
|
|
433
|
+
staticMinutes,
|
|
434
|
+
provenance: 'static-transfer-pack',
|
|
435
|
+
asOf: null,
|
|
436
|
+
freshness: 'fallback',
|
|
437
|
+
cache: staticCache(cached ? 'stale' : 'bypass'),
|
|
438
|
+
evidenceClass: 'static_transfer_estimate',
|
|
439
|
+
fallbackReason: `${refusal.code}:${direction.direction}:${refusal.detail}`,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
275
442
|
const cacheInfo = {
|
|
276
443
|
status: cached ? 'requery' : 'miss',
|
|
277
444
|
ageS: 0,
|