@danceiny/gotry 0.0.1-rc.16 → 0.0.1-rc.18

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 (229) hide show
  1. package/README.md +161 -116
  2. package/README.zh-CN.md +173 -144
  3. package/bin/gotry-booking-copilot.js +53 -0
  4. package/bin/gotry-bootstrap.js +113 -66
  5. package/bin/gotry-inner.js +436 -70
  6. package/bin/gotry-runtime-resolution.d.ts +27 -0
  7. package/bin/gotry-runtime-resolution.js +50 -0
  8. package/bin/gotry.js +1 -1
  9. package/cordis.gotry-patch.yml +5 -1
  10. package/dist/capabilities/agent-reach-deep.js +1 -1
  11. package/dist/capabilities/agent-reach.js +1 -1
  12. package/dist/capabilities/anything.js +1 -1
  13. package/dist/capabilities/artifacts.js +1 -1
  14. package/dist/capabilities/effect.js +1 -1
  15. package/dist/capabilities/fact-log.js +1 -1
  16. package/dist/capabilities/flyai.js +20 -6
  17. package/dist/capabilities/hbcli.js +1 -1
  18. package/dist/capabilities/incident-log.js +1 -1
  19. package/dist/capabilities/model-override.js +18 -0
  20. package/dist/capabilities/opensky.js +1 -1
  21. package/dist/capabilities/resilience.js +1 -1
  22. package/dist/capabilities/session/action-cache.js +1 -1
  23. package/dist/capabilities/session/adapters/ctrip-flight.js +1 -1
  24. package/dist/capabilities/session/adapters/meituan-local.js +1 -1
  25. package/dist/capabilities/session/benchmark.js +1 -1
  26. package/dist/capabilities/session/extension-bridge.js +19 -6
  27. package/dist/capabilities/session/extension-channel.js +3 -3
  28. package/dist/capabilities/session/extension-distribution.js +234 -0
  29. package/dist/capabilities/session/extract.js +1 -1
  30. package/dist/capabilities/session/golden-score.js +92 -0
  31. package/dist/capabilities/session/health-watch.js +1 -1
  32. package/dist/capabilities/session/read-guard.js +1 -1
  33. package/dist/capabilities/session/static-flight-golden.js +137 -0
  34. package/dist/capabilities/session/transport.js +1 -1
  35. package/dist/capabilities/session/wizard.js +21 -251
  36. package/dist/capabilities/session-consent.js +1 -1
  37. package/dist/capabilities/session-login.js +11 -5
  38. package/dist/capabilities/session-search.js +46 -4
  39. package/dist/capabilities/weather.js +168 -46
  40. package/dist/data/session-golden-20.json +25 -0
  41. package/dist/data/sf-golden-manifest.json +102 -0
  42. package/dist/data/sf-static-routes.json +91 -0
  43. package/dist/scripts/action-cache-tests.js +1 -1
  44. package/dist/scripts/agent-planning-budget-e2e.js +227 -0
  45. package/dist/scripts/agent-planning-budget-tests.js +173 -0
  46. package/dist/scripts/agent-reach-deep-tests.js +1 -1
  47. package/dist/scripts/agent-reach-tests.js +1 -1
  48. package/dist/scripts/agent-reach-wrapper-tests.js +1 -1
  49. package/dist/scripts/anything-tests.js +1 -1
  50. package/dist/scripts/async-collect.js +1 -1
  51. package/dist/scripts/benchmark-environment-bridge-e2e.js +981 -0
  52. package/dist/scripts/benchmark-environment-bridge-tests.js +2544 -0
  53. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +335 -0
  54. package/dist/scripts/booking-copilot-availability-policy-v2-tests.js +1355 -0
  55. package/dist/scripts/booking-copilot-bin-proof-tests.js +89 -0
  56. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +113 -0
  57. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +356 -0
  58. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +557 -0
  59. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +140 -0
  60. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +202 -0
  61. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +102 -0
  62. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +374 -0
  63. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +447 -0
  64. package/dist/scripts/booking-copilot-runtime-proof-tests.js +205 -0
  65. package/dist/scripts/booking-copilot-server-proof-tests.js +317 -0
  66. package/dist/scripts/booking-copilot-startup-proof-tests.js +283 -0
  67. package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +3935 -0
  68. package/dist/scripts/booking-saga-tests.js +1 -1
  69. package/dist/scripts/booking-surface-contract-proof-tests.js +393 -0
  70. package/dist/scripts/booking-surface-v2-contract-proof-tests.js +1174 -0
  71. package/dist/scripts/bootstrap-tests.js +45 -39
  72. package/dist/scripts/build-changelog.js +1 -1
  73. package/dist/scripts/changelog-tests.js +1 -1
  74. package/dist/scripts/companion-tests.js +1 -1
  75. package/dist/scripts/diff-test.js +1 -1
  76. package/dist/scripts/dsh-runtime-closure-tests.js +225 -0
  77. package/dist/scripts/dsh-runtime-closure.js +150 -0
  78. package/dist/scripts/effect-tests.js +1 -1
  79. package/dist/scripts/engine-run.js +1 -1
  80. package/dist/scripts/engine-tests.js +1 -1
  81. package/dist/scripts/evaluation-cadence-tests.js +347 -0
  82. package/dist/scripts/evaluation-contract-tests.js +574 -0
  83. package/dist/scripts/extension-distribution-cli.js +35 -0
  84. package/dist/scripts/extension-distribution-tests.js +341 -0
  85. package/dist/scripts/extension-tests.js +128 -23
  86. package/dist/scripts/fact-gate-tests.js +1 -1
  87. package/dist/scripts/flyai-tests.js +1 -1
  88. package/dist/scripts/hbcli-e2e-tests.js +1 -1
  89. package/dist/scripts/hbcli-tests.js +1 -1
  90. package/dist/scripts/health-watch-cli.js +1 -1
  91. package/dist/scripts/i18n-tests.js +1 -1
  92. package/dist/scripts/incident-tests.js +1 -1
  93. package/dist/scripts/journey-tests.js +1 -1
  94. package/dist/scripts/ledger-tests.js +1 -1
  95. package/dist/scripts/ledger-workflow-crash.js +1 -1
  96. package/dist/scripts/memory-capture-tests.js +1 -1
  97. package/dist/scripts/memory-decay-tests.js +1 -1
  98. package/dist/scripts/memory-metrics.js +1 -1
  99. package/dist/scripts/memory-value-report.js +1 -1
  100. package/dist/scripts/model-override-e2e.js +176 -0
  101. package/dist/scripts/nightly-evidence-tests.js +1 -1
  102. package/dist/scripts/nightly-evidence.js +1 -1
  103. package/dist/scripts/nudge-digest.js +1 -1
  104. package/dist/scripts/onboarding-tests.js +21 -53
  105. package/dist/scripts/opensky-check.js +1 -1
  106. package/dist/scripts/opensky-tests.js +1 -1
  107. package/dist/scripts/pnpm-dsh-closure-proof.js +20 -0
  108. package/dist/scripts/price-drift-tests.js +1 -1
  109. package/dist/scripts/price-drift-watch.js +1 -1
  110. package/dist/scripts/probe-poi-tests.js +1 -1
  111. package/dist/scripts/product-metrics.js +1 -1
  112. package/dist/scripts/publish-preverify.js +40 -4
  113. package/dist/scripts/realtime-pricing-tests.js +1 -1
  114. package/dist/scripts/replay-async.js +1 -1
  115. package/dist/scripts/replay-real.js +1 -1
  116. package/dist/scripts/replay.js +1 -1
  117. package/dist/scripts/session-attach-diagnose.js +1 -1
  118. package/dist/scripts/session-attach-poc.js +1 -1
  119. package/dist/scripts/session-benchmark.js +1 -1
  120. package/dist/scripts/session-extract-tests.js +1 -1
  121. package/dist/scripts/session-login.js +1 -1
  122. package/dist/scripts/session-tests.js +70 -20
  123. package/dist/scripts/sf-live-benchmark.js +338 -0
  124. package/dist/scripts/sf-live-cli-tests.js +21 -0
  125. package/dist/scripts/sf-soft-score-tests.js +108 -0
  126. package/dist/scripts/sf-summary.js +93 -0
  127. package/dist/scripts/skeleton-check.js +1 -1
  128. package/dist/scripts/skeleton-integration-test.js +1 -1
  129. package/dist/scripts/skills-contract-tests.js +1 -1
  130. package/dist/scripts/smoke-session-gate-tests.js +29 -0
  131. package/dist/scripts/smoke.js +79 -33
  132. package/dist/scripts/state-cli-tests.js +1 -1
  133. package/dist/scripts/state-cli.js +1 -1
  134. package/dist/scripts/static-golden-tests.js +299 -0
  135. package/dist/scripts/time-eval-tests.js +1 -1
  136. package/dist/scripts/travel-timeline-tests.js +1 -1
  137. package/dist/scripts/unified-tests.js +1 -1
  138. package/dist/scripts/weather-tests.js +694 -44
  139. package/dist/scripts/z3-race-tests.js +1 -1
  140. package/dist/src/artifact-gate.js +1 -1
  141. package/dist/src/benchmark-agent-conformance.js +370 -0
  142. package/dist/src/benchmark-environment-bridge.js +384 -0
  143. package/dist/src/benchmark-headless-child-diagnostics.js +173 -0
  144. package/dist/src/benchmark-tool-isolation.js +124 -0
  145. package/dist/src/bookable-facts.js +1 -1
  146. package/dist/src/booking-saga.js +1 -1
  147. package/dist/src/booking-surface/availability-policy-v2.js +830 -0
  148. package/dist/src/booking-surface/canonical-schema.js +113 -0
  149. package/dist/src/booking-surface/contracts-v2.js +89 -0
  150. package/dist/src/booking-surface/contracts.js +46 -0
  151. package/dist/src/booking-surface/dsh-planner.js +453 -0
  152. package/dist/src/booking-surface/dsh-plugin.js +93 -0
  153. package/dist/src/booking-surface/error-codes.js +94 -0
  154. package/dist/src/booking-surface/index.js +15 -0
  155. package/dist/src/booking-surface/profile.js +68 -0
  156. package/dist/src/booking-surface/runtime-v2.js +1771 -0
  157. package/dist/src/booking-surface/runtime.js +351 -0
  158. package/dist/src/booking-surface/server-v2.js +334 -0
  159. package/dist/src/booking-surface/server.js +302 -0
  160. package/dist/src/booking-surface/startup.js +159 -0
  161. package/dist/src/booking-surface/validation-v2.js +319 -0
  162. package/dist/src/booking-surface/validation.js +809 -0
  163. package/dist/src/bridge.js +1 -1
  164. package/dist/src/companions.js +1 -1
  165. package/dist/src/contracts.js +1 -1
  166. package/dist/src/dsh-llm.js +1 -1
  167. package/dist/src/engine.js +1 -1
  168. package/dist/src/evaluation-cadence.js +234 -0
  169. package/dist/src/evaluation-contracts.js +906 -0
  170. package/dist/src/i18n.js +1 -1
  171. package/dist/src/index.js +50 -19
  172. package/dist/src/journey.js +1 -1
  173. package/dist/src/loop.js +1 -1
  174. package/dist/src/memory-capture.js +1 -1
  175. package/dist/src/memory-decay.js +1 -1
  176. package/dist/src/memory-utility.js +1 -1
  177. package/dist/src/mock-llm.js +1 -1
  178. package/dist/src/model.js +1 -1
  179. package/dist/src/realtime-pricing.js +1 -1
  180. package/dist/src/slot-spec.js +1 -1
  181. package/dist/src/state-ledger.js +2 -1
  182. package/dist/src/time-anchor.js +1 -1
  183. package/dist/src/tool-budget.js +136 -0
  184. package/dist/src/tool-packet.js +1 -1
  185. package/dist/src/travel-slots.js +1 -1
  186. package/dist/src/travel-timeline.js +1 -1
  187. package/dist/src/unified.js +1 -1
  188. package/dist/src/wish-pool.js +1 -1
  189. package/dist/src/z3-shared.js +1 -1
  190. package/extension/README.md +31 -7
  191. package/package.json +286 -11
  192. package/schemas/booking.surface.v1.schema.json +927 -0
  193. package/schemas/booking.surface.v2.schema.json +61 -0
  194. package/ts/capabilities/flyai.ts +16 -3
  195. package/ts/capabilities/session/extension-bridge.ts +37 -14
  196. package/ts/capabilities/session/extension-channel.ts +6 -3
  197. package/ts/capabilities/session/extension-distribution.ts +264 -0
  198. package/ts/capabilities/session/golden-score.ts +139 -0
  199. package/ts/capabilities/session/health-watch.ts +1 -1
  200. package/ts/capabilities/session/static-flight-golden.ts +209 -0
  201. package/ts/capabilities/session/wizard.ts +34 -176
  202. package/ts/capabilities/session-login.ts +15 -5
  203. package/ts/capabilities/session-search.ts +40 -3
  204. package/ts/capabilities/weather.ts +141 -52
  205. package/ts/package.json +3 -3
  206. package/ts/src/benchmark-agent-conformance.ts +448 -0
  207. package/ts/src/benchmark-environment-bridge.ts +348 -0
  208. package/ts/src/benchmark-headless-child-diagnostics.ts +184 -0
  209. package/ts/src/benchmark-tool-isolation.ts +166 -0
  210. package/ts/src/booking-surface/availability-policy-v2.ts +523 -0
  211. package/ts/src/booking-surface/canonical-schema.js +113 -0
  212. package/ts/src/booking-surface/contracts-v2.ts +118 -0
  213. package/ts/src/booking-surface/contracts.ts +380 -0
  214. package/ts/src/booking-surface/dsh-planner.ts +452 -0
  215. package/ts/src/booking-surface/dsh-plugin.js +93 -0
  216. package/ts/src/booking-surface/error-codes.ts +101 -0
  217. package/ts/src/booking-surface/index.ts +12 -0
  218. package/ts/src/booking-surface/profile.ts +42 -0
  219. package/ts/src/booking-surface/runtime-v2.ts +1466 -0
  220. package/ts/src/booking-surface/runtime.ts +483 -0
  221. package/ts/src/booking-surface/server-v2.ts +247 -0
  222. package/ts/src/booking-surface/server.ts +324 -0
  223. package/ts/src/booking-surface/startup.ts +196 -0
  224. package/ts/src/booking-surface/validation-v2.ts +205 -0
  225. package/ts/src/booking-surface/validation.ts +453 -0
  226. package/ts/src/index.ts +64 -11
  227. package/ts/src/state-ledger.ts +1 -0
  228. package/ts/src/tool-budget.ts +165 -0
  229. package/dist/scripts/wizard-bootstrap.js +0 -32
@@ -14,7 +14,8 @@
14
14
  */
15
15
 
16
16
  import { openSession } from './session/transport.ts'
17
- import { extensionCookieNames, extensionOpenLogin, classifyBridgeFailure, NEEDS_EXTENSION_HINT } from './session/extension-channel.ts'
17
+ import { extensionCookieNames, extensionOpenLogin, classifyBridgeFailure } from './session/extension-channel.ts'
18
+ import { EXTENSION_STORE_URL } from './session/extension-bridge.ts'
18
19
  import { resolveTransportMode } from './session-search.ts'
19
20
 
20
21
  export interface LoginTarget {
@@ -55,6 +56,9 @@ export interface SessionLoginResult {
55
56
  /** 已检出的登录票据 cookie 名(名字,非值——本模块永不读取/存储/回传任何 cookie 值) */
56
57
  tickets?: string[]
57
58
  error?: string
59
+ /** needs-extension 时给出 Chrome Web Store URL —— dsh UI 应直接渲成可点链接(浏览器自己当安装器,gotry 不插手) */
60
+ installUrl?: string
61
+ installAction?: 'add-to-chrome'
58
62
  }
59
63
 
60
64
  function err(site: string, verdict: SessionLoginResult['verdict'], error: string, started: number, ts: string): SessionLoginResult {
@@ -158,10 +162,16 @@ export async function sessionLogin(q: SessionLoginQuery = {}): Promise<SessionLo
158
162
  */
159
163
  async function sessionLoginViaExtension(site: string, target: LoginTarget, q: SessionLoginQuery, started: number, ts: string): Promise<SessionLoginResult> {
160
164
  const evidenceTag = `[会话:${site}-login@${ts}]`
161
- const quick = (verdict: SessionLoginResult['verdict'], error: string): SessionLoginResult => ({
162
- ok: false, via: 'session-login-error', evidence: `[会话:login-error@${ts}] ${error.slice(0, 200)}`,
163
- latencyMs: Date.now() - started, verdict, site, error: verdict === 'needs-extension' ? `${error};${NEEDS_EXTENSION_HINT}`.slice(0, 400) : error.slice(0, 400),
164
- })
165
+ const quick = (verdict: SessionLoginResult['verdict'], error: string): SessionLoginResult => {
166
+ const base: SessionLoginResult = {
167
+ ok: false, via: 'session-login-error', evidence: `[会话:login-error@${ts}] ${error.slice(0, 200)}`,
168
+ latencyMs: Date.now() - started, verdict, site, error: error.slice(0, 400),
169
+ }
170
+ // needs-extension:浏览器自己当安装器,gotry 不弹面板不开剪贴板——只把商店 URL 给 dsh UI
171
+ if (verdict === 'needs-extension') base.installUrl = EXTENSION_STORE_URL
172
+ if (verdict === 'needs-extension') base.installAction = 'add-to-chrome'
173
+ return base
174
+ }
165
175
  // 自动检测优先:先只读票据名——已登录则零交互直接确认(与 cdp 车道同一语义,founder「UI 里有自动检测吗」)
166
176
  const pre = await extensionCookieNames({ site, domain: target.domain, ticketNames: target.names, timeoutMs: 8_000 })
167
177
  if (!pre.ok) return quick(classifyBridgeFailure(pre.kind) === 'needs-extension' ? 'needs-extension' : 'error', pre.summary)
@@ -13,10 +13,11 @@
13
13
  */
14
14
 
15
15
  import { openSession } from './session/transport.ts'
16
- import { extensionCookieNames, extensionSearchJob, classifyBridgeFailure, NEEDS_EXTENSION_HINT } from './session/extension-channel.ts'
16
+ import { extensionCookieNames, extensionSearchJob, classifyBridgeFailure } from './session/extension-channel.ts'
17
17
  import { appendFileSync, mkdirSync } from 'node:fs'
18
18
  import { dirname } from 'node:path'
19
19
  import { buildEntryUrl, NETWORK_HINTS, parseBatchSearch, LOGIN_COOKIE_NAMES, SITE_DOMAIN, type SessionFlightOption } from './session/adapters/ctrip-flight.ts'
20
+ import { EXTENSION_STORE_URL } from './session/extension-bridge.ts'
20
21
 
21
22
  export type SessionVerdict = 'hit' | 'miss' | 'error' | 'challenged' | 'cooldown' | 'needs-login' | 'needs-attach' | 'needs-extension'
22
23
 
@@ -28,6 +29,9 @@ export interface SessionSearchResult {
28
29
  verdict: SessionVerdict
29
30
  options?: SessionFlightOption[]
30
31
  error?: string
32
+ /** needs-extension 时给出 Chrome Web Store URL —— dsh UI 应直接渲成可点链接(浏览器自己当安装器,gotry 不插手) */
33
+ installUrl?: string
34
+ installAction?: 'add-to-chrome'
31
35
  }
32
36
 
33
37
  export interface SessionFlightQuery {
@@ -68,6 +72,33 @@ export function resolveTransportMode(profileDir?: string): 'cdp' | 'persistent'
68
72
  return (process.env.GOTRY_SESSION_TRANSPORT ?? '').trim().toLowerCase() === 'cdp' ? 'cdp' : 'extension'
69
73
  }
70
74
 
75
+ /** Live browser transport is an explicit opt-in for smoke/full-suite callers. */
76
+ export function sessionLiveEnabled(env: Partial<Pick<NodeJS.ProcessEnv, 'GOTRY_SESSION_LIVE'>> = process.env): boolean {
77
+ return env.GOTRY_SESSION_LIVE === '1'
78
+ }
79
+
80
+ /** Deterministic offline observation used when the live session gate is closed. */
81
+ export function offlineSessionFlightResult(): SessionSearchResult {
82
+ return {
83
+ ok: false,
84
+ via: 'session-ctrip-flight-error',
85
+ evidence: '[会话:ctrip-flight@offline] GOTRY_SESSION_LIVE!=1; live transport not invoked',
86
+ latencyMs: 0,
87
+ verdict: 'error',
88
+ error: 'GOTRY_SESSION_LIVE=1 required for live session transport',
89
+ }
90
+ }
91
+
92
+ /** Gate a caller before it can invoke the browser/session transport. */
93
+ export async function gatedSessionFlightSearch(
94
+ q: SessionFlightQuery,
95
+ search: (query: SessionFlightQuery) => Promise<SessionSearchResult> = sessionFlightSearch,
96
+ env: Partial<Pick<NodeJS.ProcessEnv, 'GOTRY_SESSION_LIVE'>> = process.env,
97
+ ): Promise<SessionSearchResult> {
98
+ if (!sessionLiveEnabled(env)) return offlineSessionFlightResult()
99
+ return search(q)
100
+ }
101
+
71
102
  /** 扩展车道 job 审计(ReadGuard 审计同款 JSONL,kind 区分;auditPath 缺省不落盘) */
72
103
  export function appendExtensionAudit(auditPath: string | undefined, entry: { kind: 'extension-session-job'; site: string; url: string; jobId: string; result: string }): void {
73
104
  if (!auditPath) return
@@ -105,7 +136,10 @@ export async function sessionFlightSearch(q: SessionFlightQuery): Promise<Sessio
105
136
  const login = await extensionCookieNames({ site, domain: SITE_DOMAIN.replace(/^\./, ''), ticketNames: LOGIN_COOKIE_NAMES })
106
137
  if (!login.ok) {
107
138
  const verdict = classifyBridgeFailure(login.kind)
108
- return err(verdict, verdict === 'needs-extension' ? `${login.summary};${NEEDS_EXTENSION_HINT}` : login.summary)
139
+ if (verdict === 'needs-extension') {
140
+ return { ok: false, via: 'session-ctrip-flight-error', evidence: '[会话:ctrip-flight-needs-extension@ts]', latencyMs: Date.now() - started, verdict, error: login.summary, installUrl: EXTENSION_STORE_URL, installAction: 'add-to-chrome' as const }
141
+ }
142
+ return err(verdict, login.summary)
109
143
  }
110
144
  // 登录态闸:用户自己的账号;匿名默认拒(allowAnonymous 仅链路自检且证据标自检态)
111
145
  if (login.tickets.length === 0 && !q.allowAnonymous) {
@@ -119,7 +153,10 @@ export async function sessionFlightSearch(q: SessionFlightQuery): Promise<Sessio
119
153
  })
120
154
  if (!r.ok) {
121
155
  const verdict = classifyBridgeFailure(r.kind)
122
- return err(verdict, verdict === 'needs-extension' ? `${r.summary};${NEEDS_EXTENSION_HINT}` : r.summary)
156
+ if (verdict === 'needs-extension') {
157
+ return { ok: false, via: 'session-ctrip-flight-error', evidence: '[会话:ctrip-flight-needs-extension@ts]', latencyMs: Date.now() - started, verdict, error: r.summary, installUrl: EXTENSION_STORE_URL, installAction: 'add-to-chrome' as const }
158
+ }
159
+ return err(verdict, r.summary)
123
160
  }
124
161
  const title = r.title
125
162
  const head = r.body.slice(0, 5000)
@@ -59,17 +59,53 @@ export function wmoLabel(code: number): string {
59
59
  return WMO_ZH[code] ?? `天气码${code}`
60
60
  }
61
61
 
62
- async function fetchJson(url: string, timeoutMs: number, headers: Record<string, string> = {}): Promise<{ ok: boolean; data?: unknown; error?: string }> {
63
- const ctrl = new AbortController()
64
- const timer = setTimeout(() => ctrl.abort(), timeoutMs)
62
+ type FetchImpl = typeof fetch
63
+ const monotonicNow = (): number => performance.now()
64
+ const MIN_TIMER_MS = 1
65
+ const TIMEOUT_ERROR = 'timeoutMs must be a finite positive integer'
66
+ const BUDGET_EXHAUSTED_ERROR = 'total timeout budget exhausted'
67
+ const REQUEST_ERROR = 'request failed'
68
+ const normalizeTimeout = (value: number): number | null => Number.isInteger(value) && value > 0 ? value : null
69
+
70
+ /** Rejection values are untrusted: only a real string message is safe to expose. */
71
+ const normalizeError = (value: unknown): string => {
65
72
  try {
66
- const res = await fetch(url, { signal: ctrl.signal, headers: { Accept: 'application/json', ...headers } })
73
+ if (value instanceof Error && typeof value.message === 'string' && value.message.length > 0) {
74
+ return value.message.slice(0, 200)
75
+ }
76
+ if (typeof value === 'string' && value.length > 0) return value.slice(0, 200)
77
+ } catch {
78
+ return REQUEST_ERROR
79
+ }
80
+ return REQUEST_ERROR
81
+ }
82
+
83
+ async function fetchJson(url: string, timeoutMs: number, headers: Record<string, string> = {}, fetchImpl: FetchImpl = globalThis.fetch): Promise<{ ok: boolean; data?: unknown; error?: string; timedOut?: boolean }> {
84
+ if (!Number.isFinite(timeoutMs) || timeoutMs < MIN_TIMER_MS) return { ok: false, error: BUDGET_EXHAUSTED_ERROR }
85
+ const ctrl = new AbortController()
86
+ const timerDelay = Math.floor(timeoutMs)
87
+ if (timerDelay < MIN_TIMER_MS) return { ok: false, error: BUDGET_EXHAUSTED_ERROR }
88
+ let timer: ReturnType<typeof setTimeout> | undefined
89
+ let timedOut = false
90
+ const timeout = new Promise<never>((_, reject) => {
91
+ timer = setTimeout(() => {
92
+ timedOut = true
93
+ ctrl.abort()
94
+ reject(new Error(`timeout after ${timeoutMs}ms`))
95
+ }, timerDelay)
96
+ })
97
+ const operation = (async () => {
98
+ const res = await fetchImpl(url, { signal: ctrl.signal, headers: { Accept: 'application/json', ...headers } })
67
99
  if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
68
100
  return { ok: true, data: await res.json() }
101
+ })()
102
+ try {
103
+ return await Promise.race([operation, timeout])
69
104
  } catch (e) {
70
- return { ok: false, error: (e as Error).message.slice(0, 200) }
105
+ return { ok: false, error: normalizeError(e), ...(timedOut ? { timedOut: true } : {}) }
71
106
  } finally {
72
- clearTimeout(timer)
107
+ if (timer !== undefined) clearTimeout(timer)
108
+ void operation.catch(() => undefined)
73
109
  }
74
110
  }
75
111
 
@@ -77,6 +113,7 @@ const NOMINATIM_BASE = 'https://nominatim.openstreetmap.org/search'
77
113
 
78
114
  /** 行政级/人口重镇 feature_code(open-meteo 命名惯例):PPLC=首都 PPLA*=首府 PPLB=城镇 PPLX=区片 */
79
115
  const MAJOR_FEATURE = /^(PPLC|PPLA|PPLB|PPLX)/
116
+ const isRecord = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === 'object' && !Array.isArray(value)
80
117
 
81
118
  interface GeoHitInternal {
82
119
  name: string
@@ -101,15 +138,28 @@ function stripHits(hits: GeoHitInternal[]): Array<{ name: string; latitude: numb
101
138
  * 兜底仍空则回退 open-meteo 弱结果(不比此前更差)。 */
102
139
  export async function geocodePlace(
103
140
  name: string,
104
- opts: { timeoutMs?: number; count?: number } = {},
141
+ opts: { timeoutMs?: number; count?: number; fetchImpl?: FetchImpl } = {},
105
142
  ): Promise<{ ok: boolean; evidence: string; via?: 'open-meteo' | 'nominatim'; results: Array<{ name: string; latitude: number; longitude: number; country?: string; admin1?: string }>; error?: string }> {
106
143
  const ts = new Date().toISOString()
107
144
  const count = opts.count ?? 5
108
145
  const query = name.trim()
146
+ const timeoutMs = normalizeTimeout(opts.timeoutMs ?? 15_000)
147
+ if (timeoutMs === null) return { ok: false, evidence: `[实时API:open-meteo-geo@error@${ts}]`, results: [], error: TIMEOUT_ERROR }
109
148
  const omUrl = `${GEOCODE_BASE}?name=${encodeURIComponent(query)}&count=${count}&language=zh&format=json`
110
- const r = await fetchJson(omUrl, opts.timeoutMs ?? 15_000)
111
- const omResults: GeoHitInternal[] = r.ok
112
- ? ((r.data as { results?: unknown[] })?.results ?? []).map((item) => {
149
+ // timeoutMs is a budget for the complete primary→fallback chain, not per request.
150
+ const deadline = monotonicNow() + timeoutMs
151
+ const remaining = (): number | null => {
152
+ const value = deadline - monotonicNow()
153
+ return Number.isFinite(value) && value >= MIN_TIMER_MS ? value : null
154
+ }
155
+ const primaryRemaining = remaining()
156
+ const r = primaryRemaining === null
157
+ ? { ok: false, error: BUDGET_EXHAUSTED_ERROR }
158
+ : await fetchJson(omUrl, primaryRemaining, {}, opts.fetchImpl)
159
+ const primaryPayload = r.ok && isRecord(r.data) && Array.isArray(r.data['results']) ? r.data['results'] : null
160
+ const omResults: GeoHitInternal[] = primaryPayload
161
+ ? primaryPayload.map((item) => {
162
+ if (!isRecord(item)) return null
113
163
  const it = item as Record<string, unknown>
114
164
  return {
115
165
  name: String(it['name'] ?? ''),
@@ -120,7 +170,7 @@ export async function geocodePlace(
120
170
  population: Number(it['population'] ?? 0) || 0,
121
171
  featureCode: String(it['feature_code'] ?? ''),
122
172
  }
123
- }).filter(h => h.name && Number.isFinite(h.latitude) && Number.isFinite(h.longitude))
173
+ }).filter((h) => !!h && !!h.name && Number.isFinite(h.latitude) && Number.isFinite(h.longitude)) as GeoHitInternal[]
124
174
  : []
125
175
  omResults.sort((a, b) => (b.population - a.population) || (Number(MAJOR_FEATURE.test(b.featureCode)) - Number(MAJOR_FEATURE.test(a.featureCode))))
126
176
  const top = omResults[0]
@@ -131,20 +181,22 @@ export async function geocodePlace(
131
181
  // 弱命中/零结果/主源请求失败:Nominatim 兜底(免费无 key;中文 accept-language)
132
182
  const nomTs = new Date().toISOString()
133
183
  const nomUrl = `${NOMINATIM_BASE}?q=${encodeURIComponent(query)}&format=jsonv2&limit=${count}&accept-language=zh&addressdetails=1`
134
- const nom = await fetchJson(nomUrl, opts.timeoutMs ?? 15_000, { 'User-Agent': 'gotry-travel-agent/0.1 (+https://github.com/Danceiny/gotry)' })
135
- const nomResults: Array<{ name: string; latitude: number; longitude: number; country?: string; admin1?: string }> = nom.ok
136
- ? ((nom.data as unknown[]) ?? []).map((item) => {
184
+ const fallbackRemaining = primaryRemaining === null || r.timedOut ? null : remaining()
185
+ const nom = fallbackRemaining === null
186
+ ? { ok: false, error: BUDGET_EXHAUSTED_ERROR }
187
+ : await fetchJson(nomUrl, fallbackRemaining, { 'User-Agent': 'gotry-travel-agent/0.1 (+https://github.com/Danceiny/gotry)' }, opts.fetchImpl)
188
+ const nomResults: Array<{ name: string; latitude: number; longitude: number; country?: string; admin1?: string }> = []
189
+ if (nom.ok && Array.isArray(nom.data)) {
190
+ const parsedNom = nom.data.map((item) => {
191
+ if (!isRecord(item)) return null
137
192
  const it = item as Record<string, unknown>
138
- const addr = (it['address'] ?? {}) as Record<string, string>
139
- return {
140
- name: String(it['name'] ?? ''),
141
- latitude: Number(it['lat']),
142
- longitude: Number(it['lon']),
143
- country: addr['country'],
144
- admin1: addr['province'] ?? addr['state'] ?? addr['county'],
145
- }
146
- }).filter(h => h.name && Number.isFinite(h.latitude) && Number.isFinite(h.longitude))
147
- : []
193
+ const addr = isRecord(it['address']) ? it['address'] : {}
194
+ const country = typeof addr['country'] === 'string' ? addr['country'] : undefined
195
+ const admin1 = [addr['province'], addr['state'], addr['county']].find(v => typeof v === 'string') as string | undefined
196
+ return { name: String(it['name'] ?? ''), latitude: Number(it['lat']), longitude: Number(it['lon']), ...(country ? { country } : {}), ...(admin1 ? { admin1 } : {}) }
197
+ }).filter((h) => !!h && !!h.name && Number.isFinite(h.latitude) && Number.isFinite(h.longitude)) as Array<{ name: string; latitude: number; longitude: number; country?: string; admin1?: string }>
198
+ nomResults.push(...parsedNom)
199
+ }
148
200
  if (nomResults.length) {
149
201
  const omNote = r.ok
150
202
  ? `open-meteo ${omResults.length} 条弱命中(无人口/行政级,不足采信)`
@@ -166,35 +218,54 @@ export async function geocodePlace(
166
218
  /** 未来天气预报(≤16 天) */
167
219
  export async function getForecast(
168
220
  point: WeatherPoint,
169
- opts: { days?: number; timeoutMs?: number } = {},
221
+ opts: { days?: number; timeoutMs?: number; fetchImpl?: FetchImpl } = {},
170
222
  ): Promise<WeatherResult> {
171
- const started = Date.now()
223
+ const started = monotonicNow()
172
224
  const ts = new Date().toISOString()
173
- const days = Math.min(opts.days ?? 7, 16)
225
+ const requestedDays = opts.days ?? 7
226
+ const days = Number.isInteger(requestedDays) && requestedDays > 0 ? Math.min(requestedDays, 16) : 0
227
+ const timeoutMs = normalizeTimeout(opts.timeoutMs ?? 15_000)
228
+ if (days < 1 || timeoutMs === null) return { ok: false, via: 'open-meteo-error', evidence: `[实时API:open-meteo@error@${ts}]`, latencyMs: monotonicNow() - started, error: days < 1 ? 'days must be valid positive values' : TIMEOUT_ERROR }
174
229
  const url = `${FORECAST_BASE}?latitude=${point.latitude}&longitude=${point.longitude}`
175
230
  + `&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max,weathercode`
176
231
  + `&forecast_days=${days}&timezone=auto`
177
- const r = await fetchJson(url, opts.timeoutMs ?? 15_000)
178
- const latencyMs = Date.now() - started
232
+ const r = await fetchJson(url, timeoutMs, {}, opts.fetchImpl)
233
+ const latencyMs = monotonicNow() - started
179
234
  if (!r.ok) {
180
235
  return { ok: false, via: 'open-meteo-error', evidence: `[实时API:open-meteo@error@${ts}]`, latencyMs, error: r.error }
181
236
  }
182
- const daily = (r.data as { daily?: { time: string[]; temperature_2m_max: number[]; temperature_2m_min: number[]; precipitation_probability_max?: (number | null)[]; weathercode: number[] }; timezone?: string }).daily
183
- if (!daily) {
237
+ const payload = isRecord(r.data) ? r.data : null
238
+ const daily = isRecord(payload?.['daily']) ? payload['daily'] as { time?: unknown[]; temperature_2m_max?: unknown[]; temperature_2m_min?: unknown[]; precipitation_probability_max?: unknown[]; weathercode?: unknown[] } : null
239
+ if (!daily || !Array.isArray(daily.time) || !Array.isArray(daily.temperature_2m_max)
240
+ || !Array.isArray(daily.temperature_2m_min) || !Array.isArray(daily.weathercode)
241
+ || daily.time.length !== days || daily.temperature_2m_max.length !== days
242
+ || daily.temperature_2m_min.length !== days || daily.weathercode.length !== days
243
+ || daily.time.some(v => typeof v !== 'string')
244
+ || daily.temperature_2m_max.some(v => typeof v !== 'number' || !Number.isFinite(v))
245
+ || daily.temperature_2m_min.some(v => typeof v !== 'number' || !Number.isFinite(v))
246
+ || daily.weathercode.some(v => typeof v !== 'number' || !Number.isFinite(v))
247
+ || (daily.precipitation_probability_max !== undefined && daily.precipitation_probability_max !== null
248
+ && !Array.isArray(daily.precipitation_probability_max))
249
+ || (Array.isArray(daily.precipitation_probability_max)
250
+ && (daily.precipitation_probability_max.length !== days
251
+ || daily.precipitation_probability_max.some(v => v !== null && (typeof v !== 'number' || !Number.isFinite(v)))))) {
184
252
  return { ok: false, via: 'open-meteo-error', evidence: `[实时API:open-meteo@error@${ts}]`, latencyMs, error: 'empty daily payload' }
185
253
  }
186
- const rows: WeatherDaily[] = daily.time.map((date, i) => ({
187
- date,
188
- tempMaxC: daily.temperature_2m_max[i] ?? NaN,
189
- tempMinC: daily.temperature_2m_min[i] ?? NaN,
190
- precipProbMaxPct: daily.precipitation_probability_max?.[i] ?? null,
191
- weatherCode: daily.weathercode[i] ?? -1,
254
+ const times = daily.time as string[]
255
+ const maxes = daily.temperature_2m_max as number[]
256
+ const mins = daily.temperature_2m_min as number[]
257
+ const codes = daily.weathercode as number[]
258
+ const precip = Array.isArray(daily.precipitation_probability_max)
259
+ ? daily.precipitation_probability_max as Array<number | null>
260
+ : undefined
261
+ const rows: WeatherDaily[] = times.map((date, i) => ({
262
+ date, tempMaxC: maxes[i]!, tempMinC: mins[i]!, precipProbMaxPct: precip?.[i] ?? null, weatherCode: codes[i]!,
192
263
  }))
193
264
  return {
194
265
  ok: true, via: 'open-meteo',
195
266
  evidence: `[实时API:open-meteo@${ts}]`,
196
267
  latencyMs,
197
- timezone: (r.data as { timezone?: string }).timezone,
268
+ timezone: typeof payload?.['timezone'] === 'string' ? payload['timezone'] : undefined,
198
269
  daily: rows,
199
270
  }
200
271
  }
@@ -203,39 +274,57 @@ export async function getForecast(
203
274
  export async function getClimate(
204
275
  point: WeatherPoint,
205
276
  month: number,
206
- opts: { year?: number; timeoutMs?: number } = {},
277
+ opts: { year?: number; timeoutMs?: number; fetchImpl?: FetchImpl } = {},
207
278
  ): Promise<WeatherResult> {
208
- const started = Date.now()
279
+ const started = monotonicNow()
209
280
  const ts = new Date().toISOString()
281
+ const timeoutMs = normalizeTimeout(opts.timeoutMs ?? 15_000)
282
+ if (timeoutMs === null) return { ok: false, via: 'open-meteo-error', evidence: `[实时API:open-meteo-climate@error@${ts}]`, latencyMs: monotonicNow() - started, error: TIMEOUT_ERROR }
210
283
  // 默认取去年同月做气候基线
211
- const year = opts.year ?? (new Date().getFullYear() - 1)
284
+ const year = opts.year ?? (new Date().getUTCFullYear() - 1)
285
+ if (!Number.isInteger(month) || month < 1 || month > 12 || !Number.isInteger(year) || year < 1940 || year > new Date().getUTCFullYear()) {
286
+ return { ok: false, via: 'open-meteo-error', evidence: `[实时API:open-meteo-climate@error@${ts}]`, latencyMs: monotonicNow() - started, error: 'month/year must be valid historical calendar values' }
287
+ }
212
288
  const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate()
289
+ const expectedDates = Array.from({ length: lastDay }, (_, i) => `${year}-${String(month).padStart(2, '0')}-${String(i + 1).padStart(2, '0')}`)
213
290
  const start = `${year}-${String(month).padStart(2, '0')}-01`
214
291
  const end = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
215
292
  const url = `${ARCHIVE_BASE}?latitude=${point.latitude}&longitude=${point.longitude}`
216
293
  + `&start_date=${start}&end_date=${end}`
217
294
  + `&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,weathercode&timezone=auto`
218
- const r = await fetchJson(url, opts.timeoutMs ?? 15_000)
219
- const latencyMs = Date.now() - started
295
+ const r = await fetchJson(url, timeoutMs, {}, opts.fetchImpl)
296
+ const latencyMs = monotonicNow() - started
220
297
  if (!r.ok) {
221
298
  return { ok: false, via: 'open-meteo-error', evidence: `[实时API:open-meteo-climate@error@${ts}]`, latencyMs, error: r.error }
222
299
  }
223
- const daily = (r.data as { daily?: { time: string[]; temperature_2m_max: number[]; temperature_2m_min: number[]; weathercode: number[] } }).daily
224
- if (!daily) {
300
+ const payload = isRecord(r.data) ? r.data : null
301
+ const daily = isRecord(payload?.['daily']) ? payload['daily'] as { time?: unknown[]; temperature_2m_max?: unknown[]; temperature_2m_min?: unknown[]; weathercode?: unknown[] } : null
302
+ if (!daily || !Array.isArray(daily.time) || !Array.isArray(daily.temperature_2m_max)
303
+ || !Array.isArray(daily.temperature_2m_min) || !Array.isArray(daily.weathercode)
304
+ || daily.time.length !== lastDay
305
+ || daily.time.length !== daily.temperature_2m_max.length
306
+ || daily.time.length !== daily.temperature_2m_min.length
307
+ || daily.time.length !== daily.weathercode.length
308
+ || daily.time.some(v => typeof v !== 'string')
309
+ || daily.time.some((v, i) => v !== expectedDates[i])
310
+ || daily.temperature_2m_max.some(v => typeof v !== 'number' || !Number.isFinite(v))
311
+ || daily.temperature_2m_min.some(v => typeof v !== 'number' || !Number.isFinite(v))
312
+ || daily.weathercode.some(v => typeof v !== 'number' || !Number.isFinite(v))) {
225
313
  return { ok: false, via: 'open-meteo-error', evidence: `[实时API:open-meteo-climate@error@${ts}]`, latencyMs, error: 'empty daily payload' }
226
314
  }
227
- const rows: WeatherDaily[] = daily.time.map((date, i) => ({
228
- date,
229
- tempMaxC: daily.temperature_2m_max[i] ?? NaN,
230
- tempMinC: daily.temperature_2m_min[i] ?? NaN,
231
- precipProbMaxPct: null, // archive 端点没有概率,用降水量另行估算
232
- weatherCode: daily.weathercode[i] ?? -1,
315
+ const times = daily.time as string[]
316
+ const maxes = daily.temperature_2m_max as number[]
317
+ const mins = daily.temperature_2m_min as number[]
318
+ const codes = daily.weathercode as number[]
319
+ const rows: WeatherDaily[] = times.map((date, i) => ({
320
+ date, tempMaxC: maxes[i]!, tempMinC: mins[i]!, precipProbMaxPct: null, // archive 端点没有概率,用降水量另行估算
321
+ weatherCode: codes[i]!,
233
322
  }))
234
323
  return {
235
324
  ok: true, via: 'open-meteo',
236
325
  evidence: `[实时API:open-meteo-climate@${ts}]`,
237
326
  latencyMs,
238
- timezone: (r.data as { timezone?: string }).timezone,
327
+ timezone: typeof payload?.['timezone'] === 'string' ? payload['timezone'] : undefined,
239
328
  daily: rows,
240
329
  }
241
330
  }
package/ts/package.json CHANGED
@@ -10,9 +10,9 @@
10
10
  "smoke": "tsx scripts/smoke.ts"
11
11
  },
12
12
  "dependencies": {
13
- "@deepseek-ai/cordis": "*",
14
- "@deepseek-ai/dsh-tools": "*",
15
- "@deepseek-ai/schemastery": "*",
13
+ "@deepseek-ai/cordis": "4.0.2",
14
+ "@deepseek-ai/dsh-tools": "0.1.2-alpha.3",
15
+ "@deepseek-ai/schemastery": "^3.18.2",
16
16
  "@types/better-sqlite3": "^9.6.0",
17
17
  "better-sqlite3": "^13.0.3",
18
18
  "z3-solver": "^5.2.0"