@danceiny/gotry 0.0.1-rc.21 → 0.0.1-rc.24

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 (327) hide show
  1. package/README.md +102 -194
  2. package/README.zh-CN.md +115 -210
  3. package/bin/gotry-backend.js +53 -0
  4. package/bin/gotry-bootstrap.js +466 -35
  5. package/bin/gotry-inner.js +255 -36
  6. package/bin/gotry-process-liveness.d.ts +27 -0
  7. package/bin/gotry-process-liveness.js +97 -0
  8. package/bin/gotry-runtime-resolution.d.ts +1 -1
  9. package/bin/gotry-runtime-resolution.js +2 -2
  10. package/client/client.js +174 -0
  11. package/cordis.gotry-patch.yml +70 -93
  12. package/dist/capabilities/agent-reach-deep.js +45 -122
  13. package/dist/capabilities/agent-reach.js +63 -148
  14. package/dist/capabilities/anything.js +89 -75
  15. package/dist/capabilities/artifacts.js +123 -104
  16. package/dist/capabilities/channel-health.js +65 -45
  17. package/dist/capabilities/channel-registry.js +108 -87
  18. package/dist/capabilities/doctor.js +357 -235
  19. package/dist/capabilities/effect.js +223 -309
  20. package/dist/capabilities/fact-log.js +28 -14
  21. package/dist/capabilities/flyai.js +245 -156
  22. package/dist/capabilities/ground-transfer.js +509 -0
  23. package/dist/capabilities/hbcli.js +144 -148
  24. package/dist/capabilities/hotelbyte-transaction.js +194 -0
  25. package/dist/capabilities/incident-log.js +131 -61
  26. package/dist/capabilities/model-override.js +30 -10
  27. package/dist/capabilities/opensky.js +54 -43
  28. package/dist/capabilities/resilience.js +50 -32
  29. package/dist/capabilities/session/action-cache.js +50 -44
  30. package/dist/capabilities/session/adapters/ctrip-flight.js +119 -62
  31. package/dist/capabilities/session/adapters/ctrip-hotel.js +125 -110
  32. package/dist/capabilities/session/adapters/dida-portal.js +206 -0
  33. package/dist/capabilities/session/adapters/meituan-local.js +35 -54
  34. package/dist/capabilities/session/adapters/rail-12306.js +169 -112
  35. package/dist/capabilities/session/benchmark.js +85 -149
  36. package/dist/capabilities/session/extension-bridge.js +320 -259
  37. package/dist/capabilities/session/extension-channel.js +57 -76
  38. package/dist/capabilities/session/extension-distribution.js +115 -119
  39. package/dist/capabilities/session/extract.js +31 -21
  40. package/dist/capabilities/session/golden-score.js +55 -33
  41. package/dist/capabilities/session/health-watch.js +146 -154
  42. package/dist/capabilities/session/read-guard.js +55 -47
  43. package/dist/capabilities/session/static-flight-golden.js +49 -36
  44. package/dist/capabilities/session/transport.js +54 -61
  45. package/dist/capabilities/session/wizard.js +63 -93
  46. package/dist/capabilities/session-consent.js +143 -47
  47. package/dist/capabilities/session-login.js +95 -126
  48. package/dist/capabilities/session-search.js +391 -253
  49. package/dist/capabilities/sponsor-plugin.js +341 -0
  50. package/dist/capabilities/visa-policy.js +87 -73
  51. package/dist/capabilities/weather.js +163 -235
  52. package/dist/scripts/action-cache-tests.js +24 -59
  53. package/dist/scripts/agent-planning-turn-deadline-e2e.js +91 -142
  54. package/dist/scripts/agent-planning-turn-deadline-tests.js +80 -176
  55. package/dist/scripts/agent-reach-deep-tests.js +30 -40
  56. package/dist/scripts/agent-reach-tests.js +26 -18
  57. package/dist/scripts/agent-reach-wrapper-tests.js +46 -50
  58. package/dist/scripts/anything-tests.js +58 -62
  59. package/dist/scripts/artifact-client-contract-tests.js +69 -0
  60. package/dist/scripts/artifacts-capability-tests.js +235 -0
  61. package/dist/scripts/async-collect.js +26 -15
  62. package/dist/scripts/benchmark-environment-bridge-e2e.js +287 -684
  63. package/dist/scripts/benchmark-environment-bridge-tests.js +818 -3123
  64. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +66 -279
  65. package/dist/scripts/booking-copilot-availability-policy-tests.js +153 -1257
  66. package/dist/scripts/booking-copilot-bin-proof-tests.js +22 -45
  67. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +64 -76
  68. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +69 -199
  69. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +393 -759
  70. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +33 -75
  71. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +60 -110
  72. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +28 -66
  73. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +72 -271
  74. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +73 -275
  75. package/dist/scripts/booking-copilot-runtime-proof-tests.js +784 -3398
  76. package/dist/scripts/booking-copilot-server-proof-tests.js +63 -168
  77. package/dist/scripts/booking-copilot-startup-proof-tests.js +38 -124
  78. package/dist/scripts/booking-executor-tests.js +80 -0
  79. package/dist/scripts/booking-recovery-chain-tests.js +182 -0
  80. package/dist/scripts/booking-saga-tests.js +61 -123
  81. package/dist/scripts/booking-surface-contract-proof-tests.js +152 -1167
  82. package/dist/scripts/bootstrap-tests.js +1383 -128
  83. package/dist/scripts/build-changelog.js +89 -130
  84. package/dist/scripts/build-metrics-report.js +135 -143
  85. package/dist/scripts/changelog-tests.js +35 -82
  86. package/dist/scripts/channel-probe-tests.js +44 -116
  87. package/dist/scripts/channel-probe.js +84 -167
  88. package/dist/scripts/channel-registry-tests.js +97 -185
  89. package/dist/scripts/companion-tests.js +25 -70
  90. package/dist/scripts/dida-cookie-probe.js +87 -0
  91. package/dist/scripts/dida-sw-probe.js +114 -0
  92. package/dist/scripts/diff-test.js +17 -12
  93. package/dist/scripts/doctor-tests.js +428 -120
  94. package/dist/scripts/dsh-artifact-e2e.js +150 -0
  95. package/dist/scripts/dsh-artifact-web-e2e.js +443 -0
  96. package/dist/scripts/dsh-http-proxy-sse-proof.js +90 -0
  97. package/dist/scripts/dsh-runtime-closure-tests.js +110 -153
  98. package/dist/scripts/dsh-runtime-closure.js +75 -63
  99. package/dist/scripts/dsh-session-v3-migration-proof.js +106 -0
  100. package/dist/scripts/dsh-subprocess-local-proof.js +149 -0
  101. package/dist/scripts/dsh-target-closure-proof.js +178 -0
  102. package/dist/scripts/effect-tests.js +165 -552
  103. package/dist/scripts/engine-run.js +1 -3
  104. package/dist/scripts/engine-tests.js +16 -10
  105. package/dist/scripts/evaluation-cadence-tests.js +69 -242
  106. package/dist/scripts/evaluation-contract-tests.js +193 -499
  107. package/dist/scripts/extension-distribution-cli.js +19 -15
  108. package/dist/scripts/extension-distribution-tests.js +98 -211
  109. package/dist/scripts/extension-tests.js +366 -389
  110. package/dist/scripts/fact-gate-tests.js +725 -263
  111. package/dist/scripts/fixtures/extension-bridge-unref-child.mjs +21 -0
  112. package/dist/scripts/flight-hotel-anchor-363-registered-e2e.js +169 -0
  113. package/dist/scripts/flight-malformed-tests.js +159 -0
  114. package/dist/scripts/flyai-tests.js +325 -68
  115. package/dist/scripts/g5-guard-tests.js +131 -0
  116. package/dist/scripts/g5-guard.js +189 -0
  117. package/dist/scripts/gen-lunar-anchors.js +106 -0
  118. package/dist/scripts/gotry-backend-kernel-tests.js +79 -0
  119. package/dist/scripts/ground-transfer-tests.js +956 -0
  120. package/dist/scripts/hbcli-e2e-tests.js +71 -90
  121. package/dist/scripts/hbcli-live-optin-tests.js +96 -0
  122. package/dist/scripts/hbcli-release-contract-tests.js +122 -0
  123. package/dist/scripts/hbcli-tests.js +41 -115
  124. package/dist/scripts/health-watch-cli.js +42 -28
  125. package/dist/scripts/hotel-date-gate-tests.js +496 -0
  126. package/dist/scripts/hotelbyte-reconcile-tests.js +141 -0
  127. package/dist/scripts/hotelbyte-spawn-e2e-tests.js +373 -0
  128. package/dist/scripts/i18n-tests.js +16 -9
  129. package/dist/scripts/incident-tests.js +281 -87
  130. package/dist/scripts/issue-194-job-id-guard-tests.js +106 -0
  131. package/dist/scripts/issue-233-cancel-refund-commission-tests.js +392 -0
  132. package/dist/scripts/issue-271-liveness-tests.js +547 -0
  133. package/dist/scripts/issue-289-model-retry-real-tests.js +296 -0
  134. package/dist/scripts/issue-289-model-retry-tests.js +441 -0
  135. package/dist/scripts/issue-289-web-retry-e2e.js +630 -0
  136. package/dist/scripts/issue-338-home-city-e2e.js +577 -0
  137. package/dist/scripts/issue343-real-entry-e2e.js +160 -0
  138. package/dist/scripts/journey-tests.js +22 -27
  139. package/dist/scripts/kernel-manifest-evidence.js +109 -0
  140. package/dist/scripts/kernel-manifest-gate.js +126 -0
  141. package/dist/scripts/kernel-manifest-tests.js +212 -0
  142. package/dist/scripts/kernel-manifest-trace-entry.js +7 -0
  143. package/dist/scripts/kernel-manifest-trace-hook.mjs +28 -0
  144. package/dist/scripts/kernel-manifest.js +369 -0
  145. package/dist/scripts/ledger-repair-apply-tests.js +316 -0
  146. package/dist/scripts/ledger-repair-plan-tests.js +375 -0
  147. package/dist/scripts/ledger-tests.js +325 -340
  148. package/dist/scripts/ledger-workflow-crash.js +10 -20
  149. package/dist/scripts/map-tools-vendor-package-proof.js +206 -134
  150. package/dist/scripts/memory-capture-tests.js +242 -61
  151. package/dist/scripts/memory-decay-tests.js +18 -52
  152. package/dist/scripts/memory-lifecycle-tests.js +657 -0
  153. package/dist/scripts/memory-lifecycle.js +196 -0
  154. package/dist/scripts/memory-metrics.js +24 -12
  155. package/dist/scripts/memory-value-report.js +474 -222
  156. package/dist/scripts/memory-value-tests.js +328 -0
  157. package/dist/scripts/metrics-report-tests.js +62 -199
  158. package/dist/scripts/model-override-e2e.js +66 -105
  159. package/dist/scripts/nightly-evidence-tests.js +64 -89
  160. package/dist/scripts/nightly-evidence.js +108 -111
  161. package/dist/scripts/nudge-digest.js +39 -28
  162. package/dist/scripts/onboarding-tests.js +63 -88
  163. package/dist/scripts/opensky-check.js +18 -13
  164. package/dist/scripts/opensky-tests.js +28 -28
  165. package/dist/scripts/persona-surface-guard-tests.js +37 -12
  166. package/dist/scripts/pnpm-dsh-closure-proof.js +2 -5
  167. package/dist/scripts/policy-anchor-359-registered-e2e.js +214 -0
  168. package/dist/scripts/price-drift-tests.js +126 -227
  169. package/dist/scripts/price-drift-watch.js +209 -253
  170. package/dist/scripts/probe-poi-tests.js +52 -45
  171. package/dist/scripts/product-metrics.js +187 -273
  172. package/dist/scripts/publish-preverify.js +47 -41
  173. package/dist/scripts/realtime-pricing-tests.js +37 -76
  174. package/dist/scripts/replay-async.js +20 -18
  175. package/dist/scripts/replay-real.js +16 -18
  176. package/dist/scripts/replay.js +52 -82
  177. package/dist/scripts/session-attach-diagnose.js +16 -12
  178. package/dist/scripts/session-attach-poc.js +34 -37
  179. package/dist/scripts/session-benchmark.js +66 -147
  180. package/dist/scripts/session-dida-live-e2e.js +151 -0
  181. package/dist/scripts/session-extract-tests.js +14 -35
  182. package/dist/scripts/session-login.js +14 -13
  183. package/dist/scripts/session-tests.js +545 -583
  184. package/dist/scripts/sf-live-benchmark.js +92 -110
  185. package/dist/scripts/sf-live-cli-tests.js +2 -9
  186. package/dist/scripts/sf-soft-score-tests.js +19 -60
  187. package/dist/scripts/sf-summary-tests.js +149 -0
  188. package/dist/scripts/sf-summary.js +466 -84
  189. package/dist/scripts/skeleton-check.js +16 -14
  190. package/dist/scripts/skeleton-integration-test.js +11 -10
  191. package/dist/scripts/skills-contract-tests.js +51 -46
  192. package/dist/scripts/smoke-session-gate-tests.js +12 -20
  193. package/dist/scripts/smoke.js +541 -422
  194. package/dist/scripts/sponsor-reuse-tests.js +262 -0
  195. package/dist/scripts/state-cli-tests.js +226 -101
  196. package/dist/scripts/state-cli.js +549 -212
  197. package/dist/scripts/static-golden-tests.js +46 -180
  198. package/dist/scripts/time-eval-tests.js +210 -192
  199. package/dist/scripts/travel-timeline-tests.js +29 -93
  200. package/dist/scripts/turn-handoff-collect-tests.js +46 -93
  201. package/dist/scripts/turn-handoff-collect.js +71 -99
  202. package/dist/scripts/turn-policy-tests.js +28 -29
  203. package/dist/scripts/typed-contract-canary.js +77 -162
  204. package/dist/scripts/tz-resolver-sanity.js +50 -0
  205. package/dist/scripts/tz-resolver-tests.js +584 -0
  206. package/dist/scripts/unified-tests.js +23 -19
  207. package/dist/scripts/visa-policy-tests.js +31 -44
  208. package/dist/scripts/weather-tests.js +121 -543
  209. package/dist/scripts/wish-channel-gate-tests.js +29 -86
  210. package/dist/scripts/write-gate-tests.js +522 -0
  211. package/dist/scripts/z3-lifecycle-fault-tests.js +188 -0
  212. package/dist/scripts/z3-lifecycle-tests.js +170 -0
  213. package/dist/scripts/z3-race-repeat-tests.js +39 -0
  214. package/dist/scripts/z3-race-tests.js +18 -12
  215. package/dist/src/artifact-gate.js +652 -221
  216. package/dist/src/backend/kernel.js +91 -0
  217. package/dist/src/backend/modules/booking-copilot.js +55 -0
  218. package/dist/src/backend/modules/booking-executor.js +124 -0
  219. package/dist/src/backend/modules/session-search.js +194 -0
  220. package/dist/src/benchmark-agent-conformance.js +296 -262
  221. package/dist/src/benchmark-environment-bridge.js +298 -413
  222. package/dist/src/benchmark-headless-child-diagnostics.js +93 -100
  223. package/dist/src/benchmark-tool-isolation.js +94 -67
  224. package/dist/src/bookable-facts.js +299 -174
  225. package/dist/src/booking-saga.js +64 -120
  226. package/dist/src/booking-surface/availability-policy.js +443 -576
  227. package/dist/src/booking-surface/cancel-refund-commission.js +379 -0
  228. package/dist/src/booking-surface/contracts.js +24 -80
  229. package/dist/src/booking-surface/dsh-planner.js +510 -281
  230. package/dist/src/booking-surface/dsh-plugin.js +17 -1
  231. package/dist/src/booking-surface/error-codes.js +82 -10
  232. package/dist/src/booking-surface/index.js +0 -3
  233. package/dist/src/booking-surface/profile.js +10 -54
  234. package/dist/src/booking-surface/recovery-chain.js +367 -0
  235. package/dist/src/booking-surface/runtime.js +765 -891
  236. package/dist/src/booking-surface/server.js +200 -251
  237. package/dist/src/booking-surface/startup.js +58 -54
  238. package/dist/src/booking-surface/validation.js +150 -218
  239. package/dist/src/bridge.js +20 -13
  240. package/dist/src/companions.js +48 -74
  241. package/dist/src/contracts.js +21 -43
  242. package/dist/src/dsh-llm.js +82 -120
  243. package/dist/src/engine.js +253 -239
  244. package/dist/src/evaluation-cadence.js +72 -209
  245. package/dist/src/evaluation-contracts.js +220 -843
  246. package/dist/src/flight-pack-adapter.js +54 -0
  247. package/dist/src/flight-pack-contract.js +2 -0
  248. package/dist/src/gotry-backend.js +70 -0
  249. package/dist/src/hotel-date-gate.js +118 -0
  250. package/dist/src/i18n.js +18 -7
  251. package/dist/src/index.js +1425 -1629
  252. package/dist/src/journey.js +132 -81
  253. package/dist/src/ledger-repair-apply.js +538 -0
  254. package/dist/src/ledger-repair-plan.js +463 -0
  255. package/dist/src/loop.js +257 -166
  256. package/dist/src/memory-capture.js +180 -32
  257. package/dist/src/memory-decay.js +30 -14
  258. package/dist/src/memory-lifecycle.js +1310 -0
  259. package/dist/src/memory-utility.js +27 -40
  260. package/dist/src/mock-llm.js +36 -61
  261. package/dist/src/model.js +81 -36
  262. package/dist/src/realtime-pricing.js +63 -72
  263. package/dist/src/slot-spec.js +84 -106
  264. package/dist/src/state-ledger.js +310 -471
  265. package/dist/src/subagent-job-id-guard.js +58 -0
  266. package/dist/src/time-anchor.js +231 -33
  267. package/dist/src/tool-packet.js +20 -8
  268. package/dist/src/travel-slots.js +58 -35
  269. package/dist/src/travel-timeline.js +44 -63
  270. package/dist/src/turn-deadline.js +121 -127
  271. package/dist/src/turn-policy.js +61 -89
  272. package/dist/src/tz-resolver.js +143 -0
  273. package/dist/src/unified.js +504 -381
  274. package/dist/src/wish-pool.js +39 -14
  275. package/dist/src/write-gate.js +495 -0
  276. package/dist/src/z3-shared.js +273 -7
  277. package/extension/README.md +4 -3
  278. package/extension/background.js +125 -14
  279. package/extension/content-bridge.js +23 -1
  280. package/extension/content-main.js +15 -4
  281. package/extension/manifest.json +16 -8
  282. package/package.json +251 -220
  283. package/ts/capabilities/agent-reach.ts +4 -4
  284. package/ts/capabilities/anything.ts +1 -1
  285. package/ts/capabilities/artifacts.ts +46 -9
  286. package/ts/capabilities/flyai.ts +156 -37
  287. package/ts/capabilities/hbcli.ts +1 -1
  288. package/ts/capabilities/incident-log.ts +88 -59
  289. package/ts/capabilities/session/adapters/ctrip-flight.ts +99 -36
  290. package/ts/capabilities/session/adapters/dida-portal.ts +250 -0
  291. package/ts/capabilities/session/adapters/rail-12306.ts +123 -24
  292. package/ts/capabilities/session/extension-bridge.ts +226 -101
  293. package/ts/capabilities/session/extension-channel.ts +22 -9
  294. package/ts/capabilities/session/wizard.ts +2 -2
  295. package/ts/capabilities/session-consent.ts +98 -14
  296. package/ts/capabilities/session-login.ts +1 -0
  297. package/ts/capabilities/session-search.ts +315 -31
  298. package/ts/dsh-runtime/vendor/README.md +8 -4
  299. package/ts/dsh-runtime/vendor/dsh-map-tools/README.en.md +7 -1
  300. package/ts/dsh-runtime/vendor/dsh-map-tools/README.md +5 -3
  301. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/settings-ns.js +1 -1
  302. package/ts/dsh-runtime/vendor/dsh-map-tools/lib/types/settings-ns.d.ts +1 -1
  303. package/ts/dsh-runtime/vendor/dsh-map-tools/package.json +6 -6
  304. package/ts/package.json +236 -16
  305. package/ts/scripts/map-tools-vendor-package-proof.ts +152 -31
  306. package/ts/scripts/state-cli.ts +418 -51
  307. package/ts/src/artifact-gate.ts +561 -15
  308. package/ts/src/benchmark-environment-bridge.ts +37 -20
  309. package/ts/src/bookable-facts.ts +183 -7
  310. package/ts/src/booking-surface/cancel-refund-commission.ts +488 -0
  311. package/ts/src/booking-surface/dsh-planner.ts +240 -32
  312. package/ts/src/booking-surface/dsh-plugin.js +17 -1
  313. package/ts/src/booking-surface/error-codes.ts +75 -0
  314. package/ts/src/booking-surface/recovery-chain.ts +409 -0
  315. package/ts/src/booking-surface/server.ts +84 -51
  316. package/ts/src/booking-surface/validation.ts +2 -0
  317. package/ts/src/contracts.ts +12 -2
  318. package/ts/src/dsh-llm.ts +4 -8
  319. package/ts/src/index.ts +609 -90
  320. package/ts/src/loop.ts +116 -7
  321. package/ts/src/memory-capture.ts +227 -3
  322. package/ts/src/mock-llm.ts +4 -8
  323. package/ts/src/model.ts +88 -2
  324. package/ts/src/state-ledger.ts +47 -29
  325. package/ts/src/subagent-job-id-guard.ts +72 -0
  326. package/ts/src/time-anchor.ts +217 -13
  327. package/ts/src/unified.ts +316 -54
package/ts/src/loop.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { InterviewQuestion, TripState, TravelerProfile, Turn, CalendarState, SolveResult } from './contracts.ts'
10
10
  import type { JourneySpecTS } from './unified.ts'
11
+ import { buildTimeAnchor, isRealIsoDate, planningWindowBounds, parsePlanningWindow, type PlanningWindow, type TimeAnchor } from './time-anchor.ts'
11
12
  import type { TravelSlotExtraction } from './travel-slots.ts'
12
13
  import { anythingSearch } from '../capabilities/anything.ts'
13
14
 
@@ -95,6 +96,98 @@ export function validateSpec(spec: JourneySpecTS): string | null {
95
96
  return null
96
97
  }
97
98
 
99
+ export interface PlanningWindowRejection {
100
+ segmentId: string
101
+ optionId: string
102
+ date: string
103
+ }
104
+
105
+ export interface PlanningWindowCheck {
106
+ spec: JourneySpecTS
107
+ rejected: PlanningWindowRejection[]
108
+ error?: string
109
+ }
110
+
111
+ export interface PlanningWindowOptions {
112
+ /** Registered feasibility tool opts into the dated-candidate default future floor. */
113
+ defaultDatedFuture?: boolean
114
+ }
115
+
116
+ function isoDateInBounds(date: string, bounds: { start: string; end: string }): boolean {
117
+ const m = date.match(/^(\d{4})-(\d{2})-(\d{2})$/)
118
+ if (!m || !isRealIsoDate(Number(m[1]), Number(m[2]), Number(m[3]))) return false
119
+ return date >= bounds.start && date <= bounds.end
120
+ }
121
+
122
+ function hasDatedCandidates(spec: JourneySpecTS): boolean {
123
+ return spec.segments.some(segment => segment.date !== undefined || segment.options.some(option => option.date !== undefined))
124
+ }
125
+
126
+ /**
127
+ * Enforce a named-year future window at the planner boundary. Historical
128
+ * lookup/backtesting remains an explicit bypass; an expired named year fails
129
+ * instead of being silently rolled into a different year.
130
+ */
131
+ export function applyPlanningWindow(
132
+ spec: JourneySpecTS,
133
+ window: PlanningWindow | null,
134
+ anchor: TimeAnchor,
135
+ options: PlanningWindowOptions = {},
136
+ ): PlanningWindowCheck {
137
+ // Dated recommendations are future-oriented even without optional named-year context.
138
+ // Fully dateless legacy calculations remain untouched; historical bypass is explicit.
139
+ if (window?.intent === 'historical') return { spec, rejected: [] }
140
+ const implicitFutureFloor = !window && options.defaultDatedFuture === true && hasDatedCandidates(spec)
141
+ if (!window && !implicitFutureFloor) return { spec, rejected: [] }
142
+
143
+ const currentYear = Number(anchor.today.slice(0, 4))
144
+ if (window && window.requestedYear < currentYear) {
145
+ return {
146
+ spec,
147
+ rejected: [],
148
+ error: `${window.requestedYear} 年已结束(当前规划参考日 ${anchor.today}),不会自动改用其他年份;如需历史查询请明确说明“历史/回测”。`,
149
+ }
150
+ }
151
+
152
+ const bounds = window
153
+ ? planningWindowBounds(window, anchor)
154
+ : { start: anchor.today, end: '9999-12-31' }
155
+ if (!bounds) return { spec, rejected: [], error: `无法建立 ${window!.requestedYear} 年的未来规划窗口。` }
156
+
157
+ const rejected: PlanningWindowRejection[] = []
158
+ const guardedSegments = spec.segments.map(segment => ({
159
+ ...segment,
160
+ options: segment.options.filter(option => {
161
+ const date = option.date ?? segment.date
162
+ if (date && isoDateInBounds(date, bounds)) return true
163
+ rejected.push({ segmentId: segment.id, optionId: option.id, date: date ?? '未提供明确日期' })
164
+ return false
165
+ }),
166
+ }))
167
+
168
+ if (guardedSegments.some(segment => segment.options.length === 0)) {
169
+ return {
170
+ spec: { ...spec, segments: guardedSegments },
171
+ rejected,
172
+ error: implicitFutureFloor
173
+ ? `带日期的未来推荐必须不早于当前规划参考日 ${bounds.start};没有带明确日期且在未来窗口内的可接受方案。`
174
+ : `${window!.requestedYear} 年未来规划窗口为 ${bounds.start} 至 ${bounds.end};没有带明确日期且落在窗口内的可接受方案。`,
175
+ }
176
+ }
177
+ return { spec: { ...spec, segments: guardedSegments }, rejected }
178
+ }
179
+
180
+ function latestPlanningWindow(history: Turn[], userMsg: string, anchor: TimeAnchor): PlanningWindow | null {
181
+ const turns = [...history, { role: 'user' as const, text: userMsg }]
182
+ for (let i = turns.length - 1; i >= 0; i--) {
183
+ const turn = turns[i]
184
+ if (turn?.role !== 'user') continue
185
+ const parsed = parsePlanningWindow(turn.text, anchor)
186
+ if (parsed) return parsed
187
+ }
188
+ return null
189
+ }
190
+
98
191
  /** 求解端口:S3 起由 unified 求解器实现;循环只认此签名(确定性责任)。 */
99
192
  export type SolvePort = (spec: JourneySpecTS) => Promise<SolveResult>
100
193
 
@@ -206,15 +299,15 @@ export function renderSolve(state: TripState): string {
206
299
  lines.push(`- ${l['leg']} ${l['service']}:${l['dep']} 起飞,${l['wake']} 出发,${l['arrive_stay']} 到,`
207
300
  + `门到门 ${l['door_to_door']},落地精力 ${l['energy_pct']}%,¥${l['price_cny']}`)
208
301
  }
209
- for (const e of s.work_window_exclusions ?? []) {
210
- lines.push(`- 已排除 ${e.option}(工作窗口):${e.reason}`)
211
- }
212
302
  for (const n of s.skeleton_notes ?? []) lines.push(`- ${n}`)
213
303
  for (const f of s.red_flags ?? []) lines.push(`- ⚠️ ${f}`)
214
304
  } else {
215
305
  lines.push(`**当前约束下不可行——冲突:${(s.unsat_core ?? []).join('、')}`)
216
306
  for (const sg of s.suggestions ?? []) lines.push(`- 放宽「${sg.relax}」可解(约 ¥${sg.money_cny})`)
217
307
  }
308
+ for (const e of s.work_window_exclusions ?? []) {
309
+ lines.push(`- 已排除 ${e.option}(工作窗口):${e.reason}`)
310
+ }
218
311
  if (state.gates.length) {
219
312
  lines.push('', '**待你决定(选择题)**')
220
313
  for (const g of state.gates) {
@@ -231,7 +324,9 @@ export async function runTurn(
231
324
  llm: LlmPort,
232
325
  history: Turn[] = [],
233
326
  solve: SolvePort | null = null,
327
+ now: Date = new Date(),
234
328
  ): Promise<{ reply: string; state: TripState }> {
329
+ const anchor = buildTimeAnchor(now)
235
330
  const facts = await llm.extractFacts([...history, { role: 'user', text: userMsg }], state)
236
331
 
237
332
  const conflicts: string[] = []
@@ -287,7 +382,22 @@ export async function runTurn(
287
382
 
288
383
  // 约束齐备(无阻塞问题)→ 翻译 spec → **校验闸 + 日期一致性闸** → 求解 → 渲染
289
384
  if (blocking.length === 0 && solve) {
290
- const spec = await llm.extractSpec([...history, { role: 'user', text: userMsg }], state)
385
+ const extractedSpec = await llm.extractSpec([...history, { role: 'user', text: userMsg }], state)
386
+ const planning = latestPlanningWindow(history, userMsg, anchor)
387
+ const planningCheck = extractedSpec ? applyPlanningWindow(extractedSpec, planning, anchor) : null
388
+ if (planningCheck?.error) {
389
+ state.spec = undefined
390
+ state.solve = undefined
391
+ parts.push(`⚠️ ${planningCheck.error}`)
392
+ return { reply: parts.join('\n\n'), state }
393
+ }
394
+ if (planningCheck?.rejected.length) {
395
+ const rejected = planningCheck.rejected
396
+ .map(r => `${r.segmentId}/${r.optionId}(${r.date})`)
397
+ .join('、')
398
+ parts.push(`已排除规划窗口外的候选:${rejected}`)
399
+ }
400
+ const spec = planningCheck?.spec ?? extractedSpec
291
401
  // 场景路由:erhai 候选标记 → 候选求解(洱海金标准的引擎判定)——纯 TS unify 路径
292
402
  if (spec && (spec as unknown as { note?: string }).note === 'erhai-candidates') {
293
403
  const { readFile } = await import('node:fs/promises')
@@ -311,11 +421,10 @@ export async function runTurn(
311
421
  // D-10 切片 C:spec↔槽位日期一致性闸(ADR-10 翻译≠造数的执行点)。
312
422
  // LLM 翻译的 spec 日期与代码层换算的槽位日期分歧时不求解、不猜,追问确认——
313
423
  // 日期是求解关键输入,错日期 = 错判决。槽位缺失/unresolved 不参与(不造假阳性)。
314
- const ext = await llm.extractSlots([...history, { role: 'user', text: userMsg }])
424
+ const ext = await llm.extractSlots([...history, { role: 'user', text: userMsg }], now)
315
425
  if (ext) {
316
426
  const { resolveSlots, specDateMismatches } = await import('./slot-spec.ts')
317
- const { buildTimeAnchor } = await import('./time-anchor.ts')
318
- const mismatches = specDateMismatches(spec, resolveSlots(ext, buildTimeAnchor()))
427
+ const mismatches = specDateMismatches(spec, resolveSlots(ext, anchor))
319
428
  if (mismatches.length > 0) {
320
429
  const lines = mismatches.map(m => `- ${m.specAt}:翻译说 ${m.specDate},你的原话换算是 ${m.slotDate}(${m.field})`)
321
430
  parts.push(`⚠️ 日期分歧,确认后我再算(以你为准):\n${lines.join('\n')}`)
@@ -9,31 +9,143 @@
9
9
  * ① 追加不删史(evidence 永不移除,P0 反幻觉:历史依据不可篡改)
10
10
  * ② 幂等(同 evidence 不重复入池)
11
11
  * ③ 结构守卫(weights 存在性校验、空补丁拒绝、权重归一)
12
+ *
13
+ * 额外承载(issue #338):用户显式声明的常驻城市/默认出发地——typed
14
+ * homeCity 字段 + 纯函数 resolveDefaultOrigin(explicit origin, profile)。
15
+ * - 当前轮显式出发地永远赢(explicit origin > home city > missing)
16
+ * - homeCity 变更必须伴新 evidence(P0 与 weights/hard 同向);单条 evidence
17
+ * 可作为省略 binding 的兼容形态,多条 evidence 必须显式绑定
18
+ * - 该契约不触碰算术/求解——只读字段,不进 feasibility/evaluate 路径
19
+ *
20
+ * 持久化语义(issue #338):home-city 是 typed self-contained preference
21
+ * 记录 `{ value, evidence, updated_at }`,与全局 evidence 列表解耦——
22
+ * 一次不相关的动机补丁不会改写它的「证据指针」「更新时间」「当前值」。
23
+ * `updated_at` 由调用方在事件边界注入(账本事件行的 ts),mergeProfile
24
+ * 自身不在 fold 路径里再读时钟——保证 rebuildProjections() 与直读投影
25
+ * 永不分叉。
12
26
  */
13
27
 
28
+ export interface HomeCityPreference {
29
+ /** string=当前默认城市,null=已显式清除(审计一行留痕) */
30
+ value: string | null
31
+ /** 锁定到本次 home-city 操作的用户原话指针(append-only evidence 池里的那条) */
32
+ evidence: string
33
+ /** 账本事件行的 ts(非折叠期再读时钟) */
34
+ updated_at: string
35
+ }
36
+
14
37
  export interface ProfilePatch {
15
38
  weights?: Record<string, number>
16
39
  /** 新增依据(用户原话或工具证据);只增不改不删 */
17
40
  evidence?: string[]
18
41
  hard?: Record<string, unknown>
42
+ /**
43
+ * 持久化用户常驻城市/默认出发地(issue #338)。
44
+ * - string = 设置默认城市(用户原话指明常驻)
45
+ * - null = 显式清除(审计一行留痕,用于遗忘/迁居)
46
+ * - undefined = 字段未传,保持当前值不动
47
+ * 改值须伴至少一条新 evidence(同 weights/hard 的 P0 守卫);恰好一条非空
48
+ * evidence 时可省略 homeCityEvidence,多条 evidence 必须显式绑定。
49
+ * 进入合并前会 trim;trim 后空白 → 视为「未传」,不触发变更、不落事件。
50
+ */
51
+ homeCity?: string | null
52
+ /**
53
+ * homeCity 在本次 profile.evidence 中的 exact 用户原话绑定。
54
+ * 显式传入时必须非空、逐字出现在 evidence 且相对当前画像为新值;
55
+ * 未传时仅允许 evidence 恰好一条且非空,由该条提供 unambiguous binding;
56
+ * 唯一例外是同 value/clear state + 同 typed evidence 的重复调用(幂等)。
57
+ */
58
+ homeCityEvidence?: string
19
59
  }
20
60
 
21
61
  export interface MergedProfile {
22
62
  weights: Record<string, number>
23
63
  evidence: string[]
24
64
  hard: Record<string, unknown>
65
+ /** 当前持久默认城市。undefined 从未保存;null 显式清除;string 当前默认。 */
66
+ homeCity?: string | null
67
+ /**
68
+ * home-city 自含记录(issue #338):value + 锁定证据 + 更新时间三件套,
69
+ * 与全局 evidence 列表解耦。缺少或不满足完整 typed contract 的画像按
70
+ * missing 处理;raw homeCity 不会成为默认值或证据来源。
71
+ */
72
+ homeCityPreference?: HomeCityPreference
73
+ }
74
+
75
+ const ISO_LIKE_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?(Z|[+-]\d{2}:\d{2})$/
76
+
77
+ function isNonBlankString(value: unknown): value is string {
78
+ return typeof value === 'string' && value.trim().length > 0
79
+ }
80
+
81
+ function isValidIsoLikeTimestamp(value: unknown): value is string {
82
+ if (typeof value !== 'string') return false
83
+ const match = ISO_LIKE_TIMESTAMP.exec(value)
84
+ if (!match) return false
85
+ const year = Number(match[1])
86
+ const month = Number(match[2])
87
+ const day = Number(match[3])
88
+ const hour = Number(match[4])
89
+ const minute = Number(match[5])
90
+ const second = Number(match[6])
91
+ const zone = match[7]!
92
+ const daysInMonth = month === 2
93
+ ? (year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28)
94
+ : [31, 0, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]
95
+ if (!daysInMonth || day < 1 || day > daysInMonth) return false
96
+ // ISO permits 24:00:00 as the end-of-day spelling; no other 24:xx value.
97
+ if (hour > 23 && !(hour === 24 && minute === 0 && second === 0)) return false
98
+ if (minute > 59 || second > 59) return false
99
+ if (zone !== 'Z' && (Number(zone.slice(1, 3)) > 23 || Number(zone.slice(4, 6)) > 59)) return false
100
+ return Number.isFinite(Date.parse(value))
25
101
  }
26
102
 
27
- /** 与现有画像合并;无可并入内容返回 null(调用方跳过落盘)。纯函数。 */
103
+ /**
104
+ * Typed home-city preference 的唯一有效性判定。
105
+ * 不读取、不修写 raw profile;raw homeCity 若存在只作镜像一致性校验。
106
+ */
107
+ export function isValidHomeCityPreference(profile: {
108
+ homeCity?: unknown
109
+ evidence?: unknown
110
+ homeCityPreference?: unknown
111
+ } | null): boolean {
112
+ const preference = profile?.homeCityPreference
113
+ if (!preference || typeof preference !== 'object' || Array.isArray(preference)) return false
114
+ const candidate = preference as Partial<HomeCityPreference>
115
+ if (!(candidate.value === null || isNonBlankString(candidate.value))) return false
116
+ if (!isNonBlankString(candidate.evidence) || !isValidIsoLikeTimestamp(candidate.updated_at)) return false
117
+ if (!profile || !Array.isArray(profile.evidence) || !profile.evidence.some(entry => entry === candidate.evidence)) return false
118
+ if (profile && Object.prototype.hasOwnProperty.call(profile, 'homeCity') && profile.homeCity !== candidate.value) return false
119
+ return true
120
+ }
121
+
122
+ /**
123
+ * 合并守门。纯函数;不读时钟——`now` 由调用方从事件边界(appendMotivationPatch
124
+ * 写时 ISO 或 foldEvent 重放时 row.ts)注入,保证写路径与重建路径时间戳一致。
125
+ *
126
+ * 触发 homeCity 操作的同时,会原地维护 typed homeCityPreference:
127
+ * - valid new binding → 新 `{value, evidence: <homeCityEvidence exact string>, updated_at: now}`
128
+ * - unchanged → 沿用 base 的 homeCityPreference(若有);缺失 typed 字段保持缺失
129
+ * - missing/mismatched/stale binding → 返回 null,由账本跳过整次事件
130
+ */
28
131
  export function mergeProfile(
29
- current: { weights?: Record<string, number>; evidence?: string[]; hard?: Record<string, unknown> } | null,
132
+ current: {
133
+ weights?: Record<string, number>
134
+ evidence?: string[]
135
+ hard?: Record<string, unknown>
136
+ homeCity?: string | null
137
+ homeCityPreference?: HomeCityPreference
138
+ } | null,
30
139
  patch: ProfilePatch | null,
140
+ now: string,
31
141
  ): MergedProfile | null {
32
142
  if (!patch) return null
33
143
  const base = {
34
144
  weights: (current?.weights ?? {}) as Record<string, number>,
35
145
  evidence: (current?.evidence ?? []) as string[],
36
146
  hard: (current?.hard ?? {}) as Record<string, unknown>,
147
+ homeCity: (current?.homeCity === undefined ? undefined : current.homeCity) as string | null | undefined,
148
+ homeCityPreference: current?.homeCityPreference as HomeCityPreference | undefined,
37
149
  } // 首次保存:无档案即空档案
38
150
  const newEvidence = (patch.evidence ?? []).filter(Boolean).filter(e => !base.evidence.includes(e))
39
151
  const patchWeights = patch.weights
@@ -41,7 +153,46 @@ export function mergeProfile(
41
153
  JSON.stringify(patchWeights) !== JSON.stringify(base.weights)
42
154
  const hardChanged = !!patch.hard && Object.keys(patch.hard).length > 0 &&
43
155
  JSON.stringify(patch.hard) !== JSON.stringify(base.hard)
44
- if (newEvidence.length === 0 && !weightsChanged && !hardChanged) return null
156
+ // homeCity 变更判定:
157
+ // patch.homeCity === undefined → 字段未传,不视为变更
158
+ // string 先 trim;trim 后空白 → 视作「未传」(避免把空白值当清除语义或无意义值写入)
159
+ // trim 后与 base.homeCity 仍不等 → 视为变更(包括 string↔null、null↔undefined 等差异)
160
+ let patchedHomeCityInput: string | null | undefined = patch.homeCity
161
+ if (typeof patchedHomeCityInput === 'string') {
162
+ const trimmed = patchedHomeCityInput.trim()
163
+ patchedHomeCityInput = trimmed.length === 0 ? undefined : trimmed
164
+ }
165
+ const homeCityTouched = patchedHomeCityInput !== undefined
166
+ const homeCityChanged = homeCityTouched && patchedHomeCityInput !== base.homeCity
167
+
168
+ if (!homeCityTouched && patch.homeCityEvidence !== undefined) return null
169
+
170
+ // homeCity 的语义绑定必须在 profile patch 边界明确给出,不从自然语言或
171
+ // evidence 数组位置推断。唯一兼容例外:本次只有一条非空 evidence 时,
172
+ // 该条本身就是 unambiguous binding;多条 evidence 仍必须显式命名。
173
+ let homeCityEvidence: string | undefined
174
+ let homeCityEvidenceIsNew = false
175
+ if (homeCityTouched) {
176
+ const evidenceEntries = patch.evidence ?? []
177
+ const candidate = patch.homeCityEvidence !== undefined
178
+ ? patch.homeCityEvidence
179
+ : evidenceEntries.length === 1 && isNonBlankString(evidenceEntries[0])
180
+ ? evidenceEntries[0]
181
+ : undefined
182
+ const appearsInCall = isNonBlankString(candidate) && evidenceEntries.some(e => e === candidate)
183
+ homeCityEvidenceIsNew = appearsInCall && !base.evidence.includes(candidate)
184
+ const exactDuplicate = appearsInCall && !homeCityEvidenceIsNew && !homeCityChanged
185
+ && base.homeCityPreference?.value === patchedHomeCityInput
186
+ && base.homeCityPreference?.evidence === candidate
187
+ if (!appearsInCall || (!homeCityEvidenceIsNew && !exactDuplicate)) return null
188
+ homeCityEvidence = candidate
189
+ }
190
+
191
+ // A same-city/clear operation with a new bound quote is a real preference
192
+ // update even though the value itself did not change. The exact duplicate
193
+ // above remains a no-op.
194
+ const homeCityPreferenceChanged = homeCityTouched && homeCityEvidenceIsNew
195
+ if (newEvidence.length === 0 && !weightsChanged && !hardChanged && !homeCityChanged && !homeCityPreferenceChanged) return null
45
196
 
46
197
  // 权重变更必须伴至少一条新 evidence(P0:改画像要有依据),否则拒该部分
47
198
  let weights = { ...base.weights }
@@ -51,9 +202,82 @@ export function mergeProfile(
51
202
  if (sum > 0) for (const k of Object.keys(weights)) weights[k] = Math.round((weights[k]! / sum) * 100) / 100
52
203
  }
53
204
 
205
+ // homeCity 操作只接受上面验证过的 exact homeCityEvidence 绑定;无关 evidence
206
+ // 可以继续追加到全局池,但绝不会成为 homeCityPreference 的语义指针。
207
+ let homeCity: string | null | undefined = base.homeCity
208
+ let homeCityPreference: HomeCityPreference | undefined = base.homeCityPreference
209
+ if (homeCityPreferenceChanged) {
210
+ homeCity = patchedHomeCityInput ?? null
211
+ if (homeCityEvidence !== undefined) {
212
+ homeCityPreference = { value: homeCity, evidence: homeCityEvidence, updated_at: now }
213
+ }
214
+ }
215
+
54
216
  return {
55
217
  weights,
56
218
  evidence: [...base.evidence, ...newEvidence],
57
219
  hard: hardChanged ? { ...base.hard, ...(patch.hard ?? {}) } : { ...base.hard },
220
+ homeCity,
221
+ homeCityPreference,
222
+ }
223
+ }
224
+
225
+ /**
226
+ * 默认出发地解析契约(issue #338)。
227
+ *
228
+ * 纯函数,不读文件、不查 IP/浏览器位置/语言/时区/历史行程;不构造新 evaluate/solve 路径。
229
+ * 调用方负责传入当轮上下文(currentTripOrigin)与持久画像(profile)。
230
+ *
231
+ * 三态判决:
232
+ * - explicit 当轮显式出发地(用户当轮输入);永远赢,不被持久默认覆盖
233
+ * - home_default 持久化 homeCity;仅在当轮未指定时填补缺省(用于软排序/兜底)
234
+ * - missing 二者皆空或显式已清除;调用方须向用户澄清而非自行猜测
235
+ *
236
+ * 该契约的硬约束:
237
+ * - 不删除候选、不改变可行性判定、不进 evaluate/solve 路径
238
+ * - 「只进软排序/补缺」语义由调用方在排序/回退层使用本结果
239
+ *
240
+ * 证据来源:只接受 self-contained typed preference(issue #338)的完整合同。
241
+ * 缺字段、镜像不一致或字段 malformed 的画像一律 missing,不从 raw homeCity
242
+ * 或全局 evidence 推断默认值/原话。
243
+ */
244
+ export interface DefaultOriginResolution {
245
+ /** 解析后的出发地;null = 调用方应澄清 */
246
+ origin: string | null
247
+ /** 取值来源 */
248
+ source: 'explicit' | 'home_default' | 'missing'
249
+ /** 当前持久 homeCity(原值,便于 UI/日志/审计展示) */
250
+ homeCity: string | null | undefined
251
+ /** 锁定到本次 home-city 操作的用户原话指针;仅 valid home_default 填 */
252
+ homeCityEvidence?: string[]
253
+ /** 是否来自完整 typed preference 路径(便于 audit/UI 区分) */
254
+ homeCityPreferenceSource: 'typed' | 'none'
255
+ }
256
+
257
+ export function resolveDefaultOrigin(
258
+ currentTripOrigin: string | null | undefined,
259
+ profile: {
260
+ homeCity?: string | null
261
+ evidence?: string[]
262
+ homeCityPreference?: HomeCityPreference
263
+ } | null,
264
+ ): DefaultOriginResolution {
265
+ const trimmed = typeof currentTripOrigin === 'string' ? currentTripOrigin.trim() : ''
266
+ const validTypedPreference = isValidHomeCityPreference(profile)
267
+ const preference = validTypedPreference ? profile?.homeCityPreference as HomeCityPreference : undefined
268
+ const typedValue = preference?.value
269
+ const homeCity = validTypedPreference ? typedValue : undefined
270
+ if (trimmed) {
271
+ return { origin: trimmed, source: 'explicit', homeCity, homeCityPreferenceSource: 'none' }
272
+ }
273
+ if (validTypedPreference && typeof typedValue === 'string') {
274
+ return {
275
+ origin: typedValue.trim(),
276
+ source: 'home_default',
277
+ homeCity,
278
+ homeCityEvidence: [preference!.evidence],
279
+ homeCityPreferenceSource: 'typed',
280
+ }
58
281
  }
282
+ return { origin: null, source: 'missing', homeCity, homeCityPreferenceSource: 'none' }
59
283
  }
@@ -9,6 +9,7 @@ import type { InterviewQuestion, TravelerProfile, TripState, Turn, CalendarState
9
9
  import type { JourneySpecTS } from './unified.ts'
10
10
  import type { TravelSlotExtraction } from './travel-slots.ts'
11
11
  import { parseFlightPackToSpec } from './unified.ts'
12
+ import { mergeProfileWorkWindow } from './flight-pack-adapter.ts'
12
13
 
13
14
  interface ScriptStep {
14
15
  /** 命中条件:用户消息包含此关键词(第一条匹配生效) */
@@ -79,14 +80,9 @@ export function createMockLlm(flightPackPath?: string, slotScript: SlotScriptSte
79
80
  const { readFile } = await import('node:fs/promises')
80
81
  const pack = JSON.parse(await readFile(flightPackPath, 'utf-8'))
81
82
  const spec = parseFlightPackToSpec(pack)
82
- spec.workWindow = {
83
- homeTzOffsetMin: state.profile.workWindow.homeTzOffsetMin,
84
- startMin: state.profile.workWindow.startMin,
85
- endMin: state.profile.workWindow.endMin,
86
- workdays: state.profile.workWindow.workdays,
87
- }
88
- spec.budgetCny = 9000
89
- return spec
83
+ const merged = mergeProfileWorkWindow(spec, state.profile.workWindow)
84
+ merged.budgetCny = 9000
85
+ return merged
90
86
  },
91
87
  async extractSlots(history: Turn[]): Promise<TravelSlotExtraction | null> {
92
88
  const last = history[history.length - 1]?.text ?? ''
package/ts/src/model.ts CHANGED
@@ -19,6 +19,16 @@ export interface TransferMode {
19
19
  mode: string
20
20
  minutes: number
21
21
  priceCny: number
22
+ /**
23
+ * Per-direction overrides supplied by the ground-transfer capability when a
24
+ * driving route fact successfully resolves for outbound (A→B) and/or return
25
+ * (B→A). `minutesOut` feeds the outbound arrival stay minute;
26
+ * `minutesRet` feeds the return departure-from-stay minute. When absent
27
+ * the solver falls back to the symmetric `minutes` field, preserving
28
+ * byte-for-byte behavior for static and symmetric inputs.
29
+ */
30
+ minutesOut?: number
31
+ minutesRet?: number
22
32
  }
23
33
 
24
34
  export interface Service {
@@ -26,6 +36,14 @@ export interface Service {
26
36
  depMin: number
27
37
  arrMin: number
28
38
  priceCny: number
39
+ /**
40
+ * Issue #343 v2 路径填充(可选):service 出发/到达对应的真实 UTC instant(epoch ms)。
41
+ * 同一 service 在 arrMin - depMin ≤ 真实飞行时长 跨日 / DST 切换场景下需要这两个字段
42
+ * 才能算出正确的 (arrUtcMs - depUtcMs) / 60000。v1 数值路径不填,由 evaluateChoice/Segment 走
43
+ * 旧 `(arrMin - depMin) - tzOffsetMin` 公式,字节不变。
44
+ */
45
+ depUtcMs?: number
46
+ arrUtcMs?: number
29
47
  }
30
48
 
31
49
  export interface HubAccess {
@@ -46,6 +64,8 @@ export interface MotivationProfile {
46
64
  export interface Candidate {
47
65
  id: string
48
66
  name: string
67
+ /** 可选的明确出行日;未提供时保持旧版无日期可行性计算语义 */
68
+ date?: string
49
69
  hub: string
50
70
  bufferOutMin: number
51
71
  bufferRetMin: number
@@ -147,6 +167,7 @@ export function parseCandidate(d: Record<string, unknown>): Candidate {
147
167
  return {
148
168
  id: String(d['id']),
149
169
  name: String(d['name']),
170
+ date: typeof d['date'] === 'string' ? d['date'] : undefined,
150
171
  hub: String(d['hub']),
151
172
  bufferOutMin: Number(d['buffer_out_min'] ?? 60),
152
173
  bufferRetMin: Number(d['buffer_ret_min'] ?? 60),
@@ -161,11 +182,76 @@ export function parseCandidate(d: Record<string, unknown>): Candidate {
161
182
  }
162
183
  }
163
184
 
185
+ /** Issue #343 d2d canonical arithmetic。 */
186
+ export class NonpositiveDurationError extends Error {
187
+ /** 'arr_not_after_dep' | 'legacy_negative_offset' */
188
+ readonly kind: 'arr_not_after_dep' | 'legacy_negative_offset'
189
+ readonly code = 'nonpositive_duration'
190
+ constructor(kind: 'arr_not_after_dep' | 'legacy_negative_offset', message: string) {
191
+ super(message)
192
+ this.kind = kind
193
+ this.name = 'NonpositiveDurationError'
194
+ }
195
+ }
196
+
197
+ /**
198
+ * doorToDoorFromMove 所需的最小 move 字段。本模型层不耦合 unified.ts 的 MoveSpecTS(避免循环)。
199
+ * unified.ts evaluateOptionMove 必须用这个 helper,而不是再写一份 (arrUtcMs - depUtcMs)/60000。
200
+ */
201
+ export interface D2DMoveView {
202
+ bufferMin: number
203
+ originTransferMin: number
204
+ destTransferMin: number
205
+ /** 仅 v1 路径用;v2 路径无需。 */
206
+ tzOffsetMin?: number
207
+ }
208
+
209
+ export interface DoorToDoorParts {
210
+ /** 真实飞行时长(分);v2 = (arrUtcMs - depUtcMs)/60000,v1 = (arrMin - depMin) - tzOffsetMin */
211
+ trueFlightMin: number
212
+ /** 门到门(分) = buffer + originTransfer + trueFlight + destTransfer */
213
+ doorToDoorMin: number
214
+ }
215
+
216
+ /**
217
+ * 单段门到门时长(分)。v2 路径用真实 UTC instant(跨日 / 反向日界线 / DST 切换正确),
218
+ * 且强制 `arrUtcMs > depUtcMs` — parse 边界也会再做一次防御性 throw。
219
+ * v1 路径用墙上分钟差 - tzOffsetMin(原数值口径,字节不变,即使结果为负也照旧返回)。
220
+ *
221
+ * v2:arrUtcMs 必须严格大于 depUtcMs;否则抛 NonpositiveDurationError(arr_not_after_dep)。
222
+ * v1:沿用 legacy 公式,字节不变;不抛错。
223
+ *
224
+ * 调用方(unified.ts evaluateOptionMove)只负责拿到 doorToDoorMin 并包装 LegReport。
225
+ */
226
+ export function doorToDoorFromMove(svc: Service, mv: D2DMoveView): DoorToDoorParts {
227
+ if (typeof svc.depUtcMs === 'number' && typeof svc.arrUtcMs === 'number') {
228
+ if (svc.arrUtcMs <= svc.depUtcMs) {
229
+ throw new NonpositiveDurationError(
230
+ 'arr_not_after_dep',
231
+ `service ${svc.id}: arrUtcMs (${svc.arrUtcMs}) must be strictly greater than depUtcMs (${svc.depUtcMs})`,
232
+ )
233
+ }
234
+ const trueFlightMin = (svc.arrUtcMs - svc.depUtcMs) / 60_000
235
+ const doorToDoorMin = mv.bufferMin + mv.originTransferMin + trueFlightMin + mv.destTransferMin
236
+ return { trueFlightMin, doorToDoorMin }
237
+ }
238
+ // v1 legacy 字节不变:不校验正负,沿用原公式。
239
+ const trueFlightMin = (svc.arrMin - svc.depMin) - (mv.tzOffsetMin ?? 0)
240
+ const doorToDoorMin = mv.bufferMin + mv.originTransferMin + trueFlightMin + mv.destTransferMin
241
+ return { trueFlightMin, doorToDoorMin }
242
+ }
243
+
164
244
  export function evaluateChoice(cand: Candidate, req: TravelRequest, ch: Choice): TrueCost {
165
245
  /** 门到门全成本核算(与 Python evaluate_choice 完全一致)。 */
166
246
  const access = req.homeHubAccess[cand.hub]
247
+ const outTransferMin = typeof ch.outTransfer.minutesOut === 'number'
248
+ ? ch.outTransfer.minutesOut
249
+ : ch.outTransfer.minutes
250
+ const retTransferMin = typeof ch.retTransfer.minutesRet === 'number'
251
+ ? ch.retTransfer.minutesRet
252
+ : ch.retTransfer.minutes
167
253
  const wake = ch.outService.depMin - cand.bufferOutMin - access.toHubMin
168
- const arriveStay = ch.outService.arrMin + ch.outTransfer.minutes
254
+ const arriveStay = ch.outService.arrMin + outTransferMin
169
255
  const d2dOut = arriveStay - wake
170
256
 
171
257
  let energy = 100
@@ -179,7 +265,7 @@ export function evaluateChoice(cand: Candidate, req: TravelRequest, ch: Choice):
179
265
 
180
266
  const day1Raw = Math.max(0, DAY_END_MIN - arriveStay) / 60
181
267
  const day1 = day1Raw * (0.5 + energy / 200)
182
- const leaveStayRet = ch.retService.depMin - cand.bufferRetMin - ch.retTransfer.minutes
268
+ const leaveStayRet = ch.retService.depMin - cand.bufferRetMin - retTransferMin
183
269
  const day2 = Math.max(0, leaveStayRet - DAY2_START_MIN) / 60 * DAY2_QUALITY
184
270
  const midDays = Math.max(0, ch.days - 2)
185
271
  const usable = day1 + day2 + midDays * 8.0 * DAY2_QUALITY