@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.
Files changed (65) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +119 -136
  4. package/bin/gotry-extension-status.d.ts +12 -0
  5. package/bin/gotry-extension-status.js +113 -0
  6. package/bin/gotry-flyai-verification.d.ts +9 -0
  7. package/bin/gotry-flyai-verification.js +63 -0
  8. package/bin/gotry-hbcli-release.d.ts +3 -0
  9. package/bin/gotry-hbcli-release.js +4 -0
  10. package/bin/gotry-inner.js +2 -1
  11. package/client/client.js +228 -0
  12. package/dist/capabilities/doctor.js +14 -23
  13. package/dist/capabilities/effect.js +45 -12
  14. package/dist/capabilities/flyai-config.js +12 -10
  15. package/dist/capabilities/flyai.js +87 -56
  16. package/dist/capabilities/hbcli-config.js +156 -0
  17. package/dist/capabilities/hbcli.js +29 -2
  18. package/dist/capabilities/resilience.js +6 -6
  19. package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
  20. package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
  21. package/dist/capabilities/session/ctrip-city.js +186 -0
  22. package/dist/capabilities/session/extension-bridge.js +8 -1
  23. package/dist/capabilities/session/gui-flight.js +737 -0
  24. package/dist/capabilities/session/gui-hotel.js +413 -0
  25. package/dist/capabilities/session-search.js +98 -11
  26. package/dist/scripts/artifact-client-contract-tests.js +4 -3
  27. package/dist/scripts/artifact-delivery-tests.js +31 -0
  28. package/dist/scripts/bootstrap-tests.js +37 -10
  29. package/dist/scripts/ctrip-city-tests.js +80 -0
  30. package/dist/scripts/doctor-tests.js +36 -7
  31. package/dist/scripts/dsh-artifact-web-e2e.js +204 -43
  32. package/dist/scripts/effect-tests.js +602 -504
  33. package/dist/scripts/extension-tests.js +14 -8
  34. package/dist/scripts/flight-malformed-tests.js +1 -1
  35. package/dist/scripts/flyai-setup-tests.js +93 -5
  36. package/dist/scripts/flyai-tests.js +768 -653
  37. package/dist/scripts/gotry-web-api-tests.js +119 -0
  38. package/dist/scripts/gotry-web-client-tests.js +47 -0
  39. package/dist/scripts/gui-flight-live.js +27 -0
  40. package/dist/scripts/hbcli-tests.js +1 -1
  41. package/dist/scripts/hbcli-upgrade-tests.js +107 -0
  42. package/dist/scripts/hbcli-web-setup-tests.js +114 -0
  43. package/dist/scripts/hotel-date-gate-tests.js +1 -1
  44. package/dist/scripts/hotel-result-tests.js +169 -0
  45. package/dist/scripts/issue-271-liveness-tests.js +3 -1
  46. package/dist/scripts/session-tests.js +177 -6
  47. package/dist/src/artifact-delivery.js +26 -0
  48. package/dist/src/gotry-web-api.js +219 -0
  49. package/dist/src/index.js +45 -17
  50. package/extension/manifest.json +2 -2
  51. package/package.json +8 -2
  52. package/ts/capabilities/flyai-config.ts +12 -10
  53. package/ts/capabilities/flyai.ts +91 -50
  54. package/ts/capabilities/hbcli-config.ts +128 -0
  55. package/ts/capabilities/hbcli.ts +30 -3
  56. package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
  57. package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
  58. package/ts/capabilities/session/ctrip-city.ts +158 -0
  59. package/ts/capabilities/session/extension-bridge.ts +9 -2
  60. package/ts/capabilities/session/gui-flight.ts +792 -0
  61. package/ts/capabilities/session/gui-hotel.ts +451 -0
  62. package/ts/capabilities/session-search.ts +115 -15
  63. package/ts/src/artifact-delivery.ts +25 -0
  64. package/ts/src/gotry-web-api.ts +189 -0
  65. package/ts/src/index.ts +46 -18
package/dist/src/index.js CHANGED
@@ -42,6 +42,8 @@ import { generateItineraryDeckExport } from '../capabilities/itinerary-deck-expo
42
42
  import { interpretEffect, declinedObservation } from '../capabilities/effect.js';
43
43
  import { customerSendCode, customerLogin } from '../capabilities/hbcli.js';
44
44
  import { FLYAI_POI_CATEGORIES } from '../capabilities/flyai.js';
45
+ import { registerGotryWebApi } from './gotry-web-api.js';
46
+ import { registerArtifactDeliveries } from './artifact-delivery.js';
45
47
  import { createFlyaiSetupTool } from './flyai-setup-tool.js';
46
48
  import { appendFacts, loadFactRegistry } from '../capabilities/fact-log.js';
47
49
  import { factsFromFlyai, factsFromHotel, factsFromSession, factsFromSessionTrain } from './bookable-facts.js';
@@ -429,6 +431,10 @@ export function apply(ctx, config, seams = {}) {
429
431
  // 固定 policy 保证评测可复现,不走路由。GOTRY_HANDOFF_CHILD=1 是收集器
430
432
  // 派生的后台规划会话:唯一出口 converge + 长 leash——前台转后台,后台
431
433
  // 必须产出最终交付物,不得再次 handoff(否则无限递归)。
434
+ if (!rawBenchmarkEnvironmentConfigPath) {
435
+ registerGotryWebApi(ctx, config.stateRoot ?? '.', config.hbcliBin);
436
+ registerArtifactDeliveries(ctx);
437
+ }
432
438
  const handoffChild = process.env.GOTRY_HANDOFF_CHILD === '1';
433
439
  installTurnDeadline(ctx, handoffChild
434
440
  ? { fixedPolicy: { softMs: 300_000, hardMs: 900_000, exit: 'converge' } }
@@ -1299,10 +1305,15 @@ export function apply(ctx, config, seams = {}) {
1299
1305
  const resp = itp.result;
1300
1306
  const dir = await ensureStateDir(config.stateRoot);
1301
1307
  const isLive = resp.via === 'hbcli-realtime';
1302
- const evidence = isLive ? resp.evidence : '[静态包:估算]';
1308
+ const staticStays = resp.hotels?.stays;
1309
+ const hasEstimate = Array.isArray(staticStays) && staticStays.length > 0;
1310
+ const liveList = resp.hotels?.list;
1311
+ const verdict = isLive ? (liveList?.length ? 'hit' : 'miss') : hasEstimate ? 'estimated' : 'error';
1312
+ const evidence = hasEstimate ? '[静态包:估算]' : resp.evidence;
1303
1313
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `hotel_search:${resp.via}`).catch(() => { });
1304
1314
  const payload = {
1305
- ok: true,
1315
+ ok: isLive || hasEstimate,
1316
+ verdict,
1306
1317
  hotels: resp.hotels ?? null,
1307
1318
  evidence,
1308
1319
  destination: q.destination,
@@ -1310,6 +1321,7 @@ export function apply(ctx, config, seams = {}) {
1310
1321
  latency_ms: Date.now() - started,
1311
1322
  summary: resp.summary,
1312
1323
  error: resp.error,
1324
+ ...(verdict !== 'hit' ? await routingField('search-hotel', 'hbcli-hotel') : {}),
1313
1325
  ...(dateNotes.length ? { date_notes: dateNotes } : {}),
1314
1326
  };
1315
1327
  return JSON.parse(JSON.stringify(payload));
@@ -1336,13 +1348,14 @@ export function apply(ctx, config, seams = {}) {
1336
1348
  };
1337
1349
  }
1338
1350
  const h = r.hotels;
1339
- const liveCount = Array.isArray(h) ? h.length : 0;
1351
+ const liveList = h?.list;
1352
+ const liveCount = Array.isArray(liveList) ? liveList.length : 0;
1340
1353
  // 静态包是 {meta, stays} 对象而非数组(issue #24:此前计数恒 0 → UI 显示「无结果」)
1341
1354
  const stays = !Array.isArray(h) && h && typeof h === 'object' ? h.stays : undefined;
1342
1355
  const staticCount = Array.isArray(stays) ? stays.length : 0;
1343
1356
  const tag = r.via === 'hbcli-realtime'
1344
- ? (liveCount ? `实时 ${liveCount} 家` : '实时')
1345
- : staticCount ? `静态包 ${staticCount} 块` : (liveCount ? `${liveCount} 家` : '无结果');
1357
+ ? `实时 ${liveCount} 家`
1358
+ : staticCount ? `静态包 ${staticCount} 块` : '查询未完成';
1346
1359
  return {
1347
1360
  card: 'generic',
1348
1361
  title: `酒店:${r.destination ?? ''} ${tag}`,
@@ -1725,8 +1738,9 @@ export function apply(ctx, config, seams = {}) {
1725
1738
  description: routed('gotry_session_search', 'Search on the USER\'S OWN browser session (Ctrip kind="flight"(default)/"hotel"; 12306 kind="train" — public query face, no login needed). '
1726
1739
  + '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). '
1727
1740
  + '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). '
1728
- + 'kind="flight": from/to 中文城市名 + date YYYY-MM-DD — sniffs the site search API for structured options. Evidence [会话:ctrip-flight@ts]. '
1729
- + '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]. '
1741
+ + '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. '
1742
+ + '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]. '
1743
+ + '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]. '
1730
1744
  + '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]. '
1731
1745
  + '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]. '
1732
1746
  + '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); '
@@ -1739,15 +1753,20 @@ export function apply(ctx, config, seams = {}) {
1739
1753
  // execute 内归一后走原条件闸,结构化报错不崩。flyai 因 kind required 仍在宿主权即拒。
1740
1754
  parameters: {
1741
1755
  kind: { type: 'string', enum: ['flight', 'hotel', 'train', 'dida'], description: '默认 flight 机票;hotel 携程酒店(用户登录态真实价);train 12306 余票(公开面);dida 供应商门户实时价(员工登录态)' },
1742
- from: { type: 'string', description: '出发城市中文(词表内),如 上海——kind=flight|train 必填' },
1756
+ from: { type: 'string', description: '出发城市名(机票支持中英文及机场名,自动解析),如 上海——kind=flight|train 必填' },
1743
1757
  to: { type: 'string', description: '到达城市中文——kind=flight|train 必填;kind=hotel 时为目的地(必填)' },
1744
1758
  date: { type: 'string', description: '出发日期 YYYY-MM-DD——kind=flight|train 必填,须为今天或未来' },
1745
- cityId: { type: 'integer', description: '携程酒店 city= 数字,仅 kind=hotel;目的地码表外时 web 搜 hotels.ctrip.com list URL 取 city=' },
1759
+ fromCityCode: { type: 'string', description: '显式携程出发城市/机场三字码,仅 kind=flight;同名城市确认后使用' },
1760
+ toCityCode: { type: 'string', description: '显式携程到达城市/机场三字码,仅 kind=flight;同名城市确认后使用' },
1761
+ cityId: { type: 'integer', description: '显式携程酒店 city= 数字,仅 kind=hotel;未提供时自动解析酒店目的地,不能使用机票城市码' },
1762
+ country: { type: 'string', description: '目的地国家中英文,仅 kind=hotel;用于同名城市消歧,例如 阿联酋' },
1746
1763
  checkIn: { type: 'string', description: '入住日 YYYY-MM-DD,仅 kind=hotel 可选' },
1747
1764
  checkOut: { type: 'string', description: '退房日 YYYY-MM-DD,仅 kind=hotel 可选' },
1748
1765
  adults: { type: 'integer', description: '成人数,仅 kind=hotel 可选' },
1749
1766
  fromStationTelecode: { type: 'string', description: '出发站三位电报码,仅 kind=train;城市码表外时填(kyfw 查询页 URL 里)' },
1750
1767
  toStationTelecode: { type: 'string', description: '到达站三位电报码,仅 kind=train,同上' },
1768
+ backDate: { type: 'string', description: '回程日期 YYYY-MM-DD,仅 kind=flight;存在即往返检索——须 ui=gui(GUI 车道站点表单原生往返;bridge 车道不知往返 URL 形状,不猜)' },
1769
+ ui: { type: 'string', enum: ['bridge', 'gui'], description: 'kind=flight|hotel:bridge(默认,用户登录态被动嗅探)|gui(gotry 隔离匿名窗口驱动 PC 表单——flight 往返/任意城市原生+低价日历;hotel 任意目的地原生(cityId 码表不在主链路);匿名公开价)' },
1751
1770
  },
1752
1771
  output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String(v.summary ?? JSON.stringify(v).slice(0, 600)) }] },
1753
1772
  async execute(args, _exec) {
@@ -1824,17 +1843,17 @@ export function apply(ctx, config, seams = {}) {
1824
1843
  }
1825
1844
  if (q.kind === 'hotel') {
1826
1845
  if (!q.to) {
1827
- return { ok: false, summary: 'kind=hotel 需要 to(目的地中文;城市码表外带 cityId=携程酒店 list 页 URL 里的 city= 数字)' };
1846
+ return { ok: false, summary: 'kind=hotel 需要 to(目的地名称);自动解析城市,同名城市可带 country 或确认后的 cityId' };
1828
1847
  }
1829
1848
  const itp = await runSessionEffect({
1830
- effect: 'SESSION_HOTEL_SEARCH',
1849
+ effect: q.ui === 'gui' ? 'SESSION_HOTEL_GUI_SEARCH' : 'SESSION_HOTEL_SEARCH',
1831
1850
  params: {
1832
- to: q.to, cityId: q.cityId, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults,
1851
+ to: q.to, country: q.country, cityId: q.cityId, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults,
1833
1852
  auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
1834
1853
  },
1835
1854
  });
1836
1855
  if (!itp.result)
1837
- return declinedObservation('SESSION_HOTEL_SEARCH', itp.trace);
1856
+ return declinedObservation(q.ui === 'gui' ? 'SESSION_HOTEL_GUI_SEARCH' : 'SESSION_HOTEL_SEARCH', itp.trace);
1838
1857
  const r = itp.result;
1839
1858
  await noteChannel('session:ctrip-hotel', r.verdict);
1840
1859
  // D-26(issue #118):exact-date 会话酒店 hit/miss 落账(摸底无档期不落;cooldown 是节律闸非结论,不落)
@@ -1856,14 +1875,19 @@ export function apply(ctx, config, seams = {}) {
1856
1875
  }));
1857
1876
  }
1858
1877
  if (!q.from || !q.to || !q.date) {
1859
- return { ok: false, summary: '需要 from/to(中文城市名,词表内)与 date(YYYY-MM-DD);酒店/火车检索分别用 kind=hotel / kind=train' };
1878
+ return { ok: false, summary: '需要 from/to(城市或机场名)与 date(YYYY-MM-DD);酒店/火车检索分别用 kind=hotel / kind=train' };
1860
1879
  }
1861
1880
  // 效应解译层(ADR-18):SESSION 通道策略=永不重试/不熔断,节律闸在渠道内;
1862
1881
  // 解译器只做分发与证据拼装,verdict 语义(risk 型 needs-login/challenged)原样透传
1882
+ // GUI 车道(2026-10-14):ui=gui → gotry 隔离匿名窗口驱动 PC 表单(往返 backDate/任意城市原生)。
1883
+ if (q.ui !== undefined && q.ui !== 'bridge' && q.ui !== 'gui') {
1884
+ return { ok: false, summary: 'kind=flight 的 ui 只接受 bridge(默认,登录态被动嗅探)|gui(隔离匿名窗口驱动表单)' };
1885
+ }
1863
1886
  const itp = await runSessionEffect({
1864
- effect: 'SESSION_FLIGHT_SEARCH',
1887
+ effect: q.ui === 'gui' ? 'SESSION_FLIGHT_GUI_SEARCH' : 'SESSION_FLIGHT_SEARCH',
1865
1888
  params: {
1866
- from: q.from, to: q.to, date: q.date,
1889
+ from: q.from, to: q.to, date: q.date, fromCityCode: q.fromCityCode, toCityCode: q.toCityCode,
1890
+ ...(q.backDate ? { backDate: q.backDate } : {}),
1867
1891
  // ADR-15 收尾:ReadGuard 审计在生产工具路径同样落盘(此前仅测试传隔离 stateRoot 才有 JSONL)
1868
1892
  auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
1869
1893
  },
@@ -1876,8 +1900,12 @@ export function apply(ctx, config, seams = {}) {
1876
1900
  const avMapS = await loadAirlineAirportMap();
1877
1901
  await appendFacts(config.stateRoot ?? '.', factsFromSession({ origin: q.from, destination: q.to, date: q.date }, r, new Date().toISOString(), avMapS?.city_alias));
1878
1902
  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}`);
1903
+ const lane = q.ui === 'gui' ? `GUI 车道(携程,匿名公开价${q.backDate ? ',往返' : ''})` : '会话检索(携程,用户本人登录态)';
1904
+ const cal = (r.calendar ?? []).slice(0, 14).map(d => `${d.date} ${d.price !== undefined ? `¥${d.price}` : (d.priceRaw ?? '?')}`);
1905
+ const calBlock = cal.length > 0 ? `\n低价日历(匿名公开价,该航线哪些日期有票):\n${cal.join(' | ')}` : '';
1906
+ const urlBlock = q.ui === 'gui' && r.listUrl ? `\nlistUrl(站点真实形状,#663 URL 教师):${r.listUrl.slice(0, 160)}` : '';
1879
1907
  const summary = r.verdict === 'hit'
1880
- ? `${q.from}→${q.to} ${q.date} 会话检索(携程,用户本人登录态)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}`
1908
+ ? `${q.from}→${q.to} ${q.date} ${lane}${(r.options ?? []).length > 0 ? ` 行情前 ${top.length} 条:\n${top.join('\n')}` : ''}${calBlock}${urlBlock}\n${r.evidence}`
1881
1909
  : `会话检索未取回(${r.verdict}):${r.error ?? ''} ${r.evidence}`;
1882
1910
  return JSON.parse(JSON.stringify({
1883
1911
  ...r, summary,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Stai Travel Bridge",
4
- "version": "0.2.0.30",
4
+ "version": "0.2.0.32",
5
5
  "description": "Stai 连接获准的旅行检索与供应商门户:检索结果经本机或授权后端桥接;门户可触发一次性登录代填,扩展不持久保存凭据。",
6
6
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnuzBX8rT8+RZKEUip5VRA0cY056pExy9PNdN9YCiuY3dgglRbcIBhjuFJoR6EX3/MaUv82vJVyItRVrowXLTwDk4WZof0vwFgKL0zBK304PC46pUaAKTSu1PgXeL1+j5KTAGz/9a9+RnPdbj7jND5rM/MOmBHNPVPpp1NlwxRGIgF9OxHSOfXORcH4Iyte1aJSFUJbFtNS/DaE5D+KjyupPLyIqVN/dCiKnNsTCEHQB9ngeli665PFp76hkccHQQZ4sVuMLS4zBPcazUsp1kcnW+xsQoUATkODTCY+Pzk3zUVF1QOESTjRCVnbNi8tkO43RMRs2q63gX/3inFivcEwIDAQAB",
7
7
  "permissions": [
@@ -56,5 +56,5 @@
56
56
  "run_at": "document_start"
57
57
  }
58
58
  ],
59
- "version_name": "0.2.0-rc.30"
59
+ "version_name": "0.2.0-rc.32"
60
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danceiny/gotry",
3
- "version": "0.2.0-rc.30",
3
+ "version": "0.2.0-rc.32",
4
4
  "description": "GoTry — 从出发到下一次出发的 AI 旅行 Agent(dsh 插件)。npm/源码入口共用锁定的 dsh runtime + README 安装路径。",
5
5
  "type": "module",
6
6
  "main": "ts/src/index.ts",
@@ -44,7 +44,10 @@
44
44
  "client": {
45
45
  "platform": "web",
46
46
  "inject": [
47
- "@deepseek-ai/dsh-client-ui-tool"
47
+ "@deepseek-ai/dsh-client-ui-tool",
48
+ "@deepseek-ai/dsh-client-ui-primitives",
49
+ "@deepseek-ai/dsh-client-ui-plugin-manager",
50
+ "@deepseek-ai/dsh-client-ui-sidebar-right"
48
51
  ]
49
52
  }
50
53
  },
@@ -57,6 +60,8 @@
57
60
  "bin/",
58
61
  "ts/src/index.ts",
59
62
  "ts/src/flyai-setup-tool.ts",
63
+ "ts/src/gotry-web-api.ts",
64
+ "ts/src/artifact-delivery.ts",
60
65
  "ts/src/subagent-job-id-guard.ts",
61
66
  "ts/src/benchmark-environment-bridge.ts",
62
67
  "ts/src/benchmark-tool-isolation.ts",
@@ -110,6 +115,7 @@
110
115
  "ts/capabilities/opensky.ts",
111
116
  "ts/capabilities/flyai.ts",
112
117
  "ts/capabilities/flyai-config.ts",
118
+ "ts/capabilities/hbcli-config.ts",
113
119
  "ts/capabilities/session-search.ts",
114
120
  "ts/capabilities/session/",
115
121
  "extension/",
@@ -185,22 +185,22 @@ export function verifySecureMode(dir: string, file: string): { ok: boolean; erro
185
185
  /**
186
186
  * 官方 CLI 1.0.16 首次运行可能先创建 ~/.flyai 为 0755,并在其中写入
187
187
  * 0600 device-id。仅对当前用户拥有且本身不是 symlink 的配置目录收紧到
188
- * 0700;验证回执所在 ~/.gotry 仍严格拒绝既有 0755 目录。
188
+ * 0700。回执所在 ~/.gotry 采用相同迁移规则,兼容 doctor 创建的目录。
189
189
  */
190
- function prepareFlyaiConfigDir(dir: string, file: string): { ok: boolean; error?: string; unsupported?: boolean } {
190
+ function preparePrivateDir(dir: string, file: string): { ok: boolean; error?: string; unsupported?: boolean } {
191
191
  if (process.platform === 'win32') return { ok: false, unsupported: true, error: 'Windows 无 0700/0600 权限位,安全写不支持' }
192
192
  try {
193
193
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
194
194
  const entry = lstatSync(dir)
195
- if (entry.isSymbolicLink()) return { ok: false, error: '.flyai 目录是符号链接,拒绝写入' }
196
- if (!entry.isDirectory()) return { ok: false, error: '.flyai 路径不是目录,拒绝写入' }
195
+ if (entry.isSymbolicLink()) return { ok: false, error: '配置目录是符号链接,拒绝写入' }
196
+ if (!entry.isDirectory()) return { ok: false, error: '配置路径不是目录,拒绝写入' }
197
197
  if (typeof process.getuid === 'function' && entry.uid !== process.getuid()) {
198
- return { ok: false, error: '.flyai 目录不属于当前用户,拒绝写入' }
198
+ return { ok: false, error: '配置目录不属于当前用户,拒绝写入' }
199
199
  }
200
200
  if ((entry.mode & 0o777) !== 0o700) chmodSync(dir, 0o700)
201
201
  const after = lstatSync(dir)
202
- if (after.isSymbolicLink() || !after.isDirectory()) return { ok: false, error: '.flyai 目录状态发生变化,拒绝写入' }
203
- if (typeof process.getuid === 'function' && after.uid !== process.getuid()) return { ok: false, error: '.flyai 目录所有者发生变化,拒绝写入' }
202
+ if (after.isSymbolicLink() || !after.isDirectory()) return { ok: false, error: '配置目录状态发生变化,拒绝写入' }
203
+ if (typeof process.getuid === 'function' && after.uid !== process.getuid()) return { ok: false, error: '配置目录所有者发生变化,拒绝写入' }
204
204
  return verifySecureMode(dir, file)
205
205
  } catch (e) {
206
206
  return { ok: false, error: (e as Error).message }
@@ -259,7 +259,7 @@ export function saveFlyaiKey(key: string, opts: FlyaiConfigPaths = {}): SaveFlya
259
259
  const read = readFlyaiConfig(configPath)
260
260
  if (!read.ok) return { ok: false, error: `原配置损坏,已拒绝覆盖(${read.error});请先人工修复 ${configPath}` }
261
261
 
262
- const configDir = prepareFlyaiConfigDir(dirname(configPath), configPath)
262
+ const configDir = preparePrivateDir(dirname(configPath), configPath)
263
263
  if (!configDir.ok) return { ok: false, error: configDir.error }
264
264
  const write = atomicWriteJson(configPath, { ...read.data, FLYAI_API_KEY: text })
265
265
  if (!write.ok) return { ok: false, error: write.error }
@@ -297,7 +297,7 @@ export function clearFlyaiKey(opts: FlyaiConfigPaths = {}): ClearFlyaiKeyResult
297
297
  return { ok: false, source: 'none', error: `移除文件失败:${(e as Error).message}` }
298
298
  }
299
299
  } else {
300
- const configDir = prepareFlyaiConfigDir(dirname(configPath), configPath)
300
+ const configDir = preparePrivateDir(dirname(configPath), configPath)
301
301
  if (!configDir.ok) return { ok: false, source: 'none', error: configDir.error }
302
302
  const write = atomicWriteJson(configPath, next)
303
303
  if (!write.ok) return { ok: false, source: 'none', error: write.error }
@@ -355,7 +355,9 @@ export function readFlyaiVerification(homeDir: string): FlyaiVerification | unde
355
355
 
356
356
  /** 原子写验证记录(权限硬校验);成功=true;失败不抛错 */
357
357
  export function writeFlyaiVerification(entry: FlyaiVerification, homeDir: string): boolean {
358
- return atomicWriteJson(flyaiVerificationPath(homeDir), entry).ok
358
+ const path = flyaiVerificationPath(homeDir)
359
+ const prepared = preparePrivateDir(dirname(path), path)
360
+ return prepared.ok && atomicWriteJson(path, entry).ok
359
361
  }
360
362
 
361
363
  export function removeFlyaiVerification(homeDir: string): void {
@@ -32,7 +32,7 @@
32
32
  import { closeSync, openSync, readFileSync, unlinkSync } from 'node:fs'
33
33
  import { tmpdir } from 'node:os'
34
34
  import { join } from 'node:path'
35
- import { resolveFlyaiEndpoint, resolveFlyaiKey, type FlyaiKeySource } from './flyai-config.ts'
35
+ import { normalizeEndpoint, resolveFlyaiEndpoint, resolveFlyaiKey, sha256Hex, type FlyaiKeySource } from './flyai-config.ts'
36
36
  import { spawnBounded } from './spawn-bounded.ts'
37
37
 
38
38
  export type FlyaiKind =
@@ -239,6 +239,8 @@ export interface FlyaiResult {
239
239
  verdict: FlyaiVerdict
240
240
  /** true = 已识别的上游普通网络/HTTP 5xx/429 瞬时失败,可交给 effect 层退避重试 */
241
241
  retryable?: boolean
242
+ /** 独立于重试:只在明确上游故障时计熔断;unknown 不增失败,也不宣称恢复。 */
243
+ upstreamHealth?: 'healthy' | 'unhealthy' | 'unknown'
242
244
  /** 存在时表示请求未得到可判定的上游响应,应 fail-closed 且只执行一次。 */
243
245
  localTermination?: FlyaiLocalTermination
244
246
  kind: FlyaiKind
@@ -411,7 +413,7 @@ export function parseFlyaiItemList(stdout: string): unknown[] {
411
413
  }
412
414
 
413
415
  /** 解析 stdout 中首个完整 JSON 对象(不限 itemList;ai-search 用) */
414
- export function parseFlyaiJsonEnvelope(stdout: string): Record<string, unknown> {
416
+ export function parseFlyaiJsonEnvelope(stdout: string, accepts: (envelope: Record<string, unknown>) => boolean = () => true): Record<string, unknown> {
415
417
  let start = -1
416
418
  let depth = 0
417
419
  let inString = false
@@ -435,7 +437,12 @@ export function parseFlyaiJsonEnvelope(stdout: string): Record<string, unknown>
435
437
  depth -= 1
436
438
  if (depth === 0) {
437
439
  const candidate = stdout.slice(start, index + 1)
438
- try { return JSON.parse(candidate) as Record<string, unknown> } catch { start = -1; depth = 0 }
440
+ try {
441
+ const parsed = JSON.parse(candidate) as Record<string, unknown>
442
+ if (accepts(parsed)) return parsed
443
+ } catch { /* prefix logs may contain balanced non-JSON braces */ }
444
+ start = -1
445
+ depth = 0
439
446
  }
440
447
  }
441
448
  }
@@ -749,6 +756,15 @@ const KIND_LABEL: Record<FlyaiKind, string> = {
749
756
  keyword: 'keyword', ai: 'ai', 'marriott-hotel': 'marriott hotel', 'marriott-package': 'marriott package',
750
757
  }
751
758
 
759
+ /** 每种检索 × 生效 endpoint/key 隔离;Map 中只留下不可逆指纹。 */
760
+ export function flyaiBreakerScope(kind: unknown): string {
761
+ const key = resolveFlyaiKey()
762
+ const endpoint = resolveFlyaiEndpoint().url
763
+ const identity = sha256Hex(JSON.stringify([normalizeEndpoint(endpoint) ?? endpoint, key.key ?? null]))
764
+ const safeKind = typeof kind === 'string' && Object.hasOwn(KIND_LABEL, kind) ? kind : 'invalid-kind'
765
+ return `${safeKind}:${identity}`
766
+ }
767
+
752
768
  /**
753
769
  * FlyAI 官方只读检索(8 命令)— 任何失败走降级;不抛错。
754
770
  */
@@ -758,9 +774,9 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
758
774
  const envContext = await runtimeEnvNote()
759
775
  const envNote = envContext.note
760
776
  const sensitive = envContext.sensitive
761
- const safe = <T extends FlyaiResult>(result: T): T => safeFlyaiResult(result, sensitive)
777
+ const safe = (result: FlyaiResult): FlyaiResult => safeFlyaiResult({ upstreamHealth: 'unknown', retryable: false, ...result }, sensitive)
762
778
  const built = buildCliArgs(q)
763
- if (built.error) return { ...badArg(q.kind, built.error, ts), ...envNote }
779
+ if (built.error) return safe({ ...badArg(q.kind, built.error, ts), ...envNote })
764
780
 
765
781
  const prefix = q.cliPrefixArgs ?? ['-y', '@fly-ai/flyai-cli@1.0.16']
766
782
  const r = await sh(q.cliBin ?? 'npx', [...prefix, ...built.args!], {
@@ -769,7 +785,6 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
769
785
  signal: q.signal,
770
786
  })
771
787
  const latencyMs = Date.now() - started
772
- const combined = `${r.stderr}\n${r.stdout}`
773
788
 
774
789
  if (r.cancelled) {
775
790
  return safe({
@@ -797,37 +812,55 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
797
812
  return safe(localTerminationResult(q.kind, r.localTermination, r, latencyMs, ts, envNote))
798
813
  }
799
814
 
800
- if (r.error || r.code !== 0 || /HTTP\s*(4\d\d|5\d\d)/.test(combined)) {
815
+ if (r.error || r.code !== 0 || explicitUpstreamFailure(r.stderr)) {
801
816
  return safe(classifyUpstreamFailure(q.kind, r, latencyMs, ts, envNote))
802
817
  }
803
818
 
819
+ // Validate the response envelope before accepting inventory for any kind.
820
+ // A business status is not an HTTP status; successful data is never scanned
821
+ // for fault words (for example an AI answer quoting an HTTP error).
822
+ let envelope: Record<string, unknown>
823
+ try {
824
+ envelope = parseFlyaiJsonEnvelope(r.stdout, candidate => ['data', 'status', 'error'].some(key => Object.hasOwn(candidate, key))
825
+ || explicitUpstreamFailure(nonEmptyText(candidate.message) ?? ''))
826
+ } catch (e) {
827
+ if (explicitUpstreamFailure(r.stdout)) return safe(classifyUpstreamFailure(q.kind, r, latencyMs, ts, envNote))
828
+ const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160)
829
+ return safe({
830
+ kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
831
+ evidence: `[实时API:flyai@error@${ts}] parse failed: ${raw}`,
832
+ error: `failed to parse flyai output (${(e as Error).message}): ${raw}`,
833
+ })
834
+ }
835
+ const systemMessage = nonEmptyText(envelope.systemMessage)
836
+ const envelopeFailure = `${nonEmptyText(envelope.message) ?? ''}\n${envelope.error == null ? '' : JSON.stringify(envelope.error)}`
837
+ if (explicitUpstreamFailure(envelopeFailure)) {
838
+ return safe(classifyUpstreamFailure(q.kind, { ...r, stdout: envelopeFailure }, latencyMs, ts, envNote))
839
+ }
840
+ if ((envelope.status !== undefined && envelope.status !== 0) || envelope.error != null) {
841
+ return safe({
842
+ kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
843
+ evidence: `[实时API:flyai@error@${ts}] upstream response status=${String(envelope.status ?? 'unknown')}`,
844
+ error: nonEmptyText(envelope.message) ?? 'FlyAI upstream response rejected the query',
845
+ ...(systemMessage ? { systemMessage } : {}),
846
+ })
847
+ }
848
+
804
849
  // ai-search:data 是自由形状,不走 itemList。
805
850
  if (q.kind === 'ai') {
806
- let envelope: Record<string, unknown>
807
- try {
808
- envelope = parseFlyaiJsonEnvelope(r.stdout)
809
- } catch (e) {
810
- const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160)
811
- return safe({
812
- kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
813
- evidence: `[实时API:flyai@error@${ts}] parse failed: ${raw}`,
814
- error: `failed to parse flyai ai output (${(e as Error).message}): ${raw}`,
815
- })
816
- }
817
- const systemMessage = nonEmptyText(envelope.systemMessage)
818
- if (envelope.status !== undefined && envelope.status !== 0) {
851
+ if (!Object.hasOwn(envelope, 'data')) {
819
852
  return safe({
820
853
  kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
821
- evidence: `[实时API:flyai@error@${ts}] ai status=${String(envelope.status)}`,
822
- error: nonEmptyText(envelope.message) ?? 'ai-search upstream failure',
854
+ evidence: `[实时API:flyai@error@${ts}] malformed ai response: missing data`,
855
+ error: 'FlyAI ai response malformed: missing data member',
823
856
  ...(systemMessage ? { systemMessage } : {}),
824
857
  })
825
858
  }
826
859
  const aiData = envelope.data
827
- const empty = aiData === undefined || aiData === null || aiData === ''
860
+ const empty = aiData === null || aiData === ''
828
861
  || (Array.isArray(aiData) && aiData.length === 0)
829
862
  return safe(JSON.parse(JSON.stringify({
830
- kind: q.kind, ...envNote, latencyMs, ok: true, via: 'flyai',
863
+ kind: q.kind, ...envNote, latencyMs, ok: true, via: 'flyai', upstreamHealth: 'healthy',
831
864
  verdict: empty ? 'miss' : 'hit',
832
865
  ...(empty ? {} : { aiData }),
833
866
  ...(systemMessage ? { systemMessage } : {}),
@@ -835,22 +868,10 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
835
868
  })) as FlyaiResult)
836
869
  }
837
870
 
838
- let items: unknown[]
839
- try {
840
- items = parseFlyaiItemList(r.stdout) as RawItem[]
841
- } catch (e) {
871
+ const data = envelope.data as { itemList?: unknown } | null
872
+ if (!Array.isArray(data?.itemList)) {
842
873
  const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160)
843
- // Sentinel 限流:exit 0,stdout 为 {"message":"SentinelBlockException…"}
844
- if (/Sentinel/i.test(raw)) {
845
- return safe({
846
- kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
847
- retryable: false,
848
- evidence: `[实时API:flyai@error@${ts}] sentinel: ${raw}`,
849
- error: `FlyAI Sentinel 限流:${raw}`,
850
- setup: '平台 Sentinel 限流(恢复窗口未公开)——稍后重试,或改走 gotry_session_search(账号会话)。',
851
- })
852
- }
853
- const reason = (e as Error).message
874
+ const reason = 'no complete FlyAI itemList JSON object'
854
875
  return safe({
855
876
  kind: q.kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict: 'error',
856
877
  retryable: false,
@@ -859,7 +880,17 @@ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
859
880
  })
860
881
  }
861
882
 
862
- return safe(parseItemListResult(q.kind, items, latencyMs, ts, envNote, r.stdout))
883
+ return safe(parseItemListResult(q.kind, data.itemList, latencyMs, ts, envNote, r.stdout))
884
+ }
885
+
886
+ // Explicit protocol/transport evidence only. Bare business codes and generic
887
+ // words such as "network" or "timeout" do not diagnose an upstream outage.
888
+ const HTTP_FAILURE = /\bHTTP(?:\/[\d.]+)?\s*[:=]?\s*([45]\d\d)\b/i
889
+ const TRANSPORT_FAILURE = /\b(?:ETIMEDOUT|ECONNRESET|ECONNREFUSED|ECONNABORTED|EHOSTUNREACH|ENETUNREACH|EAI_AGAIN)\b|socket hang up|fetch failed|network (?:error|failure|unreachable)|(?:request|connection) (?:timed out|timeout|reset|refused)/i
890
+
891
+ function explicitUpstreamFailure(text: string): boolean {
892
+ return HTTP_FAILURE.test(text) || TRANSPORT_FAILURE.test(text)
893
+ || /Invalid API key|Trial limit reached|SentinelBlockException/i.test(text)
863
894
  }
864
895
 
865
896
  /** 当前进程 key 来源/endpoint 覆盖(仅展示,与 spawn 实际生效同源) */
@@ -886,39 +917,47 @@ function classifyUpstreamFailure(
886
917
  ): FlyaiResult {
887
918
  const upstream = `${r.stderr}\n${r.stdout}`.replace(/\s+/g, ' ').trim()
888
919
  const tag = (verdict: FlyaiVerdict, evidence: string, extra: Partial<FlyaiResult>): FlyaiResult =>
889
- ({ kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict, evidence, ...extra })
920
+ ({ kind, ...envNote, latencyMs, ok: false, via: 'flyai-error', verdict, upstreamHealth: 'unknown', evidence, ...extra })
921
+ const httpStatus = Number(HTTP_FAILURE.exec(upstream)?.[1])
890
922
 
891
- if (/Invalid API key|HTTP\s*401|\b401\b/.test(upstream)) {
923
+ if (/Invalid API key/i.test(upstream) || httpStatus === 401) {
892
924
  return tag('auth-error', `[实时API:flyai@error@${ts}] 401 invalid api key`, {
893
925
  retryable: false,
894
926
  error: `FlyAI 鉴权失败(401):${upstream.slice(0, 160)}`,
895
927
  setup: '当前 key 无效或已吊销——在本机终端运行 `gotry setup flyai` 重新设置并验证(模型无法替你输入 key);`gotry setup flyai --clear` 可清除错误 key,清除后仍可匿名试用。',
896
928
  })
897
929
  }
898
- if (/HTTP\s*403|\b403\b/.test(upstream)) {
930
+ if (httpStatus === 403) {
899
931
  return tag('forbidden', `[实时API:flyai@error@${ts}] 403 forbidden`, {
900
932
  retryable: false,
901
933
  error: `FlyAI 拒绝访问(403):${upstream.slice(0, 160)}`,
902
934
  setup: '上游 403:核对该 key 的权限范围与控制台状态;如 endpoint 被 DEBUG_FLYAI_MCP_URL 覆盖,请确认指向。',
903
935
  })
904
936
  }
905
- if (/Trial limit reached/.test(upstream)) {
937
+ if (/Trial limit reached/i.test(upstream)) {
906
938
  return tag('needs-setup', `[实时API:flyai@error@${ts}] trial quota exhausted`, {
907
939
  retryable: false,
908
940
  error: `FlyAI 匿名试用额度已用尽:${upstream.slice(0, 160)}`,
909
941
  setup: '打开 https://flyai.open.fliggy.com/console,登录后复制 API Key,本机运行 `gotry setup flyai` 配置并验证;本会话请勿盲重试 gotry_flyai_search,机/火/酒改走 gotry_session_search。',
910
942
  })
911
943
  }
912
- if (/HTTP\s*429|\b429\b/.test(upstream)) {
944
+ if (/SentinelBlockException/i.test(upstream)) {
945
+ return tag('error', `[实时API:flyai@error@${ts}] sentinel`, {
946
+ retryable: false, upstreamHealth: 'unhealthy',
947
+ error: `FlyAI Sentinel 限流:${upstream.slice(0, 160)}`,
948
+ setup: '平台 Sentinel 限流(恢复窗口未公开);稍后重试,或改走 gotry_session_search(账号会话)。',
949
+ })
950
+ }
951
+ if (httpStatus === 429) {
913
952
  return tag('rate-limited', `[实时API:flyai@error@${ts}] 429 rate limited`, {
914
- retryable: true,
953
+ retryable: true, upstreamHealth: 'unhealthy',
915
954
  error: `FlyAI 普通限流(非试用达限):${upstream.slice(0, 160)}`,
916
955
  setup: '上游普通限流:稍候再查,勿高频连发;仍 429 请稍后或换 gotry_session_search。',
917
956
  })
918
957
  }
919
- if (/timeout|timed out|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ECONNABORTED|EHOSTUNREACH|ENETUNREACH|EAI_AGAIN|socket|network|fetch failed|HTTP\s*5\d\d|\b5\d\d\b/i.test(upstream)) {
958
+ if (TRANSPORT_FAILURE.test(upstream) || httpStatus >= 500) {
920
959
  return tag('error', `[实时API:flyai@error@${ts}] transient upstream`, {
921
- retryable: true,
960
+ retryable: true, upstreamHealth: 'unhealthy',
922
961
  error: `FlyAI 瞬时网络/上游错误:${upstream.slice(0, 160)}`,
923
962
  setup: '网络抖动或上游 5xx:稍后重试;持续失败请检查本机网络或改走 gotry_session_search。',
924
963
  })
@@ -982,7 +1021,7 @@ function parseItemListResult(
982
1021
  const count = parsed.options.length
983
1022
  const verdict: FlyaiVerdict = count > 0 ? 'hit' : 'miss'
984
1023
  const evidence = `[实时API:flyai@${ts}] ${count}/${items.length} ${KIND_LABEL[kind]} options`
985
- const common = { kind, ...envNote, latencyMs, ok: true, via: 'flyai' as const, verdict, evidence, ...(systemMessage ? { systemMessage } : {}) }
1024
+ const common = { kind, ...envNote, latencyMs, ok: true, via: 'flyai' as const, upstreamHealth: 'healthy' as const, verdict, evidence, ...(systemMessage ? { systemMessage } : {}) }
986
1025
 
987
1026
  switch (kind) {
988
1027
  case 'flight':
@@ -1088,7 +1127,9 @@ function parseHotelItems(items: unknown[]): ParsedItems<FlyaiHotelOption> {
1088
1127
  }
1089
1128
  const it = raw as RawItem
1090
1129
  const name = nonEmptyText(it.name)
1091
- const star = stringField(it, 'star')
1130
+ // Unrated homestays may return whitespace (live Harbin response).
1131
+ // Only this optional descriptive field permits blank text; types stay strict.
1132
+ const star = typeof it.star === 'string' && !it.star.trim() ? undefined : stringField(it, 'star')
1092
1133
  const rawPrice = stringField(it, 'price')
1093
1134
  const rate = it.rate === undefined ? undefined : it.rate === null ? null : nonEmptyText(it.rate) ?? 'bad'
1094
1135
  const address = stringField(it, 'address')