@danceiny/gotry 0.2.0-rc.30 → 0.2.0-rc.32
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 +119 -136
- package/bin/gotry-extension-status.d.ts +12 -0
- package/bin/gotry-extension-status.js +113 -0
- package/bin/gotry-flyai-verification.d.ts +9 -0
- package/bin/gotry-flyai-verification.js +63 -0
- package/bin/gotry-hbcli-release.d.ts +3 -0
- package/bin/gotry-hbcli-release.js +4 -0
- package/bin/gotry-inner.js +2 -1
- package/client/client.js +228 -0
- package/dist/capabilities/doctor.js +14 -23
- package/dist/capabilities/effect.js +45 -12
- package/dist/capabilities/flyai-config.js +12 -10
- package/dist/capabilities/flyai.js +87 -56
- package/dist/capabilities/hbcli-config.js +156 -0
- package/dist/capabilities/hbcli.js +29 -2
- package/dist/capabilities/resilience.js +6 -6
- package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
- package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
- package/dist/capabilities/session/ctrip-city.js +186 -0
- package/dist/capabilities/session/extension-bridge.js +8 -1
- package/dist/capabilities/session/gui-flight.js +737 -0
- package/dist/capabilities/session/gui-hotel.js +413 -0
- package/dist/capabilities/session-search.js +98 -11
- package/dist/scripts/artifact-client-contract-tests.js +4 -3
- package/dist/scripts/artifact-delivery-tests.js +31 -0
- package/dist/scripts/bootstrap-tests.js +37 -10
- package/dist/scripts/ctrip-city-tests.js +80 -0
- package/dist/scripts/doctor-tests.js +36 -7
- package/dist/scripts/dsh-artifact-web-e2e.js +204 -43
- package/dist/scripts/effect-tests.js +602 -504
- package/dist/scripts/extension-tests.js +14 -8
- package/dist/scripts/flight-malformed-tests.js +1 -1
- package/dist/scripts/flyai-setup-tests.js +93 -5
- package/dist/scripts/flyai-tests.js +768 -653
- package/dist/scripts/gotry-web-api-tests.js +119 -0
- package/dist/scripts/gotry-web-client-tests.js +47 -0
- package/dist/scripts/gui-flight-live.js +27 -0
- package/dist/scripts/hbcli-tests.js +1 -1
- package/dist/scripts/hbcli-upgrade-tests.js +107 -0
- package/dist/scripts/hbcli-web-setup-tests.js +114 -0
- package/dist/scripts/hotel-date-gate-tests.js +1 -1
- package/dist/scripts/hotel-result-tests.js +169 -0
- package/dist/scripts/issue-271-liveness-tests.js +3 -1
- package/dist/scripts/session-tests.js +177 -6
- package/dist/src/artifact-delivery.js +26 -0
- package/dist/src/gotry-web-api.js +219 -0
- package/dist/src/index.js +45 -17
- package/extension/manifest.json +2 -2
- package/package.json +8 -2
- package/ts/capabilities/flyai-config.ts +12 -10
- package/ts/capabilities/flyai.ts +91 -50
- package/ts/capabilities/hbcli-config.ts +128 -0
- package/ts/capabilities/hbcli.ts +30 -3
- package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
- package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
- package/ts/capabilities/session/ctrip-city.ts +158 -0
- package/ts/capabilities/session/extension-bridge.ts +9 -2
- package/ts/capabilities/session/gui-flight.ts +792 -0
- package/ts/capabilities/session/gui-hotel.ts +451 -0
- package/ts/capabilities/session-search.ts +115 -15
- package/ts/src/artifact-delivery.ts +25 -0
- package/ts/src/gotry-web-api.ts +189 -0
- package/ts/src/index.ts +46 -18
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Bridge successful GoTry render results to DSH's native present/delivery protocol. */
|
|
2
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
3
|
+
import type {} from '@deepseek-ai/dsh-tool-present/types'
|
|
4
|
+
import type {} from '@deepseek-ai/dsh-tools'
|
|
5
|
+
import { realpathSync, statSync } from 'node:fs'
|
|
6
|
+
import { isAbsolute, relative, sep } from 'node:path'
|
|
7
|
+
|
|
8
|
+
export function registerArtifactDeliveries(ctx: Context): void {
|
|
9
|
+
// Tool-registration-only hosts have no conversation event surface.
|
|
10
|
+
if (typeof ctx.on !== 'function') return
|
|
11
|
+
ctx.on('tools/result', (exec, result) => {
|
|
12
|
+
if (result.isError || exec.signal.aborted || !['gotry_itinerary_render', 'gotry_itinerary_deck_render'].includes(exec.name)) return
|
|
13
|
+
const value = result.value as { ok?: boolean; path?: unknown } | null
|
|
14
|
+
const session = exec.agent?.session, cwd = session?.header.cwd
|
|
15
|
+
if (!session || !cwd || !isAbsolute(cwd) || value?.ok !== true || typeof value.path !== 'string' || !isAbsolute(value.path)) return
|
|
16
|
+
const boundary = ctx.get('sessionProjections')?.stateOf(session, 'turnBoundary')
|
|
17
|
+
if (!boundary || boundary.openTurnStartSeq === null) return
|
|
18
|
+
try {
|
|
19
|
+
const path = realpathSync(value.path), root = realpathSync(cwd), inside = relative(root, path)
|
|
20
|
+
if (inside === '..' || inside.startsWith('..' + sep) || isAbsolute(inside) || !statSync(path).isFile()) return
|
|
21
|
+
session.append('deliverables/presented', { turn: boundary.lastTurn, callId: exec.callId,
|
|
22
|
+
files: [{ path: value.path, description: 'GoTry itinerary' }] })
|
|
23
|
+
} catch { /* A deleted or escaped file cannot become a delivery. */ }
|
|
24
|
+
})
|
|
25
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/** Native Connection routes. Secrets never enter model tools or conversation events. */
|
|
2
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
3
|
+
import type { HostConnectionHandle } from '@deepseek-ai/dsh-client-connection'
|
|
4
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
import { isAbsolute } from 'node:path'
|
|
7
|
+
import { verifyFlyaiCandidate } from '../../bin/gotry-flyai-verification.js'
|
|
8
|
+
import { listArtifacts } from '../capabilities/artifacts.ts'
|
|
9
|
+
import {
|
|
10
|
+
atomicWriteJson, clearFlyaiKey, displayEndpoint, endpointFingerprint, flyaiConfigPath,
|
|
11
|
+
isPlaceholderFlyaiKey, maskFlyaiKey, readFlyaiConfig, readFlyaiVerification,
|
|
12
|
+
removeFlyaiVerification, resolveFlyaiEndpoint, resolveFlyaiKey, saveFlyaiKey,
|
|
13
|
+
sha256Hex, writeFlyaiVerification,
|
|
14
|
+
} from '../capabilities/flyai-config.ts'
|
|
15
|
+
import {
|
|
16
|
+
hbcliCredentialPath, hbcliSetupStatus, readHbcliCredentials, removeHbcliVerification,
|
|
17
|
+
verifyHbcliCandidate, withHbcliCredentials, withoutHbcliCredentials, writeHbcliCredentials, writeHbcliVerification,
|
|
18
|
+
} from '../capabilities/hbcli-config.ts'
|
|
19
|
+
|
|
20
|
+
interface WebOptions {
|
|
21
|
+
stateRoot: string
|
|
22
|
+
homeDir?: string
|
|
23
|
+
env?: NodeJS.ProcessEnv
|
|
24
|
+
hbcliBin?: string
|
|
25
|
+
workspaceForSession: (sessionId: string) => Promise<string | undefined>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const json = (value: unknown, status = 200) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } })
|
|
29
|
+
const rejection = (error: string, status = 400) => json({ ok: false, error }, status)
|
|
30
|
+
const BODY_LIMIT = 8192
|
|
31
|
+
|
|
32
|
+
async function readInput(request: Request): Promise<Record<string, unknown> | Response> {
|
|
33
|
+
if (request.headers.get('content-type')?.split(';')[0].trim() !== 'application/json') return rejection('需要 JSON 请求。', 415)
|
|
34
|
+
const reader = request.body?.getReader()
|
|
35
|
+
if (!reader) return rejection('请求为空。')
|
|
36
|
+
const chunks: Uint8Array[] = []
|
|
37
|
+
let bytes = 0
|
|
38
|
+
try {
|
|
39
|
+
while (true) {
|
|
40
|
+
const chunk = await reader.read()
|
|
41
|
+
if (chunk.done) break
|
|
42
|
+
bytes += chunk.value.byteLength
|
|
43
|
+
if (bytes > BODY_LIMIT) { await reader.cancel(); return rejection('请求过大。', 413) }
|
|
44
|
+
chunks.push(chunk.value)
|
|
45
|
+
}
|
|
46
|
+
const value: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
47
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : rejection('请求无效。')
|
|
48
|
+
} catch { return rejection('请求无效。') }
|
|
49
|
+
finally { reader.releaseLock() }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createGotryWebHandlers(options: WebOptions) {
|
|
53
|
+
const homeDir = options.homeDir ?? homedir(), env = options.env ?? process.env
|
|
54
|
+
const paths = { homeDir, env }, configPath = flyaiConfigPath(homeDir)
|
|
55
|
+
const hbPaths = { homeDir, env, hbcliBin: options.hbcliBin }
|
|
56
|
+
let mutations: Promise<unknown> = Promise.resolve()
|
|
57
|
+
const status = () => {
|
|
58
|
+
const current = resolveFlyaiKey(paths), endpoint = resolveFlyaiEndpoint(paths), receipt = readFlyaiVerification(homeDir)
|
|
59
|
+
const matches = current.key && receipt?.keySha256 === sha256Hex(current.key) && receipt.source === current.source
|
|
60
|
+
&& receipt.endpointFingerprint === endpointFingerprint(endpoint.url) && receipt.endpointDebug === endpoint.debug
|
|
61
|
+
return { ok: true, configured: Boolean(current.key), verified: Boolean(matches && receipt?.verdict === 'verified'),
|
|
62
|
+
writable: current.source !== 'env' && current.source !== 'env-debug' && process.platform !== 'win32', source: current.source,
|
|
63
|
+
endpoint: displayEndpoint(endpoint.url), endpointDebug: endpoint.debug,
|
|
64
|
+
...(matches && receipt ? { checkedAt: receipt.at, verdict: receipt.verdict } : {}) }
|
|
65
|
+
}
|
|
66
|
+
const bytesAt = () => existsSync(configPath) ? readFileSync(configPath, 'utf8') : undefined
|
|
67
|
+
async function mutate(input: Record<string, unknown>, signal: AbortSignal): Promise<Response> {
|
|
68
|
+
if (signal.aborted) return rejection('操作已取消。', 409)
|
|
69
|
+
if (!status().writable) return rejection('当前 Key 由环境变量提供或此平台不支持安全写入。', 409)
|
|
70
|
+
if (input.action === 'clear') {
|
|
71
|
+
const result = clearFlyaiKey(paths)
|
|
72
|
+
if (!result.ok) return rejection('无法清除配置,请检查本机配置文件及权限。', 409)
|
|
73
|
+
removeFlyaiVerification(homeDir)
|
|
74
|
+
return json(status())
|
|
75
|
+
}
|
|
76
|
+
const key = typeof input.key === 'string' ? input.key.trim() : ''
|
|
77
|
+
if (!key || key.length > 2048 || /[\r\n\0]/.test(key) || isPlaceholderFlyaiKey(key)) return rejection('请粘贴控制台中的真实 API Key。')
|
|
78
|
+
const before = readFlyaiConfig(configPath)
|
|
79
|
+
if (!before.ok) return rejection('原配置损坏,已保留原文件,请先修复。', 409)
|
|
80
|
+
const previousBytes = bytesAt(), endpoint = resolveFlyaiEndpoint(paths), fingerprint = endpointFingerprint(endpoint.url)
|
|
81
|
+
if (!fingerprint) return rejection('当前服务地址无效。', 409)
|
|
82
|
+
const verify = await verifyFlyaiCandidate(key, { env: { ...env, HOME: homeDir }, signal })
|
|
83
|
+
if (signal.aborted || verify.verdict !== 'hit') return json({ ok: false, verdict: signal.aborted ? 'cancelled' : verify.verdict,
|
|
84
|
+
error: signal.aborted ? '验证已取消,旧配置保留。' : verify.error ?? '验证失败,旧配置保留。' }, 422)
|
|
85
|
+
if (!status().writable || bytesAt() !== previousBytes || fingerprint !== endpointFingerprint(resolveFlyaiEndpoint(paths).url)) {
|
|
86
|
+
return rejection('配置在验证期间发生变化,请重新读取后再试。', 409)
|
|
87
|
+
}
|
|
88
|
+
const saved = saveFlyaiKey(key, paths)
|
|
89
|
+
if (!saved.ok) return rejection('安全保存失败,旧配置保留,请检查本机权限。', 409)
|
|
90
|
+
const receiptSaved = writeFlyaiVerification({ schema: 'gotry.flyai-verification.v1', source: 'config',
|
|
91
|
+
keySha256: sha256Hex(key), maskedKey: maskFlyaiKey(key), verdict: 'verified', endpointFingerprint: fingerprint,
|
|
92
|
+
endpointDebug: endpoint.debug, at: new Date().toISOString() }, homeDir)
|
|
93
|
+
if (!receiptSaved) {
|
|
94
|
+
const restored = previousBytes === undefined
|
|
95
|
+
? (() => { try { rmSync(configPath, { force: true }); return true } catch { return false } })()
|
|
96
|
+
: atomicWriteJson(configPath, before.data).ok
|
|
97
|
+
return rejection(restored ? '验证回执保存失败,旧配置已恢复。' : '验证回执保存失败,恢复旧配置失败,请检查本机文件。', 409)
|
|
98
|
+
}
|
|
99
|
+
return json(status())
|
|
100
|
+
}
|
|
101
|
+
async function mutateHbcli(input: Record<string, unknown>, signal: AbortSignal): Promise<Response> {
|
|
102
|
+
if (signal.aborted) return rejection('操作已取消。', 409)
|
|
103
|
+
if (!hbcliSetupStatus(hbPaths).writable) return rejection('当前凭证来自环境变量,或本机配置不可安全写入。', 409)
|
|
104
|
+
const before = readHbcliCredentials(hbPaths)
|
|
105
|
+
if (!before.ok) return rejection('原配置损坏,已保留原文件,请先修复。', 409)
|
|
106
|
+
if (input.action === 'clear') {
|
|
107
|
+
if (!writeHbcliCredentials(withoutHbcliCredentials(before.data), hbPaths)) return rejection('无法清除配置,请检查本机文件及权限。', 409)
|
|
108
|
+
removeHbcliVerification(homeDir)
|
|
109
|
+
return json(hbcliSetupStatus(hbPaths))
|
|
110
|
+
}
|
|
111
|
+
const appKey = typeof input.appKey === 'string' ? input.appKey.trim() : ''
|
|
112
|
+
const appSecret = typeof input.appSecret === 'string' ? input.appSecret.trim() : ''
|
|
113
|
+
if ([appKey, appSecret].some(value => !value || value.length > 2048 || /[\r\n\0]/.test(value))) return rejection('请填写 HotelByte 提供的 App Key 和 App Secret。')
|
|
114
|
+
const verified = await verifyHbcliCandidate(appKey, appSecret, hbPaths, signal)
|
|
115
|
+
if (signal.aborted || verified.verdict === 'error' || verified.verdict === 'cancelled') {
|
|
116
|
+
return json({ ok: false, error: verified.error ?? '验证已取消,旧配置保留。' }, 422)
|
|
117
|
+
}
|
|
118
|
+
const current = readHbcliCredentials(hbPaths)
|
|
119
|
+
if (!hbcliSetupStatus(hbPaths).writable || !current.ok || current.bytes !== before.bytes) return rejection('配置在验证期间发生变化,请重新读取后再试。', 409)
|
|
120
|
+
const next = withHbcliCredentials(before.data, appKey, appSecret)
|
|
121
|
+
if (!writeHbcliCredentials(next, hbPaths)) return rejection('安全保存失败,旧配置保留,请检查本机权限。', 409)
|
|
122
|
+
if (!writeHbcliVerification(next, verified.verdict, hbPaths)) {
|
|
123
|
+
const restored = before.bytes === undefined
|
|
124
|
+
? (() => { try { rmSync(hbcliCredentialPath(hbPaths)); return true } catch { return false } })()
|
|
125
|
+
: writeHbcliCredentials(before.data, hbPaths)
|
|
126
|
+
return rejection(restored ? '验证回执保存失败,旧配置已恢复。' : '验证回执保存失败,请检查本机文件。', 409)
|
|
127
|
+
}
|
|
128
|
+
return json(hbcliSetupStatus(hbPaths))
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
async hbcli(request: Request): Promise<Response> {
|
|
132
|
+
if (request.method === 'GET') return json(hbcliSetupStatus(hbPaths))
|
|
133
|
+
if (request.method !== 'POST') return rejection('不支持此操作。', 405)
|
|
134
|
+
const input = await readInput(request)
|
|
135
|
+
if (input instanceof Response) return input
|
|
136
|
+
if (!['save', 'clear'].includes(String(input.action))
|
|
137
|
+
|| Object.keys(input).some(key => !['action', 'appKey', 'appSecret'].includes(key))
|
|
138
|
+
|| (input.action === 'clear' && ('appKey' in input || 'appSecret' in input))) return rejection('请求无效。')
|
|
139
|
+
const operation = mutations.then(() => mutateHbcli(input, request.signal)).catch(() => rejection('操作失败,未能确认保存,请重新读取状态。', 500))
|
|
140
|
+
mutations = operation.then(() => undefined)
|
|
141
|
+
return operation
|
|
142
|
+
},
|
|
143
|
+
async flyai(request: Request): Promise<Response> {
|
|
144
|
+
if (request.method === 'GET') return json(status())
|
|
145
|
+
if (request.method !== 'POST') return rejection('不支持此操作。', 405)
|
|
146
|
+
const input = await readInput(request)
|
|
147
|
+
if (input instanceof Response) return input
|
|
148
|
+
if (!['save', 'clear'].includes(String(input.action)) || Object.keys(input).some(key => key !== 'action' && key !== 'key')
|
|
149
|
+
|| (input.action === 'clear' && 'key' in input)) return rejection('请求无效。')
|
|
150
|
+
const operation = mutations.then(() => mutate(input, request.signal)).catch(() => rejection('操作失败,未能确认保存,请重新读取状态。', 500))
|
|
151
|
+
mutations = operation.then(() => undefined)
|
|
152
|
+
return operation
|
|
153
|
+
},
|
|
154
|
+
async artifacts(request: Request): Promise<Response> {
|
|
155
|
+
const query = new URL(request.url).searchParams
|
|
156
|
+
if ([...query.keys()].some(key => !['sessionId', 'search', 'offset', 'limit'].includes(key))) return rejection('查询字段无效。')
|
|
157
|
+
const sessionId = query.get('sessionId') ?? ''
|
|
158
|
+
if (!/^[A-Za-z0-9_-]{1,256}$/.test(sessionId)) return rejection('需要有效会话。')
|
|
159
|
+
const cwd = await options.workspaceForSession(sessionId)
|
|
160
|
+
if (!cwd || !isAbsolute(cwd)) return rejection('会话工作目录不可用。', 404)
|
|
161
|
+
try {
|
|
162
|
+
return json({ ok: true, ...await listArtifacts({ stateRoot: options.stateRoot, cwd,
|
|
163
|
+
handoffRoot: env.GOTRY_TURN_HANDOFF_ROOT ?? (options.stateRoot !== '.' ? options.stateRoot : cwd),
|
|
164
|
+
search: query.get('search') ?? undefined, offset: query.has('offset') ? Number(query.get('offset')) : undefined,
|
|
165
|
+
limit: query.has('limit') ? Number(query.get('limit')) : 20 }) })
|
|
166
|
+
} catch { return rejection('查询参数无效或产物暂时不可读取。') }
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function registerGotryWebApi(ctx: Context, stateRoot: string, hbcliBin?: string): void {
|
|
172
|
+
if (typeof ctx.inject !== 'function') return
|
|
173
|
+
ctx.inject(['connection', 'sessions'], scope => {
|
|
174
|
+
const connection = scope.get('connection') as HostConnectionHandle
|
|
175
|
+
const handlers = createGotryWebHandlers({ stateRoot, hbcliBin, workspaceForSession: async sessionId => {
|
|
176
|
+
const id = sessionId as Parameters<typeof scope.sessions.get>[0]
|
|
177
|
+
const live = scope.sessions.get(id)?.header
|
|
178
|
+
const stored = live ? undefined : await scope.get('sessionPersistence')?.stat(sessionId)
|
|
179
|
+
return (live ?? stored?.header)?.cwd
|
|
180
|
+
} })
|
|
181
|
+
// DSH's streaming adapter always attaches a request body; GET cannot use
|
|
182
|
+
// it. Exact routes have one body mode, so reads and bounded writes differ.
|
|
183
|
+
scope.effect(() => connection.fetch.register({ path: '/api/gotry/flyai', methods: ['GET'], requestBody: 'buffered', fetch: handlers.flyai }))
|
|
184
|
+
scope.effect(() => connection.fetch.register({ path: '/api/gotry/flyai/write', methods: ['POST'], requestBody: 'streaming', fetch: handlers.flyai }))
|
|
185
|
+
scope.effect(() => connection.fetch.register({ path: '/api/gotry/hbcli', methods: ['GET'], requestBody: 'buffered', fetch: handlers.hbcli }))
|
|
186
|
+
scope.effect(() => connection.fetch.register({ path: '/api/gotry/hbcli/write', methods: ['POST'], requestBody: 'streaming', fetch: handlers.hbcli }))
|
|
187
|
+
scope.effect(() => connection.fetch.register({ path: '/api/gotry/artifacts', methods: ['GET'], requestBody: 'buffered', fetch: handlers.artifacts }))
|
|
188
|
+
})
|
|
189
|
+
}
|
package/ts/src/index.ts
CHANGED
|
@@ -59,6 +59,8 @@ import { generateItineraryDeckExport } from '../capabilities/itinerary-deck-expo
|
|
|
59
59
|
import { interpretEffect, declinedObservation, type EffectInterpreter } from '../capabilities/effect.ts'
|
|
60
60
|
import { customerSendCode, customerLogin } from '../capabilities/hbcli.ts'
|
|
61
61
|
import { FLYAI_POI_CATEGORIES, type FlyaiKind, type FlyaiQuery, type FlyaiResult } from '../capabilities/flyai.ts'
|
|
62
|
+
import { registerGotryWebApi } from './gotry-web-api.ts'
|
|
63
|
+
import { registerArtifactDeliveries } from './artifact-delivery.ts'
|
|
62
64
|
import { createFlyaiSetupTool } from './flyai-setup-tool.ts'
|
|
63
65
|
import { appendFacts, loadFactRegistry } from '../capabilities/fact-log.ts'
|
|
64
66
|
import { factsFromFlyai, factsFromHotel, factsFromSession, factsFromSessionTrain } from './bookable-facts.ts'
|
|
@@ -520,6 +522,10 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
|
|
|
520
522
|
// 固定 policy 保证评测可复现,不走路由。GOTRY_HANDOFF_CHILD=1 是收集器
|
|
521
523
|
// 派生的后台规划会话:唯一出口 converge + 长 leash——前台转后台,后台
|
|
522
524
|
// 必须产出最终交付物,不得再次 handoff(否则无限递归)。
|
|
525
|
+
if (!rawBenchmarkEnvironmentConfigPath) {
|
|
526
|
+
registerGotryWebApi(ctx, config.stateRoot ?? '.', config.hbcliBin)
|
|
527
|
+
registerArtifactDeliveries(ctx)
|
|
528
|
+
}
|
|
523
529
|
const handoffChild = process.env.GOTRY_HANDOFF_CHILD === '1'
|
|
524
530
|
installTurnDeadline(ctx, handoffChild
|
|
525
531
|
? { fixedPolicy: { softMs: 300_000, hardMs: 900_000, exit: 'converge' } }
|
|
@@ -1413,10 +1419,15 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
|
|
|
1413
1419
|
const resp = itp.result
|
|
1414
1420
|
const dir = await ensureStateDir(config.stateRoot)
|
|
1415
1421
|
const isLive = resp.via === 'hbcli-realtime'
|
|
1416
|
-
const
|
|
1422
|
+
const staticStays = (resp.hotels as { stays?: unknown[] } | null)?.stays
|
|
1423
|
+
const hasEstimate = Array.isArray(staticStays) && staticStays.length > 0
|
|
1424
|
+
const liveList = (resp.hotels as { list?: unknown[] } | null)?.list
|
|
1425
|
+
const verdict = isLive ? (liveList?.length ? 'hit' : 'miss') : hasEstimate ? 'estimated' : 'error'
|
|
1426
|
+
const evidence = hasEstimate ? '[静态包:估算]' : resp.evidence
|
|
1417
1427
|
await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `hotel_search:${resp.via}`).catch(() => {})
|
|
1418
1428
|
const payload = {
|
|
1419
|
-
ok:
|
|
1429
|
+
ok: isLive || hasEstimate,
|
|
1430
|
+
verdict,
|
|
1420
1431
|
hotels: resp.hotels ?? null,
|
|
1421
1432
|
evidence,
|
|
1422
1433
|
destination: q.destination,
|
|
@@ -1424,6 +1435,7 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
|
|
|
1424
1435
|
latency_ms: Date.now() - started,
|
|
1425
1436
|
summary: resp.summary,
|
|
1426
1437
|
error: resp.error,
|
|
1438
|
+
...(verdict !== 'hit' ? await routingField('search-hotel', 'hbcli-hotel') : {}),
|
|
1427
1439
|
...(dateNotes.length ? { date_notes: dateNotes } : {}),
|
|
1428
1440
|
} as never
|
|
1429
1441
|
return JSON.parse(JSON.stringify(payload)) as never
|
|
@@ -1450,13 +1462,14 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
|
|
|
1450
1462
|
}
|
|
1451
1463
|
}
|
|
1452
1464
|
const h = r.hotels
|
|
1453
|
-
const
|
|
1465
|
+
const liveList = (h as { list?: unknown[] } | null)?.list
|
|
1466
|
+
const liveCount = Array.isArray(liveList) ? liveList.length : 0
|
|
1454
1467
|
// 静态包是 {meta, stays} 对象而非数组(issue #24:此前计数恒 0 → UI 显示「无结果」)
|
|
1455
1468
|
const stays = !Array.isArray(h) && h && typeof h === 'object' ? (h as { stays?: unknown[] }).stays : undefined
|
|
1456
1469
|
const staticCount = Array.isArray(stays) ? stays.length : 0
|
|
1457
1470
|
const tag = r.via === 'hbcli-realtime'
|
|
1458
|
-
?
|
|
1459
|
-
: staticCount ? `静态包 ${staticCount} 块` :
|
|
1471
|
+
? `实时 ${liveCount} 家`
|
|
1472
|
+
: staticCount ? `静态包 ${staticCount} 块` : '查询未完成'
|
|
1460
1473
|
return {
|
|
1461
1474
|
card: 'generic',
|
|
1462
1475
|
title: `酒店:${r.destination ?? ''} ${tag}`,
|
|
@@ -1853,8 +1866,9 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
|
|
|
1853
1866
|
'Search on the USER\'S OWN browser session (Ctrip kind="flight"(default)/"hotel"; 12306 kind="train" — public query face, no login needed). '
|
|
1854
1867
|
+ '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). '
|
|
1855
1868
|
+ 'Transport: Stai Travel 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). '
|
|
1856
|
-
+ 'kind="flight": from/to
|
|
1857
|
-
+ '
|
|
1869
|
+
+ 'kind="flight": from/to city or airport names (Chinese/English) + date YYYY-MM-DD; unknown names resolve from supplier metadata, ambiguous names require confirmation; optional fromCityCode/toCityCode are explicit Ctrip three-letter codes — sniffs the site search API for structured options. '
|
|
1870
|
+
+ 'ui?="bridge" (default; the user\'s own logged-in session, real member prices) | "gui" (gotry opens its OWN isolated anonymous Chrome window and drives the Ctrip PC search form — roundtrip via backDate? and ANY city by construction: the site\'s own autocomplete resolves cities; anonymous public prices, NOT logged-in prices; verdicts carry anonymous=true; result-binding iteration tracked in #661). Evidence [会话:ctrip-flight@ts] / [会话:ctrip-flight-gui@ts]. '
|
|
1871
|
+
+ 'kind="hotel": to=destination name, country? for same-name disambiguation; cityId? = explicit numeric city= from a Ctrip hotel list URL; otherwise resolves from the hotel destination service, checkIn?/checkOut? (YYYY-MM-DD), adults?; hotel prices are the user\'s real logged-in prices. Evidence [会话:ctrip-hotel@ts]. '
|
|
1858
1872
|
+ '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]. '
|
|
1859
1873
|
+ '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]. '
|
|
1860
1874
|
+ '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); '
|
|
@@ -1867,21 +1881,26 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
|
|
|
1867
1881
|
// execute 内归一后走原条件闸,结构化报错不崩。flyai 因 kind required 仍在宿主权即拒。
|
|
1868
1882
|
parameters: {
|
|
1869
1883
|
kind: { type: 'string', enum: ['flight', 'hotel', 'train', 'dida'], description: '默认 flight 机票;hotel 携程酒店(用户登录态真实价);train 12306 余票(公开面);dida 供应商门户实时价(员工登录态)' },
|
|
1870
|
-
from: { type: 'string', description: '
|
|
1884
|
+
from: { type: 'string', description: '出发城市名(机票支持中英文及机场名,自动解析),如 上海——kind=flight|train 必填' },
|
|
1871
1885
|
to: { type: 'string', description: '到达城市中文——kind=flight|train 必填;kind=hotel 时为目的地(必填)' },
|
|
1872
1886
|
date: { type: 'string', description: '出发日期 YYYY-MM-DD——kind=flight|train 必填,须为今天或未来' },
|
|
1873
|
-
|
|
1887
|
+
fromCityCode: { type: 'string', description: '显式携程出发城市/机场三字码,仅 kind=flight;同名城市确认后使用' },
|
|
1888
|
+
toCityCode: { type: 'string', description: '显式携程到达城市/机场三字码,仅 kind=flight;同名城市确认后使用' },
|
|
1889
|
+
cityId: { type: 'integer', description: '显式携程酒店 city= 数字,仅 kind=hotel;未提供时自动解析酒店目的地,不能使用机票城市码' },
|
|
1890
|
+
country: { type: 'string', description: '目的地国家中英文,仅 kind=hotel;用于同名城市消歧,例如 阿联酋' },
|
|
1874
1891
|
checkIn: { type: 'string', description: '入住日 YYYY-MM-DD,仅 kind=hotel 可选' },
|
|
1875
1892
|
checkOut: { type: 'string', description: '退房日 YYYY-MM-DD,仅 kind=hotel 可选' },
|
|
1876
1893
|
adults: { type: 'integer', description: '成人数,仅 kind=hotel 可选' },
|
|
1877
1894
|
fromStationTelecode: { type: 'string', description: '出发站三位电报码,仅 kind=train;城市码表外时填(kyfw 查询页 URL 里)' },
|
|
1878
1895
|
toStationTelecode: { type: 'string', description: '到达站三位电报码,仅 kind=train,同上' },
|
|
1896
|
+
backDate: { type: 'string', description: '回程日期 YYYY-MM-DD,仅 kind=flight;存在即往返检索——须 ui=gui(GUI 车道站点表单原生往返;bridge 车道不知往返 URL 形状,不猜)' },
|
|
1897
|
+
ui: { type: 'string', enum: ['bridge', 'gui'], description: 'kind=flight|hotel:bridge(默认,用户登录态被动嗅探)|gui(gotry 隔离匿名窗口驱动 PC 表单——flight 往返/任意城市原生+低价日历;hotel 任意目的地原生(cityId 码表不在主链路);匿名公开价)' },
|
|
1879
1898
|
},
|
|
1880
1899
|
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String((v as { summary?: string }).summary ?? JSON.stringify(v).slice(0, 600)) }] },
|
|
1881
1900
|
async execute(args, _exec) {
|
|
1882
1901
|
const selectedKind = resolveSessionSearchKind(args)
|
|
1883
1902
|
if (!selectedKind.ok) return { ok: false, summary: selectedKind.reason } as const
|
|
1884
|
-
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 }
|
|
1903
|
+
const q = { ...unwrapQuery<{ kind?: string; from?: string; to?: string; date?: string; fromCityCode?: string; toCityCode?: string; cityId?: number | string; country?: string; checkIn?: string; checkOut?: string; adults?: number; fromStationTelecode?: string; toStationTelecode?: string; backDate?: string; ui?: string }>(args), kind: selectedKind.kind }
|
|
1885
1904
|
const runSessionEffect = seams.effect ?? interpretEffect
|
|
1886
1905
|
// ---- Dida 供应商门户(2026-09-09 实装;hotel-be portal integration 迁移线)----
|
|
1887
1906
|
if (q.kind === 'dida') {
|
|
@@ -1949,16 +1968,16 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
|
|
|
1949
1968
|
}
|
|
1950
1969
|
if (q.kind === 'hotel') {
|
|
1951
1970
|
if (!q.to) {
|
|
1952
|
-
return { ok: false, summary: 'kind=hotel 需要 to
|
|
1971
|
+
return { ok: false, summary: 'kind=hotel 需要 to(目的地名称);自动解析城市,同名城市可带 country 或确认后的 cityId' } as const
|
|
1953
1972
|
}
|
|
1954
1973
|
const itp = await runSessionEffect({
|
|
1955
|
-
effect: 'SESSION_HOTEL_SEARCH',
|
|
1974
|
+
effect: q.ui === 'gui' ? 'SESSION_HOTEL_GUI_SEARCH' : 'SESSION_HOTEL_SEARCH',
|
|
1956
1975
|
params: {
|
|
1957
|
-
to: q.to, cityId: q.cityId, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults,
|
|
1976
|
+
to: q.to, country: q.country, cityId: q.cityId, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults,
|
|
1958
1977
|
auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
|
|
1959
1978
|
},
|
|
1960
1979
|
})
|
|
1961
|
-
if (!itp.result) return declinedObservation('SESSION_HOTEL_SEARCH', itp.trace)
|
|
1980
|
+
if (!itp.result) return declinedObservation(q.ui === 'gui' ? 'SESSION_HOTEL_GUI_SEARCH' : 'SESSION_HOTEL_SEARCH', itp.trace)
|
|
1962
1981
|
const r = itp.result
|
|
1963
1982
|
await noteChannel('session:ctrip-hotel', r.verdict)
|
|
1964
1983
|
// D-26(issue #118):exact-date 会话酒店 hit/miss 落账(摸底无档期不落;cooldown 是节律闸非结论,不落)
|
|
@@ -1980,14 +1999,19 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
|
|
|
1980
1999
|
})) as Record<string, never>
|
|
1981
2000
|
}
|
|
1982
2001
|
if (!q.from || !q.to || !q.date) {
|
|
1983
|
-
return { ok: false, summary: '需要 from/to
|
|
2002
|
+
return { ok: false, summary: '需要 from/to(城市或机场名)与 date(YYYY-MM-DD);酒店/火车检索分别用 kind=hotel / kind=train' } as const
|
|
1984
2003
|
}
|
|
1985
2004
|
// 效应解译层(ADR-18):SESSION 通道策略=永不重试/不熔断,节律闸在渠道内;
|
|
1986
2005
|
// 解译器只做分发与证据拼装,verdict 语义(risk 型 needs-login/challenged)原样透传
|
|
2006
|
+
// GUI 车道(2026-10-14):ui=gui → gotry 隔离匿名窗口驱动 PC 表单(往返 backDate/任意城市原生)。
|
|
2007
|
+
if (q.ui !== undefined && q.ui !== 'bridge' && q.ui !== 'gui') {
|
|
2008
|
+
return { ok: false, summary: 'kind=flight 的 ui 只接受 bridge(默认,登录态被动嗅探)|gui(隔离匿名窗口驱动表单)' } as const
|
|
2009
|
+
}
|
|
1987
2010
|
const itp = await runSessionEffect({
|
|
1988
|
-
effect: 'SESSION_FLIGHT_SEARCH',
|
|
2011
|
+
effect: q.ui === 'gui' ? 'SESSION_FLIGHT_GUI_SEARCH' : 'SESSION_FLIGHT_SEARCH',
|
|
1989
2012
|
params: {
|
|
1990
|
-
from: q.from, to: q.to, date: q.date,
|
|
2013
|
+
from: q.from, to: q.to, date: q.date, fromCityCode: q.fromCityCode, toCityCode: q.toCityCode,
|
|
2014
|
+
...(q.backDate ? { backDate: q.backDate } : {}),
|
|
1991
2015
|
// ADR-15 收尾:ReadGuard 审计在生产工具路径同样落盘(此前仅测试传隔离 stateRoot 才有 JSONL)
|
|
1992
2016
|
auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
|
|
1993
2017
|
},
|
|
@@ -1999,8 +2023,12 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
|
|
|
1999
2023
|
const avMapS = await loadAirlineAirportMap()
|
|
2000
2024
|
await appendFacts(config.stateRoot ?? '.', factsFromSession({ origin: q.from, destination: q.to, date: q.date }, r, new Date().toISOString(), avMapS?.city_alias))
|
|
2001
2025
|
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}`)
|
|
2026
|
+
const lane = q.ui === 'gui' ? `GUI 车道(携程,匿名公开价${q.backDate ? ',往返' : ''})` : '会话检索(携程,用户本人登录态)'
|
|
2027
|
+
const cal = (r.calendar ?? []).slice(0, 14).map(d => `${d.date} ${d.price !== undefined ? `¥${d.price}` : (d.priceRaw ?? '?')}`)
|
|
2028
|
+
const calBlock = cal.length > 0 ? `\n低价日历(匿名公开价,该航线哪些日期有票):\n${cal.join(' | ')}` : ''
|
|
2029
|
+
const urlBlock = q.ui === 'gui' && r.listUrl ? `\nlistUrl(站点真实形状,#663 URL 教师):${r.listUrl.slice(0, 160)}` : ''
|
|
2002
2030
|
const summary = r.verdict === 'hit'
|
|
2003
|
-
? `${q.from}→${q.to} ${q.date}
|
|
2031
|
+
? `${q.from}→${q.to} ${q.date} ${lane}${(r.options ?? []).length > 0 ? ` 行情前 ${top.length} 条:\n${top.join('\n')}` : ''}${calBlock}${urlBlock}\n${r.evidence}`
|
|
2004
2032
|
: `会话检索未取回(${r.verdict}):${r.error ?? ''} ${r.evidence}`
|
|
2005
2033
|
return JSON.parse(JSON.stringify({
|
|
2006
2034
|
...r, summary,
|